Completes securing the web front end (vrek gol-wqf95dq, iss-r5vrjx7). - /login: Server Action with a generic error, same-site-only redirect back to ?next=, and throttling of failed logins (10 per address and 100 overall per 15 min). The header shows "Signed in as" with Sign out (iss-nj9wmwp). - First run with no admin: instrumentation prints a one-time setup code, shared with the app through globalThis. /setup requires it, and 5 wrong codes rotate it. "Skip for now" runs unsecured for the browser session behind a red warning banner on every page (iss-9nxdndr). - src/proxy.ts: optimistic redirects to /login or /setup, 401 for the API, and the 12 h sliding session refresh. requirePageAccess() and apiAccessDenied() re-check in the page and all 6 route handlers (iss-76d5wrb). - An expired session now shows "Your session has ended" instead of the camera-login form. - README documents in-app setup, skipping and signing in. Verified with unit tests (383, 99.9% line coverage), end to end against `next start`, and manually in a browser by the user. Refreshes the vrek export. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
60 lines
2.4 KiB
TypeScript
60 lines
2.4 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
/**
|
|
* Every API handler must refuse a signed-out request before doing anything else
|
|
* (vrek iss-76d5wrb). The camera and store modules are replaced with spies that must never
|
|
* be reached.
|
|
*/
|
|
const denied = vi.fn<() => Promise<Response | null>>();
|
|
vi.mock("@/lib/access", () => ({ apiAccessDenied: denied }));
|
|
|
|
const touched = vi.fn();
|
|
const trap = () => touched();
|
|
vi.mock("@/lib/camera", () => ({
|
|
getCameraInfo: trap,
|
|
getSnapshot: trap,
|
|
testCredentials: trap,
|
|
resetConnection: trap,
|
|
}));
|
|
vi.mock("@/lib/camera-route", () => ({ cameraTarget: trap, cameraErrorResponse: trap }));
|
|
vi.mock("@/lib/credential-store", () => ({
|
|
credentialsSchema: { safeParse: trap },
|
|
setCredentials: trap,
|
|
deleteCredentials: trap,
|
|
describeCredentials: trap,
|
|
}));
|
|
vi.mock("@/lib/onvif", () => ({ discoverCameras: trap, discoverRequestSchema: { safeParse: trap } }));
|
|
vi.mock("@/lib/camera-registry", () => ({ recordDiscovered: trap }));
|
|
|
|
const info = await import("./cameras/[id]/info/route");
|
|
const snapshot = await import("./cameras/[id]/snapshot/route");
|
|
const credentials = await import("./cameras/[id]/credentials/route");
|
|
const discover = await import("./discover/route");
|
|
|
|
const ctx = { params: Promise.resolve({ id: "11111111-2222-3333-4444-555555555555" }) };
|
|
const req = (method = "GET") =>
|
|
new Request("http://localhost/api/x", { method, body: method === "GET" ? undefined : "{}" });
|
|
|
|
const handlers: [string, () => Promise<Response>][] = [
|
|
["GET /api/cameras/[id]/info", () => info.GET(req(), ctx)],
|
|
["GET /api/cameras/[id]/snapshot", () => snapshot.GET(req(), ctx)],
|
|
["GET /api/cameras/[id]/credentials", () => credentials.GET(req(), ctx)],
|
|
["PUT /api/cameras/[id]/credentials", () => credentials.PUT(req("PUT"), ctx)],
|
|
["DELETE /api/cameras/[id]/credentials", () => credentials.DELETE(req("DELETE"), ctx)],
|
|
["POST /api/discover", () => discover.POST(req("POST"))],
|
|
];
|
|
|
|
describe("API access control", () => {
|
|
beforeEach(() => {
|
|
touched.mockReset();
|
|
denied.mockReset().mockResolvedValue(Response.json({ error: "Sign in required" }, { status: 401 }));
|
|
});
|
|
|
|
it.each(handlers)("%s returns 401 and touches nothing when signed out", async (_name, call) => {
|
|
const res = await call();
|
|
expect(res.status).toBe(401);
|
|
expect(await res.json()).toEqual({ error: "Sign in required" });
|
|
expect(touched).not.toHaveBeenCalled();
|
|
});
|
|
});
|