Files
zpan/server/middleware/authz.integration.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

268 lines
11 KiB
TypeScript

import { sql } from 'drizzle-orm'
import { describe, expect, it } from 'vitest'
import { adminHeaders, authedHeaders, createTestApp } from '../test/setup.js'
import { requirePermission } from './authz.js'
type TestCtx = Awaited<ReturnType<typeof createTestApp>>
type TestApp = TestCtx['app']
type TestDb = TestCtx['db']
type TestAuth = TestCtx['auth']
// Mounts the permission-gated probe routes on a real app so requirePermission
// runs after the production authMiddleware (which resolves the principal,
// userId, orgId, and deps from the request). Each route maps to one guard in
// requirePermission; the body is a sentinel proving the middleware called next.
function mountProbes(app: TestApp) {
app.get('/api/test-authz/api-perm', requirePermission('remoteDownload', 'create'), (c) => c.json({ ok: true }))
app.get('/api/test-authz/no-downloader', requirePermission('remoteDownload', 'read'), (c) => c.json({ ok: true }))
app.get(
'/api/test-authz/team-editor',
requirePermission('remoteDownload', 'create', { minTeamRole: 'editor' }),
(c) => c.json({ ok: true }),
)
}
// Creates an API key via the real better-auth plugin (keys are properly hashed)
// scoped to the given permissions. Returns the raw key usable as a Bearer token.
async function createApiKey(
auth: TestAuth,
orgId: string,
userId: string,
permissions?: Record<string, string[]>,
): Promise<string> {
// biome-ignore lint/suspicious/noExplicitAny: better-auth plugin API is not fully typed
const result = (await (auth.api as any).createApiKey({
body: {
configId: 'ihost',
organizationId: orgId,
userId,
...(permissions ? { permissions } : {}),
},
})) as { key: string }
return result.key
}
async function getOrgId(db: TestDb): Promise<string> {
const rows = await db.all<{ id: string }>(sql`
SELECT id FROM organization WHERE metadata LIKE '%"type":"personal"%' LIMIT 1
`)
return rows[0].id
}
async function getUserId(db: TestDb, email: string): Promise<string> {
const rows = await db.all<{ id: string }>(sql`SELECT id FROM user WHERE email = ${email}`)
return rows[0].id
}
// Registers a downloader and returns its bearer token. Mirrors the device-login
// flow the CLI uses; needed to mint a `downloader` principal.
async function registerDownloader(app: TestApp, name: string): Promise<string> {
const admin = await adminHeaders(app)
const codeRes = await app.request('/api/auth/device/code', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ client_id: 'zpan-cli', scope: 'downloader:register' }),
})
const code = (await codeRes.json()) as { device_code: string; user_code: string }
// Claim the user code with the admin session before approving (device flow).
await app.request(`/api/auth/device?user_code=${encodeURIComponent(code.user_code)}`, { headers: admin })
await app.request('/api/auth/device/approve', {
method: 'POST',
headers: { ...admin, 'Content-Type': 'application/json' },
body: JSON.stringify({ userCode: code.user_code }),
})
const tokenRes = await app.request('/api/auth/device/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
device_code: code.device_code,
client_id: 'zpan-cli',
}),
})
const token = (await tokenRes.json()) as { access_token: string }
const createRes = await app.request('/api/downloads/downloaders', {
method: 'POST',
headers: { Authorization: `Bearer ${token.access_token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
name,
heartbeat: {
version: '1.0.0',
hostname: 'host',
platform: 'linux',
arch: 'x64',
engine: 'builtin',
capabilities: [],
maxConcurrentTasks: 1,
currentTasks: 0,
downloadBps: 0,
uploadBps: 0,
freeDiskBytes: 0,
},
}),
})
const created = (await createRes.json()) as { token: string }
return created.token
}
describe('requirePermission middleware', () => {
it('returns 401 when there is no principal (unauthenticated)', async () => {
const { app } = await createTestApp()
mountProbes(app)
const res = await app.request('/api/test-authz/api-perm')
expect(res.status).toBe(401)
const body = (await res.json()) as { error: { message: string; status: string } }
expect(body.error.message).toBe('Unauthorized')
expect(body.error.status).toBe('UNAUTHENTICATED')
})
it('returns 403 when an api-key principal lacks the required permission', async () => {
const { app, db, auth } = await createTestApp()
mountProbes(app)
await authedHeaders(app)
const orgId = await getOrgId(db)
const userId = await getUserId(db, 'test@example.com')
// Key authenticates (valid) but carries only `read`, not the `create` the
// probe route demands, so the api-key branch denies with 403.
const key = await createApiKey(auth, orgId, userId, { remoteDownload: ['read'] })
const res = await app.request('/api/test-authz/api-perm', {
headers: { Authorization: `Bearer ${key}` },
})
expect(res.status).toBe(403)
const body = (await res.json()) as { error: { message: string; status: string } }
expect(body.error.message).toBe('Forbidden')
expect(body.error.status).toBe('PERMISSION_DENIED')
})
it('allows an api-key principal that has the required permission', async () => {
const { app, db, auth } = await createTestApp()
mountProbes(app)
await authedHeaders(app)
const orgId = await getOrgId(db)
const userId = await getUserId(db, 'test@example.com')
const key = await createApiKey(auth, orgId, userId, { remoteDownload: ['create'] })
const res = await app.request('/api/test-authz/api-perm', {
headers: { Authorization: `Bearer ${key}` },
})
expect(res.status).toBe(200)
await expect(res.json()).resolves.toEqual({ ok: true })
})
it('returns 401 for a downloader principal when allowDownloader is not set', async () => {
const { app } = await createTestApp({ DOWNLOAD_TOKEN_SECRET: 'test-download-token-secret' })
mountProbes(app)
const downloaderToken = await registerDownloader(app, 'authz-downloader')
const res = await app.request('/api/test-authz/no-downloader', {
headers: { Authorization: `Bearer ${downloaderToken}` },
})
expect(res.status).toBe(401)
const body = (await res.json()) as { error: { message: string; status: string } }
expect(body.error.message).toBe('Unauthorized')
expect(body.error.status).toBe('UNAUTHENTICATED')
})
it('returns 403 when a team member role is below the required minTeamRole', async () => {
const { app, db } = await createTestApp()
mountProbes(app)
const headers = await authedHeaders(app, 'viewer@example.com')
const userId = await getUserId(db, 'viewer@example.com')
const teamOrgId = 'team-low-role'
await db.run(sql`
INSERT INTO organization (id, name, slug, metadata)
VALUES (${teamOrgId}, 'Low Role Team', ${teamOrgId}, '{"type":"team"}')
`)
await db.run(sql`
INSERT INTO member (id, organization_id, user_id, role)
VALUES (${`member-${teamOrgId}`}, ${teamOrgId}, ${userId}, 'viewer')
`)
const setActive = await app.request('/api/auth/organization/set-active', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ organizationId: teamOrgId }),
})
const cookies = setActive.headers.getSetCookie()
if (cookies.length > 0) headers.Cookie = cookies.map((c) => c.split(';')[0]).join('; ')
const res = await app.request('/api/test-authz/team-editor', { headers })
expect(res.status).toBe(403)
const body = (await res.json()) as { error: { message: string; status: string } }
expect(body.error.message).toBe('Forbidden')
expect(body.error.status).toBe('PERMISSION_DENIED')
})
it('allows a team member whose role meets the required minTeamRole', async () => {
const { app, db } = await createTestApp()
mountProbes(app)
const headers = await authedHeaders(app, 'editor@example.com')
const userId = await getUserId(db, 'editor@example.com')
const teamOrgId = 'team-ok-role'
await db.run(sql`
INSERT INTO organization (id, name, slug, metadata)
VALUES (${teamOrgId}, 'OK Role Team', ${teamOrgId}, '{"type":"team"}')
`)
await db.run(sql`
INSERT INTO member (id, organization_id, user_id, role)
VALUES (${`member-${teamOrgId}`}, ${teamOrgId}, ${userId}, 'editor')
`)
const setActive = await app.request('/api/auth/organization/set-active', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ organizationId: teamOrgId }),
})
const cookies = setActive.headers.getSetCookie()
if (cookies.length > 0) headers.Cookie = cookies.map((c) => c.split(';')[0]).join('; ')
const res = await app.request('/api/test-authz/team-editor', { headers })
expect(res.status).toBe(200)
await expect(res.json()).resolves.toEqual({ ok: true })
})
it('allows a personal-org user without a member row via the isPersonalOrg fallback', async () => {
const { app, db } = await createTestApp()
mountProbes(app)
const headers = await authedHeaders(app, 'personal@example.com')
const orgId = await getOrgId(db)
// Drop the member row so getMemberRole returns null, forcing the
// isPersonalOrg branch (a personal org owner still has full access).
await db.run(sql`DELETE FROM member WHERE organization_id = ${orgId}`)
const res = await app.request('/api/test-authz/team-editor', { headers })
expect(res.status).toBe(200)
await expect(res.json()).resolves.toEqual({ ok: true })
})
it('returns 403 for a team org with no member row that is not personal', async () => {
const { app, db } = await createTestApp()
mountProbes(app)
const headers = await authedHeaders(app, 'orphan@example.com')
const userId = await getUserId(db, 'orphan@example.com')
const teamOrgId = 'team-no-member'
await db.run(sql`
INSERT INTO organization (id, name, slug, metadata)
VALUES (${teamOrgId}, 'No Member Team', ${teamOrgId}, '{"type":"team"}')
`)
// Member row only needed so set-active accepts it; remove it afterwards to
// hit the "no member row, not personal" final 403.
await db.run(sql`
INSERT INTO member (id, organization_id, user_id, role)
VALUES (${`member-${teamOrgId}`}, ${teamOrgId}, ${userId}, 'owner')
`)
const setActive = await app.request('/api/auth/organization/set-active', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ organizationId: teamOrgId }),
})
const cookies = setActive.headers.getSetCookie()
if (cookies.length > 0) headers.Cookie = cookies.map((c) => c.split(';')[0]).join('; ')
await db.run(sql`DELETE FROM member WHERE organization_id = ${teamOrgId}`)
const res = await app.request('/api/test-authz/team-editor', { headers })
expect(res.status).toBe(403)
const body = (await res.json()) as { error: { message: string } }
expect(body.error.message).toBe('Forbidden')
})
})