mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
Add the user-facing account-security surface in Settings: - Active sessions (closes #64): GET /api/auth/me/sessions lists each browser login with a coarse device label, masked IP, resolved location, the sliding last-active stamp, and the current-session marker. DELETE the collection signs out everywhere else (keeps the current session, revokes other sessions and native refresh tokens, leaves API tokens alone); DELETE one revokes a single session, scoped to the owner so a foreign id 404s. - A sliding last-active stamp on the session, written throttled and fire-and-forget off the request-resolution path. - Security activity: GET /api/auth/me/security-activity, the shared read over the audit log filtered to sign-ins, second-factor changes, password changes, session revocations, exports, and deletions, with masked IPs. OpenAPI regenerated for the new endpoints.
132 lines
4.3 KiB
TypeScript
132 lines
4.3 KiB
TypeScript
/**
|
|
* Integration test — v1.23 active-session management + known-device ledger
|
|
* against a real Postgres (migration 0203 applied by the container harness).
|
|
*
|
|
* Covers the DB-layer contracts the unit mocks can't pin:
|
|
* - destroyOtherSessions keeps the current session, deletes the rest, revokes
|
|
* native refresh tokens, and leaves API tokens untouched.
|
|
* - destroySessionById is scoped to the owning user (no cross-user delete).
|
|
* - the (userId, deviceHash) unique index enforces the login-alert dedupe.
|
|
*/
|
|
import { beforeEach, describe, expect, it } from "vitest";
|
|
|
|
import { destroyOtherSessions, destroySessionById } from "@/lib/auth/session";
|
|
import { getPrismaClient, truncateAllTables } from "./setup";
|
|
|
|
beforeEach(async () => {
|
|
await truncateAllTables(getPrismaClient());
|
|
});
|
|
|
|
async function makeUser(username: string) {
|
|
return getPrismaClient().user.create({
|
|
data: { username, email: `${username}@example.test` },
|
|
});
|
|
}
|
|
|
|
describe("destroyOtherSessions", () => {
|
|
it("keeps the current session, removes the others, revokes refresh tokens, keeps API tokens", async () => {
|
|
const prisma = getPrismaClient();
|
|
const user = await makeUser("sess-owner");
|
|
|
|
const current = await prisma.session.create({
|
|
data: { userId: user.id, expiresAt: new Date(Date.now() + 1e6) },
|
|
});
|
|
await prisma.session.create({
|
|
data: { userId: user.id, expiresAt: new Date(Date.now() + 1e6) },
|
|
});
|
|
await prisma.session.create({
|
|
data: { userId: user.id, expiresAt: new Date(Date.now() + 1e6) },
|
|
});
|
|
const refresh = await prisma.refreshToken.create({
|
|
data: {
|
|
userId: user.id,
|
|
tokenHash: "rt-hash-1",
|
|
expiresAt: new Date(Date.now() + 1e6),
|
|
},
|
|
});
|
|
const apiToken = await prisma.apiToken.create({
|
|
data: {
|
|
userId: user.id,
|
|
name: "automation",
|
|
tokenHash: "at-hash-1",
|
|
permissions: ["*"],
|
|
},
|
|
});
|
|
|
|
const result = await destroyOtherSessions(user.id, current.id);
|
|
expect(result.sessionsRevoked).toBe(2);
|
|
|
|
const remaining = await prisma.session.findMany({
|
|
where: { userId: user.id },
|
|
});
|
|
expect(remaining).toHaveLength(1);
|
|
expect(remaining[0].id).toBe(current.id);
|
|
|
|
const rt = await prisma.refreshToken.findUnique({
|
|
where: { id: refresh.id },
|
|
});
|
|
expect(rt?.revokedAt).not.toBeNull();
|
|
|
|
const at = await prisma.apiToken.findUnique({ where: { id: apiToken.id } });
|
|
expect(at?.revoked).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("destroySessionById", () => {
|
|
it("revokes an owned session and refuses a cross-user id", async () => {
|
|
const prisma = getPrismaClient();
|
|
const owner = await makeUser("owner");
|
|
const other = await makeUser("intruder");
|
|
|
|
const ownerSession = await prisma.session.create({
|
|
data: { userId: owner.id, expiresAt: new Date(Date.now() + 1e6) },
|
|
});
|
|
const otherSession = await prisma.session.create({
|
|
data: { userId: other.id, expiresAt: new Date(Date.now() + 1e6) },
|
|
});
|
|
|
|
// Cross-user attempt: owner tries to delete the intruder's session.
|
|
expect(await destroySessionById(owner.id, otherSession.id)).toBe(false);
|
|
expect(
|
|
await prisma.session.findUnique({ where: { id: otherSession.id } }),
|
|
).not.toBeNull();
|
|
|
|
// Owned delete succeeds.
|
|
expect(await destroySessionById(owner.id, ownerSession.id)).toBe(true);
|
|
expect(
|
|
await prisma.session.findUnique({ where: { id: ownerSession.id } }),
|
|
).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("UserKnownDevice unique index", () => {
|
|
it("dedupes on (userId, deviceHash) — the same fingerprint cannot insert twice", async () => {
|
|
const prisma = getPrismaClient();
|
|
const user = await makeUser("device-owner");
|
|
|
|
await prisma.userKnownDevice.create({
|
|
data: {
|
|
userId: user.id,
|
|
deviceHash: "hash-abc",
|
|
label: "Firefox on macOS",
|
|
},
|
|
});
|
|
|
|
await expect(
|
|
prisma.userKnownDevice.create({
|
|
data: { userId: user.id, deviceHash: "hash-abc" },
|
|
}),
|
|
).rejects.toThrow();
|
|
|
|
// A different hash for the same user inserts fine.
|
|
await prisma.userKnownDevice.create({
|
|
data: { userId: user.id, deviceHash: "hash-def" },
|
|
});
|
|
|
|
const rows = await prisma.userKnownDevice.findMany({
|
|
where: { userId: user.id },
|
|
});
|
|
expect(rows).toHaveLength(2);
|
|
});
|
|
});
|