A module left out for an unknown key inside an entry lost its whole manifest, so every consumer of what it provides was refused and its data stopped being copied. Read past only the unknown key, keep its backup lines, and say in the condition what stops.
405 lines
19 KiB
Go
405 lines
19 KiB
Go
package catalogue
|
|
|
|
import (
|
|
"encoding/json"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// A preference has a default in the definition; the mesh's layer, then the node's, override it; a key
|
|
// with a default is not stray; and a value that is the operator's still has none (novox/hq ADR 0262).
|
|
|
|
func notifier() Manifest {
|
|
return Manifest{Module: "notifier",
|
|
Settings: map[string]SettingDeclaration{
|
|
"font-size": {Kind: KindPreference, Default: float64(10), Why: "readable at a scale of one"},
|
|
"width": {Kind: KindPreference, Default: float64(250), Why: "fits a title of forty characters"},
|
|
},
|
|
Resources: []map[string]any{{"id": "configuration", "type": "file", "path": "/x/notifierrc",
|
|
"content": "font = Inter ${setting:font-size}\nwidth = ${setting:width}\n"}},
|
|
}
|
|
}
|
|
|
|
func parsed(t *testing.T, m Manifest) error {
|
|
t.Helper()
|
|
raw, err := json.Marshal(m)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, err = ParseManifest(raw)
|
|
return err
|
|
}
|
|
|
|
func TestAnUnsetPreferenceTakesItsDefault(t *testing.T) {
|
|
m := notifier()
|
|
for _, layers := range [][]Layer{nil, {{From: MeshWideLayer, Values: map[string]any{}}}} {
|
|
file := map[string]any{}
|
|
for k, v := range m.Resources[0] {
|
|
file[k] = v
|
|
}
|
|
if err := settingInto(file, WithDefaults(m, layers), m.Module); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if file["content"] != "font = Inter 10\nwidth = 250\n" {
|
|
t.Fatalf("filled as %q", file["content"])
|
|
}
|
|
}
|
|
if err := JudgeSettings(m, nil, false); err != nil {
|
|
t.Fatalf("a module whose every key has a default does not compose with no layer: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestTheNodeOverTheMeshOverTheDefault(t *testing.T) {
|
|
m := notifier()
|
|
layers := []Layer{
|
|
{From: MeshWideLayer, Values: map[string]any{"width": float64(300)}},
|
|
{From: "laptop", Values: map[string]any{"font-size": float64(13), "width": float64(340)}},
|
|
}
|
|
file := map[string]any{"type": "file", "content": m.Resources[0]["content"]}
|
|
if err := settingInto(file, WithDefaults(m, layers), m.Module); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if file["content"] != "font = Inter 13\nwidth = 340\n" {
|
|
t.Fatalf("filled as %q", file["content"])
|
|
}
|
|
file = map[string]any{"type": "file", "content": m.Resources[0]["content"]}
|
|
if err := settingInto(file, WithDefaults(m, layers[:1]), m.Module); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if file["content"] != "font = Inter 10\nwidth = 300\n" {
|
|
t.Fatalf("the mesh's layer over the default filled as %q", file["content"])
|
|
}
|
|
}
|
|
|
|
// Composed for a machine, the way a push writes it: the default reaches the file, and the node's
|
|
// layer overrides it.
|
|
func TestAComposedMachineGetsTheDefaultAndTheNodesValue(t *testing.T) {
|
|
r := anAdoptedAnchor()
|
|
r.Modules = append(r.Modules, notifier())
|
|
with := anchorRendering(false)
|
|
composed, err := r.Compose(with)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if why, left := composed.LeftOut["notifier"]; left {
|
|
t.Fatalf("the notifier was left out: %s", why)
|
|
}
|
|
if c := byID(composed.Resources)["notifier.configuration"]["content"]; c != "font = Inter 10\nwidth = 250\n" {
|
|
t.Fatalf("composed with no layer as %q", c)
|
|
}
|
|
with.Settings["notifier"] = []Layer{{From: "anchor", Values: map[string]any{"font-size": float64(13)}}}
|
|
if composed, err = r.Compose(with); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if c := byID(composed.Resources)["notifier.configuration"]["content"]; c != "font = Inter 13\nwidth = 250\n" {
|
|
t.Fatalf("composed with the node's font size as %q", c)
|
|
}
|
|
}
|
|
|
|
// Setting a key the module gives a default is not refused as reaching nothing: it overrides the
|
|
// default, which is how a running module gains a setting with no gap between.
|
|
func TestAKeyWithADefaultIsNotStray(t *testing.T) {
|
|
m := notifier()
|
|
m.Resources[0]["content"] = "font = Inter ${setting:font-size}\nwidth = ${setting:width}\n"
|
|
stray := UnusedSettings(m, []Layer{{From: "laptop", Values: map[string]any{"font-size": float64(13), "colour": "red"}}})
|
|
joined := strings.Join(stray, "; ")
|
|
if strings.Contains(joined, `"font-size"`) || !strings.Contains(joined, `"colour"`) {
|
|
t.Fatalf("stray: %s", joined)
|
|
}
|
|
}
|
|
|
|
// A default fills ${setting:…} only: it never becomes a key of a mergeable file (issue 168).
|
|
func TestADefaultIsNotMergedIntoAJSONFile(t *testing.T) {
|
|
m := notifier()
|
|
m.Resources = append(m.Resources, map[string]any{"id": "other", "type": "file", "path": "/x/other.json",
|
|
"merge": MergeJSON, "content": `{"keep": 1}`})
|
|
out, err := ApplySettings(m.Resources[1], WithDefaults(m, nil))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if strings.Contains(out["content"].(string), "font-size") {
|
|
t.Fatalf("a default reached a mergeable file: %s", out["content"])
|
|
}
|
|
}
|
|
|
|
// A value that is the operator's has no default: no layer setting it is refused by name, as before.
|
|
func TestAnOperatorsValueWithoutADefaultIsStillRefused(t *testing.T) {
|
|
m := notifier()
|
|
m.Resources[0]["content"] = "font = Inter ${setting:font-size}\nwidth = ${setting:width}\nfrom = ${setting:domain}\n"
|
|
err := JudgeSettings(m, nil, false)
|
|
if err == nil || !strings.Contains(err.Error(), "${setting:domain}") {
|
|
t.Fatalf("judged %v", err)
|
|
}
|
|
if strings.Contains(err.Error(), "font-size") {
|
|
t.Fatalf("a default was named as set today: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestTheParserTakesAPreferenceAndRefusesTheRest(t *testing.T) {
|
|
if err := parsed(t, notifier()); err != nil {
|
|
t.Fatalf("a preference with a default was refused: %v", err)
|
|
}
|
|
for _, c := range []struct {
|
|
name string
|
|
key string
|
|
d SettingDeclaration
|
|
refuse string
|
|
}{
|
|
{"no kind", "font-size", SettingDeclaration{Default: float64(10), Why: "x"}, `only a "preference" has a default`},
|
|
{"another kind", "font-size", SettingDeclaration{Kind: "operator", Default: float64(10), Why: "x"}, `only a "preference"`},
|
|
{"no default", "font-size", SettingDeclaration{Kind: KindPreference, Why: "x"}, "no default"},
|
|
{"an empty default", "font-size", SettingDeclaration{Kind: KindPreference, Default: " ", Why: "x"}, "an empty default"},
|
|
{"an object", "font-size", SettingDeclaration{Kind: KindPreference, Default: map[string]any{"a": 1.0}, Why: "x"}, "a string, a number or a boolean"},
|
|
{"no why", "font-size", SettingDeclaration{Kind: KindPreference, Default: float64(10)}, "no why"},
|
|
{"the operator's", "mail-domain", SettingDeclaration{Kind: KindPreference, Default: "example.tld", Why: "x"}, "is the operator's value"},
|
|
{"a secret", "api-token", SettingDeclaration{Kind: KindPreference, Default: "x", Why: "x"}, "is the operator's value"},
|
|
{"the mesh's word", PortsSetting, SettingDeclaration{Kind: KindPreference, Default: float64(1), Why: "x"}, "the mesh reads"},
|
|
{"read by nothing", "height", SettingDeclaration{Kind: KindPreference, Default: float64(300), Why: "x"}, "reaches nothing"},
|
|
} {
|
|
m := notifier()
|
|
m.Resources[0]["content"] = m.Resources[0]["content"].(string) + "x = ${setting:" + c.key + "}\n"
|
|
if c.name == "read by nothing" {
|
|
m.Resources[0]["content"] = "font = Inter ${setting:font-size}\nwidth = ${setting:width}\n"
|
|
}
|
|
m.Settings[c.key] = c.d
|
|
err := parsed(t, m)
|
|
if err == nil || !strings.Contains(err.Error(), c.refuse) {
|
|
t.Errorf("%s: parsed %v, want %q", c.name, err, c.refuse)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestEveryValueSaysWhereItCameFrom(t *testing.T) {
|
|
m := notifier()
|
|
m.Resources[0]["content"] = m.Resources[0]["content"].(string) + "x = ${setting:position}\n"
|
|
got := Effective(m, []Layer{
|
|
{From: MeshWideLayer, Values: map[string]any{"width": float64(300), "position": "top-right"}},
|
|
{From: "laptop", Values: map[string]any{"font-size": float64(13)}},
|
|
})
|
|
want := map[string]string{"font-size": "laptop", "position": MeshWideLayer, "width": MeshWideLayer}
|
|
if len(got) != 3 {
|
|
t.Fatalf("effective: %+v", got)
|
|
}
|
|
for _, s := range got {
|
|
if s.From != want[s.Key] {
|
|
t.Errorf("%s from %q, want %q", s.Key, s.From, want[s.Key])
|
|
}
|
|
}
|
|
if got[0].Key != "font-size" || got[0].Value != float64(13) || got[0].Default != float64(10) {
|
|
t.Fatalf("font-size: %+v", got[0])
|
|
}
|
|
only := Effective(m, nil)
|
|
if only[0].From != DefaultLayer || only[0].Value != float64(10) {
|
|
t.Fatalf("with no layer: %+v", only)
|
|
}
|
|
}
|
|
|
|
// A node may be called `default`. Its layer is a node's like any other: it overrides the module's
|
|
// default, it merges into a mergeable file, and it is named among what is set.
|
|
func TestANodeCalledDefaultIsANodesLayer(t *testing.T) {
|
|
m := notifier()
|
|
node := []Layer{{From: DefaultLayer, Values: map[string]any{"font-size": float64(13)}}}
|
|
file := map[string]any{"type": "file", "content": m.Resources[0]["content"]}
|
|
if err := settingInto(file, WithDefaults(m, node), m.Module); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if file["content"] != "font = Inter 13\nwidth = 250\n" {
|
|
t.Fatalf("the node called default was dropped: %q", file["content"])
|
|
}
|
|
out, err := ApplySettings(map[string]any{"id": "j", "type": "file", "merge": MergeJSON, "content": `{}`}, node)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.Contains(out["content"].(string), `"font-size": 13`) {
|
|
t.Fatalf("the node called default did not merge: %s", out["content"])
|
|
}
|
|
if got := Effective(m, node); got[0].FromDefault || got[0].Value != float64(13) {
|
|
t.Fatalf("the node's value read as the default: %+v", got[0])
|
|
}
|
|
}
|
|
|
|
// The operator's own value is told by what a key's name is about: its last word, or its last two as
|
|
// a known compound; a plural as its singular; and never a number or a switch.
|
|
func TestAKeyIsTheOperatorsByWhatItIsAbout(t *testing.T) {
|
|
for _, key := range []string{"max-tokens", "show-hostname", "ghost-opacity", "users-per-page", "mailbox-size",
|
|
"font-size", "width", "keyboard-delay", "ipv6-preferred", "client-width", "user-agent", "url-timeout",
|
|
"site-title", "cert-renewal-days", "name", "font-name", "allow-resize", "use-gpu", "disable-sender-check",
|
|
"max-recipients", "gateway-timeout", "sender-delay"} {
|
|
if w := operatorsWord(key); w != "" {
|
|
t.Errorf("%s read as the operator's (%s)", key, w)
|
|
}
|
|
}
|
|
for key, word := range map[string]string{"mail-domain": "domain", "api-key": "key", "admin-password": "password",
|
|
"oauth-client-id": "client-id", "db-dsn": "dsn", "public-ip": "ip", "puid": "puid", "dns-zone": "zone",
|
|
"webhook": "webhook", "notify_phone": "phone", "cors.origin": "origin", "smtp-pass": "pass",
|
|
"backup-passphrase": "passphrase", "data-owner": "owner", "fqdn": "fqdn", "tenant": "tenant", "host": "host",
|
|
"allowed-hosts": "host", "admin-emails": "email", "tokens": "token", "hostname": "hostname",
|
|
"apikey": "apikey", "servername": "servername", "tls-cert": "cert", "site": "site", "timezone": "timezone",
|
|
"bearer": "bearer", "bind-ipv4": "ipv4", "listen-ipv6": "ipv6", "site-name": "site-name",
|
|
"server-name": "server-name", "public-name": "public-name", "smtp-relay": "smtp-relay",
|
|
"host-name": "host-name", "user-name": "user-name", "domain-name": "domain-name",
|
|
"nameserver": "nameserver", "upstream-nameservers": "nameserver", "dns-server": "dns-server",
|
|
"dns-servers": "dns-server", "default-gateway": "gateway", "lan-subnet": "subnet", "sender": "sender",
|
|
"notify-recipients": "recipient", "contact": "contact", "tls-certificate": "certificate",
|
|
"allow-from": "allow-from", "deny-from": "deny-from", "allow-hosts": "host", "use-host": "host"} {
|
|
if w := operatorsWord(key); w != word {
|
|
t.Errorf("%s: read %q, want %q", key, w, word)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A default is a value the mesh writes, so the installation check reads it, even under a setting
|
|
// called `description` or `why`; a setting's own why is prose and is not read.
|
|
func TestTheInstallationCheckReadsADefault(t *testing.T) {
|
|
m := notifier()
|
|
m.Settings["relay"] = SettingDeclaration{Kind: KindPreference, Default: "relay.acme.be", Why: "as at relay.acme.be"}
|
|
m.Settings["description"] = SettingDeclaration{Kind: KindPreference, Default: "notes.acme.be", Why: "x"}
|
|
problems := strings.Join(InstallationProblems(m), "; ")
|
|
for _, want := range []string{"relay.acme.be at settings.relay.default", "notes.acme.be at settings.description.default"} {
|
|
if !strings.Contains(problems, want) {
|
|
t.Errorf("not reported: %q in %s", want, problems)
|
|
}
|
|
}
|
|
if strings.Contains(problems, "settings.relay.why") {
|
|
t.Errorf("a why was read as a value: %s", problems)
|
|
}
|
|
}
|
|
|
|
// A default fills ${setting:…} in what a module contributes and serves, and never replaces a value a
|
|
// contribution states itself.
|
|
func TestADefaultFillsAContributionAndAServedFactButNotALiteral(t *testing.T) {
|
|
m := notifier()
|
|
m.Settings["site-title"] = SettingDeclaration{Kind: KindPreference, Default: "Notes", Why: "x"}
|
|
contribution := map[string]any{"title": "${setting:site-title}", "width": "fixed", "port": float64(8080)}
|
|
got, err := overridden(contribution, WithDefaults(m, nil), "a contribution")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got["title"] != "Notes" || got["width"] != "fixed" {
|
|
t.Fatalf("contribution: %v", got)
|
|
}
|
|
got, err = overridden(contribution, WithDefaults(m, []Layer{{From: "laptop", Values: map[string]any{"width": "wide"}}}), "a contribution")
|
|
if err != nil || got["width"] != "wide" {
|
|
t.Fatalf("a node's value did not override a contribution's own key: %v %v", got, err)
|
|
}
|
|
served, err := Settle(map[string]any{"name": "${setting:site-title}"}, WithDefaults(m, nil))
|
|
if err != nil || served["name"] != "Notes" {
|
|
t.Fatalf("served: %v %v", served, err)
|
|
}
|
|
if _, err := Settle(map[string]any{"name": "${setting:site-title}"}, nil); err == nil {
|
|
t.Fatal("a served fact without the defaults was filled")
|
|
}
|
|
}
|
|
|
|
// A stored manifest with a key this controller does not know, at the top or inside any block, is read
|
|
// and its module is left out of every declaration by name; the rest of the catalogue is read; the module
|
|
// check refuses it. One case per block whose decoder wraps the decoder's words in its own.
|
|
func TestAStoredManifestWithAnUnknownKeyIsLeftOutAndRegistrationRefusesIt(t *testing.T) {
|
|
for where, raw := range map[string]string{
|
|
"the top": `{"module": "later", "version": "2", "a-field-from-later": {"x": 1}}`,
|
|
"a state": `{"module": "later", "version": "2", "state": [{"name": "s", "a-field-from-later": 1}]}`,
|
|
"a provided name": `{"module": "later", "version": "2", "provides": [{"name": "p", "a-field-from-later": 1}]}`,
|
|
"an offer's identity": `{"module": "later", "version": "2", "provides": [{"name": "p", "identity": {"in": "x", "a-field-from-later": 1}}]}`,
|
|
"a backup": `{"module": "later", "version": "2", "data": {"own": [{"id": "d", "path": "${dir:d}", "class": "valuable", "backup": {"dump": "x", "into": "y", "a-field-from-later": 1}}]}}`,
|
|
"an own secret": `{"module": "later", "version": "2", "own-secrets": {"s": {"path": "/x", "a-field-from-later": 1}}}`,
|
|
"a seat's verb": `{"module": "later", "version": "2", "seats": [{"name": "later-seat", "serves": [{"name": "v", "a-field-from-later": 1}]}]}`,
|
|
} {
|
|
var m Manifest
|
|
if err := json.Unmarshal([]byte(raw), &m); err != nil {
|
|
t.Errorf("%s: the stored manifest was not read: %v", where, err)
|
|
continue
|
|
}
|
|
if m.Module != "later" || m.Version != "2" || !strings.Contains(m.UnknownField(), `"a-field-from-later"`) {
|
|
t.Errorf("%s: read as %q %q, unknown %q", where, m.Module, m.Version, m.UnknownField())
|
|
continue
|
|
}
|
|
if where != "the top" && !strings.Contains(m.UnknownField(), ": json: unknown field") {
|
|
t.Errorf("%s: the block's decoder did not say it: %q", where, m.UnknownField())
|
|
}
|
|
left := Resolution{Node: "laptop", Modules: []Manifest{m, notifier()}}.LeftOut(nil, false)
|
|
if why := left["later"]; !strings.Contains(why, "uses a field this controller does not know") ||
|
|
!strings.Contains(why, "a-field-from-later") {
|
|
t.Errorf("%s: not left out by name: %v", where, left)
|
|
}
|
|
if _, notifierLeft := left["notifier"]; notifierLeft {
|
|
t.Errorf("%s: another module was left out with it: %v", where, left)
|
|
}
|
|
if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), "a-field-from-later") {
|
|
t.Errorf("%s: the module check took it: %v", where, err)
|
|
}
|
|
}
|
|
var known Manifest
|
|
if err := json.Unmarshal([]byte(`{"module": "now", "state": [{"name": "s"}]}`), &known); err != nil || known.UnknownField() != "" {
|
|
t.Fatalf("a known manifest: %v %q", err, known.UnknownField())
|
|
}
|
|
// A malformed manifest is still refused: only an unknown key is read past.
|
|
var bad Manifest
|
|
if err := json.Unmarshal([]byte(`{"module": "bad", "state": [{"name": 3}]}`), &bad); err == nil {
|
|
t.Fatal("a malformed stored manifest was read")
|
|
}
|
|
}
|
|
|
|
// A module left out for a key this controller does not know, inside an entry, is read past that key
|
|
// alone: it still provides what it provides, its other entries are whole, and a key of the same name
|
|
// that another entry knows is kept (novox/hq ADR 0262).
|
|
func TestALeftOutModuleStillProvidesWhatItProvides(t *testing.T) {
|
|
var m Manifest
|
|
raw := `{"module": "later", "version": "2",
|
|
"provides": [{"name": "db", "scope": "mesh"}, {"name": "cache", "a-field-from-later": 1}],
|
|
"state": [{"name": "s", "history": 3}],
|
|
"data": {"own": [{"id": "d", "path": "${dir:d}", "class": "valuable", "backup": {"dump": "x", "into": "d", "class": "later"}}]},
|
|
"resources": [{"id": "d", "type": "directory", "mode": "0700"}]}`
|
|
if err := json.Unmarshal([]byte(raw), &m); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(m.Provides) != 2 || m.Provides[0].Name != "db" || m.Provides[1].Name != "cache" {
|
|
t.Fatalf("provides: %+v", m.Provides)
|
|
}
|
|
if len(m.State) != 1 || m.State[0].History != 3 {
|
|
t.Fatalf("state: %+v", m.State)
|
|
}
|
|
if m.Data == nil || len(m.Data.Own) != 1 || m.Data.Own[0].Class != "valuable" {
|
|
t.Fatalf("data: the item's own class was taken for the backup's unknown one: %+v", m.Data)
|
|
}
|
|
for _, want := range []string{"provides[1].a-field-from-later", "data.own[0].backup.class"} {
|
|
if !strings.Contains(m.UnknownField(), want) {
|
|
t.Errorf("unknown %q does not say it read without %s", m.UnknownField(), want)
|
|
}
|
|
}
|
|
if !strings.Contains(UnknownFieldReason(m), "left out") {
|
|
t.Fatal(UnknownFieldReason(m))
|
|
}
|
|
}
|
|
|
|
// A left-out module's data is still copied: the backup holder on its machine keeps its lines while every
|
|
// other thing of it is left out.
|
|
func TestALeftOutModulesDataIsStillBackedUp(t *testing.T) {
|
|
var later Manifest
|
|
if err := json.Unmarshal([]byte(`{"module": "later", "version": "2", "a-field-from-later": 1,
|
|
"resources": [{"id": "d", "type": "directory", "mode": "0700"}, {"id": "rc", "type": "file", "path": "/etc/later.conf", "content": "x\n"}],
|
|
"data": {"own": [{"id": "d", "path": "${dir:d}", "class": "valuable"}]}}`), &later); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
holder := Manifest{Module: "backups", Version: "1",
|
|
Claims: []Claim{{Name: BackupSeat, Scope: ScopeNode}},
|
|
Resources: []map[string]any{{"id": "list", "type": "file", "path": "/etc/backups.list", "mode": "0644",
|
|
"content": "${contribution:" + BackupSeat + ":backup}"}}}
|
|
r := anAdoptedAnchor()
|
|
r.Modules = append(r.Modules, holder, later)
|
|
composed, err := r.Compose(anchorRendering(false))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, left := composed.LeftOut["later"]; !left {
|
|
t.Fatalf("not left out: %v", composed.LeftOut)
|
|
}
|
|
got := byID(composed.Resources)
|
|
if _, declared := got["later.rc"]; declared {
|
|
t.Fatal("the left-out module's file is still declared")
|
|
}
|
|
list, _ := got["backups.list"]["content"].(string)
|
|
if !strings.Contains(list, "# later") || !strings.Contains(list, "path /var/lib/later/d") {
|
|
t.Fatalf("the left-out module's data is no longer backed up:\n%s", list)
|
|
}
|
|
}
|