Files
beardrive/internal/webapp/upload_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

330 lines
11 KiB
Go

package webapp
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"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"
)
var webDevice = Identity{ID: "webdev", Name: "webhost", Author: "web@test"}
// uploadServer returns an upload-enabled Server over the fake remote, with
// the backend optionally wrapped (e.g. to add signing).
func (f *fakeRemote) uploadServer(wrap func(remote.Backend) remote.Backend) *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() })
if wrap != nil {
be = wrap(be)
}
return &Server{
Source: &RemoteSource{Backend: be, Device: webDevice},
Volume: "testvol", Refresh: 0,
Upload: UploadConfig{Enabled: true},
}
}
// signingBackend fakes an object store that can presign uploads.
type signingBackend struct {
remote.Backend
signed []string // keys presigned so far
}
func (b *signingBackend) SignPut(_ context.Context, key string, size int64, ttl time.Duration) (*remote.SignedPut, error) {
b.signed = append(b.signed, key)
return &remote.SignedPut{
URL: "https://storage.example/" + key + "?sig=abc", Method: "PUT",
Headers: map[string]string{"Content-Length": fmt.Sprint(size)},
Expires: time.Now().Add(ttl),
}, nil
}
func do(t *testing.T, h http.Handler, method, url string, body any) *httptest.ResponseRecorder {
t.Helper()
var rd *bytes.Reader
switch b := body.(type) {
case nil:
rd = bytes.NewReader(nil)
case []byte:
rd = bytes.NewReader(b)
default:
data, err := json.Marshal(b)
if err != nil {
t.Fatal(err)
}
rd = bytes.NewReader(data)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(method, url, rd))
return rec
}
func shaOf(content string) string {
sum := sha256.Sum256([]byte(content))
return hex.EncodeToString(sum[:])
}
func initReq(path, content string) map[string]any {
return map[string]any{"path": path, "sha256": shaOf(content), "size": len(content)}
}
func TestConfigExposesNoStorageInfo(t *testing.T) {
f := newFakeRemote(t)
h := f.server().Handler()
rec := do(t, h, "GET", "/api/config", nil)
if rec.Code != 200 {
t.Fatalf("config: %d", rec.Code)
}
body := rec.Body.String()
if strings.Contains(body, f.dir) || strings.Contains(body, "file://") {
t.Fatalf("config leaks storage location: %s", body)
}
var cfg struct {
Volume string `json:"volume"`
Upload struct {
Enabled bool `json:"enabled"`
} `json:"upload"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &cfg); err != nil {
t.Fatal(err)
}
if cfg.Volume != "testvol" || cfg.Upload.Enabled {
t.Fatalf("config = %+v, want volume testvol, upload disabled", cfg)
}
// the old endpoint that used to expose the remote URL must be gone
if rec := do(t, h, "GET", "/api/volume", nil); rec.Code == 200 {
t.Fatalf("/api/volume should not exist, got %d", rec.Code)
}
}
func TestUploadDisabledByDefault(t *testing.T) {
f := newFakeRemote(t)
h := f.server().Handler() // read-only server
if rec := do(t, h, "POST", "/api/upload/init", initReq("a.md", "x")); rec.Code != http.StatusForbidden {
t.Fatalf("init on read-only server: %d, want 403", rec.Code)
}
if rec := do(t, h, "PUT", "/api/upload/content?path=a.md", []byte("x")); rec.Code != http.StatusForbidden {
t.Fatalf("content on read-only server: %d, want 403", rec.Code)
}
if rec := do(t, h, "POST", "/api/upload/commit", initReq("a.md", "x")); rec.Code != http.StatusForbidden {
t.Fatalf("commit on read-only server: %d, want 403", rec.Code)
}
}
// A file:// backend cannot presign, so the server must direct the client to
// upload through it — and the upload must land as blob + journal op with the
// server's device identity and a lamport past every existing op.
func TestServerModeUpload(t *testing.T) {
f := newFakeRemote(t)
f.put("deva", "old.md", "existing") // pre-existing history from another device
srv := f.uploadServer(nil)
h := srv.Handler()
rec := do(t, h, "POST", "/api/upload/init", initReq("notes/new.md", "hello"))
if rec.Code != 200 {
t.Fatalf("init: %d %s", rec.Code, rec.Body)
}
var plan struct {
Mode string `json:"mode"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &plan); err != nil {
t.Fatal(err)
}
if plan.Mode != "server" {
t.Fatalf("mode = %q, want server (file backend cannot presign)", plan.Mode)
}
rec = do(t, h, "PUT", "/api/upload/content?path=notes/new.md", []byte("hello"))
if rec.Code != 200 {
t.Fatalf("content: %d %s", rec.Code, rec.Body)
}
// visible immediately (snapshot invalidated), served back intact
if rec := do(t, h, "GET", "/api/file?path=notes/new.md", nil); rec.Code != 200 || rec.Body.String() != "hello" {
t.Fatalf("file after upload: %d %q", rec.Code, rec.Body)
}
// blob is content-addressed in the store
if _, err := os.Stat(filepath.Join(f.dir, "blobs", shaOf("hello"))); err != nil {
t.Fatalf("blob not in store: %v", err)
}
// journaled under the server's own device, never anyone else's
ops, err := journal.ReadFile(filepath.Join(f.dir, "journal", webDevice.ID+".jsonl"))
if err != nil || len(ops) != 1 {
t.Fatalf("web journal ops = %v, %v", ops, err)
}
op := ops[0]
if op.Device != webDevice.ID || op.Author != webDevice.Author || op.Seq != 1 {
t.Fatalf("op identity = %+v", op)
}
if op.Lamport <= 1 { // deva's put had lamport 1; ours must sort after it
t.Fatalf("lamport = %d, want > 1", op.Lamport)
}
if op.Kind != journal.KindPut || op.Path != "notes/new.md" || op.Blob != shaOf("hello") || op.Size != 5 {
t.Fatalf("op = %+v", op)
}
}
func TestDirectModeUpload(t *testing.T) {
f := newFakeRemote(t)
var sb *signingBackend
srv := f.uploadServer(func(be remote.Backend) remote.Backend {
sb = &signingBackend{Backend: be}
return sb
})
h := srv.Handler()
content := "direct content"
req := initReq("docs/d.md", content)
rec := do(t, h, "POST", "/api/upload/init", req)
if rec.Code != 200 {
t.Fatalf("init: %d %s", rec.Code, rec.Body)
}
var plan struct {
Mode, URL, Method string
Exists bool
Expires time.Time
}
if err := json.Unmarshal(rec.Body.Bytes(), &plan); err != nil {
t.Fatal(err)
}
if plan.Mode != "direct" || plan.Exists {
t.Fatalf("plan = %+v, want fresh direct upload", plan)
}
wantKey := "blobs/" + shaOf(content)
if !strings.Contains(plan.URL, wantKey) || plan.Method != "PUT" {
t.Fatalf("plan = %+v, want presigned PUT of %s", plan, wantKey)
}
if plan.Expires.IsZero() || plan.Expires.After(time.Now().Add(DefaultUploadTTL+time.Minute)) {
t.Fatalf("expires = %v, want bounded by ttl", plan.Expires)
}
if len(sb.signed) != 1 || sb.signed[0] != wantKey {
t.Fatalf("signed keys = %v", sb.signed)
}
// committing before the blob arrived must be refused: a journal op must
// never point at missing content
if rec := do(t, h, "POST", "/api/upload/commit", req); rec.Code != http.StatusConflict {
t.Fatalf("commit without blob: %d %s, want 409", rec.Code, rec.Body)
}
// simulate the client's direct PUT to storage
if err := os.WriteFile(filepath.Join(f.dir, "blobs", shaOf(content)), []byte(content), 0o644); err != nil {
t.Fatal(err)
}
if rec := do(t, h, "POST", "/api/upload/commit", req); rec.Code != 200 {
t.Fatalf("commit: %d %s", rec.Code, rec.Body)
}
if rec := do(t, h, "GET", "/api/file?path=docs/d.md", nil); rec.Code != 200 || rec.Body.String() != content {
t.Fatalf("file after direct upload: %d %q", rec.Code, rec.Body)
}
// re-uploading identical content: init should say it's already there
rec = do(t, h, "POST", "/api/upload/init", initReq("copy.md", content))
var again struct {
Mode string
Exists bool
}
if err := json.Unmarshal(rec.Body.Bytes(), &again); err != nil {
t.Fatal(err)
}
if again.Mode != "direct" || !again.Exists {
t.Fatalf("re-init = %+v, want direct+exists", again)
}
}
func TestUploadPathValidation(t *testing.T) {
f := newFakeRemote(t)
h := f.uploadServer(nil).Handler()
for _, bad := range []string{"", "/abs.md", "../escape.md", "a/../../b", "dir/", ".bdrive", "x/.bdrive-tmp-1"} {
body := map[string]any{"path": bad, "sha256": shaOf("x"), "size": 1}
if rec := do(t, h, "POST", "/api/upload/init", body); rec.Code != http.StatusBadRequest {
t.Errorf("init path %q: %d, want 400", bad, rec.Code)
}
if rec := do(t, h, "PUT", "/api/upload/content?path="+bad, []byte("x")); rec.Code != http.StatusBadRequest {
t.Errorf("content path %q: %d, want 400", bad, rec.Code)
}
}
// bad sha256 values
for _, sha := range []string{"", "xyz", strings.Repeat("A", 64)} {
body := map[string]any{"path": "ok.md", "sha256": sha, "size": 1}
if rec := do(t, h, "POST", "/api/upload/init", body); rec.Code != http.StatusBadRequest {
t.Errorf("init sha %q: %d, want 400", sha, rec.Code)
}
}
}
func TestDirSourceUpload(t *testing.T) {
root := t.TempDir()
srv := &Server{
Source: &DirSource{Root: root}, Volume: "local", Refresh: 0,
Upload: UploadConfig{Enabled: true},
}
h := srv.Handler()
// a plain folder cannot presign either → server mode
rec := do(t, h, "POST", "/api/upload/init", initReq("sub/note.md", "hi"))
if rec.Code != 200 || !strings.Contains(rec.Body.String(), `"server"`) {
t.Fatalf("init: %d %s, want server mode", rec.Code, rec.Body)
}
if rec := do(t, h, "PUT", "/api/upload/content?path=sub/note.md", []byte("hi")); rec.Code != 200 {
t.Fatalf("content: %d %s", rec.Code, rec.Body)
}
data, err := os.ReadFile(filepath.Join(root, "sub", "note.md"))
if err != nil || string(data) != "hi" {
t.Fatalf("file on disk = %q, %v", data, err)
}
// direct-mode commit makes no sense for a plain folder
if rec := do(t, h, "POST", "/api/upload/commit", initReq("sub/note.md", "hi")); rec.Code != http.StatusBadRequest {
t.Fatalf("commit on dir source: %d, want 400", rec.Code)
}
// upload config advertises enabled
rec = do(t, h, "GET", "/api/config", nil)
if !strings.Contains(rec.Body.String(), `"enabled":true`) {
t.Fatalf("config = %s, want upload enabled", rec.Body)
}
}
// Uploads from two requests racing must serialize on the journal: both ops
// survive with distinct seqs.
func TestConcurrentCommits(t *testing.T) {
f := newFakeRemote(t)
h := f.uploadServer(nil).Handler()
done := make(chan int, 2)
for i := 0; i < 2; i++ {
go func(i int) {
content := fmt.Sprintf("body-%d", i)
rec := do(t, h, "PUT", fmt.Sprintf("/api/upload/content?path=f%d.md", i), []byte(content))
done <- rec.Code
}(i)
}
for i := 0; i < 2; i++ {
if code := <-done; code != 200 {
t.Fatalf("concurrent upload: %d", code)
}
}
ops, err := journal.ReadFile(filepath.Join(f.dir, "journal", webDevice.ID+".jsonl"))
if err != nil || len(ops) != 2 {
t.Fatalf("ops = %v, %v; want 2", ops, err)
}
if ops[0].Seq == ops[1].Seq || ops[0].Lamport == ops[1].Lamport {
t.Fatalf("seq/lamport must be distinct: %+v %+v", ops[0], ops[1])
}
}