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 }