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:
@@ -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"),
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user