chore: Replaces Unkey and Update rate limiting in the management API v2. (#6273)

This commit is contained in:
Piyush Gupta
2025-07-22 15:03:29 +05:30
committed by GitHub
parent ed89f12af8
commit eee9ee8995
11 changed files with 67 additions and 311 deletions
+6 -6
View File
@@ -1,6 +1,7 @@
import { ApiAuditLog } from "@/app/lib/api/with-api-logging";
import { checkRateLimitAndThrowError } from "@/modules/api/v2/lib/rate-limit";
import { formatZodError, handleApiError } from "@/modules/api/v2/lib/utils";
import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { ZodRawShape, z } from "zod";
import { TAuthenticationApiKey } from "@formbricks/types/auth";
import { authenticateRequest } from "./authenticate-request";
@@ -104,11 +105,10 @@ export const apiWrapper = async <S extends ExtendedSchemas>({
}
if (rateLimit) {
const rateLimitResponse = await checkRateLimitAndThrowError({
identifier: authentication.data.hashedApiKey,
});
if (!rateLimitResponse.ok) {
return handleApiError(request, rateLimitResponse.error);
try {
await applyRateLimit(rateLimitConfigs.api.v2, authentication.data.hashedApiKey);
} catch (error) {
return handleApiError(request, { type: "too_many_requests", details: error.message });
}
}
@@ -1,22 +1,26 @@
import { apiWrapper } from "@/modules/api/v2/auth/api-wrapper";
import { authenticateRequest } from "@/modules/api/v2/auth/authenticate-request";
import { checkRateLimitAndThrowError } from "@/modules/api/v2/lib/rate-limit";
import { handleApiError } from "@/modules/api/v2/lib/utils";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { checkRateLimit } from "@/modules/core/rate-limit/rate-limit";
import { describe, expect, test, vi } from "vitest";
import { z } from "zod";
import { err, ok, okVoid } from "@formbricks/types/error-handlers";
import { err, ok } from "@formbricks/types/error-handlers";
vi.mock("../authenticate-request", () => ({
authenticateRequest: vi.fn(),
}));
vi.mock("@/modules/api/v2/lib/rate-limit", () => ({
checkRateLimitAndThrowError: vi.fn(),
vi.mock("@/modules/core/rate-limit/rate-limit", () => ({
checkRateLimit: vi.fn(),
}));
vi.mock("@/modules/api/v2/lib/utils", () => ({
handleApiError: vi.fn(),
vi.mock("@/modules/core/rate-limit/rate-limit-configs", () => ({
rateLimitConfigs: {
api: {
v2: { interval: 60, allowedPerInterval: 100, namespace: "api:v2" },
},
},
}));
vi.mock("@/modules/api/v2/lib/utils", () => ({
@@ -24,20 +28,31 @@ vi.mock("@/modules/api/v2/lib/utils", () => ({
handleApiError: vi.fn(),
}));
const mockAuthentication = {
type: "apiKey" as const,
environmentPermissions: [
{
environmentId: "env-id",
environmentType: "development" as const,
projectId: "project-id",
projectName: "Project Name",
permission: "manage" as const,
},
],
hashedApiKey: "hashed-api-key",
apiKeyId: "api-key-id",
organizationId: "org-id",
organizationAccess: {} as any,
} as any;
describe("apiWrapper", () => {
test("should handle request and return response", async () => {
const request = new Request("http://localhost", {
headers: { "x-api-key": "valid-api-key" },
});
vi.mocked(authenticateRequest).mockResolvedValue(
ok({
type: "apiKey",
environmentId: "env-id",
hashedApiKey: "hashed-api-key",
})
);
vi.mocked(checkRateLimitAndThrowError).mockResolvedValue(okVoid());
vi.mocked(authenticateRequest).mockResolvedValue(ok(mockAuthentication));
vi.mocked(checkRateLimit).mockResolvedValue(ok({ allowed: true }));
const handler = vi.fn().mockResolvedValue(new Response("ok", { status: 200 }));
const response = await apiWrapper({
@@ -74,13 +89,7 @@ describe("apiWrapper", () => {
headers: { "Content-Type": "application/json" },
});
vi.mocked(authenticateRequest).mockResolvedValue(
ok({
type: "apiKey",
environmentId: "env-id",
hashedApiKey: "hashed-api-key",
})
);
vi.mocked(authenticateRequest).mockResolvedValue(ok(mockAuthentication));
const bodySchema = z.object({ key: z.string() });
const handler = vi.fn().mockResolvedValue(new Response("ok", { status: 200 }));
@@ -107,14 +116,7 @@ describe("apiWrapper", () => {
headers: { "Content-Type": "application/json" },
});
vi.mocked(authenticateRequest).mockResolvedValue(
ok({
type: "apiKey",
environmentId: "env-id",
hashedApiKey: "hashed-api-key",
})
);
vi.mocked(authenticateRequest).mockResolvedValue(ok(mockAuthentication));
vi.mocked(handleApiError).mockResolvedValue(new Response("error", { status: 400 }));
const bodySchema = z.object({ key: z.string() });
@@ -134,13 +136,7 @@ describe("apiWrapper", () => {
test("should parse query schema correctly", async () => {
const request = new Request("http://localhost?key=value");
vi.mocked(authenticateRequest).mockResolvedValue(
ok({
type: "apiKey",
environmentId: "env-id",
hashedApiKey: "hashed-api-key",
})
);
vi.mocked(authenticateRequest).mockResolvedValue(ok(mockAuthentication));
const querySchema = z.object({ key: z.string() });
const handler = vi.fn().mockResolvedValue(new Response("ok", { status: 200 }));
@@ -163,14 +159,7 @@ describe("apiWrapper", () => {
test("should handle query schema errors", async () => {
const request = new Request("http://localhost?foo%ZZ=abc");
vi.mocked(authenticateRequest).mockResolvedValue(
ok({
type: "apiKey",
environmentId: "env-id",
hashedApiKey: "hashed-api-key",
})
);
vi.mocked(authenticateRequest).mockResolvedValue(ok(mockAuthentication));
vi.mocked(handleApiError).mockResolvedValue(new Response("error", { status: 400 }));
const querySchema = z.object({ key: z.string() });
@@ -190,13 +179,7 @@ describe("apiWrapper", () => {
test("should parse params schema correctly", async () => {
const request = new Request("http://localhost");
vi.mocked(authenticateRequest).mockResolvedValue(
ok({
type: "apiKey",
environmentId: "env-id",
hashedApiKey: "hashed-api-key",
})
);
vi.mocked(authenticateRequest).mockResolvedValue(ok(mockAuthentication));
const paramsSchema = z.object({ key: z.string() });
const handler = vi.fn().mockResolvedValue(new Response("ok", { status: 200 }));
@@ -220,14 +203,7 @@ describe("apiWrapper", () => {
test("should handle no external params", async () => {
const request = new Request("http://localhost");
vi.mocked(authenticateRequest).mockResolvedValue(
ok({
type: "apiKey",
environmentId: "env-id",
hashedApiKey: "hashed-api-key",
})
);
vi.mocked(authenticateRequest).mockResolvedValue(ok(mockAuthentication));
vi.mocked(handleApiError).mockResolvedValue(new Response("error", { status: 400 }));
const paramsSchema = z.object({ key: z.string() });
@@ -248,14 +224,7 @@ describe("apiWrapper", () => {
test("should handle params schema errors", async () => {
const request = new Request("http://localhost");
vi.mocked(authenticateRequest).mockResolvedValue(
ok({
type: "apiKey",
environmentId: "env-id",
hashedApiKey: "hashed-api-key",
})
);
vi.mocked(authenticateRequest).mockResolvedValue(ok(mockAuthentication));
vi.mocked(handleApiError).mockResolvedValue(new Response("error", { status: 400 }));
const paramsSchema = z.object({ key: z.string() });
@@ -273,21 +242,13 @@ describe("apiWrapper", () => {
expect(handler).not.toHaveBeenCalled();
});
test("should handle rate limit errors", async () => {
test("should handle rate limit exceeded", async () => {
const request = new Request("http://localhost", {
headers: { "x-api-key": "valid-api-key" },
});
vi.mocked(authenticateRequest).mockResolvedValue(
ok({
type: "apiKey",
environmentId: "env-id",
hashedApiKey: "hashed-api-key",
})
);
vi.mocked(checkRateLimitAndThrowError).mockResolvedValue(
err({ type: "rateLimitExceeded" } as unknown as ApiErrorResponseV2)
);
vi.mocked(authenticateRequest).mockResolvedValue(ok(mockAuthentication));
vi.mocked(checkRateLimit).mockResolvedValue(ok({ allowed: false }));
vi.mocked(handleApiError).mockImplementation(
(_request: Request, _error: ApiErrorResponseV2): Response =>
new Response("rate limit exceeded", { status: 429 })
@@ -302,4 +263,24 @@ describe("apiWrapper", () => {
expect(response.status).toBe(429);
expect(handler).not.toHaveBeenCalled();
});
test("should handle rate limit check failure gracefully", async () => {
const request = new Request("http://localhost", {
headers: { "x-api-key": "valid-api-key" },
});
vi.mocked(authenticateRequest).mockResolvedValue(ok(mockAuthentication));
// When rate limiting fails (e.g., Redis connection issues), checkRateLimit fails open by returning allowed: true
vi.mocked(checkRateLimit).mockResolvedValue(ok({ allowed: true }));
const handler = vi.fn().mockResolvedValue(new Response("ok", { status: 200 }));
const response = await apiWrapper({
request,
handler,
});
// Should fail open for availability
expect(response.status).toBe(200);
expect(handler).toHaveBeenCalled();
});
});
-71
View File
@@ -1,71 +0,0 @@
import { MANAGEMENT_API_RATE_LIMIT, RATE_LIMITING_DISABLED, UNKEY_ROOT_KEY } from "@/lib/constants";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { type LimitOptions, Ratelimit, type RatelimitResponse } from "@unkey/ratelimit";
import { logger } from "@formbricks/logger";
import { Result, err, okVoid } from "@formbricks/types/error-handlers";
export type RateLimitHelper = {
identifier: string;
opts?: LimitOptions;
/**
* Using a callback instead of a regular return to provide headers even
* when the rate limit is reached and an error is thrown.
**/
onRateLimiterResponse?: (response: RatelimitResponse) => void;
};
let warningDisplayed = false;
/** Prevent flooding the logs while testing/building */
function logOnce(message: string) {
if (warningDisplayed) return;
logger.warn(message);
warningDisplayed = true;
}
export function rateLimiter() {
if (RATE_LIMITING_DISABLED) {
logOnce("Rate limiting disabled");
return () => ({ success: true, limit: 10, remaining: 999, reset: 0 }) as RatelimitResponse;
}
if (!UNKEY_ROOT_KEY) {
logOnce("Disabled due to not finding UNKEY_ROOT_KEY env variable");
return () => ({ success: true, limit: 10, remaining: 999, reset: 0 }) as RatelimitResponse;
}
const timeout = {
fallback: { success: true, limit: 10, remaining: 999, reset: 0 },
ms: 5000,
};
const limiter = {
api: new Ratelimit({
rootKey: UNKEY_ROOT_KEY,
namespace: "api",
limit: MANAGEMENT_API_RATE_LIMIT.allowedPerInterval,
duration: MANAGEMENT_API_RATE_LIMIT.interval * 1000,
timeout,
}),
};
async function rateLimit({ identifier, opts }: RateLimitHelper) {
return await limiter.api.limit(identifier, opts);
}
return rateLimit;
}
export const checkRateLimitAndThrowError = async ({
identifier,
opts,
}: RateLimitHelper): Promise<Result<void, ApiErrorResponseV2>> => {
const response = await rateLimiter()({ identifier, opts });
const { success } = response;
if (!success) {
return err({
type: "too_many_requests",
});
}
return okVoid();
};
@@ -1,114 +0,0 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { logger } from "@formbricks/logger";
vi.mock("@formbricks/logger", () => ({
logger: {
warn: vi.fn(),
},
}));
vi.mock("@unkey/ratelimit", () => ({
Ratelimit: vi.fn(),
}));
describe("when rate limiting is disabled", () => {
beforeEach(async () => {
vi.resetModules();
const constants = await vi.importActual("@/lib/constants");
vi.doMock("@/lib/constants", () => ({
...constants,
MANAGEMENT_API_RATE_LIMIT: { allowedPerInterval: 5, interval: 60 },
RATE_LIMITING_DISABLED: true,
}));
});
test("should log a warning once and return a stubbed response", async () => {
const loggerSpy = vi.spyOn(logger, "warn");
const { rateLimiter } = await import("@/modules/api/v2/lib/rate-limit");
const res1 = await rateLimiter()({ identifier: "test-id" });
expect(res1).toEqual({ success: true, limit: 10, remaining: 999, reset: 0 });
expect(loggerSpy).toHaveBeenCalled();
// Subsequent calls won't log again.
await rateLimiter()({ identifier: "another-id" });
expect(loggerSpy).toHaveBeenCalledTimes(1);
loggerSpy.mockRestore();
});
});
describe("when UNKEY_ROOT_KEY is missing", () => {
beforeEach(async () => {
vi.resetModules();
const constants = await vi.importActual("@/lib/constants");
vi.doMock("@/lib/constants", () => ({
...constants,
MANAGEMENT_API_RATE_LIMIT: { allowedPerInterval: 5, interval: 60 },
RATE_LIMITING_DISABLED: false,
UNKEY_ROOT_KEY: "",
}));
});
test("should log a warning about missing UNKEY_ROOT_KEY and return stub response", async () => {
const loggerSpy = vi.spyOn(logger, "warn");
const { rateLimiter } = await import("@/modules/api/v2/lib/rate-limit");
const limiterFunc = rateLimiter();
const res = await limiterFunc({ identifier: "test-id" });
expect(res).toEqual({ success: true, limit: 10, remaining: 999, reset: 0 });
expect(loggerSpy).toHaveBeenCalled();
loggerSpy.mockRestore();
});
});
describe("when rate limiting is active (enabled)", () => {
const mockResponse = { success: true, limit: 5, remaining: 2, reset: 1000 };
let limitMock: ReturnType<typeof vi.fn>;
beforeEach(async () => {
vi.resetModules();
const constants = await vi.importActual("@/lib/constants");
vi.doMock("@/lib/constants", () => ({
...constants,
MANAGEMENT_API_RATE_LIMIT: { allowedPerInterval: 5, interval: 60 },
RATE_LIMITING_DISABLED: false,
UNKEY_ROOT_KEY: "valid-key",
}));
limitMock = vi.fn().mockResolvedValue(mockResponse);
const RatelimitMock = vi.fn().mockImplementation(() => {
return { limit: limitMock };
});
vi.doMock("@unkey/ratelimit", () => ({
Ratelimit: RatelimitMock,
}));
});
test("should create a rate limiter that calls the limit method with the proper arguments", async () => {
const { rateLimiter } = await import("../rate-limit");
const limiterFunc = rateLimiter();
const res = await limiterFunc({ identifier: "abc", opts: { cost: 1 } });
expect(limitMock).toHaveBeenCalledWith("abc", { cost: 1 });
expect(res).toEqual(mockResponse);
});
test("checkRateLimitAndThrowError returns okVoid when rate limit is not exceeded", async () => {
limitMock.mockResolvedValueOnce({ success: true, limit: 5, remaining: 3, reset: 1000 });
const { checkRateLimitAndThrowError } = await import("../rate-limit");
const result = await checkRateLimitAndThrowError({ identifier: "abc" });
expect(result.ok).toBe(true);
});
test("checkRateLimitAndThrowError returns an error when the rate limit is exceeded", async () => {
limitMock.mockResolvedValueOnce({ success: false, limit: 5, remaining: 0, reset: 1000 });
const { checkRateLimitAndThrowError } = await import("../rate-limit");
const result = await checkRateLimitAndThrowError({ identifier: "abc" });
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toEqual({ type: "too_many_requests" });
}
});
});