mirror of
https://github.com/saltbo/zpan.git
synced 2026-08-30 17:50:07 +08:00
feat: publish Restish OpenAPI security bindings (#540)
Agent-Profile: https://agent-kanban.dev/agents/1dc839c09b5ee5e5 Co-authored-by: Marina Zhou <marina-zhou@mails.agent-kanban.dev>
This commit is contained in:
committed by
GitHub
parent
d22227ed2f
commit
00d298627c
+81
-48
@@ -1,6 +1,9 @@
|
||||
import { release as osRelease } from 'node:os'
|
||||
import { OpenAPIHono } from '@hono/zod-openapi'
|
||||
import { Scalar } from '@scalar/hono-api-reference'
|
||||
import { AGENT_OAUTH_CLIENT_ID } from '@shared/agent-oauth'
|
||||
import { AGENT_API_KEY_SHORTCUT_SCOPES, AgentApiKeyShortcut } from '@shared/api-key-templates'
|
||||
import { AuthorizationScope } from '@shared/authorization'
|
||||
import type { Context } from 'hono'
|
||||
import { cors } from 'hono/cors'
|
||||
import type { Auth } from './auth'
|
||||
@@ -175,6 +178,7 @@ export function createApp(platform: Platform, auth: Auth, deps: Deps = createDep
|
||||
const doc = app.getOpenAPIDocument({
|
||||
openapi: '3.1.0',
|
||||
info: { title: 'ZPan API', version: '0.1.0' },
|
||||
servers: [{ url: '/', description: 'Current ZPan origin' }],
|
||||
// Top-level tag order + descriptions; Scalar groups operations by these.
|
||||
tags: [
|
||||
{ name: 'Objects', description: 'Files and folders, including S3 multipart upload sessions' },
|
||||
@@ -209,15 +213,7 @@ export function createApp(platform: Platform, auth: Auth, deps: Deps = createDep
|
||||
tokenUrl: '/api/auth/oauth2/token',
|
||||
refreshUrl: '/api/auth/oauth2/token',
|
||||
scopes: {
|
||||
'objects:read': 'List, inspect, and download objects',
|
||||
'objects:create': 'Create folders and upload objects',
|
||||
'objects:update': 'Rename, move, and copy objects',
|
||||
'objects:delete': 'Soft-delete objects',
|
||||
'shares:read': 'List and inspect shares',
|
||||
'shares:create': 'Create public shares',
|
||||
'shares:delete': 'Revoke shares',
|
||||
'quota:read': 'Inspect workspace quota',
|
||||
'storage-usage:read': 'Inspect workspace storage usage',
|
||||
...agentScopeDescriptions(),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -229,45 +225,10 @@ export function createApp(platform: Platform, auth: Auth, deps: Deps = createDep
|
||||
...doc.components.schemas,
|
||||
}
|
||||
Object.assign(doc, {
|
||||
'x-cli-config': {
|
||||
auth: {
|
||||
reader: {
|
||||
type: 'oauth-authorization-code',
|
||||
params: {
|
||||
authorize_url: '/api/auth/oauth2/authorize',
|
||||
token_url: '/api/auth/oauth2/token',
|
||||
client_id: 'zpan-agent',
|
||||
scopes: 'openid offline_access objects:read shares:read quota:read storage-usage:read',
|
||||
redirect_path: '/callback',
|
||||
},
|
||||
},
|
||||
'file-manager': {
|
||||
type: 'oauth-authorization-code',
|
||||
params: {
|
||||
authorize_url: '/api/auth/oauth2/authorize',
|
||||
token_url: '/api/auth/oauth2/token',
|
||||
client_id: 'zpan-agent',
|
||||
scopes:
|
||||
'openid offline_access objects:read objects:create objects:update objects:delete shares:read quota:read storage-usage:read',
|
||||
redirect_path: '/callback',
|
||||
},
|
||||
},
|
||||
publisher: {
|
||||
type: 'oauth-authorization-code',
|
||||
params: {
|
||||
authorize_url: '/api/auth/oauth2/authorize',
|
||||
token_url: '/api/auth/oauth2/token',
|
||||
client_id: 'zpan-agent',
|
||||
scopes:
|
||||
'openid offline_access objects:read shares:read shares:create shares:delete quota:read storage-usage:read',
|
||||
redirect_path: '/callback',
|
||||
},
|
||||
},
|
||||
ci: {
|
||||
type: 'http-bearer',
|
||||
params: { token: 'env:ZPAN_AGENT_API_KEY' },
|
||||
},
|
||||
},
|
||||
'x-cli-config': restishCliConfig(),
|
||||
'x-zpan-discovery': {
|
||||
oauthAuthorizationServer: '/.well-known/oauth-authorization-server/api/auth',
|
||||
oauthProtectedResource: '/.well-known/oauth-protected-resource/api',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -294,6 +255,15 @@ export function createApp(platform: Platform, auth: Auth, deps: Deps = createDep
|
||||
}
|
||||
}
|
||||
|
||||
for (const [path, pathItem] of Object.entries(doc.paths)) {
|
||||
if (!path.startsWith('/api/auth/') || !pathItem || typeof pathItem !== 'object') continue
|
||||
for (const operation of Object.values(pathItem)) {
|
||||
if (!operation || typeof operation !== 'object') continue
|
||||
Object.assign(operation, { 'x-mcp-ignore': true })
|
||||
if (path.includes('/callback')) Object.assign(operation, { 'x-cli-ignore': true })
|
||||
}
|
||||
}
|
||||
|
||||
return c.json(doc)
|
||||
})
|
||||
|
||||
@@ -460,6 +430,69 @@ function getCorsOrigins(platform: Platform): Set<string> {
|
||||
return origins
|
||||
}
|
||||
|
||||
function agentScopeDescriptions(): Record<string, string> {
|
||||
return {
|
||||
[AuthorizationScope.OBJECTS_READ]: 'List, inspect, and download objects',
|
||||
[AuthorizationScope.OBJECTS_CREATE]: 'Create folders and upload objects',
|
||||
[AuthorizationScope.OBJECTS_UPDATE]: 'Rename, move, and copy objects',
|
||||
[AuthorizationScope.OBJECTS_DELETE]: 'Soft-delete objects',
|
||||
[AuthorizationScope.SHARES_READ]: 'List and inspect shares',
|
||||
[AuthorizationScope.SHARES_CREATE]: 'Create public shares',
|
||||
[AuthorizationScope.SHARES_DELETE]: 'Revoke shares',
|
||||
[AuthorizationScope.QUOTA_READ]: 'Inspect workspace quota',
|
||||
[AuthorizationScope.STORAGE_USAGE_READ]: 'Inspect workspace storage usage',
|
||||
}
|
||||
}
|
||||
|
||||
function restishCliConfig() {
|
||||
const oauthCredential = (scopes: readonly AuthorizationScope[]) => ({
|
||||
auth: {
|
||||
type: 'oauth-authorization-code',
|
||||
params: {
|
||||
authorize_url: '/api/auth/oauth2/authorize',
|
||||
token_url: '/api/auth/oauth2/token',
|
||||
client_id: AGENT_OAUTH_CLIENT_ID,
|
||||
scopes: ['openid', 'offline_access', ...scopes].join(' '),
|
||||
redirect_path: '/callback',
|
||||
},
|
||||
},
|
||||
satisfies: [...scopes],
|
||||
})
|
||||
|
||||
return {
|
||||
profiles: {
|
||||
default: {
|
||||
credentials: {
|
||||
agentOAuth2: oauthCredential(AGENT_API_KEY_SHORTCUT_SCOPES[AgentApiKeyShortcut.READER]),
|
||||
},
|
||||
},
|
||||
reader: {
|
||||
credentials: {
|
||||
agentOAuth2: oauthCredential(AGENT_API_KEY_SHORTCUT_SCOPES[AgentApiKeyShortcut.READER]),
|
||||
},
|
||||
},
|
||||
'file-manager': {
|
||||
credentials: {
|
||||
agentOAuth2: oauthCredential(AGENT_API_KEY_SHORTCUT_SCOPES[AgentApiKeyShortcut.FILE_MANAGER]),
|
||||
},
|
||||
},
|
||||
publisher: {
|
||||
credentials: {
|
||||
agentOAuth2: oauthCredential(AGENT_API_KEY_SHORTCUT_SCOPES[AgentApiKeyShortcut.PUBLISHER]),
|
||||
},
|
||||
},
|
||||
ci: {
|
||||
credentials: {
|
||||
agentApiKey: {
|
||||
auth: { type: 'bearer', params: { token: 'env:ZPAN_AGENT_API_KEY' } },
|
||||
satisfies: [...AGENT_API_KEY_SHORTCUT_SCOPES[AgentApiKeyShortcut.FILE_MANAGER]],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export type AppType = ReturnType<typeof createApp>
|
||||
|
||||
// Sub-router types for RPC clients — avoids combined AppType OOM
|
||||
|
||||
+17
-3
@@ -1,4 +1,5 @@
|
||||
import { createRoute, type RouteConfig, type z } from '@hono/zod-openapi'
|
||||
import { AGENT_GRANTABLE_API_KEY_SCOPES } from '@shared/api-key-templates'
|
||||
import { errorResponseSchema } from '@shared/schemas'
|
||||
import { authorize, type RouteAuthorizationDeclaration } from '../middleware/authz'
|
||||
|
||||
@@ -23,6 +24,8 @@ export const jsonBody = <T extends z.ZodType>(schema: T) => ({
|
||||
// `jsonError`; this just documents the response shape in the OpenAPI document.
|
||||
export const errorResponse = (description: string) => jsonContent(errorResponseSchema, description)
|
||||
|
||||
const AGENT_GRANTABLE_SCOPE_SET = new Set<string>(AGENT_GRANTABLE_API_KEY_SCOPES)
|
||||
|
||||
export function authRoute<P extends string, T extends Omit<RouteConfig, 'path'> & { path: P }>(
|
||||
auth: RouteAuthorizationDeclaration,
|
||||
config: T,
|
||||
@@ -34,6 +37,7 @@ export function authRoute<P extends string, T extends Omit<RouteConfig, 'path'>
|
||||
middleware,
|
||||
security: openApiSecurity(auth),
|
||||
'x-zpan-auth': openApiAuthMetadata(auth),
|
||||
...openApiCliMetadata(auth),
|
||||
} as T) as T & { getRoutingPath(): string }
|
||||
}
|
||||
|
||||
@@ -58,9 +62,19 @@ function openApiSecurity(auth: RouteAuthorizationDeclaration): Record<string, st
|
||||
if (auth.access === 'downloader' || auth.access === 'downloader-bootstrap' || auth.access === 'task-upload-token') {
|
||||
return [{ bearerAuth: [] }]
|
||||
}
|
||||
return auth.scopes?.length
|
||||
? [{ bearerAuth: [...auth.scopes] }, { cookieAuth: [] }]
|
||||
: [{ bearerAuth: [] }, { cookieAuth: [] }]
|
||||
if (!auth.scopes?.length) return [{ bearerAuth: [] }, { cookieAuth: [] }]
|
||||
return auth.scopes.every((scope) => AGENT_GRANTABLE_SCOPE_SET.has(scope))
|
||||
? [{ agentOAuth2: [...auth.scopes] }, { agentApiKey: [...auth.scopes] }, { cookieAuth: [] }]
|
||||
: [{ bearerAuth: [...auth.scopes] }, { cookieAuth: [] }]
|
||||
}
|
||||
|
||||
function openApiCliMetadata(auth: RouteAuthorizationDeclaration): Record<string, boolean> {
|
||||
if (auth.access === 'anyOf') {
|
||||
return auth.policies.every((policy) => openApiCliMetadata(policy)['x-mcp-ignore']) ? { 'x-mcp-ignore': true } : {}
|
||||
}
|
||||
if (auth.access === 'public' || auth.access === 'protected') return {}
|
||||
if (auth.access === 'internal') return { 'x-cli-ignore': true, 'x-mcp-ignore': true }
|
||||
return { 'x-mcp-ignore': true }
|
||||
}
|
||||
|
||||
function openApiAuthMetadata(auth: RouteAuthorizationDeclaration): Record<string, unknown> {
|
||||
|
||||
+315
-5
@@ -57,19 +57,139 @@ describe('global OpenAPI document', () => {
|
||||
const { app } = await createTestApp({ DOWNLOAD_TOKEN_SECRET: 'test-download-token-secret' })
|
||||
const res = await app.request('/api/openapi.json')
|
||||
const doc = (await res.json()) as {
|
||||
components?: { securitySchemes?: Record<string, unknown> }
|
||||
'x-cli-config'?: { auth?: Record<string, { params?: { client_id?: string; redirect_path?: string } }> }
|
||||
components?: {
|
||||
securitySchemes?: Record<
|
||||
string,
|
||||
{ type?: string; scheme?: string; flows?: { authorizationCode?: { scopes?: Record<string, string> } } }
|
||||
>
|
||||
}
|
||||
'x-cli-config'?: {
|
||||
profiles?: Record<
|
||||
string,
|
||||
{
|
||||
credentials?: Record<
|
||||
string,
|
||||
{
|
||||
auth?: { type?: string; params?: Record<string, string> }
|
||||
satisfies?: string[]
|
||||
}
|
||||
>
|
||||
}
|
||||
>
|
||||
}
|
||||
}
|
||||
|
||||
expect(doc.components?.securitySchemes?.agentOAuth2).toMatchObject({
|
||||
type: 'oauth2',
|
||||
flows: { authorizationCode: { authorizationUrl: '/api/auth/oauth2/authorize' } },
|
||||
flows: {
|
||||
authorizationCode: {
|
||||
authorizationUrl: '/api/auth/oauth2/authorize',
|
||||
tokenUrl: '/api/auth/oauth2/token',
|
||||
refreshUrl: '/api/auth/oauth2/token',
|
||||
scopes: expect.objectContaining({
|
||||
[AuthorizationScope.OBJECTS_READ]: 'List, inspect, and download objects',
|
||||
[AuthorizationScope.OBJECTS_CREATE]: 'Create folders and upload objects',
|
||||
[AuthorizationScope.SHARES_CREATE]: 'Create public shares',
|
||||
}),
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(doc.components?.securitySchemes?.agentApiKey).toMatchObject({ type: 'http', scheme: 'bearer' })
|
||||
expect(doc['x-cli-config']?.auth?.reader?.params).toMatchObject({
|
||||
const profiles = doc['x-cli-config']?.profiles
|
||||
expect(Object.keys(profiles ?? {})).toEqual(['default', 'reader', 'file-manager', 'publisher', 'ci'])
|
||||
expect(profiles?.reader?.credentials?.agentOAuth2).toMatchObject({
|
||||
auth: {
|
||||
type: 'oauth-authorization-code',
|
||||
params: {
|
||||
authorize_url: '/api/auth/oauth2/authorize',
|
||||
token_url: '/api/auth/oauth2/token',
|
||||
client_id: 'zpan-agent',
|
||||
redirect_path: '/callback',
|
||||
scopes: 'openid offline_access objects:read shares:read quota:read storage-usage:read',
|
||||
},
|
||||
},
|
||||
satisfies: [
|
||||
AuthorizationScope.OBJECTS_READ,
|
||||
AuthorizationScope.SHARES_READ,
|
||||
AuthorizationScope.QUOTA_READ,
|
||||
AuthorizationScope.STORAGE_USAGE_READ,
|
||||
],
|
||||
})
|
||||
expect(profiles?.default?.credentials?.agentOAuth2).toEqual(profiles?.reader?.credentials?.agentOAuth2)
|
||||
expect(profiles?.['file-manager']?.credentials?.agentOAuth2).toMatchObject({
|
||||
auth: {
|
||||
type: 'oauth-authorization-code',
|
||||
params: {
|
||||
authorize_url: '/api/auth/oauth2/authorize',
|
||||
token_url: '/api/auth/oauth2/token',
|
||||
client_id: 'zpan-agent',
|
||||
redirect_path: '/callback',
|
||||
scopes:
|
||||
'openid offline_access objects:read objects:create objects:update objects:delete shares:read quota:read storage-usage:read',
|
||||
},
|
||||
},
|
||||
satisfies: [
|
||||
AuthorizationScope.OBJECTS_READ,
|
||||
AuthorizationScope.OBJECTS_CREATE,
|
||||
AuthorizationScope.OBJECTS_UPDATE,
|
||||
AuthorizationScope.OBJECTS_DELETE,
|
||||
AuthorizationScope.SHARES_READ,
|
||||
AuthorizationScope.QUOTA_READ,
|
||||
AuthorizationScope.STORAGE_USAGE_READ,
|
||||
],
|
||||
})
|
||||
expect(profiles?.publisher?.credentials?.agentOAuth2).toMatchObject({
|
||||
auth: {
|
||||
type: 'oauth-authorization-code',
|
||||
params: {
|
||||
authorize_url: '/api/auth/oauth2/authorize',
|
||||
token_url: '/api/auth/oauth2/token',
|
||||
client_id: 'zpan-agent',
|
||||
redirect_path: '/callback',
|
||||
scopes:
|
||||
'openid offline_access objects:read shares:read shares:create shares:delete quota:read storage-usage:read',
|
||||
},
|
||||
},
|
||||
satisfies: [
|
||||
AuthorizationScope.OBJECTS_READ,
|
||||
AuthorizationScope.SHARES_READ,
|
||||
AuthorizationScope.SHARES_CREATE,
|
||||
AuthorizationScope.SHARES_DELETE,
|
||||
AuthorizationScope.QUOTA_READ,
|
||||
AuthorizationScope.STORAGE_USAGE_READ,
|
||||
],
|
||||
})
|
||||
expect(profiles?.default?.credentials?.agentOAuth2?.auth?.params).toMatchObject({
|
||||
client_id: 'zpan-agent',
|
||||
redirect_path: '/callback',
|
||||
})
|
||||
expect(profiles?.ci?.credentials?.agentApiKey).toMatchObject({
|
||||
auth: { type: 'bearer', params: { token: 'env:ZPAN_AGENT_API_KEY' } },
|
||||
satisfies: [
|
||||
AuthorizationScope.OBJECTS_READ,
|
||||
AuthorizationScope.OBJECTS_CREATE,
|
||||
AuthorizationScope.OBJECTS_UPDATE,
|
||||
AuthorizationScope.OBJECTS_DELETE,
|
||||
AuthorizationScope.SHARES_READ,
|
||||
AuthorizationScope.QUOTA_READ,
|
||||
AuthorizationScope.STORAGE_USAGE_READ,
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('publishes relative servers and discovery links for self-hosted origins', async () => {
|
||||
const { app } = await createTestApp({ DOWNLOAD_TOKEN_SECRET: 'test-download-token-secret' })
|
||||
const res = await app.request('/api/openapi.json')
|
||||
const doc = (await res.json()) as {
|
||||
servers?: { url?: string }[]
|
||||
'x-zpan-discovery'?: Record<string, string>
|
||||
}
|
||||
|
||||
expect(doc.servers).toEqual([{ url: '/', description: 'Current ZPan origin' }])
|
||||
expect(doc['x-zpan-discovery']).toEqual({
|
||||
oauthAuthorizationServer: '/.well-known/oauth-authorization-server/api/auth',
|
||||
oauthProtectedResource: '/.well-known/oauth-protected-resource/api',
|
||||
})
|
||||
})
|
||||
|
||||
it('publishes OAuth discovery and protected-resource metadata at root locations', async () => {
|
||||
@@ -183,6 +303,28 @@ describe('global OpenAPI document', () => {
|
||||
expect(route.middleware).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('emits Agent OAuth and API-key security only for Agent-grantable protected scopes', () => {
|
||||
const route = authRoute(
|
||||
{
|
||||
access: 'protected',
|
||||
scopes: [AuthorizationScope.OBJECTS_CREATE],
|
||||
minTeamRole: 'editor',
|
||||
},
|
||||
{
|
||||
operationId: 'agentGrantableAuthzProbe',
|
||||
method: 'post',
|
||||
path: '/probe',
|
||||
responses: { 200: { description: 'OK' } },
|
||||
},
|
||||
) as { security?: unknown }
|
||||
|
||||
expect(route.security).toEqual([
|
||||
{ agentOAuth2: [AuthorizationScope.OBJECTS_CREATE] },
|
||||
{ agentApiKey: [AuthorizationScope.OBJECTS_CREATE] },
|
||||
{ cookieAuth: [] },
|
||||
])
|
||||
})
|
||||
|
||||
it('detects OpenAPI operations missing explicit authorization declarations without an allowlist', () => {
|
||||
expect(
|
||||
findOperationsMissingAuthContract({
|
||||
@@ -208,7 +350,7 @@ describe('global OpenAPI document', () => {
|
||||
const { app } = await createTestApp({ DOWNLOAD_TOKEN_SECRET: 'test-download-token-secret' })
|
||||
const res = await app.request('/api/openapi.json')
|
||||
const doc = (await res.json()) as {
|
||||
paths: Record<string, Record<string, { security?: unknown; 'x-zpan-auth'?: unknown }>>
|
||||
paths: Record<string, Record<string, { security?: unknown; 'x-zpan-auth'?: unknown; 'x-mcp-ignore'?: boolean }>>
|
||||
}
|
||||
|
||||
const operation = doc.paths['/api/downloads/downloaders']?.post
|
||||
@@ -217,6 +359,174 @@ describe('global OpenAPI document', () => {
|
||||
access: 'anyOf',
|
||||
policies: [{ access: 'admin' }, { access: 'downloader-bootstrap' }],
|
||||
})
|
||||
expect(operation?.['x-mcp-ignore']).toBe(true)
|
||||
})
|
||||
|
||||
it('marks session, admin, and credential-management operations as ignored by MCP without hiding them from Restish', async () => {
|
||||
const { app } = await createTestApp({ DOWNLOAD_TOKEN_SECRET: 'test-download-token-secret' })
|
||||
const res = await app.request('/api/openapi.json')
|
||||
const doc = (await res.json()) as {
|
||||
paths: Record<string, Record<string, { 'x-cli-ignore'?: boolean; 'x-mcp-ignore'?: boolean }>>
|
||||
}
|
||||
|
||||
const ignoredOperations = [
|
||||
doc.paths['/api/workspaces/{orgId}/agent-api-keys']?.get,
|
||||
doc.paths['/api/workspaces/{orgId}/agent-api-keys']?.post,
|
||||
doc.paths['/api/agent-oauth-grants']?.get,
|
||||
doc.paths['/api/agent-oauth-grants/{grantId}']?.delete,
|
||||
doc.paths['/api/site/storages']?.post,
|
||||
doc.paths['/api/auth/sign-in/email']?.post,
|
||||
doc.paths['/api/auth/sign-out']?.post,
|
||||
]
|
||||
|
||||
for (const operation of ignoredOperations) {
|
||||
expect(operation?.['x-mcp-ignore']).toBe(true)
|
||||
expect(operation?.['x-cli-ignore']).toBeUndefined()
|
||||
}
|
||||
expect(doc.paths['/api/auth/callback/{id}']?.get?.['x-mcp-ignore']).toBe(true)
|
||||
expect(doc.paths['/api/auth/callback/{id}']?.get?.['x-cli-ignore']).toBe(true)
|
||||
expect(doc.paths['/api/objects']?.get?.['x-mcp-ignore']).toBeUndefined()
|
||||
expect(Object.keys(doc.paths)).not.toContain('/api/openapi.agent.json')
|
||||
expect(await app.request('/api/openapi.agent.json')).toMatchObject({ status: 404 })
|
||||
})
|
||||
|
||||
it('publishes stable upload operations for Restish plugin discovery', async () => {
|
||||
const { app } = await createTestApp({ DOWNLOAD_TOKEN_SECRET: 'test-download-token-secret' })
|
||||
const res = await app.request('/api/openapi.json')
|
||||
const doc = (await res.json()) as {
|
||||
paths: Record<
|
||||
string,
|
||||
Record<
|
||||
string,
|
||||
{
|
||||
operationId?: string
|
||||
security?: Record<string, string[]>[]
|
||||
parameters?: { name?: string; in?: string; required?: boolean; schema?: unknown }[]
|
||||
requestBody?: {
|
||||
content?: {
|
||||
'application/json'?: { schema?: { properties?: Record<string, unknown>; required?: string[] } }
|
||||
}
|
||||
}
|
||||
responses?: Record<
|
||||
string,
|
||||
{
|
||||
content?: {
|
||||
'application/json'?: { schema?: { $ref?: string; allOf?: unknown[]; properties?: unknown } }
|
||||
}
|
||||
}
|
||||
>
|
||||
}
|
||||
>
|
||||
>
|
||||
}
|
||||
|
||||
expect(doc.paths['/api/objects']?.post).toMatchObject({
|
||||
operationId: 'createObject',
|
||||
security: [
|
||||
{ agentOAuth2: [AuthorizationScope.OBJECTS_CREATE] },
|
||||
{ agentApiKey: [AuthorizationScope.OBJECTS_CREATE] },
|
||||
{ cookieAuth: [] },
|
||||
],
|
||||
})
|
||||
expect(doc.paths['/api/objects']?.post?.responses?.['201']).toBeDefined()
|
||||
expect(doc.paths['/api/objects']?.post?.requestBody).toBeDefined()
|
||||
expect(doc.paths['/api/objects']?.post?.requestBody?.content?.['application/json']?.schema).toMatchObject({
|
||||
required: ['name'],
|
||||
properties: {
|
||||
name: expect.any(Object),
|
||||
type: expect.any(Object),
|
||||
size: expect.any(Object),
|
||||
parent: expect.any(Object),
|
||||
onConflict: expect.any(Object),
|
||||
storageId: expect.any(Object),
|
||||
},
|
||||
})
|
||||
expect(doc.paths['/api/objects']?.post?.responses?.['201']?.content?.['application/json']?.schema).toMatchObject({
|
||||
allOf: [
|
||||
{ $ref: '#/components/schemas/Matter' },
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
upload: {
|
||||
type: 'object',
|
||||
required: [
|
||||
'sessionId',
|
||||
'uploadId',
|
||||
'mode',
|
||||
'partSize',
|
||||
'partCount',
|
||||
'expiresAt',
|
||||
'presignedExpiresAt',
|
||||
'requiredHeaders',
|
||||
'urls',
|
||||
'parts',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
expect(doc.paths['/api/objects/{id}/uploads/{uploadSessionId}/parts']?.post).toMatchObject({
|
||||
operationId: 'presignObjectUploadParts',
|
||||
parameters: expect.arrayContaining([
|
||||
expect.objectContaining({ name: 'id', in: 'path', required: true }),
|
||||
expect.objectContaining({ name: 'uploadSessionId', in: 'path', required: true }),
|
||||
]),
|
||||
requestBody: {
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { required: ['partNumbers'], properties: { partNumbers: expect.any(Object) } },
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
required: [
|
||||
'uploadId',
|
||||
'mode',
|
||||
'partSize',
|
||||
'partCount',
|
||||
'presignedExpiresAt',
|
||||
'requiredHeaders',
|
||||
'parts',
|
||||
],
|
||||
properties: { parts: expect.any(Object) },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(doc.paths['/api/objects/{id}/uploads/{uploadSessionId}/completions']?.post).toMatchObject({
|
||||
operationId: 'completeObjectUpload',
|
||||
parameters: expect.arrayContaining([
|
||||
expect.objectContaining({ name: 'id', in: 'path', required: true }),
|
||||
expect.objectContaining({ name: 'uploadSessionId', in: 'path', required: true }),
|
||||
]),
|
||||
requestBody: {
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { required: ['parts'], properties: { parts: expect.any(Object) } },
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: { 200: { content: { 'application/json': { schema: { $ref: '#/components/schemas/Matter' } } } } },
|
||||
})
|
||||
expect(doc.paths['/api/objects/{id}/uploads/{uploadSessionId}']?.delete).toMatchObject({
|
||||
operationId: 'abortObjectUpload',
|
||||
parameters: expect.arrayContaining([
|
||||
expect.objectContaining({ name: 'id', in: 'path', required: true }),
|
||||
expect.objectContaining({ name: 'uploadSessionId', in: 'path', required: true }),
|
||||
expect.objectContaining({ name: 'strictStorageCleanup', in: 'query', required: false }),
|
||||
]),
|
||||
responses: { 204: { description: 'Aborted upload and discarded the draft' } },
|
||||
})
|
||||
expect(
|
||||
doc.paths['/api/objects/{id}/uploads/{uploadSessionId}']?.delete?.responses?.['204']?.content,
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
it('documents owner role requirements for store operations that enforce owner team role', async () => {
|
||||
|
||||
Reference in New Issue
Block a user