Files
beardrive/internal/webapp/lifecycle_test.go
Snow LeeandClaude Fable 5 e022998c9f polish(hub): round-2 UX — close the gap to the usability bar
Admin: top-level ⚙ settings entry (owners/admins), signup form states the
domain restriction up front, invite list shows creator + join count,
org-wide share audit shows creator/date and confirms before revoke,
self-row role/remove controls disabled to avoid footguns.

User: mobile header no longer overflows (per-file actions move to the ⌘K
palette on narrow viewports; tables/pre scroll in their own container),
empty-state copy works on mobile, share confirmation is now an explicit
"anyone with this link can view" dialog with copy/open/revoke, invite
links carry a "you've been invited" banner through login, joining opens
the joined project, brand shown as the title, logout labeled, palette
placeholder clarified to "file names".

Tests: invite use-counter + creator in the owner list.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R7Q9ZKSZRTdvrSJkYLUmYs
2026-07-08 22:39:07 -07:00

160 lines
5.1 KiB
Go

package webapp
import (
"encoding/json"
"net/http"
"path/filepath"
"testing"
)
func TestOrgLifecycle(t *testing.T) {
db, _ := OpenOrgDB(filepath.Join(t.TempDir(), "orgs.json"))
o, _ := db.Create("acme", "alice@x.io")
db.AddMember(o.ID, "bob@x.io", RoleMember)
// rename
if err := db.Rename(o.ID, "Acme Inc"); err != nil {
t.Fatal(err)
}
if got, _ := db.Get(o.ID); got.Name != "Acme Inc" {
t.Fatalf("rename failed: %q", got.Name)
}
// promote bob, then the last-owner guard
if err := db.SetRole(o.ID, "bob@x.io", RoleOwner); err != nil {
t.Fatal(err)
}
if err := db.SetRole(o.ID, "alice@x.io", RoleMember); err != nil {
t.Fatal(err) // ok: bob is still an owner
}
if err := db.SetRole(o.ID, "bob@x.io", RoleMember); err == nil {
t.Fatal("demoting the last owner must be refused")
}
// remove member (bob is the only owner now)
if err := db.RemoveMember(o.ID, "alice@x.io"); err != nil {
t.Fatal(err)
}
if err := db.RemoveMember(o.ID, "bob@x.io"); err == nil {
t.Fatal("removing the last owner must be refused")
}
// invite revoke
inv, _ := db.CreateInvite(o.ID, "bob@x.io", 0)
if got := db.ListInvites(o.ID); len(got) != 1 {
t.Fatalf("invite list = %d", len(got))
}
if !db.RevokeInvite(inv.Token) {
t.Fatal("revoke returned false")
}
if _, ok := db.Redeem(inv.Token); ok {
t.Fatal("revoked invite still redeems")
}
}
func TestProjectLifecycle(t *testing.T) {
db, _ := OpenProjectDB(filepath.Join(t.TempDir(), "projects.json"))
p, _, _ := db.GetOrCreate("wiki", "o-1")
db.GetOrCreate("docs", "o-1")
if err := db.Rename(p.ID, "handbook"); err != nil {
t.Fatal(err)
}
if got, _ := db.Get(p.ID); got.Name != "handbook" {
t.Fatalf("rename: %q", got.Name)
}
// name collision within the org is refused
if err := db.Rename(p.ID, "docs"); err == nil {
t.Fatal("rename to an existing org-name must be refused")
}
if err := db.Delete(p.ID); err != nil {
t.Fatal(err)
}
if _, ok := db.Get(p.ID); ok {
t.Fatal("deleted project still present")
}
}
// Owner-only guards on the HTTP surface: a plain member is refused, an owner
// succeeds.
func TestAdminEndpointsOwnerOnly(t *testing.T) {
h, _, alice, bob, pa := orgHubSrv(t)
// bob is not even a member of alice's org → 403 on rename
if rec := doAs(t, h, "PATCH", "/api/orgs/"+pa.Org, map[string]string{"name": "x"}, bob); rec.Code != http.StatusForbidden {
t.Fatalf("non-member org rename: %d", rec.Code)
}
// alice (owner) can rename her org
if rec := doAs(t, h, "PATCH", "/api/orgs/"+pa.Org, map[string]string{"name": "Alice Co"}, alice); rec.Code != 200 {
t.Fatalf("owner org rename: %d %s", rec.Code, rec.Body)
}
// alice can rename her project
if rec := doAs(t, h, "PATCH", "/api/projects/"+pa.ID, map[string]string{"name": "notes"}, alice); rec.Code != 200 {
t.Fatalf("owner project rename: %d %s", rec.Code, rec.Body)
}
// bob cannot delete alice's project (not a member → 404, doesn't leak)
if rec := doAs(t, h, "DELETE", "/api/projects/"+pa.ID, nil, bob); rec.Code == 200 {
t.Fatal("non-member deleted a project")
}
// alice can delete it
if rec := doAs(t, h, "DELETE", "/api/projects/"+pa.ID, nil, alice); rec.Code != 200 {
t.Fatalf("owner project delete: %d %s", rec.Code, rec.Body)
}
}
// The invite→join→role→remove flow over HTTP, end to end.
func TestMemberManagementHTTP(t *testing.T) {
h, _, alice, bob, pa := orgHubSrv(t)
// invite bob and have him join
rec := doAs(t, h, "POST", "/api/orgs/"+pa.Org+"/invites", nil, alice)
var inv struct {
Token string `json:"token"`
}
mustJSON(t, rec, &inv)
if rec := doAs(t, h, "POST", "/api/invites/"+inv.Token, nil, bob); rec.Code != 200 {
t.Fatalf("bob join: %d %s", rec.Code, rec.Body)
}
// alice promotes bob to owner
if rec := doAs(t, h, "PATCH", "/api/orgs/"+pa.Org+"/members/bob@x.io", map[string]string{"role": "owner"}, alice); rec.Code != 200 {
t.Fatalf("promote: %d %s", rec.Code, rec.Body)
}
// alice removes bob
if rec := doAs(t, h, "DELETE", "/api/orgs/"+pa.Org+"/members/bob@x.io", nil, alice); rec.Code != 200 {
t.Fatalf("remove: %d %s", rec.Code, rec.Body)
}
// bob is out: his project list no longer shows it
rec = doAs(t, h, "GET", "/api/projects", nil, bob)
var list struct {
Projects []Project `json:"projects"`
}
json.Unmarshal(rec.Body.Bytes(), &list)
for _, p := range list.Projects {
if p.ID == pa.ID {
t.Fatal("removed member still sees the project")
}
}
}
// A joined invite bumps its use counter, visible in the owner's invite list.
func TestInviteUseCounter(t *testing.T) {
h, _, alice, bob, pa := orgHubSrv(t)
rec := doAs(t, h, "POST", "/api/orgs/"+pa.Org+"/invites", nil, alice)
var inv struct{ Token string }
mustJSON(t, rec, &inv)
doAs(t, h, "POST", "/api/invites/"+inv.Token, nil, bob)
rec = doAs(t, h, "GET", "/api/orgs/"+pa.Org+"/invites", nil, alice)
var out struct {
Invites []struct {
Token string `json:"token"`
Uses int `json:"uses"`
Creator string `json:"creator"`
} `json:"invites"`
}
mustJSON(t, rec, &out)
if len(out.Invites) != 1 || out.Invites[0].Uses != 1 {
t.Fatalf("invite uses = %+v, want 1 join recorded", out.Invites)
}
if out.Invites[0].Creator == "" {
t.Fatal("invite list should carry the creator")
}
}