/** @jest-environment node */ import { makeZodVerifiedAPICall, makeAPICall, } from "@/src/__tests__/test-utils"; import { z } from "zod/v4"; import { createAndAddApiKeysToDb, createBasicAuthHeader, } from "@langfuse/shared/src/server"; import { prisma } from "@langfuse/shared/src/db"; import { randomUUID } from "crypto"; // Schema for project response const ProjectResponseSchema = z.object({ data: z.array( z.object({ id: z.string(), name: z.string(), metadata: z.object({}).optional(), retentionDays: z.number().nullable().optional(), }), ), }); // Schema for project creation response const ProjectCreationResponseSchema = z.object({ id: z.string(), name: z.string(), metadata: z.object({}).optional(), retentionDays: z.number().nullable().optional(), }); // Schema for project update response const ProjectUpdateResponseSchema = z.object({ id: z.string(), name: z.string(), metadata: z.object({}).optional(), retentionDays: z.number().nullable().optional(), }); // Schema for project deletion response const ProjectDeletionResponseSchema = z.object({ success: z.boolean(), message: z.string(), }); // Schema for API keys response const ApiKeysResponseSchema = z.object({ apiKeys: z.array( z.object({ id: z.string(), createdAt: z.string().or(z.date()), expiresAt: z.string().or(z.date()).nullable(), lastUsedAt: z.string().or(z.date()).nullable(), note: z.string().nullable(), publicKey: z.string(), displaySecretKey: z.string().nullable(), }), ), }); // Schema for API key creation response const ApiKeyCreationResponseSchema = z.object({ id: z.string(), publicKey: z.string(), secretKey: z.string(), displaySecretKey: z.string(), note: z.string().nullable(), createdAt: z.string().or(z.date()), expiresAt: z.string().or(z.date()).optional(), }); // Schema for API key deletion response const ApiKeyDeletionResponseSchema = z.object({ success: z.boolean(), }); describe("Projects API", () => { // Test variables const projectId = "7a88fb47-b4e2-43b8-a06c-a5ce950dc53a"; const projectName = "Seed Project"; const projectApiKey = "pk-lf-1234567890"; const projectSecretKey = "sk-lf-1234567890"; const invalidApiKey = "pk-lf-invalid"; const invalidSecretKey = "sk-lf-invalid"; const orgApiKey = `pk-lf-org-${randomUUID().substring(0, 8)}`; const orgSecretKey = `sk-lf-org-${randomUUID().substring(0, 8)}`; beforeAll(async () => { await createAndAddApiKeysToDb({ prisma, entityId: "seed-org-id", scope: "ORGANIZATION", predefinedKeys: { publicKey: orgApiKey, secretKey: orgSecretKey, }, }); }); describe("GET /api/public/projects", () => { it("should return project data with valid project API key authentication", async () => { const response = await makeZodVerifiedAPICall( ProjectResponseSchema, "GET", "/api/public/projects", undefined, createBasicAuthHeader(projectApiKey, projectSecretKey), 200, // Expected status code is 200 OK ); expect(response.status).toBe(200); expect(response.body.data).toHaveLength(1); expect(response.body.data[0]).toMatchObject({ id: projectId, name: projectName, metadata: {}, }); }); it("should return 401 when invalid API keys are provided", async () => { const result = await makeAPICall( "GET", "/api/public/projects", undefined, createBasicAuthHeader(invalidApiKey, invalidSecretKey), ); expect(result.status).toBe(401); expect(result.body.message).toBeDefined(); }); it("should return 405 for non-GET methods", async () => { const result = await makeAPICall( "PUT", // Changed from POST to PUT since we now support POST "/api/public/projects", {}, createBasicAuthHeader(projectApiKey, projectSecretKey), ); expect(result.status).toBe(405); expect(result.body.message).toContain("Method not allowed"); }); it("should handle different authentication formats", async () => { // Test with Bearer token format const bearerResult = await makeAPICall( "GET", "/api/public/projects", undefined, `Bearer ${projectSecretKey}`, ); expect(bearerResult.status).toBe(401); // Test with just the secret key (no Bearer prefix) const secretKeyResult = await makeAPICall( "GET", "/api/public/projects", undefined, projectSecretKey, ); expect(secretKeyResult.status).toBe(401); }); }); describe("POST /api/public/projects", () => { // Clean up test projects after each test afterEach(async () => { // Delete any test projects created during tests await prisma.project.deleteMany({ where: { name: { startsWith: "Test Project", }, }, }); }); it("should create a new project with valid organization API key", async () => { const uniqueProjectName = `Test Project ${randomUUID().substring(0, 8)}`; const metadata = { plan: "pro", features: ["all"] }; const response = await makeZodVerifiedAPICall( ProjectCreationResponseSchema, "POST", "/api/public/projects", { name: uniqueProjectName, metadata: metadata, }, createBasicAuthHeader(orgApiKey, orgSecretKey), 201, // Expected status code is 201 Created ); expect(response.status).toBe(201); expect(response.body).toMatchObject({ name: uniqueProjectName, }); expect(response.body.metadata).toEqual(metadata); expect(response.body.id).toBeDefined(); // Verify the project was actually created in the database const project = await prisma.project.findUnique({ where: { id: response.body.id }, }); expect(project).not.toBeNull(); expect(project?.name).toBe(uniqueProjectName); expect(project?.metadata).toEqual(metadata); }); it("should create a new project with retention days", async () => { const uniqueProjectName = `Test Project ${randomUUID().substring(0, 8)}`; const response = await makeZodVerifiedAPICall( ProjectCreationResponseSchema, "POST", "/api/public/projects", { name: uniqueProjectName, retention: 0, // Setting retention to 0 (no retention) }, createBasicAuthHeader(orgApiKey, orgSecretKey), 201, // Expected status code is 201 Created ); expect(response.status).toBe(201); expect(response.body).toMatchObject({ name: uniqueProjectName, }); // Verify the project was created with the correct retention days const project = await prisma.project.findUnique({ where: { id: response.body.id }, }); expect(project).not.toBeNull(); expect(project?.retentionDays).toBe(0); }); it("should validate retention days", async () => { const uniqueProjectName = `Test Project ${randomUUID().substring(0, 8)}`; // Test with invalid retention days (less than 3 and not 0) const invalidResult = await makeAPICall( "POST", "/api/public/projects", { name: uniqueProjectName, retention: 2, // Invalid: less than 3 and not 0 }, createBasicAuthHeader(orgApiKey, orgSecretKey), ); expect(invalidResult.status).toBe(400); expect(invalidResult.body.message).toContain("Invalid retention value"); }); it("should return 403 when using project API key instead of organization API key", async () => { const uniqueProjectName = `Test Project ${randomUUID().substring(0, 8)}`; const result = await makeAPICall( "POST", "/api/public/projects", { name: uniqueProjectName, }, createBasicAuthHeader(projectApiKey, projectSecretKey), ); expect(result.status).toBe(403); expect(result.body.message).toContain( "Organization-scoped API key required", ); }); it("should return 401 when invalid API keys are provided", async () => { const uniqueProjectName = `Test Project ${randomUUID().substring(0, 8)}`; const result = await makeAPICall( "POST", "/api/public/projects", { name: uniqueProjectName, }, createBasicAuthHeader(invalidApiKey, invalidSecretKey), ); expect(result.status).toBe(401); expect(result.body.message).toContain("Invalid credentials"); }); it("should return 400 when project name is invalid", async () => { // Test with a name that's too short const shortNameResult = await makeAPICall( "POST", "/api/public/projects", { name: "AB", // Too short }, createBasicAuthHeader(orgApiKey, orgSecretKey), ); expect(shortNameResult.status).toBe(400); expect(shortNameResult.body.message).toContain("Invalid project name"); // Test with a name that's too long const longNameResult = await makeAPICall( "POST", "/api/public/projects", { name: "A".repeat(61), // Too long (more than 60 characters) }, createBasicAuthHeader(orgApiKey, orgSecretKey), ); expect(longNameResult.status).toBe(400); expect(longNameResult.body.message).toContain("Invalid project name"); }); it("should return 409 when project name already exists in the organization", async () => { const uniqueProjectName = `Test Project ${randomUUID().substring(0, 8)}`; // First create a project await makeAPICall( "POST", "/api/public/projects", { name: uniqueProjectName, }, createBasicAuthHeader(orgApiKey, orgSecretKey), ); // Try to create another project with the same name const duplicateResult = await makeAPICall( "POST", "/api/public/projects", { name: uniqueProjectName, }, createBasicAuthHeader(orgApiKey, orgSecretKey), ); expect(duplicateResult.status).toBe(409); expect(duplicateResult.body.message).toContain("already exists"); }); }); describe("PUT /api/public/projects/[projectId]", () => { let testProjectId: string; beforeEach(async () => { // Create a test project to update const uniqueProjectName = `Test Project ${randomUUID().substring(0, 8)}`; const project = await prisma.project.create({ data: { name: uniqueProjectName, orgId: "seed-org-id", // Same org ID used for the API key metadata: { plan: "free", features: ["basic"] }, }, }); testProjectId = project.id; }); afterEach(async () => { // Clean up test projects await prisma.project.deleteMany({ where: { id: testProjectId, }, }); }); it("should update project retention days with valid organization API key", async () => { const response = await makeZodVerifiedAPICall( ProjectUpdateResponseSchema, "PUT", `/api/public/projects/${testProjectId}`, { name: "Updated Project Name", retention: 7, // Valid retention value }, createBasicAuthHeader(orgApiKey, orgSecretKey), 200, // Expected status code is 200 OK ); expect(response.status).toBe(200); expect(response.body.name).toBe("Updated Project Name"); expect(response.body.retentionDays).toBe(7); // Verify the project was updated in the database const project = await prisma.project.findUnique({ where: { id: testProjectId }, }); expect(project).not.toBeNull(); expect(project?.name).toBe("Updated Project Name"); expect(project?.retentionDays).toBe(7); }); it("should validate retention days on update", async () => { // Test with invalid retention days (less than 3 and not 0) const invalidResult = await makeAPICall( "PUT", `/api/public/projects/${testProjectId}`, { name: "Updated Project Name", retention: 2, // Invalid: less than 3 and not 0 }, createBasicAuthHeader(orgApiKey, orgSecretKey), ); expect(invalidResult.status).toBe(400); expect(invalidResult.body.message).toContain("Invalid retention value"); }); it("should allow setting retention to 0", async () => { const response = await makeZodVerifiedAPICall( ProjectUpdateResponseSchema, "PUT", `/api/public/projects/${testProjectId}`, { name: "Updated Project Name", retention: 0, // Valid: 0 means no retention }, createBasicAuthHeader(orgApiKey, orgSecretKey), 200, // Expected status code is 200 OK ); expect(response.status).toBe(200); // Retentions with value 0 are not returned. expect(response.body.retentionDays).toBeUndefined(); }); it("should update project metadata", async () => { const newMetadata = { plan: "enterprise", features: ["all", "custom"] }; const response = await makeZodVerifiedAPICall( ProjectUpdateResponseSchema, "PUT", `/api/public/projects/${testProjectId}`, { name: "Updated Project Name", metadata: newMetadata, }, createBasicAuthHeader(orgApiKey, orgSecretKey), 200, // Expected status code is 200 OK ); expect(response.status).toBe(200); expect(response.body.name).toBe("Updated Project Name"); expect(response.body.metadata).toEqual(newMetadata); // Verify the project was updated in the database const project = await prisma.project.findUnique({ where: { id: testProjectId }, }); expect(project).not.toBeNull(); expect(project?.name).toBe("Updated Project Name"); expect(project?.metadata).toEqual(newMetadata); }); it("should return 403 when using project API key instead of organization API key", async () => { const result = await makeAPICall( "PUT", `/api/public/projects/${testProjectId}`, { retention: 7, }, createBasicAuthHeader(projectApiKey, projectSecretKey), ); expect(result.status).toBe(403); expect(result.body.message).toContain( "Organization-scoped API key required", ); }); it("should return 404 when project does not exist", async () => { const nonExistentProjectId = randomUUID(); const result = await makeAPICall( "PUT", `/api/public/projects/${nonExistentProjectId}`, { retention: 7, }, createBasicAuthHeader(orgApiKey, orgSecretKey), ); expect(result.status).toBe(404); expect(result.body.message).toContain("Project not found"); }); }); describe("DELETE /api/public/projects/[projectId]", () => { let testProjectId: string; beforeEach(async () => { // Create a test project to delete const uniqueProjectName = `Test Project ${randomUUID().substring(0, 8)}`; const project = await prisma.project.create({ data: { name: uniqueProjectName, orgId: "seed-org-id", // Same org ID used for the API key }, }); testProjectId = project.id; }); afterEach(async () => { // Clean up any remaining test projects await prisma.project.deleteMany({ where: { id: testProjectId, }, }); }); it("should delete a project with valid organization API key", async () => { const response = await makeZodVerifiedAPICall( ProjectDeletionResponseSchema, "DELETE", `/api/public/projects/${testProjectId}`, undefined, createBasicAuthHeader(orgApiKey, orgSecretKey), 202, // Expected status code is 202 Accepted ); expect(response.status).toBe(202); expect(response.body.success).toBe(true); expect(response.body.message).toContain("being processed asynchronously"); // Verify the project was marked as deleted in the database const project = await prisma.project.findUnique({ where: { id: testProjectId }, }); expect(project).not.toBeNull(); expect(project?.deletedAt).not.toBeNull(); }); it("should return 403 when using project API key instead of organization API key", async () => { const result = await makeAPICall( "DELETE", `/api/public/projects/${testProjectId}`, undefined, createBasicAuthHeader(projectApiKey, projectSecretKey), ); expect(result.status).toBe(403); expect(result.body.message).toContain( "Organization-scoped API key required", ); }); it("should return 401 when invalid API keys are provided", async () => { const result = await makeAPICall( "DELETE", `/api/public/projects/${testProjectId}`, undefined, createBasicAuthHeader(invalidApiKey, invalidSecretKey), ); expect(result.status).toBe(401); expect(result.body.message).toBeDefined(); }); it("should return 404 when project does not exist", async () => { const nonExistentProjectId = randomUUID(); const result = await makeAPICall( "DELETE", `/api/public/projects/${nonExistentProjectId}`, undefined, createBasicAuthHeader(orgApiKey, orgSecretKey), ); expect(result.status).toBe(404); expect(result.body.message).toContain("Project not found"); }); }); describe("GET /api/public/projects/[projectId]/apiKeys", () => { it("should return API keys with valid organization API key authentication", async () => { const response = await makeZodVerifiedAPICall( ApiKeysResponseSchema, "GET", `/api/public/projects/${projectId}/apiKeys`, undefined, createBasicAuthHeader(orgApiKey, orgSecretKey), 200, // Expected status code is 200 OK ); expect(response.status).toBe(200); expect(response.body.apiKeys.length).toBeGreaterThanOrEqual(1); expect(response.body.apiKeys[0]).toHaveProperty("id"); expect(response.body.apiKeys[0]).toHaveProperty("publicKey"); }); it("should return 401 when invalid API keys are provided", async () => { const result = await makeAPICall( "GET", `/api/public/projects/${projectId}/apiKeys`, undefined, createBasicAuthHeader(invalidApiKey, invalidSecretKey), ); expect(result.status).toBe(401); expect(result.body.message).toBeDefined(); }); it("should return 403 when using project API key instead of organization API key", async () => { const result = await makeAPICall( "GET", `/api/public/projects/${projectId}/apiKeys`, undefined, createBasicAuthHeader(projectApiKey, projectSecretKey), ); expect(result.status).toBe(403); expect(result.body.message).toContain( "Organization-scoped API key required", ); }); it("should return 404 when project does not exist", async () => { const nonExistentProjectId = randomUUID(); const result = await makeAPICall( "GET", `/api/public/projects/${nonExistentProjectId}/apiKeys`, undefined, createBasicAuthHeader(orgApiKey, orgSecretKey), ); expect(result.status).toBe(404); expect(result.body.message).toContain("Project not found"); }); it("should return 405 for non-GET/POST methods", async () => { const result = await makeAPICall( "PUT", `/api/public/projects/${projectId}/apiKeys`, {}, createBasicAuthHeader(orgApiKey, orgSecretKey), ); expect(result.status).toBe(405); expect(result.body.message).toContain("Method Not Allowed"); }); }); describe("POST /api/public/projects/[projectId]/apiKeys", () => { let createdApiKeyId: string; afterEach(async () => { // Clean up any API keys created during tests if (createdApiKeyId) { await prisma.apiKey.deleteMany({ where: { id: createdApiKeyId, }, }); createdApiKeyId = ""; } }); it("should create a new API key with valid organization API key", async () => { const note = `Test API Key ${randomUUID().substring(0, 8)}`; const response = await makeZodVerifiedAPICall( ApiKeyCreationResponseSchema, "POST", `/api/public/projects/${projectId}/apiKeys`, { note, }, createBasicAuthHeader(orgApiKey, orgSecretKey), 201, // Expected status code is 201 Created ); expect(response.status).toBe(201); expect(response.body).toHaveProperty("id"); expect(response.body).toHaveProperty("publicKey"); expect(response.body).toHaveProperty("secretKey"); expect(response.body.note).toBe(note); // Store the created API key ID for cleanup createdApiKeyId = response.body.id; // Verify the API key was actually created in the database const apiKey = await prisma.apiKey.findUnique({ where: { id: response.body.id }, }); expect(apiKey).not.toBeNull(); expect(apiKey?.note).toBe(note); expect(apiKey?.projectId).toBe(projectId); expect(apiKey?.scope).toBe("PROJECT"); }); it("should return 403 when using project API key instead of organization API key", async () => { const note = `Test API Key ${randomUUID().substring(0, 8)}`; const result = await makeAPICall( "POST", `/api/public/projects/${projectId}/apiKeys`, { note, }, createBasicAuthHeader(projectApiKey, projectSecretKey), ); expect(result.status).toBe(403); expect(result.body.message).toContain( "Organization-scoped API key required", ); }); it("should return 401 when invalid API keys are provided", async () => { const note = `Test API Key ${randomUUID().substring(0, 8)}`; const result = await makeAPICall( "POST", `/api/public/projects/${projectId}/apiKeys`, { note, }, createBasicAuthHeader(invalidApiKey, invalidSecretKey), ); expect(result.status).toBe(401); expect(result.body.message).toBeDefined(); }); it("should return 404 when project does not exist", async () => { const nonExistentProjectId = randomUUID(); const note = `Test API Key ${randomUUID().substring(0, 8)}`; const result = await makeAPICall( "POST", `/api/public/projects/${nonExistentProjectId}/apiKeys`, { note, }, createBasicAuthHeader(orgApiKey, orgSecretKey), ); expect(result.status).toBe(404); expect(result.body.message).toContain("Project not found"); }); }); describe("DELETE /api/public/projects/[projectId]/apiKeys/[apiKeyId]", () => { let deleteTestApiKeyId: string; beforeEach(async () => { // Create a test API key to delete const apiKeyMeta = await createAndAddApiKeysToDb({ prisma, entityId: projectId, scope: "PROJECT", note: `Delete Test API Key ${randomUUID().substring(0, 8)}`, }); deleteTestApiKeyId = apiKeyMeta.id; }); afterEach(async () => { // Clean up any remaining test API keys try { await prisma.apiKey.deleteMany({ where: { id: deleteTestApiKeyId, }, }); } catch (error) { // Ignore errors if the API key was already deleted by the test } }); it("should delete an API key with valid organization API key", async () => { const response = await makeZodVerifiedAPICall( ApiKeyDeletionResponseSchema, "DELETE", `/api/public/projects/${projectId}/apiKeys/${deleteTestApiKeyId}`, undefined, createBasicAuthHeader(orgApiKey, orgSecretKey), 200, // Expected status code is 200 OK ); expect(response.status).toBe(200); expect(response.body.success).toBe(true); // Verify the API key was actually deleted from the database const apiKey = await prisma.apiKey.findUnique({ where: { id: deleteTestApiKeyId }, }); expect(apiKey).toBeNull(); }); it("should return 403 when using project API key instead of organization API key", async () => { const result = await makeAPICall( "DELETE", `/api/public/projects/${projectId}/apiKeys/${deleteTestApiKeyId}`, undefined, createBasicAuthHeader(projectApiKey, projectSecretKey), ); expect(result.status).toBe(403); expect(result.body.message).toContain( "Organization-scoped API key required", ); }); it("should return 401 when invalid API keys are provided", async () => { const result = await makeAPICall( "DELETE", `/api/public/projects/${projectId}/apiKeys/${deleteTestApiKeyId}`, undefined, createBasicAuthHeader(invalidApiKey, invalidSecretKey), ); expect(result.status).toBe(401); expect(result.body.message).toBeDefined(); }); it("should return 404 when API key does not exist", async () => { const nonExistentApiKeyId = randomUUID(); const result = await makeAPICall( "DELETE", `/api/public/projects/${projectId}/apiKeys/${nonExistentApiKeyId}`, undefined, createBasicAuthHeader(orgApiKey, orgSecretKey), ); expect(result.status).toBe(404); expect(result.body.message).toContain("API key not found"); }); it("should return 404 when project does not exist", async () => { const nonExistentProjectId = randomUUID(); const result = await makeAPICall( "DELETE", `/api/public/projects/${nonExistentProjectId}/apiKeys/${deleteTestApiKeyId}`, undefined, createBasicAuthHeader(orgApiKey, orgSecretKey), ); expect(result.status).toBe(404); expect(result.body.message).toContain("Project not found"); }); }); it("should return 405 for unallowed methods", async () => { const result = await makeAPICall( "PATCH", `/api/public/projects/${projectId}`, undefined, createBasicAuthHeader(orgApiKey, orgSecretKey), ); expect(result.status).toBe(405); expect(result.body.message).toContain("Method not allowed"); }); });