Files
mesh-host/internal/userlist/userlist_test.go
T
jschoubben 3f02bd7ff6 The bus node writes the bus's user list alone from the newest composition of the bus module, with no want, and says it in the bus module's have (ADR 0547 §8, issue 1945)
The list reached the bus only through a want of the bus module, which nobody writes (ADR 0539 §15): on 2026-10-25 it
stood from 11:23 to 20:50 and was put back by hand. The node agent now follows its composition bucket for the newest
signed composition carrying the user list, writes that file alone through the apply's file step when it differs
(mode 0600, read back, recorded as the module's), moves nothing that reads it (the bus module's entrypoint reloads the
server in place), never goes back to an older list, looks again after every apply of the bus module, and says source,
revision, wanted and written digests, users and any error in the bus module's have (mesh-sdk UserListWritten).

Read with mesh-delivery-view.node_haves (module nats); the bus's own nats_user_can says in_force once it reloaded.
2026-10-25 21:50:49 +01:00

223 lines
7.1 KiB
Go

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.
func comp(t *testing.T, module, fp, content string, signer ed25519.PrivateKey) (string, []byte) {
t.Helper()
server, _ := json.Marshal(map[string]any{"id": module + ".server-conf", "type": "file", "module": module,
"path": "/var/lib/nats-module/conf/nats.conf", "content": "include accounts.conf\n"})
rs := []json.RawMessage{server}
if content != "" {
users, _ := json.Marshal(map[string]any{"id": module + ".bus-users", "type": "file", "module": module,
"path": "/var/lib/nats-module/conf/accounts.conf", "content": content, "mode": "0600"})
rs = append(rs, users)
}
c := delivery.Composed{Node: node, Module: module, FP: fp, Resources: rs}
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
}
// 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("a"), list("a", "b"), list("a", "b", "c")
for _, c := range []struct {
name string
before, after []put
again bool
failWrite bool
wantWrites []string // contents written, in order
wantUsers int
wantErr string
}{
{name: "the list there is written once", before: []put{{"nats", fpOf("a"), two, false, false}},
wantWrites: []string{two}, wantUsers: 2},
{name: "a newer revision is written, the older fingerprint is not",
before: []put{{"nats", fpOf("a"), one, false, false}, {"nats", fpOf("b"), two, false, false}},
after: []put{{"nats", fpOf("a"), three, false, false}},
wantWrites: []string{two, three}, wantUsers: 3},
{name: "a composition signed by a stranger is no source",
before: []put{{"nats", fpOf("a"), one, false, false}},
after: []put{{"nats", fpOf("b"), three, true, false}},
wantWrites: []string{one}, wantUsers: 1},
{name: "a composition without the list is ignored", before: []put{{"web", fpOf("a"), "", false, false}}},
{name: "a deleted key is no source",
before: []put{{"nats", fpOf("a"), one, false, false}, {"nats", fpOf("b"), two, false, false}},
after: []put{{"nats", fpOf("b"), "", false, true}, {"nats", fpOf("a"), three, false, false}},
wantWrites: []string{two, three}, wantUsers: 3},
{name: "asked again, the same source is written again", before: []put{{"nats", fpOf("a"), two, false, false}},
again: true, wantWrites: []string{two, two}, wantUsers: 2},
{name: "a failed write is said and kept", before: []put{{"nats", fpOf("a"), two, false, false}}, failWrite: true,
wantWrites: []string{two}, wantUsers: 2, wantErr: "disk full"},
} {
t.Run(c.name, func(t *testing.T) {
f := &feed{ch: make(chan agent.Update, 64)}
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} },
Write: func(_ context.Context, module string, r json.RawMessage) (string, bool, error) {
var res struct{ Content string }
_ = json.Unmarshal(r, &res)
mu.Lock()
writes = append(writes, res.Content)
mu.Unlock()
if c.failWrite {
return "", false, errors.New("disk full")
}
return Digest([]byte(res.Content)), 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)
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):
}
}
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 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)
}
})
}
}