251 lines
11 KiB
Go
251 lines
11 KiB
Go
package catalogue
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// A process resource may declare `bus` (novox/hq ADR 0436 §1): what it emits, the module's own work-queue
|
|
// streams it consumes, the module's own state it reads and writes, whether it reads the router's records of
|
|
// this module's own asks, and the tools and seat verbs it calls. The fixture is mesh-mail's future manifest
|
|
// shape (ADR 0436 §7, §8), copied from mesh-catalog/modules/mesh-mail/module.json and extended as settled by
|
|
// the lead: process id "send" is the manifest's own `id` (internal/catalogue/runtime.go:72-74 is the model for
|
|
// a process id); the account file uses the controller's real JSON tags (modules.go:1041-1056 issueWith).
|
|
func mailSenderProcessManifest(t *testing.T, bus string) string {
|
|
t.Helper()
|
|
process := `{"id":"send","type":"process","run":["./mesh-mail","send"],"user":"mesh-mail-send"`
|
|
if bus != "" {
|
|
process += `,"bus":` + bus
|
|
}
|
|
process += `}`
|
|
// The stream itself is plain here, not retention work-queue: 1179 checks only that a process names a
|
|
// stream the module owns, never its retention — that rule is 1178's (WorkQueueStreamProblems), and is
|
|
// tested against its own fixture there (work_queue_consumer_test.go) so this one stays accepted whether
|
|
// or not a process currently names "mail-sends" in bus.queues.
|
|
return `{"module":"mesh-mail","version":"1","emits":["mail.send.sent","mail.send.failed"],` +
|
|
`"state":["sends"],"streams":[{"name":"mail-sends","events":["mail.send.approved"],"max-age-days":1,` +
|
|
`"max-mib":64}],"resources":[{"id":"account","type":"user","name":"mesh-mail-send"},` + process + `]}`
|
|
}
|
|
|
|
const mailSenderBus = `{"emits":["mail.send.sent","mail.send.failed"],"queues":["mail-sends"],` +
|
|
`"state":["sends"],"asks":true,"invokes":["seat:operator-channel.tell"]}`
|
|
|
|
// 1179/AC1: a well-formed bus on a process whose account the module makes is accepted, and the catalogue
|
|
// gives it whole to the grant composition (ProcessBuses, which 1180 reads).
|
|
func TestAProcessDeclaresItsBusAndIsAccepted(t *testing.T) {
|
|
m, err := ParseManifest([]byte(mailSenderProcessManifest(t, mailSenderBus)))
|
|
if err != nil {
|
|
t.Fatalf("a well-formed process bus was refused: %v", err)
|
|
}
|
|
buses := m.ProcessBuses()
|
|
b, ok := buses["send"]
|
|
if !ok {
|
|
t.Fatalf("the process's bus was not given to the grant composition: %+v", buses)
|
|
}
|
|
if !strSliceEqual(b.Emits, []string{"mail.send.sent", "mail.send.failed"}) ||
|
|
!strSliceEqual(b.Queues, []string{"mail-sends"}) || !strSliceEqual(b.State, []string{"sends"}) ||
|
|
!b.Asks || !strSliceEqual(b.Invokes, []string{"seat:operator-channel.tell"}) {
|
|
t.Fatalf("the bus read as %+v", b)
|
|
}
|
|
|
|
// 1952 S5 (novox/hq ADR 0564 Limits 4; the lead, 2026-10-29): a tools bundle declares its own bus account, read as a
|
|
// process's, named after its binary and kept through resolution; a removal is from the closed set, alone, and the
|
|
// bus module's (mesh-broker) alone.
|
|
toolsIn := func(claims, bus, mode, where string) string {
|
|
return `{"module":"nats","version":"1","claims":` + claims + `,"own-secrets":{"broker":"${dir:mesh-state}/broker",` +
|
|
`"broker-nats-tools":"${dir:tools-secret}/broker-nats-tools"},"resources":[{"id":"mesh-state","type":"directory",` +
|
|
`"mode":"0700","place":"mesh","unknown":"program"},{"id":"tools-secret","type":"directory","mode":"` + mode +
|
|
`","path":"` + where + `","unknown":"program"}],"build":{"artifacts":[{"name":"tools","kind":"bundle",` +
|
|
`"language":"go","system":"arch","from":"cmd/nats-tools","binary":"nats-tools","loads":["nats-tools"],"bus":` + bus + `}]}}`
|
|
}
|
|
tools := func(claims, bus string) string { return toolsIn(claims, bus, "0711", "/var/lib/mesh/nats-tools") }
|
|
broker := `[{"name":"mesh-broker","scope":"mesh"}]`
|
|
for _, c := range []struct {
|
|
name, manifest, refused string
|
|
}{
|
|
{name: "the bus module's tools removing", manifest: tools(broker, `{"removes":["DEAD_LETTER_NOTICES.controller","mesh-host_acts"]}`)},
|
|
{name: "a removal outside the set", manifest: tools(broker, `{"removes":["EVENTS.a_m"]}`), refused: "not one of"},
|
|
{name: "a removal beside a grant", manifest: tools(broker, `{"removes":["mesh-host_acts"],"invokes":["nats.nats_streams"]}`), refused: "with nothing else"},
|
|
{name: "another module's tools removing", manifest: tools(`[]`, `{"removes":["mesh-host_acts"]}`), refused: "only the bus module"},
|
|
{name: "a tools bundle consuming a queue", manifest: tools(broker, `{"queues":["x"]}`), refused: "holds no consumer"},
|
|
{name: "a process removing", manifest: mailSenderProcessManifest(t, `{"removes":["mesh-host_acts"]}`), refused: "only a tools bundle"},
|
|
// The runtime's account reads the account's secret: it lies where others may pass (the host agent, 2026-10-29).
|
|
{name: "a tools secret the runtime cannot reach", manifest: toolsIn(broker, `{"removes":["mesh-host_acts"]}`, "0700",
|
|
"/var/lib/mesh/nats-tools"), refused: "cannot reach"},
|
|
// Its parents too: under the module's mesh place, /var/lib/mesh/nats is 0700 (the lead, read on novox 2026-10-29).
|
|
{name: "a tools secret beneath the module's mesh place", manifest: toolsIn(broker, `{"removes":["mesh-host_acts"]}`, "0711",
|
|
"${dir:mesh-state}/tools"), refused: "cannot reach"},
|
|
} {
|
|
m, err := ParseManifest([]byte(c.manifest))
|
|
if c.refused != "" {
|
|
if err == nil || !strings.Contains(err.Error(), c.refused) {
|
|
t.Errorf("%s: %v, want refused saying %q", c.name, err, c.refused)
|
|
}
|
|
continue
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("%s: %v", c.name, err)
|
|
}
|
|
accounts := m.BusAccounts()
|
|
if len(accounts) != 1 || accounts[0].ID != "nats-tools" || !accounts[0].Tools ||
|
|
!strSliceEqual(accounts[0].Bus.Removes, []string{RemovesControllerDeadLetterConsumer, RemovesEngineActs}) {
|
|
t.Errorf("%s: the accounts are %+v", c.name, accounts)
|
|
}
|
|
// Its secret's file is the runtime account's, as the runtime's own credential is; the module's other secrets stay
|
|
// its own.
|
|
if (Resolution{Account: "ops"}).ownSecretOwner(m, "broker-nats-tools") != "ops" ||
|
|
(Resolution{Account: "ops"}).ownSecretOwner(m, "broker") != "" || (Resolution{}).ownSecretOwner(m, "broker-nats-tools") != "" {
|
|
t.Errorf("%s: the tools secret's owner is %q", c.name, (Resolution{Account: "ops"}).ownSecretOwner(m, "broker-nats-tools"))
|
|
}
|
|
// Resolved, the build goes and its bundles keep the bus (build.go).
|
|
resolved := Manifest{Module: "nats", Bundles: []Bundle{{Name: "tools", Binary: "nats-tools", Bus: m.Build.Artifacts[0].Bus}}}
|
|
if got := resolved.BusAccounts(); len(got) != 1 || got[0].ID != "nats-tools" {
|
|
t.Errorf("%s: resolved, the accounts are %+v", c.name, got)
|
|
}
|
|
}
|
|
if got := m.BusAccounts(); len(got) != 1 || got[0].ID != "send" || got[0].Tools {
|
|
t.Errorf("the process's account is %+v", got)
|
|
}
|
|
}
|
|
|
|
func strSliceEqual(a, b []string) bool {
|
|
if len(a) != len(b) {
|
|
return false
|
|
}
|
|
for i := range a {
|
|
if a[i] != b[i] {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// 1179/AC2: a bus on a process with no user, user root, or the operator account is refused naming the
|
|
// process and the account rule.
|
|
func TestAProcessBusAccountMustBeTheModulesOwn(t *testing.T) {
|
|
for name, c := range map[string]struct{ user, says string }{
|
|
"no user": {"", "send"},
|
|
"user root": {"root", "root"},
|
|
"the operator account": {"${machine:account}", "operator"},
|
|
} {
|
|
raw := `{"module":"mesh-mail","version":"1","emits":["mail.send.sent"],"state":["sends"],` +
|
|
`"streams":[{"name":"mail-sends","events":["mail.send.approved"],"retention":"work-queue",` +
|
|
`"max-deliveries":5}],"resources":[{"id":"account","type":"user","name":"mesh-mail-send"},` +
|
|
`{"id":"send","type":"process","run":["./mesh-mail","send"]`
|
|
if c.user != "" {
|
|
raw += `,"user":"` + c.user + `"`
|
|
}
|
|
raw += `,"bus":{"emits":["mail.send.sent"]}}]}`
|
|
_, err := ParseManifest([]byte(raw))
|
|
if err == nil || !strings.Contains(err.Error(), "send") || !strings.Contains(err.Error(), c.says) {
|
|
t.Errorf("%s: %v; want a refusal naming the process and %q", name, err, c.says)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 1179/AC3: a bus naming a stream, state or event the module does not declare as its own is refused naming
|
|
// it.
|
|
func TestAProcessBusNamesOnlyTheModulesOwn(t *testing.T) {
|
|
for name, c := range map[string]struct{ bus, says string }{
|
|
"another module's event": {`{"emits":["billing.paid"]}`, "billing.paid"},
|
|
"another module's queue": {`{"queues":["other-queue"]}`, "other-queue"},
|
|
"another module's state": {`{"state":["other-state"]}`, "other-state"},
|
|
} {
|
|
_, err := ParseManifest([]byte(mailSenderProcessManifest(t, c.bus)))
|
|
if err == nil || !strings.Contains(err.Error(), c.says) {
|
|
t.Errorf("%s: %v; want a refusal naming %q", name, err, c.says)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 1179/AC4: a bus declaring serves, holds, claims or a seat is refused naming the key; a bus with invokes of
|
|
// a tool or a seat verb is accepted.
|
|
func TestAProcessBusNeverServesOrHoldsASeat(t *testing.T) {
|
|
for name, c := range map[string]struct{ bus, says string }{
|
|
"serves": {`{"emits":["mail.send.sent"],"serves":["send"]}`, "serves"},
|
|
"holds": {`{"emits":["mail.send.sent"],"holds":["mail-sender"]}`, "holds"},
|
|
"claims": {`{"emits":["mail.send.sent"],"claims":["mail-sender"]}`, "claims"},
|
|
"seat": {`{"emits":["mail.send.sent"],"seat":"mail-sender"}`, "seat"},
|
|
} {
|
|
_, err := ParseManifest([]byte(mailSenderProcessManifest(t, c.bus)))
|
|
if err == nil || !strings.Contains(err.Error(), c.says) {
|
|
t.Errorf("%s: %v; want a refusal naming %q", name, err, c.says)
|
|
}
|
|
}
|
|
accepted := `{"emits":["mail.send.sent","mail.send.failed"],"invokes":["seat:operator-channel.tell","mail.status"]}`
|
|
if _, err := ParseManifest([]byte(mailSenderProcessManifest(t, accepted))); err != nil {
|
|
t.Fatalf("a bus invoking a tool and a seat verb was refused: %v", err)
|
|
}
|
|
}
|
|
|
|
// 1179/AC5: a process declaring bus is composed with no bus key on the wire, and is otherwise byte for byte
|
|
// what main composes for the same process without bus.
|
|
func TestAProcessesBusNeverReachesTheWire(t *testing.T) {
|
|
withBus, err := ParseManifest([]byte(mailSenderProcessManifest(t, mailSenderBus)))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
withoutBus, err := ParseManifest([]byte(mailSenderProcessManifest(t, "")))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
with := Rendering{Needed: map[string]map[string]string{}}
|
|
outWith, err := Resolution{Node: "novox", Modules: []Manifest{withBus}}.Declaration(with)
|
|
if err != nil {
|
|
t.Fatalf("composing with bus: %v", err)
|
|
}
|
|
outWithout, err := Resolution{Node: "novox", Modules: []Manifest{withoutBus}}.Declaration(with)
|
|
if err != nil {
|
|
t.Fatalf("composing without bus: %v", err)
|
|
}
|
|
var withProcess, withoutProcess map[string]any
|
|
for _, r := range outWith {
|
|
if r["id"] == "mesh-mail.send" {
|
|
withProcess = r
|
|
}
|
|
}
|
|
for _, r := range outWithout {
|
|
if r["id"] == "mesh-mail.send" {
|
|
withoutProcess = r
|
|
}
|
|
}
|
|
if withProcess == nil || withoutProcess == nil {
|
|
t.Fatalf("the process was not composed at all: with=%v without=%v", withProcess, withoutProcess)
|
|
}
|
|
if _, has := withProcess["bus"]; has {
|
|
t.Fatalf("the process resource carried a bus key onto the wire: %v", withProcess)
|
|
}
|
|
delete(withProcess, "bus")
|
|
if !mapsEqual(withProcess, withoutProcess) {
|
|
t.Fatalf("with bus declared, the process composed differently: %+v\nwant: %+v", withProcess, withoutProcess)
|
|
}
|
|
}
|
|
|
|
func mapsEqual(a, b map[string]any) bool {
|
|
if len(a) != len(b) {
|
|
return false
|
|
}
|
|
for k, v := range a {
|
|
bv, ok := b[k]
|
|
if !ok {
|
|
return false
|
|
}
|
|
if av, ok := v.([]any); ok {
|
|
bbv, ok := bv.([]any)
|
|
if !ok || len(av) != len(bbv) {
|
|
return false
|
|
}
|
|
for i := range av {
|
|
if av[i] != bbv[i] {
|
|
return false
|
|
}
|
|
}
|
|
continue
|
|
}
|
|
if v != bv {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|