package desktop import ( "context" "errors" "os" "os/exec" "path/filepath" "strings" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/bnema/dumber/internal/domain/entity" ) const testCaptureEnvFile = "DUMBER_TEST_CAPTURE_ENV_FILE" const testRootCacheEnvVar = "DUMBER_TEST_ROOT_CACHE_PATH" type testSessionSpawnEnvironment struct { rootDir string } func (testSessionSpawnEnvironment) RootCacheEnvVar() string { return testRootCacheEnvVar } func (e testSessionSpawnEnvironment) SessionRootCachePath(sessionID entity.SessionID) string { return filepath.Join(e.rootDir, "sessions", string(sessionID)) } func TestMain(m *testing.M) { if envFile := os.Getenv(testCaptureEnvFile); envFile != "" && len(os.Args) >= 2 && os.Args[1] == "browse" { if err := writeFileAtomically(envFile, []byte(strings.Join(os.Environ(), "\n")), 0o644); err != nil { os.Exit(2) } os.Exit(0) } os.Exit(m.Run()) } func writeFileAtomically(path string, data []byte, perm os.FileMode) error { tmpFile, err := os.CreateTemp(filepath.Dir(path), filepath.Base(path)+".tmp-*") if err != nil { return err } tmpPath := tmpFile.Name() defer func() { _ = os.Remove(tmpPath) }() if _, err := tmpFile.Write(data); err != nil { _ = tmpFile.Close() return err } if err := tmpFile.Chmod(perm); err != nil { _ = tmpFile.Close() return err } if err := tmpFile.Close(); err != nil { return err } if err := os.Rename(tmpPath, path); err != nil { return err } return nil } func TestSanitizedChildEnv_RemovesLayerShellPreloadOnly(t *testing.T) { env := sanitizedChildEnv([]string{ "PATH=/usr/bin", "LD_PRELOAD=/tmp/libgtk4-layer-shell.so.0 /tmp/keep.so", }) if len(env) != 2 { t.Fatalf("expected two env entries, got %#v", env) } if env[0] != "PATH=/usr/bin" { t.Fatalf("expected PATH to be preserved, got %#v", env) } if env[1] != "LD_PRELOAD=/tmp/keep.so" { t.Fatalf("expected non-layer-shell preload to remain, got %#v", env) } } func TestSanitizedChildEnv_DropsEmptyLDPreload(t *testing.T) { env := sanitizedChildEnv([]string{"LD_PRELOAD=/tmp/libgtk4-layer-shell.so.0"}) if len(env) != 0 { t.Fatalf("expected layer-shell-only preload to be removed, got %#v", env) } } func TestSanitizedChildEnv_PreservesColonSeparatedEntries(t *testing.T) { env := sanitizedChildEnv([]string{"LD_PRELOAD=a.so:/tmp/libgtk4-layer-shell.so.0:b.so"}) if len(env) != 1 || env[0] != "LD_PRELOAD=a.so b.so" { t.Fatalf("expected unrelated colon-separated entries to remain, got %#v", env) } } func TestWithoutEnvKeys_RemovesExplicitOverrides(t *testing.T) { env := withoutEnvKeys([]string{ "PATH=/usr/bin", RestoreSessionEnvVar + "=old-session", testRootCacheEnvVar + "=/tmp/old-root", }, RestoreSessionEnvVar, testRootCacheEnvVar) if len(env) != 1 || env[0] != "PATH=/usr/bin" { t.Fatalf("expected override keys to be removed, got %#v", env) } } func TestSessionSpawner_SpawnWithSession_PassesRestoreSessionAndEngineOverride(t *testing.T) { rootDir := filepath.Join(t.TempDir(), "cef-root") spawner := NewSessionSpawner(t.Context(), testSessionSpawnEnvironment{rootDir: rootDir}) childEnvFile := filepath.Join(t.TempDir(), "child-env.txt") t.Setenv(testCaptureEnvFile, childEnvFile) t.Setenv("XDG_DATA_HOME", filepath.Join(t.TempDir(), "data")) t.Setenv("ENV", "") sessionID := entity.SessionID("session-123") requireNoError(t, spawner.SpawnWithSession(sessionID)) env := readEnvFile(t, childEnvFile) wantRoot := filepath.Join(rootDir, "sessions", string(sessionID)) if got := env[RestoreSessionEnvVar]; got != string(sessionID) { t.Fatalf("restore session env = %q, want %q", got, string(sessionID)) } if got := env[testRootCacheEnvVar]; got != wantRoot { t.Fatalf("engine state root env = %q, want %q", got, wantRoot) } } func readEnvFile(t *testing.T, path string) map[string]string { t.Helper() deadline := time.Now().Add(2 * time.Second) for time.Now().Before(deadline) { data, err := os.ReadFile(path) if err == nil { env := make(map[string]string) for line := range strings.SplitSeq(strings.TrimSpace(string(data)), "\n") { if line == "" { continue } key, value, ok := strings.Cut(line, "=") if ok { env[key] = value } } return env } time.Sleep(10 * time.Millisecond) } t.Fatalf("timed out waiting for child env file %q", path) return nil } func TestSessionSpawner_SpawnWithSession_OmitsEngineOverrideWhenNotConfigured(t *testing.T) { spawner := NewSessionSpawner(t.Context(), nil) childEnvFile := filepath.Join(t.TempDir(), "child-env.txt") t.Setenv(testCaptureEnvFile, childEnvFile) t.Setenv("ENV", "") sessionID := entity.SessionID("session-plain") requireNoError(t, spawner.SpawnWithSession(sessionID)) env := readEnvFile(t, childEnvFile) if got := env[RestoreSessionEnvVar]; got != string(sessionID) { t.Fatalf("restore session env = %q, want %q", got, string(sessionID)) } if got := env[testRootCacheEnvVar]; got != "" { t.Fatalf("engine state root env = %q, want empty", got) } } func TestLaunchBrowserBrowseURL_ResolveErrorDoesNotLeakURL(t *testing.T) { wantErr := errors.New("resolve failed") err := launchBrowserBrowseURL( "https://example.com/private?token=secret", func() (string, error) { return "", wantErr }, nil, ) if err == nil { t.Fatal("expected resolve error") } if !errors.Is(err, wantErr) { t.Fatalf("expected error %v, got %v", wantErr, err) } if strings.Contains(err.Error(), "token=secret") || strings.Contains(err.Error(), "https://example.com") { t.Fatalf("expected error to redact URL, got %q", err) } } func TestLaunchBrowserBrowseURL_SpawnErrorDoesNotLeakURL(t *testing.T) { wantErr := errors.New("spawn failed") err := launchBrowserBrowseURL( "https://example.com/private?token=secret", func() (string, error) { return "/usr/bin/dumber", nil }, func(*exec.Cmd) error { return wantErr }, ) if err == nil { t.Fatal("expected spawn error") } if !errors.Is(err, wantErr) { t.Fatalf("expected error %v, got %v", wantErr, err) } if strings.Contains(err.Error(), "token=secret") || strings.Contains(err.Error(), "https://example.com") { t.Fatalf("expected error to redact URL, got %q", err) } } func TestLaunchBrowserBrowseURL_ClearsFreshWindowMarker(t *testing.T) { t.Setenv(FreshWindowLaunchEnvVar, "1") err := launchBrowserBrowseURL( "https://example.com", func() (string, error) { return "/usr/bin/dumber", nil }, func(cmd *exec.Cmd) error { assert.NotContains(t, cmd.Env, FreshWindowLaunchEnvVar+"=1") return nil }, ) require.NoError(t, err) } func TestSetAsDefaultBrowserAlsoUpdatesMimeHandlers(t *testing.T) { t.Setenv("HOME", t.TempDir()) t.Setenv("XDG_DATA_HOME", filepath.Join(os.Getenv("HOME"), ".local", "share")) desktopPath, err := getDesktopFilePath() require.NoError(t, err) require.NoError(t, os.MkdirAll(filepath.Dir(desktopPath), 0o755)) require.NoError(t, os.WriteFile(desktopPath, []byte(desktopFileTemplate), 0o644)) binDir := t.TempDir() logPath := filepath.Join(binDir, "commands.log") xdgSettingsPath := writeExecutable(t, filepath.Join(binDir, "xdg-settings"), "#!/bin/sh\necho \"$@\" >> \""+logPath+"\"\n") xdgMimePath := writeExecutable(t, filepath.Join(binDir, "xdg-mime"), "#!/bin/sh\necho \"$@\" >> \""+logPath+"\"\n") a := &Adapter{xdgSettingsPath: xdgSettingsPath, xdgMimePath: xdgMimePath} desktopFile := a.findDesktopFile(context.Background()) require.NotEmpty(t, desktopFile) err = a.SetAsDefaultBrowser(context.Background()) require.NoError(t, err) logged, err := os.ReadFile(logPath) require.NoError(t, err) commands := strings.Split(strings.TrimSpace(string(logged)), "\n") assert.Contains(t, commands, "set default-web-browser "+desktopFile) assert.Contains(t, commands, "default "+desktopFile+" x-scheme-handler/http x-scheme-handler/https text/html text/xml application/xhtml+xml") } func TestGetStatusRequiresBrowserLinkMimeHandlersToMatch(t *testing.T) { t.Setenv("HOME", t.TempDir()) t.Setenv("XDG_DATA_HOME", filepath.Join(os.Getenv("HOME"), ".local", "share")) binDir := t.TempDir() xdgSettingsPath := writeExecutable(t, filepath.Join(binDir, "xdg-settings"), "#!/bin/sh\nif [ \"$1\" = \"get\" ]; then\n echo dumber.desktop\nfi\n") 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") a := &Adapter{xdgSettingsPath: xdgSettingsPath, xdgMimePath: xdgMimePath} status, err := a.GetStatus(context.Background()) require.NoError(t, err) assert.False(t, status.IsDefaultBrowser) } func writeExecutable(t *testing.T, path, content string) string { t.Helper() require.NoError(t, os.WriteFile(path, []byte(content), 0o755)) return path } func requireNoError(t *testing.T, err error) { t.Helper() if err != nil { t.Fatal(err) } } func TestLaunchBrowserBrowseURL_ChildEnvMergesRuntimeSafety(t *testing.T) { t.Setenv("GODEBUG", "gctrace=1") err := launchBrowserBrowseURL( "https://example.com", func() (string, error) { return "/usr/bin/dumber", nil }, func(cmd *exec.Cmd) error { found := false for _, entry := range cmd.Env { if entry == "GODEBUG=gctrace=1,gcshrinkstackoff=1" { found = true } } assert.True(t, found, "child env must merge safety after sanitization: %#v", cmd.Env) return nil }, ) require.NoError(t, err) }