test(core): Add repeatable upgrade and rotation tests for the encryption key rollout (no-changelog) (#37885)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Savelii 2026-09-17 08:45:47 +00:00 committed by GitHub
parent 7fb840ec1d
commit 692a4f69cc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 1504 additions and 7 deletions

View File

@ -0,0 +1,152 @@
name: 'Test: Encryption Rollout'
on:
workflow_dispatch:
inputs:
test:
description: 'Which test to run'
type: choice
options:
- upgrade
- rotation
default: upgrade
db:
description: 'Database backends to run'
type: choice
options:
- both
- sqlite
- postgres
default: both
from-image:
description: 'Old release image to seed on (upgrade only; empty = pinned script default)'
type: string
default: ''
schedule:
# Twice a day, 09:00 and 21:00 UTC.
- cron: '0 9,21 * * *'
# PR events use the workflow file from the branch, so changes to the tests
# verify themselves (both modes) before the merge.
pull_request:
paths:
- 'packages/testing/playwright/tests/infrastructure/encryption/**'
- 'packages/testing/containers/services/n8n.ts'
- 'packages/testing/containers/services/types.ts'
- 'packages/testing/containers/stack.ts'
- '.github/workflows/encryption-upgrade-test.yml'
permissions:
contents: read
# A new push to the same PR cancels the runs of the previous one.
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}
cancel-in-progress: true
jobs:
encryption-cycle:
name: ${{ matrix.mode }} cycle (${{ matrix.db }})
runs-on: ubuntu-latest
timeout-minutes: 60
strategy:
fail-fast: false
matrix:
# PRs run both modes; a dispatch runs the selected one; the cron runs
# the full upgrade cycle. Each database backend is its own job.
mode: ${{ fromJSON(github.event_name == 'pull_request' && '["upgrade","rotation"]' || format('["{0}"]', inputs.test || 'upgrade')) }}
db: ${{ fromJSON((inputs.db == 'sqlite' || inputs.db == 'postgres') && format('["{0}"]', inputs.db) || '["sqlite","postgres"]') }}
env:
MODE: ${{ matrix.mode }}
DB: ${{ matrix.db }}
# Not a hidden dir: upload-artifact skips hidden paths by default.
WORK_ROOT: ${{ github.workspace }}/upgrade-test-work
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
# The image under test is the docker build of this checkout
# (n8nio/n8n:local) — the same artifact a release ships.
- name: Setup and build the image under test
uses: ./.github/actions/setup-nodejs
with:
build-command: pnpm build:docker
- name: Run the test cycle
env:
FROM_IMAGE_INPUT: ${{ inputs.from-image }}
ENCRYPTION_CYCLE_REQUIRED: 'true'
run: |
mkdir -p "$WORK_ROOT"
if [ -n "$FROM_IMAGE_INPUT" ]; then export FROM_IMAGE="$FROM_IMAGE_INPUT"; fi
set -o pipefail
pnpm --filter=n8n-playwright test:encryption:"$MODE" 2>&1 | tee "$WORK_ROOT/run.log"
echo "PASS: ${MODE}-cycle — all requested backends green ($DB)" | tee -a "$WORK_ROOT/run.log"
- name: Write run summary
if: always()
run: |
LOG="$WORK_ROOT/run.log"
[ -f "$LOG" ] || { echo "## Encryption test ($MODE, $DB)" >> "$GITHUB_STEP_SUMMARY"; echo "No run log was produced." >> "$GITHUB_STEP_SUMMARY"; exit 0; }
{
echo "## Encryption test ($MODE, $DB)"
echo
if grep -q "all requested backends green" "$LOG"; then
echo "**Result: PASS** :white_check_mark:"
else
echo "**Result: FAIL** :x:"
fi
echo
echo '```'
grep -E "PASS \[|PASS:|FAIL:" "$LOG" || true
echo '```'
echo
echo '```'
# Each metrics block ends at the first blank line (it may carry a
# "rotate api:" line after the totals row).
awk '/=== metrics \[/{f=1} f && /^$/{f=0; print ""} f{print}' "$LOG"
echo '```'
if ! grep -q "all requested backends green" "$LOG"; then
echo
echo '<details><summary>Last 80 log lines</summary>'
echo
echo '```'
tail -n 80 "$LOG"
echo '```'
echo '</details>'
fi
} >> "$GITHUB_STEP_SUMMARY"
- name: Upload run artifacts
if: always()
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: encryption-test-${{ matrix.mode }}-${{ matrix.db }}-${{ github.run_id }}
retention-days: 14
if-no-files-found: warn
path: |
${{ github.workspace }}/upgrade-test-work/run.log
${{ github.workspace }}/upgrade-test-work/*/metrics.csv
${{ github.workspace }}/upgrade-test-work/*/n8n.log
# A scheduled run fails silently otherwise; the job-level gate is required
# because a step-level `if: failure()` never fires on a needs-failure.
notify-on-failure:
name: Notify on scheduled failure
runs-on: ubuntu-slim
needs: [encryption-cycle]
if: ${{ always() && github.event_name == 'schedule' && contains(needs.*.result, 'failure') }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
sparse-checkout: .github/scripts/slack
- name: Send Slack notification
env:
SLACK_TOKEN: ${{ secrets.QBOT_SLACK_TOKEN }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
node .github/scripts/slack/notify.mjs \
--channel '#alerts-build' \
--text "<${RUN_URL}|Scheduled encryption rollout test failed>"

View File

@ -79,6 +79,8 @@
"test:ci:backend:integration": "turbo run test:integration --continue --concurrency=1 --filter='!./packages/frontend/**' --filter='!./packages/modules/*/frontend'",
"test:ci:backend:integration:changed": "turbo run test:integration:changed --continue --concurrency=1 --filter='!./packages/frontend/**' --filter='!./packages/modules/*/frontend'",
"test:affected": "turbo run test --affected --concurrency=1",
"test:encryption:upgrade": "pnpm --filter=n8n-playwright test:encryption:upgrade",
"test:encryption:rotation": "pnpm --filter=n8n-playwright test:encryption:rotation",
"db:schema:docs": "turbo run build --filter=@n8n/db && pnpm --filter=@n8n/db schema:docs",
"db:schema:check": "turbo run build --filter=@n8n/db && pnpm --filter=@n8n/db schema:check",
"db:schema:check:sqlite": "turbo run build --filter=@n8n/db && pnpm --filter=@n8n/db schema:check:sqlite",

View File

@ -83,6 +83,23 @@ export interface N8NInstancesOptions {
filesToMount?: FileToMount[];
coverageHostDir?: string;
registerContainer?: (container: StartedTestContainer) => void;
/**
* Override the n8n image for these instances (default: the process-wide
* TEST_IMAGE_N8N resolution). Lets one process boot different releases in
* sequence — the upgrade/downgrade cycles swap images over the same data.
*/
image?: string;
/**
* Host dir bind-mounted as the container's home (`/home/node`), so the
* user folder (settings file, sqlite database) outlives the container and
* a different image can boot on the same data. Disables container reuse.
* Pair with `user` so the files stay owned by the host user.
*/
userHomeHostDir?: string;
/** Run the container as this uid:gid (e.g. the host user for bind mounts). */
user?: string;
/** Readiness timeout override; an old release migrating a fresh DB can exceed the default. */
startupTimeoutMs?: number;
}
export interface N8NInstancesResult {
@ -154,6 +171,10 @@ interface SharedConfig {
filesToMount?: FileToMount[];
coverageHostDir?: string;
registerContainer?: (container: StartedTestContainer) => void;
image?: string;
userHomeHostDir?: string;
user?: string;
startupTimeoutMs?: number;
}
interface ContainerStartResult {
@ -182,30 +203,53 @@ async function createContainer(
filesToMount,
coverageHostDir,
registerContainer,
image,
userHomeHostDir,
user,
startupTimeoutMs,
} = shared;
const { consumer, throwWithLogs, getLogs } = createSilentLogConsumer();
const { strategy: waitStrategy, getLastBody: getLastReadinessBody } = createReadinessProbe(
'/healthz/readiness',
N8N_READINESS_PORT,
{ startupTimeoutMs: N8N_STARTUP_TIMEOUT_MS, readTimeoutMs: N8N_READ_TIMEOUT_MS },
{
startupTimeoutMs: startupTimeoutMs ?? N8N_STARTUP_TIMEOUT_MS,
readTimeoutMs: N8N_READ_TIMEOUT_MS,
},
);
const containerEnvironment = coverageHostDir
? { ...environment, NODE_V8_COVERAGE: CONTAINER_COVERAGE_DIR }
: environment;
let container = new GenericContainer(N8N_IMAGE)
const containerImage = image ?? N8N_IMAGE;
let container = new GenericContainer(containerImage)
.withEnvironment(containerEnvironment)
.withLabels({
'com.docker.compose.project': projectName,
'com.docker.compose.service': SERVICE_LABEL[role],
instance: instanceNumber.toString(),
})
.withPullPolicy(new N8nImagePullPolicy(N8N_IMAGE))
.withPullPolicy(new N8nImagePullPolicy(containerImage))
.withName(name)
.withLogConsumer(consumer)
.withNetwork(network);
if (user) {
container = container.withUser(user);
}
// withBindMounts REPLACES the mount list, so all mounts go in one call.
const bindMounts: Array<{ source: string; target: string; mode: 'rw' }> = [];
if (userHomeHostDir) {
// The whole home is mounted (not just ~/.n8n) so the settings file and
// the sqlite database live on the host and a different image can boot
// on the same data later.
mkdirSync(userHomeHostDir, { recursive: true });
bindMounts.push({ source: userHomeHostDir, target: '/home/node', mode: 'rw' });
}
if (coverageHostDir) {
// Per-container host dir → /cov; n8n flushes V8 here on graceful stop.
// Reuse must stay off so the process actually exits and flushes.
@ -215,10 +259,16 @@ async function createContainer(
// is direct (no Docker Desktop uid mapping), so make the dir writable by
// the container or NODE_V8_COVERAGE silently fails to flush.
chmodSync(hostCoverageDir, 0o777);
container = container.withBindMounts([
{ source: hostCoverageDir, target: CONTAINER_COVERAGE_DIR, mode: 'rw' },
]);
} else {
bindMounts.push({ source: hostCoverageDir, target: CONTAINER_COVERAGE_DIR, mode: 'rw' });
}
if (bindMounts.length > 0) {
container = container.withBindMounts(bindMounts);
}
// Reuse stays off for a coverage or mounted-home container: the process
// must exit to flush coverage, and cycle data belongs to the cycle.
if (!coverageHostDir && !userHomeHostDir) {
container = container.withReuse();
}
@ -285,6 +335,10 @@ export async function createN8NInstances(
filesToMount,
coverageHostDir,
registerContainer,
image,
userHomeHostDir,
user,
startupTimeoutMs,
} = options;
const log = createElapsedLogger('n8n-instances');
@ -300,6 +354,10 @@ export async function createN8NInstances(
filesToMount,
coverageHostDir,
registerContainer,
image,
userHomeHostDir,
user,
startupTimeoutMs,
};
const workerShared: SharedConfig = {
@ -310,6 +368,9 @@ export async function createN8NInstances(
filesToMount,
coverageHostDir,
registerContainer,
image,
user,
startupTimeoutMs,
};
const webhookShared: SharedConfig = {
@ -319,6 +380,9 @@ export async function createN8NInstances(
resourceQuota: webhookResourceQuota ?? resourceQuota,
filesToMount,
registerContainer,
image,
user,
startupTimeoutMs,
};
const sharedByRole: Record<InstanceRole, SharedConfig> = {

View File

@ -100,6 +100,23 @@ export interface StackConfig {
* Opt-in capability for the coverage pipeline; off by default.
*/
coverageHostDir?: string;
/**
* Override the n8n image for this stack (default: the process-wide
* TEST_IMAGE_N8N resolution). `stack.replaceN8N()` can then swap to a
* different image on the same data — the upgrade/downgrade cycles.
*/
image?: string;
/**
* Host dir bind-mounted as the n8n container's home (`/home/node`), so the
* user folder (settings file, sqlite database) outlives the container and
* `replaceN8N()` can boot another image on the same data. Single-main
* stacks only. Pair with `user` so the files stay owned by the host user.
*/
userHomeHostDir?: string;
/** Run the n8n containers as this uid:gid (e.g. the host user for bind mounts). */
user?: string;
/** n8n readiness timeout override; an old release migrating a fresh DB can exceed the default. */
startupTimeoutMs?: number;
}
export interface Service<TResult extends ServiceResult = ServiceResult> {

View File

@ -36,6 +36,13 @@ const SERVICE_REGISTRY: Record<ServiceName, Service> = services;
export type N8NConfig = StackConfig;
export interface ReplaceN8NOptions {
/** The image the replacement main boots. */
image: string;
/** Merged over the stack's original user env (e.g. a feature flag flip). */
env?: Record<string, string>;
}
export interface N8NStack {
attemptId: string;
baseUrl: string;
@ -53,6 +60,15 @@ export interface N8NStack {
metrics: ServiceHelpers['observability']['metrics'];
findContainers: (namePattern: string | RegExp) => StartedTestContainer[];
stopContainer: (namePattern: string | RegExp) => Promise<StoppedTestContainer | null>;
/**
* Stops the current n8n main and boots a replacement with the requested
* image, keeping the service containers, network, host port, and
* readiness/logging/cleanup behavior. Data survives the swap when it lives
* outside the replaced container: in the postgres service, or in a
* `userHomeHostDir` mount (the sqlite file and user folder). Single-main
* stacks only — the substrate of the upgrade/downgrade cycles.
*/
replaceN8N: (options: ReplaceN8NOptions) => Promise<void>;
/** Direct URLs to each main instance (bypasses load balancer). Index 0 = main-1, etc. */
mainUrls: string[];
/**
@ -112,8 +128,16 @@ export async function createN8NStack(config: N8NConfig = {}): Promise<N8NStack>
external = false,
networkName,
coverageHostDir,
image,
userHomeHostDir,
user,
startupTimeoutMs,
} = config;
if (userHomeHostDir && (mains > 1 || workers > 0 || webhooks > 0)) {
throw new Error('userHomeHostDir supports single-main stacks only (one shared home)');
}
const log = createElapsedLogger('stack');
const isQueueMode = mains > 1 || workers > 0 || webhooks > 0;
@ -308,6 +332,10 @@ export async function createN8NStack(config: N8NConfig = {}): Promise<N8NStack>
resources.trackContainer(container);
if (!containers.includes(container)) containers.push(container);
},
image,
userHomeHostDir,
user,
startupTimeoutMs,
});
telemetry.finishStage();
} catch (error) {
@ -481,6 +509,44 @@ export async function createN8NStack(config: N8NConfig = {}): Promise<N8NStack>
const container = containers.find((c) => regex.test(c.getName()));
return container ? await container.stop() : null;
},
async replaceN8N(options: ReplaceN8NOptions): Promise<void> {
if (mains > 1 || needsLoadBalancer) {
throw new Error('replaceN8N supports single-main stacks only');
}
const current = containers.find((c) => c.getName().endsWith('-n8n'));
if (current) {
await current.stop();
containers.splice(containers.indexOf(current), 1);
}
const endAcquisition = resources.beginAcquisition();
try {
await createN8NInstances({
mains: 1,
workers: 0,
projectName: uniqueProjectName,
network,
serviceEnvironment: environment,
userEnvironment: { ...env, ...options.env },
usePostgres,
baseUrl,
// The same host port keeps `baseUrl` valid across the swap.
allocatedPort: allocatedMainPort,
resourceQuota,
filesToMount,
coverageHostDir,
registerContainer: (container) => {
resources.trackContainer(container);
if (!containers.includes(container)) containers.push(container);
},
image: options.image,
userHomeHostDir,
user,
startupTimeoutMs,
});
} finally {
endAcquisition();
}
},
mainUrls,
internalMainUrls,
startupDiagnostics: n8nResult.diagnostics,

View File

@ -16,6 +16,8 @@
"bench:canvas:sentinels": "node scripts/canvas-perf-sentinels.mjs test-results.json",
"bench:canvas:report": "playwright show-report",
"test:infrastructure": "playwright test --project='*:infrastructure'",
"test:encryption:upgrade": "playwright test --project='encryption:infrastructure' tests/infrastructure/encryption/upgrade-cycle.spec.ts",
"test:encryption:rotation": "playwright test --project='encryption:infrastructure' tests/infrastructure/encryption/rotation-cycle.spec.ts",
"test:benchmark": "playwright test --project='benchmarking:infrastructure'",
"benchmark:sizing-matrix": "tsx scripts/sizing-matrix-aggregate.ts",
"test:container:sqlite": "playwright test --project='sqlite:*'",

View File

@ -263,6 +263,20 @@ export function getProjects(): Project[] {
use: { containerConfig: BENCHMARKING_DEFAULT_CONFIG },
});
// API-only, no browser: the specs manage their own stack (they swap n8n
// images mid-test via stack.replaceN8N), so no containerConfig fixture.
projects.push({
name: 'encryption:infrastructure',
testDir: './tests/infrastructure/encryption',
workers: 1,
// One upgrade cycle boots four instances and pulls the old release.
timeout: 900_000,
retries: 0,
// No browser runs here — the global trace/video/screenshot capture
// only produces empty artifacts for these tests.
use: { trace: 'off', video: 'off', screenshot: 'off' },
});
for (const { name, config } of LOCAL_ONLY_BENCHMARK_PROFILES) {
projects.push({
name: `benchmark-${name}:infrastructure`,

View File

@ -0,0 +1,123 @@
# Encryption-cycle tests
Repeatable end-to-end tests for the encryption-key rollout: an API-only
Playwright suite (project `encryption:infrastructure`, no browser) on the
`n8n-containers` Testcontainers stack. Both modes run on both supported
databases: sqlite and postgres.
```bash
pnpm build:docker # builds n8nio/n8n:local — the image under test
pnpm test:encryption:upgrade # full upgrade cycle (old release + the local image)
pnpm test:encryption:rotation # standalone rotation test (the local image only)
```
## Layout
| File | Responsibility |
|---|---|
| `upgrade-cycle.spec.ts` / `rotation-cycle.spec.ts` | one test per backend; phases render as `test.step()` |
| `spec-helpers.ts` | env parameters, docker/image skip gate, the loud FAIL path, attachments |
| `harness.ts` | phase/step logging, metrics + summary, failure type |
| `instances.ts` | stack boot and per-phase image swaps; log capture |
| `api.ts` | REST client and the encryption assertions (`assertDecrypts`, `rotateKey`, …) |
| `db.ts` | raw DB access: `node:sqlite` on the mounted file / `psql` in the postgres container |
| `journeys.ts` | seed + per-boot checks: an active scheduled workflow bound to the credential |
| `cycles.ts` | the two scenarios: `runUpgradeCycle` (P1–P4), `runRotationCycle` (R1–R3) |
The stack capabilities this test leans on: `createN8NStack` accepts an
`image`, a `userHomeHostDir` bind mount (the user folder — settings file and
sqlite database — outlives the container), a `user` override so the mounted
files stay host-owned, and a `startupTimeoutMs` for old-release migrations;
`stack.replaceN8N({ image, env })` then swaps the running n8n main for a
different image while the database service, network, user folder, and host
port stay alive. One stack lives through every phase; only the image changes.
## Upgrade mode (`MODE=upgrade`, the default)
Proves that an upgrade keeps existing data readable, that a rollback stays
safe while the rotation flag is off, and that flipping the flag switches new
writes to the new `keyId:`-prefixed format without breaking anything written
before.
| Phase | Image | Checks |
|---|---|---|
| P1 seed | `FROM_IMAGE` (pinned `n8nio/n8n:2.37.10`; bump deliberately) | owner + credential created, decrypt round-trip, scheduled workflow seeded and executing |
| P2 upgrade | `TO_IMAGE`, rotation flag **off** | old credential decrypts; a new write is byte-compatible legacy format (`U2FsdGVkX1…`); key store seeded with exactly 2 rows; the newer release re-activates and executes the workflow the old release wrote |
| P3 downgrade | `FROM_IMAGE` again, same data | the value written by the **newer** instance decrypts on the **older** one; the old release still activates and executes on the migrated-forward database |
| P4 write-on | `TO_IMAGE`, flag **on** | mixed data decrypts; new write is `<activeKeyId>:…`; rotation via `POST /rest/encryption/keys` moves the write key immediately; all four data generations decrypt |
## Rotation mode (`MODE=rotation`)
The standalone test of DB-stored key rotation. Boots only the image under
test on a fresh database with the flag on.
| Phase | Checks |
|---|---|
| R1 seed | fresh boot seeds exactly 2 `deployment_key` rows; a write is prefixed with the active key id |
| R2 rotate | two rotations via `POST /rest/encryption/keys`; a write after each one uses the newest key id; 4 key rows kept, exactly 1 active |
| R3 restart | a fresh process on the same data: keys reload from the database, a new write keeps the active key id, every generation decrypts |
## Data journeys
Each cycle seeds and keeps re-checking on every boot:
- **A static credential** (`httpHeaderAuth`) — the encrypted payload. The
unique secret lives in the non-password `name` field, so the REST
round-trip (`GET /rest/credentials/:id?includeData=true`) proves the
stored blob decrypts; the raw column is also byte-asserted
(legacy prefix / `keyId:` prefix).
- **An active scheduled workflow** (Schedule Trigger → HTTP Request bound to
the credential, against the instance's own `/healthz`). Every boot must
re-activate it and produce a fresh successful execution — that decrypts
the credential inside the execution engine, so a read-path regression
fails the run even when the credentials endpoint still works.
Not covered here (license-gated on the published images, and the e2e feature
stub does not ship in the docker dist — tracked separately): variables, SSO
settings (SAML/OIDC secrets in `settings.value`), and end-user (dynamic)
credentials.
## Metrics
Every run prints a summary table per backend: per-phase duration, number of
decrypt round-trips, and their average/max end-to-end latency. Runs that
rotate also print a `rotate api:` line with the count and average/max latency
of the rotation call itself, and every run prints a `scheduled executions:`
line — how many boots produced a fresh successful execution and how long the
wait was (activation + schedule fire + engine decrypt, end to end). Raw
samples land in `<work root>/<backend>/metrics.csv`.
## Failure behavior
Any failed check throws and fails the Playwright test: a loud block prints
the `FAIL` line with the expected/actual details, the current container's
docker log is appended to `<backend>/n8n.log`, the last 40 log lines print
inline, and the stack (n8n, database, network) is torn down. The log and
`metrics.csv` are attached to the test. In CI the job summary shows
**Result: FAIL** with the last 80 run-log lines, the artifacts still upload,
and a failed scheduled run notifies Slack. Without docker (or without the
locally built image) the tests skip — except in the encryption CI workflow,
where `ENCRYPTION_CYCLE_REQUIRED=true` turns that into a failure.
## Parameters
| Env | Default | Meaning |
|---|---|---|
| `MODE` | `upgrade` | `upgrade` (full cycle) or `rotation` (standalone rotation test) |
| `DB` | `both` | database backends to run: `sqlite`, `postgres`, or `both` |
| `FROM_IMAGE` | `n8nio/n8n:2.37.10` (pinned) | the "old" release to seed on and downgrade to (upgrade mode only) |
| `TO_IMAGE` | `n8nio/n8n:local` | the image under test (`pnpm build:docker` output) |
| `TEST_IMAGE_POSTGRES` | stack default | the postgres container image |
| `WORK_ROOT` | `mktemp -d` | where the home dirs, logs, and metrics land |
## CI
`.github/workflows/encryption-upgrade-test.yml` ("Test: Encryption Rollout")
runs the upgrade cycle on a twice-daily cron and notifies Slack when a
scheduled run fails. A manual `workflow_dispatch` run has a `test` select
(`upgrade` | `rotation`) plus `db` and `from-image` inputs. A PR that touches
the tests or the stack pieces they lean on runs the full matrix — both modes
x both databases, four parallel jobs — with the workflow file taken from the
PR branch. The run summary
carries the result and the metrics; the run log plus `metrics.csv` and
`n8n.log` are uploaded as an artifact per job (mode x database).

View File

@ -0,0 +1,162 @@
import type { CycleContext } from './harness';
import { fail, metric, ok, step } from './harness';
const OWNER_EMAIL = 'owner@example.com';
const OWNER_PASSWORD = 'SuperSecret123';
interface RestResponse {
code: number;
ms: number;
body: unknown;
text: string;
}
/**
* Minimal REST client against one running instance. Keeps its own cookie so a
* login survives only as long as the instance it came from — every boot logs
* in again.
*/
export class RestClient {
private cookie = '';
constructor(
private readonly ctx: CycleContext,
readonly baseUrl: string,
) {}
async request(method: string, path: string, payload?: unknown): Promise<RestResponse> {
const headers: Record<string, string> = { 'browser-id': 'encryption-cycle' };
if (this.cookie) headers.cookie = this.cookie;
if (payload !== undefined) headers['content-type'] = 'application/json';
const started = performance.now();
const res = await fetch(`${this.baseUrl}${path}`, {
method,
headers,
body: payload === undefined ? undefined : JSON.stringify(payload),
});
const text = await res.text();
const ms = performance.now() - started;
const setCookie = res.headers.get('set-cookie');
if (setCookie) this.cookie = setCookie.split(';')[0];
let body: unknown = null;
try {
body = JSON.parse(text);
} catch {
// non-JSON bodies stay available via `text`
}
return { code: res.status, ms, body, text };
}
private dataOf(res: RestResponse): Record<string, unknown> {
return ((res.body as Record<string, unknown>)?.data ?? {}) as Record<string, unknown>;
}
async createOwner(): Promise<void> {
step(this.ctx, 'creating the owner account');
const res = await this.request('POST', '/rest/owner/setup', {
email: OWNER_EMAIL,
firstName: 'Spec',
lastName: 'Owner',
password: OWNER_PASSWORD,
});
if (res.code !== 200) fail('owner setup failed', `${res.code}: ${res.text.slice(0, 300)}`);
ok(this.ctx, 'owner created');
}
/** The login DTO field name differs across releases; try both. */
async login(): Promise<void> {
step(this.ctx, `logging in as ${OWNER_EMAIL}`);
this.cookie = '';
let res = await this.request('POST', '/rest/login', {
emailOrLdapLoginId: OWNER_EMAIL,
password: OWNER_PASSWORD,
});
if (res.code !== 200) {
res = await this.request('POST', '/rest/login', {
email: OWNER_EMAIL,
password: OWNER_PASSWORD,
});
}
if (res.code !== 200) fail('login failed', `${res.code}: ${res.text.slice(0, 300)}`);
ok(this.ctx, 'logged in');
}
async createCredential(name: string, secret: string): Promise<string> {
const res = await this.request('POST', '/rest/credentials', {
name,
type: 'httpHeaderAuth',
data: { name: secret, value: 'spec-header-password' },
});
if (res.code !== 200) {
fail(`create credential "${name}" failed`, `${res.code}: ${res.text.slice(0, 300)}`);
}
return String(this.dataOf(res).id);
}
/**
* The unique secret lives in the NON-password `name` field: the API returns
* it decrypted verbatim, while both fields sit in one encrypted blob — so
* the round-trip proves the stored value decrypts. Records decrypt_ms.
*/
async assertDecrypts(credId: string, expected: string, label: string): Promise<void> {
step(this.ctx, `checking decrypt of credential ${credId} (${label})`);
const res = await this.request('GET', `/rest/credentials/${credId}?includeData=true`);
if (res.code !== 200) {
fail(`${label}: GET credential ${credId} returned ${res.code}`, res.text.slice(0, 300));
}
const data = this.dataOf(res).data as Record<string, unknown> | undefined;
const got = data?.name;
if (got !== expected) {
fail(
`${label}: credential ${credId} did not decrypt to the seeded secret`,
`expected: ${expected}\nactual: ${String(got)}`,
);
}
metric(this.ctx, 'decrypt_ms', this.ctx.phase, Math.round(res.ms * 10) / 10);
ok(
this.ctx,
`credential ${credId} decrypts to the seeded secret (${(res.ms / 1000).toFixed(3)}s)`,
);
}
/** Rotates the data-encryption key; returns the new key id. Records rotate_ms. */
async rotateKey(oldKeyId: string): Promise<string> {
const res = await this.request('POST', '/rest/encryption/keys', { type: 'data_encryption' });
if (res.code !== 200)
fail('key rotation via API failed', `${res.code}: ${res.text.slice(0, 300)}`);
metric(this.ctx, 'rotate_ms', this.ctx.phase, Math.round(res.ms * 10) / 10);
const rawId = this.dataOf(res).id;
const newId = typeof rawId === 'string' ? rawId : '';
if (!newId || newId === oldKeyId) {
fail('rotation did not produce a new key id', `old: ${oldKeyId}, new: ${newId}`);
}
return newId;
}
async createWorkflow(
payload: Record<string, unknown>,
): Promise<{ id: string; versionId: string }> {
const res = await this.request('POST', '/rest/workflows', payload);
if (res.code !== 200) fail('create workflow failed', `${res.code}: ${res.text.slice(0, 300)}`);
const data = this.dataOf(res);
return { id: String(data.id), versionId: String(data.versionId) };
}
/** Activation endpoint differs across releases; try the route, then the update fallback. */
async activateWorkflow(id: string, versionId: string): Promise<void> {
let res = await this.request('POST', `/rest/workflows/${id}/activate`, { versionId });
if (res.code === 404 || res.code === 405) {
res = await this.request('PATCH', `/rest/workflows/${id}`, { active: true, versionId });
}
if (res.code !== 200) {
fail(`activate workflow ${id} failed`, `${res.code}: ${res.text.slice(0, 300)}`);
}
}
async getWorkflowActive(id: string): Promise<boolean> {
const res = await this.request('GET', `/rest/workflows/${id}`);
if (res.code !== 200)
fail(`GET workflow ${id} failed`, `${res.code}: ${res.text.slice(0, 300)}`);
return Boolean(this.dataOf(res).active);
}
}

View File

@ -0,0 +1,235 @@
import type { RestClient } from './api';
import type { CycleContext } from './harness';
import { fail, ok, passLine, runPhase, step, summary } from './harness';
import { bootStack, finishCycle, readInstanceVersion, swapInstance } from './instances';
import type { SeededJourneys } from './journeys';
import {
assertJourneys,
assertKeyRowCount,
assertLegacyFormat,
assertPrefixed,
getActiveKeyId,
seedJourneys,
} from './journeys';
export interface CycleImages {
/** The old release the upgrade cycle seeds on and downgrades to. */
from: string;
/** The build under test. */
to: string;
}
const secret = (tag: string) => `enc-${tag}-${Date.now()}-${Math.floor(Math.random() * 1e6)}`;
/**
* MODE=upgrade — P1 seed on the old release, P2 upgrade with the rotation
* flag off (byte-compatible writes), P3 downgrade-read on the old release,
* P4 write-on + rotate. One stack lives through all four phases; only the
* n8n image is swapped (`stack.replaceN8N`), so the database, network, user
* folder, and host port stay.
*/
export async function runUpgradeCycle(ctx: CycleContext, images: CycleImages): Promise<void> {
const SECRET_A = secret('up-A');
const SECRET_B = secret('up-B');
const SECRET_C = secret('up-C');
const SECRET_D = secret('up-D');
let api: RestClient;
let fromVersion = '?';
let credA = '';
let credB = '';
let seeded: SeededJourneys;
await runPhase(ctx, 'P1 seed', async () => {
step(ctx, `seeding on the old release (${images.from})`);
api = await bootStack(ctx, {
image: images.from,
rotationFlag: false,
label: 'the old release',
});
fromVersion = await readInstanceVersion(ctx);
step(ctx, `FROM version: ${fromVersion}`);
await api.createOwner();
step(ctx, 'creating credential A (the seed data)');
credA = await api.createCredential('encryption-cycle cred A (seeded on FROM)', SECRET_A);
ok(ctx, `credential A = ${credA}`);
await api.assertDecrypts(credA, SECRET_A, 'baseline on FROM');
seeded = await seedJourneys(ctx, api, credA, 'encryption-cycle cred A (seeded on FROM)');
await assertJourneys(ctx, api, seeded, 'baseline on FROM');
});
await runPhase(ctx, 'P2 upgrade', async () => {
api = await swapInstance(ctx, {
image: images.to,
rotationFlag: false,
label: 'the build under test',
});
await api.login();
await api.assertDecrypts(credA, SECRET_A, 'old data survives the upgrade');
step(ctx, 'creating credential B on the upgraded instance');
credB = await api.createCredential(
'encryption-cycle cred B (written on TO, flag off)',
SECRET_B,
);
ok(ctx, `credential B = ${credB}`);
step(ctx, 'checking B is stored in the LEGACY format (flag off => byte-compatible writes)');
await assertLegacyFormat(ctx, credB, 'flag-off write');
await api.assertDecrypts(credB, SECRET_B, 'fresh write on TO');
step(ctx, 'checking the key store got seeded (exactly 2 deployment_key rows)');
await assertKeyRowCount(ctx, 2);
await assertJourneys(ctx, api, seeded, 'after the upgrade');
});
await runPhase(ctx, 'P3 downgrade', async () => {
api = await swapInstance(ctx, {
image: images.from,
rotationFlag: false,
label: 'the old release (downgrade)',
});
await api.login();
await api.assertDecrypts(
credB,
SECRET_B,
'value written by the NEWER instance reads on the OLDER one',
);
await api.assertDecrypts(credA, SECRET_A, 'original data still fine on FROM');
await assertJourneys(ctx, api, seeded, 'after the downgrade');
});
await runPhase(ctx, 'P4 write-on', async () => {
api = await swapInstance(ctx, {
image: images.to,
rotationFlag: true,
label: 'the build under test (flag on)',
});
await api.login();
await api.assertDecrypts(credA, SECRET_A, 'mixed: seeded on FROM');
await api.assertDecrypts(credB, SECRET_B, 'mixed: legacy written on TO');
step(ctx, 'reading the active data-encryption key id');
const activeKeyId = await getActiveKeyId(ctx);
if (!activeKeyId) fail('no active aes-256-gcm deployment_key row');
ok(ctx, `active key id: ${activeKeyId}`);
step(ctx, 'creating credential C (must be keyId-prefixed)');
const credC = await api.createCredential('encryption-cycle cred C (flag on)', SECRET_C);
await assertPrefixed(ctx, credC, activeKeyId, 'flag-on write');
await api.assertDecrypts(credC, SECRET_C, 'prefixed write');
step(ctx, 'rotating the key via POST /rest/encryption/keys');
const newKeyId = await api.rotateKey(activeKeyId);
ok(ctx, `rotated: ${activeKeyId} -> ${newKeyId}`);
step(ctx, 'creating credential D (must use the NEW key id)');
const credD = await api.createCredential('encryption-cycle cred D (after rotate)', SECRET_D);
await assertPrefixed(ctx, credD, newKeyId, 'write after rotation');
step(ctx, 'final sweep: all four generations must decrypt');
for (const [id, expected] of [
[credA, SECRET_A],
[credB, SECRET_B],
[credC, SECRET_C],
[credD, SECRET_D],
] as const) {
await api.assertDecrypts(id, expected, 'all generations');
}
await assertJourneys(ctx, api, seeded, 'write-on');
});
await finishCycle(ctx);
summary(ctx);
passLine(
`PASS [${ctx.backend}]: seed(${fromVersion}) -> upgrade(read) -> downgrade-read -> write-on+rotate, all decrypts OK`,
);
}
/**
* MODE=rotation — the standalone test of DB-stored key rotation: the build
* under test only, fresh database, flag ON. R1 seed, R2 rotate twice with a
* write after each, R3 restart-read (a fresh process must reload the keys
* from the database).
*/
export async function runRotationCycle(ctx: CycleContext, images: CycleImages): Promise<void> {
const SECRET_A = secret('rot-A');
const SECRET_B = secret('rot-B');
const SECRET_C = secret('rot-C');
const SECRET_D = secret('rot-D');
let api: RestClient;
let credA = '';
let credB = '';
let credC = '';
let key1 = '';
let key3 = '';
let seeded: SeededJourneys;
await runPhase(ctx, 'R1 seed', async () => {
step(ctx, 'booting the build under test on a fresh database, rotation flag ON');
api = await bootStack(ctx, {
image: images.to,
rotationFlag: true,
label: 'the build under test',
});
await api.createOwner();
step(ctx, 'checking the key store got seeded (exactly 2 deployment_key rows)');
await assertKeyRowCount(ctx, 2);
key1 = await getActiveKeyId(ctx);
if (!key1) fail('no active aes-256-gcm deployment_key row');
ok(ctx, `active key id: ${key1}`);
step(ctx, 'creating credential A (must be keyId-prefixed)');
credA = await api.createCredential('rotation-cycle cred A (initial key)', SECRET_A);
await assertPrefixed(ctx, credA, key1, 'first write');
await api.assertDecrypts(credA, SECRET_A, 'first write');
seeded = await seedJourneys(ctx, api, credA, 'rotation-cycle cred A (initial key)');
await assertJourneys(ctx, api, seeded, 'seed');
});
await runPhase(ctx, 'R2 rotate', async () => {
step(ctx, 'first rotation via POST /rest/encryption/keys');
const key2 = await api.rotateKey(key1);
ok(ctx, `rotated: ${key1} -> ${key2}`);
step(ctx, 'creating credential B (must use the NEW key id)');
credB = await api.createCredential('rotation-cycle cred B (after 1st rotate)', SECRET_B);
await assertPrefixed(ctx, credB, key2, 'write after the 1st rotation');
await api.assertDecrypts(credB, SECRET_B, 'write after the 1st rotation');
step(ctx, 'second rotation via POST /rest/encryption/keys');
key3 = await api.rotateKey(key2);
ok(ctx, `rotated: ${key2} -> ${key3}`);
step(ctx, 'creating credential C (must use the NEWEST key id)');
credC = await api.createCredential('rotation-cycle cred C (after 2nd rotate)', SECRET_C);
await assertPrefixed(ctx, credC, key3, 'write after the 2nd rotation');
await api.assertDecrypts(credC, SECRET_C, 'write after the 2nd rotation');
step(ctx, 'checking the key store keeps every generation (4 rows, exactly 1 active)');
await assertKeyRowCount(ctx, 4);
const activeRows = await getActiveKeyId(ctx);
if (!activeRows || activeRows.includes('\n')) {
fail('expected exactly 1 active key', `active rows:\n${activeRows}`);
}
ok(ctx, '4 key rows kept, exactly 1 active');
});
await runPhase(ctx, 'R3 restart', async () => {
step(ctx, 'restarting the instance (keys must reload from the database)');
api = await swapInstance(ctx, {
image: images.to,
rotationFlag: true,
label: 'the build under test (restarted)',
});
await api.login();
step(
ctx,
'creating credential D (the restarted instance must keep writing with the active key)',
);
const credD = await api.createCredential('rotation-cycle cred D (after restart)', SECRET_D);
await assertPrefixed(ctx, credD, key3, 'write after the restart');
step(ctx, 'final sweep: every generation must decrypt after the restart');
for (const [id, expected] of [
[credA, SECRET_A],
[credB, SECRET_B],
[credC, SECRET_C],
[credD, SECRET_D],
] as const) {
await api.assertDecrypts(id, expected, 'all generations');
}
await assertJourneys(ctx, api, seeded, 'after the restart');
});
await finishCycle(ctx);
summary(ctx);
passLine(`PASS [${ctx.backend}]: seed(flag on) -> rotate x2 -> restart-read, all decrypts OK`);
}

View File

@ -0,0 +1,53 @@
import { join } from 'node:path';
import { DatabaseSync } from 'node:sqlite';
import type { CycleContext } from './harness';
import { fail } from './harness';
/**
* Raw database access for the assertions. sqlite reads the database file on
* the host (the container home is bind-mounted and host-owned); postgres runs
* psql inside the postgres container. Both return pipe-separated rows, so the
* call sites stay backend-agnostic.
*/
export async function dbQuery(ctx: CycleContext, sql: string): Promise<string> {
if (ctx.backend === 'postgres') {
const pg = ctx.stack?.serviceResults.postgres?.container;
if (!pg) return fail('postgres container is not running');
const result = await pg.exec([
'psql',
'-U',
'n8n_user',
'-d',
'n8n_db',
'-tA',
'-F|',
'-c',
sql,
]);
if (result.exitCode !== 0) {
return fail(`psql failed: ${sql}`, result.output);
}
return result.output.trim();
}
// The running container writes this file concurrently (WAL mode), so a
// read can hit SQLITE_BUSY: give sqlite a busy timeout and retry the
// whole open+read a few times before failing loudly.
const dbPath = join(ctx.homeDir, '.n8n', 'database.sqlite');
let lastError: unknown;
for (let attempt = 0; attempt < 5; attempt++) {
if (attempt > 0) await new Promise((resolve) => setTimeout(resolve, 300));
let db: DatabaseSync | undefined;
try {
db = new DatabaseSync(dbPath, { readOnly: true, timeout: 5000 });
const rows = db.prepare(sql).all() as Array<Record<string, unknown>>;
return rows.map((r) => Object.values(r).join('|')).join('\n');
} catch (error) {
lastError = error;
} finally {
db?.close();
}
}
return fail(`sqlite query failed after retries: ${sql}`, String(lastError));
}

View File

@ -0,0 +1,151 @@
import { test } from '@playwright/test';
import { appendFileSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import type { N8NStack } from 'n8n-containers/stack';
/** One backend run's mutable state: paths, metrics, and the current phase. */
export interface CycleContext {
mode: 'upgrade' | 'rotation';
backend: 'sqlite' | 'postgres';
homeDir: string;
logFile: string;
metricsFile: string;
/** The backend's stack, set by bootStack() and cleared by finishCycle(). */
stack?: N8NStack;
phase: string;
phaseStartedMs: number;
}
export function createCycleContext(
mode: 'upgrade' | 'rotation',
backend: 'sqlite' | 'postgres',
workRoot: string,
): CycleContext {
const backendDir = join(workRoot, backend);
const ctx: CycleContext = {
mode,
backend,
homeDir: join(backendDir, 'home'),
logFile: join(backendDir, 'n8n.log'),
metricsFile: join(backendDir, 'metrics.csv'),
phase: 'setup',
phaseStartedMs: Date.now(),
};
// A reused WORK_ROOT must start clean: a leftover home dir would boot the
// seed phase on the previous run's database.
rmSync(backendDir, { recursive: true, force: true });
mkdirSync(ctx.homeDir, { recursive: true });
writeFileSync(ctx.metricsFile, '');
writeFileSync(ctx.logFile, '');
return ctx;
}
const ts = () => new Date().toTimeString().slice(0, 8);
export function metric(ctx: CycleContext, kind: string, label: string, value: number): void {
appendFileSync(ctx.metricsFile, `${kind},${label},${value}\n`);
}
/** Rolls the phase over for metrics/logging and runs the body as a test step. */
export async function runPhase(
ctx: CycleContext,
name: string,
body: () => Promise<void>,
): Promise<void> {
metric(ctx, 'phase_s', ctx.phase, Math.round((Date.now() - ctx.phaseStartedMs) / 1000));
ctx.phase = name;
ctx.phaseStartedMs = Date.now();
console.log('');
console.log(`[${ts()}] ================ [${ctx.backend}] ${name} ================`);
await test.step(name, body);
}
export function step(ctx: CycleContext, msg: string): void {
console.log(`[${ts()}] [${ctx.backend}/${ctx.phase}] ${msg}`);
}
export function ok(ctx: CycleContext, msg: string): void {
console.log(`[${ts()}] [${ctx.backend}/${ctx.phase}] OK: ${msg}`);
}
/** A failed check. run.ts turns it into the loud FAIL output and exit 1. */
export class CycleFailure extends Error {
constructor(
message: string,
readonly details?: string,
) {
super(message);
this.name = 'CycleFailure';
}
}
export function fail(msg: string, details?: string): never {
throw new CycleFailure(msg, details);
}
/** Prints the per-backend metrics block the CI summary parses. */
export function summary(ctx: CycleContext): void {
metric(ctx, 'phase_s', ctx.phase, Math.round((Date.now() - ctx.phaseStartedMs) / 1000));
const phaseSeconds = new Map<string, number>();
const decrypts = new Map<string, number[]>();
const rotates: number[] = [];
const journeyExecs: number[] = [];
const lines = readFileSync(ctx.metricsFile, 'utf8')
.split('\n')
.filter((l) => l.length > 0);
for (const line of lines) {
const [kind, label, value] = line.split(',');
if (kind === 'phase_s' && label !== 'setup') {
phaseSeconds.set(label, (phaseSeconds.get(label) ?? 0) + Number(value));
} else if (kind === 'decrypt_ms') {
const list = decrypts.get(label) ?? [];
list.push(Number(value));
decrypts.set(label, list);
} else if (kind === 'rotate_ms') {
rotates.push(Number(value));
} else if (kind === 'journey_exec_ms') {
journeyExecs.push(Number(value));
}
}
const row = (label: string, secs: number | string, count: string, avg: string, max: string) =>
`${label.padEnd(14)} ${String(secs).padStart(8)}s ${count.padStart(15)} ${avg.padStart(8)} ${max.padStart(8)}`;
console.log('');
console.log(`=== metrics [${ctx.backend}] ===`);
console.log(
`${'phase'.padEnd(14)} ${'duration'.padStart(9)} ${'decrypt checks'.padStart(15)} ${'avg ms'.padStart(8)} ${'max ms'.padStart(8)}`,
);
let totalSecs = 0;
const allMs: number[] = [];
for (const [label, secs] of phaseSeconds) {
totalSecs += secs;
const ms = decrypts.get(label) ?? [];
allMs.push(...ms);
const avg = ms.length ? (ms.reduce((a, b) => a + b, 0) / ms.length).toFixed(1) : '-';
const max = ms.length ? Math.max(...ms).toFixed(1) : '-';
console.log(row(label, secs, String(ms.length), avg, max));
}
const avgAll = allMs.length ? (allMs.reduce((a, b) => a + b, 0) / allMs.length).toFixed(1) : '-';
const maxAll = allMs.length ? Math.max(...allMs).toFixed(1) : '-';
console.log(row('total', totalSecs, String(allMs.length), avgAll, maxAll));
if (rotates.length) {
const avg = (rotates.reduce((a, b) => a + b, 0) / rotates.length).toFixed(1);
console.log(
`rotate api: ${rotates.length} calls, avg ${avg} ms, max ${Math.max(...rotates).toFixed(1)} ms`,
);
}
if (journeyExecs.length) {
const avg = (journeyExecs.reduce((a, b) => a + b, 0) / journeyExecs.length / 1000).toFixed(1);
const max = (Math.max(...journeyExecs) / 1000).toFixed(1);
console.log(
`scheduled executions: ${journeyExecs.length} boots verified, avg ${avg} s, max ${max} s to a fresh success`,
);
}
}
export function passLine(msg: string): void {
console.log('');
console.log(`[${ts()}] ${msg}`);
}

View File

@ -0,0 +1,144 @@
import { appendFileSync, readFileSync } from 'node:fs';
import { RestClient } from './api';
import type { CycleContext } from './harness';
import { fail, ok, step } from './harness';
import { N8NStartupError } from 'n8n-containers/services/n8n';
import { createN8NStack } from 'n8n-containers/stack';
/** Env every phase shares; the rotation flag is the only per-phase change. */
const CYCLE_ENV: Record<string, string> = {
HOME: '/home/node',
N8N_LOG_LEVEL: 'info',
N8N_RUNNERS_ENABLED: 'false',
N8N_RUNNERS_MODE: 'internal',
N8N_VERSION_NOTIFICATIONS_ENABLED: 'false',
N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS: 'false',
};
const flagEnv = (rotationFlag: boolean): Record<string, string> => ({
N8N_ENV_FEAT_ENCRYPTION_KEY_ROTATION: rotationFlag ? 'true' : 'false',
});
const hostUser = () => `${process.getuid?.() ?? 1000}:${process.getgid?.() ?? 1000}`;
export interface PhaseInstance {
image: string;
rotationFlag: boolean;
label: string;
}
/**
* Boots the backend's stack — the database service plus the first n8n image —
* over the context's persistent home dir. The container runs as the host user
* so the mounted files stay host-owned: later phases boot a different image on
* the same data, and the sqlite assertions read the file directly.
*/
export async function bootStack(ctx: CycleContext, options: PhaseInstance): Promise<RestClient> {
const { image, rotationFlag, label } = options;
step(ctx, `starting ${label} (${image}, rotation flag: ${rotationFlag ? 'on' : 'off'})`);
try {
ctx.stack = await createN8NStack({
postgres: ctx.backend === 'postgres',
projectName: `enc-${ctx.mode}-${ctx.backend}-${process.pid}`,
env: { ...CYCLE_ENV, ...flagEnv(rotationFlag) },
image,
userHomeHostDir: ctx.homeDir,
user: hostUser(),
// An old release migrating a fresh database can exceed the default 60s.
startupTimeoutMs: 180_000,
});
} catch (error) {
return bootFailure(ctx, label, error);
}
ok(ctx, `instance is ready (${ctx.stack.baseUrl})`);
return new RestClient(ctx, ctx.stack.baseUrl);
}
/**
* Swaps the running n8n main for the requested image on the live stack
* (`stack.replaceN8N`): the database, network, user folder, and host port
* stay. Collects the outgoing container's logs first.
*/
export async function swapInstance(ctx: CycleContext, options: PhaseInstance): Promise<RestClient> {
const { image, rotationFlag, label } = options;
const stack = requireStack(ctx);
await collectCurrentLogs(ctx);
step(ctx, `swapping to ${label} (${image}, rotation flag: ${rotationFlag ? 'on' : 'off'})`);
try {
await stack.replaceN8N({ image, env: flagEnv(rotationFlag) });
} catch (error) {
return bootFailure(ctx, label, error);
}
ok(ctx, `instance is ready (${stack.baseUrl})`);
return new RestClient(ctx, stack.baseUrl);
}
/** Collects the current main's logs and tears the whole stack down. */
export async function finishCycle(ctx: CycleContext): Promise<void> {
if (!ctx.stack) return;
step(ctx, `stopping the stack (logs in ${ctx.logFile})`);
await collectCurrentLogs(ctx);
await ctx.stack.stop();
ctx.stack = undefined;
}
function requireStack(ctx: CycleContext) {
if (!ctx.stack) return fail('the stack is not running');
return ctx.stack;
}
function bootFailure(ctx: CycleContext, label: string, error: unknown): never {
// Keep the boot log readable in the FAIL output: the startup diagnostics
// land in the backend's n8n.log, the FAIL block prints its tail.
if (error instanceof N8NStartupError) {
for (const logs of Object.values(error.diagnostics.logs)) {
appendFileSync(ctx.logFile, logs);
}
const readiness = Object.values(error.diagnostics.readinessPayloads).at(-1);
fail(`${label} did not become ready`, `last readiness payload: ${readiness ?? '(none)'}`);
}
throw error;
}
/** Appends the running main's docker logs to the backend's n8n.log. */
export async function collectCurrentLogs(ctx: CycleContext): Promise<void> {
const container = ctx.stack?.findContainers(/-n8n$/)[0];
if (!container) return;
try {
const stream = await container.logs();
const chunks: Buffer[] = [];
await new Promise<void>((resolve) => {
stream.on('data', (chunk: Buffer) => chunks.push(Buffer.from(chunk)));
stream.on('end', resolve);
stream.on('error', () => resolve());
// docker follows the log stream; a short settle window is enough for history.
setTimeout(resolve, 2000);
});
appendFileSync(ctx.logFile, Buffer.concat(chunks).toString('utf8'));
} catch {
// logs are diagnostics only; never fail the cycle over them
}
}
export async function readInstanceVersion(ctx: CycleContext): Promise<string> {
const container = ctx.stack?.findContainers(/-n8n$/)[0];
if (!container) return '?';
try {
const result = await container.exec(['n8n', '--version']);
const lines = result.output.trim().split('\n');
return lines[lines.length - 1] ?? '?';
} catch {
return '?';
}
}
/** The last-40-lines tail the FAIL output prints. */
export function logTail(ctx: CycleContext): string {
try {
const lines = readFileSync(ctx.logFile, 'utf8').split('\n');
return lines.slice(-40).join('\n');
} catch {
return '(no log file)';
}
}

View File

@ -0,0 +1,162 @@
import type { RestClient } from './api';
import { dbQuery } from './db';
import type { CycleContext } from './harness';
import { fail, metric, ok, step } from './harness';
/** Ids of the data one cycle seeds and keeps asserting across phases. */
export interface SeededJourneys {
workflowId: string;
}
const LEGACY_PREFIX = 'U2FsdGVkX1';
async function rawCredentialValue(ctx: CycleContext, credId: string): Promise<string> {
return await dbQuery(ctx, `SELECT data FROM credentials_entity WHERE id='${credId}';`);
}
export async function getActiveKeyId(ctx: CycleContext): Promise<string> {
return await dbQuery(
ctx,
"SELECT id FROM deployment_key WHERE type='data_encryption' AND status='active' AND algorithm='aes-256-gcm';",
);
}
/** The raw column must start with "<keyId>:". */
export async function assertPrefixed(
ctx: CycleContext,
credId: string,
keyId: string,
label: string,
): Promise<void> {
const raw = await rawCredentialValue(ctx, credId);
if (!raw.startsWith(`${keyId}:`)) {
fail(
`${label}: value is not prefixed with the expected key id`,
`expected prefix: ${keyId}:\nraw (first 60): ${raw.slice(0, 60)}`,
);
}
ok(ctx, `${label}: value is prefixed with key id ${keyId}`);
}
export async function assertLegacyFormat(
ctx: CycleContext,
credId: string,
label: string,
): Promise<void> {
const raw = await rawCredentialValue(ctx, credId);
if (!raw.startsWith(LEGACY_PREFIX)) {
fail(`${label}: new write is not legacy-format`, `raw (first 60): ${raw.slice(0, 60)}`);
}
ok(ctx, `${label}: stored in legacy format (${LEGACY_PREFIX}...)`);
}
export async function assertKeyRowCount(ctx: CycleContext, expected: number): Promise<void> {
const count = await dbQuery(
ctx,
"SELECT COUNT(*) AS c FROM deployment_key WHERE type='data_encryption';",
);
if (count !== String(expected)) {
fail(`expected exactly ${expected} deployment_key rows, got ${count}`);
}
ok(ctx, `deployment_key has exactly ${expected} rows`);
}
/**
* Seeds one ACTIVE scheduled workflow whose HTTP node uses the seeded
* credential against the instance's own /healthz. Every later boot then
* exercises activation and an execution that decrypts the credential inside
* the engine — a read-path regression fails the run even when the
* credentials GET endpoint still works.
*
* Variables and SSO settings are deliberately absent: on the published
* docker images they are license-gated (the e2e feature stub does not ship
* in the docker dist), so those journeys live behind a real license.
*/
export async function seedJourneys(
ctx: CycleContext,
api: RestClient,
credentialId: string,
credentialName: string,
): Promise<SeededJourneys> {
const stamp = `${Date.now()}-${process.pid}`;
step(ctx, 'seeding an active scheduled workflow bound to the credential');
const { id: workflowId, versionId } = await api.createWorkflow({
name: `encryption-cycle scheduled (${stamp})`,
nodes: [
{
id: 'schedule-1',
name: 'Schedule Trigger',
type: 'n8n-nodes-base.scheduleTrigger',
typeVersion: 1.2,
position: [0, 0],
parameters: { rule: { interval: [{ field: 'seconds', secondsInterval: 2 }] } },
},
{
id: 'http-1',
name: 'Self Healthz',
type: 'n8n-nodes-base.httpRequest',
typeVersion: 4.2,
position: [200, 0],
parameters: {
url: 'http://localhost:5678/healthz',
authentication: 'genericCredentialType',
genericAuthType: 'httpHeaderAuth',
},
credentials: { httpHeaderAuth: { id: credentialId, name: credentialName } },
},
],
connections: {
'Schedule Trigger': { main: [[{ node: 'Self Healthz', type: 'main', index: 0 }]] },
},
settings: { executionOrder: 'v1' },
active: false,
});
await api.activateWorkflow(workflowId, versionId);
ok(ctx, `workflow ${workflowId} created and activated`);
return { workflowId };
}
/**
* Per-boot journey checks: the workflow is still active and a fresh
* execution completes — proving the schedule re-armed on this boot and the
* engine decrypted the credential.
*/
export async function assertJourneys(
ctx: CycleContext,
api: RestClient,
seeded: SeededJourneys,
label: string,
): Promise<void> {
step(ctx, `checking the scheduled workflow is active and executes (${label})`);
const active = await api.getWorkflowActive(seeded.workflowId);
if (!active) fail(`${label}: seeded workflow is no longer active`);
const countSql = `SELECT COUNT(*) AS c FROM execution_entity WHERE "workflowId"='${seeded.workflowId}' AND status='success';`;
const baseline = Number(await dbQuery(ctx, countSql));
const waitStarted = Date.now();
const deadline = waitStarted + 30_000;
let current = baseline;
while (current <= baseline && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 1000));
current = Number(await dbQuery(ctx, countSql));
}
if (current <= baseline) {
const lastStatus = await dbQuery(
ctx,
`SELECT status FROM execution_entity WHERE "workflowId"='${seeded.workflowId}' ORDER BY id DESC LIMIT 1;`,
);
fail(
`${label}: no new successful execution within 30s`,
`successful executions: ${baseline} (unchanged), last status: ${lastStatus || '(none)'}`,
);
}
// Time from "instance is up" polling start until a fresh execution landed:
// activation + schedule fire + engine decrypt, end to end.
metric(ctx, 'journey_exec_ms', ctx.phase, Date.now() - waitStarted);
ok(
ctx,
`a scheduled execution succeeded on this boot (engine decrypted the credential; ${baseline} -> ${current})`,
);
}

View File

@ -0,0 +1,10 @@
// The installed @types/node predates the node:sqlite module (available at
// runtime since Node 22.5; engines require >= 24). Remove when @types/node
// ships the built-in declaration.
declare module 'node:sqlite' {
export class DatabaseSync {
constructor(path: string, options?: { readOnly?: boolean; timeout?: number });
prepare(sql: string): { all(): unknown[] };
close(): void;
}
}

View File

@ -0,0 +1,16 @@
import { test } from '@playwright/test';
import { backends, runBackendCycle } from './spec-helpers';
// API-only suite on the n8n-containers stack — no browser. See README.md.
test.describe('encryption rollout: rotation cycle', () => {
for (const backend of backends()) {
test(
`rotation cycle (${backend})`,
{ annotation: [{ type: 'owner', description: 'Identity & Access' }] },
async () => {
await runBackendCycle('rotation', backend);
},
);
}
});

View File

@ -0,0 +1,108 @@
import { test } from '@playwright/test';
import { execSync } from 'node:child_process';
import { existsSync, mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import type { CycleImages } from './cycles';
import { runRotationCycle, runUpgradeCycle } from './cycles';
import { createCycleContext, CycleFailure } from './harness';
import { collectCurrentLogs, logTail } from './instances';
export type Backend = 'sqlite' | 'postgres';
export type Mode = 'upgrade' | 'rotation';
/** DB=sqlite|postgres|both (default both) picks the per-backend tests. */
export function backends(): Backend[] {
const db = process.env.DB ?? 'both';
if (db === 'sqlite' || db === 'postgres') return [db];
return ['sqlite', 'postgres'];
}
export function cycleImages(): CycleImages {
return {
// Pinned so the suite is repeatable; bump deliberately when the
// compatibility baseline changes.
from: process.env.FROM_IMAGE ?? 'n8nio/n8n:2.37.10',
to: process.env.TO_IMAGE ?? 'n8nio/n8n:local',
};
}
let cachedWorkRoot: string | undefined;
/** One work root per process; the CI artifact globs point at WORK_ROOT. */
export function workRoot(): string {
cachedWorkRoot ??= process.env.WORK_ROOT ?? mkdtempSync(join(tmpdir(), 'encryption-cycle-'));
return cachedWorkRoot;
}
function commandOk(command: string): boolean {
try {
execSync(command, { stdio: 'ignore' });
return true;
} catch {
return false;
}
}
/**
* Skips when the environment cannot run the cycle: no docker, or the default
* locally-built image is absent. The encryption CI workflow builds the image
* and sets ENCRYPTION_CYCLE_REQUIRED=true, so a broken build fails loudly
* there instead of skipping.
*/
function skipUnlessRunnable(images: CycleImages): void {
const required = process.env.ENCRYPTION_CYCLE_REQUIRED === 'true';
const dockerUp = commandOk('docker info');
if (required && !dockerUp) throw new Error('docker is not available');
test.skip(!dockerUp, 'docker is not available');
if (images.to === 'n8nio/n8n:local') {
const imagePresent = commandOk(`docker image inspect ${images.to}`);
if (required && !imagePresent) {
throw new Error(`${images.to} not found; run \`pnpm build:docker\` first`);
}
test.skip(
!imagePresent,
`${images.to} not found — run \`pnpm build:docker\`, or set TO_IMAGE to a published tag`,
);
}
}
/**
* Runs one backend cycle with the loud failure contract: on any error the
* running container's log is collected, the stack is torn down, the log tail
* is printed, and the log/metrics files are attached to the test.
*/
export async function runBackendCycle(mode: Mode, backend: Backend): Promise<void> {
const images = cycleImages();
skipUnlessRunnable(images);
const ctx = createCycleContext(mode, backend, workRoot());
try {
if (mode === 'rotation') {
await runRotationCycle(ctx, images);
} else {
await runUpgradeCycle(ctx, images);
}
} catch (error) {
const message =
error instanceof CycleFailure
? `${error.message}${error.details ? `\n${error.details}` : ''}`
: String(error);
console.log(`[${backend}/${ctx.phase}] FAIL: ${mode}-cycle - ${message}`);
await collectCurrentLogs(ctx);
await ctx.stack?.stop().catch(() => {});
console.log(`--- last 40 log lines (${ctx.logFile}) ---`);
console.log(logTail(ctx));
throw error;
} finally {
for (const [name, path] of [
[`${backend}-n8n.log`, ctx.logFile],
[`${backend}-metrics.csv`, ctx.metricsFile],
] as const) {
if (existsSync(path)) {
await test.info().attach(name, { path, contentType: 'text/plain' });
}
}
}
}

View File

@ -0,0 +1,16 @@
import { test } from '@playwright/test';
import { backends, runBackendCycle } from './spec-helpers';
// API-only suite on the n8n-containers stack — no browser. See README.md.
test.describe('encryption rollout: upgrade cycle', () => {
for (const backend of backends()) {
test(
`upgrade cycle (${backend})`,
{ annotation: [{ type: 'owner', description: 'Identity & Access' }] },
async () => {
await runBackendCycle('upgrade', backend);
},
);
}
});