Composed without a root-free judgement, a membership would drop a kind's verified-sender and the operator's channel approvals would stop being honoured; any loss now stays a shadow difference, named. A tool no longer waits on a pass's round trip. compose-now says the controller serves no verb to write a module's grant again.
761 lines
27 KiB
Go
761 lines
27 KiB
Go
package manifest
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
|
"git.novox.be/novox/mesh-sdk/go/delivery"
|
|
|
|
"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{}
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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:
|
|
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})
|
|
}
|
|
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"]}`
|
|
)
|
|
|
|
// 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},
|
|
{"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 create, a publish only of memberships", func(t *testing.T) {
|
|
forbidden := []string{"time.Tick", "time.NewTicker", "time.Sleep", "time.After", "time.NewTimer", "time.AfterFunc",
|
|
"PublishMsg", "CreateStream", "CreateKeyValue", "CreateOrUpdate", "UpdateStream", "UpdateKeyValue",
|
|
"CreateObjectStore", "EnsureStream", "RaiseBuckets", "RaiseLogs", ".Create("}
|
|
// The one publish: a membership, on its own subject, in publish.go.
|
|
publishOnly := filepath.Join("cmd", "mesh-grants", "publish.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 && (strings.Contains(string(src), ".Publish(") || strings.Contains(string(src), "mesh.assignment")) {
|
|
t.Errorf("%s publishes: only publish.go publishes, and only memberships", path)
|
|
}
|
|
if path == publishOnly && strings.Count(string(src), ".Publish(") != 1 {
|
|
t.Errorf("publish.go publishes other than one membership")
|
|
}
|
|
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)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
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("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")
|
|
for _, row := range []struct {
|
|
name string
|
|
loaded []grants.User
|
|
added, dropped, gone []string
|
|
differ []string
|
|
equal bool
|
|
}{
|
|
{"equal", shadow, nil, nil, nil, nil, true},
|
|
{"one user the bus lacks", shadow[1:], []string{shadow[0].User}, nil, nil, nil, false},
|
|
{"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},
|
|
{"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},
|
|
{"grants differ", changed, nil, nil, nil, []string{"a.mod-x"}, false},
|
|
} {
|
|
t.Run(row.name, func(t *testing.T) {
|
|
c := grants.Compare(shadow, 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)
|
|
}
|
|
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])
|
|
}
|
|
})
|
|
}
|
|
})
|
|
|
|
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)
|
|
|
|
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)
|
|
}
|
|
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 would lose the module's
|
|
// plain subject (mod-x now runs on two machines), a loss, so it stays a shadow difference.
|
|
b.assign("b", "mod-x")
|
|
moved := p.Compose()
|
|
if !moved.Written || moved.Revision != 2 || !slices.Equal(moved.Published, []string{"b.mod-x"}) || len(moved.NotPublished) != 1 || !strings.HasPrefix(moved.NotPublished[0], "a.mod-x: "+grants.ShadowDifference) || 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")
|
|
as, _ := a.(grants.Assertion)
|
|
if err != nil || as.Asserted || as.Says != grants.ShadowAsserts || len(as.Items) != 3 {
|
|
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"})
|
|
for _, row := range []struct {
|
|
name, act, caller string
|
|
says, never []string
|
|
}{
|
|
{"take-over without the operator's credential", "take-over", "shanks.node-tools", []string{"operator's alone"}, nil},
|
|
{"hand-back without the operator's credential", "hand-back", "unproven", []string{"operator's alone"}, nil},
|
|
{"take-over while a user differs names it", "take-over", "person.operator", []string{"users differ", "b.mod-x", grants.NotComposable}, []string{"b.gone"}},
|
|
{"hand-back in shadow", "hand-back", "person.operator", []string{"nothing to hand back"}, nil},
|
|
} {
|
|
t.Run(row.name, func(t *testing.T) {
|
|
var err error
|
|
if row.act == "take-over" {
|
|
err = p.TakeOver(row.caller, "a row")
|
|
} else {
|
|
err = p.HandBack(row.caller, "a row")
|
|
}
|
|
if err == nil {
|
|
t.Fatalf("%s by %s was not refused", row.act, row.caller)
|
|
}
|
|
for _, s := range row.says {
|
|
if !strings.Contains(err.Error(), s) {
|
|
t.Errorf("refusal %q does not say %q", err, s)
|
|
}
|
|
}
|
|
for _, s := range row.never {
|
|
if strings.Contains(err.Error(), s) {
|
|
t.Errorf("refusal %q names %q", err, s)
|
|
}
|
|
}
|
|
raw, _, _, _ := b.ownGet(grants.StateTakeover, grants.KeySwitch)
|
|
var s grants.Switch
|
|
_ = json.Unmarshal(raw, &s)
|
|
last := s.Acts[len(s.Acts)-1]
|
|
if s.State != grants.Shadow || last.Act != row.act || last.By != row.caller || last.Refused == "" {
|
|
t.Errorf("the act is not recorded: %+v", s)
|
|
}
|
|
})
|
|
}
|
|
t.Run("take-over with only a user placement no longer names is refused for want of records alone", func(t *testing.T) {
|
|
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"})
|
|
err := p.TakeOver("person.operator", "lists agree")
|
|
if err == nil || strings.Contains(err.Error(), "differ") || !strings.Contains(err.Error(), grants.NotComposable) {
|
|
t.Errorf("take-over = %v, want refused for the enrolment users and persons alone", err)
|
|
}
|
|
})
|
|
|
|
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: a first or an additive one published, a loss never", func(t *testing.T) {
|
|
const key = "a.mod-x"
|
|
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
|
|
}{
|
|
{"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, ""},
|
|
{"a change that drops verified-sender is not published, and named", func(m map[string]any) map[string]any {
|
|
m["seat-traffic"] = map[string]any{"kinds": []any{map[string]any{"seat": "channel", "kind": "telegram", "capabilities": []any{"verified-sender"}}}}
|
|
return m
|
|
}, false, "verified-sender"},
|
|
{"a change that drops any other field is not 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"})
|
|
return m
|
|
}, false, "tool.gone"},
|
|
} {
|
|
t.Run(row.name, func(t *testing.T) {
|
|
b := fixture()
|
|
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.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])
|
|
}
|
|
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("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)
|
|
}
|
|
})
|
|
}
|
|
|
|
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.
|
|
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")
|
|
}
|