packed_test.go 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318
  1. package office
  2. import (
  3. "archive/zip"
  4. "bytes"
  5. "encoding/json"
  6. "errors"
  7. "io"
  8. "strings"
  9. "testing"
  10. "imuslab.com/arozos/mod/office/internal/media"
  11. "imuslab.com/arozos/mod/office/internal/officetest"
  12. )
  13. func TestPackUnpackRoundtrip(t *testing.T) {
  14. envelope := `{"type":"arozos/office","app":"presentation","body":{"slides":[` +
  15. `{"objects":[{"type":"image","props":{"src":"` + officetest.PngDataURL + `"}},` +
  16. `{"type":"image","props":{"src":"` + officetest.PngDataURL + `"}},` +
  17. `{"type":"image","props":{"src":"../media?file=user%3A%2FPhoto%2Fcat.png"}},` +
  18. `{"type":"text","props":{"html":"hello data: not a url"}}]}]}}`
  19. reads := []string{}
  20. readVpath := func(vp string) ([]byte, error) {
  21. reads = append(reads, vp)
  22. return []byte("fake-image-bytes"), nil
  23. }
  24. packed, err := PackEnvelope(envelope, readVpath)
  25. if err != nil {
  26. t.Fatalf("PackEnvelope: %v", err)
  27. }
  28. if packed[0] != 'P' || packed[1] != 'K' {
  29. t.Fatalf("packed output is not a zip")
  30. }
  31. if len(reads) != 1 || reads[0] != "user:/Photo/cat.png" {
  32. t.Errorf("media link vpath resolution = %v, want [user:/Photo/cat.png]", reads)
  33. }
  34. // the packed json must not contain base64 blobs or media links
  35. mid, err := UnpackEnvelope(packed)
  36. if err != nil {
  37. t.Fatalf("UnpackEnvelope: %v", err)
  38. }
  39. if strings.Contains(mid, "asset://") {
  40. t.Errorf("unpacked JSON still contains asset refs")
  41. }
  42. if !strings.Contains(mid, "data:image/png;base64,") {
  43. t.Errorf("image data URL not restored")
  44. }
  45. if !strings.Contains(mid, "hello data: not a url") {
  46. t.Errorf("plain text mangled: %.200s", mid)
  47. }
  48. // media link became an embedded asset (portable)
  49. if strings.Contains(mid, "media?file=") {
  50. t.Errorf("legacy media link not embedded")
  51. }
  52. }
  53. func TestPackDedupe(t *testing.T) {
  54. envelope := `{"a":"` + officetest.PngDataURL + `","b":"` + officetest.PngDataURL + `"}`
  55. packed, err := PackEnvelope(envelope, nil)
  56. if err != nil {
  57. t.Fatalf("PackEnvelope: %v", err)
  58. }
  59. // identical media stored once: zip smaller than 2x the image
  60. if n := strings.Count(string(packed), "assets/"); n != 2 { // local + central dir entry
  61. t.Errorf("expected exactly 1 asset (2 zip mentions), got %d mentions", n)
  62. }
  63. }
  64. func TestUnpackEnvelopeToLinks(t *testing.T) {
  65. envelope := `{"app":"presentation","body":{"src":"` + officetest.PngDataURL + `","txt":"plain"}}`
  66. packed, err := PackEnvelope(envelope, nil)
  67. if err != nil {
  68. t.Fatalf("PackEnvelope: %v", err)
  69. }
  70. saved := map[string][]byte{}
  71. out, err := UnpackEnvelopeToLinks(packed,
  72. func(name string, content []byte) error {
  73. saved[name] = content
  74. return nil
  75. },
  76. func(name string) string {
  77. return "../../media?file=user%3A%2F.appdata%2FOffice%2Fcache%2Fabc%2F" + name
  78. })
  79. if err != nil {
  80. t.Fatalf("UnpackEnvelopeToLinks: %v", err)
  81. }
  82. if len(saved) != 1 {
  83. t.Fatalf("expected 1 asset written, got %d", len(saved))
  84. }
  85. if strings.Contains(out, "data:image") || strings.Contains(out, "asset://") {
  86. t.Errorf("asset ref not rewritten to link: %.200s", out)
  87. }
  88. if !strings.Contains(out, "media?file=user%3A%2F.appdata") {
  89. t.Errorf("media link missing: %.200s", out)
  90. }
  91. if !strings.Contains(out, `"txt":"plain"`) {
  92. t.Errorf("plain values mangled: %.200s", out)
  93. }
  94. // legacy passthrough
  95. legacy := `{"a":1}`
  96. got, err := UnpackEnvelopeToLinks([]byte(legacy), nil, nil)
  97. if err != nil || got != legacy {
  98. t.Errorf("legacy passthrough broken: %q %v", got, err)
  99. }
  100. }
  101. func TestUnpackLegacyPassthrough(t *testing.T) {
  102. legacy := `{"type":"arozos/office","app":"document","body":{"html":"<p>old file</p>"}}`
  103. out, err := UnpackEnvelope([]byte(legacy))
  104. if err != nil {
  105. t.Fatalf("UnpackEnvelope legacy: %v", err)
  106. }
  107. if out != legacy {
  108. t.Errorf("legacy JSON must pass through unchanged")
  109. }
  110. }
  111. func TestPackErrors(t *testing.T) {
  112. if _, err := PackEnvelope("{not json", nil); err == nil {
  113. t.Errorf("invalid json: expected error")
  114. }
  115. if _, err := UnpackEnvelope([]byte("PK\x03\x04 garbage")); err == nil {
  116. t.Errorf("corrupt zip: expected error")
  117. }
  118. }
  119. func TestMediaLinkVpath(t *testing.T) {
  120. tests := []struct{ in, want string }{
  121. {"../media?file=user:/x.png", "user:/x.png"},
  122. {"../../media?file=user%3A%2Fa%20b.jpg", "user:/a b.jpg"},
  123. {"media/download/?file=user:/v.mp4", "user:/v.mp4"},
  124. {"https://example.com/x.png", ""},
  125. {"plain text", ""},
  126. }
  127. for _, tc := range tests {
  128. if got := media.LinkVpath(tc.in); got != tc.want {
  129. t.Errorf("mediaLinkVpath(%q) = %q, want %q", tc.in, got, tc.want)
  130. }
  131. }
  132. }
  133. // A Docs body keeps its pictures inside one HTML string. Links in src and
  134. // poster attributes are embedded; hyperlinks and unreadable files are not.
  135. func TestPackEmbedsHTMLMediaLinks(t *testing.T) {
  136. body := `<h1>Hi</h1>` +
  137. `<p><img src="../../media?file=user%3A%2FPhoto%2Fcat.png" alt="cat"></p>` +
  138. `<p><img class="x" src='../../media?file=user:/Photo/cat.png&amp;nocache=1'></p>` +
  139. `<video poster="../../media?file=user%3A%2FPhoto%2Fdog.JPG"></video>` +
  140. `<a href="../../media?file=user%3A%2FDocs%2Fbig.zip">link</a>` +
  141. `<img src="../../media?file=user%3A%2Fsecret.png">` +
  142. `<img src="https://example.com/remote.png">` +
  143. `<p>ask about media?file=user:/x.png in text</p>`
  144. envelope, _ := json.Marshal(map[string]interface{}{
  145. "type": "arozos/office", "app": "document",
  146. "body": map[string]interface{}{"html": body},
  147. })
  148. reads := map[string]int{}
  149. readVpath := func(vp string) ([]byte, error) {
  150. reads[vp]++
  151. if vp == "user:/secret.png" {
  152. return nil, errors.New("read access denied")
  153. }
  154. return []byte("bytes of " + vp), nil
  155. }
  156. packed, err := PackEnvelope(string(envelope), readVpath)
  157. if err != nil {
  158. t.Fatalf("PackEnvelope: %v", err)
  159. }
  160. tests := []struct {
  161. name string
  162. vpath string
  163. reads int
  164. }{
  165. {"same file read once for two links", "user:/Photo/cat.png", 1},
  166. {"poster embedded", "user:/Photo/dog.JPG", 1},
  167. {"href never read", "user:/Docs/big.zip", 0},
  168. {"unreadable file tried", "user:/secret.png", 1},
  169. }
  170. for _, tc := range tests {
  171. if got := reads[tc.vpath]; got != tc.reads {
  172. t.Errorf("%s: %s read %d times, want %d", tc.name, tc.vpath, got, tc.reads)
  173. }
  174. }
  175. // the stored document.json carries asset refs in the attributes
  176. doc := packedDocument(t, packed)
  177. if n := strings.Count(doc, "asset://"); n != 3 {
  178. t.Errorf("document.json has %d asset refs, want 3 (two cats + poster): %s", n, doc)
  179. }
  180. for _, keep := range []string{
  181. `href=\"../../media?file=user%3A%2FDocs%2Fbig.zip\"`,
  182. `src=\"../../media?file=user%3A%2Fsecret.png\"`,
  183. `src=\"https://example.com/remote.png\"`,
  184. `ask about media?file=user:/x.png in text`,
  185. `alt=\"cat\"`,
  186. } {
  187. if !strings.Contains(doc, keep) {
  188. t.Errorf("document.json lost %s: %s", keep, doc)
  189. }
  190. }
  191. // unpacking inlines them again, in place
  192. out, err := UnpackEnvelope(packed)
  193. if err != nil {
  194. t.Fatalf("UnpackEnvelope: %v", err)
  195. }
  196. if strings.Contains(out, "asset://") {
  197. t.Errorf("UnpackEnvelope left asset refs: %s", out)
  198. }
  199. if n := strings.Count(out, "data:image/png;base64,"); n != 2 {
  200. t.Errorf("UnpackEnvelope restored %d png data URLs, want 2", n)
  201. }
  202. if !strings.Contains(out, `poster=\"data:image/jpeg;base64,`) {
  203. t.Errorf("poster not restored as a jpeg data URL: %s", out)
  204. }
  205. // and the ArozOS load path turns them into cache links
  206. links, err := UnpackEnvelopeToLinks(packed,
  207. func(string, []byte) error { return nil },
  208. func(name string) string { return "../../media?file=cache%2F" + name })
  209. if err != nil {
  210. t.Fatalf("UnpackEnvelopeToLinks: %v", err)
  211. }
  212. if strings.Contains(links, "asset://") || strings.Count(links, "media?file=cache%2F") != 3 {
  213. t.Errorf("UnpackEnvelopeToLinks did not relink the attributes: %s", links)
  214. }
  215. }
  216. func TestResolveAssetRefs(t *testing.T) {
  217. known := map[string]string{"abc.png": "A", "x y.png": "SPACED"}
  218. resolve := func(name string) (string, bool) { v, ok := known[name]; return v, ok }
  219. tests := []struct{ in, want string }{
  220. {"asset://abc.png", "A"},
  221. {"asset://x y.png", "SPACED"}, // whole-string refs keep accepting any name
  222. {"asset://missing.png", "asset://missing.png"},
  223. {`<img src="asset://abc.png"><img src='asset://abc.png'>`, `<img src="A"><img src='A'>`},
  224. {`<img src="asset://missing.png">`, `<img src="asset://missing.png">`},
  225. {"no refs here", "no refs here"},
  226. }
  227. for _, tc := range tests {
  228. if got := resolveAssetRefs(tc.in, resolve); got != tc.want {
  229. t.Errorf("resolveAssetRefs(%q) = %q, want %q", tc.in, got, tc.want)
  230. }
  231. }
  232. }
  233. func TestAssetExt(t *testing.T) {
  234. tests := []struct{ in, want string }{
  235. {"user:/a/cat.PNG", "png"},
  236. {"user:/a/clip.mp4", "mp4"},
  237. {"user:/a/noext", "bin"},
  238. {"user:/a/odd.j pg", "bin"},
  239. {"user:/a/x.verylongextension", "bin"},
  240. }
  241. for _, tc := range tests {
  242. if got := assetExt(tc.in); got != tc.want {
  243. t.Errorf("assetExt(%q) = %q, want %q", tc.in, got, tc.want)
  244. }
  245. }
  246. }
  247. // packedDocument reads document.json out of a container, as stored
  248. func packedDocument(t *testing.T, packed []byte) string {
  249. t.Helper()
  250. zr, err := zip.NewReader(bytes.NewReader(packed), int64(len(packed)))
  251. if err != nil {
  252. t.Fatalf("container is not a zip: %v", err)
  253. }
  254. for _, f := range zr.File {
  255. if f.Name != packedDocName {
  256. continue
  257. }
  258. rc, err := f.Open()
  259. if err != nil {
  260. t.Fatalf("open %s: %v", f.Name, err)
  261. }
  262. defer rc.Close()
  263. b, err := io.ReadAll(rc)
  264. if err != nil {
  265. t.Fatalf("read %s: %v", f.Name, err)
  266. }
  267. return string(b)
  268. }
  269. t.Fatalf("container has no %s", packedDocName)
  270. return ""
  271. }
  272. func TestInlineMediaLinks(t *testing.T) {
  273. png := officetest.PngBytes(t)
  274. read := func(vp string) ([]byte, error) {
  275. switch vp {
  276. case "user:/a.png":
  277. return png, nil
  278. case "user:/b.txt":
  279. return []byte("not a picture"), nil
  280. }
  281. return nil, errors.New("missing")
  282. }
  283. body := `{"src":"../../media?file=user%3A%2Fa.png","html":"<img src=\"../media?file=user%3A%2Fa.png\"><img src=\"../media?file=user%3A%2Fb.txt\">","other":"media is a word"}`
  284. out, err := InlineMediaLinks(body, read)
  285. if err != nil {
  286. t.Fatal(err)
  287. }
  288. if strings.Count(out, "data:image/png;base64,") != 2 {
  289. t.Errorf("pictures not inlined: %s", out)
  290. }
  291. if !strings.Contains(out, "user%3A%2Fb.txt") || !strings.Contains(out, "media is a word") {
  292. t.Errorf("non-pictures must stay: %s", out)
  293. }
  294. if same, _ := InlineMediaLinks(body, nil); same != body {
  295. t.Errorf("no reader must mean no change")
  296. }
  297. }