Files
Spanglish/backend/src/lib/betterAuth.integration.test.ts
T
MichilisandClaude Opus 5 87bf9a6151 Fix Google sign-in for existing email and ticket-buyer accounts.
Google sign-in only worked for people who already had a linked google
row in auth_accounts. Anyone who first appeared another way -- a guest
ticket purchase, or an email/password signup made after the Better Auth
migration -- got a 401 "account not linked".

trustedProviders: ['google'] defeats only one of better-auth's two
linking gates. The second, requireLocalEmailVerified, defaults to true
and refuses the link whenever the LOCAL users.email_verified is false,
independently of whether the provider is trusted. That flag is false for
every guest-booking row and for every post-migration signup, since
requireEmailVerification is off and no verification mail is sent.

Turn that gate off: the Google id_token is signature-verified against
Google's JWKS with issuer/audience/max-age checks and carries its own
email_verified, so the local column proves nothing extra here.

Linking alone was not enough. getAuthUser() rejects any session whose
user is not 'active', so a ticket buyer would link Google, receive a
cookie, and still look logged out. A databaseHooks.account.create.after
hook now promotes unclaimed rows to claimed/active when a google account
is attached, scoped in the WHERE clause so a suspended account is never
reactivated this way.

Also normalize users.email. The unique index is case-sensitive while
better-auth lowercases every lookup, so someone who booked as
John@Gmail.com was invisible to sign-in and Google minted a SECOND user
row, stranding their tickets on the first. normalizeEmail() covers the
find-or-create sites in tickets.ts and door.ts plus the claim-eligibility
lookup, and an idempotent migration lowercases existing rows -- skipping
any that would collide and reporting those for manual merge, since
merging two people's tickets and payments is not a migration's call.
tickets.attendeeEmail still stores the address exactly as typed.

Tests drive the real signInSocial id-token path with Google stubbed by
signing tokens with a throwaway RS256 key and serving our own JWKS, so
the actual verification runs without network or credentials. That also
makes the deprecation risk loud: requireLocalEmailVerified is marked for
removal upstream, and an upgrade that drops it now fails CI instead of
silently locking ticket buyers out again.

Frontend carries error.code through so OAUTH_LINK_ERROR renders an
actionable message in both locales rather than a bare "account not
linked".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 05:31:02 +00:00

520 lines
21 KiB
TypeScript

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<typeof googleIdToken>[0],
returnHeaders = false
): Promise<any> {
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);
});