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>
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
'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, sessionToken, profileId
|
||||
|
||||
before(() => {
|
||||
db = initDb(':memory:')
|
||||
server = createServer(db)
|
||||
const p = db.createProfile('prog@example.com', 'Progress')
|
||||
profileId = p.id
|
||||
sessionToken = 'progtoken'
|
||||
db.createSession(sessionToken, profileId)
|
||||
return new Promise(resolve => server.listen(0, resolve))
|
||||
})
|
||||
|
||||
after(() => {
|
||||
db._db.close()
|
||||
return new Promise(resolve => server.close(resolve))
|
||||
})
|
||||
|
||||
const authHeader = () => ({ Authorization: `Bearer ${sessionToken}` })
|
||||
|
||||
describe('GET /api/progress', () => {
|
||||
test('returns progress for authed user', async () => {
|
||||
const r = await request(server, 'GET', '/api/progress', null, authHeader())
|
||||
assert.equal(r.status, 200)
|
||||
assert.equal(r.body.level, 1)
|
||||
assert.equal(r.body.score, 0)
|
||||
})
|
||||
|
||||
test('returns 401 without auth', async () => {
|
||||
const r = await request(server, 'GET', '/api/progress', null)
|
||||
assert.equal(r.status, 401)
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /api/progress/answer', () => {
|
||||
test('records correct answer and returns updated progress', async () => {
|
||||
const r = await request(server, 'POST', '/api/progress/answer',
|
||||
{ letter: 'A', correct: true }, authHeader())
|
||||
assert.equal(r.status, 200)
|
||||
assert.ok(r.body.score > 0)
|
||||
assert.equal(r.body.streak, 1)
|
||||
})
|
||||
|
||||
test('records incorrect answer (resets streak)', async () => {
|
||||
const r = await request(server, 'POST', '/api/progress/answer',
|
||||
{ letter: 'A', correct: false }, authHeader())
|
||||
assert.equal(r.status, 200)
|
||||
assert.equal(r.body.streak, 0)
|
||||
})
|
||||
|
||||
test('returns 401 without auth', async () => {
|
||||
const r = await request(server, 'POST', '/api/progress/answer',
|
||||
{ letter: 'A', correct: true })
|
||||
assert.equal(r.status, 401)
|
||||
})
|
||||
|
||||
test('returns 400 if letter missing', async () => {
|
||||
const r = await request(server, 'POST', '/api/progress/answer',
|
||||
{ correct: true }, authHeader())
|
||||
assert.equal(r.status, 400)
|
||||
})
|
||||
|
||||
test('returns 400 if correct is missing', async () => {
|
||||
const r = await request(server, 'POST', '/api/progress/answer',
|
||||
{ letter: 'A' }, authHeader())
|
||||
assert.equal(r.status, 400)
|
||||
})
|
||||
|
||||
test('returns 400 if correct is non-boolean', async () => {
|
||||
const r = await request(server, 'POST', '/api/progress/answer',
|
||||
{ letter: 'A', correct: 'true' }, authHeader())
|
||||
assert.equal(r.status, 400)
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET /api/mnemonics', () => {
|
||||
before(() => {
|
||||
db.saveMnemonic(profileId, 'A', 'a phrase')
|
||||
})
|
||||
|
||||
test('returns mnemonic map', async () => {
|
||||
const r = await request(server, 'GET', '/api/mnemonics', null, authHeader())
|
||||
assert.equal(r.status, 200)
|
||||
assert.equal(r.body.A, 'a phrase')
|
||||
})
|
||||
|
||||
test('returns 401 without auth', async () => {
|
||||
const r = await request(server, 'GET', '/api/mnemonics', null)
|
||||
assert.equal(r.status, 401)
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /api/mnemonics', () => {
|
||||
test('saves mnemonic and returns ok', async () => {
|
||||
const r = await request(server, 'POST', '/api/mnemonics',
|
||||
{ letter: 'E', phrase: 'yes' }, authHeader())
|
||||
assert.equal(r.status, 200)
|
||||
assert.equal(r.body.ok, true)
|
||||
const m = db.getMnemonics(profileId)
|
||||
assert.equal(m.E, 'yes')
|
||||
})
|
||||
|
||||
test('returns 400 if letter or phrase missing', async () => {
|
||||
const r = await request(server, 'POST', '/api/mnemonics',
|
||||
{ letter: 'T' }, authHeader())
|
||||
assert.equal(r.status, 400)
|
||||
})
|
||||
|
||||
test('returns 401 without auth', async () => {
|
||||
const r = await request(server, 'POST', '/api/mnemonics',
|
||||
{ letter: 'E', phrase: 'yes' })
|
||||
assert.equal(r.status, 401)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user