internal/httpapi/notes.go exposes:
- GET /api/notes list summaries {id, title, updated_at}
- GET /api/notes/{id} full {id, title, content, updated_at}
- PUT /api/notes/{id} create/update; ?base=<unix> for
optimistic-locking conflict detection
- DELETE /api/notes/{id} remove; ?base=<unix> guards against
deleting a row modified after the
client last saw it
Backed by tenant.FS so all reads/writes go through the per-user
sandbox — path traversal is rejected at parse time (regex slug)
and again by os.Root inside the FS layer.
On-disk format is plain Markdown: first line `# Title`, rest is
content. grep / cat / vim still produce a usable view of raw
files. Title round-trips through composeNote/splitTitle.
Conflict semantics: when the client supplies ?base=<unix>, the
server compares against the file's mtime. If the file is newer,
respond 409 with the current note body so the client can present
a merge UI. Same logic on DELETE returns 409 alone.
cmd/librenotes/serve.go grows a tenantPool that memoises FS
handles per user id; defer-closes them on shutdown.
Tests cover: full CRUD round-trip, cross-tenant isolation,
unauthenticated 401s, invalid IDs (regex rejection), and the
conflict path with a real mtime advance.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
221 lines
5.7 KiB
Go
221 lines
5.7 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.librete.ch/public/librenotes/internal/auth"
|
|
"git.librete.ch/public/librenotes/internal/tenant"
|
|
)
|
|
|
|
func newNotesServer(t *testing.T) (*Server, string) {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
signer := auth.NewSigner([]byte("test-secret-32-bytes-of-keymaterial!!"))
|
|
var (
|
|
mu sync.Mutex
|
|
tenants = map[string]*tenant.FS{}
|
|
)
|
|
fsFor := func(uid string) (*tenant.FS, error) {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
if fs, ok := tenants[uid]; ok {
|
|
return fs, nil
|
|
}
|
|
fs, err := tenant.Open(filepath.Join(dir, uid))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
tenants[uid] = fs
|
|
t.Cleanup(func() { _ = fs.Close() })
|
|
return fs, nil
|
|
}
|
|
srv := &Server{
|
|
Auth: auth.Handlers{},
|
|
Signer: signer,
|
|
Logger: quietLogger(),
|
|
Notes: NotesHandler{FSFor: fsFor},
|
|
}
|
|
return srv, dir
|
|
}
|
|
|
|
func tokenFor(t *testing.T, signer *auth.Signer, uid string) string {
|
|
t.Helper()
|
|
tok, err := signer.Issue(uid, uid+"@x")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return tok
|
|
}
|
|
|
|
func do(t *testing.T, mux http.Handler, method, target, tok string, body any) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
var rdr *bytes.Reader
|
|
if body != nil {
|
|
buf, _ := json.Marshal(body)
|
|
rdr = bytes.NewReader(buf)
|
|
} else {
|
|
rdr = bytes.NewReader(nil)
|
|
}
|
|
req := httptest.NewRequest(method, target, rdr)
|
|
if tok != "" {
|
|
req.Header.Set("Authorization", "Bearer "+tok)
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
mux.ServeHTTP(rec, req)
|
|
return rec
|
|
}
|
|
|
|
func TestNotesCRUD(t *testing.T) {
|
|
srv, _ := newNotesServer(t)
|
|
mux := srv.Routes()
|
|
tok := tokenFor(t, srv.Signer, "user-a")
|
|
|
|
// PUT
|
|
rec := do(t, mux, http.MethodPut, "/api/notes/hello", tok,
|
|
map[string]string{"title": "Hello", "content": "world\n"})
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("put: %d body=%s", rec.Code, rec.Body)
|
|
}
|
|
|
|
// GET single
|
|
rec = do(t, mux, http.MethodGet, "/api/notes/hello", tok, nil)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("get: %d", rec.Code)
|
|
}
|
|
var n note
|
|
_ = json.NewDecoder(rec.Body).Decode(&n)
|
|
if n.Title != "Hello" || !strings.Contains(n.Content, "world") {
|
|
t.Errorf("got %+v", n)
|
|
}
|
|
|
|
// GET list
|
|
rec = do(t, mux, http.MethodGet, "/api/notes", tok, nil)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("list: %d", rec.Code)
|
|
}
|
|
var summaries []noteSummary
|
|
_ = json.NewDecoder(rec.Body).Decode(&summaries)
|
|
if len(summaries) != 1 || summaries[0].ID != "hello" {
|
|
t.Errorf("list: %+v", summaries)
|
|
}
|
|
|
|
// DELETE
|
|
rec = do(t, mux, http.MethodDelete, "/api/notes/hello", tok, nil)
|
|
if rec.Code != http.StatusNoContent {
|
|
t.Fatalf("delete: %d", rec.Code)
|
|
}
|
|
rec = do(t, mux, http.MethodGet, "/api/notes/hello", tok, nil)
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Errorf("post-delete get: %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestNotesTenantIsolation(t *testing.T) {
|
|
srv, _ := newNotesServer(t)
|
|
mux := srv.Routes()
|
|
tokA := tokenFor(t, srv.Signer, "user-a")
|
|
tokB := tokenFor(t, srv.Signer, "user-b")
|
|
|
|
do(t, mux, http.MethodPut, "/api/notes/private", tokA,
|
|
map[string]string{"title": "Private", "content": "secret"})
|
|
|
|
rec := do(t, mux, http.MethodGet, "/api/notes/private", tokB, nil)
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Errorf("user B should not see user A's note: %d", rec.Code)
|
|
}
|
|
|
|
rec = do(t, mux, http.MethodGet, "/api/notes", tokB, nil)
|
|
if !strings.Contains(rec.Body.String(), "[]") {
|
|
t.Errorf("user B should have empty list, got %s", rec.Body)
|
|
}
|
|
}
|
|
|
|
func TestNotesUnauthenticated(t *testing.T) {
|
|
srv, _ := newNotesServer(t)
|
|
mux := srv.Routes()
|
|
rec := do(t, mux, http.MethodGet, "/api/notes", "", nil)
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Errorf("got %d", rec.Code)
|
|
}
|
|
rec = do(t, mux, http.MethodPut, "/api/notes/x", "",
|
|
map[string]string{"title": "x", "content": "x"})
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Errorf("got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestNotesInvalidIDs(t *testing.T) {
|
|
srv, _ := newNotesServer(t)
|
|
mux := srv.Routes()
|
|
tok := tokenFor(t, srv.Signer, "u")
|
|
for _, bad := range []string{"WithUpper", "trailing.", "weird%21"} {
|
|
rec := do(t, mux, http.MethodGet, "/api/notes/"+bad, tok, nil)
|
|
if rec.Code != http.StatusBadRequest && rec.Code != http.StatusNotFound {
|
|
t.Errorf("id %q: got %d", bad, rec.Code)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestNotesConflict(t *testing.T) {
|
|
srv, _ := newNotesServer(t)
|
|
mux := srv.Routes()
|
|
tok := tokenFor(t, srv.Signer, "u")
|
|
|
|
rec := do(t, mux, http.MethodPut, "/api/notes/conf", tok,
|
|
map[string]string{"title": "v1", "content": "first"})
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatal(rec.Code)
|
|
}
|
|
var n note
|
|
_ = json.NewDecoder(rec.Body).Decode(&n)
|
|
base := n.UpdatedAt
|
|
|
|
// Wait so mtime advances on a 1-second filesystem.
|
|
time.Sleep(1100 * time.Millisecond)
|
|
|
|
// Server-side write (simulating another client) bumps the mtime.
|
|
rec = do(t, mux, http.MethodPut, "/api/notes/conf", tok,
|
|
map[string]string{"title": "v2", "content": "second"})
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatal(rec.Code)
|
|
}
|
|
|
|
// First client tries to write with the old base; should 409.
|
|
target := fmt.Sprintf("/api/notes/conf?base=%d", base)
|
|
rec = do(t, mux, http.MethodPut, target, tok,
|
|
map[string]string{"title": "v1-updated", "content": "third"})
|
|
if rec.Code != http.StatusConflict {
|
|
t.Errorf("expected 409, got %d", rec.Code)
|
|
}
|
|
var current note
|
|
_ = json.NewDecoder(rec.Body).Decode(¤t)
|
|
if current.Title != "v2" {
|
|
t.Errorf("conflict body should carry server state, got %+v", current)
|
|
}
|
|
}
|
|
|
|
func TestSafeNoteID(t *testing.T) {
|
|
cases := map[string]string{
|
|
"Hello World": "hello-world",
|
|
" spaces ": "spaces",
|
|
"WithUpper": "withupper",
|
|
"-leading": "leading",
|
|
"trailing-": "trailing",
|
|
"weird@chars!": "weirdchars",
|
|
}
|
|
for in, want := range cases {
|
|
if got := SafeNoteID(in); got != want {
|
|
t.Errorf("SafeNoteID(%q) = %q want %q", in, got, want)
|
|
}
|
|
}
|
|
}
|