Artifact 715fc37e2d445693aa19bb41848ff905398a0ce4448a2d7788a23f8b2a5bdd0c:
- File
hub/repo_test.go
— part of check-in
[a8e62ce82d]
at
2026-05-04 20:53:37
on branch feat/130-hub-setusercaps
— feat(hub): SetUserCaps + Config.NobodyCaps for unauth-clone hubs
Adds Repo.SetUserCaps(login, caps) and a Hub delegate, wrapping libfossil's Repo.SetCaps. Lets consumers grant or change caps on existing users without leaving the hub package — primarily for the common bones case where the libfossil-pre-populated 'nobody' user needs 'gio' to allow unauthenticated clone/pull/push.
Also adds Config.NobodyCaps so the typical setup is one declarative field instead of a manual SetUserCaps call after every NewHub. NobodyCaps is applied at bootstrap by calling SetUserCaps("nobody", v) internally — no method-body duplication. Empty NobodyCaps leaves nobody at whatever libfossil set it to (idempotent re-open path preserves prior caps, doesn't overwrite).
Tests: - TestRepo_SetUserCaps_RoundTrip — set + read back - TestRepo_SetUserCaps_RejectsEmptyLogin - TestNewHub_NobodyCaps_AppliedAtBootstrap — Config field works - TestNewHub_NobodyCaps_Idempotent — re-running with same caps OK - TestNewHub_NobodyCaps_EmptyDoesNotOverwrite — empty doesn't reset
Closes #130 (user: [email protected], size: 8109) [annotate] [blame] [check-ins using] [more...]
package hub import ( "bytes" "context" "path/filepath" "testing" ) // TestOpenRepo_RoundTrip exercises every *Repo method via OpenRepo on a // path that NewHub previously bootstrapped — proving CLI-style and // daemon-style code paths produce equivalent observable state. func TestOpenRepo_RoundTrip(t *testing.T) { dir := t.TempDir() repoPath := filepath.Join(dir, "hub.fossil") // Phase 1: bootstrap via NewHub, seed some state. h, err := NewHub(context.Background(), Config{RepoPath: repoPath}) if err != nil { t.Fatalf("NewHub: %v", err) } if _, err := h.Commit(context.Background(), CommitOpts{ Files: []FileToCommit{{Name: "seed.txt", Content: []byte("seed")}}, Message: "seed", Author: "hub", }); err != nil { t.Fatalf("Hub.Commit: %v", err) } if err := h.AddUser(User{Login: "alice", Caps: "admin"}); err != nil { t.Fatalf("Hub.AddUser: %v", err) } _ = h.Stop() // Phase 2: re-open via OpenRepo — same state should be visible. r, err := OpenRepo(repoPath) if err != nil { t.Fatalf("OpenRepo: %v", err) } t.Cleanup(func() { _ = r.Close() }) got, err := r.Read(context.Background(), "seed.txt") if err != nil { t.Fatalf("Repo.Read: %v", err) } if !bytes.Equal(got, []byte("seed")) { t.Errorf("Repo.Read seed.txt = %q, want %q", got, "seed") } if !r.HasUser("alice") { t.Error("Repo.HasUser(alice) = false after NewHub seeded") } user, err := r.GetUser("alice") if err != nil { t.Fatalf("Repo.GetUser: %v", err) } if user.Login != "alice" || user.Caps != "admin" { t.Errorf("Repo.GetUser = %+v, want {alice admin}", user) } users, err := r.ListUsers() if err != nil { t.Fatalf("Repo.ListUsers: %v", err) } if len(users) == 0 { t.Error("Repo.ListUsers returned empty") } // New commit + new user via the *Repo path. rev, err := r.Commit(context.Background(), CommitOpts{ Files: []FileToCommit{{Name: "from-repo.txt", Content: []byte("v")}}, Message: "from repo", Author: "alice", }) if err != nil { t.Fatalf("Repo.Commit: %v", err) } if rev == "" { t.Fatal("Repo.Commit returned empty RevID") } gotRev, err := r.ReadAt(context.Background(), rev, "from-repo.txt") if err != nil { t.Fatalf("Repo.ReadAt: %v", err) } if !bytes.Equal(gotRev, []byte("v")) { t.Errorf("Repo.ReadAt = %q, want %q", gotRev, "v") } if err := r.AddUser(User{Login: "bob", Caps: "ro"}); err != nil { t.Fatalf("Repo.AddUser: %v", err) } if err := r.RemoveUser("bob"); err != nil { t.Fatalf("Repo.RemoveUser: %v", err) } if r.HasUser("bob") { t.Error("HasUser(bob) = true after RemoveUser") } } func TestOpenRepo_RejectsEmptyPath(t *testing.T) { if _, err := OpenRepo(""); err == nil { t.Fatal("OpenRepo with empty path should error") } } func TestOpenRepo_RejectsMissingPath(t *testing.T) { if _, err := OpenRepo(filepath.Join(t.TempDir(), "does-not-exist.fossil")); err == nil { t.Fatal("OpenRepo with non-existent path should error") } } func TestRepo_Close_IsIdempotent(t *testing.T) { dir := t.TempDir() repoPath := filepath.Join(dir, "hub.fossil") // Bootstrap a fossil at this path so OpenRepo can succeed. h, err := NewHub(context.Background(), Config{RepoPath: repoPath}) if err != nil { t.Fatalf("NewHub: %v", err) } _ = h.Stop() r, err := OpenRepo(repoPath) if err != nil { t.Fatalf("OpenRepo: %v", err) } if err := r.Close(); err != nil { t.Fatalf("first Close: %v", err) } if err := r.Close(); err != nil { t.Fatalf("second Close: %v (want nil)", err) } } // TestRepo_SetUserCaps_RoundTrip exercises SetUserCaps against an existing // user. The 'nobody' user is libfossil-pre-populated with empty caps; // SetUserCaps replaces them. func TestRepo_SetUserCaps_RoundTrip(t *testing.T) { dir := t.TempDir() repoPath := filepath.Join(dir, "hub.fossil") h, err := NewHub(context.Background(), Config{RepoPath: repoPath}) if err != nil { t.Fatalf("NewHub: %v", err) } _ = h.Stop() r, err := OpenRepo(repoPath) if err != nil { t.Fatalf("OpenRepo: %v", err) } t.Cleanup(func() { _ = r.Close() }) if err := r.SetUserCaps("nobody", "gio"); err != nil { t.Fatalf("SetUserCaps: %v", err) } got, err := r.GetUser("nobody") if err != nil { t.Fatalf("GetUser nobody: %v", err) } if got.Caps != "gio" { t.Errorf("nobody.Caps = %q, want %q", got.Caps, "gio") } } func TestRepo_SetUserCaps_RejectsEmptyLogin(t *testing.T) { dir := t.TempDir() repoPath := filepath.Join(dir, "hub.fossil") h, err := NewHub(context.Background(), Config{RepoPath: repoPath}) if err != nil { t.Fatalf("NewHub: %v", err) } _ = h.Stop() r, err := OpenRepo(repoPath) if err != nil { t.Fatalf("OpenRepo: %v", err) } t.Cleanup(func() { _ = r.Close() }) if err := r.SetUserCaps("", "gio"); err == nil { t.Fatal("SetUserCaps with empty login should error") } } // TestNewHub_NobodyCaps_AppliedAtBootstrap proves Config.NobodyCaps is // applied during NewHub so unauthenticated request setup is one-line. func TestNewHub_NobodyCaps_AppliedAtBootstrap(t *testing.T) { dir := t.TempDir() repoPath := filepath.Join(dir, "hub.fossil") h, err := NewHub(context.Background(), Config{ RepoPath: repoPath, NobodyCaps: "gio", }) if err != nil { t.Fatalf("NewHub: %v", err) } t.Cleanup(func() { _ = h.Stop() }) got, err := h.GetUser("nobody") if err != nil { t.Fatalf("Hub.GetUser nobody: %v", err) } if got.Caps != "gio" { t.Errorf("nobody.Caps after bootstrap = %q, want %q", got.Caps, "gio") } } // TestNewHub_NobodyCaps_Idempotent proves calling NewHub twice with the // same NobodyCaps on an existing repo leaves the same end state. func TestNewHub_NobodyCaps_Idempotent(t *testing.T) { dir := t.TempDir() repoPath := filepath.Join(dir, "hub.fossil") h1, err := NewHub(context.Background(), Config{RepoPath: repoPath, NobodyCaps: "gio"}) if err != nil { t.Fatalf("NewHub phase 1: %v", err) } _ = h1.Stop() h2, err := NewHub(context.Background(), Config{RepoPath: repoPath, NobodyCaps: "gio"}) if err != nil { t.Fatalf("NewHub phase 2 (open): %v", err) } t.Cleanup(func() { _ = h2.Stop() }) got, err := h2.GetUser("nobody") if err != nil { t.Fatalf("GetUser: %v", err) } if got.Caps != "gio" { t.Errorf("nobody.Caps after re-open = %q, want %q", got.Caps, "gio") } } // TestNewHub_NobodyCaps_EmptyDoesNotOverwrite proves empty NobodyCaps // doesn't replace previously-set caps. Bootstrap with "gio", then re-open // with NobodyCaps unset — caps should still be "gio". func TestNewHub_NobodyCaps_EmptyDoesNotOverwrite(t *testing.T) { dir := t.TempDir() repoPath := filepath.Join(dir, "hub.fossil") h1, err := NewHub(context.Background(), Config{RepoPath: repoPath, NobodyCaps: "gio"}) if err != nil { t.Fatalf("NewHub phase 1: %v", err) } _ = h1.Stop() h2, err := NewHub(context.Background(), Config{RepoPath: repoPath /* NobodyCaps left empty */}) if err != nil { t.Fatalf("NewHub phase 2: %v", err) } t.Cleanup(func() { _ = h2.Stop() }) got, err := h2.GetUser("nobody") if err != nil { t.Fatalf("GetUser: %v", err) } if got.Caps != "gio" { t.Errorf("nobody.Caps after empty NobodyCaps = %q, want %q (no overwrite)", got.Caps, "gio") } } // TestHub_Repo_AccessorReturnsLiveHandle proves Hub.Repo() returns a *Repo // whose lifecycle is bound to the Hub. Operations against it observe the // same state as Hub.* methods. func TestHub_Repo_AccessorReturnsLiveHandle(t *testing.T) { dir := t.TempDir() repoPath := filepath.Join(dir, "hub.fossil") h, err := NewHub(context.Background(), Config{RepoPath: repoPath}) if err != nil { t.Fatalf("NewHub: %v", err) } t.Cleanup(func() { _ = h.Stop() }) if err := h.AddUser(User{Login: "alice"}); err != nil { t.Fatalf("Hub.AddUser: %v", err) } r := h.Repo() if r == nil { t.Fatal("Hub.Repo returned nil") } if !r.HasUser("alice") { t.Error("Repo.HasUser(alice) = false after Hub.AddUser") } // Adding via *Repo is observable via *Hub. if err := r.AddUser(User{Login: "bob"}); err != nil { t.Fatalf("Repo.AddUser: %v", err) } if !h.HasUser("bob") { t.Error("Hub.HasUser(bob) = false after Repo.AddUser") } }