Merge branch 'master' into gov-234-export-only-workflow-node-and-agent-spans-by-default

This commit is contained in:
Olivier 2026-10-16 19:06:28 +02:00 committed by GitHub
commit c2deaaa0ea
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
60 changed files with 2458 additions and 483 deletions

View File

@ -14,11 +14,15 @@ import { publicApiPaginationSchema } from '../pagination/pagination.dto';
* `PutCredentialTypeInstancePolicyDto`, …), so validation is identical on both surfaces.
*/
/** A rule that never matches because an earlier rule in the same list already covers it. */
const shadowWarningSchema = z.object({
ruleId: z.string(),
shadowedByRuleId: z.string(),
});
/**
* A saved rule worth a second look: `shadowed` when an earlier rule in the same list already
* covers it, so it never matches. `notLoaded` when it names a package or type this instance
* has not loaded, so nothing here uses it yet. `NODES_EXCLUDE` and `NODES_INCLUDE` count too.
*/
const ruleWarningSchema = z.discriminatedUnion('kind', [
z.object({ kind: z.literal('shadowed'), ruleId: z.string(), shadowedByRuleId: z.string() }),
z.object({ kind: z.literal('notLoaded'), ruleId: z.string() }),
]);
/** Plain rule arrays on purpose: a response never re-runs the duplicate-id refinement. */
function publicSchemasFor<Rule extends z.ZodTypeAny>(rule: Rule) {
@ -52,9 +56,9 @@ function publicSchemasFor<Rule extends z.ZodTypeAny>(rule: Rule) {
return {
effective: effectivePolicy.shape,
effectiveWriteResult: { ...effectivePolicy.shape, warnings: z.array(shadowWarningSchema) },
effectiveWriteResult: { ...effectivePolicy.shape, warnings: z.array(ruleWarningSchema) },
document: policyDocument.shape,
documentWriteResult: { policy: policyDocument, warnings: z.array(shadowWarningSchema) },
documentWriteResult: { policy: policyDocument, warnings: z.array(ruleWarningSchema) },
documentList: { data: z.array(policyDocument), nextCursor: z.string().nullable() },
// The scope's version after a replace, which always bumps it.
attachments: { attachments: z.array(attachment), version: z.number().int().positive() },
@ -67,13 +71,13 @@ const credentialType = publicSchemasFor(credentialTypePolicyRuleSchemas.rule);
/** `GET` of a scope's composed node type policy. */
export class PolicyEffectivePublicDto extends Z.class(nodeType.effective) {}
/** `PUT` of a scope's composed node type policy: the new state plus shadowing warnings. */
/** `PUT` of a scope's composed node type policy: the new state plus rule warnings. */
export class PolicyEffectiveWriteResultPublicDto extends Z.class(nodeType.effectiveWriteResult) {}
/** One reusable node type policy document. */
export class PolicyDocumentPublicDto extends Z.class(nodeType.document) {}
/** Create or update of a node type policy document, plus shadowing warnings. */
/** Create or update of a node type policy document, plus rule warnings. */
export class PolicyDocumentWriteResultPublicDto extends Z.class(nodeType.documentWriteResult) {}
export class PolicyDocumentListPublicDto extends Z.class(nodeType.documentList) {}

View File

@ -77,6 +77,7 @@ export default [
| [node-connection-type-literal](docs/rules/node-connection-type-literal.md) | Disallow string literals in node description `inputs`/`outputs` — use `NodeConnectionTypes` enum instead | ✅ ☑️ | | 🔧 | | |
| [node-registration-complete](docs/rules/node-registration-complete.md) | Ensure every `.node.ts` file in the `nodes/` directory is registered in the "n8n.nodes" array of package.json | | ✅ ☑️ | | | |
| [node-usable-as-tool](docs/rules/node-usable-as-tool.md) | Ensure node classes have usableAsTool property | ✅ ☑️ | | 🔧 | | |
| [open-ended-node-fields](docs/rules/open-ended-node-fields.md) | Discourage optional open-ended JSON fields in node properties | | ✅ ☑️ | | | |
| [package-name-convention](docs/rules/package-name-convention.md) | Enforce correct package naming convention for n8n community nodes | ✅ ☑️ | | | 💡 | |
| [require-files-array](docs/rules/require-files-array.md) | Require a non-empty "files" array in package.json | | ✅ ☑️ | | | |
| [require-homepage](docs/rules/require-homepage.md) | Require a "homepage" field with a valid URL in package.json | | ✅ ☑️ | | | |

View File

@ -0,0 +1,35 @@
# Discourage optional open-ended JSON fields in node properties (`@n8n/community-nodes/open-ended-node-fields`)
⚠️ This rule _warns_ in the following configs: ✅ `recommended`, ☑️ `recommendedWithoutN8nCloudSupport`.
<!-- end auto-generated rule header -->
## Rule Details
This rule warns about optional top-level JSON properties in node descriptions. A JSON configuration field can hide parameters from the editor. Typed parameters make configuration easier to find, validate, and change.
Place optional parameters under Additional Options or Additional Fields. The rule does not report required JSON properties or typed collection options.
## Examples
### Incorrect
```typescript
properties: [
{ displayName: 'Configuration', name: 'configuration', type: 'json', default: '{}' },
];
```
### Correct
```typescript
properties: [
{
displayName: 'Additional Fields',
name: 'additionalFields',
type: 'collection',
default: {},
options: [{ displayName: 'Limit', name: 'limit', type: 'number', default: 10 }],
},
];
```

View File

@ -29,6 +29,7 @@ const configs = {
'@n8n/community-nodes/no-deprecated-workflow-functions': 'error',
'@n8n/community-nodes/no-emoji-in-options': 'error',
'@n8n/community-nodes/node-usable-as-tool': 'error',
'@n8n/community-nodes/open-ended-node-fields': 'warn',
'@n8n/community-nodes/package-name-convention': 'error',
'@n8n/community-nodes/credential-test-required': 'error',
'@n8n/community-nodes/no-credential-reuse': 'error',
@ -79,6 +80,7 @@ const configs = {
'@n8n/community-nodes/no-deprecated-workflow-functions': 'error',
'@n8n/community-nodes/no-emoji-in-options': 'error',
'@n8n/community-nodes/node-usable-as-tool': 'error',
'@n8n/community-nodes/open-ended-node-fields': 'warn',
'@n8n/community-nodes/package-name-convention': 'error',
'@n8n/community-nodes/credential-test-required': 'error',
'@n8n/community-nodes/no-credential-reuse': 'error',

View File

@ -31,6 +31,7 @@ import { NodeClassDescriptionIconMissingRule } from './node-class-description-ic
import { NodeConnectionTypeLiteralRule } from './node-connection-type-literal.js';
import { NodeRegistrationCompleteRule } from './node-registration-complete.js';
import { NodeUsableAsToolRule } from './node-usable-as-tool.js';
import { OpenEndedNodeFieldsRule } from './open-ended-node-fields.js';
import { PackageNameConventionRule } from './package-name-convention.js';
import { RequireFilesArrayRule } from './require-files-array.js';
import { RequireHomepageRule } from './require-homepage.js';
@ -54,6 +55,7 @@ export const rules = {
'no-deprecated-workflow-functions': NoDeprecatedWorkflowFunctionsRule,
'no-emoji-in-options': NoEmojiInOptionsRule,
'node-usable-as-tool': NodeUsableAsToolRule,
'open-ended-node-fields': OpenEndedNodeFieldsRule,
'package-name-convention': PackageNameConventionRule,
'credential-test-required': CredentialTestRequiredRule,
'credential-unnecessary-password': CredentialUnnecessaryPasswordRule,

View File

@ -0,0 +1,122 @@
import { Linter } from 'eslint';
import { describe, expect, it } from 'vitest';
import { configs } from '../plugin.js';
const linter = new Linter();
function createNodeCode(property: string): string {
return `
export class TestNode extends Node {
description = {
displayName: 'Test Node',
name: 'testNode',
group: ['transform'],
version: 1,
inputs: ['main'],
outputs: ['main'],
properties: [${property}],
};
}`;
}
function lintNode(code: string, config: keyof typeof configs) {
return linter.verify(code, [{ ...configs[config], files: ['**/*.node.ts'] }], {
filename: 'src/rules/TestNode.node.ts',
});
}
describe.each(['recommended', 'recommendedWithoutN8nCloudSupport'] as const)(
'%s node property review',
(config) => {
// CE-1879: Warn when an optional JSON field replaces typed node parameters.
it('warns about an optional JSON configuration catch-all', () => {
const code = createNodeCode(`{
displayName: 'Configuration',
name: 'configuration',
type: 'json',
required: false,
default: '{}',
}`);
const warnings = lintNode(code, config).filter(
({ severity, message }) => severity === 1 && /Additional (Options|Fields)/i.test(message),
);
expect(warnings).toEqual([
expect.objectContaining({
severity: 1,
message: expect.stringMatching(/Additional (Options|Fields)/i),
}),
]);
});
it('does not warn about typed parameters inside Additional Fields', () => {
const code = createNodeCode(`{
displayName: 'Additional Fields',
name: 'additionalFields',
type: 'collection',
default: {},
options: [{ displayName: 'Limit', name: 'limit', type: 'number', default: 10 }],
}`);
const messages = lintNode(code, config);
expect(messages).toEqual(
expect.arrayContaining([
expect.objectContaining({
ruleId: '@n8n/community-nodes/require-node-description-fields',
}),
]),
);
const warnings = messages.filter(
({ severity, message }) => severity === 1 && /Additional (Options|Fields)/i.test(message),
);
expect(warnings).toEqual([]);
});
it('warns when an open-ended JSON field does not declare required', () => {
const code = createNodeCode(`{
displayName: 'Configuration',
name: 'configuration',
type: 'json',
default: '{}',
}`);
expect(
lintNode(code, config).filter(
({ ruleId }) => ruleId === '@n8n/community-nodes/open-ended-node-fields',
),
).toHaveLength(1);
});
it('does not warn about required JSON fields', () => {
const code = createNodeCode(`{
displayName: 'Request Body',
name: 'body',
type: 'json',
required: true,
default: '{}',
}`);
expect(
lintNode(code, config).filter(
({ ruleId }) => ruleId === '@n8n/community-nodes/open-ended-node-fields',
),
).toEqual([]);
});
it('does not inspect classes that are not nodes', () => {
const code = `class Helper {
description = { properties: [{ type: 'json', required: false }] };
}`;
expect(
lintNode(code, config).filter(
({ ruleId }) => ruleId === '@n8n/community-nodes/open-ended-node-fields',
),
).toEqual([]);
});
},
);

View File

@ -0,0 +1,55 @@
import { AST_NODE_TYPES } from '@typescript-eslint/utils';
import {
createRule,
findArrayLiteralProperty,
findNodeDescriptionObject,
findObjectProperty,
getBooleanLiteralValue,
getStringLiteralValue,
isFileType,
isNodeTypeClass,
} from '../utils/index.js';
export const OpenEndedNodeFieldsRule = createRule({
name: 'open-ended-node-fields',
meta: {
type: 'suggestion',
docs: {
description: 'Discourage optional open-ended JSON fields in node properties',
},
messages: {
optionalJson:
'Optional JSON fields can hide configuration. Use typed parameters under Additional Options or Additional Fields instead.',
},
schema: [],
},
defaultOptions: [],
create(context) {
if (!isFileType(context.filename, '.node.ts')) return {};
return {
ClassDeclaration(node) {
if (!isNodeTypeClass(node)) return;
const description = findNodeDescriptionObject(node);
if (!description) return;
const properties = findArrayLiteralProperty(description, 'properties');
if (!properties) return;
for (const property of properties.elements) {
if (property?.type !== AST_NODE_TYPES.ObjectExpression) continue;
const type = findObjectProperty(property, 'type');
if (getStringLiteralValue(type?.value ?? null) !== 'json') continue;
const required = findObjectProperty(property, 'required');
if (getBooleanLiteralValue(required?.value ?? null) === true) continue;
context.report({ node: property, messageId: 'optionalJson' });
}
},
};
},
});

View File

@ -90,6 +90,9 @@ export const TYPE_AVAILABILITY_POLICIES_TELEMETRY = defineTelemetryEvents({
shadow_warning_count: z
.number()
.describe('Saved rules that can never match, because an earlier rule already covers them'),
not_loaded_warning_count: z
.number()
.describe('Saved rules that name a package or type this instance has not loaded'),
version: z.number().describe('The scope version after the write'),
}),
},

View File

@ -758,7 +758,8 @@ describe('TelemetryEventRelay', () => {
after: { defaultAction: 'allow', version: 1 },
rulesBefore: null,
rulesAfter: [denyRule],
warningCount: 0,
shadowWarningCount: 0,
notLoadedWarningCount: 0,
});
expect(telemetry.track).toHaveBeenCalledWith(
@ -780,7 +781,8 @@ describe('TelemetryEventRelay', () => {
after: { defaultAction: 'deny', version: 1 },
rulesBefore: null,
rulesAfter: [denyRule, allowPackageRule, delegateRule],
warningCount: 2,
shadowWarningCount: 2,
notLoadedWarningCount: 1,
};
eventService.emit('node-type-policy-saved', event);
@ -810,6 +812,7 @@ describe('TelemetryEventRelay', () => {
allowed_types: ['n8n-nodes-base.code'],
previous_rule_count: null,
shadow_warning_count: 2,
not_loaded_warning_count: 1,
version: 1,
},
);
@ -831,7 +834,8 @@ describe('TelemetryEventRelay', () => {
after: { defaultAction: 'allow', version: 1 },
rulesBefore: null,
rulesAfter: [credentialDenyRule],
warningCount: 0,
shadowWarningCount: 0,
notLoadedWarningCount: 0,
};
eventService.emit('node-type-policy-saved', event);
@ -871,7 +875,8 @@ describe('TelemetryEventRelay', () => {
rulesAfter: [
{ id: 'rule-1', action: 'deny', selector: { kind: 'extends', value: 'oAuth2Api' } },
],
warningCount: 0,
shadowWarningCount: 0,
notLoadedWarningCount: 0,
});
expect(telemetry.track).toHaveBeenCalledWith(
@ -900,7 +905,8 @@ describe('TelemetryEventRelay', () => {
after: { defaultAction: 'allow', version: 1 },
rulesBefore: null,
rulesAfter: [credentialPackageDenyRule],
warningCount: 0,
shadowWarningCount: 0,
notLoadedWarningCount: 0,
};
eventService.emit('node-type-policy-saved', event);
@ -927,7 +933,8 @@ describe('TelemetryEventRelay', () => {
after: { defaultAction: 'deny', version: 1 },
rulesBefore: null,
rulesAfter: [],
warningCount: 0,
shadowWarningCount: 0,
notLoadedWarningCount: 0,
};
eventService.emit('node-type-policy-saved', event);
@ -945,7 +952,8 @@ describe('TelemetryEventRelay', () => {
after: { defaultAction: 'deny', version: 5 },
rulesBefore: [denyRule],
rulesAfter: [allowPackageRule],
warningCount: 0,
shadowWarningCount: 0,
notLoadedWarningCount: 0,
};
eventService.emit('node-type-policy-saved', event);
@ -975,7 +983,8 @@ describe('TelemetryEventRelay', () => {
after: { defaultAction: 'deny', version: 1 },
rulesBefore: null,
rulesAfter: [],
warningCount: 0,
shadowWarningCount: 0,
notLoadedWarningCount: 0,
};
eventService.emit('node-type-policy-saved', event);
@ -1097,7 +1106,8 @@ describe('TelemetryEventRelay', () => {
after: { defaultAction: 'allow', version: 1 },
rulesBefore: null,
rulesAfter: [denyRule],
warningCount: 0,
shadowWarningCount: 0,
notLoadedWarningCount: 0,
});
expect(telemetry.track).toHaveBeenCalledWith(
@ -1121,7 +1131,8 @@ describe('TelemetryEventRelay', () => {
after: { defaultAction: 'allow', version: 1 },
rulesBefore: null,
rulesAfter: [{ ...allowPackageRule, action: 'deny' }],
warningCount: 0,
shadowWarningCount: 0,
notLoadedWarningCount: 0,
});
expect(telemetry.track).toHaveBeenCalledWith(
@ -1151,7 +1162,8 @@ describe('TelemetryEventRelay', () => {
action: 'allow' as const,
selector: { kind: 'name' as const, value: name },
})),
warningCount: 0,
shadowWarningCount: 0,
notLoadedWarningCount: 0,
});
const properties = vi.mocked(telemetry.track).mock.calls.at(-1)?.[1];
@ -1174,7 +1186,8 @@ describe('TelemetryEventRelay', () => {
after: { defaultAction: 'deny', version: 1 },
rulesBefore: null,
rulesAfter: [],
warningCount: 0,
shadowWarningCount: 0,
notLoadedWarningCount: 0,
});
expect(telemetry.track).toHaveBeenCalledWith(

View File

@ -1450,7 +1450,8 @@ export type RelayEventMap = {
after: { defaultAction: PolicyAction; version: number };
rulesBefore: readonly PolicyRule[] | null;
rulesAfter: readonly PolicyRule[];
warningCount: number;
shadowWarningCount: number;
notLoadedWarningCount: number;
};
'node-type-policy-attachments-updated': {

View File

@ -595,7 +595,8 @@ export class TelemetryEventRelay extends EventRelay {
after,
rulesBefore,
rulesAfter,
warningCount,
shadowWarningCount,
notLoadedWarningCount,
}: RelayEventMap['node-type-policy-saved']) {
if (!isPolicyKind(kind)) return;
@ -619,7 +620,8 @@ export class TelemetryEventRelay extends EventRelay {
...countSelectorKinds(rulesAfter),
...summarizeTypeAvailability(rulesAfter, after.defaultAction, types, resolvePackage),
previous_rule_count: rulesBefore?.length ?? null,
shadow_warning_count: warningCount,
shadow_warning_count: shadowWarningCount,
not_loaded_warning_count: notLoadedWarningCount,
version: after.version,
},
);

View File

@ -220,7 +220,15 @@ covers `googleOAuth2Api` and `googleSheetsOAuth2Api`. A `name` rule stays exact,
`name oAuth2Api` blocks only the generic OAuth2 credential. Rule order decides as usual: an
`allow name googleSheetsOAuth2Api` placed before `deny extends oAuth2Api` keeps Sheets usable,
and placed after it, the shadow lint warns that it can never match. A write rejects an `extends`
rule in a node type policy, and one that names a credential type that is not installed.
rule in a node type policy.
A rule can name a package or type that this instance has not loaded. The write saves it and
returns a `notLoaded` warning for it, next to the `shadowed` warnings. This applies to all
three selectors. Thus, when you uninstall a package, its rules do not block later writes to the
policy. An installed node that `NODES_EXCLUDE` or `NODES_INCLUDE` filters out is not loaded
either. The policy still enforces its rule, because evaluation matches type names, but a policy
cannot make a filtered-out node available. One exception: a write rejects a `package` rule on `n8n-creds-base`, because no package
can ever provide it.
### Reading it on the execution path
@ -324,6 +332,7 @@ through a sealed repository method, and the lint rule that guards that has no al
| `credential-type-policy.check.ts` | Credential types: all eight points, including `credentialSave` |
| `policy-evaluator.ts` | Pure evaluation: first match per scope, then the instance ∩ project composition |
| `policy-shadow-lint.ts` | Warns at write time about rules an earlier rule already covers |
| `policy-rule-lint.ts` | Adds `notLoaded` warnings and merges all write-time warnings in rule order |
| `package-resolver.ts` | Resolves a type's package per `kind`, for the `package` selector |
| `type-availability-policy.service.ts` | Reads and writes the store, with versioning and row locks |
| `type-availability-policy-instance.controller.ts` | Instance scope, documents and attachments, `node-types` |

View File

@ -3,6 +3,7 @@ import type { NodeTypes } from '@/node-types';
import { CREDENTIAL_TYPES_KIND, NODE_TYPES_KIND } from '../constants';
import {
loadedCheckFor,
isCredentialTypeKnown,
isPackageInstalled,
packageResolverFor,
@ -168,3 +169,122 @@ describe('isCredentialTypeKnown', () => {
expect(isCredentialTypeKnown(registry, 'constructor')).toBe(false);
});
});
describe('loadedCheckFor', () => {
/** Stands in for `NodeTypes.resolveBaseName`: `slackTool` is a synthetic variant of `slack`. */
const nodeTypes = {
resolveBaseName: (name: string) => ({
baseName: name === 'n8n-nodes-base.slackTool' ? 'n8n-nodes-base.slack' : name,
isSyntheticTool: name === 'n8n-nodes-base.slackTool',
}),
} as unknown as NodeTypes;
/** A stand-in for `LoadNodesAndCredentials`, built from just the fields this check reads. */
function makeFullRegistry(overrides: {
loaders?: LoadNodesAndCredentials['loaders'];
knownNodes?: LoadNodesAndCredentials['knownNodes'];
knownCredentials?: LoadNodesAndCredentials['knownCredentials'];
}): LoadNodesAndCredentials {
return {
loaders: overrides.loaders ?? {},
knownNodes: overrides.knownNodes ?? {},
knownCredentials: overrides.knownCredentials ?? {},
} as unknown as LoadNodesAndCredentials;
}
describe('package selector', () => {
it('is true for a package that has a registered loader', () => {
const registry = makeFullRegistry({
loaders: { 'n8n-nodes-base': {} as LoadNodesAndCredentials['loaders'][string] },
});
const isLoaded = loadedCheckFor(NODE_TYPES_KIND, nodeTypes, registry);
expect(isLoaded({ kind: 'package', value: 'n8n-nodes-base' })).toBe(true);
});
it('is false for a package with no registered loader', () => {
const isLoaded = loadedCheckFor(NODE_TYPES_KIND, nodeTypes, makeFullRegistry({}));
expect(isLoaded({ kind: 'package', value: 'n8n-nodes-not-installed' })).toBe(false);
});
it('is false for a package named after an inherited Object.prototype property', () => {
const isLoaded = loadedCheckFor(NODE_TYPES_KIND, nodeTypes, makeFullRegistry({}));
expect(isLoaded({ kind: 'package', value: 'constructor' })).toBe(false);
});
});
describe('extends selector', () => {
it('is true for a known credential type, checked the same way for either kind', () => {
const registry = makeFullRegistry({
knownCredentials: { oAuth2Api: { className: '', sourcePath: '' } },
});
const selector = { kind: 'extends', value: 'oAuth2Api' } as const;
expect(loadedCheckFor(NODE_TYPES_KIND, nodeTypes, registry)(selector)).toBe(true);
expect(loadedCheckFor(CREDENTIAL_TYPES_KIND, {} as NodeTypes, registry)(selector)).toBe(true);
});
it('is false for an unknown credential type', () => {
const isLoaded = loadedCheckFor(CREDENTIAL_TYPES_KIND, {} as NodeTypes, makeFullRegistry({}));
expect(isLoaded({ kind: 'extends', value: 'unknownApi' })).toBe(false);
});
it('is false for a value named after an inherited Object.prototype property', () => {
const isLoaded = loadedCheckFor(CREDENTIAL_TYPES_KIND, {} as NodeTypes, makeFullRegistry({}));
expect(isLoaded({ kind: 'extends', value: 'constructor' })).toBe(false);
});
});
describe('name selector, node-types kind', () => {
it('is true when the resolved base name is a known node', () => {
const registry = makeFullRegistry({
knownNodes: { 'n8n-nodes-base.slack': { className: '', sourcePath: '' } },
});
const isLoaded = loadedCheckFor(NODE_TYPES_KIND, nodeTypes, registry);
expect(isLoaded({ kind: 'name', value: 'n8n-nodes-base.slack' })).toBe(true);
});
it('is false when the resolved base name is not a known node', () => {
const isLoaded = loadedCheckFor(NODE_TYPES_KIND, nodeTypes, makeFullRegistry({}));
expect(isLoaded({ kind: 'name', value: 'n8n-nodes-base.slack' })).toBe(false);
});
it('treats a synthetic tool variant as installed when its base node is known', () => {
const registry = makeFullRegistry({
knownNodes: { 'n8n-nodes-base.slack': { className: '', sourcePath: '' } },
});
const isLoaded = loadedCheckFor(NODE_TYPES_KIND, nodeTypes, registry);
expect(isLoaded({ kind: 'name', value: 'n8n-nodes-base.slackTool' })).toBe(true);
});
it('is false for a name resolving to an inherited Object.prototype property', () => {
const isLoaded = loadedCheckFor(NODE_TYPES_KIND, nodeTypes, makeFullRegistry({}));
expect(isLoaded({ kind: 'name', value: 'constructor' })).toBe(false);
});
});
describe('name selector, credential-types kind', () => {
it('is true when the value is a known credential type', () => {
const registry = makeFullRegistry({
knownCredentials: { slackApi: { className: '', sourcePath: '' } },
});
const isLoaded = loadedCheckFor(CREDENTIAL_TYPES_KIND, {} as NodeTypes, registry);
expect(isLoaded({ kind: 'name', value: 'slackApi' })).toBe(true);
});
it('is false when the value is not a known credential type', () => {
const isLoaded = loadedCheckFor(CREDENTIAL_TYPES_KIND, {} as NodeTypes, makeFullRegistry({}));
expect(isLoaded({ kind: 'name', value: 'unknownApi' })).toBe(false);
});
});
});

View File

@ -0,0 +1,98 @@
import { lintPolicyRules, lintRulesForMissingTypes } from '../policy-rule-lint';
import type { PolicyRule, PolicySelector } from '../policy-rule.types';
type RuleOverrides = Partial<PolicyRule> & Pick<PolicyRule, 'id' | 'selector'>;
const rule = (overrides: RuleOverrides): PolicyRule => ({
action: 'allow',
...overrides,
});
describe('lintRulesForMissingTypes', () => {
it('returns no warnings when every rule is installed', () => {
const rules = [
rule({ id: 'a', selector: { kind: 'name', value: 'typeA' } }),
rule({ id: 'b', selector: { kind: 'package', value: 'n8n-nodes-base' } }),
];
expect(lintRulesForMissingTypes(rules, () => true)).toEqual([]);
});
it('flags each rule whose selector is not installed, carrying its id', () => {
const rules = [
rule({ id: 'installed', selector: { kind: 'name', value: 'typeA' } }),
rule({ id: 'missing-one', selector: { kind: 'name', value: 'typeB' } }),
rule({ id: 'missing-two', selector: { kind: 'package', value: 'n8n-nodes-missing' } }),
];
const isLoaded = (selector: PolicySelector) => selector.value === 'typeA';
expect(lintRulesForMissingTypes(rules, isLoaded)).toEqual([
{ kind: 'notLoaded', ruleId: 'missing-one' },
{ kind: 'notLoaded', ruleId: 'missing-two' },
]);
});
it('returns no warnings for an empty rule list', () => {
expect(lintRulesForMissingTypes([], () => false)).toEqual([]);
});
});
describe('lintPolicyRules', () => {
const policedType = (name: string) => ({ name, baseName: name });
const resolvePackage = () => null;
it('returns no warnings when every rule is installed and none are shadowed', () => {
const rules = [
rule({ id: 'a', selector: { kind: 'name', value: 'typeA' } }),
rule({ id: 'b', selector: { kind: 'name', value: 'typeB' } }),
];
expect(lintPolicyRules(rules, { resolvePackage, policedType, isLoaded: () => true })).toEqual(
[],
);
});
it('flags not-installed rules that are not shadowed, in rule order', () => {
const rules = [
rule({ id: 'a', selector: { kind: 'name', value: 'typeA' } }),
rule({ id: 'b', selector: { kind: 'name', value: 'typeB' } }),
];
const isLoaded = (selector: PolicySelector) => selector.value !== 'typeB';
expect(lintPolicyRules(rules, { resolvePackage, policedType, isLoaded })).toEqual([
{ kind: 'notLoaded', ruleId: 'b' },
]);
});
it('merges shadow and not-installed warnings, sorted by rule position rather than warning kind', () => {
const rules = [
rule({ id: 'a', selector: { kind: 'name', value: 'typeA' } }),
rule({ id: 'b', selector: { kind: 'name', value: 'typeB' } }),
// identical selector to `a`, so shadowed by it
rule({ id: 'c', selector: { kind: 'name', value: 'typeA' } }),
];
const isLoaded = (selector: PolicySelector) => selector.value !== 'typeB';
expect(lintPolicyRules(rules, { resolvePackage, policedType, isLoaded })).toEqual([
{ kind: 'notLoaded', ruleId: 'b' },
{ kind: 'shadowed', ruleId: 'c', shadowedByRuleId: 'a' },
]);
});
it('lists the shadowed warning before the notLoaded warning for the same rule', () => {
const ruleX = rule({ id: 'x', selector: { kind: 'name', value: 'typeX' } });
// identical selector to `ruleX`, so shadowed by it, and separately stubbed as not installed
const ruleY = rule({ id: 'y', selector: { kind: 'name', value: 'typeX' } });
const notLoadedSelectors = new Set<PolicySelector>([ruleY.selector]);
const isLoaded = (selector: PolicySelector) => !notLoadedSelectors.has(selector);
expect(lintPolicyRules([ruleX, ruleY], { resolvePackage, policedType, isLoaded })).toEqual([
{ kind: 'shadowed', ruleId: 'y', shadowedByRuleId: 'x' },
{ kind: 'notLoaded', ruleId: 'y' },
]);
});
it('returns no warnings for an empty rule list', () => {
expect(lintPolicyRules([], { resolvePackage, policedType, isLoaded: () => true })).toEqual([]);
});
});

View File

@ -26,7 +26,7 @@ describe('lintRulesForShadowing', () => {
];
expect(lintRulesForShadowing(rules)).toEqual([
{ ruleId: 'deny-slack', shadowedByRuleId: 'allow-package' },
{ kind: 'shadowed', ruleId: 'deny-slack', shadowedByRuleId: 'allow-package' },
]);
});
@ -66,7 +66,7 @@ describe('lintRulesForShadowing', () => {
];
expect(lintRulesForShadowing(rules, undefined, policedType)).toEqual([
{ ruleId: 'deny-gmail-tool', shadowedByRuleId: 'allow-gmail' },
{ kind: 'shadowed', ruleId: 'deny-gmail-tool', shadowedByRuleId: 'allow-gmail' },
]);
});
@ -109,7 +109,7 @@ describe('lintRulesForShadowing', () => {
];
expect(lintRulesForShadowing(rules)).toEqual([
{ ruleId: 'deny-slack', shadowedByRuleId: 'allow-slack' },
{ kind: 'shadowed', ruleId: 'deny-slack', shadowedByRuleId: 'allow-slack' },
]);
});
@ -121,8 +121,8 @@ describe('lintRulesForShadowing', () => {
];
expect(lintRulesForShadowing(rules)).toEqual([
{ ruleId: 'second-package', shadowedByRuleId: 'first-package' },
{ ruleId: 'later-name', shadowedByRuleId: 'first-package' },
{ kind: 'shadowed', ruleId: 'second-package', shadowedByRuleId: 'first-package' },
{ kind: 'shadowed', ruleId: 'later-name', shadowedByRuleId: 'first-package' },
]);
});
@ -137,7 +137,7 @@ describe('lintRulesForShadowing', () => {
];
expect(lintRulesForShadowing(rules, resolvePackage)).toEqual([
{ ruleId: 'deny-slack-api', shadowedByRuleId: 'allow-package' },
{ kind: 'shadowed', ruleId: 'deny-slack-api', shadowedByRuleId: 'allow-package' },
]);
});
@ -162,7 +162,7 @@ describe('lintRulesForShadowing', () => {
];
expect(lintRulesForShadowing(nameFirst)).toEqual([
{ ruleId: 'later-name', shadowedByRuleId: 'earlier-name' },
{ kind: 'shadowed', ruleId: 'later-name', shadowedByRuleId: 'earlier-name' },
]);
const packageFirst = [
@ -172,8 +172,8 @@ describe('lintRulesForShadowing', () => {
];
expect(lintRulesForShadowing(packageFirst)).toEqual([
{ ruleId: 'later-name-1', shadowedByRuleId: 'earlier-package' },
{ ruleId: 'later-name-2', shadowedByRuleId: 'earlier-package' },
{ kind: 'shadowed', ruleId: 'later-name-1', shadowedByRuleId: 'earlier-package' },
{ kind: 'shadowed', ruleId: 'later-name-2', shadowedByRuleId: 'earlier-package' },
]);
});
@ -196,18 +196,18 @@ describe('lintRulesForShadowing', () => {
it('flags a later name rule for a type built on an earlier family rule', () => {
expect(
lint([family('oauth2', 'oAuth2Api'), name('sheets', 'googleSheetsOAuth2Api')]),
).toEqual([{ ruleId: 'sheets', shadowedByRuleId: 'oauth2' }]);
).toEqual([{ kind: 'shadowed', ruleId: 'sheets', shadowedByRuleId: 'oauth2' }]);
});
it('flags a later name rule for the family base itself', () => {
expect(lint([family('oauth2', 'oAuth2Api'), name('generic', 'oAuth2Api')])).toEqual([
{ ruleId: 'generic', shadowedByRuleId: 'oauth2' },
{ kind: 'shadowed', ruleId: 'generic', shadowedByRuleId: 'oauth2' },
]);
});
it('flags a later family rule nested inside an earlier one', () => {
expect(lint([family('oauth2', 'oAuth2Api'), family('google', 'googleOAuth2Api')])).toEqual([
{ ruleId: 'google', shadowedByRuleId: 'oauth2' },
{ kind: 'shadowed', ruleId: 'google', shadowedByRuleId: 'oauth2' },
]);
});

View File

@ -15,7 +15,7 @@ import type { TypeAvailabilityPolicyScopeRepository } from '../database/reposito
import type { TypeAvailabilityPolicyRepository } from '../database/repositories/type-availability-policy.repository';
import { TypeAvailabilityPolicy } from '../database/entities/type-availability-policy.entity';
import { TypeAvailabilityPolicyScope } from '../database/entities/type-availability-policy-scope.entity';
import type { PolicyRule } from '../policy-rule.types';
import type { PolicyAttachment, PolicyRule } from '../policy-rule.types';
import {
LOCAL_READ_MAX_ENTRIES,
TypeAvailabilityPolicyService,
@ -46,6 +46,8 @@ const RULE: PolicyRule = {
selector: { kind: 'name', value: 'n8n-nodes-base.slack' },
};
const RULE_2: PolicyRule = { ...RULE, id: 'r2' };
const DELEGATE_RULE: PolicyRule = {
id: 'r-delegate',
action: 'delegate',
@ -113,6 +115,11 @@ describe('TypeAvailabilityPolicyService', () => {
scopeRepository.findScopeKeysByIds.mockResolvedValue([]);
// Every fixture rule names `n8n-nodes-base`, so it must resolve as an installed package.
loadNodesAndCredentials.loaders = { 'n8n-nodes-base': makeLoader('n8n-nodes-base') };
// Fixture `name` rules name Slack, so it must resolve as a loaded node.
Object.defineProperty(loadNodesAndCredentials, 'knownNodes', {
configurable: true,
value: { 'n8n-nodes-base.slack': { className: '', sourcePath: '' } },
});
nodeTypes.resolveBaseName.mockImplementation((name) => ({
baseName: name,
isSyntheticTool: false,
@ -159,6 +166,85 @@ describe('TypeAvailabilityPolicyService', () => {
});
});
describe('getAttachments', () => {
it.each([
[KIND, null],
[KIND, 'project-1'],
[CREDENTIAL_TYPES_KIND, null],
[CREDENTIAL_TYPES_KIND, 'project-1'],
])('reads %s attachments for project %s in evaluation order', async (kind, projectId) => {
const ctx: OperationContext = {};
const scope = makeScope({ kind, projectId, version: 7 });
const normalLate: PolicyAttachment = {
policyId: 'normal-late',
rules: [RULE],
priority: 10,
isFloor: false,
};
const floorLate: PolicyAttachment = {
policyId: 'floor-late',
rules: [],
priority: 20,
isFloor: true,
};
const normalEarly: PolicyAttachment = {
policyId: 'normal-early',
rules: [],
priority: -5,
isFloor: false,
};
const floorEarly: PolicyAttachment = {
policyId: 'floor-early',
rules: [RULE],
priority: 0,
isFloor: true,
};
const stored = [normalLate, floorLate, normalEarly, floorEarly];
scopeRepository.findScopeByIdAndKind.mockResolvedValue(scope);
attachmentRepository.listAttachmentsForScope.mockResolvedValue(stored);
const result = await service.getAttachments(kind, scope.id, ctx);
expect(result).toEqual({
attachments: [floorEarly, floorLate, normalEarly, normalLate],
version: 7,
});
expect(stored).toEqual([normalLate, floorLate, normalEarly, floorEarly]);
expect(scopeRepository.findScopeByIdAndKind).toHaveBeenCalledWith(scope.id, kind, ctx);
expect(attachmentRepository.listAttachmentsForScope).toHaveBeenCalledWith(scope.id, ctx);
expect(scopeRepository.findScopeByIdAndKind.mock.invocationCallOrder[0]).toBeLessThan(
attachmentRepository.listAttachmentsForScope.mock.invocationCallOrder[0],
);
expect(transactionRunner.run).not.toHaveBeenCalled();
expect(eventService.emit).not.toHaveBeenCalled();
expect(cacheService.get).not.toHaveBeenCalled();
expect(cacheService.set).not.toHaveBeenCalled();
expect(cacheService.deleteMany).not.toHaveBeenCalled();
});
it.each([KIND, CREDENTIAL_TYPES_KIND])('rejects an unknown scope for %s', async (kind) => {
scopeRepository.findScopeByIdAndKind.mockResolvedValue(null);
await expect(service.getAttachments(kind, 'missing')).rejects.toThrow(NotFoundError);
expect(attachmentRepository.listAttachmentsForScope).not.toHaveBeenCalled();
});
it.each([KIND, CREDENTIAL_TYPES_KIND])(
'rejects a scope of another kind for %s',
async (kind) => {
const otherKind = kind === KIND ? CREDENTIAL_TYPES_KIND : KIND;
const scope = makeScope({ kind: otherKind });
scopeRepository.findScopeByIdAndKind.mockImplementation(async (id, requestedKind) =>
id === scope.id && requestedKind === scope.kind ? scope : null,
);
await expect(service.getAttachments(kind, scope.id)).rejects.toThrow(NotFoundError);
expect(scopeRepository.findScopeByIdAndKind).toHaveBeenCalledWith(scope.id, kind, {});
expect(attachmentRepository.listAttachmentsForScope).not.toHaveBeenCalled();
},
);
});
describe('setDefaultAction', () => {
it('rejects a delegate defaultAction at project scope before opening a transaction', async () => {
await expect(
@ -315,21 +401,72 @@ describe('TypeAvailabilityPolicyService', () => {
const { warnings } = await service.createPolicyDocument(KIND, [RULE, shadowed], 'user-1');
expect(warnings).toEqual([{ ruleId: 'r2', shadowedByRuleId: 'r1' }]);
expect(warnings).toEqual([{ kind: 'shadowed', ruleId: 'r2', shadowedByRuleId: 'r1' }]);
expect(policyRepository.createPolicy).toHaveBeenCalled();
});
it('rejects a package rule naming a package that is not installed', async () => {
it('saves a package rule naming a package that is not installed, with a warning', async () => {
policyRepository.createPolicy.mockResolvedValue(makePolicy());
const rule: PolicyRule = {
id: 'r1',
action: 'deny',
selector: { kind: 'package', value: 'n8n-nodes-not-installed' },
};
await expect(service.createPolicyDocument(KIND, [rule], 'user-1')).rejects.toThrow(
'Package rule names a package that is not installed: n8n-nodes-not-installed',
const { warnings } = await service.createPolicyDocument(KIND, [rule], 'user-1');
expect(warnings).toEqual([{ kind: 'notLoaded', ruleId: 'r1' }]);
expect(policyRepository.createPolicy).toHaveBeenCalled();
});
it('saves a name rule naming a node type that is not loaded, with a warning', async () => {
policyRepository.createPolicy.mockResolvedValue(makePolicy());
const rule: PolicyRule = {
id: 'r1',
action: 'deny',
selector: { kind: 'name', value: 'n8n-nodes-base.notLoaded' },
};
const { warnings } = await service.createPolicyDocument(KIND, [rule], 'user-1');
expect(warnings).toEqual([{ kind: 'notLoaded', ruleId: 'r1' }]);
});
it('checks a synthetic tool variant rule against its base node', async () => {
policyRepository.createPolicy.mockResolvedValue(makePolicy());
nodeTypes.resolveBaseName.mockImplementation((name) => ({
baseName: name.replace(/Tool$/, ''),
isSyntheticTool: name.endsWith('Tool'),
}));
const rules: PolicyRule[] = [
{ id: 'r1', action: 'deny', selector: { kind: 'name', value: 'n8n-nodes-base.slackTool' } },
{ id: 'r2', action: 'deny', selector: { kind: 'name', value: 'n8n-nodes-base.fooTool' } },
];
const { warnings } = await service.createPolicyDocument(KIND, rules, 'user-1');
expect(warnings).toEqual([{ kind: 'notLoaded', ruleId: 'r2' }]);
});
it('lists a shadowed warning before a notLoaded warning for the same rule, in rule order', async () => {
policyRepository.createPolicy.mockResolvedValue(makePolicy());
const missing = (id: string): PolicyRule => ({
id,
action: 'deny',
selector: { kind: 'name', value: 'n8n-nodes-base.notLoaded' },
});
const { warnings } = await service.createPolicyDocument(
KIND,
[missing('r1'), RULE_2, missing('r3')],
'user-1',
);
expect(policyRepository.createPolicy).not.toHaveBeenCalled();
expect(warnings).toEqual([
{ kind: 'notLoaded', ruleId: 'r1' },
{ kind: 'shadowed', ruleId: 'r3', shadowedByRuleId: 'r1' },
{ kind: 'notLoaded', ruleId: 'r3' },
]);
});
describe('extends rules', () => {
@ -360,17 +497,37 @@ describe('TypeAvailabilityPolicyService', () => {
expect(policyRepository.createPolicy).not.toHaveBeenCalled();
});
it('rejects an extends rule naming a credential type that is not installed', async () => {
await expect(
service.createPolicyDocument(
CREDENTIAL_TYPES_KIND,
[familyRule('r1', 'notInstalledApi')],
'user-1',
),
).rejects.toThrow(
'Extends rule names a credential type that is not installed: notInstalledApi',
it('saves an extends rule naming a credential type that is not installed, with a warning', async () => {
policyRepository.createPolicy.mockResolvedValue(
makePolicy({ kind: CREDENTIAL_TYPES_KIND }),
);
expect(policyRepository.createPolicy).not.toHaveBeenCalled();
const { warnings } = await service.createPolicyDocument(
CREDENTIAL_TYPES_KIND,
[familyRule('r1', 'notLoadedApi')],
'user-1',
);
expect(warnings).toEqual([{ kind: 'notLoaded', ruleId: 'r1' }]);
});
it('saves a credential name rule naming a type that is not installed, with a warning', async () => {
policyRepository.createPolicy.mockResolvedValue(
makePolicy({ kind: CREDENTIAL_TYPES_KIND }),
);
const rule: PolicyRule = {
id: 'r1',
action: 'deny',
selector: { kind: 'name', value: 'notLoadedApi' },
};
const { warnings } = await service.createPolicyDocument(
CREDENTIAL_TYPES_KIND,
[rule],
'user-1',
);
expect(warnings).toEqual([{ kind: 'notLoaded', ruleId: 'r1' }]);
});
it('warns about a derived-type rule placed after the family rule that covers it', async () => {
@ -389,7 +546,7 @@ describe('TypeAvailabilityPolicyService', () => {
'user-1',
);
expect(warnings).toEqual([{ ruleId: 'r2', shadowedByRuleId: 'r1' }]);
expect(warnings).toEqual([{ kind: 'shadowed', ruleId: 'r2', shadowedByRuleId: 'r1' }]);
});
});
@ -438,7 +595,7 @@ describe('TypeAvailabilityPolicyService', () => {
};
await expect(service.createPolicyDocument(KIND, [rule], 'user-1')).rejects.toThrow(
'Package rule names a package that is not installed: n8n-creds-base',
'Package rule names "n8n-creds-base", which no package provides. Write a credential type rule instead.',
);
expect(policyRepository.createPolicy).not.toHaveBeenCalled();
});
@ -451,19 +608,29 @@ describe('TypeAvailabilityPolicyService', () => {
scopeRepository.containsProjectScope.mockResolvedValue(false);
});
it('rejects a package rule naming a package that is not installed, before opening a transaction', async () => {
const rule: PolicyRule = {
id: 'r1',
it('keeps accepting edits to a document whose package rule names an uninstalled package', async () => {
const missingPackage: PolicyRule = {
id: 'r-missing',
action: 'deny',
selector: { kind: 'package', value: 'n8n-nodes-not-installed' },
selector: { kind: 'package', value: 'n8n-nodes-mcp' },
};
await expect(
service.updatePolicyDocument(KIND, 'policy-1', [rule], 1, 'user-1'),
).rejects.toThrow(
'Package rule names a package that is not installed: n8n-nodes-not-installed',
policyRepository.findByIdAndKind.mockResolvedValue(
makePolicy({ rules: [missingPackage], version: 1 }),
);
expect(transactionRunner.run).not.toHaveBeenCalled();
policyRepository.updateRules.mockResolvedValue(
makePolicy({ rules: [missingPackage, RULE], version: 2 }),
);
const { warnings } = await service.updatePolicyDocument(
KIND,
'policy-1',
[missingPackage, RULE],
1,
'user-1',
);
expect(warnings).toEqual([{ kind: 'notLoaded', ruleId: 'r-missing' }]);
expect(policyRepository.updateRules).toHaveBeenCalled();
});
it('rejects a node type rule naming a credential-only node, before opening a transaction', async () => {
@ -818,6 +985,42 @@ describe('TypeAvailabilityPolicyService', () => {
expect(result.version).toBe(2);
});
it.each([
[KIND, null],
[KIND, 'project-1'],
[CREDENTIAL_TYPES_KIND, null],
[CREDENTIAL_TYPES_KIND, 'project-1'],
])('returns %s attachments for project %s in evaluation order', async (kind, projectId) => {
const scope = makeScope({ kind, projectId, version: 1 });
const stored: PolicyAttachment[] = [
{ policyId: 'normal-late', rules: [RULE], priority: 10, isFloor: false },
{ policyId: 'floor-late', rules: [], priority: 20, isFloor: true },
{ policyId: 'normal-early', rules: [], priority: -5, isFloor: false },
{ policyId: 'floor-early', rules: [RULE], priority: 0, isFloor: true },
];
scopeRepository.findScopeByIdAndKind.mockResolvedValueOnce(scope);
scopeRepository.findScopeById.mockResolvedValueOnce(
makeScope({ kind, projectId, version: 2 }),
);
policyRepository.findManyByIds.mockResolvedValue([]);
attachmentRepository.listAttachmentsForScope
.mockResolvedValueOnce([])
.mockResolvedValueOnce(stored);
const result = await service.replaceAttachments(
kind,
scope.id,
stored.map(({ policyId, priority, isFloor }) => ({ policyId, priority, isFloor })),
scope.version,
'user-1',
);
expect(result).toEqual({
attachments: [stored[3], stored[1], stored[2], stored[0]],
version: 2,
});
});
it('rejects attaching a document with a delegate rule to a project scope, and writes nothing', async () => {
scopeRepository.findScopeByIdAndKind.mockResolvedValue(makeScope({ projectId: 'project-1' }));
policyRepository.findManyByIds.mockResolvedValue([
@ -868,11 +1071,11 @@ describe('TypeAvailabilityPolicyService', () => {
attachmentRepository.listScopeIdsAttachedToPolicy.mockResolvedValue(['scope-1']);
});
it('rejects a package rule naming a package that is not installed, before opening a transaction', async () => {
it('rejects a package rule on the credential-only prefix, before opening a transaction', async () => {
const rule: PolicyRule = {
id: 'r1',
action: 'deny',
selector: { kind: 'package', value: 'n8n-nodes-not-installed' },
selector: { kind: 'package', value: 'n8n-creds-base' },
};
await expect(
@ -883,9 +1086,7 @@ describe('TypeAvailabilityPolicyService', () => {
0,
'user-1',
),
).rejects.toThrow(
'Package rule names a package that is not installed: n8n-nodes-not-installed',
);
).rejects.toThrow('Package rule names "n8n-creds-base"');
expect(transactionRunner.run).not.toHaveBeenCalled();
});
@ -1137,7 +1338,8 @@ describe('TypeAvailabilityPolicyService', () => {
after: { defaultAction: 'deny', version: 1 },
rulesBefore: null,
rulesAfter: [RULE],
warningCount: 0,
shadowWarningCount: 0,
notLoadedWarningCount: 0,
});
});
@ -1188,10 +1390,47 @@ describe('TypeAvailabilityPolicyService', () => {
after: { defaultAction: 'deny', version: 2 },
rulesBefore: [],
rulesAfter: [RULE],
warningCount: 0,
shadowWarningCount: 0,
notLoadedWarningCount: 0,
});
});
it('saves a rule for an uninstalled package, returns its warning and counts it apart', async () => {
const missingPackage: PolicyRule = {
id: 'r-missing',
action: 'deny',
selector: { kind: 'package', value: 'n8n-nodes-mcp' },
};
const scope = makeScope({ defaultAction: 'allow', version: 1 });
scopeRepository.findScopeByKindAndProject.mockResolvedValue(scope);
const existingPolicy = makePolicy({ rules: [missingPackage], version: 1 });
attachmentRepository.listAttachmentsForScope.mockResolvedValue([
{ policyId: existingPolicy.id, rules: [missingPackage], priority: 0, isFloor: false },
]);
policyRepository.findByIdAndKind.mockResolvedValue(existingPolicy);
policyRepository.updateRules.mockResolvedValue(
makePolicy({ rules: [missingPackage, RULE, RULE_2], version: 2 }),
);
scopeRepository.findScopeById.mockResolvedValue(makeScope({ version: 2 }));
const result = await service.setEffectivePolicy(
KIND,
null,
{ rules: [missingPackage, RULE, RULE_2], defaultAction: 'allow' },
1,
'user-2',
);
expect(result.warnings).toEqual([
{ kind: 'notLoaded', ruleId: 'r-missing' },
{ kind: 'shadowed', ruleId: 'r2', shadowedByRuleId: 'r1' },
]);
expect(eventService.emit).toHaveBeenCalledWith(
'node-type-policy-saved',
expect.objectContaining({ shadowWarningCount: 1, notLoadedWarningCount: 1 }),
);
});
it('throws NotFoundError when the document update unexpectedly finds no row', async () => {
const scope = makeScope({ defaultAction: 'allow', version: 1 });
scopeRepository.findScopeByKindAndProject.mockResolvedValue(scope);

View File

@ -143,6 +143,17 @@ export class CredentialTypePolicyInstanceController {
return { success: true };
}
@Get('/scopes/:scopeId/attachments')
@Licensed(LICENSE_FEATURES.TYPE_AVAILABILITY_POLICIES)
@GlobalScope('credentialTypePolicy:manage')
async getAttachments(
_req: AuthenticatedRequest,
_res: Response,
@Param('scopeId') scopeId: string,
) {
return await this.service.getAttachments(CREDENTIAL_TYPES_KIND, scopeId);
}
@Put('/scopes/:scopeId/attachments')
@Licensed(LICENSE_FEATURES.TYPE_AVAILABILITY_POLICIES)
@GlobalScope('credentialTypePolicy:manage')

View File

@ -7,6 +7,7 @@ import {
type PackageResolver,
type PolicedType,
} from './policy-evaluator';
import type { PolicySelector } from './policy-rule.types';
/**
* A credential type name (e.g. `slackApi`) carries no package prefix, unlike a node type
@ -93,7 +94,7 @@ export function policedTypeFor(
});
}
/** Whether a credential type is known, checked at write time for an `extends` rule's value. */
/** Whether a credential type is known, checked at write time for a rule's value. */
export function isCredentialTypeKnown(
loadNodesAndCredentials: LoadNodesAndCredentials,
typeName: string,
@ -102,8 +103,7 @@ export function isCredentialTypeKnown(
}
/**
* Whether `packageName` names a package this instance actually has loaded — checked at write
* time so a `package` rule can never be saved unable to match anything.
* Whether `packageName` names a package this instance actually has loaded.
*
* `Object.hasOwn`, not `in`: `in` also matches an inherited property, so a rule naming
* `toString` or `constructor` would pass this check through `Object.prototype` even though
@ -115,3 +115,30 @@ export function isPackageInstalled(
): boolean {
return Object.hasOwn(loadNodesAndCredentials.loaders, packageName);
}
/**
* Whether a selector names something this instance has loaded, for the write-time
* `notLoaded` warning. A `name` rule on a synthetic tool variant checks its base node.
*/
export function loadedCheckFor(
kind: string,
nodeTypes: NodeTypes,
loadNodesAndCredentials: LoadNodesAndCredentials,
): (selector: PolicySelector) => boolean {
const policedType = policedTypeFor(kind, nodeTypes, loadNodesAndCredentials);
const isNameKnown =
kind === NODE_TYPES_KIND
? (name: string) => Object.hasOwn(loadNodesAndCredentials.knownNodes, name)
: (name: string) => isCredentialTypeKnown(loadNodesAndCredentials, name);
return (selector) => {
switch (selector.kind) {
case 'package':
return isPackageInstalled(loadNodesAndCredentials, selector.value);
case 'extends':
return isCredentialTypeKnown(loadNodesAndCredentials, selector.value);
case 'name':
return isNameKnown(policedType(selector.value).baseName);
}
};
}

View File

@ -0,0 +1,48 @@
import type { PackageResolver, PolicedType } from './policy-evaluator';
import type { PolicyRule, PolicySelector } from './policy-rule.types';
import { lintRulesForShadowing, type ShadowWarning } from './policy-shadow-lint';
/**
* `ruleId` names a package or type this instance has not loaded. The rule still applies by
* name, for example to an imported workflow. `NODES_EXCLUDE` and `NODES_INCLUDE` count too.
*/
export type NotLoadedWarning = {
readonly kind: 'notLoaded';
readonly ruleId: string;
};
export type PolicyRuleWarning = ShadowWarning | NotLoadedWarning;
/**
* Warns instead of rejecting: a package can be uninstalled after its rule is saved, and the
* rule must not block later, unrelated writes to the same policy.
*/
export function lintRulesForMissingTypes(
rules: readonly PolicyRule[],
isLoaded: (selector: PolicySelector) => boolean,
): NotLoadedWarning[] {
return rules
.filter((rule) => !isLoaded(rule.selector))
.map((rule) => ({ kind: 'notLoaded', ruleId: rule.id }));
}
/**
* Every warning for a rule list, in rule order. A rule with both kinds lists `shadowed` first,
* since the sort is stable.
*/
export function lintPolicyRules(
rules: readonly PolicyRule[],
resolvers: {
resolvePackage: PackageResolver;
policedType: (name: string) => PolicedType;
isLoaded: (selector: PolicySelector) => boolean;
},
): PolicyRuleWarning[] {
const position = new Map(rules.map((rule, index) => [rule.id, index]));
const warnings: PolicyRuleWarning[] = [
...lintRulesForShadowing(rules, resolvers.resolvePackage, resolvers.policedType),
...lintRulesForMissingTypes(rules, resolvers.isLoaded),
];
return warnings.sort((a, b) => (position.get(a.ruleId) ?? 0) - (position.get(b.ruleId) ?? 0));
}

View File

@ -10,6 +10,7 @@ import type { PolicyRule, PolicySelector } from './policy-rule.types';
* appears earlier in the same rule list and matches every type that `ruleId` would match.
*/
export type ShadowWarning = {
readonly kind: 'shadowed';
readonly ruleId: string;
readonly shadowedByRuleId: string;
};
@ -113,7 +114,7 @@ export function lintRulesForShadowing(
}
if (shadowedBy) {
warnings.push({ ruleId: rule.id, shadowedByRuleId: shadowedBy.rule.id });
warnings.push({ kind: 'shadowed', ruleId: rule.id, shadowedByRuleId: shadowedBy.rule.id });
}
const firstOccurrenceByValue = firstByKind[selector.kind];

View File

@ -143,6 +143,17 @@ export class TypeAvailabilityPolicyInstanceController {
return { success: true };
}
@Get('/scopes/:scopeId/attachments')
@Licensed(LICENSE_FEATURES.TYPE_AVAILABILITY_POLICIES)
@GlobalScope('nodeTypePolicy:manage')
async getAttachments(
_req: AuthenticatedRequest,
_res: Response,
@Param('scopeId') scopeId: string,
) {
return await this.service.getAttachments(NODE_TYPES_KIND, scopeId);
}
@Put('/scopes/:scopeId/attachments')
@Licensed(LICENSE_FEATURES.TYPE_AVAILABILITY_POLICIES)
@GlobalScope('nodeTypePolicy:manage')

View File

@ -6,6 +6,7 @@ import { TransactionRunner, type OperationContext } from '@n8n/db';
import { Service } from '@n8n/di';
import { LRUCache } from 'lru-cache';
import {
CREDENTIAL_ONLY_NODE_PREFIX,
getCredentialOnlyNodeCredentialType,
isCredentialOnlyNodeType,
OperationalError,
@ -22,12 +23,7 @@ import { TypeAvailabilityPolicyRepository } from './database/repositories/type-a
import type { TypeAvailabilityPolicy } from './database/entities/type-availability-policy.entity';
import type { TypeAvailabilityPolicyScope } from './database/entities/type-availability-policy-scope.entity';
import { CREDENTIAL_TYPES_KIND, NODE_TYPES_KIND } from './constants';
import {
isCredentialTypeKnown,
isPackageInstalled,
packageResolverFor,
policedTypeFor,
} from './package-resolver';
import { loadedCheckFor, packageResolverFor, policedTypeFor } from './package-resolver';
import { evaluateComposedType, orderedAttachments, type ComposedVerdict } from './policy-evaluator';
import type {
PolicyAction,
@ -35,7 +31,7 @@ import type {
PolicyRule,
PolicyScopeKey,
} from './policy-rule.types';
import { lintRulesForShadowing, type ShadowWarning } from './policy-shadow-lint';
import { lintPolicyRules, type PolicyRuleWarning } from './policy-rule-lint';
/**
* A scope that has never been written has no row. That "unconfigured" state behaves as
@ -146,6 +142,11 @@ export type EffectivePolicy = {
readonly attachments: readonly PolicyAttachment[];
};
export type ScopeAttachments = {
readonly attachments: readonly PolicyAttachment[];
readonly version: number;
};
/** The allow-all state of a scope with no row, for a composition with no project to read. */
const UNCONFIGURED_PROJECT: Omit<EffectivePolicy, 'kind'> = {
scopeId: null,
@ -187,7 +188,7 @@ export type ComposedTypeEvaluation = {
type PolicyDocumentWrite = {
readonly policy: TypeAvailabilityPolicy;
readonly warnings: readonly ShadowWarning[];
readonly warnings: readonly PolicyRuleWarning[];
};
function flattenRules(attachments: readonly PolicyAttachment[]): PolicyRule[] {
@ -226,33 +227,6 @@ function assertNoDelegateAtProjectScope(
}
}
/**
* A `package` rule that names a package this instance never loaded can never match anything —
* worse than not supporting the rule at all, since it looks like it works. Checked at every
* write so a policy is never saved with one.
*/
function assertPackagesInstalled(
rules: readonly PolicyRule[],
loadNodesAndCredentials: LoadNodesAndCredentials,
): void {
const missing = new Set<string>();
for (const rule of rules) {
if (
rule.selector.kind === 'package' &&
!isPackageInstalled(loadNodesAndCredentials, rule.selector.value)
) {
missing.add(rule.selector.value);
}
}
if (missing.size > 0) {
throw new UserError(
`Package rule names a package that is not installed: ${[...missing].join(', ')}`,
);
}
}
/**
* A credential-only node (`n8n-creds-base.<type>`) exists only in the editor. It is stored and
* executed as `n8n-nodes-base.httpRequest`, so a node type rule on the generated name would
@ -273,30 +247,25 @@ function assertNoCredentialOnlyNodeRules(kind: string, rules: readonly PolicyRul
}
/**
* Only credential types declare `extends`, so the selector means nothing for another kind. An
* unknown base would match nothing, the same trap `assertPackagesInstalled` closes.
* No loader provides the credential-only prefix, so a `package` rule on it can never match on
* any instance. Unlike a package that is merely not installed, nothing can fix it later.
*/
function assertExtendsSelectorsValid(
kind: string,
rules: readonly PolicyRule[],
loadNodesAndCredentials: LoadNodesAndCredentials,
): void {
const values = rules.flatMap((rule) =>
rule.selector.kind === 'extends' ? [rule.selector.value] : [],
);
if (values.length === 0) return;
if (kind !== CREDENTIAL_TYPES_KIND) {
throw new UserError('An "extends" rule is only valid in a credential type policy');
function assertNoCredentialOnlyPackageRules(rules: readonly PolicyRule[]): void {
for (const rule of rules) {
if (rule.selector.kind === 'package' && rule.selector.value === CREDENTIAL_ONLY_NODE_PREFIX) {
throw new UserError(
`Package rule names "${CREDENTIAL_ONLY_NODE_PREFIX}", which no package provides. Write a credential type rule instead.`,
);
}
}
}
const unknown = new Set(
values.filter((value) => !isCredentialTypeKnown(loadNodesAndCredentials, value)),
);
if (unknown.size > 0) {
throw new UserError(
`Extends rule names a credential type that is not installed: ${[...unknown].join(', ')}`,
);
/** Only credential types declare `extends`, so the selector means nothing for another kind. */
function assertExtendsSelectorsValid(kind: string, rules: readonly PolicyRule[]): void {
if (kind === CREDENTIAL_TYPES_KIND) return;
if (rules.some((rule) => rule.selector.kind === 'extends')) {
throw new UserError('An "extends" rule is only valid in a credential type policy');
}
}
@ -490,19 +459,28 @@ export class TypeAvailabilityPolicyService {
return scope;
}
/**
* Rejects a rule that is malformed for this kind. A rule that only names something not
* loaded saves, and comes back as a `notLoaded` warning.
*/
private validateAndLintRules(kind: string, rules: readonly PolicyRule[]): PolicyRuleWarning[] {
assertNoCredentialOnlyPackageRules(rules);
assertNoCredentialOnlyNodeRules(kind, rules);
assertExtendsSelectorsValid(kind, rules);
return lintPolicyRules(rules, {
resolvePackage: packageResolverFor(kind, this.loadNodesAndCredentials),
policedType: policedTypeFor(kind, this.nodeTypes, this.loadNodesAndCredentials),
isLoaded: loadedCheckFor(kind, this.nodeTypes, this.loadNodesAndCredentials),
});
}
async createPolicyDocument(
kind: string,
rules: readonly PolicyRule[],
updatedBy: string,
): Promise<PolicyDocumentWrite> {
assertPackagesInstalled(rules, this.loadNodesAndCredentials);
assertNoCredentialOnlyNodeRules(kind, rules);
assertExtendsSelectorsValid(kind, rules, this.loadNodesAndCredentials);
const warnings = lintRulesForShadowing(
rules,
packageResolverFor(kind, this.loadNodesAndCredentials),
policedTypeFor(kind, this.nodeTypes, this.loadNodesAndCredentials),
);
const warnings = this.validateAndLintRules(kind, rules);
const policy = await this.policyRepository.createPolicy({ kind, rules, updatedBy }, {});
@ -535,14 +513,7 @@ export class TypeAvailabilityPolicyService {
expectedVersion: number,
updatedBy: string,
): Promise<PolicyDocumentWrite> {
assertPackagesInstalled(rules, this.loadNodesAndCredentials);
assertNoCredentialOnlyNodeRules(kind, rules);
assertExtendsSelectorsValid(kind, rules, this.loadNodesAndCredentials);
const warnings = lintRulesForShadowing(
rules,
packageResolverFor(kind, this.loadNodesAndCredentials),
policedTypeFor(kind, this.nodeTypes, this.loadNodesAndCredentials),
);
const warnings = this.validateAndLintRules(kind, rules);
const result = await this.transactionRunner.run({}, async (ctx) => {
let invalidated: PolicyScopeKey[] = [];
@ -677,6 +648,21 @@ export class TypeAvailabilityPolicyService {
return await this.policyRepository.findPageByKind(kind, offset, limit, {});
}
async getAttachments(
kind: string,
scopeId: string,
ctx: OperationContext = {},
): Promise<ScopeAttachments> {
const scope = await this.scopeRepository.findScopeByIdAndKind(scopeId, kind, ctx);
if (!scope) {
throw new NotFoundError(`Policy scope not found: ${scopeId}`);
}
const attachments = await this.attachmentRepository.listAttachmentsForScope(scopeId, ctx);
return { attachments: orderedAttachments(attachments), version: scope.version };
}
/**
* Replaces every attachment on one scope, keyed by `scopeId` rather than `(kind,
* projectId)` — a caller replacing attachments already holds a scope id from a prior
@ -699,7 +685,7 @@ export class TypeAvailabilityPolicyService {
attachments: readonly AttachmentInput[],
expectedVersion: number,
updatedBy: string,
): Promise<{ attachments: readonly PolicyAttachment[]; version: number }> {
): Promise<ScopeAttachments> {
assertNoDuplicateAttachmentSlots(attachments);
const result = await this.transactionRunner.run({}, async (ctx) => {
@ -768,7 +754,7 @@ export class TypeAvailabilityPolicyService {
after: { attachments: result.after, version: result.versionAfter },
});
return { attachments: result.after, version: result.versionAfter };
return { attachments: orderedAttachments(result.after), version: result.versionAfter };
}
/**
@ -791,18 +777,10 @@ export class TypeAvailabilityPolicyService {
defaultAction: PolicyAction;
version: number;
rules: PolicyRule[];
warnings: readonly ShadowWarning[];
warnings: readonly PolicyRuleWarning[];
}> {
assertNoDelegateAtProjectScope(projectId, input.defaultAction, input.rules);
assertPackagesInstalled(input.rules, this.loadNodesAndCredentials);
assertNoCredentialOnlyNodeRules(kind, input.rules);
assertExtendsSelectorsValid(kind, input.rules, this.loadNodesAndCredentials);
const warnings = lintRulesForShadowing(
input.rules,
packageResolverFor(kind, this.loadNodesAndCredentials),
policedTypeFor(kind, this.nodeTypes, this.loadNodesAndCredentials),
);
const warnings = this.validateAndLintRules(kind, input.rules);
const result = await this.transactionRunner.run({}, async (ctx) => {
const scope = await this.scopeRepository.findScopeByKindAndProject(
@ -991,7 +969,8 @@ export class TypeAvailabilityPolicyService {
after: result.scopeAfter,
rulesBefore: result.documentBefore?.rules ?? null,
rulesAfter: result.documentAfter.rules,
warningCount: warnings.length,
shadowWarningCount: warnings.filter((warning) => warning.kind === 'shadowed').length,
notLoadedWarningCount: warnings.filter((warning) => warning.kind === 'notLoaded').length,
});
return {

View File

@ -43,6 +43,7 @@ describe('CredentialTypePoliciesPublicController route metadata', () => {
'createCredentialTypePolicyDocument',
'deleteCredentialTypePolicyDocument',
'getCredentialTypeInstancePolicy',
'getCredentialTypePolicyAttachments',
'getCredentialTypePolicyDocument',
'getCredentialTypeProjectPolicy',
'listCredentialTypePolicyDocuments',
@ -119,6 +120,9 @@ describe('CredentialTypePoliciesPublicController with the module disabled', () =
await expect(controller.getCredentialTypePolicyDocument(req, res, 'policy-id')).rejects.toThrow(
ServiceUnavailableError,
);
await expect(
controller.getCredentialTypePolicyAttachments(req, res, 'scope-id'),
).rejects.toThrow(ServiceUnavailableError);
await expect(
controller.deleteCredentialTypePolicyDocument(req, res, 'policy-id'),
).rejects.toThrow(ServiceUnavailableError);
@ -162,9 +166,35 @@ describe('CredentialTypePoliciesPublicController handler bodies', () => {
defaultAction: 'deny' as const,
version: 4,
rules,
warnings: [{ ruleId: 'r2', shadowedByRuleId: 'r1' }],
warnings: [{ kind: 'shadowed' as const, ruleId: 'r2', shadowedByRuleId: 'r1' }],
};
it('getCredentialTypePolicyAttachments reads the credential type scope with extends rules', async () => {
const result = {
attachments: [
{
policyId: 'policy-id',
rules: [
{
id: 'r1',
action: 'deny' as const,
selector: { kind: 'extends' as const, value: 'oAuth2Api' },
},
],
priority: 5,
isFloor: true,
},
],
version: 3,
};
service.getAttachments.mockResolvedValue(result);
expect(await controller.getCredentialTypePolicyAttachments(req, res, 'scope-id')).toEqual(
result,
);
expect(service.getAttachments).toHaveBeenCalledWith(CREDENTIAL_TYPES_KIND, 'scope-id');
});
it('getCredentialTypeInstancePolicy reads the null-project scope and maps the response', async () => {
service.getEffectivePolicy.mockResolvedValue(effectivePolicy);

View File

@ -40,6 +40,7 @@ describe('NodeTypePoliciesPublicController route metadata', () => {
'createNodeTypePolicyDocument',
'deleteNodeTypePolicyDocument',
'getNodeTypeInstancePolicy',
'getNodeTypePolicyAttachments',
'getNodeTypePolicyDocument',
'getNodeTypeProjectPolicy',
'listNodeTypePolicyDocuments',
@ -114,6 +115,9 @@ describe('NodeTypePoliciesPublicController with the module disabled', () => {
await expect(controller.getNodeTypePolicyDocument(req, res, 'policy-id')).rejects.toThrow(
ServiceUnavailableError,
);
await expect(controller.getNodeTypePolicyAttachments(req, res, 'scope-id')).rejects.toThrow(
ServiceUnavailableError,
);
await expect(controller.deleteNodeTypePolicyDocument(req, res, 'policy-id')).rejects.toThrow(
ServiceUnavailableError,
);
@ -157,7 +161,7 @@ describe('NodeTypePoliciesPublicController handler bodies', () => {
defaultAction: 'deny' as const,
version: 4,
rules,
warnings: [{ ruleId: 'r2', shadowedByRuleId: 'r1' }],
warnings: [{ kind: 'shadowed' as const, ruleId: 'r2', shadowedByRuleId: 'r1' }],
};
it('fails loudly instead of returning an extends rule, which no node type write accepts', async () => {
@ -171,6 +175,35 @@ describe('NodeTypePoliciesPublicController handler bodies', () => {
);
});
it('getNodeTypePolicyAttachments reads the node type scope and maps the response', async () => {
const result = {
attachments: [{ policyId: 'policy-id', rules, priority: 5, isFloor: true }],
version: 3,
};
service.getAttachments.mockResolvedValue(result);
expect(await controller.getNodeTypePolicyAttachments(req, res, 'scope-id')).toEqual(result);
expect(service.getAttachments).toHaveBeenCalledWith(NODE_TYPES_KIND, 'scope-id');
});
it('getNodeTypePolicyAttachments rejects an extends rule', async () => {
service.getAttachments.mockResolvedValue({
attachments: [
{
policyId: 'policy-id',
rules: [{ id: 'r1', action: 'deny', selector: { kind: 'extends', value: 'oAuth2Api' } }],
priority: 0,
isFloor: false,
},
],
version: 3,
});
await expect(controller.getNodeTypePolicyAttachments(req, res, 'scope-id')).rejects.toThrow(
'A node type policy holds an extends rule',
);
});
it('getNodeTypeInstancePolicy reads the null-project scope and maps the response', async () => {
service.getEffectivePolicy.mockResolvedValue(effectivePolicy);

View File

@ -43,6 +43,7 @@ import type { Response } from 'express';
import { NotFoundError, ServiceUnavailableError } from '@n8n/errors';
import { CREDENTIAL_TYPES_KIND } from '@/modules/type-availability-policies/constants';
import type { TypeAvailabilityPolicy } from '@/modules/type-availability-policies/database/entities/type-availability-policy.entity';
import type { ScopeAttachments } from '@/modules/type-availability-policies/type-availability-policy.service';
import {
encodeNextCursor,
resolveOffsetPagination,
@ -62,6 +63,18 @@ function toPublicDocument(policy: TypeAvailabilityPolicy): CredentialTypePolicyD
};
}
function toPublicAttachments(result: ScopeAttachments): CredentialTypePolicyAttachmentsPublicDto {
return {
attachments: result.attachments.map((attachment) => ({
policyId: attachment.policyId,
rules: [...attachment.rules],
priority: attachment.priority,
isFloor: attachment.isFloor,
})),
version: result.version,
};
}
/**
* Public API surface for credential type availability policies. Every route delegates to the
* same `TypeAvailabilityPolicyService` methods as the internal `/rest` controllers and reuses
@ -331,6 +344,28 @@ export class CredentialTypePoliciesPublicController {
await (await this.service()).deletePolicyDocument(CREDENTIAL_TYPES_KIND, policyId, req.user.id);
}
@Get('/scopes/:scopeId/attachments')
@Licensed(LICENSE_FEATURES.TYPE_AVAILABILITY_POLICIES)
@ApiKeyScope('credentialTypePolicy:manage')
@GlobalScope('credentialTypePolicy:manage')
@ApiSummary("Preview: Retrieve a scope's attached policy documents")
@ApiDescription(
'**Preview** — breaking changes may still occur without major version bump.\n\nReturns every attached policy document and the scope version. Attachments use evaluation order: floor first, then priority ascending within each group. Use this version when replacing the attachments. `scopeId` comes from `GET /instance` or `GET /projects/{projectId}`, and is null until the scope is first written. This route needs instance-level access, also for a project scope.',
)
@ApiTags(tags)
@ApiResponse(200, CredentialTypePolicyAttachmentsPublicDto)
@ApiErrorResponse(404)
@ApiErrorResponse(503)
async getCredentialTypePolicyAttachments(
_req: AuthenticatedRequest,
_res: Response,
@Param('scopeId', credentialTypePolicyScopeIdParamSchema) scopeId: string,
): Promise<CredentialTypePolicyAttachmentsPublicDto> {
const result = await (await this.service()).getAttachments(CREDENTIAL_TYPES_KIND, scopeId);
return toPublicAttachments(result);
}
@Put('/scopes/:scopeId/attachments')
@Licensed(LICENSE_FEATURES.TYPE_AVAILABILITY_POLICIES)
@ApiKeyScope('credentialTypePolicy:manage')
@ -358,14 +393,6 @@ export class CredentialTypePoliciesPublicController {
req.user.id,
);
return {
attachments: result.attachments.map((attachment) => ({
policyId: attachment.policyId,
rules: [...attachment.rules],
priority: attachment.priority,
isFloor: attachment.isFloor,
})),
version: result.version,
};
return toPublicAttachments(result);
}
}

View File

@ -47,6 +47,7 @@ import { UnexpectedError } from 'n8n-workflow';
import { NotFoundError, ServiceUnavailableError } from '@n8n/errors';
import { NODE_TYPES_KIND } from '@/modules/type-availability-policies/constants';
import type { TypeAvailabilityPolicy } from '@/modules/type-availability-policies/database/entities/type-availability-policy.entity';
import type { ScopeAttachments } from '@/modules/type-availability-policies/type-availability-policy.service';
import {
encodeNextCursor,
resolveOffsetPagination,
@ -74,6 +75,18 @@ function toPublicDocument(policy: TypeAvailabilityPolicy): PolicyDocumentPublicD
};
}
function toPublicAttachments(result: ScopeAttachments): PolicyAttachmentsPublicDto {
return {
attachments: result.attachments.map((attachment) => ({
policyId: attachment.policyId,
rules: toNodeTypeRules(attachment.rules),
priority: attachment.priority,
isFloor: attachment.isFloor,
})),
version: result.version,
};
}
/**
* Public API surface for node type availability policies. Every route delegates to the same
* `TypeAvailabilityPolicyService` methods as the internal `/rest` controllers and reuses their
@ -340,6 +353,28 @@ export class NodeTypePoliciesPublicController {
await (await this.service()).deletePolicyDocument(NODE_TYPES_KIND, policyId, req.user.id);
}
@Get('/scopes/:scopeId/attachments')
@Licensed(LICENSE_FEATURES.TYPE_AVAILABILITY_POLICIES)
@ApiKeyScope('nodeTypePolicy:manage')
@GlobalScope('nodeTypePolicy:manage')
@ApiSummary("Preview: Retrieve a scope's attached policy documents")
@ApiDescription(
'**Preview** — breaking changes may still occur without major version bump.\n\nReturns every attached policy document and the scope version. Attachments use evaluation order: floor first, then priority ascending within each group. Use this version when replacing the attachments. `scopeId` comes from `GET /instance` or `GET /projects/{projectId}`, and is null until the scope is first written. This route needs instance-level access, also for a project scope.',
)
@ApiTags(tags)
@ApiResponse(200, PolicyAttachmentsPublicDto)
@ApiErrorResponse(404)
@ApiErrorResponse(503)
async getNodeTypePolicyAttachments(
_req: AuthenticatedRequest,
_res: Response,
@Param('scopeId', nodeTypePolicyScopeIdParamSchema) scopeId: string,
): Promise<PolicyAttachmentsPublicDto> {
const result = await (await this.service()).getAttachments(NODE_TYPES_KIND, scopeId);
return toPublicAttachments(result);
}
@Put('/scopes/:scopeId/attachments')
@Licensed(LICENSE_FEATURES.TYPE_AVAILABILITY_POLICIES)
@ApiKeyScope('nodeTypePolicy:manage')
@ -367,14 +402,6 @@ export class NodeTypePoliciesPublicController {
req.user.id,
);
return {
attachments: result.attachments.map((attachment) => ({
policyId: attachment.policyId,
rules: toNodeTypeRules(attachment.rules),
priority: attachment.priority,
isFloor: attachment.isFloor,
})),
version: result.version,
};
return toPublicAttachments(result);
}
}

View File

@ -0,0 +1,31 @@
operationId: getCredentialTypePolicyAttachments
tags:
- CredentialTypePolicy
summary: "Preview: Retrieve a scope's attached policy documents"
description: |-
**Preview** — breaking changes may still occur without major version bump.
Returns every attached policy document and the scope version. Attachments use evaluation order: floor first, then priority ascending within each group. Use this version when replacing the attachments. `scopeId` comes from `GET /instance` or `GET /projects/{projectId}`, and is null until the scope is first written. This route needs instance-level access, also for a project scope.
x-required-scope: credentialTypePolicy:manage
parameters:
- schema:
type: string
required: true
description: The ID of the credential type policy scope.
name: scopeId
in: path
responses:
'200':
description: Operation successful.
content:
application/json:
schema:
$ref: ../../../../shared/spec/schemas/credentialTypePolicyAttachmentsPublicDto.generated.yml
'401':
$ref: ../../../../shared/spec/responses/unauthorized.yml
'403':
$ref: ../../../../shared/spec/responses/forbidden.yml
'404':
$ref: ../../../../shared/spec/responses/notFound.yml
'503':
$ref: ../../../../shared/spec/responses/serviceUnavailable.yml

View File

@ -49,89 +49,7 @@ responses:
content:
application/json:
schema:
type: object
properties:
attachments:
type: array
items:
type: object
properties:
policyId:
type: string
rules:
type: array
items:
type: object
properties:
id:
type: string
minLength: 1
action:
type: string
enum:
- allow
- deny
- delegate
selector:
oneOf:
- type: object
properties:
kind:
type: string
enum:
- name
value:
type: string
minLength: 1
required:
- kind
- value
- type: object
properties:
kind:
type: string
enum:
- package
value:
type: string
minLength: 1
required:
- kind
- value
- type: object
properties:
kind:
type: string
enum:
- extends
value:
type: string
minLength: 1
required:
- kind
- value
description: Matches the named credential type and every type that extends it
type: object
required:
- id
- action
- selector
priority:
type: integer
isFloor:
type: boolean
required:
- policyId
- rules
- priority
- isFloor
version:
type: integer
minimum: 0
exclusiveMinimum: true
required:
- attachments
- version
$ref: ../../../../shared/spec/schemas/credentialTypePolicyAttachmentsPublicDto.generated.yml
'400':
$ref: ../../../../shared/spec/responses/badRequest.yml
'401':

View File

@ -0,0 +1,31 @@
operationId: getNodeTypePolicyAttachments
tags:
- NodeTypePolicy
summary: "Preview: Retrieve a scope's attached policy documents"
description: |-
**Preview** — breaking changes may still occur without major version bump.
Returns every attached policy document and the scope version. Attachments use evaluation order: floor first, then priority ascending within each group. Use this version when replacing the attachments. `scopeId` comes from `GET /instance` or `GET /projects/{projectId}`, and is null until the scope is first written. This route needs instance-level access, also for a project scope.
x-required-scope: nodeTypePolicy:manage
parameters:
- schema:
type: string
required: true
description: The ID of the node type policy scope.
name: scopeId
in: path
responses:
'200':
description: Operation successful.
content:
application/json:
schema:
$ref: ../../../../shared/spec/schemas/policyAttachmentsPublicDto.generated.yml
'401':
$ref: ../../../../shared/spec/responses/unauthorized.yml
'403':
$ref: ../../../../shared/spec/responses/forbidden.yml
'404':
$ref: ../../../../shared/spec/responses/notFound.yml
'503':
$ref: ../../../../shared/spec/responses/serviceUnavailable.yml

View File

@ -49,76 +49,7 @@ responses:
content:
application/json:
schema:
type: object
properties:
attachments:
type: array
items:
type: object
properties:
policyId:
type: string
rules:
type: array
items:
type: object
properties:
id:
type: string
minLength: 1
action:
type: string
enum:
- allow
- deny
- delegate
selector:
oneOf:
- type: object
properties:
kind:
type: string
enum:
- name
value:
type: string
minLength: 1
required:
- kind
- value
- type: object
properties:
kind:
type: string
enum:
- package
value:
type: string
minLength: 1
required:
- kind
- value
type: object
required:
- id
- action
- selector
priority:
type: integer
isFloor:
type: boolean
required:
- policyId
- rules
- priority
- isFloor
version:
type: integer
minimum: 0
exclusiveMinimum: true
required:
- attachments
- version
$ref: ../../../../shared/spec/schemas/policyAttachmentsPublicDto.generated.yml
'400':
$ref: ../../../../shared/spec/responses/badRequest.yml
'401':

View File

@ -39,6 +39,8 @@ paths:
delete:
$ref: ./handlers/credential-type-policies/spec/paths/deleteCredentialTypePolicyDocument.generated.yml
/credential-type-policies/scopes/{scopeId}/attachments:
get:
$ref: ./handlers/credential-type-policies/spec/paths/getCredentialTypePolicyAttachments.generated.yml
put:
$ref: ./handlers/credential-type-policies/spec/paths/replaceCredentialTypePolicyAttachments.generated.yml
/credentials:
@ -215,6 +217,8 @@ paths:
delete:
$ref: ./handlers/node-type-policies/spec/paths/deleteNodeTypePolicyDocument.generated.yml
/node-type-policies/scopes/{scopeId}/attachments:
get:
$ref: ./handlers/node-type-policies/spec/paths/getNodeTypePolicyAttachments.generated.yml
put:
$ref: ./handlers/node-type-policies/spec/paths/replaceNodeTypePolicyAttachments.generated.yml
/settings/otel:

View File

@ -0,0 +1,83 @@
type: object
properties:
attachments:
type: array
items:
type: object
properties:
policyId:
type: string
rules:
type: array
items:
type: object
properties:
id:
type: string
minLength: 1
action:
type: string
enum:
- allow
- deny
- delegate
selector:
oneOf:
- type: object
properties:
kind:
type: string
enum:
- name
value:
type: string
minLength: 1
required:
- kind
- value
- type: object
properties:
kind:
type: string
enum:
- package
value:
type: string
minLength: 1
required:
- kind
- value
- type: object
properties:
kind:
type: string
enum:
- extends
value:
type: string
minLength: 1
required:
- kind
- value
description: Matches the named credential type and every type that extends it
type: object
required:
- id
- action
- selector
priority:
type: integer
isFloor:
type: boolean
required:
- policyId
- rules
- priority
- isFloor
version:
type: integer
minimum: 0
exclusiveMinimum: true
required:
- attachments
- version

View File

@ -88,15 +88,33 @@ properties:
warnings:
type: array
items:
oneOf:
- type: object
properties:
kind:
type: string
enum:
- shadowed
ruleId:
type: string
shadowedByRuleId:
type: string
required:
- kind
- ruleId
- shadowedByRuleId
- type: object
properties:
kind:
type: string
enum:
- notLoaded
ruleId:
type: string
required:
- kind
- ruleId
type: object
properties:
ruleId:
type: string
shadowedByRuleId:
type: string
required:
- ruleId
- shadowedByRuleId
required:
- policy
- warnings

View File

@ -73,15 +73,33 @@ properties:
warnings:
type: array
items:
oneOf:
- type: object
properties:
kind:
type: string
enum:
- shadowed
ruleId:
type: string
shadowedByRuleId:
type: string
required:
- kind
- ruleId
- shadowedByRuleId
- type: object
properties:
kind:
type: string
enum:
- notLoaded
ruleId:
type: string
required:
- kind
- ruleId
type: object
properties:
ruleId:
type: string
shadowedByRuleId:
type: string
required:
- ruleId
- shadowedByRuleId
required:
- scopeId
- rules

View File

@ -0,0 +1,70 @@
type: object
properties:
attachments:
type: array
items:
type: object
properties:
policyId:
type: string
rules:
type: array
items:
type: object
properties:
id:
type: string
minLength: 1
action:
type: string
enum:
- allow
- deny
- delegate
selector:
oneOf:
- type: object
properties:
kind:
type: string
enum:
- name
value:
type: string
minLength: 1
required:
- kind
- value
- type: object
properties:
kind:
type: string
enum:
- package
value:
type: string
minLength: 1
required:
- kind
- value
type: object
required:
- id
- action
- selector
priority:
type: integer
isFloor:
type: boolean
required:
- policyId
- rules
- priority
- isFloor
version:
type: integer
minimum: 0
exclusiveMinimum: true
required:
- attachments
- version

View File

@ -75,15 +75,33 @@ properties:
warnings:
type: array
items:
oneOf:
- type: object
properties:
kind:
type: string
enum:
- shadowed
ruleId:
type: string
shadowedByRuleId:
type: string
required:
- kind
- ruleId
- shadowedByRuleId
- type: object
properties:
kind:
type: string
enum:
- notLoaded
ruleId:
type: string
required:
- kind
- ruleId
type: object
properties:
ruleId:
type: string
shadowedByRuleId:
type: string
required:
- ruleId
- shadowedByRuleId
required:
- policy
- warnings

View File

@ -60,15 +60,33 @@ properties:
warnings:
type: array
items:
oneOf:
- type: object
properties:
kind:
type: string
enum:
- shadowed
ruleId:
type: string
shadowedByRuleId:
type: string
required:
- kind
- ruleId
- shadowedByRuleId
- type: object
properties:
kind:
type: string
enum:
- notLoaded
ruleId:
type: string
required:
- kind
- ruleId
type: object
properties:
ruleId:
type: string
shadowedByRuleId:
type: string
required:
- ruleId
- shadowedByRuleId
required:
- scopeId
- rules

View File

@ -220,31 +220,40 @@ describe('FrontendService', () => {
// constructor-injected mock above must also be registered here.
Container.set(License, license);
return {
service: new FrontendService(
globalConfig,
logger,
loadNodesAndCredentials,
credentialTypes,
credentialsOverwrites,
// Suppress the constructor's background write so it cannot outlive the test.
// Restore the method so tests and reload handlers can call it explicitly.
const generateTypes = vi
.spyOn(FrontendService.prototype, 'generateTypes')
.mockResolvedValueOnce();
try {
return {
service: new FrontendService(
globalConfig,
logger,
loadNodesAndCredentials,
credentialTypes,
credentialsOverwrites,
license,
mailer,
instanceSettings,
urlService,
securityConfig,
pushConfig,
binaryDataConfig,
licenseState,
moduleRegistry,
mfaService,
ownershipService,
aiUsageService,
workflowRepository,
workflowReviewPolicyService,
inboxService,
),
license,
mailer,
instanceSettings,
urlService,
securityConfig,
pushConfig,
binaryDataConfig,
licenseState,
moduleRegistry,
mfaService,
ownershipService,
aiUsageService,
workflowRepository,
workflowReviewPolicyService,
inboxService,
),
license,
};
};
} finally {
generateTypes.mockRestore();
}
};
beforeEach(() => {
@ -1368,9 +1377,9 @@ describe('FrontendService', () => {
const largeNodes = createNodes(5000);
const smallNodes = createNodes(1);
const collectTypes = loadNodesAndCredentials.collectTypes as Mock;
collectTypes.mockReturnValueOnce(new Promise(() => {}));
const { service } = createMockService();
expect(collectTypes).not.toHaveBeenCalled();
for (let attempt = 0; attempt < 10; attempt++) {
collectTypes
@ -1383,6 +1392,8 @@ describe('FrontendService', () => {
expect(readTypesFile('credentials')).toEqual([]);
expect(readTypesFile('node-versions')).toEqual(expect.any(Array));
}
expect(collectTypes).toHaveBeenCalledTimes(20);
});
});
});

View File

@ -45,10 +45,11 @@ describe('credential type availability policy instance controller RBAC', () => {
expect(response.statusCode).toBe(403);
});
test('GET /credential-type-policies/instance rejects a member with 403', async () => {
const response = await testServer
.authAgentFor(member)
.get('/credential-type-policies/instance');
test.each([
'/credential-type-policies/instance',
'/credential-type-policies/scopes/does-not-exist/attachments',
])('GET %s rejects a member with 403', async (path) => {
const response = await testServer.authAgentFor(member).get(path);
expect(response.statusCode).toBe(403);
});
@ -71,16 +72,65 @@ describe('credential type availability policy instance controller license gating
testServer.license.enable(LICENSE_FEATURES.TYPE_AVAILABILITY_POLICIES);
});
test('rejects an owner with 403 when the license feature is disabled', async () => {
test.each([
'/credential-type-policies/instance',
'/credential-type-policies/scopes/does-not-exist/attachments',
])('GET %s rejects an owner with 403 when the license feature is disabled', async (path) => {
testServer.license.disable(LICENSE_FEATURES.TYPE_AVAILABILITY_POLICIES);
const response = await testServer.authAgentFor(owner).get('/credential-type-policies/instance');
const response = await testServer.authAgentFor(owner).get(path);
expect(response.statusCode).toBe(403);
});
});
describe('credential type availability policy instance controller admin happy path', () => {
test('GET attachments returns rules and a version that PUT accepts without a read audit event', async () => {
const agent = testServer.authAgentFor(owner);
const rule = { id: 'r1', action: 'deny', selector: { kind: 'name', value: 'slackApi' } };
const instance = await agent.put('/credential-type-policies/instance').send({
rules: [rule],
defaultAction: 'allow',
version: 0,
});
expect(instance.statusCode).toBe(200);
const path = `/credential-type-policies/scopes/${instance.body.data.scopeId}/attachments`;
const emitSpy = vi.spyOn(Container.get(EventService), 'emit');
const read = await agent.get(path);
expect(read.statusCode).toBe(200);
expect(read.body.data).toEqual({
attachments: [{ policyId: expect.any(String), rules: [rule], priority: 0, isFloor: false }],
version: instance.body.data.version,
});
expect(emitSpy).not.toHaveBeenCalled();
const updated = await agent
.put(path)
.send({ attachments: [], version: read.body.data.version });
expect(updated.statusCode).toBe(200);
expect(updated.body.data).toEqual({ attachments: [], version: read.body.data.version + 1 });
const empty = await agent.get(path);
expect(empty.body.data).toEqual(updated.body.data);
});
test('GET attachments returns 404 for an unknown scope and a node type scope', async () => {
const agent = testServer.authAgentFor(owner);
expect(
(await agent.get('/credential-type-policies/scopes/does-not-exist/attachments')).statusCode,
).toBe(404);
const other = await agent.put('/node-type-policies/instance').send({
rules: [],
defaultAction: 'allow',
version: 0,
});
expect(other.statusCode).toBe(200);
expect(
(await agent.get(`/credential-type-policies/scopes/${other.body.data.scopeId}/attachments`))
.statusCode,
).toBe(404);
});
test('PUT /instance persists and fires an audit event carrying the credential-types kind', async () => {
const eventService = Container.get(EventService);
const emitSpy = vi.spyOn(eventService, 'emit');

View File

@ -183,19 +183,19 @@ describe('a rule on a base credential type', () => {
});
describe('writing an extends rule', () => {
test('rejects a base credential type that is not installed', async () => {
test('saves a base credential type that is not installed, with a warning', async () => {
const response = await ownerAgent.put('/credential-type-policies/instance').send({
rules: [
{ id: 'r1', action: 'deny', selector: { kind: 'extends', value: 'notInstalledApi' } },
],
rules: [{ id: 'r1', action: 'deny', selector: { kind: 'extends', value: 'notLoadedApi' } }],
defaultAction: 'allow',
version: 0,
});
expect(response.statusCode).toBe(400);
expect(response.body.message).toBe(
'Extends rule names a credential type that is not installed: notInstalledApi',
);
expect(response.statusCode).toBe(200);
const persisted = await ownerAgent.get('/credential-type-policies/instance');
expect(persisted.body.data.rules).toEqual([
{ id: 'r1', action: 'deny', selector: { kind: 'extends', value: 'notLoadedApi' } },
]);
expect(response.body.data.warnings).toEqual([{ kind: 'notLoaded', ruleId: 'r1' }]);
});
test('rejects the rule in a node type policy request', async () => {

View File

@ -32,8 +32,7 @@ beforeAll(async () => {
owner = await createOwner();
member = await createMember();
// A `package` rule is rejected at write time unless the package is actually loaded — see
// `n8n-nodes-base` used as a stand-in for "an installed package" below.
// `n8n-nodes-base` stands in for an installed package, so its rules save without a warning.
Container.get(LoadNodesAndCredentials).loaders = {
'n8n-nodes-base': mock<NodeLoader>({ known: { nodes: {}, credentials: {} } }),
};
@ -62,8 +61,11 @@ describe('node type availability policy instance controller RBAC', () => {
expect(response.statusCode).toBe(403);
});
test('GET /node-type-policies/instance rejects a member with 403', async () => {
const response = await testServer.authAgentFor(member).get('/node-type-policies/instance');
test.each([
'/node-type-policies/instance',
'/node-type-policies/scopes/does-not-exist/attachments',
])('GET %s rejects a member with 403', async (path) => {
const response = await testServer.authAgentFor(member).get(path);
expect(response.statusCode).toBe(403);
});
@ -130,16 +132,65 @@ describe('node type availability policy instance controller license gating', ()
testServer.license.enable(LICENSE_FEATURES.TYPE_AVAILABILITY_POLICIES);
});
test('rejects an owner with 403 when the license feature is disabled', async () => {
test.each([
'/node-type-policies/instance',
'/node-type-policies/scopes/does-not-exist/attachments',
])('GET %s rejects an owner with 403 when the license feature is disabled', async (path) => {
testServer.license.disable(LICENSE_FEATURES.TYPE_AVAILABILITY_POLICIES);
const response = await testServer.authAgentFor(owner).get('/node-type-policies/instance');
const response = await testServer.authAgentFor(owner).get(path);
expect(response.statusCode).toBe(403);
});
});
describe('node type availability policy instance controller admin happy path', () => {
test('GET attachments returns rules and a version that PUT accepts without a read audit event', async () => {
const agent = testServer.authAgentFor(owner);
const rule = { id: 'r1', action: 'deny', selector: { kind: 'name', value: 'a.b' } };
const instance = await agent.put('/node-type-policies/instance').send({
rules: [rule],
defaultAction: 'allow',
version: 0,
});
expect(instance.statusCode).toBe(200);
const path = `/node-type-policies/scopes/${instance.body.data.scopeId}/attachments`;
const emitSpy = vi.spyOn(Container.get(EventService), 'emit');
const read = await agent.get(path);
expect(read.statusCode).toBe(200);
expect(read.body.data).toEqual({
attachments: [{ policyId: expect.any(String), rules: [rule], priority: 0, isFloor: false }],
version: instance.body.data.version,
});
expect(emitSpy).not.toHaveBeenCalled();
const updated = await agent
.put(path)
.send({ attachments: [], version: read.body.data.version });
expect(updated.statusCode).toBe(200);
expect(updated.body.data).toEqual({ attachments: [], version: read.body.data.version + 1 });
const empty = await agent.get(path);
expect(empty.body.data).toEqual(updated.body.data);
});
test('GET attachments returns 404 for an unknown scope and a credential type scope', async () => {
const agent = testServer.authAgentFor(owner);
expect(
(await agent.get('/node-type-policies/scopes/does-not-exist/attachments')).statusCode,
).toBe(404);
const other = await agent.put('/credential-type-policies/instance').send({
rules: [],
defaultAction: 'allow',
version: 0,
});
expect(other.statusCode).toBe(200);
expect(
(await agent.get(`/node-type-policies/scopes/${other.body.data.scopeId}/attachments`))
.statusCode,
).toBe(404);
});
test('PUT /instance persists, bumps version, and fires an audit event', async () => {
const eventService = Container.get(EventService);
const emitSpy = vi.spyOn(eventService, 'emit');
@ -236,6 +287,29 @@ describe('node type availability policy instance controller admin happy path', (
expect(persisted.body.data.rules).toEqual([rule('r2', 'c.d')]);
});
test('a package rule for an uninstalled package saves with a warning, and does not block later writes', async () => {
const agent = testServer.authAgentFor(owner);
const missingPackage = {
id: 'r-mcp',
action: 'deny' as const,
selector: { kind: 'package' as const, value: 'n8n-nodes-mcp' },
};
const first = await agent
.put('/node-type-policies/instance')
.send({ rules: [missingPackage], defaultAction: 'allow', version: 0 });
expect(first.statusCode).toBe(200);
expect(first.body.data.warnings).toEqual([{ kind: 'notLoaded', ruleId: 'r-mcp' }]);
const unrelatedChange = await agent.put('/node-type-policies/instance').send({
rules: [missingPackage],
defaultAction: 'deny',
version: first.body.data.version,
});
expect(unrelatedChange.statusCode).toBe(200);
expect(unrelatedChange.body.data.warnings).toEqual([{ kind: 'notLoaded', ruleId: 'r-mcp' }]);
});
test('a PUT /instance that changes the rules and the default action together moves the version by one', async () => {
const agent = testServer.authAgentFor(owner);

View File

@ -61,6 +61,13 @@ afterEach(async () => {
]);
});
// Fixture rules name `slackApi`; without it loaded, every write would also warn `notLoaded`.
beforeEach(() => {
vi.spyOn(Container.get(LoadNodesAndCredentials), 'knownCredentials', 'get').mockReturnValue({
slackApi: { className: '', sourcePath: '' },
});
});
/**
* Least privilege first: the API-key scope check and the RBAC check are independent, so a key
* that lacks the scope and a key whose user lacks the RBAC grant are both rejected with 403.
@ -82,6 +89,7 @@ describe('credential type policies public API authorization', () => {
['GET', '/credential-type-policies/policies/does-not-exist'],
['PUT', '/credential-type-policies/policies/does-not-exist'],
['DELETE', '/credential-type-policies/policies/does-not-exist'],
['GET', '/credential-type-policies/scopes/does-not-exist/attachments'],
['PUT', '/credential-type-policies/scopes/does-not-exist/attachments'],
])('%s %s rejects an owner key without the scope with 403', async (method, path) => {
const agent = testServer.publicApiAgentFor(unscopedOwner);
@ -113,6 +121,7 @@ describe('credential type policies public API authorization', () => {
['GET', '/credential-type-policies/policies/does-not-exist'],
['PUT', '/credential-type-policies/policies/does-not-exist'],
['DELETE', '/credential-type-policies/policies/does-not-exist'],
['GET', '/credential-type-policies/scopes/does-not-exist/attachments'],
['PUT', '/credential-type-policies/scopes/does-not-exist/attachments'],
])(
'%s %s rejects a project admin with 403 (instance routes are global-only)',
@ -168,12 +177,13 @@ describe('credential type policies public API license gating', () => {
testServer.license.enable(LICENSE_FEATURES.TYPE_AVAILABILITY_POLICIES);
});
test('rejects an owner with 403 when the license feature is disabled', async () => {
test.each([
'/credential-type-policies/instance',
'/credential-type-policies/scopes/does-not-exist/attachments',
])('GET %s rejects an owner with 403 when the license feature is disabled', async (path) => {
testServer.license.disable(LICENSE_FEATURES.TYPE_AVAILABILITY_POLICIES);
const instance = await testServer
.publicApiAgentFor(owner)
.get('/credential-type-policies/instance');
const instance = await testServer.publicApiAgentFor(owner).get(path);
expect(instance.statusCode).toBe(403);
const projectPolicy = await testServer
@ -425,7 +435,9 @@ describe('credential type policies public API policy documents', () => {
.send({ rules: [DENY_RULE, { ...DENY_RULE, id: 'r2', action: 'allow' }] });
expect(response.statusCode).toBe(201);
expect(response.body.warnings).toEqual([{ ruleId: 'r2', shadowedByRuleId: 'r1' }]);
expect(response.body.warnings).toEqual([
{ kind: 'shadowed', ruleId: 'r2', shadowedByRuleId: 'r1' },
]);
});
test('POST rejects duplicate rule ids with 400', async () => {
@ -596,22 +608,117 @@ describe('credential type policies public API policy documents', () => {
});
describe('credential type policies public API attachments', () => {
test.each(['instance', 'project'])(
'GET and PUT return every attachment in evaluation order for %s scope',
async (scope) => {
const agent = testServer.publicApiAgentFor(owner);
const effectivePath =
scope === 'instance' ? '/credential-type-policies/instance' : projectRoute(project.id);
const configured = await agent.put(effectivePath).send(ALLOW_ALL);
expect(configured.statusCode).toBe(200);
const path = `/credential-type-policies/scopes/${configured.body.scopeId}/attachments`;
const slots = [
{ priority: 10, isFloor: false },
{ priority: 20, isFloor: true },
{ priority: -5, isFloor: false },
{ priority: 0, isFloor: true },
];
const attachments = [];
for (const [index, slot] of slots.entries()) {
const rules = [{ ...DENY_RULE, id: `r${index}` }];
const created = await agent.post('/credential-type-policies/policies').send({ rules });
expect(created.statusCode).toBe(201);
attachments.push({ policyId: created.body.policy.id as string, rules, ...slot });
}
const replaced = await agent.put(path).send({
attachments: attachments.map(({ rules: _rules, ...attachment }) => attachment),
version: configured.body.version,
});
expect(replaced.statusCode).toBe(200);
expect(replaced.body).toEqual({
attachments: [attachments[3], attachments[1], attachments[2], attachments[0]],
version: configured.body.version + 1,
});
const emitSpy = vi.spyOn(Container.get(EventService), 'emit');
const read = await agent.get(path);
expect(read.statusCode).toBe(200);
expect(read.body).toEqual(replaced.body);
expect(emitSpy.mock.calls.filter(([event]) => event.startsWith('node-type-policy-'))).toEqual(
[],
);
const effective = await agent.get(effectivePath);
expect(Object.keys(effective.body).sort()).toEqual([...EFFECTIVE_KEYS].sort());
const updated = await agent.put(path).send({ attachments: [], version: read.body.version });
expect(updated.statusCode).toBe(200);
expect(updated.body).toEqual({ attachments: [], version: read.body.version + 1 });
const empty = await agent.get(path);
expect(empty.statusCode).toBe(200);
expect(empty.body).toEqual(updated.body);
},
);
test('GET returns 404 for an unknown scope and a node type scope', async () => {
const agent = testServer.publicApiAgentFor(owner);
expect(
(await agent.get('/credential-type-policies/scopes/does-not-exist/attachments')).statusCode,
).toBe(404);
const other = await agent.put('/node-type-policies/instance').send(ALLOW_ALL);
expect(other.statusCode).toBe(200);
expect(
(await agent.get(`/credential-type-policies/scopes/${other.body.scopeId}/attachments`))
.statusCode,
).toBe(404);
});
test('PUT with a stale version returns 409 and preserves the attachments and version', async () => {
const agent = testServer.publicApiAgentFor(owner);
const instance = await agent.put('/credential-type-policies/instance').send({
...ALLOW_ALL,
rules: [DENY_RULE],
});
const instance = await agent.put('/credential-type-policies/instance').send(ALLOW_ALL);
expect(instance.statusCode).toBe(200);
const created = await agent
.post('/credential-type-policies/policies')
.send({ rules: [DENY_RULE] });
expect(created.statusCode).toBe(201);
const policyId = created.body.policy.id as string;
const stale = await agent
.put(`/credential-type-policies/scopes/${instance.body.scopeId}/attachments`)
.send({ attachments: [], version: 0 });
const path = `/credential-type-policies/scopes/${instance.body.scopeId}/attachments`;
const attached = await agent.put(path).send({
attachments: [{ policyId, priority: 0, isFloor: false }],
version: instance.body.version,
});
expect(attached.statusCode).toBe(200);
const read = await agent.get(path);
expect(read.statusCode).toBe(200);
expect(read.body).toEqual({
attachments: [{ policyId, rules: [DENY_RULE], priority: 0, isFloor: false }],
version: instance.body.version + 1,
});
const replaced = await agent.put(path).send({
attachments: read.body.attachments.map(({ policyId }: { policyId: string }) => ({
policyId,
priority: 5,
isFloor: false,
})),
version: read.body.version,
});
expect(replaced.statusCode).toBe(200);
expect(replaced.body).toEqual({
attachments: [{ policyId, rules: [DENY_RULE], priority: 5, isFloor: false }],
version: read.body.version + 1,
});
const stale = await agent.put(path).send({ attachments: [], version: read.body.version });
expect(stale.statusCode).toBe(409);
const persisted = await agent.get('/credential-type-policies/instance');
expect(persisted.body.rules).toEqual([DENY_RULE]);
expect(persisted.body.version).toBe(instance.body.version);
const persisted = await agent.get(path);
expect(persisted.statusCode).toBe(200);
expect(persisted.body).toEqual(replaced.body);
const updated = await agent
.put(path)
.send({ attachments: [], version: persisted.body.version });
expect(updated.statusCode).toBe(200);
expect(updated.body).toEqual({ attachments: [], version: persisted.body.version + 1 });
});
test.each([undefined, -1, 0.5])('PUT rejects invalid version %s with 400', async (version) => {
@ -767,5 +874,10 @@ describe('credential type policies public API extends rules', () => {
expect(attachments.body.attachments).toEqual([
{ policyId, rules: [EXTENDS_RULE], priority: 1, isFloor: false },
]);
const readAttachments = await agent.get(
`/credential-type-policies/scopes/${instancePut.body.scopeId}/attachments`,
);
expect(readAttachments.statusCode).toBe(200);
expect(readAttachments.body).toEqual(attachments.body);
});
});

View File

@ -4,6 +4,7 @@ import { LICENSE_FEATURES } from '@n8n/constants';
import type { Project, User } from '@n8n/db';
import { Container } from '@n8n/di';
import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials';
import { TypeAvailabilityPolicyRepository } from '@/modules/type-availability-policies/database/repositories/type-availability-policy.repository';
import { createMemberWithApiKey, createOwnerWithApiKey } from '@test-integration/db/users';
import * as utils from '@test-integration/utils';
@ -56,6 +57,13 @@ afterEach(async () => {
]);
});
// Fixture rules name `a.b`; without it loaded, every write would also warn `notLoaded`.
beforeEach(() => {
vi.spyOn(Container.get(LoadNodesAndCredentials), 'knownNodes', 'get').mockReturnValue({
'a.b': { className: '', sourcePath: '' },
});
});
/**
* Least privilege first: the API-key scope check and the RBAC check are independent, so a key
* that lacks the scope and a key whose user lacks the RBAC grant are both rejected with 403.
@ -77,6 +85,7 @@ describe('node type policies public API authorization', () => {
['GET', '/node-type-policies/policies/does-not-exist'],
['PUT', '/node-type-policies/policies/does-not-exist'],
['DELETE', '/node-type-policies/policies/does-not-exist'],
['GET', '/node-type-policies/scopes/does-not-exist/attachments'],
['PUT', '/node-type-policies/scopes/does-not-exist/attachments'],
])('%s %s rejects an owner key without the scope with 403', async (method, path) => {
const agent = testServer.publicApiAgentFor(unscopedOwner);
@ -108,6 +117,7 @@ describe('node type policies public API authorization', () => {
['GET', '/node-type-policies/policies/does-not-exist'],
['PUT', '/node-type-policies/policies/does-not-exist'],
['DELETE', '/node-type-policies/policies/does-not-exist'],
['GET', '/node-type-policies/scopes/does-not-exist/attachments'],
['PUT', '/node-type-policies/scopes/does-not-exist/attachments'],
])(
'%s %s rejects a project admin with 403 (instance routes are global-only)',
@ -163,10 +173,13 @@ describe('node type policies public API license gating', () => {
testServer.license.enable(LICENSE_FEATURES.TYPE_AVAILABILITY_POLICIES);
});
test('rejects an owner with 403 when the license feature is disabled', async () => {
test.each([
'/node-type-policies/instance',
'/node-type-policies/scopes/does-not-exist/attachments',
])('GET %s rejects an owner with 403 when the license feature is disabled', async (path) => {
testServer.license.disable(LICENSE_FEATURES.TYPE_AVAILABILITY_POLICIES);
const instance = await testServer.publicApiAgentFor(owner).get('/node-type-policies/instance');
const instance = await testServer.publicApiAgentFor(owner).get(path);
expect(instance.statusCode).toBe(403);
const projectPolicy = await testServer
@ -416,7 +429,9 @@ describe('node type policies public API policy documents', () => {
.send({ rules: [DENY_RULE, { ...DENY_RULE, id: 'r2', action: 'allow' }] });
expect(response.statusCode).toBe(201);
expect(response.body.warnings).toEqual([{ ruleId: 'r2', shadowedByRuleId: 'r1' }]);
expect(response.body.warnings).toEqual([
{ kind: 'shadowed', ruleId: 'r2', shadowedByRuleId: 'r1' },
]);
});
test('POST rejects duplicate rule ids with 400', async () => {
@ -564,22 +579,114 @@ describe('node type policies public API policy documents', () => {
});
describe('node type policies public API attachments', () => {
test.each(['instance', 'project'])(
'GET and PUT return every attachment in evaluation order for %s scope',
async (scope) => {
const agent = testServer.publicApiAgentFor(owner);
const effectivePath =
scope === 'instance' ? '/node-type-policies/instance' : projectRoute(project.id);
const configured = await agent.put(effectivePath).send(ALLOW_ALL);
expect(configured.statusCode).toBe(200);
const path = `/node-type-policies/scopes/${configured.body.scopeId}/attachments`;
const slots = [
{ priority: 10, isFloor: false },
{ priority: 20, isFloor: true },
{ priority: -5, isFloor: false },
{ priority: 0, isFloor: true },
];
const attachments = [];
for (const [index, slot] of slots.entries()) {
const rules = [{ ...DENY_RULE, id: `r${index}` }];
const created = await agent.post('/node-type-policies/policies').send({ rules });
expect(created.statusCode).toBe(201);
attachments.push({ policyId: created.body.policy.id as string, rules, ...slot });
}
const replaced = await agent.put(path).send({
attachments: attachments.map(({ rules: _rules, ...attachment }) => attachment),
version: configured.body.version,
});
expect(replaced.statusCode).toBe(200);
expect(replaced.body).toEqual({
attachments: [attachments[3], attachments[1], attachments[2], attachments[0]],
version: configured.body.version + 1,
});
const emitSpy = vi.spyOn(Container.get(EventService), 'emit');
const read = await agent.get(path);
expect(read.statusCode).toBe(200);
expect(read.body).toEqual(replaced.body);
expect(emitSpy.mock.calls.filter(([event]) => event.startsWith('node-type-policy-'))).toEqual(
[],
);
const effective = await agent.get(effectivePath);
expect(Object.keys(effective.body).sort()).toEqual([...EFFECTIVE_KEYS].sort());
const updated = await agent.put(path).send({ attachments: [], version: read.body.version });
expect(updated.statusCode).toBe(200);
expect(updated.body).toEqual({ attachments: [], version: read.body.version + 1 });
const empty = await agent.get(path);
expect(empty.statusCode).toBe(200);
expect(empty.body).toEqual(updated.body);
},
);
test('GET returns 404 for an unknown scope and a credential type scope', async () => {
const agent = testServer.publicApiAgentFor(owner);
expect(
(await agent.get('/node-type-policies/scopes/does-not-exist/attachments')).statusCode,
).toBe(404);
const other = await agent.put('/credential-type-policies/instance').send(ALLOW_ALL);
expect(other.statusCode).toBe(200);
expect(
(await agent.get(`/node-type-policies/scopes/${other.body.scopeId}/attachments`)).statusCode,
).toBe(404);
});
test('PUT with a stale version returns 409 and preserves the attachments and version', async () => {
const agent = testServer.publicApiAgentFor(owner);
const instance = await agent.put('/node-type-policies/instance').send({
...ALLOW_ALL,
rules: [DENY_RULE],
});
const instance = await agent.put('/node-type-policies/instance').send(ALLOW_ALL);
expect(instance.statusCode).toBe(200);
const created = await agent.post('/node-type-policies/policies').send({ rules: [DENY_RULE] });
expect(created.statusCode).toBe(201);
const policyId = created.body.policy.id as string;
const stale = await agent
.put(`/node-type-policies/scopes/${instance.body.scopeId}/attachments`)
.send({ attachments: [], version: 0 });
const path = `/node-type-policies/scopes/${instance.body.scopeId}/attachments`;
const attached = await agent.put(path).send({
attachments: [{ policyId, priority: 0, isFloor: false }],
version: instance.body.version,
});
expect(attached.statusCode).toBe(200);
const read = await agent.get(path);
expect(read.statusCode).toBe(200);
expect(read.body).toEqual({
attachments: [{ policyId, rules: [DENY_RULE], priority: 0, isFloor: false }],
version: instance.body.version + 1,
});
const replaced = await agent.put(path).send({
attachments: read.body.attachments.map(({ policyId }: { policyId: string }) => ({
policyId,
priority: 5,
isFloor: false,
})),
version: read.body.version,
});
expect(replaced.statusCode).toBe(200);
expect(replaced.body).toEqual({
attachments: [{ policyId, rules: [DENY_RULE], priority: 5, isFloor: false }],
version: read.body.version + 1,
});
const stale = await agent.put(path).send({ attachments: [], version: read.body.version });
expect(stale.statusCode).toBe(409);
const persisted = await agent.get('/node-type-policies/instance');
expect(persisted.body.rules).toEqual([DENY_RULE]);
expect(persisted.body.version).toBe(instance.body.version);
const persisted = await agent.get(path);
expect(persisted.statusCode).toBe(200);
expect(persisted.body).toEqual(replaced.body);
const updated = await agent
.put(path)
.send({ attachments: [], version: persisted.body.version });
expect(updated.statusCode).toBe(200);
expect(updated.body).toEqual({ attachments: [], version: persisted.body.version + 1 });
});
test.each([undefined, -1, 0.5])('PUT rejects invalid version %s with 400', async (version) => {

View File

@ -3558,6 +3558,8 @@
"settings.communityNodes.uninstallAction.label": "Uninstall package",
"settings.communityNodes.upToDate.tooltip": "You are up to date",
"settings.communityNodes.failedToLoad.tooltip": "This package failed to load. It may be corrupted, or it may need a newer version of n8n. Try uninstalling and reinstalling it, or upgrade n8n.",
"settings.communityNodes.restricted.badge.label": "Restricted",
"settings.communityNodes.restricted.tooltip.heading": "Blocked by an instance policy",
"settings.communityNodes.managedByEnv": "Community packages are managed via environment variables and cannot be modified from the UI.",
"settings.communityNodes.fetchError.title": "Problem fetching installed packages",
"settings.communityNodes.fetchError.message": "There may be a problem with your internet connection or your n8n instance",

View File

@ -5,6 +5,9 @@ import { STORES } from '@n8n/stores';
import CommunityPackageCard from './CommunityPackageCard.vue';
import { createComponentRenderer } from '@/__tests__/render';
import type { PublicInstalledNode, PublicInstalledPackage } from 'n8n-workflow';
import { useTypeAvailabilityPoliciesStore } from '@n8n/frontend-module-type-availability-policies';
import type { NodeTypeAvailability } from '@n8n/api-types';
import { waitFor } from '@testing-library/vue';
const communityPackage: PublicInstalledPackage = {
packageName: 'n8n-nodes-test',
@ -113,4 +116,72 @@ describe('CommunityPackageCard', () => {
expect(queryByText('Uninstall package')).not.toBeInTheDocument();
});
});
describe('restricted badge', () => {
const mockNodeAvailability = (
unavailable: Record<string, Pick<NodeTypeAvailability, 'scope' | 'optInAvailable'>>,
) => {
vi.spyOn(useTypeAvailabilityPoliciesStore(), 'getNodeTypeAvailability').mockImplementation(
(name: string) =>
name in unavailable
? { name, available: false, ...unavailable[name] }
: { name, available: true },
);
};
// Reka UI only mounts tooltip content once the trigger is hovered.
function hoverTrigger(element: Element) {
element.dispatchEvent(
new PointerEvent('pointermove', {
bubbles: true,
cancelable: true,
pointerType: 'mouse',
clientX: 100,
clientY: 100,
}),
);
}
it('shows the badge and lists instance-blocked node types in the tooltip', async () => {
mockNodeAvailability({ 'n8n-nodes-test.oldNode': { scope: 'instance' } });
const { getByText } = renderComponent({ props: { communityPackage } });
const badge = getByText('Restricted');
expect(badge).toBeInTheDocument();
hoverTrigger(badge);
await waitFor(() => {
expect(document.querySelector('[data-dismissable-layer]')).toHaveTextContent('OldNode');
});
expect(document.querySelector('[data-dismissable-layer]')).not.toHaveTextContent('TestNode');
});
it('does not show the badge when no node type is blocked', () => {
mockNodeAvailability({});
const { queryByText } = renderComponent({ props: { communityPackage } });
expect(queryByText('Restricted')).not.toBeInTheDocument();
});
it('ignores a project-scoped restriction', () => {
mockNodeAvailability({ 'n8n-nodes-test.oldNode': { scope: 'project' } });
const { queryByText } = renderComponent({ props: { communityPackage } });
expect(queryByText('Restricted')).not.toBeInTheDocument();
});
it('ignores an instance restriction that projects can opt into', () => {
mockNodeAvailability({
'n8n-nodes-test.oldNode': { scope: 'instance', optInAvailable: true },
});
const { queryByText } = renderComponent({ props: { communityPackage } });
expect(queryByText('Restricted')).not.toBeInTheDocument();
});
});
});

View File

@ -1,6 +1,6 @@
<script lang="ts" setup>
import { useUIStore } from '@/app/stores/ui.store';
import type { IUser, PublicInstalledPackage } from 'n8n-workflow';
import type { IUser, PublicInstalledNode, PublicInstalledPackage } from 'n8n-workflow';
import { COMMUNITY_PACKAGE_MANAGE_ACTIONS } from '../communityNodes.constants';
import { NPM_PACKAGE_DOCS_BASE_URL } from '@/app/constants';
import { useI18n } from '@n8n/i18n';
@ -8,6 +8,7 @@ import { useTelemetry } from '@n8n/composables/useTelemetry';
import { useSettingsStore } from '@n8n/stores/settings.store';
import type { UserAction } from '@n8n/design-system';
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
import { getNodeTypeRestriction } from '@n8n/frontend-module-type-availability-policies';
import { computed, ref, watch } from 'vue';
import {
findVettedCommunityNodeAttributes,
@ -16,6 +17,7 @@ import {
import {
N8nActionToggle,
N8nBadge,
N8nButton,
N8nIcon,
N8nLoading,
@ -57,6 +59,17 @@ const hasUpdateAvailable = computed(() => {
});
});
// Packages are installed instance-wide, so only a hard instance block counts. A delegated
// type (`optInAvailable`) is not blocked, because any project can allow it.
const blockedNodes = computed<PublicInstalledNode[]>(() => {
if (!props.communityPackage) return [];
return props.communityPackage.installedNodes.filter((node) => {
const restriction = getNodeTypeRestriction(node.type);
return restriction?.scope === 'instance' && !restriction.optInAvailable;
});
});
const packageActions = computed<Array<UserAction<IUser>>>(() => {
const actions: Array<UserAction<IUser>> = [
{
@ -120,6 +133,25 @@ watch(
<div :class="$style.cardInfoContainer">
<div :class="$style.cardTitle">
<N8nText :bold="true" size="large">{{ communityPackage.packageName }}</N8nText>
<N8nTooltip v-if="blockedNodes.length > 0" placement="top">
<template #content>
<div>
<N8nText tag="p" size="small" :bold="true">
{{ i18n.baseText('settings.communityNodes.restricted.tooltip.heading') }}
</N8nText>
<ul :class="$style.blockedNodesList">
<li v-for="node in blockedNodes" :key="node.type">{{ node.name }}</li>
</ul>
</div>
</template>
<N8nBadge
variant="filled"
leading-icon="lock"
data-test-id="community-package-restricted-badge"
>
{{ i18n.baseText('settings.communityNodes.restricted.badge.label') }}
</N8nBadge>
</N8nTooltip>
</div>
<div :class="$style.cardSubtitle">
<N8nText :bold="true" size="small" color="text-light">
@ -216,6 +248,9 @@ watch(
}
.cardTitle {
display: flex;
align-items: center;
gap: var(--spacing--2xs);
flex-basis: 100%;
span {
@ -223,6 +258,11 @@ watch(
}
}
.blockedNodesList {
margin: var(--spacing--4xs) 0 0;
padding-left: var(--spacing--sm);
}
.cardSubtitle {
margin-top: 2px;
padding-right: var(--spacing--md);

View File

@ -7,6 +7,9 @@ import { setActivePinia } from 'pinia';
import { STORES } from '@n8n/stores';
import { useCommunityNodesStore } from '../communityNodes.store';
import { useUIStore } from '@/app/stores/ui.store';
import { useProjectsStore } from '@/features/collaboration/projects/projects.store';
import { mockedStore } from '@/__tests__/utils';
import { useTypeAvailabilityPoliciesStore } from '@n8n/frontend-module-type-availability-policies';
import { COMMUNITY_PACKAGE_INSTALL_MODAL_KEY } from '../communityNodes.constants';
import type { PublicInstalledPackage, PublicInstalledNode } from 'n8n-workflow';
@ -111,6 +114,7 @@ const setup = (
availablePackageCount?: number;
communityNodesManagedByEnv?: boolean;
} = {},
{ beforeRender }: { beforeRender?: () => void } = {},
) => {
const {
packages = [],
@ -140,6 +144,8 @@ const setup = (
uiStore.openModal = vi.fn();
beforeRender?.();
return {
...renderComponent({ pinia }),
communityNodesStore,
@ -282,4 +288,23 @@ describe('SettingsCommunityNodesView', () => {
expect(uiStore.openModal).toHaveBeenCalledWith(COMMUNITY_PACKAGE_INSTALL_MODAL_KEY);
});
});
describe('node type availability', () => {
it("loads the policy for the user's personal project", async () => {
const PERSONAL_PROJECT_ID = 'personal-project-1';
let fetchForProject: ReturnType<typeof vi.fn>;
setup(undefined, {
beforeRender: () => {
mockedStore(useProjectsStore).personalProject = { id: PERSONAL_PROJECT_ID } as never;
fetchForProject = vi
.spyOn(useTypeAvailabilityPoliciesStore(), 'fetchForProject')
.mockResolvedValue(undefined);
},
});
await flushPromises();
expect(fetchForProject!).toHaveBeenCalledWith(PERSONAL_PROJECT_ID);
});
});
});

View File

@ -10,7 +10,7 @@ import type { PublicInstalledPackage } from 'n8n-workflow';
import { useCommunityNodesStore } from '../communityNodes.store';
import { useUIStore } from '@/app/stores/ui.store';
import { onBeforeUnmount, ref, computed, onBeforeMount, onMounted } from 'vue';
import { onBeforeUnmount, ref, computed, onBeforeMount, onMounted, watch } from 'vue';
import { useExternalHooks } from '@/app/composables/useExternalHooks';
import { useRouter } from 'vue-router';
import { usePushConnection } from '@/app/composables/usePushConnection';
@ -18,6 +18,8 @@ import { usePushConnectionStore } from '@/app/stores/pushConnection.store';
import { useI18n } from '@n8n/i18n';
import { useTelemetry } from '@n8n/composables/useTelemetry';
import { useSettingsStore } from '@n8n/stores/settings.store';
import { useProjectsStore } from '@/features/collaboration/projects/projects.store';
import { useTypeAvailabilityPoliciesStore } from '@n8n/frontend-module-type-availability-policies';
import { N8nEmptyState, N8nButton, N8nHeading, N8nNotice } from '@n8n/design-system';
const PACKAGE_COUNT_THRESHOLD = 31;
@ -36,6 +38,8 @@ const documentTitle = useDocumentTitle();
const communityNodesStore = useCommunityNodesStore();
const uiStore = useUIStore();
const settingsStore = useSettingsStore();
const projectsStore = useProjectsStore();
const typeAvailabilityPoliciesStore = useTypeAvailabilityPoliciesStore();
const isManagedByEnv = computed((): boolean => {
return settingsStore.settings.communityNodesManagedByEnv ?? false;
@ -154,6 +158,16 @@ onBeforeUnmount(() => {
pushStore.pushDisconnect();
pushConnection.terminate();
});
// Node type availability is project-scoped, but this page has no project context of its own.
// The personal project is only used to carry the instance-wide verdicts the cards need.
watch(
() => projectsStore.personalProject?.id,
(projectId) => {
if (projectId) void typeAvailabilityPoliciesStore.fetchForProject(projectId);
},
{ immediate: true },
);
</script>
<template>

View File

@ -1,2 +1,3 @@
export * as deleteRows from './deleteRows.operation';
export * as getAll from './getAll.operation';
export * as insert from './insert.operation';

View File

@ -44,14 +44,17 @@ export function quotePostgrestComponent(value: unknown): string {
}
/**
* Turns the Select Rows, Sort and Output Columns fields into Data API query parameters.
* Turns the Select Rows conditions into the one query parameter that narrows a
* request. Returns an empty object when nothing survives, which is how a caller
* that must not run unfiltered tells the difference.
*
* Conditions always go into a single `and=()` or `or=()` group rather than one
* parameter per column, so two conditions on the same column both survive.
*/
export function buildLakebaseQuery(input: LakebaseQueryInput): IDataObject {
const qs: IDataObject = {};
export function buildLakebaseFilter(input: {
where: LakebaseWhereRule[];
combineConditions: 'AND' | 'OR';
}): IDataObject {
const terms = input.where
.filter((rule) => rule.column)
.map((rule) => {
@ -75,9 +78,14 @@ export function buildLakebaseQuery(input: LakebaseQueryInput): IDataObject {
return `${column}.${condition}.${quotePostgrestComponent(rule.value)}`;
});
if (terms.length > 0) {
qs[input.combineConditions === 'OR' ? 'or' : 'and'] = `(${terms.join(',')})`;
}
if (terms.length === 0) return {};
return { [input.combineConditions === 'OR' ? 'or' : 'and']: `(${terms.join(',')})` };
}
/** Adds the read-only concerns, sorting and column selection, on top of the filter */
export function buildLakebaseQuery(input: LakebaseQueryInput): IDataObject {
const qs: IDataObject = buildLakebaseFilter(input);
const order = input.sort
.filter((rule) => rule.column)

View File

@ -0,0 +1,40 @@
import type { IDataObject, IExecuteFunctions, INodeExecutionData } from 'n8n-workflow';
import { NodeOperationError } from 'n8n-workflow';
import { lakebaseApiRequest } from '../../transport';
import { buildLakebaseFilter, type LakebaseWhereRule } from './conditions';
import { resolveLakebaseTableUrl } from './helpers';
export async function execute(this: IExecuteFunctions, i: number): Promise<INodeExecutionData[]> {
const url = await resolveLakebaseTableUrl(this, i);
const where = this.getNodeParameter('where.values', i, []) as LakebaseWhereRule[];
const combineConditions = this.getNodeParameter('combineConditions', i, 'AND') as 'AND' | 'OR';
const qs = buildLakebaseFilter({ where, combineConditions });
// The Data API applies no filter of its own, so an unfiltered delete empties
// the table and there is nothing to undo it. A condition row left blank
// builds nothing, which is why this tests the filter rather than the rows.
if (Object.keys(qs).length === 0) {
throw new NodeOperationError(this.getNode(), 'At least one condition is required', {
itemIndex: i,
description: 'Add a condition under Select Rows so the node knows which rows to delete.',
});
}
const response = await lakebaseApiRequest(this, {
method: 'DELETE',
url,
qs,
json: true,
headers: { Accept: 'application/json', Prefer: 'return=representation' },
});
// `return=representation` echoes the removed rows. An empty answer cannot tell
// "nothing matched" from "the project ignores the header and rows went", so report
// success as Insert does rather than return nothing and stop the branch.
const deleted = Array.isArray(response) ? (response as IDataObject[]) : [];
if (deleted.length === 0) return [{ json: { success: true }, pairedItem: { item: i } }];
return deleted.map((row) => ({ json: row, pairedItem: { item: i } }));
}

View File

@ -11,6 +11,12 @@ export const lakebaseOperations: INodeProperties = {
},
},
options: [
{
name: 'Delete',
value: 'deleteRows',
description: 'Delete the rows that match the conditions',
action: 'Delete rows',
},
{
name: 'Get Many',
value: 'getAll',

View File

@ -2,6 +2,7 @@ import type { INodeProperties } from 'n8n-workflow';
const showForLakebase = { resource: ['lakebase'] };
const showForGetAll = { resource: ['lakebase'], operation: ['getAll'] };
const showForFilter = { resource: ['lakebase'], operation: ['getAll', 'deleteRows'] };
export const lakebaseParameters: INodeProperties[] = [
{
@ -200,9 +201,10 @@ export const lakebaseParameters: INodeProperties[] = [
},
placeholder: 'Add Condition',
default: {},
description: 'If not set, the node returns all rows',
description:
'Which rows to act on. Get Many returns rows from the full table if you set no conditions. Delete needs at least one condition.',
displayOptions: {
show: showForGetAll,
show: showForFilter,
},
options: [
{
@ -280,7 +282,7 @@ export const lakebaseParameters: INodeProperties[] = [
],
default: 'AND',
displayOptions: {
show: showForGetAll,
show: showForFilter,
},
},
{

View File

@ -0,0 +1,144 @@
import type { IDataObject, IExecuteFunctions, INode, NodeParameterValueType } from 'n8n-workflow';
import { mockDeep } from 'vitest-mock-extended';
import { execute as deleteRows } from '../actions/lakebase/deleteRows.operation';
const TABLE_URL = 'https://host.example/api/2.0/workspace/7/rest/app/public/orders';
// The URL assembly belongs to the picker PR, so the operation tests pin a fixed table URL
vi.mock('../actions/lakebase/helpers', () => ({
resolveLakebaseTableUrl: vi.fn(async () => TABLE_URL),
resolveLakebaseSchemaUrl: vi.fn(async () => TABLE_URL.replace(/\/orders$/, '')),
}));
const node: INode = {
id: '1',
name: 'Databricks',
type: 'n8n-nodes-base.databricks',
typeVersion: 1,
position: [0, 0],
parameters: {},
};
const matchSku = [{ column: 'sku', condition: 'eq', value: 'WF-1' }];
describe('Lakebase -> Delete', () => {
const setupContext = (
overrides: Record<string, NodeParameterValueType | object> = {},
itemIndex = 0,
) => {
const parameters: Record<string, NodeParameterValueType | object> = {
authentication: 'oAuth2',
'where.values': matchSku,
...overrides,
};
const context = mockDeep<IExecuteFunctions>();
context.getNode.mockReturnValue(node);
context.getInputData.mockReturnValue([]);
context.getExecutionCancelSignal.mockReturnValue(undefined);
context.getNodeParameter.mockImplementation((name, index, fallback) =>
index === itemIndex || name === 'authentication' ? (parameters[name] ?? fallback) : fallback,
);
context.getCredentials.mockResolvedValue({ host: 'https://host.example' });
return context;
};
const apiMock = (context: ReturnType<typeof setupContext>) =>
context.helpers.httpRequestWithAuthentication;
const run = (
response: unknown,
overrides: Record<string, NodeParameterValueType | object> = {},
itemIndex = 0,
) => {
const context = setupContext(overrides, itemIndex);
apiMock(context).mockResolvedValue(response);
return { context, result: deleteRows.call(context, itemIndex) };
};
it('deletes the rows the conditions select', async () => {
const { context, result } = run([{ id: 1, sku: 'WF-1' }]);
expect(await result).toEqual([{ json: { id: 1, sku: 'WF-1' }, pairedItem: { item: 0 } }]);
expect(apiMock(context).mock.calls[0][0]).toBe('databricksOAuth2Api');
expect(apiMock(context).mock.calls[0][1]).toEqual(
expect.objectContaining({
method: 'DELETE',
url: TABLE_URL,
qs: { and: '(sku.eq.WF-1)' },
}),
);
});
it('asks the Data API to echo what it removed', async () => {
const { context, result } = run([{ id: 1 }]);
await result;
const options = apiMock(context).mock.calls[0][1] as { headers: Record<string, string> };
expect(options.headers.Prefer).toBe('return=representation');
});
it('combines conditions with OR when asked', async () => {
const { context, result } = run([{ id: 1 }], {
'where.values': [
{ column: 'sku', condition: 'eq', value: 'a' },
{ column: 'sku', condition: 'eq', value: 'b' },
],
combineConditions: 'OR',
});
await result;
expect((apiMock(context).mock.calls[0][1] as { qs: IDataObject }).qs).toEqual({
or: '(sku.eq.a,sku.eq.b)',
});
});
it('sends no sort or column selection', async () => {
const { context, result } = run([{ id: 1 }], {
'sort.values': [{ column: 'sku', direction: 'asc' }],
'options.outputColumns': ['sku'],
});
await result;
const { qs } = apiMock(context).mock.calls[0][1] as { qs: IDataObject };
expect(qs).toEqual({ and: '(sku.eq.WF-1)' });
});
it.each([
['no conditions at all', []],
['a condition row with no column', [{ condition: 'eq', value: 'WF-1' }]],
['several rows, none with a column', [{ condition: 'eq' }, { value: 'x' }]],
])('refuses to run with %s', async (_name, where) => {
const { context, result } = run([{ id: 1 }], { 'where.values': where });
await expect(result).rejects.toThrow('At least one condition is required');
expect(apiMock(context)).not.toHaveBeenCalled();
});
it('refuses an OR filter that builds nothing', async () => {
const { context, result } = run([{ id: 1 }], {
'where.values': [{ condition: 'eq', value: 'x' }],
combineConditions: 'OR',
});
await expect(result).rejects.toThrow('At least one condition is required');
expect(apiMock(context)).not.toHaveBeenCalled();
});
it.each([
['nothing matched', []],
['the project does not echo rows', undefined],
['the response is not an array', { ok: true }],
])('reports success when %s', async (_name, response) => {
const { result } = run(response);
expect(await result).toEqual([{ json: { success: true }, pairedItem: { item: 0 } }]);
});
it('pairs each removed row to the item it came from', async () => {
const { result } = run([{ id: 1 }, { id: 2 }], {}, 3);
expect(await result).toEqual([
{ json: { id: 1 }, pairedItem: { item: 3 } },
{ json: { id: 2 }, pairedItem: { item: 3 } },
]);
});
});

View File

@ -36,6 +36,10 @@ export const LAKEBASE_ERROR_HINTS = new Map<string, string>([
'42501',
'The Postgres role is not granted to the Data API. Run `GRANT "your-role" TO authenticator` as the role\'s creator. A project owner cannot use the Data API at all, so use a non-owner identity.',
],
[
'23503',
'If you insert or update a row, the row it points at must exist. If you delete a row, delete the rows that point at it first, or set the foreign key to cascade.',
],
['23505', 'A row with the same key already exists.'],
['23502', 'A column that cannot be null has no value.'],
]);

View File

@ -19,6 +19,31 @@ import { sleep } from '@n8n/utils/sleep';
import type { RecurringEventInstance } from './EventInterface';
export function parseAttendees(
this: IExecuteFunctions,
attendees: unknown,
itemIndex: number,
): Array<{ email: string }> {
if (
!Array.isArray(attendees) ||
!attendees.every(
(attendee: unknown): attendee is string =>
typeof attendee === 'string' &&
attendee.split(',').every((email) => email.trim().length > 0),
)
) {
throw new NodeOperationError(this.getNode(), 'Invalid attendee value', {
description:
'Set each attendee to an email address string. To include multiple addresses in one entry, separate them with commas.',
itemIndex,
});
}
return attendees.flatMap((attendee) =>
attendee.split(',').map((email) => ({ email: email.trim() })),
);
}
export async function googleApiRequest(
this: IExecuteFunctions | ILoadOptionsFunctions | IPollFunctions,
method: IHttpRequestMethods,

View File

@ -27,6 +27,7 @@ import {
googleApiRequest,
googleApiRequestAllItems,
googleApiRequestWithRetries,
parseAttendees,
type RecurrentEvent,
} from './GenericFunctions';
import { sortItemKeysByPriorityList } from '../../../utils/utilities';
@ -241,16 +242,7 @@ export class GoogleCalendar implements INodeType {
},
};
if (additionalFields.attendees) {
body.attendees = [];
(additionalFields.attendees as string[]).forEach((attendee) => {
body.attendees!.push.apply(
body.attendees,
attendee
.split(',')
.map((a) => a.trim())
.map((email) => ({ email })),
);
});
body.attendees = parseAttendees.call(this, additionalFields.attendees, i);
}
if (additionalFields.color) {
body.colorId = additionalFields.color as string;
@ -642,16 +634,7 @@ export class GoogleCalendar implements INodeType {
}
// nodeVersion < 1.2
if (updateFields.attendees) {
body.attendees = [];
(updateFields.attendees as string[]).forEach((attendee) => {
body.attendees!.push.apply(
body.attendees,
attendee
.split(',')
.map((a) => a.trim())
.map((email) => ({ email })),
);
});
body.attendees = parseAttendees.call(this, updateFields.attendees, i);
}
// nodeVersion >= 1.2
if (updateFields.attendeesUi) {
@ -663,6 +646,7 @@ export class GoogleCalendar implements INodeType {
};
}
).values;
const parsedAttendees = parseAttendees.call(this, attendees, i);
body.attendees = [];
if (mode === 'add') {
const event = await googleApiRequest.call(
@ -674,15 +658,7 @@ export class GoogleCalendar implements INodeType {
body.attendees?.push(attendee);
});
}
attendees.forEach((attendee) => {
body.attendees!.push.apply(
body.attendees,
attendee
.split(',')
.map((a) => a.trim())
.map((email) => ({ email })),
);
});
body.attendees.push(...parsedAttendees);
}
if (updateFields.color) {
body.colorId = updateFields.color as string;

View File

@ -0,0 +1,175 @@
import type { IDataObject, IExecuteFunctions, INode } from 'n8n-workflow';
import { NodeOperationError } from 'n8n-workflow';
import { mock, mockDeep } from 'vitest-mock-extended';
import * as genericFunctions from '../../GenericFunctions';
import { GoogleCalendar } from '../../GoogleCalendar.node';
describe('Google Calendar > Event > Create', () => {
const googleCalendar = new GoogleCalendar();
const createdEvent = { id: 'test-event' };
let executeFunctions: ReturnType<typeof mockDeep<IExecuteFunctions>>;
beforeEach(() => {
vi.spyOn(genericFunctions, 'googleApiRequest').mockResolvedValue(createdEvent);
executeFunctions = mockDeep<IExecuteFunctions>();
executeFunctions.getInputData.mockReturnValue([{ json: {} }]);
executeFunctions.getNode.mockReturnValue(
mock<INode>({
name: 'Google Calendar',
type: 'n8n-nodes-base.googleCalendar',
typeVersion: 1.3,
}),
);
executeFunctions.getTimezone.mockReturnValue('UTC');
executeFunctions.continueOnFail.mockReturnValue(false);
executeFunctions.helpers.returnJsonArray.mockReturnValue([{ json: createdEvent }]);
executeFunctions.helpers.constructExecutionMetaData.mockImplementation((data, { itemData }) =>
data.map((item) => ({ ...item, pairedItem: itemData })),
);
});
afterEach(() => {
vi.restoreAllMocks();
});
const setupCreate = (additionalFields: IDataObject) => {
executeFunctions.getNodeParameter
.mockReturnValueOnce('event')
.mockReturnValueOnce('create')
.mockReturnValueOnce('primary')
.mockReturnValueOnce('2026-07-18T09:00:00Z')
.mockReturnValueOnce('2026-07-18T10:00:00Z')
.mockReturnValueOnce(true)
.mockReturnValueOnce(additionalFields);
};
it('should create an event with email strings and comma-separated attendees', async () => {
setupCreate({
attendees: ['alice@example.com, bob@example.com', 'carol@example.com'],
});
await expect(googleCalendar.execute.call(executeFunctions)).resolves.toEqual([
[{ json: createdEvent, pairedItem: { item: 0 } }],
]);
expect(genericFunctions.googleApiRequest).toHaveBeenCalledWith(
'POST',
'/calendar/v3/calendars/primary/events',
expect.objectContaining({
attendees: [
{ email: 'alice@example.com' },
{ email: 'bob@example.com' },
{ email: 'carol@example.com' },
],
}),
{},
);
});
it('should create an event with an empty attendee list', async () => {
setupCreate({ attendees: [] });
await googleCalendar.execute.call(executeFunctions);
expect(genericFunctions.googleApiRequest).toHaveBeenCalledWith(
'POST',
'/calendar/v3/calendars/primary/events',
expect.objectContaining({ attendees: [] }),
{},
);
});
it.each([
{ attendees: [''] },
{ attendees: [' \t\n '] },
{ attendees: ['alice@example.com,'] },
{ attendees: [',alice@example.com'] },
{ attendees: ['alice@example.com,,bob@example.com'] },
{ attendees: ['alice@example.com, \t ,bob@example.com'] },
{ attendees: ['alice@example.com', ''] },
])('should report a node error for blank attendee entries: $attendees', async ({ attendees }) => {
setupCreate({ attendees });
const execution = googleCalendar.execute.call(executeFunctions);
await expect(execution).rejects.toBeInstanceOf(NodeOperationError);
await expect(execution).rejects.toMatchObject({
message: 'Invalid attendee value',
context: { itemIndex: 0 },
});
expect(genericFunctions.googleApiRequest).not.toHaveBeenCalled();
});
it('should report a node error when an attendee expression returns an object', async () => {
// NODE-5494: An attendee expression can return an object instead of an email string.
setupCreate({ attendees: [{ email: 'alice@example.com' }] });
const execution = googleCalendar.execute.call(executeFunctions);
await expect(execution).rejects.toBeInstanceOf(NodeOperationError);
await expect(execution).rejects.toMatchObject({
message: expect.stringMatching(/attendee/i),
context: { itemIndex: 0 },
});
expect(genericFunctions.googleApiRequest).not.toHaveBeenCalled();
});
it.each([null, 42, false, ['alice@example.com']])(
'should report a node error for a non-string attendee: %j',
async (attendee) => {
setupCreate({ attendees: [attendee] });
const execution = googleCalendar.execute.call(executeFunctions);
await expect(execution).rejects.toBeInstanceOf(NodeOperationError);
await expect(execution).rejects.toMatchObject({ context: { itemIndex: 0 } });
expect(genericFunctions.googleApiRequest).not.toHaveBeenCalled();
},
);
it.each([{ email: 'alice@example.com' }, 'alice@example.com', 42, true])(
'should report a node error when the attendee list is not an array: %j',
async (attendees) => {
setupCreate({ attendees });
await expect(googleCalendar.execute.call(executeFunctions)).rejects.toBeInstanceOf(
NodeOperationError,
);
expect(genericFunctions.googleApiRequest).not.toHaveBeenCalled();
},
);
it('should identify the input item with an invalid attendee', async () => {
executeFunctions.getInputData.mockReturnValue([{ json: {} }, { json: {} }]);
setupCreate({ attendees: ['alice@example.com'] });
executeFunctions.getNodeParameter
.mockReturnValueOnce('primary')
.mockReturnValueOnce('2026-07-18T09:00:00Z')
.mockReturnValueOnce('2026-07-18T10:00:00Z')
.mockReturnValueOnce(true)
.mockReturnValueOnce({ attendees: [{ email: 'bob@example.com' }] });
await expect(googleCalendar.execute.call(executeFunctions)).rejects.toMatchObject({
context: { itemIndex: 1 },
});
expect(genericFunctions.googleApiRequest).toHaveBeenCalledTimes(1);
});
it('should return the attendee error when continue on fail is enabled', async () => {
setupCreate({ attendees: [{ email: 'alice@example.com' }] });
executeFunctions.continueOnFail.mockReturnValue(true);
executeFunctions.helpers.returnJsonArray.mockImplementation((data) => [
{ json: data as IDataObject },
]);
await expect(googleCalendar.execute.call(executeFunctions)).resolves.toEqual([
[
{
json: { error: 'Invalid attendee value' },
pairedItem: { item: 0 },
},
],
]);
expect(genericFunctions.googleApiRequest).not.toHaveBeenCalled();
});
});

View File

@ -1,6 +1,7 @@
import type { MockProxy } from 'vitest-mock-extended';
import { mock } from 'vitest-mock-extended';
import type { IDataObject, INode, IExecuteFunctions } from 'n8n-workflow';
import { NodeOperationError } from 'n8n-workflow';
import * as genericFunctions from '../../GenericFunctions';
import { GoogleCalendar } from '../../GoogleCalendar.node';
@ -62,7 +63,10 @@ describe('Google Calendar Node', () => {
);
});
it('should update replace attendees', async () => {
it.each([
{ attendees: ['email1@mail.com'], expectedAttendees: [{ email: 'email1@mail.com' }] },
{ attendees: [], expectedAttendees: [] },
])('should update replace attendees: $attendees', async ({ attendees, expectedAttendees }) => {
mockExecuteFunctions.getInputData.mockReturnValue([{ json: {} }]);
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('event');
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('update');
@ -75,7 +79,7 @@ describe('Google Calendar Node', () => {
attendeesUi: {
values: {
mode: 'replace',
attendees: ['email1@mail.com'],
attendees,
},
},
});
@ -85,7 +89,7 @@ describe('Google Calendar Node', () => {
expect(genericFunctions.googleApiRequest).toHaveBeenCalledWith(
'PATCH',
'/calendar/v3/calendars/undefined/events/myEvent',
{ attendees: [{ email: 'email1@mail.com' }] },
{ attendees: expectedAttendees },
{},
);
});
@ -127,6 +131,44 @@ describe('Google Calendar Node', () => {
);
});
describe.each([
{ version: 1.1, mode: 'legacy' },
{ version: 1.2, mode: 'replace' },
{ version: 1.3, mode: 'add' },
])('attendee validation in version $version ($mode)', ({ version, mode }) => {
it.each([
{ attendees: [{ email: 'alice@example.com' }] },
{ attendees: [''] },
{ attendees: [' \t '] },
{ attendees: ['alice@example.com,'] },
])('should report a node error for invalid attendees: $attendees', async ({ attendees }) => {
const updateFields =
mode === 'legacy' ? { attendees } : { attendeesUi: { values: { mode, attendees } } };
mockExecuteFunctions.getInputData.mockReturnValue([{ json: {} }]);
mockExecuteFunctions.getNode.mockReturnValue(mock<INode>({ typeVersion: version }));
mockExecuteFunctions.getNodeParameter
.mockReturnValueOnce('event')
.mockReturnValueOnce('update')
.mockReturnValueOnce('myCalendar')
.mockReturnValueOnce('myEvent');
if (version >= 1.3) {
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('instance');
}
mockExecuteFunctions.getNodeParameter
.mockReturnValueOnce(true)
.mockReturnValueOnce(updateFields);
const execution = googleCalendar.execute.call(mockExecuteFunctions);
await expect(execution).rejects.toBeInstanceOf(NodeOperationError);
await expect(execution).rejects.toMatchObject({
message: expect.stringMatching(/attendee/i),
context: { itemIndex: 0 },
});
expect(genericFunctions.googleApiRequest).not.toHaveBeenCalled();
});
});
describe('all-day <-> timed conversion', () => {
const setupUpdate = (updateFields: IDataObject) => {
mockExecuteFunctions.getInputData.mockReturnValue([{ json: {} }]);

View File

@ -484,6 +484,9 @@
"DELETE /node-type-policies/policies/{policyId}": {
"status": "gap"
},
"GET /node-type-policies/scopes/{scopeId}/attachments": {
"status": "gap"
},
"PUT /node-type-policies/scopes/{scopeId}/attachments": {
"status": "gap"
},
@ -514,6 +517,9 @@
"DELETE /credential-type-policies/policies/{policyId}": {
"status": "gap"
},
"GET /credential-type-policies/scopes/{scopeId}/attachments": {
"status": "gap"
},
"PUT /credential-type-policies/scopes/{scopeId}/attachments": {
"status": "gap"
}