/**
* 環境変数検証の期待値表(指示書I 段階5-B の検査1〜6)。
*
* 検査ごとに「引っかかる入力」「引っかからない入力」「本番判定 true/false で
* 結果が変わること」を固定する。
* ★エラーメッセージに値が含まれないことのテストを含む(要点2 が後から壊れるのを防ぐ)★
*/
import { describe, expect, it } from "vitest";
import {
checkEnv,
databaseWriteCredentials,
formatFinding,
hasError,
type EnvCheckFinding,
} from "@/lib/config/env-check";
/** 全検査を通過する最小構成(本番判定でも findings が空になる値) */
const clean: Record<string, string | undefined> = {
AUTH_TOKEN: "a-real-secret-value",
DATABASE_URL: "mysql://user:pw@db.example.internal:3306/clean_watch",
USE_FIXTURES: "false",
LOG_LEVEL: "info",
// ★書き込み用アカウント(指示書AT §3-2)★ — 使うときは部品変数の HOST / NAME も要る
DATABASE_HOST: "db.example.internal",
DATABASE_NAME: "clean_watch",
DATABASE_WRITE_USER: "writer-user-example",
DATABASE_WRITE_PASSWORD: "writer-password-example",
};
function find(
findings: EnvCheckFinding[],
key: string
): EnvCheckFinding | undefined {
return findings.find((f) => f.key === key);
}
describe("checkEnv() — 正常系", () => {
it("本番判定でも、正しく埋まっていれば findings は空", () => {
expect(checkEnv(clean, true)).toEqual([]);
expect(hasError(checkEnv(clean, true))).toBe(false);
});
it("開発機(本番判定 false)でも空", () => {
expect(checkEnv(clean, false)).toEqual([]);
});
});
describe("検査1: AUTH_TOKEN", () => {
it("★changeme のままなら エラー★", () => {
const f = find(checkEnv({ ...clean, AUTH_TOKEN: "changeme" }, true), "AUTH_TOKEN");
expect(f?.severity).toBe("error");
});
it("★開発機でも エラー(本番判定に関係なく落とす)★", () => {
const f = find(checkEnv({ ...clean, AUTH_TOKEN: "changeme" }, false), "AUTH_TOKEN");
expect(f?.severity).toBe("error");
});
it("未設定なら エラー", () => {
expect(
find(checkEnv({ ...clean, AUTH_TOKEN: undefined }, false), "AUTH_TOKEN")
?.severity
).toBe("error");
});
it("空文字なら エラー", () => {
expect(
find(checkEnv({ ...clean, AUTH_TOKEN: "" }, false), "AUTH_TOKEN")?.severity
).toBe("error");
});
it("ちゃんとした値なら引っかからない", () => {
expect(find(checkEnv(clean, true), "AUTH_TOKEN")).toBeUndefined();
});
});
describe("検査2: ALLOW_DIRECT_ACCESS", () => {
it("★本番判定で true なら エラー★", () => {
const f = find(
checkEnv({ ...clean, ALLOW_DIRECT_ACCESS: "true" }, true),
"ALLOW_DIRECT_ACCESS"
);
expect(f?.severity).toBe("error");
});
it("★開発機で true なら 警告(落としてはいけない)★", () => {
const f = find(
checkEnv({ ...clean, ALLOW_DIRECT_ACCESS: "true" }, false),
"ALLOW_DIRECT_ACCESS"
);
expect(f?.severity).toBe("warn");
expect(hasError(checkEnv({ ...clean, ALLOW_DIRECT_ACCESS: "true" }, false))).toBe(
false
);
});
it("未設定なら引っかからない(=本番の正しい状態)", () => {
expect(find(checkEnv(clean, true), "ALLOW_DIRECT_ACCESS")).toBeUndefined();
});
it("false / 1 / TRUE は(アプリ側で false 扱いなので)引っかからない", () => {
for (const value of ["false", "1", "TRUE"]) {
expect(
find(
checkEnv({ ...clean, ALLOW_DIRECT_ACCESS: value }, true),
"ALLOW_DIRECT_ACCESS"
)
).toBeUndefined();
}
});
});
describe("検査3: USE_FIXTURES", () => {
it("★本番判定で false 以外なら 警告★", () => {
for (const value of [undefined, "true", "yes", ""]) {
const f = find(
checkEnv({ ...clean, USE_FIXTURES: value }, true),
"USE_FIXTURES"
);
expect(f?.severity).toBe("warn");
}
});
it("開発機では引っかからない", () => {
expect(
find(checkEnv({ ...clean, USE_FIXTURES: "true" }, false), "USE_FIXTURES")
).toBeUndefined();
});
it("false なら引っかからない", () => {
expect(find(checkEnv(clean, true), "USE_FIXTURES")).toBeUndefined();
});
});
describe("検査4/5: DATABASE_URL", () => {
it("未設定なら エラー", () => {
expect(
find(checkEnv({ ...clean, DATABASE_URL: undefined }, false), "DATABASE_URL")
?.severity
).toBe("error");
});
it("★本番判定で localhost なら 警告★", () => {
const f = find(
checkEnv(
{ ...clean, DATABASE_URL: "mysql://root@localhost:3306/dwatch_local" },
true
),
"DATABASE_URL"
);
expect(f?.severity).toBe("warn");
});
it("★本番判定で 127.0.0.1 なら 警告★", () => {
const f = find(
checkEnv(
{ ...clean, DATABASE_URL: "mysql://root@127.0.0.1:3306/dwatch_local" },
true
),
"DATABASE_URL"
);
expect(f?.severity).toBe("warn");
});
it("開発機で localhost なら引っかからない(=ローカル開発の正常な状態)", () => {
expect(
find(
checkEnv(
{ ...clean, DATABASE_URL: "mysql://root@localhost:3306/dwatch_local" },
false
),
"DATABASE_URL"
)
).toBeUndefined();
});
it("リモートホストなら引っかからない", () => {
expect(find(checkEnv(clean, true), "DATABASE_URL")).toBeUndefined();
});
});
describe("検査6: LOG_LEVEL", () => {
it("★debug を弾かないこと(resolveLevel() が開発時に返す値)★", () => {
expect(
find(checkEnv({ ...clean, LOG_LEVEL: "debug" }, false), "LOG_LEVEL")
).toBeUndefined();
});
it("pino のレベル名はすべて通す", () => {
for (const level of ["trace", "debug", "info", "warn", "error", "fatal"]) {
expect(
find(checkEnv({ ...clean, LOG_LEVEL: level }, true), "LOG_LEVEL")
).toBeUndefined();
}
});
it("想定外の値なら 警告", () => {
const f = find(
checkEnv({ ...clean, LOG_LEVEL: "verbose" }, true),
"LOG_LEVEL"
);
expect(f?.severity).toBe("warn");
});
it("未設定なら引っかからない(NODE_ENV から判定されるため)", () => {
expect(
find(checkEnv({ ...clean, LOG_LEVEL: undefined }, true), "LOG_LEVEL")
).toBeUndefined();
});
});
describe("★要点2: エラーメッセージに値そのものを出さないこと★", () => {
const secretPassword = "S3cr3t-P4ssw0rd-DoNotLeak";
const secretToken = "super-secret-shared-token";
it("★DATABASE_URL のパスワードが出力に含まれないこと★", () => {
const env = {
...clean,
DATABASE_URL: `mysql://admin:${secretPassword}@localhost:3306/dwatch_local`,
};
const output = checkEnv(env, true).map(formatFinding).join("\n");
expect(output).not.toContain(secretPassword);
expect(output).not.toContain("admin");
// 警告自体は出ていること(何も検出していないから含まれないのでは意味がない)
expect(output).toContain("DATABASE_URL");
});
it("★パースできない DATABASE_URL でも値が漏れないこと★", () => {
const env = { ...clean, DATABASE_URL: `not-a-url-${secretPassword}` };
const output = checkEnv(env, true).map(formatFinding).join("\n");
expect(output).not.toContain(secretPassword);
});
it("★AUTH_TOKEN の値が出力に含まれないこと★", () => {
const env = { ...clean, AUTH_TOKEN: secretToken, LOG_LEVEL: "verbose" };
const output = checkEnv(env, true).map(formatFinding).join("\n");
expect(output).not.toContain(secretToken);
});
it("★どの検査結果にも、渡した値の文字列が含まれないこと★(総当たり)", () => {
const env = {
AUTH_TOKEN: "changeme",
DATABASE_URL: `mysql://u:${secretPassword}@localhost:3306/d`,
USE_FIXTURES: "true",
ALLOW_DIRECT_ACCESS: "true",
LOG_LEVEL: "verbose",
};
const findings = checkEnv(env, true);
expect(findings.length).toBeGreaterThan(0);
const output = findings.map(formatFinding).join("\n");
expect(output).not.toContain(secretPassword);
// changeme は「キーワード名」として言及してよい唯一の例外(値そのものが公開情報のため)
});
});
describe("hasError() / formatFinding()", () => {
it("エラーが1件でもあれば true", () => {
expect(hasError(checkEnv({ ...clean, AUTH_TOKEN: "changeme" }, false))).toBe(
true
);
});
it("警告だけなら false(終了コード0)", () => {
const findings = checkEnv({ ...clean, LOG_LEVEL: "verbose" }, true);
expect(findings.length).toBeGreaterThan(0);
expect(hasError(findings)).toBe(false);
});
it("★警告とエラーが出力で区別できること★", () => {
expect(
formatFinding({ severity: "error", key: "K", message: "m" })
).toMatch(/^ERROR: /);
expect(formatFinding({ severity: "warn", key: "K", message: "m" })).toMatch(
/^WARN /
);
});
});
/**
* 検査7: 書き込み用アカウント(指示書AT §3-2 / 段階7)
* 未設定 = warn「書き込みは参照用アカウントで行います」/ 片方だけ = error / 値は出さない
*/
describe("検査7: DATABASE_WRITE_USER / DATABASE_WRITE_PASSWORD", () => {
const none = { ...clean, DATABASE_WRITE_USER: undefined, DATABASE_WRITE_PASSWORD: undefined };
it("databaseWriteCredentials(): none / partial / both(空文字は未設定扱い)", () => {
expect(databaseWriteCredentials({})).toBe("none");
expect(databaseWriteCredentials({ DATABASE_WRITE_USER: "", DATABASE_WRITE_PASSWORD: "" })).toBe("none");
expect(databaseWriteCredentials({ DATABASE_WRITE_USER: "u" })).toBe("partial");
expect(databaseWriteCredentials({ DATABASE_WRITE_PASSWORD: "p" })).toBe("partial");
expect(databaseWriteCredentials({ DATABASE_WRITE_USER: "u", DATABASE_WRITE_PASSWORD: "" })).toBe("partial");
expect(databaseWriteCredentials({ DATABASE_WRITE_USER: "u", DATABASE_WRITE_PASSWORD: "p" })).toBe("both");
});
it("★2つとも未設定 → 警告(参照用アカウントで書く)★。開発機でも本番でも同じ", () => {
for (const isProduction of [true, false]) {
const f = find(checkEnv(none, isProduction), "DATABASE_WRITE_USER");
expect(f?.severity).toBe("warn");
expect(f?.message).toContain("参照用アカウント");
}
expect(hasError(checkEnv(none, true))).toBe(false);
});
it("★片方だけ → エラー★(黙って参照用に倒さない)", () => {
for (const env of [
{ ...none, DATABASE_WRITE_USER: "u" },
{ ...none, DATABASE_WRITE_PASSWORD: "p" },
]) {
expect(find(checkEnv(env, false), "DATABASE_WRITE_USER")?.severity).toBe("error");
}
});
it("2つとも設定でも DATABASE_HOST / DATABASE_NAME が無ければエラー(接続先を組み立てられない)", () => {
expect(
find(checkEnv({ ...clean, DATABASE_HOST: undefined }, false), "DATABASE_WRITE_USER")?.severity
).toBe("error");
expect(
find(checkEnv({ ...clean, DATABASE_NAME: undefined }, false), "DATABASE_WRITE_USER")?.severity
).toBe("error");
});
it("2つとも設定し HOST / NAME もあれば何も出ない", () => {
expect(find(checkEnv(clean, true), "DATABASE_WRITE_USER")).toBeUndefined();
});
it("★出力に書き込み用のユーザー名・パスワードが含まれないこと★", () => {
const secretUser = "write-user-DoNotLeak";
const secretPassword = "write-pass-DoNotLeak";
for (const env of [
{ ...none, DATABASE_WRITE_USER: secretUser },
{ ...none, DATABASE_WRITE_PASSWORD: secretPassword },
{ ...clean, DATABASE_WRITE_USER: secretUser, DATABASE_WRITE_PASSWORD: secretPassword, DATABASE_HOST: undefined },
]) {
const output = checkEnv(env, true).map(formatFinding).join("\n");
// 何かは出ていること(何も検出していないから含まれないのでは意味がない)
expect(output).toContain("DATABASE_WRITE_USER");
expect(output).not.toContain(secretUser);
expect(output).not.toContain(secretPassword);
}
});
});