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).
130 lines
4.6 KiB
Go
130 lines
4.6 KiB
Go
package services
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestSlugifyTitle(t *testing.T) {
|
|
cases := []struct{ in, want string }{
|
|
{"UPC Klageschrift Strategie", "upc-klageschrift-strategie"},
|
|
{"Hülle für Münch (München!)", "huelle-fuer-muench-muenchen"},
|
|
{" ", ""},
|
|
{"&&&", ""},
|
|
{"A really really really really long title that ought to be clamped to forty chars max", "a-really-really-really-really-long-title"},
|
|
{"Straße ABC", "strasse-abc"},
|
|
{"---leading-and-trailing---", "leading-and-trailing"},
|
|
}
|
|
for _, c := range cases {
|
|
got := slugifyTitle(c.in)
|
|
if got != c.want {
|
|
t.Errorf("slugifyTitle(%q) = %q; want %q", c.in, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestNormaliseRegime(t *testing.T) {
|
|
for _, valid := range []string{"upc", "DE", " epa ", "Other", ""} {
|
|
if _, err := normaliseRegime(valid); err != nil {
|
|
t.Errorf("normaliseRegime(%q) errored unexpectedly: %v", valid, err)
|
|
}
|
|
}
|
|
if _, err := normaliseRegime("bogus"); !errors.Is(err, ErrInvalidInput) {
|
|
t.Errorf("normaliseRegime(bogus) expected ErrInvalidInput, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestNormaliseLang(t *testing.T) {
|
|
for _, valid := range []string{"de", "EN", " ", ""} {
|
|
if _, err := normaliseLang(valid); err != nil {
|
|
t.Errorf("normaliseLang(%q) errored: %v", valid, err)
|
|
}
|
|
}
|
|
if _, err := normaliseLang("fr"); !errors.Is(err, ErrInvalidInput) {
|
|
t.Errorf("normaliseLang(fr) expected ErrInvalidInput, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestNormaliseSliceAVisibility(t *testing.T) {
|
|
// Slice B opened up 'shared' as a valid author-set visibility
|
|
// (alongside 'private' and 'firm'). 'global' stays admin-only via
|
|
// ChecklistPromotionService.
|
|
for _, valid := range []string{"private", "firm", "shared", " ", ""} {
|
|
if _, err := normaliseSliceAVisibility(valid); err != nil {
|
|
t.Errorf("visibility(%q) errored: %v", valid, err)
|
|
}
|
|
}
|
|
for _, bad := range []string{"global", "public"} {
|
|
if _, err := normaliseSliceAVisibility(bad); !errors.Is(err, ErrInvalidInput) {
|
|
t.Errorf("visibility(%q) expected ErrInvalidInput, got %v", bad, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRequireNonEmptyTrimmed(t *testing.T) {
|
|
if _, err := requireNonEmptyTrimmed(" ", "title", 200); !errors.Is(err, ErrInvalidInput) {
|
|
t.Errorf("empty title should be rejected, got %v", err)
|
|
}
|
|
if got, err := requireNonEmptyTrimmed(" hello ", "title", 200); err != nil || got != "hello" {
|
|
t.Errorf("expected 'hello', got %q (err=%v)", got, err)
|
|
}
|
|
if _, err := requireNonEmptyTrimmed(strings.Repeat("x", 201), "title", 200); !errors.Is(err, ErrInvalidInput) {
|
|
t.Errorf("over-length title should be rejected, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestValidateBodyShape(t *testing.T) {
|
|
// Happy path: at least one group, at least one item.
|
|
ok := json.RawMessage(`{"groups":[{"titleDE":"G1","titleEN":"G1","items":[{"labelDE":"X","labelEN":"X"}]}]}`)
|
|
if err := validateBodyShape(ok); err != nil {
|
|
t.Errorf("valid body rejected: %v", err)
|
|
}
|
|
// Empty groups.
|
|
if err := validateBodyShape(json.RawMessage(`{"groups":[]}`)); !errors.Is(err, ErrInvalidInput) {
|
|
t.Errorf("empty groups expected ErrInvalidInput, got %v", err)
|
|
}
|
|
// Group with no items.
|
|
if err := validateBodyShape(json.RawMessage(`{"groups":[{"titleDE":"G","titleEN":"G","items":[]}]}`)); !errors.Is(err, ErrInvalidInput) {
|
|
t.Errorf("empty items expected ErrInvalidInput, got %v", err)
|
|
}
|
|
// Missing field.
|
|
if err := validateBodyShape(json.RawMessage(nil)); !errors.Is(err, ErrInvalidInput) {
|
|
t.Errorf("nil body expected ErrInvalidInput, got %v", err)
|
|
}
|
|
// Malformed JSON.
|
|
if err := validateBodyShape(json.RawMessage(`{not json`)); !errors.Is(err, ErrInvalidInput) {
|
|
t.Errorf("malformed body expected ErrInvalidInput, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestChecklistCatalogIsStaticSlug(t *testing.T) {
|
|
// nil DB is fine — we never touch it in this test.
|
|
cat := NewChecklistCatalogService(nil)
|
|
if !cat.IsStaticSlug("upc-statement-of-claim") {
|
|
t.Error("expected static slug to be detected")
|
|
}
|
|
if cat.IsStaticSlug("u-some-authored-slug") {
|
|
t.Error("unexpected static-slug match for authored slug")
|
|
}
|
|
}
|
|
|
|
func TestChecklistVisibilityPredicate(t *testing.T) {
|
|
got := checklistVisibilityPredicate("c", 1)
|
|
for _, want := range []string{"c.owner_id = $1", "c.visibility IN ('firm', 'global')", "u.global_role = 'global_admin'"} {
|
|
if !strings.Contains(got, want) {
|
|
t.Errorf("predicate missing %q in: %s", want, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestClampFreeText(t *testing.T) {
|
|
if got := clampFreeText(" hello ", 200); got != "hello" {
|
|
t.Errorf("expected trimmed 'hello', got %q", got)
|
|
}
|
|
if got := clampFreeText(strings.Repeat("x", 250), 200); len(got) != 200 {
|
|
t.Errorf("expected clamp to 200, got len=%d", len(got))
|
|
}
|
|
}
|