/**
 * API-handler contract tests (M13).
 *
 * `ROUTE_CONTRACT` below is the checked-in contract for the entire REST
 * surface: every registered route (in registration order, which is semantic —
 * the router is first-match-wins) paired with the exact status an
 * UNAUTHENTICATED request must receive.
 *
 * What this guards against:
 *  - silently dropped/renamed routes (e.g. during a route-module refactor)
 *  - a protected route accidentally becoming public (401 → 2xx is a security
 *    regression and fails loudly here)
 *  - handlers that 500 on anonymous traffic instead of failing cleanly
 *  - error responses that stop emitting the `{ error: string }` JSON envelope
 *
 * How to update on an INTENTIONAL change: add/remove/edit the table entry in
 * the same PR that changes the route, so the diff documents the new contract.
 * A new route that serves anonymous traffic (expected status 2xx/3xx) must
 * also be added to `ANONYMOUS_SURFACE` with a one-line justification.
 *
 * Status legend:
 *   401      protected — handler rejects before any work happens
 *   400/404  reachable anonymously, but rejects the dummy input/params
 *   2xx/204  deliberately public — must be listed in ANONYMOUS_SURFACE
 */
import { beforeAll, describe, expect, it } from 'vitest';
import { createApiRouter } from '../../api/router.js';
import { registerCMSRoutes } from '../../api/handlers.js';
import { setActuateConfig } from '../../config/runtime.js';
import { initDB } from '../../db.js';
// ─── Harness ──────────────────────────────────────────────────────────
/**
 * A Prisma-shaped mock where every model delegate exists and returns empty
 * results. The sweep never authenticates, so protected handlers must reject
 * before touching the DB; public handlers see an empty-but-healthy database.
 */
function permissiveMockDb() {
    const delegate = {
        findUnique: async () => null,
        findFirst: async () => null,
        findMany: async () => [],
        count: async () => 0,
        aggregate: async () => ({}),
        groupBy: async () => [],
        create: async (a) => ({ id: 'x', ...a?.data }),
        update: async (a) => ({ id: 'x', ...a?.data }),
        updateMany: async () => ({ count: 0 }),
        upsert: async () => ({ id: 'x' }),
        delete: async () => ({ id: 'x' }),
        deleteMany: async () => ({ count: 0 }),
    };
    const base = {
        $queryRaw: async () => [{ ok: 1 }],
        $executeRaw: async () => 0,
        $transaction: async (fn) => typeof fn === 'function'
            ? fn(permissiveMockDb())
            : Promise.all(fn),
    };
    return new Proxy(base, {
        get(target, prop) {
            if (prop in target)
                return target[prop];
            if (typeof prop === 'string' && !prop.startsWith('$') && prop !== 'then')
                return delegate;
            return undefined;
        },
    });
}
const router = createApiRouter();
beforeAll(() => {
    process.env.CMS_SECRET = 'contract-suite-secret-0123456789abcdef';
    process.env.CRON_SECRET = 'cron-secret-for-contract-tests';
    setActuateConfig({
        collections: [
            {
                slug: 'pages',
                labels: { singular: 'Page', plural: 'Pages' },
                fields: [{ name: 'title', type: 'text' }],
            },
        ],
    });
    initDB(permissiveMockDb());
    registerCMSRoutes(router);
});
/** Fire an anonymous request at a route, substituting dummy path params. */
async function anonymousRequest(method, pattern) {
    const path = pattern.replace(/:([^/]+)/g, 'test-param');
    const init = method === 'GET' || method === 'OPTIONS'
        ? { method }
        : {
            method,
            headers: { 'content-type': 'application/json' },
            body: JSON.stringify({}),
        };
    // A handler that hangs on anonymous traffic (e.g. an SSE stream that does
    // not reject unauthenticated clients) should fail this suite cleanly
    // rather than stall the worker until the global test timeout.
    return Promise.race([
        router.handle(new Request(`https://example.com${path}`, init)),
        new Promise((_, reject) => setTimeout(() => reject(new Error(`${method} ${pattern} did not respond within 5s`)), 5000)),
    ]);
}
// ─── The contract ─────────────────────────────────────────────────────
const ROUTE_CONTRACT = [
    ['GET /auth/csrf', 200],
    ['GET /openapi.json', 200],
    ['GET /collections', 200],
    ['GET /docs', 200],
    ['POST /auth/login', 400],
    ['GET /dev/login', 404],
    ['POST /dev/login', 404],
    ['POST /auth/logout', 401],
    ['POST /auth/forgot-password', 400],
    ['POST /auth/reset-password', 400],
    ['GET /auth/me', 401],
    ['POST /auth/totp/setup', 401],
    ['POST /auth/totp/verify', 401],
    ['POST /auth/totp/disable', 401],
    ['POST /auth/totp/login', 400],
    ['GET /auth/oauth/:provider', 400],
    ['GET /auth/oauth/:provider/callback', 400],
    ['GET /collections/:slug', 401],
    // Registered before `:id` so the literal `field-values` segment wins.
    ['GET /collections/:slug/field-values', 401],
    ['GET /collections/:slug/:id', 401],
    ['POST /collections/:slug', 401],
    ['PUT /collections/:slug/:id', 401],
    ['DELETE /collections/:slug/:id', 401],
    ['GET /page-sections/types', 401],
    ['POST /page-sections/validate', 401],
    ['GET /page-sections/:collection/:id', 401],
    ['POST /page-sections/:collection/:id', 401],
    ['POST /page-sections/:collection/:id/reorder', 401],
    ['PATCH /page-sections/:collection/:id/:sectionId', 401],
    ['DELETE /page-sections/:collection/:id/:sectionId', 401],
    ['GET /post-templates/:type', 401],
    ['PUT /post-templates/:type', 401],
    ['GET /posts', 401],
    ['GET /media', 401],
    ['POST /media/presign', 401],
    ['POST /media/upload', 401],
    ['POST /media/:id/optimize', 401],
    ['PUT /media/:id', 401],
    ['DELETE /media/:id', 401],
    ['GET /updates/check', 401],
    ['GET /updates/config', 401],
    ['PUT /updates/config', 401],
    ['POST /updates/apply', 401],
    ['GET /captcha/config', 200],
    ['GET /plan', 401],
    ['GET /setup/status', 200],
    ['POST /setup/create-admin', 400],
    ['GET /health', 200],
    ['GET /stats', 401],
    ['GET /stats/content-health', 401],
    ['GET /stats/api-delivery', 401],
    ['GET /search/global', 401],
    ['GET /api-keys', 401],
    ['POST /api-keys', 401],
    ['DELETE /api-keys/:id', 401],
    ['GET /users', 401],
    ['GET /users/stats', 401],
    ['GET /users/search', 401],
    ['POST /users/invite', 401],
    ['DELETE /users/:id', 401],
    ['PATCH /users/:id/role', 401],
    ['GET /users/:id', 401],
    ['GET /users/:id/sessions', 401],
    ['DELETE /users/:id/sessions/:sessionId', 401],
    ['POST /users/:id/sessions/revoke-all', 401],
    ['GET /users/:id/activity', 401],
    ['GET /users/:id/permissions', 401],
    ['POST /users/:id/password-reset', 401],
    ['POST /users/:id/force-password-reset', 401],
    ['POST /users/:id/mfa-reset', 401],
    ['POST /users/:id/require-mfa', 401],
    ['POST /users/:id/suspend', 401],
    ['POST /users/:id/reactivate', 401],
    ['POST /users/:id/lock', 401],
    ['POST /users/:id/unlock', 401],
    ['POST /users/:id/transfer-content', 401],
    ['GET /invites', 401],
    ['POST /invites/:id/resend', 401],
    ['POST /invites/:id/cancel', 401],
    ['PATCH /invites/:id', 401],
    ['GET /access-review', 401],
    ['POST /access-review/:id/dismiss', 401],
    ['GET /forms', 401],
    ['GET /forms/:id/submissions', 401],
    ['OPTIONS /public/forms/:slug', 204],
    ['GET /public/forms/:slug', 404],
    ['OPTIONS /public/forms/:slug/submit', 204],
    ['POST /public/forms/:slug/submit', 404],
    ['POST /forms/:id/submit', 404],
    ['GET /forms/stats', 401],
    ['GET /forms/sidebar-counts', 401],
    ['GET /forms/entries/counts', 401],
    ['GET /forms/entries/export', 401],
    ['GET /forms/entries/:entryId', 401],
    ['PATCH /forms/entries/:entryId', 401],
    ['DELETE /forms/entries/:entryId', 401],
    ['POST /forms/entries/bulk', 401],
    ['GET /forms/entries', 401],
    ['POST /forms', 401],
    ['GET /forms/:id', 401],
    ['PATCH /forms/:id', 401],
    ['DELETE /forms/:id', 401],
    ['POST /forms/:id/duplicate', 401],
    ['GET /forms/:id/schema', 401],
    ['PUT /forms/:id/schema', 401],
    ['GET /forms/:id/notifications', 401],
    ['PUT /forms/:id/notifications', 401],
    ['PUT /forms/:id/notify', 401],
    ['GET /forms/:id/embed', 401],
    ['PUT /forms/:id/embed', 401],
    ['GET /forms/:id/webhooks', 401],
    ['POST /forms/:id/webhooks', 401],
    ['PATCH /forms/:id/webhooks/:webhookId', 401],
    ['DELETE /forms/:id/webhooks/:webhookId', 401],
    ['POST /forms/:id/webhooks/:webhookId/test', 401],
    ['POST /forms/:id/notifications/test', 401],
    ['GET /redirects', 401],
    ['POST /redirects', 401],
    ['DELETE /redirects/:id', 401],
    ['GET /seo/redirects', 401],
    ['GET /seo/redirects/overview', 401],
    ['GET /seo/redirects/chains', 401],
    ['POST /seo/redirects/flatten', 401],
    ['GET /seo/redirects/suggestions', 401],
    ['POST /seo/redirects/suggest', 401],
    ['POST /seo/redirects/suggestions/:id/accept', 401],
    ['POST /seo/redirects/suggestions/:id/dismiss', 401],
    ['GET /seo/redirects/export', 401],
    ['POST /seo/redirects/import', 401],
    ['POST /seo/redirects', 401],
    ['GET /seo/redirects/:id', 401],
    ['PUT /seo/redirects/:id', 401],
    ['DELETE /seo/redirects/:id', 401],
    ['GET /redirects/resolve', 200],
    ['GET /redirects/public', 200],
    ['POST /redirects/record-hit', 200],
    ['POST /redirects/record-404', 200],
    ['GET /seo/pages', 401],
    ['GET /seo/link-health', 401],
    ['POST /seo/scan', 401],
    ['POST /seo/audit', 401],
    ['GET /seo/audits', 401],
    ['GET /seo/audits/:id', 401],
    ['GET /seo/issues', 401],
    ['GET /seo/issues/export-brief', 401],
    ['POST /seo/issues/batch-content-briefs', 401],
    ['GET /seo/issues/:id/content-brief', 401],
    ['POST /seo/issues/:id/content-brief', 401],
    ['GET /seo/issues/:id/content-research', 401],
    ['POST /seo/issues/:id/content-research', 401],
    ['POST /seo/issues/:id/research-and-brief', 401],
    ['GET /seo/issues/:id', 401],
    ['POST /seo/issues/:id/resolve', 401],
    ['POST /seo/issues/:id/ignore', 401],
    ['POST /seo/issues/:id/reopen', 401],
    ['POST /seo/issues/:id/suggest-fix', 401],
    ['POST /seo/issues/:id/apply-fix', 401],
    ['GET /seo/overview', 401],
    ['GET /seo/search-performance', 401],
    ['GET /seo/content', 401],
    ['GET /seo/content/:entityType/:entityId', 401],
    ['PUT /seo/content/:entityType/:entityId', 401],
    ['GET /seo/analysis/:documentId', 401],
    ['GET /seo/readability/:documentId', 401],
    ['GET /seo/internal-links/:documentId', 401],
    ['GET /llms.txt', 200],
    ['GET /llms-full.txt', 404],
    ['GET /md', 400],
    ['GET /ai-crawlers/trap', 200],
    ['GET /ai-crawlers', 401],
    ['GET /sitemap.xml', 200],
    ['GET /sitemaps/:slug', 404],
    ['GET /feed.xml', 200],
    ['GET /feed.json', 200],
    ['GET /robots.txt', 200],
    // 404 here because INDEXNOW_KEY is unset in the test env (route is public but
    // disabled until a key is configured).
    ['GET /indexnow-key.txt', 404],
    ['GET /og.png', 200],
    ['GET /seo/schema/:documentId', 401],
    ['GET /seo/meta/:documentId', 401],
    ['GET /seo/summary', 401],
    ['GET /seo/config', 401],
    ['PUT /seo/config', 401],
    ['GET /seo/index-status', 401],
    ['GET /seo/sitemap/status', 401],
    ['POST /seo/sitemap/regenerate', 401],
    ['GET /seo/crawl-settings', 401],
    ['PUT /seo/crawl-settings', 401],
    ['GET /seo/robots', 401],
    ['PUT /seo/robots', 401],
    ['GET /resolve', 400],
    ['GET /folders', 401],
    ['POST /folders', 401],
    ['PUT /folders/:id', 401],
    ['DELETE /folders/:id', 401],
    ['PUT /documents/:id/folder', 401],
    ['PUT /media/:id/folder', 401],
    ['POST /preview/token', 401],
    ['GET /preview/:collection/:id', 401],
    ['GET /preview/stream', 401],
    ['POST /documents/:documentId/comments', 401],
    ['GET /documents/:documentId/comments', 401],
    ['PATCH /comments/:id', 401],
    ['POST /comments/:id/resolve', 401],
    ['POST /comments/:id/reopen', 401],
    ['DELETE /comments/:id', 401],
    ['GET /notifications', 401],
    ['GET /notifications/unread-count', 401],
    ['POST /notifications/:id/read', 401],
    ['POST /notifications/read-all', 401],
    ['GET /notifications/stream', 401],
    ['POST /collections/:slug/:id/workflow', 401],
    ['GET /collections/:slug/:id/workflow', 401],
    ['GET /collections/:slug/:id/versions', 401],
    ['POST /collections/:slug/:id/versions/:versionId/restore', 401],
    ['POST /collections/:slug/:id/schedule', 401],
    ['DELETE /collections/:slug/:id/schedule', 401],
    ['POST /scheduling/run', 401],
    ['GET /cron/publish', 401],
    ['POST /cron/publish', 401],
    ['GET /cron/cleanup', 401],
    ['POST /cron/cleanup', 401],
    ['GET /cron/seo-scan', 401],
    ['POST /cron/seo-scan', 401],
    ['GET /cron/content-health', 401],
    ['POST /cron/content-health', 401],
    ['GET /scheduling/calendar', 401],
    ['GET /public/globals/:slug', 404],
    ['GET /public/navigations/:slug', 404],
    ['GET /public/brand', 200],
    ['GET /public/brand/favicon', 404],
    ['GET /public/brand/apple-touch-icon', 404],
    ['GET /appearance', 401],
    ['GET /security', 401],
    ['GET /ai/status', 401],
    ['GET /ai/settings', 401],
    ['PUT /ai/settings', 401],
    ['POST /ai/providers', 401],
    ['PUT /ai/providers/:id', 401],
    ['DELETE /ai/providers/:id', 401],
    ['POST /ai/providers/:id/test', 401],
    ['POST /ai/providers/:id/sync-models', 401],
    ['GET /ai/models', 401],
    ['GET /ai/usage/summary', 401],
    ['POST /ai/brand-voice/analyze', 401],
    ['POST /ai/brand-voice/score', 401],
    ['POST /ai/brand-voice/fix', 401],
    ['PUT /security', 401],
    ['POST /security/sessions/revoke-all', 401],
    ['GET /globals/:slug', 401],
    ['PUT /globals/:slug', 401],
    ['GET /email/settings', 401],
    ['PUT /email/settings', 401],
    ['POST /email/settings/test', 401],
    ['GET /webhooks', 401],
    ['POST /webhooks', 401],
    ['PUT /webhooks/:id', 401],
    ['DELETE /webhooks/:id', 401],
    ['GET /webhooks/:id/deliveries', 401],
    ['POST /webhooks/retry', 401],
    ['GET /script-tags/resolve', 200],
    ['GET /script-tags', 401],
    ['POST /script-tags', 401],
    ['PUT /script-tags/:id', 401],
    ['DELETE /script-tags/:id', 401],
    ['GET /presence/:documentId', 401],
    ['POST /presence/:documentId/heartbeat', 401],
    ['GET /page-templates', 401],
    ['GET /page-templates/:id', 401],
    ['POST /page-templates', 401],
    ['PUT /page-templates/:id', 401],
    ['DELETE /page-templates/:id', 401],
    ['POST /page-templates/seed', 401],
    ['GET /saved-sections', 401],
    ['GET /saved-sections/:id', 401],
    ['POST /saved-sections', 401],
    ['PUT /saved-sections/:id', 401],
    ['DELETE /saved-sections/:id', 401],
    ['POST /page-builder/generate', 401],
    ['POST /page-builder/generate-block', 401],
    ['POST /page-builder/create', 401],
    ['POST /collections/:slug/ai-create', 401],
    ['POST /page-builder/audit-a11y', 401],
    ['POST /page-builder/fix-a11y', 401],
    ['POST /ai/audit-document', 401],
    ['POST /ai/coauthor', 401],
    ['POST /ai/suggest-internal-links', 401],
    ['POST /ai/media-metadata', 401],
    ['POST /ai/seo/generate-field', 401],
    ['POST /ai/seo/explain-issue', 401],
    ['POST /ai/seo/generate-schema', 401],
    ['POST /ai/seo/summarize', 401],
];
/**
 * Routes that deliberately answer anonymous requests with 2xx, and why.
 * Everything else that is not 401 is anonymous-reachable but rejects the
 * sweep's dummy input (400/404), which the table above already pins down.
 */
const ANONYMOUS_SURFACE = [
    ['GET /auth/csrf', 'issues the CSRF token cookie that login itself requires'],
    ['GET /openapi.json', 'public API discovery — schema-shape metadata only'],
    ['GET /collections', 'public discovery for agents/MCP, same data as OpenAPI'],
    ['GET /docs', 'public HTML API docs'],
    ['GET /captcha/config', 'public form embeds need the captcha site key'],
    ['GET /setup/status', 'setup wizard probe; reveals only whether setup ran'],
    ['GET /health', 'health probe for uptime monitors'],
    ['OPTIONS /public/forms/:slug', 'CORS preflight for cross-origin form embeds'],
    ['OPTIONS /public/forms/:slug/submit', 'CORS preflight for cross-origin form embeds'],
    ['GET /redirects/resolve', 'public-site middleware resolves redirects per-request'],
    ['GET /redirects/public', 'public-site middleware bulk-loads the redirect map'],
    ['POST /redirects/record-hit', 'public-site middleware logs redirect hits'],
    ['POST /redirects/record-404', 'public-site frontend logs broken links'],
    ['GET /llms.txt', 'public SEO artifact'],
    ['GET /ai-crawlers/trap', 'honeypot — records AI-crawler hits, served to anyone'],
    ['GET /sitemap.xml', 'public SEO artifact'],
    ['GET /feed.xml', 'public content feed'],
    ['GET /feed.json', 'public content feed'],
    ['GET /robots.txt', 'public SEO artifact'],
    ['GET /og.png', 'public Open Graph image'],
    ['GET /public/brand', 'public site brand settings (colors, logo)'],
    ['GET /script-tags/resolve', 'public site resolves enabled script tags'],
];
// ─── Tests ────────────────────────────────────────────────────────────
describe('route inventory contract', () => {
    it('registers exactly the expected routes, in registration order', () => {
        const actual = router.routes.map((r) => `${r.method} ${r.path}`);
        expect(actual).toEqual(ROUTE_CONTRACT.map(([route]) => route));
    });
    it('the contract table has no duplicate routes', () => {
        const routes = ROUTE_CONTRACT.map(([route]) => route);
        expect(new Set(routes).size).toBe(routes.length);
    });
    it('every 2xx route is documented in ANONYMOUS_SURFACE (and vice versa)', () => {
        const documented = ANONYMOUS_SURFACE.map(([route]) => route).sort();
        const anonymous2xx = ROUTE_CONTRACT.filter(([, status]) => status >= 200 && status < 300)
            .map(([route]) => route)
            .sort();
        expect(anonymous2xx).toEqual(documented);
    });
});
describe('unauthenticated status contract', () => {
    it.each(ROUTE_CONTRACT.map(([route, status]) => ({ route, status })))('$route -> $status', async ({ route, status }) => {
        const [method, pattern] = route.split(' ');
        const res = await anonymousRequest(method, pattern);
        expect(res.status).toBe(status);
        // 5xx on anonymous traffic is never acceptable, regardless of the table.
        expect(res.status, `${route} returned a server error`).toBeLessThan(500);
        // Error envelope contract: JSON error responses carry { error: string }.
        const contentType = res.headers.get('content-type') ?? '';
        if (res.status >= 400 && contentType.includes('application/json')) {
            const body = (await res.json());
            expect(typeof body.error, `${route} error body missing "error" key`).toBe('string');
        }
    });
});
//# sourceMappingURL=route-contracts.test.js.map