mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
The mocked route tests for the session list and revoke, the device list and revoke, the security-activity feed and the FHIR surface move onto the kit. Each claim is now shown on the stored rows and on the next request: a revoked session, device login or token is refused when it is presented again, another account's session or device answers 404 and stays signed in, the current-session and current-device flags follow the credential that anchors them, and no row id, full IP or push token reaches a response. The FHIR read set, covered against a database only by the scope check until now, is driven with seeded readings and medications: every resource carries the seeded values, another account's rows never appear, a fhir:read token reaches every data route and an unrelated narrow token is refused, the module gate closes all eleven data routes, and $everything serves the same entries as the document builder with references that resolve inside the response. The module-gate inventory now names the new file as its behavioural proof.
152 lines
4.8 KiB
TypeScript
152 lines
4.8 KiB
TypeScript
/**
|
|
* `GET /api/auth/me/security-activity`: the account's recent security events,
|
|
* against the real database and through the real API kit.
|
|
*
|
|
* What a person relies on here: the feed shows their own sign-in, export and
|
|
* erasure events and nothing else, newest first in a total order, with the
|
|
* IP masked and no event detail body; the page is capped at the requested
|
|
* limit and says when more rows exist; a limit outside 1-100 is a 422 that
|
|
* names it rather than a silent clamp.
|
|
*/
|
|
import { beforeEach, describe, expect, it } from "vitest";
|
|
|
|
import { GET } from "@/app/api/auth/me/security-activity/route";
|
|
import type { User } from "@/generated/prisma/client";
|
|
|
|
import { anonymous, callRoute, type RouteAuth } from "../../kit/call-route";
|
|
import { makeSession, makeUser } from "../../kit/factories";
|
|
import { getPrismaClient, truncateAllTables } from "../setup";
|
|
|
|
const prisma = getPrismaClient();
|
|
|
|
type SecurityEvent = {
|
|
action: string;
|
|
createdAt: string;
|
|
location: string | null;
|
|
ipMasked: string | null;
|
|
carrier: string | null;
|
|
};
|
|
|
|
beforeEach(async () => {
|
|
await truncateAllTables(prisma);
|
|
});
|
|
|
|
async function audit(
|
|
user: Pick<User, "id">,
|
|
action: string,
|
|
createdAt: Date,
|
|
extra: { ipAddress?: string; location?: string; carrier?: string } = {},
|
|
) {
|
|
return prisma.auditLog.create({
|
|
data: {
|
|
userId: user.id,
|
|
action,
|
|
createdAt,
|
|
details: JSON.stringify({ secretish: "detail body" }),
|
|
...extra,
|
|
},
|
|
});
|
|
}
|
|
|
|
async function feed(auth: RouteAuth, limit?: number | string) {
|
|
return callRoute<{ events: SecurityEvent[] }>(GET, {
|
|
method: "GET",
|
|
route: "/api/auth/me/security-activity",
|
|
query: { limit },
|
|
auth,
|
|
});
|
|
}
|
|
|
|
describe("GET /api/auth/me/security-activity", () => {
|
|
it("answers 401 without a credential", async () => {
|
|
expect((await feed(anonymous)).status).toBe(401);
|
|
});
|
|
|
|
it("shows the caller's security events newest first, masked, without detail bodies, and nothing else", async () => {
|
|
const owner = await makeUser();
|
|
const other = await makeUser();
|
|
await audit(
|
|
owner,
|
|
"auth.login.password",
|
|
new Date("2026-05-01T08:00:00Z"),
|
|
{
|
|
ipAddress: "203.0.113.7",
|
|
location: "Berlin, DE",
|
|
carrier: "Example Carrier",
|
|
},
|
|
);
|
|
await audit(owner, "export.download", new Date("2026-05-02T08:00:00Z"));
|
|
await audit(owner, "user.data.clear", new Date("2026-05-03T08:00:00Z"));
|
|
await audit(owner, "measurement.create", new Date("2026-05-04T08:00:00Z"));
|
|
await audit(other, "auth.login.password", new Date("2026-05-05T08:00:00Z"));
|
|
|
|
const res = await feed(await makeSession(owner));
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.meta).toEqual({ truncated: false, limit: 50 });
|
|
expect(res.data.events).toEqual([
|
|
{
|
|
action: "user.data.clear",
|
|
createdAt: "2026-05-03T08:00:00.000Z",
|
|
location: null,
|
|
ipMasked: null,
|
|
carrier: null,
|
|
},
|
|
{
|
|
action: "export.download",
|
|
createdAt: "2026-05-02T08:00:00.000Z",
|
|
location: null,
|
|
ipMasked: null,
|
|
carrier: null,
|
|
},
|
|
{
|
|
action: "auth.login.password",
|
|
createdAt: "2026-05-01T08:00:00.000Z",
|
|
location: "Berlin, DE",
|
|
ipMasked: "203.0.x.x",
|
|
carrier: "Example Carrier",
|
|
},
|
|
]);
|
|
const serialised = JSON.stringify(res.body);
|
|
expect(serialised).not.toContain("203.0.113.7");
|
|
expect(serialised).not.toContain("detail body");
|
|
});
|
|
|
|
it("caps the page at the limit, says more exist, and breaks a timestamp tie by id", async () => {
|
|
const owner = await makeUser();
|
|
const same = new Date("2026-05-01T08:00:00Z");
|
|
const rows = [
|
|
await audit(owner, "auth.login.password", same),
|
|
await audit(owner, "auth.login.passkey", same),
|
|
await audit(owner, "auth.logout", same),
|
|
];
|
|
const byIdDesc = [...rows].sort((a, b) => (a.id < b.id ? 1 : -1));
|
|
const session = await makeSession(owner);
|
|
|
|
const capped = await feed(session, 2);
|
|
expect(capped.status).toBe(200);
|
|
expect(capped.meta).toEqual({ truncated: true, limit: 2 });
|
|
expect(capped.data.events.map((e) => e.action)).toEqual(
|
|
byIdDesc.slice(0, 2).map((r) => r.action),
|
|
);
|
|
|
|
const exact = await feed(session, 3);
|
|
expect(exact.meta).toEqual({ truncated: false, limit: 3 });
|
|
expect(exact.data.events.map((e) => e.action)).toEqual(
|
|
byIdDesc.map((r) => r.action),
|
|
);
|
|
});
|
|
|
|
it("answers a limit past the published ceiling with a 422 naming it", async () => {
|
|
const owner = await makeUser();
|
|
|
|
const res = await feed(await makeSession(owner), 500);
|
|
|
|
expect(res.status).toBe(422);
|
|
expect(res.meta?.errorCode).toBe("security_activity.invalid");
|
|
expect(res.body).toMatchObject({
|
|
details: { issues: [expect.objectContaining({ path: "limit" })] },
|
|
});
|
|
});
|
|
});
|