import { describe, it, expect, beforeAll, vi } from 'vitest'; import { execFileSync } from 'child_process'; import { mkdtempSync } from 'fs'; import { tmpdir } from 'os'; import { join } from 'path'; import { exportJWK, generateKeyPair, SignJWT } from 'jose'; import { normalizeEmail } from './utils.js'; // Environment must be pinned BEFORE the db/betterAuth singletons are imported // (dotenv never overrides pre-set values). const dir = mkdtempSync(join(tmpdir(), 'ba-test-')); const dbPath = join(dir, 'test.db'); process.env.DB_TYPE = 'sqlite'; process.env.DATABASE_URL = dbPath; process.env.FRONTEND_URL = 'http://localhost:3002'; process.env.BETTER_AUTH_SECRET = 'integration-test-secret-0123456789abcdef'; delete process.env.REDIS_URL; // memory lockout/rate-limit backends // Google IS configured here: account linking is the whole point of the tests at // the bottom of this file, and betterAuth.ts omits `socialProviders` entirely // when this is unset. No real credentials are involved — the id tokens are // signed with a throwaway keypair and Google's JWKS endpoint is stubbed below. const GOOGLE_CLIENT_ID = 'spanglish-test.apps.googleusercontent.com'; process.env.GOOGLE_CLIENT_ID = GOOGLE_CLIENT_ID; // Capture outgoing auth emails (magic links, password resets) const sentEmails: Array<{ to: string; subject: string; html: string }> = []; vi.mock('./email.js', () => ({ sendEmail: vi.fn(async (opts: any) => { sentEmails.push(opts); }), emailService: {}, default: {}, })); let auth: (typeof import('./betterAuth.js'))['auth']; let db: any; let sqlite: any; function lastEmailTo(email: string) { const found = [...sentEmails].reverse().find((e) => e.to === email); expect(found, `expected an email sent to ${email}`).toBeTruthy(); return found!; } function extractToken(html: string, param = 'token'): string { const match = html.match(new RegExp(`[?&]${param}=([^"&\\s]+)`)); expect(match, `expected a ${param} in the email link`).toBeTruthy(); return decodeURIComponent(match![1]); } // ---- Google Identity Services stub ------------------------------------- // verifyGoogleIdToken() checks signature, issuer, audience and max age against // Google's published JWKS; its only network call is that JWKS fetch. Signing // with our own key and serving our own JWKS exercises the real verification // path without touching the network or needing OAuth credentials. const GOOGLE_KID = 'spanglish-test-key'; let googlePrivateKey: CryptoKey; async function installGoogleStub() { const { publicKey, privateKey } = await generateKeyPair('RS256', { extractable: true }); googlePrivateKey = privateKey as CryptoKey; const jwk = { ...(await exportJWK(publicKey)), kid: GOOGLE_KID, alg: 'RS256', use: 'sig' }; const realFetch = globalThis.fetch; globalThis.fetch = (async (input: any, init?: any) => { const url = typeof input === 'string' ? input : (input?.url ?? String(input)); if (url.startsWith('https://www.googleapis.com/oauth2/v3/certs')) { return new Response(JSON.stringify({ keys: [jwk] }), { status: 200, headers: { 'content-type': 'application/json' }, }); } return realFetch(input, init); }) as typeof fetch; } function googleIdToken(opts: { email: string; sub: string; name?: string; emailVerified?: boolean }) { return new SignJWT({ email: opts.email, email_verified: opts.emailVerified ?? true, name: opts.name ?? 'Google User', picture: 'https://example.test/avatar.png', }) .setProtectedHeader({ alg: 'RS256', kid: GOOGLE_KID }) .setIssuer('https://accounts.google.com') .setAudience(GOOGLE_CLIENT_ID) .setSubject(opts.sub) .setIssuedAt() .setExpirationTime('10m') .sign(googlePrivateKey); } async function signInWithGoogle( opts: Parameters[0], returnHeaders = false ): Promise { const token = await googleIdToken(opts); return auth.api.signInSocial({ body: { provider: 'google', idToken: { token } }, headers: new Headers(), ...(returnHeaders ? { returnHeaders: true } : {}), } as any); } /** Insert a user the way a guest booking does (routes/tickets.ts, routes/door.ts): * unclaimed, unverified, and with no auth_accounts row at all. */ function insertBookingUser(id: string, email: string, name = 'Ticket Buyer') { const now = new Date().toISOString(); sqlite .prepare( `INSERT INTO users (id, email, password, name, role, is_claimed, account_status, email_verified, created_at, updated_at) VALUES (?, ?, NULL, ?, 'user', 0, 'unclaimed', 0, ?, ?)` ) .run(id, email, name, now, now); return id; } function userRow(email: string) { return sqlite .prepare('SELECT id, email, is_claimed, account_status, email_verified FROM users WHERE email = ?') .get(email); } function googleAccountsFor(userId: string) { return sqlite .prepare("SELECT id, account_id FROM auth_accounts WHERE user_id = ? AND provider_id = 'google'") .all(userId); } function cookieHeaders(setCookie: string | null): Headers { const sessionPart = (setCookie || '') .split(/,(?=[^ ;]+=)/) .map((c) => c.split(';')[0].trim()) .filter((c) => c.includes('session_token')) .join('; '); return new Headers({ cookie: sessionPart }); } beforeAll(() => { execFileSync('npx', ['tsx', 'src/db/migrate.ts'], { env: { ...process.env }, stdio: 'pipe', }); return (async () => { ({ auth } = await import('./betterAuth.js')); ({ db } = await import('../db/index.js')); const Database = (await import('better-sqlite3')).default; sqlite = new Database(dbPath); await installGoogleStub(); })(); }, 120_000); describe('Better Auth integration', () => { it('makes the first registered user an admin, later users regular', async () => { const first = await auth.api.signUpEmail({ body: { email: 'admin@test.py', password: 'FirstAdmin1!x', name: 'Admin' }, }); expect((first.user as any).id).toBeTruthy(); const row = sqlite.prepare('SELECT role, is_claimed, account_status FROM users WHERE email = ?').get('admin@test.py'); expect(row.role).toBe('admin'); expect(row.account_status).toBe('active'); await auth.api.signUpEmail({ body: { email: 'user@test.py', password: 'SecondUser1!x', name: 'User' }, }); const row2 = sqlite.prepare('SELECT role FROM users WHERE email = ?').get('user@test.py'); expect(row2.role).toBe('user'); }); it('stores credential passwords as argon2id in auth_accounts, not users', async () => { const acct = sqlite .prepare("SELECT a.password FROM auth_accounts a JOIN users u ON u.id = a.user_id WHERE u.email = ? AND a.provider_id = 'credential'") .get('admin@test.py'); expect(acct.password.startsWith('$argon2id$')).toBe(true); const user = sqlite.prepare('SELECT password FROM users WHERE email = ?').get('admin@test.py'); expect(user.password).toBeNull(); }); it('rejects passwords that violate the policy', async () => { // Too short await expect( auth.api.signUpEmail({ body: { email: 'weak1@test.py', password: 'Short1!', name: 'W' } }) ).rejects.toThrow(/at least 10 characters/); // Long enough but no character mix (app policy hook) await expect( auth.api.signUpEmail({ body: { email: 'weak2@test.py', password: 'alllowercasepw', name: 'W' } }) ).rejects.toThrow(/uppercase and lowercase/); // Common password normalized (policy blocklist) await expect( auth.api.signUpEmail({ body: { email: 'weak3@test.py', password: 'Spanglish!', name: 'W' } }) ).rejects.toThrow(/too common/); expect(sqlite.prepare("SELECT COUNT(*) AS n FROM users WHERE email LIKE 'weak%'").get().n).toBe(0); }); it('locks an email after 5 failed sign-ins', async () => { await auth.api.signUpEmail({ body: { email: 'lockout@test.py', password: 'LockoutPass1!', name: 'L' }, }); for (let i = 0; i < 5; i++) { await expect( auth.api.signInEmail({ body: { email: 'lockout@test.py', password: 'WrongPass1!x' } }) ).rejects.toThrow(); } // Correct password now also refused: locked await expect( auth.api.signInEmail({ body: { email: 'lockout@test.py', password: 'LockoutPass1!' } }) ).rejects.toThrow(/Too many login attempts/); }); it('refuses sign-in for banned (suspended) users and kills nothing else', async () => { await auth.api.signUpEmail({ body: { email: 'banned@test.py', password: 'BannedPass1!x', name: 'B' }, }); sqlite.prepare("UPDATE users SET banned = 1, account_status = 'suspended' WHERE email = ?").run('banned@test.py'); await expect( auth.api.signInEmail({ body: { email: 'banned@test.py', password: 'BannedPass1!x' } }) ).rejects.toThrow(/suspended|banned/i); }); it('verifies legacy bcrypt hashes and upgrades them to argon2 on sign-in', async () => { const bcrypt = (await import('bcryptjs')).default; const legacyHash = bcrypt.hashSync('LegacyBcrypt1!', 10); const su = await auth.api.signUpEmail({ body: { email: 'legacy@test.py', password: 'TempPass123!x', name: 'Legacy' }, }); sqlite .prepare("UPDATE auth_accounts SET password = ? WHERE user_id = ? AND provider_id = 'credential'") .run(legacyHash, (su.user as any).id); const si = await auth.api.signInEmail({ body: { email: 'legacy@test.py', password: 'LegacyBcrypt1!' }, }); expect(si.user.email).toBe('legacy@test.py'); // Upgrade happens in the after-hook; poll briefly for it let upgraded = ''; for (let i = 0; i < 20 && !upgraded.startsWith('$argon2'); i++) { await new Promise((r) => setTimeout(r, 100)); upgraded = sqlite .prepare("SELECT password FROM auth_accounts WHERE user_id = ? AND provider_id = 'credential'") .get((su.user as any).id).password; } expect(upgraded.startsWith('$argon2id$')).toBe(true); // And the upgraded hash still verifies const again = await auth.api.signInEmail({ body: { email: 'legacy@test.py', password: 'LegacyBcrypt1!' }, }); expect(again.user.email).toBe('legacy@test.py'); }); it('magic link signs in existing users but never creates accounts', async () => { await auth.api.signUpEmail({ body: { email: 'magic@test.py', password: 'MagicPass12!x', name: 'M' }, }); await auth.api.signInMagicLink({ body: { email: 'magic@test.py', callbackURL: '/dashboard' }, headers: new Headers(), }); const email = lastEmailTo('magic@test.py'); // Emails link to the frontend page, not the raw API endpoint expect(email.html).toContain('http://localhost:3002/auth/magic-link?token='); const token = extractToken(email.html); const verified = await auth.api.magicLinkVerify({ query: { token }, headers: new Headers(), }); expect((verified as any).user?.email ?? (verified as any).session?.userId).toBeTruthy(); // Unknown email: enumeration-safe success (an email may still go out), // but verification can never create an account (disableSignUp) const ghost = await auth.api.signInMagicLink({ body: { email: 'ghost@test.py' }, headers: new Headers(), }); expect((ghost as any).status).toBe(true); const ghostEmail = sentEmails.filter((e) => e.to === 'ghost@test.py').pop(); if (ghostEmail) { const ghostToken = extractToken(ghostEmail.html); await expect( auth.api.magicLinkVerify({ query: { token: ghostToken }, headers: new Headers() }) ).rejects.toThrow(); } expect(sqlite.prepare('SELECT COUNT(*) AS n FROM users WHERE email = ?').get('ghost@test.py').n).toBe(0); }); it('completes the guest claim path: magic link session + setPassword', async () => { // Simulate tickets.ts guest creation: user row, no credential account const guestId = 'guest-claim-user-000001'; const now = new Date().toISOString(); sqlite .prepare( `INSERT INTO users (id, email, password, name, role, is_claimed, account_status, email_verified, banned, token_version, created_at, updated_at) VALUES (?, ?, NULL, 'Guest', 'user', 0, 'unclaimed', 0, 0, 0, ?, ?)` ) .run(guestId, 'guest@test.py', now, now); await auth.api.signInMagicLink({ body: { email: 'guest@test.py', callbackURL: '/auth/claim-account' }, headers: new Headers(), }); const token = extractToken(lastEmailTo('guest@test.py').html); const verified = await auth.api.magicLinkVerify({ query: { token }, returnHeaders: true, headers: new Headers(), }); const headers = cookieHeaders(verified.headers.get('set-cookie')); // The session works even while unclaimed (the claim endpoint depends on this) const session = await auth.api.getSession({ headers }); expect((session?.user as any)?.accountStatus).toBe('unclaimed'); // Set the password (what /api/auth-ext/claim-account does) await auth.api.setPassword({ body: { newPassword: 'ClaimedPass1!x' }, headers }); const acct = sqlite .prepare("SELECT password FROM auth_accounts WHERE user_id = ? AND provider_id = 'credential'") .get(guestId); expect(acct.password.startsWith('$argon2id$')).toBe(true); // The claim email uses the claim template with the frontend link const claimEmail = lastEmailTo('guest@test.py'); expect(claimEmail.subject).toContain('Claim'); expect(claimEmail.html).toContain('callbackURL=%2Fauth%2Fclaim-account'); }); it('password reset revokes existing sessions and applies the new password', async () => { const su = await auth.api.signUpEmail({ body: { email: 'reset@test.py', password: 'BeforeReset1!x', name: 'R' }, }); const userId = (su.user as any).id; // A live session from sign-in await auth.api.signInEmail({ body: { email: 'reset@test.py', password: 'BeforeReset1!x' } }); expect( sqlite.prepare('SELECT COUNT(*) AS n FROM auth_sessions WHERE user_id = ?').get(userId).n ).toBeGreaterThan(0); await auth.api.requestPasswordReset({ body: { email: 'reset@test.py', redirectTo: '/auth/reset-password' }, }); const email = lastEmailTo('reset@test.py'); // Reset URLs are either .../reset-password/{token}?... or ...?token={token} const html = email.html; const pathMatch = html.match(/reset-password\/([^?"&\s]+)/); const token = pathMatch ? decodeURIComponent(pathMatch[1]) : extractToken(html); await auth.api.resetPassword({ body: { newPassword: 'AfterReset1!x', token } }); // revokeSessionsOnPasswordReset: true expect( sqlite.prepare('SELECT COUNT(*) AS n FROM auth_sessions WHERE user_id = ?').get(userId).n ).toBe(0); await expect( auth.api.signInEmail({ body: { email: 'reset@test.py', password: 'BeforeReset1!x' } }) ).rejects.toThrow(); const after = await auth.api.signInEmail({ body: { email: 'reset@test.py', password: 'AfterReset1!x' }, }); expect(after.user.email).toBe('reset@test.py'); }); it('sessions are stored in auth_sessions with 7-day expiry', async () => { const si = await auth.api.signInEmail({ body: { email: 'admin@test.py', password: 'FirstAdmin1!x' }, }); const row = sqlite .prepare('SELECT expires_at FROM auth_sessions WHERE token = ?') .get((si as any).token); expect(row).toBeTruthy(); const days = (row.expires_at - Date.now()) / (1000 * 60 * 60 * 24); expect(days).toBeGreaterThan(6.5); expect(days).toBeLessThan(7.5); }); }); describe('Google sign-in and account linking', () => { it('links Google onto a guest-booking user instead of failing with "account not linked"', async () => { const id = insertBookingUser('booking-user-1', 'buyer@test.py'); expect(userRow('buyer@test.py').email_verified).toBe(0); const res = await signInWithGoogle({ email: 'buyer@test.py', sub: 'google-sub-buyer' }); expect(res.user.id).toBe(id); expect(googleAccountsFor(id)).toHaveLength(1); expect(sqlite.prepare('SELECT COUNT(*) AS n FROM users WHERE email = ?').get('buyer@test.py').n).toBe(1); }); it('claims the booking account so the resulting session is actually accepted', async () => { // getAuthUser() (lib/auth.ts) rejects any session whose user is not // 'active', so linking alone would leave the user looking logged out. const id = insertBookingUser('booking-user-2', 'buyer2@test.py'); const { headers, response } = await signInWithGoogle( { email: 'buyer2@test.py', sub: 'google-sub-buyer2' }, true ); expect(response.user.id).toBe(id); const row = userRow('buyer2@test.py'); expect(row.account_status).toBe('active'); expect(row.is_claimed).toBe(1); expect(row.email_verified).toBe(1); const session = await auth.api.getSession({ headers: cookieHeaders(headers.get('set-cookie')), }); expect(session?.user.id).toBe(id); expect((session?.user as any).accountStatus).toBe('active'); }); it('never reactivates a suspended account through a Google link', async () => { const now = new Date().toISOString(); sqlite .prepare( `INSERT INTO users (id, email, name, role, is_claimed, account_status, email_verified, banned, created_at, updated_at) VALUES (?, ?, 'Suspended', 'user', 1, 'suspended', 1, 1, ?, ?)` ) .run('suspended-google', 'suspended-google@test.py', now, now); await expect( signInWithGoogle({ email: 'suspended-google@test.py', sub: 'google-sub-suspended' }) ).rejects.toThrow(); expect(userRow('suspended-google@test.py').account_status).toBe('suspended'); }); it('links Google onto an email/password account created after the Better Auth migration', async () => { // Better Auth writes email_verified = 0 on sign-up (requireEmailVerification // is off), which used to be enough to block linking on its own. await auth.api.signUpEmail({ body: { email: 'pwuser@test.py', password: 'PwUserPass1!x', name: 'Pw User' }, }); expect(userRow('pwuser@test.py').email_verified).toBe(0); const id = userRow('pwuser@test.py').id; const res = await signInWithGoogle({ email: 'pwuser@test.py', sub: 'google-sub-pwuser' }); expect(res.user.id).toBe(id); expect(googleAccountsFor(id)).toHaveLength(1); // The credential account survives: they can still sign in with a password. const after = await auth.api.signInEmail({ body: { email: 'pwuser@test.py', password: 'PwUserPass1!x' }, }); expect(after.user.id).toBe(id); }); it('creates exactly one user for a brand-new Google address and reuses it on the next sign-in', async () => { const first = await signInWithGoogle({ email: 'fresh@test.py', sub: 'google-sub-fresh' }); const row = userRow('fresh@test.py'); expect(row.id).toBe(first.user.id); expect(row.email_verified).toBe(1); expect(row.account_status).toBe('active'); const second = await signInWithGoogle({ email: 'fresh@test.py', sub: 'google-sub-fresh' }); expect(second.user.id).toBe(first.user.id); expect(sqlite.prepare('SELECT COUNT(*) AS n FROM users WHERE email = ?').get('fresh@test.py').n).toBe(1); expect(googleAccountsFor(first.user.id)).toHaveLength(1); }); it('rejects an id token minted for a different client id', async () => { const token = await new SignJWT({ email: 'forged@test.py', email_verified: true, name: 'F' }) .setProtectedHeader({ alg: 'RS256', kid: GOOGLE_KID }) .setIssuer('https://accounts.google.com') .setAudience('some-other-app.apps.googleusercontent.com') .setSubject('google-sub-forged') .setIssuedAt() .setExpirationTime('10m') .sign(googlePrivateKey); await expect( auth.api.signInSocial({ body: { provider: 'google', idToken: { token } }, headers: new Headers(), } as any) ).rejects.toThrow(); expect(userRow('forged@test.py')).toBeUndefined(); }); }); describe('users.email normalization', () => { it('normalizes an address to its canonical stored form', () => { expect(normalizeEmail(' John@Example.COM ')).toBe('john@example.com'); }); it('lowercases legacy mixed-case rows on migrate, and reports collisions instead of merging', async () => { const now = new Date().toISOString(); const insert = (id: string, email: string, status = 'unclaimed') => sqlite .prepare( `INSERT INTO users (id, email, name, role, is_claimed, account_status, email_verified, created_at, updated_at) VALUES (?, ?, 'Legacy', 'user', 0, ?, 0, ?, ?)` ) .run(id, email, status, now, now); insert('legacy-mixed', 'John@Example.com'); // A pair that genuinely collides: the migration must leave both alone. insert('legacy-dup-lower', 'dup@example.com'); insert('legacy-dup-mixed', 'Dup@Example.com'); // The backfill lives in migrate.ts and is idempotent, so just re-run it. execFileSync('npx', ['tsx', 'src/db/migrate.ts'], { env: { ...process.env }, stdio: 'pipe' }); expect(userRow('john@example.com').id).toBe('legacy-mixed'); expect(userRow('John@Example.com')).toBeUndefined(); expect(userRow('Dup@Example.com').id).toBe('legacy-dup-mixed'); expect(userRow('dup@example.com').id).toBe('legacy-dup-lower'); // ...and the lowercased row is now reachable by Google sign-in. const res = await signInWithGoogle({ email: 'john@example.com', sub: 'google-sub-legacy' }); expect(res.user.id).toBe('legacy-mixed'); }, 120_000); });