package runtime import ( "context" "encoding/json" "errors" "sort" "strings" "testing" "git.novox.be/novox/mesh-sdk/go/buscalls" "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/membership" "github.com/nats-io/nats.go" "github.com/nats-io/nats.go/jetstream" "github.com/novox/mesh-tools/node-tools/internal/bus" mt "github.com/novox/mesh-tools/node-tools/internal/meshtest" ) // 1966/AC16: given a module with a ledger binding, when the runner lists its tools, then the four repair tools are // among them, marked acts or reads; a module without one has none, and a binding written later brings them. func TestTheLedgerRepairsAreToolsOfEveryLedgerOwner(t *testing.T) { mesh := mt.New(t) owner := mt.MembershipOf("owner", "anchor", true, nil) owner.Bindings = []membership.BusBinding{{Local: "records", Storage: catalogue.StorageLedger, Template: catalogue.TemplateLedger, History: "LEDGER_owner_records", Subject: "mesh.ledger.owner.records.history", State: "owner_records", Snapshot: "mesh.ledger.owner.records.snapshot"}} plain := mt.MembershipOf("plain", "anchor", true, nil) mesh.Issue(t, owner) mesh.Issue(t, plain) nodeTools := connect(t, "node-tools", "anchor") stop, err := Run(nodeTools, []Served{{"owner", []string{mt.Fixture("caller-echo.py")}}, {"plain", []string{mt.Fixture("caller-echo.py")}}}, nil, func(string, ...any) {}) if err != nil { t.Fatal(err) } defer stop() asker := connect(t, "console", "workstation") listed := func(module string) (string, error) { answered, err := asker.Ask(module+"."+ToolsVerb, map[string]any{}, "") if err != nil { return "", err } var got ToolsAnswer if err := json.Unmarshal(answered.Result, &got); err != nil { return "", err } var names []string for _, tool := range got.Tools { names = append(names, tool.Name+":"+tool.Effect) } sort.Strings(names) return strings.Join(names, " "), nil } for _, c := range []struct{ name, module, want string }{ {"a ledger owner lists the four repairs", "owner", "ledger_lag:reads ledger_rebuild:acts ledger_reindex:acts ledger_snapshot:acts who:"}, {"a module with no ledger has none", "plain", "who:"}, } { t.Run(c.name, func(t *testing.T) { var got string mt.Until(t, func() (err error) { got, err = listed(c.module); return }) if got != c.want { t.Fatalf("%s lists %q, want %q", c.module, got, c.want) } }) } t.Run("a ledger bound later brings them", func(t *testing.T) { plain.Bindings = owner.Bindings mesh.Issue(t, plain) mt.Until(t, func() error { got, err := listed("plain") if err == nil && !strings.Contains(got, "ledger_rebuild:acts") { err = errors.New("listed " + got) } return err }) }) } // aLedger raises one ledger of module m on the bus as the bus module would create it, binds it in m's membership, and // answers the runtime's buses for m and for other; its types travel on its binding, and a second binding carries none. func aLedger(t *testing.T) (m, other *moduleBus, mesh *mt.Mesh) { t.Helper() mesh = mt.New(t) nc, err := nats.Connect(mt.URL(t)) if err != nil { t.Fatal(err) } t.Cleanup(nc.Close) js, _ := jetstream.New(nc) ctx := context.Background() for _, cfg := range []jetstream.StreamConfig{ {Name: "LEDGER_m_records", Subjects: []string{"mesh.ledger.m.records.history.>"}}, {Name: "SNAPSHOT_m_records", Subjects: []string{"mesh.ledger.m.records.snapshot.>"}, MaxMsgsPerSubject: 1}, } { if _, err := js.CreateStream(ctx, cfg); err != nil { t.Fatal(err) } } if _, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: "m_records"}); err != nil { t.Fatal(err) } mem := mt.MembershipOf("m", "anchor", false, nil) mem.Bindings = []membership.BusBinding{{Local: "records", Storage: catalogue.StorageLedger, Template: catalogue.TemplateLedger, History: "LEDGER_m_records", Subject: "mesh.ledger.m.records.history", State: "m_records", Snapshot: "mesh.ledger.m.records.snapshot", Types: []catalogue.LedgerType{{Name: "decision", Schema: map[string]catalogue.LedgerField{"title": {Type: "string"}}, Operations: []catalogue.LedgerOperation{{Name: "title", Effect: catalogue.EffectSetFields, Fields: []string{"title"}, Author: true}}}}}, {Local: "untyped", Storage: catalogue.StorageLedger, Template: catalogue.TemplateLedger, History: "LEDGER_m_records", State: "m_records"}, {Local: "elsewhere", Storage: catalogue.StorageLedger, Template: catalogue.TemplateLedger, History: "LEDGER_m_records", Subject: "mesh.ledger.m.records.history", State: "m_records", Types: []catalogue.LedgerType{{Name: "decision"}}}} mesh.Issue(t, mem) mesh.Issue(t, mt.MembershipOf("other", "anchor", false, nil)) conn := connect(t, "node-tools", "anchor") conn.Follow("m") conn.Follow("other") mt.Until(t, func() error { if conn.Membership("m") == nil || conn.Membership("other") == nil { return errors.New("not issued yet") } return nil }) all := &consumers{conn: conn, logf: t.Logf, of: map[string]*moduleEvents{}, ledgers: ledgerSource{ writer: func(string) (*bus.Conn, error) { return conn, nil }, }} return all.forModule("m").(*moduleBus), all.forModule("other").(*moduleBus), mesh } // 1966/AC17, 1966/AC18: the author an apply is stamped with is the caller the runtime proved for the call in flight it // names, whatever author the bundle wrote; another module's call, or one not in flight, is refused naming it (ADR 0597 ยง7). func TestAnApplyIsStampedWithTheCallerTheRuntimeProved(t *testing.T) { m, other, _ := aLedger(t) call, end := m.Begin("op@shanks") defer end() theirs, endTheirs := other.Begin("op@shanks") defer endTheirs() forged := `"body":{"title":"T","author":{"account":"x","caller":"forged"}}` for _, c := range []struct{ name, params, caller, refusal string }{ {"1966/AC17 a call in flight stamps its proven caller", `{"ledger":"records","instance":"decision/1","operation":"title",` + forged + `,"call":"` + call + `","author":"forged"}`, "op@shanks", ""}, {"naming no call stamps the account alone", `{"ledger":"records","instance":"decision/2","operation":"title",` + forged + `}`, "", ""}, {"1966/AC18 another module's call is refused, naming it", `{"ledger":"records","instance":"decision/3","operation":"title",` + forged + `,"call":"` + theirs + `"}`, "", "the call " + theirs + ", which is another module's"}, {"a ledger bound with no types is refused, naming it", `{"ledger":"untyped","instance":"decision/3","operation":"title",` + forged + `}`, "", `m's ledger "untyped" is bound with no types`}, {"a binding naming subjects its account is not granted is refused, naming both", `{"ledger":"elsewhere","instance":"decision/3","operation":"title",` + forged + `}`, "", "bound with the history subject mesh.ledger.m.records.history, and its ledger account is granted mesh.ledger.m.elsewhere.history alone"}, {"a call not in flight is refused, naming it", `{"ledger":"records","instance":"decision/3","operation":"title",` + forged + `,"call":"c0ffee"}`, "", "the call c0ffee, which is not a tool call in flight"}, } { t.Run(c.name, func(t *testing.T) { var req struct { Instance string `json:"instance"` } _ = json.Unmarshal([]byte(c.params), &req) _, err := m.BusState(buscalls.LedgerApply, json.RawMessage(c.params)) if c.refusal != "" { if err == nil || !strings.Contains(err.Error(), c.refusal) { t.Fatalf("applied with %v, want refused with %q", err, c.refusal) } return } if err != nil { t.Fatal(err) } raw, err := m.BusState(buscalls.LedgerHistory, json.RawMessage(`{"ledger":"records","instance":"`+req.Instance+`"}`)) if err != nil { t.Fatal(err) } var page buscalls.HistoryPage _ = json.Unmarshal(raw, &page) if len(page.Operations) != 1 || page.Operations[0].Author != (buscalls.Author{Account: "m", Caller: c.caller}) { t.Fatalf("the history holds %+v, want the author {m %s}", page.Operations, c.caller) } state, _ := m.BusState(buscalls.LedgerGet, json.RawMessage(`{"ledger":"records","instance":"`+req.Instance+`"}`)) if !strings.Contains(string(state), `"author":{"account":"m"`) || strings.Contains(string(state), "forged") { t.Fatalf("the state took the bundle's author: %s", state) } }) } end() if _, err := m.BusState(buscalls.LedgerApply, json.RawMessage(`{"ledger":"records","instance":"decision/4","operation":"title","call":"`+call+`"}`)); err == nil { t.Fatal("a call that ended was still taken as in flight") } }