package catalogue import ( "os" "os/exec" "path/filepath" "strings" "testing" ) // Defends novox/hq ADR 0203 (the account's environment is one module's, and every module // contributes to it) and ADR 0204 (shell code in named slots, placed by the login shell's holder). // contributors is a fixed set of contributions, in no particular order: what the renderings are // asserted against byte for byte. go-toolchain and zsh both put ~/.local/bin on PATH, which is the // ordinary case of two modules sharing a directory, and is written once. func contributors() []Manifest { return []Manifest{ {Module: "zsh", Environment: &Environment{ Variables: map[string]string{"XDG_CONFIG_HOME": "${machine:account-home}/.config", "EDITOR": "vim"}, Path: []PathEntry{ {Entry: "${machine:account-home}/.local/bin", At: PathAtStart}, {Entry: "${machine:account-home}/bin", At: PathAtStart}, {Entry: "/opt/scripts", At: PathAtEnd}, }, }}, {Module: "go-toolchain", Environment: &Environment{ Variables: map[string]string{"GOPATH": "${machine:account-home}/go"}, Path: []PathEntry{ {Entry: "${machine:account-home}/go/bin", At: PathAtStart}, {Entry: "/usr/local/go/bin", At: PathAtStart}, {Entry: "${machine:account-home}/.local/bin", At: PathAtStart}, }, }}, {Module: "agent", Environment: &Environment{ Variables: map[string]string{"DISABLE_AUTOUPDATER": "1"}, Path: []PathEntry{{Entry: "/opt/agent/bin", At: PathAtEnd}}, }}, // A module contributing nothing is in the set and writes nothing. {Module: "postgres"}, } } var operatorFacts = map[string]string{"name": "workstation", "account": "op", "account-home": "/home/op"} // The final PATH this set composes, around whatever the account had: the start entries in module // order and then declared order, the account's own, then the end entries. const composedPOSIX = `# agent export DISABLE_AUTOUPDATER='1' # go-toolchain export GOPATH='/home/op/go' # zsh export EDITOR='vim' export XDG_CONFIG_HOME='/home/op/.config' # zsh case ":${PATH}:" in *':/home/op/bin:'*) ;; *) PATH='/home/op/bin'"${PATH:+:${PATH}}" ;; esac # go-toolchain case ":${PATH}:" in *':/home/op/.local/bin:'*) ;; *) PATH='/home/op/.local/bin'"${PATH:+:${PATH}}" ;; esac case ":${PATH}:" in *':/usr/local/go/bin:'*) ;; *) PATH='/usr/local/go/bin'"${PATH:+:${PATH}}" ;; esac case ":${PATH}:" in *':/home/op/go/bin:'*) ;; *) PATH='/home/op/go/bin'"${PATH:+:${PATH}}" ;; esac # agent case ":${PATH}:" in *':/opt/agent/bin:'*) ;; *) PATH="${PATH:+${PATH}:}"'/opt/agent/bin' ;; esac # zsh case ":${PATH}:" in *':/opt/scripts:'*) ;; *) PATH="${PATH:+${PATH}:}"'/opt/scripts' ;; esac export PATH ` const composedSystemd = `# agent DISABLE_AUTOUPDATER=1 # go-toolchain GOPATH=/home/op/go # zsh EDITOR=vim XDG_CONFIG_HOME=/home/op/.config # go-toolchain, zsh PATH=/home/op/go/bin:/usr/local/go/bin:/home/op/.local/bin:/home/op/bin${PATH:+:$PATH} # agent, zsh PATH=${PATH:+$PATH:}/opt/agent/bin:/opt/scripts ` func TestTheEnvironmentRendersForAPOSIXShellByteForByte(t *testing.T) { env, err := environmentOn(contributors(), operatorFacts) if err != nil { t.Fatal(err) } if got := env.posix(); got != composedPOSIX { t.Fatalf("the POSIX rendering is\n%s\nnot\n%s", got, composedPOSIX) } } func TestTheEnvironmentRendersForTheServiceManagerByteForByte(t *testing.T) { env, err := environmentOn(contributors(), operatorFacts) if err != nil { t.Fatal(err) } if got := env.systemd(); got != composedSystemd { t.Fatalf("the environment.d rendering is\n%s\nnot\n%s", got, composedSystemd) } } // Sourcing twice changes nothing (ADR 0203 §3): a login shell that starts another reads the file // again, and a PATH that grew each time would be the symptom. Run by a real `sh`, because the claim // is about what a shell does with the file, not about what the file looks like. func TestThePOSIXEnvironmentSourcedTwiceLeavesPATHAsOnce(t *testing.T) { sh, err := exec.LookPath("sh") if err != nil { t.Skip("no sh on this machine") } script := "PATH=/usr/bin:/bin\n" + composedPOSIX + "once=$PATH\n" + composedPOSIX + `[ "$PATH" = "$once" ] || { echo "changed: $once -> $PATH"; exit 1; }` + "\n" + `echo "$PATH"; echo "$GOPATH"` out, err := exec.Command(sh, "-c", script).CombinedOutput() if err != nil { t.Fatalf("sourcing twice: %v\n%s", err, out) } lines := strings.Split(strings.TrimSpace(string(out)), "\n") want := "/home/op/go/bin:/usr/local/go/bin:/home/op/.local/bin:/home/op/bin:/usr/bin:/bin:/opt/agent/bin:/opt/scripts" if lines[0] != want { t.Fatalf("PATH is %s, not %s", lines[0], want) } if lines[1] != "/home/op/go" { t.Fatalf("GOPATH was not exported: %q", lines[1]) } // And an entry the account already has stays where it is, and once. out, err = exec.Command(sh, "-c", "PATH=/opt/scripts:/usr/bin\n"+composedPOSIX+`echo "$PATH"`).CombinedOutput() if err != nil { t.Fatalf("%v\n%s", err, out) } if got := strings.TrimSpace(string(out)); got != "/home/op/go/bin:/usr/local/go/bin:/home/op/.local/bin:/home/op/bin:/opt/scripts:/usr/bin:/opt/agent/bin" { t.Fatalf("an entry already on PATH was added again or moved: %s", got) } } // The environment.d rendering, read by the service manager's own generator where this machine has // one — the same reader an account's user manager runs, so the PATH it composes is the one asserted. func TestTheServiceManagerReadsTheSystemdRenderingAsMeant(t *testing.T) { generator := "/usr/lib/systemd/user-environment-generators/30-systemd-environment-d-generator" if _, err := os.Stat(generator); err != nil { t.Skip("no environment.d generator on this machine") } config := t.TempDir() if err := os.MkdirAll(filepath.Join(config, "environment.d"), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(config, "environment.d", "50-mesh.conf"), []byte(composedSystemd), 0o644); err != nil { t.Fatal(err) } cmd := exec.Command(generator) cmd.Env = []string{"PATH=/usr/bin:/bin", "HOME=" + config, "XDG_CONFIG_HOME=" + config} out, err := cmd.CombinedOutput() if err != nil { t.Fatalf("%v\n%s", err, out) } want := "PATH=/home/op/go/bin:/usr/local/go/bin:/home/op/.local/bin:/home/op/bin:/usr/bin:/bin:/opt/agent/bin:/opt/scripts" if !strings.Contains(string(out), want+"\n") || !strings.Contains(string(out), "GOPATH=/home/op/go\n") { t.Fatalf("the service manager read\n%s", out) } } // Nothing contributed renders nothing, in both formats — not an empty `export PATH`. func TestNoContributionsRenderNothing(t *testing.T) { env, err := environmentOn([]Manifest{{Module: "postgres"}}, operatorFacts) if err != nil { t.Fatal(err) } if env.posix() != "" || env.systemd() != "" { t.Fatalf("an empty environment rendered %q and %q", env.posix(), env.systemd()) } } // A ${machine:…} fact the machine does not have is refused naming the module, as a file's is. func TestAContributedFactTheMachineLacksIsRefused(t *testing.T) { _, err := environmentOn(contributors(), map[string]string{"name": "server"}) if err == nil || !strings.Contains(err.Error(), "go-toolchain sets GOPATH") || !strings.Contains(err.Error(), "${machine:account-home}") { t.Fatalf("a missing account home was not refused by name: %v", err) } } // ADR 0203 §5: two modules setting one variable are refused, both named — neither silently wins. func TestAVariableTwoModulesSetIsRefusedNamingBoth(t *testing.T) { modules := append(contributors(), Manifest{Module: "neovim", Environment: &Environment{ Variables: map[string]string{"EDITOR": "nvim"}}}) _, err := environmentOn(modules, operatorFacts) if err == nil || err.Error() != "neovim and zsh both set EDITOR on this machine; the account has one "+ "environment, so one of them must stop setting it (novox/hq ADR 0203)" { t.Fatalf("a variable set twice was not refused naming both: %v", err) } // And at composition, whether or not the node holds the environment. r := Resolution{Node: "workstation", Account: "op", Modules: modules} if _, err := r.Declaration(Rendering{}); err == nil || !strings.Contains(err.Error(), "neovim and zsh both set EDITOR") { t.Fatalf("composition accepted a variable set twice: %v", err) } } // shells contributes code for several shells and slots, in no order. func shells() []Manifest { return []Manifest{ {Module: "zsh-syntax-highlighting", Shell: []ShellCode{ {For: "zsh", Slot: "last", Code: "source /usr/share/zsh/plugins/zsh-syntax-highlighting/zsh-syntax-highlighting.zsh"}, }}, {Module: "powerlevel10k", Shell: []ShellCode{ {For: "zsh", Slot: "first", Code: "if [[ -r \"${XDG_CACHE_HOME:-$HOME/.cache}/p10k-instant-prompt-${(%):-%n}.zsh\" ]]; then\n" + " source \"${XDG_CACHE_HOME:-$HOME/.cache}/p10k-instant-prompt-${(%):-%n}.zsh\"\nfi\n"}, {For: "zsh", Slot: "normal", Code: "source ~/.local/share/powerlevel10k/powerlevel10k.zsh-theme"}, {For: "zsh", Slot: "normal", Code: "[[ -f ~/.local/share/powerlevel10k/p10k.zsh ]] && source ~/.local/share/powerlevel10k/p10k.zsh"}, }}, {Module: "zsh-autosuggestions", Shell: []ShellCode{ {For: "zsh", Slot: "normal", Code: "source /usr/share/zsh/plugins/zsh-autosuggestions/zsh-autosuggestions.zsh"}, {For: "bash", Slot: "normal", Code: "echo not for zsh"}, }}, {Module: "direnv", Shell: []ShellCode{ {For: "fish", Slot: "last", Code: "direnv hook fish | source"}, {For: "bash", Slot: "last", Code: "eval \"$(direnv hook bash)\""}, }}, } } // ADR 0204 §3: a slot holds that shell's code only, in module order, each module's pieces in the // order it declared them under a line naming it; empty when nothing is contributed. func TestShellCodeLandsInItsSlotInModuleOrderForItsShellOnly(t *testing.T) { if got, want := shellCode(shells(), "zsh", "normal"), "# powerlevel10k\n"+ "source ~/.local/share/powerlevel10k/powerlevel10k.zsh-theme\n"+ "[[ -f ~/.local/share/powerlevel10k/p10k.zsh ]] && source ~/.local/share/powerlevel10k/p10k.zsh\n"+ "# zsh-autosuggestions\n"+ "source /usr/share/zsh/plugins/zsh-autosuggestions/zsh-autosuggestions.zsh\n"; got != want { t.Fatalf("zsh's normal slot is\n%s\nnot\n%s", got, want) } if got, want := shellCode(shells(), "zsh", "last"), "# zsh-syntax-highlighting\n"+ "source /usr/share/zsh/plugins/zsh-syntax-highlighting/zsh-syntax-highlighting.zsh\n"; got != want { t.Fatalf("zsh's last slot is\n%s\nnot\n%s", got, want) } if got, want := shellCode(shells(), "bash", "last"), "# direnv\neval \"$(direnv hook bash)\"\n"; got != want { t.Fatalf("bash's last slot is %q, not %q", got, want) } if got := shellCode(shells(), "fish", "first"); got != "" { t.Fatalf("a slot nobody contributed to holds %q", got) } } // The holder of node-login-shell, as WP3's zsh module writes its block, with its own zsh around the // slots — which holds `${…}` of the shell's own that no mesh pass may touch either. func zshHolder() Manifest { return Manifest{Module: "zsh", Claims: []Claim{{Name: LoginShellSeat, Scope: ScopeNode}}, Resources: []map[string]any{ {"id": "zshrc", "type": "file", "path": "${machine:account-home}/.zshrc", "content": "" + "${shell:zsh:first}" + "PROMPT='%n@%m ${PWD/#$HOME/~} '\n" + "${shell:zsh:normal}" + "alias ll='ls -l'\n" + "${shell:zsh:last}"}, }} } // The case the ordering exists for: contributed zsh code full of `${…}` reaches the file byte for // byte, because the shell's code is placed after every other placeholder pass and in one pass — a // scanner for the mesh's placeholders that ran after it would read `${XDG_CACHE_HOME:-…}` and // `${(%):-%n}` as the mesh's, or fill a `${machine:…}` some module wrote for its shell to see. func TestShellCodeReachesTheHoldersFileByteForByte(t *testing.T) { modules := append(shells(), zshHolder(), Manifest{Module: "sly", Shell: []ShellCode{ {For: "zsh", Slot: "last", Code: "echo ${machine:account-home} ${secret:x} ${shell:zsh:first} ${environment:posix}"}, }}) r := Resolution{Node: "workstation", Account: "op", Modules: modules} out, err := r.Declaration(Rendering{}) if err != nil { t.Fatal(err) } var zshrc map[string]any for _, res := range out { if res["id"] == "zsh.zshrc" { zshrc = res } } if zshrc == nil { t.Fatalf("the holder's file was not composed: %v", out) } if zshrc["path"] != "/home/op/.zshrc" { t.Fatalf("the holder's own placeholders were not filled first: %v", zshrc["path"]) } want := "# powerlevel10k\n" + "if [[ -r \"${XDG_CACHE_HOME:-$HOME/.cache}/p10k-instant-prompt-${(%):-%n}.zsh\" ]]; then\n" + " source \"${XDG_CACHE_HOME:-$HOME/.cache}/p10k-instant-prompt-${(%):-%n}.zsh\"\nfi\n" + "PROMPT='%n@%m ${PWD/#$HOME/~} '\n" + "# powerlevel10k\n" + "source ~/.local/share/powerlevel10k/powerlevel10k.zsh-theme\n" + "[[ -f ~/.local/share/powerlevel10k/p10k.zsh ]] && source ~/.local/share/powerlevel10k/p10k.zsh\n" + "# zsh-autosuggestions\n" + "source /usr/share/zsh/plugins/zsh-autosuggestions/zsh-autosuggestions.zsh\n" + "alias ll='ls -l'\n" + "# sly\n" + "echo ${machine:account-home} ${secret:x} ${shell:zsh:first} ${environment:posix}\n" + "# zsh-syntax-highlighting\n" + "source /usr/share/zsh/plugins/zsh-syntax-highlighting/zsh-syntax-highlighting.zsh\n" if got := zshrc["content"]; got != want { t.Fatalf("the holder's .zshrc is\n%s\nnot\n%s", got, want) } } // The holder of node-environment places both renderings, and they are the same as rendered alone. func TestTheEnvironmentHolderPlacesBothRenderings(t *testing.T) { holder := Manifest{Module: "node-env", Claims: []Claim{{Name: EnvironmentSeat, Scope: ScopeNode}}, Resources: []map[string]any{ {"id": "posix", "type": "file", "path": "${machine:account-home}/.config/mesh/environment.sh", "content": "# The mesh's environment.\n${environment:posix}"}, {"id": "systemd", "type": "file", "path": "${machine:account-home}/.config/environment.d/50-mesh.conf", "content": "${environment:systemd}"}, }} r := Resolution{Node: "workstation", Account: "op", Modules: append(contributors(), holder)} out, err := r.Declaration(Rendering{}) if err != nil { t.Fatal(err) } by := map[string]any{} for _, res := range out { by[res["id"].(string)] = res["content"] } if by["node-env.posix"] != "# The mesh's environment.\n"+composedPOSIX { t.Fatalf("the POSIX file is\n%v", by["node-env.posix"]) } if by["node-env.systemd"] != composedSystemd { t.Fatalf("the environment.d file is\n%v", by["node-env.systemd"]) } } // ADR 0203 §5 and ADR 0204 §3: a placeholder outside the seat's holder is refused — by the parser, // which is what the catalogue check and registration run, and again at composition, in the same words. func TestAPlaceholderOutsideTheHolderIsRefused(t *testing.T) { for _, c := range []struct{ content, want string }{ {"${environment:posix}", "toolchain's resource rc names ${environment:posix} and toolchain does not claim node-environment"}, {"${shell:zsh:normal}", "toolchain's resource rc names ${shell:zsh:normal} and toolchain does not claim node-login-shell"}, } { raw := `{"module":"toolchain","resources":[{"id":"rc","type":"file","path":"/etc/rc","content":"` + c.content + `"}]}` if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), c.want) { t.Errorf("the catalogue check accepted %s outside its holder: %v", c.content, err) } m := Manifest{Module: "toolchain", Resources: []map[string]any{ {"id": "rc", "type": "file", "path": "/etc/rc", "content": c.content}}} r := Resolution{Node: "workstation", Account: "op", Modules: []Manifest{m}} if _, err := r.Declaration(Rendering{}); err == nil || !strings.Contains(err.Error(), c.want) { t.Errorf("composition accepted %s outside its holder: %v", c.content, err) } } } // A key nobody renders is refused, not left in the file as a literal. func TestAnUnknownPlaceholderKeyIsRefused(t *testing.T) { for _, c := range []struct{ content, want string }{ {"${environment:foo}", "names ${environment:foo}; the environment is ${environment:posix} or ${environment:systemd}"}, {"${shell:zsh:middle}", "names ${shell:zsh:middle}; shell code is ${shell::}"}, {"${shell:tcsh:first}", "names ${shell:tcsh:first}; shell code is ${shell::}"}, {"${shell:zsh}", "names ${shell:zsh}; shell code is ${shell::}"}, } { raw := `{"module":"holder","claims":[{"name":"node-environment","scope":"node"},{"name":"node-login-shell","scope":"node"}],` + `"resources":[{"id":"rc","type":"file","path":"/etc/rc","content":"` + c.content + `"}]}` if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), c.want) { t.Errorf("%s was accepted: %v", c.content, err) } } // And outside a file's content, where nothing could be placed. raw := `{"module":"holder","claims":[{"name":"node-environment","scope":"node"}],` + `"resources":[{"id":"rc","type":"file","path":"/etc/${environment:posix}","content":"x"}]}` if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), "names ${environment:posix} in its path; the environment and the shell's code are placed only in a file's content") { t.Errorf("a placeholder in a path was accepted: %v", err) } } // What ADR 0203 §2 allows a contribution to say, refused at parse when it says anything else. func TestAMalformedEnvironmentIsRefusedAtParse(t *testing.T) { for _, c := range []struct{ environment, want string }{ {`{"variables":{"1X":"a"}}`, `tool sets the variable "1X", which is not a name a shell accepts`}, {`{"variables":{"MY-VAR":"a"}}`, `tool sets the variable "MY-VAR", which is not a name a shell accepts`}, {`{"variables":{"PATH":"/bin"}}`, `tool sets PATH as a variable; a module adds an entry under environment.path`}, {`{"variables":{"A":"$HOME/x"}}`, `tool sets A to "$HOME/x", which holds a $ that is not one of the machine's ${machine:…} facts`}, {`{"variables":{"A":"${HOME}/x"}}`, `tool sets A to "${HOME}/x", which holds a $`}, {`{"variables":{"A":"it's"}}`, `tool sets A to "it's", which holds a quote`}, {`{"variables":{"A":"say \"hi\""}}`, `which holds a quote`}, {`{"variables":{"A":"a\\b"}}`, `which holds a backslash`}, {`{"variables":{"A":"a\nb"}}`, `which holds a line break`}, {`{"variables":{"A":"a\u0000b"}}`, `which holds a NUL`}, {`{"path":[{"entry":"","at":"start"}]}`, `tool's PATH entry 1 names no directory`}, {`{"path":[{"entry":"/a:/b","at":"start"}]}`, `tool puts "/a:/b" on PATH, which holds a colon`}, {`{"path":[{"entry":"$HOME/bin","at":"start"}]}`, `tool puts "$HOME/bin" on PATH, which holds a $`}, {`{"path":[{"entry":"/a","at":"middle"}]}`, `tool puts "/a" on PATH at "middle"; an entry goes at "start" or "end"`}, {`{"path":[{"entry":"/a"}]}`, `tool puts "/a" on PATH at ""`}, {`{"path":[{"entry":"/a","at":"start"},{"entry":"/a","at":"end"}]}`, `tool puts "/a" on PATH twice`}, {`{"variables":{"A":"x"},"paths":[]}`, `unknown field "paths"`}, } { _, err := ParseManifest([]byte(`{"module":"tool","environment":` + c.environment + `}`)) if err == nil || !strings.Contains(err.Error(), c.want) { t.Errorf("%s: want %q, got %v", c.environment, c.want, err) } } // What is allowed: a literal, and the machine's own facts. if _, err := ParseManifest([]byte(`{"module":"tool","environment":{` + `"variables":{"GOPATH":"${machine:account-home}/go","DISABLE_X":"1","ANSWER":"a b+c=d"},` + `"path":[{"entry":"${machine:account-home}/go/bin","at":"start"},{"entry":"/opt/x","at":"end"}]}}`)); err != nil { t.Fatalf("a well-formed environment was refused: %v", err) } } func TestMalformedShellCodeIsRefusedAtParse(t *testing.T) { for _, c := range []struct{ shell, want string }{ {`[{"for":"tcsh","slot":"normal","code":"x"}]`, `tool's shell code 1 is for "tcsh"; the shells are zsh, bash, fish`}, {`[{"for":"zsh","slot":"middle","code":"x"}]`, `tool's shell code 1 goes in the slot "middle"; the slots are first, normal, last`}, {`[{"for":"zsh","slot":"last","code":"x"},{"for":"zsh","slot":"last","code":" \n"}]`, `tool's shell code 2 has no code`}, {`[{"for":"zsh","slot":"last","code":"x","order":1}]`, `unknown field "order"`}, } { _, err := ParseManifest([]byte(`{"module":"tool","shell":` + c.shell + `}`)) if err == nil || !strings.Contains(err.Error(), c.want) { t.Errorf("%s: want %q, got %v", c.shell, c.want, err) } } // The code itself is never judged: a shell's own `${…}` is not the mesh's. if _, err := ParseManifest([]byte(`{"module":"tool","shell":[{"for":"zsh","slot":"first",` + `"code":"source \"${XDG_CACHE_HOME:-$HOME/.cache}/p10k-instant-prompt-${(%):-%n}.zsh\""}]}`)); err != nil { t.Fatalf("shell code was judged as if it were the mesh's: %v", err) } } // ADR 0203 §1 and ADR 0204 §1: both seats are the mesh's own, held once per machine; the login // shell's contract is `execute`, described and with a schema an agent can call. func TestTheSeatTableCarriesTheEnvironmentAndTheLoginShell(t *testing.T) { env, ok := SeatNamed("node-environment") if !ok || env.Scope != ScopeNode || env.Decision != "novox/hq ADR 0203" || len(env.Serves)+len(env.Accepts)+len(env.Emits) != 0 || env.Delivers != "" { t.Fatalf("node-environment is not a node seat with no protocol: %+v (defined %v)", env, ok) } shell, ok := SeatNamed("node-login-shell") if !ok || shell.Scope != ScopeNode || shell.Decision != "novox/hq ADR 0204" { t.Fatalf("node-login-shell is not a node seat: %+v (defined %v)", shell, ok) } if len(shell.Serves) != 1 || shell.Serves[0].Name != "execute" || shell.Serves[0].Description == "" { t.Fatalf("the login shell serves %+v, not execute alone", shell.Serves) } props, _ := shell.Serves[0].Input["properties"].(map[string]any) required, _ := shell.Serves[0].Input["required"].([]string) if _, has := props["command"]; !has || len(required) != 1 || required[0] != "command" { t.Fatalf("execute does not require a command: %v", shell.Serves[0].Input) } if _, has := props["timeout_seconds"]; !has { t.Fatalf("execute takes no timeout: %v", props) } } // ADR 0204 §1: the login shell is the mesh's, so no module declares it — neither under the mesh's // name nor under the name a module gave it before. func TestNoModuleMayDeclareTheLoginShell(t *testing.T) { for _, n := range []string{"login-shell", "node-login-shell", "node-environment"} { raw := `{"module":"zsh","seats":[{"name":"` + n + `","scope":"node","serves":["execute"]}],"tools":["execute"]}` _, err := ParseManifest([]byte(raw)) if err == nil { t.Errorf("a module declaring %q was accepted", n) } } got := strings.Join(declaredSeatProblems(Manifest{Module: "zsh", DefinesSeats: []SeatDeclaration{{Name: "login-shell", Scope: ScopeNode}}}), "; ") if !strings.Contains(got, `zsh declares a seat named "login-shell"; the login shell is the mesh's own seat node-login-shell`) { t.Fatalf("declaring login-shell was not refused by name: %q", got) } // And a shell module claiming the mesh's seat, serving execute, is what the seat is for. m, err := ParseManifest([]byte(`{"module":"zsh","tools":["execute"],` + `"claims":[{"name":"node-login-shell","scope":"node"}]}`)) if err != nil { t.Fatal(err) } if err := CanHold(m, Seat{Name: LoginShellSeat, Scope: ScopeNode, Serves: loginShellVerbs()}); err != nil { t.Fatalf("a shell module claiming the seat cannot hold it: %v", err) } }