package userlist import ( "context" "crypto/ed25519" "encoding/json" "errors" "strings" "sync" "testing" "time" "git.novox.be/novox/mesh-sdk/go/delivery" "github.com/novox/mesh-host/internal/agent" ) const node = "novox" func key() (ed25519.PublicKey, ed25519.PrivateKey) { pub, k, err := ed25519.GenerateKey(nil) if err != nil { panic(err) } return pub, k } var controllerPub, controllerKey = key() var _, strangerKey = key() func fpOf(c string) string { return delivery.SourcePrefix2 + strings.Repeat(c, 64) } // feed is a watch the test writes to: the keys there before the watch, Caught, then each put. type feed struct { ch chan agent.Update rev uint64 first []agent.Update } func (f *feed) Watch(context.Context, string) (<-chan agent.Update, error) { for _, u := range f.first { f.ch <- u } f.ch <- agent.Update{Caught: true} return f.ch, nil } func list(users ...string) string { var b strings.Builder b.WriteString("authorization {\n users = [\n") for _, u := range users { b.WriteString(` {user: "` + u + `", password: "$2a$11$x"}` + "\n") } b.WriteString(" ]\n}\n") return b.String() } // comp is a composition of module at fp holding the user list content (none when empty), signed by signer; by digest // when byDigest, as the controller sends a file over 64 KiB; for another node or module than its key when asked. func comp(t *testing.T, module, fp, content string, signer ed25519.PrivateKey, p put) (string, []byte) { t.Helper() owner := module if p.asModule != "" { owner = p.asModule } server, _ := json.Marshal(map[string]any{"id": owner + ".server-conf", "type": "file", "module": owner, "path": "/var/lib/nats-module/conf/nats.conf", "content": "include accounts.conf\n"}) rs := []json.RawMessage{server} if content != "" { r := map[string]any{"id": owner + ".bus-users", "type": "file", "module": owner, "path": "/var/lib/nats-module/conf/accounts.conf", "content": content, "mode": "0600"} if p.byDigest { delete(r, "content") r["digest"], r["size"] = Digest([]byte(content)), len(content) } users, _ := json.Marshal(r) rs = append(rs, users) } c := delivery.Composed{Node: node, Module: module, FP: fp, Resources: rs} if p.asNode != "" { c.Node = p.asNode } if p.asModule != "" { c.Module = p.asModule } k, err := delivery.ComposedKey(node, module, fp) if err != nil { t.Fatal(err) } signed, err := delivery.SignComposed(c, signer) if err != nil { t.Fatal(err) } raw, err := delivery.EncodeComposed(signed) if err != nil { t.Fatal(err) } return k, raw } type put struct { module, fp, content string stranger, deleted bool byDigest bool asNode, asModule string } // Defends novox/hq ADR 0547 ยง8: the bus node writes the newest signed list alone, only when it changed, and reports it. func TestTheBusNodeWritesTheNewestSignedUserListAlone(t *testing.T) { one, two, three := list("controller", "a"), list("controller", "a", "b"), list("controller", "a", "b", "c") five := list("controller", "a", "b", "c", "d") for _, c := range []struct { name string before, after []put again bool failWrite bool onDisk string wantWrites []string // contents written, in order wantUsers int wantErr string wantRefused bool }{ {name: "the list there is written once", before: []put{{module: "nats", fp: fpOf("a"), content: two}}, wantWrites: []string{two}, wantUsers: 3}, {name: "a newer revision is written, the older fingerprint is not", before: []put{{module: "nats", fp: fpOf("a"), content: one}, {module: "nats", fp: fpOf("b"), content: two}}, after: []put{{module: "nats", fp: fpOf("a"), content: three}}, wantWrites: []string{two, three}, wantUsers: 4}, {name: "a composition signed by a stranger is no source", before: []put{{module: "nats", fp: fpOf("a"), content: one}}, after: []put{{module: "nats", fp: fpOf("b"), content: three, stranger: true}}, wantWrites: []string{one}, wantUsers: 2}, {name: "a composition without the list is ignored", before: []put{{module: "web", fp: fpOf("a"), content: ""}}}, {name: "a deleted key is no source", before: []put{{module: "nats", fp: fpOf("a"), content: one}, {module: "nats", fp: fpOf("b"), content: two}}, after: []put{{module: "nats", fp: fpOf("b"), content: "", deleted: true}, {module: "nats", fp: fpOf("a"), content: three}}, wantWrites: []string{two, three}, wantUsers: 4}, {name: "asked again, the same source is written again", before: []put{{module: "nats", fp: fpOf("a"), content: two}}, again: true, wantWrites: []string{two, two}, wantUsers: 3}, {name: "a list sent by digest counts the users of what it names", before: []put{{module: "nats", fp: fpOf("a"), content: three, byDigest: true}}, wantWrites: []string{three}, wantUsers: 4}, {name: "another node's composition under this node's key is no source", before: []put{{module: "nats", fp: fpOf("a"), content: two, asNode: "ace"}}}, {name: "another module's composition under the bus module's key is no source", before: []put{{module: "nats", fp: fpOf("a"), content: two, asModule: "web"}}}, {name: "a list without the controller is refused and the file kept", before: []put{{module: "nats", fp: fpOf("a"), content: list("a", "b")}}, onDisk: two, wantUsers: 2, wantErr: "holds no user \"controller\"", wantRefused: true}, {name: "a list that drops more than half is refused and the file kept", before: []put{{module: "nats", fp: fpOf("a"), content: one}}, onDisk: five, wantUsers: 2, wantErr: "drops more than half", wantRefused: true}, {name: "a list that drops half is written", before: []put{{module: "nats", fp: fpOf("a"), content: two}}, 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"}, } { t.Run(c.name, func(t *testing.T) { f := &feed{ch: make(chan agent.Update, 64)} 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, Trusted: func() []ed25519.PublicKey { return []ed25519.PublicKey{controllerPub} }, Resolve: func(_ context.Context, r json.RawMessage) ([]byte, error) { var res struct{ Content, Digest string } _ = json.Unmarshal(r, &res) if res.Digest != "" { body, ok := objects[res.Digest] if !ok { return nil, errors.New("no object " + res.Digest) } return []byte(body), nil } return []byte(res.Content), nil }, OnDisk: func(json.RawMessage) ([]byte, error) { if c.onDisk == "" { return nil, nil } return []byte(c.onDisk), nil }, Write: func(_ context.Context, module string, r json.RawMessage, body []byte) (string, bool, error) { mu.Lock() writes = append(writes, string(body)) mu.Unlock() if c.failWrite { return "", false, errors.New("disk full") } return Digest(body), true, nil }, Report: func(_ context.Context, module string, w Written) { mu.Lock() reports = append(reports, w) mu.Unlock() reported <- struct{}{} }, Now: func() time.Time { return time.Date(2026, 10, 25, 21, 50, 0, 0, time.UTC) }, }) update := func(p put) agent.Update { f.rev++ signer := controllerKey if p.stranger { signer = strangerKey } k, raw := comp(t, p.module, p.fp, p.content, signer, p) if p.byDigest { objects[Digest([]byte(p.content))] = p.content } if p.deleted { return agent.Update{Key: k, Revision: f.rev, Deleted: true} } return agent.Update{Key: k, Value: raw, Revision: f.rev} } for _, p := range c.before { f.first = append(f.first, update(p)) } ctx, cancel := context.WithCancel(context.Background()) defer cancel() done := make(chan error, 1) go func() { done <- fl.Run(ctx) }() for _, p := range c.after { f.ch <- update(p) } if c.again { // After the first write is reported, so the look again is not folded into it. select { case <-reported: case <-time.After(5 * time.Second): } fl.Again() } deadline := time.After(5 * time.Second) for { mu.Lock() n := len(writes) mu.Unlock() if n >= len(c.wantWrites) { break } select { case <-deadline: t.Fatalf("writes %d, want %d", n, len(c.wantWrites)) case <-time.After(5 * time.Millisecond): } } if c.wantRefused { select { case <-reported: case <-time.After(5 * time.Second): t.Fatal("a refused list was not reported") } } time.Sleep(50 * time.Millisecond) // nothing more is written cancel() <-done mu.Lock() defer mu.Unlock() if strings.Join(writes, "|") != strings.Join(c.wantWrites, "|") { t.Fatalf("written %q, want %q", writes, c.wantWrites) } module, st := fl.State() 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) } return } if len(c.wantWrites) == 0 { if module != "" || len(reports) != 0 { t.Fatalf("a node with no list wrote or said something: %q %+v", module, reports) } return } if module != "nats" || st.Users != c.wantUsers || st.Wanted != Digest([]byte(c.wantWrites[len(c.wantWrites)-1])) { t.Errorf("state %q %+v", module, st) } if c.wantErr != "" { if !strings.Contains(st.Error, c.wantErr) || st.Digest != "" { t.Errorf("a failed write is not kept: %+v", st) } } else if st.Digest != st.Wanted || st.Error != "" { t.Errorf("written %+v", st) } if len(reports) == 0 || reports[len(reports)-1] != st { t.Errorf("reports %+v, last state %+v", reports, st) } }) } }