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'; // 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 delete process.env.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]); } 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); })(); }, 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); }); });