Files
kitadmin 85ba992432 feat: server HTTP API with auth, progress, and admin routes (Tasks 6+7)
- server.js: Node HTTP server with all API routes, static file serving, SPA fallback
- Path traversal protection on static file serving
- Async handler wrapped with .catch() to prevent unhandled rejections
- readBody: size limit (1MB) + error handler
- letter validation: single [A-Z] char check on progress/mnemonics routes
- phrase length limit (500 chars) on POST /api/mnemonics
- requireAdmin reads ADMIN_PASSWORD at request time for testability
- logout uses session.token from requireAuth (not re-read from header)
- 51 server tests passing (auth, progress, admin routes + edge cases)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 02:04:57 +00:00

92 lines
2.8 KiB
JavaScript

'use strict'
const { test, describe, before, after } = require('node:test')
const assert = require('node:assert/strict')
const http = require('node:http')
const { initDb } = require('../db.js')
const { createServer } = require('../server.js')
function request(server, method, path, body, headers = {}) {
return new Promise((resolve, reject) => {
const addr = server.address()
const opts = {
hostname: '127.0.0.1',
port: addr.port,
path,
method,
headers: { 'Content-Type': 'application/json', ...headers },
}
const req = http.request(opts, res => {
let data = ''
res.on('data', c => { data += c })
res.on('end', () => {
let json = null
try { json = JSON.parse(data) } catch {}
resolve({ status: res.statusCode, headers: res.headers, body: json, raw: data })
})
})
req.on('error', reject)
if (body) req.write(JSON.stringify(body))
req.end()
})
}
let db, server
const ADMIN_PWD = 'testadminpwd'
before(() => {
process.env.ADMIN_PASSWORD = ADMIN_PWD
db = initDb(':memory:')
server = createServer(db)
const p1 = db.createProfile('adm1@example.com', 'Admin1')
const p2 = db.createProfile('adm2@example.com', 'Admin2')
db.recordAnswer(p1.id, 'A', true)
db.recordAnswer(p2.id, 'B', false)
return new Promise(resolve => server.listen(0, resolve))
})
after(() => {
delete process.env.ADMIN_PASSWORD
db._db.close()
return new Promise(resolve => server.close(resolve))
})
describe('GET /api/admin/users', () => {
test('returns user list with valid admin password', async () => {
const r = await request(server, 'GET', '/api/admin/users', null, {
Authorization: `Bearer ${ADMIN_PWD}`,
})
assert.equal(r.status, 200)
assert.ok(Array.isArray(r.body))
const emails = r.body.map(u => u.email)
assert.ok(emails.includes('adm1@example.com'))
})
test('returns 401 without admin password', async () => {
const r = await request(server, 'GET', '/api/admin/users', null)
assert.equal(r.status, 401)
})
test('returns 401 with wrong admin password', async () => {
const r = await request(server, 'GET', '/api/admin/users', null, {
Authorization: 'Bearer wrongpwd',
})
assert.equal(r.status, 401)
})
})
describe('GET /api/admin/stats', () => {
test('returns stats with valid admin password', async () => {
const r = await request(server, 'GET', '/api/admin/stats', null, {
Authorization: `Bearer ${ADMIN_PWD}`,
})
assert.equal(r.status, 200)
assert.ok(typeof r.body.totalUsers === 'number')
assert.ok(Array.isArray(r.body.mostMissed))
})
test('returns 401 without admin password', async () => {
const r = await request(server, 'GET', '/api/admin/stats', null)
assert.equal(r.status, 401)
})
})