mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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:
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user