test(server): plan 4 — at-rest encryption pins for AirTrail/doc-sync/webhook secrets (3h L3)

Task 8b-2 item 4: the three credential columns the 3h Task 7 review flagged
as having no at-rest pin — users.airtrail_api_key (AirtrailService#saveSettings),
document_connections.secrets (DocSyncConfigService's whole-secret-map
column), and trip_document_links.webhook_secret (createLink's per-binding
mint). Each test saves through the real service, reads the raw column via
better-sqlite3, asserts it is enc:v1:-prefixed ciphertext that never
contains the plaintext, and asserts the service's own decrypt path rounds
it back to the original value.
This commit is contained in:
jubnl
2026-09-24 07:05:32 +02:00
parent 07de2d3e5a
commit 6965869dda
2 changed files with 119 additions and 0 deletions
@@ -0,0 +1,84 @@
/**
* `AirtrailService` — 3h L3 carry (`phase3h-reports/task-7-review.md`'s L3:
* "`users.airtrail_api_key` has no at-rest pin"). The credential lives in a
* plain `users` column, not a dedicated secrets table, so this is the
* service-level pin `dawarich.service.test.ts`'s DAWARICH-SVC-050 already
* gives the Dawarich connection: save through the real service, read the raw
* column, assert it is `enc:v1:`-prefixed ciphertext (never the plaintext),
* and assert `getAirtrailCredentials` round-trips it back to the original
* value. The database is real in-memory SQLite (`createSnapshotTestDb()`) —
* a stubbed repository would only prove some string was handed along, and
* "the key is never in the column as typed" is the one thing worth pinning.
* `checkSsrf` and the `AirtrailClient` are the only two fakes: both would
* otherwise leave the process.
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
import { createSnapshotTestDb } from '../../helpers/db-mock';
const { checkSsrf } = vi.hoisted(() => ({ checkSsrf: vi.fn() }));
vi.mock('../../../src/utils/ssrfGuard', () => ({
checkSsrf,
safeFetch: vi.fn(),
}));
import { resetTestDb } from '../../helpers/test-db';
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
import { createUser } from '../../helpers/factories';
import { Users } from '../../../src/db/entities/Users.entity';
import { AuditLog } from '../../../src/db/entities/AuditLog.entity';
import type { UsersRepository } from '../../../src/db/repositories/Users.repository';
import { AuditService } from '../../../src/nest/audit/audit.service';
import { AirtrailService } from '../../../src/nest/integrations/airtrail.service';
import type { AirtrailClient } from '../../../src/nest/integrations/airtrail.client';
const testDb = createSnapshotTestDb();
let t: TestOrm;
let usersRepo: UsersRepository;
let svc: AirtrailService;
const listFlights = vi.fn();
beforeAll(async () => {
t = await createTestOrm(testDb);
usersRepo = t.repo(Users);
svc = new AirtrailService(usersRepo, new AuditService(t.repo(AuditLog), usersRepo), { listFlights } as unknown as AirtrailClient);
});
beforeEach(() => {
resetTestDb(testDb);
t.clear();
checkSsrf.mockReset();
checkSsrf.mockResolvedValue({ allowed: true, isPrivate: false });
});
afterAll(async () => { await t.close(); testDb.close(); });
function rawAirtrailKey(userId: number): string | null {
return (testDb.prepare('SELECT airtrail_api_key FROM users WHERE id = ?').get(userId) as { airtrail_api_key: string | null }).airtrail_api_key;
}
describe('AirtrailService — at-rest encryption (3h L3)', () => {
it('AIRTRAIL-SVC-ENC-001: saveSettings stores the key enc:v1:-prefixed, never as typed, and round-trips through getAirtrailCredentials', async () => {
const { user } = createUser(testDb);
await svc.saveSettings(user.id, 'https://airtrail.example.test', 'super-secret-key', false, false, null);
const stored = rawAirtrailKey(user.id);
expect(stored).toMatch(/^enc:v1:/);
expect(stored).not.toContain('super-secret-key');
const creds = await svc.getAirtrailCredentials(user.id);
expect(creds?.apiKey).toBe('super-secret-key');
});
it('AIRTRAIL-SVC-ENC-002: a second save with a new key re-encrypts to a different ciphertext that still round-trips', async () => {
const { user } = createUser(testDb);
await svc.saveSettings(user.id, 'https://airtrail.example.test', 'first-key', false, false, null);
const first = rawAirtrailKey(user.id);
await svc.saveSettings(user.id, 'https://airtrail.example.test', 'second-key', false, false, null);
const second = rawAirtrailKey(user.id);
expect(second).toMatch(/^enc:v1:/);
expect(second).not.toBe(first);
expect(second).not.toContain('second-key');
expect((await svc.getAirtrailCredentials(user.id))?.apiKey).toBe('second-key');
});
});
@@ -350,6 +350,23 @@ describe('a secret the provider earned itself', () => {
expect((await svc.getConnection(conn.id) as ConnectionRow).secrets).not.toContain('DEVICE-7');
});
// 3h L3 carry (task-7-review.md's L3: "document_connections.secrets ... has
// no `enc:v1:` check"). The whole-secret-map column is one `maybe_encrypt_
// api_key` call (`encryptSecrets`, doc-sync-secrets.ts) over the JSON blob,
// so the at-rest pin is the same shape as `AirtrailService`'s and
// `DawarichService`'s: read the raw column, assert the ciphertext prefix,
// then assert `decryptSecrets` round-trips it.
it('DOCSYNC-ENC-001: the raw document_connections.secrets column is enc:v1:-prefixed ciphertext, never plaintext, and round-trips', async () => {
const conn = await nas();
const raw = (testDb.prepare('SELECT secrets FROM document_connections WHERE id = ?').get(conn.id) as { secrets: string | null }).secrets;
expect(raw).toMatch(/^enc:v1:/);
expect(raw).not.toContain('nas-pw');
expect(raw).not.toContain('123456');
expect(await secretsOf(conn.id)).toEqual({ password: 'nas-pw', otp_code: '123456' });
});
it('never reaches a client, not even as a mask', async () => {
const conn = await nas();
await svc.saveEarnedSecret(conn.id, 'device_token', EARNED);
@@ -651,6 +668,24 @@ describe('createLink', () => {
expect(view.webhookSecret).toBe(DOCSYNC_SECRET_MASK);
expect(JSON.stringify(view)).not.toContain(secret);
});
// 3h L3 carry (task-7-review.md's L3: "trip_document_links.webhook_secret —
// No at-rest pin."). `createLink` mints the secret with the same
// `encryptSecrets` call the connection's own secret map uses (`doc-sync-
// config.service.ts:417`), so the pin is the same shape: raw column is
// `enc:v1:`-prefixed ciphertext, never the plaintext, and `webhookSecret`
// round-trips it.
it('DOCSYNC-ENC-002: the raw trip_document_links.webhook_secret column is enc:v1:-prefixed ciphertext, never plaintext, and round-trips', async () => {
const conn = await connect();
const bound = await link(conn.id);
const raw = (testDb.prepare('SELECT webhook_secret FROM trip_document_links WHERE id = ?').get(bound.id) as { webhook_secret: string | null }).webhook_secret;
expect(raw).toMatch(/^enc:v1:/);
const secret = svc.webhookSecret(bound);
expect(secret).toBeTruthy();
expect(raw).not.toContain(secret);
});
});
describe('publicLink', () => {