import { createHash } from "node:crypto";
import fs from "node:fs";
import { promises as fsp } 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 { GET as getLogs } from "@/app/api/ds-devices/[serial_number]/logs/route";
import { POST as postExport } from "@/app/api/ds-devices/[serial_number]/logs/exports/route";
import { GET as getExport } from "@/app/api/ds-devices/[serial_number]/logs/exports/[export_id]/route";
import { EXPORT_FILE_IDS_MAX_BYTES } from "@/lib/config/ds-agent";
import { db } from "@/lib/db";
import { createFakeRelay } from "@/lib/ds-agent/__tests__/fake-relay";
import { __setRelayForTesting } from "@/lib/ds-agent/relay";
import { __setAgentTokenStoreForTesting, createAgentTokenStore, issueAgentToken } from "@/lib/ds-agent/tokens";
import { fileIdsOfJsonBytes } from "@/lib/ds-logs/__tests__/file-ids-of-bytes";
import { FIXTURE_EXPORT_ZIP_BASE64 } from "@/lib/fixtures/ds-device-logs";
import { logEvent } from "@/lib/logger";
import type { DsAgentCommand, DsLogExportResponse, DsLogListResponse } from "@/lib/types";
/** 制御盤 `agent.py:62` の `RESPONSE_READ_MAX_BYTES`(指示書BK K7-5) */
const AGENT_RESPONSE_READ_MAX_BYTES = 64 * 1024;
/**
* 指示書AY 段階7-4 — ルートのハンドラを直接呼んで一連(ブラウザ ↔ 制御盤)。
* - 一覧: ブラウザ GET logs ↔ 制御盤 GET commands → POST result
* - エクスポート: ブラウザ POST exports ↔ 制御盤 prepared → 201 → ブラウザ GET ↔ chunks 0..2 → complete
* - ★読んだ zip のバイト列と SHA-256 が送ったものと一致・`Content-Length` が一致★
* - ★ディスクに書かないこと★(スパイ + ソースの静的検査。D3)
*/
vi.mock("@/lib/db/use-fixtures", () => ({ USE_FIXTURES: false }));
vi.mock("@/lib/logger", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/logger")>();
return { ...actual, logEvent: vi.fn() };
});
vi.mock("@/lib/logger/api", () => ({ logApiError: vi.fn() }));
const SERIAL = "DS-002";
/** ★短い f1 は偶然一致しやすいので長い目印(指示書BJ T-c)★ */
const SENTINEL_FILE_ID = "FILEID-SENTINEL-7f3a9c2e1b4d6a8e0c5f7d9b1a3e5c7d";
const SENTINEL_NAME = "SENTINEL-DIR/SENTINEL-NAME.log";
const VALID_LIST = {
types: [{ key: "app", label: "アプリ" }],
items: [
{
file_id: SENTINEL_FILE_ID,
name: SENTINEL_NAME,
type: "app",
size_bytes: 10,
modified_at: "2026-10-19T11:00:00+09:00",
},
],
truncated: false,
};
/** 3チャンク(4・4・4 バイト)で流量制御の経路を通す */
const CHUNKS = [new Uint8Array([1, 2, 3, 4]), new Uint8Array([5, 6, 7, 8]), new Uint8Array([9, 10, 11, 12])];
const PAYLOAD = (() => {
const out = new Uint8Array(CHUNKS.reduce((n, c) => n + c.length, 0));
let o = 0;
for (const c of CHUNKS) {
out.set(c, o);
o += c.length;
}
return out;
})();
const PAYLOAD_SHA = createHash("sha256").update(PAYLOAD).digest("hex");
let dir = "";
let fake: ReturnType<typeof createFakeRelay>;
let token = "";
let exists: ReturnType<typeof vi.spyOn>;
beforeEach(async () => {
dir = await fsp.mkdtemp(path.join(os.tmpdir(), "dwatch-agent-e2e-"));
__setAgentTokenStoreForTesting(createAgentTokenStore(path.join(dir, "ds-agent-tokens.json")));
// ★トークンの発行(ストアの書き込み)はディスク・スパイを仕掛ける前に済ませる★
token = (await issueAgentToken(SERIAL)).token;
fake = createFakeRelay({ chunkMaxBytes: 4 });
__setRelayForTesting(fake.relay);
exists = vi.spyOn(db, "dsDeviceExists").mockResolvedValue(true);
});
afterEach(async () => {
__setRelayForTesting(null);
__setAgentTokenStoreForTesting(null);
exists.mockRestore();
vi.restoreAllMocks();
await fsp.rm(dir, { recursive: true, force: true });
});
const sha = (bytes: Uint8Array) => createHash("sha256").update(bytes).digest("hex");
async function waitUntilConnected(serial = SERIAL): Promise<void> {
await vi.waitFor(
() => {
if (!fake.relay.agentStatus(serial).connected) {
throw new Error(`${serial} が接続状態にならなかった`);
}
},
{ timeout: 4000, interval: 5 },
);
}
function agentRequest(pathname: string, method: string, body?: BodyInit, headers: Record<string, string> = {}): Request {
return new Request(`http://localhost${pathname}`, {
method,
headers: { authorization: `Bearer ${token}`, ...headers },
body,
});
}
function browserLogs(): Promise<Response> {
return getLogs(new Request(`http://localhost/api/ds-devices/${SERIAL}/logs`), {
params: Promise.resolve({ serial_number: SERIAL }),
});
}
function browserExport(fileIds: string[] = [SENTINEL_FILE_ID]): Promise<Response> {
return postExport(
new Request(`http://localhost/api/ds-devices/${SERIAL}/logs/exports`, {
method: "POST",
body: JSON.stringify({ file_ids: fileIds }),
}),
{ params: Promise.resolve({ serial_number: SERIAL }) }
);
}
function browserDownload(exportId: string): Promise<Response> {
return getExport(new Request(`http://localhost/api/ds-devices/${SERIAL}/logs/exports/${exportId}`), {
params: Promise.resolve({ serial_number: SERIAL, export_id: exportId }),
});
}
async function agentPoll(wait = 25): Promise<Response> {
return getCommands(agentRequest(`/api/ds-agent/commands?wait=${wait}`, "GET", undefined, { "x-agent-version": "1.0.0" }));
}
function agentPostResult(commandId: string, body: unknown): Promise<Response> {
return postResult(agentRequest(`/api/ds-agent/commands/${commandId}/result`, "POST", JSON.stringify(body)), {
params: Promise.resolve({ command_id: commandId }),
});
}
function agentPostPrepared(commandId: string, body: unknown): Promise<Response> {
return postPrepared(agentRequest(`/api/ds-agent/commands/${commandId}/prepared`, "POST", JSON.stringify(body)), {
params: Promise.resolve({ command_id: commandId }),
});
}
function agentPostChunk(commandId: string, seq: number, bytes: Uint8Array): Promise<Response> {
return postChunk(
agentRequest(`/api/ds-agent/commands/${commandId}/chunks/${seq}`, "POST", Buffer.from(bytes), {
"x-chunk-sha256": sha(bytes),
}),
{ params: Promise.resolve({ command_id: commandId, seq: String(seq) }) }
);
}
function agentPostComplete(commandId: string, body: unknown): Promise<Response> {
return postComplete(agentRequest(`/api/ds-agent/commands/${commandId}/complete`, "POST", JSON.stringify(body)), {
params: Promise.resolve({ command_id: commandId }),
});
}
function agentPostFail(commandId: string, body: unknown): Promise<Response> {
return postFail(agentRequest(`/api/ds-agent/commands/${commandId}/fail`, "POST", JSON.stringify(body)), {
params: Promise.resolve({ command_id: commandId }),
});
}
/** 一覧 → prepared → download → chunks → complete まで(本文を読み切る) */
async function runFullExportRoundTrip(): Promise<{ exportId: string; joined: Uint8Array }> {
const pollExport = agentPoll();
await waitUntilConnected();
const exportP = browserExport();
const exportCmd = (await (await pollExport).json()) as DsAgentCommand;
expect(exportCmd.kind).toBe("export");
expect(
(
await agentPostPrepared(exportCmd.command_id, {
total_bytes: PAYLOAD.length,
sha256: PAYLOAD_SHA,
file_count: 1,
})
).status
).toBe(204);
const created = await exportP;
expect(created.status).toBe(201);
const createdBody = (await created.json()) as DsLogExportResponse;
const pollSend = agentPoll();
const downloadP = browserDownload(createdBody.export_id);
const sendCmd = (await (await pollSend).json()) as DsAgentCommand;
if (sendCmd.kind !== "send") throw new Error("send 以外");
const exportCommandId = sendCmd.params.export_command_id;
const chunk0P = agentPostChunk(exportCommandId, 0, CHUNKS[0]);
const downloadRes = await downloadP;
expect(downloadRes.status).toBe(200);
const reader = downloadRes.body!.getReader();
const received: Uint8Array[] = [];
const first = await reader.read();
received.push(first.value!);
expect((await chunk0P).status).toBe(204);
for (const seq of [1, 2]) {
const chunkP = agentPostChunk(exportCommandId, seq, CHUNKS[seq]);
const chunk = await reader.read();
received.push(chunk.value!);
expect((await chunkP).status).toBe(204);
}
expect(
(await agentPostComplete(exportCommandId, { total_bytes: PAYLOAD.length, sha256: PAYLOAD_SHA })).status
).toBe(204);
expect((await reader.read()).done).toBe(true);
const joined = new Uint8Array(received.reduce((n, c) => n + c.length, 0));
let o = 0;
for (const c of received) {
joined.set(c, o);
o += c.length;
}
return { exportId: createdBody.export_id, joined };
}
/** zip 中央ディレクトリの名前(順番どおり) */
function zipCentralDirectoryNames(bytes: Uint8Array): string[] {
const buf = Buffer.from(bytes);
const names: string[] = [];
let i = 0;
while (i < buf.length - 4) {
if (buf.readUInt32LE(i) === 0x02014b50) {
const nameLen = buf.readUInt16LE(i + 28);
const extraLen = buf.readUInt16LE(i + 30);
const commentLen = buf.readUInt16LE(i + 32);
names.push(buf.subarray(i + 46, i + 46 + nameLen).toString("utf8"));
i += 46 + nameLen + extraLen + commentLen;
} else {
i++;
}
}
return names;
}
describe("一連: 一覧(ブラウザ GET logs ↔ 制御盤 result)", () => {
it("一覧がルートを通って往復する", async () => {
const poll = agentPoll();
await waitUntilConnected();
const listP = browserLogs();
const pollRes = await poll;
expect(pollRes.status).toBe(200);
const command = (await pollRes.json()) as DsAgentCommand;
expect(command.kind).toBe("list");
expect((await agentPostResult(command.command_id, VALID_LIST)).status).toBe(204);
const listRes = await listP;
expect(listRes.status).toBe(200);
const body = (await listRes.json()) as DsLogListResponse;
expect(body.items.map((i) => i.file_id)).toEqual([SENTINEL_FILE_ID]);
expect(body.agent.connected).toBe(true);
expect(body.truncated).toBe(false);
});
it("形の崩れた /result → 制御盤は 400・ブラウザは 502 result_invalid(指示書BK K5)", async () => {
const poll = agentPoll();
await waitUntilConnected();
const listP = browserLogs();
const command = (await (await poll).json()) as DsAgentCommand;
const agentRes = await agentPostResult(command.command_id, { types: [], items: "x" });
expect(agentRes.status).toBe(400);
expect(((await agentRes.json()) as { error: { code: string } }).error.code).toBe("result_invalid");
const listRes = await listP;
expect(listRes.status).toBe(502);
expect(await listRes.json()).toEqual({
error: {
code: "agent_error",
message: "制御盤でエラーが発生しました",
details: { agent_code: ["result_invalid"] },
},
});
});
});
describe("一連: エクスポート(prepared → chunks 0..2 → complete)", () => {
it("K7-5 file_ids 上限ちょうどでも poll 応答は 64KB 以下で JSON として読める", async () => {
const fileIds = fileIdsOfJsonBytes(EXPORT_FILE_IDS_MAX_BYTES);
expect(new TextEncoder().encode(JSON.stringify(fileIds)).length).toBe(EXPORT_FILE_IDS_MAX_BYTES);
const poll = agentPoll();
await waitUntilConnected();
const exportP = browserExport(fileIds);
const pollRes = await poll;
expect(pollRes.status).toBe(200);
const text = await pollRes.text();
expect(new TextEncoder().encode(text).length).toBeLessThanOrEqual(AGENT_RESPONSE_READ_MAX_BYTES);
const command = JSON.parse(text) as DsAgentCommand;
expect(command.kind).toBe("export");
if (command.kind !== "export") throw new Error("export 以外");
expect(command.params.file_ids).toEqual(fileIds);
expect(
(await agentPostFail(command.command_id, { code: "io_error", message: "cleanup" })).status
).toBe(204);
await expect(exportP).resolves.toMatchObject({ status: 502 });
});
it("zip のバイト列と SHA-256・Content-Length が一致する", async () => {
// 1) 準備
const pollExport = agentPoll();
await waitUntilConnected();
const exportP = browserExport();
const exportCmdRes = await pollExport;
expect(exportCmdRes.status).toBe(200);
const exportCmd = (await exportCmdRes.json()) as DsAgentCommand;
expect(exportCmd.kind).toBe("export");
expect(
(
await agentPostPrepared(exportCmd.command_id, {
total_bytes: PAYLOAD.length,
sha256: PAYLOAD_SHA,
file_count: 1,
})
).status
).toBe(204);
const created = await exportP;
expect(created.status).toBe(201);
const createdBody = (await created.json()) as DsLogExportResponse;
expect(createdBody.total_bytes).toBe(PAYLOAD.length);
expect(createdBody.file_count).toBe(1);
// 2) 受け取り(send)+ チャンク 0..2
const pollSend = agentPoll();
const downloadP = browserDownload(createdBody.export_id);
const sendCmdRes = await pollSend;
expect(sendCmdRes.status).toBe(200);
const sendCmd = (await sendCmdRes.json()) as DsAgentCommand;
expect(sendCmd.kind).toBe("send");
if (sendCmd.kind !== "send") throw new Error("send 以外");
// ★チャンク・complete は export の指示の id★(Q16)
const exportCommandId = sendCmd.params.export_command_id;
const chunk0P = agentPostChunk(exportCommandId, 0, CHUNKS[0]);
const downloadRes = await downloadP;
expect(downloadRes.status).toBe(200);
expect(downloadRes.headers.get("content-type")).toBe("application/zip");
expect(downloadRes.headers.get("cache-control")).toBe("no-store");
expect(downloadRes.headers.get("x-accel-buffering")).toBe("no");
expect(downloadRes.headers.get("content-disposition")).toMatch(/^attachment;/);
expect(downloadRes.headers.get("content-length")).toBe(String(PAYLOAD.length));
const reader = downloadRes.body!.getReader();
const received: Uint8Array[] = [];
const first = await reader.read();
expect(first.done).toBe(false);
received.push(first.value!);
expect((await chunk0P).status).toBe(204);
const chunk1P = agentPostChunk(exportCommandId, 1, CHUNKS[1]);
const second = await reader.read();
expect(second.done).toBe(false);
received.push(second.value!);
expect((await chunk1P).status).toBe(204);
const chunk2P = agentPostChunk(exportCommandId, 2, CHUNKS[2]);
const third = await reader.read();
expect(third.done).toBe(false);
received.push(third.value!);
expect((await chunk2P).status).toBe(204);
expect(
(await agentPostComplete(exportCommandId, { total_bytes: PAYLOAD.length, sha256: PAYLOAD_SHA })).status
).toBe(204);
const end = await reader.read();
expect(end.done).toBe(true);
const joined = new Uint8Array(received.reduce((n, c) => n + c.length, 0));
let o = 0;
for (const c of received) {
joined.set(c, o);
o += c.length;
}
expect(Buffer.from(joined).equals(Buffer.from(PAYLOAD))).toBe(true);
expect(createHash("sha256").update(joined).digest("hex")).toBe(PAYLOAD_SHA);
});
});
describe("ディスクに書かないこと(D3・指示書BJ T-b)", () => {
// スパイは `fs` の既定オブジェクト経由の書き込みしか捕まえない。名前付き import・動的 import・require は下の静的な検査で捕まえる
it("一覧+エクスポート中継のあいだ writeFile / createWriteStream / mkdtemp / tmpdir は 0回(トークン発行の後)", async () => {
// ★本物を呼ばない(G3: スパイだけだとファイルが書かれる)★
const writeFileSync = vi.spyOn(fs, "writeFileSync").mockImplementation(() => undefined);
const writeFile = vi.spyOn(fsp, "writeFile").mockImplementation(async () => undefined);
const appendFile = vi.spyOn(fsp, "appendFile").mockImplementation(async () => undefined);
const createWriteStream = vi.spyOn(fs, "createWriteStream").mockImplementation(() => {
throw new Error("createWriteStream が呼ばれた");
});
const mkdtemp = vi.spyOn(fsp, "mkdtemp").mockImplementation(async () => {
throw new Error("mkdtemp が呼ばれた");
});
const tmpdir = vi.spyOn(os, "tmpdir").mockImplementation(() => {
throw new Error("tmpdir が呼ばれた");
});
const poll = agentPoll();
await waitUntilConnected();
const listP = browserLogs();
const pollRes = await poll;
const command = (await pollRes.json()) as DsAgentCommand;
await agentPostResult(command.command_id, VALID_LIST);
expect((await listP).status).toBe(200);
const { joined } = await runFullExportRoundTrip();
expect(Buffer.from(joined).equals(Buffer.from(PAYLOAD))).toBe(true);
expect(writeFileSync).not.toHaveBeenCalled();
expect(writeFile).not.toHaveBeenCalled();
expect(appendFile).not.toHaveBeenCalled();
expect(createWriteStream).not.toHaveBeenCalled();
expect(mkdtemp).not.toHaveBeenCalled();
expect(tmpdir).not.toHaveBeenCalled();
});
it("ソースの静的検査: relay / ds-logs / agent ルート / logs ルート / agent-request に fs・os のモジュール名と require が無い", async () => {
const roots = [
path.join(process.cwd(), "lib", "ds-agent", "relay.ts"),
path.join(process.cwd(), "lib", "ds-agent", "agent-request.ts"),
path.join(process.cwd(), "lib", "ds-logs"),
path.join(process.cwd(), "app", "api", "ds-agent"),
path.join(process.cwd(), "app", "api", "ds-devices", "[serial_number]", "logs"),
];
const files: string[] = [];
for (const root of roots) {
const st = await fsp.stat(root);
if (st.isFile()) {
files.push(root);
continue;
}
const walk = async (dirPath: string) => {
for (const ent of await fsp.readdir(dirPath, { withFileTypes: true })) {
const full = path.join(dirPath, ent.name);
if (ent.isDirectory()) {
if (ent.name === "__tests__") continue;
await walk(full);
} else if (ent.name.endsWith(".ts") && !ent.name.endsWith(".test.ts")) {
files.push(full);
}
}
};
await walk(root);
}
const banned = [
/["'](?:node:)?(?:fs|os)(?:\/promises)?["']/, // import・export・動的 import・require のどれで書いても、モジュール名の文字列で当たる
/\brequire\s*\(/,
/\bimport\s*\(/, // 動的 import はモジュール名によらず禁じる(テンプレート文字列の import(`node:fs`) も塞ぐ)
/\btmpdir\b/,
/\bmkdtemp\b/,
];
for (const file of files) {
const text = await fsp.readFile(file, "utf8");
for (const re of banned) {
expect(text, `${file} に ${re} がある`).not.toMatch(re);
}
}
});
});
describe("ログのイベント(指示書BJ T-c)", () => {
it("一覧→成功エクスポート→失敗エクスポートで6種が出て、file_id・名前・トークン・SHA-256 が載らない", async () => {
vi.mocked(logEvent).mockClear();
fake.events.length = 0;
const poll = agentPoll();
await waitUntilConnected();
const listP = browserLogs();
const listCmd = (await (await poll).json()) as DsAgentCommand;
await agentPostResult(listCmd.command_id, VALID_LIST);
expect((await listP).status).toBe(200);
await runFullExportRoundTrip();
// 2本目: 準備中に fail
const pollFail = agentPoll();
const exportFailP = browserExport();
const failCmd = (await (await pollFail).json()) as DsAgentCommand;
expect(failCmd.kind).toBe("export");
expect(
(await agentPostFail(failCmd.command_id, { code: "io_error", message: "読めません" })).status
).toBe(204);
await expect(exportFailP).resolves.toMatchObject({ status: 502 });
const routeEvents = vi.mocked(logEvent).mock.calls.map((call) => call[1] as { event?: string });
const relayEvents = fake.events;
const all = [...routeEvents, ...relayEvents];
const kinds = new Set(all.map((e) => e.event).filter((e): e is string => typeof e === "string"));
for (const needed of [
"ds_agent.connected",
"ds_logs.export_completed",
"ds_logs.export_failed",
"ds_logs.list",
"ds_logs.export_prepared",
"ds_logs.export_started",
]) {
expect(kinds.has(needed), `${needed} が出ていない`).toBe(true);
}
const forbidden = [SENTINEL_FILE_ID, SENTINEL_NAME, token, PAYLOAD_SHA];
for (const event of all) {
const text = JSON.stringify(event);
for (const needle of forbidden) {
expect(text, `${JSON.stringify(event?.event)} に秘密が載っている`).not.toContain(needle);
}
}
});
});
describe("fixtures の zip の中のパス(指示書BJ T-d・指示書AY M17)", () => {
it("中央ディレクトリの名前が <種類>/<ファイル名>(順番どおり)", () => {
const bytes = Buffer.from(FIXTURE_EXPORT_ZIP_BASE64, "base64");
expect(zipCentralDirectoryNames(bytes)).toEqual([
"api_client/api_client.log",
"postcondition_security/postcondition_security.log.2026-10-18",
]);
});
});