Emdash source with visual editor image upload fix
Fixes: 1. media.ts: wrap placeholder generation in try-catch 2. toolbar.ts: check r.ok, display error message in popover
This commit is contained in:
278
packages/core/tests/unit/auth/passkey-management.test.ts
Normal file
278
packages/core/tests/unit/auth/passkey-management.test.ts
Normal file
@@ -0,0 +1,278 @@
|
||||
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<Database>;
|
||||
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<Credential> {
|
||||
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());
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user