feat(11-06): add the tide fake Centrifugo recorder, broadcast goldens and parity:broadcasts
- CentrifugoRecorder records publish/broadcast requests (method, path, whether the API key matched, JSON body) and binds loopback only - BroadcastGolden load/write, NormalizePublications (timestamps, actor, captured ids only) and DiffPublications (structural, key order ignored) - RecordBroadcasts runs a flow or one step against a loopback backend - summer parity:broadcasts wraps it; README documents format and rules
This commit is contained in:
@@ -38,6 +38,7 @@ func toolCommands() []bonfire.Command {
|
|||||||
parityProxyCommand(),
|
parityProxyCommand(),
|
||||||
parityRecordCommand(),
|
parityRecordCommand(),
|
||||||
parityReplayCommand(),
|
parityReplayCommand(),
|
||||||
|
parityBroadcastsCommand(),
|
||||||
delegateCommand("migrate", "Run plugin migrations in the app binary"),
|
delegateCommand("migrate", "Run plugin migrations in the app binary"),
|
||||||
delegateRollbackCommand(),
|
delegateRollbackCommand(),
|
||||||
delegateCommand("migrate:status", "Show per-plugin migration history in the app binary"),
|
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() {
|
for _, c := range toolCommands() {
|
||||||
names = append(names, c.Name)
|
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"} {
|
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"} {
|
||||||
if !slices.Contains(names, want) {
|
if !slices.Contains(names, want) {
|
||||||
t.Fatalf("missing %s in %v", want, names)
|
t.Fatalf("missing %s in %v", want, names)
|
||||||
}
|
}
|
||||||
@@ -33,6 +33,7 @@ func TestToolCommandNames(t *testing.T) {
|
|||||||
"make:job": {"[plugin] [name]"},
|
"make:job": {"[plugin] [name]"},
|
||||||
"make:admin-controller": {"[plugin] [name]"},
|
"make:admin-controller": {"[plugin] [name]"},
|
||||||
"schedule:run": {"--once"},
|
"schedule:run": {"--once"},
|
||||||
|
"parity:broadcasts": {"--flow", "--step", "--ids", "127.0.0.1:8424"},
|
||||||
}
|
}
|
||||||
for cmd, wants := range helpWants {
|
for cmd, wants := range helpWants {
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
|
|||||||
@@ -3,8 +3,10 @@ package main
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"git.golem15.com/golem15/summercms/modules/bonfire"
|
"git.golem15.com/golem15/summercms/modules/bonfire"
|
||||||
"git.golem15.com/golem15/summercms/modules/tide"
|
"git.golem15.com/golem15/summercms/modules/tide"
|
||||||
@@ -65,6 +67,118 @@ func parityReplayCommand() bonfire.Command {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func parityBroadcastsCommand() bonfire.Command {
|
||||||
|
return bonfire.Command{
|
||||||
|
Name: "parity:broadcasts",
|
||||||
|
Description: "Record the Centrifugo publications a reference backend sends during a flow into a golden",
|
||||||
|
Flags: []bonfire.Flag{
|
||||||
|
{Name: "flow", Description: "YAML flow spec to run against the target"},
|
||||||
|
{Name: "target", Description: "Loopback base URL of the reference backend"},
|
||||||
|
{Name: "vars", Description: "Private mode-0600 variable store outside fixtures"},
|
||||||
|
{Name: "listen", Description: "Loopback address of the fake Centrifugo recorder", Default: tide.DefaultCentrifugoListen},
|
||||||
|
{Name: "out", Description: "Destination golden path"},
|
||||||
|
{Name: "name", Description: "Golden name (default: the step, else the flow name)"},
|
||||||
|
{Name: "step", Description: "Record only this step's publications; earlier steps run as setup"},
|
||||||
|
{Name: "ids", Description: "Comma-separated id:* variables to replace with placeholders"},
|
||||||
|
{Name: "rules", Description: "Committed YAML capture rules"},
|
||||||
|
{Name: "api-key", Description: "Centrifugo API key the backend sends (default: $PARITY_CENTRIFUGO_API_KEY)"},
|
||||||
|
{Name: "settle", Description: "Wait for late publications after each step (Go duration)", Default: tide.DefaultBroadcastSettle.String()},
|
||||||
|
{Name: "pending", Description: "Mark the golden recorded but not yet asserted, with this reason"},
|
||||||
|
},
|
||||||
|
Run: runParityBroadcasts,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runParityBroadcasts(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
|
||||||
|
flowPath, err := requireFlag(in, "flow", "parity:broadcasts")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
target, err := requireFlag(in, "target", "parity:broadcasts")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
outPath, err := requireFlag(in, "out", "parity:broadcasts")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
varsPath, err := requireFlag(in, "vars", "parity:broadcasts")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := varsOutsideDir(varsPath, filepath.Dir(outPath)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
apiKey := flagValue(in, "api-key")
|
||||||
|
if apiKey == "" {
|
||||||
|
apiKey = strings.TrimSpace(os.Getenv("PARITY_CENTRIFUGO_API_KEY"))
|
||||||
|
}
|
||||||
|
if apiKey == "" {
|
||||||
|
return fmt.Errorf("parity:broadcasts requires --api-key or PARITY_CENTRIFUGO_API_KEY")
|
||||||
|
}
|
||||||
|
spec, err := tide.LoadFlow(flowPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
cfg := tide.BroadcastConfig{
|
||||||
|
Target: target,
|
||||||
|
Listen: flagValue(in, "listen"),
|
||||||
|
APIKey: apiKey,
|
||||||
|
Step: flagValue(in, "step"),
|
||||||
|
Name: flagValue(in, "name"),
|
||||||
|
}
|
||||||
|
if s := flagValue(in, "settle"); s != "" {
|
||||||
|
d, err := time.ParseDuration(s)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("parity:broadcasts --settle: %w", err)
|
||||||
|
}
|
||||||
|
cfg.Settle = d
|
||||||
|
}
|
||||||
|
for _, id := range strings.Split(flagValue(in, "ids"), ",") {
|
||||||
|
if id = strings.TrimSpace(id); id != "" {
|
||||||
|
cfg.IDs = append(cfg.IDs, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rc := tide.RecordConfig{}
|
||||||
|
if err := attachRulesAndVars(in, &rc); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
cfg.Rules, cfg.Store = rc.Rules, rc.Store
|
||||||
|
golden, err := tide.RecordBroadcasts(ctx, spec, cfg)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
golden.Pending = flagValue(in, "pending")
|
||||||
|
if err := tide.WriteBroadcastGolden(outPath, golden); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out.Success(fmt.Sprintf("recorded %d publications into %s", len(golden.Publications), outPath))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// varsOutsideDir refuses a vars store inside the directory a golden is
|
||||||
|
// written to, so captured secrets never sit next to committed files.
|
||||||
|
func varsOutsideDir(varsPath, dir string) error {
|
||||||
|
absVars, err := filepath.Abs(varsPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
absDir, err := filepath.Abs(dir)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if eval, err := filepath.EvalSymlinks(absDir); err == nil {
|
||||||
|
absDir = eval
|
||||||
|
}
|
||||||
|
if eval, err := filepath.EvalSymlinks(filepath.Dir(absVars)); err == nil {
|
||||||
|
absVars = filepath.Join(eval, filepath.Base(absVars))
|
||||||
|
}
|
||||||
|
if absVars == absDir || strings.HasPrefix(absVars, absDir+string(os.PathSeparator)) {
|
||||||
|
return fmt.Errorf("parity:broadcasts: vars file %q must be outside %q", varsPath, dir)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func runParityProxy(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
|
func runParityProxy(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
|
||||||
rulesPath, err := requireFlag(in, "rules", "parity:proxy")
|
rulesPath, err := requireFlag(in, "rules", "parity:proxy")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ HTTP parity toolkit that records request and response fixtures from a reference
|
|||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
`tide` is the acceptance-test engine for porting an existing WinterCMS or other PHP backend to SummerCMS: the reference backend's real responses define the contract, and the Go port must reproduce them. It records YAML fixtures (flows of request and response steps) either by driving a spec against a target or by sitting as a loopback reverse proxy in front of the reference backend while a real client uses it, then replays those fixtures against the port and diffs the responses after masking values that legitimately differ, such as IDs and timestamps. The `summer` CLI's parity:proxy, parity:record and parity:replay commands are thin wrappers around this package. It has no WinterCMS counterpart.
|
`tide` is the acceptance-test engine for porting an existing WinterCMS or other PHP backend to SummerCMS: the reference backend's real responses define the contract, and the Go port must reproduce them. It records YAML fixtures (flows of request and response steps) either by driving a spec against a target or by sitting as a loopback reverse proxy in front of the reference backend while a real client uses it, then replays those fixtures against the port and diffs the responses after masking values that legitimately differ, such as IDs and timestamps. It also checks realtime side effects: a fake Centrifugo recorder captures the publications a backend sends while a flow runs, and broadcast golden files hold them normalised for comparison. The `summer` CLI's parity:proxy, parity:record, parity:replay and parity:broadcasts commands are thin wrappers around this package. It has no WinterCMS counterpart.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
@@ -16,6 +16,9 @@ HTTP parity toolkit that records request and response fixtures from a reference
|
|||||||
- Capture rules: `tide.Rules` (loaded with `tide.LoadRules`) decide which request and response headers are kept per route and which values are captured into variables, from response JSON paths, headers, redirect query strings or form fields.
|
- Capture rules: `tide.Rules` (loaded with `tide.LoadRules`) decide which request and response headers are kept per route and which values are captured into variables, from response JSON paths, headers, redirect query strings or form fields.
|
||||||
- Variables: `tide.Store` holds captured values such as tokens and IDs in a mode-0600 file, `tide.Store.Expand` substitutes `{{name}}` placeholders before a request is sent, and `tide.ScrubStep` puts placeholders back into fixtures. Scrubbing fails when a step still holds an unclassified token- or password-shaped value, so credentials do not leak into committed fixtures.
|
- Variables: `tide.Store` holds captured values such as tokens and IDs in a mode-0600 file, `tide.Store.Expand` substitutes `{{name}}` placeholders before a request is sent, and `tide.ScrubStep` puts placeholders back into fixtures. Scrubbing fails when a step still holds an unclassified token- or password-shaped value, so credentials do not leak into committed fixtures.
|
||||||
- Replay and diff: `tide.ReplayFlow` re-sends each step, compares status, a fixed set of contract headers and the body, and returns `tide.Result` with per-step `tide.Diff` entries. JSON bodies are compared structurally after masking `id`, `*_id` and `*_ids` values and `*_at` timestamps; other bodies are compared byte for byte.
|
- Replay and diff: `tide.ReplayFlow` re-sends each step, compares status, a fixed set of contract headers and the body, and returns `tide.Result` with per-step `tide.Diff` entries. JSON bodies are compared structurally after masking `id`, `*_id` and `*_ids` values and `*_at` timestamps; other bodies are compared byte for byte.
|
||||||
|
- Fake Centrifugo: `tide.NewCentrifugoRecorder` returns an `http.Handler` that records every POST to a path ending in `/publish` or `/broadcast` as a `tide.Publication` (method, path, whether `Authorization: apikey <key>` carried the configured key, JSON body) and answers `{"result":{}}`. Paths ending in `/presence` answer `{"result":{"presence":{}}}`, `/unsubscribe` and `/info` answer `{"result":{}}`, anything else is 404. Bodies are capped at `tide.MaxPublicationBody` (1 MiB). The API key is only compared, never stored. `tide.CentrifugoRecorder.ListenAndServe` binds loopback addresses only, like the recording proxy.
|
||||||
|
- Broadcast goldens: `tide.RecordBroadcasts` runs a flow against a loopback reference backend whose Centrifugo API URL points at a recorder on `tide.DefaultCentrifugoListen` (`127.0.0.1:8424`). With `tide.BroadcastConfig` `Step` set, earlier steps run as setup and only that step's publications are kept. The result is a `tide.BroadcastGolden`, written with `tide.WriteBroadcastGolden` (which refuses token-shaped bodies) and read strictly with `tide.LoadBroadcastGolden`. A golden with `pending` set is recorded but not yet asserted.
|
||||||
|
- Broadcast normalisation: `tide.NormalizePublications` masks only `$.data.timestamp` and `$.data.payload.timestamp` (ISO 8601 with an offset) as `"{{timestamp}}"`, `$.data.payload.actor` (an object of exactly `user_id` and `name`) as `"{{actor}}"`, and values equal to an `id:*` variable of a `tide.Store`: numbers or strings under `id`, `*_id` or `*_ids` keys, and the numeric last segment of a channel name such as `room:12`. A masked number is written as a bare `{{id:name}}`, so a number that becomes a string still differs. A value matching two id variables is an error. `tide.DiffPublications` compares the count, method, path, authorization flag and body (structurally, key order ignored) and reports paths such as `$[0].body.data.payload.id`.
|
||||||
- Manifests: `tide.Manifest` lists routes with auth groups, a pending or ported status, cases and fixture paths; `tide.RecordManifest` records missing cases in batches of at most `tide.MaxBatch`, and `tide.ReplayManifest` replays every recorded case into a `tide.Coverage` table.
|
- Manifests: `tide.Manifest` lists routes with auth groups, a pending or ported status, cases and fixture paths; `tide.RecordManifest` records missing cases in batches of at most `tide.MaxBatch`, and `tide.ReplayManifest` replays every recorded case into a `tide.Coverage` table.
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
@@ -49,6 +52,33 @@ for _, step := range res.Steps {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Record the publications a reference backend sends for one flow step, then compare a new backend's publications with them:
|
||||||
|
|
||||||
|
```go
|
||||||
|
golden, err := tide.RecordBroadcasts(ctx, spec, tide.BroadcastConfig{
|
||||||
|
Target: "http://127.0.0.1:8000",
|
||||||
|
APIKey: "test-only-key",
|
||||||
|
Store: store,
|
||||||
|
Step: "delete",
|
||||||
|
IDs: []string{"id:room", "id:item"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tide.WriteBroadcastGolden("testdata/broadcasts/deleted.yaml", golden); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Later, with the new backend publishing to rec (a tide.CentrifugoRecorder):
|
||||||
|
norm, err := tide.NormalizePublications(rec.Publications(), idsOfTheNewBackend)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, d := range tide.DiffPublications(golden.Publications, norm) {
|
||||||
|
fmt.Printf("%s: want %s, got %s\n", d.Path, d.Expected, d.Actual)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## API reference
|
## API reference
|
||||||
|
|
||||||
| Identifier | Description |
|
| Identifier | Description |
|
||||||
@@ -71,13 +101,52 @@ for _, step := range res.Steps {
|
|||||||
| `tide.ValidateManifest` | Checks a manifest in `tide.ModeAllowIncomplete` or `tide.ModeRequireRecorded` mode. |
|
| `tide.ValidateManifest` | Checks a manifest in `tide.ModeAllowIncomplete` or `tide.ModeRequireRecorded` mode. |
|
||||||
| `tide.RecordManifest` | Records a manifest's seed flow and missing route cases in batches. |
|
| `tide.RecordManifest` | Records a manifest's seed flow and missing route cases in batches. |
|
||||||
| `tide.ReplayManifest` | Replays every recorded route case and builds a coverage table. |
|
| `tide.ReplayManifest` | Replays every recorded route case and builds a coverage table. |
|
||||||
|
| `tide.CentrifugoRecorder` | Fake Centrifugo HTTP API: `tide.CentrifugoRecorder.Publications`, `tide.CentrifugoRecorder.Reset`, `tide.CentrifugoRecorder.ListenAndServe`. |
|
||||||
|
| `tide.NewCentrifugoRecorder` | Builds a recorder from `tide.CentrifugoRecorderOptions` (the expected API key). |
|
||||||
|
| `tide.Publication` | One recorded publish or broadcast request: method, path, authorization flag, JSON body. |
|
||||||
|
| `tide.RecordBroadcasts` | Runs a flow (or one step of it) against a loopback backend and returns its normalised publications as a golden. |
|
||||||
|
| `tide.BroadcastConfig` | Target, recorder address, API key, vars store, step, id variables and settle time for `tide.RecordBroadcasts`. |
|
||||||
|
| `tide.BroadcastGolden` | Versioned broadcast golden: name, flow, optional pending reason, publications. |
|
||||||
|
| `tide.LoadBroadcastGolden` | Reads a golden strictly and checks every body parses. |
|
||||||
|
| `tide.WriteBroadcastGolden` | Writes a golden atomically, refusing token-shaped bodies. |
|
||||||
|
| `tide.NormalizePublications` | Masks timestamps, the actor and captured ids in publication bodies. |
|
||||||
|
| `tide.DiffPublications` | Structural diff of two publication lists. |
|
||||||
|
| `tide.DefaultCentrifugoListen` | Default recorder address, `127.0.0.1:8424`. |
|
||||||
| `tide.Coverage` | Recorded, passing, failing and unrecorded counts, with table rows and a summary line. |
|
| `tide.Coverage` | Recorded, passing, failing and unrecorded counts, with table rows and a summary line. |
|
||||||
|
|
||||||
|
## CLI commands
|
||||||
|
|
||||||
|
`summer parity:broadcasts` wraps `tide.RecordBroadcasts` and `tide.WriteBroadcastGolden`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
summer parity:broadcasts \
|
||||||
|
--flow testdata/broadcasts/flows/item-lifecycle.yaml \
|
||||||
|
--step delete --name deleted --ids id:room,id:item \
|
||||||
|
--target http://127.0.0.1:8000 \
|
||||||
|
--vars /tmp/parity/vars.yaml \
|
||||||
|
--api-key test-only-key \
|
||||||
|
--out testdata/broadcasts/deleted.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
`--listen` defaults to `127.0.0.1:8424`, `--api-key` to `$PARITY_CENTRIFUGO_API_KEY` and `--settle` to `500ms`. `--ids` defaults to the `id:*` variables the flow mentions. `--pending` stores a reason the golden is not asserted yet. The target and the listen address must be loopback, and the vars file must be outside the golden's directory. The command writes:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
version: 1
|
||||||
|
name: deleted
|
||||||
|
flow: "items/lifecycle#delete"
|
||||||
|
publications:
|
||||||
|
- method: POST
|
||||||
|
path: /api/publish
|
||||||
|
authorization: true
|
||||||
|
body: |-
|
||||||
|
{"channel":"room:{{id:room}}","data":{"event":"deleted","payload":{"id":{{id:item}},"actor":"{{actor}}","timestamp":"{{timestamp}}"},"timestamp":"{{timestamp}}"}}
|
||||||
|
```
|
||||||
|
|
||||||
## Dependencies
|
## Dependencies
|
||||||
|
|
||||||
- SummerCMS modules: none.
|
- SummerCMS modules: none.
|
||||||
- Third-party: `github.com/goccy/go-yaml` (fixture, rules and manifest parsing).
|
- Third-party: `github.com/goccy/go-yaml` (fixture, rules and manifest parsing).
|
||||||
- Standard library: `net/http`, `net/http/httputil`, `encoding/json`, `crypto/sha256`, among others.
|
- Standard library: `net/http`, `net/http/httputil`, `encoding/json`, `crypto/sha256`, `crypto/subtle`, among others.
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
@@ -85,4 +154,4 @@ for _, step := range res.Steps {
|
|||||||
go test ./modules/tide/...
|
go test ./modules/tide/...
|
||||||
```
|
```
|
||||||
|
|
||||||
The tests run recording, the proxy and replay against local `net/http/httptest` servers and use the sample spec in `modules/tide/testdata/`; they need no external services.
|
The tests run recording, the proxy, replay and the fake Centrifugo recorder against local `net/http/httptest` servers and use the sample spec in `modules/tide/testdata/`; they need no external services.
|
||||||
|
|||||||
370
modules/tide/centrifugo.go
Normal file
370
modules/tide/centrifugo.go
Normal file
@@ -0,0 +1,370 @@
|
|||||||
|
package tide
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/subtle"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DefaultCentrifugoListen is the loopback address the fake Centrifugo
|
||||||
|
// recorder binds when no other address is given.
|
||||||
|
const DefaultCentrifugoListen = "127.0.0.1:8424"
|
||||||
|
|
||||||
|
// MaxPublicationBody caps one recorded Centrifugo API request body.
|
||||||
|
const MaxPublicationBody = 1 << 20
|
||||||
|
|
||||||
|
// DefaultBroadcastSettle is how long RecordBroadcasts waits after a step
|
||||||
|
// for publications that arrive after its HTTP response.
|
||||||
|
const DefaultBroadcastSettle = 500 * time.Millisecond
|
||||||
|
|
||||||
|
// Publication is one publish or broadcast request received by the fake
|
||||||
|
// Centrifugo recorder. The Authorization header value is never kept: only
|
||||||
|
// whether it carried the expected API key.
|
||||||
|
type Publication struct {
|
||||||
|
Method string `yaml:"method"`
|
||||||
|
Path string `yaml:"path"`
|
||||||
|
Authorization bool `yaml:"authorization"`
|
||||||
|
Body json.RawMessage `yaml:"body"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CentrifugoRecorderOptions configures NewCentrifugoRecorder.
|
||||||
|
type CentrifugoRecorderOptions struct {
|
||||||
|
// APIKey is the key the backend under test sends as
|
||||||
|
// "Authorization: apikey <key>". It is only compared, never stored.
|
||||||
|
APIKey string
|
||||||
|
}
|
||||||
|
|
||||||
|
// CentrifugoRecorder is a fake Centrifugo HTTP API. It records every
|
||||||
|
// publish and broadcast request and answers the other server API calls a
|
||||||
|
// backend makes with empty successful results.
|
||||||
|
type CentrifugoRecorder struct {
|
||||||
|
apiKey string
|
||||||
|
mu sync.Mutex
|
||||||
|
pubs []Publication
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewCentrifugoRecorder returns an empty recorder.
|
||||||
|
func NewCentrifugoRecorder(opts CentrifugoRecorderOptions) *CentrifugoRecorder {
|
||||||
|
return &CentrifugoRecorder{apiKey: opts.APIKey}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServeHTTP records POST .../publish and .../broadcast and answers
|
||||||
|
// {"result":{}}. .../presence answers an empty presence map, .../unsubscribe
|
||||||
|
// and .../info answer {"result":{}}, anything else is 404.
|
||||||
|
func (c *CentrifugoRecorder) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
path := strings.TrimRight(r.URL.Path, "/")
|
||||||
|
recorded := strings.HasSuffix(path, "/publish") || strings.HasSuffix(path, "/broadcast")
|
||||||
|
answered := strings.HasSuffix(path, "/presence") || strings.HasSuffix(path, "/unsubscribe") || strings.HasSuffix(path, "/info")
|
||||||
|
if !recorded && !answered {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
w.Header().Set("Allow", http.MethodPost)
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
raw, err := io.ReadAll(io.LimitReader(r.Body, MaxPublicationBody+1))
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "read body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(raw) > MaxPublicationBody {
|
||||||
|
http.Error(w, "body too large", http.StatusRequestEntityTooLarge)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if recorded {
|
||||||
|
c.mu.Lock()
|
||||||
|
c.pubs = append(c.pubs, Publication{
|
||||||
|
Method: r.Method,
|
||||||
|
Path: r.URL.Path,
|
||||||
|
Authorization: c.authorized(r.Header.Get("Authorization")),
|
||||||
|
Body: json.RawMessage(raw),
|
||||||
|
})
|
||||||
|
c.mu.Unlock()
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
if strings.HasSuffix(path, "/presence") {
|
||||||
|
_, _ = io.WriteString(w, `{"result":{"presence":{}}}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = io.WriteString(w, `{"result":{}}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CentrifugoRecorder) authorized(header string) bool {
|
||||||
|
if c.apiKey == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
want := "apikey " + c.apiKey
|
||||||
|
return subtle.ConstantTimeCompare([]byte(header), []byte(want)) == 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Publications returns a copy of the recorded requests in arrival order.
|
||||||
|
func (c *CentrifugoRecorder) Publications() []Publication {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
out := make([]Publication, len(c.pubs))
|
||||||
|
for i, p := range c.pubs {
|
||||||
|
p.Body = append(json.RawMessage(nil), p.Body...)
|
||||||
|
out[i] = p
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset forgets every recorded request.
|
||||||
|
func (c *CentrifugoRecorder) Reset() {
|
||||||
|
c.mu.Lock()
|
||||||
|
c.pubs = nil
|
||||||
|
c.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListenAndServe serves the recorder on addr until ctx is cancelled. addr
|
||||||
|
// must be a loopback address, the same rule the recording proxy applies.
|
||||||
|
func (c *CentrifugoRecorder) ListenAndServe(ctx context.Context, addr string) error {
|
||||||
|
if err := requireLoopbackAddr(addr); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ln, err := net.Listen("tcp", addr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("tide: centrifugo recorder listen %s: %w", addr, err)
|
||||||
|
}
|
||||||
|
srv := &http.Server{Handler: c, ReadHeaderTimeout: 10 * time.Second}
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
shutCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||||
|
_ = srv.Shutdown(shutCtx)
|
||||||
|
cancel()
|
||||||
|
case <-done:
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
err = srv.Serve(ln)
|
||||||
|
close(done)
|
||||||
|
if errors.Is(err, http.ErrServerClosed) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// BroadcastConfig drives RecordBroadcasts.
|
||||||
|
type BroadcastConfig struct {
|
||||||
|
// Target is the loopback base URL of the reference backend.
|
||||||
|
Target string
|
||||||
|
// Listen is the loopback address of the recorder the backend publishes
|
||||||
|
// to. Empty means DefaultCentrifugoListen.
|
||||||
|
Listen string
|
||||||
|
// APIKey is the Centrifugo API key the backend is configured with.
|
||||||
|
APIKey string
|
||||||
|
// Store holds and receives captured variables (the private vars file).
|
||||||
|
Store *Store
|
||||||
|
// Rules are optional capture rules merged into steps without captures.
|
||||||
|
Rules Rules
|
||||||
|
// Step names the flow step whose publications are recorded. Earlier
|
||||||
|
// steps run first as setup and their publications are discarded; later
|
||||||
|
// steps do not run. Empty records the whole flow.
|
||||||
|
Step string
|
||||||
|
// IDs names the id:* variables replaced by placeholders. Empty means
|
||||||
|
// every id:* placeholder or capture the flow mentions.
|
||||||
|
IDs []string
|
||||||
|
// Name is the golden name. Empty uses the step, else the flow name.
|
||||||
|
Name string
|
||||||
|
// Settle is the wait for publications that arrive after a response.
|
||||||
|
Settle time.Duration
|
||||||
|
// Client and MaxBody are passed to RecordFlow.
|
||||||
|
Client *http.Client
|
||||||
|
MaxBody int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordBroadcasts runs spec against the reference backend while a
|
||||||
|
// loopback CentrifugoRecorder stands in for Centrifugo, then returns the
|
||||||
|
// normalised publications as a golden. It fails when nothing is published.
|
||||||
|
func RecordBroadcasts(ctx context.Context, spec Flow, cfg BroadcastConfig) (BroadcastGolden, error) {
|
||||||
|
if err := validateFlow(spec); err != nil {
|
||||||
|
return BroadcastGolden{}, err
|
||||||
|
}
|
||||||
|
if _, err := parseLoopbackUpstream(cfg.Target); err != nil {
|
||||||
|
return BroadcastGolden{}, fmt.Errorf("tide: broadcast target: %w", err)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(cfg.APIKey) == "" {
|
||||||
|
return BroadcastGolden{}, fmt.Errorf("tide: broadcast recording needs the backend's Centrifugo API key")
|
||||||
|
}
|
||||||
|
listen := cfg.Listen
|
||||||
|
if listen == "" {
|
||||||
|
listen = DefaultCentrifugoListen
|
||||||
|
}
|
||||||
|
if err := requireLoopbackAddr(listen); err != nil {
|
||||||
|
return BroadcastGolden{}, err
|
||||||
|
}
|
||||||
|
idx := -1
|
||||||
|
if cfg.Step != "" {
|
||||||
|
for i, s := range spec.Steps {
|
||||||
|
if s.ID == cfg.Step {
|
||||||
|
idx = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if idx < 0 {
|
||||||
|
return BroadcastGolden{}, fmt.Errorf("tide: flow %q has no step %q", spec.Name, cfg.Step)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
store := cfg.Store
|
||||||
|
if store == nil {
|
||||||
|
store = mustMemoryStore()
|
||||||
|
}
|
||||||
|
settle := cfg.Settle
|
||||||
|
if settle <= 0 {
|
||||||
|
settle = DefaultBroadcastSettle
|
||||||
|
}
|
||||||
|
|
||||||
|
rec := NewCentrifugoRecorder(CentrifugoRecorderOptions{APIKey: cfg.APIKey})
|
||||||
|
srvCtx, cancel := context.WithCancel(ctx)
|
||||||
|
defer cancel()
|
||||||
|
errCh := make(chan error, 1)
|
||||||
|
go func() { errCh <- rec.ListenAndServe(srvCtx, listen) }()
|
||||||
|
if err := waitListening(ctx, listen, errCh); err != nil {
|
||||||
|
return BroadcastGolden{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
record := func(steps []Step) error {
|
||||||
|
sub := spec
|
||||||
|
sub.Steps = steps
|
||||||
|
_, err := RecordFlow(ctx, sub, RecordConfig{Target: cfg.Target, Client: cfg.Client, MaxBody: cfg.MaxBody, Store: store, Rules: cfg.Rules})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return sleepCtx(ctx, settle)
|
||||||
|
}
|
||||||
|
steps := spec.Steps
|
||||||
|
if idx >= 0 {
|
||||||
|
if idx > 0 {
|
||||||
|
if err := record(spec.Steps[:idx]); err != nil {
|
||||||
|
return BroadcastGolden{}, err
|
||||||
|
}
|
||||||
|
rec.Reset()
|
||||||
|
}
|
||||||
|
steps = spec.Steps[idx : idx+1]
|
||||||
|
}
|
||||||
|
if err := record(steps); err != nil {
|
||||||
|
return BroadcastGolden{}, err
|
||||||
|
}
|
||||||
|
pubs := rec.Publications()
|
||||||
|
if len(pubs) == 0 {
|
||||||
|
return BroadcastGolden{}, fmt.Errorf("tide: flow %q published nothing to %s; is the backend's Centrifugo API URL pointed at the recorder?", spec.Name, listen)
|
||||||
|
}
|
||||||
|
|
||||||
|
names := cfg.IDs
|
||||||
|
if len(names) == 0 {
|
||||||
|
names = flowIDNames(spec)
|
||||||
|
}
|
||||||
|
idStore := mustMemoryStore()
|
||||||
|
for _, n := range names {
|
||||||
|
v, ok := store.Get(n)
|
||||||
|
if !ok || v == "" {
|
||||||
|
return BroadcastGolden{}, fmt.Errorf("tide: id variable %q is not in the vars store", n)
|
||||||
|
}
|
||||||
|
idStore.Set(n, v)
|
||||||
|
}
|
||||||
|
norm, err := NormalizePublications(pubs, idStore)
|
||||||
|
if err != nil {
|
||||||
|
return BroadcastGolden{}, err
|
||||||
|
}
|
||||||
|
for i, p := range norm {
|
||||||
|
if strings.Contains(string(p.Body), cfg.APIKey) {
|
||||||
|
return BroadcastGolden{}, fmt.Errorf("tide: publication %d body contains the Centrifugo API key", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
g := BroadcastGolden{
|
||||||
|
Version: CurrentVersion,
|
||||||
|
Name: cfg.Name,
|
||||||
|
Flow: spec.Name,
|
||||||
|
Publications: norm,
|
||||||
|
}
|
||||||
|
if cfg.Step != "" {
|
||||||
|
g.Flow += "#" + cfg.Step
|
||||||
|
}
|
||||||
|
if g.Name == "" {
|
||||||
|
g.Name = cfg.Step
|
||||||
|
}
|
||||||
|
if g.Name == "" {
|
||||||
|
g.Name = spec.Name
|
||||||
|
}
|
||||||
|
return g, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitListening(ctx context.Context, addr string, errCh <-chan error) error {
|
||||||
|
deadline := time.Now().Add(5 * time.Second)
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case err := <-errCh:
|
||||||
|
if err == nil {
|
||||||
|
err = fmt.Errorf("stopped")
|
||||||
|
}
|
||||||
|
return fmt.Errorf("tide: centrifugo recorder: %w", err)
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
conn, err := net.DialTimeout("tcp", addr, 200*time.Millisecond)
|
||||||
|
if err == nil {
|
||||||
|
_ = conn.Close()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
return fmt.Errorf("tide: centrifugo recorder did not start on %s: %w", addr, err)
|
||||||
|
}
|
||||||
|
if err := sleepCtx(ctx, 20*time.Millisecond); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sleepCtx(ctx context.Context, d time.Duration) error {
|
||||||
|
t := time.NewTimer(d)
|
||||||
|
defer t.Stop()
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
case <-t.C:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// flowIDNames lists the id:* variables a flow mentions in request
|
||||||
|
// placeholders or captures, sorted.
|
||||||
|
func flowIDNames(spec Flow) []string {
|
||||||
|
seen := map[string]struct{}{}
|
||||||
|
add := func(s string) {
|
||||||
|
for _, m := range placeholderRe.FindAllStringSubmatch(s, -1) {
|
||||||
|
if strings.HasPrefix(m[1], "id:") {
|
||||||
|
seen[m[1]] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, step := range spec.Steps {
|
||||||
|
add(step.Request.Path)
|
||||||
|
add(step.Request.Query)
|
||||||
|
add(string(step.Request.Body))
|
||||||
|
for _, v := range step.Request.Headers {
|
||||||
|
add(v)
|
||||||
|
}
|
||||||
|
for _, c := range step.Capture {
|
||||||
|
if strings.HasPrefix(c.As, "id:") {
|
||||||
|
seen[c.As] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out := make([]string, 0, len(seen))
|
||||||
|
for n := range seen {
|
||||||
|
out = append(out, n)
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
541
modules/tide/centrifugo_golden.go
Normal file
541
modules/tide/centrifugo_golden.go
Normal file
@@ -0,0 +1,541 @@
|
|||||||
|
package tide
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/goccy/go-yaml"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BroadcastGolden is a versioned file of normalised Centrifugo requests a
|
||||||
|
// reference backend sent while a flow ran. Pending, when set, says why the
|
||||||
|
// golden is recorded but not yet asserted.
|
||||||
|
type BroadcastGolden struct {
|
||||||
|
Version int
|
||||||
|
Name string
|
||||||
|
Flow string
|
||||||
|
Pending string
|
||||||
|
Publications []Publication
|
||||||
|
}
|
||||||
|
|
||||||
|
type goldenFile struct {
|
||||||
|
Version int `yaml:"version"`
|
||||||
|
Name string `yaml:"name"`
|
||||||
|
Flow string `yaml:"flow,omitempty"`
|
||||||
|
Pending string `yaml:"pending,omitempty"`
|
||||||
|
Publications []goldenPubFile `yaml:"publications"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type goldenPubFile struct {
|
||||||
|
Method string `yaml:"method"`
|
||||||
|
Path string `yaml:"path"`
|
||||||
|
Authorization bool `yaml:"authorization"`
|
||||||
|
Body Body `yaml:"body"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadBroadcastGolden reads a golden strictly (unknown fields rejected) and
|
||||||
|
// checks that every body parses.
|
||||||
|
func LoadBroadcastGolden(path string) (BroadcastGolden, error) {
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return BroadcastGolden{}, fmt.Errorf("tide: read %s: %w", path, err)
|
||||||
|
}
|
||||||
|
var f goldenFile
|
||||||
|
dec := yaml.NewDecoder(bytes.NewReader(raw), yaml.DisallowUnknownField())
|
||||||
|
if err := dec.Decode(&f); err != nil {
|
||||||
|
return BroadcastGolden{}, fmt.Errorf("tide: parse golden %s: %w", path, err)
|
||||||
|
}
|
||||||
|
g := BroadcastGolden{Version: f.Version, Name: f.Name, Flow: f.Flow, Pending: f.Pending}
|
||||||
|
for _, p := range f.Publications {
|
||||||
|
g.Publications = append(g.Publications, Publication{
|
||||||
|
Method: p.Method,
|
||||||
|
Path: p.Path,
|
||||||
|
Authorization: p.Authorization,
|
||||||
|
Body: json.RawMessage(p.Body),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if err := validateGolden(g); err != nil {
|
||||||
|
return BroadcastGolden{}, fmt.Errorf("tide: golden %s: %w", path, err)
|
||||||
|
}
|
||||||
|
return g, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteBroadcastGolden writes g atomically. It refuses a golden whose
|
||||||
|
// bodies still hold a token-, secret- or password-shaped value.
|
||||||
|
func WriteBroadcastGolden(path string, g BroadcastGolden) error {
|
||||||
|
if err := validateGolden(g); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for i, p := range g.Publications {
|
||||||
|
if hit := remainingCredential(string(p.Body)); hit != "" {
|
||||||
|
return fmt.Errorf("tide: golden publication %d holds an unclassified credential-shaped value (%s)", i, hit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
fmt.Fprintf(&b, "version: %d\n", g.Version)
|
||||||
|
writeKV(&b, 0, "name", g.Name)
|
||||||
|
if g.Flow != "" {
|
||||||
|
writeKV(&b, 0, "flow", g.Flow)
|
||||||
|
}
|
||||||
|
if g.Pending != "" {
|
||||||
|
writeKV(&b, 0, "pending", g.Pending)
|
||||||
|
}
|
||||||
|
if len(g.Publications) == 0 {
|
||||||
|
b.WriteString("publications: []\n")
|
||||||
|
} else {
|
||||||
|
b.WriteString("publications:\n")
|
||||||
|
}
|
||||||
|
for _, p := range g.Publications {
|
||||||
|
fmt.Fprintf(&b, " - method: %s\n", encodeScalar(p.Method))
|
||||||
|
writeKV(&b, 4, "path", p.Path)
|
||||||
|
fmt.Fprintf(&b, " authorization: %t\n", p.Authorization)
|
||||||
|
writeBody(&b, 4, string(p.Body))
|
||||||
|
}
|
||||||
|
dir := filepath.Dir(path)
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
return fmt.Errorf("tide: create golden dir: %w", err)
|
||||||
|
}
|
||||||
|
tmp, err := os.CreateTemp(dir, ".tide-golden-*.tmp")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("tide: create temp golden: %w", err)
|
||||||
|
}
|
||||||
|
name := tmp.Name()
|
||||||
|
if _, err := tmp.WriteString(b.String()); err != nil {
|
||||||
|
_ = tmp.Close()
|
||||||
|
_ = os.Remove(name)
|
||||||
|
return fmt.Errorf("tide: write golden: %w", err)
|
||||||
|
}
|
||||||
|
if err := tmp.Sync(); err != nil {
|
||||||
|
_ = tmp.Close()
|
||||||
|
_ = os.Remove(name)
|
||||||
|
return fmt.Errorf("tide: sync golden: %w", err)
|
||||||
|
}
|
||||||
|
if err := tmp.Close(); err != nil {
|
||||||
|
_ = os.Remove(name)
|
||||||
|
return fmt.Errorf("tide: close golden: %w", err)
|
||||||
|
}
|
||||||
|
if err := os.Chmod(name, 0o644); err != nil {
|
||||||
|
_ = os.Remove(name)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.Rename(name, path); err != nil {
|
||||||
|
_ = os.Remove(name)
|
||||||
|
return fmt.Errorf("tide: commit golden: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateGolden(g BroadcastGolden) error {
|
||||||
|
if g.Version != CurrentVersion {
|
||||||
|
return fmt.Errorf("tide: unsupported golden version %d (want %d)", g.Version, CurrentVersion)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(g.Name) == "" {
|
||||||
|
return fmt.Errorf("tide: golden name is required")
|
||||||
|
}
|
||||||
|
for i, p := range g.Publications {
|
||||||
|
if strings.TrimSpace(p.Method) == "" || strings.TrimSpace(p.Path) == "" {
|
||||||
|
return fmt.Errorf("tide: publication %d is missing method or path", i)
|
||||||
|
}
|
||||||
|
if _, err := decodePlaceholderJSON(p.Body); err != nil {
|
||||||
|
return fmt.Errorf("tide: publication %d body: %w", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// isoOffsetRe is the ISO 8601 shape of Carbon's toIso8601String, which
|
||||||
|
// Centrifugo payload timestamps use. Other shapes are left unmasked so a
|
||||||
|
// format change shows up as a diff.
|
||||||
|
var isoOffsetRe = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2}$`)
|
||||||
|
|
||||||
|
// channelIDRe matches a channel name whose last segment is numeric, such as
|
||||||
|
// collection:12, acme:collection:12 or presence:room:7.
|
||||||
|
var channelIDRe = regexp.MustCompile(`^((?:[a-z][a-z0-9_.-]*:)+)([0-9]+)$`)
|
||||||
|
|
||||||
|
// NormalizePublications masks the values that legitimately differ between
|
||||||
|
// two backends and nothing else:
|
||||||
|
// - $.data.timestamp and $.data.payload.timestamp become "{{timestamp}}"
|
||||||
|
// when they have the ISO 8601 offset shape;
|
||||||
|
// - $.data.payload.actor becomes "{{actor}}" when it is an object with
|
||||||
|
// exactly user_id and name;
|
||||||
|
// - a number or string under an id, *_id or *_ids key that equals an id:*
|
||||||
|
// variable of store becomes that {{id:name}} placeholder (bare for a
|
||||||
|
// number, quoted for a string), and so does the numeric last segment of
|
||||||
|
// a channel name such as collection:12.
|
||||||
|
//
|
||||||
|
// A value that matches two id variables is an error. Bodies are re-encoded
|
||||||
|
// with object keys in their original order.
|
||||||
|
func NormalizePublications(pubs []Publication, store *Store) ([]Publication, error) {
|
||||||
|
ids := idValues(store)
|
||||||
|
out := make([]Publication, len(pubs))
|
||||||
|
for i, p := range pubs {
|
||||||
|
root, err := decodeOrdered(p.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("tide: publication %d body: %w", i, err)
|
||||||
|
}
|
||||||
|
n := &normalizer{ids: ids}
|
||||||
|
root = n.walk("$", "", root)
|
||||||
|
if n.err != nil {
|
||||||
|
return nil, fmt.Errorf("tide: publication %d: %w", i, n.err)
|
||||||
|
}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := encodeOrdered(&buf, root); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
p.Body = json.RawMessage(buf.Bytes())
|
||||||
|
out[i] = p
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DiffPublications compares expected with actual: the count, then per
|
||||||
|
// publication the method, path, authorization flag and the body with the
|
||||||
|
// structural JSON diff (key order ignored). Paths look like $[0].body.data.
|
||||||
|
func DiffPublications(expected, actual []Publication) []Diff {
|
||||||
|
var diffs []Diff
|
||||||
|
if len(expected) != len(actual) {
|
||||||
|
diffs = append(diffs, Diff{
|
||||||
|
Path: "$",
|
||||||
|
Expected: fmt.Sprintf("%d publications", len(expected)),
|
||||||
|
Actual: fmt.Sprintf("%d publications", len(actual)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for i := 0; i < min(len(expected), len(actual)); i++ {
|
||||||
|
w, g := expected[i], actual[i]
|
||||||
|
base := fmt.Sprintf("$[%d]", i)
|
||||||
|
if w.Method != g.Method {
|
||||||
|
diffs = append(diffs, Diff{Path: base + ".method", Expected: w.Method, Actual: g.Method})
|
||||||
|
}
|
||||||
|
if w.Path != g.Path {
|
||||||
|
diffs = append(diffs, Diff{Path: base + ".path", Expected: w.Path, Actual: g.Path})
|
||||||
|
}
|
||||||
|
if w.Authorization != g.Authorization {
|
||||||
|
diffs = append(diffs, Diff{Path: base + ".authorization", Expected: fmt.Sprint(w.Authorization), Actual: fmt.Sprint(g.Authorization)})
|
||||||
|
}
|
||||||
|
wv, err := decodePlaceholderJSON(w.Body)
|
||||||
|
if err != nil {
|
||||||
|
diffs = append(diffs, Diff{Path: base + ".body", Expected: "valid JSON", Actual: err.Error()})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
gv, err := decodePlaceholderJSON(g.Body)
|
||||||
|
if err != nil {
|
||||||
|
diffs = append(diffs, Diff{Path: base + ".body", Expected: formatValue(wv), Actual: err.Error()})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
compareValue(base+".body", wv, gv, &diffs)
|
||||||
|
}
|
||||||
|
sort.SliceStable(diffs, func(i, j int) bool { return diffs[i].Path < diffs[j].Path })
|
||||||
|
return diffs
|
||||||
|
}
|
||||||
|
|
||||||
|
func idValues(store *Store) map[string][]string {
|
||||||
|
out := map[string][]string{}
|
||||||
|
if store == nil {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
store.mu.Lock()
|
||||||
|
defer store.mu.Unlock()
|
||||||
|
for k, v := range store.vals {
|
||||||
|
if strings.HasPrefix(k, "id:") && v != "" {
|
||||||
|
out[v] = append(out[v], k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for v := range out {
|
||||||
|
sort.Strings(out[v])
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
type normalizer struct {
|
||||||
|
ids map[string][]string
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *normalizer) lookup(v string) (string, bool) {
|
||||||
|
names := n.ids[v]
|
||||||
|
switch len(names) {
|
||||||
|
case 0:
|
||||||
|
return "", false
|
||||||
|
case 1:
|
||||||
|
return names[0], true
|
||||||
|
default:
|
||||||
|
if n.err == nil {
|
||||||
|
n.err = fmt.Errorf("id value %s is ambiguous: %s", v, strings.Join(names, ", "))
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *normalizer) walk(path, key string, v *onode) *onode {
|
||||||
|
switch path {
|
||||||
|
case "$.data.timestamp", "$.data.payload.timestamp":
|
||||||
|
if v.kind == kindString && isoOffsetRe.MatchString(v.str) {
|
||||||
|
return &onode{kind: kindString, str: "{{timestamp}}"}
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
case "$.data.payload.actor":
|
||||||
|
if v.kind == kindObject && len(v.keys) == 2 && hasKeys(v, "user_id", "name") {
|
||||||
|
return &onode{kind: kindString, str: "{{actor}}"}
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
switch v.kind {
|
||||||
|
case kindObject:
|
||||||
|
for i, k := range v.keys {
|
||||||
|
v.vals[i] = n.walk(pathJoin(path, k), k, v.vals[i])
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
case kindArray:
|
||||||
|
for i := range v.vals {
|
||||||
|
v.vals[i] = n.walk(fmt.Sprintf("%s[%d]", path, i), key, v.vals[i])
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
case kindNumber:
|
||||||
|
if key != "" && isIDKey(key) {
|
||||||
|
if name, ok := n.lookup(v.str); ok {
|
||||||
|
return &onode{kind: kindPlaceholder, str: name}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
case kindString:
|
||||||
|
if key != "" && isIDKey(key) {
|
||||||
|
if name, ok := n.lookup(v.str); ok {
|
||||||
|
return &onode{kind: kindString, str: "{{" + name + "}}"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if m := channelIDRe.FindStringSubmatch(v.str); m != nil {
|
||||||
|
if name, ok := n.lookup(m[2]); ok {
|
||||||
|
return &onode{kind: kindString, str: m[1] + "{{" + name + "}}"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
default:
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasKeys(v *onode, keys ...string) bool {
|
||||||
|
for _, want := range keys {
|
||||||
|
found := false
|
||||||
|
for _, k := range v.keys {
|
||||||
|
if k == want {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
kindNull = iota
|
||||||
|
kindBool
|
||||||
|
kindNumber
|
||||||
|
kindString
|
||||||
|
kindArray
|
||||||
|
kindObject
|
||||||
|
// kindPlaceholder is a bare {{name}} standing for a number.
|
||||||
|
kindPlaceholder
|
||||||
|
)
|
||||||
|
|
||||||
|
// onode is a JSON value that keeps object keys in document order.
|
||||||
|
type onode struct {
|
||||||
|
kind int
|
||||||
|
str string // string value, number literal or placeholder name
|
||||||
|
b bool
|
||||||
|
keys []string
|
||||||
|
vals []*onode
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeOrdered(raw []byte) (*onode, error) {
|
||||||
|
dec := json.NewDecoder(bytes.NewReader(raw))
|
||||||
|
dec.UseNumber()
|
||||||
|
v, err := decodeOrderedValue(dec)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if dec.More() {
|
||||||
|
return nil, fmt.Errorf("trailing JSON after first value")
|
||||||
|
}
|
||||||
|
if _, err := dec.Token(); err == nil {
|
||||||
|
return nil, fmt.Errorf("trailing JSON after first value")
|
||||||
|
}
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeOrderedValue(dec *json.Decoder) (*onode, error) {
|
||||||
|
tok, err := dec.Token()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
switch t := tok.(type) {
|
||||||
|
case json.Delim:
|
||||||
|
switch t {
|
||||||
|
case '{':
|
||||||
|
n := &onode{kind: kindObject}
|
||||||
|
for dec.More() {
|
||||||
|
kt, err := dec.Token()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
k, ok := kt.(string)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("object key %v is not a string", kt)
|
||||||
|
}
|
||||||
|
v, err := decodeOrderedValue(dec)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
n.keys = append(n.keys, k)
|
||||||
|
n.vals = append(n.vals, v)
|
||||||
|
}
|
||||||
|
if _, err := dec.Token(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
case '[':
|
||||||
|
n := &onode{kind: kindArray}
|
||||||
|
for dec.More() {
|
||||||
|
v, err := decodeOrderedValue(dec)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
n.vals = append(n.vals, v)
|
||||||
|
}
|
||||||
|
if _, err := dec.Token(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unexpected delimiter %v", t)
|
||||||
|
}
|
||||||
|
case string:
|
||||||
|
return &onode{kind: kindString, str: t}, nil
|
||||||
|
case json.Number:
|
||||||
|
return &onode{kind: kindNumber, str: string(t)}, nil
|
||||||
|
case bool:
|
||||||
|
return &onode{kind: kindBool, b: t}, nil
|
||||||
|
case nil:
|
||||||
|
return &onode{kind: kindNull}, nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unexpected token %v", tok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func encodeOrdered(buf *bytes.Buffer, n *onode) error {
|
||||||
|
switch n.kind {
|
||||||
|
case kindNull:
|
||||||
|
buf.WriteString("null")
|
||||||
|
case kindBool:
|
||||||
|
if n.b {
|
||||||
|
buf.WriteString("true")
|
||||||
|
} else {
|
||||||
|
buf.WriteString("false")
|
||||||
|
}
|
||||||
|
case kindNumber:
|
||||||
|
buf.WriteString(n.str)
|
||||||
|
case kindPlaceholder:
|
||||||
|
buf.WriteString("{{" + n.str + "}}")
|
||||||
|
case kindString:
|
||||||
|
return encodeJSONString(buf, n.str)
|
||||||
|
case kindArray:
|
||||||
|
buf.WriteByte('[')
|
||||||
|
for i, v := range n.vals {
|
||||||
|
if i > 0 {
|
||||||
|
buf.WriteByte(',')
|
||||||
|
}
|
||||||
|
if err := encodeOrdered(buf, v); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
buf.WriteByte(']')
|
||||||
|
case kindObject:
|
||||||
|
buf.WriteByte('{')
|
||||||
|
for i, k := range n.keys {
|
||||||
|
if i > 0 {
|
||||||
|
buf.WriteByte(',')
|
||||||
|
}
|
||||||
|
if err := encodeJSONString(buf, k); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
buf.WriteByte(':')
|
||||||
|
if err := encodeOrdered(buf, n.vals[i]); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
buf.WriteByte('}')
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("tide: unknown JSON node kind %d", n.kind)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func encodeJSONString(buf *bytes.Buffer, s string) error {
|
||||||
|
var tmp bytes.Buffer
|
||||||
|
enc := json.NewEncoder(&tmp)
|
||||||
|
enc.SetEscapeHTML(false)
|
||||||
|
if err := enc.Encode(s); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
buf.Write(bytes.TrimSuffix(tmp.Bytes(), []byte("\n")))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// numberPlaceholderSuffix marks a bare {{name}} (a masked number) when a
|
||||||
|
// normalised body is parsed for diffing, so a number that turned into a
|
||||||
|
// string, or the reverse, still shows up as a difference.
|
||||||
|
const numberPlaceholderSuffix = " (number)"
|
||||||
|
|
||||||
|
// decodePlaceholderJSON parses a normalised body: JSON in which a bare
|
||||||
|
// {{name}} may stand where a number was.
|
||||||
|
func decodePlaceholderJSON(raw []byte) (any, error) {
|
||||||
|
var out bytes.Buffer
|
||||||
|
inString := false
|
||||||
|
escaped := false
|
||||||
|
for i := 0; i < len(raw); i++ {
|
||||||
|
c := raw[i]
|
||||||
|
if inString {
|
||||||
|
out.WriteByte(c)
|
||||||
|
switch {
|
||||||
|
case escaped:
|
||||||
|
escaped = false
|
||||||
|
case c == '\\':
|
||||||
|
escaped = true
|
||||||
|
case c == '"':
|
||||||
|
inString = false
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if c == '"' {
|
||||||
|
inString = true
|
||||||
|
out.WriteByte(c)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if c == '{' && i+1 < len(raw) && raw[i+1] == '{' {
|
||||||
|
end := bytes.Index(raw[i:], []byte("}}"))
|
||||||
|
if end < 0 {
|
||||||
|
return nil, fmt.Errorf("unterminated placeholder at byte %d", i)
|
||||||
|
}
|
||||||
|
name := string(raw[i+2 : i+end])
|
||||||
|
if name == "" || strings.ContainsAny(name, "{}\"\\") {
|
||||||
|
return nil, fmt.Errorf("invalid placeholder at byte %d", i)
|
||||||
|
}
|
||||||
|
if err := encodeJSONString(&out, "{{"+name+"}}"+numberPlaceholderSuffix); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
i += end + 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out.WriteByte(c)
|
||||||
|
}
|
||||||
|
return decodeJSON(out.Bytes())
|
||||||
|
}
|
||||||
236
modules/tide/centrifugo_test.go
Normal file
236
modules/tide/centrifugo_test.go
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
package tide
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func postJSON(t *testing.T, url, auth, body string) *http.Response {
|
||||||
|
t.Helper()
|
||||||
|
req, err := http.NewRequest(http.MethodPost, url, strings.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if auth != "" {
|
||||||
|
req.Header.Set("Authorization", auth)
|
||||||
|
}
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = resp.Body.Close() })
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCentrifugoRecorderRecordsPublishAndBroadcast(t *testing.T) {
|
||||||
|
rec := NewCentrifugoRecorder(CentrifugoRecorderOptions{APIKey: "k1"})
|
||||||
|
srv := httptest.NewServer(rec)
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
|
||||||
|
resp := postJSON(t, srv.URL+"/api/publish", "apikey k1", `{"channel":"room:1","data":{}}`)
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
if resp.StatusCode != 200 || string(body) != `{"result":{}}` {
|
||||||
|
t.Fatalf("publish answer %d %s", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
postJSON(t, srv.URL+"/api/broadcast", "apikey wrong", `{"channels":["a:1","b:2"]}`)
|
||||||
|
resp = postJSON(t, srv.URL+"/api/presence", "apikey k1", `{"channel":"room:1"}`)
|
||||||
|
body, _ = io.ReadAll(resp.Body)
|
||||||
|
if string(body) != `{"result":{"presence":{}}}` {
|
||||||
|
t.Fatalf("presence answer %s", body)
|
||||||
|
}
|
||||||
|
if resp := postJSON(t, srv.URL+"/api/other", "", `{}`); resp.StatusCode != 404 {
|
||||||
|
t.Fatalf("unknown path %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
pubs := rec.Publications()
|
||||||
|
if len(pubs) != 2 {
|
||||||
|
t.Fatalf("publications %d, want 2 (presence is not recorded)", len(pubs))
|
||||||
|
}
|
||||||
|
if pubs[0].Path != "/api/publish" || !pubs[0].Authorization || pubs[0].Method != "POST" {
|
||||||
|
t.Fatalf("first %+v", pubs[0])
|
||||||
|
}
|
||||||
|
if pubs[1].Path != "/api/broadcast" || pubs[1].Authorization {
|
||||||
|
t.Fatalf("second %+v", pubs[1])
|
||||||
|
}
|
||||||
|
rec.Reset()
|
||||||
|
if len(rec.Publications()) != 0 {
|
||||||
|
t.Fatal("reset kept publications")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCentrifugoRecorderRefusesNonLoopback(t *testing.T) {
|
||||||
|
rec := NewCentrifugoRecorder(CentrifugoRecorderOptions{})
|
||||||
|
if err := rec.ListenAndServe(t.Context(), "0.0.0.0:0"); err == nil || !strings.Contains(err.Error(), "loopback") {
|
||||||
|
t.Fatalf("want loopback refusal, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizePublications(t *testing.T) {
|
||||||
|
store := mustMemoryStore()
|
||||||
|
store.Set("id:collection", "12")
|
||||||
|
store.Set("id:album", "40")
|
||||||
|
store.Set("jwt:alice", "not-an-id")
|
||||||
|
pubs := []Publication{{
|
||||||
|
Method: "POST",
|
||||||
|
Path: "/api/publish",
|
||||||
|
Body: json.RawMessage(`{"channel":"collection:12","data":{"event":"deleted.acme.album",` +
|
||||||
|
`"payload":{"id":40,"collection_id":12,"action":"deleted","actor":{"user_id":3,"name":null},` +
|
||||||
|
`"timestamp":"2026-09-30T10:00:00+00:00","count":12},"timestamp":"2026-09-30T10:00:01+00:00"}}`),
|
||||||
|
}}
|
||||||
|
got, err := NormalizePublications(pubs, store)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := `{"channel":"collection:{{id:collection}}","data":{"event":"deleted.acme.album",` +
|
||||||
|
`"payload":{"id":{{id:album}},"collection_id":{{id:collection}},"action":"deleted","actor":"{{actor}}",` +
|
||||||
|
`"timestamp":"{{timestamp}}","count":12},"timestamp":"{{timestamp}}"}}`
|
||||||
|
if string(got[0].Body) != want {
|
||||||
|
t.Fatalf("normalised\n got %s\nwant %s", got[0].Body, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A Z timestamp and an actor with extra keys are left alone.
|
||||||
|
odd := []Publication{{Method: "POST", Path: "/p", Body: json.RawMessage(
|
||||||
|
`{"data":{"payload":{"actor":{"user_id":1,"name":"x","role":"a"}},"timestamp":"2026-09-30T10:00:00Z"}}`)}}
|
||||||
|
got, err = NormalizePublications(odd, store)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(got[0].Body), `"2026-09-30T10:00:00Z"`) || !strings.Contains(string(got[0].Body), `"role":"a"`) {
|
||||||
|
t.Fatalf("over-normalised %s", got[0].Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
store.Set("id:other", "12")
|
||||||
|
if _, err := NormalizePublications(pubs, store); err == nil || !strings.Contains(err.Error(), "ambiguous") {
|
||||||
|
t.Fatalf("want ambiguity error, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiffPublications(t *testing.T) {
|
||||||
|
want := []Publication{{Method: "POST", Path: "/api/publish", Authorization: true,
|
||||||
|
Body: json.RawMessage(`{"channel":"c:{{id:c}}","data":{"payload":{"id":{{id:a}},"n":1}}}`)}}
|
||||||
|
same := []Publication{{Method: "POST", Path: "/api/publish", Authorization: true,
|
||||||
|
Body: json.RawMessage(`{"data":{"payload":{"n":1,"id":{{id:a}}}},"channel":"c:{{id:c}}"}`)}}
|
||||||
|
if d := DiffPublications(want, same); len(d) != 0 {
|
||||||
|
t.Fatalf("key order must not matter: %+v", d)
|
||||||
|
}
|
||||||
|
stringID := []Publication{{Method: "POST", Path: "/api/broadcast", Authorization: false,
|
||||||
|
Body: json.RawMessage(`{"channel":"c:{{id:c}}","data":{"payload":{"id":"{{id:a}}","n":1}}}`)}}
|
||||||
|
d := DiffPublications(want, stringID)
|
||||||
|
paths := map[string]bool{}
|
||||||
|
for _, x := range d {
|
||||||
|
paths[x.Path] = true
|
||||||
|
}
|
||||||
|
for _, p := range []string{"$[0].path", "$[0].authorization", "$[0].body.data.payload.id"} {
|
||||||
|
if !paths[p] {
|
||||||
|
t.Fatalf("missing diff at %s in %+v", p, d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if d := DiffPublications(want, nil); len(d) != 1 || d[0].Path != "$" {
|
||||||
|
t.Fatalf("count diff %+v", d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBroadcastGoldenRoundTrip(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "g.yaml")
|
||||||
|
g := BroadcastGolden{Version: CurrentVersion, Name: "deleted", Flow: "flows/x#delete", Pending: "later",
|
||||||
|
Publications: []Publication{{Method: "POST", Path: "/api/publish", Authorization: true,
|
||||||
|
Body: json.RawMessage(`{"channel":"c:{{id:c}}","data":{"payload":{"id":{{id:a}}}}}`)}}}
|
||||||
|
if err := WriteBroadcastGolden(path, g); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
back, err := LoadBroadcastGolden(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if back.Name != g.Name || back.Flow != g.Flow || back.Pending != g.Pending || len(back.Publications) != 1 {
|
||||||
|
t.Fatalf("round trip %+v", back)
|
||||||
|
}
|
||||||
|
if d := DiffPublications(g.Publications, back.Publications); len(d) != 0 {
|
||||||
|
t.Fatalf("round trip diff %+v", d)
|
||||||
|
}
|
||||||
|
|
||||||
|
leak := g
|
||||||
|
leak.Publications = []Publication{{Method: "POST", Path: "/p",
|
||||||
|
Body: json.RawMessage(`{"token":"eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.c2lnbmF0dXJl"}`)}}
|
||||||
|
if err := WriteBroadcastGolden(filepath.Join(dir, "leak.yaml"), leak); err == nil {
|
||||||
|
t.Fatal("a JWT-shaped body must be refused")
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "bad.yaml"), []byte("version: 1\nname: x\nextra: 1\npublications: []\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := LoadBroadcastGolden(filepath.Join(dir, "bad.yaml")); err == nil {
|
||||||
|
t.Fatal("unknown field must be rejected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func freeLoopbackAddr(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
addr := ln.Addr().String()
|
||||||
|
_ = ln.Close()
|
||||||
|
return addr
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecordBroadcastsStep(t *testing.T) {
|
||||||
|
listen := freeLoopbackAddr(t)
|
||||||
|
publish := func(body string) {
|
||||||
|
req, _ := http.NewRequest(http.MethodPost, "http://"+listen+"/api/publish", strings.NewReader(body))
|
||||||
|
req.Header.Set("Authorization", "apikey test-key")
|
||||||
|
if resp, err := http.DefaultClient.Do(req); err == nil {
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodPost:
|
||||||
|
publish(`{"channel":"room:5","data":{"event":"created","payload":{"id":77},"timestamp":"2026-09-30T10:00:00+00:00"}}`)
|
||||||
|
_, _ = io.WriteString(w, `{"data":{"id":77}}`)
|
||||||
|
case http.MethodDelete:
|
||||||
|
publish(`{"channel":"room:5","data":{"event":"deleted","payload":{"id":77},"timestamp":"2026-09-30T10:00:02+00:00"}}`)
|
||||||
|
_, _ = io.WriteString(w, `{"message":"deleted"}`)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
t.Cleanup(backend.Close)
|
||||||
|
|
||||||
|
store := mustMemoryStore()
|
||||||
|
store.Set("id:room", "5")
|
||||||
|
spec := Flow{Version: CurrentVersion, Name: "items", Steps: []Step{
|
||||||
|
{ID: "create", Request: Request{Method: "POST", Path: "/items"},
|
||||||
|
Capture: []CaptureRule{{From: "response.json", Path: "$.data.id", As: "id:item"}}},
|
||||||
|
{ID: "delete", Request: Request{Method: "DELETE", Path: "/items/{{id:item}}"}},
|
||||||
|
}}
|
||||||
|
ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
g, err := RecordBroadcasts(ctx, spec, BroadcastConfig{
|
||||||
|
Target: backend.URL, Listen: listen, APIKey: "test-key", Store: store,
|
||||||
|
Step: "delete", IDs: []string{"id:room", "id:item"}, Settle: 50 * time.Millisecond,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if g.Name != "delete" || g.Flow != "items#delete" || len(g.Publications) != 1 {
|
||||||
|
t.Fatalf("golden %+v", g)
|
||||||
|
}
|
||||||
|
want := `{"channel":"room:{{id:room}}","data":{"event":"deleted","payload":{"id":{{id:item}}},"timestamp":"{{timestamp}}"}}`
|
||||||
|
if string(g.Publications[0].Body) != want || !g.Publications[0].Authorization {
|
||||||
|
t.Fatalf("publication %+v body %s", g.Publications[0], g.Publications[0].Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := RecordBroadcasts(ctx, spec, BroadcastConfig{Target: "http://192.0.2.1:80", APIKey: "k"}); err == nil {
|
||||||
|
t.Fatal("a non-loopback target must be refused")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user