返回 DeepSeek-Reasonix
browser_diagnostic_export_test.go
根目录 / desktop / browser_diagnostic_export_test.go
1 package main
2
3 import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "errors"
8 "io"
9 "net/http"
10 "os"
11 "path/filepath"
12 "strings"
13 "testing"
14 "time"
15
16 "reasonix/desktop/internal/browserops"
17 "reasonix/internal/browser"
18 "reasonix/internal/secrets"
19 "reasonix/internal/session"
20 )
21
22 type diagnosticRequestHost func(context.Context, string, any, any) error
23
24 func (f diagnosticRequestHost) Request(ctx context.Context, method string, params, result any) error {
25 return f(ctx, method, params, result)
26 }
27
28 func TestBrowserDiagnosticWriteCorrelationAndRemoteSessionRotation(t *testing.T) {
29 app, exec := newBrowserExecutorForTest(t, &fakeBrowserHost{})
30 scope := browserDiagnosticScope(localDesktopHostID, "source")
31 exec.diagnosticScope = scope
32 exec.host = diagnosticRequestHost(func(ctx context.Context, method string, params, result any) error {
33 data, _ := json.Marshal(params)
34 var fields map[string]any
35 _ = json.Unmarshal(data, &fields)
36 if method == "host/browser.grant" && fields["diagnosticScope"] != scope {
37 t.Fatal("missing scope")
38 }
39 if method == "host/browser.tabs.open" && (fields["operationId"] != "open-operation" || fields["requestId"] == nil) {
40 t.Fatal("missing operation correlation")
41 }
42 return nil
43 })
44 if _, err := exec.Open(t.Context(), browser.OpenRequest{OperationID: "open-operation", URL: "https://example.test"}); err != nil {
45 t.Fatal(err)
46 }
47 if rows := app.browserOps.Diagnostics(scope).Operations; len(rows) != 1 || rows[0].State != browserops.StateExecuted {
48 t.Fatalf("missing durable evidence: %+v", rows)
49 }
50
51 tab := &remoteTab{id: "remote", ref: RemoteTabRef{HostID: "host"}, session: remoteTabSessionState{path: "/same/path", sessionID: "first"}, routing: remoteTabSessionRouting{currentPath: remoteSessionIDRoutePrefix + "first"}}
52 app.remoteTabs = map[string]*remoteTab{tab.id: tab}
53 first := app.browserExecutorForRemoteTab(tab, "/same/path").(*hostBrowserExecutor)
54 tab.routing.currentPath = remoteSessionIDRoutePrefix + "second"
55 tab.session.sessionID = "second"
56 second := app.browserExecutorForRemoteTab(tab, "/same/path").(*hostBrowserExecutor)
57 if first == second || first.diagnosticScope == second.diagnosticScope || !first.revoked.Load() {
58 t.Fatal("same path mixed different canonical sessions")
59 }
60 if app.browserExecutorForRemoteTab(tab, "/stale/path") != nil {
61 t.Fatal("accepted stale browser binding")
62 }
63 }
64
65 func TestBrowserDiagnosticProvisionalResumeDoesNotChangeOwnership(t *testing.T) {
66 app, _ := newBrowserExecutorForTest(t, &fakeBrowserHost{})
67 client := &http.Client{}
68 tab := &remoteTab{id: "remote", ref: RemoteTabRef{HostID: "host"}, client: client, gen: 1, state: "ready", session: remoteTabSessionState{path: "/old.jsonl", sessionID: "old"}, routing: remoteTabSessionRouting{currentPath: remoteSessionIDRoutePrefix + "old", running: map[string]bool{}}}
69 app.remoteTabs = map[string]*remoteTab{tab.id: tab}
70 old := app.browserExecutorForRemoteTab(tab, tab.session.path).(*hostBrowserExecutor)
71 route := app.beginRemoteTabProvisionalResume(tab.id, tab, client, 1, remoteSessionIDRoutePrefix+"new")
72 if !route.active {
73 t.Fatal("fixture did not publish provisional route")
74 }
75 if exec := app.browserExecutorForRemoteTab(tab, "/old.jsonl"); exec != nil {
76 t.Fatal("old request acquired provisional new-session attribution")
77 }
78 // A resume holds sessionMu while RPCs are pending. Browser resolution must
79 // reject promptly under remoteTabMu, without inverting that lock order.
80 tab.sessionMu.Lock()
81 done := make(chan error, 1)
82 go func() { _, err := app.resolveRemoteBrowserSession("host", "/old.jsonl"); done <- err }()
83 select {
84 case err := <-done:
85 tab.sessionMu.Unlock()
86 if !errors.Is(err, browser.ErrNoGrant) {
87 t.Fatalf("transition returned %v", err)
88 }
89 case <-time.After(3 * time.Second):
90 tab.sessionMu.Unlock()
91 <-done
92 t.Fatal("browser resolution waited for the transition mutex")
93 }
94 if old.revoked.Load() {
95 t.Fatal("provisional switch revoked the old grant")
96 }
97 if !app.rollbackRemoteTabProvisionalResume(tab.id, tab, client, 1, route) {
98 t.Fatal("rollback failed")
99 }
100 res, err := app.resolveRemoteBrowserSession("host", "/old.jsonl")
101 if err != nil || res.exec != old {
102 t.Fatalf("rollback lost original executor: %v", err)
103 }
104 // Even without an active transition, a mismatched canonical route fails closed.
105 tab.routing.currentPath = remoteSessionIDRoutePrefix + "new"
106 if app.browserExecutorForRemoteTab(tab, "/old.jsonl") != nil || old.revoked.Load() {
107 t.Fatal("inconsistent identity changed ownership")
108 }
109 }
110
111 func TestBrowserDiagnosticLegacyRemoteBindingRemainsAvailable(t *testing.T) {
112 app, _ := newBrowserExecutorForTest(t, &fakeBrowserHost{})
113 tab := &remoteTab{id: "legacy", ref: RemoteTabRef{HostID: "host"}, session: remoteTabSessionState{path: "/legacy.jsonl"}, routing: remoteTabSessionRouting{currentPath: "/legacy.jsonl"}}
114 app.remoteTabs = map[string]*remoteTab{tab.id: tab}
115 res, err := app.resolveRemoteBrowserSession("host", "/legacy.jsonl")
116 if err != nil {
117 t.Fatal(err)
118 }
119 if res.exec.(*hostBrowserExecutor).diagnosticScope != "" {
120 t.Fatal("guessed a canonical identity for a legacy peer")
121 }
122 }
123
124 func TestBrowserDiagnosticCancellationRemainsUnavailable(t *testing.T) {
125 ctx, cancel := context.WithCancel(t.Context())
126 entered := make(chan struct{})
127 host := diagnosticRequestHost(func(ctx context.Context, _ string, _ any, _ any) error {
128 close(entered)
129 <-ctx.Done()
130 return ctx.Err()
131 })
132 done := make(chan any, 1)
133 go func() { done <- captureBrowserHostDiagnostics(ctx, host, browserDiagnosticScope("host", "source")) }()
134 <-entered
135 cancel()
136 data, _ := json.Marshal(<-done)
137 if !bytes.Contains(data, []byte("capture_interrupted")) {
138 t.Fatalf("lost cancellation evidence: %s", data)
139 }
140 }
141
142 func TestBrowserDiagnosticHostCompatibilityAndIdentity(t *testing.T) {
143 scope := browserDiagnosticScope("host", "session")
144 for _, tc := range []struct {
145 name string
146 reply any
147 err error
148 available bool
149 }{
150 {"supported", map[string]any{"available": true, "scope": scope, "entries": []any{}}, nil, true},
151 {"old", nil, errors.New("method not found"), false},
152 {"wrong-session", map[string]any{"available": true, "scope": "other"}, nil, false},
153 {"oversized", map[string]any{"available": true, "scope": scope, "entries": strings.Repeat("x", 513<<10)}, nil, false},
154 } {
155 t.Run(tc.name, func(t *testing.T) {
156 host := &fakeBrowserHost{replies: map[string]any{"host/browser.exportDiagnostics": tc.reply}, errs: map[string]error{"host/browser.exportDiagnostics": tc.err}}
157 result := captureBrowserHostDiagnostics(t.Context(), host, scope)
158 data, _ := json.Marshal(result)
159 var parsed struct {
160 Available bool `json:"available"`
161 }
162 if json.Unmarshal(data, &parsed) != nil || parsed.Available != tc.available {
163 t.Fatalf("unexpected result %s", data)
164 }
165 })
166 }
167 if scope == browserDiagnosticScope("other-host", "session") || browserDiagnosticScope("host", "") != "" {
168 t.Fatal("invalid scope isolation")
169 }
170 }
171
172 func TestBrowserDiagnosticColdExportIncludesOnlyFixedSource(t *testing.T) {
173 app, ref := activityBaselineFixture(t, "browser-diagnostic")
174 ledger, err := app.browserLedger()
175 if err != nil {
176 t.Fatal(err)
177 }
178 for _, op := range []browserops.Operation{
179 {ID: "source-operation", DiagnosticScope: browserDiagnosticScope(localDesktopHostID, ref.SessionID), Action: "click"},
180 {ID: "foreign-operation", DiagnosticScope: browserDiagnosticScope(localDesktopHostID, "other"), Action: "click"},
181 } {
182 if err = ledger.Reserve(op); err != nil {
183 t.Fatal(err)
184 }
185 }
186 if err = app.desktopSessionService("").Close(t.Context(), ref); err != nil {
187 t.Fatal(err)
188 }
189 path := filepath.Join(t.TempDir(), "export.json")
190 app.setNativeHost(&recordingNativeHost{dialogPath: path, onCall: func(name string) {
191 if strings.HasPrefix(name, "SaveFileDialog:") {
192 app.activeTabID = "other"
193 }
194 }})
195 handle, err := app.BeginSessionExportForTarget(SessionSelector{Ref: &ref}, "", "diagnostic", "Browser", "")
196 if err != nil {
197 t.Fatal(err)
198 }
199 if _, err = app.FinishSessionExport(handle.ExportID); err != nil {
200 t.Fatal(err)
201 }
202 data, err := os.ReadFile(path)
203 if err != nil {
204 t.Fatal(err)
205 }
206 var result map[string]json.RawMessage
207 if err = json.Unmarshal(data, &result); err != nil {
208 t.Fatal(err)
209 }
210 section := result["browserDiagnostics"]
211 var status struct {
212 Host struct {
213 Available bool `json:"available"`
214 } `json:"host"`
215 }
216 if err = json.Unmarshal(section, &status); err != nil {
217 t.Fatal(err)
218 }
219 if !bytes.Contains(section, []byte("source-operation")) || bytes.Contains(section, []byte("foreign-operation")) || status.Host.Available {
220 t.Fatalf("bad evidence %s", section)
221 }
222 if op, _ := ledger.Lookup("source-operation"); op.State != browserops.StateReserved {
223 t.Fatal("export settled write")
224 }
225 }
226
227 func TestBrowserDiagnosticRemoteMergePreservesOldProtocolAndFixedScope(t *testing.T) {
228 isolateDesktopUserDirs(t)
229 var seenBody []byte
230 app, tab := remoteRuntimeTestApp(&http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
231 switch req.URL.Path {
232 case "/session-export/snapshot":
233 return remoteRuntimeTestResponse(req, 200, remoteRuntimeTestJSON(t, session.ExportSnapshot{Ref: session.SessionRef{HostID: "fixture-host", SessionID: "source"}, StorageGeneration: "g"})), nil
234 case "/session-export/validate":
235 return remoteRuntimeTestResponse(req, 204, ""), nil
236 case "/session-export/diagnostic":
237 seenBody, _ = io.ReadAll(req.Body)
238 return remoteRuntimeTestResponse(req, 200, `{"metadata":{"oldRemote":true},"commits":[{"source":"REMOTE-EVIDENCE"}]}`), nil
239 default:
240 t.Fatalf("unexpected request %s", req.URL)
241 return nil, nil
242 }
243 })})
244 ledger, err := app.browserLedger()
245 if err != nil {
246 t.Fatal(err)
247 }
248 tab.capabilities["session-export-v1"] = true
249 tab.routing.currentPath = remoteSessionIDRoutePrefix + "source"
250 tab.session.path = "/source/session"
251 if err = ledger.Reserve(browserops.Operation{ID: "remote-browser-op", DiagnosticScope: browserDiagnosticScope(tab.ref.HostID, "source")}); err != nil {
252 t.Fatal(err)
253 }
254 path := filepath.Join(t.TempDir(), "remote.json")
255 app.setNativeHost(&recordingNativeHost{dialogPath: path, onCall: func(name string) {
256 if strings.HasPrefix(name, "SaveFileDialog:") {
257 tab.session.path = "/other/session"
258 tab.routing.currentPath = remoteSessionIDRoutePrefix + "other"
259 }
260 }})
261 handle, err := app.BeginSessionExportForTarget(SessionSelector{}, tab.id, "diagnostic", "Remote", "")
262 if err != nil {
263 t.Fatal(err)
264 }
265 if _, err = app.FinishSessionExport(handle.ExportID); err != nil {
266 t.Fatal(err)
267 }
268 data, _ := os.ReadFile(path)
269 if !json.Valid(data) || !bytes.Contains(data, []byte("REMOTE-EVIDENCE")) || !bytes.Contains(data, []byte("remote-browser-op")) {
270 t.Fatalf("bad remote merge %s", data)
271 }
272 if bytes.Contains(seenBody, []byte("browserDiagnostics")) {
273 t.Fatal("new evidence sent through old remote observation protocol")
274 }
275 }
276
277 func TestBrowserDiagnosticAppendHandlesEmptyAndLargeDocuments(t *testing.T) {
278 for _, raw := range []string{"{}\n", `{"commits":["` + strings.Repeat("x", 2<<20) + `"]}` + "\n"} {
279 path := filepath.Join(t.TempDir(), "export.json")
280 err := writeGoalDiagnosticsFile(path, func(dst io.Writer) error {
281 if _, err := io.WriteString(dst, raw); err != nil {
282 return err
283 }
284 return appendBrowserDiagnosticSection(dst, []byte(`{"available":true}`))
285 })
286 if err != nil {
287 t.Fatal(err)
288 }
289 data, _ := os.ReadFile(path)
290 if !json.Valid(data) {
291 t.Fatal("invalid appended JSON")
292 }
293 }
294 path := filepath.Join(t.TempDir(), "existing.json")
295 if err := os.WriteFile(path, []byte("original"), 0600); err != nil {
296 t.Fatal(err)
297 }
298 err := writeGoalDiagnosticsFile(path, func(dst io.Writer) error {
299 _, _ = io.WriteString(dst, `{"commits":[`)
300 return appendBrowserDiagnosticSection(dst, []byte(`{}`))
301 })
302 data, _ := os.ReadFile(path)
303 if err == nil || string(data) != "original" {
304 t.Fatal("partial remote response replaced destination")
305 }
306 }
307
308 // Native qualification can feed the real Electron report through the same Go
309 // validation, redaction and file merge used by remote session exports. Normal
310 // unit runs use a small host-protocol fixture and need no Node/Electron runtime.
311 func TestBrowserDiagnosticHostEvidenceFinalJSON(t *testing.T) {
312 scope := strings.Repeat("a", 64)
313 report := json.RawMessage(`{"available":true,"scope":"` + scope + `","entries":[{"kind":"page","message":"https://example.test/path http://example.test/path"}]}`)
314 if fixture := os.Getenv("REASONIX_BROWSER_DIAGNOSTIC_FIXTURE"); fixture != "" {
315 data, err := os.ReadFile(fixture)
316 if err != nil {
317 t.Fatal(err)
318 }
319 report = data
320 }
321 host := &fakeBrowserHost{replies: map[string]any{"host/browser.exportDiagnostics": report}}
322 evidence := captureBrowserHostDiagnostics(t.Context(), host, scope)
323 encoded, err := json.Marshal(map[string]any{"host": evidence})
324 if err != nil {
325 t.Fatal(err)
326 }
327 encoded = []byte(secrets.Redact(string(encoded)))
328 path := filepath.Join(t.TempDir(), "final-diagnostics.json")
329 if err = writeGoalDiagnosticsFile(path, func(dst io.Writer) error {
330 if _, err := io.WriteString(dst, `{"commits":[]}`); err != nil {
331 return err
332 }
333 return appendBrowserDiagnosticSection(dst, encoded)
334 }); err != nil {
335 t.Fatal(err)
336 }
337 data, err := os.ReadFile(path)
338 if err != nil {
339 t.Fatal(err)
340 }
341 var exported struct {
342 Browser struct {
343 Host struct {
344 Available bool `json:"available"`
345 Entries []json.RawMessage `json:"entries"`
346 } `json:"host"`
347 } `json:"browserDiagnostics"`
348 }
349 if err = json.Unmarshal(data, &exported); err != nil {
350 t.Fatal(err)
351 }
352 if !exported.Browser.Host.Available || len(exported.Browser.Host.Entries) == 0 {
353 t.Fatal("lost native diagnostic evidence")
354 }
355 for _, secret := range []string{"NATIVE-SECRET", "REVIEW-OAUTH-CODE", "REVIEW-PASSWORD", "REVIEW-FRAGMENT", "REVIEW-SIGNATURE"} {
356 if bytes.Contains(data, []byte(secret)) {
357 t.Fatalf("secret survived final JSON: %s", secret)
358 }
359 }
360 if !bytes.Contains(data, []byte("https://example.test/path")) || !bytes.Contains(data, []byte("http://example.test/path")) {
361 t.Fatal("sanitized URL evidence was lost")
362 }
363 }
364
364 lines GO