diff --git a/src/web/api/session.ts b/src/web/api/session.ts index d45811f..e595b89 100644 --- a/src/web/api/session.ts +++ b/src/web/api/session.ts @@ -88,11 +88,15 @@ export function createSessionRouter( } const { token } = sessions.createSession(user.id); setSessionCookie(res, token); - audit.record({ - actorId: user.id, actorUsername: user.username, - targetUserId: user.id, targetUsername: user.username, - action: "admin.first_created", - }); + try { + audit.record({ + actorId: user.id, actorUsername: user.username, + targetUserId: user.id, targetUsername: user.username, + action: "admin.first_created", + }); + } catch (auditErr) { + logger.warn({ err: auditErr, action: "admin.first_created" }, "audit insert failed"); + } logger.info({ userId: user.id, username }, "First admin created"); res.json({ id: user.id, username: user.username }); } catch (err) { @@ -151,11 +155,15 @@ export function createSessionRouter( await users.changePassword(u.id, newPassword); const currentToken = parseTokenFromCookie(req.headers.cookie); sessions.deleteAllForUser(u.id, currentToken ?? undefined); - audit.record({ - actorId: u.id, actorUsername: u.username, - targetUserId: u.id, targetUsername: u.username, - action: "user.password_changed", - }); + try { + audit.record({ + actorId: u.id, actorUsername: u.username, + targetUserId: u.id, targetUsername: u.username, + action: "user.password_changed", + }); + } catch (auditErr) { + logger.warn({ err: auditErr, action: "user.password_changed" }, "audit insert failed"); + } res.status(204).end(); }); diff --git a/src/web/api/users.test.ts b/src/web/api/users.test.ts index b9c9744..f2d8742 100644 --- a/src/web/api/users.test.ts +++ b/src/web/api/users.test.ts @@ -137,4 +137,56 @@ describe("users router", () => { .send({ newPassword: "short" }); expect(res.status).toBe(400); }); + + it("returns 201 even if audit insert fails (POST /api/users)", async () => { + // Build a broken audit store that throws on record() + const brokenAudit = { + record: () => { throw new Error("simulated disk-full"); }, + list: () => [], + }; + // Reassemble app with the broken audit + const localApp = express(); + localApp.use(express.json()); + localApp.use(cookieParser()); + localApp.use("/api", createRequireAuth(sessions)); + localApp.use( + "/api/users", + createUsersRouter(users, sessions, brokenAudit, pino({ level: "silent" })) + ); + const res = await request(localApp) + .post("/api/users") + .set("Cookie", aliceCookie) + .send({ username: "charlie", password: "charlie-pw" }); + expect(res.status).toBe(201); + expect(users.countUsers()).toBe(3); + }); + + it("POST /:id/reset-password on self preserves the actor's current session", async () => { + // Alice resets her OWN password + const res = await request(app) + .post(`/api/users/${aliceId}/reset-password`) + .set("Cookie", aliceCookie) + .send({ newPassword: "alice-new-pw" }); + expect(res.status).toBe(204); + + // Alice's CURRENT session should still work + // (we'd need a protected endpoint to verify; use GET /api/users which is already mounted) + const followUp = await request(app).get("/api/users").set("Cookie", aliceCookie); + expect(followUp.status).toBe(200); + + // The password hash IS updated (sanity check) + const alice = users.findById(aliceId); + expect(await users.verifyPassword("alice-new-pw", alice!.passwordHash)).toBe(true); + }); + + it("POST /:id/reset-password on another user does NOT preserve any of target's sessions", async () => { + const bobToken = sessions.createSession(bobId).token; + const res = await request(app) + .post(`/api/users/${bobId}/reset-password`) + .set("Cookie", aliceCookie) + .send({ newPassword: "bob-new-pw" }); + expect(res.status).toBe(204); + // Bob's session should be dead + expect(sessions.validateAndTouch(bobToken)).toBeNull(); + }); }); diff --git a/src/web/api/users.ts b/src/web/api/users.ts index 79e89dc..0204f16 100644 --- a/src/web/api/users.ts +++ b/src/web/api/users.ts @@ -4,6 +4,7 @@ import type { UserStore } from "../../data/users.js"; import { UsernameTakenError } from "../../data/users.js"; import type { SessionStore } from "../../data/sessions.js"; import type { AuditStore } from "../../data/audit.js"; +import { extractSessionToken } from "../auth/validateSession.js"; function isValidUsername(v: unknown): v is string { return typeof v === "string" && /^[A-Za-z0-9_\-.]{3,32}$/.test(v); @@ -33,11 +34,15 @@ export function createUsersRouter( } try { const u = await users.createUser(username, password); - audit.record({ - actorId: req.user!.id, actorUsername: req.user!.username, - targetUserId: u.id, targetUsername: u.username, - action: "user.created", - }); + try { + audit.record({ + actorId: req.user!.id, actorUsername: req.user!.username, + targetUserId: u.id, targetUsername: u.username, + action: "user.created", + }); + } catch (auditErr) { + logger.warn({ err: auditErr, action: "user.created" }, "audit insert failed"); + } logger.info({ createdBy: req.user!.id, newUserId: u.id, username }, "User created"); res.status(201).json({ id: u.id, username: u.username }); } catch (err) { @@ -67,11 +72,15 @@ export function createUsersRouter( return; } sessions.deleteAllForUser(targetId); - audit.record({ - actorId: req.user!.id, actorUsername: req.user!.username, - targetUserId: target.id, targetUsername: target.username, - action: "user.deleted", - }); + try { + audit.record({ + actorId: req.user!.id, actorUsername: req.user!.username, + targetUserId: target.id, targetUsername: target.username, + action: "user.deleted", + }); + } catch (auditErr) { + logger.warn({ err: auditErr, action: "user.deleted" }, "audit insert failed"); + } logger.info({ deletedBy: req.user!.id, deletedUserId: targetId }, "User deleted"); res.status(204).end(); }); @@ -90,13 +99,19 @@ export function createUsersRouter( } await users.changePassword(targetId, newPassword); // Invalidate all sessions for the target user (except current actor's if it's the same user) - const exceptToken = targetId === req.user!.id ? undefined : undefined; + const exceptToken = targetId === req.user!.id + ? (extractSessionToken(req.headers.cookie) ?? undefined) + : undefined; sessions.deleteAllForUser(targetId, exceptToken); - audit.record({ - actorId: req.user!.id, actorUsername: req.user!.username, - targetUserId: target.id, targetUsername: target.username, - action: "user.password_reset", - }); + try { + audit.record({ + actorId: req.user!.id, actorUsername: req.user!.username, + targetUserId: target.id, targetUsername: target.username, + action: "user.password_reset", + }); + } catch (auditErr) { + logger.warn({ err: auditErr, action: "user.password_reset" }, "audit insert failed"); + } logger.info({ resetBy: req.user!.id, targetUserId: targetId }, "Password reset"); res.status(204).end(); });