119 lines
5.3 KiB
TypeScript
119 lines
5.3 KiB
TypeScript
import { describe, expect, test } from "vitest";
|
|
|
|
import { openApiDocument } from "../apps/api/src/openapi.js";
|
|
import { renderOpenApiSecurityGuardResult, validateOpenApiSecurity, type OpenApiDocument } from "./openapi-guard.js";
|
|
|
|
function cloneDocument(): OpenApiDocument {
|
|
return structuredClone(openApiDocument) as OpenApiDocument;
|
|
}
|
|
|
|
function operation(document: OpenApiDocument, path: string, method: string) {
|
|
const pathItem = document.paths?.[path];
|
|
if (!pathItem) throw new Error(`Missing fixture path ${path}`);
|
|
|
|
const candidate = pathItem[method];
|
|
if (typeof candidate !== "object" || candidate === null || Array.isArray(candidate)) {
|
|
throw new Error(`Missing fixture operation ${method.toUpperCase()} ${path}`);
|
|
}
|
|
|
|
return candidate as Record<string, unknown>;
|
|
}
|
|
|
|
describe("openapi security guard", () => {
|
|
test("passes the current OpenAPI document without mutating it", () => {
|
|
const document = cloneDocument();
|
|
const before = JSON.stringify(document);
|
|
|
|
const result = validateOpenApiSecurity(document);
|
|
|
|
expect(result).toEqual({ ok: true, violations: [] });
|
|
expect(JSON.stringify(document)).toEqual(before);
|
|
expect(renderOpenApiSecurityGuardResult(result)).toMatch(/^PASS openapi-security/);
|
|
});
|
|
|
|
test("fails when an operation references an undeclared security scheme", () => {
|
|
const document = cloneDocument();
|
|
operation(document, "/v1/session/current-user", "get").security = [{ missingScheme: [] }];
|
|
|
|
const result = validateOpenApiSecurity(document);
|
|
|
|
expect(result.ok).toBe(false);
|
|
expect(result.violations).toContain("GET /v1/session/current-user getCurrentUserSession: references undeclared security scheme 'missingScheme'");
|
|
});
|
|
|
|
test("fails when a protected operation omits non-empty security", () => {
|
|
const document = cloneDocument();
|
|
delete operation(document, "/v1/session/current-user", "get").security;
|
|
|
|
const result = validateOpenApiSecurity(document);
|
|
|
|
expect(result.ok).toBe(false);
|
|
expect(result.violations).toContain("GET /v1/session/current-user getCurrentUserSession: protected operation must declare non-empty security");
|
|
});
|
|
|
|
test("fails when the document has no operation surface", () => {
|
|
const result = validateOpenApiSecurity({ components: { securitySchemes: { bearerAuth: {}, apiKeyHeader: {} } }, paths: {} });
|
|
|
|
expect(result.ok).toBe(false);
|
|
expect(result.violations).toContain("OpenAPI document must define at least one path");
|
|
expect(result.violations).toContain("OpenAPI document must define at least one operation");
|
|
});
|
|
|
|
test("fails when required security schemes are missing", () => {
|
|
const document = cloneDocument();
|
|
document.components = { securitySchemes: {} };
|
|
|
|
const result = validateOpenApiSecurity(document);
|
|
|
|
expect(result.ok).toBe(false);
|
|
expect(result.violations).toContain("OpenAPI document must declare security scheme 'bearerAuth'");
|
|
expect(result.violations).toContain("OpenAPI document must declare security scheme 'apiKeyHeader'");
|
|
});
|
|
|
|
test("enforces public and optional-anonymous allowlist behavior", () => {
|
|
const publicDocument = cloneDocument();
|
|
operation(publicDocument, "/healthz", "get").security = [{ bearerAuth: [] }];
|
|
|
|
expect(validateOpenApiSecurity(publicDocument).violations).toContain(
|
|
"GET /healthz getHealthz: public allowlisted operation must omit security or declare security: []"
|
|
);
|
|
|
|
const optionalDocument = cloneDocument();
|
|
expect(validateOpenApiSecurity(optionalDocument).ok).toBe(true);
|
|
|
|
const anonymousProtectedDocument = cloneDocument();
|
|
operation(anonymousProtectedDocument, "/v1/session/current-user", "get").security = [{ bearerAuth: [] }, {}];
|
|
|
|
expect(validateOpenApiSecurity(anonymousProtectedDocument).violations).toContain(
|
|
"GET /v1/session/current-user getCurrentUserSession: anonymous security requirement is only allowed for optional-auth allowlisted operations"
|
|
);
|
|
});
|
|
|
|
test("keeps the global analytics overview public without making the ingest token a read credential", () => {
|
|
const document = cloneDocument();
|
|
operation(document, "/v1/analytics/public", "get").security = [{ analyticsToken: [] }];
|
|
|
|
expect(validateOpenApiSecurity(document).violations).toContain(
|
|
"GET /v1/analytics/public getPublicAnalyticsOverview: public allowlisted operation must omit security or declare security: []"
|
|
);
|
|
});
|
|
|
|
test("documents Aircall body-token authentication without inventing a header security scheme", () => {
|
|
const document = cloneDocument();
|
|
expect(validateOpenApiSecurity(document).ok).toBe(true);
|
|
|
|
operation(document, "/v1/webhooks/telephony/aircall", "post").security = [{ bearerAuth: [] }];
|
|
|
|
expect(validateOpenApiSecurity(document).violations).toContain(
|
|
"POST /v1/webhooks/telephony/aircall receiveAircallTelephonyWebhook: request-body credential operation must omit security or declare security: []"
|
|
);
|
|
|
|
operation(document, "/v1/webhooks/telephony/aircall", "post").security = [];
|
|
operation(document, "/v1/webhooks/admin-telephony/aircall", "post").security = [{ bearerAuth: [] }];
|
|
|
|
expect(validateOpenApiSecurity(document).violations).toContain(
|
|
"POST /v1/webhooks/admin-telephony/aircall receiveAdminAircallTelephonyWebhook: request-body credential operation must omit security or declare security: []"
|
|
);
|
|
});
|
|
});
|