mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 13:39:44 +02:00
feat(core): Add REST endpoints to manage linked n8n instances (no-changelog)
Add routes to list, link, check, change and unlink linked instances for the signed-in user. Store a default project on each link and pick it from the projects that the linked instance lists. Move the shared request schemas and summary types to @n8n/api-types. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018gK4jZbBjL8toMAnrfJ4mh
This commit is contained in:
parent
2b0b6477ea
commit
408cd9b2bb
4
docs/generated/sqlite-schema/README.md
generated
4
docs/generated/sqlite-schema/README.md
generated
|
|
@ -87,7 +87,7 @@ Auto-generated from the SQLite migrations in @n8n/db. Do not edit by hand.
|
|||
| [instance_monitoring_report](instance_monitoring_report.md) | 10 | | table |
|
||||
| [instance_version_history](instance_version_history.md) | 5 | | table |
|
||||
| [invalid_auth_token](invalid_auth_token.md) | 2 | | table |
|
||||
| [linked_instance](linked_instance.md) | 9 | | table |
|
||||
| [linked_instance](linked_instance.md) | 11 | | table |
|
||||
| [mcp_registry_server](mcp_registry_server.md) | 7 | | table |
|
||||
| [migration_finding](migration_finding.md) | 10 | | table |
|
||||
| [migration_finding_sync](migration_finding_sync.md) | 3 | | table |
|
||||
|
|
@ -1168,6 +1168,8 @@ erDiagram
|
|||
"linked_instance" {
|
||||
varchar_2048_ baseUrl
|
||||
datetime_3_ createdAt
|
||||
varchar_36_ defaultRemoteProjectId
|
||||
varchar_255_ defaultRemoteProjectName
|
||||
varchar id PK
|
||||
datetime_3_ lastVerifiedAt
|
||||
varchar_64_ name
|
||||
|
|
|
|||
8
docs/generated/sqlite-schema/linked_instance.md
generated
8
docs/generated/sqlite-schema/linked_instance.md
generated
|
|
@ -6,7 +6,7 @@
|
|||
<summary><strong>Table Definition</strong></summary>
|
||||
|
||||
```sql
|
||||
CREATE TABLE "linked_instance" ("id" varchar PRIMARY KEY NOT NULL, "userId" varchar NOT NULL, "name" varchar(64) NOT NULL, "baseUrl" varchar(2048) NOT NULL, "tokenEncrypted" text NOT NULL, "status" varchar(16) NOT NULL DEFAULT ('unknown'), "lastVerifiedAt" datetime(3), "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "updatedAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), CONSTRAINT "CHK_linked_instance_status" CHECK ("status" IN ('online', 'offline', 'unauthorised', 'mcp-disabled', 'unknown')), CONSTRAINT "FK_d4eb138413c145f8eef4e603d82" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE)
|
||||
CREATE TABLE "linked_instance" ("id" varchar PRIMARY KEY NOT NULL, "userId" varchar NOT NULL, "name" varchar(64) NOT NULL, "baseUrl" varchar(2048) NOT NULL, "tokenEncrypted" text NOT NULL, "status" varchar(16) NOT NULL DEFAULT ('unknown'), "lastVerifiedAt" datetime(3), "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "updatedAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "defaultRemoteProjectId" varchar(36), "defaultRemoteProjectName" varchar(255), CONSTRAINT "CHK_linked_instance_status" CHECK (("status" IN ('online', 'offline', 'unauthorised', 'mcp-disabled', 'unknown'))), CONSTRAINT "FK_d4eb138413c145f8eef4e603d82" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)
|
||||
```
|
||||
|
||||
</details>
|
||||
|
|
@ -17,6 +17,8 @@ CREATE TABLE "linked_instance" ("id" varchar PRIMARY KEY NOT NULL, "userId" varc
|
|||
| ---- | ---- | ------- | -------- | -------- | ------- | ------- |
|
||||
| baseUrl | varchar(2048) | | false | | | |
|
||||
| createdAt | datetime(3) | STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') | false | | | |
|
||||
| defaultRemoteProjectId | varchar(36) | | true | | | |
|
||||
| defaultRemoteProjectName | varchar(255) | | true | | | |
|
||||
| id | varchar | | false | | | |
|
||||
| lastVerifiedAt | datetime(3) | | true | | | |
|
||||
| name | varchar(64) | | false | | | |
|
||||
|
|
@ -29,7 +31,7 @@ CREATE TABLE "linked_instance" ("id" varchar PRIMARY KEY NOT NULL, "userId" varc
|
|||
|
||||
| Name | Type | Definition |
|
||||
| ---- | ---- | ---------- |
|
||||
| - | CHECK | CHECK ("status" IN ('online', 'offline', 'unauthorised', 'mcp-disabled', 'unknown')) |
|
||||
| - | CHECK | CHECK (("status" IN ('online', 'offline', 'unauthorised', 'mcp-disabled', 'unknown'))) |
|
||||
| - (Foreign key ID: 0) | FOREIGN KEY | FOREIGN KEY (userId) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE |
|
||||
| id | PRIMARY KEY | PRIMARY KEY (id) |
|
||||
| sqlite_autoindex_linked_instance_1 | PRIMARY KEY | PRIMARY KEY (id) |
|
||||
|
|
@ -52,6 +54,8 @@ erDiagram
|
|||
"linked_instance" {
|
||||
varchar_2048_ baseUrl
|
||||
datetime_3_ createdAt
|
||||
varchar_36_ defaultRemoteProjectId
|
||||
varchar_255_ defaultRemoteProjectName
|
||||
varchar id PK
|
||||
datetime_3_ lastVerifiedAt
|
||||
varchar_64_ name
|
||||
|
|
|
|||
2
docs/generated/sqlite-schema/user.md
generated
2
docs/generated/sqlite-schema/user.md
generated
|
|
@ -280,6 +280,8 @@ erDiagram
|
|||
"linked_instance" {
|
||||
varchar_2048_ baseUrl
|
||||
datetime_3_ createdAt
|
||||
varchar_36_ defaultRemoteProjectId
|
||||
varchar_255_ defaultRemoteProjectName
|
||||
varchar id PK
|
||||
datetime_3_ lastVerifiedAt
|
||||
varchar_64_ name
|
||||
|
|
|
|||
|
|
@ -42,6 +42,21 @@ export {
|
|||
export { InstanceAiMcpCreateConnectionRequestDto } from './instance-ai/instance-ai-mcp-create-connection-request.dto';
|
||||
export { InstanceAiMcpUpdateConnectionRequestDto } from './instance-ai/instance-ai-mcp-update-connection-request.dto';
|
||||
|
||||
export { LinkInstanceRequestDto } from './linked-instances/link-instance-request.dto';
|
||||
export { UpdateLinkedInstanceRequestDto } from './linked-instances/update-linked-instance-request.dto';
|
||||
export {
|
||||
LINKED_INSTANCE_INPUT_MESSAGES,
|
||||
LINKED_INSTANCE_MAX_URL_LENGTH,
|
||||
LINKED_INSTANCE_STATUSES,
|
||||
linkedInstanceNameSchema,
|
||||
linkedInstanceRemoteProjectIdSchema,
|
||||
linkedInstanceTokenSchema,
|
||||
linkedInstanceUrlSchema,
|
||||
type LinkedInstanceRemoteProject,
|
||||
type LinkedInstanceStatus,
|
||||
type LinkedInstanceSummary,
|
||||
} from './linked-instances/linked-instance.schema';
|
||||
|
||||
export { BinaryDataQueryDto } from './binary-data/binary-data-query.dto';
|
||||
export { BinaryDataSignedQueryDto } from './binary-data/binary-data-signed-query.dto';
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,149 @@
|
|||
import { randomBytes } from 'node:crypto';
|
||||
|
||||
import { LinkInstanceRequestDto } from '../link-instance-request.dto';
|
||||
import { LINKED_INSTANCE_INPUT_MESSAGES as MESSAGES } from '../linked-instance.schema';
|
||||
import { UpdateLinkedInstanceRequestDto } from '../update-linked-instance-request.dto';
|
||||
|
||||
const fakeToken = () => `tok_${randomBytes(12).toString('hex')}`;
|
||||
|
||||
const firstIssue = (result: { success: boolean; error?: { issues: unknown[] } }) =>
|
||||
result.error?.issues[0];
|
||||
|
||||
describe('LinkInstanceRequestDto', () => {
|
||||
const valid = (overrides: Record<string, unknown> = {}) => ({
|
||||
name: 'Cloud',
|
||||
url: 'acme.app.n8n.cloud',
|
||||
token: fakeToken(),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it('accepts a link request and trims the name and the token', () => {
|
||||
const token = fakeToken();
|
||||
|
||||
const result = LinkInstanceRequestDto.safeParse(
|
||||
valid({ name: ' Cloud (prod) ', token: ` ${token}\n` }),
|
||||
);
|
||||
|
||||
expect(result.data).toEqual({ name: 'Cloud (prod)', url: 'acme.app.n8n.cloud', token });
|
||||
});
|
||||
|
||||
// The server normalises the address, so the DTO must not reject what users type.
|
||||
it.each(['acme.app.n8n.cloud', 'localhost:5678', 'http://127.0.0.1:5680/', ''])(
|
||||
'leaves the address %j to the server',
|
||||
(url) => {
|
||||
expect(LinkInstanceRequestDto.safeParse(valid({ url })).success).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it('accepts an address of 2048 characters and rejects a longer one', () => {
|
||||
const at = (length: number) => `https://a.example/${'a'.repeat(length - 18)}`;
|
||||
|
||||
expect(LinkInstanceRequestDto.safeParse(valid({ url: at(2048) })).success).toBe(true);
|
||||
const result = LinkInstanceRequestDto.safeParse(valid({ url: at(2049) }));
|
||||
expect(firstIssue(result)).toMatchObject({ path: ['url'], message: MESSAGES.url });
|
||||
});
|
||||
|
||||
it.each([
|
||||
['an empty name', ''],
|
||||
['a name of only spaces', ' '],
|
||||
['a name over 64 characters', 'a'.repeat(65)],
|
||||
['markup', '<b>Cloud</b>'],
|
||||
['a line break', 'Cloud\nprod'],
|
||||
['a quote', 'Cloud "prod"'],
|
||||
])('rejects %s with the en-GB name message', (_, name) => {
|
||||
const result = LinkInstanceRequestDto.safeParse(valid({ name }));
|
||||
|
||||
expect(firstIssue(result)).toMatchObject({ path: ['name'], message: MESSAGES.name });
|
||||
});
|
||||
|
||||
it.each(['Café Cloud', 'prod-eu_2 (v1.0)', 'a'.repeat(64)])('accepts the name %j', (name) => {
|
||||
expect(LinkInstanceRequestDto.safeParse(valid({ name })).data?.name).toBe(name);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['an empty token', ''],
|
||||
['a token over 4096 characters', 'a'.repeat(4097)],
|
||||
['a token with a space inside', 'abc def'],
|
||||
['a token with an accented letter', 'tokén'],
|
||||
['a token with a zero-width space inside', 'abcdef'],
|
||||
])('rejects %s with the en-GB token message', (_, token) => {
|
||||
const result = LinkInstanceRequestDto.safeParse(valid({ token }));
|
||||
|
||||
expect(firstIssue(result)).toMatchObject({ path: ['token'], message: MESSAGES.token });
|
||||
});
|
||||
|
||||
it('accepts a token of 4096 characters', () => {
|
||||
const token = 'a'.repeat(4096);
|
||||
|
||||
expect(LinkInstanceRequestDto.safeParse(valid({ token })).data?.token).toBe(token);
|
||||
});
|
||||
|
||||
it('never repeats the token in a validation issue', () => {
|
||||
const token = `${fakeToken()} ${fakeToken()}`;
|
||||
|
||||
const result = LinkInstanceRequestDto.safeParse(valid({ token }));
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(JSON.stringify(result.error)).not.toContain(token.split(' ')[0]);
|
||||
});
|
||||
|
||||
it.each(['name', 'url', 'token'])('requires %s', (field) => {
|
||||
const body: Record<string, unknown> = valid();
|
||||
delete body[field];
|
||||
|
||||
expect(firstIssue(LinkInstanceRequestDto.safeParse(body))).toMatchObject({ path: [field] });
|
||||
});
|
||||
|
||||
it('drops fields that only the server sets', () => {
|
||||
const result = LinkInstanceRequestDto.safeParse(
|
||||
valid({ status: 'online', tokenEncrypted: 'x', defaultRemoteProjectId: 'p1' }),
|
||||
);
|
||||
|
||||
expect(Object.keys(result.data ?? {}).sort()).toEqual(['name', 'token', 'url']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('UpdateLinkedInstanceRequestDto', () => {
|
||||
it('accepts each change alone, and no change at all', () => {
|
||||
const token = fakeToken();
|
||||
|
||||
expect(UpdateLinkedInstanceRequestDto.safeParse({ name: ' Laptop ' }).data).toEqual({
|
||||
name: 'Laptop',
|
||||
});
|
||||
expect(UpdateLinkedInstanceRequestDto.safeParse({ token }).data).toEqual({ token });
|
||||
expect(
|
||||
UpdateLinkedInstanceRequestDto.safeParse({ defaultRemoteProjectId: 'Ab3_x-9' }).data,
|
||||
).toEqual({ defaultRemoteProjectId: 'Ab3_x-9' });
|
||||
expect(UpdateLinkedInstanceRequestDto.safeParse({}).data).toEqual({});
|
||||
});
|
||||
|
||||
it('applies the link rules to the name and the token', () => {
|
||||
expect(firstIssue(UpdateLinkedInstanceRequestDto.safeParse({ name: '' }))).toMatchObject({
|
||||
message: MESSAGES.name,
|
||||
});
|
||||
expect(firstIssue(UpdateLinkedInstanceRequestDto.safeParse({ token: 'a b' }))).toMatchObject({
|
||||
message: MESSAGES.token,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
['an empty id', ''],
|
||||
['an id over 36 characters', 'a'.repeat(37)],
|
||||
['an id with a slash', '../projects'],
|
||||
['an id with a space', 'a b'],
|
||||
['null', null],
|
||||
])('rejects %s as the default project', (_, defaultRemoteProjectId) => {
|
||||
const result = UpdateLinkedInstanceRequestDto.safeParse({ defaultRemoteProjectId });
|
||||
|
||||
expect(firstIssue(result)).toMatchObject({ path: ['defaultRemoteProjectId'] });
|
||||
});
|
||||
|
||||
it.each(['a', 'a'.repeat(36), '6bd0d6a4-8f43-4a8e-a5b6-1c2d3e4f5a6b'])(
|
||||
'accepts the project id %j',
|
||||
(defaultRemoteProjectId) => {
|
||||
expect(UpdateLinkedInstanceRequestDto.safeParse({ defaultRemoteProjectId }).success).toBe(
|
||||
true,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
import { Z } from '../../zod-class';
|
||||
import {
|
||||
linkedInstanceNameSchema,
|
||||
linkedInstanceTokenSchema,
|
||||
linkedInstanceUrlSchema,
|
||||
} from './linked-instance.schema';
|
||||
|
||||
export class LinkInstanceRequestDto extends Z.class({
|
||||
name: linkedInstanceNameSchema,
|
||||
url: linkedInstanceUrlSchema,
|
||||
token: linkedInstanceTokenSchema,
|
||||
}) {}
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
// The name shows in prompts and in the UI, so it has no markup or line breaks.
|
||||
const NAME_PATTERN = /^[\p{L}\p{N} ._()-]+$/u;
|
||||
// A header value can hold only visible ASCII characters.
|
||||
const TOKEN_PATTERN = /^[\x21-\x7E]+$/;
|
||||
// n8n project ids are nanoids or UUIDs. Other characters are not sent back to the instance.
|
||||
const REMOTE_PROJECT_ID_PATTERN = /^[A-Za-z0-9_-]+$/;
|
||||
|
||||
export const LINKED_INSTANCE_MAX_URL_LENGTH = 2048;
|
||||
|
||||
/** en-GB messages. They never repeat the input, so they cannot leak the token. */
|
||||
export const LINKED_INSTANCE_INPUT_MESSAGES = {
|
||||
name: 'Enter a name of 1 to 64 characters. Use only letters, digits, spaces and these characters: - _ . ( )',
|
||||
url: 'That address is not valid. Check it and try again.',
|
||||
token:
|
||||
'Enter an access token of 1 to 4096 characters. Use only the letters A to Z, digits and symbols, without spaces or accented characters.',
|
||||
defaultRemoteProjectId: 'Choose a project that this access token can see in that instance.',
|
||||
} as const;
|
||||
|
||||
const messages = LINKED_INSTANCE_INPUT_MESSAGES;
|
||||
|
||||
export const linkedInstanceNameSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, messages.name)
|
||||
.max(64, messages.name)
|
||||
.regex(NAME_PATTERN, messages.name);
|
||||
|
||||
/** Only the length: the server reads the address and names each problem in its own message. */
|
||||
export const linkedInstanceUrlSchema = z.string().max(LINKED_INSTANCE_MAX_URL_LENGTH, messages.url);
|
||||
|
||||
export const linkedInstanceTokenSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, messages.token)
|
||||
.max(4096, messages.token)
|
||||
.regex(TOKEN_PATTERN, messages.token);
|
||||
|
||||
export const linkedInstanceRemoteProjectIdSchema = z
|
||||
.string()
|
||||
.min(1, messages.defaultRemoteProjectId)
|
||||
.max(36, messages.defaultRemoteProjectId)
|
||||
.regex(REMOTE_PROJECT_ID_PATTERN, messages.defaultRemoteProjectId);
|
||||
|
||||
export const LINKED_INSTANCE_STATUSES = [
|
||||
'online',
|
||||
'offline',
|
||||
'unauthorised',
|
||||
'mcp-disabled',
|
||||
'unknown',
|
||||
] as const;
|
||||
|
||||
/** Result of the last check of a linked instance. */
|
||||
export type LinkedInstanceStatus = (typeof LINKED_INSTANCE_STATUSES)[number];
|
||||
|
||||
/** A project on the linked instance, as that instance named it. */
|
||||
export type LinkedInstanceRemoteProject = { id: string; name: string };
|
||||
|
||||
/** What a user can see of a linked instance. It never holds the access token. */
|
||||
export type LinkedInstanceSummary = {
|
||||
id: string;
|
||||
name: string;
|
||||
baseUrl: string;
|
||||
status: LinkedInstanceStatus;
|
||||
lastVerifiedAt: string | null;
|
||||
createdAt: string;
|
||||
/** Where new automations go on the linked instance. `null` when the instance listed no project. */
|
||||
defaultRemoteProject: LinkedInstanceRemoteProject | null;
|
||||
};
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
import { Z } from '../../zod-class';
|
||||
import {
|
||||
linkedInstanceNameSchema,
|
||||
linkedInstanceRemoteProjectIdSchema,
|
||||
linkedInstanceTokenSchema,
|
||||
} from './linked-instance.schema';
|
||||
|
||||
/** Each field is optional. The server rejects a request that changes nothing. */
|
||||
export class UpdateLinkedInstanceRequestDto extends Z.class({
|
||||
name: linkedInstanceNameSchema.optional(),
|
||||
token: linkedInstanceTokenSchema.optional(),
|
||||
defaultRemoteProjectId: linkedInstanceRemoteProjectIdSchema.optional(),
|
||||
}) {}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
import type { MigrationContext, ReversibleMigration } from '../migration-types';
|
||||
|
||||
const TABLE_NAME = 'linked_instance';
|
||||
const COLUMN_NAMES = ['defaultRemoteProjectId', 'defaultRemoteProjectName'];
|
||||
|
||||
// No other table refers to `linked_instance`, so the table copy on SQLite deletes no rows elsewhere.
|
||||
export class AddDefaultRemoteProjectToLinkedInstance1791416459011 implements ReversibleMigration {
|
||||
async up({ schemaBuilder: { addColumns, column } }: MigrationContext) {
|
||||
await addColumns(
|
||||
TABLE_NAME,
|
||||
[
|
||||
column('defaultRemoteProjectId')
|
||||
.varchar(36)
|
||||
.comment(
|
||||
'Id of the project on the linked instance that gets new automations. NULL when the instance listed no project.',
|
||||
),
|
||||
column('defaultRemoteProjectName')
|
||||
.varchar(255)
|
||||
.comment('Name of that project when it was last read from the linked instance'),
|
||||
],
|
||||
{ recreatesOnSqlite: true },
|
||||
);
|
||||
}
|
||||
|
||||
async down({ schemaBuilder: { dropColumns } }: MigrationContext) {
|
||||
await dropColumns(TABLE_NAME, COLUMN_NAMES, { recreatesOnSqlite: true });
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,152 @@
|
|||
import { randomBytes } from 'node:crypto';
|
||||
|
||||
import { LinkInstanceRequestDto, UpdateLinkedInstanceRequestDto } from '@n8n/api-types';
|
||||
import { BadRequestError } from '@n8n/errors';
|
||||
import fc from 'fast-check';
|
||||
|
||||
import {
|
||||
LINK_INPUT_MESSAGES,
|
||||
parseLinkInput,
|
||||
parseLinkUpdate,
|
||||
type LinkUpdateInput,
|
||||
} from '../link-input';
|
||||
|
||||
const ADDRESS = 'https://acme.app.n8n.cloud';
|
||||
const FIXED_MESSAGES: string[] = Object.values(LINK_INPUT_MESSAGES);
|
||||
|
||||
// Valid and invalid characters for names, with lengths around the limit of 64.
|
||||
const nameArb = fc.oneof(
|
||||
fc.string({ unit: 'binary', maxLength: 70 }),
|
||||
fc
|
||||
.array(
|
||||
fc.constantFrom(
|
||||
...['a', 'Z', 'é', '運', '7', ' ', '.', '_', '(', ')', '-'],
|
||||
...['\n', '\t', '<', '"', '\u00a0', '\u200b', '🚀'],
|
||||
),
|
||||
{ maxLength: 70 },
|
||||
)
|
||||
.map((characters) => characters.join('')),
|
||||
);
|
||||
|
||||
const visibleAsciiArb = fc.string({ unit: 'binary-ascii', maxLength: 40 });
|
||||
|
||||
// Valid and invalid tokens, also near the limit of 4096 characters and with outer whitespace.
|
||||
const tokenArb = fc.oneof(
|
||||
visibleAsciiArb,
|
||||
fc
|
||||
.tuple(fc.constantFrom('', ' ', '\n', '\t '), visibleAsciiArb, fc.constantFrom('', ' ', '\r\n'))
|
||||
.map(([before, token, after]) => `${before}${token}${after}`),
|
||||
fc.integer({ min: 4090, max: 4100 }).map((length) => 'a'.repeat(length)),
|
||||
fc.string({ unit: 'binary', maxLength: 20 }),
|
||||
);
|
||||
|
||||
const projectIdArb = fc.oneof(
|
||||
fc.stringMatching(/^[A-Za-z0-9_-]{1,40}$/),
|
||||
fc.string({ maxLength: 40 }),
|
||||
fc.constant(''),
|
||||
);
|
||||
|
||||
const updateArb: fc.Arbitrary<LinkUpdateInput> = fc.record(
|
||||
{ name: nameArb, token: tokenArb, defaultRemoteProjectId: projectIdArb },
|
||||
{ requiredKeys: [] },
|
||||
);
|
||||
|
||||
const sentKeys = (input: LinkUpdateInput) =>
|
||||
Object.entries(input)
|
||||
.filter(([, value]) => value !== undefined)
|
||||
.map(([key]) => key)
|
||||
.sort();
|
||||
|
||||
function settle<T>(run: () => T): { value?: T; error?: unknown } {
|
||||
try {
|
||||
return { value: run() };
|
||||
} catch (error) {
|
||||
return { error };
|
||||
}
|
||||
}
|
||||
|
||||
describe('link input properties', () => {
|
||||
it('keeps exactly the sent fields, or names the problem in a fixed message', () => {
|
||||
fc.assert(
|
||||
fc.property(updateArb, (input) => {
|
||||
const { value, error } = settle(() => parseLinkUpdate(input));
|
||||
|
||||
if (error) {
|
||||
expect(error).toBeInstanceOf(BadRequestError);
|
||||
expect(FIXED_MESSAGES).toContain((error as Error).message);
|
||||
return;
|
||||
}
|
||||
expect(Object.keys(value ?? {}).sort()).toEqual(sentKeys(input));
|
||||
if (input.name !== undefined) expect(value?.name).toBe(input.name.trim());
|
||||
if (input.token !== undefined) expect(value?.token).toBe(input.token.trim());
|
||||
if (input.defaultRemoteProjectId !== undefined) {
|
||||
expect(value?.defaultRemoteProjectId).toBe(input.defaultRemoteProjectId);
|
||||
}
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects only a change that changes nothing with the no-change message', () => {
|
||||
fc.assert(
|
||||
fc.property(updateArb, (input) => {
|
||||
const { error } = settle(() => parseLinkUpdate(input));
|
||||
|
||||
const isNoChange = (error as Error | undefined)?.message === LINK_INPUT_MESSAGES.noChange;
|
||||
expect(isNoChange).toBe(sentKeys(input).length === 0);
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('never repeats a rejected token in the error', () => {
|
||||
const rejectedTokenArb = fc
|
||||
.tuple(fc.constantFrom(' ', 'é', '\u0007', '\u200b'), fc.integer({ min: 1, max: 20 }))
|
||||
.map(([bad, at]) => {
|
||||
const valid = `tok_${randomBytes(12).toString('hex')}`;
|
||||
const parts = [valid.slice(0, at), valid.slice(at)];
|
||||
return { token: parts.join(bad), parts };
|
||||
});
|
||||
|
||||
fc.assert(
|
||||
fc.property(rejectedTokenArb, ({ token, parts }) => {
|
||||
const { error } = settle(() => parseLinkUpdate({ token }));
|
||||
|
||||
expect(error).toBeInstanceOf(BadRequestError);
|
||||
const text = JSON.stringify({ message: (error as Error).message, error });
|
||||
for (const part of parts) {
|
||||
if (part.length >= 4) expect(text).not.toContain(part);
|
||||
}
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts the same name and token as the link request DTO, with the same result', () => {
|
||||
fc.assert(
|
||||
fc.property(nameArb, tokenArb, (name, token) => {
|
||||
const dto = LinkInstanceRequestDto.safeParse({ name, url: ADDRESS, token });
|
||||
const service = settle(() => parseLinkInput({ name, address: ADDRESS, token }));
|
||||
|
||||
expect(dto.success).toBe(service.error === undefined);
|
||||
if (dto.success) {
|
||||
expect(service.value).toEqual({
|
||||
name: dto.data.name,
|
||||
origin: ADDRESS,
|
||||
token: dto.data.token,
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts the same change as the update request DTO, with the same result', () => {
|
||||
fc.assert(
|
||||
fc.property(updateArb, (input) => {
|
||||
fc.pre(sentKeys(input).length > 0);
|
||||
const dto = UpdateLinkedInstanceRequestDto.safeParse(input);
|
||||
const service = settle(() => parseLinkUpdate(input));
|
||||
|
||||
expect(dto.success).toBe(service.error === undefined);
|
||||
if (dto.success) expect(service.value).toEqual(dto.data);
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,125 @@
|
|||
import { randomBytes } from 'node:crypto';
|
||||
|
||||
import { LINKED_INSTANCE_INPUT_MESSAGES } from '@n8n/api-types';
|
||||
import { BadRequestError } from '@n8n/errors';
|
||||
|
||||
import {
|
||||
ADDRESS_ERROR_MESSAGES,
|
||||
LINK_INPUT_MESSAGES,
|
||||
parseLinkName,
|
||||
parseLinkToken,
|
||||
parseLinkUpdate,
|
||||
} from '../link-input';
|
||||
|
||||
const fakeToken = () => `tok_${randomBytes(12).toString('hex')}`;
|
||||
|
||||
function expectBadRequest(run: () => unknown, message: string) {
|
||||
let error: unknown;
|
||||
try {
|
||||
run();
|
||||
} catch (e) {
|
||||
error = e;
|
||||
}
|
||||
expect(error).toBeInstanceOf(BadRequestError);
|
||||
expect(error).toHaveProperty('message', message);
|
||||
}
|
||||
|
||||
describe('link input messages', () => {
|
||||
it('shares the messages that the request DTOs give', () => {
|
||||
expect(LINK_INPUT_MESSAGES.name).toBe(LINKED_INSTANCE_INPUT_MESSAGES.name);
|
||||
expect(LINK_INPUT_MESSAGES.token).toBe(LINKED_INSTANCE_INPUT_MESSAGES.token);
|
||||
expect(LINK_INPUT_MESSAGES.defaultRemoteProjectId).toBe(
|
||||
LINKED_INSTANCE_INPUT_MESSAGES.defaultRemoteProjectId,
|
||||
);
|
||||
expect(ADDRESS_ERROR_MESSAGES.invalid).toBe(LINKED_INSTANCE_INPUT_MESSAGES.url);
|
||||
});
|
||||
|
||||
it('names each problem in en-GB', () => {
|
||||
expect(LINK_INPUT_MESSAGES).toEqual({
|
||||
name: 'Enter a name of 1 to 64 characters. Use only letters, digits, spaces and these characters: - _ . ( )',
|
||||
token:
|
||||
'Enter an access token of 1 to 4096 characters. Use only the letters A to Z, digits and symbols, without spaces or accented characters.',
|
||||
defaultRemoteProjectId: 'Choose a project that this access token can see in that instance.',
|
||||
noChange: 'Change the name, the access token or the default project.',
|
||||
});
|
||||
expect(ADDRESS_ERROR_MESSAGES).toEqual({
|
||||
empty: 'Enter the address of the n8n instance.',
|
||||
invalid: 'That address is not valid. Check it and try again.',
|
||||
'unsupported-protocol': 'Enter an address that starts with https://',
|
||||
'insecure-http':
|
||||
'Use https:// for this address. Plain http:// works only for an instance on this computer.',
|
||||
'has-credentials': 'Remove the user name and password from the address.',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseLinkName', () => {
|
||||
it('trims a valid name', () => {
|
||||
expect(parseLinkName(' Cloud (prod) ')).toBe('Cloud (prod)');
|
||||
});
|
||||
|
||||
it.each(['', '<b>Cloud</b>', 'a'.repeat(65), 42, undefined])('rejects %j', (name) => {
|
||||
expectBadRequest(() => parseLinkName(name), LINK_INPUT_MESSAGES.name);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseLinkToken', () => {
|
||||
it('trims a valid token', () => {
|
||||
const token = fakeToken();
|
||||
|
||||
expect(parseLinkToken(`\n${token} `)).toBe(token);
|
||||
});
|
||||
|
||||
it.each(['', 'a b', 'tokén', 'a'.repeat(4097), 42, undefined])('rejects %j', (token) => {
|
||||
expectBadRequest(() => parseLinkToken(token), LINK_INPUT_MESSAGES.token);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseLinkUpdate', () => {
|
||||
it('keeps only the fields that the user sent, cleaned', () => {
|
||||
const token = fakeToken();
|
||||
|
||||
expect(parseLinkUpdate({ name: ' Laptop ' })).toEqual({ name: 'Laptop' });
|
||||
expect(parseLinkUpdate({ token: ` ${token}` })).toEqual({ token });
|
||||
expect(parseLinkUpdate({ defaultRemoteProjectId: 'Xk3pQ9aZ' })).toEqual({
|
||||
defaultRemoteProjectId: 'Xk3pQ9aZ',
|
||||
});
|
||||
expect(parseLinkUpdate({ name: 'Laptop', token, defaultRemoteProjectId: 'Xk3pQ9aZ' })).toEqual({
|
||||
name: 'Laptop',
|
||||
token,
|
||||
defaultRemoteProjectId: 'Xk3pQ9aZ',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a change that changes nothing', () => {
|
||||
expectBadRequest(() => parseLinkUpdate({}), LINK_INPUT_MESSAGES.noChange);
|
||||
expectBadRequest(
|
||||
() => parseLinkUpdate({ name: undefined, token: undefined }),
|
||||
LINK_INPUT_MESSAGES.noChange,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a name, a token or a project id that is not valid', () => {
|
||||
expectBadRequest(() => parseLinkUpdate({ name: 'a\nb' }), LINK_INPUT_MESSAGES.name);
|
||||
expectBadRequest(() => parseLinkUpdate({ token: 'a b' }), LINK_INPUT_MESSAGES.token);
|
||||
expectBadRequest(
|
||||
() => parseLinkUpdate({ defaultRemoteProjectId: '../x' }),
|
||||
LINK_INPUT_MESSAGES.defaultRemoteProjectId,
|
||||
);
|
||||
expectBadRequest(
|
||||
() => parseLinkUpdate({ defaultRemoteProjectId: '' }),
|
||||
LINK_INPUT_MESSAGES.defaultRemoteProjectId,
|
||||
);
|
||||
});
|
||||
|
||||
it('checks every sent field, also when another field is valid', () => {
|
||||
expectBadRequest(
|
||||
() => parseLinkUpdate({ name: 'Laptop', token: 'a b' }),
|
||||
LINK_INPUT_MESSAGES.token,
|
||||
);
|
||||
expectBadRequest(
|
||||
() => parseLinkUpdate({ token: fakeToken(), defaultRemoteProjectId: 'a/b' }),
|
||||
LINK_INPUT_MESSAGES.defaultRemoteProjectId,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -13,6 +13,7 @@ import {
|
|||
} from '../database/repositories/linked-instance.repository';
|
||||
|
||||
const MIGRATION_NAME = 'CreateLinkedInstanceTable1791401960379';
|
||||
const COLUMNS_MIGRATION_NAME = 'AddDefaultRemoteProjectToLinkedInstance1791416459011';
|
||||
|
||||
let repository: LinkedInstanceRepository;
|
||||
let alice: User;
|
||||
|
|
@ -165,21 +166,76 @@ describe('LinkedInstanceRepository', () => {
|
|||
await expect(repository.deleteForUser(alice.id, link.id)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("updates the status of only the user's link", async () => {
|
||||
it('stores the default remote project, and leaves it empty when not given', async () => {
|
||||
const withProject = await createLink(alice.id, {
|
||||
defaultRemoteProjectId: 'Xk3pQ9aZ1bC2dE4f',
|
||||
defaultRemoteProjectName: 'Ops',
|
||||
});
|
||||
const withoutProject = await createLink(alice.id, { baseUrl: 'http://localhost:5678' });
|
||||
|
||||
await expect(repository.findForUser(alice.id, withProject.id)).resolves.toMatchObject({
|
||||
defaultRemoteProjectId: 'Xk3pQ9aZ1bC2dE4f',
|
||||
defaultRemoteProjectName: 'Ops',
|
||||
});
|
||||
await expect(repository.findForUser(alice.id, withoutProject.id)).resolves.toMatchObject({
|
||||
defaultRemoteProjectId: null,
|
||||
defaultRemoteProjectName: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("updates only the given columns of only the user's link", async () => {
|
||||
const link = await createLink(alice.id, {
|
||||
defaultRemoteProjectId: 'Xk3pQ9aZ1bC2dE4f',
|
||||
defaultRemoteProjectName: 'Ops',
|
||||
});
|
||||
const otherLink = await createLink(alice.id, { baseUrl: 'http://localhost:5678' });
|
||||
|
||||
await expect(repository.updateForUser(bob.id, link.id, { name: 'Taken' })).resolves.toBe(false);
|
||||
await expect(
|
||||
repository.updateForUser(alice.id, link.id, {
|
||||
name: 'Cloud EU',
|
||||
tokenEncrypted: 'new ciphertext',
|
||||
defaultRemoteProjectId: null,
|
||||
defaultRemoteProjectName: null,
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
|
||||
await expect(repository.findForUser(alice.id, link.id)).resolves.toMatchObject({
|
||||
name: 'Cloud EU',
|
||||
baseUrl: 'https://acme.app.n8n.cloud',
|
||||
tokenEncrypted: 'new ciphertext',
|
||||
status: 'online',
|
||||
lastVerifiedAt: new Date('2026-10-07T12:00:00.000Z'),
|
||||
defaultRemoteProjectId: null,
|
||||
defaultRemoteProjectName: null,
|
||||
});
|
||||
await expect(repository.findForUser(alice.id, otherLink.id)).resolves.toMatchObject({
|
||||
name: 'Cloud',
|
||||
tokenEncrypted: 'ciphertext',
|
||||
});
|
||||
});
|
||||
|
||||
it('reports whether the link exists when an update has no values', async () => {
|
||||
const link = await createLink(alice.id);
|
||||
|
||||
await expect(repository.updateForUser(alice.id, link.id, {})).resolves.toBe(true);
|
||||
await expect(repository.updateForUser(bob.id, link.id, {})).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("records a check on only the user's link", async () => {
|
||||
const link = await createLink(alice.id);
|
||||
const checkedAt = new Date('2026-10-08T08:30:00.000Z');
|
||||
const check = { status: 'unauthorised', lastVerifiedAt: checkedAt } as const;
|
||||
|
||||
await expect(repository.updateStatus(bob.id, link.id, 'offline', checkedAt)).resolves.toBe(
|
||||
false,
|
||||
);
|
||||
await expect(repository.updateForUser(bob.id, link.id, check)).resolves.toBe(false);
|
||||
await expect(repository.findForUser(alice.id, link.id)).resolves.toMatchObject({
|
||||
status: 'online',
|
||||
});
|
||||
|
||||
await expect(
|
||||
repository.updateStatus(alice.id, link.id, 'unauthorised', checkedAt),
|
||||
).resolves.toBe(true);
|
||||
await expect(repository.updateForUser(alice.id, link.id, check)).resolves.toBe(true);
|
||||
await expect(repository.findForUser(alice.id, link.id)).resolves.toMatchObject({
|
||||
name: 'Cloud',
|
||||
tokenEncrypted: 'ciphertext',
|
||||
status: 'unauthorised',
|
||||
lastVerifiedAt: checkedAt,
|
||||
});
|
||||
|
|
@ -205,7 +261,7 @@ type WrappedMigration = new () => {
|
|||
down(queryRunner: QueryRunner): Promise<void>;
|
||||
};
|
||||
|
||||
describe(`${MIGRATION_NAME} migration`, () => {
|
||||
describe('linked_instance migrations', () => {
|
||||
async function withQueryRunner(fn: (queryRunner: QueryRunner) => Promise<void>) {
|
||||
const queryRunner = Container.get(DataSource).createQueryRunner();
|
||||
try {
|
||||
|
|
@ -215,37 +271,77 @@ describe(`${MIGRATION_NAME} migration`, () => {
|
|||
}
|
||||
}
|
||||
|
||||
it('ran with the test database, and its down and up steps drop and create the table', async () => {
|
||||
async function loadMigration(name: string) {
|
||||
const dataSource = Container.get(DataSource);
|
||||
const { tablePrefix } = Container.get(GlobalConfig).database;
|
||||
const executed = await dataSource.query<MigrationRow[]>(
|
||||
`SELECT name FROM ${dataSource.driver.escape(`${tablePrefix}migrations`)}`,
|
||||
);
|
||||
expect(executed.map(({ name }) => name)).toContain(MIGRATION_NAME);
|
||||
expect(executed.map((row) => row.name)).toContain(name);
|
||||
|
||||
const MigrationClass = (dataSource.options.migrations as WrappedMigration[]).find(
|
||||
(migration) => migration.name === MIGRATION_NAME,
|
||||
(migration) => migration.name === name,
|
||||
);
|
||||
if (!MigrationClass) throw new Error(`The DataSource has no ${MIGRATION_NAME}`);
|
||||
if (!MigrationClass) throw new Error(`The DataSource has no ${name}`);
|
||||
// A copied template database (CI) skips the migration run that wraps the classes,
|
||||
// and only a wrapped class accepts a query runner. A second wrap does nothing.
|
||||
wrapMigration(MigrationClass);
|
||||
const migration = new MigrationClass();
|
||||
const tableName = `${tablePrefix}linked_instance`;
|
||||
return new MigrationClass();
|
||||
}
|
||||
|
||||
const tableName = () => `${Container.get(GlobalConfig).database.tablePrefix}linked_instance`;
|
||||
|
||||
it(`${MIGRATION_NAME} ran, and its down and up steps drop and create the table`, async () => {
|
||||
const createTable = await loadMigration(MIGRATION_NAME);
|
||||
const addColumns = await loadMigration(COLUMNS_MIGRATION_NAME);
|
||||
|
||||
await withQueryRunner(async (queryRunner) => {
|
||||
await migration.down(queryRunner);
|
||||
// The later migration goes down first and comes back last, as in a real rollback.
|
||||
await addColumns.down(queryRunner);
|
||||
await createTable.down(queryRunner);
|
||||
try {
|
||||
expect(await queryRunner.hasTable(tableName)).toBe(false);
|
||||
expect(await queryRunner.hasTable(tableName())).toBe(false);
|
||||
} finally {
|
||||
// Re-create the table also when the check fails, so later tests have it.
|
||||
await migration.up(queryRunner);
|
||||
await createTable.up(queryRunner);
|
||||
await addColumns.up(queryRunner);
|
||||
}
|
||||
expect(await queryRunner.hasTable(tableName)).toBe(true);
|
||||
expect(await queryRunner.hasTable(tableName())).toBe(true);
|
||||
});
|
||||
|
||||
// The re-created table keeps the unique rule.
|
||||
await createLink(alice.id);
|
||||
await expect(repository.createForUser(newLink(alice.id))).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it(`${COLUMNS_MIGRATION_NAME} adds the default project columns and keeps the rows and the rules`, async () => {
|
||||
const migration = await loadMigration(COLUMNS_MIGRATION_NAME);
|
||||
const link = await createLink(alice.id, {
|
||||
defaultRemoteProjectId: 'Xk3pQ9aZ1bC2dE4f',
|
||||
defaultRemoteProjectName: 'Ops',
|
||||
});
|
||||
|
||||
await withQueryRunner(async (queryRunner) => {
|
||||
await migration.down(queryRunner);
|
||||
try {
|
||||
expect(await queryRunner.hasColumn(tableName(), 'defaultRemoteProjectId')).toBe(false);
|
||||
expect(await queryRunner.hasColumn(tableName(), 'defaultRemoteProjectName')).toBe(false);
|
||||
} finally {
|
||||
await migration.up(queryRunner);
|
||||
}
|
||||
});
|
||||
|
||||
// The row stays, and the new columns start empty.
|
||||
await expect(repository.findForUser(alice.id, link.id)).resolves.toMatchObject({
|
||||
name: 'Cloud',
|
||||
tokenEncrypted: 'ciphertext',
|
||||
defaultRemoteProjectId: null,
|
||||
defaultRemoteProjectName: null,
|
||||
});
|
||||
// The copy of the table on SQLite keeps the unique rule and the user foreign key.
|
||||
await expect(repository.createForUser(newLink(alice.id))).resolves.toBeNull();
|
||||
await expect(repository.createForUser(newLink(randomUUID()))).rejects.toThrow();
|
||||
await Container.get(UserRepository).delete({ id: alice.id });
|
||||
await expect(repository.listForUser(alice.id)).resolves.toEqual([]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,463 @@
|
|||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { LINKED_INSTANCE_INPUT_MESSAGES } from '@n8n/api-types';
|
||||
import { mockInstance } from '@n8n/backend-test-utils';
|
||||
import type { User } from '@n8n/db';
|
||||
import { Container } from '@n8n/di';
|
||||
import { createChatUser, createMember } from '@test-integration/db/users';
|
||||
import type { SuperAgentTest } from '@test-integration/types';
|
||||
import * as utils from '@test-integration/utils';
|
||||
import { mock, type MockProxy } from 'vitest-mock-extended';
|
||||
|
||||
import { LinkedInstanceRepository } from '../database/repositories/linked-instance.repository';
|
||||
import { ADDRESS_ERROR_MESSAGES, LINK_INPUT_MESSAGES } from '../link-input';
|
||||
import {
|
||||
DUPLICATE_LINK_MESSAGE,
|
||||
LINK_NOT_FOUND_MESSAGE,
|
||||
LinkedInstancesService,
|
||||
PROBE_FAILURE_MESSAGES,
|
||||
REMOTE_PROJECTS_MESSAGES,
|
||||
} from '../linked-instances.service';
|
||||
import {
|
||||
RemoteInstanceClientFactory,
|
||||
type RemoteInstanceClient,
|
||||
} from '../remote/remote-instance.client';
|
||||
import {
|
||||
CLOUD,
|
||||
fakeToken,
|
||||
OPS,
|
||||
PERSONAL,
|
||||
SALES,
|
||||
searchProjectsOutput,
|
||||
} from './linked-instances.test-helpers';
|
||||
|
||||
const clientFactory = mockInstance(RemoteInstanceClientFactory);
|
||||
|
||||
const testServer = utils.setupTestServer({
|
||||
endpointGroups: ['linked-instances'],
|
||||
modules: ['linked-instances'],
|
||||
});
|
||||
|
||||
const FAILURES = ['unreachable', 'mcp-disabled', 'unauthorised'] as const;
|
||||
|
||||
/** Every token and every ciphertext that this file made. No response may hold one. */
|
||||
const secrets = new Set<string>();
|
||||
const responses: string[] = [];
|
||||
|
||||
const newToken = () => {
|
||||
const token = fakeToken();
|
||||
secrets.add(token);
|
||||
return token;
|
||||
};
|
||||
|
||||
const containsSecret = (text: string) => [...secrets].some((secret) => text.includes(secret));
|
||||
|
||||
type RecordedResponse = { status: number; text: string; headers: Record<string, unknown> };
|
||||
|
||||
/** Sends the request and checks that the response holds no token known so far. */
|
||||
async function recorded<T extends RecordedResponse>(request: PromiseLike<T>): Promise<T> {
|
||||
const response = await request;
|
||||
const text = JSON.stringify({ headers: response.headers, text: response.text });
|
||||
responses.push(text);
|
||||
expect(containsSecret(text)).toBe(false);
|
||||
return response;
|
||||
}
|
||||
|
||||
let alice: User;
|
||||
let bob: User;
|
||||
let aliceAgent: SuperAgentTest;
|
||||
let bobAgent: SuperAgentTest;
|
||||
let chatAgent: SuperAgentTest;
|
||||
let client: MockProxy<RemoteInstanceClient>;
|
||||
let repository: LinkedInstanceRepository;
|
||||
|
||||
const link = async (agent = aliceAgent, body: Record<string, unknown> = {}) =>
|
||||
await recorded(
|
||||
agent.post('/linked-instances').send({ name: 'Cloud', url: CLOUD, token: newToken(), ...body }),
|
||||
);
|
||||
|
||||
/** Links an instance for Alice and returns its id. */
|
||||
async function linkedId(): Promise<string> {
|
||||
const response = await link();
|
||||
expect(response.status).toBe(201);
|
||||
return response.body.data.id;
|
||||
}
|
||||
|
||||
const storedToken = async (owner: User, id: string) =>
|
||||
(await Container.get(LinkedInstancesService).getTokenForUse(owner, id)).token;
|
||||
|
||||
beforeAll(async () => {
|
||||
repository = Container.get(LinkedInstanceRepository);
|
||||
alice = await createMember();
|
||||
bob = await createMember();
|
||||
aliceAgent = testServer.authAgentFor(alice);
|
||||
bobAgent = testServer.authAgentFor(bob);
|
||||
chatAgent = testServer.authAgentFor(await createChatUser());
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await repository.delete({});
|
||||
client = mock<RemoteInstanceClient>();
|
||||
client.probe.mockResolvedValue({ ok: true, toolNames: ['search_workflows', 'search_projects'] });
|
||||
client.callTool.mockResolvedValue(searchProjectsOutput([OPS, SALES, PERSONAL]));
|
||||
clientFactory.create.mockReset();
|
||||
clientFactory.create.mockReturnValue(client);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
for (const row of await repository.find()) secrets.add(row.tokenEncrypted);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
expect(responses.length).toBeGreaterThan(40);
|
||||
expect(responses.filter(containsSecret)).toEqual([]);
|
||||
});
|
||||
|
||||
describe('LinkedInstancesController', () => {
|
||||
describe('POST /linked-instances', () => {
|
||||
it('links the instance, picks the default project and answers 201 without the token', async () => {
|
||||
const token = newToken();
|
||||
|
||||
const response = await link(aliceAgent, { url: 'Acme.app.n8n.cloud/home', token });
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.body.data).toEqual({
|
||||
id: expect.any(String),
|
||||
name: 'Cloud',
|
||||
baseUrl: CLOUD,
|
||||
status: 'online',
|
||||
lastVerifiedAt: expect.any(String),
|
||||
createdAt: expect.any(String),
|
||||
defaultRemoteProject: { id: OPS.id, name: OPS.name },
|
||||
});
|
||||
expect(clientFactory.create).toHaveBeenCalledWith({ origin: CLOUD, token });
|
||||
expect(client.callTool).toHaveBeenCalledWith(
|
||||
'search_projects',
|
||||
{ limit: 100 },
|
||||
expect.anything(),
|
||||
);
|
||||
const row = await repository.findForUser(alice.id, response.body.data.id);
|
||||
expect(row?.tokenEncrypted).not.toContain(token);
|
||||
await expect(storedToken(alice, response.body.data.id)).resolves.toBe(token);
|
||||
});
|
||||
|
||||
it('links without a default project when the instance cannot list projects', async () => {
|
||||
client.probe.mockResolvedValue({ ok: true, toolNames: ['search_workflows'] });
|
||||
|
||||
const response = await link();
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.body.data.defaultRemoteProject).toBeNull();
|
||||
});
|
||||
|
||||
it.each(FAILURES)(
|
||||
'answers 400 with the en-GB message when the probe finds %s',
|
||||
async (reason) => {
|
||||
client.probe.mockResolvedValue({ ok: false, reason });
|
||||
|
||||
const response = await link();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.message).toBe(PROBE_FAILURE_MESSAGES[reason]);
|
||||
await expect(repository.count()).resolves.toBe(0);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
['an empty address', '', ADDRESS_ERROR_MESSAGES.empty],
|
||||
['an FTP address', 'ftp://files.example.com', ADDRESS_ERROR_MESSAGES['unsupported-protocol']],
|
||||
['a remote HTTP address', 'http://example.com', ADDRESS_ERROR_MESSAGES['insecure-http']],
|
||||
[
|
||||
'an address with a password',
|
||||
'https://user:pw@example.com',
|
||||
ADDRESS_ERROR_MESSAGES['has-credentials'],
|
||||
],
|
||||
[
|
||||
'an address over 2048 characters',
|
||||
`https://example.com/${'a'.repeat(2048)}`,
|
||||
LINKED_INSTANCE_INPUT_MESSAGES.url,
|
||||
],
|
||||
])('answers 400 for %s without a probe', async (_, url, message) => {
|
||||
const response = await link(aliceAgent, { url });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.message).toBe(message);
|
||||
expect(clientFactory.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['name', { name: '<b>Cloud</b>' }, LINK_INPUT_MESSAGES.name],
|
||||
['name', { name: 'a'.repeat(65) }, LINK_INPUT_MESSAGES.name],
|
||||
['token', { token: 'two words' }, LINK_INPUT_MESSAGES.token],
|
||||
['token', { token: '' }, LINK_INPUT_MESSAGES.token],
|
||||
])('answers 400 for a %s that is not valid', async (_, body, message) => {
|
||||
const response = await link(aliceAgent, body);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.message).toBe(message);
|
||||
expect(clientFactory.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// `recorded` fails the test when the response repeats either token.
|
||||
it('answers 400 to a token that is not valid without repeating the token', async () => {
|
||||
const response = await link(aliceAgent, { token: `${newToken()} ${newToken()}` });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.message).toBe(LINK_INPUT_MESSAGES.token);
|
||||
await expect(repository.count()).resolves.toBe(0);
|
||||
});
|
||||
|
||||
it('answers 409 when the user already linked the address, and lets another user link it', async () => {
|
||||
await linkedId();
|
||||
|
||||
const again = await link(aliceAgent, { name: 'Again', url: 'https://ACME.app.n8n.cloud/' });
|
||||
const bobs = await link(bobAgent);
|
||||
|
||||
expect(again.status).toBe(409);
|
||||
expect(again.body.message).toBe(DUPLICATE_LINK_MESSAGE);
|
||||
expect(bobs.status).toBe(201);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /linked-instances', () => {
|
||||
it("lists only the user's own links", async () => {
|
||||
const id = await linkedId();
|
||||
await link(bobAgent, { name: 'Bob cloud' });
|
||||
|
||||
const response = await recorded(aliceAgent.get('/linked-instances'));
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.data).toEqual([
|
||||
expect.objectContaining({
|
||||
id,
|
||||
name: 'Cloud',
|
||||
defaultRemoteProject: { id: OPS.id, name: 'Ops' },
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('lists nothing for a user without links', async () => {
|
||||
const response = await recorded(bobAgent.get('/linked-instances'));
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.data).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /linked-instances/:id/verify', () => {
|
||||
it('checks the instance again with the stored token', async () => {
|
||||
const id = await linkedId();
|
||||
const token = await storedToken(alice, id);
|
||||
clientFactory.create.mockClear();
|
||||
|
||||
const response = await recorded(aliceAgent.post(`/linked-instances/${id}/verify`));
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.data).toMatchObject({ id, status: 'online' });
|
||||
expect(clientFactory.create).toHaveBeenCalledWith({ origin: CLOUD, token });
|
||||
});
|
||||
|
||||
it.each([
|
||||
['unreachable', 'offline'],
|
||||
['mcp-disabled', 'mcp-disabled'],
|
||||
['unauthorised', 'unauthorised'],
|
||||
] as const)('records a %s instance as %s', async (reason, status) => {
|
||||
const id = await linkedId();
|
||||
client.probe.mockResolvedValue({ ok: false, reason });
|
||||
|
||||
const response = await recorded(aliceAgent.post(`/linked-instances/${id}/verify`));
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.data.status).toBe(status);
|
||||
await expect(repository.findForUser(alice.id, id)).resolves.toMatchObject({ status });
|
||||
});
|
||||
|
||||
it.each([
|
||||
['another user', () => bobAgent],
|
||||
['the owner, for an unknown id', () => aliceAgent],
|
||||
])('answers 404 to %s', async (who, agentFor) => {
|
||||
const id = who === 'another user' ? await linkedId() : randomUUID();
|
||||
clientFactory.create.mockClear();
|
||||
|
||||
const response = await recorded(agentFor().post(`/linked-instances/${id}/verify`));
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.message).toBe(LINK_NOT_FOUND_MESSAGE);
|
||||
expect(clientFactory.create).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /linked-instances/:id', () => {
|
||||
it('renames the link', async () => {
|
||||
const id = await linkedId();
|
||||
|
||||
const response = await recorded(
|
||||
aliceAgent.patch(`/linked-instances/${id}`).send({ name: 'Cloud (EU)' }),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.data).toMatchObject({ id, name: 'Cloud (EU)' });
|
||||
});
|
||||
|
||||
it('replaces the token after the new token passes the probe', async () => {
|
||||
const id = await linkedId();
|
||||
const token = newToken();
|
||||
|
||||
const response = await recorded(aliceAgent.patch(`/linked-instances/${id}`).send({ token }));
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.data).toMatchObject({ id, status: 'online' });
|
||||
await expect(storedToken(alice, id)).resolves.toBe(token);
|
||||
});
|
||||
|
||||
it.each(FAILURES)('keeps the old token when the new token gets %s', async (reason) => {
|
||||
const id = await linkedId();
|
||||
const oldToken = await storedToken(alice, id);
|
||||
client.probe.mockResolvedValue({ ok: false, reason });
|
||||
|
||||
const response = await recorded(
|
||||
aliceAgent.patch(`/linked-instances/${id}`).send({ token: newToken() }),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.message).toBe(PROBE_FAILURE_MESSAGES[reason]);
|
||||
await expect(storedToken(alice, id)).resolves.toBe(oldToken);
|
||||
});
|
||||
|
||||
it('keeps the old token when the new token is not valid, without a probe', async () => {
|
||||
const id = await linkedId();
|
||||
const oldToken = await storedToken(alice, id);
|
||||
clientFactory.create.mockClear();
|
||||
|
||||
const response = await recorded(
|
||||
aliceAgent.patch(`/linked-instances/${id}`).send({ token: `${newToken()} ${newToken()}` }),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.message).toBe(LINK_INPUT_MESSAGES.token);
|
||||
expect(clientFactory.create).not.toHaveBeenCalled();
|
||||
await expect(storedToken(alice, id)).resolves.toBe(oldToken);
|
||||
});
|
||||
|
||||
it('sets a default project that the instance lists, and records it as online', async () => {
|
||||
const id = await linkedId();
|
||||
await repository.updateForUser(alice.id, id, { status: 'offline' });
|
||||
|
||||
const response = await recorded(
|
||||
aliceAgent.patch(`/linked-instances/${id}`).send({ defaultRemoteProjectId: SALES.id }),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.data).toMatchObject({
|
||||
status: 'online',
|
||||
defaultRemoteProject: { id: SALES.id, name: 'Sales' },
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
'a project that the instance does not list',
|
||||
{ defaultRemoteProjectId: 'Unknown0123' },
|
||||
LINK_INPUT_MESSAGES.defaultRemoteProjectId,
|
||||
],
|
||||
[
|
||||
'a project id that is not valid',
|
||||
{ defaultRemoteProjectId: '../x' },
|
||||
LINK_INPUT_MESSAGES.defaultRemoteProjectId,
|
||||
],
|
||||
['a request that changes nothing', {}, LINK_INPUT_MESSAGES.noChange],
|
||||
['a name that is not valid', { name: 'Cloud\nprod' }, LINK_INPUT_MESSAGES.name],
|
||||
])('answers 400 for %s', async (_, body, message) => {
|
||||
const id = await linkedId();
|
||||
|
||||
const response = await recorded(aliceAgent.patch(`/linked-instances/${id}`).send(body));
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.message).toBe(message);
|
||||
await expect(repository.findForUser(alice.id, id)).resolves.toMatchObject({
|
||||
name: 'Cloud',
|
||||
defaultRemoteProjectId: OPS.id,
|
||||
});
|
||||
});
|
||||
|
||||
it('answers 400 when the token cannot list projects', async () => {
|
||||
const id = await linkedId();
|
||||
client.probe.mockResolvedValue({ ok: true, toolNames: ['search_workflows'] });
|
||||
|
||||
const response = await recorded(
|
||||
aliceAgent.patch(`/linked-instances/${id}`).send({ defaultRemoteProjectId: SALES.id }),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.message).toBe(REMOTE_PROJECTS_MESSAGES.unavailable);
|
||||
});
|
||||
|
||||
it('answers 404 to another user and keeps the link', async () => {
|
||||
const id = await linkedId();
|
||||
|
||||
const response = await recorded(
|
||||
bobAgent.patch(`/linked-instances/${id}`).send({ name: 'Mine now', token: newToken() }),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.message).toBe(LINK_NOT_FOUND_MESSAGE);
|
||||
await expect(repository.findForUser(alice.id, id)).resolves.toMatchObject({ name: 'Cloud' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /linked-instances/:id', () => {
|
||||
it('unlinks the instance and answers 204 without a body', async () => {
|
||||
const id = await linkedId();
|
||||
|
||||
const response = await recorded(aliceAgent.delete(`/linked-instances/${id}`));
|
||||
|
||||
expect(response.status).toBe(204);
|
||||
expect(response.text).toBe('');
|
||||
await expect(repository.count()).resolves.toBe(0);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['another user', () => bobAgent, true],
|
||||
['the owner, for a malformed id', () => aliceAgent, false],
|
||||
])('answers 404 to %s and keeps the link', async (_, agentFor, useRealId) => {
|
||||
const id = await linkedId();
|
||||
|
||||
const response = await recorded(
|
||||
agentFor().delete(`/linked-instances/${useRealId ? id : 'not-a-uuid'}`),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.message).toBe(LINK_NOT_FOUND_MESSAGE);
|
||||
await expect(repository.count()).resolves.toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('scope', () => {
|
||||
it('answers 403 on every route to a user without the Assistant scope', async () => {
|
||||
const id = await linkedId();
|
||||
clientFactory.create.mockClear();
|
||||
|
||||
const statuses = [
|
||||
(await recorded(chatAgent.get('/linked-instances'))).status,
|
||||
(
|
||||
await recorded(
|
||||
chatAgent.post('/linked-instances').send({ name: 'C', url: CLOUD, token: newToken() }),
|
||||
)
|
||||
).status,
|
||||
(await recorded(chatAgent.post(`/linked-instances/${id}/verify`))).status,
|
||||
(await recorded(chatAgent.patch(`/linked-instances/${id}`).send({ name: 'Taken' }))).status,
|
||||
(await recorded(chatAgent.delete(`/linked-instances/${id}`))).status,
|
||||
];
|
||||
|
||||
expect(statuses).toEqual([403, 403, 403, 403, 403]);
|
||||
expect(clientFactory.create).not.toHaveBeenCalled();
|
||||
await expect(repository.findForUser(alice.id, id)).resolves.toMatchObject({ name: 'Cloud' });
|
||||
});
|
||||
|
||||
it('answers 401 without a session', async () => {
|
||||
const response = await recorded(testServer.authlessAgent.get('/linked-instances'));
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,130 @@
|
|||
// The routes get their rate limiters only in production, so this file runs as production.
|
||||
vi.mock('@n8n/backend-common', async () => {
|
||||
const actual = await vi.importActual<typeof import('@n8n/backend-common')>('@n8n/backend-common');
|
||||
return { ...actual, inProduction: true };
|
||||
});
|
||||
|
||||
import { mockInstance } from '@n8n/backend-test-utils';
|
||||
import { Container } from '@n8n/di';
|
||||
import { createMember } from '@test-integration/db/users';
|
||||
import type { SuperAgentTest } from '@test-integration/types';
|
||||
import * as utils from '@test-integration/utils';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
|
||||
import { LinkedInstanceRepository } from '../database/repositories/linked-instance.repository';
|
||||
import { PROBE_FAILURE_MESSAGES } from '../linked-instances.service';
|
||||
import {
|
||||
RemoteInstanceClientFactory,
|
||||
type RemoteInstanceClient,
|
||||
} from '../remote/remote-instance.client';
|
||||
import { CLOUD, fakeToken } from './linked-instances.test-helpers';
|
||||
|
||||
const LIMIT = 10;
|
||||
|
||||
const clientFactory = mockInstance(RemoteInstanceClientFactory);
|
||||
|
||||
const testServer = utils.setupTestServer({
|
||||
endpointGroups: ['linked-instances'],
|
||||
modules: ['linked-instances'],
|
||||
});
|
||||
|
||||
const tokens: string[] = [];
|
||||
const responses: string[] = [];
|
||||
|
||||
const newToken = () => {
|
||||
const token = fakeToken();
|
||||
tokens.push(token);
|
||||
return token;
|
||||
};
|
||||
|
||||
type SentResponse = { status: number; text: string; body: { message?: string } };
|
||||
|
||||
/** Sends the request and keeps the response, so that `afterAll` can check it for tokens. */
|
||||
async function recorded(request: PromiseLike<SentResponse>): Promise<SentResponse> {
|
||||
const response = await request;
|
||||
responses.push(response.text);
|
||||
return response;
|
||||
}
|
||||
|
||||
const newAgent = async () => testServer.authAgentFor(await createMember());
|
||||
|
||||
const linkRequest = async (agent: SuperAgentTest) =>
|
||||
await recorded(
|
||||
agent.post('/linked-instances').send({ name: 'Cloud', url: CLOUD, token: newToken() }),
|
||||
);
|
||||
|
||||
// An unknown id is a 404 after the limiter, so it uses up the budget without a link.
|
||||
const UNKNOWN_LINK = '/linked-instances/00000000-0000-4000-8000-000000000000';
|
||||
|
||||
beforeAll(async () => {
|
||||
await Container.get(LinkedInstanceRepository).delete({});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
const client = mock<RemoteInstanceClient>();
|
||||
// A failed probe still counts, so no request here stores a link.
|
||||
client.probe.mockResolvedValue({ ok: false, reason: 'unreachable' });
|
||||
clientFactory.create.mockReset();
|
||||
clientFactory.create.mockReturnValue(client);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
expect(responses.length).toBeGreaterThan(3 * LIMIT);
|
||||
const leaks = responses.filter((text) => tokens.some((token) => text.includes(token)));
|
||||
expect(leaks).toEqual([]);
|
||||
});
|
||||
|
||||
describe('LinkedInstancesController rate limits', () => {
|
||||
it('answers 429 to the 11th link request of a user within a minute, without a probe', async () => {
|
||||
const agent = await newAgent();
|
||||
for (let attempt = 1; attempt <= LIMIT; attempt++) {
|
||||
const response = await linkRequest(agent);
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.message).toBe(PROBE_FAILURE_MESSAGES.unreachable);
|
||||
}
|
||||
expect(clientFactory.create).toHaveBeenCalledTimes(LIMIT);
|
||||
clientFactory.create.mockClear();
|
||||
|
||||
const limited = await linkRequest(agent);
|
||||
|
||||
expect(limited.status).toBe(429);
|
||||
expect(clientFactory.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('counts the requests of each user on their own', async () => {
|
||||
const limitedAgent = await newAgent();
|
||||
const otherAgent = await newAgent();
|
||||
for (let attempt = 1; attempt <= LIMIT; attempt++) await linkRequest(limitedAgent);
|
||||
expect((await linkRequest(limitedAgent)).status).toBe(429);
|
||||
|
||||
const response = await linkRequest(otherAgent);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.message).toBe(PROBE_FAILURE_MESSAGES.unreachable);
|
||||
});
|
||||
|
||||
it('limits checks of a link in the same way, with a budget of their own', async () => {
|
||||
const agent = await newAgent();
|
||||
for (let attempt = 1; attempt <= LIMIT; attempt++) {
|
||||
expect((await recorded(agent.post(`${UNKNOWN_LINK}/verify`))).status).toBe(404);
|
||||
}
|
||||
|
||||
expect((await recorded(agent.post(`${UNKNOWN_LINK}/verify`))).status).toBe(429);
|
||||
expect((await linkRequest(agent)).status).toBe(400);
|
||||
});
|
||||
|
||||
// A new token reaches the instance, so a change uses the same kind of budget.
|
||||
it('limits changes of a link in the same way, and leaves the list without a limit', async () => {
|
||||
const agent = await newAgent();
|
||||
const change = async () =>
|
||||
(await recorded(agent.patch(UNKNOWN_LINK).send({ token: newToken() }))).status;
|
||||
for (let attempt = 1; attempt <= LIMIT; attempt++) {
|
||||
expect(await change()).toBe(404);
|
||||
}
|
||||
|
||||
expect(await change()).toBe(429);
|
||||
for (let attempt = 0; attempt <= LIMIT; attempt++) {
|
||||
expect((await recorded(agent.get('/linked-instances'))).status).toBe(200);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,593 @@
|
|||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import type { LinkedInstanceSummary } from '@n8n/api-types';
|
||||
import { BadRequestError, NotFoundError } from '@n8n/errors';
|
||||
|
||||
import { LINK_INPUT_MESSAGES } from '../link-input';
|
||||
import {
|
||||
LINK_NOT_FOUND_MESSAGE,
|
||||
PROBE_FAILURE_MESSAGES,
|
||||
REMOTE_PROJECTS_MESSAGES,
|
||||
} from '../linked-instances.service';
|
||||
import { RemoteInstanceError } from '../remote/remote-instance.errors';
|
||||
import {
|
||||
CLOUD,
|
||||
expectRejection,
|
||||
fakeToken,
|
||||
OPS,
|
||||
PERSONAL,
|
||||
SALES,
|
||||
searchProjectsOutput,
|
||||
setup,
|
||||
user,
|
||||
} from './linked-instances.test-helpers';
|
||||
|
||||
const WITH_PROJECTS = ['search_workflows', 'search_projects'];
|
||||
|
||||
const FAILURES = ['unreachable', 'mcp-disabled', 'unauthorised'] as const;
|
||||
|
||||
const ref = ({ id, name }: { id: string; name: string }) => ({ id, name });
|
||||
|
||||
/** A service with one link, made while the instance listed `projects`. */
|
||||
async function linked(projects = [OPS, PERSONAL]) {
|
||||
const context = setup();
|
||||
const { service, client } = context;
|
||||
client.probe.mockResolvedValue({ ok: true, toolNames: WITH_PROJECTS });
|
||||
client.callTool.mockResolvedValue(searchProjectsOutput(projects));
|
||||
const alice = user();
|
||||
const token = fakeToken();
|
||||
const link = await service.link(alice, { name: 'Cloud', address: CLOUD, token });
|
||||
client.probe.mockClear();
|
||||
client.callTool.mockClear();
|
||||
client.close.mockClear();
|
||||
context.clientFactory.create.mockClear();
|
||||
return { ...context, alice, token, link };
|
||||
}
|
||||
|
||||
describe('LinkedInstancesService and the linked instance', () => {
|
||||
describe('link: default remote project', () => {
|
||||
it('stores the first team project that the instance lists', async () => {
|
||||
const { service, client, clientFactory } = setup();
|
||||
client.probe.mockResolvedValue({ ok: true, toolNames: WITH_PROJECTS });
|
||||
client.callTool.mockResolvedValue(searchProjectsOutput([SALES, OPS, PERSONAL]));
|
||||
|
||||
const summary = await service.link(user(), {
|
||||
name: 'Cloud',
|
||||
address: CLOUD,
|
||||
token: fakeToken(),
|
||||
});
|
||||
|
||||
expect(summary.defaultRemoteProject).toEqual(ref(SALES));
|
||||
expect(clientFactory.create).toHaveBeenCalledTimes(1);
|
||||
expect(client.probe.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
client.callTool.mock.invocationCallOrder[0],
|
||||
);
|
||||
expect(client.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('stores the personal project when the instance has no licence for team projects', async () => {
|
||||
const { service, client } = setup();
|
||||
client.probe.mockResolvedValue({ ok: true, toolNames: WITH_PROJECTS });
|
||||
client.callTool.mockResolvedValue(searchProjectsOutput([OPS, PERSONAL], false));
|
||||
|
||||
const summary = await service.link(user(), {
|
||||
name: 'Cloud',
|
||||
address: CLOUD,
|
||||
token: fakeToken(),
|
||||
});
|
||||
|
||||
expect(summary.defaultRemoteProject).toEqual(ref(PERSONAL));
|
||||
});
|
||||
|
||||
it('links without a default project when the token cannot list projects', async () => {
|
||||
const { service, client } = setup();
|
||||
|
||||
const summary = await service.link(user(), {
|
||||
name: 'Cloud',
|
||||
address: CLOUD,
|
||||
token: fakeToken(),
|
||||
});
|
||||
|
||||
expect(summary).toMatchObject({ status: 'online', defaultRemoteProject: null });
|
||||
expect(client.callTool).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['the tool fails', new RemoteInstanceError('tool-error')],
|
||||
['the instance does not answer in time', new RemoteInstanceError('timeout')],
|
||||
])('links without a default project when %s', async (_, failure) => {
|
||||
const { service, client, logger, rows } = setup();
|
||||
client.probe.mockResolvedValue({ ok: true, toolNames: WITH_PROJECTS });
|
||||
client.callTool.mockRejectedValue(failure);
|
||||
|
||||
const summary = await service.link(user(), {
|
||||
name: 'Cloud',
|
||||
address: CLOUD,
|
||||
token: fakeToken(),
|
||||
});
|
||||
|
||||
expect(summary.defaultRemoteProject).toBeNull();
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(logger.warn).toHaveBeenCalledWith('Could not list the projects of a linked instance', {
|
||||
origin: CLOUD,
|
||||
reason: failure.reason,
|
||||
});
|
||||
expect(client.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('links without a default project when the project list has an unknown format', async () => {
|
||||
const { service, client } = setup();
|
||||
client.probe.mockResolvedValue({ ok: true, toolNames: WITH_PROJECTS });
|
||||
client.callTool.mockResolvedValue('Ops, Sales');
|
||||
|
||||
const summary = await service.link(user(), {
|
||||
name: 'Cloud',
|
||||
address: CLOUD,
|
||||
token: fakeToken(),
|
||||
});
|
||||
|
||||
expect(summary.defaultRemoteProject).toBeNull();
|
||||
});
|
||||
|
||||
it('fails, stores nothing and closes the client on an unexpected error', async () => {
|
||||
const { service, client, rows } = setup();
|
||||
client.probe.mockResolvedValue({ ok: true, toolNames: WITH_PROJECTS });
|
||||
client.callTool.mockRejectedValue(new TypeError('bug'));
|
||||
|
||||
await expect(
|
||||
service.link(user(), { name: 'Cloud', address: CLOUD, token: fakeToken() }),
|
||||
).rejects.toThrow(TypeError);
|
||||
expect(rows).toHaveLength(0);
|
||||
expect(client.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not list projects when the probe fails', async () => {
|
||||
const { service, client } = setup();
|
||||
client.probe.mockResolvedValue({ ok: false, reason: 'unauthorised' });
|
||||
|
||||
await expectRejection(
|
||||
service.link(user(), { name: 'Cloud', address: CLOUD, token: fakeToken() }),
|
||||
BadRequestError,
|
||||
PROBE_FAILURE_MESSAGES.unauthorised,
|
||||
);
|
||||
expect(client.callTool).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('list', () => {
|
||||
it('shows the default project only when both its id and its name are stored', async () => {
|
||||
const { service, alice, link, rows } = await linked();
|
||||
expect(link.defaultRemoteProject).toEqual(ref(OPS));
|
||||
|
||||
rows[0].defaultRemoteProjectName = null;
|
||||
await expect(service.list(alice)).resolves.toEqual([{ ...link, defaultRemoteProject: null }]);
|
||||
|
||||
rows[0].defaultRemoteProjectName = 'Ops';
|
||||
rows[0].defaultRemoteProjectId = null;
|
||||
await expect(service.list(alice)).resolves.toEqual([{ ...link, defaultRemoteProject: null }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('verify', () => {
|
||||
it('probes with the stored token and records that the instance is online', async () => {
|
||||
const { service, client, clientFactory, alice, token, link, rows } = await linked();
|
||||
rows[0].status = 'offline';
|
||||
rows[0].lastVerifiedAt = new Date('2026-01-01T00:00:00.000Z');
|
||||
const before = Date.now();
|
||||
|
||||
const summary = await service.verify(alice, link.id);
|
||||
|
||||
expect(clientFactory.create).toHaveBeenCalledWith({ origin: CLOUD, token });
|
||||
expect(client.close).toHaveBeenCalledTimes(1);
|
||||
expect(summary).toEqual({ ...link, status: 'online', lastVerifiedAt: expect.any(String) });
|
||||
expect(Date.parse(summary.lastVerifiedAt ?? '')).toBeGreaterThanOrEqual(before);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['unreachable', 'offline'],
|
||||
['mcp-disabled', 'mcp-disabled'],
|
||||
['unauthorised', 'unauthorised'],
|
||||
] as const)('records a %s instance as %s and keeps the link', async (reason, status) => {
|
||||
const { service, client, alice, token, link } = await linked();
|
||||
client.probe.mockResolvedValue({ ok: false, reason });
|
||||
const before = Date.now();
|
||||
|
||||
const summary = await service.verify(alice, link.id);
|
||||
|
||||
expect(summary).toMatchObject({ status, defaultRemoteProject: link.defaultRemoteProject });
|
||||
expect(Date.parse(summary.lastVerifiedAt ?? '')).toBeGreaterThanOrEqual(before);
|
||||
await expect(service.getTokenForUse(alice, link.id)).resolves.toEqual({
|
||||
origin: CLOUD,
|
||||
token,
|
||||
});
|
||||
expect(client.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("throws NotFoundError for another user's link without a probe", async () => {
|
||||
const { service, clientFactory, link } = await linked();
|
||||
|
||||
await expectRejection(service.verify(user(), link.id), NotFoundError, LINK_NOT_FOUND_MESSAGE);
|
||||
expect(clientFactory.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(['not-a-uuid', randomUUID()])('throws NotFoundError for the id %j', async (id) => {
|
||||
const { service, alice, repository, clientFactory } = await linked();
|
||||
|
||||
await expect(service.verify(alice, id)).rejects.toThrow(NotFoundError);
|
||||
expect(clientFactory.create).not.toHaveBeenCalled();
|
||||
expect(repository.updateForUser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws NotFoundError when the link goes away during the probe', async () => {
|
||||
const { service, alice, link, repository } = await linked();
|
||||
repository.updateForUser.mockResolvedValueOnce(false);
|
||||
|
||||
await expectRejection(service.verify(alice, link.id), NotFoundError, LINK_NOT_FOUND_MESSAGE);
|
||||
});
|
||||
|
||||
it('keeps the default project without a call to list the projects', async () => {
|
||||
const { service, client, alice, link } = await linked();
|
||||
client.callTool.mockResolvedValue(searchProjectsOutput([SALES, PERSONAL]));
|
||||
|
||||
const summary = await service.verify(alice, link.id);
|
||||
|
||||
expect(summary.defaultRemoteProject).toEqual(ref(OPS));
|
||||
expect(client.callTool).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('picks a default project for a link that has none, as a new link does', async () => {
|
||||
const { service, client, alice, link, rows } = await linked();
|
||||
rows[0].defaultRemoteProjectId = null;
|
||||
rows[0].defaultRemoteProjectName = null;
|
||||
rows[0].status = 'offline';
|
||||
client.callTool.mockResolvedValue(searchProjectsOutput([SALES, OPS, PERSONAL]));
|
||||
|
||||
const summary = await service.verify(alice, link.id);
|
||||
|
||||
expect(summary).toMatchObject({ status: 'online', defaultRemoteProject: ref(SALES) });
|
||||
await expect(service.list(alice)).resolves.toEqual([summary]);
|
||||
expect(client.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('records the instance as online when it still does not list its projects', async () => {
|
||||
const { service, client, logger, alice, link, rows } = await linked();
|
||||
rows[0].defaultRemoteProjectId = null;
|
||||
rows[0].status = 'offline';
|
||||
client.callTool.mockRejectedValue(new RemoteInstanceError('timeout'));
|
||||
|
||||
const summary = await service.verify(alice, link.id);
|
||||
|
||||
expect(summary).toMatchObject({ status: 'online', defaultRemoteProject: null });
|
||||
expect(logger.warn).toHaveBeenCalledWith('Could not list the projects of a linked instance', {
|
||||
origin: CLOUD,
|
||||
reason: 'timeout',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not list the projects when the probe fails', async () => {
|
||||
const { service, client, alice, link, rows } = await linked();
|
||||
rows[0].defaultRemoteProjectId = null;
|
||||
client.probe.mockResolvedValue({ ok: false, reason: 'unreachable' });
|
||||
|
||||
const summary = await service.verify(alice, link.id);
|
||||
|
||||
expect(summary).toMatchObject({ status: 'offline', defaultRemoteProject: null });
|
||||
expect(client.callTool).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('renames the link without a call to the instance', async () => {
|
||||
const { service, clientFactory, alice, link } = await linked();
|
||||
|
||||
const summary = await service.update(alice, link.id, { name: ' Cloud (EU) ' });
|
||||
|
||||
expect(summary).toEqual({ ...link, name: 'Cloud (EU)' });
|
||||
expect(clientFactory.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a change that changes nothing, or a name that is not valid', async () => {
|
||||
const { service, alice, link } = await linked();
|
||||
|
||||
await expectRejection(
|
||||
service.update(alice, link.id, {}),
|
||||
BadRequestError,
|
||||
LINK_INPUT_MESSAGES.noChange,
|
||||
);
|
||||
await expectRejection(
|
||||
service.update(alice, link.id, { name: '<b>x</b>' }),
|
||||
BadRequestError,
|
||||
LINK_INPUT_MESSAGES.name,
|
||||
);
|
||||
await expect(service.list(alice)).resolves.toEqual([link]);
|
||||
});
|
||||
|
||||
it('replaces the token after the new token passes the probe', async () => {
|
||||
const { service, client, clientFactory, alice, link, rows, cipher } = await linked();
|
||||
rows[0].status = 'unauthorised';
|
||||
const newToken = fakeToken();
|
||||
|
||||
const summary = await service.update(alice, link.id, { token: ` ${newToken} ` });
|
||||
|
||||
expect(clientFactory.create).toHaveBeenCalledWith({ origin: CLOUD, token: newToken });
|
||||
expect(summary).toMatchObject({ status: 'online', defaultRemoteProject: ref(OPS) });
|
||||
await expect(service.getTokenForUse(alice, link.id)).resolves.toEqual({
|
||||
origin: CLOUD,
|
||||
token: newToken,
|
||||
});
|
||||
expect(rows[0].tokenEncrypted).not.toContain(newToken);
|
||||
await expect(cipher.decryptV2(rows[0].tokenEncrypted)).resolves.toBe(newToken);
|
||||
expect(client.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each(FAILURES)(
|
||||
'keeps the old token, the name and the status when the new token gets %s',
|
||||
async (reason) => {
|
||||
const { service, client, alice, token, link } = await linked();
|
||||
client.probe.mockResolvedValue({ ok: false, reason });
|
||||
|
||||
await expectRejection(
|
||||
service.update(alice, link.id, { name: 'Renamed', token: fakeToken() }),
|
||||
BadRequestError,
|
||||
PROBE_FAILURE_MESSAGES[reason],
|
||||
);
|
||||
|
||||
await expect(service.getTokenForUse(alice, link.id)).resolves.toEqual({
|
||||
origin: CLOUD,
|
||||
token,
|
||||
});
|
||||
await expect(service.list(alice)).resolves.toEqual([link]);
|
||||
expect(client.close).toHaveBeenCalledTimes(1);
|
||||
},
|
||||
);
|
||||
|
||||
it('keeps the default project while the new token sees it, with its current name', async () => {
|
||||
const { service, client, alice, link } = await linked([OPS, PERSONAL]);
|
||||
client.callTool.mockResolvedValue(
|
||||
searchProjectsOutput([SALES, { ...OPS, name: 'Operations' }, PERSONAL]),
|
||||
);
|
||||
|
||||
const summary = await service.update(alice, link.id, { token: fakeToken() });
|
||||
|
||||
expect(summary.defaultRemoteProject).toEqual({ id: OPS.id, name: 'Operations' });
|
||||
});
|
||||
|
||||
it('picks a new default project when the new token does not see the old one', async () => {
|
||||
const { service, client, alice, link } = await linked([OPS, PERSONAL]);
|
||||
const otherPersonal = {
|
||||
id: 'Bo8bT8sU7vW6xY5z',
|
||||
name: 'Bob <bob@acme.test>',
|
||||
type: 'personal',
|
||||
};
|
||||
client.callTool.mockResolvedValue(searchProjectsOutput([otherPersonal]));
|
||||
|
||||
const summary = await service.update(alice, link.id, { token: fakeToken() });
|
||||
|
||||
expect(summary.defaultRemoteProject).toEqual(ref(otherPersonal));
|
||||
});
|
||||
|
||||
it.each([
|
||||
['cannot list projects', ['search_workflows'], undefined],
|
||||
['fails to list projects', WITH_PROJECTS, new RemoteInstanceError('tool-error')],
|
||||
])('keeps the default project when the new token %s', async (_, toolNames, failure) => {
|
||||
const { service, client, alice, link } = await linked();
|
||||
client.probe.mockResolvedValue({ ok: true, toolNames });
|
||||
if (failure) client.callTool.mockRejectedValue(failure);
|
||||
|
||||
const summary = await service.update(alice, link.id, { token: fakeToken() });
|
||||
|
||||
expect(summary).toMatchObject({ status: 'online', defaultRemoteProject: ref(OPS) });
|
||||
});
|
||||
|
||||
it('sets a default project that the instance lists for the stored token', async () => {
|
||||
const { service, client, clientFactory, alice, token, link } = await linked();
|
||||
client.callTool.mockResolvedValue(
|
||||
searchProjectsOutput([OPS, { ...SALES, name: ' Sales\nEMEA ' }, PERSONAL]),
|
||||
);
|
||||
|
||||
const summary = await service.update(alice, link.id, { defaultRemoteProjectId: SALES.id });
|
||||
|
||||
expect(clientFactory.create).toHaveBeenCalledWith({ origin: CLOUD, token });
|
||||
expect(summary).toEqual({
|
||||
...link,
|
||||
lastVerifiedAt: expect.any(String),
|
||||
defaultRemoteProject: { id: SALES.id, name: 'Sales EMEA' },
|
||||
});
|
||||
expect(client.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('records that the instance is online after the probe for a new default project', async () => {
|
||||
const { service, alice, link, rows } = await linked();
|
||||
rows[0].status = 'offline';
|
||||
rows[0].lastVerifiedAt = new Date('2026-01-01T00:00:00.000Z');
|
||||
const before = Date.now();
|
||||
|
||||
const summary = await service.update(alice, link.id, {
|
||||
defaultRemoteProjectId: PERSONAL.id,
|
||||
});
|
||||
|
||||
expect(summary).toMatchObject({ status: 'online', defaultRemoteProject: ref(PERSONAL) });
|
||||
expect(Date.parse(summary.lastVerifiedAt ?? '')).toBeGreaterThanOrEqual(before);
|
||||
});
|
||||
|
||||
it('decrypts the stored token only when no new token replaces it', async () => {
|
||||
const { service, cipher, alice, link } = await linked();
|
||||
cipher.decryptV2.mockClear();
|
||||
|
||||
await service.update(alice, link.id, { name: 'Renamed' });
|
||||
await service.update(alice, link.id, { token: fakeToken() });
|
||||
expect(cipher.decryptV2).not.toHaveBeenCalled();
|
||||
|
||||
await service.update(alice, link.id, { defaultRemoteProjectId: PERSONAL.id });
|
||||
expect(cipher.decryptV2).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('checks a new default project with a new token sent with it', async () => {
|
||||
const { service, client, clientFactory, alice, link } = await linked();
|
||||
client.callTool.mockResolvedValue(searchProjectsOutput([PERSONAL]));
|
||||
const newToken = fakeToken();
|
||||
|
||||
const summary = await service.update(alice, link.id, {
|
||||
token: newToken,
|
||||
defaultRemoteProjectId: PERSONAL.id,
|
||||
});
|
||||
|
||||
expect(clientFactory.create).toHaveBeenCalledWith({ origin: CLOUD, token: newToken });
|
||||
expect(summary).toMatchObject({ status: 'online', defaultRemoteProject: ref(PERSONAL) });
|
||||
await expect(service.getTokenForUse(alice, link.id)).resolves.toHaveProperty(
|
||||
'token',
|
||||
newToken,
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
'a project that the instance does not list',
|
||||
{ toolNames: WITH_PROJECTS, result: searchProjectsOutput([OPS, PERSONAL]) },
|
||||
LINK_INPUT_MESSAGES.defaultRemoteProjectId,
|
||||
],
|
||||
[
|
||||
'a token that cannot list projects',
|
||||
{ toolNames: ['search_workflows'], result: undefined },
|
||||
REMOTE_PROJECTS_MESSAGES.unavailable,
|
||||
],
|
||||
[
|
||||
'a failed project list',
|
||||
{ toolNames: WITH_PROJECTS, result: new RemoteInstanceError('timeout') },
|
||||
REMOTE_PROJECTS_MESSAGES.failed,
|
||||
],
|
||||
[
|
||||
'a project list in an unknown format',
|
||||
{ toolNames: WITH_PROJECTS, result: 'Sales' },
|
||||
REMOTE_PROJECTS_MESSAGES.failed,
|
||||
],
|
||||
])('rejects %s and changes nothing', async (_, remote, message) => {
|
||||
const { service, client, alice, link } = await linked();
|
||||
client.probe.mockResolvedValue({ ok: true, toolNames: remote.toolNames });
|
||||
if (remote.result instanceof Error) client.callTool.mockRejectedValue(remote.result);
|
||||
else client.callTool.mockResolvedValue(remote.result);
|
||||
|
||||
await expectRejection(
|
||||
service.update(alice, link.id, { name: 'Renamed', defaultRemoteProjectId: SALES.id }),
|
||||
BadRequestError,
|
||||
message,
|
||||
);
|
||||
await expect(service.list(alice)).resolves.toEqual([link]);
|
||||
expect(client.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('rejects a default project when the stored token no longer works', async () => {
|
||||
const { service, client, alice, link } = await linked();
|
||||
client.probe.mockResolvedValue({ ok: false, reason: 'unauthorised' });
|
||||
|
||||
await expectRejection(
|
||||
service.update(alice, link.id, { defaultRemoteProjectId: OPS.id }),
|
||||
BadRequestError,
|
||||
PROBE_FAILURE_MESSAGES.unauthorised,
|
||||
);
|
||||
expect(client.callTool).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('passes on an unexpected error from the project list and changes nothing', async () => {
|
||||
const { service, client, alice, link } = await linked();
|
||||
client.callTool.mockRejectedValue(new TypeError('bug'));
|
||||
|
||||
await expect(
|
||||
service.update(alice, link.id, { defaultRemoteProjectId: OPS.id }),
|
||||
).rejects.toThrow(TypeError);
|
||||
await expect(service.list(alice)).resolves.toEqual([link]);
|
||||
});
|
||||
|
||||
it("throws NotFoundError for another user's link without a call to the instance", async () => {
|
||||
const { service, clientFactory, link } = await linked();
|
||||
|
||||
await expectRejection(
|
||||
service.update(user(), link.id, { token: fakeToken() }),
|
||||
NotFoundError,
|
||||
LINK_NOT_FOUND_MESSAGE,
|
||||
);
|
||||
await expect(service.update(user(), 'not-a-uuid', { name: 'x' })).rejects.toThrow(
|
||||
NotFoundError,
|
||||
);
|
||||
expect(clientFactory.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws NotFoundError when the link goes away during the change', async () => {
|
||||
const { service, alice, link, repository } = await linked();
|
||||
repository.updateForUser.mockResolvedValueOnce(false);
|
||||
|
||||
await expectRejection(
|
||||
service.update(alice, link.id, { name: 'Renamed' }),
|
||||
NotFoundError,
|
||||
LINK_NOT_FOUND_MESSAGE,
|
||||
);
|
||||
});
|
||||
|
||||
it('logs which fields changed, without their values', async () => {
|
||||
const { service, logger, alice, link } = await linked();
|
||||
const newToken = fakeToken();
|
||||
|
||||
await service.update(alice, link.id, { name: 'Renamed', token: newToken });
|
||||
|
||||
expect(logger.info).toHaveBeenLastCalledWith('Changed a linked instance', {
|
||||
userId: alice.id,
|
||||
linkedInstanceId: link.id,
|
||||
fields: ['name', 'token'],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('token secrecy', () => {
|
||||
type Outcome = { value?: unknown; error?: unknown };
|
||||
|
||||
const settle = async (promise: Promise<unknown>) =>
|
||||
await promise.then(
|
||||
(value) => ({ value, error: undefined }),
|
||||
(error: unknown) => ({ value: undefined, error }),
|
||||
);
|
||||
|
||||
it('never puts the old or the new token in logs, errors or return values', async () => {
|
||||
const { service, client, logger, alice, token, link } = await linked();
|
||||
const tokens = [token];
|
||||
const outcomes: Outcome[] = [{ value: link }];
|
||||
|
||||
outcomes.push(await settle(service.verify(alice, link.id)));
|
||||
for (const reason of FAILURES) {
|
||||
client.probe.mockResolvedValueOnce({ ok: false, reason });
|
||||
outcomes.push(await settle(service.verify(alice, link.id)));
|
||||
const rejected = fakeToken();
|
||||
tokens.push(rejected);
|
||||
client.probe.mockResolvedValueOnce({ ok: false, reason });
|
||||
outcomes.push(await settle(service.update(alice, link.id, { token: rejected })));
|
||||
}
|
||||
const badToken = `${fakeToken()} x`;
|
||||
tokens.push(badToken.split(' ')[0]);
|
||||
outcomes.push(await settle(service.update(alice, link.id, { token: badToken })));
|
||||
client.callTool.mockRejectedValueOnce(new RemoteInstanceError('tool-error'));
|
||||
outcomes.push(
|
||||
await settle(service.update(alice, link.id, { defaultRemoteProjectId: OPS.id })),
|
||||
);
|
||||
const rotated = fakeToken();
|
||||
tokens.push(rotated);
|
||||
outcomes.push(await settle(service.update(alice, link.id, { token: rotated })));
|
||||
outcomes.push(await settle(service.update(user(), link.id, { token: fakeToken() })));
|
||||
|
||||
const texts = [
|
||||
...outcomes.map(({ value }) => JSON.stringify(value) ?? ''),
|
||||
...outcomes.flatMap(({ error }) =>
|
||||
error instanceof Error ? [error.message, error.stack ?? '', JSON.stringify(error)] : [],
|
||||
),
|
||||
...[logger.info, logger.warn, logger.error, logger.debug].flatMap((fn) =>
|
||||
fn.mock.calls.map((call) => JSON.stringify(call)),
|
||||
),
|
||||
];
|
||||
expect(outcomes.filter(({ error }) => error).length).toBeGreaterThanOrEqual(6);
|
||||
const rotatedOutcome = outcomes[outcomes.length - 2].value as LinkedInstanceSummary;
|
||||
expect(rotatedOutcome.status).toBe('online');
|
||||
for (const text of texts) {
|
||||
for (const secret of tokens) expect(text).not.toContain(secret);
|
||||
expect(text).not.toContain('enc:');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,109 +1,17 @@
|
|||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import type { Logger } from '@n8n/backend-common';
|
||||
import type { User } from '@n8n/db';
|
||||
import { BadRequestError, ConflictError, NotFoundError } from '@n8n/errors';
|
||||
import type { Cipher } from 'n8n-core';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
|
||||
import type { LinkedInstance } from '../database/entities/linked-instance.entity';
|
||||
import type { LinkedInstanceRepository } from '../database/repositories/linked-instance.repository';
|
||||
import { ADDRESS_ERROR_MESSAGES, LINK_INPUT_MESSAGES } from '../link-input';
|
||||
import { LinkedInstanceStore } from '../linked-instance.store';
|
||||
import {
|
||||
DUPLICATE_LINK_MESSAGE,
|
||||
LINK_NOT_FOUND_MESSAGE,
|
||||
LinkedInstancesService,
|
||||
PROBE_FAILURE_MESSAGES,
|
||||
} from '../linked-instances.service';
|
||||
import type {
|
||||
RemoteInstanceClient,
|
||||
RemoteInstanceClientFactory,
|
||||
RemoteProbeResult,
|
||||
} from '../remote/remote-instance.client';
|
||||
import type { RemoteProbeResult } from '../remote/remote-instance.client';
|
||||
import { CLOUD, expectRejection, fakeToken, setup, user } from './linked-instances.test-helpers';
|
||||
|
||||
const TOKEN = 'n8n_api_secret-token-0123456789';
|
||||
const CLOUD = 'https://acme.app.n8n.cloud';
|
||||
|
||||
/** Reversible, and the ciphertext never contains the clear text. */
|
||||
const fakeCipher = () => {
|
||||
const cipher = mock<Cipher>();
|
||||
cipher.encryptV2.mockImplementation(
|
||||
async (data) =>
|
||||
`enc:${Buffer.from(typeof data === 'string' ? data : JSON.stringify(data)).toString('base64')}`,
|
||||
);
|
||||
cipher.decryptV2.mockImplementation(async (data) =>
|
||||
Buffer.from(data.slice(4), 'base64').toString(),
|
||||
);
|
||||
return cipher;
|
||||
};
|
||||
|
||||
/** An in-memory repository with the same ownership rules as the real one. */
|
||||
function fakeRepository() {
|
||||
const rows: LinkedInstance[] = [];
|
||||
const repository = mock<LinkedInstanceRepository>();
|
||||
const owned = (userId: string, id: string) => (row: LinkedInstance) =>
|
||||
row.userId === userId && row.id === id;
|
||||
|
||||
repository.listForUser.mockImplementation(async (userId) =>
|
||||
rows.filter((row) => row.userId === userId),
|
||||
);
|
||||
repository.findForUser.mockImplementation(
|
||||
async (userId, id) => rows.find(owned(userId, id)) ?? null,
|
||||
);
|
||||
repository.existsForUser.mockImplementation(async (userId, baseUrl) =>
|
||||
rows.some((row) => row.userId === userId && row.baseUrl === baseUrl),
|
||||
);
|
||||
repository.createForUser.mockImplementation(async (input) => {
|
||||
if (rows.some((row) => row.userId === input.userId && row.baseUrl === input.baseUrl)) {
|
||||
return null;
|
||||
}
|
||||
const now = new Date('2026-10-07T12:00:00.000Z');
|
||||
const row = { id: randomUUID(), ...input, createdAt: now, updatedAt: now } as LinkedInstance;
|
||||
rows.push(row);
|
||||
return row;
|
||||
});
|
||||
repository.deleteForUser.mockImplementation(async (userId, id) => {
|
||||
const index = rows.findIndex(owned(userId, id));
|
||||
if (index === -1) return false;
|
||||
rows.splice(index, 1);
|
||||
return true;
|
||||
});
|
||||
return { repository, rows };
|
||||
}
|
||||
|
||||
function setup() {
|
||||
const { repository, rows } = fakeRepository();
|
||||
const cipher = fakeCipher();
|
||||
const logger = mock<Logger>();
|
||||
logger.scoped.mockReturnValue(logger);
|
||||
const client = mock<RemoteInstanceClient>();
|
||||
client.probe.mockResolvedValue({ ok: true, toolNames: ['search_workflows'] });
|
||||
const clientFactory = mock<RemoteInstanceClientFactory>();
|
||||
clientFactory.create.mockReturnValue(client);
|
||||
const service = new LinkedInstancesService(
|
||||
logger,
|
||||
new LinkedInstanceStore(repository, cipher),
|
||||
clientFactory,
|
||||
);
|
||||
return { service, repository, rows, cipher, logger, client, clientFactory };
|
||||
}
|
||||
|
||||
const user = (id = randomUUID()) => mock<User>({ id });
|
||||
|
||||
/** Checks the class and the exact message of a rejection. */
|
||||
async function expectRejection(
|
||||
promise: Promise<unknown>,
|
||||
errorClass: new (message: string) => Error,
|
||||
message: string,
|
||||
) {
|
||||
const error = await promise.then(
|
||||
() => undefined,
|
||||
(e: unknown) => e,
|
||||
);
|
||||
expect(error).toBeInstanceOf(errorClass);
|
||||
expect(error).toHaveProperty('message', message);
|
||||
}
|
||||
const TOKEN = fakeToken();
|
||||
|
||||
const input = (overrides: Partial<{ name: string; address: string; token: string }> = {}) => ({
|
||||
name: 'Cloud',
|
||||
|
|
@ -136,6 +44,7 @@ describe('LinkedInstancesService', () => {
|
|||
status: 'online',
|
||||
lastVerifiedAt: expect.any(String),
|
||||
createdAt: '2026-10-07T12:00:00.000Z',
|
||||
defaultRemoteProject: null,
|
||||
});
|
||||
expect(Object.keys(summary)).not.toContain('tokenEncrypted');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,128 @@
|
|||
import { randomBytes, randomUUID } from 'node:crypto';
|
||||
|
||||
import type { Logger } from '@n8n/backend-common';
|
||||
import type { User } from '@n8n/db';
|
||||
import type { Cipher } from 'n8n-core';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
|
||||
import type { LinkedInstance } from '../database/entities/linked-instance.entity';
|
||||
import type { LinkedInstanceRepository } from '../database/repositories/linked-instance.repository';
|
||||
import { LinkedInstanceStore } from '../linked-instance.store';
|
||||
import { LinkedInstancesService } from '../linked-instances.service';
|
||||
import type {
|
||||
RemoteInstanceClient,
|
||||
RemoteInstanceClientFactory,
|
||||
} from '../remote/remote-instance.client';
|
||||
|
||||
export const CLOUD = 'https://acme.app.n8n.cloud';
|
||||
export const CREATED_AT = '2026-10-07T12:00:00.000Z';
|
||||
|
||||
/** A new fake token for each call, so no test holds a fixed secret-like string. */
|
||||
export const fakeToken = () => `n8n_test_${randomBytes(16).toString('hex')}`;
|
||||
|
||||
/** Reversible, and the ciphertext never contains the clear text. */
|
||||
export const fakeCipher = () => {
|
||||
const cipher = mock<Cipher>();
|
||||
cipher.encryptV2.mockImplementation(
|
||||
async (data) =>
|
||||
`enc:${Buffer.from(typeof data === 'string' ? data : JSON.stringify(data)).toString('base64')}`,
|
||||
);
|
||||
cipher.decryptV2.mockImplementation(async (data) =>
|
||||
Buffer.from(data.slice(4), 'base64').toString(),
|
||||
);
|
||||
return cipher;
|
||||
};
|
||||
|
||||
/** An in-memory repository with the same ownership rules as the real one. */
|
||||
export function fakeRepository() {
|
||||
const rows: LinkedInstance[] = [];
|
||||
const repository = mock<LinkedInstanceRepository>();
|
||||
const owned = (userId: string, id: string) => (row: LinkedInstance) =>
|
||||
row.userId === userId && row.id === id;
|
||||
const change = (userId: string, id: string, values: Partial<LinkedInstance>) => {
|
||||
const row = rows.find(owned(userId, id));
|
||||
if (!row) return false;
|
||||
Object.assign(row, values, { updatedAt: new Date() });
|
||||
return true;
|
||||
};
|
||||
|
||||
repository.listForUser.mockImplementation(async (userId) =>
|
||||
rows.filter((row) => row.userId === userId),
|
||||
);
|
||||
repository.findForUser.mockImplementation(
|
||||
async (userId, id) => rows.find(owned(userId, id)) ?? null,
|
||||
);
|
||||
repository.existsForUser.mockImplementation(async (userId, baseUrl) =>
|
||||
rows.some((row) => row.userId === userId && row.baseUrl === baseUrl),
|
||||
);
|
||||
repository.createForUser.mockImplementation(async (input) => {
|
||||
if (rows.some((row) => row.userId === input.userId && row.baseUrl === input.baseUrl)) {
|
||||
return null;
|
||||
}
|
||||
const now = new Date(CREATED_AT);
|
||||
const row = { id: randomUUID(), ...input, createdAt: now, updatedAt: now } as LinkedInstance;
|
||||
rows.push(row);
|
||||
return row;
|
||||
});
|
||||
repository.updateForUser.mockImplementation(async (userId, id, update) =>
|
||||
change(userId, id, update),
|
||||
);
|
||||
repository.deleteForUser.mockImplementation(async (userId, id) => {
|
||||
const index = rows.findIndex(owned(userId, id));
|
||||
if (index === -1) return false;
|
||||
rows.splice(index, 1);
|
||||
return true;
|
||||
});
|
||||
return { repository, rows };
|
||||
}
|
||||
|
||||
export function setup() {
|
||||
const { repository, rows } = fakeRepository();
|
||||
const cipher = fakeCipher();
|
||||
const logger = mock<Logger>();
|
||||
logger.scoped.mockReturnValue(logger);
|
||||
const client = mock<RemoteInstanceClient>();
|
||||
client.probe.mockResolvedValue({ ok: true, toolNames: ['search_workflows'] });
|
||||
const clientFactory = mock<RemoteInstanceClientFactory>();
|
||||
clientFactory.create.mockReturnValue(client);
|
||||
const service = new LinkedInstancesService(
|
||||
logger,
|
||||
new LinkedInstanceStore(repository, cipher),
|
||||
clientFactory,
|
||||
);
|
||||
return { service, repository, rows, cipher, logger, client, clientFactory };
|
||||
}
|
||||
|
||||
export const user = (id = randomUUID()) => mock<User>({ id });
|
||||
|
||||
/** Checks the class and the exact message of a rejection. */
|
||||
export async function expectRejection(
|
||||
promise: Promise<unknown>,
|
||||
errorClass: new (message: string) => Error,
|
||||
message: string,
|
||||
) {
|
||||
const error = await promise.then(
|
||||
() => undefined,
|
||||
(e: unknown) => e,
|
||||
);
|
||||
expect(error).toBeInstanceOf(errorClass);
|
||||
expect(error).toHaveProperty('message', message);
|
||||
}
|
||||
|
||||
type RemoteProjectOutput = { id: string; name: string; type: string };
|
||||
|
||||
// Projects as `search_projects` lists them. The ids have the format of n8n project ids.
|
||||
export const OPS: RemoteProjectOutput = { id: 'Xk3pQ9aZ1bC2dE4f', name: 'Ops', type: 'team' };
|
||||
export const SALES: RemoteProjectOutput = { id: 'Sa1eS0pQ9aZ1bC2d', name: 'Sales', type: 'team' };
|
||||
export const PERSONAL: RemoteProjectOutput = {
|
||||
id: 'Pm0rT8sU7vW6xY5z',
|
||||
name: 'Ada Lovelace <ada@acme.test>',
|
||||
type: 'personal',
|
||||
};
|
||||
|
||||
/** The structured output of `search_projects` on an n8n instance: team projects first. */
|
||||
export const searchProjectsOutput = (data: RemoteProjectOutput[], teamProjectsEnabled = true) => ({
|
||||
data,
|
||||
count: data.length,
|
||||
teamProjectsEnabled,
|
||||
});
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
import fc from 'fast-check';
|
||||
|
||||
import {
|
||||
pickDefaultRemoteProject,
|
||||
reconcileDefaultRemoteProject,
|
||||
type RemoteProject,
|
||||
type RemoteProjectList,
|
||||
} from '../remote-project-picker';
|
||||
|
||||
// Few ids, so that lists often hold the current project and repeat ids.
|
||||
const projectArb: fc.Arbitrary<RemoteProject> = fc.record({
|
||||
id: fc.constantFrom('a', 'b', 'c', 'd', 'e'),
|
||||
name: fc.string({ maxLength: 8 }),
|
||||
type: fc.constantFrom('personal' as const, 'team' as const),
|
||||
});
|
||||
|
||||
const listArb: fc.Arbitrary<RemoteProjectList> = fc.record({
|
||||
projects: fc.array(projectArb, { maxLength: 8 }),
|
||||
teamProjectsEnabled: fc.boolean(),
|
||||
});
|
||||
|
||||
const currentArb = fc.option(
|
||||
fc.record({ id: fc.constantFrom('a', 'b', 'f'), name: fc.string({ maxLength: 8 }) }),
|
||||
{ nil: null },
|
||||
);
|
||||
|
||||
const isInputOrNull = (result: RemoteProject | null, list: RemoteProjectList) =>
|
||||
result === null || list.projects.includes(result);
|
||||
|
||||
describe('remote project picker properties', () => {
|
||||
it('returns one of the listed projects or null', () => {
|
||||
fc.assert(
|
||||
fc.property(listArb, currentArb, (list, current) => {
|
||||
expect(isInputOrNull(pickDefaultRemoteProject(list), list)).toBe(true);
|
||||
expect(isInputOrNull(reconcileDefaultRemoteProject(current, list), list)).toBe(true);
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('returns the first team project whenever team projects are licensed and listed', () => {
|
||||
fc.assert(
|
||||
fc.property(listArb, (list) => {
|
||||
const firstTeam = list.projects.find((project) => project.type === 'team');
|
||||
fc.pre(list.teamProjectsEnabled && firstTeam !== undefined);
|
||||
|
||||
expect(pickDefaultRemoteProject(list)).toBe(firstTeam);
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('never picks a team project without a team licence', () => {
|
||||
fc.assert(
|
||||
fc.property(listArb, (list) => {
|
||||
const unlicensed = { ...list, teamProjectsEnabled: false };
|
||||
|
||||
expect(pickDefaultRemoteProject(unlicensed)?.type ?? 'personal').toBe('personal');
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('returns null only when no listed project fits', () => {
|
||||
fc.assert(
|
||||
fc.property(listArb, (list) => {
|
||||
const fits = list.projects.some(
|
||||
(project) => project.type === 'personal' || list.teamProjectsEnabled,
|
||||
);
|
||||
|
||||
expect(pickDefaultRemoteProject(list) === null).toBe(!fits);
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the current project id while it is listed, else picks like a new link', () => {
|
||||
fc.assert(
|
||||
fc.property(listArb, currentArb, (list, current) => {
|
||||
const listed = list.projects.find((project) => project.id === current?.id);
|
||||
const result = reconcileDefaultRemoteProject(current, list);
|
||||
|
||||
expect(result).toBe(listed ?? pickDefaultRemoteProject(list));
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
import {
|
||||
pickDefaultRemoteProject,
|
||||
reconcileDefaultRemoteProject,
|
||||
type RemoteProject,
|
||||
} from '../remote-project-picker';
|
||||
|
||||
const personal: RemoteProject = {
|
||||
id: 'p-me',
|
||||
name: 'Ada Lovelace <ada@acme.test>',
|
||||
type: 'personal',
|
||||
};
|
||||
const ops: RemoteProject = { id: 't-ops', name: 'Ops', type: 'team' };
|
||||
const sales: RemoteProject = { id: 't-sales', name: 'Sales', type: 'team' };
|
||||
|
||||
const list = (projects: RemoteProject[], teamProjectsEnabled = true) => ({
|
||||
projects,
|
||||
teamProjectsEnabled,
|
||||
});
|
||||
|
||||
describe('pickDefaultRemoteProject', () => {
|
||||
it('picks the first listed team project, so teammates see new automations', () => {
|
||||
expect(pickDefaultRemoteProject(list([ops, sales, personal]))).toBe(ops);
|
||||
});
|
||||
|
||||
it('picks a team project also when the personal project comes first', () => {
|
||||
expect(pickDefaultRemoteProject(list([personal, sales, ops]))).toBe(sales);
|
||||
});
|
||||
|
||||
it('picks the personal project when no team project is listed', () => {
|
||||
expect(pickDefaultRemoteProject(list([personal]))).toBe(personal);
|
||||
});
|
||||
|
||||
it('picks the personal project when team projects are not licensed', () => {
|
||||
expect(pickDefaultRemoteProject(list([ops, sales, personal], false))).toBe(personal);
|
||||
});
|
||||
|
||||
it('picks nothing when team projects are not licensed and no personal project is listed', () => {
|
||||
expect(pickDefaultRemoteProject(list([ops, sales], false))).toBeNull();
|
||||
});
|
||||
|
||||
it('picks nothing from an empty list', () => {
|
||||
expect(pickDefaultRemoteProject(list([]))).toBeNull();
|
||||
expect(pickDefaultRemoteProject(list([], false))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('reconcileDefaultRemoteProject', () => {
|
||||
it('keeps the current project while it is listed, with the name that the instance gives now', () => {
|
||||
const renamed: RemoteProject = { ...sales, name: 'Sales EMEA' };
|
||||
|
||||
const result = reconcileDefaultRemoteProject(
|
||||
{ id: sales.id, name: 'Sales' },
|
||||
list([ops, renamed, personal]),
|
||||
);
|
||||
|
||||
expect(result).toBe(renamed);
|
||||
});
|
||||
|
||||
it('keeps a listed team project that the user chose, also without a team licence', () => {
|
||||
expect(reconcileDefaultRemoteProject(sales, list([sales, personal], false))).toBe(sales);
|
||||
});
|
||||
|
||||
it('keeps the current personal project instead of a team project', () => {
|
||||
expect(reconcileDefaultRemoteProject(personal, list([ops, personal]))).toBe(personal);
|
||||
});
|
||||
|
||||
it('picks a new default when the instance no longer lists the current project', () => {
|
||||
const gone = { id: 't-gone', name: 'Archive' };
|
||||
|
||||
expect(reconcileDefaultRemoteProject(gone, list([sales, personal]))).toBe(sales);
|
||||
expect(reconcileDefaultRemoteProject(gone, list([sales, personal], false))).toBe(personal);
|
||||
expect(reconcileDefaultRemoteProject(gone, list([]))).toBeNull();
|
||||
});
|
||||
|
||||
it('picks a default when there is no current project', () => {
|
||||
expect(reconcileDefaultRemoteProject(null, list([personal, ops]))).toBe(ops);
|
||||
expect(reconcileDefaultRemoteProject(null, list([]))).toBeNull();
|
||||
});
|
||||
|
||||
it('matches the current project by id, not by name', () => {
|
||||
const sameName: RemoteProject = { id: 't-other', name: 'Sales', type: 'team' };
|
||||
|
||||
expect(reconcileDefaultRemoteProject(sales, list([sameName, personal]))).toBe(sameName);
|
||||
expect(reconcileDefaultRemoteProject(sales, list([personal, sameName], false))).toBe(personal);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,248 @@
|
|||
import fc from 'fast-check';
|
||||
|
||||
import { RemoteInstanceError } from '../remote/remote-instance.errors';
|
||||
import { parseRemoteProjects } from '../remote-projects';
|
||||
|
||||
const ID_PATTERN = /^[A-Za-z0-9_-]{1,36}$/;
|
||||
// Control, format and separator characters, and half surrogate pairs. A clean name has none.
|
||||
const UNSAFE_CHARACTER = /[\p{Cc}\p{Cf}\p{Cs}\p{Zl}\p{Zp}]/u;
|
||||
const UNSAFE_CHARACTERS = /[\p{Cc}\p{Cf}\p{Cs}\p{Zl}\p{Zp}]/gu;
|
||||
const WHITESPACE = /\s/gu;
|
||||
|
||||
/** The characters that a reader sees, in order: no unsafe characters and no whitespace. */
|
||||
const visible = (text: string) => text.replace(UNSAFE_CHARACTERS, '').replace(WHITESPACE, '');
|
||||
|
||||
// Hard cases for a name that another instance sends.
|
||||
const trickyCharacterArb = fc.constantFrom(
|
||||
' ',
|
||||
'\t',
|
||||
'\n',
|
||||
'\r',
|
||||
'\u0000',
|
||||
'\u0007',
|
||||
'\u0085',
|
||||
'\u00a0',
|
||||
'\u2028',
|
||||
'\u2029',
|
||||
'\u200b',
|
||||
'\u200d',
|
||||
'\u202e',
|
||||
'\u2066',
|
||||
'\u3000',
|
||||
'\ufeff',
|
||||
'\ud83d',
|
||||
'\udc00',
|
||||
'🚀',
|
||||
'é',
|
||||
'運',
|
||||
);
|
||||
|
||||
// Any code point, also half surrogate pairs, with many of the hard cases.
|
||||
const characterArb = fc.oneof(
|
||||
{ weight: 3, arbitrary: fc.integer({ min: 0, max: 0x10ffff }).map(String.fromCodePoint) },
|
||||
{ weight: 3, arbitrary: trickyCharacterArb },
|
||||
{ weight: 4, arbitrary: fc.constantFrom('a', 'Z', '7', '-', '.', '<', '"') },
|
||||
);
|
||||
|
||||
const textArb = (maxLength: number) =>
|
||||
fc.array(characterArb, { maxLength }).map((characters) => characters.join(''));
|
||||
|
||||
// At least one letter that survives the cleaning, at any place in the name.
|
||||
const usableNameArb = fc
|
||||
.tuple(textArb(150), fc.constantFrom('a', 'Z', '7', 'é', '運', '🚀'), textArb(150))
|
||||
.map(([before, letter, after]) => `${before}${letter}${after}`);
|
||||
|
||||
const idArb = fc.stringMatching(ID_PATTERN);
|
||||
const typeArb = fc.constantFrom('personal' as const, 'team' as const);
|
||||
|
||||
const validItemArb = fc
|
||||
.tuple(
|
||||
fc.record({ id: idArb, name: usableNameArb, type: typeArb }),
|
||||
fc.record(
|
||||
{ matchType: fc.constantFrom('exact', 'partial'), role: fc.string() },
|
||||
{ requiredKeys: [] },
|
||||
),
|
||||
)
|
||||
.map(([project, extra]) => ({ ...extra, ...project }));
|
||||
|
||||
const badIdArb = fc.oneof(
|
||||
fc.constant(''),
|
||||
fc.stringMatching(/^[A-Za-z0-9_-]{37,60}$/),
|
||||
fc
|
||||
.tuple(fc.string({ maxLength: 10 }), fc.constantFrom('/', '.', ' ', 'é', '\n', ':', '%'))
|
||||
.map(([id, character]) => `${id}${character}`),
|
||||
fc.integer(),
|
||||
fc.constant(null),
|
||||
);
|
||||
|
||||
const badTypeArb = fc.oneof(
|
||||
fc.string().filter((type) => type !== 'personal' && type !== 'team'),
|
||||
fc.constant(undefined),
|
||||
fc.integer(),
|
||||
);
|
||||
|
||||
// Whitespace and characters without width. Two halves of a pair would make a visible character.
|
||||
const blankCharacterArb = fc.constantFrom(
|
||||
...[' ', '\t', '\n', '\r', '\u0000', '\u0007', '\u0085', '\u00a0', '\u2028', '\u2029'],
|
||||
...['\u200b', '\u200d', '\u202e', '\u2066', '\u3000', '\ufeff'],
|
||||
);
|
||||
|
||||
// Names that are empty after the cleaning, or not text.
|
||||
const badNameArb = fc.oneof(
|
||||
fc
|
||||
.tuple(fc.array(blankCharacterArb, { maxLength: 20 }), fc.constantFrom('', '\ud83d', '\udc00'))
|
||||
.map(([characters, half]) => `${characters.join('')}${half}`),
|
||||
fc.integer(),
|
||||
fc.constant(null),
|
||||
);
|
||||
|
||||
const invalidItemArb = fc.oneof(
|
||||
fc.oneof(fc.string(), fc.integer(), fc.boolean(), fc.constant(null), fc.array(fc.string())),
|
||||
fc.tuple(validItemArb, badIdArb).map(([item, id]) => ({ ...item, id })),
|
||||
fc.tuple(validItemArb, badTypeArb).map(([item, type]) => ({ ...item, type })),
|
||||
fc.tuple(validItemArb, badNameArb).map(([item, name]) => ({ ...item, name })),
|
||||
validItemArb.map(({ id, type }) => ({ id, type })),
|
||||
);
|
||||
|
||||
type ValidItem = { id: string; name: string; type: 'personal' | 'team' };
|
||||
|
||||
type Entry = { valid: true; item: ValidItem } | { valid: false; item: unknown };
|
||||
|
||||
const entriesArb: fc.Arbitrary<Entry[]> = fc.array(
|
||||
fc.oneof(
|
||||
{ weight: 2, arbitrary: validItemArb.map((item) => ({ valid: true as const, item })) },
|
||||
{ weight: 1, arbitrary: invalidItemArb.map((item) => ({ valid: false as const, item })) },
|
||||
),
|
||||
{ maxLength: 12 },
|
||||
);
|
||||
|
||||
const licenceArb = fc.option(fc.boolean(), { nil: undefined });
|
||||
|
||||
const resultFor = (items: unknown[], teamProjectsEnabled?: boolean) => ({
|
||||
data: items,
|
||||
count: items.length,
|
||||
...(teamProjectsEnabled === undefined ? {} : { teamProjectsEnabled }),
|
||||
});
|
||||
|
||||
const namesOf = (items: unknown[]) =>
|
||||
parseRemoteProjects(resultFor(items)).projects.map(({ name }) => name);
|
||||
|
||||
describe('parseRemoteProjects properties', () => {
|
||||
it('never throws for a list, whatever its items', () => {
|
||||
fc.assert(
|
||||
fc.property(fc.array(fc.anything()), licenceArb, (items, licence) => {
|
||||
expect(() => parseRemoteProjects(resultFor(items, licence))).not.toThrow();
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the valid items, in their order, and skips all others', () => {
|
||||
fc.assert(
|
||||
fc.property(entriesArb, (entries) => {
|
||||
const { projects } = parseRemoteProjects(resultFor(entries.map(({ item }) => item)));
|
||||
|
||||
const expected = entries.flatMap((entry) => (entry.valid ? [entry.item] : []));
|
||||
expect(projects.map(({ id, type }) => ({ id, type }))).toEqual(
|
||||
expected.map(({ id, type }) => ({ id, type })),
|
||||
);
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('returns only ids that are safe to send back, and names of 1 to 255 clean characters', () => {
|
||||
fc.assert(
|
||||
fc.property(entriesArb, fc.array(fc.anything(), { maxLength: 4 }), (entries, others) => {
|
||||
const items = [...entries.map(({ item }) => item), ...others];
|
||||
const { projects } = parseRemoteProjects(resultFor(items));
|
||||
|
||||
for (const { id, name } of projects) {
|
||||
expect(id).toMatch(ID_PATTERN);
|
||||
expect(name.length).toBeGreaterThanOrEqual(1);
|
||||
expect(name.length).toBeLessThanOrEqual(255);
|
||||
expect(name).not.toMatch(UNSAFE_CHARACTER);
|
||||
expect(name).toBe(name.trim());
|
||||
}
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('reads the team licence, and counts team projects as licensed when it is absent', () => {
|
||||
fc.assert(
|
||||
fc.property(entriesArb, licenceArb, (entries, licence) => {
|
||||
const items = entries.map(({ item }) => item);
|
||||
|
||||
const result = parseRemoteProjects(resultFor(items, licence));
|
||||
|
||||
expect(result.teamProjectsEnabled).toBe(licence ?? true);
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps a clean name of up to 255 characters, apart from the outer whitespace', () => {
|
||||
const cleanNameArb = usableNameArb
|
||||
.map((name) => name.replace(UNSAFE_CHARACTERS, ''))
|
||||
.filter((name) => name.length <= 255);
|
||||
|
||||
fc.assert(
|
||||
fc.property(idArb, cleanNameArb, typeArb, (id, name, type) => {
|
||||
expect(namesOf([{ id, name, type }])).toEqual([name.trim()]);
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps every visible character in order, and cuts only the end of a long name', () => {
|
||||
fc.assert(
|
||||
fc.property(idArb, usableNameArb, typeArb, (id, name, type) => {
|
||||
const [cleaned] = namesOf([{ id, name, type }]);
|
||||
|
||||
const shown = cleaned.endsWith('...') ? cleaned.slice(0, -3) : cleaned;
|
||||
expect(visible(name).startsWith(visible(shown))).toBe(true);
|
||||
if (!cleaned.endsWith('...')) expect(visible(cleaned)).toBe(visible(name));
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('cuts a name with more than 255 visible characters to 254 or 255, with an ellipsis', () => {
|
||||
const longNameArb = fc
|
||||
.tuple(
|
||||
fc.array(fc.constantFrom('a', 'é', '運', '🚀'), { minLength: 256, maxLength: 400 }),
|
||||
textArb(20),
|
||||
)
|
||||
.map(([letters, tail]) => `${letters.join('')}${tail}`);
|
||||
|
||||
fc.assert(
|
||||
fc.property(idArb, longNameArb, typeArb, (id, name, type) => {
|
||||
const [cleaned] = namesOf([{ id, name, type }]);
|
||||
|
||||
expect(cleaned.endsWith('...')).toBe(true);
|
||||
expect(cleaned.length).toBeGreaterThanOrEqual(254);
|
||||
expect(cleaned.length).toBeLessThanOrEqual(255);
|
||||
expect(cleaned).not.toMatch(UNSAFE_CHARACTER);
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('throws a tool error for a result without a project list', () => {
|
||||
const withoutListArb = fc
|
||||
.anything()
|
||||
.filter(
|
||||
(value) =>
|
||||
typeof value !== 'object' ||
|
||||
value === null ||
|
||||
!Array.isArray((value as { data?: unknown }).data),
|
||||
);
|
||||
|
||||
fc.assert(
|
||||
fc.property(withoutListArb, (result) => {
|
||||
let error: unknown;
|
||||
try {
|
||||
parseRemoteProjects(result);
|
||||
} catch (e) {
|
||||
error = e;
|
||||
}
|
||||
expect(error).toBeInstanceOf(RemoteInstanceError);
|
||||
expect(error).toHaveProperty('reason', 'tool-error');
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,317 @@
|
|||
import { mock } from 'vitest-mock-extended';
|
||||
|
||||
import type { RemoteInstanceClient } from '../remote/remote-instance.client';
|
||||
import { RemoteInstanceError } from '../remote/remote-instance.errors';
|
||||
import { listRemoteProjects, parseRemoteProjects, SEARCH_PROJECTS_TOOL } from '../remote-projects';
|
||||
|
||||
const ops = { id: 'Xk3pQ9aZ1bC2dE4f', name: 'Ops', type: 'team' };
|
||||
const personal = { id: 'Pm0rT8sU7vW6xY5z', name: 'Ada Lovelace <ada@acme.test>', type: 'personal' };
|
||||
|
||||
/** The structured output of `search_projects` on an n8n instance. */
|
||||
const toolOutput = (data: unknown[], extra: Record<string, unknown> = {}) => ({
|
||||
data,
|
||||
count: data.length,
|
||||
teamProjectsEnabled: true,
|
||||
...extra,
|
||||
});
|
||||
|
||||
async function expectToolError(run: () => unknown) {
|
||||
let error: unknown;
|
||||
try {
|
||||
await run();
|
||||
} catch (e) {
|
||||
error = e;
|
||||
}
|
||||
expect(error).toBeInstanceOf(RemoteInstanceError);
|
||||
expect(error).toHaveProperty('reason', 'tool-error');
|
||||
expect(error).toHaveProperty(
|
||||
'message',
|
||||
'The linked instance sent its projects in an unknown format.',
|
||||
);
|
||||
}
|
||||
|
||||
describe('parseRemoteProjects', () => {
|
||||
it('reads the projects in order and the team licence', () => {
|
||||
expect(parseRemoteProjects(toolOutput([ops, personal]))).toEqual({
|
||||
projects: [ops, personal],
|
||||
teamProjectsEnabled: true,
|
||||
});
|
||||
expect(parseRemoteProjects(toolOutput([personal], { teamProjectsEnabled: false }))).toEqual({
|
||||
projects: [personal],
|
||||
teamProjectsEnabled: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('counts team projects as licensed when an older instance does not say', () => {
|
||||
expect(parseRemoteProjects({ data: [ops] }).teamProjectsEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it('drops fields that it does not use', () => {
|
||||
const result = parseRemoteProjects(
|
||||
toolOutput([{ ...ops, matchType: 'exact', role: 'project:viewer' }], { hint: 'Ask first' }),
|
||||
);
|
||||
|
||||
expect(result).toEqual({ projects: [ops], teamProjectsEnabled: true });
|
||||
});
|
||||
|
||||
it.each([
|
||||
['a project of an unknown type', { ...ops, type: 'workspace' }],
|
||||
['a project without an id', { name: 'Ops', type: 'team' }],
|
||||
['a project with an empty id', { ...ops, id: '' }],
|
||||
['a project with an id over 36 characters', { ...ops, id: 'a'.repeat(37) }],
|
||||
['a project with a path in the id', { ...ops, id: '../workflows' }],
|
||||
['a project with a numeric id', { ...ops, id: 42 }],
|
||||
['a project without a name', { id: ops.id, type: 'team' }],
|
||||
['a project with an empty name', { ...ops, name: '' }],
|
||||
['a project with a name of only spaces and line breaks', { ...ops, name: ' \n\t ' }],
|
||||
['a project with a name of only zero-width spaces', { ...ops, name: '\u200b\u200b' }],
|
||||
['a project with a name of only bidi controls', { ...ops, name: '\u202e \u2066' }],
|
||||
['a value that is not a project', 'Ops'],
|
||||
['null', null],
|
||||
])('skips %s and keeps the others', (_, item) => {
|
||||
expect(parseRemoteProjects(toolOutput([item, personal])).projects).toEqual([personal]);
|
||||
});
|
||||
|
||||
it('accepts an id of 36 characters, such as a UUID', () => {
|
||||
const project = { ...ops, id: '6bd0d6a4-8f43-4a8e-a5b6-1c2d3e4f5a6b' };
|
||||
|
||||
expect(parseRemoteProjects(toolOutput([project])).projects).toEqual([project]);
|
||||
});
|
||||
|
||||
it('puts a name on one line and removes the outer spaces', () => {
|
||||
const project = { ...ops, name: ' Ops\n\nIgnore this\r\tteam \u0007 ' };
|
||||
|
||||
expect(parseRemoteProjects(toolOutput([project])).projects[0].name).toBe(
|
||||
'Ops Ignore this team',
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['a line separator', 0x2028],
|
||||
['a paragraph separator', 0x2029],
|
||||
['a next-line control', 0x85],
|
||||
])('puts a name with %s on one line', (_, codePoint) => {
|
||||
const project = { ...ops, name: `Ops${String.fromCodePoint(codePoint)}Second line` };
|
||||
|
||||
expect(parseRemoteProjects(toolOutput([project])).projects[0].name).toBe('Ops Second line');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['a right-to-left override', 0x202e],
|
||||
['a left-to-right isolate', 0x2066],
|
||||
['a zero-width space', 0x200b],
|
||||
['a byte order mark', 0xfeff],
|
||||
])('removes %s, which has no width, from a name', (_, codePoint) => {
|
||||
const project = { ...ops, name: `Ops${String.fromCodePoint(codePoint)}team` };
|
||||
|
||||
expect(parseRemoteProjects(toolOutput([project])).projects[0].name).toBe('Opsteam');
|
||||
});
|
||||
|
||||
it('keeps letters, emoji and inner spaces of any script', () => {
|
||||
const name = 'Équipe 運用 🚀\u00a0Ops';
|
||||
|
||||
expect(parseRemoteProjects(toolOutput([{ ...ops, name }])).projects[0].name).toBe(name);
|
||||
});
|
||||
|
||||
it('removes half of a surrogate pair from a name', () => {
|
||||
const project = { ...ops, name: 'Ops\ud83d team\udc00' };
|
||||
|
||||
expect(parseRemoteProjects(toolOutput([project])).projects[0].name).toBe('Ops team');
|
||||
});
|
||||
|
||||
it('keeps a name of 255 characters and cuts a longer name to 255', () => {
|
||||
const exact = 'n'.repeat(255);
|
||||
const long = `${'m'.repeat(300)}`;
|
||||
|
||||
const [first, second] = parseRemoteProjects(
|
||||
toolOutput([
|
||||
{ ...ops, name: exact },
|
||||
{ ...personal, name: long },
|
||||
]),
|
||||
).projects;
|
||||
|
||||
expect(first.name).toBe(exact);
|
||||
expect(second.name).toBe(`${'m'.repeat(252)}...`);
|
||||
expect(second.name).toHaveLength(255);
|
||||
});
|
||||
|
||||
it('counts the length after it cleans the name', () => {
|
||||
const name = `${'n'.repeat(255)}\u200b\u202e`;
|
||||
|
||||
expect(parseRemoteProjects(toolOutput([{ ...ops, name }])).projects[0].name).toBe(
|
||||
'n'.repeat(255),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not cut an emoji in two when it cuts a long name', () => {
|
||||
const name = `${'m'.repeat(251)}🚀${'m'.repeat(10)}`;
|
||||
|
||||
const [project] = parseRemoteProjects(toolOutput([{ ...ops, name }])).projects;
|
||||
|
||||
expect(project.name).toBe(`${'m'.repeat(251)}...`);
|
||||
});
|
||||
|
||||
it('returns an empty list when the instance lists no project', () => {
|
||||
expect(parseRemoteProjects(toolOutput([]))).toEqual({
|
||||
projects: [],
|
||||
teamProjectsEnabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
['text', 'Projects: Ops'],
|
||||
['null', null],
|
||||
['an object without a list', { count: 0 }],
|
||||
['a list that is not an array', { data: { ops } }],
|
||||
['a licence flag that is not a boolean', { data: [], teamProjectsEnabled: 'yes' }],
|
||||
])('throws a tool error for %s', async (_, result) => {
|
||||
await expectToolError(() => parseRemoteProjects(result));
|
||||
});
|
||||
|
||||
it('reads the list when the total is not a number', () => {
|
||||
expect(parseRemoteProjects({ data: [ops], count: 'many' }).projects).toEqual([ops]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('listRemoteProjects', () => {
|
||||
it('asks for up to 100 projects within 10 seconds and reads the result', async () => {
|
||||
const client = mock<RemoteInstanceClient>();
|
||||
client.callTool.mockResolvedValue(toolOutput([ops, personal]));
|
||||
|
||||
const result = await listRemoteProjects(client);
|
||||
|
||||
expect(SEARCH_PROJECTS_TOOL).toBe('search_projects');
|
||||
expect(client.callTool).toHaveBeenCalledWith(
|
||||
'search_projects',
|
||||
{ limit: 100 },
|
||||
{ timeoutMs: 10_000 },
|
||||
);
|
||||
expect(result).toEqual({ projects: [ops, personal], teamProjectsEnabled: true });
|
||||
});
|
||||
|
||||
it('passes on a failed call', async () => {
|
||||
const client = mock<RemoteInstanceClient>();
|
||||
const failure = new RemoteInstanceError('timeout');
|
||||
client.callTool.mockRejectedValue(failure);
|
||||
|
||||
await expect(listRemoteProjects(client)).rejects.toBe(failure);
|
||||
});
|
||||
|
||||
it('throws a tool error when the result is not a project list', async () => {
|
||||
const client = mock<RemoteInstanceClient>();
|
||||
client.callTool.mockResolvedValue('No projects');
|
||||
|
||||
await expectToolError(async () => await listRemoteProjects(client));
|
||||
});
|
||||
|
||||
describe('when team projects fill the list', () => {
|
||||
const team = (index: number) => ({ id: `team${index}`, name: `Team ${index}`, type: 'team' });
|
||||
const fullPage = (teamProjectsEnabled = false) =>
|
||||
toolOutput(
|
||||
Array.from({ length: 100 }, (_, index) => team(index)),
|
||||
{
|
||||
count: 150,
|
||||
teamProjectsEnabled,
|
||||
},
|
||||
);
|
||||
|
||||
it('asks for the personal project and adds it after the team projects', async () => {
|
||||
const client = mock<RemoteInstanceClient>();
|
||||
client.callTool
|
||||
.mockResolvedValueOnce(fullPage())
|
||||
.mockResolvedValueOnce(toolOutput([personal], { count: 1 }));
|
||||
|
||||
const result = await listRemoteProjects(client);
|
||||
|
||||
expect(client.callTool).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'search_projects',
|
||||
{ type: 'personal', limit: 1 },
|
||||
{ timeoutMs: 10_000 },
|
||||
);
|
||||
expect(result.teamProjectsEnabled).toBe(false);
|
||||
expect(result.projects).toHaveLength(101);
|
||||
expect(result.projects.at(-1)).toEqual(personal);
|
||||
});
|
||||
|
||||
it('adds only a personal project from the second answer', async () => {
|
||||
const client = mock<RemoteInstanceClient>();
|
||||
client.callTool.mockResolvedValueOnce(fullPage()).mockResolvedValueOnce(toolOutput([ops]));
|
||||
|
||||
const result = await listRemoteProjects(client);
|
||||
|
||||
expect(result.projects).toHaveLength(100);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['the list is complete', toolOutput([ops], { count: 1 })],
|
||||
['the instance does not send the total', { data: [ops] }],
|
||||
['the list holds the personal project', toolOutput([ops, personal], { count: 150 })],
|
||||
])('makes one call when %s', async (_, output) => {
|
||||
const client = mock<RemoteInstanceClient>();
|
||||
client.callTool.mockResolvedValue(output);
|
||||
|
||||
await listRemoteProjects(client);
|
||||
|
||||
expect(client.callTool).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('passes on a failed second call', async () => {
|
||||
const client = mock<RemoteInstanceClient>();
|
||||
const failure = new RemoteInstanceError('tool-error');
|
||||
client.callTool.mockResolvedValueOnce(fullPage(true)).mockRejectedValueOnce(failure);
|
||||
|
||||
await expect(listRemoteProjects(client)).rejects.toBe(failure);
|
||||
});
|
||||
});
|
||||
|
||||
describe('time limit', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
const neverAnswers = async () => await new Promise<never>(() => {});
|
||||
|
||||
const answersAfter = (ms: number, output: unknown) => async () =>
|
||||
await new Promise((resolve) => setTimeout(() => resolve(output), ms));
|
||||
|
||||
it('throws a timeout error when the instance does not answer in 10 seconds', async () => {
|
||||
const client = mock<RemoteInstanceClient>();
|
||||
client.callTool.mockImplementation(neverAnswers);
|
||||
|
||||
const listing = listRemoteProjects(client).catch((error: unknown) => error);
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
|
||||
const error = await listing;
|
||||
expect(error).toBeInstanceOf(RemoteInstanceError);
|
||||
expect(error).toHaveProperty('reason', 'timeout');
|
||||
});
|
||||
|
||||
it('counts both calls against the same 10 seconds', async () => {
|
||||
const client = mock<RemoteInstanceClient>();
|
||||
client.callTool
|
||||
.mockImplementationOnce(answersAfter(6_000, toolOutput([ops], { count: 150 })))
|
||||
.mockImplementationOnce(answersAfter(6_000, toolOutput([personal])));
|
||||
|
||||
const listing = listRemoteProjects(client).catch((error: unknown) => error);
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
|
||||
expect(client.callTool).toHaveBeenCalledTimes(2);
|
||||
await expect(listing).resolves.toHaveProperty('reason', 'timeout');
|
||||
});
|
||||
|
||||
it('returns the projects when the instance answers in time', async () => {
|
||||
const client = mock<RemoteInstanceClient>();
|
||||
client.callTool.mockImplementation(answersAfter(9_999, toolOutput([ops])));
|
||||
|
||||
const listing = listRemoteProjects(client);
|
||||
await vi.advanceTimersByTimeAsync(9_999);
|
||||
|
||||
await expect(listing).resolves.toEqual({ projects: [ops], teamProjectsEnabled: true });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,8 +1,7 @@
|
|||
import type { LinkedInstanceStatus } from '@n8n/api-types';
|
||||
import { DateTimeColumn, WithTimestamps } from '@n8n/db';
|
||||
import { Column, Entity, Index, PrimaryColumn } from '@n8n/typeorm';
|
||||
|
||||
import type { LinkedInstanceStatus } from '../../linked-instances.types';
|
||||
|
||||
/** Another n8n instance that one user linked. The user's deletion removes the row. */
|
||||
@Entity('linked_instance')
|
||||
@Index(['userId', 'baseUrl'], { unique: true })
|
||||
|
|
@ -30,4 +29,12 @@ export class LinkedInstance extends WithTimestamps {
|
|||
|
||||
@DateTimeColumn({ nullable: true })
|
||||
lastVerifiedAt: Date | null;
|
||||
|
||||
/** Project on the linked instance that gets new automations. Set with its name, or not at all. */
|
||||
@Column({ type: 'varchar', length: 36, nullable: true })
|
||||
defaultRemoteProjectId: string | null;
|
||||
|
||||
/** Name of that project when it was last read from the linked instance. */
|
||||
@Column({ type: 'varchar', length: 255, nullable: true })
|
||||
defaultRemoteProjectName: string | null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import type { LinkedInstanceStatus } from '@n8n/api-types';
|
||||
import {
|
||||
BaseRepository,
|
||||
isUniqueConstraintError,
|
||||
|
|
@ -9,7 +10,6 @@ import {
|
|||
import { Service } from '@n8n/di';
|
||||
import { DataSource } from '@n8n/typeorm';
|
||||
|
||||
import type { LinkedInstanceStatus } from '../../linked-instances.types';
|
||||
import { LinkedInstance } from '../entities/linked-instance.entity';
|
||||
|
||||
export type NewLinkedInstance = {
|
||||
|
|
@ -19,8 +19,23 @@ export type NewLinkedInstance = {
|
|||
tokenEncrypted: string;
|
||||
status: LinkedInstanceStatus;
|
||||
lastVerifiedAt: Date | null;
|
||||
defaultRemoteProjectId?: string | null;
|
||||
defaultRemoteProjectName?: string | null;
|
||||
};
|
||||
|
||||
/** The columns that a user can change on their link. A column that is left out keeps its value. */
|
||||
export type LinkedInstanceUpdate = Partial<
|
||||
Pick<
|
||||
LinkedInstance,
|
||||
| 'name'
|
||||
| 'tokenEncrypted'
|
||||
| 'status'
|
||||
| 'lastVerifiedAt'
|
||||
| 'defaultRemoteProjectId'
|
||||
| 'defaultRemoteProjectName'
|
||||
>
|
||||
>;
|
||||
|
||||
/** Every query filters by `userId`, so a user can only reach their own links. */
|
||||
@Service()
|
||||
export class LinkedInstanceRepository extends BaseRepository<LinkedInstance> {
|
||||
|
|
@ -70,14 +85,11 @@ export class LinkedInstanceRepository extends BaseRepository<LinkedInstance> {
|
|||
return (affected ?? 0) > 0;
|
||||
}
|
||||
|
||||
/** Returns `false` when the user has no link with this id. */
|
||||
async updateStatus(
|
||||
userId: string,
|
||||
id: string,
|
||||
status: LinkedInstanceStatus,
|
||||
verifiedAt: Date,
|
||||
): Promise<boolean> {
|
||||
const { affected } = await this.update({ id, userId }, { status, lastVerifiedAt: verifiedAt });
|
||||
/** Sets the given columns in one statement. Returns `false` when the user has no link with this id. */
|
||||
async updateForUser(userId: string, id: string, update: LinkedInstanceUpdate): Promise<boolean> {
|
||||
// TypeORM rejects an update without values.
|
||||
if (Object.keys(update).length === 0) return await this.existsBy({ id, userId });
|
||||
const { affected } = await this.update({ id, userId }, update);
|
||||
return (affected ?? 0) > 0;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,11 @@
|
|||
import {
|
||||
LINKED_INSTANCE_INPUT_MESSAGES,
|
||||
linkedInstanceNameSchema,
|
||||
linkedInstanceRemoteProjectIdSchema,
|
||||
linkedInstanceTokenSchema,
|
||||
linkedInstanceUrlSchema,
|
||||
} from '@n8n/api-types';
|
||||
import { BadRequestError } from '@n8n/errors';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { normaliseInstanceAddress, type InstanceAddressError } from './instance-address';
|
||||
|
||||
|
|
@ -7,25 +13,18 @@ export type LinkInstanceInput = { name: string; address: string; token: string }
|
|||
|
||||
export type ParsedLinkInput = { name: string; origin: string; token: string };
|
||||
|
||||
// The name shows in prompts and in the UI, so it has no markup or line breaks.
|
||||
const NAME_PATTERN = /^[\p{L}\p{N} ._()-]+$/u;
|
||||
// A header value can hold only visible ASCII characters.
|
||||
const TOKEN_PATTERN = /^[\x21-\x7E]+$/;
|
||||
const MAX_ADDRESS_LENGTH = 2048;
|
||||
|
||||
const nameSchema = z.string().trim().min(1).max(64).regex(NAME_PATTERN);
|
||||
const tokenSchema = z.string().trim().min(1).max(4096).regex(TOKEN_PATTERN);
|
||||
const addressSchema = z.string().max(MAX_ADDRESS_LENGTH);
|
||||
export type LinkUpdateInput = { name?: string; token?: string; defaultRemoteProjectId?: string };
|
||||
|
||||
export const LINK_INPUT_MESSAGES = {
|
||||
name: 'Enter a name of 1 to 64 characters. Use only letters, digits, spaces and these characters: - _ . ( )',
|
||||
token:
|
||||
'Enter an access token of 1 to 4096 characters. Use only the letters A to Z, digits and symbols, without spaces or accented characters.',
|
||||
name: LINKED_INSTANCE_INPUT_MESSAGES.name,
|
||||
token: LINKED_INSTANCE_INPUT_MESSAGES.token,
|
||||
defaultRemoteProjectId: LINKED_INSTANCE_INPUT_MESSAGES.defaultRemoteProjectId,
|
||||
noChange: 'Change the name, the access token or the default project.',
|
||||
} as const;
|
||||
|
||||
export const ADDRESS_ERROR_MESSAGES: Record<InstanceAddressError, string> = {
|
||||
empty: 'Enter the address of the n8n instance.',
|
||||
invalid: 'That address is not valid. Check it and try again.',
|
||||
invalid: LINKED_INSTANCE_INPUT_MESSAGES.url,
|
||||
'unsupported-protocol': 'Enter an address that starts with https://',
|
||||
'insecure-http':
|
||||
'Use https:// for this address. Plain http:// works only for an instance on this computer.',
|
||||
|
|
@ -33,23 +32,52 @@ export const ADDRESS_ERROR_MESSAGES: Record<InstanceAddressError, string> = {
|
|||
};
|
||||
|
||||
function parseOrigin(address: unknown): string {
|
||||
const parsed = addressSchema.safeParse(address);
|
||||
const parsed = linkedInstanceUrlSchema.safeParse(address);
|
||||
if (!parsed.success) throw new BadRequestError(ADDRESS_ERROR_MESSAGES.invalid);
|
||||
const result = normaliseInstanceAddress(parsed.data);
|
||||
if (!result.ok) throw new BadRequestError(ADDRESS_ERROR_MESSAGES[result.error]);
|
||||
return result.origin;
|
||||
}
|
||||
|
||||
/** @throws {BadRequestError} */
|
||||
export function parseLinkName(name: unknown): string {
|
||||
const parsed = linkedInstanceNameSchema.safeParse(name);
|
||||
if (!parsed.success) throw new BadRequestError(LINK_INPUT_MESSAGES.name);
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
/** @throws {BadRequestError} with a message that never repeats the token */
|
||||
export function parseLinkToken(token: unknown): string {
|
||||
const parsed = linkedInstanceTokenSchema.safeParse(token);
|
||||
if (!parsed.success) throw new BadRequestError(LINK_INPUT_MESSAGES.token);
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks and cleans what a user gives to link an instance.
|
||||
* The error messages never repeat the input, so they cannot leak the token.
|
||||
* @throws {BadRequestError}
|
||||
*/
|
||||
export function parseLinkInput(input: LinkInstanceInput): ParsedLinkInput {
|
||||
const name = nameSchema.safeParse(input.name);
|
||||
if (!name.success) throw new BadRequestError(LINK_INPUT_MESSAGES.name);
|
||||
const name = parseLinkName(input.name);
|
||||
const origin = parseOrigin(input.address);
|
||||
const token = tokenSchema.safeParse(input.token);
|
||||
if (!token.success) throw new BadRequestError(LINK_INPUT_MESSAGES.token);
|
||||
return { name: name.data, origin, token: token.data };
|
||||
const token = parseLinkToken(input.token);
|
||||
return { name, origin, token };
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks a change to a link. Leaves out the fields that the user did not send.
|
||||
* @throws {BadRequestError} when a field is not valid or nothing changes
|
||||
*/
|
||||
export function parseLinkUpdate(input: LinkUpdateInput): LinkUpdateInput {
|
||||
const update: LinkUpdateInput = {};
|
||||
if (input.name !== undefined) update.name = parseLinkName(input.name);
|
||||
if (input.token !== undefined) update.token = parseLinkToken(input.token);
|
||||
if (input.defaultRemoteProjectId !== undefined) {
|
||||
const projectId = linkedInstanceRemoteProjectIdSchema.safeParse(input.defaultRemoteProjectId);
|
||||
if (!projectId.success) throw new BadRequestError(LINK_INPUT_MESSAGES.defaultRemoteProjectId);
|
||||
update.defaultRemoteProjectId = projectId.data;
|
||||
}
|
||||
if (Object.keys(update).length === 0) throw new BadRequestError(LINK_INPUT_MESSAGES.noChange);
|
||||
return update;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,17 @@
|
|||
import type {
|
||||
LinkedInstanceRemoteProject,
|
||||
LinkedInstanceStatus,
|
||||
LinkedInstanceSummary,
|
||||
} from '@n8n/api-types';
|
||||
import { Service } from '@n8n/di';
|
||||
import { Cipher } from 'n8n-core';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { LinkedInstance } from './database/entities/linked-instance.entity';
|
||||
import { LinkedInstanceRepository } from './database/repositories/linked-instance.repository';
|
||||
import type { LinkedInstanceStatus, LinkedInstanceSummary } from './linked-instances.types';
|
||||
import {
|
||||
LinkedInstanceRepository,
|
||||
type LinkedInstanceUpdate,
|
||||
} from './database/repositories/linked-instance.repository';
|
||||
|
||||
export type NewLinkedInstanceInput = {
|
||||
userId: string;
|
||||
|
|
@ -13,10 +20,26 @@ export type NewLinkedInstanceInput = {
|
|||
token: string;
|
||||
status: LinkedInstanceStatus;
|
||||
verifiedAt: Date;
|
||||
defaultRemoteProject: LinkedInstanceRemoteProject | null;
|
||||
};
|
||||
|
||||
/** The fields that a change sets. A field that is left out keeps its value. */
|
||||
export type LinkedInstanceChanges = {
|
||||
name?: string;
|
||||
token?: string;
|
||||
status?: LinkedInstanceStatus;
|
||||
verifiedAt?: Date;
|
||||
defaultRemoteProject?: LinkedInstanceRemoteProject | null;
|
||||
};
|
||||
|
||||
export type LinkedInstanceCredentials = { origin: string; token: string };
|
||||
|
||||
/** A link as stored. Do not keep or log the token that `readToken` returns. */
|
||||
export type StoredLinkedInstance = {
|
||||
summary: LinkedInstanceSummary;
|
||||
readToken: () => Promise<string>;
|
||||
};
|
||||
|
||||
const idSchema = z.string().uuid();
|
||||
|
||||
// A Postgres uuid column rejects other strings, so an id in another format matches no row.
|
||||
|
|
@ -25,6 +48,7 @@ function isLinkedInstanceId(id: string): boolean {
|
|||
}
|
||||
|
||||
function toSummary(row: LinkedInstance): LinkedInstanceSummary {
|
||||
const { defaultRemoteProjectId: projectId, defaultRemoteProjectName: projectName } = row;
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
|
|
@ -32,12 +56,21 @@ function toSummary(row: LinkedInstance): LinkedInstanceSummary {
|
|||
status: row.status,
|
||||
lastVerifiedAt: row.lastVerifiedAt?.toISOString() ?? null,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
defaultRemoteProject: projectId && projectName ? { id: projectId, name: projectName } : null,
|
||||
};
|
||||
}
|
||||
|
||||
function toProjectColumns(project: LinkedInstanceRemoteProject | null) {
|
||||
return {
|
||||
defaultRemoteProjectId: project?.id ?? null,
|
||||
defaultRemoteProjectName: project?.name ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps linked instances and their encrypted access tokens.
|
||||
* The token goes in only as ciphertext, and comes out in clear only through `readCredentials`.
|
||||
* The token goes in only as ciphertext, and comes out in clear only through `readCredentials`
|
||||
* and the `readToken` function of `findForUse`.
|
||||
*/
|
||||
@Service()
|
||||
export class LinkedInstanceStore {
|
||||
|
|
@ -51,6 +84,13 @@ export class LinkedInstanceStore {
|
|||
return rows.map(toSummary);
|
||||
}
|
||||
|
||||
/** Returns `null` when the user has no link with this id. */
|
||||
async getForUser(userId: string, id: string): Promise<LinkedInstanceSummary | null> {
|
||||
if (!isLinkedInstanceId(id)) return null;
|
||||
const row = await this.repository.findForUser(userId, id);
|
||||
return row ? toSummary(row) : null;
|
||||
}
|
||||
|
||||
async existsForUser(userId: string, origin: string): Promise<boolean> {
|
||||
return await this.repository.existsForUser(userId, origin);
|
||||
}
|
||||
|
|
@ -64,21 +104,62 @@ export class LinkedInstanceStore {
|
|||
tokenEncrypted: await this.cipher.encryptV2(input.token),
|
||||
status: input.status,
|
||||
lastVerifiedAt: input.verifiedAt,
|
||||
...toProjectColumns(input.defaultRemoteProject),
|
||||
});
|
||||
return row ? toSummary(row) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the given fields in one statement. Encrypts a new token.
|
||||
* Returns `null` when the user has no link with this id.
|
||||
*/
|
||||
async updateForUser(
|
||||
userId: string,
|
||||
id: string,
|
||||
changes: LinkedInstanceChanges,
|
||||
): Promise<LinkedInstanceSummary | null> {
|
||||
if (!isLinkedInstanceId(id)) return null;
|
||||
const update = await this.toUpdate(changes);
|
||||
if (!(await this.repository.updateForUser(userId, id, update))) return null;
|
||||
return await this.getForUser(userId, id);
|
||||
}
|
||||
|
||||
/** Returns `false` when the user has no link with this id. */
|
||||
async deleteForUser(userId: string, id: string): Promise<boolean> {
|
||||
if (!isLinkedInstanceId(id)) return false;
|
||||
return await this.repository.deleteForUser(userId, id);
|
||||
}
|
||||
|
||||
/** Decrypts the token for one request. Returns `null` when the user has no link with this id. */
|
||||
async readCredentials(userId: string, id: string): Promise<LinkedInstanceCredentials | null> {
|
||||
/**
|
||||
* Reads the link once. The token stays encrypted until `readToken` runs.
|
||||
* Returns `null` when the user has no link with this id.
|
||||
*/
|
||||
async findForUse(userId: string, id: string): Promise<StoredLinkedInstance | null> {
|
||||
if (!isLinkedInstanceId(id)) return null;
|
||||
const row = await this.repository.findForUser(userId, id);
|
||||
if (!row) return null;
|
||||
return { origin: row.baseUrl, token: await this.cipher.decryptV2(row.tokenEncrypted) };
|
||||
const { tokenEncrypted } = row;
|
||||
return {
|
||||
summary: toSummary(row),
|
||||
readToken: async () => await this.cipher.decryptV2(tokenEncrypted),
|
||||
};
|
||||
}
|
||||
|
||||
/** Decrypts the token for one request. Returns `null` when the user has no link with this id. */
|
||||
async readCredentials(userId: string, id: string): Promise<LinkedInstanceCredentials | null> {
|
||||
const link = await this.findForUse(userId, id);
|
||||
if (!link) return null;
|
||||
return { origin: link.summary.baseUrl, token: await link.readToken() };
|
||||
}
|
||||
|
||||
private async toUpdate(changes: LinkedInstanceChanges): Promise<LinkedInstanceUpdate> {
|
||||
const { name, token, status, verifiedAt, defaultRemoteProject } = changes;
|
||||
return {
|
||||
...(name === undefined ? {} : { name }),
|
||||
...(token === undefined ? {} : { tokenEncrypted: await this.cipher.encryptV2(token) }),
|
||||
...(status === undefined ? {} : { status }),
|
||||
...(verifiedAt === undefined ? {} : { lastVerifiedAt: verifiedAt }),
|
||||
...(defaultRemoteProject === undefined ? {} : toProjectColumns(defaultRemoteProject)),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,78 @@
|
|||
import {
|
||||
LinkInstanceRequestDto,
|
||||
UpdateLinkedInstanceRequestDto,
|
||||
type LinkedInstanceSummary,
|
||||
} from '@n8n/api-types';
|
||||
import { Time } from '@n8n/constants';
|
||||
import { AuthenticatedRequest } from '@n8n/db';
|
||||
import {
|
||||
Body,
|
||||
createUserKeyedRateLimiter,
|
||||
Delete,
|
||||
Get,
|
||||
GlobalScope,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
RestController,
|
||||
} from '@n8n/decorators';
|
||||
import type { Response } from 'express';
|
||||
|
||||
import { LinkedInstancesService } from './linked-instances.service';
|
||||
|
||||
// These routes reach another instance, so each user gets a small budget on each route.
|
||||
const remoteCallRateLimit = () =>
|
||||
createUserKeyedRateLimiter({ limit: 10, windowMs: Time.minutes.toMilliseconds });
|
||||
|
||||
/** Each signed-in user manages only their own links. No response holds a token. */
|
||||
@RestController('/linked-instances')
|
||||
export class LinkedInstancesController {
|
||||
constructor(private readonly service: LinkedInstancesService) {}
|
||||
|
||||
@Get('/')
|
||||
@GlobalScope('instanceAi:message')
|
||||
async list(req: AuthenticatedRequest): Promise<LinkedInstanceSummary[]> {
|
||||
return await this.service.list(req.user);
|
||||
}
|
||||
|
||||
@Post('/', { keyedRateLimit: remoteCallRateLimit() })
|
||||
@GlobalScope('instanceAi:message')
|
||||
async link(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response,
|
||||
@Body payload: LinkInstanceRequestDto,
|
||||
): Promise<LinkedInstanceSummary> {
|
||||
const { name, url, token } = payload;
|
||||
const summary = await this.service.link(req.user, { name, address: url, token });
|
||||
res.status(201);
|
||||
return summary;
|
||||
}
|
||||
|
||||
@Post('/:id/verify', { keyedRateLimit: remoteCallRateLimit() })
|
||||
@GlobalScope('instanceAi:message')
|
||||
async verify(
|
||||
req: AuthenticatedRequest,
|
||||
_res: Response,
|
||||
@Param('id') id: string,
|
||||
): Promise<LinkedInstanceSummary> {
|
||||
return await this.service.verify(req.user, id);
|
||||
}
|
||||
|
||||
@Patch('/:id', { keyedRateLimit: remoteCallRateLimit() })
|
||||
@GlobalScope('instanceAi:message')
|
||||
async update(
|
||||
req: AuthenticatedRequest,
|
||||
_res: Response,
|
||||
@Param('id') id: string,
|
||||
@Body payload: UpdateLinkedInstanceRequestDto,
|
||||
): Promise<LinkedInstanceSummary> {
|
||||
return await this.service.update(req.user, id, payload);
|
||||
}
|
||||
|
||||
@Delete('/:id')
|
||||
@GlobalScope('instanceAi:message')
|
||||
async unlink(req: AuthenticatedRequest, res: Response, @Param('id') id: string): Promise<void> {
|
||||
await this.service.unlink(req.user, id);
|
||||
res.status(204).send();
|
||||
}
|
||||
}
|
||||
|
|
@ -5,7 +5,7 @@ import { BackendModule } from '@n8n/decorators';
|
|||
@BackendModule({ name: 'linked-instances', instanceTypes: ['main'] })
|
||||
export class LinkedInstancesModule implements ModuleInterface {
|
||||
async init() {
|
||||
// The REST endpoints come in a later change. The services load on first use.
|
||||
await import('./linked-instances.controller.js');
|
||||
}
|
||||
|
||||
async entities() {
|
||||
|
|
|
|||
|
|
@ -1,15 +1,38 @@
|
|||
import type {
|
||||
LinkedInstanceRemoteProject,
|
||||
LinkedInstanceStatus,
|
||||
LinkedInstanceSummary,
|
||||
} from '@n8n/api-types';
|
||||
import { Logger } from '@n8n/backend-common';
|
||||
import type { User } from '@n8n/db';
|
||||
import { Service } from '@n8n/di';
|
||||
import { BadRequestError, ConflictError, NotFoundError } from '@n8n/errors';
|
||||
|
||||
import { parseLinkInput, type LinkInstanceInput } from './link-input';
|
||||
import { LinkedInstanceStore, type LinkedInstanceCredentials } from './linked-instance.store';
|
||||
import type { LinkedInstanceSummary } from './linked-instances.types';
|
||||
import {
|
||||
LINK_INPUT_MESSAGES,
|
||||
parseLinkInput,
|
||||
parseLinkUpdate,
|
||||
type LinkInstanceInput,
|
||||
type LinkUpdateInput,
|
||||
} from './link-input';
|
||||
import {
|
||||
LinkedInstanceStore,
|
||||
type LinkedInstanceChanges,
|
||||
type LinkedInstanceCredentials,
|
||||
type StoredLinkedInstance,
|
||||
} from './linked-instance.store';
|
||||
import {
|
||||
RemoteInstanceClientFactory,
|
||||
type RemoteInstanceClient,
|
||||
type RemoteProbeFailureReason,
|
||||
} from './remote/remote-instance.client';
|
||||
import { RemoteInstanceError } from './remote/remote-instance.errors';
|
||||
import {
|
||||
pickDefaultRemoteProject,
|
||||
reconcileDefaultRemoteProject,
|
||||
type RemoteProjectList,
|
||||
} from './remote-project-picker';
|
||||
import { listRemoteProjects, SEARCH_PROJECTS_TOOL } from './remote-projects';
|
||||
|
||||
export const DUPLICATE_LINK_MESSAGE = 'You already linked this instance.';
|
||||
export const LINK_NOT_FOUND_MESSAGE = 'We could not find this linked instance.';
|
||||
|
|
@ -20,6 +43,34 @@ export const PROBE_FAILURE_MESSAGES: Record<RemoteProbeFailureReason, string> =
|
|||
unauthorised: 'That instance refused the access token. Create a new token and try again.',
|
||||
};
|
||||
|
||||
export const REMOTE_PROJECTS_MESSAGES = {
|
||||
unavailable: 'That instance does not let this access token list its projects.',
|
||||
failed: 'We could not get the projects from that instance. Try again.',
|
||||
} as const;
|
||||
|
||||
// A failed check keeps the link. Its status tells the user what to fix.
|
||||
const PROBE_FAILURE_STATUSES: Record<RemoteProbeFailureReason, LinkedInstanceStatus> = {
|
||||
unreachable: 'offline',
|
||||
'mcp-disabled': 'mcp-disabled',
|
||||
unauthorised: 'unauthorised',
|
||||
};
|
||||
|
||||
/** @throws {BadRequestError} with the en-GB message for the failure */
|
||||
async function probeOrThrow(client: RemoteInstanceClient): Promise<string[]> {
|
||||
const probe = await client.probe();
|
||||
if (!probe.ok) throw new BadRequestError(PROBE_FAILURE_MESSAGES[probe.reason]);
|
||||
return probe.toolNames;
|
||||
}
|
||||
|
||||
// A passed probe shows that the instance answers with this token.
|
||||
function passedProbeChanges(newToken?: string): LinkedInstanceChanges {
|
||||
return {
|
||||
...(newToken === undefined ? {} : { token: newToken }),
|
||||
status: 'online',
|
||||
verifiedAt: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
/** Links other n8n instances for one user. Each user sees and changes only their own links. */
|
||||
@Service()
|
||||
export class LinkedInstancesService {
|
||||
|
|
@ -38,7 +89,8 @@ export class LinkedInstancesService {
|
|||
}
|
||||
|
||||
/**
|
||||
* Stores the link only when the instance answers with the token.
|
||||
* Stores the link only when the instance answers with the token. Also picks the project
|
||||
* on that instance that gets new automations, when the instance lists its projects.
|
||||
* @throws {BadRequestError} when the input is not valid or the probe fails
|
||||
* @throws {ConflictError} when the user already linked this address
|
||||
*/
|
||||
|
|
@ -48,7 +100,10 @@ export class LinkedInstancesService {
|
|||
throw new ConflictError(DUPLICATE_LINK_MESSAGE);
|
||||
}
|
||||
|
||||
await this.verify(origin, token);
|
||||
const defaultRemoteProject = await this.withClient({ origin, token }, async (client) => {
|
||||
const projects = await this.tryListProjects(client, await probeOrThrow(client), origin);
|
||||
return projects ? pickDefaultRemoteProject(projects) : null;
|
||||
});
|
||||
|
||||
const linked = await this.store.create({
|
||||
userId: user.id,
|
||||
|
|
@ -57,6 +112,7 @@ export class LinkedInstancesService {
|
|||
token,
|
||||
status: 'online',
|
||||
verifiedAt: new Date(),
|
||||
defaultRemoteProject,
|
||||
});
|
||||
// Another request from the same user can link the same address between the check and the insert.
|
||||
if (!linked) throw new ConflictError(DUPLICATE_LINK_MESSAGE);
|
||||
|
|
@ -69,6 +125,57 @@ export class LinkedInstancesService {
|
|||
return linked;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks the instance again with the stored token and records the result.
|
||||
* A failed check is a status, not an error. A link without a default project gets one,
|
||||
* as a new link does.
|
||||
* @throws {NotFoundError} when the user has no link with this id
|
||||
*/
|
||||
async verify(user: User, id: string): Promise<LinkedInstanceSummary> {
|
||||
const link = await this.findLink(user, id);
|
||||
const credentials = { origin: link.summary.baseUrl, token: await link.readToken() };
|
||||
const changes = await this.withClient(
|
||||
credentials,
|
||||
async (client) => await this.check(client, link.summary),
|
||||
);
|
||||
|
||||
const summary = await this.store.updateForUser(user.id, id, changes);
|
||||
if (!summary) throw new NotFoundError(LINK_NOT_FOUND_MESSAGE);
|
||||
|
||||
this.logger.info('Checked a linked instance', {
|
||||
userId: user.id,
|
||||
linkedInstanceId: id,
|
||||
status: summary.status,
|
||||
});
|
||||
return summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renames the link, replaces its token or sets its default project. A new token must pass
|
||||
* the probe first, else the old token stays. A new default project must be one that the
|
||||
* instance lists for the token. A passed probe also records that the instance is online.
|
||||
* @throws {BadRequestError} when the input is not valid, the probe fails or the project is not listed
|
||||
* @throws {NotFoundError} when the user has no link with this id
|
||||
*/
|
||||
async update(user: User, id: string, input: LinkUpdateInput): Promise<LinkedInstanceSummary> {
|
||||
const update = parseLinkUpdate(input);
|
||||
const link = await this.findLink(user, id);
|
||||
|
||||
const remoteChanges = await this.readRemoteChanges(link, update);
|
||||
const summary = await this.store.updateForUser(user.id, id, {
|
||||
...(update.name === undefined ? {} : { name: update.name }),
|
||||
...remoteChanges,
|
||||
});
|
||||
if (!summary) throw new NotFoundError(LINK_NOT_FOUND_MESSAGE);
|
||||
|
||||
this.logger.info('Changed a linked instance', {
|
||||
userId: user.id,
|
||||
linkedInstanceId: id,
|
||||
fields: Object.keys(update),
|
||||
});
|
||||
return summary;
|
||||
}
|
||||
|
||||
/** @throws {NotFoundError} when the user has no link with this id */
|
||||
async unlink(user: User, id: string): Promise<void> {
|
||||
const deleted = await this.store.deleteForUser(user.id, id);
|
||||
|
|
@ -86,11 +193,111 @@ export class LinkedInstancesService {
|
|||
return credentials;
|
||||
}
|
||||
|
||||
private async verify(origin: string, token: string): Promise<void> {
|
||||
const client = this.clientFactory.create({ origin, token });
|
||||
/** @throws {NotFoundError} when the user has no link with this id */
|
||||
private async findLink(user: User, id: string): Promise<StoredLinkedInstance> {
|
||||
const link = await this.store.findForUse(user.id, id);
|
||||
if (!link) throw new NotFoundError(LINK_NOT_FOUND_MESSAGE);
|
||||
return link;
|
||||
}
|
||||
|
||||
private async check(
|
||||
client: RemoteInstanceClient,
|
||||
current: LinkedInstanceSummary,
|
||||
): Promise<LinkedInstanceChanges> {
|
||||
const probe = await client.probe();
|
||||
if (!probe.ok) {
|
||||
return { status: PROBE_FAILURE_STATUSES[probe.reason], verifiedAt: new Date() };
|
||||
}
|
||||
const changes = passedProbeChanges();
|
||||
if (current.defaultRemoteProject) return changes;
|
||||
|
||||
// The project list can fail when the user links the instance, so each check tries again.
|
||||
const projects = await this.tryListProjects(client, probe.toolNames, current.baseUrl);
|
||||
return projects
|
||||
? { ...changes, defaultRemoteProject: pickDefaultRemoteProject(projects) }
|
||||
: changes;
|
||||
}
|
||||
|
||||
/** Only a new token or a new default project needs the instance. */
|
||||
private async readRemoteChanges(
|
||||
link: StoredLinkedInstance,
|
||||
update: LinkUpdateInput,
|
||||
): Promise<LinkedInstanceChanges> {
|
||||
if (update.token === undefined && update.defaultRemoteProjectId === undefined) return {};
|
||||
|
||||
const { summary: current } = link;
|
||||
// A new token replaces the stored one, so the stored one stays encrypted.
|
||||
const token = update.token ?? (await link.readToken());
|
||||
return await this.withClient({ origin: current.baseUrl, token }, async (client) => {
|
||||
const toolNames = await probeOrThrow(client);
|
||||
const changes = passedProbeChanges(update.token);
|
||||
if (update.defaultRemoteProjectId !== undefined) {
|
||||
const project = await this.findListedProject(
|
||||
client,
|
||||
toolNames,
|
||||
update.defaultRemoteProjectId,
|
||||
);
|
||||
return { ...changes, defaultRemoteProject: project };
|
||||
}
|
||||
// The new token can belong to another user, who sees other projects.
|
||||
const projects = await this.tryListProjects(client, toolNames, current.baseUrl);
|
||||
const defaultRemoteProject = projects
|
||||
? reconcileDefaultRemoteProject(current.defaultRemoteProject, projects)
|
||||
: undefined;
|
||||
return { ...changes, defaultRemoteProject };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A link works without a default project, so a failure here only leaves the project unknown.
|
||||
* @returns `undefined` when the instance does not list its projects for this token
|
||||
*/
|
||||
private async tryListProjects(
|
||||
client: RemoteInstanceClient,
|
||||
toolNames: string[],
|
||||
origin: string,
|
||||
): Promise<RemoteProjectList | undefined> {
|
||||
if (!toolNames.includes(SEARCH_PROJECTS_TOOL)) return undefined;
|
||||
try {
|
||||
const probe = await client.probe();
|
||||
if (!probe.ok) throw new BadRequestError(PROBE_FAILURE_MESSAGES[probe.reason]);
|
||||
return await listRemoteProjects(client);
|
||||
} catch (error) {
|
||||
if (!(error instanceof RemoteInstanceError)) throw error;
|
||||
this.logger.warn('Could not list the projects of a linked instance', {
|
||||
origin,
|
||||
reason: error.reason,
|
||||
});
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** @throws {BadRequestError} when the instance does not list a project with this id */
|
||||
private async findListedProject(
|
||||
client: RemoteInstanceClient,
|
||||
toolNames: string[],
|
||||
projectId: string,
|
||||
): Promise<LinkedInstanceRemoteProject> {
|
||||
if (!toolNames.includes(SEARCH_PROJECTS_TOOL)) {
|
||||
throw new BadRequestError(REMOTE_PROJECTS_MESSAGES.unavailable);
|
||||
}
|
||||
const { projects } = await listRemoteProjects(client).catch((error: unknown) => {
|
||||
if (error instanceof RemoteInstanceError) {
|
||||
throw new BadRequestError(REMOTE_PROJECTS_MESSAGES.failed);
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
const project = projects.find(({ id }) => id === projectId);
|
||||
if (!project) throw new BadRequestError(LINK_INPUT_MESSAGES.defaultRemoteProjectId);
|
||||
return project;
|
||||
}
|
||||
|
||||
/** Opens one client for the work and always closes it. */
|
||||
private async withClient<T>(
|
||||
credentials: LinkedInstanceCredentials,
|
||||
work: (client: RemoteInstanceClient) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const client = this.clientFactory.create(credentials);
|
||||
try {
|
||||
return await work(client);
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,20 +0,0 @@
|
|||
export const LINKED_INSTANCE_STATUSES = [
|
||||
'online',
|
||||
'offline',
|
||||
'unauthorised',
|
||||
'mcp-disabled',
|
||||
'unknown',
|
||||
] as const;
|
||||
|
||||
/** Result of the last check of a linked instance. */
|
||||
export type LinkedInstanceStatus = (typeof LINKED_INSTANCE_STATUSES)[number];
|
||||
|
||||
/** What a user can see of a linked instance. It never holds the access token. */
|
||||
export type LinkedInstanceSummary = {
|
||||
id: string;
|
||||
name: string;
|
||||
baseUrl: string;
|
||||
status: LinkedInstanceStatus;
|
||||
lastVerifiedAt: string | null;
|
||||
createdAt: string;
|
||||
};
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
import type { LinkedInstanceRemoteProject, ProjectType } from '@n8n/api-types';
|
||||
|
||||
/** A project that the remote `search_projects` tool listed for the token's user. */
|
||||
export type RemoteProject = LinkedInstanceRemoteProject & { type: ProjectType };
|
||||
|
||||
export type RemoteProjectList = {
|
||||
projects: readonly RemoteProject[];
|
||||
/** `false` when the linked instance has no licence for team projects. */
|
||||
teamProjectsEnabled: boolean;
|
||||
};
|
||||
|
||||
const isTeam = (project: RemoteProject) => project.type === 'team';
|
||||
const isPersonal = (project: RemoteProject) => project.type === 'personal';
|
||||
|
||||
/**
|
||||
* Chooses where new automations go on a linked instance: the first listed team project, so that
|
||||
* teammates see them, else the personal project. The instance does not tell which projects the
|
||||
* user can create workflows in, so the user can change the choice. Without a licence for team
|
||||
* projects, the instance itself advises the personal project.
|
||||
* @returns one of the listed projects, or `null` when no project fits
|
||||
*/
|
||||
export function pickDefaultRemoteProject(list: RemoteProjectList): RemoteProject | null {
|
||||
const team = list.teamProjectsEnabled ? list.projects.find(isTeam) : undefined;
|
||||
return team ?? list.projects.find(isPersonal) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps the current default project while the instance still lists it, and then returns the
|
||||
* listed project, so that a renamed project shows its new name. Otherwise picks a new default.
|
||||
* @returns one of the listed projects, or `null` when no project fits
|
||||
*/
|
||||
export function reconcileDefaultRemoteProject(
|
||||
current: LinkedInstanceRemoteProject | null,
|
||||
list: RemoteProjectList,
|
||||
): RemoteProject | null {
|
||||
const listed = current && list.projects.find((project) => project.id === current.id);
|
||||
return listed ?? pickDefaultRemoteProject(list);
|
||||
}
|
||||
121
packages/cli/src/modules/linked-instances/remote-projects.ts
Normal file
121
packages/cli/src/modules/linked-instances/remote-projects.ts
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
import { linkedInstanceRemoteProjectIdSchema } from '@n8n/api-types';
|
||||
import { truncate } from '@n8n/utils/string/truncate';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { withDeadline } from '@/modules/policy-infrastructure/policy-decision.service';
|
||||
|
||||
import type { RemoteInstanceClient } from './remote/remote-instance.client';
|
||||
import { RemoteInstanceError } from './remote/remote-instance.errors';
|
||||
import type { RemoteProject, RemoteProjectList } from './remote-project-picker';
|
||||
|
||||
/** The MCP tool of an n8n instance that lists the projects of the token's user. */
|
||||
export const SEARCH_PROJECTS_TOOL = 'search_projects';
|
||||
|
||||
// The highest limit that the tool accepts.
|
||||
const MAX_PROJECTS = 100;
|
||||
// For the whole list, also for the connection that the first call opens.
|
||||
const LIST_TIMEOUT_MS = 10_000;
|
||||
// The length of the column that keeps the name.
|
||||
const MAX_NAME_LENGTH = 255;
|
||||
const ELLIPSIS_LENGTH = 3;
|
||||
// A teammate on the other instance sets the name, and it shows in the UI and in prompts.
|
||||
// Characters without width go: format characters (such as bidi controls) and half surrogate pairs.
|
||||
const INVISIBLE_CHARACTERS = /[\p{Cf}\p{Cs}]+/gu;
|
||||
// Control characters and line or paragraph separators become one space, so the name stays on one line.
|
||||
const LINE_BREAKS = /[\p{Cc}\p{Zl}\p{Zp}]+/gu;
|
||||
|
||||
function cleanName(name: string): string {
|
||||
const oneLine = name.replace(INVISIBLE_CHARACTERS, '').replace(LINE_BREAKS, ' ').trim();
|
||||
if (oneLine.length <= MAX_NAME_LENGTH) return oneLine;
|
||||
// `truncate` counts UTF-16 units, so it can cut a character in two. That half goes.
|
||||
return truncate(oneLine, MAX_NAME_LENGTH - ELLIPSIS_LENGTH).replace(INVISIBLE_CHARACTERS, '');
|
||||
}
|
||||
|
||||
// Unknown fields are dropped, so a newer n8n version can add fields.
|
||||
const resultSchema = z.object({
|
||||
data: z.array(z.unknown()),
|
||||
// Only tells if the list is complete, so a value that is not valid does not block the list.
|
||||
count: z.number().optional().catch(undefined),
|
||||
teamProjectsEnabled: z.boolean().optional(),
|
||||
});
|
||||
|
||||
const projectSchema = z.object({
|
||||
id: linkedInstanceRemoteProjectIdSchema,
|
||||
name: z.string().transform(cleanName).pipe(z.string().min(1)),
|
||||
type: z.enum(['personal', 'team']),
|
||||
});
|
||||
|
||||
type ProjectPage = { list: RemoteProjectList; complete: boolean };
|
||||
|
||||
const isPersonal = (project: RemoteProject) => project.type === 'personal';
|
||||
|
||||
function parsePage(result: unknown): ProjectPage {
|
||||
const parsed = resultSchema.safeParse(result);
|
||||
if (!parsed.success) {
|
||||
throw new RemoteInstanceError(
|
||||
'tool-error',
|
||||
'The linked instance sent its projects in an unknown format.',
|
||||
);
|
||||
}
|
||||
const { data, count, teamProjectsEnabled = true } = parsed.data;
|
||||
const projects = data.flatMap((item) => {
|
||||
const project = projectSchema.safeParse(item);
|
||||
return project.success ? [project.data] : [];
|
||||
});
|
||||
// Without a total, the list counts as complete.
|
||||
const complete = count === undefined || count <= data.length;
|
||||
return { list: { projects, teamProjectsEnabled }, complete };
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the result of the `search_projects` tool. It skips each project that it cannot use,
|
||||
* such as a project of a type that a newer n8n version adds, instead of failing.
|
||||
* An older instance does not tell if team projects are licensed, so they count as licensed.
|
||||
* @throws {RemoteInstanceError} when the result holds no project list
|
||||
*/
|
||||
export function parseRemoteProjects(result: unknown): RemoteProjectList {
|
||||
return parsePage(result).list;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops waiting after the list time. The caller closes the client, and that ends the request.
|
||||
* @throws {RemoteInstanceError} with the reason `timeout` when the time is over
|
||||
*/
|
||||
async function withinListTime<T>(work: () => Promise<T>): Promise<T> {
|
||||
const deadline: { signal?: AbortSignal } = {};
|
||||
try {
|
||||
return await withDeadline(async (signal) => {
|
||||
deadline.signal = signal;
|
||||
return await work();
|
||||
}, LIST_TIMEOUT_MS);
|
||||
} catch (error) {
|
||||
if (deadline.signal?.aborted) throw new RemoteInstanceError('timeout');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function searchProjects(
|
||||
client: RemoteInstanceClient,
|
||||
args: Record<string, unknown>,
|
||||
): Promise<ProjectPage> {
|
||||
const result = await client.callTool(SEARCH_PROJECTS_TOOL, args, { timeoutMs: LIST_TIMEOUT_MS });
|
||||
return parsePage(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists up to 100 projects of the token's user, team projects first, in 10 seconds.
|
||||
* When team projects fill the list, a second call gets the personal project.
|
||||
* @throws {RemoteInstanceError}
|
||||
*/
|
||||
export async function listRemoteProjects(client: RemoteInstanceClient): Promise<RemoteProjectList> {
|
||||
return await withinListTime(async () => {
|
||||
const { list, complete } = await searchProjects(client, { limit: MAX_PROJECTS });
|
||||
if (complete || list.projects.some(isPersonal)) return list;
|
||||
|
||||
const personal = await searchProjects(client, { type: 'personal', limit: 1 });
|
||||
return {
|
||||
...list,
|
||||
projects: [...list.projects, ...personal.list.projects.filter(isPersonal)],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
@ -63,7 +63,8 @@ type EndpointGroup =
|
|||
| 'encryption-keys'
|
||||
| 'workflow-reviews'
|
||||
| 'test-webhooks'
|
||||
| 'type-availability-policies';
|
||||
| 'type-availability-policies'
|
||||
| 'linked-instances';
|
||||
|
||||
type ModuleName =
|
||||
| 'agents'
|
||||
|
|
@ -85,7 +86,8 @@ type ModuleName =
|
|||
| 'token-exchange'
|
||||
| 'policy-infrastructure'
|
||||
| 'workflow-reviews'
|
||||
| 'type-availability-policies';
|
||||
| 'type-availability-policies'
|
||||
| 'linked-instances';
|
||||
|
||||
export interface SetupProps {
|
||||
endpointGroups?: EndpointGroup[];
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user