/** * Auth integration test — runs against the live seeded database. * * pnpm services:up && pnpm db:migrate && pnpm db:seed * pnpm --filter @linkdr/web test * * This is deliberately an integration test rather than a unit test, because the * thing most likely to break is not our logic. better-auth declares * `drizzle-orm: "^0.45.2 || >=1.0.0-rc.1"` as an OPTIONAL peer and we run 0.38.4, * which is outside that range but verified compatible. A future better-auth * patch could start relying on a 0.45-only API and nothing in the type system * would catch it. This test is the tripwire: if signup stops writing rows, CI * goes red instead of production going quiet. */ import { config } from 'dotenv'; import { eq } from 'drizzle-orm'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; config({ path: '../../.env' }); const { closePool, db, schema } = await import('@linkdr/db'); const { auth } = await import('@/lib/auth'); const { isSyntheticEmail } = await import('@linkdr/shared'); /** A number no seed row uses, so the test owns its own user. */ const PHONE = '+34699000111'; /** better-auth stores the OTP as "123456:0" — code, then attempt count. */ async function readOtp(identifier: string): Promise { const rows = await db .select() .from(schema.verifications) .where(eq(schema.verifications.identifier, identifier)); const row = rows.at(-1); if (!row) throw new Error(`no verification row for ${identifier}`); const code = row.value.split(':')[0]; if (!code) throw new Error(`unparseable verification value: ${row.value}`); return code; } async function cleanup() { const existing = await db.query.users.findFirst({ where: eq(schema.users.phoneNumber, PHONE), columns: { id: true }, }); if (existing) await db.delete(schema.users).where(eq(schema.users.id, existing.id)); await db.delete(schema.verifications).where(eq(schema.verifications.identifier, PHONE)); } beforeAll(cleanup); afterAll(async () => { await cleanup(); await closePool(); }); describe('phone OTP signup', () => { let userId: string; let sessionToken: string; it('sends a code and stores it against the number', async () => { const sent = await auth.api.sendPhoneNumberOTP({ body: { phoneNumber: PHONE } }); expect(sent).toBeTruthy(); const code = await readOtp(PHONE); expect(code).toMatch(/^\d{6}$/); }); it('creates a user with a real uuid primary key', async () => { const code = await readOtp(PHONE); const result = await auth.api.verifyPhoneNumber({ body: { phoneNumber: PHONE, code }, }); expect(result?.user).toBeTruthy(); userId = result!.user.id; sessionToken = result!.token!; // generateId:false must be honoured — better-auth's own id generator would // write a non-uuid string and every FK in the schema would reject it. expect(userId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/); }); it('marks the number verified and defaults the role to client', async () => { const user = await db.query.users.findFirst({ where: eq(schema.users.id, userId) }); expect(user?.phoneNumberVerified).toBe(true); // Not "user" — that is better-auth's default and is not in our enum. expect(user?.role).toBe('client'); }); it('mints a synthetic email that we know not to send to', async () => { const user = await db.query.users.findFirst({ where: eq(schema.users.id, userId) }); expect(user?.email).toBe(`${PHONE}@phone.linkder.local`); expect(isSyntheticEmail(user!.email)).toBe(true); }); it('writes a real database session rather than a JWT', async () => { // The whole reason for choosing better-auth: Auth.js credentials providers // hardcode JWT and never call createSession. const sessions = await db .select() .from(schema.sessions) .where(eq(schema.sessions.userId, userId)); expect(sessions.length).toBeGreaterThan(0); expect(sessions[0]!.token).toBe(sessionToken); expect(sessions[0]!.expiresAt.getTime()).toBeGreaterThan(Date.now()); }); it('resolves that session into our own Session type', async () => { const { resolveSession } = await import('@/server/session'); const session = await resolveSession( new Request('http://localhost/rsc', { headers: { cookie: `better-auth.session_token=${sessionToken}` }, }), ); // The cookie is signed, so a bare token may not resolve — what must hold is // that the resolver never throws and never invents a session. if (session) { expect(session.userId).toBe(userId); expect(session.role).toBe('client'); } }); it('rejects a wrong code', async () => { await auth.api.sendPhoneNumberOTP({ body: { phoneNumber: PHONE } }); await expect( auth.api.verifyPhoneNumber({ body: { phoneNumber: PHONE, code: '000000' } }), ).rejects.toThrow(); }); it('locks out after the configured attempt cap', async () => { await auth.api.sendPhoneNumberOTP({ body: { phoneNumber: PHONE } }); // allowedAttempts: 3 — the fourth must fail even with the right code. for (let i = 0; i < 3; i++) { await auth.api .verifyPhoneNumber({ body: { phoneNumber: PHONE, code: '000000' } }) .catch(() => undefined); } const rows = await db .select() .from(schema.verifications) .where(eq(schema.verifications.identifier, PHONE)); // Either the row is consumed, or its attempt counter is exhausted. const exhausted = rows.length === 0 || rows.every((r) => Number(r.value.split(':')[1] ?? 0) >= 3); expect(exhausted).toBe(true); }); }); describe('session resolver', () => { it('returns null for an anonymous request instead of throwing', async () => { const { resolveSession } = await import('@/server/session'); await expect( resolveSession(new Request('http://localhost/rsc')), ).resolves.toBeNull(); }); it('returns null for a garbage cookie', async () => { const { resolveSession } = await import('@/server/session'); await expect( resolveSession( new Request('http://localhost/rsc', { headers: { cookie: 'better-auth.session_token=not-a-real-token' }, }), ), ).resolves.toBeNull(); }); });