155 lines
6.2 KiB
TypeScript
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);
|
|
});
|
|
});
|