import { describe, it, expect, beforeEach } from 'vitest'; import { createHash, randomBytes } from 'node:crypto'; import { createTestDb, type TestDb } from '../helpers/db'; import { forwardAuthSessions, forwardAuthExchanges, forwardAuthAccess, groups, users, proxyHosts } from '@/src/lib/db/schema'; import { eq } from 'drizzle-orm'; let db: TestDb; beforeEach(() => { db = createTestDb(); }); function nowIso() { return new Date().toISOString(); } function futureIso(seconds: number) { return new Date(Date.now() + seconds * 1000).toISOString(); } function hashToken(raw: string): string { return createHash('sha256').update(raw).digest('hex'); } async function insertUser(overrides: Partial = {}) { const now = nowIso(); const [user] = await db.insert(users).values({ email: `user${Math.random().toString(36).slice(2)}@localhost`, name: 'Test User', role: 'user', provider: 'credentials', subject: `test-${Date.now()}-${Math.random()}`, status: 'active', createdAt: now, updatedAt: now, ...overrides, }).returning(); return user; } async function insertProxyHost(overrides: Partial = {}) { const now = nowIso(); const [host] = await db.insert(proxyHosts).values({ name: 'Test Host', domains: JSON.stringify(['app.example.com']), upstreams: JSON.stringify(['backend:8080']), sslForced: true, hstsEnabled: true, hstsSubdomains: false, allowWebsocket: true, preserveHostHeader: true, skipHttpsHostnameValidation: false, enabled: true, createdAt: now, updatedAt: now, ...overrides, }).returning(); return host; } describe('forward auth sessions', () => { it('creates a session with hashed token', async () => { const user = await insertUser(); const rawToken = randomBytes(32).toString('hex'); const tokenHash = hashToken(rawToken); const now = nowIso(); const [session] = await db.insert(forwardAuthSessions).values({ userId: user.id, tokenHash, expiresAt: futureIso(3600), createdAt: now, }).returning(); expect(session.tokenHash).toBe(tokenHash); expect(session.userId).toBe(user.id); }); it('enforces unique token hashes', async () => { const user = await insertUser(); const tokenHash = hashToken('same-token'); const now = nowIso(); await db.insert(forwardAuthSessions).values({ userId: user.id, tokenHash, expiresAt: futureIso(3600), createdAt: now, }); await expect( db.insert(forwardAuthSessions).values({ userId: user.id, tokenHash, expiresAt: futureIso(3600), createdAt: now, }) ).rejects.toThrow(); }); it('cascades user deletion to sessions', async () => { const user = await insertUser(); const now = nowIso(); await db.insert(forwardAuthSessions).values({ userId: user.id, tokenHash: hashToken('token1'), expiresAt: futureIso(3600), createdAt: now, }); await db.delete(users).where(eq(users.id, user.id)); const sessions = await db.query.forwardAuthSessions.findMany(); expect(sessions).toHaveLength(0); }); }); describe('forward auth exchanges', () => { it('creates an exchange code linked to a session', async () => { const user = await insertUser(); const now = nowIso(); const [session] = await db.insert(forwardAuthSessions).values({ userId: user.id, tokenHash: hashToken('session-token'), expiresAt: futureIso(3600), createdAt: now, }).returning(); const rawCode = randomBytes(32).toString('hex'); const [exchange] = await db.insert(forwardAuthExchanges).values({ sessionId: session.id, codeHash: hashToken(rawCode), sessionToken: 'raw-session-token', redirectUri: 'https://app.example.com/path', expiresAt: futureIso(60), used: false, createdAt: now, }).returning(); expect(exchange.sessionId).toBe(session.id); expect(exchange.sessionToken).toBe('raw-session-token'); expect(exchange.used).toBe(false); }); it('cascades session deletion to exchanges', async () => { const user = await insertUser(); const now = nowIso(); const [session] = await db.insert(forwardAuthSessions).values({ userId: user.id, tokenHash: hashToken('session2'), expiresAt: futureIso(3600), createdAt: now, }).returning(); await db.insert(forwardAuthExchanges).values({ sessionId: session.id, codeHash: hashToken('code1'), sessionToken: 'raw-token', redirectUri: 'https://app.example.com/', expiresAt: futureIso(60), used: false, createdAt: now, }); await db.delete(forwardAuthSessions).where(eq(forwardAuthSessions.id, session.id)); const exchanges = await db.query.forwardAuthExchanges.findMany(); expect(exchanges).toHaveLength(0); }); }); describe('forward auth access', () => { it('creates user-level access for a proxy host', async () => { const user = await insertUser(); const host = await insertProxyHost(); const now = nowIso(); const [access] = await db.insert(forwardAuthAccess).values({ proxyHostId: host.id, userId: user.id, groupId: null, createdAt: now, }).returning(); expect(access.proxyHostId).toBe(host.id); expect(access.userId).toBe(user.id); expect(access.groupId).toBeNull(); }); it('creates group-level access for a proxy host', async () => { const host = await insertProxyHost(); const now = nowIso(); const [group] = await db.insert(groups).values({ name: 'Devs', createdAt: now, updatedAt: now, }).returning(); const [access] = await db.insert(forwardAuthAccess).values({ proxyHostId: host.id, userId: null, groupId: group.id, createdAt: now, }).returning(); expect(access.groupId).toBe(group.id); expect(access.userId).toBeNull(); }); it('prevents duplicate user access per host', async () => { const user = await insertUser(); const host = await insertProxyHost(); const now = nowIso(); await db.insert(forwardAuthAccess).values({ proxyHostId: host.id, userId: user.id, groupId: null, createdAt: now, }); await expect( db.insert(forwardAuthAccess).values({ proxyHostId: host.id, userId: user.id, groupId: null, createdAt: now, }) ).rejects.toThrow(); }); it('cascades proxy host deletion to access entries', async () => { const user = await insertUser(); const host = await insertProxyHost(); const now = nowIso(); await db.insert(forwardAuthAccess).values({ proxyHostId: host.id, userId: user.id, groupId: null, createdAt: now, }); await db.delete(proxyHosts).where(eq(proxyHosts.id, host.id)); const access = await db.query.forwardAuthAccess.findMany(); expect(access).toHaveLength(0); }); it('cascades group deletion to access entries', async () => { const host = await insertProxyHost(); const now = nowIso(); const [group] = await db.insert(groups).values({ name: 'Team', createdAt: now, updatedAt: now, }).returning(); await db.insert(forwardAuthAccess).values({ proxyHostId: host.id, userId: null, groupId: group.id, createdAt: now, }); await db.delete(groups).where(eq(groups.id, group.id)); const access = await db.query.forwardAuthAccess.findMany(); expect(access).toHaveLength(0); }); it('allows both user and group access on same host', async () => { const user = await insertUser(); const host = await insertProxyHost(); const now = nowIso(); const [group] = await db.insert(groups).values({ name: 'Group', createdAt: now, updatedAt: now, }).returning(); await db.insert(forwardAuthAccess).values([ { proxyHostId: host.id, userId: user.id, groupId: null, createdAt: now }, { proxyHostId: host.id, userId: null, groupId: group.id, createdAt: now }, ]); const access = await db.query.forwardAuthAccess.findMany({ where: (t, { eq }) => eq(t.proxyHostId, host.id), }); expect(access).toHaveLength(2); }); });