main 5041353

oauth_test.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close