diff --git a/addons/api/addon/handlers/sqlite-handler.js b/addons/api/addon/handlers/sqlite-handler.js index 1e976b334..d489d006d 100644 --- a/addons/api/addon/handlers/sqlite-handler.js +++ b/addons/api/addon/handlers/sqlite-handler.js @@ -63,14 +63,35 @@ export default class SqliteHandler { rateLimitWarning; if (!peekDb) { - const tokenKey = `${type}-${hashCode(remainingQuery)}`; + let tokenKey = `${type}-${hashCode(remainingQuery)}`; + // The global token key represents a recursive query from global scope, + // which pulls back every resource of this type. + const globalTokenKey = `${type}-${hashCode({ scope_id: 'global', recursive: true })}`; if (storeToken) { - const [tokenObj] = await this.sqlite.fetchResource({ - ...generateSQLExpressions('token', { - filters: { id: [{ equals: tokenKey }] }, - }), - }); - listToken = tokenObj?.token; + // Always prefer the global token when it exists for + // any more fine-grained query. The global query already fetched every + // item for this resource type, so we should just re-use it. + if (tokenKey !== globalTokenKey) { + const [globalTokenObj] = await this.sqlite.fetchResource({ + ...generateSQLExpressions('token', { + filters: { id: [{ equals: globalTokenKey }] }, + }), + }); + if (globalTokenObj?.token) { + tokenKey = globalTokenKey; + listToken = globalTokenObj.token; + } + } + + // Only look up the specific token if no global token was found. + if (!listToken) { + 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 diff --git a/addons/api/tests/unit/handlers/sqlite-handler-test.js b/addons/api/tests/unit/handlers/sqlite-handler-test.js index 8976b6c1b..188dfecea 100644 --- a/addons/api/tests/unit/handlers/sqlite-handler-test.js +++ b/addons/api/tests/unit/handlers/sqlite-handler-test.js @@ -470,6 +470,62 @@ module('Unit | Handler | sqlite-handler', function (hooks) { assert.strictEqual(results[0].name, 'specific-target'); }); + test('it reuses the global token for a more specific query when the global token exists', async function (assert) { + const targets = this.server.createList('target', 3, { scope }); + let capturedListToken; + let responseToken = 'specific-list-token'; + + this.server.get('targets', (_schema, request) => { + capturedListToken = request.queryParams.list_token; + return { + items: targets.map((t) => + this.server.serializerOrRegistry.serialize(t), + ), + list_token: responseToken, + response_type: 'complete', + }; + }); + + // Run the specific query first + await store.query('target', { scope_id: 'some-specific-scope' }); + + // Switch the response token and run the global query + responseToken = 'global-list-token'; + await store.query('target', { scope_id: 'global', recursive: true }); + + capturedListToken = undefined; + + // Re-run the specific query which has a cached token but should prefer the global token since it exists + await store.query('target', { scope_id: 'some-specific-scope' }); + + assert.strictEqual(capturedListToken, 'global-list-token'); + }); + + test('it uses the specific token when no global token exists', async function (assert) { + const targets = this.server.createList('target', 3, { scope }); + let capturedListToken; + + this.server.get('targets', (_schema, request) => { + capturedListToken = request.queryParams.list_token; + return { + items: targets.map((t) => + this.server.serializerOrRegistry.serialize(t), + ), + list_token: 'specific-list-token', + response_type: 'complete', + }; + }); + + await store.query('target', { scope_id: 'some-specific-scope' }); + + capturedListToken = undefined; + + // Re-run the specific query which should just use the normal specific token since no global token exists + await store.query('target', { scope_id: 'some-specific-scope' }); + + assert.strictEqual(capturedListToken, 'specific-list-token'); + }); + test('it retries query when invalid list token error occurs', async function (assert) { let targets = this.server.createList('target', 5, { scope }); diff --git a/addons/core/translations/resources/en-us.yaml b/addons/core/translations/resources/en-us.yaml index ba64409a5..aee5967ec 100644 --- a/addons/core/translations/resources/en-us.yaml +++ b/addons/core/translations/resources/en-us.yaml @@ -663,13 +663,14 @@ role: edit-grants: title: Edit Grants description: Modify existing grant strings or add new grant strings to this role. - no-suggestions: No suggestions completion-info: + no-suggestions: No suggestions wildcard-types: Wildcard - matches all types wildcard-ids: Wildcard - matches all IDs wildcard-actions: Wildcard - matches all actions template-value: Template value all-fields: All fields + loading-ids: Still loading ID suggestions… linting-errors: general: whitespace-not-allowed: Whitespace is not allowed diff --git a/addons/rose/addon/styles/hds/themes/dark-mode/index.scss b/addons/rose/addon/styles/hds/themes/dark-mode/index.scss index d405d48f6..ec0ee6c8b 100644 --- a/addons/rose/addon/styles/hds/themes/dark-mode/index.scss +++ b/addons/rose/addon/styles/hds/themes/dark-mode/index.scss @@ -191,6 +191,56 @@ } } +// Overrides for CodeMirror 6 autocomplete and lint tooltips/panels. +@mixin dark-mode-codemirror-override { + /* stylelint-disable selector-class-pattern */ + .cm-editor { + // Autocomplete dropdown and lint hover tooltips + .cm-tooltip { + background-color: var(--token-color-palette-neutral-50); + border: 1px solid var(--token-color-palette-neutral-100); + color: var(--token-color-palette-neutral-400); + } + + // Arrow pointer for tooltips shown above the cursor + .cm-tooltip-above::before { + border-top-color: var(--token-color-palette-neutral-50); + } + + // Arrow pointer for tooltips shown below the cursor + .cm-tooltip-below::before { + border-bottom-color: var(--token-color-palette-neutral-50); + } + + // Autocomplete list > selected item highlight + .cm-tooltip-autocomplete > ul > li[aria-selected] { + background-color: var(--token-color-palette-blue-300); + color: var(--token-color-palette-neutral-700); + } + + // Content/gutter border + .cm-content { + border-left: 1px solid var(--token-color-palette-neutral-100); + } + + // Lint diagnostics panel (shown at the bottom of the editor) + .cm-panel.cm-panel-lint { + border-top: 3px solid var(--token-color-palette-neutral-100); + + ul { + background-color: var(--token-color-palette-neutral-100); + + // Lint panel > selected diagnostic item highlight + &:focus li.cm-diagnostic[aria-selected] { + background-color: var(--token-color-palette-neutral-50); + color: var(--token-color-palette-neutral-400); + } + } + } + } + /* stylelint-enable selector-class-pattern */ +} + @media (prefers-color-scheme: dark) { .ember-application:not(.rose-theme-light) { @include dark-mode; @@ -198,6 +248,7 @@ @include dark-mode-ember-dropdown-override; @include dark-mode-app-header-override; @include dark-mode-app-side-nav-override; + @include dark-mode-codemirror-override; } } @@ -207,4 +258,5 @@ @include dark-mode-ember-dropdown-override; @include dark-mode-app-header-override; @include dark-mode-app-side-nav-override; + @include dark-mode-codemirror-override; } diff --git a/ui/admin/app/components/form/role/edit-grants/index.hbs b/ui/admin/app/components/form/role/edit-grants/index.hbs index 0ccea87b9..86949c136 100644 --- a/ui/admin/app/components/form/role/edit-grants/index.hbs +++ b/ui/admin/app/components/form/role/edit-grants/index.hbs @@ -22,6 +22,7 @@ @ariaLabel={{t 'form.secret-editor.label'}} @value={{this.grantStringsText}} @onInput={{this.onInput}} + @onSetup={{this.onSetup}} @customExtensions={{this.customExtensions}} @cspNonce={{(csp-nonce)}} data-test-code-editor diff --git a/ui/admin/app/components/form/role/edit-grants/index.js b/ui/admin/app/components/form/role/edit-grants/index.js index 1acefebea..4ba76354b 100644 --- a/ui/admin/app/components/form/role/edit-grants/index.js +++ b/ui/admin/app/components/form/role/edit-grants/index.js @@ -17,50 +17,155 @@ import { } from '@hashicorp/design-system-components/codemirror'; import { createGrantCompletionSource } from 'admin/utils/grant-completions'; import { createGrantLinter } from 'admin/utils/grant-linter'; +import { + GRANT_SCOPE_THIS, + GRANT_SCOPE_CHILDREN, + GRANT_SCOPE_DESCENDANTS, + GRANT_SCOPE_KEYWORDS, +} from 'api/models/role'; export default class FormRoleEditGrantsComponent extends Component { @service intl; + @service db; // =attributes + #editorView; exportOptionsMap = { terraform: 'terraform', nativeHCL: 'native-hcl' }; exportOptions = Object.values(this.exportOptionsMap); - completionTranslatedStrings = { - noSuggestions: this.intl.t('resources.role.edit-grants.no-suggestions'), - wildcardTypes: this.intl.t( - 'resources.role.edit-grants.completion-info.wildcard-types', - ), - wildcardIds: this.intl.t( - 'resources.role.edit-grants.completion-info.wildcard-ids', - ), - templateValue: this.intl.t( - 'resources.role.edit-grants.completion-info.template-value', - ), - wildcardActions: this.intl.t( - 'resources.role.edit-grants.completion-info.wildcard-actions', - ), - allFields: this.intl.t( - 'resources.role.edit-grants.completion-info.all-fields', - ), - }; - - translateLintingError = (key, options = {}) => - this.intl.t(`resources.role.edit-grants.linting-errors.${key}`, options); - - completionSource = createGrantCompletionSource( - this.args.grantsSchema, - this.completionTranslatedStrings, - ); - linterSource = createGrantLinter( - this.args.grantsSchema, - this.translateLintingError, - ); @tracked grantStringsText = (this.args.model?.grant_strings ?? []).join('\n'); @tracked currentLineText = this.args.model?.grant_strings?.[0] ?? ''; @tracked showExportOptionsFlyout = false; @tracked selectedExportOption = this.exportOptions[0]; + /** + * Returns the scope IDs that should be used to filter ID suggestions. + * @returns {Promise} + */ + async getAllowedScopeIds() { + const grantScopeIds = this.args.model?.grant_scope_ids ?? []; + const roleScopeId = this.args.model?.scopeID; + + // Descendants covers the entire subtree so no need to filter + if (grantScopeIds.includes(GRANT_SCOPE_DESCENDANTS)) { + return null; + } + + const scopeIds = new Set(); + + if (grantScopeIds.includes(GRANT_SCOPE_THIS)) { + scopeIds.add(roleScopeId); + } + + if (grantScopeIds.includes(GRANT_SCOPE_CHILDREN)) { + // Grab direct children scopes + try { + const childScopes = await this.db.query('scope', { + query: { filters: { scope_id: [{ equals: roleScopeId }] } }, + select: [{ field: 'id' }], + }); + childScopes.forEach(({ id }) => { + scopeIds.add(id); + }); + } catch { + // In cases of errors, just return all IDs without filtering rather than blocking completions entirely + return null; + } + } + + // Any concrete org/project IDs specified directly + grantScopeIds + .filter((id) => !GRANT_SCOPE_KEYWORDS.includes(id)) + .forEach((id) => scopeIds.add(id)); + + return scopeIds.size ? [...scopeIds] : null; + } + + getIsLoading = () => { + return this.args.loadResourcesTask?.isRunning ?? false; + }; + + /** + * Looks up resource IDs from the local DB. + * @param {string} partial - The current partial value the user has typed + * @param {string[]|null} [compatibleResourceTypes] - Resource types to restrict results to + * @returns {Promise>} + */ + idLookup = async (partial, compatibleResourceTypes) => { + // When the partial already contains an underscore we know the ID prefix + // (e.g. "ttcp_") and can narrow down which types to query. + let typesToQuery; + if (partial.includes('_')) { + const idPrefix = partial.split('_')[0]; + const matchingGrantTypes = (this.args.grantsSchema?.resource_types ?? []) + .filter((rt) => rt.id_prefixes?.includes(idPrefix)) + .map((rt) => rt.type); + + typesToQuery = (this.args.searchableResourceTypes ?? []).filter((t) => + matchingGrantTypes.includes(t), + ); + } else { + typesToQuery = this.args.searchableResourceTypes ?? []; + } + + // Restrict to the compatible resource types if provided + if (compatibleResourceTypes) { + typesToQuery = typesToQuery.filter((t) => + compatibleResourceTypes.includes(t), + ); + } + + if (!typesToQuery.length) { + return []; + } + + const scopeIds = await this.getAllowedScopeIds(); + const filters = {}; + if (scopeIds) { + filters.scope_id = scopeIds.map((id) => ({ equals: id })); + } + + const results = await Promise.allSettled( + typesToQuery.map((type) => + this.db.query(type, { + query: { + // Search both id and name so a user can type a resource name and + // still get the ID inserted as the completion + search: partial + ? { text: partial, fields: ['id', 'name'] } + : undefined, + filters, + }, + page: 1, + pageSize: 10, + select: [{ field: 'id' }, { field: 'name' }], + }), + ), + ); + + return results + .filter(({ status }) => status === 'fulfilled') + .flatMap(({ value }) => value) + .map(({ id, name }) => ({ id, name })); + }; + + translateLintingError = (key, options = {}) => + this.intl.t(`resources.role.edit-grants.linting-errors.${key}`, options); + translateCompletionString = (key, options = {}) => + this.intl.t(`resources.role.edit-grants.completion-info.${key}`, options); + + completionSource = createGrantCompletionSource( + this.args.grantsSchema, + this.translateCompletionString, + this.idLookup, + this.getIsLoading, + ); + linterSource = createGrantLinter( + this.args.grantsSchema, + this.translateLintingError, + ); + customExtensions = [ autocompletion({ override: [this.completionSource], @@ -72,6 +177,11 @@ export default class FormRoleEditGrantsComponent extends Component { keymap.of([...completionKeymap, ...lintKeymap]), ]; + willDestroy() { + super.willDestroy(); + this.#editorView?.destroy(); + } + get grantStrings() { return this.grantStringsText .split('\n') @@ -118,6 +228,11 @@ export default class FormRoleEditGrantsComponent extends Component { // =actions + @action + onSetup(view) { + this.#editorView = view; + } + @action onInput(value, view) { this.grantStringsText = value; diff --git a/ui/admin/app/routes/scopes/scope/roles/role/edit-grants.js b/ui/admin/app/routes/scopes/scope/roles/role/edit-grants.js index 8173a1d91..fdd3825fd 100644 --- a/ui/admin/app/routes/scopes/scope/roles/role/edit-grants.js +++ b/ui/admin/app/routes/scopes/scope/roles/role/edit-grants.js @@ -5,6 +5,16 @@ import Route from '@ember/routing/route'; import { service } from '@ember/service'; +import { dropTask } from 'ember-concurrency'; +import { modelMapping } from 'api/services/sqlite'; +import { notifyError } from 'core/decorators/notify'; + +// Only resource types that have a name column are useful for ID suggestions +// Currently this excludes session-recording and sessions as they aren't likely to be used +// and could be expensive to load. +const NAMED_RESOURCE_TYPES = Object.keys(modelMapping).filter( + (type) => 'name' in modelMapping[type], +); export default class ScopesScopeRolesRoleEditGrantsRoute extends Route { @service store; @@ -13,15 +23,32 @@ export default class ScopesScopeRolesRoleEditGrantsRoute extends Route { const role = this.modelFor('scopes.scope.roles.role'); let grantsSchema = { resource_types: [] }; - try { - grantsSchema = await this.fetchGrantsSchema(); - } catch { - // TODO: Return an error message - } + grantsSchema = await this.fetchGrantsSchema(); - return { role, grantsSchema }; + // Kick off background loading of all supported resource types into DB without awaiting. + this.loadResourcesTask.perform(role.scopeID); + + return { + role, + grantsSchema, + loadResourcesTask: this.loadResourcesTask, + searchableResourceTypes: NAMED_RESOURCE_TYPES, + }; } + /** + * Loads all supported resource types recursively so they are available + * for ID autocompletion suggestions. + */ + loadResourcesTask = dropTask(async (scopeId) => { + await Promise.allSettled( + NAMED_RESOURCE_TYPES.map((type) => + this.store.query(type, { scope_id: scopeId, recursive: true }), + ), + ); + }); + + @notifyError(({ message }) => message, { catch: true }) async fetchGrantsSchema() { const adapter = this.store.adapterFor('application'); const url = `${adapter.host ?? ''}/grants-schema.json`; diff --git a/ui/admin/app/templates/scopes/scope/roles/role/edit-grants.hbs b/ui/admin/app/templates/scopes/scope/roles/role/edit-grants.hbs index f02b67391..5c2113f58 100644 --- a/ui/admin/app/templates/scopes/scope/roles/role/edit-grants.hbs +++ b/ui/admin/app/templates/scopes/scope/roles/role/edit-grants.hbs @@ -31,6 +31,8 @@ diff --git a/ui/admin/app/utils/grant-completions.js b/ui/admin/app/utils/grant-completions.js index 3a7640695..dc55c1b44 100644 --- a/ui/admin/app/utils/grant-completions.js +++ b/ui/admin/app/utils/grant-completions.js @@ -5,6 +5,12 @@ const GRANT_FIELDS = ['ids', 'type', 'actions', 'output_fields']; const ID_TEMPLATES = ['{{.User.Id}}', '{{.Account.Id}}']; +const TEMPLATE_RESOURCE_TYPES = { + '{{.User.Id}}': 'user', + '{{.Account.Id}}': 'account', + '{{user.id}}': 'user', + '{{account.id}}': 'account', +}; const filterByPrefix = (values, partial) => values.filter((value) => value.startsWith(partial)); const withWildCard = (values = []) => ['*', ...new Set(values)]; @@ -33,7 +39,7 @@ const parseGrantFields = (lineText) => .filter(({ fieldName }) => fieldName); const normalizeGrantsSchema = (grantsSchema) => { - const resourceTypes = grantsSchema.resource_types ?? []; + const resourceTypes = grantsSchema?.resource_types ?? []; const resourcesByType = resourceTypes.reduce((resources, resourceType) => { const collectionActions = resourceType.collection_actions ?? []; @@ -84,8 +90,13 @@ const normalizeGrantsSchema = (grantsSchema) => { }; }; -const getResourceTypesForId = (schema, id) => - id.includes('_') +const getResourceTypesForId = (schema, id) => { + const templateType = TEMPLATE_RESOURCE_TYPES[id]; + if (templateType) { + return [templateType].filter((type) => schema.resourcesByType[type]); + } + + return id.includes('_') ? [ schema.resourceTypesByIdPrefix.find( ({ idPrefix }) => id.split('_')[0] === idPrefix, @@ -94,6 +105,7 @@ const getResourceTypesForId = (schema, id) => : schema.resourceTypesByIdPrefix .filter(({ idPrefix }) => idPrefix.startsWith(id)) .map(({ type }) => type); +}; const getCompatibleResourceTypeForIds = (schema, idsValue) => { const resourceTypesById = parseIds(idsValue).flatMap((id) => @@ -234,7 +246,38 @@ const getActionOptions = (schema, typeValue, idsValue) => { : actionOptions; }; -function grantCompletions(context, schema, translatedStrings) { +const getIdLookupTypes = (schema, typeValue, enteredIds) => { + if (enteredIds.length > 0) { + const compatibleResourceType = getCompatibleResourceTypeForIds( + schema, + enteredIds.join(','), + ); + // No valid suggestions if there are mixed resource types across entered IDs + return compatibleResourceType ? [compatibleResourceType] : []; + } + + if (typeValue === '*') { + // Only parent resource IDs (those that have child types) are useful + return Object.keys(schema.childResourceTypesByParentType); + } + + if (typeValue) { + const parentType = schema.resourcesByType[typeValue]?.parentType; + // Restrict to the parent's IDs otherwise there are no valid ID suggestions besides wildcard + return parentType ? [parentType] : []; + } + + // All types are potentially valid + return null; +}; + +async function grantCompletions( + context, + schema, + translate, + idLookup, + getIsLoading, +) { const line = context.state.doc.lineAt(context.pos); const beforeCursor = line.text.slice(0, context.pos - line.from); @@ -281,37 +324,73 @@ function grantCompletions(context, schema, translatedStrings) { const options = filterByPrefix(typeOptions, partial).map((type) => ({ label: type, type: 'type', - info: type === '*' ? translatedStrings.wildcardTypes : '', + info: type === '*' ? translate('wildcard-types') : '', })); return { from: context.pos - partial.length, options: options.length ? options - : [getNoSuggestionsOption(translatedStrings.noSuggestions)], + : [getNoSuggestionsOption(translate('no-suggestions'))], }; } - const idsValueMatch = beforeCursor.match(/ids=([a-z0-9_*.{},-]*)$/); + const idsValueMatch = beforeCursor.match(/ids=([\w*.{},\s-]*)$/); if (idsValueMatch) { const enteredIds = idsValueMatch[1].split(','); const partial = enteredIds.pop() ?? ''; const hasEnteredIds = enteredIds.length > 0; - // TODO: Add loaded ID suggestions here? Logic below will change + + const staticOptions = filterByPrefix(['*', ...ID_TEMPLATES], partial) + .filter((value) => !hasEnteredIds || value !== '*') + .filter( + (value) => + (!hasEnteredIds && !typeValue) || !ID_TEMPLATES.includes(value), + ) + .filter((value) => !enteredIds.includes(value)) + .map((value) => ({ + label: value, + type: value === '*' ? 'constant' : 'interface', + info: + value === '*' + ? translate('wildcard-ids') + : translate('template-value'), + })); + + const compatibleResourceTypes = getIdLookupTypes( + schema, + typeValue, + enteredIds, + ); + const ids = await idLookup(partial, compatibleResourceTypes); + const idOptions = ids + .filter(({ id }) => !enteredIds.includes(id)) + .map(({ id, name }) => ({ + label: id, + detail: name, + type: 'variable', + })); + + const allOptions = [...staticOptions, ...idOptions]; + + // Show a loading placeholder when IDs are still being fetched in the background + if (getIsLoading()) { + allOptions.push({ + label: translate('loading-ids'), + type: 'text', + apply: () => {}, + }); + } return { from: context.pos - partial.length, - options: filterByPrefix(['*', ...ID_TEMPLATES], partial) - .filter((value) => !hasEnteredIds || value !== '*') - .filter((value) => !enteredIds.includes(value)) - .map((value) => ({ - label: value, - type: value === '*' ? 'constant' : 'interface', - info: - value === '*' - ? translatedStrings.wildcardIds - : translatedStrings.templateValue, - })), + options: allOptions.length + ? allOptions + : [getNoSuggestionsOption(translate('no-suggestions'))], + // We do our own filtering via DB search, so disable CodeMirror's + // built-in filtering. Without this, name-based searches are dropped + // because the label (the ID) doesn't match the typed text (the name). + filter: false, }; } @@ -321,6 +400,7 @@ function grantCompletions(context, schema, translatedStrings) { const enteredActions = actionsValueMatch[1].split(','); const partial = enteredActions.pop() ?? ''; const hasEnteredActions = enteredActions.length > 0; + const options = filterByPrefix( getActionOptions(schema, typeValue, idsValue), partial, @@ -330,14 +410,14 @@ function grantCompletions(context, schema, translatedStrings) { .map((action) => ({ label: action, type: 'constant', - info: action === '*' ? translatedStrings.wildcardActions : '', + info: action === '*' ? translate('wildcard-actions') : '', })); return { from: context.pos - partial.length, options: options.length ? options - : [getNoSuggestionsOption(translatedStrings.noSuggestions)], + : [getNoSuggestionsOption(translate('no-suggestions'))], }; } @@ -357,7 +437,7 @@ function grantCompletions(context, schema, translatedStrings) { .map((value) => ({ label: value, type: 'constant', - info: translatedStrings.allFields, + info: translate('all-fields'), })), }; } @@ -367,9 +447,12 @@ function grantCompletions(context, schema, translatedStrings) { export const createGrantCompletionSource = ( grantsSchema, - translatedStrings, + translate, + idLookup, + getIsLoading, ) => { const schema = normalizeGrantsSchema(grantsSchema); - return (context) => grantCompletions(context, schema, translatedStrings); + return (context) => + grantCompletions(context, schema, translate, idLookup, getIsLoading); }; diff --git a/ui/admin/app/utils/grant-linter.js b/ui/admin/app/utils/grant-linter.js index a126248d6..5f64fa9b2 100644 --- a/ui/admin/app/utils/grant-linter.js +++ b/ui/admin/app/utils/grant-linter.js @@ -30,7 +30,7 @@ const createAddTextAction = (name) => ({ }); const normalizeGrantsSchema = (grantsSchema) => { - const resourceTypes = grantsSchema.resource_types ?? []; + const resourceTypes = grantsSchema?.resource_types ?? []; const resourcesByType = resourceTypes.reduce((resources, resourceType) => { const collectionActions = resourceType.collection_actions ?? []; diff --git a/ui/admin/tests/unit/utils/grant-completions-test.js b/ui/admin/tests/unit/utils/grant-completions-test.js index 872d355b0..e63f788c6 100644 --- a/ui/admin/tests/unit/utils/grant-completions-test.js +++ b/ui/admin/tests/unit/utils/grant-completions-test.js @@ -38,25 +38,12 @@ module('Unit | Utils | grant-completions', function (hooks) { hooks.beforeEach(async function () { this.intl = this.owner.lookup('service:intl'); - this.completionTranslatedStrings = { - noSuggestions: this.intl.t('resources.role.edit-grants.no-suggestions'), - wildcardTypes: this.intl.t( - 'resources.role.edit-grants.completion-info.wildcard-types', - ), - wildcardIds: this.intl.t( - 'resources.role.edit-grants.completion-info.wildcard-ids', - ), - wildcardActions: this.intl.t( - 'resources.role.edit-grants.completion-info.wildcard-actions', - ), - templateValue: this.intl.t( - 'resources.role.edit-grants.completion-info.template-value', - ), - allFields: this.intl.t( - 'resources.role.edit-grants.completion-info.all-fields', - ), - }; - this.noSuggestionsLabel = this.completionTranslatedStrings.noSuggestions; + + this.translate = (key) => + this.intl.t(`resources.role.edit-grants.completion-info.${key}`); + + this.idLookup = async () => []; + this.getIsLoading = () => false; const response = await fetch('/grants-schema.json'); @@ -65,6 +52,8 @@ module('Unit | Utils | grant-completions', function (hooks) { } const grantsSchemaData = await response.json(); + this.grantsSchemaData = grantsSchemaData; + allActionLabels = grantsSchemaData.resource_types.reduce( (labels, resource) => { const resourceLabels = [ @@ -79,7 +68,9 @@ module('Unit | Utils | grant-completions', function (hooks) { this.grantCompletionSource = createGrantCompletionSource( grantsSchemaData, - this.completionTranslatedStrings, + this.translate, + this.idLookup, + this.getIsLoading, ); }); @@ -130,13 +121,33 @@ module('Unit | Utils | grant-completions', function (hooks) { lineText: 'ids=hs_123;type=h', expectedLabels: ['NO_SUGGESTIONS'], }, + '{{.User.Id}} template has no child resource types so shows no suggestions': + { + lineText: 'ids={{.User.Id}};type=', + expectedLabels: ['NO_SUGGESTIONS'], + }, + '{{.Account.Id}} template has no child resource types so shows no suggestions': + { + lineText: 'ids={{.Account.Id}};type=', + expectedLabels: ['NO_SUGGESTIONS'], + }, + '{{user.id}} template has no child resource types so shows no suggestions': + { + lineText: 'ids={{user.id}};type=', + expectedLabels: ['NO_SUGGESTIONS'], + }, + '{{account.id}} template has no child resource types so shows no suggestions': + { + lineText: 'ids={{account.id}};type=', + expectedLabels: ['NO_SUGGESTIONS'], + }, }, - function (assert, { lineText, expectedLabels }) { - const completionResult = this.grantCompletionSource( + async function (assert, { lineText, expectedLabels }) { + const completionResult = await this.grantCompletionSource( createCompletionContext(lineText), ); const resolvedLabels = expectedLabels.map((label) => - label === 'NO_SUGGESTIONS' ? this.noSuggestionsLabel : label, + label === 'NO_SUGGESTIONS' ? this.translate('no-suggestions') : label, ); assert.deepEqual(getLabels(completionResult), resolvedLabels); @@ -280,76 +291,439 @@ module('Unit | Utils | grant-completions', function (hooks) { lineText: 'ids=*;type=*;actions=', expectedLabels: () => allActionLabels, }, - 'canonical template ids without a type show a no suggestions placeholder': - { - lineText: 'ids={{.User.Id}};actions=', - expectedLabels: ['NO_SUGGESTIONS'], - }, + '{{.User.Id}} template without a type suggests user id actions': { + lineText: 'ids={{.User.Id}};actions=', + expectedLabels: [ + '*', + 'list-resolvable-aliases', + 'read', + 'update', + 'delete', + 'add-accounts', + 'set-accounts', + 'remove-accounts', + ], + }, + '{{.Account.Id}} template without a type suggests account id actions': { + lineText: 'ids={{.Account.Id}};actions=', + expectedLabels: [ + '*', + 'set-password', + 'change-password', + 'read', + 'update', + 'delete', + ], + }, + '{{user.id}} template without a type suggests user id actions': { + lineText: 'ids={{user.id}};actions=', + expectedLabels: [ + '*', + 'list-resolvable-aliases', + 'read', + 'update', + 'delete', + 'add-accounts', + 'set-accounts', + 'remove-accounts', + ], + }, + '{{account.id}} template without a type suggests account id actions': { + lineText: 'ids={{account.id}};actions=', + expectedLabels: [ + '*', + 'set-password', + 'change-password', + 'read', + 'update', + 'delete', + ], + }, + 'mixed user and account templates show a no suggestions placeholder': { + lineText: 'ids={{.User.Id}},{{.Account.Id}};actions=', + expectedLabels: ['NO_SUGGESTIONS'], + }, }, - function (assert, { lineText, expectedLabels }) { - const completionResult = this.grantCompletionSource( + async function (assert, { lineText, expectedLabels }) { + const completionResult = await this.grantCompletionSource( createCompletionContext(lineText), ); const resolvedLabels = ( typeof expectedLabels === 'function' ? expectedLabels() : expectedLabels ).map((label) => - label === 'NO_SUGGESTIONS' ? this.noSuggestionsLabel : label, + label === 'NO_SUGGESTIONS' ? this.translate('no-suggestions') : label, ); assert.deepEqual(getLabels(completionResult), resolvedLabels); }, ); - test.each( - 'ids suggestions', - { - 'include supported template values': { - lineText: 'ids={{', - expectedLabels: ['{{.User.Id}}', '{{.Account.Id}}'], - }, - }, - function (assert, { lineText, expectedLabels }) { - const completionResult = this.grantCompletionSource( - createCompletionContext(lineText), - ); - - assert.deepEqual(getLabels(completionResult), expectedLabels); - }, - ); - - test('completion info labels are translated values', function (assert) { - const typeCompletionResult = this.grantCompletionSource( + test('completion info labels are translated values', async function (assert) { + const typeCompletionResult = await this.grantCompletionSource( createCompletionContext('type='), ); - const idsCompletionResult = this.grantCompletionSource( + const idsCompletionResult = await this.grantCompletionSource( createCompletionContext('ids='), ); - const outputFieldsCompletionResult = this.grantCompletionSource( + const outputFieldsCompletionResult = await this.grantCompletionSource( createCompletionContext('output_fields='), ); - const actionsCompletionResult = this.grantCompletionSource( + const actionsCompletionResult = await this.grantCompletionSource( createCompletionContext('actions='), ); assert.strictEqual( typeCompletionResult.options[0].info, - this.completionTranslatedStrings.wildcardTypes, + this.translate('wildcard-types'), ); assert.deepEqual(getInfos(idsCompletionResult), [ - this.completionTranslatedStrings.wildcardIds, - this.completionTranslatedStrings.templateValue, - this.completionTranslatedStrings.templateValue, + this.translate('wildcard-ids'), + this.translate('template-value'), + this.translate('template-value'), ]); assert.strictEqual( outputFieldsCompletionResult.options[0].info, - this.completionTranslatedStrings.allFields, + this.translate('all-fields'), ); assert.strictEqual( actionsCompletionResult.options[0].info, - this.completionTranslatedStrings.wildcardActions, + this.translate('wildcard-actions'), ); }); + + module('id auto completion', function () { + test('idLookup results are included alongside static options', async function (assert) { + const source = createGrantCompletionSource( + this.grantsSchemaData, + this.translate, + async () => [ + { id: 'ttcp_abc123', name: 'My Target' }, + { id: 'ttcp_def456', name: 'Another Target' }, + ], + () => false, + ); + + const result = await source(createCompletionContext('ids=')); + + const labels = getLabels(result); + assert.true(labels.includes('*')); + assert.true(labels.includes('{{.User.Id}}')); + assert.true(labels.includes('{{.Account.Id}}')); + assert.true(labels.includes('ttcp_abc123')); + assert.true(labels.includes('ttcp_def456')); + }); + + test('idLookup results have detail set to the resource name', async function (assert) { + const source = createGrantCompletionSource( + this.grantsSchemaData, + this.translate, + async () => [{ id: 'ttcp_abc123', name: 'My Target' }], + () => false, + ); + + const result = await source(createCompletionContext('ids=')); + + const idOption = result.options.find((o) => o.label === 'ttcp_abc123'); + assert.ok(idOption, 'found the ID option'); + assert.strictEqual(idOption.detail, 'My Target'); + }); + + test('static options appear before idLookup results', async function (assert) { + const source = createGrantCompletionSource( + this.grantsSchemaData, + this.translate, + async () => [{ id: 'ttcp_abc123', name: 'My Target' }], + () => false, + ); + + const result = await source(createCompletionContext('ids=')); + const labels = getLabels(result); + + assert.true( + labels.indexOf('*') < labels.indexOf('ttcp_abc123'), + 'wildcard appears before DB results', + ); + }); + + test('loading placeholder is appended when getIsLoading returns true', async function (assert) { + const source = createGrantCompletionSource( + this.grantsSchemaData, + this.translate, + async () => [{ id: 'ttcp_abc123', name: 'My Target' }], + () => true, + ); + + const result = await source(createCompletionContext('ids=')); + const labels = getLabels(result); + + assert.true(labels.includes(this.translate('loading-ids'))); + assert.strictEqual( + labels.at(-1), + this.translate('loading-ids'), + 'loading placeholder is the last option', + ); + }); + + test('loading placeholder is NOT present when getIsLoading returns false', async function (assert) { + const source = createGrantCompletionSource( + this.grantsSchemaData, + this.translate, + async () => [], + () => false, + ); + + const result = await source(createCompletionContext('ids=')); + const labels = getLabels(result); + + assert.false(labels.includes(this.translate('loading-ids'))); + }); + + test('wildcard and templates are hidden when any IDs are already entered', async function (assert) { + const source = createGrantCompletionSource( + this.grantsSchemaData, + this.translate, + async () => [], + () => false, + ); + + const result = await source(createCompletionContext('ids=ttcp_abc123,')); + const labels = getLabels(result); + + assert.false(labels.includes('*')); + assert.false(labels.includes('{{.User.Id}}')); + assert.false(labels.includes('{{.Account.Id}}')); + }); + + test('templates are hidden when a type field is present, even with no IDs entered', async function (assert) { + const source = createGrantCompletionSource( + this.grantsSchemaData, + this.translate, + async () => [], + () => false, + ); + + const result = await source(createCompletionContext('type=user;ids=')); + const labels = getLabels(result); + + assert.false(labels.includes('{{.User.Id}}')); + assert.false(labels.includes('{{.Account.Id}}')); + }); + + test('wildcard is still suggested when a type field is present and no IDs are entered', async function (assert) { + const source = createGrantCompletionSource( + this.grantsSchemaData, + this.translate, + async () => [], + () => false, + ); + + const result = await source(createCompletionContext('type=user;ids=')); + const labels = getLabels(result); + + assert.true(labels.includes('*')); + }); + + test('templates are suggested when no type field and no IDs are entered', async function (assert) { + const source = createGrantCompletionSource( + this.grantsSchemaData, + this.translate, + async () => [], + () => false, + ); + + const result = await source(createCompletionContext('ids=')); + const labels = getLabels(result); + + assert.true(labels.includes('{{.User.Id}}')); + assert.true(labels.includes('{{.Account.Id}}')); + }); + + test('when type=* idLookup is called with all parent resource types', async function (assert) { + assert.expect(1); + + const source = createGrantCompletionSource( + this.grantsSchemaData, + this.translate, + async (partial, compatibleResourceTypes) => { + assert.deepEqual(compatibleResourceTypes, [ + 'auth-method', + 'host-catalog', + 'credential-store', + ]); + return []; + }, + () => false, + ); + + await source(createCompletionContext('type=*;ids=')); + }); + + test('when type is a child type idLookup is called with the parent type and its results are included', async function (assert) { + assert.expect(2); + + const source = createGrantCompletionSource( + this.grantsSchemaData, + this.translate, + async (partial, compatibleResourceTypes) => { + assert.deepEqual(compatibleResourceTypes, ['host-catalog']); + return [{ id: 'hcst_parentid', name: 'My Host Catalog' }]; + }, + () => false, + ); + + const result = await source( + createCompletionContext('type=host-set;ids='), + ); + + assert.true(getLabels(result).includes('hcst_parentid')); + }); + + test('already-entered IDs are not offered again', async function (assert) { + const source = createGrantCompletionSource( + this.grantsSchemaData, + this.translate, + async () => [], + () => false, + ); + + const result = await source(createCompletionContext('ids={{.User.Id}},')); + const labels = getLabels(result); + + assert.false(labels.includes('{{.User.Id}}')); + }); + + test('empty idLookup result still returns static options', async function (assert) { + const source = createGrantCompletionSource( + this.grantsSchemaData, + this.translate, + async () => [], + () => false, + ); + + const result = await source(createCompletionContext('ids=')); + + assert.deepEqual(getLabels(result), [ + '*', + '{{.User.Id}}', + '{{.Account.Id}}', + ]); + }); + + test('partial prefix filters static options correctly', async function (assert) { + const source = createGrantCompletionSource( + this.grantsSchemaData, + this.translate, + async () => [], + () => false, + ); + + const result = await source(createCompletionContext('ids={{')); + + const labels = getLabels(result); + assert.false(labels.includes('*'), 'wildcard filtered out by prefix'); + assert.deepEqual(labels, ['{{.User.Id}}', '{{.Account.Id}}']); + }); + + test('idLookup is called when partial contains uppercase letters and spaces, enabling name-based search', async function (assert) { + assert.expect(2); + + const source = createGrantCompletionSource( + this.grantsSchemaData, + this.translate, + async (partial) => { + assert.strictEqual(partial, 'My Target'); + return [{ id: 'ttcp_abc123', name: 'My Target' }]; + }, + () => false, + ); + + const result = await source(createCompletionContext('ids=My Target')); + const labels = getLabels(result); + + assert.true(labels.includes('ttcp_abc123')); + }); + + test('idLookup results with undefined name still work (detail is undefined)', async function (assert) { + const source = createGrantCompletionSource( + this.grantsSchemaData, + this.translate, + async () => [{ id: 'ttcp_abc123', name: undefined }], + () => false, + ); + + const result = await source(createCompletionContext('ids=')); + const idOption = result.options.find((o) => o.label === 'ttcp_abc123'); + + assert.ok(idOption); + assert.strictEqual(idOption.detail, undefined); + }); + + test('idLookup is called with compatibleResourceTypes when entered IDs all share a single resource type', async function (assert) { + assert.expect(1); + + const source = createGrantCompletionSource( + this.grantsSchemaData, + this.translate, + async (partial, compatibleResourceTypes) => { + assert.deepEqual(compatibleResourceTypes, ['target']); + return []; + }, + () => false, + ); + + await source(createCompletionContext('ids=ttcp_abc123,')); + }); + + test('no suggestions shown when entered IDs have mixed resource types', async function (assert) { + const source = createGrantCompletionSource( + this.grantsSchemaData, + this.translate, + async () => [], + () => false, + ); + + const result = await source( + createCompletionContext('ids=ttcp_abc123,hs_456,'), + ); + + assert.deepEqual(getLabels(result), [this.translate('no-suggestions')]); + }); + + test('idLookup results matching already-entered IDs are excluded', async function (assert) { + const source = createGrantCompletionSource( + this.grantsSchemaData, + this.translate, + async () => [ + { id: 'ttcp_abc123', name: 'Already Entered' }, + { id: 'ttcp_def456', name: 'New Target' }, + ], + () => false, + ); + + const result = await source(createCompletionContext('ids=ttcp_abc123,')); + const labels = getLabels(result); + + assert.false( + labels.includes('ttcp_abc123'), + 'already-entered ID excluded', + ); + assert.true(labels.includes('ttcp_def456'), 'new ID included'); + }); + + test('no suggestions shown when idLookup returns empty results with entered IDs', async function (assert) { + const source = createGrantCompletionSource( + this.grantsSchemaData, + this.translate, + async () => [], + () => false, + ); + + const result = await source(createCompletionContext('ids=ttcp_abc123,')); + + assert.deepEqual(getLabels(result), [this.translate('no-suggestions')]); + }); + }); });