Files
lobehub__lobehub/packages/openapi/scripts/generate-openapi.test.ts
Rdmclin2 7abba9672f ✨ feat(openapi): add api-keys, evals, mcp-servers and usage to the v1 API (#18141)
* ✨ feat(openapi): expand v1 REST surface with api-keys, evals, mcp-servers and usage

Add new OpenAPI v1 resources (API keys, evals, MCP servers, usage) and
harden the existing controllers with shared validators, pagination
helpers and public-field projection so responses no longer leak internal
columns. Usage reporting goes through a business-server slot so
self-hosted deployments fall back to an unavailable summary.

* 🐛 fix(openapi): enforce row-owner checks on MCP writes and drop the unused upload pathname

The connector model's ownership predicate is workspace-wide, so the REST MCP
write paths let any member with only `agent:update:owner` patch, delete or sync
a server created by another member. The connector tRPC mutations already guard
this with `assertWorkspaceRowManageable`; add the equivalent `assertRowManageable`
to the OpenAPI BaseService and apply it before every MCP mutation.

Also remove `pathname` from the public upload schema, route docs and spec: it
never reached the storage key, and its validator allowed any 1-1024 char string,
so wiring it through would have handed clients an unchecked object path. The
batch upload schema already omitted it.

* 🐛 fix(openapi): drop request fields the v1 run paths never honor

`n` never reached ChatStreamPayload and /chat returns a single content string,
so accepting it promised multiple choices and delivered one. `caseSelection` is
storage-only for internal runs — this endpoint always creates `mode: 'internal'`,
which pre-creates topics for every case in the dataset — so a caller asking for a
subset would silently run the whole thing. `maxConcurrency` is consumed nowhere.

Remove all three from the public schemas and regenerate the spec, matching the
`pathname` removal: the contract should only advertise knobs the run path honors.
`k`, `maxSteps` and `timeout` stay because the run service does read them.

* 🐛 fix(openapi): admin-gate workspace API-key routes

`workspaceAuthMiddleware` only enforces the owner/admin role for `apikey` auth,
so an OIDC/session-authenticated workspace member holding `api_key:update:owner`
could PATCH or DELETE another member's workspace key by id — `ApiKeyModel`'s
predicate is workspace-wide, not row-owner.

The tRPC side already gates this: `apiKeyProcedure` applies
`requireWorkspaceRoleWhenScoped('admin')` to the whole router, so the REST
surface was strictly more permissive. Add the equivalent Hono middleware and
apply it to every api-keys route so both transports gate identically.

* 🐛 fix(openapi): preserve an explicit temperature of 0 on /chat

`ChatStreamPayload` was built with `params.temperature || 1`, so a request
asking for deterministic output at `temperature: 0` was silently sent at 1 —
the schema has accepted `min(0)` since the endpoint was introduced.

Pre-existing since #12758 rather than a regression here, but it is the same
contract-vs-implementation mismatch this PR has been clearing up, and the fix is
`??` instead of `||`. Scoped to the request path: `generateReply` applies the
same falsy fallback to an agent's stored config, which is a separate behavioural
change for existing agents and is left alone.

* 🐛 fix(openapi): require chat:write to queue an eval run

An internal eval run pre-creates real chat topics through `TopicModel`, and the
QStash workflow keeps writing topic/message state afterwards. The route gated
only on `agent:write` + `model:invoke`, so a restricted API key deliberately
denied `chat:write` could still persist chat artifacts through `/eval/runs`.

`/responses` already enforces the full `agent:write` + `chat:write` +
`model:invoke` trio for exactly this reason; match it here.

* 🐛 fix(openapi): correct file response schemas and regenerate the SDK

The spec fell back to the bare resource schema for any one-segment path, so
`POST /files`, `GET /files/{id}`, `POST /files/batches` and `POST /files/queries`
all advertised `File` while the controllers actually return wrapped payloads
(`{ file, parsed? }`, `{ successful, failed, summary }`, `{ files, failed, ... }`).
Generated clients would have mistyped every one of them. Describe the four
explicitly ahead of the fallback.

`packages/sdk/src/generated` was also stale — its drift test was already failing
locally, since the SDK is not in the PR CI whitelist. Regenerated it from the
corrected spec, so the new api-keys / eval / mcp-servers / usage resources are
callable and the file operations carry the right types.

* 🐛 fix(openapi): keep MCP server routes to base-scope connectors

`isPublicMcpServer` ignored `agentId`, but `queryPublic()` (the list path)
filters `agent_id IS NULL` while `findPublicById` does not. So the id routes —
GET/PATCH/DELETE and POST /:id/sync — could reach agent-scoped connectors the
list route cannot even surface.

Deleting one that way skipped the connector router's agent-plugin unpin, which
exists specifically so a delete never leaves the owning agent with a dangling
pinned tool. Require `agentId === null` in the predicate so the whole group is
consistently base-scoped; agent-scoped connectors stay managed through the agent
profile.

* 🐛 fix(openapi): stop typing named sub-operations as the group resource

The success-schema fallback treated any single-segment path as a resource
operation, so `GET /messages/count` was advertised as `Message` (it returns
`{ count }`) and `GET /users/me` as `User` (a caller without `user:read`
deliberately gets only `{ id }`). Generated SDK callers were typed against the
wrong shapes.

Narrow the fallback to the two real resource operations — the collection root
and `{id}` — and describe `messages/count`, `messages/replies` and `users/me`
explicitly. An undescribed sub-operation now degrades to a permissive object
rather than a confidently wrong resource type, which is the failure mode that
produced both this and the earlier `/files` mistyping. SDK regenerated.
2026-08-10 16:21:49 -07:00

90 lines
4.2 KiB
TypeScript

import { execSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { describe, expect, it } from 'vitest';
import { parse } from 'yaml';
const PKG_ROOT = path.join(__dirname, '..');
describe('generate-openapi', () => {
it('openapi.yml stays in sync with the registered routes', () => {
// Runs the generator in --check mode as a subprocess (the same command CI
// and developers use), so the committed spec failing to match the routes
// fails this test with the generator's own diagnostics.
try {
execSync('bun scripts/generate-openapi.ts --check', {
cwd: PKG_ROOT,
encoding: 'utf8',
stdio: 'pipe',
});
} catch (error) {
const execError = error as { stderr?: string; stdout?: string };
throw new Error(
`openapi.yml is out of date. Run \`bun generate:openapi\` in packages/openapi and commit the result.\n${execError.stderr ?? ''}${execError.stdout ?? ''}`,
{ cause: error },
);
}
}, 120_000);
it('committed spec is a valid OpenAPI 3.1 document with full route coverage', () => {
const spec = parse(readFileSync(path.join(PKG_ROOT, 'openapi.yml'), 'utf8'));
expect(spec.openapi).toBe('3.1.0');
expect(spec.info.title).toBe('LobeHub API');
expect(spec.components.securitySchemes.bearerAuth.scheme).toBe('bearer');
expect(spec.components.schemas.Agent.additionalProperties).toBe(false);
expect(spec.components.schemas.Agent.properties).not.toHaveProperty('clientId');
expect(spec.components.schemas.Agent.properties).not.toHaveProperty('marketIdentifier');
expect(spec.components.schemas.Agent.properties).not.toHaveProperty('userId');
expect(spec.components.schemas.Agent.properties).not.toHaveProperty('workspaceId');
expect(spec.components.schemas.Provider.properties).not.toHaveProperty('keyVaults');
expect(spec.components.schemas.ChatResponse.additionalProperties).toBe(false);
expect(spec.components.schemas.McpServer.properties).not.toHaveProperty('credentials');
expect(spec.components.schemas.McpServer.properties).not.toHaveProperty('oidcConfig');
expect(spec.components.schemas.ApiKey.properties.scopes.items.enum).toEqual(
expect.arrayContaining(['mcp:read', 'mcp:write', 'usage:read']),
);
expect(
spec.paths['/api/v1/api-keys'].post.requestBody.content['application/json'].schema.properties
.scopes.anyOf[0].items.enum,
).toEqual(expect.arrayContaining(['mcp:read', 'mcp:write', 'usage:read']));
expect(spec.paths['/api/v1/chat'].post.responses['200'].content).toBeTruthy();
expect(spec.paths['/api/v1/chat/translate'].post.responses['200'].content).toBeTruthy();
expect(spec.paths['/api/v1/chat/generate-reply'].post.responses['200'].content).toBeTruthy();
const operations = Object.values(spec.paths as Record<string, object>).flatMap((item) =>
Object.keys(item).filter((method) =>
['delete', 'get', 'patch', 'post', 'put'].includes(method),
),
);
// Business endpoints + /health; grows as routes are added.
expect(operations.length).toBeGreaterThanOrEqual(97);
// Every response object must carry a description (required by OpenAPI).
for (const item of Object.values(spec.paths as Record<string, Record<string, any>>)) {
for (const op of Object.values(item)) {
if (!op.responses) continue;
expect(
op.responses['200']?.content ??
op.responses['201']?.content ??
op.responses['202']?.content,
).toBeTruthy();
expect(op.responses['400']?.content).toBeTruthy();
expect(op.responses['401']?.content).toBeTruthy();
expect(op.responses['403']?.content).toBeTruthy();
expect(op.responses['404']?.content).toBeTruthy();
expect(op.responses['409']?.content).toBeTruthy();
expect(op.responses['429']?.content).toBeTruthy();
expect(op.responses['500']?.content).toBeTruthy();
for (const response of Object.values(
op.responses as Record<string, { description?: string }>,
)) {
expect(response.description).toBeTruthy();
expect(response.description).not.toContain('schema pending');
}
}
}
});
});