feat(14-01): record vendor calls with summer parity:upstream and replay them offline
- WriteUpstream masks vars, hashes long base64 JSON strings and refuses unmasked Authorization/X-Api-Key - multipart requests recorded as ordered parts; the fake compares parts and hashed payloads - loopback CONNECT recording proxy with a local ECDSA parity CA, script and forward modes - parity:upstream command, README and parity docs
This commit is contained in:
@@ -10,7 +10,7 @@ Besides building and scaffolding, the `summer` tool carries two groups of utilit
|
||||
|
||||
## API parity commands
|
||||
|
||||
When you port an existing backend to SummerCMS, its real responses are the contract your port must meet. The parity commands wrap [tide](../../modules/tide/README.md): they record fixtures from the reference backend and replay them against the port. Every address they listen on or connect to must be a loopback address, and captured secrets go to a variables file with mode 0600, outside the committed fixtures.
|
||||
When you port an existing backend to SummerCMS, its real responses are the contract your port must meet. The parity commands wrap [tide](../../modules/tide/README.md): they record fixtures from the reference backend and replay them against the port. Every address they listen on or connect to must be a loopback address (the one exception is `parity:upstream --mode forward`, which sends each recorded vendor call once to the real vendor), and captured secrets go to a variables file with mode 0600, outside the committed fixtures.
|
||||
|
||||
| Command | Flags | Purpose |
|
||||
|---------|-------|---------|
|
||||
@@ -18,6 +18,7 @@ When you port an existing backend to SummerCMS, its real responses are the contr
|
||||
| `summer parity:record` | `--spec`, `--target`, `--output`, `--rules`, `--vars`, `--update`; `--manifest`, `--fixtures`, `--next-batch`, `--resume`, `--allow-incomplete`, `--require-recorded` | Sends the requests of a YAML spec to a target and records the responses as a fixture, or records the missing cases of a route manifest in batches of at most 15. |
|
||||
| `summer parity:replay` | `--fixtures`, `--target`, `--vars`, `--manifest`, `--self-check`, `--require-recorded` | Replays recorded fixtures against a backend and reports the differences after masking IDs and timestamps. |
|
||||
| `summer parity:broadcasts` | `--flow`, `--target`, `--vars`, `--listen` (default `127.0.0.1:8424`), `--out`, `--name`, `--step`, `--ids`, `--rules`, `--api-key`, `--settle` (default `500ms`), `--pending` | Runs a flow against the reference backend with a fake Centrifugo server and records the realtime publications it sends into a golden file. |
|
||||
| `summer parity:upstream` | `--listen` (default `127.0.0.1:8425`), `--ca-dir`, `--out`, `--mode` (default `script`; or `forward`), `--script`, `--vars` | Runs a loopback HTTPS recording proxy with a locally generated CA. The reference backend sends its vendor API calls through it; each call is answered from the `--script` file (or forwarded once to the vendor in `forward` mode) and written, with credentials masked, into an upstream sidecar (`<fixture>.upstream.yaml`) when the command is interrupted. |
|
||||
|
||||
A typical port records once against the reference backend and replays against the Go backend on every change:
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: Parity testing
|
||||
description: Record the reference backend's responses and broadcasts with tide, replay them against the Go port and diff them after masking IDs and timestamps.
|
||||
description: Record the reference backend's responses, broadcasts and vendor calls with tide, replay them against the Go port and diff them after masking IDs and timestamps.
|
||||
section: services
|
||||
order: 150
|
||||
---
|
||||
@@ -124,3 +124,47 @@ summer parity:broadcasts --flow testdata/broadcasts/flows/post-lifecycle.yaml --
|
||||
Point the reference backend's Centrifugo API URL at the recorder (`127.0.0.1:8424` by default). `--step` keeps only the publications of one step, running the earlier steps as setup. Timestamps, the actor, the `*_at` dates inside a published album, a notification's own `created_at` and `id` under `$.data.payload` (the id only when it is a positive integer, as `{{id}}` unless a captured ID names it) and captured IDs are masked (`tide.NormalizePublications`), so the Go port's publications, recorded the same way, compare with `tide.DiffPublications`. A golden with `--pending` set is recorded but not yet asserted.
|
||||
|
||||
On the Go side, the memory realtime driver records publications the same way in tests; see [Realtime](realtime.md).
|
||||
|
||||
## Upstream exchanges
|
||||
|
||||
Some routes call outside services: a metadata API, a model vendor, a ticketing system. Two things must hold for them: the port must send the vendor the same requests the reference backend sent, and tests must never call the vendor. Upstream sidecars cover both.
|
||||
|
||||
A sidecar sits next to its fixture as `<fixture>.upstream.yaml` (`tide.UpstreamPath`) and holds the vendor exchanges of that fixture in recorded order:
|
||||
|
||||
```yaml
|
||||
version: 1
|
||||
exchanges:
|
||||
- request:
|
||||
method: POST
|
||||
url: https://api.example.com/v1/items?mode=fast
|
||||
headers:
|
||||
Authorization: Bearer {{secret:example-token}}
|
||||
Content-Type: application/json
|
||||
User-Agent: example-client/1.0
|
||||
body: '{"name":"widget"}'
|
||||
response:
|
||||
status: 201
|
||||
headers:
|
||||
Content-Type: application/json
|
||||
body: '{"id":7}'
|
||||
```
|
||||
|
||||
Only the compared request headers are kept (`tide.UpstreamCompareHeaders`: User-Agent, Accept, Content-Type, Authorization, X-Api-Key and the model vendors' version headers). A multipart request keeps an ordered list of `parts` instead of a body, with the SHA-256 of each file instead of its bytes, and a JSON string longer than 1024 characters that is base64 (an uploaded photo, for example) is stored as `{{sha256:<hex>}}`.
|
||||
|
||||
### Recording through the proxy
|
||||
|
||||
The reference backend's vendor URLs are usually code literals, so the way to capture what it really sends is a recording HTTPS proxy. `summer parity:upstream` runs one on `127.0.0.1:8425`:
|
||||
|
||||
```sh
|
||||
summer parity:upstream --ca-dir /tmp/parity/ca --vars /tmp/parity/vars.yaml \
|
||||
--script testdata/parity/vendor-script.yaml \
|
||||
--out testdata/parity/routes/POST_items_jwt__ok.upstream.yaml
|
||||
```
|
||||
|
||||
It prints the path of its CA certificate. Start the reference backend with `HTTPS_PROXY=http://127.0.0.1:8425` and that certificate as its curl and OpenSSL CA file, run the case, then interrupt the command to write the sidecar. In the default `script` mode each request is answered from the script file, a list of `responses` entries (`tide.UpstreamScriptResponse`: `method`, `host`, `path` and the `response` to send), so recording needs no real vendor and no real credential; a request no entry matches is answered with status 599 and the sidecar is not written. `--mode forward` sends each request once to the real vendor through a guarded client instead. Use it only by hand, never in CI.
|
||||
|
||||
The rules match the other recorders: the proxy listens on loopback only, the CA key is kept with mode 0600 in `--ca-dir` outside the fixtures tree, and every value from the variables file is replaced by its `{{name}}` placeholder. `tide.WriteUpstream` refuses to write an Authorization or X-Api-Key header that no variable masks.
|
||||
|
||||
### Replaying offline
|
||||
|
||||
In a Go test, load the sidecar with `tide.LoadUpstream`, build the fake with `tide.NewUpstreamFake` and hand it to the code under test through `fetchguard.WithTransport` (see [Outbound HTTP](outbound-http.md#testing-outbound-calls)). The fake answers each request from the next recorded exchange without dialing and asserts what the port sent: method, scheme, host, path, query (in any order), the compared headers with placeholders expanded from the variables store, and the body (JSON semantically, multipart part by part, base64 payloads by hash). `tide.UpstreamFake.Verify` then fails on any mismatch, any extra request and any recorded exchange the port never sent.
|
||||
|
||||
Reference in New Issue
Block a user