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, "owner@example.com") editor := seedUser(t, s, "editor@example.com") viewer := seedUser(t, s, "viewer@example.com") stranger := seedUser(t, s, "stranger@example.com") 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, "editor@example.com", domain.RoleEditor); err != nil { t.Fatalf("share editor: %v", err) } if _, err := s.AddShare(ctx, owner, g.ID, "viewer@example.com", 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, nil) 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, "owner@example.com") other := seedUser(t, s, "other@example.com") g := seedGarden(t, s, owner) // Owner sees it as "owner". own, _ := s.ListGardens(ctx, owner) if len(own) != 1 || own[0].MyRole != domain.RoleOwner { 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, "other@example.com", 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, "owner@example.com") seedUser(t, s, "friend@example.com") g := seedGarden(t, s, owner) // Unknown email → ErrShareUserNotFound. if _, err := s.AddShare(ctx, owner, g.ID, "nobody@example.com", 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, "owner@example.com", 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, "friend@example.com", "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, "friend@example.com", domain.RoleViewer); err != nil { t.Fatalf("first share: %v", err) } if _, err := s.AddShare(ctx, owner, g.ID, "friend@example.com", 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, "owner@example.com") friend := seedUser(t, s, "friend@example.com") g := seedGarden(t, s, owner) if _, err := s.AddShare(ctx, owner, g.ID, "friend@example.com", 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, nil); !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, "stranger@example.com") if err := s.RemoveShare(ctx, stranger, g.ID, owner); !errors.Is(err, domain.ErrNotFound) { t.Errorf("stranger remove = %v, want ErrNotFound", err) } }