Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 107 additions & 1 deletion src/apis.registration.test.ts
Original file line number Diff line number Diff line change
@@ -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' }));
Expand All @@ -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',
Expand Down Expand Up @@ -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<string, unknown>),
...(body as Record<string, unknown>),
});
}
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<string, unknown>),
...(body.data as Record<string, unknown>),
});
}
return origJson(body);
}) as typeof res.json;
next();
});
wrapper.use(inner);
return wrapper;
}

describe('POST /api/apis', () => {
Expand Down Expand Up @@ -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/);
});
});
26 changes: 20 additions & 6 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -181,8 +182,19 @@ export const createApp = (dependencies?: Partial<AppDependencies>) => {
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(),
Expand All @@ -201,9 +213,6 @@ export const createApp = (dependencies?: Partial<AppDependencies>) => {
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";
Expand Down Expand Up @@ -736,13 +745,14 @@ export const createApp = (dependencies?: Partial<AppDependencies>) => {
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,
Expand All @@ -751,6 +761,10 @@ export const createApp = (dependencies?: Partial<AppDependencies>) => {
})),
});

if (status === "active") {
listingsCache.invalidateAll();
}

res.status(201).json(successEnvelope(api, requestId));
} catch (err) {
next(err);
Expand Down
13 changes: 9 additions & 4 deletions src/routes/apis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ export function createApisRouter(deps: ApisRouterDeps = {}): Router {
}

res.set('ETag', etag);
res.json(cached);
res.json({ ...cached });
return;
}

Expand Down Expand Up @@ -264,7 +264,7 @@ export function createApisRouter(deps: ApisRouterDeps = {}): Router {
}

res.set('ETag', etag);
res.json(response);
res.json({ ...response });
} catch (error) {
next(error);
}
Expand Down Expand Up @@ -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,
Expand All @@ -356,14 +357,18 @@ export function createApisRouter(deps: ApisRouterDeps = {}): Router {
})),
});

if (status === "active") {
cache.invalidateAll();
}

await recordApiAudit(req, "API_CREATE", user.id, {
apiId: api.id,
before: null,
after: {
name: payload.name,
base_url: payload.base_url,
category: payload.category,
status: "active",
status,
endpointCount: payload.endpoints.length,
},
});
Expand Down
45 changes: 45 additions & 0 deletions src/validators/apiRegistration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
});
7 changes: 6 additions & 1 deletion src/validators/apis.ts
Original file line number Diff line number Diff line change
@@ -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+)?$/;

Expand All @@ -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"),
Expand Down