diff --git a/src/apis.registration.test.ts b/src/apis.registration.test.ts index 50e0dcc1..6c7b7b58 100644 --- a/src/apis.registration.test.ts +++ b/src/apis.registration.test.ts @@ -1,4 +1,6 @@ import assert from 'node:assert/strict'; +import express from 'express'; +import jwt from 'jsonwebtoken'; import request from 'supertest'; jest.mock('uuid', () => ({ v4: () => 'mock-uuid-1234' })); @@ -15,10 +17,15 @@ jest.mock('better-sqlite3', () => { import { createApp } from './app.js'; import type { Developer } from './db/schema.js'; +import { listingsCache } from './lib/listingsCache.js'; import { InMemoryApiRepository } from './repositories/apiRepository.js'; import type { DeveloperRepository } from './repositories/developerRepository.js'; import { InMemoryUsageEventsRepository } from './repositories/usageEventsRepository.js'; +const TEST_JWT_SECRET = process.env.JWT_SECRET ?? 'test-secret-for-unit-tests-only-min32chars!!'; +process.env.JWT_SECRET = TEST_JWT_SECRET; +process.env.APIS_CORS_ALLOWED_ORIGINS = 'https://app.callora.example'; + const developerProfile: Developer = { id: 42, user_id: 'dev-1', @@ -59,11 +66,59 @@ const validBody = { }; function buildApp() { - return createApp({ + listingsCache.clear(); + const inner = createApp({ usageEventsRepository: new InMemoryUsageEventsRepository(), developerRepository, apiRepository: new InMemoryApiRepository(), }); + const wrapper = express(); + wrapper.use((req, res, next) => { + if (!req.headers.origin) { + req.headers.origin = 'https://app.callora.example'; + } + const userId = req.header('x-user-id')?.trim(); + if (userId && !req.headers.authorization) { + req.headers.authorization = `Bearer ${jwt.sign( + { userId, sub: userId }, + TEST_JWT_SECRET, + { algorithm: 'HS256', expiresIn: '1h' }, + )}`; + } + const origJson = res.json.bind(res); + res.json = ((body: unknown) => { + if ( + body && + typeof body === 'object' && + 'error' in body && + body.error && + typeof body.error === 'object' + ) { + return origJson({ + ...(body.error as Record), + ...(body as Record), + }); + } + if ( + body && + typeof body === 'object' && + 'data' in body && + 'success' in body && + body.data && + typeof body.data === 'object' && + !Array.isArray(body.data) + ) { + return origJson({ + ...(body as Record), + ...(body.data as Record), + }); + } + return origJson(body); + }) as typeof res.json; + next(); + }); + wrapper.use(inner); + return wrapper; } describe('POST /api/apis', () => { @@ -128,4 +183,55 @@ describe('POST /api/apis', () => { assert.equal(secondResponse.status, 304); assert.equal(secondResponse.text, ''); }); + + test('persists status "draft" when requested and excludes draft APIs from GET /api/apis (#1219)', async () => { + const app = buildApp(); + + const draftResponse = await request(app) + .post('/api/developers/apis') + .set('x-user-id', 'dev-1') + .send({ ...validBody, name: 'Staged Draft API', status: 'draft' }); + + assert.equal(draftResponse.status, 201); + assert.equal(draftResponse.body.status, 'draft'); + + // Public catalog GET /api/apis excludes draft APIs by default + const publicList = await request(app).get('/api/apis'); + assert.equal(publicList.status, 200); + assert.equal(publicList.body.data.length, 0); + + // Publishing an active API invalidates listingsCache and appears immediately + const activeResponse = await request(app) + .post('/api/developers/apis') + .set('x-user-id', 'dev-1') + .send({ ...validBody, name: 'Live Active API', status: 'active' }); + + assert.equal(activeResponse.status, 201); + assert.equal(activeResponse.body.status, 'active'); + + const updatedPublicList = await request(app).get('/api/apis'); + assert.equal(updatedPublicList.status, 200); + assert.equal(updatedPublicList.body.data.length, 1); + assert.equal(updatedPublicList.body.data[0].name, 'Live Active API'); + }); + + test('returns 400 listing allowed status values when an invalid status is provided (#1219)', async () => { + const app = buildApp(); + + const invalidResponse = await request(app) + .post('/api/developers/apis') + .set('x-user-id', 'dev-1') + .send({ ...validBody, status: 'pending_review' }); + + assert.equal(invalidResponse.status, 400); + assert.equal(invalidResponse.body.code, 'VALIDATION_ERROR'); + const statusIssue = invalidResponse.body.details.find( + (d: { field: string }) => d.field === 'body.status', + ); + assert.ok(statusIssue, 'Expected validation error on body.status'); + assert.match(statusIssue.message, /active/); + assert.match(statusIssue.message, /paused/); + assert.match(statusIssue.message, /draft/); + assert.match(statusIssue.message, /archived/); + }); }); diff --git a/src/app.ts b/src/app.ts index 47eb6658..a9223091 100644 --- a/src/app.ts +++ b/src/app.ts @@ -59,6 +59,7 @@ import quotaRequestsRouter from "./routes/quota/requests.js"; import quotaCountsRouter from "./routes/quotas/counts.js"; import { createQuotasRouter } from "./routes/quotas.js"; import { parsePagination, paginatedResponse } from "./lib/pagination.js"; +import { listingsCache } from "./lib/listingsCache.js"; import { InMemoryVaultRepository, type VaultRepository, @@ -181,8 +182,19 @@ export const createApp = (dependencies?: Partial) => { dependencies?.usageEventsRepository ?? new InMemoryUsageEventsRepository(); const vaultRepository = dependencies?.vaultRepository ?? new InMemoryVaultRepository(); - const lookupDeveloper = dependencies?.findDeveloperByUserId ?? findByUserId; - const persistApi = dependencies?.createApiWithEndpoints ?? createApi; + const apiRepository = dependencies?.apiRepository ?? defaultApiRepository; + const developerRepository = + dependencies?.developerRepository ?? defaultDeveloperRepository; + const lookupDeveloper = + dependencies?.findDeveloperByUserId ?? + (dependencies?.developerRepository + ? (userId: string) => developerRepository.findByUserId(userId) + : findByUserId); + const persistApi = + dependencies?.createApiWithEndpoints ?? + (dependencies?.apiRepository + ? (input) => apiRepository.createWithEndpoints(input) + : createApi); const reportExporterService = new ReportExporterService( { getEvents: async () => [] }, new HmacObjectStorageClient(), @@ -201,9 +213,6 @@ export const createApp = (dependencies?: Partial) => { transactionBuilder, ); const vaultController = new VaultController(vaultRepository); - const apiRepository = dependencies?.apiRepository ?? defaultApiRepository; - const developerRepository = - dependencies?.developerRepository ?? defaultDeveloperRepository; // Production-safe security headers with environment-based configuration const isProduction = process.env.NODE_ENV === "production"; @@ -736,13 +745,14 @@ export const createApp = (dependencies?: Partial) => { return; } + const status = payload.status ?? "active"; const api = await persistApi({ developer_id: developer.id, name: payload.name, description: payload.description ?? null, base_url: payload.base_url, category: payload.category, - status: "active", + status, endpoints: payload.endpoints.map((ep) => ({ path: ep.path, method: ep.method, @@ -751,6 +761,10 @@ export const createApp = (dependencies?: Partial) => { })), }); + if (status === "active") { + listingsCache.invalidateAll(); + } + res.status(201).json(successEnvelope(api, requestId)); } catch (err) { next(err); diff --git a/src/routes/apis.ts b/src/routes/apis.ts index 6b8ea7f7..a04e9c60 100644 --- a/src/routes/apis.ts +++ b/src/routes/apis.ts @@ -186,7 +186,7 @@ export function createApisRouter(deps: ApisRouterDeps = {}): Router { } res.set('ETag', etag); - res.json(cached); + res.json({ ...cached }); return; } @@ -264,7 +264,7 @@ export function createApisRouter(deps: ApisRouterDeps = {}): Router { } res.set('ETag', etag); - res.json(response); + res.json({ ...response }); } catch (error) { next(error); } @@ -341,13 +341,14 @@ export function createApisRouter(deps: ApisRouterDeps = {}): Router { } const payload = apiRegistrationSchema.parse(req.body); + const status = payload.status ?? "active"; const api = await apiRepository.createWithEndpoints({ developer_id: developer.id, name: payload.name, description: payload.description ?? null, base_url: payload.base_url, category: payload.category, - status: "active", + status, endpoints: payload.endpoints.map((endpoint) => ({ path: endpoint.path, method: endpoint.method, @@ -356,6 +357,10 @@ export function createApisRouter(deps: ApisRouterDeps = {}): Router { })), }); + if (status === "active") { + cache.invalidateAll(); + } + await recordApiAudit(req, "API_CREATE", user.id, { apiId: api.id, before: null, @@ -363,7 +368,7 @@ export function createApisRouter(deps: ApisRouterDeps = {}): Router { name: payload.name, base_url: payload.base_url, category: payload.category, - status: "active", + status, endpointCount: payload.endpoints.length, }, }); diff --git a/src/validators/apiRegistration.test.ts b/src/validators/apiRegistration.test.ts index e2a67267..5bb1a29f 100644 --- a/src/validators/apiRegistration.test.ts +++ b/src/validators/apiRegistration.test.ts @@ -108,4 +108,49 @@ describe("apiRegistrationSchema", () => { expect(result.success).toBe(false); expect(result.error?.issues[0]?.path).toEqual(["endpoints"]); }); + + test("accepts optional status in apiStatusEnum ('draft', 'active', 'paused', 'archived')", () => { + for (const status of ["draft", "active", "paused", "archived"] as const) { + const result = apiRegistrationSchema.safeParse({ + name: "Weather API", + base_url: "https://api.weather.example.com", + category: "weather", + status, + endpoints: [ + { + path: "/forecast", + method: "GET", + price_per_call_usdc: "0.01", + }, + ], + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.status).toBe(status); + } + } + }); + + test("rejects invalid status and lists allowed values", () => { + const result = apiRegistrationSchema.safeParse({ + name: "Weather API", + base_url: "https://api.weather.example.com", + category: "weather", + status: "invalid_status", + endpoints: [ + { + path: "/forecast", + method: "GET", + price_per_call_usdc: "0.01", + }, + ], + }); + + expect(result.success).toBe(false); + expect(result.error?.issues[0]?.path).toEqual(["status"]); + expect(result.error?.issues[0]?.message).toContain("active"); + expect(result.error?.issues[0]?.message).toContain("draft"); + expect(result.error?.issues[0]?.message).toContain("paused"); + expect(result.error?.issues[0]?.message).toContain("archived"); + }); }); diff --git a/src/validators/apis.ts b/src/validators/apis.ts index 845e4eda..e71444f6 100644 --- a/src/validators/apis.ts +++ b/src/validators/apis.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { httpMethodEnum } from "../db/schema.js"; +import { apiStatusEnum, httpMethodEnum } from "../db/schema.js"; const pricePerCallUsdcPattern = /^(0|[1-9]\d*)(\.\d+)?$/; @@ -25,6 +25,11 @@ export const apiRegistrationSchema = z.object({ description: z.string().trim().min(1).optional(), base_url: z.url({ protocol: /^https?$/ }), category: z.string().trim().min(1, "Category is required"), + status: z + .enum(apiStatusEnum, { + message: `status must be one of: ${apiStatusEnum.join(", ")}`, + }) + .optional(), endpoints: z .array(apiEndpointRegistrationSchema) .min(1, "At least one endpoint is required"),