import assert from 'node:assert/strict'; import { after, before, describe, it } from 'node:test'; import { AuthProvider } from '@prisma/client'; import { prisma } from '../src/lib/prisma.js'; import { GENERIC_CREDENTIALS_MESSAGE } from '../src/modules/auth/auth.service.js'; import { isArgon2idHash } from '../src/modules/auth/password.js'; import { hashSessionToken } from '../src/modules/auth/sessionToken.js'; import { assertNoSecrets, authHeader, createTestApp, isDatabaseAvailable, requestJson, } from './helpers.js'; const app = createTestApp(); const createdUserIds: bigint[] = []; async function registerUser(email: string, password: string) { return requestJson(app, '/auth/register', { method: 'POST', body: JSON.stringify({ email, password }), }); } describe('authentication API', () => { let dbAvailable = false; before(async () => { dbAvailable = await isDatabaseAvailable(); }); after(async () => { if (dbAvailable && createdUserIds.length > 0) { await prisma.user.deleteMany({ where: { id: { in: createdUserIds } } }); } }); async function trackUser(externalId: string | undefined) { if (!externalId) { return; } const user = await prisma.user.findUnique({ where: { externalId } }); if (user) { createdUserIds.push(user.id); } } it('registers a valid email/password user', async (t) => { if (!dbAvailable) { t.skip('MySQL is not running'); return; } const email = `reg-${Date.now()}@example.com`; const { status, body, raw } = await registerUser(email, 'password12'); assert.equal(status, 201); assert.equal(body.success, true); assert.equal(body.data?.user && (body.data.user as { email: string }).email, email); assert.equal((body.data?.user as { isGuest: boolean }).isGuest, false); assert.equal((body.data?.user as { profileComplete: boolean }).profileComplete, false); assert.equal(typeof body.data?.sessionToken, 'string'); assertNoSecrets(raw); await trackUser((body.data?.user as { id: string }).id); const identity = await prisma.authIdentity.findFirst({ where: { email, provider: AuthProvider.password }, }); assert.ok(identity?.passwordHash); assert.equal(isArgon2idHash(identity.passwordHash), true); assert.notEqual(identity.passwordHash, 'password12'); }); it('rejects duplicate email', async (t) => { if (!dbAvailable) { t.skip('MySQL is not running'); return; } const email = `dup-${Date.now()}@example.com`; const first = await registerUser(email, 'password12'); await trackUser((first.body.data?.user as { id: string }).id); const second = await registerUser(email.toUpperCase(), 'password12'); assert.equal(second.status, 409); assert.equal(second.body.error?.code, 'CONFLICT'); assertNoSecrets(second.raw); }); it('rejects invalid email', async () => { const { status, body, raw } = await registerUser('not-an-email', 'password12'); assert.equal(status, 422); assert.equal(body.error?.code, 'VALIDATION_ERROR'); assertNoSecrets(raw); }); it('rejects invalid password', async () => { const { status, body, raw } = await registerUser('short@example.com', 'short'); assert.equal(status, 422); assert.equal(body.error?.code, 'VALIDATION_ERROR'); assertNoSecrets(raw); }); it('does not return password on registration', async (t) => { if (!dbAvailable) { t.skip('MySQL is not running'); return; } const email = `nopw-${Date.now()}@example.com`; const { raw, body } = await registerUser(email, 'password12'); await trackUser((body.data?.user as { id: string }).id); assert.doesNotMatch(raw, /password/i); assertNoSecrets(raw); }); it('logs in with valid credentials', async (t) => { if (!dbAvailable) { t.skip('MySQL is not running'); return; } const email = `login-${Date.now()}@example.com`; const registered = await registerUser(email, 'password12'); await trackUser((registered.body.data?.user as { id: string }).id); const { status, body, raw } = await requestJson(app, '/auth/login', { method: 'POST', body: JSON.stringify({ email, password: 'password12' }), }); assert.equal(status, 200); assert.equal(typeof body.data?.sessionToken, 'string'); assert.equal((body.data?.user as { email: string }).email, email); assertNoSecrets(raw); }); it('rejects wrong password and unknown email with the same message', async (t) => { if (!dbAvailable) { t.skip('MySQL is not running'); return; } const email = `enum-${Date.now()}@example.com`; const registered = await registerUser(email, 'password12'); await trackUser((registered.body.data?.user as { id: string }).id); const wrongPassword = await requestJson(app, '/auth/login', { method: 'POST', body: JSON.stringify({ email, password: 'wrong-password' }), }); const unknownEmail = await requestJson(app, '/auth/login', { method: 'POST', body: JSON.stringify({ email: `missing-${Date.now()}@example.com`, password: 'password12' }), }); assert.equal(wrongPassword.status, 401); assert.equal(unknownEmail.status, 401); assert.equal(wrongPassword.body.error?.code, 'INVALID_CREDENTIALS'); assert.equal(unknownEmail.body.error?.code, 'INVALID_CREDENTIALS'); assert.equal(wrongPassword.body.error?.message, GENERIC_CREDENTIALS_MESSAGE); assert.equal(unknownEmail.body.error?.message, GENERIC_CREDENTIALS_MESSAGE); assert.equal(wrongPassword.body.error?.message, unknownEmail.body.error?.message); }); it('authenticates /auth/me with a valid token', async (t) => { if (!dbAvailable) { t.skip('MySQL is not running'); return; } const email = `me-${Date.now()}@example.com`; const registered = await registerUser(email, 'password12'); await trackUser((registered.body.data?.user as { id: string }).id); const token = registered.body.data?.sessionToken as string; const { status, body, raw } = await requestJson(app, '/auth/me', { headers: authHeader(token), }); assert.equal(status, 200); assert.equal((body.data as { email: string }).email, email); assert.equal((body.data as { isGuest: boolean }).isGuest, false); assert.doesNotMatch(raw, /sessionToken/); assertNoSecrets(raw); }); it('rejects a missing bearer token', async () => { const missing = await requestJson(app, '/auth/me'); assert.equal(missing.status, 401); assert.equal(missing.body.error?.code, 'UNAUTHORIZED'); assertNoSecrets(missing.raw); }); it('rejects missing, invalid, expired, and revoked sessions', async (t) => { if (!dbAvailable) { t.skip('MySQL is not running'); return; } const missing = await requestJson(app, '/auth/me'); assert.equal(missing.status, 401); const invalid = await requestJson(app, '/auth/me', { headers: authHeader('not-a-valid-token'), }); assert.equal(invalid.status, 401); const email = `sess-${Date.now()}@example.com`; const registered = await registerUser(email, 'password12'); const token = registered.body.data?.sessionToken as string; await trackUser((registered.body.data?.user as { id: string }).id); const session = await prisma.authSession.findUnique({ where: { tokenHash: hashSessionToken(token) }, }); assert.ok(session); await prisma.authSession.update({ where: { id: session.id }, data: { expiresAt: new Date(Date.now() - 1000) }, }); const expired = await requestJson(app, '/auth/me', { headers: authHeader(token) }); assert.equal(expired.status, 401); const second = await registerUser(`sess2-${Date.now()}@example.com`, 'password12'); await trackUser((second.body.data?.user as { id: string }).id); const liveToken = second.body.data?.sessionToken as string; const logout = await requestJson(app, '/auth/logout', { method: 'POST', headers: authHeader(liveToken), }); assert.equal(logout.status, 200); const revoked = await requestJson(app, '/auth/me', { headers: authHeader(liveToken) }); assert.equal(revoked.status, 401); const repeatedLogout = await requestJson(app, '/auth/logout', { method: 'POST', headers: authHeader(liveToken), }); assert.equal(repeatedLogout.status, 401); }); it('creates a guest user with a session', async (t) => { if (!dbAvailable) { t.skip('MySQL is not running'); return; } const { status, body, raw } = await requestJson(app, '/auth/guest', { method: 'POST' }); assert.equal(status, 201); assert.equal((body.data?.user as { isGuest: boolean }).isGuest, true); assert.equal((body.data?.user as { email: string | null }).email, null); assert.equal(typeof body.data?.sessionToken, 'string'); assert.doesNotMatch((body.data?.user as { id: string }).id, /^guest-/); assertNoSecrets(raw); await trackUser((body.data?.user as { id: string }).id); const me = await requestJson(app, '/auth/me', { headers: authHeader(body.data?.sessionToken as string), }); assert.equal(me.status, 200); assert.equal((me.body.data as { isGuest: boolean }).isGuest, true); }); });