Newer
Older
client_dwatch / lib / ds-agent / __tests__ / tokens.test.ts
/**
 * 指示書AY §3 表3 K1〜K12(`lib/ds-agent/tokens.ts`。ストアは mkdtemp)。
 * ブリーフ §5 と監督判断 第2ラウンドから本書で具体化。★P1・P2 の行は §11 Q14・Q15 の採用で確定★。
 * (KA1〜KA8 はルートのテスト。段階7)
 *
 * | #   | 入力                                                       | 期待 |
 * | K1  | issueAgentToken("DS-002")                                  | token が /^[A-Za-z0-9_-]{43}$/・issued_at が +09:00 の ISO。ストアに DS-002: { token_sha256: 64桁の16進, issued_at }。ファイルに生のトークンが無い |
 * | K2  | verifyAgentAuthorization("Bearer " + K1 の値)              | "DS-002" |
 * | K3  | "Bearer " + 形は正しいが違う43文字                        | null |
 * | K4  | null / ""                                                  | null |
 * | K5  | K1 の値だけ(Bearer なし)                                 | null |
 * | K6  | "bearer " + 値 / "Bearer  " + 値 / 末尾に空白            | null(厳密に Bearer +空白1つ) |
 * | K7  | ストアが空                                                 | null |
 * | K8  | revokeAgentToken("DS-002") の後に K2                       | null |
 * | K9  | DS-002 を再発行                                            | 古い値は null、新しい値は "DS-002" |
 * | K10 | DS-002 と DS-003 を発行                                    | それぞれの値がそれぞれのシリアルを返す |
 * | K11 | ストアの1件の token_sha256 が壊れている                    | その1件だけ無視し例外にしない。他は通る |
 * | K12 | 検証した後に別のストアのインスタンス(同じパス)から失効  | 次の検証は null(キャッシュは mtime で無効になる) |
 *
 * ★ストアとパスを一緒に差し替える★(§11-4 M13)。★`data/settings/` の実物に触らない★。
 */
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 } from "vitest";
import {
  __setAgentTokenStoreForTesting,
  createAgentTokenStore,
  issueAgentToken,
  revokeAgentToken,
  verifyAgentAuthorization,
} from "@/lib/ds-agent/tokens";

let dir: string;
let filePath: string;

beforeEach(async () => {
  dir = await fs.mkdtemp(path.join(os.tmpdir(), "dwatch-agent-tokens-"));
  filePath = path.join(dir, "ds-agent-tokens.json");
  __setAgentTokenStoreForTesting(createAgentTokenStore(filePath));
});

afterEach(async () => {
  __setAgentTokenStoreForTesting(null);
  await fs.rm(dir, { recursive: true, force: true });
});

async function readStoreFile(): Promise<Record<string, { token_sha256: unknown; issued_at: unknown }>> {
  return JSON.parse(await fs.readFile(filePath, "utf-8"));
}

/**
 * ファイルの mtime を確実に変える。
 * ★ファイルシステムによって mtime の分解能が粗い(FAT は2秒など)ので、書き込みだけに頼らず未来の時刻へ進める★
 */
async function bumpMtime(): Promise<void> {
  const future = new Date(Date.now() + 10_000);
  await fs.utimes(filePath, future, future);
}

const sha256Hex = (s: string) => createHash("sha256").update(s).digest("hex");

describe("制御盤トークン — 表3 K1〜K12", () => {
  it("K1 発行: 43文字の base64url・+09:00 の issued_at・ストアには SHA-256 だけ", async () => {
    const { token, issued_at } = await issueAgentToken("DS-002");
    expect(token).toMatch(/^[A-Za-z0-9_-]{43}$/);
    expect(issued_at).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+09:00$/);

    const stored = await readStoreFile();
    expect(stored["DS-002"]).toEqual({ token_sha256: sha256Hex(token), issued_at });
    expect(stored["DS-002"].token_sha256).toMatch(/^[0-9a-f]{64}$/);

    const raw = await fs.readFile(filePath, "utf-8");
    expect(raw).not.toContain(token);
  });

  it("K2 正しい Bearer → シリアル", async () => {
    const { token } = await issueAgentToken("DS-002");
    await expect(verifyAgentAuthorization(`Bearer ${token}`)).resolves.toBe("DS-002");
  });

  it("K3 形は正しいが違う43文字 → null", async () => {
    const { token } = await issueAgentToken("DS-002");
    const other = token[0] === "A" ? "B".repeat(43) : "A".repeat(43);
    await expect(verifyAgentAuthorization(`Bearer ${other}`)).resolves.toBeNull();
  });

  it.each<[string, string | null]>([
    ["null", null],
    ["空文字", ""],
  ])("K4 %s → null", async (_label, header) => {
    await issueAgentToken("DS-002");
    await expect(verifyAgentAuthorization(header)).resolves.toBeNull();
  });

  it("K5 Bearer なしの値だけ → null", async () => {
    const { token } = await issueAgentToken("DS-002");
    await expect(verifyAgentAuthorization(token)).resolves.toBeNull();
  });

  it.each<[string, (token: string) => string]>([
    ["小文字の bearer", (t) => `bearer ${t}`],
    ["空白2つ", (t) => `Bearer  ${t}`],
    ["末尾に空白", (t) => `Bearer ${t} `],
  ])("K6 %s → null(厳密に Bearer +空白1つ)", async (_label, build) => {
    const { token } = await issueAgentToken("DS-002");
    await expect(verifyAgentAuthorization(build(token))).resolves.toBeNull();
  });

  it("K7 ストアが空(未登録)→ null", async () => {
    await expect(verifyAgentAuthorization(`Bearer ${"A".repeat(43)}`)).resolves.toBeNull();
  });

  it("K8 失効の後は null", async () => {
    const { token } = await issueAgentToken("DS-002");
    await expect(revokeAgentToken("DS-002")).resolves.toBe(true);
    await expect(verifyAgentAuthorization(`Bearer ${token}`)).resolves.toBeNull();
  });

  it("K9 再発行: 古い値は null、新しい値は DS-002", async () => {
    const first = await issueAgentToken("DS-002");
    const second = await issueAgentToken("DS-002");
    expect(second.token).not.toBe(first.token);
    await expect(verifyAgentAuthorization(`Bearer ${first.token}`)).resolves.toBeNull();
    await expect(verifyAgentAuthorization(`Bearer ${second.token}`)).resolves.toBe("DS-002");
  });

  it("K10 DS-002 と DS-003 はそれぞれのシリアルを返す", async () => {
    const a = await issueAgentToken("DS-002");
    const b = await issueAgentToken("DS-003");
    await expect(verifyAgentAuthorization(`Bearer ${a.token}`)).resolves.toBe("DS-002");
    await expect(verifyAgentAuthorization(`Bearer ${b.token}`)).resolves.toBe("DS-003");
  });

  it("K11 壊れた1件だけ無視し例外にしない(他の制御盤は通る)", async () => {
    const a = await issueAgentToken("DS-002");
    const b = await issueAgentToken("DS-003");
    const stored = await readStoreFile();
    stored["DS-003"].token_sha256 = "not-a-64-hex";
    await fs.writeFile(filePath, JSON.stringify(stored), "utf-8");
    await bumpMtime();

    await expect(verifyAgentAuthorization(`Bearer ${a.token}`)).resolves.toBe("DS-002");
    await expect(verifyAgentAuthorization(`Bearer ${b.token}`)).resolves.toBeNull();
  });

  it("K12 別のストアのインスタンス(同じパス)から失効 → キャッシュは mtime で無効になり null", async () => {
    const { token } = await issueAgentToken("DS-002");
    await expect(verifyAgentAuthorization(`Bearer ${token}`)).resolves.toBe("DS-002");

    const other = createAgentTokenStore(filePath);
    expect(other.filePath).toBe(filePath);
    const data = await other.store.read();
    delete data["DS-002"];
    await other.store.write(data);
    await bumpMtime();

    await expect(verifyAgentAuthorization(`Bearer ${token}`)).resolves.toBeNull();
  });
});