309 lines
18 KiB
JavaScript
309 lines
18 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
import {
|
|
M3_IO_CHAIN,
|
|
M3_IO_CONTROL_ROUTE,
|
|
M3_STATUS_ROUTE
|
|
} from "../../../internal/cloud/m3-io-control.mjs";
|
|
import { checkFrontendNoDirectRuntimeCalls } from "../../../scripts/src/m3-io-control-e2e.mjs";
|
|
|
|
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
const repoRoot = path.resolve(rootDir, "../..");
|
|
const exactTrustedRoute = `${M3_IO_CHAIN.sourceResourceId}:${M3_IO_CHAIN.sourcePort} -> ${M3_IO_CHAIN.patchPanelServiceId} -> ${M3_IO_CHAIN.targetResourceId}:${M3_IO_CHAIN.targetPort}`;
|
|
const readOnlyRpcMethods = Object.freeze([
|
|
"system.health",
|
|
"cloud.adapter.describe",
|
|
"audit.event.query",
|
|
"evidence.record.query"
|
|
]);
|
|
|
|
function readText(relativePath) {
|
|
return fs.readFileSync(path.resolve(repoRoot, relativePath), "utf8");
|
|
}
|
|
|
|
export function runM3ControlPanelGuard() {
|
|
const html = readText("web/hwlab-cloud-web/index.html");
|
|
const app = readText("web/hwlab-cloud-web/app.mjs");
|
|
const styles = readText("web/hwlab-cloud-web/styles.css");
|
|
const artifactPublisher = readText("scripts/dev-artifact-publish.mjs");
|
|
const cloudWebRouteSource = readText("internal/dev-entrypoint/cloud-web-routes.mjs");
|
|
const browserSource = `${html}\n${app}\n${styles}`;
|
|
|
|
assert.equal(M3_IO_CONTROL_ROUTE, "/v1/m3/io", "M3 IO control route must remain exact");
|
|
assert.equal(M3_STATUS_ROUTE, "/v1/m3/status", "M3 status aggregate route must remain exact");
|
|
assert.equal(
|
|
exactTrustedRoute,
|
|
"res_boxsimu_1:DO1 -> hwlab-patch-panel -> res_boxsimu_2:DI1",
|
|
"M3 trusted route constant must remain exact"
|
|
);
|
|
|
|
assertControlPanelDom(html);
|
|
assertControlPanelSource(app);
|
|
assertBrowserWriteBoundaries({ app, html, artifactPublisher, cloudWebRouteSource });
|
|
assertNoStaticDevLiveClaim({ html, app });
|
|
|
|
for (const pattern of [
|
|
/#panel-control\s*{\s*grid-template-rows:\s*auto auto auto minmax\(0,\s*1fr\);/s,
|
|
/\.m3-flow-summary\s*{/s,
|
|
/\.m3-flow-line\s*{/s,
|
|
/\.m3-flow-kv\s*{/s,
|
|
/\.m3-control-form\s*{/s,
|
|
/\.m3-control-grid\s*{/s,
|
|
/\.m3-control-actions\s*{/s
|
|
]) {
|
|
assert.match(styles, pattern, `missing M3 control style ${pattern.source}`);
|
|
}
|
|
|
|
for (const copy of [
|
|
"M3 IO 控制请求失败",
|
|
"M3 IO 只读 readiness 未 green",
|
|
"M3 IO 只读 readiness 已确认",
|
|
"受控路径受阻",
|
|
"等待 cloud-api 返回 M3 IO 只读 readiness",
|
|
"不可用时页面不直连模拟器",
|
|
"浏览器不直连 box-simu",
|
|
"阻塞原因=",
|
|
"仍在等待完整可信记录",
|
|
"页面不直连模拟器,也不伪造硬件状态。",
|
|
"控制可达 / 等待记录",
|
|
"待可信记录",
|
|
"选择 DO1 true/false 后点击“写入 DO1”",
|
|
"最近 trace 与 operation 可在“可信记录”页签追踪",
|
|
"DO1目标=",
|
|
"DI1观测=",
|
|
"trusted=",
|
|
"durable="
|
|
]) {
|
|
assert.match(browserSource, new RegExp(escapeRegExp(copy), "u"), `missing Chinese blocked/error copy: ${copy}`);
|
|
}
|
|
}
|
|
|
|
function assertControlPanelDom(html) {
|
|
const controlPanel = elementSectionById(html, "panel-control");
|
|
assert.match(controlPanel, /data-side-panel="control"/u, "right-side control tab panel must be present");
|
|
assert.match(controlPanel, /<h2>基础 IO 控制<\/h2>/u, "M3 control panel title must be user-visible without milestone shorthand");
|
|
assert.match(controlPanel, /id="m3-control-status"[^>]*>探测中</u, "M3 control status starts as probing, not live");
|
|
assert.match(controlPanel, /id="m3-flow-summary"[^>]*aria-label="M3 DO1 到 DI1 操作摘要"/u, "M3 operation summary must be visible above the form");
|
|
for (const id of ["m3-flow-state", "m3-action-summary", "m3-do1-target", "m3-di1-observed", "m3-trust-summary", "m3-trace-summary"]) {
|
|
assert.match(controlPanel, new RegExp(`id="${id}"`, "u"), `M3 flow summary missing #${id}`);
|
|
}
|
|
assert.match(controlPanel, /DO1 -> patch-panel -> DI1/u, "M3 summary must state the operator-facing DO1 to DI1 route");
|
|
assert.match(controlPanel, /id="m3-records-jump"[\s\S]*查看最近操作\/trace/u, "M3 summary must link recent trace to records tab");
|
|
assert.match(controlPanel, /<form[^>]+id="m3-control-form"[^>]+aria-label="DO1 写入"/u, "DO write form must be labeled");
|
|
assert.match(controlPanel, /id="m3-gateway-select"[\s\S]*value="gwsimu_1"[\s\S]*gateway-simu-1/u, "DO write uses source gateway");
|
|
assert.match(controlPanel, /id="m3-box-select"[\s\S]*value="res_boxsimu_1"[\s\S]*box-simu-1 \/ res_boxsimu_1/u, "DO write uses source box resource");
|
|
assert.match(controlPanel, /id="m3-port-select"[\s\S]*value="DO1"[\s\S]*DO1/u, "DO write exposes only DO1");
|
|
assert.match(controlPanel, /id="m3-value-select"[\s\S]*value="true"[\s\S]*true[\s\S]*value="false"[\s\S]*false/u, "DO write exposes true and false values");
|
|
assert.match(controlPanel, /id="m3-write-do"[^>]*type="submit"[^>]*>写入 DO1<\/button>/u, "DO write affordance must be visible");
|
|
assert.match(controlPanel, /id="m3-read-di"[^>]*type="button"[^>]*>读取 DI1<\/button>/u, "DI read affordance must be visible");
|
|
assert.match(controlPanel, /id="control-list"/u, "control panel must include operation feedback list");
|
|
assert.doesNotMatch(controlPanel, /tone-dev-live|开发实况 DEV-LIVE/u, "static control panel must not claim DEV-LIVE");
|
|
|
|
const wiringPanel = elementSectionById(html, "panel-wiring");
|
|
assert.match(html, new RegExp(escapeRegExp(exactTrustedRoute), "u"), "static fallback must show the exact M3 route");
|
|
for (const value of ["res_boxsimu_1", "DO1", "hwlab-patch-panel", "res_boxsimu_2", "DI1"]) {
|
|
assert.match(wiringPanel, new RegExp(escapeRegExp(value), "u"), `wiring panel missing ${value}`);
|
|
}
|
|
assert.match(wiringPanel, /data-wiring-layout="two-column-long-table"/u, "wiring panel must use the #276 two-column long-table layout");
|
|
assert.equal((wiringPanel.match(/<th\b/gu) ?? []).length, 2, "wiring table must have exactly two device columns");
|
|
assert.equal((wiringPanel.match(/<td\b/gu) ?? []).length, 2, "static wiring IO row must have exactly two port cells");
|
|
for (const header of ["源设备", "源端口", "接线盘", "目标设备", "目标端口", "状态", "证据来源", "轨迹/证据"]) {
|
|
assert.doesNotMatch(wiringPanel, new RegExp(`<th>${escapeRegExp(header)}<\\/th>`, "u"), `wiring table must not restore legacy wide column ${header}`);
|
|
}
|
|
assert.match(wiringPanel, /待可信记录/u, "static wiring row must stay blocked");
|
|
assert.match(wiringPanel, /等待可信记录/u, "static wiring row must require live evidence");
|
|
}
|
|
|
|
function assertControlPanelSource(app) {
|
|
const trustedRouteSource = constObjectSource(app, "M3_TRUSTED_ROUTE");
|
|
for (const [field, value] of [
|
|
["fromResourceId", M3_IO_CHAIN.sourceResourceId],
|
|
["fromPort", M3_IO_CHAIN.sourcePort],
|
|
["patchPanelServiceId", M3_IO_CHAIN.patchPanelServiceId],
|
|
["toResourceId", M3_IO_CHAIN.targetResourceId],
|
|
["toPort", M3_IO_CHAIN.targetPort]
|
|
]) {
|
|
assert.match(trustedRouteSource, new RegExp(`${field}:\\s*"${escapeRegExp(value)}"`, "u"), `M3 trusted route source missing ${field}`);
|
|
}
|
|
|
|
const initBody = functionBody(app, "initM3Control");
|
|
assert.match(initBody, /el\.m3ControlForm\.addEventListener\("submit"[\s\S]*runM3IoAction\("do\.write"\)/u, "submit must run do.write");
|
|
assert.match(initBody, /el\.m3ReadDi\.addEventListener\("click"[\s\S]*runM3IoAction\("di\.read"\)/u, "DI button must run di.read");
|
|
|
|
const actionBody = functionBody(app, "runM3IoAction");
|
|
assert.match(actionBody, /if \(!m3ControlCanOperate\(\)\)/u, "M3 action must fail closed before POST when readiness is blocked");
|
|
assert.match(actionBody, /fetchJson\("\/v1\/m3\/io",\s*\{[\s\S]*method:\s*"POST"/u, "M3 action must POST same-origin /v1/m3/io");
|
|
assert.match(actionBody, /fetchJson\("\/v1\/m3\/status"\)/u, "M3 action may refresh only through same-origin aggregate status after control");
|
|
assert.doesNotMatch(actionBody, /\bfetch\(/u, "M3 action must not use raw fetch");
|
|
assert.doesNotMatch(actionBody, /fetchJson\(\s*["'`](?!\/v1\/m3\/(?:io|status))/u, "M3 action must not call any route except /v1/m3/io and /v1/m3/status");
|
|
assert.match(actionBody, /gatewayId:\s*el\.m3GatewaySelect\.value/u, "DO write request must use the selected source gateway");
|
|
assert.match(actionBody, /resourceId:\s*el\.m3BoxSelect\.value/u, "DO write request must use the selected source resource");
|
|
assert.match(actionBody, /boxId:\s*"boxsimu_1"/u, "DO write request must target source boxsimu_1");
|
|
assert.match(actionBody, /port:\s*el\.m3PortSelect\.value/u, "DO write request must use selected DO1 port");
|
|
assert.match(actionBody, /value:\s*el\.m3ValueSelect\.value === "true"/u, "DO write request must convert true/false to boolean");
|
|
assert.match(actionBody, /gatewayId:\s*"gwsimu_2"/u, "DI read request must use target gateway");
|
|
assert.match(actionBody, /resourceId:\s*M3_TRUSTED_ROUTE\.toResourceId/u, "DI read request must use trusted target resource");
|
|
assert.match(actionBody, /boxId:\s*"boxsimu_2"/u, "DI read request must target boxsimu_2");
|
|
assert.match(actionBody, /port:\s*M3_TRUSTED_ROUTE\.toPort/u, "DI read request must use trusted DI1 port");
|
|
assert.match(actionBody, /"X-Trace-Id":\s*traceId/u, "M3 action must attach trace header");
|
|
assert.match(actionBody, /"X-Actor-Id":\s*"usr_hwlab_cloud_web"/u, "M3 action must attach web actor header");
|
|
assert.match(actionBody, /evidenceState\?\.status === "green" \? "DEV-LIVE" : "BLOCKED"/u, "M3 operation sourceKind must not claim DEV-LIVE unless evidence is green");
|
|
|
|
const canOperateBody = functionBody(app, "m3ControlCanOperate");
|
|
assert.match(canOperateBody, /readiness\?\.status === "ready"/u, "M3 controls must require readiness.status=ready");
|
|
assert.match(canOperateBody, /readiness\?\.controlReady === true/u, "M3 controls must require readiness.controlReady=true");
|
|
assert.match(canOperateBody, /readiness\?\.sourceKind === "DEV-LIVE"/u, "M3 controls must not unlock on SOURCE/DRY-RUN/BLOCKED readiness");
|
|
assert.match(canOperateBody, /readiness\?\.evidenceLevel === "DEV-LIVE"/u, "M3 controls must require live readiness evidence level");
|
|
|
|
const statusBody = functionBody(app, "renderM3ControlStatus");
|
|
assert.match(statusBody, /m3ControlStatusTitle\(\{ operation, contract, controlReady \}\)/u, "ready status title must use the control-ready-aware helper");
|
|
assert.match(statusBody, /renderM3FlowSummary\(\{ label, tone, operation, contract, controlReady, pending \}\)/u, "control status must keep the operator summary in sync");
|
|
|
|
const flowSummaryBody = functionBody(app, "renderM3FlowSummary");
|
|
for (const term of ["m3ActionSummary", "m3Do1Target", "m3Di1Observed", "m3TrustSummary", "m3TraceSummary"]) {
|
|
assert.match(flowSummaryBody, new RegExp(`el\\.${term}\\.textContent`, "u"), `M3 flow summary must update ${term}`);
|
|
}
|
|
assert.match(flowSummaryBody, /m3OperationDo1Target\(operation/u, "M3 flow summary must show the DO1 target");
|
|
assert.match(flowSummaryBody, /m3OperationDi1Observed\(operation/u, "M3 flow summary must show the DI1 observation");
|
|
assert.match(flowSummaryBody, /m3TrustSummaryText\(operation, contract\)/u, "M3 flow summary must show trusted/durable status");
|
|
assert.match(flowSummaryBody, /m3TraceSummaryText\(operation, status\)/u, "M3 flow summary must show recent operation/trace ids");
|
|
|
|
const statusTitleBody = functionBody(app, "m3ControlStatusTitle");
|
|
assert.match(statusTitleBody, /if \(controlReady\)/u, "ready status title must not fall through to blocked copy");
|
|
assert.match(statusTitleBody, /只读 readiness 已确认/u, "ready status title must show confirmed Chinese readiness copy");
|
|
assert.match(statusTitleBody, /return m3ControlBlockedReason\(contract\)/u, "blocked status title must still surface Chinese blocked reason");
|
|
|
|
const controlRowsBody = functionBody(app, "controlRows");
|
|
assert.match(controlRowsBody, /选择 DO1 true\/false 后点击/u, "control copy must start with an operator action script");
|
|
assert.match(controlRowsBody, /最近 trace 与 operation/u, "control copy must direct users to recent trace records");
|
|
assert.match(controlRowsBody, /按钮只走受控后端/u, "control copy must describe the controlled backend route");
|
|
assert.match(controlRowsBody, /gateway-simu -> box-simu -> hwlab-patch-panel/u, "control copy must show backend-owned path");
|
|
assert.match(controlRowsBody, /m3ControlBlockedReason\(\)/u, "blocked control copy must surface the cloud-api Chinese readiness reason");
|
|
assert.match(controlRowsBody, /受控路径读取/u, "control rows must describe DI read affordance");
|
|
|
|
const operationDetailBody = functionBody(app, "m3OperationDetail");
|
|
for (const field of ["operation", "trace", "audit", "evidence"]) {
|
|
assert.match(operationDetailBody, new RegExp(`recordField\\("${field}"`, "u"), `operation detail missing ${field}`);
|
|
}
|
|
assert.match(operationDetailBody, /operation\.blocker\?\.zh/u, "operation detail must prefer Chinese blocker copy");
|
|
assert.match(operationDetailBody, /DO1目标=/u, "operation detail must expose DO1 target");
|
|
assert.match(operationDetailBody, /DI1观测=/u, "operation detail must expose DI1 observation");
|
|
|
|
const operationRecordBody = functionBody(app, "operationRecordCards");
|
|
for (const field of ["action", "trace", "audit", "evidence"]) {
|
|
assert.match(operationRecordBody, new RegExp(`recordField\\("${field}"`, "u"), `operation record missing ${field}`);
|
|
}
|
|
assert.match(operationRecordBody, /DO1目标=/u, "operation records must expose DO1 target");
|
|
assert.match(operationRecordBody, /DI1观测=/u, "operation records must expose DI1 observation");
|
|
assert.match(operationRecordBody, /trusted=/u, "operation records must expose trusted state");
|
|
assert.match(operationRecordBody, /durable=/u, "operation records must expose durable state");
|
|
assert.match(operationRecordBody, /via=\/v1\/m3\/io/u, "operation record must show the control route");
|
|
assert.match(operationRecordBody, /frontendBypass=false/u, "operation record must show no frontend bypass");
|
|
}
|
|
|
|
function assertBrowserWriteBoundaries({ app, html, artifactPublisher, cloudWebRouteSource }) {
|
|
const browserSource = `${html}\n${app}`;
|
|
const sanitizedBrowserSource = browserSource.replace(/data-static-source-fallback="SOURCE"/gu, "");
|
|
const frontendGuard = checkFrontendNoDirectRuntimeCalls({
|
|
appSource: app,
|
|
htmlSource: html,
|
|
artifactPublisherSource: artifactPublisher,
|
|
cloudWebRouteSource
|
|
});
|
|
assert.equal(frontendGuard.status, "pass", frontendGuard.issues.join("\n"));
|
|
assert.deepEqual(
|
|
uniqueSorted(rpcCallsFrom(app)),
|
|
[...readOnlyRpcMethods].sort(),
|
|
"browser JSON-RPC calls must stay read-only"
|
|
);
|
|
|
|
const postTargets = [...app.matchAll(/fetchJson\(\s*["']([^"']+)["'][\s\S]{0,260}?method:\s*["']POST["']/gu)]
|
|
.map((match) => match[1]);
|
|
assert.deepEqual(
|
|
uniqueSorted(postTargets),
|
|
["/json-rpc", "/v1/agent/chat", "/v1/agent/chat/cancel", M3_IO_CONTROL_ROUTE].sort(),
|
|
"browser POST routes must be limited to read-only RPC envelope, agent chat lifecycle, and M3 IO control"
|
|
);
|
|
|
|
const controlWriteTargets = postTargets.filter((target) => target !== "/json-rpc" && target !== "/v1/agent/chat" && target !== "/v1/agent/chat/cancel");
|
|
assert.deepEqual(controlWriteTargets, [M3_IO_CONTROL_ROUTE], "same-origin /v1/m3/io is the only browser hardware write control route");
|
|
assert.match(app, /fetchJson\("\/v1\/m3\/status"\)/u, "browser hardware status reads must use same-origin /v1/m3/status");
|
|
|
|
for (const [pattern, label] of [
|
|
[/\bcallRpc\(\s*["']hardware\./u, "generic hardware JSON-RPC"],
|
|
[/hardware\.operation\.request/u, "hardware operation RPC"],
|
|
[/hardware\.invoke\.shell/u, "hardware shell RPC"],
|
|
[/audit\.event\.write/u, "audit write RPC"],
|
|
[/evidence\.record\.write/u, "evidence write RPC"],
|
|
[/\/v1\/rpc\//u, "direct REST RPC bridge"],
|
|
[/\bfetch(?:Json)?\(\s*["'`][^"'`]*(?:gateway-simu|box-simu|patch-panel|:7101|:7201|:7301)/iu, "direct simulator fetch"],
|
|
[/https?:\/\/[^"'`\s]*(?:gateway-simu|box-simu|patch-panel|:7101|:7201|:7301)/iu, "direct simulator URL"],
|
|
[/\/wiring\/apply|\/wiring\/reload|\/signals\/route/u, "patch-panel write endpoint"]
|
|
]) {
|
|
assert.doesNotMatch(sanitizedBrowserSource, pattern, `browser source must not expose ${label}`);
|
|
}
|
|
}
|
|
|
|
function assertNoStaticDevLiveClaim({ html, app }) {
|
|
const controlPanel = elementSectionById(html, "panel-control");
|
|
const wiringPanel = elementSectionById(html, "panel-wiring");
|
|
assert.doesNotMatch(`${controlPanel}\n${wiringPanel}`, /tone-dev-live|开发实况 DEV-LIVE/u, "static M3 panels must not claim DEV-LIVE");
|
|
assert.match(`${controlPanel}\n${wiringPanel}`, /tone-blocked|待可信记录/u, "static M3 panels must keep blocked/source posture");
|
|
assert.doesNotMatch(
|
|
functionBody(app, "renderM3ControlStatus"),
|
|
/contract\?\.status === "available"[\s\S]{0,120}disabled/u,
|
|
"M3 status rendering must not unlock controls from route availability alone"
|
|
);
|
|
}
|
|
|
|
function rpcCallsFrom(source) {
|
|
return [...source.matchAll(/callRpc\(\s*["']([^"']+)["']/gu)].map((match) => match[1]);
|
|
}
|
|
|
|
function uniqueSorted(values) {
|
|
return [...new Set(values)].sort();
|
|
}
|
|
|
|
function elementSectionById(source, id) {
|
|
const start = source.search(new RegExp(`<section\\b[^>]*\\bid=["']${escapeRegExp(id)}["'][^>]*>`, "u"));
|
|
assert.notEqual(start, -1, `missing section #${id}`);
|
|
const end = source.indexOf("</section>", start);
|
|
assert.notEqual(end, -1, `unterminated section #${id}`);
|
|
return source.slice(start, end + "</section>".length);
|
|
}
|
|
|
|
function constObjectSource(source, constName) {
|
|
const match = source.match(new RegExp(`const\\s+${escapeRegExp(constName)}\\s*=\\s*Object\\.freeze\\(\\{`, "u"));
|
|
if (!match) return "";
|
|
const start = match.index;
|
|
const end = source.indexOf("});", start);
|
|
return end === -1 ? source.slice(start) : source.slice(start, end + 3);
|
|
}
|
|
|
|
function functionBody(source, functionName) {
|
|
const match = source.match(new RegExp(`function\\s+${escapeRegExp(functionName)}\\s*\\([^)]*\\)\\s*\\{`, "u"));
|
|
if (!match) return "";
|
|
let depth = 0;
|
|
for (let index = match.index + match[0].length - 1; index < source.length; index += 1) {
|
|
const char = source[index];
|
|
if (char === "{") depth += 1;
|
|
if (char === "}") {
|
|
depth -= 1;
|
|
if (depth === 0) return source.slice(match.index, index + 1);
|
|
}
|
|
}
|
|
return "";
|
|
}
|
|
|
|
function escapeRegExp(value) {
|
|
return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
}
|
|
|
|
if (process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1])) {
|
|
runM3ControlPanelGuard();
|
|
console.log("M3 control panel SOURCE/DOM guard passed");
|
|
}
|