1package desktop
2
3import (
4 "context"
5 "errors"
6 "os"
7 "os/exec"
8 "path/filepath"
9 "strings"
10 "testing"
11 "time"
12
13 "github.com/stretchr/testify/assert"
14 "github.com/stretchr/testify/require"
15
16 "github.com/bnema/dumber/internal/domain/entity"
17)
18
19const testCaptureEnvFile = "DUMBER_TEST_CAPTURE_ENV_FILE"
20
21const testRootCacheEnvVar = "DUMBER_TEST_ROOT_CACHE_PATH"
22
23type testSessionSpawnEnvironment struct {
24 rootDir string
25}
26
27func (testSessionSpawnEnvironment) RootCacheEnvVar() string {
28 return testRootCacheEnvVar
29}
30
31func (e testSessionSpawnEnvironment) SessionRootCachePath(sessionID entity.SessionID) string {
32 return filepath.Join(e.rootDir, "sessions", string(sessionID))
33}
34
35func TestMain(m *testing.M) {
36 if envFile := os.Getenv(testCaptureEnvFile); envFile != "" && len(os.Args) >= 2 && os.Args[1] == "browse" {
37 if err := writeFileAtomically(envFile, []byte(strings.Join(os.Environ(), "\n")), 0o644); err != nil {
38 os.Exit(2)
39 }
40 os.Exit(0)
41 }
42
43 os.Exit(m.Run())
44}
45
46func writeFileAtomically(path string, data []byte, perm os.FileMode) error {
47 tmpFile, err := os.CreateTemp(filepath.Dir(path), filepath.Base(path)+".tmp-*")
48 if err != nil {
49 return err
50 }
51
52 tmpPath := tmpFile.Name()
53 defer func() {
54 _ = os.Remove(tmpPath)
55 }()
56
57 if _, err := tmpFile.Write(data); err != nil {
58 _ = tmpFile.Close()
59 return err
60 }
61 if err := tmpFile.Chmod(perm); err != nil {
62 _ = tmpFile.Close()
63 return err
64 }
65 if err := tmpFile.Close(); err != nil {
66 return err
67 }
68 if err := os.Rename(tmpPath, path); err != nil {
69 return err
70 }
71
72 return nil
73}
74
75func TestSanitizedChildEnv_RemovesLayerShellPreloadOnly(t *testing.T) {
76 env := sanitizedChildEnv([]string{
77 "PATH=/usr/bin",
78 "LD_PRELOAD=/tmp/libgtk4-layer-shell.so.0 /tmp/keep.so",
79 })
80
81 if len(env) != 2 {
82 t.Fatalf("expected two env entries, got %#v", env)
83 }
84 if env[0] != "PATH=/usr/bin" {
85 t.Fatalf("expected PATH to be preserved, got %#v", env)
86 }
87 if env[1] != "LD_PRELOAD=/tmp/keep.so" {
88 t.Fatalf("expected non-layer-shell preload to remain, got %#v", env)
89 }
90}
91
92func TestSanitizedChildEnv_DropsEmptyLDPreload(t *testing.T) {
93 env := sanitizedChildEnv([]string{"LD_PRELOAD=/tmp/libgtk4-layer-shell.so.0"})
94 if len(env) != 0 {
95 t.Fatalf("expected layer-shell-only preload to be removed, got %#v", env)
96 }
97}
98
99func TestSanitizedChildEnv_PreservesColonSeparatedEntries(t *testing.T) {
100 env := sanitizedChildEnv([]string{"LD_PRELOAD=a.so:/tmp/libgtk4-layer-shell.so.0:b.so"})
101 if len(env) != 1 || env[0] != "LD_PRELOAD=a.so b.so" {
102 t.Fatalf("expected unrelated colon-separated entries to remain, got %#v", env)
103 }
104}
105
106func TestWithoutEnvKeys_RemovesExplicitOverrides(t *testing.T) {
107 env := withoutEnvKeys([]string{
108 "PATH=/usr/bin",
109 RestoreSessionEnvVar + "=old-session",
110 testRootCacheEnvVar + "=/tmp/old-root",
111 }, RestoreSessionEnvVar, testRootCacheEnvVar)
112
113 if len(env) != 1 || env[0] != "PATH=/usr/bin" {
114 t.Fatalf("expected override keys to be removed, got %#v", env)
115 }
116}
117
118func TestSessionSpawner_SpawnWithSession_PassesRestoreSessionAndEngineOverride(t *testing.T) {
119 rootDir := filepath.Join(t.TempDir(), "cef-root")
120 spawner := NewSessionSpawner(t.Context(), testSessionSpawnEnvironment{rootDir: rootDir})
121 childEnvFile := filepath.Join(t.TempDir(), "child-env.txt")
122 t.Setenv(testCaptureEnvFile, childEnvFile)
123 t.Setenv("XDG_DATA_HOME", filepath.Join(t.TempDir(), "data"))
124 t.Setenv("ENV", "")
125
126 sessionID := entity.SessionID("session-123")
127 requireNoError(t, spawner.SpawnWithSession(sessionID))
128
129 env := readEnvFile(t, childEnvFile)
130 wantRoot := filepath.Join(rootDir, "sessions", string(sessionID))
131 if got := env[RestoreSessionEnvVar]; got != string(sessionID) {
132 t.Fatalf("restore session env = %q, want %q", got, string(sessionID))
133 }
134 if got := env[testRootCacheEnvVar]; got != wantRoot {
135 t.Fatalf("engine state root env = %q, want %q", got, wantRoot)
136 }
137}
138
139func readEnvFile(t *testing.T, path string) map[string]string {
140 t.Helper()
141
142 deadline := time.Now().Add(2 * time.Second)
143 for time.Now().Before(deadline) {
144 data, err := os.ReadFile(path)
145 if err == nil {
146 env := make(map[string]string)
147 for _, line := range strings.Split(strings.TrimSpace(string(data)), "\n") {
148 if line == "" {
149 continue
150 }
151 key, value, ok := strings.Cut(line, "=")
152 if ok {
153 env[key] = value
154 }
155 }
156 return env
157 }
158 time.Sleep(10 * time.Millisecond)
159 }
160
161 t.Fatalf("timed out waiting for child env file %q", path)
162 return nil
163}
164
165func TestSessionSpawner_SpawnWithSession_OmitsEngineOverrideWhenNotConfigured(t *testing.T) {
166 spawner := NewSessionSpawner(t.Context(), nil)
167 childEnvFile := filepath.Join(t.TempDir(), "child-env.txt")
168 t.Setenv(testCaptureEnvFile, childEnvFile)
169 t.Setenv("ENV", "")
170
171 sessionID := entity.SessionID("session-plain")
172 requireNoError(t, spawner.SpawnWithSession(sessionID))
173
174 env := readEnvFile(t, childEnvFile)
175 if got := env[RestoreSessionEnvVar]; got != string(sessionID) {
176 t.Fatalf("restore session env = %q, want %q", got, string(sessionID))
177 }
178 if got := env[testRootCacheEnvVar]; got != "" {
179 t.Fatalf("engine state root env = %q, want empty", got)
180 }
181}
182
183func TestLaunchBrowserBrowseURL_ResolveErrorDoesNotLeakURL(t *testing.T) {
184 wantErr := errors.New("resolve failed")
185
186 err := launchBrowserBrowseURL(
187 "https://example.com/private?token=secret",
188 func() (string, error) { return "", wantErr },
189 nil,
190 )
191
192 if err == nil {
193 t.Fatal("expected resolve error")
194 }
195 if !errors.Is(err, wantErr) {
196 t.Fatalf("expected error %v, got %v", wantErr, err)
197 }
198 if strings.Contains(err.Error(), "token=secret") || strings.Contains(err.Error(), "https://example.com") {
199 t.Fatalf("expected error to redact URL, got %q", err)
200 }
201}
202
203func TestLaunchBrowserBrowseURL_SpawnErrorDoesNotLeakURL(t *testing.T) {
204 wantErr := errors.New("spawn failed")
205
206 err := launchBrowserBrowseURL(
207 "https://example.com/private?token=secret",
208 func() (string, error) { return "/usr/bin/dumber", nil },
209 func(*exec.Cmd) error { return wantErr },
210 )
211
212 if err == nil {
213 t.Fatal("expected spawn error")
214 }
215 if !errors.Is(err, wantErr) {
216 t.Fatalf("expected error %v, got %v", wantErr, err)
217 }
218 if strings.Contains(err.Error(), "token=secret") || strings.Contains(err.Error(), "https://example.com") {
219 t.Fatalf("expected error to redact URL, got %q", err)
220 }
221}
222
223func TestSetAsDefaultBrowserAlsoUpdatesMimeHandlers(t *testing.T) {
224 t.Setenv("HOME", t.TempDir())
225 t.Setenv("XDG_DATA_HOME", filepath.Join(os.Getenv("HOME"), ".local", "share"))
226
227 desktopPath, err := getDesktopFilePath()
228 require.NoError(t, err)
229 require.NoError(t, os.MkdirAll(filepath.Dir(desktopPath), 0o755))
230 require.NoError(t, os.WriteFile(desktopPath, []byte(desktopFileTemplate), 0o644))
231
232 binDir := t.TempDir()
233 logPath := filepath.Join(binDir, "commands.log")
234
235 xdgSettingsPath := writeExecutable(t, filepath.Join(binDir, "xdg-settings"), "#!/bin/sh\necho \"$@\" >> \""+logPath+"\"\n")
236 xdgMimePath := writeExecutable(t, filepath.Join(binDir, "xdg-mime"), "#!/bin/sh\necho \"$@\" >> \""+logPath+"\"\n")
237
238 a := &Adapter{xdgSettingsPath: xdgSettingsPath, xdgMimePath: xdgMimePath}
239 desktopFile := a.findDesktopFile(context.Background())
240 require.NotEmpty(t, desktopFile)
241
242 err = a.SetAsDefaultBrowser(context.Background())
243 require.NoError(t, err)
244
245 logged, err := os.ReadFile(logPath)
246 require.NoError(t, err)
247 commands := strings.Split(strings.TrimSpace(string(logged)), "\n")
248
249 assert.Contains(t, commands, "set default-web-browser "+desktopFile)
250 assert.Contains(t, commands, "default "+desktopFile+" x-scheme-handler/http x-scheme-handler/https text/html text/xml application/xhtml+xml")
251}
252
253func TestGetStatusRequiresBrowserLinkMimeHandlersToMatch(t *testing.T) {
254 t.Setenv("HOME", t.TempDir())
255 t.Setenv("XDG_DATA_HOME", filepath.Join(os.Getenv("HOME"), ".local", "share"))
256
257 binDir := t.TempDir()
258 xdgSettingsPath := writeExecutable(t, filepath.Join(binDir, "xdg-settings"), "#!/bin/sh\nif [ \"$1\" = \"get\" ]; then\n echo dumber.desktop\nfi\n")
259 xdgMimePath := writeExecutable(t, filepath.Join(binDir, "xdg-mime"), "#!/bin/sh\nif [ \"$1\" = \"query\" ]; then\n if [ \"$3\" = \"x-scheme-handler/http\" ]; then\n echo zen.desktop\n else\n echo dumber.desktop\n fi\nfi\n")
260
261 a := &Adapter{xdgSettingsPath: xdgSettingsPath, xdgMimePath: xdgMimePath}
262
263 status, err := a.GetStatus(context.Background())
264 require.NoError(t, err)
265 assert.False(t, status.IsDefaultBrowser)
266}
267
268func writeExecutable(t *testing.T, path, content string) string {
269 t.Helper()
270 require.NoError(t, os.WriteFile(path, []byte(content), 0o755))
271 return path
272}
273
274func requireNoError(t *testing.T, err error) {
275 t.Helper()
276 if err != nil {
277 t.Fatal(err)
278 }
279}