@lovrozagar/oat 0.1.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/LICENSE +21 -0
- package/README.md +401 -0
- package/bin/oat.js +2 -0
- package/dist/cli.js +386 -0
- package/dist/config/define-config.d.ts +165 -0
- package/dist/config/define-config.d.ts.map +1 -0
- package/dist/config/define-config.js +24 -0
- package/dist/config/define-config.js.map +1 -0
- package/dist/config/load.d.ts +10 -0
- package/dist/config/load.d.ts.map +1 -0
- package/dist/config/load.js +61 -0
- package/dist/config/load.js.map +1 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +9 -0
- package/dist/reference/defects.d.ts +254 -0
- package/dist/reference/defects.d.ts.map +1 -0
- package/dist/reference/defects.js +265 -0
- package/dist/reference/defects.js.map +1 -0
- package/dist/reference/dialect.d.ts +104 -0
- package/dist/reference/dialect.d.ts.map +1 -0
- package/dist/reference/dialect.js +174 -0
- package/dist/reference/dialect.js.map +1 -0
- package/dist/reference/http.d.ts +79 -0
- package/dist/reference/http.d.ts.map +1 -0
- package/dist/reference/http.js +889 -0
- package/dist/reference/http.js.map +1 -0
- package/dist/reference/model.d.ts +56 -0
- package/dist/reference/model.d.ts.map +1 -0
- package/dist/reference/model.js +156 -0
- package/dist/reference/model.js.map +1 -0
- package/dist/reference/query.d.ts +45 -0
- package/dist/reference/query.d.ts.map +1 -0
- package/dist/reference/query.js +353 -0
- package/dist/reference/query.js.map +1 -0
- package/dist/reference/spec.d.ts +13 -0
- package/dist/reference/spec.d.ts.map +1 -0
- package/dist/reference/spec.js +547 -0
- package/dist/reference/spec.js.map +1 -0
- package/dist/reference/store-api.d.ts +59 -0
- package/dist/reference/store-api.d.ts.map +1 -0
- package/dist/reference/store-api.js +76 -0
- package/dist/reference/store-api.js.map +1 -0
- package/dist/reference/stores/memory.d.ts +27 -0
- package/dist/reference/stores/memory.d.ts.map +1 -0
- package/dist/reference/stores/memory.js +97 -0
- package/dist/reference/stores/memory.js.map +1 -0
- package/dist/reference/stores/postgres.d.ts +61 -0
- package/dist/reference/stores/postgres.d.ts.map +1 -0
- package/dist/reference/stores/postgres.js +406 -0
- package/dist/reference/stores/postgres.js.map +1 -0
- package/dist/reference/stores/sqlite-driver.d.ts +54 -0
- package/dist/reference/stores/sqlite-driver.d.ts.map +1 -0
- package/dist/reference/stores/sqlite-driver.js +99 -0
- package/dist/reference/stores/sqlite-driver.js.map +1 -0
- package/dist/reference/stores/sqlite.d.ts +68 -0
- package/dist/reference/stores/sqlite.d.ts.map +1 -0
- package/dist/reference/stores/sqlite.js +464 -0
- package/dist/reference/stores/sqlite.js.map +1 -0
- package/dist/report/console.d.ts +33 -0
- package/dist/report/console.d.ts.map +1 -0
- package/dist/report/console.js +237 -0
- package/dist/report/console.js.map +1 -0
- package/dist/report/matrix.d.ts +131 -0
- package/dist/report/matrix.d.ts.map +1 -0
- package/dist/report/matrix.js +865 -0
- package/dist/report/matrix.js.map +1 -0
- package/dist/report/render.d.ts +66 -0
- package/dist/report/render.d.ts.map +1 -0
- package/dist/report/render.js +423 -0
- package/dist/report/render.js.map +1 -0
- package/dist/runtime/async.d.ts +34 -0
- package/dist/runtime/async.d.ts.map +1 -0
- package/dist/runtime/async.js +130 -0
- package/dist/runtime/async.js.map +1 -0
- package/dist/runtime/auth.d.ts +46 -0
- package/dist/runtime/auth.d.ts.map +1 -0
- package/dist/runtime/auth.js +236 -0
- package/dist/runtime/auth.js.map +1 -0
- package/dist/runtime/checks.d.ts +89 -0
- package/dist/runtime/checks.d.ts.map +1 -0
- package/dist/runtime/checks.js +3400 -0
- package/dist/runtime/checks.js.map +1 -0
- package/dist/runtime/client.d.ts +65 -0
- package/dist/runtime/client.d.ts.map +1 -0
- package/dist/runtime/client.js +122 -0
- package/dist/runtime/client.js.map +1 -0
- package/dist/runtime/finding.d.ts +50 -0
- package/dist/runtime/finding.d.ts.map +1 -0
- package/dist/runtime/finding.js +50 -0
- package/dist/runtime/finding.js.map +1 -0
- package/dist/runtime/fixture.d.ts +20 -0
- package/dist/runtime/fixture.d.ts.map +1 -0
- package/dist/runtime/fixture.js +175 -0
- package/dist/runtime/fixture.js.map +1 -0
- package/dist/runtime/progress.d.ts +41 -0
- package/dist/runtime/progress.d.ts.map +1 -0
- package/dist/runtime/progress.js +186 -0
- package/dist/runtime/progress.js.map +1 -0
- package/dist/runtime/run.d.ts +69 -0
- package/dist/runtime/run.d.ts.map +1 -0
- package/dist/runtime/run.js +575 -0
- package/dist/runtime/run.js.map +1 -0
- package/dist/runtime/teardown.d.ts +38 -0
- package/dist/runtime/teardown.d.ts.map +1 -0
- package/dist/runtime/teardown.js +129 -0
- package/dist/runtime/teardown.js.map +1 -0
- package/dist/runtime/validate.d.ts +31 -0
- package/dist/runtime/validate.d.ts.map +1 -0
- package/dist/runtime/validate.js +111 -0
- package/dist/runtime/validate.js.map +1 -0
- package/dist/runtime/world.d.ts +49 -0
- package/dist/runtime/world.d.ts.map +1 -0
- package/dist/runtime/world.js +119 -0
- package/dist/runtime/world.js.map +1 -0
- package/dist/spec/collection.d.ts +31 -0
- package/dist/spec/collection.d.ts.map +1 -0
- package/dist/spec/collection.js +121 -0
- package/dist/spec/collection.js.map +1 -0
- package/dist/spec/conventions.d.ts +103 -0
- package/dist/spec/conventions.d.ts.map +1 -0
- package/dist/spec/conventions.js +275 -0
- package/dist/spec/conventions.js.map +1 -0
- package/dist/spec/extensions.d.ts +74 -0
- package/dist/spec/extensions.d.ts.map +1 -0
- package/dist/spec/extensions.js +335 -0
- package/dist/spec/extensions.js.map +1 -0
- package/dist/spec/graph.d.ts +89 -0
- package/dist/spec/graph.d.ts.map +1 -0
- package/dist/spec/graph.js +318 -0
- package/dist/spec/graph.js.map +1 -0
- package/dist/spec/load.d.ts +36 -0
- package/dist/spec/load.d.ts.map +1 -0
- package/dist/spec/load.js +166 -0
- package/dist/spec/load.js.map +1 -0
- package/dist/spec/types.d.ts +57 -0
- package/dist/spec/types.d.ts.map +1 -0
- package/dist/spec/types.js +24 -0
- package/dist/spec/types.js.map +1 -0
- package/labs/README.md +130 -0
- package/labs/annotated-openapi.yaml +311 -0
- package/labs/anyrow.config.ts +107 -0
- package/labs/demo.mjs +124 -0
- package/labs/local.config.ts +41 -0
- package/labs/minimal.config.ts +32 -0
- package/package.json +80 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 lovrozagar
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
# oat
|
|
2
|
+
|
|
3
|
+
OpenAPI Tester — behavioural testing of a live backend against its OpenAPI specification.
|
|
4
|
+
|
|
5
|
+
Schema validators answer *did this response match its declared shape?* Most real API bugs do not violate a schema:
|
|
6
|
+
|
|
7
|
+
- a resource exists on `GET /tables/{id}` but not on `GET /tables`
|
|
8
|
+
- `?filter=status.eq.nope` returns every row, because the backend dropped the param
|
|
9
|
+
- paging at `limit=2` yields 9 rows; `limit=100` yields 10 — the sort has no total order
|
|
10
|
+
- `PATCH { name }` also cleared `instruction`
|
|
11
|
+
- `?filter=id.eq.<another tenant's id>` returns the row
|
|
12
|
+
|
|
13
|
+
oat asserts the same fact through every projection the API offers, then compares those projections against each other.
|
|
14
|
+
|
|
15
|
+
## Table of contents
|
|
16
|
+
|
|
17
|
+
- [Install](#install)
|
|
18
|
+
- [Quick start](#quick-start)
|
|
19
|
+
- [Commands](#commands)
|
|
20
|
+
- [Configuration](#configuration)
|
|
21
|
+
- [OpenAPI meta tags](#openapi-meta-tags)
|
|
22
|
+
- [What gets tested](#what-gets-tested)
|
|
23
|
+
- [Reports](#reports)
|
|
24
|
+
- [Conformance](#conformance)
|
|
25
|
+
- [What this is not](#what-this-is-not)
|
|
26
|
+
- [Labs](#labs)
|
|
27
|
+
- [License](#license)
|
|
28
|
+
|
|
29
|
+
## Install
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
npm i -D @lovrozagar/oat
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Requires Node.js 20 or later.
|
|
36
|
+
|
|
37
|
+
## Quick start
|
|
38
|
+
|
|
39
|
+
oat ships a demo API — the same reference backend its self-test uses — so you can try it before pointing it at anything real:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
# terminal 1
|
|
43
|
+
oat serve --defects STALE_LIST,PATCH_REPLACES
|
|
44
|
+
|
|
45
|
+
# terminal 2
|
|
46
|
+
oat run --config labs/local.config.ts --base-url <url printed above>
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
```
|
|
50
|
+
25 checks · 3 entities · 164 requests · 0.2s
|
|
51
|
+
|
|
52
|
+
BACKEND DEFECTS (5)
|
|
53
|
+
table list projection does not reflect a completed write
|
|
54
|
+
table PATCH changed fields the request did not mention
|
|
55
|
+
|
|
56
|
+
cleaned up 22/22 created record(s)
|
|
57
|
+
report: oat-out/oat-report.md
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
`oat-out/` holds a markdown report, a JSON copy for CI, and one runnable `curl` script per finding (`issue-repro/`). Drop `--defects` and a correct backend reports nothing across 55 checks.
|
|
61
|
+
|
|
62
|
+
Against your own API:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
oat doctor --spec https://api.example.com/openapi.json
|
|
66
|
+
oat plan --spec https://api.example.com/openapi.json
|
|
67
|
+
oat run --config oat.config.ts
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
`doctor` is the adoption command. It runs offline against the spec alone and reports every coverage gap, naming the meta tag that would close it.
|
|
71
|
+
|
|
72
|
+
## Commands
|
|
73
|
+
|
|
74
|
+
```
|
|
75
|
+
oat run --config <file> test a live backend and write a report
|
|
76
|
+
oat plan --spec <url|file> derive and print the test model (offline)
|
|
77
|
+
oat doctor --spec <url|file> report what oat can and cannot test, and why
|
|
78
|
+
oat serve [--defects A,B] run the demo API
|
|
79
|
+
oat conformance self-test: injected defects vs detection
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Useful flags:
|
|
83
|
+
|
|
84
|
+
| flag | |
|
|
85
|
+
| --- | --- |
|
|
86
|
+
| `--config` | config module (`.ts` / `.js` / `.mjs` / `.json`) with a default export |
|
|
87
|
+
| `--spec` | OpenAPI document, URL or path |
|
|
88
|
+
| `--base-url` | backend under test, overrides the config |
|
|
89
|
+
| `--only` | comma-separated entity names |
|
|
90
|
+
| `--out` | report directory (default `./oat-out`) |
|
|
91
|
+
| `--concurrency` | entities tested in parallel (default 1) |
|
|
92
|
+
| `--quiet` | no live progress on stderr (`progress.log` is still written) |
|
|
93
|
+
| `--keep-fixtures` | leave created records in place |
|
|
94
|
+
| `--backend` | conformance storage: `memory` \| `sqlite` \| `postgres` \| `d1` |
|
|
95
|
+
| `--dialect` | API shape: `postgrest` \| `classic` \| `linked` \| `jsonapi` \| `plain` |
|
|
96
|
+
| `--fuzz` | inject random defect combinations |
|
|
97
|
+
| `--precision` | vary cohort data against a correct backend |
|
|
98
|
+
|
|
99
|
+
## Configuration
|
|
100
|
+
|
|
101
|
+
Two inputs, always: the spec, and a config file. oat never reads your source, assumes your framework, or hardcodes a route. Backend-specific knowledge lives in one of two places:
|
|
102
|
+
|
|
103
|
+
- **`x-*` tags in the spec** — optional; each degrades to a heuristic and reports the degradation rather than guessing silently. A complete worked document is [`labs/annotated-openapi.yaml`](./labs/annotated-openapi.yaml).
|
|
104
|
+
- **`oat.config.ts`** — auth, roots, and the one hook oat cannot infer: values delivered outside HTTP (verification tokens, OTPs).
|
|
105
|
+
|
|
106
|
+
A backend adopts oat by adding tags, not by adapting to oat.
|
|
107
|
+
|
|
108
|
+
Smallest useful config — a long-lived API key and two tenants, so isolation checks can run:
|
|
109
|
+
|
|
110
|
+
```ts
|
|
111
|
+
import { defineConfig } from "@lovrozagar/oat"
|
|
112
|
+
|
|
113
|
+
export default defineConfig({
|
|
114
|
+
baseUrl: "https://api.example.com",
|
|
115
|
+
spec: "https://api.example.com/openapi.json",
|
|
116
|
+
principals: [
|
|
117
|
+
{
|
|
118
|
+
id: "alpha",
|
|
119
|
+
headers: { authorization: `Bearer ${process.env.API_TOKEN}` },
|
|
120
|
+
roots: { project_id: process.env.PROJECT_A },
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
id: "beta",
|
|
124
|
+
headers: { authorization: `Bearer ${process.env.API_TOKEN_B}` },
|
|
125
|
+
roots: { project_id: process.env.PROJECT_B },
|
|
126
|
+
},
|
|
127
|
+
],
|
|
128
|
+
})
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Multi-step login is a chain of operations. Values flow forward through `saveAs`; the credential is taken from the last response:
|
|
132
|
+
|
|
133
|
+
```ts
|
|
134
|
+
{
|
|
135
|
+
id: "alpha",
|
|
136
|
+
auth: {
|
|
137
|
+
credentialFrom: "$.access_token",
|
|
138
|
+
steps: [{ operationId: "auth.token", body: { key: process.env.API_KEY } }],
|
|
139
|
+
},
|
|
140
|
+
roots: { org_id: "org_alpha" },
|
|
141
|
+
}
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
`resolveOutOfBand` on the config is the hook for emailed tokens and OTPs. Credentials refresh from JWT `exp` before they expire. See [`labs/minimal.config.ts`](./labs/minimal.config.ts) and [`labs/anyrow.config.ts`](./labs/anyrow.config.ts).
|
|
145
|
+
|
|
146
|
+
## OpenAPI meta tags
|
|
147
|
+
|
|
148
|
+
Vendor-neutral `x-*` extensions oat reads from your spec. Every one is **optional**. Precedence is always: **explicit tag → heuristic → skip with a coverage gap**.
|
|
149
|
+
|
|
150
|
+
`oat conformance` loads [`labs/annotated-openapi.yaml`](./labs/annotated-openapi.yaml) and asserts the derived model matches what its comments claim, so the examples cannot drift from the implementation.
|
|
151
|
+
|
|
152
|
+
```bash
|
|
153
|
+
oat plan --spec openapi.yaml
|
|
154
|
+
oat doctor --spec openapi.yaml
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
### `x-invalidate`
|
|
158
|
+
|
|
159
|
+
```yaml
|
|
160
|
+
x-invalidate:
|
|
161
|
+
- GET /v1/projects/{project_id}/tables
|
|
162
|
+
- GET /v1/projects/{project_id}/tables/{table_id}
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
`string[]` of `"METHOD /path"`. Names the read routes this mutation must change. Colon (`/tables/:id`) and brace (`/tables/{id}`) syntax both work; oat normalises to brace.
|
|
166
|
+
|
|
167
|
+
This is the entity graph. Inverted, it gives every entity its *read surface* — the projections through which it is observable. Highest-value tag, and the one many specs already emit.
|
|
168
|
+
|
|
169
|
+
**Fallback:** pair a mutator with the sibling collection and item routes on the same path prefix. Catches the obvious cases, misses cross-entity effects.
|
|
170
|
+
|
|
171
|
+
### `x-entity`
|
|
172
|
+
|
|
173
|
+
```yaml
|
|
174
|
+
x-entity:
|
|
175
|
+
name: table
|
|
176
|
+
action: create # create | list | read | update | delete | action
|
|
177
|
+
identity: id # optional; property that identifies an instance
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
Overrides path-segment entity inference. Needed when the path does not follow `/<plural>/{id}`.
|
|
181
|
+
|
|
182
|
+
`identity` matters when the item schema does not declare an `id` — for example free-form row objects.
|
|
183
|
+
|
|
184
|
+
**Fallback:** deepest plural segment + HTTP verb. Irregular paths (`/rows/aggregate`) yield nothing and lose lifecycle tracking.
|
|
185
|
+
|
|
186
|
+
### `x-invite`
|
|
187
|
+
|
|
188
|
+
```yaml
|
|
189
|
+
x-invite:
|
|
190
|
+
invite: table.invite # operationId that creates the invite
|
|
191
|
+
accept: invite.accept # operationId the invitee calls
|
|
192
|
+
revoke: table.revoke # operationId that removes the grant
|
|
193
|
+
granteeField: key # invite body field naming the invitee
|
|
194
|
+
tokenPointer: $.token # where the accept token is
|
|
195
|
+
grantPointer: $.grant_id # where the revoke handle is
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
Declares the delegated-access flow. oat asserts the timeline: the invitee cannot read before accept, can after, and cannot after revoke. Put the tag on the invite operation.
|
|
199
|
+
|
|
200
|
+
The invitee's config must set `inviteAs` to the value the invite body expects.
|
|
201
|
+
|
|
202
|
+
**Fallback:** the check does not run. There is no heuristic for a multi-step flow.
|
|
203
|
+
|
|
204
|
+
### `x-query`
|
|
205
|
+
|
|
206
|
+
```yaml
|
|
207
|
+
x-query:
|
|
208
|
+
filterable: [id, name, slug, status, created_at]
|
|
209
|
+
sortable: [name, created_at, updated_at]
|
|
210
|
+
searchable: [name, slug]
|
|
211
|
+
selectable: [id, name, slug, created_at]
|
|
212
|
+
maxLimit: 100
|
|
213
|
+
defaultOrder: created_at.desc
|
|
214
|
+
stableTiebreak: id
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
Declares what the list endpoint's `filter` / `order` / `q` / `select` params actually support.
|
|
218
|
+
|
|
219
|
+
Without it, oat has to guess which fields are filterable from the item schema and will report false failures on fields the backend does not index.
|
|
220
|
+
|
|
221
|
+
`stableTiebreak` is load-bearing: if a sort has no total order, keyset pagination is unsound and page walks silently drop or duplicate rows. Declaring it lets oat assert it; omitting it makes oat test for the instability instead.
|
|
222
|
+
|
|
223
|
+
**Fallback:** if a filter / order / select / search *role* resolves (`sort`, `fields`, `q`, `where`, …), treat every scalar property as filterable/sortable and warn. Pagination-only lists stay uncovered.
|
|
224
|
+
|
|
225
|
+
### `x-async`
|
|
226
|
+
|
|
227
|
+
```yaml
|
|
228
|
+
x-async:
|
|
229
|
+
poll: "GET /v1/projects/{project_id}/batches/{batch_id}"
|
|
230
|
+
idFrom: batch_id
|
|
231
|
+
until: "status.in.complete,partial,failed"
|
|
232
|
+
successWhen: "status.eq.complete"
|
|
233
|
+
timeoutMs: 120000
|
|
234
|
+
pollIntervalMs: 2000
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
Marks an operation whose HTTP response is a receipt, not an outcome. oat drives the poll to a terminal state, then treats that payload as the real result.
|
|
238
|
+
|
|
239
|
+
Without this, every extract / export / batch endpoint is untestable beyond "did it return 200".
|
|
240
|
+
|
|
241
|
+
**Fallback:** treated as synchronous; downstream assertions are marked `COVERAGE_GAP`.
|
|
242
|
+
|
|
243
|
+
### `x-effects`
|
|
244
|
+
|
|
245
|
+
```yaml
|
|
246
|
+
x-effects:
|
|
247
|
+
- { entity: table, op: create, count: 1 }
|
|
248
|
+
- { entity: delivery, op: append, count: 1 }
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
`x-invalidate` says *this GET changes*; `x-effects` says *how*. That is what lets oat assert an exact delta (`count 4 → 5`, new id present) instead of a weak body inequality.
|
|
252
|
+
|
|
253
|
+
Also tells oat to track and clean up resources created as side effects.
|
|
254
|
+
|
|
255
|
+
`op`: `create` | `append` | `update` | `delete` | `replace`.
|
|
256
|
+
|
|
257
|
+
**Fallback:** derived from `x-entity.action` for the operation's own entity only.
|
|
258
|
+
|
|
259
|
+
### `x-soft-delete`
|
|
260
|
+
|
|
261
|
+
```yaml
|
|
262
|
+
x-soft-delete: deleted_at
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
On the entity's delete (or any operation on the entity). Tells oat that DELETE tombstones rather than removes.
|
|
266
|
+
|
|
267
|
+
Changes the post-delete assertion from "absent everywhere" to "absent from the default list". Without this tag, correct soft-delete behaviour is reported as a bug.
|
|
268
|
+
|
|
269
|
+
### `x-immutable` / `x-generated`
|
|
270
|
+
|
|
271
|
+
```yaml
|
|
272
|
+
x-immutable: [id, project_id, created_at]
|
|
273
|
+
x-generated: [id, created_at, updated_at]
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
`x-generated` lets oat build valid create bodies without guessing, and assert the fields appear. `x-immutable` powers the update-safety case: PATCH each immutable field and require the stored value to be unchanged.
|
|
277
|
+
|
|
278
|
+
**Fallback:** `readOnly: true` is honoured as generated; no immutability testing without the tag.
|
|
279
|
+
|
|
280
|
+
### `x-tenant`
|
|
281
|
+
|
|
282
|
+
```yaml
|
|
283
|
+
x-tenant: project_id
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
Names the path parameter that scopes the operation to a tenant.
|
|
287
|
+
|
|
288
|
+
Drives the isolation matrix: a second principal in another tenant must not see, read, or mutate the record — and `filter=id.eq.<other tenant's id>` must return empty rather than becoming an authz bypass.
|
|
289
|
+
|
|
290
|
+
**Fallback:** regex over `{organization_id}`, `{project_id}`, `{tenant_id}`, `{workspace_id}`, `{app_slug}`. Without the tag, a cross-tenant read is an ambiguity, not a security finding.
|
|
291
|
+
|
|
292
|
+
### `x-root`
|
|
293
|
+
|
|
294
|
+
```yaml
|
|
295
|
+
x-root: true
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
On a **path parameter**, not an operation. Declares that this resource has no create endpoint and must be supplied by config.
|
|
299
|
+
|
|
300
|
+
**Fallback:** inferred when a path param has no discoverable create op; everything beneath it is reported `UNSEEDABLE`.
|
|
301
|
+
|
|
302
|
+
### `x-cost` / `x-destructive` / `x-idempotent`
|
|
303
|
+
|
|
304
|
+
```yaml
|
|
305
|
+
x-cost: high # low | medium | high
|
|
306
|
+
x-destructive: true # excluded outside --profile paranoid
|
|
307
|
+
x-idempotent: true # oat asserts repeat-equivalence
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
`x-cost: high` is why an extract runs once instead of inside a permutation loop.
|
|
311
|
+
|
|
312
|
+
### `x-cleanup`
|
|
313
|
+
|
|
314
|
+
```yaml
|
|
315
|
+
x-cleanup: "DELETE /v1/projects/{project_id}/tables/{table_id}"
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
Explicit teardown when the delete op is not discoverable from the entity graph.
|
|
319
|
+
|
|
320
|
+
**Fallback:** the entity's `delete` op; oat reports leaked resources at end of run if there is none.
|
|
321
|
+
|
|
322
|
+
### `x-fresh-principal`
|
|
323
|
+
|
|
324
|
+
```yaml
|
|
325
|
+
x-fresh-principal: true
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
Operation mutates session or principal state and must run against a freshly provisioned principal (login, logout, token refresh, key rotation).
|
|
329
|
+
|
|
330
|
+
## What gets tested
|
|
331
|
+
|
|
332
|
+
55 property checks. None of them needs ground truth about what your data "should" be. A filter and its negation must partition the set; a page walk must cover the collection; a record read four ways must read the same.
|
|
333
|
+
|
|
334
|
+
| group | examples |
|
|
335
|
+
| --- | --- |
|
|
336
|
+
| Foundations | create lands, schema matches, page size is honoured |
|
|
337
|
+
| Query, single axis | filter, sort, search, select, cursor vs page, count |
|
|
338
|
+
| Query, composition | filter+sort, filter+select, search+filter, and the triples |
|
|
339
|
+
| Spec as adversary | declared filterable / sortable / selectable fields actually work |
|
|
340
|
+
| Isolation | cross-tenant item, filter, and existence disclosure |
|
|
341
|
+
| Authorization | rank is monotonic; invite grants then revokes |
|
|
342
|
+
| Tagged behaviour | immutable, soft-delete, invalidate, effects, async |
|
|
343
|
+
| Validation | enum, maxLength, required, content-type |
|
|
344
|
+
| Writes | PATCH minimality, idempotency, delete 404, lost update |
|
|
345
|
+
|
|
346
|
+
Every check reaches a stated outcome: a finding, *did not apply* (with what it needed), *blocked by an earlier failure* (with the cause), or *could not conclude* (with why). None of the last three is a pass.
|
|
347
|
+
|
|
348
|
+
Findings are graded by how much the document states. A cross-tenant read is a security finding when `x-tenant` declares the boundary, and an ambiguity when oat only inferred it.
|
|
349
|
+
|
|
350
|
+
## Reports
|
|
351
|
+
|
|
352
|
+
A run writes:
|
|
353
|
+
|
|
354
|
+
| file | |
|
|
355
|
+
| --- | --- |
|
|
356
|
+
| `oat-out/oat-report.md` | human report |
|
|
357
|
+
| `oat-out/oat-report.json` | CI / machine copy |
|
|
358
|
+
| `oat-out/issue-repro/*.sh` | one `curl` script per finding — omitted when the run is clean |
|
|
359
|
+
| `oat-out/progress.log` | live logfmt progress (also `.jsonl` and `.tsv`) |
|
|
360
|
+
|
|
361
|
+
Cascade suppression is transitive: one root cause produces one finding, not a page of consequences.
|
|
362
|
+
|
|
363
|
+
## Conformance
|
|
364
|
+
|
|
365
|
+
oat ships four reference backends — memory, SQLite, Postgres, and Cloudflare D1 — behind the same HTTP surface and the same 56 named defects. `oat conformance` injects each defect and asserts the matching diagnosis, then runs a correct backend and asserts nothing is reported.
|
|
366
|
+
|
|
367
|
+
They are served in five dialects (`postgrest`, `classic`, `linked`, `jsonapi`, `plain`) so a check that only works on one spelling is visible.
|
|
368
|
+
|
|
369
|
+
```bash
|
|
370
|
+
npm test # memory + sqlite, every dialect
|
|
371
|
+
oat conformance --fuzz 300 # random defect combinations
|
|
372
|
+
oat conformance --precision 60 # varied data, correct backend — any finding is a false positive
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
The testing path never touches a database. Runtime dependencies are `ajv`, `ajv-formats`, and `yaml`. SQL drivers are optional and loaded on demand.
|
|
376
|
+
|
|
377
|
+
## What this is not
|
|
378
|
+
|
|
379
|
+
oat is not a fuzzer. Tools like [Schemathesis](https://schemathesis.readthedocs.io/) generate inputs from your schema and check that responses validate — that finds crashes, 500s, and schema drift. Run both.
|
|
380
|
+
|
|
381
|
+
The difference is *statefulness*. A fuzzer sends independent requests and has no model of what the API should now contain, so it cannot ask whether the record it just created appears in the listing, whether a filter and its negation partition the set, or whether another tenant can read a record they should not see.
|
|
382
|
+
|
|
383
|
+
| | schema fuzzers | oat |
|
|
384
|
+
| --- | --- | --- |
|
|
385
|
+
| multi-step auth | header injection | declarative chain, JSON-path binding |
|
|
386
|
+
| out-of-band token | — | `resolveOutOfBand` hook |
|
|
387
|
+
| credential refresh | — | JWT `exp`, refreshes before expiry |
|
|
388
|
+
| expected-state oracle | none | shadow model of everything it created |
|
|
389
|
+
| N live principals | — | peer tenants + optional rank lattice |
|
|
390
|
+
| cross-tenant read / filter / disclosure | — | 3 checks |
|
|
391
|
+
| stateful invariants | random walk | 55 property checks |
|
|
392
|
+
| N-role permission matrix | — | monotonicity over `rank` |
|
|
393
|
+
| invite / delegated access | — | `x-invite` timeline |
|
|
394
|
+
|
|
395
|
+
## Labs
|
|
396
|
+
|
|
397
|
+
[`labs/`](./labs) is a family of real Hono backends on Cloudflare D1, used to iterate oat against correct APIs and planted bugs. Schema is generated from [`labs/worlds/catalog.ts`](./labs/worlds/catalog.ts). See [`labs/README.md`](./labs/README.md).
|
|
398
|
+
|
|
399
|
+
## License
|
|
400
|
+
|
|
401
|
+
MIT
|
package/bin/oat.js
ADDED