feat(backend): device-account binding for pro/legend users
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
This commit is contained in:
parent
4c4792c153
commit
1bc38e0732
@ -0,0 +1,32 @@
|
||||
-- Device-Account-Binding: Bypass-Schutz für Pro/Legend-User.
|
||||
--
|
||||
-- Wenn ein Pro/Legend-User ein Gerät registriert, wird dieses Gerät an seinen
|
||||
-- Account gebunden. Ein Ausloggen + Einloggen mit einem anderen Account auf
|
||||
-- demselben Gerät wird mit 409 DEVICE_LOCKED blockiert.
|
||||
--
|
||||
-- Neue Felder auf user_devices:
|
||||
-- bound_to_plan — Plan des Users ZUM ZEITPUNKT der Bindung. Binding gilt
|
||||
-- nur wenn bound_to_plan IN ('pro','legend','standard','premium').
|
||||
-- Free-Devices binden NICHT. Der Lock bleibt bestehen
|
||||
-- auch wenn der Original-User danach auf Free downgradet
|
||||
-- (Lock aufheben = Bypass-Vektor öffnen).
|
||||
-- release_requested_at — wann der Original-User "Gerät freigeben" angeklickt
|
||||
-- hat. release_requested_at + 24h = automatische Freigabe
|
||||
-- (24h Cooldown schützt gegen impulsive Freigabe im Drang-Window).
|
||||
-- lock_notified_at — Rate-Limit: max. 1 Mail pro Device pro 6h wenn jemand
|
||||
-- auf einem gebundenen Gerät versucht sich einzuloggen.
|
||||
--
|
||||
-- DSGVO-Hinweis (Hans-Müller): wenn der Original-User sein Konto löscht
|
||||
-- (Art. 17 Recht auf Löschung), werden alle seine user_devices-Rows kaskadiert
|
||||
-- gelöscht → alle Device-Locks automatisch released. Dies ist korrekt, da die
|
||||
-- Verarbeitungsgrundlage (Nutzervertrag) erlischt. Keine gesonderte Cascade-Logik
|
||||
-- nötig — DB-ON DELETE CASCADE reicht.
|
||||
|
||||
ALTER TABLE "rebreak"."user_devices"
|
||||
ADD COLUMN IF NOT EXISTS "bound_to_plan" TEXT NULL,
|
||||
ADD COLUMN IF NOT EXISTS "release_requested_at" TIMESTAMPTZ NULL,
|
||||
ADD COLUMN IF NOT EXISTS "lock_notified_at" TIMESTAMPTZ NULL;
|
||||
|
||||
-- Index: schnelle Suche "ist deviceId schon an einen anderen User gebunden?"
|
||||
CREATE INDEX IF NOT EXISTS "user_devices_device_id_idx"
|
||||
ON "rebreak"."user_devices" ("device_id");
|
||||
@ -839,6 +839,18 @@ model ConsentLog {
|
||||
// Device-Binding pro User: Free=1, Pro=1, Legend=3 (siehe plan-features.ts maxDevices).
|
||||
// Frontend liefert via Capacitor Device.getId() eine persistente UUID — diese wird
|
||||
// bei jedem authentifizierten Request via x-device-id Header geprüft.
|
||||
//
|
||||
// Device-Account-Lock (Bypass-Schutz):
|
||||
// boundToPlan — Plan des Users zum Zeitpunkt der Bindung. NULL = noch nicht
|
||||
// gebunden (Free-User oder Device vor Migration). Lock gilt
|
||||
// nur wenn boundToPlan IN (pro, legend, standard, premium).
|
||||
// releaseRequestedAt — wann Original-User "Gerät freigeben" angeklickt hat.
|
||||
// releaseRequestedAt + 24h = Freigabe (Drang-Cooldown).
|
||||
// lockNotifiedAt — Rate-Limit-Marker: letzte Mail-Notification bei Login-Versuch
|
||||
// auf gebundenem Gerät. Max 1 Mail / 6h / Device.
|
||||
//
|
||||
// DSGVO Hans-Müller: Art-17-Konto-Löschung kaskadiert user_devices via ON DELETE CASCADE
|
||||
// im DB-FK → alle Device-Locks automatisch released. Keine gesonderte Logik nötig.
|
||||
model UserDevice {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
userId String @map("user_id") @db.Uuid
|
||||
@ -850,8 +862,19 @@ model UserDevice {
|
||||
lastSeenAt DateTime @default(now()) @map("last_seen_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
// ─── Device-Account-Lock ────────────────────────────────────────────────
|
||||
/// Plan des Users zum Zeitpunkt der Bindung. NULL → kein Lock aktiv.
|
||||
/// Lock gilt wenn: boundToPlan != null AND releaseRequestedAt + 24h > NOW()
|
||||
boundToPlan String? @map("bound_to_plan")
|
||||
/// Wann der Original-User "Gerät freigeben" beantragt hat.
|
||||
/// NULL → noch kein Release-Request. Freigabe wird aktiv nach +24h.
|
||||
releaseRequestedAt DateTime? @map("release_requested_at")
|
||||
/// Letzte Mail-Notification bei fremdem Login-Versuch. Rate-Limit 6h.
|
||||
lockNotifiedAt DateTime? @map("lock_notified_at")
|
||||
|
||||
@@unique([userId, deviceId])
|
||||
@@index([userId])
|
||||
@@index([deviceId])
|
||||
@@map("user_devices")
|
||||
@@schema("rebreak")
|
||||
}
|
||||
|
||||
@ -1,5 +1,14 @@
|
||||
import { serverSupabaseClient } from "../../utils/useSupabase";
|
||||
import { getProfile } from "../../db/profile";
|
||||
import {
|
||||
findActiveDeviceLock,
|
||||
bindDeviceToUser,
|
||||
isLockingPlan,
|
||||
markDeviceLockNotified,
|
||||
} from "../../db/devices";
|
||||
import {
|
||||
isLockNotifyRateLimited,
|
||||
sendDeviceLockEmail,
|
||||
} from "../../utils/device-lock-email";
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const { username, password } = await readBody(event);
|
||||
@ -11,6 +20,83 @@ export default defineEventHandler(async (event) => {
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Device-Lock-Check (vor Auth) ──────────────────────────────────────────
|
||||
// Wenn x-device-id Header gesetzt und das Device an einen anderen Pro/Legend-
|
||||
// User gebunden ist → 409 DEVICE_LOCKED. Login wird NICHT durchgeführt.
|
||||
//
|
||||
// Warum vor Auth: wir brauchen nicht zu wissen ob die Credentials korrekt sind.
|
||||
// Das würde nur Timing-Side-Channel für Account-Enumeration öffnen.
|
||||
const incomingDeviceId = getHeader(event, "x-device-id");
|
||||
|
||||
if (incomingDeviceId) {
|
||||
// Wir brauchen die user-id des einlogenden Users für den Check —
|
||||
// aber wir haben noch keinen eingeloggten User. Wir übergeben eine
|
||||
// Dummy-UUID die nie matcht, da wir hier nur prüfen ob das Device an
|
||||
// irgendwen (außer NULL) gebunden ist. findActiveDeviceLock matched
|
||||
// "deviceId + NOT userId", also matcht die Dummy-UUID nie auf eine Row.
|
||||
const DUMMY_REQUESTING_USER = "00000000-0000-0000-0000-000000000000";
|
||||
|
||||
const lock = await findActiveDeviceLock(incomingDeviceId, DUMMY_REQUESTING_USER);
|
||||
|
||||
if (lock) {
|
||||
// Async: Mail-Notification an Original-User (Rate-Limited auf 6h)
|
||||
if (!isLockNotifyRateLimited(lock.lockNotifiedAt)) {
|
||||
// Original-User-Profil laden für Mail-Details
|
||||
// Alle Ressourcen hier im äußeren Scope cachen (kein async-after-response)
|
||||
const config = useRuntimeConfig(event);
|
||||
const supabaseCfg = (config as any).public?.supabase ?? (config as any).supabase;
|
||||
const resendApiKey = (config as any).resendApiKey as string | undefined;
|
||||
const supabaseServiceKey = (config as any).supabaseServiceKey as string | undefined;
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const { getProfile: gp } = await import("../../db/profile");
|
||||
const ownerProfile = await gp(lock.userId);
|
||||
if (ownerProfile && resendApiKey && supabaseServiceKey) {
|
||||
const { createClient } = await import("@supabase/supabase-js");
|
||||
const adminClient = createClient(
|
||||
supabaseCfg.url as string,
|
||||
supabaseServiceKey,
|
||||
);
|
||||
const { data: adminUser } = await adminClient.auth.admin.getUserById(lock.userId);
|
||||
const ownerEmail = adminUser?.user?.email;
|
||||
|
||||
if (ownerEmail) {
|
||||
await sendDeviceLockEmail({
|
||||
recipientNickname: ownerProfile.nickname ?? ownerProfile.username ?? "Nutzer",
|
||||
recipientEmail: ownerEmail,
|
||||
deviceRowId: lock.id,
|
||||
deviceName: lock.name,
|
||||
lockNotifiedAt: lock.lockNotifiedAt,
|
||||
resendApiKey,
|
||||
});
|
||||
await markDeviceLockNotified(lock.id);
|
||||
}
|
||||
}
|
||||
} catch (mailErr: any) {
|
||||
console.error("[login] device-lock mail failed:", mailErr?.message ?? mailErr);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
// lockedUntil: release_requested_at + 24h, oder "unbestimmt" wenn kein Request
|
||||
const lockedUntil = lock.releaseRequestedAt
|
||||
? new Date(lock.releaseRequestedAt.getTime() + 24 * 60 * 60 * 1000).toISOString()
|
||||
: null;
|
||||
|
||||
throw createError({
|
||||
statusCode: 409,
|
||||
statusMessage: "DEVICE_LOCKED",
|
||||
data: {
|
||||
error: "DEVICE_LOCKED",
|
||||
lockedUntil,
|
||||
releaseRequestable: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Normaler Auth-Flow ─────────────────────────────────────────────────────
|
||||
const email = `${username.toLowerCase()}@rebreak.internal`;
|
||||
const supabase = serverSupabaseClient(event);
|
||||
|
||||
@ -21,6 +107,24 @@ export default defineEventHandler(async (event) => {
|
||||
if (error) throw createError({ statusCode: 401, message: error.message });
|
||||
|
||||
const dbProfile = await getProfile(data.user.id);
|
||||
const normalizedPlan = (
|
||||
dbProfile?.plan === "premium"
|
||||
? "legend"
|
||||
: dbProfile?.plan === "standard"
|
||||
? "pro"
|
||||
: dbProfile?.plan ?? "free"
|
||||
) as "free" | "pro" | "legend";
|
||||
|
||||
// ─── Device-Binding nach erfolgreichem Login ────────────────────────────────
|
||||
// Wenn Pro/Legend-User einloggt und Device-ID bekannt → Device binden.
|
||||
// Free-User binden nicht (isLockingPlan filtert).
|
||||
if (incomingDeviceId && isLockingPlan(dbProfile?.plan ?? "free")) {
|
||||
// Fire-and-forget — Login soll nicht wegen Binding-Fehler blockieren
|
||||
void bindDeviceToUser(data.user.id, incomingDeviceId, dbProfile?.plan ?? "free")
|
||||
.catch((err: any) => {
|
||||
console.error("[login] device-bind failed:", err?.message ?? err);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
session: {
|
||||
@ -34,11 +138,7 @@ export default defineEventHandler(async (event) => {
|
||||
username: dbProfile?.username ?? "",
|
||||
nickname: dbProfile?.nickname ?? null,
|
||||
avatar: dbProfile?.avatar ?? null,
|
||||
plan: (dbProfile?.plan === "premium"
|
||||
? "legend"
|
||||
: dbProfile?.plan === "standard"
|
||||
? "pro"
|
||||
: dbProfile?.plan ?? "free") as "free" | "pro" | "legend",
|
||||
plan: normalizedPlan,
|
||||
streak: dbProfile?.streak ?? 0,
|
||||
created_at: dbProfile?.createdAt?.toISOString() ?? data.user.created_at,
|
||||
},
|
||||
|
||||
29
backend/server/api/devices/[id]/cancel-release.post.ts
Normal file
29
backend/server/api/devices/[id]/cancel-release.post.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import { cancelDeviceRelease } from "../../../db/devices";
|
||||
|
||||
/**
|
||||
* POST /api/devices/:id/cancel-release
|
||||
*
|
||||
* User zieht einen offenen Release-Request zurück.
|
||||
* Setzt release_requested_at zurück auf NULL — Lock bleibt aktiv.
|
||||
*
|
||||
* Auth: eingeloggter User, ownership-check via userId im DB-Query.
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = await requireUser(event, { skipDeviceCheck: true });
|
||||
const id = getRouterParam(event, "id");
|
||||
|
||||
if (!id) {
|
||||
throw createError({ statusCode: 400, data: { error: "MISSING_ID" } });
|
||||
}
|
||||
|
||||
const updated = await cancelDeviceRelease(user.id, id);
|
||||
|
||||
if (!updated) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
data: { error: "DEVICE_NOT_FOUND_OR_NO_PENDING_RELEASE" },
|
||||
});
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
35
backend/server/api/devices/[id]/request-release.post.ts
Normal file
35
backend/server/api/devices/[id]/request-release.post.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import { requestDeviceRelease } from "../../../db/devices";
|
||||
|
||||
/**
|
||||
* POST /api/devices/:id/request-release
|
||||
*
|
||||
* Original-User markiert sein eigenes Device für Freigabe.
|
||||
* Freigabe wird aktiv nach 24h (Cooldown schützt gegen impulsiven Release im Drang-Window).
|
||||
*
|
||||
* Auth: eingeloggter User, ownership-check via userId im DB-Query.
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = await requireUser(event, { skipDeviceCheck: true });
|
||||
const id = getRouterParam(event, "id");
|
||||
|
||||
if (!id) {
|
||||
throw createError({ statusCode: 400, data: { error: "MISSING_ID" } });
|
||||
}
|
||||
|
||||
const updated = await requestDeviceRelease(user.id, id);
|
||||
|
||||
if (!updated) {
|
||||
// Device nicht gefunden, gehört nicht dem User, oder hat kein boundToPlan
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
data: { error: "DEVICE_NOT_FOUND_OR_NOT_BOUND" },
|
||||
});
|
||||
}
|
||||
|
||||
const releaseAt = new Date(Date.now() + 24 * 60 * 60 * 1000);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
releaseAt: releaseAt.toISOString(),
|
||||
};
|
||||
});
|
||||
@ -7,6 +7,7 @@ import { usePrisma } from "../utils/prisma";
|
||||
|
||||
export interface DeviceRecord {
|
||||
id: string;
|
||||
userId: string;
|
||||
deviceId: string;
|
||||
platform: string;
|
||||
model: string | null;
|
||||
@ -14,10 +15,173 @@ export interface DeviceRecord {
|
||||
osVersion: string | null;
|
||||
lastSeenAt: Date;
|
||||
createdAt: Date;
|
||||
// Device-Account-Lock
|
||||
boundToPlan: string | null;
|
||||
releaseRequestedAt: Date | null;
|
||||
lockNotifiedAt: Date | null;
|
||||
}
|
||||
|
||||
/** Pläne die einen Device-Account-Lock aktivieren. Free-User binden nie. */
|
||||
const LOCKING_PLANS = new Set(["pro", "legend", "standard", "premium"]);
|
||||
|
||||
/** Ist ein Plan ein "locking" Plan (Pro/Legend inkl. Legacy-Namen)? */
|
||||
export function isLockingPlan(plan: string | null | undefined): boolean {
|
||||
if (!plan) return false;
|
||||
return LOCKING_PLANS.has(plan.toLowerCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft ob ein gegebenes deviceId bereits an einen anderen User gebunden ist
|
||||
* (Lock aktiv). Gibt die bound Row zurück wenn ja, null wenn frei.
|
||||
*
|
||||
* "Gebunden" = boundToPlan ist gesetzt (isLockingPlan) UND kein Release
|
||||
* abgelaufen (releaseRequestedAt + 24h <= NOW() = released).
|
||||
*/
|
||||
export async function findActiveDeviceLock(
|
||||
deviceId: string,
|
||||
requestingUserId: string,
|
||||
): Promise<DeviceRecord | null> {
|
||||
const db = usePrisma();
|
||||
|
||||
const row = await db.userDevice.findFirst({
|
||||
where: {
|
||||
deviceId,
|
||||
// Gebunden an einen anderen User
|
||||
NOT: { userId: requestingUserId },
|
||||
// Binding existiert (Pro/Legend-Account)
|
||||
boundToPlan: { not: null },
|
||||
},
|
||||
select: {
|
||||
...DEVICE_SELECT_WITH_LOCK,
|
||||
},
|
||||
});
|
||||
|
||||
if (!row) return null;
|
||||
|
||||
// Kein Lock wenn boundToPlan kein Locking-Plan (Sicherheitsnetz, eigentlich
|
||||
// schon durch oben gefiltert aber explizit prüfen)
|
||||
if (!isLockingPlan(row.boundToPlan)) return null;
|
||||
|
||||
// Wenn Release-Request existiert und 24h abgelaufen → Lock ist released
|
||||
if (row.releaseRequestedAt) {
|
||||
const releaseAt = new Date(row.releaseRequestedAt.getTime() + 24 * 60 * 60 * 1000);
|
||||
if (releaseAt <= new Date()) return null;
|
||||
}
|
||||
|
||||
// Lock ist aktiv
|
||||
return row;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bindet ein Device an den User (setzt boundToPlan).
|
||||
* Wird nach erfolgreichem Login aufgerufen wenn user.plan ein Locking-Plan ist.
|
||||
* Idempotent: wenn bereits gebunden → kein Update.
|
||||
*/
|
||||
export async function bindDeviceToUser(
|
||||
userId: string,
|
||||
deviceId: string,
|
||||
plan: string,
|
||||
): Promise<void> {
|
||||
if (!isLockingPlan(plan)) return; // Free-User binden nicht
|
||||
|
||||
const db = usePrisma();
|
||||
await db.userDevice.updateMany({
|
||||
where: {
|
||||
userId,
|
||||
deviceId,
|
||||
boundToPlan: null, // Nur setzen wenn noch nicht gebunden
|
||||
},
|
||||
data: {
|
||||
boundToPlan: plan,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Request-Release: Original-User setzt release_requested_at = NOW().
|
||||
* Nach 24h ist der Lock automatisch gelöst (Lazy-Check in findActiveDeviceLock).
|
||||
* Gibt false zurück wenn Device nicht gefunden oder nicht dem User gehört.
|
||||
*/
|
||||
export async function requestDeviceRelease(
|
||||
userId: string,
|
||||
deviceId: string, // row-id (UUID), nicht deviceId-String
|
||||
): Promise<boolean> {
|
||||
const db = usePrisma();
|
||||
const result = await db.userDevice.updateMany({
|
||||
where: {
|
||||
id: deviceId,
|
||||
userId, // Ownership-Check
|
||||
boundToPlan: { not: null }, // Muss gebunden sein um freizugeben
|
||||
},
|
||||
data: {
|
||||
releaseRequestedAt: new Date(),
|
||||
},
|
||||
});
|
||||
return result.count > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel-Release: User zieht den Release-Request zurück.
|
||||
* Setzt release_requested_at zurück auf NULL.
|
||||
*/
|
||||
export async function cancelDeviceRelease(
|
||||
userId: string,
|
||||
deviceId: string, // row-id
|
||||
): Promise<boolean> {
|
||||
const db = usePrisma();
|
||||
const result = await db.userDevice.updateMany({
|
||||
where: {
|
||||
id: deviceId,
|
||||
userId,
|
||||
releaseRequestedAt: { not: null }, // Muss offener Request sein
|
||||
},
|
||||
data: {
|
||||
releaseRequestedAt: null,
|
||||
},
|
||||
});
|
||||
return result.count > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Setzt lockNotifiedAt für Rate-Limiting der E-Mail-Notifications.
|
||||
*/
|
||||
export async function markDeviceLockNotified(rowId: string): Promise<void> {
|
||||
const db = usePrisma();
|
||||
await db.userDevice.update({
|
||||
where: { id: rowId },
|
||||
data: { lockNotifiedAt: new Date() },
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-Release: Alle Devices die 30 Tage nicht gesehen wurden UND noch
|
||||
* boundToPlan gesetzt haben → boundToPlan zurücksetzen.
|
||||
* Wird vom Cron-Plugin (device-lock-cron.ts) aufgerufen.
|
||||
*
|
||||
* Schützt vor verlorenen/verkauften/defekten Geräten ohne Customer-Support.
|
||||
*/
|
||||
export async function autoReleaseInactiveDevices(): Promise<number> {
|
||||
const db = usePrisma();
|
||||
const cutoff = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
||||
|
||||
const result = await db.userDevice.updateMany({
|
||||
where: {
|
||||
boundToPlan: { not: null },
|
||||
lastSeenAt: { lt: cutoff },
|
||||
// Nur wenn kein Release-Request bereits pending (würde in 24h ohnehin ablaufen)
|
||||
},
|
||||
data: {
|
||||
boundToPlan: null,
|
||||
releaseRequestedAt: null,
|
||||
},
|
||||
});
|
||||
|
||||
return result.count;
|
||||
}
|
||||
|
||||
const DEVICE_SELECT = {
|
||||
id: true,
|
||||
userId: true,
|
||||
deviceId: true,
|
||||
platform: true,
|
||||
model: true,
|
||||
@ -25,8 +189,14 @@ const DEVICE_SELECT = {
|
||||
osVersion: true,
|
||||
lastSeenAt: true,
|
||||
createdAt: true,
|
||||
boundToPlan: true,
|
||||
releaseRequestedAt: true,
|
||||
lockNotifiedAt: true,
|
||||
} as const;
|
||||
|
||||
// Alias — identisch mit DEVICE_SELECT, explizit für Lock-Queries
|
||||
const DEVICE_SELECT_WITH_LOCK = DEVICE_SELECT;
|
||||
|
||||
/** Liste aller Devices eines Users, aktuellstes zuerst. */
|
||||
export async function listUserDevices(userId: string): Promise<DeviceRecord[]> {
|
||||
const db = usePrisma();
|
||||
|
||||
57
backend/server/plugins/device-lock-cron.ts
Normal file
57
backend/server/plugins/device-lock-cron.ts
Normal file
@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Device-Lock Auto-Release Cron
|
||||
*
|
||||
* Läuft alle 24h. Findet alle UserDevice-Rows die:
|
||||
* - boundToPlan gesetzt haben (Lock aktiv)
|
||||
* - lastSeenAt < NOW() - 30 Tage (Gerät inaktiv)
|
||||
*
|
||||
* → setzt boundToPlan + releaseRequestedAt zurück auf NULL.
|
||||
*
|
||||
* Begründung 30-Tage-Limit: schützt vor verlorenen/verkauften/defekten
|
||||
* Geräten ohne dass User in Customer-Support muss. Nach 30 Tagen ohne
|
||||
* authentifizierten API-Call ist das Gerät faktisch nicht mehr in Benutzung.
|
||||
*
|
||||
* Zusätzlich wird hier der "abgelaufene Release-Request" Lazy-Check
|
||||
* nicht ersetzt — der läuft in findActiveDeviceLock() inline. Dieser
|
||||
* Cron ist nur für den 30-Tage-Inaktivitäts-Case.
|
||||
*/
|
||||
import { consola } from "consola";
|
||||
import { autoReleaseInactiveDevices } from "../db/devices";
|
||||
|
||||
const TWENTY_FOUR_HOURS = 24 * 60 * 60 * 1000;
|
||||
|
||||
export default defineNitroPlugin((nitro) => {
|
||||
if (import.meta.dev) {
|
||||
consola.info("[device-lock-cron] Skipping cron in dev mode");
|
||||
return;
|
||||
}
|
||||
|
||||
consola.info("[device-lock-cron] Starting (24h interval)");
|
||||
|
||||
// Erster Lauf nach 2 Minuten (Server-Boot-Phase abwarten)
|
||||
const initialTimer = setTimeout(() => {
|
||||
runAutoRelease().catch(() => {});
|
||||
}, 2 * 60 * 1000);
|
||||
|
||||
const interval = setInterval(() => {
|
||||
runAutoRelease().catch(() => {});
|
||||
}, TWENTY_FOUR_HOURS);
|
||||
|
||||
nitro.hooks.hook("close", () => {
|
||||
clearTimeout(initialTimer);
|
||||
clearInterval(interval);
|
||||
});
|
||||
});
|
||||
|
||||
async function runAutoRelease() {
|
||||
try {
|
||||
const released = await autoReleaseInactiveDevices();
|
||||
if (released > 0) {
|
||||
consola.success(`[device-lock-cron] Auto-released ${released} inactive device locks (30d)`);
|
||||
} else {
|
||||
consola.info("[device-lock-cron] No inactive device locks to release");
|
||||
}
|
||||
} catch (err: any) {
|
||||
consola.error("[device-lock-cron] run failed:", err?.message ?? err);
|
||||
}
|
||||
}
|
||||
139
backend/server/utils/device-lock-email.ts
Normal file
139
backend/server/utils/device-lock-email.ts
Normal file
@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Device-Lock Email-Notification
|
||||
*
|
||||
* Wird versendet wenn ein fremder Account versucht sich auf einem gebundenen
|
||||
* Gerät (Pro/Legend) einzuloggen.
|
||||
*
|
||||
* Rate-Limit: max. 1 Mail pro Device pro 6h (lockNotifiedAt in UserDevice).
|
||||
*
|
||||
* Anonymität: nur nickname wird im Mail-Body gezeigt — NIEMALS firstName/email.
|
||||
* Siehe memory/feedback_anonymity_nickname.md
|
||||
*
|
||||
* Template-Inhalt ist bewusst sprachneutral (strukturierte Info) und NICHT
|
||||
* Lyra-Voice-formatiert (kein SOS-Ton). Plain informational.
|
||||
*/
|
||||
|
||||
import { Resend } from "resend";
|
||||
|
||||
const LOCK_NOTIFY_COOLDOWN_MS = 6 * 60 * 60 * 1000; // 6h
|
||||
|
||||
export interface DeviceLockEmailOpts {
|
||||
/** Nickname des Original-Users (der das Gerät besitzt) */
|
||||
recipientNickname: string;
|
||||
/** Email-Adresse des Original-Users (Supabase-Auth-Email — nur für Versand, erscheint NICHT im Body) */
|
||||
recipientEmail: string;
|
||||
/** Row-ID des UserDevice (für Release-Link) */
|
||||
deviceRowId: string;
|
||||
/** Lesbarer Name des Geräts (z.B. "Chahines iPhone") oder null */
|
||||
deviceName: string | null;
|
||||
/** lockNotifiedAt des Devices — Rate-Limit-Check */
|
||||
lockNotifiedAt: Date | null;
|
||||
/** Resend API Key — caller holt via useRuntimeConfig im Request-Context */
|
||||
resendApiKey: string;
|
||||
/** App-Base-URL für Links (z.B. "https://app.rebreak.org") */
|
||||
appBaseUrl?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt true zurück wenn innerhalb der letzten 6h bereits eine Mail für dieses
|
||||
* Device verschickt wurde (Rate-Limit-Check ohne DB-Call nötig).
|
||||
*/
|
||||
export function isLockNotifyRateLimited(lockNotifiedAt: Date | null): boolean {
|
||||
if (!lockNotifiedAt) return false;
|
||||
return Date.now() - lockNotifiedAt.getTime() < LOCK_NOTIFY_COOLDOWN_MS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sendet die Device-Lock-Notification per Resend.
|
||||
* Fire-and-forget Pattern: Caller sollte nicht auf Fehler warten.
|
||||
* Bei Fehler wird geloggt aber kein Throw (Mail-Fehler blockiert nie den Auth-Flow).
|
||||
*/
|
||||
export async function sendDeviceLockEmail(opts: DeviceLockEmailOpts): Promise<void> {
|
||||
if (!opts.resendApiKey) {
|
||||
console.warn("[device-lock-email] resendApiKey not provided — skipping mail");
|
||||
return;
|
||||
}
|
||||
|
||||
const resend = new Resend(opts.resendApiKey);
|
||||
|
||||
const baseUrl = opts.appBaseUrl ?? "https://app.rebreak.org";
|
||||
const deviceLabel = opts.deviceName ?? "dein Gerät";
|
||||
|
||||
// Deep-Link zur Devices-Settings-Page — Frontend rendert Release-Button
|
||||
const devicesUrl = `${baseUrl}/settings/devices`;
|
||||
const reviewUrl = `${baseUrl}/settings`;
|
||||
|
||||
const subject = `Anmeldeversuch auf ${deviceLabel}`;
|
||||
|
||||
const html = `
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>${subject}</title>
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; color: #1a1a1a; background: #f5f5f7; margin: 0; padding: 0; }
|
||||
.container { max-width: 560px; margin: 32px auto; background: #fff; border-radius: 12px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.08); }
|
||||
.header { background: #1a1a1a; padding: 24px 32px; }
|
||||
.header h1 { color: #fff; font-size: 18px; font-weight: 600; margin: 0; letter-spacing: -0.3px; }
|
||||
.body { padding: 28px 32px; }
|
||||
.body p { font-size: 15px; line-height: 1.6; color: #3a3a3a; margin: 0 0 16px; }
|
||||
.highlight { background: #f5f5f7; border-radius: 8px; padding: 12px 16px; margin: 20px 0; font-size: 14px; color: #555; }
|
||||
.actions { margin: 24px 0 0; display: flex; flex-direction: column; gap: 10px; }
|
||||
.btn { display: block; text-align: center; padding: 12px 20px; border-radius: 8px; font-size: 15px; font-weight: 500; text-decoration: none; }
|
||||
.btn-primary { background: #1a1a1a; color: #fff; }
|
||||
.btn-secondary { background: #f5f5f7; color: #1a1a1a; }
|
||||
.footer { padding: 16px 32px; font-size: 12px; color: #888; border-top: 1px solid #f0f0f0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>ReBreak — Sicherheitshinweis</h1>
|
||||
</div>
|
||||
<div class="body">
|
||||
<p>Hallo ${opts.recipientNickname},</p>
|
||||
<p>
|
||||
Auf <strong>${deviceLabel}</strong> hat sich jemand mit einem anderen Account angemeldet.
|
||||
Dein Gerät ist mit deinem ReBreak-Account verknüpft — der Anmeldeversuch wurde geblockt.
|
||||
</p>
|
||||
<div class="highlight">
|
||||
<strong>Was das bedeutet:</strong><br>
|
||||
Dein Schutz ist aktiv. Der andere Account hat auf diesem Gerät keinen Zugang bekommen.
|
||||
Falls das du warst und du den Account wechseln möchtest, kannst du das Gerät unten freigeben.
|
||||
</div>
|
||||
<p>
|
||||
<strong>Wenn das nicht du warst:</strong> Alles ist in Ordnung — kein Handlungsbedarf.
|
||||
Dein Schutz funktioniert wie gewünscht.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Wenn das du warst:</strong> Du kannst das Gerät freigeben.
|
||||
Bitte beachte: die Freigabe wird erst nach 24 Stunden wirksam.
|
||||
Das ist ein bewusstes Sicherheits-Feature — es schützt dich davor,
|
||||
im Drang-Moment impulsiv die Schutz-Bindung aufzuheben.
|
||||
</p>
|
||||
<div class="actions">
|
||||
<a href="${devicesUrl}" class="btn btn-primary">Meine Geräte verwalten</a>
|
||||
<a href="${reviewUrl}" class="btn btn-secondary">Account überprüfen</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer">
|
||||
Diese Mail wurde automatisch verschickt. Falls du Fragen hast, melde dich unter support@rebreak.org.
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`.trim();
|
||||
|
||||
try {
|
||||
await resend.emails.send({
|
||||
from: "ReBreak <noreply@rebreak.org>",
|
||||
to: opts.recipientEmail,
|
||||
subject,
|
||||
html,
|
||||
});
|
||||
} catch (err: any) {
|
||||
console.error("[device-lock-email] Failed to send:", err?.message ?? err);
|
||||
}
|
||||
}
|
||||
284
backend/tests/devices/device-account-binding.test.ts
Normal file
284
backend/tests/devices/device-account-binding.test.ts
Normal file
@ -0,0 +1,284 @@
|
||||
/**
|
||||
* 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();
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user