Files
HiveGrid-Wireframes/hivegrid-cloud/apps/web/app/utils/auth/mfa/trusted-device.server.test.ts
T

155 lines
6.2 KiB
TypeScript

/**
* D1 (Trust-Device) — DB-gestützter, widerrufbarer Geräte-Marker. Das Cookie trägt einen opaken,
* signierten Token; Autorität ist die TrustedDevice-Tabelle (Hash, userId, Ablauf, Widerruf).
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@toolctrl/config", async () => {
const actual = await vi.importActual<typeof import("@toolctrl/config")>("@toolctrl/config");
return {
...actual,
loadRequiredWebConfig: () => ({ nodeEnv: "test", sessionSecret: "test-session-secret-please-change" }),
};
});
vi.mock("~/utils/db.server", () => ({
db: {
trustedDevice: {
create: vi.fn(),
findUnique: vi.fn(),
findMany: vi.fn(),
updateMany: vi.fn(),
update: vi.fn(),
},
},
}));
vi.mock("~/services/auth/sessionLifecycle.server", () => ({
buildSessionDeviceContext: vi.fn().mockResolvedValue({ ipHash: null, userAgent: "UA", approxLocation: "DE" }),
}));
import { db } from "~/utils/db.server";
import {
__resetTrustedDeviceCookieForTests,
clearTrustedDeviceCookie,
forgetCurrentTrustedDevice,
isTrustedDevice,
rememberDeviceForUser,
revokeAllTrustedDevices,
revokeTrustedDevice,
TRUSTED_DEVICE_COOKIE_NAME,
} from "./trusted-device.server";
const create = vi.mocked(db.trustedDevice.create);
const findUnique = vi.mocked(db.trustedDevice.findUnique);
const updateMany = vi.mocked(db.trustedDevice.updateMany);
function activeRow(overrides: Record<string, unknown> = {}) {
return {
id: "td-1",
userId: "user-1",
tokenHash: "hash",
createdAt: new Date(),
lastUsedAt: new Date(),
expiresAt: new Date(Date.now() + 1000 * 60 * 60),
revokedAt: null,
ipHash: null,
userAgent: "UA",
deviceLabel: null,
approxLocation: "DE",
...overrides,
};
}
async function rememberAndGetRequest(userId = "user-1") {
create.mockResolvedValue(activeRow() as never);
const setCookie = await rememberDeviceForUser({ request: new Request("https://app.example.com/login-mfa"), userId });
const pair = setCookie.split(";")[0];
return new Request("https://app.example.com/login", { headers: { Cookie: pair } });
}
beforeEach(() => {
vi.clearAllMocks();
__resetTrustedDeviceCookieForTests();
});
afterEach(() => __resetTrustedDeviceCookieForTests());
describe("trusted-device (DB-backed)", () => {
it("remembers a device: writes a row and returns a Set-Cookie", async () => {
create.mockResolvedValue(activeRow() as never);
const setCookie = await rememberDeviceForUser({ request: new Request("https://app.example.com/login-mfa"), userId: "user-1" });
expect(create).toHaveBeenCalledTimes(1);
expect(setCookie).toContain(TRUSTED_DEVICE_COOKIE_NAME);
// Nur der Hash, nie der rohe Token, wird persistiert.
const data = (create.mock.calls[0][0] as { data: { tokenHash: string } }).data;
expect(data.tokenHash).toMatch(/^[0-9a-f]{64}$/);
});
it("round-trips: a remembered device is trusted for the same user", async () => {
const request = await rememberAndGetRequest("user-1");
findUnique.mockResolvedValue(activeRow() as never);
await expect(isTrustedDevice(request, "user-1")).resolves.toBe(true);
});
it("touches lastUsedAt (throttled) when the marker was last used long ago", async () => {
const request = await rememberAndGetRequest("user-1");
findUnique.mockResolvedValue(activeRow({ lastUsedAt: new Date(0) }) as never);
vi.mocked(db.trustedDevice.update).mockResolvedValue(activeRow() as never);
await expect(isTrustedDevice(request, "user-1")).resolves.toBe(true);
expect(db.trustedDevice.update).toHaveBeenCalledTimes(1);
});
it("rejects a marker that belongs to a different user", async () => {
const request = await rememberAndGetRequest("user-1");
findUnique.mockResolvedValue(activeRow({ userId: "user-2" }) as never);
await expect(isTrustedDevice(request, "user-1")).resolves.toBe(false);
});
it("rejects an expired marker", async () => {
const request = await rememberAndGetRequest("user-1");
findUnique.mockResolvedValue(activeRow({ expiresAt: new Date(Date.now() - 1000) }) as never);
await expect(isTrustedDevice(request, "user-1")).resolves.toBe(false);
});
it("rejects a revoked marker", async () => {
const request = await rememberAndGetRequest("user-1");
findUnique.mockResolvedValue(activeRow({ revokedAt: new Date() }) as never);
await expect(isTrustedDevice(request, "user-1")).resolves.toBe(false);
});
it("returns false (and skips the DB) without a cookie", async () => {
await expect(isTrustedDevice(new Request("https://app.example.com/login"), "user-1")).resolves.toBe(false);
expect(findUnique).not.toHaveBeenCalled();
});
it("rejects a tampered cookie (broken signature) without hitting the DB", async () => {
const request = await rememberAndGetRequest("user-1");
const tampered = new Request("https://app.example.com/login", { headers: { Cookie: `${request.headers.get("Cookie")}TAMPER` } });
findUnique.mockClear();
await expect(isTrustedDevice(tampered, "user-1")).resolves.toBe(false);
expect(findUnique).not.toHaveBeenCalled();
});
it("revokes a single device scoped to the owner", async () => {
updateMany.mockResolvedValue({ count: 1 } as never);
await expect(revokeTrustedDevice({ userId: "user-1", deviceId: "td-1" })).resolves.toBe(true);
expect(updateMany).toHaveBeenCalledWith(expect.objectContaining({ where: expect.objectContaining({ id: "td-1", userId: "user-1", revokedAt: null }) }));
});
it("revokes all devices of a user", async () => {
updateMany.mockResolvedValue({ count: 3 } as never);
await expect(revokeAllTrustedDevices("user-1")).resolves.toBe(3);
});
it("forgets the current device: revokes its row and clears the cookie", async () => {
const request = await rememberAndGetRequest("user-1");
updateMany.mockResolvedValue({ count: 1 } as never);
const setCookie = await forgetCurrentTrustedDevice(request);
expect(updateMany).toHaveBeenCalledTimes(1);
expect(setCookie).toMatch(/Max-Age=0|Expires=Thu, 01 Jan 1970/);
});
it("clears the cookie without DB access", async () => {
const cleared = await clearTrustedDeviceCookie();
expect(cleared).toContain(TRUSTED_DEVICE_COOKIE_NAME);
});
});