mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-12 16:10:30 +02:00
Merge branch 'master' into ce-877-eslint-cred-class-name-suffix-credential-class-names-must
This commit is contained in:
commit
a3efb6cdd7
|
|
@ -1,12 +1,20 @@
|
|||
import { ListEncryptionKeysQueryDto } from '../list-encryption-keys-query.dto';
|
||||
import {
|
||||
ENCRYPTION_KEYS_SORT_OPTIONS,
|
||||
ListEncryptionKeysQueryDto,
|
||||
} from '../list-encryption-keys-query.dto';
|
||||
|
||||
describe('ListEncryptionKeysQueryDto', () => {
|
||||
describe('Valid requests', () => {
|
||||
test('should succeed with no query params', () => {
|
||||
test('should succeed with no query params and apply pagination defaults', () => {
|
||||
const result = ListEncryptionKeysQueryDto.safeParse({});
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.type).toBeUndefined();
|
||||
expect(result.data.skip).toBe(0);
|
||||
expect(result.data.take).toBe(10);
|
||||
expect(result.data.sortBy).toBeUndefined();
|
||||
expect(result.data.activatedFrom).toBeUndefined();
|
||||
expect(result.data.activatedTo).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -18,8 +26,63 @@ describe('ListEncryptionKeysQueryDto', () => {
|
|||
}
|
||||
});
|
||||
|
||||
test('should accept arbitrary type string', () => {
|
||||
const result = ListEncryptionKeysQueryDto.safeParse({ type: 'some_future_type' });
|
||||
test('should accept skip and take as strings', () => {
|
||||
const result = ListEncryptionKeysQueryDto.safeParse({ skip: '20', take: '50' });
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.skip).toBe(20);
|
||||
expect(result.data.take).toBe(50);
|
||||
}
|
||||
});
|
||||
|
||||
test('should cap take at MAX_ITEMS_PER_PAGE (250)', () => {
|
||||
const result = ListEncryptionKeysQueryDto.safeParse({ take: '500' });
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.take).toBe(250);
|
||||
}
|
||||
});
|
||||
|
||||
test.each(ENCRYPTION_KEYS_SORT_OPTIONS)('should accept sortBy=%s', (sortBy) => {
|
||||
const result = ListEncryptionKeysQueryDto.safeParse({ sortBy });
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.sortBy).toBe(sortBy);
|
||||
}
|
||||
});
|
||||
|
||||
test.each([
|
||||
'2026-04-21T10:00:00.000Z',
|
||||
'2026-04-21T10:00:00Z',
|
||||
'2026-04-21T10:00:00+02:00',
|
||||
'2026-04-21T10:00:00-08:00',
|
||||
])('should accept activatedFrom=%s', (activatedFrom) => {
|
||||
const result = ListEncryptionKeysQueryDto.safeParse({ activatedFrom });
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.activatedFrom).toBe(activatedFrom);
|
||||
}
|
||||
});
|
||||
|
||||
test('should accept activatedTo as ISO datetime', () => {
|
||||
const result = ListEncryptionKeysQueryDto.safeParse({
|
||||
activatedTo: '2026-04-22T23:59:59.999Z',
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.activatedTo).toBe('2026-04-22T23:59:59.999Z');
|
||||
}
|
||||
});
|
||||
|
||||
test('should succeed with all params combined', () => {
|
||||
const result = ListEncryptionKeysQueryDto.safeParse({
|
||||
type: 'data_encryption',
|
||||
skip: '10',
|
||||
take: '25',
|
||||
sortBy: 'updatedAt:desc',
|
||||
activatedFrom: '2026-04-01T00:00:00.000Z',
|
||||
activatedTo: '2026-04-30T23:59:59.999Z',
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
@ -30,6 +93,7 @@ describe('ListEncryptionKeysQueryDto', () => {
|
|||
{ name: 'boolean', type: true },
|
||||
{ name: 'array', type: ['data_encryption'] },
|
||||
{ name: 'object', type: { kind: 'data_encryption' } },
|
||||
{ name: 'unknown literal string', type: 'some_future_type' },
|
||||
])('should fail when type is a $name', ({ type }) => {
|
||||
const result = ListEncryptionKeysQueryDto.safeParse({ type });
|
||||
expect(result.success).toBe(false);
|
||||
|
|
@ -37,5 +101,64 @@ describe('ListEncryptionKeysQueryDto', () => {
|
|||
expect(result.error.issues[0].path).toEqual(['type']);
|
||||
}
|
||||
});
|
||||
|
||||
test('should fail when skip is negative', () => {
|
||||
const result = ListEncryptionKeysQueryDto.safeParse({ skip: '-1' });
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.issues[0].path).toEqual(['skip']);
|
||||
}
|
||||
});
|
||||
|
||||
test('should fail when take is non-numeric', () => {
|
||||
const result = ListEncryptionKeysQueryDto.safeParse({ take: 'abc' });
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.issues[0].path).toEqual(['take']);
|
||||
}
|
||||
});
|
||||
|
||||
test('should fail when skip is non-numeric', () => {
|
||||
const result = ListEncryptionKeysQueryDto.safeParse({ skip: 'abc' });
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.issues[0].path).toEqual(['skip']);
|
||||
}
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ name: 'no direction', sortBy: 'createdAt' },
|
||||
{ name: 'unknown field', sortBy: 'foo:asc' },
|
||||
{ name: 'unknown direction', sortBy: 'createdAt:up' },
|
||||
{ name: 'wrong casing', sortBy: 'CREATEDAT:asc' },
|
||||
{ name: 'array', sortBy: ['createdAt:asc'] },
|
||||
])('should fail when sortBy is $name', ({ sortBy }) => {
|
||||
const result = ListEncryptionKeysQueryDto.safeParse({ sortBy });
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.issues[0].path).toEqual(['sortBy']);
|
||||
}
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ name: 'date-only', value: '2026-04-21' },
|
||||
{ name: 'plain string', value: 'not-a-date' },
|
||||
{ name: 'empty string', value: '' },
|
||||
{ name: 'number', value: 1716290000000 },
|
||||
])('should fail when activatedFrom is $name', ({ value }) => {
|
||||
const result = ListEncryptionKeysQueryDto.safeParse({ activatedFrom: value });
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.issues[0].path).toEqual(['activatedFrom']);
|
||||
}
|
||||
});
|
||||
|
||||
test('should fail when activatedTo is not ISO', () => {
|
||||
const result = ListEncryptionKeysQueryDto.safeParse({ activatedTo: '2026-04-21' });
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.issues[0].path).toEqual(['activatedTo']);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,8 +1,3 @@
|
|||
export type EncryptionKeyResponseDto = {
|
||||
id: string;
|
||||
type: string;
|
||||
algorithm: string | null;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
import type { EncryptionKey } from '../../schemas/encryption-key.schema';
|
||||
|
||||
export type EncryptionKeyResponseDto = EncryptionKey;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,27 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
import { Z } from '../../zod-class';
|
||||
import { paginationSchema } from '../pagination/pagination.dto';
|
||||
|
||||
export const ENCRYPTION_KEYS_SORT_OPTIONS = [
|
||||
'createdAt:asc',
|
||||
'createdAt:desc',
|
||||
'updatedAt:asc',
|
||||
'updatedAt:desc',
|
||||
'status:asc',
|
||||
'status:desc',
|
||||
] as const;
|
||||
|
||||
export type EncryptionKeysSortOption = (typeof ENCRYPTION_KEYS_SORT_OPTIONS)[number];
|
||||
|
||||
export class ListEncryptionKeysQueryDto extends Z.class({
|
||||
type: z.string().optional(),
|
||||
...paginationSchema,
|
||||
type: z.literal('data_encryption').optional(),
|
||||
sortBy: z
|
||||
.enum(ENCRYPTION_KEYS_SORT_OPTIONS, {
|
||||
message: `sortBy must be one of: ${ENCRYPTION_KEYS_SORT_OPTIONS.join(', ')}`,
|
||||
})
|
||||
.optional(),
|
||||
activatedFrom: z.string().datetime({ offset: true }).optional(),
|
||||
activatedTo: z.string().datetime({ offset: true }).optional(),
|
||||
}) {}
|
||||
|
|
|
|||
|
|
@ -236,5 +236,9 @@ export { VersionSinceDateQueryDto } from './instance-version-history/version-sin
|
|||
export { VersionQueryDto } from './instance-version-history/version-query.dto';
|
||||
|
||||
export { CreateEncryptionKeyDto } from './encryption/create-encryption-key.dto';
|
||||
export { ListEncryptionKeysQueryDto } from './encryption/list-encryption-keys-query.dto';
|
||||
export {
|
||||
ListEncryptionKeysQueryDto,
|
||||
ENCRYPTION_KEYS_SORT_OPTIONS,
|
||||
type EncryptionKeysSortOption,
|
||||
} from './encryption/list-encryption-keys-query.dto';
|
||||
export type { EncryptionKeyResponseDto } from './encryption/encryption-key-response.dto';
|
||||
|
|
|
|||
|
|
@ -176,6 +176,13 @@ export {
|
|||
userDetailSchema,
|
||||
} from './schemas/user.schema';
|
||||
|
||||
export {
|
||||
encryptionKeySchema,
|
||||
encryptionKeysListSchema,
|
||||
type EncryptionKey,
|
||||
type EncryptionKeysList,
|
||||
} from './schemas/encryption-key.schema';
|
||||
|
||||
export {
|
||||
DATA_TABLE_COLUMN_REGEX,
|
||||
DATA_TABLE_COLUMN_MAX_LENGTH,
|
||||
|
|
|
|||
18
packages/@n8n/api-types/src/schemas/encryption-key.schema.ts
Normal file
18
packages/@n8n/api-types/src/schemas/encryption-key.schema.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
export const encryptionKeySchema = z.object({
|
||||
id: z.string(),
|
||||
type: z.string(),
|
||||
algorithm: z.string().nullable(),
|
||||
status: z.string(),
|
||||
createdAt: z.string().datetime(),
|
||||
updatedAt: z.string().datetime(),
|
||||
});
|
||||
|
||||
export const encryptionKeysListSchema = z.object({
|
||||
count: z.number(),
|
||||
items: z.array(encryptionKeySchema),
|
||||
});
|
||||
|
||||
export type EncryptionKey = z.infer<typeof encryptionKeySchema>;
|
||||
export type EncryptionKeysList = z.infer<typeof encryptionKeysListSchema>;
|
||||
|
|
@ -3,6 +3,19 @@ import { DataSource, Repository } from '@n8n/typeorm';
|
|||
|
||||
import { DeploymentKey } from '../entities/deployment-key';
|
||||
|
||||
export type DeploymentKeySortField = 'createdAt' | 'updatedAt' | 'status';
|
||||
export type DeploymentKeySortDirection = 'ASC' | 'DESC';
|
||||
|
||||
export type ListDeploymentKeysOptions = {
|
||||
type?: string;
|
||||
sortField: DeploymentKeySortField;
|
||||
sortDirection: DeploymentKeySortDirection;
|
||||
skip: number;
|
||||
take: number;
|
||||
createdAtFrom?: Date;
|
||||
createdAtTo?: Date;
|
||||
};
|
||||
|
||||
@Service()
|
||||
export class DeploymentKeyRepository extends Repository<DeploymentKey> {
|
||||
constructor(dataSource: DataSource) {
|
||||
|
|
@ -17,6 +30,40 @@ export class DeploymentKeyRepository extends Repository<DeploymentKey> {
|
|||
return await this.find({ where: { type } });
|
||||
}
|
||||
|
||||
async findAndCountForList(
|
||||
opts: ListDeploymentKeysOptions,
|
||||
): Promise<{ items: DeploymentKey[]; count: number }> {
|
||||
const qb = this.createQueryBuilder('deploymentKey');
|
||||
|
||||
if (opts.type) {
|
||||
qb.andWhere('deploymentKey.type = :type', { type: opts.type });
|
||||
}
|
||||
|
||||
if (opts.createdAtFrom && opts.createdAtTo) {
|
||||
qb.andWhere('deploymentKey.createdAt BETWEEN :from AND :to', {
|
||||
from: opts.createdAtFrom,
|
||||
to: opts.createdAtTo,
|
||||
});
|
||||
} else if (opts.createdAtFrom) {
|
||||
qb.andWhere('deploymentKey.createdAt >= :from', { from: opts.createdAtFrom });
|
||||
} else if (opts.createdAtTo) {
|
||||
qb.andWhere('deploymentKey.createdAt <= :to', { to: opts.createdAtTo });
|
||||
}
|
||||
|
||||
qb.orderBy(`deploymentKey.${opts.sortField}`, opts.sortDirection);
|
||||
|
||||
// Stable secondary sort so pagination is deterministic when ties occur.
|
||||
if (opts.sortField !== 'createdAt') {
|
||||
qb.addOrderBy('deploymentKey.createdAt', 'DESC');
|
||||
}
|
||||
qb.addOrderBy('deploymentKey.id', 'ASC');
|
||||
|
||||
qb.skip(opts.skip).take(opts.take);
|
||||
|
||||
const [items, count] = await qb.getManyAndCount();
|
||||
return { items, count };
|
||||
}
|
||||
|
||||
/**
|
||||
* Inserts the entity if no active row with that type exists yet.
|
||||
* On a unique-index conflict (concurrent multi-main startup), the insert
|
||||
|
|
|
|||
|
|
@ -7,7 +7,12 @@ export { AuthProviderSyncHistoryRepository } from './auth-provider-sync-history.
|
|||
export { BinaryDataRepository } from './binary-data.repository';
|
||||
export { CredentialsRepository } from './credentials.repository';
|
||||
export { CredentialDependencyRepository } from './credential-dependency.repository';
|
||||
export { DeploymentKeyRepository } from './deployment-key.repository';
|
||||
export {
|
||||
DeploymentKeyRepository,
|
||||
type DeploymentKeySortField,
|
||||
type DeploymentKeySortDirection,
|
||||
type ListDeploymentKeysOptions,
|
||||
} from './deployment-key.repository';
|
||||
export { ExecutionAnnotationRepository } from './execution-annotation.repository';
|
||||
export { ExecutionDataRepository } from './execution-data.repository';
|
||||
export { ExecutionMetadataRepository } from './execution-metadata.repository';
|
||||
|
|
|
|||
1
packages/@n8n/engine/.gitignore
vendored
Normal file
1
packages/@n8n/engine/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
dist
|
||||
14
packages/@n8n/engine/README.md
Normal file
14
packages/@n8n/engine/README.md
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
# @n8n/engine
|
||||
|
||||
n8n workflow execution engine (v2).
|
||||
|
||||
See the [Engine 2.0 project](https://linear.app/n8n/project/engine-20-59ba0ba60995)
|
||||
for context. Public API and core interfaces are still being defined; the package
|
||||
is currently a scaffold and is not yet wired into n8n.
|
||||
|
||||
## Dependencies
|
||||
|
||||
Be careful adding dependencies here. This package should not have runtime
|
||||
dependencies on other n8n components, only interfaces. If there is some
|
||||
functionality that should be shared, it should be factored into a common
|
||||
library.
|
||||
4
packages/@n8n/engine/eslint.config.mjs
Normal file
4
packages/@n8n/engine/eslint.config.mjs
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
import { defineConfig } from 'eslint/config';
|
||||
import { nodeConfig } from '@n8n/eslint-config/node';
|
||||
|
||||
export default defineConfig(nodeConfig);
|
||||
28
packages/@n8n/engine/package.json
Normal file
28
packages/@n8n/engine/package.json
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
{
|
||||
"name": "@n8n/engine",
|
||||
"version": "0.1.0",
|
||||
"description": "n8n workflow execution engine (v2)",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"build": "tsc -p tsconfig.build.json",
|
||||
"format": "biome format --write src",
|
||||
"format:check": "biome ci src",
|
||||
"lint": "eslint . --quiet",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:dev": "vitest --watch",
|
||||
"watch": "tsc -p tsconfig.build.json --watch"
|
||||
},
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"files": [
|
||||
"dist/**/*"
|
||||
],
|
||||
"dependencies": {},
|
||||
"devDependencies": {
|
||||
"@n8n/typescript-config": "workspace:*",
|
||||
"@n8n/vitest-config": "workspace:*",
|
||||
"vitest": "catalog:"
|
||||
}
|
||||
}
|
||||
5
packages/@n8n/engine/src/index.ts
Normal file
5
packages/@n8n/engine/src/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
// Public API of @n8n/engine.
|
||||
//
|
||||
// Intentionally empty for now. The StartExecution API surface and core engine
|
||||
// interfaces will land in subsequent CAT-2859 sub-tickets.
|
||||
export {};
|
||||
11
packages/@n8n/engine/tsconfig.build.json
Normal file
11
packages/@n8n/engine/tsconfig.build.json
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"extends": ["./tsconfig.json", "@n8n/typescript-config/tsconfig.build.json"],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"tsBuildInfoFile": "dist/build.tsbuildinfo"
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/__tests__/**", "**/*.test.ts"]
|
||||
}
|
||||
13
packages/@n8n/engine/tsconfig.json
Normal file
13
packages/@n8n/engine/tsconfig.json
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"extends": [
|
||||
"@n8n/typescript-config/tsconfig.common.json",
|
||||
"@n8n/typescript-config/tsconfig.backend.json"
|
||||
],
|
||||
"compilerOptions": {
|
||||
"target": "es2023",
|
||||
"lib": ["es2023"],
|
||||
"types": ["node"],
|
||||
"tsBuildInfoFile": "dist/typecheck.tsbuildinfo"
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
3
packages/@n8n/engine/vitest.config.ts
Normal file
3
packages/@n8n/engine/vitest.config.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import { createVitestConfig } from '@n8n/vitest-config/node';
|
||||
|
||||
export default createVitestConfig();
|
||||
|
|
@ -2,6 +2,7 @@ import { mockInstance } from '@n8n/backend-test-utils';
|
|||
import type { DeploymentKey } from '@n8n/db';
|
||||
import { Container } from '@n8n/di';
|
||||
|
||||
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||
import { EncryptionKeyController } from '@/modules/encryption-key-manager/encryption-key.controller';
|
||||
import { KeyManagerService } from '@/modules/encryption-key-manager/key-manager.service';
|
||||
|
||||
|
|
@ -17,6 +18,8 @@ const makeKey = (overrides: Partial<DeploymentKey> = {}): DeploymentKey =>
|
|||
...overrides,
|
||||
}) as DeploymentKey;
|
||||
|
||||
const baseQuery = { skip: 0, take: 10 } as never;
|
||||
|
||||
describe('EncryptionKeyController', () => {
|
||||
const keyManagerService = mockInstance(KeyManagerService);
|
||||
|
||||
|
|
@ -25,76 +28,123 @@ describe('EncryptionKeyController', () => {
|
|||
});
|
||||
|
||||
describe('GET /encryption/keys', () => {
|
||||
it('returns all keys when no type filter is provided', async () => {
|
||||
keyManagerService.listKeys.mockResolvedValue([
|
||||
makeKey({ id: 'k1', algorithm: 'aes-256-cbc', status: 'inactive' }),
|
||||
makeKey({ id: 'k2', algorithm: 'aes-256-gcm', status: 'active' }),
|
||||
]);
|
||||
it('returns the { count, items } envelope built from the service result', async () => {
|
||||
keyManagerService.listKeys.mockResolvedValue({
|
||||
items: [
|
||||
makeKey({ id: 'k1', algorithm: 'aes-256-cbc', status: 'inactive' }),
|
||||
makeKey({ id: 'k2', algorithm: 'aes-256-gcm', status: 'active' }),
|
||||
],
|
||||
count: 2,
|
||||
});
|
||||
|
||||
const result = await Container.get(EncryptionKeyController).list({}, {}, { type: undefined });
|
||||
const result = await Container.get(EncryptionKeyController).list({}, {}, baseQuery);
|
||||
|
||||
expect(keyManagerService.listKeys).toHaveBeenCalledWith(undefined);
|
||||
expect(result).toEqual([
|
||||
{
|
||||
id: 'k1',
|
||||
type: 'data_encryption',
|
||||
algorithm: 'aes-256-cbc',
|
||||
status: 'inactive',
|
||||
createdAt: '2026-01-15T00:00:00.000Z',
|
||||
updatedAt: '2026-01-15T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'k2',
|
||||
type: 'data_encryption',
|
||||
algorithm: 'aes-256-gcm',
|
||||
status: 'active',
|
||||
createdAt: '2026-01-15T00:00:00.000Z',
|
||||
updatedAt: '2026-01-15T00:00:00.000Z',
|
||||
},
|
||||
]);
|
||||
expect(keyManagerService.listKeys).toHaveBeenCalledWith(baseQuery);
|
||||
expect(result).toEqual({
|
||||
count: 2,
|
||||
items: [
|
||||
{
|
||||
id: 'k1',
|
||||
type: 'data_encryption',
|
||||
algorithm: 'aes-256-cbc',
|
||||
status: 'inactive',
|
||||
createdAt: '2026-01-15T00:00:00.000Z',
|
||||
updatedAt: '2026-01-15T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'k2',
|
||||
type: 'data_encryption',
|
||||
algorithm: 'aes-256-gcm',
|
||||
status: 'active',
|
||||
createdAt: '2026-01-15T00:00:00.000Z',
|
||||
updatedAt: '2026-01-15T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('forwards the type filter to the service', async () => {
|
||||
keyManagerService.listKeys.mockResolvedValue([makeKey({ id: 'k1' })]);
|
||||
it('forwards the full query (type, sortBy, dates, pagination) to the service', async () => {
|
||||
keyManagerService.listKeys.mockResolvedValue({ items: [makeKey({ id: 'k1' })], count: 1 });
|
||||
|
||||
const result = await Container.get(EncryptionKeyController).list(
|
||||
{},
|
||||
{},
|
||||
{ type: 'data_encryption' },
|
||||
);
|
||||
const query = {
|
||||
skip: 10,
|
||||
take: 25,
|
||||
type: 'data_encryption',
|
||||
sortBy: 'updatedAt:desc',
|
||||
activatedFrom: '2026-04-01T00:00:00.000Z',
|
||||
activatedTo: '2026-04-30T23:59:59.999Z',
|
||||
} as never;
|
||||
|
||||
expect(keyManagerService.listKeys).toHaveBeenCalledWith('data_encryption');
|
||||
expect(result).toHaveLength(1);
|
||||
const result = await Container.get(EncryptionKeyController).list({}, {}, query);
|
||||
|
||||
expect(keyManagerService.listKeys).toHaveBeenCalledWith(query);
|
||||
expect(result.count).toBe(1);
|
||||
});
|
||||
|
||||
it('never includes the raw key value in the response', async () => {
|
||||
keyManagerService.listKeys.mockResolvedValue([makeKey({ value: 'very-secret' })]);
|
||||
keyManagerService.listKeys.mockResolvedValue({
|
||||
items: [makeKey({ value: 'very-secret' })],
|
||||
count: 1,
|
||||
});
|
||||
|
||||
const result = await Container.get(EncryptionKeyController).list({}, {}, { type: undefined });
|
||||
const result = await Container.get(EncryptionKeyController).list({}, {}, baseQuery);
|
||||
|
||||
expect(JSON.stringify(result)).not.toContain('very-secret');
|
||||
expect(result[0]).not.toHaveProperty('value');
|
||||
expect(result.items[0]).not.toHaveProperty('value');
|
||||
});
|
||||
|
||||
it('returns an empty array when no keys exist', async () => {
|
||||
keyManagerService.listKeys.mockResolvedValue([]);
|
||||
it('returns an empty envelope when no keys exist', async () => {
|
||||
keyManagerService.listKeys.mockResolvedValue({ items: [], count: 0 });
|
||||
|
||||
const result = await Container.get(EncryptionKeyController).list({}, {}, { type: undefined });
|
||||
const result = await Container.get(EncryptionKeyController).list({}, {}, baseQuery);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(result).toEqual({ count: 0, items: [] });
|
||||
});
|
||||
|
||||
it('preserves service ordering and maps nullable algorithm', async () => {
|
||||
keyManagerService.listKeys.mockResolvedValue([
|
||||
makeKey({ id: 'first', algorithm: null }),
|
||||
makeKey({ id: 'second', algorithm: 'aes-256-gcm' }),
|
||||
]);
|
||||
keyManagerService.listKeys.mockResolvedValue({
|
||||
items: [
|
||||
makeKey({ id: 'first', algorithm: null }),
|
||||
makeKey({ id: 'second', algorithm: 'aes-256-gcm' }),
|
||||
],
|
||||
count: 2,
|
||||
});
|
||||
|
||||
const result = await Container.get(EncryptionKeyController).list({}, {}, { type: undefined });
|
||||
const result = await Container.get(EncryptionKeyController).list({}, {}, baseQuery);
|
||||
|
||||
expect(result.map((row) => row.id)).toEqual(['first', 'second']);
|
||||
expect(result[0].algorithm).toBeNull();
|
||||
expect(result[1].algorithm).toBe('aes-256-gcm');
|
||||
expect(result.items.map((row) => row.id)).toEqual(['first', 'second']);
|
||||
expect(result.items[0].algorithm).toBeNull();
|
||||
expect(result.items[1].algorithm).toBe('aes-256-gcm');
|
||||
});
|
||||
|
||||
it('throws BadRequestError when activatedFrom is after activatedTo', async () => {
|
||||
const query = {
|
||||
skip: 0,
|
||||
take: 10,
|
||||
activatedFrom: '2026-04-30T00:00:00.000Z',
|
||||
activatedTo: '2026-04-01T00:00:00.000Z',
|
||||
} as never;
|
||||
|
||||
await expect(Container.get(EncryptionKeyController).list({}, {}, query)).rejects.toThrow(
|
||||
BadRequestError,
|
||||
);
|
||||
expect(keyManagerService.listKeys).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('accepts equal activatedFrom and activatedTo', async () => {
|
||||
keyManagerService.listKeys.mockResolvedValue({ items: [], count: 0 });
|
||||
|
||||
const query = {
|
||||
skip: 0,
|
||||
take: 10,
|
||||
activatedFrom: '2026-04-15T00:00:00.000Z',
|
||||
activatedTo: '2026-04-15T00:00:00.000Z',
|
||||
} as never;
|
||||
|
||||
const result = await Container.get(EncryptionKeyController).list({}, {}, query);
|
||||
|
||||
expect(result).toEqual({ count: 0, items: [] });
|
||||
expect(keyManagerService.listKeys).toHaveBeenCalledWith(query);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -243,26 +243,78 @@ describe('KeyManagerService', () => {
|
|||
});
|
||||
|
||||
describe('listKeys()', () => {
|
||||
it('returns all keys when no type filter is provided', async () => {
|
||||
it('forwards pagination and defaults to createdAt:desc when sortBy is not provided', async () => {
|
||||
const rows = [makeKey({ id: 'k1' }), makeKey({ id: 'k2' })];
|
||||
repository.find.mockResolvedValue(rows);
|
||||
repository.findAndCountForList.mockResolvedValue({ items: rows, count: 2 });
|
||||
|
||||
const result = await Container.get(KeyManagerService).listKeys();
|
||||
const result = await Container.get(KeyManagerService).listKeys({
|
||||
skip: 0,
|
||||
take: 10,
|
||||
} as never);
|
||||
|
||||
expect(repository.find).toHaveBeenCalledWith();
|
||||
expect(repository.findAllByType).not.toHaveBeenCalled();
|
||||
expect(result).toBe(rows);
|
||||
expect(repository.findAndCountForList).toHaveBeenCalledWith({
|
||||
type: undefined,
|
||||
sortField: 'createdAt',
|
||||
sortDirection: 'DESC',
|
||||
skip: 0,
|
||||
take: 10,
|
||||
createdAtFrom: undefined,
|
||||
createdAtTo: undefined,
|
||||
});
|
||||
expect(result).toEqual({ items: rows, count: 2 });
|
||||
});
|
||||
|
||||
it('filters by type when provided', async () => {
|
||||
it('parses sortBy into sortField and sortDirection', async () => {
|
||||
repository.findAndCountForList.mockResolvedValue({ items: [], count: 0 });
|
||||
|
||||
await Container.get(KeyManagerService).listKeys({
|
||||
skip: 5,
|
||||
take: 25,
|
||||
sortBy: 'updatedAt:asc',
|
||||
} as never);
|
||||
|
||||
expect(repository.findAndCountForList).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sortField: 'updatedAt',
|
||||
sortDirection: 'ASC',
|
||||
skip: 5,
|
||||
take: 25,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('forwards type and date range parsed as Date instances', async () => {
|
||||
repository.findAndCountForList.mockResolvedValue({ items: [], count: 0 });
|
||||
|
||||
await Container.get(KeyManagerService).listKeys({
|
||||
skip: 0,
|
||||
take: 10,
|
||||
type: 'data_encryption',
|
||||
sortBy: 'status:desc',
|
||||
activatedFrom: '2026-04-01T00:00:00.000Z',
|
||||
activatedTo: '2026-04-30T23:59:59.999Z',
|
||||
} as never);
|
||||
|
||||
const call = repository.findAndCountForList.mock.calls[0][0];
|
||||
expect(call.type).toBe('data_encryption');
|
||||
expect(call.sortField).toBe('status');
|
||||
expect(call.sortDirection).toBe('DESC');
|
||||
expect(call.createdAtFrom).toBeInstanceOf(Date);
|
||||
expect(call.createdAtFrom?.toISOString()).toBe('2026-04-01T00:00:00.000Z');
|
||||
expect(call.createdAtTo).toBeInstanceOf(Date);
|
||||
expect(call.createdAtTo?.toISOString()).toBe('2026-04-30T23:59:59.999Z');
|
||||
});
|
||||
|
||||
it('returns the repository result unchanged', async () => {
|
||||
const rows = [makeKey({ id: 'k1' })];
|
||||
repository.findAllByType.mockResolvedValue(rows);
|
||||
repository.findAndCountForList.mockResolvedValue({ items: rows, count: 7 });
|
||||
|
||||
const result = await Container.get(KeyManagerService).listKeys('data_encryption');
|
||||
const result = await Container.get(KeyManagerService).listKeys({
|
||||
skip: 0,
|
||||
take: 10,
|
||||
} as never);
|
||||
|
||||
expect(repository.findAllByType).toHaveBeenCalledWith('data_encryption');
|
||||
expect(repository.find).not.toHaveBeenCalled();
|
||||
expect(result).toBe(rows);
|
||||
expect(result).toEqual({ items: rows, count: 7 });
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,14 +1,17 @@
|
|||
import {
|
||||
CreateEncryptionKeyDto,
|
||||
ListEncryptionKeysQueryDto,
|
||||
type EncryptionKeyResponseDto,
|
||||
type EncryptionKey,
|
||||
type EncryptionKeysList,
|
||||
} from '@n8n/api-types';
|
||||
import { type DeploymentKey } from '@n8n/db';
|
||||
import { Body, Get, GlobalScope, Post, Query, RestController } from '@n8n/decorators';
|
||||
|
||||
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||
|
||||
import { KeyManagerService } from './key-manager.service';
|
||||
|
||||
function toResponseDto(row: DeploymentKey): EncryptionKeyResponseDto {
|
||||
function toResponseDto(row: DeploymentKey): EncryptionKey {
|
||||
return {
|
||||
id: row.id,
|
||||
type: row.type,
|
||||
|
|
@ -29,9 +32,17 @@ export class EncryptionKeyController {
|
|||
_req: unknown,
|
||||
_res: unknown,
|
||||
@Query query: ListEncryptionKeysQueryDto,
|
||||
): Promise<EncryptionKeyResponseDto[]> {
|
||||
const rows = await this.keyManagerService.listKeys(query.type);
|
||||
return rows.map(toResponseDto);
|
||||
): Promise<EncryptionKeysList> {
|
||||
if (
|
||||
query.activatedFrom &&
|
||||
query.activatedTo &&
|
||||
new Date(query.activatedFrom).getTime() > new Date(query.activatedTo).getTime()
|
||||
) {
|
||||
throw new BadRequestError('activatedFrom must be earlier than or equal to activatedTo');
|
||||
}
|
||||
|
||||
const { items, count } = await this.keyManagerService.listKeys(query);
|
||||
return { count, items: items.map(toResponseDto) };
|
||||
}
|
||||
|
||||
@Post('/')
|
||||
|
|
@ -40,7 +51,7 @@ export class EncryptionKeyController {
|
|||
_req: unknown,
|
||||
_res: unknown,
|
||||
@Body _body: CreateEncryptionKeyDto,
|
||||
): Promise<EncryptionKeyResponseDto> {
|
||||
): Promise<EncryptionKey> {
|
||||
const row = await this.keyManagerService.rotateKey();
|
||||
return toResponseDto(row);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,10 @@
|
|||
import { DeploymentKeyRepository, type DeploymentKey } from '@n8n/db';
|
||||
import type { ListEncryptionKeysQueryDto } from '@n8n/api-types';
|
||||
import {
|
||||
DeploymentKeyRepository,
|
||||
type DeploymentKey,
|
||||
type DeploymentKeySortDirection,
|
||||
type DeploymentKeySortField,
|
||||
} from '@n8n/db';
|
||||
import { Service } from '@n8n/di';
|
||||
import { Cipher, type CipherAlgorithm } from 'n8n-core';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
|
|
@ -88,10 +94,28 @@ export class KeyManagerService {
|
|||
});
|
||||
}
|
||||
|
||||
/** Lists encryption keys, optionally filtered by type. */
|
||||
async listKeys(type?: string): Promise<DeploymentKey[]> {
|
||||
if (type) return await this.deploymentKeyRepository.findAllByType(type);
|
||||
return await this.deploymentKeyRepository.find();
|
||||
/**
|
||||
* Lists encryption keys with pagination, optional filtering by type and
|
||||
* activation date, and an optional `sortBy` of the form `field:direction`.
|
||||
* Defaults to `createdAt:desc` when no `sortBy` is provided.
|
||||
*/
|
||||
async listKeys(
|
||||
query: ListEncryptionKeysQueryDto,
|
||||
): Promise<{ items: DeploymentKey[]; count: number }> {
|
||||
const [field, direction] = (query.sortBy ?? 'createdAt:desc').split(':') as [
|
||||
DeploymentKeySortField,
|
||||
'asc' | 'desc',
|
||||
];
|
||||
|
||||
return await this.deploymentKeyRepository.findAndCountForList({
|
||||
type: query.type,
|
||||
sortField: field,
|
||||
sortDirection: direction.toUpperCase() as DeploymentKeySortDirection,
|
||||
skip: query.skip,
|
||||
take: query.take,
|
||||
createdAtFrom: query.activatedFrom ? new Date(query.activatedFrom) : undefined,
|
||||
createdAtTo: query.activatedTo ? new Date(query.activatedTo) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { testDb } from '@n8n/backend-test-utils';
|
||||
import type { User } from '@n8n/db';
|
||||
import type { DeploymentKey, User } from '@n8n/db';
|
||||
import { DeploymentKeyRepository } from '@n8n/db';
|
||||
import { Container } from '@n8n/di';
|
||||
|
||||
|
|
@ -33,20 +33,32 @@ const seedKey = async (
|
|||
algorithm: string | null;
|
||||
status: string;
|
||||
value: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}> = {},
|
||||
) =>
|
||||
await deploymentKeyRepository.save(
|
||||
) => {
|
||||
const { createdAt, updatedAt, ...rest } = overrides;
|
||||
const saved = await deploymentKeyRepository.save(
|
||||
deploymentKeyRepository.create({
|
||||
type: 'data_encryption',
|
||||
value: 'seed-value',
|
||||
algorithm: 'aes-256-cbc',
|
||||
status: 'inactive',
|
||||
...overrides,
|
||||
...rest,
|
||||
}),
|
||||
);
|
||||
if (createdAt || updatedAt) {
|
||||
await deploymentKeyRepository.update(saved.id, {
|
||||
...(createdAt ? { createdAt } : {}),
|
||||
...(updatedAt ? { updatedAt } : {}),
|
||||
});
|
||||
return await deploymentKeyRepository.findOneByOrFail({ id: saved.id });
|
||||
}
|
||||
return saved;
|
||||
};
|
||||
|
||||
describe('GET /encryption/keys', () => {
|
||||
test('returns all keys for owner with id/type/algorithm/status/createdAt/updatedAt (never value)', async () => {
|
||||
test('returns { count, items } envelope; items shape includes id/type/algorithm/status/createdAt/updatedAt and never value', async () => {
|
||||
const legacy = await seedKey({ algorithm: 'aes-256-cbc', status: 'inactive', value: 'legacy' });
|
||||
const active = await seedKey({
|
||||
algorithm: 'aes-256-gcm',
|
||||
|
|
@ -56,7 +68,10 @@ describe('GET /encryption/keys', () => {
|
|||
|
||||
const response = await ownerAgent.get('/encryption/keys').expect(200);
|
||||
|
||||
const rows = response.body.data as Array<Record<string, unknown>>;
|
||||
expect(response.body.data).toHaveProperty('count', 2);
|
||||
expect(response.body.data).toHaveProperty('items');
|
||||
|
||||
const rows = response.body.data.items as Array<Record<string, unknown>>;
|
||||
expect(rows).toHaveLength(2);
|
||||
|
||||
const ids = rows.map((r) => r.id);
|
||||
|
|
@ -84,18 +99,198 @@ describe('GET /encryption/keys', () => {
|
|||
|
||||
const response = await ownerAgent.get('/encryption/keys?type=data_encryption').expect(200);
|
||||
|
||||
const rows = response.body.data as Array<Record<string, unknown>>;
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].type).toBe('data_encryption');
|
||||
expect(response.body.data.count).toBe(1);
|
||||
expect(response.body.data.items).toHaveLength(1);
|
||||
expect(response.body.data.items[0].type).toBe('data_encryption');
|
||||
});
|
||||
|
||||
test('returns 400 when type is not in the whitelist', async () => {
|
||||
await ownerAgent.get('/encryption/keys?type=other_type').expect(400);
|
||||
});
|
||||
|
||||
test('returns 403 for a non-owner user', async () => {
|
||||
await memberAgent.get('/encryption/keys').expect(403);
|
||||
});
|
||||
|
||||
test('returns empty list when no keys exist', async () => {
|
||||
test('returns empty envelope when no keys exist', async () => {
|
||||
const response = await ownerAgent.get('/encryption/keys').expect(200);
|
||||
expect(response.body.data).toEqual([]);
|
||||
expect(response.body.data).toEqual({ count: 0, items: [] });
|
||||
});
|
||||
|
||||
describe('pagination', () => {
|
||||
const seedMany = async (count: number) => {
|
||||
for (let i = 0; i < count; i++) {
|
||||
await seedKey({
|
||||
algorithm: 'aes-256-gcm',
|
||||
status: i === 0 ? 'active' : 'inactive',
|
||||
value: `seed-${i}`,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
test('returns the first page with skip=0&take=10', async () => {
|
||||
await seedMany(25);
|
||||
|
||||
const response = await ownerAgent.get('/encryption/keys?skip=0&take=10').expect(200);
|
||||
|
||||
expect(response.body.data.count).toBe(25);
|
||||
expect(response.body.data.items).toHaveLength(10);
|
||||
});
|
||||
|
||||
test('returns the last page with skip=20&take=10', async () => {
|
||||
await seedMany(25);
|
||||
|
||||
const response = await ownerAgent.get('/encryption/keys?skip=20&take=10').expect(200);
|
||||
|
||||
expect(response.body.data.count).toBe(25);
|
||||
expect(response.body.data.items).toHaveLength(5);
|
||||
});
|
||||
|
||||
test('returns 400 when skip is negative', async () => {
|
||||
await ownerAgent.get('/encryption/keys?skip=-1').expect(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sorting', () => {
|
||||
const setupThreeKeys = async () => {
|
||||
const a = await seedKey({
|
||||
algorithm: 'aes-256-cbc',
|
||||
status: 'active',
|
||||
value: 'a',
|
||||
createdAt: new Date('2026-04-15T00:00:00.000Z'),
|
||||
updatedAt: new Date('2026-04-25T00:00:00.000Z'),
|
||||
});
|
||||
const b = await seedKey({
|
||||
algorithm: 'aes-256-gcm',
|
||||
status: 'inactive',
|
||||
value: 'b',
|
||||
createdAt: new Date('2026-04-21T00:00:00.000Z'),
|
||||
updatedAt: new Date('2026-04-21T00:00:00.000Z'),
|
||||
});
|
||||
const c = await seedKey({
|
||||
algorithm: 'aes-256-gcm',
|
||||
status: 'inactive',
|
||||
value: 'c',
|
||||
createdAt: new Date('2026-04-25T00:00:00.000Z'),
|
||||
updatedAt: new Date('2026-04-15T00:00:00.000Z'),
|
||||
});
|
||||
return { a, b, c };
|
||||
};
|
||||
|
||||
test('sortBy=createdAt:asc orders by createdAt ascending', async () => {
|
||||
const { a, b, c } = await setupThreeKeys();
|
||||
|
||||
const response = await ownerAgent.get('/encryption/keys?sortBy=createdAt:asc').expect(200);
|
||||
|
||||
const ids = response.body.data.items.map((r: { id: string }) => r.id);
|
||||
expect(ids).toEqual([a.id, b.id, c.id]);
|
||||
});
|
||||
|
||||
test('sortBy=createdAt:desc orders by createdAt descending', async () => {
|
||||
const { a, b, c } = await setupThreeKeys();
|
||||
|
||||
const response = await ownerAgent.get('/encryption/keys?sortBy=createdAt:desc').expect(200);
|
||||
|
||||
const ids = response.body.data.items.map((r: { id: string }) => r.id);
|
||||
expect(ids).toEqual([c.id, b.id, a.id]);
|
||||
});
|
||||
|
||||
test('sortBy=updatedAt:asc orders by updatedAt ascending', async () => {
|
||||
const { a, b, c } = await setupThreeKeys();
|
||||
|
||||
const response = await ownerAgent.get('/encryption/keys?sortBy=updatedAt:asc').expect(200);
|
||||
|
||||
const ids = response.body.data.items.map((r: { id: string }) => r.id);
|
||||
expect(ids).toEqual([c.id, b.id, a.id]);
|
||||
});
|
||||
|
||||
test('sortBy=status:asc orders status lexicographically (active before inactive)', async () => {
|
||||
const { a } = await setupThreeKeys();
|
||||
|
||||
const response = await ownerAgent.get('/encryption/keys?sortBy=status:asc').expect(200);
|
||||
|
||||
const items = response.body.data.items as Array<{ id: string; status: string }>;
|
||||
expect(items[0].id).toBe(a.id);
|
||||
expect(items[0].status).toBe('active');
|
||||
expect(items.slice(1).map((r) => r.status)).toEqual(['inactive', 'inactive']);
|
||||
});
|
||||
|
||||
test('returns 400 when sortBy is not in the whitelist', async () => {
|
||||
await ownerAgent.get('/encryption/keys?sortBy=foo:asc').expect(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('activation date filter', () => {
|
||||
const setupRange = async () => {
|
||||
const inRange = await seedKey({
|
||||
algorithm: 'aes-256-gcm',
|
||||
status: 'inactive',
|
||||
value: 'in-range',
|
||||
createdAt: new Date('2026-04-21T12:00:00.000Z'),
|
||||
});
|
||||
const before = await seedKey({
|
||||
algorithm: 'aes-256-gcm',
|
||||
status: 'inactive',
|
||||
value: 'before',
|
||||
createdAt: new Date('2026-04-15T00:00:00.000Z'),
|
||||
});
|
||||
const after = await seedKey({
|
||||
algorithm: 'aes-256-gcm',
|
||||
status: 'inactive',
|
||||
value: 'after',
|
||||
createdAt: new Date('2026-04-25T00:00:00.000Z'),
|
||||
});
|
||||
return { inRange, before, after };
|
||||
};
|
||||
|
||||
test('returns only keys created in [activatedFrom, activatedTo]', async () => {
|
||||
const { inRange } = await setupRange();
|
||||
|
||||
const response = await ownerAgent
|
||||
.get(
|
||||
'/encryption/keys?activatedFrom=2026-04-20T00:00:00.000Z&activatedTo=2026-04-22T23:59:59.999Z',
|
||||
)
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.data.count).toBe(1);
|
||||
expect(response.body.data.items[0].id).toBe(inRange.id);
|
||||
});
|
||||
|
||||
test('returns only keys created at or after activatedFrom when activatedTo is omitted', async () => {
|
||||
const { inRange, after } = await setupRange();
|
||||
|
||||
const response = await ownerAgent
|
||||
.get('/encryption/keys?activatedFrom=2026-04-20T00:00:00.000Z')
|
||||
.expect(200);
|
||||
|
||||
const ids = response.body.data.items.map((r: { id: string }) => r.id);
|
||||
expect(ids).toEqual(expect.arrayContaining([inRange.id, after.id]));
|
||||
expect(response.body.data.count).toBe(2);
|
||||
});
|
||||
|
||||
test('returns only keys created at or before activatedTo when activatedFrom is omitted', async () => {
|
||||
const { before, inRange } = await setupRange();
|
||||
|
||||
const response = await ownerAgent
|
||||
.get('/encryption/keys?activatedTo=2026-04-22T23:59:59.999Z')
|
||||
.expect(200);
|
||||
|
||||
const ids = response.body.data.items.map((r: { id: string }) => r.id);
|
||||
expect(ids).toEqual(expect.arrayContaining([before.id, inRange.id]));
|
||||
expect(response.body.data.count).toBe(2);
|
||||
});
|
||||
|
||||
test('returns 400 when activatedFrom > activatedTo', async () => {
|
||||
await ownerAgent
|
||||
.get(
|
||||
'/encryption/keys?activatedFrom=2026-04-30T00:00:00.000Z&activatedTo=2026-04-01T00:00:00.000Z',
|
||||
)
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
test('returns 400 when activatedFrom is not a valid ISO datetime', async () => {
|
||||
await ownerAgent.get('/encryption/keys?activatedFrom=2026-04-21').expect(400);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -125,7 +320,7 @@ describe('POST /encryption/keys', () => {
|
|||
const rows = await deploymentKeyRepository.find({ where: { type: 'data_encryption' } });
|
||||
expect(rows).toHaveLength(2);
|
||||
|
||||
const active = rows.filter((r) => r.status === 'active');
|
||||
const active = rows.filter((r: DeploymentKey) => r.status === 'active');
|
||||
expect(active).toHaveLength(1);
|
||||
expect(active[0].algorithm).toBe('aes-256-gcm');
|
||||
expect(typeof active[0].value).toBe('string');
|
||||
|
|
|
|||
|
|
@ -0,0 +1,86 @@
|
|||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { ref, computed, defineComponent, h } from 'vue';
|
||||
import type { SimplifiedNodeType } from '@/Interface';
|
||||
|
||||
const useNodeIconSourceMock = vi.fn();
|
||||
|
||||
vi.mock('@/app/composables/useNodeIconSource', () => ({
|
||||
useNodeIconSource: (...args: unknown[]) => {
|
||||
useNodeIconSourceMock(...args);
|
||||
return computed(() => undefined);
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@n8n/design-system', () => ({
|
||||
N8nNodeIcon: defineComponent({
|
||||
props: ['type', 'src', 'name', 'disabled', 'size', 'circle'],
|
||||
setup() {
|
||||
return () => h('div', { class: 'stubbed-node-icon' });
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
import NodeIcon from './NodeIcon.vue';
|
||||
|
||||
const setNodeType: SimplifiedNodeType = {
|
||||
name: 'n8n-nodes-base.set',
|
||||
displayName: 'Edit Fields',
|
||||
description: '',
|
||||
group: ['input'],
|
||||
defaults: {},
|
||||
outputs: [],
|
||||
};
|
||||
|
||||
const calendarNodeType: SimplifiedNodeType = {
|
||||
name: 'n8n-nodes-base.googleCalendar',
|
||||
displayName: 'Google Calendar',
|
||||
description: '',
|
||||
group: ['input'],
|
||||
defaults: {},
|
||||
outputs: [],
|
||||
};
|
||||
|
||||
describe('NodeIcon.vue', () => {
|
||||
it('passes reactive getters to useNodeIconSource so the icon refreshes when the nodeType prop changes', () => {
|
||||
useNodeIconSourceMock.mockClear();
|
||||
|
||||
const nodeTypeRef = ref<SimplifiedNodeType>(setNodeType);
|
||||
|
||||
mount(NodeIcon, {
|
||||
global: {
|
||||
plugins: [createTestingPinia({ stubActions: false })],
|
||||
},
|
||||
props: {
|
||||
nodeType: nodeTypeRef.value,
|
||||
},
|
||||
});
|
||||
|
||||
expect(useNodeIconSourceMock).toHaveBeenCalledTimes(1);
|
||||
const [nodeTypeArg, nodeArg] = useNodeIconSourceMock.mock.calls[0];
|
||||
|
||||
expect(typeof nodeTypeArg).toBe('function');
|
||||
expect(typeof nodeArg).toBe('function');
|
||||
});
|
||||
|
||||
it('the nodeType getter passed to useNodeIconSource reflects the latest prop value', async () => {
|
||||
useNodeIconSourceMock.mockClear();
|
||||
|
||||
const wrapper = mount(NodeIcon, {
|
||||
global: {
|
||||
plugins: [createTestingPinia({ stubActions: false })],
|
||||
},
|
||||
props: {
|
||||
nodeType: setNodeType,
|
||||
},
|
||||
});
|
||||
|
||||
const [nodeTypeGetter] = useNodeIconSourceMock.mock.calls[0];
|
||||
expect((nodeTypeGetter as () => SimplifiedNodeType)()).toEqual(setNodeType);
|
||||
|
||||
await wrapper.setProps({ nodeType: calendarNodeType });
|
||||
|
||||
expect((nodeTypeGetter as () => SimplifiedNodeType)()).toEqual(calendarNodeType);
|
||||
});
|
||||
});
|
||||
|
|
@ -40,7 +40,10 @@ const emit = defineEmits<{
|
|||
click: [];
|
||||
}>();
|
||||
|
||||
const iconSourceFromNodeType = useNodeIconSource(props.nodeType, props.node ?? null);
|
||||
const iconSourceFromNodeType = useNodeIconSource(
|
||||
() => props.nodeType,
|
||||
() => props.node ?? null,
|
||||
);
|
||||
|
||||
const iconSource = computed(() => props.iconSource ?? iconSourceFromNodeType.value);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import type { INode } from 'n8n-workflow';
|
||||
import { getNodeIconSource, type IconNodeType, type NodeIconSource } from '../utils/nodeIcon';
|
||||
import { computed, toValue, type ComputedRef, type MaybeRef } from 'vue';
|
||||
import { computed, toValue, type ComputedRef, type MaybeRefOrGetter } from 'vue';
|
||||
import { useWorkflowsStore } from '../stores/workflows.store';
|
||||
import {
|
||||
createWorkflowDocumentId,
|
||||
|
|
@ -8,8 +8,8 @@ import {
|
|||
} from '../stores/workflowDocument.store';
|
||||
|
||||
export function useNodeIconSource(
|
||||
nodeType: MaybeRef<IconNodeType | string | null | undefined>,
|
||||
node?: MaybeRef<INode | null>,
|
||||
nodeType: MaybeRefOrGetter<IconNodeType | string | null | undefined>,
|
||||
node?: MaybeRefOrGetter<INode | null>,
|
||||
): ComputedRef<NodeIconSource | undefined> {
|
||||
const workflowsStore = useWorkflowsStore();
|
||||
const workflowDocumentStore = computed(() =>
|
||||
|
|
|
|||
|
|
@ -83,4 +83,56 @@ describe('useUniqueNodeName', () => {
|
|||
|
||||
expect(uniqueNodeName('S3')).toBe('S32');
|
||||
});
|
||||
|
||||
test('should preserve decimal suffix when duplicating a node name ending with a version-like decimal', () => {
|
||||
const workflowDocumentStore = useWorkflowDocumentStore(createWorkflowDocumentId(TEST_WF_ID));
|
||||
|
||||
const mockCanvasNames = new Set(['Claude Sonnet 4.6']);
|
||||
|
||||
vi.spyOn(workflowDocumentStore, 'canvasNames', 'get').mockReturnValue(mockCanvasNames);
|
||||
|
||||
const { uniqueNodeName } = useUniqueNodeName();
|
||||
|
||||
expect(uniqueNodeName('Claude Sonnet 4.6')).toBe('Claude Sonnet 4.61');
|
||||
|
||||
mockCanvasNames.add('Claude Sonnet 4.61');
|
||||
|
||||
expect(uniqueNodeName('Claude Sonnet 4.6')).toBe('Claude Sonnet 4.62');
|
||||
});
|
||||
|
||||
test('should preserve multi-digit decimal suffix when duplicating', () => {
|
||||
const workflowDocumentStore = useWorkflowDocumentStore(createWorkflowDocumentId(TEST_WF_ID));
|
||||
|
||||
const mockCanvasNames = new Set(['GPT 5.10']);
|
||||
|
||||
vi.spyOn(workflowDocumentStore, 'canvasNames', 'get').mockReturnValue(mockCanvasNames);
|
||||
|
||||
const { uniqueNodeName } = useUniqueNodeName();
|
||||
|
||||
expect(uniqueNodeName('GPT 5.10')).toBe('GPT 5.101');
|
||||
});
|
||||
|
||||
test('should preserve multi-segment decimal suffix when duplicating', () => {
|
||||
const workflowDocumentStore = useWorkflowDocumentStore(createWorkflowDocumentId(TEST_WF_ID));
|
||||
|
||||
const mockCanvasNames = new Set(['Gemini 2.0.5']);
|
||||
|
||||
vi.spyOn(workflowDocumentStore, 'canvasNames', 'get').mockReturnValue(mockCanvasNames);
|
||||
|
||||
const { uniqueNodeName } = useUniqueNodeName();
|
||||
|
||||
expect(uniqueNodeName('Gemini 2.0.5')).toBe('Gemini 2.0.51');
|
||||
});
|
||||
|
||||
test('should still treat trailing digits with no decimal as a counter', () => {
|
||||
const workflowDocumentStore = useWorkflowDocumentStore(createWorkflowDocumentId(TEST_WF_ID));
|
||||
|
||||
const mockCanvasNames = new Set(['MyNode', 'MyNode42']);
|
||||
|
||||
vi.spyOn(workflowDocumentStore, 'canvasNames', 'get').mockReturnValue(mockCanvasNames);
|
||||
|
||||
const { uniqueNodeName } = useUniqueNodeName();
|
||||
|
||||
expect(uniqueNodeName('MyNode42')).toBe('MyNode43');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -97,14 +97,21 @@ export function useUniqueNodeName() {
|
|||
throw new Error('Failed to find match for unique name');
|
||||
}
|
||||
|
||||
if (match?.groups?.suffix !== '') {
|
||||
index = parseInt(match.groups.suffix, 10);
|
||||
let { base, suffix } = match.groups;
|
||||
|
||||
if (suffix !== '' && /\d\.$/.test(base)) {
|
||||
base += suffix;
|
||||
suffix = '';
|
||||
}
|
||||
|
||||
unique = match.groups.base;
|
||||
if (suffix !== '') {
|
||||
index = parseInt(suffix, 10);
|
||||
}
|
||||
|
||||
unique = base;
|
||||
|
||||
while (canvasNames.has(unique) || extraNames.includes(unique)) {
|
||||
unique = match.groups.base + index++;
|
||||
unique = base + index++;
|
||||
}
|
||||
|
||||
return unique;
|
||||
|
|
|
|||
|
|
@ -1,16 +1,29 @@
|
|||
import type { CreateEncryptionKeyDto } from '@n8n/api-types';
|
||||
import type {
|
||||
CreateEncryptionKeyDto,
|
||||
EncryptionKeysList,
|
||||
EncryptionKeysSortOption,
|
||||
} from '@n8n/api-types';
|
||||
import { makeRestApiRequest, type IRestApiContext } from '@n8n/rest-api-client';
|
||||
|
||||
import type { EncryptionKey } from './encryption-keys.types';
|
||||
|
||||
const ENDPOINT = '/encryption/keys';
|
||||
|
||||
export const getEncryptionKeys = async (context: IRestApiContext): Promise<EncryptionKey[]> => {
|
||||
return await makeRestApiRequest<EncryptionKey[]>(context, 'GET', ENDPOINT, {
|
||||
type: 'data_encryption',
|
||||
});
|
||||
export type GetEncryptionKeysParams = {
|
||||
type?: 'data_encryption';
|
||||
skip?: number;
|
||||
take?: number;
|
||||
sortBy?: EncryptionKeysSortOption;
|
||||
activatedFrom?: string;
|
||||
activatedTo?: string;
|
||||
};
|
||||
|
||||
export const getEncryptionKeys = async (
|
||||
context: IRestApiContext,
|
||||
params: GetEncryptionKeysParams = {},
|
||||
): Promise<EncryptionKeysList> =>
|
||||
await makeRestApiRequest<EncryptionKeysList>(context, 'GET', ENDPOINT, { ...params });
|
||||
|
||||
export const rotateEncryptionKey = async (context: IRestApiContext): Promise<EncryptionKey> => {
|
||||
const payload: CreateEncryptionKeyDto = { type: 'data_encryption' };
|
||||
return await makeRestApiRequest<EncryptionKey>(context, 'POST', ENDPOINT, { ...payload });
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { createPinia, setActivePinia } from 'pinia';
|
||||
|
||||
import * as encryptionKeysApi from './encryption-keys.api';
|
||||
import { useEncryptionKeysStore } from './encryption-keys.store';
|
||||
import type { EncryptionKey } from './encryption-keys.types';
|
||||
|
||||
|
|
@ -24,74 +25,238 @@ const makeKey = (overrides: Partial<EncryptionKey> = {}): EncryptionKey => ({
|
|||
...overrides,
|
||||
});
|
||||
|
||||
const apiMock = vi.mocked(encryptionKeysApi.getEncryptionKeys);
|
||||
const rotateMock = vi.mocked(encryptionKeysApi.rotateEncryptionKey);
|
||||
|
||||
const stubResponse = (items: EncryptionKey[] = [], count?: number) =>
|
||||
apiMock.mockResolvedValueOnce({ items, count: count ?? items.length });
|
||||
|
||||
describe('encryption-keys.store', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
apiMock.mockReset();
|
||||
rotateMock.mockReset();
|
||||
});
|
||||
|
||||
describe('visibleKeys with date-range filter', () => {
|
||||
// Build the local YYYY-MM-DD that the picker would emit for a given timestamp,
|
||||
// matching the formatter used by the store. Keeps tests TZ-independent.
|
||||
const localDay = (iso: string) => new Intl.DateTimeFormat('en-CA').format(new Date(iso));
|
||||
|
||||
it('returns the key when From and To are both set to its activation day', () => {
|
||||
describe('fetchKeys', () => {
|
||||
it('uses defaults: page 0, items-per-page 25, sortBy createdAt:desc, type=data_encryption', async () => {
|
||||
stubResponse([makeKey()]);
|
||||
const store = useEncryptionKeysStore();
|
||||
|
||||
await store.fetchKeys();
|
||||
|
||||
expect(apiMock).toHaveBeenCalledTimes(1);
|
||||
expect(apiMock.mock.calls[0][1]).toEqual({
|
||||
type: 'data_encryption',
|
||||
skip: 0,
|
||||
take: 25,
|
||||
sortBy: 'createdAt:desc',
|
||||
activatedFrom: undefined,
|
||||
activatedTo: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('populates items and totalCount from the response', async () => {
|
||||
const key = makeKey();
|
||||
store.keys = [key];
|
||||
const day = localDay(key.createdAt);
|
||||
store.setFilters({ activatedFrom: day, activatedTo: day });
|
||||
stubResponse([key], 7);
|
||||
|
||||
expect(store.visibleKeys).toEqual([key]);
|
||||
const store = useEncryptionKeysStore();
|
||||
await store.fetchKeys();
|
||||
|
||||
expect(store.items).toEqual([key]);
|
||||
expect(store.totalCount).toBe(7);
|
||||
});
|
||||
|
||||
it('includes a key whose activation day falls inside the range', () => {
|
||||
it('sends skip = page * itemsPerPage', async () => {
|
||||
stubResponse();
|
||||
const store = useEncryptionKeysStore();
|
||||
const key = makeKey({ createdAt: '2026-04-21T10:00:00.000Z' });
|
||||
store.keys = [key];
|
||||
const day = localDay(key.createdAt);
|
||||
store.itemsPerPage = 10;
|
||||
store.page = 3;
|
||||
|
||||
await store.fetchKeys();
|
||||
|
||||
expect(apiMock.mock.calls[0][1]).toMatchObject({ skip: 30, take: 10 });
|
||||
});
|
||||
|
||||
it('sets isLoading during the call', async () => {
|
||||
let resolveCall!: () => void;
|
||||
apiMock.mockImplementationOnce(
|
||||
async () =>
|
||||
await new Promise((resolve) => {
|
||||
resolveCall = () => resolve({ items: [], count: 0 });
|
||||
}),
|
||||
);
|
||||
|
||||
const store = useEncryptionKeysStore();
|
||||
const pending = store.fetchKeys();
|
||||
expect(store.isLoading).toBe(true);
|
||||
resolveCall();
|
||||
await pending;
|
||||
expect(store.isLoading).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mutations reset to page 0 and forward to the API on next fetch', () => {
|
||||
it('setSort updates sort, resets page to 0, and forwards in next fetch', async () => {
|
||||
const store = useEncryptionKeysStore();
|
||||
store.page = 4;
|
||||
|
||||
store.setSort({ field: 'updatedAt', direction: 'asc' });
|
||||
|
||||
expect(store.page).toBe(0);
|
||||
expect(store.sort).toEqual({ field: 'updatedAt', direction: 'asc' });
|
||||
|
||||
stubResponse();
|
||||
await store.fetchKeys();
|
||||
|
||||
expect(apiMock.mock.calls[0][1]).toMatchObject({ sortBy: 'updatedAt:asc' });
|
||||
});
|
||||
|
||||
it('setItemsPerPage updates pagination and resets page to 0', async () => {
|
||||
const store = useEncryptionKeysStore();
|
||||
store.page = 4;
|
||||
|
||||
store.setItemsPerPage(50);
|
||||
|
||||
expect(store.page).toBe(0);
|
||||
expect(store.itemsPerPage).toBe(50);
|
||||
|
||||
stubResponse();
|
||||
await store.fetchKeys();
|
||||
|
||||
expect(apiMock.mock.calls[0][1]).toMatchObject({ skip: 0, take: 50 });
|
||||
});
|
||||
|
||||
it('setFilters updates filters, resets page, and forwards in next fetch', async () => {
|
||||
const store = useEncryptionKeysStore();
|
||||
store.page = 2;
|
||||
|
||||
store.setFilters({
|
||||
activatedFrom: localDay('2026-04-20T00:00:00.000Z'),
|
||||
activatedTo: localDay('2026-04-22T00:00:00.000Z'),
|
||||
activatedFrom: '2026-04-01T00:00:00.000Z',
|
||||
activatedTo: '2026-04-30T23:59:59.999Z',
|
||||
});
|
||||
|
||||
expect(store.visibleKeys.map((k) => k.id)).toContain(key.id);
|
||||
// Sanity-check the local-day calculation isn't pinned to UTC.
|
||||
expect(typeof day).toBe('string');
|
||||
});
|
||||
expect(store.page).toBe(0);
|
||||
|
||||
it('excludes a key whose activation day is before From', () => {
|
||||
const store = useEncryptionKeysStore();
|
||||
const key = makeKey({ createdAt: '2026-04-15T10:00:00.000Z' });
|
||||
store.keys = [key];
|
||||
store.setFilters({
|
||||
activatedFrom: localDay('2026-04-20T00:00:00.000Z'),
|
||||
activatedTo: null,
|
||||
stubResponse();
|
||||
await store.fetchKeys();
|
||||
|
||||
expect(apiMock.mock.calls[0][1]).toMatchObject({
|
||||
activatedFrom: '2026-04-01T00:00:00.000Z',
|
||||
activatedTo: '2026-04-30T23:59:59.999Z',
|
||||
});
|
||||
|
||||
expect(store.visibleKeys).toEqual([]);
|
||||
});
|
||||
|
||||
it('excludes a key whose activation day is after To', () => {
|
||||
it('resetFilters clears filters and resets page', async () => {
|
||||
const store = useEncryptionKeysStore();
|
||||
const key = makeKey({ createdAt: '2026-04-25T10:00:00.000Z' });
|
||||
store.keys = [key];
|
||||
store.setFilters({
|
||||
activatedFrom: null,
|
||||
activatedTo: localDay('2026-04-20T00:00:00.000Z'),
|
||||
store.filters = {
|
||||
activatedFrom: '2026-04-01T00:00:00.000Z',
|
||||
activatedTo: '2026-04-30T23:59:59.999Z',
|
||||
};
|
||||
store.page = 2;
|
||||
|
||||
store.resetFilters();
|
||||
|
||||
expect(store.page).toBe(0);
|
||||
expect(store.filters).toEqual({ activatedFrom: null, activatedTo: null });
|
||||
|
||||
stubResponse();
|
||||
await store.fetchKeys();
|
||||
|
||||
expect(apiMock.mock.calls[0][1]).toMatchObject({
|
||||
activatedFrom: undefined,
|
||||
activatedTo: undefined,
|
||||
});
|
||||
|
||||
expect(store.visibleKeys).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns all keys when no filter is set', () => {
|
||||
it('setPage does NOT reset other state', () => {
|
||||
const store = useEncryptionKeysStore();
|
||||
const keys = [
|
||||
makeKey({ id: 'a', createdAt: '2026-04-15T10:00:00.000Z' }),
|
||||
makeKey({ id: 'b', createdAt: '2026-04-21T10:00:00.000Z' }),
|
||||
];
|
||||
store.keys = keys;
|
||||
store.itemsPerPage = 50;
|
||||
store.sort = { field: 'updatedAt', direction: 'asc' };
|
||||
|
||||
expect(store.visibleKeys.map((k) => k.id).sort()).toEqual(['a', 'b']);
|
||||
store.setPage(7);
|
||||
|
||||
expect(store.page).toBe(7);
|
||||
expect(store.itemsPerPage).toBe(50);
|
||||
expect(store.sort).toEqual({ field: 'updatedAt', direction: 'asc' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('rotateKey', () => {
|
||||
it('calls API, resets to page 0 and createdAt:desc, then refetches', async () => {
|
||||
rotateMock.mockResolvedValueOnce(makeKey());
|
||||
const fetchedKey = makeKey({ id: 'fresh' });
|
||||
stubResponse([fetchedKey], 1);
|
||||
|
||||
const store = useEncryptionKeysStore();
|
||||
store.page = 4;
|
||||
store.sort = { field: 'updatedAt', direction: 'asc' };
|
||||
|
||||
await store.rotateKey();
|
||||
|
||||
expect(rotateMock).toHaveBeenCalledTimes(1);
|
||||
expect(store.page).toBe(0);
|
||||
expect(store.sort).toEqual({ field: 'createdAt', direction: 'desc' });
|
||||
expect(apiMock).toHaveBeenCalledTimes(1);
|
||||
expect(apiMock.mock.calls[0][1]).toMatchObject({
|
||||
skip: 0,
|
||||
sortBy: 'createdAt:desc',
|
||||
});
|
||||
expect(store.items).toEqual([fetchedKey]);
|
||||
expect(store.totalCount).toBe(1);
|
||||
});
|
||||
|
||||
it('toggles isRotating during the call', async () => {
|
||||
let resolveRotate!: (value: EncryptionKey) => void;
|
||||
rotateMock.mockImplementationOnce(
|
||||
async () =>
|
||||
await new Promise<EncryptionKey>((resolve) => {
|
||||
resolveRotate = resolve;
|
||||
}),
|
||||
);
|
||||
stubResponse();
|
||||
|
||||
const store = useEncryptionKeysStore();
|
||||
const pending = store.rotateKey();
|
||||
expect(store.isRotating).toBe(true);
|
||||
resolveRotate(makeKey());
|
||||
await pending;
|
||||
expect(store.isRotating).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isEmpty', () => {
|
||||
it('is true when no items and no filters', () => {
|
||||
const store = useEncryptionKeysStore();
|
||||
expect(store.isEmpty).toBe(true);
|
||||
});
|
||||
|
||||
it('is false when filters are active even if there are no items', () => {
|
||||
const store = useEncryptionKeysStore();
|
||||
store.filters = { activatedFrom: '2026-04-01T00:00:00.000Z', activatedTo: null };
|
||||
expect(store.isEmpty).toBe(false);
|
||||
});
|
||||
|
||||
it('is false when there are items', () => {
|
||||
const store = useEncryptionKeysStore();
|
||||
store.items = [makeKey()];
|
||||
store.totalCount = 1;
|
||||
expect(store.isEmpty).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('activeKey', () => {
|
||||
it('returns the key with status=active', () => {
|
||||
const store = useEncryptionKeysStore();
|
||||
const active = makeKey({ id: 'a', status: 'active' });
|
||||
store.items = [makeKey({ id: 'b', status: 'inactive' }), active];
|
||||
expect(store.activeKey).toEqual(active);
|
||||
});
|
||||
|
||||
it('returns null when no active key is present', () => {
|
||||
const store = useEncryptionKeysStore();
|
||||
store.items = [makeKey({ status: 'inactive' })];
|
||||
expect(store.activeKey).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,10 +3,11 @@ import { defineStore } from 'pinia';
|
|||
import { useRootStore } from '@n8n/stores/useRootStore';
|
||||
|
||||
import * as encryptionKeysApi from './encryption-keys.api';
|
||||
import type {
|
||||
EncryptionKey,
|
||||
EncryptionKeyFilters,
|
||||
EncryptionKeySort,
|
||||
import {
|
||||
toApiSort,
|
||||
type EncryptionKey,
|
||||
type EncryptionKeyFilters,
|
||||
type EncryptionKeySort,
|
||||
} from './encryption-keys.types';
|
||||
|
||||
const DEFAULT_SORT: EncryptionKeySort = { field: 'createdAt', direction: 'desc' };
|
||||
|
|
@ -16,68 +17,45 @@ const DEFAULT_FILTERS: EncryptionKeyFilters = {
|
|||
activatedTo: null,
|
||||
};
|
||||
|
||||
// `en-CA` formats a Date as `YYYY-MM-DD` in the user's local time zone — same
|
||||
// shape as the date strings produced by the picker, enabling lexicographic
|
||||
// comparison without time-of-day or UTC-offset surprises.
|
||||
const localDateFormatter = new Intl.DateTimeFormat('en-CA');
|
||||
const DEFAULT_PAGE = 0;
|
||||
const DEFAULT_ITEMS_PER_PAGE = 25;
|
||||
|
||||
export const useEncryptionKeysStore = defineStore('encryptionKeys', () => {
|
||||
const rootStore = useRootStore();
|
||||
|
||||
const keys = ref<EncryptionKey[]>([]);
|
||||
const items = ref<EncryptionKey[]>([]);
|
||||
const totalCount = ref(0);
|
||||
const isLoading = ref(false);
|
||||
const isRotating = ref(false);
|
||||
|
||||
const page = ref(DEFAULT_PAGE);
|
||||
const itemsPerPage = ref(DEFAULT_ITEMS_PER_PAGE);
|
||||
const sort = ref<EncryptionKeySort>({ ...DEFAULT_SORT });
|
||||
const filters = ref<EncryptionKeyFilters>({ ...DEFAULT_FILTERS });
|
||||
|
||||
const compareKeys = (a: EncryptionKey, b: EncryptionKey, field: EncryptionKeySort['field']) => {
|
||||
if (field === 'status') {
|
||||
return a.status.localeCompare(b.status);
|
||||
}
|
||||
const hasActiveFilters = computed(
|
||||
() => filters.value.activatedFrom !== null || filters.value.activatedTo !== null,
|
||||
);
|
||||
|
||||
if (field === 'updatedAt') {
|
||||
const valueA = a.status === 'inactive' ? a.updatedAt : null;
|
||||
const valueB = b.status === 'inactive' ? b.updatedAt : null;
|
||||
if (valueA === null && valueB === null) return 0;
|
||||
if (valueA === null) return 1;
|
||||
if (valueB === null) return -1;
|
||||
return new Date(valueA).getTime() - new Date(valueB).getTime();
|
||||
}
|
||||
const activeKey = computed(() => items.value.find((key) => key.status === 'active') ?? null);
|
||||
|
||||
return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
|
||||
};
|
||||
|
||||
const matchesFilters = (key: EncryptionKey) => {
|
||||
const { activatedFrom, activatedTo } = filters.value;
|
||||
if (!activatedFrom && !activatedTo) return true;
|
||||
|
||||
// Compare on the user's local calendar day so `from === to` includes keys
|
||||
// activated at any time during that day.
|
||||
const activatedDay = localDateFormatter.format(new Date(key.createdAt));
|
||||
|
||||
if (activatedFrom && activatedDay < activatedFrom) return false;
|
||||
if (activatedTo && activatedDay > activatedTo) return false;
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const visibleKeys = computed<EncryptionKey[]>(() => {
|
||||
const filtered = keys.value.filter(matchesFilters);
|
||||
const sorted = [...filtered].sort((a, b) => {
|
||||
const diff = compareKeys(a, b, sort.value.field);
|
||||
return sort.value.direction === 'asc' ? diff : -diff;
|
||||
});
|
||||
return sorted;
|
||||
});
|
||||
|
||||
const activeKey = computed(() => keys.value.find((key) => key.status === 'active') ?? null);
|
||||
|
||||
const isEmpty = computed(() => !isLoading.value && keys.value.length === 0);
|
||||
const isEmpty = computed(
|
||||
() => !isLoading.value && totalCount.value === 0 && !hasActiveFilters.value,
|
||||
);
|
||||
|
||||
const fetchKeys = async () => {
|
||||
isLoading.value = true;
|
||||
try {
|
||||
keys.value = await encryptionKeysApi.getEncryptionKeys(rootStore.restApiContext);
|
||||
const response = await encryptionKeysApi.getEncryptionKeys(rootStore.restApiContext, {
|
||||
type: 'data_encryption',
|
||||
skip: page.value * itemsPerPage.value,
|
||||
take: itemsPerPage.value,
|
||||
sortBy: toApiSort(sort.value),
|
||||
activatedFrom: filters.value.activatedFrom ?? undefined,
|
||||
activatedTo: filters.value.activatedTo ?? undefined,
|
||||
});
|
||||
items.value = response.items as EncryptionKey[];
|
||||
totalCount.value = response.count;
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
|
|
@ -87,35 +65,55 @@ export const useEncryptionKeysStore = defineStore('encryptionKeys', () => {
|
|||
isRotating.value = true;
|
||||
try {
|
||||
await encryptionKeysApi.rotateEncryptionKey(rootStore.restApiContext);
|
||||
// After rotation, jump back to the first page sorted by `createdAt:desc`
|
||||
// so the newly active key is visible regardless of the previous view.
|
||||
page.value = DEFAULT_PAGE;
|
||||
sort.value = { ...DEFAULT_SORT };
|
||||
await fetchKeys();
|
||||
} finally {
|
||||
isRotating.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const setPage = (next: number) => {
|
||||
page.value = next;
|
||||
};
|
||||
|
||||
const setItemsPerPage = (next: number) => {
|
||||
itemsPerPage.value = next;
|
||||
page.value = DEFAULT_PAGE;
|
||||
};
|
||||
|
||||
const setSort = (next: EncryptionKeySort) => {
|
||||
sort.value = { ...next };
|
||||
page.value = DEFAULT_PAGE;
|
||||
};
|
||||
|
||||
const setFilters = (next: Partial<EncryptionKeyFilters>) => {
|
||||
filters.value = { ...filters.value, ...next };
|
||||
page.value = DEFAULT_PAGE;
|
||||
};
|
||||
|
||||
const resetFilters = () => {
|
||||
filters.value = { ...DEFAULT_FILTERS };
|
||||
page.value = DEFAULT_PAGE;
|
||||
};
|
||||
|
||||
return {
|
||||
keys,
|
||||
visibleKeys,
|
||||
items,
|
||||
totalCount,
|
||||
activeKey,
|
||||
isLoading,
|
||||
isRotating,
|
||||
isEmpty,
|
||||
page,
|
||||
itemsPerPage,
|
||||
sort,
|
||||
filters,
|
||||
fetchKeys,
|
||||
rotateKey,
|
||||
setPage,
|
||||
setItemsPerPage,
|
||||
setSort,
|
||||
setFilters,
|
||||
resetFilters,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
import type { EncryptionKeyResponseDto } from '@n8n/api-types';
|
||||
import type {
|
||||
EncryptionKey as EncryptionKeyApiType,
|
||||
EncryptionKeysSortOption,
|
||||
} from '@n8n/api-types';
|
||||
|
||||
export type EncryptionKeyStatus = 'active' | 'inactive';
|
||||
|
||||
|
|
@ -7,18 +10,24 @@ export type EncryptionKeyStatus = 'active' | 'inactive';
|
|||
* transitions from `active` to `inactive` its `updatedAt` is bumped, and that
|
||||
* transition cannot be undone.
|
||||
*/
|
||||
export type EncryptionKey = Omit<EncryptionKeyResponseDto, 'status'> & {
|
||||
export type EncryptionKey = Omit<EncryptionKeyApiType, 'status'> & {
|
||||
status: EncryptionKeyStatus;
|
||||
};
|
||||
|
||||
export type EncryptionKeySortField = 'createdAt' | 'updatedAt' | 'status';
|
||||
export type EncryptionKeySortDirection = 'asc' | 'desc';
|
||||
|
||||
export type EncryptionKeySort = {
|
||||
field: EncryptionKeySortField;
|
||||
direction: 'asc' | 'desc';
|
||||
direction: EncryptionKeySortDirection;
|
||||
};
|
||||
|
||||
export type EncryptionKeyFilters = {
|
||||
/** ISO datetime, inclusive lower bound */
|
||||
activatedFrom: string | null;
|
||||
/** ISO datetime, inclusive upper bound */
|
||||
activatedTo: string | null;
|
||||
};
|
||||
|
||||
export const toApiSort = (sort: EncryptionKeySort): EncryptionKeysSortOption =>
|
||||
`${sort.field}:${sort.direction}` as EncryptionKeysSortOption;
|
||||
|
|
|
|||
|
|
@ -29,24 +29,41 @@ const makeKey = (overrides: Partial<EncryptionKey> = {}): EncryptionKey => ({
|
|||
...overrides,
|
||||
});
|
||||
|
||||
const seedStore = (overrides: Partial<{ items: EncryptionKey[]; totalCount: number }> = {}) => {
|
||||
const store = mockedStore(useEncryptionKeysStore);
|
||||
store.items = overrides.items ?? [makeKey()];
|
||||
store.totalCount = overrides.totalCount ?? store.items.length;
|
||||
store.fetchKeys.mockResolvedValue(undefined);
|
||||
return store;
|
||||
};
|
||||
|
||||
describe('SettingsEncryptionKeys', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
setActivePinia(createTestingPinia({ stubActions: false }));
|
||||
});
|
||||
|
||||
it('renders a row for each encryption key with a masked id', async () => {
|
||||
const store = mockedStore(useEncryptionKeysStore);
|
||||
store.keys = [
|
||||
makeKey(),
|
||||
makeKey({
|
||||
id: '74f6c1e9b4d8a2f51234',
|
||||
status: 'inactive',
|
||||
createdAt: '2026-03-15T10:00:00.000Z',
|
||||
}),
|
||||
];
|
||||
store.visibleKeys = store.keys;
|
||||
store.fetchKeys.mockResolvedValue(undefined);
|
||||
it('calls fetchKeys on mount', async () => {
|
||||
const store = seedStore();
|
||||
renderComponent(SettingsEncryptionKeys);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(store.fetchKeys).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('renders a row for each item with a masked id', async () => {
|
||||
seedStore({
|
||||
items: [
|
||||
makeKey(),
|
||||
makeKey({
|
||||
id: '74f6c1e9b4d8a2f51234',
|
||||
status: 'inactive',
|
||||
createdAt: '2026-03-15T10:00:00.000Z',
|
||||
}),
|
||||
],
|
||||
totalCount: 2,
|
||||
});
|
||||
|
||||
renderComponent(SettingsEncryptionKeys);
|
||||
|
||||
|
|
@ -57,17 +74,17 @@ describe('SettingsEncryptionKeys', () => {
|
|||
});
|
||||
|
||||
it('shows active and inactive status badges', async () => {
|
||||
const store = mockedStore(useEncryptionKeysStore);
|
||||
store.keys = [
|
||||
makeKey({ status: 'active' }),
|
||||
makeKey({
|
||||
id: '74f6c1e9b4d8a2f51234',
|
||||
status: 'inactive',
|
||||
createdAt: '2026-03-15T10:00:00.000Z',
|
||||
}),
|
||||
];
|
||||
store.visibleKeys = store.keys;
|
||||
store.fetchKeys.mockResolvedValue(undefined);
|
||||
seedStore({
|
||||
items: [
|
||||
makeKey({ status: 'active' }),
|
||||
makeKey({
|
||||
id: '74f6c1e9b4d8a2f51234',
|
||||
status: 'inactive',
|
||||
createdAt: '2026-03-15T10:00:00.000Z',
|
||||
}),
|
||||
],
|
||||
totalCount: 2,
|
||||
});
|
||||
|
||||
renderComponent(SettingsEncryptionKeys);
|
||||
|
||||
|
|
@ -78,10 +95,7 @@ describe('SettingsEncryptionKeys', () => {
|
|||
});
|
||||
|
||||
it('rotates the key after confirmation and reports success', async () => {
|
||||
const store = mockedStore(useEncryptionKeysStore);
|
||||
store.keys = [makeKey()];
|
||||
store.visibleKeys = store.keys;
|
||||
store.fetchKeys.mockResolvedValue(undefined);
|
||||
const store = seedStore();
|
||||
store.rotateKey.mockResolvedValue(undefined);
|
||||
|
||||
renderComponent(SettingsEncryptionKeys);
|
||||
|
|
@ -100,10 +114,7 @@ describe('SettingsEncryptionKeys', () => {
|
|||
});
|
||||
|
||||
it('surfaces rotate errors via showError', async () => {
|
||||
const store = mockedStore(useEncryptionKeysStore);
|
||||
store.keys = [makeKey()];
|
||||
store.visibleKeys = store.keys;
|
||||
store.fetchKeys.mockResolvedValue(undefined);
|
||||
const store = seedStore();
|
||||
const error = new Error('rotate failed');
|
||||
store.rotateKey.mockRejectedValue(error);
|
||||
|
||||
|
|
@ -122,21 +133,13 @@ describe('SettingsEncryptionKeys', () => {
|
|||
});
|
||||
|
||||
describe('filter popover', () => {
|
||||
const seedKeys = () => {
|
||||
const store = mockedStore(useEncryptionKeysStore);
|
||||
store.keys = [makeKey()];
|
||||
store.visibleKeys = store.keys;
|
||||
store.fetchKeys.mockResolvedValue(undefined);
|
||||
return store;
|
||||
};
|
||||
|
||||
const openPopover = async () => {
|
||||
const trigger = await screen.findByRole('button', { name: 'Filter' });
|
||||
await fireEvent.click(trigger);
|
||||
};
|
||||
|
||||
it('hides the Clear button when no filter is active', async () => {
|
||||
seedKeys();
|
||||
seedStore();
|
||||
renderComponent(SettingsEncryptionKeys);
|
||||
|
||||
await openPopover();
|
||||
|
|
@ -146,8 +149,11 @@ describe('SettingsEncryptionKeys', () => {
|
|||
});
|
||||
|
||||
it('shows the Clear button when a filter is active', async () => {
|
||||
const store = seedKeys();
|
||||
store.filters = { activatedFrom: '2025-06-01', activatedTo: '2025-12-15' };
|
||||
const store = seedStore();
|
||||
store.filters = {
|
||||
activatedFrom: '2025-06-01T00:00:00.000Z',
|
||||
activatedTo: '2025-12-15T23:59:59.999Z',
|
||||
};
|
||||
renderComponent(SettingsEncryptionKeys);
|
||||
|
||||
await openPopover();
|
||||
|
|
@ -156,36 +162,43 @@ describe('SettingsEncryptionKeys', () => {
|
|||
expect(screen.getByRole('button', { name: 'Clear' })).toBeVisible();
|
||||
});
|
||||
|
||||
it('commits the seeded filter to the store when Apply is clicked', async () => {
|
||||
const store = seedKeys();
|
||||
store.filters = { activatedFrom: '2025-06-01', activatedTo: '2025-12-15' };
|
||||
it('converts the seeded local-day picker values to ISO instants and refetches', async () => {
|
||||
const store = seedStore();
|
||||
store.filters = {
|
||||
activatedFrom: '2025-06-01T00:00:00.000Z',
|
||||
activatedTo: '2025-12-15T00:00:00.000Z',
|
||||
};
|
||||
renderComponent(SettingsEncryptionKeys);
|
||||
|
||||
await openPopover();
|
||||
await fireEvent.click(await screen.findByRole('button', { name: 'Apply' }));
|
||||
|
||||
expect(store.setFilters).toHaveBeenCalledWith({
|
||||
activatedFrom: '2025-06-01',
|
||||
activatedTo: '2025-12-15',
|
||||
});
|
||||
expect(store.setFilters).toHaveBeenCalledTimes(1);
|
||||
const arg = store.setFilters.mock.calls[0][0];
|
||||
expect(typeof arg.activatedFrom).toBe('string');
|
||||
expect(typeof arg.activatedTo).toBe('string');
|
||||
expect(arg.activatedFrom).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
|
||||
expect(arg.activatedTo).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
|
||||
expect(store.fetchKeys).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resets the store and closes the popover when Clear is clicked', async () => {
|
||||
const store = seedKeys();
|
||||
store.filters = { activatedFrom: '2025-06-01', activatedTo: '2025-12-15' };
|
||||
it('resets the store and refetches when Clear is clicked', async () => {
|
||||
const store = seedStore();
|
||||
store.filters = {
|
||||
activatedFrom: '2025-06-01T00:00:00.000Z',
|
||||
activatedTo: '2025-12-15T23:59:59.999Z',
|
||||
};
|
||||
renderComponent(SettingsEncryptionKeys);
|
||||
|
||||
await openPopover();
|
||||
await fireEvent.click(await screen.findByRole('button', { name: 'Clear' }));
|
||||
|
||||
expect(store.resetFilters).toHaveBeenCalledTimes(1);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('button', { name: 'Apply' })).not.toBeInTheDocument();
|
||||
});
|
||||
expect(store.fetchKeys).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not commit changes when the popover is dismissed without Apply', async () => {
|
||||
const store = seedKeys();
|
||||
const store = seedStore();
|
||||
renderComponent(SettingsEncryptionKeys);
|
||||
|
||||
await openPopover();
|
||||
|
|
@ -193,30 +206,5 @@ describe('SettingsEncryptionKeys', () => {
|
|||
|
||||
expect(store.setFilters).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('re-seeds the draft from the store on every open', async () => {
|
||||
const store = seedKeys();
|
||||
store.filters = { activatedFrom: '2025-06-01', activatedTo: null };
|
||||
renderComponent(SettingsEncryptionKeys);
|
||||
|
||||
// First open + Apply commits the seeded value.
|
||||
await openPopover();
|
||||
await fireEvent.click(await screen.findByRole('button', { name: 'Apply' }));
|
||||
expect(store.setFilters).toHaveBeenLastCalledWith({
|
||||
activatedFrom: '2025-06-01',
|
||||
activatedTo: null,
|
||||
});
|
||||
|
||||
// Mutate the store as if another code path changed the filter.
|
||||
store.filters = { activatedFrom: '2025-12-15', activatedTo: null };
|
||||
|
||||
// Re-open + Apply must commit the NEW seeded value, proving re-seed.
|
||||
await openPopover();
|
||||
await fireEvent.click(await screen.findByRole('button', { name: 'Apply' }));
|
||||
expect(store.setFilters).toHaveBeenLastCalledWith({
|
||||
activatedFrom: '2025-12-15',
|
||||
activatedTo: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<script lang="ts" setup>
|
||||
import { computed, onMounted, ref, shallowRef, watch } from 'vue';
|
||||
import { parseDate } from '@internationalized/date';
|
||||
import { parseDate, type CalendarDate } from '@internationalized/date';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import {
|
||||
N8nAlertDialog,
|
||||
|
|
@ -18,7 +18,7 @@ import {
|
|||
type DateRange,
|
||||
type DateValue,
|
||||
} from '@n8n/design-system';
|
||||
import type { TableHeader } from '@n8n/design-system/components/N8nDataTableServer';
|
||||
import type { TableHeader, TableOptions } from '@n8n/design-system/components/N8nDataTableServer';
|
||||
|
||||
import { useDocumentTitle } from '@/app/composables/useDocumentTitle';
|
||||
import { useToast } from '@/app/composables/useToast';
|
||||
|
|
@ -35,6 +35,13 @@ const store = useEncryptionKeysStore();
|
|||
|
||||
const DOCS_URL = 'https://docs.n8n.io/hosting/configuration/encryption-keys/';
|
||||
|
||||
const SORT_FIELDS: readonly EncryptionKeySortField[] = ['createdAt', 'updatedAt', 'status'];
|
||||
|
||||
// Drives the date-input segment order in the picker (e.g. dd/mm/yyyy vs mm/dd/yyyy).
|
||||
// Falls back to a sensible default if the browser does not expose a language.
|
||||
const browserLocale =
|
||||
typeof navigator !== 'undefined' && navigator.language ? navigator.language : 'en-GB';
|
||||
|
||||
const isConfirmRotateOpen = ref(false);
|
||||
|
||||
const sortOptions = computed<Array<{ value: EncryptionKeySortField; label: string }>>(() => [
|
||||
|
|
@ -91,14 +98,25 @@ const headers = computed<Array<TableHeader<EncryptionKey>>>(() => [
|
|||
const archiveDate = (key: EncryptionKey): string | null =>
|
||||
key.status === 'inactive' ? key.updatedAt : null;
|
||||
|
||||
const visibleKeys = computed(() => store.visibleKeys);
|
||||
const tableOptions = ref<TableOptions>({
|
||||
page: 0,
|
||||
itemsPerPage: 25,
|
||||
sortBy: [{ id: 'createdAt', desc: true }],
|
||||
});
|
||||
|
||||
const pageState = ref({ page: 1, itemsPerPage: 25 });
|
||||
const isSortField = (id: string): id is EncryptionKeySortField =>
|
||||
(SORT_FIELDS as readonly string[]).includes(id);
|
||||
|
||||
const sortByModel = computed<EncryptionKeySortField>({
|
||||
get: () => store.sort.field,
|
||||
set: (field: EncryptionKeySortField) => {
|
||||
store.setSort({ field, direction: store.sort.direction });
|
||||
tableOptions.value = {
|
||||
...tableOptions.value,
|
||||
page: 0,
|
||||
sortBy: [{ id: field, desc: store.sort.direction === 'desc' }],
|
||||
};
|
||||
void refetch();
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -107,7 +125,9 @@ const isFilterOpen = ref(false);
|
|||
const stringToDateValue = (value: string | null): DateValue | undefined => {
|
||||
if (!value) return undefined;
|
||||
try {
|
||||
return parseDate(value);
|
||||
// `value` is an ISO datetime in the store, but the picker reads only
|
||||
// the calendar-day component. Slice off the time portion before parsing.
|
||||
return parseDate(value.slice(0, 10));
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
|
|
@ -116,6 +136,26 @@ const stringToDateValue = (value: string | null): DateValue | undefined => {
|
|||
const dateValueToString = (value: DateValue | undefined): string | null =>
|
||||
value ? value.toString() : null;
|
||||
|
||||
/**
|
||||
* Convert a local-calendar day (`YYYY-MM-DD`) into the start-of-day instant
|
||||
* in the user's local timezone, expressed as ISO. Without `Z`, `new Date(...)`
|
||||
* parses a local-time wall clock — which is what we want here.
|
||||
*/
|
||||
const localDayToIsoStart = (day: string) => new Date(`${day}T00:00:00`).toISOString();
|
||||
|
||||
/**
|
||||
* Convert a local-calendar day to the end-of-day instant (23:59:59.999) in the
|
||||
* user's local timezone, expressed as ISO. Used as the inclusive upper bound
|
||||
* so a single-day filter `from=to=2026-04-21` includes all keys created during
|
||||
* the user's local 2026-04-21.
|
||||
*/
|
||||
const localDayToIsoEnd = (day: string) => {
|
||||
const d = new Date(`${day}T00:00:00`);
|
||||
d.setDate(d.getDate() + 1);
|
||||
d.setMilliseconds(d.getMilliseconds() - 1);
|
||||
return d.toISOString();
|
||||
};
|
||||
|
||||
const draftRange = shallowRef<DateRange>({
|
||||
start: stringToDateValue(store.filters.activatedFrom),
|
||||
end: stringToDateValue(store.filters.activatedTo),
|
||||
|
|
@ -136,18 +176,54 @@ const hasActiveFilter = computed(
|
|||
() => store.filters.activatedFrom !== null || store.filters.activatedTo !== null,
|
||||
);
|
||||
|
||||
const onApplyFilter = () => {
|
||||
store.setFilters({
|
||||
activatedFrom: dateValueToString(draftRange.value.start),
|
||||
activatedTo: dateValueToString(draftRange.value.end),
|
||||
});
|
||||
isFilterOpen.value = false;
|
||||
const refetch = async () => {
|
||||
try {
|
||||
await store.fetchKeys();
|
||||
} catch (error) {
|
||||
showError(error, i18n.baseText('settings.encryptionKeys.loadError'));
|
||||
}
|
||||
};
|
||||
|
||||
const onClearFilter = () => {
|
||||
const onUpdateOptions = async (next: TableOptions) => {
|
||||
tableOptions.value = next;
|
||||
// Only forward sort/page-size changes when they actually changed, otherwise
|
||||
// the store's reset-to-page-0 side effects would silently override an
|
||||
// in-flight page change.
|
||||
if (store.itemsPerPage !== next.itemsPerPage) {
|
||||
store.setItemsPerPage(next.itemsPerPage);
|
||||
}
|
||||
if (next.sortBy.length > 0) {
|
||||
const first = next.sortBy[0];
|
||||
const field = isSortField(first.id) ? first.id : 'createdAt';
|
||||
const direction = first.desc ? 'desc' : 'asc';
|
||||
if (store.sort.field !== field || store.sort.direction !== direction) {
|
||||
store.setSort({ field, direction });
|
||||
}
|
||||
}
|
||||
// Apply page last so the user's selected page wins over any reset side effect.
|
||||
store.setPage(next.page);
|
||||
await refetch();
|
||||
};
|
||||
|
||||
const onApplyFilter = async () => {
|
||||
const startDay = dateValueToString(draftRange.value.start as CalendarDate | undefined);
|
||||
const endDay = dateValueToString(draftRange.value.end as CalendarDate | undefined);
|
||||
|
||||
store.setFilters({
|
||||
activatedFrom: startDay ? localDayToIsoStart(startDay) : null,
|
||||
activatedTo: endDay ? localDayToIsoEnd(endDay) : null,
|
||||
});
|
||||
tableOptions.value = { ...tableOptions.value, page: 0 };
|
||||
isFilterOpen.value = false;
|
||||
await refetch();
|
||||
};
|
||||
|
||||
const onClearFilter = async () => {
|
||||
store.resetFilters();
|
||||
tableOptions.value = { ...tableOptions.value, page: 0 };
|
||||
seedDraftFromStore();
|
||||
isFilterOpen.value = false;
|
||||
await refetch();
|
||||
};
|
||||
|
||||
const openRotateConfirm = () => {
|
||||
|
|
@ -163,6 +239,12 @@ const closeRotateConfirm = () => {
|
|||
const onConfirmRotate = async () => {
|
||||
try {
|
||||
await store.rotateKey();
|
||||
// Sync table chrome with the store's reset to page 0 / createdAt:desc.
|
||||
tableOptions.value = {
|
||||
...tableOptions.value,
|
||||
page: 0,
|
||||
sortBy: [{ id: 'createdAt', desc: true }],
|
||||
};
|
||||
isConfirmRotateOpen.value = false;
|
||||
showMessage({
|
||||
type: 'success',
|
||||
|
|
@ -183,11 +265,7 @@ const copyKeyId = async (id: string) => {
|
|||
|
||||
onMounted(async () => {
|
||||
documentTitle.set(i18n.baseText('settings.encryptionKeys.title'));
|
||||
try {
|
||||
await store.fetchKeys();
|
||||
} catch (error) {
|
||||
showError(error, i18n.baseText('settings.encryptionKeys.loadError'));
|
||||
}
|
||||
await refetch();
|
||||
});
|
||||
</script>
|
||||
|
||||
|
|
@ -225,7 +303,7 @@ onMounted(async () => {
|
|||
</N8nSelect>
|
||||
</div>
|
||||
|
||||
<N8nDateRangePicker v-model="draftRange" v-model:open="isFilterOpen">
|
||||
<N8nDateRangePicker v-model="draftRange" v-model:open="isFilterOpen" :locale="browserLocale">
|
||||
<template #trigger>
|
||||
<N8nIconButton
|
||||
icon="funnel"
|
||||
|
|
@ -273,14 +351,16 @@ onMounted(async () => {
|
|||
|
||||
<div :class="$style.tableWrapper">
|
||||
<N8nDataTableServer
|
||||
v-model:page="pageState.page"
|
||||
v-model:items-per-page="pageState.itemsPerPage"
|
||||
v-model:sort-by="tableOptions.sortBy"
|
||||
v-model:page="tableOptions.page"
|
||||
v-model:items-per-page="tableOptions.itemsPerPage"
|
||||
:headers="headers"
|
||||
:items="visibleKeys"
|
||||
:items-length="visibleKeys.length"
|
||||
:items="store.items"
|
||||
:items-length="store.totalCount"
|
||||
:loading="store.isLoading"
|
||||
:page-sizes="[10, 25, 50]"
|
||||
data-testid="encryption-keys-table"
|
||||
@update:options="onUpdateOptions"
|
||||
>
|
||||
<template #[`item.id`]="{ item }">
|
||||
<div :class="$style.keyCell">
|
||||
|
|
|
|||
|
|
@ -321,6 +321,53 @@ describe('useWorkflowDiffUI', () => {
|
|||
|
||||
expect(selectedNode.value).toEqual(mockNode);
|
||||
});
|
||||
|
||||
it('should return target node for modified nodes when type changed', () => {
|
||||
const baseNode = createMockNode({
|
||||
id: 'node-1',
|
||||
name: 'Edit Fields',
|
||||
type: 'n8n-nodes-base.set',
|
||||
});
|
||||
const targetNode = createMockNode({
|
||||
id: 'node-1',
|
||||
name: 'Get Calendar Events',
|
||||
type: 'n8n-nodes-base.googleCalendar',
|
||||
});
|
||||
const nodesDiff = ref(
|
||||
new Map([['node-1', { status: NodeDiffStatus.Modified, node: baseNode }]]),
|
||||
);
|
||||
|
||||
const { selectedNode } = useWorkflowDiffUI({
|
||||
sourceWorkflow: computed(() => createMockWorkflow({ nodes: [baseNode] })),
|
||||
targetWorkflow: computed(() => createMockWorkflow({ nodes: [targetNode] })),
|
||||
nodesDiff,
|
||||
connectionsDiff: ref(new Map()),
|
||||
selectedDetailId: ref('node-1'),
|
||||
});
|
||||
|
||||
expect(selectedNode.value).toEqual(targetNode);
|
||||
});
|
||||
|
||||
it('should fall back to source node for deleted nodes', () => {
|
||||
const deletedNode = createMockNode({
|
||||
id: 'node-1',
|
||||
name: 'Removed Node',
|
||||
type: 'n8n-nodes-base.slack',
|
||||
});
|
||||
const nodesDiff = ref(
|
||||
new Map([['node-1', { status: NodeDiffStatus.Deleted, node: deletedNode }]]),
|
||||
);
|
||||
|
||||
const { selectedNode } = useWorkflowDiffUI({
|
||||
sourceWorkflow: computed(() => createMockWorkflow({ nodes: [deletedNode] })),
|
||||
targetWorkflow: computed(() => createMockWorkflow({ nodes: [] })),
|
||||
nodesDiff,
|
||||
connectionsDiff: ref(new Map()),
|
||||
selectedDetailId: ref('node-1'),
|
||||
});
|
||||
|
||||
expect(selectedNode.value).toEqual(deletedNode);
|
||||
});
|
||||
});
|
||||
|
||||
describe('changesCount', () => {
|
||||
|
|
|
|||
|
|
@ -165,12 +165,17 @@ export function useWorkflowDiffUI(options: UseWorkflowDiffUIOptions) {
|
|||
|
||||
// Selected node
|
||||
const selectedNode = computed<INodeUi | undefined>(() => {
|
||||
if (!selectedDetailId.value) return undefined;
|
||||
const id = selectedDetailId.value;
|
||||
if (!id) return undefined;
|
||||
|
||||
const node = nodesDiff.value.get(selectedDetailId.value)?.node;
|
||||
if (!node) return undefined;
|
||||
|
||||
return node;
|
||||
// Prefer the target workflow so modified nodes whose type changed
|
||||
// surface the new node (and its icon) rather than the base version.
|
||||
// Falls back to source for deleted nodes, then to the diff entry.
|
||||
return (
|
||||
targetWorkflow.value?.nodes.find((node) => node.id === id) ??
|
||||
sourceWorkflow.value?.nodes.find((node) => node.id === id) ??
|
||||
nodesDiff.value.get(id)?.node
|
||||
);
|
||||
});
|
||||
|
||||
// Node diffs for aside panel
|
||||
|
|
|
|||
|
|
@ -1334,6 +1334,18 @@ importers:
|
|||
specifier: workspace:*
|
||||
version: link:../typescript-config
|
||||
|
||||
packages/@n8n/engine:
|
||||
devDependencies:
|
||||
'@n8n/typescript-config':
|
||||
specifier: workspace:*
|
||||
version: link:../typescript-config
|
||||
'@n8n/vitest-config':
|
||||
specifier: workspace:*
|
||||
version: link:../vitest-config
|
||||
vitest:
|
||||
specifier: 'catalog:'
|
||||
version: 4.1.1(@opentelemetry/api@1.9.0)(@types/node@20.19.21)(@vitest/browser-playwright@4.0.16)(jsdom@23.0.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))(vite@8.0.2(@types/node@20.19.21)(esbuild@0.25.10)(jiti@2.6.1)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.16.1)(tsx@4.19.3)(yaml@2.8.3))
|
||||
|
||||
packages/@n8n/errors:
|
||||
dependencies:
|
||||
callsites:
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user