import { describe, expect, test } from "vitest";
import type { LogEvent } from "@/lib/logger/events";
import {
enforceForbiddenErrorFieldsPolicy,
enforceForbiddenFieldsPolicy,
findForbiddenFields,
findForbiddenFieldsInError,
FORBIDDEN_CREDENTIAL_FIELDS,
FORBIDDEN_LOG_FIELDS,
FORBIDDEN_PII_FIELDS,
stripForbiddenFields,
stripForbiddenFieldsFromError,
} from "@/lib/logger/redact";
type KeysOfUnion<T> = T extends unknown ? keyof T : never;
type EventKeys = KeysOfUnion<LogEvent>;
type ForbiddenHit = Extract<EventKeys, (typeof FORBIDDEN_LOG_FIELDS)[number]>;
/**
* イベント型のキー集合に §4 の禁止フィールドが1つでも混ざると、
* この代入が型エラーになりテストファイルがコンパイルできない。
*/
const _noForbiddenOnEventType: ForbiddenHit extends never ? true : never =
true;
void _noForbiddenOnEventType;
describe("forbidden log fields", () => {
test("§4 の記録禁止フィールドがすべて FORBIDDEN_LOG_FIELDS に含まれる", () => {
const required = [
"facility_name",
"address",
"phone",
"email",
"manager_name",
"charger_name",
"inspector_name",
"customer_name",
"notes",
"comment",
"memo",
"note",
"detail",
];
expect([...FORBIDDEN_PII_FIELDS]).toEqual(required);
expect([...FORBIDDEN_LOG_FIELDS]).toEqual(
expect.arrayContaining(required)
);
});
test("認証情報のキーが FORBIDDEN_LOG_FIELDS に含まれる", () => {
const credentials = [
"cookie",
"set-cookie",
"authorization",
"token",
"session",
"secret",
"password",
"api_key",
// 指示書I 段階4 で追加。cookie 名の正は lib/auth/cookie.ts の SESSION_COOKIE_NAME
"dwatch_session",
];
expect([...FORBIDDEN_CREDENTIAL_FIELDS]).toEqual(credentials);
expect([...FORBIDDEN_LOG_FIELDS]).toEqual(
expect.arrayContaining(credentials)
);
});
test("§4 の13キーと認証9キーの両方を漏れなく持つ", () => {
// 21 → 22。指示書I 段階4 で dwatch_session を足した分
expect(FORBIDDEN_LOG_FIELDS).toHaveLength(22);
});
describe("認証情報キーの個別ケース", () => {
const credentialCases: Array<[string, unknown]> = [
["cookie", "dwatch_session=abc123"],
["set-cookie", "dwatch_session=abc123; HttpOnly"],
["authorization", "Bearer abc123"],
["token", "abc123"],
["session", "abc123"],
["secret", "abc123"],
["password", "abc123"],
["api_key", "abc123"],
// 指示書I 段階4。cookie 名をキーにして値を渡してしまった場合も止める
["dwatch_session", "abc123"],
];
test.each(credentialCases)("%s を検出する", (key, value) => {
expect(findForbiddenFields({ event: "api.error", [key]: value })).toEqual([
key,
]);
});
test.each(credentialCases)("%s を除去する", (key, value) => {
const stripped = stripForbiddenFields({
event: "api.error",
[key]: value,
});
expect(stripped).toEqual({ event: "api.error" });
expect(JSON.stringify(stripped)).not.toContain("abc123");
});
test.each(credentialCases)("入れ子の %s も検出する", (key, value) => {
expect(
findForbiddenFields({ event: "api.error", headers: { [key]: value } })
).toEqual([key]);
});
test("HTTPヘッダの大文字表記でも検出する(Authorization / Set-Cookie)", () => {
expect(
findForbiddenFields({
headers: { Authorization: "Bearer x", "Set-Cookie": "a=b" },
})
).toEqual(["Authorization", "Set-Cookie"]);
});
test("api_key に似た許可キー(facility_id)は検出しない", () => {
expect(findForbiddenFields({ facility_id: 1, token_count: 3 })).toEqual(
[]
);
});
});
describe("findForbiddenFields(検出のみ、副作用なし)", () => {
test("許可フィールドだけのペイロードは何も検出しない", () => {
const payload: LogEvent = {
event: "api.error",
route: "/api/clean-logs",
method: "GET",
error_code: "internal_error",
error_message: "内部エラーが発生しました",
http_status: 500,
};
expect(findForbiddenFields(payload)).toEqual([]);
});
test("禁止フィールド名を検出する", () => {
expect(
findForbiddenFields({ event: "api.error", facility_name: "dummy" })
).toEqual(["facility_name"]);
});
test("入れ子の comment も検出する(内容ではなくキー名)", () => {
expect(
findForbiddenFields({ event: "db.write", nested: { comment: "自由記述" } })
).toEqual(["comment"]);
});
test("配列の中も検出する", () => {
expect(
findForbiddenFields({ items: [{ email: "a@example.com" }] })
).toEqual(["email"]);
});
test("comment_changed はキー名が comment ではないので検出しない", () => {
expect(findForbiddenFields({ event: "db.write", comment_changed: true })).toEqual(
[]
);
});
});
describe("stripForbiddenFields(除去。入れ子・配列も対象)", () => {
test("トップレベルの禁止キーを取り除く", () => {
expect(
stripForbiddenFields({ event: "api.error", facility_name: "dummy" })
).toEqual({ event: "api.error" });
});
test("入れ子・配列の禁止キーも取り除く", () => {
expect(
stripForbiddenFields({
event: "db.write",
nested: { comment: "秘密", ok: 1 },
items: [{ email: "a@example.com", facility_id: 1 }],
})
).toEqual({
event: "db.write",
nested: { ok: 1 },
items: [{ facility_id: 1 }],
});
});
test("元のオブジェクトを変更しない", () => {
const original = { event: "api.error", facility_name: "dummy" };
stripForbiddenFields(original);
expect(original).toEqual({ event: "api.error", facility_name: "dummy" });
});
});
describe("enforceForbiddenFieldsPolicy(環境で挙動を分岐)", () => {
test("非production: 禁止フィールドがあれば例外を投げる", () => {
expect(() =>
enforceForbiddenFieldsPolicy(
{ event: "api.error", facility_name: "dummy" },
false
)
).toThrow(/facility_name/);
});
test("非production: 禁止フィールドが無ければそのまま返す", () => {
const payload = { event: "api.error" as const };
const result = enforceForbiddenFieldsPolicy(payload, false);
expect(result).toEqual({ payload, leaked: [] });
});
test("production: 例外を投げず、キーを除去した payload と leaked を返す", () => {
const result = enforceForbiddenFieldsPolicy(
{ event: "api.error", facility_name: "dummy", http_status: 500 },
true
);
expect(result.leaked).toEqual(["facility_name"]);
expect(result.payload).toEqual({ event: "api.error", http_status: 500 });
});
test("production: 除去後の payload に値(facility_nameの中身)が残らない", () => {
const result = enforceForbiddenFieldsPolicy(
{ event: "api.error", facility_name: "本物の施設名" },
true
);
expect(JSON.stringify(result.payload)).not.toContain("本物の施設名");
});
});
describe("err の独自プロパティ", () => {
/** Prisma のエラーを模した、meta に値を持つ例外 */
class FakePrismaError extends Error {
code = "P2002";
meta: Record<string, unknown>;
constructor(message: string, meta: Record<string, unknown>) {
super(message);
this.name = "PrismaClientKnownRequestError";
this.meta = meta;
}
}
test("列挙可能な独自プロパティの禁止キーを検出する", () => {
const err = new FakePrismaError("boom", { authorization: "Bearer x" });
expect(findForbiddenFieldsInError(err)).toEqual(["authorization"]);
});
test("meta の入れ子も検出する", () => {
const err = new FakePrismaError("boom", {
target: { cookie: "dwatch_session=abc123" },
});
expect(findForbiddenFieldsInError(err)).toEqual(["cookie"]);
});
test("message / stack はキー検査の対象外(非列挙なので拾わない)", () => {
const err = new Error("boom");
expect(findForbiddenFieldsInError(err)).toEqual([]);
});
test("除去しても message / stack と型名が残る", () => {
const err = new FakePrismaError("boom", { token: "abc123" });
const cleaned = stripForbiddenFieldsFromError(err) as Error & {
code?: string;
meta?: unknown;
};
expect(cleaned).toBeInstanceOf(Error);
expect(cleaned.constructor.name).toBe("FakePrismaError");
expect(cleaned.name).toBe("PrismaClientKnownRequestError");
expect(cleaned.message).toBe("boom");
expect(cleaned.stack).toBeTruthy();
// 許可プロパティは残り、禁止キーだけ消える
expect(cleaned.code).toBe("P2002");
expect(cleaned.meta).toEqual({});
});
test("元の例外オブジェクトを変更しない", () => {
const err = new FakePrismaError("boom", { token: "abc123" });
stripForbiddenFieldsFromError(err);
expect(err.meta).toEqual({ token: "abc123" });
});
test("非production: err に禁止キーがあれば例外を投げる", () => {
const err = new FakePrismaError("boom", { password: "abc123" });
expect(() => enforceForbiddenErrorFieldsPolicy(err, false)).toThrow(
/password/
);
});
test("production: 例外を投げず、除去した err と leaked を返す", () => {
const err = new FakePrismaError("boom", { password: "abc123" });
const result = enforceForbiddenErrorFieldsPolicy(err, true);
expect(result.leaked).toEqual(["password"]);
expect((result.err as { meta?: unknown }).meta).toEqual({});
});
test("禁止キーが無ければ同一オブジェクトをそのまま返す", () => {
const err = new FakePrismaError("boom", { facility_id: 1 });
const result = enforceForbiddenErrorFieldsPolicy(err, true);
expect(result.leaked).toEqual([]);
expect(result.err).toBe(err);
});
test("object 以外(文字列 throw)でも落ちない", () => {
expect(findForbiddenFieldsInError("boom")).toEqual([]);
expect(stripForbiddenFieldsFromError("boom")).toBe("boom");
expect(enforceForbiddenErrorFieldsPolicy(undefined, false)).toEqual({
err: undefined,
leaked: [],
});
});
});
});