mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 13:39:44 +02:00
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:
parent
7fb840ec1d
commit
692a4f69cc
152
.github/workflows/encryption-upgrade-test.yml
vendored
Normal file
152
.github/workflows/encryption-upgrade-test.yml
vendored
Normal 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>"
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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> = {
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:*'",
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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`);
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
|
@ -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}`);
|
||||
}
|
||||
|
|
@ -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)';
|
||||
}
|
||||
}
|
||||
|
|
@ -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})`,
|
||||
);
|
||||
}
|
||||
10
packages/testing/playwright/tests/infrastructure/encryption/node-sqlite.d.ts
vendored
Normal file
10
packages/testing/playwright/tests/infrastructure/encryption/node-sqlite.d.ts
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
|
|
@ -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' });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user