Files
mesh-delivery/modules/mesh-grants/cmd/mesh-grants/composed_integration_test.go
T
jschoubben 0b03b9c7c5 mesh-grants: create a module's object buckets and history streams, grant their readers read-only, and warn at 75 percent
ADR 0592 §5, §6, §9, §25 (the grants part's share by ADR 0593 §10): an object bucket and a stream that
keeps its history are created create-only with their caps and no age limit, refusing new data when full;
the owner alone writes its bucket and a module its grant names reads it; the part raises
mesh-grants.history-full once any passes 75 percent. Each engine also reads the indexer's manifest
verdicts by one key for compose-candidate.

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-02 18:56:19 +01:00

301 lines
12 KiB
Go

//go:build integration
package main
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"os"
"path/filepath"
"slices"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/seal"
"github.com/nats-io/nats-server/v2/server"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
"mesh-grants/internal/buscompose"
)
// theBusNodeFollowsTheSealedListUnderTheComposedGrants, run by TestTheGrantsPartWhole, defends novox/hq ADR 0547 clause 7 on a real server (issue 1945): with the list this composer writes, the grants part
// creates its object bucket and its state, puts the sealed list and names it in `user-list.sealed`; the node-engine of
// the node holding mesh-broker watches that one key, reads the object by its name and opens it with its key, and may
// neither list the bucket, nor read another key of the state, nor write; another node reads nothing of either.
func theBusNodeFollowsTheSealedListUnderTheComposedGrants(t *testing.T) {
records := buscompose.Records{
Nodes: []string{"novox", "ace"},
Assigned: map[string][]buscompose.Declared{
"novox": {{Module: "nats", SnapshotsTheBus: true},
{Module: buscompose.GrantsModule, State: []buscompose.Bucket{{Module: buscompose.GrantsModule, Name: "user-list"}}}},
"ace": {{Module: "plex", Reads: []string{delivery.ComposedRead}},
{Module: "mesh-delivery-view", Reads: []string{delivery.ComposedRead}, ReadsObjects: []string{"mesh-knowledge_sections"}},
{Module: "mesh-knowledge", Objects: []buscompose.ObjectBucket{{Module: "mesh-knowledge", Name: "sections", MaxMiB: 1, Restricted: true}}}},
},
}
// The node-engine's state as the part's records carry it: every node's compositions (ADR 0593 §6).
records.Engine = &buscompose.Declared{Module: buscompose.EngineModule, State: buscompose.EngineComposedBuckets(records.Nodes)}
users, err := buscompose.Users(records)
if err != nil {
t.Fatal(err)
}
for i := range users {
users[i].PasswordHash = "pw-" + users[i].Username()
}
accounts, err := buscompose.ComposeAccounts(users)
if err != nil {
t.Fatal(err)
}
dir := t.TempDir()
conf := filepath.Join(dir, "server.conf")
if err := os.WriteFile(conf, []byte("listen: 127.0.0.1:-1\njetstream { store_dir: \""+dir+"\" }\n"+accounts), 0o600); err != nil {
t.Fatal(err)
}
opts, err := server.ProcessConfigFile(conf)
if err != nil {
t.Fatalf("the composed list is not the server's: %v", err)
}
opts.NoLog, opts.NoSigs = true, true
s, err := server.NewServer(opts)
if err != nil {
t.Fatal(err)
}
go s.Start()
if !s.ReadyForConnections(30 * time.Second) {
t.Fatal("the bus did not come up")
}
t.Cleanup(s.Shutdown)
dial := func(user string) *nats.Conn {
nc, err := nats.Connect(s.ClientURL(), nats.UserInfo(user, "pw-"+user), nats.CustomInboxPrefix("_INBOX."+user))
if err != nil {
t.Fatalf("%s: %v", user, err)
}
t.Cleanup(nc.Close)
return nc
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// The grants part: its state and its object bucket, the sealed list put and named.
grants, err := jetstream.New(dial("novox." + buscompose.GrantsModule))
if err != nil {
t.Fatal(err)
}
// Its state is made as the grants part makes a declared state, by its stream's name (CreateKeyValue would ask the
// account's info, which no module is granted).
if _, err := grants.CreateStream(ctx, jetstream.StreamConfig{Name: "KV_" + buscompose.GrantsUserListBucket,
Subjects: []string{"$KV." + buscompose.GrantsUserListBucket + ".>"}, MaxMsgsPerSubject: 1, AllowDirect: true,
AllowRollup: true, DenyDelete: true, Discard: jetstream.DiscardNew}); err != nil {
t.Fatalf("the grants part's state: %v", err)
}
kv, err := grants.KeyValue(ctx, buscompose.GrantsUserListBucket)
if err != nil {
t.Fatalf("the grants part binds its state: %v", err)
}
objects, err := grants.CreateObjectStore(ctx, jetstream.ObjectStoreConfig{Bucket: buscompose.GrantsUserListObjects})
if err != nil {
t.Fatalf("the grants part's object bucket: %v", err)
}
node, err := seal.GenerateKeyPair()
if err != nil {
t.Fatal(err)
}
list := []byte(accounts)
digest := delivery.UserListDigest(list)
object, err := delivery.SealUserList(list, node.PublicKey)
if err != nil {
t.Fatal(err)
}
name := delivery.UserListObjectName(digest)
for range 2 { // put twice: a second put of a name purges its old chunks
if _, err := objects.PutBytes(ctx, name, object); err != nil {
t.Fatalf("the grants part put its object: %v", err)
}
}
value, err := delivery.EncodeGrantsUserList(delivery.GrantsUserList{Follow: delivery.FollowGrants, Digest: digest,
Object: name, SealedTo: delivery.SealingKeyID(node.PublicKey), Node: "novox", Revision: 1, Users: len(users)})
if err != nil {
t.Fatal(err)
}
if _, err := kv.Put(ctx, buscompose.GrantsUserListKey, value); err != nil {
t.Fatal(err)
}
if _, err := kv.Put(ctx, "list", []byte(`{"v":1}`)); err != nil {
t.Fatal(err)
}
// The bus node's engine: the key watched, the object by name (as mesh-host's link reads a declaration's file).
busNode := dial("node.novox")
js, err := busNode.JetStream(nats.MaxWait(5 * time.Second))
if err != nil {
t.Fatal(err)
}
keys, err := js.KeyValue(buscompose.GrantsUserListBucket)
if err != nil {
t.Fatalf("the bus node binds the grants part's state: %v", err)
}
w, err := keys.Watch(buscompose.GrantsUserListKey)
if err != nil {
t.Fatalf("the bus node watches the key: %v", err)
}
defer w.Stop()
var got delivery.GrantsUserList
select {
case e := <-w.Updates():
if e == nil {
t.Fatal("the watch found no value")
}
if got, err = delivery.DecodeGrantsUserList(e.Value()); err != nil {
t.Fatal(err)
}
case <-ctx.Done():
t.Fatal("the watch said nothing")
}
read := func(js nats.JetStreamContext, name string) ([]byte, error) {
meta, err := js.GetLastMsg(buscompose.GrantsUserListObjectStream, "$O."+buscompose.GrantsUserListObjects+".M."+
base64.URLEncoding.EncodeToString([]byte(name)), nats.DirectGet())
if err != nil {
return nil, err
}
var info struct {
NUID string `json:"nuid"`
Size int `json:"size"`
}
if err := json.Unmarshal(meta.Data, &info); err != nil {
return nil, err
}
sub, err := js.SubscribeSync("$O."+buscompose.GrantsUserListObjects+".C."+info.NUID, nats.OrderedConsumer(),
nats.BindStream(buscompose.GrantsUserListObjectStream))
if err != nil {
return nil, err
}
defer sub.Unsubscribe()
var body []byte
for len(body) < info.Size {
m, err := sub.NextMsg(5 * time.Second)
if err != nil {
return nil, err
}
body = append(body, m.Data...)
}
return body, nil
}
body, err := read(js, got.Object)
if err != nil {
t.Fatalf("the bus node reads the object by its name: %v", err)
}
opened, err := delivery.OpenUserList(body, got.Digest, func(b seal.Box) ([]byte, error) {
s, err := seal.Open(b, node.PrivateKey)
return []byte(s), err
})
if err != nil || !bytes.Equal(opened, list) {
t.Fatalf("the bus node opened %d bytes, %v", len(opened), err)
}
// And nothing beyond: no other key, no list of the bucket, no write.
if _, err := keys.Get("list"); err == nil {
t.Error("the bus node read another key of the grants part's state")
}
if _, err := js.StreamInfo(buscompose.GrantsUserListObjectStream); err == nil {
t.Error("the bus node read the object stream's info, which lists every name")
}
if _, err := js.Publish("$O."+buscompose.GrantsUserListObjects+".M.x", []byte("x")); err == nil {
t.Error("the bus node wrote the grants part's bucket")
}
// Another node reads nothing of it.
other, err := dial("node.ace").JetStream(nats.MaxWait(2 * time.Second))
if err != nil {
t.Fatal(err)
}
if _, err := read(other, got.Object); err == nil {
t.Error("another node read the object")
}
if _, err := other.GetLastMsg("KV_"+buscompose.GrantsUserListBucket, "$KV."+buscompose.GrantsUserListBucket+"."+buscompose.GrantsUserListKey, nats.DirectGet()); err == nil {
t.Errorf("another node read the key: %v", err)
}
// A context of its own: the refusals above each wait out their bound.
ctx, cancel = context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// ADR 0593 §6 (D14): the grants part creates each node's compositions; ace's engine writes its own and not novox's;
// the view, a named reader, finds every node's bucket by listing and reads it, writing nothing; plex reads none.
for _, node := range records.Nodes {
cfg := buscompose.BucketStreamConfig(buscompose.EngineComposedBuckets([]string{node})[0])
if _, err := grants.CreateStream(ctx, cfg); err != nil {
t.Fatalf("the grants part creates %s: %v", cfg.Name, err)
}
}
aceEngine, _ := dial("node.ace").JetStream(nats.MaxWait(2 * time.Second))
if _, err := aceEngine.Publish("$KV.composed-ace.ace.node", []byte(`{}`)); err != nil {
t.Errorf("ace's engine cannot write its own compositions: %v", err)
}
if _, err := aceEngine.Publish("$KV.composed-novox.novox.node", []byte(`{}`)); err == nil {
t.Error("ace's engine wrote novox's compositions")
}
view, err := jetstream.New(dial("ace.mesh-delivery-view"))
if err != nil {
t.Fatal(err)
}
var found []string
for name := range view.KeyValueStoreNames(ctx).Name() {
found = append(found, name)
}
if !slices.Contains(found, "composed-ace") || !slices.Contains(found, "composed-novox") {
t.Errorf("the view finds the buckets %v", found)
}
composed, err := view.KeyValue(ctx, "composed-ace")
if err != nil {
t.Fatalf("the view binds ace's compositions: %v", err)
}
if _, err := composed.Get(ctx, "ace.node"); err != nil {
t.Errorf("the view reads ace's own composition: %v", err)
}
refusedIn := func() context.Context { // a refused publish is answered by no one: it waits out a short bound
c, stop := context.WithTimeout(ctx, 2*time.Second)
t.Cleanup(stop)
return c
}
if _, err := composed.Put(refusedIn(), "ace.node", []byte(`{}`)); err == nil {
t.Error("the view wrote a composition")
}
plex, _ := dial("ace.plex").JetStream(nats.MaxWait(2 * time.Second))
if _, err := plex.GetLastMsg("KV_composed-ace", "$KV.composed-ace.ace.node", nats.DirectGet()); err == nil {
t.Error("plex, not a named reader, read a composition")
}
// ADR 0592 §5, §9: a module's own object bucket, created by the grants part, written (and an object replaced) by its
// owner alone, read by the module its grant names, read-only, and by no other.
sections := buscompose.ObjectBucket{Module: "mesh-knowledge", Name: "sections", MaxMiB: 1, Restricted: true}
if _, err := grants.CreateStream(ctx, buscompose.ObjectsStreamConfig(sections)); err != nil {
t.Fatalf("the grants part creates %s: %v", sections.Stream(), err)
}
owner, err := jetstream.New(dial("ace.mesh-knowledge"))
if err != nil {
t.Fatal(err)
}
ownStore, err := owner.ObjectStore(ctx, sections.Bucket())
if err != nil {
t.Fatalf("the owner binds its object bucket: %v", err)
}
for range 2 { // put twice: a second put of a name purges its old chunks
if _, err := ownStore.PutBytes(ctx, "s1", []byte("body")); err != nil {
t.Fatalf("the owner puts an object: %v", err)
}
}
readStore, err := view.ObjectStore(ctx, sections.Bucket())
if err != nil {
t.Fatalf("the granted reader binds the bucket: %v", err)
}
if got, err := readStore.GetBytes(ctx, "s1"); err != nil || string(got) != "body" {
t.Errorf("the granted reader reads %q, %v", got, err)
}
if _, err := readStore.PutBytes(refusedIn(), "s2", []byte("x")); err == nil {
t.Error("the granted reader wrote the bucket")
}
if _, err := plex.GetLastMsg(sections.Stream(), "$O."+sections.Bucket()+".M.x", nats.DirectGet()); err == nil {
t.Error("a module the grant does not name read the bucket")
}
}