Files
beardrive/internal/webapp/cli_e2e_test.go
T
fb6ce347c4 feat(daemon): survive a reboot — login autostart on macOS/Linux/Windows, and a lock instead of a pidfile (#88)
* feat(daemon): bring sync back after a reboot, and stop trusting the pidfile

A reboot killed every daemon and nothing restarted them. Agent hooks still
synced per turn, which is what made it easy to miss: a folder looked fine
while an agent worked in it and went stale the moment one didn't. `bdrive
init` now registers a login item (macOS: a user LaunchAgent) that runs the
new `bdrive resume` — one registration per machine, which starts a daemon for
every enrolled, unpaused mount, so adding a project later needs no
re-registration and `bdrive stop` still means stay stopped. `--no-autostart`
opts out, `bdrive autostart install|uninstall` manages it.

Writing the plist is the whole job: no `launchctl` shell-out. launchd loads
agents at login anyway, the caller has just started the daemon for this
session, and shelling out would let a test or a packaging script register a
real login item as a side effect.

The recovery path was also broken, which is why this is one change. Liveness
came from `kill(pid, 0)` on daemon.pid — but that file lives in
$BDRIVE_HOME and survives the reboot that killed its process, so any
same-user process recycling the pid read as a live daemon. `bdrive status`
said "running", and worse `daemon.Start` returned early, so the one
documented recovery (`bdrive init`) reported success and started nothing.
Liveness is now an flock held for the daemon's lifetime: the kernel drops it
at death or reboot, and it makes two daemons on one mount impossible. The pid
stays for display and for signalling.

internal/autostart is darwin-only today; autostart_other.go returns
ErrUnsupported and every caller already treats that as "nothing to do", so
Linux (systemd user unit) and Windows are one file each.

Tests: internal/daemon gets its first ones — a recycled pid must not read as
running (the exact regression), the lock decides liveness, a second holder is
refused. internal/autostart covers write/idempotency/stale-path-rewrite/
uninstall with HOME redirected, and lints the plist with plutil so launchd
can actually parse it. The CLI e2e asserts init registers the agent, that it
runs `resume`, that resume finds the live daemon instead of starting a
second, and that --no-autostart is silent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016aYntCWwdUhpzUfEk3ddyJ

* feat(autostart): Linux support — a systemd user unit alongside the launchd agent

Same three functions, same discipline. Linux writes
$XDG_CONFIG_HOME/systemd/user/beardrive.service (Type=oneshot, no Restart= —
`bdrive resume` exits by design) plus the default.target.wants symlink that
`systemctl --user enable` would create, because systemd ignores a unit
nothing wants. No `systemctl` shell-out, for the same reasons as launchctl:
the file is the registration, it only matters at the next login, and a
container or ssh session has no session bus to talk to.

Install declines with ErrUnsupported unless systemd is actually the init
system (/run/systemd/system, i.e. sd_booted) — on Alpine, WSL1 or a slim
container a unit file is inert decoration, and reporting "registered" would
be a lie. Installed() likewise requires the enable symlink, not just the
unit: a unit nothing wants never starts.

os.UserConfigDir honors XDG_CONFIG_HOME, so relocated config dirs work.
Windows is now the only gap; autostart_other.go is !darwin && !linux, and the
shared writeIfDifferent/selfPath moved into the tag-free autostart.go (darwin
now uses them too).

Tests run on Linux, not just compiled for it: cross-compiled test binaries
executed in a container, both with /run/systemd/system present (unit written,
enabled, idempotent, stale ExecStart rewritten, broken symlink repaired,
XDG honored, uninstall removes both) and without it (Install declines and
writes nothing). The daemon flock tests were run there too, since flock
semantics are per-OS.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016aYntCWwdUhpzUfEk3ddyJ

* feat(autostart): Windows support — a per-user Run entry

Third platform, same three functions. Windows has no user service manager in
the launchd/systemd sense, so the registration is a HKCU\...\Run value via
golang.org/x/sys/windows/registry (already in the module graph; go mod tidy
just promotes it to direct).

Chosen over the alternatives for the same reason the other two write files:
no admin rights, no COM (a Startup-folder .lnk needs it), no schtasks
shell-out. It is also honestly discoverable — the entry appears in Task
Manager's Startup tab, where someone can disable it without knowing bdrive
exists. The executable is quoted because Explorer parses the value as a
command line and Program Files has a space in it.

Two things a reader should not have to discover for themselves:

- The tests here have NEVER RUN. They are written and compile-checked
  (GOOS=windows go test -c) from macOS; there is no Windows host or usable
  container on an arm64 mac. They execute the first time the suite runs on
  Windows. They also cannot use a temp HOME the way the macOS and Linux tests
  do — HKCU is real — so each one snapshots and restores the previous value.
- `GOOS=windows go build ./...` still does not pass, and this package is not
  why: internal/store's Lock uses syscall.Flock and internal/daemon uses
  syscall.Kill and Setsid, all unix-only (true before this branch too). A
  Windows port means LockFileEx plus a stop story for a platform with no
  SIGTERM — a separate change, against the sync invariants, and untestable
  from here. So this code is correct and currently unreachable.

autostart_other.go is now !darwin && !linux && !windows (the BSDs).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016aYntCWwdUhpzUfEk3ddyJ

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 13:32:13 +09:00

584 lines
20 KiB
Go

package webapp
// CLI onboarding e2e: builds the real bdrive binary and drives the exact
// commands an agent (or the INSTALL_FOR_AGENTS.md runbook) runs against an
// in-process hub — device-code login, init, re-init, hooks, stop. This is
// the deterministic half of onboarding testing; the conversational half
// (does the agent ask before mounting?) lives in the onboarding-e2e skill.
//
// The key regression this guards: `bdrive init` must register agent sync
// hooks itself — a separate `bdrive hooks install` gets blocked by agent
// permission classifiers, which is how teams end up without hooks.
import (
"encoding/json"
"io"
"net/http"
"net/http/cookiejar"
"net/http/httptest"
"net/url"
"os"
"os/exec"
"path/filepath"
"regexp"
"runtime"
"strings"
"testing"
"time"
"github.com/runbear-io/beardrive/internal/remote"
)
// cliEnv is a signed-in CLI against a throwaway hub: the real binary, an
// isolated HOME/BDRIVE_HOME, and a browser session for hub-side assertions.
type cliEnv struct {
run func(dir string, args ...string) (string, error)
hub *httptest.Server
browser *http.Client
home string // the isolated HOME; hooks live under here now
}
func newCLIEnv(t *testing.T) cliEnv {
t.Helper()
if testing.Short() {
t.Skip("builds and execs the bdrive binary; skipped with -short")
}
bin := filepath.Join(t.TempDir(), "bdrive")
build := exec.Command("go", "build", "-o", bin, "github.com/runbear-io/beardrive/cmd/bdrive")
if out, err := build.CombinedOutput(); err != nil {
t.Fatalf("go build: %v\n%s", err, out)
}
hub := startTestHub(t)
// Isolate the CLI completely: fresh BDRIVE_HOME and a fresh HOME, so
// agent-platform detection can't see or touch the real ~/.codex etc.
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 = dir
cmd.Env = env
out, err := cmd.CombinedOutput()
return string(out), err
}
// Sign in via the real device-code flow, approved over HTTP as the
// runbook's "any signed-in browser" (a cookie session from /auth/login).
login := exec.Command(bin, "login", "--device", hub.URL)
login.Env = env
logFile := filepath.Join(t.TempDir(), "login.log")
f, err := os.Create(logFile)
if err != nil {
t.Fatal(err)
}
login.Stdout, login.Stderr = f, f
if err := login.Start(); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { login.Process.Kill() })
approve := waitForApprovalLink(t, logFile)
browser := signedInBrowser(t, hub.URL)
if _, err := browser.PostForm(approve, nil); err != nil {
t.Fatal(err)
}
if err := login.Wait(); err != nil {
out, _ := os.ReadFile(logFile)
t.Fatalf("login --device: %v\n%s", err, out)
}
return cliEnv{run: run, hub: hub, browser: browser, home: home}
}
func TestCLIOnboardingE2E(t *testing.T) {
e := newCLIEnv(t)
run, hub, browser := e.run, e.hub, e.browser
// --- Init in a folder where Claude Code is in use. Hooks must be
// registered by init itself, before the first sync output.
work := filepath.Join(t.TempDir(), "proj")
if err := os.MkdirAll(filepath.Join(work, ".claude"), 0o755); err != nil {
t.Fatal(err)
}
defer run(work, "stop", work) // don't leak the daemon on failure
out, err := run(work, "init", "--name", "cli-e2e", "--yes")
if err != nil {
t.Fatalf("init: %v\n%s", err, out)
}
if !strings.Contains(out, "claude") || !strings.Contains(out, "hooks registered") {
t.Fatalf("init did not report registering claude hooks:\n%s", out)
}
settings, err := os.ReadFile(filepath.Join(e.home, ".claude", "settings.json"))
if err != nil {
t.Fatalf("init did not write the user's .claude/settings.json: %v", err)
}
for _, want := range []string{"bdrive sync", "bdrive read-log"} {
if !strings.Contains(string(settings), want) {
t.Fatalf("user settings.json missing %q hook:\n%s", want, settings)
}
}
// Nothing agent-shaped may be created inside the project: it would sync.
assertNoProjectHookFiles(t, work)
// A reboot kills the daemon, so init registers the login agent that
// brings it back. It must land in the user's own LaunchAgents dir (this
// test's isolated HOME) and point at `bdrive resume`, which covers every
// mount rather than needing one registration per project.
if runtime.GOOS == "darwin" {
plist := filepath.Join(e.home, "Library", "LaunchAgents", "ai.beardrive.daemon.plist")
body, err := os.ReadFile(plist)
if err != nil {
t.Fatalf("init did not register the login agent: %v", err)
}
if !strings.Contains(string(body), "<string>resume</string>") {
t.Fatalf("login agent does not run `bdrive resume`:\n%s", body)
}
if out, err := run(work, "autostart"); err != nil || !strings.Contains(out, "registered") {
t.Fatalf("autostart status: %v\n%s", err, out)
}
}
// resume is idempotent against a live daemon — the login agent runs it on
// a machine where nothing is stopped, and must not start a second one.
out, err = run(work, "resume")
if err != nil || !strings.Contains(out, "already running 1") {
t.Fatalf("resume should have found the running daemon: %v\n%s", err, out)
}
// The hooks are the whole agent integration: init must not install a
// skill file anywhere, and no `skill` subcommand may come back.
for _, agent := range []string{"claude", "codex", "gemini", "hermes"} {
p := filepath.Join(e.home, "."+agent, "skills", "beardrive", "SKILL.md")
if _, err := os.Stat(p); err == nil {
t.Fatalf("init installed a skill at %s — the hooks are the integration now", p)
}
}
if out, err := run(work, "skill"); err == nil {
t.Fatalf("`bdrive skill` still exists:\n%s", out)
}
// The project must actually exist on the hub, created under the account.
if projects := hubProjects(t, browser, hub.URL); !strings.Contains(projects, "cli-e2e") {
t.Fatalf("hub project list missing cli-e2e: %s", projects)
}
// --- Re-running init resumes and converges hooks idempotently.
out, err = run(work, "init", "--yes")
if err != nil {
t.Fatalf("re-init: %v\n%s", err, out)
}
if !strings.Contains(out, "resuming") || !strings.Contains(out, "hooks already registered") {
t.Fatalf("re-init should resume with hooks already registered:\n%s", out)
}
if out, err = run(work, "hooks"); err != nil || !strings.Contains(out, "hooks registered") {
t.Fatalf("hooks status: %v\n%s", err, out)
}
if out, err = run(work, "stop", work); err != nil {
t.Fatalf("stop: %v\n%s", err, out)
}
cfg1, err := os.ReadFile(filepath.Join(work, ".bdrive", "config.json"))
if err != nil {
t.Fatal(err)
}
// --- A second mount on the same machine (the "add another folder" flow)
// must create a separate project and leave the first mount untouched.
// Also the opt-out: --no-hooks must leave the platform config alone.
work2 := filepath.Join(t.TempDir(), "proj2")
if err := os.MkdirAll(filepath.Join(work2, ".claude"), 0o755); err != nil {
t.Fatal(err)
}
defer run(work2, "stop", work2)
out, err = run(work2, "init", "--name", "cli-e2e-nohooks", "--yes", "--no-hooks", "--no-autostart")
if err != nil {
t.Fatalf("init --no-hooks: %v\n%s", err, out)
}
if _, err := os.Stat(filepath.Join(work2, ".claude", "settings.json")); !os.IsNotExist(err) {
t.Fatalf("--no-hooks still wrote .claude/settings.json (stat err: %v)", err)
}
if strings.Contains(out, "login:") {
t.Fatalf("--no-autostart still touched the login agent:\n%s", out)
}
if out, err = run(work2, "stop", work2); err != nil {
t.Fatalf("stop: %v\n%s", err, out)
}
cfg1b, err := os.ReadFile(filepath.Join(work, ".bdrive", "config.json"))
if err != nil || string(cfg1) != string(cfg1b) {
t.Fatalf("second init disturbed the first mount's config (err %v):\nbefore: %s\nafter: %s", err, cfg1, cfg1b)
}
projects := hubProjects(t, browser, hub.URL)
for _, want := range []string{"cli-e2e", "cli-e2e-nohooks"} {
if !strings.Contains(projects, want) {
t.Fatalf("hub missing project %q after second init: %s", want, projects)
}
}
}
// Two sibling folders under one parent, each synced to a DIFFERENT project —
// the shape a second project lands in on a machine that already syncs one.
// The mounts must stay fully independent: separate ids, separate content, and
// the first one's config untouched by the second's init.
func TestCLISiblingProjectMounts(t *testing.T) {
e := newCLIEnv(t)
run, hub, browser := e.run, e.hub, e.browser
parent := t.TempDir()
a, b := filepath.Join(parent, "a"), filepath.Join(parent, "b")
for dir, file := range map[string]string{a: "brand.md", b: "adr.md"} {
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, file), []byte("# "+file+"\n"), 0o644); err != nil {
t.Fatal(err)
}
}
if out, err := run(a, "init", "--name", "project-a", "--yes"); err != nil {
t.Fatalf("init a: %v\n%s", err, out)
}
defer run(a, "stop", a)
cfgA, err := os.ReadFile(filepath.Join(a, ".bdrive", "config.json"))
if err != nil {
t.Fatal(err)
}
if out, err := run(b, "init", "--name", "project-b", "--yes"); err != nil {
t.Fatalf("init b: %v\n%s", err, out)
}
defer run(b, "stop", b)
// The second init must not have touched the first mount.
cfgAafter, err := os.ReadFile(filepath.Join(a, ".bdrive", "config.json"))
if err != nil || string(cfgA) != string(cfgAafter) {
t.Fatalf("init in b/ changed a/'s config (err %v):\nbefore: %s\nafter: %s", err, cfgA, cfgAafter)
}
cfgB, err := os.ReadFile(filepath.Join(b, ".bdrive", "config.json"))
if err != nil {
t.Fatal(err)
}
if string(cfgA) == string(cfgB) {
t.Fatalf("sibling mounts share a config: %s", cfgA)
}
// Each project holds its own file and not the other's.
idA, idB := projectIDByName(t, browser, hub.URL, "project-a"), projectIDByName(t, browser, hub.URL, "project-b")
pathsA, pathsB := hubPaths(t, browser, hub.URL, idA), hubPaths(t, browser, hub.URL, idB)
if !pathsA["brand.md"] || pathsA["adr.md"] {
t.Fatalf("project-a content wrong: %v", pathsA)
}
if !pathsB["adr.md"] || pathsB["brand.md"] {
t.Fatalf("project-b content wrong: %v", pathsB)
}
}
// One project mounted from two folders on the SAME device. The remote journal
// key is per-device, so a second mount would restart the sequence and
// overwrite the first mount's ops — its files would vanish from the hub. Init
// must refuse; if it ever accepts again, the first mount's file must survive.
func TestCLISameProjectTwoMounts(t *testing.T) {
e := newCLIEnv(t)
run, hub, browser := e.run, e.hub, e.browser
first, second := filepath.Join(t.TempDir(), "first"), filepath.Join(t.TempDir(), "second")
if err := os.MkdirAll(first, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(first, "adr.md"), []byte("# ADR\n"), 0o644); err != nil {
t.Fatal(err)
}
if out, err := run(first, "init", "--name", "dup", "--yes"); err != nil {
t.Fatalf("init first: %v\n%s", err, out)
}
defer run(first, "stop", first)
id := projectIDByName(t, browser, hub.URL, "dup")
if err := os.MkdirAll(second, 0o755); err != nil {
t.Fatal(err)
}
out, err := run(second, "init", "--project", id, "--yes")
defer run(second, "stop", second)
if err != nil {
// The refusal must name the folder already holding the project.
if !strings.Contains(out, first) {
t.Fatalf("refusal should point at the existing mount %s:\n%s", first, out)
}
if config := filepath.Join(second, ".bdrive", "config.json"); fileExists(config) {
t.Fatalf("refused init still wrote %s", config)
}
return
}
if paths := hubPaths(t, browser, hub.URL, id); !paths["adr.md"] {
t.Fatalf("second mount erased the first mount's file from the hub: %v\n%s", paths, out)
}
}
// A mount below the session's directory (a repo root with wiki/ mounted) and
// one above it (a session inside the synced folder) must both sync: agent
// hooks run wherever the editor was opened, which is rarely the mount root.
func TestCLISyncResolvesMountFromAnyDirectory(t *testing.T) {
e := newCLIEnv(t)
run := e.run
repo := t.TempDir()
wiki := filepath.Join(repo, "wiki")
deep := filepath.Join(wiki, "notes")
for _, dir := range []string{deep, filepath.Join(repo, ".claude"), filepath.Join(wiki, ".claude")} {
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
}
if err := os.WriteFile(filepath.Join(deep, "a.md"), []byte("# A\n"), 0o644); err != nil {
t.Fatal(err)
}
// Run init the way an agent does: from the repo root, naming the subfolder.
if out, err := run(repo, "init", "wiki", "--name", "scoped", "--yes"); err != nil {
t.Fatalf("init wiki: %v\n%s", err, out)
}
defer run(wiki, "stop", wiki)
// From the repo root: the mount is below, found through the registry.
out, err := run(repo, "sync")
if err != nil || !strings.Contains(out, wiki) {
t.Fatalf("sync at the repo root missed the wiki mount: %v\n%s", err, out)
}
// From inside the mount: walk up to its root.
out, err = run(deep, "sync")
if err != nil || !strings.Contains(out, wiki) {
t.Fatalf("sync inside the mount did not resolve its root: %v\n%s", err, out)
}
// Hooks are user-level; the repo and the mount stay clean.
for _, dir := range []string{repo, wiki} {
assertNoProjectHookFiles(t, dir)
}
// Reaching the repo through a symlink must find the same mount: macOS
// hands sessions /tmp paths for mounts registered under /private/tmp.
link := filepath.Join(t.TempDir(), "link")
if err := os.Symlink(repo, link); err != nil {
t.Skipf("symlinks unavailable: %v", err)
}
if out, err := run(link, "sync"); err != nil || !strings.Contains(out, "project") {
t.Fatalf("sync through a symlinked repo root missed the mount: %v\n%s", err, out)
}
}
// Wherever init runs — inside the mount, at a repo root, anywhere — the hooks
// go to the user's config and the project tree is left clean. Platforms read
// hook config only from the directory a session starts in, so one user-level
// registration is what actually covers every session.
func TestCLIInitWritesNoProjectHookFiles(t *testing.T) {
e := newCLIEnv(t)
run := e.run
repo := t.TempDir()
wiki := filepath.Join(repo, "wiki")
for _, dir := range []string{filepath.Join(repo, ".git"), filepath.Join(repo, ".claude"), filepath.Join(wiki, ".claude")} {
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
}
// Pre-existing project hooks from an older version, plus a foreign hook.
stale := filepath.Join(repo, ".claude", "settings.json")
old := `{"hooks":{"UserPromptSubmit":[` +
`{"hooks":[{"type":"command","command":"sh -c 'bdrive sync .'"}]},` +
`{"hooks":[{"type":"command","command":"echo mine"}]}]}}`
if err := os.WriteFile(stale, []byte(old), 0o644); err != nil {
t.Fatal(err)
}
out, err := run(wiki, "init", "--name", "in-repo", "--yes")
if err != nil {
t.Fatalf("init: %v\n%s", err, out)
}
defer run(wiki, "stop", wiki)
if !fileExists(filepath.Join(e.home, ".claude", "settings.json")) {
t.Fatalf("hooks not registered in the user config:\n%s", out)
}
assertNoProjectHookFiles(t, wiki)
// The stale project config keeps the foreign hook but loses ours.
data, err := os.ReadFile(stale)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(data), "bdrive sync") {
t.Fatalf("stale project hooks survived: %s", data)
}
if !strings.Contains(string(data), "echo mine") {
t.Fatalf("migration removed a hook that was not ours: %s", data)
}
if !strings.Contains(out, "moved out of") {
t.Fatalf("init did not report the migration:\n%s", out)
}
}
// assertNoProjectHookFiles fails if BearDrive left agent config in a project.
func assertNoProjectHookFiles(t *testing.T, dir string) {
t.Helper()
for _, rel := range []string{
filepath.Join(".claude", "settings.json"),
filepath.Join(".codex", "hooks.json"),
filepath.Join(".gemini", "settings.json"),
} {
if fileExists(filepath.Join(dir, rel)) {
t.Fatalf("%s exists in the project — hooks must be user-level only", filepath.Join(dir, rel))
}
}
}
func fileExists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
// hubPaths is the set of paths a project's history knows about.
func hubPaths(t *testing.T, browser *http.Client, hubURL, projectID string) map[string]bool {
t.Helper()
resp, err := browser.Get(hubURL + "/api/p/" + projectID + "/history")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var body struct {
Entries []struct{ Path string } `json:"entries"`
}
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
t.Fatal(err)
}
paths := map[string]bool{}
for _, e := range body.Entries {
paths[e.Path] = true
}
return paths
}
// projectID looks a project up by name through the hub's own API.
func projectIDByName(t *testing.T, browser *http.Client, hubURL, name string) string {
t.Helper()
var body struct {
Projects []struct{ ID, Name string } `json:"projects"`
}
if err := json.Unmarshal([]byte(hubProjects(t, browser, hubURL)), &body); err != nil {
t.Fatal(err)
}
for _, p := range body.Projects {
if p.Name == name {
return p.ID
}
}
t.Fatalf("no project named %q on the hub", name)
return ""
}
// startTestHub serves a minimal hub (auth + orgs + projects + store proxy)
// on an ephemeral port, mirroring TestE2EServe's wiring without the seeds.
func startTestHub(t *testing.T) *httptest.Server {
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)
}
srv := &Server{Root: be, Projects: db, Device: webDevice, Upload: UploadConfig{Enabled: true}}
srv.Devices, _ = OpenDeviceRegistry(filepath.Join(state, "devices.json"))
auth, err := OpenBuiltinAuth(filepath.Join(state, "auth.json"), false, nil)
if err != nil {
t.Fatal(err)
}
if _, err := auth.signup(e2eAdmin, "E2E Admin", e2ePassword); err != nil {
t.Fatal(err)
}
srv.Auth = auth
orgs, err := OpenOrgDB(filepath.Join(state, "orgs.json"))
if err != nil {
t.Fatal(err)
}
if _, err := orgs.Create("default", e2eAdmin); err != nil {
t.Fatal(err)
}
srv.Dir = LocalDirectory{OrgDB: orgs}
ts := httptest.NewServer(srv.Handler())
t.Cleanup(ts.Close)
return ts
}
// signedInBrowser returns an http client holding a hub session cookie for
// the admin account — the "any signed-in browser" of the device flow.
func signedInBrowser(t *testing.T, hubURL string) *http.Client {
t.Helper()
jar, _ := cookiejar.New(nil)
c := &http.Client{Jar: jar, Timeout: 10 * time.Second}
resp, err := c.PostForm(hubURL+"/auth/login", url.Values{
"email": {e2eAdmin}, "password": {e2ePassword},
})
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if len(jar.Cookies(mustParse(t, hubURL))) == 0 {
t.Fatal("browser sign-in left no session cookie")
}
return c
}
func hubProjects(t *testing.T, browser *http.Client, hubURL string) string {
t.Helper()
resp, err := browser.Get(hubURL + "/api/projects")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatal(err)
}
return string(data)
}
var approveRe = regexp.MustCompile(`(https?://\S+/auth/device/[a-f0-9]+)`)
// waitForCode polls the login command's output for the device code it prints.
func waitForApprovalLink(t *testing.T, logFile string) string {
t.Helper()
deadline := time.Now().Add(15 * time.Second)
for time.Now().Before(deadline) {
data, _ := os.ReadFile(logFile)
if m := approveRe.FindSubmatch(data); m != nil {
return string(m[1])
}
time.Sleep(100 * time.Millisecond)
}
data, _ := os.ReadFile(logFile)
t.Fatalf("login --device never printed an approval link:\n%s", data)
return ""
}
func envWithout(names ...string) []string {
var out []string
Env:
for _, kv := range os.Environ() {
for _, n := range names {
if strings.HasPrefix(kv, n+"=") {
continue Env
}
}
out = append(out, kv)
}
return out
}
func mustParse(t *testing.T, raw string) *url.URL {
t.Helper()
u, err := url.Parse(raw)
if err != nil {
t.Fatal(err)
}
return u
}