feat: add worker unit tests for CryptID auth handlers
- 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 <noreply@anthropic.com>
This commit is contained in:
parent
a662b4798f
commit
4f6ff1797f
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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<string, unknown> = 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> = {}): 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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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')
|
||||
}
|
||||
}
|
||||
})
|
||||
Loading…
Reference in New Issue