返回 CodeWhale
admin-login.test.ts
根目录 / web / lib / admin-login.test.ts
1 import { beforeEach, describe, expect, it, vi } from "vitest";
2 const mocks = vi.hoisted(() => ({
3 getAgentEnv: vi.fn(), safeEqual: vi.fn(), createSession: vi.fn(), limit: vi.fn(),
4 }));
5 vi.mock("@/lib/community-agent", () => mocks);
6 import { POST } from "@/app/api/admin/login/route";
7
8 function request(token = "wrong", ip = "127.0.0.1") {
9 return new Request("https://codewhale.net/api/admin/login?locale=en", {
10 method: "POST",
11 headers: { "Content-Type": "application/x-www-form-urlencoded", "CF-Connecting-IP": ip },
12 body: new URLSearchParams({ token }),
13 });
14 }
15 beforeEach(() => {
16 vi.resetAllMocks();
17 mocks.getAgentEnv.mockResolvedValue({ MAINTAINER_TOKEN: "configured", ADMIN_LOGIN_LIMITER: { limit: mocks.limit }, CURATED_KV: {} });
18 mocks.limit.mockResolvedValue({ success: true });
19 mocks.safeEqual.mockResolvedValue(false);
20 mocks.createSession.mockResolvedValue("session-id");
21 });
22 describe("admin login attempt boundary", () => {
23 it("bounds repeated guesses independently of token and forged IP", async () => {
24 let attempts = 0;
25 mocks.limit.mockImplementation(async () => ({ success: ++attempts <= 5 }));
26 for (let index = 0; index < 7; index++) {
27 const response = await POST(request(`guess-${index}`, `192.0.2.${index}`));
28 expect(response.status).toBe(index < 5 ? 303 : 429);
29 if (index >= 5) expect(response.headers.get("Retry-After")).toBe("60");
30 expect(response.headers.get("Cache-Control")).toBe("no-store");
31 }
32 expect(mocks.safeEqual).toHaveBeenCalledTimes(5);
33 expect(mocks.createSession).not.toHaveBeenCalled();
34 expect(mocks.limit.mock.calls.map(([options]) => options.key)).toEqual(Array(7).fill("codewhale-web:admin-login"));
35 });
36 it("does not parse or compare credentials after refusal", async () => {
37 mocks.limit.mockResolvedValue({ success: false });
38 const req = request("configured");
39 const read = vi.spyOn(req, "text");
40 expect((await POST(req)).status).toBe(429);
41 expect(read).not.toHaveBeenCalled();
42 expect(mocks.safeEqual).not.toHaveBeenCalled();
43 expect(mocks.createSession).not.toHaveBeenCalled();
44 });
45 it("fails closed if the binding is missing or unavailable", async () => {
46 mocks.getAgentEnv.mockResolvedValueOnce({ MAINTAINER_TOKEN: "configured" });
47 expect((await POST(request())).status).toBe(503);
48 mocks.limit.mockRejectedValue(new Error("binding unavailable"));
49 expect((await POST(request())).status).toBe(503);
50 expect(mocks.safeEqual).not.toHaveBeenCalled();
51 expect(mocks.createSession).not.toHaveBeenCalled();
52 });
53 it("retains the authenticated session and redirect after an allowed attempt", async () => {
54 mocks.safeEqual.mockResolvedValue(true);
55 const response = await POST(request("configured"));
56 expect(response.status).toBe(303);
57 expect(response.headers.get("Location")).toBe("https://codewhale.net/en/admin");
58 const cookie = response.headers.get("Set-Cookie");
59 expect(cookie).toContain("mt_sid=session-id");
60 expect(cookie).toContain("HttpOnly");
61 expect(cookie).toContain("Secure");
62 expect(cookie?.toLowerCase()).toContain("samesite=strict");
63 expect(mocks.createSession).toHaveBeenCalledOnce();
64 });
65 });
66
66 lines TYPESCRIPT