mirror of
https://github.com/screentinker/screentinker.git
synced 2026-06-20 05:02:54 -06:00
test(api): fix spec scope drift + guard it in CI; Redoc provenance
Self-review follow-ups, kept as a separate commit so the review trail is honest. - Spec drift: POST /widgets/preview was documented scope 'read' but the method-based tokenScopeGate enforces 'write' for any POST, so a read-token integrator following the published docs would hit a surprise 403. The code is right; fix the SPEC to match it. - Guard it forever: test/openapi-contract.test.js cross-checks every spec operation's x-required-scope against the enforcement rule, and that every documented path is a public (token-reachable) router - both derived from the same config/api-surface.js. Adds js-yaml (devDep) to parse the spec. Spec/enforcement drift now fails CI. - Vendored Redoc: add frontend/vendor/README.md (library, version 2.3.9, source, update steps) and drop the dangling //# sourceMappingURL line so /docs doesn't 404 in devtools. Remaining (non-security) test-coverage gaps tracked in #92. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
2ad9f54b8e
commit
33eaef826c
|
|
@ -1559,8 +1559,8 @@ paths:
|
||||||
post:
|
post:
|
||||||
tags: [widgets]
|
tags: [widgets]
|
||||||
summary: Render an unsaved widget config to HTML (non-persisting)
|
summary: Render an unsaved widget config to HTML (non-persisting)
|
||||||
description: 'Requires scope: read. Returns rendered HTML; does not create anything.'
|
description: 'Requires scope: write (any POST needs write under the scope ladder); renders to HTML without persisting anything.'
|
||||||
x-required-scope: read
|
x-required-scope: write
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
content:
|
content:
|
||||||
|
|
|
||||||
18
frontend/vendor/README.md
vendored
Normal file
18
frontend/vendor/README.md
vendored
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
# Vendored front-end libraries
|
||||||
|
|
||||||
|
Third-party libraries committed directly to the repo (not fetched from a CDN or built
|
||||||
|
from npm) so self-hosted / air-gapped instances work with no external dependency and no
|
||||||
|
build step.
|
||||||
|
|
||||||
|
## redoc.standalone.js
|
||||||
|
- **Library:** Redoc — renders the OpenAPI reference served at `/docs`.
|
||||||
|
- **Version:** 2.3.9
|
||||||
|
- **Source:** https://cdn.redoc.ly/redoc/v2.3.9/bundles/redoc.standalone.js
|
||||||
|
- **Why committed:** the API reference must render on offline instances — no CDN, no build step.
|
||||||
|
- **Regenerate / update:**
|
||||||
|
```sh
|
||||||
|
curl -sL https://cdn.redoc.ly/redoc/v2.3.9/bundles/redoc.standalone.js \
|
||||||
|
-o frontend/vendor/redoc.standalone.js
|
||||||
|
# drop the trailing sourcemap comment (the .map is intentionally not vendored)
|
||||||
|
sed -i '/sourceMappingURL=redoc.standalone.js.map/d' frontend/vendor/redoc.standalone.js
|
||||||
|
```
|
||||||
1
frontend/vendor/redoc.standalone.js
vendored
1
frontend/vendor/redoc.standalone.js
vendored
File diff suppressed because one or more lines are too long
31
server/package-lock.json
generated
31
server/package-lock.json
generated
|
|
@ -26,6 +26,7 @@
|
||||||
"uuid": "^14.0.0"
|
"uuid": "^14.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"js-yaml": "^4.2.0",
|
||||||
"socket.io-client": "^4.8.3"
|
"socket.io-client": "^4.8.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -692,6 +693,13 @@
|
||||||
"streamx": "^2.15.0"
|
"streamx": "^2.15.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/argparse": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Python-2.0"
|
||||||
|
},
|
||||||
"node_modules/array-flatten": {
|
"node_modules/array-flatten": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
|
||||||
|
|
@ -2186,6 +2194,29 @@
|
||||||
"@pkgjs/parseargs": "^0.11.0"
|
"@pkgjs/parseargs": "^0.11.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/js-yaml": {
|
||||||
|
"version": "4.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz",
|
||||||
|
"integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==",
|
||||||
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/puzrin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/nodeca"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"argparse": "^2.0.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"js-yaml": "bin/js-yaml.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/json-bigint": {
|
"node_modules/json-bigint": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz",
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@
|
||||||
"uuid": "^14.0.0"
|
"uuid": "^14.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"js-yaml": "^4.2.0",
|
||||||
"socket.io-client": "^4.8.3"
|
"socket.io-client": "^4.8.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
52
server/test/openapi-contract.test.js
Normal file
52
server/test/openapi-contract.test.js
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Contract tests for the published OpenAPI spec. The spec is the integrator-facing
|
||||||
|
// contract, so it must not drift from what the server actually enforces. These parse
|
||||||
|
// docs/openapi.yaml directly (no server needed) and are derived from the same
|
||||||
|
// config/api-surface.js the server mounts from.
|
||||||
|
//
|
||||||
|
// Born from a real self-review finding: POST /widgets/preview was documented as scope
|
||||||
|
// 'read' while the method-based tokenScopeGate enforces 'write' for any POST, so a
|
||||||
|
// read-token integrator following the docs would hit a surprise 403. This makes that
|
||||||
|
// class of drift fail CI forever after.
|
||||||
|
|
||||||
|
const { test } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const yaml = require('js-yaml');
|
||||||
|
const { PUBLIC_ROUTERS, JWT_ONLY_ROUTERS } = require('../config/api-surface');
|
||||||
|
|
||||||
|
const spec = yaml.load(fs.readFileSync(path.join(__dirname, '..', '..', 'docs', 'openapi.yaml'), 'utf8'));
|
||||||
|
const METHODS = ['get', 'post', 'put', 'delete', 'patch', 'head'];
|
||||||
|
// Spec paths are written without the /api prefix (servers: [{ url: /api }]).
|
||||||
|
const PUBLIC_PREFIXES = PUBLIC_ROUTERS.map(r => r.path.replace(/^\/api/, ''));
|
||||||
|
const JWT_ONLY_PREFIXES = JWT_ONLY_ROUTERS.map(r => r.path.replace(/^\/api/, ''));
|
||||||
|
const underPrefix = (p, prefixes) => prefixes.some(pre => p === pre || p.startsWith(pre + '/'));
|
||||||
|
|
||||||
|
test('openapi: every operation x-required-scope matches the method-based enforcement', () => {
|
||||||
|
// Mirrors tokenScopeGate (GET/HEAD -> read, mutations -> write) + requireScope('full')
|
||||||
|
// on the operational command route. Public render endpoints (security: []) carry no scope.
|
||||||
|
const mismatches = [];
|
||||||
|
for (const [p, ops] of Object.entries(spec.paths || {})) {
|
||||||
|
for (const [m, op] of Object.entries(ops)) {
|
||||||
|
if (!METHODS.includes(m) || !op || typeof op !== 'object') continue;
|
||||||
|
if (Array.isArray(op.security) && op.security.length === 0) continue; // unauthenticated render
|
||||||
|
const expected = (m === 'get' || m === 'head') ? 'read' : (p.includes('command') ? 'full' : 'write');
|
||||||
|
if (op['x-required-scope'] !== expected) {
|
||||||
|
mismatches.push(`${m.toUpperCase()} ${p}: spec='${op['x-required-scope']}' enforcement='${expected}'`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.deepEqual(mismatches, [], 'spec x-required-scope drifted from enforcement:\n' + mismatches.join('\n'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('openapi: every documented path is a token-reachable (public) router, never JWT-only', () => {
|
||||||
|
// The spec must never advertise a JWT-only / privileged route as part of the token
|
||||||
|
// surface (it would invite an integrator to call something their token can't reach).
|
||||||
|
const offenders = [];
|
||||||
|
for (const p of Object.keys(spec.paths || {})) {
|
||||||
|
if (underPrefix(p, JWT_ONLY_PREFIXES) || !underPrefix(p, PUBLIC_PREFIXES)) offenders.push(p);
|
||||||
|
}
|
||||||
|
assert.deepEqual(offenders, [], 'spec documents non-public paths:\n' + offenders.join('\n'));
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue