mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-10 02:24:17 -05:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ce0a943cf4 | |||
| 401276dae6 | |||
| 51583a422b | |||
| ff20e7b074 | |||
| b92ea5f64a |
@@ -177,7 +177,7 @@ describe("authenticateRequest", () => {
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null when API key has no environment permissions", async () => {
|
||||
test("returns null by default when API key has no environment permissions", async () => {
|
||||
const request = new NextRequest("http://localhost", {
|
||||
headers: { "x-api-key": "valid-api-key" },
|
||||
});
|
||||
@@ -198,6 +198,72 @@ describe("authenticateRequest", () => {
|
||||
const result = await authenticateRequest(request);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("authenticates a valid API key with no environment permissions when explicitly allowed", async () => {
|
||||
const request = new NextRequest("http://localhost", {
|
||||
headers: { "x-api-key": "valid-api-key" },
|
||||
});
|
||||
|
||||
const mockApiKeyData = {
|
||||
id: "api-key-id",
|
||||
organizationId: "org-id",
|
||||
organizationAccess: "all" as const,
|
||||
createdAt: new Date(),
|
||||
createdBy: "user-id",
|
||||
lastUsedAt: null,
|
||||
label: "Test API Key",
|
||||
apiKeyEnvironments: [],
|
||||
};
|
||||
|
||||
vi.mocked(getApiKeyWithPermissions).mockResolvedValue(mockApiKeyData as any);
|
||||
|
||||
const result = await authenticateRequest(request, { allowOrganizationOnlyApiKey: true });
|
||||
expect(result).toEqual({
|
||||
type: "apiKey",
|
||||
environmentPermissions: [],
|
||||
apiKeyId: "api-key-id",
|
||||
organizationId: "org-id",
|
||||
organizationAccess: "all",
|
||||
});
|
||||
});
|
||||
|
||||
test("authenticates a read-only organization API key with no environment permissions", async () => {
|
||||
const request = new NextRequest("http://localhost/api/v1/management/surveys", {
|
||||
headers: { "x-api-key": "read-only-org-api-key" },
|
||||
});
|
||||
|
||||
const mockApiKeyData = {
|
||||
id: "api-key-id",
|
||||
organizationId: "org-id",
|
||||
organizationAccess: {
|
||||
accessControl: {
|
||||
read: true,
|
||||
write: false,
|
||||
},
|
||||
},
|
||||
createdAt: new Date(),
|
||||
createdBy: "user-id",
|
||||
lastUsedAt: null,
|
||||
label: "Read-only Organization API Key",
|
||||
apiKeyEnvironments: [],
|
||||
};
|
||||
|
||||
vi.mocked(getApiKeyWithPermissions).mockResolvedValue(mockApiKeyData as any);
|
||||
|
||||
const result = await authenticateRequest(request, { allowOrganizationOnlyApiKey: true });
|
||||
expect(result).toEqual({
|
||||
type: "apiKey",
|
||||
environmentPermissions: [],
|
||||
apiKeyId: "api-key-id",
|
||||
organizationId: "org-id",
|
||||
organizationAccess: {
|
||||
accessControl: {
|
||||
read: true,
|
||||
write: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleErrorResponse", () => {
|
||||
|
||||
@@ -9,18 +9,22 @@ import {
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { getApiKeyWithPermissions } from "@/modules/organization/settings/api-keys/lib/api-key";
|
||||
|
||||
export const authenticateRequest = async (request: NextRequest): Promise<TAuthenticationApiKey | null> => {
|
||||
const apiKey = request.headers.get("x-api-key");
|
||||
if (!apiKey) return null;
|
||||
type AuthenticateApiKeyOptions = {
|
||||
allowOrganizationOnlyApiKey?: boolean;
|
||||
};
|
||||
|
||||
// Get API key with permissions
|
||||
export const authenticateApiKey = async (
|
||||
apiKey: string,
|
||||
options: AuthenticateApiKeyOptions = {}
|
||||
): Promise<TAuthenticationApiKey | null> => {
|
||||
const apiKeyData = await getApiKeyWithPermissions(apiKey);
|
||||
if (!apiKeyData) return null;
|
||||
|
||||
// In the route handlers, we'll do more specific permission checks
|
||||
const environmentIds = apiKeyData.apiKeyEnvironments.map((env) => env.environmentId);
|
||||
if (environmentIds.length === 0) return null;
|
||||
if (!options.allowOrganizationOnlyApiKey && apiKeyData.apiKeyEnvironments.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// In the route handlers, we'll do more specific permission checks
|
||||
const authentication: TAuthenticationApiKey = {
|
||||
type: "apiKey",
|
||||
environmentPermissions: apiKeyData.apiKeyEnvironments.map((env) => ({
|
||||
@@ -38,6 +42,16 @@ export const authenticateRequest = async (request: NextRequest): Promise<TAuthen
|
||||
return authentication;
|
||||
};
|
||||
|
||||
export const authenticateRequest = async (
|
||||
request: NextRequest,
|
||||
options: AuthenticateApiKeyOptions = {}
|
||||
): Promise<TAuthenticationApiKey | null> => {
|
||||
const apiKey = request.headers.get("x-api-key");
|
||||
if (!apiKey) return null;
|
||||
|
||||
return authenticateApiKey(apiKey, options);
|
||||
};
|
||||
|
||||
export const handleErrorResponse = (error: any): Response => {
|
||||
switch (error.message) {
|
||||
case "NotAuthenticated":
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
import { EnvironmentType } from "@prisma/client";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import type { TAuthenticationApiKey } from "@formbricks/types/auth";
|
||||
import { getEnvironment } from "@/lib/environment/service";
|
||||
import { buildApiKeyMeResponse } from "./api-key-response";
|
||||
|
||||
vi.mock("@/lib/environment/service", () => ({
|
||||
getEnvironment: vi.fn(),
|
||||
}));
|
||||
|
||||
const baseAuthentication = {
|
||||
type: "apiKey" as const,
|
||||
apiKeyId: "api-key-id",
|
||||
organizationId: "org-id",
|
||||
organizationAccess: {
|
||||
accessControl: {
|
||||
read: false,
|
||||
write: false,
|
||||
},
|
||||
},
|
||||
environmentPermissions: [],
|
||||
};
|
||||
|
||||
const environmentPermission = (
|
||||
environmentId: string,
|
||||
permission: "read" | "write" | "manage" = "read"
|
||||
): TAuthenticationApiKey["environmentPermissions"][number] => ({
|
||||
environmentId,
|
||||
permission,
|
||||
environmentType: EnvironmentType.development,
|
||||
projectId: "project-id",
|
||||
projectName: "Project Name",
|
||||
});
|
||||
|
||||
describe("buildApiKeyMeResponse", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("returns auth metadata for an organization-read API key without environment permissions", async () => {
|
||||
const response = await buildApiKeyMeResponse({
|
||||
...baseAuthentication,
|
||||
organizationAccess: {
|
||||
accessControl: {
|
||||
read: true,
|
||||
write: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(response?.status).toBe(200);
|
||||
expect(await response?.json()).toEqual({
|
||||
environmentPermissions: [],
|
||||
organizationId: "org-id",
|
||||
organizationAccess: {
|
||||
accessControl: {
|
||||
read: true,
|
||||
write: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(getEnvironment).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("returns auth metadata with permissions for organization-read API keys with multiple environments", async () => {
|
||||
const response = await buildApiKeyMeResponse({
|
||||
...baseAuthentication,
|
||||
organizationAccess: {
|
||||
accessControl: {
|
||||
read: true,
|
||||
write: false,
|
||||
},
|
||||
},
|
||||
environmentPermissions: [
|
||||
environmentPermission("env-1", "read"),
|
||||
environmentPermission("env-2", "write"),
|
||||
],
|
||||
});
|
||||
|
||||
expect(response?.status).toBe(200);
|
||||
expect(await response?.json()).toEqual({
|
||||
environmentPermissions: [
|
||||
{
|
||||
environmentId: "env-1",
|
||||
environmentType: EnvironmentType.development,
|
||||
permissions: "read",
|
||||
projectId: "project-id",
|
||||
projectName: "Project Name",
|
||||
},
|
||||
{
|
||||
environmentId: "env-2",
|
||||
environmentType: EnvironmentType.development,
|
||||
permissions: "write",
|
||||
projectId: "project-id",
|
||||
projectName: "Project Name",
|
||||
},
|
||||
],
|
||||
organizationId: "org-id",
|
||||
organizationAccess: {
|
||||
accessControl: {
|
||||
read: true,
|
||||
write: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(getEnvironment).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("returns the legacy environment response for a single environment permission", async () => {
|
||||
const createdAt = new Date("2026-01-01T00:00:00.000Z");
|
||||
const updatedAt = new Date("2026-01-02T00:00:00.000Z");
|
||||
|
||||
vi.mocked(getEnvironment).mockResolvedValue({
|
||||
id: "env-id",
|
||||
type: EnvironmentType.development,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
projectId: "project-id",
|
||||
appSetupCompleted: true,
|
||||
});
|
||||
|
||||
const response = await buildApiKeyMeResponse({
|
||||
...baseAuthentication,
|
||||
environmentPermissions: [environmentPermission("env-id", "read")],
|
||||
});
|
||||
|
||||
expect(response?.status).toBe(200);
|
||||
expect(await response?.json()).toEqual({
|
||||
id: "env-id",
|
||||
type: EnvironmentType.development,
|
||||
createdAt: createdAt.toISOString(),
|
||||
updatedAt: updatedAt.toISOString(),
|
||||
appSetupCompleted: true,
|
||||
project: {
|
||||
id: "project-id",
|
||||
name: "Project Name",
|
||||
},
|
||||
});
|
||||
expect(getEnvironment).toHaveBeenCalledWith("env-id");
|
||||
});
|
||||
|
||||
test("returns the legacy environment response for an organization-read API key with one environment", async () => {
|
||||
const createdAt = new Date("2026-01-01T00:00:00.000Z");
|
||||
const updatedAt = new Date("2026-01-02T00:00:00.000Z");
|
||||
|
||||
vi.mocked(getEnvironment).mockResolvedValue({
|
||||
id: "env-id",
|
||||
type: EnvironmentType.development,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
projectId: "project-id",
|
||||
appSetupCompleted: true,
|
||||
});
|
||||
|
||||
const response = await buildApiKeyMeResponse({
|
||||
...baseAuthentication,
|
||||
organizationAccess: {
|
||||
accessControl: {
|
||||
read: true,
|
||||
write: false,
|
||||
},
|
||||
},
|
||||
environmentPermissions: [environmentPermission("env-id", "read")],
|
||||
});
|
||||
|
||||
expect(response?.status).toBe(200);
|
||||
expect(await response?.json()).toEqual({
|
||||
id: "env-id",
|
||||
type: EnvironmentType.development,
|
||||
createdAt: createdAt.toISOString(),
|
||||
updatedAt: updatedAt.toISOString(),
|
||||
appSetupCompleted: true,
|
||||
project: {
|
||||
id: "project-id",
|
||||
name: "Project Name",
|
||||
},
|
||||
});
|
||||
expect(getEnvironment).toHaveBeenCalledWith("env-id");
|
||||
});
|
||||
|
||||
test("returns null when an API key has neither organization read nor exactly one environment", async () => {
|
||||
const response = await buildApiKeyMeResponse(baseAuthentication);
|
||||
|
||||
expect(response).toBeNull();
|
||||
expect(getEnvironment).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("returns null when the single permitted environment no longer exists", async () => {
|
||||
vi.mocked(getEnvironment).mockResolvedValue(null);
|
||||
|
||||
const response = await buildApiKeyMeResponse({
|
||||
...baseAuthentication,
|
||||
environmentPermissions: [environmentPermission("env-id", "read")],
|
||||
});
|
||||
|
||||
expect(response).toBeNull();
|
||||
expect(getEnvironment).toHaveBeenCalledWith("env-id");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { OrganizationAccessType } from "@formbricks/types/api-key";
|
||||
import type { TAuthenticationApiKey } from "@formbricks/types/auth";
|
||||
import { getEnvironment } from "@/lib/environment/service";
|
||||
import { hasOrganizationAccess } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||
|
||||
const buildApiKeyMetadataResponse = (authentication: TAuthenticationApiKey) => ({
|
||||
environmentPermissions: authentication.environmentPermissions.map((permission) => ({
|
||||
environmentId: permission.environmentId,
|
||||
environmentType: permission.environmentType,
|
||||
permissions: permission.permission,
|
||||
projectId: permission.projectId,
|
||||
projectName: permission.projectName,
|
||||
})),
|
||||
organizationId: authentication.organizationId,
|
||||
organizationAccess: authentication.organizationAccess,
|
||||
});
|
||||
|
||||
export const buildApiKeyMeResponse = async (
|
||||
authentication: TAuthenticationApiKey
|
||||
): Promise<Response | null> => {
|
||||
const environmentPermissionCount = authentication.environmentPermissions.length;
|
||||
|
||||
if (environmentPermissionCount !== 1) {
|
||||
if (hasOrganizationAccess(authentication, OrganizationAccessType.Read)) {
|
||||
return Response.json(buildApiKeyMetadataResponse(authentication));
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const permission = authentication.environmentPermissions[0];
|
||||
const environment = await getEnvironment(permission.environmentId);
|
||||
|
||||
if (!environment) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Response.json({
|
||||
id: environment.id,
|
||||
type: environment.type,
|
||||
createdAt: environment.createdAt,
|
||||
updatedAt: environment.updatedAt,
|
||||
appSetupCompleted: environment.appSetupCompleted,
|
||||
project: {
|
||||
id: permission.projectId,
|
||||
name: permission.projectName,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,128 @@
|
||||
import { EnvironmentType } from "@prisma/client";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { authenticateApiKey } from "@/app/api/v1/auth";
|
||||
import { getEnvironment } from "@/lib/environment/service";
|
||||
import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
|
||||
import { GET } from "./route";
|
||||
|
||||
const { mockHeaders, mockPrisma } = vi.hoisted(() => ({
|
||||
mockHeaders: vi.fn(),
|
||||
mockPrisma: {
|
||||
user: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: mockHeaders,
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: mockPrisma,
|
||||
}));
|
||||
|
||||
vi.mock("@/app/api/v1/auth", () => ({
|
||||
authenticateApiKey: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/api/v1/management/me/lib/utils", () => ({
|
||||
getSessionUser: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/environment/service", () => ({
|
||||
getEnvironment: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/core/rate-limit/helpers", () => ({
|
||||
applyRateLimit: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
describe("GET /api/v1/management/me", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockHeaders.mockResolvedValue(new Headers({ "x-api-key": "read-only-org-api-key" }));
|
||||
});
|
||||
|
||||
test("accepts a read-only organization API key without environment permissions", async () => {
|
||||
vi.mocked(authenticateApiKey).mockResolvedValue({
|
||||
type: "apiKey",
|
||||
apiKeyId: "api-key-id",
|
||||
organizationId: "org-id",
|
||||
organizationAccess: {
|
||||
accessControl: {
|
||||
read: true,
|
||||
write: false,
|
||||
},
|
||||
},
|
||||
environmentPermissions: [],
|
||||
});
|
||||
|
||||
const response = await GET();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toEqual({
|
||||
environmentPermissions: [],
|
||||
organizationId: "org-id",
|
||||
organizationAccess: {
|
||||
accessControl: {
|
||||
read: true,
|
||||
write: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(authenticateApiKey).toHaveBeenCalledWith("read-only-org-api-key", {
|
||||
allowOrganizationOnlyApiKey: true,
|
||||
});
|
||||
expect(applyRateLimit).toHaveBeenCalledWith(expect.any(Object), "api-key-id");
|
||||
});
|
||||
|
||||
test("preserves the legacy environment response for single-environment API keys", async () => {
|
||||
const createdAt = new Date("2026-01-01T00:00:00.000Z");
|
||||
const updatedAt = new Date("2026-01-02T00:00:00.000Z");
|
||||
|
||||
vi.mocked(authenticateApiKey).mockResolvedValue({
|
||||
type: "apiKey",
|
||||
apiKeyId: "api-key-id",
|
||||
organizationId: "org-id",
|
||||
organizationAccess: {
|
||||
accessControl: {
|
||||
read: false,
|
||||
write: false,
|
||||
},
|
||||
},
|
||||
environmentPermissions: [
|
||||
{
|
||||
environmentId: "env-id",
|
||||
environmentType: EnvironmentType.development,
|
||||
permission: "read",
|
||||
projectId: "project-id",
|
||||
projectName: "Project Name",
|
||||
},
|
||||
],
|
||||
});
|
||||
vi.mocked(getEnvironment).mockResolvedValue({
|
||||
id: "env-id",
|
||||
type: EnvironmentType.development,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
projectId: "project-id",
|
||||
appSetupCompleted: true,
|
||||
});
|
||||
|
||||
const response = await GET();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toEqual({
|
||||
id: "env-id",
|
||||
type: EnvironmentType.development,
|
||||
createdAt: createdAt.toISOString(),
|
||||
updatedAt: updatedAt.toISOString(),
|
||||
appSetupCompleted: true,
|
||||
project: {
|
||||
id: "project-id",
|
||||
name: "Project Name",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,104 +1,13 @@
|
||||
import { headers } from "next/headers";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { authenticateApiKey } from "@/app/api/v1/auth";
|
||||
import { buildApiKeyMeResponse } from "@/app/api/v1/management/me/lib/api-key-response";
|
||||
import { getSessionUser } from "@/app/api/v1/management/me/lib/utils";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { CONTROL_HASH } from "@/lib/constants";
|
||||
import { hashSha256, parseApiKeyV2, verifySecret } from "@/lib/crypto";
|
||||
import { publicUserSelect } from "@/lib/user/public-user";
|
||||
import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
|
||||
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
|
||||
|
||||
const ALLOWED_PERMISSIONS = ["manage", "read", "write"] as const;
|
||||
|
||||
const apiKeySelect = {
|
||||
id: true,
|
||||
organizationId: true,
|
||||
lastUsedAt: true,
|
||||
apiKeyEnvironments: {
|
||||
select: {
|
||||
environment: {
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
projectId: true,
|
||||
appSetupCompleted: true,
|
||||
project: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
permission: true,
|
||||
},
|
||||
},
|
||||
hashedKey: true,
|
||||
};
|
||||
|
||||
type ApiKeyData = {
|
||||
id: string;
|
||||
hashedKey: string;
|
||||
organizationId: string;
|
||||
lastUsedAt: Date | null;
|
||||
apiKeyEnvironments: Array<{
|
||||
permission: string;
|
||||
environment: {
|
||||
id: string;
|
||||
type: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
projectId: string;
|
||||
appSetupCompleted: boolean;
|
||||
project: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
};
|
||||
}>;
|
||||
};
|
||||
|
||||
const validateApiKey = async (apiKey: string): Promise<ApiKeyData | null> => {
|
||||
const v2Parsed = parseApiKeyV2(apiKey);
|
||||
|
||||
if (v2Parsed) {
|
||||
return validateV2ApiKey(v2Parsed);
|
||||
}
|
||||
|
||||
return validateLegacyApiKey(apiKey);
|
||||
};
|
||||
|
||||
const validateV2ApiKey = async (v2Parsed: { secret: string }): Promise<ApiKeyData | null> => {
|
||||
// Step 1: Fast SHA-256 lookup by indexed lookupHash
|
||||
const lookupHash = hashSha256(v2Parsed.secret);
|
||||
|
||||
const apiKeyData = await prisma.apiKey.findUnique({
|
||||
where: { lookupHash },
|
||||
select: apiKeySelect,
|
||||
});
|
||||
|
||||
// Step 2: Security verification with bcrypt
|
||||
// Always perform bcrypt verification to prevent timing attacks
|
||||
// Use a control hash when API key doesn't exist to maintain constant timing
|
||||
const hashToVerify = apiKeyData?.hashedKey || CONTROL_HASH;
|
||||
const isValid = await verifySecret(v2Parsed.secret, hashToVerify);
|
||||
|
||||
if (!apiKeyData || !isValid) return null;
|
||||
|
||||
return apiKeyData;
|
||||
};
|
||||
|
||||
const validateLegacyApiKey = async (apiKey: string): Promise<ApiKeyData | null> => {
|
||||
const hashedKey = hashSha256(apiKey);
|
||||
const result = await prisma.apiKey.findFirst({
|
||||
where: { hashedKey },
|
||||
select: apiKeySelect,
|
||||
});
|
||||
return result;
|
||||
};
|
||||
|
||||
const checkRateLimit = async (userId: string) => {
|
||||
try {
|
||||
await applyRateLimit(rateLimitConfigs.api.v1, userId);
|
||||
@@ -110,59 +19,23 @@ const checkRateLimit = async (userId: string) => {
|
||||
return null;
|
||||
};
|
||||
|
||||
const updateApiKeyUsage = async (apiKeyId: string) => {
|
||||
await prisma.apiKey.update({
|
||||
where: { id: apiKeyId },
|
||||
data: { lastUsedAt: new Date() },
|
||||
});
|
||||
};
|
||||
|
||||
const buildEnvironmentResponse = (apiKeyData: ApiKeyData) => {
|
||||
const env = apiKeyData.apiKeyEnvironments[0].environment;
|
||||
return Response.json({
|
||||
id: env.id,
|
||||
type: env.type,
|
||||
createdAt: env.createdAt,
|
||||
updatedAt: env.updatedAt,
|
||||
appSetupCompleted: env.appSetupCompleted,
|
||||
project: {
|
||||
id: env.projectId,
|
||||
name: env.project.name,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const isValidApiKeyEnvironment = (apiKeyData: ApiKeyData): boolean => {
|
||||
return (
|
||||
apiKeyData.apiKeyEnvironments.length === 1 &&
|
||||
ALLOWED_PERMISSIONS.includes(
|
||||
apiKeyData.apiKeyEnvironments[0].permission as (typeof ALLOWED_PERMISSIONS)[number]
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const handleApiKeyAuthentication = async (apiKey: string) => {
|
||||
const apiKeyData = await validateApiKey(apiKey);
|
||||
const authentication = await authenticateApiKey(apiKey, { allowOrganizationOnlyApiKey: true });
|
||||
|
||||
if (!apiKeyData) {
|
||||
if (!authentication) {
|
||||
return responses.notAuthenticatedResponse();
|
||||
}
|
||||
|
||||
if (!apiKeyData.lastUsedAt || apiKeyData.lastUsedAt <= new Date(Date.now() - 1000 * 30)) {
|
||||
// Fire-and-forget: update lastUsedAt in the background without blocking the response
|
||||
updateApiKeyUsage(apiKeyData.id).catch((error) => {
|
||||
console.error("Failed to update API key usage:", error);
|
||||
});
|
||||
}
|
||||
|
||||
const rateLimitError = await checkRateLimit(apiKeyData.id);
|
||||
const rateLimitError = await checkRateLimit(authentication.apiKeyId);
|
||||
if (rateLimitError) return rateLimitError;
|
||||
|
||||
if (!isValidApiKeyEnvironment(apiKeyData)) {
|
||||
const apiKeyMeResponse = await buildApiKeyMeResponse(authentication);
|
||||
|
||||
if (!apiKeyMeResponse) {
|
||||
return responses.badRequestResponse("You can't use this method with this API key");
|
||||
}
|
||||
|
||||
return buildEnvironmentResponse(apiKeyData);
|
||||
return apiKeyMeResponse;
|
||||
};
|
||||
|
||||
const handleSessionAuthentication = async () => {
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import type { TAuthenticationApiKey } from "@formbricks/types/auth";
|
||||
import { getEnvironmentIdsByOrganizationId } from "@/lib/environment/organization";
|
||||
import { getReadableEnvironmentIds } from "./access";
|
||||
|
||||
vi.mock("@/lib/environment/organization", () => ({
|
||||
getEnvironmentIdsByOrganizationId: vi.fn(),
|
||||
}));
|
||||
|
||||
const baseAuthentication = {
|
||||
type: "apiKey" as const,
|
||||
apiKeyId: "api-key-id",
|
||||
organizationId: "org-id",
|
||||
organizationAccess: {
|
||||
accessControl: {
|
||||
read: false,
|
||||
write: false,
|
||||
},
|
||||
},
|
||||
environmentPermissions: [],
|
||||
};
|
||||
|
||||
const environmentPermission = (
|
||||
environmentId: string,
|
||||
permission: "read" | "write" | "manage"
|
||||
): TAuthenticationApiKey["environmentPermissions"][number] => ({
|
||||
environmentId,
|
||||
permission,
|
||||
environmentType: "development",
|
||||
projectId: `project-${environmentId}`,
|
||||
projectName: `Project ${environmentId}`,
|
||||
});
|
||||
|
||||
describe("getReadableEnvironmentIds", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("returns all organization environments when API key has organization read access", async () => {
|
||||
vi.mocked(getEnvironmentIdsByOrganizationId).mockResolvedValue(["env-1", "env-2"]);
|
||||
|
||||
const result = await getReadableEnvironmentIds({
|
||||
...baseAuthentication,
|
||||
organizationAccess: {
|
||||
accessControl: {
|
||||
read: true,
|
||||
write: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual(["env-1", "env-2"]);
|
||||
expect(getEnvironmentIdsByOrganizationId).toHaveBeenCalledWith("org-id");
|
||||
});
|
||||
|
||||
test("returns an empty list when an organization-read API key belongs to an organization without environments", async () => {
|
||||
vi.mocked(getEnvironmentIdsByOrganizationId).mockResolvedValue([]);
|
||||
|
||||
const result = await getReadableEnvironmentIds({
|
||||
...baseAuthentication,
|
||||
organizationAccess: {
|
||||
accessControl: {
|
||||
read: true,
|
||||
write: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(getEnvironmentIdsByOrganizationId).toHaveBeenCalledWith("org-id");
|
||||
});
|
||||
|
||||
test("returns all organization environments when API key has organization write access", async () => {
|
||||
vi.mocked(getEnvironmentIdsByOrganizationId).mockResolvedValue(["env-1"]);
|
||||
|
||||
const result = await getReadableEnvironmentIds({
|
||||
...baseAuthentication,
|
||||
organizationAccess: {
|
||||
accessControl: {
|
||||
read: false,
|
||||
write: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual(["env-1"]);
|
||||
expect(getEnvironmentIdsByOrganizationId).toHaveBeenCalledWith("org-id");
|
||||
});
|
||||
|
||||
test("returns de-duplicated environment permissions that allow GET without organization access", async () => {
|
||||
const result = await getReadableEnvironmentIds({
|
||||
...baseAuthentication,
|
||||
environmentPermissions: [
|
||||
environmentPermission("env-1", "read"),
|
||||
environmentPermission("env-2", "write"),
|
||||
environmentPermission("env-3", "manage"),
|
||||
environmentPermission("env-1", "read"),
|
||||
],
|
||||
});
|
||||
|
||||
expect(result).toEqual(["env-1", "env-2", "env-3"]);
|
||||
expect(getEnvironmentIdsByOrganizationId).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("returns null when the API key has no readable access", async () => {
|
||||
const result = await getReadableEnvironmentIds(baseAuthentication);
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(getEnvironmentIdsByOrganizationId).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import { OrganizationAccessType } from "@formbricks/types/api-key";
|
||||
import type { TAuthenticationApiKey } from "@formbricks/types/auth";
|
||||
import { getEnvironmentIdsByOrganizationId } from "@/lib/environment/organization";
|
||||
import { hasOrganizationAccess, hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||
|
||||
export const getReadableEnvironmentIds = async (
|
||||
authentication: TAuthenticationApiKey
|
||||
): Promise<string[] | null> => {
|
||||
if (hasOrganizationAccess(authentication, OrganizationAccessType.Read)) {
|
||||
return getEnvironmentIdsByOrganizationId(authentication.organizationId);
|
||||
}
|
||||
|
||||
const environmentIds = authentication.environmentPermissions
|
||||
.filter((permission) =>
|
||||
hasPermission(authentication.environmentPermissions, permission.environmentId, "GET")
|
||||
)
|
||||
.map((permission) => permission.environmentId);
|
||||
|
||||
const readableEnvironmentIds = Array.from(new Set(environmentIds));
|
||||
|
||||
return readableEnvironmentIds.length > 0 ? readableEnvironmentIds : null;
|
||||
};
|
||||
@@ -0,0 +1,149 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { getEnvironmentIdsByOrganizationId } from "@/lib/environment/organization";
|
||||
import { getApiKeyWithPermissions } from "@/modules/organization/settings/api-keys/lib/api-key";
|
||||
import { getSurveys } from "./lib/surveys";
|
||||
import { GET } from "./route";
|
||||
|
||||
vi.mock("@/modules/organization/settings/api-keys/lib/api-key", () => ({
|
||||
getApiKeyWithPermissions: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/environment/organization", () => ({
|
||||
getEnvironmentIdsByOrganizationId: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./lib/surveys", () => ({
|
||||
getSurveys: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/core/rate-limit/helpers", () => ({
|
||||
applyRateLimit: vi.fn().mockResolvedValue(undefined),
|
||||
applyIPRateLimit: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/lib/api/api-error-reporter", () => ({
|
||||
reportApiError: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/constants", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/constants")>();
|
||||
return { ...actual, AUDIT_LOG_ENABLED: false };
|
||||
});
|
||||
|
||||
describe("GET /api/v1/management/surveys", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getEnvironmentIdsByOrganizationId).mockResolvedValue(["env-1", "env-2"]);
|
||||
vi.mocked(getSurveys).mockResolvedValue([]);
|
||||
});
|
||||
|
||||
test("accepts a read-only organization API key without environment permissions", async () => {
|
||||
vi.mocked(getApiKeyWithPermissions).mockResolvedValue({
|
||||
id: "api-key-id",
|
||||
organizationId: "org-id",
|
||||
organizationAccess: {
|
||||
accessControl: {
|
||||
read: true,
|
||||
write: false,
|
||||
},
|
||||
},
|
||||
apiKeyEnvironments: [],
|
||||
} as any);
|
||||
|
||||
const request = new NextRequest("http://localhost/api/v1/management/surveys", {
|
||||
headers: { "x-api-key": "read-only-org-api-key" },
|
||||
});
|
||||
|
||||
const response = await GET(request, {} as any);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(getEnvironmentIdsByOrganizationId).toHaveBeenCalledWith("org-id");
|
||||
expect(getSurveys).toHaveBeenCalledWith(["env-1", "env-2"], undefined, undefined);
|
||||
});
|
||||
|
||||
test("returns an empty survey list for an organization-read API key when the organization has no environments", async () => {
|
||||
vi.mocked(getEnvironmentIdsByOrganizationId).mockResolvedValue([]);
|
||||
vi.mocked(getApiKeyWithPermissions).mockResolvedValue({
|
||||
id: "api-key-id",
|
||||
organizationId: "org-id",
|
||||
organizationAccess: {
|
||||
accessControl: {
|
||||
read: true,
|
||||
write: false,
|
||||
},
|
||||
},
|
||||
apiKeyEnvironments: [],
|
||||
} as any);
|
||||
|
||||
const request = new NextRequest("http://localhost/api/v1/management/surveys", {
|
||||
headers: { "x-api-key": "read-only-org-api-key" },
|
||||
});
|
||||
|
||||
const response = await GET(request, {} as any);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(getEnvironmentIdsByOrganizationId).toHaveBeenCalledWith("org-id");
|
||||
expect(getSurveys).toHaveBeenCalledWith([], undefined, undefined);
|
||||
});
|
||||
|
||||
test("rejects an organization-only API key without readable organization access", async () => {
|
||||
vi.mocked(getApiKeyWithPermissions).mockResolvedValue({
|
||||
id: "api-key-id",
|
||||
organizationId: "org-id",
|
||||
organizationAccess: {
|
||||
accessControl: {
|
||||
read: false,
|
||||
write: false,
|
||||
},
|
||||
},
|
||||
apiKeyEnvironments: [],
|
||||
} as any);
|
||||
|
||||
const request = new NextRequest("http://localhost/api/v1/management/surveys", {
|
||||
headers: { "x-api-key": "no-access-org-api-key" },
|
||||
});
|
||||
|
||||
const response = await GET(request, {} as any);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(await response.json()).toMatchObject({ code: "unauthorized" });
|
||||
expect(getEnvironmentIdsByOrganizationId).not.toHaveBeenCalled();
|
||||
expect(getSurveys).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("uses explicit readable environment permissions without organization read access", async () => {
|
||||
vi.mocked(getApiKeyWithPermissions).mockResolvedValue({
|
||||
id: "api-key-id",
|
||||
organizationId: "org-id",
|
||||
organizationAccess: {
|
||||
accessControl: {
|
||||
read: false,
|
||||
write: false,
|
||||
},
|
||||
},
|
||||
apiKeyEnvironments: [
|
||||
{
|
||||
environmentId: "env-1",
|
||||
permission: "read",
|
||||
environment: {
|
||||
id: "env-1",
|
||||
type: "development",
|
||||
projectId: "project-1",
|
||||
project: { id: "project-1", name: "Project 1" },
|
||||
},
|
||||
},
|
||||
],
|
||||
} as any);
|
||||
|
||||
const request = new NextRequest("http://localhost/api/v1/management/surveys?limit=10&offset=5", {
|
||||
headers: { "x-api-key": "environment-read-api-key" },
|
||||
});
|
||||
|
||||
const response = await GET(request, {} as any);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(getEnvironmentIdsByOrganizationId).not.toHaveBeenCalled();
|
||||
expect(getSurveys).toHaveBeenCalledWith(["env-1"], 10, 5);
|
||||
});
|
||||
});
|
||||
@@ -14,9 +14,11 @@ import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||
import { createSurvey } from "@/lib/survey/service";
|
||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||
import { resolveStorageUrlsInObject } from "@/modules/storage/utils";
|
||||
import { getReadableEnvironmentIds } from "./lib/access";
|
||||
import { getSurveys } from "./lib/surveys";
|
||||
|
||||
export const GET = withV1ApiWrapper({
|
||||
allowOrganizationOnlyApiKey: true,
|
||||
handler: async ({ req, authentication }) => {
|
||||
if (!authentication || !("apiKeyId" in authentication)) {
|
||||
return { response: responses.notAuthenticatedResponse() };
|
||||
@@ -27,9 +29,10 @@ export const GET = withV1ApiWrapper({
|
||||
const limit = searchParams.has("limit") ? Number(searchParams.get("limit")) : undefined;
|
||||
const offset = searchParams.has("offset") ? Number(searchParams.get("offset")) : undefined;
|
||||
|
||||
const environmentIds = authentication.environmentPermissions.map(
|
||||
(permission) => permission.environmentId
|
||||
);
|
||||
const environmentIds = await getReadableEnvironmentIds(authentication);
|
||||
if (!environmentIds) {
|
||||
return { response: responses.unauthorizedResponse() };
|
||||
}
|
||||
|
||||
const surveys = await getSurveys(environmentIds, limit, offset);
|
||||
|
||||
|
||||
@@ -587,6 +587,56 @@ describe("withV1ApiWrapper", () => {
|
||||
});
|
||||
expect(mockedQueueAuditEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("does not allow organization-only API keys by default", async () => {
|
||||
const { authenticateRequest } = await import("@/app/api/v1/auth");
|
||||
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
|
||||
await import("@/app/middleware/endpoint-validator");
|
||||
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(null);
|
||||
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
|
||||
vi.mocked(isManagementApiRoute).mockReturnValue({
|
||||
isManagementApi: true,
|
||||
authenticationMethod: AuthenticationMethod.ApiKey,
|
||||
});
|
||||
vi.mocked(isIntegrationRoute).mockReturnValue(false);
|
||||
|
||||
const handler = vi.fn();
|
||||
const req = createMockRequest({ url: "https://api.test/api/v1/management/action-classes" });
|
||||
const { withV1ApiWrapper } = await import("./with-api-logging");
|
||||
const wrapped = withV1ApiWrapper({ handler });
|
||||
const response = await wrapped(req, undefined);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
expect(authenticateRequest).toHaveBeenCalledWith(req, { allowOrganizationOnlyApiKey: false });
|
||||
});
|
||||
|
||||
test("allows organization-only API keys when the route opts in", async () => {
|
||||
const { authenticateRequest } = await import("@/app/api/v1/auth");
|
||||
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
|
||||
await import("@/app/middleware/endpoint-validator");
|
||||
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication);
|
||||
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
|
||||
vi.mocked(isManagementApiRoute).mockReturnValue({
|
||||
isManagementApi: true,
|
||||
authenticationMethod: AuthenticationMethod.ApiKey,
|
||||
});
|
||||
vi.mocked(isIntegrationRoute).mockReturnValue(false);
|
||||
|
||||
const handler = vi.fn().mockResolvedValue({
|
||||
response: responses.successResponse({ data: "test" }),
|
||||
});
|
||||
const req = createMockRequest({ url: V1_MANAGEMENT_SURVEYS_URL });
|
||||
const { withV1ApiWrapper } = await import("./with-api-logging");
|
||||
const wrapped = withV1ApiWrapper({ handler, allowOrganizationOnlyApiKey: true });
|
||||
const response = await wrapped(req, undefined);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(handler).toHaveBeenCalled();
|
||||
expect(authenticateRequest).toHaveBeenCalledWith(req, { allowOrganizationOnlyApiKey: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildAuditLogBaseObject", () => {
|
||||
|
||||
@@ -46,6 +46,11 @@ export interface TWithV1ApiWrapperParams<
|
||||
* the legacy JSON 401. Use this to return a custom response (e.g. RFC 9457 problem+json for V3).
|
||||
*/
|
||||
unauthenticatedResponse?: (req: NextRequest) => Response;
|
||||
/**
|
||||
* Most v1 management routes are environment-scoped. Enable this only for routes that explicitly
|
||||
* support organization-only API keys.
|
||||
*/
|
||||
allowOrganizationOnlyApiKey?: boolean;
|
||||
}
|
||||
|
||||
enum ApiV1RouteTypeEnum {
|
||||
@@ -142,16 +147,17 @@ const setupAuditLog = (
|
||||
*/
|
||||
const handleAuthentication = async (
|
||||
authenticationMethod: AuthenticationMethod,
|
||||
req: NextRequest
|
||||
req: NextRequest,
|
||||
allowOrganizationOnlyApiKey = false
|
||||
): Promise<TApiV1Authentication> => {
|
||||
switch (authenticationMethod) {
|
||||
case AuthenticationMethod.ApiKey:
|
||||
return await authenticateRequest(req);
|
||||
return await authenticateRequest(req, { allowOrganizationOnlyApiKey });
|
||||
case AuthenticationMethod.Session:
|
||||
return await getServerSession(authOptions);
|
||||
case AuthenticationMethod.Both: {
|
||||
const session = await getServerSession(authOptions);
|
||||
return session ?? (await authenticateRequest(req));
|
||||
return session ?? (await authenticateRequest(req, { allowOrganizationOnlyApiKey }));
|
||||
}
|
||||
case AuthenticationMethod.None:
|
||||
return null;
|
||||
@@ -251,7 +257,14 @@ const getRouteType = (
|
||||
export const withV1ApiWrapper = <TResult extends { response: Response; error?: unknown }, TProps = unknown>(
|
||||
params: TWithV1ApiWrapperParams<TResult, TProps>
|
||||
): ((req: NextRequest, props: TProps) => Promise<Response>) => {
|
||||
const { handler, action, targetType, customRateLimitConfig, unauthenticatedResponse } = params;
|
||||
const {
|
||||
handler,
|
||||
action,
|
||||
targetType,
|
||||
customRateLimitConfig,
|
||||
unauthenticatedResponse,
|
||||
allowOrganizationOnlyApiKey,
|
||||
} = params;
|
||||
return async (req: NextRequest, props: TProps): Promise<Response> => {
|
||||
// === Audit Log Setup ===
|
||||
const saveAuditLog = action && targetType;
|
||||
@@ -270,7 +283,7 @@ export const withV1ApiWrapper = <TResult extends { response: Response; error?: u
|
||||
}
|
||||
|
||||
// === Authentication ===
|
||||
const authentication = await handleAuthentication(authenticationMethod, req);
|
||||
const authentication = await handleAuthentication(authenticationMethod, req, allowOrganizationOnlyApiKey);
|
||||
|
||||
if (!authentication && routeType !== ApiV1RouteTypeEnum.Client) {
|
||||
if (unauthenticatedResponse) {
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { getEnvironmentIdsByOrganizationId } from "./organization";
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
environment: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/validate", () => ({
|
||||
validateInputs: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("getEnvironmentIdsByOrganizationId", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("returns environment IDs for all projects in an organization", async () => {
|
||||
vi.mocked(prisma.environment.findMany).mockResolvedValue([{ id: "env-1" }, { id: "env-2" }] as any);
|
||||
|
||||
const result = await getEnvironmentIdsByOrganizationId("clh6pzwx90000e9ogjr0mf7so");
|
||||
|
||||
expect(result).toEqual(["env-1", "env-2"]);
|
||||
expect(prisma.environment.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
project: {
|
||||
organizationId: "clh6pzwx90000e9ogjr0mf7so",
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("returns an empty list when the organization has no environments", async () => {
|
||||
vi.mocked(prisma.environment.findMany).mockResolvedValue([]);
|
||||
|
||||
const result = await getEnvironmentIdsByOrganizationId("clh6pzwx90000e9ogjr0mf7so");
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
test("throws DatabaseError for known Prisma errors", async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", {
|
||||
code: "P2002",
|
||||
clientVersion: "5.0.0",
|
||||
});
|
||||
vi.mocked(prisma.environment.findMany).mockRejectedValue(prismaError);
|
||||
|
||||
await expect(getEnvironmentIdsByOrganizationId("clh6pzwx90000e9ogjr0mf7so")).rejects.toThrow(
|
||||
DatabaseError
|
||||
);
|
||||
});
|
||||
|
||||
test("rethrows unknown errors", async () => {
|
||||
const error = new Error("boom");
|
||||
vi.mocked(prisma.environment.findMany).mockRejectedValue(error);
|
||||
|
||||
await expect(getEnvironmentIdsByOrganizationId("clh6pzwx90000e9ogjr0mf7so")).rejects.toThrow(error);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import "server-only";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
|
||||
export const getEnvironmentIdsByOrganizationId = reactCache(
|
||||
async (organizationId: string): Promise<string[]> => {
|
||||
validateInputs([organizationId, ZId]);
|
||||
|
||||
try {
|
||||
const environments = await prisma.environment.findMany({
|
||||
where: {
|
||||
project: {
|
||||
organizationId,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
return environments.map((environment) => environment.id);
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
);
|
||||
Reference in New Issue
Block a user