Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72639bd6a3 | ||
|
|
31f0044c0f | ||
|
|
d0924f7e6d | ||
|
|
d2616acf36 | ||
|
|
dfd116bd56 | ||
|
|
830acef847 | ||
|
|
ee7c916494 | ||
|
|
46d477ea5b | ||
|
|
177b4af695 | ||
|
|
d2b8805605 | ||
|
|
39dedc68ec | ||
|
|
bd67756cda | ||
|
|
dd37fa4270 | ||
|
|
3c5c048152 | ||
|
|
dd6e4ef2d0 | ||
|
|
bce79f3488 | ||
|
|
74793a2399 | ||
|
|
6b120b053a | ||
|
|
1d0d62337e | ||
|
|
df8bfc42bb | ||
|
|
ff087738fc | ||
|
|
27567c88c9 | ||
|
|
9077a747d6 | ||
|
|
b359d9f525 | ||
|
|
ad7bd2e52d | ||
|
|
36aed9588b | ||
|
|
f8c81a29c4 | ||
|
|
c91771e152 | ||
|
|
3b7873302b | ||
|
|
d79bd54f0b | ||
|
|
51dc9a0e49 | ||
|
|
b49e55d400 | ||
|
|
33cb7e0a50 | ||
|
|
9edc90306e | ||
|
|
0a75e05b28 | ||
|
|
4a179ac3e3 | ||
|
|
42e32f9568 | ||
|
|
0223755b2a | ||
|
|
30a5f2a701 | ||
|
|
d2d351c9f7 | ||
|
|
e1696e9a4d | ||
|
|
25f015b179 | ||
|
|
9e263379a7 | ||
|
|
4c7ea161cd | ||
|
|
dc026c44a1 | ||
|
|
cb8b21c10c | ||
|
|
47c1beadcd | ||
|
|
8f7eb2abea | ||
|
|
c2b0f321de | ||
|
|
ba7b8b8824 | ||
|
|
9e612cf83d | ||
|
|
2154251c30 | ||
|
|
2d38266609 | ||
|
|
d414df4ca4 | ||
|
|
eab93cf6c5 | ||
|
|
3304def44a | ||
|
|
a4bf02b21b | ||
|
|
e0982da744 | ||
|
|
8abed1a809 | ||
|
|
a9a6518b23 | ||
|
|
1bafbe1ad2 | ||
|
|
a26074a3b8 | ||
|
|
90fd2f1447 | ||
|
|
e59690e703 | ||
|
|
bf06e9f654 | ||
|
|
84359d8e79 | ||
|
|
7801429530 | ||
|
|
dc9376a460 | ||
|
|
1f4076d8d4 | ||
|
|
f202d0f533 | ||
|
|
4344dcbb36 | ||
|
|
87d6bbae2a | ||
|
|
7a42bf456e | ||
|
|
a51f1eee8e | ||
|
|
5b72a916e4 | ||
|
|
42013a919f | ||
|
|
3918f82e14 | ||
|
|
6e79a2a133 | ||
|
|
088deaed3a | ||
|
|
7504e7710c | ||
|
|
bb7352ca54 | ||
|
|
b42ecbff36 | ||
|
|
5fd9f0216a | ||
|
|
942ea99459 | ||
|
|
990e2d6b9d | ||
|
|
f776ba0617 | ||
|
|
fb57965457 | ||
|
|
b99fce7d55 | ||
|
|
0a2d743ea2 | ||
|
|
fbd613084d | ||
|
|
4cd56f8ae5 | ||
|
|
c0d41c0a62 | ||
|
|
389c635030 | ||
|
|
d93a0c170c | ||
|
|
aa18f599ce | ||
|
|
aa7eb2e844 |
No files matched your search
+3
-2
@@ -13,9 +13,10 @@ project {
|
||||
"**/node_modules/**",
|
||||
"**/dist/**",
|
||||
"**/coverage/**",
|
||||
"ui/desktop/electron-app/out/**",
|
||||
"ui/desktop/electron-app/ember-dist/**",
|
||||
"**/pnpm-lock.yaml",
|
||||
"**/pnpm-workspace.yaml",
|
||||
"ui/desktop/electron-app/out/**",
|
||||
"ui/desktop/electron-app/ember-dist/**",
|
||||
"e2e-tests/api-client/**"
|
||||
]
|
||||
}
|
||||
@@ -12,12 +12,17 @@
|
||||
## Checklist
|
||||
<!-- strikethrough the checklist item that is not relevant to your change -->
|
||||
|
||||
<!-- If you are merging a long lived branch, major feature, or UI altering changes,
|
||||
please add the a11y-tests label. Other cases, like a dependency change or
|
||||
translation update likely does not need an a11y audit -->
|
||||
|
||||
- [ ] I have added before and after screenshots for UI changes
|
||||
- [ ] I have added JSON response output for API changes
|
||||
- [ ] I have added steps to reproduce and test for bug fixes in the description
|
||||
- [ ] I have commented on my code, particularly in hard-to-understand areas
|
||||
- [ ] My changes generate no new warnings
|
||||
- [ ] I have added tests that prove my fix is effective or that my feature works
|
||||
- [ ] I have added `a11y-tests` label to run a11y audit tests if needed
|
||||
|
||||
## PCI review checklist
|
||||
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
name: A11y Tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [labeled, opened, synchronize, reopened]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
|
||||
a11y-test:
|
||||
name: Run A11y Tests
|
||||
if: contains(github.event.pull_request.labels.*.name, 'a11y-tests')
|
||||
runs-on: ${{ fromJSON(vars.RUNNER) }}
|
||||
timeout-minutes: 40
|
||||
steps:
|
||||
- uses: browser-actions/setup-chrome@c785b87e244131f27c9f19c1a33e2ead956ab7ce # v1.7.3
|
||||
with:
|
||||
install-dependencies: true
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda #v4.1.0
|
||||
with:
|
||||
run_install: false
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: '**/pnpm-lock.yaml'
|
||||
- run: pnpm install
|
||||
- name: Run a11y tests
|
||||
env:
|
||||
# this will generate the test reports but causes tests to pass even when there a11y failures
|
||||
ENABLE_A11Y_MIDDLEWARE_REPORTER: true
|
||||
run: pnpm test-a11y
|
||||
- name: Consolidate ember a11y reports
|
||||
run: |
|
||||
mkdir ember-a11y-reports
|
||||
jq -s 'map(.[])' ./ui/admin/ember-a11y-report/*.json > ember-a11y-reports/admin.json
|
||||
jq -s 'map(.[])' ./ui/desktop/ember-a11y-report/*.json > ember-a11y-reports/desktop.json
|
||||
- name: Output ember a11y test report summary (admin)
|
||||
run: 'cat ember-a11y-reports/admin.json | jq "map({moduleName,testName,routes,urls,violations: .violations | map({id, nodes: .nodes | map({failureSummary, targets: .[].[]?.relatedNodes?.[]?.target? })}) })"'
|
||||
- name: Output ember a11y test report summary (desktop)
|
||||
run: 'cat ember-a11y-reports/desktop.json | jq "map({moduleName,testName,routes,urls,violations: .violations | map({id, nodes: .nodes | map({failureSummary, targets: .[].[]?.relatedNodes?.[]?.target? })}) })"'
|
||||
- name: Upload ember a11y reports
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: ember-a11y-reports
|
||||
path: ./ember-a11y-reports
|
||||
- name: Report a11y test results status
|
||||
if: always()
|
||||
# fail CI when the combined array length of admin and desktop a11y reports is not 0
|
||||
run: |
|
||||
EMBER_A11Y_FAILURE_COUNT=$(jq -s 'map(.[]) | length' ember-a11y-reports/admin.json ember-a11y-reports/desktop.json)
|
||||
echo "$EMBER_A11Y_FAILURE_COUNT ember-a11y test failures"
|
||||
if [ "$EMBER_A11Y_FAILURE_COUNT" != '0' ]; then
|
||||
exit 1
|
||||
fi
|
||||
@@ -7,6 +7,10 @@ jobs:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
# Ref and repository aren't normally needed, this is a workaround for a bug in auto commit
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.ref }}
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
- uses: hashicorp/setup-copywrite@32638da2d4e81d56a0764aa1547882fc4d209636 # v1.1.3
|
||||
- name: Add missing Copyright Headers
|
||||
run: copywrite headers
|
||||
|
||||
@@ -58,7 +58,7 @@ jobs:
|
||||
runs-on: ${{ fromJSON(vars.RUNNER) }}
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: browser-actions/setup-chrome@c785b87e244131f27c9f19c1a33e2ead956ab7ce # v1.7.3
|
||||
- uses: browser-actions/setup-chrome@b94431e051d1c52dcbe9a7092a4f10f827795416 # v2.1.0
|
||||
with:
|
||||
install-dependencies: true
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
@@ -9,7 +9,7 @@ jobs:
|
||||
test:
|
||||
if: ${{contains(github.event.pull_request.labels.*.name, 'desktop')}}
|
||||
name: Run Desktop E2E Tests
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: ${{ fromJSON(vars.RUNNER_UBUNTU_22) }}
|
||||
env:
|
||||
ENOS_VAR_boundary_edition: "ent"
|
||||
ENOS_VAR_e2e_debug_no_run: true
|
||||
|
||||
@@ -45,6 +45,7 @@ module.exports = {
|
||||
'./blueprints/*/index.js',
|
||||
'./config/**/*.js',
|
||||
'./tests/dummy/config/**/*.js',
|
||||
'./broccoli-plugins/**/*.js',
|
||||
],
|
||||
parserOptions: {
|
||||
sourceType: 'script',
|
||||
|
||||
@@ -56,32 +56,33 @@ export default function setupStubs(hooks) {
|
||||
stubTypes = types;
|
||||
|
||||
types.forEach((type, i) => {
|
||||
let models;
|
||||
let resourceName;
|
||||
if (typeOf(type) === 'object') {
|
||||
models = type.func();
|
||||
resourceName = type.resource;
|
||||
} else {
|
||||
models = this.server.schema[type].all().models;
|
||||
resourceName = type;
|
||||
}
|
||||
|
||||
this.ipcStub
|
||||
.withArgs('searchCacheDaemon')
|
||||
.onCall(i)
|
||||
.returns({
|
||||
[underscore(resourceNames[singularize(resourceName)])]: models.map(
|
||||
(model) => {
|
||||
// Use internal serializer to serialize the model correctly
|
||||
// according to our mirage serializers
|
||||
const modelData =
|
||||
this.server.serializerOrRegistry.serialize(model);
|
||||
.callsFake(() => {
|
||||
let models;
|
||||
let resourceName;
|
||||
if (typeOf(type) === 'object') {
|
||||
models = type.func();
|
||||
resourceName = type.resource;
|
||||
} else {
|
||||
models = this.server.schema[type].all().models;
|
||||
resourceName = type;
|
||||
}
|
||||
|
||||
// Serialize the data properly to standard JSON as that is what
|
||||
// we're expecting from the cache daemon response
|
||||
return JSON.parse(JSON.stringify(modelData));
|
||||
},
|
||||
),
|
||||
return {
|
||||
[underscore(resourceNames[singularize(resourceName)])]:
|
||||
models.map((model) => {
|
||||
// Use internal serializer to serialize the model correctly
|
||||
// according to our mirage serializers
|
||||
const modelData =
|
||||
this.server.serializerOrRegistry.serialize(model);
|
||||
|
||||
// Serialize the data properly to standard JSON as that is what
|
||||
// we're expecting from the cache daemon response
|
||||
return JSON.parse(JSON.stringify(modelData));
|
||||
}),
|
||||
};
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
+10
-8
@@ -3,15 +3,17 @@
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
/**
|
||||
* This test helper can help set up and cleanup the database in your tests.
|
||||
*
|
||||
* ```js
|
||||
* import { setupIndexedDb } from 'api/test-support/helpers/indexed-db';
|
||||
* import { setupSqlite } from 'api/test-support/helpers/sqlite';
|
||||
*
|
||||
* module('Acceptance | my test', function(hooks) {
|
||||
* setupApplicationTest(hooks);
|
||||
* setupIndexedDb(hooks);
|
||||
* setupSqlite(hooks);
|
||||
*
|
||||
* // add your actual tests here
|
||||
* });
|
||||
@@ -19,17 +21,17 @@
|
||||
*
|
||||
* @param hooks
|
||||
*/
|
||||
export function setupIndexedDb(hooks) {
|
||||
export function setupSqlite(hooks) {
|
||||
hooks.beforeEach(async function () {
|
||||
const indexedDb = this.owner.lookup('service:indexed-db');
|
||||
await indexedDb.setup(`test-indexed-db-${Date.now()}`);
|
||||
const sqliteDb = this.owner.lookup('service:sqlite');
|
||||
await sqliteDb.setup(`test-sqlite-${uuidv4()}`);
|
||||
});
|
||||
|
||||
hooks.afterEach(async function () {
|
||||
const indexedDb = this.owner.lookup('service:indexed-db');
|
||||
const sqliteDb = this.owner.lookup('service:sqlite');
|
||||
|
||||
await indexedDb.db.delete();
|
||||
await sqliteDb.deleteDatabase();
|
||||
});
|
||||
}
|
||||
|
||||
export default setupIndexedDb;
|
||||
export default setupSqlite;
|
||||
@@ -44,7 +44,8 @@ export default class TargetAbility extends ModelAbility {
|
||||
*/
|
||||
get canAddInjectedApplicationCredentialSources() {
|
||||
return (
|
||||
this.model.isSSH && this.hasAuthorizedAction('add-credential-sources')
|
||||
(this.model.isSSH || this.model.isRDP) &&
|
||||
this.hasAuthorizedAction('add-credential-sources')
|
||||
);
|
||||
}
|
||||
/**
|
||||
|
||||
@@ -76,13 +76,13 @@ export default class GeneratedCredentialLibraryModel extends BaseModel {
|
||||
path;
|
||||
|
||||
@attr('string', {
|
||||
for: 'vault-generic',
|
||||
for: ['vault-generic', 'vault-ldap'],
|
||||
description: 'It indicates the type of credential the library returns.',
|
||||
})
|
||||
credential_type;
|
||||
|
||||
@attr('object-as-array', {
|
||||
for: 'vault-generic',
|
||||
for: ['vault-generic', 'vault-ldap'],
|
||||
description: 'It indicates the credential mapping overrides.',
|
||||
})
|
||||
credential_mapping_overrides;
|
||||
|
||||
@@ -51,17 +51,17 @@ export default class GeneratedCredentialModel extends BaseModel {
|
||||
})
|
||||
version;
|
||||
|
||||
// =attributes (username_password, ssh_private_key)
|
||||
// =attributes (username_password, ssh_private_key, username_password_domain)
|
||||
@attr('string', {
|
||||
for: ['username_password', 'ssh_private_key'],
|
||||
for: ['username_password', 'ssh_private_key', 'username_password_domain'],
|
||||
isNestedAttribute: true,
|
||||
description: 'The username for credential.',
|
||||
})
|
||||
username;
|
||||
|
||||
// =attributes (username_password)
|
||||
// =attributes (username_password, username_password_domain)
|
||||
@attr('string', {
|
||||
for: 'username_password',
|
||||
for: ['username_password', 'username_password_domain'],
|
||||
isNestedAttribute: true,
|
||||
isSecret: true,
|
||||
description: 'The password for credential.',
|
||||
@@ -92,4 +92,12 @@ export default class GeneratedCredentialModel extends BaseModel {
|
||||
description: 'The secret associated with credential.',
|
||||
})
|
||||
json_object;
|
||||
|
||||
// =attributes (username_password_domain)
|
||||
@attr('string', {
|
||||
for: 'username_password_domain',
|
||||
isNestedAttribute: true,
|
||||
description: 'The domain for credential.',
|
||||
})
|
||||
domain;
|
||||
}
|
||||
@@ -1,242 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import { modelIndexes } from 'api/services/indexed-db';
|
||||
import { service } from '@ember/service';
|
||||
import { getOwner, setOwner } from '@ember/application';
|
||||
import { queryIndexedDb } from '../utils/indexed-db-query';
|
||||
import { paginateResults } from '../utils/paginate-results';
|
||||
import { sortResults } from '../utils/sort-results';
|
||||
import { hashCode } from '../utils/hash-code';
|
||||
|
||||
/**
|
||||
* Handler to sit in front of the API request layer
|
||||
* so we can handle any caching with indexedDb first
|
||||
*/
|
||||
export default class IndexedDbHandler {
|
||||
@service indexedDb;
|
||||
|
||||
batchLimit = 10_000;
|
||||
|
||||
constructor(context) {
|
||||
setOwner(this, getOwner(context));
|
||||
}
|
||||
|
||||
async request(context, next) {
|
||||
const { batchLimit } = this;
|
||||
|
||||
switch (context.request.op) {
|
||||
case 'query': {
|
||||
const { store, data } = context.request;
|
||||
// TODO: Remove storeToken option as this is a temporary fix for auth-methods.
|
||||
const {
|
||||
type,
|
||||
query,
|
||||
options: {
|
||||
pushToStore = true,
|
||||
peekIndexedDB = false,
|
||||
storeToken = true,
|
||||
} = {},
|
||||
} = data;
|
||||
const supportedModels = Object.keys(modelIndexes);
|
||||
const { db: indexedDb } = this.indexedDb ?? {};
|
||||
|
||||
// Go through normal flow if we don't yet support the model
|
||||
// or if we don't have an indexedDb instance
|
||||
if (!supportedModels.includes(type) || !indexedDb) {
|
||||
return next(context.request);
|
||||
}
|
||||
|
||||
let { page, pageSize, query: queryObj, ...remainingQuery } = query;
|
||||
|
||||
const adapter = store.adapterFor(type);
|
||||
const schema = store.modelFor(type);
|
||||
const serializer = store.serializerFor(type);
|
||||
|
||||
if (!peekIndexedDB) {
|
||||
const tokenKey = `${type}-${hashCode(remainingQuery)}`;
|
||||
let payload;
|
||||
let listToken;
|
||||
let writeToIndexedDbPromise;
|
||||
|
||||
if (storeToken) {
|
||||
const tokenObj = await indexedDb.token.get(tokenKey);
|
||||
listToken = tokenObj?.token;
|
||||
}
|
||||
|
||||
do {
|
||||
try {
|
||||
payload = await adapter.query(store, schema, {
|
||||
...remainingQuery,
|
||||
list_token: listToken,
|
||||
batchLimit: batchLimit,
|
||||
});
|
||||
|
||||
// await the previous writeToIndexedDbPromise before writing to indexedDb again
|
||||
if (writeToIndexedDbPromise) {
|
||||
await writeToIndexedDbPromise;
|
||||
}
|
||||
} catch (err) {
|
||||
payload = await this.retryQueryFailure({
|
||||
err,
|
||||
indexedDb,
|
||||
type,
|
||||
adapter,
|
||||
store,
|
||||
schema,
|
||||
remainingQuery,
|
||||
});
|
||||
}
|
||||
listToken = payload.list_token;
|
||||
|
||||
// Don't await immediately and let the next query run while we write to indexedDb
|
||||
writeToIndexedDbPromise = this.writeToIndexedDb({
|
||||
payload,
|
||||
storeToken,
|
||||
indexedDb,
|
||||
tokenKey,
|
||||
type,
|
||||
serializer,
|
||||
store,
|
||||
schema,
|
||||
});
|
||||
} while (payload.response_type === 'delta');
|
||||
|
||||
await writeToIndexedDbPromise;
|
||||
}
|
||||
|
||||
// Results returned in JSON API reponse, but in a format optimized for indexedDb
|
||||
const indexedDbResults = await queryIndexedDb(
|
||||
indexedDb,
|
||||
type,
|
||||
queryObj,
|
||||
);
|
||||
|
||||
// Normalize the results and clean up indexedDb format, ready for ember data consumption
|
||||
const normalizedResults = indexedDbResults.map((item) => {
|
||||
return this.indexedDb.normalizeData({
|
||||
data: item,
|
||||
cleanData: false,
|
||||
schema,
|
||||
serializer,
|
||||
});
|
||||
});
|
||||
|
||||
// Sort the results
|
||||
const sortedResults = sortResults(normalizedResults, {
|
||||
querySort: queryObj?.sort,
|
||||
schema,
|
||||
});
|
||||
|
||||
// Paginate the results
|
||||
const paginatedResults = paginateResults(sortedResults, page, pageSize);
|
||||
|
||||
// If we are not pushing to the store, use the raw data with id property
|
||||
const records = pushToStore
|
||||
? store.push({ data: paginatedResults })
|
||||
: paginatedResults.map((record) => ({
|
||||
...record.attributes,
|
||||
id: record.id,
|
||||
}));
|
||||
|
||||
// Set a meta property on the array to store the total items of the results.
|
||||
// This isn't conventional but is better than returning an ArrayProxy
|
||||
// or EmberArray since the ember store query method asserts it has to be an array
|
||||
// so we can't just return an object.
|
||||
records.meta = { totalItems: indexedDbResults.length };
|
||||
return records;
|
||||
}
|
||||
default:
|
||||
return next(context.request);
|
||||
}
|
||||
}
|
||||
|
||||
async retryQueryFailure({
|
||||
err,
|
||||
indexedDb,
|
||||
type,
|
||||
adapter,
|
||||
store,
|
||||
schema,
|
||||
remainingQuery,
|
||||
}) {
|
||||
// If we get an invalid list token, we'll delete the token and
|
||||
// clear the DB and try again without a token.
|
||||
if (
|
||||
err?.errors[0].status === 400 &&
|
||||
err?.errors[0].code === 'invalid list token'
|
||||
) {
|
||||
await indexedDb[type].clear();
|
||||
// Delete all tokens of the same resource type so we don't keep clearing the DB.
|
||||
// Even if other tokens may still be valid, we might have cleared data that they
|
||||
// depended on so we should clear all tokens of the same type when clearing the DB.
|
||||
const tokenKeys = await indexedDb.token
|
||||
.where(':id')
|
||||
.startsWith(type)
|
||||
.primaryKeys();
|
||||
|
||||
await indexedDb.token.bulkDelete(tokenKeys);
|
||||
|
||||
return await adapter.query(store, schema, {
|
||||
...remainingQuery,
|
||||
});
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async writeToIndexedDb({
|
||||
payload,
|
||||
storeToken,
|
||||
indexedDb,
|
||||
tokenKey,
|
||||
type,
|
||||
serializer,
|
||||
store,
|
||||
schema,
|
||||
}) {
|
||||
// Store the token we just got back from the payload if it exists
|
||||
if (payload.list_token && storeToken) {
|
||||
await indexedDb.token.put({
|
||||
id: tokenKey,
|
||||
token: payload.list_token,
|
||||
});
|
||||
}
|
||||
|
||||
// Remove any records from the DB if the API indicates they've been deleted
|
||||
if (payload.removed_ids?.length > 0) {
|
||||
await indexedDb[type].bulkDelete(payload.removed_ids);
|
||||
}
|
||||
|
||||
// This is a temporary fix of clearing the DB (specifically for auth-methods)
|
||||
// since we are not storing the token we do not get back a list of removed_ids
|
||||
// from the API call and we do not want deleted items from showing in list view.
|
||||
if (!storeToken) {
|
||||
await indexedDb[type].clear();
|
||||
}
|
||||
|
||||
const normalizedPayload = serializer.normalizeResponse(
|
||||
store,
|
||||
schema,
|
||||
payload,
|
||||
null,
|
||||
'query',
|
||||
);
|
||||
|
||||
const { data: payloadData } = normalizedPayload;
|
||||
|
||||
// Store the new data we just got back from the API refresh
|
||||
const items = payloadData.map((datum) =>
|
||||
this.indexedDb.normalizeData({
|
||||
data: datum,
|
||||
cleanData: true,
|
||||
schema,
|
||||
serializer,
|
||||
}),
|
||||
);
|
||||
|
||||
await indexedDb[type].bulkPut(items);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import { getOwner, setOwner } from '@ember/owner';
|
||||
import { service } from '@ember/service';
|
||||
import { modelMapping } from 'api/services/sqlite';
|
||||
import { get } from '@ember/object';
|
||||
import { typeOf } from '@ember/utils';
|
||||
import { hashCode } from '../utils/hash-code';
|
||||
import { generateSQLExpressions } from '../utils/sqlite-query';
|
||||
|
||||
const isISODateString = (str) =>
|
||||
typeOf(str) === 'string' &&
|
||||
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z?$/.test(str);
|
||||
|
||||
export default class SqliteHandler {
|
||||
@service sqlite;
|
||||
|
||||
batchLimit = 5_000;
|
||||
|
||||
constructor(context) {
|
||||
setOwner(this, getOwner(context));
|
||||
}
|
||||
|
||||
async request(context, next) {
|
||||
switch (context.request.op) {
|
||||
case 'query': {
|
||||
const { store, data } = context.request;
|
||||
const {
|
||||
type,
|
||||
query,
|
||||
options: {
|
||||
pushToStore = true,
|
||||
peekDb = false,
|
||||
storeToken = true,
|
||||
} = {},
|
||||
} = data;
|
||||
const supportedModels = Object.keys(modelMapping);
|
||||
|
||||
// Go through normal flow if we don't yet support the model
|
||||
// or if we don't have a sqlite db instance
|
||||
if (!supportedModels.includes(type) || !this.sqlite.worker) {
|
||||
return next(context.request);
|
||||
}
|
||||
|
||||
const adapter = store.adapterFor(type);
|
||||
const schema = store.modelFor(type);
|
||||
const serializer = store.serializerFor(type);
|
||||
|
||||
let { page, pageSize, query: queryObj, ...remainingQuery } = query;
|
||||
let payload,
|
||||
listToken,
|
||||
writeToDbPromise,
|
||||
totalInsert = 0;
|
||||
|
||||
if (!peekDb) {
|
||||
const tokenKey = `${type}-${hashCode(remainingQuery)}`;
|
||||
if (storeToken) {
|
||||
const [tokenObj] = await this.sqlite.fetchResource({
|
||||
...generateSQLExpressions('token', {
|
||||
filters: { id: [{ equals: tokenKey }] },
|
||||
}),
|
||||
});
|
||||
listToken = tokenObj?.token;
|
||||
} else {
|
||||
// This is a temporary fix of clearing the DB (specifically for auth-methods)
|
||||
// since we are not storing the token we do not get back a list of removed_ids
|
||||
// from the API call and we do not want deleted items from showing in list view.
|
||||
await this.sqlite.deleteResource(type);
|
||||
}
|
||||
|
||||
do {
|
||||
try {
|
||||
payload = await adapter.query(store, schema, {
|
||||
...remainingQuery,
|
||||
list_token: listToken,
|
||||
batchLimit: this.batchLimit,
|
||||
});
|
||||
totalInsert += payload.items?.length ?? 0;
|
||||
|
||||
// await the previous writeToDbPromise before writing to db again
|
||||
if (writeToDbPromise) {
|
||||
await writeToDbPromise;
|
||||
}
|
||||
} catch (err) {
|
||||
payload = await this.retryQueryFailure({
|
||||
err,
|
||||
type,
|
||||
adapter,
|
||||
store,
|
||||
schema,
|
||||
remainingQuery,
|
||||
});
|
||||
totalInsert += payload.items?.length ?? 0;
|
||||
}
|
||||
listToken = payload.list_token;
|
||||
|
||||
writeToDbPromise = this.writeToDb(
|
||||
payload,
|
||||
storeToken,
|
||||
tokenKey,
|
||||
serializer,
|
||||
store,
|
||||
schema,
|
||||
type,
|
||||
);
|
||||
} while (payload.response_type === 'delta');
|
||||
|
||||
await writeToDbPromise;
|
||||
}
|
||||
|
||||
if (totalInsert > 0) {
|
||||
// If there were any inserts, let sqlite handle running analyze on the DB
|
||||
await this.sqlite.analyzeDatabase();
|
||||
}
|
||||
|
||||
const { sql, parameters } = generateSQLExpressions(type, queryObj, {
|
||||
page,
|
||||
pageSize,
|
||||
select: ['data'],
|
||||
});
|
||||
|
||||
const rows = await this.sqlite.fetchResource({
|
||||
sql,
|
||||
parameters,
|
||||
});
|
||||
|
||||
const { sql: countSql, parameters: countParams } =
|
||||
generateSQLExpressions(type, queryObj, {
|
||||
select: ['count(*) as total'],
|
||||
});
|
||||
const count = await this.sqlite.fetchResource({
|
||||
sql: countSql,
|
||||
parameters: countParams,
|
||||
});
|
||||
|
||||
const results = rows.map((item) =>
|
||||
JSON.parse(item.data, (key, value) =>
|
||||
// Reviver function to convert any ISO strings back to date objects
|
||||
// as we had to initially convert them to strings when stored in SQLite
|
||||
isISODateString(value) ? new Date(value) : value,
|
||||
),
|
||||
);
|
||||
|
||||
// If we are not pushing to the store, use the raw data with id property
|
||||
const records = pushToStore
|
||||
? store.push({ data: results })
|
||||
: results.map((record) => ({
|
||||
...record.attributes,
|
||||
id: record.id,
|
||||
}));
|
||||
|
||||
// Set a meta property on the array to store the total items of the results.
|
||||
// This isn't conventional but is better than returning an ArrayProxy
|
||||
// or EmberArray since the ember store query method asserts it has to be an array
|
||||
// so we can't just return an object.
|
||||
records.meta = { totalItems: count[0].total };
|
||||
return records;
|
||||
}
|
||||
default:
|
||||
return next(context.request);
|
||||
}
|
||||
}
|
||||
|
||||
async retryQueryFailure({
|
||||
err,
|
||||
type,
|
||||
adapter,
|
||||
store,
|
||||
schema,
|
||||
remainingQuery,
|
||||
}) {
|
||||
// If we get an invalid list token, we'll delete the token and
|
||||
// clear the DB and try again without a token.
|
||||
if (
|
||||
err?.errors?.[0].status === 400 &&
|
||||
err?.errors?.[0].code === 'invalid list token'
|
||||
) {
|
||||
await this.sqlite.deleteResource(type);
|
||||
// Delete all tokens of the same resource type so we don't keep clearing the DB.
|
||||
// Even if other tokens may still be valid, we might have cleared data that they
|
||||
// depended on so we should clear all tokens of the same type when clearing the DB.
|
||||
const { sql, parameters } = generateSQLExpressions('token', {
|
||||
filters: { id: [{ contains: type }] },
|
||||
});
|
||||
const tokenRows = await this.sqlite.fetchResource({
|
||||
sql,
|
||||
parameters,
|
||||
});
|
||||
const tokenIds = tokenRows.map((row) => row.id);
|
||||
|
||||
if (tokenIds.length > 0) {
|
||||
await this.sqlite.deleteResource('token', tokenIds);
|
||||
}
|
||||
|
||||
return await adapter.query(store, schema, {
|
||||
...remainingQuery,
|
||||
});
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async writeToDb(
|
||||
payload,
|
||||
storeToken,
|
||||
tokenKey,
|
||||
serializer,
|
||||
store,
|
||||
schema,
|
||||
type,
|
||||
) {
|
||||
if (payload.list_token && storeToken) {
|
||||
await this.sqlite.insertResource('token', [
|
||||
[tokenKey, payload.list_token],
|
||||
]);
|
||||
}
|
||||
|
||||
// Remove any records from the DB if the API indicates they've been deleted
|
||||
if (payload.removed_ids?.length > 0) {
|
||||
await this.sqlite.deleteResource(type, payload.removed_ids);
|
||||
}
|
||||
|
||||
const normalizedPayload = serializer.normalizeResponse(
|
||||
store,
|
||||
schema,
|
||||
payload,
|
||||
null,
|
||||
'query',
|
||||
);
|
||||
|
||||
const { data: payloadData } = normalizedPayload;
|
||||
|
||||
// Store the new data we just got back from the API refresh
|
||||
const items = payloadData.map((datum) => {
|
||||
const params = Object.values(modelMapping[type]).map((key) => {
|
||||
let value;
|
||||
|
||||
if (key === 'id') {
|
||||
value = datum.id;
|
||||
} else {
|
||||
value = get(datum, `attributes.${key}`);
|
||||
}
|
||||
|
||||
if (typeOf(value) === 'date') {
|
||||
return value.toISOString();
|
||||
}
|
||||
return value;
|
||||
});
|
||||
|
||||
return [...params, JSON.stringify(datum)];
|
||||
});
|
||||
|
||||
await this.sqlite.insertResource(type, items);
|
||||
}
|
||||
}
|
||||
@@ -4,14 +4,22 @@
|
||||
*/
|
||||
|
||||
import GeneratedCredentialLibraryModel from '../generated/models/credential-library';
|
||||
|
||||
import {
|
||||
TYPE_CREDENTIAL_SSH_PRIVATE_KEY,
|
||||
TYPE_CREDENTIAL_USERNAME_PASSWORD_DOMAIN,
|
||||
TYPE_CREDENTIAL_USERNAME_PASSWORD,
|
||||
} from 'api/models/credential';
|
||||
/**
|
||||
* Enum options for credential library.
|
||||
*/
|
||||
export const options = {
|
||||
http_method: ['GET', 'POST'],
|
||||
key_type: ['ed25519', 'ecdsa', 'rsa'],
|
||||
credential_types: ['ssh_private_key', 'username_password'],
|
||||
credential_types: [
|
||||
TYPE_CREDENTIAL_SSH_PRIVATE_KEY,
|
||||
TYPE_CREDENTIAL_USERNAME_PASSWORD,
|
||||
TYPE_CREDENTIAL_USERNAME_PASSWORD_DOMAIN,
|
||||
],
|
||||
mapping_overrides: {
|
||||
username_password: ['username_attribute', 'password_attribute'],
|
||||
ssh_private_key: [
|
||||
@@ -19,12 +27,18 @@ export const options = {
|
||||
'private_key_passphrase_attribute',
|
||||
'username_attribute',
|
||||
],
|
||||
username_password_domain: [
|
||||
'username_attribute',
|
||||
'password_attribute',
|
||||
'domain_attribute',
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const TYPE_CREDENTIAL_LIBRARY_VAULT_GENERIC = 'vault-generic';
|
||||
export const TYPE_CREDENTIAL_LIBRARY_VAULT_SSH_CERTIFICATE =
|
||||
'vault-ssh-certificate';
|
||||
export const TYPE_CREDENTIAL_LIBRARY_VAULT_LDAP = 'vault-ldap';
|
||||
|
||||
/**
|
||||
* Supported Credential Library types.
|
||||
@@ -32,6 +46,7 @@ export const TYPE_CREDENTIAL_LIBRARY_VAULT_SSH_CERTIFICATE =
|
||||
export const TYPES_CREDENTIAL_LIBRARY = Object.freeze([
|
||||
TYPE_CREDENTIAL_LIBRARY_VAULT_GENERIC,
|
||||
TYPE_CREDENTIAL_LIBRARY_VAULT_SSH_CERTIFICATE,
|
||||
TYPE_CREDENTIAL_LIBRARY_VAULT_LDAP,
|
||||
]);
|
||||
|
||||
export default class CredentialLibraryModel extends GeneratedCredentialLibraryModel {
|
||||
@@ -52,7 +67,8 @@ export default class CredentialLibraryModel extends GeneratedCredentialLibraryMo
|
||||
get isVault() {
|
||||
return (
|
||||
this.type === TYPE_CREDENTIAL_LIBRARY_VAULT_GENERIC ||
|
||||
this.type === TYPE_CREDENTIAL_LIBRARY_VAULT_SSH_CERTIFICATE
|
||||
this.type === TYPE_CREDENTIAL_LIBRARY_VAULT_SSH_CERTIFICATE ||
|
||||
this.type === TYPE_CREDENTIAL_LIBRARY_VAULT_LDAP
|
||||
);
|
||||
}
|
||||
|
||||
@@ -71,4 +87,12 @@ export default class CredentialLibraryModel extends GeneratedCredentialLibraryMo
|
||||
get isVaultSSHCertificate() {
|
||||
return this.type === TYPE_CREDENTIAL_LIBRARY_VAULT_SSH_CERTIFICATE;
|
||||
}
|
||||
|
||||
/**
|
||||
* True if credential is a vault ldap type.
|
||||
* @type {boolean}
|
||||
*/
|
||||
get isVaultLDAP() {
|
||||
return this.type === TYPE_CREDENTIAL_LIBRARY_VAULT_LDAP;
|
||||
}
|
||||
}
|
||||
@@ -12,10 +12,12 @@ import GeneratedCredentialModel from '../generated/models/credential';
|
||||
export const TYPE_CREDENTIAL_USERNAME_PASSWORD = 'username_password';
|
||||
export const TYPE_CREDENTIAL_SSH_PRIVATE_KEY = 'ssh_private_key';
|
||||
export const TYPE_CREDENTIAL_JSON = 'json';
|
||||
export const TYPE_CREDENTIAL_USERNAME_PASSWORD_DOMAIN = 'username_password_domain';
|
||||
|
||||
export const TYPES_CREDENTIAL = Object.freeze([
|
||||
TYPE_CREDENTIAL_USERNAME_PASSWORD,
|
||||
TYPE_CREDENTIAL_SSH_PRIVATE_KEY,
|
||||
TYPE_CREDENTIAL_USERNAME_PASSWORD_DOMAIN,
|
||||
TYPE_CREDENTIAL_JSON,
|
||||
]);
|
||||
|
||||
|
||||
@@ -9,7 +9,13 @@ import { service } from '@ember/service';
|
||||
|
||||
export const TYPE_TARGET_TCP = 'tcp';
|
||||
export const TYPE_TARGET_SSH = 'ssh';
|
||||
export const TYPES_TARGET = Object.freeze([TYPE_TARGET_TCP, TYPE_TARGET_SSH]);
|
||||
export const TYPE_TARGET_RDP = 'rdp';
|
||||
|
||||
export const TYPES_TARGET = Object.freeze([
|
||||
TYPE_TARGET_TCP,
|
||||
TYPE_TARGET_SSH,
|
||||
TYPE_TARGET_RDP,
|
||||
]);
|
||||
|
||||
export default class TargetModel extends GeneratedTargetModel {
|
||||
// =services
|
||||
@@ -347,4 +353,12 @@ export default class TargetModel extends GeneratedTargetModel {
|
||||
get isSSH() {
|
||||
return this.type === TYPE_TARGET_SSH;
|
||||
}
|
||||
|
||||
/**
|
||||
* True if the target type is rdp.
|
||||
* @type {boolean}
|
||||
*/
|
||||
get isRDP() {
|
||||
return this.type === TYPE_TARGET_RDP;
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import ApplicationSerializer from './application';
|
||||
import {
|
||||
TYPE_CREDENTIAL_LIBRARY_VAULT_GENERIC,
|
||||
TYPE_CREDENTIAL_LIBRARY_VAULT_SSH_CERTIFICATE,
|
||||
TYPE_CREDENTIAL_LIBRARY_VAULT_LDAP,
|
||||
options,
|
||||
} from '../models/credential-library';
|
||||
|
||||
@@ -29,6 +30,8 @@ export default class CredentialLibrarySerializer extends ApplicationSerializer {
|
||||
return this.serializeVaultGeneric(...arguments);
|
||||
case TYPE_CREDENTIAL_LIBRARY_VAULT_SSH_CERTIFICATE:
|
||||
return this.serializeVaultSshCertificate(...arguments);
|
||||
case TYPE_CREDENTIAL_LIBRARY_VAULT_LDAP:
|
||||
return this.serializeVaultLdap(...arguments);
|
||||
default:
|
||||
return super.serialize(...arguments);
|
||||
}
|
||||
@@ -44,21 +47,7 @@ export default class CredentialLibrarySerializer extends ApplicationSerializer {
|
||||
if (!serialized.attributes?.http_method?.match(/post/i)) {
|
||||
serialized.attributes.http_request_body = null;
|
||||
}
|
||||
const { credential_type, credential_mapping_overrides } = serialized;
|
||||
|
||||
// API expects to send null to fields if it is undefined or deleted
|
||||
if (credential_mapping_overrides && !isNew) {
|
||||
serialized.credential_mapping_overrides = options.mapping_overrides[
|
||||
credential_type
|
||||
].reduce((obj, key) => {
|
||||
if (credential_mapping_overrides[key]) {
|
||||
obj[key] = credential_mapping_overrides[key];
|
||||
} else {
|
||||
obj[key] = null;
|
||||
}
|
||||
return obj;
|
||||
}, {});
|
||||
}
|
||||
this.handleCredentialMappingOverrides(serialized, isNew);
|
||||
return serialized;
|
||||
}
|
||||
}
|
||||
@@ -74,4 +63,36 @@ export default class CredentialLibrarySerializer extends ApplicationSerializer {
|
||||
return serialized;
|
||||
}
|
||||
}
|
||||
|
||||
serializeVaultLdap(snapshot) {
|
||||
const { isNew } = snapshot;
|
||||
const serialized = super.serialize(...arguments);
|
||||
if (serialized.attributes) {
|
||||
this.handleCredentialMappingOverrides(serialized, isNew);
|
||||
return serialized;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle credential mapping overrides for serialization.
|
||||
* @param {Object} serialized - The serialized object.
|
||||
* @param {boolean} isNew - Indicates if the record is new.
|
||||
*/
|
||||
handleCredentialMappingOverrides(serialized, isNew) {
|
||||
const { credential_type, credential_mapping_overrides } = serialized;
|
||||
|
||||
// API expects to send null to fields if it is undefined or deleted
|
||||
if (credential_mapping_overrides && !isNew) {
|
||||
serialized.credential_mapping_overrides = options.mapping_overrides[
|
||||
credential_type
|
||||
].reduce((obj, key) => {
|
||||
if (credential_mapping_overrides[key]) {
|
||||
obj[key] = credential_mapping_overrides[key];
|
||||
} else {
|
||||
obj[key] = null;
|
||||
}
|
||||
return obj;
|
||||
}, {});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,170 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import Service from '@ember/service';
|
||||
import Dexie from 'dexie';
|
||||
import { typeOf } from '@ember/utils';
|
||||
|
||||
// List of resources we support to be cached and searched in
|
||||
// indexedDb. Any field that can be searched upon should be added to
|
||||
// this index. Increment the version number of the database whenever
|
||||
// indexes change.
|
||||
export const modelIndexes = {
|
||||
token: '&id, token',
|
||||
target:
|
||||
'&id, attributes.created_time, attributes.type, attributes.name, attributes.description, attributes.address, attributes.scope.scope_id',
|
||||
session:
|
||||
'&id, attributes.created_time, attributes.type, attributes.status, attributes.endpoint, attributes.target_id, attributes.user_id, attributes.scope.scope_id',
|
||||
user: '&id, attributes.created_time, attributes.name, attributes.description, attributes.scope.scope_id',
|
||||
group:
|
||||
'&id, attributes.created_time, attributes.name, attributes.description, attributes.scope.scope_id',
|
||||
role: '&id, attributes.created_time, attributes.name, attributes.description, attributes.scope.scope_id',
|
||||
scope:
|
||||
'&id, attributes.created_time, attributes.type, attributes.name, attributes.description, attributes.scope.scope_id',
|
||||
'credential-store':
|
||||
'&id, attributes.created_time, attributes.name, attributes.description, attributes.type, attributes.scope.scope_id',
|
||||
'host-catalog':
|
||||
'&id, attributes.created_time, attributes.type, attributes.name, attributes.description, attributes.scope.scope_id, attributes.plugin.name',
|
||||
'auth-method':
|
||||
'&id, attributes.created_time, attributes.type, attributes.name, attributes.description, attributes.is_primary, attributes.scope.scope_id',
|
||||
'session-recording':
|
||||
'&id, attributes.created_time, attributes.type, attributes.state, attributes.start_time, attributes.end_time, attributes.duration, attributes.scope.scope_id, attributes.create_time_values.user.id, attributes.create_time_values.user.name, attributes.create_time_values.target.id, attributes.create_time_values.target.name, attributes.create_time_values.target.scope.id, attributes.create_time_values.target.scope.name',
|
||||
alias:
|
||||
'&id, attributes.created_time, attributes.type, attributes.value, attributes.name, attributes.description, attributes.destination_id, attributes.scope.scope_id',
|
||||
};
|
||||
|
||||
export const formatDbName = (userId, clusterUrl) =>
|
||||
`boundary-${userId}-${clusterUrl}`;
|
||||
|
||||
/**
|
||||
* Recursively copy over the object and convert any booleans to "true" or "false" so they can be indexed.
|
||||
* Also converts any ember-like arrays back to a standard array.
|
||||
* @param result
|
||||
* @param object
|
||||
* @returns {*}
|
||||
*/
|
||||
const convertFields = (result, object) => {
|
||||
Object.entries(object).forEach(([key, value]) => {
|
||||
if (typeOf(value) === 'object') {
|
||||
result[key] = convertFields({}, value);
|
||||
} else if (typeOf(value) === 'boolean') {
|
||||
result[key] = value ? 'true' : 'false';
|
||||
} else if (typeOf(value) === 'array') {
|
||||
result[key] = [...value];
|
||||
} else {
|
||||
result[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Recursively copy over the object and convert any string "true" or "false" back to booleans.
|
||||
* Will also convert any array fields back using their model transformation.
|
||||
* @param result
|
||||
* @param object
|
||||
* @param schema
|
||||
* @param serializer
|
||||
* @returns {*}
|
||||
*/
|
||||
const unconvertFields = (result, object, schema, serializer) => {
|
||||
Object.entries(object).forEach(([key, value]) => {
|
||||
if (typeOf(value) === 'object') {
|
||||
result[key] = unconvertFields({}, value, schema, serializer);
|
||||
} else if (value === 'true' || value === 'false') {
|
||||
result[key] = value === 'true';
|
||||
} else if (typeOf(value) === 'array') {
|
||||
const attribute = schema.attributes.get(key);
|
||||
|
||||
// If attribute type is array, we transform it back to a tracked array
|
||||
if (attribute?.type === 'array') {
|
||||
const transformer = serializer.transformFor(attribute.type);
|
||||
result[key] = transformer.deserialize(value, attribute.options);
|
||||
return;
|
||||
}
|
||||
|
||||
result[key] = value;
|
||||
} else {
|
||||
result[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Service to encapsulate the IndexedDB implementation. To use this service, call
|
||||
* `setup` from the application root. This will create the database and the necessary indexes.
|
||||
*
|
||||
* Make sure to increment the version number of the database whenever the indexes change.
|
||||
* This will delete the old database and create a new one with the new indexes.
|
||||
*/
|
||||
export default class IndexedDbService extends Service {
|
||||
// =attributes
|
||||
#db;
|
||||
#version = 2;
|
||||
|
||||
get db() {
|
||||
return this.#db;
|
||||
}
|
||||
|
||||
async setup(dbName) {
|
||||
// Don't run setup again if we already have one or if we didn't get a name
|
||||
if (this.#db || !dbName) {
|
||||
return;
|
||||
}
|
||||
|
||||
const doesDbExist = await Dexie.exists(dbName);
|
||||
|
||||
// If the database already exists, open the database and
|
||||
// check if the version is a lower version than the current one
|
||||
if (doesDbExist) {
|
||||
const dbVerifier = new Dexie(dbName);
|
||||
await dbVerifier.open();
|
||||
if (dbVerifier.verno < this.#version) {
|
||||
await dbVerifier.delete();
|
||||
} else {
|
||||
dbVerifier.close();
|
||||
}
|
||||
}
|
||||
|
||||
this.#db = new Dexie(dbName);
|
||||
this.#db.version(this.#version).stores(modelIndexes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup the data by converting all fields to plain javascript objects.
|
||||
* We will also convert booleans to "false" or "true".
|
||||
* IndexedDB doesn't support indexes on boolean values so we have to
|
||||
* convert them to be able to support using boolean values.
|
||||
*
|
||||
* Undo the operation by setting `convertFields` to false.
|
||||
*
|
||||
* @param {Object} data
|
||||
* @param {Boolean} cleanData
|
||||
* @param {Object} schema
|
||||
* @param {Object} serializer
|
||||
* @return {{id, type, attributes: {}, relationships: {}}}
|
||||
*/
|
||||
normalizeData({ data, cleanData, schema, serializer }) {
|
||||
if (!data) {
|
||||
return data;
|
||||
}
|
||||
|
||||
let { id, type, attributes = {}, relationships = {} } = data;
|
||||
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
attributes: cleanData
|
||||
? convertFields({}, attributes)
|
||||
: unconvertFields({}, attributes, schema, serializer),
|
||||
relationships: cleanData
|
||||
? convertFields({}, relationships)
|
||||
: unconvertFields({}, relationships, schema, serializer),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import Service from '@ember/service';
|
||||
import { PWBHost } from 'promise-worker-bi';
|
||||
|
||||
export const formatDbName = (userId, clusterUrl) =>
|
||||
`boundary-${userId}-${clusterUrl}`;
|
||||
|
||||
// A mapping of columns to their expected model attributes for supported models
|
||||
// The JSON data column is not included here but is assumed to always be last.
|
||||
export const modelMapping = {
|
||||
target: {
|
||||
id: 'id',
|
||||
type: 'type',
|
||||
name: 'name',
|
||||
description: 'description',
|
||||
address: 'address',
|
||||
scope_id: 'scope.scope_id',
|
||||
created_time: 'created_time',
|
||||
},
|
||||
alias: {
|
||||
id: 'id',
|
||||
type: 'type',
|
||||
name: 'name',
|
||||
description: 'description',
|
||||
destination_id: 'destination_id',
|
||||
value: 'value',
|
||||
scope_id: 'scope.scope_id',
|
||||
created_time: 'created_time',
|
||||
},
|
||||
group: {
|
||||
id: 'id',
|
||||
name: 'name',
|
||||
description: 'description',
|
||||
scope_id: 'scope.scope_id',
|
||||
created_time: 'created_time',
|
||||
},
|
||||
role: {
|
||||
id: 'id',
|
||||
name: 'name',
|
||||
description: 'description',
|
||||
scope_id: 'scope.scope_id',
|
||||
created_time: 'created_time',
|
||||
},
|
||||
user: {
|
||||
id: 'id',
|
||||
name: 'name',
|
||||
description: 'description',
|
||||
scope_id: 'scope.scope_id',
|
||||
created_time: 'created_time',
|
||||
},
|
||||
'credential-store': {
|
||||
id: 'id',
|
||||
type: 'type',
|
||||
name: 'name',
|
||||
description: 'description',
|
||||
scope_id: 'scope.scope_id',
|
||||
created_time: 'created_time',
|
||||
},
|
||||
scope: {
|
||||
id: 'id',
|
||||
type: 'type',
|
||||
name: 'name',
|
||||
description: 'description',
|
||||
scope_id: 'scope.scope_id',
|
||||
created_time: 'created_time',
|
||||
},
|
||||
'auth-method': {
|
||||
id: 'id',
|
||||
type: 'type',
|
||||
name: 'name',
|
||||
description: 'description',
|
||||
is_primary: 'is_primary',
|
||||
scope_id: 'scope.scope_id',
|
||||
created_time: 'created_time',
|
||||
},
|
||||
'host-catalog': {
|
||||
id: 'id',
|
||||
type: 'type',
|
||||
name: 'name',
|
||||
description: 'description',
|
||||
plugin_name: 'plugin.name',
|
||||
scope_id: 'scope.scope_id',
|
||||
created_time: 'created_time',
|
||||
},
|
||||
'session-recording': {
|
||||
id: 'id',
|
||||
type: 'type',
|
||||
state: 'state',
|
||||
start_time: 'start_time',
|
||||
end_time: 'end_time',
|
||||
duration: 'duration',
|
||||
scope_id: 'scope.scope_id',
|
||||
user_id: 'create_time_values.user.id',
|
||||
user_name: 'create_time_values.user.name',
|
||||
target_id: 'create_time_values.target.id',
|
||||
target_name: 'create_time_values.target.name',
|
||||
target_scope_id: 'create_time_values.target.scope.id',
|
||||
target_scope_name: 'create_time_values.target.scope.name',
|
||||
created_time: 'created_time',
|
||||
},
|
||||
session: {
|
||||
id: 'id',
|
||||
type: 'type',
|
||||
status: 'status',
|
||||
endpoint: 'endpoint',
|
||||
target_id: 'target_id',
|
||||
user_id: 'user_id',
|
||||
scope_id: 'scope.scope_id',
|
||||
created_time: 'created_time',
|
||||
},
|
||||
};
|
||||
|
||||
// A list of tables that we support searching using FTS5 in SQLite.
|
||||
export const searchTables = new Set([
|
||||
'target',
|
||||
'alias',
|
||||
'group',
|
||||
'role',
|
||||
'user',
|
||||
'credential-store',
|
||||
'scope',
|
||||
'auth-method',
|
||||
'host-catalog',
|
||||
'session-recording',
|
||||
'session',
|
||||
]);
|
||||
|
||||
export default class SqliteDbService extends Service {
|
||||
// =attributes
|
||||
|
||||
// We need a handle on the original web worker to properly clean it up.
|
||||
webWorker;
|
||||
// This will be the worker we interact with that is wrapped by PWBHost.
|
||||
worker;
|
||||
|
||||
setup(dbName) {
|
||||
if (this.worker) {
|
||||
// If the worker already exists, we don't need to do anything.
|
||||
return;
|
||||
}
|
||||
|
||||
// In production, our JS files will be fingerprinted with an MD5 hash
|
||||
// so we need a way to map the worker file name to be able to find it.
|
||||
//
|
||||
// Ember is automagically replacing any URL path with the fingerprinted
|
||||
// asset, and this is done by broccoli-asset-rev.
|
||||
//
|
||||
// The problem is it's a bit finicky as seen in this issue
|
||||
// https://github.com/ember-cli/broccoli-asset-rewrite/issues/66
|
||||
// and will not work properly if it's part of a template string or interpolated.
|
||||
// Therefore, we need the URL path in a string somewhere to be replaced properly.
|
||||
this.webWorker = new Worker('/workers/sqlite-worker.js', {
|
||||
type: 'module',
|
||||
});
|
||||
|
||||
// We use PWBHost to simplify communication so we can just send and receive
|
||||
// messages with promises rather than having to manually set up event listeners
|
||||
this.worker = new PWBHost(this.webWorker);
|
||||
this.worker.register(async ({ method }) => {
|
||||
if (method === 'getDatabaseName') {
|
||||
return dbName;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fetchResource({ sql, parameters }) {
|
||||
return this.worker.postMessage({
|
||||
method: 'fetchResource',
|
||||
payload: { sql, parameters },
|
||||
});
|
||||
}
|
||||
|
||||
insertResource(resource, items) {
|
||||
return this.worker.postMessage({
|
||||
method: 'insertResource',
|
||||
payload: { resource, items, modelMapping },
|
||||
});
|
||||
}
|
||||
|
||||
deleteResource(resource, ids) {
|
||||
return this.worker.postMessage({
|
||||
method: 'deleteResource',
|
||||
payload: { resource, ids },
|
||||
});
|
||||
}
|
||||
|
||||
analyzeDatabase() {
|
||||
return this.worker.postMessage({ method: 'analyzeDatabase' });
|
||||
}
|
||||
|
||||
async downloadDatabase() {
|
||||
const byteArray = await this.worker.postMessage({
|
||||
method: 'downloadDatabase',
|
||||
});
|
||||
|
||||
const blob = new Blob([byteArray.buffer], {
|
||||
type: 'application/x-sqlite3',
|
||||
});
|
||||
|
||||
// Create temporary elements to initialize the download
|
||||
const a = window.document.createElement('a');
|
||||
window.document.body.appendChild(a);
|
||||
a.href = window.URL.createObjectURL(blob);
|
||||
a.download = 'boundary.db';
|
||||
a.addEventListener('click', function () {
|
||||
setTimeout(function () {
|
||||
window.URL.revokeObjectURL(a.href);
|
||||
a.remove();
|
||||
}, 500);
|
||||
});
|
||||
a.click();
|
||||
}
|
||||
|
||||
async clearDatabase() {
|
||||
await this.worker.postMessage({ method: 'clearDatabase' });
|
||||
}
|
||||
|
||||
async deleteDatabase() {
|
||||
await this.worker.postMessage({ method: 'deleteDatabase' });
|
||||
}
|
||||
|
||||
willDestroy() {
|
||||
this.webWorker?.terminate();
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import Store from 'ember-data/store';
|
||||
import { CacheHandler } from '@ember-data/store';
|
||||
import RequestManager from '@ember-data/request';
|
||||
import { LegacyNetworkHandler } from '@ember-data/legacy-compat';
|
||||
import IndexedDbHandler from 'api/handlers/indexed-db-handler';
|
||||
import SqliteHandler from 'api/handlers/sqlite-handler';
|
||||
|
||||
export default class extends Store {
|
||||
requestManager = new RequestManager();
|
||||
@@ -18,9 +18,9 @@ export default class extends Store {
|
||||
constructor(args) {
|
||||
super(args);
|
||||
|
||||
const indexedDbHandler = new IndexedDbHandler(this);
|
||||
const sqliteHandler = new SqliteHandler(this);
|
||||
|
||||
this.requestManager.use([indexedDbHandler, LegacyNetworkHandler]);
|
||||
this.requestManager.use([sqliteHandler, LegacyNetworkHandler]);
|
||||
this.requestManager.useCache(CacheHandler);
|
||||
}
|
||||
}
|
||||
@@ -1,258 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import { get } from '@ember/object';
|
||||
import { modelIndexes } from '../services/indexed-db';
|
||||
|
||||
/**
|
||||
* Takes a POJO representing a filter query and builds a query to
|
||||
* query indexedDb with. Note that the first filter is important as that
|
||||
* will use the actual index in indexedDb while any subsequent ones will be less efficient.
|
||||
*
|
||||
* @example
|
||||
* const filter = queryIndexedDb(indexedDb, type, {
|
||||
* search: generated',
|
||||
* filters: {
|
||||
* scope_id: {
|
||||
* logicalOperator: 'and',
|
||||
* values: [{ notEquals: 'scope1' }, { notEquals: 'scope2' }],
|
||||
* },
|
||||
* },
|
||||
* });
|
||||
*/
|
||||
export async function queryIndexedDb(indexedDb, resource, query) {
|
||||
let { search, filters = {} } = query ?? {};
|
||||
let filterCollection = indexedDb[resource];
|
||||
|
||||
search = getSearchObject(search, resource);
|
||||
filters = cleanObject(filters);
|
||||
|
||||
if (Object.keys(filters).length) {
|
||||
// Remove the first item to use as the initial collection and then reduce the remaining ones
|
||||
const entries = Object.entries(filters);
|
||||
const [firstKey, firstFilterArrayOrObject] = entries.shift();
|
||||
|
||||
filterCollection = await entries.reduce(
|
||||
async (currCollection, [key, filterArrayOrObject]) => {
|
||||
// Grab the results from the set of one filters to be `AND` together with the rest of the filters
|
||||
const filterResults = await buildInitialWhereClause({
|
||||
table: indexedDb[resource],
|
||||
key,
|
||||
filterArrayOrObject,
|
||||
}).primaryKeys();
|
||||
const resultIdsSet = new Set(filterResults);
|
||||
|
||||
return (await currCollection).and((record) => {
|
||||
return resultIdsSet.has(record.id);
|
||||
});
|
||||
},
|
||||
buildInitialWhereClause({
|
||||
key: firstKey,
|
||||
table: indexedDb[resource],
|
||||
filterArrayOrObject: firstFilterArrayOrObject,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (search) {
|
||||
const { text, fields } = search;
|
||||
|
||||
filterCollection = filterCollection.filter((record) =>
|
||||
fields
|
||||
.map((key) => get(record, key))
|
||||
.some((element) => {
|
||||
if (typeof element === 'string') {
|
||||
return element?.toLowerCase().includes(text?.toLowerCase());
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// filterCollection can be Dexie Table or a Dexie Collection.
|
||||
// This is an important detail you need to keep in mind when working on it.
|
||||
return filterCollection.toArray();
|
||||
}
|
||||
|
||||
const buildInitialWhereClause = ({ filterArrayOrObject, table, key }) => {
|
||||
const filterValueArray = Array.isArray(filterArrayOrObject)
|
||||
? filterArrayOrObject
|
||||
: filterArrayOrObject.values;
|
||||
|
||||
const { logicalOperator } = filterArrayOrObject;
|
||||
|
||||
// Short circuit if all the filter types were equals or notEquals since indexedDb
|
||||
// can more efficiently query if we pass all values at once
|
||||
const checkAllFilterType = (type) =>
|
||||
filterValueArray.every((obj) => Object.keys(obj)[0] === type);
|
||||
if (checkAllFilterType('equals')) {
|
||||
return table
|
||||
.where(getKey(key))
|
||||
.anyOf(filterValueArray.map((obj) => obj['equals']));
|
||||
}
|
||||
if (checkAllFilterType('notEquals')) {
|
||||
return table
|
||||
.where(getKey(key))
|
||||
.noneOf(filterValueArray.map((obj) => obj['notEquals']));
|
||||
}
|
||||
|
||||
// Remove the first item to use as the initial where clause and then reduce the remaining ones
|
||||
const [firstOperation, firstFilterValue] = Object.entries(
|
||||
filterValueArray[0],
|
||||
)[0];
|
||||
|
||||
// Not all are filters are equals or notEquals,
|
||||
// manually build the initial where clause and resulting collections
|
||||
return filterValueArray.slice(1).reduce(
|
||||
(result, currObj) => {
|
||||
const [operation, value] = Object.entries(currObj)[0];
|
||||
|
||||
return buildIndexedDbCollection({
|
||||
key: getKey(key),
|
||||
value,
|
||||
collection: result,
|
||||
operation,
|
||||
logicalOperator,
|
||||
});
|
||||
},
|
||||
buildIndexedDbWhere({
|
||||
table,
|
||||
key: getKey(key),
|
||||
operation: firstOperation,
|
||||
value: firstFilterValue,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds the initial where clause for indexed DB to filter on. Note that this makes
|
||||
* the first filter important as that will do the real filtering from indexed DB while
|
||||
* any subsequent filter has to be done in memory.
|
||||
*/
|
||||
const buildIndexedDbWhere = ({ table, key, value, operation }) => {
|
||||
const baseWhereClause = table.where(key);
|
||||
switch (operation) {
|
||||
case 'contains':
|
||||
// We return an error due to not being able to support contains on first filter due to performance.
|
||||
throw new Error('Contains is not supported as the first filter.');
|
||||
case 'gt':
|
||||
return baseWhereClause.above(value);
|
||||
case 'gte':
|
||||
return baseWhereClause.aboveOrEqual(value);
|
||||
case 'lt':
|
||||
return baseWhereClause.below(value);
|
||||
case 'lte':
|
||||
return baseWhereClause.belowOrEqual(value);
|
||||
case 'notEquals':
|
||||
return baseWhereClause.notEqual(value);
|
||||
default:
|
||||
return baseWhereClause.equals(value);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Continues building the queries for indexed DB to filter on. Note that these are not true
|
||||
* indexed DB queries and in fact just standard JS filters for logical "and" queries and parallel queries
|
||||
* to indexed DB for logical "or" queries that get deduped.
|
||||
*/
|
||||
const buildIndexedDbCollection = ({
|
||||
key,
|
||||
value,
|
||||
collection,
|
||||
operation,
|
||||
logicalOperator,
|
||||
}) => {
|
||||
if (logicalOperator === 'and') {
|
||||
switch (operation) {
|
||||
case 'contains':
|
||||
return collection.and((record) => get(record, key)?.includes(value));
|
||||
case 'gt':
|
||||
return collection.and((record) => get(record, key) > value);
|
||||
case 'gte':
|
||||
return collection.and((record) => get(record, key) >= value);
|
||||
case 'lt':
|
||||
return collection.and((record) => get(record, key) < value);
|
||||
case 'lte':
|
||||
return collection.and((record) => get(record, key) <= value);
|
||||
case 'notEquals':
|
||||
return collection.and((record) => get(record, key) !== value);
|
||||
default:
|
||||
return collection.and((record) => get(record, key) === value);
|
||||
}
|
||||
}
|
||||
|
||||
switch (operation) {
|
||||
case 'contains':
|
||||
// We return an error due to not being able to support contains on this filter due to performance.
|
||||
throw new Error(
|
||||
'Contains is not supported as a filter option with "or" operator.',
|
||||
);
|
||||
case 'gt':
|
||||
return collection.or(key).above(value);
|
||||
case 'gte':
|
||||
return collection.or(key).aboveOrEqual(value);
|
||||
case 'lt':
|
||||
return collection.or(key).below(value);
|
||||
case 'lte':
|
||||
return collection.or(key).belowOrEqual(value);
|
||||
case 'notEquals':
|
||||
return collection.or(key).notEqual(value);
|
||||
default:
|
||||
return collection.or(key).equals(value);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Translates the fields passed in from the UI for resources to the keys indexed DB knows them as.
|
||||
* Usually it's the path of the actual normalized model.
|
||||
*/
|
||||
const getKey = (key) => {
|
||||
switch (key) {
|
||||
case 'scope_id':
|
||||
return `attributes.scope.${key}`;
|
||||
case 'id':
|
||||
return key;
|
||||
default:
|
||||
return `attributes.${key}`;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Cleans the object to remove any keys that have a value of an empty array. This makes
|
||||
* it easier so we don't have to worry about empty arrays getting queried in indexed db.
|
||||
*/
|
||||
const cleanObject = (obj) => {
|
||||
if (!obj) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return Object.entries(obj).reduce((result, [key, value]) => {
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length > 0) {
|
||||
result[key] = value;
|
||||
}
|
||||
} else if (value.values.length > 0) {
|
||||
result[key] = value;
|
||||
}
|
||||
|
||||
return result;
|
||||
}, {});
|
||||
};
|
||||
|
||||
/**
|
||||
* Makes the search object that is expected when a user searches. Uses
|
||||
* the indexes we setup in indexed db.
|
||||
*/
|
||||
const getSearchObject = (text, resource) => {
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
text,
|
||||
fields: modelIndexes[resource]
|
||||
.split(', ')
|
||||
.map((index) => index.replace('&', '')),
|
||||
};
|
||||
};
|
||||
@@ -1,84 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
// created_time is not an attribute defined on all models, but is present in all the resources
|
||||
// coming from API
|
||||
const SORT_DEFAULT_ATTRIBUTE = 'created_time';
|
||||
const SORT_DIRECTION_ASCENDING = 'asc';
|
||||
const SORT_DIRECTION_DESCENDING = 'desc';
|
||||
|
||||
const sortFunctions = {
|
||||
string: (a, b) => String(a).localeCompare(String(b)),
|
||||
date: (a, b) => new Date(a).getTime() - new Date(b).getTime(),
|
||||
number: (a, b) => Number(a) - Number(b),
|
||||
boolean: (a, b) => Number(a) - Number(b),
|
||||
};
|
||||
|
||||
function getSortableValue(schema, record, attribute) {
|
||||
if (attribute === 'id') {
|
||||
return record.id;
|
||||
}
|
||||
|
||||
// all models have a created_time attribute but not all models have it defined
|
||||
if (attribute === 'created_time') {
|
||||
return record.attributes.created_time;
|
||||
}
|
||||
|
||||
if (schema.attributes.has(attribute)) {
|
||||
return record.attributes[attribute];
|
||||
}
|
||||
|
||||
const attributes = Array.from(schema.attributes.keys());
|
||||
throw new Error(
|
||||
`The attribute "${attribute}" does not map to the model definition of type "${record.type}". Supported sortable attributes are 'id', 'created_time', or ${attributes.map((attr) => `'${attr}'`).join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
export const sortResults = (results, { querySort, schema }) => {
|
||||
querySort = querySort ?? {};
|
||||
const sortAttribute = querySort.attribute || SORT_DEFAULT_ATTRIBUTE;
|
||||
|
||||
// Default sort direction is ascending unless we are sorting by `created_time` (default sort attribute)
|
||||
const defaultSortDirection =
|
||||
sortAttribute === SORT_DEFAULT_ATTRIBUTE && !querySort.sortFunction
|
||||
? SORT_DIRECTION_DESCENDING
|
||||
: SORT_DIRECTION_ASCENDING;
|
||||
const sortDirection = querySort.direction || defaultSortDirection;
|
||||
|
||||
if (
|
||||
![SORT_DIRECTION_ASCENDING, SORT_DIRECTION_DESCENDING].includes(
|
||||
sortDirection,
|
||||
)
|
||||
) {
|
||||
throw new Error('Invalid sort direction');
|
||||
}
|
||||
|
||||
// Execute custom sort function if one is provided.
|
||||
if (querySort.sortFunction) {
|
||||
return results.toSorted((a, b) => {
|
||||
const sortResult = querySort.sortFunction(a, b);
|
||||
|
||||
return sortDirection === SORT_DIRECTION_ASCENDING
|
||||
? sortResult
|
||||
: -1 * sortResult;
|
||||
});
|
||||
}
|
||||
|
||||
const sortAttributeDataType = schema.attributes.get(sortAttribute)?.type;
|
||||
|
||||
const sortFunction =
|
||||
sortFunctions[sortAttributeDataType] ?? sortFunctions.string;
|
||||
|
||||
return results.toSorted((a, b) => {
|
||||
// Extract the values to sort and sort them.
|
||||
const sortValueA = getSortableValue(schema, a, sortAttribute);
|
||||
const sortValueB = getSortableValue(schema, b, sortAttribute);
|
||||
const sortResult = sortFunction(sortValueA, sortValueB);
|
||||
|
||||
return sortDirection === SORT_DIRECTION_ASCENDING
|
||||
? sortResult
|
||||
: -1 * sortResult;
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import { modelMapping } from 'api/services/sqlite';
|
||||
import { searchTables } from 'api/services/sqlite';
|
||||
import { typeOf } from '@ember/utils';
|
||||
import { underscore } from '@ember/string';
|
||||
|
||||
/**
|
||||
* Takes a POJO representing a filter query and builds a SQL query.
|
||||
*
|
||||
* @example
|
||||
* const filter = generateSQLExpressions(type, {
|
||||
* search: generated',
|
||||
* filters: {
|
||||
* scope_id: {
|
||||
* logicalOperator: 'and',
|
||||
* values: [{ notEquals: 'scope1' }, { notEquals: 'scope2' }],
|
||||
* },
|
||||
* },
|
||||
* });
|
||||
*/
|
||||
export function generateSQLExpressions(
|
||||
resource,
|
||||
query = {},
|
||||
{ page, pageSize, select } = {},
|
||||
) {
|
||||
let { search, filters, sort = {} } = query;
|
||||
const conditions = [];
|
||||
const parameters = [];
|
||||
const tableName = underscore(resource);
|
||||
|
||||
addFilterConditions({ filters, parameters, conditions });
|
||||
addSearchConditions({ search, resource, tableName, parameters, conditions });
|
||||
|
||||
const orderByClause = constructOrderByClause(resource, sort);
|
||||
|
||||
const whereClause = conditions.length ? `WHERE ${and(conditions)}` : '';
|
||||
|
||||
const paginationClause = page && pageSize ? `LIMIT ? OFFSET ?` : '';
|
||||
if (paginationClause) {
|
||||
parameters.push(pageSize, (page - 1) * pageSize);
|
||||
}
|
||||
|
||||
const selectClause = `SELECT ${select ? select.join(', ') : '*'} FROM "${tableName}"`;
|
||||
|
||||
return {
|
||||
// Replace any empty newlines or leading whitespace on each line to be consistent with formatting
|
||||
// This is mainly to help us read and test the generated SQL as it has no effect on the actual SQL execution.
|
||||
sql: `
|
||||
${selectClause}
|
||||
${whereClause}
|
||||
${orderByClause}
|
||||
${paginationClause}`
|
||||
.replace(/^\s+/gm, '')
|
||||
.trim(),
|
||||
parameters,
|
||||
};
|
||||
}
|
||||
|
||||
function addFilterConditions({ filters, parameters, conditions }) {
|
||||
if (!filters) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [key, filterArrayOrObject] of Object.entries(filters)) {
|
||||
// Filter can be an array or an object with a `values` property
|
||||
const filterValueArray = Array.isArray(filterArrayOrObject)
|
||||
? filterArrayOrObject
|
||||
: filterArrayOrObject.values;
|
||||
|
||||
if (!filterValueArray || !filterValueArray.length) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// When attribute conditions are a series of equals/notEquals
|
||||
// replace with in/notIn to avoid hitting the sqlite maximum
|
||||
// expression tree depth.
|
||||
const firstOperator = Object.keys(filterValueArray[0])[0];
|
||||
const allOperatorsEqual = filterValueArray
|
||||
.flatMap((item) => Object.keys(item))
|
||||
.every((op) => op === firstOperator);
|
||||
if (
|
||||
filterValueArray.length > 1 &&
|
||||
(firstOperator === 'equals' || firstOperator === 'notEquals') &&
|
||||
allOperatorsEqual
|
||||
) {
|
||||
const operation = firstOperator === 'equals' ? 'in' : 'notIn';
|
||||
const values = filterValueArray
|
||||
.filter((f) => f)
|
||||
.map((filterObjValue) => {
|
||||
let value = Object.values(filterObjValue)[0];
|
||||
if (typeOf(value) === 'date') {
|
||||
value = value.toISOString();
|
||||
}
|
||||
parameters.push(value);
|
||||
return value;
|
||||
});
|
||||
const filterCondition = `${key}${OPERATORS[operation](values)}`;
|
||||
conditions.push(parenthetical(filterCondition));
|
||||
continue;
|
||||
}
|
||||
|
||||
const filterConditions = filterValueArray
|
||||
.filter((f) => f)
|
||||
.map((filterObjValue) => {
|
||||
let [operation, value] = Object.entries(filterObjValue)[0];
|
||||
|
||||
// SQLite needs to be working with ISO strings
|
||||
if (typeOf(value) === 'date') {
|
||||
value = value.toISOString();
|
||||
}
|
||||
|
||||
// Handle LIKE operator separately
|
||||
if (operation === 'contains') {
|
||||
parameters.push(`%${value}%`);
|
||||
} else {
|
||||
parameters.push(value);
|
||||
}
|
||||
|
||||
return `${key}${OPERATORS[operation]}`;
|
||||
});
|
||||
|
||||
const { logicalOperator } = filterArrayOrObject;
|
||||
// Default to `or` if we don't have a logical operator
|
||||
conditions.push(
|
||||
parenthetical(
|
||||
logicalOperator === 'and'
|
||||
? and(filterConditions)
|
||||
: or(filterConditions),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function addSearchConditions({
|
||||
search,
|
||||
resource,
|
||||
tableName,
|
||||
parameters,
|
||||
conditions,
|
||||
}) {
|
||||
if (!search) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (searchTables.has(resource)) {
|
||||
// Use the special prefix indicator "*" for full-text search
|
||||
parameters.push(`"${search}"*`);
|
||||
// Use a subquery to match against the FTS table with rowids as SQLite is
|
||||
// much more efficient with FTS queries when using rowids or MATCH (or both).
|
||||
// We could have also used a join here but a subquery is simpler.
|
||||
conditions.push(
|
||||
`rowid IN (SELECT rowid FROM ${tableName}_fts WHERE ${tableName}_fts MATCH ?)`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const fields = Object.keys(modelMapping[resource]);
|
||||
const searchConditions = parenthetical(
|
||||
or(
|
||||
fields.map((field) => {
|
||||
parameters.push(`%${search}%`);
|
||||
return `${field}${OPERATORS['contains']}`;
|
||||
}),
|
||||
),
|
||||
);
|
||||
conditions.push(searchConditions);
|
||||
}
|
||||
|
||||
function constructOrderByClause(resource, sort) {
|
||||
const defaultOrderByClause = 'ORDER BY created_time DESC';
|
||||
|
||||
const { attributes, customSort, direction, isCoalesced } = sort;
|
||||
const sortDirection = direction === 'desc' ? 'DESC' : 'ASC';
|
||||
|
||||
// We have to check if the attributes are valid for the resource
|
||||
// as we can't use parameterized queries for ORDER BY
|
||||
const validAttributes = Object.keys(modelMapping[resource] ?? {});
|
||||
if (attributes?.some((attr) => !validAttributes.includes(attr))) {
|
||||
return modelMapping[resource]?.created_time ? defaultOrderByClause : '';
|
||||
}
|
||||
|
||||
if (customSort?.attributeMap) {
|
||||
const whenClauses = Object.keys(customSort.attributeMap).reduce(
|
||||
(acc, key) => {
|
||||
return acc + `WHEN '${key}' THEN '${customSort.attributeMap[key]}' `;
|
||||
},
|
||||
'',
|
||||
);
|
||||
return `ORDER BY CASE ${attributes.join(', ')} ${whenClauses}END ${sortDirection}`;
|
||||
} else if (attributes?.length > 0) {
|
||||
const commaSeparatedVals = attributes.join(', ');
|
||||
|
||||
// In places where `collate nocase` is used, it is to ensure case is ignored on the initial sort.
|
||||
// Then, a sort on the same condition is performed to ensure upper-case strings are given preference in a tie.
|
||||
if (isCoalesced) {
|
||||
return `ORDER BY COALESCE(${attributes.join(', ')}) COLLATE NOCASE ${sortDirection}, COALESCE(${commaSeparatedVals}) ${sortDirection}`;
|
||||
}
|
||||
|
||||
const attributesWithNoCollate = attributes
|
||||
.map((attr) => `${attr} COLLATE NOCASE ${sortDirection}`)
|
||||
.join(', ');
|
||||
const attributesWithDirection = attributes
|
||||
.map((attr) => `${attr} ${sortDirection}`)
|
||||
.join(', ');
|
||||
return `ORDER BY ${attributesWithNoCollate}, ${attributesWithDirection}`;
|
||||
} else if (modelMapping[resource]?.created_time) {
|
||||
return defaultOrderByClause;
|
||||
} else {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
// Comparison Operators
|
||||
const OPERATORS = {
|
||||
equals: ' = ?',
|
||||
notEquals: ' != ?',
|
||||
gt: ' > ?',
|
||||
gte: ' >= ?',
|
||||
lt: ' < ?',
|
||||
lte: ' <= ?',
|
||||
contains: ' LIKE ?',
|
||||
in: (values) => ` IN (${values.map(() => '?').join(', ')})`,
|
||||
notIn: (values) => ` NOT IN (${values.map(() => '?').join(', ')})`,
|
||||
};
|
||||
|
||||
// Logical Operators
|
||||
const and = (clauses) => clauses.join(' AND ');
|
||||
const or = (clauses) => clauses.join(' OR ');
|
||||
const parenthetical = (expression) => (expression ? `(${expression})` : '');
|
||||
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import { PWBWorker } from 'promise-worker-bi';
|
||||
import sqlite3InitModule from '@sqlite.org/sqlite-wasm';
|
||||
import sharedServiceInit from './utils/shared-service';
|
||||
import {
|
||||
CLEAR_DB,
|
||||
CREATE_TABLES,
|
||||
DELETE_STATEMENT,
|
||||
INSERT_STATEMENTS,
|
||||
} from './utils/schema';
|
||||
|
||||
// Production builds error out when using top level awaits so we'll just use an async IIFE
|
||||
(async () => {
|
||||
/** @type {import('@sqlite.org/sqlite-wasm').OpfsSAHPoolDatabase} */
|
||||
let db;
|
||||
/** @type {import('@sqlite.org/sqlite-wasm').SAHPoolUtil} */
|
||||
let poolUtil;
|
||||
/** @type {import('@sqlite.org/sqlite-wasm').Sqlite3Static} */
|
||||
let sqlite3;
|
||||
|
||||
const { promise: ready, resolve: readyResolve } = Promise.withResolvers();
|
||||
|
||||
// Maximum number of host parameters for SQLite prepared statements.
|
||||
// See "Maximum Number Of Host Parameters In A Single SQL Statement" in
|
||||
// https://www.sqlite.org/limits.html
|
||||
const MAX_HOST_PARAMETERS = 32766;
|
||||
const SCHEMA_VERSION = 1;
|
||||
|
||||
// Some browsers do not allow calling getDirectory in private browsing modes even
|
||||
// if we're in a secure context. This will cause the SQLite setup to fail so we should
|
||||
// check ahead of time to see if we're able to call the API.
|
||||
let canCallGetDirectory;
|
||||
try {
|
||||
canCallGetDirectory = Boolean(await navigator.storage.getDirectory());
|
||||
} catch {
|
||||
canCallGetDirectory = false;
|
||||
}
|
||||
|
||||
const isSecure = self.isSecureContext && canCallGetDirectory;
|
||||
|
||||
// Error code for sqlite corruption errors.
|
||||
// See https://sqlite.org/rescode.html#corrupt
|
||||
const SQLITE_CORRUPT = 11;
|
||||
|
||||
const methods = {
|
||||
initializeSQLite: async () => {
|
||||
sqlite3 = await sqlite3InitModule({
|
||||
print: console.log,
|
||||
printErr: console.error,
|
||||
});
|
||||
const dbName = await promiseWorker.postMessage({
|
||||
method: 'getDatabaseName',
|
||||
});
|
||||
|
||||
function setupDb() {
|
||||
const [row] = db.exec('PRAGMA user_version;', { rowMode: 'object' });
|
||||
// Check if we're on a newer schema version and clear the database if so
|
||||
if (SCHEMA_VERSION > row.user_version) {
|
||||
db.exec(CLEAR_DB);
|
||||
}
|
||||
|
||||
db.exec(CREATE_TABLES(SCHEMA_VERSION));
|
||||
// Force an analyze on initial connection
|
||||
db.exec('PRAGMA optimize=0x10002;');
|
||||
}
|
||||
|
||||
// Instantiate an in memory DB if we're not in a secure context as OPFS is unavailable otherwise
|
||||
if (!isSecure) {
|
||||
db = new sqlite3.oo1.DB(':memory:');
|
||||
setupDb();
|
||||
return;
|
||||
}
|
||||
|
||||
let done = false;
|
||||
while (!done) {
|
||||
try {
|
||||
poolUtil = await sqlite3.installOpfsSAHPoolVfs();
|
||||
db = new poolUtil.OpfsSAHPoolDb(dbName);
|
||||
|
||||
setupDb();
|
||||
done = true;
|
||||
} catch (/** @type {import('@sqlite.org/sqlite-wasm').SQLite3Error} */ err) {
|
||||
// If we get a SQLite3Error and the pool is at capacity, we'll assume the error is
|
||||
// because we hit the cap so we add more capacity and see if that solves it.
|
||||
if (
|
||||
err.name === 'SQLite3Error' &&
|
||||
poolUtil.getCapacity() === poolUtil.getFileCount()
|
||||
) {
|
||||
await poolUtil.addCapacity(1);
|
||||
|
||||
// If we have more than 20 files, that might indicate a problem so let's just
|
||||
// remove the VFS and wipe all the client data across users and restart.
|
||||
if (poolUtil.getCapacity() > 20) {
|
||||
await poolUtil.removeVfs();
|
||||
}
|
||||
} else {
|
||||
console.error('SQLite initialization error:', err);
|
||||
|
||||
// We might have gotten ourselves into a bad state which corrupted the DB so clear it out and retry
|
||||
if (err?.resultCode === SQLITE_CORRUPT) {
|
||||
poolUtil.unlink(`/${dbName}`);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
analyzeDatabase: () => {
|
||||
db.exec('PRAGMA optimize');
|
||||
},
|
||||
clearDatabase: () => {
|
||||
db.exec(CLEAR_DB);
|
||||
db.exec(CREATE_TABLES(SCHEMA_VERSION));
|
||||
},
|
||||
deleteDatabase: () => {
|
||||
if (!db) {
|
||||
throw new Error('No database was initialized');
|
||||
}
|
||||
|
||||
const name = db.dbFilename();
|
||||
db.close();
|
||||
db = null;
|
||||
poolUtil.unlink(`/${name}`);
|
||||
},
|
||||
downloadDatabase: () => {
|
||||
return sqlite3.capi.sqlite3_js_db_export(db);
|
||||
},
|
||||
fetchResource: ({ sql, parameters = [] }) => {
|
||||
try {
|
||||
return db.exec({
|
||||
sql,
|
||||
bind: parameters,
|
||||
rowMode: 'object',
|
||||
});
|
||||
} catch (err) {
|
||||
// TODO: Add error handling to rest of methods and properly return the error back to caller
|
||||
console.error('Fetch resource error:', err, sql, parameters);
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
insertResource: ({ resource, items, modelMapping }) => {
|
||||
if (items.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const numberOfParameters = items.reduce((acc, item) => {
|
||||
return acc + item.length;
|
||||
}, 0);
|
||||
|
||||
// We want to use as many VALUES as part of the insert statement as it's quicker
|
||||
// than doing multiple inserts. If we have too many rows to insert, we'll manually
|
||||
// chunk them up to avoid hitting the maximum number of host parameters
|
||||
// and execute these separate insert statements in a transaction
|
||||
if (numberOfParameters > MAX_HOST_PARAMETERS) {
|
||||
const chunkSize = Math.floor(MAX_HOST_PARAMETERS / items[0].length);
|
||||
|
||||
return db.transaction(() => {
|
||||
const results = [];
|
||||
for (let i = 0; i < items.length; i += chunkSize) {
|
||||
const chunk = items.slice(i, i + chunkSize);
|
||||
const result = db.exec({
|
||||
sql: INSERT_STATEMENTS(resource, chunk, modelMapping),
|
||||
bind: chunk.flat(),
|
||||
rowMode: 'object',
|
||||
});
|
||||
results.push(result);
|
||||
}
|
||||
return results;
|
||||
});
|
||||
}
|
||||
|
||||
db.exec({
|
||||
sql: INSERT_STATEMENTS(resource, items, modelMapping),
|
||||
bind: items.flat(),
|
||||
rowMode: 'object',
|
||||
});
|
||||
},
|
||||
deleteResource: ({ resource, ids }) => {
|
||||
// Check if we have too many parameters for the deletion and chunk if so
|
||||
if (ids?.length > MAX_HOST_PARAMETERS) {
|
||||
return db.transaction(() => {
|
||||
const results = [];
|
||||
for (let i = 0; i < ids.length; i += MAX_HOST_PARAMETERS) {
|
||||
const chunk = ids.slice(i, i + MAX_HOST_PARAMETERS);
|
||||
const result = db.exec({
|
||||
sql: DELETE_STATEMENT(resource, chunk),
|
||||
bind: chunk,
|
||||
rowMode: 'object',
|
||||
});
|
||||
results.push(result);
|
||||
}
|
||||
return results;
|
||||
});
|
||||
}
|
||||
|
||||
return db.exec({
|
||||
sql: DELETE_STATEMENT(resource, ids),
|
||||
bind: ids,
|
||||
rowMode: 'object',
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
const promiseWorker = new PWBWorker();
|
||||
promiseWorker.register(async ({ method, payload }) => {
|
||||
if (!sharedService) {
|
||||
await ready;
|
||||
return methods[method](payload);
|
||||
}
|
||||
|
||||
await sharedService.status.ready;
|
||||
return sharedService.proxy[method](payload);
|
||||
});
|
||||
|
||||
const initializeOnProviderChange = async (isProvider) => {
|
||||
if (!isProvider) {
|
||||
return;
|
||||
}
|
||||
// Initialize the SQLite database when we become a provider
|
||||
await sharedService.proxy.initializeSQLite();
|
||||
};
|
||||
|
||||
// Only use the shared service in a secure context as weblocks aren't available otherwise
|
||||
let sharedService;
|
||||
if (isSecure) {
|
||||
sharedService = sharedServiceInit(
|
||||
'sqlite',
|
||||
methods,
|
||||
initializeOnProviderChange,
|
||||
);
|
||||
} else {
|
||||
await methods.initializeSQLite();
|
||||
readyResolve();
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,478 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import { underscore } from '@ember/string';
|
||||
|
||||
export const CLEAR_DB = `
|
||||
PRAGMA writable_schema = 1;
|
||||
DELETE FROM sqlite_master;
|
||||
PRAGMA writable_schema = 0;
|
||||
VACUUM;
|
||||
PRAGMA integrity_check;`;
|
||||
|
||||
const createTargetTables = `
|
||||
CREATE TABLE IF NOT EXISTS target (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
type TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
address TEXT,
|
||||
scope_id TEXT NOT NULL,
|
||||
created_time TEXT NOT NULL,
|
||||
data TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_target_scope_id_created_time ON target(scope_id, created_time DESC);
|
||||
|
||||
-- Create a contentless FTS table as we will only use the rowids.
|
||||
-- Note that this only creates the FTS index and cannot reference the target content
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS target_fts USING fts5(
|
||||
id,
|
||||
type,
|
||||
name,
|
||||
description,
|
||||
address,
|
||||
scope_id,
|
||||
created_time,
|
||||
content='',
|
||||
);
|
||||
|
||||
-- Create triggers to keep the FTS table in sync with the target table
|
||||
-- Note: The FTS table does not have an update trigger, as it's updated only on
|
||||
-- INSERT and DELETE as we do an INSERT OR REPLACE INTO on the table
|
||||
-- which replaces the row and doesn't execute an UPDATE trigger.
|
||||
CREATE TRIGGER IF NOT EXISTS target_ai AFTER INSERT ON target BEGIN
|
||||
INSERT INTO target_fts(
|
||||
rowid, id, type, name, description, address, scope_id, created_time
|
||||
) VALUES (
|
||||
new.rowid, new.id, new.type, new.name, new.description, new.address, new.scope_id, new.created_time
|
||||
);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS target_ad AFTER DELETE ON target BEGIN
|
||||
INSERT INTO target_fts(target_fts, rowid, id, type, name, description, address, scope_id, created_time)
|
||||
VALUES('delete', old.rowid, old.id, old.type, old.name, old.description, old.address, old.scope_id, old.created_time);
|
||||
END;`;
|
||||
|
||||
const createAliasTables = `
|
||||
CREATE TABLE IF NOT EXISTS alias (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
type TEXT NOT NULL,
|
||||
name TEXT,
|
||||
description TEXT,
|
||||
destination_id TEXT,
|
||||
value TEXT,
|
||||
scope_id TEXT NOT NULL,
|
||||
created_time TEXT NOT NULL,
|
||||
data TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_alias_created_time ON alias(created_time DESC);
|
||||
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS alias_fts USING fts5(
|
||||
id,
|
||||
type,
|
||||
name,
|
||||
description,
|
||||
destination_id,
|
||||
value,
|
||||
scope_id,
|
||||
created_time,
|
||||
content='',
|
||||
);
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS alias_ai AFTER INSERT ON alias BEGIN
|
||||
INSERT INTO alias_fts(
|
||||
rowid, id, type, name, description, destination_id, value, scope_id, created_time
|
||||
) VALUES (
|
||||
new.rowid, new.id, new.type, new.name, new.description, new.destination_id, new.value, new.scope_id, new.created_time
|
||||
);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS alias_ad AFTER DELETE ON alias BEGIN
|
||||
INSERT INTO alias_fts(alias_fts, rowid, id, type, name, description, destination_id, value, scope_id, created_time)
|
||||
VALUES('delete', old.rowid, old.id, old.type, old.name, old.description, old.destination_id, old.value, old.scope_id, old.created_time);
|
||||
END;`;
|
||||
|
||||
const createGroupTables = `
|
||||
CREATE TABLE IF NOT EXISTS "group" (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
name TEXT,
|
||||
description TEXT,
|
||||
scope_id TEXT NOT NULL,
|
||||
created_time TEXT NOT NULL,
|
||||
data TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_group_scope_id_created_time ON "group"(scope_id, created_time DESC);
|
||||
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS group_fts USING fts5(
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
scope_id,
|
||||
created_time,
|
||||
content='',
|
||||
);
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS group_ai AFTER INSERT ON "group" BEGIN
|
||||
INSERT INTO group_fts(
|
||||
rowid, id, name, description, scope_id, created_time
|
||||
) VALUES (
|
||||
new.rowid, new.id, new.name, new.description, new.scope_id, new.created_time
|
||||
);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS group_ad AFTER DELETE ON "group" BEGIN
|
||||
INSERT INTO group_fts(group_fts, rowid, id, name, description, scope_id, created_time)
|
||||
VALUES('delete', old.rowid, old.id, old.name, old.description, old.scope_id, old.created_time);
|
||||
END;`;
|
||||
|
||||
const createRoleTables = `
|
||||
CREATE TABLE IF NOT EXISTS role (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
name TEXT,
|
||||
description TEXT,
|
||||
scope_id TEXT NOT NULL,
|
||||
created_time TEXT NOT NULL,
|
||||
data TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_role_scope_id_created_time ON role(scope_id, created_time DESC);
|
||||
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS role_fts USING fts5(
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
scope_id,
|
||||
created_time,
|
||||
content='',
|
||||
);
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS role_ai AFTER INSERT ON role BEGIN
|
||||
INSERT INTO role_fts(
|
||||
rowid, id, name, description, scope_id, created_time
|
||||
) VALUES (
|
||||
new.rowid, new.id, new.name, new.description, new.scope_id, new.created_time
|
||||
);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS role_ad AFTER DELETE ON role BEGIN
|
||||
INSERT INTO role_fts(role_fts, rowid, id, name, description, scope_id, created_time)
|
||||
VALUES('delete', old.rowid, old.id, old.name, old.description, old.scope_id, old.created_time);
|
||||
END;`;
|
||||
|
||||
const createUserTables = `
|
||||
CREATE TABLE IF NOT EXISTS user (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
name TEXT,
|
||||
description TEXT,
|
||||
scope_id TEXT NOT NULL,
|
||||
created_time TEXT NOT NULL,
|
||||
data TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_scope_id_created_time ON user(scope_id, created_time DESC);
|
||||
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS user_fts USING fts5(
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
scope_id,
|
||||
created_time,
|
||||
content='',
|
||||
);
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS user_ai AFTER INSERT ON user BEGIN
|
||||
INSERT INTO user_fts(
|
||||
rowid, id, name, description, scope_id, created_time
|
||||
) VALUES (
|
||||
new.rowid, new.id, new.name, new.description, new.scope_id, new.created_time
|
||||
);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS user_ad AFTER DELETE ON user BEGIN
|
||||
INSERT INTO user_fts(user_fts, rowid, id, name, description, scope_id, created_time)
|
||||
VALUES('delete', old.rowid, old.id, old.name, old.description, old.scope_id, old.created_time);
|
||||
END;`;
|
||||
|
||||
const createCredentialStoreTables = `
|
||||
CREATE TABLE IF NOT EXISTS credential_store (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
type TEXT NOT NULL,
|
||||
name TEXT,
|
||||
description TEXT,
|
||||
scope_id TEXT NOT NULL,
|
||||
created_time TEXT NOT NULL,
|
||||
data TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_credential_store_scope_id_created_time ON credential_store(scope_id, created_time DESC);
|
||||
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS credential_store_fts USING fts5(
|
||||
id,
|
||||
type,
|
||||
name,
|
||||
description,
|
||||
scope_id,
|
||||
created_time,
|
||||
content='',
|
||||
);
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS credential_store_ai AFTER INSERT ON credential_store BEGIN
|
||||
INSERT INTO credential_store_fts(
|
||||
rowid, id, type, name, description, scope_id, created_time
|
||||
) VALUES (
|
||||
new.rowid, new.id, new.type, new.name, new.description, new.scope_id, new.created_time
|
||||
);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS credential_store_ad AFTER DELETE ON credential_store BEGIN
|
||||
INSERT INTO credential_store_fts(credential_store_fts, rowid, id, type, name, description, scope_id, created_time)
|
||||
VALUES('delete', old.rowid, old.id, old.type, old.name, old.description, old.scope_id, old.created_time);
|
||||
END;`;
|
||||
|
||||
const createScopeTables = `
|
||||
CREATE TABLE IF NOT EXISTS scope (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
type TEXT NOT NULL,
|
||||
name TEXT,
|
||||
description TEXT,
|
||||
scope_id TEXT NOT NULL,
|
||||
created_time TEXT NOT NULL,
|
||||
data TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_scope_scope_id_created_time ON scope(scope_id, created_time DESC);
|
||||
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS scope_fts USING fts5(
|
||||
id,
|
||||
type,
|
||||
name,
|
||||
description,
|
||||
scope_id,
|
||||
created_time,
|
||||
content='',
|
||||
);
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS scope_ai AFTER INSERT ON scope BEGIN
|
||||
INSERT INTO scope_fts(
|
||||
rowid, id, type, name, description, scope_id, created_time
|
||||
) VALUES (
|
||||
new.rowid, new.id, new.type, new.name, new.description, new.scope_id, new.created_time
|
||||
);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS scope_ad AFTER DELETE ON scope BEGIN
|
||||
INSERT INTO scope_fts(scope_fts, rowid, id, type, name, description, scope_id, created_time)
|
||||
VALUES('delete', old.rowid, old.id, old.type, old.name, old.description, old.scope_id, old.created_time);
|
||||
END;`;
|
||||
|
||||
const createAuthMethodTables = `
|
||||
CREATE TABLE IF NOT EXISTS auth_method (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
type TEXT NOT NULL,
|
||||
name TEXT,
|
||||
description TEXT,
|
||||
is_primary INT,
|
||||
scope_id TEXT NOT NULL,
|
||||
created_time TEXT NOT NULL,
|
||||
data TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_auth_method_scope_id_created_time ON auth_method(scope_id, created_time DESC);
|
||||
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS auth_method_fts USING fts5(
|
||||
id,
|
||||
type,
|
||||
name,
|
||||
description,
|
||||
is_primary,
|
||||
scope_id,
|
||||
created_time,
|
||||
content='',
|
||||
);
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS auth_method_ai AFTER INSERT ON auth_method BEGIN
|
||||
INSERT INTO auth_method_fts(
|
||||
rowid, id, type, name, description, is_primary, scope_id, created_time
|
||||
) VALUES (
|
||||
new.rowid, new.id, new.type, new.name, new.description, new.is_primary, new.scope_id, new.created_time
|
||||
);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS auth_method_ad AFTER DELETE ON auth_method BEGIN
|
||||
INSERT INTO auth_method_fts(auth_method_fts, rowid, id, type, name, description, is_primary, scope_id, created_time)
|
||||
VALUES('delete', old.rowid, old.id, old.type, old.name, old.description, old.is_primary, old.scope_id, old.created_time);
|
||||
END;`;
|
||||
|
||||
const createHostCatalogTables = `
|
||||
CREATE TABLE IF NOT EXISTS host_catalog (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
type TEXT NOT NULL,
|
||||
name TEXT,
|
||||
description TEXT,
|
||||
plugin_name TEXT,
|
||||
scope_id TEXT NOT NULL,
|
||||
created_time TEXT NOT NULL,
|
||||
data TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_host_catalog_scope_id_created_time ON host_catalog(scope_id, created_time DESC);
|
||||
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS host_catalog_fts USING fts5(
|
||||
id,
|
||||
type,
|
||||
name,
|
||||
description,
|
||||
plugin_name,
|
||||
scope_id,
|
||||
created_time,
|
||||
content='',
|
||||
);
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS host_catalog_ai AFTER INSERT ON host_catalog BEGIN
|
||||
INSERT INTO host_catalog_fts(
|
||||
rowid, id, type, name, description, plugin_name, scope_id, created_time
|
||||
) VALUES (
|
||||
new.rowid, new.id, new.type, new.name, new.description, new.plugin_name, new.scope_id, new.created_time
|
||||
);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS host_catalog_ad AFTER DELETE ON host_catalog BEGIN
|
||||
INSERT INTO host_catalog_fts(host_catalog_fts, rowid, id, type, name, description, plugin_name, scope_id, created_time)
|
||||
VALUES('delete', old.rowid, old.id, old.type, old.name, old.description, old.plugin_name, old.scope_id, old.created_time);
|
||||
END;`;
|
||||
|
||||
const createSessionRecordingTables = `
|
||||
CREATE TABLE IF NOT EXISTS session_recording (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
type TEXT NOT NULL,
|
||||
state TEXT,
|
||||
start_time TEXT,
|
||||
end_time TEXT,
|
||||
duration TEXT,
|
||||
scope_id TEXT NOT NULL,
|
||||
user_id TEXT,
|
||||
user_name TEXT,
|
||||
target_id TEXT,
|
||||
target_name TEXT,
|
||||
target_scope_id TEXT,
|
||||
target_scope_name TEXT,
|
||||
created_time TEXT NOT NULL,
|
||||
data TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_session_recording_created_time ON session_recording(created_time DESC);
|
||||
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS session_recording_fts USING fts5(
|
||||
id,
|
||||
type,
|
||||
state,
|
||||
start_time,
|
||||
end_time,
|
||||
duration,
|
||||
scope_id,
|
||||
user_id,
|
||||
user_name,
|
||||
target_id,
|
||||
target_name,
|
||||
target_scope_id,
|
||||
target_scope_name,
|
||||
created_time,
|
||||
content='',
|
||||
);
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS session_recording_ai AFTER INSERT ON session_recording BEGIN
|
||||
INSERT INTO session_recording_fts(
|
||||
rowid, id, type, state, start_time, end_time, duration, scope_id, user_id, user_name, target_id, target_name, target_scope_id, target_scope_name, created_time
|
||||
) VALUES (
|
||||
new.rowid, new.id, new.type, new.state, new.start_time, new.end_time, new.duration, new.scope_id, new.user_id, new.user_name, new.target_id, new.target_name, new.target_scope_id, new.target_scope_name, new.created_time
|
||||
);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS session_recording_ad AFTER DELETE ON session_recording BEGIN
|
||||
INSERT INTO session_recording_fts(session_recording_fts, rowid, id, type, state, start_time, end_time, duration, scope_id, user_id, user_name, target_id, target_name, target_scope_id, target_scope_name, created_time)
|
||||
VALUES('delete', old.rowid, old.id, old.type, old.state, old.start_time, old.end_time, old.duration, old.scope_id, old.user_id, old.user_name, old.target_id, old.target_name, old.target_scope_id, old.target_scope_name, old.created_time);
|
||||
END;`;
|
||||
|
||||
const createSessionTables = `
|
||||
CREATE TABLE IF NOT EXISTS session (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
type TEXT,
|
||||
status TEXT,
|
||||
endpoint TEXT,
|
||||
target_id TEXT,
|
||||
user_id TEXT,
|
||||
scope_id TEXT NOT NULL,
|
||||
created_time TEXT NOT NULL,
|
||||
data TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_session_scope_id_created_time ON session(scope_id, created_time DESC);
|
||||
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS session_fts USING fts5(
|
||||
id,
|
||||
type,
|
||||
status,
|
||||
endpoint,
|
||||
target_id,
|
||||
user_id,
|
||||
scope_id,
|
||||
created_time,
|
||||
content='',
|
||||
);
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS session_ai AFTER INSERT ON session BEGIN
|
||||
INSERT INTO session_fts(
|
||||
rowid, id, type, status, endpoint, target_id, user_id, scope_id, created_time
|
||||
) VALUES (
|
||||
new.rowid, new.id, new.type, new.status, new.endpoint, new.target_id, new.user_id, new.scope_id, new.created_time
|
||||
);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS session_ad AFTER DELETE ON session BEGIN
|
||||
INSERT INTO session_fts(session_fts, rowid, id, type, status, endpoint, target_id, user_id, scope_id, created_time)
|
||||
VALUES('delete', old.rowid, old.id, old.type, old.status, old.endpoint, old.target_id, old.user_id, old.scope_id, old.created_time);
|
||||
END;`;
|
||||
|
||||
export const CREATE_TABLES = (version) => `
|
||||
BEGIN;
|
||||
|
||||
-- This ensures multiple triggers can activate in the scenario of using REPLACE INTO
|
||||
PRAGMA recursive_triggers = true;
|
||||
PRAGMA user_version = ${version};
|
||||
|
||||
CREATE TABLE IF NOT EXISTS token (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
token TEXT NOT NULL
|
||||
);
|
||||
|
||||
${createTargetTables}
|
||||
${createAliasTables}
|
||||
${createGroupTables}
|
||||
${createRoleTables}
|
||||
${createUserTables}
|
||||
${createCredentialStoreTables}
|
||||
${createScopeTables}
|
||||
${createAuthMethodTables}
|
||||
${createHostCatalogTables}
|
||||
${createSessionRecordingTables}
|
||||
${createSessionTables}
|
||||
|
||||
COMMIT;`;
|
||||
|
||||
export const INSERT_STATEMENTS = (resource, items, modelMapping) => {
|
||||
if (resource === 'token') {
|
||||
return `REPLACE INTO token (id, token) VALUES ${items.map(() => '(?, ?)').join(', ')};`;
|
||||
}
|
||||
|
||||
if (!modelMapping?.[resource]) {
|
||||
throw new Error(`modelMapping is required for ${resource} insertions.`);
|
||||
}
|
||||
|
||||
const columns = Object.keys(modelMapping[resource]);
|
||||
// Extra column for the data column
|
||||
const numColumns = Array(columns.length + 1);
|
||||
const placeholders = `(${numColumns.fill('?').join(', ')})`;
|
||||
|
||||
return `REPLACE INTO "${underscore(resource)}" (${columns.join(', ')}, data) VALUES ${items.map(() => placeholders).join(', ')};`;
|
||||
};
|
||||
|
||||
export const DELETE_STATEMENT = (resource, ids) => {
|
||||
const baseDeleteStatement = `DELETE FROM "${underscore(resource)}"`;
|
||||
return ids?.length > 0
|
||||
? `${baseDeleteStatement} WHERE id IN (${ids.map(() => '?').join(',')})`
|
||||
: baseDeleteStatement;
|
||||
};
|
||||
@@ -0,0 +1,269 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
let clientChannel = null;
|
||||
let commonChannel = null;
|
||||
const requestsInFlight = new Map();
|
||||
|
||||
export default (serviceName, target, onProviderChange) => {
|
||||
commonChannel = new BroadcastChannel(
|
||||
`shared-service-common-channel-${serviceName}`,
|
||||
);
|
||||
|
||||
let { promise: ready, resolve: readyResolve } = Promise.withResolvers();
|
||||
const status = {
|
||||
ready,
|
||||
isServiceProvider: false,
|
||||
};
|
||||
|
||||
const proxy = getProxy(target, status);
|
||||
|
||||
// This lock is used to ensure that only one tab can become the provider with an indefinite lock.
|
||||
navigator.locks.request(serviceName, { mode: 'exclusive' }, async () => {
|
||||
// If a client was promoted to provider, reset the ready status and resolver
|
||||
if (readyResolve === null) {
|
||||
const { promise: newReady, resolve: newResolve } =
|
||||
Promise.withResolvers();
|
||||
status.ready = newReady;
|
||||
readyResolve = newResolve;
|
||||
}
|
||||
|
||||
status.isServiceProvider = true;
|
||||
await onBecomeProvider({
|
||||
serviceName,
|
||||
onProviderChange,
|
||||
target,
|
||||
status,
|
||||
});
|
||||
readyResolve();
|
||||
readyResolve = null;
|
||||
await new Promise(() => {});
|
||||
});
|
||||
|
||||
(async () => {
|
||||
let providerExists = false;
|
||||
|
||||
// Make sure a lock was acquired which means we have a provider
|
||||
while (!providerExists) {
|
||||
const locks = await navigator.locks.query();
|
||||
providerExists = locks.held?.some((lock) => lock.name === serviceName);
|
||||
}
|
||||
|
||||
if (!status.isServiceProvider) {
|
||||
await onNotProvider({
|
||||
serviceName,
|
||||
onProviderChange,
|
||||
status,
|
||||
});
|
||||
readyResolve();
|
||||
readyResolve = null;
|
||||
}
|
||||
})();
|
||||
|
||||
return { proxy, status };
|
||||
};
|
||||
|
||||
const getClientBroadcastChannelName = (clientId, serviceName) =>
|
||||
`${clientId}-${serviceName}`;
|
||||
|
||||
// Generate a random ID for the tab to use as a lock name to hold indefinitely until the tab is closed
|
||||
const getClientId = () => {
|
||||
const clientId = uuidv4();
|
||||
|
||||
// Keep the lock until this context is destroyed (which means the tab was closed).
|
||||
navigator.locks.request(clientId, { mode: 'exclusive' }, async () => {
|
||||
await new Promise(() => {});
|
||||
});
|
||||
|
||||
return clientId;
|
||||
};
|
||||
|
||||
// Sends a message to the provider to call the method on behalf of a client.
|
||||
const requestFromProvider = (uuid, resolve, reject, method, args) => {
|
||||
const onRequestListener = getOnRequestListener(uuid, resolve, reject);
|
||||
|
||||
// Listen for a response to know we finished and remove from the list of requests in flight
|
||||
clientChannel?.addEventListener('message', onRequestListener);
|
||||
clientChannel?.postMessage({
|
||||
id: uuid,
|
||||
type: 'request',
|
||||
method,
|
||||
args,
|
||||
});
|
||||
};
|
||||
|
||||
// Return the listener used for responding to requests. Only listens for response messages.
|
||||
const getOnRequestListener = (uuid, resolve, reject) => {
|
||||
const listener = ({ data }) => {
|
||||
const { id, type } = data;
|
||||
if (id !== uuid || type === 'request') {
|
||||
return;
|
||||
}
|
||||
|
||||
requestsInFlight.delete(uuid);
|
||||
clientChannel?.removeEventListener('message', listener);
|
||||
|
||||
if (data.error) {
|
||||
reject(data.error);
|
||||
}
|
||||
|
||||
resolve(data.result);
|
||||
};
|
||||
|
||||
return listener;
|
||||
};
|
||||
|
||||
// When we become a client instead of a provider we do two things:
|
||||
// 1. Register ourselves with the provider and wait for a response that says we registered.
|
||||
// 2. Setup an event listener to know when a provider has changed.
|
||||
const onNotProvider = async ({ serviceName, onProviderChange, status }) => {
|
||||
const clientId = getClientId();
|
||||
clientChannel = new BroadcastChannel(
|
||||
getClientBroadcastChannelName(clientId, serviceName),
|
||||
);
|
||||
|
||||
const register = async () =>
|
||||
new Promise((resolve) => {
|
||||
const onRegisteredListener = ({ data }) => {
|
||||
if (data.clientId === clientId && data.type === 'registered') {
|
||||
commonChannel.removeEventListener('message', onRegisteredListener);
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
|
||||
// Add an event listener to check for the registration response
|
||||
commonChannel.addEventListener('message', onRegisteredListener);
|
||||
commonChannel.postMessage({ type: 'register', clientId });
|
||||
});
|
||||
|
||||
commonChannel.addEventListener('message', async ({ data }) => {
|
||||
// Check if this was a provider change. If so and we're not the new provider, re-register.
|
||||
if (data.type === 'providerChange' && !status.isServiceProvider) {
|
||||
await onProviderChange?.(status.isServiceProvider);
|
||||
await register();
|
||||
|
||||
// If there were requests in flight when the provider was changed,
|
||||
// we need to resend them to the new provider.
|
||||
if (requestsInFlight.size > 0) {
|
||||
requestsInFlight.forEach(({ method, args, resolve, reject }, uuid) => {
|
||||
requestFromProvider(uuid, resolve, reject, method, args);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await register();
|
||||
};
|
||||
|
||||
// When we become the provider, we do two things:
|
||||
// 1. Set an event listener to check for clients trying to register with us. As part of registration,
|
||||
// we also setup a listener with the individual client to listen for requests.
|
||||
// 2. Send a message to all tabs that we've now become the provider
|
||||
const onBecomeProvider = async ({
|
||||
serviceName,
|
||||
onProviderChange,
|
||||
target,
|
||||
status,
|
||||
}) => {
|
||||
// Set an event listener for other tabs to register with the provider tab
|
||||
commonChannel.addEventListener('message', async (event) => {
|
||||
const { clientId, type } = event.data;
|
||||
if (type !== 'register') {
|
||||
return;
|
||||
}
|
||||
|
||||
const clientChannel = new BroadcastChannel(
|
||||
getClientBroadcastChannelName(clientId, serviceName),
|
||||
);
|
||||
|
||||
// Acquire another lock that immediately is waiting for the
|
||||
// initial lock on the client tab to be resolved to handle cleanup.
|
||||
navigator.locks.request(clientId, { mode: 'exclusive' }, async () => {
|
||||
// The client tab was closed, clean up.
|
||||
clientChannel.close();
|
||||
});
|
||||
|
||||
// Setup an event listener for communication between the provider and subscriber to respond to requests
|
||||
clientChannel.addEventListener('message', async ({ data }) => {
|
||||
if (data.type === 'response') {
|
||||
return;
|
||||
}
|
||||
const { method, args, id } = data;
|
||||
|
||||
let result, error;
|
||||
try {
|
||||
result = await target[method](...args);
|
||||
} catch (e) {
|
||||
// Error objects won't be properly serialized, we need to reconstruct an error like object
|
||||
error =
|
||||
e instanceof Error
|
||||
? Object.fromEntries(
|
||||
Object.getOwnPropertyNames(e).map((k) => [k, e[k]]),
|
||||
)
|
||||
: e;
|
||||
}
|
||||
|
||||
clientChannel.postMessage({
|
||||
id,
|
||||
type: 'response',
|
||||
result,
|
||||
error,
|
||||
method,
|
||||
});
|
||||
});
|
||||
|
||||
// Send back a message to the client that we finished registering
|
||||
commonChannel.postMessage({ type: 'registered', clientId, serviceName });
|
||||
});
|
||||
|
||||
await onProviderChange?.(status.isServiceProvider);
|
||||
commonChannel.postMessage({ type: 'providerChange', serviceName });
|
||||
|
||||
// If we became the new provider and there were requests in flight, request it directly again.
|
||||
if (requestsInFlight.size > 0) {
|
||||
await Promise.all(
|
||||
Array.from(
|
||||
requestsInFlight,
|
||||
async ([uuid, { method, args, resolve, reject }]) => {
|
||||
try {
|
||||
const result = await target[method](...args);
|
||||
resolve(result);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
} finally {
|
||||
requestsInFlight.delete(uuid);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Returns a proxy that we will have all caller methods go through.
|
||||
// If we're the provider, we just call the method directly. If we're not the provider, we'll
|
||||
// send the request through our established client channel to have the provider respond with the data.
|
||||
const getProxy = (target, status) =>
|
||||
new Proxy(target, {
|
||||
get: (target, method) => {
|
||||
if (method === 'then' || method === 'catch' || method === 'finally') {
|
||||
// Return undefined for these methods to allow promise chaining to work correctly
|
||||
return;
|
||||
}
|
||||
|
||||
return async (...args) => {
|
||||
if (status.isServiceProvider) {
|
||||
return await target[method](...args);
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const uuid = uuidv4();
|
||||
requestFromProvider(uuid, resolve, reject, method, args);
|
||||
requestsInFlight.set(uuid, { method, args, resolve, reject });
|
||||
});
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -3,4 +3,4 @@
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
export { default } from 'api/services/indexed-db';
|
||||
export { default } from 'api/services/sqlite';
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
const Plugin = require('broccoli-plugin');
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const esbuild = require('esbuild');
|
||||
|
||||
module.exports = class BuildWorkers extends Plugin {
|
||||
build() {
|
||||
this.inputPaths.forEach((inputPath) => {
|
||||
this.#buildWorkers(inputPath);
|
||||
});
|
||||
}
|
||||
|
||||
#buildWorkers(inputPath) {
|
||||
const workers = this.#getWorkers(inputPath);
|
||||
|
||||
const workerBuilder = this.#configureWorkerBuilder({
|
||||
isProduction: this.isProduction,
|
||||
buildDir: this.outputPath,
|
||||
});
|
||||
|
||||
Object.entries(workers).map(workerBuilder);
|
||||
}
|
||||
|
||||
#getWorkers(inputPath) {
|
||||
let workers = {};
|
||||
let dir = fs.readdirSync(inputPath);
|
||||
|
||||
dir
|
||||
.filter((name) => name.endsWith('-worker.js'))
|
||||
.forEach((name) => {
|
||||
workers[name] = path.join(inputPath, name);
|
||||
});
|
||||
|
||||
return workers;
|
||||
}
|
||||
|
||||
#configureWorkerBuilder({ isProduction, buildDir }) {
|
||||
return ([name, entryPath]) => {
|
||||
esbuild.buildSync({
|
||||
loader: { '.js': 'js' },
|
||||
entryPoints: [entryPath],
|
||||
bundle: true,
|
||||
outfile: path.join(buildDir, name),
|
||||
format: 'esm',
|
||||
minify: isProduction,
|
||||
sourcemap: !isProduction,
|
||||
});
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -5,6 +5,69 @@
|
||||
|
||||
'use strict';
|
||||
|
||||
const { WatchedDir } = require('broccoli-source');
|
||||
const funnel = require('broccoli-funnel');
|
||||
const merge = require('broccoli-merge-trees');
|
||||
const path = require('path');
|
||||
|
||||
const buildWorkers = require('./broccoli-plugins/build-workers');
|
||||
|
||||
const workerRoot = path.join(__dirname, 'addon', 'workers');
|
||||
|
||||
module.exports = {
|
||||
name: require('./package').name,
|
||||
|
||||
options: {
|
||||
// Don't automatically import sqlite, we need to manually control how it gets used
|
||||
autoImport: {
|
||||
exclude: ['@sqlite.org/sqlite-wasm'],
|
||||
},
|
||||
},
|
||||
|
||||
treeForAddon() {
|
||||
// Exclude anything in the workers folder from being bundled in the final
|
||||
// build as we're manually bundling the files ourselves below.
|
||||
const tree = this._super.treeForAddon.apply(this, arguments);
|
||||
return funnel(tree, {
|
||||
exclude: ['api/workers/**/*'],
|
||||
});
|
||||
},
|
||||
|
||||
treeForPublic(tree) {
|
||||
const env = this.parent?.app?.env ?? 'production';
|
||||
const { enableSqlite } = this.parent?.app?.options[this.name] ?? {};
|
||||
|
||||
// If sqlite is not enabled and we're not running a test directly in the addon,
|
||||
// we don't need to build the workers
|
||||
if (!enableSqlite && this.app?.env !== 'test') {
|
||||
return tree;
|
||||
}
|
||||
|
||||
// We need to process and build our workers separately from ember as these
|
||||
// workers are not part of the app and are not getting properly bundled otherwise
|
||||
let workerTree = new WatchedDir(workerRoot);
|
||||
workerTree = new buildWorkers([workerTree]);
|
||||
workerTree.isProduction = env === 'production';
|
||||
|
||||
const workers = funnel(workerTree, {
|
||||
destDir: 'workers',
|
||||
});
|
||||
|
||||
// Also include the sqlite wasm file directly to be placed next to the workers
|
||||
const sqliteWasmDirectoryPath = path.resolve(
|
||||
require.resolve('@sqlite.org/sqlite-wasm/sqlite3.wasm'),
|
||||
'..',
|
||||
);
|
||||
const wasmFile = funnel(sqliteWasmDirectoryPath, {
|
||||
files: ['sqlite3.wasm'],
|
||||
destDir: 'workers',
|
||||
});
|
||||
|
||||
const trees = [workers, wasmFile];
|
||||
if (tree) {
|
||||
trees.push(tree);
|
||||
}
|
||||
|
||||
return merge(trees);
|
||||
},
|
||||
};
|
||||
@@ -70,11 +70,16 @@ function routes() {
|
||||
let resultSet;
|
||||
// Default parent scope is global
|
||||
if (!scope_id) scope_id = 'global';
|
||||
// We do not return global scope to mimic our API.
|
||||
if (recursive && scope_id === 'global') {
|
||||
resultSet = scopes.all();
|
||||
resultSet = scopes.where((scope) => {
|
||||
return scope.scope ? scope.type !== 'global' : false;
|
||||
});
|
||||
} else {
|
||||
resultSet = scopes.where((scope) => {
|
||||
return scope.scope ? scope.scope.id === scope_id : false;
|
||||
return scope.scope
|
||||
? scope.scope.id === scope_id && scope.type !== 'global'
|
||||
: false;
|
||||
});
|
||||
}
|
||||
const filteredResultSet = resultSet.filter(makeBooleanFilter(filter));
|
||||
|
||||
@@ -10,6 +10,7 @@ import generateId from '../helpers/id';
|
||||
import {
|
||||
TYPE_CREDENTIAL_LIBRARY_VAULT_GENERIC,
|
||||
TYPE_CREDENTIAL_LIBRARY_VAULT_SSH_CERTIFICATE,
|
||||
TYPE_CREDENTIAL_LIBRARY_VAULT_LDAP,
|
||||
TYPES_CREDENTIAL_LIBRARY,
|
||||
options,
|
||||
} from 'api/models/credential-library';
|
||||
@@ -22,6 +23,9 @@ export default factory.extend({
|
||||
if (this.type === TYPE_CREDENTIAL_LIBRARY_VAULT_GENERIC) {
|
||||
return options.credential_types[i % options.credential_types.length];
|
||||
}
|
||||
if (this.type === TYPE_CREDENTIAL_LIBRARY_VAULT_LDAP) {
|
||||
return options.credential_types[2]; // Only supports username, password & domain credential type
|
||||
}
|
||||
},
|
||||
authorized_actions: () =>
|
||||
permissions.authorizedActionsFor('credential-library') || [
|
||||
@@ -45,6 +49,10 @@ export default factory.extend({
|
||||
key_bits: faker.number.int(999),
|
||||
path: faker.system.directoryPath(),
|
||||
};
|
||||
case TYPE_CREDENTIAL_LIBRARY_VAULT_LDAP:
|
||||
return {
|
||||
path: faker.system.directoryPath(),
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -58,7 +58,7 @@ export default factory.extend({
|
||||
const { scope, type } = credentialStore;
|
||||
switch (type) {
|
||||
case 'vault':
|
||||
server.createList('credential-library', 2, {
|
||||
server.createList('credential-library', 3, {
|
||||
scope,
|
||||
credentialStore,
|
||||
});
|
||||
|
||||
@@ -7,8 +7,14 @@ import factory from '../generated/factories/credential';
|
||||
import { faker } from '@faker-js/faker';
|
||||
import permissions from '../helpers/permissions';
|
||||
import generatedId from '../helpers/id';
|
||||
import {
|
||||
TYPES_CREDENTIAL,
|
||||
TYPE_CREDENTIAL_SSH_PRIVATE_KEY,
|
||||
TYPE_CREDENTIAL_USERNAME_PASSWORD,
|
||||
TYPE_CREDENTIAL_USERNAME_PASSWORD_DOMAIN,
|
||||
} from 'api/models/credential';
|
||||
|
||||
const types = ['username_password', 'ssh_private_key', 'json'];
|
||||
const types = [...TYPES_CREDENTIAL];
|
||||
|
||||
export default factory.extend({
|
||||
type: (i) => types[i % types.length],
|
||||
@@ -20,6 +26,8 @@ export default factory.extend({
|
||||
return generatedId('credup_');
|
||||
case 'json':
|
||||
return generatedId('credjson_');
|
||||
case 'username_password_domain':
|
||||
return generatedId('credupd_');
|
||||
}
|
||||
},
|
||||
authorized_actions: () =>
|
||||
@@ -30,8 +38,21 @@ export default factory.extend({
|
||||
'delete',
|
||||
],
|
||||
attributes() {
|
||||
return {
|
||||
username: faker.internet.userName(),
|
||||
};
|
||||
switch (this.type) {
|
||||
case TYPE_CREDENTIAL_USERNAME_PASSWORD:
|
||||
return { username: faker.internet.userName() };
|
||||
|
||||
case TYPE_CREDENTIAL_USERNAME_PASSWORD_DOMAIN:
|
||||
return {
|
||||
username: faker.internet.userName(),
|
||||
domain: faker.internet.domainName(),
|
||||
};
|
||||
|
||||
case TYPE_CREDENTIAL_SSH_PRIVATE_KEY:
|
||||
return { username: faker.internet.userName() };
|
||||
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -78,6 +78,15 @@ export default factory.extend({
|
||||
return name;
|
||||
},
|
||||
|
||||
scope() {
|
||||
if (this.type === 'global') {
|
||||
return {
|
||||
id: 'global',
|
||||
type: 'global',
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
withChildren: trait({
|
||||
afterCreate(record, server) {
|
||||
if (record.type === 'global') {
|
||||
|
||||
@@ -8,7 +8,16 @@ import { trait } from 'miragejs';
|
||||
import { faker } from '@faker-js/faker';
|
||||
import permissions from '../helpers/permissions';
|
||||
import generateId from '../helpers/id';
|
||||
import { TYPES_TARGET, TYPE_TARGET_SSH } from 'api/models/target';
|
||||
import {
|
||||
TYPES_TARGET,
|
||||
TYPE_TARGET_SSH,
|
||||
TYPE_TARGET_RDP,
|
||||
} from 'api/models/target';
|
||||
import {
|
||||
TYPE_CREDENTIAL_USERNAME_PASSWORD_DOMAIN,
|
||||
TYPE_CREDENTIAL_USERNAME_PASSWORD,
|
||||
TYPE_CREDENTIAL_JSON,
|
||||
} from 'api/models/credential';
|
||||
|
||||
const randomBoolean = (chance = 0.5) => Math.random() < chance;
|
||||
const randomFilter = () =>
|
||||
@@ -16,6 +25,35 @@ const randomFilter = () =>
|
||||
const hostSetChance = 0.3;
|
||||
const types = [...TYPES_TARGET];
|
||||
|
||||
/**
|
||||
* Helper to select items from a collection based on a condition.
|
||||
*/
|
||||
const selectItems = (collection, condition) =>
|
||||
collection
|
||||
.where(condition)
|
||||
.models.filter(() => randomBoolean())
|
||||
.map((item) => item.id);
|
||||
|
||||
/**
|
||||
* Helper to update target with selected credential sources and host sets.
|
||||
*/
|
||||
const updateTarget = (
|
||||
target,
|
||||
hostSets,
|
||||
isInjectable,
|
||||
injectedSources,
|
||||
brokeredSources,
|
||||
) => {
|
||||
const fieldsToUpdate = {
|
||||
hostSets,
|
||||
brokeredCredentialSourceIds: brokeredSources,
|
||||
};
|
||||
if (isInjectable) {
|
||||
fieldsToUpdate.injectedApplicationCredentialSourceIds = injectedSources;
|
||||
}
|
||||
target.update(fieldsToUpdate);
|
||||
};
|
||||
|
||||
export default factory.extend({
|
||||
authorized_actions: () =>
|
||||
permissions.authorizedActionsFor('target') || [
|
||||
@@ -45,29 +83,83 @@ export default factory.extend({
|
||||
*/
|
||||
withAssociations: trait({
|
||||
afterCreate(target, server) {
|
||||
let randomlySelectedHostSets,
|
||||
randomlySelectedCredentialLibraries,
|
||||
randomlySelectedCredentials;
|
||||
randomlySelectedHostSets = server.schema.hostSets
|
||||
const { scope } = target;
|
||||
|
||||
const randomlySelectedHostSets = server.schema.hostSets
|
||||
// BLERG: fun fact, for no reason at all, the element passed
|
||||
// into a where function is not a full model instance, as you might
|
||||
// expect at this level of abstraction, but appears to be a serialized
|
||||
// representation of the model instance. It's very confusing since
|
||||
// the result set of `where` _is a collection of model instances_.
|
||||
.where((hostSet) => hostSet.scopeId === target.scope.id)
|
||||
.where((hostSet) => hostSet.scopeId === scope.id)
|
||||
.models.filter(() => randomBoolean(hostSetChance));
|
||||
|
||||
randomlySelectedCredentialLibraries = server.schema.credentialLibraries
|
||||
.where(
|
||||
const randomlySelectedBrokeredCredentialSources = [
|
||||
...selectItems(
|
||||
server.schema.credentialLibraries,
|
||||
(credentialLibrary) => credentialLibrary.scopeId === target.scope.id,
|
||||
)
|
||||
.models.filter(() => randomBoolean())
|
||||
.map((cred) => cred.id);
|
||||
randomlySelectedCredentials = server.schema.credentials
|
||||
.where((credential) => credential.scopeId === target.scope.id)
|
||||
.models.filter(() => randomBoolean())
|
||||
.map((cred) => cred.id);
|
||||
),
|
||||
...selectItems(
|
||||
server.schema.credentials,
|
||||
(credential) => credential.scopeId === target.scope.id,
|
||||
),
|
||||
];
|
||||
|
||||
// Select compatible credential libraries and credentials for injection
|
||||
// Since not every credential or credential library can be injected, we filter them to mimic the expected behavior.
|
||||
const filteredCredentialLibraries = selectItems(
|
||||
server.schema.credentialLibraries,
|
||||
(cred) =>
|
||||
cred.scopeId === scope.id &&
|
||||
cred.credential_type !== TYPE_CREDENTIAL_USERNAME_PASSWORD_DOMAIN,
|
||||
);
|
||||
const filteredCredentials = selectItems(
|
||||
server.schema.credentials,
|
||||
(cred) =>
|
||||
cred.scopeId === scope.id &&
|
||||
![
|
||||
TYPE_CREDENTIAL_JSON,
|
||||
TYPE_CREDENTIAL_USERNAME_PASSWORD_DOMAIN,
|
||||
].includes(cred.type),
|
||||
);
|
||||
|
||||
// RDP specific credential libraries and credentials
|
||||
const filteredCredentialLibrariesForRDP = selectItems(
|
||||
server.schema.credentialLibraries,
|
||||
(cred) =>
|
||||
cred.scopeId === scope.id &&
|
||||
[
|
||||
TYPE_CREDENTIAL_USERNAME_PASSWORD_DOMAIN,
|
||||
TYPE_CREDENTIAL_USERNAME_PASSWORD,
|
||||
].includes(cred.credential_type),
|
||||
);
|
||||
const filteredCredentialsForRDP = selectItems(
|
||||
server.schema.credentials,
|
||||
(cred) =>
|
||||
cred.scopeId === scope.id &&
|
||||
[
|
||||
TYPE_CREDENTIAL_USERNAME_PASSWORD_DOMAIN,
|
||||
TYPE_CREDENTIAL_USERNAME_PASSWORD,
|
||||
].includes(cred.type),
|
||||
);
|
||||
|
||||
// Update target based on type
|
||||
const isInjectable =
|
||||
target.type === TYPE_TARGET_SSH || target.type === TYPE_TARGET_RDP;
|
||||
const injectedCredentialSources =
|
||||
target.type === TYPE_TARGET_RDP
|
||||
? [...filteredCredentialLibrariesForRDP, ...filteredCredentialsForRDP]
|
||||
: [...filteredCredentialLibraries, ...filteredCredentials];
|
||||
|
||||
updateTarget(
|
||||
target,
|
||||
randomlySelectedHostSets,
|
||||
isInjectable,
|
||||
injectedCredentialSources,
|
||||
randomlySelectedBrokeredCredentialSources,
|
||||
);
|
||||
|
||||
// Add storage bucket for SSH targets
|
||||
const storageBuckets = server.schema.storageBuckets.all().models;
|
||||
const randomlySelectedStorageBucket =
|
||||
storageBuckets.length === 0
|
||||
@@ -81,6 +173,7 @@ export default factory.extend({
|
||||
});
|
||||
}
|
||||
|
||||
// Add Aliases
|
||||
const aliases = server.schema.aliases.all().models;
|
||||
const randomlySelectedAliases =
|
||||
aliases.length === 0 ? undefined : faker.helpers.arrayElement(aliases);
|
||||
@@ -89,17 +182,6 @@ export default factory.extend({
|
||||
aliases: randomlySelectedAliases.aliases,
|
||||
});
|
||||
}
|
||||
target.update({
|
||||
hostSets: randomlySelectedHostSets,
|
||||
brokeredCredentialSourceIds: [
|
||||
...randomlySelectedCredentialLibraries,
|
||||
...randomlySelectedCredentials,
|
||||
],
|
||||
injectedApplicationCredentialSourceIds: [
|
||||
...randomlySelectedCredentialLibraries,
|
||||
...randomlySelectedCredentials,
|
||||
],
|
||||
});
|
||||
},
|
||||
}),
|
||||
|
||||
|
||||
@@ -3,4 +3,6 @@
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
export { default } from 'rose/helpers/map-type-to-color';
|
||||
import Model from './base';
|
||||
|
||||
export default Model.extend({});
|
||||
@@ -122,8 +122,8 @@ export default function (server) {
|
||||
{ scope, type: 'plugin' },
|
||||
'withChildren',
|
||||
);
|
||||
server.createList('credential-store', 3, { scope }, 'withAssociations');
|
||||
server.createList('target', 2, { scope }, 'withAssociations');
|
||||
server.createList('credential-store', 5, { scope }, 'withAssociations');
|
||||
server.createList('target', 4, { scope }, 'withAssociations');
|
||||
server.create('target', { scope, address: '0.0.0.0' });
|
||||
// Sessions have target data. Create it after targets.
|
||||
server.createList('session', 4, { scope }, 'withAssociations');
|
||||
|
||||
@@ -246,6 +246,8 @@ export default function initializeMockIPC(server, config) {
|
||||
}
|
||||
pauseClientAgent() {}
|
||||
resumeClientAgent() {}
|
||||
hasRunningSessions() {}
|
||||
stopAll() {}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+10
-3
@@ -27,15 +27,22 @@
|
||||
"dependencies": {
|
||||
"@babel/core": "^7.26.10",
|
||||
"@ember/string": "^4.0.0",
|
||||
"dexie": "^3.2.4",
|
||||
"@sqlite.org/sqlite-wasm": "^3.49.1-build4",
|
||||
"broccoli-funnel": "^3.0.8",
|
||||
"broccoli-merge-trees": "^4.2.0",
|
||||
"broccoli-plugin": "^4.0.7",
|
||||
"broccoli-source": "^3.0.1",
|
||||
"ember-auto-import": "^2.10.0",
|
||||
"ember-can": "^4.2.0",
|
||||
"ember-cli-babel": "^8.2.0",
|
||||
"ember-cli-htmlbars": "^6.3.0",
|
||||
"ember-cli-mirage": "^3.0.3",
|
||||
"ember-data": "~5.3.12",
|
||||
"esbuild": "^0.25.4",
|
||||
"miragejs": "^0.1.48",
|
||||
"tracked-built-ins": "^3.1.1"
|
||||
"promise-worker-bi": "^5.0.1",
|
||||
"tracked-built-ins": "^4.0.0",
|
||||
"uuid": "^11.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/eslint-parser": "^7.25.1",
|
||||
@@ -45,7 +52,7 @@
|
||||
"@ember/test-waiters": "^4.1.0",
|
||||
"@embroider/test-setup": "^4.0.0",
|
||||
"@faker-js/faker": "^8.0.2",
|
||||
"@glimmer/component": "^1.1.2",
|
||||
"@glimmer/component": "^2.0.0",
|
||||
"@glimmer/tracking": "^1.1.2",
|
||||
"broccoli-asset-rev": "^3.0.0",
|
||||
"concurrently": "^9.1.0",
|
||||
|
||||
@@ -1,487 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import { module, test } from 'qunit';
|
||||
import { setupTest } from 'ember-qunit';
|
||||
import IndexedDbHandler from 'api/handlers/indexed-db-handler';
|
||||
import RequestManager from '@ember-data/request';
|
||||
import setupMirage from 'ember-cli-mirage/test-support/setup-mirage';
|
||||
import { faker } from '@faker-js/faker';
|
||||
import sinon from 'sinon';
|
||||
import { assert } from '@ember/debug';
|
||||
import { setupIndexedDb } from 'api/test-support/helpers/indexed-db';
|
||||
import TargetModel from 'api/models/target';
|
||||
|
||||
function createPaginatedResponseHandler(mirageRecords, { pageSize }) {
|
||||
assert('pageSize is required', pageSize);
|
||||
const pagesCount = mirageRecords.length / pageSize;
|
||||
|
||||
const results = [];
|
||||
for (let i = 0; i < pagesCount; i++) {
|
||||
const items = mirageRecords.slice(i * pageSize, (i + 1) * pageSize);
|
||||
const isLastPage = i === pagesCount - 1;
|
||||
const list_token = `list-token-${faker.string.nanoid({ min: 5, max: 5 })}`;
|
||||
|
||||
results.push({
|
||||
items,
|
||||
list_token,
|
||||
response_type: !isLastPage ? 'delta' : 'complete',
|
||||
});
|
||||
}
|
||||
|
||||
function nextResult(listToken) {
|
||||
const index = results.findIndex(
|
||||
({ list_token }) => list_token === listToken,
|
||||
);
|
||||
const nextResult = index === -1 ? null : results[index + 1];
|
||||
return nextResult;
|
||||
}
|
||||
|
||||
// mirage handler compatible with paginated responses
|
||||
return function (_schema, request) {
|
||||
const listToken = request.queryParams.list_token;
|
||||
if (!listToken) {
|
||||
return results[0];
|
||||
}
|
||||
|
||||
const result = nextResult(listToken);
|
||||
|
||||
if (!result) {
|
||||
return new Response(
|
||||
404,
|
||||
{},
|
||||
`No results found for list token "${listToken}"`,
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
}
|
||||
|
||||
module('Unit | Handler | indexed-db-handler', function (hooks) {
|
||||
setupTest(hooks);
|
||||
setupIndexedDb(hooks);
|
||||
setupMirage(hooks);
|
||||
|
||||
let manager,
|
||||
indexeddbHandler,
|
||||
store,
|
||||
applicationAdapter,
|
||||
indexedbHandlerSpy,
|
||||
applicationAdapterSpy;
|
||||
hooks.beforeEach(async function setupIndexHandler() {
|
||||
store = this.owner.lookup('service:store');
|
||||
manager = new RequestManager();
|
||||
indexeddbHandler = new IndexedDbHandler(store);
|
||||
applicationAdapter = store.adapterFor('application');
|
||||
|
||||
// spy on all handler and application adapter methods
|
||||
indexedbHandlerSpy = sinon.spy(indexeddbHandler);
|
||||
applicationAdapterSpy = sinon.spy(applicationAdapter);
|
||||
|
||||
store.requestManager = manager;
|
||||
manager.use([indexeddbHandler]);
|
||||
});
|
||||
|
||||
module('fetching and batch loading', function (hooks) {
|
||||
// `testBatchLimit` and `serverResultPageLimit` are intentionally difference
|
||||
// so that multiple calls to the server are required to fetch a single batch,
|
||||
// and these multiples can be asserted in the tests individually
|
||||
const testBatchLimit = 10;
|
||||
const serverResultPageLimit = 5;
|
||||
let aliases, aliasResponseHandlerSpy;
|
||||
|
||||
hooks.beforeEach(function setupMirageData() {
|
||||
indexeddbHandler.batchLimit = testBatchLimit;
|
||||
|
||||
aliases = this.server.createList('alias', 50);
|
||||
|
||||
const aliasResponseHandler = createPaginatedResponseHandler(
|
||||
this.server.schema.aliases.all().models,
|
||||
{ pageSize: serverResultPageLimit },
|
||||
);
|
||||
|
||||
aliasResponseHandlerSpy = sinon.spy(aliasResponseHandler);
|
||||
this.server.get('aliases', aliasResponseHandlerSpy);
|
||||
});
|
||||
|
||||
test('it returns all data for a resource from the server', async function (assert) {
|
||||
const results = await store.query('alias', {});
|
||||
|
||||
assert.strictEqual(
|
||||
aliases.length,
|
||||
results.length,
|
||||
'all records are returned',
|
||||
);
|
||||
|
||||
const aliasesSortedByCreatedTime = [...aliases].sort(
|
||||
(a, b) => new Date(b.created_time) - new Date(a.created_time),
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
aliasesSortedByCreatedTime.map(({ name }) => name),
|
||||
results.map(({ name }) => name),
|
||||
'`name` property matches between the returned results and the mirage records sorted by created_time',
|
||||
);
|
||||
});
|
||||
|
||||
test('supports batching requests and writes to indexedDB', async function (assert) {
|
||||
await store.query('alias', {});
|
||||
|
||||
assert.strictEqual(
|
||||
indexedbHandlerSpy.writeToIndexedDb.callCount,
|
||||
aliases.length / testBatchLimit,
|
||||
'handler #writeToIndexedDb is called once for each batch',
|
||||
);
|
||||
|
||||
assert.strictEqual(
|
||||
applicationAdapterSpy.query.callCount,
|
||||
aliases.length / testBatchLimit,
|
||||
'application adapter #query is called once for each batch',
|
||||
);
|
||||
|
||||
assert.strictEqual(
|
||||
aliasResponseHandlerSpy.callCount,
|
||||
aliases.length / serverResultPageLimit,
|
||||
'server handler should be called once for each page',
|
||||
);
|
||||
});
|
||||
|
||||
test('it loads from indexeddb only and does not fetch from api, when peekIndexedDB option is used', async function (assert) {
|
||||
const callCounts = {
|
||||
get writeToIndexedDb() {
|
||||
return indexedbHandlerSpy.writeToIndexedDb.callCount;
|
||||
},
|
||||
get aliasResponseHandler() {
|
||||
return aliasResponseHandlerSpy.callCount;
|
||||
},
|
||||
};
|
||||
|
||||
// before first query, no data has been fetched or written to indexeddb
|
||||
// the call counts should therefore be 0
|
||||
assert.strictEqual(callCounts.aliasResponseHandler, 0);
|
||||
assert.strictEqual(callCounts.writeToIndexedDb, 0);
|
||||
|
||||
const results = await store.query('alias', {});
|
||||
assert.strictEqual(results.length, 50);
|
||||
|
||||
// call counts expected after first query
|
||||
const expectedWriteToIndexedDbCallCount = 5;
|
||||
const expectedResponseHandlerCallCount = 10;
|
||||
|
||||
// first `store.query()` calls api and writes batches to indexededdb
|
||||
assert.strictEqual(
|
||||
callCounts.aliasResponseHandler,
|
||||
expectedResponseHandlerCallCount,
|
||||
);
|
||||
assert.strictEqual(
|
||||
callCounts.writeToIndexedDb,
|
||||
expectedWriteToIndexedDbCallCount,
|
||||
);
|
||||
|
||||
await store.query('alias', {}, { peekIndexedDB: true });
|
||||
assert.strictEqual(results.length, 50);
|
||||
|
||||
// second run does not increase call counts to api or writes to indexeddb
|
||||
assert.strictEqual(
|
||||
callCounts.aliasResponseHandler,
|
||||
expectedResponseHandlerCallCount,
|
||||
);
|
||||
assert.strictEqual(
|
||||
callCounts.writeToIndexedDb,
|
||||
expectedWriteToIndexedDbCallCount,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
module('sorting', function () {
|
||||
test('it sorts by descending `created_time` by default', async function (assert) {
|
||||
const target2020 = this.server.create('target', {
|
||||
name: 'Target 2020',
|
||||
created_time: '2020-01-01T00:00:00Z',
|
||||
});
|
||||
|
||||
const target2021 = this.server.create('target', {
|
||||
name: 'Target 2021',
|
||||
created_time: '2021-01-01T00:00:00Z',
|
||||
});
|
||||
|
||||
const target2022 = this.server.create('target', {
|
||||
name: 'Target 2022',
|
||||
created_time: '2022-01-01T00:00:00Z',
|
||||
});
|
||||
|
||||
const target2023 = this.server.create('target', {
|
||||
name: 'Target 2023',
|
||||
created_time: '2023-01-01T00:00:00Z',
|
||||
});
|
||||
|
||||
const shuffledTargets = faker.helpers.shuffle([
|
||||
target2020,
|
||||
target2021,
|
||||
target2022,
|
||||
target2023,
|
||||
]);
|
||||
|
||||
this.server.get(
|
||||
'targets',
|
||||
createPaginatedResponseHandler(shuffledTargets, {
|
||||
pageSize: 1,
|
||||
}),
|
||||
);
|
||||
|
||||
const results = await store.query('target', {});
|
||||
assert.deepEqual(
|
||||
results.map(({ name }) => name),
|
||||
['Target 2023', 'Target 2022', 'Target 2021', 'Target 2020'],
|
||||
);
|
||||
});
|
||||
|
||||
test('it sorts by ascending (default) `name` using string sorting', async function (assert) {
|
||||
const target001 = this.server.create('target', { name: 'Target 001' });
|
||||
const target002 = this.server.create('target', { name: 'Target 002' });
|
||||
const target003 = this.server.create('target', { name: 'Target 003' });
|
||||
const target004 = this.server.create('target', { name: 'Target 004' });
|
||||
const target005 = this.server.create('target', { name: 'Target 005' });
|
||||
|
||||
const shuffledTargets = faker.helpers.shuffle([
|
||||
target001,
|
||||
target002,
|
||||
target003,
|
||||
target004,
|
||||
target005,
|
||||
]);
|
||||
|
||||
this.server.get(
|
||||
'targets',
|
||||
createPaginatedResponseHandler(shuffledTargets, {
|
||||
pageSize: 1,
|
||||
}),
|
||||
);
|
||||
|
||||
const results = await store.query('target', {
|
||||
query: { sort: { attribute: 'name' } },
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
results.map(({ name }) => name),
|
||||
['Target 001', 'Target 002', 'Target 003', 'Target 004', 'Target 005'],
|
||||
);
|
||||
});
|
||||
|
||||
test('it sorts by descending, then ascending `name` using string sorting', async function (assert) {
|
||||
const alias001 = this.server.create('alias', { name: 'Alias 001' });
|
||||
const alias002 = this.server.create('alias', { name: 'Alias 002' });
|
||||
const alias003 = this.server.create('alias', { name: 'Alias 003' });
|
||||
const alias004 = this.server.create('alias', { name: 'Alias 004' });
|
||||
|
||||
const shuffledAliases = faker.helpers.shuffle([
|
||||
alias001,
|
||||
alias002,
|
||||
alias003,
|
||||
alias004,
|
||||
]);
|
||||
|
||||
this.server.get(
|
||||
'aliases',
|
||||
createPaginatedResponseHandler(shuffledAliases, {
|
||||
pageSize: 1,
|
||||
}),
|
||||
);
|
||||
|
||||
const resultsDesc = await store.query('alias', {
|
||||
query: { sort: { attribute: 'name', direction: 'desc' } },
|
||||
});
|
||||
const resultsAsc = await store.query('alias', {
|
||||
query: { sort: { attribute: 'name', direction: 'asc' } },
|
||||
});
|
||||
assert.deepEqual(
|
||||
resultsDesc.map(({ name }) => name),
|
||||
['Alias 004', 'Alias 003', 'Alias 002', 'Alias 001'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
resultsAsc.map(({ name }) => name),
|
||||
['Alias 001', 'Alias 002', 'Alias 003', 'Alias 004'],
|
||||
);
|
||||
});
|
||||
|
||||
test('it sorts by descending, then ascending `name` using custom string sorting', async function (assert) {
|
||||
const alias1 = this.server.create('alias', { name: 'alias1' });
|
||||
const alias2 = this.server.create('alias', { name: 'alias2' });
|
||||
const alias3 = this.server.create('alias', { name: 'alias3' });
|
||||
const alias4 = this.server.create('alias', { name: 'alias4' });
|
||||
|
||||
const sortName = (recordA, recordB) => {
|
||||
const nameMap = {
|
||||
alias3: 'Alpha',
|
||||
alias4: 'Beta',
|
||||
alias1: 'Delta',
|
||||
alias2: 'Epsilon',
|
||||
};
|
||||
return String(nameMap[recordA.attributes.name]).localeCompare(
|
||||
String(nameMap[recordB.attributes.name]),
|
||||
);
|
||||
};
|
||||
|
||||
const shuffledAliases = faker.helpers.shuffle([
|
||||
alias1,
|
||||
alias2,
|
||||
alias3,
|
||||
alias4,
|
||||
]);
|
||||
|
||||
this.server.get(
|
||||
'aliases',
|
||||
createPaginatedResponseHandler(shuffledAliases, {
|
||||
pageSize: 1,
|
||||
}),
|
||||
);
|
||||
|
||||
const resultsDesc = await store.query('alias', {
|
||||
query: { sort: { sortFunction: sortName, direction: 'desc' } },
|
||||
});
|
||||
const resultsAsc = await store.query('alias', {
|
||||
query: { sort: { sortFunction: sortName, direction: 'asc' } },
|
||||
});
|
||||
assert.deepEqual(
|
||||
resultsDesc.map(({ name }) => name),
|
||||
['alias2', 'alias1', 'alias4', 'alias3'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
resultsAsc.map(({ name }) => name),
|
||||
['alias3', 'alias4', 'alias1', 'alias2'],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
module('option: pushToStore', function (hooks) {
|
||||
let mirageTargets;
|
||||
|
||||
hooks.beforeEach(function () {
|
||||
mirageTargets = this.server.createList('target', 5);
|
||||
});
|
||||
|
||||
test('it pushes fetched results to the store by default', async function (assert) {
|
||||
this.server.get(
|
||||
'targets',
|
||||
createPaginatedResponseHandler(mirageTargets, {
|
||||
pageSize: 1,
|
||||
}),
|
||||
);
|
||||
|
||||
assert.strictEqual(mirageTargets.length, 5);
|
||||
assert.strictEqual(store.peekAll('target').length, 0);
|
||||
const results = await store.query('target', {});
|
||||
assert.strictEqual(results.length, 5);
|
||||
assert.ok(
|
||||
results.every((result) => result instanceof TargetModel),
|
||||
'results are ember data models',
|
||||
);
|
||||
assert.strictEqual(store.peekAll('target').length, 5);
|
||||
});
|
||||
|
||||
test('it pushes fethed results to the store when pushToStore is true', async function (assert) {
|
||||
this.server.get(
|
||||
'targets',
|
||||
createPaginatedResponseHandler(mirageTargets, {
|
||||
pageSize: 1,
|
||||
}),
|
||||
);
|
||||
|
||||
assert.strictEqual(mirageTargets.length, 5);
|
||||
assert.strictEqual(store.peekAll('target').length, 0);
|
||||
const results = await store.query('target', {}, { pushToStore: true });
|
||||
assert.strictEqual(results.length, 5);
|
||||
assert.ok(
|
||||
results.every((result) => result instanceof TargetModel),
|
||||
'results are ember data models',
|
||||
);
|
||||
assert.strictEqual(store.peekAll('target').length, 5);
|
||||
});
|
||||
|
||||
test('it does not push fetched results to the store when pushToStore is false', async function (assert) {
|
||||
this.server.get(
|
||||
'targets',
|
||||
createPaginatedResponseHandler(mirageTargets, {
|
||||
pageSize: 1,
|
||||
}),
|
||||
);
|
||||
|
||||
assert.strictEqual(mirageTargets.length, 5);
|
||||
assert.strictEqual(store.peekAll('target').length, 0);
|
||||
const results = await store.query('target', {}, { pushToStore: false });
|
||||
assert.strictEqual(results.length, 5);
|
||||
assert.ok(
|
||||
results.every((result) => !(result instanceof TargetModel)),
|
||||
'results are not ember data models',
|
||||
);
|
||||
assert.strictEqual(store.peekAll('target').length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
test('it supports searching', async function (assert) {
|
||||
this.server.create('target', {
|
||||
name: 'Magical Target',
|
||||
description: 'A target',
|
||||
});
|
||||
|
||||
this.server.create('target', {
|
||||
name: 'Target 2',
|
||||
description: 'Target with magic',
|
||||
});
|
||||
|
||||
this.server.createList('target', 4);
|
||||
|
||||
this.server.get(
|
||||
'targets',
|
||||
createPaginatedResponseHandler(this.server.schema.targets.all().models, {
|
||||
pageSize: 1,
|
||||
}),
|
||||
);
|
||||
|
||||
const results = await store.query('target', {
|
||||
query: { search: 'magic' },
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
results
|
||||
// search results aren't returning in a consistent order so
|
||||
// the sorting is used to be able to assert
|
||||
.sort((a, b) => (a.name > b.name ? 1 : -1))
|
||||
.map(({ name, description }) => ({ name, description })),
|
||||
[
|
||||
{
|
||||
description: 'A target',
|
||||
name: 'Magical Target',
|
||||
},
|
||||
{
|
||||
description: 'Target with magic',
|
||||
name: 'Target 2',
|
||||
},
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('it supports filtering', async function (assert) {
|
||||
this.server.create('target', {
|
||||
name: 'specific-target',
|
||||
});
|
||||
|
||||
this.server.createList('target', 5);
|
||||
|
||||
this.server.get(
|
||||
'targets',
|
||||
createPaginatedResponseHandler(this.server.schema.targets.all().models, {
|
||||
pageSize: 1,
|
||||
}),
|
||||
);
|
||||
|
||||
const results = await store.query('target', {
|
||||
query: { filters: { name: [{ equals: 'specific-target' }] } },
|
||||
});
|
||||
|
||||
assert.strictEqual(results[0].name, 'specific-target');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,526 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import { module, test } from 'qunit';
|
||||
import { setupTest } from 'ember-qunit';
|
||||
import SqliteHandler from 'api/handlers/sqlite-handler';
|
||||
import RequestManager from '@ember-data/request';
|
||||
import setupMirage from 'ember-cli-mirage/test-support/setup-mirage';
|
||||
import { faker } from '@faker-js/faker';
|
||||
import sinon from 'sinon';
|
||||
import { assert } from '@ember/debug';
|
||||
import { setupSqlite } from 'api/test-support/helpers/sqlite';
|
||||
import TargetModel from 'api/models/target';
|
||||
|
||||
function createPaginatedResponseHandler(mirageRecords, { pageSize }) {
|
||||
assert('pageSize is required', pageSize);
|
||||
assert('mirage server on `this` is required', this.server);
|
||||
const pagesCount = mirageRecords.length / pageSize;
|
||||
|
||||
const results = [];
|
||||
for (let i = 0; i < pagesCount; i++) {
|
||||
const items = mirageRecords.slice(i * pageSize, (i + 1) * pageSize);
|
||||
const isLastPage = i === pagesCount - 1;
|
||||
const list_token = `list-token-${faker.string.nanoid({ min: 5, max: 5 })}`;
|
||||
|
||||
results.push({
|
||||
items: items.map((model) =>
|
||||
this.server.serializerOrRegistry.serialize(model),
|
||||
),
|
||||
list_token,
|
||||
response_type: !isLastPage ? 'delta' : 'complete',
|
||||
});
|
||||
}
|
||||
|
||||
function nextResult(listToken) {
|
||||
const index = results.findIndex(
|
||||
({ list_token }) => list_token === listToken,
|
||||
);
|
||||
const nextResult = index === -1 ? null : results[index + 1];
|
||||
return nextResult;
|
||||
}
|
||||
|
||||
// mirage handler compatible with paginated responses
|
||||
return function (_schema, request) {
|
||||
const listToken = request.queryParams.list_token;
|
||||
if (!listToken) {
|
||||
return results[0];
|
||||
}
|
||||
|
||||
const result = nextResult(listToken);
|
||||
|
||||
if (!result) {
|
||||
return new Response(
|
||||
404,
|
||||
{},
|
||||
`No results found for list token "${listToken}"`,
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
}
|
||||
|
||||
module('Unit | Handler | sqlite-handler', function (hooks) {
|
||||
setupTest(hooks);
|
||||
setupMirage(hooks);
|
||||
setupSqlite(hooks);
|
||||
|
||||
let manager,
|
||||
scope,
|
||||
sqliteHandler,
|
||||
store,
|
||||
applicationAdapter,
|
||||
sqliteHandlerSpy;
|
||||
hooks.beforeEach(async function setupSqliteHandler() {
|
||||
store = this.owner.lookup('service:store');
|
||||
manager = new RequestManager();
|
||||
sqliteHandler = new SqliteHandler(store);
|
||||
applicationAdapter = store.adapterFor('application');
|
||||
|
||||
// spy on handler methods
|
||||
sqliteHandlerSpy = sinon.spy(sqliteHandler);
|
||||
|
||||
store.requestManager = manager;
|
||||
manager.use([sqliteHandler]);
|
||||
|
||||
scope = this.server.create('scope', {
|
||||
type: 'project',
|
||||
name: 'Project Scope',
|
||||
});
|
||||
});
|
||||
|
||||
hooks.afterEach(async function cleanUpSqliteHandler() {
|
||||
sinon.restore();
|
||||
});
|
||||
|
||||
module('fetching and batch loading', function (hooks) {
|
||||
// `testBatchLimit` and `serverResultPageLimit` are intentionally difference
|
||||
// so that multiple calls to the server are required to fetch a single batch,
|
||||
// and these multiples can be asserted in the tests individually
|
||||
const testBatchLimit = 10;
|
||||
const serverResultPageLimit = 5;
|
||||
let targets, targetsResponseHandlerSpy;
|
||||
|
||||
hooks.beforeEach(function setupMirageData() {
|
||||
sqliteHandler.batchLimit = testBatchLimit;
|
||||
|
||||
targets = this.server.createList('target', 50, { scope });
|
||||
|
||||
// This is to just add a number to each target name to guarantee uniqueness in
|
||||
// case there was a duplicate rather than increasing the number of words generated by faker.
|
||||
// Because we have a UNIQUE constraint on target names and we do UPSERTS, if there's a
|
||||
// duplicate name we'll end up replacing it in the DB and then have fewer targets
|
||||
// than expected and flaky tests.
|
||||
for (let i = 0; i < targets.length; i++) {
|
||||
targets[i].name = targets[i].name + i;
|
||||
}
|
||||
|
||||
const targetResponseHandler = createPaginatedResponseHandler.call(
|
||||
this,
|
||||
this.server.schema.targets.all().models,
|
||||
{ pageSize: serverResultPageLimit },
|
||||
);
|
||||
|
||||
targetsResponseHandlerSpy = sinon.spy(targetResponseHandler);
|
||||
this.server.get('targets', targetsResponseHandlerSpy);
|
||||
});
|
||||
|
||||
test('it returns all data for a resource from the server', async function (assert) {
|
||||
const results = await store.query('target', {});
|
||||
|
||||
assert.strictEqual(
|
||||
targets.length,
|
||||
results.length,
|
||||
'all records are returned',
|
||||
);
|
||||
});
|
||||
|
||||
test('supports batching requests and writes to SQLite', async function (assert) {
|
||||
const applicationAdapterSpy = sinon.spy(applicationAdapter);
|
||||
|
||||
await store.query('target', {});
|
||||
|
||||
assert.strictEqual(
|
||||
sqliteHandlerSpy.writeToDb.callCount,
|
||||
targets.length / testBatchLimit,
|
||||
'handler #writeToDb is called once for each batch',
|
||||
);
|
||||
|
||||
assert.strictEqual(
|
||||
applicationAdapterSpy.query.callCount,
|
||||
targets.length / testBatchLimit,
|
||||
'application adapter #query is called once for each batch',
|
||||
);
|
||||
|
||||
assert.strictEqual(
|
||||
targetsResponseHandlerSpy.callCount,
|
||||
targets.length / serverResultPageLimit,
|
||||
'server handler should be called once for each page',
|
||||
);
|
||||
});
|
||||
|
||||
test('it loads from SQLite only and does not fetch from api, when peekDb option is used', async function (assert) {
|
||||
const callCounts = {
|
||||
get writeToDb() {
|
||||
return sqliteHandlerSpy.writeToDb.callCount;
|
||||
},
|
||||
get targetResponseHandler() {
|
||||
return targetsResponseHandlerSpy.callCount;
|
||||
},
|
||||
};
|
||||
|
||||
// before first query, no data has been fetched or written to sqlite
|
||||
// the call counts should therefore be 0
|
||||
assert.strictEqual(callCounts.targetResponseHandler, 0);
|
||||
assert.strictEqual(callCounts.writeToDb, 0);
|
||||
|
||||
const results = await store.query('target', {});
|
||||
assert.strictEqual(results.length, 50);
|
||||
|
||||
// call counts expected after first query
|
||||
const expectedWriteToSqliteCallCount = 5;
|
||||
const expectedResponseHandlerCallCount = 10;
|
||||
|
||||
// first `store.query()` calls api and writes batches to sqlite
|
||||
assert.strictEqual(
|
||||
callCounts.targetResponseHandler,
|
||||
expectedResponseHandlerCallCount,
|
||||
);
|
||||
assert.strictEqual(callCounts.writeToDb, expectedWriteToSqliteCallCount);
|
||||
|
||||
await store.query('target', {}, { peekDb: true });
|
||||
assert.strictEqual(results.length, 50);
|
||||
|
||||
// second run does not increase call counts to api or writes to sqlite
|
||||
assert.strictEqual(
|
||||
callCounts.targetResponseHandler,
|
||||
expectedResponseHandlerCallCount,
|
||||
);
|
||||
assert.strictEqual(callCounts.writeToDb, expectedWriteToSqliteCallCount);
|
||||
});
|
||||
|
||||
test('it stores and retrieves tokens when storeToken is true (default)', async function (assert) {
|
||||
const sqliteSpy = sinon.spy(sqliteHandler.sqlite);
|
||||
|
||||
await store.query('target', {});
|
||||
|
||||
assert.strictEqual(
|
||||
sqliteSpy.insertResource.withArgs('token').callCount,
|
||||
targets.length / testBatchLimit,
|
||||
);
|
||||
assert.strictEqual(
|
||||
sqliteSpy.insertResource.withArgs('target').callCount,
|
||||
targets.length / testBatchLimit,
|
||||
);
|
||||
});
|
||||
|
||||
test('it clears database and does not store tokens when storeToken is false', async function (assert) {
|
||||
const sqliteSpy = sinon.spy(sqliteHandler.sqlite);
|
||||
|
||||
await store.query('target', {}, { storeToken: false });
|
||||
|
||||
// Targets are all deleted one time
|
||||
assert.ok(sqliteSpy.deleteResource.calledOnceWithExactly('target'));
|
||||
|
||||
assert.ok(sqliteSpy.insertResource.withArgs('token').notCalled);
|
||||
assert.strictEqual(
|
||||
sqliteSpy.insertResource.withArgs('target').callCount,
|
||||
targets.length / testBatchLimit,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
module('option: pushToStore', function (hooks) {
|
||||
let mirageTargets;
|
||||
|
||||
hooks.beforeEach(function () {
|
||||
mirageTargets = this.server.createList('target', 5, { scope });
|
||||
});
|
||||
|
||||
test('it pushes fetched results to the store by default', async function (assert) {
|
||||
this.server.get(
|
||||
'targets',
|
||||
createPaginatedResponseHandler.call(this, mirageTargets, {
|
||||
pageSize: 1,
|
||||
}),
|
||||
);
|
||||
|
||||
assert.strictEqual(mirageTargets.length, 5);
|
||||
assert.strictEqual(store.peekAll('target').length, 0);
|
||||
const results = await store.query('target', {});
|
||||
assert.strictEqual(results.length, 5);
|
||||
assert.ok(
|
||||
results.every((result) => result instanceof TargetModel),
|
||||
'results are ember data models',
|
||||
);
|
||||
assert.strictEqual(store.peekAll('target').length, 5);
|
||||
});
|
||||
|
||||
test('it pushes fetched results to the store when pushToStore is true', async function (assert) {
|
||||
this.server.get(
|
||||
'targets',
|
||||
createPaginatedResponseHandler.call(this, mirageTargets, {
|
||||
pageSize: 1,
|
||||
}),
|
||||
);
|
||||
|
||||
assert.strictEqual(mirageTargets.length, 5);
|
||||
assert.strictEqual(store.peekAll('target').length, 0);
|
||||
const results = await store.query('target', {}, { pushToStore: true });
|
||||
assert.strictEqual(results.length, 5);
|
||||
assert.ok(
|
||||
results.every((result) => result instanceof TargetModel),
|
||||
'results are ember data models',
|
||||
);
|
||||
assert.strictEqual(store.peekAll('target').length, 5);
|
||||
});
|
||||
|
||||
test('it does not push fetched results to the store when pushToStore is false', async function (assert) {
|
||||
this.server.get(
|
||||
'targets',
|
||||
createPaginatedResponseHandler.call(this, mirageTargets, {
|
||||
pageSize: 1,
|
||||
}),
|
||||
);
|
||||
|
||||
assert.strictEqual(mirageTargets.length, 5);
|
||||
assert.strictEqual(store.peekAll('target').length, 0);
|
||||
const results = await store.query('target', {}, { pushToStore: false });
|
||||
assert.strictEqual(results.length, 5);
|
||||
assert.ok(
|
||||
results.every((result) => !(result instanceof TargetModel)),
|
||||
'results are not ember data models',
|
||||
);
|
||||
assert.strictEqual(store.peekAll('target').length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
module('sorting', function () {
|
||||
test('it sorts by ascending (default) `name` using string sorting', async function (assert) {
|
||||
const target001 = this.server.create('target', {
|
||||
name: 'Target 001',
|
||||
scope,
|
||||
});
|
||||
const target002 = this.server.create('target', {
|
||||
name: 'Target 002',
|
||||
scope,
|
||||
});
|
||||
const target003 = this.server.create('target', {
|
||||
name: 'Target 003',
|
||||
scope,
|
||||
});
|
||||
const target004 = this.server.create('target', {
|
||||
name: 'Target 004',
|
||||
scope,
|
||||
});
|
||||
const target005 = this.server.create('target', {
|
||||
name: 'Target 005',
|
||||
scope,
|
||||
});
|
||||
|
||||
const shuffledTargets = faker.helpers.shuffle([
|
||||
target001,
|
||||
target002,
|
||||
target003,
|
||||
target004,
|
||||
target005,
|
||||
]);
|
||||
|
||||
this.server.get(
|
||||
'targets',
|
||||
createPaginatedResponseHandler.call(this, shuffledTargets, {
|
||||
pageSize: 1,
|
||||
}),
|
||||
);
|
||||
|
||||
const results = await store.query('target', {
|
||||
query: { sort: { attributes: ['name'] } },
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
results.map(({ name }) => name),
|
||||
['Target 001', 'Target 002', 'Target 003', 'Target 004', 'Target 005'],
|
||||
);
|
||||
});
|
||||
|
||||
test('it sorts by descending, then ascending `name` using string sorting', async function (assert) {
|
||||
const target001 = this.server.create('target', {
|
||||
name: 'Target 001',
|
||||
scope,
|
||||
});
|
||||
const target002 = this.server.create('target', {
|
||||
name: 'Target 002',
|
||||
scope,
|
||||
});
|
||||
const target003 = this.server.create('target', {
|
||||
name: 'Target 003',
|
||||
scope,
|
||||
});
|
||||
const target004 = this.server.create('target', {
|
||||
name: 'Target 004',
|
||||
scope,
|
||||
});
|
||||
|
||||
const shuffledTargets = faker.helpers.shuffle([
|
||||
target001,
|
||||
target002,
|
||||
target003,
|
||||
target004,
|
||||
]);
|
||||
|
||||
this.server.get(
|
||||
'targets',
|
||||
createPaginatedResponseHandler.call(this, shuffledTargets, {
|
||||
pageSize: 1,
|
||||
}),
|
||||
);
|
||||
|
||||
const resultsDesc = await store.query('target', {
|
||||
query: { sort: { attributes: ['name'], direction: 'desc' } },
|
||||
});
|
||||
const resultsAsc = await store.query('target', {
|
||||
query: { sort: { attributes: ['name'], direction: 'asc' } },
|
||||
});
|
||||
assert.deepEqual(
|
||||
resultsDesc.map(({ name }) => name),
|
||||
['Target 004', 'Target 003', 'Target 002', 'Target 001'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
resultsAsc.map(({ name }) => name),
|
||||
['Target 001', 'Target 002', 'Target 003', 'Target 004'],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('it supports searching', async function (assert) {
|
||||
this.server.create('target', {
|
||||
name: 'Magical Target',
|
||||
description: 'A target',
|
||||
scope,
|
||||
});
|
||||
|
||||
this.server.create('target', {
|
||||
name: 'Target 2',
|
||||
description: 'Target with magic',
|
||||
scope,
|
||||
});
|
||||
|
||||
this.server.createList('target', 4, { scope });
|
||||
|
||||
this.server.get(
|
||||
'targets',
|
||||
createPaginatedResponseHandler.call(
|
||||
this,
|
||||
this.server.schema.targets.all().models,
|
||||
{
|
||||
pageSize: 1,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
const results = await store.query('target', {
|
||||
query: { search: 'magic' },
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
results
|
||||
// search results aren't returning in a consistent order so
|
||||
// the sorting is used to be able to assert
|
||||
.sort((a, b) => (a.name > b.name ? 1 : -1))
|
||||
.map(({ name, description }) => ({ name, description })),
|
||||
[
|
||||
{
|
||||
description: 'A target',
|
||||
name: 'Magical Target',
|
||||
},
|
||||
{
|
||||
description: 'Target with magic',
|
||||
name: 'Target 2',
|
||||
},
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('it supports filtering', async function (assert) {
|
||||
this.server.create('target', {
|
||||
name: 'specific-target',
|
||||
scope,
|
||||
});
|
||||
|
||||
this.server.createList('target', 5, { scope });
|
||||
|
||||
this.server.get(
|
||||
'targets',
|
||||
createPaginatedResponseHandler.call(
|
||||
this,
|
||||
this.server.schema.targets.all().models,
|
||||
{
|
||||
pageSize: 1,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
const results = await store.query('target', {
|
||||
query: { filters: { name: [{ equals: 'specific-target' }] } },
|
||||
});
|
||||
|
||||
assert.strictEqual(results[0].name, 'specific-target');
|
||||
});
|
||||
|
||||
test('it retries query when invalid list token error occurs', async function (assert) {
|
||||
let targets = this.server.createList('target', 5, { scope });
|
||||
|
||||
const sqliteSpy = sinon.spy(sqliteHandler.sqlite);
|
||||
|
||||
// Mock the first query to fail with invalid list token error but return data as normal otherwise
|
||||
sinon
|
||||
.stub(applicationAdapter, 'query')
|
||||
.callThrough()
|
||||
.onFirstCall()
|
||||
.throws(() => {
|
||||
const error = new Error('Invalid list token');
|
||||
error.errors = [
|
||||
{
|
||||
status: 400,
|
||||
code: 'invalid list token',
|
||||
},
|
||||
];
|
||||
return error;
|
||||
});
|
||||
|
||||
this.server.get(
|
||||
'targets',
|
||||
createPaginatedResponseHandler.call(this, targets, {
|
||||
pageSize: 5,
|
||||
}),
|
||||
);
|
||||
|
||||
// Insert tokens to simulate existing state
|
||||
await sqliteHandler.sqlite.insertResource('token', [
|
||||
['target-123', 'invalid key'],
|
||||
['target-321', 'valid key'],
|
||||
['session-123', 'session key'],
|
||||
]);
|
||||
|
||||
const results = await store.query('target', {});
|
||||
|
||||
assert.ok(sqliteHandlerSpy.retryQueryFailure.calledOnce);
|
||||
|
||||
assert.ok(sqliteSpy.deleteResource.calledWithExactly('target'));
|
||||
assert.ok(
|
||||
sqliteSpy.deleteResource.calledWithExactly('token', [
|
||||
'target-123',
|
||||
'target-321',
|
||||
]),
|
||||
);
|
||||
|
||||
// Verify the query eventually succeeded
|
||||
assert.strictEqual(results.length, 5);
|
||||
|
||||
// Verify query was called twice (initial fail + retry)
|
||||
assert.ok(applicationAdapter.query.calledTwice);
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,11 @@
|
||||
|
||||
import { module, test } from 'qunit';
|
||||
import { setupTest } from 'ember-qunit';
|
||||
import { TYPE_CREDENTIAL_LIBRARY_VAULT_GENERIC } from 'api/models/credential-library';
|
||||
import {
|
||||
TYPE_CREDENTIAL_LIBRARY_VAULT_GENERIC,
|
||||
TYPE_CREDENTIAL_LIBRARY_VAULT_SSH_CERTIFICATE,
|
||||
TYPE_CREDENTIAL_LIBRARY_VAULT_LDAP,
|
||||
} from 'api/models/credential-library';
|
||||
|
||||
module('Unit | Model | credential library', function (hooks) {
|
||||
setupTest(hooks);
|
||||
@@ -22,10 +26,18 @@ module('Unit | Model | credential library', function (hooks) {
|
||||
type: TYPE_CREDENTIAL_LIBRARY_VAULT_GENERIC,
|
||||
});
|
||||
const modelB = store.createRecord('credential-library', {
|
||||
type: TYPE_CREDENTIAL_LIBRARY_VAULT_SSH_CERTIFICATE,
|
||||
});
|
||||
const modelC = store.createRecord('credential-library', {
|
||||
type: TYPE_CREDENTIAL_LIBRARY_VAULT_LDAP,
|
||||
});
|
||||
const modelD = store.createRecord('credential-library', {
|
||||
type: 'unknown',
|
||||
});
|
||||
|
||||
assert.false(modelA.isUnknown);
|
||||
assert.true(modelB.isUnknown);
|
||||
assert.false(modelB.isUnknown);
|
||||
assert.false(modelC.isUnknown);
|
||||
assert.true(modelD.isUnknown);
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,13 @@
|
||||
|
||||
import { module, test } from 'qunit';
|
||||
import { setupTest } from 'ember-qunit';
|
||||
import {
|
||||
TYPE_CREDENTIAL_JSON,
|
||||
TYPE_CREDENTIAL_SSH_PRIVATE_KEY,
|
||||
TYPE_CREDENTIAL_USERNAME_PASSWORD_DOMAIN,
|
||||
TYPE_CREDENTIAL_USERNAME_PASSWORD
|
||||
} from 'api/models/credential';
|
||||
|
||||
|
||||
module('Unit | Model | credential', function (hooks) {
|
||||
setupTest(hooks);
|
||||
@@ -28,12 +35,12 @@ module('Unit | Model | credential', function (hooks) {
|
||||
|
||||
test('it has isJSON property and returns the expected values', async function (assert) {
|
||||
const store = this.owner.lookup('service:store');
|
||||
const modelA = store.createRecord('credential', { type: 'json' });
|
||||
const modelA = store.createRecord('credential', { type: TYPE_CREDENTIAL_JSON });
|
||||
const modelB = store.createRecord('credential', {
|
||||
type: 'username_password',
|
||||
type: TYPE_CREDENTIAL_USERNAME_PASSWORD,
|
||||
});
|
||||
const modelC = store.createRecord('credential', {
|
||||
type: 'ssh_private_key',
|
||||
type: TYPE_CREDENTIAL_SSH_PRIVATE_KEY,
|
||||
});
|
||||
|
||||
assert.true(modelA.isJSON);
|
||||
@@ -44,15 +51,17 @@ module('Unit | Model | credential', function (hooks) {
|
||||
test('it has isUnknown property and returns the expected values', async function (assert) {
|
||||
const store = this.owner.lookup('service:store');
|
||||
const modelA = store.createRecord('credential', {
|
||||
type: 'username_password',
|
||||
type: TYPE_CREDENTIAL_USERNAME_PASSWORD
|
||||
});
|
||||
const modelB = store.createRecord('credential', {
|
||||
type: 'ssh_private_key',
|
||||
type: TYPE_CREDENTIAL_SSH_PRIVATE_KEY
|
||||
});
|
||||
const modelC = store.createRecord('credential', { type: 'unknown' });
|
||||
const modelD = store.createRecord('credential', { type: TYPE_CREDENTIAL_USERNAME_PASSWORD_DOMAIN });
|
||||
|
||||
assert.false(modelA.isUnknown);
|
||||
assert.false(modelB.isUnknown);
|
||||
assert.true(modelC.isUnknown);
|
||||
assert.false(modelD.isUnknown);
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,11 @@
|
||||
import { module, test } from 'qunit';
|
||||
import { setupTest } from 'ember-qunit';
|
||||
import setupMirage from 'ember-cli-mirage/test-support/setup-mirage';
|
||||
import { TYPE_TARGET_TCP, TYPE_TARGET_SSH } from 'api/models/target';
|
||||
import {
|
||||
TYPE_TARGET_TCP,
|
||||
TYPE_TARGET_SSH,
|
||||
TYPE_TARGET_RDP,
|
||||
} from 'api/models/target';
|
||||
|
||||
module('Unit | Model | target', function (hooks) {
|
||||
setupTest(hooks);
|
||||
@@ -386,6 +390,7 @@ module('Unit | Model | target', function (hooks) {
|
||||
});
|
||||
assert.true(modelSSH.isSSH);
|
||||
assert.false(modelSSH.isTCP);
|
||||
assert.false(modelSSH.isRDP);
|
||||
});
|
||||
|
||||
test('it has isTCP property and returns the expected values', function (assert) {
|
||||
@@ -395,5 +400,16 @@ module('Unit | Model | target', function (hooks) {
|
||||
});
|
||||
assert.true(modelTCP.isTCP);
|
||||
assert.false(modelTCP.isSSH);
|
||||
assert.false(modelTCP.isRDP);
|
||||
});
|
||||
|
||||
test('it has isRDP property and returns the expected values', function (assert) {
|
||||
const store = this.owner.lookup('service:store');
|
||||
const modelRDP = store.createRecord('target', {
|
||||
type: TYPE_TARGET_RDP,
|
||||
});
|
||||
assert.true(modelRDP.isRDP);
|
||||
assert.false(modelRDP.isSSH);
|
||||
assert.false(modelRDP.isTCP);
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import { setupTest } from 'ember-qunit';
|
||||
import {
|
||||
TYPE_CREDENTIAL_LIBRARY_VAULT_GENERIC,
|
||||
TYPE_CREDENTIAL_LIBRARY_VAULT_SSH_CERTIFICATE,
|
||||
TYPE_CREDENTIAL_LIBRARY_VAULT_LDAP,
|
||||
} from 'api/models/credential-library';
|
||||
|
||||
module('Unit | Serializer | credential library', function (hooks) {
|
||||
@@ -109,6 +110,7 @@ module('Unit | Serializer | credential library', function (hooks) {
|
||||
let serializedVaultGenericRecord = vaultGenericRecord.serialize();
|
||||
let serializedVaultSSHCertificateRecord =
|
||||
vaultSSHCertificateRecord.serialize();
|
||||
|
||||
assert.deepEqual(serializedVaultGenericRecord, {
|
||||
attributes: {
|
||||
path: null,
|
||||
@@ -279,4 +281,82 @@ module('Unit | Serializer | credential library', function (hooks) {
|
||||
version: 1,
|
||||
});
|
||||
});
|
||||
|
||||
test('it serializes vault-ldap correctly on create', function (assert) {
|
||||
const store = this.owner.lookup('service:store');
|
||||
const serializer = store.serializerFor('credential-library');
|
||||
const record = store.createRecord('credential-library', {
|
||||
type: TYPE_CREDENTIAL_LIBRARY_VAULT_LDAP,
|
||||
name: 'Name',
|
||||
description: 'Description',
|
||||
path: '/vault/path',
|
||||
version: 1,
|
||||
credential_type: 'username_password_domain',
|
||||
credential_mapping_overrides: [
|
||||
{ key: 'username_attribute', value: 'user' },
|
||||
{ key: 'domain_attribute', value: 'domain' },
|
||||
],
|
||||
});
|
||||
const snapshot = record._createSnapshot();
|
||||
const serializedRecord = serializer.serialize(snapshot);
|
||||
|
||||
assert.deepEqual(serializedRecord, {
|
||||
type: TYPE_CREDENTIAL_LIBRARY_VAULT_LDAP,
|
||||
credential_store_id: null,
|
||||
name: 'Name',
|
||||
description: 'Description',
|
||||
version: 1,
|
||||
credential_type: 'username_password_domain',
|
||||
credential_mapping_overrides: {
|
||||
username_attribute: 'user',
|
||||
domain_attribute: 'domain',
|
||||
},
|
||||
attributes: {
|
||||
path: '/vault/path',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('it serializes vault-ldap correctly on update', function (assert) {
|
||||
const store = this.owner.lookup('service:store');
|
||||
const serializer = store.serializerFor('credential-library');
|
||||
store.push({
|
||||
data: {
|
||||
id: '1',
|
||||
type: 'credential-library',
|
||||
attributes: {
|
||||
type: TYPE_CREDENTIAL_LIBRARY_VAULT_LDAP,
|
||||
name: 'Name',
|
||||
description: 'Description',
|
||||
path: '/vault/path',
|
||||
version: 1,
|
||||
credential_type: 'username_password_domain',
|
||||
credential_mapping_overrides: [
|
||||
{ key: 'username_attribute', value: 'user' },
|
||||
{ key: 'domain_attribute', value: 'domain' },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
const record = store.peekRecord('credential-library', '1');
|
||||
const snapshot = record._createSnapshot();
|
||||
const serializedRecord = serializer.serialize(snapshot);
|
||||
|
||||
assert.deepEqual(serializedRecord, {
|
||||
type: TYPE_CREDENTIAL_LIBRARY_VAULT_LDAP,
|
||||
credential_store_id: null,
|
||||
name: 'Name',
|
||||
description: 'Description',
|
||||
version: 1,
|
||||
credential_type: 'username_password_domain',
|
||||
credential_mapping_overrides: {
|
||||
username_attribute: 'user',
|
||||
domain_attribute: 'domain',
|
||||
password_attribute: null,
|
||||
},
|
||||
attributes: {
|
||||
path: '/vault/path',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,21 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import { module, test } from 'qunit';
|
||||
import { setupTest } from 'dummy/tests/helpers';
|
||||
import Service from '@ember/service';
|
||||
|
||||
class SessionService extends Service {}
|
||||
|
||||
module('Unit | Service | indexed-db', function (hooks) {
|
||||
setupTest(hooks);
|
||||
|
||||
test('it exists', function (assert) {
|
||||
this.owner.register('service:session', SessionService);
|
||||
|
||||
let service = this.owner.lookup('service:indexed-db');
|
||||
assert.ok(service);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import { module, test } from 'qunit';
|
||||
import { setupTest } from 'dummy/tests/helpers';
|
||||
import { setupSqlite } from 'api/test-support/helpers/sqlite';
|
||||
import { modelMapping, searchTables } from 'api/services/sqlite';
|
||||
import { underscore } from '@ember/string';
|
||||
|
||||
const supportedModels = Object.keys(modelMapping);
|
||||
const supportedFtsTables = [...searchTables];
|
||||
|
||||
module('Unit | Service | sqlite', function (hooks) {
|
||||
setupTest(hooks);
|
||||
setupSqlite(hooks);
|
||||
|
||||
test.each(
|
||||
'Mapping matches table columns',
|
||||
supportedModels,
|
||||
async function (assert, resource) {
|
||||
const service = this.owner.lookup('service:sqlite');
|
||||
|
||||
const tableColumns = await service.fetchResource({
|
||||
sql: `SELECT * FROM pragma_table_info('${underscore(resource)}')`,
|
||||
});
|
||||
|
||||
const columnNames = tableColumns.map((column) => column.name);
|
||||
|
||||
const modelKeys = Object.keys(modelMapping[resource]);
|
||||
|
||||
// Last column should always be "data" which we don't map
|
||||
assert.strictEqual(columnNames[columnNames.length - 1], 'data');
|
||||
assert.deepEqual(columnNames.slice(0, -1), modelKeys);
|
||||
},
|
||||
);
|
||||
|
||||
test.each(
|
||||
'Mapping matches fts table columns',
|
||||
supportedFtsTables,
|
||||
async function (assert, resource) {
|
||||
const service = this.owner.lookup('service:sqlite');
|
||||
|
||||
const tableColumns = await service.fetchResource({
|
||||
sql: `SELECT * FROM pragma_table_info('${underscore(resource)}_fts')`,
|
||||
});
|
||||
|
||||
const columnNames = tableColumns.map((column) => column.name);
|
||||
const modelKeys = Object.keys(modelMapping[resource]);
|
||||
|
||||
assert.deepEqual(columnNames, modelKeys);
|
||||
},
|
||||
);
|
||||
|
||||
test('it inserts and deletes large amounts of rows', async function (assert) {
|
||||
const service = this.owner.lookup('service:sqlite');
|
||||
|
||||
const targets = [];
|
||||
const numberOfTargets = 35_000;
|
||||
|
||||
for (let i = 0; i < numberOfTargets; i++) {
|
||||
targets.push([
|
||||
`ttcp_${i}`,
|
||||
'tcp',
|
||||
`name${i}`,
|
||||
null,
|
||||
'',
|
||||
'p_SWAXCPqUPr',
|
||||
'2025-07-17T21:39:11.171Z',
|
||||
'test data',
|
||||
]);
|
||||
}
|
||||
await service.insertResource('target', targets);
|
||||
|
||||
let count = await service.fetchResource({
|
||||
sql: `SELECT count(*) as count FROM target`,
|
||||
});
|
||||
let total = count[0];
|
||||
|
||||
assert.strictEqual(total.count, numberOfTargets);
|
||||
|
||||
const ids = targets.map((target) => target[0]);
|
||||
await service.deleteResource('target', ids);
|
||||
|
||||
count = await service.fetchResource({
|
||||
sql: `SELECT count(*) as count FROM target`,
|
||||
});
|
||||
total = count[0];
|
||||
|
||||
assert.strictEqual(total.count, 0);
|
||||
});
|
||||
});
|
||||
@@ -1,609 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import { queryIndexedDb } from 'api/utils/indexed-db-query';
|
||||
import { module, test } from 'qunit';
|
||||
import { setupTest } from 'ember-qunit';
|
||||
import setupMirage from 'ember-cli-mirage/test-support/setup-mirage';
|
||||
import { setupIndexedDb } from 'api/test-support/helpers/indexed-db';
|
||||
import { pluralize } from 'ember-inflector';
|
||||
import { camelize } from '@ember/string';
|
||||
import { TYPE_TARGET_TCP, TYPE_TARGET_SSH } from 'api/models/target';
|
||||
import { faker } from '@faker-js/faker';
|
||||
|
||||
const seedIndexDb = async (resource, store, indexedDb, server) => {
|
||||
const resourceData =
|
||||
server.schema[pluralize(camelize(resource))].all().models;
|
||||
const serializedData = resourceData.map((resource) =>
|
||||
JSON.parse(JSON.stringify(server.serializerOrRegistry.serialize(resource))),
|
||||
);
|
||||
const serializer = store.serializerFor(resource);
|
||||
const schema = store.modelFor(resource);
|
||||
const normalizedPayload = serializer.normalizeResponse(
|
||||
store,
|
||||
schema,
|
||||
{ items: serializedData },
|
||||
null,
|
||||
'query',
|
||||
);
|
||||
|
||||
await indexedDb.db[resource].bulkPut(
|
||||
normalizedPayload.data.map((datum) =>
|
||||
indexedDb.normalizeData({
|
||||
data: datum,
|
||||
cleanData: true,
|
||||
schema,
|
||||
serializer,
|
||||
}),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
module('Unit | Utility | indexed-db-query', function (hooks) {
|
||||
setupTest(hooks);
|
||||
setupMirage(hooks);
|
||||
setupIndexedDb(hooks);
|
||||
|
||||
let indexedDb;
|
||||
let store;
|
||||
|
||||
let scope1;
|
||||
let scope2;
|
||||
const resource = 'target';
|
||||
|
||||
hooks.beforeEach(function () {
|
||||
indexedDb = this.owner.lookup('service:indexed-db');
|
||||
store = this.owner.lookup('service:store');
|
||||
scope1 = this.server.create('scope', {
|
||||
id: 'p_123',
|
||||
type: 'project',
|
||||
});
|
||||
scope2 = this.server.create('scope', {
|
||||
id: 'p_456',
|
||||
type: 'project',
|
||||
});
|
||||
this.server.createList(resource, 5, {
|
||||
type: TYPE_TARGET_SSH,
|
||||
scopeId: scope1.id,
|
||||
});
|
||||
this.server.createList(resource, 5, {
|
||||
type: TYPE_TARGET_TCP,
|
||||
scopeId: scope2.id,
|
||||
});
|
||||
});
|
||||
|
||||
test('it filters by single field', async function (assert) {
|
||||
await seedIndexDb(resource, store, indexedDb, this.server);
|
||||
const query = {
|
||||
filters: {
|
||||
type: [{ equals: TYPE_TARGET_SSH }],
|
||||
},
|
||||
};
|
||||
|
||||
const result = await queryIndexedDb(indexedDb.db, resource, query);
|
||||
|
||||
assert.strictEqual(result.length, 5);
|
||||
});
|
||||
|
||||
test('it filters by single field with "and" logicalOperator', async function (assert) {
|
||||
await seedIndexDb(resource, store, indexedDb, this.server);
|
||||
const query = {
|
||||
filters: {
|
||||
type: {
|
||||
logicalOperator: 'and',
|
||||
values: [{ equals: TYPE_TARGET_SSH }],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = await queryIndexedDb(indexedDb.db, resource, query);
|
||||
|
||||
assert.strictEqual(result.length, 5);
|
||||
});
|
||||
|
||||
test('it filters by single field with "or" logicalOperator', async function (assert) {
|
||||
await seedIndexDb(resource, store, indexedDb, this.server);
|
||||
const query = {
|
||||
filters: {
|
||||
type: {
|
||||
logicalOperator: 'or',
|
||||
values: [{ equals: TYPE_TARGET_SSH }, { equals: TYPE_TARGET_TCP }],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = await queryIndexedDb(indexedDb.db, resource, query);
|
||||
|
||||
assert.strictEqual(result.length, 10);
|
||||
});
|
||||
|
||||
test('it filters by multiple fields', async function (assert) {
|
||||
await seedIndexDb(resource, store, indexedDb, this.server);
|
||||
const query = {
|
||||
filters: {
|
||||
type: [{ equals: TYPE_TARGET_SSH }],
|
||||
scope_id: [{ equals: scope1.id }],
|
||||
},
|
||||
};
|
||||
|
||||
const result = await queryIndexedDb(indexedDb.db, resource, query);
|
||||
|
||||
assert.strictEqual(result.length, 5);
|
||||
});
|
||||
|
||||
test('it filters by multiple fields with "or" logicalOperator', async function (assert) {
|
||||
this.server.create(resource, { type: TYPE_TARGET_TCP, scopeId: scope1.id });
|
||||
await seedIndexDb(resource, store, indexedDb, this.server);
|
||||
const query = {
|
||||
filters: {
|
||||
type: {
|
||||
logicalOperator: 'or',
|
||||
values: [{ equals: TYPE_TARGET_SSH }, { equals: TYPE_TARGET_TCP }],
|
||||
},
|
||||
scope_id: [{ equals: scope1.id }],
|
||||
},
|
||||
};
|
||||
|
||||
const result = await queryIndexedDb(indexedDb.db, resource, query);
|
||||
assert.strictEqual(result.length, 6);
|
||||
});
|
||||
|
||||
test('it filters by multiple fields with "and" logicalOperator', async function (assert) {
|
||||
this.server.create(resource, { type: TYPE_TARGET_TCP, scopeId: scope1.id });
|
||||
await seedIndexDb(resource, store, indexedDb, this.server);
|
||||
const query = {
|
||||
filters: {
|
||||
type: {
|
||||
logicalOperator: 'and',
|
||||
values: [{ equals: TYPE_TARGET_SSH }, { notEquals: TYPE_TARGET_TCP }],
|
||||
},
|
||||
scope_id: [{ equals: scope1.id }],
|
||||
},
|
||||
};
|
||||
|
||||
const result = await queryIndexedDb(indexedDb.db, resource, query);
|
||||
assert.strictEqual(result.length, 5);
|
||||
});
|
||||
|
||||
test('it filters by multiple fields and uses two "or" logicalOperators', async function (assert) {
|
||||
await seedIndexDb(resource, store, indexedDb, this.server);
|
||||
const query = {
|
||||
filters: {
|
||||
type: {
|
||||
logicalOperator: 'or',
|
||||
values: [{ equals: TYPE_TARGET_SSH }, { equals: TYPE_TARGET_TCP }],
|
||||
},
|
||||
scope_id: {
|
||||
logicalOperator: 'or',
|
||||
values: [{ equals: scope1.id }, { equals: scope2.id }],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = await queryIndexedDb(indexedDb.db, resource, query);
|
||||
assert.strictEqual(result.length, 10);
|
||||
});
|
||||
|
||||
test('it filters by multiple fields and uses two "and" logicalOperators', async function (assert) {
|
||||
await seedIndexDb(resource, store, indexedDb, this.server);
|
||||
const query = {
|
||||
filters: {
|
||||
type: {
|
||||
logicalOperator: 'and',
|
||||
values: [{ notEquals: TYPE_TARGET_SSH }, { equals: TYPE_TARGET_TCP }],
|
||||
},
|
||||
scope_id: {
|
||||
logicalOperator: 'and',
|
||||
values: [{ equals: scope2.id }, { notEquals: scope1.id }],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = await queryIndexedDb(indexedDb.db, resource, query);
|
||||
assert.strictEqual(result.length, 5);
|
||||
});
|
||||
|
||||
test('it cannot filter with "contains" as initial operation', async function (assert) {
|
||||
assert.expect(1);
|
||||
await seedIndexDb(resource, store, indexedDb, this.server);
|
||||
const query = {
|
||||
filters: {
|
||||
scope_id: [{ contains: scope1.id }],
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
await queryIndexedDb(indexedDb.db, resource, query);
|
||||
} catch (e) {
|
||||
assert.strictEqual(
|
||||
e.message,
|
||||
'Contains is not supported as the first filter.',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('it cannot filter with "contains" as subsequent operation', async function (assert) {
|
||||
assert.expect(2);
|
||||
await seedIndexDb(resource, store, indexedDb, this.server);
|
||||
const query = {
|
||||
filters: {
|
||||
scope_id: {
|
||||
logicalOperator: 'and',
|
||||
values: [{ equals: scope1.id }, { contains: scope2.id.slice(0, 2) }],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = await queryIndexedDb(indexedDb.db, resource, query);
|
||||
|
||||
assert.strictEqual(result.length, 5);
|
||||
|
||||
query.filters.scope_id.logicalOperator = 'or';
|
||||
|
||||
try {
|
||||
await queryIndexedDb(indexedDb.db, resource, query);
|
||||
} catch (e) {
|
||||
assert.strictEqual(
|
||||
e.message,
|
||||
'Contains is not supported as a filter option with "or" operator.',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// TODO: Modify test cases that use comparison opertators to compare fields that
|
||||
// are of type number. Additionally, add test case for searching on boolean fields.
|
||||
test('it filters by single field with "gt" as initial operation', async function (assert) {
|
||||
await seedIndexDb(resource, store, indexedDb, this.server);
|
||||
const query = {
|
||||
filters: {
|
||||
scope_id: [{ gt: scope1.id }],
|
||||
},
|
||||
};
|
||||
|
||||
const result = await queryIndexedDb(indexedDb.db, resource, query);
|
||||
|
||||
assert.strictEqual(result.length, 5);
|
||||
});
|
||||
|
||||
test('it filters by single field with "gt" as subsequent operation', async function (assert) {
|
||||
await seedIndexDb(resource, store, indexedDb, this.server);
|
||||
const query = {
|
||||
filters: {
|
||||
scope_id: {
|
||||
logicalOperator: 'and',
|
||||
values: [{ equals: scope2.id }, { gt: scope1.id }],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
let result = await queryIndexedDb(indexedDb.db, resource, query);
|
||||
|
||||
assert.strictEqual(result.length, 5);
|
||||
|
||||
query.filters.scope_id.logicalOperator = 'or';
|
||||
|
||||
result = await queryIndexedDb(indexedDb.db, resource, query);
|
||||
|
||||
assert.strictEqual(result.length, 5);
|
||||
});
|
||||
|
||||
test('it filters by single field with "gte" as initial operation', async function (assert) {
|
||||
await seedIndexDb(resource, store, indexedDb, this.server);
|
||||
const query = {
|
||||
filters: {
|
||||
scope_id: [{ gte: scope1.id }],
|
||||
},
|
||||
};
|
||||
|
||||
const result = await queryIndexedDb(indexedDb.db, resource, query);
|
||||
|
||||
assert.strictEqual(result.length, 10);
|
||||
});
|
||||
|
||||
test('it filters by single field with "gte" as subsequent operation', async function (assert) {
|
||||
await seedIndexDb(resource, store, indexedDb, this.server);
|
||||
const query = {
|
||||
filters: {
|
||||
scope_id: {
|
||||
logicalOperator: 'and',
|
||||
values: [{ equals: scope2.id }, { gte: scope1.id }],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
let result = await queryIndexedDb(indexedDb.db, resource, query);
|
||||
|
||||
assert.strictEqual(result.length, 5);
|
||||
|
||||
query.filters.scope_id.logicalOperator = 'or';
|
||||
|
||||
result = await queryIndexedDb(indexedDb.db, resource, query);
|
||||
|
||||
assert.strictEqual(result.length, 10);
|
||||
});
|
||||
|
||||
test('it filters by single field with "lt" as initial operation', async function (assert) {
|
||||
await seedIndexDb(resource, store, indexedDb, this.server);
|
||||
const query = {
|
||||
filters: {
|
||||
scope_id: [{ lt: scope2.id }],
|
||||
},
|
||||
};
|
||||
|
||||
const result = await queryIndexedDb(indexedDb.db, resource, query);
|
||||
|
||||
assert.strictEqual(result.length, 5);
|
||||
});
|
||||
|
||||
test('it filters by single field with "lt" as subsequent operation', async function (assert) {
|
||||
await seedIndexDb(resource, store, indexedDb, this.server);
|
||||
const query = {
|
||||
filters: {
|
||||
scope_id: {
|
||||
logicalOperator: 'and',
|
||||
values: [{ equals: scope1.id }, { lt: scope2.id }],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
let result = await queryIndexedDb(indexedDb.db, resource, query);
|
||||
|
||||
assert.strictEqual(result.length, 5);
|
||||
|
||||
query.filters.scope_id.logicalOperator = 'or';
|
||||
|
||||
result = await queryIndexedDb(indexedDb.db, resource, query);
|
||||
|
||||
assert.strictEqual(result.length, 5);
|
||||
});
|
||||
|
||||
test('it filters by single field with "lte" as initial operation', async function (assert) {
|
||||
await seedIndexDb(resource, store, indexedDb, this.server);
|
||||
const query = {
|
||||
filters: {
|
||||
scope_id: [{ lte: scope2.id }],
|
||||
},
|
||||
};
|
||||
|
||||
const result = await queryIndexedDb(indexedDb.db, resource, query);
|
||||
|
||||
assert.strictEqual(result.length, 10);
|
||||
});
|
||||
|
||||
test('it filters by single field with "lte" as subsequent operation', async function (assert) {
|
||||
await seedIndexDb(resource, store, indexedDb, this.server);
|
||||
const query = {
|
||||
filters: {
|
||||
scope_id: {
|
||||
logicalOperator: 'and',
|
||||
values: [{ equals: scope1.id }, { lte: scope2.id }],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
let result = await queryIndexedDb(indexedDb.db, resource, query);
|
||||
|
||||
assert.strictEqual(result.length, 5);
|
||||
|
||||
query.filters.scope_id.logicalOperator = 'or';
|
||||
|
||||
result = await queryIndexedDb(indexedDb.db, resource, query);
|
||||
|
||||
assert.strictEqual(result.length, 10);
|
||||
});
|
||||
|
||||
test('it filters by single field with "notEquals" as initial operation', async function (assert) {
|
||||
await seedIndexDb(resource, store, indexedDb, this.server);
|
||||
const query = {
|
||||
filters: {
|
||||
scope_id: [{ notEquals: scope1.id }],
|
||||
},
|
||||
};
|
||||
|
||||
const result = await queryIndexedDb(indexedDb.db, resource, query);
|
||||
|
||||
assert.strictEqual(result.length, 5);
|
||||
});
|
||||
|
||||
test('it filters by single field with "notEquals" as subsequent operation', async function (assert) {
|
||||
await seedIndexDb(resource, store, indexedDb, this.server);
|
||||
const query = {
|
||||
filters: {
|
||||
scope_id: {
|
||||
logicalOperator: 'and',
|
||||
values: [{ equals: scope2.id }, { notEquals: scope1.id }],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
let result = await queryIndexedDb(indexedDb.db, resource, query);
|
||||
|
||||
assert.strictEqual(result.length, 5);
|
||||
|
||||
query.filters.scope_id.logicalOperator = 'or';
|
||||
|
||||
result = await queryIndexedDb(indexedDb.db, resource, query);
|
||||
|
||||
assert.strictEqual(result.length, 5);
|
||||
});
|
||||
|
||||
test('it filters and returns zero results', async function (assert) {
|
||||
await seedIndexDb(resource, store, indexedDb, this.server);
|
||||
const query = {
|
||||
filters: {
|
||||
type: [{ equals: 'unknown' }],
|
||||
},
|
||||
};
|
||||
|
||||
const result = await queryIndexedDb(indexedDb.db, resource, query);
|
||||
|
||||
assert.strictEqual(result.length, 0);
|
||||
});
|
||||
|
||||
test('it searches with a query that matches a field exactly', async function (assert) {
|
||||
await seedIndexDb(resource, store, indexedDb, this.server);
|
||||
const query = { search: scope1.id };
|
||||
|
||||
const result = await queryIndexedDb(indexedDb.db, resource, query);
|
||||
|
||||
assert.strictEqual(result.length, 5);
|
||||
});
|
||||
|
||||
test('it searches with a query that matches a field partially', async function (assert) {
|
||||
await seedIndexDb(resource, store, indexedDb, this.server);
|
||||
const query = { search: scope2.id.slice(0, 2) };
|
||||
|
||||
const result = await queryIndexedDb(indexedDb.db, resource, query);
|
||||
|
||||
assert.strictEqual(result.length, 10);
|
||||
});
|
||||
|
||||
test('it searches and returns no results with a query that matches no fields', async function (assert) {
|
||||
await seedIndexDb(resource, store, indexedDb, this.server);
|
||||
const query = { search: `${scope1.id}456789` };
|
||||
|
||||
const result = await queryIndexedDb(indexedDb.db, resource, query);
|
||||
|
||||
assert.strictEqual(result.length, 0);
|
||||
});
|
||||
|
||||
test('it filters by a single field and searches', async function (assert) {
|
||||
await seedIndexDb(resource, store, indexedDb, this.server);
|
||||
const query = {
|
||||
search: scope1.id,
|
||||
filters: {
|
||||
type: [{ equals: TYPE_TARGET_SSH }],
|
||||
},
|
||||
};
|
||||
|
||||
const result = await queryIndexedDb(indexedDb.db, resource, query);
|
||||
|
||||
assert.strictEqual(result.length, 5);
|
||||
});
|
||||
|
||||
test('it filters by multiple fields and searches', async function (assert) {
|
||||
const target = this.server.create(resource, {
|
||||
name: 'Generated_ssh_target',
|
||||
type: TYPE_TARGET_SSH,
|
||||
scopeId: scope1.id,
|
||||
});
|
||||
await seedIndexDb(resource, store, indexedDb, this.server);
|
||||
const query = {
|
||||
search: target.name,
|
||||
filters: {
|
||||
type: [{ equals: TYPE_TARGET_SSH }],
|
||||
scope_id: [{ equals: scope1.id }],
|
||||
},
|
||||
};
|
||||
|
||||
const result = await queryIndexedDb(indexedDb.db, resource, query);
|
||||
|
||||
assert.strictEqual(result.length, 1);
|
||||
});
|
||||
|
||||
test('it filters on boolean type fields with truthy value', async function (assert) {
|
||||
const authMethod = this.server.create('auth-method', {
|
||||
name: 'Generated_auth_method',
|
||||
scopeId: scope1.id,
|
||||
});
|
||||
scope1.update({ primaryAuthMethodId: authMethod.id });
|
||||
await seedIndexDb('auth-method', store, indexedDb, this.server);
|
||||
const query = { filters: { is_primary: [{ equals: 'true' }] } };
|
||||
|
||||
const result = await queryIndexedDb(indexedDb.db, 'auth-method', query);
|
||||
|
||||
assert.strictEqual(result.length, 1);
|
||||
});
|
||||
|
||||
test('it filters on boolean type fields with falsy value', async function (assert) {
|
||||
const authMethod = this.server.create('auth-method', {
|
||||
name: 'Generated_auth_method',
|
||||
scopeId: scope1.id,
|
||||
});
|
||||
scope1.update({ primaryAuthMethodId: authMethod.id });
|
||||
await seedIndexDb('auth-method', store, indexedDb, this.server);
|
||||
const query = { filters: { is_primary: [{ equals: 'false' }] } };
|
||||
|
||||
const result = await queryIndexedDb(indexedDb.db, 'auth-method', query);
|
||||
|
||||
assert.strictEqual(result.length, 0);
|
||||
});
|
||||
|
||||
test('it filters on date type fields and returns results', async function (assert) {
|
||||
const pastDate = new Date('2024-09-19T10:00:00.000Z');
|
||||
const createdTime = new Date('2024-09-23T10:00:00.000Z');
|
||||
this.server.create('session-recording', {
|
||||
created_time: createdTime,
|
||||
});
|
||||
await seedIndexDb('session-recording', store, indexedDb, this.server);
|
||||
const query = { filters: { created_time: [{ gte: pastDate }] } };
|
||||
|
||||
const result = await queryIndexedDb(
|
||||
indexedDb.db,
|
||||
'session-recording',
|
||||
query,
|
||||
);
|
||||
|
||||
assert.strictEqual(result.length, 1);
|
||||
});
|
||||
|
||||
test('it filters on date type fields and returns results - from and to', async function (assert) {
|
||||
const from = new Date('2024-09-19T10:00:00.000Z');
|
||||
const to = new Date('2024-09-25T10:00:00.000Z');
|
||||
const createdTime = new Date('2024-09-23T10:00:00.000Z');
|
||||
const createdTime2 = new Date('2024-09-28T10:00:00.000Z');
|
||||
this.server.create('session-recording', {
|
||||
created_time: createdTime,
|
||||
});
|
||||
this.server.create('session-recording', {
|
||||
created_time: createdTime2,
|
||||
});
|
||||
await seedIndexDb('session-recording', store, indexedDb, this.server);
|
||||
const query = {
|
||||
filters: {
|
||||
created_time: {
|
||||
logicalOperator: 'and',
|
||||
values: [{ gte: from }, { lte: to }],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = await queryIndexedDb(
|
||||
indexedDb.db,
|
||||
'session-recording',
|
||||
query,
|
||||
);
|
||||
|
||||
assert.strictEqual(result.length, 1);
|
||||
assert.strictEqual(
|
||||
result[0].attributes.created_time.toISOString(),
|
||||
createdTime.toISOString(),
|
||||
);
|
||||
});
|
||||
|
||||
test('it filters on date type fields and returns no results', async function (assert) {
|
||||
const createdTime = faker.date.between({
|
||||
from: '2024-09-19T00:00:00.000Z',
|
||||
to: '2024-09-20T00:00:00.000Z',
|
||||
});
|
||||
const pastDate = faker.date.between({
|
||||
from: '2024-09-23T00:00:00.000Z',
|
||||
to: '2024-09-24T00:00:00.000Z',
|
||||
});
|
||||
this.server.create('session-recording', {
|
||||
created_time: createdTime,
|
||||
});
|
||||
await seedIndexDb('session-recording', store, indexedDb, this.server);
|
||||
const query = { filters: { created_time: [{ gte: pastDate }] } };
|
||||
|
||||
const result = await queryIndexedDb(
|
||||
indexedDb.db,
|
||||
'session-recording',
|
||||
query,
|
||||
);
|
||||
|
||||
assert.strictEqual(result.length, 0);
|
||||
});
|
||||
});
|
||||
@@ -1,488 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import { assert, module, test } from 'qunit';
|
||||
import { sortResults } from 'api/utils/sort-results';
|
||||
import { faker } from '@faker-js/faker';
|
||||
import { setupTest } from 'ember-qunit';
|
||||
|
||||
function createJsonApiMockRecord(id, type, attributes) {
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
attributes,
|
||||
};
|
||||
}
|
||||
|
||||
module('Unit | Utility | sortResults', function (hooks) {
|
||||
setupTest(hooks);
|
||||
|
||||
module('Defaults', function () {
|
||||
test("throws an error when an attribute is not id, created_time or defined as a schema attribute, even when it exists on the record's attribute object", function (assert) {
|
||||
const result01 = createJsonApiMockRecord('id_01', 'target', {
|
||||
name: 'Result 01',
|
||||
});
|
||||
const result02 = createJsonApiMockRecord('id_02', 'target', {
|
||||
name: 'Result 02',
|
||||
});
|
||||
const result03 = createJsonApiMockRecord('id_03', 'target', {
|
||||
name: 'Result 03',
|
||||
});
|
||||
|
||||
const shuffledResults = faker.helpers.shuffle([
|
||||
result01,
|
||||
result02,
|
||||
result03,
|
||||
]);
|
||||
|
||||
const querySort = { attribute: 'name' };
|
||||
|
||||
// Note: `name` is not defined as a schema attribute but exists on the record
|
||||
const schema = { attributes: new Map() };
|
||||
schema.attributes.set('date', { type: 'date' });
|
||||
|
||||
assert.throws(
|
||||
() => sortResults(shuffledResults, { querySort, schema }),
|
||||
/Error: The attribute "name" does not map to the model definition of type "target". Supported sortable attributes are 'id', 'created_time', or 'date'/,
|
||||
);
|
||||
});
|
||||
|
||||
test('throws an error when results do not contain a valid sort direction', function (assert) {
|
||||
const result01 = createJsonApiMockRecord('id_01', 'target', {
|
||||
name: 'Result 01',
|
||||
});
|
||||
|
||||
const querySort = {
|
||||
attribute: 'name',
|
||||
direction: 'invalid sort direction',
|
||||
};
|
||||
const schema = { attributes: new Map() };
|
||||
schema.attributes.set('name', { type: 'string' });
|
||||
assert.throws(
|
||||
() => sortResults([result01], { querySort, schema }),
|
||||
/Error: Invalid sort direction/,
|
||||
);
|
||||
});
|
||||
|
||||
test('when passing no querySort, sorts using default created_time, descending order and string sorting', async function (assert) {
|
||||
const result01 = createJsonApiMockRecord('id_01', 'target', {
|
||||
created_time: '2021',
|
||||
});
|
||||
const result02 = createJsonApiMockRecord('id_02', 'target', {
|
||||
created_time: '2022',
|
||||
});
|
||||
const result03 = createJsonApiMockRecord('id_03', 'target', {
|
||||
created_time: '2023',
|
||||
});
|
||||
const result04 = createJsonApiMockRecord('id_04', 'target', {
|
||||
created_time: '2024',
|
||||
});
|
||||
|
||||
const shuffledResults = faker.helpers.shuffle([
|
||||
result01,
|
||||
result02,
|
||||
result03,
|
||||
result04,
|
||||
]);
|
||||
const querySort = {};
|
||||
const schema = { attributes: new Map() };
|
||||
schema.attributes.set('created_time', { type: 'string' });
|
||||
const sortedResults = sortResults(shuffledResults, {
|
||||
querySort,
|
||||
schema,
|
||||
});
|
||||
assert.deepEqual(
|
||||
sortedResults.map(({ attributes: { created_time } }) => created_time),
|
||||
['2024', '2023', '2022', '2021'],
|
||||
);
|
||||
});
|
||||
|
||||
test('when sorting by id, without being on the results attributes it sorts as expected ', function () {
|
||||
const result01 = createJsonApiMockRecord('id_01', 'target', {
|
||||
name: 'Result 01',
|
||||
});
|
||||
const result02 = createJsonApiMockRecord('id_02', 'target', {
|
||||
name: 'Result 02',
|
||||
});
|
||||
const result03 = createJsonApiMockRecord('id_03', 'target', {
|
||||
name: 'Result 03',
|
||||
});
|
||||
const result04 = createJsonApiMockRecord('id_04', 'target', {
|
||||
name: 'Result 04',
|
||||
});
|
||||
|
||||
const shuffledResults = faker.helpers.shuffle([
|
||||
result01,
|
||||
result02,
|
||||
result03,
|
||||
result04,
|
||||
]);
|
||||
const querySort = { attribute: 'id' };
|
||||
const schema = { attributes: new Map() };
|
||||
schema.attributes.set('id', { type: 'string' });
|
||||
const sortedResults = sortResults(shuffledResults, { querySort, schema });
|
||||
|
||||
assert.deepEqual(
|
||||
sortedResults.map(({ id }) => id),
|
||||
['id_01', 'id_02', 'id_03', 'id_04'],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
module('string sorting', function () {
|
||||
test('it sorts by ascending (default) `name` using string sorting', async function (assert) {
|
||||
const result01 = createJsonApiMockRecord('id_01', 'target', {
|
||||
name: 'Result 01',
|
||||
});
|
||||
const result02 = createJsonApiMockRecord('id_02', 'target', {
|
||||
name: 'Result 02',
|
||||
});
|
||||
const result03 = createJsonApiMockRecord('id_03', 'target', {
|
||||
name: 'Result 03',
|
||||
});
|
||||
const result04 = createJsonApiMockRecord('id_04', 'target', {
|
||||
name: 'Result 04',
|
||||
});
|
||||
|
||||
const shuffledResults = faker.helpers.shuffle([
|
||||
result01,
|
||||
result02,
|
||||
result03,
|
||||
result04,
|
||||
]);
|
||||
const querySort = { attribute: 'name' };
|
||||
const schema = { attributes: new Map() };
|
||||
schema.attributes.set('name', { type: 'string' });
|
||||
const sortedResults = sortResults(shuffledResults, { querySort, schema });
|
||||
assert.deepEqual(
|
||||
sortedResults.map(({ attributes: { name } }) => name),
|
||||
['Result 01', 'Result 02', 'Result 03', 'Result 04'],
|
||||
);
|
||||
});
|
||||
|
||||
test('it sorts by ascending then descending `name` using string sorting', async function (assert) {
|
||||
const result01 = createJsonApiMockRecord('id_01', 'target', {
|
||||
name: 'Result 01',
|
||||
});
|
||||
const result02 = createJsonApiMockRecord('id_02', 'target', {
|
||||
name: 'Result 02',
|
||||
});
|
||||
const result03 = createJsonApiMockRecord('id_03', 'target', {
|
||||
name: 'Result 03',
|
||||
});
|
||||
const result04 = createJsonApiMockRecord('id_04', 'target', {
|
||||
name: 'Result 04',
|
||||
});
|
||||
|
||||
const shuffledResults = faker.helpers.shuffle([
|
||||
result01,
|
||||
result02,
|
||||
result03,
|
||||
result04,
|
||||
]);
|
||||
const querySortAsc = { attribute: 'name', direction: 'asc' };
|
||||
const querySortDesc = { attribute: 'name', direction: 'desc' };
|
||||
const schema = { attributes: new Map() };
|
||||
schema.attributes.set('name', { type: 'string' });
|
||||
const sortedResultsAsc = sortResults(shuffledResults, {
|
||||
querySort: querySortAsc,
|
||||
schema,
|
||||
});
|
||||
const sortedResultsDesc = sortResults(shuffledResults, {
|
||||
querySort: querySortDesc,
|
||||
schema,
|
||||
});
|
||||
assert.deepEqual(
|
||||
sortedResultsAsc.map(({ attributes: { name } }) => name),
|
||||
['Result 01', 'Result 02', 'Result 03', 'Result 04'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
sortedResultsDesc.map(({ attributes: { name } }) => name),
|
||||
['Result 04', 'Result 03', 'Result 02', 'Result 01'],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
module('date sorting', function () {
|
||||
test('it sorts by ascending (default) `date` using date sorting', function (assert) {
|
||||
const result01 = createJsonApiMockRecord('id_01', 'target', {
|
||||
date: '2021',
|
||||
});
|
||||
const result02 = createJsonApiMockRecord('id_02', 'target', {
|
||||
date: '2022',
|
||||
});
|
||||
const result03 = createJsonApiMockRecord('id_03', 'target', {
|
||||
date: '2023',
|
||||
});
|
||||
const result04 = createJsonApiMockRecord('id_04', 'target', {
|
||||
date: '2024',
|
||||
});
|
||||
|
||||
const shuffledResults = faker.helpers.shuffle([
|
||||
result01,
|
||||
result02,
|
||||
result03,
|
||||
result04,
|
||||
]);
|
||||
const querySort = { attribute: 'date' };
|
||||
const schema = { attributes: new Map() };
|
||||
schema.attributes.set('date', { type: 'date' });
|
||||
|
||||
const sortedResults = sortResults(shuffledResults, { querySort, schema });
|
||||
|
||||
assert.deepEqual(
|
||||
sortedResults.map(({ attributes: { date } }) => date),
|
||||
['2021', '2022', '2023', '2024'],
|
||||
);
|
||||
});
|
||||
|
||||
test('it sorts by ascending then descending `date` using date sorting', function (assert) {
|
||||
const result01 = createJsonApiMockRecord('id_01', 'target', {
|
||||
date: '2021',
|
||||
});
|
||||
const result02 = createJsonApiMockRecord('id_02', 'target', {
|
||||
date: '2022',
|
||||
});
|
||||
const result03 = createJsonApiMockRecord('id_03', 'target', {
|
||||
date: '2023',
|
||||
});
|
||||
const result04 = createJsonApiMockRecord('id_04', 'target', {
|
||||
date: '2024',
|
||||
});
|
||||
|
||||
const shuffledResults = faker.helpers.shuffle([
|
||||
result01,
|
||||
result02,
|
||||
result03,
|
||||
result04,
|
||||
]);
|
||||
const querySortAsc = { attribute: 'date', direction: 'asc' };
|
||||
const querySortDesc = { attribute: 'date', direction: 'desc' };
|
||||
const schema = { attributes: new Map() };
|
||||
schema.attributes.set('date', { type: 'date' });
|
||||
|
||||
const sortedResultsAsc = sortResults(shuffledResults, {
|
||||
querySort: querySortAsc,
|
||||
schema,
|
||||
});
|
||||
const sortedResultsDesc = sortResults(shuffledResults, {
|
||||
querySort: querySortDesc,
|
||||
schema,
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
sortedResultsAsc.map(({ attributes: { date } }) => date),
|
||||
['2021', '2022', '2023', '2024'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
sortedResultsDesc.map(({ attributes: { date } }) => date),
|
||||
['2024', '2023', '2022', '2021'],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
module('number sorting', function () {
|
||||
test('it sorts by ascending (default) `sessions`', function (assert) {
|
||||
const result01 = createJsonApiMockRecord('id_01', 'target', {
|
||||
sessions: 10,
|
||||
});
|
||||
const result02 = createJsonApiMockRecord('id_01', 'target', {
|
||||
sessions: 200,
|
||||
});
|
||||
const result03 = createJsonApiMockRecord('id_01', 'target', {
|
||||
sessions: 3333,
|
||||
});
|
||||
const result04 = createJsonApiMockRecord('id_01', 'target', {
|
||||
sessions: 40444,
|
||||
});
|
||||
|
||||
const shuffledResults = faker.helpers.shuffle([
|
||||
result01,
|
||||
result02,
|
||||
result03,
|
||||
result04,
|
||||
]);
|
||||
const querySort = { attribute: 'sessions' };
|
||||
const schema = { attributes: new Map() };
|
||||
schema.attributes.set('sessions', { type: 'number' });
|
||||
|
||||
const sortedResults = sortResults(shuffledResults, { querySort, schema });
|
||||
|
||||
assert.deepEqual(
|
||||
sortedResults.map(({ attributes: { sessions } }) => sessions),
|
||||
[10, 200, 3333, 40444],
|
||||
);
|
||||
});
|
||||
|
||||
test('it sorts by ascending then descending `sessions`', function (assert) {
|
||||
const result01 = createJsonApiMockRecord('id_01', 'target', {
|
||||
sessions: 10,
|
||||
});
|
||||
const result02 = createJsonApiMockRecord('id_01', 'target', {
|
||||
sessions: 200,
|
||||
});
|
||||
const result03 = createJsonApiMockRecord('id_01', 'target', {
|
||||
sessions: 3333,
|
||||
});
|
||||
const result04 = createJsonApiMockRecord('id_01', 'target', {
|
||||
sessions: 40444,
|
||||
});
|
||||
|
||||
const shuffledResults = faker.helpers.shuffle([
|
||||
result01,
|
||||
result02,
|
||||
result03,
|
||||
result04,
|
||||
]);
|
||||
const querySortAsc = { attribute: 'sessions', direction: 'asc' };
|
||||
const querySortDesc = { attribute: 'sessions', direction: 'desc' };
|
||||
const schema = { attributes: new Map() };
|
||||
schema.attributes.set('sessions', { type: 'number' });
|
||||
|
||||
const sortedResultsAsc = sortResults(shuffledResults, {
|
||||
querySort: querySortAsc,
|
||||
schema,
|
||||
});
|
||||
const sortedResultsDesc = sortResults(shuffledResults, {
|
||||
querySort: querySortDesc,
|
||||
schema,
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
sortedResultsAsc.map(({ attributes: { sessions } }) => sessions),
|
||||
[10, 200, 3333, 40444],
|
||||
);
|
||||
assert.deepEqual(
|
||||
sortedResultsDesc.map(({ attributes: { sessions } }) => sessions),
|
||||
[40444, 3333, 200, 10],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
module('boolean sorting', function () {
|
||||
test('it sorts by ascending (default) `active`', function (assert) {
|
||||
const result01 = { attributes: { active: true } };
|
||||
const result02 = { attributes: { active: false } };
|
||||
const result03 = { attributes: { active: false } };
|
||||
const result04 = { attributes: { active: true } };
|
||||
const shuffledResults = faker.helpers.shuffle([
|
||||
result01,
|
||||
result02,
|
||||
result03,
|
||||
result04,
|
||||
]);
|
||||
const querySort = { attribute: 'active' };
|
||||
const schema = { attributes: new Map() };
|
||||
schema.attributes.set('active', { type: 'boolean' });
|
||||
|
||||
const sortedResults = sortResults(shuffledResults, { querySort, schema });
|
||||
|
||||
assert.deepEqual(
|
||||
sortedResults.map(({ attributes: { active } }) => active),
|
||||
[false, false, true, true],
|
||||
);
|
||||
});
|
||||
|
||||
test('it sorts by ascending then descending `active`', function (assert) {
|
||||
const result01 = { attributes: { active: true } };
|
||||
const result02 = { attributes: { active: false } };
|
||||
const result03 = { attributes: { active: false } };
|
||||
const result04 = { attributes: { active: true } };
|
||||
const shuffledResults = faker.helpers.shuffle([
|
||||
result01,
|
||||
result02,
|
||||
result03,
|
||||
result04,
|
||||
]);
|
||||
const querySortAsc = { attribute: 'active', direction: 'asc' };
|
||||
const querySortDesc = { attribute: 'active', direction: 'desc' };
|
||||
const schema = { attributes: new Map() };
|
||||
schema.attributes.set('active', { type: 'boolean' });
|
||||
|
||||
const sortedResultsAsc = sortResults(shuffledResults, {
|
||||
querySort: querySortAsc,
|
||||
schema,
|
||||
});
|
||||
const sortedResultsDesc = sortResults(shuffledResults, {
|
||||
querySort: querySortDesc,
|
||||
schema,
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
sortedResultsAsc.map(({ attributes: { active } }) => active),
|
||||
[false, false, true, true],
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
sortedResultsDesc.map(({ attributes: { active } }) => active),
|
||||
[true, true, false, false],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
module('custom function sorting', function () {
|
||||
const result01 = createJsonApiMockRecord('id_01', 'target', {
|
||||
name: 'target1',
|
||||
});
|
||||
const result02 = createJsonApiMockRecord('id_02', 'target', {
|
||||
name: 'target2',
|
||||
});
|
||||
const result03 = createJsonApiMockRecord('id_03', 'target', {
|
||||
name: 'target3',
|
||||
});
|
||||
const result04 = createJsonApiMockRecord('id_04', 'target', {
|
||||
name: 'target4',
|
||||
});
|
||||
|
||||
const sortFunction = (recordA, recordB) => {
|
||||
const nameMap = {
|
||||
target3: 'Alpha',
|
||||
target4: 'Beta',
|
||||
target1: 'Delta',
|
||||
target2: 'Epsilon',
|
||||
};
|
||||
return String(nameMap[recordA.attributes.name]).localeCompare(
|
||||
String(nameMap[recordB.attributes.name]),
|
||||
);
|
||||
};
|
||||
const schema = { attributes: new Map() };
|
||||
|
||||
test.each(
|
||||
'it sorts by `name` using custom sorting function',
|
||||
{
|
||||
'ascending (default)': {
|
||||
querySort: { sortFunction },
|
||||
expectedResults: ['target3', 'target4', 'target1', 'target2'],
|
||||
},
|
||||
descending: {
|
||||
querySort: { sortFunction, direction: 'desc' },
|
||||
expectedResults: ['target2', 'target1', 'target4', 'target3'],
|
||||
},
|
||||
ascending: {
|
||||
querySort: { sortFunction, direction: 'asc' },
|
||||
expectedResults: ['target3', 'target4', 'target1', 'target2'],
|
||||
},
|
||||
},
|
||||
function (assert, input) {
|
||||
const shuffledResults = faker.helpers.shuffle([
|
||||
result01,
|
||||
result02,
|
||||
result03,
|
||||
result04,
|
||||
]);
|
||||
schema.attributes.set('name', { type: 'string' });
|
||||
const sortedResults = sortResults(shuffledResults, {
|
||||
querySort: input.querySort,
|
||||
schema,
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
sortedResults.map(({ attributes: { name } }) => name),
|
||||
input.expectedResults,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,367 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import { generateSQLExpressions } from 'api/utils/sqlite-query';
|
||||
import { module, test } from 'qunit';
|
||||
|
||||
module('Unit | Utility | sqlite-query', function (hooks) {
|
||||
const date = new Date(2025, 1, 1);
|
||||
const isoDateString = date.toISOString();
|
||||
|
||||
hooks.beforeEach(function () {
|
||||
String.prototype.removeExtraWhiteSpace = function () {
|
||||
return this.replace(/^\s+/gm, '').trim();
|
||||
};
|
||||
});
|
||||
|
||||
hooks.afterEach(function () {
|
||||
delete String.prototype.removeExtraWhiteSpace;
|
||||
});
|
||||
|
||||
// TODO: Add a normal LIKE search when we add a resource that uses it
|
||||
test('it generates search correctly with FTS5', function (assert) {
|
||||
const query = { search: 'favorite' };
|
||||
|
||||
const { sql, parameters } = generateSQLExpressions('target', query);
|
||||
assert.strictEqual(
|
||||
sql,
|
||||
`
|
||||
SELECT * FROM "target"
|
||||
WHERE rowid IN (SELECT rowid FROM target_fts WHERE target_fts MATCH ?)
|
||||
ORDER BY created_time DESC`.removeExtraWhiteSpace(),
|
||||
);
|
||||
assert.deepEqual(parameters, ['"favorite"*']);
|
||||
});
|
||||
|
||||
test('it grabs resources not in the model mapping', function (assert) {
|
||||
const query = {
|
||||
filters: { id: [{ equals: 'tokenKey' }] },
|
||||
};
|
||||
|
||||
const { sql, parameters } = generateSQLExpressions('token', query);
|
||||
assert.strictEqual(
|
||||
sql,
|
||||
`
|
||||
SELECT * FROM "token"
|
||||
WHERE (id = ?)`.removeExtraWhiteSpace(),
|
||||
);
|
||||
assert.deepEqual(parameters, ['tokenKey']);
|
||||
});
|
||||
|
||||
test('it executes count queries correctly', function (assert) {
|
||||
const select = {
|
||||
select: ['count(*) as total'],
|
||||
};
|
||||
|
||||
const { sql, parameters } = generateSQLExpressions('target', {}, select);
|
||||
assert.strictEqual(
|
||||
sql,
|
||||
`
|
||||
SELECT count(*) as total FROM "target"
|
||||
ORDER BY created_time DESC`.removeExtraWhiteSpace(),
|
||||
);
|
||||
assert.deepEqual(parameters, []);
|
||||
});
|
||||
|
||||
test.each(
|
||||
'it generates filters correctly',
|
||||
{
|
||||
equals: {
|
||||
query: {
|
||||
filters: {
|
||||
type: [{ equals: 'ssh' }],
|
||||
},
|
||||
},
|
||||
expectedWhereClause: 'WHERE (type = ?)',
|
||||
expectedParams: ['ssh'],
|
||||
},
|
||||
notEquals: {
|
||||
query: {
|
||||
filters: {
|
||||
type: [{ notEquals: 'ssh' }],
|
||||
},
|
||||
},
|
||||
expectedWhereClause: 'WHERE (type != ?)',
|
||||
expectedParams: ['ssh'],
|
||||
},
|
||||
contains: {
|
||||
query: {
|
||||
filters: {
|
||||
type: [{ contains: 'ssh' }],
|
||||
},
|
||||
},
|
||||
expectedWhereClause: 'WHERE (type LIKE ?)',
|
||||
expectedParams: ['%ssh%'],
|
||||
},
|
||||
greaterThan: {
|
||||
query: {
|
||||
filters: {
|
||||
created_time: [{ gt: date }],
|
||||
numberField: [{ gt: 10 }],
|
||||
},
|
||||
},
|
||||
expectedWhereClause: 'WHERE (created_time > ?) AND (numberField > ?)',
|
||||
expectedParams: [isoDateString, 10],
|
||||
},
|
||||
lessThan: {
|
||||
query: {
|
||||
filters: {
|
||||
created_time: [{ lt: date }],
|
||||
numberField: [{ lt: 10 }],
|
||||
},
|
||||
},
|
||||
expectedWhereClause: 'WHERE (created_time < ?) AND (numberField < ?)',
|
||||
expectedParams: [isoDateString, 10],
|
||||
},
|
||||
greaterThanOrEqual: {
|
||||
query: {
|
||||
filters: {
|
||||
created_time: [{ gte: date }],
|
||||
numberField: [{ gte: 10 }],
|
||||
},
|
||||
},
|
||||
expectedWhereClause: 'WHERE (created_time >= ?) AND (numberField >= ?)',
|
||||
expectedParams: [isoDateString, 10],
|
||||
},
|
||||
lessThanOrEqual: {
|
||||
query: {
|
||||
filters: {
|
||||
created_time: [{ lte: date }],
|
||||
numberField: [{ lte: 10 }],
|
||||
},
|
||||
},
|
||||
expectedWhereClause: 'WHERE (created_time <= ?) AND (numberField <= ?)',
|
||||
expectedParams: [isoDateString, 10],
|
||||
},
|
||||
logicalOperators: {
|
||||
query: {
|
||||
filters: {
|
||||
id: {
|
||||
logicalOperator: 'and',
|
||||
values: [{ notEquals: 'id1' }, { notEquals: 'id2' }],
|
||||
},
|
||||
status: {
|
||||
logicalOperator: 'or',
|
||||
values: [{ equals: 'active' }, { equals: 'pending' }],
|
||||
},
|
||||
type: [{ equals: 'ssh' }],
|
||||
},
|
||||
},
|
||||
expectedWhereClause:
|
||||
'WHERE (id NOT IN (?, ?)) AND (status IN (?, ?)) AND (type = ?)',
|
||||
expectedParams: ['id1', 'id2', 'active', 'pending', 'ssh'],
|
||||
},
|
||||
},
|
||||
|
||||
function (assert, { query, expectedWhereClause, expectedParams }) {
|
||||
const { sql, parameters } = generateSQLExpressions('target', query);
|
||||
assert.strictEqual(
|
||||
sql,
|
||||
`
|
||||
SELECT * FROM "target"
|
||||
${expectedWhereClause}
|
||||
ORDER BY created_time DESC`.removeExtraWhiteSpace(),
|
||||
);
|
||||
assert.deepEqual(parameters, expectedParams);
|
||||
},
|
||||
);
|
||||
|
||||
test.each(
|
||||
'it generates sort direction correctly',
|
||||
[
|
||||
['asc', 'ASC'],
|
||||
['desc', 'DESC'],
|
||||
],
|
||||
function (assert, [direction, expectedDirection]) {
|
||||
const query = {
|
||||
sort: { attributes: ['name'], direction },
|
||||
};
|
||||
|
||||
const { sql, parameters } = generateSQLExpressions('target', query);
|
||||
assert.strictEqual(
|
||||
sql,
|
||||
`
|
||||
SELECT * FROM "target"
|
||||
ORDER BY name COLLATE NOCASE ${expectedDirection}, name ${expectedDirection}`.removeExtraWhiteSpace(),
|
||||
);
|
||||
assert.deepEqual(parameters, []);
|
||||
},
|
||||
);
|
||||
|
||||
test.each(
|
||||
'it generates sort order clause correctly',
|
||||
{
|
||||
'sort on multiple attributes and coalesced': {
|
||||
sort: {
|
||||
attributes: ['name', 'id'],
|
||||
isCoalesced: true,
|
||||
},
|
||||
expectedOrderByClause: `ORDER BY COALESCE(name, id) COLLATE NOCASE DESC, COALESCE(name, id) DESC`,
|
||||
},
|
||||
'sort on multiple attributes': {
|
||||
sort: {
|
||||
attributes: ['name', 'id'],
|
||||
},
|
||||
expectedOrderByClause: `ORDER BY name COLLATE NOCASE DESC, id COLLATE NOCASE DESC, name DESC, id DESC`,
|
||||
},
|
||||
'sort on mapped attributes': {
|
||||
sort: {
|
||||
attributes: ['type'],
|
||||
customSort: { attributeMap: { ssh: 'SSH', tcp: 'Generic TCP' } },
|
||||
},
|
||||
expectedOrderByClause: `ORDER BY CASE type WHEN 'ssh' THEN 'SSH' WHEN 'tcp' THEN 'Generic TCP' END DESC`,
|
||||
},
|
||||
},
|
||||
function (assert, { sort, expectedOrderByClause }) {
|
||||
const query = {
|
||||
sort: { ...sort, direction: 'desc' },
|
||||
};
|
||||
|
||||
const { sql, parameters } = generateSQLExpressions('target', query);
|
||||
assert.strictEqual(
|
||||
sql,
|
||||
`
|
||||
SELECT * FROM "target"
|
||||
${expectedOrderByClause}`.removeExtraWhiteSpace(),
|
||||
);
|
||||
assert.deepEqual(parameters, []);
|
||||
},
|
||||
);
|
||||
|
||||
test.each(
|
||||
'it generates pagination clause',
|
||||
[
|
||||
{ page: 1, pageSize: 10, expectedParams: [10, 0] },
|
||||
{ page: 3, pageSize: 10, expectedParams: [10, 20] },
|
||||
{ page: 7, pageSize: 50, expectedParams: [50, 300] },
|
||||
],
|
||||
function (assert, { page, pageSize, expectedParams }) {
|
||||
const { sql, parameters } = generateSQLExpressions(
|
||||
'target',
|
||||
{},
|
||||
{ page, pageSize },
|
||||
);
|
||||
assert.strictEqual(
|
||||
sql,
|
||||
`
|
||||
SELECT * FROM "target"
|
||||
ORDER BY created_time DESC
|
||||
LIMIT ? OFFSET ?`.removeExtraWhiteSpace(),
|
||||
);
|
||||
assert.deepEqual(parameters, expectedParams);
|
||||
},
|
||||
);
|
||||
|
||||
test('it generates FTS5 search with filters', function (assert) {
|
||||
const query = {
|
||||
search: 'favorite',
|
||||
filters: {
|
||||
id: {
|
||||
logicalOperator: 'and',
|
||||
values: [{ notEquals: 'id1' }, { notEquals: 'id2' }],
|
||||
},
|
||||
status: [{ equals: 'active' }, { equals: 'pending' }],
|
||||
},
|
||||
};
|
||||
|
||||
const { sql, parameters } = generateSQLExpressions('target', query);
|
||||
assert.strictEqual(
|
||||
sql,
|
||||
`
|
||||
SELECT * FROM "target"
|
||||
WHERE (id NOT IN (?, ?)) AND (status IN (?, ?)) AND rowid IN (SELECT rowid FROM target_fts WHERE target_fts MATCH ?)
|
||||
ORDER BY created_time DESC`.removeExtraWhiteSpace(),
|
||||
);
|
||||
assert.deepEqual(parameters, [
|
||||
'id1',
|
||||
'id2',
|
||||
'active',
|
||||
'pending',
|
||||
'"favorite"*',
|
||||
]);
|
||||
});
|
||||
|
||||
test('it generates SQL with all clauses combined', function (assert) {
|
||||
const query = {
|
||||
search: 'favorite',
|
||||
filters: {
|
||||
type: [{ equals: 'ssh' }],
|
||||
status: {
|
||||
logicalOperator: 'or',
|
||||
values: [{ equals: 'active' }, { equals: 'pending' }],
|
||||
},
|
||||
created_time: [{ gte: date }],
|
||||
},
|
||||
sort: { attributes: ['name'], direction: 'desc' },
|
||||
};
|
||||
|
||||
const { sql, parameters } = generateSQLExpressions('target', query, {
|
||||
page: 2,
|
||||
pageSize: 15,
|
||||
select: ['data'],
|
||||
});
|
||||
|
||||
assert.strictEqual(
|
||||
sql,
|
||||
`
|
||||
SELECT data FROM "target"
|
||||
WHERE (type = ?) AND (status IN (?, ?)) AND (created_time >= ?) AND rowid IN (SELECT rowid FROM target_fts WHERE target_fts MATCH ?)
|
||||
ORDER BY name COLLATE NOCASE DESC, name DESC
|
||||
LIMIT ? OFFSET ?`.removeExtraWhiteSpace(),
|
||||
);
|
||||
|
||||
assert.deepEqual(parameters, [
|
||||
'ssh',
|
||||
'active',
|
||||
'pending',
|
||||
isoDateString,
|
||||
'"favorite"*',
|
||||
15,
|
||||
15,
|
||||
]);
|
||||
});
|
||||
|
||||
test.each(
|
||||
'it handles empty and invalid fields',
|
||||
{
|
||||
emptyObject: {},
|
||||
emptySearch: { search: '' },
|
||||
emptyFilter: { filters: {} },
|
||||
emptyFilterAttributes: { filters: { type: [] } },
|
||||
invalidSortAttribute: {
|
||||
sort: { attributes: ['test'], direction: 'asc' },
|
||||
},
|
||||
invalidCoalescedSortAttribute: {
|
||||
sort: {
|
||||
attributes: ['id', 'test'],
|
||||
direction: 'asc',
|
||||
isCoalesced: true,
|
||||
},
|
||||
},
|
||||
onlyPage: { page: 10 },
|
||||
onlyPageSize: { pageSize: 5 },
|
||||
},
|
||||
function (assert, query) {
|
||||
const { sql: targetSql, parameters: targetParameters } =
|
||||
generateSQLExpressions('target', query);
|
||||
assert.strictEqual(
|
||||
targetSql,
|
||||
`
|
||||
SELECT * FROM "target"
|
||||
ORDER BY created_time DESC`.removeExtraWhiteSpace(),
|
||||
);
|
||||
assert.deepEqual(targetParameters, []);
|
||||
|
||||
const { sql: resourceSql, parameters: resourceParameters } =
|
||||
generateSQLExpressions('resource', query);
|
||||
assert.strictEqual(
|
||||
resourceSql,
|
||||
`
|
||||
SELECT * FROM "resource"`.removeExtraWhiteSpace(),
|
||||
);
|
||||
assert.deepEqual(resourceParameters, []);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -38,7 +38,7 @@
|
||||
"@ember/optional-features": "^2.1.0",
|
||||
"@ember/test-helpers": "^5.2.1",
|
||||
"@embroider/test-setup": "^4.0.0",
|
||||
"@glimmer/component": "^1.1.2",
|
||||
"@glimmer/component": "^2.0.0",
|
||||
"@glimmer/tracking": "^1.1.2",
|
||||
"broccoli-asset-rev": "^3.0.0",
|
||||
"concurrently": "^9.1.0",
|
||||
|
||||
@@ -33,7 +33,8 @@ export function notifySuccess(notification) {
|
||||
const text = intlService.t(candidateKey);
|
||||
const value = await method.apply(this, arguments);
|
||||
notifyService.success(text, {
|
||||
notificationType: 'success',
|
||||
color: 'success',
|
||||
title: intlService.t('states.success'),
|
||||
dismiss: (flash) => flash.destroyMessage(),
|
||||
});
|
||||
return value;
|
||||
@@ -99,7 +100,8 @@ export function notifyError(
|
||||
const sticky = options.sticky === undefined ? true : options.sticky;
|
||||
|
||||
notifyService.danger(text, {
|
||||
notificationType: 'error',
|
||||
color: 'critical',
|
||||
title: intlService.t('states.error'),
|
||||
sticky,
|
||||
dismiss: (flash) => flash.destroyMessage(),
|
||||
});
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
"ember-cli-htmlbars": "^6.3.0",
|
||||
"ember-cli-sass": "^11.0.1",
|
||||
"ember-concurrency": "^4.0.3",
|
||||
"ember-feature-flags": "^6.0.0",
|
||||
"ember-feature-flags": "^7.0.0",
|
||||
"ember-intl": "^7.1.8",
|
||||
"ember-loading": "^2.0.0",
|
||||
"ember-truth-helpers": "^4.0.3",
|
||||
@@ -56,7 +56,7 @@
|
||||
"@ember/test-helpers": "^5.2.1",
|
||||
"@embroider/test-setup": "^4.0.0",
|
||||
"@faker-js/faker": "^8.0.2",
|
||||
"@glimmer/component": "^1.1.2",
|
||||
"@glimmer/component": "^2.0.0",
|
||||
"@glimmer/tracking": "^1.1.2",
|
||||
"broccoli-asset-rev": "^3.0.0",
|
||||
"concurrently": "^9.1.0",
|
||||
|
||||
@@ -55,6 +55,9 @@ do-this-later: Do this later
|
||||
toggle-color-theme: Toggle Color Theme
|
||||
toggle-feature-edition: Toggle Feature Edition
|
||||
toggle-licensed-features: Toggle Licensed Features
|
||||
manage-db: Manage SQLite
|
||||
download-db: Download SQLite DB
|
||||
clear-db: Clear SQLite DB
|
||||
more: ', +{remainingItems} more'
|
||||
search: Search
|
||||
yes: Yes
|
||||
|
||||
@@ -27,3 +27,7 @@ onboarding-failed:
|
||||
required-field: This is a required field.
|
||||
client-agent-failed:
|
||||
sessions: Unable to retrieve sessions from the client agent.
|
||||
rdp-warning:
|
||||
title: Continue in your RDP client
|
||||
description: Your RDP client may show a pop-up to accept a certificate to connect. This is normal for RDP clients.
|
||||
do-not-show-again: Do not show again
|
||||
@@ -50,6 +50,7 @@ session_max_seconds:
|
||||
session_connection_limit:
|
||||
label: Maximum Connections
|
||||
help: The maximum number of connections allowed per session. For unlimited, specify "-1".
|
||||
rdp-windows-notice: 'Note: The Windows Remote Desktop Connection client requires a connection limit of 2 or higher'
|
||||
session_id:
|
||||
label: Session ID
|
||||
worker_release_version:
|
||||
|
||||
@@ -343,6 +343,12 @@ session:
|
||||
connected:
|
||||
title: Connected
|
||||
description: You can now access {targetDisplayName}
|
||||
stop-sessions-and-signout:
|
||||
title: Sign out of Boundary?
|
||||
description: Signing out will stop all active and pending target sessions. Are you sure you want to sign out?
|
||||
stop-sessions-before-quit:
|
||||
title: Close sessions before quitting?
|
||||
description: Quitting the app will stop all active and pending sessions. Are you sure you want to quit?
|
||||
actions:
|
||||
connect: Connect
|
||||
end: End session
|
||||
@@ -363,6 +369,7 @@ session:
|
||||
proxy-url:
|
||||
address-port: Address & port
|
||||
ssh: SSH
|
||||
rdp: RDP
|
||||
shell: Shell
|
||||
global:
|
||||
title: Global
|
||||
@@ -722,9 +729,11 @@ target:
|
||||
types:
|
||||
tcp: Generic TCP
|
||||
ssh: SSH
|
||||
rdp: RDP
|
||||
help:
|
||||
tcp: Generic TCP supports a broad range of connection types.
|
||||
ssh: Protocol-aware SSH with support for credential injection.
|
||||
rdp: Remote Desktop Protocol for Windows servers.
|
||||
filter:
|
||||
active-sessions:
|
||||
yes: Has active sessions
|
||||
@@ -742,6 +751,7 @@ target:
|
||||
default_client_port:
|
||||
label: Default Client Port
|
||||
help: The local proxy port on which to listen by default when a session is started on a client.
|
||||
rdp-windows-notice: 'Note: Windows OS prevents port 3389 from being used.'
|
||||
sidebar:
|
||||
label: Session Recording
|
||||
help: Record SSH sessions on this target and save them to a storage bucket.
|
||||
@@ -804,6 +814,10 @@ target:
|
||||
add:
|
||||
title: Add Injected Application Credential Sources
|
||||
description: Select injected Application credential sources to assign to this target.
|
||||
alert:
|
||||
title: Cannot connect to this target without injected application credentials
|
||||
description: Injected application credentials are used by Boundary to sign in to remote resources without showing them to the end-user.
|
||||
action: Add injected application credentials
|
||||
workers:
|
||||
description: You can customize how your workers route traffic to this target by setting up a filter that matches specific worker tags. If your target is in a private network, we recommend setting up an egress filter to specify a worker inside the network so that Boundary can access the host. Ingress and egress filters are allowed to select the same worker.
|
||||
messages:
|
||||
@@ -898,10 +912,12 @@ credential-library:
|
||||
new: New Credential Library
|
||||
ssh_private_key: SSH Private Key
|
||||
username_password: Username & Password
|
||||
username_password_domain: Username, Password & Domain
|
||||
username_attribute: Username
|
||||
password_attribute: Password
|
||||
private_key_passphrase_attribute: Private Key Passphrase
|
||||
private_key_attribute: Private Key
|
||||
domain_attribute: Domain
|
||||
actions:
|
||||
create: New Credential Library
|
||||
delete: Delete Credential Library
|
||||
@@ -916,6 +932,7 @@ credential-library:
|
||||
credential_type:
|
||||
label: Credential Type
|
||||
help: Specify the type of credential this library will issue. This must be set to use credential injection. After this data is saved, it cannot be changed later.
|
||||
description: The type of credential the library uses for credential injection.
|
||||
credential_mapping_overrides:
|
||||
label: Credential mapping overrides
|
||||
help: Specify the credential fields to use instead of the defaults
|
||||
@@ -949,9 +966,11 @@ credential-library:
|
||||
types:
|
||||
vault-generic: Generic Secrets
|
||||
vault-ssh-certificate: SSH Certificates
|
||||
vault-ldap: LDAP
|
||||
help:
|
||||
vault-generic: Authorize sessions using credentials brokered by Vault
|
||||
vault-ssh-certificate: Authorize sessions using the SSH Secrets Engine in Vault
|
||||
vault-ldap: Authorize sessions using the LDAP Secrets Engine in Vault
|
||||
credential:
|
||||
title: Credential
|
||||
title_plural: Credentials
|
||||
@@ -968,12 +987,14 @@ credential:
|
||||
types:
|
||||
username_password: Username & Password
|
||||
ssh_private_key: Username & Key Pair
|
||||
username_password_domain: Username, Password & Domain
|
||||
json: JSON
|
||||
unknown: Unknown
|
||||
help:
|
||||
username_password: Connect using the provided username and password.
|
||||
ssh_private_key: Connect using a username, public key, and private key.
|
||||
json: Connect using a JSON blob containing the credentials.
|
||||
username_password_domain: Includes a domain field for Active Directory
|
||||
form:
|
||||
username:
|
||||
label: Username
|
||||
@@ -981,6 +1002,9 @@ credential:
|
||||
password:
|
||||
label: Password
|
||||
help: The password to use when signing in with this credential store.
|
||||
domain:
|
||||
label: Domain
|
||||
help: The Active Directory domain or FQDN (e.g., contoso.local)
|
||||
private_key:
|
||||
label: SSH Private Key
|
||||
help: The private key to use when connecting with this credential store.
|
||||
@@ -1200,7 +1224,7 @@ alias:
|
||||
help: Specify any target across your deployment to associate this alias with
|
||||
value:
|
||||
label: Alias Value
|
||||
help: The alias you would like to use for this resource
|
||||
help: The alias you would like to use for this resource. If using Windows, using a "." in the hostname is recommended.
|
||||
authorize_session_arguments:
|
||||
label: Host ID
|
||||
help: Always use this host when connecting using this alias
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
export default function mapTypeToColor(flashMsgType) {
|
||||
// Maps the flash message type parameter to the correct hds color.
|
||||
switch (flashMsgType) {
|
||||
case 'error':
|
||||
return 'critical';
|
||||
case 'success':
|
||||
return 'success';
|
||||
case 'warning':
|
||||
return 'warning';
|
||||
default:
|
||||
return 'neutral';
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import { modifier } from 'ember-modifier';
|
||||
|
||||
/**
|
||||
* Used to detect clicks occurring _inside_ the modified element.
|
||||
* This is useful especially for dropdowns to toggle content visibility
|
||||
* when the user clicks an item.
|
||||
*/
|
||||
export default modifier(
|
||||
(element, [callback]) => {
|
||||
function handleClick(event) {
|
||||
if (element.contains(event.target)) {
|
||||
callback(element, event);
|
||||
}
|
||||
}
|
||||
|
||||
element.addEventListener('click', handleClick, false);
|
||||
|
||||
return () => {
|
||||
element.removeEventListener('click', handleClick, false);
|
||||
};
|
||||
},
|
||||
{ eager: false },
|
||||
);
|
||||
@@ -1,28 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import { modifier } from 'ember-modifier';
|
||||
|
||||
/**
|
||||
* Used to detect clicks occurring _outside_ of the modified element.
|
||||
* This is useful especially for dropdowns to toggle content visibility
|
||||
* when the user clicks away.
|
||||
*/
|
||||
export default modifier(
|
||||
(element, [callback]) => {
|
||||
function handleClick(event) {
|
||||
if (!element.contains(event.target)) {
|
||||
callback(element, event);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('click', handleClick, false);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('click', handleClick, false);
|
||||
};
|
||||
},
|
||||
{ eager: false },
|
||||
);
|
||||
@@ -1,6 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
export { default } from 'rose/modifiers/on-click-inside';
|
||||
@@ -1,6 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
export { default } from 'rose/modifiers/on-click-outside';
|
||||
@@ -16,8 +16,19 @@ body {
|
||||
// and link list panel
|
||||
font-family: typography.$family-primary;
|
||||
|
||||
// these 3 are used across the app
|
||||
// this is used across the app
|
||||
line-height: typography.line-height(m);
|
||||
}
|
||||
|
||||
// This is use to set the color and background color
|
||||
// of the ember application depending
|
||||
// on where the ember application is mounted
|
||||
// When running the tests, the ember application
|
||||
// is mounted on a div element with the class 'ember-application'
|
||||
// When running the app, the ember application
|
||||
// is mounted on the body element
|
||||
.ember-application {
|
||||
// these are used across the app
|
||||
color: var(--stark);
|
||||
background-color: var(--subtle);
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
"@ember/optional-features": "^2.1.0",
|
||||
"@ember/test-helpers": "^5.2.1",
|
||||
"@embroider/test-setup": "^4.0.0",
|
||||
"@glimmer/component": "^1.1.2",
|
||||
"@glimmer/component": "^2.0.0",
|
||||
"@glimmer/tracking": "^1.1.2",
|
||||
"broccoli-asset-rev": "^3.0.0",
|
||||
"concurrently": "^9.1.0",
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import { module, test } from 'qunit';
|
||||
import { setupRenderingTest } from 'ember-qunit';
|
||||
import { render } from '@ember/test-helpers';
|
||||
import { hbs } from 'ember-cli-htmlbars';
|
||||
|
||||
module('Integration | Helper | map-type-to-color', function (hooks) {
|
||||
setupRenderingTest(hooks);
|
||||
|
||||
test('it returns correct color when flash message type is `success`', async function (assert) {
|
||||
this.set('inputValue', 'success');
|
||||
|
||||
await render(hbs`{{map-type-to-color this.inputValue}}`);
|
||||
|
||||
assert.dom(this.element).hasText('success');
|
||||
});
|
||||
|
||||
test('it returns correct color when flash message type is `error`', async function (assert) {
|
||||
this.set('inputValue', 'error');
|
||||
|
||||
await render(hbs`{{map-type-to-color this.inputValue}}`);
|
||||
|
||||
assert.dom(this.element).hasText('critical');
|
||||
});
|
||||
|
||||
test('it returns correct color when flash message type is `warning`', async function (assert) {
|
||||
this.set('inputValue', 'warning');
|
||||
|
||||
await render(hbs`{{map-type-to-color this.inputValue}}`);
|
||||
|
||||
assert.dom(this.element).hasText('warning');
|
||||
});
|
||||
|
||||
test('it returns default color when flash message type does not match', async function (assert) {
|
||||
this.set('inputValue', 'other');
|
||||
|
||||
await render(hbs`{{map-type-to-color this.inputValue}}`);
|
||||
|
||||
assert.dom(this.element).hasText('neutral');
|
||||
});
|
||||
});
|
||||
@@ -1,42 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import { module, test } from 'qunit';
|
||||
import { setupRenderingTest } from 'ember-qunit';
|
||||
import { render, click } from '@ember/test-helpers';
|
||||
import { hbs } from 'ember-cli-htmlbars';
|
||||
|
||||
module('Integration | Modifier | on-click-inside', function (hooks) {
|
||||
setupRenderingTest(hooks);
|
||||
|
||||
test('it fires event when inside is clicked', async function (assert) {
|
||||
assert.expect(2);
|
||||
this.set('clickedInside', (element) => {
|
||||
assert.ok(element);
|
||||
assert.strictEqual(element.id, 'inside');
|
||||
});
|
||||
await render(hbs`
|
||||
<div id="outside"></div>
|
||||
<div id="inside" {{on-click-inside this.clickedInside}}>
|
||||
<div id="item" />
|
||||
</div>
|
||||
`);
|
||||
await click('#item');
|
||||
});
|
||||
|
||||
test('it does not fire event when outside is clicked', async function (assert) {
|
||||
assert.expect(0);
|
||||
this.set('clickedInside', () => {
|
||||
assert.ok(true);
|
||||
});
|
||||
await render(hbs`
|
||||
<div id="outside"></div>
|
||||
<div id="inside" {{on-click-inside this.clickedInside}}>
|
||||
<div id="item" />
|
||||
</div>
|
||||
`);
|
||||
await click('#outside');
|
||||
});
|
||||
});
|
||||
@@ -1,42 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import { module, test } from 'qunit';
|
||||
import { setupRenderingTest } from 'ember-qunit';
|
||||
import { render, click } from '@ember/test-helpers';
|
||||
import { hbs } from 'ember-cli-htmlbars';
|
||||
|
||||
module('Integration | Modifier | on-click-outside', function (hooks) {
|
||||
setupRenderingTest(hooks);
|
||||
|
||||
test('it fires event when an outside element is clicked', async function (assert) {
|
||||
assert.expect(2);
|
||||
this.set('clickedOutside', (element, event) => {
|
||||
assert.ok(element);
|
||||
assert.strictEqual(event.target.id, 'outside');
|
||||
});
|
||||
|
||||
await render(hbs`<div id="outside"></div>
|
||||
<div id="inside" {{on-click-outside this.clickedOutside}}></div>`);
|
||||
|
||||
await click('#outside');
|
||||
});
|
||||
|
||||
test('it does not fire event when element is clicked', async function (assert) {
|
||||
assert.expect(1);
|
||||
this.set('clickedOutside', () => {
|
||||
assert.ok(true);
|
||||
});
|
||||
|
||||
await render(hbs`<div id="outside"></div>
|
||||
<div id="inside" {{on-click-outside this.clickedOutside}}>
|
||||
<div id="item" />
|
||||
</div>`);
|
||||
|
||||
await click('#inside');
|
||||
await click('#item');
|
||||
await click('#outside');
|
||||
});
|
||||
});
|
||||
@@ -22,6 +22,11 @@
|
||||
- [Developing Tests](#developing-tests)
|
||||
- [Test names and tagging](#test-names-and-tagging)
|
||||
- [Selecting / Locating Elements](#selecting--locating-elements)
|
||||
- [Api Client Fixture](#api-client-fixture)
|
||||
- [Using the fixture](#using-the-fixture)
|
||||
- [Features](#features)
|
||||
- [Generating the api client](#generating-the-api-client)
|
||||
- [Testing with resources (api, page objects, cli)](#testing-with-resources-api-page-objects-cli)
|
||||
|
||||
<!-- END doctoc generated TOC please keep comment here to allow auto update -->
|
||||
|
||||
@@ -259,3 +264,80 @@ accessibility features perceive the page. `getByLabel` should be used for form f
|
||||
|
||||
Additional information can be found [here](https://playwright.dev/docs/locators#locating-elements)
|
||||
and [here](https://testing-library.com/docs/queries/about/#priority).
|
||||
|
||||
### Api Client Fixture
|
||||
The `apiClient` fixture is available in tests to make calls against the boundary api. This fixture wraps an api client that is auto-generated from the [boundary api openapi swagger spec](https://raw.githubusercontent.com/hashicorp/boundary/main/internal/gen/controller.swagger.json).
|
||||
|
||||
#### Using the fixture
|
||||
The api client fixture can be used in tests anywhere fixtures are available:
|
||||
```js
|
||||
// within a test
|
||||
test(
|
||||
'An example test',
|
||||
{ tag: ['@ce', '@ent', '@aws', '@docker'] },
|
||||
async ({ apiClient }) => {
|
||||
org = await apiClient.clients.Scope.scopeServiceCreateScope({
|
||||
item: {
|
||||
name: 'Org A',
|
||||
scopeId: 'global',
|
||||
},
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
// in `beforeEach` (also works the same for `afterEach`)
|
||||
test.beforeEach(async ({ apiClient }) => {
|
||||
org = await apiClient.clients.Scope.scopeServiceCreateScope({
|
||||
item: {
|
||||
name: 'Org A',
|
||||
scopeId: 'global',
|
||||
},
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
#### Features
|
||||
|
||||
* The api client should have all publicly documented APIs
|
||||
* Generated api client uses typescript to help with the discovery of the required arguments
|
||||
* Any untyped `attributes` that have been documented by a `description` in the swagger doc will also be included as a docblock comment when inspecting `attributes` in the editor (see `ControllerApiResourcesAccountsV1Account.attributes` in `models/ControllerApiResourcesAccountsV1Account.ts` for an example)
|
||||
* Automatic resource clean up
|
||||
* Any resources created by the api client will be automatically cleaned up after the test is finished. To skip cleanup of a resource pass in the created resource to `apiClient.skipCleanup(createdResource);` (where `createdResource` has an `id` property).
|
||||
* In some cases resources will need child resources, created outside the api, to be cleaned up first. For example, a storage bucket created by the api client cannot be automatically cleaned up until all session recordings have been deleted. Session recordings are created automatically and not tracked by the API so these need to be deleted manually. Session recordings cannot be deleted unless they are in a `available` state which depends on the session being recorded being in a `terminated` state. Sessions also are not created by the api and need to be cleaned up manually.
|
||||
* Error logging
|
||||
* When api calls fail with an http status code >= 400 the error response is logged
|
||||
|
||||
#### Generating the api client
|
||||
|
||||
The generated client is found in the `api-client` folder. Manual changes should not be made to any of the files in this folder because they will be overriden the next time the client is generated.
|
||||
|
||||
To regenerate the api-client run the `generate:api` package.json scripts command. By default this will use the latest swagger doc from the public [`boundary` repo](https://github.com/hashicorp/boundary) on the `main` branch at `main/internal/gen/controller.swagger.json`. Note: The `generate:api` script calls `openapi-generator-cli` which requires java to be available on your PATH environment variable.
|
||||
|
||||
```shell
|
||||
pnpm generate:api-client
|
||||
```
|
||||
|
||||
If working on an E2E test that is working against a different version of the boundary api, ensure the updated openapi swagger doc has been generated and set `OPENAPI_SWAGGER_URL_OR_FILE` to the path or url:
|
||||
```shell
|
||||
# using a custom url (must be publicly accessible)
|
||||
# in the following example $COMMITISH can be replaced with a branch name, commit, or tag
|
||||
OPENAPI_SWAGGER_URL_OR_FILE=https://raw.githubusercontent.com/hashicorp/boundary/$COMMITISH/internal/gen/controller.swagger.json pnpm generate:api-client
|
||||
|
||||
# using a local absolute or relative path
|
||||
OPENAPI_SWAGGER_URL_OR_FILE=../../boundary-enterprise/internal/gen/controller.swagger.json pnpm generate:api-client
|
||||
```
|
||||
|
||||
Consider committing any changes to the generated `api-client` directory if the changes are needed for the current e2e tests to pass.
|
||||
|
||||
### Testing with resources (api, page objects, cli)
|
||||
|
||||
When testing an E2E workflow, resources often use page objects (within `admin/pages` and `desktop/pages`) or use methods on playwright locators directly. These simulate user actions to ensure that we are testing our applications the way our users use them.
|
||||
|
||||
In cases where resources need to be scaffolded as a prerequisite to a test, but are not needed to be tested directly, the `apiClient` fixture should be used. This type of common setup can usually be done in `test.beforeEach` to separate it from the `test` itself. Using the `apiClient` has the added benefit of:
|
||||
* being quicker to set up (api calls are faster)
|
||||
* return api responses and resource definitions
|
||||
* any created resource is automatically cleaned up after the test
|
||||
|
||||
Aside from creating resources, the `apiClient` fixture can be used in any case where interaction with the boundary api is needed.
|
||||
|
||||
As a last resort when page objects and the `apiClient` fixture can't be used then the boundary cli helper can be used.
|
||||
@@ -15,20 +15,9 @@ export class AliasesPage extends BaseResourcePage {
|
||||
* @param {string} targetId ID of the target
|
||||
* @returns Name of the alias
|
||||
*/
|
||||
async createAliasForTarget(alias, targetId, orgName) {
|
||||
async createAliasForTarget(alias, targetId) {
|
||||
const aliasName = 'Alias ' + nanoid();
|
||||
|
||||
await this.page
|
||||
.getByRole('link', { name: `Back to ${orgName}`, exact: true })
|
||||
.click();
|
||||
await this.page.getByRole('link', { name: 'Orgs' }).click();
|
||||
await this.page.getByRole('link', { name: 'Aliases' }).click();
|
||||
await expect(
|
||||
this.page
|
||||
.getByRole('navigation', { name: 'breadcrumbs' })
|
||||
.getByText('Aliases'),
|
||||
).toBeVisible();
|
||||
|
||||
const newButtonIsVisible = await this.page
|
||||
.getByRole('link', { name: 'Create a new alias', exact: true })
|
||||
.isVisible();
|
||||
|
||||
@@ -97,7 +97,7 @@ export class CredentialStoresPage extends BaseResourcePage {
|
||||
await this.page.getByLabel('Address').fill(vaultAddr);
|
||||
await this.page
|
||||
.getByLabel('Worker filter')
|
||||
.locator('textarea')
|
||||
.getByRole('textbox')
|
||||
.fill(workerFilter);
|
||||
await this.page.getByLabel('Token').fill(clientToken);
|
||||
await this.page.getByRole('button', { name: 'Save' }).click();
|
||||
@@ -153,7 +153,9 @@ export class CredentialStoresPage extends BaseResourcePage {
|
||||
.getByRole('group', { name: 'Type' })
|
||||
.getByLabel('Username & Key Pair')
|
||||
.click();
|
||||
await this.page.getByLabel('Username', { exact: true }).fill(username);
|
||||
await this.page
|
||||
.getByLabel('Username Required', { exact: true })
|
||||
.fill(username);
|
||||
const keyData = await readFile(keyPath, {
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
@@ -211,8 +213,77 @@ export class CredentialStoresPage extends BaseResourcePage {
|
||||
.getByRole('group', { name: 'Type' })
|
||||
.getByLabel('Username & Password')
|
||||
.click();
|
||||
await this.page.getByLabel('Username', { exact: true }).fill(username);
|
||||
await this.page.getByLabel('Password', { exact: true }).fill(password);
|
||||
await this.page
|
||||
.getByLabel('Username Required', { exact: true })
|
||||
.fill(username);
|
||||
await this.page
|
||||
.getByLabel('Password Required', { exact: true })
|
||||
.fill(password);
|
||||
await this.page.getByRole('button', { name: 'Save' }).click();
|
||||
await this.dismissSuccessAlert();
|
||||
await expect(
|
||||
this.page
|
||||
.getByRole('navigation', { name: 'breadcrumbs' })
|
||||
.getByText(credentialName),
|
||||
).toBeVisible();
|
||||
|
||||
return credentialName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a static username, password & Domain credential.
|
||||
* Assumes you have selected the desired project.
|
||||
* @param {string} username username for the credential
|
||||
* @param {string} password password for the credential
|
||||
* @param {string} domain domain for the credential
|
||||
* @returns Name of the credential
|
||||
*/
|
||||
async createStaticCredentialUsernamePasswordDomain(
|
||||
username,
|
||||
password,
|
||||
domain,
|
||||
) {
|
||||
const credentialsBreadcrumbIsVisible = await this.page
|
||||
.getByRole('breadcrumbs', { name: 'Credentials' })
|
||||
.isVisible();
|
||||
if (credentialsBreadcrumbIsVisible) {
|
||||
await this.page.getByRole('breadcrumbs', { name: 'Credentials' }).click();
|
||||
} else {
|
||||
await this.page
|
||||
.getByRole('link', { name: 'Credentials', exact: true })
|
||||
.click();
|
||||
}
|
||||
|
||||
await expect(
|
||||
this.page
|
||||
.getByRole('navigation', { name: 'breadcrumbs' })
|
||||
.getByText('Credentials'),
|
||||
).toBeVisible();
|
||||
|
||||
const newButtonIsVisible = await this.page
|
||||
.getByRole('link', { name: 'New', exact: true })
|
||||
.isVisible();
|
||||
if (newButtonIsVisible) {
|
||||
await this.page.getByRole('link', { name: 'New', exact: true }).click();
|
||||
} else {
|
||||
await this.page.getByText('Manage').click();
|
||||
await this.page.getByRole('link', { name: 'New Credential' }).click();
|
||||
}
|
||||
|
||||
const credentialName = 'Credential ' + nanoid();
|
||||
await this.page.getByLabel('Name (Optional)').fill(credentialName);
|
||||
await this.page.getByLabel('Description').fill('This is an automated test');
|
||||
await this.page
|
||||
.getByRole('group', { name: 'Type' })
|
||||
.getByLabel('Username, Password & Domain')
|
||||
.click();
|
||||
await this.page
|
||||
.getByLabel('Username Required', { exact: true })
|
||||
.fill(username);
|
||||
await this.page
|
||||
.getByLabel('Password Required', { exact: true })
|
||||
.fill(password);
|
||||
await this.page.getByLabel('Domain Required', { exact: true }).fill(domain);
|
||||
await this.page.getByRole('button', { name: 'Save' }).click();
|
||||
await this.dismissSuccessAlert();
|
||||
await expect(
|
||||
|
||||
@@ -31,7 +31,9 @@ export class StorageBucketsPage extends BaseResourcePage {
|
||||
await this.page
|
||||
.getByRole('link', { name: 'Storage Buckets', exact: true })
|
||||
.click();
|
||||
await this.page.getByRole('link', { name: 'New Storage Bucket' }).click();
|
||||
await this.page
|
||||
.getByRole('link', { name: 'New Storage Bucket', exact: true })
|
||||
.click();
|
||||
await this.page.getByLabel('Name (Optional)').fill(storageBucketName);
|
||||
await this.page.getByLabel('Scope').selectOption({ label: scope });
|
||||
await this.page
|
||||
@@ -44,7 +46,7 @@ export class StorageBucketsPage extends BaseResourcePage {
|
||||
await this.page.getByLabel('Secret access key').fill(secretAccessKey);
|
||||
await this.page
|
||||
.getByLabel('Worker filter')
|
||||
.locator('textarea')
|
||||
.getByRole('textbox')
|
||||
.fill(workerFilter);
|
||||
await this.page.getByLabel('Disable credential rotation').click();
|
||||
await this.page.getByRole('button', { name: 'Save' }).click();
|
||||
@@ -82,7 +84,9 @@ export class StorageBucketsPage extends BaseResourcePage {
|
||||
await this.page
|
||||
.getByRole('link', { name: 'Storage Buckets', exact: true })
|
||||
.click();
|
||||
await this.page.getByRole('link', { name: 'New Storage Bucket' }).click();
|
||||
await this.page
|
||||
.getByRole('link', { name: 'New Storage Bucket', exact: true })
|
||||
.click();
|
||||
await this.page.getByLabel('Name (Optional)').fill(storageBucketName);
|
||||
await this.page.getByLabel('Scope').selectOption({ label: scope });
|
||||
await this.page
|
||||
@@ -96,7 +100,7 @@ export class StorageBucketsPage extends BaseResourcePage {
|
||||
await this.page.getByLabel('Secret access key').fill(secretAccessKey);
|
||||
await this.page
|
||||
.getByLabel('Worker filter')
|
||||
.locator('textarea')
|
||||
.getByRole('textbox')
|
||||
.fill(workerFilter);
|
||||
await this.page.getByLabel('Disable credential rotation').click();
|
||||
await this.page.getByRole('button', { name: 'Save' }).click();
|
||||
|
||||
@@ -70,8 +70,33 @@ export class TargetsPage extends BaseResourcePage {
|
||||
* @param {string} alias alias used for the target
|
||||
* @returns Name of the target
|
||||
*/
|
||||
async createTargetWithAddressAndAlias(address, port, alias) {
|
||||
async createTargetWithAddressAndAlias(
|
||||
address,
|
||||
port,
|
||||
alias,
|
||||
targetType = 'tcp',
|
||||
) {
|
||||
const targetName = 'Target ' + nanoid();
|
||||
let targetTypeLabel;
|
||||
|
||||
switch (targetType) {
|
||||
case 'ssh': {
|
||||
targetTypeLabel = 'SSH';
|
||||
break;
|
||||
}
|
||||
case 'rdp': {
|
||||
targetTypeLabel = 'RDP';
|
||||
break;
|
||||
}
|
||||
case 'tcp': {
|
||||
targetTypeLabel = 'Generic TCP';
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
throw new Error(`Unexpected target type passed ${targetType}`);
|
||||
}
|
||||
}
|
||||
|
||||
await this.page
|
||||
.getByRole('navigation', { name: 'Application local navigation' })
|
||||
.getByRole('link', { name: 'Targets' })
|
||||
@@ -79,6 +104,7 @@ export class TargetsPage extends BaseResourcePage {
|
||||
await this.page.getByRole('link', { name: 'New', exact: true }).click();
|
||||
await this.page.getByLabel('Name').fill(targetName);
|
||||
await this.page.getByLabel('Description').fill('This is an automated test');
|
||||
await this.page.getByLabel(targetTypeLabel).click();
|
||||
await this.page.getByLabel('Target Address').fill(address);
|
||||
await this.page.getByLabel('Default Port').fill(port);
|
||||
await this.page
|
||||
@@ -236,6 +262,112 @@ export class TargetsPage extends BaseResourcePage {
|
||||
return targetName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new RDP target.
|
||||
* Assumes you have selected the desired project.
|
||||
* @param {string} port Port of the target
|
||||
* @returns Name of the target
|
||||
*/
|
||||
async createRdpTargetEnt(port) {
|
||||
const targetName = 'Target ' + nanoid();
|
||||
await this.page
|
||||
.getByRole('navigation', { name: 'Application local navigation' })
|
||||
.getByRole('link', { name: 'Targets' })
|
||||
.click();
|
||||
await this.page.getByRole('link', { name: 'New', exact: true }).click();
|
||||
await this.page.getByLabel('Name').fill(targetName);
|
||||
await this.page.getByLabel('Description').fill('This is an automated test');
|
||||
await this.page
|
||||
.getByRole('group', { name: 'Type' })
|
||||
.getByLabel('RDP')
|
||||
.click();
|
||||
await this.page.getByLabel('Default Port').fill(port);
|
||||
await this.page.getByRole('button', { name: 'Save' }).click();
|
||||
await this.dismissSuccessAlert();
|
||||
await expect(
|
||||
this.page
|
||||
.getByRole('navigation', { name: 'breadcrumbs' })
|
||||
.getByText(targetName),
|
||||
).toBeVisible();
|
||||
|
||||
return targetName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new RDP target with address in boundary-enterprise
|
||||
* Assumes you have selected the desired project.
|
||||
* @param {string} address Address of the target
|
||||
* @param {string} port Port of the target
|
||||
* @returns Name of the target
|
||||
*/
|
||||
async createRDPTargetWithAddressEnt(address, port) {
|
||||
const targetName = 'Target ' + nanoid();
|
||||
await this.page
|
||||
.getByRole('navigation', { name: 'Application local navigation' })
|
||||
.getByRole('link', { name: 'Targets' })
|
||||
.click();
|
||||
await this.page.getByRole('link', { name: 'New', exact: true }).click();
|
||||
await this.page.getByLabel('Name').fill(targetName);
|
||||
await this.page.getByLabel('Description').fill('This is an automated test');
|
||||
await this.page
|
||||
.getByRole('group', { name: 'Type' })
|
||||
.getByLabel('RDP')
|
||||
.click();
|
||||
await this.page.getByLabel('Target Address').fill(address);
|
||||
await this.page.getByLabel('Default Port').fill(port);
|
||||
await this.page.getByRole('button', { name: 'Save' }).click();
|
||||
await this.dismissSuccessAlert();
|
||||
await expect(
|
||||
this.page
|
||||
.getByRole('navigation', { name: 'breadcrumbs' })
|
||||
.getByText(targetName),
|
||||
).toBeVisible();
|
||||
|
||||
return targetName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new RDP target with address and alias.
|
||||
* Assumes you have selected the desired project.
|
||||
* @param {string} address Address of the target
|
||||
* @param {string} port Port of the target
|
||||
* @param {string} alias alias used for the target
|
||||
* @returns Name of the target
|
||||
*/
|
||||
|
||||
async createRdpTargetWithAddressAndAlias(address, port, alias) {
|
||||
const targetName = 'Target ' + nanoid();
|
||||
await this.page
|
||||
.getByRole('navigation', { name: 'Application local navigation' })
|
||||
.getByRole('link', { name: 'Targets' })
|
||||
.click();
|
||||
await this.page.getByRole('link', { name: 'New', exact: true }).click();
|
||||
await this.page.getByLabel('Name').fill(targetName);
|
||||
await this.page.getByLabel('Description').fill('This is an automated test');
|
||||
await this.page
|
||||
.getByRole('group', { name: 'Type' })
|
||||
.getByLabel('RDP')
|
||||
.click();
|
||||
await this.page.getByLabel('Target Address').fill(address);
|
||||
await this.page.getByLabel('Default Port').fill(port);
|
||||
await this.page
|
||||
.getByRole('group', { name: 'Aliases' })
|
||||
.getByLabel('value')
|
||||
.last()
|
||||
.fill(alias);
|
||||
await this.page.getByRole('button', { name: 'Add' }).click();
|
||||
|
||||
await this.page.getByRole('button', { name: 'Save' }).click();
|
||||
await this.dismissSuccessAlert();
|
||||
await expect(
|
||||
this.page
|
||||
.getByRole('navigation', { name: 'breadcrumbs' })
|
||||
.getByText(targetName),
|
||||
).toBeVisible();
|
||||
|
||||
return targetName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a host source to a target. Assume you have selected the desired target.
|
||||
* @param {string} hostSourceName Name of host source that will be attached to the target
|
||||
@@ -261,8 +393,8 @@ export class TargetsPage extends BaseResourcePage {
|
||||
.click();
|
||||
|
||||
await this.page
|
||||
.getByRole('cell', { name: hostSourceName })
|
||||
.locator('..')
|
||||
.getByRole('row')
|
||||
.filter({ has: this.page.getByRole('cell', { name: hostSourceName }) })
|
||||
.getByRole('checkbox')
|
||||
.click({ force: true });
|
||||
await this.page.getByRole('button', { name: 'Add Host Sources' }).click();
|
||||
@@ -274,9 +406,8 @@ export class TargetsPage extends BaseResourcePage {
|
||||
|
||||
async removeHostSourceFromTarget(hostSourceName) {
|
||||
await this.page
|
||||
.getByRole('link', { name: hostSourceName })
|
||||
.locator('..')
|
||||
.locator('..')
|
||||
.getByRole('row')
|
||||
.filter({ has: this.page.getByRole('link', { name: hostSourceName }) })
|
||||
.getByRole('button', { name: 'Manage' })
|
||||
.click();
|
||||
await this.page.getByRole('button', { name: 'Remove' }).click();
|
||||
@@ -307,7 +438,7 @@ export class TargetsPage extends BaseResourcePage {
|
||||
.getByText('Edit Ingress Worker Filter'),
|
||||
).toBeVisible();
|
||||
|
||||
await this.page.locator('textarea').fill(filter);
|
||||
await this.page.locator('.CodeMirror').getByRole('textbox').fill(filter);
|
||||
|
||||
await this.page.getByRole('button', { name: 'Save' }).click();
|
||||
await this.dismissSuccessAlert();
|
||||
@@ -336,7 +467,7 @@ export class TargetsPage extends BaseResourcePage {
|
||||
.getByText('Edit Egress Worker Filter'),
|
||||
).toBeVisible();
|
||||
|
||||
await this.page.locator('textarea').fill(filter);
|
||||
await this.page.locator('.CodeMirror').getByRole('textbox').fill(filter);
|
||||
|
||||
await this.page.getByRole('button', { name: 'Save' }).click();
|
||||
await this.dismissSuccessAlert();
|
||||
@@ -377,8 +508,8 @@ export class TargetsPage extends BaseResourcePage {
|
||||
.click();
|
||||
|
||||
await this.page
|
||||
.getByRole('cell', { name: credentialName })
|
||||
.locator('..')
|
||||
.getByRole('row')
|
||||
.filter({ has: this.page.getByRole('cell', { name: credentialName }) })
|
||||
.getByRole('checkbox')
|
||||
.click({ force: true });
|
||||
await this.page
|
||||
@@ -434,8 +565,8 @@ export class TargetsPage extends BaseResourcePage {
|
||||
.click();
|
||||
|
||||
await this.page
|
||||
.getByRole('cell', { name: credentialName })
|
||||
.locator('..')
|
||||
.getByRole('row')
|
||||
.filter({ has: this.page.getByRole('cell', { name: credentialName }) })
|
||||
.getByRole('checkbox')
|
||||
.click({ force: true });
|
||||
await this.page
|
||||
|
||||
@@ -46,8 +46,8 @@ export class UsersPage extends BaseResourcePage {
|
||||
.getByRole('link', { name: 'Add Accounts', exact: true })
|
||||
.click();
|
||||
await this.page
|
||||
.getByRole('cell', { name: loginName })
|
||||
.locator('..')
|
||||
.getByRole('row')
|
||||
.filter({ has: this.page.getByRole('cell', { name: loginName }) })
|
||||
.getByRole('checkbox')
|
||||
.click({ force: true });
|
||||
|
||||
|
||||
@@ -38,9 +38,8 @@ export class WorkersPage extends BaseResourcePage {
|
||||
*/
|
||||
async editTag(origKey, newKey, newValue) {
|
||||
await this.page
|
||||
.getByRole('table')
|
||||
.getByRole('cell', { name: origKey })
|
||||
.locator('..')
|
||||
.getByRole('row')
|
||||
.filter({ has: this.page.getByRole('cell', { name: origKey }) })
|
||||
.getByRole('cell', { name: 'Overflow Options' })
|
||||
.click();
|
||||
await this.page.getByRole('button', { name: 'Edit Tag' }).click();
|
||||
@@ -58,9 +57,8 @@ export class WorkersPage extends BaseResourcePage {
|
||||
*/
|
||||
async removeTag(key) {
|
||||
await this.page
|
||||
.getByRole('table')
|
||||
.getByRole('cell', { name: key })
|
||||
.locator('..')
|
||||
.getByRole('row')
|
||||
.filter({ has: this.page.getByRole('cell', { name: key }) })
|
||||
.getByRole('cell', { name: 'Overflow Options' })
|
||||
.click();
|
||||
await this.page.getByRole('button', { name: 'Remove Tag' }).click();
|
||||
|
||||
@@ -169,6 +169,7 @@ test.describe('Aliases (Enterprise)', () => {
|
||||
targetAddress,
|
||||
targetPort,
|
||||
alias,
|
||||
'ssh',
|
||||
);
|
||||
const credentialStoresPage = new CredentialStoresPage(page);
|
||||
await credentialStoresPage.createStaticCredentialStore();
|
||||
@@ -268,16 +269,27 @@ test.describe('Aliases (Enterprise)', () => {
|
||||
targetName,
|
||||
);
|
||||
|
||||
await page
|
||||
.getByRole('link', { name: `Back to ${orgName}`, exact: true })
|
||||
.click();
|
||||
await page.getByRole('link', { name: 'Back to Global' }).click();
|
||||
await page.getByRole('link', { name: 'Aliases' }).click();
|
||||
await expect(
|
||||
page
|
||||
.getByRole('navigation', { name: 'breadcrumbs' })
|
||||
.getByText('Aliases'),
|
||||
).toBeVisible();
|
||||
alias = 'example.alias.' + nanoid();
|
||||
const aliasesPage = new AliasesPage(page);
|
||||
await aliasesPage.createAliasForTarget(alias, targetId, orgName);
|
||||
connect = await boundaryCli.connectSshToAlias(alias);
|
||||
await aliasesPage.createAliasForTarget(alias, targetId);
|
||||
await page
|
||||
.getByRole('navigation', { name: 'Application local navigation' })
|
||||
.getByRole('link', { name: 'Orgs' })
|
||||
.click();
|
||||
await page.getByRole('link', { name: orgName }).click();
|
||||
await page.getByRole('link', { name: projectName }).click();
|
||||
|
||||
connect = await boundaryCli.connectSshToAlias(alias);
|
||||
const sessionsPage = new SessionsPage(page);
|
||||
await sessionsPage.waitForSessionToBeVisible(targetName);
|
||||
} finally {
|
||||
|
||||
@@ -228,9 +228,19 @@ test.describe('Aliases', () => {
|
||||
targetName,
|
||||
);
|
||||
|
||||
await page
|
||||
.getByRole('link', { name: `Back to ${orgName}`, exact: true })
|
||||
.click();
|
||||
await page.getByRole('link', { name: 'Back to Global' }).click();
|
||||
await page.getByRole('link', { name: 'Aliases' }).click();
|
||||
await expect(
|
||||
page
|
||||
.getByRole('navigation', { name: 'breadcrumbs' })
|
||||
.getByText('Aliases'),
|
||||
).toBeVisible();
|
||||
alias = 'example.alias.' + nanoid();
|
||||
const aliasesPage = new AliasesPage(page);
|
||||
await aliasesPage.createAliasForTarget(alias, targetId, orgName);
|
||||
await aliasesPage.createAliasForTarget(alias, targetId);
|
||||
await page.getByRole('link', { name: 'Orgs', exact: true }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Orgs' })).toBeVisible();
|
||||
await page.getByRole('link', { name: orgName }).click();
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import { test as base } from '../../global-setup.js';
|
||||
import { expect } from '@playwright/test';
|
||||
import { customAlphabet } from 'nanoid';
|
||||
|
||||
import * as boundaryCli from '../../helpers/boundary-cli';
|
||||
import { readFile } from 'fs/promises';
|
||||
|
||||
// These tests are to make sure that the api-client fixture works as expected.
|
||||
// Test helpers or fixtures don't always have tests, but because this one has
|
||||
// enough complexity it's worth testing
|
||||
|
||||
const nanoid = customAlphabet('1234567890abcdefghijklmnopqrstuvwxyz', 5);
|
||||
const scopeId = 'global';
|
||||
const aliasCleanedUpDescription = `this alias should be cleaned up ${nanoid()}`;
|
||||
const aliasSkippedDescription = `this alias should not be cleaned up ${nanoid()}`;
|
||||
|
||||
const test = base.extend(
|
||||
{
|
||||
apiClientTestAfter: async ({}, use) => {
|
||||
let apiClientTestAfter;
|
||||
const setApiClientTestAfter = (fn) => {
|
||||
apiClientTestAfter = fn;
|
||||
};
|
||||
await use(setApiClientTestAfter);
|
||||
await apiClientTestAfter?.();
|
||||
},
|
||||
},
|
||||
{ scope: 'test', auto: true },
|
||||
);
|
||||
|
||||
test(
|
||||
'resources can be created (example: alias)',
|
||||
{ tag: ['@ce', '@ent', '@aws', '@docker'] },
|
||||
async ({ apiClient, apiClientTestAfter }) => {
|
||||
await apiClient.clients.Alias.aliasServiceCreateAlias({
|
||||
item: {
|
||||
scopeId,
|
||||
description: aliasCleanedUpDescription,
|
||||
value: `a-${nanoid(4)}.dev`,
|
||||
type: 'target',
|
||||
},
|
||||
});
|
||||
|
||||
const skipAlias = await apiClient.clients.Alias.aliasServiceCreateAlias({
|
||||
item: {
|
||||
scopeId,
|
||||
description: aliasSkippedDescription,
|
||||
value: `b-${nanoid(4)}.dev`,
|
||||
type: 'target',
|
||||
},
|
||||
});
|
||||
|
||||
apiClient.skipCleanup(skipAlias);
|
||||
|
||||
// These assertions ensure that by the end of this test the two aliases
|
||||
// exist. In the next test they are checked to see if clean up and
|
||||
// skipping clean up worked correctly
|
||||
const aliases = await apiClient.clients.Alias.aliasServiceListAliases({
|
||||
scopeId,
|
||||
});
|
||||
const cleanedUpAlias = aliases.items.find(
|
||||
({ description }) => description === aliasCleanedUpDescription,
|
||||
);
|
||||
expect(cleanedUpAlias).toBeTruthy();
|
||||
const cleanUpSkippedAlias = aliases.items.find(
|
||||
({ description }) => description === aliasSkippedDescription,
|
||||
);
|
||||
expect(cleanUpSkippedAlias).toBeTruthy();
|
||||
|
||||
apiClientTestAfter(async () => {
|
||||
const aliases = await apiClient.clients.Alias.aliasServiceListAliases({
|
||||
scopeId,
|
||||
});
|
||||
const cleanedUpAlias = aliases.items.find(
|
||||
({ description }) => description === aliasCleanedUpDescription,
|
||||
);
|
||||
// the `cleanedUpAlias` has been cleaned up so it isn't expected to be found
|
||||
expect(cleanedUpAlias).toBeFalsy();
|
||||
const cleanUpSkippedAlias = aliases.items.find(
|
||||
({ description }) => description === aliasSkippedDescription,
|
||||
);
|
||||
// the `cleanUpSkippedAlias` skipped cleanup so it should still exist
|
||||
expect(cleanUpSkippedAlias).toBeTruthy();
|
||||
|
||||
// manually clean up skipped resource
|
||||
await apiClient.clients.Alias.aliasServiceDeleteAlias({
|
||||
id: cleanUpSkippedAlias.id,
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'it logs the response when an error is returned from an api call',
|
||||
{ tag: ['@ce', '@ent', '@aws', '@docker'] },
|
||||
async ({ apiClient }) => {
|
||||
const consoleErrors = [];
|
||||
const originalConsoleError = console.error;
|
||||
console.error = (toLog) => {
|
||||
consoleErrors.push(toLog);
|
||||
};
|
||||
|
||||
await apiClient.clients.Alias.aliasServiceListAliases({
|
||||
scopeId: 'not a valid scope id',
|
||||
// this request is expected to fail and throw
|
||||
}).catch(() => {});
|
||||
|
||||
expect(consoleErrors).toHaveLength(1);
|
||||
expect(consoleErrors[0]).toContain(
|
||||
'Error response from Boundary API, status code: 400, with method: GET',
|
||||
);
|
||||
expect(consoleErrors[0]).toContain('Improperly formatted identifier.');
|
||||
|
||||
// restore the original console.error
|
||||
console.error = originalConsoleError;
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'it throws when #skipCleanup is given an object without an id',
|
||||
{ tag: ['@ce', '@ent', '@aws', '@docker'] },
|
||||
({ apiClient }) => {
|
||||
expect(() =>
|
||||
apiClient.skipCleanup('not an object with an id property'),
|
||||
).toThrow('The `id` field is expected on the resource');
|
||||
},
|
||||
);
|
||||
|
||||
// This test serves as a more complex example and exercises many connected resources.
|
||||
// It also serves to illustrate that there are some resources, like storage buckets,
|
||||
// that may be dependent on other resources created outside the api being cleaned up first,
|
||||
// like sessions being cancelled and session recordings being deleted. Once these are
|
||||
// cleaned up then automatic clean up should work.
|
||||
test(
|
||||
'Storage buckets can be cleaned up after a session is manually cancelled and session recordings are manually deleted',
|
||||
{ tag: ['@ent', '@docker'] },
|
||||
async ({
|
||||
controllerAddr,
|
||||
adminAuthMethodId,
|
||||
adminLoginName,
|
||||
adminPassword,
|
||||
bucketAccessKeyId,
|
||||
bucketEndpointUrl,
|
||||
bucketName,
|
||||
bucketSecretAccessKey,
|
||||
region,
|
||||
sshUser,
|
||||
sshKeyPath,
|
||||
targetAddress,
|
||||
targetPort,
|
||||
workerTagEgress,
|
||||
apiClient,
|
||||
apiClientTestAfter,
|
||||
}) => {
|
||||
await boundaryCli.checkBoundaryCli();
|
||||
|
||||
let connect;
|
||||
try {
|
||||
await boundaryCli.authenticateBoundary(
|
||||
controllerAddr,
|
||||
adminAuthMethodId,
|
||||
adminLoginName,
|
||||
adminPassword,
|
||||
);
|
||||
|
||||
const org = await apiClient.clients.Scope.scopeServiceCreateScope({
|
||||
item: {
|
||||
name: `Org-${nanoid()}`,
|
||||
scopeId: 'global',
|
||||
},
|
||||
});
|
||||
|
||||
const project = await apiClient.clients.Scope.scopeServiceCreateScope({
|
||||
item: {
|
||||
name: `Project-${nanoid()}`,
|
||||
scopeId: org.id,
|
||||
},
|
||||
});
|
||||
|
||||
const storageBucket =
|
||||
await apiClient.clients.StorageBucket.storageBucketServiceCreateStorageBucket(
|
||||
{
|
||||
pluginName: 'minio',
|
||||
item: {
|
||||
scopeId: org.scopeId,
|
||||
bucketName,
|
||||
workerFilter: `"${workerTagEgress}" in "/tags/type"`,
|
||||
attributes: {
|
||||
region,
|
||||
endpoint_url: bucketEndpointUrl,
|
||||
disable_credential_rotation: true,
|
||||
},
|
||||
secrets: {
|
||||
access_key_id: bucketAccessKeyId,
|
||||
secret_access_key: bucketSecretAccessKey,
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const target = await apiClient.clients.Target.targetServiceCreateTarget({
|
||||
item: {
|
||||
name: `ssh-target-${nanoid()}`,
|
||||
scopeId: project.id,
|
||||
type: 'ssh',
|
||||
address: targetAddress,
|
||||
ingressWorkerFilter: `"${workerTagEgress}" in "/tags/type"`,
|
||||
egressWorkerFilter: `"${workerTagEgress}" in "/tags/type"`,
|
||||
attributes: {
|
||||
default_port: targetPort,
|
||||
enable_session_recording: true,
|
||||
storage_bucket_id: storageBucket.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const credentialStore =
|
||||
await apiClient.clients.CredentialStore.credentialStoreServiceCreateCredentialStore(
|
||||
{
|
||||
item: {
|
||||
scopeId: project.id,
|
||||
type: 'static',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const keyData = await readFile(sshKeyPath, {
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
|
||||
const credential =
|
||||
await apiClient.clients.Credential.credentialServiceCreateCredential({
|
||||
item: {
|
||||
credentialStoreId: credentialStore.id,
|
||||
type: 'ssh_private_key',
|
||||
attributes: {
|
||||
username: sshUser,
|
||||
private_key: keyData,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await apiClient.clients.Target.targetServiceAddTargetCredentialSources({
|
||||
id: target.id,
|
||||
body: {
|
||||
injectedApplicationCredentialSourceIds: [credential.id],
|
||||
version: 1,
|
||||
},
|
||||
});
|
||||
|
||||
const storagePolicy =
|
||||
await apiClient.clients.Policy.policyServiceCreatePolicy({
|
||||
item: {
|
||||
type: 'storage',
|
||||
scopeId: 'global',
|
||||
attributes: {
|
||||
delete_after: { days: 1 },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
apiClient.clients.Scope.scopeServiceAttachStoragePolicy({
|
||||
id: org.id,
|
||||
body: {
|
||||
storagePolicyId: storagePolicy.id,
|
||||
version: 1,
|
||||
},
|
||||
});
|
||||
|
||||
connect = await boundaryCli.connectSshToTarget(target.id);
|
||||
|
||||
let waitForSessionCount = 0;
|
||||
let sshSession;
|
||||
while (!sshSession) {
|
||||
const sessions =
|
||||
await apiClient.clients.Session.sessionServiceListSessions({
|
||||
scopeId: target.scopeId,
|
||||
});
|
||||
|
||||
sshSession = sessions?.items.find(
|
||||
(session) =>
|
||||
session.status === 'active' && session.targetId === target.id,
|
||||
);
|
||||
|
||||
if (waitForSessionCount >= 20) {
|
||||
throw new Error(
|
||||
'Max attempts exceeded waiting for target to session to be created',
|
||||
);
|
||||
}
|
||||
|
||||
// avoid being rate limited
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
waitForSessionCount++;
|
||||
}
|
||||
|
||||
await apiClient.clients.Session.sessionServiceCancelSession({
|
||||
id: sshSession.id,
|
||||
body: {
|
||||
version: sshSession.version,
|
||||
},
|
||||
});
|
||||
|
||||
let waitForSessionRecordingCount = 0;
|
||||
let completedSessionRecording;
|
||||
while (!completedSessionRecording) {
|
||||
completedSessionRecording = (
|
||||
await apiClient.clients.SessionRecording.sessionRecordingServiceListSessionRecordings(
|
||||
{
|
||||
scopeId: 'global',
|
||||
recursive: true,
|
||||
},
|
||||
)
|
||||
).items.find(
|
||||
(recording) =>
|
||||
recording.sessionId === sshSession.id &&
|
||||
recording.state === 'available',
|
||||
);
|
||||
|
||||
// Session recordings have to be cleaned up manually before a storage bucket can be deleted.
|
||||
// And session recordings can only be cleaned up once they are completed
|
||||
if (completedSessionRecording) {
|
||||
await apiClient.clients.SessionRecording.sessionRecordingServiceDeleteSessionRecording(
|
||||
{
|
||||
id: completedSessionRecording.id,
|
||||
body: { version: completedSessionRecording.version },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (waitForSessionRecordingCount >= 20) {
|
||||
throw new Error(
|
||||
'Max attempts exceeded waiting for target to session recording to be completed',
|
||||
);
|
||||
}
|
||||
|
||||
// avoid being rate limited
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
waitForSessionRecordingCount++;
|
||||
}
|
||||
} finally {
|
||||
// End `boundary connect` process
|
||||
connect?.kill('SIGTERM');
|
||||
}
|
||||
|
||||
apiClientTestAfter(async () => {
|
||||
const storageBuckets =
|
||||
(
|
||||
await apiClient.clients.StorageBucket.storageBucketServiceListStorageBuckets(
|
||||
{
|
||||
scopeId: 'global',
|
||||
recursive: true,
|
||||
},
|
||||
)
|
||||
).items ?? [];
|
||||
|
||||
expect(
|
||||
storageBuckets.find((bucket) => bucket.id === storageBuckets.id),
|
||||
).toBeUndefined();
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -223,15 +223,15 @@ test(
|
||||
|
||||
await expect(
|
||||
page
|
||||
.getByRole('cell', { name: ldapAccountName })
|
||||
.locator('..')
|
||||
.getByRole('row')
|
||||
.filter({ has: page.getByRole('cell', { name: ldapAccountName }) })
|
||||
.getByRole('cell')
|
||||
.nth(fullNameIndex),
|
||||
).toHaveText(ldapUserName);
|
||||
await expect(
|
||||
page
|
||||
.getByRole('cell', { name: ldapAccountName })
|
||||
.locator('..')
|
||||
.getByRole('row')
|
||||
.filter({ has: page.getByRole('cell', { name: ldapAccountName }) })
|
||||
.getByRole('cell')
|
||||
.nth(emailIndex),
|
||||
).toHaveText(ldapUserName + '@mail.com');
|
||||
@@ -265,15 +265,15 @@ test(
|
||||
|
||||
await expect(
|
||||
page
|
||||
.getByRole('cell', { name: ldapAccountName })
|
||||
.locator('..')
|
||||
.getByRole('row')
|
||||
.filter({ has: page.getByRole('cell', { name: ldapAccountName }) })
|
||||
.getByRole('cell')
|
||||
.nth(fullNameIndex),
|
||||
).toHaveText(ldapUserName);
|
||||
await expect(
|
||||
page
|
||||
.getByRole('cell', { name: ldapAccountName })
|
||||
.locator('..')
|
||||
.getByRole('row')
|
||||
.filter({ has: page.getByRole('cell', { name: ldapAccountName }) })
|
||||
.getByRole('cell')
|
||||
.nth(emailIndex),
|
||||
).toHaveText(ldapUserName + '@mail.com');
|
||||
|
||||
@@ -84,7 +84,7 @@ test(
|
||||
// Set auth method as primary
|
||||
await page.getByText('Manage', { exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Make Primary' }).click();
|
||||
await page.getByRole('button', { name: 'OK' }).click();
|
||||
await page.getByRole('button', { name: 'OK', exact: true }).click();
|
||||
await expect(
|
||||
page.getByRole('alert').getByText('Success', { exact: true }),
|
||||
).toBeVisible();
|
||||
@@ -181,15 +181,15 @@ test(
|
||||
|
||||
await expect(
|
||||
page
|
||||
.getByRole('cell', { name: userName })
|
||||
.locator('..')
|
||||
.getByRole('row')
|
||||
.filter({ has: page.getByRole('cell', { name: userName }) })
|
||||
.getByRole('cell')
|
||||
.nth(fullNameIndex),
|
||||
).toHaveText(userName);
|
||||
await expect(
|
||||
page
|
||||
.getByRole('cell', { name: userName })
|
||||
.locator('..')
|
||||
.getByRole('row')
|
||||
.filter({ has: page.getByRole('cell', { name: userName }) })
|
||||
.getByRole('cell')
|
||||
.nth(emailIndex),
|
||||
).toHaveText(email);
|
||||
@@ -223,15 +223,15 @@ test(
|
||||
|
||||
await expect(
|
||||
page
|
||||
.getByRole('cell', { name: userName })
|
||||
.locator('..')
|
||||
.getByRole('row')
|
||||
.filter({ has: page.getByRole('cell', { name: userName }) })
|
||||
.getByRole('cell')
|
||||
.nth(fullNameIndex),
|
||||
).toHaveText(userName);
|
||||
await expect(
|
||||
page
|
||||
.getByRole('cell', { name: userName })
|
||||
.locator('..')
|
||||
.getByRole('row')
|
||||
.filter({ has: page.getByRole('cell', { name: userName }) })
|
||||
.getByRole('cell')
|
||||
.nth(emailIndex),
|
||||
).toHaveText(email);
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import { test } from '../../global-setup.js';
|
||||
import { expect } from '@playwright/test';
|
||||
|
||||
import * as boundaryCli from '../../helpers/boundary-cli';
|
||||
import * as boundaryHttp from '../../helpers/boundary-http.js';
|
||||
import { LoginPage } from '../pages/login.js';
|
||||
|
||||
// Reset storage state for this file to avoid being authenticated
|
||||
test.use({ storageState: { cookies: [], origins: [] } });
|
||||
|
||||
test.beforeAll(async () => {
|
||||
await boundaryCli.checkBoundaryCli();
|
||||
});
|
||||
|
||||
test(
|
||||
'Verify user can change password',
|
||||
{ tag: ['@ce', '@ent', '@aws', '@docker'] },
|
||||
async ({ page, request }) => {
|
||||
await page.goto('/');
|
||||
let org;
|
||||
let authMethod;
|
||||
try {
|
||||
let account;
|
||||
let user;
|
||||
|
||||
// Create a new org, password auth method, account, and user via http API
|
||||
org = await boundaryHttp.createOrg(request);
|
||||
|
||||
authMethod = await boundaryHttp.createPasswordAuthMethod(request, org.id);
|
||||
const username = 'test-user';
|
||||
const password = 'password';
|
||||
account = await boundaryHttp.createPasswordAccount(request, {
|
||||
authMethodId: authMethod.id,
|
||||
username,
|
||||
password,
|
||||
});
|
||||
await boundaryHttp.makeAuthMethodPrimary(request, {
|
||||
org,
|
||||
authMethodId: authMethod.id,
|
||||
});
|
||||
|
||||
user = await boundaryHttp.createUser(request, org.id);
|
||||
await boundaryHttp.addAccountsToUser(request, {
|
||||
user,
|
||||
accountIds: [account.id],
|
||||
});
|
||||
|
||||
// Log in using new account
|
||||
await page.reload();
|
||||
await page.getByText('Choose a different scope').click();
|
||||
await page.getByRole('link', { name: org.name }).click();
|
||||
await page.getByRole('link', { name: authMethod.name }).click();
|
||||
const loginPage = new LoginPage(page);
|
||||
await loginPage.login(username, password);
|
||||
await expect(
|
||||
page
|
||||
.getByRole('navigation', { name: 'breadcrumbs' })
|
||||
.getByText('Projects'),
|
||||
).toBeVisible();
|
||||
|
||||
// Change password for user
|
||||
await page.getByRole('button', { name: username }).click();
|
||||
await page.getByRole('link', { name: 'Change Password' }).click();
|
||||
const newPassword = 'new-password';
|
||||
await page.getByLabel('Current Password').fill(password);
|
||||
await page.getByLabel('New Password').fill(newPassword);
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
await expect(
|
||||
page.getByRole('alert').getByText('Success', { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Projects' }),
|
||||
).toBeVisible();
|
||||
|
||||
// Confirm user cannot log in with old password
|
||||
await loginPage.logout(username);
|
||||
await page.getByText('Choose a different scope').click();
|
||||
await page.getByRole('link', { name: org.name }).click();
|
||||
await page.getByRole('link', { name: authMethod.name }).click();
|
||||
await page.getByLabel('Login Name').fill(username);
|
||||
await page.getByLabel('Password', { exact: true }).fill(password);
|
||||
await page.getByRole('button', { name: 'Sign In' }).click();
|
||||
await expect(page.getByRole('alert').getByText('Error')).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Sign In' })).toBeVisible();
|
||||
|
||||
// Confirm user can log in with new password
|
||||
await loginPage.login(username, newPassword);
|
||||
await expect(
|
||||
page
|
||||
.getByRole('navigation', { name: 'breadcrumbs' })
|
||||
.getByText('Projects'),
|
||||
).toBeVisible();
|
||||
} finally {
|
||||
if (authMethod.id) {
|
||||
await boundaryHttp.deleteAuthMethod(request, authMethod.id);
|
||||
}
|
||||
if (org.id) {
|
||||
await boundaryHttp.deleteOrg(request, org.id);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -69,9 +69,8 @@ test(
|
||||
|
||||
// Remove a credential from the target
|
||||
await page
|
||||
.getByRole('link', { name: credentialName2 })
|
||||
.locator('..')
|
||||
.locator('..')
|
||||
.getByRole('row')
|
||||
.filter({ has: page.getByRole('link', { name: credentialName2 }) })
|
||||
.getByRole('button', { name: 'Manage' })
|
||||
.click();
|
||||
await page.getByRole('button', { name: 'Remove' }).click();
|
||||
@@ -92,9 +91,8 @@ test(
|
||||
|
||||
// Remove a credential from the target
|
||||
await page
|
||||
.getByRole('link', { name: credentialName2 })
|
||||
.locator('..')
|
||||
.locator('..')
|
||||
.getByRole('row')
|
||||
.filter({ has: page.getByRole('link', { name: credentialName2 }) })
|
||||
.getByRole('button', { name: 'Manage' })
|
||||
.click();
|
||||
await page.getByRole('button', { name: 'Remove' }).click();
|
||||
|
||||
@@ -303,9 +303,8 @@ test(
|
||||
|
||||
// Remove the host source from the target
|
||||
await page
|
||||
.getByRole('link', { name: credentialName2 })
|
||||
.locator('..')
|
||||
.locator('..')
|
||||
.getByRole('row')
|
||||
.filter({ has: page.getByRole('link', { name: credentialName2 }) })
|
||||
.getByRole('button', { name: 'Manage' })
|
||||
.click();
|
||||
await page.getByRole('button', { name: 'Remove' }).click();
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import { expect } from '@playwright/test';
|
||||
import { test } from '../../global-setup.js';
|
||||
import { execSync } from 'child_process';
|
||||
|
||||
@@ -53,31 +54,14 @@ test(
|
||||
let orgId;
|
||||
let connect;
|
||||
try {
|
||||
execSync(
|
||||
`vault policy write ${boundaryPolicyName} ./admin/tests/fixtures/boundary-controller-policy.hcl`,
|
||||
const clientToken = await vaultCli.setupVaultCredentialStore(
|
||||
boundaryPolicyName,
|
||||
secretPolicyName,
|
||||
secretsPath,
|
||||
secretName,
|
||||
sshUser,
|
||||
sshKeyPath,
|
||||
);
|
||||
execSync(`vault secrets enable -path=${secretsPath} kv-v2`);
|
||||
execSync(
|
||||
`vault kv put -mount ${secretsPath} ${secretName} ` +
|
||||
` username=${sshUser}` +
|
||||
` private_key=@${sshKeyPath}`,
|
||||
);
|
||||
execSync(
|
||||
`vault policy write ${secretPolicyName} ./admin/tests/fixtures/kv-policy.hcl`,
|
||||
);
|
||||
const vaultToken = JSON.parse(
|
||||
execSync(
|
||||
`vault token create` +
|
||||
` -no-default-policy=true` +
|
||||
` -policy=${boundaryPolicyName}` +
|
||||
` -policy=${secretPolicyName}` +
|
||||
` -orphan=true` +
|
||||
` -period=20m` +
|
||||
` -renewable=true` +
|
||||
` -format=json`,
|
||||
),
|
||||
);
|
||||
const clientToken = vaultToken.auth.client_token;
|
||||
|
||||
const orgsPage = new OrgsPage(page);
|
||||
const orgName = await orgsPage.createOrg();
|
||||
@@ -134,3 +118,49 @@ test(
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'Vault Credential Store - Username, Password & Domain credential type',
|
||||
{ tag: ['@ent', '@aws', '@docker'] },
|
||||
async ({ page, sshUser, sshKeyPath, vaultAddrPrivate, workerTagEgress }) => {
|
||||
let orgId;
|
||||
try {
|
||||
// Set up Vault credential store
|
||||
const clientToken = await vaultCli.setupVaultCredentialStore(
|
||||
boundaryPolicyName,
|
||||
secretPolicyName,
|
||||
secretsPath,
|
||||
secretName,
|
||||
sshUser,
|
||||
sshKeyPath,
|
||||
);
|
||||
const orgsPage = new OrgsPage(page);
|
||||
await orgsPage.createOrg();
|
||||
const projectsPage = new ProjectsPage(page);
|
||||
await projectsPage.createProject();
|
||||
|
||||
const credentialStoresPage = new CredentialStoresPage(page);
|
||||
await credentialStoresPage.createVaultCredentialStoreWithWorkerFilter(
|
||||
vaultAddrPrivate,
|
||||
clientToken,
|
||||
`"${workerTagEgress}" in "/tags/type"`,
|
||||
);
|
||||
|
||||
const credentialLibraryName =
|
||||
await credentialStoresPage.createVaultGenericCredentialLibraryEnt(
|
||||
`${secretsPath}/data/${secretName}`,
|
||||
'Username, Password & Domain',
|
||||
);
|
||||
|
||||
await expect(
|
||||
page
|
||||
.getByRole('navigation', { name: 'breadcrumbs' })
|
||||
.getByText(credentialLibraryName),
|
||||
).toBeVisible();
|
||||
} finally {
|
||||
if (orgId) {
|
||||
await boundaryCli.deleteScope(orgId);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -62,9 +62,9 @@ test(
|
||||
await boundaryCli.makeAuthMethodPrimary(orgId, authMethodId);
|
||||
let passwordAccountId =
|
||||
await boundaryCli.createPasswordAccount(authMethodId);
|
||||
let projectScopeRoleId = await boundaryCli.createRole(projectId);
|
||||
let orgScopeRoleId = await boundaryCli.createRole(orgId);
|
||||
let globalScopeRoleId = await boundaryCli.createRole('global');
|
||||
let projectScopeRoleId = await boundaryCli.createRole(projectId, {});
|
||||
let orgScopeRoleId = await boundaryCli.createRole(orgId, {});
|
||||
let globalScopeRoleId = await boundaryCli.createRole('global', {});
|
||||
let groupId = await boundaryCli.createGroup(orgId);
|
||||
let userId = await boundaryCli.createUser(orgId);
|
||||
let staticHostCatalogId =
|
||||
@@ -155,7 +155,7 @@ test(
|
||||
await baseResourcePage.deleteResource(page);
|
||||
|
||||
// Delete project and org
|
||||
await page.goto(`/scopes/${projectId}`);
|
||||
await page.goto(`/scopes/${projectId}/edit`);
|
||||
await baseResourcePage.deleteResource(page);
|
||||
await page.goto(`/scopes/${orgId}/edit`);
|
||||
await baseResourcePage.deleteResource(page);
|
||||
|
||||
@@ -61,9 +61,9 @@ test(
|
||||
await boundaryCli.makeAuthMethodPrimary(orgId, authMethodId);
|
||||
let passwordAccountId =
|
||||
await boundaryCli.createPasswordAccount(authMethodId);
|
||||
let projectScopeRoleId = await boundaryCli.createRole(projectId);
|
||||
let orgScopeRoleId = await boundaryCli.createRole(orgId);
|
||||
let globalScopeRoleId = await boundaryCli.createRole('global');
|
||||
let projectScopeRoleId = await boundaryCli.createRole(projectId, {});
|
||||
let orgScopeRoleId = await boundaryCli.createRole(orgId, {});
|
||||
let globalScopeRoleId = await boundaryCli.createRole('global', {});
|
||||
let groupId = await boundaryCli.createGroup(orgId);
|
||||
let userId = await boundaryCli.createUser(orgId);
|
||||
let staticHostCatalogId =
|
||||
@@ -177,7 +177,7 @@ test(
|
||||
await workersPage.deleteResource(page);
|
||||
|
||||
// Delete project
|
||||
await page.goto(`/scopes/${projectId}`);
|
||||
await page.goto(`/scopes/${projectId}/edit`);
|
||||
await baseResourcePage.deleteResource(page);
|
||||
|
||||
// Delete org
|
||||
|
||||
@@ -172,9 +172,8 @@ test.describe('AWS', () => {
|
||||
|
||||
// Remove the host source from the target
|
||||
await page
|
||||
.getByRole('link', { name: newHostSetName })
|
||||
.locator('..')
|
||||
.locator('..')
|
||||
.getByRole('row')
|
||||
.filter({ has: page.getByRole('link', { name: newHostSetName }) })
|
||||
.getByRole('button', { name: 'Manage' })
|
||||
.click();
|
||||
await page.getByRole('button', { name: 'Remove' }).click();
|
||||
|
||||
@@ -172,9 +172,8 @@ test.describe('AWS', () => {
|
||||
|
||||
// Remove the host source from the target
|
||||
await page
|
||||
.getByRole('link', { name: newHostSetName })
|
||||
.locator('..')
|
||||
.locator('..')
|
||||
.getByRole('row')
|
||||
.filter({ has: page.getByRole('link', { name: newHostSetName }) })
|
||||
.getByRole('button', { name: 'Manage' })
|
||||
.click();
|
||||
await page.getByRole('button', { name: 'Remove' }).click();
|
||||
|
||||
@@ -5,159 +5,166 @@
|
||||
|
||||
import { test } from '../../global-setup.js';
|
||||
import { expect } from '@playwright/test';
|
||||
|
||||
import * as boundaryHttp from '../../helpers/boundary-http.js';
|
||||
import { nanoid } from 'nanoid';
|
||||
|
||||
test(
|
||||
'Search and Pagination (Targets)',
|
||||
{ tag: ['@ce', '@ent', '@aws', '@docker'] },
|
||||
async ({ page, request }) => {
|
||||
let org;
|
||||
try {
|
||||
org = await boundaryHttp.createOrg(request);
|
||||
const project = await boundaryHttp.createProject(request, org.id);
|
||||
async ({ page, apiClient }) => {
|
||||
const org = await apiClient.clients.Scope.scopeServiceCreateScope({
|
||||
item: {
|
||||
name: `Org-${nanoid()}`,
|
||||
scopeId: 'global',
|
||||
},
|
||||
});
|
||||
|
||||
// Create targets
|
||||
let targets = [];
|
||||
const targetCount = 15;
|
||||
for (let i = 0; i < targetCount; i++) {
|
||||
const target = await boundaryHttp.createTarget(request, {
|
||||
const project = await apiClient.clients.Scope.scopeServiceCreateScope({
|
||||
item: {
|
||||
name: `Project-${nanoid()}`,
|
||||
scopeId: org.id,
|
||||
},
|
||||
});
|
||||
|
||||
// Create targets
|
||||
let targets = [];
|
||||
const targetCount = 15;
|
||||
for (let i = 0; i < targetCount; i++) {
|
||||
const target = await apiClient.clients.Target.targetServiceCreateTarget({
|
||||
item: {
|
||||
name: `Target-${nanoid()}`,
|
||||
scopeId: project.id,
|
||||
type: 'tcp',
|
||||
port: 22,
|
||||
});
|
||||
targets.push(target);
|
||||
}
|
||||
attributes: {
|
||||
default_port: 22,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Navigate to targets page
|
||||
await page.goto('/');
|
||||
await page.getByRole('link', { name: org.name }).click();
|
||||
await page.getByRole('link', { name: project.name }).click();
|
||||
await page
|
||||
.getByRole('navigation', { name: 'Application local navigation' })
|
||||
.getByRole('link', { name: 'Targets' })
|
||||
.click();
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Targets' }),
|
||||
).toBeVisible();
|
||||
|
||||
// Check pagination
|
||||
// The most recent target should be visible. The oldest target should not be
|
||||
// visible since there are 15 targets and the default page size is 10.
|
||||
await expect(
|
||||
page.getByRole('link', { name: targets[targets.length - 1].name }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('link', { name: targets[0].name }),
|
||||
).toBeHidden();
|
||||
|
||||
// Navigate to the second page. The oldest target should now be visible.
|
||||
await page
|
||||
.getByRole('navigation', { name: 'pagination' })
|
||||
.getByRole('link', { name: 'page 2' })
|
||||
.click();
|
||||
await expect(
|
||||
page.getByRole('link', { name: targets[0].name }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('link', { name: targets[targets.length - 1].name }),
|
||||
).toBeHidden();
|
||||
|
||||
// Use the "previous page" button to navigate back to the first page.
|
||||
await page
|
||||
.getByRole('navigation', { name: 'pagination' })
|
||||
.getByRole('link', { name: 'Previous page' })
|
||||
.click();
|
||||
await expect(
|
||||
page.getByRole('link', { name: targets[0].name }),
|
||||
).toBeHidden();
|
||||
await expect(
|
||||
page.getByRole('link', { name: targets[targets.length - 1].name }),
|
||||
).toBeVisible();
|
||||
|
||||
// Use the "next page" button to navigate back to the second page.
|
||||
await page
|
||||
.getByRole('navigation', { name: 'pagination' })
|
||||
.getByRole('link', { name: 'Next page' })
|
||||
.click();
|
||||
await expect(
|
||||
page.getByRole('link', { name: targets[0].name }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('link', { name: targets[targets.length - 1].name }),
|
||||
).toBeHidden();
|
||||
|
||||
// Use the search box to find the most recent target.
|
||||
await page
|
||||
.getByRole('searchbox', { name: 'Search' })
|
||||
.fill(targets[targets.length - 1].name);
|
||||
await expect(
|
||||
page.getByRole('link', { name: targets[0].name }),
|
||||
).toBeHidden();
|
||||
await expect(
|
||||
page.getByRole('link', { name: targets[targets.length - 1].name }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('link', { name: targets[targets.length - 2].name }),
|
||||
).toBeHidden();
|
||||
|
||||
// Clear the search box
|
||||
await page.getByRole('searchbox', { name: 'Search' }).clear();
|
||||
await expect(
|
||||
page.getByRole('link', { name: targets[0].name }),
|
||||
).toBeHidden();
|
||||
await expect(
|
||||
page.getByRole('link', { name: targets[targets.length - 2].name }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('link', { name: targets[targets.length - 1].name }),
|
||||
).toBeVisible();
|
||||
|
||||
// Use the "Items per page" options to show 30 items per page.
|
||||
await page
|
||||
.getByRole('combobox', { name: 'Items per page' })
|
||||
.selectOption('30');
|
||||
await expect(
|
||||
page.getByRole('link', { name: targets[0].name }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('link', { name: targets[targets.length - 1].name }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('link', { name: targets[targets.length - 2].name }),
|
||||
).toBeVisible();
|
||||
|
||||
// Use the "Items per page" options to show 50 items per page.
|
||||
await page
|
||||
.getByRole('combobox', { name: 'Items per page' })
|
||||
.selectOption('50');
|
||||
await expect(
|
||||
page.getByRole('link', { name: targets[0].name }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('link', { name: targets[targets.length - 1].name }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('link', { name: targets[targets.length - 2].name }),
|
||||
).toBeVisible();
|
||||
|
||||
// Use the "Items per page" options to show 10 items per page.
|
||||
await page
|
||||
.getByRole('combobox', { name: 'Items per page' })
|
||||
.selectOption('10');
|
||||
await expect(
|
||||
page.getByRole('link', { name: targets[0].name }),
|
||||
).toBeHidden();
|
||||
await expect(
|
||||
page.getByRole('link', { name: targets[targets.length - 2].name }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('link', { name: targets[targets.length - 1].name }),
|
||||
).toBeVisible();
|
||||
} finally {
|
||||
if (org.id) {
|
||||
org = await request.delete(`/v1/scopes/${org.id}`);
|
||||
}
|
||||
targets.push(target);
|
||||
}
|
||||
|
||||
// Navigate to targets page
|
||||
await page.goto('/');
|
||||
await page.getByRole('link', { name: org.name }).click();
|
||||
await page.getByRole('link', { name: project.name }).click();
|
||||
await page
|
||||
.getByRole('navigation', { name: 'Application local navigation' })
|
||||
.getByRole('link', { name: 'Targets' })
|
||||
.click();
|
||||
await expect(page.getByRole('heading', { name: 'Targets' })).toBeVisible();
|
||||
|
||||
// Check pagination
|
||||
// The most recent target should be visible. The oldest target should not be
|
||||
// visible since there are 15 targets and the default page size is 10.
|
||||
await expect(
|
||||
page.getByRole('link', { name: targets[targets.length - 1].name }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('link', { name: targets[0].name }),
|
||||
).toBeHidden();
|
||||
|
||||
// Navigate to the second page. The oldest target should now be visible.
|
||||
await page
|
||||
.getByRole('navigation', { name: 'pagination' })
|
||||
.getByRole('link', { name: 'page 2' })
|
||||
.click();
|
||||
await expect(
|
||||
page.getByRole('link', { name: targets[0].name }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('link', { name: targets[targets.length - 1].name }),
|
||||
).toBeHidden();
|
||||
|
||||
// Use the "previous page" button to navigate back to the first page.
|
||||
await page
|
||||
.getByRole('navigation', { name: 'pagination' })
|
||||
.getByRole('link', { name: 'Previous page' })
|
||||
.click();
|
||||
await expect(
|
||||
page.getByRole('link', { name: targets[0].name }),
|
||||
).toBeHidden();
|
||||
await expect(
|
||||
page.getByRole('link', { name: targets[targets.length - 1].name }),
|
||||
).toBeVisible();
|
||||
|
||||
// Use the "next page" button to navigate back to the second page.
|
||||
await page
|
||||
.getByRole('navigation', { name: 'pagination' })
|
||||
.getByRole('link', { name: 'Next page' })
|
||||
.click();
|
||||
await expect(
|
||||
page.getByRole('link', { name: targets[0].name }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('link', { name: targets[targets.length - 1].name }),
|
||||
).toBeHidden();
|
||||
|
||||
// Use the search box to find the most recent target.
|
||||
await page
|
||||
.getByRole('searchbox', { name: 'Search' })
|
||||
.fill(targets[targets.length - 1].name);
|
||||
await expect(
|
||||
page.getByRole('link', { name: targets[0].name }),
|
||||
).toBeHidden();
|
||||
await expect(
|
||||
page.getByRole('link', { name: targets[targets.length - 1].name }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('link', { name: targets[targets.length - 2].name }),
|
||||
).toBeHidden();
|
||||
|
||||
// Clear the search box
|
||||
await page.getByRole('searchbox', { name: 'Search' }).clear();
|
||||
await expect(
|
||||
page.getByRole('link', { name: targets[0].name }),
|
||||
).toBeHidden();
|
||||
await expect(
|
||||
page.getByRole('link', { name: targets[targets.length - 2].name }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('link', { name: targets[targets.length - 1].name }),
|
||||
).toBeVisible();
|
||||
|
||||
// Use the "Items per page" options to show 30 items per page.
|
||||
await page
|
||||
.getByRole('combobox', { name: 'Items per page' })
|
||||
.selectOption('30');
|
||||
await expect(
|
||||
page.getByRole('link', { name: targets[0].name }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('link', { name: targets[targets.length - 1].name }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('link', { name: targets[targets.length - 2].name }),
|
||||
).toBeVisible();
|
||||
|
||||
// Use the "Items per page" options to show 50 items per page.
|
||||
await page
|
||||
.getByRole('combobox', { name: 'Items per page' })
|
||||
.selectOption('50');
|
||||
await expect(
|
||||
page.getByRole('link', { name: targets[0].name }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('link', { name: targets[targets.length - 1].name }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('link', { name: targets[targets.length - 2].name }),
|
||||
).toBeVisible();
|
||||
|
||||
// Use the "Items per page" options to show 10 items per page.
|
||||
await page
|
||||
.getByRole('combobox', { name: 'Items per page' })
|
||||
.selectOption('10');
|
||||
await expect(
|
||||
page.getByRole('link', { name: targets[0].name }),
|
||||
).toBeHidden();
|
||||
await expect(
|
||||
page.getByRole('link', { name: targets[targets.length - 2].name }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('link', { name: targets[targets.length - 1].name }),
|
||||
).toBeVisible();
|
||||
},
|
||||
);
|
||||
@@ -10,36 +10,35 @@ import { OrgsPage } from '../pages/orgs.js';
|
||||
import * as boundaryHttp from '../../helpers/boundary-http.js';
|
||||
|
||||
test(
|
||||
'Session picker',
|
||||
{ tag: ['@ce', '@ent', '@aws', '@docker'] },
|
||||
async ({ page, request }) => {
|
||||
let org;
|
||||
try {
|
||||
//Create an org and project attached to the org
|
||||
org = await boundaryHttp.createOrg(request);
|
||||
const project = await boundaryHttp.createProject(request, org.id);
|
||||
'Session picker',
|
||||
{ tag: ['@ce', '@ent', '@aws', '@docker'] },
|
||||
async ({ page, request }) => {
|
||||
let org;
|
||||
try {
|
||||
// Create an org and project attached to the org
|
||||
org = await boundaryHttp.createOrg(request);
|
||||
const project = await boundaryHttp.createProject(request, org.id);
|
||||
|
||||
//Choose the org from the scope picker
|
||||
await page.goto('/');
|
||||
const orgsPage = new OrgsPage(page);
|
||||
await orgsPage.chooseScopeFromDropdown('Global', org.name);
|
||||
// Choose the org from the scope picker
|
||||
await page.goto('/');
|
||||
await expect(page.getByRole('heading', { name: 'Orgs' })).toBeVisible();
|
||||
const orgsPage = new OrgsPage(page);
|
||||
await orgsPage.chooseScopeFromDropdown('Global', org.name);
|
||||
|
||||
//Expect the project to be displayed on the projects page
|
||||
await expect(
|
||||
page.getByRole('link', { name: project.name })
|
||||
).toBeVisible()
|
||||
// Expect the project to be displayed on the projects page
|
||||
await expect(
|
||||
page.getByRole('link', { name: project.name }),
|
||||
).toBeVisible();
|
||||
|
||||
//Switch back to global scope
|
||||
await orgsPage.chooseScopeFromDropdown(org.name, 'Global');
|
||||
// Switch back to global scope
|
||||
await orgsPage.chooseScopeFromDropdown(org.name, 'Global');
|
||||
|
||||
//Expect to be back on the orgs page
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Orgs' })
|
||||
).toBeVisible()
|
||||
} finally {
|
||||
if (org.id) {
|
||||
org = await request.delete(`/v1/scopes/${org.id}`);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
// Expect to be back on the orgs page
|
||||
await expect(page.getByRole('heading', { name: 'Orgs' })).toBeVisible();
|
||||
} finally {
|
||||
if (org.id) {
|
||||
org = await request.delete(`/v1/scopes/${org.id}`);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -65,7 +65,12 @@ test(
|
||||
await page
|
||||
.getByRole('link', { name: `Back to ${orgName}`, exact: true })
|
||||
.click();
|
||||
await page.getByRole('link', { name: 'Orgs', exact: true }).click();
|
||||
await page
|
||||
.getByRole('link', { name: 'Back to Global', exact: true })
|
||||
.click();
|
||||
await page
|
||||
.getByRole('link', { name: 'Storage Buckets', exact: true })
|
||||
.click();
|
||||
const storageBucketsPage = new StorageBucketsPage(page);
|
||||
const storageBucketName = await storageBucketsPage.createStorageBucketAws(
|
||||
orgName,
|
||||
@@ -113,14 +118,9 @@ test(
|
||||
await page
|
||||
.getByRole('link', { name: `Back to ${orgName}`, exact: true })
|
||||
.click();
|
||||
await page.getByRole('link', { name: 'Orgs', exact: true }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Orgs' })).toBeVisible();
|
||||
await page.getByRole('link', { name: orgName }).click();
|
||||
await expect(
|
||||
page
|
||||
.getByRole('navigation', { name: 'breadcrumbs' })
|
||||
.getByText(orgName),
|
||||
).toBeVisible();
|
||||
await page
|
||||
.getByRole('link', { name: 'Storage Policies', exact: true })
|
||||
.click();
|
||||
const storagePoliciesPage = new StoragePoliciesPage(page);
|
||||
policyName = await storagePoliciesPage.createStoragePolicy();
|
||||
await orgsPage.attachStoragePolicy(policyName);
|
||||
@@ -141,8 +141,8 @@ test(
|
||||
const sessionsPage = new SessionsPage(page);
|
||||
await sessionsPage.waitForSessionToBeVisible(targetName);
|
||||
await page
|
||||
.getByRole('cell', { name: targetName })
|
||||
.locator('..')
|
||||
.getByRole('row')
|
||||
.filter({ has: page.getByRole('cell', { name: targetName }) })
|
||||
.getByRole('button', { name: 'Cancel' })
|
||||
.click();
|
||||
await expect(
|
||||
@@ -155,7 +155,9 @@ test(
|
||||
await page
|
||||
.getByRole('link', { name: `Back to ${orgName}`, exact: true })
|
||||
.click();
|
||||
await page.getByRole('link', { name: 'Orgs', exact: true }).click();
|
||||
await page
|
||||
.getByRole('link', { name: 'Back to Global', exact: true })
|
||||
.click();
|
||||
await page
|
||||
.getByRole('navigation', { name: 'Application local navigation' })
|
||||
.getByRole('link', { name: 'Session Recordings', exact: true })
|
||||
@@ -165,22 +167,14 @@ test(
|
||||
.getByRole('link', { name: 'View' })
|
||||
.click();
|
||||
await page
|
||||
.getByRole('cell', { name: 'Channel 1' })
|
||||
.locator('..')
|
||||
.getByRole('row')
|
||||
.filter({ has: page.getByRole('cell', { name: 'Channel 1' }) })
|
||||
.getByRole('link', { name: 'Play' })
|
||||
.click();
|
||||
await page.locator('div.session-recording-player').hover();
|
||||
await page.locator('.ap-playback-button').click();
|
||||
|
||||
// Edit storage policy: do not protect from deletion
|
||||
await page.getByRole('link', { name: 'Orgs', exact: true }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Orgs' })).toBeVisible();
|
||||
await page.getByRole('link', { name: orgName }).click();
|
||||
await expect(
|
||||
page
|
||||
.getByRole('navigation', { name: 'breadcrumbs' })
|
||||
.getByText(orgName),
|
||||
).toBeVisible();
|
||||
await page
|
||||
.getByRole('link', { name: 'Storage Policies', exact: true })
|
||||
.click();
|
||||
@@ -199,7 +193,9 @@ test(
|
||||
await page.getByRole('button', { name: 'Dismiss' }).click();
|
||||
|
||||
// Re-apply storage policy to the session recording and delete
|
||||
await page.getByRole('link', { name: 'Orgs', exact: true }).click();
|
||||
await page
|
||||
.getByRole('link', { name: 'Back to Global', exact: true })
|
||||
.click();
|
||||
await page
|
||||
.getByRole('link', { name: 'Session Recordings', exact: true })
|
||||
.click();
|
||||
@@ -212,7 +208,9 @@ test(
|
||||
await sessionRecordingsPage.deleteResource();
|
||||
|
||||
// Detach storage bucket from target
|
||||
await page.getByRole('link', { name: 'Orgs', exact: true }).click();
|
||||
await page
|
||||
.getByRole('link', { name: 'Back to Global', exact: true })
|
||||
.click();
|
||||
await expect(page.getByRole('heading', { name: 'Orgs' })).toBeVisible();
|
||||
await page.getByRole('link', { name: orgName }).click();
|
||||
await page.getByRole('link', { name: projectName }).click();
|
||||
|
||||
@@ -67,7 +67,12 @@ test(
|
||||
await page
|
||||
.getByRole('link', { name: `Back to ${orgName}`, exact: true })
|
||||
.click();
|
||||
await page.getByRole('link', { name: 'Orgs', exact: true }).click();
|
||||
await page
|
||||
.getByRole('link', { name: 'Back to Global', exact: true })
|
||||
.click();
|
||||
await page
|
||||
.getByRole('link', { name: 'Storage Buckets', exact: true })
|
||||
.click();
|
||||
const storageBucketsPage = new StorageBucketsPage(page);
|
||||
const storageBucketName =
|
||||
await storageBucketsPage.createStorageBucketMinio(
|
||||
@@ -121,14 +126,9 @@ test(
|
||||
await page
|
||||
.getByRole('link', { name: `Back to ${orgName}`, exact: true })
|
||||
.click();
|
||||
await page.getByRole('link', { name: 'Orgs', exact: true }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Orgs' })).toBeVisible();
|
||||
await page.getByRole('link', { name: orgName }).click();
|
||||
await expect(
|
||||
page
|
||||
.getByRole('navigation', { name: 'breadcrumbs' })
|
||||
.getByText(orgName),
|
||||
).toBeVisible();
|
||||
await page
|
||||
.getByRole('link', { name: 'Storage Policies', exact: true })
|
||||
.click();
|
||||
const storagePoliciesPage = new StoragePoliciesPage(page);
|
||||
policyName = await storagePoliciesPage.createStoragePolicy();
|
||||
await orgsPage.attachStoragePolicy(policyName);
|
||||
@@ -149,8 +149,8 @@ test(
|
||||
const sessionsPage = new SessionsPage(page);
|
||||
await sessionsPage.waitForSessionToBeVisible(targetName);
|
||||
await page
|
||||
.getByRole('cell', { name: targetName })
|
||||
.locator('..')
|
||||
.getByRole('row')
|
||||
.filter({ has: page.getByRole('cell', { name: targetName }) })
|
||||
.getByRole('button', { name: 'Cancel' })
|
||||
.click();
|
||||
await expect(
|
||||
@@ -163,7 +163,9 @@ test(
|
||||
await page
|
||||
.getByRole('link', { name: `Back to ${orgName}`, exact: true })
|
||||
.click();
|
||||
await page.getByRole('link', { name: 'Orgs', exact: true }).click();
|
||||
await page
|
||||
.getByRole('link', { name: 'Back to Global', exact: true })
|
||||
.click();
|
||||
await page
|
||||
.getByRole('navigation', { name: 'Application local navigation' })
|
||||
.getByRole('link', { name: 'Session Recordings', exact: true })
|
||||
@@ -173,22 +175,14 @@ test(
|
||||
.getByRole('link', { name: 'View' })
|
||||
.click();
|
||||
await page
|
||||
.getByRole('cell', { name: 'Channel 1' })
|
||||
.locator('..')
|
||||
.getByRole('row')
|
||||
.filter({ has: page.getByRole('cell', { name: 'Channel 1' }) })
|
||||
.getByRole('link', { name: 'Play' })
|
||||
.click();
|
||||
await page.locator('div.session-recording-player').hover();
|
||||
await page.locator('.ap-playback-button').click();
|
||||
|
||||
// Edit storage policy: do not protect from deletion
|
||||
await page.getByRole('link', { name: 'Orgs', exact: true }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Orgs' })).toBeVisible();
|
||||
await page.getByRole('link', { name: orgName }).click();
|
||||
await expect(
|
||||
page
|
||||
.getByRole('navigation', { name: 'breadcrumbs' })
|
||||
.getByText(orgName),
|
||||
).toBeVisible();
|
||||
await page
|
||||
.getByRole('link', { name: 'Storage Policies', exact: true })
|
||||
.click();
|
||||
@@ -207,7 +201,9 @@ test(
|
||||
await page.getByRole('button', { name: 'Dismiss' }).click();
|
||||
|
||||
// Re-apply storage policy to the session recording and delete
|
||||
await page.getByRole('link', { name: 'Orgs', exact: true }).click();
|
||||
await page
|
||||
.getByRole('link', { name: 'Back to Global', exact: true })
|
||||
.click();
|
||||
await page
|
||||
.getByRole('link', { name: 'Session Recordings', exact: true })
|
||||
.click();
|
||||
@@ -220,7 +216,9 @@ test(
|
||||
await sessionRecordingsPage.deleteResource();
|
||||
|
||||
// Detach storage bucket from target
|
||||
await page.getByRole('link', { name: 'Orgs', exact: true }).click();
|
||||
await page
|
||||
.getByRole('link', { name: 'Back to Global', exact: true })
|
||||
.click();
|
||||
await expect(page.getByRole('heading', { name: 'Orgs' })).toBeVisible();
|
||||
await page.getByRole('link', { name: orgName }).click();
|
||||
await page.getByRole('link', { name: projectName }).click();
|
||||
|
||||
@@ -4,8 +4,6 @@
|
||||
*/
|
||||
|
||||
import { test } from '../../global-setup.js';
|
||||
import * as boundaryHttp from '../../helpers/boundary-http.js';
|
||||
import * as boundaryCli from '../../helpers/boundary-cli.js';
|
||||
import { RolesPage } from '../pages/roles.js';
|
||||
import { expect } from '@playwright/test';
|
||||
|
||||
@@ -33,48 +31,33 @@ const columnHeaders = Object.freeze({
|
||||
|
||||
let org, rolesSortedByCreatedTime, rolesSortedByName, rolesSortedById;
|
||||
|
||||
test.beforeEach(
|
||||
async ({
|
||||
request,
|
||||
controllerAddr,
|
||||
adminAuthMethodId,
|
||||
adminLoginName,
|
||||
adminPassword,
|
||||
}) => {
|
||||
org = await boundaryHttp.createOrg(request);
|
||||
test.beforeEach(async ({ apiClient }) => {
|
||||
org = await apiClient.clients.Scope.scopeServiceCreateScope({
|
||||
item: {
|
||||
scopeId: 'global',
|
||||
},
|
||||
skipAdminRoleCreation: true,
|
||||
skipDefaultRoleCreation: true,
|
||||
});
|
||||
|
||||
await boundaryCli.authenticateBoundary(
|
||||
controllerAddr,
|
||||
adminAuthMethodId,
|
||||
adminLoginName,
|
||||
adminPassword,
|
||||
);
|
||||
|
||||
// when the org is created some roles are created automatically, but for the purposes
|
||||
// of this sorting test it's clearer if only the roles created by the test are present
|
||||
const existingRolesToDelete = await boundaryCli.listRoles(org.id);
|
||||
for (const role of existingRolesToDelete) {
|
||||
await boundaryCli.deleteRole(role.id);
|
||||
}
|
||||
|
||||
// create roles with specific names
|
||||
for (const roleName of roleNamesToCreate) {
|
||||
boundaryCli.createRole(org.id, { name: roleName });
|
||||
}
|
||||
|
||||
// collect all roles and data so that sorting can be tested based on the underlying data
|
||||
const roles = await boundaryCli.listRoles(org.id);
|
||||
rolesSortedByCreatedTime = roles.toSorted((a, b) => {
|
||||
return new Date(b.created_time) - new Date(a.created_time);
|
||||
// create roles with specific names
|
||||
const roles = [];
|
||||
for (const roleName of roleNamesToCreate) {
|
||||
const role = await apiClient.clients.Role.roleServiceCreateRole({
|
||||
item: {
|
||||
scopeId: org.id,
|
||||
name: roleName,
|
||||
},
|
||||
});
|
||||
rolesSortedByName = roles.toSorted((a, b) => a.name.localeCompare(b.name));
|
||||
rolesSortedById = roles.toSorted((a, b) => a.id.localeCompare(b.id));
|
||||
},
|
||||
);
|
||||
roles.push(role);
|
||||
}
|
||||
|
||||
test.afterEach(async ({ request }) => {
|
||||
// deletes the org and any associated roles
|
||||
await boundaryHttp.deleteOrg(request, org.id);
|
||||
// collect all roles and data so that sorting can be tested based on the underlying data
|
||||
rolesSortedByCreatedTime = roles.toSorted((a, b) => {
|
||||
return new Date(b.createdTime) - new Date(a.createdTime);
|
||||
});
|
||||
rolesSortedByName = roles.toSorted((a, b) => a.name.localeCompare(b.name));
|
||||
rolesSortedById = roles.toSorted((a, b) => a.id.localeCompare(b.id));
|
||||
});
|
||||
|
||||
test(
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
import { test } from '../../global-setup.js';
|
||||
import { expect } from '@playwright/test';
|
||||
|
||||
import * as boundaryHttp from '../../helpers/boundary-http.js';
|
||||
import * as boundaryCli from '../../helpers/boundary-cli';
|
||||
import { CredentialStoresPage } from '../pages/credential-stores.js';
|
||||
import { HostCatalogsPage } from '../pages/host-catalogs.js';
|
||||
@@ -69,8 +69,8 @@ test(
|
||||
const sessionsPage = new SessionsPage(page);
|
||||
await sessionsPage.waitForSessionToBeVisible(targetName);
|
||||
await page
|
||||
.getByRole('cell', { name: targetName })
|
||||
.locator('..')
|
||||
.getByRole('row')
|
||||
.filter({ has: page.getByRole('cell', { name: targetName }) })
|
||||
.getByRole('button', { name: 'Cancel' })
|
||||
.click();
|
||||
} finally {
|
||||
@@ -143,8 +143,8 @@ test(
|
||||
const sessionsPage = new SessionsPage(page);
|
||||
await sessionsPage.waitForSessionToBeVisible(targetName);
|
||||
await page
|
||||
.getByRole('cell', { name: targetName })
|
||||
.locator('..')
|
||||
.getByRole('row')
|
||||
.filter({ has: page.getByRole('cell', { name: targetName }) })
|
||||
.getByRole('button', { name: 'Cancel' })
|
||||
.click();
|
||||
} finally {
|
||||
@@ -200,9 +200,8 @@ test(
|
||||
// Add/Remove another host source
|
||||
await targetsPage.addHostSourceToTarget(hostSetName2);
|
||||
await page
|
||||
.getByRole('link', { name: hostSetName2 })
|
||||
.locator('..')
|
||||
.locator('..')
|
||||
.getByRole('row')
|
||||
.filter({ has: page.getByRole('link', { name: hostSetName2 }) })
|
||||
.getByRole('button', { name: 'Manage' })
|
||||
.click();
|
||||
await page.getByRole('button', { name: 'Remove' }).click();
|
||||
@@ -245,8 +244,8 @@ test(
|
||||
const sessionsPage = new SessionsPage(page);
|
||||
await sessionsPage.waitForSessionToBeVisible(targetName);
|
||||
await page
|
||||
.getByRole('cell', { name: targetName })
|
||||
.locator('..')
|
||||
.getByRole('row')
|
||||
.filter({ has: page.getByRole('cell', { name: targetName }) })
|
||||
.getByRole('button', { name: 'Cancel' })
|
||||
.click();
|
||||
} finally {
|
||||
@@ -260,3 +259,50 @@ test(
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'Verify RDP target creation',
|
||||
{ tag: ['@ent', '@aws', '@docker'] },
|
||||
async ({ request, page, sshUser, targetAddress, targetPort }) => {
|
||||
await page.goto('/');
|
||||
let org;
|
||||
try {
|
||||
org = await boundaryHttp.createOrg(request);
|
||||
const project = await boundaryHttp.createProject(request, org.id);
|
||||
|
||||
// go to the credential store page
|
||||
|
||||
await page.goto(`/scopes/${project.id}/credential-stores`);
|
||||
|
||||
// Create UPD credential
|
||||
const credentialStore = new CredentialStoresPage(page);
|
||||
await credentialStore.createStaticCredentialStore();
|
||||
const credentialName =
|
||||
await credentialStore.createStaticCredentialUsernamePasswordDomain(
|
||||
sshUser,
|
||||
'testPassword',
|
||||
'testDomain',
|
||||
);
|
||||
|
||||
const targetsPage = new TargetsPage(page);
|
||||
const rdpTarget = await targetsPage.createRDPTargetWithAddressEnt(
|
||||
targetAddress,
|
||||
targetPort,
|
||||
);
|
||||
await targetsPage.addBrokeredCredentialsToTarget(
|
||||
rdpTarget,
|
||||
credentialName,
|
||||
);
|
||||
await targetsPage.addInjectedCredentialsToTarget(
|
||||
rdpTarget,
|
||||
credentialName,
|
||||
);
|
||||
|
||||
// TODO: Connection will be tested later when we have the Proxy in place.
|
||||
} finally {
|
||||
if (org) {
|
||||
await boundaryHttp.deleteOrg(request, org.id);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -88,8 +88,8 @@ test(
|
||||
const sessionsPage = new SessionsPage(page);
|
||||
await sessionsPage.waitForSessionToBeVisible(targetName);
|
||||
await page
|
||||
.getByRole('cell', { name: targetName })
|
||||
.locator('..')
|
||||
.getByRole('row')
|
||||
.filter({ has: page.getByRole('cell', { name: targetName }) })
|
||||
.getByRole('button', { name: 'Cancel' })
|
||||
.click();
|
||||
} finally {
|
||||
@@ -155,8 +155,8 @@ test(
|
||||
const sessionsPage = new SessionsPage(page);
|
||||
await sessionsPage.waitForSessionToBeVisible(targetName);
|
||||
await page
|
||||
.getByRole('cell', { name: targetName })
|
||||
.locator('..')
|
||||
.getByRole('row')
|
||||
.filter({ has: page.getByRole('cell', { name: targetName }) })
|
||||
.getByRole('button', { name: 'Cancel' })
|
||||
.click();
|
||||
} finally {
|
||||
@@ -230,10 +230,16 @@ test(
|
||||
).toBeVisible();
|
||||
await expect(page.getByText('"dev" in "/tags/type"')).toBeVisible();
|
||||
|
||||
await page.locator('textarea').click({ force: true });
|
||||
await page
|
||||
.locator('.CodeMirror')
|
||||
.getByRole('textbox')
|
||||
.click({ force: true });
|
||||
await page.keyboard.press('Meta+A');
|
||||
await page.keyboard.press('Backspace');
|
||||
await page.locator('textarea').fill('"prod" in "/tags/type"');
|
||||
await page
|
||||
.locator('.CodeMirror')
|
||||
.getByRole('textbox')
|
||||
.fill('"prod" in "/tags/type"');
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
const basePage = new BasePage(page);
|
||||
await basePage.dismissSuccessAlert();
|
||||
|
||||
@@ -166,9 +166,8 @@ test(
|
||||
).toHaveText('config');
|
||||
await expect(
|
||||
page
|
||||
.getByRole('table')
|
||||
.getByRole('cell', { name: tagKey })
|
||||
.locator('..')
|
||||
.getByRole('row')
|
||||
.filter({ has: page.getByRole('cell', { name: tagKey }) })
|
||||
.getByRole('cell', { name: tagValue }),
|
||||
).toBeVisible();
|
||||
|
||||
@@ -178,9 +177,8 @@ test(
|
||||
await workersPage.editTag(tagKey, newTagKey, newTagValue);
|
||||
await expect(
|
||||
page
|
||||
.getByRole('table')
|
||||
.getByRole('cell', { name: newTagKey })
|
||||
.locator('..')
|
||||
.getByRole('row')
|
||||
.filter({ has: page.getByRole('cell', { name: newTagKey }) })
|
||||
.getByRole('cell', { name: newTagValue }),
|
||||
).toBeVisible();
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# OpenAPI Generator Ignore
|
||||
# Generated by openapi-generator https://github.com/openapitools/openapi-generator
|
||||
|
||||
# Use this file to prevent files from being overwritten by the generator.
|
||||
# The patterns follow closely to .gitignore or .dockerignore.
|
||||
|
||||
# As an example, the C# client generator defines ApiClient.cs.
|
||||
# You can make changes and tell OpenAPI Generator to ignore just this file by uncommenting the following line:
|
||||
#ApiClient.cs
|
||||
|
||||
# You can match any string of characters against a directory, file or extension with a single asterisk (*):
|
||||
#foo/*/qux
|
||||
# The above matches foo/bar/qux and foo/baz/qux, but not foo/bar/baz/qux
|
||||
|
||||
# You can recursively match patterns against a directory, file or extension with a double asterisk (**):
|
||||
#foo/**/qux
|
||||
# This matches foo/bar/qux, foo/baz/qux, and foo/bar/baz/qux
|
||||
|
||||
# You can also negate patterns with an exclamation (!).
|
||||
# For example, you can ignore all files in a docs folder with the file extension .md:
|
||||
#docs/*.md
|
||||
# Then explicitly reverse the ignore rule for a single file:
|
||||
#!docs/README.md
|
||||
@@ -0,0 +1,249 @@
|
||||
.openapi-generator-ignore
|
||||
apis/AccountServiceApi.ts
|
||||
apis/AliasServiceApi.ts
|
||||
apis/AuthMethodServiceApi.ts
|
||||
apis/AuthTokenServiceApi.ts
|
||||
apis/BillingServiceApi.ts
|
||||
apis/CredentialLibraryServiceApi.ts
|
||||
apis/CredentialServiceApi.ts
|
||||
apis/CredentialStoreServiceApi.ts
|
||||
apis/GroupServiceApi.ts
|
||||
apis/HostCatalogServiceApi.ts
|
||||
apis/HostServiceApi.ts
|
||||
apis/HostSetServiceApi.ts
|
||||
apis/ManagedGroupServiceApi.ts
|
||||
apis/PolicyServiceApi.ts
|
||||
apis/RoleServiceApi.ts
|
||||
apis/ScopeServiceApi.ts
|
||||
apis/SessionRecordingServiceApi.ts
|
||||
apis/SessionServiceApi.ts
|
||||
apis/StorageBucketServiceApi.ts
|
||||
apis/TargetServiceApi.ts
|
||||
apis/UserServiceApi.ts
|
||||
apis/WorkerServiceApi.ts
|
||||
apis/index.ts
|
||||
index.ts
|
||||
models/ControllerApiResourcesAccountsV1Account.ts
|
||||
models/ControllerApiResourcesAliasesV1Alias.ts
|
||||
models/ControllerApiResourcesAuthmethodsV1AuthMethod.ts
|
||||
models/ControllerApiResourcesAuthtokensV1AuthToken.ts
|
||||
models/ControllerApiResourcesBillingV1ActiveUsers.ts
|
||||
models/ControllerApiResourcesCredentiallibrariesV1CredentialLibrary.ts
|
||||
models/ControllerApiResourcesCredentialsV1Credential.ts
|
||||
models/ControllerApiResourcesCredentialstoresV1CredentialStore.ts
|
||||
models/ControllerApiResourcesGroupsV1Group.ts
|
||||
models/ControllerApiResourcesGroupsV1Member.ts
|
||||
models/ControllerApiResourcesHostcatalogsV1HostCatalog.ts
|
||||
models/ControllerApiResourcesHostsV1Host.ts
|
||||
models/ControllerApiResourcesHostsetsV1HostSet.ts
|
||||
models/ControllerApiResourcesManagedgroupsV1ManagedGroup.ts
|
||||
models/ControllerApiResourcesPluginsV1PluginInfo.ts
|
||||
models/ControllerApiResourcesPoliciesV1Policy.ts
|
||||
models/ControllerApiResourcesRolesV1Grant.ts
|
||||
models/ControllerApiResourcesRolesV1GrantJson.ts
|
||||
models/ControllerApiResourcesRolesV1Principal.ts
|
||||
models/ControllerApiResourcesRolesV1Role.ts
|
||||
models/ControllerApiResourcesScopesV1Key.ts
|
||||
models/ControllerApiResourcesScopesV1KeyVersion.ts
|
||||
models/ControllerApiResourcesScopesV1KeyVersionDestructionJob.ts
|
||||
models/ControllerApiResourcesScopesV1Scope.ts
|
||||
models/ControllerApiResourcesScopesV1ScopeInfo.ts
|
||||
models/ControllerApiResourcesSessionrecordingsV1ChannelRecording.ts
|
||||
models/ControllerApiResourcesSessionrecordingsV1ConnectionRecording.ts
|
||||
models/ControllerApiResourcesSessionrecordingsV1Credential.ts
|
||||
models/ControllerApiResourcesSessionrecordingsV1CredentialLibrary.ts
|
||||
models/ControllerApiResourcesSessionrecordingsV1CredentialStore.ts
|
||||
models/ControllerApiResourcesSessionrecordingsV1Host.ts
|
||||
models/ControllerApiResourcesSessionrecordingsV1HostCatalog.ts
|
||||
models/ControllerApiResourcesSessionrecordingsV1SessionRecording.ts
|
||||
models/ControllerApiResourcesSessionrecordingsV1Target.ts
|
||||
models/ControllerApiResourcesSessionrecordingsV1User.ts
|
||||
models/ControllerApiResourcesSessionrecordingsV1ValuesAtTime.ts
|
||||
models/ControllerApiResourcesSessionsV1Connection.ts
|
||||
models/ControllerApiResourcesSessionsV1Session.ts
|
||||
models/ControllerApiResourcesSessionsV1SessionState.ts
|
||||
models/ControllerApiResourcesStoragebucketsV1StorageBucket.ts
|
||||
models/ControllerApiResourcesTargetsV1Alias.ts
|
||||
models/ControllerApiResourcesTargetsV1AuthorizeSessionArguments.ts
|
||||
models/ControllerApiResourcesTargetsV1CredentialSource.ts
|
||||
models/ControllerApiResourcesTargetsV1HostSource.ts
|
||||
models/ControllerApiResourcesTargetsV1SessionAuthorization.ts
|
||||
models/ControllerApiResourcesTargetsV1SessionCredential.ts
|
||||
models/ControllerApiResourcesTargetsV1SessionSecret.ts
|
||||
models/ControllerApiResourcesTargetsV1Target.ts
|
||||
models/ControllerApiResourcesTargetsV1TargetAliasAttributes.ts
|
||||
models/ControllerApiResourcesUsersV1Account.ts
|
||||
models/ControllerApiResourcesUsersV1User.ts
|
||||
models/ControllerApiResourcesWorkersV1Certificate.ts
|
||||
models/ControllerApiResourcesWorkersV1CertificateAuthority.ts
|
||||
models/ControllerApiResourcesWorkersV1RemoteStoragePermissions.ts
|
||||
models/ControllerApiResourcesWorkersV1RemoteStorageState.ts
|
||||
models/ControllerApiResourcesWorkersV1Worker.ts
|
||||
models/ControllerApiServicesV1AccountServiceChangePasswordBody.ts
|
||||
models/ControllerApiServicesV1AccountServiceSetPasswordBody.ts
|
||||
models/ControllerApiServicesV1AddGroupMembersResponse.ts
|
||||
models/ControllerApiServicesV1AddHostSetHostsResponse.ts
|
||||
models/ControllerApiServicesV1AddRoleGrantScopesResponse.ts
|
||||
models/ControllerApiServicesV1AddRoleGrantsResponse.ts
|
||||
models/ControllerApiServicesV1AddRolePrincipalsResponse.ts
|
||||
models/ControllerApiServicesV1AddTargetCredentialSourcesResponse.ts
|
||||
models/ControllerApiServicesV1AddTargetHostSourcesResponse.ts
|
||||
models/ControllerApiServicesV1AddUserAccountsResponse.ts
|
||||
models/ControllerApiServicesV1AddWorkerTagsResponse.ts
|
||||
models/ControllerApiServicesV1AttachStoragePolicyResponse.ts
|
||||
models/ControllerApiServicesV1AuthMethodServiceAuthenticateBody.ts
|
||||
models/ControllerApiServicesV1AuthMethodServiceChangeStateBody.ts
|
||||
models/ControllerApiServicesV1AuthenticateResponse.ts
|
||||
models/ControllerApiServicesV1AuthorizeSessionResponse.ts
|
||||
models/ControllerApiServicesV1CancelSessionResponse.ts
|
||||
models/ControllerApiServicesV1ChangePasswordResponse.ts
|
||||
models/ControllerApiServicesV1ChangeStateResponse.ts
|
||||
models/ControllerApiServicesV1CreateAccountResponse.ts
|
||||
models/ControllerApiServicesV1CreateAliasResponse.ts
|
||||
models/ControllerApiServicesV1CreateAuthMethodResponse.ts
|
||||
models/ControllerApiServicesV1CreateControllerLedResponse.ts
|
||||
models/ControllerApiServicesV1CreateCredentialLibraryResponse.ts
|
||||
models/ControllerApiServicesV1CreateCredentialResponse.ts
|
||||
models/ControllerApiServicesV1CreateCredentialStoreResponse.ts
|
||||
models/ControllerApiServicesV1CreateGroupResponse.ts
|
||||
models/ControllerApiServicesV1CreateHostCatalogResponse.ts
|
||||
models/ControllerApiServicesV1CreateHostResponse.ts
|
||||
models/ControllerApiServicesV1CreateHostSetResponse.ts
|
||||
models/ControllerApiServicesV1CreateManagedGroupResponse.ts
|
||||
models/ControllerApiServicesV1CreatePolicyResponse.ts
|
||||
models/ControllerApiServicesV1CreateRoleResponse.ts
|
||||
models/ControllerApiServicesV1CreateScopeResponse.ts
|
||||
models/ControllerApiServicesV1CreateStorageBucketResponse.ts
|
||||
models/ControllerApiServicesV1CreateTargetResponse.ts
|
||||
models/ControllerApiServicesV1CreateUserResponse.ts
|
||||
models/ControllerApiServicesV1CreateWorkerLedResponse.ts
|
||||
models/ControllerApiServicesV1DestroyKeyVersionRequest.ts
|
||||
models/ControllerApiServicesV1DestroyKeyVersionResponse.ts
|
||||
models/ControllerApiServicesV1DetachStoragePolicyResponse.ts
|
||||
models/ControllerApiServicesV1GetAccountResponse.ts
|
||||
models/ControllerApiServicesV1GetAliasResponse.ts
|
||||
models/ControllerApiServicesV1GetAuthMethodResponse.ts
|
||||
models/ControllerApiServicesV1GetAuthTokenResponse.ts
|
||||
models/ControllerApiServicesV1GetCredentialLibraryResponse.ts
|
||||
models/ControllerApiServicesV1GetCredentialResponse.ts
|
||||
models/ControllerApiServicesV1GetCredentialStoreResponse.ts
|
||||
models/ControllerApiServicesV1GetGroupResponse.ts
|
||||
models/ControllerApiServicesV1GetHostCatalogResponse.ts
|
||||
models/ControllerApiServicesV1GetHostResponse.ts
|
||||
models/ControllerApiServicesV1GetHostSetResponse.ts
|
||||
models/ControllerApiServicesV1GetManagedGroupResponse.ts
|
||||
models/ControllerApiServicesV1GetPolicyResponse.ts
|
||||
models/ControllerApiServicesV1GetRoleResponse.ts
|
||||
models/ControllerApiServicesV1GetScopeResponse.ts
|
||||
models/ControllerApiServicesV1GetSessionRecordingResponse.ts
|
||||
models/ControllerApiServicesV1GetSessionResponse.ts
|
||||
models/ControllerApiServicesV1GetStorageBucketResponse.ts
|
||||
models/ControllerApiServicesV1GetTargetResponse.ts
|
||||
models/ControllerApiServicesV1GetUserResponse.ts
|
||||
models/ControllerApiServicesV1GetWorkerResponse.ts
|
||||
models/ControllerApiServicesV1GroupServiceAddGroupMembersBody.ts
|
||||
models/ControllerApiServicesV1GroupServiceRemoveGroupMembersBody.ts
|
||||
models/ControllerApiServicesV1GroupServiceSetGroupMembersBody.ts
|
||||
models/ControllerApiServicesV1HostSetServiceAddHostSetHostsBody.ts
|
||||
models/ControllerApiServicesV1HostSetServiceRemoveHostSetHostsBody.ts
|
||||
models/ControllerApiServicesV1HostSetServiceSetHostSetHostsBody.ts
|
||||
models/ControllerApiServicesV1ListAccountsResponse.ts
|
||||
models/ControllerApiServicesV1ListAliasesResponse.ts
|
||||
models/ControllerApiServicesV1ListAuthMethodsResponse.ts
|
||||
models/ControllerApiServicesV1ListAuthTokensResponse.ts
|
||||
models/ControllerApiServicesV1ListCredentialLibrariesResponse.ts
|
||||
models/ControllerApiServicesV1ListCredentialStoresResponse.ts
|
||||
models/ControllerApiServicesV1ListCredentialsResponse.ts
|
||||
models/ControllerApiServicesV1ListGroupsResponse.ts
|
||||
models/ControllerApiServicesV1ListHostCatalogsResponse.ts
|
||||
models/ControllerApiServicesV1ListHostSetsResponse.ts
|
||||
models/ControllerApiServicesV1ListHostsResponse.ts
|
||||
models/ControllerApiServicesV1ListKeyVersionDestructionJobsResponse.ts
|
||||
models/ControllerApiServicesV1ListKeysResponse.ts
|
||||
models/ControllerApiServicesV1ListManagedGroupsResponse.ts
|
||||
models/ControllerApiServicesV1ListPoliciesResponse.ts
|
||||
models/ControllerApiServicesV1ListResolvableAliasesResponse.ts
|
||||
models/ControllerApiServicesV1ListRolesResponse.ts
|
||||
models/ControllerApiServicesV1ListScopesResponse.ts
|
||||
models/ControllerApiServicesV1ListSessionRecordingsResponse.ts
|
||||
models/ControllerApiServicesV1ListSessionsResponse.ts
|
||||
models/ControllerApiServicesV1ListStorageBucketsResponse.ts
|
||||
models/ControllerApiServicesV1ListTargetsResponse.ts
|
||||
models/ControllerApiServicesV1ListUsersResponse.ts
|
||||
models/ControllerApiServicesV1ListWorkersResponse.ts
|
||||
models/ControllerApiServicesV1MonthlyActiveUsersResponse.ts
|
||||
models/ControllerApiServicesV1ReApplyStoragePolicyResponse.ts
|
||||
models/ControllerApiServicesV1ReadCertificateAuthorityResponse.ts
|
||||
models/ControllerApiServicesV1ReinitializeCertificateAuthorityResponse.ts
|
||||
models/ControllerApiServicesV1RemoveGroupMembersResponse.ts
|
||||
models/ControllerApiServicesV1RemoveHostSetHostsResponse.ts
|
||||
models/ControllerApiServicesV1RemoveRoleGrantScopesResponse.ts
|
||||
models/ControllerApiServicesV1RemoveRoleGrantsResponse.ts
|
||||
models/ControllerApiServicesV1RemoveRolePrincipalsResponse.ts
|
||||
models/ControllerApiServicesV1RemoveTargetCredentialSourcesResponse.ts
|
||||
models/ControllerApiServicesV1RemoveTargetHostSourcesResponse.ts
|
||||
models/ControllerApiServicesV1RemoveUserAccountsResponse.ts
|
||||
models/ControllerApiServicesV1RemoveWorkerTagsResponse.ts
|
||||
models/ControllerApiServicesV1RoleServiceAddRoleGrantScopesBody.ts
|
||||
models/ControllerApiServicesV1RoleServiceAddRoleGrantsBody.ts
|
||||
models/ControllerApiServicesV1RoleServiceAddRolePrincipalsBody.ts
|
||||
models/ControllerApiServicesV1RoleServiceRemoveRoleGrantScopesBody.ts
|
||||
models/ControllerApiServicesV1RoleServiceRemoveRoleGrantsBody.ts
|
||||
models/ControllerApiServicesV1RoleServiceRemoveRolePrincipalsBody.ts
|
||||
models/ControllerApiServicesV1RoleServiceSetRoleGrantScopesBody.ts
|
||||
models/ControllerApiServicesV1RoleServiceSetRoleGrantsBody.ts
|
||||
models/ControllerApiServicesV1RoleServiceSetRolePrincipalsBody.ts
|
||||
models/ControllerApiServicesV1RotateKeysRequest.ts
|
||||
models/ControllerApiServicesV1ScopeServiceAttachStoragePolicyBody.ts
|
||||
models/ControllerApiServicesV1ScopeServiceDetachStoragePolicyBody.ts
|
||||
models/ControllerApiServicesV1SessionServiceCancelSessionBody.ts
|
||||
models/ControllerApiServicesV1SetGroupMembersResponse.ts
|
||||
models/ControllerApiServicesV1SetHostSetHostsResponse.ts
|
||||
models/ControllerApiServicesV1SetPasswordResponse.ts
|
||||
models/ControllerApiServicesV1SetRoleGrantScopesResponse.ts
|
||||
models/ControllerApiServicesV1SetRoleGrantsResponse.ts
|
||||
models/ControllerApiServicesV1SetRolePrincipalsResponse.ts
|
||||
models/ControllerApiServicesV1SetTargetCredentialSourcesResponse.ts
|
||||
models/ControllerApiServicesV1SetTargetHostSourcesResponse.ts
|
||||
models/ControllerApiServicesV1SetUserAccountsResponse.ts
|
||||
models/ControllerApiServicesV1SetWorkerTagsResponse.ts
|
||||
models/ControllerApiServicesV1TargetServiceAddTargetCredentialSourcesBody.ts
|
||||
models/ControllerApiServicesV1TargetServiceAddTargetHostSourcesBody.ts
|
||||
models/ControllerApiServicesV1TargetServiceAuthorizeSessionBody.ts
|
||||
models/ControllerApiServicesV1TargetServiceRemoveTargetCredentialSourcesBody.ts
|
||||
models/ControllerApiServicesV1TargetServiceRemoveTargetHostSourcesBody.ts
|
||||
models/ControllerApiServicesV1TargetServiceSetTargetCredentialSourcesBody.ts
|
||||
models/ControllerApiServicesV1TargetServiceSetTargetHostSourcesBody.ts
|
||||
models/ControllerApiServicesV1UpdateAccountResponse.ts
|
||||
models/ControllerApiServicesV1UpdateAliasResponse.ts
|
||||
models/ControllerApiServicesV1UpdateAuthMethodResponse.ts
|
||||
models/ControllerApiServicesV1UpdateCredentialLibraryResponse.ts
|
||||
models/ControllerApiServicesV1UpdateCredentialResponse.ts
|
||||
models/ControllerApiServicesV1UpdateCredentialStoreResponse.ts
|
||||
models/ControllerApiServicesV1UpdateGroupResponse.ts
|
||||
models/ControllerApiServicesV1UpdateHostCatalogResponse.ts
|
||||
models/ControllerApiServicesV1UpdateHostResponse.ts
|
||||
models/ControllerApiServicesV1UpdateHostSetResponse.ts
|
||||
models/ControllerApiServicesV1UpdateManagedGroupResponse.ts
|
||||
models/ControllerApiServicesV1UpdatePolicyResponse.ts
|
||||
models/ControllerApiServicesV1UpdateRoleResponse.ts
|
||||
models/ControllerApiServicesV1UpdateScopeResponse.ts
|
||||
models/ControllerApiServicesV1UpdateStorageBucketResponse.ts
|
||||
models/ControllerApiServicesV1UpdateTargetResponse.ts
|
||||
models/ControllerApiServicesV1UpdateUserResponse.ts
|
||||
models/ControllerApiServicesV1UpdateWorkerResponse.ts
|
||||
models/ControllerApiServicesV1UserServiceAddUserAccountsBody.ts
|
||||
models/ControllerApiServicesV1UserServiceRemoveUserAccountsBody.ts
|
||||
models/ControllerApiServicesV1UserServiceSetUserAccountsBody.ts
|
||||
models/ControllerApiServicesV1WorkerServiceAddWorkerTagsBody.ts
|
||||
models/ControllerApiServicesV1WorkerServiceRemoveWorkerTagsBody.ts
|
||||
models/ControllerApiServicesV1WorkerServiceSetWorkerTagsBody.ts
|
||||
models/ControllerApiV1Error.ts
|
||||
models/ControllerApiV1ErrorDetails.ts
|
||||
models/ControllerApiV1FieldError.ts
|
||||
models/ControllerApiV1WrappedError.ts
|
||||
models/GoogleApiHttpBody.ts
|
||||
models/GoogleProtobufAny.ts
|
||||
models/GoogleProtobufNullValue.ts
|
||||
models/index.ts
|
||||
runtime.ts
|
||||
@@ -0,0 +1 @@
|
||||
7.13.0
|
||||
@@ -0,0 +1,377 @@
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/**
|
||||
* Boundary controller HTTP API
|
||||
* Welcome to the Boundary controller HTTP API documentation. This page provides a reference guide for using the Boundary controller API, a JSON-based HTTP API. The API implements commonly seen HTTP API patterns for status codes, paths, and errors. See the [API overview](https://developer.hashicorp.com/boundary/docs/api-clients/api) for more information. Before you read this page, it is useful to understand Boundary\'s [domain model](https://developer.hashicorp.com/boundary/docs/concepts/domain-model) and to be aware of the terminology used here. To get started, search for the service you want to interact with in the sidebar to the left. Each resource in Boundary, such as accounts and credential stores, has its own service. Each service contains all the API endpoints for the resource. ## Status codes - `2XX`: Boundary returns a code between `200` and `299` on success. Generally this is `200`, but implementations should be prepared to accept any `2XX` status code as indicating success. If a call returns a `2XX` code that is not `200`, it follows well-understood semantics for those status codes. - `400`: Boundary returns `400` when a command cannot be completed due to invalid user input, except for a properly-formatted identifier that does not map to an existing resource, which returns a `404` as discussed below. - `401`: Boundary returns `401` if no authentication token is provided or if the provided token is invalid. A valid token that simply does not have permission for a resource returns a `403` instead. A token that is invalid or missing, but where the anonymous user (`u_anon`) is able to successfully perform the action, will not return a `401` but instead will return the result of the action. - `403`: Boundary returns `403` if a provided token was valid but does not have the grants required to perform the requested action. - `404`: Boundary returns `404` if a resource cannot be found. Note that this happens _prior_ to authentication/authorization checking in nearly all cases as the resource information (such as its scope, available actions, etc.) is a required part of that check. As a result, an action against a resource that does not exist returns a `404` instead of a `401` or `403`. While this could be considered an information leak, since IDs are randomly generated and this only discloses whether an ID is valid, it\'s tolerable as it allows for far simpler and more robust client implementation. - `405`: Boundary returns a `405` to indicate that the method (HTTP verb or custom action) is not implemented for the given resource. - `429`: Boundary returns a `429` if any of the API rate limit quotas have been exhausted for the resource and action. It includes the `Retry-After` header so that the client knows how long to wait before making a new request. - `500`: Boundary returns `500` if an error occurred that is not (directly) tied to invalid user input. If a `500` is generated, information about the error is logged to Boundary\'s server log but is not generally provided to the client. - `503`: Boundary returns a `503` if it is unable to store a quota due to the API rate limit being exceeded. It includes the `Retry-After` header so that the client knows how long to wait before making a new request. ## List pagination Boundary uses [API pagination](https://developer.hashicorp.com/boundary/docs/api-clients/api/pagination) to support searching and filtering large lists of results efficiently.
|
||||
*
|
||||
* The version of the OpenAPI document: 0.19.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
* https://openapi-generator.tech
|
||||
* Do not edit the class manually.
|
||||
*/
|
||||
|
||||
|
||||
import * as runtime from '../runtime';
|
||||
import type {
|
||||
ControllerApiResourcesAccountsV1Account,
|
||||
ControllerApiServicesV1AccountServiceChangePasswordBody,
|
||||
ControllerApiServicesV1AccountServiceSetPasswordBody,
|
||||
ControllerApiServicesV1ListAccountsResponse,
|
||||
ControllerApiV1Error,
|
||||
} from '../models/index';
|
||||
import {
|
||||
ControllerApiResourcesAccountsV1AccountFromJSON,
|
||||
ControllerApiResourcesAccountsV1AccountToJSON,
|
||||
ControllerApiServicesV1AccountServiceChangePasswordBodyFromJSON,
|
||||
ControllerApiServicesV1AccountServiceChangePasswordBodyToJSON,
|
||||
ControllerApiServicesV1AccountServiceSetPasswordBodyFromJSON,
|
||||
ControllerApiServicesV1AccountServiceSetPasswordBodyToJSON,
|
||||
ControllerApiServicesV1ListAccountsResponseFromJSON,
|
||||
ControllerApiServicesV1ListAccountsResponseToJSON,
|
||||
ControllerApiV1ErrorFromJSON,
|
||||
ControllerApiV1ErrorToJSON,
|
||||
} from '../models/index';
|
||||
|
||||
export interface AccountServiceChangePasswordRequest {
|
||||
id: string;
|
||||
body: ControllerApiServicesV1AccountServiceChangePasswordBody;
|
||||
}
|
||||
|
||||
export interface AccountServiceCreateAccountRequest {
|
||||
item: Omit<ControllerApiResourcesAccountsV1Account, 'id'|'created_time'|'updated_time'|'managed_group_ids'|'authorized_actions'>;
|
||||
}
|
||||
|
||||
export interface AccountServiceDeleteAccountRequest {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface AccountServiceGetAccountRequest {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface AccountServiceListAccountsRequest {
|
||||
authMethodId?: string;
|
||||
filter?: string;
|
||||
listToken?: string;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export interface AccountServiceSetPasswordRequest {
|
||||
id: string;
|
||||
body: ControllerApiServicesV1AccountServiceSetPasswordBody;
|
||||
}
|
||||
|
||||
export interface AccountServiceUpdateAccountRequest {
|
||||
id: string;
|
||||
item: Omit<ControllerApiResourcesAccountsV1Account, 'id'|'created_time'|'updated_time'|'managed_group_ids'|'authorized_actions'>;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
export class AccountServiceApi extends runtime.BaseAPI {
|
||||
|
||||
/**
|
||||
* Sets the password for the provided account.
|
||||
*/
|
||||
async accountServiceChangePasswordRaw(requestParameters: AccountServiceChangePasswordRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<runtime.ApiResponse<ControllerApiResourcesAccountsV1Account>> {
|
||||
if (requestParameters['id'] == null) {
|
||||
throw new runtime.RequiredError(
|
||||
'id',
|
||||
'Required parameter "id" was null or undefined when calling accountServiceChangePassword().'
|
||||
);
|
||||
}
|
||||
|
||||
if (requestParameters['body'] == null) {
|
||||
throw new runtime.RequiredError(
|
||||
'body',
|
||||
'Required parameter "body" was null or undefined when calling accountServiceChangePassword().'
|
||||
);
|
||||
}
|
||||
|
||||
const queryParameters: any = {};
|
||||
|
||||
const headerParameters: runtime.HTTPHeaders = {};
|
||||
|
||||
headerParameters['Content-Type'] = 'application/json';
|
||||
|
||||
if (this.configuration && this.configuration.apiKey) {
|
||||
headerParameters["Authorization"] = await this.configuration.apiKey("Authorization"); // ApiKeyAuth authentication
|
||||
}
|
||||
|
||||
const response = await this.request({
|
||||
path: `/v1/accounts/{id}:change-password`.replace(`{${"id"}}`, encodeURIComponent(String(requestParameters['id']))),
|
||||
method: 'POST',
|
||||
headers: headerParameters,
|
||||
query: queryParameters,
|
||||
body: ControllerApiServicesV1AccountServiceChangePasswordBodyToJSON(requestParameters['body']),
|
||||
}, initOverrides);
|
||||
|
||||
return new runtime.JSONApiResponse(response, (jsonValue) => ControllerApiResourcesAccountsV1AccountFromJSON(jsonValue));
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the password for the provided account.
|
||||
*/
|
||||
async accountServiceChangePassword(requestParameters: AccountServiceChangePasswordRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<ControllerApiResourcesAccountsV1Account> {
|
||||
const response = await this.accountServiceChangePasswordRaw(requestParameters, initOverrides);
|
||||
return await response.value();
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a single account in the provided auth method.
|
||||
*/
|
||||
async accountServiceCreateAccountRaw(requestParameters: AccountServiceCreateAccountRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<runtime.ApiResponse<ControllerApiResourcesAccountsV1Account>> {
|
||||
if (requestParameters['item'] == null) {
|
||||
throw new runtime.RequiredError(
|
||||
'item',
|
||||
'Required parameter "item" was null or undefined when calling accountServiceCreateAccount().'
|
||||
);
|
||||
}
|
||||
|
||||
const queryParameters: any = {};
|
||||
|
||||
const headerParameters: runtime.HTTPHeaders = {};
|
||||
|
||||
headerParameters['Content-Type'] = 'application/json';
|
||||
|
||||
if (this.configuration && this.configuration.apiKey) {
|
||||
headerParameters["Authorization"] = await this.configuration.apiKey("Authorization"); // ApiKeyAuth authentication
|
||||
}
|
||||
|
||||
const response = await this.request({
|
||||
path: `/v1/accounts`,
|
||||
method: 'POST',
|
||||
headers: headerParameters,
|
||||
query: queryParameters,
|
||||
body: ControllerApiResourcesAccountsV1AccountToJSON(requestParameters['item']),
|
||||
}, initOverrides);
|
||||
|
||||
return new runtime.JSONApiResponse(response, (jsonValue) => ControllerApiResourcesAccountsV1AccountFromJSON(jsonValue));
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a single account in the provided auth method.
|
||||
*/
|
||||
async accountServiceCreateAccount(requestParameters: AccountServiceCreateAccountRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<ControllerApiResourcesAccountsV1Account> {
|
||||
const response = await this.accountServiceCreateAccountRaw(requestParameters, initOverrides);
|
||||
return await response.value();
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes an account.
|
||||
*/
|
||||
async accountServiceDeleteAccountRaw(requestParameters: AccountServiceDeleteAccountRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<runtime.ApiResponse<object>> {
|
||||
if (requestParameters['id'] == null) {
|
||||
throw new runtime.RequiredError(
|
||||
'id',
|
||||
'Required parameter "id" was null or undefined when calling accountServiceDeleteAccount().'
|
||||
);
|
||||
}
|
||||
|
||||
const queryParameters: any = {};
|
||||
|
||||
const headerParameters: runtime.HTTPHeaders = {};
|
||||
|
||||
if (this.configuration && this.configuration.apiKey) {
|
||||
headerParameters["Authorization"] = await this.configuration.apiKey("Authorization"); // ApiKeyAuth authentication
|
||||
}
|
||||
|
||||
const response = await this.request({
|
||||
path: `/v1/accounts/{id}`.replace(`{${"id"}}`, encodeURIComponent(String(requestParameters['id']))),
|
||||
method: 'DELETE',
|
||||
headers: headerParameters,
|
||||
query: queryParameters,
|
||||
}, initOverrides);
|
||||
|
||||
return new runtime.JSONApiResponse<any>(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes an account.
|
||||
*/
|
||||
async accountServiceDeleteAccount(requestParameters: AccountServiceDeleteAccountRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<object> {
|
||||
const response = await this.accountServiceDeleteAccountRaw(requestParameters, initOverrides);
|
||||
return await response.value();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a single account based on the input ID
|
||||
*/
|
||||
async accountServiceGetAccountRaw(requestParameters: AccountServiceGetAccountRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<runtime.ApiResponse<ControllerApiResourcesAccountsV1Account>> {
|
||||
if (requestParameters['id'] == null) {
|
||||
throw new runtime.RequiredError(
|
||||
'id',
|
||||
'Required parameter "id" was null or undefined when calling accountServiceGetAccount().'
|
||||
);
|
||||
}
|
||||
|
||||
const queryParameters: any = {};
|
||||
|
||||
const headerParameters: runtime.HTTPHeaders = {};
|
||||
|
||||
if (this.configuration && this.configuration.apiKey) {
|
||||
headerParameters["Authorization"] = await this.configuration.apiKey("Authorization"); // ApiKeyAuth authentication
|
||||
}
|
||||
|
||||
const response = await this.request({
|
||||
path: `/v1/accounts/{id}`.replace(`{${"id"}}`, encodeURIComponent(String(requestParameters['id']))),
|
||||
method: 'GET',
|
||||
headers: headerParameters,
|
||||
query: queryParameters,
|
||||
}, initOverrides);
|
||||
|
||||
return new runtime.JSONApiResponse(response, (jsonValue) => ControllerApiResourcesAccountsV1AccountFromJSON(jsonValue));
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a single account based on the input ID
|
||||
*/
|
||||
async accountServiceGetAccount(requestParameters: AccountServiceGetAccountRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<ControllerApiResourcesAccountsV1Account> {
|
||||
const response = await this.accountServiceGetAccountRaw(requestParameters, initOverrides);
|
||||
return await response.value();
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists all accounts in a specific auth method.
|
||||
*/
|
||||
async accountServiceListAccountsRaw(requestParameters: AccountServiceListAccountsRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<runtime.ApiResponse<ControllerApiServicesV1ListAccountsResponse>> {
|
||||
const queryParameters: any = {};
|
||||
|
||||
if (requestParameters['authMethodId'] != null) {
|
||||
queryParameters['auth_method_id'] = requestParameters['authMethodId'];
|
||||
}
|
||||
|
||||
if (requestParameters['filter'] != null) {
|
||||
queryParameters['filter'] = requestParameters['filter'];
|
||||
}
|
||||
|
||||
if (requestParameters['listToken'] != null) {
|
||||
queryParameters['list_token'] = requestParameters['listToken'];
|
||||
}
|
||||
|
||||
if (requestParameters['pageSize'] != null) {
|
||||
queryParameters['page_size'] = requestParameters['pageSize'];
|
||||
}
|
||||
|
||||
const headerParameters: runtime.HTTPHeaders = {};
|
||||
|
||||
if (this.configuration && this.configuration.apiKey) {
|
||||
headerParameters["Authorization"] = await this.configuration.apiKey("Authorization"); // ApiKeyAuth authentication
|
||||
}
|
||||
|
||||
const response = await this.request({
|
||||
path: `/v1/accounts`,
|
||||
method: 'GET',
|
||||
headers: headerParameters,
|
||||
query: queryParameters,
|
||||
}, initOverrides);
|
||||
|
||||
return new runtime.JSONApiResponse(response, (jsonValue) => ControllerApiServicesV1ListAccountsResponseFromJSON(jsonValue));
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists all accounts in a specific auth method.
|
||||
*/
|
||||
async accountServiceListAccounts(requestParameters: AccountServiceListAccountsRequest = {}, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<ControllerApiServicesV1ListAccountsResponse> {
|
||||
const response = await this.accountServiceListAccountsRaw(requestParameters, initOverrides);
|
||||
return await response.value();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the password for the provided account.
|
||||
*/
|
||||
async accountServiceSetPasswordRaw(requestParameters: AccountServiceSetPasswordRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<runtime.ApiResponse<ControllerApiResourcesAccountsV1Account>> {
|
||||
if (requestParameters['id'] == null) {
|
||||
throw new runtime.RequiredError(
|
||||
'id',
|
||||
'Required parameter "id" was null or undefined when calling accountServiceSetPassword().'
|
||||
);
|
||||
}
|
||||
|
||||
if (requestParameters['body'] == null) {
|
||||
throw new runtime.RequiredError(
|
||||
'body',
|
||||
'Required parameter "body" was null or undefined when calling accountServiceSetPassword().'
|
||||
);
|
||||
}
|
||||
|
||||
const queryParameters: any = {};
|
||||
|
||||
const headerParameters: runtime.HTTPHeaders = {};
|
||||
|
||||
headerParameters['Content-Type'] = 'application/json';
|
||||
|
||||
if (this.configuration && this.configuration.apiKey) {
|
||||
headerParameters["Authorization"] = await this.configuration.apiKey("Authorization"); // ApiKeyAuth authentication
|
||||
}
|
||||
|
||||
const response = await this.request({
|
||||
path: `/v1/accounts/{id}:set-password`.replace(`{${"id"}}`, encodeURIComponent(String(requestParameters['id']))),
|
||||
method: 'POST',
|
||||
headers: headerParameters,
|
||||
query: queryParameters,
|
||||
body: ControllerApiServicesV1AccountServiceSetPasswordBodyToJSON(requestParameters['body']),
|
||||
}, initOverrides);
|
||||
|
||||
return new runtime.JSONApiResponse(response, (jsonValue) => ControllerApiResourcesAccountsV1AccountFromJSON(jsonValue));
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the password for the provided account.
|
||||
*/
|
||||
async accountServiceSetPassword(requestParameters: AccountServiceSetPasswordRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<ControllerApiResourcesAccountsV1Account> {
|
||||
const response = await this.accountServiceSetPasswordRaw(requestParameters, initOverrides);
|
||||
return await response.value();
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates an account.
|
||||
*/
|
||||
async accountServiceUpdateAccountRaw(requestParameters: AccountServiceUpdateAccountRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<runtime.ApiResponse<ControllerApiResourcesAccountsV1Account>> {
|
||||
if (requestParameters['id'] == null) {
|
||||
throw new runtime.RequiredError(
|
||||
'id',
|
||||
'Required parameter "id" was null or undefined when calling accountServiceUpdateAccount().'
|
||||
);
|
||||
}
|
||||
|
||||
if (requestParameters['item'] == null) {
|
||||
throw new runtime.RequiredError(
|
||||
'item',
|
||||
'Required parameter "item" was null or undefined when calling accountServiceUpdateAccount().'
|
||||
);
|
||||
}
|
||||
|
||||
const queryParameters: any = {};
|
||||
|
||||
const headerParameters: runtime.HTTPHeaders = {};
|
||||
|
||||
headerParameters['Content-Type'] = 'application/json';
|
||||
|
||||
if (this.configuration && this.configuration.apiKey) {
|
||||
headerParameters["Authorization"] = await this.configuration.apiKey("Authorization"); // ApiKeyAuth authentication
|
||||
}
|
||||
|
||||
const response = await this.request({
|
||||
path: `/v1/accounts/{id}`.replace(`{${"id"}}`, encodeURIComponent(String(requestParameters['id']))),
|
||||
method: 'PATCH',
|
||||
headers: headerParameters,
|
||||
query: queryParameters,
|
||||
body: ControllerApiResourcesAccountsV1AccountToJSON(requestParameters['item']),
|
||||
}, initOverrides);
|
||||
|
||||
return new runtime.JSONApiResponse(response, (jsonValue) => ControllerApiResourcesAccountsV1AccountFromJSON(jsonValue));
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates an account.
|
||||
*/
|
||||
async accountServiceUpdateAccount(requestParameters: AccountServiceUpdateAccountRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<ControllerApiResourcesAccountsV1Account> {
|
||||
const response = await this.accountServiceUpdateAccountRaw(requestParameters, initOverrides);
|
||||
return await response.value();
|
||||
}
|
||||
|
||||
}
|
||||
Loaded 100 of 652 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user