package resolution import ( "bytes" "encoding/json" "errors" "strings" "testing" mcat "git.novox.be/novox/mesh-sdk/go/catalogue" ) // inConfigForm is a module in the config form that registers: every value typed, described, defaulted or // required, placed in a file or read at runtime, and its mergeable file naming its keys. func inConfigForm() map[string]any { return map[string]any{ "module": "notifier", "version": "1", "config": map[string]any{ "font-size": map[string]any{"type": "number", "description": "the notification's font size, in points", "minimum": 6, "maximum": 40, "default": 10}, "width": map[string]any{"type": "number", "description": "the notification's width, in pixels", "default": 250}, "position": map[string]any{"type": "string", "description": "the corner it shows in, a word", "enum": []any{"top-right", "top-left"}, "default": "top-right"}, "theme": map[string]any{"type": "string", "description": "the colour theme, a lower-case name", "pattern": "[a-z]+", "default": "dark"}, "history": map[string]any{"type": "number", "description": "how many notifications it keeps, a count", "default": 20, "runtime": true}, "sender": map[string]any{"type": "string", "description": "the address it mails a digest from", "required": true}, }, "resources": []any{ map[string]any{"id": "configuration", "type": "file", "path": "/x/notifierrc", "content": "font = Inter ${config:font-size}\nwidth = ${config:width}\nfrom = ${config:sender}\n"}, map[string]any{"id": "rules", "type": "file", "path": "/x/rules.json", "merge": "json", "takes-config": []any{"position", "theme"}, "content": "{\"position\": \"top-right\"}\n"}, map[string]any{"id": "notifier", "type": "service", "unit": "notifier.service", "reload-on": []any{"configuration"}, "restart-on": []any{"rules"}}, }, } } func parseRaw(t *testing.T, m map[string]any) (mcat.Manifest, error) { t.Helper() raw, err := json.Marshal(m) if err != nil { t.Fatal(err) } return decodeForTest(raw) } // settingsDunst is the dunst module as it is written today; configDunst the same module rewritten from settings to // config. const settingsDunst = `{"module": "dunst", "version": "1", "settings": { "font-size": {"kind": "preference", "default": 10, "why": "10 points of Inter reads well on a screen of about 100 DPI"}, "width": {"kind": "preference", "default": 250, "why": "250 pixels fits a title of about forty characters at 10 points"} }, "resources": [ {"id": "package", "type": "package", "package": "dunst"}, {"id": "configuration", "type": "file", "path": "/home/me/.config/dunst/dunstrc", "mode": "0644", "content": "[global]\n font = Inter ${setting:font-size}\n width = ${setting:width}\n"}, {"id": "rules", "type": "file", "path": "/home/me/.config/dunst/rules.json", "merge": "json", "takes-settings": [], "content": "{\"urgency\": \"low\"}\n"}, {"id": "notifier", "type": "service", "unit": "dunst.service", "reload-on": ["configuration"]} ]}` const configDunst = `{"module": "dunst", "version": "1", "config": { "font-size": {"type": "number", "description": "the font size of a notification, in points", "minimum": 6, "maximum": 40, "default": 10}, "width": {"type": "number", "description": "the width of a notification, in pixels", "minimum": 100, "default": 250} }, "resources": [ {"id": "package", "type": "package", "package": "dunst"}, {"id": "configuration", "type": "file", "path": "/home/me/.config/dunst/dunstrc", "mode": "0644", "content": "[global]\n font = Inter ${config:font-size}\n width = ${config:width}\n"}, {"id": "rules", "type": "file", "path": "/home/me/.config/dunst/rules.json", "merge": "json", "takes-config": [], "content": "{\"urgency\": \"low\"}\n"}, {"id": "notifier", "type": "service", "unit": "dunst.service", "reload-on": ["configuration"]} ]}` // ${config:} fills exactly as ${setting:} does: the module rewritten from settings to config composes // to the same bytes, with no layer, with the node's, and with the mesh's under the node's. func TestAModuleRewrittenFromSettingsToConfigComposesByteIdentically(t *testing.T) { composed := func(manifest string, layers []mcat.Layer) []byte { t.Helper() m, err := decodeForTest([]byte(manifest)) if err != nil { t.Fatal(err) } r := anAdoptedAnchor() r.Modules = append(r.Modules, m) with := anchorRendering(false) if layers != nil { with.Settings["dunst"] = layers } c, err := Compose(r, with) if err != nil { t.Fatal(err) } if why, left := c.LeftOut["dunst"]; left { t.Fatalf("dunst was left out: %s", why) } raw, err := json.Marshal(c) if err != nil { t.Fatal(err) } return raw } for _, layers := range [][]mcat.Layer{ nil, {{From: "anchor", Values: map[string]any{"font-size": float64(13)}}}, {{From: mcat.MeshWideLayer, Values: map[string]any{"width": float64(300)}}, {From: "anchor", Values: map[string]any{"font-size": float64(12), "width": float64(320)}}}, } { was, is := composed(settingsDunst, layers), composed(configDunst, layers) if !bytes.Equal(was, is) { t.Fatalf("composed differently with %v:\nsettings: %s\nconfig: %s", layers, was, is) } if !strings.Contains(string(is), "font = Inter 1") || strings.Contains(string(is), "${config:") { t.Fatalf("not filled: %s", is) } } } // A required value nothing sets is refused at composition in the config form's words: ${config:} and the // config verb, never `settings set` (review of mesh-controller#320). The settings form's words are unchanged. func TestAnUnsetRequiredValueIsRefusedInTheConfigFormsWords(t *testing.T) { m, err := parseRaw(t, inConfigForm()) if err != nil { t.Fatal(err) } r := anAdoptedAnchor() r.Modules = append(r.Modules, m) composed, err := Compose(r, anchorRendering(false)) if err != nil { t.Fatal(err) } why, left := composed.LeftOut["notifier"] if !left { t.Fatal("a module whose required value nothing sets was not left out") } for _, said := range []string{"${config:sender}", `required config value "sender"`, "config verb"} { if !strings.Contains(why, said) { t.Fatalf("left out without %q: %s", said, why) } } for _, unsaid := range []string{"${setting:", "settings set"} { if strings.Contains(why, unsaid) { t.Fatalf("left out in the settings form's words (%q): %s", unsaid, why) } } var unset *UnsetSettingError if err := unsetInConfigWords(settingInto(map[string]any{"type": "file", "content": "${setting:sender}"}, nil, "notifier"), m); !errors.As(err, &unset) || unset.Setting != "sender" { t.Fatalf("not typed as a value nobody gave: %v", err) } // A contribution's value too (issue 1126, seed-8: de-spiegel's route names were refused with ${setting:…}). withRoute := inConfigForm() withRoute["contributes"] = map[string]any{"route": map[string]any{"site": map[string]any{"name": "${config:sender}", "port": 80}}} routed, err := parseRaw(t, withRoute) if err != nil { t.Fatal(err) } _, err = resolutionComposed(mcat.Resolution{}, routed, "route", routed.ContributesMany["route"]["site"], nil, "notifier contributing site to route") if !errors.As(err, &unset) || unset.Module != "notifier" || !strings.Contains(err.Error(), `notifier has a contribution site to route, in "name", that says ${config:sender}`) || strings.Contains(err.Error(), "${setting:") { t.Fatalf("a contribution's unset value is not said in the config form's words: %v", err) } settingsForm := notifier() settingsForm.Resources[0]["content"] = "from = ${setting:domain}\n" err = unsetInConfigWords(settingInto(settingsForm.Resources[0], nil, "notifier"), settingsForm) if err == nil || !strings.Contains(err.Error(), "`settings set notifier `") { t.Fatalf("the settings form's refusal changed: %v", err) } }