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) } } }