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

222 lines
7.6 KiB
Go

package main
import (
"context"
"encoding/json"
"os"
"strings"
"sync"
"testing"
"time"
)
// fakeBus is the work queue and the state: it records each ask and lets a test write the answer, as the converter
// process would under the ask's id.
type fakeBus struct {
mu sync.Mutex
asks []Ask
answers map[string]json.RawMessage
answerOn func(Ask) json.RawMessage // answers an ask as it is published
}
func (b *fakeBus) Publish(id string, body any) error {
b.mu.Lock()
defer b.mu.Unlock()
raw, _ := json.Marshal(body)
var a Ask
_ = json.Unmarshal(raw, &a)
b.asks = append(b.asks, a)
if b.answerOn != nil {
if ans := b.answerOn(a); ans != nil {
b.answers[id] = ans
}
}
return nil
}
func (b *fakeBus) Answer(id string) (json.RawMessage, error) {
b.mu.Lock()
defer b.mu.Unlock()
return b.answers[id], nil
}
func rig(wait time.Duration) (*Service, *fakeBus) {
b := &fakeBus{answers: map[string]json.RawMessage{}}
return &Service{Asks: b, Now: time.Now, Wait: wait, Poll: time.Millisecond}, b
}
const get, put = "http://novox.internal:20001/b/doc.docx?X-Amz-Signature=a", "http://novox.internal:20001/b/doc.pdf?X-Amz-Signature=b"
// 1595/AC1: one ask with those fields is published on convert-asks, and the verb answers what the converter wrote under
// the ask's id.
func TestConvertQueuesOneAskAndAnswersWhatTheConverterWrote(t *testing.T) {
s, b := rig(time.Second)
b.answerOn = func(a Ask) json.RawMessage { return json.RawMessage(`{"size": 4096, "sha256": "ab12"}`) }
got, err := s.Convert(context.Background(), get, "DOCX", put, "")
if err != nil {
t.Fatal(err)
}
if len(b.asks) != 1 {
t.Fatalf("%d asks queued", len(b.asks))
}
a := b.asks[0]
if a.Op != OpConvert || a.Source != get || a.Type != "docx" || a.Target != put || a.ID == "" {
t.Fatalf("ask %+v", a)
}
if got["size"] != float64(4096) || got["sha256"] != "ab12" || got["ask"] != a.ID {
t.Fatalf("answer %v", got)
}
}
// 1595/AC1: an error the converter wrote is the verb's error.
func TestConvertAnswersTheConvertersError(t *testing.T) {
s, b := rig(time.Second)
b.answerOn = func(a Ask) json.RawMessage {
return json.RawMessage(`{"error": "-4: the document server could not download the file"}`)
}
if _, err := s.Convert(context.Background(), get, "doc", put, ""); err == nil || !strings.Contains(err.Error(), "-4: the document server") {
t.Fatalf("error %v", err)
}
}
// 1595/AC2: a type outside the twelve is refused naming the types taken, and nothing is queued.
func TestConvertRefusesOtherTypes(t *testing.T) {
s, b := rig(time.Second)
for _, typ := range []string{"exe", "pdf", "", "docm", "jpg"} {
_, err := s.Convert(context.Background(), get, typ, put, "")
if err == nil || !strings.Contains(err.Error(), "docx") || !strings.Contains(err.Error(), "txt") {
t.Errorf("type %q: %v", typ, err)
}
}
if _, err := s.Convert(context.Background(), "file:///etc/passwd", "docx", put, ""); err == nil {
t.Error("a file link was taken")
}
if len(b.asks) != 0 {
t.Fatalf("%d asks queued", len(b.asks))
}
if len(Types) != 12 {
t.Fatalf("%d types", len(Types))
}
}
// 1595/AC3, AC6: with no answer within the call's bound the verb answers that it is converting, with the ask id,
// and does not fail; convert {ask} then returns the converter's answer once it is written, or converting again.
func TestAConversionNotDoneInTimeIsAnsweredAsConvertingAndRead(t *testing.T) {
s, b := rig(30 * time.Millisecond)
got, err := s.Convert(context.Background(), get, "docx", put, "")
if err != nil {
t.Fatalf("a slow converter failed the call: %v", err)
}
id := b.asks[0].ID
if got["state"] != "converting" || got["ask"] != id {
t.Fatalf("answer %v", got)
}
again, err := s.Read(context.Background(), id)
if err != nil || again["state"] != "converting" || again["ask"] != id {
t.Fatalf("read while converting: %v, %v", again, err)
}
b.mu.Lock()
b.answers[id] = json.RawMessage(`{"size": 77, "sha256": "cd34"}`)
b.mu.Unlock()
done, err := s.Read(context.Background(), id)
if err != nil || done["size"] != float64(77) || done["sha256"] != "cd34" || done["state"] != nil {
t.Fatalf("read after: %v, %v", done, err)
}
if len(b.asks) != 1 {
t.Errorf("%d asks queued, want the one", len(b.asks))
}
if ConvertWait != 25*time.Second {
t.Errorf("a call waits %s, want 25s", ConvertWait)
}
// Through the tool: ask alone reads; ask with anything else is refused; a made-up ask id is refused.
tl := tools(s)[0]
if r, err := tl.Run(map[string]any{"ask": id}); err != nil || r.(map[string]any)["size"] != float64(77) {
t.Errorf("tool read: %v, %v", r, err)
}
if _, err := tl.Run(map[string]any{"ask": id, "source": get}); err == nil {
t.Error("ask with a source was accepted")
}
if _, err := tl.Run(map[string]any{"ask": "../x"}); err == nil {
t.Error("a made-up ask id was accepted")
}
}
// 1595/AC4: the tool side opens no credential: its code never names the JWT or a secret file, and the manifest's
// tools bundle is given no env naming one.
func TestTheToolSideHoldsNoCredential(t *testing.T) {
// The tool side is these two files; the converter process (issue 1596) is code of its own, which alone reads the token.
for _, f := range []string{"main.go", "convert.go"} {
raw, err := os.ReadFile(f)
if err != nil {
t.Fatal(err)
}
for _, word := range []string{"jwt", "JWT", ".secret", "os.ReadFile", "os.Open"} {
if strings.Contains(string(raw), word) {
t.Errorf("%s mentions %q", f, word)
}
}
}
m := manifest(t)
for _, a := range m["build"].(map[string]any)["artifacts"].([]any) {
if env, ok := a.(map[string]any)["env"]; ok {
t.Errorf("an artifact is given env %v", env)
}
}
}
func manifest(t *testing.T) map[string]any {
t.Helper()
raw, err := os.ReadFile("../../module.json")
if err != nil {
t.Fatal(err)
}
var m map[string]any
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
return m
}
// 1595/AC5: only-office claims document-converter (scope mesh) serving convert, declares the stream convert-asks as a
// work queue and the state convert-answers (the shape of brother's device-asks and device-answers).
func TestManifestClaimsTheSeat(t *testing.T) {
m := manifest(t)
seats, _ := m["seats"].([]any)
if len(seats) != 1 || seats[0].(map[string]any)["name"] != "document-converter" || seats[0].(map[string]any)["scope"] != "mesh" ||
len(seats[0].(map[string]any)["serves"].([]any)) != 1 || seats[0].(map[string]any)["serves"].([]any)[0] != "convert" {
t.Fatalf("the seat document-converter is not declared in seats (the mesh defines no seat a module does not declare): %v", seats)
}
claims := m["claims"].([]any)
c := claims[0].(map[string]any)
if len(claims) != 1 || c["name"] != "document-converter" || c["scope"] != "mesh" ||
len(c["serves"].([]any)) != 1 || c["serves"].([]any)[0] != "convert" {
t.Fatalf("claims %v", claims)
}
streams := m["streams"].([]any)
s := streams[0].(map[string]any)
if len(streams) != 1 || s["name"] != "convert-asks" || s["retention"] != "work-queue" || s["max-deliveries"] != float64(3) ||
s["events"].([]any)[0] != EventAsked {
t.Fatalf("streams %v", streams)
}
state := m["state"].([]any)
st := state[0].(map[string]any)
if len(state) != 1 || st["name"] != AnswersState || st["ttl-seconds"] != float64(600) {
t.Fatalf("state %v", state)
}
found := false
for _, a := range m["build"].(map[string]any)["artifacts"].([]any) {
found = found || a.(map[string]any)["from"] == "cmd/only-office"
}
if !found {
t.Error("no Go bundle")
}
}
func TestTheVerbIsListedUnderTheSeat(t *testing.T) {
s, _ := rig(time.Second)
ts := tools(s)
if len(ts) != 1 || ts[0].Name != "document-converter.convert" {
t.Fatalf("tools %v", ts)
}
}