244 lines
8.5 KiB
JavaScript
244 lines
8.5 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import { createHash } from "node:crypto";
|
|
import { isUtf8 } from "node:buffer";
|
|
const digest = (bytes) => createHash("sha256").update(bytes).digest("hex");
|
|
// The native decoder displays strict UTF-8 and retains invalid bytes as null text.
|
|
const textOf = (expected) => {
|
|
const value = Buffer.from(expected.base64, "base64");
|
|
return isUtf8(value) ? value.toString("utf8") : null;
|
|
};
|
|
const field = (record, pointer) =>
|
|
record.binary_fields.find((item) => item.pointer === pointer);
|
|
const bytes = (record, pointer) => {
|
|
const value = field(record, pointer);
|
|
assert.equal(
|
|
value?.state,
|
|
"retained",
|
|
`Missing retained bytes at ${pointer}`,
|
|
);
|
|
const decoded = Buffer.from(value.content_base64, "base64");
|
|
assert.equal(value.bytes, decoded.length);
|
|
assert.equal(value.sha256, digest(decoded));
|
|
return decoded;
|
|
};
|
|
const binaryEqual = (record, pointer, expected) => {
|
|
const decoded = bytes(record, pointer);
|
|
assert.deepEqual(decoded, Buffer.from(expected.base64, "base64"), pointer);
|
|
assert.equal(field(record, pointer).sha256, expected.sha256);
|
|
assert.equal(field(record, pointer).bytes, expected.bytes);
|
|
};
|
|
const caseOf = (record) =>
|
|
record.reported.request.headers.find(
|
|
([name]) => name === "x-lab-case",
|
|
)?.[1] ?? "curl";
|
|
const byCase = (value, name) => {
|
|
const selected = value.records.filter((record) => caseOf(record) === name);
|
|
assert.equal(
|
|
selected.length,
|
|
1,
|
|
`Expected exactly one producer record for ${name}`,
|
|
);
|
|
return selected[0];
|
|
};
|
|
const connectionH3 = (record, side) => {
|
|
const conn = record.reported[side];
|
|
assert.equal(conn.transport_protocol, "udp");
|
|
assert.equal(bytes(record, `/${side}/alpn`).toString(), "h3");
|
|
assert.equal(conn.alpn, "h3", `${side}.alpn readable value`);
|
|
assert.equal(typeof conn.timestamp_tls_setup, "number");
|
|
};
|
|
|
|
function assertHeaders(record, side, headers) {
|
|
const regular = headers.filter(
|
|
([name]) => !Buffer.from(name.base64, "base64").toString().startsWith(":"),
|
|
);
|
|
assert.equal(record.reported[side].headers.length, regular.length);
|
|
for (const [index, pair] of regular.entries()) {
|
|
binaryEqual(record, `/${side}/headers/${index}/0`, pair[0]);
|
|
binaryEqual(record, `/${side}/headers/${index}/1`, pair[1]);
|
|
assert.deepEqual(
|
|
record.reported[side].headers[index],
|
|
pair.map(textOf),
|
|
`${side}.headers[${index}] readable values`,
|
|
);
|
|
}
|
|
}
|
|
|
|
export function assertEndpointHeaders(actual, expected) {
|
|
// The proxy rebuilds pseudo fields from its HTTP state. Compare their values
|
|
// by name while retaining the exact order and duplicates of regular headers.
|
|
const partition = (headers) => {
|
|
const pseudo = new Map();
|
|
const regular = [];
|
|
for (const pair of headers) {
|
|
const name = Buffer.from(pair[0].base64, "base64").toString();
|
|
if (name.startsWith(":")) {
|
|
assert.equal(pseudo.has(name), false);
|
|
pseudo.set(name, pair);
|
|
} else regular.push(pair);
|
|
}
|
|
return { pseudo, regular };
|
|
};
|
|
assert.deepEqual(partition(actual), partition(expected));
|
|
}
|
|
|
|
export function requireHttp3(record) {
|
|
for (const side of ["request", "response"])
|
|
assert.equal(record.reported[side].http_version, "HTTP/3");
|
|
connectionH3(record, "client_conn");
|
|
connectionH3(record, "server_conn");
|
|
}
|
|
|
|
export function verifyNormalRecords(normal, manifest, finalProducer) {
|
|
for (const record of normal.records) {
|
|
const name = caseOf(record);
|
|
const producer = finalProducer.find(
|
|
(event) => event.id === record.reported.id,
|
|
);
|
|
assert.ok(producer, `No actual producer observation for ${name}`);
|
|
assert.equal(record.reported.error, null);
|
|
assert.equal(producer.error, null);
|
|
assert.equal(record.reported.response.status_code, 200);
|
|
requireHttp3(record);
|
|
for (const side of ["request", "response"]) {
|
|
assert.equal(
|
|
record.reported[side].http_version,
|
|
producer[side].http_version,
|
|
);
|
|
binaryEqual(record, `/${side}/content`, producer[side].content);
|
|
assertHeaders(
|
|
record,
|
|
side,
|
|
manifest[side === "request" ? "requests" : "responses"][name].headers,
|
|
);
|
|
}
|
|
// Native UUIDs must be retained as UUIDs; the producer exports no stream mapping.
|
|
for (const side of ["client_conn", "server_conn"])
|
|
for (const key of ["id", "tls_version", "timestamp_tls_setup", "error"])
|
|
assert.equal(
|
|
record.reported[side][key],
|
|
producer[side][key],
|
|
`${side}.${key} differs from producer observation ${producer.id}`,
|
|
);
|
|
binaryEqual(record, "/request/content", manifest.requests[name].body);
|
|
binaryEqual(record, "/response/content", manifest.responses[name].body);
|
|
const pseudo = {
|
|
":method": "method",
|
|
":scheme": "scheme",
|
|
":authority": "authority",
|
|
":path": "path",
|
|
};
|
|
for (const [headerName, headerValue] of manifest.requests[name].headers) {
|
|
const decodedName = Buffer.from(headerName.base64, "base64").toString();
|
|
if (pseudo[decodedName]) {
|
|
binaryEqual(record, `/request/${pseudo[decodedName]}`, headerValue);
|
|
assert.equal(
|
|
record.reported.request[pseudo[decodedName]],
|
|
textOf(headerValue),
|
|
`request.${pseudo[decodedName]} readable value`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
export { bytes, caseOf, byCase };
|
|
|
|
export function verifyConcurrency(normal, manifest) {
|
|
const parallelNames = ["parallel-0", "parallel-1", "parallel-2"];
|
|
const sent = manifest.events.filter(
|
|
(event) =>
|
|
event.event === "request-sent" && parallelNames.includes(event.case),
|
|
);
|
|
const received = manifest.events.filter(
|
|
(event) =>
|
|
event.event === "request-received" && parallelNames.includes(event.case),
|
|
);
|
|
const responses = manifest.events.filter(
|
|
(event) =>
|
|
event.event === "response-sent" && parallelNames.includes(event.case),
|
|
);
|
|
assert.equal(new Set(sent.map((event) => event.connection)).size, 1);
|
|
assert.equal(new Set(received.map((event) => event.connection)).size, 1);
|
|
assert.equal(new Set(sent.map((event) => event.stream_id)).size, 3);
|
|
assert.equal(new Set(received.map((event) => event.stream_id)).size, 3);
|
|
assert.ok(
|
|
Math.max(...received.map((event) => event.monotonic)) <
|
|
responses[0].monotonic,
|
|
);
|
|
assert.deepEqual(
|
|
responses.map((event) => event.case),
|
|
received.map((event) => event.case).reverse(),
|
|
);
|
|
for (const side of ["client_conn", "server_conn"])
|
|
assert.equal(
|
|
new Set(
|
|
parallelNames.map((name) => byCase(normal, name).reported[side].id),
|
|
).size,
|
|
1,
|
|
);
|
|
const normalConnections = new Set(
|
|
[...sent, ...received].map((event) => event.connection),
|
|
);
|
|
for (const connection of normalConnections) {
|
|
const handshakes = manifest.events.filter(
|
|
(event) => event.event === "handshake" && event.connection === connection,
|
|
);
|
|
assert.equal(handshakes.length, 1);
|
|
assert.equal(handshakes[0].alpn, "h3");
|
|
assert.equal(handshakes[0].quic_version, 1);
|
|
}
|
|
assert.equal(
|
|
new Set(normal.records.map((record) => record.reported.id)).size,
|
|
4,
|
|
);
|
|
assert.equal(sent.length, 3);
|
|
const receivedByClient = manifest.events.filter(
|
|
(event) =>
|
|
event.event === "response-received" &&
|
|
sent.some(
|
|
(request) =>
|
|
request.connection === event.connection &&
|
|
request.stream_id === event.stream_id,
|
|
),
|
|
);
|
|
assert.equal(receivedByClient.length, 3);
|
|
for (const request of sent) {
|
|
const response = receivedByClient.find(
|
|
(event) =>
|
|
event.connection === request.connection &&
|
|
event.stream_id === request.stream_id,
|
|
);
|
|
assert.ok(
|
|
response,
|
|
"Each sent request needs an independently observed client response",
|
|
);
|
|
assert.deepEqual(response.body, manifest.responses[request.case].body);
|
|
assert.deepEqual(request.body, manifest.requests[request.case].body);
|
|
assertEndpointHeaders(
|
|
request.headers,
|
|
manifest.requests[request.case].headers,
|
|
);
|
|
assertEndpointHeaders(
|
|
response.headers,
|
|
manifest.responses[request.case].headers,
|
|
);
|
|
}
|
|
return {
|
|
sent: sent.map((event) => event.case),
|
|
completed_at_origin: received.map((event) => event.case),
|
|
dispatched_at_origin: responses.map((event) => event.case),
|
|
completed_at_client: receivedByClient.map(
|
|
(event) =>
|
|
sent.find(
|
|
(request) =>
|
|
request.connection === event.connection &&
|
|
request.stream_id === event.stream_id,
|
|
).case,
|
|
),
|
|
saved_in_capture: normal.records
|
|
.filter((record) => parallelNames.includes(caseOf(record)))
|
|
.map(caseOf),
|
|
};
|
|
}
|