255 lines
10 KiB
Go
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\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, "<b>G</b>") || !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")
|
|
}
|
|
}
|