- Create @reviq/virtual-authenticator package with cryptographically valid WebAuthn credential generation for testing - Add e2e tests for WebAuthn registration, authentication, passkey management - Add unit tests for passkey-helpers and VirtualAuthenticator - Add security tests for counter replay and tampered responses - Configure test database environment in devenv.nix - Add turbo.json test tasks and workspace configuration Test results: 98 tests passing (54 virtual-authenticator, 25 e2e, 19 unit) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
192 lines
6.0 KiB
TypeScript
192 lines
6.0 KiB
TypeScript
/**
|
|
* Unit tests for passkey-helpers utility functions
|
|
*/
|
|
|
|
import type { PasskeyRow } from "../../utils/passkey-helpers.js";
|
|
import { describe, expect, test } from "bun:test";
|
|
import {
|
|
base64urlToUint8Array,
|
|
formatPasskeyDate,
|
|
parsePasskeyRow,
|
|
uint8ArrayToBase64url,
|
|
} from "../../utils/passkey-helpers.js";
|
|
|
|
describe("base64urlToUint8Array", () => {
|
|
test("converts base64url string to Uint8Array", () => {
|
|
// "Hello" in base64url
|
|
const base64url = "SGVsbG8";
|
|
const result = base64urlToUint8Array(base64url);
|
|
|
|
expect(result).toBeInstanceOf(Uint8Array);
|
|
expect(Buffer.from(result).toString()).toBe("Hello");
|
|
});
|
|
|
|
test("handles empty string", () => {
|
|
const result = base64urlToUint8Array("");
|
|
expect(result).toHaveLength(0);
|
|
});
|
|
|
|
test("handles base64url without padding", () => {
|
|
// "abc" in base64url (no padding needed)
|
|
const base64url = "YWJj";
|
|
const result = base64urlToUint8Array(base64url);
|
|
expect(Buffer.from(result).toString()).toBe("abc");
|
|
});
|
|
|
|
test("handles binary data with special characters", () => {
|
|
// Binary data that would have + and / in standard base64
|
|
const original = new Uint8Array([0xff, 0xfe, 0xfd, 0xfc]);
|
|
const base64url = Buffer.from(original).toString("base64url");
|
|
const result = base64urlToUint8Array(base64url);
|
|
|
|
expect(result).toEqual(original);
|
|
});
|
|
});
|
|
|
|
describe("uint8ArrayToBase64url", () => {
|
|
test("converts Uint8Array to base64url string", () => {
|
|
// "Hello" as bytes
|
|
const bytes = new Uint8Array([72, 101, 108, 108, 111]);
|
|
const result = uint8ArrayToBase64url(bytes);
|
|
|
|
expect(result).toBe("SGVsbG8");
|
|
});
|
|
|
|
test("handles empty array", () => {
|
|
const result = uint8ArrayToBase64url(new Uint8Array([]));
|
|
expect(result).toBe("");
|
|
});
|
|
|
|
test("produces URL-safe output (no +, /, or =)", () => {
|
|
// Binary data that would produce + and / in standard base64
|
|
const bytes = new Uint8Array([0xff, 0xfe, 0xfd, 0xfc, 0xfb, 0xfa]);
|
|
const result = uint8ArrayToBase64url(bytes);
|
|
|
|
expect(result).not.toContain("+");
|
|
expect(result).not.toContain("/");
|
|
expect(result).not.toContain("=");
|
|
});
|
|
|
|
test("roundtrips correctly with base64urlToUint8Array", () => {
|
|
const original = new Uint8Array([1, 2, 3, 4, 5, 100, 200, 255]);
|
|
const encoded = uint8ArrayToBase64url(original);
|
|
const decoded = base64urlToUint8Array(encoded);
|
|
|
|
expect(decoded).toEqual(original);
|
|
});
|
|
});
|
|
|
|
describe("parsePasskeyRow", () => {
|
|
const createMockRow = (overrides: Partial<PasskeyRow> = {}): PasskeyRow => ({
|
|
id: 1,
|
|
user_id: 100,
|
|
credential_id: new Uint8Array([1, 2, 3, 4, 5]),
|
|
public_key: new Uint8Array([10, 20, 30, 40, 50]),
|
|
webauthn_user_id: "webauthn-user-123",
|
|
counter: "42",
|
|
device_type: "multiDevice",
|
|
backup_eligible: true,
|
|
backup_status: true,
|
|
transports: ["internal", "hybrid"],
|
|
rpid: "localhost",
|
|
name: "Test Passkey",
|
|
last_used_at: new Date("2024-01-15T10:00:00Z"),
|
|
created_at: new Date("2024-01-01T00:00:00Z"),
|
|
...overrides,
|
|
});
|
|
|
|
test("converts DB row to ParsedPasskey format", () => {
|
|
const row = createMockRow();
|
|
const result = parsePasskeyRow(row);
|
|
|
|
expect(result.id).toBe(1);
|
|
expect(result.credentialId).toBe(uint8ArrayToBase64url(row.credential_id));
|
|
expect(result.publicKey).toBeInstanceOf(Uint8Array);
|
|
expect(result.counter).toBe(42);
|
|
expect(result.deviceType).toBe("multiDevice");
|
|
expect(result.backupEligible).toBe(true);
|
|
expect(result.backupStatus).toBe(true);
|
|
expect(result.transports).toEqual(["internal", "hybrid"]);
|
|
expect(result.rpid).toBe("localhost");
|
|
expect(result.name).toBe("Test Passkey");
|
|
});
|
|
|
|
test("handles string counter", () => {
|
|
const row = createMockRow({ counter: "100" });
|
|
const result = parsePasskeyRow(row);
|
|
expect(result.counter).toBe(100);
|
|
});
|
|
|
|
test("handles number counter", () => {
|
|
const row = createMockRow({ counter: 200 });
|
|
const result = parsePasskeyRow(row);
|
|
expect(result.counter).toBe(200);
|
|
});
|
|
|
|
test("handles bigint counter", () => {
|
|
const row = createMockRow({ counter: BigInt(300) });
|
|
const result = parsePasskeyRow(row);
|
|
expect(result.counter).toBe(300);
|
|
});
|
|
|
|
test("handles null transports", () => {
|
|
const row = createMockRow({ transports: null });
|
|
const result = parsePasskeyRow(row);
|
|
expect(result.transports).toBeNull();
|
|
});
|
|
|
|
test("handles null last_used_at", () => {
|
|
const row = createMockRow({ last_used_at: null });
|
|
const result = parsePasskeyRow(row);
|
|
expect(result.lastUsedAt).toBeNull();
|
|
});
|
|
|
|
test("preserves created_at date", () => {
|
|
const createdAt = new Date("2024-06-15T12:00:00Z");
|
|
const row = createMockRow({ created_at: createdAt });
|
|
const result = parsePasskeyRow(row);
|
|
expect(result.createdAt).toEqual(createdAt);
|
|
});
|
|
|
|
test("handles singleDevice device type", () => {
|
|
const row = createMockRow({ device_type: "singleDevice" });
|
|
const result = parsePasskeyRow(row);
|
|
expect(result.deviceType).toBe("singleDevice");
|
|
});
|
|
});
|
|
|
|
describe("formatPasskeyDate", () => {
|
|
test("formats date with month, day, year, and time", () => {
|
|
const date = new Date("2024-01-15T10:30:00");
|
|
const result = formatPasskeyDate(date);
|
|
|
|
// Should contain month abbreviation
|
|
expect(result).toContain("Jan");
|
|
// Should contain day
|
|
expect(result).toContain("15");
|
|
// Should contain year
|
|
expect(result).toContain("2024");
|
|
});
|
|
|
|
test("includes time component", () => {
|
|
const date = new Date("2024-06-20T14:45:00");
|
|
const result = formatPasskeyDate(date);
|
|
|
|
// Should contain time in some format
|
|
expect(result).toMatch(/\d{1,2}:\d{2}/);
|
|
});
|
|
|
|
test("handles different months", () => {
|
|
const dates = [
|
|
{ date: new Date("2024-03-01"), month: "Mar" },
|
|
{ date: new Date("2024-07-15"), month: "Jul" },
|
|
{ date: new Date("2024-12-31"), month: "Dec" },
|
|
];
|
|
|
|
for (const { date, month } of dates) {
|
|
const result = formatPasskeyDate(date);
|
|
expect(result).toContain(month);
|
|
}
|
|
});
|
|
});
|