package catalogue import ( "encoding/json" "strings" "testing" ) func file(content string, protected ...string) map[string]any { r := map[string]any{"id": "conf", "type": "file", "path": "/etc/thing.json", "merge": MergeJSON, "content": content} if len(protected) > 0 { var as []any for _, p := range protected { as = append(as, p) } r["protected"] = as } return r } func merged(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) } var parsed map[string]any if err := json.Unmarshal([]byte(out["content"].(string)), &parsed); err != nil { t.Fatalf("the merged file is not JSON: %v", err) } return parsed } func TestASettingBeatsTheModulesDefault(t *testing.T) { // The whole rule. A setting is a statement about that key made deliberately; the default was // only ever what to do in the absence of one. So there is nothing to resolve. got := merged(t, file(`{"port":8080,"log":"info"}`), Layer{From: "node", Values: map[string]any{"port": 9090.0}}) if got["port"] != 9090.0 { t.Errorf("port is %v; the setting should have won", got["port"]) } if got["log"] != "info" { t.Errorf("log is %v; a key nobody set should keep the module's value", got["log"]) } } func TestUpstreamKeepsTheKeysNobodySet(t *testing.T) { // The point of merging rather than replacing: the module can change its half freely and only // the keys somebody actually cares about are pinned. got := merged(t, file(`{"port":8080,"log":"info","workers":4}`), Layer{From: "node", Values: map[string]any{"log": "debug"}}) if got["port"] != 8080.0 || got["workers"] != 4.0 { t.Errorf("the module's other keys did not survive: %v", got) } } func TestTheNodeBeatsTheMesh(t *testing.T) { // Two layers, in order. A node that differs is expressed by differing, rather than by // repeating everything the rest of the mesh already says. got := merged(t, file(`{"log":"info"}`), Layer{From: "mesh", Values: map[string]any{"log": "warn", "workers": 8.0}}, Layer{From: "node", Values: map[string]any{"log": "debug"}}) if got["log"] != "debug" { t.Errorf("log is %v; the node's setting should have won", got["log"]) } if got["workers"] != 8.0 { t.Errorf("workers is %v; a mesh-wide setting the node did not touch should stand", got["workers"]) } } func TestNestedBlocksMergeRatherThanReplace(t *testing.T) { // Setting one field of a block must not delete its siblings, or every setting would have to // restate the whole block and would then pin all of it against upstream. got := merged(t, file(`{"http":{"gzip":"off","timeout":30,"port":80}}`), Layer{From: "node", Values: map[string]any{ "http": map[string]any{"gzip": "on"}}}) block := got["http"].(map[string]any) if block["gzip"] != "on" { t.Errorf("gzip is %v", block["gzip"]) } if block["timeout"] != 30.0 || block["port"] != 80.0 { t.Errorf("the block's other fields were lost: %v", block) } } func TestAListIsReplacedWholeNotMerged(t *testing.T) { // A list that merged element-wise could neither be shortened nor reordered, and there is no // correct guess about which element is "the same one". got := merged(t, file(`{"hosts":["a","b","c"]}`), Layer{From: "node", Values: map[string]any{"hosts": []any{"x"}}}) hosts := got["hosts"].([]any) if len(hosts) != 1 || hosts[0] != "x" { t.Errorf("hosts is %v; a list is replaced whole", hosts) } } func TestAProtectedKeyIsRefusedNotIgnored(t *testing.T) { // A setting quietly dropped is somebody believing they changed something. Refusing says so // while they are looking at it. _, err := ApplySettings(file(`{"socket":"/run/thing.sock","log":"info"}`, "socket"), []Layer{{From: "node", Values: map[string]any{"socket": "/tmp/mine.sock"}}}) if err == nil { t.Fatal("a protected key was overridden") } if !strings.Contains(err.Error(), "socket") || !strings.Contains(err.Error(), "not settable") { t.Errorf("the refusal does not say which key or why: %v", err) } } func TestSettingsAroundAProtectedKeyStillApply(t *testing.T) { got := merged(t, file(`{"socket":"/run/thing.sock","log":"info"}`, "socket"), Layer{From: "node", Values: map[string]any{"log": "debug"}}) if got["log"] != "debug" { t.Errorf("log is %v", got["log"]) } } func TestTheSameSettingsAlwaysProduceTheSameBytes(t *testing.T) { // A file whose lines move for no reason makes every reconcile look like a change, and a // service reflecting it would restart for ever. // // Note what this defends: Go's JSON encoder sorts map keys, so the stability comes from the // standard library and this passes with the merging removed. It is worth keeping as the thing // that would catch a move to an encoder that does not sort — but it is not evidence about the // code below it, and it was checked. resource := file(`{"b":2,"a":1,"c":3}`) layer := Layer{From: "node", Values: map[string]any{"z": 26.0, "a": 100.0}} first, err := ApplySettings(resource, []Layer{layer}) if err != nil { t.Fatal(err) } for i := 0; i < 5; i++ { again, err := ApplySettings(resource, []Layer{layer}) if err != nil { t.Fatal(err) } if again["content"] != first["content"] { t.Fatal("the same settings produced different bytes") } } } func TestAFileThatDoesNotMergeIsLeftAlone(t *testing.T) { plain := map[string]any{"id": "conf", "type": "file", "path": "/etc/thing", "content": "not structured at all\n"} out, err := ApplySettings(plain, []Layer{{From: "node", Values: map[string]any{"x": 1}}}) if err != nil { t.Fatal(err) } if out["content"] != "not structured at all\n" { t.Errorf("a file with no merge rule was changed: %v", out["content"]) } } func TestContentThatIsNotJSONIsRefusedWhereSomebodyIsLooking(t *testing.T) { // Rather than on the machine, at apply time, as a file the program cannot read. _, err := ApplySettings(file(`this is not json`), nil) if err == nil { t.Fatal("a module claiming to merge as JSON shipped something else and was accepted") } } // No layer replaces a value the vault fills, at any depth (novox/hq issue 610): searxng's server.secret_key // sits under `server`, a key its file takes, and a layer reaching it was let through by a top-level-only check. func TestNoLayerReplacesAVaultSecretAtAnyDepth(t *testing.T) { file := map[string]any{"id": "settings", "type": "file", "merge": MergeJSON, "content": `{"server": {"secret_key": "${secret:secret}", "port": 8080}, "ui": {"theme": "simple"}, "token": "${secret:token}"}`} layer := func(values map[string]any) []Layer { return []Layer{{From: "anchor", Values: values}} } merged, err := ApplySettings(file, layer(map[string]any{"server": map[string]any{"port": float64(9000)}, "ui": map[string]any{"theme": "dark"}})) if err != nil { t.Fatalf("a key beside the secret was refused: %v", err) } if c := merged["content"].(string); !strings.Contains(c, `"secret_key": "${secret:secret}"`) || !strings.Contains(c, `"port": 9000`) { t.Fatalf("the secret or the setting did not land as meant:\n%s", c) } for name, values := range map[string]map[string]any{ "the nested secret": {"server": map[string]any{"secret_key": "chosen"}}, "the whole map holding it": {"server": "elsewhere"}, "a top-level secret": {"token": "chosen"}, "the secret, emptied": {"server": map[string]any{"secret_key": ""}}, } { if _, err := ApplySettings(file, layer(values)); err == nil || !strings.Contains(err.Error(), "vault") { t.Errorf("%s: %v, want a refusal naming the vault", name, err) } } }