import type { AuthAdapter, Credential, User } from "@emdash-cms/auth"; import { Role } from "@emdash-cms/auth"; import { createKyselyAdapter } from "@emdash-cms/auth/adapters/kysely"; import type { Kysely } from "kysely"; import { describe, it, expect, beforeEach, afterEach } from "vitest"; import type { Database } from "../../../src/database/types.js"; import { setupTestDatabase, teardownTestDatabase } from "../../utils/test-db.js"; describe("Passkey Management", () => { let db: Kysely; let adapter: AuthAdapter; let testUser: User; beforeEach(async () => { db = await setupTestDatabase(); adapter = createKyselyAdapter(db); // Create a test user testUser = await adapter.createUser({ email: "test@example.com", name: "Test User", role: Role.ADMIN, }); }); afterEach(async () => { await teardownTestDatabase(db); }); // Helper to create a test credential async function createTestCredential(userId: string, name?: string): Promise { const credentialId = `cred-${Date.now()}-${Math.random().toString(36).slice(2)}`; return adapter.createCredential({ id: credentialId, userId, publicKey: new Uint8Array([1, 2, 3, 4]), counter: 0, deviceType: "multiDevice", backedUp: true, transports: ["internal"], name: name ?? null, }); } describe("getCredentialById", () => { it("should return credential by ID", async () => { const created = await createTestCredential(testUser.id, "My MacBook"); const credential = await adapter.getCredentialById(created.id); expect(credential).not.toBeNull(); expect(credential?.id).toBe(created.id); expect(credential?.userId).toBe(testUser.id); expect(credential?.name).toBe("My MacBook"); expect(credential?.deviceType).toBe("multiDevice"); expect(credential?.backedUp).toBe(true); }); it("should return null for non-existent credential", async () => { const credential = await adapter.getCredentialById("non-existent"); expect(credential).toBeNull(); }); }); describe("getCredentialsByUserId", () => { it("should return empty array for user with no passkeys", async () => { const credentials = await adapter.getCredentialsByUserId(testUser.id); expect(credentials).toEqual([]); }); it("should return all passkeys for a user", async () => { await createTestCredential(testUser.id, "MacBook Pro"); await createTestCredential(testUser.id, "iPhone"); await createTestCredential(testUser.id, null); const credentials = await adapter.getCredentialsByUserId(testUser.id); expect(credentials).toHaveLength(3); const names = credentials.map((c) => c.name); expect(names).toContain("MacBook Pro"); expect(names).toContain("iPhone"); expect(names).toContain(null); }); it("should not return passkeys from other users", async () => { const otherUser = await adapter.createUser({ email: "other@example.com", name: "Other User", }); await createTestCredential(testUser.id, "Test User Passkey"); await createTestCredential(otherUser.id, "Other User Passkey"); const testUserCreds = await adapter.getCredentialsByUserId(testUser.id); const otherUserCreds = await adapter.getCredentialsByUserId(otherUser.id); expect(testUserCreds).toHaveLength(1); expect(testUserCreds[0].name).toBe("Test User Passkey"); expect(otherUserCreds).toHaveLength(1); expect(otherUserCreds[0].name).toBe("Other User Passkey"); }); }); describe("updateCredentialName", () => { it("should update the credential name", async () => { const credential = await createTestCredential(testUser.id, "Old Name"); await adapter.updateCredentialName(credential.id, "New Name"); const updated = await adapter.getCredentialById(credential.id); expect(updated?.name).toBe("New Name"); }); it("should set name to null when provided null", async () => { const credential = await createTestCredential(testUser.id, "Has Name"); await adapter.updateCredentialName(credential.id, null); const updated = await adapter.getCredentialById(credential.id); expect(updated?.name).toBeNull(); }); it("should handle empty string as name", async () => { const credential = await createTestCredential(testUser.id, "Has Name"); await adapter.updateCredentialName(credential.id, ""); const updated = await adapter.getCredentialById(credential.id); expect(updated?.name).toBe(""); }); }); describe("countCredentialsByUserId", () => { it("should return 0 for user with no passkeys", async () => { const count = await adapter.countCredentialsByUserId(testUser.id); expect(count).toBe(0); }); it("should return correct count", async () => { await createTestCredential(testUser.id); await createTestCredential(testUser.id); await createTestCredential(testUser.id); const count = await adapter.countCredentialsByUserId(testUser.id); expect(count).toBe(3); }); it("should only count credentials for the specified user", async () => { const otherUser = await adapter.createUser({ email: "other@example.com", }); await createTestCredential(testUser.id); await createTestCredential(testUser.id); await createTestCredential(otherUser.id); const testUserCount = await adapter.countCredentialsByUserId(testUser.id); const otherUserCount = await adapter.countCredentialsByUserId(otherUser.id); expect(testUserCount).toBe(2); expect(otherUserCount).toBe(1); }); }); describe("deleteCredential", () => { it("should delete a credential", async () => { const credential = await createTestCredential(testUser.id); await adapter.deleteCredential(credential.id); const deleted = await adapter.getCredentialById(credential.id); expect(deleted).toBeNull(); }); it("should not affect other credentials", async () => { await createTestCredential(testUser.id, "Keep This"); const cred2 = await createTestCredential(testUser.id, "Delete This"); await adapter.deleteCredential(cred2.id); const remaining = await adapter.getCredentialsByUserId(testUser.id); expect(remaining).toHaveLength(1); expect(remaining[0].name).toBe("Keep This"); }); }); describe("Passkey Management Flow", () => { it("should support full CRUD flow", async () => { // Create passkeys const passkey1 = await createTestCredential(testUser.id, "MacBook"); const passkey2 = await createTestCredential(testUser.id, "iPhone"); // List passkeys let passkeys = await adapter.getCredentialsByUserId(testUser.id); expect(passkeys).toHaveLength(2); // Rename a passkey await adapter.updateCredentialName(passkey1.id, "MacBook Pro M3"); const renamed = await adapter.getCredentialById(passkey1.id); expect(renamed?.name).toBe("MacBook Pro M3"); // Delete a passkey (not the last one) const countBefore = await adapter.countCredentialsByUserId(testUser.id); expect(countBefore).toBe(2); await adapter.deleteCredential(passkey2.id); const countAfter = await adapter.countCredentialsByUserId(testUser.id); expect(countAfter).toBe(1); // Verify only one remains passkeys = await adapter.getCredentialsByUserId(testUser.id); expect(passkeys).toHaveLength(1); expect(passkeys[0].name).toBe("MacBook Pro M3"); }); it("should enforce 'cannot delete last passkey' in application logic", async () => { // Create a single passkey const passkey = await createTestCredential(testUser.id, "Only Passkey"); // Check count before deletion attempt const count = await adapter.countCredentialsByUserId(testUser.id); expect(count).toBe(1); // Application should check count and prevent deletion // The adapter itself doesn't enforce this - it's the API layer's job if (count <= 1) { // Don't delete - this is what the API should do const stillExists = await adapter.getCredentialById(passkey.id); expect(stillExists).not.toBeNull(); } }); }); describe("Credential properties", () => { it("should preserve all credential properties", async () => { await adapter.createCredential({ id: "test-cred-123", userId: testUser.id, publicKey: new Uint8Array([10, 20, 30, 40, 50]), counter: 5, deviceType: "singleDevice", backedUp: false, transports: ["usb", "nfc"], name: "YubiKey 5", }); const retrieved = await adapter.getCredentialById("test-cred-123"); expect(retrieved).not.toBeNull(); expect(retrieved?.id).toBe("test-cred-123"); expect(retrieved?.userId).toBe(testUser.id); expect(retrieved?.counter).toBe(5); expect(retrieved?.deviceType).toBe("singleDevice"); expect(retrieved?.backedUp).toBe(false); expect(retrieved?.transports).toEqual(["usb", "nfc"]); expect(retrieved?.name).toBe("YubiKey 5"); expect(retrieved?.createdAt).toBeInstanceOf(Date); expect(retrieved?.lastUsedAt).toBeInstanceOf(Date); }); it("should update lastUsedAt when counter is updated", async () => { const credential = await createTestCredential(testUser.id); const originalLastUsed = credential.lastUsedAt; // Small delay to ensure time difference await new Promise((resolve) => setTimeout(resolve, 10)); await adapter.updateCredentialCounter(credential.id, 1); const updated = await adapter.getCredentialById(credential.id); expect(updated?.counter).toBe(1); expect(updated?.lastUsedAt.getTime()).toBeGreaterThan(originalLastUsed.getTime()); }); }); });