/**
* 段階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");
});
});