2026-03-13 09:28:38 +00:00
|
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
2026-03-12 23:43:57 +00:00
|
|
|
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)
|
|
|
|
|
})
|
|
|
|
|
})
|
2026-03-13 09:28:38 +00:00
|
|
|
|
|
|
|
|
describe('auth rate limiting', () => {
|
|
|
|
|
let prodApp: InstanceType<typeof Hono>
|
|
|
|
|
let cleanup: ReturnType<typeof setInterval>
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
})
|
|
|
|
|
})
|