1package desktop
2
3import (
4 "context"
5 "encoding/json"
6 "errors"
7 "net"
8 "os"
9 "path/filepath"
10 "testing"
11 "time"
12
13 "github.com/bnema/dumber/internal/infrastructure/runtimeprofile"
14 "github.com/stretchr/testify/assert"
15 "github.com/stretchr/testify/require"
16)
17
18type browserWindowOpenerFunc func(context.Context, string) error
19
20func (f browserWindowOpenerFunc) OpenExternalURL(ctx context.Context, url string) error {
21 return f(ctx, url)
22}
23
24func (f browserWindowOpenerFunc) OpenFreshWindow(ctx context.Context, url string) error {
25 return f(ctx, url)
26}
27
28type browserWindowOpener struct {
29 openExternalURL func(context.Context, string) error
30 openFreshWindow func(context.Context, string) error
31}
32
33func (o browserWindowOpener) OpenExternalURL(ctx context.Context, url string) error {
34 return o.openExternalURL(ctx, url)
35}
36
37func (o browserWindowOpener) OpenFreshWindow(ctx context.Context, url string) error {
38 return o.openFreshWindow(ctx, url)
39}
40
41func waitForSocket(t *testing.T, path string) {
42 t.Helper()
43
44 require.Eventually(t, func() bool {
45 _, err := os.Stat(path)
46 return err == nil
47 }, time.Second, 10*time.Millisecond)
48}
49
50func waitForSocketGone(t *testing.T, path string) {
51 t.Helper()
52
53 require.Eventually(t, func() bool {
54 _, err := os.Stat(path)
55 return os.IsNotExist(err)
56 }, time.Second, 10*time.Millisecond)
57}
58
59func shortTempDir(t *testing.T) string {
60 t.Helper()
61
62 dir, err := os.MkdirTemp("", "dbr-")
63 require.NoError(t, err)
64 t.Cleanup(func() {
65 _ = os.RemoveAll(dir)
66 })
67
68 return dir
69}
70
71func TestValidateBrowserLaunchSocketDirOwnedAcceptsCurrentUser(t *testing.T) {
72 ipc := testIPC(shortTempDir(t))
73 require.NoError(t, os.MkdirAll(filepath.Dir(ipc.BrowserLaunchSocket), 0o700))
74
75 require.NoError(t, validateBrowserLaunchSocketDirOwned(ipc.BrowserLaunchSocket, uint32(os.Geteuid())))
76}
77
78func TestValidateBrowserLaunchSocketDirOwnedRejectsUnexpectedOwner(t *testing.T) {
79 ipc := testIPC(shortTempDir(t))
80 require.NoError(t, os.MkdirAll(filepath.Dir(ipc.BrowserLaunchSocket), 0o700))
81
82 unexpectedUID := uint32(os.Geteuid() + 1)
83 if unexpectedUID == uint32(os.Geteuid()) {
84 unexpectedUID++
85 }
86
87 err := validateBrowserLaunchSocketDirOwned(ipc.BrowserLaunchSocket, unexpectedUID)
88 require.Error(t, err)
89 require.Contains(t, err.Error(), "browser launch dir owner")
90}
91
92func TestValidateBrowserLaunchSocketDirOwnedRejectsWritableUserParent(t *testing.T) {
93 root := shortTempDir(t)
94 ipc := runtimeprofile.IPCPaths{
95 RuntimeDir: filepath.Join(root, "dumber", "dev-abc123", "cef"),
96 BrowserLaunchSocket: filepath.Join(root, "dumber", "dev-abc123", "cef", browserLaunchSocketName),
97 }
98 require.NoError(t, os.MkdirAll(filepath.Dir(ipc.BrowserLaunchSocket), 0o700))
99 require.NoError(t, os.Chmod(filepath.Join(root, "dumber"), 0o777))
100 t.Cleanup(func() {
101 _ = os.Chmod(filepath.Join(root, "dumber"), 0o700)
102 })
103
104 err := validateBrowserLaunchSocketDirOwned(ipc.BrowserLaunchSocket, uint32(os.Geteuid()))
105 require.Error(t, err)
106 require.Contains(t, err.Error(), "browser launch dir permissions")
107}
108
109func testIPC(root string) runtimeprofile.IPCPaths {
110 runtimeDir := filepath.Join(root, "runtime")
111 return runtimeprofile.IPCPaths{
112 RuntimeDir: runtimeDir,
113 BrowserLaunchSocket: filepath.Join(runtimeDir, browserLaunchSocketName),
114 }
115}
116
117func testNamespacedIPC(root, engine string) runtimeprofile.IPCPaths {
118 runtimeDir := filepath.Join(root, "runtime", engine)
119 return runtimeprofile.IPCPaths{
120 RuntimeDir: runtimeDir,
121 BrowserLaunchSocket: filepath.Join(runtimeDir, browserLaunchSocketName),
122 }
123}
124
125func overrideBrowserLaunchRequestID(t *testing.T, id string) func() {
126 t.Helper()
127 previous := newBrowserLaunchRequestID
128 newBrowserLaunchRequestID = func() string { return id }
129 return func() { newBrowserLaunchRequestID = previous }
130}
131
132func TestBrowserLaunchRelay_DeliverOpenExternalURL_MissingListenerReturnsFalseNil(t *testing.T) {
133 relay := NewBrowserLaunchRelay(testIPC(shortTempDir(t)))
134
135 delivered, err := relay.DeliverOpenExternalURL(context.Background(), "https://example.com")
136
137 require.NoError(t, err)
138 assert.False(t, delivered)
139}
140
141func TestBrowserLaunchRelay_SameProfileAndEngine_UsesSameSocket(t *testing.T) {
142 ipc := testNamespacedIPC(shortTempDir(t), "cef")
143 listenerRelay := NewBrowserLaunchRelay(ipc)
144 clientRelay := NewBrowserLaunchRelay(ipc)
145
146 received := make(chan string, 1)
147 ctx := t.Context()
148
149 closer, err := listenerRelay.Listen(ctx, browserWindowOpenerFunc(func(_ context.Context, url string) error {
150 received <- url
151 return nil
152 }))
153 require.NoError(t, err)
154 defer closer.Close()
155
156 waitForSocket(t, ipc.BrowserLaunchSocket)
157
158 delivered, err := clientRelay.DeliverOpenExternalURL(context.Background(), "https://example.com/same-namespace")
159 require.NoError(t, err)
160 require.True(t, delivered)
161 require.Equal(t, "https://example.com/same-namespace", <-received)
162}
163
164func TestBrowserLaunchRelay_DevCEFAndDevWebKit_UseDifferentSockets(t *testing.T) {
165 root := t.TempDir()
166 cefIPC := testNamespacedIPC(root, "cef")
167 wkIPC := testNamespacedIPC(root, "webkit")
168 require.NotEqual(t, cefIPC.BrowserLaunchSocket, wkIPC.BrowserLaunchSocket)
169}
170
171func TestBrowserLaunchRelay_DeliverOpenExternalURL_SendsRequestIDAndAcceptsMatchingResponse(t *testing.T) {
172 ipc := testIPC(shortTempDir(t))
173 relay := NewBrowserLaunchRelay(ipc)
174 restore := overrideBrowserLaunchRequestID(t, "request-test-1")
175 defer restore()
176
177 receivedRequest := make(chan browserLaunchRequest, 1)
178 require.NoError(t, os.MkdirAll(ipc.RuntimeDir, 0o700))
179 listener, err := net.ListenUnix("unix", &net.UnixAddr{Name: ipc.BrowserLaunchSocket, Net: "unix"})
180 require.NoError(t, err)
181 defer func() {
182 _ = listener.Close()
183 _ = os.Remove(ipc.BrowserLaunchSocket)
184 }()
185
186 go func() {
187 conn, acceptErr := listener.AcceptUnix()
188 if acceptErr != nil {
189 return
190 }
191 defer conn.Close()
192 var request browserLaunchRequest
193 if decodeErr := json.NewDecoder(conn).Decode(&request); decodeErr != nil {
194 return
195 }
196 receivedRequest <- request
197 _ = json.NewEncoder(conn).Encode(browserLaunchResponse{RequestID: request.RequestID, Accepted: true})
198 }()
199
200 delivered, err := relay.DeliverOpenExternalURL(context.Background(), "https://example.com/request-id")
201
202 require.NoError(t, err)
203 require.True(t, delivered)
204 request := <-receivedRequest
205 require.Equal(t, "request-test-1", request.RequestID)
206 require.Equal(t, "https://example.com/request-id", request.URL)
207}
208
209func TestBrowserLaunchRelay_DeliverOpenExternalURL_RejectsMismatchedResponseRequestID(t *testing.T) {
210 ipc := testIPC(shortTempDir(t))
211 relay := NewBrowserLaunchRelay(ipc)
212 restore := overrideBrowserLaunchRequestID(t, "request-test-2")
213 defer restore()
214
215 require.NoError(t, os.MkdirAll(ipc.RuntimeDir, 0o700))
216 listener, err := net.ListenUnix("unix", &net.UnixAddr{Name: ipc.BrowserLaunchSocket, Net: "unix"})
217 require.NoError(t, err)
218 defer func() {
219 _ = listener.Close()
220 _ = os.Remove(ipc.BrowserLaunchSocket)
221 }()
222
223 go func() {
224 conn, acceptErr := listener.AcceptUnix()
225 if acceptErr != nil {
226 return
227 }
228 defer conn.Close()
229 var request browserLaunchRequest
230 if decodeErr := json.NewDecoder(conn).Decode(&request); decodeErr != nil {
231 return
232 }
233 _ = json.NewEncoder(conn).Encode(browserLaunchResponse{RequestID: "different-request", Accepted: true})
234 }()
235
236 delivered, err := relay.DeliverOpenExternalURL(context.Background(), "https://example.com/request-id-mismatch")
237
238 require.Error(t, err)
239 require.True(t, delivered)
240 require.Contains(t, err.Error(), "mismatched browser launch relay response")
241}
242
243func TestBrowserLaunchRelay_DeliverOpenExternalURL_RoundTrip(t *testing.T) {
244 ipc := testIPC(shortTempDir(t))
245 relay := NewBrowserLaunchRelay(ipc)
246
247 received := make(chan string, 1)
248 ctx := t.Context()
249
250 closer, err := relay.Listen(ctx, browserWindowOpener{
251 openExternalURL: func(_ context.Context, url string) error {
252 received <- url
253 return nil
254 },
255 openFreshWindow: func(context.Context, string) error {
256 t.Fatal("external URL relay request must preserve configured placement")
257 return nil
258 },
259 })
260 require.NoError(t, err)
261 defer closer.Close()
262
263 waitForSocket(t, ipc.BrowserLaunchSocket)
264
265 delivered, err := relay.DeliverOpenExternalURL(context.Background(), "https://example.com/new")
266
267 require.NoError(t, err)
268 assert.True(t, delivered)
269
270 select {
271 case got := <-received:
272 assert.Equal(t, "https://example.com/new", got)
273 case <-time.After(time.Second):
274 t.Fatal("expected opener to receive the URL")
275 }
276}
277
278func TestBrowserLaunchRelay_DeliverOpenFreshWindow_RoundTrip(t *testing.T) {
279 ipc := testIPC(shortTempDir(t))
280 relay := NewBrowserLaunchRelay(ipc)
281
282 freshWindows := make(chan string, 1)
283 ctx := t.Context()
284 closer, err := relay.Listen(ctx, browserWindowOpener{
285 openExternalURL: func(context.Context, string) error {
286 t.Fatal("external-link placement must not handle standalone omnibox navigation")
287 return nil
288 },
289 openFreshWindow: func(_ context.Context, url string) error {
290 freshWindows <- url
291 return nil
292 },
293 })
294 require.NoError(t, err)
295 defer closer.Close()
296
297 waitForSocket(t, ipc.BrowserLaunchSocket)
298 delivered, err := relay.DeliverOpenFreshWindow(context.Background(), "https://example.com/omnibox")
299
300 require.NoError(t, err)
301 require.True(t, delivered)
302 select {
303 case got := <-freshWindows:
304 assert.Equal(t, "https://example.com/omnibox", got)
305 case <-time.After(time.Second):
306 t.Fatal("expected fresh-window opener to receive the URL")
307 }
308}
309
310func TestBrowserLaunchRelay_RequiresBrowserLaunchSocketPath(t *testing.T) {
311 relay := NewBrowserLaunchRelay(runtimeprofile.IPCPaths{})
312
313 _, err := relay.Listen(context.Background(), browserWindowOpenerFunc(func(context.Context, string) error { return nil }))
314 require.Error(t, err)
315 require.Contains(t, err.Error(), "browser launch socket path")
316}
317
318func TestBrowserLaunchRelay_SecondListenWhileLiveFailsWithoutReplacingListener(t *testing.T) {
319 ipc := testIPC(shortTempDir(t))
320 relay := NewBrowserLaunchRelay(ipc)
321
322 firstReceived := make(chan string, 1)
323 ctx := t.Context()
324
325 closer, err := relay.Listen(ctx, browserWindowOpenerFunc(func(_ context.Context, url string) error {
326 firstReceived <- url
327 return nil
328 }))
329 require.NoError(t, err)
330 defer closer.Close()
331
332 waitForSocket(t, ipc.BrowserLaunchSocket)
333
334 _, err = relay.Listen(ctx, browserWindowOpenerFunc(func(context.Context, string) error { return nil }))
335 require.Error(t, err)
336
337 delivered, err := relay.DeliverOpenExternalURL(context.Background(), "https://example.com/live")
338 require.NoError(t, err)
339 require.True(t, delivered)
340
341 select {
342 case got := <-firstReceived:
343 assert.Equal(t, "https://example.com/live", got)
344 case <-time.After(time.Second):
345 t.Fatal("expected the original listener to remain active")
346 }
347}
348
349func TestBrowserLaunchRelay_RebindsStaleSocketPath(t *testing.T) {
350 ipc := testIPC(shortTempDir(t))
351 relay := NewBrowserLaunchRelay(ipc)
352
353 require.NoError(t, os.MkdirAll(filepath.Dir(ipc.BrowserLaunchSocket), 0o700))
354 require.NoError(t, os.WriteFile(ipc.BrowserLaunchSocket, []byte("stale"), 0o600))
355
356 received := make(chan string, 1)
357 ctx := t.Context()
358
359 closer, err := relay.Listen(ctx, browserWindowOpenerFunc(func(_ context.Context, url string) error {
360 received <- url
361 return nil
362 }))
363 require.NoError(t, err)
364 defer closer.Close()
365
366 waitForSocket(t, ipc.BrowserLaunchSocket)
367
368 delivered, err := relay.DeliverOpenExternalURL(context.Background(), "https://example.com/stale")
369 require.NoError(t, err)
370 require.True(t, delivered)
371
372 select {
373 case got := <-received:
374 assert.Equal(t, "https://example.com/stale", got)
375 case <-time.After(time.Second):
376 t.Fatal("expected stale socket path to be rebound")
377 }
378}
379
380func TestBrowserLaunchRelay_RejectsMalformedPayloads(t *testing.T) {
381 ipc := testIPC(shortTempDir(t))
382 relay := NewBrowserLaunchRelay(ipc)
383
384 received := make(chan string, 1)
385 ctx := t.Context()
386
387 closer, err := relay.Listen(ctx, browserWindowOpenerFunc(func(_ context.Context, url string) error {
388 received <- url
389 return nil
390 }))
391 require.NoError(t, err)
392 defer closer.Close()
393
394 waitForSocket(t, ipc.BrowserLaunchSocket)
395
396 conn, err := net.Dial("unix", ipc.BrowserLaunchSocket)
397 require.NoError(t, err)
398
399 _, err = conn.Write([]byte("not-json"))
400 require.NoError(t, err)
401 require.NoError(t, conn.Close())
402
403 delivered, err := relay.DeliverOpenExternalURL(context.Background(), "https://example.com/still-works")
404
405 require.NoError(t, err)
406 assert.True(t, delivered)
407
408 select {
409 case got := <-received:
410 assert.Equal(t, "https://example.com/still-works", got)
411 case <-time.After(time.Second):
412 t.Fatal("expected opener to receive the URL after malformed payload")
413 }
414}
415
416func TestBrowserLaunchRelay_CloseRemovesSocketPath(t *testing.T) {
417 ipc := testIPC(shortTempDir(t))
418 relay := NewBrowserLaunchRelay(ipc)
419
420 ctx := t.Context()
421
422 closer, err := relay.Listen(ctx, browserWindowOpenerFunc(func(context.Context, string) error { return nil }))
423 require.NoError(t, err)
424
425 waitForSocket(t, ipc.BrowserLaunchSocket)
426
427 require.NoError(t, closer.Close())
428 waitForSocketGone(t, ipc.BrowserLaunchSocket)
429}
430
431func TestBrowserLaunchRelay_ContextCancelStopsServing(t *testing.T) {
432 ipc := testIPC(shortTempDir(t))
433 relay := NewBrowserLaunchRelay(ipc)
434
435 ctx, cancel := context.WithCancel(context.Background())
436 closer, err := relay.Listen(ctx, browserWindowOpenerFunc(func(context.Context, string) error { return nil }))
437 require.NoError(t, err)
438
439 waitForSocket(t, ipc.BrowserLaunchSocket)
440
441 cancel()
442 waitForSocketGone(t, ipc.BrowserLaunchSocket)
443 require.NoError(t, closer.Close())
444
445 delivered, err := relay.DeliverOpenExternalURL(context.Background(), "https://example.com/after-cancel")
446 require.NoError(t, err)
447 assert.False(t, delivered)
448}
449
450func TestBrowserLaunchRelay_DeliverOpenExternalURL_RespectsResponseReadTimeout(t *testing.T) {
451 ipc := testIPC(shortTempDir(t))
452 require.NoError(t, os.MkdirAll(ipc.RuntimeDir, 0o700))
453
454 listener, err := net.ListenUnix("unix", &net.UnixAddr{Name: ipc.BrowserLaunchSocket, Net: "unix"})
455 require.NoError(t, err)
456 defer func() {
457 _ = listener.Close()
458 _ = os.Remove(ipc.BrowserLaunchSocket)
459 }()
460
461 ready := make(chan struct{})
462 go func() {
463 close(ready)
464 conn, acceptErr := listener.AcceptUnix()
465 if acceptErr != nil {
466 return
467 }
468 defer conn.Close()
469 <-time.After(time.Second)
470 }()
471 <-ready
472
473 relay := NewBrowserLaunchRelay(ipc)
474 ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
475 defer cancel()
476
477 delivered, err := relay.DeliverOpenExternalURL(ctx, "https://example.com/slow")
478 require.Error(t, err)
479 require.ErrorIs(t, err, context.DeadlineExceeded)
480 assert.False(t, delivered)
481}
482
483func TestBrowserLaunchRelay_DeliverOpenExternalURL_ReturnsUnconfirmedErrorOnNoDeadlineTimeout(t *testing.T) {
484 ipc := testIPC(shortTempDir(t))
485 require.NoError(t, os.MkdirAll(ipc.RuntimeDir, 0o700))
486
487 listener, err := net.ListenUnix("unix", &net.UnixAddr{Name: ipc.BrowserLaunchSocket, Net: "unix"})
488 require.NoError(t, err)
489 defer func() {
490 _ = listener.Close()
491 _ = os.Remove(ipc.BrowserLaunchSocket)
492 }()
493
494 ready := make(chan struct{})
495 go func() {
496 close(ready)
497 conn, acceptErr := listener.AcceptUnix()
498 if acceptErr != nil {
499 return
500 }
501 defer conn.Close()
502 <-time.After(time.Second)
503 }()
504 <-ready
505
506 relay := NewBrowserLaunchRelay(ipc)
507
508 started := make(chan struct{})
509
510 type deliverResult struct {
511 delivered bool
512 err error
513 }
514 result := make(chan deliverResult, 1)
515 go func() {
516 close(started)
517 delivered, err := relay.DeliverOpenExternalURL(context.Background(), "https://example.com/slow")
518 result <- deliverResult{delivered: delivered, err: err}
519 }()
520 <-started
521
522 select {
523 case got := <-result:
524 assert.True(t, got.delivered)
525 require.Error(t, got.err)
526 require.ErrorIs(t, got.err, ErrBrowserLaunchRelayUnconfirmed)
527 case <-time.After(browserLaunchIOTimeout * 3):
528 t.Fatal("DeliverOpenExternalURL blocked without a caller deadline")
529 }
530}
531
532func TestBrowserLaunchRelay_AcknowledgesBeforeOpenerReturns(t *testing.T) {
533 ipc := testIPC(shortTempDir(t))
534 relay := NewBrowserLaunchRelay(ipc)
535
536 started := make(chan struct{})
537 release := make(chan struct{})
538 ctx := t.Context()
539
540 closer, err := relay.Listen(ctx, browserWindowOpenerFunc(func(_ context.Context, _ string) error {
541 close(started)
542 <-release
543 return nil
544 }))
545 require.NoError(t, err)
546 defer closer.Close()
547
548 waitForSocket(t, ipc.BrowserLaunchSocket)
549
550 conn, err := net.Dial("unix", ipc.BrowserLaunchSocket)
551 require.NoError(t, err)
552 defer conn.Close()
553 require.NoError(t, conn.SetDeadline(time.Now().Add(300*time.Millisecond)))
554 require.NoError(t, json.NewEncoder(conn).Encode(browserLaunchRequest{RequestID: "raw-request-1", URL: "https://example.com/slow-but-healthy"}))
555
556 var response browserLaunchResponse
557 require.NoError(t, json.NewDecoder(conn).Decode(&response))
558 assert.Empty(t, response.Error)
559 assert.True(t, response.Accepted)
560 assert.Equal(t, "raw-request-1", response.RequestID)
561
562 select {
563 case <-started:
564 case <-time.After(time.Second):
565 t.Fatal("expected opener to be invoked after acknowledgement")
566 }
567 close(release)
568}
569
570func TestBrowserLaunchRelay_AcknowledgesAcceptedEvenWhenOpenerReturnsLateError(t *testing.T) {
571 ipc := testIPC(shortTempDir(t))
572 relay := NewBrowserLaunchRelay(ipc)
573
574 started := make(chan struct{})
575 ctx := t.Context()
576
577 closer, err := relay.Listen(ctx, browserWindowOpenerFunc(func(_ context.Context, _ string) error {
578 close(started)
579 return errors.New("late open failure")
580 }))
581 require.NoError(t, err)
582 defer closer.Close()
583
584 waitForSocket(t, ipc.BrowserLaunchSocket)
585
586 conn, err := net.Dial("unix", ipc.BrowserLaunchSocket)
587 require.NoError(t, err)
588 defer conn.Close()
589 require.NoError(t, conn.SetDeadline(time.Now().Add(300*time.Millisecond)))
590 require.NoError(t, json.NewEncoder(conn).Encode(browserLaunchRequest{RequestID: "raw-request-2", URL: "https://example.com/fails-later"}))
591
592 var response browserLaunchResponse
593 require.NoError(t, json.NewDecoder(conn).Decode(&response))
594 assert.Empty(t, response.Error)
595 assert.True(t, response.Accepted)
596 assert.Equal(t, "raw-request-2", response.RequestID)
597
598 select {
599 case <-started:
600 case <-time.After(time.Second):
601 t.Fatal("expected opener to be invoked after accepted acknowledgement")
602 }
603}
604
605func TestBrowserLaunchRelay_SilentClientDoesNotStallListener(t *testing.T) {
606 ipc := testIPC(shortTempDir(t))
607 relay := NewBrowserLaunchRelay(ipc)
608
609 received := make(chan string, 1)
610 ctx := t.Context()
611
612 closer, err := relay.Listen(ctx, browserWindowOpenerFunc(func(_ context.Context, url string) error {
613 received <- url
614 return nil
615 }))
616 require.NoError(t, err)
617 defer closer.Close()
618
619 waitForSocket(t, ipc.BrowserLaunchSocket)
620
621 conn, err := net.Dial("unix", ipc.BrowserLaunchSocket)
622 require.NoError(t, err)
623 defer conn.Close()
624 time.Sleep(20 * time.Millisecond)
625
626 type deliverResult struct {
627 delivered bool
628 err error
629 }
630 result := make(chan deliverResult, 1)
631 go func() {
632 delivered, err := relay.DeliverOpenExternalURL(context.Background(), "https://example.com/recovered")
633 result <- deliverResult{delivered: delivered, err: err}
634 }()
635
636 select {
637 case got := <-result:
638 require.NoError(t, got.err)
639 require.True(t, got.delivered)
640 case <-time.After(300 * time.Millisecond):
641 t.Fatal("silent client stalled the relay listener")
642 }
643
644 select {
645 case got := <-received:
646 assert.Equal(t, "https://example.com/recovered", got)
647 case <-time.After(time.Second):
648 t.Fatal("expected opener to receive the URL")
649 }
650}