mirror of
https://github.com/saltbo/zpan.git
synced 2026-09-24 23:22:31 +08:00
* 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>
268 lines
11 KiB
TypeScript
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')
|
|
})
|
|
})
|