Newer
Older
client_dwatch / app / api / ds-agent / __tests__ / end-to-end.test.ts
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",
    ]);
  });
});