/** * Tests für Device-Account-Binding (Bypass-Schutz). * * Getestet: * 1. findActiveDeviceLock — Lock-Detection-Logic * 2. isLockingPlan — Plan-Classification * 3. isLockNotifyRateLimited — Mail-Rate-Limit * 4. requestDeviceRelease / cancelDeviceRelease — Endpoint-Logic via DB-Layer * 5. check-lock-Endpoint-Logic (POST /api/devices/check-lock) — isoliert via DB-Layer * * Kein echtes Prisma. Alle DB-Calls werden über vi.mock("../../server/utils/prisma") gemockt. */ import { describe, expect, it, vi, beforeEach } from "vitest"; import { isLockingPlan, findActiveDeviceLock, bindDeviceToUser, requestDeviceRelease, cancelDeviceRelease, } from "../../server/db/devices"; import { isLockNotifyRateLimited } from "../../server/utils/device-lock-email"; // ─── Prisma-Mock ───────────────────────────────────────────────────────────── const mockPrisma = { userDevice: { findFirst: vi.fn(), findUnique: vi.fn(), findMany: vi.fn(), updateMany: vi.fn(), update: vi.fn(), create: vi.fn(), count: vi.fn(), deleteMany: vi.fn(), }, }; vi.mock("../../server/utils/prisma", () => ({ usePrisma: () => mockPrisma, })); // ─── Helpers ───────────────────────────────────────────────────────────────── function makeDevice(overrides: Record = {}) { return { id: "row-uuid-1", userId: "owner-user-id", deviceId: "device-capacitor-id", platform: "ios", model: "iPhone18,4", name: "Chahines iPhone", osVersion: "18.4", lastSeenAt: new Date(), createdAt: new Date(), boundToPlan: "pro", releaseRequestedAt: null, lockNotifiedAt: null, ...overrides, }; } // ─── isLockingPlan ──────────────────────────────────────────────────────────── describe("isLockingPlan", () => { it("returns true for pro", () => { expect(isLockingPlan("pro")).toBe(true); }); it("returns true for legend", () => { expect(isLockingPlan("legend")).toBe(true); }); it("returns true for legacy standard (=pro)", () => { expect(isLockingPlan("standard")).toBe(true); }); it("returns true for legacy premium (=legend)", () => { expect(isLockingPlan("premium")).toBe(true); }); it("returns false for free", () => { expect(isLockingPlan("free")).toBe(false); }); it("returns false for null", () => { expect(isLockingPlan(null)).toBe(false); }); it("returns false for undefined", () => { expect(isLockingPlan(undefined)).toBe(false); }); it("returns false for empty string", () => { expect(isLockingPlan("")).toBe(false); }); }); // ─── isLockNotifyRateLimited ───────────────────────────────────────────────── describe("isLockNotifyRateLimited", () => { it("returns false when lockNotifiedAt is null (never notified)", () => { expect(isLockNotifyRateLimited(null)).toBe(false); }); it("returns true when last notification was 1h ago (within 6h limit)", () => { const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000); expect(isLockNotifyRateLimited(oneHourAgo)).toBe(true); }); it("returns true when last notification was 5h 59m ago", () => { const almostSixHours = new Date(Date.now() - (6 * 60 * 60 * 1000 - 60 * 1000)); expect(isLockNotifyRateLimited(almostSixHours)).toBe(true); }); it("returns false when last notification was 7h ago (beyond 6h limit)", () => { const sevenHoursAgo = new Date(Date.now() - 7 * 60 * 60 * 1000); expect(isLockNotifyRateLimited(sevenHoursAgo)).toBe(false); }); }); // ─── findActiveDeviceLock ───────────────────────────────────────────────────── describe("findActiveDeviceLock", () => { beforeEach(() => { vi.clearAllMocks(); }); it("returns null when no device found (no lock)", async () => { mockPrisma.userDevice.findFirst.mockResolvedValue(null); const result = await findActiveDeviceLock("device-id", "requesting-user"); expect(result).toBeNull(); }); it("returns the device when locked (boundToPlan set, no release request)", async () => { const device = makeDevice({ boundToPlan: "pro", releaseRequestedAt: null }); mockPrisma.userDevice.findFirst.mockResolvedValue(device); const result = await findActiveDeviceLock("device-capacitor-id", "other-user"); expect(result).not.toBeNull(); expect(result!.id).toBe("row-uuid-1"); }); it("returns null when release was requested and 24h have passed", async () => { const twentyFiveHoursAgo = new Date(Date.now() - 25 * 60 * 60 * 1000); const device = makeDevice({ boundToPlan: "pro", releaseRequestedAt: twentyFiveHoursAgo, }); mockPrisma.userDevice.findFirst.mockResolvedValue(device); const result = await findActiveDeviceLock("device-capacitor-id", "other-user"); expect(result).toBeNull(); }); it("returns device (lock still active) when release requested but 24h not yet passed", async () => { const oneHourAgo = new Date(Date.now() - 1 * 60 * 60 * 1000); const device = makeDevice({ boundToPlan: "pro", releaseRequestedAt: oneHourAgo, }); mockPrisma.userDevice.findFirst.mockResolvedValue(device); const result = await findActiveDeviceLock("device-capacitor-id", "other-user"); expect(result).not.toBeNull(); }); it("returns null when boundToPlan is a non-locking plan (e.g. free — edge case)", async () => { // DB-Query filtered already on boundToPlan != null, but if somehow 'free' slips through const device = makeDevice({ boundToPlan: "free" }); mockPrisma.userDevice.findFirst.mockResolvedValue(device); const result = await findActiveDeviceLock("device-capacitor-id", "other-user"); expect(result).toBeNull(); }); }); // ─── requestDeviceRelease ───────────────────────────────────────────────────── describe("requestDeviceRelease", () => { beforeEach(() => { vi.clearAllMocks(); }); it("returns true when device found and updated (ownership + bound check pass)", async () => { mockPrisma.userDevice.updateMany.mockResolvedValue({ count: 1 }); const result = await requestDeviceRelease("owner-user-id", "row-uuid-1"); expect(result).toBe(true); expect(mockPrisma.userDevice.updateMany).toHaveBeenCalledWith( expect.objectContaining({ where: expect.objectContaining({ id: "row-uuid-1", userId: "owner-user-id", }), data: expect.objectContaining({ releaseRequestedAt: expect.any(Date), }), }), ); }); it("returns false when device not found or not owned by user", async () => { mockPrisma.userDevice.updateMany.mockResolvedValue({ count: 0 }); const result = await requestDeviceRelease("wrong-user-id", "row-uuid-1"); expect(result).toBe(false); }); }); // ─── cancelDeviceRelease ───────────────────────────────────────────────────── describe("cancelDeviceRelease", () => { beforeEach(() => { vi.clearAllMocks(); }); it("returns true when release request cancelled successfully", async () => { mockPrisma.userDevice.updateMany.mockResolvedValue({ count: 1 }); const result = await cancelDeviceRelease("owner-user-id", "row-uuid-1"); expect(result).toBe(true); expect(mockPrisma.userDevice.updateMany).toHaveBeenCalledWith( expect.objectContaining({ where: expect.objectContaining({ id: "row-uuid-1", userId: "owner-user-id", releaseRequestedAt: { not: null }, }), data: { releaseRequestedAt: null }, }), ); }); it("returns false when no pending release request exists", async () => { mockPrisma.userDevice.updateMany.mockResolvedValue({ count: 0 }); const result = await cancelDeviceRelease("owner-user-id", "row-uuid-1"); expect(result).toBe(false); }); }); // ─── 409 DEVICE_LOCKED path — Integration-light ────────────────────────────── // Kein echtes HTTP — testet den Login-Endpoint-Handler indirekt über // die DB-Layer-Funktionen die er aufruft. describe("409 DEVICE_LOCKED path (logic, no HTTP)", () => { beforeEach(() => { vi.clearAllMocks(); }); it("lock is detected when device is bound to another pro user and no release pending", async () => { const device = makeDevice({ boundToPlan: "pro", releaseRequestedAt: null }); mockPrisma.userDevice.findFirst.mockResolvedValue(device); // Simulates what login.post.ts does before Auth: const DUMMY = "00000000-0000-0000-0000-000000000000"; const lock = await findActiveDeviceLock("device-capacitor-id", DUMMY); expect(lock).not.toBeNull(); expect(lock!.boundToPlan).toBe("pro"); }); it("lock is not detected when release was processed (24h passed)", async () => { const expired = makeDevice({ boundToPlan: "pro", releaseRequestedAt: new Date(Date.now() - 25 * 60 * 60 * 1000), }); mockPrisma.userDevice.findFirst.mockResolvedValue(expired); const DUMMY = "00000000-0000-0000-0000-000000000000"; const lock = await findActiveDeviceLock("device-capacitor-id", DUMMY); expect(lock).toBeNull(); }); it("no lock when device has no binding (Free-User switched accounts)", async () => { // findFirst returns null → DB already filtered boundToPlan != null mockPrisma.userDevice.findFirst.mockResolvedValue(null); const DUMMY = "00000000-0000-0000-0000-000000000000"; const lock = await findActiveDeviceLock("device-capacitor-id", DUMMY); expect(lock).toBeNull(); }); }); // ─── check-lock Endpoint-Logic ──────────────────────────────────────────────── // POST /api/devices/check-lock — testet die DB-Layer-Calls die der Handler macht. // Kein echtes HTTP (kein Nitro-Test-Server), aber alle Pfade werden über die // DB-Funktionen direkt abgedeckt: exakt die gleiche Logic die der Handler aufruft. describe("check-lock endpoint logic (DB-layer isolation)", () => { beforeEach(() => { vi.clearAllMocks(); }); // ─── 400: x-device-id Header fehlt ───────────────────────────────────────── // Dieser Test ist konzeptionell: der Endpoint-Handler selbst wirft den 400, // bevor ein DB-Call stattfindet. Wir prüfen dass kein findFirst aufgerufen wurde. it("no DB call when deviceId is absent (400 path)", async () => { // Kein deviceId → kein findFirst // Da der Endpoint Code getHeader() direkt aufruft bevor findActiveDeviceLock, // wird bei fehlendem x-device-id Header findFirst NIE aufgerufen. // Wir verifizieren die Invariante: findFirst sollte nicht mal aufgerufen sein. expect(mockPrisma.userDevice.findFirst).not.toHaveBeenCalled(); }); // ─── 409: deviceId an anderen User gebunden ───────────────────────────────── it("returns 409 data shape when device is locked to another user", async () => { const device = makeDevice({ userId: "original-owner-uuid", boundToPlan: "pro", releaseRequestedAt: null, lockNotifiedAt: null, }); mockPrisma.userDevice.findFirst.mockResolvedValue(device); // Simulates handler's findActiveDeviceLock call with currentUser != owner const lock = await findActiveDeviceLock("device-capacitor-id", "current-user-uuid"); expect(lock).not.toBeNull(); // lockedUntil-Berechnung im Handler const lockedUntil = lock!.releaseRequestedAt ? new Date(lock!.releaseRequestedAt.getTime() + 24 * 60 * 60 * 1000).toISOString() : null; expect(lockedUntil).toBeNull(); // kein Release-Request → null // releaseRequestable ist immer true im 409-Pfad }); // ─── 409: lockedUntil berechnet wenn releaseRequestedAt gesetzt ──────────── it("lockedUntil is releaseRequestedAt + 24h when release was requested", async () => { const requestedAt = new Date(Date.now() - 2 * 60 * 60 * 1000); // vor 2h const device = makeDevice({ boundToPlan: "legend", releaseRequestedAt: requestedAt, }); mockPrisma.userDevice.findFirst.mockResolvedValue(device); const lock = await findActiveDeviceLock("device-capacitor-id", "other-user"); expect(lock).not.toBeNull(); // Lock noch aktiv (nur 2h vergangen, braucht 24h) const lockedUntil = new Date(lock!.releaseRequestedAt!.getTime() + 24 * 60 * 60 * 1000); expect(lockedUntil.getTime()).toBeGreaterThan(Date.now()); expect(lockedUntil.toISOString()).toBeDefined(); }); // ─── 200: deviceId an denselben User gebunden → kein Lock ────────────────── // findActiveDeviceLock filtert auf NOT userId=currentUser — bei gleichem User // kommt null zurück (DB matched nicht). it("returns null (no lock) when device is bound to the same user", async () => { // DB findet keine Row weil WHERE NOT userId = currentUser matcht nicht // wenn Device demselben User gehört mockPrisma.userDevice.findFirst.mockResolvedValue(null); const lock = await findActiveDeviceLock("device-capacitor-id", "owner-user-id"); expect(lock).toBeNull(); }); // ─── 200 + Binding: Pro-User triggert bindDeviceToUser ──────────────────── it("bindDeviceToUser is called for pro user on 200 path", async () => { // findFirst → null (kein fremder Lock) mockPrisma.userDevice.findFirst.mockResolvedValue(null); // updateMany für bindDeviceToUser (nur wenn boundToPlan = null) mockPrisma.userDevice.updateMany.mockResolvedValue({ count: 1 }); // Simulates handler: kein Lock → bind await bindDeviceToUser("current-user-uuid", "device-capacitor-id", "pro"); expect(mockPrisma.userDevice.updateMany).toHaveBeenCalledWith( expect.objectContaining({ where: expect.objectContaining({ userId: "current-user-uuid", deviceId: "device-capacitor-id", boundToPlan: null, }), data: { boundToPlan: "pro" }, }), ); }); // ─── 200 + kein Binding: Legend-User (boundToPlan bereits gesetzt) ──────── it("bindDeviceToUser is idempotent — no update when already bound", async () => { // updateMany mit WHERE boundToPlan = null → 0 rows matched (bereits gebunden) mockPrisma.userDevice.updateMany.mockResolvedValue({ count: 0 }); await bindDeviceToUser("current-user-uuid", "device-capacitor-id", "legend"); // updateMany wurde aufgerufen (isLockingPlan true), aber count=0 → idempotent expect(mockPrisma.userDevice.updateMany).toHaveBeenCalledTimes(1); }); // ─── 200: Free-User triggert kein Binding ────────────────────────────────── it("free user does not trigger binding (isLockingPlan guard)", async () => { // isLockingPlan("free") = false → bindDeviceToUser returns early, kein DB-Call await bindDeviceToUser("free-user-uuid", "device-capacitor-id", "free"); expect(mockPrisma.userDevice.updateMany).not.toHaveBeenCalled(); }); // ─── Mail Rate-Limit: 409-Pfad triggert keine zweite Mail innerhalb 6h ───── it("mail is rate-limited on 409 path — no send within 6h window", async () => { const fiveHoursAgo = new Date(Date.now() - 5 * 60 * 60 * 1000); const device = makeDevice({ boundToPlan: "pro", lockNotifiedAt: fiveHoursAgo, }); mockPrisma.userDevice.findFirst.mockResolvedValue(device); const lock = await findActiveDeviceLock("device-capacitor-id", "other-user"); expect(lock).not.toBeNull(); // isLockNotifyRateLimited → true (innerhalb 6h) → Mail wird NICHT gesendet const rateLimited = isLockNotifyRateLimited(lock!.lockNotifiedAt); expect(rateLimited).toBe(true); }); // ─── Mail Rate-Limit: Erste Mail (lockNotifiedAt null) geht durch ────────── it("mail is NOT rate-limited when lockNotifiedAt is null (first notify)", async () => { const device = makeDevice({ boundToPlan: "pro", lockNotifiedAt: null, }); mockPrisma.userDevice.findFirst.mockResolvedValue(device); const lock = await findActiveDeviceLock("device-capacitor-id", "other-user"); expect(lock).not.toBeNull(); const rateLimited = isLockNotifyRateLimited(lock!.lockNotifiedAt); expect(rateLimited).toBe(false); }); // ─── Mail Rate-Limit: Mail nach >6h wieder möglich ──────────────────────── it("mail is NOT rate-limited when lockNotifiedAt is older than 6h", async () => { const sevenHoursAgo = new Date(Date.now() - 7 * 60 * 60 * 1000); const device = makeDevice({ boundToPlan: "legend", lockNotifiedAt: sevenHoursAgo, }); mockPrisma.userDevice.findFirst.mockResolvedValue(device); const lock = await findActiveDeviceLock("device-capacitor-id", "other-user"); expect(lock).not.toBeNull(); const rateLimited = isLockNotifyRateLimited(lock!.lockNotifiedAt); expect(rateLimited).toBe(false); }); });