/**
* 段階2 の判定(IP制限 → 認証)のテスト。
* ★Response は組み立てず判定だけを返す設計なので、素の値で全経路を固定できる★
*/
import { describe, expect, it } from "vitest";
import {
authenticate,
isApiPath,
isPublicPath,
SESSION_COOKIE_NAME,
} from "@/lib/proxy/authenticate";
/** 既定の依存。各テストで必要な分だけ上書きする */
const base = {
allowedIps: [] as readonly string[],
allowAllWhenEmpty: true,
allowDirectAccess: true,
trustedProxyHops: 1,
expectedToken: "correct-token",
};
describe("isPublicPath()", () => {
it("/login と /api/auth/login は認証不要", () => {
expect(isPublicPath("/login")).toBe(true);
expect(isPublicPath("/api/auth/login")).toBe(true);
});
it("配下も通す", () => {
expect(isPublicPath("/login/help")).toBe(true);
});
it("★/api/auth/logout は認証が要る★(cookie を消すだけだが、公開にする理由がない)", () => {
expect(isPublicPath("/api/auth/logout")).toBe(false);
});
it("★前方一致で誤って通さないこと★", () => {
expect(isPublicPath("/login-secret")).toBe(false);
expect(isPublicPath("/dashboard")).toBe(false);
expect(isPublicPath("/api/dashboard")).toBe(false);
});
});
describe("isApiPath()", () => {
it("APIは 401、画面は /login リダイレクト に分けるための判定", () => {
expect(isApiPath("/api/dashboard")).toBe(true);
expect(isApiPath("/api")).toBe(true);
expect(isApiPath("/dashboard")).toBe(false);
expect(isApiPath("/apiary")).toBe(false);
});
});
describe("authenticate() — IP制限が先、認証が後", () => {
it("★IP拒否は認証より先に効く★(正しいトークンを持っていても 403)", () => {
expect(
authenticate("/dashboard", "1.2.3.4, 198.51.100.9", "correct-token", {
...base,
allowedIps: ["203.0.113.10"],
allowAllWhenEmpty: false,
allowDirectAccess: false,
})
).toEqual({ kind: "deny_ip" });
});
it("★IP制限は /login にもかかる★(ログイン画面だけ誰でも開けるのでは意味が薄れる)", () => {
expect(
authenticate("/login", "1.2.3.4, 198.51.100.9", null, {
...base,
allowedIps: ["203.0.113.10"],
allowAllWhenEmpty: false,
allowDirectAccess: false,
})
).toEqual({ kind: "deny_ip" });
});
it("★最重要: 先頭に許可IPを書いた偽装ヘッダは素通りしない★", () => {
// 攻撃者が X-Forwarded-For: <許可IP> を自分で付け、nginx が実接続元を末尾に追記した形
expect(
authenticate("/dashboard", "203.0.113.10, 198.51.100.9", "correct-token", {
...base,
allowedIps: ["203.0.113.10"],
allowAllWhenEmpty: false,
allowDirectAccess: false,
})
).toEqual({ kind: "deny_ip" });
});
it("末尾が許可IPなら通る(nginx が追記した正常系)", () => {
expect(
authenticate("/dashboard", "1.2.3.4, 203.0.113.10", "correct-token", {
...base,
allowedIps: ["203.0.113.10"],
allowAllWhenEmpty: false,
allowDirectAccess: false,
})
).toEqual({ kind: "allow" });
});
it("★XFF が無く ALLOW_DIRECT_ACCESS=false なら 403★(allowlist が空でも)", () => {
expect(
authenticate("/dashboard", null, "correct-token", {
...base,
allowedIps: [],
allowAllWhenEmpty: true,
allowDirectAccess: false,
})
).toEqual({ kind: "deny_ip" });
});
it("XFF が無くても ALLOW_DIRECT_ACCESS=true なら IP判定は通る(ローカル開発)", () => {
expect(
authenticate("/dashboard", null, "correct-token", base)
).toEqual({ kind: "allow" });
});
});
describe("authenticate() — 認証", () => {
it("cookie が無ければ未認証", () => {
expect(authenticate("/dashboard", null, null, base)).toEqual({
kind: "unauthenticated",
});
});
it("cookie の値が違えば未認証", () => {
expect(authenticate("/dashboard", null, "wrong", base)).toEqual({
kind: "unauthenticated",
});
});
it("cookie の値が正しければ通る", () => {
expect(authenticate("/dashboard", null, "correct-token", base)).toEqual({
kind: "allow",
});
});
it("APIルートも同じ判定(Response の作り分けは proxy.ts の責務)", () => {
expect(authenticate("/api/dashboard", null, null, base)).toEqual({
kind: "unauthenticated",
});
});
it("★/login は cookie 無しでも通る★(ここを保護すると誰もログインできない)", () => {
expect(authenticate("/login", null, null, base)).toEqual({ kind: "allow" });
expect(authenticate("/api/auth/login", null, null, base)).toEqual({
kind: "allow",
});
});
it("★AUTH_TOKEN が未設定なら誰も通らない★(設定漏れを全公開にしない)", () => {
expect(
authenticate("/dashboard", null, "anything", {
...base,
expectedToken: undefined,
})
).toEqual({ kind: "unauthenticated" });
expect(
authenticate("/dashboard", null, "", { ...base, expectedToken: "" })
).toEqual({ kind: "unauthenticated" });
});
it("★AUTH_TOKEN 未設定でも /login は開ける★(そうでないと設定ミスを直せない)", () => {
expect(
authenticate("/login", null, null, { ...base, expectedToken: undefined })
).toEqual({ kind: "allow" });
});
});
describe("cookie 名", () => {
it("dwatch_session(lib/logger/redact.ts のマスキング対象と揃っていること)", () => {
expect(SESSION_COOKIE_NAME).toBe("dwatch_session");
});
});