diff --git a/back/api/script.ts b/back/api/script.ts index 9be941d6..20435233 100644 --- a/back/api/script.ts +++ b/back/api/script.ts @@ -1,4 +1,11 @@ -import { fileExist, readDirs, readDir, rmPath, IFile } from '../config/util'; +import { + fileExist, + isPathInside, + readDirs, + readDir, + rmPath, + IFile, +} from '../config/util'; import { Router, Request, Response, NextFunction } from 'express'; import { Container } from 'typedi'; import { Logger } from 'winston'; @@ -14,7 +21,9 @@ const route = Router(); function isPathAllowed(targetPath: string): boolean { const resolved = path.resolve(targetPath); - return config.writePathList.some((x) => resolved.startsWith(x)); + return config.writePathList.some((rootPath) => + isPathInside(rootPath, resolved), + ); } const storage = multer.diskStorage({ @@ -158,7 +167,11 @@ export default (app: Router) => { if (!path.startsWith('/')) { path = join(config.scriptPath, path); } - if (config.writePathList.every((x) => !path.startsWith(x))) { + if ( + config.writePathList.every( + (rootPath) => !isPathInside(rootPath, path), + ) + ) { return res.send({ code: 403, message: t('暂无权限'), diff --git a/back/config/util.ts b/back/config/util.ts index f22f7e01..bec58867 100644 --- a/back/config/util.ts +++ b/back/config/util.ts @@ -263,13 +263,26 @@ export async function readDir( baseDir: string = '', blacklist: string[] = [], ): Promise { + const absoluteBaseDir = path.resolve(baseDir); const absoluteDir = path.resolve(baseDir, dir); - if (!absoluteDir.startsWith(path.resolve(baseDir))) { + if (!isPathInside(absoluteBaseDir, absoluteDir)) { return []; } - const relativePath = path.relative(baseDir, absoluteDir); try { + const [realBaseDir, realDirectory] = await Promise.all([ + fs.realpath(absoluteBaseDir), + fs.realpath(absoluteDir), + ]); + const directoryStat = await fs.lstat(absoluteDir); + if ( + !isPathInside(realBaseDir, realDirectory) || + !directoryStat.isDirectory() || + directoryStat.isSymbolicLink() + ) { + return []; + } + const relativePath = path.relative(absoluteBaseDir, absoluteDir); const files = await fs.readdir(absoluteDir); const result: IFile[] = []; @@ -312,6 +325,19 @@ export async function readDir( } } +export function isPathInside(rootPath: string, targetPath: string): boolean { + const relative = path.relative( + path.resolve(rootPath), + path.resolve(targetPath), + ); + return ( + relative === '' || + (relative !== '..' && + !relative.startsWith(`..${path.sep}`) && + !path.isAbsolute(relative)) + ); +} + export async function promiseExec(command: string): Promise { try { const { stderr, stdout } = await promisify(exec)(command, { diff --git a/back/services/script.ts b/back/services/script.ts index 52921c6c..251e5e9c 100644 --- a/back/services/script.ts +++ b/back/services/script.ts @@ -6,7 +6,13 @@ import CronService from './cron'; import ScheduleService, { TaskCallbacks } from './schedule'; import config from '../config'; import { TASK_COMMAND } from '../config/const'; -import { getFileContentByName, getPid, killTask, rmPath } from '../config/util'; +import { + getFileContentByName, + getPid, + isPathInside, + killTask, + rmPath, +} from '../config/util'; import taskLimit from '../shared/pLimit'; @Service() @@ -66,7 +72,7 @@ export default class ScriptService { public checkFilePath(filePath: string, fileName: string) { const finalPath = path.resolve(config.scriptPath, filePath, fileName); - return finalPath.startsWith(config.scriptPath) ? finalPath : ''; + return isPathInside(config.scriptPath, finalPath) ? finalPath : ''; } public async getFile(filePath: string, fileName: string) { diff --git a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md index 69bd7c26..896618ae 100644 --- a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md +++ b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md @@ -11,6 +11,25 @@ 最新增量证据(2026-08-20): +- D-382/ADR-0475(已接受):扩展 3.0 首发前的 2.x HTTP 兼容基线,使用真实 loopback HTTP、生产 Express middleware、System/ + Script/Open Router 与 Celebrate validator,锁定 System config/四类 mutation/reload/notify、Script list/detail/create/rename/run、 + Open app CRUD/reset-secret/token issuance,以及面板/Open token、scope、expiration、路径大小写、400/401/500 envelope。测试以 + 确定性 service/store 替代副作用边界,不启动 master、scheduler、gRPC、Keyv SQLite 或数据库。兼容不冻结安全缺陷:新增 + separator-aware `isPathInside`,Script API/service 不再用字符串前缀接受 `scripts-sibling`;`readDir` 再以 realpath containment + 和 target `lstat` 拒绝直接目录 symlink 越界,同时保持既有拒绝 envelope。该收紧不宣称 legacy API 已成为完整 filesystem + capability sandbox,也不把 2.x Open scope 提升为 3.0 Policy authority。GitNexus 对三个被改 symbol 均为 LOW;Express loader + 为 HIGH(29 个累计影响、22 个直接调用者),本切片明确不修改。D-382 聚焦 `10/10`,与 D-378 `18/18`、D-381 `2/2` + 合并为 legacy HTTP 兼容门 `30/30`;backend 全量 `1,535 total / 1,533 pass / 2 conditional skip / 0 fail`,`pnpm build:back` + 通过,18-package clean build/逐包测试单次退出 0。package boundary、Cluster dependency、Edge import、Service Bridge import、 + Cluster/Worker deployment、Console 与 Console distribution 八项审计全部 compatible/passed;workspace 仍为 18 packages、 + `singleSourcePackages=[]`、`shallowSourcePackages=[]`,Local Owner 为 `113 source / 112 nested / 1 root binary entry`。14 档 Local + artifact audit 全部 compatible;基础 Edge/Standalone 保持 `2,598,669 / 2,598,747` bytes、316 files、57 loaded modules, + Adopted 为 `2,817,964 / 2,818,087` bytes、336 files、58 loaded modules,Application+AI 为 + `4,501,822 / 4,501,954` bytes、511 files、141 loaded modules,MCP 为 `7,324,601 / 7,324,709` bytes、802 files、 + 227 loaded modules。生产修复仍在 legacy backend,测试 harness 不进入制品,没有新增 package、production dependency、binary、 + daemon、listener、timer、数据库连接或部署对象。本阶段不改变 PostgreSQL schema、ACL、repository、role、Pool、连接或 failover + 语义,因此不重跑且不重新占有 HA 证明。D-383 应执行真实 2.x SQLite 数据目录升级、Primary 双态和目标实例 rollback rehearsal; + OpenRC live actor 仍待镜像基础设施恢复后补跑。 - D-381/ADR-0474(已接受;OpenRC live actor 待镜像基础设施恢复后补跑):把 service-manager adopted rollback 从仅证明 init/process 的 `legacy_running` 推进到有界、可重放的 2.x core readiness。新增显式 Owner 私有命令 `cutover-legacy-readiness-probe`,绑定 exact cutover/profile/instance/generation、activation、当前 head、legacy-running source diff --git a/docs/adr/ADR-0475-legacy-system-script-open-api-compatibility.md b/docs/adr/ADR-0475-legacy-system-script-open-api-compatibility.md new file mode 100644 index 00000000..3d138a90 --- /dev/null +++ b/docs/adr/ADR-0475-legacy-system-script-open-api-compatibility.md @@ -0,0 +1,107 @@ +# ADR-0475:Legacy System、Script 与 Open API 兼容基线 + +- 状态:Accepted +- 日期:2026-08-20 +- 关联 RFC:QL-RFC-0001 D-378、D-381、D-382 +- 关联 ADR:ADR-0046、ADR-0471、ADR-0474 + +## 背景 + +ADR-0471 已锁定 Cron/Subscription 的核心执行 API,ADR-0474 已证明 adopted rollback 后正确 2.x 版本的本机 HTTP core +能够完成初始化。但 QingLong 2.x 部署用户仍直接依赖 System 配置、Script 文件与运行接口、Open 应用管理/token,以及面板和 +Open 调用的认证、scope 与错误 envelope。缺少这些契约时,`legacy_ready` 只能证明一个极小健康点,不能支撑 3.0 升级前的 +兼容评估。 + +兼容也不能意味着冻结已知不安全行为。现行 Script 目录检查使用字符串前缀,`scripts-sibling` 这类同前缀兄弟目录可能被当成 +脚本根目录内部;目录列表还可能经直接符号链接离开脚本根。低配路由设备和集群节点都需要相同的失败关闭边界,且修复不能引入 +新的 package、依赖或常驻资源。 + +编辑前 GitNexus upstream impact 显示:`readDir`、`isPathAllowed` 与 `ScriptService.checkFilePath` 均为 LOW,分别只有有界直接 +调用者且没有已识别 execution flow;`back/loaders/express.ts` 为 HIGH,累计影响 29 个 symbol、22 个直接调用者。实现因此不修改 +HIGH 风险的 Express 装配,只通过测试注入选择性生产 Router 和确定性 store 来执行原生产中间件。 + +## 决策 + +### 1. 用真实 HTTP 边界锁定现行契约 + +兼容门启动真实 loopback HTTP server,执行生产 `back/loaders/express.ts` 中间件以及生产 System、Script、Open Router 和 +Celebrate validator。测试只替换 Router 之外的 service/store 副作用边界,不启动 master、scheduler、gRPC、Keyv SQLite 或完整 +数据库,因此既验证路由、认证、校验和 envelope,又保持一次性、确定性和低资源成本。 + +锁定的 2.x 契约包括: + +- System config 读取,日志清理频率、Cron 并发、依赖代理与 Python 镜像更新,reload、notify 及非法 body 的 dispatch 顺序; +- Script 根目录列表、detail、create、rename、run、越界拒绝及非法 run body; +- Open app list/create/update/delete/reset-secret 与公开 token issuance; +- 面板缺失/未登记 token,Open scope 允许/拒绝、过期 token、路径大小写、Celebrate 400 和通用 500 envelope。 + +现行受保护的成功响应继续使用 HTTP 200 与 `{code:200}`;既有 Script 路径授权拒绝继续返回 HTTP 200、`{code:403}`,避免在 +3.0 孵化阶段静默破坏客户端。认证与中间件错误的既有 HTTP 状态和 JSON envelope 由测试精确锁定。 + +### 2. 路径 containment 必须按路径段判断 + +新增共享 `isPathInside(rootPath, targetPath)`,使用 `path.resolve`、`path.relative`、平台分隔符和 absolute 检查判断 lexical +containment。Script API 写入根检查和 `ScriptService.checkFilePath` 复用该函数,不再用 `startsWith` 接受同前缀兄弟目录。 + +`readDir` 在读取前同时执行: + +- lexical containment; +- base/target `realpath` containment; +- target `lstat` 必须是非符号链接目录。 + +不满足任一条件时保持兼容地返回空列表。这关闭本阶段已证明的同前缀和直接目录符号链接越界,但不把 legacy Script API 宣称为 +完整 capability filesystem sandbox;任意层级写入、TOCTOU 与 OS 权限隔离仍必须由 3.0 capability authority 和执行器边界解决。 + +### 3. Legacy Open scope 不升级为 3.0 Policy + +`/open/*` 继续验证 2.x app token、expiration 与首段 scope,作为回滚兼容合同。它不获得 Project、Policy version、Approval、 +Action digest 或 durable authorization fact,也不能作为 3.0 管理/执行 API 的授权来源。3.0 的新 API 继续使用 RFC 已定义的 +Identity、Policy、Approval 与 capability 边界。 + +### 4. 保持低配与集群部署闭包不变 + +生产变更只位于现有 legacy backend 的三个文件;测试 harness 不进入发布制品。不新增 workspace package、production dependency、 +binary、daemon、listener、timer、watcher、queue、cache、数据库连接、容器或 Kubernetes workload。Edge/Standalone 的基础运行闭包 +和 Cluster 的独立控制面边界均保持不变。 + +## 被否决方案 + +1. **只 grep 路由或快照源码**:不能证明真实 middleware 顺序、Celebrate 400、认证与响应 envelope,拒绝。 +2. **测试时启动完整 master 与数据库图**:引入无关 Keyv/SQLite、scheduler 和服务生命周期,导致兼容门不确定且资源过重,拒绝。 +3. **为测试可见性重构 `back/loaders/express.ts`**:GitNexus 返回 HIGH,且本阶段无需承担 22 个直接调用者的行为风险,拒绝。 +4. **保留字符串前缀和符号链接越界以追求兼容**:这是安全缺陷而非受支持合同,拒绝。 +5. **把 2.x Open scope 映射为 3.0 Policy**:两者缺少相同 subject、version、resource 与 durable fact 语义,拒绝。 +6. **拆出新的兼容 package**:测试和三个 legacy 修复没有独立交付、依赖或生命周期理由,拒绝。 + +## 升级与回退 + +本阶段没有 schema 或持久数据迁移。升级后,同前缀兄弟目录与直接目录符号链接不再能通过 Script 列表访问;这是失败关闭的安全 +收紧。若旧部署依赖这种越界布局,应把脚本移动到真实 script root,而不是恢复不安全检查。 + +回退代码不会破坏已有数据,但会重新暴露越界读取风险,因此不建议把该安全修复单独回退。2.x Open token 与 envelope 仍保持现行 +兼容;3.0 新 API 不接受它们作为 Policy authority。 + +## 验收证据 + +- D-382 聚焦门 `10/10`;与 D-378 Cron/Subscription `18/18`、D-381 `/api/system` readiness `2/2` 合并为 legacy HTTP 兼容门 + `30/30`。 +- backend 全量 `1,535 total / 1,533 pass / 2 conditional skip / 0 fail`;`pnpm build:back` 通过。 +- 18-package clean build 与逐包测试单次退出 0;package boundary、Cluster dependency、Edge import、Service Bridge import、 + Cluster/Worker deployment、Console 与 Console distribution 八项审计全部 compatible/passed。workspace 仍为 18 packages, + `singleSourcePackages=[]`、`shallowSourcePackages=[]`,Local Owner 为 `113 source / 112 nested / 1 root binary entry`。 +- 14 档 Local artifact audit 全部 compatible。基础 Edge/Standalone 为 `2,598,669 / 2,598,747` bytes、316 files、57 loaded + modules;Adopted 为 `2,817,964 / 2,818,087` bytes、336 files、58 loaded modules;Application+AI 为 + `4,501,822 / 4,501,954` bytes、511 files、141 loaded modules;MCP 为 `7,324,601 / 7,324,709` bytes、802 files、 + 227 loaded modules。 +- 本阶段不修改 SQL、migration、PostgreSQL ACL/repository/role/Pool、连接或 failover 语义,因此不重跑且不重新占有 + PostgreSQL HA 证明。 + +## 未完成 + +- 真实 2.x SQLite 数据目录升级与回退演练; +- Primary 双态和真实目标实例 rollback rehearsal; +- 更广的 Config、Environment、Dependency 与日志 API 兼容矩阵; +- 任意层级 Script 写入的 capability filesystem 隔离与 TOCTOU 防护; +- 固定物理 Edge 与待镜像基础设施恢复后的 OpenRC live actor。 + +本 ADR 关闭 System、Script、Open 与认证/错误 envelope 的第二批 2.x HTTP 兼容基线,不代表 QingLong 3.0 升级/回退 Gate 已全部完成。 diff --git a/docs/adr/README.md b/docs/adr/README.md index 2830b637..628ad5bf 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -478,6 +478,7 @@ | [ADR-0472](./ADR-0472-service-manager-legacy-rollback-preparation.md) | Service Manager Legacy Rollback Preparation | Accepted | | [ADR-0473](./ADR-0473-service-manager-legacy-rollback-commit.md) | Service Manager Legacy Rollback Commit | Accepted(OpenRC live actor 待补) | | [ADR-0474](./ADR-0474-bounded-legacy-core-readiness-proof.md) | 有界 Legacy Core Readiness Proof | Accepted(OpenRC live actor 待补) | +| [ADR-0475](./ADR-0475-legacy-system-script-open-api-compatibility.md) | Legacy System、Script 与 Open API 兼容基线 | Accepted | ## 规则 diff --git a/test/back/ql3LegacySystemScriptOpenCompatibility.test.cjs b/test/back/ql3LegacySystemScriptOpenCompatibility.test.cjs new file mode 100644 index 00000000..05a1c658 --- /dev/null +++ b/test/back/ql3LegacySystemScriptOpenCompatibility.test.cjs @@ -0,0 +1,688 @@ +require('ts-node/register/transpile-only'); +require('reflect-metadata'); + +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const http = require('node:http'); +const os = require('node:os'); +const path = require('node:path'); +const { after, before, test } = require('node:test'); +const express = require('express'); +const jwt = require('jsonwebtoken'); +const { Container } = require('typedi'); + +const testRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'ql3-legacy-http-compatibility-'), +); +const dataRoot = path.join(testRoot, 'data'); +process.env.QL_DATA_DIR = dataRoot; +process.env.JWT_SECRET = 'ql3-legacy-http-compatibility-secret'; +for (const directory of [ + 'bak', + 'config', + 'db', + 'log', + 'scripts', + 'scripts-sibling', + 'upload', +]) { + fs.mkdirSync(path.join(dataRoot, directory), { recursive: true }); +} +fs.writeFileSync(path.join(dataRoot, 'scripts', 'existing.js'), 'existing\n'); +fs.mkdirSync(path.join(dataRoot, 'scripts', 'jobs'), { recursive: true }); +fs.writeFileSync( + path.join(dataRoot, 'scripts-sibling', 'outside.js'), + 'must not be listed\n', +); +fs.symlinkSync( + path.join(dataRoot, 'scripts-sibling'), + path.join(dataRoot, 'scripts', 'outside-link'), + 'dir', +); + +const shareStore = { + async getApps() { + return undefined; + }, + async getAuthInfo() { + return undefined; + }, + async getLang() { + return undefined; + }, + async setLang() {}, + async updateApps() {}, + async updateAuthInfo() {}, +}; +const sharedStoreModulePath = require.resolve('../../back/shared/store'); +require.cache[sharedStoreModulePath] = { + id: sharedStoreModulePath, + filename: sharedStoreModulePath, + loaded: true, + exports: { shareStore }, + children: [], + paths: [], +}; + +const config = require('../../back/config').default; +const OpenService = require('../../back/services/open').default; +const ScriptService = require('../../back/services/script').default; +const SystemService = require('../../back/services/system').default; +const UserService = require('../../back/services/user').default; +const registerOpenRoutes = require('../../back/api/open').default; +const registerScriptRoutes = require('../../back/api/script').default; +const registerSystemRoutes = require('../../back/api/system').default; +const { isPathInside } = require('../../back/config/util'); +const apiIndexModulePath = require.resolve('../../back/api'); +require.cache[apiIndexModulePath] = { + id: apiIndexModulePath, + filename: apiIndexModulePath, + loaded: true, + exports: { + __esModule: true, + default() { + const api = express.Router(); + registerScriptRoutes(api); + registerOpenRoutes(api); + registerSystemRoutes(api); + return api; + }, + }, + children: [], + paths: [], +}; +const expressLoader = require('../../back/loaders/express').default; + +const calls = []; +const panelToken = jwt.sign({ data: 'panel-session' }, config.jwt.secret, { + algorithm: 'HS384', + expiresIn: '10m', +}); +const foreignPanelToken = jwt.sign( + { data: 'foreign-panel-session' }, + config.jwt.secret, + { algorithm: 'HS384', expiresIn: '10m' }, +); +const nowSeconds = Math.round(Date.now() / 1000); +const authInfo = { + username: 'operator', + password: 'changed', + token: panelToken, + tokens: { desktop: panelToken }, +}; +const apps = [ + { + name: 'script-client', + scopes: ['scripts'], + tokens: [{ value: 'open-script-token', expiration: nowSeconds + 3_600 }], + }, + { + name: 'system-client', + scopes: ['system'], + tokens: [{ value: 'open-system-token', expiration: nowSeconds + 3_600 }], + }, + { + name: 'expired-client', + scopes: ['scripts'], + tokens: [{ value: 'expired-open-token', expiration: nowSeconds - 1 }], + }, +]; + +let origin; +let server; +let systemFailure; + +function record(domain, operation, args, result) { + calls.push({ domain, operation, args }); + if (result instanceof Error) return Promise.reject(result); + return Promise.resolve(result); +} + +const logger = { + debug() {}, + error() {}, + info() {}, + warn() {}, +}; + +const systemService = { + getSystemConfig: (...args) => + record( + 'system', + 'config', + args, + systemFailure || { + id: 1, + type: 'systemConfig', + info: { timezone: 'Asia/Shanghai', cronConcurrency: 2 }, + }, + ), + updateLogRemoveFrequency: (...args) => + record('system', 'log-remove-frequency', args, { + code: 200, + data: args[0], + }), + updateCronConcurrency: (...args) => + record('system', 'cron-concurrency', args, { + code: 200, + data: args[0], + }), + updateDependenceProxy: (...args) => + record('system', 'dependence-proxy', args, { + code: 200, + data: args[0], + }), + updatePythonMirror: (...args) => + record('system', 'python-mirror', args, { + code: 200, + data: args[0], + }), + checkUpdate: (...args) => + record('system', 'update-check', args, { + code: 200, + data: { hasNewVersion: false, lastVersion: '2.21.0' }, + }), + updateSystem: (...args) => record('system', 'update', args, { code: 200 }), + reloadSystem: (...args) => record('system', 'reload', args, { code: 200 }), + notify: (...args) => record('system', 'notify', args, { code: 200 }), +}; + +const scriptService = { + getFile: (...args) => + record('script', 'detail', args, `content:${args[0]}:${args[1]}`), + checkFilePath(filePath, fileName) { + calls.push({ + domain: 'script', + operation: 'check-path', + args: [filePath, fileName], + }); + const resolved = path.resolve(config.scriptPath, filePath || '', fileName); + const relative = path.relative(config.scriptPath, resolved); + return relative && !relative.startsWith('..') && !path.isAbsolute(relative) + ? resolved + : ''; + }, + runScript: (...args) => + record('script', 'run', args, { code: 200, data: 321 }), + stopScript: (...args) => record('script', 'stop', args, { code: 200 }), +}; + +const openService = { + list: (...args) => + record('open', 'list', args, [ + { id: 1, name: 'automation', scopes: ['scripts'], tokens: [] }, + ]), + create: (...args) => + record('open', 'create', args, { + id: 2, + ...args[0], + client_id: 'client-created', + client_secret: 'secret-created', + tokens: [], + }), + update: (...args) => + record('open', 'update', args, { ...args[0], tokens: [] }), + remove: (...args) => record('open', 'remove', args, undefined), + resetSecret: (...args) => + record('open', 'reset-secret', args, { + id: args[0], + client_secret: 'secret-reset', + tokens: [], + }), + authToken: (...args) => + record('open', 'auth-token', args, { + code: 200, + data: { + token: 'issued-open-token', + token_type: 'Bearer', + expiration: nowSeconds + 2_592_000, + }, + }), +}; + +function authorization(token = panelToken) { + return { authorization: `Bearer ${token}`, 'user-agent': 'desktop-test' }; +} + +async function request(method, pathname, options = {}) { + const headers = { connection: 'close', ...(options.headers || {}) }; + let body; + if (Object.hasOwn(options, 'body')) { + headers['content-type'] = 'application/json'; + body = JSON.stringify(options.body); + } + const response = await fetch(`${origin}${pathname}`, { + method, + headers, + body, + redirect: 'manual', + }); + const text = await response.text(); + let parsed = text; + if ( + (response.headers.get('content-type') || '').includes('application/json') + ) { + parsed = text.length === 0 ? undefined : JSON.parse(text); + } + return { status: response.status, body: parsed, headers: response.headers }; +} + +function lastCall() { + return calls.at(-1); +} + +before(async () => { + shareStore.getAuthInfo = async () => authInfo; + shareStore.getApps = async () => apps; + Container.set('logger', logger); + Container.set(SystemService, systemService); + Container.set(ScriptService, scriptService); + Container.set(OpenService, openService); + Container.set(UserService, { + async getAuthInfo() { + return authInfo; + }, + }); + + const app = express(); + expressLoader({ app }); + server = http.createServer(app); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', resolve); + }); + const address = server.address(); + origin = `http://127.0.0.1:${address.port}`; +}); + +after(() => { + server.closeAllConnections(); + server.close(); + server.unref(); + for (const token of [ + 'logger', + SystemService, + ScriptService, + OpenService, + UserService, + ]) { + Container.remove(token); + } + delete require.cache[apiIndexModulePath]; + delete require.cache[sharedStoreModulePath]; + fs.rmSync(testRoot, { recursive: true, force: true }); +}); + +test('preserves protected System contracts and validation ordering', async (t) => { + const configResponse = await request('GET', '/api/system/config', { + headers: authorization(), + }); + assert.deepEqual(configResponse.body, { + code: 200, + data: { + id: 1, + type: 'systemConfig', + info: { timezone: 'Asia/Shanghai', cronConcurrency: 2 }, + }, + }); + assert.deepEqual(lastCall(), { + domain: 'system', + operation: 'config', + args: [], + }); + + for (const [pathName, operation, body] of [ + ['log-remove-frequency', 'log-remove-frequency', { logRemoveFrequency: 7 }], + ['cron-concurrency', 'cron-concurrency', { cronConcurrency: 3 }], + [ + 'dependence-proxy', + 'dependence-proxy', + { dependenceProxy: 'http://127.0.0.1:8080' }, + ], + [ + 'python-mirror', + 'python-mirror', + { pythonMirror: 'https://pypi.example.invalid/simple' }, + ], + ]) { + await t.test( + `${pathName} keeps the existing body and envelope`, + async () => { + const response = await request( + 'PUT', + `/api/system/config/${pathName}`, + { headers: authorization(), body }, + ); + assert.deepEqual(response, { + status: 200, + body: { code: 200, data: body }, + headers: response.headers, + }); + assert.deepEqual(lastCall(), { + domain: 'system', + operation, + args: [body], + }); + }, + ); + } + + const reloaded = await request('PUT', '/api/system/reload', { + headers: authorization(), + body: { type: 'data' }, + }); + assert.deepEqual(reloaded.body, { code: 200 }); + assert.deepEqual(lastCall(), { + domain: 'system', + operation: 'reload', + args: ['data'], + }); + + const notified = await request('PUT', '/api/system/notify', { + headers: authorization(), + body: { title: 'legacy', content: 'compatible' }, + }); + assert.deepEqual(notified.body, { code: 200 }); + assert.deepEqual(lastCall(), { + domain: 'system', + operation: 'notify', + args: [{ title: 'legacy', content: 'compatible' }], + }); + + const callsBeforeInvalid = calls.length; + const invalid = await request('PUT', '/api/system/notify', { + headers: authorization(), + body: { title: 'missing-content' }, + }); + assert.equal(invalid.status, 400); + assert.equal(invalid.body.statusCode, 400); + assert.equal(invalid.body.error, 'Bad Request'); + assert.equal(calls.length, callsBeforeInvalid); +}); + +test('preserves Script file/run contracts while failing closed outside the script root', async (t) => { + const listed = await request('GET', '/api/scripts', { + headers: authorization(), + }); + assert.equal(listed.status, 200); + assert.equal(listed.body.code, 200); + assert.deepEqual( + listed.body.data.map((entry) => [entry.title, entry.type, entry.key]), + [ + ['jobs', 'directory', 'jobs'], + ['existing.js', 'file', 'existing.js'], + ], + ); + + const escaped = await request( + 'GET', + '/api/scripts?path=..%2Fscripts-sibling', + { headers: authorization() }, + ); + assert.deepEqual(escaped, { + status: 200, + body: { code: 200, data: [] }, + headers: escaped.headers, + }); + + const symlinkEscape = await request('GET', '/api/scripts?path=outside-link', { + headers: authorization(), + }); + assert.deepEqual(symlinkEscape, { + status: 200, + body: { code: 200, data: [] }, + headers: symlinkEscape.headers, + }); + + const detail = await request( + 'GET', + '/api/scripts/detail?path=jobs&file=task.js', + { headers: authorization() }, + ); + assert.deepEqual(detail.body, { code: 200, data: 'content:jobs:task.js' }); + assert.deepEqual(lastCall(), { + domain: 'script', + operation: 'detail', + args: ['jobs', 'task.js'], + }); + + const created = await request('POST', '/api/scripts', { + headers: authorization(), + body: { filename: 'created.js', content: 'created\n' }, + }); + assert.deepEqual(created.body, { code: 200 }); + assert.equal( + fs.readFileSync(path.join(config.scriptPath, 'created.js'), 'utf8'), + 'created\n', + ); + + const renamed = await request('PUT', '/api/scripts/rename', { + headers: authorization(), + body: { + filename: 'created.js', + path: '', + newFilename: 'renamed.js', + }, + }); + assert.deepEqual(renamed.body, { code: 200 }); + assert.equal( + fs.existsSync(path.join(config.scriptPath, 'created.js')), + false, + ); + assert.equal(fs.existsSync(path.join(config.scriptPath, 'renamed.js')), true); + + const run = await request('PUT', '/api/scripts/run', { + headers: authorization(), + body: { filename: 'task.js', path: 'jobs', content: 'run\n' }, + }); + assert.deepEqual(run.body, { code: 200, data: 321 }); + assert.equal(lastCall().domain, 'script'); + assert.equal(lastCall().operation, 'run'); + assert.equal( + lastCall().args[0], + path.join(config.scriptPath, 'jobs', 'task.swap.js'), + ); + + const callsBeforeEscape = calls.length; + const rejectedRun = await request('PUT', '/api/scripts/run', { + headers: authorization(), + body: { + filename: 'outside.js', + path: '../scripts-sibling', + content: 'forbidden\n', + }, + }); + assert.deepEqual(rejectedRun.body, { code: 403, message: '暂无权限' }); + assert.equal(calls.length, callsBeforeEscape); + + await t.test( + 'invalid run body is rejected before ScriptService', + async () => { + const callCount = calls.length; + const invalid = await request('PUT', '/api/scripts/run', { + headers: authorization(), + body: { content: 'missing filename' }, + }); + assert.equal(invalid.status, 400); + assert.equal(calls.length, callCount); + }, + ); +}); + +test('preserves Open app management and token issuance envelopes', async () => { + const listed = await request('GET', '/api/apps', { + headers: authorization(), + }); + assert.deepEqual(listed.body, { + code: 200, + data: [{ id: 1, name: 'automation', scopes: ['scripts'], tokens: [] }], + }); + assert.deepEqual(lastCall(), { domain: 'open', operation: 'list', args: [] }); + + const createBody = { name: 'automation-2', scopes: ['scripts', 'system'] }; + const created = await request('POST', '/api/apps', { + headers: authorization(), + body: createBody, + }); + assert.equal(created.body.code, 200); + assert.deepEqual(created.body.data, { + id: 2, + ...createBody, + client_id: 'client-created', + client_secret: 'secret-created', + tokens: [], + }); + assert.deepEqual(lastCall(), { + domain: 'open', + operation: 'create', + args: [createBody], + }); + + const updateBody = { id: 2, name: 'renamed', scopes: ['system'] }; + const updated = await request('PUT', '/api/apps', { + headers: authorization(), + body: updateBody, + }); + assert.deepEqual(updated.body, { + code: 200, + data: { ...updateBody, tokens: [] }, + }); + + const removed = await request('DELETE', '/api/apps', { + headers: authorization(), + body: [2], + }); + assert.deepEqual(removed.body, { code: 200 }); + assert.deepEqual(lastCall(), { + domain: 'open', + operation: 'remove', + args: [[2]], + }); + + const reset = await request('PUT', '/api/apps/2/reset-secret', { + headers: authorization(), + }); + assert.deepEqual(reset.body, { + code: 200, + data: { id: 2, client_secret: 'secret-reset', tokens: [] }, + }); + assert.deepEqual(lastCall(), { + domain: 'open', + operation: 'reset-secret', + args: [2], + }); + + const issued = await request( + 'GET', + '/open/auth/token?client_id=client&client_secret=secret', + ); + assert.deepEqual(issued.body, { + code: 200, + data: { + token: 'issued-open-token', + token_type: 'Bearer', + expiration: nowSeconds + 2_592_000, + }, + }); + assert.deepEqual( + { + ...lastCall(), + args: lastCall().args.map((argument) => ({ ...argument })), + }, + { + domain: 'open', + operation: 'auth-token', + args: [{ client_id: 'client', client_secret: 'secret' }], + }, + ); +}); + +test('locks panel/Open authentication, scope and error envelopes', async () => { + const missing = await request('GET', '/api/system/config'); + assert.equal(missing.status, 401); + assert.equal(missing.body.code, 401); + + const foreign = await request('GET', '/api/system/config', { + headers: authorization(foreignPanelToken), + }); + assert.deepEqual(foreign, { + status: 401, + body: { code: 401, message: 'Token 已失效' }, + headers: foreign.headers, + }); + + const scoped = await request( + 'GET', + '/open/scripts/detail?path=jobs&file=open.js', + { headers: authorization('open-script-token') }, + ); + assert.deepEqual(scoped.body, { + code: 200, + data: 'content:jobs:open.js', + }); + + const denied = await request( + 'GET', + '/open/scripts/detail?path=jobs&file=denied.js', + { headers: authorization('open-system-token') }, + ); + assert.deepEqual(denied, { + status: 401, + body: { code: 401, message: '暂无权限' }, + headers: denied.headers, + }); + + const expired = await request( + 'GET', + '/open/scripts/detail?path=jobs&file=expired.js', + { headers: authorization('expired-open-token') }, + ); + assert.deepEqual(expired, { + status: 401, + body: { code: 401, message: 'Token 已失效' }, + headers: expired.headers, + }); + + const caseVariant = await request('GET', '/API/system'); + assert.deepEqual(caseVariant, { + status: 400, + body: { code: 400, message: 'Invalid path format' }, + headers: caseVariant.headers, + }); + + systemFailure = new Error('legacy-system-failed'); + const failed = await request('GET', '/api/system/config', { + headers: authorization(), + }); + systemFailure = undefined; + assert.deepEqual(failed, { + status: 500, + body: { code: 500, message: 'legacy-system-failed' }, + headers: failed.headers, + }); +}); + +test('uses separator-aware Script path containment across API and service boundaries', () => { + const inside = path.join(config.scriptPath, 'jobs', 'task.js'); + const sibling = path.resolve( + config.scriptPath, + '..', + 'scripts-sibling', + 'task.js', + ); + assert.equal(isPathInside(config.scriptPath, config.scriptPath), true); + assert.equal(isPathInside(config.scriptPath, inside), true); + assert.equal(isPathInside(config.scriptPath, sibling), false); + assert.equal( + ScriptService.prototype.checkFilePath.call({}, 'jobs', 'task.js'), + inside, + ); + assert.equal( + ScriptService.prototype.checkFilePath.call( + {}, + '../scripts-sibling', + 'task.js', + ), + '', + ); +});