Newer
Older
client_dwatch / lib / proxy / __tests__ / authenticate.test.ts
/**
 * 段階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");
  });
});