Files
zpan/server/http/site/system.test.ts
T
Jasper VanandClaude Opus 4.8 b3ba6c00ff refactor(api)!: unify errors to AIP-193 + Page<T> pagination, enrich access log (#443) (#444)
* refactor(api)!: unify errors to AIP-193 + Page<T> pagination, enrich access log (#443)

Settle the API consistency issues from #443 before SDKs ship. Breaking changes
across the error envelope, list envelopes, and the generated Go client.

Errors → AIP-193 google.rpc.Status (https://google.aip.dev/193):
- every error body is now { error: { code, message, status, details:[ErrorInfo] } }
- machine-readable, switchable key is details[0].reason (UPPER_SNAKE); status is the
  canonical google.rpc.Code; dynamic context lives in metadata (string→string)
- built once in server/lib/http-errors.ts (buildErrorBody/ApiError/mapDomainError);
  inline handlers use apiError(c,status,msg,opts?); thrown errors flow through
  app.onError → renderError. Resolves #8 (one casing; no-storage 503 everywhere) and
  #9 (resource/maxBytes/conflictingName/licensing fields folded into metadata;
  featureGateErrorSchema removed)

Pagination → Page<T> = { items, total, page, pageSize } via pageSchema + integer
pageQuerySchema, applied to every list endpoint. image-hosting/images stays cursor
(the one intentional exception). unreadCount moved out of the notifications list into
/notifications/stats; entitlements drop the redundant orgId; team invitations use items.

Access log: every 4xx/5xx carries reason + full message (set by apiError and
renderError); a thrown domain error logs its mapped status (409, not 500); unhandled
500s log the full cause chain while the client gets a generic message.

Frontend ApiError exposes reason/metadata/canonicalStatus; consumers updated. Go
client regenerated from the new OpenAPI document.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(api): fix e2e name-conflict assertion + cover AIP-193 error branches

- e2e/name-conflict.spec.ts: assert body.error.details[0].reason (AIP-193) instead
  of the removed top-level body.code
- unit-test buildErrorBody, ApiError, and every mapDomainError branch
  (server/lib/http-errors.test.ts) and renderError + isHandledError
  (server/middleware/error-handler.test.ts)
- integration-test the apiError error-branch guards the refactor touched:
  shares, redirect, site/invitations, objects, store/storefront, and the
  requirePermission middleware (authz) — restoring patch coverage above target

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(api): drop ad-hoc [spec:] breadcrumbs from new coverage tests

lint:spec governs spec↔test traceability: a [spec: id] breadcrumb must map to a
documented @id scenario in spec/**/*.feature. The added error-branch coverage
tests are not Gherkin scenarios, so reference no spec id — use plain titles.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(objects): allow the file-manager pageSize (500) on the objects list

The shared pageQuerySchema caps pageSize at 100, but the file manager loads a
whole folder client-side (FILES_PAGE_SIZE=500, transfer dialog 200) — the old
z.string() query param was unbounded. With the cap, GET /api/objects?pageSize=500
returned 400, the file-manager list query errored and retried, and the toolbar /
table never rendered (e2e: responsive @desktop + name-conflict table state). Raise
just this list's ceiling to 1000 (default stays 20); other lists keep the 100 cap.

Regression-tested: GET /api/objects?pageSize=500 → 200.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 22:58:35 -04:00

86 lines
4.1 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import {
CAPTCHA_ENABLED_KEY,
CAPTCHA_MIN_SCORE_KEY,
CAPTCHA_PROVIDER_KEY,
CAPTCHA_SECRET_OPTION_KEY,
CAPTCHA_SITE_KEY_KEY,
} from '../../../shared/captcha.js'
import { adminHeaders, createTestApp } from '../../test/setup.js'
async function putOption(
app: Awaited<ReturnType<typeof createTestApp>>['app'],
headers: Record<string, string>,
key: string,
body: Record<string, unknown>,
) {
return app.request(`/api/site/options/${key}`, {
method: 'PUT',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
}
describe('System API captcha options', () => {
it('requires complete provider config before captcha can be enabled', async () => {
const { app } = await createTestApp()
const admin = await adminHeaders(app)
const noKeys = await putOption(app, admin, CAPTCHA_ENABLED_KEY, { value: 'true' })
expect(noKeys.status).toBe(400)
const noKeysBody = (await noKeys.json()) as { error: { message: string } }
expect(noKeysBody.error.message).toBe('Captcha site key is required before enabling captcha')
await putOption(app, admin, CAPTCHA_SITE_KEY_KEY, { value: 'site-key' })
const noSecret = await putOption(app, admin, CAPTCHA_ENABLED_KEY, { value: 'true' })
expect(noSecret.status).toBe(400)
const noSecretBody = (await noSecret.json()) as { error: { message: string } }
expect(noSecretBody.error.message).toBe('Captcha secret key is required before enabling captcha')
await putOption(app, admin, CAPTCHA_SECRET_OPTION_KEY, { value: 'secret-key' })
await putOption(app, admin, CAPTCHA_PROVIDER_KEY, { value: 'captchafox' })
const enabled = await putOption(app, admin, CAPTCHA_ENABLED_KEY, { value: 'true' })
expect(enabled.status).toBe(201)
})
it('forces captcha public and private visibility flags', async () => {
const { app } = await createTestApp()
const admin = await adminHeaders(app)
const siteKey = await putOption(app, admin, CAPTCHA_SITE_KEY_KEY, { value: 'site-key', public: false })
expect(await siteKey.json()).toEqual({ key: CAPTCHA_SITE_KEY_KEY, value: 'site-key', public: true })
const secret = await putOption(app, admin, CAPTCHA_SECRET_OPTION_KEY, { value: 'secret-key', public: true })
expect(await secret.json()).toEqual({ key: CAPTCHA_SECRET_OPTION_KEY, value: 'secret-key', public: false })
const provider = await putOption(app, admin, CAPTCHA_PROVIDER_KEY, { value: 'hcaptcha', public: false })
expect(await provider.json()).toEqual({ key: CAPTCHA_PROVIDER_KEY, value: 'hcaptcha', public: true })
const minScore = await putOption(app, admin, CAPTCHA_MIN_SCORE_KEY, { value: '0.7', public: true })
expect(await minScore.json()).toEqual({ key: CAPTCHA_MIN_SCORE_KEY, value: '0.7', public: false })
const enabled = await putOption(app, admin, CAPTCHA_ENABLED_KEY, { value: 'true', public: false })
expect(await enabled.json()).toEqual({ key: CAPTCHA_ENABLED_KEY, value: 'true', public: true })
})
it('rejects invalid provider settings while captcha is enabled', async () => {
const { app } = await createTestApp()
const admin = await adminHeaders(app)
await putOption(app, admin, CAPTCHA_PROVIDER_KEY, { value: 'google-recaptcha' })
await putOption(app, admin, CAPTCHA_SITE_KEY_KEY, { value: 'site-key' })
await putOption(app, admin, CAPTCHA_SECRET_OPTION_KEY, { value: 'secret-key' })
await putOption(app, admin, CAPTCHA_ENABLED_KEY, { value: 'true' })
const provider = await putOption(app, admin, CAPTCHA_PROVIDER_KEY, { value: 'unknown' })
expect(provider.status).toBe(400)
const providerBody = (await provider.json()) as { error: { message: string } }
expect(providerBody.error.message).toBe('Captcha provider is invalid')
const minScore = await putOption(app, admin, CAPTCHA_MIN_SCORE_KEY, { value: '1.5' })
expect(minScore.status).toBe(400)
const minScoreBody = (await minScore.json()) as { error: { message: string } }
expect(minScoreBody.error.message).toBe('Captcha minimum score must be between 0 and 1')
})
})