Newer
Older
client_dwatch / lib / proxy / __tests__ / authenticate.test.ts
/**
 * 段階2 の判定(IP制限 → 認証)のテスト。
 * ★Response は組み立てず判定だけを返す設計なので、素の値で全経路を固定できる★
 */
import { describe, expect, it, vi } from "vitest";
import {
  authenticate,
  authenticateAgent,
  isAgentPath,
  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");
  });
});

/**
 * 指示書AY §3 表4(`lib/proxy/authenticate.ts`。`deps` で IP・`AUTH_TOKEN`・トークン検証を差し替える)。
 * ブリーフ §5 と監督判断 第2ラウンドから本書で具体化。★P1・P2 の行は §11 Q14・Q15 の採用で確定★。
 *
 * | #   | パス                                | IP   | cookie     | Authorization | 期待 |
 * | A1  | /api/ds-agent/commands              | 許可 | なし       | 正しい Bearer | allow |
 * | A2  | 同上                                | 許可 | 正しい     | なし          | unauthenticated(401) |
 * | A3  | 同上                                | 許可 | なし       | 違う Bearer   | unauthenticated |
 * | A4  | 同上                                | 許可 | 正しい     | 違う Bearer   | unauthenticated |
 * | A5  | /api/ds-devices                     | 許可 | なし       | 正しい Bearer | unauthenticated(Bearer を無視) |
 * | A6  | /api/ds-devices                     | 許可 | 正しい     | 正しい Bearer | allow(Bearer は無視) |
 * | A7  | /api/ds-agent/commands              | 拒否 | なし       | 正しい Bearer | deny_ip(403)。トークン検証を呼ばない |
 * | A8  | /api/ds-agents(似た名前)          | 許可 | 正しい     | なし          | allow(agent パスではない) |
 * | A9  | /ds-logs(画面)                    | 許可 | なし       | 正しい Bearer | unauthenticated(redirect) |
 * | A10 | /api/ds-agent / /api/ds-agent/      | 許可 | なし       | 正しい Bearer | allow(agent パス) |
 * | A11 | /api/ds-devices/DS-002/agent-token  | 許可 | なし       | 正しい Bearer | unauthenticated(制御盤のトークンでトークンを作り直せない) |
 *
 * ★proxy の振り分け(段階5-4)★: `isAgentPath()` なら `authenticateAgent()`(Bearer だけ)、それ以外は `authenticate()`(cookie だけ)。
 * 下の `decide()` はその振り分けを写したもの。「正しい Bearer」は `deps.verifyAgentToken` のスタブで作る(ストアに触らない)。
 */
const GOOD_BEARER = `Bearer ${"G".repeat(43)}`;
const BAD_BEARER = `Bearer ${"B".repeat(43)}`;
const GOOD_COOKIE = "correct-token";

const ipAllowed = {
  forwardedFor: null,
  deps: { allowedIps: [] as readonly string[], allowAllWhenEmpty: true, allowDirectAccess: true, trustedProxyHops: 1 },
};
const ipDenied = {
  forwardedFor: "1.2.3.4, 198.51.100.9",
  deps: {
    allowedIps: ["203.0.113.10"] as readonly string[],
    allowAllWhenEmpty: false,
    allowDirectAccess: false,
    trustedProxyHops: 1,
  },
};

function verifyStub() {
  return vi.fn(async (header: string | null) => (header === GOOD_BEARER ? "DS-002" : null));
}

async function decide(
  pathname: string,
  ip: "allow" | "deny",
  cookie: string | null,
  authorization: string | null,
  verifyAgentToken: ReturnType<typeof verifyStub>
) {
  const { forwardedFor, deps } = ip === "allow" ? ipAllowed : ipDenied;
  if (isAgentPath(pathname)) {
    return authenticateAgent(pathname, forwardedFor, authorization, { ...deps, verifyAgentToken });
  }
  return authenticate(pathname, forwardedFor, cookie, { ...deps, expectedToken: GOOD_COOKIE });
}

describe("isAgentPath() — 表4 A8・A10", () => {
  it.each<[string, boolean]>([
    ["/api/ds-agent", true],
    ["/api/ds-agent/", true],
    ["/api/ds-agent/commands", true],
    ["/api/ds-agent/commands/abc/chunks/0", true],
    ["/api/ds-agents", false],
    ["/api/ds-agentx/commands", false],
    ["/api/ds-devices", false],
    ["/ds-logs", false],
  ])("%s → %s", (pathname, expected) => {
    expect(isAgentPath(pathname)).toBe(expected);
  });
});

describe("制御盤の認証(Bearer)と通常の認証(cookie)の分離 — 表4", () => {
  it.each<[string, string, "allow" | "deny", string | null, string | null, string]>([
    ["A1", "/api/ds-agent/commands", "allow", null, GOOD_BEARER, "allow"],
    ["A2", "/api/ds-agent/commands", "allow", GOOD_COOKIE, null, "unauthenticated"],
    ["A3", "/api/ds-agent/commands", "allow", null, BAD_BEARER, "unauthenticated"],
    ["A4", "/api/ds-agent/commands", "allow", GOOD_COOKIE, BAD_BEARER, "unauthenticated"],
    ["A5", "/api/ds-devices", "allow", null, GOOD_BEARER, "unauthenticated"],
    ["A6", "/api/ds-devices", "allow", GOOD_COOKIE, GOOD_BEARER, "allow"],
    ["A7", "/api/ds-agent/commands", "deny", null, GOOD_BEARER, "deny_ip"],
    ["A8", "/api/ds-agents", "allow", GOOD_COOKIE, null, "allow"],
    ["A9", "/ds-logs", "allow", null, GOOD_BEARER, "unauthenticated"],
    ["A10 /api/ds-agent", "/api/ds-agent", "allow", null, GOOD_BEARER, "allow"],
    ["A10 /api/ds-agent/", "/api/ds-agent/", "allow", null, GOOD_BEARER, "allow"],
    ["A11", "/api/ds-devices/DS-002/agent-token", "allow", null, GOOD_BEARER, "unauthenticated"],
  ])("%s %s", async (_label, pathname, ip, cookie, authorization, expected) => {
    const verify = verifyStub();
    await expect(decide(pathname, ip, cookie, authorization, verify)).resolves.toEqual({ kind: expected });
  });

  it("A7 IP 拒否ならトークン検証を呼ばない", async () => {
    const verify = verifyStub();
    await decide("/api/ds-agent/commands", "deny", null, GOOD_BEARER, verify);
    expect(verify).toHaveBeenCalledTimes(0);
  });

  it.each<[string, string, string | null]>([
    ["A5", "/api/ds-devices", null],
    ["A6", "/api/ds-devices", GOOD_COOKIE],
    ["A9", "/ds-logs", null],
    ["A11", "/api/ds-devices/DS-002/agent-token", null],
  ])("%s agent パス以外では Bearer を見ない(トークン検証を呼ばない)", async (_label, pathname, cookie) => {
    const verify = verifyStub();
    await decide(pathname, "allow", cookie, GOOD_BEARER, verify);
    expect(verify).toHaveBeenCalledTimes(0);
  });

  it.each<[string]>([["/api/ds-agent/commands"], ["/api/ds-agent"], ["/api/ds-agent/"]])(
    "A2 の保険: authenticate()(同期)は agent パスなら正しい cookie でも unauthenticated(%s)",
    (pathname) => {
      expect(
        authenticate(pathname, null, GOOD_COOKIE, { ...ipAllowed.deps, expectedToken: GOOD_COOKIE })
      ).toEqual({ kind: "unauthenticated" });
    }
  );

  it("A7 の保険: authenticate()(同期)でも agent パスの IP 拒否は deny_ip", () => {
    expect(
      authenticate("/api/ds-agent/commands", ipDenied.forwardedFor, GOOD_COOKIE, {
        ...ipDenied.deps,
        expectedToken: GOOD_COOKIE,
      })
    ).toEqual({ kind: "deny_ip" });
  });

  it("authenticateAgent() の戻り値に serial を入れない(D5)", async () => {
    const verify = verifyStub();
    const decision = await authenticateAgent("/api/ds-agent/commands", null, GOOD_BEARER, {
      ...ipAllowed.deps,
      verifyAgentToken: verify,
    });
    expect(decision).toEqual({ kind: "allow" });
    expect(JSON.stringify(decision)).not.toContain("DS-002");
  });
});