package catalogue import ( "encoding/json" "strings" "testing" ) // twoFiles is a module that owns two configurable files, which issue 168 said no module could: // every key of every layer reached both. func twoFiles(app, sidecar []any) Manifest { appFile := map[string]any{"id": "app", "type": "file", "path": "/etc/app.json", "merge": MergeJSON, "content": `{"server":{"port":8080},"search":{"safe":1}}`} sideFile := map[string]any{"id": "sidecar", "type": "file", "path": "/etc/sidecar.json", "merge": MergeJSON, "content": `{"url":"http://127.0.0.1:8080"}`} if app != nil { appFile[TakesSettingsField] = app } if sidecar != nil { sideFile[TakesSettingsField] = sidecar } return Manifest{Module: "search", Resources: []map[string]any{ appFile, sideFile, {"id": "env", "type": "file", "path": "/etc/search.env", "content": "SITE=${setting:site-name}\n"}, }} } func settledContent(t *testing.T, resource map[string]any, layers []Layer) map[string]any { t.Helper() out, err := ApplySettings(resource, layers) if err != nil { t.Fatal(err) } if _, kept := out[TakesSettingsField]; kept { t.Errorf("%v still carries %s; the host knows no such field", out["id"], TakesSettingsField) } var parsed map[string]any if err := json.Unmarshal([]byte(out["content"].(string)), &parsed); err != nil { t.Fatalf("%v is not JSON after settling: %v", out["id"], err) } return parsed } func TestEachMergeableFileTakesOnlyTheKeysItNames(t *testing.T) { // novox/hq ADR 0326 §1, issue 168: two configurable files, each taking its own keys. A key for // the other file, for a ${setting:…} elsewhere or for the mesh itself lands in neither. m := twoFiles([]any{"server", "search"}, []any{"url"}) layers := []Layer{ {From: "the mesh", Values: map[string]any{ "server": map[string]any{"base_url": "https://search.example.tld/"}, "url": "http://127.0.0.1:9090", "site-name": "Search", "endpoints": map[string]any{"web": map[string]any{"reach": "public"}}}}, {From: "node-a", Values: map[string]any{"search": map[string]any{"safe": 0.0}}}, } app := settledContent(t, m.Resources[0], layers) for _, stray := range []string{"url", "site-name", "endpoints"} { if _, in := app[stray]; in { t.Errorf("app took %q, which it does not name: %v", stray, app) } } server, _ := app["server"].(map[string]any) if server["base_url"] != "https://search.example.tld/" || server["port"] != 8080.0 { t.Errorf("app's server block is %v; the setting should merge into the file's own", server) } if search, _ := app["search"].(map[string]any); search["safe"] != 0.0 { t.Errorf("app's search block is %v; the node's layer should win", search) } side := settledContent(t, m.Resources[1], layers) if side["url"] != "http://127.0.0.1:9090" { t.Errorf("sidecar's url is %v; it names url", side["url"]) } if len(side) != 1 { t.Errorf("sidecar took keys it does not name: %v", side) } } func TestAnEmptyListTakesNoSetting(t *testing.T) { // ADR 0164 §12: a file that names no setting takes none. m := twoFiles([]any{"server"}, []any{}) side := settledContent(t, m.Resources[1], []Layer{{From: "the mesh", Values: map[string]any{ "server": map[string]any{"port": 1.0}, "url": "x"}}}) if len(side) != 1 || side["url"] != "http://127.0.0.1:8080" { t.Errorf("a file taking none was changed: %v", side) } } func TestAFileThatNamesNothingKeepsTodaysForm(t *testing.T) { // ADR 0326 §3, ADR 0164 §15: a module none of whose files names keys is left as it was. m := twoFiles(nil, nil) app := settledContent(t, m.Resources[0], []Layer{{From: "the mesh", Values: map[string]any{"url": "x"}}}) if app["url"] != "x" { t.Errorf("a silent file should still take every key until its module names them: %v", app) } if unused := UnusedSettings(m, []Layer{{From: "the mesh", Values: map[string]any{"anything": 1}}}); len(unused) != 0 { t.Errorf("today's form has nothing stray, and said: %v", unused) } } func TestAKeyNoFileNamesIsStray(t *testing.T) { // ADR 0326 §5: once the files name their keys, a key none of them names — and nothing else // takes — is stray, and is said with what each file takes. m := twoFiles([]any{"server", "search"}, []any{"url"}) unused := UnusedSettings(m, []Layer{{From: "the mesh", Values: map[string]any{ "server": 1, "url": "x", "site-name": "Search", "ui": map[string]any{"theme": "dark"}}}}) if len(unused) != 1 || !strings.Contains(unused[0], `"ui"`) { t.Fatalf("only ui reaches nothing; named: %v", unused) } if !strings.Contains(unused[0], "app takes server, search") || !strings.Contains(unused[0], "sidecar takes url") { t.Errorf("the stray key's message does not say what each file takes: %s", unused[0]) } } func TestOneNamingFileMakesEveryMergeableFileName(t *testing.T) { // ADR 0326 §2: a module with one naming and one silent mergeable file is refused, naming the // silent one — the silent one would otherwise still take every key. m := twoFiles([]any{"server"}, nil) problems := TakenSettingsProblems(m) if len(problems) != 1 || !strings.Contains(problems[0], "sidecar merges and names no takes-settings") { t.Fatalf("problems: %v", problems) } if err := JudgeSettings(m, nil, false); err == nil || !strings.Contains(err.Error(), "sidecar") { t.Errorf("composing it should refuse it too, and said: %v", err) } raw, err := json.Marshal(map[string]any{"module": m.Module, "version": "1", "resources": m.Resources}) if err != nil { t.Fatal(err) } if _, err := ParseManifest(raw); err == nil || !strings.Contains(err.Error(), "sidecar merges and names no") { t.Errorf("the module check does not say it: %v", err) } } func TestTakesSettingsIsRefusedWhereItCannotMeanAnything(t *testing.T) { // ADR 0326 §4. cases := map[string]struct { resource map[string]any says string }{ "on a file that does not merge": { map[string]any{"id": "plain", "type": "file", "path": "/x", "content": "x", TakesSettingsField: []any{"a"}}, "does not merge"}, "not a list": { map[string]any{"id": "conf", "type": "file", "path": "/x", "merge": MergeJSON, "content": "{}", TakesSettingsField: "server"}, "is a list of setting keys"}, "a mesh word": { map[string]any{"id": "conf", "type": "file", "path": "/x", "merge": MergeJSON, "content": "{}", TakesSettingsField: []any{"endpoints"}}, "the mesh reads"}, "a protected key": { map[string]any{"id": "conf", "type": "file", "path": "/x", "merge": MergeJSON, "content": "{}", "protected": []any{"secret"}, TakesSettingsField: []any{"secret"}}, "keeps it as protected"}, "named twice": { map[string]any{"id": "conf", "type": "file", "path": "/x", "merge": MergeJSON, "content": "{}", TakesSettingsField: []any{"a", "a"}}, "twice"}, "not a key": { map[string]any{"id": "conf", "type": "file", "path": "/x", "merge": MergeJSON, "content": "{}", TakesSettingsField: []any{"two words"}}, "not a usable setting key"}, } for name, c := range cases { problems := TakenSettingsProblems(Manifest{Module: "m", Resources: []map[string]any{c.resource}}) if !containsText(problems, c.says) { t.Errorf("%s: want a problem saying %q, got %v", name, c.says, problems) } } // And a file that does not merge is refused where it is settled, not handed to the host. plain := map[string]any{"id": "plain", "type": "file", "path": "/x", "content": "x", TakesSettingsField: []any{"a"}} if _, err := ApplySettings(plain, nil); err == nil { t.Error("a file that does not merge and names keys was settled") } } func TestASoftwareSpelledKeyIsUsable(t *testing.T) { // A file's keys are its software's: nodered's timeZone is not lower case and is still a key. m := Manifest{Module: "flows", Resources: []map[string]any{{"id": "settings", "type": "file", "path": "/x", "merge": MergeJSON, "content": "{}", TakesSettingsField: []any{"timeZone"}}}} if problems := TakenSettingsProblems(m); len(problems) != 0 { t.Errorf("timeZone was refused: %v", problems) } } func containsText(list []string, text string) bool { for _, s := range list { if strings.Contains(s, text) { return true } } return false } func TestAModuleThatDeclaresASettingNamesItsFilesKeys(t *testing.T) { // ADR 0326 §3, ADR 0164 §15, novox/hq issue 574: only a module that declares no settings keeps // today's form. One that declares a preference and owns a silent mergeable file is refused where // a definition is written — and read and composed where a stored one is read again, so a build kept // from before the rule is still sent and still put back. declaring := func(app, sidecar []any) Manifest { m := twoFiles(app, sidecar) m.Resources[2]["content"] = "WORKERS=${setting:workers}\n" m.Settings = map[string]SettingDeclaration{"workers": {Kind: KindPreference, Default: 4.0, Why: "a number"}} return m } m := declaring(nil, nil) problems := WrittenSettingsProblems(m) if len(problems) != 2 || !containsText(problems, "mergeable app names no takes-settings") || !containsText(problems, "mergeable sidecar names no takes-settings") { t.Errorf("where it is written: %v", problems) } if problems := TakenSettingsProblems(m); len(problems) != 0 { t.Errorf("what a stored definition is read by refuses it: %v", problems) } raw, err := json.Marshal(map[string]any{"module": m.Module, "version": "1", "resources": m.Resources, "settings": map[string]any{"workers": map[string]any{"kind": KindPreference, "default": 4, "why": "a number"}}}) if err != nil { t.Fatal(err) } read, err := ParseManifest(raw) if err != nil { t.Fatalf("a stored build from before the rule is not read again: %v", err) } if err := JudgeSettings(read, nil, false); err != nil { t.Errorf("a stored definition is composed as before, and was refused: %v", err) } if problems := WrittenSettingsProblems(declaring([]any{"server"}, []any{})); len(problems) != 0 { t.Errorf("every mergeable file names its keys, and was refused: %v", problems) } if problems := WrittenSettingsProblems(twoFiles(nil, nil)); len(problems) != 0 { t.Errorf("a module declaring no setting was refused: %v", problems) } // One naming file and one silent: §2 names the silent one, and §3 does not name it again. mixed := declaring([]any{"server"}, nil) if written, taken := WrittenSettingsProblems(mixed), TakenSettingsProblems(mixed); len(written) != 0 || len(taken) != 1 { t.Errorf("the silent file is reported %d times by §3 and %d by §2; want once, by §2: %v %v", len(written), len(taken), written, taken) } }