From b61bb0c11e66107dae63f5f2786ef2f98742fd32 Mon Sep 17 00:00:00 2001 From: Danijel Martinek Date: Wed, 20 May 2026 09:22:41 +0000 Subject: [PATCH] feat(auth): add signIn rate-limit backfill with dual ip/account budgets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the rate-limit primitive end-to-end through auth.signIn as the canonical credential-stuffing defence example: - manifest: rateLimit [ip 5/1m, account 10/1h] on signIn use case - use case: rateLimit: IRateLimit dep; dual consume + TooManyRequestsError - binders: ctx.rateLimit ?? new NoopRateLimit() in bind-production + bind-dev-seed - tRPC: TooManyRequestsError → TOO_MANY_REQUESTS error code in authProcedure - tests: RecordingRateLimit dual-consume assertion; InMemoryRateLimit budget-1 ip + account rejection; coverage 100% on use-cases layer - ESLint: _manifest-ast.js extractRateLimitNames handles RateLimitBudget objects ({name,window,budget}) in addition to plain string literals, no-undeclared-rate-limit passes on both "ip" and "account" call sites Co-Authored-By: Claude Sonnet 4.6 --- coverage/summary.json | 40 +++---- .../use-cases/sign-in.use-case.test.ts | 103 +++++++++++++++++- .../application/use-cases/sign-in.use-case.ts | 30 ++++- packages/auth/src/di/bind-dev-seed.ts | 4 +- packages/auth/src/di/bind-production.ts | 4 +- .../auth/src/di/bind-production.types.test.ts | 3 +- packages/auth/src/di/module.ts | 2 + packages/auth/src/entities/errors/auth.ts | 7 ++ .../auth/src/entities/errors/errors.test.ts | 10 ++ packages/auth/src/feature.manifest.ts | 4 + packages/auth/src/index.ts | 1 + .../auth/src/integrations/api/procedures.ts | 2 + .../controllers/sign-in.controller.test.ts | 20 +++- .../auth/tests/sign-in-flow.feature.test.ts | 5 +- packages/core-eslint/rules/_manifest-ast.js | 35 +++++- .../rules/no-undeclared-rate-limit.test.js | 44 ++++++++ packages/core-testing/package.json | 1 + 17 files changed, 273 insertions(+), 42 deletions(-) diff --git a/coverage/summary.json b/coverage/summary.json index 0b9a144..398dfc6 100644 --- a/coverage/summary.json +++ b/coverage/summary.json @@ -1,33 +1,33 @@ { - "generatedAt": "2026-05-20T08:59:30.996Z", - "commit": "24b2490", + "generatedAt": "2026-05-20T09:21:52.321Z", + "commit": "91d7a24", "repo": { - "statements": 97.43, - "branches": 92.35, + "statements": 97.38, + "branches": 92.39, "functions": 97.21, - "lines": 97.43, + "lines": 97.38, "counts": { - "lf": 5910, - "lh": 5758, - "brf": 1190, - "brh": 1099, - "fnf": 358, - "fnh": 348 + "lf": 5948, + "lh": 5792, + "brf": 1196, + "brh": 1105, + "fnf": 359, + "fnh": 349 } }, "byPackage": { "@repo/auth": { - "statements": 94, - "branches": 92.11, + "statements": 93.81, + "branches": 92.5, "functions": 100, - "lines": 94, + "lines": 93.81, "counts": { - "lf": 834, - "lh": 784, - "brf": 114, - "brh": 105, - "fnf": 45, - "fnh": 45 + "lf": 872, + "lh": 818, + "brf": 120, + "brh": 111, + "fnf": 46, + "fnh": 46 } }, "@repo/blog": { diff --git a/packages/auth/src/application/use-cases/sign-in.use-case.test.ts b/packages/auth/src/application/use-cases/sign-in.use-case.test.ts index c03c986..63aace9 100644 --- a/packages/auth/src/application/use-cases/sign-in.use-case.test.ts +++ b/packages/auth/src/application/use-cases/sign-in.use-case.test.ts @@ -6,9 +6,14 @@ import { } from "@/application/use-cases/sign-in.use-case"; import { MockUsersRepository } from "@/infrastructure/repositories/users.repository.mock"; import { MockAuthenticationService } from "@/infrastructure/services/authentication.service.mock"; -import { AuthenticationError } from "@/entities/errors/auth"; +import { + AuthenticationError, + TooManyRequestsError, +} from "@/entities/errors/auth"; import type { IAuthenticationService } from "@/application/services/authentication.service.interface"; import { userFactory } from "@/__factories__/user.factory"; +import { NoopRateLimit, InMemoryRateLimit } from "@repo/core-shared/rate-limit"; +import { RecordingRateLimit } from "@repo/core-testing/rate-limit"; describe("signInUseCase", () => { it("returns a session + cookie on valid credentials", async () => { @@ -20,7 +25,7 @@ describe("signInUseCase", () => { }); await users.createUser(seedUser); - const useCase = signInUseCase(users, auth); + const useCase = signInUseCase(users, auth, new NoopRateLimit()); const result = await useCase({ username: "alice", password: "testpassword", @@ -33,7 +38,7 @@ describe("signInUseCase", () => { it("throws AuthenticationError when user does not exist", async () => { const users = new MockUsersRepository([]); const auth = new MockAuthenticationService(users); - const useCase = signInUseCase(users, auth); + const useCase = signInUseCase(users, auth, new NoopRateLimit()); await expect( useCase({ username: "ghost", password: "anything" }), @@ -50,11 +55,99 @@ describe("signInUseCase", () => { }), ); - const useCase = signInUseCase(users, auth); + const useCase = signInUseCase(users, auth, new NoopRateLimit()); await expect( useCase({ username: "alice", password: "wrong" }), ).rejects.toBeInstanceOf(AuthenticationError); }); + + it("captures both ip and account consume calls via RecordingRateLimit", async () => { + const users = new MockUsersRepository([]); + const auth = new MockAuthenticationService(users); + const rl = new RecordingRateLimit(); + const seedUser = userFactory.build({ + username: "alice", + passwordHash: "hashed_testpassword", + }); + await users.createUser(seedUser); + + const useCase = signInUseCase(users, auth, rl); + await useCase({ + username: "alice", + password: "testpassword", + clientIp: "1.2.3.4", + }); + + expect(rl.consumeCalls).toHaveLength(2); + expect(rl.consumeCalls[0]).toMatchObject({ + budgetName: "ip", + key: "signIn:ip:1.2.3.4", + }); + expect(rl.consumeCalls[1]).toMatchObject({ + budgetName: "account", + key: "signIn:account:alice", + }); + }); + + it("throws TooManyRequestsError when ip budget is exhausted", async () => { + const users = new MockUsersRepository([]); + const auth = new MockAuthenticationService(users); + const rl = new InMemoryRateLimit([ + { name: "ip", window: "1m", budget: 1 }, + { name: "account", window: "1h", budget: 10 }, + ]); + const seedUser = userFactory.build({ + username: "alice", + passwordHash: "hashed_testpassword", + }); + await users.createUser(seedUser); + + const useCase = signInUseCase(users, auth, rl); + await useCase({ + username: "alice", + password: "testpassword", + clientIp: "1.2.3.4", + }); + + await expect( + useCase({ + username: "alice", + password: "testpassword", + clientIp: "1.2.3.4", + }), + ).rejects.toBeInstanceOf(TooManyRequestsError); + }); + + it("throws TooManyRequestsError when account budget is exhausted", async () => { + const users = new MockUsersRepository([]); + const auth = new MockAuthenticationService(users); + const rl = new InMemoryRateLimit([ + { name: "ip", window: "1m", budget: 100 }, + { name: "account", window: "1h", budget: 1 }, + ]); + const seedUser = userFactory.build({ + username: "alice", + passwordHash: "hashed_testpassword", + }); + await users.createUser(seedUser); + + const useCase = signInUseCase(users, auth, rl); + // First call succeeds (ip allows, account allows, credentials ok) + await useCase({ + username: "alice", + password: "testpassword", + clientIp: "1.2.3.4", + }); + + // Second call: ip still allows (high budget), account is exhausted + await expect( + useCase({ + username: "alice", + password: "testpassword", + clientIp: "5.6.7.8", + }), + ).rejects.toBeInstanceOf(TooManyRequestsError); + }); }); describe("signInUseCase output validation", () => { @@ -69,7 +162,7 @@ describe("signInUseCase output validation", () => { createSession: async () => ({ session: { id: 123 }, cookie: null }), } as unknown as IAuthenticationService; - const useCase = signInUseCase(users, auth); + const useCase = signInUseCase(users, auth, new NoopRateLimit()); await expect( useCase({ username: "alice", password: "x" }), ).rejects.toBeInstanceOf(ZodError); diff --git a/packages/auth/src/application/use-cases/sign-in.use-case.ts b/packages/auth/src/application/use-cases/sign-in.use-case.ts index 09d886b..bf8ef72 100644 --- a/packages/auth/src/application/use-cases/sign-in.use-case.ts +++ b/packages/auth/src/application/use-cases/sign-in.use-case.ts @@ -1,6 +1,10 @@ import { z } from "zod"; -import { AuthenticationError } from "../../entities/errors/auth"; +import type { IRateLimit } from "@repo/core-shared/rate-limit"; +import { + AuthenticationError, + TooManyRequestsError, +} from "../../entities/errors/auth"; import { cookieSchema } from "../../entities/models/cookie"; import { sessionSchema } from "../../entities/models/session"; import type { IUsersRepository } from "../repositories/users.repository.interface"; @@ -11,6 +15,7 @@ export const signInInputSchema = z .object({ username: z.string().min(3).max(31), password: z.string().min(6).max(255), + clientIp: z.string().optional(), }) .strict(); export type SignInInput = z.infer; @@ -26,9 +31,28 @@ export type SignInOutput = z.infer; export type ISignInUseCase = ReturnType; export const signInUseCase = - (usersRepository: IUsersRepository, authenticationService: IAuthenticationService) => + ( + usersRepository: IUsersRepository, + authenticationService: IAuthenticationService, + rateLimit: IRateLimit, + ) => async (input: SignInInput): Promise => { - const existingUser = await usersRepository.getUserByUsername(input.username); + const { allowed: ipAllowed } = await rateLimit.consume( + "ip", + `signIn:ip:${input.clientIp ?? ""}`, + ); + if (!ipAllowed) throw new TooManyRequestsError("Too many sign-in attempts"); + + const { allowed: accountAllowed } = await rateLimit.consume( + "account", + `signIn:account:${input.username}`, + ); + if (!accountAllowed) + throw new TooManyRequestsError("Too many sign-in attempts"); + + const existingUser = await usersRepository.getUserByUsername( + input.username, + ); if (!existingUser) { throw new AuthenticationError("User does not exist"); } diff --git a/packages/auth/src/di/bind-dev-seed.ts b/packages/auth/src/di/bind-dev-seed.ts index cdeaa5c..a787938 100644 --- a/packages/auth/src/di/bind-dev-seed.ts +++ b/packages/auth/src/di/bind-dev-seed.ts @@ -10,6 +10,7 @@ import { assertFeatureConformance, wireUseCase, } from "@repo/core-shared/conformance"; +import { NoopRateLimit } from "@repo/core-shared/rate-limit"; import { authManifest } from "../feature.manifest.js"; import { authContainer } from "./container.js"; import { AUTH_SYMBOLS } from "./symbols.js"; @@ -85,12 +86,13 @@ export async function bindDevSeedAuth(ctx: BindContext): Promise { container: authContainer, symbol: AUTH_SYMBOLS.ISignInUseCase, factory: signInUseCase, - deps: [repo, authService], + deps: [repo, authService, ctx.rateLimit ?? new NoopRateLimit()], feature: "auth", layer: "use-case", name: "signIn", tracer, logger, + rateLimit: ctx.rateLimit ?? new NoopRateLimit(), }); const wrappedSignUp = wireUseCase({ container: authContainer, diff --git a/packages/auth/src/di/bind-production.ts b/packages/auth/src/di/bind-production.ts index 9adf90f..b2d9fd3 100644 --- a/packages/auth/src/di/bind-production.ts +++ b/packages/auth/src/di/bind-production.ts @@ -10,6 +10,7 @@ import { assertFeatureConformance, wireUseCase, } from "@repo/core-shared/conformance"; +import { NoopRateLimit } from "@repo/core-shared/rate-limit"; import { authManifest } from "../feature.manifest"; import { authContainer } from "./container"; import { AUTH_SYMBOLS } from "./symbols"; @@ -77,12 +78,13 @@ export function bindProductionAuth(ctx: BindProductionContext): void { container: authContainer, symbol: AUTH_SYMBOLS.ISignInUseCase, factory: signInUseCase, - deps: [repo, authService], + deps: [repo, authService, ctx.rateLimit ?? new NoopRateLimit()], feature: "auth", layer: "use-case", name: "signIn", tracer, logger, + rateLimit: ctx.rateLimit ?? new NoopRateLimit(), }); const wrappedSignUp = wireUseCase({ container: authContainer, diff --git a/packages/auth/src/di/bind-production.types.test.ts b/packages/auth/src/di/bind-production.types.test.ts index 7782a3a..cedb544 100644 --- a/packages/auth/src/di/bind-production.types.test.ts +++ b/packages/auth/src/di/bind-production.types.test.ts @@ -19,7 +19,8 @@ describe("auth.signIn binding slot (type-level)", () => { // It returns a function with no brand attached — must not be assignable. const fakeRepo = {} as never; const fakeAuth = {} as never; - const unwrapped = signInUseCase(fakeRepo, fakeAuth); + const fakeRateLimit = {} as never; + const unwrapped = signInUseCase(fakeRepo, fakeAuth, fakeRateLimit); // @ts-expect-error — unwrapped factory has no __instrumented / __captured brand const _bad: Slot = unwrapped; diff --git a/packages/auth/src/di/module.ts b/packages/auth/src/di/module.ts index eb0be3c..2159f12 100644 --- a/packages/auth/src/di/module.ts +++ b/packages/auth/src/di/module.ts @@ -1,5 +1,6 @@ import { ContainerModule, type interfaces } from "inversify"; +import { NoopRateLimit } from "@repo/core-shared/rate-limit"; import type { IUsersRepository } from "../application/repositories/users.repository.interface"; import type { IAuthenticationService } from "../application/services/authentication.service.interface"; import { MockUsersRepository } from "../infrastructure/repositories/users.repository.mock"; @@ -42,6 +43,7 @@ export const AuthModule = new ContainerModule((bind: interfaces.Bind) => { ctx.container.get( AUTH_SYMBOLS.IAuthenticationService, ), + new NoopRateLimit(), ), ); diff --git a/packages/auth/src/entities/errors/auth.ts b/packages/auth/src/entities/errors/auth.ts index 24d76ac..2658011 100644 --- a/packages/auth/src/entities/errors/auth.ts +++ b/packages/auth/src/entities/errors/auth.ts @@ -18,3 +18,10 @@ export class UnauthorizedError extends Error { this.name = "UnauthorizedError"; } } + +export class TooManyRequestsError extends Error { + constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.name = "TooManyRequestsError"; + } +} diff --git a/packages/auth/src/entities/errors/errors.test.ts b/packages/auth/src/entities/errors/errors.test.ts index 5dfc2cf..7d92301 100644 --- a/packages/auth/src/entities/errors/errors.test.ts +++ b/packages/auth/src/entities/errors/errors.test.ts @@ -3,6 +3,7 @@ import { AuthenticationError, UnauthenticatedError, UnauthorizedError, + TooManyRequestsError, } from "./auth"; import { InputParseError } from "./common"; @@ -37,3 +38,12 @@ describe("InputParseError", () => { expect(err.message).toBe("invalid input"); }); }); + +describe("TooManyRequestsError", () => { + it("is an instance of Error with the given message", () => { + const err = new TooManyRequestsError("too many attempts"); + expect(err).toBeInstanceOf(Error); + expect(err.message).toBe("too many attempts"); + expect(err.name).toBe("TooManyRequestsError"); + }); +}); diff --git a/packages/auth/src/feature.manifest.ts b/packages/auth/src/feature.manifest.ts index 3ed0bdb..74e87e4 100644 --- a/packages/auth/src/feature.manifest.ts +++ b/packages/auth/src/feature.manifest.ts @@ -21,6 +21,10 @@ export const authManifest = defineFeature({ audits: [], publishes: [], consumes: [], + rateLimit: [ + { name: "ip", window: "1m", budget: 5 }, + { name: "account", window: "1h", budget: 10 }, + ], }, signUp: { mutates: true, diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts index 665cde4..5ac9563 100644 --- a/packages/auth/src/index.ts +++ b/packages/auth/src/index.ts @@ -6,6 +6,7 @@ export { AuthenticationError, UnauthenticatedError, UnauthorizedError, + TooManyRequestsError, } from "./entities/errors/auth"; export { InputParseError } from "./entities/errors/common"; export { SESSION_COOKIE } from "./config"; diff --git a/packages/auth/src/integrations/api/procedures.ts b/packages/auth/src/integrations/api/procedures.ts index 87effe5..e050ffa 100644 --- a/packages/auth/src/integrations/api/procedures.ts +++ b/packages/auth/src/integrations/api/procedures.ts @@ -5,6 +5,7 @@ import { AuthenticationError, UnauthenticatedError, UnauthorizedError, + TooManyRequestsError, } from "../../entities/errors/auth"; import { InputParseError } from "../../entities/errors/common"; @@ -14,5 +15,6 @@ export const authProcedure = t.procedure.use( [AuthenticationError, "UNAUTHORIZED"], [UnauthenticatedError, "UNAUTHORIZED"], [UnauthorizedError, "FORBIDDEN"], + [TooManyRequestsError, "TOO_MANY_REQUESTS"], ]), ); diff --git a/packages/auth/src/interface-adapters/controllers/sign-in.controller.test.ts b/packages/auth/src/interface-adapters/controllers/sign-in.controller.test.ts index b72ada3..f98c20f 100644 --- a/packages/auth/src/interface-adapters/controllers/sign-in.controller.test.ts +++ b/packages/auth/src/interface-adapters/controllers/sign-in.controller.test.ts @@ -5,15 +5,19 @@ import { MockUsersRepository } from "@/infrastructure/repositories/users.reposit import { MockAuthenticationService } from "@/infrastructure/services/authentication.service.mock"; import { InputParseError } from "@/entities/errors/common"; import { userFactory } from "@/__factories__/user.factory"; +import { NoopRateLimit } from "@repo/core-shared/rate-limit"; describe("signInController", () => { it("returns a cookie on successful sign-in", async () => { const users = new MockUsersRepository([]); const auth = new MockAuthenticationService(users); - const seedUser = userFactory.build({ username: "alice", passwordHash: "hashed_testpassword" }); + const seedUser = userFactory.build({ + username: "alice", + passwordHash: "hashed_testpassword", + }); await users.createUser(seedUser); - const useCase = signInUseCase(users, auth); + const useCase = signInUseCase(users, auth, new NoopRateLimit()); const controller = signInController(useCase); const result = await controller({ @@ -27,18 +31,22 @@ describe("signInController", () => { it("throws InputParseError on invalid input", async () => { const users = new MockUsersRepository([]); const auth = new MockAuthenticationService(users); - const useCase = signInUseCase(users, auth); + const useCase = signInUseCase(users, auth, new NoopRateLimit()); const controller = signInController(useCase); - await expect(controller({ username: "ab" } as unknown)).rejects.toBeInstanceOf(InputParseError); + await expect( + controller({ username: "ab" } as unknown), + ).rejects.toBeInstanceOf(InputParseError); }); it("throws InputParseError when input is not an object", async () => { const users = new MockUsersRepository([]); const auth = new MockAuthenticationService(users); - const useCase = signInUseCase(users, auth); + const useCase = signInUseCase(users, auth, new NoopRateLimit()); const controller = signInController(useCase); - await expect(controller("garbage" as unknown)).rejects.toBeInstanceOf(InputParseError); + await expect(controller("garbage" as unknown)).rejects.toBeInstanceOf( + InputParseError, + ); }); }); diff --git a/packages/auth/tests/sign-in-flow.feature.test.ts b/packages/auth/tests/sign-in-flow.feature.test.ts index 78e0b0a..95844af 100644 --- a/packages/auth/tests/sign-in-flow.feature.test.ts +++ b/packages/auth/tests/sign-in-flow.feature.test.ts @@ -3,6 +3,7 @@ import { describe, it, expect } from "vitest"; import { RecordingEventBus } from "@repo/core-testing/instrumentation"; +import { NoopRateLimit } from "@repo/core-shared/rate-limit"; import { MockUsersRepository } from "../src/infrastructure/repositories/users.repository.mock"; import { MockAuthenticationService } from "../src/infrastructure/services/authentication.service.mock"; import { signInUseCase } from "../src/application/use-cases/sign-in.use-case"; @@ -18,7 +19,9 @@ describe("auth feature: sign-up → sign-in → sign-out", () => { const users = new MockUsersRepository([]); const auth = new MockAuthenticationService(users); - const signIn = signInController(signInUseCase(users, auth)); + const signIn = signInController( + signInUseCase(users, auth, new NoopRateLimit()), + ); const signUp = signUpController( signUpUseCase(users, auth, new RecordingEventBus(), undefined), ); diff --git a/packages/core-eslint/rules/_manifest-ast.js b/packages/core-eslint/rules/_manifest-ast.js index 07f96ca..eef68ab 100644 --- a/packages/core-eslint/rules/_manifest-ast.js +++ b/packages/core-eslint/rules/_manifest-ast.js @@ -7,6 +7,31 @@ function extractStringLiterals(arrayExpr) { .map((el) => el.value); } +/** + * Extract budget names from a rateLimit array that contains either string + * literals ("ip") or RateLimitBudget objects ({ name: "ip", window: "1m", budget: 5 }). + */ +function extractRateLimitNames(arrayExpr) { + const names = []; + for (const el of arrayExpr.elements) { + if (!el) continue; + if (el.type === "Literal" && typeof el.value === "string") { + names.push(el.value); + } else if (el.type === "ObjectExpression") { + const nameProp = el.properties.find( + (p) => + p.type === "Property" && + p.key.type === "Identifier" && + p.key.name === "name" && + p.value.type === "Literal" && + typeof p.value.value === "string", + ); + if (nameProp) names.push(nameProp.value.value); + } + } + return names; +} + /** * Parse a feature.manifest.ts file and extract per-use-case attributes. * Walks the AST to find the `defineFeature({...} as const)` call expression @@ -98,11 +123,12 @@ function extractUseCaseEntry(objExpr) { (key === "audits" || key === "publishes" || key === "consumes" || - key === "analyticsEvents" || - key === "rateLimit") && + key === "analyticsEvents") && prop.value.type === "ArrayExpression" ) { entry[key] = extractStringLiterals(prop.value); + } else if (key === "rateLimit" && prop.value.type === "ArrayExpression") { + entry[key] = extractRateLimitNames(prop.value); } } return entry; @@ -232,11 +258,12 @@ function extractUseCaseEntryFromObj(objExpr) { (key === "audits" || key === "publishes" || key === "consumes" || - key === "analyticsEvents" || - key === "rateLimit") && + key === "analyticsEvents") && prop.value.type === "ArrayExpression" ) { entry[key] = extractStringLiterals(prop.value); + } else if (key === "rateLimit" && prop.value.type === "ArrayExpression") { + entry[key] = extractRateLimitNames(prop.value); } } return entry; diff --git a/packages/core-eslint/rules/no-undeclared-rate-limit.test.js b/packages/core-eslint/rules/no-undeclared-rate-limit.test.js index 7f7cd30..f24239a 100644 --- a/packages/core-eslint/rules/no-undeclared-rate-limit.test.js +++ b/packages/core-eslint/rules/no-undeclared-rate-limit.test.js @@ -110,6 +110,50 @@ describe("no-undeclared-rate-limit", () => { }); }); + it("passes when rateLimit.consume budget matches a RateLimitBudget object in manifest rateLimit[]", () => { + const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "nurl-obj-")); + const featureDir = path.join(repoRoot, "packages", "demo"); + fs.mkdirSync(path.join(featureDir, "src", "application", "use-cases"), { + recursive: true, + }); + fs.writeFileSync( + path.join(featureDir, "src", "feature.manifest.ts"), + `export const demoManifest = defineFeature({ + name: "demo", + requiredCores: [], + useCases: { + signUp: { mutates: true, audits: [], publishes: [], consumes: [], rateLimit: [{ name: "ip", window: "1m", budget: 5 }, { name: "account", window: "1h", budget: 10 }] }, + }, + realtimeChannels: [], + jobs: [], +} as const);`, + ); + const useCaseFile = path.join( + featureDir, + "src", + "application", + "use-cases", + "sign-up.use-case.ts", + ); + fs.writeFileSync( + useCaseFile, + `export const signUpUseCase = (rateLimit) => async (input) => { + await rateLimit.consume("ip", input.clientIp); + await rateLimit.consume("account", input.username); +};`, + ); + tester.run("no-undeclared-rate-limit", rule, { + valid: [ + { + filename: useCaseFile, + code: fs.readFileSync(useCaseFile, "utf8"), + options: [{ repoRoot }], + }, + ], + invalid: [], + }); + }); + it("is a no-op for non-use-case files", () => { const { repoRoot } = makeFixture({ manifestRateLimit: ["signUp.ip"], diff --git a/packages/core-testing/package.json b/packages/core-testing/package.json index ea79d99..03fccc0 100644 --- a/packages/core-testing/package.json +++ b/packages/core-testing/package.json @@ -15,6 +15,7 @@ "./setup/node": "./src/setup/node.ts", "./setup/no-instrumentation": "./src/setup/no-instrumentation.ts", "./setup/no-sentry": "./src/setup/no-instrumentation.ts", + "./rate-limit": "./src/rate-limit/index.ts", "./stryker.base.json": "./stryker.base.json" }, "scripts": {