Split the unit level from the integration level in cmd, builder, link, catalogue, inventory; cheap fixtures for the archive-entry bound, the registry waits and the catalogue copy (issue 895)

This commit is contained in:
2026-10-17 21:32:23 +02:00
parent aac4bae5a4
commit 66dc501fc4
438 changed files with 22014 additions and 19825 deletions
+2
View File
@@ -1,3 +1,5 @@
//go:build integration
package inventory
import (
+2
View File
@@ -1,3 +1,5 @@
//go:build integration
package inventory
import (
+2
View File
@@ -1,3 +1,5 @@
//go:build integration
package inventory
import (
+2
View File
@@ -1,3 +1,5 @@
//go:build integration
package inventory
import (
+2
View File
@@ -1,3 +1,5 @@
//go:build integration
package inventory
import (
@@ -0,0 +1,316 @@
//go:build integration
package inventory
import (
"context"
"strings"
"testing"
"time"
"github.com/novox/mesh-controller/internal/broker"
"github.com/novox/mesh-controller/internal/catalogue"
"golang.org/x/crypto/bcrypt"
)
func aMeshWith(t *testing.T, manifests ...catalogue.Manifest) (*Inventory, context.Context) {
t.Helper()
inv := ForTest(t)
ctx := context.Background()
for _, m := range manifests {
if err := inv.RegisterModule(ctx, m, Source{Repository: "/r"}); err != nil {
t.Fatal(err)
}
}
return inv, ctx
}
func TestAnAssignedModuleBecomesAUserWithWhatItDeclared(t *testing.T) {
// It declares where its account is delivered: a module with no own secret named broker can never
// be issued one, and is no user at all (novox/hq issue 195) — the case asserted below.
shop := catalogue.Manifest{
Module: "shop", Version: "1",
Emits: []string{"order.placed"}, Tools: []string{"price"},
Uses: []string{"telegram-sender"},
OwnSecrets: catalogue.OwnSecrets{"broker": {Path: "/run/broker"}},
}
quiet := catalogue.Manifest{Module: "quiet", Version: "1", Emits: []string{"thing.happened"}}
inv, ctx := aMeshWith(t, theSeatDeclarer(), shop, quiet)
if _, err := inv.AddNode(ctx, "one"); err != nil {
t.Fatal(err)
}
for _, module := range []string{"shop", "quiet"} {
if _, err := inv.Assign(ctx, "one", module); err != nil {
t.Fatal(err)
}
}
records, err := inv.BusRecords(ctx)
if err != nil {
t.Fatal(err)
}
var on []broker.Declared
for _, d := range records.Assigned["one"] {
if d.Module == "shop" {
on = append(on, d)
}
}
if len(on) != 1 || on[0].NoAccount {
t.Fatalf("the machine's modules read as %+v", records.Assigned["one"])
}
if len(on[0].Uses) != 1 || on[0].Uses[0].Accepts[0] != "send" {
t.Fatalf("the seat it uses carries no protocol: %+v — so it would be granted nothing on a "+
"seat it was assigned to send to", on[0].Uses)
}
if len(on[0].Serves) != 1 || on[0].Serves[0] != "price" {
t.Fatalf("its tools read as %v, and a module that cannot subscribe its own tool subject "+
"serves nothing", on[0].Serves)
}
// And it derives into a user the server would accept.
users, err := broker.Users(records)
if err != nil {
t.Fatal(err)
}
var found bool
for _, u := range users {
if u.Username() != "one.shop" {
continue
}
found = true
perms, err := broker.PermissionsFor(u)
if err != nil {
t.Fatal(err)
}
// Its tools are every one under its own name — the list in the manifest is a person's
// vocabulary for asking, not the module's permission to answer.
if !granted(perms.Publish, "mesh.mod.shop.event.order.placed") ||
!granted(perms.Publish, "mesh.seat.telegram-sender.accept.send") ||
!granted(perms.Subscribe, "mesh.mod.shop.tool.>") {
t.Fatalf("one.shop's authority is not what it declared: %+v", perms)
}
}
if !found {
t.Fatal("no user was derived for the assigned module")
}
for _, u := range users {
if u.Username() == "one.quiet" {
t.Fatal("a module with nowhere to read an account was made a user (novox/hq issue 195)")
}
}
}
func TestOnlyAMachineWithALiveTokenHasAnEnrolmentUser(t *testing.T) {
inv, ctx := aMeshWith(t)
for _, name := range []string{"live", "expired", "none"} {
if _, err := inv.AddNode(ctx, name); err != nil {
t.Fatal(err)
}
}
if _, err := inv.IssueToken(ctx, "live", time.Hour); err != nil {
t.Fatal(err)
}
// Briefly, then waited out: a token with no lifetime is refused at issue, which is the right
// refusal and leaves this as the way to have an expired one.
if _, err := inv.IssueToken(ctx, "expired", 10*time.Millisecond); err != nil {
t.Fatal(err)
}
time.Sleep(50 * time.Millisecond)
records, err := inv.BusRecords(ctx)
if err != nil {
t.Fatal(err)
}
if strings.Join(records.Enrolling, ",") != "live" {
t.Fatalf("machines with a live token read as %v", records.Enrolling)
}
}
func TestIssuingATokenRecordsTheAccountItIsThePasswordOf(t *testing.T) {
inv, ctx := aMeshWith(t)
if _, err := inv.AddNode(ctx, "joiner"); err != nil {
t.Fatal(err)
}
issued, err := inv.IssueToken(ctx, "joiner", time.Hour)
if err != nil {
t.Fatal(err)
}
name := broker.Principal{Kind: broker.KindEnrolment, Node: "joiner"}.Username()
users, err := inv.BusUsers(ctx)
if err != nil {
t.Fatal(err)
}
user, has := users[name]
if !has {
t.Fatalf("no bus account for %q; the composer would leave the enrolment out and the "+
"machine would be refused before the mesh heard of it: %v", name, users)
}
if user.Kind != BusEnrolment || user.Node != "joiner" {
t.Errorf("the account is %+v, not this node's enrolment", user)
}
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(issued.Secret)); err != nil {
t.Error("the account does not accept the secret the token carries, so presenting the " +
"token would be refused by the server")
}
// And the composition contains it, which is the thing the server reads.
records, err := inv.BusRecords(ctx)
if err != nil {
t.Fatal(err)
}
derived, err := broker.Users(records)
if err != nil {
t.Fatal(err)
}
hashes := map[string]string{}
for n, u := range users {
hashes[n] = u.PasswordHash
}
_, missing := broker.WithPasswords(derived, hashes)
for _, m := range missing {
if m == name {
t.Fatal("the enrolment user is composed without a password, which is a user nobody can be")
}
}
}
func TestTheBussOwnModuleBecomesTheSnapshotUser(t *testing.T) {
bus := catalogue.Manifest{
Module: "nats", Version: "1",
Provides: []catalogue.Offer{{Name: "mesh-bus", Scope: catalogue.ScopeMesh}},
Claims: []catalogue.Claim{{Name: catalogue.BrokerSeat, Scope: catalogue.ScopeMesh}},
Tools: []string{"nats_streams"},
OwnSecrets: catalogue.OwnSecrets{"broker": {Path: "/run/broker"}},
}
shop := catalogue.Manifest{Module: "shop", Version: "1", Emits: []string{"order.placed"},
OwnSecrets: catalogue.OwnSecrets{"broker": {Path: "/run/broker"}}}
inv, ctx := aMeshWith(t, bus, shop)
if _, err := inv.AddNode(ctx, "one"); err != nil {
t.Fatal(err)
}
for _, module := range []string{"nats", "shop"} {
if _, err := inv.Assign(ctx, "one", module); err != nil {
t.Fatal(err)
}
}
records, err := inv.BusRecords(ctx)
if err != nil {
t.Fatal(err)
}
users, err := broker.Users(records)
if err != nil {
t.Fatal(err)
}
seen := map[string]bool{}
for _, u := range users {
perms, err := broker.PermissionsFor(u)
if err != nil {
t.Fatal(err)
}
snapshots := granted(perms.Publish, "$JS.API.STREAM.SNAPSHOT.*")
switch u.Username() {
case "one.nats":
seen[u.Username()] = true
if !snapshots || len(perms.Publish) != len(broker.BusSnapshotGrants.Publish) {
t.Fatalf("the bus's own user is granted %v, not the snapshot API alone", perms.Publish)
}
case "one.shop":
seen[u.Username()] = true
if snapshots {
t.Fatal("a module that is not the bus may snapshot it")
}
}
}
if !seen["one.nats"] || !seen["one.shop"] {
t.Fatalf("users derived: %v", seen)
}
}
func TestABarIsGrantedTheStateItsMachinesModulesOfferIt(t *testing.T) {
bar := catalogue.Manifest{Module: "a-bar", Version: "1",
Claims: []catalogue.Claim{{Name: catalogue.BarSeat, Scope: catalogue.ScopeNode}}}
power := catalogue.Manifest{Module: "power", Version: "1",
State: []catalogue.StateDeclaration{{Name: "draw"}},
Contributions: []catalogue.SeatContribution{{Seat: catalogue.BarSeat, Kind: catalogue.BarKindBlock,
Data: map[string]any{"bar": "bottom", "place": "status", "shows": "state",
"options": map[string]any{"state": "power.draw"}}}}}
inv, ctx := aMeshWith(t, bar, power)
for node, modules := range map[string][]string{"laptop": {"a-bar", "power"}, "desk": {"a-bar"}} {
if _, err := inv.AddNode(ctx, node); err != nil {
t.Fatal(err)
}
for _, module := range modules {
if _, err := inv.Assign(ctx, node, module); err != nil {
t.Fatal(err)
}
}
}
records, err := inv.BusRecords(ctx)
if err != nil {
t.Fatal(err)
}
reads := func(node, module string) []broker.KeyedRead {
for _, d := range records.Assigned[node] {
if d.Module == module {
if len(d.Reads) != 0 {
t.Fatalf("%s on %s reads whole buckets: %v", module, node, d.Reads)
}
return d.KeyedReads
}
}
t.Fatalf("%s is not on %s", module, node)
return nil
}
// The laptop's own key, and no other (novox/hq ADR 0260).
if got := reads("laptop", "a-bar"); len(got) != 1 || got[0] != (broker.KeyedRead{Read: "power.draw", Key: "laptop"}) {
t.Fatalf("the laptop's bar reads %v", got)
}
if got := reads("desk", "a-bar"); len(got) != 0 {
t.Fatalf("a bar with no power beside it reads %v", got)
}
if got := reads("laptop", "power"); len(got) != 0 {
t.Fatalf("power reads %v", got)
}
}
func TestTheProvisionerClientsMachineHasAnAccountOfItsOwn(t *testing.T) {
provisioner := catalogue.Manifest{
Module: "mesh-provisioner", Version: "1",
DefinesSeats: []catalogue.SeatDeclaration{{Name: broker.NodeProvisionerSeat, Scope: catalogue.ScopeNode,
Serves: []catalogue.Verb{{Name: "get"}}}},
Claims: []catalogue.Claim{{Name: broker.NodeProvisionerSeat, Scope: catalogue.ScopeNode}},
OwnSecrets: catalogue.OwnSecrets{"broker": {Path: "/run/broker"},
broker.ProvisionerSecret: {Path: "/run/provisioner-broker"}},
}
unread := provisioner
unread.Module = "provisioner-unread"
unread.DefinesSeats = nil
unread.OwnSecrets = catalogue.OwnSecrets{"broker": {Path: "/run/broker"}}
inv, ctx := aMeshWith(t, provisioner, unread)
for node, module := range map[string]string{"one": "mesh-provisioner", "two": "provisioner-unread"} {
if _, err := inv.AddNode(ctx, node); err != nil {
t.Fatal(err)
}
if _, err := inv.Assign(ctx, node, module); err != nil {
t.Fatal(err)
}
}
records, err := inv.BusRecords(ctx)
if err != nil {
t.Fatal(err)
}
users, err := broker.Users(records)
if err != nil {
t.Fatal(err)
}
var clients []string
for _, u := range users {
if u.Kind == broker.KindProvisioner {
clients = append(clients, u.Username()+" for "+u.Module)
}
}
if strings.Join(clients, ",") != "provisioner.one for mesh-provisioner" {
t.Fatalf("the provisioner clients' accounts are %v, want one for the machine whose module reads it", clients)
}
}
-299
View File
@@ -1,14 +1,11 @@
package inventory
import (
"context"
"strings"
"testing"
"time"
"github.com/novox/mesh-controller/internal/broker"
"github.com/novox/mesh-controller/internal/catalogue"
"golang.org/x/crypto/bcrypt"
)
// Reading the bus's user list out of the mesh's records, against a real store.
@@ -17,18 +14,6 @@ import (
// the server reads whatever file it is given, and a module whose user is absent fails on its first
// publish with an authorisation error that says nothing about a missing assignment.
func aMeshWith(t *testing.T, manifests ...catalogue.Manifest) (*Inventory, context.Context) {
t.Helper()
inv := ForTest(t)
ctx := context.Background()
for _, m := range manifests {
if err := inv.RegisterModule(ctx, m, Source{Repository: "/r"}); err != nil {
t.Fatal(err)
}
}
return inv, ctx
}
func theSeatDeclarer() catalogue.Manifest {
return catalogue.Manifest{
Module: "telegram", Version: "1",
@@ -41,108 +26,9 @@ func theSeatDeclarer() catalogue.Manifest {
// A module assigned to a machine becomes a user with the authority its manifest declared — and the
// protocol of a seat declared by a *different* module, which is the whole reason a seat exists.
func TestAnAssignedModuleBecomesAUserWithWhatItDeclared(t *testing.T) {
// It declares where its account is delivered: a module with no own secret named broker can never
// be issued one, and is no user at all (novox/hq issue 195) — the case asserted below.
shop := catalogue.Manifest{
Module: "shop", Version: "1",
Emits: []string{"order.placed"}, Tools: []string{"price"},
Uses: []string{"telegram-sender"},
OwnSecrets: catalogue.OwnSecrets{"broker": {Path: "/run/broker"}},
}
quiet := catalogue.Manifest{Module: "quiet", Version: "1", Emits: []string{"thing.happened"}}
inv, ctx := aMeshWith(t, theSeatDeclarer(), shop, quiet)
if _, err := inv.AddNode(ctx, "one"); err != nil {
t.Fatal(err)
}
for _, module := range []string{"shop", "quiet"} {
if _, err := inv.Assign(ctx, "one", module); err != nil {
t.Fatal(err)
}
}
records, err := inv.BusRecords(ctx)
if err != nil {
t.Fatal(err)
}
var on []broker.Declared
for _, d := range records.Assigned["one"] {
if d.Module == "shop" {
on = append(on, d)
}
}
if len(on) != 1 || on[0].NoAccount {
t.Fatalf("the machine's modules read as %+v", records.Assigned["one"])
}
if len(on[0].Uses) != 1 || on[0].Uses[0].Accepts[0] != "send" {
t.Fatalf("the seat it uses carries no protocol: %+v — so it would be granted nothing on a "+
"seat it was assigned to send to", on[0].Uses)
}
if len(on[0].Serves) != 1 || on[0].Serves[0] != "price" {
t.Fatalf("its tools read as %v, and a module that cannot subscribe its own tool subject "+
"serves nothing", on[0].Serves)
}
// And it derives into a user the server would accept.
users, err := broker.Users(records)
if err != nil {
t.Fatal(err)
}
var found bool
for _, u := range users {
if u.Username() != "one.shop" {
continue
}
found = true
perms, err := broker.PermissionsFor(u)
if err != nil {
t.Fatal(err)
}
// Its tools are every one under its own name — the list in the manifest is a person's
// vocabulary for asking, not the module's permission to answer.
if !granted(perms.Publish, "mesh.mod.shop.event.order.placed") ||
!granted(perms.Publish, "mesh.seat.telegram-sender.accept.send") ||
!granted(perms.Subscribe, "mesh.mod.shop.tool.>") {
t.Fatalf("one.shop's authority is not what it declared: %+v", perms)
}
}
if !found {
t.Fatal("no user was derived for the assigned module")
}
for _, u := range users {
if u.Username() == "one.quiet" {
t.Fatal("a module with nowhere to read an account was made a user (novox/hq issue 195)")
}
}
}
// A machine holding a live token gets an enrolment user; one whose token is spent or expired does
// not. **An enrolment user outliving its token is a right to join that nobody issued.**
func TestOnlyAMachineWithALiveTokenHasAnEnrolmentUser(t *testing.T) {
inv, ctx := aMeshWith(t)
for _, name := range []string{"live", "expired", "none"} {
if _, err := inv.AddNode(ctx, name); err != nil {
t.Fatal(err)
}
}
if _, err := inv.IssueToken(ctx, "live", time.Hour); err != nil {
t.Fatal(err)
}
// Briefly, then waited out: a token with no lifetime is refused at issue, which is the right
// refusal and leaves this as the way to have an expired one.
if _, err := inv.IssueToken(ctx, "expired", 10*time.Millisecond); err != nil {
t.Fatal(err)
}
time.Sleep(50 * time.Millisecond)
records, err := inv.BusRecords(ctx)
if err != nil {
t.Fatal(err)
}
if strings.Join(records.Enrolling, ",") != "live" {
t.Fatalf("machines with a live token read as %v", records.Enrolling)
}
}
// A module assigned and absent from the catalogue is refused rather than composed with no authority.
//
@@ -193,199 +79,14 @@ func granted(all []string, one string) bool {
// exactly that string. So this checks the two halves that make the account usable — that a row
// exists under the name the composer asks for, and that the secret handed out is what that row
// accepts.
func TestIssuingATokenRecordsTheAccountItIsThePasswordOf(t *testing.T) {
inv, ctx := aMeshWith(t)
if _, err := inv.AddNode(ctx, "joiner"); err != nil {
t.Fatal(err)
}
issued, err := inv.IssueToken(ctx, "joiner", time.Hour)
if err != nil {
t.Fatal(err)
}
name := broker.Principal{Kind: broker.KindEnrolment, Node: "joiner"}.Username()
users, err := inv.BusUsers(ctx)
if err != nil {
t.Fatal(err)
}
user, has := users[name]
if !has {
t.Fatalf("no bus account for %q; the composer would leave the enrolment out and the "+
"machine would be refused before the mesh heard of it: %v", name, users)
}
if user.Kind != BusEnrolment || user.Node != "joiner" {
t.Errorf("the account is %+v, not this node's enrolment", user)
}
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(issued.Secret)); err != nil {
t.Error("the account does not accept the secret the token carries, so presenting the " +
"token would be refused by the server")
}
// And the composition contains it, which is the thing the server reads.
records, err := inv.BusRecords(ctx)
if err != nil {
t.Fatal(err)
}
derived, err := broker.Users(records)
if err != nil {
t.Fatal(err)
}
hashes := map[string]string{}
for n, u := range users {
hashes[n] = u.PasswordHash
}
_, missing := broker.WithPasswords(derived, hashes)
for _, m := range missing {
if m == name {
t.Fatal("the enrolment user is composed without a password, which is a user nobody can be")
}
}
}
// The module holding mesh-broker is the bus, and its user is the bus's own: granted the snapshot API
// for the night's backup and nothing else, whatever tools it declares (novox/hq ADR 0235). Another
// module on the same machine is granted none of it.
func TestTheBussOwnModuleBecomesTheSnapshotUser(t *testing.T) {
bus := catalogue.Manifest{
Module: "nats", Version: "1",
Provides: []catalogue.Offer{{Name: "mesh-bus", Scope: catalogue.ScopeMesh}},
Claims: []catalogue.Claim{{Name: catalogue.BrokerSeat, Scope: catalogue.ScopeMesh}},
Tools: []string{"nats_streams"},
OwnSecrets: catalogue.OwnSecrets{"broker": {Path: "/run/broker"}},
}
shop := catalogue.Manifest{Module: "shop", Version: "1", Emits: []string{"order.placed"},
OwnSecrets: catalogue.OwnSecrets{"broker": {Path: "/run/broker"}}}
inv, ctx := aMeshWith(t, bus, shop)
if _, err := inv.AddNode(ctx, "one"); err != nil {
t.Fatal(err)
}
for _, module := range []string{"nats", "shop"} {
if _, err := inv.Assign(ctx, "one", module); err != nil {
t.Fatal(err)
}
}
records, err := inv.BusRecords(ctx)
if err != nil {
t.Fatal(err)
}
users, err := broker.Users(records)
if err != nil {
t.Fatal(err)
}
seen := map[string]bool{}
for _, u := range users {
perms, err := broker.PermissionsFor(u)
if err != nil {
t.Fatal(err)
}
snapshots := granted(perms.Publish, "$JS.API.STREAM.SNAPSHOT.*")
switch u.Username() {
case "one.nats":
seen[u.Username()] = true
if !snapshots || len(perms.Publish) != len(broker.BusSnapshotGrants.Publish) {
t.Fatalf("the bus's own user is granted %v, not the snapshot API alone", perms.Publish)
}
case "one.shop":
seen[u.Username()] = true
if snapshots {
t.Fatal("a module that is not the bus may snapshot it")
}
}
}
if !seen["one.nats"] || !seen["one.shop"] {
t.Fatalf("users derived: %v", seen)
}
}
// The bar's holder reads the state the modules beside it offer in its blocks (novox/hq ADR 0255),
// without naming them: granted on the machine where both are, and nowhere else.
func TestABarIsGrantedTheStateItsMachinesModulesOfferIt(t *testing.T) {
bar := catalogue.Manifest{Module: "a-bar", Version: "1",
Claims: []catalogue.Claim{{Name: catalogue.BarSeat, Scope: catalogue.ScopeNode}}}
power := catalogue.Manifest{Module: "power", Version: "1",
State: []catalogue.StateDeclaration{{Name: "draw"}},
Contributions: []catalogue.SeatContribution{{Seat: catalogue.BarSeat, Kind: catalogue.BarKindBlock,
Data: map[string]any{"bar": "bottom", "place": "status", "shows": "state",
"options": map[string]any{"state": "power.draw"}}}}}
inv, ctx := aMeshWith(t, bar, power)
for node, modules := range map[string][]string{"laptop": {"a-bar", "power"}, "desk": {"a-bar"}} {
if _, err := inv.AddNode(ctx, node); err != nil {
t.Fatal(err)
}
for _, module := range modules {
if _, err := inv.Assign(ctx, node, module); err != nil {
t.Fatal(err)
}
}
}
records, err := inv.BusRecords(ctx)
if err != nil {
t.Fatal(err)
}
reads := func(node, module string) []broker.KeyedRead {
for _, d := range records.Assigned[node] {
if d.Module == module {
if len(d.Reads) != 0 {
t.Fatalf("%s on %s reads whole buckets: %v", module, node, d.Reads)
}
return d.KeyedReads
}
}
t.Fatalf("%s is not on %s", module, node)
return nil
}
// The laptop's own key, and no other (novox/hq ADR 0260).
if got := reads("laptop", "a-bar"); len(got) != 1 || got[0] != (broker.KeyedRead{Read: "power.draw", Key: "laptop"}) {
t.Fatalf("the laptop's bar reads %v", got)
}
if got := reads("desk", "a-bar"); len(got) != 0 {
t.Fatalf("a bar with no power beside it reads %v", got)
}
if got := reads("laptop", "power"); len(got) != 0 {
t.Fatalf("power reads %v", got)
}
}
// A module running the machine's provisioner client gives that machine a client account of its own, apart from the
// module's and the tool runner's (novox/hq ADR 0349 clause 11, design 57 P3); one with nowhere to read the account
// gives none.
func TestTheProvisionerClientsMachineHasAnAccountOfItsOwn(t *testing.T) {
provisioner := catalogue.Manifest{
Module: "mesh-provisioner", Version: "1",
DefinesSeats: []catalogue.SeatDeclaration{{Name: broker.NodeProvisionerSeat, Scope: catalogue.ScopeNode,
Serves: []catalogue.Verb{{Name: "get"}}}},
Claims: []catalogue.Claim{{Name: broker.NodeProvisionerSeat, Scope: catalogue.ScopeNode}},
OwnSecrets: catalogue.OwnSecrets{"broker": {Path: "/run/broker"},
broker.ProvisionerSecret: {Path: "/run/provisioner-broker"}},
}
unread := provisioner
unread.Module = "provisioner-unread"
unread.DefinesSeats = nil
unread.OwnSecrets = catalogue.OwnSecrets{"broker": {Path: "/run/broker"}}
inv, ctx := aMeshWith(t, provisioner, unread)
for node, module := range map[string]string{"one": "mesh-provisioner", "two": "provisioner-unread"} {
if _, err := inv.AddNode(ctx, node); err != nil {
t.Fatal(err)
}
if _, err := inv.Assign(ctx, node, module); err != nil {
t.Fatal(err)
}
}
records, err := inv.BusRecords(ctx)
if err != nil {
t.Fatal(err)
}
users, err := broker.Users(records)
if err != nil {
t.Fatal(err)
}
var clients []string
for _, u := range users {
if u.Kind == broker.KindProvisioner {
clients = append(clients, u.Username()+" for "+u.Module)
}
}
if strings.Join(clients, ",") != "provisioner.one for mesh-provisioner" {
t.Fatalf("the provisioner clients' accounts are %v, want one for the machine whose module reads it", clients)
}
}
@@ -0,0 +1,143 @@
//go:build integration
package inventory
import (
"context"
"testing"
"golang.org/x/crypto/bcrypt"
)
func TestABusPasswordIsReturnedOnceAndOnlyItsHashIsKept(t *testing.T) {
inv := ForTest(t)
ctx := context.Background()
password, err := inv.MintBusPassword(ctx, aBusUser("shop"))
if err != nil {
t.Fatal(err)
}
if password == "" {
t.Fatal("no password came back, so nothing can be sealed to the module")
}
hash, known, err := inv.BusUserHash(ctx, "one.shop")
if err != nil || !known {
t.Fatalf("the user was not recorded: %v %v", known, err)
}
if hash == password {
t.Fatal("the store holds the password itself")
}
if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)); err != nil {
t.Fatalf("the recorded hash does not verify the password it was made from: %v", err)
}
}
func TestMintingAgainRotatesRatherThanAddsAUser(t *testing.T) {
inv := ForTest(t)
ctx := context.Background()
first, err := inv.MintBusPassword(ctx, aBusUser("shop"))
if err != nil {
t.Fatal(err)
}
second, err := inv.MintBusPassword(ctx, aBusUser("shop"))
if err != nil {
t.Fatal(err)
}
if first == second {
t.Fatal("minting twice produced the same password")
}
users, err := inv.BusUsers(ctx)
if err != nil {
t.Fatal(err)
}
if len(users) != 1 {
t.Fatalf("%d users after two mints for one name", len(users))
}
hash := users["one.shop"].PasswordHash
if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(second)); err != nil {
t.Fatal("the kept hash is not the newest password's")
}
if bcrypt.CompareHashAndPassword([]byte(hash), []byte(first)) == nil {
t.Fatal("the previous password still verifies, so a rotation revoked nothing")
}
}
func TestForgettingANodeTakesItsBusUsersWithIt(t *testing.T) {
inv := ForTest(t)
ctx := context.Background()
for _, u := range []BusUser{
{Username: "node.one", Kind: BusNode, Node: "one"},
aBusUser("shop"),
{Username: "node.two", Kind: BusNode, Node: "two"},
{Username: "controller", Kind: BusController},
} {
if _, err := inv.MintBusPassword(ctx, u); err != nil {
t.Fatal(err)
}
}
if err := inv.ForgetBusUsersOf(ctx, "one"); err != nil {
t.Fatal(err)
}
users, err := inv.BusUsers(ctx)
if err != nil {
t.Fatal(err)
}
if _, still := users["node.one"]; still {
t.Fatal("a forgotten node's host credential still works")
}
if _, still := users["one.shop"]; still {
t.Fatal("a module on a forgotten node still has a credential")
}
// And nothing else went with it: the controller has no node, and another machine's user is
// another machine's.
for _, kept := range []string{"node.two", "controller"} {
if _, ok := users[kept]; !ok {
t.Fatalf("%s was removed with another node's users", kept)
}
}
}
func TestForgettingTheUsersOfNoNodeIsRefused(t *testing.T) {
inv := ForTest(t)
if err := inv.ForgetBusUsersOf(context.Background(), ""); err == nil {
t.Fatal("forgetting the bus users of no node was allowed")
}
}
func TestACredentialTheMeshDidNotMintIsRecordedOnceAndNotOverwritten(t *testing.T) {
inv := ForTest(t)
ctx := context.Background()
if err := inv.SeedBusUser(ctx, BusUser{Username: "controller", Kind: BusController},
"bootstrap"); err != nil {
t.Fatal(err)
}
hash, known, err := inv.BusUserHash(ctx, "controller")
if err != nil || !known {
t.Fatalf("the credential was not recorded: %v %v", known, err)
}
if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte("bootstrap")); err != nil {
t.Fatalf("what was recorded does not verify the credential given: %v", err)
}
// Minted since, then seeded again — which is what a restart does. The rotation must stand, or
// every restart would put the bootstrap password back over it.
minted, err := inv.MintBusPassword(ctx, BusUser{Username: "controller", Kind: BusController})
if err != nil {
t.Fatal(err)
}
if err := inv.SeedBusUser(ctx, BusUser{Username: "controller", Kind: BusController},
"bootstrap"); err != nil {
t.Fatal(err)
}
hash, _, err = inv.BusUserHash(ctx, "controller")
if err != nil {
t.Fatal(err)
}
if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(minted)); err != nil {
t.Fatal("a restart put the bootstrap credential back over a rotated one")
}
}
-129
View File
@@ -1,7 +1,6 @@
package inventory
import (
"context"
"testing"
"golang.org/x/crypto/bcrypt"
@@ -17,147 +16,19 @@ func aBusUser(module string) BusUser {
// The plaintext comes back once and the store keeps only a hash that verifies against it. **A
// credential recoverable from the mesh's store is one whose blast radius is the store's**, so what
// is asserted is that the password is not in there.
func TestABusPasswordIsReturnedOnceAndOnlyItsHashIsKept(t *testing.T) {
inv := ForTest(t)
ctx := context.Background()
password, err := inv.MintBusPassword(ctx, aBusUser("shop"))
if err != nil {
t.Fatal(err)
}
if password == "" {
t.Fatal("no password came back, so nothing can be sealed to the module")
}
hash, known, err := inv.BusUserHash(ctx, "one.shop")
if err != nil || !known {
t.Fatalf("the user was not recorded: %v %v", known, err)
}
if hash == password {
t.Fatal("the store holds the password itself")
}
if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)); err != nil {
t.Fatalf("the recorded hash does not verify the password it was made from: %v", err)
}
}
// Minting again replaces what was there rather than failing or adding a second row: that is a
// rotation, and the old credential stops working at the next composition.
func TestMintingAgainRotatesRatherThanAddsAUser(t *testing.T) {
inv := ForTest(t)
ctx := context.Background()
first, err := inv.MintBusPassword(ctx, aBusUser("shop"))
if err != nil {
t.Fatal(err)
}
second, err := inv.MintBusPassword(ctx, aBusUser("shop"))
if err != nil {
t.Fatal(err)
}
if first == second {
t.Fatal("minting twice produced the same password")
}
users, err := inv.BusUsers(ctx)
if err != nil {
t.Fatal(err)
}
if len(users) != 1 {
t.Fatalf("%d users after two mints for one name", len(users))
}
hash := users["one.shop"].PasswordHash
if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(second)); err != nil {
t.Fatal("the kept hash is not the newest password's")
}
if bcrypt.CompareHashAndPassword([]byte(hash), []byte(first)) == nil {
t.Fatal("the previous password still verifies, so a rotation revoked nothing")
}
}
// Forgetting a node takes every credential that belonged to it — its host's and every module
// assigned to it. What a forgotten node leaves behind otherwise is a working set of credentials for
// a machine the mesh no longer knows.
func TestForgettingANodeTakesItsBusUsersWithIt(t *testing.T) {
inv := ForTest(t)
ctx := context.Background()
for _, u := range []BusUser{
{Username: "node.one", Kind: BusNode, Node: "one"},
aBusUser("shop"),
{Username: "node.two", Kind: BusNode, Node: "two"},
{Username: "controller", Kind: BusController},
} {
if _, err := inv.MintBusPassword(ctx, u); err != nil {
t.Fatal(err)
}
}
if err := inv.ForgetBusUsersOf(ctx, "one"); err != nil {
t.Fatal(err)
}
users, err := inv.BusUsers(ctx)
if err != nil {
t.Fatal(err)
}
if _, still := users["node.one"]; still {
t.Fatal("a forgotten node's host credential still works")
}
if _, still := users["one.shop"]; still {
t.Fatal("a module on a forgotten node still has a credential")
}
// And nothing else went with it: the controller has no node, and another machine's user is
// another machine's.
for _, kept := range []string{"node.two", "controller"} {
if _, ok := users[kept]; !ok {
t.Fatalf("%s was removed with another node's users", kept)
}
}
}
// Forgetting the users of no node would forget every user that has none — the controller and every
// person — so it is refused rather than run.
func TestForgettingTheUsersOfNoNodeIsRefused(t *testing.T) {
inv := ForTest(t)
if err := inv.ForgetBusUsersOf(context.Background(), ""); err == nil {
t.Fatal("forgetting the bus users of no node was allowed")
}
}
// The controller's own user is created by the installer, so the mesh has to be able to record a
// credential it did not mint — or the first composition leaves the writer out of the file it writes.
func TestACredentialTheMeshDidNotMintIsRecordedOnceAndNotOverwritten(t *testing.T) {
inv := ForTest(t)
ctx := context.Background()
if err := inv.SeedBusUser(ctx, BusUser{Username: "controller", Kind: BusController},
"bootstrap"); err != nil {
t.Fatal(err)
}
hash, known, err := inv.BusUserHash(ctx, "controller")
if err != nil || !known {
t.Fatalf("the credential was not recorded: %v %v", known, err)
}
if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte("bootstrap")); err != nil {
t.Fatalf("what was recorded does not verify the credential given: %v", err)
}
// Minted since, then seeded again — which is what a restart does. The rotation must stand, or
// every restart would put the bootstrap password back over it.
minted, err := inv.MintBusPassword(ctx, BusUser{Username: "controller", Kind: BusController})
if err != nil {
t.Fatal(err)
}
if err := inv.SeedBusUser(ctx, BusUser{Username: "controller", Kind: BusController},
"bootstrap"); err != nil {
t.Fatal(err)
}
hash, _, err = inv.BusUserHash(ctx, "controller")
if err != nil {
t.Fatal(err)
}
if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(minted)); err != nil {
t.Fatal("a restart put the bootstrap credential back over a rotated one")
}
}
// Outside a test a bus password is hashed at bcrypt's default cost: only ForTest lowers it (novox/hq issue 591).
func TestABusPasswordIsHashedAtTheDefaultCostOutsideATest(t *testing.T) {
@@ -0,0 +1,66 @@
//go:build integration
package inventory
import (
"testing"
"time"
)
func TestAReportOfABusyContainerIsKeptAndReadBack(t *testing.T) {
inv, node := aNodeWithModules(t)
ctx := t.Context()
record, err := inv.NodeByName(ctx, node)
if err != nil {
t.Fatal(err)
}
since := time.Date(2026, 10, 13, 1, 0, 0, 0, time.UTC)
waiting := []BusyWait{{ID: "build-agent.server", Container: "mesh-build-agent", State: BusyWaiting,
Since: since, Until: since.Add(15 * time.Minute), Said: "busy: a check"}}
keep := func(busy []BusyWait) bool {
t.Helper()
news, err := inv.RecordOrderedDoing(ctx, record.ID, Doing{Outcome: OutcomeApplied, Declared: "d1", Applied: 1,
Busy: busy}, ReportOrder{Epoch: 1, Sequence: 1}, func(ReportOrder) bool { return false })
if err != nil {
t.Fatal(err)
}
return news
}
read := func() []BusyWait {
t.Helper()
reports, err := inv.LastReports(ctx)
if err != nil {
t.Fatal(err)
}
for _, r := range reports {
if r.Node == node {
return r.Busy
}
}
t.Fatal("the machine's report is not read back")
return nil
}
keep(nil)
if got := read(); len(got) != 0 {
t.Fatalf("a report with nothing busy reads back %+v", got)
}
if !keep(waiting) {
t.Error("a container starting to wait is not news")
}
got := read()
if b, ok := WaitingFor(got, "build-agent"); !ok || !b.Since.Equal(since) || b.Said != "busy: a check" {
t.Fatalf("the wait is not read back: %+v", got)
}
again := []BusyWait{waiting[0]}
again[0].Said = "busy: a check, a minute on"
if keep(again) {
t.Error("a wait whose output changed is news")
}
if !keep([]BusyWait{{ID: "build-agent.server", Container: "mesh-build-agent", State: "replaced-when-idle",
Since: since, Until: since.Add(15 * time.Minute)}}) {
t.Error("a container replaced after it waited is not news")
}
if _, ok := WaitingFor(read(), "build-agent"); ok {
t.Error("a container replaced still reads as waiting")
}
}
-57
View File
@@ -83,63 +83,6 @@ func TestABusyContainerIsItsModules(t *testing.T) {
// A machine's report of a busy container is kept with its last account and read back beside it, and its
// starting and its end are news; its output alone is not (novox/hq ADR 0335). Needs the store.
func TestAReportOfABusyContainerIsKeptAndReadBack(t *testing.T) {
inv, node := aNodeWithModules(t)
ctx := t.Context()
record, err := inv.NodeByName(ctx, node)
if err != nil {
t.Fatal(err)
}
since := time.Date(2026, 10, 13, 1, 0, 0, 0, time.UTC)
waiting := []BusyWait{{ID: "build-agent.server", Container: "mesh-build-agent", State: BusyWaiting,
Since: since, Until: since.Add(15 * time.Minute), Said: "busy: a check"}}
keep := func(busy []BusyWait) bool {
t.Helper()
news, err := inv.RecordOrderedDoing(ctx, record.ID, Doing{Outcome: OutcomeApplied, Declared: "d1", Applied: 1,
Busy: busy}, ReportOrder{Epoch: 1, Sequence: 1}, func(ReportOrder) bool { return false })
if err != nil {
t.Fatal(err)
}
return news
}
read := func() []BusyWait {
t.Helper()
reports, err := inv.LastReports(ctx)
if err != nil {
t.Fatal(err)
}
for _, r := range reports {
if r.Node == node {
return r.Busy
}
}
t.Fatal("the machine's report is not read back")
return nil
}
keep(nil)
if got := read(); len(got) != 0 {
t.Fatalf("a report with nothing busy reads back %+v", got)
}
if !keep(waiting) {
t.Error("a container starting to wait is not news")
}
got := read()
if b, ok := WaitingFor(got, "build-agent"); !ok || !b.Since.Equal(since) || b.Said != "busy: a check" {
t.Fatalf("the wait is not read back: %+v", got)
}
again := []BusyWait{waiting[0]}
again[0].Said = "busy: a check, a minute on"
if keep(again) {
t.Error("a wait whose output changed is news")
}
if !keep([]BusyWait{{ID: "build-agent.server", Container: "mesh-build-agent", State: "replaced-when-idle",
Since: since, Until: since.Add(15 * time.Minute)}}) {
t.Error("a container replaced after it waited is not news")
}
if _, ok := WaitingFor(read(), "build-agent"); ok {
t.Error("a container replaced still reads as waiting")
}
}
// A machine of the rest that answered and waits for a busy container is never read as silent, its wait is kept
// out of its apply time, and once replaced its report is the end of the wait (novox/hq ADR 0335).
+2
View File
@@ -1,3 +1,5 @@
//go:build integration
package inventory
import (
+2
View File
@@ -1,3 +1,5 @@
//go:build integration
package inventory
import (
+2
View File
@@ -1,3 +1,5 @@
//go:build integration
package inventory
import (
+2
View File
@@ -1,3 +1,5 @@
//go:build integration
package inventory
import (
+2
View File
@@ -1,3 +1,5 @@
//go:build integration
package inventory
import (
+2
View File
@@ -1,3 +1,5 @@
//go:build integration
package inventory
import (
+2
View File
@@ -1,3 +1,5 @@
//go:build integration
package inventory
import (
+2
View File
@@ -1,3 +1,5 @@
//go:build integration
package inventory
import (
+2
View File
@@ -1,3 +1,5 @@
//go:build integration
package inventory
import (
+2
View File
@@ -1,3 +1,5 @@
//go:build integration
package inventory
import (
+2
View File
@@ -1,3 +1,5 @@
//go:build integration
package inventory
import (
+2
View File
@@ -1,3 +1,5 @@
//go:build integration
package inventory
import (
+2
View File
@@ -1,3 +1,5 @@
//go:build integration
package inventory
import (
+2
View File
@@ -1,3 +1,5 @@
//go:build integration
package inventory
import (
+2
View File
@@ -1,3 +1,5 @@
//go:build integration
package inventory
import (
+2
View File
@@ -1,3 +1,5 @@
//go:build integration
package inventory
import (
+2
View File
@@ -1,3 +1,5 @@
//go:build integration
package inventory
import (
+2
View File
@@ -1,3 +1,5 @@
//go:build integration
package inventory
// A carried peer is nameable (novox/hq issue 112): the operator states which machine a carried
+2
View File
@@ -1,3 +1,5 @@
//go:build integration
package inventory
import (
+2
View File
@@ -1,3 +1,5 @@
//go:build integration
package inventory
import (
+2
View File
@@ -1,3 +1,5 @@
//go:build integration
package inventory
import (
+2
View File
@@ -1,3 +1,5 @@
//go:build integration
package inventory
import (
+2
View File
@@ -1,3 +1,5 @@
//go:build integration
package inventory
import (
+2
View File
@@ -1,3 +1,5 @@
//go:build integration
package inventory
import (
+2
View File
@@ -1,3 +1,5 @@
//go:build integration
package inventory
import (
+2
View File
@@ -1,3 +1,5 @@
//go:build integration
package inventory
import (
@@ -1,3 +1,5 @@
//go:build integration
package inventory
import (
+2
View File
@@ -1,3 +1,5 @@
//go:build integration
package inventory
import (
+2
View File
@@ -1,3 +1,5 @@
//go:build integration
package inventory
import (
+2
View File
@@ -1,3 +1,5 @@
//go:build integration
package inventory
import (
+2
View File
@@ -1,3 +1,5 @@
//go:build integration
package inventory
import (
+2
View File
@@ -1,3 +1,5 @@
//go:build integration
package inventory
import (
@@ -0,0 +1,33 @@
//go:build integration
package inventory
import (
"context"
"os"
"strings"
"testing"
"time"
"github.com/jackc/pgx/v5/pgconn"
)
func TestAWrongPasswordIsAnAnswerNotAnOutage(t *testing.T) {
dsn := os.Getenv("MESH_TEST_POSTGRES")
if dsn == "" {
t.Skip("MESH_TEST_POSTGRES is not set")
}
wrong := strings.Replace(dsn, "postgres:check@", "postgres:not-the-password@", 1)
if wrong == dsn {
t.Skip("the test store's address does not carry the expected credential")
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, err := pgconn.Connect(ctx, wrong)
if err == nil {
t.Fatal("a wrong password connected")
}
if Unreachable(err) {
t.Fatalf("a wrong password was taken for an outage and would be retried for ever: %v", err)
}
}
-21
View File
@@ -5,8 +5,6 @@ import (
"errors"
"fmt"
"io"
"os"
"strings"
"testing"
"time"
@@ -55,22 +53,3 @@ func TestAStoreThatRefusesTheConnectionIsUnreachable(t *testing.T) {
// A connection the store refused on a wrong password is an answer, even though it arrives as a
// failed connection — asked again for ever, it would hold every message behind it.
func TestAWrongPasswordIsAnAnswerNotAnOutage(t *testing.T) {
dsn := os.Getenv("MESH_TEST_POSTGRES")
if dsn == "" {
t.Skip("MESH_TEST_POSTGRES is not set")
}
wrong := strings.Replace(dsn, "postgres:check@", "postgres:not-the-password@", 1)
if wrong == dsn {
t.Skip("the test store's address does not carry the expected credential")
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, err := pgconn.Connect(ctx, wrong)
if err == nil {
t.Fatal("a wrong password connected")
}
if Unreachable(err) {
t.Fatalf("a wrong password was taken for an outage and would be retried for ever: %v", err)
}
}
@@ -0,0 +1,37 @@
//go:build integration
package inventory
import (
"encoding/json"
"testing"
"github.com/novox/mesh-controller/internal/catalogue"
)
func TestAModuleInTheConfigFormIsMirroredWithItsWholeDeclaration(t *testing.T) {
inv := fresh(t)
var m catalogue.Manifest
if err := json.Unmarshal([]byte(`{"module": "proxy", "version": "1", "config": {"port": {"type": "number",
"description": "the port it listens on, a TCP port number", "default": 8080, "minimum": 1,
"maximum": 65535, "enum": [8080, 8443], "predefined": "http.port"}}}`), &m); err != nil {
t.Fatal(err)
}
if err := inv.RegisterModule(t.Context(), m, Source{}); err != nil {
t.Fatal(err)
}
values, err := inv.DeclaredConfigMirror(t.Context())
if err != nil {
t.Fatal(err)
}
var got DeclaredConfigMirrored
if err := json.Unmarshal(values["proxy"], &got); err != nil {
t.Fatal(err)
}
port, has := got.Settings["port"]
if !has || port.Type != catalogue.ConfigNumber || port.Description == "" || string(port.Default) != "8080" ||
port.Minimum == nil || *port.Minimum != 1 || port.Maximum == nil || *port.Maximum != 65535 ||
string(port.Enum) != "[8080,8443]" || port.Predefined != "http.port" {
t.Errorf("a module in the config form is mirrored as %s", values["proxy"])
}
}
-26
View File
@@ -85,29 +85,3 @@ func TestADeclarationUsingEveryFieldRoundTripsThroughTheMirror(t *testing.T) {
// **A module moved to the config form is mirrored from it** (ADR 0348 §1): the store keeps its manifest in the
// form it was written in, under `config`, and the declared-config mirror reads its declarations from there with
// the config form's fields.
func TestAModuleInTheConfigFormIsMirroredWithItsWholeDeclaration(t *testing.T) {
inv := fresh(t)
var m catalogue.Manifest
if err := json.Unmarshal([]byte(`{"module": "proxy", "version": "1", "config": {"port": {"type": "number",
"description": "the port it listens on, a TCP port number", "default": 8080, "minimum": 1,
"maximum": 65535, "enum": [8080, 8443], "predefined": "http.port"}}}`), &m); err != nil {
t.Fatal(err)
}
if err := inv.RegisterModule(t.Context(), m, Source{}); err != nil {
t.Fatal(err)
}
values, err := inv.DeclaredConfigMirror(t.Context())
if err != nil {
t.Fatal(err)
}
var got DeclaredConfigMirrored
if err := json.Unmarshal(values["proxy"], &got); err != nil {
t.Fatal(err)
}
port, has := got.Settings["port"]
if !has || port.Type != catalogue.ConfigNumber || port.Description == "" || string(port.Default) != "8080" ||
port.Minimum == nil || *port.Minimum != 1 || port.Maximum == nil || *port.Maximum != 65535 ||
string(port.Enum) != "[8080,8443]" || port.Predefined != "http.port" {
t.Errorf("a module in the config form is mirrored as %s", values["proxy"])
}
}
@@ -0,0 +1,59 @@
//go:build integration
package inventory
import (
"encoding/json"
"testing"
"time"
"github.com/novox/mesh-controller/internal/catalogue"
)
func TestWhatRunsIsTheRegisteredBuildNotANewerOnePutBack(t *testing.T) {
inv := fresh(t)
ctx := t.Context()
manifestOf := func(image string) catalogue.Manifest {
return catalogue.Manifest{Module: "one", Version: "1", Resources: []map[string]any{
{"id": "app", "type": "container", "name": "one", "image": image}}}
}
record := func(id, image string, asked time.Time) Build {
t.Helper()
raw, _ := json.Marshal(manifestOf(image))
b := Build{ID: id, Module: "one", Commit: id + "aaaaaaaa", Repository: "novox/one", Manifest: raw,
Asked: asked, At: asked, Made: []Artifact{{Name: "one", Kind: "image", Reference: image}}}
if err := inv.RecordBuild(ctx, b); err != nil {
t.Fatal(err)
}
if err := inv.RegisterModule(ctx, manifestOf(image), Source{Repository: "novox/one", BuiltFrom: b.Commit,
Asked: asked}); err != nil {
t.Fatal(err)
}
return b
}
x := "store/one@sha256:" + "1111111111111111111111111111111111111111111111111111111111111111"
y := "store/one@sha256:" + "2222222222222222222222222222222222222222222222222222222222222222"
before := record("x", x, time.Now().Add(-2*time.Hour))
failed := record("y", y, time.Now().Add(-time.Minute))
if err := inv.RecordGate(ctx, GateVerdict{Build: failed.ID, Module: "one", Commit: failed.Commit,
Machines: []string{"anchor"}, Verdict: GateFailed}); err != nil {
t.Fatal(err)
}
if err := inv.RestoreModule(ctx, before); err != nil {
t.Fatal(err)
}
held, err := inv.Held(ctx)
if err != nil {
t.Fatal(err)
}
running, err := inv.RegisteredHeld(ctx)
if err != nil {
t.Fatal(err)
}
if running["one/one"] == y {
t.Fatalf("what runs is read as the build whose gate failed (%s); Held says %s", y, held["one/one"])
}
if got := running["one/one"]; got != "" && got != x {
t.Fatalf("what runs is read as %s, want the build put back %s or nothing", got, x)
}
}
@@ -1,63 +1,12 @@
package inventory
import (
"encoding/json"
"testing"
"time"
"github.com/novox/mesh-controller/internal/catalogue"
)
// TestWhatRunsIsTheRegisteredBuildNotANewerOnePutBack is novox/hq issue 721's review: the newest build of a module
// failed its gate and the mesh put the build before it back. Held names the newest; what runs, and what a merge's build
// is compared with, is the build put back — so a merge whose bytes equal the failed build is never said unchanged.
func TestWhatRunsIsTheRegisteredBuildNotANewerOnePutBack(t *testing.T) {
inv := fresh(t)
ctx := t.Context()
manifestOf := func(image string) catalogue.Manifest {
return catalogue.Manifest{Module: "one", Version: "1", Resources: []map[string]any{
{"id": "app", "type": "container", "name": "one", "image": image}}}
}
record := func(id, image string, asked time.Time) Build {
t.Helper()
raw, _ := json.Marshal(manifestOf(image))
b := Build{ID: id, Module: "one", Commit: id + "aaaaaaaa", Repository: "novox/one", Manifest: raw,
Asked: asked, At: asked, Made: []Artifact{{Name: "one", Kind: "image", Reference: image}}}
if err := inv.RecordBuild(ctx, b); err != nil {
t.Fatal(err)
}
if err := inv.RegisterModule(ctx, manifestOf(image), Source{Repository: "novox/one", BuiltFrom: b.Commit,
Asked: asked}); err != nil {
t.Fatal(err)
}
return b
}
x := "store/one@sha256:" + "1111111111111111111111111111111111111111111111111111111111111111"
y := "store/one@sha256:" + "2222222222222222222222222222222222222222222222222222222222222222"
before := record("x", x, time.Now().Add(-2*time.Hour))
failed := record("y", y, time.Now().Add(-time.Minute))
if err := inv.RecordGate(ctx, GateVerdict{Build: failed.ID, Module: "one", Commit: failed.Commit,
Machines: []string{"anchor"}, Verdict: GateFailed}); err != nil {
t.Fatal(err)
}
if err := inv.RestoreModule(ctx, before); err != nil {
t.Fatal(err)
}
held, err := inv.Held(ctx)
if err != nil {
t.Fatal(err)
}
running, err := inv.RegisteredHeld(ctx)
if err != nil {
t.Fatal(err)
}
if running["one/one"] == y {
t.Fatalf("what runs is read as the build whose gate failed (%s); Held says %s", y, held["one/one"])
}
if got := running["one/one"]; got != "" && got != x {
t.Fatalf("what runs is read as %s, want the build put back %s or nothing", got, x)
}
}
// TestOnlyArtifactsTheRegisteredManifestNamesAreRunning: registeredOnly keeps a held artifact only when its digest is
// in its module's registered manifest.
+2
View File
@@ -1,3 +1,5 @@
//go:build integration
package inventory
import (
+2
View File
@@ -1,3 +1,5 @@
//go:build integration
package inventory
import (
+2
View File
@@ -1,3 +1,5 @@
//go:build integration
package inventory
import (
@@ -0,0 +1,58 @@
//go:build integration
package inventory
import (
"strings"
"testing"
"github.com/novox/mesh-controller/internal/catalogue"
)
func TestAMemberGivenIsReadAsGivenAndNothingElseIs(t *testing.T) {
inv := fresh(t)
ctx := t.Context()
node, err := inv.AddNode(ctx, "workstation")
if err != nil {
t.Fatal(err)
}
key, _ := aSealingKey(t)
if err := inv.RecordSealingKey(ctx, node.ID, key); err != nil {
t.Fatal(err)
}
m := catalogue.Manifest{Module: "mounts", Version: "1", OwnSecrets: catalogue.OwnSecrets{
"smb-password-*": {Path: "/s/smb-password-*.secret", IssuedBy: catalogue.IssuedOutside},
"token": {Path: "/s/token", IssuedBy: catalogue.IssuedOutside},
}}
if err := inv.RegisterModule(ctx, m, Source{}); err != nil {
t.Fatal(err)
}
if err := inv.AcceptSecretForModule(ctx, "workstation", "mounts", "smb-password-games", "hunter2"); err != nil {
t.Fatalf("a member was refused: %v", err)
}
if err := inv.AcceptSecretForModule(ctx, "workstation", "mounts", "smb-pass", "x"); err == nil {
t.Fatal("a name of no family was accepted")
}
members, err := inv.GivenMembers(ctx, "workstation", "mounts", "smb-password-*")
if err != nil {
t.Fatal(err)
}
if len(members) != 1 || members[0].Name != "smb-password-games" || !members[0].Current || members[0].Sealed == "" ||
strings.Contains(members[0].Sealed, "hunter2") {
t.Fatalf("members: %+v", members)
}
given, err := inv.GivenOwnSecrets(ctx, "workstation", "mounts")
if err != nil {
t.Fatal(err)
}
if _, was := given["smb-password-games"]; !was || len(given) != 1 {
t.Fatalf("given: %v", given)
}
// A value the mesh made is not one a person gave.
if _, err := inv.SecretForModule(ctx, "workstation", "mounts", "token"); err != nil {
t.Fatal(err)
}
if given, _ := inv.GivenOwnSecrets(ctx, "workstation", "mounts"); len(given) != 1 {
t.Fatalf("a value the mesh made counted as given: %v", given)
}
}
-47
View File
@@ -27,50 +27,3 @@ func TestAMemberIsGivableAtTheDeskAndANameOutsideTheFamilyIsNot(t *testing.T) {
}
// A member given is read back as given, sealed to the machine's key; the mesh makes none, so nothing else is.
func TestAMemberGivenIsReadAsGivenAndNothingElseIs(t *testing.T) {
inv := fresh(t)
ctx := t.Context()
node, err := inv.AddNode(ctx, "workstation")
if err != nil {
t.Fatal(err)
}
key, _ := aSealingKey(t)
if err := inv.RecordSealingKey(ctx, node.ID, key); err != nil {
t.Fatal(err)
}
m := catalogue.Manifest{Module: "mounts", Version: "1", OwnSecrets: catalogue.OwnSecrets{
"smb-password-*": {Path: "/s/smb-password-*.secret", IssuedBy: catalogue.IssuedOutside},
"token": {Path: "/s/token", IssuedBy: catalogue.IssuedOutside},
}}
if err := inv.RegisterModule(ctx, m, Source{}); err != nil {
t.Fatal(err)
}
if err := inv.AcceptSecretForModule(ctx, "workstation", "mounts", "smb-password-games", "hunter2"); err != nil {
t.Fatalf("a member was refused: %v", err)
}
if err := inv.AcceptSecretForModule(ctx, "workstation", "mounts", "smb-pass", "x"); err == nil {
t.Fatal("a name of no family was accepted")
}
members, err := inv.GivenMembers(ctx, "workstation", "mounts", "smb-password-*")
if err != nil {
t.Fatal(err)
}
if len(members) != 1 || members[0].Name != "smb-password-games" || !members[0].Current || members[0].Sealed == "" ||
strings.Contains(members[0].Sealed, "hunter2") {
t.Fatalf("members: %+v", members)
}
given, err := inv.GivenOwnSecrets(ctx, "workstation", "mounts")
if err != nil {
t.Fatal(err)
}
if _, was := given["smb-password-games"]; !was || len(given) != 1 {
t.Fatalf("given: %v", given)
}
// A value the mesh made is not one a person gave.
if _, err := inv.SecretForModule(ctx, "workstation", "mounts", "token"); err != nil {
t.Fatal(err)
}
if given, _ := inv.GivenOwnSecrets(ctx, "workstation", "mounts"); len(given) != 1 {
t.Fatalf("a value the mesh made counted as given: %v", given)
}
}
+2
View File
@@ -1,3 +1,5 @@
//go:build integration
package inventory
import (
+5 -2
View File
@@ -1,3 +1,5 @@
//go:build integration
package inventory
import (
@@ -6,12 +8,13 @@ import (
"crypto/rand"
"encoding/base64"
"errors"
"github.com/novox/mesh-controller/internal/catalogue"
"github.com/novox/mesh-controller/internal/secrets"
"reflect"
"strings"
"testing"
"github.com/novox/mesh-controller/internal/catalogue"
"github.com/novox/mesh-controller/internal/secrets"
"golang.org/x/crypto/nacl/box"
)
+2
View File
@@ -1,3 +1,5 @@
//go:build integration
package inventory
import (
+2
View File
@@ -1,3 +1,5 @@
//go:build integration
package inventory
import (
@@ -1,3 +1,5 @@
//go:build integration
package inventory
import "testing"
+2
View File
@@ -1,3 +1,5 @@
//go:build integration
package inventory
import (
@@ -1,3 +1,5 @@
//go:build integration
package inventory
import (
+2
View File
@@ -1,3 +1,5 @@
//go:build integration
package inventory
import (
@@ -1,3 +1,5 @@
//go:build integration
package inventory
import (
+2
View File
@@ -1,3 +1,5 @@
//go:build integration
package inventory
import (
+2
View File
@@ -1,3 +1,5 @@
//go:build integration
package inventory
import (
+2
View File
@@ -1,3 +1,5 @@
//go:build integration
package inventory
import (
+2
View File
@@ -1,3 +1,5 @@
//go:build integration
package inventory
import (
@@ -0,0 +1,49 @@
//go:build integration
package inventory
import (
"testing"
"time"
)
func TestAWalksRestIsReadFromEachMachinesOwnSend(t *testing.T) {
inv := ForTest(t)
ctx := t.Context()
record, err := inv.AddNode(ctx, "g14")
if err != nil {
t.Fatal(err)
}
if _, err := inv.NextSequence(ctx, record.ID); err != nil {
t.Fatal(err)
}
if err := inv.RecordSent(ctx, record.ID, "d-582", map[string]string{"build-agent": "c1"}); err != nil {
t.Fatal(err)
}
sent, found, err := inv.SentTo(ctx, "g14")
if err != nil || !found || sent.At.IsZero() || sent.Digest != "d-582" {
t.Fatalf("the machine's own send time: %+v %v %v", sent, found, err)
}
if _, ok, err := inv.FirstAppliedAfter(ctx, "g14", sent, sent.At.Add(20*time.Second)); err != nil || ok {
t.Fatalf("no report yet: %v %v", ok, err)
}
if err := inv.RecordApplyDuration(ctx, "g14", "d-582", OutcomeApplied); err != nil {
t.Fatal(err)
}
// The walk's record of its send, 20 s after the machine's own: still found, by the machine's own send.
r, ok, err := inv.FirstAppliedAfter(ctx, "g14", sent, sent.At.Add(20*time.Second))
if err != nil || !ok || r.Outcome != OutcomeApplied || r.At.Before(sent.At) {
t.Fatalf("the machine's own report of its send: %+v %v %v", r, ok, err)
}
// A later send, reported: never this walk's.
if err := inv.RecordSent(ctx, record.ID, "d-next", map[string]string{"build-agent": "c2"}); err != nil {
t.Fatal(err)
}
if err := inv.RecordApplyDuration(ctx, "g14", "d-next", OutcomeApplied); err != nil {
t.Fatal(err)
}
again, _, _ := inv.FirstAppliedAfter(ctx, "g14", sent, sent.At.Add(20*time.Second))
if !again.At.Equal(r.At) {
t.Fatalf("a later send's report read as this walk's: %+v, was %+v", again, r)
}
}
-40
View File
@@ -146,46 +146,6 @@ func TestAMachineSentTwiceByOneWalkIsHeardByItsLaterSend(t *testing.T) {
// The store: a machine's own send time is read with what it was sent, and its `apply` duration is found by it —
// the send and the duration start at one moment, the node's sent_at.
func TestAWalksRestIsReadFromEachMachinesOwnSend(t *testing.T) {
inv := ForTest(t)
ctx := t.Context()
record, err := inv.AddNode(ctx, "g14")
if err != nil {
t.Fatal(err)
}
if _, err := inv.NextSequence(ctx, record.ID); err != nil {
t.Fatal(err)
}
if err := inv.RecordSent(ctx, record.ID, "d-582", map[string]string{"build-agent": "c1"}); err != nil {
t.Fatal(err)
}
sent, found, err := inv.SentTo(ctx, "g14")
if err != nil || !found || sent.At.IsZero() || sent.Digest != "d-582" {
t.Fatalf("the machine's own send time: %+v %v %v", sent, found, err)
}
if _, ok, err := inv.FirstAppliedAfter(ctx, "g14", sent, sent.At.Add(20*time.Second)); err != nil || ok {
t.Fatalf("no report yet: %v %v", ok, err)
}
if err := inv.RecordApplyDuration(ctx, "g14", "d-582", OutcomeApplied); err != nil {
t.Fatal(err)
}
// The walk's record of its send, 20 s after the machine's own: still found, by the machine's own send.
r, ok, err := inv.FirstAppliedAfter(ctx, "g14", sent, sent.At.Add(20*time.Second))
if err != nil || !ok || r.Outcome != OutcomeApplied || r.At.Before(sent.At) {
t.Fatalf("the machine's own report of its send: %+v %v %v", r, ok, err)
}
// A later send, reported: never this walk's.
if err := inv.RecordSent(ctx, record.ID, "d-next", map[string]string{"build-agent": "c2"}); err != nil {
t.Fatal(err)
}
if err := inv.RecordApplyDuration(ctx, "g14", "d-next", OutcomeApplied); err != nil {
t.Fatal(err)
}
again, _, _ := inv.FirstAppliedAfter(ctx, "g14", sent, sent.At.Add(20*time.Second))
if !again.At.Equal(r.At) {
t.Fatalf("a later send's report read as this walk's: %+v, was %+v", again, r)
}
}
// Issue 840 (ADR 0362): the walk's report of a machine is asked for with only the provisioned modules the walk sent
// there, never every module the machine's declaration hands to the provisioner.
@@ -0,0 +1,77 @@
//go:build integration
package inventory
import (
"testing"
"time"
)
func TestAWalksMomentsAreKeptAndItsRestsReportRead(t *testing.T) {
inv := ForTest(t)
ctx := t.Context()
p := walkOf(t, recordedWalk)
p.Revision, p.Epoch = 0, 0
if err := inv.SavePlan(ctx, &p); err != nil {
t.Fatal(err)
}
back, err := inv.PlanByID(ctx, p.ID)
if err != nil || back.Times == nil || back.Times.Class != ClassCore || back.Times.WindowClosed == nil ||
!back.Times.WindowClosed.Equal(*p.Times.WindowClosed) {
t.Fatalf("the walk's moments were not kept: %v %+v", err, back.Times)
}
if back.Delivery.Merges[0].Merged.IsZero() {
t.Fatal("a merge's time was not kept")
}
sent := time.Now().UTC().Add(-time.Minute).Truncate(time.Millisecond)
if _, ok, err := inv.FirstAppliedAfter(ctx, "ace", SentDeclaration{}, sent); err != nil || ok {
t.Fatalf("no report yet: %v %v", ok, err)
}
for _, d := range []Duration{
{Kind: DurationApply, Subject: "ace", Node: "ace", Ref: "old@1", Started: sent.Add(-time.Hour), Took: time.Second, Detail: OutcomeApplied},
{Kind: DurationApply, Subject: "ace", Node: "ace", Ref: "d1@2", Started: sent.Add(-time.Second), Took: 12 * time.Second, Detail: OutcomeApplied},
} {
if err := inv.RecordDuration(ctx, d); err != nil {
t.Fatal(err)
}
}
r, ok, err := inv.FirstAppliedAfter(ctx, "ace", SentDeclaration{}, sent)
if err != nil || !ok || r.Outcome != OutcomeApplied || !r.At.Equal(sent.Add(11*time.Second)) {
t.Fatalf("the report after the send: %+v %v %v", r, ok, err)
}
if anyKept, total, err := inv.WalkPhasesKept(ctx, p.ID); err != nil || anyKept || total {
t.Fatalf("nothing kept yet: %v %v %v", anyKept, total, err)
}
if err := inv.RecordDuration(ctx, Duration{Kind: DurationWalkPhase, Subject: "core/total", Ref: p.ID + "/total",
Started: sent, Took: time.Minute}); err != nil {
t.Fatal(err)
}
if anyKept, total, err := inv.WalkPhasesKept(ctx, p.ID); err != nil || !anyKept || !total {
t.Fatalf("the total kept: %v %v %v", anyKept, total, err)
}
}
func TestALateReportIsSilentNotTheEnd(t *testing.T) {
sent := time.Date(2026, 10, 10, 18, 0, 0, 0, time.UTC)
p := walkOf(t, recordedWalk)
p.Modules["mesh-delivery"].SentAt = &sent
p.Modules["mesh-delivery"].Rest = map[string]SentDeclaration{"laptop": {Digest: "d"}, "server": {Digest: "e"}}
w := p.WalkPhases(sent.Add(3*time.Hour), func(node string, _ SentDeclaration, _ time.Time) (AppliedReport, bool) {
if node == "server" {
return AppliedReport{At: sent.Add(20 * time.Second), Outcome: OutcomeApplied}, true
}
return AppliedReport{At: sent.Add(2 * time.Hour), Outcome: OutcomeApplied}, true
})
if len(w.Silent) != 1 || w.Silent[0] != "laptop" || w.End == nil || !w.End.Equal(sent.Add(20*time.Second)) {
t.Fatalf("a late report: silent %v, end %v", w.Silent, w.End)
}
inv := ForTest(t)
ctx := t.Context()
if err := inv.RecordDuration(ctx, Duration{Kind: DurationApply, Subject: "laptop", Node: "laptop", Ref: "late@1",
Started: sent.Add(time.Hour), Took: time.Second, Detail: OutcomeApplied}); err != nil {
t.Fatal(err)
}
if _, ok, err := inv.FirstAppliedAfter(ctx, "laptop", SentDeclaration{}, sent); err != nil || ok {
t.Fatalf("a send past the bound read as the walk's: %v %v", ok, err)
}
}
-67
View File
@@ -230,76 +230,9 @@ func TestAGateKeepsItsReadings(t *testing.T) {
// A walk's moments are kept and read back with it; a machine's first report after a send is read from the
// controller's apply durations.
func TestAWalksMomentsAreKeptAndItsRestsReportRead(t *testing.T) {
inv := ForTest(t)
ctx := t.Context()
p := walkOf(t, recordedWalk)
p.Revision, p.Epoch = 0, 0
if err := inv.SavePlan(ctx, &p); err != nil {
t.Fatal(err)
}
back, err := inv.PlanByID(ctx, p.ID)
if err != nil || back.Times == nil || back.Times.Class != ClassCore || back.Times.WindowClosed == nil ||
!back.Times.WindowClosed.Equal(*p.Times.WindowClosed) {
t.Fatalf("the walk's moments were not kept: %v %+v", err, back.Times)
}
if back.Delivery.Merges[0].Merged.IsZero() {
t.Fatal("a merge's time was not kept")
}
sent := time.Now().UTC().Add(-time.Minute).Truncate(time.Millisecond)
if _, ok, err := inv.FirstAppliedAfter(ctx, "ace", SentDeclaration{}, sent); err != nil || ok {
t.Fatalf("no report yet: %v %v", ok, err)
}
for _, d := range []Duration{
{Kind: DurationApply, Subject: "ace", Node: "ace", Ref: "old@1", Started: sent.Add(-time.Hour), Took: time.Second, Detail: OutcomeApplied},
{Kind: DurationApply, Subject: "ace", Node: "ace", Ref: "d1@2", Started: sent.Add(-time.Second), Took: 12 * time.Second, Detail: OutcomeApplied},
} {
if err := inv.RecordDuration(ctx, d); err != nil {
t.Fatal(err)
}
}
r, ok, err := inv.FirstAppliedAfter(ctx, "ace", SentDeclaration{}, sent)
if err != nil || !ok || r.Outcome != OutcomeApplied || !r.At.Equal(sent.Add(11*time.Second)) {
t.Fatalf("the report after the send: %+v %v %v", r, ok, err)
}
if anyKept, total, err := inv.WalkPhasesKept(ctx, p.ID); err != nil || anyKept || total {
t.Fatalf("nothing kept yet: %v %v %v", anyKept, total, err)
}
if err := inv.RecordDuration(ctx, Duration{Kind: DurationWalkPhase, Subject: "core/total", Ref: p.ID + "/total",
Started: sent, Took: time.Minute}); err != nil {
t.Fatal(err)
}
if anyKept, total, err := inv.WalkPhasesKept(ctx, p.ID); err != nil || !anyKept || !total {
t.Fatalf("the total kept: %v %v %v", anyKept, total, err)
}
}
// A machine that slept past the bound and reported later is listed silent: its late report never moves the
// walk's end; and a report sent past the bound is not read as the walk's.
func TestALateReportIsSilentNotTheEnd(t *testing.T) {
sent := time.Date(2026, 10, 10, 18, 0, 0, 0, time.UTC)
p := walkOf(t, recordedWalk)
p.Modules["mesh-delivery"].SentAt = &sent
p.Modules["mesh-delivery"].Rest = map[string]SentDeclaration{"laptop": {Digest: "d"}, "server": {Digest: "e"}}
w := p.WalkPhases(sent.Add(3*time.Hour), func(node string, _ SentDeclaration, _ time.Time) (AppliedReport, bool) {
if node == "server" {
return AppliedReport{At: sent.Add(20 * time.Second), Outcome: OutcomeApplied}, true
}
return AppliedReport{At: sent.Add(2 * time.Hour), Outcome: OutcomeApplied}, true
})
if len(w.Silent) != 1 || w.Silent[0] != "laptop" || w.End == nil || !w.End.Equal(sent.Add(20*time.Second)) {
t.Fatalf("a late report: silent %v, end %v", w.Silent, w.End)
}
inv := ForTest(t)
ctx := t.Context()
if err := inv.RecordDuration(ctx, Duration{Kind: DurationApply, Subject: "laptop", Node: "laptop", Ref: "late@1",
Started: sent.Add(time.Hour), Took: time.Second, Detail: OutcomeApplied}); err != nil {
t.Fatal(err)
}
if _, ok, err := inv.FirstAppliedAfter(ctx, "laptop", SentDeclaration{}, sent); err != nil || ok {
t.Fatalf("a send past the bound read as the walk's: %v %v", ok, err)
}
}
// Moments out of order — #206's own walk on 2026-10-10 kept its first send 783 ms before its build — are said
// unknown, never a negative phase: the phase before is unknown and its time joins the unknown time, the phase
@@ -0,0 +1,50 @@
//go:build integration
package inventory
import (
"testing"
"time"
)
func TestAWalksStartIsKeptAndARefusedSaveLeavesItUnsaid(t *testing.T) {
inv := ForTest(t)
ctx := t.Context()
cut := time.Now().UTC().Add(-time.Minute)
p := Plan{ID: "plan-308", Repository: "novox/mesh-catalog", Commit: "abc", Created: cut, State: PlanBuilding,
Tiers: [][]string{{"messenger"}}, Modules: map[string]*PlanModule{"messenger": {}},
Times: &PlanTimes{Cut: &cut, Class: ClassLeaf}}
var said []string
was := PlanSaved
PlanSaved = func(_ *Inventory, p Plan) { said = append(said, p.WalkSaid) }
t.Cleanup(func() { PlanSaved = was })
if err := inv.SavePlan(ctx, &p); err != nil {
t.Fatal(err)
}
back, err := inv.PlanByID(ctx, p.ID)
if err != nil || back.Times == nil || back.Times.Started == nil || back.Times.Class != ClassLeaf {
t.Fatalf("the start is kept with the walk: %v %+v", err, back.Times)
}
// Read back by another controller and saved: not said again.
back.State = PlanRolling
if err := inv.SavePlan(ctx, &back); err != nil {
t.Fatal(err)
}
// A stale copy ending it is refused, and unmarked.
stale := p
stale.Revision = 1
stale.State = PlanDone
if err := inv.SavePlan(ctx, &stale); err == nil {
t.Fatal("a stale save is refused")
}
if stale.WalkSaid != "" || stale.Times.Ended != nil {
t.Fatalf("a refused save is unmarked: %q %+v", stale.WalkSaid, stale.Times)
}
back.State = PlanDone
if err := inv.SavePlan(ctx, &back); err != nil {
t.Fatal(err)
}
if len(said) != 3 || said[0] != WalkStarted || said[1] != "" || said[2] != WalkEnded {
t.Fatalf("said: %q, want started, nothing, ended", said)
}
}
-41
View File
@@ -87,44 +87,3 @@ func TestAWalksOutcome(t *testing.T) {
// The marks are kept with the walk, so a controller replaced mid-walk neither says its start again nor misses
// its end; and a save refused leaves the walk unmarked, so the save that is made says it.
func TestAWalksStartIsKeptAndARefusedSaveLeavesItUnsaid(t *testing.T) {
inv := ForTest(t)
ctx := t.Context()
cut := time.Now().UTC().Add(-time.Minute)
p := Plan{ID: "plan-308", Repository: "novox/mesh-catalog", Commit: "abc", Created: cut, State: PlanBuilding,
Tiers: [][]string{{"messenger"}}, Modules: map[string]*PlanModule{"messenger": {}},
Times: &PlanTimes{Cut: &cut, Class: ClassLeaf}}
var said []string
was := PlanSaved
PlanSaved = func(_ *Inventory, p Plan) { said = append(said, p.WalkSaid) }
t.Cleanup(func() { PlanSaved = was })
if err := inv.SavePlan(ctx, &p); err != nil {
t.Fatal(err)
}
back, err := inv.PlanByID(ctx, p.ID)
if err != nil || back.Times == nil || back.Times.Started == nil || back.Times.Class != ClassLeaf {
t.Fatalf("the start is kept with the walk: %v %+v", err, back.Times)
}
// Read back by another controller and saved: not said again.
back.State = PlanRolling
if err := inv.SavePlan(ctx, &back); err != nil {
t.Fatal(err)
}
// A stale copy ending it is refused, and unmarked.
stale := p
stale.Revision = 1
stale.State = PlanDone
if err := inv.SavePlan(ctx, &stale); err == nil {
t.Fatal("a stale save is refused")
}
if stale.WalkSaid != "" || stale.Times.Ended != nil {
t.Fatalf("a refused save is unmarked: %q %+v", stale.WalkSaid, stale.Times)
}
back.State = PlanDone
if err := inv.SavePlan(ctx, &back); err != nil {
t.Fatal(err)
}
if len(said) != 3 || said[0] != WalkStarted || said[1] != "" || said[2] != WalkEnded {
t.Fatalf("said: %q, want started, nothing, ended", said)
}
}