mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 12:21:31 +00:00
googlecas, awsacmpca: add failure_test.go covering cloud-SDK error contracts
Closes Top-10 fix #4 of the 2026-05-03 issuer-coverage audit (see cowork/issuer-coverage-audit-2026-05-03/RESULTS.md). Pre-fix, both adapters had only happy-path test coverage with a single generic ServerError pair each. Cloud CAs are typically the first-deployed issuer in enterprise pilots; their diligence reviews dig hard into IAM-error / cloud-error coverage. This commit lands the contract tests. AWSACMPCA — 5 tests in awsacmpca_failure_test.go. Each injects a typed AWS SDK v2 error via the existing mockACMPCAClient seam and asserts (1) error non-nil, (2) errors.As against the SDK's typed value succeeds (so the wrap chain through fmt.Errorf("...%w", ...) is intact), and (3) operator-actionable substring is present. 1. Issue_AccessDenied — *smithy.GenericAPIError with Code="AccessDeniedException" (the SDK does NOT generate a typed *types.AccessDeniedException; AWS uses the smithy APIError shape for IAM denials). Asserts ErrorCode + "not authorized" + IAM resource path preserved through wrap. 2. Issue_ResourceNotFound — *types.ResourceNotFoundException names the missing CA ARN. 3. Issue_Throttling — *smithy.GenericAPIError with Code="ThrottlingException", Fault=FaultServer. Asserts the retryable class (FaultServer) is preserved through wrap so upstream retry logic can engage. 4. Issue_MalformedCSR — *types.MalformedCSRException is terminal (operator must fix the CSR, not retry); asserts the validation-issue substring survives. 5. Issue_RequestInProgress — *types.RequestInProgressException wraps cleanly; classification (retry vs reissue) is upstream's responsibility per the spec's "no new retry logic" rule. GoogleCAS — 5 tests in googlecas_failure_test.go. The adapter uses stdlib net/http directly (NO Google Cloud Go SDK dependency in googlecas.go), so SDK typed-error assertions don't translate. Each test runs an httptest.Server that returns the canonical Google API JSON error envelope: {"error":{"code":N,"message":"...","status":"<STATUS>"}} and asserts (1) error non-nil, (2) operator-actionable substring, and (3) the canonical status string ("PERMISSION_DENIED", "NOT_FOUND", "UNAVAILABLE") survives the wrap chain so upstream classification can branch on it. 1. Issue_PermissionDenied — 403 / PERMISSION_DENIED; surfaced error names the IAM resource path. 2. Issue_CAPoolNotFound — 404 / NOT_FOUND; surfaced error names the missing pool resource. 3. Issue_OAuth2TokenRefreshFailure — token endpoint returns 401 invalid_grant; surfaced error mentions "token" so an operator reading the log immediately distinguishes a credential failure (rotate SA key) from a CA-side error (fix IAM binding). Test also asserts the CAS endpoint is NOT reached when the token exchange fails. 4. Issue_RegionalAPIUnavailable — 503 / UNAVAILABLE; surfaced error preserves the retryable class markers (status code + UNAVAILABLE string) for upstream retry classification. 5. Revoke_PermissionDenied — adapter does NOT silently swallow the failure; pin the contract so the audit-row atomicity guarantee from Bundle G (which lives in the service-layer wrapper, not the adapter) continues to apply. Test also verifies the revoke endpoint was actually reached, guarding against a future regression that short-circuits before the HTTP call. Coverage delta: awsacmpca: 71.0% → 71.0% (failure tests reuse existing wrap code paths; behaviour-pin contract tests, not coverage tests). googlecas: 83.4% → 84.4% (+1.0pp). go.mod: smithy-go moved indirect → direct, since the new AWSACMPCA test file imports it. CI's go-mod-tidy-drift gate enforces this. Test-only commit. No production code changes. Verified locally: - gofmt clean. - go vet ./internal/connector/issuer/awsacmpca/... ./internal/connector/issuer/googlecas/... clean. - go test -short -count=1 ./internal/connector/issuer/... green. - go test -race -count=10 ./internal/connector/issuer/awsacmpca ./internal/connector/issuer/googlecas green. Audit reference: cowork/issuer-coverage-audit-2026-05-03/RESULTS.md Top-10 fix #4.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user