From f3a21e14a1567f14a2c013f145a530bd2e1725aa Mon Sep 17 00:00:00 2001 From: mfsiega <93014743+mfsiega@users.noreply.github.com> Date: Wed, 6 May 2026 11:04:24 +0200 Subject: [PATCH 1/4] chore(core): Scaffold @n8n/engine package (no-changelog) (#29838) Co-authored-by: Claude Opus 4.7 (1M context) --- packages/@n8n/engine/.gitignore | 1 + packages/@n8n/engine/README.md | 14 ++++++++++++ packages/@n8n/engine/eslint.config.mjs | 4 ++++ packages/@n8n/engine/package.json | 28 ++++++++++++++++++++++++ packages/@n8n/engine/src/index.ts | 5 +++++ packages/@n8n/engine/tsconfig.build.json | 11 ++++++++++ packages/@n8n/engine/tsconfig.json | 13 +++++++++++ packages/@n8n/engine/vitest.config.ts | 3 +++ pnpm-lock.yaml | 12 ++++++++++ 9 files changed, 91 insertions(+) create mode 100644 packages/@n8n/engine/.gitignore create mode 100644 packages/@n8n/engine/README.md create mode 100644 packages/@n8n/engine/eslint.config.mjs create mode 100644 packages/@n8n/engine/package.json create mode 100644 packages/@n8n/engine/src/index.ts create mode 100644 packages/@n8n/engine/tsconfig.build.json create mode 100644 packages/@n8n/engine/tsconfig.json create mode 100644 packages/@n8n/engine/vitest.config.ts diff --git a/packages/@n8n/engine/.gitignore b/packages/@n8n/engine/.gitignore new file mode 100644 index 00000000000..1521c8b7652 --- /dev/null +++ b/packages/@n8n/engine/.gitignore @@ -0,0 +1 @@ +dist diff --git a/packages/@n8n/engine/README.md b/packages/@n8n/engine/README.md new file mode 100644 index 00000000000..185f90e8908 --- /dev/null +++ b/packages/@n8n/engine/README.md @@ -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. diff --git a/packages/@n8n/engine/eslint.config.mjs b/packages/@n8n/engine/eslint.config.mjs new file mode 100644 index 00000000000..f97402009c5 --- /dev/null +++ b/packages/@n8n/engine/eslint.config.mjs @@ -0,0 +1,4 @@ +import { defineConfig } from 'eslint/config'; +import { nodeConfig } from '@n8n/eslint-config/node'; + +export default defineConfig(nodeConfig); diff --git a/packages/@n8n/engine/package.json b/packages/@n8n/engine/package.json new file mode 100644 index 00000000000..f08a21a18c9 --- /dev/null +++ b/packages/@n8n/engine/package.json @@ -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:" + } +} diff --git a/packages/@n8n/engine/src/index.ts b/packages/@n8n/engine/src/index.ts new file mode 100644 index 00000000000..81901f68e5a --- /dev/null +++ b/packages/@n8n/engine/src/index.ts @@ -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 {}; diff --git a/packages/@n8n/engine/tsconfig.build.json b/packages/@n8n/engine/tsconfig.build.json new file mode 100644 index 00000000000..ab3f5caec86 --- /dev/null +++ b/packages/@n8n/engine/tsconfig.build.json @@ -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"] +} diff --git a/packages/@n8n/engine/tsconfig.json b/packages/@n8n/engine/tsconfig.json new file mode 100644 index 00000000000..9427943ed42 --- /dev/null +++ b/packages/@n8n/engine/tsconfig.json @@ -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"] +} diff --git a/packages/@n8n/engine/vitest.config.ts b/packages/@n8n/engine/vitest.config.ts new file mode 100644 index 00000000000..f8e3113a59e --- /dev/null +++ b/packages/@n8n/engine/vitest.config.ts @@ -0,0 +1,3 @@ +import { createVitestConfig } from '@n8n/vitest-config/node'; + +export default createVitestConfig(); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 073681f49fd..c207c7d9bba 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: From 08a36d7515eda29acd6c5e03f7968d4896465b3d Mon Sep 17 00:00:00 2001 From: Devendra Reddy Pennabadi Date: Wed, 6 May 2026 14:43:14 +0530 Subject: [PATCH 2/4] fix(editor): Preserve decimal suffix when duplicating a node (#29541) Co-authored-by: Garrit Franke <32395585+garritfra@users.noreply.github.com> --- .../app/composables/useUniqueNodeName.test.ts | 52 +++++++++++++++++++ .../src/app/composables/useUniqueNodeName.ts | 15 ++++-- 2 files changed, 63 insertions(+), 4 deletions(-) diff --git a/packages/frontend/editor-ui/src/app/composables/useUniqueNodeName.test.ts b/packages/frontend/editor-ui/src/app/composables/useUniqueNodeName.test.ts index fe5ba2cb54c..aa6c5b9dc05 100644 --- a/packages/frontend/editor-ui/src/app/composables/useUniqueNodeName.test.ts +++ b/packages/frontend/editor-ui/src/app/composables/useUniqueNodeName.test.ts @@ -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'); + }); }); diff --git a/packages/frontend/editor-ui/src/app/composables/useUniqueNodeName.ts b/packages/frontend/editor-ui/src/app/composables/useUniqueNodeName.ts index de57ddd138d..de83993a8ed 100644 --- a/packages/frontend/editor-ui/src/app/composables/useUniqueNodeName.ts +++ b/packages/frontend/editor-ui/src/app/composables/useUniqueNodeName.ts @@ -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; From 9afbe13b81f00f0ea7730541b4909e31b1080249 Mon Sep 17 00:00:00 2001 From: Csaba Tuncsik Date: Wed, 6 May 2026 11:20:14 +0200 Subject: [PATCH 3/4] feat(core): Server-side pagination, sorting, and filtering for encryption keys (#29708) --- .../list-encryption-keys-query.dto.test.ts | 131 ++++++++- .../encryption/encryption-key-response.dto.ts | 11 +- .../list-encryption-keys-query.dto.ts | 22 +- packages/@n8n/api-types/src/dto/index.ts | 6 +- packages/@n8n/api-types/src/index.ts | 7 + .../src/schemas/encryption-key.schema.ts | 18 ++ .../repositories/deployment-key.repository.ts | 47 ++++ packages/@n8n/db/src/repositories/index.ts | 7 +- .../encryption-key.controller.test.ts | 148 ++++++---- .../__tests__/key-manager.service.test.ts | 76 +++++- .../encryption-key.controller.ts | 23 +- .../key-manager.service.ts | 34 ++- .../integration/encryption-keys.api.test.ts | 219 ++++++++++++++- .../encryption-keys/encryption-keys.api.ts | 23 +- .../encryption-keys.store.test.ts | 253 +++++++++++++++--- .../encryption-keys/encryption-keys.store.ts | 104 ++++--- .../encryption-keys/encryption-keys.types.ts | 15 +- .../views/SettingsEncryptionKeys.test.ts | 150 +++++------ .../views/SettingsEncryptionKeys.vue | 124 +++++++-- 19 files changed, 1111 insertions(+), 307 deletions(-) create mode 100644 packages/@n8n/api-types/src/schemas/encryption-key.schema.ts diff --git a/packages/@n8n/api-types/src/dto/encryption/__tests__/list-encryption-keys-query.dto.test.ts b/packages/@n8n/api-types/src/dto/encryption/__tests__/list-encryption-keys-query.dto.test.ts index 0311ec16632..a8bb170f820 100644 --- a/packages/@n8n/api-types/src/dto/encryption/__tests__/list-encryption-keys-query.dto.test.ts +++ b/packages/@n8n/api-types/src/dto/encryption/__tests__/list-encryption-keys-query.dto.test.ts @@ -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']); + } + }); }); }); diff --git a/packages/@n8n/api-types/src/dto/encryption/encryption-key-response.dto.ts b/packages/@n8n/api-types/src/dto/encryption/encryption-key-response.dto.ts index 6294c012bc5..29a762d945c 100644 --- a/packages/@n8n/api-types/src/dto/encryption/encryption-key-response.dto.ts +++ b/packages/@n8n/api-types/src/dto/encryption/encryption-key-response.dto.ts @@ -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; diff --git a/packages/@n8n/api-types/src/dto/encryption/list-encryption-keys-query.dto.ts b/packages/@n8n/api-types/src/dto/encryption/list-encryption-keys-query.dto.ts index 6d9b3b23be4..1a09f5f3962 100644 --- a/packages/@n8n/api-types/src/dto/encryption/list-encryption-keys-query.dto.ts +++ b/packages/@n8n/api-types/src/dto/encryption/list-encryption-keys-query.dto.ts @@ -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(), }) {} diff --git a/packages/@n8n/api-types/src/dto/index.ts b/packages/@n8n/api-types/src/dto/index.ts index e89966a9af9..fb285cb0c78 100644 --- a/packages/@n8n/api-types/src/dto/index.ts +++ b/packages/@n8n/api-types/src/dto/index.ts @@ -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'; diff --git a/packages/@n8n/api-types/src/index.ts b/packages/@n8n/api-types/src/index.ts index c878c4b121a..da313139689 100644 --- a/packages/@n8n/api-types/src/index.ts +++ b/packages/@n8n/api-types/src/index.ts @@ -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, diff --git a/packages/@n8n/api-types/src/schemas/encryption-key.schema.ts b/packages/@n8n/api-types/src/schemas/encryption-key.schema.ts new file mode 100644 index 00000000000..b8b790e7a54 --- /dev/null +++ b/packages/@n8n/api-types/src/schemas/encryption-key.schema.ts @@ -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; +export type EncryptionKeysList = z.infer; diff --git a/packages/@n8n/db/src/repositories/deployment-key.repository.ts b/packages/@n8n/db/src/repositories/deployment-key.repository.ts index 5e2b7fbafbc..e54ec5ac771 100644 --- a/packages/@n8n/db/src/repositories/deployment-key.repository.ts +++ b/packages/@n8n/db/src/repositories/deployment-key.repository.ts @@ -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 { constructor(dataSource: DataSource) { @@ -17,6 +30,40 @@ export class DeploymentKeyRepository extends Repository { 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 diff --git a/packages/@n8n/db/src/repositories/index.ts b/packages/@n8n/db/src/repositories/index.ts index 872d0276fb6..0da45ba9941 100644 --- a/packages/@n8n/db/src/repositories/index.ts +++ b/packages/@n8n/db/src/repositories/index.ts @@ -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'; diff --git a/packages/cli/src/modules/encryption-key-manager/__tests__/encryption-key.controller.test.ts b/packages/cli/src/modules/encryption-key-manager/__tests__/encryption-key.controller.test.ts index eeb150f6b4c..dac1295fa8f 100644 --- a/packages/cli/src/modules/encryption-key-manager/__tests__/encryption-key.controller.test.ts +++ b/packages/cli/src/modules/encryption-key-manager/__tests__/encryption-key.controller.test.ts @@ -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 => ...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); }); }); diff --git a/packages/cli/src/modules/encryption-key-manager/__tests__/key-manager.service.test.ts b/packages/cli/src/modules/encryption-key-manager/__tests__/key-manager.service.test.ts index 53e169617b6..e290c1f7265 100644 --- a/packages/cli/src/modules/encryption-key-manager/__tests__/key-manager.service.test.ts +++ b/packages/cli/src/modules/encryption-key-manager/__tests__/key-manager.service.test.ts @@ -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 }); }); }); diff --git a/packages/cli/src/modules/encryption-key-manager/encryption-key.controller.ts b/packages/cli/src/modules/encryption-key-manager/encryption-key.controller.ts index 76bc044d0cd..5515d918a75 100644 --- a/packages/cli/src/modules/encryption-key-manager/encryption-key.controller.ts +++ b/packages/cli/src/modules/encryption-key-manager/encryption-key.controller.ts @@ -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 { - const rows = await this.keyManagerService.listKeys(query.type); - return rows.map(toResponseDto); + ): Promise { + 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 { + ): Promise { const row = await this.keyManagerService.rotateKey(); return toResponseDto(row); } diff --git a/packages/cli/src/modules/encryption-key-manager/key-manager.service.ts b/packages/cli/src/modules/encryption-key-manager/key-manager.service.ts index bc62b68eb51..87c60673efa 100644 --- a/packages/cli/src/modules/encryption-key-manager/key-manager.service.ts +++ b/packages/cli/src/modules/encryption-key-manager/key-manager.service.ts @@ -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 { - 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, + }); } /** diff --git a/packages/cli/test/integration/encryption-keys.api.test.ts b/packages/cli/test/integration/encryption-keys.api.test.ts index 3aad06eda2f..5fec4309ba8 100644 --- a/packages/cli/test/integration/encryption-keys.api.test.ts +++ b/packages/cli/test/integration/encryption-keys.api.test.ts @@ -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>; + expect(response.body.data).toHaveProperty('count', 2); + expect(response.body.data).toHaveProperty('items'); + + const rows = response.body.data.items as Array>; 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>; - 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'); diff --git a/packages/frontend/editor-ui/src/features/settings/encryption-keys/encryption-keys.api.ts b/packages/frontend/editor-ui/src/features/settings/encryption-keys/encryption-keys.api.ts index c6901a3f43f..61d3fac4039 100644 --- a/packages/frontend/editor-ui/src/features/settings/encryption-keys/encryption-keys.api.ts +++ b/packages/frontend/editor-ui/src/features/settings/encryption-keys/encryption-keys.api.ts @@ -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 => { - return await makeRestApiRequest(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 => + await makeRestApiRequest(context, 'GET', ENDPOINT, { ...params }); + export const rotateEncryptionKey = async (context: IRestApiContext): Promise => { const payload: CreateEncryptionKeyDto = { type: 'data_encryption' }; return await makeRestApiRequest(context, 'POST', ENDPOINT, { ...payload }); diff --git a/packages/frontend/editor-ui/src/features/settings/encryption-keys/encryption-keys.store.test.ts b/packages/frontend/editor-ui/src/features/settings/encryption-keys/encryption-keys.store.test.ts index 751c6cd8684..0b74b101820 100644 --- a/packages/frontend/editor-ui/src/features/settings/encryption-keys/encryption-keys.store.test.ts +++ b/packages/frontend/editor-ui/src/features/settings/encryption-keys/encryption-keys.store.test.ts @@ -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 => ({ ...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((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(); }); }); }); diff --git a/packages/frontend/editor-ui/src/features/settings/encryption-keys/encryption-keys.store.ts b/packages/frontend/editor-ui/src/features/settings/encryption-keys/encryption-keys.store.ts index 3e76ffec063..707ca74889a 100644 --- a/packages/frontend/editor-ui/src/features/settings/encryption-keys/encryption-keys.store.ts +++ b/packages/frontend/editor-ui/src/features/settings/encryption-keys/encryption-keys.store.ts @@ -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([]); + const items = ref([]); + 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({ ...DEFAULT_SORT }); const filters = ref({ ...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(() => { - 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) => { 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, diff --git a/packages/frontend/editor-ui/src/features/settings/encryption-keys/encryption-keys.types.ts b/packages/frontend/editor-ui/src/features/settings/encryption-keys/encryption-keys.types.ts index e41f9a8f52b..70f1544421f 100644 --- a/packages/frontend/editor-ui/src/features/settings/encryption-keys/encryption-keys.types.ts +++ b/packages/frontend/editor-ui/src/features/settings/encryption-keys/encryption-keys.types.ts @@ -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 & { +export type EncryptionKey = Omit & { 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; diff --git a/packages/frontend/editor-ui/src/features/settings/encryption-keys/views/SettingsEncryptionKeys.test.ts b/packages/frontend/editor-ui/src/features/settings/encryption-keys/views/SettingsEncryptionKeys.test.ts index 083871e7e22..4aca1e6fa33 100644 --- a/packages/frontend/editor-ui/src/features/settings/encryption-keys/views/SettingsEncryptionKeys.test.ts +++ b/packages/frontend/editor-ui/src/features/settings/encryption-keys/views/SettingsEncryptionKeys.test.ts @@ -29,24 +29,41 @@ const makeKey = (overrides: Partial = {}): 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, - }); - }); }); }); diff --git a/packages/frontend/editor-ui/src/features/settings/encryption-keys/views/SettingsEncryptionKeys.vue b/packages/frontend/editor-ui/src/features/settings/encryption-keys/views/SettingsEncryptionKeys.vue index fd30b233964..39db364df3f 100644 --- a/packages/frontend/editor-ui/src/features/settings/encryption-keys/views/SettingsEncryptionKeys.vue +++ b/packages/frontend/editor-ui/src/features/settings/encryption-keys/views/SettingsEncryptionKeys.vue @@ -1,6 +1,6 @@ @@ -225,7 +303,7 @@ onMounted(async () => { - +