mirror of
https://github.com/saltbo/zpan.git
synced 2026-09-21 04:59:47 +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>
387 lines
16 KiB
TypeScript
387 lines
16 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
import { adminHeaders, authedHeaders, createTestApp, seedProLicense } from '../../test/setup.js'
|
|
|
|
const githubConfig = {
|
|
type: 'builtin' as const,
|
|
clientId: 'client-id-123',
|
|
clientSecret: 'super-secret-value',
|
|
enabled: true,
|
|
}
|
|
|
|
const oidcConfig = {
|
|
type: 'oidc' as const,
|
|
clientId: 'oidc-client-id',
|
|
clientSecret: 'oidc-secret-value',
|
|
enabled: true,
|
|
discoveryUrl: 'https://accounts.example.com/.well-known/openid-configuration',
|
|
scopes: ['openid', 'email', 'profile'],
|
|
}
|
|
|
|
async function putProvider(
|
|
app: Awaited<ReturnType<typeof createTestApp>>['app'],
|
|
headers: Record<string, string>,
|
|
providerId: string,
|
|
body: Record<string, unknown>,
|
|
) {
|
|
return app.request(`/api/site/auth-providers/${providerId}`, {
|
|
method: 'PUT',
|
|
headers: { ...headers, 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(body),
|
|
})
|
|
}
|
|
|
|
describe('Auth Providers — public list', () => {
|
|
it('returns empty items when no providers are configured', async () => {
|
|
const { app } = await createTestApp()
|
|
const res = await app.request('/api/site/auth-providers')
|
|
expect(res.status).toBe(200)
|
|
const body = (await res.json()) as { items: unknown[] }
|
|
expect(body.items).toEqual([])
|
|
})
|
|
|
|
it('returns only enabled providers [spec: auth-providers/public-enabled-only]', async () => {
|
|
const { app, db } = await createTestApp()
|
|
const admin = await adminHeaders(app)
|
|
await seedProLicense(db) // 2nd provider requires social_login_unlimited
|
|
|
|
await putProvider(app, admin, 'github', { ...githubConfig, enabled: true })
|
|
await putProvider(app, admin, 'google', { ...githubConfig, clientId: 'google-id', enabled: false })
|
|
|
|
const res = await app.request('/api/site/auth-providers')
|
|
expect(res.status).toBe(200)
|
|
const body = (await res.json()) as { items: Array<Record<string, unknown>> }
|
|
expect(body.items).toHaveLength(1)
|
|
expect(body.items[0].providerId).toBe('github')
|
|
})
|
|
|
|
it('does not include clientSecret in public response [spec: auth-providers/public-no-secret]', async () => {
|
|
const { app } = await createTestApp()
|
|
const admin = await adminHeaders(app)
|
|
|
|
await putProvider(app, admin, 'github', githubConfig)
|
|
|
|
const res = await app.request('/api/site/auth-providers')
|
|
const body = (await res.json()) as { items: Array<Record<string, unknown>> }
|
|
expect(body.items[0]).not.toHaveProperty('clientSecret')
|
|
})
|
|
|
|
it('returns display name and icon from provider metadata [spec: auth-providers/metadata]', async () => {
|
|
const { app } = await createTestApp()
|
|
const admin = await adminHeaders(app)
|
|
|
|
await putProvider(app, admin, 'github', githubConfig)
|
|
|
|
const res = await app.request('/api/site/auth-providers')
|
|
const body = (await res.json()) as { items: Array<Record<string, unknown>> }
|
|
expect(body.items[0].name).toBe('GitHub')
|
|
expect(body.items[0].icon).toBe('github')
|
|
})
|
|
|
|
it('uses providerId as fallback name and icon for unknown OIDC provider [spec: auth-providers/oidc-fallback]', async () => {
|
|
const { app } = await createTestApp()
|
|
const admin = await adminHeaders(app)
|
|
|
|
await putProvider(app, admin, 'my-custom-oidc', oidcConfig)
|
|
|
|
const res = await app.request('/api/site/auth-providers')
|
|
const body = (await res.json()) as { items: Array<Record<string, unknown>> }
|
|
expect(body.items).toHaveLength(1)
|
|
// No entry in OAuthProviderMeta for 'my-custom-oidc', so falls back to providerId
|
|
expect(body.items[0].name).toBe('my-custom-oidc')
|
|
expect(body.items[0].icon).toBe('my-custom-oidc')
|
|
})
|
|
|
|
it('disabled provider does not appear in public list', async () => {
|
|
const { app } = await createTestApp()
|
|
const admin = await adminHeaders(app)
|
|
|
|
await putProvider(app, admin, 'github', { ...githubConfig, enabled: false })
|
|
|
|
const res = await app.request('/api/site/auth-providers')
|
|
const body = (await res.json()) as { items: unknown[] }
|
|
expect(body.items).toHaveLength(0)
|
|
})
|
|
})
|
|
|
|
describe('Auth Providers — admin list', () => {
|
|
it('serves the public (secret-free) list to anonymous callers [spec: auth-providers/anon-public-list]', async () => {
|
|
const { app } = await createTestApp()
|
|
const admin = await adminHeaders(app)
|
|
await putProvider(app, admin, 'github', githubConfig)
|
|
|
|
// No auth → public list, never the admin (secret-bearing) view.
|
|
const res = await app.request('/api/site/auth-providers')
|
|
expect(res.status).toBe(200)
|
|
const body = (await res.json()) as { items: Array<Record<string, unknown>> }
|
|
expect(body.items).toHaveLength(1)
|
|
expect(body.items[0]).not.toHaveProperty('clientSecret')
|
|
})
|
|
|
|
it('serves the public (secret-free) list to non-admin users [spec: auth-providers/admin-only]', async () => {
|
|
const { app } = await createTestApp()
|
|
// First sign-up makes admin; second is regular user
|
|
const admin = await adminHeaders(app)
|
|
await putProvider(app, admin, 'github', githubConfig)
|
|
await authedHeaders(app, 'regular@example.com') // registers the second user
|
|
const signInRes = await app.request('/api/auth/sign-in/email', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ email: 'regular@example.com', password: 'password123456' }),
|
|
})
|
|
const freshHeaders = { Cookie: signInRes.headers.getSetCookie().join('; ') }
|
|
const res = await app.request('/api/site/auth-providers', { headers: freshHeaders })
|
|
expect(res.status).toBe(200)
|
|
const body = (await res.json()) as { items: Array<Record<string, unknown>> }
|
|
expect(body.items).toHaveLength(1)
|
|
expect(body.items[0]).not.toHaveProperty('clientSecret')
|
|
})
|
|
|
|
it('returns empty items when no providers are configured', async () => {
|
|
const { app } = await createTestApp()
|
|
const admin = await adminHeaders(app)
|
|
const res = await app.request('/api/site/auth-providers', { headers: admin })
|
|
expect(res.status).toBe(200)
|
|
const body = (await res.json()) as { items: unknown[] }
|
|
expect(body.items).toEqual([])
|
|
})
|
|
|
|
it('returns all configs including disabled providers [spec: auth-providers/admin-list-all]', async () => {
|
|
const { app, db } = await createTestApp()
|
|
const admin = await adminHeaders(app)
|
|
await seedProLicense(db) // 2nd provider requires social_login_unlimited
|
|
|
|
await putProvider(app, admin, 'github', { ...githubConfig, enabled: true })
|
|
await putProvider(app, admin, 'google', { ...githubConfig, clientId: 'google-id', enabled: false })
|
|
|
|
const res = await app.request('/api/site/auth-providers', { headers: admin })
|
|
const body = (await res.json()) as { items: Array<Record<string, unknown>> }
|
|
expect(body.items).toHaveLength(2)
|
|
})
|
|
|
|
it('masks clientSecret leaving only last 4 chars visible [spec: auth-providers/mask-secret]', async () => {
|
|
const { app } = await createTestApp()
|
|
const admin = await adminHeaders(app)
|
|
|
|
await putProvider(app, admin, 'github', githubConfig)
|
|
|
|
const res = await app.request('/api/site/auth-providers', { headers: admin })
|
|
const body = (await res.json()) as { items: Array<Record<string, unknown>> }
|
|
const secret = body.items[0].clientSecret as string
|
|
expect(secret).toMatch(/^\*+alue$/)
|
|
expect(secret).not.toBe(githubConfig.clientSecret)
|
|
})
|
|
|
|
it('masks short secret entirely with four asterisks [spec: auth-providers/mask-short-secret]', async () => {
|
|
const { app } = await createTestApp()
|
|
const admin = await adminHeaders(app)
|
|
|
|
await putProvider(app, admin, 'github', { ...githubConfig, clientSecret: 'abc' })
|
|
|
|
const res = await app.request('/api/site/auth-providers', { headers: admin })
|
|
const body = (await res.json()) as { items: Array<Record<string, unknown>> }
|
|
expect(body.items[0].clientSecret).toBe('****')
|
|
})
|
|
})
|
|
|
|
describe('Auth Providers — admin upsert (PUT)', () => {
|
|
it('returns 401 without authentication', async () => {
|
|
const { app } = await createTestApp()
|
|
const res = await app.request('/api/site/auth-providers/github', {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(githubConfig),
|
|
})
|
|
expect(res.status).toBe(401)
|
|
})
|
|
|
|
it('admin can create a builtin provider [spec: auth-providers/create-builtin]', async () => {
|
|
const { app } = await createTestApp()
|
|
const admin = await adminHeaders(app)
|
|
|
|
const res = await putProvider(app, admin, 'github', githubConfig)
|
|
expect(res.status).toBe(200)
|
|
const body = (await res.json()) as Record<string, unknown>
|
|
expect(body.providerId).toBe('github')
|
|
expect(body.type).toBe('builtin')
|
|
expect(body.clientId).toBe(githubConfig.clientId)
|
|
expect(body.enabled).toBe(true)
|
|
})
|
|
|
|
it('blocks the second provider on the free plan with 402 [spec: auth-providers/free-limit]', async () => {
|
|
const { app } = await createTestApp()
|
|
const admin = await adminHeaders(app)
|
|
|
|
const first = await putProvider(app, admin, 'github', githubConfig)
|
|
expect(first.status).toBe(200)
|
|
|
|
const second = await putProvider(app, admin, 'google', { ...githubConfig, clientId: 'google-id' })
|
|
expect(second.status).toBe(402)
|
|
const body = (await second.json()) as {
|
|
error: { message: string; details: Array<{ reason: string; metadata: Record<string, string> }> }
|
|
}
|
|
expect(body.error.message).toBe('Feature not available')
|
|
expect(body.error.details[0].reason).toBe('FEATURE_NOT_AVAILABLE')
|
|
expect(body.error.details[0].metadata.feature).toBe('social_login_unlimited')
|
|
expect(body.error.details[0].metadata.limit).toBe('1')
|
|
})
|
|
|
|
it('allows additional providers with the social_login_unlimited entitlement [spec: auth-providers/unlimited-entitlement]', async () => {
|
|
const { app, db } = await createTestApp()
|
|
const admin = await adminHeaders(app)
|
|
await seedProLicense(db)
|
|
|
|
expect((await putProvider(app, admin, 'github', githubConfig)).status).toBe(200)
|
|
expect((await putProvider(app, admin, 'google', { ...githubConfig, clientId: 'google-id' })).status).toBe(200)
|
|
})
|
|
|
|
it('updating the only provider is not blocked by the free limit [spec: auth-providers/update-not-limited]', async () => {
|
|
const { app } = await createTestApp()
|
|
const admin = await adminHeaders(app)
|
|
|
|
await putProvider(app, admin, 'github', githubConfig)
|
|
const res = await putProvider(app, admin, 'github', { ...githubConfig, clientId: 'updated' })
|
|
expect(res.status).toBe(200)
|
|
})
|
|
|
|
it('returns masked secret on create response', async () => {
|
|
const { app } = await createTestApp()
|
|
const admin = await adminHeaders(app)
|
|
|
|
const res = await putProvider(app, admin, 'github', githubConfig)
|
|
const body = (await res.json()) as Record<string, unknown>
|
|
expect(body.clientSecret).not.toBe(githubConfig.clientSecret)
|
|
expect((body.clientSecret as string).endsWith('alue')).toBe(true)
|
|
})
|
|
|
|
it('updates an existing provider on second PUT [spec: auth-providers/update]', async () => {
|
|
const { app } = await createTestApp()
|
|
const admin = await adminHeaders(app)
|
|
|
|
await putProvider(app, admin, 'github', githubConfig)
|
|
const res = await putProvider(app, admin, 'github', { ...githubConfig, clientId: 'new-client-id', enabled: false })
|
|
expect(res.status).toBe(200)
|
|
const body = (await res.json()) as Record<string, unknown>
|
|
expect(body.clientId).toBe('new-client-id')
|
|
expect(body.enabled).toBe(false)
|
|
|
|
// Admin list should still have only one entry
|
|
const listRes = await app.request('/api/site/auth-providers', { headers: admin })
|
|
const listBody = (await listRes.json()) as { items: unknown[] }
|
|
expect(listBody.items).toHaveLength(1)
|
|
})
|
|
|
|
it('admin can create an OIDC provider with discoveryUrl [spec: auth-providers/create-oidc]', async () => {
|
|
const { app } = await createTestApp()
|
|
const admin = await adminHeaders(app)
|
|
|
|
const res = await putProvider(app, admin, 'my-oidc', oidcConfig)
|
|
expect(res.status).toBe(200)
|
|
const body = (await res.json()) as Record<string, unknown>
|
|
expect(body.providerId).toBe('my-oidc')
|
|
expect(body.type).toBe('oidc')
|
|
expect(body.discoveryUrl).toBe(oidcConfig.discoveryUrl)
|
|
expect(body.scopes).toEqual(oidcConfig.scopes)
|
|
})
|
|
|
|
it('returns 400 for unknown builtin provider ID [spec: auth-providers/unknown-builtin]', async () => {
|
|
const { app } = await createTestApp()
|
|
const admin = await adminHeaders(app)
|
|
|
|
const res = await putProvider(app, admin, 'not-a-real-provider', githubConfig)
|
|
expect(res.status).toBe(400)
|
|
const body = (await res.json()) as { error: { message: string } }
|
|
expect(body.error.message).toMatch(/Unknown builtin provider/)
|
|
})
|
|
|
|
it('returns 400 for OIDC provider missing discoveryUrl [spec: auth-providers/oidc-missing-discovery]', async () => {
|
|
const { app } = await createTestApp()
|
|
const admin = await adminHeaders(app)
|
|
|
|
const { discoveryUrl: _, ...oidcWithoutDiscovery } = oidcConfig
|
|
const res = await putProvider(app, admin, 'my-oidc', oidcWithoutDiscovery)
|
|
expect(res.status).toBe(400)
|
|
const body = (await res.json()) as { error: { message: string } }
|
|
expect(body.error.message).toMatch(/discoveryUrl is required/)
|
|
})
|
|
|
|
it('returns 400 when clientId is missing', async () => {
|
|
const { app } = await createTestApp()
|
|
const admin = await adminHeaders(app)
|
|
|
|
const { clientId: _, ...withoutClientId } = githubConfig
|
|
const res = await putProvider(app, admin, 'github', withoutClientId)
|
|
expect(res.status).toBe(400)
|
|
})
|
|
|
|
it('returns 400 when clientSecret is empty string', async () => {
|
|
const { app } = await createTestApp()
|
|
const admin = await adminHeaders(app)
|
|
|
|
const res = await putProvider(app, admin, 'github', { ...githubConfig, clientSecret: '' })
|
|
expect(res.status).toBe(400)
|
|
})
|
|
|
|
it('returns 400 when type is invalid', async () => {
|
|
const { app } = await createTestApp()
|
|
const admin = await adminHeaders(app)
|
|
|
|
const res = await putProvider(app, admin, 'github', { ...githubConfig, type: 'unknown-type' })
|
|
expect(res.status).toBe(400)
|
|
})
|
|
})
|
|
|
|
describe('Auth Providers — admin delete', () => {
|
|
it('returns 401 without authentication', async () => {
|
|
const { app } = await createTestApp()
|
|
const res = await app.request('/api/site/auth-providers/github', { method: 'DELETE' })
|
|
expect(res.status).toBe(401)
|
|
})
|
|
|
|
it('admin can delete a provider', async () => {
|
|
const { app } = await createTestApp()
|
|
const admin = await adminHeaders(app)
|
|
|
|
await putProvider(app, admin, 'github', githubConfig)
|
|
|
|
const res = await app.request('/api/site/auth-providers/github', { method: 'DELETE', headers: admin })
|
|
expect(res.status).toBe(200)
|
|
const body = (await res.json()) as Record<string, unknown>
|
|
expect(body.deleted).toBe(true)
|
|
expect(body.providerId).toBe('github')
|
|
})
|
|
|
|
it('deleted provider no longer appears in public list', async () => {
|
|
const { app } = await createTestApp()
|
|
const admin = await adminHeaders(app)
|
|
|
|
await putProvider(app, admin, 'github', githubConfig)
|
|
await app.request('/api/site/auth-providers/github', { method: 'DELETE', headers: admin })
|
|
|
|
const res = await app.request('/api/site/auth-providers')
|
|
const body = (await res.json()) as { items: unknown[] }
|
|
expect(body.items).toHaveLength(0)
|
|
})
|
|
|
|
it('deleted provider no longer appears in admin list', async () => {
|
|
const { app } = await createTestApp()
|
|
const admin = await adminHeaders(app)
|
|
|
|
await putProvider(app, admin, 'github', githubConfig)
|
|
await app.request('/api/site/auth-providers/github', { method: 'DELETE', headers: admin })
|
|
|
|
const res = await app.request('/api/site/auth-providers', { headers: admin })
|
|
const body = (await res.json()) as { items: unknown[] }
|
|
expect(body.items).toHaveLength(0)
|
|
})
|
|
|
|
it('deleting a non-existent provider returns 200 with deleted flag', async () => {
|
|
const { app } = await createTestApp()
|
|
const admin = await adminHeaders(app)
|
|
|
|
const res = await app.request('/api/site/auth-providers/github', { method: 'DELETE', headers: admin })
|
|
expect(res.status).toBe(200)
|
|
const body = (await res.json()) as Record<string, unknown>
|
|
expect(body.deleted).toBe(true)
|
|
})
|
|
})
|