Files
beardrive/internal/webapp/cli_provider_e2e_test.go
T

219 lines
7.7 KiB
Go
Raw Normal View History

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)
}
}