1package webkit
2
3import (
4 "context"
5 "encoding/base64"
6 "errors"
7 "io"
8 "net"
9 "net/http"
10 "net/http/httptest"
11 "testing"
12 "unicode/utf8"
13
14 "github.com/bnema/dumber/internal/application/port"
15 portmocks "github.com/bnema/dumber/internal/application/port/mocks"
16 "github.com/bnema/dumber/internal/application/usecase"
17 "github.com/stretchr/testify/assert"
18 "github.com/stretchr/testify/mock"
19 "github.com/stretchr/testify/require"
20)
21
22// stubHitTest simulates a WebKit HitTestResult for testing.
23type stubHitTest struct {
24 linkURI string
25 imageURI string
26 isLink bool
27 isImage bool
28 isEditable bool
29 isSelection bool
30}
31
32func TestBuildMenuContextFromHitTest(t *testing.T) {
33 t.Run("image hit test populates ImageURI", func(t *testing.T) {
34 hit := stubHitTest{
35 imageURI: "https://example.com/photo.png",
36 isImage: true,
37 }
38 ctx := buildMenuContextFromStubHitTest(hit, true, false, 100, 200)
39
40 assert.Equal(t, "https://example.com/photo.png", ctx.ImageURI)
41 assert.Equal(t, 100, ctx.X)
42 assert.Equal(t, 200, ctx.Y)
43 assert.True(t, ctx.CanGoBack)
44 assert.False(t, ctx.CanGoForward)
45 })
46
47 t.Run("link hit test populates LinkURI", func(t *testing.T) {
48 hit := stubHitTest{
49 linkURI: "https://example.com/page",
50 isLink: true,
51 }
52 ctx := buildMenuContextFromStubHitTest(hit, false, true, 50, 75)
53
54 assert.Equal(t, "https://example.com/page", ctx.LinkURI)
55 assert.Empty(t, ctx.ImageURI)
56 assert.False(t, ctx.CanGoBack)
57 assert.True(t, ctx.CanGoForward)
58 })
59
60 t.Run("editable and selection flags propagate", func(t *testing.T) {
61 hit := stubHitTest{
62 isEditable: true,
63 isSelection: true,
64 }
65 ctx := buildMenuContextFromStubHitTest(hit, false, false, 0, 0)
66
67 assert.True(t, ctx.IsEditable)
68 assert.True(t, ctx.HasSelection)
69 })
70
71 t.Run("combined image and link", func(t *testing.T) {
72 hit := stubHitTest{
73 linkURI: "https://example.com/link",
74 imageURI: "https://example.com/img.jpg",
75 isLink: true,
76 isImage: true,
77 }
78 ctx := buildMenuContextFromStubHitTest(hit, true, true, 10, 20)
79
80 assert.Equal(t, "https://example.com/link", ctx.LinkURI)
81 assert.Equal(t, "https://example.com/img.jpg", ctx.ImageURI)
82 assert.True(t, ctx.CanGoBack)
83 assert.True(t, ctx.CanGoForward)
84 })
85
86 t.Run("empty hit test produces empty context", func(t *testing.T) {
87 hit := stubHitTest{}
88 ctx := buildMenuContextFromStubHitTest(hit, false, false, 0, 0)
89
90 assert.Empty(t, ctx.LinkURI)
91 assert.Empty(t, ctx.ImageURI)
92 assert.False(t, ctx.HasSelection)
93 assert.False(t, ctx.IsEditable)
94 })
95}
96
97func TestResolveImageData(t *testing.T) {
98 t.Run("rejects empty image URI", func(t *testing.T) {
99 resolver := &contextMenuResolver{}
100 _, err := resolver.ResolveImageData(context.Background(), "")
101 require.Error(t, err)
102 assert.Contains(t, err.Error(), "empty image URI")
103 })
104
105 t.Run("rejects unsupported URI scheme", func(t *testing.T) {
106 resolver := &contextMenuResolver{}
107 _, err := resolver.ResolveImageData(context.Background(), "file:///tmp/image.png")
108 require.Error(t, err)
109 assert.Contains(t, err.Error(), "unsupported URI scheme")
110 })
111
112 t.Run("decodes data URI images", func(t *testing.T) {
113 imageBytes := []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}
114 uri := "data:image/png;base64," + base64.StdEncoding.EncodeToString(imageBytes)
115
116 resolver := &contextMenuResolver{}
117 data, err := resolver.ResolveImageData(context.Background(), uri)
118 require.NoError(t, err)
119 assert.Equal(t, imageBytes, data.Bytes)
120 assert.Equal(t, "image/png", data.MimeType)
121 })
122
123 t.Run("fetches image bytes from HTTP", func(t *testing.T) {
124 imageBytes := []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}
125 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
126 w.Header().Set("Content-Type", "image/jpeg; charset=utf-8")
127 w.Write(imageBytes)
128 }))
129 defer srv.Close()
130
131 resolver := &contextMenuResolver{allowPrivateHosts: true}
132 data, err := resolver.ResolveImageData(context.Background(), srv.URL+"/image.png")
133 require.NoError(t, err)
134 assert.Equal(t, imageBytes, data.Bytes)
135 assert.Equal(t, "image/png", data.MimeType)
136 })
137
138 t.Run("rejects DNS lookup errors", func(t *testing.T) {
139 resolver := &contextMenuResolver{
140 lookupIPAddr: func(context.Context, string) ([]net.IPAddr, error) {
141 return nil, errors.New("dns failed")
142 },
143 }
144
145 _, err := resolver.ResolveImageData(context.Background(), "https://image.example/photo.png")
146 require.Error(t, err)
147 assert.Contains(t, err.Error(), "dns failed")
148 })
149
150 t.Run("falls back to sniffed MIME type when header is generic", func(t *testing.T) {
151 imageBytes := []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}
152 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
153 w.Header().Set("Content-Type", "application/octet-stream")
154 w.Write(imageBytes)
155 }))
156 defer srv.Close()
157
158 resolver := &contextMenuResolver{allowPrivateHosts: true}
159 data, err := resolver.ResolveImageData(context.Background(), srv.URL+"/image.png")
160 require.NoError(t, err)
161 assert.Equal(t, imageBytes, data.Bytes)
162 assert.Equal(t, "image/png", data.MimeType)
163 })
164
165 t.Run("rejects DNS-resolved private hosts", func(t *testing.T) {
166 resolver := &contextMenuResolver{
167 lookupIPAddr: func(context.Context, string) ([]net.IPAddr, error) {
168 return []net.IPAddr{{IP: net.ParseIP("127.0.0.1")}}, nil
169 },
170 }
171
172 _, err := resolver.ResolveImageData(context.Background(), "https://image.example/photo.png")
173 require.Error(t, err)
174 assert.Contains(t, err.Error(), "private host")
175 })
176
177 t.Run("connect-time dial skips private addresses and uses public ones", func(t *testing.T) {
178 var dialed []string
179 resolver := &contextMenuResolver{
180 lookupIPAddr: func(context.Context, string) ([]net.IPAddr, error) {
181 return []net.IPAddr{
182 {IP: net.ParseIP("127.0.0.1")},
183 {IP: net.ParseIP("93.184.216.34")},
184 }, nil
185 },
186 dialContext: func(_ context.Context, _ string, addr string) (net.Conn, error) {
187 dialed = append(dialed, addr)
188 conn, peer := net.Pipe()
189 t.Cleanup(func() {
190 _ = conn.Close()
191 _ = peer.Close()
192 })
193 return conn, nil
194 },
195 }
196
197 conn, err := resolver.dialImageHostContext(context.Background(), "tcp", "image.example:443")
198 require.NoError(t, err)
199 require.NotNil(t, conn)
200 assert.Equal(t, []string{"93.184.216.34:443"}, dialed)
201 })
202
203 t.Run("rejects private literal addresses at connect time", func(t *testing.T) {
204 resolver := &contextMenuResolver{}
205
206 _, err := resolver.dialImageHostContext(context.Background(), "tcp", "127.0.0.1:443")
207 require.Error(t, err)
208 assert.Contains(t, err.Error(), "private host")
209 })
210
211 t.Run("redirect check rejects private targets", func(t *testing.T) {
212 resolver := &contextMenuResolver{
213 lookupIPAddr: func(context.Context, string) ([]net.IPAddr, error) {
214 return []net.IPAddr{{IP: net.ParseIP("127.0.0.1")}}, nil
215 },
216 }
217
218 req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "https://redirect.example/photo.png", http.NoBody)
219 require.NoError(t, err)
220 err = resolver.imageFetchClient(context.Background()).CheckRedirect(req, nil)
221 require.Error(t, err)
222 assert.Contains(t, err.Error(), "private host")
223 })
224
225 t.Run("rejects non-image HTTP content", func(t *testing.T) {
226 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
227 w.Header().Set("Content-Type", "text/html")
228 w.Write([]byte("<html><body>not an image</body></html>"))
229 }))
230 defer srv.Close()
231
232 resolver := &contextMenuResolver{allowPrivateHosts: true}
233 _, err := resolver.ResolveImageData(context.Background(), srv.URL+"/page.html")
234 require.Error(t, err)
235 assert.Contains(t, err.Error(), "not an image")
236 })
237
238 t.Run("rejects spoofed image headers when sniffed bytes are not an image", func(t *testing.T) {
239 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
240 w.Header().Set("Content-Type", "image/png")
241 w.Write([]byte("<html><body>not really an image</body></html>"))
242 }))
243 defer srv.Close()
244
245 resolver := &contextMenuResolver{allowPrivateHosts: true}
246 _, err := resolver.ResolveImageData(context.Background(), srv.URL+"/spoofed.png")
247 require.Error(t, err)
248 assert.Contains(t, err.Error(), "not an image")
249 assert.Contains(t, err.Error(), "text/html")
250 assert.Contains(t, err.Error(), "image/png")
251 assert.Contains(t, err.Error(), "/spoofed.png")
252 })
253
254 t.Run("returns error on HTTP failure", func(t *testing.T) {
255 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
256 w.WriteHeader(http.StatusNotFound)
257 }))
258 defer srv.Close()
259
260 resolver := &contextMenuResolver{allowPrivateHosts: true}
261 _, err := resolver.ResolveImageData(context.Background(), srv.URL+"/missing.png")
262 require.Error(t, err)
263 assert.Contains(t, err.Error(), "404")
264 })
265
266 t.Run("rejects oversized bodies", func(t *testing.T) {
267 resolver := &contextMenuResolver{
268 allowPrivateHosts: true,
269 client: &http.Client{
270 Transport: roundTripperFunc(func(*http.Request) (*http.Response, error) {
271 return &http.Response{
272 StatusCode: http.StatusOK,
273 Header: http.Header{"Content-Type": []string{"image/png"}},
274 Body: io.NopCloser(newSizedByteReader(maxImageFetchBytes + 1)),
275 }, nil
276 }),
277 },
278 }
279
280 _, err := resolver.ResolveImageData(context.Background(), "https://example.com/image.png")
281 require.Error(t, err)
282 assert.Contains(t, err.Error(), "too large")
283 })
284
285 t.Run("returns error on invalid URL", func(t *testing.T) {
286 resolver := &contextMenuResolver{}
287 _, err := resolver.ResolveImageData(context.Background(), "://bad-url")
288 require.Error(t, err)
289 })
290}
291
292func TestWebkitMenuDelegator_OpenLinkNewTab(t *testing.T) {
293 var gotURI string
294 wv := &WebView{
295 OnLinkMiddleClick: func(uri string) bool {
296 gotURI = uri
297 return true
298 },
299 }
300 delegator := &webkitMenuDelegator{wv: wv}
301
302 err := delegator.DelegateMenuAction(context.Background(), port.MenuActionOpenLinkNewTab, port.MenuContext{LinkURI: "https://example.com/new"})
303 require.NoError(t, err)
304 require.Equal(t, "https://example.com/new", gotURI)
305}
306
307func TestWebkitMenuDelegator_OpenLinkNewTabRequiresHandlerSuccess(t *testing.T) {
308 wv := &WebView{
309 OnLinkMiddleClick: func(string) bool {
310 return false
311 },
312 }
313 delegator := &webkitMenuDelegator{wv: wv}
314
315 err := delegator.DelegateMenuAction(context.Background(), port.MenuActionOpenLinkNewTab, port.MenuContext{LinkURI: "https://example.com/new"})
316 require.Error(t, err)
317 assert.Contains(t, err.Error(), "action not handled")
318}
319
320func TestContextMenuPipeline_NewExecutor_NotifiesOnCopiedText(t *testing.T) {
321 var copiedLen int
322 ctx := context.Background()
323 baseClipboard := portmocks.NewMockClipboard(t)
324 factory := portmocks.NewMockContextMenuActionExecutorFactory(t)
325 var capturedClipboard port.Clipboard
326 factory.EXPECT().NewContextMenuActionExecutor(mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
327 func(
328 clipboard port.Clipboard,
329 resolver port.ImageDataResolver,
330 saver port.ResolvedImageSaver,
331 delegator port.MenuActionDelegator,
332 ) port.ContextMenuActionExecutor {
333 capturedClipboard = clipboard
334 return usecase.NewExecuteContextMenuActionUseCase(clipboard, resolver, saver, delegator)
335 },
336 ).Once()
337 pipeline := &contextMenuPipeline{
338 clipboard: baseClipboard,
339 onCopied: func(textLen int) { copiedLen = textLen },
340 executorFactory: factory,
341 }
342
343 executor := pipeline.newExecutor(&WebView{})
344 require.NotNil(t, executor)
345
346 t.Run("copy link", func(t *testing.T) {
347 copiedLen = 0
348 require.NotNil(t, capturedClipboard)
349 baseClipboard.EXPECT().WriteText(ctx, "https://example.com/new").Return(nil).Once()
350
351 err := executor.ExecuteMenuAction(ctx, port.MenuActionCopyLink, port.MenuContext{LinkURI: "https://example.com/new"})
352 require.NoError(t, err)
353 require.Equal(t, utf8.RuneCountInString("https://example.com/new"), copiedLen)
354 })
355
356 t.Run("copy selection", func(t *testing.T) {
357 copiedLen = 0
358 require.NotNil(t, capturedClipboard)
359 baseClipboard.EXPECT().WriteText(ctx, "selected text").Return(nil).Once()
360
361 err := executor.ExecuteMenuAction(ctx, port.MenuActionCopySelection, port.MenuContext{HasSelection: true, SelectionText: "selected text"})
362 require.NoError(t, err)
363 require.Equal(t, utf8.RuneCountInString("selected text"), copiedLen)
364 })
365}
366
367// buildMenuContextFromStubHitTest is the test-only equivalent of buildMenuContextFromHitTest
368// that works with our stub type instead of requiring real WebKit objects.
369func buildMenuContextFromStubHitTest(hit stubHitTest, canGoBack, canGoForward bool, x, y int) port.MenuContext {
370 ctx := port.MenuContext{
371 CanGoBack: canGoBack,
372 CanGoForward: canGoForward,
373 X: x,
374 Y: y,
375 }
376
377 if hit.isLink {
378 ctx.LinkURI = hit.linkURI
379 }
380 if hit.isImage {
381 ctx.ImageURI = hit.imageURI
382 }
383 ctx.HasSelection = hit.isSelection
384 ctx.IsEditable = hit.isEditable
385
386 return ctx
387}
388
389type roundTripperFunc func(*http.Request) (*http.Response, error)
390
391func (f roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) {
392 return f(req)
393}
394
395type sizedByteReader struct {
396 remaining int
397}
398
399func newSizedByteReader(size int) *sizedByteReader {
400 return &sizedByteReader{remaining: size}
401}
402
403func (r *sizedByteReader) Read(p []byte) (int, error) {
404 if r.remaining <= 0 {
405 return 0, io.EOF
406 }
407 n := len(p)
408 if n > r.remaining {
409 n = r.remaining
410 }
411 for i := 0; i < n; i++ {
412 p[i] = 'x'
413 }
414 r.remaining -= n
415 return n, nil
416}