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>
520 lines
21 KiB
TypeScript
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);
|
|
});
|