Files
mesh-host/internal/userlist/userlist_test.go
T
jschoubben 5e71ab6ae6
mesh/delivery merged: source.main moved to novox/mesh-host@d75c66dc7e27
mesh/delivery-group group fix/1945-user-list-follows merged: every member is merged
mesh/check pass 5e71ab6ae612 on d75c66dc7e27 | unit 303 run, 1077 kept, 5 skipped | integration 306 run, 1079 kept, 4 skipped | over bound: unit, inte…
Count the bus's users from the list a digest names, take only a composition of this node and module, and refuse a list that would lock the mesh out (review of the bridge, ADR 0547 §8)
B1: the controller sends the user list by digest (over 64 KiB), so its users are counted from the list as resolved, not
from the composition's inline content, which is empty then.
R1: a composition signed for another node or module than its key names is no source.
R2: a list without the controller's own user, or one that drops more than half the users of the file on the machine,
is not written; the have says why and the file on the machine is kept.
2026-10-25 22:00:05 +01:00

292 lines
9.8 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; 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)
}
})
}
}