feat: 🎸 Add linting to code editor (#3211)
* feat: 🎸 Add linting to code editor * refactor: 💡 add missing case for wildcard type & ids field * refactor: 💡 simplify condition + add more tests * refactor: 💡 cleanup ids validation logic and add more lint erro * refactor: 💡 simplify logic and add actions linting cases * feat: 🎸 Add autocompletion to code editor (#3207) * refactor: 💡 apply PR feedback * refactor: 💡 PR feedback * refactor: 💡 add noop action * fix: 🐛 noop should not be suggested with resource type format ✅ Closes: https://hashicorp.atlassian.net/browse/ICU-18462 --------- Co-authored-by: Zhihe Li <zhihe.li@hashicorp.com>
This commit is contained in:
1 parent
dc224268f5
commit
1d126dc074
7 files changed
+1078
-8
No files matched your search
@@ -26,7 +26,7 @@ export default factory.extend({
|
||||
'set-grant-scopes',
|
||||
],
|
||||
grant_strings: () => [
|
||||
'ids=*;action=*',
|
||||
'ids=*;actions=*',
|
||||
'ids=*;type=host-catalog;actions=create,read',
|
||||
],
|
||||
|
||||
|
||||
@@ -670,6 +670,51 @@ role:
|
||||
wildcard-actions: Wildcard - matches all actions
|
||||
template-value: Template value
|
||||
all-fields: All fields
|
||||
linting-errors:
|
||||
general:
|
||||
whitespace-not-allowed: Whitespace is not allowed
|
||||
leading-semicolon-not-allowed: Leading semicolon is not allowed
|
||||
trailing-semicolon-not-allowed: Trailing semicolon is not allowed
|
||||
invalid-character: Invalid character "{character}"
|
||||
invalid-format: 'Invalid format: expected key=value'
|
||||
invalid-syntax: Invalid syntax
|
||||
fields:
|
||||
duplicate-field: Duplicate field "{field}"
|
||||
unknown-field: Unknown field "{field}"
|
||||
empty-value: '"{field}" value cannot be empty'
|
||||
missing-ids-or-type: Missing "ids" or "type" fields
|
||||
missing-actions: Missing "actions" field
|
||||
missing-actions-or-output-fields: Missing "actions" or "output_fields" fields
|
||||
type:
|
||||
invalid-type: Invalid resource type "{type}"
|
||||
not-top-level: Type "{type}" is not a top-level type
|
||||
not-child-type: Type "{type}" is not a child type of the pinned ID
|
||||
missing-for-wildcard-ids: Missing "type" field for wildcard ids
|
||||
wildcard-requires-ids: Missing "ids" field for wildcard type
|
||||
ids:
|
||||
empty-list: '"ids" field does not contain at least one id'
|
||||
wildcard-cannot-combine: Wildcard id "*" cannot be combined with other ids
|
||||
duplicate-template: Duplicate id "{primary}" and "{secondary}"
|
||||
duplicate-id: Duplicate id "{id}"
|
||||
unknown-template: Unknown template "{id}"
|
||||
invalid-id: Invalid id "{id}"
|
||||
mismatched-types: Ids do not have the same type. Invalid id "{id}"
|
||||
must-support-child-types: Pinned IDs must support child types. Invalid id "{id}"
|
||||
must-match-parent-type: Pinned IDs must be "{parentType}" ids. Invalid id "{id}"
|
||||
actions:
|
||||
empty-list: '"actions" field does not contain at least one action'
|
||||
wildcard-cannot-combine: Wildcard action "*" cannot be combined with other actions
|
||||
duplicate-action: Duplicate action "{action}"
|
||||
invalid-action: Invalid action "{action}"
|
||||
id-only: Only id actions for "{type}" type are allowed. Invalid action "{action}"
|
||||
collection-only: Only collection actions are allowed. Invalid action "{action}"
|
||||
noop-requires-list: '"no-op" action should only be used with "list" action'
|
||||
list-requires-noop: '"list" action should be used with "no-op" action to have an effect'
|
||||
noop-unnecessary: '"no-op" action is unnecessary when other actions are specified'
|
||||
editor:
|
||||
remove: Remove
|
||||
remove-comma: Remove comma
|
||||
add-noop: Add "no-op" action
|
||||
editor:
|
||||
title: Grant Editor
|
||||
description: Add each new grant string on a different line
|
||||
|
||||
Generated
+4
-4
@@ -8728,8 +8728,8 @@ packages:
|
||||
resolution: {integrity: sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
|
||||
terser-webpack-plugin@5.3.17:
|
||||
resolution: {integrity: sha512-YR7PtUp6GMU91BgSJmlaX/rS2lGDbAF7D+Wtq7hRO+MiljNmodYvqslzCFiYVAgW+Qoaaia/QUIP4lGXufjdZw==}
|
||||
terser-webpack-plugin@5.4.0:
|
||||
resolution: {integrity: sha512-Bn5vxm48flOIfkdl5CaD2+1CiUVbonWQ3KQPyP7/EuIl9Gbzq/gQFOzaMFUEgVjB1396tcK0SG8XcNJ/2kDH8g==}
|
||||
engines: {node: '>= 10.13.0'}
|
||||
peerDependencies:
|
||||
'@swc/core': '*'
|
||||
@@ -18918,7 +18918,7 @@ snapshots:
|
||||
mkdirp: 0.5.6
|
||||
rimraf: 2.6.3
|
||||
|
||||
terser-webpack-plugin@5.3.17(esbuild@0.25.9)(webpack@5.105.4(esbuild@0.25.9)):
|
||||
terser-webpack-plugin@5.4.0(esbuild@0.25.9)(webpack@5.105.4(esbuild@0.25.9)):
|
||||
dependencies:
|
||||
'@jridgewell/trace-mapping': 0.3.31
|
||||
jest-worker: 27.5.1
|
||||
@@ -19465,7 +19465,7 @@ snapshots:
|
||||
neo-async: 2.6.2
|
||||
schema-utils: 4.3.3
|
||||
tapable: 2.3.0
|
||||
terser-webpack-plugin: 5.3.17(esbuild@0.25.9)(webpack@5.105.4(esbuild@0.25.9))
|
||||
terser-webpack-plugin: 5.4.0(esbuild@0.25.9)(webpack@5.105.4(esbuild@0.25.9))
|
||||
watchpack: 2.5.1
|
||||
webpack-sources: 3.3.4
|
||||
transitivePeerDependencies:
|
||||
|
||||
@@ -11,9 +11,12 @@ import {
|
||||
autocompletion,
|
||||
completionKeymap,
|
||||
keymap,
|
||||
linter,
|
||||
lintGutter,
|
||||
lintKeymap,
|
||||
} from '@hashicorp/design-system-components/codemirror';
|
||||
|
||||
import { createGrantCompletionSource } from 'admin/utils/grant-completions';
|
||||
import { createGrantLinter } from 'admin/utils/grant-linter';
|
||||
|
||||
export default class FormRoleEditGrantsComponent extends Component {
|
||||
@service intl;
|
||||
@@ -41,10 +44,17 @@ export default class FormRoleEditGrantsComponent extends Component {
|
||||
),
|
||||
};
|
||||
|
||||
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] ?? '';
|
||||
@@ -57,7 +67,9 @@ export default class FormRoleEditGrantsComponent extends Component {
|
||||
// Trigger autocompletion when the user completes a grant field (which we labeled as keywords)
|
||||
activateOnCompletion: (completion) => completion.type === 'keyword',
|
||||
}),
|
||||
keymap.of(completionKeymap),
|
||||
linter(this.linterSource),
|
||||
lintGutter(),
|
||||
keymap.of([...completionKeymap, ...lintKeymap]),
|
||||
];
|
||||
|
||||
get grantStrings() {
|
||||
|
||||
@@ -0,0 +1,767 @@
|
||||
/**
|
||||
* Copyright IBM Corp. 2021, 2026
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
const GRANT_FIELDS = ['ids', 'type', 'actions', 'output_fields'];
|
||||
const ID_TEMPLATES = [
|
||||
'{{.Account.Id}}',
|
||||
'{{.User.Id}}',
|
||||
'{{user.id}}',
|
||||
'{{account.id}}',
|
||||
];
|
||||
|
||||
const createDeleteTextAction = (name) => ({
|
||||
name,
|
||||
apply(view, from, to) {
|
||||
view.dispatch({
|
||||
changes: { from, to },
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const createAddTextAction = (name) => ({
|
||||
name,
|
||||
apply(view, from) {
|
||||
view.dispatch({
|
||||
changes: { from, insert: 'no-op,' },
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const normalizeGrantsSchema = (grantsSchema) => {
|
||||
const resourceTypes = grantsSchema.resource_types ?? [];
|
||||
|
||||
const resourcesByType = resourceTypes.reduce((resources, resourceType) => {
|
||||
const collectionActions = resourceType.collection_actions ?? [];
|
||||
const idActions = resourceType.id_actions ?? [];
|
||||
const idPrefixes = resourceType.id_prefixes ?? [];
|
||||
|
||||
resources[resourceType.type] = {
|
||||
actions: [...collectionActions, ...idActions],
|
||||
collectionActions,
|
||||
idActions,
|
||||
idPrefixes,
|
||||
parentType: resourceType.parent_type,
|
||||
};
|
||||
|
||||
return resources;
|
||||
}, {});
|
||||
|
||||
const validResourceTypes = resourceTypes.map((resource) => resource.type);
|
||||
validResourceTypes.push('*');
|
||||
|
||||
const allActions = new Set();
|
||||
Object.values(resourcesByType).forEach((resource) => {
|
||||
resource.actions.forEach((action) => allActions.add(action));
|
||||
});
|
||||
const validActions = Array.from(allActions);
|
||||
validActions.push('*');
|
||||
validActions.push('no-op');
|
||||
|
||||
const resourcesByPrefix = resourceTypes.reduce((map, resource) => {
|
||||
resource.id_prefixes?.forEach((prefix) => {
|
||||
map[prefix] = resource.type;
|
||||
});
|
||||
return map;
|
||||
}, {});
|
||||
|
||||
let parentTypes = new Set(
|
||||
resourceTypes.filter((r) => r.parent_type).map((r) => r.parent_type),
|
||||
);
|
||||
parentTypes = Array.from(parentTypes);
|
||||
|
||||
const topLevelTypes = resourceTypes
|
||||
.filter((r) => {
|
||||
const hasCreateAction = r.collection_actions.includes('create');
|
||||
const hasListAction = r.collection_actions.includes('list');
|
||||
return !r.parent_type && (hasCreateAction || hasListAction);
|
||||
})
|
||||
.map((r) => r.type);
|
||||
|
||||
const childResourceTypesByParentType = resourceTypes.reduce(
|
||||
(childrenByParentType, resourceType) => {
|
||||
const parentType = resourceType.parent_type;
|
||||
|
||||
if (!parentType) {
|
||||
return childrenByParentType;
|
||||
}
|
||||
|
||||
childrenByParentType[parentType] = [
|
||||
...(childrenByParentType[parentType] ?? []),
|
||||
resourceType.type,
|
||||
];
|
||||
|
||||
return childrenByParentType;
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
return {
|
||||
resourcesByType,
|
||||
validResourceTypes,
|
||||
validActions,
|
||||
resourcesByPrefix,
|
||||
childResourceTypesByParentType,
|
||||
parentTypes,
|
||||
topLevelTypes,
|
||||
};
|
||||
};
|
||||
|
||||
const grantLinter = (context, schema, translate) => {
|
||||
const diagnostics = [];
|
||||
const doc = context.state.doc.toString();
|
||||
const lines = doc.split('\n');
|
||||
|
||||
let currentPos = 0;
|
||||
|
||||
lines.forEach((line) => {
|
||||
const trimmedLine = line.trim();
|
||||
const lineStart = currentPos;
|
||||
const lineEnd = currentPos + line.length;
|
||||
|
||||
// Check for whitespace
|
||||
const whitespaceMatches = line.matchAll(/\s+/g);
|
||||
for (const match of whitespaceMatches) {
|
||||
const spaceStart = lineStart + match.index;
|
||||
const spaceEnd = spaceStart + match[0].length;
|
||||
diagnostics.push({
|
||||
from: spaceStart,
|
||||
to: spaceEnd,
|
||||
severity: 'error',
|
||||
message: translate('general.whitespace-not-allowed'),
|
||||
actions: [createDeleteTextAction(translate('editor.remove'))],
|
||||
});
|
||||
}
|
||||
|
||||
const trimStart = line.indexOf(trimmedLine);
|
||||
// Check for leading semicolon
|
||||
if (trimmedLine.startsWith(';')) {
|
||||
diagnostics.push({
|
||||
from: lineStart,
|
||||
to: lineStart + trimStart + 1,
|
||||
severity: 'error',
|
||||
message: translate('general.leading-semicolon-not-allowed'),
|
||||
actions: [createDeleteTextAction(translate('editor.remove'))],
|
||||
});
|
||||
}
|
||||
|
||||
// Check for trailing semicolon
|
||||
if (trimmedLine.endsWith(';')) {
|
||||
const semicolonPos = lineStart + line.lastIndexOf(';');
|
||||
diagnostics.push({
|
||||
from: semicolonPos,
|
||||
to: semicolonPos + 1,
|
||||
severity: 'error',
|
||||
message: translate('general.trailing-semicolon-not-allowed'),
|
||||
actions: [createDeleteTextAction(translate('editor.remove'))],
|
||||
});
|
||||
}
|
||||
|
||||
// Check for invalid characters (only allow alphanumeric characters, equal signs, commas, semicolons, underscores, hyphens, curly braces, and periods)
|
||||
const invalidCharMatch = line.match(/[^a-zA-Z0-9=,;:_*\-.{}\s]/g) || [];
|
||||
for (const invalidChar of invalidCharMatch) {
|
||||
const invalidCharIndex = lineStart + line.indexOf(invalidChar);
|
||||
diagnostics.push({
|
||||
from: invalidCharIndex,
|
||||
to: invalidCharIndex + 1,
|
||||
severity: 'error',
|
||||
message: translate('general.invalid-character', {
|
||||
character: invalidChar,
|
||||
}),
|
||||
actions: [createDeleteTextAction(translate('editor.remove'))],
|
||||
});
|
||||
}
|
||||
|
||||
// Parse key-value pairs separated by semicolons
|
||||
const pairs = trimmedLine.split(';').filter((p) => p.trim());
|
||||
const parsedFields = {};
|
||||
const fieldPositions = {};
|
||||
const allFields = [];
|
||||
const fieldKeyCount = {};
|
||||
|
||||
let pairOffset = trimStart; // Start from where trimmed content begins
|
||||
|
||||
for (const pair of pairs) {
|
||||
const pairInLine = line.indexOf(pair, pairOffset - trimStart);
|
||||
const [key, ...valueParts] = pair.split('=');
|
||||
const value = valueParts.join('='); // Handle cases where value contains '='
|
||||
|
||||
if (!key || value === undefined) {
|
||||
// Invalid format - not a key=value pair
|
||||
const pairStart = lineStart + pairInLine;
|
||||
diagnostics.push({
|
||||
from: pairStart,
|
||||
to: pairStart + pair.length,
|
||||
severity: 'error',
|
||||
message: translate('general.invalid-format'),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const trimmedKey = key.trim();
|
||||
const keyStartInPair = pair.indexOf(trimmedKey);
|
||||
const valueStartInPair = pair.indexOf('=') + 1;
|
||||
|
||||
const pos = {
|
||||
keyStart: lineStart + pairInLine + keyStartInPair,
|
||||
valueStart: lineStart + pairInLine + valueStartInPair,
|
||||
valueEnd: lineStart + pairInLine + pair.length,
|
||||
};
|
||||
allFields.push({ key: trimmedKey, value: value.trim(), pos });
|
||||
fieldKeyCount[trimmedKey] = (fieldKeyCount[trimmedKey] ?? 0) + 1;
|
||||
|
||||
pairOffset = pairInLine + pair.length + 1; // +1 for semicolon
|
||||
}
|
||||
|
||||
// Validate there are no duplicate fields and store the last instance of each field for further validation
|
||||
for (const field of allFields) {
|
||||
parsedFields[field.key] = field.value;
|
||||
fieldPositions[field.key] = field.pos;
|
||||
if (fieldKeyCount[field.key] > 1) {
|
||||
diagnostics.push({
|
||||
from: field.pos.keyStart,
|
||||
to: field.pos.valueEnd,
|
||||
severity: 'error',
|
||||
message: translate('fields.duplicate-field', {
|
||||
field: field.key,
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Validate all fields are recognized
|
||||
for (const field of Object.keys(parsedFields)) {
|
||||
if (!GRANT_FIELDS.includes(field)) {
|
||||
const pos = fieldPositions[field];
|
||||
diagnostics.push({
|
||||
from: pos.keyStart,
|
||||
to: pos.valueEnd,
|
||||
severity: 'error',
|
||||
message: translate('fields.unknown-field', { field }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Validate field values are not empty
|
||||
for (const [field, value] of Object.entries(parsedFields)) {
|
||||
if (!value.trim()) {
|
||||
const pos = fieldPositions[field];
|
||||
diagnostics.push({
|
||||
from: pos.valueStart,
|
||||
to: pos.valueEnd,
|
||||
severity: 'error',
|
||||
message: translate('fields.empty-value', { field }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const validatedFields = {};
|
||||
|
||||
// Validate type field value is a valid resource type
|
||||
if (parsedFields.type) {
|
||||
validatedFields.type = { value: '', wildcard: false };
|
||||
const typeValue = parsedFields.type.trim();
|
||||
const pos = fieldPositions.type;
|
||||
if (!schema.validResourceTypes.includes(typeValue)) {
|
||||
diagnostics.push({
|
||||
from: pos.valueStart,
|
||||
to: pos.valueEnd,
|
||||
severity: 'error',
|
||||
message: translate('type.invalid-type', { type: typeValue }),
|
||||
});
|
||||
} else {
|
||||
validatedFields.type.value = typeValue;
|
||||
if (!parsedFields.ids) {
|
||||
if (!schema.topLevelTypes.includes(typeValue)) {
|
||||
diagnostics.push({
|
||||
from: pos.valueStart,
|
||||
to: pos.valueEnd,
|
||||
severity: 'error',
|
||||
message: translate('type.not-top-level', {
|
||||
type: typeValue,
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
validatedFields.type.wildcard = typeValue === '*';
|
||||
}
|
||||
|
||||
// Validate ids field
|
||||
if (parsedFields.ids) {
|
||||
validatedFields.ids = { value: [], knownType: '', wildcard: false };
|
||||
const pos = fieldPositions.ids;
|
||||
const idsList = validateListField(
|
||||
parsedFields.ids,
|
||||
pos,
|
||||
diagnostics,
|
||||
translate,
|
||||
);
|
||||
if (idsList.length === 0) {
|
||||
diagnostics.push({
|
||||
from: pos.valueStart,
|
||||
to: pos.valueEnd,
|
||||
severity: 'error',
|
||||
message: translate('ids.empty-list'),
|
||||
});
|
||||
} else if (idsList.includes('*')) {
|
||||
if (idsList.length > 1) {
|
||||
const wildCardIndex = pos.valueStart + parsedFields.ids.indexOf('*');
|
||||
diagnostics.push({
|
||||
from: wildCardIndex,
|
||||
to: wildCardIndex + 1, // Include wildcard and comma after it
|
||||
severity: 'error',
|
||||
message: translate('ids.wildcard-cannot-combine'),
|
||||
});
|
||||
} else {
|
||||
validatedFields.ids.wildcard = true;
|
||||
if (!parsedFields.type) {
|
||||
diagnostics.push({
|
||||
from: lineStart,
|
||||
to: lineEnd,
|
||||
severity: 'error',
|
||||
message: translate('type.missing-for-wildcard-ids'),
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const validIdType = validateIdsField(
|
||||
schema,
|
||||
diagnostics,
|
||||
idsList,
|
||||
validatedFields.type,
|
||||
pos,
|
||||
fieldPositions.type,
|
||||
translate,
|
||||
);
|
||||
if (validIdType) {
|
||||
validatedFields.ids.knownType = validIdType;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!validatedFields.ids) {
|
||||
if (!validatedFields.type) {
|
||||
diagnostics.push({
|
||||
from: lineStart,
|
||||
to: lineEnd,
|
||||
severity: 'error',
|
||||
message: translate('fields.missing-ids-or-type'),
|
||||
});
|
||||
} else if (validatedFields.type.wildcard) {
|
||||
diagnostics.push({
|
||||
from: lineStart,
|
||||
to: lineEnd,
|
||||
severity: 'error',
|
||||
message: translate('type.wildcard-requires-ids'),
|
||||
});
|
||||
} else {
|
||||
if (!parsedFields.actions && !parsedFields.output_fields) {
|
||||
diagnostics.push({
|
||||
from: lineStart,
|
||||
to: lineEnd,
|
||||
severity: 'error',
|
||||
message: translate('fields.missing-actions-or-output-fields'),
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (!parsedFields.actions && !parsedFields.output_fields) {
|
||||
diagnostics.push({
|
||||
from: lineStart,
|
||||
to: lineEnd,
|
||||
severity: 'error',
|
||||
message: translate('fields.missing-actions'),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Validate actions field
|
||||
if (parsedFields.actions) {
|
||||
const pos = fieldPositions.actions;
|
||||
const actionList = validateListField(
|
||||
parsedFields.actions,
|
||||
pos,
|
||||
diagnostics,
|
||||
translate,
|
||||
);
|
||||
if (actionList.length === 0) {
|
||||
diagnostics.push({
|
||||
from: pos.valueStart,
|
||||
to: pos.valueEnd,
|
||||
severity: 'error',
|
||||
message: translate('actions.empty-list'),
|
||||
});
|
||||
} else if (actionList.includes('*') && actionList.length > 1) {
|
||||
const wildcardIndex =
|
||||
pos.valueStart + parsedFields.actions.indexOf('*');
|
||||
diagnostics.push({
|
||||
from: wildcardIndex,
|
||||
to: wildcardIndex + 1,
|
||||
severity: 'error',
|
||||
message: translate('actions.wildcard-cannot-combine'),
|
||||
actions: [createDeleteTextAction(translate('editor.remove'))],
|
||||
});
|
||||
} else {
|
||||
// Validate individual actions against the resource type
|
||||
validateActionsField(
|
||||
schema,
|
||||
diagnostics,
|
||||
actionList,
|
||||
validatedFields,
|
||||
pos,
|
||||
translate,
|
||||
);
|
||||
}
|
||||
}
|
||||
currentPos += line.length + 1; // +1 for newline
|
||||
});
|
||||
|
||||
return diagnostics;
|
||||
};
|
||||
|
||||
const validateListField = (fieldValue, pos, diagnostics, translate) => {
|
||||
const unfilteredVals = fieldValue.split(',');
|
||||
const valList = [];
|
||||
let segmentStart = 0;
|
||||
const maxIndex = unfilteredVals.length - 1;
|
||||
for (const [index, val] of unfilteredVals.entries()) {
|
||||
const emptyVal = val.match(/^\s*$/);
|
||||
if (!emptyVal) {
|
||||
valList.push(val);
|
||||
}
|
||||
if (index < maxIndex) {
|
||||
const emptyNextVal = unfilteredVals[index + 1].match(/^\s*$/);
|
||||
if (emptyVal || emptyNextVal) {
|
||||
const commaPos = segmentStart + val.length;
|
||||
diagnostics.push({
|
||||
from: pos.valueStart + commaPos,
|
||||
to: pos.valueStart + commaPos + 1, // +1 for comma
|
||||
severity: 'error',
|
||||
message: translate('general.invalid-syntax'),
|
||||
actions: [createDeleteTextAction(translate('editor.remove-comma'))],
|
||||
});
|
||||
}
|
||||
}
|
||||
segmentStart += val.length + 1; // +1 for comma delimiter
|
||||
}
|
||||
|
||||
return valList;
|
||||
};
|
||||
|
||||
// Returns template, resource type, or null;
|
||||
const idType = (schema, id) => {
|
||||
if (id.startsWith('{{') && id.endsWith('}}')) {
|
||||
return 'template';
|
||||
}
|
||||
const prefix = id.split('_')[0];
|
||||
return schema.resourcesByPrefix[prefix];
|
||||
};
|
||||
|
||||
const getChildResourceActions = (schema, parentType) => {
|
||||
if (
|
||||
!parentType ||
|
||||
!schema.childResourceTypesByParentType[parentType]?.length
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const childTypes = schema.childResourceTypesByParentType[parentType] ?? [];
|
||||
|
||||
return [
|
||||
...new Set(
|
||||
childTypes.flatMap((type) => schema.resourcesByType[type]?.actions ?? []),
|
||||
),
|
||||
];
|
||||
};
|
||||
|
||||
const validateIdsField = (
|
||||
schema,
|
||||
diagnostics,
|
||||
idsList,
|
||||
validatedFieldsType = {},
|
||||
idsPos,
|
||||
typePos,
|
||||
translate,
|
||||
) => {
|
||||
if (
|
||||
idsList.includes('{{.Account.Id}}') &&
|
||||
idsList.includes('{{account.id}}')
|
||||
) {
|
||||
diagnostics.push({
|
||||
from: idsPos.valueStart,
|
||||
to: idsPos.valueEnd,
|
||||
severity: 'error',
|
||||
message: translate('ids.duplicate-template', {
|
||||
primary: '{{.Account.Id}}',
|
||||
secondary: '{{account.id}}',
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (idsList.includes('{{.User.Id}}') && idsList.includes('{{user.id}}')) {
|
||||
diagnostics.push({
|
||||
from: idsPos.valueStart,
|
||||
to: idsPos.valueEnd,
|
||||
severity: 'error',
|
||||
message: translate('ids.duplicate-template', {
|
||||
primary: '{{.User.Id}}',
|
||||
secondary: '{{user.id}}',
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
let segmentStart = idsPos.valueStart;
|
||||
let seenIdType;
|
||||
for (const [i, id] of idsList.entries()) {
|
||||
let segmentEnd = segmentStart + id.length;
|
||||
if (i === 0) {
|
||||
seenIdType = idType(schema, id);
|
||||
}
|
||||
if (i < idsList.length - 1) {
|
||||
segmentEnd += 1; // Include comma delimiter for all but last id
|
||||
}
|
||||
if (idsList.slice(i + 1).includes(id)) {
|
||||
diagnostics.push({
|
||||
from: segmentStart,
|
||||
to: segmentEnd,
|
||||
severity: 'error',
|
||||
message: translate('ids.duplicate-id', { id }),
|
||||
actions: [createDeleteTextAction(translate('editor.remove'))],
|
||||
});
|
||||
return;
|
||||
}
|
||||
const currentIdType = idType(schema, id);
|
||||
if (currentIdType === 'template') {
|
||||
if (!ID_TEMPLATES.includes(id)) {
|
||||
diagnostics.push({
|
||||
from: segmentStart,
|
||||
to: segmentEnd,
|
||||
severity: 'error',
|
||||
message: translate('ids.unknown-template', { id }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
if (!currentIdType) {
|
||||
diagnostics.push({
|
||||
from: segmentStart,
|
||||
to: segmentEnd,
|
||||
severity: 'error',
|
||||
message: translate('ids.invalid-id', { id }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (currentIdType !== seenIdType) {
|
||||
diagnostics.push({
|
||||
from: idsPos.valueStart,
|
||||
to: idsPos.valueEnd,
|
||||
severity: 'error',
|
||||
message: translate('ids.mismatched-types', { id }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (validatedFieldsType.wildcard) {
|
||||
// If type is wildcard, then pinned id(s) must support child types
|
||||
if (!schema.parentTypes.includes(currentIdType)) {
|
||||
diagnostics.push({
|
||||
from: idsPos.valueStart,
|
||||
to: idsPos.valueEnd,
|
||||
severity: 'error',
|
||||
message: translate('ids.must-support-child-types', { id }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
} else if (validatedFieldsType.value) {
|
||||
// If type is known and not wildcard, then pinned id(s) must match the type
|
||||
const parentType =
|
||||
schema.resourcesByType[validatedFieldsType.value]?.parentType;
|
||||
if (!parentType) {
|
||||
diagnostics.push({
|
||||
from: typePos.valueStart,
|
||||
to: typePos.valueEnd,
|
||||
severity: 'error',
|
||||
message: translate('type.not-child-type', {
|
||||
type: validatedFieldsType.value,
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (currentIdType !== parentType) {
|
||||
diagnostics.push({
|
||||
from: idsPos.valueStart,
|
||||
to: idsPos.valueEnd,
|
||||
severity: 'error',
|
||||
message: translate('ids.must-match-parent-type', {
|
||||
id,
|
||||
parentType,
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
segmentStart += id.length + 1; // +1 for comma
|
||||
}
|
||||
if (seenIdType === 'template') {
|
||||
if (idsList.length > 1) {
|
||||
seenIdType = 'template-multiple';
|
||||
} else if (
|
||||
idsList[0] === '{{.Account.Id}}' ||
|
||||
idsList[0] === '{{account.id}}'
|
||||
) {
|
||||
seenIdType = 'account-template';
|
||||
} else if (idsList[0] === '{{.User.Id}}' || idsList[0] === '{{user.id}}') {
|
||||
seenIdType = 'user-template';
|
||||
}
|
||||
}
|
||||
return seenIdType;
|
||||
};
|
||||
|
||||
const getIdActionsForResourceType = (schema, resourceType) => {
|
||||
switch (resourceType) {
|
||||
case 'template-multiple':
|
||||
return [
|
||||
'*',
|
||||
...schema.resourcesByType['account'].idActions,
|
||||
...schema.resourcesByType['user'].idActions,
|
||||
];
|
||||
case 'account-template':
|
||||
return ['*', ...schema.resourcesByType['account'].idActions];
|
||||
case 'user-template':
|
||||
return ['*', ...schema.resourcesByType['user'].idActions];
|
||||
default:
|
||||
return ['*', ...schema.resourcesByType[resourceType].idActions];
|
||||
}
|
||||
};
|
||||
|
||||
const validateActionsField = (
|
||||
schema,
|
||||
diagnostics,
|
||||
actionList,
|
||||
validatedFields,
|
||||
pos,
|
||||
translate,
|
||||
) => {
|
||||
let validActions = schema.validActions;
|
||||
let errorMessage = (action) =>
|
||||
translate('actions.invalid-action', { action });
|
||||
if (!validatedFields.type) {
|
||||
if (validatedFields.ids?.knownType) {
|
||||
// Only id actions belonging to known id type (including wildcard action)
|
||||
validActions = getIdActionsForResourceType(
|
||||
schema,
|
||||
validatedFields.ids.knownType,
|
||||
);
|
||||
errorMessage = (action) =>
|
||||
translate('actions.id-only', {
|
||||
type: validatedFields.ids.knownType,
|
||||
action,
|
||||
});
|
||||
}
|
||||
} else if (validatedFields.type.wildcard) {
|
||||
if (validatedFields.ids?.knownType) {
|
||||
// Any action from associated child resource types would be valid (including wildcard action)
|
||||
validActions = [
|
||||
'*',
|
||||
'no-op',
|
||||
...getChildResourceActions(schema, validatedFields.ids.knownType),
|
||||
];
|
||||
}
|
||||
} else if (validatedFields.type.value) {
|
||||
if (!validatedFields.ids) {
|
||||
// Only collection actions create/list belonging to the specified type
|
||||
validActions =
|
||||
schema.resourcesByType[validatedFields.type.value].collectionActions;
|
||||
validActions = validActions.filter(
|
||||
(action) => action.startsWith('create') || action.startsWith('list'),
|
||||
);
|
||||
errorMessage = (action) =>
|
||||
translate('actions.collection-only', { action });
|
||||
} else if (validatedFields.ids.knownType || validatedFields.ids.wildcard) {
|
||||
// Any action belonging to the specified type (including wildcard action)
|
||||
validActions = [
|
||||
'*',
|
||||
'no-op',
|
||||
...schema.resourcesByType[validatedFields.type.value].actions,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
let segmentStart = pos.valueStart;
|
||||
for (const [i, action] of actionList.entries()) {
|
||||
let segmentEnd = segmentStart + action.length;
|
||||
if (i < actionList.length - 1) {
|
||||
segmentEnd += 1; // Include comma delimiter for all but last action
|
||||
}
|
||||
if (actionList.slice(i + 1).includes(action)) {
|
||||
diagnostics.push({
|
||||
from: segmentStart,
|
||||
to: segmentEnd,
|
||||
severity: 'error',
|
||||
message: translate('actions.duplicate-action', { action }),
|
||||
actions: [createDeleteTextAction(translate('editor.remove'))],
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!validActions.includes(action)) {
|
||||
diagnostics.push({
|
||||
from: pos.valueStart,
|
||||
to: pos.valueEnd,
|
||||
severity: 'error',
|
||||
message: errorMessage(action),
|
||||
});
|
||||
return;
|
||||
}
|
||||
segmentStart += action.length + 1; // +1 for comma
|
||||
}
|
||||
|
||||
// no-op should be used with list action or else it has no effect.
|
||||
// Also, no-op is not necessary if there is already any other action specified.
|
||||
const noopAllowed = validActions.includes('no-op');
|
||||
const hasNoOp = actionList.includes('no-op');
|
||||
const hasList = actionList.includes('list');
|
||||
const hasOtherActions = actionList.some(
|
||||
(action) => action !== 'list' && action !== 'no-op',
|
||||
);
|
||||
if (noopAllowed) {
|
||||
if (hasNoOp) {
|
||||
if (!hasList) {
|
||||
diagnostics.push({
|
||||
from: pos.valueStart,
|
||||
to: pos.valueEnd,
|
||||
severity: 'error',
|
||||
message: translate('actions.noop-requires-list'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (hasOtherActions) {
|
||||
diagnostics.push({
|
||||
from: pos.valueStart,
|
||||
to: pos.valueEnd,
|
||||
severity: 'error',
|
||||
message: translate('actions.noop-unnecessary'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (hasList) {
|
||||
if (!hasNoOp && !hasOtherActions) {
|
||||
diagnostics.push({
|
||||
from: pos.valueStart,
|
||||
to: pos.valueEnd,
|
||||
severity: 'error',
|
||||
message: translate('actions.list-requires-noop'),
|
||||
actions: [createAddTextAction(translate('editor.add-noop'))],
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const createGrantLinter = (grantsSchema, translate) => {
|
||||
const schema = normalizeGrantsSchema(grantsSchema);
|
||||
return (context) => grantLinter(context, schema, translate);
|
||||
};
|
||||
@@ -4,7 +4,14 @@
|
||||
*/
|
||||
|
||||
import { module, test } from 'qunit';
|
||||
import { visit, click, currentURL, find, select, waitFor } from '@ember/test-helpers';
|
||||
import {
|
||||
visit,
|
||||
click,
|
||||
currentURL,
|
||||
find,
|
||||
select,
|
||||
waitFor,
|
||||
} from '@ember/test-helpers';
|
||||
import { setupApplicationTest } from 'admin/tests/helpers';
|
||||
import * as selectors from './selectors';
|
||||
import * as commonSelectors from 'admin/tests/helpers/selectors';
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
/**
|
||||
* Copyright IBM Corp. 2021, 2026
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import { module, test } from 'qunit';
|
||||
import { setupTest } from 'ember-qunit';
|
||||
import { setupMirage } from 'admin/tests/helpers/mirage';
|
||||
import { setupIntl } from 'ember-intl/test-support';
|
||||
import { createGrantLinter } from 'admin/utils/grant-linter';
|
||||
|
||||
const createContext = (lineText) => ({
|
||||
pos: lineText.length,
|
||||
state: {
|
||||
doc: {
|
||||
toString() {
|
||||
return lineText;
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
module('Unit | Utility | grant-linter', function (hooks) {
|
||||
setupTest(hooks);
|
||||
setupMirage(hooks);
|
||||
setupIntl(hooks, 'en-us');
|
||||
|
||||
hooks.beforeEach(async function () {
|
||||
const response = await fetch('/grants-schema.json');
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch grants schema: ${response.status}`);
|
||||
}
|
||||
this.intl = this.owner.lookup('service:intl');
|
||||
const grantsSchemaData = await response.json();
|
||||
const translateLintingError = (key, options = {}) =>
|
||||
this.intl.t(`resources.role.edit-grants.linting-errors.${key}`, options);
|
||||
this.grantLinter = createGrantLinter(
|
||||
grantsSchemaData,
|
||||
translateLintingError,
|
||||
);
|
||||
});
|
||||
|
||||
test.each(
|
||||
'diagnostics for syntax and formatting errors',
|
||||
[
|
||||
['ids=*; type=*; actions=*', 'Whitespace is not allowed'], // whitespace around semicolons
|
||||
['ids=*;type= *;actions=*', 'Whitespace is not allowed'], // whitespace around equal sign
|
||||
['ids=*;type=*;actions=read, write', 'Whitespace is not allowed'], // whitespace around commas
|
||||
['ids=*;type=*;actions=read,write;', 'Trailing semicolon is not allowed'], // trailing semicolon
|
||||
[';ids=*;type=*;actions=read,write', 'Leading semicolon is not allowed'], // leading semicolon
|
||||
['ids={{.Account.Id}};type=+;actions=read', 'Invalid character "+"'], // invalid character +
|
||||
['ids={{.Account.Id}};type=();actions=read', 'Invalid character "("'], // invalid characters ()
|
||||
['type=target', 'Missing "actions" or "output_fields" fields'], // missing actions/output_fields field
|
||||
['actions=read,write', 'Missing "ids" or "type" fields'], // missing type/ids field
|
||||
['ids=hc_123', 'Missing "actions" field'], // missing actions field
|
||||
[
|
||||
'type=credential-store;ids=;actions=read',
|
||||
'"ids" value cannot be empty',
|
||||
], // empty ids value
|
||||
['type=;actions=read', '"type" value cannot be empty'], // empty type field
|
||||
['ids=csst_1234;actions=', '"actions" value cannot be empty'], // empty actions value
|
||||
[
|
||||
'type=credential;ids=csst_1234;actions=read,write;ids=cs_5678',
|
||||
'Duplicate field "ids"',
|
||||
], // duplicate ids field
|
||||
[
|
||||
'type=credential;actions=read,write;type=host',
|
||||
'Duplicate field "type"',
|
||||
], // duplicate type field
|
||||
[
|
||||
'type=credential;ids=csst_1234;actions=read,write;actions=delete',
|
||||
'Duplicate field "actions"',
|
||||
], // duplicate actions field
|
||||
[
|
||||
'type=credential;ids=cs_1234;actions=read;random=name',
|
||||
'Unknown field "random"',
|
||||
], // unknown field "random"
|
||||
[
|
||||
'=credential;actions=;ids=cs_1234',
|
||||
'Invalid format: expected key=value',
|
||||
], // missing type key
|
||||
],
|
||||
function (assert, [grantString, errorMsg]) {
|
||||
const diagnostics = this.grantLinter(createContext(grantString));
|
||||
|
||||
assert.strictEqual(diagnostics[0].message, errorMsg);
|
||||
},
|
||||
);
|
||||
|
||||
test.each(
|
||||
'diagnostics for type field values',
|
||||
[
|
||||
['actions=read;type=random', 'Invalid resource type "random"'], // invalid resource type
|
||||
['actions=read;type=account', 'Type "account" is not a top-level type'], // invalid resource type
|
||||
[
|
||||
'type=credential,random;actions=read',
|
||||
'Invalid resource type "credential,random"',
|
||||
], // invalid resource type
|
||||
['ids=*;actions=read', 'Missing "type" field for wildcard ids'], // missing type field with wildcard id
|
||||
['type=credential-store;actions=create'], // valid grant string
|
||||
],
|
||||
function (assert, [grantString, errorMsg]) {
|
||||
const diagnostics = this.grantLinter(createContext(grantString));
|
||||
|
||||
if (errorMsg) {
|
||||
assert.strictEqual(diagnostics[0].message, errorMsg);
|
||||
} else {
|
||||
assert.strictEqual(diagnostics.length, 0);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test.each(
|
||||
'diagnostics for actions field values',
|
||||
[
|
||||
['type=credential-store;actions=,', 'Invalid syntax'],
|
||||
[
|
||||
'type=session;actions=read,*,write',
|
||||
'Wildcard action "*" cannot be combined with other actions',
|
||||
], // wildcard action cannot be combined with other actions
|
||||
[
|
||||
'type=policy;actions=execute',
|
||||
'Only collection actions are allowed. Invalid action "execute"',
|
||||
], // invalid action for resource type
|
||||
[
|
||||
'type=target;actions=list,no-op',
|
||||
'Only collection actions are allowed. Invalid action "no-op"',
|
||||
], // invalid action when type is not specified
|
||||
['ids=ampw_1234;type=*;actions=read,read', 'Duplicate action "read"'], // duplicate action
|
||||
[
|
||||
'ids=cs_1234;actions=create',
|
||||
'Only id actions for "credential-store" type are allowed. Invalid action "create"',
|
||||
], // invalid action for specific resource
|
||||
[
|
||||
'ids=*;type=auth-method;actions=no-op',
|
||||
'"no-op" action should only be used with "list" action',
|
||||
],
|
||||
[
|
||||
'ids=ampw_098;type=account;actions=list',
|
||||
'"list" action should be used with "no-op" action to have an effect',
|
||||
],
|
||||
[
|
||||
'ids=*;type=account;actions=list',
|
||||
'"list" action should be used with "no-op" action to have an effect',
|
||||
],
|
||||
[
|
||||
'ids=ampw_098;type=account;actions=list,read,no-op',
|
||||
'"no-op" action is unnecessary when other actions are specified',
|
||||
],
|
||||
['ids=hc_1234;type=*;actions=add-hosts,read'], // valid actions for resource type
|
||||
['type=worker;actions=create:controller-led'], // valid actions for resource type
|
||||
['type=auth-method;actions=list'], // valid actions for resource type
|
||||
],
|
||||
function (assert, [grantString, errorMsg]) {
|
||||
const diagnostics = this.grantLinter(createContext(grantString));
|
||||
|
||||
if (errorMsg) {
|
||||
assert.strictEqual(diagnostics[0].message, errorMsg);
|
||||
} else {
|
||||
assert.strictEqual(diagnostics.length, 0);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test.each(
|
||||
'diagnostics for ids field values',
|
||||
[
|
||||
['type=credential;ids=,,,;actions=read', 'Invalid syntax'],
|
||||
['ids=cs_1234,cs_1234;actions=read', 'Duplicate id "cs_1234"'], // duplicate ids
|
||||
[
|
||||
'ids={{.Account.Id}},{{.Account.Id}};actions=read',
|
||||
'Duplicate id "{{.Account.Id}}"',
|
||||
], // duplicate template ids
|
||||
[
|
||||
'ids={{.Account.Id}},{{account.id}};actions=read',
|
||||
'Duplicate id "{{.Account.Id}}" and "{{account.id}}"',
|
||||
], // duplicate template ids
|
||||
[
|
||||
'ids={{.User.Id}},{{user.id}};actions=read',
|
||||
'Duplicate id "{{.User.Id}}" and "{{user.id}}"',
|
||||
], // duplicate template ids
|
||||
[
|
||||
'actions=read;type=credential;ids=cs_1234,*,cs_5678',
|
||||
'Wildcard id "*" cannot be combined with other ids',
|
||||
], // wildcard id cannot be combined with other ids
|
||||
[
|
||||
'ids={{.Account.Id}};type=credential;actions=read',
|
||||
'Pinned IDs must be "credential-store" ids. Invalid id "{{.Account.Id}}"',
|
||||
], // template ids not valid with type specified
|
||||
[
|
||||
'ids=hsst_1234;type=*;actions=read',
|
||||
'Pinned IDs must support child types. Invalid id "hsst_1234"',
|
||||
], // template ids not valid with type specified
|
||||
['ids={{.Random.Id}};actions=read', 'Unknown template "{{.Random.Id}}"'], // invalid template id
|
||||
[
|
||||
'type=host-catalog;ids=hcst_1234;actions=list,delete',
|
||||
'Type "host-catalog" is not a child type of the pinned ID',
|
||||
], // only non-top level resource types can be pinned by id
|
||||
[
|
||||
'type=host-set;ids=hcst_1234,g_1234;actions=read',
|
||||
'Ids do not have the same type. Invalid id "g_1234"',
|
||||
], // all ids must be valid for the resource type
|
||||
[
|
||||
'type=credential;ids=csst_1234,random;actions=delete',
|
||||
'Invalid id "random"',
|
||||
], // all ids must be valid for the resource type
|
||||
[
|
||||
'ids=cs_1234,g_1234;actions=read',
|
||||
'Ids do not have the same type. Invalid id "g_1234"',
|
||||
], // all ids must be same type when type is not specified
|
||||
['ids=none_1234,csvlt_123;actions=list', 'Invalid id "none_1234"'], // valid ids prefixes only
|
||||
[
|
||||
'ids=g_123,amoidc_456;type=*;actions=read,list',
|
||||
'Pinned IDs must support child types. Invalid id "g_123"',
|
||||
], // all ids must support child types when type is wildcard
|
||||
[
|
||||
'ids=amoidc_456,hcst_123;type=*;actions=read,list',
|
||||
'Ids do not have the same type. Invalid id "hcst_123"',
|
||||
], // all ids must be the same type when type is wildcard
|
||||
['ids=cs_1234,csst_5678;actions=read'], // valid ids when they are the same type and type is not specified
|
||||
['ids=*;type=*;actions=read'], // valid wildcard id
|
||||
['ids=hcst_123,hcplg_456;type=host-set;actions=read'], // valid pinned ids for host-set resource type
|
||||
['ids={{.Account.Id}};actions=read'], // valid template id when type is not specified
|
||||
['ids={{.Account.Id}},{{.User.Id}};actions=read'], // valid template ids when type is not specified
|
||||
['ids=csst_1234,csvlt_5678;actions=*'], // valid id prefixes with same type
|
||||
['ids=amldap_123,amoidc_456;type=*;actions=read,list'], // valid ids with wildcard type
|
||||
],
|
||||
function (assert, [grantString, errorMsg]) {
|
||||
const diagnostics = this.grantLinter(createContext(grantString));
|
||||
|
||||
if (errorMsg) {
|
||||
assert.strictEqual(diagnostics[0].message, errorMsg);
|
||||
} else {
|
||||
assert.strictEqual(diagnostics.length, 0);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user