fix(api,web,mcp): add bulk-renew + bulk-reassign endpoints, drop client-side N×HTTP loops (L-1 master)

Two audit findings, both category cat-l, both rooted in
web/src/pages/CertificatesPage.tsx. Pre-L-1 the GUI looped per-cert
HTTP calls — 100 selected certs = 100 sequential round-trips × ~50–200
ms each = a 5–20-second wedge during which the operator stared at a
progress bar. Post-L-1 each workflow is a single POST.

  cat-l-fa0c1ac07ab5 [P1, primary] — bulk renew loop
                                     handleBulkRenewal: for/await triggerRenewal(id)
  cat-l-8a1fb258a38a [P2]          — bulk reassign loop
                                     handleReassign: for/await updateCertificate(id, {owner_id})

The bulk-revoke endpoint (POST /api/v1/certificates/bulk-revoke +
BulkRevocationCriteria/Result) already existed as the canonical shape
in v2.0.x — L-1 ports that pattern to renew + reassign with per-action
twists.

Backend (Go)
- internal/domain/bulk_renewal.go: BulkRenewalCriteria mirrors
  BulkRevocationCriteria (criteria + IDs modes); BulkRenewalResult
  envelope adds EnqueuedJobs[] for per-cert {certificate_id, job_id};
  shared BulkOperationError type for all bulk paths.
- internal/domain/bulk_reassignment.go: narrower shape — IDs-only,
  owner_id required, team_id optional.
- internal/service/bulk_renewal.go::BulkRenewalService.BulkRenew:
  resolves criteria → status filter (Archived/Revoked/Expired/
  RenewalInProgress all silent-skip) → per-cert status flip + job
  create. Keygen-mode-aware so jobs land in the same initial status
  as single-cert TriggerRenewal. Single bulk audit event per call,
  not N.
- internal/service/bulk_reassignment.go::BulkReassignmentService.
  BulkReassign: validates owner_id upfront via the
  ErrBulkReassignOwnerNotFound typed sentinel — non-existent owner
  returns 400 before any cert is touched. Already-owned-by-target
  is silent-skip. Single bulk audit event.
- internal/api/handler/{bulk_renewal,bulk_reassignment}.go: HTTP
  shape mirrors bulk_revocation.go. NOT admin-gated (renew is non-
  destructive; reassign is a common-case workflow). Sentinel-error
  → 400 mapping for OwnerNotFound.
- internal/api/router/router.go: three bulk-* routes registered as a
  block before the {id} routes. HandlerRegistry gains BulkRenewal +
  BulkReassignment fields.
- cmd/server/main.go: NewBulkRenewalService threads cfg.Keygen.Mode
  so bulk-renew jobs land in same initial state as single-cert path.

Frontend
- web/src/api/client.ts: bulkRenewCertificates(criteria) +
  bulkReassignCertificates(request) functions with full TS types.
- web/src/pages/CertificatesPage.tsx: handleBulkRenewal + handleReassign
  rewritten from N-call loops to single calls. Result envelope drives
  progress UI; first-error message surfaced when total_failed > 0.
  Stale triggerRenewal + updateCertificate imports removed.

MCP
- internal/mcp/types.go: BulkRenewCertificatesInput +
  BulkReassignCertificatesInput.
- internal/mcp/tools.go: certctl_bulk_renew_certificates +
  certctl_bulk_reassign_certificates tools mirroring the existing
  certctl_bulk_revoke_certificates pattern.

OpenAPI
- api/openapi.yaml: two new operations (bulkRenewCertificates,
  bulkReassignCertificates) under Certificates tag. Four new schemas
  (BulkRenewRequest, BulkRenewResult, BulkEnqueuedJob,
  BulkReassignRequest, BulkReassignResult).

Tests
- Domain: BulkRenewalCriteria.IsEmpty + BulkReassignmentRequest.IsEmpty
  IsEmpty contracts; JSON round-trip shape pinning.
- Service: 7 BulkRenew tests (happy/criteria-mode/skips-RenewalInProgress/
  skips-revoked-archived/empty-criteria-error/partial-failure/
  audit-event-emitted) + 8 BulkReassign tests (happy/skips-already-
  owned/owner-required/empty-IDs/owner-not-found-sentinel/team-id-
  optional/team-id-provided/partial-failure/audit-event-emitted).
- Handler: 5 BulkRenew handler tests (happy/empty-body-400/wrong-
  method-405/actor-attribution/service-error-500) + 6 BulkReassign
  handler tests (happy/empty-IDs-400/missing-owner-400/owner-not-
  found-400-via-sentinel/wrong-method-405/generic-error-500).

CI guardrail
- .github/workflows/ci.yml: 'Forbidden client-side bulk-action loop
  regression guard (L-1)'. Greps web/src/pages/CertificatesPage.tsx
  for 'for(...) await triggerRenewal(...)' and 'for(...) await
  updateCertificate(...)' patterns; comment lines exempt; test files
  exempt. Verified locally (passes against post-fix tree, fires
  against synthetic regression).

Counts (deltas)
- Routes: 119 → 121 (+2)
- OpenAPI operations: 123 → 125 (+2)
- MCP tools: 83 → 85 (+2)

Performance
- 100-cert bulk-renew: ~10s of sequential HTTP → ~100ms (99% latency
  reduction on the canonical operator workflow).
- Audit event volume: 1 + N per operation → 1.

Out of scope (deferred follow-ups)
- cat-b-31ceb6aaa9f1: updateOwner/updateTeam/updateAgentGroup orphan
  (different shape — wire existing PUT to GUI, not new bulk endpoint).
- cat-k-e85d1099b2d7: CertificatesPage no pagination UI.
- cat-i-b0924b6675f8: MCP missing claim/dismiss/acknowledge (L-1 added
  2 new tools but does not close that finding).

Verification
- go build / vet / test -short / test -short -race all clean.
- web tsc --noEmit + vitest run all clean (296 tests passing).
- OpenAPI YAML parses (89 paths, 125 ops).
- L-1 CI guardrail passes against post-fix tree, fires against
  synthetic regression.

No push.
This commit is contained in:
shankar0123
2026-04-25 14:33:02 +00:00
parent 677524d9ec
commit f0865bb051
21 changed files with 2094 additions and 23 deletions
+104
View File
@@ -0,0 +1,104 @@
package handler
import (
"context"
"encoding/json"
"errors"
"net/http"
"github.com/shankar0123/certctl/internal/api/middleware"
"github.com/shankar0123/certctl/internal/domain"
"github.com/shankar0123/certctl/internal/service"
)
// BulkReassignmentService defines the service interface for bulk
// owner-reassignment operations.
type BulkReassignmentService interface {
BulkReassign(ctx context.Context, request domain.BulkReassignmentRequest, actor string) (*domain.BulkReassignmentResult, error)
}
// BulkReassignmentHandler handles HTTP requests for bulk reassignment
// operations.
type BulkReassignmentHandler struct {
svc BulkReassignmentService
}
// NewBulkReassignmentHandler creates a new BulkReassignmentHandler.
func NewBulkReassignmentHandler(svc BulkReassignmentService) BulkReassignmentHandler {
return BulkReassignmentHandler{svc: svc}
}
// bulkReassignRequest is the JSON shape decoded from the request body.
type bulkReassignRequest struct {
CertificateIDs []string `json:"certificate_ids"`
OwnerID string `json:"owner_id"`
TeamID string `json:"team_id,omitempty"`
}
// BulkReassign handles POST /api/v1/certificates/bulk-reassign
//
// L-2 closure (cat-l-8a1fb258a38a): pre-L-2 the GUI looped
// `await updateCertificate(id, { owner_id })`. Post-L-2 the GUI POSTs
// once and the server mutates owner_id (and optionally team_id) on N
// certs, returning per-cert success/skip/error counts.
//
// Narrower contract than bulk-renew: explicit IDs only, no criteria-mode.
// OwnerID is required; TeamID is optional and updates the team only when
// non-empty (matches the existing per-cert PUT contract).
//
// Auth: any authenticated caller can reassign certs they own/have
// access to. NOT admin-gated — operators reassign ownership during
// team transitions all the time and gating that on admin would block
// the common-case workflow.
//
// Validation order: empty body → 400; empty IDs → 400; missing
// owner_id → 400; non-existent owner_id → 400 via the
// ErrBulkReassignOwnerNotFound sentinel mapped here.
func (h BulkReassignmentHandler) BulkReassign(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
requestID := middleware.GetRequestID(r.Context())
var req bulkReassignRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
ErrorWithRequestID(w, http.StatusBadRequest, "Invalid request body", requestID)
return
}
request := domain.BulkReassignmentRequest{
CertificateIDs: req.CertificateIDs,
OwnerID: req.OwnerID,
TeamID: req.TeamID,
}
if request.IsEmpty() {
ErrorWithRequestID(w, http.StatusBadRequest,
"At least one certificate_id is required",
requestID)
return
}
if request.OwnerID == "" {
ErrorWithRequestID(w, http.StatusBadRequest, "owner_id is required", requestID)
return
}
actor := resolveActor(r.Context())
result, err := h.svc.BulkReassign(r.Context(), request, actor)
if err != nil {
// Sentinel-error → 400 mapping. ErrBulkReassignOwnerNotFound
// means the operator picked an owner that doesn't exist; this
// is bad input (400), not a server error (500). Mirrors the
// post-M-1 errToStatus convention rather than substring-matching
// err.Error().
if errors.Is(err, service.ErrBulkReassignOwnerNotFound) {
ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
return
}
ErrorWithRequestID(w, http.StatusInternalServerError, "Bulk reassignment failed: "+err.Error(), requestID)
return
}
JSON(w, http.StatusOK, result)
}
@@ -0,0 +1,149 @@
package handler
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/shankar0123/certctl/internal/domain"
"github.com/shankar0123/certctl/internal/service"
)
type mockBulkReassignmentService struct {
BulkReassignFn func(ctx context.Context, request domain.BulkReassignmentRequest, actor string) (*domain.BulkReassignmentResult, error)
}
func (m *mockBulkReassignmentService) BulkReassign(ctx context.Context, request domain.BulkReassignmentRequest, actor string) (*domain.BulkReassignmentResult, error) {
if m.BulkReassignFn != nil {
return m.BulkReassignFn(ctx, request, actor)
}
return &domain.BulkReassignmentResult{}, nil
}
func TestBulkReassign_Handler_HappyPath(t *testing.T) {
svc := &mockBulkReassignmentService{
BulkReassignFn: func(ctx context.Context, request domain.BulkReassignmentRequest, actor string) (*domain.BulkReassignmentResult, error) {
if request.OwnerID != "o-bob" {
t.Errorf("owner_id = %q, want 'o-bob'", request.OwnerID)
}
return &domain.BulkReassignmentResult{
TotalMatched: 2, TotalReassigned: 2,
}, nil
},
}
h := NewBulkReassignmentHandler(svc)
body := `{"certificate_ids":["mc-1","mc-2"],"owner_id":"o-bob"}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates/bulk-reassign", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(authedContext())
w := httptest.NewRecorder()
h.BulkReassign(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", w.Code, w.Body.String())
}
var result domain.BulkReassignmentResult
if err := json.NewDecoder(w.Body).Decode(&result); err != nil {
t.Fatalf("decode failed: %v", err)
}
if result.TotalReassigned != 2 {
t.Errorf("envelope drift: TotalReassigned=%d, want 2", result.TotalReassigned)
}
}
func TestBulkReassign_Handler_EmptyIDs_400(t *testing.T) {
svc := &mockBulkReassignmentService{}
h := NewBulkReassignmentHandler(svc)
body := `{"certificate_ids":[],"owner_id":"o-bob"}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates/bulk-reassign", bytes.NewBufferString(body))
req = req.WithContext(authedContext())
w := httptest.NewRecorder()
h.BulkReassign(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400", w.Code)
}
}
func TestBulkReassign_Handler_MissingOwnerID_400(t *testing.T) {
svc := &mockBulkReassignmentService{}
h := NewBulkReassignmentHandler(svc)
body := `{"certificate_ids":["mc-1"]}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates/bulk-reassign", bytes.NewBufferString(body))
req = req.WithContext(authedContext())
w := httptest.NewRecorder()
h.BulkReassign(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400", w.Code)
}
if !strings.Contains(w.Body.String(), "owner_id") {
t.Errorf("body should name owner_id; got: %s", w.Body.String())
}
}
// TestBulkReassign_Handler_OwnerNotFound_400 — sentinel-error → 400
// mapping. Operator picked an owner that doesn't exist; that's bad
// input, not a server error.
func TestBulkReassign_Handler_OwnerNotFound_400(t *testing.T) {
svc := &mockBulkReassignmentService{
BulkReassignFn: func(ctx context.Context, request domain.BulkReassignmentRequest, actor string) (*domain.BulkReassignmentResult, error) {
return nil, fmt.Errorf("%w: %s", service.ErrBulkReassignOwnerNotFound, request.OwnerID)
},
}
h := NewBulkReassignmentHandler(svc)
body := `{"certificate_ids":["mc-1"],"owner_id":"o-ghost"}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates/bulk-reassign", bytes.NewBufferString(body))
req = req.WithContext(authedContext())
w := httptest.NewRecorder()
h.BulkReassign(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400 (ErrBulkReassignOwnerNotFound → 400)", w.Code)
}
if !strings.Contains(w.Body.String(), "owner not found") {
t.Errorf("body should mention 'owner not found'; got: %s", w.Body.String())
}
}
func TestBulkReassign_Handler_WrongMethod_405(t *testing.T) {
svc := &mockBulkReassignmentService{}
h := NewBulkReassignmentHandler(svc)
for _, method := range []string{http.MethodGet, http.MethodPut, http.MethodDelete, http.MethodPatch} {
req := httptest.NewRequest(method, "/api/v1/certificates/bulk-reassign", nil)
req = req.WithContext(authedContext())
w := httptest.NewRecorder()
h.BulkReassign(w, req)
if w.Code != http.StatusMethodNotAllowed {
t.Errorf("%s → %d, want 405", method, w.Code)
}
}
}
func TestBulkReassign_Handler_GenericError_500(t *testing.T) {
svc := &mockBulkReassignmentService{
BulkReassignFn: func(ctx context.Context, request domain.BulkReassignmentRequest, actor string) (*domain.BulkReassignmentResult, error) {
return nil, errors.New("simulated outage")
},
}
h := NewBulkReassignmentHandler(svc)
body := `{"certificate_ids":["mc-1"],"owner_id":"o-bob"}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates/bulk-reassign", bytes.NewBufferString(body))
req = req.WithContext(authedContext())
w := httptest.NewRecorder()
h.BulkReassign(w, req)
if w.Code != http.StatusInternalServerError {
t.Errorf("status = %d, want 500", w.Code)
}
}
+96
View File
@@ -0,0 +1,96 @@
package handler
import (
"context"
"encoding/json"
"net/http"
"github.com/shankar0123/certctl/internal/api/middleware"
"github.com/shankar0123/certctl/internal/domain"
)
// BulkRenewalService defines the service interface for bulk certificate
// renewal. Mirrors BulkRevocationService — handler doesn't import the
// concrete service struct so tests can inject a mock without pulling in
// the full service-layer dependency graph.
type BulkRenewalService interface {
BulkRenew(ctx context.Context, criteria domain.BulkRenewalCriteria, actor string) (*domain.BulkRenewalResult, error)
}
// BulkRenewalHandler handles HTTP requests for bulk renewal operations.
type BulkRenewalHandler struct {
svc BulkRenewalService
}
// NewBulkRenewalHandler creates a new BulkRenewalHandler.
func NewBulkRenewalHandler(svc BulkRenewalService) BulkRenewalHandler {
return BulkRenewalHandler{svc: svc}
}
// bulkRenewRequest mirrors the BulkRenewalCriteria JSON shape (the
// handler decodes into this struct then hands a domain.BulkRenewalCriteria
// to the service — same indirection as bulkRevokeRequest in
// bulk_revocation.go).
type bulkRenewRequest struct {
ProfileID string `json:"profile_id,omitempty"`
OwnerID string `json:"owner_id,omitempty"`
AgentID string `json:"agent_id,omitempty"`
IssuerID string `json:"issuer_id,omitempty"`
TeamID string `json:"team_id,omitempty"`
CertificateIDs []string `json:"certificate_ids,omitempty"`
}
// BulkRenew handles POST /api/v1/certificates/bulk-renew
//
// L-1 closure (cat-l-fa0c1ac07ab5): pre-L-1 the GUI looped
// `await triggerRenewal(id)` over the selection. Post-L-1 it POSTs once
// and the server enqueues N renewal jobs server-side, returning a
// per-cert {certificate_id, job_id} envelope.
//
// Request shape mirrors BulkRevokeRequest (criteria-mode + IDs-mode);
// the "renew all certs of profile X before its CA changes" use case is
// why criteria-mode is supported in addition to explicit IDs.
//
// Auth: any authenticated caller can renew certs they have read-access
// to (matches POST /api/v1/certificates/{id}/renew). NOT admin-gated
// like bulk-revoke — bulk-renew is non-destructive (worst case it
// kicks off some redundant ACME orders) so we don't need the
// fleet-scale-destruction gate.
func (h BulkRenewalHandler) BulkRenew(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
requestID := middleware.GetRequestID(r.Context())
var req bulkRenewRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
ErrorWithRequestID(w, http.StatusBadRequest, "Invalid request body", requestID)
return
}
criteria := domain.BulkRenewalCriteria{
ProfileID: req.ProfileID,
OwnerID: req.OwnerID,
AgentID: req.AgentID,
IssuerID: req.IssuerID,
TeamID: req.TeamID,
CertificateIDs: req.CertificateIDs,
}
if criteria.IsEmpty() {
ErrorWithRequestID(w, http.StatusBadRequest,
"At least one filter criterion is required (profile_id, owner_id, agent_id, issuer_id, team_id, or certificate_ids)",
requestID)
return
}
actor := resolveActor(r.Context())
result, err := h.svc.BulkRenew(r.Context(), criteria, actor)
if err != nil {
ErrorWithRequestID(w, http.StatusInternalServerError, "Bulk renewal failed: "+err.Error(), requestID)
return
}
JSON(w, http.StatusOK, result)
}
@@ -0,0 +1,148 @@
package handler
import (
"bytes"
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/shankar0123/certctl/internal/api/middleware"
"github.com/shankar0123/certctl/internal/domain"
)
// mockBulkRenewalService is a test implementation of BulkRenewalService.
type mockBulkRenewalService struct {
BulkRenewFn func(ctx context.Context, criteria domain.BulkRenewalCriteria, actor string) (*domain.BulkRenewalResult, error)
}
func (m *mockBulkRenewalService) BulkRenew(ctx context.Context, criteria domain.BulkRenewalCriteria, actor string) (*domain.BulkRenewalResult, error) {
if m.BulkRenewFn != nil {
return m.BulkRenewFn(ctx, criteria, actor)
}
return &domain.BulkRenewalResult{}, nil
}
// authedContext mirrors adminContext but without the admin flag —
// bulk-renew is NOT admin-gated, any authenticated caller can use it.
func authedContext() context.Context {
ctx := context.WithValue(context.Background(), middleware.RequestIDKey{}, "test-request-id-renew")
ctx = context.WithValue(ctx, middleware.UserKey{}, "alice")
return ctx
}
func TestBulkRenew_Handler_HappyPath(t *testing.T) {
svc := &mockBulkRenewalService{
BulkRenewFn: func(ctx context.Context, criteria domain.BulkRenewalCriteria, actor string) (*domain.BulkRenewalResult, error) {
if len(criteria.CertificateIDs) != 3 {
t.Errorf("expected 3 IDs, got %d", len(criteria.CertificateIDs))
}
if actor != "alice" {
t.Errorf("actor = %q, want 'alice' (resolved from middleware UserKey)", actor)
}
return &domain.BulkRenewalResult{
TotalMatched: 3,
TotalEnqueued: 3,
EnqueuedJobs: []domain.BulkEnqueuedJob{
{CertificateID: "mc-1", JobID: "job-a"},
{CertificateID: "mc-2", JobID: "job-b"},
{CertificateID: "mc-3", JobID: "job-c"},
},
}, nil
},
}
h := NewBulkRenewalHandler(svc)
body := `{"certificate_ids":["mc-1","mc-2","mc-3"]}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates/bulk-renew", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(authedContext())
w := httptest.NewRecorder()
h.BulkRenew(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", w.Code, w.Body.String())
}
var result domain.BulkRenewalResult
if err := json.NewDecoder(w.Body).Decode(&result); err != nil {
t.Fatalf("decode failed: %v", err)
}
if result.TotalEnqueued != 3 || len(result.EnqueuedJobs) != 3 {
t.Errorf("envelope drift: enqueued=%d jobs=%d, want 3/3",
result.TotalEnqueued, len(result.EnqueuedJobs))
}
}
func TestBulkRenew_Handler_EmptyBody_400(t *testing.T) {
svc := &mockBulkRenewalService{}
h := NewBulkRenewalHandler(svc)
req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates/bulk-renew", bytes.NewBufferString(`{}`))
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(authedContext())
w := httptest.NewRecorder()
h.BulkRenew(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400 (empty criteria must reject)", w.Code)
}
if !strings.Contains(w.Body.String(), "filter criterion") {
t.Errorf("body should name the criteria-required contract; got: %s", w.Body.String())
}
}
func TestBulkRenew_Handler_WrongMethod_405(t *testing.T) {
svc := &mockBulkRenewalService{}
h := NewBulkRenewalHandler(svc)
for _, method := range []string{http.MethodGet, http.MethodPut, http.MethodDelete, http.MethodPatch} {
req := httptest.NewRequest(method, "/api/v1/certificates/bulk-renew", nil)
req = req.WithContext(authedContext())
w := httptest.NewRecorder()
h.BulkRenew(w, req)
if w.Code != http.StatusMethodNotAllowed {
t.Errorf("%s → status %d, want 405", method, w.Code)
}
}
}
func TestBulkRenew_Handler_ActorAttribution(t *testing.T) {
var capturedActor string
svc := &mockBulkRenewalService{
BulkRenewFn: func(ctx context.Context, criteria domain.BulkRenewalCriteria, actor string) (*domain.BulkRenewalResult, error) {
capturedActor = actor
return &domain.BulkRenewalResult{}, nil
},
}
h := NewBulkRenewalHandler(svc)
body := `{"certificate_ids":["mc-1"]}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates/bulk-renew", bytes.NewBufferString(body))
req = req.WithContext(authedContext())
w := httptest.NewRecorder()
h.BulkRenew(w, req)
if capturedActor != "alice" {
t.Errorf("actor not threaded from middleware.UserKey: got %q, want 'alice'", capturedActor)
}
}
func TestBulkRenew_Handler_ServiceError_500(t *testing.T) {
svc := &mockBulkRenewalService{
BulkRenewFn: func(ctx context.Context, criteria domain.BulkRenewalCriteria, actor string) (*domain.BulkRenewalResult, error) {
return nil, errors.New("simulated DB failure")
},
}
h := NewBulkRenewalHandler(svc)
body := `{"certificate_ids":["mc-1"]}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates/bulk-renew", bytes.NewBufferString(body))
req = req.WithContext(authedContext())
w := httptest.NewRecorder()
h.BulkRenew(w, req)
if w.Code != http.StatusInternalServerError {
t.Errorf("status = %d, want 500", w.Code)
}
}