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
11,109 changes: 0 additions & 11,109 deletions apps/api/packages/docs/openapi.json

This file was deleted.

51 changes: 29 additions & 22 deletions apps/api/src/gen-openapi.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Script-style Jest spec: generates packages/docs/openapi.json using the same
// mocks as openapi-docs.spec.ts (no live DB or env vars needed).
// Skipped by default to avoid side effects in CI.
// Run manually with: cd apps/api && GEN_OPENAPI=1 npx jest src/gen-openapi.spec.ts
// Run manually with: cd apps/api && GEN_OPENAPI=1 bunx jest src/gen-openapi.spec.ts

// Mock better-auth ESM-only modules so Jest (CJS) can import AppModule's transitive AuthModule.
jest.mock('./auth/auth.server', () => ({
Expand All @@ -21,7 +21,12 @@ jest.mock('@thallesp/nestjs-better-auth', () => {
@Module({})
class AuthModuleStub {
static forRoot() {
return { module: AuthModuleStub, imports: [], providers: [], exports: [] };
return {
module: AuthModuleStub,
imports: [],
providers: [],
exports: [],
};
}
}
return { AuthModule: AuthModuleStub };
Expand Down Expand Up @@ -72,6 +77,7 @@ jest.mock('@db', () => {
organization: { findFirst: jest.fn(), findMany: jest.fn() },
auditLog: { create: jest.fn() },
trust: { findMany: jest.fn().mockResolvedValue([]) },
dynamicIntegration: { findMany: jest.fn().mockResolvedValue([]) },
apiKey: { findFirst: jest.fn() },
session: { findFirst: jest.fn() },
member: { findFirst: jest.fn() },
Expand Down Expand Up @@ -99,6 +105,13 @@ import { Test } from '@nestjs/testing';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { INestApplication, VersioningType } from '@nestjs/common';
import { AppModule } from './app.module';
import {
applyPublicOpenApiMetadata,
PUBLIC_OPENAPI_DESCRIPTION,
PUBLIC_OPENAPI_TITLE,
PUBLIC_SERVER_URL,
} from './openapi/public-docs-metadata';
import { collectPublicOpenApiIssues } from './openapi/public-docs-quality';

const shouldRun = process.env.GEN_OPENAPI === '1';
const maybeDescribe = shouldRun ? describe : describe.skip;
Expand All @@ -121,18 +134,9 @@ maybeDescribe('Generate openapi.json', () => {
});

it('writes openapi.json without excluded paths', () => {
const baseUrl = process.env.BASE_URL ?? 'http://localhost:3333';
const serverDescription = baseUrl.includes('api.staging.trycomp.ai')
? 'Staging API Server'
: baseUrl.includes('api.trycomp.ai')
? 'Production API Server'
: baseUrl.startsWith('http://localhost')
? 'Local API Server'
: 'API Server';

const config = new DocumentBuilder()
.setTitle('API Documentation')
.setDescription('The API documentation for this application')
.setTitle(PUBLIC_OPENAPI_TITLE)
.setDescription(PUBLIC_OPENAPI_DESCRIPTION)
.setVersion('1.0')
.addApiKey(
{
Expand All @@ -143,10 +147,10 @@ maybeDescribe('Generate openapi.json', () => {
},
'apikey',
)
.addServer(baseUrl, serverDescription)
.build();

const document = SwaggerModule.createDocument(app, config);
applyPublicOpenApiMetadata(document);

const openapiPath = path.join(
__dirname,
Expand All @@ -161,13 +165,16 @@ maybeDescribe('Generate openapi.json', () => {
writeFileSync(openapiPath, JSON.stringify(document, null, 2));
console.log(`OpenAPI documentation written to ${openapiPath}`);

// Verify excluded paths are absent
const hiddenPrefixes = ['/v1/auth', '/v1/admin', '/v1/internal'];
for (const prefix of hiddenPrefixes) {
const exposed = Object.keys(document.paths).filter((p) =>
p.startsWith(prefix),
);
expect(exposed).toEqual([]);
}
expect(document.servers).toEqual([
{
url: PUBLIC_SERVER_URL,
description: 'Production API Server',
},
]);

const issues = collectPublicOpenApiIssues(document);
expect(issues.excludedPaths).toEqual([]);
expect(issues.exposedTags).toEqual([]);
expect(issues.sensitiveSchemaDetails).toEqual([]);
});
});
11 changes: 9 additions & 2 deletions apps/api/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ import * as express from 'express';
import helmet from 'helmet';
import path from 'path';
import { AppModule } from './app.module';
import {
applyPublicOpenApiMetadata,
PUBLIC_OPENAPI_DESCRIPTION,
PUBLIC_OPENAPI_TITLE,
} from './openapi/public-docs-metadata';
import { isTrustedOrigin } from './auth/auth.server';
import { adminAuthRateLimiter } from './auth/admin-rate-limit.middleware';
import { originCheckMiddleware } from './auth/origin-check.middleware';
Expand Down Expand Up @@ -153,8 +158,8 @@ async function bootstrap(): Promise<void> {
const serverDescription = describeServer(baseUrl);

const config = new DocumentBuilder()
.setTitle('API Documentation')
.setDescription('The API documentation for this application')
.setTitle(PUBLIC_OPENAPI_TITLE)
.setDescription(PUBLIC_OPENAPI_DESCRIPTION)
.setVersion('1.0')
.addApiKey(
{
Expand All @@ -169,6 +174,8 @@ async function bootstrap(): Promise<void> {
.build();
const document: OpenAPIObject = SwaggerModule.createDocument(app, config);

applyPublicOpenApiMetadata(document);

// Setup Swagger UI at /api/docs
SwaggerModule.setup('api/docs', app, document, {
raw: ['json'],
Expand Down
97 changes: 73 additions & 24 deletions apps/api/src/openapi-docs.spec.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
// Mock better-auth ESM-only modules so Jest (CJS) can import AppModule's transitive AuthModule.
// These must appear before any imports so that Jest hoists them before module evaluation.

// Stub the auth instance so auth.server.ts never runs its top-level side effects
// (validateSecurityConfig, betterAuth(), Redis connection, etc.)
jest.mock('./auth/auth.server', () => ({
auth: {
api: {},
Expand All @@ -14,20 +12,23 @@ jest.mock('./auth/auth.server', () => ({
isStaticTrustedOrigin: () => false,
}));

// Stub the NestJS better-auth integration module
jest.mock('@thallesp/nestjs-better-auth', () => {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { Module } = require('@nestjs/common');
@Module({})
class AuthModuleStub {
static forRoot() {
return { module: AuthModuleStub, imports: [], providers: [], exports: [] };
return {
module: AuthModuleStub,
imports: [],
providers: [],
exports: [],
};
}
}
return { AuthModule: AuthModuleStub };
});

// Stub better-auth ESM-only packages (loaded by @trycompai/auth package)
jest.mock('better-auth/plugins/access', () => ({
createAccessControl: () => ({
newRole: () => ({}),
Expand Down Expand Up @@ -76,6 +77,7 @@ jest.mock('@db', () => {
organization: { findFirst: jest.fn(), findMany: jest.fn() },
auditLog: { create: jest.fn() },
trust: { findMany: jest.fn().mockResolvedValue([]) },
dynamicIntegration: { findMany: jest.fn().mockResolvedValue([]) },
apiKey: { findFirst: jest.fn() },
session: { findFirst: jest.fn() },
member: { findFirst: jest.fn() },
Expand All @@ -101,9 +103,20 @@ process.env.APP_AWS_BUCKET_NAME = 'test-bucket';
process.env.APP_AWS_REGION = 'us-east-1';

import { Test } from '@nestjs/testing';
import { DocumentBuilder, SwaggerModule, type OpenAPIObject } from '@nestjs/swagger';
import {
DocumentBuilder,
SwaggerModule,
type OpenAPIObject,
} from '@nestjs/swagger';
import { INestApplication, VersioningType } from '@nestjs/common';
import { AppModule } from './app.module';
import {
applyPublicOpenApiMetadata,
PUBLIC_OPENAPI_DESCRIPTION,
PUBLIC_OPENAPI_TITLE,
PUBLIC_SERVER_URL,
} from './openapi/public-docs-metadata';
import { collectPublicOpenApiIssues } from './openapi/public-docs-quality';

describe('OpenAPI document', () => {
let app: INestApplication;
Expand All @@ -119,37 +132,73 @@ describe('OpenAPI document', () => {
await app.init();

const config = new DocumentBuilder()
.setTitle('Test')
.setTitle(PUBLIC_OPENAPI_TITLE)
.setDescription(PUBLIC_OPENAPI_DESCRIPTION)
.setVersion('1.0')
.build();
document = SwaggerModule.createDocument(app, config);
applyPublicOpenApiMetadata(document);
});

afterAll(async () => {
if (app) await app.close();
});

const hiddenPrefixes = ['/v1/auth', '/v1/admin', '/v1/internal'];
describe('public metadata', () => {
it('uses production API servers in the generated Mintlify spec', () => {
expect(document.info.title).toBe(PUBLIC_OPENAPI_TITLE);
expect(document.info.description).toBe(PUBLIC_OPENAPI_DESCRIPTION);
expect(document.servers).toEqual([
{
url: PUBLIC_SERVER_URL,
description: 'Production API Server',
},
]);
});

it('keeps the public spec complete, SEO-ready, and free of private surfaces', () => {
const issues = collectPublicOpenApiIssues(document);

for (const prefix of hiddenPrefixes) {
it(`does not expose any path starting with ${prefix}`, () => {
const exposed = Object.keys(document.paths).filter((p) => p.startsWith(prefix));
expect(exposed).toEqual([]);
expect(issues.excludedPaths).toEqual([]);
expect(issues.exposedTags).toEqual([]);
expect(issues.invalidSeo).toEqual([]);
expect(issues.missingMetadata).toEqual([]);
expect(issues.missingSummaries).toEqual([]);
expect(issues.sensitiveSchemaDetails).toEqual([]);
});
}

describe('summaries', () => {
it('every public operation declares a non-empty summary', () => {
const missing: string[] = [];
for (const [routePath, methods] of Object.entries(document.paths)) {
for (const [method, op] of Object.entries(methods as Record<string, { summary?: string }>)) {
if (typeof op !== 'object' || !op) continue;
if (!op.summary || op.summary.trim() === '') {
missing.push(`${method.toUpperCase()} ${routePath}`);
it('curates high-value API pages with operation-specific SEO copy', () => {
expect(
document.paths['/v1/questionnaire/parse/upload/token'],
).toBeUndefined();

const upload = document.paths['/v1/questionnaire/parse/upload']?.post as
| {
summary?: string;
description?: string;
'x-mint'?: { href?: string; metadata?: { title?: string } };
}
| undefined;

expect(upload?.summary).toBe('Auto-answer uploaded questionnaire');
expect(upload?.description).toContain('approved organization evidence');
expect(upload?.['x-mint']?.href).toBe(
'/api-reference/questionnaire/upload-a-questionnaire-file-and-auto-answer-with-export',
);

const policies = document.paths['/v1/policies']?.get as
| {
summary?: string;
description?: string;
'x-mint'?: { metadata?: { title?: string } };
}
}
}
expect(missing).toEqual([]);
| undefined;

expect(policies?.summary).toBe('List compliance policies');
expect(policies?.description).toContain('SOC 2');
expect(policies?.['x-mint']?.metadata?.title).toBe(
'List compliance policies | Comp AI API',
);
});
});
});
Loading
Loading