443 lines
14 KiB
JavaScript
443 lines
14 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import test from "node:test";
|
|
|
|
import { createCloudRuntimeStore } from "../db/runtime-store.mjs";
|
|
import { createBoxState, createGatewayState } from "../sim/model.mjs";
|
|
import { writeBoxPort } from "../sim/l2-runtime.mjs";
|
|
import {
|
|
M3_IO_BLOCKER_CODES,
|
|
M3_IO_CHAIN,
|
|
describeM3IoControl,
|
|
handleM3IoControl
|
|
} from "./m3-io-control.mjs";
|
|
|
|
const fixedNow = "2026-05-23T00:00:00.000Z";
|
|
|
|
test("M3 IO control describes a cloud-api-only control surface contract", () => {
|
|
const contract = describeM3IoControl({
|
|
env: {
|
|
HWLAB_M3_IO_CONTROL_ENABLED: "true"
|
|
}
|
|
});
|
|
|
|
assert.equal(contract.status, "available");
|
|
assert.equal(contract.route, "/v1/m3/io");
|
|
assert.equal(contract.boundaries.frontendCallsOnly, "/v1/m3/io");
|
|
assert.equal(contract.boundaries.cloudApiDispatchesToGateway, true);
|
|
assert.equal(contract.boundaries.patchPanelOwnsPropagation, true);
|
|
assert.equal(contract.boundaries.directFrontendGatewayOrBoxAccess, false);
|
|
assert.equal(contract.boundaries.genericHardwareRpcExposedToFrontend, false);
|
|
assert.deepEqual(contract.chain, M3_IO_CHAIN);
|
|
});
|
|
|
|
test("M3 DO write dispatches through gateway-simu, ticks patch-panel, reads DI, and records audit/evidence fields", async () => {
|
|
const fixture = createM3ControlFixture();
|
|
const runtimeStore = createCloudRuntimeStore({
|
|
now: () => fixedNow
|
|
});
|
|
|
|
const result = await handleM3IoControl(
|
|
{
|
|
action: "do.write",
|
|
gatewayId: "gwsimu_1",
|
|
resourceId: "res_boxsimu_1",
|
|
boxId: "boxsimu_1",
|
|
port: "DO1",
|
|
value: true,
|
|
traceId: "trc_m3_control_write_true",
|
|
requestId: "req_m3_control_write_true",
|
|
actorId: "usr_m3_operator"
|
|
},
|
|
{
|
|
runtimeStore,
|
|
now: () => fixedNow,
|
|
env: {
|
|
HWLAB_M3_GATEWAY_SIMU_1_URL: "http://gateway-1",
|
|
HWLAB_M3_GATEWAY_SIMU_2_URL: "http://gateway-2",
|
|
HWLAB_M3_PATCH_PANEL_URL: "http://patch-panel"
|
|
},
|
|
requestJson: fixture.requestJson
|
|
}
|
|
);
|
|
|
|
assert.equal(result.status, "succeeded");
|
|
assert.equal(result.accepted, true);
|
|
assert.equal(result.action, "do.write");
|
|
assert.equal(result.command.port, "DO1");
|
|
assert.equal(result.command.value, true);
|
|
assert.match(result.operationId, /^op_m3_do_write_/u);
|
|
assert.equal(result.traceId, "trc_m3_control_write_true");
|
|
assert.match(result.auditId, /^aud_m3_do_write_/u);
|
|
assert.match(result.evidenceId, /^evd_m3_do_write_/u);
|
|
assert.equal(result.controlPath.cloudApi, true);
|
|
assert.equal(result.controlPath.gatewaySimu, true);
|
|
assert.equal(result.controlPath.boxSimu, true);
|
|
assert.equal(result.controlPath.patchPanel, true);
|
|
assert.equal(result.controlPath.frontendBypass, false);
|
|
assert.equal(result.result.value, true);
|
|
assert.equal(result.result.targetReadback.value, true);
|
|
assert.equal(result.evidenceState.status, "blocked");
|
|
assert.equal(result.evidenceState.sourceKind, "BLOCKED");
|
|
assert.match(result.evidenceState.reason, /runtime durable 未 green/u);
|
|
assert.equal(fixture.box1.ports.DO1.value, true);
|
|
assert.equal(fixture.box2.ports.DI1.value, true);
|
|
assert.deepEqual(fixture.calls.map((call) => call.path), [
|
|
"/status",
|
|
"/invoke",
|
|
"/sync/tick",
|
|
"/status",
|
|
"/invoke"
|
|
]);
|
|
assert.equal(fixture.calls.every((call) => call.url.startsWith("http://gateway-") || call.url.startsWith("http://patch-panel")), true);
|
|
|
|
const audit = runtimeStore.queryAuditEvents({ projectId: M3_IO_CHAIN.projectId });
|
|
assert.ok(audit.events.some((event) => event.action === "m3.io.do.write"));
|
|
const evidence = runtimeStore.queryEvidenceRecords({ projectId: M3_IO_CHAIN.projectId });
|
|
assert.equal(evidence.count, 1);
|
|
assert.equal(evidence.records[0].operationId, result.operationId);
|
|
});
|
|
|
|
test("M3 DI read returns structured value through gateway-simu without patch-panel mutation", async () => {
|
|
const fixture = createM3ControlFixture();
|
|
writeBoxPort({
|
|
state: fixture.box2,
|
|
port: "DI1",
|
|
value: true,
|
|
source: "patch-panel"
|
|
});
|
|
|
|
const result = await handleM3IoControl(
|
|
{
|
|
action: "di.read",
|
|
gatewayId: "gwsimu_2",
|
|
resourceId: "res_boxsimu_2",
|
|
boxId: "boxsimu_2",
|
|
port: "DI1",
|
|
traceId: "trc_m3_control_read_di",
|
|
requestId: "req_m3_control_read_di",
|
|
actorId: "usr_m3_operator"
|
|
},
|
|
{
|
|
runtimeStore: createCloudRuntimeStore({ now: () => fixedNow }),
|
|
now: () => fixedNow,
|
|
env: {
|
|
HWLAB_M3_GATEWAY_SIMU_1_URL: "http://gateway-1",
|
|
HWLAB_M3_GATEWAY_SIMU_2_URL: "http://gateway-2",
|
|
HWLAB_M3_PATCH_PANEL_URL: "http://patch-panel"
|
|
},
|
|
requestJson: fixture.requestJson
|
|
}
|
|
);
|
|
|
|
assert.equal(result.status, "succeeded");
|
|
assert.equal(result.accepted, true);
|
|
assert.equal(result.action, "di.read");
|
|
assert.equal(result.command.port, "DI1");
|
|
assert.equal(result.result.value, true);
|
|
assert.equal(result.controlPath.patchPanel, false);
|
|
assert.deepEqual(fixture.calls.map((call) => call.path), ["/status", "/invoke"]);
|
|
});
|
|
|
|
test("M3 IO control blocks with Chinese reason when gateway session is unavailable", async () => {
|
|
const result = await handleM3IoControl(
|
|
{
|
|
action: "do.write",
|
|
value: true,
|
|
traceId: "trc_m3_gateway_missing",
|
|
requestId: "req_m3_gateway_missing",
|
|
actorId: "usr_m3_operator"
|
|
},
|
|
{
|
|
runtimeStore: createCloudRuntimeStore({ now: () => fixedNow }),
|
|
now: () => fixedNow,
|
|
env: {
|
|
HWLAB_M3_GATEWAY_SIMU_1_URL: "http://gateway-1",
|
|
HWLAB_M3_GATEWAY_SIMU_2_URL: "http://gateway-2",
|
|
HWLAB_M3_PATCH_PANEL_URL: "http://patch-panel"
|
|
},
|
|
requestJson: async () => ({
|
|
ok: false,
|
|
status: 503,
|
|
body: {
|
|
error: {
|
|
message: "unreachable"
|
|
}
|
|
}
|
|
})
|
|
}
|
|
);
|
|
|
|
assert.equal(result.status, "blocked");
|
|
assert.equal(result.accepted, false);
|
|
assert.equal(result.blocker.code, M3_IO_BLOCKER_CODES.gatewayUnavailable);
|
|
assert.match(result.blocker.zh, /gateway 未注册\/不可用/u);
|
|
assert.equal(result.controlPath.cloudApi, true);
|
|
assert.equal(result.controlPath.frontendBypass, false);
|
|
});
|
|
|
|
test("M3 IO control blocks invalid port direction instead of allowing generic writes", async () => {
|
|
const result = await handleM3IoControl(
|
|
{
|
|
action: "do.write",
|
|
gatewayId: "gwsimu_1",
|
|
resourceId: "res_boxsimu_1",
|
|
port: "DI1",
|
|
value: true,
|
|
traceId: "trc_m3_invalid_port",
|
|
requestId: "req_m3_invalid_port",
|
|
actorId: "usr_m3_operator"
|
|
},
|
|
{
|
|
runtimeStore: createCloudRuntimeStore({ now: () => fixedNow }),
|
|
now: () => fixedNow,
|
|
env: {
|
|
HWLAB_M3_GATEWAY_SIMU_1_URL: "http://gateway-1",
|
|
HWLAB_M3_GATEWAY_SIMU_2_URL: "http://gateway-2",
|
|
HWLAB_M3_PATCH_PANEL_URL: "http://patch-panel"
|
|
},
|
|
requestJson: async () => {
|
|
throw new Error("gateway must not be called for invalid port direction");
|
|
}
|
|
}
|
|
);
|
|
|
|
assert.equal(result.status, "blocked");
|
|
assert.equal(result.blocker.code, M3_IO_BLOCKER_CODES.portDirectionInvalid);
|
|
assert.match(result.blocker.zh, /端口方向|资源越界/u);
|
|
});
|
|
|
|
test("M3 IO control fails closed when indexed gateway identity drifts", async () => {
|
|
const fixture = createM3ControlFixture({
|
|
gateway1Id: "gwsimu_2"
|
|
});
|
|
|
|
const result = await handleM3IoControl(
|
|
{
|
|
action: "do.write",
|
|
gatewayId: "gwsimu_1",
|
|
resourceId: "res_boxsimu_1",
|
|
boxId: "boxsimu_1",
|
|
port: "DO1",
|
|
value: true,
|
|
traceId: "trc_m3_gateway_identity_drift",
|
|
requestId: "req_m3_gateway_identity_drift",
|
|
actorId: "usr_m3_operator"
|
|
},
|
|
{
|
|
runtimeStore: createCloudRuntimeStore({ now: () => fixedNow }),
|
|
now: () => fixedNow,
|
|
env: {
|
|
HWLAB_M3_GATEWAY_SIMU_1_URL: "http://gateway-1",
|
|
HWLAB_M3_GATEWAY_SIMU_2_URL: "http://gateway-2",
|
|
HWLAB_M3_PATCH_PANEL_URL: "http://patch-panel"
|
|
},
|
|
requestJson: fixture.requestJson
|
|
}
|
|
);
|
|
|
|
assert.equal(result.status, "blocked");
|
|
assert.equal(result.accepted, false);
|
|
assert.equal(result.blocker.code, M3_IO_BLOCKER_CODES.gatewayIdentityMismatch);
|
|
assert.match(result.blocker.zh, /identity mismatch/u);
|
|
assert.deepEqual(fixture.calls.map((call) => call.path), ["/status"]);
|
|
assert.equal(fixture.box1.ports.DO1.value, false);
|
|
assert.equal(fixture.box2.ports.DI1.value, false);
|
|
});
|
|
|
|
test("M3 IO control blocks when patch-panel wiring does not deliver DO1 to DI1", async () => {
|
|
const fixture = createM3ControlFixture({
|
|
patchPanelBody: {
|
|
accepted: true,
|
|
routes: [
|
|
{
|
|
accepted: true,
|
|
deliveries: []
|
|
}
|
|
]
|
|
}
|
|
});
|
|
|
|
const result = await handleM3IoControl(
|
|
{
|
|
action: "do.write",
|
|
value: false,
|
|
traceId: "trc_m3_wiring_missing",
|
|
requestId: "req_m3_wiring_missing",
|
|
actorId: "usr_m3_operator"
|
|
},
|
|
{
|
|
runtimeStore: createCloudRuntimeStore({ now: () => fixedNow }),
|
|
now: () => fixedNow,
|
|
env: {
|
|
HWLAB_M3_GATEWAY_SIMU_1_URL: "http://gateway-1",
|
|
HWLAB_M3_GATEWAY_SIMU_2_URL: "http://gateway-2",
|
|
HWLAB_M3_PATCH_PANEL_URL: "http://patch-panel"
|
|
},
|
|
requestJson: fixture.requestJson
|
|
}
|
|
);
|
|
|
|
assert.equal(result.status, "blocked");
|
|
assert.equal(result.accepted, false);
|
|
assert.equal(result.blocker.code, M3_IO_BLOCKER_CODES.wiringMissing);
|
|
assert.match(result.blocker.zh, /wiring 不存在|未路由/u);
|
|
});
|
|
|
|
function createM3ControlFixture({ patchPanelBody, gateway1Id = "gwsimu_1", gateway2Id = "gwsimu_2" } = {}) {
|
|
const calls = [];
|
|
const box1 = createBoxState({
|
|
boxId: "boxsimu_1",
|
|
gatewaySessionId: "gws_gwsimu_1",
|
|
ports: ["DO1", "DI1"],
|
|
now: fixedNow
|
|
});
|
|
box1.resource.resourceId = "res_boxsimu_1";
|
|
box1.capabilities = box1.capabilities.map((capability) => ({
|
|
...capability,
|
|
resourceId: "res_boxsimu_1",
|
|
projectId: M3_IO_CHAIN.projectId
|
|
}));
|
|
box1.projectId = M3_IO_CHAIN.projectId;
|
|
box1.resource.projectId = M3_IO_CHAIN.projectId;
|
|
|
|
const box2 = createBoxState({
|
|
boxId: "boxsimu_2",
|
|
gatewaySessionId: "gws_gwsimu_2",
|
|
ports: ["DO1", "DI1"],
|
|
now: fixedNow
|
|
});
|
|
box2.resource.resourceId = "res_boxsimu_2";
|
|
box2.capabilities = box2.capabilities.map((capability) => ({
|
|
...capability,
|
|
resourceId: "res_boxsimu_2",
|
|
projectId: M3_IO_CHAIN.projectId
|
|
}));
|
|
box2.projectId = M3_IO_CHAIN.projectId;
|
|
box2.resource.projectId = M3_IO_CHAIN.projectId;
|
|
|
|
const gateway1 = createGatewayStatus(gateway1Id, "http://gateway-1", [box1]);
|
|
const gateway2 = createGatewayStatus(gateway2Id, "http://gateway-2", [box2]);
|
|
|
|
async function requestJson(url, { method = "GET", body } = {}) {
|
|
const parsed = new URL(url);
|
|
calls.push({
|
|
url,
|
|
method,
|
|
path: parsed.pathname,
|
|
body
|
|
});
|
|
|
|
if (url.startsWith("http://gateway-1") && parsed.pathname === "/status") {
|
|
return { ok: true, status: 200, body: gateway1 };
|
|
}
|
|
if (url.startsWith("http://gateway-2") && parsed.pathname === "/status") {
|
|
return { ok: true, status: 200, body: gateway2 };
|
|
}
|
|
if (url.startsWith("http://gateway-1") && parsed.pathname === "/invoke") {
|
|
return {
|
|
ok: true,
|
|
status: 200,
|
|
body: writeBoxPort({
|
|
state: box1,
|
|
port: body.port,
|
|
capabilityId: body.capabilityId,
|
|
value: body.value,
|
|
source: "gateway-simu"
|
|
})
|
|
};
|
|
}
|
|
if (url.startsWith("http://gateway-2") && parsed.pathname === "/invoke") {
|
|
return {
|
|
ok: true,
|
|
status: 200,
|
|
body: {
|
|
accepted: true,
|
|
operationId: body.operationId,
|
|
traceId: body.traceId,
|
|
boxId: box2.boxId,
|
|
resourceId: box2.resource.resourceId,
|
|
capabilityId: body.capabilityId,
|
|
port: body.port,
|
|
value: box2.ports[body.port].value,
|
|
state: box2.ports[body.port],
|
|
auditId: "aud_box2_read",
|
|
evidenceId: "evd_box2_read"
|
|
}
|
|
};
|
|
}
|
|
if (url.startsWith("http://patch-panel") && parsed.pathname === "/sync/tick") {
|
|
if (patchPanelBody) {
|
|
return { ok: true, status: 200, body: patchPanelBody };
|
|
}
|
|
box2.ports.DI1 = {
|
|
...box2.ports.DI1,
|
|
value: body.signals[0].value,
|
|
source: "patch-panel",
|
|
sourceResourceId: "res_boxsimu_1",
|
|
sourcePort: "DO1",
|
|
propagatedBy: "hwlab-patch-panel",
|
|
updatedAt: fixedNow
|
|
};
|
|
return {
|
|
ok: true,
|
|
status: 200,
|
|
body: {
|
|
accepted: true,
|
|
propagatedBy: "hwlab-patch-panel",
|
|
routeCount: 1,
|
|
deliveryCount: 1,
|
|
routes: [
|
|
{
|
|
accepted: true,
|
|
deliveryCount: 1,
|
|
deliveries: [
|
|
{
|
|
resourceId: "res_boxsimu_2",
|
|
port: "DI1",
|
|
value: body.signals[0].value,
|
|
sourceResourceId: "res_boxsimu_1",
|
|
sourcePort: "DO1",
|
|
deliveryStatus: "applied"
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
};
|
|
}
|
|
|
|
throw new Error(`unexpected request ${method} ${url}`);
|
|
}
|
|
|
|
return {
|
|
calls,
|
|
box1,
|
|
box2,
|
|
requestJson
|
|
};
|
|
}
|
|
|
|
function createGatewayStatus(gatewayId, endpoint, boxes) {
|
|
const state = createGatewayState({
|
|
gatewayId,
|
|
endpoint,
|
|
boxes: boxes.map((box) => box.resource.resourceId),
|
|
now: fixedNow
|
|
});
|
|
state.projectId = M3_IO_CHAIN.projectId;
|
|
state.session.projectId = M3_IO_CHAIN.projectId;
|
|
state.registry = {
|
|
gatewayId,
|
|
gatewaySessionId: state.session.gatewaySessionId,
|
|
endpoint,
|
|
boxes: boxes.map((box) => ({
|
|
boxId: box.boxId,
|
|
resourceId: box.resource.resourceId,
|
|
url: `http://${box.boxId}`,
|
|
state: "registered",
|
|
capabilities: box.capabilities
|
|
}))
|
|
};
|
|
return state;
|
|
}
|