diff --git a/apps/web/app/api/v3/feedbackRecords/token/route.test.ts b/apps/web/app/api/v3/feedbackRecords/token/route.test.ts deleted file mode 100644 index f8308cec7c..0000000000 --- a/apps/web/app/api/v3/feedbackRecords/token/route.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { beforeEach, describe, expect, test, vi } from "vitest"; - -const { mockCreateGatewayServiceTokenResponse, mockWithV3ApiWrapper, mockWrapperAuthentication } = - vi.hoisted(() => ({ - mockCreateGatewayServiceTokenResponse: vi.fn(), - mockWithV3ApiWrapper: vi.fn(), - mockWrapperAuthentication: { - current: { - user: { id: "user_1" }, - } as { user: { id: string } } | null, - }, - })); - -const installWrapperMock = () => { - mockWithV3ApiWrapper.mockImplementation( - ({ - handler, - }: { - handler: (params: { authentication: { user: { id: string } } | null }) => Promise; - }) => - async () => - await handler({ - authentication: mockWrapperAuthentication.current, - } as never) - ); -}; - -vi.mock("@/modules/gateway-auth/lib/token", () => ({ - createGatewayServiceTokenResponse: mockCreateGatewayServiceTokenResponse, -})); - -vi.mock("@/app/api/v3/lib/api-wrapper", () => ({ - withV3ApiWrapper: mockWithV3ApiWrapper, -})); - -describe("POST /api/v3/feedbackRecords/token", () => { - beforeEach(() => { - vi.resetModules(); - vi.clearAllMocks(); - installWrapperMock(); - mockWrapperAuthentication.current = { - user: { id: "user_1" }, - }; - mockCreateGatewayServiceTokenResponse.mockReturnValue( - Response.json({ - token: "gateway-token", - expiresAt: "2026-04-24T00:10:00.000Z", - }) - ); - }); - - test("delegates to the generic gateway token helper for feedbackRecords", async () => { - const { POST } = await import("./route"); - const response = await POST({} as never, {} as never); - - expect(response.status).toBe(200); - await expect(response.json()).resolves.toEqual({ - token: "gateway-token", - expiresAt: "2026-04-24T00:10:00.000Z", - }); - expect(mockCreateGatewayServiceTokenResponse).toHaveBeenCalledWith( - { user: { id: "user_1" } }, - "feedbackRecords" - ); - }); -}); diff --git a/apps/web/lib/jwt.ts b/apps/web/lib/jwt.ts index 7728c398b4..2e4f6eacf6 100644 --- a/apps/web/lib/jwt.ts +++ b/apps/web/lib/jwt.ts @@ -5,8 +5,6 @@ import { ENCRYPTION_KEY, NEXTAUTH_SECRET } from "@/lib/constants"; import { symmetricDecrypt, symmetricEncrypt } from "@/lib/crypto"; import { getGatewayAuthServiceTokenPurpose, TGatewayAuthService } from "@/modules/gateway-auth/lib/service"; -export const FEEDBACK_RECORDS_GATEWAY_TOKEN_PURPOSE = - getGatewayAuthServiceTokenPurpose("feedbackRecords"); const FEEDBACK_RECORDS_GATEWAY_TOKEN_TTL_SECONDS = 60 * 10; // Helper function to decrypt with fallback to plain text diff --git a/apps/web/app/api/(internal)/envoy-auth/[...path]/route.test.ts b/apps/web/modules/envoy-auth/service.test.ts similarity index 92% rename from apps/web/app/api/(internal)/envoy-auth/[...path]/route.test.ts rename to apps/web/modules/envoy-auth/service.test.ts index ce362047fe..ae2d2536ec 100644 --- a/apps/web/app/api/(internal)/envoy-auth/[...path]/route.test.ts +++ b/apps/web/modules/envoy-auth/service.test.ts @@ -1,6 +1,6 @@ import { NextRequest } from "next/server"; import { beforeEach, describe, expect, test, vi } from "vitest"; -import { DELETE, GET, HEAD, OPTIONS, PATCH, POST } from "./route"; +import { authorizeEnvoyRequest } from "./service"; const { mockAuthenticateApiKeyFromHeaders, @@ -91,7 +91,7 @@ const createRequest = ( body, }); -describe("Envoy auth route", () => { +describe("authorizeEnvoyRequest", () => { beforeEach(() => { vi.resetAllMocks(); mockGetApiKeyFromHeaders.mockReturnValue(null); @@ -131,7 +131,7 @@ describe("Envoy auth route", () => { ], }); - const response = await POST( + const response = await authorizeEnvoyRequest( createRequest("http://localhost/api/envoy-auth/api/v3/feedbackRecords", { method: "POST", headers: { @@ -160,7 +160,7 @@ describe("Envoy auth route", () => { feedbackRecordDirectoryPermissions: [], }); - const response = await GET( + const response = await authorizeEnvoyRequest( createRequest("http://localhost/api/envoy-auth/v1/feedback-records", { method: "DELETE", headers: { @@ -173,7 +173,7 @@ describe("Envoy auth route", () => { }); test("returns 400 for unsupported envoy auth routes", async () => { - const response = await GET(createRequest("http://localhost/api/envoy-auth/api/v1/test")); + const response = await authorizeEnvoyRequest(createRequest("http://localhost/api/envoy-auth/api/v1/test")); expect(response.status).toBe(400); }); @@ -197,7 +197,7 @@ describe("Envoy auth route", () => { }, }); - const response = await GET( + const response = await authorizeEnvoyRequest( createRequest(`http://localhost/api/envoy-auth/v1/feedback-records/${feedbackRecordId}`, { headers: { "x-api-key": "fbk_test", @@ -227,7 +227,7 @@ describe("Envoy auth route", () => { }, }); - const response = await GET( + const response = await authorizeEnvoyRequest( createRequest(`http://localhost/api/envoy-auth/v1/feedback-records/${feedbackRecordId}`, { headers: { "x-api-key": "fbk_test", @@ -244,7 +244,7 @@ describe("Envoy auth route", () => { userId: "user_1", }); - const response = await GET( + const response = await authorizeEnvoyRequest( createRequest( `http://localhost/api/envoy-auth/v1/feedback-records?tenant_id=${feedbackRecordDirectoryId}`, { @@ -264,7 +264,7 @@ describe("Envoy auth route", () => { mockGetBearerTokenFromHeaders.mockReturnValue("header.payload.signature"); mockVerifyFeedbackRecordsGatewayToken.mockReturnValue({ userId: "user_1" }); - const response = await PATCH( + const response = await authorizeEnvoyRequest( createRequest(`http://localhost/api/envoy-auth/v1/feedback-records/${feedbackRecordId}`, { method: "PATCH", headers: { @@ -296,7 +296,7 @@ describe("Envoy auth route", () => { userId: "user_2", }); - const response = await GET( + const response = await authorizeEnvoyRequest( createRequest( `http://localhost/api/envoy-auth/v1/feedback-records?tenant_id=${feedbackRecordDirectoryId}`, { @@ -336,7 +336,7 @@ describe("Envoy auth route", () => { feedbackRecordDirectoryPermissions: [], }); - const response = await DELETE( + const response = await authorizeEnvoyRequest( createRequest( `http://localhost/api/envoy-auth/v1/feedback-records?tenant_id=${feedbackRecordDirectoryId}`, { @@ -360,7 +360,7 @@ describe("Envoy auth route", () => { isArchived: true, }); - const response = await GET( + const response = await authorizeEnvoyRequest( createRequest( `http://localhost/api/envoy-auth/v1/feedback-records?tenant_id=${feedbackRecordDirectoryId}`, { @@ -385,7 +385,7 @@ describe("Envoy auth route", () => { feedbackRecordDirectoryPermissions: [], }); - const response = await GET( + const response = await authorizeEnvoyRequest( createRequest( `http://localhost/api/envoy-auth/api/v3/feedbackRecordsFoo?tenant_id=${feedbackRecordDirectoryId}`, { @@ -399,7 +399,7 @@ describe("Envoy auth route", () => { expect(response.status).toBe(400); }); - test("handles HEAD requests through the generic route instead of 405ing at Next.js", async () => { + test("handles HEAD requests through the generic service instead of 405ing at Next.js", async () => { mockGetApiKeyFromHeaders.mockReturnValue("fbk_test"); mockAuthenticateApiKeyFromHeaders.mockResolvedValue({ type: "apiKey", @@ -410,7 +410,7 @@ describe("Envoy auth route", () => { feedbackRecordDirectoryPermissions: [], }); - const response = await HEAD( + const response = await authorizeEnvoyRequest( createRequest(`http://localhost/api/envoy-auth/v1/feedback-records/${feedbackRecordId}`, { method: "HEAD", headers: { @@ -422,7 +422,7 @@ describe("Envoy auth route", () => { expect(response.status).toBe(400); }); - test("handles OPTIONS requests through the generic route instead of 405ing at Next.js", async () => { + test("handles OPTIONS requests through the generic service instead of 405ing at Next.js", async () => { mockGetApiKeyFromHeaders.mockReturnValue("fbk_test"); mockAuthenticateApiKeyFromHeaders.mockResolvedValue({ type: "apiKey", @@ -433,7 +433,7 @@ describe("Envoy auth route", () => { feedbackRecordDirectoryPermissions: [], }); - const response = await OPTIONS( + const response = await authorizeEnvoyRequest( createRequest("http://localhost/api/envoy-auth/v1/feedback-records", { method: "OPTIONS", headers: { diff --git a/apps/web/modules/hub/service.test.ts b/apps/web/modules/hub/service.test.ts index cd2097f148..7f5326ecf4 100644 --- a/apps/web/modules/hub/service.test.ts +++ b/apps/web/modules/hub/service.test.ts @@ -232,7 +232,7 @@ describe("hub service", () => { expect(vi.mocked(cache.withCache)).toHaveBeenCalledOnce(); expect(vi.mocked(cache.withCache)).toHaveBeenCalledWith( expect.any(Function), - createCacheKey.custom("hub", "0194d8a0-3d55-7ff4-9f62-8d02c3fbcfe8", "feedback_record_tenant"), + createCacheKey.hub.feedbackRecordTenant("0194d8a0-3d55-7ff4-9f62-8d02c3fbcfe8"), 60_000 ); expect(retrieve).toHaveBeenCalledWith("0194d8a0-3d55-7ff4-9f62-8d02c3fbcfe8"); diff --git a/apps/web/modules/hub/service.ts b/apps/web/modules/hub/service.ts index 95bcd58a88..109464fc1e 100644 --- a/apps/web/modules/hub/service.ts +++ b/apps/web/modules/hub/service.ts @@ -91,7 +91,7 @@ export const getFeedbackRecordTenant = async (recordId: string): Promise { }); }); + describe("hub namespace", () => { + test("should create feedback record tenant key", () => { + const key = createCacheKey.hub.feedbackRecordTenant("0194d8a0-3d55-7ff4-9f62-8d02c3fbcfe8"); + expect(key).toBe("fb:hub:0194d8a0-3d55-7ff4-9f62-8d02c3fbcfe8:feedback_record_tenant"); + }); + + test("should throw error for empty feedback record id", () => { + expect(() => createCacheKey.hub.feedbackRecordTenant("")).toThrow( + "Invalid Cache key: Parts cannot be empty" + ); + }); + }); + describe("custom namespace", () => { test("should create custom key with subResource", () => { const key = createCacheKey.custom("analytics", "user-456", "daily-stats"); @@ -162,6 +175,7 @@ describe("@formbricks/cache cacheKeys", () => { createCacheKey.organization.billing("org-1"), createCacheKey.license.status("org-1"), createCacheKey.license.previous_result("org-1"), + createCacheKey.hub.feedbackRecordTenant("record-1"), createCacheKey.rateLimit.core("api", "user-1", 123456), createCacheKey.custom("analytics", "temp-1"), createCacheKey.custom("analytics", "temp-1", "sub"), @@ -181,6 +195,7 @@ describe("@formbricks/cache cacheKeys", () => { createCacheKey.workspace.state("env-123"), createCacheKey.organization.billing("org-456"), createCacheKey.license.status("license-789"), + createCacheKey.hub.feedbackRecordTenant("record-321"), createCacheKey.rateLimit.core("api", "user-101", 1640995200), createCacheKey.custom("analytics", "analytics-102", "daily"), ]; @@ -198,6 +213,7 @@ describe("@formbricks/cache cacheKeys", () => { expect(() => createCacheKey.workspace.state("")).toThrow(errorMessage); expect(() => createCacheKey.organization.billing("")).toThrow(errorMessage); expect(() => createCacheKey.license.status("")).toThrow(errorMessage); + expect(() => createCacheKey.hub.feedbackRecordTenant("")).toThrow(errorMessage); expect(() => createCacheKey.rateLimit.core("", "user", 123)).toThrow(errorMessage); expect(() => createCacheKey.custom("analytics", "")).toThrow(errorMessage); }); diff --git a/packages/cache/src/cache-keys.ts b/packages/cache/src/cache-keys.ts index 2326e0683d..97f2f3ee8b 100644 --- a/packages/cache/src/cache-keys.ts +++ b/packages/cache/src/cache-keys.ts @@ -40,6 +40,12 @@ export const createCacheKey = { countBySurveyId: (surveyId: string): CacheKey => makeCacheKey("response", surveyId, "count"), }, + // Hub-related keys + hub: { + feedbackRecordTenant: (recordId: string): CacheKey => + makeCacheKey("hub", recordId, "feedback_record_tenant"), + }, + // Rate limiting and security rateLimit: { core: (namespace: string, identifier: string, windowStart: number): CacheKey => diff --git a/packages/cache/types/keys.ts b/packages/cache/types/keys.ts index bf58cc6abc..026d578195 100644 --- a/packages/cache/types/keys.ts +++ b/packages/cache/types/keys.ts @@ -16,4 +16,4 @@ export type CacheKey = z.infer; * Possible namespaces for custom cache keys * Add new namespaces here as they are introduced */ -export type CustomCacheNamespace = "analytics" | "billing" | "hub"; +export type CustomCacheNamespace = "analytics" | "billing";