diff --git a/docs/cmd/integration-jfrog-run.md b/docs/cmd/integration-jfrog-run.md index 49481ac..05de97a 100644 --- a/docs/cmd/integration-jfrog-run.md +++ b/docs/cmd/integration-jfrog-run.md @@ -8,7 +8,7 @@ blocked for all developers using that JFrog instance. ## Synopsis ``` -safedep integration jfrog run --instance-url --instance-access-token +safedep integration jfrog run --instance-url --insecure-instance-access-token ``` ## Quick start @@ -17,23 +17,28 @@ safedep integration jfrog run --instance-url --instance-access-token maxIssueIDLen { - drytui.Warning("Skipping report %s: issue id %q exceeds JFrog %d-char limit", report.GetReportId(), id, maxIssueIDLen) - return jfrogEvent{}, false + return jfrogEvent{}, fmt.Sprintf("issue id %q exceeds JFrog %d-char limit", id, maxIssueIDLen), false } // XRay summary is a synthesized headline, not the feed's title. The feed @@ -257,7 +257,7 @@ func buildEvent(report *threatintelv1.PackageReport) (jfrogEvent, bool) { VulnerableVersions: vulnerableVersionRanges(pkg.GetVersions()), }}, Sources: []jfrogSource{{SourceID: "safedep-threat-intel"}}, - }, true + }, "", true } // do issues a single XRay request with the standard headers and bounded diff --git a/internal/cmd/integration/jfrog/client_test.go b/internal/cmd/integration/jfrog/client_test.go index dcd43ba..d442de7 100644 --- a/internal/cmd/integration/jfrog/client_test.go +++ b/internal/cmd/integration/jfrog/client_test.go @@ -62,7 +62,7 @@ func newJFrogMock(t *testing.T, status int, respBody string) (*httptest.Server, func TestPush_HappyPath_ConstructsCorrectRequest(t *testing.T) { srv, cap := newJFrogMock(t, http.StatusCreated, "") - c := newJFrogClient(jfrogConfig{url: srv.URL, accessToken: "TOK"}) + c := newJFrogClient(jfrogConfig{url: srv.URL, accessToken: "TOK"}, newReporter(nil)) report := newTestReport("01KR0EKN6PMW0ZRFRN992H1PKX", "make-array", packagev1.Ecosystem_ECOSYSTEM_NPM, "0.1.2") _, status, err := c.pushMaliciousPackage(context.Background(), report) @@ -110,7 +110,7 @@ func TestPush_HappyPath_ConstructsCorrectRequest(t *testing.T) { func TestPush_SummarySynthesized_DescriptionFromFeed(t *testing.T) { srv, cap := newJFrogMock(t, http.StatusCreated, "") - c := newJFrogClient(jfrogConfig{url: srv.URL, accessToken: "TOK"}) + c := newJFrogClient(jfrogConfig{url: srv.URL, accessToken: "TOK"}, newReporter(nil)) report := newTestReport("01KR0EKN6PMW0ZRFRN992H1PKX", "secretkey-2fa", packagev1.Ecosystem_ECOSYSTEM_NPM, "1.0.0") report.SetTitle("secretkey-2fa exfiltrates 2FA secrets") @@ -133,7 +133,7 @@ func TestPush_SummarySynthesized_DescriptionFromFeed(t *testing.T) { func TestPush_MultipleVersions_OneComponentManyRanges(t *testing.T) { srv, cap := newJFrogMock(t, http.StatusCreated, "") - c := newJFrogClient(jfrogConfig{url: srv.URL, accessToken: "TOK"}) + c := newJFrogClient(jfrogConfig{url: srv.URL, accessToken: "TOK"}, newReporter(nil)) report := newTestReport("01KR0EKN6PMW0ZRFRN992H1PKX", "express-logger-pro", packagev1.Ecosystem_ECOSYSTEM_NPM, "9.9.9", "9.9.10", "2.0.0") @@ -153,7 +153,7 @@ func TestPush_MultipleVersions_OneComponentManyRanges(t *testing.T) { func TestPush_EmptyVersions_OpenRange(t *testing.T) { srv, cap := newJFrogMock(t, http.StatusCreated, "") - c := newJFrogClient(jfrogConfig{url: srv.URL, accessToken: "TOK"}) + c := newJFrogClient(jfrogConfig{url: srv.URL, accessToken: "TOK"}, newReporter(nil)) // Empty versions means every version is affected. It is NOT skipped. report := newTestReport("01KR0EKN6PMW0ZRFRN992H1PKX", "evil", packagev1.Ecosystem_ECOSYSTEM_PYPI) @@ -171,7 +171,7 @@ func TestPush_EmptyVersions_OpenRange(t *testing.T) { func TestPush_TrimsTrailingSlashFromURL(t *testing.T) { srv, cap := newJFrogMock(t, http.StatusCreated, "") - c := newJFrogClient(jfrogConfig{url: srv.URL + "/", accessToken: "TOK"}) + c := newJFrogClient(jfrogConfig{url: srv.URL + "/", accessToken: "TOK"}, newReporter(nil)) report := newTestReport("01KR0EKN6PMW0ZRFRN992H1PKX", "foo", packagev1.Ecosystem_ECOSYSTEM_NPM, "1.0.0") _, _, err := c.pushMaliciousPackage(context.Background(), report) @@ -184,7 +184,7 @@ func TestPush_TrimsTrailingSlashFromURL(t *testing.T) { func TestPush_NonSuccessStatus_ReturnsErrorWithBody(t *testing.T) { srv, _ := newJFrogMock(t, http.StatusUnauthorized, `{"error":"Bad Credentials"}`) - c := newJFrogClient(jfrogConfig{url: srv.URL, accessToken: "bad"}) + c := newJFrogClient(jfrogConfig{url: srv.URL, accessToken: "bad"}, newReporter(nil)) report := newTestReport("01KR0EKN6PMW0ZRFRN992H1PKX", "foo", packagev1.Ecosystem_ECOSYSTEM_NPM, "1.0.0") _, status, err := c.pushMaliciousPackage(context.Background(), report) @@ -200,7 +200,7 @@ func TestPush_AlreadyExists400IsBenign(t *testing.T) { // it does not upsert on a duplicate id. This is the desired state, so it is // benign: status returned, no error, mirroring a delete 404. srv, _ := newJFrogMock(t, http.StatusBadRequest, `{"error":"Vulnerability already exists"}`) - c := newJFrogClient(jfrogConfig{url: srv.URL, accessToken: "TOK"}) + c := newJFrogClient(jfrogConfig{url: srv.URL, accessToken: "TOK"}, newReporter(nil)) report := newTestReport("01KR0EKN6PMW0ZRFRN992H1PKX", "foo", packagev1.Ecosystem_ECOSYSTEM_NPM, "1.0.0") id, status, err := c.pushMaliciousPackage(context.Background(), report) @@ -213,7 +213,7 @@ func TestPush_AlreadyExists400IsBenign(t *testing.T) { func TestPush_BadRequestOther_ReturnsError(t *testing.T) { // A 400 that is not "already exists" is a real error, not benign. srv, _ := newJFrogMock(t, http.StatusBadRequest, `{"error":"malformed payload"}`) - c := newJFrogClient(jfrogConfig{url: srv.URL, accessToken: "TOK"}) + c := newJFrogClient(jfrogConfig{url: srv.URL, accessToken: "TOK"}, newReporter(nil)) report := newTestReport("01KR0EKN6PMW0ZRFRN992H1PKX", "foo", packagev1.Ecosystem_ECOSYSTEM_NPM, "1.0.0") _, status, err := c.pushMaliciousPackage(context.Background(), report) @@ -256,7 +256,7 @@ func TestPush_SkipConditions_ReturnZeroStatusNoCallNoError(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { srv, cap := newJFrogMock(t, http.StatusCreated, "") - c := newJFrogClient(jfrogConfig{url: srv.URL, accessToken: "TOK"}) + c := newJFrogClient(jfrogConfig{url: srv.URL, accessToken: "TOK"}, newReporter(nil)) _, status, err := c.pushMaliciousPackage(context.Background(), tt.makeReport()) @@ -269,7 +269,7 @@ func TestPush_SkipConditions_ReturnZeroStatusNoCallNoError(t *testing.T) { func TestDelete_HappyPath_IssuesDeleteToEventID(t *testing.T) { srv, cap := newJFrogMock(t, http.StatusOK, "") - c := newJFrogClient(jfrogConfig{url: srv.URL, accessToken: "TOK"}) + c := newJFrogClient(jfrogConfig{url: srv.URL, accessToken: "TOK"}, newReporter(nil)) report := newTestReport("01KR0EKN6PMW0ZRFRN992H1PKX", "make-array", packagev1.Ecosystem_ECOSYSTEM_NPM, "0.1.2") id, status, err := c.deleteMaliciousPackage(context.Background(), report) @@ -286,7 +286,7 @@ func TestDelete_HappyPath_IssuesDeleteToEventID(t *testing.T) { func TestDelete_NotFoundIsBenign(t *testing.T) { srv, cap := newJFrogMock(t, http.StatusNotFound, `{"error":"not found"}`) - c := newJFrogClient(jfrogConfig{url: srv.URL, accessToken: "TOK"}) + c := newJFrogClient(jfrogConfig{url: srv.URL, accessToken: "TOK"}, newReporter(nil)) report := newTestReport("01KR0EKN6PMW0ZRFRN992H1PKX", "foo", packagev1.Ecosystem_ECOSYSTEM_NPM, "1.0.0") id, status, err := c.deleteMaliciousPackage(context.Background(), report) @@ -299,7 +299,7 @@ func TestDelete_NotFoundIsBenign(t *testing.T) { func TestDelete_ServerErrorReturnsError(t *testing.T) { srv, _ := newJFrogMock(t, http.StatusInternalServerError, `{"error":"boom"}`) - c := newJFrogClient(jfrogConfig{url: srv.URL, accessToken: "TOK"}) + c := newJFrogClient(jfrogConfig{url: srv.URL, accessToken: "TOK"}, newReporter(nil)) report := newTestReport("01KR0EKN6PMW0ZRFRN992H1PKX", "foo", packagev1.Ecosystem_ECOSYSTEM_NPM, "1.0.0") _, status, err := c.deleteMaliciousPackage(context.Background(), report) @@ -312,7 +312,7 @@ func TestDelete_ServerErrorReturnsError(t *testing.T) { func TestDelete_OverLengthIDSkipsNoCall(t *testing.T) { srv, cap := newJFrogMock(t, http.StatusOK, "") - c := newJFrogClient(jfrogConfig{url: srv.URL, accessToken: "TOK"}) + c := newJFrogClient(jfrogConfig{url: srv.URL, accessToken: "TOK"}, newReporter(nil)) // "SD-" + 30 chars is one over the limit, so it was never pushed. report := newTestReport(strings.Repeat("A", 30), "foo", packagev1.Ecosystem_ECOSYSTEM_NPM, "1.0.0") diff --git a/internal/cmd/integration/jfrog/cmd_test.go b/internal/cmd/integration/jfrog/cmd_test.go index 8b68fa1..b7fb5af 100644 --- a/internal/cmd/integration/jfrog/cmd_test.go +++ b/internal/cmd/integration/jfrog/cmd_test.go @@ -34,7 +34,7 @@ func TestRegister_buildsJFrogTree(t *testing.T) { assert.NotEmpty(t, leaf.Short) assert.NotEmpty(t, leaf.Long) assert.NotNil(t, leaf.Flags().Lookup("instance-url")) - assert.NotNil(t, leaf.Flags().Lookup("instance-access-token")) + assert.NotNil(t, leaf.Flags().Lookup("insecure-instance-access-token")) assert.NotNil(t, leaf.Flags().Lookup("poll-interval")) assert.NotNil(t, leaf.Flags().Lookup("backfill")) assert.NotNil(t, leaf.Flags().Lookup("dry-run")) @@ -97,8 +97,8 @@ func TestResolveConfig(t *testing.T) { wantErr: true, }, { - // time.After(0) fires immediately — would turn the poll loop - // into a tight hammer on the SafeDep API. Must reject. + // time.After(0) fires immediately. It would turn the poll loop + // into a busy loop on the SafeDep API. Must reject. name: "zero poll interval rejected", in: runInput{ InstanceURL: "https://example.jfrog.io", diff --git a/internal/cmd/integration/jfrog/printclient.go b/internal/cmd/integration/jfrog/printclient.go index bc97a49..c54daad 100644 --- a/internal/cmd/integration/jfrog/printclient.go +++ b/internal/cmd/integration/jfrog/printclient.go @@ -10,44 +10,68 @@ import ( var _ xrayClient = (*printClient)(nil) // printClient is the dry-run adapter. It builds the event the real client would -// push, via the shared buildEvent, then prints it instead of sending. It holds -// no state and never opens a connection, so it needs no JFrog credentials. -type printClient struct{} +// push, with the shared buildEvent, then previews it instead of sending. The +// preview is a user-facing result, so it goes through the reporter. The dry-run +// banner and any skip are operational logs. +type printClient struct { + rep *reporter +} -func newPrintClient() *printClient { return &printClient{} } +func newPrintClient(rep *reporter) *printClient { return &printClient{rep: rep} } func (c *printClient) validate(_ context.Context) error { - drytui.Info("Dry run: previewing the feed, nothing is sent to JFrog (no JFrog credentials needed)") + c.rep.logInfo("Dry run: previewing the feed, nothing is sent to JFrog (no JFrog credentials needed)") return nil } -// pushMaliciousPackage builds the event the real push would send, prints it, and -// returns status 0 so the service's handlePush stays quiet (this method already -// logged the preview line). A skipped report returns ("", 0, nil), matching the -// real client. +// pushMaliciousPackage previews the push and returns status 0 so the service's +// handlePush stays quiet (this method already reported the preview). A skipped +// report returns ("", 0, nil), matching the real client. func (c *printClient) pushMaliciousPackage(_ context.Context, report *threatintelv1.PackageReport) (string, int, error) { - event, ok := buildEvent(report) + event, reason, ok := buildEvent(report) if !ok { - // buildEvent already logged why it is skipped. + logSkip(c.rep, report, reason) return "", 0, nil } - versions := displayVersions(report.GetPackage().GetVersions()) - drytui.Success("Would push: %s (%s) versions: %s", report.GetPackage().GetName(), event.PackageType, versions) - drytui.Info(" JFrog issue id: %s", event.ID) + name := report.GetPackage().GetName() + c.rep.result( + func() { + drytui.Success("Would push: %s (%s) versions: %s", name, event.PackageType, displayVersions(report.GetPackage().GetVersions())) + drytui.Info(" JFrog issue id: %s", event.ID) + }, + jsonEvent{ + Event: eventDryRunPush, + ReportID: report.GetReportId(), + Package: name, + Ecosystem: event.PackageType, + Versions: cleanVersions(report.GetPackage().GetVersions()), + IssueID: event.ID, + }) return event.ID, 0, nil } -// deleteMaliciousPackage prints what the real delete would remove and sends -// nothing. It returns status 0 so the service stays quiet (this already logged -// the preview). A skipped id returns ("", 0, nil), matching the real client. +// deleteMaliciousPackage previews the delete and sends nothing. It returns +// status 0 so the service stays quiet. A skipped id returns ("", 0, nil). func (c *printClient) deleteMaliciousPackage(_ context.Context, report *threatintelv1.PackageReport) (string, int, error) { id := issueID(report) if len(id) > maxIssueIDLen { return "", 0, nil } - drytui.Success("Would delete: %s (%s)", report.GetPackage().GetName(), ecosystemToJFrog(report.GetEcosystem())) - drytui.Info(" JFrog issue id: %s", id) + name := report.GetPackage().GetName() + eco := ecosystemToJFrog(report.GetEcosystem()) + c.rep.result( + func() { + drytui.Success("Would delete: %s (%s)", name, eco) + drytui.Info(" JFrog issue id: %s", id) + }, + jsonEvent{ + Event: eventDryRunDelete, + ReportID: report.GetReportId(), + Package: name, + Ecosystem: eco, + IssueID: id, + }) return id, 0, nil } diff --git a/internal/cmd/integration/jfrog/printclient_test.go b/internal/cmd/integration/jfrog/printclient_test.go index e679fba..3c3aa0a 100644 --- a/internal/cmd/integration/jfrog/printclient_test.go +++ b/internal/cmd/integration/jfrog/printclient_test.go @@ -13,7 +13,7 @@ import ( func TestPrintClient_NeverSendsAndNeedsNoCreds(t *testing.T) { // Empty config, no server: if the print client reached JFrog it would // error. It must not, because it only builds the event and prints. - c := newPrintClient() + c := newPrintClient(newReporter(nil)) require.NoError(t, c.validate(context.Background())) @@ -25,7 +25,7 @@ func TestPrintClient_NeverSendsAndNeedsNoCreds(t *testing.T) { } func TestPrintClient_SkippedReportReturnsZeroNoError(t *testing.T) { - c := newPrintClient() + c := newPrintClient(newReporter(nil)) // An id of "SD-" + a 30-char report id is one over the JFrog limit, so // buildEvent skips it. The print client mirrors the real client's skip. @@ -37,7 +37,7 @@ func TestPrintClient_SkippedReportReturnsZeroNoError(t *testing.T) { } func TestPrintClient_DeletePreviewsAndNeverSends(t *testing.T) { - c := newPrintClient() + c := newPrintClient(newReporter(nil)) report := newTestReport("01KR0EKN6PMW0ZRFRN992H1PKX", "make-array", packagev1.Ecosystem_ECOSYSTEM_NPM, "0.1.2") id, status, err := c.deleteMaliciousPackage(context.Background(), report) diff --git a/internal/cmd/integration/jfrog/reporter.go b/internal/cmd/integration/jfrog/reporter.go new file mode 100644 index 0000000..6fc1fcf --- /dev/null +++ b/internal/cmd/integration/jfrog/reporter.go @@ -0,0 +1,106 @@ +package jfrog + +import ( + "encoding/json" + "fmt" + + threatintelv1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/threatintel/v1" + "github.com/safedep/cli/internal/tui" + "github.com/safedep/dry/log" + drytui "github.com/safedep/dry/tui" + tuioutput "github.com/safedep/dry/tui/output" + "github.com/safedep/dry/tui/style" +) + +// jsonEvent names for the -o json output stream. Only user-facing results reach +// this stream. A result is a real state change in XRay, or a dry-run preview of +// one. +const ( + eventPushed = "package_pushed" + eventDeleted = "package_deleted" + eventDryRunPush = "dry_run_package_push" + eventDryRunDelete = "dry_run_package_delete" +) + +// jsonEvent is one JSONL record on stdout. Fields are omitempty so each record +// carries only what it has. +type jsonEvent struct { + Event string `json:"event"` + ReportID string `json:"report_id,omitempty"` + Package string `json:"package,omitempty"` + Ecosystem string `json:"ecosystem,omitempty"` + Versions []string `json:"versions,omitempty"` + IssueID string `json:"issue_id,omitempty"` + Status int `json:"status,omitempty"` +} + +func (e jsonEvent) RenderJSON() ([]byte, error) { return json.Marshal(e) } +func (e jsonEvent) RenderTable() string { return e.Event } +func (e jsonEvent) RenderPlain() string { return e.Event } + +// reporter sends daemon activity to the right stream for the active output +// mode. With -o json the user asked for machine output. So it writes only result +// events, as JSONL on stdout, and drops every log line. In any other mode it +// writes nothing to stdout. It sends results and logs to stderr as drytui lines, +// the same as the rest of the CLI. +type reporter struct { + out *tui.Printer + json bool +} + +func newReporter(out *tui.Printer) *reporter { + return &reporter{out: out, json: out != nil && out.Mode() == tui.ModeJSON} +} + +// result reports one user-facing result. With -o json it writes a JSONL record +// to stdout. In any other mode it runs the human drytui line. +func (r *reporter) result(human func(), ev jsonEvent) { + if r.json { + if err := r.out.Print(ev); err != nil { + // A stdout write failure is not actionable by the operator, but it + // must not be swallowed silently. + log.Warnf("integration jfrog: emit json event: %v", err) + } + return + } + human() +} + +// The log* methods are for operational messages. They print to stderr in human +// modes. They print nothing under -o json. + +func (r *reporter) logInfo(format string, a ...any) { + if r.json { + return + } + drytui.Info(format, a...) +} + +func (r *reporter) logSuccess(format string, a ...any) { + if r.json { + return + } + drytui.Success(format, a...) +} + +func (r *reporter) logWarn(format string, a ...any) { + if r.json { + return + } + drytui.Warning(format, a...) +} + +// logDim prints a dimmed line at normal verbosity. drytui.Faint shows a line +// only with --verbose. logDim shows it always in human modes. Use it for +// frequent no-ops that should stay visible but quiet. +func (r *reporter) logDim(format string, a ...any) { + if r.json { + return + } + _, _ = fmt.Fprintln(tuioutput.Stderr(), style.Faint(fmt.Sprintf(format, a...))) +} + +// logSkip reports a skipped report identically from both clients. +func logSkip(r *reporter, report *threatintelv1.PackageReport, reason string) { + r.logWarn("Skipping report %s: %s", report.GetReportId(), reason) +} diff --git a/internal/cmd/integration/jfrog/reporter_test.go b/internal/cmd/integration/jfrog/reporter_test.go new file mode 100644 index 0000000..486931c --- /dev/null +++ b/internal/cmd/integration/jfrog/reporter_test.go @@ -0,0 +1,203 @@ +package jfrog + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "net/http" + "os" + "strings" + "testing" + + packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1" + "github.com/safedep/cli/internal/tui" + tuioutput "github.com/safedep/dry/tui/output" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// captureStreams redirects the shared dry/tui stdout and stderr to buffers for +// the test, restoring the real writers on cleanup. It returns (stdout, stderr) +// so a test can assert that -o json writes results to stdout and nothing to +// stderr. +func captureStreams(t *testing.T) (*bytes.Buffer, *bytes.Buffer) { + t.Helper() + var out, errb bytes.Buffer + tuioutput.SetWriters(&out, &errb) + t.Cleanup(func() { tuioutput.SetWriters(os.Stdout, os.Stderr) }) + return &out, &errb +} + +// jsonLines splits captured JSONL into non-empty trimmed lines. +func jsonLines(t *testing.T, s string) []map[string]any { + t.Helper() + var events []map[string]any + for _, line := range strings.Split(strings.TrimRight(s, "\n"), "\n") { + if line == "" { + continue + } + var m map[string]any + require.NoError(t, json.Unmarshal([]byte(line), &m), "each stdout line must be valid JSON: %q", line) + events = append(events, m) + } + return events +} + +func jsonReporter() *reporter { return newReporter(tui.NewPrinter(tui.ModeJSON)) } + +func TestJSONEvent_MarshalsFlatOmitEmpty(t *testing.T) { + b, err := jsonEvent{Event: eventPushed, Package: "foo", IssueID: "SD-1", Status: 201}.RenderJSON() + require.NoError(t, err) + + var m map[string]any + require.NoError(t, json.Unmarshal(b, &m)) + assert.Equal(t, eventPushed, m["event"]) + assert.Equal(t, "foo", m["package"]) + assert.Equal(t, "SD-1", m["issue_id"]) + assert.EqualValues(t, 201, m["status"]) + _, hasVersions := m["versions"] + assert.False(t, hasVersions, "empty fields must be omitted") +} + +func TestNewReporter_JSONByMode(t *testing.T) { + assert.True(t, newReporter(tui.NewPrinter(tui.ModeJSON)).json) + assert.False(t, newReporter(tui.NewPrinter(tui.ModeTable)).json) + assert.False(t, newReporter(tui.NewPrinter(tui.ModePlain)).json) + assert.False(t, newReporter(nil).json, "nil printer must not panic and defaults to human") +} + +// TestReporter_JSON_ResultOnlyOnStdout is the core of abhisek's ask: under +// -o json, results go to stdout as JSONL and every log is suppressed, so +// nothing lands on stderr. +func TestReporter_JSON_ResultOnlyOnStdout(t *testing.T) { + out, errb := captureStreams(t) + r := jsonReporter() + + r.logInfo("connectivity ok") + r.logSuccess("pushed something") + r.logWarn("transient error") + r.logDim("already pushed") + assert.Empty(t, out.String(), "logs must not reach stdout") + assert.Empty(t, errb.String(), "logs must be suppressed entirely under -o json") + + r.result(func() { t.Fatal("human closure must not run under -o json") }, + jsonEvent{Event: eventPushed, Package: "a"}) + + events := jsonLines(t, out.String()) + require.Len(t, events, 1) + assert.Equal(t, eventPushed, events[0]["event"]) + assert.Empty(t, errb.String(), "a result writes JSON to stdout, nothing to stderr") +} + +// TestReporter_Human_LogsToStderrNotStdout confirms human mode is unchanged: +// logs go to stderr, stdout stays empty. +func TestReporter_Human_LogsToStderrNotStdout(t *testing.T) { + out, errb := captureStreams(t) + r := newReporter(tui.NewPrinter(tui.ModeTable)) + + r.logInfo("hello world") + assert.Empty(t, out.String(), "human logs never touch stdout") + assert.Contains(t, errb.String(), "hello world") + + ran := false + r.result(func() { ran = true }, jsonEvent{Event: eventPushed}) + assert.True(t, ran, "human mode runs the result closure") + assert.Empty(t, out.String(), "human mode writes no JSON to stdout") +} + +// TestService_JSON_OnlyStateChanges asserts that under -o json only a real +// state change reaches stdout, and no-ops produce nothing on any stream. +func TestService_JSON_OnlyStateChanges(t *testing.T) { + tests := []struct { + name string + fake *fakeXrayClient + withdrawn bool + wantEvent string // "" means no output at all (it is a suppressed log) + }{ + {name: "pushed is output", fake: &fakeXrayClient{pushStat: http.StatusCreated}, wantEvent: eventPushed}, + {name: "deleted is output", fake: &fakeXrayClient{delStat: http.StatusOK}, withdrawn: true, wantEvent: eventDeleted}, + {name: "already pushed is suppressed", fake: &fakeXrayClient{pushStat: http.StatusBadRequest}, wantEvent: ""}, + {name: "does not exist is suppressed", fake: &fakeXrayClient{delStat: http.StatusNotFound}, withdrawn: true, wantEvent: ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + out, errb := captureStreams(t) + svc := newFeedService(nil, tt.fake, jsonReporter()) + + report := newTestReport("r-1", "pkg-a", packagev1.Ecosystem_ECOSYSTEM_NPM, "1.0.0") + report.SetWithdrawn(tt.withdrawn) + require.NoError(t, svc.handleRecord(context.Background(), report)) + + assert.Empty(t, errb.String(), "under -o json nothing is written to stderr") + events := jsonLines(t, out.String()) + if tt.wantEvent == "" { + assert.Empty(t, events, "an operational no-op must not reach the json output") + return + } + require.Len(t, events, 1) + assert.Equal(t, tt.wantEvent, events[0]["event"]) + assert.Equal(t, "pkg-a", events[0]["package"]) + assert.Equal(t, "SD-r-1", events[0]["issue_id"]) + }) + } +} + +func TestService_JSON_FailureIsSuppressed(t *testing.T) { + out, errb := captureStreams(t) + svc := newFeedService(nil, &fakeXrayClient{pushErr: errors.New("boom")}, jsonReporter()) + + report := newTestReport("r-1", "pkg-a", packagev1.Ecosystem_ECOSYSTEM_NPM, "1.0.0") + require.NoError(t, svc.handleRecord(context.Background(), report)) + + assert.Empty(t, out.String(), "a failure is a log, not output") + assert.Empty(t, errb.String(), "under -o json a failure log is suppressed") +} + +// TestService_Human_NoOpShownDim confirms the no-op now shows in human mode +// without --verbose (it moved from Faint to a dim line at normal verbosity). +func TestService_Human_NoOpShownDim(t *testing.T) { + out, errb := captureStreams(t) + svc := newFeedService(nil, &fakeXrayClient{pushStat: http.StatusBadRequest}, newReporter(tui.NewPrinter(tui.ModeTable))) + + report := newTestReport("r-1", "pkg-a", packagev1.Ecosystem_ECOSYSTEM_NPM, "1.0.0") + require.NoError(t, svc.handleRecord(context.Background(), report)) + + assert.Empty(t, out.String(), "a no-op is a log, never stdout") + assert.Contains(t, errb.String(), "Already pushed", "the no-op is shown (dimmed) at normal verbosity") +} + +func TestPrintClient_JSON_EmitsDryRunPush(t *testing.T) { + out, errb := captureStreams(t) + c := newPrintClient(jsonReporter()) + + report := newTestReport("r-1", "make-array", packagev1.Ecosystem_ECOSYSTEM_NPM, "0.1.2") + id, status, err := c.pushMaliciousPackage(context.Background(), report) + require.NoError(t, err) + assert.Equal(t, "SD-r-1", id) + assert.Equal(t, 0, status) + + assert.Empty(t, errb.String()) + events := jsonLines(t, out.String()) + require.Len(t, events, 1) + assert.Equal(t, eventDryRunPush, events[0]["event"]) + assert.Equal(t, "make-array", events[0]["package"]) + assert.Equal(t, "npm", events[0]["ecosystem"]) + assert.Equal(t, []any{"0.1.2"}, events[0]["versions"]) +} + +func TestClient_JSON_SkipSuppressed(t *testing.T) { + out, errb := captureStreams(t) + c := newJFrogClient(jfrogConfig{url: "https://unused.example", accessToken: "TOK"}, jsonReporter()) + + // "SD-" + 30 chars is one over the JFrog id limit, so buildEvent skips it + // before any HTTP call. The skip is a log, suppressed under -o json. + report := newTestReport(strings.Repeat("A", 30), "foo", packagev1.Ecosystem_ECOSYSTEM_NPM, "1.0.0") + _, status, err := c.pushMaliciousPackage(context.Background(), report) + require.NoError(t, err) + assert.Equal(t, 0, status) + + assert.Empty(t, out.String(), "a skip is a log, not output") + assert.Empty(t, errb.String(), "under -o json the skip log is suppressed") +} diff --git a/internal/cmd/integration/jfrog/run.go b/internal/cmd/integration/jfrog/run.go index f3f0e40..61e8080 100644 --- a/internal/cmd/integration/jfrog/run.go +++ b/internal/cmd/integration/jfrog/run.go @@ -60,17 +60,20 @@ func runCmd(a *app.App) *cobra.Command { svc := threatintelv1grpc.NewThreatIntelServiceClient(client.Connection()) - source, xc, err := buildSourceAndClient(a, svc, cfg) + rep := newReporter(a.Output) + source, xc, err := buildSourceAndClient(a, svc, cfg, rep) if err != nil { return err } - return newFeedService(source, xc).run(cmd.Context()) + return newFeedService(source, xc, rep).run(cmd.Context()) }, } cmd.Flags().StringVar(&in.InstanceURL, "instance-url", "", "JFrog instance URL (or "+envJFrogURL+")") - cmd.Flags().StringVar(&in.InstanceAccessToken, "instance-access-token", "", "JFrog access token (or "+envJFrogToken+")") + // A token on the command line is saved in the shell history and shown in the + // process list. So the flag name says insecure. Prefer the env var. + cmd.Flags().StringVar(&in.InstanceAccessToken, "insecure-instance-access-token", "", "JFrog access token, insecure (prefer "+envJFrogToken+")") cmd.Flags().DurationVar(&in.PollInterval, "poll-interval", 5*time.Minute, "sleep duration between feed drains") cmd.Flags().DurationVar(&in.Backfill, "backfill", 0, "first-run window to seed the cursor (e.g. 24h, 168h); 0 starts fresh from now") cmd.Flags().BoolVar(&in.DryRun, "dry-run", false, "preview the feed and print what would be pushed, without sending to JFrog (no JFrog credentials needed)") @@ -83,7 +86,7 @@ func runCmd(a *app.App) *cobra.Command { // profile-scoped cursor: a dry-run tests the pipeline as-is and differs only in // the client (print instead of JFrog). A dry-run advances the saved cursor, so // run `cursor remove` before the first real run to re-process what it previewed. -func buildSourceAndClient(a *app.App, svc threatintelv1grpc.ThreatIntelServiceClient, cfg cmdConfig) (*feedSource, xrayClient, error) { +func buildSourceAndClient(a *app.App, svc threatintelv1grpc.ThreatIntelServiceClient, cfg cmdConfig, rep *reporter) (*feedSource, xrayClient, error) { // Cursor is stored in the profile-scoped KV store so each SafeDep // credential profile has an independent cursor. Switching --profile // automatically switches the cursor. @@ -92,12 +95,12 @@ func buildSourceAndClient(a *app.App, svc threatintelv1grpc.ThreatIntelServiceCl return nil, nil, fmt.Errorf("run: open cursor store: %w", err) } - source := newFeedSource(svc, kv, cfg.source.pollInterval, cfg.source.backfillWindow) + source := newFeedSource(svc, kv, cfg.source.pollInterval, cfg.source.backfillWindow, rep) if cfg.dryRun { - return source, newPrintClient(), nil + return source, newPrintClient(rep), nil } - return source, newJFrogClient(cfg.jfrog), nil + return source, newJFrogClient(cfg.jfrog, rep), nil } // resolveConfig collapses CLI flags + environment variables into a single @@ -172,7 +175,7 @@ func resolveJFrogConfig(in runInput) (jfrogConfig, error) { token = config.EnvVar(envJFrogToken) } if token == "" { - return jfrogConfig{}, fmt.Errorf("run: --instance-access-token or %s is required", envJFrogToken) + return jfrogConfig{}, fmt.Errorf("run: --insecure-instance-access-token or %s is required", envJFrogToken) } return jfrogConfig{url: url, accessToken: token}, nil diff --git a/internal/cmd/integration/jfrog/service.go b/internal/cmd/integration/jfrog/service.go index ddbe28f..a101f98 100644 --- a/internal/cmd/integration/jfrog/service.go +++ b/internal/cmd/integration/jfrog/service.go @@ -17,10 +17,11 @@ import ( type feedService struct { source packageSource client xrayClient + rep *reporter } -func newFeedService(source packageSource, client xrayClient) *feedService { - return &feedService{source: source, client: client} +func newFeedService(source packageSource, client xrayClient, rep *reporter) *feedService { + return &feedService{source: source, client: client, rep: rep} } // run validates the client once, then blocks in the source until ctx is @@ -53,7 +54,8 @@ func (s *feedService) handleRecord(ctx context.Context, report *threatintelv1.Pa func (s *feedService) handlePush(ctx context.Context, report *threatintelv1.PackageReport) error { id, status, err := s.client.pushMaliciousPackage(ctx, report) if err != nil { - drytui.Warning("Push failed for %s: %v", report.GetReportId(), err) + // An error is operational, so it is a log, not output. + s.rep.logWarn("Push failed for %s: %v", report.GetReportId(), err) return nil } if status == 0 { @@ -61,16 +63,22 @@ func (s *feedService) handlePush(ctx context.Context, report *threatintelv1.Pack } name := report.GetPackage().GetName() + eco := ecosystemToJFrog(report.GetEcosystem()) if status == http.StatusBadRequest { - // Already present in XRay (see pushMaliciousPackage). A benign no-op, - // rendered like a delete "already absent" rather than a failure. - drytui.Info("Already present %s (%s): issue %s in XRay", report.GetReportId(), name, id) + // Already pushed to XRay (see pushMaliciousPackage). Nothing changed, so + // this is a log, not output. It is frequent, so it is dimmed. + s.rep.logDim("Already pushed %s (%s): issue %s", report.GetReportId(), name, id) return nil } - versions := displayVersions(report.GetPackage().GetVersions()) - drytui.Success("Pushed: %s (%s) versions: %s", name, ecosystemToJFrog(report.GetEcosystem()), versions) - drytui.Info(" JFrog: %s [%d]", id, status) + // A real state change (package blocked in XRay). This is user-facing output. + versions := report.GetPackage().GetVersions() + s.rep.result( + func() { + drytui.Success("Pushed: %s (%s) versions: %s", name, eco, displayVersions(versions)) + drytui.Info(" JFrog: %s [%d]", id, status) + }, + jsonEvent{Event: eventPushed, ReportID: report.GetReportId(), Package: name, Ecosystem: eco, Versions: cleanVersions(versions), IssueID: id, Status: status}) return nil } @@ -81,7 +89,8 @@ func (s *feedService) handlePush(ctx context.Context, report *threatintelv1.Pack func (s *feedService) handleDelete(ctx context.Context, report *threatintelv1.PackageReport) error { id, status, err := s.client.deleteMaliciousPackage(ctx, report) if err != nil { - drytui.Warning("Delete failed for %s: %v", report.GetReportId(), err) + // An error is operational, so it is a log, not output. + s.rep.logWarn("Delete failed for %s: %v", report.GetReportId(), err) return nil } if status == 0 { @@ -89,25 +98,41 @@ func (s *feedService) handleDelete(ctx context.Context, report *threatintelv1.Pa } name := report.GetPackage().GetName() + eco := ecosystemToJFrog(report.GetEcosystem()) if status == http.StatusNotFound { - drytui.Info("Withdrawn %s (%s): issue %s already absent in XRay", report.GetReportId(), name, id) + // The issue does not exist in XRay. This is the state the delete wants, + // and nothing changed, so it is a log, not output. It is frequent, so it + // is dimmed. + s.rep.logDim("Package to delete, does not exist %s (%s): issue %s", report.GetReportId(), name, id) return nil } - drytui.Success("Deleted: %s (%s)", name, ecosystemToJFrog(report.GetEcosystem())) - drytui.Info(" JFrog: %s [%d]", id, status) + // A real state change (block removed in XRay). This is user-facing output. + s.rep.result( + func() { + drytui.Success("Deleted: %s (%s)", name, eco) + drytui.Info(" JFrog: %s [%d]", id, status) + }, + jsonEvent{Event: eventDeleted, ReportID: report.GetReportId(), Package: name, Ecosystem: eco, IssueID: id, Status: status}) return nil } -// displayVersions renders affected versions for the log line. Empty means all -// versions, mirroring vulnerableVersionRanges. -func displayVersions(versions []string) string { +// cleanVersions drops empty entries from the affected-version list. An empty +// result means all versions, mirroring vulnerableVersionRanges. +func cleanVersions(versions []string) []string { cleaned := make([]string, 0, len(versions)) for _, v := range versions { if v != "" { cleaned = append(cleaned, v) } } + return cleaned +} + +// displayVersions renders affected versions for the human log line. Empty means +// all versions. +func displayVersions(versions []string) string { + cleaned := cleanVersions(versions) if len(cleaned) == 0 { return "all" } diff --git a/internal/cmd/integration/jfrog/service_test.go b/internal/cmd/integration/jfrog/service_test.go index bcfb9e2..c638913 100644 --- a/internal/cmd/integration/jfrog/service_test.go +++ b/internal/cmd/integration/jfrog/service_test.go @@ -56,7 +56,7 @@ func TestHandleRecord_RoutesWithdrawnToDeleteElsePush(t *testing.T) { withdrawn.SetWithdrawn(true) fake := &fakeXrayClient{} - svc := newFeedService(nil, fake) + svc := newFeedService(nil, fake, newReporter(nil)) require.NoError(t, svc.handleRecord(context.Background(), push)) require.NoError(t, svc.handleRecord(context.Background(), withdrawn)) @@ -70,7 +70,7 @@ func TestHandleRecord_DeleteFailureIsNotFatal(t *testing.T) { withdrawn.SetWithdrawn(true) fake := &fakeXrayClient{delErr: errors.New("boom")} - svc := newFeedService(nil, fake) + svc := newFeedService(nil, fake, newReporter(nil)) // A delete error is logged, never returned: one bad delete cannot stop the // daemon. @@ -82,7 +82,7 @@ func TestHandleRecord_Delete404IsHandled(t *testing.T) { withdrawn.SetWithdrawn(true) fake := &fakeXrayClient{delStat: http.StatusNotFound} - svc := newFeedService(nil, fake) + svc := newFeedService(nil, fake, newReporter(nil)) assert.NoError(t, svc.handleRecord(context.Background(), withdrawn)) assert.Equal(t, []string{"wd-1"}, fake.deleted) @@ -94,7 +94,7 @@ func TestHandleRecord_PushAlreadyPresentIsBenign(t *testing.T) { // A 400 "already exists" surfaces as status 400 with no error, the same // benign shape as a delete 404. It must not stop the daemon. fake := &fakeXrayClient{pushStat: http.StatusBadRequest} - svc := newFeedService(nil, fake) + svc := newFeedService(nil, fake, newReporter(nil)) assert.NoError(t, svc.handleRecord(context.Background(), push)) assert.Equal(t, []string{"push-1"}, fake.pushed) diff --git a/internal/cmd/integration/jfrog/source_feed.go b/internal/cmd/integration/jfrog/source_feed.go index 1186c7e..2005fa9 100644 --- a/internal/cmd/integration/jfrog/source_feed.go +++ b/internal/cmd/integration/jfrog/source_feed.go @@ -12,7 +12,6 @@ import ( threatintelv1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/threatintel/v1" threatintelsvcv1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/services/threatintel/v1" "github.com/safedep/cli/internal/storage" - drytui "github.com/safedep/dry/tui" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" "google.golang.org/protobuf/types/known/timestamppb" @@ -56,31 +55,35 @@ type feedSource struct { cursor *cursorStore pollInterval time.Duration backfillWindow time.Duration + rep *reporter } // feedPageSize matches the server's cap of 100 reports per page. const feedPageSize = 100 -func newFeedSource(svc threatintelv1grpc.ThreatIntelServiceClient, kv *storage.KV[cursorState], pollInterval, backfillWindow time.Duration) *feedSource { +func newFeedSource(svc threatintelv1grpc.ThreatIntelServiceClient, kv *storage.KV[cursorState], pollInterval, backfillWindow time.Duration, rep *reporter) *feedSource { return &feedSource{ svc: svc, cursor: newCursorStore(kv), pollInterval: pollInterval, backfillWindow: backfillWindow, + rep: rep, } } // subscribe drives the feed loop until ctx is cancelled. A bad cycle is logged // and retried, never fatal. func (s *feedSource) subscribe(ctx context.Context, onRecord recordHandler) error { - drytui.Info("Starting JFrog Syncing with SafeDep Threat Intel Feed") + // Feed-loop activity is operational. It is a log: stderr in human modes, + // nothing under -o json. It is never output. + s.rep.logInfo("Starting JFrog Syncing with SafeDep Threat Intel Feed") s.logStartMode(ctx) for { err := s.syncOnce(ctx, onRecord) switch { case err == nil: - drytui.Info("Feed cycle complete at %s, next in %s", time.Now().UTC().Format(time.RFC3339), s.pollInterval) + s.rep.logInfo("Feed cycle complete at %s, next in %s", time.Now().UTC().Format(time.RFC3339), s.pollInterval) case ctx.Err() != nil: return nil case isCallbackError(err): @@ -91,7 +94,7 @@ func (s *feedSource) subscribe(ctx context.Context, onRecord recordHandler) erro return err default: // Transient infra error. Log and retry next cycle. - drytui.Warning("Feed cycle error: %v", err) + s.rep.logWarn("Feed cycle error: %v", err) } select { @@ -111,11 +114,11 @@ func (s *feedSource) logStartMode(ctx context.Context) { } switch { case !state.LastSeenAt.IsZero(): - drytui.Info("Resuming from saved cursor (last update %s)", state.LastSeenAt.UTC().Format(time.RFC3339)) + s.rep.logInfo("Resuming from saved cursor (last update %s)", state.LastSeenAt.UTC().Format(time.RFC3339)) case s.backfillWindow > 0: - drytui.Info("No saved cursor: backfilling reports from the last %s", s.backfillWindow) + s.rep.logInfo("No saved cursor: backfilling reports from the last %s", s.backfillWindow) default: - drytui.Info("No saved cursor: starting fresh from now") + s.rep.logInfo("No saved cursor: starting fresh from now") } } diff --git a/internal/cmd/integration/jfrog/source_feed_test.go b/internal/cmd/integration/jfrog/source_feed_test.go index 715751e..8c3520e 100644 --- a/internal/cmd/integration/jfrog/source_feed_test.go +++ b/internal/cmd/integration/jfrog/source_feed_test.go @@ -145,7 +145,7 @@ func sinceTime(req *threatintelsvcv1.ListPackageReportsRequest) time.Time { func TestSyncOnce_FirstRun_SinceIsNowWithZeroBackfill(t *testing.T) { fake := &fakeThreatIntelClient{queue: []fakeReportsResp{{resp: makeReportsPage(time.Now().UTC(), "")}}} - src := newFeedSource(fake, newTestKV(t), time.Minute, 0) + src := newFeedSource(fake, newTestKV(t), time.Minute, 0, newReporter(nil)) before := time.Now().UTC() handler, _ := drainHandler() @@ -162,7 +162,7 @@ func TestSyncOnce_FirstRun_SinceIsNowWithZeroBackfill(t *testing.T) { func TestSyncOnce_FirstRun_BackfillSeedsSince(t *testing.T) { backfill := 24 * time.Hour fake := &fakeThreatIntelClient{queue: []fakeReportsResp{{resp: makeReportsPage(time.Now().UTC(), "")}}} - src := newFeedSource(fake, newTestKV(t), time.Minute, backfill) + src := newFeedSource(fake, newTestKV(t), time.Minute, backfill, newReporter(nil)) handler, _ := drainHandler() require.NoError(t, src.syncOnce(context.Background(), handler)) @@ -179,7 +179,7 @@ func TestSyncOnce_FirstRun_BackfillSeedsSince(t *testing.T) { func TestSyncOnce_RequestShape_MaliciousAscendingPaged(t *testing.T) { fake := &fakeThreatIntelClient{queue: []fakeReportsResp{{resp: makeReportsPage(time.Now().UTC(), "")}}} - src := newFeedSource(fake, newTestKV(t), time.Minute, 0) + src := newFeedSource(fake, newTestKV(t), time.Minute, 0, newReporter(nil)) handler, _ := drainHandler() require.NoError(t, src.syncOnce(context.Background(), handler)) @@ -203,7 +203,7 @@ func TestSyncOnce_DeliversReports_AdvancesCursorToMaxUpdatedAt(t *testing.T) { )}}} store := newCursorStore(newTestKV(t)) - src := &feedSource{svc: fake, cursor: store, pollInterval: time.Minute} + src := &feedSource{svc: fake, cursor: store, pollInterval: time.Minute, rep: newReporter(nil)} handler, got := drainHandler() require.NoError(t, src.syncOnce(context.Background(), handler)) @@ -229,7 +229,7 @@ func TestSyncOnce_MultiPage_SinceConstantTokensAdvance(t *testing.T) { {resp: makeReportsPage(base, "", reportSpec{id: "c", name: "pkg-c", versions: []string{"3.0"}, updatedOffset: 2 * time.Hour})}, }} - src := &feedSource{svc: fake, cursor: store, pollInterval: time.Minute} + src := &feedSource{svc: fake, cursor: store, pollInterval: time.Minute, rep: newReporter(nil)} handler, got := drainHandler() require.NoError(t, src.syncOnce(context.Background(), handler)) @@ -255,7 +255,7 @@ func TestSyncOnce_WithdrawnDelivered_CursorAdvancesPastIt(t *testing.T) { )}}} store := newCursorStore(newTestKV(t)) - src := &feedSource{svc: fake, cursor: store, pollInterval: time.Minute} + src := &feedSource{svc: fake, cursor: store, pollInterval: time.Minute, rep: newReporter(nil)} handler, got := drainHandler() require.NoError(t, src.syncOnce(context.Background(), handler)) @@ -275,7 +275,7 @@ func TestSyncOnce_GRPCFailure_PropagatesAndKeepsCursor(t *testing.T) { require.NoError(t, store.save(context.Background(), cursorState{LastSeenAt: original})) fake := &fakeThreatIntelClient{queue: []fakeReportsResp{{err: errors.New("grpc unavailable")}}} - src := &feedSource{svc: fake, cursor: store, pollInterval: time.Minute} + src := &feedSource{svc: fake, cursor: store, pollInterval: time.Minute, rep: newReporter(nil)} handler, _ := drainHandler() err := src.syncOnce(context.Background(), handler) @@ -293,7 +293,7 @@ func TestSyncOnce_FirstRunNoReports_AnchorsCursor(t *testing.T) { {resp: makeReportsPage(time.Now().UTC(), "")}, // cycle 2: empty }} store := newCursorStore(newTestKV(t)) - src := &feedSource{svc: fake, cursor: store, pollInterval: time.Minute, backfillWindow: 24 * time.Hour} + src := &feedSource{svc: fake, cursor: store, pollInterval: time.Minute, backfillWindow: 24 * time.Hour, rep: newReporter(nil)} handler, _ := drainHandler() require.NoError(t, src.syncOnce(context.Background(), handler)) @@ -318,7 +318,7 @@ func TestSyncOnce_PermissionDenied_ReturnsNotEntitled(t *testing.T) { entErr := status.Error(codes.PermissionDenied, "entitlement verification failed: required entitlement is not available for tenant") fake := &fakeThreatIntelClient{queue: []fakeReportsResp{{err: entErr}}} - src := newFeedSource(fake, newTestKV(t), time.Minute, 0) + src := newFeedSource(fake, newTestKV(t), time.Minute, 0, newReporter(nil)) handler, _ := drainHandler() err := src.syncOnce(context.Background(), handler) @@ -330,7 +330,7 @@ func TestSubscribe_NotEntitled_StopsImmediately(t *testing.T) { fake := &fakeThreatIntelClient{queue: []fakeReportsResp{{err: entErr}}} // Long interval so a wrongly-retrying implementation would hang the test. - src := newFeedSource(fake, newTestKV(t), time.Hour, 0) + src := newFeedSource(fake, newTestKV(t), time.Hour, 0, newReporter(nil)) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() @@ -361,7 +361,7 @@ func TestSyncOnce_AuthFailure_ReturnsAuthError(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { fake := &fakeThreatIntelClient{queue: []fakeReportsResp{{err: tt.err}}} - src := newFeedSource(fake, newTestKV(t), time.Minute, 0) + src := newFeedSource(fake, newTestKV(t), time.Minute, 0, newReporter(nil)) handler, _ := drainHandler() err := src.syncOnce(context.Background(), handler) @@ -375,7 +375,7 @@ func TestSubscribe_AuthFailure_StopsImmediately(t *testing.T) { fake := &fakeThreatIntelClient{queue: []fakeReportsResp{{err: authErr}}} // Long interval so a wrongly-retrying implementation would hang the test. - src := newFeedSource(fake, newTestKV(t), time.Hour, 0) + src := newFeedSource(fake, newTestKV(t), time.Hour, 0, newReporter(nil)) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() @@ -395,7 +395,7 @@ func TestSyncOnce_CallbackError_StopsAndWraps(t *testing.T) { reportSpec{id: "b", name: "pkg-b", versions: []string{"2.0"}, updatedOffset: time.Second}, )}}} - src := &feedSource{svc: fake, cursor: newCursorStore(newTestKV(t)), pollInterval: time.Minute} + src := &feedSource{svc: fake, cursor: newCursorStore(newTestKV(t)), pollInterval: time.Minute, rep: newReporter(nil)} stop := errors.New("callback bailed") delivered := 0 @@ -421,7 +421,7 @@ func TestSubscribe_CallbackError_PropagatesImmediately(t *testing.T) { // Long interval so a buggy implementation that retries instead of // surfacing would obviously hang the test (caught by deadline). - src := newFeedSource(fake, newTestKV(t), time.Hour, 0) + src := newFeedSource(fake, newTestKV(t), time.Hour, 0, newReporter(nil)) stop := errors.New("handler said stop") ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) @@ -449,7 +449,7 @@ func TestSubscribe_InfraError_LoggedAndRetried(t *testing.T) { {resp: makeReportsPage(time.Now().UTC(), "")}, }} - src := newFeedSource(fake, newTestKV(t), 10*time.Millisecond, 0) + src := newFeedSource(fake, newTestKV(t), 10*time.Millisecond, 0, newReporter(nil)) ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) defer cancel()