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
130 lines
6.2 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|