chore: merge rate limiter epic branch into main (#6236)

Co-authored-by: Harsh Bhat <90265455+harshsbhat@users.noreply.github.com>
Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: Piyush Gupta <56182734+gupta-piyush19@users.noreply.github.com>
Co-authored-by: Aditya <162564995+Naidu-4444@users.noreply.github.com>
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
Co-authored-by: Dhruwang Jariwala <67850763+Dhruwang@users.noreply.github.com>
Co-authored-by: Johannes <72809645+jobenjada@users.noreply.github.com>
Co-authored-by: Jakob Schott <154420406+jakobsitory@users.noreply.github.com>
Co-authored-by: Suraj <surajsuthar0067@gmail.com>
Co-authored-by: Kshitij Sharma <63995641+kshitij-codes@users.noreply.github.com>
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
Co-authored-by: Matti Nannt <mail@matthiasnannt.com>
This commit is contained in:
Victor Hugo dos Santos
2025-07-16 19:28:59 +07:00
committed by GitHub
parent bea02ba3b5
commit ef973c8995
72 changed files with 4207 additions and 988 deletions
+381
View File
@@ -0,0 +1,381 @@
import { describe, expect, test } from "vitest";
import { createCacheKey, parseCacheKey, validateCacheKey } from "./cacheKeys";
describe("cacheKeys", () => {
describe("createCacheKey", () => {
describe("environment keys", () => {
test("should create environment state key", () => {
const key = createCacheKey.environment.state("env123");
expect(key).toBe("fb:env:env123:state");
});
test("should create environment surveys key", () => {
const key = createCacheKey.environment.surveys("env456");
expect(key).toBe("fb:env:env456:surveys");
});
test("should create environment actionClasses key", () => {
const key = createCacheKey.environment.actionClasses("env789");
expect(key).toBe("fb:env:env789:action_classes");
});
test("should create environment config key", () => {
const key = createCacheKey.environment.config("env101");
expect(key).toBe("fb:env:env101:config");
});
test("should create environment segments key", () => {
const key = createCacheKey.environment.segments("env202");
expect(key).toBe("fb:env:env202:segments");
});
});
describe("organization keys", () => {
test("should create organization billing key", () => {
const key = createCacheKey.organization.billing("org123");
expect(key).toBe("fb:org:org123:billing");
});
test("should create organization environments key", () => {
const key = createCacheKey.organization.environments("org456");
expect(key).toBe("fb:org:org456:environments");
});
test("should create organization config key", () => {
const key = createCacheKey.organization.config("org789");
expect(key).toBe("fb:org:org789:config");
});
test("should create organization limits key", () => {
const key = createCacheKey.organization.limits("org101");
expect(key).toBe("fb:org:org101:limits");
});
});
describe("license keys", () => {
test("should create license status key", () => {
const key = createCacheKey.license.status("org123");
expect(key).toBe("fb:license:org123:status");
});
test("should create license features key", () => {
const key = createCacheKey.license.features("org456");
expect(key).toBe("fb:license:org456:features");
});
test("should create license usage key", () => {
const key = createCacheKey.license.usage("org789");
expect(key).toBe("fb:license:org789:usage");
});
test("should create license check key", () => {
const key = createCacheKey.license.check("org123", "feature-x");
expect(key).toBe("fb:license:org123:check:feature-x");
});
test("should create license previous_result key", () => {
const key = createCacheKey.license.previous_result("org456");
expect(key).toBe("fb:license:org456:previous_result");
});
});
describe("user keys", () => {
test("should create user profile key", () => {
const key = createCacheKey.user.profile("user123");
expect(key).toBe("fb:user:user123:profile");
});
test("should create user preferences key", () => {
const key = createCacheKey.user.preferences("user456");
expect(key).toBe("fb:user:user456:preferences");
});
test("should create user organizations key", () => {
const key = createCacheKey.user.organizations("user789");
expect(key).toBe("fb:user:user789:organizations");
});
test("should create user permissions key", () => {
const key = createCacheKey.user.permissions("user123", "org456");
expect(key).toBe("fb:user:user123:org:org456:permissions");
});
});
describe("project keys", () => {
test("should create project config key", () => {
const key = createCacheKey.project.config("proj123");
expect(key).toBe("fb:project:proj123:config");
});
test("should create project environments key", () => {
const key = createCacheKey.project.environments("proj456");
expect(key).toBe("fb:project:proj456:environments");
});
test("should create project surveys key", () => {
const key = createCacheKey.project.surveys("proj789");
expect(key).toBe("fb:project:proj789:surveys");
});
});
describe("survey keys", () => {
test("should create survey metadata key", () => {
const key = createCacheKey.survey.metadata("survey123");
expect(key).toBe("fb:survey:survey123:metadata");
});
test("should create survey responses key", () => {
const key = createCacheKey.survey.responses("survey456");
expect(key).toBe("fb:survey:survey456:responses");
});
test("should create survey stats key", () => {
const key = createCacheKey.survey.stats("survey789");
expect(key).toBe("fb:survey:survey789:stats");
});
});
describe("session keys", () => {
test("should create session data key", () => {
const key = createCacheKey.session.data("session123");
expect(key).toBe("fb:session:session123:data");
});
test("should create session permissions key", () => {
const key = createCacheKey.session.permissions("session456");
expect(key).toBe("fb:session:session456:permissions");
});
});
describe("rate limit keys", () => {
test("should create rate limit api key", () => {
const key = createCacheKey.rateLimit.api("api-key-123", "endpoint-v1");
expect(key).toBe("fb:rate_limit:api:api-key-123:endpoint-v1");
});
test("should create rate limit login key", () => {
const key = createCacheKey.rateLimit.login("user-ip-hash");
expect(key).toBe("fb:rate_limit:login:user-ip-hash");
});
test("should create rate limit core key", () => {
const key = createCacheKey.rateLimit.core("auth:login", "user123", 1703174400);
expect(key).toBe("fb:rate_limit:auth:login:user123:1703174400");
});
});
describe("custom keys", () => {
test("should create custom key without subResource", () => {
const key = createCacheKey.custom("temp", "identifier123");
expect(key).toBe("fb:temp:identifier123");
});
test("should create custom key with subResource", () => {
const key = createCacheKey.custom("analytics", "user456", "daily-stats");
expect(key).toBe("fb:analytics:user456:daily-stats");
});
test("should work with all valid namespaces", () => {
const validNamespaces = ["temp", "analytics", "webhook", "integration", "backup"];
validNamespaces.forEach((namespace) => {
const key = createCacheKey.custom(namespace, "test-id");
expect(key).toBe(`fb:${namespace}:test-id`);
});
});
test("should throw error for invalid namespace", () => {
expect(() => createCacheKey.custom("invalid", "identifier")).toThrow(
"Invalid cache namespace: invalid. Use: temp, analytics, webhook, integration, backup"
);
});
test("should throw error for empty namespace", () => {
expect(() => createCacheKey.custom("", "identifier")).toThrow(
"Invalid cache namespace: . Use: temp, analytics, webhook, integration, backup"
);
});
});
});
describe("validateCacheKey", () => {
test("should validate correct cache keys", () => {
const validKeys = [
"fb:env:env123:state",
"fb:user:user456:profile",
"fb:org:org789:billing",
"fb:rate_limit:api:key123:endpoint",
"fb:custom:namespace:identifier:sub:resource",
];
validKeys.forEach((key) => {
expect(validateCacheKey(key)).toBe(true);
});
});
test("should reject keys without fb prefix", () => {
const invalidKeys = ["env:env123:state", "user:user456:profile", "redis:key:value", "cache:item:data"];
invalidKeys.forEach((key) => {
expect(validateCacheKey(key)).toBe(false);
});
});
test("should reject keys with insufficient parts", () => {
const invalidKeys = ["fb:", "fb:env", "fb:env:", "fb:user:user123:"];
invalidKeys.forEach((key) => {
expect(validateCacheKey(key)).toBe(false);
});
});
test("should reject keys with empty parts", () => {
const invalidKeys = ["fb::env123:state", "fb:env::state", "fb:env:env123:", "fb:user::profile"];
invalidKeys.forEach((key) => {
expect(validateCacheKey(key)).toBe(false);
});
});
test("should validate minimum valid key", () => {
expect(validateCacheKey("fb:a:b")).toBe(true);
});
});
describe("parseCacheKey", () => {
test("should parse basic cache key", () => {
const result = parseCacheKey("fb:env:env123:state");
expect(result).toEqual({
prefix: "fb",
resource: "env",
identifier: "env123",
subResource: "state",
full: "fb:env:env123:state",
});
});
test("should parse key without subResource", () => {
const result = parseCacheKey("fb:user:user123");
expect(result).toEqual({
prefix: "fb",
resource: "user",
identifier: "user123",
subResource: undefined,
full: "fb:user:user123",
});
});
test("should parse key with multiple subResource parts", () => {
const result = parseCacheKey("fb:user:user123:org:org456:permissions");
expect(result).toEqual({
prefix: "fb",
resource: "user",
identifier: "user123",
subResource: "org:org456:permissions",
full: "fb:user:user123:org:org456:permissions",
});
});
test("should parse rate limit key with timestamp", () => {
const result = parseCacheKey("fb:rate_limit:auth:login:user123:1703174400");
expect(result).toEqual({
prefix: "fb",
resource: "rate_limit",
identifier: "auth",
subResource: "login:user123:1703174400",
full: "fb:rate_limit:auth:login:user123:1703174400",
});
});
test("should throw error for invalid cache key", () => {
const invalidKeys = ["invalid:key:format", "fb:env", "fb::env123:state", "redis:user:profile"];
invalidKeys.forEach((key) => {
expect(() => parseCacheKey(key)).toThrow(`Invalid cache key format: ${key}`);
});
});
});
describe("cache key patterns and consistency", () => {
test("all environment keys should follow same pattern", () => {
const envId = "test-env-123";
const envKeys = [
createCacheKey.environment.state(envId),
createCacheKey.environment.surveys(envId),
createCacheKey.environment.actionClasses(envId),
createCacheKey.environment.config(envId),
createCacheKey.environment.segments(envId),
];
envKeys.forEach((key) => {
expect(key).toMatch(/^fb:env:test-env-123:.+$/);
expect(validateCacheKey(key)).toBe(true);
});
});
test("all organization keys should follow same pattern", () => {
const orgId = "test-org-456";
const orgKeys = [
createCacheKey.organization.billing(orgId),
createCacheKey.organization.environments(orgId),
createCacheKey.organization.config(orgId),
createCacheKey.organization.limits(orgId),
];
orgKeys.forEach((key) => {
expect(key).toMatch(/^fb:org:test-org-456:.+$/);
expect(validateCacheKey(key)).toBe(true);
});
});
test("all generated keys should be parseable", () => {
const testKeys = [
createCacheKey.environment.state("env123"),
createCacheKey.user.profile("user456"),
createCacheKey.organization.billing("org789"),
createCacheKey.survey.metadata("survey101"),
createCacheKey.session.data("session202"),
createCacheKey.rateLimit.core("auth:login", "user303", 1703174400),
createCacheKey.custom("temp", "temp404", "cleanup"),
];
testKeys.forEach((key) => {
expect(() => parseCacheKey(key)).not.toThrow();
const parsed = parseCacheKey(key);
expect(parsed.prefix).toBe("fb");
expect(parsed.full).toBe(key);
expect(parsed.resource).toBeTruthy();
expect(parsed.identifier).toBeTruthy();
});
});
test("keys should be unique across different resources", () => {
const keys = [
createCacheKey.environment.state("same-id"),
createCacheKey.user.profile("same-id"),
createCacheKey.organization.billing("same-id"),
createCacheKey.project.config("same-id"),
createCacheKey.survey.metadata("same-id"),
];
const uniqueKeys = new Set(keys);
expect(uniqueKeys.size).toBe(keys.length);
});
test("namespace validation should prevent collisions", () => {
// These should not throw (valid namespaces)
expect(() => createCacheKey.custom("temp", "id")).not.toThrow();
expect(() => createCacheKey.custom("analytics", "id")).not.toThrow();
// These should throw (reserved/invalid namespaces)
expect(() => createCacheKey.custom("env", "id")).toThrow();
expect(() => createCacheKey.custom("user", "id")).toThrow();
expect(() => createCacheKey.custom("org", "id")).toThrow();
});
});
});
+3
View File
@@ -11,6 +11,7 @@ import "server-only";
* - Predictable invalidation patterns
* - Multi-tenant safe
*/
export const createCacheKey = {
// Environment-related keys
environment: {
@@ -71,6 +72,8 @@ export const createCacheKey = {
rateLimit: {
api: (identifier: string, endpoint: string) => `fb:rate_limit:api:${identifier}:${endpoint}`,
login: (identifier: string) => `fb:rate_limit:login:${identifier}`,
core: (namespace: string, identifier: string, windowStart: number) =>
`fb:rate_limit:${namespace}:${identifier}:${windowStart}`,
},
// Custom keys with validation
+261
View File
@@ -0,0 +1,261 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
// Mock the logger
vi.mock("@formbricks/logger", () => ({
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}));
// Mock the redis client
const mockRedisClient = {
connect: vi.fn(),
disconnect: vi.fn(),
on: vi.fn(),
isReady: true,
get: vi.fn(),
set: vi.fn(),
del: vi.fn(),
exists: vi.fn(),
expire: vi.fn(),
ttl: vi.fn(),
keys: vi.fn(),
flushall: vi.fn(),
};
vi.mock("redis", () => ({
createClient: vi.fn(() => mockRedisClient),
}));
// Mock crypto for UUID generation
vi.mock("crypto", () => ({
randomUUID: vi.fn(() => "test-uuid-123"),
}));
describe("Redis module", () => {
beforeEach(() => {
vi.clearAllMocks();
// Reset environment variable
process.env.REDIS_URL = "redis://localhost:6379";
// Reset isReady state
mockRedisClient.isReady = true;
// Make connect resolve successfully
mockRedisClient.connect.mockResolvedValue(undefined);
});
afterEach(() => {
vi.resetModules();
process.env.REDIS_URL = undefined;
});
describe("Module initialization", () => {
test("should create Redis client when REDIS_URL is set", async () => {
const { createClient } = await import("redis");
// Re-import the module to trigger initialization
await import("./redis");
expect(createClient).toHaveBeenCalledWith({
url: "redis://localhost:6379",
socket: {
reconnectStrategy: expect.any(Function),
},
});
});
test("should not create Redis client when REDIS_URL is not set", async () => {
delete process.env.REDIS_URL;
const { createClient } = await import("redis");
// Clear the module cache and re-import
vi.resetModules();
await import("./redis");
expect(createClient).not.toHaveBeenCalled();
});
test("should set up event listeners", async () => {
// Re-import the module to trigger initialization
await import("./redis");
expect(mockRedisClient.on).toHaveBeenCalledWith("error", expect.any(Function));
expect(mockRedisClient.on).toHaveBeenCalledWith("connect", expect.any(Function));
expect(mockRedisClient.on).toHaveBeenCalledWith("reconnecting", expect.any(Function));
expect(mockRedisClient.on).toHaveBeenCalledWith("ready", expect.any(Function));
});
test("should attempt initial connection", async () => {
// Re-import the module to trigger initialization
await import("./redis");
expect(mockRedisClient.connect).toHaveBeenCalled();
});
});
describe("getRedisClient", () => {
test("should return client when ready", async () => {
mockRedisClient.isReady = true;
const { getRedisClient } = await import("./redis");
const client = getRedisClient();
expect(client).toBe(mockRedisClient);
});
test("should return null when client is not ready", async () => {
mockRedisClient.isReady = false;
const { getRedisClient } = await import("./redis");
const client = getRedisClient();
expect(client).toBeNull();
});
test("should return null when no REDIS_URL is set", async () => {
delete process.env.REDIS_URL;
vi.resetModules();
const { getRedisClient } = await import("./redis");
const client = getRedisClient();
expect(client).toBeNull();
});
});
describe("disconnectRedis", () => {
test("should disconnect the client", async () => {
const { disconnectRedis } = await import("./redis");
await disconnectRedis();
expect(mockRedisClient.disconnect).toHaveBeenCalled();
});
test("should handle case when client is null", async () => {
delete process.env.REDIS_URL;
vi.resetModules();
const { disconnectRedis } = await import("./redis");
await expect(disconnectRedis()).resolves.toBeUndefined();
});
});
describe("Reconnection strategy", () => {
test("should configure reconnection strategy properly", async () => {
const { createClient } = await import("redis");
// Re-import the module to trigger initialization
await import("./redis");
const createClientCall = vi.mocked(createClient).mock.calls[0];
const config = createClientCall[0] as any;
expect(config.socket.reconnectStrategy).toBeDefined();
expect(typeof config.socket.reconnectStrategy).toBe("function");
});
});
describe("Event handlers", () => {
test("should log error events", async () => {
const { logger } = await import("@formbricks/logger");
// Re-import the module to trigger initialization
await import("./redis");
// Find the error event handler
const errorCall = vi.mocked(mockRedisClient.on).mock.calls.find((call) => call[0] === "error");
const errorHandler = errorCall?.[1];
const testError = new Error("Test error");
errorHandler?.(testError);
expect(logger.error).toHaveBeenCalledWith("Redis client error:", testError);
});
test("should log connect events", async () => {
const { logger } = await import("@formbricks/logger");
// Re-import the module to trigger initialization
await import("./redis");
// Find the connect event handler
const connectCall = vi.mocked(mockRedisClient.on).mock.calls.find((call) => call[0] === "connect");
const connectHandler = connectCall?.[1];
connectHandler?.();
expect(logger.info).toHaveBeenCalledWith("Redis client connected");
});
test("should log reconnecting events", async () => {
const { logger } = await import("@formbricks/logger");
// Re-import the module to trigger initialization
await import("./redis");
// Find the reconnecting event handler
const reconnectingCall = vi
.mocked(mockRedisClient.on)
.mock.calls.find((call) => call[0] === "reconnecting");
const reconnectingHandler = reconnectingCall?.[1];
reconnectingHandler?.();
expect(logger.info).toHaveBeenCalledWith("Redis client reconnecting");
});
test("should log ready events", async () => {
const { logger } = await import("@formbricks/logger");
// Re-import the module to trigger initialization
await import("./redis");
// Find the ready event handler
const readyCall = vi.mocked(mockRedisClient.on).mock.calls.find((call) => call[0] === "ready");
const readyHandler = readyCall?.[1];
readyHandler?.();
expect(logger.info).toHaveBeenCalledWith("Redis client ready");
});
test("should log end events", async () => {
const { logger } = await import("@formbricks/logger");
// Re-import the module to trigger initialization
await import("./redis");
// Find the end event handler
const endCall = vi.mocked(mockRedisClient.on).mock.calls.find((call) => call[0] === "end");
const endHandler = endCall?.[1];
endHandler?.();
expect(logger.info).toHaveBeenCalledWith("Redis client disconnected");
});
});
describe("Connection failure handling", () => {
test("should handle initial connection failure", async () => {
const { logger } = await import("@formbricks/logger");
const connectionError = new Error("Connection failed");
mockRedisClient.connect.mockRejectedValue(connectionError);
vi.resetModules();
await import("./redis");
// Wait for the connection promise to resolve
await new Promise((resolve) => setTimeout(resolve, 0));
expect(logger.error).toHaveBeenCalledWith("Initial Redis connection failed:", connectionError);
});
});
});
+64 -6
View File
@@ -1,11 +1,69 @@
import { REDIS_URL } from "@/lib/constants";
import Redis from "ioredis";
import { createClient } from "redis";
import { logger } from "@formbricks/logger";
const redis = REDIS_URL ? new Redis(REDIS_URL) : null;
type RedisClient = ReturnType<typeof createClient>;
if (!redis) {
logger.info("REDIS_URL is not set");
const REDIS_URL = process.env.REDIS_URL;
let client: RedisClient | null = null;
if (REDIS_URL) {
client = createClient({
url: REDIS_URL,
socket: {
reconnectStrategy: (retries) => {
logger.info(`Redis reconnection attempt ${retries}`);
// For the first 5 attempts, use exponential backoff with max 5 second delay
if (retries <= 5) {
return Math.min(retries * 1000, 5000);
}
// After 5 attempts, use a longer delay but never give up
// This ensures the client keeps trying to reconnect when Redis comes back online
logger.info("Redis reconnection using extended delay (30 seconds)");
return 30000; // 30 second delay for persistent reconnection attempts
},
},
});
client.on("error", (err) => {
logger.error("Redis client error:", err);
});
client.on("connect", () => {
logger.info("Redis client connected");
});
client.on("reconnecting", () => {
logger.info("Redis client reconnecting");
});
client.on("ready", () => {
logger.info("Redis client ready");
});
client.on("end", () => {
logger.info("Redis client disconnected");
});
// Connect immediately
client.connect().catch((err) => {
logger.error("Initial Redis connection failed:", err);
});
}
export default redis;
export const getRedisClient = (): RedisClient | null => {
if (!client?.isReady) {
logger.warn("Redis client not ready, operations will be skipped");
return null;
}
return client;
};
export const disconnectRedis = async (): Promise<void> => {
if (client) {
await client.disconnect();
client = null;
}
};