import { createHash } from "node:crypto";
import { promises as fs } from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
// ★ルート本体は静的 import★
import { GET as getCommands } from "@/app/api/ds-agent/commands/route";
import { POST as postResult } from "@/app/api/ds-agent/commands/[command_id]/result/route";
import { POST as postPrepared } from "@/app/api/ds-agent/commands/[command_id]/prepared/route";
import { POST as postChunk } from "@/app/api/ds-agent/commands/[command_id]/chunks/[seq]/route";
import { POST as postComplete } from "@/app/api/ds-agent/commands/[command_id]/complete/route";
import { POST as postFail } from "@/app/api/ds-agent/commands/[command_id]/fail/route";
import { LIST_RESULT_MAX_ITEMS, PATH_COMMAND_ID_MAX_LENGTH } from "@/lib/config/ds-agent";
import { createFakeRelay, flush } from "@/lib/ds-agent/__tests__/fake-relay";
import { __setRelayForTesting } from "@/lib/ds-agent/relay";
import { __setAgentTokenStoreForTesting, createAgentTokenStore, issueAgentToken } from "@/lib/ds-agent/tokens";
import type { DsAgentCommand } from "@/lib/types";
/**
* 指示書AY 段階7-4 — 制御盤向けの6本(`app/api/ds-agent/**`)。
* - ★Bearer なし・違う → 401(`requireAgent()` の再検証)★
* - 表5 R7(どのルートでも last_seen_at が進む)・R35・R36・チャンクの 400 / 409 / 410・段階7-2 W1〜W11・prepared の検証
* - ★表5 R44(オブジェクトでない本文は4ルートとも 400 result_invalid。500 にならない)★
* - ★command_id / seq の 422(段階3-3 X2・X5・X6)★
*
* トークンストアは mkdtemp(★ストアとパスを一緒に差し替える。M13★)。relay は偽の時計。★agent のルートは DB を読まない★。
*/
vi.mock("@/lib/logger", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/logger")>();
return { ...actual, logEvent: vi.fn() };
});
let dir = "";
let fake: ReturnType<typeof createFakeRelay>;
let token002 = "";
let token003 = "";
beforeEach(async () => {
dir = await fs.mkdtemp(path.join(os.tmpdir(), "dwatch-agent-routes-"));
__setAgentTokenStoreForTesting(createAgentTokenStore(path.join(dir, "ds-agent-tokens.json")));
token002 = (await issueAgentToken("DS-002")).token;
token003 = (await issueAgentToken("DS-003")).token;
fake = createFakeRelay({ chunkMaxBytes: 16 });
__setRelayForTesting(fake.relay);
});
afterEach(async () => {
__setRelayForTesting(null);
__setAgentTokenStoreForTesting(null);
await fs.rm(dir, { recursive: true, force: true });
});
const signal = () => new AbortController().signal;
const sha = (bytes: Uint8Array) => createHash("sha256").update(bytes).digest("hex");
interface CallOptions {
token?: string | null;
body?: BodyInit;
headers?: Record<string, string>;
}
function agentRequest(pathname: string, method: string, opts: CallOptions = {}): Request {
const token = opts.token === undefined ? token002 : opts.token;
const headers: Record<string, string> = { ...(opts.headers ?? {}) };
if (token !== null) headers.authorization = `Bearer ${token}`;
return new Request(`http://localhost${pathname}`, { method, headers, body: opts.body });
}
const commands = (qs = "", opts: CallOptions = {}) =>
getCommands(agentRequest(`/api/ds-agent/commands${qs}`, "GET", opts));
type CommandRoute = "result" | "prepared" | "complete" | "fail";
const COMMAND_ROUTES = { result: postResult, prepared: postPrepared, complete: postComplete, fail: postFail };
function commandPost(route: CommandRoute, commandId: string, body: string, opts: CallOptions = {}) {
return COMMAND_ROUTES[route](
agentRequest(`/api/ds-agent/commands/${commandId}/${route}`, "POST", { ...opts, body }),
{ params: Promise.resolve({ command_id: commandId }) }
);
}
function chunkPost(commandId: string, seq: string, bytes: Uint8Array, opts: CallOptions & { sha?: string | null } = {}) {
const headers: Record<string, string> = {};
const hash = opts.sha === undefined ? sha(bytes) : opts.sha;
if (hash !== null) headers["x-chunk-sha256"] = hash;
return postChunk(
agentRequest(`/api/ds-agent/commands/${commandId}/chunks/${seq}`, "POST", {
...opts,
headers,
body: Buffer.from(bytes),
}),
{ params: Promise.resolve({ command_id: commandId, seq }) }
);
}
async function errorCode(res: Response): Promise<string> {
return ((await res.json()) as { error: { code: string } }).error.code;
}
/** 本文を1回だけ読んで code と message を両方返す(指示書BK K1) */
async function errorBody(res: Response): Promise<{ code: string; message: string }> {
const error = ((await res.json()) as { error: { code: string; message: string } }).error;
return { code: error.code, message: error.message };
}
/**
* ルート経由の `GET /ds-agent/commands` は `requireAgent()`(async)のあとで poll に入る。
* ★`flush()` 1回では足りないことがある★ので、接続中になるまで待つ(偽の時計のタイマー登録前に `at()` しない)。
*/
async function waitUntilConnected(serial = "DS-002"): Promise<void> {
await vi.waitFor(
() => {
if (!fake.relay.agentStatus(serial).connected) {
throw new Error(`${serial} が接続状態にならなかった`);
}
},
{ timeout: 4000, interval: 5 },
);
}
/** ブラウザの役: 一覧を依頼し、制御盤の役: list の指示を受け取る */
async function pendingList() {
const poll = fake.relay.poll("DS-002", "1.0.0", 25, signal());
const list = fake.relay.requestList("DS-002");
const command = (await poll) as DsAgentCommand;
return { list, command };
}
/** ブラウザの役: エクスポートを依頼し、制御盤の役: export の指示を受け取る */
async function pendingExport() {
const poll = fake.relay.poll("DS-002", "1.0.0", 25, signal());
const created = fake.relay.createExport("DS-002", ["f1"], signal());
const command = (await poll) as DsAgentCommand;
return { created, command };
}
const VALID_LIST = {
types: [{ key: "app", label: "アプリ" }],
items: [{ file_id: "f1", name: "app/app.log", type: "app", size_bytes: 10, modified_at: "2026-10-19T11:00:00+09:00" }],
truncated: false,
};
describe("認証(requireAgent の再検証)", () => {
const cases: Array<[string, (opts: CallOptions) => Promise<Response>]> = [
["commands", (o) => commands("", o)],
["result", (o) => commandPost("result", "x", "{}", o)],
["prepared", (o) => commandPost("prepared", "x", "{}", o)],
["complete", (o) => commandPost("complete", "x", "{}", o)],
["fail", (o) => commandPost("fail", "x", "{}", o)],
["chunks", (o) => chunkPost("x", "0", new Uint8Array([1]), o)],
];
it.each(cases)("%s: Bearer なし → 401", async (_name, call) => {
const res = await call({ token: null });
expect(res.status).toBe(401);
expect(await errorCode(res)).toBe("unauthorized");
});
it.each(cases)("%s: 違う Bearer → 401", async (_name, call) => {
const res = await call({ token: "Z".repeat(43) });
expect(res.status).toBe(401);
});
// ★commands 以外★: requireAgent の touch だけで終わる。commands は poll で待つので別ケース
it.each(cases.filter(([name]) => name !== "commands"))(
"R7 %s: 認証が通れば last_seen_at が進む(D19)",
async (_name, call) => {
await fake.at(80);
await call({});
expect(fake.relay.agentStatus("DS-002").last_seen_at).toBe("2026-10-19T12:01:20+09:00");
}
);
it("R7 commands: 認証が通れば last_seen_at が進む(D19)", async () => {
await fake.at(80);
const res = commands("?wait=1");
await waitUntilConnected();
expect(fake.relay.agentStatus("DS-002").last_seen_at).toBe("2026-10-19T12:01:20+09:00");
await fake.at(81);
expect((await res).status).toBe(204);
});
});
describe("GET /ds-agent/commands", () => {
it.each(["wait=0", "wait=26", "wait=abc"])("%s → 422(丸めない。表5 R8)", async (qs) => {
const res = await commands(`?${qs}`);
expect(res.status).toBe(422);
expect(await errorCode(res)).toBe("validation_failed");
});
it("指示があれば 200(kind: list)・X-Agent-Version を記録", async () => {
const res = commands("?wait=5", { headers: { "x-agent-version": "1.2.3" } });
await waitUntilConnected();
const list = fake.relay.requestList("DS-002");
const done = await res;
expect(done.status).toBe(200);
const command = (await done.json()) as DsAgentCommand;
expect(command).toMatchObject({ kind: "list", params: {} });
expect(fake.relay.agentStatus("DS-002").agent_version).toBe("1.2.3");
fake.relay.submitListResult("DS-002", command.command_id, VALID_LIST);
await expect(list).resolves.toMatchObject({ kind: "ok" });
});
it("指示が無ければ wait 秒で 204・形の合わない X-Agent-Version は無視(拒否しない)", async () => {
const res = commands("?wait=5", { headers: { "x-agent-version": "bad version!" } });
await waitUntilConnected();
await fake.at(5);
const done = await res;
expect(done.status).toBe(204);
expect(fake.relay.agentStatus("DS-002").agent_version).toBeNull();
});
});
describe("POST …/result(段階7-2 W1〜W11)", () => {
it("W1 正しい結果 → 204、ブラウザに一覧が返る", async () => {
const { list, command } = await pendingList();
const res = await commandPost("result", command.command_id, JSON.stringify(VALID_LIST));
expect(res.status).toBe(204);
await expect(list).resolves.toEqual({ kind: "ok", result: VALID_LIST });
});
it("W9 { error } → 204、ブラウザには agent_error", async () => {
const { list, command } = await pendingList();
const res = await commandPost("result", command.command_id, JSON.stringify({ error: { code: "io_error", message: "m" } }));
expect(res.status).toBe(204);
await expect(list).resolves.toEqual({ kind: "agent_error", code: "io_error" });
});
it("W11 name に / を含む → 受理", async () => {
const { command } = await pendingList();
const body = { ...VALID_LIST, items: [{ ...VALID_LIST.items[0], name: "api_client/api_client.log.2026-10-18" }] };
expect((await commandPost("result", command.command_id, JSON.stringify(body))).status).toBe(204);
});
it.each<[string, unknown]>([
["W2 key の重複", { ...VALID_LIST, types: [...VALID_LIST.types, { key: "app", label: "x" }] }],
["W3 types に無い type", { ...VALID_LIST, items: [{ ...VALID_LIST.items[0], type: "xyz" }] }],
["W4 オフセットなし", { ...VALID_LIST, items: [{ ...VALID_LIST.items[0], modified_at: "2026-10-19T11:00:00" }] }],
["W4b 存在しない日付", { ...VALID_LIST, items: [{ ...VALID_LIST.items[0], modified_at: "2026-02-30T00:00:00+09:00" }] }],
["W5 size_bytes が負", { ...VALID_LIST, items: [{ ...VALID_LIST.items[0], size_bytes: -1 }] }],
["W6 file_id の重複", { ...VALID_LIST, items: [VALID_LIST.items[0], { ...VALID_LIST.items[0], name: "app/b.log" }] }],
[
"W7 LIST_RESULT_MAX_ITEMS + 1 件",
{
...VALID_LIST,
items: Array.from({ length: LIST_RESULT_MAX_ITEMS + 1 }, (_, i) => ({ ...VALID_LIST.items[0], file_id: `f${i}` })),
},
],
["R47 file_id が8193文字", { ...VALID_LIST, items: [{ ...VALID_LIST.items[0], file_id: "x".repeat(8193) }] }],
])("%s → 400 result_invalid・ブラウザの一覧は 502 で終わる(指示書BJ R43′)", async (_label, body) => {
const { list, command } = await pendingList();
const res = await commandPost("result", command.command_id, JSON.stringify(body));
expect(res.status).toBe(400);
expect(await errorCode(res)).toBe("result_invalid");
await expect(list).resolves.toEqual({ kind: "agent_error", code: "result_invalid" });
const after = await commandPost("result", command.command_id, JSON.stringify(VALID_LIST));
expect(after.status).toBe(404);
expect(await errorCode(after)).toBe("not_found");
});
it.each<[string, string]>([
["R45 key が空", ""],
["R46 key にカンマ", "a,b"],
["R46b key が空白だけ", " "],
["R46b key の先頭が空白", " app"],
])("%s → 400 result_invalid・typesの形式が不正です(指示書BK K1)", async (_label, bad) => {
const body = {
...VALID_LIST,
types: [{ key: bad, label: "x" }],
items: [{ ...VALID_LIST.items[0], type: bad }],
};
const { list, command } = await pendingList();
const res = await commandPost("result", command.command_id, JSON.stringify(body));
expect(res.status).toBe(400);
expect(await errorBody(res)).toEqual({ code: "result_invalid", message: "typesの形式が不正です" });
await expect(list).resolves.toEqual({ kind: "agent_error", code: "result_invalid" });
const after = await commandPost("result", command.command_id, JSON.stringify(VALID_LIST));
expect(after.status).toBe(404);
expect(await errorCode(after)).toBe("not_found");
});
it("W8 本体が 4MB を超える → 400 result_invalid・ブラウザの一覧は 502 で終わる", async () => {
const { list, command } = await pendingList();
const res = await commandPost("result", command.command_id, JSON.stringify(VALID_LIST) + " ".repeat(4 * 1024 * 1024));
expect(res.status).toBe(400);
await expect(list).resolves.toEqual({ kind: "agent_error", code: "result_invalid" });
});
it("R43‴ export の指示 id に形の崩れた /result → 400・エクスポートは続く", async () => {
const { created, command } = await pendingExport();
const res = await commandPost("result", command.command_id, JSON.stringify({ types: [], items: [] }));
expect(res.status).toBe(400);
expect(await errorCode(res)).toBe("result_invalid");
expect(
(
await commandPost(
"prepared",
command.command_id,
JSON.stringify({ total_bytes: 4, sha256: "a".repeat(64), file_count: 1 })
)
).status
).toBe(204);
await expect(created).resolves.toMatchObject({ kind: "prepared" });
});
it("R48 prepared の total_bytes: 0 → 400、1 はよい", async () => {
const { created, command } = await pendingExport();
expect(
(
await commandPost(
"prepared",
command.command_id,
JSON.stringify({ total_bytes: 0, sha256: "a".repeat(64), file_count: 1 })
)
).status
).toBe(400);
expect(
(
await commandPost(
"prepared",
command.command_id,
JSON.stringify({ total_bytes: 1, sha256: "a".repeat(64), file_count: 1 })
)
).status
).toBe(204);
await expect(created).resolves.toMatchObject({ kind: "prepared", totalBytes: 1 });
});
it("R50 /result に X-Agent-Version → connected の agent_version が入る", async () => {
await commandPost("result", "x", "{}", { headers: { "x-agent-version": "1.2.3" } });
const connected = fake.events.filter((e) => e.event === "ds_agent.connected");
expect(connected).toHaveLength(1);
expect(connected[0]).toMatchObject({ agent_version: "1.2.3" });
expect(fake.relay.agentStatus("DS-002").agent_version).toBe("1.2.3");
});
it("R51 /result に形の合わない X-Agent-Version → agent_version は null(拒否しない)", async () => {
await commandPost("result", "x", "{}", { headers: { "x-agent-version": "bad version!" } });
expect(fake.relay.agentStatus("DS-002").agent_version).toBeNull();
const connected = fake.events.filter((e) => e.event === "ds_agent.connected");
expect(connected[0]).toMatchObject({ agent_version: null });
});
it("時間切れの後の結果 → 410 command_expired(表5 R3)", async () => {
const { command } = await pendingList();
await fake.at(fake.limits.listTimeoutSec);
const res = await commandPost("result", command.command_id, JSON.stringify(VALID_LIST));
expect(res.status).toBe(410);
expect(await errorCode(res)).toBe("command_expired");
});
it("R35 DS-003 のトークンで DS-002 の指示に結果 → 404(403 にしない)", async () => {
const { command } = await pendingList();
const res = await commandPost("result", command.command_id, JSON.stringify(VALID_LIST), { token: token003 });
expect(res.status).toBe(404);
});
});
describe("表5 R44 — オブジェクトでない本文は4ルートとも 400 result_invalid(500 にしない)", () => {
const routes: CommandRoute[] = ["result", "prepared", "complete", "fail"];
const bodies = ["null", "[]", "0", '"x"'];
it.each(routes.flatMap((route) => bodies.map((body) => [route, body] as const)))(
"%s に %s → 400 result_invalid",
async (route, body) => {
const { command } = await pendingExport();
const res = await commandPost(route, command.command_id, body);
expect(res.status).toBe(400);
expect(await errorCode(res)).toBe("result_invalid");
}
);
});
describe("POST …/prepared", () => {
it("正しい本体 → 204・ブラウザに prepared", async () => {
const { created, command } = await pendingExport();
const res = await commandPost(
"prepared",
command.command_id,
JSON.stringify({ total_bytes: 4, sha256: "a".repeat(64), file_count: 1 })
);
expect(res.status).toBe(204);
await expect(created).resolves.toMatchObject({ kind: "prepared", totalBytes: 4 });
});
it.each<[string, unknown]>([
["sha256 が大文字", { total_bytes: 4, sha256: "A".repeat(64), file_count: 1 }],
["file_count が0", { total_bytes: 4, sha256: "a".repeat(64), file_count: 0 }],
["total_bytes が無い", { sha256: "a".repeat(64), file_count: 1 }],
])("%s → 400 result_invalid", async (_label, body) => {
const { command } = await pendingExport();
expect((await commandPost("prepared", command.command_id, JSON.stringify(body))).status).toBe(400);
});
it("R36 list の指示に prepared → 404", async () => {
const { command } = await pendingList();
const res = await commandPost(
"prepared",
command.command_id,
JSON.stringify({ total_bytes: 4, sha256: "a".repeat(64), file_count: 1 })
);
expect(res.status).toBe(404);
});
});
describe("パスパラメータ(段階3-3 X2・X5・X6)", () => {
it("X2 command_id が長すぎる → 422", async () => {
const res = await commandPost("fail", "c".repeat(PATH_COMMAND_ID_MAX_LENGTH + 1), "{}");
expect(res.status).toBe(422);
});
it.each(["-1", "01", "1.0", "abc", "1e3", "1234567890"])("X5・X6 seq=%s → 422", async (seq) => {
const res = await chunkPost("x", seq, new Uint8Array([1]));
expect(res.status).toBe(422);
});
});
describe("POST …/chunks/{seq}", () => {
async function preparedExport(payload: Uint8Array) {
const { created, command } = await pendingExport();
fake.relay.submitPrepared("DS-002", command.command_id, { total_bytes: payload.length, sha256: sha(payload), file_count: 1 });
const prepared = await created;
if (prepared.kind !== "prepared") throw new Error("prepared にならない");
return { exportCommandId: command.command_id, exportId: prepared.exportId };
}
async function sending(payload: Uint8Array) {
const p = await preparedExport(payload);
const poll = fake.relay.poll("DS-002", "1.0.0", 25, signal());
const claim = fake.relay.claimExport("DS-002", p.exportId, signal());
await poll;
return { ...p, claim };
}
const payload = new Uint8Array(20).fill(5);
it("X-Chunk-Sha256 が無い・形が違う → 400 chunk_invalid", async () => {
const { exportCommandId } = await sending(payload);
expect((await chunkPost(exportCommandId, "0", payload.subarray(0, 16), { sha: null })).status).toBe(400);
expect((await chunkPost(exportCommandId, "0", payload.subarray(0, 16), { sha: "XYZ" })).status).toBe(400);
});
it("R20 send の前のチャンク → 409 chunk_out_of_order", async () => {
const { exportCommandId } = await preparedExport(payload);
const res = await chunkPost(exportCommandId, "0", payload.subarray(0, 16));
expect(res.status).toBe(409);
expect(await errorCode(res)).toBe("chunk_out_of_order");
});
it("R49 保留中に次のチャンク → 409・先の POST は 410 export_cancelled", async () => {
const { exportCommandId, claim } = await sending(payload);
const first = chunkPost(exportCommandId, "0", payload.subarray(0, 16));
await flush();
const claimed = await claim;
if (claimed.kind !== "stream") throw new Error("stream にならない");
// ★読まないまま次のチャンクを送る★
const second = await chunkPost(exportCommandId, "1", payload.subarray(16));
expect(second.status).toBe(409);
expect(await errorCode(second)).toBe("chunk_out_of_order");
const firstDone = await first;
expect(firstDone.status).toBe(410);
expect(await errorCode(firstDone)).toBe("export_cancelled");
expect(fake.events.some((e) => e.event === "ds_logs.export_failed" && e.reason === "chunk_out_of_order")).toBe(
true
);
await fake.at(fake.limits.exportIdleTimeoutSec + 1);
expect(fake.events.some((e) => e.event === "ds_logs.export_failed" && e.reason === "flow_timeout")).toBe(false);
});
it("R23 ハッシュ不一致 → 400 chunk_invalid", async () => {
const { exportCommandId, claim } = await sending(payload);
// ★わざと違うハッシュ(全部 0)★
const res = await chunkPost(exportCommandId, "0", payload.subarray(0, 16), { sha: "0".repeat(64) });
expect(res.status).toBe(400);
expect(await errorCode(res)).toBe("chunk_invalid");
await expect(claim).resolves.toEqual({ kind: "agent_error", code: "chunk_invalid" });
});
it("R24 chunkMaxBytes + 1 → 400", async () => {
const big = new Uint8Array(17).fill(5);
const { exportCommandId } = await sending(big);
expect((await chunkPost(exportCommandId, "0", big)).status).toBe(400);
});
it("取り消し・失敗の後のチャンク → 410 export_cancelled", async () => {
const { exportCommandId, claim } = await sending(payload);
await fake.at(fake.limits.exportFirstChunkTimeoutSec + 0.001);
await expect(claim).resolves.toEqual({ kind: "timeout" });
const res = await chunkPost(exportCommandId, "0", payload.subarray(0, 16));
expect(res.status).toBe(410);
expect(await errorCode(res)).toBe("export_cancelled");
});
it("R35 DS-003 のトークンで DS-002 の指示にチャンク → 404", async () => {
const { exportCommandId } = await sending(payload);
expect((await chunkPost(exportCommandId, "0", payload.subarray(0, 16), { token: token003 })).status).toBe(404);
});
it("R42 send の指示の command_id にチャンク → 404", async () => {
const p = await preparedExport(payload);
const poll = fake.relay.poll("DS-002", "1.0.0", 25, signal());
void fake.relay.claimExport("DS-002", p.exportId, signal());
const send = (await poll) as DsAgentCommand;
expect((await chunkPost(send.command_id, "0", payload.subarray(0, 16))).status).toBe(404);
});
});
describe("POST …/complete と …/fail", () => {
it("complete: 流れていないのに complete → 400 result_invalid(照合が通らない)", async () => {
const { command } = await pendingExport();
fake.relay.submitPrepared("DS-002", command.command_id, { total_bytes: 4, sha256: "a".repeat(64), file_count: 1 });
const res = await commandPost("complete", command.command_id, JSON.stringify({ total_bytes: 4, sha256: "a".repeat(64) }));
expect(res.status).toBe(400);
expect(await errorCode(res)).toBe("result_invalid");
});
it("complete: 終わった後 → 410 export_cancelled", async () => {
const { command } = await pendingExport();
fake.relay.failCommand("DS-002", command.command_id, "io_error", "m");
const res = await commandPost("complete", command.command_id, JSON.stringify({ total_bytes: 4, sha256: "a".repeat(64) }));
expect(res.status).toBe(410);
});
it("fail: 準備中 → 204・ブラウザに agent_error(制御盤の code)", async () => {
const { created, command } = await pendingExport();
const res = await commandPost("fail", command.command_id, JSON.stringify({ code: "too_large", message: "大きすぎます" }));
expect(res.status).toBe(204);
await expect(created).resolves.toEqual({ kind: "agent_error", code: "too_large" });
});
it("fail: 形の誤り(code が65文字)→ 400 result_invalid", async () => {
const { command } = await pendingExport();
const res = await commandPost("fail", command.command_id, JSON.stringify({ code: "c".repeat(65), message: "m" }));
expect(res.status).toBe(400);
});
it("fail: 失効の後 → 410", async () => {
const { command } = await pendingExport();
fake.relay.failCommand("DS-002", command.command_id, "io_error", "m");
const res = await commandPost("fail", command.command_id, JSON.stringify({ code: "io_error", message: "m" }));
expect(res.status).toBe(410);
});
it("無い指示 → 404", async () => {
const res = await commandPost("fail", "no-such", JSON.stringify({ code: "io_error", message: "m" }));
expect(res.status).toBe(404);
});
});