209 lines
7.0 KiB
TypeScript
209 lines
7.0 KiB
TypeScript
/**
|
|||
|
|
* API authentication audit test.
|
||
|
|
* Verifies auth requirements on all endpoints.
|
||
|
|
* Public = leaderboard, fight replay, stats, docs, health, name check.
|
||
|
|
* Protected = mutations, wallet, bets placement, admin, matchmaking.
|
||
|
|
*/
|
||
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||
|
|
import { Hono } from 'hono'
|
||
|
|
|
||
|
|
// We test against the real app routes to verify actual auth behavior
|
||
|
|
import { authRouter } from './auth.js'
|
||
|
|
import { botsRouter } from './bots.js'
|
||
|
|
|
||
|
|
const app = new Hono()
|
||
|
|
app.route('/api/auth', authRouter)
|
||
|
|
app.route('/api/bots', botsRouter)
|
||
|
|
|
||
|
|
describe('API auth audit — public endpoints accessible without auth', () => {
|
||
|
|
it('GET /api/auth/check-name/:name returns 200 without auth', async () => {
|
||
|
|
const res = await app.request('/api/auth/check-name/testbot')
|
||
|
|
expect(res.status).toBe(200)
|
||
|
|
})
|
||
|
|
|
||
|
|
it('POST /api/auth/login returns 200 without auth', async () => {
|
||
|
|
const res = await app.request('/api/auth/login', {
|
||
|
|
method: 'POST',
|
||
|
|
headers: { 'Content-Type': 'application/json' },
|
||
|
|
body: JSON.stringify({ pubkey: '0'.repeat(64) }),
|
||
|
|
})
|
||
|
|
expect(res.status).toBe(200)
|
||
|
|
})
|
||
|
|
|
||
|
|
it('GET /api/bots returns 200 without auth', async () => {
|
||
|
|
const res = await app.request('/api/bots')
|
||
|
|
expect(res.status).toBe(200)
|
||
|
|
})
|
||
|
|
|
||
|
|
it('GET /api/bots/meta/archetypes returns 200 without auth', async () => {
|
||
|
|
const res = await app.request('/api/bots/meta/archetypes')
|
||
|
|
expect(res.status).toBe(200)
|
||
|
|
})
|
||
|
|
})
|
||
|
|
|
||
|
|
describe('API auth audit — protected endpoints require auth', () => {
|
||
|
|
it('POST /api/auth/nostr/session returns 401 without NIP-98 header', async () => {
|
||
|
|
const res = await app.request('/api/auth/nostr/session', {
|
||
|
|
method: 'POST',
|
||
|
|
})
|
||
|
|
expect(res.status).toBe(401)
|
||
|
|
})
|
||
|
|
|
||
|
|
it('POST /api/auth/register rejects missing pubkey', async () => {
|
||
|
|
const res = await app.request('/api/auth/register', {
|
||
|
|
method: 'POST',
|
||
|
|
headers: { 'Content-Type': 'application/json' },
|
||
|
|
body: JSON.stringify({}),
|
||
|
|
})
|
||
|
|
expect(res.status).toBe(400)
|
||
|
|
})
|
||
|
|
|
||
|
|
it('POST /api/auth/register rejects invalid pubkey', async () => {
|
||
|
|
const res = await app.request('/api/auth/register', {
|
||
|
|
method: 'POST',
|
||
|
|
headers: { 'Content-Type': 'application/json' },
|
||
|
|
body: JSON.stringify({ pubkey: 'invalid', name: 'test' }),
|
||
|
|
})
|
||
|
|
expect(res.status).toBe(400)
|
||
|
|
})
|
||
|
|
|
||
|
|
it('POST /api/auth/update rejects missing pubkey', async () => {
|
||
|
|
const res = await app.request('/api/auth/update', {
|
||
|
|
method: 'POST',
|
||
|
|
headers: { 'Content-Type': 'application/json' },
|
||
|
|
body: JSON.stringify({}),
|
||
|
|
})
|
||
|
|
expect(res.status).toBe(400)
|
||
|
|
})
|
||
|
|
})
|
||
|
|
|
||
|
|
describe('API auth audit — rate limiting active on auth endpoints', () => {
|
||
|
|
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-audit'
|
||
|
|
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('register is rate limited (10 per 10 minutes)', async () => {
|
||
|
|
// Exhaust limit
|
||
|
|
for (let i = 0; i < 10; i++) {
|
||
|
|
await prodApp.request('/api/auth/register', {
|
||
|
|
method: 'POST',
|
||
|
|
headers: { 'Content-Type': 'application/json' },
|
||
|
|
body: JSON.stringify({ pubkey: 'a'.repeat(64), name: 'bot' }),
|
||
|
|
})
|
||
|
|
}
|
||
|
|
const res = await prodApp.request('/api/auth/register', {
|
||
|
|
method: 'POST',
|
||
|
|
headers: { 'Content-Type': 'application/json' },
|
||
|
|
body: JSON.stringify({ pubkey: 'a'.repeat(64), name: 'bot' }),
|
||
|
|
})
|
||
|
|
expect(res.status).toBe(429)
|
||
|
|
})
|
||
|
|
|
||
|
|
it('nostr/session is rate limited (10 per minute)', async () => {
|
||
|
|
for (let i = 0; i < 10; i++) {
|
||
|
|
await prodApp.request('/api/auth/nostr/session', { method: 'POST' })
|
||
|
|
}
|
||
|
|
const res = await prodApp.request('/api/auth/nostr/session', { method: 'POST' })
|
||
|
|
expect(res.status).toBe(429)
|
||
|
|
})
|
||
|
|
|
||
|
|
it('login is rate limited (10 per minute)', async () => {
|
||
|
|
for (let i = 0; i < 10; i++) {
|
||
|
|
await prodApp.request('/api/auth/login', {
|
||
|
|
method: 'POST',
|
||
|
|
headers: { 'Content-Type': 'application/json' },
|
||
|
|
body: JSON.stringify({ pubkey: '0'.repeat(64) }),
|
||
|
|
})
|
||
|
|
}
|
||
|
|
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)
|
||
|
|
})
|
||
|
|
})
|
||
|
|
|
||
|
|
describe('API auth audit — input validation on mutation endpoints', () => {
|
||
|
|
it('register: rejects name with unicode', async () => {
|
||
|
|
const res = await app.request('/api/auth/register', {
|
||
|
|
method: 'POST',
|
||
|
|
headers: { 'Content-Type': 'application/json' },
|
||
|
|
body: JSON.stringify({ pubkey: 'a'.repeat(64), name: 'café' }),
|
||
|
|
})
|
||
|
|
expect(res.status).toBe(400)
|
||
|
|
})
|
||
|
|
|
||
|
|
it('register: rejects pubkey with non-hex chars', async () => {
|
||
|
|
const res = await app.request('/api/auth/register', {
|
||
|
|
method: 'POST',
|
||
|
|
headers: { 'Content-Type': 'application/json' },
|
||
|
|
body: JSON.stringify({ pubkey: 'g'.repeat(64), name: 'mybot' }),
|
||
|
|
})
|
||
|
|
expect(res.status).toBe(400)
|
||
|
|
})
|
||
|
|
|
||
|
|
it('register: rejects name >12 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: 'toolongbotname' }),
|
||
|
|
})
|
||
|
|
expect(res.status).toBe(400)
|
||
|
|
})
|
||
|
|
|
||
|
|
it('register-human: rejects missing name', async () => {
|
||
|
|
const res = await app.request('/api/auth/register-human', {
|
||
|
|
method: 'POST',
|
||
|
|
headers: { 'Content-Type': 'application/json' },
|
||
|
|
body: JSON.stringify({ pubkey: 'a'.repeat(64) }),
|
||
|
|
})
|
||
|
|
expect(res.status).toBe(400)
|
||
|
|
})
|
||
|
|
})
|
||
|
|
|
||
|
|
describe('API auth audit — error responses do not leak internals', () => {
|
||
|
|
it('register: error message does not contain file paths', async () => {
|
||
|
|
const res = await app.request('/api/auth/register', {
|
||
|
|
method: 'POST',
|
||
|
|
headers: { 'Content-Type': 'application/json' },
|
||
|
|
body: JSON.stringify({}),
|
||
|
|
})
|
||
|
|
const body = await res.json() as { error: string }
|
||
|
|
expect(body.error).not.toMatch(/\/src\/|\.ts:|\.js:/)
|
||
|
|
})
|
||
|
|
|
||
|
|
it('login: error message does not contain stack traces', async () => {
|
||
|
|
const res = await app.request('/api/auth/login', {
|
||
|
|
method: 'POST',
|
||
|
|
headers: { 'Content-Type': 'application/json' },
|
||
|
|
body: JSON.stringify({}),
|
||
|
|
})
|
||
|
|
const body = await res.json() as { error: string }
|
||
|
|
expect(body.error).not.toMatch(/at\s+Object|at\s+Module|SQLITE_/)
|
||
|
|
})
|
||
|
|
|
||
|
|
it('nostr/session: 401 error does not leak server info', async () => {
|
||
|
|
const res = await app.request('/api/auth/nostr/session', { method: 'POST' })
|
||
|
|
const body = await res.json() as { error: string }
|
||
|
|
expect(res.status).toBe(401)
|
||
|
|
expect(body.error).not.toMatch(/\/Users\/|\/home\/|node_modules/)
|
||
|
|
})
|
||
|
|
})
|