Files
beardrive/internal/webapp/store_test.go
T
Snow LeeandClaude Fable 5 94ef66a4a9 feat(hub): organizations — projects walled by team membership, with invites
Every hub project now belongs to exactly one org (file-backed orgs.json,
same load-at-open + atomic-rewrite discipline as the other registries).
Membership (email -> owner|member) gates every per-project route: the
viewer APIs, uploads, history, blobs, shares management, and the device
sync store proxy; /api/projects lists only your orgs' projects, and
project names are now scoped per org. Public share links (/s/) stay
public by design.

Design choices, per the simplest-consistent rule:
- Migration: a pre-org hub sweeps all org-less projects into a "default"
  org at startup; ALL existing accounts join it (they could all see every
  project before, so anything narrower would lock someone out), oldest
  account as owner. Zero manual steps.
- An account in no org that creates a project gets a fresh org named
  after itself, so nobody is ever blocked from starting to sync.
- Invites are expiring multi-use links (default 7 days): an owner mints
  /#join/<token>, any signed-in account that opens it joins as member.
  The web UI shows the org in a sidebar footer (members on click,
  Invite button for owners).

bdrive init needed no changes: its connect-existing flow lists projects
through the now-filtered API.

Tests: OrgDB + migration units; a 403/access matrix over every
per-project route; invite mint/redeem flow; and a multi-device syncer
test proving a device holding an org-B token can neither pull org A's
files nor push into its store (sync degrades to Offline, never partial).

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

319 lines
10 KiB
Go

package webapp
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"github.com/runbear-io/beardrive/internal/remote"
)
// newHub builds a hub server over a fresh storage root with one project.
func newHub(t *testing.T, 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)
}
if !out.Created || out.Project.Name != "my-app" || !strings.HasPrefix(out.Project.ID, "p-") {
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)
}
}