mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
219 lines
7.7 KiB
Go
219 lines
7.7 KiB
Go
package webapp
|
|||
|
|
|
||
|
|
import (
|
||
|
|
"encoding/json"
|
||
|
|
"net/http"
|
||
|
|
"net/http/httptest"
|
||
|
|
"os"
|
||
|
|
"os/exec"
|
||
|
|
"path/filepath"
|
||
|
|
"strings"
|
||
|
|
"sync"
|
||
|
|
"testing"
|
||
|
|
|
||
|
|
"github.com/runbear-io/beardrive/internal/remote"
|
||
|
|
)
|
||
|
|
|
||
|
|
// The real binary, over real HTTP, against a hub whose AuthProvider is NOT
|
||
|
|
// BuiltinAuth — the shape every managed deployment has, and the one no test in
|
||
|
|
// this repo drove.
|
||
|
|
//
|
||
|
|
// That gap is why the outage shipped and survived a release. `ownJournal`
|
||
|
|
// refuses a journal write for every provider, but the binder that satisfies it
|
||
|
|
// was wired behind `if a, ok := s.Auth.(*BuiltinAuth); ok`, so on a managed hub
|
||
|
|
// nothing was ever bound and every push was refused forever. Every existing
|
||
|
|
// test used BuiltinAuth, where the wiring happened to work, and every in-process
|
||
|
|
// test of the gate asserted the refusal — which was still correct. Only a hub
|
||
|
|
// with a different provider, driven the whole way from `bdrive login` to a
|
||
|
|
// journal object in the store, tells the two apart.
|
||
|
|
//
|
||
|
|
// The two sub-tests differ in ONE thing: whether the provider calls the binder
|
||
|
|
// at mint. Everything else — the CLI, the flags, the files — is identical.
|
||
|
|
|
||
|
|
// cliprovAuth is a managed provider: it authenticates against its own notion of
|
||
|
|
// a session, mints its own CLI tokens, and the hub knows nothing about either.
|
||
|
|
// Modelled on the cloud's, including the mint point being the only place a
|
||
|
|
// binding could be made.
|
||
|
|
type cliprovAuth struct {
|
||
|
|
callBinder bool
|
||
|
|
user User
|
||
|
|
token string
|
||
|
|
|
||
|
|
mu sync.Mutex
|
||
|
|
bind DeviceBinder
|
||
|
|
}
|
||
|
|
|
||
|
|
func (a *cliprovAuth) CLILoginPath() string { return "/auth/cli" }
|
||
|
|
func (a *cliprovAuth) Accounts() []User { return []User{a.user} }
|
||
|
|
|
||
|
|
func (a *cliprovAuth) Authenticate(r *http.Request) (User, bool) {
|
||
|
|
if r.Header.Get("Authorization") != "Bearer "+a.token {
|
||
|
|
return User{}, false
|
||
|
|
}
|
||
|
|
return a.user, true
|
||
|
|
}
|
||
|
|
|
||
|
|
func (a *cliprovAuth) UseDeviceBinder(b DeviceBinder) {
|
||
|
|
a.mu.Lock()
|
||
|
|
defer a.mu.Unlock()
|
||
|
|
a.bind = b
|
||
|
|
}
|
||
|
|
|
||
|
|
// finishLogin is the mint point. The contract is: bind before the token goes
|
||
|
|
// out, and refuse the login if the bind is refused.
|
||
|
|
func (a *cliprovAuth) finishLogin(w http.ResponseWriter, r *http.Request) {
|
||
|
|
a.mu.Lock()
|
||
|
|
bind := a.bind
|
||
|
|
a.mu.Unlock()
|
||
|
|
if a.callBinder && bind != nil {
|
||
|
|
if err := bind(a.user.Email, r); err != nil {
|
||
|
|
http.Error(w, err.Error(), http.StatusConflict)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
}
|
||
|
|
json.NewEncoder(w).Encode(map[string]any{"token": a.token, "user": a.user})
|
||
|
|
}
|
||
|
|
|
||
|
|
// Register mounts the CLI endpoints. The device-code flow approves itself on
|
||
|
|
// the first poll, so the whole run is headless — no browser, no cookie.
|
||
|
|
func (a *cliprovAuth) Register(mux *http.ServeMux) {
|
||
|
|
mux.HandleFunc("POST /api/auth/device/start", func(w http.ResponseWriter, r *http.Request) {
|
||
|
|
json.NewEncoder(w).Encode(map[string]any{
|
||
|
|
"code": "cliprov", "verify_url": "/auth/device/cliprov", "interval": 1,
|
||
|
|
})
|
||
|
|
})
|
||
|
|
mux.HandleFunc("POST /api/auth/device/poll", a.finishLogin)
|
||
|
|
mux.HandleFunc("POST /api/auth/exchange", a.finishLogin)
|
||
|
|
}
|
||
|
|
|
||
|
|
// startManagedHub is startTestHub with a managed provider, and with the syncing
|
||
|
|
// account as a plain org MEMBER of somebody else's project. That last part is
|
||
|
|
// load-bearing: ownJournal has an admin recovery arm, so a user who created the
|
||
|
|
// project pushes fine with no binding at all and the bug hides. The field report
|
||
|
|
// was a member on a project owned by someone else, which is what this is.
|
||
|
|
func startManagedHub(t *testing.T, callBinder bool) (*httptest.Server, string, string) {
|
||
|
|
t.Helper()
|
||
|
|
state := t.TempDir()
|
||
|
|
be, err := remote.Open(t.Context(), "file://"+filepath.Join(state, "storage"))
|
||
|
|
if err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
db, err := OpenProjectDB(filepath.Join(state, "projects.json"))
|
||
|
|
if err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
const member = "member@x.io"
|
||
|
|
srv := &Server{Root: be, Projects: db, Device: webDevice, Upload: UploadConfig{Enabled: true}}
|
||
|
|
srv.Devices, _ = OpenDeviceRegistry(filepath.Join(state, "devices.json"))
|
||
|
|
srv.Auth = &cliprovAuth{
|
||
|
|
callBinder: callBinder,
|
||
|
|
user: User{ID: "u-member", Email: member, Name: "Member"},
|
||
|
|
token: "bdt_cliprov_token",
|
||
|
|
}
|
||
|
|
orgs, err := OpenOrgDB(filepath.Join(state, "orgs.json"))
|
||
|
|
if err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
org, err := orgs.Create("acme", "owner@x.io")
|
||
|
|
if err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
if err := orgs.AddMember(org.ID, member, "member"); err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
srv.Dir = LocalDirectory{OrgDB: orgs}
|
||
|
|
p, _, err := db.GetOrCreate("team", org.ID)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
ts := httptest.NewServer(srv.Handler())
|
||
|
|
t.Cleanup(ts.Close)
|
||
|
|
return ts, p.ID, filepath.Join(state, "storage", p.ID, "journal")
|
||
|
|
}
|
||
|
|
|
||
|
|
// cliprovRun signs the CLI in, connects it to the project, and syncs one file.
|
||
|
|
// It returns the `bdrive sync` output and the hub's journal directory.
|
||
|
|
func cliprovRun(t *testing.T, callBinder bool) (string, string) {
|
||
|
|
t.Helper()
|
||
|
|
if testing.Short() {
|
||
|
|
t.Skip("builds and execs the bdrive binary; skipped with -short")
|
||
|
|
}
|
||
|
|
bin := filepath.Join(t.TempDir(), "bdrive")
|
||
|
|
if out, err := exec.Command("go", "build", "-o", bin,
|
||
|
|
"github.com/runbear-io/beardrive/cmd/bdrive").CombinedOutput(); err != nil {
|
||
|
|
t.Fatalf("go build: %v\n%s", err, out)
|
||
|
|
}
|
||
|
|
hub, projectID, journalDir := startManagedHub(t, callBinder)
|
||
|
|
|
||
|
|
home := t.TempDir()
|
||
|
|
env := append(envWithout("HOME", "BDRIVE_HOME"),
|
||
|
|
"HOME="+home, "BDRIVE_HOME="+filepath.Join(home, ".bdrive"))
|
||
|
|
run := func(dir string, args ...string) (string, error) {
|
||
|
|
cmd := exec.Command(bin, args...)
|
||
|
|
cmd.Dir, cmd.Env = dir, env
|
||
|
|
out, err := cmd.CombinedOutput()
|
||
|
|
return string(out), err
|
||
|
|
}
|
||
|
|
|
||
|
|
// The device flow approves itself, so this is the whole sign-in.
|
||
|
|
if out, err := run(home, "login", "--device", hub.URL); err != nil {
|
||
|
|
t.Fatalf("login: %v\n%s", err, out)
|
||
|
|
}
|
||
|
|
|
||
|
|
work := t.TempDir()
|
||
|
|
if err := os.WriteFile(filepath.Join(work, "notes.md"), []byte("from the field report"), 0o644); err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
t.Cleanup(func() { run(work, "stop", work) }) // never leak the daemon
|
||
|
|
if out, err := run(work, "init", "--project", projectID, "--yes", "--no-autostart"); err != nil {
|
||
|
|
t.Fatalf("init: %v\n%s", err, out)
|
||
|
|
}
|
||
|
|
// The daemon init started keeps cycling; `sync` takes the same volume flock,
|
||
|
|
// so the two serialize rather than race. Not stopped first on purpose —
|
||
|
|
// `bdrive stop` PAUSES the mount, and a paused mount refuses to sync at all.
|
||
|
|
out, err := run(work, "sync", work)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("sync: %v\n%s", err, out)
|
||
|
|
}
|
||
|
|
return out, journalDir
|
||
|
|
}
|
||
|
|
|
||
|
|
// A managed provider that honours the contract: the device binds at mint and
|
||
|
|
// the CLI's journal reaches the hub.
|
||
|
|
func TestCLIManagedProviderDeviceCanPush(t *testing.T) {
|
||
|
|
out, journalDir := cliprovRun(t, true)
|
||
|
|
|
||
|
|
if strings.Contains(out, "read-only") {
|
||
|
|
t.Fatalf("a bound device was refused:\n%s", out)
|
||
|
|
}
|
||
|
|
entries, err := os.ReadDir(journalDir)
|
||
|
|
if err != nil || len(entries) == 0 {
|
||
|
|
t.Fatalf("no journal reached the hub (%v): the push did not land\n%s", err, out)
|
||
|
|
}
|
||
|
|
body, err := os.ReadFile(filepath.Join(journalDir, entries[0].Name()))
|
||
|
|
if err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
if !strings.Contains(string(body), "notes.md") {
|
||
|
|
t.Fatalf("the hub's journal does not carry the edit: %s", body)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// And the outage itself, so the fix cannot silently regress into it: a provider
|
||
|
|
// that ignores the binder produces exactly the reported symptom — a `write`
|
||
|
|
// member whose blobs upload and whose journal is refused, forever.
|
||
|
|
func TestCLIManagedProviderThatIgnoresTheBinderIsRefused(t *testing.T) {
|
||
|
|
out, journalDir := cliprovRun(t, false)
|
||
|
|
|
||
|
|
if !strings.Contains(out, "read-only") {
|
||
|
|
t.Fatalf("expected the documented refusal, got:\n%s", out)
|
||
|
|
}
|
||
|
|
if !strings.Contains(out, "not registered to your account") {
|
||
|
|
t.Fatalf("the refusal did not say why — that sentence is the only thing that "+
|
||
|
|
"tells this apart from a genuine read-only grant:\n%s", out)
|
||
|
|
}
|
||
|
|
if entries, err := os.ReadDir(journalDir); err == nil && len(entries) > 0 {
|
||
|
|
t.Fatalf("an unbound device wrote a journal to the hub: %v", entries)
|
||
|
|
}
|
||
|
|
}
|