mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
Project gains two optional fields — Description (<=280 chars) and Icon (a
lucide icon name) — and PATCH /api/projects/{id} becomes a real partial
update: every field is a *string, so only the keys present in the body
change, and {"description":""} clears where an omitted key leaves alone.
Validation returns 400 for an empty or >120-char name, a sibling-name
collision, a >280-char description, and an icon failing ^[a-z0-9-]{1,32}$.
The permission gate is deliberately untouched.
Storage: the file backend marshals Project whole, so it rides along; the SQL
backend needs the two columns added to an already-created table, which
CREATE TABLE IF NOT EXISTS can't do — hence addColumns(), an idempotent
ALTER helper (same shape BEA-2 introduces for creator/default_level, so the
two merge into one map).
Frontend: Settings is now shadcn sectioned cards (General / About / Danger
zone — adds card, separator, textarea to components/ui), with an RHF+zod
form that PATCHes only its dirty keys and refreshes the hub queries, so the
nav mark and dashboard header update without a reload. Icons come from a
curated ~30-icon lucide shortlist (named imports, so Vite still tree-shakes
the rest); an unknown or empty name renders the folder placeholder. The
glyph shows in the project mark on the switcher trigger and every menu row,
and beside the name on the dashboard header with the description under it.
The org admin panel loses its per-project Rename button, which collapses its
two project lists into one read-only list for everybody.
One fix found while driving the real UI: Tailwind preflight is off in this
app, so copied shadcn form controls rendered monospace/black and cards drew
a near-white hairline. Both are now supplied by slot in style.css.
277 lines
9.3 KiB
Go
277 lines
9.3 KiB
Go
package webapp
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"path/filepath"
|
|
"strings"
|
|
"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")
|
|
}
|
|
|
|
// Partial update: only the fields you pass move.
|
|
ptr := func(s string) *string { return &s }
|
|
if err := db.Update(p.ID, nil, ptr("the team handbook"), ptr("book-open")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got, _ := db.Get(p.ID)
|
|
if got.Name != "handbook" || got.Description != "the team handbook" || got.Icon != "book-open" {
|
|
t.Fatalf("update: %+v", got)
|
|
}
|
|
// icon-only update leaves name and description alone
|
|
if err := db.Update(p.ID, nil, nil, ptr("users")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got, _ = db.Get(p.ID); got.Name != "handbook" || got.Description != "the team handbook" || got.Icon != "users" {
|
|
t.Fatalf("icon-only update: %+v", got)
|
|
}
|
|
// present-and-empty clears; absent does not
|
|
if err := db.Update(p.ID, nil, ptr(""), ptr("")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got, _ = db.Get(p.ID); got.Description != "" || got.Icon != "" || got.Name != "handbook" {
|
|
t.Fatalf("clear: %+v", got)
|
|
}
|
|
|
|
for _, tc := range []struct {
|
|
what string
|
|
name, desc, icon *string
|
|
}{
|
|
{"empty name", ptr(" "), nil, nil},
|
|
{"name over 120", ptr(strings.Repeat("x", 121)), nil, nil},
|
|
{"sibling collision", ptr("docs"), nil, nil},
|
|
{"description over 280", nil, ptr(strings.Repeat("d", 281)), nil},
|
|
{"icon uppercase", nil, nil, ptr("Folder")},
|
|
{"icon with space", nil, nil, ptr("a b")},
|
|
{"icon over 32", nil, nil, ptr(strings.Repeat("a", 33))},
|
|
} {
|
|
if err := db.Update(p.ID, tc.name, tc.desc, tc.icon); err == nil {
|
|
t.Fatalf("%s: expected an error", tc.what)
|
|
}
|
|
}
|
|
// a rejected update leaves the record untouched
|
|
if got, _ = db.Get(p.ID); got.Name != "handbook" || got.Description != "" || got.Icon != "" {
|
|
t.Fatalf("rejected updates mutated the project: %+v", got)
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
// PATCH /api/projects/{id} is a partial update: only the keys in the body
|
|
// move, present-and-empty clears, and every validation failure is a 400. The
|
|
// permission gate is unchanged — a non-member still gets what it got before.
|
|
func TestProjectUpdatePartial(t *testing.T) {
|
|
h, srv, alice, bob, pa := orgHubSrv(t)
|
|
|
|
patch := func(body string, c *http.Cookie) int {
|
|
return doAs(t, h, "PATCH", "/api/projects/"+pa.ID, []byte(body), c).Code
|
|
}
|
|
get := func() Project {
|
|
t.Helper()
|
|
p, ok := srv.Projects.Get(pa.ID)
|
|
if !ok {
|
|
t.Fatal("project vanished")
|
|
}
|
|
return p
|
|
}
|
|
|
|
if code := patch(`{"name":"notes"}`, alice); code != 200 {
|
|
t.Fatalf("rename: %d", code)
|
|
}
|
|
if p := get(); p.Name != "notes" || p.Description != "" || p.Icon != "" {
|
|
t.Fatalf("name-only patch touched other fields: %+v", p)
|
|
}
|
|
if code := patch(`{"icon":"book-open"}`, alice); code != 200 {
|
|
t.Fatalf("icon: %d", code)
|
|
}
|
|
if p := get(); p.Name != "notes" || p.Icon != "book-open" {
|
|
t.Fatalf("icon-only patch: %+v", p)
|
|
}
|
|
if code := patch(`{"description":"what support reads"}`, alice); code != 200 {
|
|
t.Fatalf("description: %d", code)
|
|
}
|
|
if p := get(); p.Description != "what support reads" || p.Icon != "book-open" {
|
|
t.Fatalf("description-only patch: %+v", p)
|
|
}
|
|
if code := patch(`{"description":""}`, alice); code != 200 {
|
|
t.Fatalf("clear description: %d", code)
|
|
}
|
|
if p := get(); p.Description != "" || p.Icon != "book-open" || p.Name != "notes" {
|
|
t.Fatalf("clearing description touched other fields: %+v", p)
|
|
}
|
|
|
|
// alice's org gets a sibling so the collision rule has something to hit
|
|
if rec := doAs(t, h, "POST", "/api/projects", map[string]string{"name": "docs"}, alice); rec.Code != 200 {
|
|
t.Fatalf("create sibling: %d %s", rec.Code, rec.Body)
|
|
}
|
|
|
|
for _, tc := range []struct{ what, body string }{
|
|
{"empty name", `{"name":""}`},
|
|
{"long name", `{"name":"` + strings.Repeat("x", 121) + `"}`},
|
|
{"sibling collision", `{"name":"docs"}`},
|
|
{"long description", `{"description":"` + strings.Repeat("d", 281) + `"}`},
|
|
{"bad icon", `{"icon":"BookOpen"}`},
|
|
} {
|
|
if code := patch(tc.body, alice); code != http.StatusBadRequest {
|
|
t.Fatalf("%s: got %d, want 400", tc.what, code)
|
|
}
|
|
}
|
|
// and none of those touched the record
|
|
if p := get(); p.Name != "notes" || p.Description != "" || p.Icon != "book-open" {
|
|
t.Fatalf("rejected patches mutated the project: %+v", p)
|
|
}
|
|
|
|
// gate unchanged: a non-member gets 404 (the project doesn't exist for him)
|
|
if code := patch(`{"icon":"users"}`, bob); code == 200 {
|
|
t.Fatal("non-member updated a project")
|
|
}
|
|
}
|
|
|
|
// 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")
|
|
}
|
|
}
|