diff --git a/pkg/config/types.go b/pkg/config/types.go index d1d3f74..db996c9 100644 --- a/pkg/config/types.go +++ b/pkg/config/types.go @@ -48,7 +48,9 @@ type InventoryConfig struct { NativeTypePattern string `yaml:"native_type_pattern"` } -// EOLConfig defines EOL provider configuration +// EOLConfig defines EOL provider configuration. +// +//nolint:govet // YAML field order chosen for readability of resources.yaml type EOLConfig struct { Provider string `yaml:"provider"` Product string `yaml:"product"` diff --git a/pkg/inventory/wiz/http_client.go b/pkg/inventory/wiz/http_client.go index f865102..b8bc0ab 100644 --- a/pkg/inventory/wiz/http_client.go +++ b/pkg/inventory/wiz/http_client.go @@ -33,12 +33,17 @@ const reportDownloadQuery = `query ReportDownloadUrl($reportId: ID!) { }` // HTTPClient implements WizClient using net/http -// -//nolint:govet // field alignment sacrificed for readability type HTTPClient struct { clientID string clientSecret string httpClient *http.Client + + // authURL and graphqlURL default to the Wiz production endpoints + // in NewHTTPClient. They are package-private so tests can stand + // up an httptest.Server and point the client at it without an + // extra public constructor. + authURL string + graphqlURL string } // NewHTTPClient creates a new HTTPClient for the Wiz API @@ -47,6 +52,8 @@ func NewHTTPClient(clientID, clientSecret string) *HTTPClient { clientID: clientID, clientSecret: clientSecret, httpClient: &http.Client{Timeout: 30 * time.Second}, + authURL: wizAuthURL, + graphqlURL: wizGraphQLURL, } } @@ -86,7 +93,7 @@ func (c *HTTPClient) GetAccessToken(ctx context.Context) (string, error) { params.Set("client_id", c.clientID) params.Set("client_secret", c.clientSecret) - req, err := http.NewRequestWithContext(ctx, http.MethodPost, wizAuthURL, strings.NewReader(params.Encode())) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.authURL, strings.NewReader(params.Encode())) if err != nil { return "", errors.Wrap(err, "failed to create auth request") } @@ -170,7 +177,7 @@ func (c *HTTPClient) DownloadReport(ctx context.Context, downloadURL string) (io func (c *HTTPClient) doGraphQL(ctx context.Context, accessToken string, reqBody []byte, result any) error { var lastErr error for i := 0; i < maxRetries; i++ { - req, err := http.NewRequestWithContext(ctx, http.MethodPost, wizGraphQLURL, bytes.NewReader(reqBody)) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.graphqlURL, bytes.NewReader(reqBody)) if err != nil { return errors.Wrap(err, "failed to create GraphQL request") } diff --git a/pkg/inventory/wiz/http_client_test.go b/pkg/inventory/wiz/http_client_test.go new file mode 100644 index 0000000..95c3105 --- /dev/null +++ b/pkg/inventory/wiz/http_client_test.go @@ -0,0 +1,265 @@ +package wiz + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// newTestHTTPClient builds an HTTPClient pointed at the supplied test +// servers. Used by every http_client test below — keeps each test +// from repeating the constructor + URL plumbing. +func newTestHTTPClient(authURL, graphqlURL string) *HTTPClient { + return &HTTPClient{ + clientID: "test-client-id", + clientSecret: "test-client-secret", + httpClient: &http.Client{Timeout: 5 * time.Second}, + authURL: authURL, + graphqlURL: graphqlURL, + } +} + +func TestNewHTTPClient_DefaultsToProductionURLs(t *testing.T) { + c := NewHTTPClient("id", "secret") + require.NotNil(t, c) + assert.Equal(t, "id", c.clientID) + assert.Equal(t, "secret", c.clientSecret) + assert.Equal(t, wizAuthURL, c.authURL, "auth URL must default to the Wiz production endpoint") + assert.Equal(t, wizGraphQLURL, c.graphqlURL, "graphql URL must default to the Wiz production endpoint") + assert.NotNil(t, c.httpClient) + assert.Equal(t, 30*time.Second, c.httpClient.Timeout) +} + +// ---------------- GetAccessToken ---------------- + +func TestGetAccessToken_HappyPath(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Auth endpoint receives form-encoded credentials. + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "application/x-www-form-urlencoded", r.Header.Get("Content-Type")) + + require.NoError(t, r.ParseForm()) + assert.Equal(t, "client_credentials", r.Form.Get("grant_type")) + assert.Equal(t, "beyond-api", r.Form.Get("audience")) + assert.Equal(t, "test-client-id", r.Form.Get("client_id")) + assert.Equal(t, "test-client-secret", r.Form.Get("client_secret")) + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"access_token":"opaque-token-xyz"}`)) + })) + defer srv.Close() + + c := newTestHTTPClient(srv.URL, "") + tok, err := c.GetAccessToken(context.Background()) + require.NoError(t, err) + assert.Equal(t, "opaque-token-xyz", tok) +} + +func TestGetAccessToken_Non200Status(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "invalid_client", http.StatusUnauthorized) + })) + defer srv.Close() + + c := newTestHTTPClient(srv.URL, "") + _, err := c.GetAccessToken(context.Background()) + require.Error(t, err) + assert.Contains(t, err.Error(), "auth failed") + assert.Contains(t, err.Error(), "401") +} + +func TestGetAccessToken_BadJSON(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{not-json`)) + })) + defer srv.Close() + + c := newTestHTTPClient(srv.URL, "") + _, err := c.GetAccessToken(context.Background()) + require.Error(t, err) + assert.Contains(t, err.Error(), "parse") +} + +func TestGetAccessToken_TransportError(t *testing.T) { + // Closed server -> connection refused. Exercises the failed-Do branch. + srv := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {})) + srv.Close() + + c := newTestHTTPClient(srv.URL, "") + _, err := c.GetAccessToken(context.Background()) + require.Error(t, err) + assert.Contains(t, err.Error(), "auth request") +} + +// ---------------- GetReport ---------------- + +func TestGetReport_HappyPath(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Auth header is forwarded. + assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization")) + assert.Equal(t, "application/json", r.Header.Get("Content-Type")) + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "data": { + "report": { + "id": "rep-1", + "name": "Aurora Inventory", + "lastRun": {"status": "COMPLETED", "url": "https://files.example/abc.csv"} + } + } + }`)) + })) + defer srv.Close() + + c := newTestHTTPClient("", srv.URL) + rep, err := c.GetReport(context.Background(), "test-token", "rep-1") + require.NoError(t, err) + assert.Equal(t, "rep-1", rep.ID) + assert.Equal(t, "Aurora Inventory", rep.Name) + assert.Equal(t, "https://files.example/abc.csv", rep.DownloadURL) +} + +func TestGetReport_LastRunNotCompleted(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{ + "data": {"report": {"id":"r","name":"n","lastRun":{"status":"FAILED","url":""}}} + }`)) + })) + defer srv.Close() + + c := newTestHTTPClient("", srv.URL) + _, err := c.GetReport(context.Background(), "tok", "r") + require.Error(t, err) + assert.Contains(t, err.Error(), "FAILED") +} + +func TestGetReport_GraphQLErrorArray(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"errors": [{"message":"unauthorized"}]}`)) + })) + defer srv.Close() + + c := newTestHTTPClient("", srv.URL) + _, err := c.GetReport(context.Background(), "tok", "r") + require.Error(t, err) + assert.Contains(t, err.Error(), "unauthorized") +} + +func TestGetReport_HTTPErrorStatus(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "boom", http.StatusInternalServerError) + })) + defer srv.Close() + + c := newTestHTTPClient("", srv.URL) + _, err := c.GetReport(context.Background(), "tok", "r") + require.Error(t, err) + assert.Contains(t, err.Error(), "500") +} + +// ---------------- doGraphQL retry behavior ---------------- + +func TestDoGraphQL_RateLimitRetriesThenSucceeds(t *testing.T) { + // First call returns a 429-style "Rate limit exceeded" body, second + // returns a happy COMPLETED report. Verifies the retry loop honors + // the rate-limit substring detection AND that a per-attempt success + // breaks out of the loop. + calls := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + calls++ + if calls == 1 { + http.Error(w, "Rate limit exceeded — slow down", http.StatusTooManyRequests) + return + } + _, _ = w.Write([]byte(`{ + "data":{"report":{"id":"r","name":"n","lastRun":{"status":"COMPLETED","url":"u"}}} + }`)) + })) + defer srv.Close() + + // Override retryBackoff via a shorter context-aware path: replace the + // package-level constant by stubbing the HTTPClient's timeout to be + // far longer than the backoff. retryBackoff is 3s; the test will + // take ~3s but that's acceptable for a single test. + c := newTestHTTPClient("", srv.URL) + c.httpClient.Timeout = 30 * time.Second + + rep, err := c.GetReport(context.Background(), "tok", "r") + require.NoError(t, err) + assert.Equal(t, "r", rep.ID) + assert.Equal(t, 2, calls, "client must have retried after the rate-limit hit") +} + +func TestDoGraphQL_ContextCancelDuringBackoff(t *testing.T) { + // Server always returns rate-limit; we cancel the context during + // the backoff sleep and expect the GraphQL caller to return the + // context error rather than continuing to retry. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "Rate limit exceeded", http.StatusTooManyRequests) + })) + defer srv.Close() + + c := newTestHTTPClient("", srv.URL) + + ctx, cancel := context.WithCancel(context.Background()) + go func() { + time.Sleep(100 * time.Millisecond) + cancel() + }() + + _, err := c.GetReport(ctx, "tok", "r") + require.Error(t, err) + assert.True(t, + strings.Contains(err.Error(), "context canceled") || strings.Contains(err.Error(), "context deadline"), + "expected context-cancellation error, got: %s", err.Error()) +} + +// ---------------- DownloadReport ---------------- + +func TestDownloadReport_HappyPath(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + w.Header().Set("Content-Type", "text/csv") + _, _ = w.Write([]byte("col1,col2\nv1,v2\n")) + })) + defer srv.Close() + + c := newTestHTTPClient("", "") + rc, err := c.DownloadReport(context.Background(), srv.URL) + require.NoError(t, err) + defer rc.Close() + + body, err := io.ReadAll(rc) + require.NoError(t, err) + assert.Contains(t, string(body), "col1,col2") +} + +func TestDownloadReport_Non200Status(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "expired", http.StatusForbidden) + })) + defer srv.Close() + + c := newTestHTTPClient("", "") + _, err := c.DownloadReport(context.Background(), srv.URL) + require.Error(t, err) + assert.Contains(t, err.Error(), "403") +} + +func TestDownloadReport_TransportError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {})) + srv.Close() // close immediately so connect refuses + + c := newTestHTTPClient("", "") + _, err := c.DownloadReport(context.Background(), srv.URL) + require.Error(t, err) + assert.Contains(t, err.Error(), "download") +} diff --git a/pkg/policy/messages_test.go b/pkg/policy/messages_test.go new file mode 100644 index 0000000..264ff55 --- /dev/null +++ b/pkg/policy/messages_test.go @@ -0,0 +1,305 @@ +package policy + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "github.com/block/Version-Guard/pkg/types" +) + +// timePtr is a tiny helper so the tests below can stay readable. +func timePtr(t time.Time) *time.Time { return &t } + +// ---------------- GetMessage / per-status message helpers ---------------- + +func TestGetMessage_Red_IsEOL(t *testing.T) { + p := NewDefaultPolicy() + res := &types.Resource{Engine: "aurora-mysql", CurrentVersion: "5.6.10a"} + eol := time.Date(2023, 6, 1, 0, 0, 0, 0, time.UTC) + lc := &types.VersionLifecycle{IsEOL: true, EOLDate: &eol} + + got := p.GetMessage(res, lc, types.StatusRed) + assert.Contains(t, got, "past End-of-Life") + assert.Contains(t, got, "5.6.10a") + assert.Contains(t, got, "Jun 2023") +} + +func TestGetMessage_Red_IsDeprecated(t *testing.T) { + p := NewDefaultPolicy() + res := &types.Resource{Engine: "postgres", CurrentVersion: "11.0"} + dep := time.Date(2024, 5, 15, 0, 0, 0, 0, time.UTC) + lc := &types.VersionLifecycle{IsDeprecated: true, DeprecationDate: &dep} + + got := p.GetMessage(res, lc, types.StatusRed) + assert.Contains(t, got, "deprecated") + assert.Contains(t, got, "May 2024") +} + +func TestGetMessage_Red_ExtendedSupportEnded(t *testing.T) { + p := NewDefaultPolicy() + res := &types.Resource{Engine: "aurora-postgresql", CurrentVersion: "11.21"} + ended := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) // in the past + lc := &types.VersionLifecycle{ExtendedSupportEnd: &ended} + + got := p.GetMessage(res, lc, types.StatusRed) + assert.Contains(t, got, "Extended support") + assert.Contains(t, got, "has ended") +} + +func TestGetMessage_Red_Fallback(t *testing.T) { + // Status forced to RED but none of the typed reasons apply — exercises + // the bottom "requires immediate attention" fallback. + p := NewDefaultPolicy() + res := &types.Resource{Engine: "lambda", CurrentVersion: "nodejs14.x"} + lc := &types.VersionLifecycle{} + + got := p.GetMessage(res, lc, types.StatusRed) + assert.Contains(t, got, "requires immediate attention") +} + +func TestGetMessage_Yellow_ExtendedSupport(t *testing.T) { + p := NewDefaultPolicy() + res := &types.Resource{Engine: "aurora-mysql", CurrentVersion: "5.7"} + lc := &types.VersionLifecycle{IsExtendedSupport: true} + + got := p.GetMessage(res, lc, types.StatusYellow) + assert.Contains(t, got, "extended support") + assert.Contains(t, got, "6x standard cost") +} + +func TestGetMessage_Yellow_ApproachingEOL(t *testing.T) { + p := NewDefaultPolicy() + res := &types.Resource{Engine: "redis", CurrentVersion: "6.0"} + // Add a small extra buffer so the integer-truncated days-until + // value is stable regardless of when the test runs (24h * 30 + 1h + // guarantees we observe "30 days" not "29 days" after rounding). + soon := time.Now().Add(30*24*time.Hour + time.Hour) + lc := &types.VersionLifecycle{EOLDate: &soon} + + got := p.GetMessage(res, lc, types.StatusYellow) + assert.Contains(t, got, "will reach End-of-Life") + assert.Regexp(t, `\d+ days`, got) +} + +func TestGetMessage_Yellow_ApproachingDeprecation(t *testing.T) { + p := NewDefaultPolicy() + res := &types.Resource{Engine: "lambda", CurrentVersion: "nodejs18.x"} + soon := time.Now().Add(45*24*time.Hour + time.Hour) + lc := &types.VersionLifecycle{DeprecationDate: &soon} + + got := p.GetMessage(res, lc, types.StatusYellow) + assert.Contains(t, got, "will be deprecated") + assert.Regexp(t, `\d+ days`, got) +} + +func TestGetMessage_Yellow_Fallback(t *testing.T) { + // YELLOW with no typed reason — exercises the fallback branch + // ("should be upgraded soon"). + p := NewDefaultPolicy() + res := &types.Resource{Engine: "eks", CurrentVersion: "1.28"} + lc := &types.VersionLifecycle{} + + got := p.GetMessage(res, lc, types.StatusYellow) + assert.Contains(t, got, "should be upgraded soon") +} + +func TestGetMessage_Green(t *testing.T) { + p := NewDefaultPolicy() + res := &types.Resource{Engine: "postgres", CurrentVersion: "16.2"} + lc := &types.VersionLifecycle{} + + got := p.GetMessage(res, lc, types.StatusGreen) + assert.Contains(t, got, "currently supported") +} + +func TestGetMessage_Unknown_NoLifecycle(t *testing.T) { + p := NewDefaultPolicy() + res := &types.Resource{Engine: "lambda", CurrentVersion: "java8.al2"} + lc := &types.VersionLifecycle{Version: ""} // no lifecycle data + + got := p.GetMessage(res, lc, types.StatusUnknown) + assert.Contains(t, got, "No lifecycle data available") +} + +func TestGetMessage_Unknown_HaveLifecycleButCantClassify(t *testing.T) { + p := NewDefaultPolicy() + res := &types.Resource{Engine: "redis", CurrentVersion: "7.0"} + lc := &types.VersionLifecycle{Version: "7.0"} // we have data, but not enough + + got := p.GetMessage(res, lc, types.StatusUnknown) + assert.Contains(t, got, "Unable to determine support status") +} + +func TestGetMessage_DefaultArm_BogusStatus(t *testing.T) { + p := NewDefaultPolicy() + res := &types.Resource{Engine: "aurora-mysql", CurrentVersion: "8.0"} + lc := &types.VersionLifecycle{} + + got := p.GetMessage(res, lc, types.Status("BOGUS")) + assert.Contains(t, got, "Unknown status") +} + +// ---------------- GetRecommendation ---------------- + +func TestGetRecommendation_Red_WithUpgradeTarget(t *testing.T) { + p := NewDefaultPolicy() + res := &types.Resource{Engine: "aurora-mysql", CurrentVersion: "5.6"} + lc := &types.VersionLifecycle{Version: "5.6", RecommendedVersion: "8.0"} + + got := p.GetRecommendation(res, lc, types.StatusRed) + assert.Contains(t, got, "Upgrade to aurora-mysql 8.0") + assert.Contains(t, got, "immediately") +} + +func TestGetRecommendation_Red_NoUpgradeTarget(t *testing.T) { + // RecommendedVersion empty -> generic wording. + p := NewDefaultPolicy() + res := &types.Resource{Engine: "redis", CurrentVersion: "5.0"} + lc := &types.VersionLifecycle{Version: "5.0"} + + got := p.GetRecommendation(res, lc, types.StatusRed) + assert.Contains(t, got, "Upgrade to the latest supported version") +} + +func TestGetRecommendation_Yellow_ExtendedSupport_WithNonExtendedTarget(t *testing.T) { + p := NewDefaultPolicy() + res := &types.Resource{Engine: "aurora-postgresql", CurrentVersion: "11"} + lc := &types.VersionLifecycle{ + Version: "11", + IsExtendedSupport: true, + RecommendedNonExtendedVersion: "16", + } + + got := p.GetRecommendation(res, lc, types.StatusYellow) + assert.Contains(t, got, "Upgrade to aurora-postgresql 16") + assert.Contains(t, got, "avoid extended support costs") +} + +func TestGetRecommendation_Yellow_ExtendedSupport_NoNonExtendedTarget(t *testing.T) { + // Every supported cycle is itself in extended support — falls back + // to the neutral wording rather than over-promising cost relief. + p := NewDefaultPolicy() + res := &types.Resource{Engine: "aurora-mysql", CurrentVersion: "5.6"} + lc := &types.VersionLifecycle{Version: "5.6", IsExtendedSupport: true} + + got := p.GetRecommendation(res, lc, types.StatusYellow) + assert.Contains(t, got, "Upgrade to a supported version") + assert.Contains(t, got, "avoid extended support costs") +} + +func TestGetRecommendation_Yellow_ApproachingEOL_WithTarget(t *testing.T) { + p := NewDefaultPolicy() + res := &types.Resource{Engine: "redis", CurrentVersion: "6.0"} + lc := &types.VersionLifecycle{Version: "6.0", RecommendedVersion: "7.2"} + + got := p.GetRecommendation(res, lc, types.StatusYellow) + assert.Contains(t, got, "Plan upgrade to redis 7.2") + assert.Contains(t, got, "within the next 90 days") +} + +func TestGetRecommendation_Yellow_ApproachingEOL_NoTarget(t *testing.T) { + p := NewDefaultPolicy() + res := &types.Resource{Engine: "redis", CurrentVersion: "6.0"} + lc := &types.VersionLifecycle{Version: "6.0"} + + got := p.GetRecommendation(res, lc, types.StatusYellow) + assert.Contains(t, got, "Plan upgrade to the latest supported version") +} + +func TestGetRecommendation_Green(t *testing.T) { + p := NewDefaultPolicy() + got := p.GetRecommendation(&types.Resource{}, &types.VersionLifecycle{}, types.StatusGreen) + assert.Equal(t, "No action required", got) +} + +func TestGetRecommendation_Unknown(t *testing.T) { + p := NewDefaultPolicy() + got := p.GetRecommendation(&types.Resource{}, &types.VersionLifecycle{}, types.StatusUnknown) + assert.Contains(t, got, "Verify version") +} + +func TestGetRecommendation_DefaultArm_BogusStatus(t *testing.T) { + p := NewDefaultPolicy() + got := p.GetRecommendation(&types.Resource{}, &types.VersionLifecycle{}, types.Status("BOGUS")) + assert.Equal(t, "Unable to provide recommendation", got) +} + +// ---------------- usableUpgradeTarget edge cases ---------------- + +func TestUsableUpgradeTarget(t *testing.T) { + res := &types.Resource{CurrentVersion: "5.6.10a"} + lc := &types.VersionLifecycle{Version: "5.6"} + + // Empty candidate -> empty. + assert.Empty(t, usableUpgradeTarget(res, lc, "")) + + // Candidate equals lifecycle cycle -> empty (would be a no-op). + assert.Empty(t, usableUpgradeTarget(res, lc, "5.6")) + + // Candidate equals resource's current full version -> empty. + assert.Empty(t, usableUpgradeTarget(res, lc, "5.6.10a")) + + // Different candidate -> returned. + assert.Equal(t, "8.0", usableUpgradeTarget(res, lc, "8.0")) +} + +// ---------------- versionMatches ---------------- + +func TestVersionMatches(t *testing.T) { + tests := []struct { + lifecycleVersion string + resourceVersion string + want bool + }{ + // Exact match. + {"5.6", "5.6", true}, + // Prefix match (resource has patch). + {"5.6", "5.6.10a", true}, + // Mismatch. + {"5.6", "5.7", false}, + // k8s- prefix stripped. + {"1.31", "k8s-1.31.5", true}, + // kubernetes- prefix stripped. + {"1.31", "kubernetes-1.31.5", true}, + // Empty resource. + {"5.6", "", false}, + // Bare-major prefix should NOT match a different major. + {"8", "8.0.35", true}, // "8.0.35" startsWith "8." + {"8", "80.0.0", false}, // "80.0.0" does NOT startsWith "8." — guards against false-prefix + } + + for _, tt := range tests { + t.Run(tt.lifecycleVersion+"/"+tt.resourceVersion, func(t *testing.T) { + assert.Equal(t, tt.want, versionMatches(tt.lifecycleVersion, tt.resourceVersion)) + }) + } +} + +// ---------------- Classify branch coverage gaps ---------------- + +func TestClassify_GreenIsExplicitOnIsSupported(t *testing.T) { + // Lifecycle has version match + IsSupported=true and no warning + // flags -> GREEN. (Existing tests cover the EOL/yellow paths but + // not this branch in default_test.go.) + p := NewDefaultPolicy() + res := &types.Resource{Engine: "postgres", CurrentVersion: "16.2"} + lc := &types.VersionLifecycle{Version: "16.2", IsSupported: true} + + assert.Equal(t, types.StatusGreen, p.Classify(res, lc)) +} + +func TestClassify_FallsThroughToUnknownWhenNotSupportedAndNoSignal(t *testing.T) { + // Version matches but lifecycle has IsSupported=false and no RED + // or YELLOW signal — neither GREEN nor RED nor YELLOW applies, so + // the function returns UNKNOWN. + p := NewDefaultPolicy() + res := &types.Resource{Engine: "redis", CurrentVersion: "7.0"} + lc := &types.VersionLifecycle{Version: "7.0", IsSupported: false} + + assert.Equal(t, types.StatusUnknown, p.Classify(res, lc)) +} + +// keep timePtr referenced even if other tests stop using it +var _ = timePtr