From 4f6ff1797f6ea8d87e45d7db8b9455a1e13e3d73 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Thu, 18 Dec 2025 02:46:28 -0500 Subject: [PATCH] feat: add worker unit tests for CryptID auth handlers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Create 25 unit tests for CryptID authentication handlers - Add vitest.worker.config.ts for worker test environment - Update CI workflow to run worker tests - Test coverage for: - handleCheckUsername (validation, normalization) - handleLinkEmail (validation, database errors) - handleVerifyEmail (token validation) - handleRequestDeviceLink (validation, 404 handling) - handleLinkDevice (token validation) - handleLookup (publicKey validation) - handleGetDevices (auth validation) - handleRevokeDevice (auth and validation) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .github/workflows/test.yml | 3 + package.json | 3 +- tests/worker/cryptid-auth.test.ts | 443 ++++++++++++++++++++++++++++++ vitest.worker.config.ts | 25 ++ 4 files changed, 473 insertions(+), 1 deletion(-) create mode 100644 tests/worker/cryptid-auth.test.ts create mode 100644 vitest.worker.config.ts diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index fd80ddb..1b012b7 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -30,6 +30,9 @@ jobs: - name: Run unit tests with coverage run: npm run test:coverage + - name: Run worker tests + run: npm run test:worker + - name: Upload coverage to Codecov uses: codecov/codecov-action@v4 with: diff --git a/package.json b/package.json index f5de006..eca4e1c 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,8 @@ "test:e2e": "playwright test", "test:e2e:ui": "playwright test --ui", "test:e2e:headed": "playwright test --headed", - "test:all": "vitest run && playwright test", + "test:worker": "vitest run --config vitest.worker.config.ts", + "test:all": "vitest run && vitest run --config vitest.worker.config.ts && playwright test", "multmux:install": "npm install --workspaces", "multmux:build": "npm run build --workspace=@multmux/server --workspace=@multmux/cli", "multmux:dev:server": "npm run dev --workspace=@multmux/server", diff --git a/tests/worker/cryptid-auth.test.ts b/tests/worker/cryptid-auth.test.ts new file mode 100644 index 0000000..d30f7b0 --- /dev/null +++ b/tests/worker/cryptid-auth.test.ts @@ -0,0 +1,443 @@ +/** + * Worker tests for CryptID Authentication handlers + * + * Tests the D1-backed auth handlers with mocked database responses. + * This tests the business logic without requiring a full Miniflare setup. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { + handleCheckUsername, + handleLinkEmail, + handleVerifyEmail, + handleRequestDeviceLink, + handleLinkDevice, + handleLookup, + handleGetDevices, + handleRevokeDevice, +} from '../../worker/cryptidAuth' +import type { Environment } from '../../worker/types' + +// Mock D1 database +function createMockD1() { + const mockResults: Map = new Map() + + return { + prepare: vi.fn((sql: string) => ({ + bind: vi.fn((...args: unknown[]) => ({ + first: vi.fn(async () => { + // Return mock data based on query and args + const key = `${sql}:${JSON.stringify(args)}` + return mockResults.get(key) ?? null + }), + all: vi.fn(async () => ({ + results: mockResults.get(`all:${sql}`) ?? [] + })), + run: vi.fn(async () => ({ success: true })), + })), + first: vi.fn(async () => null), + all: vi.fn(async () => ({ results: [] })), + run: vi.fn(async () => ({ success: true })), + })), + // Helper to set mock return values + __setMockResult: (key: string, value: unknown) => { + mockResults.set(key, value) + }, + __clearMocks: () => { + mockResults.clear() + }, + } +} + +// Create mock environment +function createMockEnv(overrides: Partial = {}): Environment { + return { + TLDRAW_BUCKET: {} as R2Bucket, + BOARD_BACKUPS_BUCKET: {} as R2Bucket, + AUTOMERGE_DURABLE_OBJECT: {} as DurableObjectNamespace, + DAILY_API_KEY: 'mock-daily-key', + DAILY_DOMAIN: 'mock.daily.co', + CRYPTID_DB: createMockD1() as unknown as D1Database, + RESEND_API_KEY: 'mock-resend-key', + APP_URL: 'https://test.example.com', + ...overrides, + } +} + +// Create mock request +function createMockRequest( + url: string, + options: RequestInit = {} +): Request { + return new Request(url, { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + ...options, + }) +} + +describe('handleCheckUsername', () => { + let env: Environment + + beforeEach(() => { + env = createMockEnv() + }) + + it('returns error when username is missing', async () => { + const request = createMockRequest('https://test.com/auth/check-username') + const response = await handleCheckUsername(request, env) + const data = await response.json() as { error: string } + + expect(response.status).toBe(400) + expect(data.error).toBe('Username is required') + }) + + it('returns unavailable for username too short', async () => { + const request = createMockRequest('https://test.com/auth/check-username?username=ab') + const response = await handleCheckUsername(request, env) + const data = await response.json() as { available: boolean; error: string } + + expect(data.available).toBe(false) + expect(data.error).toBe('Username must be at least 3 characters') + }) + + it('returns unavailable for username too long', async () => { + const longUsername = 'a'.repeat(21) + const request = createMockRequest(`https://test.com/auth/check-username?username=${longUsername}`) + const response = await handleCheckUsername(request, env) + const data = await response.json() as { available: boolean; error: string } + + expect(data.available).toBe(false) + expect(data.error).toBe('Username must be 20 characters or less') + }) + + it('normalizes username to lowercase', async () => { + const request = createMockRequest('https://test.com/auth/check-username?username=TestUser') + const response = await handleCheckUsername(request, env) + const data = await response.json() as { username: string } + + expect(data.username).toBe('testuser') + }) + + it('returns available when username does not exist', async () => { + const request = createMockRequest('https://test.com/auth/check-username?username=newuser') + const response = await handleCheckUsername(request, env) + const data = await response.json() as { available: boolean } + + expect(data.available).toBe(true) + }) + + it('returns available true when no database configured', async () => { + const envWithoutDb = createMockEnv({ CRYPTID_DB: undefined }) + const request = createMockRequest('https://test.com/auth/check-username?username=testuser') + const response = await handleCheckUsername(request, envWithoutDb) + const data = await response.json() as { available: boolean } + + expect(data.available).toBe(true) + }) +}) + +describe('handleLinkEmail', () => { + let env: Environment + + beforeEach(() => { + env = createMockEnv() + // Mock fetch for email sending + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ id: 'mock-email-id' }), + }) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('returns error when required fields are missing', async () => { + const request = createMockRequest('https://test.com/auth/link-email', { + method: 'POST', + body: JSON.stringify({ email: 'test@example.com' }), + }) + const response = await handleLinkEmail(request, env) + const data = await response.json() as { error: string } + + expect(response.status).toBe(400) + expect(data.error).toBe('Missing required fields') + }) + + it('returns error for invalid email format', async () => { + const request = createMockRequest('https://test.com/auth/link-email', { + method: 'POST', + body: JSON.stringify({ + email: 'not-an-email', + cryptidUsername: 'testuser', + publicKey: 'mock-public-key', + }), + }) + const response = await handleLinkEmail(request, env) + const data = await response.json() as { error: string } + + expect(response.status).toBe(400) + expect(data.error).toBe('Invalid email format') + }) + + it('returns error when database not configured', async () => { + const envWithoutDb = createMockEnv({ CRYPTID_DB: undefined }) + const request = createMockRequest('https://test.com/auth/link-email', { + method: 'POST', + body: JSON.stringify({ + email: 'test@example.com', + cryptidUsername: 'testuser', + publicKey: 'mock-public-key', + }), + }) + const response = await handleLinkEmail(request, envWithoutDb) + const data = await response.json() as { error: string } + + expect(response.status).toBe(503) + expect(data.error).toBe('Database not configured') + }) +}) + +describe('handleVerifyEmail', () => { + let env: Environment + + beforeEach(() => { + env = createMockEnv() + }) + + it('returns error when database not configured', async () => { + const envWithoutDb = createMockEnv({ CRYPTID_DB: undefined }) + const response = await handleVerifyEmail('test-token', envWithoutDb) + const data = await response.json() as { error: string } + + expect(response.status).toBe(503) + expect(data.error).toBe('Database not configured') + }) + + it('returns error for invalid/expired token', async () => { + const response = await handleVerifyEmail('invalid-token', env) + const data = await response.json() as { error: string } + + expect(response.status).toBe(400) + expect(data.error).toBe('Invalid or expired token') + }) +}) + +describe('handleRequestDeviceLink', () => { + let env: Environment + + beforeEach(() => { + env = createMockEnv() + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ id: 'mock-email-id' }), + }) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('returns error when required fields are missing', async () => { + const request = createMockRequest('https://test.com/auth/request-device-link', { + method: 'POST', + body: JSON.stringify({ email: 'test@example.com' }), + }) + const response = await handleRequestDeviceLink(request, env) + const data = await response.json() as { error: string } + + expect(response.status).toBe(400) + expect(data.error).toBe('Missing required fields') + }) + + it('returns error when database not configured', async () => { + const envWithoutDb = createMockEnv({ CRYPTID_DB: undefined }) + const request = createMockRequest('https://test.com/auth/request-device-link', { + method: 'POST', + body: JSON.stringify({ + email: 'test@example.com', + publicKey: 'mock-public-key', + }), + }) + const response = await handleRequestDeviceLink(request, envWithoutDb) + const data = await response.json() as { error: string } + + expect(response.status).toBe(503) + expect(data.error).toBe('Database not configured') + }) + + it('returns error when no verified account found', async () => { + const request = createMockRequest('https://test.com/auth/request-device-link', { + method: 'POST', + body: JSON.stringify({ + email: 'nonexistent@example.com', + publicKey: 'mock-public-key', + }), + }) + const response = await handleRequestDeviceLink(request, env) + const data = await response.json() as { error: string } + + expect(response.status).toBe(404) + expect(data.error).toBe('No verified CryptID account found for this email') + }) +}) + +describe('handleLinkDevice', () => { + let env: Environment + + beforeEach(() => { + env = createMockEnv() + }) + + it('returns error when database not configured', async () => { + const envWithoutDb = createMockEnv({ CRYPTID_DB: undefined }) + const response = await handleLinkDevice('test-token', envWithoutDb) + const data = await response.json() as { error: string } + + expect(response.status).toBe(503) + expect(data.error).toBe('Database not configured') + }) + + it('returns error for invalid/expired token', async () => { + const response = await handleLinkDevice('invalid-token', env) + const data = await response.json() as { error: string } + + expect(response.status).toBe(400) + expect(data.error).toBe('Invalid or expired token') + }) +}) + +describe('handleLookup', () => { + let env: Environment + + beforeEach(() => { + env = createMockEnv() + }) + + it('returns error when publicKey is missing', async () => { + const request = createMockRequest('https://test.com/auth/lookup', { + method: 'POST', + body: JSON.stringify({}), + }) + const response = await handleLookup(request, env) + const data = await response.json() as { error: string } + + expect(response.status).toBe(400) + expect(data.error).toBe('Missing publicKey') + }) + + it('returns error when database not configured', async () => { + const envWithoutDb = createMockEnv({ CRYPTID_DB: undefined }) + const request = createMockRequest('https://test.com/auth/lookup', { + method: 'POST', + body: JSON.stringify({ publicKey: 'mock-public-key' }), + }) + const response = await handleLookup(request, envWithoutDb) + const data = await response.json() as { error: string } + + expect(response.status).toBe(503) + expect(data.error).toBe('Database not configured') + }) + + it('returns found: false when publicKey not in database', async () => { + const request = createMockRequest('https://test.com/auth/lookup', { + method: 'POST', + body: JSON.stringify({ publicKey: 'unknown-public-key' }), + }) + const response = await handleLookup(request, env) + const data = await response.json() as { found: boolean } + + expect(data.found).toBe(false) + }) +}) + +describe('handleGetDevices', () => { + let env: Environment + + beforeEach(() => { + env = createMockEnv() + }) + + it('returns error when publicKey is missing', async () => { + const request = createMockRequest('https://test.com/auth/devices', { + method: 'POST', + body: JSON.stringify({}), + }) + const response = await handleGetDevices(request, env) + const data = await response.json() as { error: string } + + expect(response.status).toBe(400) + expect(data.error).toBe('Missing publicKey') + }) + + it('returns error when database not configured', async () => { + const envWithoutDb = createMockEnv({ CRYPTID_DB: undefined }) + const request = createMockRequest('https://test.com/auth/devices', { + method: 'POST', + body: JSON.stringify({ publicKey: 'mock-public-key' }), + }) + const response = await handleGetDevices(request, envWithoutDb) + const data = await response.json() as { error: string } + + expect(response.status).toBe(503) + expect(data.error).toBe('Database not configured') + }) + + it('returns error when device not found', async () => { + const request = createMockRequest('https://test.com/auth/devices', { + method: 'POST', + body: JSON.stringify({ publicKey: 'unknown-public-key' }), + }) + const response = await handleGetDevices(request, env) + const data = await response.json() as { error: string } + + expect(response.status).toBe(404) + expect(data.error).toBe('Device not found') + }) +}) + +describe('handleRevokeDevice', () => { + let env: Environment + + beforeEach(() => { + env = createMockEnv() + }) + + it('returns error when publicKey is missing', async () => { + const request = createMockRequest('https://test.com/auth/devices/device-123', { + method: 'DELETE', + body: JSON.stringify({}), + }) + const response = await handleRevokeDevice('device-123', request, env) + const data = await response.json() as { error: string } + + expect(response.status).toBe(400) + expect(data.error).toBe('Missing publicKey') + }) + + it('returns error when database not configured', async () => { + const envWithoutDb = createMockEnv({ CRYPTID_DB: undefined }) + const request = createMockRequest('https://test.com/auth/devices/device-123', { + method: 'DELETE', + body: JSON.stringify({ publicKey: 'mock-public-key' }), + }) + const response = await handleRevokeDevice('device-123', request, envWithoutDb) + const data = await response.json() as { error: string } + + expect(response.status).toBe(503) + expect(data.error).toBe('Database not configured') + }) + + it('returns unauthorized when device not found', async () => { + const request = createMockRequest('https://test.com/auth/devices/device-123', { + method: 'DELETE', + body: JSON.stringify({ publicKey: 'unknown-public-key' }), + }) + const response = await handleRevokeDevice('device-123', request, env) + const data = await response.json() as { error: string } + + expect(response.status).toBe(401) + expect(data.error).toBe('Unauthorized') + }) +}) diff --git a/vitest.worker.config.ts b/vitest.worker.config.ts new file mode 100644 index 0000000..f991828 --- /dev/null +++ b/vitest.worker.config.ts @@ -0,0 +1,25 @@ +import { defineConfig } from 'vitest/config' +import path from 'path' + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + include: ['tests/worker/**/*.test.ts'], + coverage: { + provider: 'v8', + reporter: ['text', 'html', 'lcov'], + include: ['worker/**/*.ts'], + exclude: [ + 'worker/**/*.d.ts', + 'worker/wasm.d.ts', + ], + }, + testTimeout: 10000, + }, + resolve: { + alias: { + '@': path.resolve(__dirname, './src') + } + } +})