From fbcf44702cd6d0c193ec2ac978da4676e9911ef1 Mon Sep 17 00:00:00 2001 From: mesh-admin Date: Wed, 14 Oct 2026 05:48:49 +0000 Subject: [PATCH] Prove the list is read strictly and judges module check, the gate and registration Part 4: the tests of the list's reading and of the controller's commands on it (hq ADR 0348 clause 7, issue 661). Co-Authored-By: Claude Opus 5.5 --- cmd/mesh-controller/predefined_test.go | 197 +++++++++++++++++++++++++ internal/catalogue/predefined_test.go | 175 ++++++++++++++++++++++ 2 files changed, 372 insertions(+) create mode 100644 cmd/mesh-controller/predefined_test.go create mode 100644 internal/catalogue/predefined_test.go diff --git a/cmd/mesh-controller/predefined_test.go b/cmd/mesh-controller/predefined_test.go new file mode 100644 index 00000000..25f09c50 --- /dev/null +++ b/cmd/mesh-controller/predefined_test.go @@ -0,0 +1,197 @@ +package main + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/novox/mesh-controller/internal/catalogue" + "github.com/novox/mesh-controller/internal/link" +) + +// The list of predefined keys (novox/hq ADR 0348 §7, design 57 package K2, issue 661): `module check` and the +// merge gate judge each manifest against the list at the root of its tree; registration publishes the newest +// trunk build's list of the catalogue and judges every module against the list the mesh publishes. + +const theFirstKeys = `{ + "font-size": {"type": "number", "description": "the size text is shown in, in points", "minimum": 6, "maximum": 72}, + "agent-managed-settings": {"type": "object", "description": "the managed settings of every agent session, a JSON object", "trusted": true} +}` + +// notifierFallingBackTo is the notifier in the config form, its font size falling back to the key named. +func notifierFallingBackTo(key, kind string) string { + def := "10" + if kind == "string" { + def = `"10"` + } + return `{"module":"notifier","version":"1", + "config":{"font-size":{"type":"` + kind + `","description":"the notification's font size, in points", + "default":` + def + `,"predefined":"` + key + `"}}, + "resources":[{"id":"conf","type":"file","path":"/etc/notifier/notifier.conf","mode":"0644", + "content":"font = ${config:font-size}\n"}]}` +} + +// withoutAPublishedList judges the test against no list the mesh published, and puts the process's back after. +func withoutAPublishedList(t *testing.T) { + t.Helper() + was := catalogue.ReplacePublished(nil) + t.Cleanup(func() { catalogue.ReplacePublished(was) }) +} + +// aRepositoryTree is a repository's checkout holding one module at modules/notifier, and the list when given. +func aRepositoryTree(t *testing.T, manifest string, list *string) string { + t.Helper() + root := t.TempDir() + if err := os.Mkdir(filepath.Join(root, ".git"), 0o755); err != nil { + t.Fatal(err) + } + if list != nil { + if err := os.WriteFile(filepath.Join(root, catalogue.PredefinedFile), []byte(*list), 0o644); err != nil { + t.Fatal(err) + } + } + return aTemplatedModule(t, filepath.Join(root, "modules", "notifier"), manifest, nil) +} + +func TestModuleCheckJudgesAManifestAgainstTheListAtItsRepositoryRoot(t *testing.T) { + withoutAPublishedList(t) + list := theFirstKeys + check := func(path string) (string, error) { + var out bytes.Buffer + err := moduleCheck([]string{path}, &out) + return out.String(), err + } + if out, err := check(aRepositoryTree(t, notifierFallingBackTo("font-size", "number"), &list)); err != nil || + !strings.Contains(out, "notifier: ok") { + t.Fatalf("a module naming font-size was refused: %v\n%s", err, out) + } + for _, c := range []struct { + manifest string + list *string + says string + }{ + {notifierFallingBackTo("font-scale", "number"), &list, `"font-scale", which is not on the list`}, + {notifierFallingBackTo("font-size", "string"), &list, "which is a number, and this value is a string"}, + {notifierFallingBackTo("font-size", "number"), nil, "is not present"}, + {notifierFallingBackTo("font-size", "number"), ptr(`{"font-size": {"type": "number"}}`), "no description"}, + } { + if out, err := check(aRepositoryTree(t, c.manifest, c.list)); err == nil || !strings.Contains(out, c.says) { + t.Errorf("wanted the check refused saying %q, got %v:\n%s", c.says, err, out) + } + } +} + +func ptr(s string) *string { return &s } + +// writeTheList puts the list at the root of a tree the gate judges. +func writeTheList(t *testing.T, tree, list string) string { + t.Helper() + if err := os.WriteFile(filepath.Join(tree, catalogue.PredefinedFile), []byte(list), 0o644); err != nil { + t.Fatal(err) + } + return tree +} + +func TestTheMergeGateJudgesEachManifestAgainstTheListOfTheTree(t *testing.T) { + withoutAPublishedList(t) + f, manifests := templatedMesh(t) + + v := gateJudged(t, f, writeTheList(t, aTree(t, withEdit(manifests, "notifier", + notifierFallingBackTo("font-size", "number"))), theFirstKeys), "modules/notifier/module.json") + if v.Verdict == "fail" || v.Modules["notifier"] != "changed" { + t.Fatalf("a module naming font-size on the tree's list failed the gate (%v):\n%s", v.Modules, v.Report()) + } + for _, c := range []struct{ manifest, list, says string }{ + {notifierFallingBackTo("font-scale", "number"), theFirstKeys, `"font-scale", which is not on the list`}, + {notifierFallingBackTo("font-size", "string"), theFirstKeys, "which is a number"}, + {inlineNotifier, `{"font-size": {"type": "number", "description": "points", "unit": "pt"}}`, `unknown field "unit"`}, + } { + v := gateJudged(t, f, writeTheList(t, aTree(t, withEdit(manifests, "notifier", c.manifest)), c.list), + "modules/notifier/module.json", catalogue.PredefinedFile) + if v.Verdict != "fail" || !strings.Contains(v.Report(), c.says) { + t.Errorf("wanted the gate to fail saying %q:\n%s", c.says, v.Report()) + } + } +} + +// catalogueBuild is a trunk build of the catalogue carrying the list at a generation. +func catalogueBuild(id, manifest, list string, generation int) link.BuildResult { + r := link.BuildResult{ID: id, Repository: "http://forge.internal:20000/novox/mesh-catalog.git", + Path: "modules/notifier", Ref: "main", On: "anchor", Commit: strings.Repeat(id[len(id)-1:], 12), + Manifest: json.RawMessage(manifest), Source: &link.SourceOnSeat{Seat: "git", Repository: "novox/mesh-catalog"}} + if list != "" { + r.Predefined = &link.PredefinedList{Keys: json.RawMessage(list), Generation: generation} + } + return r +} + +func TestRegistrationPublishesTheNewestTrunkListAndJudgesEveryModuleAgainstIt(t *testing.T) { + withoutAPublishedList(t) + open := aMesh(t) + ctx := t.Context() + + // A manifest naming font-size registers, and its build's list is published. + if _, _, err := takeIn(ctx, open.inventory, asTheOperator(t, open.inventory, + catalogueBuild("b-1", notifierFallingBackTo("font-size", "number"), theFirstKeys, 10))); err != nil { + t.Fatalf("a module naming font-size was not registered: %v", err) + } + kept, has, err := open.inventory.PredefinedKept(ctx) + if err != nil || !has || kept.Generation != 10 || kept.Build != "b-1" { + t.Fatalf("the list was not published: %+v %v %v", kept, has, err) + } + if got := catalogue.PublishedPredefined(); len(got) != 2 { + t.Fatalf("the controller judges against %v, not the list published", got) + } + + // One naming a key not on the list, or of another type, is refused and never enters the catalogue. + for i, c := range []struct{ manifest, says string }{ + {notifierFallingBackTo("font-scale", "number"), "not on the list"}, + {notifierFallingBackTo("font-size", "string"), "which is a number"}, + } { + id := []string{"b-2", "b-3"}[i] + if _, _, err := takeIn(ctx, open.inventory, catalogueBuild(id, c.manifest, theFirstKeys, 10)); err == nil || + !strings.Contains(err.Error(), c.says) { + t.Errorf("wanted the registration refused saying %q, got %v", c.says, err) + } + } + src, err := open.inventory.SourceOf(ctx, "notifier") + if err != nil || src.BuiltFrom != strings.Repeat("1", 12) { + t.Fatalf("a refused build replaced the registered one: %+v %v", src, err) + } + + // A newer build's list, which drops agent-managed-settings, replaces the published one. + smaller := `{"font-size": {"type": "number", "description": "points", "minimum": 6, "maximum": 72}}` + if _, _, err := takeIn(ctx, open.inventory, catalogueBuild("b-4", + notifierFallingBackTo("font-size", "number"), smaller, 12)); err != nil { + t.Fatal(err) + } + // The older build's outcome heard later registers nothing new about the list: the newer stands. + if _, _, err := takeIn(ctx, open.inventory, catalogueBuild("b-5", + notifierFallingBackTo("font-size", "number"), theFirstKeys, 11)); err != nil { + t.Fatal(err) + } + kept, _, _ = open.inventory.PredefinedKept(ctx) + if kept.Build != "b-4" || len(kept.Keys) != 1 || len(catalogue.PublishedPredefined()) != 1 { + t.Fatalf("an older build's list heard later replaced the newer one: %+v", kept) + } + + // A build of another repository publishes nothing, and its manifest is judged against the published list. + other := catalogueBuild("b-6", notifierFallingBackTo("font-size", "number"), theFirstKeys, 99) + other.Repository, other.Source.Repository = "http://forge.internal:20000/novox/notifier.git", "novox/notifier" + other.Manifest = json.RawMessage(strings.Replace(notifierFallingBackTo("font-size", "number"), `"notifier"`, + `"chimes"`, 1)) + if _, _, err := takeIn(ctx, open.inventory, asTheOperator(t, open.inventory, other)); err != nil { + t.Fatalf("a module of another repository naming a published key: %v", err) + } + if kept, _, _ = open.inventory.PredefinedKept(ctx); kept.Build != "b-4" { + t.Fatalf("a build of another repository published its list: %+v", kept) + } + other.ID, other.Manifest = "b-7", json.RawMessage(strings.Replace(notifierFallingBackTo("agent-managed-settings", + "number"), `"notifier"`, `"chimes"`, 1)) + if _, _, err := takeIn(ctx, open.inventory, other); err == nil || !strings.Contains(err.Error(), "not on the list") { + t.Fatalf("a key only another repository's list holds was registered: %v", err) + } +} diff --git a/internal/catalogue/predefined_test.go b/internal/catalogue/predefined_test.go new file mode 100644 index 00000000..c683f763 --- /dev/null +++ b/internal/catalogue/predefined_test.go @@ -0,0 +1,175 @@ +package catalogue + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +// The list of predefined keys (novox/hq ADR 0348 §7, design 57 package K2, issue 661): read strictly, and every +// manifest naming a key judged against the list of the tree it is read in. + +// theFirstKeys is the catalogue's first list: font-size and agent-managed-settings. +const theFirstKeys = `{ + "font-size": {"type": "number", "description": "the size text is shown in, in points", "minimum": 6, "maximum": 72}, + "agent-managed-settings": {"type": "object", "description": "the managed settings of every agent session, a JSON object", "trusted": true} +}` + +func TestTheFirstListIsRead(t *testing.T) { + list, err := ParsePredefined([]byte(theFirstKeys)) + if err != nil { + t.Fatal(err) + } + if got := strings.Join(list.Keys(), ","); got != "agent-managed-settings,font-size" { + t.Fatalf("keys %s", got) + } + if k := list["font-size"]; k.Type != ConfigNumber || *k.Minimum != 6 || *k.Maximum != 72 || k.Trusted { + t.Fatalf("font-size read as %+v", k) + } + if k := list["agent-managed-settings"]; k.Type != ConfigObject || !k.Trusted { + t.Fatalf("agent-managed-settings read as %+v", k) + } + // One list is one text, however it was written. + a, _ := list.Canonical() + again, _ := ParsePredefined(a) + b, _ := again.Canonical() + if string(a) != string(b) { + t.Fatalf("canonical text moved:\n%s\n%s", a, b) + } +} + +func TestAListIsRefusedWholeForAnyWrongKey(t *testing.T) { + for _, c := range []struct{ file, says string }{ + {`[]`, "not a JSON object of keys"}, + {`null`, "not a JSON object of keys"}, + {`{"font-size": {"type": "number", "description": "points", "default": 10}}`, `unknown field "default"`}, + {`{"font-size": {"description": "points"}}`, "no type"}, + {`{"font-size": {"type": "decimal", "description": "points"}}`, `type "decimal"`}, + {`{"font-size": {"type": "number"}}`, "no description"}, + {`{"accent": {"type": "object", "description": "colours"}}`, "not trusted"}, + {`{"font-size": {"type": "number", "description": "points", "minimum": 9, "maximum": 6}}`, "above its maximum"}, + {`{"accent": {"type": "string", "description": "a name", "pattern": "("}}`, "not a regular expression"}, + {`{"accent": {"type": "boolean", "description": "on or off", "minimum": 1}}`, "minimum or maximum is for a number"}, + {`{"Font Size": {"type": "number", "description": "points"}}`, "not a usable key"}, + {`{"places": {"type": "string", "description": "where"}}`, "placement"}, + {`{"puid": {"type": "number", "description": "an id"}}`, "placement"}, + } { + if _, err := ParsePredefined([]byte(c.file)); err == nil || !strings.Contains(err.Error(), c.says) { + t.Errorf("%s: wanted a refusal saying %q, got %v", c.file, c.says, err) + } + } +} + +// fallingBackTo is inConfigForm with its font size falling back to a predefined key. +func fallingBackTo(t *testing.T, key string) []byte { + t.Helper() + m := inConfigForm() + declaration(m, "font-size")["predefined"] = key + raw, err := json.Marshal(m) + if err != nil { + t.Fatal(err) + } + return raw +} + +// A manifest naming font-size registers against the list; one naming a key not on it, or of another type, is +// refused — judged against the list given, whatever the mesh published. +func TestAManifestIsJudgedAgainstTheListOfItsTree(t *testing.T) { + withPredefined(t, nil) + list, err := ParsePredefined([]byte(theFirstKeys)) + if err != nil { + t.Fatal(err) + } + if _, err := ParseManifestWith(fallingBackTo(t, "font-size"), list); err != nil { + t.Fatalf("a value falling back to font-size is refused: %v", err) + } + if _, err := ParseManifestWith(fallingBackTo(t, "font-scale"), list); err == nil || + !strings.Contains(err.Error(), `"font-scale", which is not on the list`) { + t.Fatalf("a key not on the list: %v", err) + } + m := inConfigForm() + declaration(m, "theme")["predefined"] = "font-size" + raw, _ := json.Marshal(m) + if _, err := ParseManifestWith(raw, list); err == nil || !strings.Contains(err.Error(), "which is a number") { + t.Fatalf("a key of another type: %v", err) + } + // Where no tree is read, the published list judges: none published refuses. + if _, err := ParseManifest(fallingBackTo(t, "font-size")); err == nil || !strings.Contains(err.Error(), "is not present") { + t.Fatalf("judged against no list: %v", err) + } + withPredefined(t, list) + if _, err := ParseManifest(fallingBackTo(t, "font-size")); err != nil { + t.Fatalf("judged against the published list: %v", err) + } +} + +// A template's text filled from the tree keeps the list the manifest was judged against: read again, it is not +// judged against the published one. +func TestTemplatesFilledKeepTheListTheManifestWasJudgedAgainst(t *testing.T) { + withPredefined(t, nil) + list, _ := ParsePredefined([]byte(theFirstKeys)) + dir := t.TempDir() + if err := os.MkdirAll(filepath.Join(dir, TemplatesDir), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, TemplatesDir, "rc"), []byte("font = ${config:font-size}\n"), 0o644); err != nil { + t.Fatal(err) + } + m := inConfigForm() + declaration(m, "font-size")["predefined"] = "font-size" + r := resourceOf(m, "configuration") + delete(r, "content") + r["template"] = TemplatesDir + "/rc" + m["build"] = map[string]any{"artifacts": []any{map[string]any{"name": "files", "kind": "archive", "from": "files"}}} + declaration(m, "width")["runtime"] = true + declaration(m, "sender")["runtime"] = true + raw, _ := json.Marshal(m) + parsed, err := ParseManifestWith(raw, list) + if err != nil { + t.Fatal(err) + } + if _, err := WithTemplatesFrom(parsed, dir); err != nil { + t.Fatalf("filled from the tree, it was judged against another list: %v", err) + } +} + +// module check reads the list at the root of the repository above the manifest; no repository, no list. +func TestTheListIsFoundAtTheRepositoryRootAboveAManifest(t *testing.T) { + root := t.TempDir() + module := filepath.Join(root, "modules", "dunst") + if err := os.MkdirAll(module, 0o755); err != nil { + t.Fatal(err) + } + if list, err := PredefinedAbove(module); err != nil || list != nil { + t.Fatalf("outside a repository: %v %v", list, err) + } + if err := os.Mkdir(filepath.Join(root, ".git"), 0o755); err != nil { + t.Fatal(err) + } + if list, err := PredefinedAbove(module); err != nil || list != nil { + t.Fatalf("a repository without the file: %v %v", list, err) + } + if err := os.WriteFile(filepath.Join(root, PredefinedFile), []byte(theFirstKeys), 0o644); err != nil { + t.Fatal(err) + } + list, err := PredefinedAbove(module) + if err != nil || len(list) != 2 { + t.Fatalf("the list above: %v %v", list, err) + } + if err := os.WriteFile(filepath.Join(root, PredefinedFile), []byte(`{"font-size": {}}`), 0o644); err != nil { + t.Fatal(err) + } + if _, err := PredefinedAbove(module); err == nil { + t.Fatal("a wrong list above was read") + } + if err := os.Remove(filepath.Join(root, PredefinedFile)); err != nil { + t.Fatal(err) + } + if err := os.Symlink("/etc/hostname", filepath.Join(root, PredefinedFile)); err == nil { + if _, err := PredefinedAbove(module); err == nil || !strings.Contains(err.Error(), "not a plain file") { + t.Fatalf("a link read as the list: %v", err) + } + } +}