258 lines
8.9 KiB
TypeScript
258 lines
8.9 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { compareApiDescriptions, compareApis } from "../../src/core/compare";
|
|
import { validateHarAgainstOpenApi } from "../../src/core/contract";
|
|
import { inspectHar, inspectRawExchange } from "../../src/core/har";
|
|
import {
|
|
collectOperations,
|
|
operationExample,
|
|
securityInventory,
|
|
} from "../../src/core/operations";
|
|
import { parseApiDocument } from "../../src/core/parse";
|
|
import { createWorkspace } from "../../src/core/refs";
|
|
|
|
const SPEC = `openapi: 3.1.0
|
|
info: { title: T, version: '1' }
|
|
servers: [{ url: "https://api.example.test" }]
|
|
paths:
|
|
/items/{id}:
|
|
get:
|
|
operationId: readItem
|
|
parameters:
|
|
- { name: id, in: path, required: true, schema: { type: string, example: "a'b" } }
|
|
responses:
|
|
"200": { description: ok, content: { application/json: { schema: { type: object, properties: { id: {type: string} } } } } }
|
|
components:
|
|
securitySchemes: { key: { type: apiKey, in: header, name: X-Key } }
|
|
`;
|
|
|
|
describe("operation derivation, comparisons and saved exchanges", () => {
|
|
it("collects operations, schemes and safely quoted inert commands", () => {
|
|
const document = parseApiDocument(SPEC, "openapi.yaml");
|
|
const operations = collectOperations(document.value);
|
|
expect(operations.map((item) => item.key)).toEqual(["GET /items/{id}"]);
|
|
const example = operationExample(createWorkspace(document), operations[0]!);
|
|
expect(example.url).toContain("a'b");
|
|
expect(example.commands.curl).toContain("curl --request GET");
|
|
expect(example.commands.fetch).toContain("fetch(");
|
|
expect(securityInventory(document.value)).toEqual([
|
|
{ name: "key", type: "apiKey", detail: "header · X-Key" },
|
|
]);
|
|
});
|
|
|
|
it("inventories and compares OpenAPI 3.1 webhook receiver operations", () => {
|
|
const document = parseApiDocument(`openapi: 3.1.0
|
|
info: { title: Hooks, version: '1' }
|
|
paths: {}
|
|
webhooks:
|
|
orderChanged:
|
|
post:
|
|
operationId: receiveOrderChange
|
|
requestBody:
|
|
content:
|
|
application/json:
|
|
schema: { type: object, properties: { id: { type: string } } }
|
|
responses: { '204': { description: accepted } }
|
|
`);
|
|
const operations = collectOperations(document.value);
|
|
expect(operations).toEqual([
|
|
expect.objectContaining({
|
|
key: "WEBHOOK orderChanged POST",
|
|
kind: "webhook",
|
|
method: "POST",
|
|
path: "orderChanged",
|
|
}),
|
|
]);
|
|
const example = operationExample(createWorkspace(document), operations[0]!);
|
|
expect(example.url).toBe(
|
|
"https://receiver.example.test/webhooks/orderChanged",
|
|
);
|
|
expect(example.notices.join(" ")).toMatch(/placeholder/iu);
|
|
|
|
const removed = parseApiDocument(
|
|
"openapi: 3.1.0\ninfo: {title: Hooks, version: '2'}\npaths: {}\n",
|
|
);
|
|
expect(compareApis(document, removed)).toContainEqual(
|
|
expect.objectContaining({
|
|
level: "breaking",
|
|
path: "WEBHOOK orderChanged POST",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("reports a missing local reference without aborting generation", () => {
|
|
const document = parseApiDocument(
|
|
"openapi: 3.1.0\ninfo: {title: Missing, version: '1'}\npaths: {/x: {get: {responses: {'200': {description: ok, content: {application/json: {schema: {$ref: 'missing.yaml#/Result'}}}}}}}}",
|
|
"openapi.yaml",
|
|
);
|
|
const operation = collectOperations(document.value)[0]!;
|
|
const example = operationExample(createWorkspace(document), operation);
|
|
expect(example.response).toBeUndefined();
|
|
expect(example.notices.join(" ")).toMatch(/missing\.yaml/iu);
|
|
});
|
|
|
|
it("classifies removed operations and additions", () => {
|
|
const before = parseApiDocument(SPEC, "before.yaml");
|
|
const after = parseApiDocument(
|
|
SPEC.replace(" get:", " post:"),
|
|
"after.yaml",
|
|
);
|
|
expect(compareApis(before, after)).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ level: "breaking", path: "GET /items/{id}" }),
|
|
expect.objectContaining({
|
|
level: "non-breaking",
|
|
path: "POST /items/{id}",
|
|
}),
|
|
]),
|
|
);
|
|
});
|
|
|
|
it("reports directional request and response schema regressions", () => {
|
|
const before = parseApiDocument(`openapi: 3.1.0
|
|
info: { title: Contract, version: '1' }
|
|
paths:
|
|
/items:
|
|
post:
|
|
requestBody:
|
|
content:
|
|
application/json:
|
|
schema: { type: object, properties: { kind: { type: string, enum: [a, b] } } }
|
|
responses:
|
|
'200':
|
|
description: ok
|
|
content:
|
|
application/json:
|
|
schema: { type: object, required: [id], properties: { id: { type: string } } }
|
|
`);
|
|
const after = parseApiDocument(`openapi: 3.1.0
|
|
info: { title: Contract, version: '2' }
|
|
paths:
|
|
/items:
|
|
post:
|
|
requestBody:
|
|
content:
|
|
application/json:
|
|
schema: { type: object, required: [kind], properties: { kind: { type: string, enum: [a] } } }
|
|
responses:
|
|
'200':
|
|
description: ok
|
|
content:
|
|
application/json:
|
|
schema: { type: object, properties: {} }
|
|
`);
|
|
const changes = compareApis(before, after);
|
|
expect(changes).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
level: "breaking",
|
|
message: expect.stringContaining("Request enum removed"),
|
|
}),
|
|
expect.objectContaining({
|
|
level: "breaking",
|
|
message: expect.stringContaining("became required"),
|
|
}),
|
|
expect.objectContaining({
|
|
level: "breaking",
|
|
message: expect.stringContaining("Required response property"),
|
|
}),
|
|
]),
|
|
);
|
|
});
|
|
|
|
it("collects OpenAPI 3.2 QUERY and custom methods", () => {
|
|
const document = parseApiDocument(
|
|
`openapi: 3.2.0
|
|
info: { title: Extended, version: '1' }
|
|
paths:
|
|
/items:
|
|
query: { responses: { '200': { description: ok } } }
|
|
additionalOperations:
|
|
COPY: { responses: { '204': { description: copied } } }
|
|
`,
|
|
);
|
|
expect(collectOperations(document.value).map((item) => item.key)).toEqual([
|
|
"QUERY /items",
|
|
"COPY /items",
|
|
]);
|
|
});
|
|
|
|
it("compares AsyncAPI operations without treating them as HTTP paths", () => {
|
|
const before = parseApiDocument(
|
|
"asyncapi: 3.1.0\ninfo: {title: E, version: '1'}\nchannels: { c: {address: events, messages: {}} }\noperations: { send: {action: send, channel: {$ref: '#/channels/c'}} }",
|
|
);
|
|
const after = parseApiDocument(
|
|
"asyncapi: 3.1.0\ninfo: {title: E, version: '2'}\nchannels: { c: {address: events, messages: {}} }\noperations: {}",
|
|
);
|
|
expect(compareApiDescriptions(before, after)).toContainEqual(
|
|
expect.objectContaining({
|
|
level: "breaking",
|
|
message: "Operation was removed.",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("summarizes HAR and raw exchanges without exposing headers", () => {
|
|
const har = inspectHar(
|
|
'{"log":{"entries":[{"time":7,"request":{"method":"POST","url":"https://example.test/x","headers":[{"name":"Authorization","value":"secret"}],"postData":{"text":"abc"}},"response":{"status":201,"headers":[],"content":{"size":9,"mimeType":"application/json"}}}]}}',
|
|
);
|
|
expect(har[0]).toMatchObject({
|
|
method: "POST",
|
|
status: 201,
|
|
requestHeaders: 1,
|
|
requestBytes: 3,
|
|
responseBytes: 9,
|
|
});
|
|
expect(JSON.stringify(har)).not.toContain("secret");
|
|
expect(
|
|
inspectRawExchange(
|
|
"GET /x HTTP/1.1\r\nHost: example.test\r\n\r\n--- response ---\r\nHTTP/1.1 204 No Content\r\nX-Test: yes\r\n",
|
|
)[0],
|
|
).toMatchObject({ method: "GET", url: "/x", status: 204 });
|
|
});
|
|
|
|
it("matches HAR exchanges to operations and validates captured response JSON", () => {
|
|
const document = parseApiDocument(SPEC, "openapi.yaml");
|
|
const report = validateHarAgainstOpenApi(
|
|
JSON.stringify({
|
|
log: {
|
|
entries: [
|
|
{
|
|
request: {
|
|
method: "GET",
|
|
url: "https://api.example.test/items/item-1",
|
|
headers: [
|
|
{ name: "Authorization", value: "Bearer do-not-report" },
|
|
],
|
|
},
|
|
response: {
|
|
status: 200,
|
|
headers: [],
|
|
content: {
|
|
mimeType: "application/json",
|
|
text: JSON.stringify({ id: 42 }),
|
|
},
|
|
},
|
|
},
|
|
{
|
|
request: {
|
|
method: "POST",
|
|
url: "https://api.example.test/not-declared",
|
|
headers: [],
|
|
},
|
|
response: { status: 404, headers: [], content: {} },
|
|
},
|
|
],
|
|
},
|
|
}),
|
|
createWorkspace(document),
|
|
);
|
|
expect(report.coveredOperations).toBe(1);
|
|
expect(report.unmatchedExchanges).toBe(1);
|
|
expect(report.exchanges[0]?.operation).toBe("GET /items/{id}");
|
|
expect(report.diagnostics.map((item) => item.message).join("\n")).toMatch(
|
|
/expected string|does not match a declared operation/iu,
|
|
);
|
|
expect(JSON.stringify(report)).not.toContain("do-not-report");
|
|
});
|
|
});
|