From 6965869dda5bcbfbab94462009aeea10241f4586 Mon Sep 17 00:00:00 2001 From: jubnl Date: Thu, 24 Sep 2026 07:05:32 +0200 Subject: [PATCH] =?UTF-8?q?test(server):=20plan=204=20=E2=80=94=20at-rest?= =?UTF-8?q?=20encryption=20pins=20for=20AirTrail/doc-sync/webhook=20secret?= =?UTF-8?q?s=20(3h=20L3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../tests/unit/nest/airtrail.service.test.ts | 84 +++++++++++++++++++ .../doc-sync/doc-sync-config.service.test.ts | 35 ++++++++ 2 files changed, 119 insertions(+) create mode 100644 server/tests/unit/nest/airtrail.service.test.ts diff --git a/server/tests/unit/nest/airtrail.service.test.ts b/server/tests/unit/nest/airtrail.service.test.ts new file mode 100644 index 000000000..865f53355 --- /dev/null +++ b/server/tests/unit/nest/airtrail.service.test.ts @@ -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'); + }); +}); diff --git a/server/tests/unit/nest/doc-sync/doc-sync-config.service.test.ts b/server/tests/unit/nest/doc-sync/doc-sync-config.service.test.ts index d1ab9d630..9fc77b874 100644 --- a/server/tests/unit/nest/doc-sync/doc-sync-config.service.test.ts +++ b/server/tests/unit/nest/doc-sync/doc-sync-config.service.test.ts @@ -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', () => {