379 lines
11 KiB
TypeScript
379 lines
11 KiB
TypeScript
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
|
import {
|
|
withAdminContext,
|
|
withUserContext,
|
|
} from '../../src/services/database/user-context.service';
|
|
import type { Pool, PoolClient } from 'pg';
|
|
|
|
/**
|
|
* Records every query call so the test can assert ordering and arguments.
|
|
*/
|
|
function makeMockClient() {
|
|
const calls: Array<{ sql: string; params?: unknown[] }> = [];
|
|
const client = {
|
|
query: vi.fn(async (sql: string, params?: unknown[]) => {
|
|
calls.push({ sql, params });
|
|
return { rows: [], rowCount: 0 } as unknown;
|
|
}),
|
|
release: vi.fn(),
|
|
} as unknown as PoolClient;
|
|
return { client, calls };
|
|
}
|
|
|
|
function makeMockPool(client: PoolClient): Pool {
|
|
return {
|
|
connect: vi.fn(async () => client),
|
|
} as unknown as Pool;
|
|
}
|
|
|
|
describe('withUserContext', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it('authenticated path sets role + jwt claims inside a transaction and commits', async () => {
|
|
const { client, calls } = makeMockClient();
|
|
const pool = makeMockPool(client);
|
|
|
|
const result = await withUserContext(
|
|
pool,
|
|
{
|
|
id: 'ZVP5j6raUC9cuBIWzDGjdNdelMFjWNc5',
|
|
role: 'authenticated',
|
|
email: 'alice@example.com',
|
|
},
|
|
async (db) => {
|
|
await db.query('SELECT 1');
|
|
return 'ok';
|
|
}
|
|
);
|
|
|
|
expect(result).toBe('ok');
|
|
const sequence = calls.map((c) => c.sql);
|
|
expect(sequence).toEqual([
|
|
'BEGIN',
|
|
'SET LOCAL ROLE authenticated',
|
|
'SELECT set_config($1, $2, true)',
|
|
'SELECT 1',
|
|
'COMMIT',
|
|
'RESET ROLE',
|
|
]);
|
|
|
|
expect(calls[2].params?.[0]).toBe('request.jwt.claims');
|
|
expect(JSON.parse(calls[2].params![1] as string)).toEqual({
|
|
role: 'authenticated',
|
|
sub: 'ZVP5j6raUC9cuBIWzDGjdNdelMFjWNc5',
|
|
email: 'alice@example.com',
|
|
});
|
|
expect(client.release).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it('strips the anonymous sentinel subject from database claims', async () => {
|
|
const { client, calls } = makeMockClient();
|
|
const pool = makeMockPool(client);
|
|
|
|
await withUserContext(pool, { id: 'anonymous', role: 'anon' }, async () => {});
|
|
|
|
// The sentinel is an API-level label: auth.uid() casts sub to uuid, so
|
|
// it must never reach the database claims
|
|
expect(calls[2].params?.[0]).toBe('request.jwt.claims');
|
|
expect(JSON.parse(calls[2].params![1] as string)).toEqual({ role: 'anon' });
|
|
const setLocalRole = calls.find((c) => c.sql.startsWith('SET LOCAL ROLE'));
|
|
expect(setLocalRole?.sql).toBe('SET LOCAL ROLE anon');
|
|
});
|
|
|
|
it('strips anon subjects even when they are UUIDs (legacy anon JWTs)', async () => {
|
|
const { client, calls } = makeMockClient();
|
|
const pool = makeMockPool(client);
|
|
|
|
// The legacy shared anon UUID never identified anyone — ownership is an
|
|
// authenticated-only concept, regardless of the subject's shape
|
|
await withUserContext(
|
|
pool,
|
|
{ id: '12345678-1234-5678-90ab-cdef12345678', role: 'anon' },
|
|
async () => {}
|
|
);
|
|
|
|
expect(JSON.parse(calls[2].params![1] as string)).toEqual({ role: 'anon' });
|
|
});
|
|
|
|
it('can run as project_admin when the caller wants database policies to decide', async () => {
|
|
const { client, calls } = makeMockClient();
|
|
const pool = makeMockPool(client);
|
|
|
|
await withUserContext(
|
|
pool,
|
|
{
|
|
id: 'local:admin',
|
|
role: 'project_admin',
|
|
},
|
|
async () => {}
|
|
);
|
|
|
|
expect(calls.map((c) => c.sql)).toContain('SET LOCAL ROLE project_admin');
|
|
expect(calls[2].params?.[0]).toBe('request.jwt.claims');
|
|
expect(JSON.parse(calls[2].params![1] as string)).toEqual({
|
|
role: 'project_admin',
|
|
});
|
|
});
|
|
|
|
it('sets extra runtime GUCs inside the same transaction', async () => {
|
|
const { client, calls } = makeMockClient();
|
|
const pool = makeMockPool(client);
|
|
|
|
await withUserContext(
|
|
pool,
|
|
{ id: 'u1', email: 'u1@example.com', role: 'authenticated' },
|
|
async (db) => {
|
|
await db.query('SELECT 1');
|
|
},
|
|
{
|
|
'realtime.channel_name': 'chat:lobby',
|
|
ignored: undefined,
|
|
}
|
|
);
|
|
|
|
expect(calls.map((c) => c.sql)).toEqual([
|
|
'BEGIN',
|
|
'SET LOCAL ROLE authenticated',
|
|
'SELECT set_config($1, $2, true)',
|
|
'SELECT set_config($1, $2, true)',
|
|
'SELECT 1',
|
|
'COMMIT',
|
|
'RESET ROLE',
|
|
]);
|
|
expect(calls[2].params?.[0]).toBe('request.jwt.claims');
|
|
expect(calls[3].params).toEqual(['realtime.channel_name', 'chat:lobby']);
|
|
});
|
|
|
|
it('rejects attempts to override centralized JWT settings', async () => {
|
|
const { client, calls } = makeMockClient();
|
|
const pool = makeMockPool(client);
|
|
|
|
await expect(
|
|
withUserContext(
|
|
pool,
|
|
{ id: 'u1', email: 'u1@example.com', role: 'authenticated' },
|
|
async () => {},
|
|
{ 'request.jwt.foo': 'evil' }
|
|
)
|
|
).rejects.toThrow(/must not override request\.jwt\.\*/);
|
|
|
|
expect(calls.map((c) => c.sql)).toEqual([
|
|
'BEGIN',
|
|
'SET LOCAL ROLE authenticated',
|
|
'SELECT set_config($1, $2, true)',
|
|
'ROLLBACK',
|
|
'RESET ROLE',
|
|
]);
|
|
expect(client.release).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it('rejects mixed-case attempts to override centralized JWT settings', async () => {
|
|
const { client, calls } = makeMockClient();
|
|
const pool = makeMockPool(client);
|
|
|
|
await expect(
|
|
withUserContext(
|
|
pool,
|
|
{ id: 'u1', email: 'u1@example.com', role: 'authenticated' },
|
|
async () => {},
|
|
{ 'Request.Jwt.Claims': '{"sub":"evil"}' }
|
|
)
|
|
).rejects.toThrow(/must not override request\.jwt\.\*/);
|
|
|
|
expect(calls.map((c) => c.sql)).toEqual([
|
|
'BEGIN',
|
|
'SET LOCAL ROLE authenticated',
|
|
'SELECT set_config($1, $2, true)',
|
|
'ROLLBACK',
|
|
'RESET ROLE',
|
|
]);
|
|
expect(client.release).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it('rolls back and resets role if fn throws', async () => {
|
|
const { client, calls } = makeMockClient();
|
|
const pool = makeMockPool(client);
|
|
|
|
await expect(
|
|
withUserContext(
|
|
pool,
|
|
{ id: 'u1', email: 'u1@example.com', role: 'authenticated' },
|
|
async () => {
|
|
throw new Error('boom');
|
|
}
|
|
)
|
|
).rejects.toThrow('boom');
|
|
|
|
// Pin the exact sequence — flipping order or skipping RESET ROLE
|
|
// silently leaks role state across the pool.
|
|
expect(calls.map((c) => c.sql)).toEqual([
|
|
'BEGIN',
|
|
'SET LOCAL ROLE authenticated',
|
|
'SELECT set_config($1, $2, true)',
|
|
'ROLLBACK',
|
|
'RESET ROLE',
|
|
]);
|
|
expect(client.release).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it('always releases the client even if RESET ROLE fails', async () => {
|
|
const { client, calls } = makeMockClient();
|
|
const pool = makeMockPool(client);
|
|
|
|
// Make RESET ROLE fail
|
|
(client.query as ReturnType<typeof vi.fn>).mockImplementation(
|
|
async (sql: string, params?: unknown[]) => {
|
|
calls.push({ sql, params });
|
|
if (sql === 'RESET ROLE') {
|
|
throw new Error('reset failed');
|
|
}
|
|
return { rows: [], rowCount: 0 };
|
|
}
|
|
);
|
|
|
|
await withUserContext(
|
|
pool,
|
|
{ id: 'u1', email: 'u1@example.com', role: 'authenticated' },
|
|
async () => {}
|
|
);
|
|
|
|
expect(calls.map((c) => c.sql)).toContain('RESET ROLE');
|
|
expect(client.release).toHaveBeenCalledOnce();
|
|
});
|
|
});
|
|
|
|
describe('withAdminContext', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it('uses session-level project_admin context by default and resets it', async () => {
|
|
const { client, calls } = makeMockClient();
|
|
|
|
const result = await withAdminContext(client, async () => {
|
|
await client.query('SELECT 1');
|
|
return 'ok';
|
|
});
|
|
|
|
expect(result).toBe('ok');
|
|
expect(calls.map((c) => c.sql)).toEqual([
|
|
'SET ROLE project_admin',
|
|
'SELECT set_config($1, $2, $3)',
|
|
'SELECT 1',
|
|
'RESET ROLE',
|
|
'SELECT set_config($1, $2, $3)',
|
|
]);
|
|
expect(calls[1].params).toEqual([
|
|
'request.jwt.claims',
|
|
JSON.stringify({ role: 'project_admin' }),
|
|
false,
|
|
]);
|
|
expect(calls[4].params).toEqual(['request.jwt.claims', '{}', false]);
|
|
});
|
|
|
|
it('uses transaction-local project_admin context when requested', async () => {
|
|
const { client, calls } = makeMockClient();
|
|
|
|
await withAdminContext(
|
|
client,
|
|
async () => {
|
|
await client.query('CREATE TABLE public.todos (id uuid)');
|
|
},
|
|
true
|
|
);
|
|
|
|
expect(calls.map((c) => c.sql)).toEqual([
|
|
'SET LOCAL ROLE project_admin',
|
|
'SELECT set_config($1, $2, $3)',
|
|
'CREATE TABLE public.todos (id uuid)',
|
|
'RESET ROLE',
|
|
'SELECT set_config($1, $2, $3)',
|
|
]);
|
|
expect(calls[1].params).toEqual([
|
|
'request.jwt.claims',
|
|
JSON.stringify({ role: 'project_admin' }),
|
|
true,
|
|
]);
|
|
expect(calls[4].params).toEqual(['request.jwt.claims', '{}', true]);
|
|
});
|
|
|
|
it('does not swallow admin context cleanup failures', async () => {
|
|
const { client, calls } = makeMockClient();
|
|
const resetError = new Error('reset failed');
|
|
const cleanupErrors: Error[] = [];
|
|
|
|
(client.query as ReturnType<typeof vi.fn>).mockImplementation(
|
|
async (sql: string, params?: unknown[]) => {
|
|
calls.push({ sql, params });
|
|
if (sql === 'RESET ROLE') {
|
|
throw resetError;
|
|
}
|
|
return { rows: [], rowCount: 0 };
|
|
}
|
|
);
|
|
|
|
await expect(
|
|
withAdminContext(
|
|
client,
|
|
async () => 'ok',
|
|
false,
|
|
(error) => cleanupErrors.push(error)
|
|
)
|
|
).rejects.toBe(resetError);
|
|
expect(cleanupErrors).toEqual([resetError]);
|
|
});
|
|
|
|
it('preserves the original SQL error when admin context cleanup also fails', async () => {
|
|
const { client, calls } = makeMockClient();
|
|
const sqlError = new Error('sql failed');
|
|
const resetError = new Error('reset failed');
|
|
const cleanupErrors: Error[] = [];
|
|
|
|
(client.query as ReturnType<typeof vi.fn>).mockImplementation(
|
|
async (sql: string, params?: unknown[]) => {
|
|
calls.push({ sql, params });
|
|
if (sql === 'RESET ROLE') {
|
|
throw resetError;
|
|
}
|
|
return { rows: [], rowCount: 0 };
|
|
}
|
|
);
|
|
|
|
await expect(
|
|
withAdminContext(
|
|
client,
|
|
async () => {
|
|
throw sqlError;
|
|
},
|
|
false,
|
|
(error) => cleanupErrors.push(error)
|
|
)
|
|
).rejects.toBe(sqlError);
|
|
expect(sqlError.cause).toBe(resetError);
|
|
expect(cleanupErrors).toEqual([resetError]);
|
|
});
|
|
|
|
it('lets the surrounding transaction rollback clear local context after SQL failures', async () => {
|
|
const { client, calls } = makeMockClient();
|
|
|
|
await expect(
|
|
withAdminContext(
|
|
client,
|
|
async () => {
|
|
await client.query('SELECT broken');
|
|
throw new Error('sql failed');
|
|
},
|
|
true
|
|
)
|
|
).rejects.toThrow('sql failed');
|
|
|
|
expect(calls.map((c) => c.sql)).toEqual([
|
|
'SET LOCAL ROLE project_admin',
|
|
'SELECT set_config($1, $2, $3)',
|
|
'SELECT broken',
|
|
]);
|
|
});
|
|
});
|