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

@@ -39,6 +39,7 @@ func toolCommands() []bonfire.Command {
parityRecordCommand(),
parityReplayCommand(),
parityBroadcastsCommand(),
parityUpstreamCommand(),
delegateCommand("migrate", "Run plugin migrations in the app binary"),
delegateRollbackCommand(),
delegateCommand("migrate:status", "Show per-plugin migration history in the app binary"),

View File

@@ -19,7 +19,7 @@ func TestToolCommandNames(t *testing.T) {
for _, c := range toolCommands() {
names = append(names, c.Name)
}
for _, want := range []string{"build", "make:plugin", "make:model", "make:migration", "make:command", "make:job", "make:admin-controller", "plugin:add", "dev", "migrate", "migrate:rollback", "migrate:status", "serve", "queue:work", "queue:clear", "schedule:run", "parity:broadcasts", "docs:build", "docs:sync", "docs:serve"} {
for _, want := range []string{"build", "make:plugin", "make:model", "make:migration", "make:command", "make:job", "make:admin-controller", "plugin:add", "dev", "migrate", "migrate:rollback", "migrate:status", "serve", "queue:work", "queue:clear", "schedule:run", "parity:broadcasts", "parity:upstream", "docs:build", "docs:sync", "docs:serve"} {
if !slices.Contains(names, want) {
t.Fatalf("missing %s in %v", want, names)
}
@@ -34,6 +34,7 @@ func TestToolCommandNames(t *testing.T) {
"make:admin-controller": {"[plugin] [name]"},
"schedule:run": {"--once"},
"parity:broadcasts": {"--flow", "--step", "--ids", "127.0.0.1:8424"},
"parity:upstream": {"--listen", "--ca-dir", "--out", "--mode", "--script", "--vars", "127.0.0.1:8425"},
"docs:build": {"--out", "--src", "--root", "--base-url", "--check"},
"docs:sync": {"--src", "--root"},
"docs:serve": {"--root", "--src", "--base-url", "--addr", "--allow-remote", "127.0.0.1:8088"},

View File

@@ -89,6 +89,67 @@ func parityBroadcastsCommand() bonfire.Command {
}
}
func parityUpstreamCommand() bonfire.Command {
return bonfire.Command{
Name: "parity:upstream",
Description: "Record the vendor HTTPS calls a reference backend sends through a loopback recording proxy into an upstream sidecar",
Flags: []bonfire.Flag{
{Name: "listen", Description: "Loopback address of the recording proxy", Default: tide.DefaultUpstreamProxyListen},
{Name: "ca-dir", Description: "Directory of the local parity CA, outside the fixtures tree (key kept mode 0600)"},
{Name: "out", Description: "Destination sidecar path (<fixture>.upstream.yaml)"},
{Name: "mode", Description: "script (answer from --script) or forward (send once to the real vendor)", Default: "script"},
{Name: "script", Description: "YAML file of scripted vendor responses (script mode)"},
{Name: "vars", Description: "Private mode-0600 variable store outside fixtures; its values are masked in the sidecar"},
},
Run: runParityUpstream,
}
}
func runParityUpstream(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
caDir, err := requireFlag(in, "ca-dir", "parity:upstream")
if err != nil {
return err
}
outPath, err := requireFlag(in, "out", "parity:upstream")
if err != nil {
return err
}
varsPath, err := requireFlag(in, "vars", "parity:upstream")
if err != nil {
return err
}
listen := flagValue(in, "listen")
if listen == "" {
listen = tide.DefaultUpstreamProxyListen
}
proxy, err := tide.NewUpstreamProxy(tide.UpstreamProxyConfig{
Listen: listen,
CADir: caDir,
Out: outPath,
Mode: flagValue(in, "mode"),
Script: flagValue(in, "script"),
VarsPath: varsPath,
})
if err != nil {
return err
}
certPath := filepath.Join(caDir, "parity-ca.pem")
if abs, err := filepath.Abs(certPath); err == nil {
certPath = abs
}
out.Info(fmt.Sprintf("upstream proxy listening on %s", listen))
out.Info(fmt.Sprintf("parity CA certificate: %s", certPath))
out.Info(fmt.Sprintf("point the reference backend at it: HTTPS_PROXY=http://%s, curl.cainfo and openssl.cafile=%s", listen, certPath))
if err := proxy.ListenAndServe(ctx); err != nil {
return err
}
if err := proxy.Flush(); err != nil {
return err
}
out.Success(fmt.Sprintf("wrote %s", outPath))
return nil
}
func runParityBroadcasts(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
flowPath, err := requireFlag(in, "flow", "parity:broadcasts")
if err != nil {

View File

@@ -15,7 +15,7 @@ import (
func TestParityCommandContract(t *testing.T) {
names := commandNames()
for _, want := range []string{"parity:record", "parity:proxy", "parity:replay"} {
for _, want := range []string{"parity:record", "parity:proxy", "parity:replay", "parity:upstream"} {
if !containsName(names, want) {
t.Fatalf("missing %s in %v", want, names)
}
@@ -82,6 +82,29 @@ func TestParityCommandContract(t *testing.T) {
}
assertNoSecrets(t, out+"\n"+errString(err), jwt, inv)
upDir := t.TempDir()
upScript := filepath.Join(upDir, "script.yaml")
if err := os.WriteFile(upScript, []byte("responses: []\n"), 0o644); err != nil {
t.Fatal(err)
}
upOut := filepath.Join(outDir, "routes", "POST_x__ok.upstream.yaml")
out, err = runParityCapture("parity:upstream", "--listen", "0.0.0.0:8425", "--ca-dir", filepath.Join(upDir, "ca"), "--out", upOut, "--script", upScript, "--vars", filepath.Join(upDir, "vars.yaml"))
if err == nil || !strings.Contains(err.Error(), "loopback") {
t.Fatalf("upstream non-loopback: %v %s", err, out)
}
out, err = runParityCapture("parity:upstream", "--ca-dir", filepath.Join(upDir, "ca"), "--out", upOut, "--script", upScript, "--vars", filepath.Join(outDir, "routes", "vars.yaml"))
if err == nil || !strings.Contains(err.Error(), "vars file") {
t.Fatalf("upstream vars inside output dir: %v %s", err, out)
}
out, err = runParityCapture("parity:upstream", "--ca-dir", filepath.Join(outDir, "routes", "ca"), "--out", upOut, "--script", upScript, "--vars", filepath.Join(upDir, "vars.yaml"))
if err == nil || !strings.Contains(err.Error(), "ca dir") {
t.Fatalf("upstream CA inside output dir: %v %s", err, out)
}
out, err = runParityCapture("parity:upstream", "--out", upOut, "--vars", filepath.Join(upDir, "vars.yaml"))
if err == nil || !strings.Contains(err.Error(), "--ca-dir") {
t.Fatalf("upstream missing --ca-dir: %v %s", err, out)
}
missing := filepath.Join(outDir, "missing-var.yaml")
if err := os.WriteFile(missing, []byte("version: 1\nname: miss\nsteps:\n - id: a\n request:\n method: GET\n path: /x/{{missing}}\n response:\n status: 200\n"), 0o644); err != nil {
t.Fatal(err)