import "./setup"; import assert from "node:assert/strict"; import test, { after, before, beforeEach } from "node:test"; import { prisma } from "../src/lib/db"; import { deletePasskey, listPasskeys, renamePasskey } from "../src/lib/passkeys"; import { env } from "../src/lib/env"; /** * Prüft die Verwaltung der Passkeys und die Sperre gegen das Aussperren. * * Die eigentliche WebAuthn-Prüfung braucht einen echten Authenticator und * läuft deshalb im Klickweg mit virtuellem Authenticator, nicht hier. */ const MIT_PASSWORT = "test-passkey-pw@flimmer.invalid"; const OHNE_PASSWORT = "test-passkey-nopw@flimmer.invalid"; let mitPasswortId: string; let ohnePasswortId: string; let zaehler = 0; async function passkeyAnlegen(userId: string, name: string) { zaehler += 1; return prisma.passkey.create({ data: { userId, credentialId: `test-credential-${zaehler}-${Date.now()}`, publicKey: Buffer.from([1, 2, 3]), name, }, select: { id: true }, }); } async function aufraeumen() { await prisma.user.deleteMany({ where: { email: { in: [MIT_PASSWORT, OHNE_PASSWORT] } } }); } before(async () => { await aufraeumen(); mitPasswortId = ( await prisma.user.create({ data: { email: MIT_PASSWORT, name: "Mit Passwort", passwordHash: "$2a$10$platzhalter" }, select: { id: true }, }) ).id; ohnePasswortId = ( await prisma.user.create({ data: { email: OHNE_PASSWORT, name: "Ohne Passwort" }, select: { id: true }, }) ).id; }); beforeEach(async () => { await prisma.passkey.deleteMany({ where: { userId: { in: [mitPasswortId, ohnePasswortId] } } }); }); after(async () => { await aufraeumen(); await prisma.$disconnect(); }); test("die RP ID kommt aus APP_URL, nicht aus dem Host-Header", () => { // Ein Passkey haengt fest an diesem Namen — hinter einem Reverse Proxy waere // der Host-Header der falsche Ort, ihn herzunehmen. assert.equal(env.passkeys.rpId, new URL(env.appUrl).hostname); assert.equal(env.passkeys.rpId, "localhost"); }); test("ueber localhost sind Passkeys erlaubt, ueber nacktes HTTP nicht", () => { assert.equal(env.passkeys.isAvailable, true, "localhost gilt als sicherer Kontext"); }); test("Passkeys lassen sich auflisten, umbenennen und entfernen", async () => { const passkey = await passkeyAnlegen(mitPasswortId, "Erstes Geraet"); const liste = await listPasskeys(mitPasswortId); assert.equal(liste.length, 1); assert.equal(liste[0]!.name, "Erstes Geraet"); assert.equal(liste[0]!.lastUsedAt, null, "frisch angelegt, noch nicht benutzt"); await renamePasskey(mitPasswortId, passkey.id, " Yubikey am Schluesselbund "); assert.equal( (await listPasskeys(mitPasswortId))[0]!.name, "Yubikey am Schluesselbund", "Leerraum wird abgeschnitten", ); await deletePasskey(mitPasswortId, passkey.id); assert.equal((await listPasskeys(mitPasswortId)).length, 0); }); test("fremde Passkeys sind unerreichbar", async () => { const fremd = await passkeyAnlegen(ohnePasswortId, "Fremdes Geraet"); await assert.rejects(() => renamePasskey(mitPasswortId, fremd.id, "Meiner"), /gibt es nicht/); await assert.rejects(() => deletePasskey(mitPasswortId, fremd.id), /gibt es nicht/); // Und er steht unveraendert beim Eigentuemer. const liste = await listPasskeys(ohnePasswortId); assert.equal(liste.length, 1); assert.equal(liste[0]!.name, "Fremdes Geraet"); }); test("der letzte Anmeldeweg laesst sich nicht entfernen", async () => { // Konto ohne Passwort, nur ein Passkey: das Entfernen wuerde aussperren. const einziger = await passkeyAnlegen(ohnePasswortId, "Einziger Weg"); await assert.rejects( () => deletePasskey(ohnePasswortId, einziger.id), /letzter Anmeldeweg/, "ohne anderen Weg bleibt der Passkey stehen", ); assert.equal((await listPasskeys(ohnePasswortId)).length, 1); // Mit einem zweiten Passkey geht es dann. await passkeyAnlegen(ohnePasswortId, "Zweiter Weg"); await deletePasskey(ohnePasswortId, einziger.id); assert.equal((await listPasskeys(ohnePasswortId)).length, 1); }); test("mit Passwort darf auch der einzige Passkey weg", async () => { const passkey = await passkeyAnlegen(mitPasswortId, "Einziger Passkey"); await deletePasskey(mitPasswortId, passkey.id); assert.equal((await listPasskeys(mitPasswortId)).length, 0); }); test("das Loeschen eines Kontos nimmt seine Passkeys mit", async () => { const email = "test-passkey-weg@flimmer.invalid"; await prisma.user.deleteMany({ where: { email } }); const user = await prisma.user.create({ data: { email, name: "Weg" }, select: { id: true } }); await passkeyAnlegen(user.id, "Verschwindet"); await prisma.user.delete({ where: { id: user.id } }); assert.equal(await prisma.passkey.count({ where: { userId: user.id } }), 0); });