bindAllProduction injected NoopRateLimit, so the sign-in budgets the auth manifest declares were never enforced in production (audit finding A4/B3). The production ctx now binds InMemoryRateLimit seeded from the manifest's rateLimit budgets (manifest stays the source of truth); dev-seed intentionally keeps Noop so local iteration never throttles. A regression test drives sign-in through the REAL auth production binder + app router and asserts the 6th failed attempt returns TOO_MANY_REQUESTS while other IPs stay unaffected. In-memory counters are per-process; multi-instance deployments need a shared IRateLimit backend. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
98 lines
3.6 KiB
TypeScript
98 lines
3.6 KiB
TypeScript
// A4/B3 regression: the PRODUCTION binder must enforce the auth manifest's
|
|
// rate-limit budgets. Unlike bind-production.test.ts (which mocks every
|
|
// feature binder), this file runs the REAL auth production binder against a
|
|
// stubbed Payload local API and drives sign-in through the app router.
|
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
|
|
vi.mock("@repo/core-cms", () => ({ default: Promise.resolve({}) }));
|
|
|
|
const payloadStub = vi.hoisted(() => ({
|
|
secret: "test-secret",
|
|
jobs: { queue: vi.fn() },
|
|
// No user ever matches → every sign-in fails and consumes budget.
|
|
find: vi.fn(async () => ({ docs: [] })),
|
|
findByID: vi.fn(async () => null),
|
|
create: vi.fn(async ({ data }: { data: Record<string, unknown> }) => data),
|
|
}));
|
|
vi.mock("payload", () => ({ getPayload: vi.fn(async () => payloadStub) }));
|
|
|
|
// Other features are irrelevant here — mock their binders so this test only
|
|
// boots the auth production path.
|
|
vi.mock("@repo/blog/di/bind-production", () => ({
|
|
bindProductionBlog: vi.fn(),
|
|
}));
|
|
vi.mock("@repo/marketing-pages/di/bind-production", () => ({
|
|
bindProductionMarketingPages: vi.fn(),
|
|
}));
|
|
vi.mock("@repo/navigation/di/bind-production", () => ({
|
|
bindProductionNavigation: vi.fn(),
|
|
}));
|
|
vi.mock("@repo/media/di/bind-production", () => ({
|
|
bindProductionMedia: vi.fn(),
|
|
}));
|
|
|
|
describe("bindAllProduction rate limiting (A4/B3)", () => {
|
|
beforeEach(() => {
|
|
vi.resetModules();
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it("returns TOO_MANY_REQUESTS once the manifest ip budget is exhausted", async () => {
|
|
const { bindAllProduction } = await import("./bind-production");
|
|
await bindAllProduction();
|
|
|
|
const { appRouter } = await import("@repo/core-api");
|
|
const { authManifest } = await import("@repo/auth");
|
|
const caller = appRouter.createCaller({ clientIp: "203.0.113.7" });
|
|
|
|
const attempt = () =>
|
|
caller.auth.signIn({ username: "ghost", password: "wrong-password" });
|
|
|
|
// The manifest is the budget's source of truth: 5 failed attempts pass
|
|
// through (UNAUTHORIZED), the 6th trips the ip bucket.
|
|
const ipBudget = authManifest.useCases.signIn.rateLimit.find(
|
|
(b) => b.name === "ip",
|
|
);
|
|
expect(ipBudget).toBeDefined();
|
|
for (let i = 0; i < ipBudget!.budget; i++) {
|
|
await expect(attempt()).rejects.toMatchObject({ code: "UNAUTHORIZED" });
|
|
}
|
|
await expect(attempt()).rejects.toMatchObject({
|
|
code: "TOO_MANY_REQUESTS",
|
|
});
|
|
});
|
|
|
|
it("keeps other client IPs unaffected by an exhausted bucket", async () => {
|
|
const { bindAllProduction } = await import("./bind-production");
|
|
await bindAllProduction();
|
|
|
|
const { appRouter } = await import("@repo/core-api");
|
|
const { authManifest } = await import("@repo/auth");
|
|
|
|
const throttled = appRouter.createCaller({ clientIp: "198.51.100.9" });
|
|
const ipBudget = authManifest.useCases.signIn.rateLimit.find(
|
|
(b) => b.name === "ip",
|
|
)!;
|
|
for (let i = 0; i < ipBudget.budget; i++) {
|
|
await expect(
|
|
throttled.auth.signIn({
|
|
username: `user${i}x`,
|
|
password: "wrong-password",
|
|
}),
|
|
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
|
|
}
|
|
await expect(
|
|
throttled.auth.signIn({ username: "user0x", password: "wrong-password" }),
|
|
).rejects.toMatchObject({ code: "TOO_MANY_REQUESTS" });
|
|
|
|
// A different IP still gets an ordinary auth failure, not a throttle.
|
|
const fresh = appRouter.createCaller({ clientIp: "192.0.2.55" });
|
|
await expect(
|
|
fresh.auth.signIn({
|
|
username: "someoneelse",
|
|
password: "wrong-password",
|
|
}),
|
|
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
|
|
});
|
|
});
|