package office import ( "encoding/json" "os" "strings" "testing" "git.novox.be/novox/mesh-sdk/go/catalogue" ) // 1830/AC1 (unit: manifest check) // Given the catalogue, when only-office's manifest is parsed, then it offers document-server with // credential own jwt, jwt says taken at-start, and serves document-server on port 9070 with a // public-root. func TestManifestOffersDocumentServer(t *testing.T) { raw, err := os.ReadFile("module.json") if err != nil { t.Fatalf("reading module.json: %v", err) } m, err := catalogue.ParseManifest(raw) if err != nil { t.Fatalf("parsing module.json: %v", err) } var offer *catalogue.Offer for i := range m.Provides { if m.Provides[i].Name == "document-server" { offer = &m.Provides[i] break } } if offer == nil { t.Fatal("manifest does not provide document-server") } if offer.Scope != catalogue.ScopeMesh { t.Errorf("document-server scope = %q, want %q", offer.Scope, catalogue.ScopeMesh) } if offer.Credential == nil || offer.Credential.Own != "jwt" { t.Errorf("document-server credential = %+v, want own jwt", offer.Credential) } own, ok := m.OwnSecrets["jwt"] if !ok { t.Fatal("manifest declares no own-secret jwt") } if own.Taken != catalogue.TakenAtStart { t.Errorf("own-secrets.jwt.taken = %q, want %q", own.Taken, catalogue.TakenAtStart) } serve, ok := m.Serves["document-server"] if !ok { t.Fatal("manifest serves no document-server") } if scheme, _ := serve["scheme"].(string); scheme != "http" { t.Errorf("serves.document-server.scheme = %q, want http", scheme) } if port, _ := serve["port"].(float64); port != 9070 { t.Errorf("serves.document-server.port = %v, want 9070", serve["port"]) } if root, _ := serve["public-root"].(string); root != "https://${bound:route:name}/" { t.Errorf("serves.document-server.public-root = %q, want https://${bound:route:name}/", root) } } // 1837/AC1 (unit: manifest check) // Given the catalogue, when only-office's manifest and its server.env template are read, then the // document server's secure-link secret is an own secret taken at start, and server.env hands it to // the container as SECURE_LINK_SECRET, so its document service and its nginx sign and check // Editor.bin links with one value across every restart. func TestSecureLinkSecretIsPinned(t *testing.T) { raw, err := os.ReadFile("module.json") if err != nil { t.Fatalf("reading module.json: %v", err) } m, err := catalogue.ParseManifest(raw) if err != nil { t.Fatalf("parsing module.json: %v", err) } own, ok := m.OwnSecrets["secure-link"] if !ok { t.Fatal("manifest declares no own-secret secure-link") } if own.Taken != catalogue.TakenAtStart { t.Errorf("own-secrets.secure-link.taken = %q, want %q", own.Taken, catalogue.TakenAtStart) } env, err := os.ReadFile("files/server.env") if err != nil { t.Fatalf("reading files/server.env: %v", err) } if !strings.Contains(string(env), "\nSECURE_LINK_SECRET=${secret:secure-link}\n") { t.Errorf("files/server.env does not set SECURE_LINK_SECRET=${secret:secure-link}:\n%s", env) } } // 1837/AC3 (unit: manifest check) // Given the catalogue, when only-office's manifest is read, then the directory mounted at // /var/lib/onlyoffice (server-lib, where the document service writes the converted files nginx // serves) lets other users pass through it (mode 0711): the image makes it the document service's // own, and nginx runs as another user, so a mode of 0700 makes nginx answer 403 "Permission denied" // on Editor.bin. Host accounts are kept out by the box above it (1843/AC1). func TestLibDirectoryLetsNginxThrough(t *testing.T) { m := readLayout(t) src := m.volumeSource(t, "/var/lib/onlyoffice") if src != "${dir:server-lib}" { t.Fatalf("/var/lib/onlyoffice is mounted from %q, want ${dir:server-lib}", src) } d := m.dir(t, "server-lib") if d.Mode != "0711" { t.Errorf("server-lib directory mode = %q, want 0711 so nginx can reach the document service's cache", d.Mode) } } // 1843/AC1 (unit: manifest check) // Given the catalogue, when only-office's manifest is read, then it declares a directory box with no // place, no path and no owner (root, /var/lib/only-office/box) and mode 0700, and every host source of // the server container's volumes is a directory whose path is ${dir:box}/, so no account but // root can pass to anything the container writes: the image opens what is below each mount to every // account on every start. func TestContainerDirectoriesSitInARootOnlyBox(t *testing.T) { m := readLayout(t) box := m.dir(t, "box") if box.Place != "" || box.Path != "" || box.Owner != "" { t.Errorf("box = place %q, path %q, owner %q, want none of them (root, at /only-office/box)", box.Place, box.Path, box.Owner) } if box.Mode != "0700" { t.Errorf("box mode = %q, want 0700", box.Mode) } c := m.container(t, "server") if len(c.Volumes) == 0 { t.Fatal("the server container mounts nothing") } for _, v := range c.Volumes { src, target := splitVolume(v) id, ok := strings.CutPrefix(src, "${dir:") if !ok || !strings.HasSuffix(id, "}") { t.Errorf("volume %q has a host source that is not a directory of the module", v) continue } d := m.dir(t, strings.TrimSuffix(id, "}")) if !strings.HasPrefix(d.Path, "${dir:box}/") || strings.Count(d.Path, "/") != 1 { t.Errorf("%s (mounted at %s) has path %q, want ${dir:box}/", d.ID, target, d.Path) } if d.Owner != "" { t.Errorf("%s is owned by %q, want root", d.ID, d.Owner) } } } // 1843/AC2 (unit: manifest check) // Given the catalogue, when only-office's manifest is read, then the module root (state) is still // root-owned, mode 0711, place ".", and the converter's secrets and environment still name // ${dir:state}/jwt.secret and ${dir:state}/broker-converter.secret: the converter passes the root and // nothing below it, and no account but root owns a directory on any path. func TestConverterPassesOnlyTheRoot(t *testing.T) { m := readLayout(t) state := m.dir(t, "state") if state.Place != "." || state.Owner != "" || state.Mode != "0711" { t.Errorf("state = place %q, owner %q, mode %q, want place ., root, 0711", state.Place, state.Owner, state.Mode) } for _, r := range m.Resources { if r.Type == "directory" && r.Owner != "" { t.Errorf("directory %s is owned by %q, want root", r.ID, r.Owner) } } for name, want := range map[string]string{"jwt": "${dir:state}/jwt.secret", "broker-converter": "${dir:state}/broker-converter.secret"} { if got := m.OwnSecrets[name].Path; got != want { t.Errorf("own-secrets.%s.path = %q, want %q", name, got, want) } } var conv *layoutResource for i := range m.Resources { if m.Resources[i].ID == "converter" { conv = &m.Resources[i] } } if conv == nil { t.Fatal("manifest declares no converter") } if conv.Env["ONLY_OFFICE_JWT"] != "${dir:state}/jwt.secret" || conv.Env["ONLY_OFFICE_BROKER"] != "${dir:state}/broker-converter.secret" { t.Errorf("converter env = %v, want its secrets in ${dir:state}", conv.Env) } } // 1843/AC3 (unit: manifest check) // Given the catalogue, when only-office's manifest is read, then the directory lib is declared with no // path (its old place, /var/lib/only-office/lib) and mode 0700, so the next apply closes the copy the // image opened, which mesh-host keeps because it is not empty; no container volume names ${dir:lib}; // and every data entry points at a directory that is mounted now. func TestOldLibIsClosedAndUnmounted(t *testing.T) { m := readLayout(t) lib := m.dir(t, "lib") if lib.Path != "" || lib.Place != "" { t.Errorf("lib = path %q, place %q, want neither: it stays where the old copy is", lib.Path, lib.Place) } if lib.Mode != "0700" { t.Errorf("lib mode = %q, want 0700", lib.Mode) } mounted := map[string]bool{} for _, v := range m.container(t, "server").Volumes { src, _ := splitVolume(v) mounted[src] = true } if mounted["${dir:lib}"] { t.Error("the server container still mounts ${dir:lib}") } for _, d := range m.Data.Own { if !mounted[d.Path] { t.Errorf("data entry %s points at %q, which the server container does not mount", d.ID, d.Path) } } } type layoutResource struct { ID string `json:"id"` Type string `json:"type"` Mode string `json:"mode"` Place string `json:"place"` Path string `json:"path"` Owner string `json:"owner"` Volumes []string `json:"volumes"` Env map[string]string `json:"env"` } type layout struct { Resources []layoutResource `json:"resources"` OwnSecrets map[string]struct { Path string `json:"path"` } `json:"own-secrets"` Data struct { Own []struct { ID string `json:"id"` Path string `json:"path"` } `json:"own"` } `json:"data"` } func readLayout(t *testing.T) layout { t.Helper() raw, err := os.ReadFile("module.json") if err != nil { t.Fatalf("reading module.json: %v", err) } if _, err := catalogue.ParseManifest(raw); err != nil { t.Fatalf("parsing module.json: %v", err) } var m layout if err := json.Unmarshal(raw, &m); err != nil { t.Fatalf("parsing module.json: %v", err) } return m } func (m layout) dir(t *testing.T, id string) layoutResource { t.Helper() for _, r := range m.Resources { if r.ID == id && r.Type == "directory" { return r } } t.Fatalf("manifest declares no directory %s", id) return layoutResource{} } func (m layout) container(t *testing.T, id string) layoutResource { t.Helper() for _, r := range m.Resources { if r.ID == id && r.Type == "container" { return r } } t.Fatalf("manifest declares no container %s", id) return layoutResource{} } func (m layout) volumeSource(t *testing.T, target string) string { t.Helper() for _, v := range m.container(t, "server").Volumes { if src, to := splitVolume(v); to == target { return src } } t.Fatalf("the server container mounts nothing at %s", target) return "" } // splitVolume is a volume's host source and container target; the source's own ${dir:…} holds a colon. func splitVolume(v string) (src, target string) { i := strings.LastIndex(v, ":") if i < 0 { return v, "" } return v[:i], v[i+1:] }