mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 16:21:30 +00:00
af4fa12724
Self-audit caught five real gaps in 3ef45e2; this commit closes them. # Phase 8 — issuer/target audit rows now classified as 'config' The Phase 8 prompt explicitly required existing config-mutation calls (issuer config, target config, etc.) to write event_category=config. The3ef45e2commit only migrated the auth service callers; the 6 issuer/target call-sites (internal/service/issuer.go: create/update/delete_issuer + internal/service/target.go: create/update/delete_target) still defaulted to cert_lifecycle. They now pass through RecordEventWithCategory(..., domain.EventCategoryConfig, ...) so auditors filtering /v1/audit?category=config see the slice the migration's docstring promised. # Auditor exit-criterion test Phase 8's exit criteria pin 'a user with the auditor role can list / export audit events but gets 403 on every other endpoint.' Bundle 1 unit invariants (auditor permission set, rbacGate behaviour) were in place but no end-to-end test walked the full set of admin perms with an auditor actor. internal/api/router/rbac_gate_integration_test.go gains TestRBACGate_AuditorRole_403sOnAdminRoutes (table-driven across all 5 admin perms — cert.bulk_revoke / crl.admin / scep.admin / est.admin / ca.hierarchy.manage) plus TestRBACGate_AuditorRole_PassesAuditReadGate (positive case for audit.read). # gofmt drift3ef45e2left two cosmetic struct-field-alignment diffs in internal/cli/auth.go and internal/api/handler/audit_handler_test.go that gofmt -l flagged. CI's gofmt step would have failed; gofmt -w applied; gofmt -l now clean across the repo. # CHANGELOG path-prefix CHANGELOG.md v2.1.0 used '/v1/auth/bootstrap' shorthand in the operator-facing flow examples. The actual route is '/api/v1/auth/bootstrap'; an operator copy-pasting the curl would 404. All five hits replaced. Verifications: gofmt clean, go vet ./internal/service/ ./internal/api/router/ clean, go test -short -count=1 green across internal/service + internal/api/router, including the 6 new auditor sub-tests (PASS).
431 lines
12 KiB
Go
431 lines
12 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/certctl-io/certctl/internal/api/middleware"
|
|
"github.com/certctl-io/certctl/internal/domain"
|
|
)
|
|
|
|
// mockAuditService implements AuditService for testing.
|
|
type mockAuditService struct {
|
|
listFunc func(page, perPage int) ([]domain.AuditEvent, int64, error)
|
|
listByCatFunc func(category string, page, perPage int) ([]domain.AuditEvent, int64, error)
|
|
getFunc func(id string) (*domain.AuditEvent, error)
|
|
}
|
|
|
|
func (m *mockAuditService) ListAuditEvents(_ context.Context, page, perPage int) ([]domain.AuditEvent, int64, error) {
|
|
if m.listFunc != nil {
|
|
return m.listFunc(page, perPage)
|
|
}
|
|
return nil, 0, nil
|
|
}
|
|
|
|
func (m *mockAuditService) ListAuditEventsByCategory(_ context.Context, category string, page, perPage int) ([]domain.AuditEvent, int64, error) {
|
|
if m.listByCatFunc != nil {
|
|
return m.listByCatFunc(category, page, perPage)
|
|
}
|
|
if m.listFunc != nil {
|
|
return m.listFunc(page, perPage)
|
|
}
|
|
return nil, 0, nil
|
|
}
|
|
|
|
func (m *mockAuditService) GetAuditEvent(_ context.Context, id string) (*domain.AuditEvent, error) {
|
|
if m.getFunc != nil {
|
|
return m.getFunc(id)
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
func TestListAuditEvents_Success(t *testing.T) {
|
|
events := []domain.AuditEvent{
|
|
{
|
|
ID: "ev-1",
|
|
Action: "certificate_issued",
|
|
Actor: "user@example.com",
|
|
ActorType: domain.ActorTypeUser,
|
|
ResourceID: "mc-api-prod",
|
|
ResourceType: "Certificate",
|
|
Timestamp: time.Now(),
|
|
},
|
|
{
|
|
ID: "ev-2",
|
|
Action: "certificate_renewed",
|
|
Actor: "user@example.com",
|
|
ActorType: domain.ActorTypeUser,
|
|
ResourceID: "mc-api-prod",
|
|
ResourceType: "Certificate",
|
|
Timestamp: time.Now(),
|
|
},
|
|
}
|
|
|
|
mockSvc := &mockAuditService{
|
|
listFunc: func(page, perPage int) ([]domain.AuditEvent, int64, error) {
|
|
if page != 1 || perPage != 50 {
|
|
t.Errorf("ListAuditEvents called with page=%d, perPage=%d, expected 1, 50", page, perPage)
|
|
}
|
|
return events, 2, nil
|
|
},
|
|
}
|
|
|
|
handler := NewAuditHandler(mockSvc)
|
|
|
|
req, err := http.NewRequest(http.MethodGet, "/api/v1/audit", nil)
|
|
if err != nil {
|
|
t.Fatalf("NewRequest failed: %v", err)
|
|
}
|
|
|
|
// Add request ID to context
|
|
ctx := context.WithValue(req.Context(), middleware.RequestIDKey{}, "test-req-id")
|
|
req = req.WithContext(ctx)
|
|
|
|
w := httptest.NewRecorder()
|
|
handler.ListAuditEvents(w, req)
|
|
|
|
if status := w.Code; status != http.StatusOK {
|
|
t.Errorf("ListAuditEvents returned status %d, want %d", status, http.StatusOK)
|
|
}
|
|
|
|
var result PagedResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&result); err != nil {
|
|
t.Fatalf("failed to decode response: %v", err)
|
|
}
|
|
|
|
if result.Total != 2 {
|
|
t.Errorf("Total = %d, want 2", result.Total)
|
|
}
|
|
|
|
if result.Page != 1 {
|
|
t.Errorf("Page = %d, want 1", result.Page)
|
|
}
|
|
|
|
if result.PerPage != 50 {
|
|
t.Errorf("PerPage = %d, want 50", result.PerPage)
|
|
}
|
|
|
|
// Check data is present
|
|
if result.Data == nil {
|
|
t.Error("Data is nil, want events slice")
|
|
}
|
|
}
|
|
|
|
func TestListAuditEvents_WithPagination(t *testing.T) {
|
|
events := []domain.AuditEvent{
|
|
{
|
|
ID: "ev-5",
|
|
Action: "certificate_issued",
|
|
Actor: "user@example.com",
|
|
ActorType: domain.ActorTypeUser,
|
|
ResourceID: "mc-api-prod",
|
|
ResourceType: "Certificate",
|
|
Timestamp: time.Now(),
|
|
},
|
|
}
|
|
|
|
mockSvc := &mockAuditService{
|
|
listFunc: func(page, perPage int) ([]domain.AuditEvent, int64, error) {
|
|
if page != 2 || perPage != 25 {
|
|
t.Errorf("ListAuditEvents called with page=%d, perPage=%d, expected 2, 25", page, perPage)
|
|
}
|
|
return events, 100, nil
|
|
},
|
|
}
|
|
|
|
handler := NewAuditHandler(mockSvc)
|
|
|
|
req, err := http.NewRequest(http.MethodGet, "/api/v1/audit?page=2&per_page=25", nil)
|
|
if err != nil {
|
|
t.Fatalf("NewRequest failed: %v", err)
|
|
}
|
|
|
|
ctx := context.WithValue(req.Context(), middleware.RequestIDKey{}, "test-req-id")
|
|
req = req.WithContext(ctx)
|
|
|
|
w := httptest.NewRecorder()
|
|
handler.ListAuditEvents(w, req)
|
|
|
|
if status := w.Code; status != http.StatusOK {
|
|
t.Errorf("ListAuditEvents returned status %d, want %d", status, http.StatusOK)
|
|
}
|
|
|
|
var result PagedResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&result); err != nil {
|
|
t.Fatalf("failed to decode response: %v", err)
|
|
}
|
|
|
|
if result.Page != 2 {
|
|
t.Errorf("Page = %d, want 2", result.Page)
|
|
}
|
|
|
|
if result.PerPage != 25 {
|
|
t.Errorf("PerPage = %d, want 25", result.PerPage)
|
|
}
|
|
}
|
|
|
|
func TestListAuditEvents_PerPageMaxLimit(t *testing.T) {
|
|
mockSvc := &mockAuditService{
|
|
listFunc: func(page, perPage int) ([]domain.AuditEvent, int64, error) {
|
|
// Should be capped at 500
|
|
if perPage > 500 {
|
|
t.Errorf("perPage = %d, expected <= 500", perPage)
|
|
}
|
|
return []domain.AuditEvent{}, 0, nil
|
|
},
|
|
}
|
|
|
|
handler := NewAuditHandler(mockSvc)
|
|
|
|
req, err := http.NewRequest(http.MethodGet, "/api/v1/audit?per_page=1000", nil)
|
|
if err != nil {
|
|
t.Fatalf("NewRequest failed: %v", err)
|
|
}
|
|
|
|
ctx := context.WithValue(req.Context(), middleware.RequestIDKey{}, "test-req-id")
|
|
req = req.WithContext(ctx)
|
|
|
|
w := httptest.NewRecorder()
|
|
handler.ListAuditEvents(w, req)
|
|
|
|
if status := w.Code; status != http.StatusOK {
|
|
t.Errorf("ListAuditEvents returned status %d, want %d", status, http.StatusOK)
|
|
}
|
|
|
|
var result PagedResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&result); err != nil {
|
|
t.Fatalf("failed to decode response: %v", err)
|
|
}
|
|
|
|
if result.PerPage > 500 {
|
|
t.Errorf("PerPage = %d, want <= 500", result.PerPage)
|
|
}
|
|
}
|
|
|
|
func TestListAuditEvents_EmptyResult(t *testing.T) {
|
|
mockSvc := &mockAuditService{
|
|
listFunc: func(page, perPage int) ([]domain.AuditEvent, int64, error) {
|
|
return []domain.AuditEvent{}, 0, nil
|
|
},
|
|
}
|
|
|
|
handler := NewAuditHandler(mockSvc)
|
|
|
|
req, err := http.NewRequest(http.MethodGet, "/api/v1/audit", nil)
|
|
if err != nil {
|
|
t.Fatalf("NewRequest failed: %v", err)
|
|
}
|
|
|
|
ctx := context.WithValue(req.Context(), middleware.RequestIDKey{}, "test-req-id")
|
|
req = req.WithContext(ctx)
|
|
|
|
w := httptest.NewRecorder()
|
|
handler.ListAuditEvents(w, req)
|
|
|
|
if status := w.Code; status != http.StatusOK {
|
|
t.Errorf("ListAuditEvents returned status %d, want %d", status, http.StatusOK)
|
|
}
|
|
|
|
var result PagedResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&result); err != nil {
|
|
t.Fatalf("failed to decode response: %v", err)
|
|
}
|
|
|
|
if result.Total != 0 {
|
|
t.Errorf("Total = %d, want 0", result.Total)
|
|
}
|
|
}
|
|
|
|
func TestListAuditEvents_ServiceError(t *testing.T) {
|
|
mockSvc := &mockAuditService{
|
|
listFunc: func(page, perPage int) ([]domain.AuditEvent, int64, error) {
|
|
return nil, 0, errors.New("database error")
|
|
},
|
|
}
|
|
|
|
handler := NewAuditHandler(mockSvc)
|
|
|
|
req, err := http.NewRequest(http.MethodGet, "/api/v1/audit", nil)
|
|
if err != nil {
|
|
t.Fatalf("NewRequest failed: %v", err)
|
|
}
|
|
|
|
ctx := context.WithValue(req.Context(), middleware.RequestIDKey{}, "test-req-id")
|
|
req = req.WithContext(ctx)
|
|
|
|
w := httptest.NewRecorder()
|
|
handler.ListAuditEvents(w, req)
|
|
|
|
if status := w.Code; status != http.StatusInternalServerError {
|
|
t.Errorf("ListAuditEvents returned status %d, want %d", status, http.StatusInternalServerError)
|
|
}
|
|
|
|
var errResp ErrorResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&errResp); err != nil {
|
|
t.Fatalf("failed to decode error response: %v", err)
|
|
}
|
|
|
|
if errResp.Message != "Failed to list audit events" {
|
|
t.Errorf("Message = %q, want 'Failed to list audit events'", errResp.Message)
|
|
}
|
|
}
|
|
|
|
func TestListAuditEvents_MethodNotAllowed(t *testing.T) {
|
|
mockSvc := &mockAuditService{}
|
|
handler := NewAuditHandler(mockSvc)
|
|
|
|
req, err := http.NewRequest(http.MethodPost, "/api/v1/audit", nil)
|
|
if err != nil {
|
|
t.Fatalf("NewRequest failed: %v", err)
|
|
}
|
|
|
|
ctx := context.WithValue(req.Context(), middleware.RequestIDKey{}, "test-req-id")
|
|
req = req.WithContext(ctx)
|
|
|
|
w := httptest.NewRecorder()
|
|
handler.ListAuditEvents(w, req)
|
|
|
|
if status := w.Code; status != http.StatusMethodNotAllowed {
|
|
t.Errorf("ListAuditEvents returned status %d, want %d", status, http.StatusMethodNotAllowed)
|
|
}
|
|
}
|
|
|
|
func TestGetAuditEvent_Success(t *testing.T) {
|
|
event := &domain.AuditEvent{
|
|
ID: "ev-123",
|
|
Action: "certificate_issued",
|
|
Actor: "user@example.com",
|
|
ActorType: domain.ActorTypeUser,
|
|
ResourceID: "mc-api-prod",
|
|
ResourceType: "Certificate",
|
|
Timestamp: time.Now(),
|
|
}
|
|
|
|
mockSvc := &mockAuditService{
|
|
getFunc: func(id string) (*domain.AuditEvent, error) {
|
|
if id != "ev-123" {
|
|
t.Errorf("GetAuditEvent called with id=%q, expected ev-123", id)
|
|
}
|
|
return event, nil
|
|
},
|
|
}
|
|
|
|
handler := NewAuditHandler(mockSvc)
|
|
|
|
req, err := http.NewRequest(http.MethodGet, "/api/v1/audit/ev-123", nil)
|
|
if err != nil {
|
|
t.Fatalf("NewRequest failed: %v", err)
|
|
}
|
|
|
|
ctx := context.WithValue(req.Context(), middleware.RequestIDKey{}, "test-req-id")
|
|
req = req.WithContext(ctx)
|
|
|
|
w := httptest.NewRecorder()
|
|
handler.GetAuditEvent(w, req)
|
|
|
|
if status := w.Code; status != http.StatusOK {
|
|
t.Errorf("GetAuditEvent returned status %d, want %d", status, http.StatusOK)
|
|
}
|
|
|
|
var result domain.AuditEvent
|
|
if err := json.NewDecoder(w.Body).Decode(&result); err != nil {
|
|
t.Fatalf("failed to decode response: %v", err)
|
|
}
|
|
|
|
if result.ID != "ev-123" {
|
|
t.Errorf("ID = %q, want ev-123", result.ID)
|
|
}
|
|
|
|
if result.Action != "certificate_issued" {
|
|
t.Errorf("Action = %q, want certificate_issued", result.Action)
|
|
}
|
|
}
|
|
|
|
func TestGetAuditEvent_NotFound(t *testing.T) {
|
|
mockSvc := &mockAuditService{
|
|
getFunc: func(id string) (*domain.AuditEvent, error) {
|
|
return nil, errors.New("not found")
|
|
},
|
|
}
|
|
|
|
handler := NewAuditHandler(mockSvc)
|
|
|
|
req, err := http.NewRequest(http.MethodGet, "/api/v1/audit/nonexistent", nil)
|
|
if err != nil {
|
|
t.Fatalf("NewRequest failed: %v", err)
|
|
}
|
|
|
|
ctx := context.WithValue(req.Context(), middleware.RequestIDKey{}, "test-req-id")
|
|
req = req.WithContext(ctx)
|
|
|
|
w := httptest.NewRecorder()
|
|
handler.GetAuditEvent(w, req)
|
|
|
|
if status := w.Code; status != http.StatusNotFound {
|
|
t.Errorf("GetAuditEvent returned status %d, want %d", status, http.StatusNotFound)
|
|
}
|
|
|
|
var errResp ErrorResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&errResp); err != nil {
|
|
t.Fatalf("failed to decode error response: %v", err)
|
|
}
|
|
|
|
if errResp.Message != "Audit event not found" {
|
|
t.Errorf("Message = %q, want 'Audit event not found'", errResp.Message)
|
|
}
|
|
}
|
|
|
|
func TestGetAuditEvent_MethodNotAllowed(t *testing.T) {
|
|
mockSvc := &mockAuditService{}
|
|
handler := NewAuditHandler(mockSvc)
|
|
|
|
req, err := http.NewRequest(http.MethodDelete, "/api/v1/audit/ev-123", nil)
|
|
if err != nil {
|
|
t.Fatalf("NewRequest failed: %v", err)
|
|
}
|
|
|
|
ctx := context.WithValue(req.Context(), middleware.RequestIDKey{}, "test-req-id")
|
|
req = req.WithContext(ctx)
|
|
|
|
w := httptest.NewRecorder()
|
|
handler.GetAuditEvent(w, req)
|
|
|
|
if status := w.Code; status != http.StatusMethodNotAllowed {
|
|
t.Errorf("GetAuditEvent returned status %d, want %d", status, http.StatusMethodNotAllowed)
|
|
}
|
|
}
|
|
|
|
func TestGetAuditEvent_EmptyID(t *testing.T) {
|
|
mockSvc := &mockAuditService{}
|
|
handler := NewAuditHandler(mockSvc)
|
|
|
|
req, err := http.NewRequest(http.MethodGet, "/api/v1/audit/", nil)
|
|
if err != nil {
|
|
t.Fatalf("NewRequest failed: %v", err)
|
|
}
|
|
|
|
ctx := context.WithValue(req.Context(), middleware.RequestIDKey{}, "test-req-id")
|
|
req = req.WithContext(ctx)
|
|
|
|
w := httptest.NewRecorder()
|
|
handler.GetAuditEvent(w, req)
|
|
|
|
if status := w.Code; status != http.StatusBadRequest {
|
|
t.Errorf("GetAuditEvent returned status %d, want %d", status, http.StatusBadRequest)
|
|
}
|
|
|
|
var errResp ErrorResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&errResp); err != nil {
|
|
t.Fatalf("failed to decode error response: %v", err)
|
|
}
|
|
|
|
if errResp.Message != "Audit event ID is required" {
|
|
t.Errorf("Message = %q, want 'Audit event ID is required'", errResp.Message)
|
|
}
|
|
}
|