mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 12:21:31 +00:00
383 lines
15 KiB
Go
383 lines
15 KiB
Go
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/certctl-io/certctl/internal/connector/issuer"
|
|
"github.com/certctl-io/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) {
|
|
// Tagged switch on r.URL.Path keeps staticcheck QF1002 happy
|
|
// (only equality cases against a single field). The other
|
|
// failure tests use mixed equality + strings.Contains so they
|
|
// stay on `switch { case ... }`.
|
|
switch r.URL.Path {
|
|
case "/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 the project guidelines warn
|
|
// 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)
|
|
}
|
|
}
|