Files
certctl/internal/service/agent_test.go
T
Shankar 6b2d1375e6 fix(m2-pr-e): collapse AgentService.HeartbeatWithContext into Heartbeat
PR-E of 6 in the M-2 end-to-end remediation sequence. Collapses the
HeartbeatWithContext wrapper into a single ctx-first Heartbeat method,
matching D-1 (ctx-only signatures, no dual forms). The handler-facing
method name is preserved (D-4) — internal/api/handler/agents.go already
declares `Heartbeat(ctx, ...)` on its local service interface, and the
handler mock at internal/api/handler/agent_handler_test.go already
takes `_ context.Context` as its first param, so no handler churn.

Changes
-------
internal/service/agent.go
  - Delete the zero-body Heartbeat wrapper that forwarded to
    HeartbeatWithContext with context.Background().
  - Rename HeartbeatWithContext → Heartbeat (ctx-bearing body
    folded directly into the canonical method).

internal/service/agent_test.go
  - TestHeartbeat (L95) and TestHeartbeat_NotFound (L128):
    agentService.HeartbeatWithContext(ctx, ...) → .Heartbeat(ctx, ...).

internal/service/concurrent_test.go
  - L162: agentSvc.HeartbeatWithContext(ctx, agentID, metadata)
    → .Heartbeat(ctx, agentID, metadata).

internal/service/context_test.go
  - L179 + L232: agentSvc.HeartbeatWithContext(ctx, ...) → .Heartbeat(...)
  - L185 + L238 t.Logf strings: "HeartbeatWithContext with ..." →
    "Heartbeat with ..." to match the collapsed method name.

Verification (Go 1.25.9 linux/arm64, CI-parity caches)
------------------------------------------------------
  go build ./...                 clean
  go vet ./...                   clean
  go test -short ./internal/service/... ./internal/api/handler/... \
    ./internal/integration/...   all ok
  go test -race -short same set  all ok
  go test -short ./...           all packages ok
  golangci-lint run ./...        0 issues

Locked decisions from the M-2 plan:
  D-1 ctx-only signatures (no dual forms)
  D-4 preserve handler method names facing the router
  D-5 domain types stay ctx-free

Audit complete. Commit: 855124a9d9. Sections: 12. Findings: 2/7/10/4/6.
2026-04-18 01:25:20 +00:00

639 lines
21 KiB
Go

package service
import (
"context"
"encoding/base64"
"log/slog"
"testing"
"time"
"github.com/shankar0123/certctl/internal/domain"
)
func TestRegisterAgent(t *testing.T) {
ctx := context.Background()
agentRepo := &mockAgentRepo{
Agents: make(map[string]*domain.Agent),
HeartbeatUpdates: make(map[string]time.Time),
}
certRepo := &mockCertRepo{
Certs: make(map[string]*domain.ManagedCertificate),
Versions: make(map[string][]*domain.CertificateVersion),
}
jobRepo := &mockJobRepo{
Jobs: make(map[string]*domain.Job),
StatusUpdates: make(map[string]domain.JobStatus),
}
targetRepo := &mockTargetRepo{
Targets: make(map[string]*domain.DeploymentTarget),
}
auditRepo := &mockAuditRepo{Events: []*domain.AuditEvent{}}
auditService := NewAuditService(auditRepo)
issuerRegistry := NewIssuerRegistry(slog.Default())
agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, issuerRegistry, nil)
agent, apiKey, err := agentService.Register(ctx, "prod-agent-1", "server-01.example.com")
if err != nil {
t.Fatalf("Register failed: %v", err)
}
if agent.Name != "prod-agent-1" {
t.Errorf("expected name prod-agent-1, got %s", agent.Name)
}
if agent.Hostname != "server-01.example.com" {
t.Errorf("expected hostname server-01.example.com, got %s", agent.Hostname)
}
if agent.Status != domain.AgentStatusOnline {
t.Errorf("expected status Online, got %s", agent.Status)
}
if apiKey == "" {
t.Fatal("expected non-empty API key")
}
if len(agentRepo.Agents) != 1 {
t.Errorf("expected 1 agent in repo, got %d", len(agentRepo.Agents))
}
}
func TestHeartbeat(t *testing.T) {
ctx := context.Background()
now := time.Now()
agent := &domain.Agent{
ID: "agent-001",
Name: "prod-agent",
Hostname: "server-01",
Status: domain.AgentStatusOnline,
RegisteredAt: now,
LastHeartbeatAt: &now,
APIKeyHash: "hash123",
}
agentRepo := &mockAgentRepo{
Agents: map[string]*domain.Agent{"agent-001": agent},
HeartbeatUpdates: make(map[string]time.Time),
}
certRepo := &mockCertRepo{
Certs: make(map[string]*domain.ManagedCertificate),
Versions: make(map[string][]*domain.CertificateVersion),
}
jobRepo := &mockJobRepo{
Jobs: make(map[string]*domain.Job),
StatusUpdates: make(map[string]domain.JobStatus),
}
targetRepo := &mockTargetRepo{
Targets: make(map[string]*domain.DeploymentTarget),
}
auditRepo := &mockAuditRepo{}
auditService := NewAuditService(auditRepo)
issuerRegistry := NewIssuerRegistry(slog.Default())
agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, issuerRegistry, nil)
err := agentService.Heartbeat(ctx, "agent-001", nil)
if err != nil {
t.Fatalf("Heartbeat failed: %v", err)
}
if _, ok := agentRepo.HeartbeatUpdates["agent-001"]; !ok {
t.Fatal("heartbeat not recorded")
}
}
func TestHeartbeat_NotFound(t *testing.T) {
ctx := context.Background()
agentRepo := &mockAgentRepo{
Agents: make(map[string]*domain.Agent),
HeartbeatUpdates: make(map[string]time.Time),
}
certRepo := &mockCertRepo{
Certs: make(map[string]*domain.ManagedCertificate),
Versions: make(map[string][]*domain.CertificateVersion),
}
jobRepo := &mockJobRepo{
Jobs: make(map[string]*domain.Job),
StatusUpdates: make(map[string]domain.JobStatus),
}
targetRepo := &mockTargetRepo{
Targets: make(map[string]*domain.DeploymentTarget),
}
auditRepo := &mockAuditRepo{}
auditService := NewAuditService(auditRepo)
issuerRegistry := NewIssuerRegistry(slog.Default())
agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, issuerRegistry, nil)
err := agentService.Heartbeat(ctx, "nonexistent", nil)
if err == nil {
t.Fatal("expected error for nonexistent agent")
}
}
func TestGetPendingWork(t *testing.T) {
ctx := context.Background()
now := time.Now()
agentID := "agent-001"
agent := &domain.Agent{
ID: agentID,
Name: "prod-agent",
Hostname: "server-01",
Status: domain.AgentStatusOnline,
RegisteredAt: now,
LastHeartbeatAt: &now,
APIKeyHash: "hash123",
}
job1 := &domain.Job{
ID: "job-001",
Type: domain.JobTypeDeployment,
CertificateID: "cert-001",
Status: domain.JobStatusPending,
AgentID: &agentID,
CreatedAt: now,
}
job2 := &domain.Job{
ID: "job-002",
Type: domain.JobTypeRenewal,
CertificateID: "cert-002",
Status: domain.JobStatusPending,
CreatedAt: now,
}
agentRepo := &mockAgentRepo{
Agents: map[string]*domain.Agent{agentID: agent},
HeartbeatUpdates: make(map[string]time.Time),
}
certRepo := &mockCertRepo{
Certs: make(map[string]*domain.ManagedCertificate),
Versions: make(map[string][]*domain.CertificateVersion),
}
jobRepo := &mockJobRepo{
Jobs: map[string]*domain.Job{"job-001": job1, "job-002": job2},
StatusUpdates: make(map[string]domain.JobStatus),
}
targetRepo := &mockTargetRepo{
Targets: make(map[string]*domain.DeploymentTarget),
}
auditRepo := &mockAuditRepo{}
auditService := NewAuditService(auditRepo)
issuerRegistry := NewIssuerRegistry(slog.Default())
agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, issuerRegistry, nil)
jobs, err := agentService.GetPendingWork(ctx, agentID)
if err != nil {
t.Fatalf("GetPendingWork failed: %v", err)
}
if len(jobs) != 1 {
t.Errorf("expected 1 deployment job, got %d", len(jobs))
}
if len(jobs) > 0 && jobs[0].Type != domain.JobTypeDeployment {
t.Errorf("expected JobTypeDeployment, got %s", jobs[0].Type)
}
}
func TestGetPendingWork_OnlyReturnsAgentJobs(t *testing.T) {
ctx := context.Background()
now := time.Now()
agentA := "agent-A"
agentB := "agent-B"
agentRepo := &mockAgentRepo{
Agents: map[string]*domain.Agent{
agentA: {ID: agentA, Name: "agent-A", Hostname: "host-a", Status: domain.AgentStatusOnline, RegisteredAt: now, APIKeyHash: "hashA"},
agentB: {ID: agentB, Name: "agent-B", Hostname: "host-b", Status: domain.AgentStatusOnline, RegisteredAt: now, APIKeyHash: "hashB"},
},
HeartbeatUpdates: make(map[string]time.Time),
}
jobA := &domain.Job{ID: "job-A", Type: domain.JobTypeDeployment, CertificateID: "cert-001", Status: domain.JobStatusPending, AgentID: &agentA, CreatedAt: now}
jobB := &domain.Job{ID: "job-B", Type: domain.JobTypeDeployment, CertificateID: "cert-002", Status: domain.JobStatusPending, AgentID: &agentB, CreatedAt: now}
jobRepo := &mockJobRepo{
Jobs: map[string]*domain.Job{"job-A": jobA, "job-B": jobB},
StatusUpdates: make(map[string]domain.JobStatus),
}
certRepo := &mockCertRepo{Certs: make(map[string]*domain.ManagedCertificate), Versions: make(map[string][]*domain.CertificateVersion)}
targetRepo := &mockTargetRepo{Targets: make(map[string]*domain.DeploymentTarget)}
auditService := NewAuditService(&mockAuditRepo{})
issuerRegistry := NewIssuerRegistry(slog.Default())
agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, issuerRegistry, nil)
// Agent A should only see its job
jobsA, err := agentService.GetPendingWork(ctx, agentA)
if err != nil {
t.Fatalf("GetPendingWork for agent-A failed: %v", err)
}
if len(jobsA) != 1 {
t.Fatalf("expected 1 job for agent-A, got %d", len(jobsA))
}
if jobsA[0].ID != "job-A" {
t.Errorf("expected job-A, got %s", jobsA[0].ID)
}
// Agent B should only see its job
jobsB, err := agentService.GetPendingWork(ctx, agentB)
if err != nil {
t.Fatalf("GetPendingWork for agent-B failed: %v", err)
}
if len(jobsB) != 1 {
t.Fatalf("expected 1 job for agent-B, got %d", len(jobsB))
}
if jobsB[0].ID != "job-B" {
t.Errorf("expected job-B, got %s", jobsB[0].ID)
}
}
func TestGetPendingWork_EmptyWhenNoJobsForAgent(t *testing.T) {
ctx := context.Background()
now := time.Now()
agentA := "agent-A"
agentB := "agent-B"
agentRepo := &mockAgentRepo{
Agents: map[string]*domain.Agent{
agentA: {ID: agentA, Name: "agent-A", Hostname: "host-a", Status: domain.AgentStatusOnline, RegisteredAt: now, APIKeyHash: "hashA"},
},
HeartbeatUpdates: make(map[string]time.Time),
}
// All jobs belong to agent-B
jobB := &domain.Job{ID: "job-B", Type: domain.JobTypeDeployment, CertificateID: "cert-001", Status: domain.JobStatusPending, AgentID: &agentB, CreatedAt: now}
jobRepo := &mockJobRepo{
Jobs: map[string]*domain.Job{"job-B": jobB},
StatusUpdates: make(map[string]domain.JobStatus),
}
certRepo := &mockCertRepo{Certs: make(map[string]*domain.ManagedCertificate), Versions: make(map[string][]*domain.CertificateVersion)}
targetRepo := &mockTargetRepo{Targets: make(map[string]*domain.DeploymentTarget)}
auditService := NewAuditService(&mockAuditRepo{})
issuerRegistry := NewIssuerRegistry(slog.Default())
agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, issuerRegistry, nil)
jobs, err := agentService.GetPendingWork(ctx, agentA)
if err != nil {
t.Fatalf("GetPendingWork failed: %v", err)
}
if len(jobs) != 0 {
t.Errorf("expected 0 jobs for agent-A (all jobs are for agent-B), got %d", len(jobs))
}
}
func TestGetPendingWork_DeploymentAndCSR_Scoped(t *testing.T) {
ctx := context.Background()
now := time.Now()
agentA := "agent-A"
agentRepo := &mockAgentRepo{
Agents: map[string]*domain.Agent{
agentA: {ID: agentA, Name: "agent-A", Hostname: "host-a", Status: domain.AgentStatusOnline, RegisteredAt: now, APIKeyHash: "hashA"},
},
HeartbeatUpdates: make(map[string]time.Time),
}
deployJob := &domain.Job{ID: "job-deploy", Type: domain.JobTypeDeployment, CertificateID: "cert-001", Status: domain.JobStatusPending, AgentID: &agentA, CreatedAt: now}
csrJob := &domain.Job{ID: "job-csr", Type: domain.JobTypeRenewal, CertificateID: "cert-002", Status: domain.JobStatusAwaitingCSR, AgentID: &agentA, CreatedAt: now}
jobRepo := &mockJobRepo{
Jobs: map[string]*domain.Job{"job-deploy": deployJob, "job-csr": csrJob},
StatusUpdates: make(map[string]domain.JobStatus),
}
certRepo := &mockCertRepo{Certs: make(map[string]*domain.ManagedCertificate), Versions: make(map[string][]*domain.CertificateVersion)}
targetRepo := &mockTargetRepo{Targets: make(map[string]*domain.DeploymentTarget)}
auditService := NewAuditService(&mockAuditRepo{})
issuerRegistry := NewIssuerRegistry(slog.Default())
agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, issuerRegistry, nil)
jobs, err := agentService.GetPendingWork(ctx, agentA)
if err != nil {
t.Fatalf("GetPendingWork failed: %v", err)
}
if len(jobs) != 2 {
t.Fatalf("expected 2 jobs (deployment + AwaitingCSR), got %d", len(jobs))
}
}
func TestReportJobStatus(t *testing.T) {
ctx := context.Background()
now := time.Now()
agent := &domain.Agent{
ID: "agent-001",
Name: "prod-agent",
Hostname: "server-01",
Status: domain.AgentStatusOnline,
RegisteredAt: now,
LastHeartbeatAt: &now,
APIKeyHash: "hash123",
}
job := &domain.Job{
ID: "job-001",
Type: domain.JobTypeDeployment,
CertificateID: "cert-001",
Status: domain.JobStatusRunning,
CreatedAt: now,
}
agentRepo := &mockAgentRepo{
Agents: map[string]*domain.Agent{"agent-001": agent},
HeartbeatUpdates: make(map[string]time.Time),
}
certRepo := &mockCertRepo{
Certs: make(map[string]*domain.ManagedCertificate),
Versions: make(map[string][]*domain.CertificateVersion),
}
jobRepo := &mockJobRepo{
Jobs: map[string]*domain.Job{"job-001": job},
StatusUpdates: make(map[string]domain.JobStatus),
}
targetRepo := &mockTargetRepo{
Targets: make(map[string]*domain.DeploymentTarget),
}
auditRepo := &mockAuditRepo{Events: []*domain.AuditEvent{}}
auditService := NewAuditService(auditRepo)
issuerRegistry := NewIssuerRegistry(slog.Default())
agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, issuerRegistry, nil)
err := agentService.ReportJobStatus(ctx, "agent-001", "job-001", domain.JobStatusCompleted, "")
if err != nil {
t.Fatalf("ReportJobStatus failed: %v", err)
}
if jobRepo.StatusUpdates["job-001"] != domain.JobStatusCompleted {
t.Errorf("expected status Completed, got %s", jobRepo.StatusUpdates["job-001"])
}
if len(auditRepo.Events) != 1 {
t.Errorf("expected 1 audit event, got %d", len(auditRepo.Events))
}
}
func TestMarkStaleAgentsOffline(t *testing.T) {
ctx := context.Background()
now := time.Now()
staleTime := now.Add(-3 * time.Hour)
agent1 := &domain.Agent{
ID: "agent-001",
Name: "online-agent",
Hostname: "server-01",
Status: domain.AgentStatusOnline,
RegisteredAt: now,
LastHeartbeatAt: &now,
APIKeyHash: "hash1",
}
agent2 := &domain.Agent{
ID: "agent-002",
Name: "stale-agent",
Hostname: "server-02",
Status: domain.AgentStatusOnline,
RegisteredAt: now.Add(-24 * time.Hour),
LastHeartbeatAt: &staleTime,
APIKeyHash: "hash2",
}
agentRepo := &mockAgentRepo{
Agents: map[string]*domain.Agent{"agent-001": agent1, "agent-002": agent2},
HeartbeatUpdates: make(map[string]time.Time),
}
certRepo := &mockCertRepo{
Certs: make(map[string]*domain.ManagedCertificate),
Versions: make(map[string][]*domain.CertificateVersion),
}
jobRepo := &mockJobRepo{
Jobs: make(map[string]*domain.Job),
StatusUpdates: make(map[string]domain.JobStatus),
}
targetRepo := &mockTargetRepo{
Targets: make(map[string]*domain.DeploymentTarget),
}
auditRepo := &mockAuditRepo{}
auditService := NewAuditService(auditRepo)
issuerRegistry := NewIssuerRegistry(slog.Default())
agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, issuerRegistry, nil)
err := agentService.MarkStaleAgentsOffline(ctx, 1*time.Hour)
if err != nil {
t.Fatalf("MarkStaleAgentsOffline failed: %v", err)
}
if agentRepo.Agents["agent-001"].Status != domain.AgentStatusOnline {
t.Errorf("expected agent-001 to be Online, got %s", agentRepo.Agents["agent-001"].Status)
}
if agentRepo.Agents["agent-002"].Status != domain.AgentStatusOffline {
t.Errorf("expected agent-002 to be Offline, got %s", agentRepo.Agents["agent-002"].Status)
}
}
func TestSubmitCSR(t *testing.T) {
ctx := context.Background()
now := time.Now()
agent := &domain.Agent{
ID: "agent-001",
Name: "prod-agent",
Hostname: "server-01",
Status: domain.AgentStatusOnline,
RegisteredAt: now,
LastHeartbeatAt: &now,
APIKeyHash: "hash123",
}
cert := &domain.ManagedCertificate{
ID: "cert-001",
CommonName: "example.com",
IssuerID: "iss-local",
Status: domain.CertificateStatusPending,
ExpiresAt: now.AddDate(1, 0, 0),
CreatedAt: now,
UpdatedAt: now,
}
agentRepo := &mockAgentRepo{
Agents: map[string]*domain.Agent{"agent-001": agent},
HeartbeatUpdates: make(map[string]time.Time),
}
certRepo := &mockCertRepo{
Certs: map[string]*domain.ManagedCertificate{"cert-001": cert},
Versions: make(map[string][]*domain.CertificateVersion),
}
jobRepo := &mockJobRepo{
Jobs: make(map[string]*domain.Job),
StatusUpdates: make(map[string]domain.JobStatus),
}
targetRepo := &mockTargetRepo{
Targets: make(map[string]*domain.DeploymentTarget),
}
auditRepo := &mockAuditRepo{Events: []*domain.AuditEvent{}}
auditService := NewAuditService(auditRepo)
issuerConnector := &mockIssuerConnector{
Result: &IssuanceResult{
Serial: "serial-123",
CertPEM: "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----",
ChainPEM: "-----BEGIN CERTIFICATE-----\nchain\n-----END CERTIFICATE-----",
NotBefore: now,
NotAfter: now.AddDate(1, 0, 0),
},
}
issuerRegistry := NewIssuerRegistry(slog.Default())
issuerRegistry.Set("iss-local", issuerConnector)
agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, issuerRegistry, nil)
csrPEM := generateTestCSR(t, "ECDSA", 256)
err := agentService.SubmitCSR(ctx, "agent-001", "cert-001", []byte(csrPEM))
if err != nil {
t.Fatalf("SubmitCSR failed: %v", err)
}
if len(certRepo.Versions["cert-001"]) != 1 {
t.Errorf("expected 1 certificate version, got %d", len(certRepo.Versions["cert-001"]))
}
if cert.Status != domain.CertificateStatusActive {
t.Errorf("expected certificate status Active, got %s", cert.Status)
}
}
func TestSubmitCSR_EmptyCSR(t *testing.T) {
ctx := context.Background()
now := time.Now()
agent := &domain.Agent{
ID: "agent-001",
Name: "prod-agent",
Hostname: "server-01",
Status: domain.AgentStatusOnline,
RegisteredAt: now,
LastHeartbeatAt: &now,
APIKeyHash: "hash123",
}
agentRepo := &mockAgentRepo{
Agents: map[string]*domain.Agent{"agent-001": agent},
HeartbeatUpdates: make(map[string]time.Time),
}
certRepo := &mockCertRepo{
Certs: make(map[string]*domain.ManagedCertificate),
Versions: make(map[string][]*domain.CertificateVersion),
}
jobRepo := &mockJobRepo{
Jobs: make(map[string]*domain.Job),
StatusUpdates: make(map[string]domain.JobStatus),
}
targetRepo := &mockTargetRepo{
Targets: make(map[string]*domain.DeploymentTarget),
}
auditRepo := &mockAuditRepo{}
auditService := NewAuditService(auditRepo)
issuerRegistry := NewIssuerRegistry(slog.Default())
agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, issuerRegistry, nil)
err := agentService.SubmitCSR(ctx, "agent-001", "", []byte{})
if err == nil {
t.Fatal("expected error for empty CSR")
}
}
func TestListAgents(t *testing.T) {
now := time.Now()
agent1 := &domain.Agent{
ID: "agent-001",
Name: "agent1",
Hostname: "server-01",
Status: domain.AgentStatusOnline,
RegisteredAt: now,
LastHeartbeatAt: &now,
APIKeyHash: "hash1",
}
agent2 := &domain.Agent{
ID: "agent-002",
Name: "agent2",
Hostname: "server-02",
Status: domain.AgentStatusOnline,
RegisteredAt: now,
LastHeartbeatAt: &now,
APIKeyHash: "hash2",
}
agentRepo := &mockAgentRepo{
Agents: map[string]*domain.Agent{"agent-001": agent1, "agent-002": agent2},
HeartbeatUpdates: make(map[string]time.Time),
}
certRepo := &mockCertRepo{
Certs: make(map[string]*domain.ManagedCertificate),
Versions: make(map[string][]*domain.CertificateVersion),
}
jobRepo := &mockJobRepo{
Jobs: make(map[string]*domain.Job),
StatusUpdates: make(map[string]domain.JobStatus),
}
targetRepo := &mockTargetRepo{
Targets: make(map[string]*domain.DeploymentTarget),
}
auditRepo := &mockAuditRepo{}
auditService := NewAuditService(auditRepo)
issuerRegistry := NewIssuerRegistry(slog.Default())
agentService := NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, issuerRegistry, nil)
agents, total, err := agentService.ListAgents(context.Background(), 1, 50)
if err != nil {
t.Fatalf("ListAgents failed: %v", err)
}
if len(agents) != 2 {
t.Errorf("expected 2 agents, got %d", len(agents))
}
if total != 2 {
t.Errorf("expected total 2, got %d", total)
}
}
// TestGenerateAPIKey_Properties is the core regression test for C-1 (CWE-338).
// It verifies that generateAPIKey produces cryptographically random,
// unpadded base64url-encoded, 32-byte (256-bit) keys that never collide
// across consecutive calls. Exact length and alphabet are verified against
// base64.RawURLEncoding so any silent change to entropy or encoding fails
// fast.
//
// Note on the error branch: since Go 1.24 (issue #66821) crypto/rand.Read
// treats entropy-source failures as fatal — the process is terminated
// rather than returning an error. The defensive `if err != nil` branch
// in generateAPIKey is therefore unreachable from tests on modern Go.
// It is kept to preserve the documented (string, error) contract and
// to remain correct on older Go toolchains or future changes.
func TestGenerateAPIKey_Properties(t *testing.T) {
seen := make(map[string]struct{}, 64)
for i := 0; i < 64; i++ {
k, err := generateAPIKey()
if err != nil {
t.Fatalf("generateAPIKey failed: %v", err)
}
if k == "" {
t.Fatal("expected non-empty API key")
}
// base64.RawURLEncoding of 32 bytes yields exactly 43 chars.
if got, want := len(k), 43; got != want {
t.Fatalf("expected key length %d, got %d (%q)", want, got, k)
}
decoded, err := base64.RawURLEncoding.DecodeString(k)
if err != nil {
t.Fatalf("key %q not valid base64url: %v", k, err)
}
if len(decoded) != 32 {
t.Fatalf("expected 32 decoded bytes (256 bits entropy), got %d", len(decoded))
}
if _, dup := seen[k]; dup {
t.Fatalf("collision detected after %d calls; weak PRNG?", i+1)
}
seen[k] = struct{}{}
}
}