/** * 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("@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 = {}) { 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); }); });