mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-12 05:38:53 +00:00
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:
@@ -0,0 +1,148 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/domain"
|
||||
"github.com/shankar0123/certctl/internal/repository"
|
||||
)
|
||||
|
||||
// ErrBulkReassignOwnerNotFound is the typed sentinel for a non-existent
|
||||
// target OwnerID. The handler maps it to 400 (bad input — the operator
|
||||
// picked an owner that doesn't exist) rather than 500 (server error).
|
||||
// Sentinel-error rather than substring-error matches the project's
|
||||
// post-M-1 error-mapping convention.
|
||||
var ErrBulkReassignOwnerNotFound = errors.New("owner not found")
|
||||
|
||||
// BulkReassignmentService coordinates bulk owner-reassignment of
|
||||
// certificates.
|
||||
//
|
||||
// L-2 closure (cat-l-8a1fb258a38a): the GUI used to loop
|
||||
// `await updateCertificate(id, { owner_id })` over the selection at
|
||||
// `web/src/pages/CertificatesPage.tsx::handleReassign`. Post-L-2 the
|
||||
// GUI POSTs once. Narrower than BulkRenewal: explicit IDs only, no
|
||||
// criteria-mode (criteria-mode reassignment doesn't have a strong use
|
||||
// case — operators query first then reassign by ID).
|
||||
//
|
||||
// Validation order: empty IDs → 400, missing OwnerID → 400, OwnerID
|
||||
// not in owners table → 400 (ErrBulkReassignOwnerNotFound). Resolving
|
||||
// the owner upfront means we fail-fast without mutating any cert if
|
||||
// the operator typo'd the owner ID.
|
||||
type BulkReassignmentService struct {
|
||||
certRepo repository.CertificateRepository
|
||||
ownerRepo repository.OwnerRepository
|
||||
auditService *AuditService
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// NewBulkReassignmentService creates a new BulkReassignmentService.
|
||||
func NewBulkReassignmentService(
|
||||
certRepo repository.CertificateRepository,
|
||||
ownerRepo repository.OwnerRepository,
|
||||
auditService *AuditService,
|
||||
logger *slog.Logger,
|
||||
) *BulkReassignmentService {
|
||||
return &BulkReassignmentService{
|
||||
certRepo: certRepo,
|
||||
ownerRepo: ownerRepo,
|
||||
auditService: auditService,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// BulkReassign updates owner_id (and optionally team_id) on every cert
|
||||
// in request.CertificateIDs. Skips certs whose owner_id already equals
|
||||
// the target (silent no-op — surfaced as TotalSkipped++, not as a fake
|
||||
// "succeeded" count, so operators see "5 of your 10 selections were
|
||||
// no-ops because Alice already owned them" without triaging fake
|
||||
// errors).
|
||||
//
|
||||
// Partial failures don't abort the batch — the failing cert lands in
|
||||
// Errors[]; the loop continues. Mirrors BulkRevocationService and
|
||||
// BulkRenewalService partial-failure semantics.
|
||||
//
|
||||
// Audit: a single audit event is emitted at the end with the criteria
|
||||
// + counts. NOT N events.
|
||||
func (s *BulkReassignmentService) BulkReassign(ctx context.Context, request domain.BulkReassignmentRequest, actor string) (*domain.BulkReassignmentResult, error) {
|
||||
if request.IsEmpty() {
|
||||
return nil, fmt.Errorf("at least one certificate_id is required")
|
||||
}
|
||||
if request.OwnerID == "" {
|
||||
return nil, fmt.Errorf("owner_id is required")
|
||||
}
|
||||
|
||||
// Validate the target owner exists BEFORE touching any cert. This
|
||||
// fail-fast pattern means an operator who typo'd 'o-alic' (missing
|
||||
// 'e') doesn't half-reassign 50 certs before the 51st surfaces the
|
||||
// FK violation.
|
||||
if _, err := s.ownerRepo.Get(ctx, request.OwnerID); err != nil {
|
||||
return nil, fmt.Errorf("%w: %s", ErrBulkReassignOwnerNotFound, request.OwnerID)
|
||||
}
|
||||
|
||||
result := &domain.BulkReassignmentResult{}
|
||||
|
||||
for _, id := range request.CertificateIDs {
|
||||
cert, err := s.certRepo.Get(ctx, id)
|
||||
if err != nil {
|
||||
result.TotalFailed++
|
||||
result.Errors = append(result.Errors, domain.BulkOperationError{
|
||||
CertificateID: id,
|
||||
Error: fmt.Sprintf("failed to fetch certificate: %v", err),
|
||||
})
|
||||
continue
|
||||
}
|
||||
result.TotalMatched++
|
||||
|
||||
// No-op skip: cert already owned by the target. team_id may
|
||||
// still differ — we still skip if owner matches AND
|
||||
// team_id-update is a no-op (team unchanged or team_id field
|
||||
// not set on the request). This prevents fake "reassigned"
|
||||
// counts when nothing actually changed.
|
||||
ownerUnchanged := cert.OwnerID == request.OwnerID
|
||||
teamUnchanged := request.TeamID == "" || cert.TeamID == request.TeamID
|
||||
if ownerUnchanged && teamUnchanged {
|
||||
result.TotalSkipped++
|
||||
continue
|
||||
}
|
||||
|
||||
cert.OwnerID = request.OwnerID
|
||||
if request.TeamID != "" {
|
||||
cert.TeamID = request.TeamID
|
||||
}
|
||||
if err := s.certRepo.Update(ctx, cert); err != nil {
|
||||
result.TotalFailed++
|
||||
result.Errors = append(result.Errors, domain.BulkOperationError{
|
||||
CertificateID: id,
|
||||
Error: fmt.Sprintf("failed to update certificate: %v", err),
|
||||
})
|
||||
s.logger.Warn("bulk reassignment: update failed",
|
||||
"certificate_id", id, "error", err)
|
||||
continue
|
||||
}
|
||||
result.TotalReassigned++
|
||||
}
|
||||
|
||||
// Single bulk audit event at the end.
|
||||
auditDetails := map[string]interface{}{
|
||||
"owner_id": request.OwnerID,
|
||||
"certificate_ids": strings.Join(request.CertificateIDs, ","),
|
||||
"total_matched": result.TotalMatched,
|
||||
"total_reassigned": result.TotalReassigned,
|
||||
"total_skipped": result.TotalSkipped,
|
||||
"total_failed": result.TotalFailed,
|
||||
}
|
||||
if request.TeamID != "" {
|
||||
auditDetails["team_id"] = request.TeamID
|
||||
}
|
||||
if err := s.auditService.RecordEvent(ctx, actor, domain.ActorTypeUser,
|
||||
"bulk_reassignment_initiated", "certificate", "bulk",
|
||||
auditDetails); err != nil {
|
||||
s.logger.Error("failed to record bulk reassignment audit event", "error", err)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/domain"
|
||||
)
|
||||
|
||||
func newBulkReassignmentTestService() (*BulkReassignmentService, *mockCertRepo, *mockOwnerRepo, *mockAuditRepo) {
|
||||
certRepo := newMockCertificateRepository()
|
||||
ownerRepo := newMockOwnerRepository()
|
||||
auditRepo := newMockAuditRepository()
|
||||
auditService := NewAuditService(auditRepo)
|
||||
svc := NewBulkReassignmentService(certRepo, ownerRepo, auditService, slog.Default())
|
||||
return svc, certRepo, ownerRepo, auditRepo
|
||||
}
|
||||
|
||||
// addOwnedCert seeds a cert with a specific owner+team for reassignment.
|
||||
func addOwnedCert(repo *mockCertRepo, id, ownerID, teamID string) {
|
||||
cert := &domain.ManagedCertificate{
|
||||
ID: id, CommonName: id, Status: domain.CertificateStatusActive,
|
||||
OwnerID: ownerID, TeamID: teamID,
|
||||
ExpiresAt: time.Now().AddDate(0, 1, 0),
|
||||
}
|
||||
repo.AddCert(cert)
|
||||
}
|
||||
|
||||
func addOwner(repo *mockOwnerRepo, id string) {
|
||||
repo.owners[id] = &domain.Owner{ID: id, Name: id}
|
||||
}
|
||||
|
||||
// TestBulkReassign_HappyPath — N certs all reassigned successfully.
|
||||
func TestBulkReassign_HappyPath(t *testing.T) {
|
||||
svc, certRepo, ownerRepo, _ := newBulkReassignmentTestService()
|
||||
addOwner(ownerRepo, "o-bob")
|
||||
addOwnedCert(certRepo, "mc-1", "o-alice", "")
|
||||
addOwnedCert(certRepo, "mc-2", "o-alice", "")
|
||||
addOwnedCert(certRepo, "mc-3", "o-alice", "")
|
||||
|
||||
res, err := svc.BulkReassign(context.Background(),
|
||||
domain.BulkReassignmentRequest{
|
||||
CertificateIDs: []string{"mc-1", "mc-2", "mc-3"},
|
||||
OwnerID: "o-bob",
|
||||
}, "admin")
|
||||
if err != nil {
|
||||
t.Fatalf("BulkReassign failed: %v", err)
|
||||
}
|
||||
if res.TotalReassigned != 3 || res.TotalSkipped != 0 || res.TotalFailed != 0 {
|
||||
t.Errorf("counts = reassigned:%d skipped:%d failed:%d, want 3/0/0",
|
||||
res.TotalReassigned, res.TotalSkipped, res.TotalFailed)
|
||||
}
|
||||
for _, id := range []string{"mc-1", "mc-2", "mc-3"} {
|
||||
if certRepo.Certs[id].OwnerID != "o-bob" {
|
||||
t.Errorf("cert %s: owner_id = %s, want o-bob", id, certRepo.Certs[id].OwnerID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestBulkReassign_SkipsAlreadyOwned — certs already owned by the
|
||||
// target are no-op-skipped (not counted as reassigned, not surfaced as
|
||||
// errors). Operator sees "5 of your 10 selections were no-ops because
|
||||
// Bob already owned them" without triaging fake errors.
|
||||
func TestBulkReassign_SkipsAlreadyOwned(t *testing.T) {
|
||||
svc, certRepo, ownerRepo, _ := newBulkReassignmentTestService()
|
||||
addOwner(ownerRepo, "o-bob")
|
||||
addOwnedCert(certRepo, "mc-1", "o-bob", "") // already owned by target
|
||||
addOwnedCert(certRepo, "mc-2", "o-alice", "") // needs reassign
|
||||
|
||||
res, err := svc.BulkReassign(context.Background(),
|
||||
domain.BulkReassignmentRequest{
|
||||
CertificateIDs: []string{"mc-1", "mc-2"},
|
||||
OwnerID: "o-bob",
|
||||
}, "admin")
|
||||
if err != nil {
|
||||
t.Fatalf("BulkReassign failed: %v", err)
|
||||
}
|
||||
if res.TotalReassigned != 1 || res.TotalSkipped != 1 {
|
||||
t.Errorf("counts = reassigned:%d skipped:%d, want 1/1", res.TotalReassigned, res.TotalSkipped)
|
||||
}
|
||||
if len(res.Errors) != 0 {
|
||||
t.Errorf("already-owned skip should NOT populate Errors; got %v", res.Errors)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBulkReassign_OwnerIDRequired_Error — empty owner_id rejected.
|
||||
func TestBulkReassign_OwnerIDRequired_Error(t *testing.T) {
|
||||
svc, certRepo, _, _ := newBulkReassignmentTestService()
|
||||
addOwnedCert(certRepo, "mc-1", "o-alice", "")
|
||||
_, err := svc.BulkReassign(context.Background(),
|
||||
domain.BulkReassignmentRequest{CertificateIDs: []string{"mc-1"}, OwnerID: ""}, "admin")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty owner_id, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBulkReassign_EmptyIDs_Error — empty IDs rejected.
|
||||
func TestBulkReassign_EmptyIDs_Error(t *testing.T) {
|
||||
svc, _, ownerRepo, _ := newBulkReassignmentTestService()
|
||||
addOwner(ownerRepo, "o-bob")
|
||||
_, err := svc.BulkReassign(context.Background(),
|
||||
domain.BulkReassignmentRequest{CertificateIDs: []string{}, OwnerID: "o-bob"}, "admin")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty IDs, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBulkReassign_OwnerNotFound_TypedSentinel — non-existent OwnerID
|
||||
// returns ErrBulkReassignOwnerNotFound. Handler maps this to 400 (the
|
||||
// operator picked an owner that doesn't exist) rather than 500 (server
|
||||
// error). Sentinel-error rather than substring-error matches the
|
||||
// project's post-M-1 error-mapping convention.
|
||||
func TestBulkReassign_OwnerNotFound_TypedSentinel(t *testing.T) {
|
||||
svc, certRepo, _, _ := newBulkReassignmentTestService()
|
||||
addOwnedCert(certRepo, "mc-1", "o-alice", "")
|
||||
_, err := svc.BulkReassign(context.Background(),
|
||||
domain.BulkReassignmentRequest{CertificateIDs: []string{"mc-1"}, OwnerID: "o-ghost"}, "admin")
|
||||
if err == nil {
|
||||
t.Fatal("expected ErrBulkReassignOwnerNotFound, got nil")
|
||||
}
|
||||
if !errors.Is(err, ErrBulkReassignOwnerNotFound) {
|
||||
t.Errorf("err is not ErrBulkReassignOwnerNotFound; got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBulkReassign_TeamIDOptional — happy path WITHOUT team_id leaves
|
||||
// team_id unchanged. Empty team_id in request must not zero out the
|
||||
// existing team_id on the cert.
|
||||
func TestBulkReassign_TeamIDOptional(t *testing.T) {
|
||||
svc, certRepo, ownerRepo, _ := newBulkReassignmentTestService()
|
||||
addOwner(ownerRepo, "o-bob")
|
||||
addOwnedCert(certRepo, "mc-1", "o-alice", "t-platform")
|
||||
|
||||
_, err := svc.BulkReassign(context.Background(),
|
||||
domain.BulkReassignmentRequest{
|
||||
CertificateIDs: []string{"mc-1"},
|
||||
OwnerID: "o-bob",
|
||||
// TeamID intentionally omitted
|
||||
}, "admin")
|
||||
if err != nil {
|
||||
t.Fatalf("BulkReassign failed: %v", err)
|
||||
}
|
||||
if certRepo.Certs["mc-1"].TeamID != "t-platform" {
|
||||
t.Errorf("team_id was zeroed out; want unchanged 't-platform', got %q", certRepo.Certs["mc-1"].TeamID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBulkReassign_TeamIDProvided_Updates — when TeamID is non-empty in
|
||||
// the request, both owner_id and team_id update.
|
||||
func TestBulkReassign_TeamIDProvided_Updates(t *testing.T) {
|
||||
svc, certRepo, ownerRepo, _ := newBulkReassignmentTestService()
|
||||
addOwner(ownerRepo, "o-bob")
|
||||
addOwnedCert(certRepo, "mc-1", "o-alice", "t-platform")
|
||||
|
||||
_, err := svc.BulkReassign(context.Background(),
|
||||
domain.BulkReassignmentRequest{
|
||||
CertificateIDs: []string{"mc-1"},
|
||||
OwnerID: "o-bob",
|
||||
TeamID: "t-security",
|
||||
}, "admin")
|
||||
if err != nil {
|
||||
t.Fatalf("BulkReassign failed: %v", err)
|
||||
}
|
||||
if certRepo.Certs["mc-1"].TeamID != "t-security" {
|
||||
t.Errorf("team_id = %q, want t-security", certRepo.Certs["mc-1"].TeamID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBulkReassign_PartialFailure — N=3, one cert mid-batch hits an
|
||||
// Update error. Rest of the batch continues; failure surfaced in
|
||||
// Errors.
|
||||
func TestBulkReassign_PartialFailure(t *testing.T) {
|
||||
svc, certRepo, ownerRepo, _ := newBulkReassignmentTestService()
|
||||
addOwner(ownerRepo, "o-bob")
|
||||
addOwnedCert(certRepo, "mc-1", "o-alice", "")
|
||||
addOwnedCert(certRepo, "mc-2", "o-alice", "")
|
||||
addOwnedCert(certRepo, "mc-3", "o-alice", "")
|
||||
|
||||
// Force the next Update to fail uniformly. Mirrors how
|
||||
// TestBulkRevoke_PartialFailure injects a downstream failure.
|
||||
certRepo.UpdateErr = errors.New("simulated DB outage")
|
||||
|
||||
res, err := svc.BulkReassign(context.Background(),
|
||||
domain.BulkReassignmentRequest{
|
||||
CertificateIDs: []string{"mc-1", "mc-2", "mc-3"},
|
||||
OwnerID: "o-bob",
|
||||
}, "admin")
|
||||
if err != nil {
|
||||
t.Fatalf("BulkReassign should not propagate per-cert errors; got: %v", err)
|
||||
}
|
||||
if res.TotalFailed != 3 || res.TotalReassigned != 0 {
|
||||
t.Errorf("counts = failed:%d reassigned:%d, want 3/0", res.TotalFailed, res.TotalReassigned)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBulkReassign_AuditEventEmitted — single bulk audit event.
|
||||
func TestBulkReassign_AuditEventEmitted(t *testing.T) {
|
||||
svc, certRepo, ownerRepo, auditRepo := newBulkReassignmentTestService()
|
||||
addOwner(ownerRepo, "o-bob")
|
||||
addOwnedCert(certRepo, "mc-1", "o-alice", "")
|
||||
addOwnedCert(certRepo, "mc-2", "o-alice", "")
|
||||
|
||||
_, err := svc.BulkReassign(context.Background(),
|
||||
domain.BulkReassignmentRequest{
|
||||
CertificateIDs: []string{"mc-1", "mc-2"},
|
||||
OwnerID: "o-bob",
|
||||
}, "admin")
|
||||
if err != nil {
|
||||
t.Fatalf("BulkReassign failed: %v", err)
|
||||
}
|
||||
|
||||
if len(auditRepo.Events) != 1 {
|
||||
t.Errorf("audit events count = %d, want exactly 1 (one bulk event, NOT N per-cert events)", len(auditRepo.Events))
|
||||
}
|
||||
if len(auditRepo.Events) > 0 && auditRepo.Events[0].Action != "bulk_reassignment_initiated" {
|
||||
t.Errorf("audit action = %q, want 'bulk_reassignment_initiated'", auditRepo.Events[0].Action)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/domain"
|
||||
"github.com/shankar0123/certctl/internal/repository"
|
||||
)
|
||||
|
||||
// BulkRenewalService coordinates bulk certificate renewal operations.
|
||||
// Mirrors BulkRevocationService in shape: resolve criteria → status filter →
|
||||
// per-cert action loop → aggregate result + emit one bulk audit event.
|
||||
//
|
||||
// L-1 master closure (cat-l-fa0c1ac07ab5): the GUI used to loop
|
||||
// `await triggerRenewal(id)` over the selection at
|
||||
// `web/src/pages/CertificatesPage.tsx::handleBulkRenewal` (~line 411).
|
||||
// 100 certs = 100 sequential HTTP round-trips. Post-L-1 the GUI POSTs
|
||||
// once; this service does the loop server-side and returns a single
|
||||
// envelope with per-cert {certificate_id, job_id} pairs in
|
||||
// EnqueuedJobs and per-cert errors in Errors.
|
||||
//
|
||||
// Action verb is sync-enqueue (not sync-issue): for each matched cert
|
||||
// flip status to RenewalInProgress and create a Job row. The
|
||||
// scheduler's job processor picks up the jobs asynchronously. Sync-
|
||||
// issue would block the HTTP request for minutes against a slow ACME
|
||||
// issuer, which defeats the bulk-endpoint latency improvement.
|
||||
type BulkRenewalService struct {
|
||||
certRepo repository.CertificateRepository
|
||||
jobRepo repository.JobRepository
|
||||
auditService *AuditService
|
||||
logger *slog.Logger
|
||||
keygenMode string
|
||||
}
|
||||
|
||||
// NewBulkRenewalService creates a new BulkRenewalService.
|
||||
//
|
||||
// keygenMode mirrors CertificateService.keygenMode — agent-mode jobs
|
||||
// start as AwaitingCSR (the agent generates the key + submits a CSR);
|
||||
// server-mode jobs start as Pending. The bulk path must produce jobs in
|
||||
// the SAME initial status the single-cert path does, otherwise the
|
||||
// scheduler routes them differently.
|
||||
func NewBulkRenewalService(
|
||||
certRepo repository.CertificateRepository,
|
||||
jobRepo repository.JobRepository,
|
||||
auditService *AuditService,
|
||||
logger *slog.Logger,
|
||||
keygenMode string,
|
||||
) *BulkRenewalService {
|
||||
return &BulkRenewalService{
|
||||
certRepo: certRepo,
|
||||
jobRepo: jobRepo,
|
||||
auditService: auditService,
|
||||
logger: logger,
|
||||
keygenMode: keygenMode,
|
||||
}
|
||||
}
|
||||
|
||||
// BulkRenew enqueues a renewal job for every certificate matching the
|
||||
// criteria (or in the explicit IDs list). Status filter:
|
||||
// - Archived / Expired / Revoked → silent skip (TotalSkipped++)
|
||||
// - RenewalInProgress → silent skip (avoid double-enqueue)
|
||||
// - everything else → flip to RenewalInProgress + create job
|
||||
//
|
||||
// Partial failures don't abort the batch — the failing cert lands in
|
||||
// Errors[] with the error string, and the loop continues. Mirrors
|
||||
// BulkRevocationService.BulkRevoke's partial-failure semantics.
|
||||
//
|
||||
// Audit: a single audit event is emitted at the end with the criteria
|
||||
// + counts (NOT N events). The single-cert TriggerRenewal path emits
|
||||
// per-cert audit events; the bulk path uses one bulk envelope to keep
|
||||
// audit_events from growing 100x for one operator click.
|
||||
func (s *BulkRenewalService) BulkRenew(ctx context.Context, criteria domain.BulkRenewalCriteria, actor string) (*domain.BulkRenewalResult, error) {
|
||||
if criteria.IsEmpty() {
|
||||
return nil, fmt.Errorf("at least one filter criterion is required")
|
||||
}
|
||||
|
||||
certs, err := s.resolveCertificates(ctx, criteria)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to resolve certificates: %w", err)
|
||||
}
|
||||
|
||||
result := &domain.BulkRenewalResult{
|
||||
TotalMatched: len(certs),
|
||||
}
|
||||
|
||||
for _, cert := range certs {
|
||||
// Status-filter the cert before mutating. Mirrors the
|
||||
// eligibility checks in CertificateService.TriggerRenewal so a
|
||||
// bulk caller can't bypass them. Each illegal status maps to a
|
||||
// silent TotalSkipped++ rather than an Error so the operator
|
||||
// sees "5 of your 10 selections were no-ops" without triaging
|
||||
// fake errors.
|
||||
if cert.Status == domain.CertificateStatusArchived ||
|
||||
cert.Status == domain.CertificateStatusRevoked ||
|
||||
cert.Status == domain.CertificateStatusExpired ||
|
||||
cert.Status == domain.CertificateStatusRenewalInProgress {
|
||||
result.TotalSkipped++
|
||||
continue
|
||||
}
|
||||
|
||||
// Flip status + create job. Bug-for-bug match with
|
||||
// CertificateService.TriggerRenewal so the scheduler routing
|
||||
// stays identical between the single-cert and bulk paths.
|
||||
cert.Status = domain.CertificateStatusRenewalInProgress
|
||||
if err := s.certRepo.Update(ctx, cert); err != nil {
|
||||
result.TotalFailed++
|
||||
result.Errors = append(result.Errors, domain.BulkOperationError{
|
||||
CertificateID: cert.ID,
|
||||
Error: fmt.Sprintf("failed to update certificate status: %v", err),
|
||||
})
|
||||
s.logger.Warn("bulk renewal: status update failed",
|
||||
"certificate_id", cert.ID, "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
jobStatus := domain.JobStatusPending
|
||||
if s.keygenMode == "agent" {
|
||||
jobStatus = domain.JobStatusAwaitingCSR
|
||||
}
|
||||
jobType := domain.JobTypeRenewal
|
||||
if cert.ExpiresAt.IsZero() || cert.ExpiresAt.Year() < 2000 {
|
||||
jobType = domain.JobTypeIssuance
|
||||
}
|
||||
job := &domain.Job{
|
||||
ID: generateID("job"),
|
||||
CertificateID: cert.ID,
|
||||
Type: jobType,
|
||||
Status: jobStatus,
|
||||
MaxAttempts: 3,
|
||||
ScheduledAt: time.Now(),
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
if err := s.jobRepo.Create(ctx, job); err != nil {
|
||||
result.TotalFailed++
|
||||
result.Errors = append(result.Errors, domain.BulkOperationError{
|
||||
CertificateID: cert.ID,
|
||||
Error: fmt.Sprintf("failed to create renewal job: %v", err),
|
||||
})
|
||||
s.logger.Warn("bulk renewal: job creation failed",
|
||||
"certificate_id", cert.ID, "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
result.TotalEnqueued++
|
||||
result.EnqueuedJobs = append(result.EnqueuedJobs, domain.BulkEnqueuedJob{
|
||||
CertificateID: cert.ID,
|
||||
JobID: job.ID,
|
||||
})
|
||||
}
|
||||
|
||||
// Single bulk audit event at the end. Mirrors
|
||||
// BulkRevocationService.BulkRevoke shape so the audit dashboard's
|
||||
// rendering of bulk events is uniform across {revoke, renew, reassign}.
|
||||
criteriaDetails := s.buildAuditDetails(criteria)
|
||||
criteriaDetails["total_matched"] = result.TotalMatched
|
||||
criteriaDetails["total_enqueued"] = result.TotalEnqueued
|
||||
criteriaDetails["total_skipped"] = result.TotalSkipped
|
||||
criteriaDetails["total_failed"] = result.TotalFailed
|
||||
if err := s.auditService.RecordEvent(ctx, actor, domain.ActorTypeUser,
|
||||
"bulk_renewal_initiated", "certificate", "bulk",
|
||||
criteriaDetails); err != nil {
|
||||
s.logger.Error("failed to record bulk renewal audit event", "error", err)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// resolveCertificates fetches the set of certificates matching the bulk
|
||||
// renewal criteria. Mirrors BulkRevocationService.resolveCertificates
|
||||
// behaviour exactly: explicit IDs alone → fetch each by ID; filter
|
||||
// criteria → repo.List with high per_page; both → intersect.
|
||||
func (s *BulkRenewalService) resolveCertificates(ctx context.Context, criteria domain.BulkRenewalCriteria) ([]*domain.ManagedCertificate, error) {
|
||||
hasFilterCriteria := criteria.ProfileID != "" || criteria.OwnerID != "" ||
|
||||
criteria.AgentID != "" || criteria.IssuerID != "" || criteria.TeamID != ""
|
||||
hasExplicitIDs := len(criteria.CertificateIDs) > 0
|
||||
|
||||
if hasExplicitIDs && !hasFilterCriteria {
|
||||
var certs []*domain.ManagedCertificate
|
||||
for _, id := range criteria.CertificateIDs {
|
||||
cert, err := s.certRepo.Get(ctx, id)
|
||||
if err != nil {
|
||||
continue // not-found certs silently drop out of the matched set
|
||||
}
|
||||
certs = append(certs, cert)
|
||||
}
|
||||
return certs, nil
|
||||
}
|
||||
|
||||
filter := &repository.CertificateFilter{
|
||||
OwnerID: criteria.OwnerID,
|
||||
TeamID: criteria.TeamID,
|
||||
IssuerID: criteria.IssuerID,
|
||||
AgentID: criteria.AgentID,
|
||||
ProfileID: criteria.ProfileID,
|
||||
PerPage: 10000,
|
||||
}
|
||||
certs, _, err := s.certRepo.List(ctx, filter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if hasExplicitIDs {
|
||||
idSet := make(map[string]bool, len(criteria.CertificateIDs))
|
||||
for _, id := range criteria.CertificateIDs {
|
||||
idSet[id] = true
|
||||
}
|
||||
var filtered []*domain.ManagedCertificate
|
||||
for _, cert := range certs {
|
||||
if idSet[cert.ID] {
|
||||
filtered = append(filtered, cert)
|
||||
}
|
||||
}
|
||||
return filtered, nil
|
||||
}
|
||||
return certs, nil
|
||||
}
|
||||
|
||||
// buildAuditDetails constructs a map of criteria fields for the audit
|
||||
// event. Mirrors BulkRevocationService.buildAuditDetails so the audit
|
||||
// dashboard renders bulk events uniformly.
|
||||
func (s *BulkRenewalService) buildAuditDetails(criteria domain.BulkRenewalCriteria) map[string]interface{} {
|
||||
details := map[string]interface{}{}
|
||||
if criteria.ProfileID != "" {
|
||||
details["profile_id"] = criteria.ProfileID
|
||||
}
|
||||
if criteria.OwnerID != "" {
|
||||
details["owner_id"] = criteria.OwnerID
|
||||
}
|
||||
if criteria.AgentID != "" {
|
||||
details["agent_id"] = criteria.AgentID
|
||||
}
|
||||
if criteria.IssuerID != "" {
|
||||
details["issuer_id"] = criteria.IssuerID
|
||||
}
|
||||
if criteria.TeamID != "" {
|
||||
details["team_id"] = criteria.TeamID
|
||||
}
|
||||
if len(criteria.CertificateIDs) > 0 {
|
||||
details["certificate_ids"] = strings.Join(criteria.CertificateIDs, ",")
|
||||
}
|
||||
return details
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/domain"
|
||||
)
|
||||
|
||||
// newBulkRenewalTestService spins up a BulkRenewalService wired against
|
||||
// the in-memory mocks used by every other service test in this package.
|
||||
// keygenMode defaults to "agent" — production-like routing where renewal
|
||||
// jobs start as AwaitingCSR.
|
||||
func newBulkRenewalTestService() (*BulkRenewalService, *mockCertRepo, *mockJobRepo, *mockAuditRepo) {
|
||||
certRepo := newMockCertificateRepository()
|
||||
jobRepo := &mockJobRepo{Jobs: map[string]*domain.Job{}}
|
||||
auditRepo := newMockAuditRepository()
|
||||
auditService := NewAuditService(auditRepo)
|
||||
svc := NewBulkRenewalService(certRepo, jobRepo, auditService, slog.Default(), "agent")
|
||||
return svc, certRepo, jobRepo, auditRepo
|
||||
}
|
||||
|
||||
// addRenewableCert seeds a cert that is eligible for renewal (Active
|
||||
// status, future expiry).
|
||||
func addRenewableCert(repo *mockCertRepo, id string) {
|
||||
cert := &domain.ManagedCertificate{
|
||||
ID: id,
|
||||
CommonName: id + ".example.com",
|
||||
Status: domain.CertificateStatusActive,
|
||||
ExpiresAt: time.Now().AddDate(0, 1, 0),
|
||||
IssuerID: "iss-test",
|
||||
}
|
||||
repo.AddCert(cert)
|
||||
}
|
||||
|
||||
// TestBulkRenew_ByExplicitIDs — happy path. N IDs in, N jobs enqueued,
|
||||
// EnqueuedJobs slice carries the {certificate_id, job_id} pairs.
|
||||
func TestBulkRenew_ByExplicitIDs(t *testing.T) {
|
||||
svc, certRepo, jobRepo, _ := newBulkRenewalTestService()
|
||||
addRenewableCert(certRepo, "mc-1")
|
||||
addRenewableCert(certRepo, "mc-2")
|
||||
addRenewableCert(certRepo, "mc-3")
|
||||
|
||||
res, err := svc.BulkRenew(context.Background(),
|
||||
domain.BulkRenewalCriteria{CertificateIDs: []string{"mc-1", "mc-2", "mc-3"}},
|
||||
"alice")
|
||||
if err != nil {
|
||||
t.Fatalf("BulkRenew failed: %v", err)
|
||||
}
|
||||
if res.TotalMatched != 3 || res.TotalEnqueued != 3 || res.TotalSkipped != 0 || res.TotalFailed != 0 {
|
||||
t.Errorf("counts = matched:%d enqueued:%d skipped:%d failed:%d, want 3/3/0/0",
|
||||
res.TotalMatched, res.TotalEnqueued, res.TotalSkipped, res.TotalFailed)
|
||||
}
|
||||
if len(res.EnqueuedJobs) != 3 {
|
||||
t.Fatalf("EnqueuedJobs len = %d, want 3", len(res.EnqueuedJobs))
|
||||
}
|
||||
if len(jobRepo.Jobs) != 3 {
|
||||
t.Errorf("jobRepo got %d jobs, want 3 (one per renewable cert)", len(jobRepo.Jobs))
|
||||
}
|
||||
for _, j := range res.EnqueuedJobs {
|
||||
if j.JobID == "" {
|
||||
t.Errorf("EnqueuedJob missing job_id for cert %s", j.CertificateID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestBulkRenew_ByOwnerCriteria — criteria-mode resolution. The
|
||||
// criteria-routing path must call resolveCertificates with the filter
|
||||
// branch (not the explicit-IDs branch). Mocking convention in this
|
||||
// package: mockCertRepo.List ignores the filter and returns all certs,
|
||||
// so the test seeds only certs that should match (mirrors
|
||||
// TestBulkRevoke_ByOwner shape in bulk_revocation_test.go).
|
||||
func TestBulkRenew_ByOwnerCriteria(t *testing.T) {
|
||||
svc, certRepo, _, _ := newBulkRenewalTestService()
|
||||
for _, id := range []string{"mc-a1", "mc-a2"} {
|
||||
cert := &domain.ManagedCertificate{
|
||||
ID: id, CommonName: id, Status: domain.CertificateStatusActive,
|
||||
OwnerID: "o-alice", ExpiresAt: time.Now().AddDate(0, 1, 0),
|
||||
}
|
||||
certRepo.AddCert(cert)
|
||||
}
|
||||
|
||||
res, err := svc.BulkRenew(context.Background(),
|
||||
domain.BulkRenewalCriteria{OwnerID: "o-alice"}, "alice")
|
||||
if err != nil {
|
||||
t.Fatalf("BulkRenew failed: %v", err)
|
||||
}
|
||||
if res.TotalEnqueued != 2 {
|
||||
t.Errorf("TotalEnqueued = %d, want 2 (alice's 2 certs)", res.TotalEnqueued)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBulkRenew_SkipsRenewalInProgress — a cert already in the renewal
|
||||
// flow must NOT get a second job. This is the no-double-enqueue
|
||||
// contract: dispatch the bulk-renew button twice in quick succession
|
||||
// and the second call cleanly skips.
|
||||
func TestBulkRenew_SkipsRenewalInProgress(t *testing.T) {
|
||||
svc, certRepo, jobRepo, _ := newBulkRenewalTestService()
|
||||
cert := &domain.ManagedCertificate{
|
||||
ID: "mc-rip", Status: domain.CertificateStatusRenewalInProgress,
|
||||
ExpiresAt: time.Now().AddDate(0, 1, 0),
|
||||
}
|
||||
certRepo.AddCert(cert)
|
||||
|
||||
res, err := svc.BulkRenew(context.Background(),
|
||||
domain.BulkRenewalCriteria{CertificateIDs: []string{"mc-rip"}}, "alice")
|
||||
if err != nil {
|
||||
t.Fatalf("BulkRenew failed: %v", err)
|
||||
}
|
||||
if res.TotalSkipped != 1 || res.TotalEnqueued != 0 {
|
||||
t.Errorf("counts wrong: skipped=%d enqueued=%d, want 1/0",
|
||||
res.TotalSkipped, res.TotalEnqueued)
|
||||
}
|
||||
if len(jobRepo.Jobs) != 0 {
|
||||
t.Errorf("no job should be created for already-in-progress cert; got %d jobs", len(jobRepo.Jobs))
|
||||
}
|
||||
}
|
||||
|
||||
// TestBulkRenew_SkipsRevokedAndArchived — terminal states are silent
|
||||
// no-ops, not errors. Operator selecting a mix of live and revoked certs
|
||||
// shouldn't see "ERROR: revoked cert can't be renewed" 50 times.
|
||||
func TestBulkRenew_SkipsRevokedAndArchived(t *testing.T) {
|
||||
svc, certRepo, _, _ := newBulkRenewalTestService()
|
||||
addRenewableCert(certRepo, "mc-live")
|
||||
for _, st := range []domain.CertificateStatus{
|
||||
domain.CertificateStatusRevoked,
|
||||
domain.CertificateStatusArchived,
|
||||
domain.CertificateStatusExpired,
|
||||
} {
|
||||
cert := &domain.ManagedCertificate{
|
||||
ID: "mc-" + string(st), Status: st, ExpiresAt: time.Now().AddDate(0, 1, 0),
|
||||
}
|
||||
certRepo.AddCert(cert)
|
||||
}
|
||||
|
||||
res, err := svc.BulkRenew(context.Background(),
|
||||
domain.BulkRenewalCriteria{CertificateIDs: []string{
|
||||
"mc-live", "mc-Revoked", "mc-Archived", "mc-Expired",
|
||||
}}, "alice")
|
||||
if err != nil {
|
||||
t.Fatalf("BulkRenew failed: %v", err)
|
||||
}
|
||||
if res.TotalEnqueued != 1 || res.TotalSkipped != 3 {
|
||||
t.Errorf("counts = enqueued:%d skipped:%d, want 1/3 (only mc-live qualifies)",
|
||||
res.TotalEnqueued, res.TotalSkipped)
|
||||
}
|
||||
if len(res.Errors) != 0 {
|
||||
t.Errorf("status-skip should NOT populate Errors; got %v", res.Errors)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBulkRenew_EmptyCriteria_Error — defensive contract. Mirrors
|
||||
// BulkRevocationCriteria.IsEmpty rejection so a stray empty POST
|
||||
// doesn't try to renew the entire fleet.
|
||||
func TestBulkRenew_EmptyCriteria_Error(t *testing.T) {
|
||||
svc, _, _, _ := newBulkRenewalTestService()
|
||||
_, err := svc.BulkRenew(context.Background(),
|
||||
domain.BulkRenewalCriteria{}, "alice")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty criteria, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBulkRenew_PartialFailure — N=3, jobRepo.Create injected to fail
|
||||
// on one of them. Response carries 2 enqueued + 1 error; no panic, no
|
||||
// abort.
|
||||
func TestBulkRenew_PartialFailure(t *testing.T) {
|
||||
svc, certRepo, jobRepo, _ := newBulkRenewalTestService()
|
||||
addRenewableCert(certRepo, "mc-1")
|
||||
addRenewableCert(certRepo, "mc-2")
|
||||
addRenewableCert(certRepo, "mc-3")
|
||||
|
||||
// Make Create fail uniformly. Two of the three certs will still
|
||||
// have their status flipped (because Update happened first), so
|
||||
// the failure manifests as "I tried to enqueue, the job-create
|
||||
// failed". Per-cert error string surfaced.
|
||||
jobRepo.CreateErr = errors.New("simulated DB outage")
|
||||
|
||||
res, err := svc.BulkRenew(context.Background(),
|
||||
domain.BulkRenewalCriteria{CertificateIDs: []string{"mc-1", "mc-2", "mc-3"}},
|
||||
"alice")
|
||||
if err != nil {
|
||||
t.Fatalf("BulkRenew should not propagate per-cert errors as a top-level error; got: %v", err)
|
||||
}
|
||||
if res.TotalFailed != 3 || res.TotalEnqueued != 0 {
|
||||
t.Errorf("counts = failed:%d enqueued:%d, want 3/0", res.TotalFailed, res.TotalEnqueued)
|
||||
}
|
||||
if len(res.Errors) != 3 {
|
||||
t.Errorf("Errors len = %d, want 3", len(res.Errors))
|
||||
}
|
||||
}
|
||||
|
||||
// TestBulkRenew_AuditEventEmitted — exactly ONE bulk audit event for
|
||||
// the operation, NOT N. This is the audit-volume contract that makes
|
||||
// bulk endpoints scalable.
|
||||
func TestBulkRenew_AuditEventEmitted(t *testing.T) {
|
||||
svc, certRepo, _, auditRepo := newBulkRenewalTestService()
|
||||
addRenewableCert(certRepo, "mc-1")
|
||||
addRenewableCert(certRepo, "mc-2")
|
||||
addRenewableCert(certRepo, "mc-3")
|
||||
|
||||
_, err := svc.BulkRenew(context.Background(),
|
||||
domain.BulkRenewalCriteria{CertificateIDs: []string{"mc-1", "mc-2", "mc-3"}},
|
||||
"alice")
|
||||
if err != nil {
|
||||
t.Fatalf("BulkRenew failed: %v", err)
|
||||
}
|
||||
|
||||
// audit_events count must be exactly 1 — the bulk-renewal envelope.
|
||||
// Per-cert renewal events come from CertificateService.TriggerRenewal,
|
||||
// which the bulk path bypasses for exactly this reason.
|
||||
if len(auditRepo.Events) != 1 {
|
||||
t.Errorf("audit events count = %d, want exactly 1 (one bulk event, NOT N per-cert events)", len(auditRepo.Events))
|
||||
}
|
||||
if len(auditRepo.Events) > 0 && auditRepo.Events[0].Action != "bulk_renewal_initiated" {
|
||||
t.Errorf("audit action = %q, want 'bulk_renewal_initiated'", auditRepo.Events[0].Action)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user