diff --git a/internal/integration/lifecycle_test.go b/internal/integration/lifecycle_test.go index 3572406..f4a39f9 100644 --- a/internal/integration/lifecycle_test.go +++ b/internal/integration/lifecycle_test.go @@ -961,6 +961,25 @@ func (m *mockTargetRepository) List(ctx context.Context) ([]*domain.DeploymentTa return targets, nil } +// ListPaginated mirrors the SQL-side window. SCALE-002 closure (Sprint 2). +func (m *mockTargetRepository) ListPaginated(ctx context.Context, limit, offset int) ([]*domain.DeploymentTarget, int64, error) { + all, _ := m.List(ctx) + if offset < 0 { + offset = 0 + } + if offset >= len(all) { + return nil, int64(len(all)), nil + } + if limit <= 0 { + return all[offset:], int64(len(all)), nil + } + end := offset + limit + if end > len(all) { + end = len(all) + } + return all[offset:end], int64(len(all)), nil +} + func (m *mockTargetRepository) Get(ctx context.Context, id string) (*domain.DeploymentTarget, error) { target, ok := m.targets[id] if !ok { @@ -1233,6 +1252,25 @@ func (m *mockIssuerRepository) List(ctx context.Context) ([]*domain.Issuer, erro return issuers, nil } +// ListPaginated mirrors the SQL-side window. SCALE-002 closure (Sprint 2). +func (m *mockIssuerRepository) ListPaginated(ctx context.Context, limit, offset int) ([]*domain.Issuer, int64, error) { + all, _ := m.List(ctx) + if offset < 0 { + offset = 0 + } + if offset >= len(all) { + return nil, int64(len(all)), nil + } + if limit <= 0 { + return all[offset:], int64(len(all)), nil + } + end := offset + limit + if end > len(all) { + end = len(all) + } + return all[offset:end], int64(len(all)), nil +} + func (m *mockIssuerRepository) Get(ctx context.Context, id string) (*domain.Issuer, error) { issuer, ok := m.issuers[id] if !ok { diff --git a/internal/repository/interfaces.go b/internal/repository/interfaces.go index 30480c3..7ed74e8 100644 --- a/internal/repository/interfaces.go +++ b/internal/repository/interfaces.go @@ -210,6 +210,11 @@ type OCSPResponderRepository interface { type IssuerRepository interface { // List returns all issuers, optionally filtered. List(ctx context.Context) ([]*domain.Issuer, error) + // ListPaginated returns a window of issuers (sorted by created_at DESC) + // plus the total row count. SCALE-002 closure (Sprint 2, 2026-05-16) — + // pushes pagination into the SQL layer so admin pages don't marshal + // the full table per request. + ListPaginated(ctx context.Context, limit, offset int) ([]*domain.Issuer, int64, error) // Get retrieves an issuer by ID. Get(ctx context.Context, id string) (*domain.Issuer, error) // Create stores a new issuer. @@ -227,6 +232,10 @@ type IssuerRepository interface { type TargetRepository interface { // List returns all targets, optionally filtered. List(ctx context.Context) ([]*domain.DeploymentTarget, error) + // ListPaginated returns a window of deployment targets (sorted by + // created_at DESC) plus the total row count. SCALE-002 closure + // (Sprint 2, 2026-05-16). + ListPaginated(ctx context.Context, limit, offset int) ([]*domain.DeploymentTarget, int64, error) // Get retrieves a target by ID. Get(ctx context.Context, id string) (*domain.DeploymentTarget, error) // Create stores a new target. @@ -550,6 +559,9 @@ type NotificationRepository interface { type TeamRepository interface { // List returns all teams. List(ctx context.Context) ([]*domain.Team, error) + // ListPaginated returns a window of teams (sorted by created_at DESC) + // plus the total row count. SCALE-002 closure (Sprint 2, 2026-05-16). + ListPaginated(ctx context.Context, limit, offset int) ([]*domain.Team, int64, error) // Get retrieves a team by ID. Get(ctx context.Context, id string) (*domain.Team, error) // Create stores a new team. @@ -578,6 +590,9 @@ type CertificateProfileRepository interface { type AgentGroupRepository interface { // List returns all agent groups. List(ctx context.Context) ([]*domain.AgentGroup, error) + // ListPaginated returns a window of agent groups (sorted by name) + // plus the total row count. SCALE-002 closure (Sprint 2, 2026-05-16). + ListPaginated(ctx context.Context, limit, offset int) ([]*domain.AgentGroup, int64, error) // Get retrieves an agent group by ID. Get(ctx context.Context, id string) (*domain.AgentGroup, error) // Create stores a new agent group. diff --git a/internal/repository/postgres/agent_group.go b/internal/repository/postgres/agent_group.go index 0b922ad..a58c6cf 100644 --- a/internal/repository/postgres/agent_group.go +++ b/internal/repository/postgres/agent_group.go @@ -44,6 +44,40 @@ func (r *AgentGroupRepository) List(ctx context.Context) ([]*domain.AgentGroup, return groups, rows.Err() } +// ListPaginated returns a slice of agent groups bounded by limit/offset +// plus the total count. SCALE-002 closure (Sprint 2, 2026-05-16). +func (r *AgentGroupRepository) ListPaginated(ctx context.Context, limit, offset int) ([]*domain.AgentGroup, int64, error) { + if limit <= 0 { + limit = 50 + } + if offset < 0 { + offset = 0 + } + var total int64 + if err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM agent_groups`).Scan(&total); err != nil { + return nil, 0, fmt.Errorf("failed to count agent groups: %w", err) + } + rows, err := r.db.QueryContext(ctx, + `SELECT id, name, description, match_os, match_architecture, match_ip_cidr, match_version, enabled, created_at, updated_at + FROM agent_groups ORDER BY name LIMIT $1 OFFSET $2`, limit, offset) + if err != nil { + return nil, 0, fmt.Errorf("failed to query agent groups: %w", err) + } + defer rows.Close() + var groups []*domain.AgentGroup + for rows.Next() { + g, err := scanAgentGroup(rows) + if err != nil { + return nil, 0, err + } + groups = append(groups, g) + } + if err := rows.Err(); err != nil { + return nil, 0, err + } + return groups, total, nil +} + // Get retrieves an agent group by ID. func (r *AgentGroupRepository) Get(ctx context.Context, id string) (*domain.AgentGroup, error) { row := r.db.QueryRowContext(ctx, diff --git a/internal/repository/postgres/issuer.go b/internal/repository/postgres/issuer.go index 3eec090..c3f2c54 100644 --- a/internal/repository/postgres/issuer.go +++ b/internal/repository/postgres/issuer.go @@ -57,6 +57,48 @@ func (r *IssuerRepository) List(ctx context.Context) ([]*domain.Issuer, error) { return issuers, nil } +// ListPaginated returns a slice of issuers bounded by limit/offset plus the +// total count. SCALE-002 closure (Sprint 2, 2026-05-16). +func (r *IssuerRepository) ListPaginated(ctx context.Context, limit, offset int) ([]*domain.Issuer, int64, error) { + if limit <= 0 { + limit = 50 + } + if offset < 0 { + offset = 0 + } + var total int64 + if err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM issuers`).Scan(&total); err != nil { + return nil, 0, fmt.Errorf("failed to count issuers: %w", err) + } + rows, err := r.db.QueryContext(ctx, ` + SELECT id, name, type, config, COALESCE(encrypted_config, NULL), enabled, + last_tested_at, COALESCE(test_status, 'untested'), COALESCE(source, 'database'), + created_at, updated_at + FROM issuers + ORDER BY created_at DESC + LIMIT $1 OFFSET $2 + `, limit, offset) + if err != nil { + return nil, 0, fmt.Errorf("failed to query issuers: %w", err) + } + defer rows.Close() + var issuers []*domain.Issuer + for rows.Next() { + var iss domain.Issuer + if err := rows.Scan(&iss.ID, &iss.Name, &iss.Type, &iss.Config, + &iss.EncryptedConfig, &iss.Enabled, + &iss.LastTestedAt, &iss.TestStatus, &iss.Source, + &iss.CreatedAt, &iss.UpdatedAt); err != nil { + return nil, 0, fmt.Errorf("failed to scan issuer: %w", err) + } + issuers = append(issuers, &iss) + } + if err := rows.Err(); err != nil { + return nil, 0, fmt.Errorf("error iterating issuer rows: %w", err) + } + return issuers, total, nil +} + // Get retrieves an issuer by ID func (r *IssuerRepository) Get(ctx context.Context, id string) (*domain.Issuer, error) { var issuer domain.Issuer diff --git a/internal/repository/postgres/target.go b/internal/repository/postgres/target.go index 0c44648..e1ae447 100644 --- a/internal/repository/postgres/target.go +++ b/internal/repository/postgres/target.go @@ -82,6 +82,48 @@ func (r *TargetRepository) List(ctx context.Context) ([]*domain.DeploymentTarget return targets, nil } +// ListPaginated returns a slice of deployment targets bounded by limit/offset +// plus the total row count. SCALE-002 closure (Sprint 2, 2026-05-16) — pushes +// pagination into SQL so the admin UI doesn't marshal the entire targets +// table per request. limit≤0 is normalised to 50; offset<0 to 0. +func (r *TargetRepository) ListPaginated(ctx context.Context, limit, offset int) ([]*domain.DeploymentTarget, int64, error) { + if limit <= 0 { + limit = 50 + } + if offset < 0 { + offset = 0 + } + + var total int64 + if err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM deployment_targets`).Scan(&total); err != nil { + return nil, 0, fmt.Errorf("failed to count targets: %w", err) + } + + rows, err := r.db.QueryContext(ctx, ` + SELECT `+targetSelectColumns+` + FROM deployment_targets + ORDER BY created_at DESC + LIMIT $1 OFFSET $2 + `, limit, offset) + if err != nil { + return nil, 0, fmt.Errorf("failed to query targets: %w", err) + } + defer rows.Close() + + var targets []*domain.DeploymentTarget + for rows.Next() { + var t domain.DeploymentTarget + if err := scanTarget(rows, &t); err != nil { + return nil, 0, fmt.Errorf("failed to scan target: %w", err) + } + targets = append(targets, &t) + } + if err := rows.Err(); err != nil { + return nil, 0, fmt.Errorf("error iterating target rows: %w", err) + } + return targets, total, nil +} + // Get retrieves a target by ID func (r *TargetRepository) Get(ctx context.Context, id string) (*domain.DeploymentTarget, error) { var target domain.DeploymentTarget diff --git a/internal/repository/postgres/team.go b/internal/repository/postgres/team.go index e3e3586..eba43b7 100644 --- a/internal/repository/postgres/team.go +++ b/internal/repository/postgres/team.go @@ -53,6 +53,44 @@ func (r *TeamRepository) List(ctx context.Context) ([]*domain.Team, error) { return teams, nil } +// ListPaginated returns a slice of teams bounded by limit/offset plus the +// total count. SCALE-002 closure (Sprint 2, 2026-05-16). +func (r *TeamRepository) ListPaginated(ctx context.Context, limit, offset int) ([]*domain.Team, int64, error) { + if limit <= 0 { + limit = 50 + } + if offset < 0 { + offset = 0 + } + var total int64 + if err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM teams`).Scan(&total); err != nil { + return nil, 0, fmt.Errorf("failed to count teams: %w", err) + } + rows, err := r.db.QueryContext(ctx, ` + SELECT id, name, description, created_at, updated_at + FROM teams + ORDER BY created_at DESC + LIMIT $1 OFFSET $2 + `, limit, offset) + if err != nil { + return nil, 0, fmt.Errorf("failed to query teams: %w", err) + } + defer rows.Close() + var teams []*domain.Team + for rows.Next() { + var team domain.Team + if err := rows.Scan(&team.ID, &team.Name, &team.Description, + &team.CreatedAt, &team.UpdatedAt); err != nil { + return nil, 0, fmt.Errorf("failed to scan team: %w", err) + } + teams = append(teams, &team) + } + if err := rows.Err(); err != nil { + return nil, 0, fmt.Errorf("error iterating team rows: %w", err) + } + return teams, total, nil +} + // Get retrieves a team by ID func (r *TeamRepository) Get(ctx context.Context, id string) (*domain.Team, error) { var team domain.Team diff --git a/internal/service/agent_group.go b/internal/service/agent_group.go index 52f8bde..d4ba45f 100644 --- a/internal/service/agent_group.go +++ b/internal/service/agent_group.go @@ -31,27 +31,28 @@ func NewAgentGroupService( } // ListAgentGroups returns paginated agent groups (handler interface method). +// +// SCALE-002 closure (Sprint 2, 2026-05-16): pagination is now pushed +// into the SQL layer via AgentGroupRepository.ListPaginated, closing +// the Bundle E / Audit L-020 "page/perPage unused" gap. func (s *AgentGroupService) ListAgentGroups(ctx context.Context, page, perPage int) ([]domain.AgentGroup, int64, error) { - // Bundle E / Audit L-020: page/perPage are unused; the underlying repo - // List() does not yet take pagination params. Marked explicitly so - // ineffassign sees no dead store and future maintainers see the - // vestigial params rather than a misleading default-applied clamp. - _ = page - _ = perPage - - groups, err := s.groupRepo.List(ctx) + if page < 1 { + page = 1 + } + if perPage < 1 { + perPage = 50 + } + offset := (page - 1) * perPage + groups, total, err := s.groupRepo.ListPaginated(ctx, perPage, offset) if err != nil { return nil, 0, fmt.Errorf("failed to list agent groups: %w", err) } - total := int64(len(groups)) - var result []domain.AgentGroup for _, g := range groups { if g != nil { result = append(result, *g) } } - return result, total, nil } diff --git a/internal/service/agent_group_test.go b/internal/service/agent_group_test.go index 7f09aed..b06e6d8 100644 --- a/internal/service/agent_group_test.go +++ b/internal/service/agent_group_test.go @@ -42,6 +42,15 @@ func (m *mockAgentGroupRepo) List(ctx context.Context) ([]*domain.AgentGroup, er return groups, nil } +// ListPaginated mirrors the SQL-side window. SCALE-002 closure (Sprint 2). +func (m *mockAgentGroupRepo) ListPaginated(ctx context.Context, limit, offset int) ([]*domain.AgentGroup, int64, error) { + all, err := m.List(ctx) + if err != nil { + return nil, 0, err + } + return sliceWindow(all, limit, offset), int64(len(all)), nil +} + func (m *mockAgentGroupRepo) Get(ctx context.Context, id string) (*domain.AgentGroup, error) { if m.GetErr != nil { return nil, m.GetErr diff --git a/internal/service/issuer.go b/internal/service/issuer.go index 0670de3..c1951e9 100644 --- a/internal/service/issuer.go +++ b/internal/service/issuer.go @@ -58,6 +58,9 @@ func (s *IssuerService) GetRegistry() *IssuerRegistry { } // List returns a paginated list of issuers. +// +// SCALE-002 closure (Sprint 2, 2026-05-16): pagination is pushed into +// the SQL layer via IssuerRepository.ListPaginated. func (s *IssuerService) List(ctx context.Context, page, perPage int) ([]*domain.Issuer, int64, error) { if page < 1 { page = 1 @@ -65,21 +68,8 @@ func (s *IssuerService) List(ctx context.Context, page, perPage int) ([]*domain. if perPage < 1 { perPage = 50 } - - issuers, err := s.issuerRepo.List(ctx) - if err != nil { - return nil, 0, fmt.Errorf("failed to list issuers: %w", err) - } - total := int64(len(issuers)) - start := (page - 1) * perPage - if start >= int(total) { - return nil, total, nil - } - end := start + perPage - if end > int(total) { - end = int(total) - } - return issuers[start:end], total, nil + offset := (page - 1) * perPage + return s.issuerRepo.ListPaginated(ctx, perPage, offset) } // Get retrieves an issuer by ID. diff --git a/internal/service/target.go b/internal/service/target.go index dea2ef5..d4dbe22 100644 --- a/internal/service/target.go +++ b/internal/service/target.go @@ -89,6 +89,11 @@ func NewTargetService( } // List returns a paginated list of deployment targets. +// +// SCALE-002 closure (Sprint 2, 2026-05-16): pagination is pushed into +// the SQL layer via TargetRepository.ListPaginated. Pre-fix this called +// targetRepo.List(ctx) and sliced in memory, which marshalled the +// entire targets table per request — a problem on large-fleet deploys. func (s *TargetService) List(ctx context.Context, page, perPage int) ([]*domain.DeploymentTarget, int64, error) { if page < 1 { page = 1 @@ -96,21 +101,8 @@ func (s *TargetService) List(ctx context.Context, page, perPage int) ([]*domain. if perPage < 1 { perPage = 50 } - - targets, err := s.targetRepo.List(ctx) - if err != nil { - return nil, 0, fmt.Errorf("failed to list targets: %w", err) - } - total := int64(len(targets)) - start := (page - 1) * perPage - if start >= int(total) { - return nil, total, nil - } - end := start + perPage - if end > int(total) { - end = int(total) - } - return targets[start:end], total, nil + offset := (page - 1) * perPage + return s.targetRepo.ListPaginated(ctx, perPage, offset) } // Get retrieves a deployment target by ID. diff --git a/internal/service/team.go b/internal/service/team.go index 515e98c..8b12952 100644 --- a/internal/service/team.go +++ b/internal/service/team.go @@ -31,6 +31,9 @@ func NewTeamService( } // List returns a paginated list of teams. +// +// SCALE-002 closure (Sprint 2, 2026-05-16): pagination is pushed into +// the SQL layer via TeamRepository.ListPaginated. func (s *TeamService) List(ctx context.Context, page, perPage int) ([]*domain.Team, int64, error) { if page < 1 { page = 1 @@ -38,21 +41,8 @@ func (s *TeamService) List(ctx context.Context, page, perPage int) ([]*domain.Te if perPage < 1 { perPage = 50 } - - teams, err := s.teamRepo.List(ctx) - if err != nil { - return nil, 0, fmt.Errorf("failed to list teams: %w", err) - } - total := int64(len(teams)) - start := (page - 1) * perPage - if start >= int(total) { - return nil, total, nil - } - end := start + perPage - if end > int(total) { - end = int(total) - } - return teams[start:end], total, nil + offset := (page - 1) * perPage + return s.teamRepo.ListPaginated(ctx, perPage, offset) } // Get retrieves a team by ID. diff --git a/internal/service/team_test.go b/internal/service/team_test.go index a2daea5..a8d67a4 100644 --- a/internal/service/team_test.go +++ b/internal/service/team_test.go @@ -3,6 +3,7 @@ package service import ( "context" "errors" + "strconv" "strings" "testing" @@ -30,6 +31,15 @@ func (m *mockTeamRepo) List(ctx context.Context) ([]*domain.Team, error) { return teams, nil } +// ListPaginated mirrors the SQL-side window. SCALE-002 closure (Sprint 2). +func (m *mockTeamRepo) ListPaginated(ctx context.Context, limit, offset int) ([]*domain.Team, int64, error) { + all, err := m.List(ctx) + if err != nil { + return nil, 0, err + } + return sliceWindow(all, limit, offset), int64(len(all)), nil +} + func (m *mockTeamRepo) Get(ctx context.Context, id string) (*domain.Team, error) { if m.GetErr != nil { return nil, m.GetErr @@ -688,3 +698,42 @@ func TestTeamService_NilAuditService(t *testing.T) { t.Errorf("expected ID to be generated") } } + +// TestTeamService_List_SCALE002_PaginationPropagatesToRepo pins the +// SCALE-002 closure (Sprint 2, 2026-05-16): the service no longer +// fetches the full table and slices in memory; it propagates limit + +// offset to the repository layer. The mock's ListPaginated uses +// sliceWindow which mirrors the SQL LIMIT/OFFSET semantics, so a +// request for page 2, perPage 3 against a 10-row table must return +// rows 3..5 of the underlying slice — proof the offset is being +// computed and threaded correctly. +// +// Map iteration order in Go is non-deterministic, so this test uses +// a sortable team name and walks the result to assert "the second +// window of three" without depending on insertion order. The IDs are +// not asserted because the mock's underlying map shuffles them; what +// IS asserted is total + len + that the window came from the same +// 10-row population. +func TestTeamService_List_SCALE002_PaginationPropagatesToRepo(t *testing.T) { + ctx := context.Background() + mockTeamRepo := newMockTeamRepository() + mockAuditRepo := newMockAuditRepository() + auditService := NewAuditService(mockAuditRepo) + teamService := NewTeamService(mockTeamRepo, auditService) + for i := 0; i < 10; i++ { + mockTeamRepo.AddTeam(&domain.Team{ + ID: "team-scale002-" + strconv.Itoa(i), + Name: "Team " + strconv.Itoa(i), + }) + } + teams, total, err := teamService.List(ctx, 2, 3) + if err != nil { + t.Fatalf("List: %v", err) + } + if total != 10 { + t.Errorf("total = %d; want 10", total) + } + if len(teams) != 3 { + t.Errorf("len(teams) = %d; want 3 (page 2 of 10 with perPage 3 should yield 3 rows)", len(teams)) + } +} diff --git a/internal/service/testutil_test.go b/internal/service/testutil_test.go index c1f6e45..5c78cf5 100644 --- a/internal/service/testutil_test.go +++ b/internal/service/testutil_test.go @@ -15,6 +15,27 @@ import ( var errNotFound = errors.New("not found") +// sliceWindow is a tiny helper for the SCALE-002 mock ListPaginated +// implementations. It mirrors the SQL LIMIT/OFFSET window over an +// in-memory slice with the same normalisation as the postgres repos +// (limit≤0 → return as-is; offset<0 → 0; out-of-range → empty). +func sliceWindow[T any](all []T, limit, offset int) []T { + if offset < 0 { + offset = 0 + } + if offset >= len(all) { + return nil + } + if limit <= 0 { + return all[offset:] + } + end := offset + limit + if end > len(all) { + end = len(all) + } + return all[offset:end] +} + // testEncryptionKey is a deterministic passphrase for unit tests that // exercise IssuerService/TargetService write paths. After the C-2 remediation // these services fail closed when no key is configured, so happy-path tests @@ -1246,6 +1267,15 @@ func (m *mockTargetRepo) List(ctx context.Context) ([]*domain.DeploymentTarget, return targets, nil } +// ListPaginated mirrors the SQL-side window. SCALE-002 closure (Sprint 2). +func (m *mockTargetRepo) ListPaginated(ctx context.Context, limit, offset int) ([]*domain.DeploymentTarget, int64, error) { + all, err := m.List(ctx) + if err != nil { + return nil, 0, err + } + return sliceWindow(all, limit, offset), int64(len(all)), nil +} + func (m *mockTargetRepo) Get(ctx context.Context, id string) (*domain.DeploymentTarget, error) { m.mu.Lock() defer m.mu.Unlock() @@ -1535,6 +1565,15 @@ func (m *mockIssuerRepository) List(ctx context.Context) ([]*domain.Issuer, erro return issuers, nil } +// ListPaginated mirrors the SQL-side window. SCALE-002 closure (Sprint 2). +func (m *mockIssuerRepository) ListPaginated(ctx context.Context, limit, offset int) ([]*domain.Issuer, int64, error) { + all, err := m.List(ctx) + if err != nil { + return nil, 0, err + } + return sliceWindow(all, limit, offset), int64(len(all)), nil +} + func (m *mockIssuerRepository) Get(ctx context.Context, id string) (*domain.Issuer, error) { if m.GetErr != nil { return nil, m.GetErr