package catalogue import ( "encoding/json" "strings" "testing" ) // A takes-config entry may lay a value under the name the file's software reads (issue 751): the key stays a // config key, lower-case, and the file names its own variable, such as ATLASSIAN_URL. // renaming is inConfigForm with its rules file taking `url` as NOTIFIER_URL, a value the code reading the file // falls back on when the file lacks it. func renaming() map[string]any { m := inConfigForm() m["config"].(map[string]any)["url"] = map[string]any{"type": "string", "description": "the address the notifier posts to, a URL; unset, the notifier's own environment says it", "default": ""} resourceOf(m, "rules")["takes-config"] = []any{"position", "theme", map[string]any{"key": "url", "as": "NOTIFIER_URL"}} return m } // rulesComposed is the rules file's content as the module's defaults and these layers make it. func rulesComposed(t *testing.T, m Manifest, layers ...Layer) string { t.Helper() all := layers if d, ok := Defaults(m); ok { all = append([]Layer{d}, layers...) } for _, r := range m.Resources { if r["id"] != "rules" { continue } out, err := ApplySettings(r, all) if err != nil { t.Fatal(err) } if _, left := out[TakesSettingsField]; left { t.Fatalf("the composed file still says %s: %v", TakesSettingsField, out) } return out["content"].(string) } t.Fatal("no rules file") return "" } func TestATakenValueIsLaidUnderTheFilesOwnName(t *testing.T) { m, err := parseRaw(t, renaming()) if err != nil { t.Fatalf("a renamed takes-config entry was refused: %v", err) } got := rulesComposed(t, m, Layer{From: "ace", Values: map[string]any{"url": "https://hooks.example.test/n"}}) var doc map[string]any if err := json.Unmarshal([]byte(got), &doc); err != nil { t.Fatal(err) } if doc["NOTIFIER_URL"] != "https://hooks.example.test/n" { t.Fatalf("url was not laid as NOTIFIER_URL: %s", got) } if _, said := doc["url"]; said { t.Fatalf("url was laid under the config key too: %s", got) } } func TestAnUnsetRenamedValueLeavesTheFileAsItWas(t *testing.T) { plain := inConfigForm() base, err := parseRaw(t, plain) if err != nil { t.Fatal(err) } m, err := parseRaw(t, renaming()) if err != nil { t.Fatal(err) } want := rulesComposed(t, base) if got := rulesComposed(t, m); got != want { t.Fatalf("with no layer the file is\n%s\nwanted, as without the entry,\n%s", got, want) } other := Layer{From: "ace", Values: map[string]any{"theme": "light"}} if got, want := rulesComposed(t, m, other), rulesComposed(t, base, other); got != want { t.Fatalf("with url unset the file is\n%s\nwanted\n%s", got, want) } } // A layer said before its module moved names the file's own spelling, such as nodered's timeZone on one machine. // Until K5 moves the layer and renames the key, that spelling still reaches the file, so the move changes no // machine's file; the config key wins where one layer says both. func TestALayerStillSayingTheFilesNameReachesItUntilItsMove(t *testing.T) { m, err := parseRaw(t, renaming()) if err != nil { t.Fatal(err) } for _, said := range []string{"url", "NOTIFIER_URL"} { if stray := UnusedSettings(m, []Layer{{From: "ace", Values: map[string]any{said: "https://x.test"}}}); len(stray) > 0 { t.Fatalf("%s, which the rules file takes, is said stray: %v", said, stray) } got := rulesComposed(t, m, Layer{From: "ace", Values: map[string]any{said: "https://x.test"}}) var doc map[string]any if err := json.Unmarshal([]byte(got), &doc); err != nil { t.Fatal(err) } if doc["NOTIFIER_URL"] != "https://x.test" || doc["url"] != nil { t.Fatalf("a layer saying %s made the file %s", said, got) } } both := rulesComposed(t, m, Layer{From: "ace", Values: map[string]any{"url": "https://key.test", "NOTIFIER_URL": "https://old.test"}}) if !strings.Contains(both, "https://key.test") || strings.Contains(both, "old.test") { t.Fatalf("where one layer says both, the config key did not win: %s", both) } later := rulesComposed(t, m, Layer{From: "mesh", Values: map[string]any{"url": "https://mesh.test"}}, Layer{From: "ace", Values: map[string]any{"NOTIFIER_URL": "https://ace.test"}}) if !strings.Contains(later, "https://ace.test") { t.Fatalf("a later layer's old spelling did not beat an earlier layer's key: %s", later) } // Who may set it is the same under either spelling: the rules file is trusted, so both are the terminal's. terminal := TerminalKeys(m) for _, k := range []string{"url", "NOTIFIER_URL"} { if !listHolds(terminal, k) { t.Fatalf("%s is not the terminal's in a trusted file: %v", k, terminal) } } } func listHolds(list []string, s string) bool { for _, x := range list { if x == s { return true } } return false } func TestARenamedEntryIsWrittenBackAsItWasSaid(t *testing.T) { m, err := parseRaw(t, renaming()) if err != nil { t.Fatal(err) } raw, err := json.Marshal(m) if err != nil { t.Fatal(err) } if !strings.Contains(string(raw), `"takes-config":["position","theme",{"as":"NOTIFIER_URL","key":"url"}]`) { t.Fatalf("written back as %s", raw) } if _, err := ParseManifest(raw); err != nil { t.Fatalf("read again: %v", err) } } func TestARenamedEntryIsRefusedWhereItCouldNotHold(t *testing.T) { entry := func(m map[string]any, e any) map[string]any { resourceOf(m, "rules")["takes-config"] = []any{"position", "theme", e} return m } refused(t, entry(renaming(), map[string]any{"key": "url"}), "notifier's rules", `"key" and "as"`) refused(t, entry(renaming(), map[string]any{"key": "url", "as": "NOTIFIER_URL", "when": "set"}), `"key" and "as"`) refused(t, entry(renaming(), map[string]any{"key": "URL", "as": "NOTIFIER_URL"}), "not a usable key") refused(t, entry(renaming(), map[string]any{"key": "url", "as": "NOTIFIER URL"}), "not a usable name") refused(t, entry(renaming(), map[string]any{"key": "url", "as": "endpoints"}), `"endpoints"`, "the mesh reads") refused(t, entry(renaming(), map[string]any{"key": "url", "as": "theme"}), `"theme"`, "twice") refused(t, entry(renaming(), map[string]any{"key": "theme", "as": "THEME"}), `"theme"`, "twice") refused(t, entry(renaming(), map[string]any{"key": "nothing", "as": "NOTHING"}), `"nothing"`, "declares no such value") kept := entry(renaming(), map[string]any{"key": "url", "as": "NOTIFIER_URL"}) resourceOf(kept, "rules")["protected"] = []any{"NOTIFIER_URL"} refused(t, kept, `"NOTIFIER_URL"`, "protected") // Laid only when set, so the code's own fallback holds: never required. required := renaming() declaration(required, "url")["required"] = true delete(declaration(required, "url"), "default") refused(t, required, `"url"`, "only when it is set", "never required") } func TestARenamedEntryIsTheConfigFormsOnly(t *testing.T) { m := map[string]any{ "module": "notifier", "version": "1", "resources": []any{ map[string]any{"id": "rules", "type": "file", "path": "/x/rules.json", "merge": "json", "content": "{}", "takes-settings": []any{map[string]any{"key": "url", "as": "NOTIFIER_URL"}}}, }, } refused(t, m, "notifier's rules", "config form") }