Skip to content
Merged
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
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,9 @@ jobs:
- name: Run ESLint
run: npm run lint

- name: Verify OpenAPI specification (no drift)
run: pnpm run openapi:check

npm-audit:
name: NPM Audit (High/Critical)
runs-on: ubuntu-latest
Expand Down
45 changes: 45 additions & 0 deletions backend/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
*/

import http from 'node:http';
import fs from 'node:fs';
import path from 'node:path';
import { timingSafeEqual } from 'node:crypto';
import { URL } from 'node:url';
import { makeExecutableSchema } from '@graphql-tools/schema';
Expand Down Expand Up @@ -581,6 +583,49 @@ export async function startServer(options: StartServerOptions = {}): Promise<Run
const proceed = await applyRateLimit(rateLimitMw, req, res, pathname);
if (!proceed) return; // 429 already sent

// -----------------------------------------------------------------
// OpenAPI specification and Swagger UI (Issue #1784)
// -----------------------------------------------------------------
if (pathname === '/api/docs/openapi.json' && method === 'GET') {
const specPath = path.join(__dirname, '../developer-portal/docs/openapi.json');
if (fs.existsSync(specPath)) {
const content = fs.readFileSync(specPath, 'utf8');
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(content);
return;
}
}

if ((pathname === '/api/docs' || pathname === '/api/docs/') && method === 'GET') {
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>SubTrackr API Explorer - OpenAPI 3.1</title>
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css" />
</head>
<body>
<div id="swagger-ui"></div>
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
<script>
window.onload = function() {
SwaggerUIBundle({
url: "/api/docs/openapi.json",
dom_id: '#swagger-ui',
deepLinking: true,
presets: [
SwaggerUIBundle.presets.apis,
SwaggerUIBundle.SwaggerUIStandalonePreset
]
});
};
</script>
</body>
</html>`);
return;
}

// -----------------------------------------------------------------
// Health Probes: Liveness & Readiness (bypass rate limiting)
// -----------------------------------------------------------------
Expand Down
6 changes: 6 additions & 0 deletions backend/server/createApiServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import { createLoggerFor } from '../services/shared/logging';
import { createTracerFromEnv } from '../services/shared/tracing';
import { createRequestObservability } from '../shared/middleware/requestObservability';
import { createApiKeyRevocationRouter, createPasskeyRouter } from '../services/auth/router/authRouter';
import { serveOpenApiSpec, serveSwaggerUI } from '../services/shared/swaggerUI';

// ── Zapier integration ────────────────────────────────────────────────────────
import {
Expand Down Expand Up @@ -258,6 +259,11 @@ export function createApiServer(options: CreateApiServerOptions = {}): Express {
// adds deprecation headers and rejects unknown or sunset versions.
app.use(createVersionMiddleware(versionRegistry) as unknown as express.RequestHandler);

// ── OpenAPI & Swagger UI (Issue #1784) ──────────────────────────────────────
app.get('/api/docs/openapi.json', serveOpenApiSpec);
app.get('/api/docs', serveSwaggerUI);
app.get('/api/docs/', serveSwaggerUI);

// ── CDN-cached public routes ───────────────────────────────────────────────
app.use(cacheHeadersMiddleware());
app.use(createPublicApiRouter());
Expand Down
54 changes: 54 additions & 0 deletions backend/services/shared/__tests__/generateOpenApi.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { generateOpenApiSpec, generateSpecs } from '../../../../scripts/generate-openapi';
import YAML from 'yaml';

describe('OpenAPI specification generator (issue #1784)', () => {
it('generates a valid OpenAPI 3.1.0 specification', () => {
const spec = generateOpenApiSpec() as any;
expect(spec.openapi).toBe('3.1.0');
expect(spec.info.title).toBe('SubTrackr API');
expect(spec.info.version).toBe('1.0.0');
});

it('contains all required paths and tags', () => {
const spec = generateOpenApiSpec() as any;
expect(spec.paths['/plans']).toBeDefined();
expect(spec.paths['/subscriptions']).toBeDefined();
expect(spec.paths['/payments']).toBeDefined();
expect(spec.paths['/webhooks']).toBeDefined();
expect(spec.tags.length).toBeGreaterThan(0);
});

it('incorporates source schemas into components.schemas', () => {
const spec = generateOpenApiSpec() as any;
const schemas = spec.components.schemas;
expect(schemas.PlanInput).toBeDefined();
expect(schemas.PlanUpdate).toBeDefined();
expect(schemas.SubscriptionInput).toBeDefined();
expect(schemas.SubscriptionUpdate).toBeDefined();
expect(schemas.PaymentInput).toBeDefined();
expect(schemas.WebhookInput).toBeDefined();
expect(schemas.StandardErrorEnvelope).toBeDefined();
});

it('includes cursor pagination and standard error envelopes in responses', () => {
const spec = generateOpenApiSpec() as any;
const plansGet = spec.paths['/plans'].get;
expect(plansGet.parameters.some((p: any) => p.name === 'cursor')).toBe(true);

const validationResponse = spec.components.responses.ValidationError;
expect(validationResponse).toBeDefined();
expect(validationResponse.content['application/json'].schema.$ref).toBe(
'#/components/schemas/StandardErrorEnvelope'
);
});

it('generates valid JSON and YAML strings', () => {
const { json, yaml } = generateSpecs();
const parsedJson = JSON.parse(json);
const parsedYaml = YAML.parse(yaml);

expect(parsedJson.openapi).toBe('3.1.0');
expect(parsedYaml.openapi).toBe('3.1.0');
expect(parsedJson.info.title).toBe(parsedYaml.info.title);
});
});
212 changes: 212 additions & 0 deletions backend/services/shared/__tests__/validationMiddleware.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
import { z } from 'zod';
import {
createValidationMiddleware,
validateRequest,
commonSchemas,
} from '../validationMiddleware';

describe('validationMiddleware (#1786 Request Validation Middleware)', () => {
const sampleBodySchema = z.object({
name: z.string().min(2),
price: z.number().positive(),
currency: z.string().length(3),
});

const sampleQuerySchema = z.object({
limit: z.coerce.number().min(1).max(100).default(20),
search: z.string().optional(),
});

const sampleParamsSchema = z.object({
id: z.string().min(1),
});

describe('validateRequest function', () => {
it('succeeds with valid body, query, and params', () => {
const data = {
body: { name: 'Pro Plan', price: 29.99, currency: 'USD' },
query: { limit: '10', search: 'pro' },
params: { id: 'plan_123' },
};

const result = validateRequest(data, {
body: sampleBodySchema,
query: sampleQuerySchema,
params: sampleParamsSchema,
});

expect(result.success).toBe(true);
expect(result.data?.body).toEqual({ name: 'Pro Plan', price: 29.99, currency: 'USD' });
expect(result.data?.query).toEqual({ limit: 10, search: 'pro' });
expect(result.data?.params).toEqual({ id: 'plan_123' });
});

it('returns field-level errors when body fails validation', () => {
const data = {
body: { name: 'A', price: -5, currency: 'TOOLONG' },
};

const result = validateRequest(data, {
body: sampleBodySchema,
});

expect(result.success).toBe(false);
expect(result.errors).toBeDefined();
expect(result.errors?.length).toBeGreaterThanOrEqual(3);
const fields = result.errors?.map((e) => e.field);
expect(fields).toContain('body.name');
expect(fields).toContain('body.price');
expect(fields).toContain('body.currency');
});

it('detects SQL injection and rejects', () => {
const data = {
body: { name: "admin'; DROP TABLE plans; --", price: 10, currency: 'USD' },
};

const result = validateRequest(
data,
{ body: sampleBodySchema },
{ detectSqlInjection: true }
);

expect(result.success).toBe(false);
expect(result.errors?.[0]?.code).toBe('SQL_INJECTION_DETECTED');
});

it('sanitizes XSS strings by default', () => {
const data = {
body: { name: '<b>Plan</b>', price: 10, currency: 'USD' },
};

const result = validateRequest(
data,
{ body: sampleBodySchema },
{ sanitizeXss: true }
);

expect(result.success).toBe(true);
expect((result.data?.body as any).name).toBe('Plan');
});
});

describe('createValidationMiddleware Express-style middleware', () => {
it('passes valid request through to next()', () => {
const middleware = createValidationMiddleware({
body: sampleBodySchema,
});

const req: any = {
method: 'POST',
headers: { 'x-request-id': 'req-test-1' },
body: { name: 'Enterprise', price: 99.99, currency: 'USD' },
};
const res: any = {
status: jest.fn().mockReturnThis(),
json: jest.fn(),
};
const next = jest.fn();

middleware(req, res, next);

expect(next).toHaveBeenCalledTimes(1);
expect(res.status).not.toHaveBeenCalled();
});

it('returns 422 with standard error envelope when validation fails (Express style)', () => {
const middleware = createValidationMiddleware({
body: sampleBodySchema,
});

const req: any = {
method: 'POST',
headers: { 'x-request-id': 'req-err-1' },
body: { name: 'X', price: -10, currency: 'US' },
};
const res: any = {
status: jest.fn().mockReturnThis(),
json: jest.fn(),
};
const next = jest.fn();

middleware(req, res, next);

expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(422);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({
success: false,
error: expect.objectContaining({
code: 'VALIDATION_ERROR',
message: 'Request validation failed',
details: expect.any(Object),
}),
meta: expect.objectContaining({
requestId: 'req-err-1',
apiVersion: 1,
}),
})
);
});

it('returns 422 using writeHead/end for raw Node http.ServerResponse', () => {
const middleware = createValidationMiddleware({
body: sampleBodySchema,
});

const req: any = {
method: 'POST',
body: { name: 'X', price: -10, currency: 'US' },
};
let writtenStatus = 0;
let writtenBody = '';
const res: any = {
writeHead: jest.fn((code: number) => {
writtenStatus = code;
}),
end: jest.fn((bodyStr: string) => {
writtenBody = bodyStr;
}),
};
const next = jest.fn();

middleware(req, res, next);

expect(next).not.toHaveBeenCalled();
expect(writtenStatus).toBe(422);
const parsed = JSON.parse(writtenBody);
expect(parsed.success).toBe(false);
expect(parsed.error.code).toBe('VALIDATION_ERROR');
});

it('returns 413 when body exceeds maxBodySize limit', () => {
const middleware = createValidationMiddleware(
{ body: sampleBodySchema },
{ maxBodySize: 50 }
);

const req: any = {
method: 'POST',
body: { name: 'A very large plan name that is definitely way too long to fit', price: 10, currency: 'USD' },
};
const res: any = {
status: jest.fn().mockReturnThis(),
json: jest.fn(),
};
const next = jest.fn();

middleware(req, res, next);

expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(413);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({
success: false,
error: expect.objectContaining({
code: 'BAD_REQUEST',
}),
})
);
});
});
});
16 changes: 15 additions & 1 deletion backend/services/shared/apiResponse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -323,7 +323,21 @@ export function fail(
requestId?: string,
details?: Record<string, string> | { version?: number },
): ApiErrorResponse {
const errorPayload: ApiError = { code, message, ...details };
const errorPayload: ApiError = { code, message };
if (details) {
if ('version' in details && typeof (details as { version?: unknown }).version === 'number') {
errorPayload.version = (details as { version: number }).version;
}
const fieldDetails: Record<string, string> = {};
for (const [k, v] of Object.entries(details)) {
if (k !== 'version' && typeof v === 'string') {
fieldDetails[k] = v;
}
}
if (Object.keys(fieldDetails).length > 0) {
errorPayload.details = fieldDetails;
}
}
return {
success: false,
error: errorPayload,
Expand Down
Loading
Loading