274 lines
9.0 KiB
Go
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")
|
|
}
|
|
}
|