Files
mesh-catalog/modules/only-office/cmd/only-office/converter_test.go
T

274 lines
9.0 KiB
Go

package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"strings"
"sync"
"testing"
"time"
)
const jwtSecret = "module-jwt-secret"
// fakeDocs stands for the document server's conversion service (the request and answer shapes are the conversion API's:
// https://api.onlyoffice.com/docs/docs-api/additional-api/conversion-api/request/ and .../error-codes/, read
// 2026-10-21) and the file it serves back.
type fakeDocs struct {
mu sync.Mutex
requests []map[string]any
accept string
answer string // the JSON the service answers; {fileUrl} is replaced by the file's address
pdf []byte
srv *httptest.Server
}
func newFakeDocs(t *testing.T) *fakeDocs {
d := &fakeDocs{pdf: []byte("%PDF-1.7 converted"), answer: `{"endConvert": true, "fileUrl": "{fileUrl}", "percent": 100}`}
d.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
d.mu.Lock()
defer d.mu.Unlock()
switch r.URL.Path {
case ConvertPath:
var m map[string]any
json.NewDecoder(r.Body).Decode(&m)
d.requests = append(d.requests, m)
d.accept = r.Header.Get("Accept")
io.WriteString(w, strings.ReplaceAll(d.answer, "{fileUrl}", "http://"+r.Host+"/cache/out.pdf"))
case "/cache/out.pdf":
w.Write(d.pdf)
default:
w.WriteHeader(404)
}
}))
t.Cleanup(d.srv.Close)
return d
}
type fakeTarget struct {
mu sync.Mutex
got []byte
puts int
status int
srv *httptest.Server
}
func newFakeTarget(t *testing.T) *fakeTarget {
f := &fakeTarget{status: 200}
f.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
if f.status != 200 {
w.WriteHeader(f.status)
io.WriteString(w, `<Error><Code>AccessDenied</Code><Message>Access Denied.</Message></Error>`)
return
}
f.got, _ = io.ReadAll(r.Body)
f.puts++
}))
t.Cleanup(f.srv.Close)
return f
}
type answers struct {
mu sync.Mutex
m map[string]map[string]any
}
func (a *answers) Create(key string, value any) (uint64, error) {
a.mu.Lock()
defer a.mu.Unlock()
raw, _ := json.Marshal(value)
var v map[string]any
json.Unmarshal(raw, &v)
a.m[key] = v
return 1, nil
}
func worker(d *fakeDocs) (*converterWorker, *answers) {
a := &answers{m: map[string]map[string]any{}}
return &converterWorker{JWT: func() (string, error) { return jwtSecret, nil }, Server: d.srv.URL,
Docs: http.DefaultClient, Store: http.DefaultClient, Answers: a, Now: time.Now,
NewKey: func() string { return "fresh-key" }}, a
}
func askBody(t *testing.T, target string, sha string) []byte {
raw, err := json.Marshal(Ask{ID: "ask1", Op: OpConvert, At: time.Now(), Deadline: time.Now().Add(time.Minute),
Source: "http://novox.internal:20001/b/in.docx?X-Amz-Signature=a", Type: "docx", Target: target, SHA256: sha})
if err != nil {
t.Fatal(err)
}
return raw
}
// verifies a JWT the way a document server does: three parts, HS256 over the first two with the secret.
func verifiesJWT(t *testing.T, token string) map[string]any {
t.Helper()
parts := strings.Split(token, ".")
if len(parts) != 3 {
t.Fatalf("token %q", token)
}
mac := hmac.New(sha256.New, []byte(jwtSecret))
mac.Write([]byte(parts[0] + "." + parts[1]))
if base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) != parts[2] {
t.Fatal("the token does not verify with the module's secret")
}
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
t.Fatal(err)
}
var m map[string]any
json.Unmarshal(payload, &m)
return m
}
// 1596/AC1: the server receives one conversion request with the source link, the type, outputtype pdf and a JWT that
// verifies with the module's secret, and the PDF it returns is PUT to target.
func TestTheConverterPostsTheRequestAndPutsThePDF(t *testing.T) {
d, tgt := newFakeDocs(t), newFakeTarget(t)
w, _ := worker(d)
act, _ := w.Handle(askBody(t, tgt.srv.URL+"/b/in.pdf?sig=1", strings.Repeat("ab", 32)))
if act != actionAck {
t.Fatalf("action %v", act)
}
if len(d.requests) != 1 {
t.Fatalf("%d conversion requests", len(d.requests))
}
r := d.requests[0]
if r["url"] != "http://novox.internal:20001/b/in.docx?X-Amz-Signature=a" || r["filetype"] != "docx" || r["outputtype"] != "pdf" ||
r["async"] != false || r["key"] != strings.Repeat("ab", 32) || d.accept != "application/json" {
t.Fatalf("request %v accept %q", r, d.accept)
}
claims := verifiesJWT(t, r["token"].(string))
for _, k := range []string{"url", "filetype", "outputtype", "key"} {
if claims[k] != r[k] {
t.Errorf("the token's %s is %v, the body's %v", k, claims[k], r[k])
}
}
if string(tgt.got) != string(d.pdf) || tgt.puts != 1 {
t.Fatalf("the target got %q in %d puts", tgt.got, tgt.puts)
}
}
// 1596/AC1: without a known SHA-256 the key is a fresh one.
func TestTheKeyIsFreshWhenTheSourceHashIsNotKnown(t *testing.T) {
d, tgt := newFakeDocs(t), newFakeTarget(t)
w, _ := worker(d)
w.Handle(askBody(t, tgt.srv.URL+"/x", ""))
if d.requests[0]["key"] != "fresh-key" {
t.Fatalf("key %v", d.requests[0]["key"])
}
}
// 1596/AC2: the answer under the ask id carries the PUT body's size and SHA-256.
func TestTheAnswerCarriesTheSizeAndHashOfWhatWasPut(t *testing.T) {
d, tgt := newFakeDocs(t), newFakeTarget(t)
w, a := worker(d)
w.Handle(askBody(t, tgt.srv.URL+"/x", ""))
sum := sha256.Sum256(d.pdf)
got := a.m["ask1"]
if got["size"] != float64(len(d.pdf)) || got["sha256"] != hex.EncodeToString(sum[:]) || got["error"] != nil {
t.Fatalf("answer %v", got)
}
}
// 1596/AC3: a server answer with error -4 is answered with the code and its meaning, and nothing is PUT.
func TestAServerErrorIsAnsweredWithItsMeaningAndNothingIsPut(t *testing.T) {
d, tgt := newFakeDocs(t), newFakeTarget(t)
d.answer = `{"error": -4}`
w, a := worker(d)
w.Handle(askBody(t, tgt.srv.URL+"/x", ""))
msg, _ := a.m["ask1"]["error"].(string)
if !strings.Contains(msg, "-4: error while downloading the document file to be converted") {
t.Fatalf("answer %v", a.m["ask1"])
}
if tgt.puts != 0 {
t.Fatal("something was put")
}
if got := meaningOf(-8); got != "-8: invalid token" {
t.Fatalf("-8 reads %q", got)
}
}
// 1596/AC4: a failed PUT is answered as a failure naming the store's refusal; the ask is not answered as done.
func TestAFailedPutIsAFailureNamingTheStoresRefusal(t *testing.T) {
d, tgt := newFakeDocs(t), newFakeTarget(t)
tgt.status = 403
w, a := worker(d)
w.Handle(askBody(t, tgt.srv.URL+"/x", ""))
got := a.m["ask1"]
msg, _ := got["error"].(string)
if !strings.Contains(msg, "403") || !strings.Contains(msg, "AccessDenied") || got["size"] != nil || got["sha256"] != nil {
t.Fatalf("answer %v", got)
}
}
// An ask whose deadline passed is not converted, and an unreadable ask is ended.
func TestALateAskIsNotConverted(t *testing.T) {
d, tgt := newFakeDocs(t), newFakeTarget(t)
w, a := worker(d)
w.Now = func() time.Time { return time.Now().Add(2 * time.Minute) }
if act, _ := w.Handle(askBody(t, tgt.srv.URL+"/x", "")); act != actionTerm {
t.Fatalf("action %v", act)
}
if len(d.requests) != 0 || a.m["ask1"]["error"] == nil {
t.Fatalf("requests %d, answer %v", len(d.requests), a.m["ask1"])
}
if act, _ := w.Handle([]byte("nonsense")); act != actionTerm {
t.Fatal("an unreadable ask was kept")
}
}
// 1596/AC5: the process converter has no serves, is the one consumer of convert-asks and writes convert-answers, and
// its account owns the JWT file.
func TestManifestDeclaresTheConverterProcess(t *testing.T) {
m := manifest(t)
var proc, account map[string]any
for _, r := range m["resources"].([]any) {
res := r.(map[string]any)
if res["type"] == "process" && res["name"] == "only-office-converter" {
proc = res
}
if res["type"] == "user" && res["name"] == "only-office-converter" {
account = res
}
}
if proc == nil || account == nil {
t.Fatalf("process %v account %v", proc, account)
}
if _, serves := proc["serves"]; serves {
t.Error("the converter serves")
}
bus := proc["bus"].(map[string]any)
if q := bus["queues"].([]any); len(q) != 1 || q[0] != "convert-asks" {
t.Errorf("queues %v", q)
}
if s := bus["state"].([]any); len(s) != 1 || s[0] != AnswersState {
t.Errorf("state %v", s)
}
if proc["user"] != "only-office-converter" || account["shell"] != "/usr/bin/nologin" {
t.Errorf("user %v shell %v", proc["user"], account["shell"])
}
jwt := m["own-secrets"].(map[string]any)["jwt"].(map[string]any)
if jwt["owner"] != "only-office-converter" || jwt["path"] != "${dir:state}/jwt.secret" {
t.Errorf("jwt %v", jwt)
}
broker := m["own-secrets"].(map[string]any)["broker-converter"].(map[string]any)
env := proc["env"].(map[string]any)
if broker["owner"] != "only-office-converter" || env["ONLY_OFFICE_BROKER"] != broker["path"] || env["ONLY_OFFICE_JWT"] != jwt["path"] {
t.Errorf("broker %v env %v", broker, env)
}
// The container still gets the token: server.env names it.
raw, _ := os.ReadFile("../../files/server.env")
if !strings.Contains(string(raw), "JWT_SECRET=${secret:jwt}") {
t.Error("the document server is no longer given the token")
}
}