import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { Hono } from 'hono' import { authRouter } from './auth.js' import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools' const app = new Hono() app.route('/api/auth', authRouter) function makeNip98Header(sk: Uint8Array, url: string, method: string) { const event = finalizeEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), tags: [['u', url], ['method', method]], content: '', }, sk) return `Nostr ${Buffer.from(JSON.stringify(event)).toString('base64')}` } describe('auth routes', () => { // --- check-name --- it('check-name: rejects short name', async () => { const res = await app.request('/api/auth/check-name/x') const body = await res.json() as { available: boolean; error?: string } expect(body.available).toBe(false) expect(body.error).toContain('2-12') }) it('check-name: rejects long name (>12)', async () => { const res = await app.request('/api/auth/check-name/verylongbotnamehere') const body = await res.json() as { available: boolean; error?: string } expect(body.available).toBe(false) }) it('check-name: available name returns true', async () => { const name = `t${Date.now().toString(36).slice(-6)}` const res = await app.request(`/api/auth/check-name/${name}`) const body = await res.json() as { available: boolean } expect(body.available).toBe(true) }) // --- login --- it('login: rejects invalid pubkey (too short)', async () => { const res = await app.request('/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ pubkey: 'tooshort' }), }) expect(res.status).toBe(400) const body = await res.json() as { error: string } expect(body.error).toContain('Invalid pubkey') }) it('login: rejects missing pubkey', async () => { const res = await app.request('/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}), }) expect(res.status).toBe(400) }) it('login: returns exists=false for unknown pubkey', async () => { const pk = '0'.repeat(64) const res = await app.request('/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ pubkey: pk }), }) expect(res.status).toBe(200) const body = await res.json() as { exists: boolean; pubkey?: string } expect(body.exists).toBe(false) expect(body.pubkey).toBe(pk) }) // --- register --- it('register: rejects invalid pubkey', async () => { const res = await app.request('/api/auth/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ pubkey: 'bad', name: 'test-bot' }), }) expect(res.status).toBe(400) const body = await res.json() as { error: string } expect(body.error).toContain('Invalid pubkey') }) it('register: rejects invalid name (special chars)', async () => { const res = await app.request('/api/auth/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ pubkey: 'a'.repeat(64), name: 'hello world!' }), }) expect(res.status).toBe(400) const body = await res.json() as { error: string } expect(body.error).toContain('alphanumeric') }) it('register: rejects name too short', async () => { const res = await app.request('/api/auth/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ pubkey: 'b'.repeat(64), name: 'x' }), }) expect(res.status).toBe(400) const body = await res.json() as { error: string } expect(body.error).toContain('2-12') }) // --- register-human --- it('register-human: rejects invalid pubkey', async () => { const res = await app.request('/api/auth/register-human', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ pubkey: 'short', name: 'human1' }), }) expect(res.status).toBe(400) }) // --- nostr/session --- it('nostr/session: rejects missing auth header', async () => { const res = await app.request('/api/auth/nostr/session', { method: 'POST', }) expect(res.status).toBe(401) }) it('nostr/session: accepts valid NIP-98 token', async () => { const sk = generateSecretKey() const pk = getPublicKey(sk) const header = makeNip98Header(sk, 'https://localhost/api/auth/nostr/session', 'POST') const res = await app.request('/api/auth/nostr/session', { method: 'POST', headers: { Authorization: header }, }) expect(res.status).toBe(200) const body = await res.json() as { token: string; pubkey: string; exists: boolean } expect(body.token).toBeDefined() expect(body.pubkey).toBe(pk) expect(body.exists).toBe(false) }) it('nostr/session: rejects expired NIP-98 token', async () => { const sk = generateSecretKey() const event = finalizeEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000) - 300, tags: [['u', 'https://localhost/api/auth/nostr/session'], ['method', 'POST']], content: '', }, sk) const header = `Nostr ${Buffer.from(JSON.stringify(event)).toString('base64')}` const res = await app.request('/api/auth/nostr/session', { method: 'POST', headers: { Authorization: header }, }) expect(res.status).toBe(401) }) }) describe('auth rate limiting', () => { let prodApp: InstanceType let cleanup: ReturnType beforeEach(async () => { vi.resetModules() process.env.NODE_ENV = 'production' process.env.JWT_SECRET = 'test-secret-for-rate-limit-testing' const rateLimitMod = await import('../middleware/rate-limit.js') cleanup = rateLimitMod.cleanupInterval const authMod = await import('./auth.js') prodApp = new Hono() prodApp.route('/api/auth', authMod.authRouter) }) afterEach(() => { process.env.NODE_ENV = 'test' delete process.env.JWT_SECRET clearInterval(cleanup) }) it('login: returns 429 after exceeding 10 requests per minute', async () => { // Send 10 requests (within limit) for (let i = 0; i < 10; i++) { const res = await prodApp.request('/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ pubkey: '0'.repeat(64) }), }) expect(res.status).not.toBe(429) } // 11th request should be rate limited const res = await prodApp.request('/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ pubkey: '0'.repeat(64) }), }) expect(res.status).toBe(429) const body = await res.json() as { error: string; retryAfterSec: number } expect(body.error).toContain('Too many requests') expect(body.retryAfterSec).toBeGreaterThan(0) }) it('nostr/session: returns 429 after exceeding 10 requests per minute', async () => { // Send 10 requests (within limit) for (let i = 0; i < 10; i++) { await prodApp.request('/api/auth/nostr/session', { method: 'POST', }) } // 11th request should be rate limited const res = await prodApp.request('/api/auth/nostr/session', { method: 'POST', }) expect(res.status).toBe(429) }) })