238 lines
9.7 KiB
Go
238 lines
9.7 KiB
Go
package catalogue
|
|
|
|
import (
|
|
"encoding/json"
|
|
"slices"
|
|
"sort"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// issue 900, novox/hq ADR 0394 §1: node-files is a node seat whose verbs say the commands they replace.
|
|
func TestNodeFilesIsANodeSeatWithEveryVerbOfADR0394(t *testing.T) {
|
|
s, ok := SeatNamed(FilesSeat)
|
|
if !ok {
|
|
t.Fatalf("the mesh defines no seat %s", FilesSeat)
|
|
}
|
|
if s.Scope != ScopeNode || s.Delivers != "" || !strings.Contains(s.Decision, "ADR 0394") {
|
|
t.Errorf("%s: scope %q, delivers %q, decision %q", FilesSeat, s.Scope, s.Delivers, s.Decision)
|
|
}
|
|
have := map[string]bool{}
|
|
replaced := map[string]string{}
|
|
for _, v := range s.Serves {
|
|
have[v.Name] = true
|
|
if v.Optional {
|
|
t.Errorf("%s.%s is optional", FilesSeat, v.Name)
|
|
}
|
|
// upload and download (ADR 0468 §9, issue 1718) move bytes through the object store: no command is theirs.
|
|
if len(v.Replaces) == 0 && v.Name != "upload" && v.Name != "download" {
|
|
t.Errorf("%s.%s says nothing it replaces", FilesSeat, v.Name)
|
|
}
|
|
for _, c := range v.Replaces {
|
|
replaced[c] = v.Name
|
|
}
|
|
}
|
|
for _, name := range []string{"stat", "readlink", "list", "read", "search", "account", "write", "copy", "move",
|
|
"delete", "mkdir", "chmod", "chown", "upload", "download", "trash", "restore"} {
|
|
if !have[name] {
|
|
t.Errorf("%s serves no verb %s", FilesSeat, name)
|
|
}
|
|
}
|
|
for command, verb := range map[string]string{"cat": "read", "tee": "write", "cp": "copy", "mv": "move",
|
|
"rm": "delete", "mkdir -p": "mkdir", "chmod": "chmod", "chgrp": "chown"} {
|
|
if replaced[command] != verb {
|
|
t.Errorf("%q is replaced by %q, want %s.%s", command, replaced[command], FilesSeat, verb)
|
|
}
|
|
}
|
|
}
|
|
|
|
// issue 900, ADR 0394 §2: a holder of node-files runs as the tool runner's account, so it declares no runs-as.
|
|
func TestAHolderOfNodeFilesDeclaringRunsAsIsRefused(t *testing.T) {
|
|
p := FilesHolderProblems(Manifest{Module: "files", RunsAs: "mesh-files", Claims: []Claim{{Name: FilesSeat}}})
|
|
if len(p) != 1 || !strings.Contains(p[0], "0394") || !strings.Contains(p[0], "runs-as") {
|
|
t.Errorf("a holder declaring runs-as was not refused: %v", p)
|
|
}
|
|
if p := FilesHolderProblems(Manifest{Module: "files", Claims: []Claim{{Name: FilesSeat}}}); len(p) != 0 {
|
|
t.Errorf("a holder declaring none was refused: %v", p)
|
|
}
|
|
if p := FilesHolderProblems(Manifest{Module: "other", RunsAs: "x"}); len(p) != 0 {
|
|
t.Errorf("a non-holder was judged: %v", p)
|
|
}
|
|
}
|
|
|
|
// issue 900, ADR 0394 §4: the holder is told the secret places and the paths modules own on its node.
|
|
func TestTheHolderOfNodeFilesIsToldSecretPlacesAndOwnedPaths(t *testing.T) {
|
|
files := Manifest{Module: "files", Version: "1", Claims: []Claim{{Name: FilesSeat}},
|
|
OwnSecrets: OwnSecrets{"broker": {Path: "/var/lib/files/broker"}}}
|
|
host := Manifest{Module: "hostnames", Version: "1", Claims: []Claim{{Name: "node-hostname"}},
|
|
Resources: []map[string]any{{"id": "hosts", "type": "file", "path": "/etc/hosts"}}}
|
|
app := Manifest{Module: "app", Version: "1",
|
|
Resources: []map[string]any{{"id": "conf", "type": "file", "path": "/etc/app/app.conf"}}}
|
|
r := Resolution{Node: "anchor", Modules: []Manifest{files, host, app}}
|
|
words, err := r.filesWords(files, nil, Rendering{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var places []string
|
|
if err := json.Unmarshal([]byte(words[ReadsSecretPlaces]), &places); err != nil || len(places) == 0 {
|
|
t.Errorf("no secret places: %v %v", err, words)
|
|
}
|
|
var owned map[string]string
|
|
if err := json.Unmarshal([]byte(words[FilesOwnedPaths]), &owned); err != nil {
|
|
t.Fatalf("no owned paths: %v (%v)", err, words)
|
|
}
|
|
if !strings.HasPrefix(owned["/etc/hosts"], "hostnames") {
|
|
t.Errorf("/etc/hosts is owned by %q, want hostnames", owned["/etc/hosts"])
|
|
}
|
|
if !strings.HasPrefix(owned["/etc/app/app.conf"], "app") {
|
|
t.Errorf("a placed file is owned by %q, want app", owned["/etc/app/app.conf"])
|
|
}
|
|
// Issue 1126, C3: a seat path that steps back for a seat held here is that holder's, whatever the order of the seat
|
|
// table (the controller reads it sorted by name, a node in the order compiled).
|
|
resolver := Manifest{Module: "systemd-resolved", Version: "1", Claims: []Claim{{Name: ResolverSeat}},
|
|
Resources: []map[string]any{{"id": "stub", "type": "file", "path": ResolverFile}}}
|
|
uplink := Manifest{Module: "networkmanager", Version: "1", Claims: []Claim{{Name: "node-uplink"}},
|
|
Resources: []map[string]any{{"id": "resolv", "type": "file", "path": ResolverFile}}}
|
|
compiled := seats
|
|
t.Cleanup(func() { seats = compiled })
|
|
for _, order := range []string{"compiled", "reversed"} {
|
|
if order == "reversed" {
|
|
seats = append([]Seat(nil), compiled...)
|
|
slices.Reverse(seats)
|
|
}
|
|
both := Resolution{Node: "anchor", Modules: []Manifest{files, uplink, resolver}}
|
|
w, err := both.filesWords(files, nil, Rendering{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var o map[string]string
|
|
if err := json.Unmarshal([]byte(w[FilesOwnedPaths]), &o); err != nil || o[ResolverFile] != "systemd-resolved ("+ResolverSeat+")" {
|
|
t.Errorf("seat table %s: %s is owned by %q, want systemd-resolved (%s)", order, ResolverFile, o[ResolverFile], ResolverSeat)
|
|
}
|
|
}
|
|
seats = compiled
|
|
// Issue 1126, C5 and seed-8: a module carried as it runs (compose.Carried) owns its manifest's paths (OwnsPaths), as
|
|
// the controller tells them, and not the paths its running resources are at (a directory the mesh placed).
|
|
carried := Manifest{Module: "de-spiegel", Version: "1", OwnsPaths: []string{"/var/lib/de-spiegel/server.env"},
|
|
Resources: []map[string]any{{"id": "state", "type": "directory", "path": "/var/lib/de-spiegel"}}}
|
|
w, err := Resolution{Node: "anchor", Modules: []Manifest{files, carried}}.filesWords(files, nil, Rendering{})
|
|
if err != nil || !strings.Contains(w[FilesOwnedPaths], `"/var/lib/de-spiegel/server.env":"de-spiegel (its own tools)"`) ||
|
|
strings.Contains(w[FilesOwnedPaths], `"/var/lib/de-spiegel":`) {
|
|
t.Errorf("a carried module does not own its manifest's paths alone: %v %v", w[FilesOwnedPaths], err)
|
|
}
|
|
if w, _ := r.filesWords(app, nil, Rendering{}); w[FilesOwnedPaths] != "" {
|
|
t.Error("a module not holding node-files is told the owned paths")
|
|
}
|
|
}
|
|
|
|
// issue 900: restore, like every write verb, takes a reason and an override.
|
|
func TestNodeFilesRestoreTakesIdAndReason(t *testing.T) {
|
|
s, _ := SeatNamed(FilesSeat)
|
|
for _, v := range s.Serves {
|
|
if v.Name != "restore" {
|
|
continue
|
|
}
|
|
props, _ := v.Input["properties"].(map[string]any)
|
|
for _, p := range []string{"id", "reason", "override"} {
|
|
if _, has := props[p]; !has {
|
|
t.Errorf("restore has no input %s", p)
|
|
}
|
|
}
|
|
if req, _ := v.Input["required"].([]string); strings.Join(req, ",") != "id,reason" {
|
|
t.Errorf("restore requires %v, want id and reason", req)
|
|
}
|
|
return
|
|
}
|
|
t.Error("no restore verb")
|
|
}
|
|
|
|
// 1718/AC1: node-files names upload and download, in mesh-files' order, before trash.
|
|
func TestNodeFilesNamesUploadAndDownloadBeforeTrash(t *testing.T) {
|
|
s, _ := SeatNamed(FilesSeat)
|
|
var names []string
|
|
for _, v := range s.Serves {
|
|
names = append(names, v.Name)
|
|
}
|
|
joined := strings.Join(names, ",")
|
|
if !strings.Contains(joined, "chown,upload,download,trash") {
|
|
t.Errorf("1718/AC1: node-files' verbs are %s, want upload and download between chown and trash", joined)
|
|
}
|
|
}
|
|
|
|
// 1718/AC2: a holder serving every verb including upload and download is accepted; one naming a verb outside the
|
|
// contract is still refused, and one leaving upload out is refused.
|
|
func TestAHolderOfNodeFilesServesUploadAndDownloadAndNothingElse(t *testing.T) {
|
|
s, _ := SeatNamed(FilesSeat)
|
|
var verbs []string
|
|
for _, v := range s.Serves {
|
|
verbs = append(verbs, v.Name)
|
|
}
|
|
hold := func(serves []string) error {
|
|
return CanHold(Manifest{Module: "mesh-files", Version: "1",
|
|
Claims: []Claim{{Name: FilesSeat, Scope: ScopeNode, Serves: serves}}}, s)
|
|
}
|
|
if err := hold(verbs); err != nil {
|
|
t.Errorf("1718/AC2: a holder serving every verb was refused: %v", err)
|
|
}
|
|
if err := hold(append(append([]string{}, verbs...), "sync")); err == nil || !strings.Contains(err.Error(), "does not promise") {
|
|
t.Errorf("1718/AC2: a verb outside the contract was accepted: %v", err)
|
|
}
|
|
var without []string
|
|
for _, v := range verbs {
|
|
if v != "upload" {
|
|
without = append(without, v)
|
|
}
|
|
}
|
|
if err := hold(without); err == nil || !strings.Contains(err.Error(), "upload") {
|
|
t.Errorf("1718/AC2: a holder leaving upload out was accepted: %v", err)
|
|
}
|
|
}
|
|
|
|
// 1718/AC3: upload requires path and bucket; download requires bucket, key, path and reason; sha256, key (upload)
|
|
// and override are plain strings, not true/false switches.
|
|
func TestNodeFilesUploadAndDownloadInputs(t *testing.T) {
|
|
s, _ := SeatNamed(FilesSeat)
|
|
want := map[string]struct {
|
|
required string
|
|
strs []string
|
|
}{
|
|
"upload": {"path,bucket", []string{"path", "bucket", "key"}},
|
|
"download": {"bucket,key,path,reason", []string{"bucket", "key", "path", "sha256", "reason", "override"}},
|
|
}
|
|
seen := 0
|
|
for _, v := range s.Serves {
|
|
w, ok := want[v.Name]
|
|
if !ok {
|
|
continue
|
|
}
|
|
seen++
|
|
req, _ := v.Input["required"].([]string)
|
|
got := append([]string{}, req...)
|
|
sortStrings(got)
|
|
exp := strings.Split(w.required, ",")
|
|
sortStrings(exp)
|
|
if strings.Join(got, ",") != strings.Join(exp, ",") {
|
|
t.Errorf("1718/AC3: %s requires %v, want %s", v.Name, req, w.required)
|
|
}
|
|
props, _ := v.Input["properties"].(map[string]any)
|
|
if len(props) != len(w.strs) {
|
|
t.Errorf("1718/AC3: %s has %d inputs, want %v", v.Name, len(props), w.strs)
|
|
}
|
|
for _, name := range w.strs {
|
|
p, has := props[name].(map[string]any)
|
|
if !has {
|
|
t.Errorf("1718/AC3: %s has no input %s", v.Name, name)
|
|
continue
|
|
}
|
|
if _, enum := p["enum"]; enum || p["type"] != "string" {
|
|
t.Errorf("1718/AC3: %s input %s is %v, want a plain string", v.Name, name, p)
|
|
}
|
|
}
|
|
}
|
|
if seen != 2 {
|
|
t.Errorf("1718/AC3: found %d of upload and download", seen)
|
|
}
|
|
}
|
|
|
|
func sortStrings(s []string) { sort.Strings(s) }
|