Files
datascape/canvas_test.go
T
2026-10-04 15:58:27 +02:00

255 lines
10 KiB
Go

package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
)
func writeTestFile(t *testing.T, path, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
t.Fatal(err)
}
}
func canvasRequest(h *handler, method, target, body string, header map[string]string) *httptest.ResponseRecorder {
r := httptest.NewRequest(method, target, strings.NewReader(body))
for k, v := range header {
r.Header.Set(k, v)
}
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
return w
}
func TestParseCanvas(t *testing.T) {
ok := []string{
``,
`{}`,
`{"nodes":[{"id":"a","type":"text","x":0,"y":0,"width":10,"height":10,"text":"hi","color":"3"},
{"id":"b","type":"group","x":0,"y":0,"width":10,"height":10,"color":"#ff8800"}],
"edges":[{"id":"e","fromNode":"a","toNode":"b","fromSide":"left","toEnd":"none"}]}`,
}
for _, src := range ok {
if _, err := parseCanvas([]byte(src)); err != nil {
t.Errorf("parseCanvas(%s): %v", src, err)
}
}
bad := map[string]string{
"not an object": `[]`,
"syntax": `{"nodes":[`,
"no id": `{"nodes":[{"type":"text","x":0,"y":0,"width":1,"height":1}]}`,
"duplicate id": `{"nodes":[{"id":"a","type":"text"},{"id":"a","type":"text"}]}`,
"unknown type": `{"nodes":[{"id":"a","type":"blob"}]}`,
"file w/o path": `{"nodes":[{"id":"a","type":"file"}]}`,
"link w/o url": `{"nodes":[{"id":"a","type":"link"}]}`,
"bad color": `{"nodes":[{"id":"a","type":"text","color":"red;x"}]}`,
"string x": `{"nodes":[{"id":"a","type":"text","x":"1"}]}`,
"dangling edge": `{"nodes":[{"id":"a","type":"text"}],"edges":[{"id":"e","fromNode":"a","toNode":"zz"}]}`,
"bad side": `{"nodes":[{"id":"a","type":"text"}],"edges":[{"id":"e","fromNode":"a","toNode":"a","toSide":"up"}]}`,
"bad end": `{"nodes":[{"id":"a","type":"text"}],"edges":[{"id":"e","fromNode":"a","toNode":"a","toEnd":"dot"}]}`,
}
for name, src := range bad {
if _, err := parseCanvas([]byte(src)); err == nil {
t.Errorf("%s: parseCanvas accepted %s", name, src)
}
}
}
func TestCanvasSave(t *testing.T) {
initMarkdown()
root := t.TempDir()
file := filepath.Join(root, "Page", "Board.canvas")
writeTestFile(t, file, string(emptyCanvas))
h := &handler{root: root}
save := func(body, version string) *httptest.ResponseRecorder {
return canvasRequest(h, "POST", "/Page/Board.canvas?save", body, map[string]string{"X-Canvas-Version": version})
}
// Fields the server does not know survive the round trip.
doc := `{"nodes":[{"id":"a","type":"text","x":1,"y":2,"width":3,"height":4,"text":"hi","custom":{"k":1}}],"edges":[],"extra":true}`
w := save(doc, canvasVersion(emptyCanvas))
if w.Code != http.StatusNoContent {
t.Fatalf("save: %d %s", w.Code, w.Body)
}
saved, _ := os.ReadFile(file)
var got map[string]any
if err := json.Unmarshal(saved, &got); err != nil {
t.Fatal(err)
}
if got["extra"] != true || !strings.Contains(string(saved), `"custom"`) {
t.Errorf("unknown fields lost:\n%s", saved)
}
if v := w.Header().Get("X-Canvas-Version"); v != canvasVersion(saved) {
t.Errorf("returned version %q does not match the file", v)
}
// A save based on an older version is refused and changes nothing.
if w := save(`{"nodes":[],"edges":[]}`, canvasVersion(emptyCanvas)); w.Code != http.StatusConflict {
t.Errorf("stale save: got %d, want 409", w.Code)
}
if now, _ := os.ReadFile(file); string(now) != string(saved) {
t.Error("stale save changed the file")
}
// Invalid documents are refused.
if w := save(`{"nodes":[{"id":"a","type":"nope"}]}`, canvasVersion(saved)); w.Code != http.StatusBadRequest {
t.Errorf("invalid save: got %d, want 400", w.Code)
}
if w := save(``, canvasVersion(saved)); w.Code != http.StatusBadRequest {
t.Errorf("empty save: got %d, want 400", w.Code)
}
// A canvas deleted while open is not recreated as a page.
os.Remove(file)
if w := save(doc, canvasVersion(saved)); w.Code != http.StatusNotFound {
t.Errorf("save to deleted canvas: got %d, want 404", w.Code)
}
if _, err := os.Stat(filepath.Join(root, "Page", "Board.canvas")); !os.IsNotExist(err) {
t.Error("save to deleted canvas created something")
}
}
func TestCanvasPage(t *testing.T) {
initMarkdown()
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "Page", "Board.canvas"),
`{"nodes":[{"id":"n1","type":"text","x":10,"y":20,"width":200,"height":100,"text":"# Hello\n\n- [ ] task","color":"2"}],"edges":[]}`)
writeTestFile(t, filepath.Join(root, "Page", "Broken.canvas"), `{"nodes":[`)
h := &handler{root: root}
w := canvasRequest(h, "GET", "/Page/Board.canvas", "", nil)
body := w.Body.String()
for _, want := range []string{
`class="canvas-app"`,
`data-id="n1"`,
`data-color="2"`,
`left: 10px; top: 20px; width: 200px; height: 100px;`,
`<h1 id="hello">Hello</h1>`,
`class="task-checkbox" data-task="0"`,
`id="canvas-data"`,
} {
if !strings.Contains(body, want) {
t.Errorf("canvas page lacks %q", want)
}
}
for _, unwanted := range []string{`tree-sidebar`, `hx-boost="true"`, `<footer`, `hx-post="?toggle`} {
if strings.Contains(body, unwanted) {
t.Errorf("canvas page contains %q", unwanted)
}
}
if w := canvasRequest(h, "GET", "/Page/Board.canvas?raw", "", nil); !strings.Contains(w.Body.String(), `"n1"`) ||
!strings.HasPrefix(w.Header().Get("Content-Type"), "application/json") {
t.Errorf("?raw: %s %s", w.Header().Get("Content-Type"), w.Body)
}
w = canvasRequest(h, "GET", "/Page/Broken.canvas", "", nil)
if body := w.Body.String(); !strings.Contains(body, "data-readonly") || !strings.Contains(body, "cannot be edited") {
t.Errorf("broken canvas is not read-only:\n%s", body)
}
}
func TestCanvasRenderNodes(t *testing.T) {
initMarkdown()
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "Other Page", "index.md"), "# Other\n\n![shot](<pic one.jpg>)\n\n## Part\n\nPart text\n\n## Rest\n\nRest text\n")
writeTestFile(t, filepath.Join(root, "Other Page", "notes.md"), "[rel](sub/x.md) [abs](/y/) [web](https://e.x)\n")
writeTestFile(t, filepath.Join(root, "Other Page", "pic one.jpg"), "not really")
writeTestFile(t, filepath.Join(root, "Other Page", "data.zip"), "zip")
writeTestFile(t, filepath.Join(root, ".secret", "x.md"), "secret")
writeTestFile(t, filepath.Join(root, "Board.canvas"), "")
h := &handler{root: root}
render := func(node string) string {
w := canvasRequest(h, "POST", "/Board.canvas?render", node, nil)
if w.Code != 200 {
t.Fatalf("render %s: %d %s", node, w.Code, w.Body)
}
return w.Body.String()
}
page := render(`{"id":"a","type":"file","file":"Other Page","x":0,"y":0,"width":1,"height":1}`)
if !strings.Contains(page, `href="/Other%20Page/"`) || !strings.Contains(page, ">Other<") ||
!strings.Contains(page, `src="/Other%20Page/pic%20one.jpg?w=300"`) {
t.Errorf("page node:\n%s", page)
}
part := render(`{"id":"a","type":"file","file":"/Other Page/index.md","subpath":"#Part","x":0,"y":0,"width":1,"height":1}`)
if !strings.Contains(part, "Part text") || strings.Contains(part, "Rest text") || strings.Contains(part, "shot") {
t.Errorf("subpath node:\n%s", part)
}
notes := render(`{"id":"a","type":"file","file":"Other Page/notes.md","x":0,"y":0,"width":1,"height":1}`)
for _, want := range []string{`href="/Other%20Page/sub/x.md"`, `href="/y/"`, `href="https://e.x"`} {
if !strings.Contains(notes, want) {
t.Errorf("markdown file node lacks %s:\n%s", want, notes)
}
}
if img := render(`{"id":"a","type":"file","file":"Other Page/pic one.jpg","x":0,"y":0,"width":1,"height":1}`); !strings.Contains(img, `<img src="/Other%20Page/pic%20one.jpg?w=800"`) {
t.Errorf("image node:\n%s", img)
}
if zip := render(`{"id":"a","type":"file","file":"Other Page/data.zip","x":0,"y":0,"width":1,"height":1}`); !strings.Contains(zip, `class="canvas-card" href="/Other%20Page/data.zip"`) {
t.Errorf("file card node:\n%s", zip)
}
for _, p := range []string{"nope.md", ".secret/x.md", "../../etc/passwd"} {
if out := render(`{"id":"a","type":"file","file":"` + p + `","x":0,"y":0,"width":1,"height":1}`); !strings.Contains(out, "canvas-missing") || strings.Contains(out, "secret") && p != ".secret/x.md" {
t.Errorf("file %q not reported missing:\n%s", p, out)
}
}
link := render(`{"id":"a","type":"link","url":"javascript:alert(1)","x":0,"y":0,"width":1,"height":1}`)
if strings.Contains(link, `href="javascript:`) {
t.Errorf("unsafe link rendered:\n%s", link)
}
named := render(`{"id":"a","type":"link","url":"https://jsoncanvas.org/spec/1.0/","label":"The spec","x":0,"y":0,"width":1,"height":1}`)
if !strings.Contains(named, `<span class="truncate">The spec</span><span class="muted truncate">jsoncanvas.org</span>`) || !strings.Contains(named, `title="https://jsoncanvas.org/spec/1.0/"`) {
t.Errorf("link with display text:\n%s", named)
}
group := render(`{"id":"a","type":"group","label":"<b>G</b>","color":"#abc","x":0,"y":0,"width":1,"height":1}`)
if !strings.Contains(group, "&lt;b&gt;G&lt;/b&gt;") || !strings.Contains(group, "--node-color: #abc") {
t.Errorf("group node:\n%s", group)
}
}
func TestCanvasCreate(t *testing.T) {
root := t.TempDir()
h := &handler{root: root}
create := func(name string) *httptest.ResponseRecorder {
form := url.Values{"name": {name}}.Encode()
return canvasRequest(h, "POST", "/New%20Page/?canvas", form, map[string]string{
"Content-Type": "application/x-www-form-urlencoded",
"HX-Request": "true",
})
}
w := create("My Board")
if w.Code != http.StatusNoContent || w.Header().Get("HX-Redirect") != "/New%20Page/My%20Board.canvas" {
t.Fatalf("create: %d %q %s", w.Code, w.Header().Get("HX-Redirect"), w.Body)
}
raw, err := os.ReadFile(filepath.Join(root, "New Page", "My Board.canvas"))
if err != nil || string(raw) != string(emptyCanvas) {
t.Fatalf("created file: %v %q", err, raw)
}
if w := create("My Board.canvas"); w.Code != http.StatusConflict {
t.Errorf("duplicate: got %d, want 409", w.Code)
}
for _, bad := range []string{"", " ", ".hidden", "a/b", `a\b`} {
if w := create(bad); w.Code != http.StatusBadRequest {
t.Errorf("name %q: got %d, want 400", bad, w.Code)
}
}
}
func TestCanvasListedWithIcon(t *testing.T) {
if fileIcon("Board.canvas") != iconCanvas {
t.Error("canvas files do not get the canvas icon")
}
}