import { describe, expect, it } from 'vitest'; import worker from '../worker/index'; import { AUTH_SESSION_COOKIE_NAME, hashSessionToken } from '../worker/auth'; import type { Env } from '../worker/env'; type UserRole = 'user' | 'admin'; type UserRow = { id: string; email: string; name: string | null; role: UserRole; }; type SessionRow = { id: string; user_id: string; session_token_hash: string; expires_at: string; }; type RunCall = { sql: string; params: unknown[]; }; type FirstCall = { sql: string; params: unknown[]; }; class FakeDevAuthD1 { readonly users: UserRow[] = []; readonly sessions: SessionRow[] = []; readonly runCalls: RunCall[] = []; readonly firstCalls: FirstCall[] = []; prepare(sql: string): FakeDevAuthStatement { return new FakeDevAuthStatement(this, sql); } findUserByEmail(email: string): UserRow | null { return this.users.find((u) => u.email === email) ?? null; } findSessionByHash(hash: string): SessionRow | null { return this.sessions.find((s) => s.session_token_hash === hash) ?? null; } } class FakeDevAuthStatement { private params: unknown[] = []; constructor( private readonly db: FakeDevAuthD1, private readonly sql: string, ) {} bind(...params: unknown[]): this { this.params = params; return this; } async first(): Promise { this.db.firstCalls.push({ sql: this.sql, params: this.params }); if (this.sql.includes('FROM users') && this.sql.includes('email = ?')) { const row = this.db.findUserByEmail(String(this.params[0])); if (!row) { return null; } return { id: row.id, email: row.email, name: row.name, role: row.role } as T; } return null; } async run(): Promise { this.db.runCalls.push({ sql: this.sql, params: this.params }); if (this.sql.startsWith('INSERT INTO users')) { const [id, email, name, role] = this.params; this.db.users.push({ id: String(id), email: String(email), name: name == null ? null : String(name), role: String(role) as UserRole, }); } if (this.sql.startsWith('UPDATE users SET role')) { const [role, id] = this.params; const row = this.db.users.find((u) => u.id === String(id)); if (row) { row.role = String(role) as UserRole; } } if (this.sql.startsWith('INSERT INTO sessions')) { const [id, userId, tokenHash, expiresAt] = this.params; this.db.sessions.push({ id: String(id), user_id: String(userId), session_token_hash: String(tokenHash), expires_at: String(expiresAt), }); } return { success: true, meta: { changes: 1 } } as unknown as D1Result; } } class FakeExecutionContext { waitUntil(): void {} passThroughOnException(): void {} } type DevLoginResponse = { ok: boolean; user: { id: string; email: string; name: string | null; role: string }; }; type DevErrorResponse = { error: string }; type EnvOverrides = Partial; function makeEnv(db: FakeDevAuthD1, overrides: EnvOverrides = {}): Env { return { DB: db as unknown as D1Database, PUBLIC_HOST: 'localhost', PRIVATE_HOST: 'localhost', APP_BASE_URL: 'http://localhost:8787', COOKIE_DOMAIN: '', ...overrides, } as Env; } function prodEnv(overrides: EnvOverrides = {}): Env { return { DB: {} as D1Database, PUBLIC_HOST: 'heygo.cc', PRIVATE_HOST: 'my.heygo.cc', APP_BASE_URL: 'https://heygo.cc', COOKIE_DOMAIN: '.heygo.cc', ...overrides, } as Env; } async function fetchWorker( path: string, env: Env, init: RequestInit = {}, ): Promise { const url = `http://localhost:8787${path}`; const request = new Request(url, init); const ctx = new FakeExecutionContext(); return worker.fetch( request as unknown as Parameters[0], env as unknown as Parameters[1], ctx as unknown as Parameters[2], ); } function jsonPost(path: string, body: unknown): RequestInit { return { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), }; } describe('dev auth endpoint', () => { it('creates a new admin user and sets a session cookie on POST /api/auth/dev-login', async () => { const db = new FakeDevAuthD1(); const env = makeEnv(db); const response = await fetchWorker( '/api/auth/dev-login', env, jsonPost('/api/auth/dev-login', { email: 'wahyd4@gmail.com' }), ); expect(response.status).toBe(200); const setCookie = response.headers.get('set-cookie'); expect(setCookie).not.toBeNull(); expect(setCookie).toContain(`${AUTH_SESSION_COOKIE_NAME}=`); expect(setCookie).toContain('HttpOnly'); expect(setCookie).toContain('Path=/'); // Local dev is HTTP, so Secure must NOT be present expect(setCookie).not.toContain('Secure'); const data = (await response.json()) as DevLoginResponse; expect(data).toMatchObject({ ok: true, user: { email: 'wahyd4@gmail.com', role: 'admin' }, }); // A user was created with admin role expect(db.users).toHaveLength(1); expect(db.users[0].email).toBe('wahyd4@gmail.com'); expect(db.users[0].role).toBe('admin'); // A session was created expect(db.sessions).toHaveLength(1); const createdSession = db.sessions[0]; expect(createdSession.user_id).toBe(db.users[0].id); expect(createdSession.session_token_hash).not.toBe(''); // expires_at is a valid future ISO date const expiresAt = Date.parse(createdSession.expires_at); expect(Number.isNaN(expiresAt)).toBe(false); expect(expiresAt).toBeGreaterThan(Date.now()); }); it('reuses an existing user and promotes a non-admin to admin', async () => { const db = new FakeDevAuthD1(); db.users.push({ id: 'user_existing', email: 'wahyd4@gmail.com', name: 'wahyd4', role: 'user', }); const env = makeEnv(db); const response = await fetchWorker( '/api/auth/dev-login', env, jsonPost('/api/auth/dev-login', { email: 'wahyd4@gmail.com' }), ); expect(response.status).toBe(200); const data = (await response.json()) as DevLoginResponse; expect(data.user.role).toBe('admin'); expect(data.user.id).toBe('user_existing'); // No new user inserted expect(db.users).toHaveLength(1); // Role promoted to admin via UPDATE expect(db.users[0].role).toBe('admin'); const updateCall = db.runCalls.find((c) => c.sql.startsWith('UPDATE users SET role')); expect(updateCall).toBeDefined(); expect(updateCall?.params).toEqual(['admin', 'user_existing']); // Session was still created expect(db.sessions).toHaveLength(1); }); it('does not issue an UPDATE when the existing user is already admin', async () => { const db = new FakeDevAuthD1(); db.users.push({ id: 'user_admin', email: 'wahyd4@gmail.com', name: 'wahyd4', role: 'admin', }); const env = makeEnv(db); const response = await fetchWorker( '/api/auth/dev-login', env, jsonPost('/api/auth/dev-login', { email: 'wahyd4@gmail.com' }), ); expect(response.status).toBe(200); expect(db.runCalls.find((c) => c.sql.startsWith('UPDATE users SET role'))).toBeUndefined(); expect(db.sessions).toHaveLength(1); }); it('rejects GET /api/auth/dev-login with 405', async () => { const db = new FakeDevAuthD1(); const env = makeEnv(db); const response = await fetchWorker('/api/auth/dev-login', env, { method: 'GET' }); expect(response.status).toBe(405); const data = (await response.json()) as DevErrorResponse; expect(data.error).toMatch(/method/i); // No DB writes happened expect(db.runCalls).toHaveLength(0); }); it('rejects an invalid email with 400', async () => { const db = new FakeDevAuthD1(); const env = makeEnv(db); const response = await fetchWorker( '/api/auth/dev-login', env, jsonPost('/api/auth/dev-login', { email: 'not-an-email' }), ); expect(response.status).toBe(400); const data = (await response.json()) as DevErrorResponse; expect(data.error).toMatch(/email/i); expect(db.users).toHaveLength(0); expect(db.sessions).toHaveLength(0); }); it('rejects a missing email with 400', async () => { const db = new FakeDevAuthD1(); const env = makeEnv(db); const response = await fetchWorker( '/api/auth/dev-login', env, jsonPost('/api/auth/dev-login', {}), ); expect(response.status).toBe(400); expect(db.users).toHaveLength(0); }); it('rejects an invalid JSON body with 400', async () => { const db = new FakeDevAuthD1(); const env = makeEnv(db); const response = await fetchWorker('/api/auth/dev-login', env, { method: 'POST', headers: { 'content-type': 'application/json' }, body: 'not-json', }); expect(response.status).toBe(400); const data = (await response.json()) as DevErrorResponse; expect(data.error).toMatch(/json/i); }); it('is guarded: returns 403 when PUBLIC_HOST is not localhost', async () => { const db = new FakeDevAuthD1(); const env = prodEnv({ DB: db as unknown as D1Database }); const response = await fetchWorker( '/api/auth/dev-login', env, jsonPost('/api/auth/dev-login', { email: 'wahyd4@gmail.com' }), ); expect(response.status).toBe(403); const data = (await response.json()) as DevErrorResponse; expect(data.error).toMatch(/local/i); // No user or session created expect(db.users).toHaveLength(0); expect(db.sessions).toHaveLength(0); }); it('is guarded: returns 403 even when APP_BASE_URL has no localhost', async () => { const db = new FakeDevAuthD1(); const env = prodEnv({ DB: db as unknown as D1Database, PUBLIC_HOST: 'dev.heygo.cc' }); const response = await fetchWorker( '/api/auth/dev-login', env, jsonPost('/api/auth/dev-login', { email: 'wahyd4@gmail.com' }), ); expect(response.status).toBe(403); expect(db.users).toHaveLength(0); }); it('POST /api/auth/dev-logout clears the session cookie with a past expiry', async () => { const db = new FakeDevAuthD1(); const env = makeEnv(db); const response = await fetchWorker( '/api/auth/dev-logout', env, { method: 'POST' }, ); expect(response.status).toBe(200); await expect(response.json()).resolves.toEqual({ ok: true }); const setCookie = response.headers.get('set-cookie'); expect(setCookie).not.toBeNull(); expect(setCookie).toContain(`${AUTH_SESSION_COOKIE_NAME}=;`); // Must clear the cookie via an expiry in the past expect(setCookie).toContain('Expires='); // Should NOT contain Secure (HTTP local dev) expect(setCookie).not.toContain('Secure'); }); it('dev-logout is guarded: returns 403 when not local dev', async () => { const env = prodEnv(); const response = await fetchWorker('/api/auth/dev-logout', env, { method: 'POST' }); expect(response.status).toBe(403); }); it('returns a valid session that getCurrentUser can later resolve', async () => { const db = new FakeDevAuthD1(); const env = makeEnv(db); const response = await fetchWorker( '/api/auth/dev-login', env, jsonPost('/api/auth/dev-login', { email: 'wahyd4@gmail.com' }), ); expect(response.status).toBe(200); const setCookie = response.headers.get('set-cookie'); expect(setCookie).not.toBeNull(); // Extract the raw token value from the Set-Cookie header const tokenMatch = setCookie!.match(new RegExp(`${AUTH_SESSION_COOKIE_NAME}=([^;]+)`)); expect(tokenMatch).not.toBeNull(); const token = tokenMatch![1]; // The stored hash must match a SHA-256 of the token we issued const expectedHash = await hashSessionToken(token); const session = db.findSessionByHash(expectedHash); expect(session).not.toBeNull(); expect(session?.user_id).toBe(db.users[0].id); }); it('falls through (404) for unrelated /api/ paths', async () => { const db = new FakeDevAuthD1(); const env = makeEnv(db); const response = await fetchWorker('/api/some-other-path', env, { method: 'GET' }); expect(response.status).toBe(404); // No DB writes for dev auth expect(db.runCalls).toHaveLength(0); }); });