test(qa): record Matrix route state inventory (#100039)

This commit is contained in:
Dallin Romney
2026-07-04 11:48:18 -07:00
committed by GitHub
parent 5304d006e5
commit 939f9aeff5
15 changed files with 2021 additions and 73 deletions
+14 -12
View File
@@ -23,12 +23,13 @@ Plain `pnpm openclaw qa matrix` runs `--profile all` and does not stop on first
## What the lane does
1. Provisions a disposable Tuwunel homeserver in Docker (default image `ghcr.io/matrix-construct/tuwunel:v1.5.1`, server name `matrix-qa.test`, port `28008`).
1. Provisions a disposable Tuwunel homeserver in Docker (default image `ghcr.io/matrix-construct/tuwunel:v1.5.1`, server name `matrix-qa.test`, port `28008`) behind a bounded redacting request/response recorder.
2. Registers three temporary users - `driver` (sends inbound traffic), `sut` (the OpenClaw Matrix account under test), `observer` (third-party traffic capture).
3. Seeds rooms required by the selected scenarios (main, threading, media, restart, secondary, allowlist, E2EE, verification DM, etc.).
4. Starts a child OpenClaw gateway with the real Matrix plugin scoped to the SUT account; `qa-channel` is not loaded in the child.
5. Runs scenarios in sequence, observing events through the driver/observer Matrix clients.
6. Tears down the homeserver, writes report and summary artifacts, then exits.
4. Runs the substrate-neutral `matrix-qa-v1` protocol probe against the recorded Tuwunel boundary. Unit tests prove the probe contract with the Matrix protocol fixture; the canonical QA transport adapter host in [#99707](https://github.com/openclaw/openclaw/pull/99707) owns real Crabline target wiring.
5. Starts a child OpenClaw gateway with the real Matrix plugin scoped to the SUT account; `qa-channel` is not loaded in the child.
6. Runs scenarios in sequence, observing events through the driver/observer Matrix clients and deriving route/state expectations from the recorded traffic.
7. Tears down the homeserver, writes report and evidence artifacts, then exits.
## CLI
@@ -38,14 +39,14 @@ pnpm openclaw qa matrix [options]
### Common flags
| Flag | Default | Description |
| --------------------- | --------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
| `--profile <profile>` | `all` | Scenario profile. See [Profiles](#profiles). |
| `--fail-fast` | off | Stop after the first failed check or scenario. |
| `--scenario <id>` | - | Run only this scenario. Repeatable. See [Scenarios](#scenarios). |
| `--output-dir <path>` | `<repo>/.artifacts/qa-e2e/matrix-<timestamp>` | Where reports, summary, observed events, and the output log are written. Relative paths resolve against `--repo-root`. |
| `--repo-root <path>` | `process.cwd()` | Repository root when invoking from a neutral working directory. |
| `--sut-account <id>` | `sut` | Matrix account id inside the QA gateway config. |
| Flag | Default | Description |
| --------------------- | --------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| `--profile <profile>` | `all` | Scenario profile. See [Profiles](#profiles). |
| `--fail-fast` | off | Stop after the first failed check or scenario. |
| `--scenario <id>` | - | Run only this scenario. Repeatable. See [Scenarios](#scenarios). |
| `--output-dir <path>` | `<repo>/.artifacts/qa-e2e/matrix-<timestamp>` | Where reports, summary, route/state inventory, observed events, and the output log are written. Relative paths resolve against `--repo-root`. |
| `--repo-root <path>` | `process.cwd()` | Repository root when invoking from a neutral working directory. |
| `--sut-account <id>` | `sut` | Matrix account id inside the QA gateway config. |
### Provider flags
@@ -114,6 +115,7 @@ Written to `--output-dir`:
- `matrix-qa-report.md` - Markdown protocol report (what passed, failed, was skipped, and why).
- `matrix-qa-summary.json` - Structured summary suitable for CI parsing and dashboards.
- `matrix-qa-route-state-manifest.json` - Dynamic `matrix-qa-v1` inventory keyed by scenario id. It records redacted route/body shapes, request ordering, observed retries, errors, sync-token continuity, and device/key/media/backup state families observed during that run. This is executable evidence, not a checked-in baseline.
- `matrix-qa-observed-events.json` - Observed Matrix events from the driver and observer clients. Bodies are redacted unless `OPENCLAW_QA_MATRIX_CAPTURE_CONTENT=1`; approval metadata is summarized with selected safe fields and truncated command preview.
- `matrix-qa-output.log` - Combined stdout/stderr from the run. If `OPENCLAW_RUN_NODE_OUTPUT_LOG` is set, the outer launcher's log is reused instead.
@@ -63,6 +63,7 @@ describe("matrix qa cli runtime", () => {
reportPath: "/tmp/matrix-report.md",
summaryPath: "/tmp/matrix-summary.json",
observedEventsPath: "/tmp/matrix-events.json",
routeStateManifestPath: "/tmp/matrix-route-state.json",
});
const originalStdoutWrite = process.stdout["write"];
process.stdout.write = (() => true) as typeof process.stdout.write;
@@ -104,6 +105,7 @@ describe("matrix qa cli runtime", () => {
reportPath: "/tmp/matrix-report.md",
summaryPath: "/tmp/matrix-summary.json",
observedEventsPath: "/tmp/matrix-events.json",
routeStateManifestPath: "/tmp/matrix-route-state.json",
});
const originalStdoutWrite = process.stdout["write"];
process.stdout.write = vi.fn(() => true) as unknown as typeof process.stdout.write;
+1
View File
@@ -72,6 +72,7 @@ export async function runQaMatrixCommand(opts: LiveTransportQaCommandOptions) {
const result = await runMatrixQaLive(checkedRunOptions);
printLiveTransportQaArtifacts("Matrix QA", {
report: result.reportPath,
"route/state manifest": result.routeStateManifestPath,
summary: result.summaryPath,
"observed events": result.observedEventsPath,
});
@@ -37,6 +37,7 @@ function buildMatrixQaSummaryInput(
artifactPaths: {
observedEvents: "/tmp/observed.json",
report: "/tmp/report.md",
routeStateManifest: "/tmp/route-state.json",
summary: "/tmp/summary.json",
},
checks: [{ name: "Matrix harness ready", status: "pass" }],
@@ -53,6 +54,11 @@ function buildMatrixQaSummaryInput(
}),
scenarios: [],
},
differentialProbe: {
profile: "matrix-qa-v1",
steps: [],
sync: { continuity: true, incrementalStatus: 200, initialStatus: 200 },
},
finishedAt: "2026-04-10T10:05:00.000Z",
harness: {
baseUrl: "http://127.0.0.1:28008/",
@@ -78,6 +84,25 @@ function buildMatrixQaSummaryInput(
}
describe("matrix live qa runtime", () => {
it("preserves a failed differential probe check without a probe payload", () => {
const summary = liveTesting.buildMatrixQaSummary(
buildMatrixQaSummaryInput({
checks: [
{ name: "Matrix harness ready", status: "pass" },
{
details: "missing-state response did not return M_NOT_FOUND",
name: "Matrix differential probe",
status: "fail",
},
],
differentialProbe: undefined,
}),
);
expect(summary.differentialProbe).toBeUndefined();
expect(summary.counts).toEqual({ failed: 1, passed: 1, total: 2 });
});
it("uses unique default artifact directories", () => {
const repoRoot = "/repo";
const firstOutputDir = liveTesting.resolveMatrixQaOutputDir({ repoRoot });
@@ -86,9 +111,9 @@ describe("matrix live qa runtime", () => {
expect(path.dirname(firstOutputDir)).toBe(path.join(repoRoot, ".artifacts", "qa-e2e"));
expect(path.basename(firstOutputDir)).toMatch(/^matrix-[a-z0-9]+-[a-f0-9]{8}$/u);
expect(secondOutputDir).not.toBe(firstOutputDir);
expect(
liveTesting.resolveMatrixQaOutputDir({ outputDir: ".artifacts/custom", repoRoot }),
).toBe(".artifacts/custom");
expect(liveTesting.resolveMatrixQaOutputDir({ outputDir: ".artifacts/custom", repoRoot })).toBe(
".artifacts/custom",
);
});
it("prints Matrix QA progress by default for non-interactive runs", () => {
@@ -388,6 +413,7 @@ describe("matrix live qa runtime", () => {
artifactPaths: {
observedEvents: "/tmp/observed.json",
report: "/tmp/report.md",
routeStateManifest: "/tmp/route-state.json",
summary: "/tmp/summary.json",
},
checks: [{ name: "Matrix harness ready", status: "pass" }],
@@ -450,6 +476,11 @@ describe("matrix live qa runtime", () => {
},
],
},
differentialProbe: {
profile: "matrix-qa-v1",
steps: [],
sync: { continuity: true, incrementalStatus: 200, initialStatus: 200 },
},
finishedAt: "2026-04-10T10:05:00.000Z",
harness: {
baseUrl: "http://127.0.0.1:28008/",
@@ -18,6 +18,7 @@ import {
type QaReportCheck,
} from "openclaw/plugin-sdk/qa-runtime";
import { normalizeQaProviderMode, type QaProviderModeInput } from "../../run-config.js";
import { createLiveTransportQaRunId } from "../../shared/live-transport-artifacts.js";
import { buildMatrixQaObservedEventsArtifact } from "../../substrate/artifacts.js";
import { provisionMatrixQaRoom, type MatrixQaProvisionResult } from "../../substrate/client.js";
import {
@@ -27,9 +28,12 @@ import {
type MatrixQaConfigOverrides,
type MatrixQaConfigSnapshot,
} from "../../substrate/config.js";
import {
runMatrixQaDifferentialProbe,
type MatrixQaDifferentialProbeResult,
} from "../../substrate/differential-probe.js";
import type { MatrixQaObservedEvent } from "../../substrate/events.js";
import { startMatrixQaHarness } from "../../substrate/harness.runtime.js";
import { createLiveTransportQaRunId } from "../../shared/live-transport-artifacts.js";
import { resolveMatrixQaModels, type ResolvedMatrixQaModels } from "./model-selection.js";
import type { MatrixQaSyncStreams } from "./scenario-runtime-shared.js";
import {
@@ -135,9 +139,11 @@ type MatrixQaSummary = {
serverName: string;
};
canary?: MatrixQaCanaryArtifact;
differentialProbe?: MatrixQaDifferentialProbeResult;
observedEventCount: number;
observedEventsPath: string;
reportPath: string;
routeStateManifestPath: string;
scenarios: MatrixQaScenarioResult[];
startedAt: string;
summaryPath: string;
@@ -153,6 +159,7 @@ type MatrixQaSummary = {
type MatrixQaArtifactPaths = {
observedEvents: string;
report: string;
routeStateManifest: string;
summary: string;
};
@@ -468,6 +475,7 @@ type MatrixQaRunResult = {
observedEventsPath: string;
outputDir: string;
reportPath: string;
routeStateManifestPath: string;
scenarios: MatrixQaScenarioResult[];
summaryPath: string;
};
@@ -477,6 +485,7 @@ function buildMatrixQaSummary(params: {
canary?: MatrixQaCanaryArtifact;
checks: QaReportCheck[];
config: MatrixQaSummary["config"];
differentialProbe?: MatrixQaDifferentialProbeResult;
finishedAt: string;
harness: MatrixQaSummary["harness"];
observedEventCount: number;
@@ -500,9 +509,11 @@ function buildMatrixQaSummary(params: {
finishedAt: params.finishedAt,
harness: params.harness,
canary: params.canary,
differentialProbe: params.differentialProbe,
observedEventCount: params.observedEventCount,
observedEventsPath: params.artifactPaths.observedEvents,
reportPath: params.artifactPaths.report,
routeStateManifestPath: params.artifactPaths.routeStateManifest,
scenarios: params.scenarios,
startedAt: params.startedAt,
summaryPath: params.artifactPaths.summary,
@@ -729,7 +740,6 @@ export async function runMatrixQaLive(params: {
writeMatrixQaProgress(
`topology ready ${formatMatrixQaDurationMs(provisioningMs)} rooms=${provisioning.topology.rooms.length}`,
);
const checks: QaReportCheck[] = [
{
name: "Matrix harness ready",
@@ -743,6 +753,28 @@ export async function runMatrixQaLive(params: {
].join("\n"),
},
];
harness.recording.setScenarioId("matrix-qa-v1-probe");
let differentialProbe: MatrixQaDifferentialProbeResult | undefined;
try {
differentialProbe = await withMatrixQaRunDeadline(
runDeadline,
"Matrix differential probe",
() =>
runMatrixQaDifferentialProbe({
accessToken: provisioning.driver.accessToken,
baseUrl: harness.baseUrl,
roomId: provisioning.roomId,
userId: provisioning.driver.userId,
}),
);
checks.push({ name: "Matrix differential probe", status: "pass" });
} catch (error) {
checks.push({
details: error instanceof Error ? error.message : String(error),
name: "Matrix differential probe",
status: "fail",
});
}
const scenarioResults: Array<MatrixQaScenarioResult | undefined> = Array.from({
length: scenarios.length,
});
@@ -755,7 +787,7 @@ export async function runMatrixQaLive(params: {
const syncState: { driver?: string; observer?: string } = {};
const syncStreams: MatrixQaSyncStreams = {};
let canaryMs: number | undefined;
let initialGatewayBootMs;
let initialGatewayBootMs = 0;
let scenarioGatewayBootMs = 0;
let scenarioRestartGatewayMs = 0;
let scenarioTransportInterruptMs = 0;
@@ -777,7 +809,12 @@ export async function runMatrixQaLive(params: {
const scheduledScenarios = scheduleMatrixQaScenariosInCatalogOrder(scenarios);
try {
matrixQaExecution: try {
if (params.failFast && !differentialProbe) {
writeMatrixQaProgress("fail-fast stop");
break matrixQaExecution;
}
harness.recording.setScenarioId("canary");
const ensureGatewayHarness = async (selection: MatrixQaGatewaySelection = {}) => {
const models = resolveMatrixQaGatewayModels({
defaultModels,
@@ -890,6 +927,7 @@ export async function runMatrixQaLive(params: {
if (!canaryFailed) {
for (const { scenario, originalIndex } of scheduledScenarios) {
harness.recording.setScenarioId(scenario.id);
const { entry: scenarioConfigEntry, summary: scenarioConfigSummary } =
buildMatrixQaScenarioConfigEntry({
gatewayConfigParams,
@@ -916,6 +954,8 @@ export async function runMatrixQaLive(params: {
driverDeviceId: provisioning.driver.deviceId,
driverPassword: provisioning.driver.password,
driverUserId: provisioning.driver.userId,
faultProxyObserver: harness.recording,
faultProxyTargetBaseUrl: harness.upstreamBaseUrl,
interruptTransport: async () => {
writeMatrixQaProgress(`transport interrupt start ${scenario.id}`);
const measuredInterrupt = await measureMatrixQaStep(async () => {
@@ -1079,6 +1119,7 @@ export async function runMatrixQaLive(params: {
}
}
} finally {
harness.recording.setScenarioId("cleanup");
if (gatewayHarness) {
try {
const shouldPreserveGatewayDebugArtifacts =
@@ -1137,11 +1178,22 @@ export async function runMatrixQaLive(params: {
const reportPath = path.join(outputDir, "matrix-qa-report.md");
const summaryPath = path.join(outputDir, "matrix-qa-summary.json");
const observedEventsPath = path.join(outputDir, "matrix-qa-observed-events.json");
const routeStateManifestPath = path.join(outputDir, "matrix-qa-route-state-manifest.json");
const artifactPaths = {
observedEvents: observedEventsPath,
report: reportPath,
routeStateManifest: routeStateManifestPath,
summary: summaryPath,
} satisfies MatrixQaArtifactPaths;
const routeStateManifest = harness.recording.buildManifest({
generatedAt: finishedAt,
requestedProfile: params.profile?.trim() || "all",
scenarioIds: completedScenarioResults.map((scenario) => scenario.id),
substrate: {
id: "tuwunel",
version: harness.image,
},
});
const report = renderQaMarkdownReport({
title: "Matrix QA Report",
startedAt: startedAtDate,
@@ -1173,6 +1225,7 @@ export async function runMatrixQaLive(params: {
default: defaultConfigSnapshot,
scenarios: scenarioConfigSnapshots,
},
differentialProbe,
finishedAt,
harness: {
baseUrl: harness.baseUrl,
@@ -1221,6 +1274,10 @@ export async function runMatrixQaLive(params: {
)}\n`,
{ encoding: "utf8", mode: 0o600 },
);
await fs.writeFile(routeStateManifestPath, `${JSON.stringify(routeStateManifest, null, 2)}\n`, {
encoding: "utf8",
mode: 0o600,
});
summary.timings.artifactWriteMs = Date.now() - artifactWriteStartedAtMs;
summary.timings.totalMs = Date.now() - runStartedAtMs;
await fs.writeFile(summaryPath, `${JSON.stringify(summary, null, 2)}\n`, {
@@ -1263,6 +1320,7 @@ export async function runMatrixQaLive(params: {
observedEventsPath,
outputDir,
reportPath,
routeStateManifestPath,
scenarios: completedScenarioResults,
summaryPath,
};
@@ -1017,7 +1017,8 @@ async function runMatrixQaFaultedE2eeBootstrap(context: MatrixQaScenarioContext)
result: MatrixQaE2eeBootstrapResult;
}> {
const proxy = await startMatrixQaFaultProxy({
targetBaseUrl: context.baseUrl,
targetBaseUrl: context.faultProxyTargetBaseUrl ?? context.baseUrl,
...context.faultProxyObserver,
rules: [buildRoomKeyBackupUnavailableFaultRule(context.driverAccessToken)],
});
try {
@@ -1053,7 +1054,8 @@ async function runMatrixQaFaultedRecoveryOwnerVerification(params: {
verification: Awaited<ReturnType<MatrixQaE2eeScenarioClient["verifyWithRecoveryKey"]>>;
}> {
const proxy = await startMatrixQaFaultProxy({
targetBaseUrl: params.context.baseUrl,
targetBaseUrl: params.context.faultProxyTargetBaseUrl ?? params.context.baseUrl,
...params.context.faultProxyObserver,
rules: [buildOwnerSignatureUploadBlockedFaultRule(params.accessToken)],
});
const recoveryClient = await createMatrixQaE2eeScenarioClient({
@@ -1386,7 +1388,8 @@ export async function runMatrixQaE2eeStateAfterMissingEncryptionScenario(
configPath,
});
const proxy = await startMatrixQaFaultProxy({
targetBaseUrl: context.baseUrl,
targetBaseUrl: context.faultProxyTargetBaseUrl ?? context.baseUrl,
...context.faultProxyObserver,
rules: [buildSyncStateAfterMissingEncryptionFaultRule(context.sutAccessToken)],
});
let gatewayPatched = false;
@@ -2236,7 +2239,8 @@ export async function runMatrixQaE2eeCliEncryptionSetupBootstrapFailureScenario(
throw new Error("Matrix E2EE CLI bootstrap-failure login did not return a device id");
}
const proxy = await startMatrixQaFaultProxy({
targetBaseUrl: context.baseUrl,
targetBaseUrl: context.faultProxyTargetBaseUrl ?? context.baseUrl,
...context.faultProxyObserver,
rules: [buildRoomKeyBackupUnavailableFaultRule(cliDevice.accessToken)],
});
const cli = await createMatrixQaCliE2eeSetupRuntime({
@@ -2,6 +2,7 @@
import { randomUUID } from "node:crypto";
import { createMatrixQaClient, type MatrixQaRoomObserver } from "../../substrate/client.js";
import type { MatrixQaObservedEvent } from "../../substrate/events.js";
import type { MatrixQaFaultProxyObserver } from "../../substrate/fault-proxy.js";
import { createMatrixQaRoomObserver } from "../../substrate/sync.js";
import type { MatrixQaProvisionedTopology } from "../../substrate/topology.js";
import { resolveMatrixQaScenarioRoomId } from "./scenario-catalog.js";
@@ -23,6 +24,8 @@ export type MatrixQaScenarioContext = {
driverDeviceId?: string;
driverPassword?: string;
driverUserId: string;
faultProxyObserver?: MatrixQaFaultProxyObserver;
faultProxyTargetBaseUrl?: string;
observedEvents: MatrixQaObservedEvent[];
observerAccessToken: string;
observerDeviceId?: string;
@@ -0,0 +1,78 @@
// Qa Matrix tests prove one differential probe against the Matrix substrate contract.
import { describe, expect, it } from "vitest";
import { runMatrixQaDifferentialProbe } from "./differential-probe.js";
function createProbeFetch(params?: {
missingStateErrcode?: string;
userId?: string;
}): typeof fetch {
return async (input) => {
const url = new URL(input instanceof Request ? input.url : input.toString());
const json = (body: unknown, status = 200) =>
new Response(JSON.stringify(body), {
headers: { "content-type": "application/json" },
status,
});
if (url.pathname.endsWith("/versions")) {
return json({ versions: ["v1.11"] });
}
if (url.pathname.endsWith("/account/whoami")) {
return json({ user_id: params?.userId ?? "@probe:matrix.test" });
}
if (url.pathname.endsWith("/sync")) {
return json({ next_batch: url.searchParams.has("since") ? "sync-2" : "sync-1" });
}
return json({ errcode: params?.missingStateErrcode ?? "M_NOT_FOUND" }, 404);
};
}
describe("Matrix QA differential probe", () => {
it("runs unchanged against the Matrix substrate contract", async () => {
const result = await runMatrixQaDifferentialProbe({
accessToken: "token",
baseUrl: "http://matrix.test",
fetchImpl: createProbeFetch(),
roomId: "!probe:matrix.test",
userId: "@probe:matrix.test",
});
expect(result.profile).toBe("matrix-qa-v1");
expect(result.sync).toEqual({
continuity: true,
incrementalStatus: 200,
initialStatus: 200,
});
expect(result.steps.map((step) => [step.id, step.status])).toEqual([
["versions", 200],
["whoami", 200],
["sync-initial", 200],
["sync-incremental", 200],
["missing-state", 404],
]);
expect(result.steps.at(-1)?.errcode).toBe("M_NOT_FOUND");
});
it("rejects a mismatched whoami identity", async () => {
await expect(
runMatrixQaDifferentialProbe({
accessToken: "token",
baseUrl: "http://matrix.test",
fetchImpl: createProbeFetch({ userId: "@other:matrix.test" }),
roomId: "!probe:matrix.test",
userId: "@probe:matrix.test",
}),
).rejects.toThrow("unexpected user_id");
});
it("rejects a missing-state response with the wrong Matrix errcode", async () => {
await expect(
runMatrixQaDifferentialProbe({
accessToken: "token",
baseUrl: "http://matrix.test",
fetchImpl: createProbeFetch({ missingStateErrcode: "M_FORBIDDEN" }),
roomId: "!probe:matrix.test",
userId: "@probe:matrix.test",
}),
).rejects.toThrow("did not return M_NOT_FOUND");
});
});
@@ -0,0 +1,174 @@
import { normalizeMatrixQaRoute } from "./recording-proxy.js";
// Qa Matrix plugin module probes Matrix substrates through one protocol contract.
import { requestMatrixJson, type MatrixQaFetchLike } from "./request.js";
const MATRIX_QA_DIFFERENTIAL_PROFILE = "matrix-qa-v1";
type MatrixQaProbeStep = {
errcode?: string;
id: string;
method: "GET";
responseFields: string[];
route: string;
status: number;
};
export type MatrixQaDifferentialProbeResult = {
profile: typeof MATRIX_QA_DIFFERENTIAL_PROFILE;
steps: MatrixQaProbeStep[];
sync: {
continuity: boolean;
incrementalStatus: number;
initialStatus: number;
};
};
function topLevelFields(value: unknown) {
return typeof value === "object" && value !== null && !Array.isArray(value)
? Object.keys(value).toSorted()
: [];
}
function errcode(value: unknown) {
if (typeof value !== "object" || value === null) {
return undefined;
}
const candidate = (value as { errcode?: unknown }).errcode;
return typeof candidate === "string" ? candidate : undefined;
}
function nextBatch(value: unknown) {
if (typeof value !== "object" || value === null) {
return undefined;
}
const candidate = (value as { next_batch?: unknown }).next_batch;
return typeof candidate === "string" ? candidate : undefined;
}
function userId(value: unknown) {
if (typeof value !== "object" || value === null) {
return undefined;
}
const candidate = (value as { user_id?: unknown }).user_id;
return typeof candidate === "string" ? candidate : undefined;
}
function buildStep(params: {
body: unknown;
endpoint: string;
id: string;
status: number;
}): MatrixQaProbeStep {
const code = errcode(params.body);
return {
...(code ? { errcode: code } : {}),
id: params.id,
method: "GET",
responseFields: topLevelFields(params.body),
route: normalizeMatrixQaRoute(new URL(params.endpoint, "http://matrix.test").pathname),
status: params.status,
};
}
export async function runMatrixQaDifferentialProbe(params: {
accessToken: string;
baseUrl: string;
fetchImpl?: MatrixQaFetchLike;
roomId: string;
userId: string;
}): Promise<MatrixQaDifferentialProbeResult> {
const fetchImpl = params.fetchImpl ?? fetch;
const versions = await requestMatrixJson<Record<string, unknown>>({
baseUrl: params.baseUrl,
endpoint: "/_matrix/client/versions",
fetchImpl,
method: "GET",
});
const whoami = await requestMatrixJson<Record<string, unknown>>({
accessToken: params.accessToken,
baseUrl: params.baseUrl,
endpoint: "/_matrix/client/v3/account/whoami",
fetchImpl,
method: "GET",
});
if (userId(whoami.body) !== params.userId) {
throw new Error("Matrix differential probe whoami returned an unexpected user_id");
}
const initialSync = await requestMatrixJson<Record<string, unknown>>({
accessToken: params.accessToken,
baseUrl: params.baseUrl,
endpoint: "/_matrix/client/v3/sync",
fetchImpl,
method: "GET",
query: { timeout: 0 },
});
const initialToken = nextBatch(initialSync.body);
if (!initialToken) {
throw new Error("Matrix differential probe initial sync did not return next_batch");
}
const incrementalSync = await requestMatrixJson<Record<string, unknown>>({
accessToken: params.accessToken,
baseUrl: params.baseUrl,
endpoint: "/_matrix/client/v3/sync",
fetchImpl,
method: "GET",
query: { since: initialToken, timeout: 0 },
});
const incrementalToken = nextBatch(incrementalSync.body);
if (!incrementalToken) {
throw new Error("Matrix differential probe incremental sync did not return next_batch");
}
const missingStateEndpoint = `/_matrix/client/v3/rooms/${encodeURIComponent(params.roomId)}/state/org.openclaw.qa.missing/${encodeURIComponent(params.userId)}`;
const missingState = await requestMatrixJson<Record<string, unknown>>({
accessToken: params.accessToken,
baseUrl: params.baseUrl,
endpoint: missingStateEndpoint,
fetchImpl,
method: "GET",
okStatuses: [404],
});
if (errcode(missingState.body) !== "M_NOT_FOUND") {
throw new Error("Matrix differential probe missing state did not return M_NOT_FOUND");
}
return {
profile: MATRIX_QA_DIFFERENTIAL_PROFILE,
steps: [
buildStep({
body: versions.body,
endpoint: "/_matrix/client/versions",
id: "versions",
status: versions.status,
}),
buildStep({
body: whoami.body,
endpoint: "/_matrix/client/v3/account/whoami",
id: "whoami",
status: whoami.status,
}),
buildStep({
body: initialSync.body,
endpoint: "/_matrix/client/v3/sync",
id: "sync-initial",
status: initialSync.status,
}),
buildStep({
body: incrementalSync.body,
endpoint: "/_matrix/client/v3/sync",
id: "sync-incremental",
status: incrementalSync.status,
}),
buildStep({
body: missingState.body,
endpoint: missingStateEndpoint,
id: "missing-state",
status: missingState.status,
}),
],
sync: {
continuity: Boolean(initialToken && incrementalToken),
incrementalStatus: incrementalSync.status,
initialStatus: initialSync.status,
},
};
}
@@ -1,11 +1,15 @@
// Qa Matrix tests cover fault proxy plugin behavior.
import { createServer } from "node:http";
import { gzipSync } from "node:zlib";
import { afterEach, describe, expect, it } from "vitest";
import { startMatrixQaFaultProxy, type MatrixQaFaultProxy } from "./fault-proxy.js";
const servers: Array<{ close(): Promise<void> }> = [];
async function startTargetServer(params?: { responseBody?: string }) {
async function startTargetServer(params?: {
responseBody?: Buffer | string;
responseHeaders?: Record<string, string>;
}) {
const requests: Array<{
authorization?: string;
body: string;
@@ -24,7 +28,7 @@ async function startTargetServer(params?: { responseBody?: string }) {
method: req.method ?? "GET",
url: req.url ?? "/",
});
res.writeHead(200, { "content-type": "application/json" });
res.writeHead(200, { "content-type": "application/json", ...params?.responseHeaders });
res.end(params?.responseBody ?? JSON.stringify({ forwarded: true }));
})();
});
@@ -128,6 +132,24 @@ describe("Matrix QA fault proxy", () => {
]);
});
it("strips stale content-encoding after buffering decoded bodies", async () => {
const body = Buffer.from(JSON.stringify({ forwarded: true }));
const target = await startTargetServer({
responseBody: gzipSync(body),
responseHeaders: {
"content-encoding": "gzip",
"content-length": String(gzipSync(body).byteLength),
},
});
proxy = await startMatrixQaFaultProxy({ targetBaseUrl: target.baseUrl, rules: [] });
const response = await fetch(`${proxy.baseUrl}/encoded`);
expect(response.headers.get("content-encoding")).toBeNull();
expect(response.headers.get("content-length")).toBeNull();
await expect(response.json()).resolves.toEqual({ forwarded: true });
});
it("mutates matching forwarded Matrix responses", async () => {
const target = await startTargetServer();
proxy = await startMatrixQaFaultProxy({
@@ -187,6 +209,7 @@ describe("Matrix QA fault proxy", () => {
});
expect(rejected.status).toBe(413);
expect(rejected.headers.get("connection")).toBe("close");
await expect(rejected.json()).resolves.toMatchObject({
errcode: "MATRIX_QA_FAULT_PROXY_REQUEST_TOO_LARGE",
});
@@ -8,8 +8,8 @@ import {
import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime";
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
const DEFAULT_FAULT_PROXY_REQUEST_MAX_BYTES = 16 * 1024 * 1024;
const DEFAULT_FAULT_PROXY_RESPONSE_MAX_BYTES = 16 * 1024 * 1024;
const DEFAULT_FAULT_PROXY_REQUEST_MAX_BYTES = 20 * 1024 * 1024;
const DEFAULT_FAULT_PROXY_RESPONSE_MAX_BYTES = 20 * 1024 * 1024;
const HOP_BY_HOP_HEADERS = new Set([
"connection",
@@ -23,8 +23,9 @@ const HOP_BY_HOP_HEADERS = new Set([
"upgrade",
]);
type MatrixQaFaultProxyRequest = {
export type MatrixQaFaultProxyRequest = {
bearerToken?: string;
body: Buffer;
headers: IncomingHttpHeaders;
method: string;
path: string;
@@ -37,12 +38,23 @@ type MatrixQaFaultProxyResponse = {
status: number;
};
type MatrixQaFaultProxyForwardedResponse = {
export type MatrixQaFaultProxyForwardedResponse = {
body: Buffer;
headers: Headers;
status: number;
};
export type MatrixQaFaultProxyExchange = {
context?: unknown;
request: MatrixQaFaultProxyRequest;
response: MatrixQaFaultProxyForwardedResponse;
};
export type MatrixQaFaultProxyObserver = {
createExchangeContext?: (request: MatrixQaFaultProxyRequest) => unknown;
onExchange?: (exchange: MatrixQaFaultProxyExchange) => Promise<void> | void;
};
class MatrixQaFaultProxyHttpError extends Error {
constructor(
readonly status: number,
@@ -228,13 +240,19 @@ function bufferToArrayBuffer(buffer: Buffer) {
) as ArrayBuffer;
}
function writeJsonResponse(res: ServerResponse, response: MatrixQaFaultProxyResponse) {
const body = response.body === undefined ? "" : JSON.stringify(response.body);
res.writeHead(response.status, {
"content-type": "application/json",
...response.headers,
});
res.end(body);
function normalizeJsonResponse(
response: MatrixQaFaultProxyResponse,
): MatrixQaFaultProxyForwardedResponse {
const body =
response.body === undefined ? Buffer.alloc(0) : Buffer.from(JSON.stringify(response.body));
return {
body,
headers: new Headers({
"content-type": "application/json",
...response.headers,
}),
status: response.status,
};
}
async function forwardMatrixQaFaultProxyRequest(params: {
@@ -279,10 +297,18 @@ async function forwardMatrixQaFaultProxyRequest(params: {
function writeForwardedResponse(
res: ServerResponse,
response: MatrixQaFaultProxyForwardedResponse,
options: { preserveConnectionClose?: boolean } = {},
) {
const headers: Record<string, string> = {};
for (const [key, value] of response.headers) {
if (!HOP_BY_HOP_HEADERS.has(key.toLowerCase())) {
const normalizedKey = key.toLowerCase();
const isIntentionalConnectionClose =
options.preserveConnectionClose && normalizedKey === "connection" && value === "close";
if (
(!HOP_BY_HOP_HEADERS.has(normalizedKey) || isIntentionalConnectionClose) &&
normalizedKey !== "content-encoding" &&
normalizedKey !== "content-length"
) {
headers[key] = value;
}
}
@@ -290,30 +316,38 @@ function writeForwardedResponse(
res.end(response.body);
}
export async function startMatrixQaFaultProxy(params: {
maxRequestBytes?: number;
maxResponseBytes?: number;
rules: MatrixQaFaultProxyRule[];
targetBaseUrl: string;
}): Promise<MatrixQaFaultProxy> {
export async function startMatrixQaFaultProxy(
params: MatrixQaFaultProxyObserver & {
maxRequestBytes?: number;
maxResponseBytes?: number;
rules: MatrixQaFaultProxyRule[];
targetBaseUrl: string;
},
): Promise<MatrixQaFaultProxy> {
const targetBaseUrl = new URL(params.targetBaseUrl);
const maxRequestBytes = params.maxRequestBytes ?? DEFAULT_FAULT_PROXY_REQUEST_MAX_BYTES;
const maxResponseBytes = params.maxResponseBytes ?? DEFAULT_FAULT_PROXY_RESPONSE_MAX_BYTES;
const hits: MatrixQaFaultProxyHit[] = [];
const server = createServer((req, res) => {
void (async () => {
let observedRequest: MatrixQaFaultProxyRequest | undefined;
let observedContext: unknown;
try {
const requestUrl = new URL(req.url ?? "/", targetBaseUrl);
const path = requestUrl.pathname;
const bearerToken = extractBearerToken(req.headers);
const body = await readRequestBody(req, maxRequestBytes);
const request: MatrixQaFaultProxyRequest = {
...(bearerToken ? { bearerToken } : {}),
body,
headers: req.headers,
method: req.method ?? "GET",
path,
search: requestUrl.search,
};
const body = await readRequestBody(req, maxRequestBytes);
observedRequest = request;
const context = params.createExchangeContext?.(request);
observedContext = context;
const rule = params.rules.find((candidate) => candidate.match(request));
if (rule) {
hits.push({
@@ -322,7 +356,13 @@ export async function startMatrixQaFaultProxy(params: {
ruleId: rule.id,
});
if (rule.response) {
writeJsonResponse(res, rule.response(request));
const response = normalizeJsonResponse(rule.response(request));
await params.onExchange?.({
...(context !== undefined ? { context } : {}),
request,
response,
});
writeForwardedResponse(res, response);
return;
}
}
@@ -339,25 +379,41 @@ export async function startMatrixQaFaultProxy(params: {
response: forwarded,
})
: forwarded;
await params.onExchange?.({
...(context !== undefined ? { context } : {}),
request,
response,
});
writeForwardedResponse(res, response);
} catch (error) {
if (error instanceof MatrixQaFaultProxyHttpError) {
writeJsonResponse(res, {
body: {
errcode: error.code,
error: error.message,
},
...(error.status === 413 ? { headers: { connection: "close" } } : {}),
status: error.status,
const failure =
error instanceof MatrixQaFaultProxyHttpError
? {
body: {
errcode: error.code,
error: error.message,
},
...(error.status === 413 ? { headers: { connection: "close" } } : {}),
status: error.status,
}
: {
body: {
errcode: "MATRIX_QA_FAULT_PROXY_ERROR",
error: error instanceof Error ? error.message : String(error),
},
status: 502,
};
const response = normalizeJsonResponse(failure);
if (observedRequest) {
await params.onExchange?.({
...(observedContext !== undefined ? { context: observedContext } : {}),
request: observedRequest,
response,
});
return;
}
writeJsonResponse(res, {
body: {
errcode: "MATRIX_QA_FAULT_PROXY_ERROR",
error: error instanceof Error ? error.message : String(error),
},
status: 502,
writeForwardedResponse(res, response, {
preserveConnectionClose:
error instanceof MatrixQaFaultProxyHttpError && error.status === 413,
});
}
})();
@@ -2,8 +2,10 @@
import { mkdtemp, readFile, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { withTempDir } from "openclaw/plugin-sdk/test-env";
import { describe, expect, it, vi } from "vitest";
import { testing, startMatrixQaHarness, writeMatrixQaHarnessFiles } from "./harness.runtime.js";
import type { MatrixQaRecordingProxy } from "./recording-proxy.js";
type MatrixQaHarnessDeps = Parameters<typeof startMatrixQaHarness>[1];
type MatrixQaHarnessResult = Awaited<ReturnType<typeof startMatrixQaHarness>>;
@@ -15,13 +17,23 @@ async function withStartedMatrixHarness(
const outputDir = await mkdtemp(path.join(os.tmpdir(), "matrix-qa-harness-"));
try {
const startRecordingProxyImpl =
deps?.startRecordingProxyImpl ??
(async ({ targetBaseUrl }: { targetBaseUrl: string }) =>
({
baseUrl: targetBaseUrl,
buildManifest: vi.fn(),
records: () => [],
setScenarioId: vi.fn(),
stop: vi.fn(async () => {}),
}) as unknown as MatrixQaRecordingProxy);
const result = await startMatrixQaHarness(
{
outputDir,
repoRoot: "/repo/openclaw",
homeserverPort: 28008,
},
deps,
{ ...deps, startRecordingProxyImpl },
);
await verify({ outputDir, result });
} finally {
@@ -137,6 +149,33 @@ describe("matrix harness runtime", () => {
);
});
it("stops Tuwunel when recorder startup fails", async () => {
const calls: string[] = [];
await withTempDir("matrix-qa-harness-", async (outputDir) => {
await expect(
startMatrixQaHarness(
{ outputDir, repoRoot: "/repo/openclaw" },
{
async runCommand(command, args, cwd) {
calls.push([command, ...args, `@${cwd}`].join(" "));
if (args.join(" ").includes("ps --format json")) {
return { stdout: '[{"State":"running"}]\n', stderr: "" };
}
return { stdout: "", stderr: "" };
},
fetchImpl: vi.fn(async () => ({ ok: true })),
sleepImpl: vi.fn(async () => {}),
resolveHostPortImpl: vi.fn(async (port: number) => port),
startRecordingProxyImpl: vi.fn(async () => {
throw new Error("recorder startup failed");
}),
},
),
).rejects.toThrow("recorder startup failed");
expect(calls.filter((call) => call.includes("down --remove-orphans"))).toHaveLength(2);
});
});
it("treats empty Docker health fields as a fallback to running state", async () => {
await withStartedMatrixHarness(
{
@@ -13,6 +13,7 @@ import {
type FetchLike,
type RunCommand,
} from "../docker-runtime.js";
import { startMatrixQaRecordingProxy, type MatrixQaRecordingProxy } from "./recording-proxy.js";
const MATRIX_QA_DEFAULT_IMAGE = "ghcr.io/matrix-construct/tuwunel:v1.5.1";
const MATRIX_QA_DEFAULT_SERVER_NAME = "matrix-qa.test";
@@ -41,9 +42,11 @@ type MatrixQaHarnessFiles = {
type MatrixQaHarness = MatrixQaHarnessFiles & {
baseUrl: string;
recording: MatrixQaRecordingProxy;
restartService(): Promise<void>;
stopCommand: string;
stop(): Promise<void>;
upstreamBaseUrl: string;
};
function buildVersionsUrl(baseUrl: string) {
@@ -216,6 +219,7 @@ export async function startMatrixQaHarness(
runCommand?: RunCommand;
sleepImpl?: (ms: number) => Promise<unknown>;
resolveHostPortImpl?: typeof resolveHostPort;
startRecordingProxyImpl?: typeof startMatrixQaRecordingProxy;
},
): Promise<MatrixQaHarness> {
const repoRoot = path.resolve(params.repoRoot ?? process.cwd());
@@ -223,6 +227,7 @@ export async function startMatrixQaHarness(
const runCommand = deps?.runCommand ?? execCommand;
const fetchImpl = deps?.fetchImpl ?? fetchHealthUrl;
const sleepImpl = deps?.sleepImpl ?? sleep;
const startRecordingProxyImpl = deps?.startRecordingProxyImpl ?? startMatrixQaRecordingProxy;
const homeserverPort = await resolveHostPortImpl(
params.homeserverPort ?? MATRIX_QA_DEFAULT_PORT,
params.homeserverPort != null,
@@ -255,7 +260,7 @@ export async function startMatrixQaHarness(
);
const hostBaseUrl = `http://127.0.0.1:${homeserverPort}/`;
let baseUrl = hostBaseUrl;
let upstreamBaseUrl = hostBaseUrl;
const hostReachable = await isMatrixVersionsReachable(hostBaseUrl, fetchImpl);
if (!hostReachable) {
const containerBaseUrl = await resolveComposeServiceUrl(
@@ -265,7 +270,7 @@ export async function startMatrixQaHarness(
repoRoot,
runCommand,
);
baseUrl = await waitForReachableMatrixBaseUrl({
upstreamBaseUrl = await waitForReachableMatrixBaseUrl({
composeFile: files.composeFile,
containerBaseUrl,
fetchImpl,
@@ -274,12 +279,27 @@ export async function startMatrixQaHarness(
});
}
await waitForHealth(buildVersionsUrl(baseUrl), {
await waitForHealth(buildVersionsUrl(upstreamBaseUrl), {
label: "Matrix homeserver",
composeFile: files.composeFile,
fetchImpl,
sleepImpl,
});
let recording: MatrixQaRecordingProxy;
try {
recording = await startRecordingProxyImpl({ targetBaseUrl: upstreamBaseUrl });
} catch (error) {
await withMatrixQaHarnessTimeout(
"Matrix homeserver cleanup after recorder startup failure",
MATRIX_QA_CLEANUP_TIMEOUT_MS,
runCommand(
"docker",
["compose", "-f", files.composeFile, "down", "--remove-orphans"],
repoRoot,
),
).catch(() => {});
throw error;
}
const waitForReady = async () => {
await sleepImpl(1_000);
@@ -290,7 +310,7 @@ export async function startMatrixQaHarness(
runCommand,
sleepImpl,
);
await waitForHealth(buildVersionsUrl(baseUrl), {
await waitForHealth(buildVersionsUrl(upstreamBaseUrl), {
label: "Matrix homeserver",
composeFile: files.composeFile,
fetchImpl,
@@ -300,7 +320,8 @@ export async function startMatrixQaHarness(
return {
...files,
baseUrl,
baseUrl: recording.baseUrl,
recording,
async restartService() {
await runCommand(
"docker",
@@ -311,16 +332,26 @@ export async function startMatrixQaHarness(
},
stopCommand: `docker compose -f ${files.composeFile} down --remove-orphans`,
async stop() {
await withMatrixQaHarnessTimeout(
"Matrix homeserver cleanup",
MATRIX_QA_CLEANUP_TIMEOUT_MS,
runCommand(
"docker",
["compose", "-f", files.composeFile, "down", "--remove-orphans"],
repoRoot,
const results = await Promise.allSettled([
recording.stop(),
withMatrixQaHarnessTimeout(
"Matrix homeserver cleanup",
MATRIX_QA_CLEANUP_TIMEOUT_MS,
runCommand(
"docker",
["compose", "-f", files.composeFile, "down", "--remove-orphans"],
repoRoot,
),
),
]);
const failures = results.flatMap((result) =>
result.status === "rejected" ? [result.reason] : [],
);
if (failures.length > 0) {
throw new AggregateError(failures, "Matrix QA harness cleanup failed");
}
},
upstreamBaseUrl,
};
}
@@ -0,0 +1,714 @@
// Qa Matrix tests cover redacted protocol recording and manifest derivation.
import { createServer } from "node:http";
import { afterEach, describe, expect, it } from "vitest";
import { startMatrixQaFaultProxy } from "./fault-proxy.js";
import { normalizeMatrixQaRoute, startMatrixQaRecordingProxy } from "./recording-proxy.js";
const closeCallbacks: Array<() => Promise<void>> = [];
afterEach(async () => {
while (closeCallbacks.length > 0) {
await closeCallbacks.pop()?.();
}
});
async function startRecordingTarget(options?: { alwaysFailState?: boolean }) {
let syncCount = 0;
let stateCount = 0;
const server = createServer((req, res) => {
const url = new URL(req.url ?? "/", "http://matrix.test");
if (url.pathname.endsWith("/sync")) {
syncCount += 1;
const since = url.searchParams.get("since");
res.writeHead(200, { "content-type": "application/json" });
res.end(
JSON.stringify({
account_data: {
events: [
{
content: {
encrypted: {
SECRET_NESTED_STORAGE_KEY: { ciphertext: "secret-nested-ciphertext" },
},
unsigned: {
"secret-custom-field-name": "secret-custom-field-value",
},
url: "mxc://matrix.test/secret-media",
info: { mimetype: "image/png" },
},
type: "m.cross_signing.master",
},
],
},
device_keys: {
"@secret-user:matrix.test": {
SECRET_DEVICE: {
keys: {
"ed25519:SECRET_DEVICE": "secret-signing-key",
},
},
},
},
device_one_time_keys_count: { signed_curve25519: 12 },
device_unused_fallback_key_types: ["signed_curve25519"],
next_batch: since === "echoed-unknown" ? since : `secret-sync-${syncCount}`,
device_lists: { changed: ["secret-device"] },
rooms: {
join: {
"!secret-room:matrix.test": {
timeline: {
events: [
{ type: "m.room.message" },
{ sender: "@secret-sender:matrix.test", type: "m.room.encrypted" },
],
},
},
},
},
}),
);
return;
}
if (url.pathname.includes("/room_keys/keys")) {
res.writeHead(200, { "content-type": "application/json" });
res.end(
JSON.stringify({
rooms: {
"!secret-backup-room:matrix.test": {
sessions: {
"secret-backup-session": { first_message_index: 0 },
},
},
},
}),
);
return;
}
if (url.pathname.endsWith("/keys/upload")) {
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify({ one_time_key_counts: {} }));
return;
}
if (url.pathname.endsWith("/keys/query")) {
res.writeHead(200, { "content-type": "application/json" });
res.end(
JSON.stringify({
failures: {
"secret-server.example": { errcode: "M_UNAVAILABLE" },
},
}),
);
return;
}
if (url.pathname.includes("/account_data/")) {
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify({}));
return;
}
if (url.pathname.includes("/sendToDevice/")) {
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify({}));
return;
}
if (url.pathname.includes("/state/")) {
stateCount += 1;
const status = options?.alwaysFailState || stateCount === 1 ? 401 : 200;
res.writeHead(status, { "content-type": "application/json" });
res.end(
status === 401
? JSON.stringify({ errcode: "M_UNKNOWN_TOKEN", error: "secret-response" })
: JSON.stringify({ name: "secret-state-name" }),
);
return;
}
res.writeHead(401, { "content-type": "application/json" });
res.end(JSON.stringify({ errcode: "M_UNKNOWN_TOKEN", error: "secret-response" }));
});
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
server.off("error", reject);
resolve();
});
});
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("recording target did not bind");
}
closeCallbacks.push(
async () =>
await new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
}),
);
return `http://127.0.0.1:${address.port}`;
}
describe("Matrix QA recording proxy", () => {
it("records only redacted shapes and derives scenario expectations", async () => {
const targetBaseUrl = await startRecordingTarget();
const proxy = await startMatrixQaRecordingProxy({ targetBaseUrl });
closeCallbacks.push(() => proxy.stop());
proxy.setScenarioId("matrix-recording-test");
const firstSync = await fetch(
`${proxy.baseUrl}/_matrix/client/v3/sync?timeout=0&access_token=secret-query`,
{ headers: { authorization: "Bearer secret-header" } },
);
const firstBody = (await firstSync.json()) as { next_batch: string };
await fetch(
`${proxy.baseUrl}/_matrix/client/v3/sync?timeout=0&since=${encodeURIComponent(firstBody.next_batch)}`,
{ headers: { authorization: "Bearer secret-header" } },
);
const postState = async () => {
await fetch(
`${proxy.baseUrl}/_matrix/client/v3/rooms/!secret:matrix.test/state/m.room.name`,
{
body: JSON.stringify({ password: "secret-password", body: "secret-message" }),
headers: {
authorization: "Bearer secret-header",
"content-type": "application/json",
},
method: "POST",
},
);
};
await postState();
await fetch(`${proxy.baseUrl}/_matrix/client/v3/room_keys/keys`, {
headers: {
authorization: "Bearer secret-header",
},
});
await fetch(`${proxy.baseUrl}/_matrix/client/v3/keys/upload`, {
body: JSON.stringify({
device_keys: {
algorithms: ["m.olm.v1.curve25519-aes-sha2"],
device_id: "SECRET_UPLOAD_DEVICE",
keys: { "ed25519:SECRET_UPLOAD_DEVICE": "secret-upload-key" },
user_id: "@secret-upload:matrix.test",
},
one_time_keys: {
"signed_curve25519:SECRET_ONE_TIME": { key: "secret-one-time-key" },
},
}),
headers: {
authorization: "Bearer secret-header",
"content-type": "application/json",
},
method: "POST",
});
await fetch(`${proxy.baseUrl}/_matrix/client/v3/keys/query`, {
body: JSON.stringify({ device_keys: {} }),
headers: { "content-type": "application/json" },
method: "POST",
});
await fetch(
`${proxy.baseUrl}/_matrix/client/v3/user/@secret-upload%3Amatrix.test/account_data/m.cross_signing.master`,
{
body: JSON.stringify({
encrypted: {
SECRET_STORAGE_KEY: { ciphertext: "secret-ciphertext" },
},
}),
headers: { "content-type": "application/json" },
method: "PUT",
},
);
await fetch(
`${proxy.baseUrl}/_matrix/client/v3/user/@secret-upload%3Amatrix.test/account_data/m.megolm_backup.v1`,
{
body: JSON.stringify({ version: "1" }),
headers: { "content-type": "application/json" },
method: "PUT",
},
);
await fetch(`${proxy.baseUrl}/_matrix/client/v3/sendToDevice/m.room.encrypted/transaction-42`, {
body: JSON.stringify({
messages: {
"@secret-recipient:matrix.test": {
SECRET_RECIPIENT_DEVICE: { content: "secret-device-message" },
},
},
}),
headers: { "content-type": "application/json" },
method: "PUT",
});
await postState();
const records = proxy.records();
const serialized = JSON.stringify(records);
expect(serialized).not.toContain("secret-query");
expect(serialized).not.toContain("secret-header");
expect(serialized).not.toContain("secret-password");
expect(serialized).not.toContain("secret-message");
expect(serialized).not.toContain("secret-sync");
expect(serialized).not.toContain("secret-response");
expect(serialized).not.toContain("secret-room");
expect(serialized).not.toContain("secret-backup-room");
expect(serialized).not.toContain("secret-backup-session");
expect(serialized).not.toContain("secret-state-name");
expect(serialized).not.toContain("secret-user");
expect(serialized).not.toContain("SECRET_DEVICE");
expect(serialized).not.toContain("SECRET_UPLOAD_DEVICE");
expect(serialized).not.toContain("SECRET_ONE_TIME");
expect(serialized).not.toContain("SECRET_RECIPIENT_DEVICE");
expect(serialized).not.toContain("secret-server.example");
expect(serialized).not.toContain("SECRET_STORAGE_KEY");
expect(serialized).not.toContain("SECRET_NESTED_STORAGE_KEY");
expect(serialized).not.toContain("secret-custom-field-name");
const keyUpload = records.find((record) => record.request.route.endsWith("/keys/upload"));
expect(keyUpload?.request.body).toEqual({
kind: "json",
fields: [
"device_keys.algorithms",
"device_keys.device_id",
"device_keys.keys.{keyId}",
"device_keys.user_id",
"one_time_keys.{keyId}.key",
],
});
const sendToDevice = records.find((record) => record.request.route.includes("sendToDevice"));
expect(sendToDevice?.request.body).toEqual({
kind: "json",
fields: ["messages.{userId}.{deviceId}.content"],
});
const keyQuery = records.find((record) => record.request.route.endsWith("/keys/query"));
expect(keyQuery?.response.body).toEqual({
kind: "json",
fields: ["failures.{serverName}.errcode"],
});
const secretStorage = records.find((record) =>
record.request.route.endsWith("/account_data/m.cross_signing.master"),
);
expect(secretStorage?.request.body).toEqual({
kind: "json",
fields: ["encrypted.{keyId}.ciphertext"],
});
expect(records[1]?.sync).toMatchObject({ continuity: true, since: "sync-1" });
const manifest = proxy.buildManifest({
generatedAt: "2026-07-03T00:00:00.000Z",
requestedProfile: "all",
scenarioIds: ["matrix-recording-test"],
substrate: { id: "tuwunel", version: "v1.5.1" },
});
const expectation = manifest.scenarios["matrix-recording-test"];
expect(manifest.profile).toEqual({
derivedFrom: "observed-request-response-traffic",
id: "matrix-qa-v1",
});
expect(expectation?.syncTokens).toEqual({
continuityObserved: true,
incrementalRequests: 1,
initialRequests: 1,
responseTokens: 2,
});
expect(expectation?.state.device).toEqual([
"/_matrix/client/v3/keys/upload",
"/_matrix/client/v3/sync",
]);
expect(expectation?.state.key).toContain(
"/_matrix/client/v3/user/{userId}/account_data/m.cross_signing.master",
);
expect(expectation?.state.key).toContain("/_matrix/client/v3/sync");
expect(expectation?.state.backup).toContain(
"/_matrix/client/v3/user/{userId}/account_data/m.megolm_backup.v1",
);
expect(expectation?.state.media).toContain("/_matrix/client/v3/sync");
expect(expectation?.ordering[0]).toMatchObject({
requestBody: { kind: "empty" },
responseBody: {
kind: "json",
fields: expect.arrayContaining([
"rooms.join.{roomId}.timeline.events[].sender",
"rooms.join.{roomId}.timeline.events[].type",
]),
},
});
expect(expectation?.retries).toEqual([]);
expect(expectation?.errors).toEqual([
{
errcode: "M_UNKNOWN_TOKEN",
method: "POST",
route: "/_matrix/client/v3/rooms/{roomId}/state/m.room.name",
status: 401,
},
]);
});
it("normalizes substrate-specific Matrix identifiers from routes", () => {
expect(
normalizeMatrixQaRoute(
"/_matrix/client/v3/rooms/!room%3Amatrix.test/send/m.room.message/txn-123",
),
).toBe("/_matrix/client/v3/rooms/{roomId}/send/m.room.message/{transactionId}");
expect(
normalizeMatrixQaRoute(
"/_matrix/client/v3/rooms/!room%3Amatrix.test/redact/$event%3Amatrix.test/txn-123",
),
).toBe("/_matrix/client/v3/rooms/{roomId}/redact/{eventId}/{transactionId}");
expect(normalizeMatrixQaRoute("/_matrix/media/v3/download/matrix.test/secret-media-id")).toBe(
"/_matrix/media/v3/download/{serverName}/{mediaId}",
);
expect(
normalizeMatrixQaRoute(
"/_matrix/media/v3/download/matrix.test/secret-media-id/private-report.pdf",
),
).toBe("/_matrix/media/v3/download/{serverName}/{mediaId}/{filename}");
expect(
normalizeMatrixQaRoute("/_matrix/client/v1/media/thumbnail/matrix.test/secret-media-id"),
).toBe("/_matrix/client/v1/media/thumbnail/{serverName}/{mediaId}");
expect(
normalizeMatrixQaRoute("/_matrix/client/v3/user/@alice%3Amatrix.test/filter/filter-42"),
).toBe("/_matrix/client/v3/user/{userId}/filter/{filterId}");
expect(
normalizeMatrixQaRoute(
"/_matrix/client/v3/user/@alice%3Amatrix.test/account_data/m.secret_storage.key.secret-key-id",
),
).toBe("/_matrix/client/v3/user/{userId}/account_data/m.secret_storage.key.{keyId}");
expect(
normalizeMatrixQaRoute("/_matrix/client/v3/room_keys/keys/!room%3Amatrix.test/session-42"),
).toBe("/_matrix/client/v3/room_keys/keys/{roomId}/{sessionId}");
});
it("records the response observed after scenario-local fault injection", async () => {
const targetBaseUrl = await startRecordingTarget();
const recording = await startMatrixQaRecordingProxy({ targetBaseUrl });
closeCallbacks.push(() => recording.stop());
recording.setScenarioId("matrix-fault-recording-test");
const faultProxy = await startMatrixQaFaultProxy({
targetBaseUrl,
...recording,
rules: [
{
id: "backup-unavailable",
match: (request) => request.path.endsWith("/room_keys/version"),
response: () => ({
body: { errcode: "M_NOT_FOUND", error: "secret-fault-message" },
status: 404,
}),
},
],
});
closeCallbacks.push(() => faultProxy.stop());
await fetch(`${faultProxy.baseUrl}/_matrix/client/v3/room_keys/version`);
const record = recording
.records()
.find((entry) => entry.scenarioId === "matrix-fault-recording-test");
expect(record?.response).toMatchObject({ errcode: "M_NOT_FOUND", status: 404 });
expect(JSON.stringify(record)).not.toContain("secret-fault-message");
});
it("records proxy-generated upstream failures", async () => {
const recording = await startMatrixQaRecordingProxy({
targetBaseUrl: "http://127.0.0.1:1",
});
closeCallbacks.push(() => recording.stop());
recording.setScenarioId("matrix-upstream-failure-test");
const response = await fetch(`${recording.baseUrl}/_matrix/client/v3/sync?timeout=0`);
expect(response.status).toBe(502);
expect(recording.records().at(-1)?.response).toMatchObject({
errcode: "MATRIX_QA_FAULT_PROXY_ERROR",
status: 502,
});
});
it("redacts signature upload device and cross-signing key identifiers", async () => {
const targetBaseUrl = await startRecordingTarget();
const recording = await startMatrixQaRecordingProxy({ targetBaseUrl });
closeCallbacks.push(() => recording.stop());
recording.setScenarioId("matrix-signatures-recording-test");
await fetch(`${recording.baseUrl}/_matrix/client/v3/keys/signatures/upload`, {
body: JSON.stringify({
"@secret-signing-user:matrix.test": {
SECRET_CROSS_SIGNING_KEY: { signatures: {} },
},
}),
headers: { "content-type": "application/json" },
method: "POST",
});
const record = recording.records().at(-1);
expect(record?.request.body).toEqual({
kind: "json",
fields: ["{userId}.{deviceOrKeyId}.signatures"],
});
expect(JSON.stringify(record)).not.toContain("secret-signing-user");
expect(JSON.stringify(record)).not.toContain("SECRET_CROSS_SIGNING_KEY");
});
it("reports sync continuity only when every incremental token is recognized", async () => {
const targetBaseUrl = await startRecordingTarget();
const recording = await startMatrixQaRecordingProxy({ targetBaseUrl });
closeCallbacks.push(() => recording.stop());
recording.setScenarioId("matrix-sync-discontinuity-test");
await fetch(`${recording.baseUrl}/_matrix/client/v3/sync?timeout=0&since=unknown-token`);
const manifest = recording.buildManifest({
requestedProfile: "test",
scenarioIds: ["matrix-sync-discontinuity-test"],
substrate: { id: "tuwunel", version: "test" },
});
expect(manifest.scenarios["matrix-sync-discontinuity-test"]?.syncTokens).toMatchObject({
continuityObserved: false,
incrementalRequests: 1,
});
});
it("does not learn an unknown request token from the same sync response", async () => {
const targetBaseUrl = await startRecordingTarget();
const recording = await startMatrixQaRecordingProxy({ targetBaseUrl });
closeCallbacks.push(() => recording.stop());
recording.setScenarioId("matrix-echoed-sync-token-test");
await fetch(`${recording.baseUrl}/_matrix/client/v3/sync?timeout=0&since=echoed-unknown`);
expect(recording.records().at(-1)?.sync).toMatchObject({
continuity: false,
since: "sync-unknown",
});
});
it("does not conflate distinct same-shape operations as retries", async () => {
const targetBaseUrl = await startRecordingTarget();
const recording = await startMatrixQaRecordingProxy({ targetBaseUrl });
closeCallbacks.push(() => recording.stop());
recording.setScenarioId("matrix-distinct-operation-test");
await fetch(
`${recording.baseUrl}/_matrix/client/v3/rooms/!first:matrix.test/state/m.room.name`,
{
body: JSON.stringify({ name: "first" }),
headers: { "content-type": "application/json" },
method: "POST",
},
);
await fetch(
`${recording.baseUrl}/_matrix/client/v3/rooms/!second:matrix.test/state/m.room.name`,
{
body: JSON.stringify({ name: "second" }),
headers: { "content-type": "application/json" },
method: "POST",
},
);
const manifest = recording.buildManifest({
requestedProfile: "test",
scenarioIds: ["matrix-distinct-operation-test"],
substrate: { id: "tuwunel", version: "test" },
});
expect(manifest.scenarios["matrix-distinct-operation-test"]?.retries).toEqual([]);
});
it("does not conflate identical operations from different principals", async () => {
const targetBaseUrl = await startRecordingTarget();
const recording = await startMatrixQaRecordingProxy({ targetBaseUrl });
closeCallbacks.push(() => recording.stop());
recording.setScenarioId("matrix-principal-retry-test");
const endpoint = `${recording.baseUrl}/_matrix/client/v3/rooms/!same:matrix.test/state/m.room.name`;
await fetch(endpoint, {
body: JSON.stringify({ name: "same" }),
headers: { authorization: "Bearer first", "content-type": "application/json" },
method: "POST",
});
await fetch(endpoint, {
body: JSON.stringify({ name: "same" }),
headers: { authorization: "Bearer second", "content-type": "application/json" },
method: "POST",
});
const manifest = recording.buildManifest({
requestedProfile: "test",
scenarioIds: ["matrix-principal-retry-test"],
substrate: { id: "tuwunel", version: "test" },
});
expect(manifest.scenarios["matrix-principal-retry-test"]?.retries).toEqual([]);
});
it("attributes sync completion to the active scenario", async () => {
const targetBaseUrl = await startRecordingTarget();
const recording = await startMatrixQaRecordingProxy({ targetBaseUrl });
closeCallbacks.push(() => recording.stop());
recording.setScenarioId("previous-scenario");
const context = recording.createExchangeContext?.({
body: Buffer.alloc(0),
headers: {},
method: "GET",
path: "/_matrix/client/v3/sync",
search: "?timeout=0",
});
recording.setScenarioId("active-scenario");
await recording.onExchange?.({
context,
request: {
body: Buffer.alloc(0),
headers: {},
method: "GET",
path: "/_matrix/client/v3/sync",
search: "?timeout=0",
},
response: {
body: Buffer.from(JSON.stringify({ next_batch: "sync-complete" })),
headers: new Headers({ "content-type": "application/json" }),
status: 200,
},
});
expect(recording.records().at(-1)?.scenarioId).toBe("active-scenario");
});
it("does not share sync-token continuity across principals", async () => {
const targetBaseUrl = await startRecordingTarget();
const recording = await startMatrixQaRecordingProxy({ targetBaseUrl });
closeCallbacks.push(() => recording.stop());
recording.setScenarioId("matrix-principal-sync-test");
const exchange = async (bearerToken: string, search: string, nextBatch: string) => {
const request = {
bearerToken,
body: Buffer.alloc(0),
headers: {},
method: "GET",
path: "/_matrix/client/v3/sync",
search,
};
await recording.onExchange?.({
context: recording.createExchangeContext?.(request),
request,
response: {
body: Buffer.from(JSON.stringify({ next_batch: nextBatch })),
headers: new Headers({ "content-type": "application/json" }),
status: 200,
},
});
};
await exchange("first", "?timeout=0", "shared-token");
await exchange("second", "?timeout=0&since=shared-token", "second-token");
expect(recording.records().at(-1)?.sync).toMatchObject({
continuity: false,
since: "sync-unknown",
});
});
it("ends a retry chain at the first successful recovery", async () => {
const targetBaseUrl = await startRecordingTarget();
const recording = await startMatrixQaRecordingProxy({ targetBaseUrl });
closeCallbacks.push(() => recording.stop());
recording.setScenarioId("matrix-retry-boundary-test");
const endpoint = `${recording.baseUrl}/_matrix/client/v3/rooms/!same:matrix.test/state/m.room.name`;
const request = () =>
fetch(endpoint, {
body: JSON.stringify({ name: "same" }),
headers: { "content-type": "application/json" },
method: "POST",
});
await request();
await request();
await request();
const manifest = recording.buildManifest({
requestedProfile: "test",
scenarioIds: ["matrix-retry-boundary-test"],
substrate: { id: "tuwunel", version: "test" },
});
expect(manifest.scenarios["matrix-retry-boundary-test"]?.retries).toEqual([
expect.objectContaining({ attempts: 2, statuses: [401, 200] }),
]);
});
it("records exhausted retry chains without recovery", async () => {
const targetBaseUrl = await startRecordingTarget({ alwaysFailState: true });
const recording = await startMatrixQaRecordingProxy({ targetBaseUrl });
closeCallbacks.push(() => recording.stop());
recording.setScenarioId("matrix-exhausted-retry-test");
const endpoint = `${recording.baseUrl}/_matrix/client/v3/rooms/!exhausted:matrix.test/state/m.room.name`;
await fetch(endpoint, { method: "POST" });
await fetch(endpoint, { method: "POST" });
const manifest = recording.buildManifest({
requestedProfile: "test",
scenarioIds: ["matrix-exhausted-retry-test"],
substrate: { id: "tuwunel", version: "test" },
});
expect(manifest.scenarios["matrix-exhausted-retry-test"]?.retries).toEqual([
expect.objectContaining({ attempts: 2, statuses: [401, 401] }),
]);
});
it("does not infer retries across intervening operations", async () => {
const targetBaseUrl = await startRecordingTarget();
const recording = await startMatrixQaRecordingProxy({ targetBaseUrl });
closeCallbacks.push(() => recording.stop());
recording.setScenarioId("matrix-independent-operation-test");
const stateEndpoint = `${recording.baseUrl}/_matrix/client/v3/rooms/!same:matrix.test/state/m.room.name`;
await fetch(stateEndpoint, { method: "POST" });
await fetch(`${recording.baseUrl}/_matrix/client/versions`);
await fetch(stateEndpoint, { method: "POST" });
const manifest = recording.buildManifest({
requestedProfile: "test",
scenarioIds: ["matrix-independent-operation-test"],
substrate: { id: "tuwunel", version: "test" },
});
expect(manifest.scenarios["matrix-independent-operation-test"]?.retries).toEqual([]);
});
it("records repeated retry chains for the same operation", async () => {
let requestCount = 0;
const server = createServer((_req, res) => {
requestCount += 1;
const status = requestCount % 2 === 1 ? 503 : 200;
res.writeHead(status, { "content-type": "application/json" });
res.end(JSON.stringify(status === 200 ? {} : { errcode: "M_UNAVAILABLE" }));
});
await new Promise<void>((resolve) => {
server.listen(0, "127.0.0.1", () => resolve());
});
closeCallbacks.push(
() =>
new Promise<void>((resolve, reject) => {
server.close((error) => {
if (error) {
reject(error);
return;
}
resolve();
});
}),
);
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("retry target did not bind");
}
const recording = await startMatrixQaRecordingProxy({
targetBaseUrl: `http://127.0.0.1:${address.port}`,
});
closeCallbacks.push(() => recording.stop());
recording.setScenarioId("matrix-repeated-retry-test");
const endpoint = `${recording.baseUrl}/_matrix/client/v3/rooms/!same:matrix.test/state/m.room.name`;
for (let attempt = 0; attempt < 4; attempt += 1) {
await fetch(endpoint, { method: "POST" });
}
const manifest = recording.buildManifest({
requestedProfile: "test",
scenarioIds: ["matrix-repeated-retry-test"],
substrate: { id: "tuwunel", version: "test" },
});
expect(manifest.scenarios["matrix-repeated-retry-test"]?.retries).toEqual([
expect.objectContaining({ attempts: 2, statuses: [503, 200] }),
expect.objectContaining({ attempts: 2, statuses: [503, 200] }),
]);
});
});
@@ -0,0 +1,732 @@
// Qa Matrix plugin module records redacted Matrix protocol behavior.
import { createHash } from "node:crypto";
import type { IncomingHttpHeaders } from "node:http";
import {
startMatrixQaFaultProxy,
type MatrixQaFaultProxyExchange,
type MatrixQaFaultProxyObserver,
} from "./fault-proxy.js";
const MATRIX_QA_RECORDING_PROFILE = "matrix-qa-v1";
const REDACTED_QUERY_VALUE = "[redacted]";
type MatrixQaStateFamily = "backup" | "device" | "key" | "media" | "sync-token";
type MatrixQaBodyShape =
| { kind: "binary" }
| { kind: "empty" }
| { kind: "json"; fields: string[] }
| { kind: "text" };
export type MatrixQaRecordedExchange = {
categories: MatrixQaStateFamily[];
request: {
body: MatrixQaBodyShape;
method: string;
query: Record<string, string>;
route: string;
};
response: {
body: MatrixQaBodyShape;
errcode?: string;
status: number;
};
scenarioId: string;
sequence: number;
sync?: {
continuity?: boolean;
nextBatch?: string;
since?: string;
};
};
type MatrixQaInternalRecordedExchange = MatrixQaRecordedExchange & {
operationFingerprint: string;
};
type MatrixQaRouteExpectation = {
count: number;
method: string;
route: string;
statuses: number[];
};
type MatrixQaOrderingExpectation = {
categories: MatrixQaStateFamily[];
count: number;
method: string;
requestBody: MatrixQaBodyShape;
responseBody: MatrixQaBodyShape;
route: string;
status: number;
};
type MatrixQaScenarioRouteStateExpectation = {
errors: Array<{
errcode?: string;
method: string;
route: string;
status: number;
}>;
ordering: MatrixQaOrderingExpectation[];
retries: Array<{
attempts: number;
kind: "retry";
method: string;
route: string;
statuses: number[];
}>;
routes: MatrixQaRouteExpectation[];
state: Record<MatrixQaStateFamily, string[]>;
syncTokens: {
continuityObserved: boolean;
incrementalRequests: number;
initialRequests: number;
responseTokens: number;
};
};
export type MatrixQaRouteStateManifest = {
generatedAt: string;
phases: Record<string, MatrixQaScenarioRouteStateExpectation>;
profile: {
derivedFrom: "observed-request-response-traffic";
id: typeof MATRIX_QA_RECORDING_PROFILE;
};
requestedProfile: string;
scenarios: Record<string, MatrixQaScenarioRouteStateExpectation>;
substrate: {
id: string;
version: string;
};
};
export type MatrixQaRecordingProxy = MatrixQaFaultProxyObserver & {
baseUrl: string;
buildManifest(params: {
generatedAt?: string;
requestedProfile: string;
scenarioIds: string[];
substrate: MatrixQaRouteStateManifest["substrate"];
}): MatrixQaRouteStateManifest;
records(): MatrixQaRecordedExchange[];
setScenarioId(scenarioId: string): void;
stop(): Promise<void>;
};
function normalizeHeaderValue(value: string | string[] | undefined) {
return Array.isArray(value) ? value.join(", ") : value;
}
function hasJsonContentType(headers: Headers | IncomingHttpHeaders) {
const raw = headers instanceof Headers ? headers.get("content-type") : headers["content-type"];
return (
normalizeHeaderValue(raw ?? undefined)
?.toLowerCase()
.includes("json") === true
);
}
function normalizeJsonMapKey(key: string, prefix: string, route: string) {
if (route.endsWith("/sync") && prefix === "rooms") {
return key;
}
if (key.startsWith("!")) {
return "{roomId}";
}
if (key.startsWith("@")) {
return "{userId}";
}
if (key.startsWith("$")) {
return "{eventId}";
}
if (route.endsWith("/keys/signatures/upload") && prefix === "") {
return "{userId}";
}
if (route.endsWith("/keys/signatures/upload") && prefix === "{userId}") {
return "{deviceOrKeyId}";
}
if (route.endsWith("/keys/upload") && prefix === "one_time_keys") {
return "{keyId}";
}
if (/(?:^|\.)encrypted$/u.test(prefix)) {
return "{keyId}";
}
if (prefix === "failures" && /\/keys\/(?:claim|query)$/u.test(route)) {
return "{serverName}";
}
if (
!route.endsWith("/keys/upload") &&
/^(?:device_keys|master_keys|self_signing_keys|user_signing_keys)$/u.test(prefix)
) {
return "{userId}";
}
if (/^(?:device_keys|devices|messages|one_time_keys)\.\{userId\}$/u.test(prefix)) {
return "{deviceId}";
}
if (!route.endsWith("/keys/upload") && prefix === "one_time_keys") {
return "{userId}";
}
if (
/^(?:device_keys\.)?\{userId\}\.\{deviceId\}\.(?:fallback_keys|keys|one_time_keys)$/u.test(
prefix,
) ||
/^one_time_keys\.\{userId\}\.\{deviceId\}$/u.test(prefix) ||
/^(?:ed25519|curve25519|signed_curve25519):/u.test(key)
) {
return "{keyId}";
}
if (/(?:^|\.)rooms$/u.test(prefix)) {
return "{roomId}";
}
if (/(?:^|\.)rooms\.\{roomId\}\.sessions$/u.test(prefix)) {
return "{sessionId}";
}
return key;
}
function collectJsonFields(value: unknown, route: string, prefix = "", depth = 0): string[] {
if (depth >= 8 || value === null || typeof value !== "object") {
return [];
}
if (Array.isArray(value)) {
if (value.length === 0) {
return prefix ? [`${prefix}[]`] : [];
}
const arrayPrefix = prefix ? `${prefix}[]` : "[]";
return [
...new Set(value.flatMap((entry) => collectJsonFields(entry, route, arrayPrefix, depth + 1))),
].toSorted();
}
const fixedDeviceKeysObject =
prefix === "device_keys" &&
["algorithms", "device_id", "keys", "signatures", "user_id"].some((key) => key in value);
return Object.entries(value)
.flatMap(([key, child]) => {
const safeKey = fixedDeviceKeysObject ? key : normalizeJsonMapKey(key, prefix, route);
const field = prefix ? `${prefix}.${safeKey}` : safeKey;
if (
/^(?:access_token|auth|body|ciphertext|content|file|formatted_body|password|recovery_key|session_data|token|unsigned)$/iu.test(
key,
)
) {
return [field];
}
const nested = collectJsonFields(child, route, field, depth + 1);
return nested.length > 0 ? nested : [field];
})
.toSorted();
}
function parseJsonBody(body: Buffer, headers: Headers | IncomingHttpHeaders): unknown {
if (body.byteLength === 0) {
return undefined;
}
const text = body.toString("utf8");
const firstNonWhitespace = text.trimStart()[0];
if (!hasJsonContentType(headers) && firstNonWhitespace !== "[" && firstNonWhitespace !== "{") {
return undefined;
}
try {
return JSON.parse(text) as unknown;
} catch {
return undefined;
}
}
const MATRIX_QA_STATE_FIELD_MARKERS = new Set([
"backup",
"content_uri",
"device_id",
"device_keys",
"device_lists",
"device_one_time_keys_count",
"device_unused_fallback_key_types",
"mimetype",
"one_time_key",
"session_data",
]);
function collectStateFieldMarkers(value: unknown, depth = 0): string[] {
if (depth >= 8 || value === null || typeof value !== "object") {
return [];
}
if (Array.isArray(value)) {
return [...new Set(value.flatMap((entry) => collectStateFieldMarkers(entry, depth + 1)))];
}
const markers = new Set<string>();
for (const [key, child] of Object.entries(value)) {
const normalizedKey = key.toLowerCase();
if (MATRIX_QA_STATE_FIELD_MARKERS.has(normalizedKey)) {
markers.add(normalizedKey);
}
for (const marker of collectStateFieldMarkers(child, depth + 1)) {
markers.add(marker);
}
}
return [...markers];
}
function extractStateFieldMarkers(body: Buffer, headers: Headers | IncomingHttpHeaders) {
const parsed = parseJsonBody(body, headers);
return parsed === undefined ? [] : collectStateFieldMarkers(parsed);
}
function buildBodyShape(
body: Buffer,
headers: Headers | IncomingHttpHeaders,
route: string,
): MatrixQaBodyShape {
if (body.byteLength === 0) {
return { kind: "empty" };
}
const parsed = parseJsonBody(body, headers);
if (parsed !== undefined) {
return { kind: "json", fields: collectJsonFields(parsed, route) };
}
const contentType =
headers instanceof Headers
? headers.get("content-type")
: normalizeHeaderValue(headers["content-type"]);
return contentType?.toLowerCase().startsWith("text/") ? { kind: "text" } : { kind: "binary" };
}
function normalizeMatrixIdSegment(segment: string) {
let decoded: string;
try {
decoded = decodeURIComponent(segment);
} catch {
return segment;
}
if (decoded.startsWith("!")) {
return "{roomId}";
}
if (decoded.startsWith("@")) {
return "{userId}";
}
if (decoded.startsWith("$")) {
return "{eventId}";
}
if (decoded.startsWith("#")) {
return "{roomAlias}";
}
return segment;
}
export function normalizeMatrixQaRoute(pathname: string) {
const segments = pathname.split("/");
for (let index = 0; index < segments.length; index += 1) {
const previous = segments[index - 1];
const beforePrevious = segments[index - 2];
if (previous === "rooms") {
segments[index] = "{roomId}";
continue;
}
if (previous === "profile" || previous === "user") {
segments[index] = "{userId}";
continue;
}
if (previous === "filter") {
segments[index] = "{filterId}";
continue;
}
if (previous === "join") {
segments[index] = normalizeMatrixIdSegment(segments[index] ?? "");
continue;
}
if (previous === "devices") {
segments[index] = "{deviceId}";
continue;
}
if (previous === "redact") {
segments[index] = "{eventId}";
continue;
}
if (beforePrevious === "send" || beforePrevious === "redact") {
segments[index] = "{transactionId}";
continue;
}
if (beforePrevious === "sendToDevice") {
segments[index] = "{transactionId}";
continue;
}
if (previous === "version" && beforePrevious === "room_keys") {
segments[index] = "{backupVersion}";
continue;
}
if (previous === "keys" && beforePrevious === "room_keys") {
segments[index] = "{roomId}";
continue;
}
if (segments[index - 2] === "keys" && segments[index - 3] === "room_keys") {
segments[index] = "{sessionId}";
continue;
}
if (segments[index - 2] === "state" && segments[index] !== "") {
segments[index] = "{stateKey}";
continue;
}
if (previous === "account_data" && segments[index]?.startsWith("m.secret_storage.key.")) {
segments[index] = "m.secret_storage.key.{keyId}";
continue;
}
const mediaActionIndex = segments.findIndex(
(segment) => segment === "download" || segment === "thumbnail",
);
if (mediaActionIndex >= 0 && index === mediaActionIndex + 2) {
segments[index] = "{mediaId}";
segments[index - 1] = "{serverName}";
continue;
}
if (mediaActionIndex >= 0 && index === mediaActionIndex + 3) {
segments[index] = "{filename}";
continue;
}
segments[index] = normalizeMatrixIdSegment(segments[index] ?? "");
}
return segments.join("/");
}
function buildRedactedQuery(search: string, syncTokens: Map<string, string>) {
const result: Record<string, string> = {};
for (const [key, value] of new URLSearchParams(search)) {
if (key === "since") {
result[key] = syncTokens.get(value) ?? "sync-unknown";
} else if (key === "timeout" || key === "full_state" || key === "set_presence") {
result[key] = value;
} else {
result[key] = REDACTED_QUERY_VALUE;
}
}
return result;
}
function resolveStateFamilies(params: {
requestFields: string[];
responseFields: string[];
route: string;
}) {
const fields = [...params.requestFields, ...params.responseFields].join(" ").toLowerCase();
const route = params.route.toLowerCase();
const families = new Set<MatrixQaStateFamily>();
if (route.includes("/sync")) {
families.add("sync-token");
}
if (route.includes("/room_keys/") || fields.includes("backup")) {
families.add("backup");
}
if (route.includes("/account_data/m.megolm_backup.")) {
families.add("backup");
}
if (
route.includes("/keys/") ||
route.includes("/sendtodevice/") ||
route.includes("/account_data/m.cross_signing.") ||
route.includes("/account_data/m.secret_storage.") ||
fields.includes("one_time_key") ||
fields.includes("device_one_time_keys_count") ||
fields.includes("device_unused_fallback_key_types") ||
fields.includes("device_keys") ||
fields.includes("session_data")
) {
families.add("key");
}
if (
route.includes("/devices") ||
fields.includes("device_id") ||
fields.includes("device_lists")
) {
families.add("device");
}
if (route.includes("/media/") || fields.includes("content_uri") || fields.includes("mimetype")) {
families.add("media");
}
return [...families].toSorted();
}
function buildExpectation(
records: MatrixQaInternalRecordedExchange[],
): MatrixQaScenarioRouteStateExpectation {
const orderedRecords = records.toSorted((left, right) => left.sequence - right.sequence);
const routeGroups = new Map<string, MatrixQaRouteExpectation>();
const ordering: MatrixQaOrderingExpectation[] = [];
const state = {
backup: new Set<string>(),
device: new Set<string>(),
key: new Set<string>(),
media: new Set<string>(),
"sync-token": new Set<string>(),
} satisfies Record<MatrixQaStateFamily, Set<string>>;
for (const record of orderedRecords) {
const routeKey = `${record.request.method} ${record.request.route}`;
const route = routeGroups.get(routeKey) ?? {
count: 0,
method: record.request.method,
route: record.request.route,
statuses: [],
};
route.count += 1;
if (!route.statuses.includes(record.response.status)) {
route.statuses.push(record.response.status);
route.statuses.sort((left, right) => left - right);
}
routeGroups.set(routeKey, route);
for (const category of record.categories) {
state[category].add(record.request.route);
}
const previous = ordering.at(-1);
if (
previous?.method === record.request.method &&
previous.route === record.request.route &&
previous.status === record.response.status &&
JSON.stringify(previous.requestBody) === JSON.stringify(record.request.body) &&
JSON.stringify(previous.responseBody) === JSON.stringify(record.response.body) &&
previous.categories.join("\0") === record.categories.join("\0")
) {
previous.count += 1;
} else {
ordering.push({
categories: record.categories,
count: 1,
method: record.request.method,
requestBody: record.request.body,
responseBody: record.response.body,
route: record.request.route,
status: record.response.status,
});
}
}
const routes = [...routeGroups.values()].toSorted((left, right) =>
`${left.method} ${left.route}`.localeCompare(`${right.method} ${right.route}`),
);
const retries: MatrixQaScenarioRouteStateExpectation["retries"] = [];
const adjacentOperationRuns: MatrixQaInternalRecordedExchange[][] = [];
for (const record of orderedRecords) {
const currentRun = adjacentOperationRuns.at(-1);
if (currentRun?.[0]?.operationFingerprint === record.operationFingerprint) {
currentRun.push(record);
} else {
adjacentOperationRuns.push([record]);
}
}
for (const attempts of adjacentOperationRuns) {
if (attempts[0]?.request.route.endsWith("/sync")) {
continue;
}
for (let index = 0; index < attempts.length; index += 1) {
const first = attempts[index];
if (!first || first.response.status < 400) {
continue;
}
const recoveryOffset = attempts
.slice(index + 1)
.findIndex((attempt) => attempt.response.status < 400);
const retryEndIndex = recoveryOffset < 0 ? attempts.length : index + recoveryOffset + 2;
const retryAttempts = attempts.slice(index, retryEndIndex);
if (retryAttempts.length < 2) {
continue;
}
retries.push({
attempts: retryAttempts.length,
kind: "retry",
method: first.request.method,
route: first.request.route,
statuses: retryAttempts.map((attempt) => attempt.response.status),
});
index = retryEndIndex - 1;
}
}
const incrementalSyncRecords = orderedRecords.filter(
(record) => record.sync?.since !== undefined,
);
return {
errors: orderedRecords
.filter((record) => record.response.status >= 400)
.map((record) => {
const error: MatrixQaScenarioRouteStateExpectation["errors"][number] = {
method: record.request.method,
route: record.request.route,
status: record.response.status,
};
if (record.response.errcode) {
error.errcode = record.response.errcode;
}
return error;
}),
ordering,
retries,
routes,
state: Object.fromEntries(
Object.entries(state).map(([key, values]) => [key, [...values].toSorted()]),
) as Record<MatrixQaStateFamily, string[]>,
syncTokens: {
continuityObserved:
incrementalSyncRecords.length > 0 &&
incrementalSyncRecords.every((record) => record.sync?.continuity === true),
incrementalRequests: incrementalSyncRecords.length,
initialRequests: orderedRecords.filter(
(record) => record.categories.includes("sync-token") && record.sync?.since === undefined,
).length,
responseTokens: orderedRecords.filter((record) => record.sync?.nextBatch !== undefined)
.length,
},
};
}
function extractErrcode(body: Buffer, headers: Headers) {
const parsed = parseJsonBody(body, headers);
if (typeof parsed !== "object" || parsed === null) {
return undefined;
}
const errcode = (parsed as { errcode?: unknown }).errcode;
return typeof errcode === "string" ? errcode : undefined;
}
function extractNextBatch(body: Buffer, headers: Headers) {
const parsed = parseJsonBody(body, headers);
if (typeof parsed !== "object" || parsed === null) {
return undefined;
}
const nextBatch = (parsed as { next_batch?: unknown }).next_batch;
return typeof nextBatch === "string" ? nextBatch : undefined;
}
export async function startMatrixQaRecordingProxy(params: {
targetBaseUrl: string;
}): Promise<MatrixQaRecordingProxy> {
let scenarioId = "setup";
let sequence = 0;
const records: MatrixQaInternalRecordedExchange[] = [];
const syncTokensByPrincipal = new Map<string, Map<string, string>>();
const observer: Required<MatrixQaFaultProxyObserver> = {
createExchangeContext: () => ({ scenarioId, sequence: ++sequence }),
onExchange(exchange: MatrixQaFaultProxyExchange) {
recordExchange(exchange);
},
};
const recordExchange = (exchange: MatrixQaFaultProxyExchange) => {
const context =
typeof exchange.context === "object" && exchange.context !== null
? (exchange.context as { scenarioId?: unknown; sequence?: unknown })
: undefined;
const exchangeSequence = typeof context?.sequence === "number" ? context.sequence : ++sequence;
const route = normalizeMatrixQaRoute(exchange.request.path);
const exchangeScenarioId = route.endsWith("/sync")
? scenarioId
: typeof context?.scenarioId === "string"
? context.scenarioId
: "unattributed";
const requestBody = buildBodyShape(exchange.request.body, exchange.request.headers, route);
const responseBody = buildBodyShape(exchange.response.body, exchange.response.headers, route);
const requestFields = extractStateFieldMarkers(exchange.request.body, exchange.request.headers);
const responseFields = extractStateFieldMarkers(
exchange.response.body,
exchange.response.headers,
);
const syncPrincipal = exchange.request.bearerToken ?? "anonymous";
const syncTokens = syncTokensByPrincipal.get(syncPrincipal) ?? new Map<string, string>();
syncTokensByPrincipal.set(syncPrincipal, syncTokens);
const sinceRaw = new URLSearchParams(exchange.request.search).get("since") ?? undefined;
const since = sinceRaw ? (syncTokens.get(sinceRaw) ?? "sync-unknown") : undefined;
const nextBatch = extractNextBatch(exchange.response.body, exchange.response.headers);
if (nextBatch && !syncTokens.has(nextBatch)) {
syncTokens.set(nextBatch, `sync-${syncTokens.size + 1}`);
}
const nextBatchAlias = nextBatch ? syncTokens.get(nextBatch) : undefined;
const responseErrcode = extractErrcode(exchange.response.body, exchange.response.headers);
const operationFingerprint = createHash("sha256")
.update(exchange.request.method)
.update("\0")
.update(exchange.request.path)
.update("\0")
.update(exchange.request.search)
.update("\0")
.update(exchange.request.body)
.update("\0")
.update(exchange.request.bearerToken ?? "anonymous")
.digest("hex");
records.push({
categories: resolveStateFamilies({ requestFields, responseFields, route }),
request: {
body: requestBody,
method: exchange.request.method,
query: buildRedactedQuery(exchange.request.search, syncTokens),
route,
},
response: {
body: responseBody,
...(responseErrcode ? { errcode: responseErrcode } : {}),
status: exchange.response.status,
},
scenarioId: exchangeScenarioId,
sequence: exchangeSequence,
operationFingerprint,
...(route.endsWith("/sync")
? {
sync: {
...(since ? { since } : {}),
...(nextBatchAlias ? { nextBatch: nextBatchAlias } : {}),
...(since && nextBatchAlias ? { continuity: since !== "sync-unknown" } : {}),
},
}
: {}),
});
};
const proxy = await startMatrixQaFaultProxy({
targetBaseUrl: params.targetBaseUrl,
rules: [],
...observer,
});
return {
baseUrl: proxy.baseUrl,
...observer,
buildManifest({ generatedAt, requestedProfile, scenarioIds, substrate }) {
const selectedIds = new Set(scenarioIds);
const byScenario = new Map<string, MatrixQaInternalRecordedExchange[]>();
for (const record of records) {
const entries = byScenario.get(record.scenarioId) ?? [];
entries.push(record);
byScenario.set(record.scenarioId, entries);
}
const scenarios = Object.fromEntries(
scenarioIds.map((id) => [id, buildExpectation(byScenario.get(id) ?? [])]),
);
const phases = Object.fromEntries(
[...byScenario.entries()]
.filter(([id]) => !selectedIds.has(id))
.toSorted(([left], [right]) => left.localeCompare(right))
.map(([id, entries]) => [id, buildExpectation(entries)]),
);
return {
generatedAt: generatedAt ?? new Date().toISOString(),
phases,
profile: {
derivedFrom: "observed-request-response-traffic",
id: MATRIX_QA_RECORDING_PROFILE,
},
requestedProfile,
scenarios,
substrate,
};
},
records: () =>
structuredClone(
records
.toSorted((left, right) => left.sequence - right.sequence)
.map(({ operationFingerprint: _operationFingerprint, ...record }) => record),
),
setScenarioId(nextScenarioId) {
scenarioId = nextScenarioId;
},
stop: () => proxy.stop(),
};
}
export const testing = {
buildExpectation,
normalizeMatrixQaRoute,
};
export { testing as __testing };