tenant-secrets.integration.test.ts91 lines · main
1/**
2 * Per-tenant secret persistence (Phase 3) — control plane `tenant_secrets`.
3 *
4 * Exercises the store/read helper (services/tenant-secrets.ts) end-to-end
5 * against the REAL `tenant_secrets` table that migration 0043 finally created
6 * (it was orphaned — see 0043_tenant_secrets.sql). This backs the Phase-3 auth
7 * product's per-tenant OAuth client-secret storage.
8 *
9 * Proves:
10 * - set → get round-trips the plaintext (encrypt-on-write, decrypt-on-read);
11 * - what actually lands in the column is ciphertext, NOT the plaintext;
12 * - overwrite refreshes the value (UPSERT on the project/service/name triple);
13 * - hasTenantSecret is a presence check; missing reads return null;
14 * - the `service` namespace isolates 'auth' from 'pay' (same name, no bleed).
15 *
16 * Integration test (real control Postgres, no mock.module). Gated on
17 * BRIVEN_DATA_PLANE_URL — the repo's "integration mode is on" signal. The
18 * AUTH/PAY master keys are supplied by the test:integration env; the crypto
19 * primitive reads them from process.env at call time.
20 */
21import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
22import { eq } from 'drizzle-orm';
23
24import { getDb } from '../db/client.js';
25import { organizations, projects, tenantSecrets } from '../db/schema.js';
26import { getTenantSecret, hasTenantSecret, setTenantSecret } from './tenant-secrets.js';
27
28const HAS_DB = Boolean(process.env.BRIVEN_DATA_PLANE_URL);
29const S = Date.now().toString(36);
30const ORG = `o_tsec_${S}`;
31const PROJECT = `p_tsec_${S}`;
32const SECRET = 'gho_super-secret-google-client-secret-value';
33
34describe.skipIf(!HAS_DB)('tenant-secrets persistence — OAuth secret drawer (migration 0043)', () => {
35 beforeAll(async () => {
36 const db = getDb();
37 await db
38 .insert(organizations)
39 .values({ id: ORG, slug: `tsec-${S}`, name: 'tsec', personal: true, createdBy: null });
40 await db
41 .insert(projects)
42 .values({ id: PROJECT, slug: `tsec-${S}`, name: 'tsec', orgId: ORG });
43 });
44
45 afterAll(async () => {
46 const db = getDb();
47 await db.delete(tenantSecrets).where(eq(tenantSecrets.projectId, PROJECT));
48 await db.delete(projects).where(eq(projects.id, PROJECT));
49 await db.delete(organizations).where(eq(organizations.id, ORG));
50 });
51
52 test('set → get round-trips the plaintext', async () => {
53 await setTenantSecret(PROJECT, 'auth', 'google_client_secret', SECRET);
54 expect(await getTenantSecret(PROJECT, 'auth', 'google_client_secret')).toBe(SECRET);
55 });
56
57 test('what is stored in the column is ciphertext, never the plaintext', async () => {
58 const db = getDb();
59 const [row] = await db
60 .select({ enc: tenantSecrets.encryptedValue })
61 .from(tenantSecrets)
62 .where(eq(tenantSecrets.projectId, PROJECT))
63 .limit(1);
64 expect(row).toBeTruthy();
65 expect(row?.enc).not.toBe(SECRET);
66 expect(row?.enc ?? '').not.toContain(SECRET); // plaintext nowhere in the blob
67 });
68
69 test('overwrite refreshes the value (UPSERT on project/service/name)', async () => {
70 const next = 'gho_rotated-secret-value';
71 await setTenantSecret(PROJECT, 'auth', 'google_client_secret', next);
72 expect(await getTenantSecret(PROJECT, 'auth', 'google_client_secret')).toBe(next);
73 });
74
75 test('hasTenantSecret is presence-only; missing reads return null', async () => {
76 expect(await hasTenantSecret(PROJECT, 'auth', 'google_client_secret')).toBe(true);
77 expect(await hasTenantSecret(PROJECT, 'auth', 'never_set')).toBe(false);
78 expect(await getTenantSecret(PROJECT, 'auth', 'never_set')).toBeNull();
79 });
80
81 test("the `service` namespace isolates 'auth' from 'pay'", async () => {
82 // same name under a different service is a different secret, no bleed
83 expect(await getTenantSecret(PROJECT, 'pay', 'google_client_secret')).toBeNull();
84 await setTenantSecret(PROJECT, 'pay', 'google_client_secret', 'pay-side-value');
85 expect(await getTenantSecret(PROJECT, 'pay', 'google_client_secret')).toBe('pay-side-value');
86 // auth side is unchanged by the pay write
87 expect(await getTenantSecret(PROJECT, 'auth', 'google_client_secret')).toBe(
88 'gho_rotated-secret-value',
89 );
90 });
91});