Files
mesh-sdk/go/membership/membership_test.go
T
jschoubben 17475257d1
mesh/delivery merged: source.main moved to novox/mesh-sdk@017743219669
mesh/delivery-group group fix/1966-requires-state merged: every member is merged
mesh/check checking 17475257d15f on 017743219669 | novox/mesh-sdk#177 in group fix/1966-requires-state
One rule for the state a membership issues and a manifest keeps, in both forms
A key-value entry asked in requires (ADR 0595) was bound but never issued
as a state, because each reader read the old state list alone (issue 1966).
Membership.StateIssued and StateNames, and catalogue.KeptStates, are the one
rule the tool runner, the grants part and the manifest validator call.

Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-09 12:34:36 +01:00

130 lines
6.2 KiB
Go

package membership
import (
"encoding/json"
"reflect"
"testing"
"git.novox.be/novox/mesh-sdk/go/catalogue"
)
func TestAMembershipIsPublishedAndReadAtTheSubjectDerivedFromTheTwoNames(t *testing.T) {
if got := Subject("ace", "route-proxy"); got != "mesh.assignment.ace.route-proxy" {
t.Errorf("subject: %q", got)
}
if got := DirectGetSubject("ace", "route-proxy"); got != "$JS.API.DIRECT.GET.ASSIGNMENTS.mesh.assignment.ace.route-proxy" {
t.Errorf("direct get: %q", got)
}
if AssignmentsStream != "ASSIGNMENTS" {
t.Errorf("stream: %q", AssignmentsStream)
}
}
// The shape on the wire is the controller's: a membership as issued reads back whole, with the routes kept raw.
func TestAMembershipRoundTripsAsIssued(t *testing.T) {
in := Membership{Node: "ace", Module: "route-proxy", Emits: "e", Tools: "t",
Serves: []Served{{Subject: "s", Queue: "q"}},
Receives: map[string]json.RawMessage{"route": json.RawMessage(`[{"name":"a"}]`)},
Mesh: []string{"10.10.0.7"},
State: []StateIssued{{Name: "n", Bucket: "b", Writes: true, Key: "ace"}},
SeatTraffic: &SeatTraffic{Publish: []string{"p"}, Kinds: []KindHeld{{Seat: "channel", Kind: "tg"}}}}
raw, err := json.Marshal(in)
if err != nil {
t.Fatal(err)
}
var out Membership
if err := json.Unmarshal(raw, &out); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(in, out) {
t.Fatalf("round trip changed it:\n%+v\n%+v", in, out)
}
// 1966/AC4: a membership with a ledger binding, its types among it, round-trips; one without bindings encodes as before, byte for byte.
bound := in
bound.Bindings = []BusBinding{{Local: "records", Storage: "ledger", Template: "ledger", History: "forge_records",
Subject: "forge_records.>", State: "forge_records_state", Indexes: map[string]string{"search": "forge_records_search"},
Snapshot: "forge_records_snapshot.>",
Types: []catalogue.LedgerType{{Name: "decision", Schema: map[string]catalogue.LedgerField{"title": {Type: "text"}},
Operations: []catalogue.LedgerOperation{{Name: "propose", Effect: catalogue.EffectSetFields, Fields: []string{"title"}}}}}}}
boundRaw, err := json.Marshal(bound)
if err != nil {
t.Fatal(err)
}
var boundBack Membership
if err := json.Unmarshal(boundRaw, &boundBack); err != nil || !reflect.DeepEqual(bound, boundBack) {
t.Fatalf("a membership with a ledger binding read back as %+v (%v)", boundBack, err)
}
before, _ := json.Marshal(Membership{Node: "ace", Module: "forge", Tools: "t", Emits: "e", Serves: []Served{{Subject: "s"}},
State: []StateIssued{{Name: "repos", Bucket: "forge_repos", Writes: true}}})
if string(before) != `{"node":"ace","module":"forge","serves":[{"subject":"s"}],"emits":"e","tools":"t","state":[{"name":"repos","bucket":"forge_repos","writes":true}]}` {
t.Fatalf("a membership without bindings encodes otherwise than before: %s", before)
}
// Issue 1966 (F1): what a membership issues by a local name is one rule. An old `state` entry wins; a key-value
// binding of `requires` is issued by its local name, own and written; any other binding, and a name of neither, is not.
issuing := Membership{State: []StateIssued{{Name: "repos", Bucket: "forge_repos"}},
Bindings: []BusBinding{{Local: "holds", Storage: catalogue.StorageKeyValue, Template: catalogue.TemplateState, Bucket: "forge_holds"},
{Local: "repos", Storage: catalogue.StorageKeyValue, Bucket: "forge_repos2"},
{Local: "blobs", Storage: catalogue.StorageObjectStore, ObjectStore: "forge_blobs"},
{Local: "events", Storage: catalogue.StorageStream, Stream: "forge_events"}}}
for _, c := range []struct {
name string
want StateIssued
ok bool
}{
{"holds", StateIssued{Name: "holds", Bucket: "forge_holds", Writes: true}, true},
{"repos", StateIssued{Name: "repos", Bucket: "forge_repos"}, true},
{"blobs", StateIssued{}, false},
{"events", StateIssued{}, false},
{"other", StateIssued{}, false},
} {
if got, ok := issuing.StateIssued(c.name); ok != c.ok || got != c.want {
t.Errorf("StateIssued(%q) = %+v, %v; want %+v, %v", c.name, got, ok, c.want, c.ok)
}
}
if got := issuing.StateNames(); !reflect.DeepEqual(got, []string{"holds", "repos"}) {
t.Errorf("StateNames = %v", got)
}
var wire map[string]json.RawMessage
_ = json.Unmarshal(raw, &wire)
for _, k := range []string{"receives", "mesh", "seat-traffic"} {
if _, ok := wire[k]; !ok {
t.Errorf("field %q is not on the wire under that name", k)
}
}
// The grants part's memberships hold the bare membership through this package's codec; the wrapper written before
// 2026-10-30 still reads when it says the membership was published or unchanged, and anything else is an error,
// never an empty membership or one the part held back (issue 774).
m := Membership{Node: "ace", Module: "forge", Tools: "mesh.mod.forge.tool", Serves: []Served{{Subject: "mesh.mod.forge.tool.{tool}"}},
State: []StateIssued{{Name: "repos", Bucket: "forge_repos"}}}
inner, _ := json.Marshal(m)
wrapped := func(published string) []byte {
return []byte(`{"node":"ace","module":"forge","digest":"sha256:x","membership":` + string(inner) + published + `,"retired":["a b"]}`)
}
for _, c := range []struct {
name string
raw []byte
ok bool
}{
{"bare, as written", EncodeGrantsMembership(m), true},
{"wrapped and published, as the grants part wrote it before", wrapped(`,"published":"published"`), true},
{"wrapped and unchanged", wrapped(`,"published":"unchanged"`), true},
{"wrapped and held back", wrapped(`,"published":"not published, a shadow difference: publishing would lose x"`), false},
{"wrapped, saying nothing of its publishing", wrapped(""), false},
{"not json", []byte("garbage"), false},
{"an array", []byte(`[1,2]`), false},
{"an object naming no node or module", []byte(`{"serves":[{"subject":"s"}]}`), false},
{"a wrapper whose membership is no object", []byte(`{"node":"ace","module":"forge","membership":"x","published":"published"}`), false},
{"a wrapper whose membership names nothing", []byte(`{"node":"ace","module":"forge","membership":{},"published":"published"}`), false},
} {
got, err := DecodeGrantsMembership(c.raw)
if (err == nil) != c.ok {
t.Errorf("%s: err %v", c.name, err)
continue
}
if c.ok && !reflect.DeepEqual(got, m) {
t.Errorf("%s: read %+v, want %+v", c.name, got, m)
}
}
}