Files
mesh-catalog/modules/mesh-mail-web/cmd/mesh-mail-web/manifest_test.go
T
jochen 523d7846cd Fix mesh-mail-web's manifest: operators is type list, the listen word names its port number (issue 1262)
The controller refused the manifest: config.operators declared type "array" (not one of the controller's
five types, novox/hq ADR 0348 §4 — the word is "list"), and the bundle's env word MESH_MAIL_WEB_LISTEN said
${port:web} (a bundle's word takes the port NUMBER a listens entry gives, novox/hq ADR 0192; mesh-controller
internal/catalogue/port_into.go portsFilledInto matches it against listens[].Port, never an endpoint's
label). Fixed to type "list" and ${port:8099} (the listens entry's own port). main.go and the integration
harness already read/write a plain port number, so neither needed a change. manifest_test.go now asserts
both: config.operators.Type == "list", and the listen word equals ${port:<the listens entry's port>};
confirmed each independently fails against the old values before this fix.
2026-10-19 22:47:56 +02:00

185 lines
7.1 KiB
Go

package main
// The manifest says what the code does (novox/hq ADR 0440 §1, §2, §3, §8; issue 1262).
//
// 1262/AC5: the repository's own way to run "the controller's manifest rules" is merge-check.sh, which shells
// out to the real controller binary ("$MESH_GATE" module check modules/*/module.json, merge-check.sh:127) — not
// something this package can call as a unit test, and this batch's brief refuses running merge-check.sh or any
// integration test. mesh-mail's and mesh-mail-notifier's own manifest_test.go files do not run the controller
// either: they assert the manifest's shape by hand, field by field, in Go. There is no unit-level way in this
// repository to run the controller's actual module-check rules, so TestTheManifestSaysWhatTheCodeDoes below is
// as far as the repo allows for AC5: it is the same hand-written shape assertion the sibling modules use, now
// covering every field AC1 names. The real module check still runs once, for real, through the lead's
// mesh-controller.try.
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"reflect"
"testing"
)
type seat struct {
Name string `json:"name"`
Scope string `json:"scope"`
Serves []string `json:"serves"`
}
type listen struct {
Name string `json:"name"`
Port int `json:"port"`
Protocol string `json:"protocol"`
From string `json:"from"`
}
type configEntry struct {
Type string `json:"type"`
Trusted bool `json:"trusted"`
Default []string `json:"default"`
}
type manifest struct {
Module string `json:"module"`
RunsAs string `json:"runs-as"`
Claims []seat `json:"claims"`
Invokes []string `json:"invokes"`
Reads []string `json:"reads"`
Requires []string `json:"requires"`
Contributes map[string]map[string]any `json:"contributes"`
Binds map[string]string `json:"binds"`
Secrets map[string]string `json:"secrets"`
Listens []listen `json:"listens"`
Config map[string]configEntry `json:"config"`
Tools []string `json:"tools"`
State []any `json:"state"`
OwnSecrets map[string]string `json:"own-secrets"`
SecretsOwner string `json:"secrets-owner"`
Resources []map[string]any `json:"resources"`
Build struct {
Artifacts []struct {
From string `json:"from"`
Binary string `json:"binary"`
Loads []string `json:"loads"`
Env map[string]string `json:"env"`
} `json:"artifacts"`
} `json:"build"`
}
func readManifest(t *testing.T) manifest {
t.Helper()
raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
if err != nil {
t.Fatal(err)
}
var m manifest
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
return m
}
// 1262/AC1 (and, as far as the repo allows, AC5 — see the file's own comment above).
func TestTheManifestSaysWhatTheCodeDoes(t *testing.T) {
m := readManifest(t)
if m.Module != "mesh-mail-web" {
t.Fatalf("module %q", m.Module)
}
if m.RunsAs != "mesh-mail-web" {
t.Fatalf("runs-as %q, want its own account", m.RunsAs)
}
wantClaims := []seat{{Name: "mail-webclient", Scope: "mesh", Serves: []string{"open", "status"}}}
if !reflect.DeepEqual(m.Claims, wantClaims) {
t.Fatalf("claims %+v, want %+v", m.Claims, wantClaims)
}
wantInvokes := []string{
"seat:mail-store.accounts", "seat:mail-store.search", "seat:mail-store.read",
"seat:mail-store.send", "seat:mail-store.send-status",
}
if !reflect.DeepEqual(m.Invokes, wantInvokes) {
t.Fatalf("invokes %v, want exactly the five mail-store verbs %v", m.Invokes, wantInvokes)
}
if !reflect.DeepEqual(m.Reads, []string{"mesh-mail.accounts"}) {
t.Fatalf("reads %v, want [mesh-mail.accounts]", m.Reads)
}
if !contains(m.Requires, "route") || !contains(m.Requires, "oidc-client") {
t.Fatalf("requires %v, want route and oidc-client", m.Requires)
}
if len(m.Listens) != 1 || m.Listens[0].Name != "web" || m.Listens[0].From != "mesh" || m.Listens[0].Protocol != "tcp" {
t.Fatalf("listens %+v, want exactly one, named web, from the mesh only", m.Listens)
}
// "list" is the controller's own word for a trusted array value (novox/hq ADR 0348 §4); "array" is not
// one of its five types (number, string, boolean, object, list) and the controller refuses it.
op, ok := m.Config["operators"]
if !ok || op.Type != "list" || !op.Trusted || len(op.Default) != 0 {
t.Fatalf("config.operators %+v, want a trusted list defaulting to nobody", op)
}
// No database, no state bucket, no writable directory beyond the account's own state dir: only the
// account (a user resource, not writable), the state directory, and the two files rendered into it.
if len(m.State) != 0 {
t.Fatalf("state %v, want none", m.State)
}
for _, dbish := range []string{"postgres-database", "s3-bucket"} {
if contains(m.Requires, dbish) {
t.Fatalf("requires %v, want no database or object store", m.Requires)
}
}
wantResourceIDs := map[string]bool{"account": true, "state": true, "operators": true, "oidc-config": true}
if len(m.Resources) != len(wantResourceIDs) {
t.Fatalf("resources %v, want exactly %v", m.Resources, wantResourceIDs)
}
for _, r := range m.Resources {
id := r["id"].(string)
if !wantResourceIDs[id] {
t.Fatalf("an unexpected resource %q: no writable directory beyond the state dir is wanted", id)
}
if id != "account" && r["type"] == "directory" && id != "state" {
t.Fatalf("an extra writable directory %q", id)
}
}
// The manifest's own tools match the code's (mirroring mesh-mail's and mesh-mail-notifier's own check).
var names []string
for _, tool := range (&Bundle{}).Tools() {
names = append(names, tool.Name)
}
if !reflect.DeepEqual(m.Tools, names) {
t.Fatalf("tools %v, the code serves %v", m.Tools, names)
}
a := m.Build.Artifacts
if len(a) != 2 || a[0].From != "cmd/mesh-mail-web" || a[0].Binary != "mesh-mail-web" || !reflect.DeepEqual(a[0].Loads, []string{"mesh-mail-web"}) {
t.Fatalf("build %+v", m.Build)
}
for k, v := range map[string]string{"MESH_MAIL_WEB_OPERATORS": "operators", "MESH_MAIL_WEB_OIDC": "oidc-config"} {
found := false
for _, r := range m.Resources {
if r["id"] == v {
found = a[0].Env[k] == r["path"]
}
}
if !found {
t.Fatalf("the bundle reads %s at %s, which does not match resource %s's path", k, a[0].Env[k], v)
}
}
// A bundle's word is ${port:<the number a listens entry gives>}, never the endpoint's own name
// (novox/hq ADR 0192; mesh-controller internal/catalogue/port_into.go portsFilledInto matches it against
// listens[].Port, a plain number, not a label like "web"). len(m.Listens)==1 is already checked above.
wantListen := fmt.Sprintf("${port:%d}", m.Listens[0].Port)
if a[0].Env["MESH_MAIL_WEB_LISTEN"] != wantListen {
t.Fatalf("MESH_MAIL_WEB_LISTEN is %q, want %q (the listens entry's own port number)",
a[0].Env["MESH_MAIL_WEB_LISTEN"], wantListen)
}
}
func contains(list []string, s string) bool {
for _, x := range list {
if x == s {
return true
}
}
return false
}