Files
beardrive/internal/webapp/server_test.go
T
Snow LeeandClaude Fable 5 a7bb790615 feat: multi-project sync hub, bdrive login/init onboarding, .bdrive rename
The web server (bdrive web) becomes a full sync hub, and client devices
get one-command onboarding — without ever seeing storage info or holding
cloud credentials:

- bdrive web -c config.json: server configurable from a JSON file
  (remote/addr/upload/upload_ttl/projects_db); explicit flags win.
- Hub mode: pointing bdrive web at a storage root hosts many projects,
  each under <root>/<project-id>/ (remote.Prefixed). Projects live in a
  file-backed registry (projects.json — loaded at open, rewritten
  atomically per change) with create-or-join-by-name semantics.
- Per-project APIs: /api/projects (list/create/get) and
  /api/p/<id>/{tree,file,render,download,upload/*,store/*}. The web UI
  grows a project list with per-project browsing and hash deep links.
- Browser uploads and a store proxy for syncing devices: presigned
  direct-to-storage PUTs when the backend can sign (S3 presign, GCS V4
  signed URLs; expiring, credential-free), relayed through the server
  otherwise. Journals are never presigned — only immutable blobs.
  Blobs-before-journal and one-writer-per-journal invariants hold.
- https:// remote backend: a device syncs one hub project through
  /api/p/<id>/store/* — mnt/sync/daemon/log all work unchanged.
- bdrive login <url>: verify a hub and remember it as the device default
  (settings.json). bdrive init: create-or-join a project named after the
  folder (--name/--project override), write .bdrive, seed a starter
  .bdriveignore, mount, and start the daemon — one command per project.
- Hard-break rename: .beardrive->.bdrive, .beardriveignore->.bdriveignore,
  ~/.beardrive->~/.bdrive, BEARDRIVE_HOME->BDRIVE_HOME, temp/conflict
  prefixes; old names are no longer read.
- Tests: presigning, project registry persistence, store API validation
  and gating, project isolation over live HTTP, browser upload flows, and
  two-device convergence through a hub (incl. read-only pull-only mode).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R7Q9ZKSZRTdvrSJkYLUmYs
2026-07-08 07:13:00 -07:00

220 lines
6.0 KiB
Go

package webapp
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/runbear-io/beardrive/internal/journal"
"github.com/runbear-io/beardrive/internal/remote"
)
// fakeRemote builds a beardrive remote layout (journal/<dev>.jsonl + blobs/<sha>)
// in a temp dir and returns a Server over it.
type fakeRemote struct {
t *testing.T
dir string
seq map[string]int64
lam int64
}
func newFakeRemote(t *testing.T) *fakeRemote {
t.Helper()
return newFakeRemoteAt(t, t.TempDir())
}
// newFakeRemoteAt builds the remote layout at a specific directory (e.g. a
// project prefix inside a hub's storage root).
func newFakeRemoteAt(t *testing.T, dir string) *fakeRemote {
t.Helper()
for _, d := range []string{"journal", "blobs"} {
if err := os.MkdirAll(filepath.Join(dir, d), 0o755); err != nil {
t.Fatal(err)
}
}
return &fakeRemote{t: t, dir: dir, seq: map[string]int64{}}
}
func (f *fakeRemote) put(dev, path, content string) {
f.t.Helper()
sum := sha256.Sum256([]byte(content))
blob := hex.EncodeToString(sum[:])
if err := os.WriteFile(filepath.Join(f.dir, "blobs", blob), []byte(content), 0o644); err != nil {
f.t.Fatal(err)
}
f.append(dev, journal.Op{
Kind: journal.KindPut, Path: path,
Blob: blob, Size: int64(len(content)), Mode: 0o644,
})
}
func (f *fakeRemote) del(dev, path string) {
f.t.Helper()
f.append(dev, journal.Op{Kind: journal.KindDelete, Path: path})
}
func (f *fakeRemote) append(dev string, op journal.Op) {
f.t.Helper()
f.lam++
f.seq[dev]++
op.Seq, op.Lamport = f.seq[dev], f.lam
op.Time = time.Now().UTC()
op.Device, op.DeviceName, op.Author = dev, dev, dev+"@test"
if err := journal.Append(filepath.Join(f.dir, "journal", dev+".jsonl"), []journal.Op{op}); err != nil {
f.t.Fatal(err)
}
}
func (f *fakeRemote) server() *Server {
f.t.Helper()
be, err := remote.Open(context.Background(), "file://"+f.dir)
if err != nil {
f.t.Fatal(err)
}
f.t.Cleanup(func() { be.Close() })
return &Server{Source: &RemoteSource{Backend: be}, Volume: "testvol", Refresh: 0}
}
func get(t *testing.T, h http.Handler, url string) *httptest.ResponseRecorder {
t.Helper()
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest("GET", url, nil))
return rec
}
func TestTreeAndFile(t *testing.T) {
f := newFakeRemote(t)
f.put("deva", "readme.md", "# Hello")
f.put("deva", "notes/plan.md", "- step one")
f.put("devb", "notes/img.png", "not-really-a-png")
h := f.server().Handler()
rec := get(t, h, "/api/tree")
if rec.Code != 200 {
t.Fatalf("tree: %d %s", rec.Code, rec.Body)
}
var root Node
if err := json.Unmarshal(rec.Body.Bytes(), &root); err != nil {
t.Fatal(err)
}
if len(root.Children) != 2 {
t.Fatalf("root children = %d, want 2 (notes/, readme.md)", len(root.Children))
}
if !root.Children[0].Dir || root.Children[0].Name != "notes" {
t.Fatalf("first child = %+v, want dir notes (folders first)", root.Children[0])
}
if got := len(root.Children[0].Children); got != 2 {
t.Fatalf("notes/ children = %d, want 2", got)
}
rec = get(t, h, "/api/file?path=readme.md")
if rec.Code != 200 || rec.Body.String() != "# Hello" {
t.Fatalf("file: %d %q", rec.Code, rec.Body)
}
if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/markdown") {
t.Fatalf("content-type = %q", ct)
}
etag := rec.Header().Get("ETag")
if etag == "" {
t.Fatal("no ETag")
}
req := httptest.NewRequest("GET", "/api/file?path=readme.md", nil)
req.Header.Set("If-None-Match", etag)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusNotModified {
t.Fatalf("etag revalidate: %d, want 304", rec.Code)
}
if rec := get(t, h, "/api/file?path=nope.md"); rec.Code != 404 {
t.Fatalf("missing file: %d, want 404", rec.Code)
}
}
func TestDeleteHidesFile(t *testing.T) {
f := newFakeRemote(t)
f.put("deva", "a.md", "a")
f.put("deva", "b.md", "b")
f.del("devb", "a.md")
h := f.server().Handler()
var root Node
if err := json.Unmarshal(get(t, h, "/api/tree").Body.Bytes(), &root); err != nil {
t.Fatal(err)
}
if len(root.Children) != 1 || root.Children[0].Name != "b.md" {
t.Fatalf("tree after delete = %+v, want only b.md", root.Children)
}
if rec := get(t, h, "/api/file?path=a.md"); rec.Code != 404 {
t.Fatalf("deleted file: %d, want 404", rec.Code)
}
}
func TestRenderMarkdown(t *testing.T) {
f := newFakeRemote(t)
f.put("deva", "doc.md", "# Title\n\nsee [[plan]] and [[plan|the plan]]\n\n<script>x</script>")
h := f.server().Handler()
rec := get(t, h, "/api/render?path=doc.md")
if rec.Code != 200 {
t.Fatalf("render: %d %s", rec.Code, rec.Body)
}
var doc struct {
HTML string `json:"html"`
Author string `json:"author"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &doc); err != nil {
t.Fatal(err)
}
for _, want := range []string{"<h1", "Title", `href="wiki:plan"`, ">the plan</a>"} {
if !strings.Contains(doc.HTML, want) {
t.Errorf("html missing %q:\n%s", want, doc.HTML)
}
}
if strings.Contains(doc.HTML, "<script>") {
t.Errorf("raw HTML not escaped:\n%s", doc.HTML)
}
if doc.Author != "deva@test" {
t.Errorf("author = %q", doc.Author)
}
}
func TestDownload(t *testing.T) {
f := newFakeRemote(t)
f.put("deva", "notes/plan.md", "content")
h := f.server().Handler()
rec := get(t, h, "/api/download?path=notes/plan.md")
if rec.Code != 200 || rec.Body.String() != "content" {
t.Fatalf("download: %d %q", rec.Code, rec.Body)
}
if cd := rec.Header().Get("Content-Disposition"); cd != `attachment; filename="plan.md"` {
t.Fatalf("content-disposition = %q", cd)
}
}
func TestFrontendServed(t *testing.T) {
f := newFakeRemote(t)
h := f.server().Handler()
rec := get(t, h, "/")
if rec.Code != 200 || !strings.Contains(rec.Body.String(), "<title>BearDrive</title>") {
t.Fatalf("index: %d", rec.Code)
}
}
func TestExpandWikilinks(t *testing.T) {
got := string(expandWikilinks([]byte("a [[x y]] b [[u|v]] c [[no")))
want := "a [x y](wiki:x%20y) b [v](wiki:u) c [[no"
if got != want {
t.Errorf("got %q, want %q", got, want)
}
}