Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions cmd/flashduty/main_test.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,6 +3,9 @@ package main
import (
"bytes"
"fmt"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"runtime"
Expand DownExpand Up@@ -108,3 +111,49 @@ func TestSetVersionInfoBeforeExecute(t *testing.T) {
}
}
}

// Test 79: When a compact list projection overflows its byte budget, the
// binary exits non-zero, writes nothing to stdout, and reports the error on
// stderr — a pipeline reading stdout must see a failed call, never an empty
// page masquerading as "no data".
func TestProjectionOverflowFailsHard(t *testing.T) {
binPath := buildTestBinary(t, "")

// Stub the alert-event list endpoint with a page whose projection stays
// over the 16 KiB budget even after value shortening.
var body strings.Builder
body.WriteString(`{"request_id":"r","error":{"code":"OK","message":""},"data":{"total":100,"items":[`)
for i := 0; i < 100; i++ {
if i > 0 {
body.WriteByte(',')
}
fmt.Fprintf(&body, `{"event_id":"%024x","alert_id":"%024x","event_severity":"Warning","event_status":"Triggered","event_time":1712000000,"title":%q}`,
i, i+1_000_000, strings.Repeat("x", 200))
}
body.WriteString(`]}}`)

srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(body.String()))
}))
defer srv.Close()

run := exec.Command(binPath, "alert-event", "list", "--limit", "100",
"--output-format", "json", "--app-key", "test-key", "--base-url", srv.URL)
// Isolate HOME so the test never reads the developer's real CLI config.
run.Env = append(os.Environ(), "HOME="+t.TempDir())
var stdout, stderr bytes.Buffer
run.Stdout = &stdout
run.Stderr = &stderr

err := run.Run()
if err == nil {
t.Fatalf("[#79] expected non-zero exit code for an over-budget projection, got success; stderr:\n%s", stderr.String())
}
if stdout.Len() != 0 {
t.Errorf("[#79] a failed projection must write nothing to stdout, got %d bytes:\n%s", stdout.Len(), stdout.String())
}
if !strings.Contains(stderr.String(), "Error: projected list is") || !strings.Contains(stderr.String(), "exceeds the 16384-byte limit") {
t.Errorf("[#79] stderr should report the byte-limit refusal, got:\n%s", stderr.String())
}
}
4 changes: 2 additions & 2 deletions internal/cli/alert.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -93,7 +93,7 @@ func newAlertListCmd() *cobra.Command {
if err != nil {
return err
}
return ctx.PrintList(proj, nil, len(result.Items), page, int(result.Total))
return ctx.PrintList(proj, nil, len(result.Items), page, limit, int(result.Total))
}

cols := []output.Column{
Expand All@@ -106,7 +106,7 @@ func newAlertListCmd() *cobra.Command {
{Header: "STARTED", Field: func(v any) string { return output.FormatTime(v.(flashduty.AlertItem).StartTime) }},
}

return ctx.PrintList(result.Items, cols, len(result.Items), page, int(result.Total))
return ctx.PrintList(result.Items, cols, len(result.Items), page, limit, int(result.Total))
})
},
}
Expand Down
4 changes: 2 additions & 2 deletions internal/cli/alert_event.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -102,10 +102,10 @@ func newAlertEventListCmd() *cobra.Command {
return err
}
noteProjectionShortening(cmd.ErrOrStderr(), note)
return ctx.PrintList(proj, nil, len(result.Items), page, int(result.Total))
return ctx.PrintList(proj, nil, len(result.Items), page, limit, int(result.Total))
}

return ctx.PrintList(result.Items, cols, len(result.Items), page, int(result.Total))
return ctx.PrintList(result.Items, cols, len(result.Items), page, limit, int(result.Total))
})
},
}
Expand Down
2 changes: 1 addition & 1 deletion internal/cli/audit.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -96,7 +96,7 @@ func newAuditSearchCmd() *cobra.Command {
}},
}

return ctx.PrintList(result.Docs, cols, len(result.Docs), page, int(result.Total))
return ctx.PrintList(result.Docs, cols, len(result.Docs), page, limit, int(result.Total))
})
},
}
Expand Down
2 changes: 1 addition & 1 deletion internal/cli/change.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -86,7 +86,7 @@ func newChangeListCmd() *cobra.Command {
{Header: "TIME", Field: func(v any) string { return output.FormatTime(v.(flashduty.ChangeItem).StartTime) }},
}

return ctx.PrintList(result.Items, cols, len(result.Items), page, int(result.Total))
return ctx.PrintList(result.Items, cols, len(result.Items), page, limit, int(result.Total))
})
},
}
Expand Down
16 changes: 13 additions & 3 deletions internal/cli/command.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -48,13 +48,23 @@ func runCommand(cmd *cobra.Command, args []string, fn func(ctx *RunContext) erro
}

// PrintList prints items as a table and appends a "Showing N results (page P, total T)." footer.
func (ctx *RunContext) PrintList(items any, cols []output.Column, count, page, total int) error {
// In structured mode the footer is suppressed to keep stdout byte-pure for
// jq/toon pipelines, so a page with more rows beyond it is announced on
// stderr instead — without it a consumer sees a partial page
// (e.g. the default --limit 20 of a far larger total) as the whole set. The
// judgment accounts for the page offset: on the last page
// ((page-1)*limit+count reaches total) there is no rest, so no note.
func (ctx *RunContext) PrintList(items any, cols []output.Column, count, page, limit, total int) error {
if err := ctx.Printer.Print(items, cols); err != nil {
return err
}
if !ctx.Structured() {
_, _ = fmt.Fprintf(ctx.Writer, "Showing %d results (page %d, total %d).\n", count, page, total)
if ctx.Structured() {
if (page-1)*limit+count < total {
_, _ = fmt.Fprintf(ctx.Cmd.ErrOrStderr(), "note: showing %d of %d total results (page %d); raise --limit or use --page for the rest\n", count, total, page)
}
return nil
}
_, _ = fmt.Fprintf(ctx.Writer, "Showing %d results (page %d, total %d).\n", count, page, total)
return nil
}

Expand Down
161 changes: 161 additions & 0 deletions internal/cli/command_test.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -1594,6 +1594,167 @@ type readerFunc func([]byte) (int, error)

func (f readerFunc) Read(p []byte) (int, error) { return f(p) }

// ---------------------------------------------------------------------------
// Structured list truncation indicator
// ---------------------------------------------------------------------------

// TestCommandAlertListStructuredAnnouncesTruncation pins that a structured
// list page which doesn't cover the server-reported total says so on stderr:
// stdout is reserved for the jq/toon pipeline, so without the note a consumer
// sees the default --limit page as the whole set. Table mode keeps its
// "Showing N results" footer on stdout and emits no stderr note.
func TestCommandAlertListStructuredAnnouncesTruncation(t *testing.T) {
twoOfFive := map[string]any{"items": []any{alertRow(), alertRow()}, "total": 5}

t.Run("json page short of total", func(t *testing.T) {
saveAndResetGlobals(t)
stub := newGFStub(t)
stub.data = twoOfFive

out, stderrText, err := execCommandSplit("alert", "list", "--output-format", "json")
if err != nil {
t.Fatalf("execCommandSplit: %v", err)
}
var rows []map[string]any
if err := json.Unmarshal([]byte(strings.TrimSpace(out)), &rows); err != nil {
t.Fatalf("stdout must stay parseable JSON: %v\n%s", err, out)
}
if len(rows) != 2 {
t.Fatalf("got %d rows, want the 2 the page carries", len(rows))
}
if !strings.Contains(stderrText, "note: showing 2 of 5 total results (page 1)") {
t.Errorf("truncated structured page should announce itself on stderr, got:\n%s", stderrText)
}
})

t.Run("json page covers total", func(t *testing.T) {
saveAndResetGlobals(t)
stub := newGFStub(t)
stub.data = map[string]any{"items": []any{alertRow(), alertRow()}, "total": 2}

_, stderrText, err := execCommandSplit("alert", "list", "--output-format", "json")
if err != nil {
t.Fatalf("execCommandSplit: %v", err)
}
if strings.Contains(stderrText, "note: showing") {
t.Errorf("a page covering the total must not cry truncation, got:\n%s", stderrText)
}
})

t.Run("table keeps the footer on stdout", func(t *testing.T) {
saveAndResetGlobals(t)
stub := newGFStub(t)
stub.data = twoOfFive

out, stderrText, err := execCommandSplit("alert", "list")
if err != nil {
t.Fatalf("execCommandSplit: %v", err)
}
if !strings.Contains(out, "Showing 2 results (page 1, total 5).") {
t.Errorf("table mode should keep the stdout footer, got:\n%s", out)
}
if strings.Contains(stderrText, "note: showing") {
t.Errorf("table mode already footers the count; no stderr note wanted, got:\n%s", stderrText)
}
})

// The truncation judgment must account for the page offset: page 2 of a
// total 40 at --limit 20 IS the last page — there is no rest, so no note.
fullPage := make([]any, 20)
for i := range fullPage {
fullPage[i] = alertRow()
}
lastPage := map[string]any{"items": fullPage, "total": 40}

t.Run("json last page prints no note", func(t *testing.T) {
saveAndResetGlobals(t)
stub := newGFStub(t)
stub.data = lastPage

_, stderrText, err := execCommandSplit("alert", "list", "--limit", "20", "--page", "2", "--output-format", "json")
if err != nil {
t.Fatalf("execCommandSplit: %v", err)
}
if strings.Contains(stderrText, "note: showing") {
t.Errorf("the last page has no rest to page for; no note wanted, got:\n%s", stderrText)
}
})

t.Run("json first page of same total still notes", func(t *testing.T) {
saveAndResetGlobals(t)
stub := newGFStub(t)
stub.data = lastPage

_, stderrText, err := execCommandSplit("alert", "list", "--limit", "20", "--page", "1", "--output-format", "json")
if err != nil {
t.Fatalf("execCommandSplit: %v", err)
}
if !strings.Contains(stderrText, "note: showing 20 of 40 total results (page 1)") {
t.Errorf("page 1 with a page 2 beyond it should announce itself on stderr, got:\n%s", stderrText)
}
})
}

// ---------------------------------------------------------------------------
// Projection overflow is a hard failure
// ---------------------------------------------------------------------------

// TestCommandListProjectionOverflowFails pins that a compact list projection
// which cannot fit the byte budget fails the command instead of emitting
// anything: Execute returns the error and stdout stays empty, so a pipeline
// reading stdout sees a failed call, never an empty page masquerading as
// "no data".
func TestCommandListProjectionOverflowFails(t *testing.T) {
t.Run("incident list", func(t *testing.T) {
saveAndResetGlobals(t)
stub := newGFStub(t)
items := make([]any, 100)
for i := range items {
row := incidentRow()
row["incident_id"] = fmt.Sprintf("inc-%024d", i)
row["title"] = strings.Repeat("x", 200)
items[i] = row
}
stub.data = map[string]any{"items": items, "total": len(items)}

out, stderrText, err := execCommandSplit("incident", "list", "--limit", "100", "--output-format", "json")
if err == nil || !strings.Contains(err.Error(), "exceeds the 16384-byte limit") {
t.Fatalf("irreducible projection error = %v, want the byte-limit refusal", err)
}
if out != "" {
t.Errorf("a failed projection must write nothing to stdout, got %d bytes", len(out))
}
if strings.Contains(stderrText, "exceeds the") {
t.Errorf("the error is returned for the entrypoint to report, not printed mid-run, got:\n%s", stderrText)
}
})

t.Run("alert-event list", func(t *testing.T) {
saveAndResetGlobals(t)
stub := newGFStub(t)
items := make([]any, 100)
for i := range items {
items[i] = map[string]any{
"event_id": fmt.Sprintf("%024x", i),
"alert_id": fmt.Sprintf("%024x", i+1_000_000),
"event_severity": "Warning",
"event_status": "Triggered",
"event_time": 1712000000 + i,
"title": strings.Repeat("x", 200),
}
}
stub.data = map[string]any{"items": items, "total": len(items)}

out, _, err := execCommandSplit("alert-event", "list", "--limit", "100", "--output-format", "json")
if err == nil || !strings.Contains(err.Error(), "exceeds the 16384-byte limit") {
t.Fatalf("irreducible projection error = %v, want the byte-limit refusal", err)
}
if out != "" {
t.Errorf("a failed projection must write nothing to stdout, got %d bytes", len(out))
}
})
}

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
Expand Down
4 changes: 2 additions & 2 deletions internal/cli/incident.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -142,10 +142,10 @@ func newIncidentListCmd() *cobra.Command {
return err
}
noteProjectionShortening(cmd.ErrOrStderr(), note)
return ctx.PrintList(proj, nil, len(result.Items), page, int(result.Total))
return ctx.PrintList(proj, nil, len(result.Items), page, limit, int(result.Total))
}

return ctx.PrintList(result.Items, incidentColumns(), len(result.Items), page, int(result.Total))
return ctx.PrintList(result.Items, incidentColumns(), len(result.Items), page, limit, int(result.Total))
})
},
}
Expand Down
2 changes: 1 addition & 1 deletion internal/cli/insight.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -131,7 +131,7 @@ func newInsightIncidentsCmd() *cobra.Command {
}},
}

return ctx.PrintList(result.Items, cols, len(result.Items), page, int(result.Total))
return ctx.PrintList(result.Items, cols, len(result.Items), page, limit, int(result.Total))
})
},
}
Expand Down