Files
dockhand-upstream/tests/encryption-rotation-coverage.test.ts
T

115 lines
5.1 KiB
TypeScript

/**
* Regression guard for audit 2026-06-26 #6: backup_destinations were excluded
* from ENCRYPTION_KEY rotation/migration in encryption.ts::migrateCredentials.
* After an app-key rotation every OTHER credential table was re-encrypted with
* the new key while backup destination password/envVars stayed on the OLD key,
* so restic got the literal `enc:v1:...` blob as its password and every backup
* broke silently.
*
* migrateCredentials talks to better-sqlite3, which bun test can't load, so we
* can't drive the rotation end-to-end here. Instead we assert — at the source
* level — that the migrator actually references the backupDestinations table in
* BOTH the rotation branch and the plain-text migration branch. This is the
* exact thing the bug got wrong: a hardcoded table list that silently omitted
* one table. If someone drops the coverage again, this fails.
*/
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const here = dirname(fileURLToPath(import.meta.url));
const encryptionSrc = readFileSync(
join(here, '..', 'src', 'lib', 'server', 'encryption.ts'),
'utf8'
);
describe('encryption key rotation covers backup destinations (audit #6)', () => {
it('imports the backupDestinations table into migrateCredentials', () => {
assert.match(
encryptionSrc,
/backupDestinations/,
'migrateCredentials must import/use backupDestinations or key rotation silently breaks backups'
);
});
it('re-encrypts backup destination password and envVars during rotation', () => {
// The rotation branch collects encrypted values into allEncrypted; both
// fields must be pushed so the new key re-encrypts them.
assert.match(encryptionSrc, /table:\s*'backupDestinations',\s*id:\s*dest\.id,\s*field:\s*'password'/);
assert.match(encryptionSrc, /table:\s*'backupDestinations',\s*id:\s*dest\.id,\s*field:\s*'envVars'/);
});
it('writes the re-encrypted backup destination values back on rotation', () => {
// HIGH #7 (2026-07-02) moved the rotation writes into ONE transaction, so
// the write goes through the tx handle rather than db directly.
assert.match(
encryptionSrc,
/item\.table === 'backupDestinations'[\s\S]{0,200}(db|tx)\.update\(backupDestinations\)/,
'rotation must (db|tx).update(backupDestinations) with the re-encrypted value'
);
});
it('migrates legacy plain-text backup destination secrets to encrypted form', () => {
// The plain-text branch must also touch backupDestinations, not just the
// other credential tables.
assert.match(
encryptionSrc,
/dest\.password[\s\S]{0,120}encrypt\(dest\.password\)/,
'plain-text migration must encrypt an unencrypted backup destination password'
);
});
});
// Same bug class for the secret_providers table: its `config` column holds the
// encrypted Vault/Doppler/1Password token. If rotation omits it, the provider
// config stays on the OLD key and becomes permanently undecryptable once the key
// file is deleted, so every provider-bound stack fails to deploy.
describe('encryption key rotation covers secret providers', () => {
it('imports the secretProviders table into migrateCredentials', () => {
assert.match(
encryptionSrc,
/secretProviders/,
'migrateCredentials must import/use secretProviders or key rotation breaks provider-bound stacks'
);
});
it('collects the secret provider config for re-encryption during rotation', () => {
assert.match(encryptionSrc, /table:\s*'secretProviders',\s*id:\s*p\.id,\s*field:\s*'config'/);
});
it('writes the re-encrypted secret provider config back on rotation', () => {
assert.match(
encryptionSrc,
/item\.table === 'secretProviders'[\s\S]{0,200}(db|tx)\.update\(secretProviders\)/,
'rotation must (db|tx).update(secretProviders) with the re-encrypted config'
);
});
});
// The rollback envelope is the reason a mid-rotation crash can't strand
// credentials as an undecryptable old/new-key mix. A true execution test isn't
// possible here (migrateCredentials imports better-sqlite3, which bun test can't
// load, and the HTTP API can't force a mid-transaction failure), so we assert the
// safety contract at the source level: it must survive any future refactor.
describe('encryption key rotation rolls back atomically on failure', () => {
it('re-encrypts every credential inside a single db.transaction', () => {
assert.match(
encryptionSrc,
/db\.transaction\(async \(tx[\s\S]*?for \(const item of allEncrypted\)/,
'the re-encrypt loop must run inside db.transaction so a mid-loop failure rolls back'
);
});
it('captures the old key and restores it when the transaction throws', () => {
assert.match(encryptionSrc, /const oldKey = cachedKey;/, 'must capture the old key before switching');
// The catch block must put the in-memory key back to match the rolled-back DB.
assert.match(
encryptionSrc,
/catch \([\s\S]*?cachedKey = oldKey;[\s\S]*?throw /,
'on failure the catch must restore cachedKey = oldKey AND rethrow so nothing becomes unrecoverable'
);
});
});