| 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 |