Newer
Older
client_dwatch / lib / logger / __tests__ / ds-agent-events.test.ts
/**
 * 制御盤ログのイベント(指示書AY 段階10)に禁止キーが載らないことを確かめる。
 * ★`file_name` / `file_ids` / `file_id` / `token` / `authorization` をキーにも値にも出さない★(D12)。
 */
import { describe, expect, test } from "vitest";
import type { LogEvent } from "@/lib/logger/events";
import { createLogger, emitTo } from "@/lib/logger";
import { runWithRequestId } from "@/lib/logger/context";

/** イベントのキー・入れ子のキーに出てはいけない名前(段階10) */
const FORBIDDEN_EVENT_KEYS = [
  "file_name",
  "file_ids",
  "file_id",
  "token",
  "authorization",
] as const;

type KeysOfUnion<T> = T extends unknown ? keyof T : never;
type EventKeys = KeysOfUnion<LogEvent>;
type ForbiddenHit = Extract<EventKeys, (typeof FORBIDDEN_EVENT_KEYS)[number]>;

/**
 * LogEvent のキー集合に禁止キーが1つでも混ざると、この代入が型エラーになる。
 */
const _noForbiddenKeysOnLogEvent: ForbiddenHit extends never ? true : never = true;
void _noForbiddenKeysOnLogEvent;

/** オブジェクトのキーを再帰的に集める(配列の要素も辿る) */
function collectKeys(value: unknown, into: Set<string>): void {
  if (value === null || typeof value !== "object") return;
  if (Array.isArray(value)) {
    for (const item of value) collectKeys(item, into);
    return;
  }
  for (const [key, child] of Object.entries(value as Record<string, unknown>)) {
    into.add(key);
    collectKeys(child, into);
  }
}

function emitJson(event: LogEvent, level: "info" | "warn" = "info"): Record<string, unknown> {
  const lines: string[] = [];
  const target = createLogger({
    write(s: string) {
      lines.push(s);
    },
  });
  runWithRequestId("ds-agent-events-test", () => {
    emitTo(target, level, event);
  });
  expect(lines).toHaveLength(1);
  return JSON.parse(lines[0]) as Record<string, unknown>;
}

/** 段階10の表にあるイベントを1つずつ(フィールドは表どおり。禁止キーは付けない) */
const SAMPLE_EVENTS: Array<{ level: "info" | "warn"; event: LogEvent }> = [
  {
    level: "info",
    event: {
      event: "ds_agent.connected",
      serial_number: "DS-002",
      agent_version: "1.0.0",
    },
  },
  {
    level: "info",
    event: {
      event: "ds_agent.disconnected",
      serial_number: "DS-002",
      agent_version: null,
    },
  },
  {
    level: "warn",
    event: {
      event: "ds_agent.auth_failed",
      route: "/api/ds-agent/commands",
      method: "GET",
      reason: "missing",
    },
  },
  {
    level: "info",
    event: { event: "ds_agent.token_issued", serial_number: "DS-002" },
  },
  {
    level: "info",
    event: { event: "ds_agent.token_revoked", serial_number: "DS-002" },
  },
  {
    level: "info",
    event: {
      event: "ds_logs.list",
      serial_number: "DS-002",
      item_count: 3,
      truncated: false,
      duration_ms: 12,
      result: "ok",
    },
  },
  {
    level: "info",
    event: {
      event: "ds_logs.list",
      serial_number: "DS-002",
      item_count: 0,
      truncated: false,
      duration_ms: 5,
      result: "agent_error",
      agent_code: "io_error",
    },
  },
  {
    level: "info",
    event: {
      event: "ds_logs.export_prepared",
      serial_number: "DS-002",
      file_count: 2,
      total_bytes: 1024,
      duration_ms: 40,
    },
  },
  {
    level: "info",
    event: {
      event: "ds_logs.export_started",
      serial_number: "DS-002",
      file_count: 2,
    },
  },
  {
    level: "info",
    event: {
      event: "ds_logs.export_completed",
      serial_number: "DS-002",
      file_count: 2,
      total_bytes: 1024,
      duration_ms: 90,
    },
  },
  {
    level: "warn",
    event: {
      event: "ds_logs.export_failed",
      serial_number: "DS-002",
      stage: "prepare",
      reason: "too_large",
      bytes_sent: 0,
      agent_code: "too_large",
    },
  },
];

describe("ds_agent / ds_logs イベント(指示書AY 段階10)", () => {
  test.each(SAMPLE_EVENTS)(
    "$event.event の出力に禁止キー(file_name / file_ids / file_id / token / authorization)が無い",
    ({ level, event }) => {
      const out = emitJson(event, level);
      const keys = new Set<string>();
      collectKeys(out, keys);
      for (const forbidden of FORBIDDEN_EVENT_KEYS) {
        expect(keys.has(forbidden), `forbidden key present: ${forbidden}`).toBe(false);
      }
      // 値の文字列にもトークンっぽいキー名を埋め込まない(serial 等の通常値は除外)
      const blob = JSON.stringify(out);
      expect(blob).not.toMatch(/"file_name"/);
      expect(blob).not.toMatch(/"file_ids"/);
      expect(blob).not.toMatch(/"file_id"/);
      expect(blob).not.toMatch(/"token"/);
      expect(blob).not.toMatch(/"authorization"/);
    }
  );

  test("token_issued / token_revoked は serial_number だけ(表3 KA8)", () => {
    for (const name of ["ds_agent.token_issued", "ds_agent.token_revoked"] as const) {
      const sample = SAMPLE_EVENTS.find((s) => s.event.event === name);
      expect(sample).toBeDefined();
      const keys = Object.keys(sample!.event).sort();
      expect(keys).toEqual(["event", "serial_number"]);
    }
  });
});