mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-12 22:08:52 +00:00
95d0d85391
Five small closures wrapping the Low-tier and Info-tier audit findings. Q.1 — cmd/cli round-out (L-001 closed) ====================================== cmd/cli/dispatch_test.go: ~30 dispatch tests across handleCerts / handleAgents / handleJobs / handleImport / handleStatus. httptest.NewTLSServer mocks the API; cli.NewClient(_, _, _, _, true) constructs an insecure-skip-verify client. Each test pins the missing-args usage-print path AND the happy-path delegation. Result: 7.1% -> 63.5% coverage (gate: >=30%). Q.2 — awssm round-out (L-002 closed) ====================================== internal/connector/discovery/awssm/awssm_edge_test.go: New() default constructor, extractKeyInfo (ECDSA/Ed25519/unknown — was RSA-only), processSecret filter arms (NamePrefix mismatch / TagFilter mismatch / empty-value / GetSecretValue error), realSMClient stub-contract pin (ListSecrets / GetSecretValue / NewRealSMClient), and EmailAddresses SAN extraction. Result: 78.2% -> 96.0% coverage (gate: >=85%). Q.3 — Property-based testing pilot (L-003 closed) ====================================== gopter@v0.2.11 added to go.mod (test-only). internal/crypto/encryption_property_test.go: - TestProperty_EncryptDecryptRoundTrip — 50 successful tests, DecryptIfKeySet(EncryptIfKeySet(x, k), k) == x - TestProperty_WrongPassphraseRejected — 30 successful tests, AEAD never returns nil-error AND bytes-equal plaintext under wrong passphrase Both skipped under -short to keep developer loop fast (PBKDF2 600k rounds × 50 iters ≈ 15s on -race CI). internal/pkcs7/length_property_test.go: - TestProperty_ASN1LengthRoundTrip — three sub-properties: decodeLength(encode(x)) == x for x ∈ [0, 2³¹−1]; short-form invariant (length<128 → 1 byte == length); long-form invariant (length>=128 → high bit set + N bytes follow). 500 successful tests in <10ms. Q.4 — Architecture diagram multi-agent update (L-004 closed) ====================================== docs/qa-test-guide.md::Architecture: ASCII diagram updated to show 'certctl-agent (×N)' + callout explaining seed_demo.sql provisions 12 agent rows (1 active, 2 retired, 9 reserved/sentinel) for Parts 04, 05, 55 + FSM coverage. Operators running parallel-agent topologies guided to AGENT_COUNT=N + 'make qa-stats'. Q.5 — Test-naming CI guard (I-001 closed) ====================================== .github/workflows/ci.yml: Test-naming convention guard added after the QA-doc seed-count drift guard. Greps for func Test<X>( missing the <X>_<Scenario> suffix. Prints first 20 non-conformant as ::warning:: annotations. continue-on-error: true (informational). Excludes TestMain + TestProperty_*. Promotion to hard-fail tracked as I-001-extended. Verification ====================================== - python3 yaml.safe_load on ci.yml: OK - go vet ./cmd/cli/... ./internal/connector/discovery/awssm/... ./internal/crypto/... ./internal/pkcs7/...: clean - go test -short -count=1 across all four packages: PASS - go test -count=1 (full property tests): PASS - crypto 15.4s (50 + 30 × 600k PBKDF2) - pkcs7 5ms Audit deliverables ====================================== - gap-backlog.md: strikethroughs on L-001/L-002/L-003/L-004/I-001 with per-finding closure note - closure-plan.md: ticks Bundle Q [x] with per-item breakdown Closes: L-001, L-002, L-003, L-004, I-001 Bundle: Q (Property-Based + Hygiene)
330 lines
12 KiB
Go
330 lines
12 KiB
Go
package awssm
|
|
|
|
import (
|
|
"context"
|
|
"crypto/ecdsa"
|
|
"crypto/ed25519"
|
|
"crypto/elliptic"
|
|
"crypto/rand"
|
|
"crypto/rsa"
|
|
"crypto/x509"
|
|
"crypto/x509/pkix"
|
|
"errors"
|
|
"log/slog"
|
|
"math/big"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/shankar0123/certctl/internal/config"
|
|
)
|
|
|
|
// Bundle Q (L-002 closure): edge-case coverage for awssm to push above 80%.
|
|
//
|
|
// Adds tests for:
|
|
//
|
|
// - New() default-constructor path (was 0%): nil config, nil logger, normal path
|
|
// - NewWithClient() default-arg paths
|
|
// - extractKeyInfo for ECDSA + Ed25519 + unknown key types (was RSA-only)
|
|
// - processSecret's NamePrefix filter and TagFilter mismatch skip arms
|
|
// - realSMClient stub methods (ListSecrets / GetSecretValue) — pin the
|
|
// "documented stub returns empty + no error" contract so a future
|
|
// refactor that swaps in real SDK calls without updating callers is
|
|
// caught immediately
|
|
// - ValidateConfig nil-config branch
|
|
|
|
func TestNew_NilConfig_PopulatesDefaults(t *testing.T) {
|
|
src := New(nil, slog.Default())
|
|
if src == nil {
|
|
t.Fatal("New(nil, _) returned nil source")
|
|
}
|
|
if src.cfg == nil {
|
|
t.Errorf("expected New to populate empty config when nil supplied")
|
|
}
|
|
}
|
|
|
|
func TestNew_NilLogger_PopulatesDefaults(t *testing.T) {
|
|
cfg := &config.AWSSecretsMgrDiscoveryConfig{Region: "us-east-1"}
|
|
src := New(cfg, nil)
|
|
if src == nil {
|
|
t.Fatal("New(_, nil) returned nil source")
|
|
}
|
|
if src.logger == nil {
|
|
t.Errorf("expected New to populate default logger when nil supplied")
|
|
}
|
|
}
|
|
|
|
func TestNew_NormalPath_CreatesSource(t *testing.T) {
|
|
cfg := &config.AWSSecretsMgrDiscoveryConfig{Region: "us-west-2"}
|
|
src := New(cfg, slog.Default())
|
|
if src == nil {
|
|
t.Fatal("New returned nil")
|
|
}
|
|
if src.client == nil {
|
|
t.Errorf("expected New to wire up a real SM client")
|
|
}
|
|
// Sanity: real client should be a *realSMClient pointing at us-west-2.
|
|
rc, ok := src.client.(*realSMClient)
|
|
if !ok {
|
|
t.Fatalf("expected *realSMClient, got %T", src.client)
|
|
}
|
|
if rc.region != "us-west-2" {
|
|
t.Errorf("expected region us-west-2, got %q", rc.region)
|
|
}
|
|
}
|
|
|
|
func TestNewWithClient_NilConfig_NilLogger_PopulatesDefaults(t *testing.T) {
|
|
mock := newMockSMClient()
|
|
src := NewWithClient(nil, mock, nil)
|
|
if src == nil {
|
|
t.Fatal("NewWithClient returned nil")
|
|
}
|
|
if src.cfg == nil || src.logger == nil {
|
|
t.Errorf("expected NewWithClient to populate cfg + logger defaults")
|
|
}
|
|
}
|
|
|
|
func TestValidateConfig_NilConfig_FailsClosed(t *testing.T) {
|
|
src := &Source{} // explicit nil cfg
|
|
if err := src.ValidateConfig(); err == nil {
|
|
t.Errorf("expected ValidateConfig to fail when cfg is nil")
|
|
}
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// extractKeyInfo: every key-type arm.
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
func TestExtractKeyInfo_RSA(t *testing.T) {
|
|
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
|
if err != nil {
|
|
t.Fatalf("rsa.GenerateKey: %v", err)
|
|
}
|
|
cert := &x509.Certificate{PublicKey: &key.PublicKey}
|
|
algo, size := extractKeyInfo(cert)
|
|
if algo != "RSA" {
|
|
t.Errorf("expected RSA, got %q", algo)
|
|
}
|
|
if size != 2048 {
|
|
t.Errorf("expected size 2048, got %d", size)
|
|
}
|
|
}
|
|
|
|
func TestExtractKeyInfo_ECDSA(t *testing.T) {
|
|
key, err := ecdsa.GenerateKey(elliptic.P384(), rand.Reader)
|
|
if err != nil {
|
|
t.Fatalf("ecdsa.GenerateKey: %v", err)
|
|
}
|
|
cert := &x509.Certificate{PublicKey: &key.PublicKey}
|
|
algo, size := extractKeyInfo(cert)
|
|
if algo != "ECDSA" {
|
|
t.Errorf("expected ECDSA, got %q", algo)
|
|
}
|
|
if size != 384 {
|
|
t.Errorf("expected size 384 (P-384 curve), got %d", size)
|
|
}
|
|
}
|
|
|
|
func TestExtractKeyInfo_Ed25519(t *testing.T) {
|
|
pub, _, err := ed25519.GenerateKey(rand.Reader)
|
|
if err != nil {
|
|
t.Fatalf("ed25519.GenerateKey: %v", err)
|
|
}
|
|
cert := &x509.Certificate{PublicKey: pub}
|
|
algo, size := extractKeyInfo(cert)
|
|
if algo != "Ed25519" {
|
|
t.Errorf("expected Ed25519, got %q", algo)
|
|
}
|
|
if size != 256 {
|
|
t.Errorf("expected size 256, got %d", size)
|
|
}
|
|
}
|
|
|
|
func TestExtractKeyInfo_Unknown(t *testing.T) {
|
|
// PublicKey type that's none of the known cases → falls through to default.
|
|
cert := &x509.Certificate{PublicKey: struct{ X int }{42}}
|
|
algo, size := extractKeyInfo(cert)
|
|
if algo != "Unknown" {
|
|
t.Errorf("expected Unknown, got %q", algo)
|
|
}
|
|
if size != 0 {
|
|
t.Errorf("expected size 0 for unknown, got %d", size)
|
|
}
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// processSecret: filter arms.
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
func TestProcessSecret_NamePrefixMismatch_SkipsSilently(t *testing.T) {
|
|
// L-002: NamePrefix-mismatched secret must be silently skipped (no error,
|
|
// no entry added, no GetSecretValue call). This exercises the prefix
|
|
// short-circuit that previously sat on the un-tested side of the branch.
|
|
mock := newMockSMClient()
|
|
mock.secrets["other/cert"] = "ignored-value"
|
|
mock.secretMetadata["other/cert"] = SecretMetadata{Name: "other/cert"}
|
|
|
|
cfg := &config.AWSSecretsMgrDiscoveryConfig{
|
|
Region: "us-east-1",
|
|
NamePrefix: "prod/", // "other/cert" doesn't start with "prod/"
|
|
}
|
|
src := NewWithClient(cfg, mock, slog.Default())
|
|
|
|
report, err := src.Discover(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("Discover: %v", err)
|
|
}
|
|
if len(report.Certificates) != 0 {
|
|
t.Errorf("expected 0 certs (prefix mismatch), got %d", len(report.Certificates))
|
|
}
|
|
if len(report.Errors) != 0 {
|
|
t.Errorf("expected 0 errors, got %v", report.Errors)
|
|
}
|
|
}
|
|
|
|
func TestProcessSecret_TagFilterMismatch_SkipsSilently(t *testing.T) {
|
|
// L-002: TagFilter-mismatched secret must be silently skipped. Pins the
|
|
// branch where the secret has tags but they don't match the configured
|
|
// key=value pair.
|
|
mock := newMockSMClient()
|
|
mock.secrets["prod/cert"] = "ignored"
|
|
mock.secretMetadata["prod/cert"] = SecretMetadata{
|
|
Name: "prod/cert",
|
|
Tags: map[string]string{"type": "password"}, // mismatch: cfg wants type=certificate
|
|
}
|
|
|
|
cfg := &config.AWSSecretsMgrDiscoveryConfig{
|
|
Region: "us-east-1",
|
|
TagFilter: "type=certificate",
|
|
}
|
|
src := NewWithClient(cfg, mock, slog.Default())
|
|
|
|
report, err := src.Discover(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("Discover: %v", err)
|
|
}
|
|
if len(report.Certificates) != 0 {
|
|
t.Errorf("expected 0 certs (tag mismatch), got %d", len(report.Certificates))
|
|
}
|
|
}
|
|
|
|
func TestProcessSecret_EmptyValue_Skipped(t *testing.T) {
|
|
// L-002: empty secret value short-circuits parseCertificateData and
|
|
// returns nil error.
|
|
mock := newMockSMClient()
|
|
mock.secrets["prod/empty"] = ""
|
|
mock.secretMetadata["prod/empty"] = SecretMetadata{
|
|
Name: "prod/empty",
|
|
Tags: map[string]string{"type": "certificate"},
|
|
}
|
|
|
|
cfg := &config.AWSSecretsMgrDiscoveryConfig{Region: "us-east-1"}
|
|
src := NewWithClient(cfg, mock, slog.Default())
|
|
|
|
report, err := src.Discover(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("Discover: %v", err)
|
|
}
|
|
if len(report.Certificates) != 0 {
|
|
t.Errorf("expected 0 certs (empty value), got %d", len(report.Certificates))
|
|
}
|
|
}
|
|
|
|
func TestProcessSecret_GetSecretError_PropagatesToErrors(t *testing.T) {
|
|
// Round-out for processSecret: GetSecretValue error path adds to report.Errors.
|
|
mock := newMockSMClient()
|
|
mock.secretMetadata["prod/missing"] = SecretMetadata{
|
|
Name: "prod/missing",
|
|
Tags: map[string]string{"type": "certificate"},
|
|
}
|
|
mock.getErrors["prod/missing"] = errors.New("AccessDenied")
|
|
|
|
cfg := &config.AWSSecretsMgrDiscoveryConfig{Region: "us-east-1"}
|
|
src := NewWithClient(cfg, mock, slog.Default())
|
|
|
|
report, err := src.Discover(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("Discover: %v", err)
|
|
}
|
|
if len(report.Errors) == 0 {
|
|
t.Errorf("expected error in report, got none")
|
|
}
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// realSMClient: stub-contract pinning.
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
func TestRealSMClient_ListSecrets_StubReturnsEmpty(t *testing.T) {
|
|
// L-002: pin the documented stub contract. ListSecrets in the current
|
|
// implementation is a placeholder — empty slice + no error. A future
|
|
// refactor wiring up the real AWS SDK should update tests, not silently
|
|
// change return values.
|
|
c := newRealSMClient("us-east-1", slog.Default()).(*realSMClient)
|
|
got, err := c.ListSecrets(context.Background(), "tag-key:type")
|
|
if err != nil {
|
|
t.Errorf("expected nil err from stub, got %v", err)
|
|
}
|
|
if len(got) != 0 {
|
|
t.Errorf("expected empty slice from stub, got %d entries", len(got))
|
|
}
|
|
}
|
|
|
|
func TestRealSMClient_GetSecretValue_StubReturnsEmpty(t *testing.T) {
|
|
c := newRealSMClient("us-east-1", slog.Default()).(*realSMClient)
|
|
got, err := c.GetSecretValue(context.Background(), "any/secret")
|
|
if err != nil {
|
|
t.Errorf("expected nil err from stub, got %v", err)
|
|
}
|
|
if got != "" {
|
|
t.Errorf("expected empty string from stub, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestNewRealSMClient_PopulatesFields(t *testing.T) {
|
|
c := newRealSMClient("eu-west-1", slog.Default()).(*realSMClient)
|
|
if c.region != "eu-west-1" {
|
|
t.Errorf("expected region eu-west-1, got %q", c.region)
|
|
}
|
|
if c.logger == nil {
|
|
t.Errorf("expected logger to be populated")
|
|
}
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// buildDiscoveredCertEntry: edge cases on EmailAddresses-based SAN extraction.
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
func TestBuildDiscoveredCertEntry_WithEmailSANs(t *testing.T) {
|
|
// Pin the EmailAddresses → SAN append path (was uncovered).
|
|
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
|
if err != nil {
|
|
t.Fatalf("GenerateKey: %v", err)
|
|
}
|
|
template := &x509.Certificate{
|
|
SerialNumber: big.NewInt(42),
|
|
Subject: pkix.Name{CommonName: "test.example.com"},
|
|
NotBefore: time.Now().Add(-1 * time.Hour),
|
|
NotAfter: time.Now().Add(24 * time.Hour),
|
|
KeyUsage: x509.KeyUsageDigitalSignature,
|
|
DNSNames: []string{"test.example.com"},
|
|
EmailAddresses: []string{"alice@example.com", "bob@example.com"},
|
|
}
|
|
certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
|
|
if err != nil {
|
|
t.Fatalf("CreateCertificate: %v", err)
|
|
}
|
|
cert, err := x509.ParseCertificate(certDER)
|
|
if err != nil {
|
|
t.Fatalf("ParseCertificate: %v", err)
|
|
}
|
|
|
|
src := NewWithClient(&config.AWSSecretsMgrDiscoveryConfig{Region: "us-east-1"}, newMockSMClient(), slog.Default())
|
|
entry, err := src.buildDiscoveredCertEntry(cert, "prod/test")
|
|
if err != nil {
|
|
t.Fatalf("buildDiscoveredCertEntry: %v", err)
|
|
}
|
|
if len(entry.SANs) != 3 {
|
|
t.Errorf("expected 3 SANs (1 DNS + 2 emails), got %d: %v", len(entry.SANs), entry.SANs)
|
|
}
|
|
}
|