package buscompose import ( "encoding/json" "os" "path/filepath" "reflect" "regexp" "strings" "testing" ) // Defends issue 1377 and novox/hq ADR 0547 §5: this is the controller's bus composer copied, and it composes byte for // byte what the controller's composes. testdata/controller-user-lists.json was written by the controller's own // internal/broker (mesh-controller branch fix/1945-mesh-grants-publishes-memberships, and fix/1790-grants-create-buckets for the grants part creating declared streams), lists and memberships, from fixtures_test.go compiled there unchanged; composed.conf is the // controller's golden of the whole server configuration. A row that differs is the copy drifting from the controller, // which is a fault while both compose (until the controller loses its lease, ADR 0547 §13), but for the grants the copy // gives beyond it on purpose (beyondTheControllers): the copy grants every user everything the controller's grants, and // only those more. // createdAdvisory is one subscription to a bucket's creation in a composed list, as stateCreatedHeard composes it. var createdAdvisory = regexp.MustCompile(`"\$JS\.EVENT\.ADVISORY\.STREAM\.CREATED\.KV_[^"]*", `) // beyondTheControllers is what the copy grants beyond the controller's on purpose (novox/hq ADR 0547 §2, ADR 0555 §3, // issue 1126), by user: a node's account reads every node's own facts, `.node` of the have bucket where the node's // record rides, and what it composes its own modules from (NodeComposeReads). After the take-over (ADR 0547 §8) the // grants part's list is the one the bus loads; a hand-back loads the controller's, which lacks these reads, and the nodes // keep applying without composing their own until the next take-over. Each principal's hearing of its buckets' creation // (issue 1790) is the other row beyond, matched by createdAdvisory. func beyondTheControllers(user string) map[string]bool { out := map[string]bool{} node, ok := strings.CutPrefix(user, "node.") if !ok { return out } for _, s := range append(readGrants("node-engine_have", "*.node"), NodeComposeReads(node)...) { out[s] = true } return out } // quotedToken is a quoted token of a composed list. var quotedToken = regexp.MustCompile(`"[^"]*"`) // grantsByUser splits a composed list into the text outside its users and each user's quoted tokens (its name, its // password's hash, every subject it may publish or subscribe to), by user. func grantsByUser(list string) (string, map[string]map[string]bool) { var outside strings.Builder users := map[string]map[string]bool{} user := "" for _, line := range strings.SplitAfter(list, "\n") { trimmed := strings.TrimSpace(line) if name, ok := strings.CutPrefix(trimmed, `{ user: "`); ok { user, _, _ = strings.Cut(name, `"`) users[user] = map[string]bool{} } else if user != "" && trimmed == "]" { user = "" } if user == "" { outside.WriteString(line) continue } for _, q := range quotedToken.FindAllString(line, -1) { users[user][strings.Trim(q, `"`)] = true } } return outside.String(), users } // grantsAsTheControllers fails t unless the copy's list is the controller's, user for user, but for the grants it // names on purpose (beyondTheControllers, the creation advisories): every grant of the controller's is the copy's, and // the copy's beyond them are exactly those, every one of them there. func grantsAsTheControllers(t *testing.T, got, want string) { t.Helper() gotOutside, gotUsers := grantsByUser(got) wantOutside, wantUsers := grantsByUser(want) if gotOutside != wantOutside { t.Errorf("the list differs from the controller's outside its users:\n%s", gotOutside) } for user, was := range wantUsers { now, ok := gotUsers[user] if !ok { t.Errorf("the copy has no user %s", user) continue } intended := beyondTheControllers(user) for g := range was { if !now[g] { t.Errorf("%s: the copy does not grant %s, which the controller's does", user, g) } } for g := range now { if !was[g] && !intended[g] && !createdAdvisory.MatchString(`"`+g+`", `) { t.Errorf("%s: the copy grants %s beyond the controller's, and no decision names it", user, g) } } for g := range intended { if !now[g] { t.Errorf("%s: the copy does not grant %s, which a node composing its own modules reads", user, g) } } } for user := range gotUsers { if _, ok := wantUsers[user]; !ok { t.Errorf("the copy has a user %s the controller's has not", user) } } } func TestTheCopiedComposerComposesAsTheControllers(t *testing.T) { raw, err := os.ReadFile(filepath.Join("testdata", "controller-user-lists.json")) if err != nil { t.Fatal(err) } var want []struct { Name, List string Missing []string Err string Memberships json.RawMessage } if err := json.Unmarshal(raw, &want); err != nil { t.Fatal(err) } fixtures := UserListFixtures() if len(want) != len(fixtures) { t.Fatalf("%d fixtures, %d lists the controller composed", len(fixtures), len(want)) } for i, f := range fixtures { t.Run(f.Name, func(t *testing.T) { if want[i].Name != f.Name { t.Fatalf("fixture %q against the controller's %q", f.Name, want[i].Name) } list, missing, err := ComposedFor(f) got := "" if err != nil { got = err.Error() } if got != want[i].Err { t.Fatalf("error %q, the controller's %q", got, want[i].Err) } if strings.Join(missing, ",") != strings.Join(want[i].Missing, ",") { t.Errorf("left out %v, the controller %v", missing, want[i].Missing) } // The one row the copy composes beyond the controller's, on purpose (novox/hq issue 1790, 2026-10-26, after // the take-over of ADR 0547 milestone 2): each principal hears its own and read buckets created, so a state // missing at its start is bound when it appears. The controller's composer is frozen and its list no longer // loaded; while it is not, this row is left out of the comparison and nothing else. grantsAsTheControllers(t, list, want[i].List) // And every membership the records issue, as the controller's MembershipFor makes it (ADR 0547 §5): the // grants part publishes them. raw, err := json.Marshal(MembershipsFor(f)) if err != nil { t.Fatal(err) } var gotM, wasM any if err := json.Unmarshal(raw, &gotM); err != nil { t.Fatal(err) } if err := json.Unmarshal(want[i].Memberships, &wasM); err != nil { t.Fatal(err) } if !reflect.DeepEqual(gotM, wasM) { t.Errorf("the memberships differ from the controller's:\n%s", raw) } }) } // An asker on a seat pulled by kind hears what that seat's workers gave up on, and no other stream's dead letters // (novox/hq issue 734: a check ask given up during a bus reload left its try running for ever). t.Run("an asker reads its seat's dead letters", func(t *testing.T) { build := Seat{Name: "node-build-go", Scope: "node", Accepts: []string{"delivery", "check", "recheck"}, PulledByKind: true} perms, err := PermissionsFor(Principal{Kind: KindModule, Node: "one", Module: "mesh-delivery-checker", Uses: []Seat{build}}) if err != nil { t.Fatal(err) } for subject, want := range map[string]bool{ "$JS.API.CONSUMER.CREATE.DEAD_LETTERS.x1.mesh.events.dead.SEAT_NODE_BUILD_GO.>": true, "$JS.API.CONSUMER.CREATE.DEAD_LETTERS.x1.mesh.events.dead.SEAT_NODE_BUILD_GO.w.next": true, "$JS.API.CONSUMER.DELETE.DEAD_LETTERS.x1": true, "$JS.API.CONSUMER.MSG.NEXT.DEAD_LETTERS.x1": true, "$JS.API.CONSUMER.CREATE.DEAD_LETTERS.x1.mesh.events.dead.EVENTS.>": false, "$JS.API.CONSUMER.CREATE.DEAD_LETTERS.x1": false, "$JS.API.STREAM.PURGE.DEAD_LETTERS": false, "$JS.FC.DEAD_LETTERS.x1": false, } { if got := MayPublish(perms, subject); got != want { t.Errorf("may publish %s: %v, want %v", subject, got, want) } } plain, err := PermissionsFor(Principal{Kind: KindModule, Node: "one", Module: "shop", Uses: []Seat{{Name: "telegram-sender", Accepts: []string{"send"}}}}) if err != nil { t.Fatal(err) } if MayPublish(plain, "$JS.API.CONSUMER.DELETE.DEAD_LETTERS.x1") { t.Error("a user of a seat not pulled by kind reads DEAD_LETTERS") } }) t.Run("the controller's golden server configuration", func(t *testing.T) { seat := Seat{Name: "telegram-sender", Accepts: []string{"send"}, Emits: []string{"delivered", "failed"}} got, err := Compose( Server{ClientPort: 4222, MonitoringPort: 8222, StoreDir: "/data", TLSCert: "/tls/tls.crt", TLSKey: "/tls/tls.key", TLSCA: "/tls/ca.crt"}, []Principal{ {Kind: KindController, PasswordHash: "$2a$11$cccccccccccccccccccccc"}, {Kind: KindEnrolment, Node: "one", PasswordHash: "$2a$11$eeeeeeeeeeeeeeeeeeeeee"}, {Kind: KindNode, Node: "one", PasswordHash: "$2a$11$nnnnnnnnnnnnnnnnnnnnnn"}, {Kind: KindModule, Node: "one", Module: "telegram", Holds: []Seat{seat}, Serves: []string{"status"}, PasswordHash: "$2a$11$tttttttttttttttttttttt"}, {Kind: KindModule, Node: "two", Module: "shop", Uses: []Seat{seat}, Emits: []string{"order.placed"}, PasswordHash: "$2a$11$ssssssssssssssssssssss"}, {Kind: KindModule, Node: "two", Module: "audit", Consumes: []string{"shop.order.placed"}, PasswordHash: "$2a$11$aaaaaaaaaaaaaaaaaaaaaa"}, {Kind: KindModule, Node: "one", Module: WatchmanModule, PasswordHash: "$2a$11$wwwwwwwwwwwwwwwwwwwwww"}, {Kind: KindModule, Node: "one", Module: "nats", SnapshotsTheBus: true, Serves: []string{"nats_streams"}, PasswordHash: "$2a$11$bbbbbbbbbbbbbbbbbbbbbb"}, }) if err != nil { t.Fatal(err) } want, err := os.ReadFile(filepath.Join("testdata", "composed.conf")) if err != nil { t.Fatal(err) } grantsAsTheControllers(t, got, string(want)) }) }