mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
Access was binary and org-wide: any org member got full read+write on every project. Now each project carries four ordered levels, resolved by one resolver and enforced at one choke point. - `projectPerm` (perms.go) replaces `projectAllowed`; `proj(level, h)` in server.go gates every per-project route by the level it declares at registration, so no handler grows its own check. - `Project` gains Creator/Default/Perms. `Default == ""` means write, so an upgraded hub behaves identically until someone edits permissions. - Creator becomes the first project admin; org owners are implicitly admin everywhere in their org and a grant naming one is refused, not ignored; a project always keeps at least one explicit admin. - Default `none` makes a project invite-only. A `none` member is treated exactly like a non-member, including on create-or-join by name. - Rename/delete move from org-owner-only to project `admin`. - Both metadata backends persist it: the file store rides along, the SQL store gains `project_perms` plus an idempotent ALTER for the two new columns (migrate() had only ever created tables). Client side, a refusal stops looking like an outage: `remote.ErrForbidden` plus `Result.ReadOnly` (push refused → pull-only) and `Result.NoAccess` (pull refused → paused, working folder untouched). Neither sets Offline, neither loses a local op, and re-granting self-heals on the next cycle. `bdrive status`/`sync` and the daemon (once, on transition) say which. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
113 lines
3.6 KiB
Go
113 lines
3.6 KiB
Go
package syncer
|
|
|
|
import (
|
|
"context"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/runbear-io/beardrive/internal/remote"
|
|
"github.com/runbear-io/beardrive/internal/webapp"
|
|
)
|
|
|
|
// newHub spins up a bdrive web hub over a fresh storage root and returns the
|
|
// test server plus one project.
|
|
func newHub(t *testing.T, storage remote.Backend, upload bool) (*httptest.Server, webapp.Project) {
|
|
t.Helper()
|
|
db, err := webapp.OpenProjectDB(filepath.Join(t.TempDir(), "projects.json"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
p, _, err := db.GetOrCreate("vol", "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
srv := &webapp.Server{
|
|
Root: storage, Projects: db, Refresh: 0,
|
|
Upload: webapp.UploadConfig{Enabled: upload},
|
|
}
|
|
ts := httptest.NewServer(srv.Handler())
|
|
t.Cleanup(ts.Close)
|
|
return ts, p
|
|
}
|
|
|
|
// A device syncing through a bdrive web server (https:// remote) must
|
|
// converge with a device talking to the object store directly: the server is
|
|
// just a broker, not a different sync model.
|
|
func TestSyncThroughWebServer(t *testing.T) {
|
|
storage := sharedRemote(t) // the object store only the server knows about
|
|
ts, p := newHub(t, storage, true)
|
|
|
|
viaServer, err := remote.Open(context.Background(), ts.URL+"/p/"+p.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer viaServer.Close()
|
|
|
|
a := newDevice(t, "deva", viaServer) // storage-blind client
|
|
b := newDevice(t, "devb", remote.Prefixed(storage, p.ID)) // direct-to-storage device
|
|
|
|
// client → server → storage → direct device
|
|
write(t, a.Folder, "notes/from-client.md", "hello via server")
|
|
cycle(t, a)
|
|
res := cycle(t, b)
|
|
if res.PulledOps != 1 || read(t, b.Folder, "notes/from-client.md") != "hello via server" {
|
|
t.Fatalf("b did not receive client's file: %+v", res)
|
|
}
|
|
|
|
// direct device → storage → server → client
|
|
time.Sleep(10 * time.Millisecond)
|
|
write(t, b.Folder, "notes/from-direct.md", "hello back")
|
|
write(t, b.Folder, "notes/from-client.md", "edited directly")
|
|
cycle(t, b)
|
|
cycle(t, a)
|
|
if read(t, a.Folder, "notes/from-direct.md") != "hello back" {
|
|
t.Fatal("client did not receive direct device's file")
|
|
}
|
|
if read(t, a.Folder, "notes/from-client.md") != "edited directly" {
|
|
t.Fatal("client did not receive the edit")
|
|
}
|
|
|
|
// deletes propagate through the server too
|
|
os.Remove(filepath.Join(a.Folder, "notes", "from-direct.md"))
|
|
cycle(t, a)
|
|
cycle(t, b)
|
|
if _, err := os.Stat(filepath.Join(b.Folder, "notes", "from-direct.md")); !os.IsNotExist(err) {
|
|
t.Fatal("delete via server did not propagate")
|
|
}
|
|
}
|
|
|
|
// With uploads disabled on the server, a client can still pull (read-only
|
|
// follower) — its pushes report ReadOnly instead of failing the cycle. Not
|
|
// Offline: the server answered, it just said no, and retrying forever as if
|
|
// the network were down would hide that from the user.
|
|
func TestReadOnlyServerClientStillPulls(t *testing.T) {
|
|
storage := sharedRemote(t)
|
|
ts, p := newHub(t, storage, false) // read-only hub
|
|
|
|
viaServer, err := remote.Open(context.Background(), ts.URL+"/p/"+p.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer viaServer.Close()
|
|
|
|
b := newDevice(t, "devb", remote.Prefixed(storage, p.ID))
|
|
write(t, b.Folder, "shared.md", "server-side truth")
|
|
cycle(t, b)
|
|
|
|
a := newDevice(t, "deva", viaServer)
|
|
write(t, a.Folder, "local-only.md", "cannot push this")
|
|
res, err := a.Cycle(context.Background())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !res.ReadOnly || res.Offline {
|
|
t.Fatalf("push against a read-only server should report ReadOnly, not Offline: %+v", res)
|
|
}
|
|
if read(t, a.Folder, "shared.md") != "server-side truth" {
|
|
t.Fatal("client should still pull from a read-only server")
|
|
}
|
|
}
|