m/paliad#61 Slice B backend. Implements the explicit-share path
(checklist_shares + visibility predicate extension) and the
global_admin-only promotion / demotion of authored templates to and
from the firm catalog.
Schema (mig 115, idempotent):
- paliad.checklist_shares (uuid id, checklist_id FK, polymorphic
recipient via xor-check: recipient_kind in {user, office,
partner_unit, project} with exactly one matching recipient_* column
populated; granted_by FK; granted_at)
- Hot-path lookup index + per-kind partial UNIQUE indexes prevent
duplicate grants
- RLS: SELECT owner OR self-recipient (user-kind) OR global_admin;
INSERT owner-only with granted_by=self; DELETE owner OR global_admin;
no UPDATE (revoke = DELETE)
- can_see_checklist CREATE OR REPLACE — adds 4 share branches; project-
share branch uses inline ltree walk over projects.path because
can_see_project reads auth.uid() (NULL on service-role connection,
same pattern as visibility.go)
- xor-check verified live: rejects kind='user' with recipient_office
set; accepts the matching kind/recipient pair
Services:
- ChecklistShareService — Grant (owner-only, validates recipient kind +
required FK target, friendly 409 on partial-unique-index conflict),
Revoke (owner or global_admin), ListGrants (owner or global_admin;
enriches recipient_label via LEFT JOINs)
- ChecklistPromotionService — Promote (global_admin → visibility=global
+ promoted_at/by + audit), Demote (global_admin → target visibility,
default 'firm', clears promoted_at/by; rejects demote of non-global
rows)
- ChecklistCatalogService.checklistVisibilityPredicate extended to
include all 5 share branches; service-role-friendly (no auth.uid())
- ChecklistTemplateService.normaliseSliceAVisibility now accepts
'shared' as an author-set value; 'global' stays admin-only
Endpoints:
- GET /api/checklists/templates/{slug}/shares — list grants (owner/admin)
- POST /api/checklists/templates/{slug}/shares — grant
- DELETE /api/checklists/shares/{id} — revoke
- POST /api/admin/checklists/{slug}/promote — promote to global
- POST /api/admin/checklists/{slug}/demote — demote (body.target default 'firm')
Audit (paliad.system_audit_log):
- checklist.shared — recipient_kind + recipient_id in metadata
- checklist.unshared — same shape, captured pre-DELETE
- checklist.promoted_global — prior_visibility + owner_id
- checklist.demoted — target_visibility
Tests: validateShareInput covers all 4 kinds (happy + missing-id);
predicate-shape test asserts all 6 visibility branches present;
pqUniqueViolation regex sniff; nullableString helper; SliceB visibility
opens 'shared' but keeps 'global' admin-only.
Hotfix-merge note: head shipped 794617c after Slice A — the
template-edit page route moved from /checklists/{slug}/edit to
/checklists/templates/{slug}/edit to disambiguate from
/checklists/instances/{id}. Slice B routes follow the safe
/<resource>/<noun>/{id} pattern (no new {slug}-then-verb endpoints).
108 lines
3.3 KiB
Go
108 lines
3.3 KiB
Go
package services
|
|
|
|
import (
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
func TestValidateShareInput(t *testing.T) {
|
|
uid := uuid.New()
|
|
puID := uuid.New()
|
|
prID := uuid.New()
|
|
|
|
cases := []struct {
|
|
name string
|
|
kind string
|
|
input ShareGrantInput
|
|
wantErr bool
|
|
}{
|
|
{"user happy", "user", ShareGrantInput{RecipientKind: "user", UserID: &uid}, false},
|
|
{"user missing id", "user", ShareGrantInput{RecipientKind: "user"}, true},
|
|
{"office happy", "office", ShareGrantInput{RecipientKind: "office", Office: "munich"}, false},
|
|
{"office unknown key", "office", ShareGrantInput{RecipientKind: "office", Office: "atlantis"}, true},
|
|
{"office empty", "office", ShareGrantInput{RecipientKind: "office"}, true},
|
|
{"partner_unit happy", "partner_unit", ShareGrantInput{RecipientKind: "partner_unit", PartnerUnitID: &puID}, false},
|
|
{"partner_unit missing id", "partner_unit", ShareGrantInput{RecipientKind: "partner_unit"}, true},
|
|
{"project happy", "project", ShareGrantInput{RecipientKind: "project", ProjectID: &prID}, false},
|
|
{"project missing id", "project", ShareGrantInput{RecipientKind: "project"}, true},
|
|
{"unknown kind", "bogus", ShareGrantInput{RecipientKind: "bogus"}, true},
|
|
}
|
|
for _, c := range cases {
|
|
err := validateShareInput(c.kind, c.input)
|
|
if c.wantErr && !errors.Is(err, ErrInvalidInput) {
|
|
t.Errorf("%s: expected ErrInvalidInput, got %v", c.name, err)
|
|
}
|
|
if !c.wantErr && err != nil {
|
|
t.Errorf("%s: unexpected error %v", c.name, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPredicateIncludesAllShareBranches(t *testing.T) {
|
|
pred := checklistVisibilityPredicate("c", 1)
|
|
wants := []string{
|
|
"c.owner_id = $1",
|
|
"c.visibility IN ('firm', 'global')",
|
|
"u.global_role = 'global_admin'",
|
|
"s.recipient_kind = 'user'",
|
|
"s.recipient_kind = 'office'",
|
|
"s.recipient_kind = 'partner_unit'",
|
|
"s.recipient_kind = 'project'",
|
|
"paliad.checklist_shares",
|
|
"paliad.partner_unit_members",
|
|
"paliad.projects",
|
|
"paliad.project_teams",
|
|
}
|
|
for _, w := range wants {
|
|
if !strings.Contains(pred, w) {
|
|
t.Errorf("predicate missing %q in:\n%s", w, pred)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPqUniqueViolationDetection(t *testing.T) {
|
|
cases := []struct {
|
|
err string
|
|
want bool
|
|
}{
|
|
{"pq: duplicate key value violates unique constraint \"checklist_shares_user_uniq\"", true},
|
|
{"pq: 23505 something", true},
|
|
{"some other error", false},
|
|
}
|
|
for _, c := range cases {
|
|
got := pqUniqueViolation(errors.New(c.err))
|
|
if got != c.want {
|
|
t.Errorf("pqUniqueViolation(%q) = %v; want %v", c.err, got, c.want)
|
|
}
|
|
}
|
|
if pqUniqueViolation(nil) {
|
|
t.Error("nil err should not be a unique violation")
|
|
}
|
|
}
|
|
|
|
func TestNullableString(t *testing.T) {
|
|
if got := nullableString(""); got != nil {
|
|
t.Errorf("empty should map to nil, got %v", got)
|
|
}
|
|
if got := nullableString(" "); got != nil {
|
|
t.Errorf("whitespace should map to nil, got %v", got)
|
|
}
|
|
if got := nullableString(" munich "); got != "munich" {
|
|
t.Errorf("expected trimmed 'munich', got %v", got)
|
|
}
|
|
}
|
|
|
|
func TestNormaliseSliceAVisibilityAcceptsShared(t *testing.T) {
|
|
for _, v := range []string{"private", "firm", "shared"} {
|
|
if _, err := normaliseSliceAVisibility(v); err != nil {
|
|
t.Errorf("Slice-B visibility %q rejected: %v", v, err)
|
|
}
|
|
}
|
|
if _, err := normaliseSliceAVisibility("global"); !errors.Is(err, ErrInvalidInput) {
|
|
t.Errorf("'global' should be rejected as author-set, got %v", err)
|
|
}
|
|
}
|