diff --git a/cmd/mesh-host/userlist.go b/cmd/mesh-host/userlist.go index ff5527a..a0d58fc 100644 --- a/cmd/mesh-host/userlist.go +++ b/cmd/mesh-host/userlist.go @@ -9,7 +9,10 @@ import ( "os" "git.novox.be/novox/mesh-sdk/go/declaration" + "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-sdk/go/seal" "github.com/novox/mesh-host/internal/apply" + "github.com/novox/mesh-host/internal/identity" "github.com/novox/mesh-host/internal/link" "github.com/novox/mesh-host/internal/store" "github.com/novox/mesh-host/internal/system" @@ -42,10 +45,37 @@ func userListFollower(opts options, session *link.Session, composedBucket string Report: func(ctx context.Context, module string, w userlist.Written) { session.NoteUserList(ctx, module, w) }, + // From take-over, the grants part's list (ADR 0547 clauses 7, 8): its one key watched, its object read by name and + // opened with this machine's sealing key, the key's public half said in the have so the grants part seals to it. + Grants: bucketWatch{session, delivery.GrantsUserListBucket}, + Fetch: func(ctx context.Context, name string) ([]byte, error) { + return session.GrantsObject(ctx, name, grantsListLimit) + }, + Open: func(b seal.Box) ([]byte, error) { + key, err := identity.LoadSealingKey(identity.SealingKeyPath(opts.state)) + if err != nil { + return nil, err + } + return key.OpenVaultBox(b) + }, + Key: func() string { + key, err := identity.LoadSealingKey(identity.SealingKeyPath(opts.state)) + if err != nil { + return "" + } + pem, err := key.PublicPEM() + if err != nil { + return "" + } + return pem + }, Say: announceOr(say), }) } +// grantsListLimit is the largest sealed list the bus node reads: the live list is about a hundred kilobytes. +const grantsListLimit = 16 << 20 + // userListFile is a user-list resource as the file it declares. func userListFile(raw []byte) (*declaration.File, error) { r, err := declaration.ParseResource(raw) diff --git a/go.mod b/go.mod index c100297..785b51d 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/novox/mesh-host go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261025204730-0be0ffd6159e + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026105311-175a5567b8bd github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 golang.org/x/crypto v0.57.0 diff --git a/go.sum b/go.sum index f843500..7ce13b1 100644 --- a/go.sum +++ b/go.sum @@ -4,6 +4,8 @@ git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261025204213-f63fa86c7456 h1:He2o9vSK git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261025204213-f63fa86c7456/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261025204730-0be0ffd6159e h1:k/1NjjW5HuuuHW2ldyWTvjp+1u3fN0RO18Q57Iup6Dk= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261025204730-0be0ffd6159e/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026105311-175a5567b8bd h1:ef6y09csev6xY/suFrIFGUXbgvDA29kummFtHcIb3B8= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026105311-175a5567b8bd/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= diff --git a/internal/link/files.go b/internal/link/files.go index 91e6959..b4a251c 100644 --- a/internal/link/files.go +++ b/internal/link/files.go @@ -10,6 +10,7 @@ import ( "time" "git.novox.be/novox/mesh-sdk/go/declaration" + "git.novox.be/novox/mesh-sdk/go/delivery" "github.com/nats-io/nats.go" ) @@ -92,7 +93,32 @@ func (b OverNATS) FileObject(ctx context.Context, name string, limit int64) ([]b if err != nil { return nil, err } - stream := declaration.FileStreamPrefix + bucket + return objectByName(ctx, js, bucket, declaration.FileStreamPrefix+bucket, name, limit) +} + +// GrantsObject reads one object of the grants part's bucket of sealed user lists by its name (novox/hq ADR 0547 clause 7), +// with the same subjects and nothing more as FileObject: the bus node is granted exactly those (mesh-sdk +// buscompose.GrantsUserListFollows). +func (b OverNATS) GrantsObject(ctx context.Context, name string, limit int64) ([]byte, error) { + js := b.JS + wait := 10 * time.Second + if at, ok := ctx.Deadline(); ok { + if wait = time.Until(at); wait <= 0 { + return nil, context.DeadlineExceeded + } + } + if b.Conn != nil { + bounded, err := b.Conn.JetStream(nats.MaxWait(wait)) + if err != nil { + return nil, err + } + js = bounded + } + return objectByName(ctx, js, delivery.GrantsUserListObjects, delivery.GrantsUserListObjectStream, name, limit) +} + +// objectByName is one object of bucket (its stream stream) by its name, no larger than limit, verified against its digest. +func objectByName(ctx context.Context, js nats.JetStreamContext, bucket, stream, name string, limit int64) ([]byte, error) { meta, err := js.GetLastMsg(stream, "$O."+bucket+".M."+base64.URLEncoding.EncodeToString([]byte(name)), nats.DirectGet(), nats.Context(ctx)) if err != nil { @@ -109,7 +135,7 @@ func (b OverNATS) FileObject(ctx context.Context, name string, limit int64) ([]b return nil, fmt.Errorf("the object %s was deleted from the bucket %s", name, bucket) } if info.Size > uint64(limit) { - return nil, fmt.Errorf("the object is larger than the %d bytes the declaration names", limit) + return nil, fmt.Errorf("the object is larger than the %d bytes allowed", limit) } if info.Size == 0 { return []byte{}, nil @@ -142,6 +168,28 @@ func (l *natsLink) FileObject(ctx context.Context, name string, limit int64) ([] return OverNATS{Conn: l.conn, JS: l.js, Node: l.node}.FileObject(ctx, name, limit) } +func (l *natsLink) GrantsObject(ctx context.Context, name string, limit int64) ([]byte, error) { + return OverNATS{Conn: l.conn, JS: l.js, Node: l.node}.GrantsObject(ctx, name, limit) +} + +// GrantsObjects is a link that can read an object of the grants part's bucket of sealed user lists. +type GrantsObjects interface { + GrantsObject(ctx context.Context, name string, limit int64) ([]byte, error) +} + +// GrantsObject reads an object of the grants part's bucket by its name on the link open now. +func (q *Session) GrantsObject(ctx context.Context, name string, limit int64) ([]byte, error) { + q.init() + q.mu.Lock() + bus := q.bus + q.mu.Unlock() + g, ok := bus.(GrantsObjects) + if bus == nil || !ok { + return nil, ErrNoLink + } + return g.GrantsObject(ctx, name, limit) +} + var ( _ FilesBus = OverNATS{} _ FilesBus = (*natsLink)(nil) diff --git a/internal/userlist/userlist.go b/internal/userlist/userlist.go index 7992a6e..7653ac2 100644 --- a/internal/userlist/userlist.go +++ b/internal/userlist/userlist.go @@ -15,6 +15,13 @@ // older list, and when asked (Again). // // It reads no want and writes none, moves no fingerprint, and touches nothing of the bus module but its user list. +// +// **From take-over it follows the grants part's list instead** (ADR 0547 clauses 7 and 8, issue 1945). On the node whose +// compositions carry the list, it also watches the grants part's one key `user-list.sealed`. While that key says +// FollowGrants, the list is the object the key names, read by its name, opened with this node's sealing key and checked +// against the key's digest, and written to the same file (mode 0600) only when the file's digest differs; otherwise the +// controller's compositions are the source, as before. Never both. The have says which it follows and this node's key, +// which the grants part seals to. package userlist import ( @@ -31,6 +38,7 @@ import ( "time" "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-sdk/go/seal" "github.com/novox/mesh-host/internal/agent" ) @@ -59,8 +67,15 @@ type Env struct { Write func(ctx context.Context, module string, resource json.RawMessage, body []byte) (digest string, changed bool, err error) // Report says what was written, in the module's have. Report func(ctx context.Context, module string, w Written) - Now func() time.Time - Say func(string) + // Grants watches the grants part's state (its key delivery.GrantsUserListKey); nil follows the controller's alone. + Grants agent.Watcher + // Fetch reads an object of the grants part's bucket by its name; Open opens a box sealed to this node's key; Key is + // this node's sealing key's public half (SPKI PEM), said in the have so the grants part seals to it ("" unknown). + Fetch func(ctx context.Context, name string) ([]byte, error) + Open func(seal.Box) ([]byte, error) + Key func() string + Now func() time.Time + Say func(string) } type source struct { @@ -83,6 +98,11 @@ type Follower struct { state Written mod string said map[string]bool + // grants is the grants part's key as last heard (nil when none), and following the source last taken: FollowGrants + // or FollowController. + grants *delivery.GrantsUserList + grantsRev uint64 + following string } // New is a follower for a node. @@ -120,6 +140,7 @@ func (f *Follower) Run(ctx context.Context) error { return fmt.Errorf("the user list cannot be followed: %w", err) } caught := false + var grants <-chan agent.Update for { again := false select { @@ -136,13 +157,81 @@ func (f *Follower) Run(ctx context.Context) error { } else { f.take(u) } + case u, ok := <-grants: + if !ok { + // Its watch ended (the link, or a grant the bus took away): heard again at the next event. + grants = nil + f.sayOnce("the grants part's user list is not followed until its key can be watched again") + continue + } + if u.Caught { + continue // nothing changed by the watch catching up + } + f.takeGrants(u) } if caught { f.settle(ctx, again) + // The grants part's key is watched on the node whose compositions carry the list: the bus node alone is + // granted it (mesh-sdk buscompose.GrantsUserListFollows). + if grants == nil && f.env.Grants != nil && f.busNode() { + ch, err := f.env.Grants.Watch(ctx, delivery.GrantsUserListKey) + if err != nil { + f.sayOnce("the grants part's user list cannot be watched yet, so the controller's is followed: " + err.Error()) + } else { + grants = ch + } + } } } } +// busNode says some composition of this node carries the bus's user list. +func (f *Follower) busNode() bool { + f.mu.Lock() + defer f.mu.Unlock() + return f.cur.key != "" || len(f.srcs) > 0 +} + +// takeGrants keeps the grants part's key as heard; a value it cannot read is said and leaves the controller's list +// followed (no list is followed that the node cannot check). +func (f *Follower) takeGrants(u agent.Update) { + f.mu.Lock() + defer f.mu.Unlock() + f.grantsRev = u.Revision + if u.Deleted { + f.grants = nil + return + } + g, err := delivery.DecodeGrantsUserList(u.Value) + if err != nil { + f.grants = nil + f.sayOnceLocked("the grants part's user-list key cannot be read, so the controller's list is followed: " + err.Error()) + return + } + f.grants = &g +} + +// Following is which list the follower takes now: the grants part's while its key says so, the controller's otherwise. +func (f *Follower) Following() string { + f.mu.Lock() + defer f.mu.Unlock() + return f.followingLocked() +} + +func (f *Follower) followingLocked() string { + if f.grants != nil && f.grants.Follow == delivery.FollowGrants { + return delivery.FollowGrants + } + return delivery.FollowController +} + +func (f *Follower) key() string { + if f.env.Key == nil { + return "" + } + return f.env.Key() +} + // take keeps a composition key's change when it carries the bus's user list, and forgets a key deleted. func (f *Follower) take(u agent.Update) { node, module, _, isNode, err := delivery.ParseComposedKey(u.Key) @@ -192,6 +281,17 @@ func (f *Follower) take(u agent.Update) { // A source never goes back: a key deleted, or a newer one refused, leaves the list written as it is, since an older // list would take away accounts a newer one gave. func (f *Follower) settle(ctx context.Context, again bool) { + f.mu.Lock() + follow := f.followingLocked() + if follow != f.following { + // The switch turned: the other source is written, though neither changed. + again = true + } + f.mu.Unlock() + if follow == delivery.FollowGrants { + f.settleGrants(ctx, again) + return + } f.mu.Lock() newest := f.cur for _, s := range f.srcs { @@ -207,7 +307,8 @@ func (f *Follower) settle(ctx context.Context, again bool) { at := fmt.Sprintf("%s@%d", newest.key, newest.rev) f.mu.Unlock() - w := Written{Source: newest.key, Revision: newest.rev, Wanted: newest.wanted, At: f.env.Now().UTC()} + w := Written{Source: newest.key, Revision: newest.rev, Wanted: newest.wanted, At: f.env.Now().UTC(), + Follows: delivery.FollowController, Key: f.key()} var changed bool body, err := f.env.Resolve(ctx, newest.resource) if err == nil { @@ -230,6 +331,7 @@ func (f *Follower) settle(ctx context.Context, again bool) { prev := f.state f.mod, f.state = newest.module, w f.written = err == nil + f.following = delivery.FollowController f.mu.Unlock() if err != nil { f.sayOnce(fmt.Sprintf("the bus's user list of %s could not be written: %v", at, err)) @@ -238,12 +340,108 @@ func (f *Follower) settle(ctx context.Context, again bool) { at, w.Users, short(w.Digest))) } // Reported when what it says changed: a look again that found the file as it was says nothing new. - if f.env.Report != nil && (changed || prev.Source != w.Source || prev.Revision != w.Revision || - prev.Digest != w.Digest || prev.Error != w.Error) { + if f.env.Report != nil && (changed || reportable(prev, w)) { f.env.Report(ctx, newest.module, w) } } +// reportable says a have's user list says something new: a look again that found the file as it was says nothing. +func reportable(prev, w Written) bool { + return prev.Source != w.Source || prev.Revision != w.Revision || prev.Digest != w.Digest || prev.Error != w.Error || + prev.Follows != w.Follows || prev.Key != w.Key +} + +// settleGrants writes the grants part's list, the object its key names, into the file the controller's composition of +// the bus module declares, made 0600 (ADR 0547 clause 7): when the key moved, when the switch turned, or when asked. +func (f *Follower) settleGrants(ctx context.Context, again bool) { + f.mu.Lock() + g, rev := *f.grants, f.grantsRev + file := f.cur + for _, s := range f.srcs { + if s.rev > file.rev { + file = s + } + } + if !again && f.following == delivery.FollowGrants && f.state.Revision == rev && f.written { + f.mu.Unlock() + return + } + f.mu.Unlock() + + w := Written{Source: delivery.GrantsUserListSource, Revision: rev, Wanted: g.Digest, At: f.env.Now().UTC(), + Follows: delivery.FollowGrants, Key: f.key()} + var err error + var changed bool + resource, rerr := mode0600(file.resource) + switch { + case file.key == "" || rerr != nil: + err = fmt.Errorf("no composition of this node declares the bus's user-list file, so the grants part's list has nowhere to be written") + case g.Digest == "": + err = fmt.Errorf("the grants part's key names no list yet; the list on the machine is kept") + case f.env.Fetch == nil || f.env.Open == nil: + err = fmt.Errorf("this engine cannot read or open the grants part's list") + case g.SealedTo != "" && w.Key != "" && g.SealedTo != delivery.SealingKeyID(w.Key): + err = fmt.Errorf("the grants part's list is sealed to another key than this node's (%s); the list on the machine is kept until it seals to this one", g.SealedTo) + } + var body []byte + if err == nil { + var object []byte + if object, err = f.env.Fetch(ctx, g.Object); err == nil { + body, err = delivery.OpenUserList(object, g.Digest, f.env.Open) + } + if err != nil { + err = fmt.Errorf("the grants part's list %s could not be had: %w", short(g.Digest), err) + } + } + if err == nil { + w.Users = Users(body) + if err = f.guard(resource, body); err != nil { + if disk, derr := f.env.OnDisk(resource); derr == nil && disk != nil { + w.Digest = Digest(disk) + } + } + } + if err == nil { + w.Digest, changed, err = f.env.Write(ctx, file.module, resource, body) + } else if w.Digest == "" && file.key != "" && f.env.OnDisk != nil { + if disk, derr := f.env.OnDisk(resource); derr == nil && disk != nil { + w.Digest = Digest(disk) + } + } + if err != nil { + w.Error = err.Error() + } + f.mu.Lock() + prev := f.state + f.mod, f.state = file.module, w + f.written = err == nil + f.following = delivery.FollowGrants + f.mu.Unlock() + at := fmt.Sprintf("%s@%d", delivery.GrantsUserListSource, rev) + if err != nil { + f.sayOnce(fmt.Sprintf("the bus's user list of %s could not be written: %v", at, err)) + } else if changed { + f.env.Say(fmt.Sprintf("the bus's user list from the grants part (%s, revision %d) is written alone (%d users, %s); the bus reloads it itself", + at, g.Revision, w.Users, short(w.Digest))) + } + if f.env.Report != nil && file.module != "" && (changed || reportable(prev, w)) { + f.env.Report(ctx, file.module, w) + } +} + +// mode0600 is a user-list resource made mode 0600, whatever its composition said (ADR 0547 clause 7). +func mode0600(raw json.RawMessage) (json.RawMessage, error) { + if len(raw) == 0 { + return nil, fmt.Errorf("no resource") + } + var r map[string]any + if err := json.Unmarshal(raw, &r); err != nil { + return nil, err + } + r["mode"] = "0600" + return json.Marshal(r) +} + // ControllerUser is the controller's own user in the list: a list without it would lock the controller out of the bus. const ControllerUser = "controller" diff --git a/internal/userlist/userlist_test.go b/internal/userlist/userlist_test.go index d4d52c1..4663e9a 100644 --- a/internal/userlist/userlist_test.go +++ b/internal/userlist/userlist_test.go @@ -11,6 +11,7 @@ import ( "time" "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-sdk/go/seal" "github.com/novox/mesh-host/internal/agent" ) @@ -34,6 +35,9 @@ type feed struct { ch chan agent.Update rev uint64 first []agent.Update + // watched is closed once the watch caught up, so a later put is not read before the keys there before it. + watched chan struct{} + once sync.Once } func (f *feed) Watch(context.Context, string) (<-chan agent.Update, error) { @@ -41,6 +45,11 @@ func (f *feed) Watch(context.Context, string) (<-chan agent.Update, error) { f.ch <- u } f.ch <- agent.Update{Caught: true} + f.once.Do(func() { + if f.watched != nil { + close(f.watched) + } + }) return f.ch, nil } @@ -97,6 +106,13 @@ func comp(t *testing.T, module, fp, content string, signer ed25519.PrivateKey, p return k, raw } +// gput is the grants part's key as the bus node hears it at the watch's start (ADR 0547 clauses 7, 8): which list to +// follow, and the list sealed to the node's key; one sealed to another key, or one that does not open to the key's digest. +type gput struct { + follow, content string + otherKey, badDigest bool +} + type put struct { module, fp, content string stranger, deleted bool @@ -118,6 +134,9 @@ func TestTheBusNodeWritesTheNewestSignedUserListAlone(t *testing.T) { wantUsers int wantErr string wantRefused bool + grants []gput + // wantSource and wantFollows are what the last have says it took the list from and follows. + wantSource, wantFollows string }{ {name: "the list there is written once", before: []put{{module: "nats", fp: fpOf("a"), content: two}}, wantWrites: []string{two}, wantUsers: 3}, @@ -153,15 +172,64 @@ func TestTheBusNodeWritesTheNewestSignedUserListAlone(t *testing.T) { onDisk: list("controller", "a", "b", "c", "d", "e"), wantWrites: []string{two}, wantUsers: 3}, {name: "a failed write is said and kept", before: []put{{module: "nats", fp: fpOf("a"), content: two}}, failWrite: true, wantWrites: []string{two}, wantUsers: 3, wantErr: "disk full"}, + // ADR 0547 clauses 7, 8: never both lists; the grants part's from take-over, the controller's otherwise. + {name: "from take-over the grants part's list is written into the same file", + before: []put{{module: "nats", fp: fpOf("a"), content: two}}, grants: []gput{{follow: delivery.FollowGrants, content: three}}, + wantWrites: []string{two, three}, wantUsers: 4, wantSource: delivery.GrantsUserListSource, wantFollows: delivery.FollowGrants}, + {name: "in shadow the grants part's list is not taken", + before: []put{{module: "nats", fp: fpOf("a"), content: two}}, grants: []gput{{follow: delivery.FollowController, content: three}}, + wantWrites: []string{two}, wantUsers: 3, wantSource: "novox.nats." + fpOf("a")[len(delivery.SourcePrefix2):], wantFollows: delivery.FollowController}, + {name: "a grants list that does not open to its digest is refused and the file kept", + before: []put{{module: "nats", fp: fpOf("a"), content: two}}, grants: []gput{{follow: delivery.FollowGrants, content: three, badDigest: true}}, + onDisk: two, wantWrites: []string{two}, wantErr: "could not be had", wantRefused: true, wantSource: delivery.GrantsUserListSource}, + {name: "a grants list sealed to another key is refused and the file kept", + before: []put{{module: "nats", fp: fpOf("a"), content: two}}, grants: []gput{{follow: delivery.FollowGrants, content: three, otherKey: true}}, + onDisk: two, wantWrites: []string{two}, wantErr: "sealed to another key", wantRefused: true, wantSource: delivery.GrantsUserListSource}, } { t.Run(c.name, func(t *testing.T) { - f := &feed{ch: make(chan agent.Update, 64)} + f := &feed{ch: make(chan agent.Update, 64), watched: make(chan struct{})} objects := map[string]string{} var mu sync.Mutex var writes []string var reports []Written reported := make(chan struct{}, 64) - fl := New(Env{Node: node, Composed: f, + gf := &feed{ch: make(chan agent.Update, 64)} + sealed := map[string][]byte{} + nodeKey, err := seal.GenerateKeyPair() + if err != nil { + t.Fatal(err) + } + for i, g := range c.grants { + to := nodeKey.PublicKey + if g.otherKey { + other, _ := seal.GenerateKeyPair() + to = other.PublicKey + } + digest := delivery.UserListDigest([]byte(g.content)) + if g.badDigest { + digest = delivery.UserListDigest([]byte("another list")) + } + object, err := delivery.SealUserList([]byte(g.content), to) + if err != nil { + t.Fatal(err) + } + sealed[delivery.UserListObjectName(digest)] = object + raw, err := delivery.EncodeGrantsUserList(delivery.GrantsUserList{Follow: g.follow, Digest: digest, + Object: delivery.UserListObjectName(digest), SealedTo: delivery.SealingKeyID(to), Node: node, Revision: uint64(i + 1)}) + if err != nil { + t.Fatal(err) + } + gf.first = append(gf.first, agent.Update{Key: delivery.GrantsUserListKey, Value: raw, Revision: uint64(i + 1)}) + } + fl := New(Env{Node: node, Composed: f, Grants: gf, + Fetch: func(_ context.Context, name string) ([]byte, error) { + if o, ok := sealed[name]; ok { + return o, nil + } + return nil, errors.New("no object " + name) + }, + Open: func(b seal.Box) ([]byte, error) { out, err := seal.Open(b, nodeKey.PrivateKey); return []byte(out), err }, + Key: func() string { return nodeKey.PublicKey }, Trusted: func() []ed25519.PublicKey { return []ed25519.PublicKey{controllerPub} }, Resolve: func(_ context.Context, r json.RawMessage) ([]byte, error) { var res struct{ Content, Digest string } @@ -220,6 +288,7 @@ func TestTheBusNodeWritesTheNewestSignedUserListAlone(t *testing.T) { defer cancel() done := make(chan error, 1) go func() { done <- fl.Run(ctx) }() + <-f.watched for _, p := range c.after { f.ch <- update(p) } @@ -252,6 +321,14 @@ func TestTheBusNodeWritesTheNewestSignedUserListAlone(t *testing.T) { t.Fatal("a refused list was not reported") } } + if c.wantSource != "" { + for until := time.Now().Add(5 * time.Second); ; { + if _, st := fl.State(); st.Source == c.wantSource && (c.wantErr == "" || st.Error != "") || time.Now().After(until) { + break + } + time.Sleep(5 * time.Millisecond) + } + } time.Sleep(50 * time.Millisecond) // nothing more is written cancel() <-done @@ -261,6 +338,10 @@ func TestTheBusNodeWritesTheNewestSignedUserListAlone(t *testing.T) { t.Fatalf("written %q, want %q", writes, c.wantWrites) } module, st := fl.State() + if c.wantSource != "" && (st.Source != c.wantSource || st.Key != nodeKey.PublicKey || + (c.wantFollows != "" && st.Follows != c.wantFollows)) { + t.Fatalf("the have says source %q follows %q key %t; want %q %q", st.Source, st.Follows, st.Key == nodeKey.PublicKey, c.wantSource, c.wantFollows) + } if c.wantRefused { if !strings.Contains(st.Error, c.wantErr) || st.Digest != Digest([]byte(c.onDisk)) || st.Users != c.wantUsers { t.Fatalf("refused list's state %+v", st)