Skip to content

Commit 6a0b635

Browse files
Merge pull request #17 from snyk/feat/return-codes
feat: [DGP-503] have extension return 0 for success and 1 for finding…
2 parents db62d00 + 1b87478 commit 6a0b635

File tree

3 files changed

+348
-3
lines changed

3 files changed

+348
-3
lines changed

internal/commands/ostest/ostest.go

Lines changed: 111 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,19 @@ package ostest
1111
import (
1212
"context"
1313
"encoding/json"
14+
std_errors "errors"
1415
"fmt"
1516
"math"
1617
"os"
18+
"sort"
1719

1820
"github.com/rs/zerolog"
1921
"github.com/snyk/go-application-framework/pkg/apiclients/testapi"
2022
"github.com/snyk/go-application-framework/pkg/configuration"
2123
"github.com/snyk/go-application-framework/pkg/local_workflows/code_workflow"
2224
"github.com/snyk/go-application-framework/pkg/local_workflows/config_utils"
25+
"github.com/snyk/go-application-framework/pkg/local_workflows/content_type"
26+
"github.com/snyk/go-application-framework/pkg/local_workflows/json_schemas"
2327
"github.com/snyk/go-application-framework/pkg/workflow"
2428

2529
service "github.com/snyk/cli-extension-os-flows/internal/common"
@@ -41,6 +45,12 @@ const FeatureFlagRiskScore = "feature_flag_experimental_risk_score"
4145
// FeatureFlagRiskScoreInCLI is used to gate the risk score feature in the CLI.
4246
const FeatureFlagRiskScoreInCLI = "feature_flag_experimental_risk_score_in_cli"
4347

48+
// ApplicationJSONContentType matches the content type for legacy JSON findings records.
49+
const ApplicationJSONContentType = "application/json"
50+
51+
// ErrNoSummaryData is returned when a test summary cannot be generated due to lack of data.
52+
var ErrNoSummaryData = std_errors.New("no summary data to create")
53+
4454
// RegisterWorkflows registers the "test" workflow.
4555
func RegisterWorkflows(e workflow.Engine) error {
4656
// Check if workflow already exists
@@ -291,7 +301,105 @@ func runTest(
291301
return nil, fmt.Errorf("error converting snyk schema findings to legacy json: %w", err)
292302
}
293303

294-
return []workflow.Data{newWorkflowData("application/json", legacyJSON)}, nil
304+
legacyData := NewWorkflowData(ApplicationJSONContentType, legacyJSON)
305+
outputData := []workflow.Data{legacyData}
306+
307+
summaryData, err := NewSummaryData(finalResult, logger, currentDir)
308+
if err != nil {
309+
if !std_errors.Is(err, ErrNoSummaryData) {
310+
logger.Warn().Err(err).Msg("Failed to create test summary for exit code handling")
311+
}
312+
} else {
313+
outputData = append(outputData, summaryData)
314+
}
315+
316+
return outputData, nil
317+
}
318+
319+
// extractSeverityKeys returns a map of severity keys present in the summaries.
320+
func extractSeverityKeys(summaries ...*testapi.FindingSummary) map[string]bool {
321+
keys := make(map[string]bool)
322+
for _, summary := range summaries {
323+
if summary != nil && summary.CountBy != nil {
324+
if severityCounts, ok := (*summary.CountBy)["severity"]; ok {
325+
for severity := range severityCounts {
326+
keys[severity] = true
327+
}
328+
}
329+
}
330+
}
331+
return keys
332+
}
333+
334+
// getSeverityCount safely retrieves the count for a given severity from a summary.
335+
func getSeverityCount(summary *testapi.FindingSummary, severity string) uint32 {
336+
if summary == nil || summary.CountBy == nil {
337+
return 0
338+
}
339+
if severityCounts, ok := (*summary.CountBy)["severity"]; ok {
340+
return severityCounts[severity]
341+
}
342+
return 0
343+
}
344+
345+
// NewSummaryData creates a workflow.Data object containing a json_schemas.TestSummary
346+
// from a testapi.TestResult. This is used for downstream processing, like determining
347+
// the CLI exit code.
348+
func NewSummaryData(testResult testapi.TestResult, logger *zerolog.Logger, path string) (workflow.Data, error) {
349+
rawSummary := testResult.GetRawSummary()
350+
effectiveSummary := testResult.GetEffectiveSummary()
351+
352+
if rawSummary == nil || effectiveSummary == nil {
353+
return nil, fmt.Errorf("test result missing summary information")
354+
}
355+
356+
severityKeys := extractSeverityKeys(rawSummary, effectiveSummary)
357+
358+
if len(severityKeys) == 0 && rawSummary.Count == 0 {
359+
logger.Debug().Msg("No findings in summary, skipping summary creation.")
360+
return nil, fmt.Errorf("no findings in summary: %w", ErrNoSummaryData)
361+
}
362+
363+
var summaryResults []json_schemas.TestSummaryResult
364+
for severity := range severityKeys {
365+
total := getSeverityCount(rawSummary, severity)
366+
open := getSeverityCount(effectiveSummary, severity)
367+
368+
if total > 0 || open > 0 {
369+
ignored := 0
370+
if total > open {
371+
ignored = int(total - open)
372+
}
373+
summaryResults = append(summaryResults, json_schemas.TestSummaryResult{
374+
Severity: severity,
375+
Total: int(total),
376+
Open: int(open),
377+
Ignored: ignored,
378+
})
379+
}
380+
}
381+
382+
if len(summaryResults) > 0 {
383+
// Sort results for consistent output, matching the standard CLI order.
384+
sort.Slice(summaryResults, func(i, j int) bool {
385+
order := map[string]int{"critical": 4, "high": 3, "medium": 2, "low": 1}
386+
return order[summaryResults[i].Severity] > order[summaryResults[j].Severity]
387+
})
388+
389+
testSummary := json_schemas.NewTestSummary("open-source", path)
390+
testSummary.Results = summaryResults
391+
testSummary.SeverityOrderAsc = []string{"low", "medium", "high", "critical"}
392+
393+
summaryBytes, err := json.Marshal(testSummary)
394+
if err != nil {
395+
return nil, fmt.Errorf("failed to marshal test summary: %w", err)
396+
}
397+
398+
summaryWorkflowData := NewWorkflowData(content_type.TEST_SUMMARY, summaryBytes)
399+
return summaryWorkflowData, nil
400+
}
401+
402+
return nil, fmt.Errorf("no summary results to process: %w", ErrNoSummaryData)
295403
}
296404

297405
// runReachabilityFlow handles the reachability analysis flow.
@@ -334,8 +442,8 @@ func createDepGraph(ictx workflow.InvocationContext) (testapi.IoSnykApiV1testdep
334442
return depGraphStruct, nil
335443
}
336444

337-
// Temporary support for dumping findings output to built-in JSON formatter.
338-
func newWorkflowData(contentType string, data []byte) workflow.Data {
445+
// NewWorkflowData creates a workflow.Data object with the given content type and data.
446+
func NewWorkflowData(contentType string, data []byte) workflow.Data {
339447
return workflow.NewData(
340448
workflow.NewTypeIdentifier(WorkflowID, "ostest"),
341449
contentType,
Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
package ostest_test
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"errors"
7+
"testing"
8+
"time"
9+
10+
"github.com/google/uuid"
11+
12+
"github.com/rs/zerolog"
13+
"github.com/snyk/go-application-framework/pkg/apiclients/testapi"
14+
"github.com/snyk/go-application-framework/pkg/local_workflows/content_type"
15+
"github.com/snyk/go-application-framework/pkg/local_workflows/json_schemas"
16+
"github.com/stretchr/testify/assert"
17+
"github.com/stretchr/testify/require"
18+
19+
"github.com/snyk/cli-extension-os-flows/internal/commands/ostest"
20+
)
21+
22+
// mockTestResult is a mock implementation of testapi.TestResult for testing.
23+
type mockTestResult struct {
24+
rawSummary *testapi.FindingSummary
25+
effectiveSummary *testapi.FindingSummary
26+
}
27+
28+
func (m *mockTestResult) GetRawSummary() *testapi.FindingSummary {
29+
return m.rawSummary
30+
}
31+
32+
func (m *mockTestResult) GetEffectiveSummary() *testapi.FindingSummary {
33+
return m.effectiveSummary
34+
}
35+
36+
// These methods are not used in newSummaryData but are required to satisfy the interface.
37+
func (m *mockTestResult) GetTestSubject() testapi.TestSubject {
38+
return testapi.TestSubject{}
39+
}
40+
41+
func (m *mockTestResult) Findings(_ context.Context) ([]testapi.FindingData, bool, error) {
42+
return nil, false, nil
43+
}
44+
45+
func (m *mockTestResult) GetExecutionState() testapi.TestExecutionStates {
46+
return ""
47+
}
48+
49+
func (m *mockTestResult) GetTestConfiguration() *testapi.TestConfiguration {
50+
return nil
51+
}
52+
53+
func (m *mockTestResult) GetSubjectLocators() *[]testapi.TestSubjectLocator {
54+
return nil
55+
}
56+
57+
func (m *mockTestResult) GetPassFail() *testapi.PassFail {
58+
return nil
59+
}
60+
61+
func (m *mockTestResult) GetOutcomeReason() *testapi.TestOutcomeReason {
62+
return nil
63+
}
64+
65+
func (m *mockTestResult) GetErrors() *[]testapi.IoSnykApiCommonError {
66+
return nil
67+
}
68+
69+
func (m *mockTestResult) GetWarnings() *[]testapi.IoSnykApiCommonError {
70+
return nil
71+
}
72+
73+
func (m *mockTestResult) GetCreatedAt() *time.Time {
74+
return nil
75+
}
76+
77+
func (m *mockTestResult) GetBreachedPolicies() *testapi.PolicyRefSet {
78+
return nil
79+
}
80+
81+
func (m *mockTestResult) GetTestID() *uuid.UUID {
82+
return nil
83+
}
84+
85+
func (m *mockTestResult) GetID() string {
86+
return ""
87+
}
88+
89+
func Test_newSummaryData(t *testing.T) {
90+
logger := zerolog.Nop()
91+
path := "/test/path"
92+
93+
t.Run("no findings should not create summary data, implying exit code 0", func(t *testing.T) {
94+
testResult := &mockTestResult{
95+
rawSummary: &testapi.FindingSummary{Count: 0},
96+
effectiveSummary: &testapi.FindingSummary{Count: 0},
97+
}
98+
99+
data, err := ostest.NewSummaryData(testResult, &logger, path)
100+
assert.Nil(t, data)
101+
assert.True(t, errors.Is(err, ostest.ErrNoSummaryData))
102+
assert.ErrorContains(t, err, "no findings in summary")
103+
})
104+
105+
t.Run("no open or total findings should not create summary data", func(t *testing.T) {
106+
testResult := &mockTestResult{
107+
rawSummary: &testapi.FindingSummary{
108+
Count: 0,
109+
CountBy: &map[string]map[string]uint32{
110+
"severity": {"high": 0},
111+
},
112+
},
113+
effectiveSummary: &testapi.FindingSummary{
114+
Count: 0,
115+
CountBy: &map[string]map[string]uint32{
116+
"severity": {"high": 0},
117+
},
118+
},
119+
}
120+
121+
data, err := ostest.NewSummaryData(testResult, &logger, path)
122+
assert.Nil(t, data)
123+
assert.True(t, errors.Is(err, ostest.ErrNoSummaryData))
124+
assert.ErrorContains(t, err, "no summary results to process")
125+
})
126+
127+
t.Run("one critical finding should create summary data, implying exit code 1", func(t *testing.T) {
128+
testResult := &mockTestResult{
129+
rawSummary: &testapi.FindingSummary{
130+
Count: 1,
131+
CountBy: &map[string]map[string]uint32{
132+
"severity": {"critical": 1},
133+
},
134+
},
135+
effectiveSummary: &testapi.FindingSummary{
136+
Count: 1,
137+
CountBy: &map[string]map[string]uint32{
138+
"severity": {"critical": 1},
139+
},
140+
},
141+
}
142+
143+
data, err := ostest.NewSummaryData(testResult, &logger, path)
144+
require.NoError(t, err)
145+
require.NotNil(t, data)
146+
147+
assert.Equal(t, content_type.TEST_SUMMARY, data.GetContentType())
148+
149+
var summary json_schemas.TestSummary
150+
payload, ok := data.GetPayload().([]byte)
151+
require.True(t, ok)
152+
err = json.Unmarshal(payload, &summary)
153+
require.NoError(t, err)
154+
155+
assert.Equal(t, "open-source", summary.Type)
156+
assert.Equal(t, path, summary.Path)
157+
require.Len(t, summary.Results, 1)
158+
assert.Equal(t, "critical", summary.Results[0].Severity)
159+
assert.Equal(t, 1, summary.Results[0].Total)
160+
assert.Equal(t, 1, summary.Results[0].Open)
161+
assert.Equal(t, 0, summary.Results[0].Ignored)
162+
})
163+
164+
t.Run("multiple findings with ignored", func(t *testing.T) {
165+
testResult := &mockTestResult{
166+
rawSummary: &testapi.FindingSummary{
167+
Count: 3,
168+
CountBy: &map[string]map[string]uint32{
169+
"severity": {
170+
"high": 2,
171+
"medium": 1,
172+
},
173+
},
174+
},
175+
effectiveSummary: &testapi.FindingSummary{
176+
Count: 1,
177+
CountBy: &map[string]map[string]uint32{
178+
"severity": {
179+
"high": 1,
180+
},
181+
},
182+
},
183+
}
184+
185+
data, err := ostest.NewSummaryData(testResult, &logger, path)
186+
require.NoError(t, err)
187+
require.NotNil(t, data)
188+
189+
// Verify content type is set correctly
190+
assert.Equal(t, content_type.TEST_SUMMARY, data.GetContentType())
191+
192+
var summary json_schemas.TestSummary
193+
payload, ok := data.GetPayload().([]byte)
194+
require.True(t, ok)
195+
err = json.Unmarshal(payload, &summary)
196+
require.NoError(t, err)
197+
198+
require.Len(t, summary.Results, 2)
199+
// Results are sorted by severity descending
200+
assert.Equal(t, "high", summary.Results[0].Severity)
201+
assert.Equal(t, 2, summary.Results[0].Total)
202+
assert.Equal(t, 1, summary.Results[0].Open)
203+
assert.Equal(t, 1, summary.Results[0].Ignored)
204+
205+
assert.Equal(t, "medium", summary.Results[1].Severity)
206+
assert.Equal(t, 1, summary.Results[1].Total)
207+
assert.Equal(t, 0, summary.Results[1].Open)
208+
assert.Equal(t, 1, summary.Results[1].Ignored)
209+
})
210+
211+
t.Run("summary is nil", func(t *testing.T) {
212+
testResult := &mockTestResult{
213+
rawSummary: nil,
214+
effectiveSummary: nil,
215+
}
216+
217+
data, err := ostest.NewSummaryData(testResult, &logger, path)
218+
assert.Nil(t, data)
219+
assert.Error(t, err)
220+
assert.Contains(t, err.Error(), "test result missing summary information")
221+
})
222+
223+
t.Run("newWorkflowData creates correct content types", func(t *testing.T) {
224+
// Test legacy findings content type
225+
legacyData := []byte(`{"findings": "data"}`)
226+
legacyWorkflowData := ostest.NewWorkflowData(ostest.ApplicationJSONContentType, legacyData)
227+
assert.Equal(t, ostest.ApplicationJSONContentType, legacyWorkflowData.GetContentType())
228+
assert.Equal(t, legacyData, legacyWorkflowData.GetPayload())
229+
230+
// Test summary content type
231+
summaryData := []byte(`{"summary": "data"}`)
232+
summaryWorkflowData := ostest.NewWorkflowData(content_type.TEST_SUMMARY, summaryData)
233+
assert.Equal(t, content_type.TEST_SUMMARY, summaryWorkflowData.GetContentType())
234+
assert.Equal(t, summaryData, summaryWorkflowData.GetPayload())
235+
})
236+
}

internal/legacy/transform/transform.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,7 @@ func ConvertSnykSchemaFindingsToLegacyJSON(params *SnykSchemaToLegacyParams) (js
211211
DisplayTargetFile: path,
212212
DependencyCount: int64(params.DepCount),
213213
Vulnerabilities: []definitions.Vulnerability{},
214+
Ok: len(params.Findings) == 0,
214215
}
215216

216217
for _, finding := range params.Findings {

0 commit comments

Comments
 (0)