/**
* 指示書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();
});
});