Files
paliad/internal/services/visibility_test.go
m 31db66e3b7 refactor(t-paliad-076): consolidate visibility predicate — 6 dashboard/agenda sites use helper
F-2 from t-paliad-074 audit. The inlined visibility predicate had drifted
back into 6 hot-path SQL sites despite the central helper extracted in
t-paliad-058. Consolidating now so future visibility changes (e.g.
Chinese-wall in design v2 §8) only need one edit.

**Sites converted (6):**
- dashboard_service.go:158, 214, 244, 274
- agenda_service.go:138, 204

All six replace `$N = 'global_admin' OR EXISTS (path-walk)` with the
existing `visibilityPredicatePositional("p", 1)` helper. The helper
resolves global_admin via EXISTS on paliad.users — the role string no
longer flows through positional args, removing one foot-gun (typo'd
literal mismatched against bound role) entirely. Equivalence verified
on the live youpc DB:

    tester@hlc.de (global_admin, 1 team membership):
      old predicate count = 11   new predicate count = 11
    standard user (no team):
      old predicate count =  0   new predicate count =  0

**No new helper variant added.** The audit suggested
`visibilityPredicateLateral`, but the existing positional helper drops
into the dashboard/agenda WHERE clauses unchanged — adding a redundant
variant would be technical debt. dashboard/agenda do not use LATERAL
JOIN; they use plain WHERE EXISTS in (sub-)SELECT context, which is
already what visibilityPredicatePositional emits.

**Other 4 sites flagged by audit — left intentionally:**

- reminder_service.go:312, 325 are role-restricted (`pt.role = 'lead'`)
  membership checks, NOT visibility predicates. Adding a global_admin
  shortcut to the lead branch would over-include rows: every global
  admin would receive every project's lead-targeted reminder, even with
  the `own.escalation_contact_id` override that exists precisely to
  avoid that. global_admin already has its own dedicated branch in the
  query (`$3 = TRUE AND own.escalation_contact_id IS NULL` at line 328).

- deadline_service.go:422 (`assertCanAdminProject`) is role-restricted
  (`pt.role IN ('admin', 'lead')`) and already short-circuits global_admin
  at the Go level before the SQL runs (line 413). Both halves correct;
  no change needed.

- team_service.go:162 (`IsEffectiveMember`) was dead code with no callers
  in the entire repo. "Is this user a structural team member?" and
  "can this user see this project?" are different questions; adding a
  global_admin shortcut would have conflated them. Deleted instead.

**Test:** new TestVisibilityPredicate_DashboardAgendaForGlobalAdmin in
visibility_test.go seeds a project + deadline + appointment + activity
event with project_teams empty, then asserts a global_admin sees all
four on /dashboard and /agenda while a standard user sees none. Skips
when TEST_DATABASE_URL is unset (matching the existing live-DB tests).

**Pre-existing finding (separate concern):** the live-DB test gate is
currently blocked locally by a stale `public.paliad_schema_migrations`
(version=2, dirty=t) left over from before the schema-pinned tracker
landed. Authoritative `paliad.paliad_schema_migrations` is at version
27, dirty=f. Out of scope for this task; should be filed as cleanup.
2026-04-30 03:48:49 +02:00

326 lines
12 KiB
Go

package services
import (
"context"
"errors"
"os"
"testing"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
"mgit.msbls.de/m/patholo/internal/db"
)
// TestVisibilityPredicate_GlobalAdminWithoutTeam is the regression for
// t-paliad-058. A global_admin without any project_teams row must still be
// able to read every Project — matching paliad.can_see_project's RLS
// behaviour. Pre-fix, visibilityPredicatePositional compared the bound role
// to the literal 'admin', so callers passing the (correct) 'global_admin'
// value silently fell through to the team-membership branch and got 404.
//
// Skipped when TEST_DATABASE_URL is unset, mirroring the other live-DB tests.
func TestVisibilityPredicate_GlobalAdminWithoutTeam(t *testing.T) {
url := os.Getenv("TEST_DATABASE_URL")
if url == "" {
t.Skip("TEST_DATABASE_URL not set — skipping live DB test")
}
if err := db.ApplyMigrations(url); err != nil {
t.Fatalf("apply migrations: %v", err)
}
pool, err := sqlx.Connect("postgres", url)
if err != nil {
t.Fatalf("connect: %v", err)
}
defer pool.Close()
ctx := context.Background()
adminID := uuid.New()
standardID := uuid.New()
projectID := uuid.New()
cleanup := func() {
pool.ExecContext(ctx, `DELETE FROM paliad.project_teams WHERE project_id = $1`, projectID)
pool.ExecContext(ctx, `DELETE FROM paliad.projects WHERE id = $1`, projectID)
pool.ExecContext(ctx, `DELETE FROM paliad.users WHERE id IN ($1, $2)`, adminID, standardID)
pool.ExecContext(ctx, `DELETE FROM auth.users WHERE id IN ($1, $2)`, adminID, standardID)
}
cleanup()
defer cleanup()
if _, err := pool.ExecContext(ctx,
`INSERT INTO auth.users (id, email) VALUES ($1, $2), ($3, $4)`,
adminID, "vis-admin@hlc.com", standardID, "vis-standard@hlc.com"); err != nil {
t.Fatalf("seed auth.users: %v", err)
}
if _, err := pool.ExecContext(ctx,
`INSERT INTO paliad.users (id, email, display_name, office, global_role, lang)
VALUES ($1, $2, 'Vis Admin', 'munich', 'global_admin', 'de'),
($3, $4, 'Vis Standard', 'munich', 'standard', 'de')`,
adminID, "vis-admin@hlc.com", standardID, "vis-standard@hlc.com"); err != nil {
t.Fatalf("seed paliad.users: %v", err)
}
// Project created by adminID. NOTE: ProjectService.Create normally
// auto-adds the creator to project_teams (so they'd see the project via
// team membership too) — for this regression we INSERT the row directly
// and leave project_teams empty. That isolates the global_admin shortcut.
if _, err := pool.ExecContext(ctx,
`INSERT INTO paliad.projects (id, type, path, title, reference, status, created_by)
VALUES ($1, 'project', $1::text, 'Visibility Test Project', '2026/9997', 'active', $2)`,
projectID, adminID); err != nil {
t.Fatalf("seed paliad.projects: %v", err)
}
users := NewUserService(pool)
projects := NewProjectService(pool, users)
// global_admin without team membership: must see the project.
if _, err := projects.GetByID(ctx, adminID, projectID); err != nil {
t.Fatalf("GetByID for global_admin without team: %v (want nil — global_admin sees all)", err)
}
tree, err := projects.BuildTree(ctx, adminID)
if err != nil {
t.Fatalf("BuildTree for global_admin: %v", err)
}
if !treeContains(tree, projectID) {
t.Errorf("BuildTree omitted project %s for global_admin", projectID)
}
// standard user without team membership: must NOT see the project.
if _, err := projects.GetByID(ctx, standardID, projectID); !errors.Is(err, ErrNotVisible) {
t.Fatalf("GetByID for standard without team: err=%v, want ErrNotVisible", err)
}
tree, err = projects.BuildTree(ctx, standardID)
if err != nil {
t.Fatalf("BuildTree for standard: %v", err)
}
if treeContains(tree, projectID) {
t.Errorf("BuildTree leaked project %s to standard user without team", projectID)
}
}
func treeContains(nodes []*ProjectTreeNode, id uuid.UUID) bool {
for _, n := range nodes {
if n.ID == id {
return true
}
if treeContains(n.Children, id) {
return true
}
}
return false
}
// TestVisibilityPredicate_DashboardAgendaForGlobalAdmin is the regression for
// t-paliad-076. After consolidating the inlined predicates in dashboard and
// agenda services, a global_admin without any project_teams row must still
// see Project-scoped rows on:
// - DashboardService.Get (summary counts, upcoming deadlines, upcoming
// appointments, recent activity)
// - AgendaService.List (deadlines + appointments)
//
// The standard user with no membership must see none of those rows.
//
// Skipped when TEST_DATABASE_URL is unset, mirroring the other live-DB tests.
func TestVisibilityPredicate_DashboardAgendaForGlobalAdmin(t *testing.T) {
url := os.Getenv("TEST_DATABASE_URL")
if url == "" {
t.Skip("TEST_DATABASE_URL not set — skipping live DB test")
}
if err := db.ApplyMigrations(url); err != nil {
t.Fatalf("apply migrations: %v", err)
}
pool, err := sqlx.Connect("postgres", url)
if err != nil {
t.Fatalf("connect: %v", err)
}
defer pool.Close()
ctx := context.Background()
adminID := uuid.New()
standardID := uuid.New()
projectID := uuid.New()
deadlineID := uuid.New()
appointmentID := uuid.New()
eventID := uuid.New()
cleanup := func() {
pool.ExecContext(ctx, `DELETE FROM paliad.project_events WHERE project_id = $1`, projectID)
pool.ExecContext(ctx, `DELETE FROM paliad.appointments WHERE id = $1`, appointmentID)
pool.ExecContext(ctx, `DELETE FROM paliad.deadlines WHERE id = $1`, deadlineID)
pool.ExecContext(ctx, `DELETE FROM paliad.project_teams WHERE project_id = $1`, projectID)
pool.ExecContext(ctx, `DELETE FROM paliad.projects WHERE id = $1`, projectID)
pool.ExecContext(ctx, `DELETE FROM paliad.users WHERE id IN ($1, $2)`, adminID, standardID)
pool.ExecContext(ctx, `DELETE FROM auth.users WHERE id IN ($1, $2)`, adminID, standardID)
}
cleanup()
defer cleanup()
if _, err := pool.ExecContext(ctx,
`INSERT INTO auth.users (id, email) VALUES ($1, $2), ($3, $4)`,
adminID, "vis-da-admin@hlc.com", standardID, "vis-da-standard@hlc.com"); err != nil {
t.Fatalf("seed auth.users: %v", err)
}
if _, err := pool.ExecContext(ctx,
`INSERT INTO paliad.users (id, email, display_name, office, global_role, lang)
VALUES ($1, $2, 'Vis DA Admin', 'munich', 'global_admin', 'de'),
($3, $4, 'Vis DA Standard', 'munich', 'standard', 'de')`,
adminID, "vis-da-admin@hlc.com", standardID, "vis-da-standard@hlc.com"); err != nil {
t.Fatalf("seed paliad.users: %v", err)
}
// Project owned by adminID. project_teams intentionally left empty so the
// only path to visibility is the global_admin branch of the predicate.
if _, err := pool.ExecContext(ctx,
`INSERT INTO paliad.projects (id, type, path, title, reference, status, created_by)
VALUES ($1, 'project', $1::text, 'Visibility DA Project', '2026/9996', 'active', $2)`,
projectID, adminID); err != nil {
t.Fatalf("seed paliad.projects: %v", err)
}
// Seed one pending deadline within the dashboard's 7-day window so it
// shows up in both summary counts AND the upcoming list.
dueDate := time.Now().UTC().AddDate(0, 0, 2).Format("2006-01-02")
if _, err := pool.ExecContext(ctx,
`INSERT INTO paliad.deadlines (id, project_id, title, due_date, source, status, created_by)
VALUES ($1, $2, 'Vis DA Deadline', $3::date, 'manual', 'pending', $4)`,
deadlineID, projectID, dueDate, adminID); err != nil {
t.Fatalf("seed paliad.deadlines: %v", err)
}
// Seed one appointment within the dashboard's 7-day window.
startAt := time.Now().UTC().Add(48 * time.Hour)
if _, err := pool.ExecContext(ctx,
`INSERT INTO paliad.appointments
(id, project_id, title, start_at, appointment_type, created_by)
VALUES ($1, $2, 'Vis DA Appointment', $3, 'meeting', $4)`,
appointmentID, projectID, startAt, adminID); err != nil {
t.Fatalf("seed paliad.appointments: %v", err)
}
// Seed one project event so recent activity has a row to surface.
if _, err := pool.ExecContext(ctx,
`INSERT INTO paliad.project_events
(id, project_id, event_type, title, description, event_date,
created_by, metadata, created_at, updated_at)
VALUES ($1, $2, 'project_created', 'Created', NULL, $3, $4,
'{}'::jsonb, $3, $3)`,
eventID, projectID, time.Now().UTC(), adminID); err != nil {
t.Fatalf("seed paliad.project_events: %v", err)
}
users := NewUserService(pool)
dashboard := NewDashboardService(pool, users)
agenda := NewAgendaService(pool, users)
t.Run("global_admin sees dashboard rows without team membership", func(t *testing.T) {
data, err := dashboard.Get(ctx, adminID)
if err != nil {
t.Fatalf("dashboard.Get: %v", err)
}
if data.MatterSummary.Total < 1 {
t.Errorf("MatterSummary.Total = %d, want >= 1 (global_admin must see the seeded project)", data.MatterSummary.Total)
}
if data.DeadlineSummary.ThisWeek+data.DeadlineSummary.Upcoming+data.DeadlineSummary.Overdue < 1 {
t.Errorf("DeadlineSummary has 0 pending; want >= 1 (global_admin must see the seeded deadline)")
}
if !containsDeadline(data.UpcomingDeadlines, deadlineID) {
t.Errorf("UpcomingDeadlines missing %s for global_admin", deadlineID)
}
if !containsAppointment(data.UpcomingAppointments, appointmentID) {
t.Errorf("UpcomingAppointments missing %s for global_admin", appointmentID)
}
if !containsActivityProject(data.RecentActivity, projectID) {
t.Errorf("RecentActivity missing project %s for global_admin", projectID)
}
})
t.Run("standard user sees nothing without team membership", func(t *testing.T) {
data, err := dashboard.Get(ctx, standardID)
if err != nil {
t.Fatalf("dashboard.Get standard: %v", err)
}
if containsDeadline(data.UpcomingDeadlines, deadlineID) {
t.Errorf("UpcomingDeadlines leaked %s to standard user", deadlineID)
}
if containsAppointment(data.UpcomingAppointments, appointmentID) {
t.Errorf("UpcomingAppointments leaked %s to standard user", appointmentID)
}
if containsActivityProject(data.RecentActivity, projectID) {
t.Errorf("RecentActivity leaked project %s to standard user", projectID)
}
})
t.Run("global_admin sees agenda rows without team membership", func(t *testing.T) {
from := time.Now().UTC().AddDate(0, 0, -1)
to := time.Now().UTC().AddDate(0, 0, 14)
items, err := agenda.List(ctx, adminID, AgendaFilter{
From: from, To: to, IncludeDeadlines: true, IncludeAppointments: true,
})
if err != nil {
t.Fatalf("agenda.List: %v", err)
}
if !containsAgendaID(items, deadlineID) {
t.Errorf("agenda missing deadline %s for global_admin", deadlineID)
}
if !containsAgendaID(items, appointmentID) {
t.Errorf("agenda missing appointment %s for global_admin", appointmentID)
}
})
t.Run("standard user sees no agenda rows without team membership", func(t *testing.T) {
from := time.Now().UTC().AddDate(0, 0, -1)
to := time.Now().UTC().AddDate(0, 0, 14)
items, err := agenda.List(ctx, standardID, AgendaFilter{
From: from, To: to, IncludeDeadlines: true, IncludeAppointments: true,
})
if err != nil {
t.Fatalf("agenda.List standard: %v", err)
}
if containsAgendaID(items, deadlineID) {
t.Errorf("agenda leaked deadline %s to standard user", deadlineID)
}
if containsAgendaID(items, appointmentID) {
t.Errorf("agenda leaked appointment %s to standard user", appointmentID)
}
})
}
func containsDeadline(rows []UpcomingDeadline, id uuid.UUID) bool {
for _, r := range rows {
if r.ID == id {
return true
}
}
return false
}
func containsAppointment(rows []UpcomingAppointment, id uuid.UUID) bool {
for _, r := range rows {
if r.ID == id {
return true
}
}
return false
}
func containsActivityProject(rows []ActivityEntry, projectID uuid.UUID) bool {
for _, r := range rows {
if r.ProjectID == projectID {
return true
}
}
return false
}
func containsAgendaID(items []AgendaItem, id uuid.UUID) bool {
for _, it := range items {
if it.ID == id {
return true
}
}
return false
}