mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 21:31:34 +00:00
43e1c89623
Close coverage gaps identified by dual-audit (qualitative + quantitative). New test files for config (0%→98%), router (0%→100%), handler validation, health, audit, response helpers, webhook notifier (0%→88%), email notifier, middleware (recovery, rate limiter), domain profile, service nil-safety, config helpers, issuer bootstrap, and server bootstrap wiring. Expanded existing tests for ACME (34%→42%), step-ca (42%→52%), F5, SSH, agent (43%→63%), scheduler (88%→99%), renewal service, and issuerfactory. All tests pass: go test -short, go vet, go test -race clean. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
428 lines
11 KiB
Go
428 lines
11 KiB
Go
package handler
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestEncodeCursor_ProducesValidBase64(t *testing.T) {
|
|
// Test that encodeCursor produces valid base64 with correct format
|
|
originalTime := time.Date(2024, 3, 15, 10, 30, 45, 123456789, time.UTC)
|
|
originalID := "cert-12345"
|
|
|
|
// Encode
|
|
encoded := encodeCursor(originalTime, originalID)
|
|
|
|
// Verify it's valid base64
|
|
decoded, err := base64.URLEncoding.DecodeString(encoded)
|
|
if err != nil {
|
|
t.Fatalf("encoded cursor is not valid base64: %v", err)
|
|
}
|
|
|
|
// Verify contains both timestamp and ID
|
|
decodedStr := string(decoded)
|
|
if !strings.Contains(decodedStr, originalID) {
|
|
t.Errorf("decoded cursor doesn't contain ID %q, got %q", originalID, decodedStr)
|
|
}
|
|
|
|
// Verify it's not empty and has expected structure (timestamp:id)
|
|
if !strings.Contains(decodedStr, ":") {
|
|
t.Errorf("decoded cursor doesn't contain colon separator, got %q", decodedStr)
|
|
}
|
|
}
|
|
|
|
func TestEncodeCursor_DifferentTimes(t *testing.T) {
|
|
id := "test-id"
|
|
time1 := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
|
|
time2 := time.Date(2024, 1, 2, 0, 0, 0, 0, time.UTC)
|
|
|
|
cursor1 := encodeCursor(time1, id)
|
|
cursor2 := encodeCursor(time2, id)
|
|
|
|
// Different times should produce different cursors
|
|
if cursor1 == cursor2 {
|
|
t.Error("Different times produced identical cursors")
|
|
}
|
|
}
|
|
|
|
func TestEncodeCursor_DifferentIDs(t *testing.T) {
|
|
now := time.Now()
|
|
id1 := "cert-1"
|
|
id2 := "cert-2"
|
|
|
|
cursor1 := encodeCursor(now, id1)
|
|
cursor2 := encodeCursor(now, id2)
|
|
|
|
// Different IDs should produce different cursors
|
|
if cursor1 == cursor2 {
|
|
t.Error("Different IDs produced identical cursors")
|
|
}
|
|
}
|
|
|
|
func TestDecodeCursor_InvalidBase64(t *testing.T) {
|
|
// Create the decodeCursor function from the closure - matching actual behavior
|
|
decodeCursor := func(cursor string) (time.Time, string, error) {
|
|
raw, err := base64.URLEncoding.DecodeString(cursor)
|
|
if err != nil {
|
|
return time.Time{}, "", err
|
|
}
|
|
parts := strings.SplitN(string(raw), ":", 2)
|
|
if len(parts) != 2 {
|
|
return time.Time{}, "", fmt.Errorf("invalid cursor format")
|
|
}
|
|
t, err := time.Parse(time.RFC3339Nano, parts[0])
|
|
if err != nil {
|
|
return time.Time{}, "", err
|
|
}
|
|
return t, parts[1], nil
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
cursor string
|
|
expectError bool
|
|
}{
|
|
{"invalid base64", "!!!invalid!!!", true},
|
|
{"empty string", "", true},
|
|
{"no colon separator", base64.URLEncoding.EncodeToString([]byte("no-separator-here")), true},
|
|
{"invalid timestamp", base64.URLEncoding.EncodeToString([]byte("not-a-timestamp:id-123")), true},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
_, _, err := decodeCursor(tt.cursor)
|
|
if tt.expectError && err == nil {
|
|
t.Error("expected error for invalid cursor, got nil")
|
|
}
|
|
if !tt.expectError && err != nil {
|
|
t.Errorf("expected no error, got %v", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestJSON_SetsContentType(t *testing.T) {
|
|
w := httptest.NewRecorder()
|
|
data := map[string]string{"key": "value"}
|
|
|
|
JSON(w, http.StatusOK, data)
|
|
|
|
contentType := w.Header().Get("Content-Type")
|
|
if contentType != "application/json" {
|
|
t.Errorf("Content-Type = %q, want application/json", contentType)
|
|
}
|
|
}
|
|
|
|
func TestJSON_SetsStatusCode(t *testing.T) {
|
|
w := httptest.NewRecorder()
|
|
data := map[string]string{"key": "value"}
|
|
|
|
JSON(w, http.StatusCreated, data)
|
|
|
|
if w.Code != http.StatusCreated {
|
|
t.Errorf("Status code = %d, want %d", w.Code, http.StatusCreated)
|
|
}
|
|
}
|
|
|
|
func TestJSON_EncodesData(t *testing.T) {
|
|
w := httptest.NewRecorder()
|
|
data := map[string]interface{}{
|
|
"string": "value",
|
|
"number": 42,
|
|
"bool": true,
|
|
"null": nil,
|
|
}
|
|
|
|
JSON(w, http.StatusOK, data)
|
|
|
|
var result map[string]interface{}
|
|
if err := json.NewDecoder(w.Body).Decode(&result); err != nil {
|
|
t.Fatalf("failed to decode response: %v", err)
|
|
}
|
|
|
|
if result["string"] != "value" {
|
|
t.Errorf("string = %v, want value", result["string"])
|
|
}
|
|
|
|
if result["number"] != float64(42) {
|
|
t.Errorf("number = %v, want 42", result["number"])
|
|
}
|
|
|
|
if result["bool"] != true {
|
|
t.Errorf("bool = %v, want true", result["bool"])
|
|
}
|
|
|
|
if result["null"] != nil {
|
|
t.Errorf("null = %v, want nil", result["null"])
|
|
}
|
|
}
|
|
|
|
func TestError_SetsStatusCode(t *testing.T) {
|
|
w := httptest.NewRecorder()
|
|
|
|
Error(w, http.StatusBadRequest, "Invalid input")
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("Status code = %d, want %d", w.Code, http.StatusBadRequest)
|
|
}
|
|
}
|
|
|
|
func TestError_SetsContentType(t *testing.T) {
|
|
w := httptest.NewRecorder()
|
|
|
|
Error(w, http.StatusBadRequest, "Invalid input")
|
|
|
|
contentType := w.Header().Get("Content-Type")
|
|
if contentType != "application/json" {
|
|
t.Errorf("Content-Type = %q, want application/json", contentType)
|
|
}
|
|
}
|
|
|
|
func TestError_IncludesMessage(t *testing.T) {
|
|
w := httptest.NewRecorder()
|
|
message := "Something went wrong"
|
|
|
|
Error(w, http.StatusInternalServerError, message)
|
|
|
|
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 != message {
|
|
t.Errorf("Message = %q, want %q", errResp.Message, message)
|
|
}
|
|
}
|
|
|
|
func TestError_IncludesStatusText(t *testing.T) {
|
|
w := httptest.NewRecorder()
|
|
|
|
Error(w, http.StatusNotFound, "Resource not found")
|
|
|
|
var errResp ErrorResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&errResp); err != nil {
|
|
t.Fatalf("failed to decode error response: %v", err)
|
|
}
|
|
|
|
if errResp.Error != http.StatusText(http.StatusNotFound) {
|
|
t.Errorf("Error = %q, want %q", errResp.Error, http.StatusText(http.StatusNotFound))
|
|
}
|
|
}
|
|
|
|
func TestErrorWithRequestID_SetsStatusCode(t *testing.T) {
|
|
w := httptest.NewRecorder()
|
|
|
|
ErrorWithRequestID(w, http.StatusBadRequest, "Invalid input", "req-123")
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("Status code = %d, want %d", w.Code, http.StatusBadRequest)
|
|
}
|
|
}
|
|
|
|
func TestErrorWithRequestID_IncludesRequestID(t *testing.T) {
|
|
w := httptest.NewRecorder()
|
|
requestID := "req-abc-def-ghi"
|
|
|
|
ErrorWithRequestID(w, http.StatusInternalServerError, "Server error", requestID)
|
|
|
|
var errResp ErrorResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&errResp); err != nil {
|
|
t.Fatalf("failed to decode error response: %v", err)
|
|
}
|
|
|
|
if errResp.RequestID != requestID {
|
|
t.Errorf("RequestID = %q, want %q", errResp.RequestID, requestID)
|
|
}
|
|
}
|
|
|
|
func TestErrorWithRequestID_IncludesMessage(t *testing.T) {
|
|
w := httptest.NewRecorder()
|
|
message := "Database connection failed"
|
|
|
|
ErrorWithRequestID(w, http.StatusServiceUnavailable, message, "req-123")
|
|
|
|
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 != message {
|
|
t.Errorf("Message = %q, want %q", errResp.Message, message)
|
|
}
|
|
}
|
|
|
|
func TestPagedResponse_Structure(t *testing.T) {
|
|
response := PagedResponse{
|
|
Data: []string{"item1", "item2"},
|
|
Total: 100,
|
|
Page: 2,
|
|
PerPage: 50,
|
|
}
|
|
|
|
data, err := json.Marshal(response)
|
|
if err != nil {
|
|
t.Fatalf("failed to marshal response: %v", err)
|
|
}
|
|
|
|
var result map[string]interface{}
|
|
if err := json.Unmarshal(data, &result); err != nil {
|
|
t.Fatalf("failed to unmarshal response: %v", err)
|
|
}
|
|
|
|
if result["total"] != float64(100) {
|
|
t.Errorf("total = %v, want 100", result["total"])
|
|
}
|
|
|
|
if result["page"] != float64(2) {
|
|
t.Errorf("page = %v, want 2", result["page"])
|
|
}
|
|
|
|
if result["per_page"] != float64(50) {
|
|
t.Errorf("per_page = %v, want 50", result["per_page"])
|
|
}
|
|
|
|
if result["data"] == nil {
|
|
t.Error("data is nil")
|
|
}
|
|
}
|
|
|
|
func TestCursorPagedResponse_Structure(t *testing.T) {
|
|
response := CursorPagedResponse{
|
|
Data: []string{"item1", "item2"},
|
|
Total: 100,
|
|
NextCursor: "abc123def456",
|
|
PageSize: 50,
|
|
}
|
|
|
|
data, err := json.Marshal(response)
|
|
if err != nil {
|
|
t.Fatalf("failed to marshal response: %v", err)
|
|
}
|
|
|
|
var result map[string]interface{}
|
|
if err := json.Unmarshal(data, &result); err != nil {
|
|
t.Fatalf("failed to unmarshal response: %v", err)
|
|
}
|
|
|
|
if result["total"] != float64(100) {
|
|
t.Errorf("total = %v, want 100", result["total"])
|
|
}
|
|
|
|
if result["next_cursor"] != "abc123def456" {
|
|
t.Errorf("next_cursor = %v, want abc123def456", result["next_cursor"])
|
|
}
|
|
|
|
if result["page_size"] != float64(50) {
|
|
t.Errorf("page_size = %v, want 50", result["page_size"])
|
|
}
|
|
}
|
|
|
|
func TestCursorPagedResponse_EmptyNextCursor(t *testing.T) {
|
|
// When NextCursor is empty, it should be omitted from JSON
|
|
response := CursorPagedResponse{
|
|
Data: []string{},
|
|
Total: 0,
|
|
NextCursor: "",
|
|
PageSize: 50,
|
|
}
|
|
|
|
data, err := json.Marshal(response)
|
|
if err != nil {
|
|
t.Fatalf("failed to marshal response: %v", err)
|
|
}
|
|
|
|
// Empty string for next_cursor should be omitted due to omitempty tag
|
|
if bytes.Contains(data, []byte("next_cursor")) {
|
|
t.Error("empty next_cursor should be omitted from JSON")
|
|
}
|
|
}
|
|
|
|
func TestFilterFields_SingleObject(t *testing.T) {
|
|
data := map[string]interface{}{
|
|
"id": "cert-123",
|
|
"name": "My Cert",
|
|
"expiry": "2025-01-01",
|
|
"status": "active",
|
|
}
|
|
|
|
result := filterFields(data, []string{"id", "name"})
|
|
|
|
resultMap, ok := result.(map[string]interface{})
|
|
if !ok {
|
|
t.Fatalf("result is not map[string]interface{}, got %T", result)
|
|
}
|
|
|
|
if resultMap["id"] != "cert-123" {
|
|
t.Errorf("id = %v, want cert-123", resultMap["id"])
|
|
}
|
|
|
|
if resultMap["name"] != "My Cert" {
|
|
t.Errorf("name = %v, want My Cert", resultMap["name"])
|
|
}
|
|
|
|
if _, hasExpiry := resultMap["expiry"]; hasExpiry {
|
|
t.Error("expiry should be filtered out")
|
|
}
|
|
|
|
if _, hasStatus := resultMap["status"]; hasStatus {
|
|
t.Error("status should be filtered out")
|
|
}
|
|
}
|
|
|
|
func TestFilterFields_EmptyFields(t *testing.T) {
|
|
// Empty fields list should return data unchanged
|
|
data := map[string]interface{}{
|
|
"id": "cert-123",
|
|
"name": "My Cert",
|
|
}
|
|
|
|
result := filterFields(data, []string{})
|
|
|
|
// Should return original data unchanged
|
|
resultMap, ok := result.(map[string]interface{})
|
|
if !ok {
|
|
t.Fatalf("result is not map[string]interface{}, got %T", result)
|
|
}
|
|
|
|
if len(resultMap) != 2 {
|
|
t.Errorf("filtered result has %d fields, want 2", len(resultMap))
|
|
}
|
|
}
|
|
|
|
func TestFilterFields_NoMatchingFields(t *testing.T) {
|
|
data := map[string]interface{}{
|
|
"id": "cert-123",
|
|
"name": "My Cert",
|
|
}
|
|
|
|
result := filterFields(data, []string{"nonexistent", "also-not-there"})
|
|
|
|
resultMap, ok := result.(map[string]interface{})
|
|
if !ok {
|
|
t.Fatalf("result is not map[string]interface{}, got %T", result)
|
|
}
|
|
|
|
if len(resultMap) != 0 {
|
|
t.Errorf("filtered result has %d fields, want 0", len(resultMap))
|
|
}
|
|
}
|
|
|
|
func TestFilterFields_InvalidJSON(t *testing.T) {
|
|
// Non-serializable data should be returned as-is
|
|
data := make(chan int) // channels can't be marshaled to JSON
|
|
|
|
result := filterFields(data, []string{"field"})
|
|
|
|
// Should return original data unchanged
|
|
if result != data {
|
|
t.Error("invalid data should be returned unchanged")
|
|
}
|
|
}
|