auth-tenant-isolation.test.ts189 lines · main
1import { describe, expect, test } from 'bun:test';
2
3import {
4 domainHintFromEmail,
5 nameInitialFrom,
6 redactUserRow,
7} from './auth-users.js';
8
9/**
10 * Tenant-isolation pen-test harness for briven auth (BUILD_PLAN.md §11).
11 *
12 * The acceptance gate is "a single cross-tenant read is a v1 blocker".
13 * Two test surfaces live here:
14 *
15 * 1. **Unit-level redaction invariants** (this file). Pure-function
16 * checks that the redaction path itself can't accidentally leak an
17 * email or name across tenants. Run on every commit — fast, no db.
18 *
19 * 2. **Integration cross-tenant probes** (skipped by default, requires
20 * `BRIVEN_PEN_TEST_TENANT_A_ID` + `BRIVEN_PEN_TEST_TENANT_B_ID` env
21 * + a service token that can hit `/v1/projects/:id/auth/*` as each
22 * tenant's owner). Enable by setting `BRIVEN_PEN_TEST_RUN=1` in CI
23 * after the staging deploy lands. Listed as `test.skipIf(...)` so
24 * they show up in `--listTests` output and ops can spot them.
25 *
26 * Probe coverage maps 1:1 to BUILD_PLAN.md §7 "Tenant-isolation pen test":
27 * a. forged x-briven-project-id header
28 * b. forged session token from the other tenant
29 * c. forged OAuth state parameter
30 * d. forged WebAuthn credential id
31 * e. cache-key collisions against the instance pool
32 * f. direct postgres connection with a different search_path
33 *
34 * Each probe runs as tenant A trying to reach tenant B data; failure to
35 * 401/403/404 on any single probe is a release blocker.
36 */
37
38const RUN_INTEGRATION = process.env.BRIVEN_PEN_TEST_RUN === '1';
39const TENANT_A = process.env.BRIVEN_PEN_TEST_TENANT_A_ID ?? null;
40const TENANT_B = process.env.BRIVEN_PEN_TEST_TENANT_B_ID ?? null;
41const TENANT_A_TOKEN = process.env.BRIVEN_PEN_TEST_TENANT_A_TOKEN ?? null;
42const TENANT_B_TOKEN = process.env.BRIVEN_PEN_TEST_TENANT_B_TOKEN ?? null;
43const API_ORIGIN = process.env.BRIVEN_PEN_TEST_API_ORIGIN ?? 'http://localhost:3001';
44
45const integrationReady =
46 RUN_INTEGRATION &&
47 TENANT_A !== null &&
48 TENANT_B !== null &&
49 TENANT_A_TOKEN !== null &&
50 TENANT_B_TOKEN !== null;
51
52describe('auth-tenant-isolation — redaction invariants (unit)', () => {
53 test('emailDomainHint never returns the full email', () => {
54 const hint = domainHintFromEmail('jane.doe@example.com');
55 expect(hint).toBe('example.com');
56 expect(hint).not.toContain('jane');
57 expect(hint).not.toContain('@');
58 });
59
60 test('emailDomainHint handles malformed inputs without crashing', () => {
61 expect(domainHintFromEmail('')).toBe('?');
62 expect(domainHintFromEmail('no-at-sign')).toBe('?');
63 expect(domainHintFromEmail('trailing@')).toBe('?');
64 });
65
66 test('nameInitialFrom returns only one grapheme', () => {
67 expect(nameInitialFrom('Jane')).toBe('J');
68 expect(nameInitialFrom(' Bob ')).toBe('B');
69 // Surrogate-pair safety — Array.from breaks at code-point boundaries
70 // so emoji-prefixed names don't return half a surrogate.
71 const initial = nameInitialFrom('👩‍🚀 Astronaut');
72 expect(initial).not.toBeNull();
73 expect((initial ?? '').length).toBeGreaterThan(0);
74 });
75
76 test('nameInitialFrom returns null for empty / whitespace-only', () => {
77 expect(nameInitialFrom(null)).toBeNull();
78 expect(nameInitialFrom('')).toBeNull();
79 expect(nameInitialFrom(' ')).toBeNull();
80 });
81
82 test('redactUserRow strips email + leaves only domain hint', () => {
83 const redacted = redactUserRow({
84 id: 'u_TEST123',
85 email: 'jane@example.com',
86 name: 'Jane Doe',
87 createdAt: '2026-05-19T00:00:00Z',
88 lastSeenAt: null,
89 providerIds: ['google'],
90 });
91 expect(redacted.emailDomainHint).toBe('example.com');
92 expect(JSON.stringify(redacted)).not.toContain('jane@');
93 expect(JSON.stringify(redacted)).not.toContain('Jane Doe');
94 });
95});
96
97describe('auth-tenant-isolation — cross-tenant probes (integration)', () => {
98 test.skipIf(!integrationReady)(
99 'a. forged x-briven-project-id — tenant A token + tenant B id → 401/403/404',
100 async () => {
101 const res = await fetch(
102 `${API_ORIGIN}/v1/projects/${TENANT_B}/auth/users`,
103 {
104 headers: { authorization: `Bearer ${TENANT_A_TOKEN}` },
105 },
106 );
107 expect([401, 403, 404]).toContain(res.status);
108 },
109 );
110
111 test.skipIf(!integrationReady)(
112 'b. tenant A admin endpoint refuses tenant B session token',
113 async () => {
114 const res = await fetch(
115 `${API_ORIGIN}/v1/projects/${TENANT_A}/auth/users`,
116 {
117 headers: { authorization: `Bearer ${TENANT_B_TOKEN}` },
118 },
119 );
120 expect([401, 403, 404]).toContain(res.status);
121 },
122 );
123
124 test.skipIf(!integrationReady)(
125 'c. forged OAuth state parameter — replay across tenants is rejected',
126 async () => {
127 const res = await fetch(
128 `${API_ORIGIN}/v1/auth-tenant/oauth/google/callback?state=forged&code=ignored`,
129 {
130 headers: { 'x-briven-project-id': TENANT_B ?? '' },
131 },
132 );
133 expect([400, 401, 403]).toContain(res.status);
134 },
135 );
136
137 test.skipIf(!integrationReady)(
138 'd. WebAuthn credential id from tenant A rejected when presented to tenant B',
139 async () => {
140 const res = await fetch(
141 `${API_ORIGIN}/v1/auth-tenant/passkey/authenticate/verify`,
142 {
143 method: 'POST',
144 headers: {
145 'content-type': 'application/json',
146 'x-briven-project-id': TENANT_B ?? '',
147 },
148 body: JSON.stringify({
149 credentialId: 'forged-from-tenant-a',
150 response: { signature: 'unused' },
151 }),
152 },
153 );
154 expect([400, 401, 404]).toContain(res.status);
155 },
156 );
157
158 test.skipIf(!integrationReady)(
159 'e. instance-pool cache-key collision — racing two tenant ids resolves to distinct instances',
160 async () => {
161 const [resA, resB] = await Promise.all([
162 fetch(`${API_ORIGIN}/v1/auth-tenant/get-session`, {
163 headers: { 'x-briven-project-id': TENANT_A ?? '' },
164 }),
165 fetch(`${API_ORIGIN}/v1/auth-tenant/get-session`, {
166 headers: { 'x-briven-project-id': TENANT_B ?? '' },
167 }),
168 ]);
169 // Indirect probe — if the pool ever returned a shared instance the
170 // user ids in the responses would collide. Here we just assert no
171 // 500 (which would indicate cross-tenant state corruption).
172 expect(resA.status).toBeLessThan(500);
173 expect(resB.status).toBeLessThan(500);
174 },
175 );
176
177 test.skipIf(!integrationReady)(
178 'f. design assertion: only path into tenant auth tables is runInProjectSchema',
179 () => {
180 // This probe is a *design assertion*, not a runtime test — we don't
181 // expose a way to set search_path from the request path. Any future
182 // code path that bypasses `runInProjectSchema` is a v1 blocker.
183 // A grep audit fits this on every PR — see
184 // `docs/runbooks/auth-tenant-isolation.md` (lands alongside the
185 // launch-readiness review, BUILD_PLAN.md §14).
186 expect(true).toBe(true);
187 },
188 );
189});