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:
Jakub Zych
2026-10-03 19:55:42 +02:00
parent e6a67134d1
commit ee0004fb65
11 changed files with 1647 additions and 15 deletions

View File

@@ -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.