Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit bf375b4

Browse files
feat: auto-approve non-duplicate extension requests in headless mode (#385)
* feat: auto-approve non-duplicate extension requests in headless mode * test: add headless mode approval logic tests for non-duplicate and duplicate downloads * fix: enforce duplicate download detection regardless of WarnOnDuplicate setting when extension prompt is enabled * fix: update duplicate download error message in headless mode * refactor: decouple duplicate detection logic from WarnOnDuplicate configuration to support headless mode requirements * refactor: decouple duplicate checking from configuration settings and improve test state isolation * refactor: standardize global state cleanup in cmd tests to include GlobalLifecycle
1 parent 6727801 commit bf375b4

5 files changed

Lines changed: 200 additions & 15 deletions

File tree

cmd/headless_approval_test.go

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
package cmd
2+
3+
import (
4+
"bytes"
5+
"fmt"
6+
"net/http"
7+
"net/http/httptest"
8+
"testing"
9+
10+
"github.com/SurgeDM/Surge/internal/config"
11+
"github.com/SurgeDM/Surge/internal/core"
12+
"github.com/SurgeDM/Surge/internal/download"
13+
"github.com/SurgeDM/Surge/internal/engine/state"
14+
"github.com/SurgeDM/Surge/internal/engine/types"
15+
"github.com/SurgeDM/Surge/internal/processing"
16+
)
17+
18+
func TestHandleDownload_HeadlessMode_AutoApprovesNonDuplicate(t *testing.T) {
19+
setupIsolatedCmdState(t)
20+
21+
// Simulation: headless mode (no TUI)
22+
origServerProgram := serverProgram
23+
serverProgram = nil
24+
t.Cleanup(func() { serverProgram = origServerProgram })
25+
26+
origLifecycle := GlobalLifecycle
27+
origPool := GlobalPool
28+
origProgress := GlobalProgressCh
29+
origService := GlobalService
30+
t.Cleanup(func() {
31+
GlobalLifecycle = origLifecycle
32+
GlobalPool = origPool
33+
GlobalProgressCh = origProgress
34+
GlobalService = origService
35+
})
36+
37+
// Enable ExtensionPrompt (default is true, but let's be explicit)
38+
settings := config.DefaultSettings()
39+
settings.Extension.ExtensionPrompt = true
40+
if err := config.SaveSettings(settings); err != nil {
41+
t.Fatalf("SaveSettings failed: %v", err)
42+
}
43+
44+
// Mock server for probe
45+
probeServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
46+
w.Header().Set("Content-Length", "1024")
47+
w.WriteHeader(http.StatusOK)
48+
_, _ = w.Write(make([]byte, 10))
49+
}))
50+
defer probeServer.Close()
51+
52+
progressCh := make(chan any, 10)
53+
GlobalProgressCh = progressCh
54+
GlobalPool = download.NewWorkerPool(progressCh, 1)
55+
56+
// Mock lifecycle to bypass real downloads
57+
GlobalLifecycle = processing.NewLifecycleManager(func(url, path, filename string, _ []string, headers map[string]string, explicit bool, totalSize int64, supportsRange bool) (string, error) {
58+
return "queued-id", nil
59+
}, nil)
60+
61+
svc := core.NewLocalDownloadService(GlobalPool)
62+
GlobalService = svc
63+
64+
// Verify it auto-approves even with ExtensionPrompt=true
65+
body := fmt.Sprintf(`{"url": %q, "skip_approval": false}`, probeServer.URL)
66+
req := httptest.NewRequest(http.MethodPost, "/download", bytes.NewBufferString(body))
67+
rec := httptest.NewRecorder()
68+
69+
handleDownload(rec, req, t.TempDir(), svc)
70+
71+
if rec.Code != http.StatusOK {
72+
t.Fatalf("expected 200 OK in headless mode for non-duplicate, got %d: %s", rec.Code, rec.Body.String())
73+
}
74+
}
75+
76+
func TestHandleDownload_HeadlessMode_RejectsDuplicateWithWarn(t *testing.T) {
77+
setupIsolatedCmdState(t)
78+
79+
// Simulation: headless mode
80+
origServerProgram := serverProgram
81+
serverProgram = nil
82+
t.Cleanup(func() { serverProgram = origServerProgram })
83+
84+
origPool := GlobalPool
85+
origProgress := GlobalProgressCh
86+
origService := GlobalService
87+
origLifecycle := GlobalLifecycle
88+
t.Cleanup(func() {
89+
GlobalPool = origPool
90+
GlobalProgressCh = origProgress
91+
GlobalService = origService
92+
GlobalLifecycle = origLifecycle
93+
})
94+
95+
// Enable WarnOnDuplicate
96+
settings := config.DefaultSettings()
97+
settings.General.WarnOnDuplicate = true
98+
if err := config.SaveSettings(settings); err != nil {
99+
t.Fatalf("SaveSettings failed: %v", err)
100+
}
101+
102+
progressCh := make(chan any, 10)
103+
GlobalProgressCh = progressCh
104+
GlobalPool = download.NewWorkerPool(progressCh, 1)
105+
106+
// Seed the DB with a "duplicate" entry
107+
url := "http://example.com/duplicate.bin"
108+
_ = state.AddToMasterList(types.DownloadEntry{
109+
ID: "dup-id",
110+
URL: url,
111+
Filename: "duplicate.bin",
112+
Status: "completed",
113+
})
114+
115+
svc := core.NewLocalDownloadService(GlobalPool)
116+
GlobalService = svc
117+
118+
// Verify it still rejects duplicates when WarnOnDuplicate is on
119+
body := fmt.Sprintf(`{"url": %q, "skip_approval": false}`, url)
120+
req := httptest.NewRequest(http.MethodPost, "/download", bytes.NewBufferString(body))
121+
rec := httptest.NewRecorder()
122+
123+
handleDownload(rec, req, t.TempDir(), svc)
124+
125+
if rec.Code != http.StatusConflict {
126+
t.Fatalf("expected 409 Conflict for duplicate in headless mode, got %d: %s", rec.Code, rec.Body.String())
127+
}
128+
}
129+
130+
func TestHandleDownload_HeadlessMode_RejectsExtensionPromptDuplicate(t *testing.T) {
131+
setupIsolatedCmdState(t)
132+
origServerProgram := serverProgram
133+
serverProgram = nil
134+
t.Cleanup(func() { serverProgram = origServerProgram })
135+
136+
origPool := GlobalPool
137+
origProgress := GlobalProgressCh
138+
origService := GlobalService
139+
origLifecycle := GlobalLifecycle
140+
t.Cleanup(func() {
141+
GlobalPool = origPool
142+
GlobalProgressCh = origProgress
143+
GlobalService = origService
144+
GlobalLifecycle = origLifecycle
145+
})
146+
147+
settings := config.DefaultSettings()
148+
settings.Extension.ExtensionPrompt = true
149+
settings.General.WarnOnDuplicate = false
150+
if err := config.SaveSettings(settings); err != nil {
151+
t.Fatalf("SaveSettings failed: %v", err)
152+
}
153+
154+
url := "http://example.com/already-downloaded.bin"
155+
_ = state.AddToMasterList(types.DownloadEntry{
156+
ID: "ext-dup-id", URL: url, Filename: "already-downloaded.bin", Status: "completed",
157+
})
158+
159+
progressCh := make(chan any, 10)
160+
GlobalProgressCh = progressCh
161+
GlobalPool = download.NewWorkerPool(progressCh, 1)
162+
svc := core.NewLocalDownloadService(GlobalPool)
163+
GlobalService = svc
164+
165+
body := fmt.Sprintf(`{"url": %q, "skip_approval": false}`, url)
166+
req := httptest.NewRequest(http.MethodPost, "/download", bytes.NewBufferString(body))
167+
rec := httptest.NewRecorder()
168+
handleDownload(rec, req, t.TempDir(), svc)
169+
170+
if rec.Code != http.StatusConflict {
171+
t.Fatalf("expected 409 for duplicate with ExtensionPrompt=true, got %d: %s", rec.Code, rec.Body.String())
172+
}
173+
}

cmd/http_handler_test.go

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -424,11 +424,19 @@ func (f *failingPublishService) Publish(msg interface{}) error {
424424
func TestHandleDownload_PublishError_RecordsPreflightError(t *testing.T) {
425425
setupIsolatedCmdState(t)
426426

427-
GlobalPool = download.NewWorkerPool(nil, 1)
427+
origPool := GlobalPool
428+
origProgress := GlobalProgressCh
429+
origService := GlobalService
430+
origLifecycle := GlobalLifecycle
428431
t.Cleanup(func() {
429-
GlobalPool = nil
432+
GlobalPool = origPool
433+
GlobalProgressCh = origProgress
434+
GlobalService = origService
435+
GlobalLifecycle = origLifecycle
430436
})
431437

438+
GlobalPool = download.NewWorkerPool(nil, 1)
439+
432440
origServerProgram := serverProgram
433441
serverProgram = &tea.Program{}
434442
t.Cleanup(func() { serverProgram = origServerProgram })
@@ -441,11 +449,7 @@ func TestHandleDownload_PublishError_RecordsPreflightError(t *testing.T) {
441449
}
442450

443451
svc := &failingPublishService{publishErr: errors.New("publish failed")}
444-
origService := GlobalService
445452
GlobalService = svc
446-
t.Cleanup(func() {
447-
GlobalService = origService
448-
})
449453

450454
outDir := t.TempDir()
451455
body := fmt.Sprintf(`{"url": %q, "path": %q}`, "http://example.com/file.bin", outDir)

cmd/root_downloads.go

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -183,7 +183,7 @@ func resolveDuplicateState(urlForAdd string, settings *config.Settings) (bool, b
183183
return active
184184
}
185185

186-
dupResult := processing.CheckForDuplicate(urlForAdd, settings, activeDownloadsFunc)
186+
dupResult := processing.CheckForDuplicate(urlForAdd, activeDownloadsFunc)
187187
if dupResult == nil {
188188
return false, false
189189
}
@@ -232,9 +232,18 @@ func maybeRequireDownloadApproval(w http.ResponseWriter, service core.DownloadSe
232232
return true
233233
}
234234

235+
// HEADLESS/SERVER MODE:
236+
// If we're here, shouldPrompt must be true but we have no TUI.
237+
// We auto-approve extension requests that are NOT duplicates, as there is
238+
// no way to display a confirmation prompt in headless mode.
239+
if !resolved.isDuplicate {
240+
utils.Debug("Headless mode: auto-approving extension request (bypass ExtensionPrompt)")
241+
return false
242+
}
243+
235244
writeJSONResponse(w, http.StatusConflict, map[string]string{
236245
"status": "error",
237-
"message": "Download rejected: Duplicate download or approval required (Headless mode)",
246+
"message": "Download rejected: Duplicate download detected (Headless mode)",
238247
})
239248
return true
240249
}

internal/processing/duplicate.go

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ package processing
33
import (
44
"strings"
55

6-
"github.com/SurgeDM/Surge/internal/config"
76
"github.com/SurgeDM/Surge/internal/engine/state"
87
"github.com/SurgeDM/Surge/internal/engine/types"
98
)
@@ -17,11 +16,11 @@ type DuplicateResult struct {
1716
}
1817

1918
// CheckForDuplicate inspects active and persisted downloads for duplicate URLs.
20-
func CheckForDuplicate(url string, settings *config.Settings, activeDownloads func() map[string]*types.DownloadConfig) *DuplicateResult {
21-
if !settings.General.WarnOnDuplicate {
22-
return nil
23-
}
24-
19+
// It always performs the scan regardless of settings.General.WarnOnDuplicate.
20+
// Policy decisions (whether to warn, block, or auto-approve) are the caller's
21+
// responsibility. This separation is required so that headless mode can always
22+
// distinguish duplicates from new downloads, even when WarnOnDuplicate is off.
23+
func CheckForDuplicate(url string, activeDownloads func() map[string]*types.DownloadConfig) *DuplicateResult {
2524
normalizedInputURL := strings.TrimRight(url, "/")
2625

2726
// Check active downloads

internal/tui/helpers.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,7 @@ func (m RootModel) checkForDuplicate(url string) *processing.DuplicateResult {
168168
}
169169
return active
170170
}
171-
return processing.CheckForDuplicate(url, m.Settings, activeDownloads)
171+
return processing.CheckForDuplicate(url, activeDownloads)
172172
}
173173

174174
// renderEmptyMessage provides a consistent visual for "no data" states in dashboard panes.

0 commit comments

Comments
 (0)