Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
181 changes: 181 additions & 0 deletions docs/openapi/bin/har-recorder.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
/**
* HAR 1.2 recorder for the compliance proxy.
*
* Captures request/response pairs as they pass through, so a single expensive
* BARA run yields a replayable corpus. Future spec changes can then be checked
* with `wiretap -z <har> -g -j /` in seconds instead of re-running the suite.
*
* Two things make the corpus practical rather than enormous:
* - Deduplication. A run produces hundreds of identical `POST /v3/roles`
* exchanges; only `maxPerKey` per (method, normalised path, status) are kept.
* - Scrubbing. Tokens and credentials never reach the file, so the corpus is
* safe to commit and diff in review.
*
* Uses only Node.js built-ins.
*/

'use strict';

const fs = require('fs');
const path = require('path');

const GUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi;

// Headers whose values must never be written to disk.
const SENSITIVE_HEADER_RE = /^(authorization|cookie|set-cookie|proxy-authorization)$|token|secret|password|credential/i;

// Content types we record as metadata only — binary or opaque payloads add
// megabytes and are worthless for schema validation.
const SKIP_BODY_RE = /^(multipart\/|application\/octet-stream|application\/zip|application\/x-tar|image\/|video\/|audio\/)/i;

class HarRecorder {
constructor(opts = {}) {
this.file = opts.file || path.join('out', 'traffic.har');
this.maxPerKey = opts.maxPerKey === undefined ? 1 : opts.maxPerKey;
this.maxBodyBytes = opts.maxBodyBytes === undefined ? 256 * 1024 : opts.maxBodyBytes;
this.entries = [];
this.counts = new Map();
this.skipped = 0;
}

/** Collapse instance-specific path segments so repeats share a key. */
static normalisePath(url) {
const pathOnly = String(url).split('?')[0];
return pathOnly.replace(GUID_RE, '{guid}');
}

/**
* Normalise the query string for keying. Parameter values are kept — a
* `?include=space` response carries an `included` block that `?include=org`
* does not, so collapsing them would lose the coverage the corpus exists
* for. Only GUIDs are masked, which is what actually varies per run.
*/
static normaliseQuery(url) {
const qs = String(url).split('?')[1];
if (!qs) return '';
return '?' + qs.split('&').filter(Boolean).sort()
.join('&').replace(GUID_RE, '{guid}');
}

static headerList(headers, scrub) {
const out = [];
for (const [name, value] of Object.entries(headers || {})) {
const values = Array.isArray(value) ? value : [value];
for (const v of values) {
out.push({
name,
value: scrub && SENSITIVE_HEADER_RE.test(name) ? 'REDACTED' : String(v),
});
}
}
return out;
}

static queryList(url) {
const qs = String(url).split('?')[1];
if (!qs) return [];
return qs.split('&').filter(Boolean).map(pair => {
const eq = pair.indexOf('=');
const raw = eq === -1 ? [pair, ''] : [pair.slice(0, eq), pair.slice(eq + 1)];
const decode = s => { try { return decodeURIComponent(s); } catch (_) { return s; } };
return { name: decode(raw[0]), value: decode(raw[1]) };
});
}

contentTypeOf(headers) {
for (const [k, v] of Object.entries(headers || {})) {
if (k.toLowerCase() === 'content-type') return String(Array.isArray(v) ? v[0] : v);
}
return '';
}

bodyText(buf, headers) {
if (!buf || buf.length === 0) return null;
if (SKIP_BODY_RE.test(this.contentTypeOf(headers))) return null;
if (buf.length > this.maxBodyBytes) return null;
const text = buf.toString('utf8');
// Reject anything that did not survive a utf8 round-trip — it is binary.
return Buffer.byteLength(text, 'utf8') === buf.length ? text : null;
}

/**
* Offer an exchange to the recorder. Returns true if it was kept.
*/
record(ex) {
const key = `${ex.method} ${HarRecorder.normalisePath(ex.url)}${HarRecorder.normaliseQuery(ex.url)} ${ex.status}`;
const seen = this.counts.get(key) || 0;
if (this.maxPerKey > 0 && seen >= this.maxPerKey) {
this.skipped++;
return false;
}
this.counts.set(key, seen + 1);

const reqText = this.bodyText(ex.requestBody, ex.requestHeaders);
const resText = this.bodyText(ex.responseBody, ex.responseHeaders);

const entry = {
startedDateTime: (ex.startedAt || new Date()).toISOString(),
time: ex.timeMs === undefined ? 0 : ex.timeMs,
request: {
method: ex.method,
url: ex.url.startsWith('http') ? ex.url : `http://localhost${ex.url}`,
httpVersion: 'HTTP/1.1',
cookies: [],
headers: HarRecorder.headerList(ex.requestHeaders, true),
// wiretap concatenates this with the query already present in
// `url`, yielding values like 'space?include=space'. The URL is
// authoritative, so leave the parsed copy empty.
queryString: [],
headersSize: -1,
bodySize: ex.requestBody ? ex.requestBody.length : 0,
},
response: {
status: ex.status,
statusText: ex.statusText || '',
httpVersion: 'HTTP/1.1',
cookies: [],
headers: HarRecorder.headerList(ex.responseHeaders, true),
content: {
size: ex.responseBody ? ex.responseBody.length : 0,
mimeType: this.contentTypeOf(ex.responseHeaders) || 'application/json',
...(resText === null ? {} : { text: resText }),
},
redirectURL: '',
headersSize: -1,
bodySize: ex.responseBody ? ex.responseBody.length : 0,
},
cache: {},
timings: { send: 0, wait: ex.timeMs === undefined ? 0 : ex.timeMs, receive: 0 },
};

if (reqText !== null) {
entry.request.postData = {
mimeType: this.contentTypeOf(ex.requestHeaders) || 'application/json',
text: reqText,
};
}

this.entries.push(entry);
return true;
}

get stats() {
return { kept: this.entries.length, deduped: this.skipped, distinct: this.counts.size };
}

/** Write the HAR to disk. Safe to call more than once. */
save() {
const har = {
log: {
version: '1.2',
creator: { name: 'capi-openapi-compliance', version: '1.0' },
entries: this.entries,
},
};
fs.mkdirSync(path.dirname(this.file), { recursive: true });
fs.writeFileSync(this.file, JSON.stringify(har, null, 2));
return this.file;
}
}

module.exports = { HarRecorder };
71 changes: 71 additions & 0 deletions docs/openapi/bin/render-check.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
#!/usr/bin/env bash
#
# Quick spec-render probe: sends a handful of authenticated requests through
# wiretap at a live CF API and summarises the violations. Answers "do the
# response schemas render?" in ~30s instead of the ~1h full compliance run.
#
# Usage:
# ./bin/render-check.sh [spec-file]
#
# Requires: cf CLI already targeted and logged in.

set -euo pipefail

SPEC="${1:-dist/latest/openapi.yaml}"
PORT=9490
REPORT=out/render-check.json
WT=./node_modules/@pb33f/wiretap/bin/wiretap

[ -f "$SPEC" ] || { echo "Spec not found: $SPEC (run 'yarn build' first)"; exit 1; }
[ -x "$WT" ] || { echo "wiretap not found at $WT (run 'yarn install')"; exit 1; }

API="$(cf api | awk '/API endpoint/ {print $3}')"
[ -n "$API" ] || { echo "cf is not targeted — run 'cf api <url>' and 'cf login'"; exit 1; }

TOKEN="$(cf oauth-token)"
case "$TOKEN" in
bearer*|Bearer*) ;;
*) echo "Could not get a token from 'cf oauth-token' — are you logged in?"; exit 1 ;;
esac

mkdir -p out
rm -f "$REPORT"

echo "spec: $SPEC"
echo "target: $API"

# Redirect all output: a background job writing to the tty gets SIGTTOU'd by zsh.
"$WT" -s "$SPEC" -u "$API" -p "$PORT" \
--stream-report --report-filename "$REPORT" \
> out/render-check-wiretap.log 2>&1 &
WT_PID=$!
trap 'kill "$WT_PID" 2>/dev/null || true' EXIT

for _ in $(seq 1 40); do
nc -z 127.0.0.1 "$PORT" 2>/dev/null && break
sleep 0.25
done
nc -z 127.0.0.1 "$PORT" 2>/dev/null || {
echo "wiretap did not come up on $PORT; see out/render-check-wiretap.log"; exit 1
}

for p in "/v3/apps" "/v3/apps?include=space" "/v3/spaces" \
"/v3/spaces?include=organization" "/v3/roles?include=user" "/v3/routes"; do
code="$(curl -s -o /dev/null -w '%{http_code}' -m 30 \
-H "Authorization: $TOKEN" "http://127.0.0.1:${PORT}${p}")"
echo " $code $p"
done

sleep 2
kill "$WT_PID" 2>/dev/null || true
wait "$WT_PID" 2>/dev/null || true
trap - EXIT

if [ ! -s "$REPORT" ]; then
echo
echo "No violations reported — wiretap found nothing to complain about."
exit 0
fi

echo
node bin/summarize-violations.js "$REPORT" --fields --top 15
Loading
Loading