Files
AgentHub/server/__tests__/openapi-coverage.test.js
T
nntrivi2001 8546748759 chore: maintenance — dependency majors, rename step 4, member Claude config, OpenAPI coverage
Dependencies
- Security: body-parser and multer DoS advisories fixed (npm audit: 0).
- Server: Express 5 (query parser kept "extended", req.body defaults to {},
  SPA catch-all /{*splat}, send-file helper restores Express 4's dot-folder
  rule for sendFile/download, alerts ack route without inline regex),
  better-sqlite3 13, js-yaml 5 (server/lib/yaml.js keeps js-yaml 4's default
  schema), uuid dropped for crypto.randomUUID, node-pty beta.18, multer 2.5.
- Client: React 19, xterm 6 (WebGL renderer replaces the removed canvas one,
  DOM fallback on context loss; Alt+arrow word moves re-mapped), lucide-react 1,
  TypeScript 7, i18next/react-i18next/codemirror minors.

Workspace rename step 4
- `agenthub lanes` and `--lane` exit 1 with "renamed: use …"; client api.ts,
  the CLI and the MCP server call /api/workspaces; /api/lanes stays mounted.

Server mode (S3): members manage their own Claude Code config
- server/lib/cc-target.js resolves whose config a /cc-config request touches
  and gates the router by allowlist; /profiles* and /plugins/update-all stay
  admin-only. Member writes go through write-as, CLI calls through sudo as the
  member (never -y), reads through openSafe; cc_config_changed reaches only
  its owner. Not verified live on an S3 install yet.

Other
- Chat: an HTML file is an artifact only when written whole and not a project
  page (index.html, or under src/, public/, templates/, …).
- OpenAPI covers every /api route; openapi-coverage.test.js guards it.
- read-pool test keeps its event loop alive (its worker is unref'd).
- Docs: plan statuses, checklists, CLI.md, LANES.md, SERVER-MODE.md, API.md.
2026-10-26 14:22:41 +07:00

126 lines
4.4 KiB
JavaScript

/**
* @file Fails when an Express route under /api has no operation in the OpenAPI
* document. Builds the app with `createApp()`, walks every router layer with
* its mount prefix (recorded by wrapping `express.application.use` before the
* server loads), and checks each method + path against `createOpenApiSpec()`.
* Path parameters are compared by position only (`:token` and `{ref}` match),
* and the two alias mounts that the spec documents by their tag description
* (`/api/workspaces` = `/api/lanes`, `/api/ai-routers` = `/api/ai-proxies`)
* are allowlisted. It also fails on a spec operation no route answers.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before } = require("node:test");
const assert = require("node:assert/strict");
const path = require("path");
const os = require("os");
const fs = require("fs");
const TEST_DB = path.join(os.tmpdir(), `dashboard-openapi-cov-${Date.now()}-${process.pid}.db`);
process.env.DASHBOARD_DB_PATH = TEST_DB;
process.env.DASHBOARD_LIVENESS_PROBE = "0";
// Record each app.use mount path on the layer it creates — Express keeps only
// a compiled matcher, which cannot be turned back into the prefix.
const express = require("express");
const originalUse = express.application.use;
express.application.use = function (mount, ...rest) {
const out = originalUse.call(this, mount, ...rest);
if (typeof mount === "string" || Array.isArray(mount)) {
const stack = (this.router || this._router).stack;
stack[stack.length - 1].__mount = mount;
}
return out;
};
const { createApp } = require("../index");
const { createOpenApiSpec } = require("../openapi");
/** Mounts that repeat another, documented mount by design (see the tags). */
const ALIAS_PREFIXES = ["/api/workspaces", "/api/ai-routers"];
/** Spec paths served by middleware rather than a route (Swagger UI). */
const MIDDLEWARE_PATHS = ["get /api/docs"];
const METHODS = new Set(["get", "post", "put", "patch", "delete"]);
/** `:id`, `:id(\\d+)`, `{id}` all become `{}`; a trailing slash is dropped. */
function normalize(p) {
return (
p
.replace(/:[A-Za-z_]\w*(\([^)]*\))?\??/g, "{}")
.replace(/\{[^}]+\}/g, "{}")
.replace(/\/$/, "") || "/"
);
}
function collectRoutes(app) {
const out = new Set();
const walk = (stack, prefixes) => {
for (const layer of stack) {
if (layer.route) {
const routePaths = [].concat(layer.route.path);
for (const prefix of prefixes) {
for (const rp of routePaths) {
for (const m of Object.keys(layer.route.methods)) {
if (METHODS.has(m)) out.add(`${m} ${normalize(prefix + rp)}`);
}
}
}
} else if (layer.handle && Array.isArray(layer.handle.stack)) {
const mounts = layer.__mount === undefined ? [""] : [].concat(layer.__mount);
walk(
layer.handle.stack,
prefixes.flatMap((a) => mounts.map((b) => a + b))
);
}
}
};
walk((app.router || app._router).stack, [""]);
return [...out].filter((k) => k.split(" ")[1].startsWith("/api"));
}
describe("OpenAPI coverage", () => {
let routes;
let documented;
before(() => {
routes = collectRoutes(createApp());
documented = new Set();
for (const [p, ops] of Object.entries(createOpenApiSpec().paths)) {
for (const m of Object.keys(ops)) if (METHODS.has(m)) documented.add(`${m} ${normalize(p)}`);
}
});
it("finds the routes it is meant to check", () => {
assert.ok(routes.length > 300, `only ${routes.length} routes found — the walk is broken`);
assert.ok(routes.includes("get /api/lanes/{}"));
});
it("documents every registered /api route", () => {
const missing = routes.filter(
(k) =>
!documented.has(k) &&
!ALIAS_PREFIXES.some((a) => {
const p = k.split(" ")[1];
return p === a || p.startsWith(a + "/");
})
);
assert.deepEqual(missing, [], `routes missing from the OpenAPI spec:\n${missing.join("\n")}`);
});
it("documents no operation that no route answers", () => {
const have = new Set(routes);
const stale = [...documented].filter((k) => !have.has(k) && !MIDDLEWARE_PATHS.includes(k));
assert.deepEqual(stale, [], `spec operations with no route:\n${stale.join("\n")}`);
});
});
process.on("exit", () => {
for (const f of [TEST_DB, `${TEST_DB}-wal`, `${TEST_DB}-shm`]) {
try {
fs.unlinkSync(f);
} catch {
/* already gone */
}
}
});