Det er løftet, hele designet blev lagt til rette omkring: noten på disken ER den fil, man ville eksportere. Der konverteres ingenting på vejen ud, så der er ingenting, konverteringen kan tage fejl af — filerne åbner i Obsidian, i en teksteditor, i hvad som helst. To ting, en eksport kan tabe stille, og som prøven holder fast: Et skråstreg i en titel ville lave en mappe på Mac og Linux og blive afvist på Windows. De tegn, ingen af de tre vil have, bliver til bindestreger. Og to noter med samme titel er to noter — et arkiv med den samme post to gange er et arkiv, der har mistet den ene. Den anden får et nummer. Fejler skrivningen midtvejs, stopper den frem for at rydde op. Svaret er allerede på vej ud, så der er ingen statuskode tilbage at sende: et afkortet zip siger selv, at det er afkortet, hvilket er bedre end en komplet fil med en note færre. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
201 lines
6.1 KiB
Go
201 lines
6.1 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"archive/zip"
|
|
"bytes"
|
|
"io"
|
|
"net/http"
|
|
"testing"
|
|
)
|
|
|
|
// A note in a project is the project's; a note in no project is its author's.
|
|
// Both answers come from the project's own roles rather than from a rule notes
|
|
// invented for themselves, which is the point of filing them there.
|
|
func TestANoteIsNotReadableByJustAnybody(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
ts.bootstrap(t)
|
|
|
|
resp, body := ts.do(t, "POST", "/api/v1/notes", map[string]any{
|
|
"body": "Noget privat om #Firma",
|
|
})
|
|
if resp.StatusCode != http.StatusCreated {
|
|
t.Fatalf("create: %d %v", resp.StatusCode, body)
|
|
}
|
|
id, _ := body["id"].(string)
|
|
if id == "" {
|
|
t.Fatal("no id came back")
|
|
}
|
|
|
|
// The tags were read out of the text on the way in, without being asked for.
|
|
links, _ := body["links"].([]any)
|
|
if len(links) != 1 {
|
|
t.Fatalf("the note recorded %v", body["links"])
|
|
}
|
|
|
|
// Somebody else gets the same answer as for a note that does not exist, so an
|
|
// id cannot be probed for existence.
|
|
other := ts.newUser(t, "anden@example.dk", "Anden")
|
|
resp, _ = other.do(t, "GET", "/api/v1/notes/"+id, nil)
|
|
if resp.StatusCode != http.StatusNotFound {
|
|
t.Errorf("a stranger got %d for somebody else's note, want 404", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
// Searching must not become a way to read what you cannot open. The filter is
|
|
// applied after the index, and this is what says so.
|
|
func TestSearchDoesNotLeakNotesYouCannotRead(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
ts.bootstrap(t)
|
|
|
|
if resp, body := ts.do(t, "POST", "/api/v1/notes", map[string]any{
|
|
"body": "Hemmeligt kodeord til hjemmesiden",
|
|
}); resp.StatusCode != http.StatusCreated {
|
|
t.Fatalf("create: %d %v", resp.StatusCode, body)
|
|
}
|
|
|
|
_, mine := ts.do(t, "GET", "/api/v1/notes?q=hemmeligt", nil)
|
|
if got := len(mine["notes"].([]any)); got != 1 {
|
|
t.Fatalf("the author found %d of their own notes", got)
|
|
}
|
|
|
|
other := ts.newUser(t, "anden@example.dk", "Anden")
|
|
_, theirs := other.do(t, "GET", "/api/v1/notes?q=hemmeligt", nil)
|
|
if got := len(theirs["notes"].([]any)); got != 0 {
|
|
t.Errorf("a stranger's search returned %d notes", got)
|
|
}
|
|
}
|
|
|
|
// The backwards question, which is what makes notes worth linking at all.
|
|
func TestANoteIsFoundFromTheTaskItMentions(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
ts.bootstrap(t)
|
|
|
|
_, task := ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{
|
|
"text": "ring til Anders",
|
|
})
|
|
taskID, _ := task["id"].(string)
|
|
if taskID == "" {
|
|
t.Fatalf("no task id: %v", task)
|
|
}
|
|
|
|
if resp, _ := ts.do(t, "POST", "/api/v1/notes", map[string]any{
|
|
"body": "Aftalt at han ringer først, se /opgave/" + taskID,
|
|
}); resp.StatusCode != http.StatusCreated {
|
|
t.Fatal("the note was not written")
|
|
}
|
|
|
|
_, found := ts.do(t, "GET", "/api/v1/notes/linking/task/"+taskID, nil)
|
|
notes, _ := found["notes"].([]any)
|
|
if len(notes) != 1 {
|
|
t.Fatalf("%d notes point at the task, want 1", len(notes))
|
|
}
|
|
}
|
|
|
|
// Editing the text moves the links with it. A link that outlives its mention
|
|
// claims a connection that is not there.
|
|
func TestChangingANoteChangesWhatItPointsAt(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
ts.bootstrap(t)
|
|
|
|
_, note := ts.do(t, "POST", "/api/v1/notes", map[string]any{"body": "Om #Firma"})
|
|
id, _ := note["id"].(string)
|
|
|
|
_, before := ts.do(t, "GET", "/api/v1/notes/linking/project/Firma", nil)
|
|
if len(before["notes"].([]any)) != 1 {
|
|
t.Fatal("the first save recorded nothing")
|
|
}
|
|
|
|
if resp, _ := ts.do(t, "PATCH", "/api/v1/notes/"+id, map[string]any{
|
|
"body": "Om #Regnskab i stedet",
|
|
}); resp.StatusCode != http.StatusOK {
|
|
t.Fatal("the note was not changed")
|
|
}
|
|
|
|
_, after := ts.do(t, "GET", "/api/v1/notes/linking/project/Firma", nil)
|
|
if got := len(after["notes"].([]any)); got != 0 {
|
|
t.Errorf("%d notes still point at Firma", got)
|
|
}
|
|
}
|
|
|
|
// The export is the promise the whole design was arranged around: the note on
|
|
// disk is already the file you would export, so nothing is converted on the way
|
|
// out and nothing can be lost in the conversion.
|
|
func TestNotesExportAsMarkdownFiles(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
ts.bootstrap(t)
|
|
|
|
bodies := []string{
|
|
"# Møde med Anders\n\nHan vil gerne have **kaffe** hver uge.",
|
|
"Slash/i/titlen\n\nog noget tekst",
|
|
"# Møde med Anders\n\nen anden note med samme titel",
|
|
}
|
|
for _, body := range bodies {
|
|
if resp, _ := ts.do(t, "POST", "/api/v1/notes", map[string]any{"body": body}); resp.StatusCode != http.StatusCreated {
|
|
t.Fatalf("create: %d", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
req, _ := http.NewRequest("GET", ts.URL+"/api/v1/export/notes.zip", nil)
|
|
req.Header.Set("Sec-Fetch-Site", "same-origin")
|
|
for _, c := range ts.client.Jar.Cookies(mustParse(t, ts.URL)) {
|
|
req.AddCookie(c)
|
|
}
|
|
resp, err := ts.client.Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("export: status %d", resp.StatusCode)
|
|
}
|
|
if ct := resp.Header.Get("Content-Type"); ct != "application/zip" {
|
|
t.Errorf("content type is %q", ct)
|
|
}
|
|
|
|
raw, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
zr, err := zip.NewReader(bytes.NewReader(raw), int64(len(raw)))
|
|
if err != nil {
|
|
t.Fatalf("the archive does not open: %v", err)
|
|
}
|
|
if len(zr.File) != 3 {
|
|
t.Fatalf("the archive holds %d files, want 3", len(zr.File))
|
|
}
|
|
|
|
byName := map[string]string{}
|
|
for _, f := range zr.File {
|
|
r, err := f.Open()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
text, _ := io.ReadAll(r)
|
|
r.Close()
|
|
byName[f.Name] = string(text)
|
|
}
|
|
|
|
// Exactly what was stored, byte for byte. A converted export is an export that
|
|
// can be wrong.
|
|
if got := byName["Møde med Anders.md"]; got != bodies[0] {
|
|
t.Errorf("the note came out as %q", got)
|
|
}
|
|
// A slash in a title would make a directory, and on Windows it is refused
|
|
// outright.
|
|
if _, ok := byName["Slash-i-titlen.md"]; !ok {
|
|
t.Errorf("the awkward title became %v", keysOf(byName))
|
|
}
|
|
// Two notes with the same title are two notes; one entry twice is one lost.
|
|
if _, ok := byName["Møde med Anders (2).md"]; !ok {
|
|
t.Errorf("the second note of the same name is missing: %v", keysOf(byName))
|
|
}
|
|
}
|
|
|
|
func keysOf(m map[string]string) []string {
|
|
out := make([]string, 0, len(m))
|
|
for k := range m {
|
|
out = append(out, k)
|
|
}
|
|
return out
|
|
}
|