feat(mind-test-harness): support multiple mock channel accounts

This commit is contained in:
YBF
2026-09-15 11:59:54 +08:00
parent 9a4ce8749b
commit 22e0212e05
7 changed files with 70 additions and 20 deletions
@@ -14,11 +14,13 @@ MIND_MOCK_PORT=8787
MIND_TEST_HARNESS_HOST=127.0.0.1
MIND_TEST_HARNESS_PORT=8788
MIND_TEST_HARNESS_BRIGHT_BASE_URL=http://127.0.0.1:7878
MIND_MOCK_CHANNEL_ACCOUNT_ID=243340382,286995452
```
- `src/entry.ts` 必须启动 Mind 授权模拟监听器和 Bright 联调页监听器。
- `pnpm dev` 不得启动、等待或依赖该包;只有显式 `pnpm dev:harness` 才运行它。
- 联调页使用 `MIND_TEST_HARNESS_BRIGHT_BASE_URL` 请求 Bright HTTP 和 WS;不能再由 `apps/server``/harness` 路由提供。
- `MIND_MOCK_CHANNEL_ACCOUNT_ID` 使用逗号分隔的非空唯一账号列表;Mind mock 对列表内账号授权,并在成功响应中回显本次请求的单个 `channelAccountId`
## 3. Contracts
@@ -34,6 +36,7 @@ MIND_TEST_HARNESS_BRIGHT_BASE_URL=http://127.0.0.1:7878
| 条件 | 结果 |
| --- | --- |
| `MIND_TEST_HARNESS_PORT``MIND_MOCK_PORT` 非 0–65535 整数 | 启动前抛出对应环境变量错误 |
| `MIND_MOCK_CHANNEL_ACCOUNT_ID` 含空项或重复账号 | 启动前抛出对应环境变量错误 |
| Bright base URL 不是精确 HTTP/HTTPS origin | 启动前抛出 `Invalid MIND_TEST_HARNESS_BRIGHT_BASE_URL` |
| 监听端口已被占用 | 启动失败并关闭已打开的另一监听器 |
| 非 `GET /` 请求联调页监听器 | 返回 404 |
+2 -2
View File
@@ -134,7 +134,7 @@ Bright 不连接 Mind 数据库、不读取认证视图、不共享 Mind 登录
### Mind 本地测试支架
`mind-test-harness` 是仅供手工测试的独立开发工具:它模拟两个 Mind 授权接口,并提供 Bright HTTP/WS 联调页。Mind 模拟默认监听 `127.0.0.1:8787`,默认 fixture 是账号 `286995452`、binding `binding-123` 和 Cookie `mind_session=mock-valid`通过 `MIND_MOCK_*` 环境变量覆盖 fixture 后重启即可模拟 binding/version/cookie 切换。两个 endpoint 还可独立设置结果:`MIND_MOCK_BINDING_RESULT=allow|scope_mismatch|binding_revoked|authorization_unavailable``MIND_MOCK_SESSION_RESULT=allow|auth_required|scope_mismatch|authorization_rejected|authorization_unavailable`
`mind-test-harness` 是仅供手工测试的独立开发工具:它模拟两个 Mind 授权接口,并提供 Bright HTTP/WS 联调页。Mind 模拟默认监听 `127.0.0.1:8787`,默认 fixture 是账号 `243340382``286995452`、binding `binding-123` 和 Cookie `mind_session=mock-valid``MIND_MOCK_CHANNEL_ACCOUNT_ID` 支持逗号分隔的账号列表,设置 `MIND_MOCK_*` 环境变量后重启即可模拟 binding/version/cookie 切换。两个 endpoint 还可独立设置结果:`MIND_MOCK_BINDING_RESULT=allow|scope_mismatch|binding_revoked|authorization_unavailable``MIND_MOCK_SESSION_RESULT=allow|auth_required|scope_mismatch|authorization_rejected|authorization_unavailable`
根目录执行 `pnpm dev` 时不会启动支架;需要手工验证时才另开终端在开发环境运行 `NODE_ENV=development pnpm dev:harness`。支架不被任何 workspace 包依赖,不含测试文件,不声明 build 脚本,也不进入根 `dev``typecheck``test``build` 或生产 Docker 镜像。支架错误只在其独立命令中报告,不能改变主流程结果。
@@ -274,7 +274,7 @@ curl http://127.0.0.1:7878/health
http://127.0.0.1:8788/
```
本地 harness 中的 OneTalk account 应 `MIND_MOCK_CHANNEL_ACCOUNT_ID`(默认 `286995452`一致;生产 Mind 页面不提交可信的 user/workspace header,而由 Cookie Session 授权结果派生。页面会连接配置的 Bright 地址的 `/ws/mind`,插件使用 Bright 的 `/ws/plugin``MIND_PAGE_ORIGIN` 必须设为该页面的 Origin。
本地 harness 中的 OneTalk account 应包含在 `MIND_MOCK_CHANNEL_ACCOUNT_ID`(默认 `243340382,286995452`;生产 Mind 页面不提交可信的 user/workspace header,而由 Cookie Session 授权结果派生。页面会连接配置的 Bright 地址的 `/ws/mind`,插件使用 Bright 的 `/ws/plugin``MIND_PAGE_ORIGIN` 必须设为该页面的 Origin。
## 加载和配置 Chrome 扩展
@@ -4,7 +4,11 @@ import type { IncomingMessage, ServerResponse } from "node:http";
import { isPlainRecord } from "@trade-message-center/onetalk-contract";
import type { MindMockAuthorization, MindMockBindingResult } from "../model.ts";
import {
authorizationForChannelAccount,
type MindMockAuthorization,
type MindMockBindingResult,
} from "../model.ts";
import { hasExactKeys, isJsonRequest, isNonEmptyString, readBody } from "../request.ts";
import { writeAuthorization, writePlainResponse, writeRejection } from "../response.ts";
@@ -39,6 +43,7 @@ export const handleBindingAuthorization = async (
request: IncomingMessage,
response: ServerResponse,
authorization: MindMockAuthorization,
channelAccountIds: readonly string[],
result: MindMockBindingResult,
): Promise<void> => {
if (!isJsonRequest(request)) {
@@ -58,7 +63,7 @@ export const handleBindingAuthorization = async (
return;
}
if (writeBindingResult(response, result)) return;
if (input.channelAccountId !== authorization.mindScope.channelAccountId) {
if (!channelAccountIds.includes(input.channelAccountId)) {
writeRejection(response, 403, "scope_mismatch");
return;
}
@@ -66,5 +71,8 @@ export const handleBindingAuthorization = async (
writeRejection(response, 403, "binding_revoked");
return;
}
writeAuthorization(response, authorization);
writeAuthorization(
response,
authorizationForChannelAccount(authorization, input.channelAccountId),
);
};
@@ -4,7 +4,11 @@ import type { IncomingMessage, ServerResponse } from "node:http";
import { isPlainRecord } from "@trade-message-center/onetalk-contract";
import type { MindMockAuthorization, MindMockSessionResult } from "../model.ts";
import {
authorizationForChannelAccount,
type MindMockAuthorization,
type MindMockSessionResult,
} from "../model.ts";
import { hasExactKeys, isJsonRequest, isNonEmptyString, readBody } from "../request.ts";
import { writeAuthorization, writePlainResponse, writeRejection } from "../response.ts";
@@ -39,6 +43,7 @@ export const handleSessionAuthorization = async (
request: IncomingMessage,
response: ServerResponse,
authorization: MindMockAuthorization,
channelAccountIds: readonly string[],
cookie: string,
result: MindMockSessionResult,
): Promise<void> => {
@@ -63,9 +68,12 @@ export const handleSessionAuthorization = async (
writeRejection(response, 401, "auth_required");
return;
}
if (input.channelAccountId !== authorization.mindScope.channelAccountId) {
if (!channelAccountIds.includes(input.channelAccountId)) {
writeRejection(response, 403, "scope_mismatch");
return;
}
writeAuthorization(response, authorization);
writeAuthorization(
response,
authorizationForChannelAccount(authorization, input.channelAccountId),
);
};
+33 -12
View File
@@ -12,7 +12,7 @@ import { ONETALK_PERMISSIONS } from "@trade-message-center/onetalk-contract";
const DEFAULT_HOST = "127.0.0.1";
const DEFAULT_PORT = 8787;
const DEFAULT_CHANNEL_ACCOUNT_ID = "243340382";
const DEFAULT_CHANNEL_ACCOUNT_ID = ["243340382", "286995452"] as const;
const DEFAULT_BINDING = "123";
const DEFAULT_AUTHORIZATION_VERSION = "mock-v1";
const DEFAULT_MIND_USER_ID = "mind-user-1";
@@ -29,6 +29,7 @@ export type MindMockConfig = {
port: number;
cookie: string;
authorization: MindMockAuthorization;
channelAccountIds: readonly string[];
bindingResult?: MindMockBindingResult;
sessionResult?: MindMockSessionResult;
};
@@ -84,18 +85,36 @@ const parsePermissions = (value: string): MindMockPermission[] => {
return permissions as MindMockPermission[];
};
const parseFixtureString = (
environment: Record<string, string | undefined>,
key: string,
defaultValue: string,
): string => {
const value = readEnvironmentValue(environment, key, defaultValue);
const validateFixtureString = (value: string, key: string): string => {
if (value.includes("\r") || value.includes("\n")) {
throw new Error(`Invalid ${key}: line breaks are not allowed`);
}
return value;
};
const parseFixtureString = (
environment: Record<string, string | undefined>,
key: string,
defaultValue: string,
): string => validateFixtureString(readEnvironmentValue(environment, key, defaultValue), key);
const parseFixtureStringList = (
environment: Record<string, string | undefined>,
key: string,
defaultValues: readonly string[],
): string[] => {
const value = readEnvironmentValue(environment, key, defaultValues.join(","));
const values = value.split(",").map((item) => item.trim());
if (
values.length === 0 ||
values.some((item) => item === "") ||
new Set(values).size !== values.length
) {
throw new Error(`Invalid ${key}: expected unique non-empty values`);
}
return values.map((item) => validateFixtureString(item, key));
};
const parseResult = <T extends string>(
environment: Record<string, string | undefined>,
key: string,
@@ -112,6 +131,11 @@ const parseResult = <T extends string>(
export const parseMindMockConfig = (
environment: Record<string, string | undefined> = process.env,
): MindMockConfig => {
const channelAccountIds = parseFixtureStringList(
environment,
"MIND_MOCK_CHANNEL_ACCOUNT_ID",
DEFAULT_CHANNEL_ACCOUNT_ID,
);
const authorization: MindMockAuthorization = {
binding: parseFixtureString(environment, "MIND_MOCK_BINDING", DEFAULT_BINDING),
authorizationVersion: parseFixtureString(
@@ -133,11 +157,7 @@ export const parseMindMockConfig = (
"MIND_MOCK_WORKSPACE_ID",
DEFAULT_WORKSPACE_ID,
),
channelAccountId: parseFixtureString(
environment,
"MIND_MOCK_CHANNEL_ACCOUNT_ID",
DEFAULT_CHANNEL_ACCOUNT_ID,
),
channelAccountId: channelAccountIds[0]!,
},
};
return {
@@ -148,6 +168,7 @@ export const parseMindMockConfig = (
),
cookie: parseFixtureString(environment, "MIND_MOCK_COOKIE", DEFAULT_COOKIE),
authorization,
channelAccountIds,
bindingResult: parseResult(environment, "MIND_MOCK_BINDING_RESULT", BINDING_MOCK_RESULTS),
sessionResult: parseResult(environment, "MIND_MOCK_SESSION_RESULT", SESSION_MOCK_RESULTS),
};
+8
View File
@@ -15,6 +15,14 @@ export type MindMockAuthorization = {
};
};
export const authorizationForChannelAccount = (
authorization: MindMockAuthorization,
channelAccountId: string,
): MindMockAuthorization => ({
...authorization,
mindScope: { ...authorization.mindScope, channelAccountId },
});
export const BINDING_MOCK_RESULTS = [
"allow",
"scope_mismatch",
+2
View File
@@ -38,6 +38,7 @@ const handleRequest = async (
request,
response,
config.authorization,
config.channelAccountIds,
config.bindingResult ?? "allow",
);
return;
@@ -47,6 +48,7 @@ const handleRequest = async (
request,
response,
config.authorization,
config.channelAccountIds,
config.cookie,
config.sessionResult ?? "allow",
);