From ca4c02ec3ee0dc22754f994968438e48c30ddeec Mon Sep 17 00:00:00 2001 From: stxkxs Date: Thu, 4 Jun 2026 20:18:51 -0700 Subject: [PATCH] =?UTF-8?q?Add=20provider=E2=86=92scanner=E2=86=92output?= =?UTF-8?q?=20integration=20tests=20and=20per-package=20coverage=20floors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The final piece of the uplift: end-to-end tests across the layers, a per-package coverage gate so no package can silently regress, and a latent bug the new tests surfaced. ──────────────────────── integration tests ──────────────────────── internal/integration runs the provider → scanner → output path with a fixture provider registered through the real provider registry (providers.NewRegistry / Capable), resolved by capability, run through each domain scanner, and rendered to both JSON and table. Six domains (orphans, storage, network, certs, tags, secrets) assert multiple rendered fields each, plus a case that proves the scanner's MinSeverity actually drops a below-threshold finding, and the no-provider error path. A command's RunE resolves providers via providers.Resolve (→ Default()), which is AWS-backed and intentionally has no test-injection seam, so the cobra shell (flag→ScanOptions, the output-format switch, gate/exit-code) is covered by package cmd's unit tests rather than here. ──────────────────────── remediate bugfix ───────────────────────── Adding cmd helper tests (cmd/remediate_test.go) exposed that remediate's bare-array fallback was unreachable: a bare JSON array fails the envelope unmarshal and returned an error before the fallback ran, so `cloudgov remediate --from ` was silently broken. The three unmarshalers now try the envelope, then the bare array, then report a useful error — with tests for envelope, bare-array, invalid-JSON, and the empty-envelope path. ──────────────────────── per-package floors ─────────────────────── .coverage-floors sets a floor per package (a few points below current, to ratchet up as coverage lands). scripts/coverage.sh enforces them in ci.yml and fails on three conditions: a package below its floor, a floored package that produced no coverage line (stale/typo'd name), and a package that reports coverage but has no floor (so new tested code can't land ungated). This replaces the single 50% total floor. golangci-lint and coverage profiling were already wired into CI. --- .coverage-floors | 30 +++ .github/workflows/ci.yml | 19 +- CLAUDE.md | 2 +- cmd/remediate.go | 55 ++--- cmd/remediate_test.go | 110 ++++++++++ internal/integration/doc.go | 13 ++ internal/integration/integration_test.go | 244 +++++++++++++++++++++++ scripts/coverage.sh | 61 ++++++ 8 files changed, 495 insertions(+), 39 deletions(-) create mode 100644 .coverage-floors create mode 100644 cmd/remediate_test.go create mode 100644 internal/integration/doc.go create mode 100644 internal/integration/integration_test.go create mode 100755 scripts/coverage.sh diff --git a/.coverage-floors b/.coverage-floors new file mode 100644 index 0000000..6c68cfe --- /dev/null +++ b/.coverage-floors @@ -0,0 +1,30 @@ +# Per-package statement-coverage floors (percent). A package below its floor fails +# CI. Floors are set a few points below current coverage to lock it in without +# flapping on small refactors; RAISE a floor when you raise its coverage (ratchet). +# Packages absent here have no floor yet (e.g. internal/integration is fixture/ +# test-only with no coverable statements of its own). +cmd 15 +internal/audit 60 +internal/baseline 70 +internal/certs 90 +internal/cloud 55 +internal/cloud/aws 80 +internal/cloud/k8s 45 +internal/compare 60 +internal/compliance 65 +internal/cost 95 +internal/drift 85 +internal/fix 18 +internal/iam 50 +internal/inventory 85 +internal/network 90 +internal/orphans 90 +internal/output 65 +internal/output/sinks 80 +internal/platform 80 +internal/providers 50 +internal/quota 95 +internal/report 40 +internal/secrets 90 +internal/storage 28 +internal/tags 90 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eda5cdf..ca03d43 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,25 +22,16 @@ jobs: - name: go build run: go build ./... - - name: go test with coverage - run: go test ./... -coverprofile=coverage.out -covermode=atomic + - name: test with per-package coverage floors + # Runs the suite with coverage and enforces .coverage-floors (each package + # has its own floor, ratcheting up as coverage lands). Produces coverage.out. + run: bash scripts/coverage.sh - name: go vet run: go vet ./... - - name: enforce coverage floor - # Phase 2 lifted internal/cloud/aws from 0% to ~65-85%. The 50% floor is - # the floor — it'll ratchet up as more code lands under test. - run: | - total=$(go tool cover -func=coverage.out | grep total: | awk '{print $3}' | sed 's/%//') - echo "total coverage: ${total}%" - # bash arithmetic on decimals via awk - if awk "BEGIN {exit !(${total} < 50)}"; then - echo "::error::coverage ${total}% is below the 50% floor" - exit 1 - fi - - name: upload coverage artifact + if: always() # keep the profile even when a floor fails, for debugging uses: actions/upload-artifact@v4 with: name: coverage diff --git a/CLAUDE.md b/CLAUDE.md index 6869f3c..8d0e382 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -187,7 +187,7 @@ each item completely; `task build` + `go test ./...` green before marking `[x]`. - **Output renderer cleanup** (split into ordered sub-items): - [x] **9a — Severity on domain structs**: `cloud.QuotaUsage` gains a `Severity` field (`json:"severity"`), set at construction in the AWS provider's `ListQuotas` (derived from utilization). All readers — `output.WriteQuotas`, `quota.Summarize`, `compare.normalizeQuotas`, and the `report` HTML generator — now read it via a `QuotaUsage.EffectiveSeverity()` accessor that falls back to computing from `Utilization` when unset (back-compat for reports saved before the field + hand-built test data). QuotaUsage was the one struct recomputing severity per-reader; the other findingless structs (OrphanResource/CostDiff/InventoryResource) carry no severity by nature. Mock-tested; verified all read sites converted by grep. - [x] **9b — split the monolithic renderer files**: the 715-line `output/table.go` + 252-line `output/json.go` are split into per-domain files — `output/.go` (iam, storage, orphans, cost, network, certs, tags, drift, compliance, lambda, k8s, secrets, audit, inventory, quota, compare, platform) each owns that domain's table renderer + JSON report struct + writer; shared infra lives in `style.go` (lipgloss styles, `colorSeverity`, `formatTags`, `truncate`) and `jsoncore.go` (`writeJSON`). Adding a domain now adds one file instead of editing two shared monoliths. (`sarif.go` left cohesive as the SARIF concern.) Pure move — verified behavior-preserving by line-conservation (869 code lines in == 869 out, sorted-identical) plus the unchanged `output` test suite (table/json/sarif) passing. The original "runtime `FindingRenderer` registry" was reframed after empirical review: commands dispatch type-specifically (compile-time-safe), renderers have heterogeneous signatures (IAM principal counts, audit `*Report`, compare `CompareResult`), and the one generic consumer (`report`) already routes through `compare.NormalizeReport`, so a uniform `any`-typed registry would trade type safety for indirection with no caller that needs it. -- [ ] **Integration tests + CI floors**: cmd→scanner→provider→output tests with fixtures; per-package coverage floors + `golangci-lint` in `ci.yml` (folds in section 6). +- [x] **Integration tests + CI floors**: `internal/integration` runs provider→scanner→output end-to-end — a fixture provider registered through the real `providers.NewRegistry`/`Capable`, resolved by capability, run through each domain scanner, rendered to JSON+table, asserting multiple fields per domain + a severity-filter-discriminates case (the cmd `RunE` shell resolves via `Default()` and is intentionally un-injectable, so the cobra layer is covered by `cmd` unit tests). Writing the `cmd` helper tests (`cmd/remediate_test.go`) surfaced + fixed a latent bug: remediate's bare-array fallback was unreachable (a bare array errored the envelope unmarshal before the fallback ran). `.coverage-floors` (per-package floors, ratcheting) enforced by `scripts/coverage.sh` in `ci.yml` — fails on below-floor, on a floored package with no coverage line (stale name), and on a tested package with no floor (ungated new code); `golangci-lint` + coverage profiling were already in CI. Verified by an adversarial review workflow (12 findings; bugfix + floor logic verified correct, the rest — honest seam labeling, multi-field assertions, filter discrimination, the two new floor guards, bugfix edge-case tests — addressed). ## how to run a single improvement pass (headless) diff --git a/cmd/remediate.go b/cmd/remediate.go index db3a662..e49e1a3 100644 --- a/cmd/remediate.go +++ b/cmd/remediate.go @@ -121,43 +121,50 @@ type orphansEnvelope struct { func unmarshalStorageReport(data []byte) ([]cloud.BucketFinding, error) { var env storageEnvelope - if err := json.Unmarshal(data, &env); err != nil { - return nil, fmt.Errorf("parse storage report: %w", err) - } - if len(env.Findings) == 0 { - // Fall back to a bare-array shape some users hand-craft. - var bare []cloud.BucketFinding - if err := json.Unmarshal(data, &bare); err == nil && len(bare) > 0 { - return bare, nil - } + envErr := json.Unmarshal(data, &env) + if envErr == nil && len(env.Findings) > 0 { + return env.Findings, nil } - return env.Findings, nil + // Fall back to a bare-array shape some users hand-craft. A bare array fails the + // envelope unmarshal above, so this must be tried regardless of envErr. + var bare []cloud.BucketFinding + if err := json.Unmarshal(data, &bare); err == nil { + return bare, nil + } + if envErr != nil { + return nil, fmt.Errorf("parse storage report: %w", envErr) + } + return env.Findings, nil // valid envelope with no findings } func unmarshalNetworkReport(data []byte) ([]cloud.NetworkFinding, error) { var env networkEnvelope - if err := json.Unmarshal(data, &env); err != nil { - return nil, fmt.Errorf("parse network report: %w", err) + envErr := json.Unmarshal(data, &env) + if envErr == nil && len(env.Findings) > 0 { + return env.Findings, nil } - if len(env.Findings) == 0 { - var bare []cloud.NetworkFinding - if err := json.Unmarshal(data, &bare); err == nil && len(bare) > 0 { - return bare, nil - } + var bare []cloud.NetworkFinding + if err := json.Unmarshal(data, &bare); err == nil { + return bare, nil + } + if envErr != nil { + return nil, fmt.Errorf("parse network report: %w", envErr) } return env.Findings, nil } func unmarshalOrphansReport(data []byte) ([]cloud.OrphanResource, error) { var env orphansEnvelope - if err := json.Unmarshal(data, &env); err != nil { - return nil, fmt.Errorf("parse orphans report: %w", err) + envErr := json.Unmarshal(data, &env) + if envErr == nil && len(env.Resources) > 0 { + return env.Resources, nil } - if len(env.Resources) == 0 { - var bare []cloud.OrphanResource - if err := json.Unmarshal(data, &bare); err == nil && len(bare) > 0 { - return bare, nil - } + var bare []cloud.OrphanResource + if err := json.Unmarshal(data, &bare); err == nil { + return bare, nil + } + if envErr != nil { + return nil, fmt.Errorf("parse orphans report: %w", envErr) } return env.Resources, nil } diff --git a/cmd/remediate_test.go b/cmd/remediate_test.go new file mode 100644 index 0000000..f0bdf3b --- /dev/null +++ b/cmd/remediate_test.go @@ -0,0 +1,110 @@ +package cmd + +import ( + "testing" + + "github.com/nanohype/cloudgov/internal/cloud" +) + +func TestUnmarshalStorageReport(t *testing.T) { + // Envelope shape. + env := []byte(`{"findings":[{"bucket":"b1","severity":"HIGH"}],"total":1}`) + got, err := unmarshalStorageReport(env) + if err != nil { + t.Fatalf("envelope: %v", err) + } + if len(got) != 1 || got[0].Bucket != "b1" { + t.Errorf("envelope: got %+v", got) + } + // Bare-array fallback. + bare := []byte(`[{"bucket":"b2","severity":"LOW"}]`) + got, err = unmarshalStorageReport(bare) + if err != nil { + t.Fatalf("bare: %v", err) + } + if len(got) != 1 || got[0].Bucket != "b2" { + t.Errorf("bare: got %+v", got) + } + // Invalid JSON errors. + if _, err := unmarshalStorageReport([]byte("not json")); err == nil { + t.Error("expected error on invalid JSON") + } + // Valid envelope with no findings returns empty, not an error (the fallback's + // final path). + got, err = unmarshalStorageReport([]byte(`{"findings":[],"total":0}`)) + if err != nil || len(got) != 0 { + t.Errorf("empty envelope: got %+v err %v, want empty/nil", got, err) + } +} + +func TestUnmarshalNetworkReport(t *testing.T) { + got, err := unmarshalNetworkReport([]byte(`{"findings":[{"resource":"sg-1","severity":"HIGH"}]}`)) + if err != nil || len(got) != 1 || got[0].Resource != "sg-1" { + t.Fatalf("envelope: got %+v err %v", got, err) + } + got, err = unmarshalNetworkReport([]byte(`[{"resource":"sg-2"}]`)) + if err != nil || len(got) != 1 || got[0].Resource != "sg-2" { + t.Fatalf("bare: got %+v err %v", got, err) + } + if _, err := unmarshalNetworkReport([]byte("not json")); err == nil { + t.Error("expected error on invalid JSON") + } + got, err = unmarshalNetworkReport([]byte(`{"findings":[]}`)) + if err != nil || len(got) != 0 { + t.Errorf("empty envelope: got %+v err %v, want empty/nil", got, err) + } +} + +func TestUnmarshalOrphansReport(t *testing.T) { + // Orphans use a "resources" envelope (not "findings"). + got, err := unmarshalOrphansReport([]byte(`{"resources":[{"Kind":"disk","ID":"vol-1"}],"total":1}`)) + if err != nil || len(got) != 1 || got[0].ID != "vol-1" { + t.Fatalf("envelope: got %+v err %v", got, err) + } + got, err = unmarshalOrphansReport([]byte(`[{"Kind":"ip","ID":"eip-1"}]`)) + if err != nil || len(got) != 1 || got[0].ID != "eip-1" { + t.Fatalf("bare: got %+v err %v", got, err) + } + if _, err := unmarshalOrphansReport([]byte("not json")); err == nil { + t.Error("expected error on invalid JSON") + } + got, err = unmarshalOrphansReport([]byte(`{"resources":[],"total":0}`)) + if err != nil || len(got) != 0 { + t.Errorf("empty envelope: got %+v err %v, want empty/nil", got, err) + } +} + +func TestFilterStorageBySeverity(t *testing.T) { + in := []cloud.BucketFinding{ + {Bucket: "crit", Severity: cloud.SeverityCritical}, + {Bucket: "low", Severity: cloud.SeverityLow}, + } + got := filterStorageBySeverity(in, cloud.SeverityHigh) + if len(got) != 1 || got[0].Bucket != "crit" { + t.Errorf("got %+v, want only crit", got) + } +} + +func TestFilterNetworkBySeverity(t *testing.T) { + in := []cloud.NetworkFinding{ + {Resource: "high", Severity: cloud.SeverityHigh}, + {Resource: "low", Severity: cloud.SeverityLow}, + } + got := filterNetworkBySeverity(in, cloud.SeverityMedium) + if len(got) != 1 || got[0].Resource != "high" { + t.Errorf("got %+v, want only high", got) + } +} + +func TestAnnounceFiles(t *testing.T) { + // Exercises both the "wrote" and the "no remediable findings" branches, plus + // the quiet short-circuit. Output goes to stderr; we assert it doesn't panic + // and that quiet suppresses cleanly. + orig := quiet + defer func() { quiet = orig }() + quiet = false + announceFiles([]string{"fix-aws.sh"}, 1) + announceFiles(nil, 3) + quiet = true + announceFiles([]string{"fix-aws.sh"}, 1) +} diff --git a/internal/integration/doc.go b/internal/integration/doc.go new file mode 100644 index 0000000..b908a80 --- /dev/null +++ b/internal/integration/doc.go @@ -0,0 +1,13 @@ +// Package integration holds tests that exercise the provider → scanner → output +// layers together. A fixture provider is registered through the real provider +// registry (providers.NewRegistry / Capable), resolved by capability, run through +// the domain scanner, and rendered by the output package. +// +// This is the substance of what a command does, but not the command shell itself: +// a command's RunE resolves providers via providers.Resolve (→ Default()), which is +// AWS-backed and intentionally has no test-injection seam, so the cobra layer +// (flag→ScanOptions threading, the output-format switch, gate/exit-code) is covered +// by the unit tests in package cmd, not here. These tests catch composition breaks +// the per-layer unit tests miss: a scanner that resolves or filters wrong, or a +// renderer that drops a field. +package integration diff --git a/internal/integration/integration_test.go b/internal/integration/integration_test.go new file mode 100644 index 0000000..c56eced --- /dev/null +++ b/internal/integration/integration_test.go @@ -0,0 +1,244 @@ +package integration + +import ( + "bytes" + "context" + "strings" + "testing" + "time" + + "github.com/nanohype/cloudgov/internal/certs" + "github.com/nanohype/cloudgov/internal/cloud" + "github.com/nanohype/cloudgov/internal/network" + orphanscanner "github.com/nanohype/cloudgov/internal/orphans" + "github.com/nanohype/cloudgov/internal/output" + "github.com/nanohype/cloudgov/internal/providers" + "github.com/nanohype/cloudgov/internal/secrets" + "github.com/nanohype/cloudgov/internal/storage" + "github.com/nanohype/cloudgov/internal/tags" +) + +// fixtureProvider is a cloud.Provider implementing every finding-domain capability +// with canned data, so the registry can resolve it by capability. +type fixtureProvider struct { + orphans []cloud.OrphanResource + buckets []cloud.BucketFinding + network []cloud.NetworkFinding + certs []cloud.CertFinding + tags []cloud.TagFinding + secrets []cloud.SecretFinding +} + +func (fixtureProvider) Name() string { return "fixture" } +func (fixtureProvider) Detect(context.Context) bool { return true } + +func (f fixtureProvider) ListOrphans(context.Context) ([]cloud.OrphanResource, error) { + return f.orphans, nil +} +func (f fixtureProvider) AuditStorage(context.Context) ([]cloud.BucketFinding, error) { + return f.buckets, nil +} +func (f fixtureProvider) AuditNetwork(context.Context) ([]cloud.NetworkFinding, error) { + return f.network, nil +} +func (f fixtureProvider) ListCertificates(context.Context) ([]cloud.CertFinding, error) { + return f.certs, nil +} +func (f fixtureProvider) AuditTags(context.Context, []string) ([]cloud.TagFinding, error) { + return f.tags, nil +} +func (f fixtureProvider) ScanSecrets(context.Context) ([]cloud.SecretFinding, error) { + return f.secrets, nil +} + +// fixtureFactory adapts a fixtureProvider to providers.Factory so it can be +// resolved through the real registry. +type fixtureFactory struct{ p cloud.Provider } + +func (fixtureFactory) Name() string { return "fixture" } +func (fixtureFactory) Detect(context.Context) bool { return true } +func (f fixtureFactory) New(context.Context) (cloud.Provider, error) { + return f.p, nil +} + +func registryFor(p cloud.Provider) *providers.Registry { + return providers.NewRegistry(fixtureFactory{p: p}) +} + +// assertRendered checks that the JSON and table renderers both surface every +// marker — an identity field plus at least one more rendered field per domain, so +// a renderer that drops a field (not just the keyed one) is caught (json renderers +// return an error; table renderers don't). +func assertRendered(t *testing.T, renderJSON func(*bytes.Buffer) error, renderTable func(*bytes.Buffer), markers ...string) { + t.Helper() + var js bytes.Buffer + if err := renderJSON(&js); err != nil { + t.Fatalf("json render: %v", err) + } + var tbl bytes.Buffer + renderTable(&tbl) + for _, m := range markers { + if !strings.Contains(js.String(), m) { + t.Errorf("json output missing %q:\n%s", m, js.String()) + } + if !strings.Contains(tbl.String(), m) { + t.Errorf("table output missing %q:\n%s", m, tbl.String()) + } + } +} + +func TestIntegration_Orphans(t *testing.T) { + fix := fixtureProvider{orphans: []cloud.OrphanResource{ + {Kind: cloud.OrphanDisk, ID: "vol-int", Name: "vol-int", Region: "us-west-2", Provider: "aws", MonthlyCost: 8, Detail: "available"}, + }} + provs, err := providers.Capable[cloud.OrphansProvider](context.Background(), registryFor(fix)) + if err != nil { + t.Fatalf("resolve: %v", err) + } + got, err := orphanscanner.Scan(context.Background(), provs, orphanscanner.ScanOptions{}) + if err != nil { + t.Fatalf("scan: %v", err) + } + if len(got) != 1 || got[0].ID != "vol-int" { + t.Fatalf("scan result: %+v", got) + } + assertRendered(t, + func(b *bytes.Buffer) error { return output.WriteOrphans(b, got) }, + func(b *bytes.Buffer) { output.OrphanResources(b, got) }, + "vol-int", "available") +} + +func TestIntegration_Storage(t *testing.T) { + fix := fixtureProvider{buckets: []cloud.BucketFinding{ + {Severity: cloud.SeverityCritical, Type: cloud.BucketPublicAccess, Provider: "aws", Bucket: "leaky-int", Region: "us-east-1", Detail: "public"}, + }} + provs, err := providers.Capable[cloud.StorageProvider](context.Background(), registryFor(fix)) + if err != nil { + t.Fatalf("resolve: %v", err) + } + got, err := storage.Scan(context.Background(), provs, storage.ScanOptions{}) + if err != nil { + t.Fatalf("scan: %v", err) + } + if len(got) != 1 || got[0].Bucket != "leaky-int" { + t.Fatalf("scan result: %+v", got) + } + assertRendered(t, + func(b *bytes.Buffer) error { return output.WriteStorage(b, got) }, + func(b *bytes.Buffer) { output.BucketFindings(b, got) }, + "leaky-int", "public") +} + +func TestIntegration_Network(t *testing.T) { + fix := fixtureProvider{network: []cloud.NetworkFinding{ + {Severity: cloud.SeverityHigh, Type: cloud.NetworkOpenIngress, Provider: "aws", Resource: "sg-int", Detail: "0.0.0.0/0 on 22"}, + }} + provs, err := providers.Capable[cloud.NetworkProvider](context.Background(), registryFor(fix)) + if err != nil { + t.Fatalf("resolve: %v", err) + } + got, err := network.Scan(context.Background(), provs, network.ScanOptions{}) + if err != nil { + t.Fatalf("scan: %v", err) + } + if len(got) != 1 || got[0].Resource != "sg-int" { + t.Fatalf("scan result: %+v", got) + } + assertRendered(t, + func(b *bytes.Buffer) error { return output.WriteNetwork(b, got) }, + func(b *bytes.Buffer) { output.NetworkFindings(b, got) }, + "sg-int", "0.0.0.0/0") +} + +func TestIntegration_Certs(t *testing.T) { + fix := fixtureProvider{certs: []cloud.CertFinding{ + {Severity: cloud.SeverityCritical, Status: cloud.CertExpired, Provider: "aws", Domain: "int.example.com", ARN: "arn:int", ExpiresAt: time.Now(), DaysLeft: -1, Detail: "expired"}, + }} + provs, err := providers.Capable[cloud.CertProvider](context.Background(), registryFor(fix)) + if err != nil { + t.Fatalf("resolve: %v", err) + } + got, err := certs.Scan(context.Background(), provs, certs.ScanOptions{}) + if err != nil { + t.Fatalf("scan: %v", err) + } + if len(got) != 1 || got[0].Domain != "int.example.com" { + t.Fatalf("scan result: %+v", got) + } + assertRendered(t, + func(b *bytes.Buffer) error { return output.WriteCerts(b, got) }, + func(b *bytes.Buffer) { output.CertFindings(b, got) }, + "int.example.com", "EXPIRED") +} + +func TestIntegration_Tags(t *testing.T) { + fix := fixtureProvider{tags: []cloud.TagFinding{ + {Severity: cloud.SeverityMedium, Provider: "aws", ResourceID: "i-int", ResourceType: "ec2:instance", Region: "us-west-2", MissingTags: []string{"owner"}, Detail: "missing owner"}, + }} + provs, err := providers.Capable[cloud.TagProvider](context.Background(), registryFor(fix)) + if err != nil { + t.Fatalf("resolve: %v", err) + } + got, err := tags.Scan(context.Background(), provs, tags.ScanOptions{}) + if err != nil { + t.Fatalf("scan: %v", err) + } + if len(got) != 1 || got[0].ResourceID != "i-int" { + t.Fatalf("scan result: %+v", got) + } + assertRendered(t, + func(b *bytes.Buffer) error { return output.WriteTags(b, got) }, + func(b *bytes.Buffer) { output.TagFindings(b, got) }, + "i-int", "ec2:instance") +} + +func TestIntegration_Secrets(t *testing.T) { + fix := fixtureProvider{secrets: []cloud.SecretFinding{ + {Severity: cloud.SeverityHigh, Type: "aws_key", Provider: "aws", Resource: "lambda:int-fn", Key: "AWS_KEY", Match: "AKIA****", Detail: "leaked key"}, + }} + provs, err := providers.Capable[cloud.SecretsProvider](context.Background(), registryFor(fix)) + if err != nil { + t.Fatalf("resolve: %v", err) + } + got, err := secrets.ScanProviders(context.Background(), provs, secrets.ScanOptions{}) + if err != nil { + t.Fatalf("scan: %v", err) + } + if len(got) != 1 || got[0].Resource != "lambda:int-fn" { + t.Fatalf("scan result: %+v", got) + } + assertRendered(t, + func(b *bytes.Buffer) error { return output.WriteSecrets(b, got) }, + func(b *bytes.Buffer) { output.SecretFindings(b, got) }, + "int-fn", "AKIA****") +} + +// TestIntegration_SeverityFilterDiscriminates proves the scanner's MinSeverity is +// actually applied through the seam: a below-threshold finding is dropped, not just +// passed through. (The other tests use a zero-value threshold that admits everything.) +func TestIntegration_SeverityFilterDiscriminates(t *testing.T) { + fix := fixtureProvider{buckets: []cloud.BucketFinding{ + {Severity: cloud.SeverityCritical, Type: cloud.BucketPublicAccess, Provider: "aws", Bucket: "crit-bkt"}, + {Severity: cloud.SeverityLow, Type: cloud.BucketNoLogging, Provider: "aws", Bucket: "low-bkt"}, + }} + provs, err := providers.Capable[cloud.StorageProvider](context.Background(), registryFor(fix)) + if err != nil { + t.Fatalf("resolve: %v", err) + } + got, err := storage.Scan(context.Background(), provs, storage.ScanOptions{MinSeverity: cloud.SeverityHigh}) + if err != nil { + t.Fatalf("scan: %v", err) + } + if len(got) != 1 || got[0].Bucket != "crit-bkt" { + t.Fatalf("MinSeverity=HIGH should drop the LOW bucket; got %+v", got) + } +} + +// TestIntegration_NoProviderErrors confirms the resolve seam errors cleanly when no +// registered provider offers the requested capability. +func TestIntegration_NoProviderErrors(t *testing.T) { + empty := providers.NewRegistry() + if _, err := providers.Capable[cloud.OrphansProvider](context.Background(), empty); err == nil { + t.Error("expected an error resolving from an empty registry") + } +} diff --git a/scripts/coverage.sh b/scripts/coverage.sh new file mode 100755 index 0000000..10a9fda --- /dev/null +++ b/scripts/coverage.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# +# coverage.sh — run the test suite with coverage and enforce the floors. +# +# Produces coverage.out, prints total coverage, and fails if: +# - any package falls below its floor in .coverage-floors; +# - a floored package produces no coverage line (stale/typo'd name — its floor +# would otherwise be silently unenforced); +# - a package reports coverage but has no floor (new code must be gated). +# Floors ratchet: raise a package's floor when you raise its coverage. Run locally +# the same way CI does: scripts/coverage.sh +set -euo pipefail + +cd "$(dirname "$0")/.." + +profile="${1:-coverage.out}" +floors_file=".coverage-floors" +module="github.com/nanohype/cloudgov" + +# One run produces both the merged profile and the per-package coverage lines. +out=$(go test ./... -coverprofile="$profile" -covermode=atomic -count=1) +echo "$out" + +total=$(go tool cover -func="$profile" | awk '/^total:/ {gsub(/%/,"",$3); print $3}') +echo "== total coverage: ${total}% ==" + +fail=0 +floored=" " +while read -r pkg floor; do + case "$pkg" in '' | '#'*) continue ;; esac + floored="${floored}${pkg} " + line=$(printf '%s\n' "$out" | grep -E "[[:space:]]${module}/${pkg}[[:space:]]" || true) + cov=$(printf '%s\n' "$line" | grep -oE "coverage: [0-9.]+%" | grep -oE "[0-9.]+" | head -1 || true) + if [ -z "$cov" ]; then + echo "::error::floored package ${pkg} produced no coverage line (stale name, or it has no test files?)" + fail=1 + continue + fi + if awk "BEGIN{exit !($cov < $floor)}"; then + echo "::error::${pkg} coverage ${cov}% is below its ${floor}% floor" + fail=1 + else + printf ' ok %-26s %5s%% >= %s%%\n' "$pkg" "$cov" "$floor" + fi +done < "$floors_file" + +# Any package that reports coverage but isn't floored is ungated — fail so new +# tested code can't land without a floor. +covered=$(printf '%s\n' "$out" | awk -v m="${module}/" '/coverage: [0-9]/ {for (i=1;i<=NF;i++) if (index($i,m)==1) {sub(m,"",$i); print $i}}' | sort -u) +for pkg in $covered; do + case "$floored" in + *" $pkg "*) ;; + *) echo "::error::package ${pkg} reports coverage but has no floor in ${floors_file}"; fail=1 ;; + esac +done + +if [ "$fail" -ne 0 ]; then + echo "== coverage floors NOT met ==" + exit 1 +fi +echo "== all per-package coverage floors met =="