- domain: Garden gains a computed MyRole ("owner"/"editor"/"viewer"); new
ShareWithUser (share + recipient identity); sentinels ErrShareUserNotFound,
ErrCannotShareWithSelf, ErrShareExists.
- store/shares.go: GetShareRole, ListSharesForGarden (joined with users),
CreateShare (UNIQUE → ErrShareExists), UpdateShareRole, DeleteShare.
- store/gardens.go: ListGardensForActor returns owned + shared-with-me gardens,
each carrying my_role (replaces the owner-only list).
- service: requireGardenRole now consults garden_shares (owner implicit, else the
share's role, else masked ErrNotFound) and stamps MyRole on every read-through.
UpdateGarden tightened to OWNER-only (editors edit contents, not the garden).
UpdatePlanting only re-checks plant visibility when the plant is CHANGED, so a
shared editor can edit a plop that uses the owner's private plant.
- service/shares.go: ListShares/AddShare/UpdateShareRole (owner only), RemoveShare
(owner or a recipient leaving). AddShare targets an existing account by exact
email; unknown → 404, self → 400, duplicate → 409.
- api: GET,POST /gardens/:id/shares and PATCH,DELETE /gardens/:id/shares/:userId.
Tests: the full {owner, editor, viewer, stranger} × {read, mutate object, mutate
planting, edit garden, delete garden, manage shares} ACL matrix; shared gardens
carry my_role; AddShare error cases; role upgrade + self-leave; plus a two-user
HTTP flow. The matrix caught the editor-cannot-edit-owner's-plant bug above.
GOWORK=off go build/vet/test ./internal/... green.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi
206 lines
8.2 KiB
Go
206 lines
8.2 KiB
Go
package service
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"testing"
|
||
|
||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
|
||
)
|
||
|
||
// wantErr asserts err matches want (nil = expected success, else errors.Is).
|
||
func wantErr(t *testing.T, name string, err, want error) {
|
||
t.Helper()
|
||
if want == nil {
|
||
if err != nil {
|
||
t.Errorf("%s: got %v, want success", name, err)
|
||
}
|
||
return
|
||
}
|
||
if !errors.Is(err, want) {
|
||
t.Errorf("%s: got %v, want %v", name, err, want)
|
||
}
|
||
}
|
||
|
||
// TestGardenACLMatrix is the authoritative {owner, editor, viewer, stranger} ×
|
||
// {read, mutate object, mutate planting, edit garden, delete garden, manage
|
||
// shares} table. Read the columns as: nil = allowed, ErrForbidden = visible but
|
||
// insufficient, ErrNotFound = existence masked.
|
||
func TestGardenACLMatrix(t *testing.T) {
|
||
ctx := context.Background()
|
||
s := newTestService(t, openConfig())
|
||
owner := seedUser(t, s, "[email protected]")
|
||
editor := seedUser(t, s, "[email protected]")
|
||
viewer := seedUser(t, s, "[email protected]")
|
||
stranger := seedUser(t, s, "[email protected]")
|
||
|
||
g := seedGarden(t, s, owner)
|
||
bed := seedBed(t, s, owner, g.ID)
|
||
plant := seedOwnPlant(t, s, owner, 10)
|
||
plop, err := s.CreatePlanting(ctx, owner, bed.ID, PlantingInput{PlantID: plant.ID, XCM: 0, YCM: 0, RadiusCM: 10})
|
||
if err != nil {
|
||
t.Fatalf("seed plop: %v", err)
|
||
}
|
||
|
||
if _, err := s.AddShare(ctx, owner, g.ID, "[email protected]", domain.RoleEditor); err != nil {
|
||
t.Fatalf("share editor: %v", err)
|
||
}
|
||
if _, err := s.AddShare(ctx, owner, g.ID, "[email protected]", domain.RoleViewer); err != nil {
|
||
t.Fatalf("share viewer: %v", err)
|
||
}
|
||
|
||
// read /full — viewer and up; stranger masked.
|
||
for _, tc := range []struct {
|
||
actor int64
|
||
want error
|
||
}{{owner, nil}, {editor, nil}, {viewer, nil}, {stranger, domain.ErrNotFound}} {
|
||
_, err := s.GardenFull(ctx, tc.actor, g.ID)
|
||
wantErr(t, "readFull", err, tc.want)
|
||
}
|
||
|
||
// mutate object — editor and up. Failure actors fail the role check before any
|
||
// version check, so a dummy version is fine; success actors need the live one.
|
||
x := 300.0
|
||
_, ev := s.UpdateObject(ctx, viewer, bed.ID, ObjectPatch{XCM: &x}, 1)
|
||
wantErr(t, "mutObject/viewer", ev, domain.ErrForbidden)
|
||
_, es := s.UpdateObject(ctx, stranger, bed.ID, ObjectPatch{XCM: &x}, 1)
|
||
wantErr(t, "mutObject/stranger", es, domain.ErrNotFound)
|
||
cur, _ := s.store.GetObject(ctx, bed.ID)
|
||
_, ee := s.UpdateObject(ctx, editor, bed.ID, ObjectPatch{XCM: &x}, cur.Version)
|
||
wantErr(t, "mutObject/editor", ee, nil)
|
||
cur, _ = s.store.GetObject(ctx, bed.ID)
|
||
_, eo := s.UpdateObject(ctx, owner, bed.ID, ObjectPatch{YCM: &x}, cur.Version)
|
||
wantErr(t, "mutObject/owner", eo, nil)
|
||
|
||
// mutate planting — editor and up.
|
||
nr := 12.0
|
||
_, pv := s.UpdatePlanting(ctx, viewer, plop.ID, PlantingPatch{RadiusCM: &nr}, 1)
|
||
wantErr(t, "mutPlop/viewer", pv, domain.ErrForbidden)
|
||
_, ps := s.UpdatePlanting(ctx, stranger, plop.ID, PlantingPatch{RadiusCM: &nr}, 1)
|
||
wantErr(t, "mutPlop/stranger", ps, domain.ErrNotFound)
|
||
curp, _ := s.store.GetPlanting(ctx, plop.ID)
|
||
_, pe := s.UpdatePlanting(ctx, editor, plop.ID, PlantingPatch{RadiusCM: &nr}, curp.Version)
|
||
wantErr(t, "mutPlop/editor", pe, nil)
|
||
|
||
// edit garden meta — OWNER only (editors edit contents, not the garden).
|
||
edit := func(actor, version int64) error {
|
||
_, err := s.UpdateGarden(ctx, actor, g.ID, GardenInput{Name: "Renamed", WidthCM: 1000, HeightCM: 1000, UnitPref: "metric"}, version)
|
||
return err
|
||
}
|
||
wantErr(t, "editGarden/editor", edit(editor, 1), domain.ErrForbidden)
|
||
wantErr(t, "editGarden/viewer", edit(viewer, 1), domain.ErrForbidden)
|
||
wantErr(t, "editGarden/stranger", edit(stranger, 1), domain.ErrNotFound)
|
||
curg, _ := s.GetGarden(ctx, owner, g.ID)
|
||
wantErr(t, "editGarden/owner", edit(owner, curg.Version), nil)
|
||
|
||
// manage shares (list) — OWNER only.
|
||
for _, tc := range []struct {
|
||
actor int64
|
||
want error
|
||
}{{editor, domain.ErrForbidden}, {viewer, domain.ErrForbidden}, {stranger, domain.ErrNotFound}, {owner, nil}} {
|
||
_, err := s.ListShares(ctx, tc.actor, g.ID)
|
||
wantErr(t, "listShares", err, tc.want)
|
||
}
|
||
|
||
// delete garden — OWNER only. Non-owners are refused (garden survives).
|
||
wantErr(t, "delGarden/editor", s.DeleteGarden(ctx, editor, g.ID), domain.ErrForbidden)
|
||
wantErr(t, "delGarden/viewer", s.DeleteGarden(ctx, viewer, g.ID), domain.ErrForbidden)
|
||
wantErr(t, "delGarden/stranger", s.DeleteGarden(ctx, stranger, g.ID), domain.ErrNotFound)
|
||
wantErr(t, "delGarden/owner", s.DeleteGarden(ctx, owner, g.ID), nil)
|
||
}
|
||
|
||
func TestListGardensIncludesSharedWithRole(t *testing.T) {
|
||
ctx := context.Background()
|
||
s := newTestService(t, openConfig())
|
||
owner := seedUser(t, s, "[email protected]")
|
||
other := seedUser(t, s, "[email protected]")
|
||
g := seedGarden(t, s, owner)
|
||
|
||
// Owner sees it as "owner".
|
||
own, _ := s.ListGardens(ctx, owner)
|
||
if len(own) != 1 || own[0].MyRole != "owner" {
|
||
t.Fatalf("owner list = %+v, want one garden with myRole owner", own)
|
||
}
|
||
// other sees nothing yet.
|
||
if list, _ := s.ListGardens(ctx, other); len(list) != 0 {
|
||
t.Fatalf("other list = %d, want 0 before sharing", len(list))
|
||
}
|
||
// Share as viewer → other sees it with myRole viewer.
|
||
if _, err := s.AddShare(ctx, owner, g.ID, "[email protected]", domain.RoleViewer); err != nil {
|
||
t.Fatalf("share: %v", err)
|
||
}
|
||
list, _ := s.ListGardens(ctx, other)
|
||
if len(list) != 1 || list[0].MyRole != domain.RoleViewer || list[0].ID != g.ID {
|
||
t.Fatalf("other list after share = %+v, want the garden with myRole viewer", list)
|
||
}
|
||
}
|
||
|
||
func TestAddShareErrors(t *testing.T) {
|
||
ctx := context.Background()
|
||
s := newTestService(t, openConfig())
|
||
owner := seedUser(t, s, "[email protected]")
|
||
seedUser(t, s, "[email protected]")
|
||
g := seedGarden(t, s, owner)
|
||
|
||
// Unknown email → ErrShareUserNotFound.
|
||
if _, err := s.AddShare(ctx, owner, g.ID, "[email protected]", domain.RoleViewer); !errors.Is(err, domain.ErrShareUserNotFound) {
|
||
t.Errorf("unknown email err = %v, want ErrShareUserNotFound", err)
|
||
}
|
||
// Self → ErrCannotShareWithSelf.
|
||
if _, err := s.AddShare(ctx, owner, g.ID, "[email protected]", domain.RoleViewer); !errors.Is(err, domain.ErrCannotShareWithSelf) {
|
||
t.Errorf("self-share err = %v, want ErrCannotShareWithSelf", err)
|
||
}
|
||
// Bad role → ErrInvalidInput.
|
||
if _, err := s.AddShare(ctx, owner, g.ID, "[email protected]", "admin"); !errors.Is(err, domain.ErrInvalidInput) {
|
||
t.Errorf("bad role err = %v, want ErrInvalidInput", err)
|
||
}
|
||
// First share ok; a second to the same user → ErrShareExists.
|
||
if _, err := s.AddShare(ctx, owner, g.ID, "[email protected]", domain.RoleViewer); err != nil {
|
||
t.Fatalf("first share: %v", err)
|
||
}
|
||
if _, err := s.AddShare(ctx, owner, g.ID, "[email protected]", domain.RoleEditor); !errors.Is(err, domain.ErrShareExists) {
|
||
t.Errorf("double-share err = %v, want ErrShareExists", err)
|
||
}
|
||
}
|
||
|
||
func TestUpdateAndRemoveShare(t *testing.T) {
|
||
ctx := context.Background()
|
||
s := newTestService(t, openConfig())
|
||
owner := seedUser(t, s, "[email protected]")
|
||
friend := seedUser(t, s, "[email protected]")
|
||
g := seedGarden(t, s, owner)
|
||
if _, err := s.AddShare(ctx, owner, g.ID, "[email protected]", domain.RoleViewer); err != nil {
|
||
t.Fatalf("share: %v", err)
|
||
}
|
||
|
||
// Upgrade to editor.
|
||
up, err := s.UpdateShareRole(ctx, owner, g.ID, friend, domain.RoleEditor)
|
||
if err != nil || up.Role != domain.RoleEditor {
|
||
t.Fatalf("upgrade role = %+v, %v", up, err)
|
||
}
|
||
// The recipient can now mutate but still can't manage shares.
|
||
bed := seedBed(t, s, owner, g.ID)
|
||
nx := 400.0
|
||
cur, _ := s.store.GetObject(ctx, bed.ID)
|
||
if _, err := s.UpdateObject(ctx, friend, bed.ID, ObjectPatch{XCM: &nx}, cur.Version); err != nil {
|
||
t.Errorf("editor should move an object: %v", err)
|
||
}
|
||
if _, err := s.ListShares(ctx, friend, g.ID); !errors.Is(err, domain.ErrForbidden) {
|
||
t.Errorf("editor listing shares = %v, want ErrForbidden", err)
|
||
}
|
||
|
||
// A recipient can remove themselves ("leave garden").
|
||
if err := s.RemoveShare(ctx, friend, g.ID, friend); err != nil {
|
||
t.Errorf("self-leave: %v", err)
|
||
}
|
||
// After leaving, the garden is invisible again.
|
||
if _, err := s.GardenFull(ctx, friend, g.ID); !errors.Is(err, domain.ErrNotFound) {
|
||
t.Errorf("after leaving, read = %v, want ErrNotFound", err)
|
||
}
|
||
// A non-participant can't remove someone else's share.
|
||
stranger := seedUser(t, s, "[email protected]")
|
||
if err := s.RemoveShare(ctx, stranger, g.ID, owner); !errors.Is(err, domain.ErrNotFound) {
|
||
t.Errorf("stranger remove = %v, want ErrNotFound", err)
|
||
}
|
||
}
|