From 4b224ad0c143700812abd7930c39838be9b80be6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 09:07:51 +0000 Subject: [PATCH] feat(integration-jfrog): add -o json output, separate from logs The run daemon separates output from logs. A single reporter (reporter.go), injected into every component, routes each line by output mode: - Output: a real state change in XRay (package_pushed, package_deleted) or a dry-run preview (dry_run_package_push, dry_run_package_delete). - Logs: everything operational (feed cycle, connectivity, startup mode, errors, no-ops, skips). Under -o json the reporter prints only result events, as JSONL on stdout, and suppresses every log: the json stream is results only, nothing on stderr. In any other mode nothing goes to stdout and results and logs render as drytui lines on stderr, the same as the rest of the CLI. The two per-package no-ops (already pushed, does not exist) are dimmed but always shown in human modes. The reporter is a constructor dependency of all four components, so wiring is uniform. Also rename the token flag to --insecure-instance-access-token: a token on the command line is saved in the shell history and shown in the process list. The docs lead with the environment variable and never show a literal token. Docs and comments for this change are written in simple, plain English. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01RS4e9U1L2dP4H5XXeGPe6y --- docs/cmd/integration-jfrog-run.md | 60 +++++- docs/integration-jfrog.md | 34 ++- internal/cmd/integration/jfrog/client.go | 32 +-- internal/cmd/integration/jfrog/client_test.go | 26 +-- internal/cmd/integration/jfrog/cmd_test.go | 6 +- internal/cmd/integration/jfrog/printclient.go | 62 ++++-- .../cmd/integration/jfrog/printclient_test.go | 6 +- internal/cmd/integration/jfrog/reporter.go | 106 +++++++++ .../cmd/integration/jfrog/reporter_test.go | 203 ++++++++++++++++++ internal/cmd/integration/jfrog/run.go | 19 +- internal/cmd/integration/jfrog/service.go | 57 +++-- .../cmd/integration/jfrog/service_test.go | 8 +- internal/cmd/integration/jfrog/source_feed.go | 19 +- .../cmd/integration/jfrog/source_feed_test.go | 30 +-- 14 files changed, 544 insertions(+), 124 deletions(-) create mode 100644 internal/cmd/integration/jfrog/reporter.go create mode 100644 internal/cmd/integration/jfrog/reporter_test.go 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()