返回 DeepSeek-Reasonix
cli_test.go
根目录 / internal / crashreport / cli_test.go
1 package crashreport
2
3 import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "errors"
8 "io"
9 "net/http"
10 "os"
11 "path/filepath"
12 "runtime"
13 "strings"
14 "sync"
15 "testing"
16 "unicode/utf8"
17 )
18
19 type roundTripFunc func(*http.Request) (*http.Response, error)
20
21 func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) }
22
23 func TestCapturePanicWritesBoundedSanitizedReport(t *testing.T) {
24 home := t.TempDir()
25 secret := "private prompt contents"
26 apiKey := "sk-proj-abcdefghijklmnopqrstuvwxyz1234567890"
27 stack := "goroutine 7 [running]:\n" +
28 "reasonix/internal/agent.run(" + secret + ")\n" +
29 "\t/Users/alice/private-project/internal/agent/run.go:42 +0x123\n" +
30 "Authorization: Bearer abcdefghijklmnopqrstuvwxyz1234567890\n" +
31 "api_key=" + apiKey
32
33 if err := CapturePanic(home, "v1.20.0", secret+" api_key="+apiKey, []byte(stack)); err != nil {
34 t.Fatal(err)
35 }
36 reports, err := List(home)
37 if err != nil || len(reports) != 1 {
38 t.Fatalf("reports=%d err=%v", len(reports), err)
39 }
40 report := reports[0].Report
41 if report.Kind != "crash" || report.Source != "cli.go" || report.Label != "panic" || report.SchemaVersion != 2 {
42 t.Fatalf("report metadata = %+v", report)
43 }
44 if len(report.EventID) != 32 || len(report.DedupKey) != 64 {
45 t.Fatalf("report identity = event %q dedup %q", report.EventID, report.DedupKey)
46 }
47 if !strings.Contains(report.Stack, "reasonix/internal/agent.run(...)") || !strings.Contains(report.Stack, "<path>/run.go:42") {
48 t.Fatalf("sanitized stack = %q", report.Stack)
49 }
50 if report.TopFrame != "reasonix/internal/agent.run <path>/run.go:42" {
51 t.Fatalf("top frame = %q", report.TopFrame)
52 }
53 preview, err := Preview(report)
54 if err != nil {
55 t.Fatal(err)
56 }
57 for _, leaked := range []string{secret, apiKey, "alice", "private-project", "Bearer abcdefghijklmnopqrstuvwxyz1234567890"} {
58 if strings.Contains(string(preview), leaked) {
59 t.Fatalf("report leaked %q:\n%s", leaked, preview)
60 }
61 }
62 report.ErrorType = "api_key=" + apiKey
63 preview, err = Preview(report)
64 if err != nil {
65 t.Fatal(err)
66 }
67 if strings.Contains(string(preview), apiKey) {
68 t.Fatalf("send-time field sanitization leaked a key:\n%s", preview)
69 }
70 path := filepath.Join(home, dirName, reports[0].ID+".json")
71 info, err := os.Stat(path)
72 if err != nil {
73 t.Fatal(err)
74 }
75 // Windows reports synthesized POSIX permission bits and enforces access
76 // through inherited ACLs, so only Unix-like systems can assert mode 0600.
77 if runtime.GOOS != "windows" && info.Mode().Perm() != 0o600 {
78 t.Fatalf("report mode=%v", info.Mode().Perm())
79 }
80
81 for i := range maxReports + 5 {
82 if err := CapturePanic(home, "v1.20.0", i, []byte(stack)); err != nil {
83 t.Fatal(err)
84 }
85 }
86 reports, err = List(home)
87 if err != nil || len(reports) != maxReports {
88 t.Fatalf("bounded reports=%d err=%v", len(reports), err)
89 }
90 }
91
92 func TestListBackfillsStableIdentityForOldPendingReport(t *testing.T) {
93 home := t.TempDir()
94 dir := filepath.Join(home, dirName)
95 if err := os.MkdirAll(dir, 0o700); err != nil {
96 t.Fatal(err)
97 }
98 name := "00000000000000000001-1-0000000000000001.json"
99 path := filepath.Join(dir, name)
100 body := `{"kind":"crash","version":"v1.20.0","os":"linux","arch":"amd64","message":"old","schemaVersion":2,"source":"cli.go","label":"panic"}`
101 if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
102 t.Fatal(err)
103 }
104 first, err := List(home)
105 if err != nil || len(first) != 1 {
106 t.Fatalf("first List reports=%d err=%v", len(first), err)
107 }
108 second, err := List(home)
109 if err != nil || len(second) != 1 {
110 t.Fatalf("second List reports=%d err=%v", len(second), err)
111 }
112 if first[0].Report.EventID == "" || first[0].Report.DedupKey == "" ||
113 first[0].Report.EventID != second[0].Report.EventID || first[0].Report.DedupKey != second[0].Report.DedupKey {
114 t.Fatalf("identity was not stable: first=%+v second=%+v", first[0].Report, second[0].Report)
115 }
116 stored, err := os.ReadFile(path)
117 if err != nil || !bytes.Contains(stored, []byte(`"eventId"`)) || !bytes.Contains(stored, []byte(`"dedupKey"`)) {
118 t.Fatalf("backfilled identity was not persisted: body=%s err=%v", stored, err)
119 }
120 }
121
122 func TestSendUsesSharedProtocolWithoutDeletingLocalReport(t *testing.T) {
123 home := t.TempDir()
124 if err := CapturePanic(home, "v1.20.0", "boom", []byte("goroutine 1 [running]:\nreasonix.run()\n\t/home/alice/reasonix/main.go:12")); err != nil {
125 t.Fatal(err)
126 }
127 pending, err := Load(home, "")
128 if err != nil {
129 t.Fatal(err)
130 }
131 var uploaded Report
132 client := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
133 if req.Method != http.MethodPost || req.URL.String() != "https://example.invalid/v1/report" {
134 t.Fatalf("request = %s %s", req.Method, req.URL)
135 }
136 if got := req.Header.Get("Content-Type"); got != "application/json" {
137 t.Fatalf("content type = %q", got)
138 }
139 if err := json.NewDecoder(req.Body).Decode(&uploaded); err != nil {
140 t.Fatal(err)
141 }
142 return &http.Response{StatusCode: http.StatusAccepted, Status: "202 Accepted", Header: make(http.Header), Body: io.NopCloser(strings.NewReader("ok"))}, nil
143 })}
144 if err := sendWithClient(context.Background(), client, "https://example.invalid/v1/report", pending.Report); err != nil {
145 t.Fatal(err)
146 }
147 if uploaded.Source != "cli.go" || uploaded.Stack == "" || uploaded.TopFrame == "" {
148 t.Fatalf("uploaded report = %+v", uploaded)
149 }
150 if _, err := Load(home, pending.ID); err != nil {
151 t.Fatalf("Send removed local report: %v", err)
152 }
153 if err := Remove(home, pending.ID); err != nil {
154 t.Fatal(err)
155 }
156 if _, err := Load(home, ""); !errors.Is(err, ErrNoReports) {
157 t.Fatalf("Load after Remove = %v", err)
158 }
159 }
160
161 func TestLoadRejectsUnknownIDWithoutPathTraversal(t *testing.T) {
162 home := t.TempDir()
163 if err := CapturePanic(home, "v1.20.0", "boom", []byte("stack")); err != nil {
164 t.Fatal(err)
165 }
166 if _, err := Load(home, "../../config.toml"); err == nil {
167 t.Fatal("path traversal ID was accepted")
168 }
169 }
170
171 func TestConcurrentCaptureKeepsQueueBounded(t *testing.T) {
172 home := t.TempDir()
173 const writers = 32
174 var wg sync.WaitGroup
175 start := make(chan struct{})
176 for i := range writers {
177 wg.Add(1)
178 go func(value int) {
179 defer wg.Done()
180 <-start
181 if err := CapturePanic(home, "v1.20.0", value, []byte("reasonix.run()\n\t/home/alice/main.go:12")); err != nil {
182 t.Errorf("CapturePanic: %v", err)
183 }
184 }(i)
185 }
186 close(start)
187 wg.Wait()
188 reports, err := List(home)
189 if err != nil || len(reports) != maxReports {
190 t.Fatalf("reports=%d err=%v", len(reports), err)
191 }
192 }
193
194 func TestCapturePanicPrunesOnlyCurrentReportFormat(t *testing.T) {
195 home := t.TempDir()
196 dir := filepath.Join(home, dirName)
197 if err := os.MkdirAll(dir, 0o700); err != nil {
198 t.Fatal(err)
199 }
200 futurePath := filepath.Join(dir, "00000000000000000000-1-0000000000000000.json")
201 futureReport := `{"kind":"crash","version":"v2.0.0","os":"linux","arch":"amd64","message":"future","schemaVersion":3,"futureField":"preserve me"}`
202 if err := os.WriteFile(futurePath, []byte(futureReport), 0o600); err != nil {
203 t.Fatal(err)
204 }
205
206 for i := range maxReports + 1 {
207 if err := CapturePanic(home, "v1.20.0", i, []byte("reasonix.run()\n\t/home/alice/main.go:12")); err != nil {
208 t.Fatal(err)
209 }
210 }
211 if _, err := os.Stat(futurePath); err != nil {
212 t.Fatalf("future report was removed: %v", err)
213 }
214 reports, err := List(home)
215 if err != nil {
216 t.Fatal(err)
217 }
218 if len(reports) != maxReports {
219 t.Fatalf("current reports=%d, want %d", len(reports), maxReports)
220 }
221 }
222
223 func TestSanitizingLimitPreservesUTF8(t *testing.T) {
224 got := sanitizeText(strings.Repeat("界", maxFieldBytes), maxFieldBytes)
225 if len(got) > maxFieldBytes || !utf8.ValidString(got) {
226 t.Fatalf("sanitized text bytes=%d valid=%v", len(got), utf8.ValidString(got))
227 }
228 }
229
229 lines GO