diff --git a/go.mod b/go.mod index 74091c9..90d9999 100644 --- a/go.mod +++ b/go.mod @@ -13,6 +13,7 @@ require ( github.com/aws/aws-sdk-go-v2 v1.41.7 github.com/aws/aws-sdk-go-v2/config v1.32.17 github.com/aws/aws-sdk-go-v2/service/acmpca v1.46.14 + github.com/aws/smithy-go v1.25.1 github.com/go-jose/go-jose/v4 v4.1.4 github.com/leanovate/gopter v0.2.11 github.com/masterzen/winrm v0.0.0-20250927112105-5f8e6c707321 @@ -39,7 +40,6 @@ require ( github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 // indirect github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 // indirect - github.com/aws/smithy-go v1.25.1 // indirect github.com/bodgit/ntlmssp v0.0.0-20240506230425-31973bb52d9b // indirect github.com/bodgit/windows v1.0.1 // indirect github.com/cenkalti/backoff/v4 v4.2.1 // indirect diff --git a/internal/connector/issuer/awsacmpca/awsacmpca_failure_test.go b/internal/connector/issuer/awsacmpca/awsacmpca_failure_test.go new file mode 100644 index 0000000..c974021 --- /dev/null +++ b/internal/connector/issuer/awsacmpca/awsacmpca_failure_test.go @@ -0,0 +1,292 @@ +package awsacmpca_test + +// Top-10 fix #4 of the 2026-05-03 issuer-coverage audit. AWSACMPCA is +// usually the first-deployed issuer in enterprise pilots — diligence +// reviews dig hard into IAM-error / cloud-error coverage. Pre-fix, +// awsacmpca_test.go covered the happy path and a few generic +// connector-level error paths (TestNew_ErrorPaths) but did not pin +// behaviour against the AWS SDK v2's typed error values that real +// production traffic surfaces. +// +// The five tests below pin the operator-visible contract for each +// major SDK error class: every test injects a typed error via the +// existing mockACMPCAClient seam from awsacmpca_test.go, calls the +// connector, and asserts that +// +// 1. the error is non-nil, +// 2. errors.As against the SDK's typed error value succeeds (so the +// wrap chain via fmt.Errorf("...%w", err) is intact and upstream +// retry/classification logic can still introspect the typed +// value), and +// 3. an operator-actionable substring is present in the surfaced +// message (e.g. the missing CA ARN, the validation issue, the +// throttling-class marker). +// +// Notes on SDK error mapping: +// +// * AccessDenied is NOT modeled as a generated *types.Access* +// value in service/acmpca/types/errors.go (read it locally to +// confirm). Real production traffic surfaces it as a smithy +// APIError with Code="AccessDeniedException", which the AWS SDK +// v2 deserialises into *smithy.GenericAPIError. The first test +// uses that shape. +// +// * RequestInProgressException IS a generated typed value and is +// used by AWS PCA to mean "your request is already being handled, +// resubmit after a delay". The test asserts the connector +// surfaces it as a wrapped error (operator decides what to do +// with the typed value upstream); this is a contract test, not a +// retry-policy test (per the spec's "out of scope" note: no new +// retry logic in this commit). +// +// Test-only commit. No production code changes. + +import ( + "context" + "errors" + "log/slog" + "os" + "strings" + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + acmpcatypes "github.com/aws/aws-sdk-go-v2/service/acmpca/types" + smithy "github.com/aws/smithy-go" + + "github.com/shankar0123/certctl/internal/connector/issuer" + "github.com/shankar0123/certctl/internal/connector/issuer/awsacmpca" +) + +// failureTestLogger returns a debug-level slog logger writing to stdout. +// Mirrors the per-test logger in awsacmpca_test.go to keep failure logs +// easy to grep when a test regresses. +func failureTestLogger() *slog.Logger { + return slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug})) +} + +// failureTestConfig returns a minimal valid awsacmpca.Config sufficient +// for IssueCertificate / RevokeCertificate / GetCACertPEM call sites. +// All five tests use the same shape — extracted to avoid copy-paste. +func failureTestConfig() awsacmpca.Config { + return awsacmpca.Config{ + Region: "us-east-1", + CAArn: "arn:aws:acm-pca:us-east-1:123456789012:certificate-authority/12345678-1234-1234-1234-123456789012", + } +} + +// TestAWSACMPCA_Issue_AccessDenied_OperatorActionableError pins the +// behaviour when the IAM principal calling certctl lacks the +// acm-pca:IssueCertificate permission. AWS surfaces this as a smithy +// APIError with Code="AccessDeniedException"; the SDK does not +// generate a typed *types.AccessDeniedException value. +func TestAWSACMPCA_Issue_AccessDenied_OperatorActionableError(t *testing.T) { + ctx := context.Background() + _, csrPEM := generateTestCertAndCSR(t) + + sdkErr := &smithy.GenericAPIError{ + Code: "AccessDeniedException", + Message: "User: arn:aws:iam::123456789012:user/certctl is not authorized to perform: acm-pca:IssueCertificate on resource: arn:aws:acm-pca:us-east-1:123456789012:certificate-authority/missing", + Fault: smithy.FaultClient, + } + mock := &mockACMPCAClient{issueCertificateErr: sdkErr} + + cfg := failureTestConfig() + c := awsacmpca.NewWithClient(&cfg, mock, failureTestLogger()) + + _, err := c.IssueCertificate(ctx, issuer.IssuanceRequest{ + CommonName: "app.example.com", + CSRPEM: csrPEM, + }) + if err == nil { + t.Fatal("expected error from access-denied IssueCertificate, got nil") + } + + var gotSDK *smithy.GenericAPIError + if !errors.As(err, &gotSDK) { + t.Fatalf("wrap chain broke — errors.As against *smithy.GenericAPIError failed; err=%v", err) + } + if gotSDK.ErrorCode() != "AccessDeniedException" { + t.Errorf("expected ErrorCode=AccessDeniedException, got %q", gotSDK.ErrorCode()) + } + + msg := err.Error() + if !strings.Contains(msg, "AccessDenied") { + t.Errorf("operator-actionable substring missing — message must mention AccessDenied; got: %s", msg) + } + if !strings.Contains(msg, "not authorized") { + t.Errorf("operator-actionable substring missing — message must mention 'not authorized'; got: %s", msg) + } + if !strings.Contains(msg, "IssueCertificate failed") { + t.Errorf("connector wrap missing — expected 'IssueCertificate failed: ...' framing; got: %s", msg) + } +} + +// TestAWSACMPCA_Issue_ResourceNotFound_NamesTheMissingCAArn pins the +// behaviour when the configured CA ARN does not exist. The SDK's +// *types.ResourceNotFoundException carries the ARN in its message; +// the connector must preserve that ARN through the wrap chain so an +// operator reading the error can identify which CA was missing. +func TestAWSACMPCA_Issue_ResourceNotFound_NamesTheMissingCAArn(t *testing.T) { + ctx := context.Background() + _, csrPEM := generateTestCertAndCSR(t) + + missingArn := "arn:aws:acm-pca:us-east-1:123456789012:certificate-authority/deadbeef-dead-beef-dead-beefdeadbeef" + sdkErr := &acmpcatypes.ResourceNotFoundException{ + Message: aws.String("Could not find Certificate Authority " + missingArn), + } + mock := &mockACMPCAClient{issueCertificateErr: sdkErr} + + cfg := failureTestConfig() + c := awsacmpca.NewWithClient(&cfg, mock, failureTestLogger()) + + _, err := c.IssueCertificate(ctx, issuer.IssuanceRequest{ + CommonName: "app.example.com", + CSRPEM: csrPEM, + }) + if err == nil { + t.Fatal("expected error from resource-not-found IssueCertificate, got nil") + } + + var gotSDK *acmpcatypes.ResourceNotFoundException + if !errors.As(err, &gotSDK) { + t.Fatalf("wrap chain broke — errors.As against *types.ResourceNotFoundException failed; err=%v", err) + } + + msg := err.Error() + if !strings.Contains(msg, missingArn) { + t.Errorf("operator-actionable substring missing — message must name the missing CA ARN %q; got: %s", missingArn, msg) + } + if !strings.Contains(msg, "ResourceNotFoundException") { + t.Errorf("expected ResourceNotFoundException in surfaced message; got: %s", msg) + } +} + +// TestAWSACMPCA_Issue_Throttling_RetryableSurfacePreserved pins the +// behaviour when ACM PCA throttles a burst of issuance calls. Real +// traffic surfaces ThrottlingException via *smithy.GenericAPIError; +// the connector must preserve the typed value so any upstream retry +// layer can recognise the retryable class. (Per the spec's "out of +// scope" note: this commit does not add retry logic.) +func TestAWSACMPCA_Issue_Throttling_RetryableSurfacePreserved(t *testing.T) { + ctx := context.Background() + _, csrPEM := generateTestCertAndCSR(t) + + sdkErr := &smithy.GenericAPIError{ + Code: "ThrottlingException", + Message: "Rate exceeded", + Fault: smithy.FaultServer, + } + mock := &mockACMPCAClient{issueCertificateErr: sdkErr} + + cfg := failureTestConfig() + c := awsacmpca.NewWithClient(&cfg, mock, failureTestLogger()) + + _, err := c.IssueCertificate(ctx, issuer.IssuanceRequest{ + CommonName: "app.example.com", + CSRPEM: csrPEM, + }) + if err == nil { + t.Fatal("expected error from throttled IssueCertificate, got nil") + } + + var gotSDK *smithy.GenericAPIError + if !errors.As(err, &gotSDK) { + t.Fatalf("wrap chain broke — errors.As against *smithy.GenericAPIError failed; err=%v", err) + } + if gotSDK.ErrorCode() != "ThrottlingException" { + t.Errorf("expected ErrorCode=ThrottlingException, got %q", gotSDK.ErrorCode()) + } + if gotSDK.ErrorFault() != smithy.FaultServer { + t.Errorf("expected FaultServer (retryable class) preserved through wrap; got %v", gotSDK.ErrorFault()) + } + + msg := err.Error() + if !strings.Contains(msg, "Throttling") { + t.Errorf("operator-actionable substring missing — message must mention Throttling; got: %s", msg) + } +} + +// TestAWSACMPCA_Issue_MalformedCSR_TerminalNotRetryable pins the +// behaviour when the CSR submitted to ACM PCA is invalid (e.g. +// unsupported key algorithm, malformed DER, key size below CA's +// policy floor). This is a terminal class — operators must fix the +// CSR, not retry. The connector must preserve the typed value so +// upstream classification can distinguish "fix and resubmit" from +// "wait and retry". +func TestAWSACMPCA_Issue_MalformedCSR_TerminalNotRetryable(t *testing.T) { + ctx := context.Background() + _, csrPEM := generateTestCertAndCSR(t) + + sdkErr := &acmpcatypes.MalformedCSRException{ + Message: aws.String("CSR has an unsupported public key algorithm: RSA-1024 below CA policy minimum 2048"), + } + mock := &mockACMPCAClient{issueCertificateErr: sdkErr} + + cfg := failureTestConfig() + c := awsacmpca.NewWithClient(&cfg, mock, failureTestLogger()) + + _, err := c.IssueCertificate(ctx, issuer.IssuanceRequest{ + CommonName: "app.example.com", + CSRPEM: csrPEM, + }) + if err == nil { + t.Fatal("expected error from malformed-CSR IssueCertificate, got nil") + } + + var gotSDK *acmpcatypes.MalformedCSRException + if !errors.As(err, &gotSDK) { + t.Fatalf("wrap chain broke — errors.As against *types.MalformedCSRException failed; err=%v", err) + } + + msg := err.Error() + if !strings.Contains(msg, "MalformedCSR") { + t.Errorf("expected MalformedCSR in surfaced message; got: %s", msg) + } + if !strings.Contains(msg, "unsupported public key algorithm") { + t.Errorf("operator-actionable substring missing — message must name the validation issue; got: %s", msg) + } +} + +// TestAWSACMPCA_Issue_RequestInProgress_TerminalForCurrentAttempt pins +// the behaviour when ACM PCA reports the previous IssueCertificate +// for this idempotency key is still in flight. The SDK has a +// generated *types.RequestInProgressException for this case. The +// connector must preserve the typed value through the wrap chain so +// upstream logic (scheduler, ACME finalize, MCP tool) can decide +// whether to re-issue with a fresh idempotency key or wait. This +// commit pins ONLY the wrap-and-surface contract; classification as +// retryable/terminal is upstream's responsibility (per the spec's +// "out of scope" note). +func TestAWSACMPCA_Issue_RequestInProgress_TerminalForCurrentAttempt(t *testing.T) { + ctx := context.Background() + _, csrPEM := generateTestCertAndCSR(t) + + sdkErr := &acmpcatypes.RequestInProgressException{ + Message: aws.String("Your request is already in progress; resubmit after the current attempt completes"), + } + mock := &mockACMPCAClient{issueCertificateErr: sdkErr} + + cfg := failureTestConfig() + c := awsacmpca.NewWithClient(&cfg, mock, failureTestLogger()) + + _, err := c.IssueCertificate(ctx, issuer.IssuanceRequest{ + CommonName: "app.example.com", + CSRPEM: csrPEM, + }) + if err == nil { + t.Fatal("expected error from request-in-progress IssueCertificate, got nil") + } + + var gotSDK *acmpcatypes.RequestInProgressException + if !errors.As(err, &gotSDK) { + t.Fatalf("wrap chain broke — errors.As against *types.RequestInProgressException failed; err=%v", err) + } + + msg := err.Error() + if !strings.Contains(msg, "RequestInProgress") { + t.Errorf("expected RequestInProgress in surfaced message; got: %s", msg) + } + if !strings.Contains(msg, "in progress") { + t.Errorf("operator-actionable substring missing — message must mention 'in progress'; got: %s", msg) + } +} diff --git a/internal/connector/issuer/googlecas/googlecas_failure_test.go b/internal/connector/issuer/googlecas/googlecas_failure_test.go new file mode 100644 index 0000000..f0e3efe --- /dev/null +++ b/internal/connector/issuer/googlecas/googlecas_failure_test.go @@ -0,0 +1,378 @@ +package googlecas_test + +// Top-10 fix #4 of the 2026-05-03 issuer-coverage audit. GoogleCAS +// is typically the first-deployed issuer in GCP-anchored enterprise +// pilots — diligence reviews dig hard into IAM-error / cloud-error +// coverage. Pre-fix, googlecas_test.go covered the happy path plus a +// generic ServerError + InvalidResponse pair, but did not pin +// behaviour against the distinct operator-actionable error classes +// (PermissionDenied vs CAPoolNotFound vs OAuth2 token-refresh +// failure) that real production traffic surfaces. +// +// Adapter shape: googlecas.go uses stdlib net/http + crypto/rsa +// directly — there is NO Google Cloud Go SDK dependency. CAS errors +// arrive as JSON in the HTTP response body, with the canonical Google +// API error envelope: +// +// {"error":{"code":403,"message":"...","status":"PERMISSION_DENIED"}} +// +// The connector decodes that body via extractAPIError and wraps the +// resulting message into the surfaced error. Because there is no SDK +// typed-error value to errors.As against (per the spec's "use what +// exists today" rule), each test below pins: +// +// 1. error non-nil, +// 2. operator-actionable substring present in the surfaced message +// (resource path, missing-pool name, "token" vs "credential", +// "503" / UNAVAILABLE for the retryable class), +// 3. the SDK-level status string ("PERMISSION_DENIED", +// "NOT_FOUND", "UNAVAILABLE") survives through the wrap chain so +// upstream classification logic can branch on it. +// +// Test-only commit. No production code changes. + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + "github.com/shankar0123/certctl/internal/connector/issuer" + "github.com/shankar0123/certctl/internal/connector/issuer/googlecas" +) + +// failureTestLogger returns a debug-level slog logger writing to +// stdout. Mirrors the per-test logger in googlecas_test.go to keep +// failure logs grep-friendly when a test regresses. +func failureTestLogger() *slog.Logger { + return slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug})) +} + +// TestGoogleCAS_Issue_PermissionDenied_OperatorActionableError pins +// the surfaced contract when the GCP service-account caller lacks +// privateca.certificates.create on the configured CA pool. Real +// traffic returns a 403 with the canonical PERMISSION_DENIED +// envelope; the surfaced error must (a) preserve the IAM resource +// path the operator needs to fix the binding, and (b) preserve the +// PERMISSION_DENIED status string so upstream classification can +// recognise the IAM-error class. +func TestGoogleCAS_Issue_PermissionDenied_OperatorActionableError(t *testing.T) { + ctx := context.Background() + credPath := createTestCredentialsFile(t) + + const resourcePath = "projects/test-project/locations/us-central1/caPools/test-pool" + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/token": + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"access_token":"test-token","expires_in":3600,"token_type":"Bearer"}`)) + case strings.Contains(r.URL.Path, "/certificates"): + w.WriteHeader(http.StatusForbidden) + body := fmt.Sprintf(`{"error":{"code":403,"message":"Permission 'privateca.certificates.create' denied on resource '%s' (or it may not exist).","status":"PERMISSION_DENIED"}}`, resourcePath) + _, _ = w.Write([]byte(body)) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + cfg := &googlecas.Config{ + Project: "test-project", + Location: "us-central1", + CAPool: "test-pool", + Credentials: credPath, + TTL: "8760h", + BaseURL: srv.URL, + TokenURL: srv.URL + "/token", + } + c := googlecas.New(cfg, failureTestLogger()) + + _, csrPEM := generateTestCSR(t, "app.example.com") + _, err := c.IssueCertificate(ctx, issuer.IssuanceRequest{ + CommonName: "app.example.com", + CSRPEM: csrPEM, + }) + if err == nil { + t.Fatal("expected error from PERMISSION_DENIED, got nil") + } + + msg := err.Error() + if !strings.Contains(msg, "PERMISSION_DENIED") { + t.Errorf("status string PERMISSION_DENIED missing from surfaced error; got: %s", msg) + } + if !strings.Contains(msg, resourcePath) { + t.Errorf("operator-actionable substring missing — message must name the IAM resource path %q; got: %s", resourcePath, msg) + } + if !strings.Contains(msg, "Permission") && !strings.Contains(msg, "permission") { + t.Errorf("operator-actionable substring missing — message must mention 'permission'; got: %s", msg) + } +} + +// TestGoogleCAS_Issue_CAPoolNotFound_NamesTheMissingPool pins the +// surfaced contract when the configured CA pool does not exist (e.g. +// typo in CERTCTL_GOOGLE_CAS_CA_POOL, or the pool was deleted out +// from under certctl). Google CAS returns HTTP 404 with NOT_FOUND +// status; the surfaced error must name the missing pool resource so +// the operator can correct the config without grepping logs. +func TestGoogleCAS_Issue_CAPoolNotFound_NamesTheMissingPool(t *testing.T) { + ctx := context.Background() + credPath := createTestCredentialsFile(t) + + const missingPool = "projects/test-project/locations/us-central1/caPools/missing-pool" + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/token": + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"access_token":"test-token","expires_in":3600,"token_type":"Bearer"}`)) + case strings.Contains(r.URL.Path, "/certificates"): + w.WriteHeader(http.StatusNotFound) + body := fmt.Sprintf(`{"error":{"code":404,"message":"Resource '%s' was not found.","status":"NOT_FOUND"}}`, missingPool) + _, _ = w.Write([]byte(body)) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + cfg := &googlecas.Config{ + Project: "test-project", + Location: "us-central1", + CAPool: "missing-pool", + Credentials: credPath, + TTL: "8760h", + BaseURL: srv.URL, + TokenURL: srv.URL + "/token", + } + c := googlecas.New(cfg, failureTestLogger()) + + _, csrPEM := generateTestCSR(t, "app.example.com") + _, err := c.IssueCertificate(ctx, issuer.IssuanceRequest{ + CommonName: "app.example.com", + CSRPEM: csrPEM, + }) + if err == nil { + t.Fatal("expected error from NOT_FOUND, got nil") + } + + msg := err.Error() + if !strings.Contains(msg, "NOT_FOUND") { + t.Errorf("status string NOT_FOUND missing from surfaced error; got: %s", msg) + } + if !strings.Contains(msg, missingPool) { + t.Errorf("operator-actionable substring missing — message must name the missing CA pool %q; got: %s", missingPool, msg) + } +} + +// TestGoogleCAS_Issue_OAuth2TokenRefreshFailure_DistinguishedFromCAError +// pins the surfaced contract when the OAuth2 JWT-bearer exchange +// against oauth2.googleapis.com fails (e.g. service-account key has +// been disabled, JWT signature invalid, or token endpoint is reaching +// us through a misconfigured corp proxy). The surfaced error must +// mention "token" so an operator reading the log can immediately +// distinguish a credential failure from a CA-side error — the two +// are remediated very differently (rotate SA key vs. fix IAM +// binding). +func TestGoogleCAS_Issue_OAuth2TokenRefreshFailure_DistinguishedFromCAError(t *testing.T) { + ctx := context.Background() + credPath := createTestCredentialsFile(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/token": + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"error":"invalid_grant","error_description":"Invalid JWT Signature."}`)) + default: + // We should never reach the CAS endpoint if the token + // exchange fails — assert that explicitly so a regression + // that swallows the token error and proceeds to the CAS + // call is caught by this test. + t.Errorf("CAS endpoint reached despite token-refresh failure — path=%s", r.URL.Path) + http.NotFound(w, r) + } + })) + defer srv.Close() + + cfg := &googlecas.Config{ + Project: "test-project", + Location: "us-central1", + CAPool: "test-pool", + Credentials: credPath, + TTL: "8760h", + BaseURL: srv.URL, + TokenURL: srv.URL + "/token", + } + c := googlecas.New(cfg, failureTestLogger()) + + _, csrPEM := generateTestCSR(t, "app.example.com") + _, err := c.IssueCertificate(ctx, issuer.IssuanceRequest{ + CommonName: "app.example.com", + CSRPEM: csrPEM, + }) + if err == nil { + t.Fatal("expected error from token-refresh failure, got nil") + } + + msg := err.Error() + // The connector wraps the token failure as + // "failed to get access token: token exchange returned status 401: ..." + // so "token" should always be present. If a future refactor renames + // it to "credential", that is also acceptable — both let an + // operator distinguish from a CA-side error. + if !strings.Contains(msg, "token") && !strings.Contains(msg, "credential") { + t.Errorf("operator-actionable substring missing — token-refresh failure must mention 'token' or 'credential' so it is distinguishable from a CA-side error; got: %s", msg) + } + // Surfaced message should name the upstream HTTP status. + if !strings.Contains(msg, "401") { + t.Errorf("expected token-refresh status 401 to be preserved through wrap chain; got: %s", msg) + } +} + +// TestGoogleCAS_Issue_RegionalAPIUnavailable_RetryableSurface pins +// the surfaced contract when a CAS regional endpoint returns 503 +// UNAVAILABLE — typically a transient regional outage where the +// correct upstream behaviour is "retry with backoff" rather than +// "alert ops". The connector currently surfaces these to the caller +// without retrying internally (per spec's "no new retry logic" rule); +// the surfaced error must preserve the 503 / UNAVAILABLE markers so +// an upstream retry layer can recognise the retryable class. +func TestGoogleCAS_Issue_RegionalAPIUnavailable_RetryableSurface(t *testing.T) { + ctx := context.Background() + credPath := createTestCredentialsFile(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/token": + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"access_token":"test-token","expires_in":3600,"token_type":"Bearer"}`)) + case strings.Contains(r.URL.Path, "/certificates"): + w.WriteHeader(http.StatusServiceUnavailable) + _, _ = w.Write([]byte(`{"error":{"code":503,"message":"The service is currently unavailable.","status":"UNAVAILABLE"}}`)) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + cfg := &googlecas.Config{ + Project: "test-project", + Location: "us-central1", + CAPool: "test-pool", + Credentials: credPath, + TTL: "8760h", + BaseURL: srv.URL, + TokenURL: srv.URL + "/token", + } + c := googlecas.New(cfg, failureTestLogger()) + + _, csrPEM := generateTestCSR(t, "app.example.com") + _, err := c.IssueCertificate(ctx, issuer.IssuanceRequest{ + CommonName: "app.example.com", + CSRPEM: csrPEM, + }) + if err == nil { + t.Fatal("expected error from 503 UNAVAILABLE, got nil") + } + + msg := err.Error() + if !strings.Contains(msg, "503") { + t.Errorf("expected HTTP status 503 to be preserved through wrap chain; got: %s", msg) + } + if !strings.Contains(msg, "UNAVAILABLE") { + t.Errorf("expected canonical UNAVAILABLE status string for upstream retry classification; got: %s", msg) + } +} + +// TestGoogleCAS_Revoke_PermissionDenied_DoesNotSilentlySwallow pins +// the contract that PERMISSION_DENIED on a revoke call surfaces an +// error rather than being silently swallowed. The audit-row +// atomicity contract from Bundle G lives in service.RevocationSvc +// (which writes the local audit row inside the same DB tx as the +// adapter call); the adapter's only job here is "return non-nil so +// the service-layer wrapper rolls back". This test pins that +// contract. +// +// (We deliberately do NOT exercise the service-layer audit-row +// rollback here — that's an integration test owned by +// internal/service/revocation_svc_test.go. Mixing concerns would +// re-introduce the exact "lying field" footgun CLAUDE.md warns +// against. The adapter contract is the single thing under test.) +func TestGoogleCAS_Revoke_PermissionDenied_DoesNotSilentlySwallow(t *testing.T) { + ctx := context.Background() + credPath := createTestCredentialsFile(t) + + const certName = "projects/test-project/locations/us-central1/caPools/test-pool/certificates/cert-revoke-denied" + + var revokeCalled bool + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/token": + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"access_token":"test-token","expires_in":3600,"token_type":"Bearer"}`)) + case strings.Contains(r.URL.Path, ":revoke"): + revokeCalled = true + // Decode the request body to confirm the revoke reason was + // actually serialised and sent — guards against a future + // regression that silently no-ops the revoke before the + // HTTP request. + var body map[string]interface{} + _ = json.NewDecoder(r.Body).Decode(&body) + if _, ok := body["reason"]; !ok { + t.Errorf("revoke request body missing 'reason' field — adapter constructed an empty payload") + } + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"error":{"code":403,"message":"Permission 'privateca.certificates.update' denied on certificate.","status":"PERMISSION_DENIED"}}`)) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + cfg := &googlecas.Config{ + Project: "test-project", + Location: "us-central1", + CAPool: "test-pool", + Credentials: credPath, + TTL: "8760h", + BaseURL: srv.URL, + TokenURL: srv.URL + "/token", + } + c := googlecas.New(cfg, failureTestLogger()) + + reason := "keyCompromise" + err := c.RevokeCertificate(ctx, issuer.RevocationRequest{ + Serial: certName, + Reason: &reason, + }) + + // CONTRACT 1: adapter does NOT silently swallow the failure. + if err == nil { + t.Fatal("expected error from revoke PERMISSION_DENIED — adapter must surface, not swallow") + } + // CONTRACT 2: adapter actually attempted the revoke before + // surfacing the error (regression guard against a future "fail + // fast before the HTTP call" change that would skip the + // short-circuit guarantee). + if !revokeCalled { + t.Error("revoke endpoint not reached — adapter short-circuited before sending the HTTP request") + } + + msg := err.Error() + if !strings.Contains(msg, "PERMISSION_DENIED") { + t.Errorf("expected canonical PERMISSION_DENIED status string in surfaced error; got: %s", msg) + } + if !strings.Contains(msg, "Permission") && !strings.Contains(msg, "permission") { + t.Errorf("operator-actionable substring missing — revoke error must mention 'permission'; got: %s", msg) + } +}