mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-17 12:22:34 +03:00
fix(ci): mirror isLocalOnlyPath in the security-tier gate and rebaseline four merged-growth file caps
Two base-reds on release/v3.8.51 (#12581), both drained at the source. 1) check:openapi-security-tiers reported six CORRECTLY annotated routes as unprotected and demanded the removal of their x-loopback-only annotation — pushing the fix in the unsafe direction. The gate re-reads routeGuard.ts as text (it cannot import the module: routeGuard pulls the server runtime and the gate runs on plain node), but it only read the FIRST half of isLocalOnlyPath(): LOCAL_ONLY_API_PREFIXES.some(...) || LOCAL_ONLY_API_PATTERNS.some(...) so every route gated by a regex (/api/providers/volcengine-plan/connect/*) or by an imported constant (VNC_ROUTE_PREFIX, which the text parse turned into the literal string "VNC_ROUTE_PREFIX") looked open. Proven with isLocalOnlyPath() at runtime: all six return true; the control /api/providers/{id}/refresh stays false. New scripts/check/routeGuardConstants.mjs reads BOTH arrays, resolves imported identifiers by following the import, and THROWS on an unresolvable token instead of silently degrading it into a literal. Its array scanner is hand-rolled because regex literals carry the brackets and commas a \[([^\]]+)\] capture plus a naive comma split break on ([^/] and {1,3}). The reverse pass (missing-annotation warnings) now uses the same predicate. 2) check:file-size: four frozen files grew past their cap through merged PRs — chat.ts +10 (#12427/#12503 video-transcript redaction, derived from the post-guardrail payload at the single dispatch point) and stream.ts / accountFallback.ts / codex.ts +17 total (#12179 hot-path regex hoisting, bounded caches, quadratic-buffering fix). All cohesive at existing chokepoints; rebaselined with the rationale recorded in the baseline file. Refs #12581
This commit is contained in:
@@ -409,7 +409,7 @@
|
||||
"open-sse/executors/antigravity.ts": 1665,
|
||||
"open-sse/executors/base.ts": 1751,
|
||||
"open-sse/executors/chatgpt-web.ts": 5056,
|
||||
"open-sse/executors/codex.ts": 1499,
|
||||
"open-sse/executors/codex.ts": 1503,
|
||||
"open-sse/executors/cursor.ts": 1759,
|
||||
"open-sse/executors/muse-spark-web.ts": 1405,
|
||||
"open-sse/handlers/chatCore.ts": 5976,
|
||||
@@ -417,13 +417,13 @@
|
||||
"open-sse/handlers/search.ts": 1789,
|
||||
"open-sse/mcp-server/schemas/tools.ts": 1621,
|
||||
"open-sse/mcp-server/server.ts": 1572,
|
||||
"open-sse/services/accountFallback.ts": 2422,
|
||||
"open-sse/services/accountFallback.ts": 2429,
|
||||
"open-sse/services/adobeFireflyBrowserLogin.ts": 1401,
|
||||
"open-sse/services/combo.ts": 4023,
|
||||
"open-sse/translator/response/openai-responses.ts": 1466,
|
||||
"open-sse/utils/cursorAgentProtobuf.ts": 1547,
|
||||
"open-sse/utils/proxyFetch.ts": 1271,
|
||||
"open-sse/utils/stream.ts": 3072,
|
||||
"open-sse/utils/stream.ts": 3078,
|
||||
"open-sse/vendor/codex-chatgpt-web/adapters/chatgpt-web/browser-worker.ts": 4398,
|
||||
"open-sse/vendor/codex-chatgpt-web/bridge.ts": 1322,
|
||||
"src/app/(dashboard)/dashboard/HomePageClient.tsx": 1344,
|
||||
@@ -451,7 +451,7 @@
|
||||
"src/shared/components/RequestLoggerV2.tsx": 1718,
|
||||
"src/shared/constants/providers/apikey/gateways.ts": 1439,
|
||||
"src/shared/services/cliRuntime.ts": 1296,
|
||||
"src/sse/handlers/chat.ts": 2424,
|
||||
"src/sse/handlers/chat.ts": 2434,
|
||||
"src/sse/services/auth.ts": 3427,
|
||||
"tests/unit/account-fallback-service.test.ts": 2453,
|
||||
"tests/unit/provider-validation-specialty.test.ts": 4656
|
||||
@@ -629,5 +629,6 @@
|
||||
"_rebaseline_2026_06_30_v3842_release_chatgptweb_compression": "v3.8.42 cycle-close file-size reconciliation (DRIFT measured OK on each PR's base, stacked above frozen at the merge tip; fast-path PR->release/** does not run check:file-size). (1) open-sse/executors/chatgpt-web.ts 2870->3206 (+336 = #5531 portable SHA3-512 sentinel-PoW wiring with the native-vs-fallback digest path + #5536 GPT-5.5 Pro handoff branch; the pure Keccak-f[1600] fallback itself already lives in the separate leaf open-sse/utils/sha3-512.ts — the executor growth is the cohesive call-site/handoff logic, not extractable without hiding the sentinel chokepoint). (2) tests/unit/chatgpt-web.test.ts 2855->3159 (+304 = #5536 GPT-5.5 Pro handoff coverage; pair-file with its executor). (3) open-sse/services/compression/strategySelector.ts 997->1022 (+25 = #5527 T02 honest default-on pipeline inflation guard wiring at the existing finalizeStackedResult choke). All cohesive at existing chokepoints; covered by tests/unit/chatgpt-web-sha3-boringssl-5531.test.ts, chatgpt-web.test.ts (GPT-5.5 Pro), compression-pipeline-inflation-guard.test.ts.",
|
||||
"open-sse/executors/chatgpt-web.ts": "3241",
|
||||
"_rebaseline_2026_08_30_11771_vercel_gateway_passthrough": "PR #11771 adds passthroughModels: true (1 line) to the Vercel AI Gateway registry entry — no split available, single-line provider-config addition.",
|
||||
"_relax_velocity_2026_08_30": "127 frozen line caps and cap/testCap raised by 20% (velocity phase; see quality-baseline.json _policy)."
|
||||
"_relax_velocity_2026_08_30": "127 frozen line caps and cap/testCap raised by 20% (velocity phase; see quality-baseline.json _policy).",
|
||||
"_rebaseline_2026_09_03_basereds_v3851_merged_growth": "Base-red drain 2026-09-03 (release/v3.8.51): four frozen files grew past their cap through already-merged PRs, all cohesive additions at the existing chokepoint with no extraction that would not hide the choke. (1) src/sse/handlers/chat.ts 2424->2434 (+10 = #12427 video-transcript log/memory redaction and its #12503 re-anchor follow-up: the redaction entry must be derived from the POST-guardrail payload at the single dispatch point, so the threading cannot move out of the handler). (2) open-sse/utils/stream.ts 3072->3078, (3) open-sse/services/accountFallback.ts 2422->2429 and (4) open-sse/executors/codex.ts 1499->1503 (+17 total = #12179 perf: hot-path regexes hoisted to module scope, bounded token caches and the quadratic-buffering fix — module-level constants and guard clauses inside the existing hot loops). Covered by the suites those PRs shipped; measured with npm run check:file-size on the pure tip."
|
||||
}
|
||||
|
||||
@@ -9,35 +9,36 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import * as yaml from "js-yaml";
|
||||
import { isLocalOnlyDocPath, readRegexArray, readStringArray } from "./routeGuardConstants.mjs";
|
||||
|
||||
const ROOT = process.cwd();
|
||||
const OPENAPI_PATH = path.join(ROOT, "docs", "openapi.yaml");
|
||||
const ROUTE_GUARD_PATH = path.join(ROOT, "src", "server", "authz", "routeGuard.ts");
|
||||
|
||||
function parseStringArray(match) {
|
||||
if (!match) return [];
|
||||
// Strip line comments before splitting — array entries in routeGuard.ts often
|
||||
// carry inline `// T-XX:` annotations that would otherwise pollute the parsed tokens.
|
||||
return match[1]
|
||||
.replace(/\/\/[^\n]*/g, "")
|
||||
.split(",")
|
||||
.map((s) => s.trim().replace(/^["']|["']$/g, ""))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
const guardSrc = fs.readFileSync(ROUTE_GUARD_PATH, "utf-8");
|
||||
const LOCAL_ONLY_PREFIXES = parseStringArray(
|
||||
guardSrc.match(/export const LOCAL_ONLY_API_PREFIXES.*?=\s*\[([^\]]+)\]/s)
|
||||
);
|
||||
const ALWAYS_PROTECTED_PATHS = parseStringArray(
|
||||
guardSrc.match(/export const ALWAYS_PROTECTED_API_PATHS.*?=\s*\[([^\]]+)\]/s)
|
||||
);
|
||||
|
||||
// Both halves of isLocalOnlyPath(): the flat prefixes AND the regex patterns.
|
||||
// Reading only the prefixes reported regex-gated and imported-constant routes
|
||||
// as unprotected (see routeGuardConstants.mjs).
|
||||
let LOCAL_ONLY_PREFIXES;
|
||||
let LOCAL_ONLY_PATTERNS;
|
||||
let ALWAYS_PROTECTED_PATHS;
|
||||
try {
|
||||
LOCAL_ONLY_PREFIXES = readStringArray(guardSrc, "LOCAL_ONLY_API_PREFIXES", { root: ROOT });
|
||||
LOCAL_ONLY_PATTERNS = readRegexArray(guardSrc, "LOCAL_ONLY_API_PATTERNS");
|
||||
ALWAYS_PROTECTED_PATHS = readStringArray(guardSrc, "ALWAYS_PROTECTED_API_PATHS", { root: ROOT });
|
||||
} catch (err) {
|
||||
console.error(`[openapi-security-tiers] FAIL — ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (LOCAL_ONLY_PREFIXES.length === 0 || ALWAYS_PROTECTED_PATHS.length === 0) {
|
||||
console.error("[openapi-security-tiers] FAIL — could not parse routeGuard.ts constants");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const localOnlyGuards = { prefixes: LOCAL_ONLY_PREFIXES, patterns: LOCAL_ONLY_PATTERNS };
|
||||
|
||||
const raw = yaml.load(fs.readFileSync(OPENAPI_PATH, "utf-8"));
|
||||
const paths = raw.paths || {};
|
||||
|
||||
@@ -49,14 +50,11 @@ for (const [pathStr, methods] of Object.entries(paths)) {
|
||||
if (!["get", "post", "put", "patch", "delete"].includes(method) || !spec) continue;
|
||||
|
||||
if (spec["x-loopback-only"] === true) {
|
||||
const matchesPrefix = LOCAL_ONLY_PREFIXES.some((prefix) => {
|
||||
const norm = prefix.endsWith("/") ? prefix.slice(0, -1) : prefix;
|
||||
return pathStr === norm || pathStr.startsWith(norm + "/");
|
||||
});
|
||||
if (!matchesPrefix) {
|
||||
if (!isLocalOnlyDocPath(pathStr, localOnlyGuards)) {
|
||||
errors.push(
|
||||
`${method.toUpperCase()} ${pathStr}: has x-loopback-only but is NOT covered by ` +
|
||||
`LOCAL_ONLY_API_PREFIXES [${LOCAL_ONLY_PREFIXES.join(", ")}]`
|
||||
`LOCAL_ONLY_API_PREFIXES [${LOCAL_ONLY_PREFIXES.join(", ")}] ` +
|
||||
`nor by LOCAL_ONLY_API_PATTERNS [${LOCAL_ONLY_PATTERNS.join(", ")}]`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -87,16 +85,12 @@ for (const [pathStr, methods] of Object.entries(paths)) {
|
||||
const reverseWarnings = [];
|
||||
for (const [pathStr, methods] of Object.entries(paths)) {
|
||||
if (!methods || typeof methods !== "object") continue;
|
||||
const fallsUnderLocalOnly = LOCAL_ONLY_PREFIXES.some((prefix) => {
|
||||
const norm = prefix.endsWith("/") ? prefix.slice(0, -1) : prefix;
|
||||
return pathStr === norm || pathStr.startsWith(norm + "/");
|
||||
});
|
||||
if (!fallsUnderLocalOnly) continue;
|
||||
if (!isLocalOnlyDocPath(pathStr, localOnlyGuards)) continue;
|
||||
for (const [method, spec] of Object.entries(methods)) {
|
||||
if (!["get", "post", "put", "patch", "delete"].includes(method) || !spec) continue;
|
||||
if (spec["x-loopback-only"] !== true) {
|
||||
reverseWarnings.push(
|
||||
`${method.toUpperCase()} ${pathStr}: falls under LOCAL_ONLY_API_PREFIXES ` +
|
||||
`${method.toUpperCase()} ${pathStr}: is LOCAL_ONLY per routeGuard ` +
|
||||
`but is missing x-loopback-only: true annotation`
|
||||
);
|
||||
}
|
||||
|
||||
187
scripts/check/routeGuardConstants.mjs
Normal file
187
scripts/check/routeGuardConstants.mjs
Normal file
@@ -0,0 +1,187 @@
|
||||
// scripts/check/routeGuardConstants.mjs
|
||||
// Shared reader for the compile-time constants in src/server/authz/routeGuard.ts.
|
||||
//
|
||||
// The security-tier gate cannot import the module itself (routeGuard pulls the
|
||||
// server runtime — runtimeSettings → localDb → ioredis — and the gate runs on
|
||||
// plain `node`), so it re-reads the constants from source. That text parse has
|
||||
// to mirror `isLocalOnlyPath()` exactly, which means BOTH halves of the
|
||||
// predicate:
|
||||
//
|
||||
// LOCAL_ONLY_API_PREFIXES.some(...) || LOCAL_ONLY_API_PATTERNS.some(...)
|
||||
//
|
||||
// Reading only the prefix array made every route that is gated by a regex
|
||||
// (e.g. /api/providers/volcengine-plan/connect/*) or by an imported constant
|
||||
// (VNC_ROUTE_PREFIX) look unprotected, and the gate then demanded the removal
|
||||
// of a CORRECT `x-loopback-only` annotation — a false positive that pushes the
|
||||
// fix in the unsafe direction. Unresolvable tokens now throw instead of
|
||||
// silently degrading into a literal.
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
/**
|
||||
* Strip `//` line comments without eating regex literals: a `//` preceded by a
|
||||
* backslash (`\/\/`) or a colon (`https://`) is content, not a comment.
|
||||
*/
|
||||
export function stripLineComments(text) {
|
||||
return text
|
||||
.split("\n")
|
||||
.map((line) => {
|
||||
for (let i = 0; i < line.length - 1; i++) {
|
||||
if (line[i] !== "/" || line[i + 1] !== "/") continue;
|
||||
const prev = i > 0 ? line[i - 1] : "";
|
||||
if (prev === "\\" || prev === ":") continue;
|
||||
return line.slice(0, i);
|
||||
}
|
||||
return line;
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Entries of the array literal assigned to `constName`.
|
||||
*
|
||||
* Hand-rolled instead of a `\[([^\]]+)\]` capture because the pattern arrays
|
||||
* hold regex literals whose character classes (`[^/]`) contain the very
|
||||
* brackets and commas a naive capture/split would break on.
|
||||
*/
|
||||
export function parseArrayTokens(src, constName) {
|
||||
const decl = src.search(new RegExp(`export const ${constName}\\b`));
|
||||
if (decl < 0) return [];
|
||||
const open = src.indexOf("[", decl);
|
||||
if (open < 0) return [];
|
||||
|
||||
let depth = 0;
|
||||
let end = -1;
|
||||
for (let i = open; i < src.length; i++) {
|
||||
const c = src[i];
|
||||
if (c === "[") depth++;
|
||||
else if (c === "]") {
|
||||
depth--;
|
||||
if (depth === 0) {
|
||||
end = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (end < 0) return [];
|
||||
|
||||
const body = stripLineComments(src.slice(open + 1, end));
|
||||
const tokens = [];
|
||||
let current = "";
|
||||
let nesting = 0;
|
||||
for (const c of body) {
|
||||
if (c === "," && nesting === 0) {
|
||||
tokens.push(current);
|
||||
current = "";
|
||||
continue;
|
||||
}
|
||||
if (c === "[" || c === "(" || c === "{") nesting++;
|
||||
else if (c === "]" || c === ")" || c === "}") nesting--;
|
||||
current += c;
|
||||
}
|
||||
tokens.push(current);
|
||||
return tokens.map((t) => t.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
/** True for a token that is already a quoted string literal. */
|
||||
function isStringLiteral(token) {
|
||||
return /^["'`]/.test(token);
|
||||
}
|
||||
|
||||
function unquote(token) {
|
||||
return token.replace(/^["'`]|["'`]$/g, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a bare identifier used inside a routeGuard array (e.g. VNC_ROUTE_PREFIX)
|
||||
* to its string value, following a local `const` or a named import. Returns null
|
||||
* when it cannot be resolved — callers must treat that as fatal.
|
||||
*/
|
||||
export function resolveIdentifier(name, { guardSrc, root, readFile = readFileIfExists } = {}) {
|
||||
const localDef = guardSrc.match(
|
||||
new RegExp(`(?:export\\s+)?const\\s+${name}\\b[^=]*=\\s*["'\`]([^"'\`]+)["'\`]`)
|
||||
);
|
||||
if (localDef) return localDef[1];
|
||||
|
||||
const imported = guardSrc.match(
|
||||
new RegExp(`import\\s*\\{[^}]*\\b${name}\\b[^}]*\\}\\s*from\\s*["']([^"']+)["']`, "s")
|
||||
);
|
||||
if (!imported) return null;
|
||||
|
||||
for (const candidate of moduleCandidates(imported[1], root)) {
|
||||
const src = readFile(candidate);
|
||||
if (!src) continue;
|
||||
const def = src.match(
|
||||
new RegExp(`export\\s+const\\s+${name}\\b[^=]*=\\s*["'\`]([^"'\`]+)["'\`]`)
|
||||
);
|
||||
if (def) return def[1];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Candidate on-disk paths for an import specifier (`@/x` → src/x, plus relatives). */
|
||||
export function moduleCandidates(spec, root) {
|
||||
const base = spec.startsWith("@/")
|
||||
? path.join(root, "src", spec.slice(2))
|
||||
: spec.startsWith(".")
|
||||
? path.join(root, "src", "server", "authz", spec)
|
||||
: null;
|
||||
if (!base) return [];
|
||||
return [`${base}.ts`, `${base}.tsx`, path.join(base, "index.ts")];
|
||||
}
|
||||
|
||||
function readFileIfExists(p) {
|
||||
try {
|
||||
return fs.readFileSync(p, "utf-8");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** String entries of a prefix array, with identifiers resolved. Throws if any cannot be. */
|
||||
export function readStringArray(src, constName, opts = {}) {
|
||||
const tokens = parseArrayTokens(src, constName);
|
||||
return tokens.map((token) => {
|
||||
if (isStringLiteral(token)) return unquote(token);
|
||||
const resolved = resolveIdentifier(token, { guardSrc: src, ...opts });
|
||||
if (resolved === null) {
|
||||
throw new Error(
|
||||
`[routeGuardConstants] could not resolve \`${token}\` used in ${constName}. ` +
|
||||
`Add a resolvable \`export const ${token} = "…"\` or inline the literal — ` +
|
||||
`leaving it unresolved would make the gate report protected routes as open.`
|
||||
);
|
||||
}
|
||||
return resolved;
|
||||
});
|
||||
}
|
||||
|
||||
/** RegExp entries of a pattern array (regex literals only). */
|
||||
export function readRegexArray(src, constName) {
|
||||
return parseArrayTokens(src, constName)
|
||||
.filter((token) => token.startsWith("/"))
|
||||
.map((token) => {
|
||||
const body = token.match(/^\/(.*)\/([a-z]*)$/s);
|
||||
if (!body)
|
||||
throw new Error(`[routeGuardConstants] unparsable regex in ${constName}: ${token}`);
|
||||
return new RegExp(body[1], body[2]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenAPI templates use `{param}`; the runtime sees a concrete segment. Swap the
|
||||
* placeholders for a segment without slashes so `[^/]+`-style patterns match.
|
||||
*/
|
||||
export function concreteFromTemplate(pathStr) {
|
||||
return pathStr.replace(/\{[^}]*\}/g, "_param_");
|
||||
}
|
||||
|
||||
/** Mirror of routeGuard.isLocalOnlyPath() for a documented (templated) path. */
|
||||
export function isLocalOnlyDocPath(pathStr, { prefixes = [], patterns = [] } = {}) {
|
||||
const concrete = concreteFromTemplate(pathStr);
|
||||
const underPrefix = prefixes.some((prefix) => {
|
||||
const norm = prefix.endsWith("/") ? prefix.slice(0, -1) : prefix;
|
||||
return pathStr === norm || pathStr.startsWith(`${norm}/`);
|
||||
});
|
||||
return underPrefix || patterns.some((re) => re.test(concrete) || re.test(pathStr));
|
||||
}
|
||||
100
tests/unit/build/route-guard-constants.test.ts
Normal file
100
tests/unit/build/route-guard-constants.test.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { test } from "node:test";
|
||||
|
||||
import {
|
||||
concreteFromTemplate,
|
||||
isLocalOnlyDocPath,
|
||||
parseArrayTokens,
|
||||
readRegexArray,
|
||||
readStringArray,
|
||||
resolveIdentifier,
|
||||
stripLineComments,
|
||||
} from "../../../scripts/check/routeGuardConstants.mjs";
|
||||
|
||||
const ROOT = path.resolve(import.meta.dirname, "..", "..", "..");
|
||||
const GUARD_SRC = fs.readFileSync(
|
||||
path.join(ROOT, "src", "server", "authz", "routeGuard.ts"),
|
||||
"utf-8"
|
||||
);
|
||||
|
||||
test("parseArrayTokens survives regex literals whose char classes hold ] and ,", () => {
|
||||
const src = [
|
||||
"export const PATTERNS: ReadonlyArray<RegExp> = [",
|
||||
" /^\\/api\\/providers\\/[^/]+\\/login\\/?$/, // inline note",
|
||||
" /^\\/api\\/x\\/[a-z]{1,3}$/,",
|
||||
"];",
|
||||
].join("\n");
|
||||
assert.deepEqual(parseArrayTokens(src, "PATTERNS"), [
|
||||
"/^\\/api\\/providers\\/[^/]+\\/login\\/?$/",
|
||||
"/^\\/api\\/x\\/[a-z]{1,3}$/",
|
||||
]);
|
||||
});
|
||||
|
||||
test("stripLineComments keeps escaped slashes and URLs, drops real comments", () => {
|
||||
assert.equal(stripLineComments("/a\\/\\/b/, // note"), "/a\\/\\/b/, ");
|
||||
assert.equal(stripLineComments('"https://x", // note'), '"https://x", ');
|
||||
assert.equal(stripLineComments("// whole line"), "");
|
||||
});
|
||||
|
||||
test("resolveIdentifier follows a named import to its exported literal", () => {
|
||||
const guardSrc = 'import { VNC_ROUTE_PREFIX } from "@/lib/vncSession/manifest";';
|
||||
const readFile = (p: string) =>
|
||||
p.endsWith(path.join("src", "lib", "vncSession", "manifest.ts"))
|
||||
? 'export const VNC_ROUTE_PREFIX = "/api/vnc-session";'
|
||||
: null;
|
||||
assert.equal(
|
||||
resolveIdentifier("VNC_ROUTE_PREFIX", { guardSrc, root: ROOT, readFile }),
|
||||
"/api/vnc-session"
|
||||
);
|
||||
assert.equal(resolveIdentifier("MISSING", { guardSrc, root: ROOT, readFile }), null);
|
||||
});
|
||||
|
||||
test("readStringArray throws instead of degrading an unresolvable token into a literal", () => {
|
||||
const src = 'export const P: ReadonlyArray<string> = ["/api/a", SOME_CONST];';
|
||||
assert.throws(
|
||||
() => readStringArray(src, "P", { root: ROOT, readFile: () => null }),
|
||||
/SOME_CONST/
|
||||
);
|
||||
});
|
||||
|
||||
test("concreteFromTemplate replaces OpenAPI placeholders with a slash-free segment", () => {
|
||||
assert.equal(concreteFromTemplate("/api/x/{id}/y"), "/api/x/_param_/y");
|
||||
});
|
||||
|
||||
test("isLocalOnlyDocPath honours prefixes AND patterns, and stays closed otherwise", () => {
|
||||
const guards = {
|
||||
prefixes: ["/api/mcp/", "/api/vnc-session"],
|
||||
patterns: [/^\/api\/providers\/[^/]+\/login\/?$/],
|
||||
};
|
||||
assert.equal(isLocalOnlyDocPath("/api/vnc-session/{params}", guards), true);
|
||||
assert.equal(isLocalOnlyDocPath("/api/mcp/tools", guards), true);
|
||||
assert.equal(isLocalOnlyDocPath("/api/providers/{provider}/login", guards), true);
|
||||
assert.equal(isLocalOnlyDocPath("/api/providers/{provider}/refresh", guards), false);
|
||||
assert.equal(isLocalOnlyDocPath("/v1/chat/completions", guards), false);
|
||||
});
|
||||
|
||||
// Regression guard for the real file: reading only LOCAL_ONLY_API_PREFIXES made
|
||||
// the gate report the VNC (imported constant) and volcengine-plan (regex) routes
|
||||
// as unprotected, and demand the removal of a correct x-loopback-only annotation.
|
||||
test("the real routeGuard constants cover the imported-constant and regex-gated routes", () => {
|
||||
const guards = {
|
||||
prefixes: readStringArray(GUARD_SRC, "LOCAL_ONLY_API_PREFIXES", { root: ROOT }),
|
||||
patterns: readRegexArray(GUARD_SRC, "LOCAL_ONLY_API_PATTERNS"),
|
||||
};
|
||||
assert.ok(guards.prefixes.includes("/api/vnc-session"), "VNC_ROUTE_PREFIX must resolve");
|
||||
assert.ok(guards.patterns.length > 0, "pattern list must be parsed");
|
||||
|
||||
for (const documented of [
|
||||
"/api/vnc-session",
|
||||
"/api/vnc-session/{params}",
|
||||
"/api/providers/volcengine-plan/connect/{sessionId}/status",
|
||||
"/api/providers/volcengine-plan/connect/{sessionId}/resend",
|
||||
]) {
|
||||
assert.equal(isLocalOnlyDocPath(documented, guards), true, `${documented} must be LOCAL_ONLY`);
|
||||
}
|
||||
|
||||
// Control: a deliberately remote-reachable provider route stays open.
|
||||
assert.equal(isLocalOnlyDocPath("/api/providers/{id}/refresh", guards), false);
|
||||
});
|
||||
Reference in New Issue
Block a user