Build image / build-and-push (push) Successful in 15s
- Security (2-model, security+correctness): the public payload zeroed Garden.OwnerID but referenced custom plants still carried Plant.OwnerID, leaking the owner's user id to anonymous viewers. Redact plant owner ids too, and assert it in the service test. - Set Cache-Control: no-store on the public read so a capability-URL response isn't held in a shared proxy cache and always reflects the live garden. - createShareLink now 400s on a present-but-malformed JSON body instead of silently enabling (an empty body still means enable-without-rotate). - Extract the transient-editor-state reset into a shared resetTransient store action (3-model finding) and use it from PublicGardenPage. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi
132 lines
4.6 KiB
Go
132 lines
4.6 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"testing"
|
|
|
|
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
|
|
)
|
|
|
|
// TestPublicShareLink covers the owner-only link lifecycle (enable / idempotent
|
|
// re-enable / rotate / disable) and the unauthenticated read it gates: a valid
|
|
// token returns the read-only /full payload with no role, an unknown/blank/
|
|
// rotated/disabled token is masked as ErrNotFound, and non-owners can't manage
|
|
// the link.
|
|
func TestPublicShareLink(t *testing.T) {
|
|
ctx := context.Background()
|
|
s := newTestService(t, openConfig())
|
|
owner := seedUser(t, s, "[email protected]")
|
|
editor := 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)
|
|
if _, err := s.CreatePlanting(ctx, owner, bed.ID, PlantingInput{PlantID: plant.ID, XCM: 0, YCM: 0, RadiusCM: 10}); 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)
|
|
}
|
|
|
|
// Disabled by default.
|
|
link, err := s.GetPublicShareLink(ctx, owner, g.ID)
|
|
if err != nil {
|
|
t.Fatalf("get link: %v", err)
|
|
}
|
|
if link.Enabled {
|
|
t.Fatalf("a new garden should have no public link")
|
|
}
|
|
|
|
// Non-owners can neither read nor manage the link. A shared editor sees the
|
|
// garden (ErrForbidden); a stranger's view is masked (ErrNotFound).
|
|
if _, err := s.GetPublicShareLink(ctx, editor, g.ID); !errors.Is(err, domain.ErrForbidden) {
|
|
t.Errorf("editor get link = %v, want ErrForbidden", err)
|
|
}
|
|
if _, err := s.EnablePublicShareLink(ctx, editor, g.ID, false); !errors.Is(err, domain.ErrForbidden) {
|
|
t.Errorf("editor enable = %v, want ErrForbidden", err)
|
|
}
|
|
if _, err := s.EnablePublicShareLink(ctx, stranger, g.ID, false); !errors.Is(err, domain.ErrNotFound) {
|
|
t.Errorf("stranger enable = %v, want ErrNotFound", err)
|
|
}
|
|
|
|
// Owner enables → a token; the public read works with no actor.
|
|
link, err = s.EnablePublicShareLink(ctx, owner, g.ID, false)
|
|
if err != nil {
|
|
t.Fatalf("enable: %v", err)
|
|
}
|
|
if !link.Enabled || link.Token == "" {
|
|
t.Fatalf("enable returned %+v, want enabled with a token", link)
|
|
}
|
|
tok := link.Token
|
|
|
|
// Re-enabling without rotate keeps the same token (idempotent).
|
|
again, err := s.EnablePublicShareLink(ctx, owner, g.ID, false)
|
|
if err != nil {
|
|
t.Fatalf("re-enable: %v", err)
|
|
}
|
|
if again.Token != tok {
|
|
t.Errorf("re-enable changed the token: %q -> %q", tok, again.Token)
|
|
}
|
|
|
|
full, err := s.PublicGarden(ctx, tok)
|
|
if err != nil {
|
|
t.Fatalf("public read: %v", err)
|
|
}
|
|
if full.Garden.ID != g.ID {
|
|
t.Errorf("public garden id = %d, want %d", full.Garden.ID, g.ID)
|
|
}
|
|
if full.Garden.MyRole != "" {
|
|
t.Errorf("public garden MyRole = %q, want empty (no role for a public viewer)", full.Garden.MyRole)
|
|
}
|
|
if len(full.Objects) != 1 || len(full.Plantings) != 1 || len(full.Plants) != 1 {
|
|
t.Errorf("public full = %d objs / %d plops / %d plants, want 1/1/1",
|
|
len(full.Objects), len(full.Plantings), len(full.Plants))
|
|
}
|
|
// The referenced plant is the owner's custom plant; its OwnerID must be
|
|
// redacted so an anonymous viewer can't learn the owner's user id.
|
|
for _, pl := range full.Plants {
|
|
if pl.OwnerID != nil {
|
|
t.Errorf("public plant %d leaks OwnerID %d, want nil", pl.ID, *pl.OwnerID)
|
|
}
|
|
}
|
|
|
|
// Unknown / blank tokens are masked, never a distinct error.
|
|
for _, bad := range []string{"does-not-exist", " ", ""} {
|
|
if _, err := s.PublicGarden(ctx, bad); !errors.Is(err, domain.ErrNotFound) {
|
|
t.Errorf("PublicGarden(%q) = %v, want ErrNotFound", bad, err)
|
|
}
|
|
}
|
|
|
|
// Rotate → fresh token; the old URL stops working.
|
|
rotated, err := s.EnablePublicShareLink(ctx, owner, g.ID, true)
|
|
if err != nil {
|
|
t.Fatalf("rotate: %v", err)
|
|
}
|
|
if rotated.Token == "" || rotated.Token == tok {
|
|
t.Fatalf("rotate token = %q (old %q), want a fresh non-empty token", rotated.Token, tok)
|
|
}
|
|
if _, err := s.PublicGarden(ctx, tok); !errors.Is(err, domain.ErrNotFound) {
|
|
t.Errorf("old token after rotate = %v, want ErrNotFound", err)
|
|
}
|
|
if _, err := s.PublicGarden(ctx, rotated.Token); err != nil {
|
|
t.Errorf("new token = %v, want success", err)
|
|
}
|
|
|
|
// Disable → link off, token no longer resolves.
|
|
if err := s.DisablePublicShareLink(ctx, owner, g.ID); err != nil {
|
|
t.Fatalf("disable: %v", err)
|
|
}
|
|
if _, err := s.PublicGarden(ctx, rotated.Token); !errors.Is(err, domain.ErrNotFound) {
|
|
t.Errorf("token after disable = %v, want ErrNotFound", err)
|
|
}
|
|
link, err = s.GetPublicShareLink(ctx, owner, g.ID)
|
|
if err != nil {
|
|
t.Fatalf("get link after disable: %v", err)
|
|
}
|
|
if link.Enabled {
|
|
t.Errorf("link still enabled after disable")
|
|
}
|
|
}
|