Merge branch 'master' into ce-877-eslint-cred-class-name-suffix-credential-class-names-must

This commit is contained in:
Garrit Franke 2026-05-06 11:39:54 +02:00 committed by GitHub
commit a3efb6cdd7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
35 changed files with 1415 additions and 320 deletions

View File

@ -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']);
}
});
});
});

View File

@ -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;

View File

@ -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(),
}) {}

View File

@ -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';

View File

@ -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,

View 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>;

View File

@ -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

View File

@ -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
View File

@ -0,0 +1 @@
dist

View 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.

View File

@ -0,0 +1,4 @@
import { defineConfig } from 'eslint/config';
import { nodeConfig } from '@n8n/eslint-config/node';
export default defineConfig(nodeConfig);

View 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:"
}
}

View 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 {};

View 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"]
}

View 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"]
}

View File

@ -0,0 +1,3 @@
import { createVitestConfig } from '@n8n/vitest-config/node';
export default createVitestConfig();

View File

@ -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);
});
});

View File

@ -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 });
});
});

View File

@ -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);
}

View File

@ -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,
});
}
/**

View File

@ -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');

View File

@ -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);
});
});

View File

@ -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);

View File

@ -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(() =>

View File

@ -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');
});
});

View File

@ -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;

View File

@ -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 });

View File

@ -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();
});
});
});

View File

@ -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,

View File

@ -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;

View File

@ -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,
});
});
});
});

View File

@ -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">

View File

@ -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', () => {

View File

@ -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

View File

@ -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: