From af6bcea41ec6f56c7377412d5ec3ab6f358e744a Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 24 Oct 2026 18:41:23 +0200 Subject: [PATCH] Prove the compositions through serve() whole, on a bus running the composed user list: the start, a done key, a real rotation (issue 1945; the review's G1) The tests no longer call the writer on a bus without permissions: the controller runs as it serves, the builder and the indexer write their own keys as their own accounts, and the operator's rotate is the real verb, so a subject the user list does not grant fails them as it would fail the mesh. --- .../composed_integration_test.go | 232 ----------- .../composed_served_integration_test.go | 374 ++++++++++++++++++ 2 files changed, 374 insertions(+), 232 deletions(-) create mode 100644 cmd/mesh-controller/composed_served_integration_test.go diff --git a/cmd/mesh-controller/composed_integration_test.go b/cmd/mesh-controller/composed_integration_test.go index 2f59c7ab..b9d88388 100644 --- a/cmd/mesh-controller/composed_integration_test.go +++ b/cmd/mesh-controller/composed_integration_test.go @@ -4,13 +4,9 @@ package main import ( "context" - "crypto/ed25519" - "encoding/json" "errors" - "strings" "sync" "testing" - "time" sdkdelivery "git.novox.be/novox/mesh-sdk/go/delivery" "github.com/nats-io/nats.go" @@ -80,74 +76,6 @@ func aComposingMesh(t *testing.T) *composingMesh { return m } -// noDeclarationSent fails the test if anything was published on a node's declaration subject. -func (m *composingMesh) noDeclarationSent(t *testing.T) { - t.Helper() - _ = m.conn.Flush() - m.mu.Lock() - defer m.mu.Unlock() - for _, s := range m.heard { - if strings.HasPrefix(s, "mesh.node.") && strings.HasSuffix(s, ".declare") { - t.Fatalf("published on %s: nothing is sent to a node", s) - } - } -} - -// composed reads a composition and its revision from the node's bucket; revision 0 when there is none. -func (m *composingMesh) composed(t *testing.T, node, key string) (sdkdelivery.Composed, uint64) { - t.Helper() - bucket, _ := sdkdelivery.ComposedBucketFor(node) - kv, err := m.js.KeyValue(t.Context(), bucket) - if errors.Is(err, jetstream.ErrBucketNotFound) { - return sdkdelivery.Composed{}, 0 - } - if err != nil { - t.Fatal(err) - } - e, err := kv.Get(t.Context(), key) - if errors.Is(err, jetstream.ErrKeyNotFound) { - return sdkdelivery.Composed{}, 0 - } - if err != nil { - t.Fatal(err) - } - c, err := sdkdelivery.DecodeComposed(e.Value()) - if err != nil { - t.Fatal(err) - } - return c, e.Revision() -} - -// keysOf are the keys of a node's composition bucket. -func (m *composingMesh) keysOf(t *testing.T, node string) map[string]uint64 { - t.Helper() - bucket, _ := sdkdelivery.ComposedBucketFor(node) - kv, err := m.js.KeyValue(t.Context(), bucket) - if err != nil { - t.Fatal(err) - } - lister, err := kv.ListKeys(t.Context()) - if err != nil { - t.Fatal(err) - } - out := map[string]uint64{} - for k := range lister.Keys() { - e, err := kv.Get(t.Context(), k) - if err != nil { - t.Fatal(err) - } - out[k] = e.Revision() - } - return out -} - -// sudoAt takes sudo in from a done key of fp at commit and assigns it to anchor: what a merged change of it is. -func (m *composingMesh) sudoAt(t *testing.T, fp, commit string) registerKeys { - t.Helper() - keys, trunk := aKeyFor("sudo", "2-"+commit[:4], fp, "novox/mesh-catalog", "modules/sudo", commit) - return trunkKeys{keyBuckets: keys, trunk: trunk} -} - // trunkKeys are a done key and the indexer's trunk value naming it. type trunkKeys struct { keyBuckets @@ -158,166 +86,6 @@ func (k trunkKeys) ModuleAt(context.Context, string, string) (sdkdelivery.Module return k.trunk, nil } -// 1945/AC3 (and AC1, AC2's half for a new fingerprint): a done key of a new fingerprint of an assigned module is taken -// in, its composition is written on the node it is assigned to, signed by the controller's key, with its digest beside -// it, and `grants..` is written: both before any want can name it. Nothing is published on a node's -// declaration subject. -func TestADoneKeyOfAnAssignedModuleIsComposedAndGranted(t *testing.T) { - m := aComposingMesh(t) - ctx := t.Context() - if _, err := m.open.inventory.Assign(ctx, "anchor", "sudo"); err != nil { - t.Fatal(err) - } - fp := fpOf('a') - said, err := liveDoneKeySteps(m.open, m.sudoAt(t, fp, "c0ffee0123456789")).take(ctx, "sudo") - if err != nil { - t.Fatalf("the done key's take-in: %v", err) - } - if !strings.Contains(said, "registered from the builder's done key") { - t.Errorf("said %q", said) - } - key, _ := sdkdelivery.ComposedKey("anchor", "sudo", fp) - c, rev := m.composed(t, "anchor", key) - if rev == 0 || c.FP != fp || len(c.Resources) == 0 { - t.Fatalf("sudo's composition on anchor at %s: revision %d, %+v", fp, rev, c) - } - ident, _ := m.open.Identity(ctx) - active, _ := ident.Active(ctx) - digest, err := sdkdelivery.VerifyComposed(c, []ed25519.PublicKey{ed25519.PublicKey(active.Public)}) - if err != nil { - t.Fatalf("the composition is not signed by the controller's key: %v", err) - } - digests, err := m.js.KeyValue(ctx, sdkdelivery.ControllerComposedBucket) - if err != nil { - t.Fatal(err) - } - e, err := digests.Get(ctx, key) - if err != nil { - t.Fatal(err) - } - if v, err := sdkdelivery.DecodeComposedDigest(e.Value()); err != nil || v.Digest != digest { - t.Errorf("the digest beside it is %+v (%v), want %s", v, err, digest) - } - grants, err := m.js.KeyValue(ctx, broker.GrantsBucket) - if err != nil { - t.Fatal(err) - } - grantKey, _ := sdkdelivery.ControllerGrantKey("sudo", fp) - if _, err := grants.Get(ctx, grantKey); err != nil { - t.Errorf("grants.%s is not written: %v", grantKey, err) - } - m.noDeclarationSent(t) -} - -// 1945/AC2: an input one module's composition reads changes, and that act writes a new revision of that module's -// composition alone: every other composition of the node keeps its revision, and nothing is sent. The input here is -// the module's newest done key, which only its own composition reads; a rotated secret reaches composeModules the -// same way (rotate.go), through the same writer. -func TestAChangeWritesANewRevisionOfOnlyTheCompositionsThatReadIt(t *testing.T) { - m := aComposingMesh(t) - ctx := t.Context() - if _, err := m.open.inventory.Assign(ctx, "anchor", "sudo"); err != nil { - t.Fatal(err) - } - if _, err := liveDoneKeySteps(m.open, m.sudoAt(t, fpOf('a'), "c0ffee0123456789")).take(ctx, "sudo"); err != nil { - t.Fatal(err) - } - before := m.keysOf(t, "anchor") - if _, err := composeModules(ctx, m.open, []string{"anchor"}); err != nil { - t.Fatal(err) - } - if again := m.keysOf(t, "anchor"); !sameRevisions(before, again) { - t.Fatalf("composing again with nothing changed wrote %v (was %v)", again, before) - } - if _, err := liveDoneKeySteps(m.open, m.sudoAt(t, fpOf('b'), "d00d0123456789ab")).take(ctx, "sudo"); err != nil { - t.Fatal(err) - } - after := m.keysOf(t, "anchor") - newKey, _ := sdkdelivery.ComposedKey("anchor", "sudo", fpOf('b')) - for k, rev := range after { - if k == newKey { - continue - } - if before[k] != rev { - t.Errorf("%s moved from revision %d to %d: only sudo's composition reads its new build", k, before[k], rev) - } - } - if after[newKey] == 0 { - t.Errorf("sudo's new composition is not written: %v", after) - } - m.noDeclarationSent(t) -} - -func sameRevisions(a, b map[string]uint64) bool { - if len(a) != len(b) { - return false - } - for k, v := range a { - if b[k] != v { - return false - } - } - return true -} - -// 1945/AC6 and AC14: after a fresh start every node has its own composition and every assigned module its composition; -// the first start deletes the merge window's, the silent sends' and the building plans' buckets and says each once as -// the controller's event bucket-deleted; a later start says nothing. -func TestAFreshStartComposesEveryNodeAndDeletesThePlansBucketsOnce(t *testing.T) { - m := aComposingMesh(t) - ctx := t.Context() - for _, b := range broker.PlanStateBuckets { - if _, err := m.js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: b}); err != nil { - t.Fatal(err) - } - } - var said []BucketDeleted - var mu sync.Mutex - sub, err := m.conn.Subscribe(link.SeatEventSubject(link.MeshControllerSeat, "bucket-deleted"), func(msg *nats.Msg) { - var d BucketDeleted - if json.Unmarshal(msg.Data, &d) == nil { - mu.Lock() - said = append(said, d) - mu.Unlock() - } - }) - if err != nil { - t.Fatal(err) - } - defer sub.Unsubscribe() - - composeAtStart(ctx, m.open) - for _, node := range []string{"anchor", "laptop"} { - own, _ := sdkdelivery.ComposedNodeKey(node) - if _, rev := m.composed(t, node, own); rev == 0 { - t.Errorf("%s has no composition of its own after the start", node) - } - } - for _, b := range broker.PlanStateBuckets { - if _, err := m.js.KeyValue(ctx, b); !errors.Is(err, jetstream.ErrBucketNotFound) { - t.Errorf("%s is still there after the first start: %v", b, err) - } - } - _ = m.conn.Flush() - time.Sleep(50 * time.Millisecond) - mu.Lock() - if len(said) != len(broker.PlanStateBuckets) { - t.Errorf("the first start said %d deletions, want %d: %+v", len(said), len(broker.PlanStateBuckets), said) - } - said = nil - mu.Unlock() - - composeAtStart(ctx, m.open) - _ = m.conn.Flush() - time.Sleep(50 * time.Millisecond) - mu.Lock() - defer mu.Unlock() - if len(said) != 0 { - t.Errorf("a later start said %+v", said) - } - m.noDeclarationSent(t) -} - // 1945/AC14: the declarations' stream stays while one node's have names another node-engine than the one registered, // and goes, said once, when every node's names it (ADR 0539 clause 11). func TestTheDeclarationsStreamGoesOnceEveryNodeRunsTheNewEngine(t *testing.T) { diff --git a/cmd/mesh-controller/composed_served_integration_test.go b/cmd/mesh-controller/composed_served_integration_test.go new file mode 100644 index 00000000..d36d1cea --- /dev/null +++ b/cmd/mesh-controller/composed_served_integration_test.go @@ -0,0 +1,374 @@ +//go:build integration + +package main + +import ( + "context" + "encoding/json" + "errors" + "net/url" + "os" + "path/filepath" + "testing" + "time" + + "git.novox.be/novox/mesh-sdk/go/catalogue" + sdkdelivery "git.novox.be/novox/mesh-sdk/go/delivery" + "github.com/nats-io/nats-server/v2/server" + "github.com/nats-io/nats.go" + "github.com/nats-io/nats.go/jetstream" + "golang.org/x/crypto/bcrypt" + + "github.com/novox/mesh-controller/internal/broker" + "github.com/novox/mesh-controller/internal/builderkeys" + "github.com/novox/mesh-controller/internal/identity" + "github.com/novox/mesh-controller/internal/inventory" +) + +// The compositions through the controller whole (novox/hq ADR 0539; the review's G1): the real store, the controller's +// signing key, and a bus that runs the user list the controller composes from that store, so a subject release 6 uses +// and the list does not grant is refused as it would be on the mesh. serve() runs whole on it; the builder and the +// indexer are their own accounts writing their own keys; a rotation is the operator's verb. +type servedMesh struct { + open *stores + controller jetstream.JetStream + builder jetstream.JetStream + indexer jetstream.JetStream +} + +// keyOfManifest is a done key of a module at fp with no artifacts, its manifest the given one, and the indexer's trunk +// value naming it in the catalogue's repository at commit. +func keyOfManifest(t *testing.T, m catalogue.Manifest, fp, commit string) (raw []byte, k sdkdelivery.BuilderBuild, trunk sdkdelivery.Module) { + t.Helper() + raw, err := json.Marshal(m) + if err != nil { + t.Fatal(err) + } + digest := sdkdelivery.ManifestDigest(raw) + return raw, sdkdelivery.BuilderBuild{State: sdkdelivery.BuildDone, By: "anchor", Manifest: digest}, + sdkdelivery.Module{FP2: fp, Manifest: digest, Repository: "novox/mesh-catalog", Commit: commit, Path: "modules/" + m.Module} +} + +func aServedMesh(t *testing.T) *servedMesh { + t.Helper() + open := theCatalogue(t) + ctx := t.Context() + inv := open.inventory + withTrunkList(t, true) + ident := identity.ForTest(t) + if _, err := ident.Establish(ctx); err != nil { + t.Fatal(err) + } + + // A provider and its consumer (bindings_test.go's store and board), registered from done keys of the catalogue's + // repository, so each has a fingerprint of the new form; the store holds the mesh's seat on anchor, the board on + // laptop is bound to it. + shelf := map[string]catalogue.Manifest{} + for _, m := range storeManifests() { + shelf[m.Module] = m + } + for i, module := range []string{"store", "board"} { + raw, k, trunk := keyOfManifest(t, shelf[module], fpOf(byte('a'+i)), "c0ffee0123456789") + keys := trunkKeys{keyBuckets: keyBuckets{keys: map[string]builderkeys.Key{module: aBuilderKey(module, trunk.FP2, k)}, + manifests: map[string]string{trunk.Manifest: string(raw)}}, trunk: trunk} + if _, _, err := takeInKey(ctx, inv, keys, keyTake{Module: module, Trunk: trunk, Ask: registrationAsk{Merge: trunk.Commit}}); err != nil { + t.Fatalf("%s is not registered from its key: %v", module, err) + } + } + if _, err := inv.SeedSeats(ctx, catalogue.DefaultSeats()); err != nil { + t.Fatal(err) + } + if _, err := inv.Assign(ctx, "anchor", "store"); err != nil { + t.Fatal(err) + } + if err := inv.HoldSeat(ctx, "mesh-store", catalogue.ScopeMesh, "anchor", "store"); err != nil { + t.Fatal(err) + } + if _, err := inv.Assign(ctx, "laptop", "board"); err != nil { + t.Fatal(err) + } + if err := inv.PinProvision(ctx, "laptop", "postgres-database", "anchor", "store"); err != nil { + t.Fatal(err) + } + + // The bus, running the user list the controller composes from the store, with the builder and the indexer as the + // accounts their own manifests make (each writes its own state). + records, err := inv.BusRecords(ctx) + if err != nil { + t.Fatal(err) + } + users, err := broker.Users(records) + if err != nil { + t.Fatal(err) + } + var controller broker.Principal + for _, p := range users { + if p.Kind == broker.KindController { + controller = p + } + } + password, err := inv.MintBusPassword(ctx, inventory.BusUser{Username: controller.Username(), Kind: inventory.BusController}) + if err != nil { + t.Fatal(err) + } + builder := broker.Principal{Kind: broker.KindModule, Node: "anchor", Module: sdkdelivery.BuilderModule, State: []string{"builds"}} + indexer := broker.Principal{Kind: broker.KindModule, Node: "anchor", Module: sdkdelivery.IndexerModule, State: []string{"modules", "manifests"}} + kept, err := inv.BusUsers(ctx) + if err != nil { + t.Fatal(err) + } + hashes := map[string]string{} + for name, u := range kept { + hashes[name] = u.PasswordHash + } + fixture, _ := bcrypt.GenerateFromPassword([]byte("pw"), bcrypt.MinCost) + hashes[builder.Username()], hashes[indexer.Username()] = string(fixture), string(fixture) + filled, missing := broker.WithPasswords([]broker.Principal{controller, builder, indexer}, hashes) + if len(missing) > 0 { + t.Fatalf("no password for %v", missing) + } + accounts, err := broker.ComposeAccounts(filled) + if err != nil { + t.Fatal(err) + } + conf := filepath.Join(t.TempDir(), "bus.conf") + if err := os.WriteFile(conf, []byte("listen: 127.0.0.1:-1\njetstream { store_dir: "+`"`+t.TempDir()+`"`+" }\n"+accounts), 0o600); err != nil { + t.Fatal(err) + } + opts, err := server.ProcessConfigFile(conf) + if err != nil { + t.Fatalf("the composed user list does not parse: %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(10 * time.Second) { + t.Fatal("the bus did not come up") + } + t.Cleanup(s.Shutdown) + u, _ := url.Parse(s.ClientURL()) + u.User = url.UserPassword(controller.Username(), password) + t.Setenv(broker.NATSVar, u.String()) + + connect := func(user, pw string) jetstream.JetStream { + t.Helper() + nc, err := nats.Connect(s.ClientURL(), nats.UserInfo(user, pw), nats.CustomInboxPrefix("_INBOX."+user)) + if err != nil { + t.Fatalf("%s cannot connect: %v", user, err) + } + t.Cleanup(nc.Close) + js, err := jetstream.New(nc) + if err != nil { + t.Fatal(err) + } + return js + } + m := &servedMesh{open: open, controller: connect(controller.Username(), password), + builder: connect(builder.Username(), "pw"), indexer: connect(indexer.Username(), "pw")} + + // The builder's and the indexer's buckets as their own parts make them, there before the controller starts (its + // watch of their keys begins at its start); and the plans' buckets a controller of release 5 left. + for _, b := range []string{sdkdelivery.BuilderBuildsBucket, sdkdelivery.ModulesBucket, sdkdelivery.ManifestsBucket} { + if _, err := m.controller.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: b, History: 5}); err != nil { + t.Fatalf("the bucket %s: %v", b, err) + } + } + for _, b := range broker.PlanStateBuckets { + if _, err := m.controller.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: b}); err != nil { + t.Fatal(err) + } + } + + serving, stop := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { done <- serve(serving) }() + t.Cleanup(func() { + stop() + select { + case <-done: + case <-time.After(30 * time.Second): + t.Log("the controller did not stop within 30 s") + } + }) + // Served once both nodes have their own composition: the start's pass ran. + for _, node := range []string{"anchor", "laptop"} { + own, _ := sdkdelivery.ComposedNodeKey(node) + m.waitFor(t, done, node, own, 0) + } + return m +} + +// aBuilderKey is the builder's done key of module at fp, as the take-in reads it. +func aBuilderKey(module, fp string, b sdkdelivery.BuilderBuild) builderkeys.Key { + return builderkeys.Key{Module: module, Fingerprint: fp, Found: true, Revision: 1, Build: b} +} + +// waitFor waits until the node's composition bucket holds key at a revision above after, failing when the controller +// stops or a minute passes: a test's wait for what an event made, never a loop of the code under test. +func (m *servedMesh) waitFor(t *testing.T, done chan error, node, key string, after uint64) uint64 { + t.Helper() + bucket, _ := sdkdelivery.ComposedBucketFor(node) + deadline := time.Now().Add(time.Minute) + for time.Now().Before(deadline) { + select { + case err := <-done: + t.Fatalf("the controller stopped: %v", err) + default: + } + if kv, err := m.controller.KeyValue(t.Context(), bucket); err == nil { + if e, err := kv.Get(t.Context(), key); err == nil && e.Revision() > after { + return e.Revision() + } + } + time.Sleep(200 * time.Millisecond) + } + t.Fatalf("%s in %s was not written within a minute", key, bucket) + return 0 +} + +// revisions are a node's composition keys and their revisions. +func (m *servedMesh) revisions(t *testing.T, node string) map[string]uint64 { + t.Helper() + bucket, _ := sdkdelivery.ComposedBucketFor(node) + kv, err := m.controller.KeyValue(t.Context(), bucket) + if err != nil { + t.Fatal(err) + } + lister, err := kv.ListKeys(t.Context()) + if err != nil { + t.Fatal(err) + } + out := map[string]uint64{} + for k := range lister.Keys() { + if e, err := kv.Get(t.Context(), k); err == nil { + out[k] = e.Revision() + } + } + return out +} + +// declarationsSent is how many declarations the bus's stream of them holds: none is ever sent in release 6. +func (m *servedMesh) declarationsSent(t *testing.T) uint64 { + t.Helper() + s, err := m.controller.Stream(t.Context(), broker.DeclarationsStream) + if errors.Is(err, jetstream.ErrStreamNotFound) { + return 0 + } + if err != nil { + t.Fatal(err) + } + info, err := s.Info(t.Context()) + if err != nil { + t.Fatal(err) + } + return info.State.Msgs +} + +// 1945/AC6, AC14: started whole on a bus running its own user list, the controller composes every node and every +// assigned module, deletes the plans' buckets and says each as bucket-deleted, and sends no declaration. +func TestServedTheStartComposesEveryNodeAndDeletesThePlansBuckets(t *testing.T) { + m := aServedMesh(t) + for _, k := range []struct{ node, module string }{{"anchor", "store"}, {"laptop", "board"}} { + fp := map[string]string{"store": fpOf('a'), "board": fpOf('b')}[k.module] + key, _ := sdkdelivery.ComposedKey(k.node, k.module, fp) + m.waitFor(t, nil, k.node, key, 0) + } + for _, b := range broker.PlanStateBuckets { + if _, err := m.controller.KeyValue(t.Context(), b); !errors.Is(err, jetstream.ErrBucketNotFound) { + t.Errorf("%s is still there: %v", b, err) + } + } + if n := m.declarationsSent(t); n != 0 { + t.Errorf("%d declarations were sent", n) + } +} + +// 1945/AC3: the builder writes the done key of a new fingerprint of an assigned module and the indexer its trunk +// value: the controller, hearing the keys, registers it, writes its composition on the node it is assigned to and +// `grants..`; nothing is sent. +func TestServedADoneKeyIsComposedAndGranted(t *testing.T) { + m := aServedMesh(t) + ctx := t.Context() + shelf := map[string]catalogue.Manifest{} + for _, mf := range storeManifests() { + shelf[mf.Module] = mf + } + next := shelf["board"] + next.Version = "2" + fp := fpOf('n') + raw, k, trunk := keyOfManifest(t, next, fp, "d00d0123456789ab") + manifests, err := m.indexer.KeyValue(ctx, sdkdelivery.ManifestsBucket) + if err != nil { + t.Fatal(err) + } + value, _ := sdkdelivery.EncodeManifest(sdkdelivery.Manifest{Module: "board", JSON: string(raw)}) + if _, err := manifests.Put(ctx, trunk.Manifest, value); err != nil { + t.Fatalf("the indexer cannot write its manifest: %v", err) + } + builds, err := m.builder.KeyValue(ctx, sdkdelivery.BuilderBuildsBucket) + if err != nil { + t.Fatal(err) + } + key, _ := sdkdelivery.BuilderBuildKey("board", fp) + value, _ = sdkdelivery.EncodeBuilderBuild(k) + if _, err := builds.Put(ctx, key, value); err != nil { + t.Fatalf("the builder cannot write its key: %v", err) + } + modules, err := m.indexer.KeyValue(ctx, sdkdelivery.ModulesBucket) + if err != nil { + t.Fatal(err) + } + mk, _ := sdkdelivery.ModulesKey("board") + value, _ = sdkdelivery.EncodeModule(trunk) + if _, err := modules.Put(ctx, mk, value); err != nil { + t.Fatalf("the indexer cannot write its trunk value: %v", err) + } + composed, _ := sdkdelivery.ComposedKey("laptop", "board", fp) + m.waitFor(t, nil, "laptop", composed, 0) + grants, err := m.controller.KeyValue(ctx, broker.GrantsBucket) + if err != nil { + t.Fatal(err) + } + gk, _ := sdkdelivery.ControllerGrantKey("board", fp) + if _, err := grants.Get(ctx, gk); err != nil { + t.Errorf("grants.%s is not written: %v", gk, err) + } + if n := m.declarationsSent(t); n != 0 { + t.Errorf("%d declarations were sent", n) + } +} + +// 1945/AC2: the operator rotates the store's credential, the real verb: the compositions that carry it (the board's on +// laptop, the store's grants on anchor) get a new revision, every other composition keeps its own, and nothing is sent. +func TestServedARotationRewritesOnlyTheCompositionsThatUseIt(t *testing.T) { + m := aServedMesh(t) + before := map[string]map[string]uint64{"anchor": m.revisions(t, "anchor"), "laptop": m.revisions(t, "laptop")} + if err := rotateCommand(t.Context(), []string{"postgres-database"}); err != nil { + t.Fatalf("rotate: %v", err) + } + boardKey, _ := sdkdelivery.ComposedKey("laptop", "board", fpOf('b')) + m.waitFor(t, nil, "laptop", boardKey, before["laptop"][boardKey]) + moved := map[string]bool{} + for node, was := range before { + for k, rev := range m.revisions(t, node) { + if rev != was[k] { + moved[k] = true + } + } + } + storeKey, _ := sdkdelivery.ComposedKey("anchor", "store", fpOf('a')) + for k := range moved { + if k != boardKey && k != storeKey { + t.Errorf("%s moved: only the compositions carrying the credential do", k) + } + } + if !moved[boardKey] { + t.Errorf("the board's composition did not move: %v", moved) + } + if n := m.declarationsSent(t); n != 0 { + t.Errorf("%d declarations were sent", n) + } +}