mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
* fix(history): page the API so old changes stop being unreachable (BEA-46) GET /api/p/<id>/history capped at ?n= and said nothing about what it was hiding, so every change older than the cap was unreachable — a project's early history sat in the journals and blob store with no way to display it. The display order used to come from two mechanisms: a stable time sort over a slice built in reverse-journal order, so the tie-break was implicit in the construction and a cursor could not re-derive it. histLess makes it one function — newest wall-clock first, ties in reverse journal.Less — used for both the sort and the skip-past-cursor step, so paging cannot disagree with the feed. The cursor is server-minted and opaque because it has to be: HistoryEntry.time is formatted to whole seconds and carries no lamport/seq, so a client-computed cursor would be lossy across same-second ops. ?n= alone returns exactly the entries it always did (the tie-break IS reverse-Less); it just gains a next_cursor key when more exist. A cursor is a position in an ordering, not a snapshot: an offline device pushing mid-scroll lands ops mid-feed by timestamp and the reader sees them on refresh — pinning would mean server state for the life of a scroll. BenchmarkHistoryPage over 5000 ops: page 1 14.4ms, page 20 15.8ms — every page re-lists and re-parses the journals, so the ceiling is gone but the per-page work is not. No cache needed at this scale. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(history): follow the cursor in the History view, with a Load more (BEA-46) The view hard-coded n=200 and rendered whatever came back, so a project past 200 changes showed a list that simply stopped. useInfiniteQuery now follows next_cursor at 100 a page, and the foot of the list says "Load more" while older changes exist — a button, not an IntersectionObserver, so it is keyboard-reachable and states out loud that there is more. Pages accumulate into one array, which is what makes the rest free: groupRuns already groups across the whole window (a run straddling a page boundary becomes one card when its second page lands — verified live: 7 files on page 1, 12 after Load more) and prevBlob already returns undefined past the end, so the oldest loaded row shows no diff base rather than diffing against the wrong predecessor. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
323 lines
10 KiB
Go
323 lines
10 KiB
Go
package webapp
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/runbear-io/beardrive/internal/remote"
|
|
)
|
|
|
|
// newHub builds a hub server over a fresh storage root with one project.
|
|
func newHub(t testing.TB, upload bool, wrap func(remote.Backend) remote.Backend) (*Server, Project, string) {
|
|
t.Helper()
|
|
root := t.TempDir()
|
|
be, err := remote.Open(context.Background(), "file://"+root)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { be.Close() })
|
|
if wrap != nil {
|
|
be = wrap(be)
|
|
}
|
|
db, err := OpenProjectDB(filepath.Join(t.TempDir(), "projects.json"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
p, _, err := db.GetOrCreate("proj", "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
srv := &Server{
|
|
Root: be, Projects: db, Device: webDevice,
|
|
Refresh: 0, Upload: UploadConfig{Enabled: upload},
|
|
}
|
|
return srv, p, root
|
|
}
|
|
|
|
func TestProjectAPI(t *testing.T) {
|
|
srv, seeded, _ := newHub(t, true, nil)
|
|
h := srv.Handler()
|
|
|
|
// create-or-join by name: new name creates, same name joins
|
|
rec := do(t, h, "POST", "/api/projects", map[string]string{"name": "my-app"})
|
|
if rec.Code != 200 {
|
|
t.Fatalf("create: %d %s", rec.Code, rec.Body)
|
|
}
|
|
var out struct {
|
|
Project Project `json:"project"`
|
|
Created bool `json:"created"`
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// New ids are UUIDs (and must satisfy the route validator, which also
|
|
// still accepts the legacy p-xxxxxxxx ids old hubs minted).
|
|
if !out.Created || out.Project.Name != "my-app" ||
|
|
uuid.Validate(out.Project.ID) != nil || !projectIDRe.MatchString(out.Project.ID) {
|
|
t.Fatalf("create = %+v", out)
|
|
}
|
|
id := out.Project.ID
|
|
|
|
rec = do(t, h, "POST", "/api/projects", map[string]string{"name": "my-app"})
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if out.Created || out.Project.ID != id {
|
|
t.Fatalf("re-create = %+v, want join of %s", out, id)
|
|
}
|
|
|
|
// list has both projects; get by id resolves
|
|
rec = do(t, h, "GET", "/api/projects", nil)
|
|
var list struct {
|
|
Projects []Project `json:"projects"`
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &list); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(list.Projects) != 2 {
|
|
t.Fatalf("projects = %+v, want 2", list.Projects)
|
|
}
|
|
if rec := do(t, h, "GET", "/api/projects/"+seeded.ID, nil); rec.Code != 200 {
|
|
t.Fatalf("get project: %d", rec.Code)
|
|
}
|
|
if rec := do(t, h, "GET", "/api/projects/p-00000000", nil); rec.Code != 404 {
|
|
t.Fatalf("get missing project: %d, want 404", rec.Code)
|
|
}
|
|
|
|
// hub config declares hub mode and never names storage
|
|
rec = do(t, h, "GET", "/api/config", nil)
|
|
if !strings.Contains(rec.Body.String(), `"hub"`) || strings.Contains(rec.Body.String(), "file://") {
|
|
t.Fatalf("config = %s", rec.Body)
|
|
}
|
|
|
|
// empty name is rejected
|
|
if rec := do(t, h, "POST", "/api/projects", map[string]string{"name": " "}); rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("empty name: %d, want 400", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestProjectCreateGatedByUpload(t *testing.T) {
|
|
srv, _, _ := newHub(t, false, nil) // read-only hub
|
|
h := srv.Handler()
|
|
if rec := do(t, h, "POST", "/api/projects", map[string]string{"name": "x"}); rec.Code != http.StatusForbidden {
|
|
t.Fatalf("create on read-only hub: %d, want 403", rec.Code)
|
|
}
|
|
// reads still work
|
|
if rec := do(t, h, "GET", "/api/projects", nil); rec.Code != 200 {
|
|
t.Fatalf("list on read-only hub: %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestProjectDBPersists(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "projects.json")
|
|
db, err := OpenProjectDB(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
p1, created, err := db.GetOrCreate("alpha", "")
|
|
if err != nil || !created {
|
|
t.Fatalf("create: %+v %v %v", p1, created, err)
|
|
}
|
|
// a fresh open (server restart) sees the same project
|
|
db2, err := OpenProjectDB(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got, ok := db2.Get(p1.ID)
|
|
if !ok || got.Name != "alpha" {
|
|
t.Fatalf("reload = %+v %v", got, ok)
|
|
}
|
|
p2, created, err := db2.GetOrCreate("alpha", "")
|
|
if err != nil || created || p2.ID != p1.ID {
|
|
t.Fatalf("get-or-create after reload = %+v %v %v", p2, created, err)
|
|
}
|
|
}
|
|
|
|
func TestStoreAPIReads(t *testing.T) {
|
|
srv, p, root := newHub(t, false, nil) // reads work even on read-only hubs
|
|
f := newFakeRemoteAt(t, filepath.Join(root, p.ID))
|
|
f.put("deva", "readme.md", "hello")
|
|
h := srv.Handler()
|
|
base := "/api/p/" + p.ID + "/store/"
|
|
|
|
rec := do(t, h, "GET", base+"list?prefix=journal/", nil)
|
|
if rec.Code != 200 {
|
|
t.Fatalf("list: %d %s", rec.Code, rec.Body)
|
|
}
|
|
var list struct {
|
|
Objects []remote.Object `json:"objects"`
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &list); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(list.Objects) != 1 || list.Objects[0].Key != "journal/deva.jsonl" {
|
|
t.Fatalf("objects = %+v", list.Objects)
|
|
}
|
|
|
|
rec = do(t, h, "GET", base+"object?key=journal/deva.jsonl", nil)
|
|
if rec.Code != 200 || !strings.Contains(rec.Body.String(), `"readme.md"`) {
|
|
t.Fatalf("get journal: %d %s", rec.Code, rec.Body)
|
|
}
|
|
blobKey := "blobs/" + shaOf("hello")
|
|
rec = do(t, h, "GET", base+"exists?key="+blobKey, nil)
|
|
if rec.Code != 200 || !strings.Contains(rec.Body.String(), "true") {
|
|
t.Fatalf("exists: %d %s", rec.Code, rec.Body)
|
|
}
|
|
rec = do(t, h, "GET", base+"object?key="+blobKey, nil)
|
|
if rec.Code != 200 || rec.Body.String() != "hello" {
|
|
t.Fatalf("get blob: %d %q", rec.Code, rec.Body)
|
|
}
|
|
|
|
// the viewer works per project too
|
|
rec = do(t, h, "GET", "/api/p/"+p.ID+"/file?path=readme.md", nil)
|
|
if rec.Code != 200 || rec.Body.String() != "hello" {
|
|
t.Fatalf("viewer file: %d %q", rec.Code, rec.Body)
|
|
}
|
|
// unknown project 404s
|
|
if rec := do(t, h, "GET", "/api/p/p-00000000/store/list?prefix=journal/", nil); rec.Code != 404 {
|
|
t.Fatalf("unknown project: %d, want 404", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestStoreAPIKeyValidation(t *testing.T) {
|
|
srv, p, _ := newHub(t, true, nil)
|
|
h := srv.Handler()
|
|
base := "/api/p/" + p.ID + "/store/"
|
|
bad := []string{
|
|
"", "x", "blobs/short", "blobs/../../etc/passwd",
|
|
"journal/../device.json", "journal/a/b.jsonl", "journal/dev.txt",
|
|
"blobs/" + strings.Repeat("G", 64), // non-hex
|
|
}
|
|
for _, key := range bad {
|
|
if rec := do(t, h, "GET", base+"object?key="+key, nil); rec.Code != http.StatusBadRequest {
|
|
t.Errorf("get %q: %d, want 400", key, rec.Code)
|
|
}
|
|
if rec := do(t, h, "PUT", base+"object?key="+key, []byte("x")); rec.Code != http.StatusBadRequest {
|
|
t.Errorf("put %q: %d, want 400", key, rec.Code)
|
|
}
|
|
}
|
|
if rec := do(t, h, "GET", base+"list?prefix=../", nil); rec.Code != http.StatusBadRequest {
|
|
t.Errorf("list bad prefix: %d, want 400", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestStoreAPIWriteGating(t *testing.T) {
|
|
srv, p, _ := newHub(t, false, nil)
|
|
h := srv.Handler()
|
|
base := "/api/p/" + p.ID + "/store/"
|
|
key := "blobs/" + shaOf("x")
|
|
if rec := do(t, h, "PUT", base+"object?key="+key, []byte("x")); rec.Code != http.StatusForbidden {
|
|
t.Fatalf("put with uploads off: %d, want 403", rec.Code)
|
|
}
|
|
if rec := do(t, h, "POST", base+"sign", map[string]any{"key": key, "size": 1}); rec.Code != http.StatusForbidden {
|
|
t.Fatalf("sign with uploads off: %d, want 403", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestSingleVolumeServerHostsNoProjects(t *testing.T) {
|
|
srv := &Server{Source: &DirSource{Root: t.TempDir()}, Volume: "local",
|
|
Upload: UploadConfig{Enabled: true}}
|
|
h := srv.Handler()
|
|
if rec := do(t, h, "GET", "/api/projects", nil); rec.Code != http.StatusNotFound {
|
|
t.Fatalf("projects on single server: %d, want 404", rec.Code)
|
|
}
|
|
if rec := do(t, h, "GET", "/api/p/p-00000000/tree", nil); rec.Code != http.StatusNotFound {
|
|
t.Fatalf("project tree on single server: %d, want 404", rec.Code)
|
|
}
|
|
}
|
|
|
|
// Journals must never be presigned — they are mutable state, only blobs are
|
|
// immutable — so sign answers "server" for them even on a signing backend.
|
|
// Presigning must survive the project-prefix wrapper.
|
|
func TestStoreSignJournalAlwaysViaServer(t *testing.T) {
|
|
var sb *signingBackend
|
|
srv, p, _ := newHub(t, true, func(be remote.Backend) remote.Backend {
|
|
sb = &signingBackend{Backend: be}
|
|
return sb
|
|
})
|
|
h := srv.Handler()
|
|
base := "/api/p/" + p.ID + "/store/"
|
|
|
|
rec := do(t, h, "POST", base+"sign", map[string]any{"key": "journal/dev1.jsonl", "size": 10})
|
|
if rec.Code != 200 || !strings.Contains(rec.Body.String(), `"server"`) {
|
|
t.Fatalf("sign journal: %d %s, want server mode", rec.Code, rec.Body)
|
|
}
|
|
blobKey := "blobs/" + shaOf("z")
|
|
rec = do(t, h, "POST", base+"sign", map[string]any{"key": blobKey, "size": 1})
|
|
if rec.Code != 200 || !strings.Contains(rec.Body.String(), `"direct"`) {
|
|
t.Fatalf("sign blob: %d %s, want direct mode", rec.Code, rec.Body)
|
|
}
|
|
// the presigned key is namespaced under the project prefix
|
|
if len(sb.signed) != 1 || sb.signed[0] != p.ID+"/"+blobKey {
|
|
t.Fatalf("signed keys = %v, want [%s/%s]", sb.signed, p.ID, blobKey)
|
|
}
|
|
}
|
|
|
|
// The full client path: an https:// remote backend (remote.Open on the
|
|
// server's /p/<id> URL) doing List/Get/Exists/Put against a live hub, and
|
|
// projects staying isolated from each other.
|
|
func TestHTTPBackendThroughServer(t *testing.T) {
|
|
srv, p, root := newHub(t, true, nil)
|
|
f := newFakeRemoteAt(t, filepath.Join(root, p.ID))
|
|
f.put("deva", "seed.md", "seeded")
|
|
other, _, err := srv.Projects.GetOrCreate("other", "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ts := httptest.NewServer(srv.Handler())
|
|
defer ts.Close()
|
|
|
|
be, err := remote.Open(context.Background(), ts.URL+"/p/"+p.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer be.Close()
|
|
|
|
objs, err := be.List(context.Background(), "journal/")
|
|
if err != nil || len(objs) != 1 {
|
|
t.Fatalf("list = %v, %v", objs, err)
|
|
}
|
|
ok, err := be.Exists(context.Background(), "blobs/"+shaOf("seeded"))
|
|
if err != nil || !ok {
|
|
t.Fatalf("exists = %v, %v", ok, err)
|
|
}
|
|
|
|
// push a blob + read it back like a syncing device would
|
|
content := "pushed through server"
|
|
blobKey := "blobs/" + shaOf(content)
|
|
if err := be.Put(context.Background(), blobKey, strings.NewReader(content), int64(len(content))); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rc, err := be.Get(context.Background(), blobKey)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got, _ := io.ReadAll(rc)
|
|
rc.Close()
|
|
if string(got) != content {
|
|
t.Fatalf("roundtrip = %q", got)
|
|
}
|
|
|
|
// project isolation: the other project sees none of it
|
|
beOther, err := remote.Open(context.Background(), ts.URL+"/p/"+other.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer beOther.Close()
|
|
objs, err = beOther.List(context.Background(), "journal/")
|
|
if err != nil || len(objs) != 0 {
|
|
t.Fatalf("other project journals = %v, %v; want none", objs, err)
|
|
}
|
|
ok, err = beOther.Exists(context.Background(), blobKey)
|
|
if err != nil || ok {
|
|
t.Fatalf("other project sees foreign blob: %v %v", ok, err)
|
|
}
|
|
}
|