Live since the take-over the shadow list was written every 3 s (revision 99270 to 99357 in 90 s, nothing differing): the sealed key read back says [] for the nil left-out a pass composes, so every pass wrote the key again; each write moved the bus node's report of what it loaded, whose revision and time were inputs of the shadow list, and woke the next pass. The bus itself reloads only when the list's bytes change (its log: two configurations alternating with the placement, f8567865 and 473f9ad3), so this loop was load, not the reload.
1944 lines
89 KiB
Go
1944 lines
89 KiB
Go
package manifest
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/nats-io/nats.go/jetstream"
|
|
|
|
"git.novox.be/novox/mesh-sdk/go/asker"
|
|
"git.novox.be/novox/mesh-sdk/go/asks"
|
|
"git.novox.be/novox/mesh-sdk/go/buscompose"
|
|
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
|
"git.novox.be/novox/mesh-sdk/go/delivery"
|
|
"git.novox.be/novox/mesh-sdk/go/outward"
|
|
"git.novox.be/novox/mesh-sdk/go/seal"
|
|
|
|
"mesh-grants/internal/grants"
|
|
)
|
|
|
|
// fakeBus is the bus as the part sees it: other parts' states to read (refusing those named), the bus module's read
|
|
// tools, the part's own states and its events. It records every write, call and event, so a row can prove nothing was
|
|
// published or asserted.
|
|
type fakeBus struct {
|
|
mu sync.Mutex
|
|
states map[string]map[string][]byte
|
|
refused map[string]bool
|
|
loaded []grants.User
|
|
streams []map[string]any
|
|
own map[string]map[string][]byte
|
|
revs map[string]uint64
|
|
refuseTo map[string]bool
|
|
calls []string
|
|
events []string
|
|
writes []string
|
|
// The assignments stream: each subject's last membership and sequence, every publish, and whether it is refused.
|
|
stream map[string][]byte
|
|
seqs map[string]uint64
|
|
seq uint64
|
|
publishes []string
|
|
refusePub bool
|
|
// hold, when set, keeps every nats_users call waiting until it is closed: a pass waiting on the bus.
|
|
hold chan struct{}
|
|
// creates are the streams the part asked to create, refused when refuseCreate.
|
|
creates []string
|
|
refuseCreate bool
|
|
// refuseUsers refuses the bus module's nats_users: the loaded list cannot be read.
|
|
refuseUsers bool
|
|
// rootFree answers the controller's root-free verb: nil is no answerer at all (no responders).
|
|
rootFree func(machines []string) (any, error)
|
|
}
|
|
|
|
func (b *fakeBus) LastMembership(node, module string) ([]byte, uint64, bool, error) {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
subject := "mesh.assignment." + node + "." + module
|
|
v, ok := b.stream[subject]
|
|
return v, b.seqs[subject], ok, nil
|
|
}
|
|
|
|
func (b *fakeBus) PublishMembership(node, module string, body []byte, after uint64) error {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
subject := "mesh.assignment." + node + "." + module
|
|
if b.refusePub {
|
|
return errors.New("Permissions Violation for Publish to " + subject)
|
|
}
|
|
if b.seqs[subject] != after {
|
|
return fmt.Errorf("wrong last sequence for %s: %d", subject, b.seqs[subject])
|
|
}
|
|
if b.stream == nil {
|
|
b.stream, b.seqs = map[string][]byte{}, map[string]uint64{}
|
|
}
|
|
b.seq++
|
|
b.stream[subject], b.seqs[subject] = body, b.seq
|
|
b.publishes = append(b.publishes, subject)
|
|
return nil
|
|
}
|
|
|
|
// CreateDeclared is the bus's create of a stream: refused when refuseCreate, held when the bus holds it, and otherwise
|
|
// held from now on, as nats_streams then reads it.
|
|
func (b *fakeBus) CreateDeclared(cfg jetstream.StreamConfig) error {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
b.creates = append(b.creates, cfg.Name)
|
|
if b.refuseCreate {
|
|
return errors.New("Permissions Violation for Publish to $JS.API.STREAM.CREATE." + cfg.Name)
|
|
}
|
|
for _, s := range b.streams {
|
|
if s["name"] == cfg.Name {
|
|
return grants.ErrHeld
|
|
}
|
|
}
|
|
b.streams = append(b.streams, map[string]any{"name": cfg.Name, "subjects": cfg.Subjects, "retention": "limits",
|
|
"max_age_hours": int(cfg.MaxAge.Hours()), "max_per_subject": int(cfg.MaxMsgsPerSubject)})
|
|
return nil
|
|
}
|
|
|
|
func newFakeBus() *fakeBus {
|
|
return &fakeBus{states: map[string]map[string][]byte{}, refused: map[string]bool{}, own: map[string]map[string][]byte{},
|
|
revs: map[string]uint64{}, refuseTo: map[string]bool{}}
|
|
}
|
|
|
|
func (b *fakeBus) put(state, key string, v any) {
|
|
raw, err := json.Marshal(v)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
if b.states[state] == nil {
|
|
b.states[state] = map[string][]byte{}
|
|
}
|
|
b.states[state][key] = raw
|
|
}
|
|
|
|
func (b *fakeBus) Keys(state string) ([]string, error) {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
if b.refused[state] {
|
|
return nil, errors.New("Permissions Violation for Publish to $JS.API.STREAM.INFO.KV_" + state)
|
|
}
|
|
var out []string
|
|
for k := range b.states[state] {
|
|
out = append(out, k)
|
|
}
|
|
slices.Sort(out)
|
|
return out, nil
|
|
}
|
|
|
|
func (b *fakeBus) Get(state, key string) ([]byte, bool, error) {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
if b.refused[state] {
|
|
return nil, false, errors.New("refused")
|
|
}
|
|
v, ok := b.states[state][key]
|
|
return v, ok, nil
|
|
}
|
|
|
|
func (b *fakeBus) Call(key string, body any) (json.RawMessage, error) {
|
|
b.mu.Lock()
|
|
hold := b.hold
|
|
b.mu.Unlock()
|
|
if hold != nil && key == grants.CallUsers {
|
|
<-hold
|
|
}
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
b.calls = append(b.calls, key)
|
|
args, _ := body.(map[string]any)
|
|
switch key {
|
|
case grants.CallUsers:
|
|
if b.refuseUsers {
|
|
return nil, errors.New("nats: no responders available for request")
|
|
}
|
|
part, _ := args["user"].(string)
|
|
if part == "" {
|
|
var names []map[string]any
|
|
for _, u := range b.loaded {
|
|
names = append(names, map[string]any{"user": u.User, "publish_grants": len(u.Publish)})
|
|
}
|
|
return json.Marshal(map[string]any{"users": names})
|
|
}
|
|
var out []grants.User
|
|
for _, u := range b.loaded {
|
|
if strings.Contains(u.User, part) {
|
|
out = append(out, u)
|
|
}
|
|
}
|
|
return json.Marshal(map[string]any{"users": out})
|
|
case grants.CallStreams:
|
|
return json.Marshal(map[string]any{"detail": b.streams})
|
|
case grants.CallRootFree:
|
|
if b.rootFree == nil {
|
|
return nil, errors.New("nats: no responders available for request")
|
|
}
|
|
out, err := b.rootFree(args["machines"].([]string))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return json.Marshal(out)
|
|
}
|
|
return nil, fmt.Errorf("no tool %s", key)
|
|
}
|
|
|
|
func (b *fakeBus) ownGet(state, key string) ([]byte, uint64, bool, error) {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
v, ok := b.own[state][key]
|
|
return v, b.revs[state+"/"+key], ok, nil
|
|
}
|
|
|
|
type ownStore struct{ b *fakeBus }
|
|
|
|
func (s ownStore) Get(state, key string) ([]byte, uint64, bool, error) { return s.b.ownGet(state, key) }
|
|
func (s ownStore) Put(state, key string, value []byte) (uint64, error) {
|
|
b := s.b
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
if b.refuseTo[state] {
|
|
return 0, errors.New("Permissions Violation for Publish to $KV.mesh-grants_" + state)
|
|
}
|
|
b.writes = append(b.writes, state)
|
|
if b.own[state] == nil {
|
|
b.own[state] = map[string][]byte{}
|
|
}
|
|
b.own[state][key] = value
|
|
b.revs[state+"/"+key]++
|
|
return b.revs[state+"/"+key], nil
|
|
}
|
|
|
|
func (b *fakeBus) Emit(event string, _ any) error {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
b.events = append(b.events, event)
|
|
return nil
|
|
}
|
|
|
|
func (b *fakeBus) count(key string) int {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
n := 0
|
|
for _, c := range b.calls {
|
|
if c == key {
|
|
n++
|
|
}
|
|
}
|
|
return n
|
|
}
|
|
|
|
// manifestOf is a fixture manifest of one module, in the indexer's two buckets.
|
|
func (b *fakeBus) manifestOf(module, raw string) {
|
|
digest := delivery.ManifestDigest([]byte(raw))
|
|
b.put(grants.ReadManifests, digest, delivery.Manifest{V: delivery.Version, Module: module, JSON: raw})
|
|
b.put(grants.ReadModules, module, delivery.Module{V: delivery.Version, FP2: "src2:" + strings.Repeat("ab", 32), Manifest: digest, Repository: "novox/x", Commit: "c"})
|
|
}
|
|
|
|
func (b *fakeBus) assign(node, module string) {
|
|
b.put(grants.ReadAssignments, node+"."+module, map[string]any{"node": node, "module": module, "assigned": "2026-10-25T20:00:00Z"})
|
|
}
|
|
|
|
const (
|
|
natsManifest = `{"module":"nats","version":"1","claims":[{"name":"mesh-broker"}],"own-secrets":{"broker":"/var/lib/nats/broker"}}`
|
|
xManifest = `{"module":"mod-x","version":"1","own-secrets":{"broker":"/var/lib/x/broker"},"state":[{"name":"kept","history":3}],` +
|
|
`"streams":[{"name":"things","events":["thing.added"],"max-age-days":2,"max-mib":5}],"logs":[{"name":"history"}]}`
|
|
yManifest = `{"module":"mod-y","version":"1","own-secrets":{"broker":"/var/lib/y/broker"},"reads":["mod-x.kept"]}`
|
|
)
|
|
|
|
// brokerSeat is one verb of the bus's seat as a membership serves it: nats claims the seat in fixture and no membership
|
|
// composed there serves it, so a membership or list that drops it leaves the seat with no holder (issue 1945).
|
|
func brokerSeat() (seat, verb string) {
|
|
s, _ := catalogue.SeatNamed(catalogue.BrokerSeat)
|
|
verb = "v"
|
|
if len(s.Serves) > 0 {
|
|
verb = s.Serves[0].Name
|
|
}
|
|
return s.Name, verb
|
|
}
|
|
|
|
// fixture is a mesh of two machines: a runs the bus and mod-x, b runs mod-y; the bus node's agent loaded a list.
|
|
func fixture() *fakeBus {
|
|
b := newFakeBus()
|
|
b.manifestOf("nats", natsManifest)
|
|
b.manifestOf("mod-x", xManifest)
|
|
b.manifestOf("mod-y", yManifest)
|
|
b.assign("a", "nats")
|
|
b.assign("a", "mod-x")
|
|
b.assign("b", "mod-y")
|
|
b.put(grants.ReadAssignments, "b.gone", map[string]any{"node": "b", "module": "gone", "assigned": "2026-10-01T00:00:00Z", "ended": "2026-10-02T00:00:00Z"})
|
|
b.put(grants.ReadHave, "a.node", map[string]any{"v": 1})
|
|
b.put(grants.ReadHave, "b.node", map[string]any{"v": 1})
|
|
b.put(grants.ReadHave, "a.nats", delivery.Have{V: delivery.Version, Phase: delivery.PhaseRunning, Health: delivery.HealthOK,
|
|
UserList: &delivery.UserListWritten{Source: "a.nats.src2", Revision: 7, Wanted: "sha256:aa", Digest: "sha256:aa", Users: 6}})
|
|
b.put(grants.ReadControllerGrant, "mod-x.0123456789ab0123456789ab0123456789ab0123456789ab0123456789ab0123", delivery.ControllerGrant{V: delivery.Version, Accounts: []string{"a.mod-x"}})
|
|
return b
|
|
}
|
|
|
|
func partOn(b *fakeBus) *grants.Part {
|
|
return &grants.Part{Read: b, Call: b, Store: ownStore{b}, Emit: b, Publish: b, Now: func() time.Time { return time.Date(2026, 10, 25, 22, 0, 0, 0, time.UTC) }}
|
|
}
|
|
|
|
// shadowOf is the list the part composes from b's records now.
|
|
func shadowOf(t *testing.T, b *fakeBus) []grants.User {
|
|
t.Helper()
|
|
recs, _ := grants.Records(grants.ReadInputs(b, &grants.Manifests{}))
|
|
users, err := grants.ComposeUsers(recs)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return users
|
|
}
|
|
|
|
func names(us []grants.User) []string {
|
|
var out []string
|
|
for _, u := range us {
|
|
out = append(out, u.User)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func gapNamed(gs []grants.Gap, input string) bool {
|
|
return slices.ContainsFunc(gs, func(g grants.Gap) bool { return strings.Contains(g.Input, input) })
|
|
}
|
|
|
|
// TestGrants is the grants part's one table of checks (ADR 0547 clauses 5 to 8 in shadow, issue 1945): its manifest,
|
|
// the shadow list composed from bus values, the compare, each tool, that nothing is published or asserted, and that a
|
|
// refused read or write never stops it.
|
|
func TestGrants(t *testing.T) {
|
|
t.Run("manifest", func(t *testing.T) {
|
|
raw, err := os.ReadFile("module.json")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
m, err := catalogue.ParseManifest(raw)
|
|
if err != nil {
|
|
t.Fatalf("mesh-sdk's manifest reader refuses the manifest: %v", err)
|
|
}
|
|
if m.Module != "mesh-grants" || len(m.Claims) != 0 {
|
|
t.Errorf("module %q claims %v: the part claims no seat until mesh-sdk's seat list names mesh-grants", m.Module, m.Claims)
|
|
}
|
|
for _, row := range []struct {
|
|
what string
|
|
got, want []string
|
|
}{
|
|
{"reads", m.Reads, grants.Reads},
|
|
{"invokes", m.Invokes, grants.Invokes},
|
|
{"consumes", m.Consumes, grants.Consumes},
|
|
{"emits", m.Emits, grants.Emits},
|
|
{"tools", m.Tools, grants.Tools},
|
|
} {
|
|
if !slices.Equal(slices.Sorted(slices.Values(row.got)), slices.Sorted(slices.Values(row.want))) {
|
|
t.Errorf("%s = %v, the program's are %v", row.what, row.got, row.want)
|
|
}
|
|
}
|
|
var states []string
|
|
for _, s := range m.State {
|
|
states = append(states, s.Name)
|
|
}
|
|
if !slices.Equal(slices.Sorted(slices.Values(states)), grants.States) {
|
|
t.Errorf("state = %v, want %v", states, grants.States)
|
|
}
|
|
if _, ok := m.OwnSecrets["broker"]; !ok || len(m.OwnSecrets) != 1 || m.SecretsOwner != "mesh-grants" {
|
|
t.Errorf("own-secrets %v owner %q: a credential of its own, broker", m.OwnSecrets, m.SecretsOwner)
|
|
}
|
|
})
|
|
|
|
t.Run("no timer, no update or delete, a publish only of memberships, a create only of declared streams", func(t *testing.T) {
|
|
forbidden := []string{"time.Tick", "time.NewTicker", "time.Sleep", "time.After", "time.NewTimer", "time.AfterFunc",
|
|
"PublishMsg", "CreateKeyValue", "CreateOrUpdate", "UpdateStream", "UpdateKeyValue", "DeleteStream", "PurgeStream",
|
|
"DeleteKeyValue", "CreateObjectStore", "EnsureStream", "RaiseBuckets", "RaiseLogs", ".Create("}
|
|
// The one publish: a membership, on its own subject, in publish.go; the one create: a declared stream the bus lacks,
|
|
// in create.go (issue 1790).
|
|
publishOnly := filepath.Join("cmd", "mesh-grants", "publish.go")
|
|
createOnly := filepath.Join("cmd", "mesh-grants", "create.go")
|
|
// And the operator's asks and conditions, on the seat operator-channel's subjects, in asking.go (ADR 0547 clause 8).
|
|
asksOnly := filepath.Join("cmd", "mesh-grants", "asking.go")
|
|
sealedOnly := filepath.Join("cmd", "mesh-grants", "sealed.go")
|
|
files := 0
|
|
for _, root := range []string{"cmd", "internal"} {
|
|
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
|
|
if err != nil || d.IsDir() || !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
|
|
return err
|
|
}
|
|
files++
|
|
src, err := os.ReadFile(path)
|
|
if path != publishOnly && path != asksOnly && (strings.Contains(string(src), ".Publish(") || strings.Contains(string(src), "mesh.assignment")) {
|
|
t.Errorf("%s publishes: only publish.go publishes memberships, and asking.go the operator's asks", path)
|
|
}
|
|
if path == asksOnly && (strings.Count(string(src), ".Publish(") != 2 || strings.Contains(string(src), "mesh.assignment")) {
|
|
t.Errorf("asking.go publishes other than an ask, a cancel and a condition, through js.Publish twice")
|
|
}
|
|
if path == publishOnly && strings.Count(string(src), ".Publish(") != 1 {
|
|
t.Errorf("publish.go publishes other than one membership")
|
|
}
|
|
if n := strings.Count(string(src), "CreateStream"); (path == createOnly && n != 1) || (path != createOnly && n != 0) {
|
|
t.Errorf("%s holds CreateStream %d times: only create.go creates, once", path, n)
|
|
}
|
|
// The one object bucket: the part's own, of the user list sealed to the bus node, in sealed.go (ADR 0547 clause 7).
|
|
if path == sealedOnly {
|
|
if strings.Count(string(src), "CreateObjectStore(") != 1 || !strings.Contains(string(src), "delivery.GrantsUserListObjects") {
|
|
t.Errorf("sealed.go creates other than the part's one object bucket")
|
|
}
|
|
src = []byte(strings.ReplaceAll(string(src), "CreateObjectStore", ""))
|
|
}
|
|
for _, f := range forbidden {
|
|
if strings.Contains(string(src), f) {
|
|
t.Errorf("%s holds %s: the part holds no timer and creates or asserts nothing", path, f)
|
|
}
|
|
}
|
|
return err
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if files < 8 {
|
|
t.Fatalf("read %d source files, want the program's", files)
|
|
}
|
|
})
|
|
|
|
t.Run("shadow list from bus values", func(t *testing.T) {
|
|
for _, row := range []struct {
|
|
name string
|
|
bend func(*fakeBus)
|
|
users []string
|
|
gap string
|
|
}{
|
|
{"every record on the bus", func(*fakeBus) {},
|
|
[]string{"a.mod-x", "a.nats", "b.mod-y", "controller", "node.a", "node.b"}, "enrolment tokens"},
|
|
{"a manifest missing is a gap and its user is left out", func(b *fakeBus) { delete(b.states[grants.ReadModules], "mod-y") },
|
|
[]string{"a.mod-x", "a.nats", "controller", "node.a", "node.b"}, "manifest of mod-y"},
|
|
{"a want's built manifest is composed, not the trunk's", func(b *fakeBus) {
|
|
built := strings.Replace(xManifest, `"history":3}]`, `"history":3},{"name":"newer"}]`, 1)
|
|
digest := delivery.ManifestDigest([]byte(built))
|
|
b.put(grants.ReadManifests, digest, delivery.Manifest{V: delivery.Version, Module: "mod-x", JSON: built})
|
|
fp := "src2:" + strings.Repeat("cd", 32)
|
|
b.put(grants.ReadWants, "want.a.mod-x", delivery.Want{V: delivery.Version, FP: fp, Composed: "sha256:" + strings.Repeat("ef", 32)})
|
|
key, _ := delivery.BuilderBuildKey("mod-x", fp)
|
|
b.put(grants.ReadBuilds, key, map[string]any{"v": delivery.Version, "state": "done", "manifest": digest})
|
|
}, []string{"a.mod-x", "a.nats", "b.mod-y", "controller", "node.a", "node.b"}, "enrolment tokens"},
|
|
{"placement refused is a gap; the machines come from the haves", func(b *fakeBus) { b.refused[grants.ReadAssignments] = true },
|
|
[]string{"controller", "node.a", "node.b"}, grants.ReadAssignments},
|
|
} {
|
|
t.Run(row.name, func(t *testing.T) {
|
|
b := fixture()
|
|
row.bend(b)
|
|
in := grants.ReadInputs(b, &grants.Manifests{})
|
|
recs, left := grants.Records(in)
|
|
users, err := grants.ComposeUsers(recs)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := names(users); !slices.Equal(got, row.users) {
|
|
t.Errorf("users = %v, want %v", got, row.users)
|
|
}
|
|
if !gapNamed(append(in.Gaps, left...), row.gap) {
|
|
t.Errorf("gaps %v name no %q", in.Gaps, row.gap)
|
|
}
|
|
if strings.HasPrefix(row.name, "a want's") {
|
|
x := users[slices.IndexFunc(users, func(u grants.User) bool { return u.User == "a.mod-x" })]
|
|
if !slices.Contains(x.Publish, "$KV.mod-x_newer.>") {
|
|
t.Errorf("a.mod-x is not granted the state its wanted build declares: %v", x.Publish)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
// A module whose only runtime-loaded part is a helper bundle (route-proxy's route-provider) says `loads` in
|
|
// the repository manifest's build.artifacts; the indexer's manifest has no derived Bundles (issue 1945).
|
|
for _, row := range []struct {
|
|
name, build string
|
|
carried bool
|
|
}{
|
|
{"a bundle that says loads is carried by node-tools", `,"build":{"artifacts":[{"name":"helper","kind":"bundle","language":"go","system":"arch","from":"cmd/h","binary":"h","loads":["h"]}]}`, true},
|
|
{"a module with no loading bundle is left to its own account", ``, false},
|
|
} {
|
|
t.Run(row.name, func(t *testing.T) {
|
|
b := fixture()
|
|
b.manifestOf("mod-z", `{"module":"mod-z","version":"1","own-secrets":{"broker":"/var/lib/z/broker"}`+row.build+`}`)
|
|
b.assign("a", "mod-z")
|
|
b.manifestOf("node-tools", `{"module":"node-tools","version":"1","own-secrets":{"broker":"/var/lib/nt/broker"}}`)
|
|
b.assign("a", "node-tools")
|
|
users := shadowOf(t, b)
|
|
tools := users[slices.IndexFunc(users, func(u grants.User) bool { return u.User == "a.node-tools" })]
|
|
if got := slices.Contains(tools.Subscribe, "mesh.mod.mod-z.tool.>"); got != row.carried {
|
|
t.Errorf("a.node-tools subscribes to mod-z's tools = %v, want %v: %v", got, row.carried, tools.Subscribe)
|
|
}
|
|
})
|
|
}
|
|
// ADR 0559 §3 (issue 1126): the indexer's placement candidates are read by every node agent (mesh-sdk
|
|
// NodeComposeReads), the assigner and the status view, each by its manifest's reads, and by no other module.
|
|
cands := fixture()
|
|
cands.manifestOf("mesh-delivery-indexer", `{"module":"mesh-delivery-indexer","version":"1","own-secrets":{"broker":"/var/lib/i/broker"},"state":[{"name":"placement-candidates","history":5}]}`)
|
|
cands.manifestOf("mesh-delivery-assigner", `{"module":"mesh-delivery-assigner","version":"1","own-secrets":{"broker":"/var/lib/as/broker"},"reads":["mesh-delivery-indexer.placement-candidates"]}`)
|
|
cands.manifestOf("mesh-delivery-view", `{"module":"mesh-delivery-view","version":"1","own-secrets":{"broker":"/var/lib/v/broker"},"reads":["mesh-delivery-indexer.placement-candidates"]}`)
|
|
for _, m := range []string{"mesh-delivery-indexer", "mesh-delivery-assigner", "mesh-delivery-view"} {
|
|
cands.assign("a", m)
|
|
}
|
|
var readers []string
|
|
for _, u := range shadowOf(t, cands) {
|
|
if slices.Contains(u.Publish, "$JS.API.DIRECT.GET.KV_"+delivery.PlacementCandidatesBucket+".>") {
|
|
readers = append(readers, u.User)
|
|
}
|
|
}
|
|
if want := []string{"a.mesh-delivery-assigner", "a.mesh-delivery-indexer", "a.mesh-delivery-view", "node.a", "node.b"}; !slices.Equal(readers, want) {
|
|
t.Errorf("the placement candidates are read by %v, want the node agents, the assigner, the view and their writer %v", readers, want)
|
|
}
|
|
b := fixture()
|
|
users := shadowOf(t, b)
|
|
y := users[slices.IndexFunc(users, func(u grants.User) bool { return u.User == "b.mod-y" })]
|
|
if !slices.Contains(y.Publish, "$JS.API.DIRECT.GET.KV_mod-x_kept.>") {
|
|
t.Errorf("b.mod-y reads mod-x.kept and is not granted its direct get: %v", y.Publish)
|
|
}
|
|
})
|
|
|
|
t.Run("root-free: the controller's word fills the composition, failing closed (issue 1945)", func(t *testing.T) {
|
|
const router = `{"module":"messenger","version":"1","runs-as":"messenger","resources":[{"id":"account","type":"user","name":"messenger","shell":"/usr/bin/nologin","home":"/var/lib/messenger"}],"own-secrets":{"broker":"/var/lib/m/broker"},"secrets-owner":"messenger",` +
|
|
`"seats":[{"name":"operator-channel","scope":"mesh","serves":["open"]},{"name":"channel","scope":"mesh","kinded":true,"accepts":["show"]}],` +
|
|
`"claims":[{"name":"operator-channel","scope":"mesh","serves":["open"]}]}`
|
|
const telegram = `{"module":"telegram","version":"1","runs-as":"telegram","resources":[{"id":"account","type":"user","name":"telegram","shell":"/usr/bin/nologin","home":"/var/lib/telegram"}],"own-secrets":{"broker":"/var/lib/t/broker"},"secrets-owner":"telegram",` +
|
|
`"claims":[{"name":"channel","scope":"mesh","kind":"telegram","capabilities":["deliver","verified-sender"]}]}`
|
|
answer := func(free ...string) func([]string) (any, error) {
|
|
return func(machines []string) (any, error) {
|
|
var vs []map[string]any
|
|
for _, m := range machines {
|
|
ok := slices.Contains(free, m)
|
|
why := "judged free"
|
|
if !ok {
|
|
why = "its node-engine judged nothing"
|
|
}
|
|
vs = append(vs, map[string]any{"machine": m, "free": ok, "why": why})
|
|
}
|
|
return map[string]any{"machines": vs}, nil
|
|
}
|
|
}
|
|
for _, row := range []struct {
|
|
name string
|
|
routerOn []string
|
|
telegramOn string
|
|
ask func([]string) (any, error)
|
|
keeps bool
|
|
note string
|
|
}{
|
|
{"router and telegram on one free machine", []string{"a"}, "a", answer("a"), true, ""},
|
|
{"router and telegram on two free machines", []string{"a"}, "b", answer("a", "b"), true, ""},
|
|
{"the holder's machine is not free", []string{"a"}, "b", answer("a"), false, "b: not free, its node-engine judged nothing"},
|
|
{"the router's machine is not free", []string{"a"}, "b", answer("b"), false, "a: not free"},
|
|
{"an error is not free", []string{"a"}, "a", func([]string) (any, error) { return nil, errors.New("boom") }, false, "boom"},
|
|
{"no answerer is not free", []string{"a"}, "a", nil, false, "no responders"},
|
|
{"a machine left out of the answer is not free", []string{"a"}, "a",
|
|
func([]string) (any, error) { return map[string]any{"machines": []any{}}, nil }, false, "named no verdict"},
|
|
{"the router on two machines frees nothing", []string{"a", "b"}, "a", answer("a", "b"), false, ""},
|
|
} {
|
|
t.Run(row.name, func(t *testing.T) {
|
|
b := fixture()
|
|
b.manifestOf("messenger", router)
|
|
b.manifestOf("telegram", telegram)
|
|
for _, node := range row.routerOn {
|
|
b.assign(node, "messenger")
|
|
}
|
|
b.assign(row.telegramOn, "telegram")
|
|
b.rootFree = row.ask
|
|
res := partOn(b).Compose()
|
|
recs, _ := grants.Records(grants.ReadInputs(b, &grants.Manifests{}))
|
|
grants.WithRootFree(&recs, b)
|
|
kept := false
|
|
for _, k := range buscompose.PlacementsOf(recs, recs.Interchangeable).Kinds {
|
|
kept = kept || slices.Contains(k.Capabilities, "verified-sender")
|
|
}
|
|
if kept != row.keeps {
|
|
t.Errorf("verified-sender kept = %v, want %v (said %v)", kept, row.keeps, res.RootNotFree)
|
|
}
|
|
if !strings.Contains(strings.Join(res.RootNotFree, " | "), row.note) || (row.note == "" && row.keeps && len(res.RootNotFree) != 0) {
|
|
t.Errorf("the pass says %v, want it to name %q", res.RootNotFree, row.note)
|
|
}
|
|
})
|
|
}
|
|
// A root-not-free condition raised or cleared wakes a pass; another kind does not (never a timer).
|
|
wb := fixture()
|
|
var handle func(string, []byte)
|
|
wf := &grants.Follower{Part: partOn(wb), Watch: &fakeWatcher{opened: make(chan string, 16)}, Logf: func(string, ...any) {},
|
|
Hear: func(h func(string, []byte)) error { handle = h; return nil }}
|
|
wctx, wcancel := context.WithCancel(context.Background())
|
|
wdone := make(chan struct{})
|
|
go func() { wf.Run(wctx); close(wdone) }()
|
|
waitFor(t, func() bool { return wb.count(grants.CallUsers) > 0 && handle != nil })
|
|
passes := func() int { return wb.count(grants.CallUsers) }
|
|
before := passes()
|
|
handle(grants.EventConditionRaised, []byte(`{"kind":"disk-full","key":"machine.a.disk-full"}`))
|
|
time.Sleep(100 * time.Millisecond)
|
|
if passes() != before {
|
|
t.Errorf("a condition of another kind woke a pass")
|
|
}
|
|
handle(grants.EventConditionCleared, []byte(`{"kind":"root-not-free","key":"machine.a.root-not-free"}`))
|
|
waitFor(t, func() bool { return passes() > before })
|
|
wcancel()
|
|
<-wdone
|
|
if !grants.IsRootNotFree([]byte(`{"kind":"root-not-free"}`)) || !grants.IsRootNotFree([]byte(`{"key":"machine.novox.root-not-free"}`)) ||
|
|
grants.IsRootNotFree([]byte(`{"kind":"disk-full","key":"machine.novox.disk-full"}`)) {
|
|
t.Errorf("only a root-not-free condition wakes a pass")
|
|
}
|
|
})
|
|
|
|
t.Run("a predefined-key fallback is read with no list; every refused manifest listed", func(t *testing.T) {
|
|
// mod-p's value falls back to a predefined key, as claude-code's managed_settings does, and no list is on the bus
|
|
// (issue 1945); mod-z's manifest carries a key mesh-sdk's reader refuses, and mod-z is assigned nowhere.
|
|
const pManifest = `{"module":"mod-p","version":"1","own-secrets":{"broker":"/var/lib/p/broker"},` +
|
|
`"config":{"size":{"type":"number","description":"The size text shows in, in points.","predefined":"font-size","default":12,"runtime":true}}}`
|
|
const zManifest = `{"module":"mod-z","version":"1","no-such-key":true}`
|
|
b := fixture()
|
|
b.manifestOf("mod-p", pManifest)
|
|
b.manifestOf("mod-z", zManifest)
|
|
b.assign("b", "mod-p")
|
|
// mesh-wireguard is provided by the mesh, not read from a repository: no manifest in the indexer, none refused,
|
|
// no gap, and its manifest is the one mesh-sdk provides (issue 1945).
|
|
b.assign("b", "mesh-wireguard")
|
|
in := grants.ReadInputs(b, &grants.Manifests{})
|
|
// An indexer row of the same name never wins: the provided manifest is used and the row is said as a gap.
|
|
bw := fixture()
|
|
bw.manifestOf("mesh-wireguard", `{"module":"mesh-wireguard","version":"9","own-secrets":{"broker":"/x"}}`)
|
|
bw.assign("b", "mesh-wireguard")
|
|
if iw := grants.ReadInputs(bw, &grants.Manifests{}); !strings.HasPrefix(iw.Digests["mesh-wireguard"], "provided:") || !gapNamed(iw.Gaps, "manifest of mesh-wireguard") || len(iw.Refused) != 0 {
|
|
t.Errorf("an indexer manifest of mesh-wireguard won or went unsaid: %v %v", iw.Digests["mesh-wireguard"], iw.Gaps)
|
|
}
|
|
if _, ok := in.Manifests["mesh-wireguard"]; !ok || gapNamed(in.Gaps, "manifest of mesh-wireguard") || !strings.HasPrefix(in.Digests["mesh-wireguard"], "provided:") {
|
|
t.Errorf("mesh-wireguard's manifest is not taken from mesh-sdk: gaps %v", in.Gaps)
|
|
}
|
|
if len(in.Refused) != 1 || in.Refused[0].Input != "manifest of mod-z" || !gapNamed(in.Gaps, "manifest of mod-z") {
|
|
t.Errorf("refused = %v: only mod-z's, and named in the gaps", in.Refused)
|
|
}
|
|
recs, _ := grants.Records(in)
|
|
users, err := grants.ComposeUsers(recs)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !slices.Contains(names(users), "b.mod-p") {
|
|
t.Errorf("b.mod-p is not composed, so its module counts as placed nowhere: %v", names(users))
|
|
}
|
|
// Issue 1126: a key a module's served value fills from its settings (mailu's smtp domain) is granted to every other
|
|
// node's agent, that key alone, so a node composing a consumer elsewhere settles what it serves.
|
|
bs := fixture()
|
|
bs.manifestOf("mod-s", `{"module":"mod-s","version":"1","own-secrets":{"broker":"/var/lib/s/broker"},`+
|
|
`"config":{"domain":{"type":"string","description":"The mail domain.","required":true}},`+
|
|
`"provides":[{"name":"smtp","scope":"mesh"}],"serves":{"smtp":{"port":587,"domain":"${config:domain}"}}}`)
|
|
bs.assign("b", "mod-s")
|
|
served := "$JS.API.DIRECT.GET.KV_mesh-config_resolved.$KV.mesh-config_resolved.b.mod-s.domain"
|
|
for _, u := range shadowOf(t, bs) {
|
|
if (u.User == "node.a") != slices.Contains(u.Publish, served) {
|
|
t.Errorf("%s is granted b's mod-s domain: %v (node a alone)", u.User, slices.Contains(u.Publish, served))
|
|
}
|
|
}
|
|
// The provider not yet moved: it runs a build whose manifest serves no setting, and the trunk's adds one, which a
|
|
// node composing a consumer reads (frombus reads the trunk's manifest of another node's module): granted too.
|
|
bt := fixture()
|
|
const oldS = `{"module":"mod-s","version":"1","own-secrets":{"broker":"/var/lib/s/broker"},` +
|
|
`"provides":[{"name":"smtp","scope":"mesh"}],"serves":{"smtp":{"port":587}}}`
|
|
bt.manifestOf("mod-s", oldS)
|
|
oldDigest := delivery.ManifestDigest([]byte(oldS))
|
|
bt.manifestOf("mod-s", `{"module":"mod-s","version":"1","own-secrets":{"broker":"/var/lib/s/broker"},`+
|
|
`"config":{"domain":{"type":"string","description":"The mail domain.","required":true}},`+
|
|
`"provides":[{"name":"smtp","scope":"mesh"}],"serves":{"smtp":{"port":587,"domain":"${config:domain}"}}}`)
|
|
bt.assign("b", "mod-s")
|
|
runsFP := "src2:" + strings.Repeat("cd", 32)
|
|
bt.put(grants.ReadWants, "want.b.mod-s", delivery.Want{V: delivery.Version, FP: runsFP, Composed: "sha256:" + strings.Repeat("ef", 32)})
|
|
runsKey, _ := delivery.BuilderBuildKey("mod-s", runsFP)
|
|
bt.put(grants.ReadBuilds, runsKey, map[string]any{"v": delivery.Version, "state": "done", "manifest": oldDigest})
|
|
for _, u := range shadowOf(t, bt) {
|
|
if u.User == "node.a" && !slices.Contains(u.Publish, served) {
|
|
t.Errorf("node a is not granted the domain the trunk's mod-s serves, while b runs one that serves none")
|
|
}
|
|
}
|
|
// The pass, the tool listing held memberships, and the log each say the refusal.
|
|
part := partOn(b)
|
|
res := part.Pass()
|
|
if len(res.Refused) != 1 || !strings.HasPrefix(res.Refused[0], "manifest of mod-z: ") {
|
|
t.Errorf("the pass says refused %v", res.Refused)
|
|
}
|
|
held, err := part.Held()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got, _ := held.(map[string]any)["refused"].([]string); len(got) != 1 {
|
|
t.Errorf("grants_membership lists refused %v", got)
|
|
}
|
|
var logged []string
|
|
var lmu sync.Mutex
|
|
f := &grants.Follower{Part: partOn(b), Watch: &fakeWatcher{opened: make(chan string, 16)}, Logf: func(format string, args ...any) {
|
|
lmu.Lock()
|
|
logged = append(logged, fmt.Sprintf(format, args...))
|
|
lmu.Unlock()
|
|
}}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
done := make(chan struct{})
|
|
go func() { f.Run(ctx); close(done) }()
|
|
waitFor(t, func() bool {
|
|
lmu.Lock()
|
|
defer lmu.Unlock()
|
|
return strings.Contains(strings.Join(logged, "\n"), "refused manifest of mod-z: ")
|
|
})
|
|
cancel()
|
|
<-done
|
|
})
|
|
|
|
t.Run("credentials", func(t *testing.T) {
|
|
shadow := []grants.User{{User: "a.held"}, {User: "a.new"}, {User: "a.bare"}}
|
|
loaded := []grants.User{{User: "a.held"}, {User: "a.bare"}, {User: "enrol.x"}, {User: "person.op"}}
|
|
hashes := map[string]grants.HeldHash{"a.held": {Hash: "$2a$10$h1", Generation: 2}, "a.new": {Hash: "$2a$10$h2", Generation: 1},
|
|
"person.op": {Hash: "$2a$10$h3", Adopted: true}}
|
|
c := grants.CredentialsOf(shadow, loaded, hashes)
|
|
for _, row := range []struct {
|
|
user, would string
|
|
held, adopted bool
|
|
}{
|
|
{"a.bare", grants.WouldLeaveOut, false, false},
|
|
{"a.held", grants.WouldSame, true, false},
|
|
{"a.new", grants.WouldAdd, true, false},
|
|
{"enrol.x", grants.WouldLoseLogin, false, false},
|
|
{"person.op", grants.WouldNotComposed, true, true},
|
|
} {
|
|
i := slices.IndexFunc(c.Users, func(u grants.Credential) bool { return u.User == row.user })
|
|
if i < 0 || c.Users[i].Would != row.would || c.Users[i].HashHeld != row.held || c.Users[i].Adopted != row.adopted {
|
|
t.Errorf("%s: %+v, want %s held %v", row.user, c.Users, row.would, row.held)
|
|
}
|
|
}
|
|
if c.HashesHeld != 3 || !slices.Equal(c.LeftOut, []string{"a.bare"}) || !slices.Equal(c.LosesLogin, []string{"enrol.x"}) ||
|
|
!slices.Equal(c.Adds, []string{"a.new"}) {
|
|
t.Errorf("credentials = %+v", c)
|
|
}
|
|
// Composed from the vault's state: a hash changes the list's state (written on the event), and no hash is ever
|
|
// in what the part writes or says.
|
|
b := fixture()
|
|
b.loaded = shadowOf(t, b)
|
|
p := partOn(b)
|
|
before := p.Compose()
|
|
b.put(grants.ReadAccounts, "a.mod-x", map[string]any{"user": "a.mod-x", "hash": "$2a$10$secrethashbytes", "generation": 1})
|
|
after := p.Compose()
|
|
cr := after.UserList.Credentials
|
|
if !after.Written || after.Revision != before.Revision+1 || cr.HashesHeld != 1 || slices.Contains(cr.LeftOut, "a.mod-x") ||
|
|
!slices.Contains(cr.LeftOut, "b.mod-y") {
|
|
t.Errorf("a hash heard = %+v", after.UserList.Credentials)
|
|
}
|
|
why := strings.Join(p.Decide(), "; ")
|
|
if !strings.Contains(why, "no hash for") || strings.Contains(why, "a.mod-x,") {
|
|
t.Errorf("take-over is refused for %q, want the users with no hash named", why)
|
|
}
|
|
for state, keys := range b.own {
|
|
for k, v := range keys {
|
|
if strings.Contains(string(v), "secrethashbytes") {
|
|
t.Errorf("the hash is written in %s %s", state, k)
|
|
}
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("compare", func(t *testing.T) {
|
|
b := fixture()
|
|
shadow := shadowOf(t, b)
|
|
in := grants.ReadInputs(b, &grants.Manifests{})
|
|
changed := slices.Clone(shadow)
|
|
x := slices.IndexFunc(changed, func(u grants.User) bool { return u.User == "a.mod-x" })
|
|
changed[x].Publish = append(slices.Clone(changed[x].Publish[1:]), "extra.subject")
|
|
// Only added rights (ADR 0562): the loaded list lacks a publish of a.mod-x the shadow grants.
|
|
fewer := slices.Clone(shadow)
|
|
fewer[x].Publish = slices.Clone(fewer[x].Publish[1:])
|
|
// A deny the shadow adds where the loaded list says its own (empty): a publish right lost. And the same deny where
|
|
// the loaded list says none, as nats_users answers today, on a user whose grants differ: it cannot be compared.
|
|
denied := slices.Clone(shadow)
|
|
denied[x].PublishDeny = []string{"mesh.seat.node-launcher.tool.secret"}
|
|
readable := slices.Clone(fewer)
|
|
for i := range readable {
|
|
readable[i].PublishDeny = []string{}
|
|
}
|
|
// The live comparison the operator's yes was refused on, 2026-10-27 21:19 UTC (issue 1945): 41 users differ, every
|
|
// difference an added right. Read whole; a row's mutate makes one of the cases that must still refuse.
|
|
var live struct {
|
|
Compare grants.Comparison `json:"compare"`
|
|
Credentials grants.Credentials `json:"credentials"`
|
|
}
|
|
if raw, err := os.ReadFile(filepath.Join("testdata", "takeover-compare-2026-10-27.json")); err != nil || json.Unmarshal(raw, &live) != nil {
|
|
t.Fatalf("the captured comparison cannot be read: %v", err)
|
|
}
|
|
if len(live.Compare.Differ) != 41 || len(live.Credentials.LosesLogin)+len(live.Credentials.LeftOut) != 0 {
|
|
t.Fatalf("the captured comparison is not the one of 21:19: %d differ", len(live.Compare.Differ))
|
|
}
|
|
captured := func(mutate func(*grants.Comparison)) *grants.Comparison {
|
|
raw, _ := json.Marshal(live.Compare)
|
|
var c grants.Comparison
|
|
_ = json.Unmarshal(raw, &c)
|
|
if mutate != nil {
|
|
mutate(&c)
|
|
}
|
|
return &c
|
|
}
|
|
nodeAce := func(c *grants.Comparison) *grants.UserDiff {
|
|
return &c.Differ[slices.IndexFunc(c.Differ, func(d grants.UserDiff) bool { return d.User == "node.ace" })]
|
|
}
|
|
for _, row := range []struct {
|
|
name string
|
|
loaded []grants.User
|
|
added, dropped, gone []string
|
|
differ []string
|
|
equal bool
|
|
// live is a captured comparison used in place of Compare; removing the users take-over is refused for
|
|
// (ADR 0562 §2), and adds the users whose added rights the ask names.
|
|
live *grants.Comparison
|
|
removing []string
|
|
adds int
|
|
}{
|
|
{"equal", shadow, nil, nil, nil, nil, true, nil, nil, 0},
|
|
{"one user the bus lacks", shadow[1:], []string{shadow[0].User}, nil, nil, nil, false, nil, []string{shadow[0].User}, 0},
|
|
{"one loaded user still placed", append(slices.Clone(shadow), grants.User{User: "a.mod-x.worker"}), nil, []string{"a.mod-x.worker"}, nil, nil, false, nil, []string{"a.mod-x.worker"}, 0},
|
|
{"one loaded user placement no longer names", append(slices.Clone(shadow), grants.User{User: "b.gone"}), nil, []string{"b.gone"}, []string{"b.gone"}, nil, true, nil, nil, 0},
|
|
{"grants differ", changed, nil, nil, nil, []string{"a.mod-x"}, false, nil, []string{"a.mod-x"}, 1},
|
|
{"only a right added", fewer, nil, nil, nil, []string{"a.mod-x"}, false, nil, nil, 1},
|
|
{"a deny added refuses", readable, nil, nil, nil, []string{"a.mod-x"}, false, nil, []string{"a.mod-x"}, 1},
|
|
{"a deny the loaded list cannot say refuses a user whose grants differ", fewer, nil, nil, nil, []string{"a.mod-x"}, false, nil, []string{"a.mod-x"}, 1},
|
|
{"captured 21:19: only added rights", nil, nil, nil, nil, nil, false, captured(nil), nil, 41},
|
|
{"captured, a right removed", nil, nil, nil, nil, nil, false, captured(func(c *grants.Comparison) {
|
|
nodeAce(c).SubscribeDropped = []string{"mesh.mod.mesh-vault.event.secret-changed"}
|
|
}), []string{"node.ace"}, 41},
|
|
{"captured, responses lost", nil, nil, nil, nil, nil, false, captured(func(c *grants.Comparison) {
|
|
nodeAce(c).Responses = "shadow false, loaded true"
|
|
}), []string{"node.ace"}, 41},
|
|
{"captured, a user dropped", nil, nil, nil, nil, nil, false, captured(func(c *grants.Comparison) {
|
|
c.Dropped = []string{"novox.mesh-vault.worker"}
|
|
}), []string{"novox.mesh-vault.worker"}, 41},
|
|
{"captured, a user added", nil, nil, nil, nil, nil, false, captured(func(c *grants.Comparison) {
|
|
c.Added = []string{"shanks.new-module"}
|
|
}), []string{"shanks.new-module"}, 41},
|
|
{"captured, a login lost", nil, nil, nil, nil, nil, false, captured(nil), []string{"novox.telegram"}, 41},
|
|
} {
|
|
t.Run(row.name, func(t *testing.T) {
|
|
var c grants.Comparison
|
|
if row.live != nil {
|
|
c = *row.live
|
|
} else {
|
|
from := shadow
|
|
if strings.Contains(row.name, "deny") {
|
|
from = denied
|
|
}
|
|
c = grants.Compare(from, row.loaded, grants.Placed(in))
|
|
var differ []string
|
|
for _, d := range c.Differ {
|
|
differ = append(differ, d.User)
|
|
}
|
|
if !slices.Equal(c.Added, nonNil(row.added)) || !slices.Equal(c.Dropped, nonNil(row.dropped)) ||
|
|
!slices.Equal(c.Gone, row.gone) || !slices.Equal(differ, row.differ) || c.Equal() != row.equal {
|
|
t.Errorf("compare = %+v", c)
|
|
}
|
|
}
|
|
var cr grants.Credentials
|
|
if row.live != nil {
|
|
cr = live.Credentials
|
|
}
|
|
if strings.Contains(row.name, "login lost") {
|
|
cr.LosesLogin = []string{"novox.telegram"}
|
|
}
|
|
// Removing, and the users with no hash in the vault, are what the yes is refused for.
|
|
var removing []string
|
|
for _, r := range c.Removing() {
|
|
removing = append(removing, strings.Fields(r)[0])
|
|
}
|
|
removing = append(removing, cr.LosesLogin...)
|
|
if !slices.Equal(removing, row.removing) || len(c.AddedRights()) != row.adds {
|
|
t.Errorf("take-over refused for %v, want %v; added rights of %d users, want %d", c.Removing(), row.removing, len(c.AddedRights()), row.adds)
|
|
}
|
|
// The ask's words pass the operator channel's content rule, read at a glance and fit one message; where only
|
|
// rights are added they name every user whose rights are added, and where a loss stands they never say none is
|
|
// taken away and name the users the yes would be refused for.
|
|
summary, points, list := grants.TakeOverWords(c, cr)
|
|
if row.removing != nil {
|
|
if strings.Contains(summary, "takes none away") {
|
|
t.Errorf("the ask says none is taken away with a loss: %q", summary)
|
|
}
|
|
for _, u := range row.removing {
|
|
if !strings.Contains(list, u) {
|
|
t.Errorf("the ask does not name %s, refused for: %s", u, list)
|
|
}
|
|
}
|
|
}
|
|
if row.live != nil && row.removing == nil && c.Equal() {
|
|
t.Error("the captured comparison is equal: the old guard would not have refused it")
|
|
}
|
|
if row.adds > 0 {
|
|
if r, ok := outward.Check(summary + "\n" + strings.Join(points, "\n") + "\n" + list); !ok {
|
|
t.Errorf("the ask's words break the content rule: %s", r)
|
|
}
|
|
}
|
|
if row.adds > 0 && row.removing == nil {
|
|
expl := summary + "\n" + strings.Join(points, "\n")
|
|
for _, text := range []string{expl, list} {
|
|
if r, ok := outward.Check(text); !ok {
|
|
t.Errorf("the ask's words break the content rule: %s", r)
|
|
}
|
|
}
|
|
if err := asks.Glance(expl); err != nil || len([]rune(list)) > grants.AddedWordsBudget {
|
|
t.Errorf("the ask's words do not fit: %v, a list of %d characters", err, len([]rune(list)))
|
|
}
|
|
for _, d := range c.AddedRights() {
|
|
if !strings.Contains(list, d.User) && !strings.Contains(list, "more users") {
|
|
t.Errorf("the ask does not name %s", d.User)
|
|
}
|
|
}
|
|
}
|
|
if row.live != nil && row.removing == nil && (!strings.Contains(summary, "adds 391 rights to 41 users") || !strings.Contains(list, "node.ace, node.novox: 36 reads, 1 call, 1 event each")) {
|
|
t.Errorf("the captured ask says %q and lists %q", summary, list)
|
|
}
|
|
if row.name == "grants differ" && (len(c.Differ[0].PublishAdded) != 1 || c.Differ[0].PublishDropped[0] != "extra.subject") {
|
|
t.Errorf("the grant difference is not named: %+v", c.Differ[0])
|
|
}
|
|
})
|
|
}
|
|
})
|
|
|
|
// 1126 (ADR 0549 §1, §3): once the catalogue places the nodes, a seat held once for the mesh is held where placement
|
|
// says, not on every machine running a module that claims it; what placement adds is composed beside the assignments.
|
|
t.Run("a mesh seat is held where placement says", func(t *testing.T) {
|
|
store := catalogue.Manifest{Module: "store", Version: "1",
|
|
DefinesSeats: []catalogue.SeatDeclaration{{Name: "store-seat", Scope: catalogue.ScopeMesh, Serves: []catalogue.Verb{{Name: "databases"}}}},
|
|
Claims: []catalogue.Claim{{Name: "store-seat", Scope: catalogue.ScopeMesh}}}
|
|
in := grants.Inputs{Nodes: []string{"a", "b"}, Assigned: map[string][]string{"a": {"store"}, "b": {"store"}},
|
|
Manifests: map[string]catalogue.Manifest{"store": store}}
|
|
holds := func(recs buscompose.Records, node string) int {
|
|
n := 0
|
|
for _, d := range recs.Assigned[node] {
|
|
n += len(d.Holds)
|
|
}
|
|
return n
|
|
}
|
|
if recs, _ := grants.Records(in); holds(recs, "a") != 1 || holds(recs, "b") != 1 {
|
|
t.Fatalf("before placement the seat is held on a %d and b %d times, want both", holds(recs, "a"), holds(recs, "b"))
|
|
}
|
|
in.Placed = map[string]delivery.Placement{
|
|
"a": {Node: "a", Modules: []delivery.PlacedModule{{Module: "store"}}, Holds: []delivery.Holding{{Claim: "store-seat", Scope: "mesh", Module: "store"}}},
|
|
"b": {Node: "b", Modules: []delivery.PlacedModule{{Module: "store"}}}}
|
|
if recs, _ := grants.Records(in); holds(recs, "a") != 1 || holds(recs, "b") != 0 {
|
|
t.Errorf("placed, the seat is held on a %d and b %d times, want a alone", holds(recs, "a"), holds(recs, "b"))
|
|
}
|
|
b := fixture()
|
|
pl, _ := delivery.EncodePlacement(delivery.Placement{Node: "b", Modules: []delivery.PlacedModule{{Module: "mod-y"}, {Module: "mod-x"}}})
|
|
b.states[grants.ReadPlacement] = map[string][]byte{"placement.b": pl}
|
|
read := grants.ReadInputs(b, &grants.Manifests{})
|
|
if !slices.Contains(read.Assigned["b"], "mod-x") || read.Placed["b"].Node != "b" || slices.ContainsFunc(read.Gaps, func(g grants.Gap) bool { return g.Input == "seat holdings" }) {
|
|
t.Errorf("read: assigned %v, placed %v, gaps %v", read.Assigned, read.Placed, read.Gaps)
|
|
}
|
|
b.refused[grants.ReadPlacement] = true
|
|
if read := grants.ReadInputs(b, &grants.Manifests{}); slices.ContainsFunc(read.Gaps, func(g grants.Gap) bool { return g.Input == grants.ReadPlacement && g.Failed }) {
|
|
t.Errorf("placement not on the bus is a failed read: %v", read.Gaps)
|
|
}
|
|
// Issue 1945, the user list of 2026-10-27 09:21: a placement's holds are the holders on record, not every holder.
|
|
// A mesh seat no placement holds keeps the module claiming it as its holder, wherever placement puts it.
|
|
in.Manifests["other"] = catalogue.Manifest{Module: "other", Version: "1", Claims: []catalogue.Claim{{Name: "other-seat", Scope: catalogue.ScopeMesh}},
|
|
DefinesSeats: []catalogue.SeatDeclaration{{Name: "other-seat", Scope: catalogue.ScopeMesh, Serves: []catalogue.Verb{{Name: "v"}}}}}
|
|
in.Assigned["b"] = append(in.Assigned["b"], "other")
|
|
in.Placed["b"] = delivery.Placement{Node: "b", Modules: []delivery.PlacedModule{{Module: "store"}, {Module: "other"}}}
|
|
if recs, _ := grants.Records(in); holds(recs, "a") != 1 || holds(recs, "b") != 1 {
|
|
t.Errorf("a seat no placement holds is held on b %d times, want once (and store-seat on a alone: a %d)", holds(recs, "b"), holds(recs, "a"))
|
|
}
|
|
in.Placed["b"] = delivery.Placement{Node: "b", Modules: []delivery.PlacedModule{{Module: "store"}, {Module: "other"}},
|
|
Holds: []delivery.Holding{{Claim: "other-seat", Scope: "mesh", Module: "someone-else"}}}
|
|
if recs, _ := grants.Records(in); holds(recs, "b") != 0 {
|
|
t.Errorf("a seat placement holds by another module is still held by other on b")
|
|
}
|
|
// The live shapes: the seed's novox placement (mesh-catalog 0c61bad, its twelve holdings, without
|
|
// anthropic-licence-manager), the licence manager's manifest (mesh-catalog c05aa5e6), and the subjects the bus
|
|
// grants novox.node-tools for its seat (nats_users, 2026-10-27 19:40Z).
|
|
file := func(name string, v any) {
|
|
raw, err := os.ReadFile(filepath.Join("testdata", name))
|
|
if err == nil {
|
|
err = json.Unmarshal(raw, v)
|
|
}
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
var seed delivery.Placement
|
|
var live struct{ Users []grants.User }
|
|
file("placement-novox-0c61bad.json", &seed)
|
|
file("node-tools-novox-licence-subjects.json", &live)
|
|
licence, _ := os.ReadFile(filepath.Join("testdata", "claude-licence-manager.json"))
|
|
n := newFakeBus()
|
|
n.manifestOf("nats", natsManifest)
|
|
n.manifestOf("claude-licence-manager", string(licence))
|
|
n.assign("novox", "nats")
|
|
n.assign("novox", "claude-licence-manager")
|
|
n.manifestOf("node-tools", `{"module":"node-tools","version":"1"}`) // the machine's tool runner, which carries the seat
|
|
n.assign("novox", "node-tools")
|
|
n.put(grants.ReadHave, "novox.node", map[string]any{"v": 1})
|
|
pl, _ = delivery.EncodePlacement(seed)
|
|
n.states[grants.ReadPlacement] = map[string][]byte{"placement.novox": pl}
|
|
recs, _ := grants.Records(grants.ReadInputs(n, &grants.Manifests{}))
|
|
if m := grants.Memberships(recs)["novox.claude-licence-manager"]; len(m.Membership.Seats) == 0 {
|
|
t.Errorf("the licence manager's membership under the seeded placement carries no seats: %+v", m.Membership)
|
|
}
|
|
var runtime grants.User
|
|
for _, u := range shadowOf(t, n) {
|
|
if u.User == "novox.node-tools" {
|
|
runtime = u
|
|
}
|
|
}
|
|
for _, s := range live.Users[0].Subscribe {
|
|
if strings.Contains(s, ".call.") && !slices.Contains(runtime.Subscribe, s) {
|
|
t.Errorf("under the seeded placement novox.node-tools is not granted %s, which the bus grants it today", s)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("compare excuses a retired verb and a multiply claimed mesh seat's added subscriptions on its machines, nothing else", func(t *testing.T) {
|
|
const retired, store, storeInfo = "mesh.seat.mesh-delivery.tool.mark-ready", "mesh.seat.mesh-store.tool.databases", "$SRV.INFO.mesh-store.>"
|
|
two := map[string][]string{"mesh-store": {"a", "n"}}
|
|
for _, row := range []struct {
|
|
name string
|
|
user string
|
|
subShadow, subBus, pubShadow []string
|
|
multi map[string][]string
|
|
differs bool
|
|
excused int
|
|
}{
|
|
{"a retired verb the shadow dropped", "a.node-tools", []string{"x"}, []string{"x", retired}, nil, nil, false, 1},
|
|
{"a verb of a live seat the shadow lost", "a.node-tools", []string{"x"}, []string{"x", store}, nil, two, true, 0},
|
|
{"a seat claimed on two machines, added to a user on one", "a.node-tools", []string{"x", store, storeInfo}, []string{"x"}, nil, two, false, 2},
|
|
{"the same added to a user on a machine that does not claim it", "q.node-tools", []string{"x", store}, []string{"x"}, nil, two, true, 0},
|
|
{"a publish of the seat added is not excused", "a.node-tools", []string{"x"}, []string{"x"}, []string{store}, two, true, 0},
|
|
{"the same added for a seat claimed once", "a.node-tools", []string{"x", store}, []string{"x"}, nil, nil, true, 0},
|
|
} {
|
|
c := grants.Compare([]grants.User{{User: row.user, Subscribe: row.subShadow, Publish: row.pubShadow}}, []grants.User{{User: row.user, Subscribe: row.subBus}}, nil).
|
|
Excusing(grants.ExcusedBy(row.multi))
|
|
if c.Equal() == row.differs || len(c.Excused) != row.excused {
|
|
t.Errorf("%s: equal %v, excused %v", row.name, c.Equal(), c.Excused)
|
|
}
|
|
}
|
|
// The guard of the user list and the memberships (issue 1945, the lead's decision in review round 1 of its fix): a
|
|
// seat the list in force holds left with no holder while a placed module still claims it, nothing else.
|
|
sm, _ := catalogue.SeatNamed(catalogue.ServiceManagerSeat)
|
|
nodeSeat := func(node string) string {
|
|
return "mesh.seat." + sm.Name + ".tool." + sm.Serves[0].Name + "." + node
|
|
}
|
|
h := grants.HoldingsOf(grants.Inputs{Assigned: map[string][]string{"novox": {"postgres", "systemd"}, "ace": {"postgres", "systemd"}},
|
|
Manifests: map[string]catalogue.Manifest{
|
|
"postgres": {Module: "postgres", Claims: []catalogue.Claim{{Name: "mesh-store", Scope: catalogue.ScopeMesh}}},
|
|
"systemd": {Module: "systemd", Claims: []catalogue.Claim{{Name: sm.Name, Scope: catalogue.ScopeNode}}}}})
|
|
u := func(user string, sub ...string) grants.User { return grants.User{User: user, Subscribe: sub} }
|
|
for _, row := range []struct {
|
|
name string
|
|
inForce, now []grants.User
|
|
lost []string
|
|
}{
|
|
{"09:21: the seat's holder lost, its claimant still placed", []grants.User{u("novox.node-tools", "x", store)}, []grants.User{u("novox.node-tools", "x")}, []string{"mesh-store"}},
|
|
{"a dropped tool", []grants.User{u("novox.node-tools", "mesh.mod.postgres.tool.gone", store)}, []grants.User{u("novox.node-tools", store)}, nil},
|
|
{"a seat moved from novox to ace", []grants.User{u("novox.node-tools", store)}, []grants.User{u("novox.node-tools"), u("ace.node-tools", store)}, nil},
|
|
{"a seat no placed module claims any more", []grants.User{u("novox.node-tools", "mesh.seat.unclaimed.tool.v")}, []grants.User{u("novox.node-tools")}, nil},
|
|
{"a retired verb dropped", []grants.User{u("novox.node-tools", retired)}, []grants.User{u("novox.node-tools")}, nil},
|
|
{"a node seat lost on novox, held on ace", []grants.User{u("novox.node-tools", nodeSeat("novox")), u("ace.node-tools", nodeSeat("ace"))},
|
|
[]grants.User{u("novox.node-tools"), u("ace.node-tools", nodeSeat("ace"))}, []string{sm.Name + "@novox"}},
|
|
} {
|
|
if lost := h.LastHolders(h.ByUsers(row.inForce), h.ByUsers(row.now)); !slices.Equal(lost, row.lost) {
|
|
t.Errorf("%s: lost %v, want %v", row.name, lost, row.lost)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("tools", func(t *testing.T) {
|
|
b := fixture()
|
|
b.loaded = shadowOf(t, b)
|
|
b.streams = []map[string]any{{"name": "KV_mod-x_kept", "subjects": []string{"$KV.mod-x_kept.>"}, "retention": "limits", "max_per_subject": 3}}
|
|
p := partOn(b)
|
|
// The bus node says its key in its have of the bus module; the process puts the sealed list (ADR 0547 clause 7).
|
|
busKey, err := seal.GenerateKeyPair()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
withKey := func(w delivery.UserListWritten) {
|
|
w.Key = busKey.PublicKey
|
|
b.put(grants.ReadHave, "a.nats", delivery.Have{V: delivery.Version, Phase: delivery.PhaseRunning, Health: delivery.HealthOK, UserList: &w})
|
|
}
|
|
loadedBefore := delivery.UserListWritten{Source: "a.nats.src2", Revision: 7, Wanted: "sha256:aa", Digest: "sha256:aa", Users: 6}
|
|
withKey(loadedBefore)
|
|
objs := &fakeObjects{}
|
|
p.Objects = objs
|
|
open := func(g delivery.GrantsUserList) string {
|
|
t.Helper()
|
|
list, err := delivery.OpenUserList(objs.objects[g.Object], g.Digest, func(bx seal.Box) ([]byte, error) {
|
|
out, err := seal.Open(bx, busKey.PrivateKey)
|
|
return []byte(out), err
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("the sealed list does not open with the bus node's key: %v", err)
|
|
}
|
|
return string(list)
|
|
}
|
|
|
|
got, _ := p.UserList()
|
|
if !strings.Contains(fmt.Sprint(got), "nothing composed yet") {
|
|
t.Errorf("user-list before any composition = %v", got)
|
|
}
|
|
first := p.Compose()
|
|
if !first.Written || first.Revision != 1 || !first.UserList.Compare.Equal() || first.UserList.Users != 6 {
|
|
t.Fatalf("first composition = %+v", first)
|
|
}
|
|
// Written in shadow, the bus node told to follow the controller's list; every user left out while the vault holds
|
|
// no hash; a tool's part (no object bucket) writes none.
|
|
if g, _, had := p.Sealed(); !had || g.Follow != delivery.FollowController || g.Revision != 1 || g.Node != "a" ||
|
|
g.Users != 0 || len(g.LeftOut) != 6 || len(objs.objects) != 1 || !strings.Contains(open(g), "accounts {") {
|
|
t.Errorf("the sealed list in shadow = %+v, %d objects", g, len(objs.objects))
|
|
}
|
|
if _, _, had := partOn(fixture()).Sealed(); had {
|
|
t.Error("a part read before any pass holds a sealed list")
|
|
}
|
|
if tp := partOn(fixture()); tp.Compose().Sealed == nil {
|
|
t.Error("a tool's pass does not say the sealed list")
|
|
} else if _, _, had := tp.Sealed(); had {
|
|
t.Error("a tool's part wrote the sealed list")
|
|
}
|
|
if len(first.Published) != 3 || len(b.publishes) != 3 {
|
|
t.Errorf("the first composition published %v, want the three assignments' memberships", first.Published)
|
|
}
|
|
// The second pass says each membership unchanged (published at the first); the third writes nothing at all.
|
|
if again := p.Compose(); again.Written || again.Revision != 1 || len(again.Published) != 0 || again.AssertionsWritten != 0 {
|
|
t.Errorf("a composition with no input changed wrote or published: %+v", again)
|
|
}
|
|
if third := p.Compose(); third.Written || third.MembershipsWritten != 0 || len(third.Published) != 0 || len(b.publishes) != 3 {
|
|
t.Errorf("a membership equal to the one last published was published again, or a state written: %+v", third)
|
|
}
|
|
// mod-x placed on b too: b.mod-x is added and its first membership published; a.mod-x's loses the module's plain
|
|
// subject (mod-x now runs on two machines), which leaves no seat without its holder, so it is published too (issue
|
|
// 1945, the lead's decision in review round 1 of its fix).
|
|
b.assign("b", "mod-x")
|
|
moved := p.Compose()
|
|
if !moved.Written || moved.Revision != 2 || !slices.Equal(moved.Published, []string{"a.mod-x", "b.mod-x"}) || len(moved.NotPublished) != 0 || moved.UserList.Compare.Equal() ||
|
|
!slices.Equal(moved.UserList.Compare.Added, []string{"b.mod-x"}) {
|
|
t.Errorf("a composition after placement moved = %+v", moved)
|
|
}
|
|
got, _ = p.UserList()
|
|
ul, _ := got.(map[string]any)
|
|
if st, ok := ul["written"].(grants.UserListState); !ok || st.Loaded == nil || st.Loaded.Digest != "sha256:aa" || st.BusNode != "a" ||
|
|
ul["revision"] != uint64(2) || !gapNamed(st.Gaps, "people") {
|
|
t.Errorf("user-list = %+v", got)
|
|
}
|
|
|
|
a, err := p.AssertOne("mod-x")
|
|
am, _ := a.(map[string]any)
|
|
as, _ := am["assertion"].(grants.Assertion)
|
|
if err != nil || as.Asserted || as.Says != grants.ShadowAsserts || len(as.Items) != 3 || am["asked"] == nil {
|
|
t.Fatalf("assert = %+v, %v", a, err)
|
|
}
|
|
for _, it := range as.Items {
|
|
if it.Stream == "KV_mod-x_kept" && len(it.Differences) != 0 {
|
|
t.Errorf("a bucket the bus holds as composed differs: %v", it.Differences)
|
|
}
|
|
if it.Stream == "THINGS" && !slices.Contains(it.Differences, "the bus holds no stream THINGS") {
|
|
t.Errorf("a stream the bus lacks is not named: %+v", it)
|
|
}
|
|
}
|
|
|
|
ms, _ := p.MembershipOf("a", "mod-x")
|
|
if mo, _ := ms.(map[string]any); !strings.Contains(fmt.Sprint(mo["published"]), "last_pass") || mo["composed"] == nil {
|
|
t.Errorf("membership = %v", ms)
|
|
}
|
|
gr, _ := p.Grants("mod-x")
|
|
if g, _ := gr.(map[string]any); g["writes"] != grants.WritesNoGrant || g["count"] != 1 {
|
|
t.Errorf("grants = %v", gr)
|
|
}
|
|
|
|
b.loaded = append(b.loaded, grants.User{User: "b.gone"})
|
|
fa := &fakeAsker{wheres: map[string]*asks.Warrant{}}
|
|
var forgot []string
|
|
n := 0
|
|
p.Nonce, p.Forget = func() string { n++; return fmt.Sprintf("%04d", n) }, func(id string) error { forgot = append(forgot, id); return nil }
|
|
var said []asks.Condition
|
|
p.Asker, p.Say = fa, func(c asks.Condition) error { said = append(said, c); return nil }
|
|
warrant := func(id string) asks.Warrant { return asks.Warrant{Ask: id, Outcome: asks.OutcomeChosen} }
|
|
last := func() grants.Act {
|
|
raw, _, _, _ := b.ownGet(grants.StateTakeover, grants.KeySwitch)
|
|
var s grants.Switch
|
|
_ = json.Unmarshal(raw, &s)
|
|
if s.Open != "" && s.Acts[len(s.Acts)-1].Said != "asked" {
|
|
t.Errorf("an ask is open and its act is not asked: %+v", s)
|
|
}
|
|
return s.Acts[len(s.Acts)-1]
|
|
}
|
|
asked := func(act string) string {
|
|
t.Helper()
|
|
var res struct{ Asked string }
|
|
var err error
|
|
var out any
|
|
if act == "take-over" {
|
|
out, err = p.TakeOver("shanks.node-tools", "a row")
|
|
} else {
|
|
out, err = p.HandBack("shanks.node-tools", "a row")
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("%s: %v", act, err)
|
|
}
|
|
raw, _ := json.Marshal(out)
|
|
_ = json.Unmarshal(raw, &res)
|
|
return res.Asked
|
|
}
|
|
yes, no := asks.Option{ID: "yes"}, asks.Option{ID: "no"}
|
|
// settled is the placement of the lists agreeing, every hash held, and the loaded list as edit leaves it.
|
|
settled := func(edit func(modX *grants.User)) func() {
|
|
return func() {
|
|
b.states[grants.ReadAssignments] = nil
|
|
b.assign("a", "nats")
|
|
b.assign("a", "mod-x")
|
|
b.assign("b", "mod-y")
|
|
b.loaded = append(shadowOf(t, b), grants.User{User: "b.gone"})
|
|
x := slices.IndexFunc(b.loaded, func(u grants.User) bool { return u.User == "a.mod-x" })
|
|
b.loaded[x].Publish = slices.Clone(b.loaded[x].Publish)
|
|
edit(&b.loaded[x])
|
|
for _, u := range shadowOf(t, b) {
|
|
b.put(grants.ReadAccounts, u.User, map[string]any{"user": u.User, "hash": "$2a$10$x", "generation": 1})
|
|
}
|
|
}
|
|
}
|
|
lacks := func(modX *grants.User) { modX.Publish = modX.Publish[1:] } // the shadow adds a right (ADR 0562)
|
|
for _, row := range []struct {
|
|
name, act string
|
|
option asks.Option
|
|
setup func()
|
|
said, never []string // the refusal's words and the users it must not name
|
|
state string
|
|
outcome string
|
|
// between changes the lists after the ask and before the yes; asked are words the ask must carry.
|
|
between func()
|
|
asked []string
|
|
}{
|
|
{"a yes while a user differs is refused and said, naming it", "take-over", yes, func() {}, []string{"users differ", "b.mod-x"}, []string{"b.gone"}, grants.Shadow, "refused", nil, nil},
|
|
{"a no leaves the switch", "take-over", no, func() {}, nil, nil, grants.Shadow, "declined", nil, nil},
|
|
{"a yes while a loaded user would lose a right is refused, naming it", "take-over", yes, settled(func(modX *grants.User) {
|
|
modX.Publish = append(modX.Publish, "extra.subject")
|
|
}), []string{"other than by an added right", "a.mod-x (would lose 1 publish)"}, []string{"b.gone"}, grants.Shadow, "refused", nil, nil},
|
|
{"a yes after the added rights changed since the ask is refused", "take-over", yes, settled(lacks), []string{"changed since you were asked"}, nil, grants.Shadow, "refused",
|
|
func() {
|
|
lacks(&b.loaded[slices.IndexFunc(b.loaded, func(u grants.User) bool { return u.User == "a.mod-x" })])
|
|
}, []string{"a.mod-x: 1 read"}},
|
|
{"a yes with only added rights and every hash held records the switch, the ask naming them", "take-over", yes, settled(lacks), nil, nil, grants.TookOver, "recorded",
|
|
nil, []string{"adds 1 right to 1 user and takes none away", "a.mod-x: 1 read"}},
|
|
{"a yes to hand back records the switch back", "hand-back", yes, func() {}, nil, nil, grants.Shadow, "recorded", nil, nil},
|
|
} {
|
|
t.Run(row.name, func(t *testing.T) {
|
|
row.setup()
|
|
before := len(fa.asks)
|
|
id := asked(row.act)
|
|
if _, err := p.TakeOver("shanks.node-tools", "again"); err == nil || !strings.Contains(err.Error(), "already asked") {
|
|
t.Errorf("a second ask while one is open = %v", err)
|
|
}
|
|
p.Compose()
|
|
p.Compose()
|
|
if len(fa.asks) != before+1 || fa.asks[before].ID != id {
|
|
t.Fatalf("the operator was asked %d times, want once: %+v", len(fa.asks)-before, fa.asks)
|
|
}
|
|
ask := fa.asks[before]
|
|
if err := ask.Check(time.Date(2026, 10, 25, 22, 0, 0, 0, time.UTC)); err != nil {
|
|
t.Errorf("the router would refuse the ask: %v", err)
|
|
}
|
|
if yesOpt, ok := ask.Option("yes"); !ok || yesOpt.Level != asks.Approve || !strings.HasPrefix(yesOpt.Binds, "sha256:") {
|
|
t.Errorf("the yes does not approve and bind its act: %+v", yesOpt)
|
|
}
|
|
for _, text := range []string{ask.Explanation, ask.Details} {
|
|
if r, ok := outward.Check(text); !ok {
|
|
t.Errorf("the ask breaks the content rule: %s", r)
|
|
}
|
|
}
|
|
if err := asks.Glance(ask.Explanation); err != nil {
|
|
t.Error(err)
|
|
}
|
|
for _, w := range row.asked {
|
|
if !strings.Contains(ask.Explanation+"\n"+ask.Details, w) {
|
|
t.Errorf("the ask does not say %q: %s\n%s", w, ask.Explanation, ask.Details)
|
|
}
|
|
}
|
|
if row.between != nil {
|
|
row.between()
|
|
}
|
|
said = nil
|
|
if err := p.Warranted(row.option, warrant(id)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
l := last()
|
|
if l.Said != row.outcome || l.Ask != id || p.SwitchState() != row.state {
|
|
t.Errorf("the act = %+v, the switch %s; want %s and %s", l, p.SwitchState(), row.outcome, row.state)
|
|
}
|
|
for _, w := range row.said {
|
|
if !strings.Contains(l.Refused, w) || (row.outcome == "refused" && (len(said) != 1 || !strings.Contains(said[0].Explanation, w))) {
|
|
t.Errorf("the refusal %q (said %v) does not say %q", l.Refused, said, w)
|
|
}
|
|
}
|
|
for _, w := range row.never {
|
|
if strings.Contains(l.Refused, w) {
|
|
t.Errorf("the refusal names %q", w)
|
|
}
|
|
}
|
|
// A recorded switch says no refusal and clears the one a refused yes left raised.
|
|
if row.outcome == "recorded" && row.act == "take-over" && (len(said) != 1 || said[0].State != asks.StateCleared || said[0].Key != "mesh-grants.take-over-refused") {
|
|
t.Errorf("a recorded switch did not clear the refusal: %v", said)
|
|
}
|
|
if row.outcome == "recorded" && !strings.Contains(fmt.Sprint(p.TakeOverState()), "bus_node_loaded_grants_list:not yet") {
|
|
t.Errorf("the state claims the bus node follows with no loaded list: %v", fmt.Sprint(p.TakeOverState()))
|
|
}
|
|
// The key the bus node follows turns with the switch at once, and the state says switched only once the bus
|
|
// node loaded the grants part's written list.
|
|
g, _, _ := p.Sealed()
|
|
if want := map[string]string{grants.TookOver: delivery.FollowGrants, grants.Shadow: delivery.FollowController}[p.SwitchState()]; g.Follow != want {
|
|
t.Errorf("the key says follow %s with the switch %s", g.Follow, p.SwitchState())
|
|
}
|
|
effect := func() string { st, _ := p.TakeOverState(); return fmt.Sprint(st.(map[string]any)["effect"]) }
|
|
if row.outcome == "recorded" && row.act == "take-over" {
|
|
if e := effect(); strings.HasPrefix(e, "switched") || !strings.Contains(e, "not in effect") {
|
|
t.Errorf("effect before the bus node loaded = %s", e)
|
|
}
|
|
if users := strings.Count(open(g), "{ user: "); users != g.Users || g.Users == 0 || len(g.LeftOut) != 0 {
|
|
t.Errorf("the sealed list holds %d users, the key says %d, left out %v", users, g.Users, g.LeftOut)
|
|
}
|
|
withKey(delivery.UserListWritten{Source: delivery.GrantsUserListSource, Revision: g.Revision, Wanted: g.Digest, Digest: g.Digest, Users: g.Users, Follows: delivery.FollowGrants})
|
|
if e := effect(); !strings.HasPrefix(e, "switched") {
|
|
t.Errorf("effect once the bus node loaded the written list = %s", e)
|
|
}
|
|
// Steady (issue 1126, 2026-10-28 00:16: the shadow list moved every 3 s, 99270 to 99357, with nothing
|
|
// differing): the bus node saying again that it loaded the same list, at a later time and the key's
|
|
// later revision, moves neither the shadow list nor the sealed key.
|
|
_, sealedRev, _ := p.Sealed()
|
|
p.Compose()
|
|
for i := 0; i < 3; i++ {
|
|
withKey(delivery.UserListWritten{Source: delivery.GrantsUserListSource, Revision: sealedRev + uint64(i+1), Wanted: g.Digest,
|
|
Digest: g.Digest, Users: g.Users, Follows: delivery.FollowGrants, At: time.Date(2026, 10, 28, 0, 16, i, 0, time.UTC)})
|
|
if res := p.Compose(); res.Written {
|
|
t.Errorf("pass %d: the shadow list is written again for the bus node saying the same list again (revision %d)", i, res.Revision)
|
|
}
|
|
if _, rev, _ := p.Sealed(); rev != sealedRev {
|
|
t.Errorf("pass %d: the sealed key is written again with nothing changed: revision %d, was %d", i, rev, sealedRev)
|
|
}
|
|
}
|
|
withKey(loadedBefore)
|
|
// After take-over a list composed past a failed read, or one dropping a user of the list in force for
|
|
// want of a hash, is not sealed: the list in force stays, and the pass says why.
|
|
b.refused[grants.ReadAccounts] = true
|
|
if res := p.Compose(); res.Sealed == nil || !strings.Contains(res.Sealed.Why, "a read of this pass failed") {
|
|
t.Errorf("a pass past a failed read = %+v", res.Sealed)
|
|
}
|
|
// While the hold lasts the warning stands, said once however many passes find it; it names why.
|
|
p.Compose()
|
|
nHeld := 0
|
|
for _, c := range said {
|
|
if c.Key == grants.HeldCondition && c.State == asks.StateRaised && c.Severity == asks.SeverityWarning && strings.Contains(c.Summary, "a read of this pass failed") {
|
|
nHeld++
|
|
}
|
|
}
|
|
if nHeld != 1 {
|
|
t.Errorf("the held list raised the warning %d times, want once: %+v", nHeld, said)
|
|
}
|
|
b.refused[grants.ReadAccounts] = false
|
|
if now, _, _ := p.Sealed(); now.Digest != g.Digest || now.Object != g.Object {
|
|
t.Errorf("the list in force moved past a failed read: %s, was %s", now.Digest, g.Digest)
|
|
}
|
|
b.mu.Lock()
|
|
hash := b.states[grants.ReadAccounts]["a.mod-x"]
|
|
delete(b.states[grants.ReadAccounts], "a.mod-x")
|
|
b.mu.Unlock()
|
|
if res := p.Compose(); res.Sealed == nil || !strings.Contains(res.Sealed.Why, "a.mod-x would be left out") {
|
|
t.Errorf("a pass that drops a.mod-x's hash = %+v", res.Sealed)
|
|
}
|
|
if now, _, _ := p.Sealed(); now.Digest != g.Digest {
|
|
t.Errorf("the list in force moved without a.mod-x: %s", now.Digest)
|
|
}
|
|
// A list in force written before the part kept its users (no sealed-users): checked against the users the
|
|
// bus loaded, so the first list after take-over has the same protection.
|
|
b.mu.Lock()
|
|
delete(b.own[grants.StateUserList], grants.KeyWrittenUsers)
|
|
b.mu.Unlock()
|
|
if res := p.Compose(); res.Sealed == nil || !strings.Contains(res.Sealed.Why, "a.mod-x would be left out") {
|
|
t.Errorf("a pass with no kept users that drops a.mod-x's hash = %+v", res.Sealed)
|
|
}
|
|
if now, _, _ := p.Sealed(); now.Digest != g.Digest {
|
|
t.Errorf("the list in force moved without a.mod-x and no kept users: %s", now.Digest)
|
|
}
|
|
b.put(grants.ReadAccounts, "a.mod-x", json.RawMessage(hash))
|
|
if p.Compose(); func() bool { now, _, _ := p.Sealed(); return now.Digest != g.Digest }() {
|
|
t.Errorf("the list in force moved with every read and hash back")
|
|
}
|
|
if last := said[len(said)-1]; last.Key != grants.HeldCondition || last.State != asks.StateCleared {
|
|
t.Errorf("the held warning is not cleared once nothing is held: %+v", last)
|
|
}
|
|
// A part started while the warning stood (the said state is in memory only) clears it on its first pass.
|
|
var fresh []asks.Condition
|
|
fp := partOn(b)
|
|
fp.Objects = p.Objects
|
|
fp.Asker, fp.Say = fa, func(c asks.Condition) error { fresh = append(fresh, c); return nil }
|
|
fp.Compose()
|
|
if len(fresh) == 0 || fresh[0].Key != grants.HeldCondition || fresh[0].State != asks.StateCleared {
|
|
t.Errorf("a fresh part's first pass does not say the held warning is cleared: %+v", fresh)
|
|
}
|
|
// Issue 1945, 2026-10-27 09:21: a list that would leave a seat the list in force holds with no holder,
|
|
// while a placed module still claims it, is held, as such a membership is: not written, the warning
|
|
// raised naming the seat. A list that only adds is written; one whose loaded grants cannot be read is held.
|
|
inForce := slices.Clone(b.loaded)
|
|
seat, verb := brokerSeat()
|
|
seatGrant := "mesh.seat." + seat + ".call." + verb + ".*"
|
|
for i := range b.loaded {
|
|
if b.loaded[i].User == "a.nats" {
|
|
b.loaded[i].Subscribe = append(slices.Clone(b.loaded[i].Subscribe), seatGrant)
|
|
}
|
|
}
|
|
b.put(grants.ReadAssignments, "a.mod-y", map[string]any{"node": "a", "module": "mod-y", "assigned": "2026-10-27T08:20:00Z"})
|
|
b.put(grants.ReadAccounts, "a.mod-y", map[string]any{"user": "a.mod-y", "hash": "$2a$10$x", "generation": 1})
|
|
said = nil
|
|
res := p.Compose()
|
|
if res.Sealed == nil || !strings.Contains(res.Sealed.Why, "publishing would lose the last holder of "+seat) {
|
|
t.Errorf("a list losing a seat grant = %+v", res.Sealed)
|
|
}
|
|
if now, _, _ := p.Sealed(); now.Digest != g.Digest {
|
|
t.Errorf("a list losing a seat grant was written: %s, was %s", now.Digest, g.Digest)
|
|
}
|
|
if len(said) != 1 || said[0].Key != grants.HeldCondition || said[0].State != asks.StateRaised || !strings.Contains(said[0].Summary, "the last holder of "+seat) {
|
|
t.Errorf("the held list did not raise the warning naming the lost grant: %+v", said)
|
|
}
|
|
b.loaded = inForce
|
|
if p.Compose(); func() bool { now, _, _ := p.Sealed(); return now.Digest == g.Digest }() {
|
|
t.Errorf("a list that only adds a.mod-y was not written")
|
|
}
|
|
written, _, _ := p.Sealed()
|
|
b.mu.Lock()
|
|
b.refuseUsers = true
|
|
b.mu.Unlock()
|
|
b.mu.Lock()
|
|
delete(b.states[grants.ReadAssignments], "a.mod-y")
|
|
b.mu.Unlock()
|
|
if res := p.Compose(); res.Sealed == nil || !strings.Contains(res.Sealed.Why, "the grants of the list in force cannot be read") {
|
|
t.Errorf("a pass whose loaded list cannot be read = %+v", res.Sealed)
|
|
}
|
|
if now, _, _ := p.Sealed(); now.Digest != written.Digest {
|
|
t.Errorf("a list was written while the loaded list could not be read")
|
|
}
|
|
b.mu.Lock()
|
|
b.refuseUsers = false
|
|
b.mu.Unlock()
|
|
}
|
|
})
|
|
}
|
|
t.Run("hand-back in shadow is refused at once, with nothing asked", func(t *testing.T) {
|
|
n := len(fa.asks)
|
|
if _, err := p.HandBack("shanks.node-tools", "a row"); err == nil || !strings.Contains(err.Error(), "nothing to hand back") {
|
|
t.Errorf("hand-back in shadow = %v", err)
|
|
}
|
|
p.Compose()
|
|
if len(fa.asks) != n {
|
|
t.Errorf("a refused hand-back asked the operator")
|
|
}
|
|
})
|
|
t.Run("an ask that can stay open is closed every way and can be asked again", func(t *testing.T) {
|
|
// A warrant the process missed is caught up from the router's record, whatever its outcome.
|
|
id := asked("take-over")
|
|
p.Compose()
|
|
fa.wheres[id] = &asks.Warrant{Ask: id, Outcome: asks.OutcomeChosen, Option: "no"}
|
|
p.Compose()
|
|
if l := last(); l.Ask != id || l.Said != "declined" {
|
|
t.Errorf("a missed warrant was not caught up: %+v", l)
|
|
}
|
|
// The router ended it with no choice, and the process was not told.
|
|
id = asked("take-over")
|
|
p.Compose()
|
|
fa.wheres[id] = &asks.Warrant{Ask: id, Outcome: asks.OutcomeCancelled}
|
|
p.Compose()
|
|
if l := last(); l.Ask != id || !strings.Contains(l.Said, "cancelled") {
|
|
t.Errorf("an ask the router ended was not closed: %+v", l)
|
|
}
|
|
// Said closes on any outcome for the open id, and none for another id.
|
|
id = asked("take-over")
|
|
p.Said(asker.Refused, asks.Warrant{Ask: "other", Outcome: asks.OutcomeRefused}, nil)
|
|
if _, err := p.TakeOver("x", "y"); err == nil {
|
|
t.Errorf("a warrant for another ask closed the open one")
|
|
}
|
|
p.Said(asker.Refused, asks.Warrant{Ask: id, Outcome: asks.OutcomeRefused}, nil)
|
|
// A warrant for an ask that is not the open one switches nothing.
|
|
if err := p.Warranted(yes, warrant("take-over-other")); err == nil {
|
|
t.Errorf("a warrant for an ask that is not open was acted on")
|
|
}
|
|
// Past its expiry and a margin the part closes it itself; the clock moves, not a timer.
|
|
id = asked("take-over")
|
|
now := p.Now
|
|
p.Now = func() time.Time { return now().Add(25 * time.Hour) }
|
|
p.Compose()
|
|
p.Now = now
|
|
if l := last(); l.Ask != id || l.Said != string(asks.OutcomeExpired) {
|
|
t.Errorf("an expired ask was not closed: %+v", l)
|
|
}
|
|
// An ask that could not be published is closed as refused, its half-made record taken away, and is not retried.
|
|
fa.fail = true
|
|
id = asked("take-over")
|
|
p.Compose()
|
|
p.Compose()
|
|
if l := last(); l.Ask != id || l.Said != "refused" || !slices.Contains(forgot, id) || !slices.Contains(fa.cancels, id) {
|
|
t.Errorf("a failed ask stays open, or is not taken back at the router: %+v, forgot %v, cancelled %v", l, forgot, fa.cancels)
|
|
}
|
|
fa.fail = false
|
|
// An answer the part's own record cannot take yet (its state out of reach) leaves the ask open, closed by
|
|
// nothing, and is taken at a later pass.
|
|
id = asked("take-over")
|
|
p.Compose()
|
|
fa.wheres[id] = &asks.Warrant{Ask: id, Outcome: asks.OutcomeChosen, Option: "no"}
|
|
fa.takeErr = errors.New("nats: no responders available for request")
|
|
p.Compose()
|
|
if l := last(); l.Ask != id || l.Said != "asked" {
|
|
t.Errorf("an answer not taken closed the ask: %+v", l)
|
|
}
|
|
fa.takeErr = nil
|
|
p.Compose()
|
|
if l := last(); l.Ask != id || l.Said != "declined" {
|
|
t.Errorf("the answer was not taken once its record could be reached: %+v", l)
|
|
}
|
|
asked("take-over")
|
|
})
|
|
|
|
t.Run("only memberships published, nothing asserted", func(t *testing.T) {
|
|
for _, s := range b.publishes {
|
|
if !strings.HasPrefix(s, "mesh.assignment.") {
|
|
t.Errorf("published on %s", s)
|
|
}
|
|
}
|
|
for _, w := range b.writes {
|
|
if !slices.Contains(grants.States, w) {
|
|
t.Errorf("wrote %s, which is not the part's own state", w)
|
|
}
|
|
}
|
|
for _, c := range b.calls {
|
|
if c != grants.CallUsers && c != grants.CallStreams {
|
|
t.Errorf("called %s: the part calls only the bus module's read tools", c)
|
|
}
|
|
}
|
|
for _, e := range b.events {
|
|
if !slices.Contains(grants.Emits, e) {
|
|
t.Errorf("said %s", e)
|
|
}
|
|
}
|
|
})
|
|
})
|
|
|
|
t.Run("memberships: published unless a seat would lose its last holder or a holder a capability it asks for", func(t *testing.T) {
|
|
const key = "a.mod-x"
|
|
brokerName, brokerVerb := brokerSeat()
|
|
// The telegram module's claim of its channel, as mesh-catalog's telegram manifest has it (c05aa5e6), on mod-x.
|
|
const telegramClaim = `[{"name":"channel","scope":"mesh","kind":"telegram","capabilities":["deliver","verified-sender"]}]`
|
|
verified := func(m map[string]any) map[string]any {
|
|
m["seat-traffic"] = map[string]any{"kinds": []any{map[string]any{"seat": "channel", "kind": "telegram", "module": "mod-x", "node": "a",
|
|
"capabilities": []any{"deliver", "verified-sender"}}}}
|
|
return m
|
|
}
|
|
composed := func(b *fakeBus) map[string]any {
|
|
recs, _ := grants.Records(grants.ReadInputs(b, &grants.Manifests{}))
|
|
raw, _ := json.Marshal(grants.Memberships(recs)[key].Membership)
|
|
var m map[string]any
|
|
_ = json.Unmarshal(raw, &m)
|
|
return m
|
|
}
|
|
for _, row := range []struct {
|
|
name string
|
|
last func(map[string]any) map[string]any
|
|
published bool
|
|
names string
|
|
// claims, when set, are mod-x's claims in its manifest for the row.
|
|
claims string
|
|
}{
|
|
{"a first membership is published", nil, true, "", ""},
|
|
{"an additive change is published, the private network carried", func(m map[string]any) map[string]any {
|
|
delete(m, "state")
|
|
m["mesh"] = []any{"10.0.0.1"}
|
|
return m
|
|
}, true, "", ""},
|
|
// The lead's decision in review round 1 of the fix of issue 1945: only a seat left with no holder, or a
|
|
// capability its holder still asks for (verified-sender, dropped by a root-free word that failed), holds a
|
|
// membership back; a dropped tool or field is published.
|
|
{"a change that drops a tool is published", func(m map[string]any) map[string]any {
|
|
m["serves"] = append(m["serves"].([]any), map[string]any{"subject": "mesh.mod.mod-x.tool.gone"})
|
|
m["mesh"] = []any{"10.0.0.1"}
|
|
return m
|
|
}, true, "", ""},
|
|
{"a change that drops verified-sender its holder still asks for is not published, and named", verified, false, "channel telegram verified-sender of a/mod-x", telegramClaim},
|
|
{"a change that drops verified-sender its holder no longer asks for is published", verified, true, `"published":"published"`, `[{"name":"channel","scope":"mesh","kind":"telegram"}]`},
|
|
{"a change that takes the last holder from a seat a placed module claims is not published, the seat named", func(m map[string]any) map[string]any {
|
|
m["seats"] = []any{map[string]any{"seat": brokerName, "verb": brokerVerb, "subject": "mesh.seat." + brokerName + ".tool." + brokerVerb}}
|
|
return m
|
|
}, false, "the last holder of " + brokerName, ""},
|
|
// A verb the seat catalogue retired on purpose (mark-ready, mesh-sdk d506f21, ADR 0421) is dropped and named; a
|
|
// verb of a seat the catalogue does not define is still a loss (issue 1945).
|
|
{"a change that drops only a retired seat verb is published, the verb named", func(m map[string]any) map[string]any {
|
|
m["seats"] = []any{map[string]any{"seat": "mesh-delivery", "verb": "mark-ready", "subject": "mesh.seat.mesh-delivery.tool.mark-ready"}}
|
|
return m
|
|
}, true, "mesh-delivery mark-ready", ""},
|
|
{"a seat no placed module claims dropped is published", func(m map[string]any) map[string]any {
|
|
m["seats"] = []any{map[string]any{"seat": "mesh-delivery", "verb": "mark-ready", "subject": "mesh.seat.mesh-delivery.tool.mark-ready"},
|
|
map[string]any{"seat": "telegram-sender", "verb": "send", "subject": "mesh.seat.telegram-sender.tool.send"}}
|
|
return m
|
|
}, true, "mesh-delivery mark-ready", ""},
|
|
} {
|
|
t.Run(row.name, func(t *testing.T) {
|
|
b := fixture()
|
|
if row.claims != "" {
|
|
b.manifestOf("mod-x", strings.Replace(xManifest, `"own-secrets"`, `"claims":`+row.claims+`,"own-secrets"`, 1))
|
|
}
|
|
b.loaded = shadowOf(t, b)
|
|
if row.last != nil {
|
|
raw, _ := json.Marshal(row.last(composed(b)))
|
|
b.stream = map[string][]byte{"mesh.assignment." + key: raw}
|
|
b.seqs = map[string]uint64{"mesh.assignment." + key: 4}
|
|
}
|
|
res := partOn(b).Compose()
|
|
if got := slices.Contains(res.Published, key); got != row.published {
|
|
t.Fatalf("published %v, not published %v", res.Published, res.NotPublished)
|
|
}
|
|
if row.published {
|
|
if row.names == "" && row.last != nil && !strings.Contains(string(b.stream["mesh.assignment."+key]), "10.0.0.1") {
|
|
t.Errorf("the private network of the last membership is not carried: %s", b.stream["mesh.assignment."+key])
|
|
}
|
|
if raw, _, _, _ := b.ownGet(grants.StateMemberships, key); !strings.Contains(string(raw), row.names) {
|
|
t.Errorf("the pass does not name %q: %s", row.names, raw)
|
|
}
|
|
return
|
|
}
|
|
said := strings.Join(res.NotPublished, "\n")
|
|
if !strings.Contains(said, grants.ShadowDifference) || !strings.Contains(said, row.names) {
|
|
t.Errorf("the loss is not named: %s", said)
|
|
}
|
|
ms, _ := partOn(b).MembershipOf("a", "mod-x")
|
|
if !strings.Contains(fmt.Sprint(ms), row.names) {
|
|
t.Errorf("grants_membership does not name what would be lost: %v", ms)
|
|
}
|
|
})
|
|
}
|
|
t.Run("a refused publish: no exit, said, published at the next event", func(t *testing.T) {
|
|
b := fixture()
|
|
b.loaded = shadowOf(t, b)
|
|
b.refusePub = true
|
|
p := partOn(b)
|
|
refused := p.Compose()
|
|
if len(refused.Published) != 0 || len(refused.NotPublished) != 3 || !strings.Contains(refused.NotPublished[0], "Permissions Violation") {
|
|
t.Errorf("a refused publish = %+v: said, and nothing published", refused)
|
|
}
|
|
b.refusePub = false
|
|
if next := p.Compose(); !slices.Equal(next.Published, []string{"a.mod-x", "a.nats", "b.mod-y"}) {
|
|
t.Errorf("the next event published %v", next.Published)
|
|
}
|
|
})
|
|
t.Run("a tool answers while a pass waits on the bus", func(t *testing.T) {
|
|
b := fixture()
|
|
b.loaded = shadowOf(t, b)
|
|
b.hold = make(chan struct{})
|
|
p := partOn(b)
|
|
done := make(chan struct{})
|
|
go func() { p.Compose(); close(done) }()
|
|
answered := make(chan struct{})
|
|
go func() {
|
|
_, _ = p.UserList()
|
|
_, _ = p.MembershipOf("a", "mod-x")
|
|
_, _ = p.Grants("")
|
|
_ = p.Last()
|
|
close(answered)
|
|
}()
|
|
select {
|
|
case <-answered:
|
|
case <-time.After(10 * time.Second):
|
|
t.Error("a tool waited on the pass's round trip")
|
|
}
|
|
close(b.hold)
|
|
<-done
|
|
})
|
|
})
|
|
|
|
t.Run("assertions: what the bus lacks is created once, what it holds is never touched", func(t *testing.T) {
|
|
b := fixture()
|
|
b.loaded = shadowOf(t, b)
|
|
// The bus holds mod-x's bucket with another history, and neither its log nor its stream.
|
|
b.streams = []map[string]any{{"name": "KV_mod-x_kept", "subjects": []string{"$KV.mod-x_kept.>"}, "retention": "limits", "max_per_subject": 2}}
|
|
p := partOn(b)
|
|
p.Create = b
|
|
assertion := func() grants.Assertion {
|
|
raw, _, _, _ := b.ownGet(grants.StateAssertions, "mod-x")
|
|
var a grants.Assertion
|
|
_ = json.Unmarshal(raw, &a)
|
|
return a
|
|
}
|
|
first := p.Compose()
|
|
if !slices.Equal(b.creates, []string{"LOG_mod-x_history", "THINGS"}) || len(first.Asserted) != 1 ||
|
|
!strings.HasPrefix(first.Asserted[0], "mod-x at ") || !slices.Contains(b.events, grants.EventAsserted) {
|
|
t.Fatalf("the first pass created %v, said %v, events %v", b.creates, first.Asserted, b.events)
|
|
}
|
|
a := assertion()
|
|
if !a.Asserted || a.Says != grants.CreatesMissing {
|
|
t.Errorf("the assertion kept = %+v", a)
|
|
}
|
|
for _, it := range a.Items {
|
|
switch it.Stream {
|
|
case "KV_mod-x_kept":
|
|
if it.Created != "" || !slices.Contains(it.Differences, "history 2 held, 3 composed") {
|
|
t.Errorf("a bucket the bus holds was touched, or its difference not said: %+v", it)
|
|
}
|
|
default:
|
|
if it.Created != grants.ItemCreated {
|
|
t.Errorf("%s: %q, want created", it.Stream, it.Created)
|
|
}
|
|
}
|
|
}
|
|
if again := p.Compose(); len(again.Asserted) != 0 || len(b.creates) != 2 {
|
|
t.Errorf("a pass with everything held created again: %v, %v", again.Asserted, b.creates)
|
|
}
|
|
// THINGS deleted since: not made again at the same manifest, said; a hand assert (the tool, asking the process)
|
|
// makes it again.
|
|
b.mu.Lock()
|
|
b.streams = slices.DeleteFunc(b.streams, func(s map[string]any) bool { return s["name"] == "THINGS" })
|
|
b.mu.Unlock()
|
|
gone := p.Compose()
|
|
if len(b.creates) != 2 || !strings.Contains(strings.Join(gone.NotAsserted, " "), "THINGS: created at this manifest and gone since") {
|
|
t.Errorf("a stream gone since was made again by itself, or not said: %v, %v", b.creates, gone.NotAsserted)
|
|
}
|
|
tool := partOn(b)
|
|
if got, err := tool.AssertOne("mod-x"); err != nil || fmt.Sprint(got) == "" || len(b.creates) != 2 {
|
|
t.Fatalf("the tool = %v, %v, creates %v: it asks, and creates nothing itself", got, err, b.creates)
|
|
}
|
|
if hand := p.Compose(); len(hand.Asserted) != 1 || b.creates[len(b.creates)-1] != "THINGS" {
|
|
t.Errorf("the hand assert did not create THINGS again: %v, %v", hand.Asserted, b.creates)
|
|
}
|
|
if after := p.Compose(); len(after.Asserted) != 0 {
|
|
t.Errorf("the hand ask was acted on twice: %v", after.Asserted)
|
|
}
|
|
for _, c := range b.creates {
|
|
if c == "KV_mod-x_kept" {
|
|
t.Error("the bucket the bus holds was asked to be created")
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("assertions: a refused create is said, never stops it, and is tried at the next event", func(t *testing.T) {
|
|
b := fixture()
|
|
b.loaded = shadowOf(t, b)
|
|
b.refuseCreate = true
|
|
p := partOn(b)
|
|
p.Create = b
|
|
refused := p.Compose()
|
|
if len(refused.Asserted) != 0 || !strings.Contains(strings.Join(refused.NotAsserted, " "), "Permissions Violation") ||
|
|
slices.Contains(b.events, grants.EventAsserted) {
|
|
t.Fatalf("a refused create = %+v", refused)
|
|
}
|
|
b.refuseCreate = false
|
|
if next := p.Compose(); len(next.Asserted) != 1 || !strings.Contains(next.Asserted[0], "KV_mod-x_kept") {
|
|
t.Errorf("the next event created %v", next.Asserted)
|
|
}
|
|
// What the bus holds unread: nothing is created.
|
|
c := fixture()
|
|
c.loaded = shadowOf(t, c)
|
|
q := partOn(c)
|
|
q.Create = c
|
|
q.Call = refusingStreams{c}
|
|
if r := q.Compose(); len(c.creates) != 0 || len(r.Asserted) != 0 {
|
|
t.Errorf("created %v with what the bus holds unread", c.creates)
|
|
}
|
|
})
|
|
|
|
t.Run("memberships held back: every one is said and listed", func(t *testing.T) {
|
|
b := fixture()
|
|
b.loaded = shadowOf(t, b)
|
|
recs, _ := grants.Records(grants.ReadInputs(b, &grants.Manifests{}))
|
|
b.stream, b.seqs = map[string][]byte{}, map[string]uint64{}
|
|
for i, key := range []string{"a.mod-x", "b.mod-y"} {
|
|
raw, _ := json.Marshal(grants.Memberships(recs)[key].Membership)
|
|
var m map[string]any
|
|
_ = json.Unmarshal(raw, &m)
|
|
seat, verb := brokerSeat()
|
|
m["seats"] = []any{map[string]any{"seat": seat, "verb": verb, "subject": "mesh.seat." + seat + ".tool." + verb}}
|
|
raw, _ = json.Marshal(m)
|
|
subject := "mesh.assignment." + key
|
|
b.stream[subject], b.seqs[subject] = raw, uint64(i+1)
|
|
}
|
|
var logged []string
|
|
var lmu sync.Mutex
|
|
w := &fakeWatcher{opened: make(chan string, 16)}
|
|
f := &grants.Follower{Part: partOn(b), Watch: w, Logf: func(format string, args ...any) {
|
|
lmu.Lock()
|
|
logged = append(logged, fmt.Sprintf(format, args...))
|
|
lmu.Unlock()
|
|
}}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
done := make(chan struct{})
|
|
go func() { f.Run(ctx); close(done) }()
|
|
waitFor(t, func() bool {
|
|
lmu.Lock()
|
|
defer lmu.Unlock()
|
|
all := strings.Join(logged, "\n")
|
|
return strings.Contains(all, "held a.mod-x: ") && strings.Contains(all, "held b.mod-y: ")
|
|
})
|
|
cancel()
|
|
<-done
|
|
held, err := partOn(b).MembershipOf("", "")
|
|
h, _ := held.(map[string]any)
|
|
if seat, _ := brokerSeat(); err != nil || h["count"] != 2 || !strings.Contains(fmt.Sprint(h["held"]), "the last holder of "+seat) {
|
|
t.Errorf("grants_membership with neither node nor module = %v, %v", held, err)
|
|
}
|
|
if _, err := partOn(b).MembershipOf("a", ""); err == nil {
|
|
t.Error("a node without a module was answered")
|
|
}
|
|
})
|
|
|
|
t.Run("a refused read or write never stops it", func(t *testing.T) {
|
|
b := fixture()
|
|
b.loaded = shadowOf(t, b)
|
|
b.refuseTo[grants.StateMemberships] = true
|
|
res := partOn(b).Compose()
|
|
if !res.Written || len(res.Errors) == 0 || !strings.Contains(strings.Join(res.Errors, " "), "memberships") {
|
|
t.Errorf("a refused write of memberships = %+v: the user list is written all the same, the refusal said", res)
|
|
}
|
|
w := &fakeWatcher{refuse: map[string]int{grants.ReadModules: 1}, opened: make(chan string, 16)}
|
|
var logged []string
|
|
var lmu sync.Mutex
|
|
f := &grants.Follower{Part: partOn(b), Watch: w, Logf: func(format string, args ...any) {
|
|
lmu.Lock()
|
|
logged = append(logged, fmt.Sprintf(format, args...))
|
|
lmu.Unlock()
|
|
}}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
done := make(chan struct{})
|
|
go func() { f.Run(ctx); close(done) }()
|
|
waitOpened(t, w, grants.ReadAssignments)
|
|
if r := f.Refused(); r[grants.ReadModules] == "" {
|
|
waitFor(t, func() bool { return f.Refused()[grants.ReadModules] != "" })
|
|
}
|
|
w.change(grants.ReadAssignments, "a.mod-x")
|
|
waitOpened(t, w, grants.ReadModules)
|
|
waitFor(t, func() bool { return len(f.Refused()) == 0 })
|
|
select {
|
|
case <-done:
|
|
t.Fatal("the follower stopped")
|
|
default:
|
|
}
|
|
cancel()
|
|
<-done
|
|
lmu.Lock()
|
|
defer lmu.Unlock()
|
|
if !strings.Contains(strings.Join(logged, "\n"), "cannot be watched yet") {
|
|
t.Errorf("the refusal was not said: %v", logged)
|
|
}
|
|
})
|
|
}
|
|
|
|
type fakeAsker struct {
|
|
asks []asks.Ask
|
|
wheres map[string]*asks.Warrant // the router's warrants, by ask
|
|
fail bool
|
|
cancels []string
|
|
// takeErr makes Take fail as the asker does when the part's own record cannot be reached: no handling, an error.
|
|
takeErr error
|
|
}
|
|
|
|
func (f *fakeAsker) Cancel(id string) error { f.cancels = append(f.cancels, id); return nil }
|
|
|
|
// fakeObjects is the part's object bucket of sealed lists.
|
|
type fakeObjects struct{ objects map[string][]byte }
|
|
|
|
func (o *fakeObjects) PutObject(name string, data []byte) error {
|
|
if o.objects == nil {
|
|
o.objects = map[string][]byte{}
|
|
}
|
|
o.objects[name] = data
|
|
return nil
|
|
}
|
|
|
|
func (o *fakeObjects) DeleteObject(name string) error { delete(o.objects, name); return nil }
|
|
|
|
func (f *fakeAsker) Ask(a asks.Ask) error {
|
|
if f.fail {
|
|
return errors.New("the bus refused the publish")
|
|
}
|
|
f.asks = append(f.asks, a)
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeAsker) Where(id string) (string, *asks.Warrant, error) { return "open", f.wheres[id], nil }
|
|
|
|
// Take hands the warrant to act as the SDK's asker does for one that chose, and records any other.
|
|
func (f *fakeAsker) Take(w asks.Warrant, act func(asks.Option, asks.Warrant) error) (asker.Handled, error) {
|
|
if f.takeErr != nil {
|
|
return "", f.takeErr
|
|
}
|
|
if w.Outcome != asks.OutcomeChosen {
|
|
return asker.Recorded, nil
|
|
}
|
|
return asker.Acted, act(asks.Option{ID: w.Option}, w)
|
|
}
|
|
|
|
func nonNil(s []string) []string {
|
|
if s == nil {
|
|
return []string{}
|
|
}
|
|
return s
|
|
}
|
|
|
|
// fakeWatcher refuses a state's first watches as told, and hands changes to the watches it opened.
|
|
// refusingStreams is the bus with nats_streams refused: what the bus holds cannot be read.
|
|
type refusingStreams struct{ *fakeBus }
|
|
|
|
func (r refusingStreams) Call(key string, body any) (json.RawMessage, error) {
|
|
if key == grants.CallStreams {
|
|
return nil, errors.New("refused")
|
|
}
|
|
return r.fakeBus.Call(key, body)
|
|
}
|
|
|
|
type fakeWatcher struct {
|
|
mu sync.Mutex
|
|
refuse map[string]int
|
|
changes map[string]func(string, []byte)
|
|
opened chan string
|
|
}
|
|
|
|
func (w *fakeWatcher) Watch(state string, changed func(string, []byte), _ func(error)) error {
|
|
w.mu.Lock()
|
|
defer w.mu.Unlock()
|
|
if w.refuse[state] > 0 {
|
|
w.refuse[state]--
|
|
return errors.New("Permissions Violation for Publish to $JS.API.STREAM.INFO.KV_" + state)
|
|
}
|
|
if w.changes == nil {
|
|
w.changes = map[string]func(string, []byte){}
|
|
}
|
|
w.changes[state] = changed
|
|
w.opened <- state
|
|
return nil
|
|
}
|
|
|
|
func (w *fakeWatcher) change(state, key string) {
|
|
w.mu.Lock()
|
|
h := w.changes[state]
|
|
w.mu.Unlock()
|
|
h(key, []byte(`{}`))
|
|
}
|
|
|
|
func waitOpened(t *testing.T, w *fakeWatcher, state string) {
|
|
t.Helper()
|
|
deadline := time.After(10 * time.Second)
|
|
for {
|
|
select {
|
|
case s := <-w.opened:
|
|
if s == state {
|
|
return
|
|
}
|
|
case <-deadline:
|
|
t.Fatalf("%s was never watched", state)
|
|
}
|
|
}
|
|
}
|
|
|
|
func waitFor(t *testing.T, ok func() bool) {
|
|
t.Helper()
|
|
for end := time.Now().Add(10 * time.Second); time.Now().Before(end); time.Sleep(10 * time.Millisecond) {
|
|
if ok() {
|
|
return
|
|
}
|
|
}
|
|
t.Fatal("never came")
|
|
}
|