From 6dfaaf465d5ce71c7ce0fd87d4c2cf6258061194 Mon Sep 17 00:00:00 2001 From: Samu Date: Sat, 29 Aug 2026 15:46:34 +0200 Subject: [PATCH] fix: map duplicate-username race to 409 and test session/cascade behavior --- server/src/routes/authRoutes.ts | 20 +++++++++++++++++-- server/test/authRoutes.test.ts | 35 +++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/server/src/routes/authRoutes.ts b/server/src/routes/authRoutes.ts index 30f384e..2ba047e 100644 --- a/server/src/routes/authRoutes.ts +++ b/server/src/routes/authRoutes.ts @@ -80,7 +80,15 @@ export async function registerAuthRoutes(app: FastifyInstance): Promise { const { username, password } = (request.body ?? {}) as { username?: string; password?: string } const invalid = validateCredentials(username, password) if (invalid) return reply.code(400).send({ error: 'invalid_input', detail: invalid }) - const user = createUser(request.server.db, username as string, await hashPassword(password as string), true) + let user + try { + user = createUser(request.server.db, username as string, await hashPassword(password as string), true) + } catch (err: any) { + if (String(err.message).includes('UNIQUE constraint failed')) { + return reply.code(403).send({ error: 'setup_already_done' }) + } + throw err + } request.server.db.prepare('INSERT INTO settings (user_id) VALUES (?)').run(user.id) const token = createSession(request.server.db, user.id) return reply.setCookie(COOKIE_NAME, token, cookieOpts()).code(200).send({ user: toPublicUser(user) }) @@ -122,7 +130,15 @@ export async function registerAuthRoutes(app: FastifyInstance): Promise { if (getUserByUsername(request.server.db, username as string)) { return reply.code(409).send({ error: 'username_taken' }) } - const user = createUser(request.server.db, username as string, await hashPassword(password as string), false) + let user + try { + user = createUser(request.server.db, username as string, await hashPassword(password as string), false) + } catch (err: any) { + if (String(err.message).includes('UNIQUE constraint failed')) { + return reply.code(409).send({ error: 'username_taken' }) + } + throw err + } request.server.db.prepare('INSERT INTO settings (user_id) VALUES (?)').run(user.id) return reply.code(200).send(toPublicUser(user)) }) diff --git a/server/test/authRoutes.test.ts b/server/test/authRoutes.test.ts index e2bc3f4..e35c0b9 100644 --- a/server/test/authRoutes.test.ts +++ b/server/test/authRoutes.test.ts @@ -71,6 +71,33 @@ describe('auth routes', () => { expect(res.statusCode).toBe(401) await app.close() }) + + it('re-login invalidates the previous session', async () => { + const app = await buildTestApp() + await setupAdmin(app) + const first = await app.inject({ + method: 'POST', + url: '/api/login', + payload: { username: 'admin', password: 'adminpass123' }, + }) + const firstCookie = getCookie(first) + + const second = await app.inject({ + method: 'POST', + url: '/api/login', + ...auth(firstCookie), + payload: { username: 'admin', password: 'adminpass123' }, + }) + expect(second.statusCode).toBe(200) + + const oldStale = await app.inject({ method: 'GET', url: '/api/me', ...auth(firstCookie) }) + expect(oldStale.statusCode).toBe(401) + + const newCookie = getCookie(second) + const fresh = await app.inject({ method: 'GET', url: '/api/me', ...auth(newCookie) }) + expect(fresh.statusCode).toBe(200) + await app.close() + }) }) describe('user admin', () => { @@ -114,11 +141,19 @@ describe('user admin', () => { it('cannot delete self; deleting another user works and cascades settings', async () => { const { app, cookie } = await adminApp() await loginAs(app, cookie, 'bob', 'bobpass123') + const settingsCount = () => + (app.db.prepare('SELECT COUNT(*) AS n FROM settings WHERE user_id = 2').get() as { n: number }).n + const sessionsCount = () => + (app.db.prepare('SELECT COUNT(*) AS n FROM sessions WHERE user_id = 2').get() as { n: number }).n + expect(settingsCount()).toBe(1) + expect(sessionsCount()).toBe(1) const selfDelete = await app.inject({ method: 'DELETE', url: '/api/users/1', ...auth(cookie) }) expect(selfDelete.statusCode).toBe(400) const del = await app.inject({ method: 'DELETE', url: '/api/users/2', ...auth(cookie) }) expect(del.statusCode).toBe(200) + expect(settingsCount()).toBe(0) + expect(sessionsCount()).toBe(0) const list = await app.inject({ method: 'GET', url: '/api/users', ...auth(cookie) }) expect(list.json().users).toHaveLength(1) await app.close()