Files
mesh-catalog/modules/mesh-mail/cmd/mesh-mail/manifest_test.go
T

332 lines
14 KiB
Go

package main
import (
"encoding/json"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
)
// The manifest says what the code does (novox/hq ADR 0359): the database and the bucket it requires (§3), the own
// secret family issued outside the mesh (§5, ADR 0283 §6), the two trusted settings (§4), the tools (§5), the events,
// its two long-running processes with their health (§6), and — ADR 0378 as amended by ADR 0379, issue 800 part A —
// the three mail seats it declares and the one, mail-store, it claims. It is assigned to no machine by itself.
type seat struct {
Name string `json:"name"`
Scope string `json:"scope"`
Serves []string `json:"serves"`
Needs []string `json:"needs"`
}
type manifest struct {
Module string `json:"module"`
Slug string `json:"slug"`
Seats []seat `json:"seats"`
Requires []string `json:"requires"`
Contributes map[string]map[string]any `json:"contributes"`
OwnSecrets map[string]map[string]string `json:"own-secrets"`
Tools []string `json:"tools"`
Emits []string `json:"emits"`
State []string `json:"state"`
Streams []struct {
Name string `json:"name"`
Events []string `json:"events"`
MaxAgeDays int `json:"max-age-days"`
MaxMiB int `json:"max-mib"`
Retention string `json:"retention"`
MaxDeliveries int `json:"max-deliveries"`
Duplicates int `json:"duplicates"`
Grant struct {
Config string `json:"config"`
Events string `json:"events"`
} `json:"grant"`
} `json:"streams"`
Consumes []string `json:"consumes"`
Invokes []string `json:"invokes"`
Claims []seat `json:"claims"`
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, map[string]map[string]any) {
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)
}
byID := map[string]map[string]any{}
for _, r := range m.Resources {
byID[r["id"].(string)] = r
}
return m, byID
}
func TestTheManifestSaysWhatTheCodeDoes(t *testing.T) {
m, byID := readManifest(t)
if m.Module != Module || !reflect.DeepEqual(m.Requires, []string{"postgres-database", "s3-bucket"}) || m.Contributes["postgres-database"]["name"] != "mail" {
t.Fatalf("module %s, requires %v, contributes %v", m.Module, m.Requires, m.Contributes)
}
// mesh_<node>_<slug> fits the S3 access key's 20 characters on every node.
if len("mesh_shanks_"+m.Slug) > 20 {
t.Fatalf("the slug %q overflows an S3 access key", m.Slug)
}
fam := m.OwnSecrets[SecretFamily+"*"]
if fam["issued-by"] != "outside" || fam["path"] != "${dir:state}/"+SecretFamily+"*.secret" || len(m.OwnSecrets) != 2 {
t.Fatalf("own secrets %v", m.OwnSecrets)
}
// 1255/AC7, 1282: the module declares the account and mail buckets' own state, beside `sends`.
if !reflect.DeepEqual(m.State, []string{"sends", "accounts", "mails"}) {
t.Fatalf("state %v, want [sends accounts mails]", m.State)
}
var names []string
for _, tool := range (&Bundle{}).Tools() {
names = append(names, tool.Name)
}
wantTools := []string{
"mail_accounts", "mail_search", "mail_read", "mail_health", "mail_send", "mail_send_status",
"mail-store.accounts", "mail-store.search", "mail-store.read", "mail-store.send", "mail-store.send-status",
}
if !reflect.DeepEqual(m.Tools, names) || !reflect.DeepEqual(names, wantTools) {
t.Fatalf("tools %v, the code serves %v", m.Tools, names)
}
// Its events go to its own stream MAIL, kept a week, each account's part heard only by the consumers the trusted
// config value grants names for it (ADR 0359 §2, §4; ADR 0363): none on the shared EVENTS stream. The module's
// top-level emits is the sender's two outcomes (novox/hq ADR 0436 §7, §11, issue 1172): the module as a whole may
// publish them, though only the process send actually does (its own bus.emits, checked in
// TestItsManifestDeclaresTheSenderProcess).
var events []string
for _, c := range Changes {
events = append(events, EventKey("*", c))
}
var mailStream *struct {
Name string `json:"name"`
Events []string `json:"events"`
MaxAgeDays int `json:"max-age-days"`
MaxMiB int `json:"max-mib"`
Retention string `json:"retention"`
MaxDeliveries int `json:"max-deliveries"`
Duplicates int `json:"duplicates"`
Grant struct {
Config string `json:"config"`
Events string `json:"events"`
} `json:"grant"`
}
for i := range m.Streams {
if m.Streams[i].Name == "mail" {
mailStream = &m.Streams[i]
}
}
if !reflect.DeepEqual(m.Emits, []string{"mail.send.sent", "mail.send.failed"}) || len(m.Streams) != 2 || mailStream == nil ||
!reflect.DeepEqual(mailStream.Events, events) || mailStream.MaxAgeDays != 7 || mailStream.Grant.Config != "grants" ||
mailStream.Grant.Events != "mail.{key}.**" {
t.Fatalf("emits %v, streams %+v; the code says %v", m.Emits, m.Streams, events)
}
// It calls nothing and consumes nothing: mesh-mail-notifier (issue 800 part B) is the one that invokes and
// consumes, not the store.
if len(m.Consumes)+len(m.Invokes) != 0 {
t.Fatalf("consumes %v, invokes %v", m.Consumes, m.Invokes)
}
// It declares the three mail seats (ADR 0378 §1 as amended by ADR 0379 §4) — mail-store and mail-webclient
// each held once for the mesh, mail-notifier held once per machine and needing node-notifier there — plus
// mail-reading (novox/hq ADR 0459 §1, issue 1426), held once for the mesh and claimed by mesh-mail-reader,
// never by mesh-mail itself; and claims only mail-store, serving the same five verbs as its own tools (§2, §3).
wantSeats := []seat{
{Name: "mail-store", Scope: "mesh", Serves: []string{"accounts", "search", "read", "send", "send-status"}},
{Name: "mail-webclient", Scope: "mesh", Serves: []string{"open", "status"}},
{Name: "mail-notifier", Scope: "node", Serves: []string{"status"}, Needs: []string{"node-notifier"}},
{Name: "mail-reading", Scope: "mesh", Serves: []string{"entries", "groups", "correct", "lessons", "undo", "status"}},
}
if !reflect.DeepEqual(m.Seats, wantSeats) {
t.Fatalf("seats %+v, want %+v", m.Seats, wantSeats)
}
wantClaims := []seat{
{Name: "mail-store", Scope: "mesh", Serves: []string{"accounts", "search", "read", "send", "send-status"}},
}
if !reflect.DeepEqual(m.Claims, wantClaims) {
t.Fatalf("claims %+v, want %+v", m.Claims, wantClaims)
}
// The two settings are trusted, and their defaults are an empty account list and no grant.
for id, want := range map[string]string{"accounts": `{"accounts":{}}`, "grants": `{"grants":{}}`} {
r := byID[id]
if r == nil || r["trusted"] != true || r["merge"] != "json" || !reflect.DeepEqual(r["takes-config"], []any{id}) {
t.Fatalf("%s: %v", id, r)
}
// The file's text is kept once, in its template under files/ (novox/hq ADR 0349 §2).
raw, err := os.ReadFile(filepath.Join("../..", r["template"].(string)))
if err != nil {
t.Fatal(err)
}
if strings.Join(strings.Fields(string(raw)), "") != want {
t.Fatalf("%s's default is %q", id, raw)
}
if id == "accounts" {
if _, err := ParseAccounts(raw); err != nil {
t.Fatal(err)
}
}
}
a := m.Build.Artifacts
if len(a) != 2 || a[1].From != "files" || a[0].From != "cmd/mesh-mail" || a[0].Binary != Module || !reflect.DeepEqual(a[0].Loads, []string{Module}) {
t.Fatalf("build %+v", m.Build)
}
for k, v := range map[string]string{"MESH_MAIL_ACCOUNTS": "accounts", "MESH_MAIL_GRANTS": "grants", "MESH_MAIL_STORE": "store", "DATABASE_URL_FILE": "database-url"} {
if a[0].Env[k] != byID[v]["path"] {
t.Errorf("the bundle reads %s at %s, and the manifest writes %s at %v", k, a[0].Env[k], v, byID[v]["path"])
}
}
if a[0].Env["MESH_MAIL_SECRETS"] != "${dir:state}" {
t.Errorf("the secrets directory is %s", a[0].Env["MESH_MAIL_SECRETS"])
}
}
// Its processes: the preparation once, keep judged by its unit, watch by mail_health (ADR 0359 §6), the watch writing
// the verdict the bundle's tool reads, as the account the bundle runs as.
func TestItsProcessesAreThePreparationTheActAndTheJudge(t *testing.T) {
m, byID := readManifest(t)
prep, keep, watch := byID["prepare"], byID["keep"], byID["watch"]
if prep == nil || prep["run-once"] != true || prep["run"].([]any)[1] != "prepare" {
t.Fatalf("prepare %v", prep)
}
if keep == nil || keep["run"].([]any)[1] != "keep" || keep["health"].(map[string]any)["kind"] != "unit" {
t.Fatalf("keep %v", keep)
}
h, _ := watch["health"].(map[string]any)
if watch == nil || watch["run"].([]any)[1] != "watch" || h["kind"] != "tool" || h["tool"] != HealthTool || h["needs"] != "postgres-database" {
t.Fatalf("watch %v", watch)
}
if watch["user"] != "${machine:account}" || watch["env"].(map[string]any)["MESH_MAIL_STATUS"] != m.Build.Artifacts[0].Env["MESH_MAIL_STATUS"] {
t.Fatalf("watch writes another status than the tool reads, or as another account: %v", watch)
}
// keep runs as the machine's root, which the own secrets' files belong to; it starts again when its settings or
// its store change.
if _, as := keep["user"]; as {
t.Fatalf("keep runs as %v", keep["user"])
}
if !reflect.DeepEqual(keep["restart-on"], []any{"database-url", "store", "accounts"}) {
t.Fatalf("keep restarts on %v", keep["restart-on"])
}
}
// 1172/AC2: the manifest declares the sender's account, own-secrets owner, work-queue stream and process bus,
// and widens the state directory and accounts.json so the sender (not the operator account) can read them,
// mirroring mesh-controller's own fixtures (internal/catalogue/process_bus_test.go:14-31,
// own_secret_owner_test.go:16-29, stream_work_queue_test.go:13-14).
func TestItsManifestDeclaresTheSenderProcess(t *testing.T) {
m, byID := readManifest(t)
account := byID["account"]
if account == nil || account["type"] != "user" || account["name"] != "mesh-mail-send" {
t.Fatalf("the sender's account resource: %v", account)
}
fam := m.OwnSecrets[SecretFamily+"*"]
if fam["owner"] != "mesh-mail-send" {
t.Fatalf("mail-credential-*'s owner: %v; want mesh-mail-send", fam["owner"])
}
// The sender's own bus account is delivered as the own secret broker-<process id>, never the module's
// own `broker` (novox/hq ADR 0436 §2): without it, the controller's mayIssue refuses to issue the
// module at all, naming the process and the secret (review round 1, finding 1).
broker := m.OwnSecrets["broker-send"]
if broker["path"] != "${dir:state}/broker-send.secret" || broker["owner"] != "mesh-mail-send" {
t.Fatalf("broker-send: %v; want path ${dir:state}/broker-send.secret, owner mesh-mail-send", broker)
}
var sendsStream *struct {
Name string `json:"name"`
Events []string `json:"events"`
MaxAgeDays int `json:"max-age-days"`
MaxMiB int `json:"max-mib"`
Retention string `json:"retention"`
MaxDeliveries int `json:"max-deliveries"`
Duplicates int `json:"duplicates"`
Grant struct {
Config string `json:"config"`
Events string `json:"events"`
} `json:"grant"`
}
for i := range m.Streams {
if m.Streams[i].Name == "mail-sends" {
sendsStream = &m.Streams[i]
}
}
if sendsStream == nil || !reflect.DeepEqual(sendsStream.Events, []string{"mail.send.approved"}) ||
sendsStream.MaxAgeDays != 1 || sendsStream.MaxMiB != 64 || sendsStream.Retention != "work-queue" ||
sendsStream.MaxDeliveries != 5 || sendsStream.Duplicates != 24 {
t.Fatalf("the stream mail-sends: %+v", sendsStream)
}
if !reflect.DeepEqual(m.Emits, []string{"mail.send.sent", "mail.send.failed"}) {
t.Fatalf("top-level emits: %v", m.Emits)
}
send := byID["send"]
if send == nil || send["type"] != "process" || send["user"] != "mesh-mail-send" {
t.Fatalf("the process send: %v", send)
}
run, _ := send["run"].([]any)
if len(run) != 2 || run[0] != "./mesh-mail" || run[1] != "send" {
t.Fatalf("send's run: %v", send["run"])
}
env, _ := send["env"].(map[string]any)
keepEnv, _ := byID["keep"]["env"].(map[string]any)
for _, k := range []string{"MESH_MAIL_ACCOUNTS", "MESH_MAIL_SECRETS"} {
if env[k] != keepEnv[k] {
t.Fatalf("send's %s is %v, keep's is %v", k, env[k], keepEnv[k])
}
}
h, _ := send["health"].(map[string]any)
if h["kind"] != "unit" {
t.Fatalf("send's health: %v", send["health"])
}
bus, _ := send["bus"].(map[string]any)
if bus == nil {
t.Fatalf("send declares no bus")
}
if !reflect.DeepEqual(toStrings(bus["emits"]), []string{"mail.send.sent", "mail.send.failed"}) ||
!reflect.DeepEqual(toStrings(bus["queues"]), []string{"mail-sends"}) ||
!reflect.DeepEqual(toStrings(bus["state"]), []string{"sends"}) || bus["asks"] != true ||
!reflect.DeepEqual(toStrings(bus["invokes"]), []string{"seat:operator-channel.tell"}) {
t.Fatalf("send's bus: %+v", bus)
}
// On novox the operator account's state directory is 0700, so mesh-mail-send cannot reach its own 0600
// files inside it: the state directory becomes 0711 (passable, not listable) and accounts.json (server
// names and addresses, no password) becomes 0644, so the sender can read the outgoing server. No password
// file's mode widens (the lead's decision, issue 1172).
if byID["state"]["mode"] != "0711" {
t.Fatalf("the state directory's mode: %v; want 0711", byID["state"]["mode"])
}
if byID["accounts"]["mode"] != "0644" {
t.Fatalf("accounts.json's mode: %v; want 0644", byID["accounts"]["mode"])
}
if byID["grants"]["mode"] != "0600" {
t.Fatalf("grants.json's mode widened to %v; it holds no password, but was not asked to widen", byID["grants"]["mode"])
}
}
func toStrings(v any) []string {
list, _ := v.([]any)
out := make([]string, 0, len(list))
for _, item := range list {
if s, ok := item.(string); ok {
out = append(out, s)
}
}
return out
}