add some basic validation

This commit is contained in:
Devin Zuczek
2026-08-05 14:00:31 -04:00
parent 6bfd4d9e50
commit b82a5e1dc0
4 changed files with 168 additions and 66 deletions
+34 -6
View File
@@ -220,8 +220,9 @@ describe('auth-gated endpoints', () => {
...form({ username: 'Coach' }),
headers: { ...(await bearer('893')), 'Content-Type': 'application/x-www-form-urlencoded' },
})
// Business errors are HTTP 200 with the { success, error, value } envelope.
expect(res.status).toBe(200)
// A refusal is a 400 carrying the same { success, error, value } envelope. It used
// to be HTTP 200, which read as a success to anything branching on the status.
expect(res.status).toBe(400)
const body = (await res.json()) as { success: boolean; error: string; value: string }
expect(body.success).toBe(false)
expect(body.error).toMatch(/already taken/i)
@@ -260,7 +261,7 @@ describe('auth-gated endpoints', () => {
...form({ username: 'coachy' }),
headers,
})
expect(blocked.status).toBe(200)
expect(blocked.status).toBe(400)
const blockedBody = (await blocked.json()) as { success: boolean; error: string }
expect(blockedBody.success).toBe(false)
expect(blockedBody.error).toMatch(/no username changes/i)
@@ -459,6 +460,31 @@ describe('auth-gated endpoints', () => {
for (const op of Object.values(ops)) expect(op.summary).toBeTruthy()
}
})
// hono-openapi registers a validated form body under `multipart/form-data` only, and
// its `media` option can't say otherwise (a precedence bug — see `withCleanSpec`). The
// real callers post `application/x-www-form-urlencoded`, so a spec that named only
// multipart would tell an integrator to send the one thing nothing here sends.
test('GET /openapi.json documents both form content types on validated routes', async () => {
const res = await exports.default.fetch(`${ORIGIN}/openapi.json`)
const spec = (await res.json()) as {
paths: Record<string, Record<string, { requestBody?: { content: Record<string, unknown> } }>>
}
for (const [path, method] of [
['/account/me/email', 'post'],
['/account/me/username', 'put'],
['/account/me/displayname', 'put'],
['/account/me/bio', 'put'],
['/account/me/phone', 'post'],
] as const) {
const content = spec.paths[path]?.[method]?.requestBody?.content ?? {}
expect(Object.keys(content).sort(), path).toEqual([
'application/x-www-form-urlencoded',
'multipart/form-data',
])
}
})
})
// The names a player chooses are alphanumeric and length-capped, by the same rule the
@@ -482,11 +508,13 @@ describe('name, email and bio validation', () => {
...form({ username }),
headers,
})
// Still the envelope at HTTP 200, like every other refusal on this route.
expect(res.status).toBe(200)
const body = (await res.json()) as { success: boolean; error: string }
// Refused by the SCHEMA (see openapi.ts `UsernameRequest`) before the handler
// runs — but still in this route's envelope, because the hook puts it there.
expect(res.status, username).toBe(400)
const body = (await res.json()) as { success: boolean; error: string; value: string }
expect(body.success, username).toBe(false)
expect(body.error).toMatch(/letters and numbers|at most 50 characters/)
expect(body.value).toBe('')
}
// The rationed change must NOT be spent by a refusal: an account starts with one,