1package content
2
3import (
4 "context"
5 "testing"
6 "time"
7
8 "github.com/stretchr/testify/assert"
9 "github.com/stretchr/testify/mock"
10 "github.com/stretchr/testify/require"
11
12 "github.com/bnema/dumber/internal/application/port"
13 portmocks "github.com/bnema/dumber/internal/application/port/mocks"
14 "github.com/bnema/dumber/internal/domain/entity"
15)
16
17type popupOAuthAutoCloseWebViewStub struct {
18 *portmocks.MockWebView
19 active bool
20 navigationCallbacks []func(string)
21 closeCallbacks []func()
22 openerMessageCallbacks []func()
23 openerNavigateCallbacks []func(string)
24 closeCount int
25}
26
27func (s *popupOAuthAutoCloseWebViewStub) AddNavigationCallback(fn func(string)) {
28 s.navigationCallbacks = append(s.navigationCallbacks, fn)
29}
30
31func (s *popupOAuthAutoCloseWebViewStub) AddCloseCallback(fn func()) {
32 s.closeCallbacks = append(s.closeCallbacks, fn)
33}
34
35func (s *popupOAuthAutoCloseWebViewStub) Close() { s.closeCount++ }
36
37func (*popupOAuthAutoCloseWebViewStub) EnablePopupOpenerBridge(port.WebView, bool) {}
38
39func (s *popupOAuthAutoCloseWebViewStub) AddOpenerMessageCallback(fn func()) {
40 s.openerMessageCallbacks = append(s.openerMessageCallbacks, fn)
41}
42
43func (s *popupOAuthAutoCloseWebViewStub) AddOpenerNavigationCallback(fn func(string)) {
44 s.openerNavigateCallbacks = append(s.openerNavigateCallbacks, fn)
45}
46
47func (s *popupOAuthAutoCloseWebViewStub) HasActivePopupOpenerBridge() bool { return s.active }
48
49func TestComposeOnClose_Order(t *testing.T) {
50 var calls []string
51
52 composed := composeOnClose(
53 func() { calls = append(calls, "existing") },
54 func() { calls = append(calls, "next") },
55 )
56 composed()
57
58 assert.Equal(t, []string{"existing", "next"}, calls)
59}
60
61func TestComposeOnURIChanged_Order(t *testing.T) {
62 var calls []string
63
64 composed := composeOnURIChanged(
65 func(_ string) { calls = append(calls, "existing") },
66 func(_ string) { calls = append(calls, "next") },
67 )
68 composed("https://example.com")
69
70 assert.Equal(t, []string{"existing", "next"}, calls)
71}
72
73func TestComposeOnLoadChanged_Order(t *testing.T) {
74 var calls []string
75
76 composed := composeOnLoadChanged(
77 func(_ port.LoadEvent) { calls = append(calls, "existing") },
78 func(_ port.LoadEvent) { calls = append(calls, "next") },
79 )
80 composed(port.LoadCommitted)
81
82 assert.Equal(t, []string{"existing", "next"}, calls)
83}
84
85func TestCapturePopupOAuthMessage_MarksPopupSeenAndSuccessful(t *testing.T) {
86 popupID := port.WebViewID(100)
87 c := &Coordinator{
88 popups: newPopupManager(),
89 }
90 c.popups.popupOAuth[popupID] = &popupOAuthState{
91 ParentPaneID: entity.PaneID("parent-pane"),
92 }
93
94 c.capturePopupOAuthMessage(popupID)
95
96 state := c.popups.popupOAuth[popupID]
97 assert.True(t, state.Seen)
98 assert.True(t, state.Success)
99 assert.Equal(t, "postmessage://oauth-complete", state.CallbackURI)
100}
101
102func TestObserveNativePopupAuth_CapturesOAuthStateAndSchedulesParentResumeOnClose(t *testing.T) {
103 popupID := port.WebViewID(999)
104 wv := &popupOAuthAutoCloseWebViewStub{MockWebView: portmocks.NewMockWebView(t)}
105 wv.EXPECT().ID().Return(popupID).Once()
106 wv.EXPECT().IsDestroyed().Return(false).Maybe()
107
108 c := &Coordinator{popups: newPopupManager()}
109 c.ObserveNativePopupAuth(context.Background(), NativePopupInput{
110 ParentPaneID: entity.PaneID("parent-pane"),
111 ParentURIAtOpen: "https://x.com/i/flow/login",
112 PopupWebView: wv,
113 TargetURI: "https://accounts.google.com/o/oauth2/v2/auth",
114 })
115 require.Len(t, wv.navigationCallbacks, 1)
116 require.Len(t, wv.closeCallbacks, 1)
117
118 wv.navigationCallbacks[0]("https://x.com/googlepopupcallback?code=123")
119 state := c.popups.popupOAuth[popupID]
120 require.NotNil(t, state)
121 assert.True(t, state.Seen)
122 assert.True(t, state.Success)
123 assert.Equal(t, "https://x.com/googlepopupcallback?code=123", state.CallbackURI)
124
125 wv.closeCallbacks[0]()
126 c.popups.mu.RLock()
127 refreshTimer := c.popups.popupRefresh[entity.PaneID("parent-pane")]
128 c.popups.mu.RUnlock()
129 assert.NotNil(t, refreshTimer)
130}
131
132func TestObserveNativePopupAuth_DoesNotTrackWhenWebViewLacksOAuthCallbacks(t *testing.T) {
133 popupID := port.WebViewID(1001)
134 wv := portmocks.NewMockWebView(t)
135 wv.EXPECT().ID().Return(popupID).Once()
136
137 c := &Coordinator{popups: newPopupManager()}
138 c.ObserveNativePopupAuth(context.Background(), NativePopupInput{
139 ParentPaneID: entity.PaneID("parent-pane"),
140 ParentURIAtOpen: "https://x.com/i/flow/login",
141 PopupWebView: wv,
142 TargetURI: "https://accounts.google.com/o/oauth2/v2/auth",
143 })
144
145 c.popups.mu.RLock()
146 _, ok := c.popups.popupOAuth[popupID]
147 c.popups.mu.RUnlock()
148 assert.False(t, ok)
149}
150
151func TestObserveNativePopupAuth_SchedulesCloseOnlyOnce(t *testing.T) {
152 popupID := port.WebViewID(1002)
153 wv := &popupOAuthAutoCloseWebViewStub{MockWebView: portmocks.NewMockWebView(t)}
154 wv.EXPECT().ID().Return(popupID).Once()
155 wv.EXPECT().IsDestroyed().Return(false).Once()
156
157 oldSchedule := scheduleNativePopupOAuthClose
158 t.Cleanup(func() {
159 scheduleNativePopupOAuthClose = oldSchedule
160 })
161
162 scheduledCalls := 0
163 scheduleNativePopupOAuthClose = func(delay time.Duration, fn func()) {
164 scheduledCalls++
165 assert.Equal(t, nativePopupOAuthCloseDelay, delay)
166 fn()
167 }
168
169 c := &Coordinator{popups: newPopupManager()}
170 c.ObserveNativePopupAuth(context.Background(), NativePopupInput{
171 ParentPaneID: entity.PaneID("parent-pane"),
172 ParentURIAtOpen: "https://x.com/i/flow/login",
173 PopupWebView: wv,
174 TargetURI: "https://accounts.google.com/o/oauth2/v2/auth",
175 })
176 require.Len(t, wv.navigationCallbacks, 1)
177
178 wv.navigationCallbacks[0]("https://x.com/googlepopupcallback?code=123")
179 wv.navigationCallbacks[0]("https://x.com/googlepopupcallback?code=123")
180
181 assert.Equal(t, 1, scheduledCalls)
182 assert.Equal(t, 1, wv.closeCount)
183}
184
185func TestSetupOAuthAutoClose_NavigationIgnoresNonTerminalURI(t *testing.T) {
186 popupID := port.WebViewID(1000)
187 wv := &popupOAuthAutoCloseWebViewStub{MockWebView: portmocks.NewMockWebView(t)}
188 c := &Coordinator{popups: newPopupManager()}
189 c.trackOAuthPopup(popupID, entity.PaneID("parent-pane"), "https://example.com/start")
190
191 c.setupOAuthAutoClose(context.Background(), entity.PaneID("popup-pane"), popupID, wv)
192 require.Len(t, wv.navigationCallbacks, 1)
193
194 wv.navigationCallbacks[0]("https://example.com/intermediate")
195
196 state, ok := c.popups.popupOAuth[popupID]
197 require.True(t, ok)
198 assert.False(t, state.Seen)
199 assert.Empty(t, state.CallbackURI)
200
201 for _, fn := range wv.closeCallbacks {
202 fn()
203 }
204}
205
206func TestHandlePopupOAuthClose_SuccessSchedulesParentResume(t *testing.T) {
207 parentPaneID := entity.PaneID("parent-pane")
208 popupID := port.WebViewID(101)
209
210 c := &Coordinator{
211 webViews: make(map[entity.PaneID]port.WebView),
212 popups: newPopupManager(),
213 }
214
215 c.trackOAuthPopup(popupID, parentPaneID, "https://www.notion.so/login")
216 c.capturePopupOAuthState(popupID, "https://www.notion.so/googlepopupcallback?code=123")
217 c.handlePopupOAuthClose(context.Background(), popupID)
218
219 c.popups.mu.RLock()
220 _, exists := c.popups.popupOAuth[popupID]
221 refreshTimer := c.popups.popupRefresh[parentPaneID]
222 c.popups.mu.RUnlock()
223
224 assert.False(t, exists, "oauth state should be removed after close handling")
225 assert.NotNil(t, refreshTimer, "oauth callback should schedule parent resume")
226
227 waitFor(t, time.Second, func() bool {
228 c.popups.mu.RLock()
229 defer c.popups.mu.RUnlock()
230 return c.popups.popupRefresh[parentPaneID] == nil
231 })
232}
233
234func TestResumeParentPaneAfterOAuth_UnchangedParentDefersSameSiteFallbackReload(t *testing.T) {
235 parentPaneID := entity.PaneID("parent-pane")
236 popupID := port.WebViewID(102)
237 parentWV := portmocks.NewMockWebView(t)
238 parentWV.EXPECT().IsDestroyed().Return(false).Once()
239 parentWV.EXPECT().URI().Return("https://www.notion.so/login").Once()
240
241 c := &Coordinator{
242 webViews: map[entity.PaneID]port.WebView{
243 parentPaneID: parentWV,
244 },
245 popups: newPopupManager(),
246 }
247
248 c.resumeParentPaneAfterOAuth(context.Background(), parentPaneID, popupID, "https://www.notion.so/login", "https://www.notion.so/googlepopupcallback?code=123")
249
250 c.popups.mu.Lock()
251 refreshTimer := c.popups.popupRefresh[parentPaneID]
252 if refreshTimer != nil {
253 refreshTimer.Stop()
254 delete(c.popups.popupRefresh, parentPaneID)
255 }
256 c.popups.mu.Unlock()
257
258 assert.NotNil(t, refreshTimer, "same-site popup callback should get a grace retry before forcing reload")
259}
260
261func TestResumeParentPaneAfterOAuth_ChangedParentSkipsIntervention(t *testing.T) {
262 parentPaneID := entity.PaneID("parent-pane")
263 popupID := port.WebViewID(103)
264 parentWV := portmocks.NewMockWebView(t)
265 parentWV.EXPECT().IsDestroyed().Return(false).Once()
266 parentWV.EXPECT().URI().Return("https://www.notion.so/oauth2callback?state=abc").Once()
267
268 c := &Coordinator{
269 webViews: map[entity.PaneID]port.WebView{
270 parentPaneID: parentWV,
271 },
272 popups: newPopupManager(),
273 }
274
275 c.resumeParentPaneAfterOAuth(context.Background(), parentPaneID, popupID, "https://www.notion.so/login", "https://www.notion.so/googlepopupcallback?code=123")
276 parentWV.AssertNotCalled(t, "Reload", mock.Anything)
277}
278
279func TestResumeParentPaneAfterOAuthAttempt_GraceExhaustedFallsBackToReload(t *testing.T) {
280 parentPaneID := entity.PaneID("parent-pane")
281 popupID := port.WebViewID(104)
282 parentWV := portmocks.NewMockWebView(t)
283 parentWV.EXPECT().IsDestroyed().Return(false).Once()
284 parentWV.EXPECT().URI().Return("https://www.notion.so/login").Once()
285 parentWV.EXPECT().Reload(mock.Anything).Return(nil).Once()
286
287 c := &Coordinator{
288 webViews: map[entity.PaneID]port.WebView{
289 parentPaneID: parentWV,
290 },
291 popups: newPopupManager(),
292 }
293
294 c.resumeParentPaneAfterOAuthAttempt(context.Background(), parentPaneID, popupID, "https://www.notion.so/login", "https://www.notion.so/googlepopupcallback?code=123", 0)
295}
296
297func TestResumeParentPaneAfterOAuth_DifferentDomainFallsBackToReload(t *testing.T) {
298 parentPaneID := entity.PaneID("parent-pane")
299 popupID := port.WebViewID(105)
300 parentWV := portmocks.NewMockWebView(t)
301 parentWV.EXPECT().IsDestroyed().Return(false).Once()
302 parentWV.EXPECT().URI().Return("https://www.notion.so/login").Once()
303 parentWV.EXPECT().Reload(mock.Anything).Return(nil).Once()
304
305 c := &Coordinator{
306 webViews: map[entity.PaneID]port.WebView{
307 parentPaneID: parentWV,
308 },
309 popups: newPopupManager(),
310 }
311
312 c.resumeParentPaneAfterOAuth(context.Background(), parentPaneID, popupID, "https://www.notion.so/login", "https://accounts.example.com/callback?code=123")
313}
314
315func TestIsOAuthURL_DoesNotTreatGenericStateParamAsOAuth(t *testing.T) {
316 url := "https://github.com/apps/linear/installations/new?state=Ar4EJk1ao3eyEDgSbYOtG8Cr4"
317
318 assert.False(t, IsOAuthURL(url))
319}
320
321func TestIsOAuthURL_DetectsOAuthAuthorizeRequest(t *testing.T) {
322 url := "https://auth.openai.com/oauth/authorize?response_type=code&client_id=app_123&redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback&scope=openid+profile+email+offline_access&state=abc"
323
324 assert.True(t, IsOAuthURL(url))
325}
326
327func TestShouldForceCloseOnSafetyTimeout(t *testing.T) {
328 assert.False(t, shouldForceCloseOnSafetyTimeout())
329}
330
331func waitFor(t *testing.T, timeout time.Duration, condition func() bool) {
332 t.Helper()
333
334 deadline := time.Now().Add(timeout)
335 for time.Now().Before(deadline) {
336 if condition() {
337 return
338 }
339 time.Sleep(20 * time.Millisecond)
340 }
341 t.Fatalf("condition not met before timeout (%s)", timeout)
342}