diff --git a/server/src/routes/auth-edge.test.ts b/server/src/routes/auth-edge.test.ts new file mode 100644 index 0000000..b95a277 --- /dev/null +++ b/server/src/routes/auth-edge.test.ts @@ -0,0 +1,137 @@ +import { describe, it, expect, vi, afterEach } from 'vitest' +import { Hono } from 'hono' +import { authRouter } from './auth.js' +import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools' +import { createJwt, extractPubkeyFromAuth } from '../middleware/jwt.js' + +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')}` +} + +function uniqueName() { + return `t${Date.now().toString(36).slice(-5)}${Math.random().toString(36).slice(2, 4)}` +} + +describe('auth edge cases — 7.2', () => { + // --- concurrent same-name registration --- + it('concurrent same-name registration: exactly one succeeds, other fails', async () => { + const name = uniqueName() + const pk1 = getPublicKey(generateSecretKey()) + const pk2 = getPublicKey(generateSecretKey()) + + const [res1, res2] = await Promise.all([ + app.request('/api/auth/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ pubkey: pk1, name }), + }), + app.request('/api/auth/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ pubkey: pk2, name }), + }), + ]) + + // Exactly one should succeed (201) + // The loser hits either the name check (409) or the DB UNIQUE constraint (500) + // depending on timing — both are acceptable as long as only one bot is created + const successCount = [res1.status, res2.status].filter(s => s === 201).length + expect(successCount).toBe(1) + // The failure status should be 4xx or 5xx + const failStatus = [res1.status, res2.status].find(s => s !== 201)! + expect(failStatus).toBeGreaterThanOrEqual(400) + }) + + it('case-insensitive name collision: "MyBot" blocks "mybot"', async () => { + const name = uniqueName() + const pk1 = getPublicKey(generateSecretKey()) + const pk2 = getPublicKey(generateSecretKey()) + + // First registration + const res1 = await app.request('/api/auth/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ pubkey: pk1, name: name.toUpperCase() }), + }) + expect(res1.status).toBe(201) + + // Second with same name different case + const res2 = await app.request('/api/auth/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ pubkey: pk2, name: name.toLowerCase() }), + }) + expect(res2.status).toBe(409) + const body = await res2.json() as { error: string } + expect(body.error).toContain('already exists') + }) + + // --- expired JWT at route level --- + it('expired JWT: extractPubkeyFromAuth returns null for expired token', () => { + vi.useFakeTimers() + const token = createJwt('a'.repeat(64), 'bot-1') + + // Advance past 24h expiry + vi.advanceTimersByTime(25 * 60 * 60 * 1000) + + const pubkey = extractPubkeyFromAuth(`Bearer ${token}`) + expect(pubkey).toBeNull() + }) + + it('NIP-98 with different key than claimed: signature rejected', async () => { + const sk1 = generateSecretKey() + const sk2 = generateSecretKey() + + // Sign with sk1 but claim sk2's pubkey by tampering + const event = finalizeEvent({ + kind: 27235, + created_at: Math.floor(Date.now() / 1000), + tags: [['u', 'https://localhost/api/auth/nostr/session'], ['method', 'POST']], + content: '', + }, sk1) + + // Tamper: replace pubkey with sk2's pubkey (simulates NIP-07 returning wrong key) + const tampered = { ...event, pubkey: getPublicKey(sk2) } + const header = `Nostr ${Buffer.from(JSON.stringify(tampered)).toString('base64')}` + + const res = await app.request('/api/auth/nostr/session', { + method: 'POST', + headers: { Authorization: header }, + }) + expect(res.status).toBe(401) + }) + + // --- duplicate pubkey registration --- + it('same pubkey cannot register twice', async () => { + const pk = getPublicKey(generateSecretKey()) + + const res1 = await app.request('/api/auth/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ pubkey: pk, name: uniqueName() }), + }) + expect(res1.status).toBe(201) + + const res2 = await app.request('/api/auth/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ pubkey: pk, name: uniqueName() }), + }) + expect(res2.status).toBe(409) + const body = await res2.json() as { error: string } + expect(body.error).toContain('already has a bot') + }) + + afterEach(() => { + vi.useRealTimers() + }) +})