main 5041353

navigation_security_test.go RawHistoryFolder

1package content
2
3import (
4	"context"
5	"errors"
6	"testing"
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	"github.com/bnema/dumber/internal/application/port/mocks"
14	"github.com/bnema/dumber/internal/domain/entity"
15)
16
17type externalLaunchRecorder struct {
18	prompts  []string
19	origins  []string
20	respond  func(bool)
21	launched []string
22}
23
24const testPaneID = entity.PaneID("pane-1")
25
26// uriChanged delivers a URI change to testPaneID as the engine callback
27// installed for wv.
28func uriChanged(ctx context.Context, c *Coordinator, wv port.WebView, uri string) {
29	identity, _ := identityForWebView(wv)
30	c.handleURIChanged(ctx, testPaneID, wv, identity, uri)
31}
32
33// externalWebView returns a WebView mock with ID 1 and the given generation.
34func externalWebView(t *testing.T, generation uint64) *mocks.MockWebView {
35	wv := mocks.NewMockWebView(t)
36	wv.EXPECT().ID().Return(port.WebViewID(1)).Maybe()
37	wv.EXPECT().Generation().Return(generation).Maybe()
38	return wv
39}
40
41func (r *externalLaunchRecorder) install(c *Coordinator) {
42	c.onLaunchExternalURL = func(uri string) {
43		r.launched = append(r.launched, uri)
44	}
45	c.onConfirmExternalLaunch = func(_ entity.PaneID, origin, uri string, respond func(bool)) {
46		r.prompts = append(r.prompts, uri)
47		r.origins = append(r.origins, origin)
48		r.respond = respond
49	}
50}
51
52func TestHandleURIChanged_ExternalSchemeWithoutConfirmerIsBlocked(t *testing.T) {
53	t.Parallel()
54
55	ctx := context.Background()
56	wv := externalWebView(t, 1)
57	wv.EXPECT().Stop(ctx).Return(nil).Once()
58
59	launchCount := 0
60	c := &Coordinator{
61		webViews:            map[entity.PaneID]port.WebView{"pane-1": wv},
62		onLaunchExternalURL: func(string) { launchCount++ },
63	}
64
65	uriChanged(ctx, c, wv, "vscode://file/etc/passwd")
66
67	assert.Equal(t, 0, launchCount)
68}
69
70func TestHandleURIChanged_ExternalSchemeLaunchesOnlyAfterConfirmation(t *testing.T) {
71	t.Parallel()
72
73	ctx := context.Background()
74	wv := externalWebView(t, 1)
75	wv.EXPECT().Stop(ctx).Return(nil).Once()
76
77	rec := &externalLaunchRecorder{}
78	c := &Coordinator{webViews: map[entity.PaneID]port.WebView{"pane-1": wv}}
79	rec.install(c)
80
81	uri := "claude://login/google-auth?code=abc"
82	uriChanged(ctx, c, wv, uri)
83
84	assert.Equal(t, []string{uri}, rec.prompts)
85	assert.Empty(t, rec.launched)
86
87	rec.respond(true)
88	assert.Equal(t, []string{uri}, rec.launched)
89}
90
91func TestHandleURIChanged_ExternalSchemeDeniedDoesNotLaunchOrGoBack(t *testing.T) {
92	t.Parallel()
93
94	ctx := context.Background()
95	wv := externalWebView(t, 1)
96	// GoBack must not be called: going back to an OAuth endpoint loops.
97	wv.EXPECT().Stop(ctx).Return(nil).Once()
98
99	rec := &externalLaunchRecorder{}
100	c := &Coordinator{webViews: map[entity.PaneID]port.WebView{"pane-1": wv}}
101	rec.install(c)
102
103	uriChanged(ctx, c, wv, "spotify://track/123")
104	rec.respond(false)
105
106	assert.Empty(t, rec.launched)
107}
108
109func TestHandleURIChanged_OneOutstandingPromptPerPane(t *testing.T) {
110	t.Parallel()
111
112	ctx := context.Background()
113	wv := externalWebView(t, 1)
114	wv.EXPECT().Stop(ctx).Return(nil).Times(3)
115
116	rec := &externalLaunchRecorder{}
117	c := &Coordinator{webViews: map[entity.PaneID]port.WebView{"pane-1": wv}}
118	rec.install(c)
119
120	// Alternating URIs must not queue more prompts while one is pending.
121	uriChanged(ctx, c, wv, "claude://a")
122	uriChanged(ctx, c, wv, "claude://b")
123	uriChanged(ctx, c, wv, "claude://a")
124	assert.Equal(t, []string{"claude://a"}, rec.prompts)
125
126	// Once answered, the pane may prompt again.
127	rec.respond(false)
128	wv.EXPECT().Stop(ctx).Return(nil).Once()
129	uriChanged(ctx, c, wv, "claude://b")
130	assert.Len(t, rec.prompts, 2)
131}
132
133func TestHandleURIChanged_StalePromptAfterReleaseDoesNotLaunch(t *testing.T) {
134	t.Parallel()
135
136	ctx := context.Background()
137	wv := externalWebView(t, 1)
138	wv.EXPECT().Stop(ctx).Return(nil).Once()
139
140	rec := &externalLaunchRecorder{}
141	c := &Coordinator{webViews: map[entity.PaneID]port.WebView{"pane-1": wv}}
142	rec.install(c)
143
144	uriChanged(ctx, c, wv, "claude://login?code=x")
145	// Pane closed while the prompt was queued.
146	c.clearExternalPrompt("pane-1")
147	delete(c.webViews, "pane-1")
148
149	rec.respond(true)
150	assert.Empty(t, rec.launched)
151}
152
153func TestHandleURIChanged_HTTPURIStillRecordsSPANavigation(t *testing.T) {
154	t.Parallel()
155
156	ctx := context.Background()
157	paneID := testPaneID
158	wv := externalWebView(t, 1)
159	wv.EXPECT().IsLoading().Return(false).Once()
160
161	var recordedPane entity.PaneID
162	var recordedURI string
163	c := &Coordinator{
164		webViews: map[entity.PaneID]port.WebView{paneID: wv},
165		onPaneURIUpdated: func(paneID entity.PaneID, uri string) {
166			recordedPane = paneID
167			recordedURI = uri
168		},
169	}
170
171	uriChanged(ctx, c, wv, "https://example.com/app#state")
172
173	assert.Equal(t, paneID, recordedPane)
174	assert.Equal(t, "https://example.com/app#state", recordedURI)
175}
176
177func TestHandleURIChanged_StopErrorDoesNotPanic(t *testing.T) {
178	t.Parallel()
179
180	ctx := context.Background()
181	wv := externalWebView(t, 1)
182	wv.EXPECT().Stop(ctx).Return(errors.New("stop failed")).Once()
183
184	rec := &externalLaunchRecorder{}
185	c := &Coordinator{webViews: map[entity.PaneID]port.WebView{"pane-1": wv}}
186	rec.install(c)
187
188	uriChanged(ctx, c, wv, "vscode://file/etc/passwd")
189
190	assert.Len(t, rec.prompts, 1)
191	assert.Empty(t, rec.launched)
192}
193
194func TestHandleURIChanged_OldAnswerCannotLaunchReplacementPrompt(t *testing.T) {
195	t.Parallel()
196
197	ctx := context.Background()
198	wv := externalWebView(t, 1)
199	wv.EXPECT().Stop(ctx).Return(nil).Twice()
200
201	rec := &externalLaunchRecorder{}
202	c := &Coordinator{webViews: map[entity.PaneID]port.WebView{"pane-1": wv}}
203	rec.install(c)
204
205	uriChanged(ctx, c, wv, "claude://old")
206	oldRespond := rec.respond
207	// The pane is invalidated, then the same WebView asks again.
208	c.clearExternalPrompt("pane-1")
209	uriChanged(ctx, c, wv, "claude://new")
210	newRespond := rec.respond
211
212	oldRespond(true)
213	assert.Empty(t, rec.launched, "old answer must not launch")
214
215	newRespond(true)
216	assert.Equal(t, []string{"claude://new"}, rec.launched)
217}
218
219func TestHandleURIChanged_RebindToNewGenerationMakesPromptStale(t *testing.T) {
220	t.Parallel()
221
222	ctx := context.Background()
223	wv := externalWebView(t, 1)
224	wv.EXPECT().Stop(ctx).Return(nil).Once()
225	rebound := externalWebView(t, 2)
226
227	rec := &externalLaunchRecorder{}
228	c := &Coordinator{webViews: map[entity.PaneID]port.WebView{"pane-1": wv}}
229	rec.install(c)
230
231	uriChanged(ctx, c, wv, "claude://login?code=x")
232	c.webViews["pane-1"] = rebound
233
234	rec.respond(true)
235	assert.Empty(t, rec.launched)
236}
237
238func TestHandleURIChanged_PromptNamesLastWebOrigin(t *testing.T) {
239	t.Parallel()
240
241	ctx := context.Background()
242	wv := externalWebView(t, 1)
243	wv.EXPECT().IsLoading().Return(true).Once()
244	wv.EXPECT().Stop(ctx).Return(nil).Once()
245
246	rec := &externalLaunchRecorder{}
247	c := &Coordinator{webViews: map[entity.PaneID]port.WebView{"pane-1": wv}}
248	rec.install(c)
249
250	uriChanged(ctx, c, wv, "https://claude.ai/login/popup-google-auth?code=x")
251	uriChanged(ctx, c, wv, "claude://login")
252
253	assert.Equal(t, []string{"claude.ai"}, rec.origins)
254}
255
256func TestHandleURIChanged_IgnoresCallbackFromReleasedWebView(t *testing.T) {
257	t.Parallel()
258
259	ctx := context.Background()
260	wv := externalWebView(t, 1)
261
262	rec := &externalLaunchRecorder{}
263	c := &Coordinator{}
264	rec.install(c)
265
266	// The callback was queued before the pane released its WebView.
267	uriChanged(ctx, c, wv, "https://site.example/")
268	uriChanged(ctx, c, wv, "claude://login")
269
270	assert.Empty(t, rec.prompts)
271	assert.Empty(t, c.externalPromptOrigins)
272}
273
274func TestHandleURIChanged_NonHTTPDocumentClearsOrigin(t *testing.T) {
275	t.Parallel()
276
277	for _, doc := range []string{"data:text/html,hi", "file://trusted.example/page.html"} {
278		ctx := context.Background()
279		wv := externalWebView(t, 1)
280		wv.EXPECT().IsLoading().Return(true).Times(2)
281		wv.EXPECT().Stop(ctx).Return(nil).Once()
282
283		rec := &externalLaunchRecorder{}
284		c := &Coordinator{webViews: map[entity.PaneID]port.WebView{"pane-1": wv}}
285		rec.install(c)
286
287		uriChanged(ctx, c, wv, "https://trusted.example/")
288		uriChanged(ctx, c, wv, doc)
289		uriChanged(ctx, c, wv, "claude://login")
290
291		assert.Equal(t, []string{""}, rec.origins, doc)
292	}
293}
294
295func TestURIChangedCallback_IgnoredAfterSameWebViewIsReusedFromPool(t *testing.T) {
296	ctx := context.Background()
297	c := newMinimalCoordinator()
298	rec := &externalLaunchRecorder{}
299	rec.install(c)
300	spaCalls := 0
301	c.onPaneURIUpdated = func(entity.PaneID, string) { spaCalls++ }
302
303	generation := uint64(1)
304	wv := mocks.NewMockWebView(t)
305	wv.EXPECT().ID().Return(port.WebViewID(1)).Maybe()
306	wv.EXPECT().Generation().RunAndReturn(func() uint64 { return generation }).Maybe()
307	var callbacks *port.WebViewCallbacks
308	wv.EXPECT().SetCallbacks(mock.Anything).Run(func(cb *port.WebViewCallbacks) { callbacks = cb }).Once()
309
310	c.setWebViewLocked(testPaneID, wv)
311	c.setupWebViewCallbacks(ctx, testPaneID, wv)
312	require.NotNil(t, callbacks)
313
314	// The same object is reused from the pool for a new binding.
315	generation = 2
316	callbacks.OnURIChanged("https://site.example/")
317	callbacks.OnURIChanged("claude://login")
318
319	assert.Empty(t, rec.prompts)
320	assert.Empty(t, c.externalPromptOrigins)
321	assert.Zero(t, spaCalls)
322}

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