Files
mesh-catalog/modules/only-office/manifest_test.go
T
jochen 29b4c02709
mesh/check checking 29b4c0270987 on cac09a4e203e
mesh/merge-gate head 29b4c0270987 merged into trunk cac09a4e203e — pass: builds only-office → novox; no bus step; every machine composes with the change as…
mesh/repo-check head 29b4c0270987 merged into trunk cac09a4e203e — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
only-office: keep its working directories under a root-only box (issue 1843)
The image opens everything below each mount to every account on every
start, so with the module root and lib at 0711 any account on the
machine could read the document cache. The mounts now sit under a
root-owned 0700 box the container never sees; the root stays 0711 for
the converter. The old lib keeps its id so mesh-host closes it to 0700
(a new id there would be used as found, issue 339).
2026-10-22 17:24:05 +02:00

297 lines
10 KiB
Go

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}/<name>, 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 <root>/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}/<name>", 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:]
}