package services import ( "context" "errors" "os" "testing" "time" "github.com/google/uuid" "github.com/jmoiron/sqlx" _ "github.com/lib/pq" "mgit.msbls.de/m/paliad/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, nil) 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.Today+data.DeadlineSummary.ThisWeek+data.DeadlineSummary.NextWeek+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 }