Closes the bypass loophole where a Pro/Legend user could log out in a
craving moment, sign in with a fresh Free account on the same iPhone,
and watch the NEFilter blocklist shrink from 208k Casino domains to
the curated 30-domain stub. The user is the patient — the addiction
itself is the attacker.
When a Pro/Legend account signs in via x-device-id, the device is
bound to that user_id (UserDevice.boundToPlan = 'pro'|'legend' …).
A subsequent login attempt from a different account on the same
device returns 409 DEVICE_LOCKED. The original user gets a Resend
email naming the nickname only (no firstName / email leaked per
the anonymity rule) with a link to either confirm the foreign attempt
or release the device.
Release flow:
- POST /api/devices/:id/request-release schedules releaseAt = now + 24h
- POST /api/devices/:id/cancel-release reverts it
- a Nitro plugin cron sweeps both (24h-requested releases AND
30-day-idle auto-releases) hourly
Free -> Free swaps stay unrestricted so onboarding on a second-hand
iPhone keeps working. Free -> Pro upgrade binds going forward; a
Pro -> Free downgrade keeps the existing lock so the bypass vector
stays closed.
Lock check runs BEFORE Supabase auth in /api/auth/login to avoid
giving a timing oracle for account enumeration. The dummy-UUID filter
in findActiveDeviceLock is the trick: it queries "someone else's
lock" with a userId that can never match.
DSGVO: ON DELETE CASCADE on UserDevice means an Art-17 deletion of
the original user releases all their locks automatically (Hans-Mueller
hand-off noted in the migration SQL).
24 vitest cases cover bind / lock / request-release-24h /
cancel-release / 30-day-idle-release / email rate-limit (1 per 6h) /
DSGVO cascade / multi-device Legend.
Migration to deploy after push:
infisical run -- npx prisma migrate deploy --schema backend/prisma/schema.prisma
Frontend follow-up (separate task):
- Sign-In: handle 409 DEVICE_LOCKED with a dedicated error UI
- Settings/Devices page: "Release device" button + 24h countdown
- GET /api/devices to include boundToPlan + releaseRequestedAt
285 lines
10 KiB
TypeScript
285 lines
10 KiB
TypeScript
/**
|
|
* 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
|
|
*
|
|
* 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,
|
|
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<string, unknown> = {}) {
|
|
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();
|
|
});
|
|
});
|