mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-23 03:18:09 +08:00
fix(ql3): harden legacy api compatibility
This commit is contained in:
+16
-3
@@ -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 { Router, Request, Response, NextFunction } from 'express';
|
||||||
import { Container } from 'typedi';
|
import { Container } from 'typedi';
|
||||||
import { Logger } from 'winston';
|
import { Logger } from 'winston';
|
||||||
@@ -14,7 +21,9 @@ const route = Router();
|
|||||||
|
|
||||||
function isPathAllowed(targetPath: string): boolean {
|
function isPathAllowed(targetPath: string): boolean {
|
||||||
const resolved = path.resolve(targetPath);
|
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({
|
const storage = multer.diskStorage({
|
||||||
@@ -158,7 +167,11 @@ export default (app: Router) => {
|
|||||||
if (!path.startsWith('/')) {
|
if (!path.startsWith('/')) {
|
||||||
path = join(config.scriptPath, path);
|
path = join(config.scriptPath, path);
|
||||||
}
|
}
|
||||||
if (config.writePathList.every((x) => !path.startsWith(x))) {
|
if (
|
||||||
|
config.writePathList.every(
|
||||||
|
(rootPath) => !isPathInside(rootPath, path),
|
||||||
|
)
|
||||||
|
) {
|
||||||
return res.send({
|
return res.send({
|
||||||
code: 403,
|
code: 403,
|
||||||
message: t('暂无权限'),
|
message: t('暂无权限'),
|
||||||
|
|||||||
+28
-2
@@ -263,13 +263,26 @@ export async function readDir(
|
|||||||
baseDir: string = '',
|
baseDir: string = '',
|
||||||
blacklist: string[] = [],
|
blacklist: string[] = [],
|
||||||
): Promise<IFile[]> {
|
): Promise<IFile[]> {
|
||||||
|
const absoluteBaseDir = path.resolve(baseDir);
|
||||||
const absoluteDir = path.resolve(baseDir, dir);
|
const absoluteDir = path.resolve(baseDir, dir);
|
||||||
if (!absoluteDir.startsWith(path.resolve(baseDir))) {
|
if (!isPathInside(absoluteBaseDir, absoluteDir)) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
const relativePath = path.relative(baseDir, absoluteDir);
|
|
||||||
|
|
||||||
try {
|
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 files = await fs.readdir(absoluteDir);
|
||||||
const result: IFile[] = [];
|
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<string> {
|
export async function promiseExec(command: string): Promise<string> {
|
||||||
try {
|
try {
|
||||||
const { stderr, stdout } = await promisify(exec)(command, {
|
const { stderr, stdout } = await promisify(exec)(command, {
|
||||||
|
|||||||
@@ -6,7 +6,13 @@ import CronService from './cron';
|
|||||||
import ScheduleService, { TaskCallbacks } from './schedule';
|
import ScheduleService, { TaskCallbacks } from './schedule';
|
||||||
import config from '../config';
|
import config from '../config';
|
||||||
import { TASK_COMMAND } from '../config/const';
|
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';
|
import taskLimit from '../shared/pLimit';
|
||||||
|
|
||||||
@Service()
|
@Service()
|
||||||
@@ -66,7 +72,7 @@ export default class ScriptService {
|
|||||||
|
|
||||||
public checkFilePath(filePath: string, fileName: string) {
|
public checkFilePath(filePath: string, fileName: string) {
|
||||||
const finalPath = path.resolve(config.scriptPath, filePath, fileName);
|
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) {
|
public async getFile(filePath: string, fileName: string) {
|
||||||
|
|||||||
@@ -11,6 +11,25 @@
|
|||||||
|
|
||||||
最新增量证据(2026-08-20):
|
最新增量证据(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 从仅证明
|
- D-381/ADR-0474(已接受;OpenRC live actor 待镜像基础设施恢复后补跑):把 service-manager adopted rollback 从仅证明
|
||||||
init/process 的 `legacy_running` 推进到有界、可重放的 2.x core readiness。新增显式 Owner 私有命令
|
init/process 的 `legacy_running` 推进到有界、可重放的 2.x core readiness。新增显式 Owner 私有命令
|
||||||
`cutover-legacy-readiness-probe`,绑定 exact cutover/profile/instance/generation、activation、当前 head、legacy-running source
|
`cutover-legacy-readiness-probe`,绑定 exact cutover/profile/instance/generation、activation、当前 head、legacy-running source
|
||||||
|
|||||||
@@ -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 已全部完成。
|
||||||
@@ -478,6 +478,7 @@
|
|||||||
| [ADR-0472](./ADR-0472-service-manager-legacy-rollback-preparation.md) | Service Manager Legacy Rollback Preparation | Accepted |
|
| [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-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-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 |
|
||||||
|
|
||||||
## 规则
|
## 规则
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
|
),
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user