@suluk/harden 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +161 -0
- package/package.json +3 -2
- package/src/audit.ts +36 -0
- package/src/index.ts +6 -0
- package/src/readiness.ts +79 -0
- package/test/harden.test.ts +28 -1
- package/test/readiness.test.ts +85 -0
package/README.md
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<a href="https://github.com/MahmoodKhalil57/suluk">
|
|
3
|
+
<img src="https://raw.githubusercontent.com/MahmoodKhalil57/suluk/main/branding/export/wordmark.png" alt="Suluk" width="360" />
|
|
4
|
+
</a>
|
|
5
|
+
</p>
|
|
6
|
+
|
|
7
|
+
# @suluk/harden
|
|
8
|
+
|
|
9
|
+
**Schema hardening as a derived, scored contract facet — grade a v4 document's input schemas A–F, fail CI below a floor, and fill the gaps with one transform.**
|
|
10
|
+
|
|
11
|
+
> **CANDIDATE tooling — not official OpenAPI.** Suluk is a single-contributor candidate for
|
|
12
|
+
> OpenAPI Specification v4.0 ("Moonwalk"), unaffiliated with the OpenAPI Initiative and unable
|
|
13
|
+
> to ratify anything on the SIG's behalf.
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
bun add @suluk/harden
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## What it does
|
|
22
|
+
|
|
23
|
+
It audits the **input** surface of an OpenAPI v4 "Suluk" document — every request body and typed parameter slot (`path` / `query` / `header` / `cookie` / `body`) — for the validations that keep malformed or oversized input from breaking the system:
|
|
24
|
+
|
|
25
|
+
- **no `any`/`unknown`** — every input has a determinable `type` (or is bounded by `enum`/`const`);
|
|
26
|
+
- **every string** a `maxLength` **and** a `pattern` (a character allowlist) — unless bounded by `format`/`enum`/`const`;
|
|
27
|
+
- **every number** a `maximum` (and ideally a `minimum`);
|
|
28
|
+
- **every array** a `maxItems` (a DoS guard);
|
|
29
|
+
- **every object** closed (`additionalProperties: false`) and typed (defined `properties`).
|
|
30
|
+
|
|
31
|
+
It scores each operation and the whole document (0–100 → letter grade), emits concrete `Finding`s with a `fix` string, and gives you a **CI gate** (`assertGrade`) so a regression below the floor throws. The inverse half — `hardenSchema` / `hardenDocument` — is the transform that *fills* those gaps with sensible baseline bounds, turning an F/D contract into a B with one call (authors then tighten per field).
|
|
32
|
+
|
|
33
|
+
`$ref`'d models are walked once and deduped across operations, so a shared schema is audited (and reported) a single time.
|
|
34
|
+
|
|
35
|
+
## When to reach for it
|
|
36
|
+
|
|
37
|
+
- You build a v4 document (from `@suluk/drizzle`, `@suluk/hono`, or by hand) and want **input-validation coverage** scored and visible — to incentivise hardening rather than hope for it.
|
|
38
|
+
- You want to **gate CI**: `assertGrade(doc, "A")` in a test throws if the authored surface regresses below the floor.
|
|
39
|
+
- You want to **auto-tighten** loose schemas: run `hardenDocument` over a built document to add baseline bounds doc-wide (including the route generator's otherwise-unbounded path-param strings).
|
|
40
|
+
|
|
41
|
+
This package only *reads and rewrites* the schemas inside a v4 document. It does not validate live requests (that's the wire / `@suluk/core`), and it does not render the grade into a UI — the `@suluk/reference` hardening panel and the `@suluk/editor` diagnostics bar do that by calling `auditDocument` themselves.
|
|
42
|
+
|
|
43
|
+
## Usage
|
|
44
|
+
|
|
45
|
+
### Audit — grade the input surface
|
|
46
|
+
|
|
47
|
+
```ts
|
|
48
|
+
import { auditDocument } from "@suluk/harden";
|
|
49
|
+
|
|
50
|
+
const report = auditDocument(doc); // doc: OpenAPIv4Document
|
|
51
|
+
report.grade; // "A" | "B" | "C" | "D" | "F"
|
|
52
|
+
report.score; // 0–100
|
|
53
|
+
report.bySeverity; // { high, medium, low } — counts across all findings
|
|
54
|
+
report.byOperation; // per-operation audits, weakest first
|
|
55
|
+
report.findings; // Finding[] — { rule, severity, path, message, fix }, deduped by rule@path
|
|
56
|
+
|
|
57
|
+
for (const f of report.findings) {
|
|
58
|
+
console.log(`${f.severity.toUpperCase()} ${f.path}: ${f.message} → ${f.fix}`);
|
|
59
|
+
}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Audit a single operation, or skip surfaces you don't author (e.g. a merged third-party auth surface) so they don't count toward the grade:
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
import { auditOperation, auditDocument } from "@suluk/harden";
|
|
66
|
+
|
|
67
|
+
// One operation: auditOperation(doc, uri, operationName, rawRequest)
|
|
68
|
+
const op = auditOperation(doc, "/users", "createUser", req);
|
|
69
|
+
|
|
70
|
+
// Exclude ingested surfaces from the rollup:
|
|
71
|
+
const report = auditDocument(doc, {
|
|
72
|
+
ignore: (uri, name) => uri.toLowerCase().includes("auth"),
|
|
73
|
+
});
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### Gate CI — `assertGrade`
|
|
77
|
+
|
|
78
|
+
The hard incentive: throw if the document's hardening grade falls below `min`. Returns the full `DocAudit` when it passes.
|
|
79
|
+
|
|
80
|
+
```ts
|
|
81
|
+
import { test } from "bun:test";
|
|
82
|
+
import { assertGrade } from "@suluk/harden";
|
|
83
|
+
|
|
84
|
+
test("contract input surface stays fully bounded (grade A)", async () => {
|
|
85
|
+
const document = await app.request("/openapi.json").then((r) => r.json());
|
|
86
|
+
const audit = assertGrade(document, "A", {
|
|
87
|
+
ignore: (uri) => uri.toLowerCase().includes("auth"), // auth is third-party
|
|
88
|
+
});
|
|
89
|
+
// throws below A; on pass you get the report:
|
|
90
|
+
expect(audit.bySeverity.high).toBe(0);
|
|
91
|
+
});
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
The thrown message names the worst operations and the high/medium counts, so a failing build tells the author exactly what to add.
|
|
95
|
+
|
|
96
|
+
### Unified contract grade — combine the input-schema grade with the agent grade
|
|
97
|
+
|
|
98
|
+
A v4 document has two graded dimensions: its **input schemas** (this package) and its **agent composition**
|
|
99
|
+
([`@suluk/agents`](../agents)' `gradeAgent`). `combineGrades` folds them into one contract grade — on the **letter**,
|
|
100
|
+
never the raw score (the two scores are non-comparable: a clean/nodes ratio vs an absolute `100 − Σ penalty`). It's a
|
|
101
|
+
pure combinator, so no package depends on the other — the caller passes the letters:
|
|
102
|
+
|
|
103
|
+
```ts
|
|
104
|
+
import { auditDocument, combineGrades, assertCombinedGrade } from "@suluk/harden";
|
|
105
|
+
import { gradeAgents } from "@suluk/agents";
|
|
106
|
+
|
|
107
|
+
const grades = [auditDocument(doc).grade, ...gradeAgents(doc).map((g) => g.grade)];
|
|
108
|
+
combineGrades(grades); // { worst, average, grades } — `worst` is the value to GATE on (a contract is as strong as its weakest dimension)
|
|
109
|
+
// `average` is informational; its ties round toward the higher letter, so it only ever masks optimistically
|
|
110
|
+
assertCombinedGrade(grades, "B"); // CI gate on the worst dimension; pass `"average"` to soften. Pass at least the doc grade — an empty set passes vacuously.
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
### Harden — the inverse transform
|
|
114
|
+
|
|
115
|
+
`hardenSchema` adds baseline bounds to a single JSON Schema; `hardenDocument` does it to **every** input schema in a built v4 document, in place. Both are idempotent and **never override an author-set bound** — they only fill gaps. Strings bounded by `enum`/`const`/`format` are left alone.
|
|
116
|
+
|
|
117
|
+
```ts
|
|
118
|
+
import { hardenSchema, hardenDocument } from "@suluk/harden";
|
|
119
|
+
|
|
120
|
+
// Per schema — layer it after your own per-field validations so it only fills what's left:
|
|
121
|
+
const schema = hardenSchema(myInsertSchema);
|
|
122
|
+
// strings → maxLength + a control-char-rejecting pattern; numbers → min/max;
|
|
123
|
+
// arrays → maxItems; objects → additionalProperties:false
|
|
124
|
+
|
|
125
|
+
// Doc-wide, in place — the transform that makes assertGrade pass:
|
|
126
|
+
const document = hardenDocument(builtDoc);
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Override the floors when the defaults (1024 chars / ±1e12 / 1000 items) are wrong for you, or pass `textPattern: null` to skip the string pattern entirely:
|
|
130
|
+
|
|
131
|
+
```ts
|
|
132
|
+
import { hardenSchema, type HardenOptions } from "@suluk/harden";
|
|
133
|
+
|
|
134
|
+
const opts: HardenOptions = { maxLength: 80, numberMax: 1_000, maxItems: 50 };
|
|
135
|
+
const tightened = hardenSchema(schema, opts);
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
## API
|
|
139
|
+
|
|
140
|
+
| Export | What it does |
|
|
141
|
+
| --- | --- |
|
|
142
|
+
| `auditDocument(doc, opts?)` | Audit the whole document → `DocAudit` (per-op grades, deduped rollup, severity breakdown). |
|
|
143
|
+
| `auditOperation(doc, uri, name, req)` | Audit one operation's input surface → `OpAudit`. |
|
|
144
|
+
| `assertGrade(doc, min, opts?)` | CI gate: throw if the grade is below `min`; else return the `DocAudit`. |
|
|
145
|
+
| `grade(score)` | Map a 0–100 score to a letter grade (`A` ≥ 90, `B` ≥ 75, `C` ≥ 60, `D` ≥ 40, else `F`). |
|
|
146
|
+
| `combineGrades(grades)` / `assertCombinedGrade(grades, min, mode?)` | UNIFIED contract grade (Stage 1.5): combine this input-schema grade with `@suluk/agents`' `gradeAgent` grade on the LETTER → `{ worst, average, grades }`; gate on the worst (safe) or `"average"`. |
|
|
147
|
+
| `hardenSchema(schema, opts?)` | Add baseline bounds to one JSON Schema (idempotent; never overrides). |
|
|
148
|
+
| `hardenDocument(doc, opts?)` | Harden every input schema in a built v4 document, in place. |
|
|
149
|
+
| `HardenOptions` | `{ maxLength?, textPattern?, numberMax?, numberMin?, maxItems? }` — overridable floors for the transform. |
|
|
150
|
+
|
|
151
|
+
Types `Audit`, `OpAudit`, `DocAudit`, `Finding`, `Severity` (`"high" \| "medium" \| "low"`), and `Grade` (`"A" \| "B" \| "C" \| "D" \| "F"`) are exported alongside.
|
|
152
|
+
|
|
153
|
+
## Boundary
|
|
154
|
+
|
|
155
|
+
The audit↔transform pair is the template for "ship both halves": the audit grades the gaps, `harden` fills them. Hardening is a **derived contract facet** — like `x-suluk-cost`/`x-suluk-access`/`x-suluk-source`, it is *projected from* the contract, not authored separately.
|
|
156
|
+
|
|
157
|
+
This package stays L3 — **render/generate, never host**. It reads and rewrites the schemas inside a v4 document and returns reports/transformed documents; it does not run a server, validate live wire requests, or store anything. You inject the document (the built v4 doc) and consume the result (a grade, findings, or a hardened copy). Surfacing the grade to a developer (a docs panel, an editor bar) is the consumer's job; enforcing the bounds at runtime is the wire's job.
|
|
158
|
+
|
|
159
|
+
## License
|
|
160
|
+
|
|
161
|
+
Apache-2.0
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@suluk/harden",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Schema HARDENING as a derived contract facet: audit a v4 'Suluk' document's input schemas for the validations that keep weird/oversized input from breaking the system — every string a maxLength + a pattern, every number a maximum, every array a maxItems, every object closed + typed, no any/unknown — score it (A-F), surface the grade to INCENTIVISE the author, and gate CI on a minimum. CANDIDATE tooling.",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
@@ -19,7 +19,8 @@
|
|
|
19
19
|
".": "./src/index.ts"
|
|
20
20
|
},
|
|
21
21
|
"dependencies": {
|
|
22
|
-
"@suluk/core": "^0.1.
|
|
22
|
+
"@suluk/core": "^0.1.13",
|
|
23
|
+
"@suluk/examples": "^0.1.0"
|
|
23
24
|
},
|
|
24
25
|
"devDependencies": {
|
|
25
26
|
"@types/bun": "latest"
|
package/src/audit.ts
CHANGED
|
@@ -125,3 +125,39 @@ export function assertGrade(doc: OpenAPIv4Document, min: Grade, opts: AuditOptio
|
|
|
125
125
|
}
|
|
126
126
|
return a;
|
|
127
127
|
}
|
|
128
|
+
|
|
129
|
+
// ─────────────────────────── UNIFIED CONTRACT GRADE (Stage 1.5) ───────────────────────────
|
|
130
|
+
// Combine grades from DIFFERENT dimensions (this package's input-schema grade + `@suluk/agents`' agent-composition
|
|
131
|
+
// grade `gradeAgent`) into ONE contract grade — on the LETTER (ordinal), NEVER the raw score: the two scores live on
|
|
132
|
+
// non-comparable scales (harden = a clean/nodes RATIO; gradeAgent = an absolute 100−Σpenalty), so only the letter is
|
|
133
|
+
// shared. This is a PURE combinator (no `@suluk/agents` dependency — the caller passes the letters): the unified grade
|
|
134
|
+
// is `combineGrades([auditDocument(doc).grade, ...gradeAgents(doc).map(g => g.grade)])`.
|
|
135
|
+
|
|
136
|
+
export interface CombinedGrade {
|
|
137
|
+
/** the WORST letter — a contract is as strong as its weakest graded dimension (the safe value to GATE on). */
|
|
138
|
+
worst: Grade;
|
|
139
|
+
/** the rounded-mean letter (informational — can mask a single failing dimension, so do not gate on it blindly; ties
|
|
140
|
+
* round toward the HIGHER letter, so the masking is always optimistic). */
|
|
141
|
+
average: Grade;
|
|
142
|
+
/** the input letters, as given. */
|
|
143
|
+
grades: Grade[];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Combine per-dimension letters into one contract grade (worst + average). Empty ⇒ vacuously A — a caller MUST pass at
|
|
147
|
+
* least the doc grade, since gating an empty set passes vacuously (`worst:"A"`). */
|
|
148
|
+
export function combineGrades(grades: Grade[]): CombinedGrade {
|
|
149
|
+
if (!grades.length) return { worst: "A", average: "A", grades: [] };
|
|
150
|
+
const ord = grades.map((g) => ORDER.indexOf(g));
|
|
151
|
+
const worst = ORDER[Math.min(...ord)]!;
|
|
152
|
+
const average = ORDER[Math.round(ord.reduce((a, b) => a + b, 0) / ord.length)]!;
|
|
153
|
+
return { worst, average, grades };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** CI gate over a combined grade. Gates on the WORST dimension by default (safe); pass `mode: "average"` to soften. */
|
|
157
|
+
export function assertCombinedGrade(grades: Grade[], min: Grade, mode: "worst" | "average" = "worst"): CombinedGrade {
|
|
158
|
+
const c = combineGrades(grades);
|
|
159
|
+
const g = mode === "average" ? c.average : c.worst;
|
|
160
|
+
if (ORDER.indexOf(g) < ORDER.indexOf(min))
|
|
161
|
+
throw new Error(`@suluk/harden: combined contract grade ${g} (${mode} of ${grades.join(", ") || "—"}) is below the required ${min}`);
|
|
162
|
+
return c;
|
|
163
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -9,7 +9,13 @@
|
|
|
9
9
|
*/
|
|
10
10
|
export {
|
|
11
11
|
auditDocument, auditOperation, assertGrade, grade,
|
|
12
|
+
// Stage 1.5: combine THIS package's input-schema grade with @suluk/agents' agent-composition grade into one
|
|
13
|
+
// contract grade — on the LETTER (the scores are non-comparable). Pure combinator; the caller passes the letters.
|
|
14
|
+
combineGrades, assertCombinedGrade, type CombinedGrade,
|
|
12
15
|
type Audit, type OpAudit, type DocAudit, type Finding, type Severity, type Grade,
|
|
13
16
|
} from "./audit";
|
|
14
17
|
// the inverse of the audit — the hardening TRANSFORM that adds the baseline bounds the audit grades for.
|
|
15
18
|
export { hardenSchema, hardenDocument, type HardenOptions } from "./harden";
|
|
19
|
+
// C043: a SECOND dimension — schema-fact READINESS (computed-required, request-without-example), separate from the
|
|
20
|
+
// security grade. Fold its letter into `combineGrades` alongside the security grade + journeys coverage.
|
|
21
|
+
export { auditReadiness, type ReadinessAudit, type ReadinessOptions } from "./readiness";
|
package/src/readiness.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* READINESS audit (C043) — a SECOND, separate harden dimension (kept apart from the security `auditDocument` grade so a
|
|
3
|
+
* security score never mixes with a readiness score). It grades schema-FACT readiness the doc can answer alone — the
|
|
4
|
+
* concerns C040/C041 made expressible:
|
|
5
|
+
* • `computed-required` — a request field marked `computed` (or `readOnly`) AND `required`: a client CANNOT send it,
|
|
6
|
+
* so the request can never be satisfied (a real contract bug). HIGH.
|
|
7
|
+
* • `request-without-example` — a request body with no curated `examples`/`example` (author one in `.meta`, or promote
|
|
8
|
+
* a tester `@public` row). A docs/demo readiness gap. LOW.
|
|
9
|
+
*
|
|
10
|
+
* BDD COVERAGE is the OTHER readiness gap — but it needs the `.feature` files, so `@suluk/journeys` computes it and the
|
|
11
|
+
* caller folds its letter in via `combineGrades` (harden never depends on journeys — the established harden+agents seam).
|
|
12
|
+
*/
|
|
13
|
+
import type { OpenAPIv4Document } from "@suluk/core";
|
|
14
|
+
import { fieldOrigin, type JsonSchema } from "@suluk/examples";
|
|
15
|
+
import { grade, type Finding, type Grade } from "./audit";
|
|
16
|
+
|
|
17
|
+
export interface ReadinessAudit {
|
|
18
|
+
findings: Finding[];
|
|
19
|
+
nodes: number;
|
|
20
|
+
clean: number;
|
|
21
|
+
score: number;
|
|
22
|
+
grade: Grade;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ReadinessOptions {
|
|
26
|
+
/** skip operations (e.g. third-party/ingested surfaces) — they don't count toward the readiness grade. */
|
|
27
|
+
ignore?: (uri: string, name: string) => boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface RawReq {
|
|
31
|
+
method: string;
|
|
32
|
+
contentSchema?: unknown;
|
|
33
|
+
parameterSchema?: { body?: unknown };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const hasExample = (s: JsonSchema): boolean => (Array.isArray(s.examples) && s.examples.length > 0) || "example" in s;
|
|
37
|
+
|
|
38
|
+
/** Audit the document's request bodies for client-sendability + example presence → findings + a readiness grade. */
|
|
39
|
+
export function auditReadiness(doc: OpenAPIv4Document, opts: ReadinessOptions = {}): ReadinessAudit {
|
|
40
|
+
const findings: Finding[] = [];
|
|
41
|
+
let nodes = 0;
|
|
42
|
+
let clean = 0;
|
|
43
|
+
const pass = () => {
|
|
44
|
+
nodes++;
|
|
45
|
+
clean++;
|
|
46
|
+
};
|
|
47
|
+
const fail = (f: Finding) => {
|
|
48
|
+
nodes++;
|
|
49
|
+
findings.push(f);
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
for (const [uri, piRaw] of Object.entries(doc.paths ?? {})) {
|
|
53
|
+
const pi = piRaw as { requests?: Record<string, RawReq> };
|
|
54
|
+
for (const [name, req] of Object.entries(pi.requests ?? {})) {
|
|
55
|
+
if (opts.ignore?.(uri, name)) continue;
|
|
56
|
+
const body = (req.contentSchema ?? req.parameterSchema?.body) as JsonSchema | undefined;
|
|
57
|
+
if (!body || typeof body !== "object") continue; // no request body → nothing to assess
|
|
58
|
+
|
|
59
|
+
// node: a curated example present?
|
|
60
|
+
if (hasExample(body)) pass();
|
|
61
|
+
else fail({ rule: "request-without-example", severity: "low", path: `${name}/body`, message: `request '${name}' has no example`, fix: "author one in .meta({ examples }), or promote a tester @public row via `journeys promote`" });
|
|
62
|
+
|
|
63
|
+
// node per REQUIRED field: is it client-sendable (not computed/readOnly)?
|
|
64
|
+
const props = (body.properties ?? {}) as Record<string, JsonSchema>;
|
|
65
|
+
const required = new Set(Array.isArray(body.required) ? (body.required as string[]) : []);
|
|
66
|
+
for (const [k, sub] of Object.entries(props)) {
|
|
67
|
+
if (!required.has(k)) continue;
|
|
68
|
+
if (fieldOrigin(sub) === "computed") {
|
|
69
|
+
fail({ rule: "computed-required", severity: "high", path: `${name}/body/${k}`, message: `required field '${k}' is computed/readOnly — a client cannot send it`, fix: "make it optional or non-computed (drop x-suluk-origin:computed / readOnly), or remove it from required" });
|
|
70
|
+
} else {
|
|
71
|
+
pass();
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const score = nodes === 0 ? 100 : Math.round((clean / nodes) * 100);
|
|
78
|
+
return { findings, nodes, clean, score, grade: grade(score) };
|
|
79
|
+
}
|
package/test/harden.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { test, expect, describe } from "bun:test";
|
|
2
|
-
import { auditOperation, auditDocument, assertGrade, grade, hardenSchema, hardenDocument } from "../src/index";
|
|
2
|
+
import { auditOperation, auditDocument, assertGrade, grade, hardenSchema, hardenDocument, combineGrades, assertCombinedGrade } from "../src/index";
|
|
3
3
|
import type { OpenAPIv4Document } from "@suluk/core";
|
|
4
4
|
|
|
5
5
|
const weakReq = { method: "post", contentSchema: { type: "object", properties: {
|
|
@@ -64,6 +64,33 @@ describe("@suluk/harden — schema hardening as a scored facet", () => {
|
|
|
64
64
|
});
|
|
65
65
|
});
|
|
66
66
|
|
|
67
|
+
describe("combineGrades — the unified contract grade (Stage 1.5: harden doc-grade × agent grade, on the LETTER)", () => {
|
|
68
|
+
test("worst is the weakest dimension; average is the rounded mean letter", () => {
|
|
69
|
+
expect(combineGrades(["A", "F"])).toEqual({ worst: "F", average: "C", grades: ["A", "F"] }); // A=4,F=0 → mean 2 → C
|
|
70
|
+
expect(combineGrades(["B", "B", "A"])).toEqual({ worst: "B", average: "B", grades: ["B", "B", "A"] });
|
|
71
|
+
expect(combineGrades(["A"])).toEqual({ worst: "A", average: "A", grades: ["A"] }); // single dimension (e.g. no agents)
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("empty ⇒ vacuously A (nothing graded)", () => {
|
|
75
|
+
expect(combineGrades([])).toEqual({ worst: "A", average: "A", grades: [] });
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("the unified grade composes the doc grade + every agent grade as letters (the documented bridge)", () => {
|
|
79
|
+
const docGrade = grade(82); // a 'B' document
|
|
80
|
+
const agentGrades = ["A", "C"] as const; // e.g. gradeAgents(doc).map(g => g.grade)
|
|
81
|
+
const unified = combineGrades([docGrade, ...agentGrades]);
|
|
82
|
+
expect(unified.worst).toBe("C"); // a contract is as strong as its weakest dimension
|
|
83
|
+
expect(unified.grades).toEqual(["B", "A", "C"]);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("assertCombinedGrade gates on the WORST by default; `average` softens the gate; passing returns the combined grade", () => {
|
|
87
|
+
expect(() => assertCombinedGrade(["A", "F"], "B")).toThrow(/below the required B/); // worst F < B → throws
|
|
88
|
+
expect(() => assertCombinedGrade(["A", "F"], "B", "average")).toThrow(); // average C < B → still throws
|
|
89
|
+
expect(() => assertCombinedGrade(["A", "F"], "C", "average")).not.toThrow(); // average C ≥ C → passes (worst F would have failed)
|
|
90
|
+
expect(assertCombinedGrade(["A", "B"], "B").worst).toBe("B"); // worst B ≥ B → passes, returns the combined grade
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
67
94
|
describe("hardenSchema / hardenDocument — the transform (inverse of the audit)", () => {
|
|
68
95
|
test("adds baseline bounds: string→maxLength+pattern, number→min/max, array→maxItems, object→closed", () => {
|
|
69
96
|
const h = hardenSchema({ type: "object", properties: {
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { test, expect, describe } from "bun:test";
|
|
2
|
+
import { auditReadiness } from "../src/index";
|
|
3
|
+
import type { OpenAPIv4Document } from "@suluk/core";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* C043 — the readiness dimension: computed-required (a required field a client can't send) + request-without-example.
|
|
7
|
+
* Separate from the security grade; folded into combineGrades alongside security + journeys coverage.
|
|
8
|
+
*/
|
|
9
|
+
const doc = {
|
|
10
|
+
openapi: "4.0.0-candidate",
|
|
11
|
+
info: { title: "Billing" },
|
|
12
|
+
paths: {
|
|
13
|
+
"/charge": {
|
|
14
|
+
requests: {
|
|
15
|
+
charge: {
|
|
16
|
+
method: "post",
|
|
17
|
+
contentSchema: {
|
|
18
|
+
type: "object",
|
|
19
|
+
required: ["amountCents", "balance"],
|
|
20
|
+
properties: {
|
|
21
|
+
amountCents: { type: "integer" },
|
|
22
|
+
balance: { type: "integer", "x-suluk-origin": "computed" }, // required AND computed → a bug
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
"/good": {
|
|
29
|
+
requests: {
|
|
30
|
+
good: {
|
|
31
|
+
method: "post",
|
|
32
|
+
contentSchema: {
|
|
33
|
+
type: "object",
|
|
34
|
+
required: ["name"],
|
|
35
|
+
properties: { name: { type: "string" } },
|
|
36
|
+
examples: [{ name: "ok" }], // has an example
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
"/health": { requests: { health: { method: "get", responses: { "200": { status: 200 } } } } }, // no body → not assessed
|
|
42
|
+
},
|
|
43
|
+
} as unknown as OpenAPIv4Document;
|
|
44
|
+
|
|
45
|
+
describe("auditReadiness", () => {
|
|
46
|
+
const audit = auditReadiness(doc);
|
|
47
|
+
const rules = audit.findings.map((f) => f.rule);
|
|
48
|
+
|
|
49
|
+
test("flags a required computed/readOnly field as not client-sendable (high)", () => {
|
|
50
|
+
const f = audit.findings.find((x) => x.rule === "computed-required");
|
|
51
|
+
expect(f).toBeDefined();
|
|
52
|
+
expect(f!.severity).toBe("high");
|
|
53
|
+
expect(f!.path).toBe("charge/body/balance");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("flags a request body with no example (low)", () => {
|
|
57
|
+
const f = audit.findings.find((x) => x.rule === "request-without-example" && x.path === "charge/body");
|
|
58
|
+
expect(f).toBeDefined();
|
|
59
|
+
expect(f!.severity).toBe("low");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("a body WITH an example + only sendable required fields raises no finding", () => {
|
|
63
|
+
expect(rules).not.toContain("good/body");
|
|
64
|
+
expect(audit.findings.some((f) => f.path.startsWith("good/"))).toBe(false);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("a body-less op is not assessed (no nodes for it)", () => {
|
|
68
|
+
expect(audit.findings.some((f) => f.path.startsWith("health/"))).toBe(false);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("produces a score + grade from the clean/nodes ratio", () => {
|
|
72
|
+
expect(audit.nodes).toBeGreaterThan(0);
|
|
73
|
+
expect(["A", "B", "C", "D", "F"]).toContain(audit.grade);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("readOnly is treated as computed (no x-suluk-origin needed)", () => {
|
|
77
|
+
const ro = { openapi: "4.0.0-candidate", info: { title: "T" }, paths: { "/x": { requests: { x: { method: "post", contentSchema: { type: "object", required: ["id"], properties: { id: { type: "string", readOnly: true } } } } } } } } as unknown as OpenAPIv4Document;
|
|
78
|
+
expect(auditReadiness(ro).findings.some((f) => f.rule === "computed-required")).toBe(true);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("a clean contract grades A", () => {
|
|
82
|
+
const clean = { openapi: "4.0.0-candidate", info: { title: "T" }, paths: { "/x": { requests: { x: { method: "post", contentSchema: { type: "object", required: ["n"], properties: { n: { type: "string" } }, examples: [{ n: "a" }] } } } } } } as unknown as OpenAPIv4Document;
|
|
83
|
+
expect(auditReadiness(clean).grade).toBe("A");
|
|
84
|
+
});
|
|
85
|
+
});
|