mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
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
160 lines
5.1 KiB
Go
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")
|
|
}
|
|
}
|