v0.31.0 bf0756b

scheme_handler_test.go RawHistoryFolder

1package webkit
2
3import (
4	"bytes"
5	"context"
6	"net/http"
7	"net/url"
8	"strings"
9	"testing"
10	"testing/fstest"
11
12	"github.com/andybalholm/brotli"
13	"github.com/bnema/dumber/assets"
14	"github.com/stretchr/testify/assert"
15	"github.com/stretchr/testify/require"
16)
17
18func TestCrashOriginalURI(t *testing.T) {
19	assert.Equal(t, "https://example.com/path?q=1", crashOriginalURI("dumb://history/crash?url=https%3A%2F%2Fexample.com%2Fpath%3Fq%3D1"))
20	assert.Empty(t, crashOriginalURI("dumb://history/crash"))
21	assert.Empty(t, crashOriginalURI("%%%"))
22}
23
24func TestBuildCrashPageHTMLIncludesReloadTarget(t *testing.T) {
25	body := buildCrashPageHTML("https://example.com/foo?a=1&b=2")
26	assert.Contains(t, body, `data-target="https://example.com/foo?a=1&b=2"`)
27	assert.Contains(t, body, "https://example.com/foo?a=1&b=2")
28}
29
30func TestBuildCrashPageHTMLFallbackReloadWithoutTarget(t *testing.T) {
31	body := buildCrashPageHTML("")
32	assert.Contains(t, body, "window.location.reload();")
33}
34
35func TestBuildCrashPageHTMLRejectsUnsafeReloadScheme(t *testing.T) {
36	// buildCrashPageHTML receives already-sanitized input from the caller.
37	// sanitizeCrashPageOriginalURI strips unsafe schemes to "".
38	sanitized := sanitizeCrashPageOriginalURI("javascript:alert(1)")
39	assert.Empty(t, sanitized)
40	body := buildCrashPageHTML(sanitized)
41	assert.Contains(t, body, `data-target=""`)
42}
43
44func TestBuildCrashPageHTMLAllowsDumbSchemeTarget(t *testing.T) {
45	body := buildCrashPageHTML("dumb://history")
46	assert.Contains(t, body, `data-target="dumb://history"`)
47}
48
49func TestBuildCrashPageHTMLEscapesScriptBreakoutPayload(t *testing.T) {
50	payload := `https://example.com/?q=</script><script>alert(1)</script>`
51	body := buildCrashPageHTML(payload)
52	assert.NotContains(t, body, "</script><script>alert(1)</script>")
53	assert.Contains(t, body, "&lt;/script&gt;&lt;script&gt;alert(1)&lt;/script&gt;")
54}
55
56func TestIsTrustedSystemviewURL(t *testing.T) {
57	t.Parallel()
58
59	for _, raw := range []string{
60		"dumb://history",
61		"dumb://favorites",
62		"dumb:config",
63	} {
64		require.True(t, isTrustedSystemviewURL(raw), raw)
65	}
66	for _, raw := range []string{
67		"",
68		"https://dumber.invalid/history",
69		"dumb://api/favicon",
70		"dumb://evil/history",
71	} {
72		require.False(t, isTrustedSystemviewURL(raw), raw)
73	}
74}
75
76func TestIsTrustedSystemviewFaviconRequestRequiresTrustedRefererWhenOriginAbsent(t *testing.T) {
77	t.Parallel()
78
79	require.True(t, isTrustedSystemviewFaviconRequest(&SchemeRequest{Referer: "dumb://history"}))
80	require.False(t, isTrustedSystemviewFaviconRequest(&SchemeRequest{Referer: ""}))
81}
82
83func TestIsTrustedSystemviewFaviconRequestHonorsOriginBeforeReferer(t *testing.T) {
84	t.Parallel()
85
86	require.True(t, isTrustedSystemviewFaviconRequest(&SchemeRequest{Origin: "dumb://history"}))
87	require.True(t, isTrustedSystemviewFaviconRequest(&SchemeRequest{Origin: "dumb://history", Referer: "https://evil.example"}))
88	require.False(t, isTrustedSystemviewFaviconRequest(&SchemeRequest{Origin: "https://evil.example", Referer: "dumb://history"}))
89}
90
91func TestRegisterDefaultsIncludesCrashHandler(t *testing.T) {
92	handler := NewDumbSchemeHandler(context.Background())
93	require.NotNil(t, handler)
94
95	handler.mu.RLock()
96	crashHandler, ok := handler.handlers["/crash"]
97	handler.mu.RUnlock()
98	require.True(t, ok)
99	require.NotNil(t, crashHandler)
100
101	resp := crashHandler.Handle(&SchemeRequest{
102		URI:    "dumb://history/crash?url=https%3A%2F%2Fexample.com",
103		Path:   "/crash",
104		Method: "GET",
105		Scheme: "dumb",
106	})
107	require.NotNil(t, resp)
108	assert.Equal(t, 200, resp.StatusCode)
109	assert.Equal(t, "text/html; charset=utf-8", resp.ContentType)
110	assert.Contains(t, string(resp.Data), "Renderer process ended")
111}
112
113func TestCrashHandlerSanitizesUnsafeURLQuery(t *testing.T) {
114	handler := NewDumbSchemeHandler(context.Background())
115	require.NotNil(t, handler)
116
117	handler.mu.RLock()
118	crashHandler, ok := handler.handlers["/crash"]
119	handler.mu.RUnlock()
120	require.True(t, ok)
121	require.NotNil(t, crashHandler)
122
123	resp := crashHandler.Handle(&SchemeRequest{
124		URI:    "dumb://history/crash?url=javascript%3Aalert(1)",
125		Path:   "/crash",
126		Method: "GET",
127		Scheme: "dumb",
128	})
129	require.NotNil(t, resp)
130	assert.Contains(t, string(resp.Data), `data-target=""`)
131}
132
133func TestHandleAsset_ConfigOpaqueFormServesSystemviewsShell(t *testing.T) {
134	h := NewDumbSchemeHandler(context.Background())
135	h.SetAssets(assets.WebUIAssets)
136
137	u, err := url.Parse("dumb:config")
138	require.NoError(t, err)
139
140	resp := h.handleAsset(u)
141	require.NotNil(t, resp)
142	assert.Equal(t, "text/html; charset=utf-8", resp.ContentType)
143	assert.Contains(t, string(resp.Data), "systemviews.wasm")
144}
145
146func TestHandleAsset_SystemviewsCSSIsServed(t *testing.T) {
147	h := NewDumbSchemeHandler(context.Background())
148	h.SetAssets(assets.WebUIAssets)
149
150	u, err := url.Parse("dumb://history/systemviews.css")
151	require.NoError(t, err)
152
153	resp := h.handleAsset(u)
154	require.NotNil(t, resp)
155	// Charset suffixes are acceptable; assert the MIME type contract only.
156	assert.Equal(t, "text/css", strings.Split(resp.ContentType, ";")[0])
157	assert.Contains(t, string(resp.Data), ".sv-app")
158}
159
160func TestHandleAsset_SystemviewsRootsServeShell(t *testing.T) {
161	h := NewDumbSchemeHandler(context.Background())
162	h.SetAssets(assets.WebUIAssets)
163
164	tests := []string{
165		"dumb://history",
166		"dumb:history",
167		"dumb://favorites",
168		"dumb:favorites",
169		"dumb://config",
170		"dumb:config",
171		"dumb://error",
172		"dumb:error",
173	}
174
175	for _, raw := range tests {
176		t.Run(raw, func(t *testing.T) {
177			u, err := url.Parse(raw)
178			require.NoError(t, err)
179
180			resp := h.handleAsset(u)
181			require.NotNil(t, resp)
182			assert.Equal(t, "text/html; charset=utf-8", resp.ContentType)
183			assert.Contains(t, string(resp.Data), "systemviews.wasm")
184		})
185	}
186}
187
188func TestConfigHandlersUseInjectedPayloadBuilders(t *testing.T) {
189	h := NewDumbSchemeHandler(context.Background())
190	h.SetConfigPayloadBuilders(
191		func() ([]byte, error) { return []byte(`{"current":true}`), nil },
192		func() ([]byte, error) { return []byte(`{"default":true}`), nil },
193	)
194
195	h.mu.RLock()
196	currentHandler := h.handlers["/api/config"]
197	defaultHandler := h.handlers["/api/config/default"]
198	h.mu.RUnlock()
199	require.NotNil(t, currentHandler)
200	require.NotNil(t, defaultHandler)
201
202	currentResp := currentHandler.Handle(&SchemeRequest{
203		URI:     "dumb://config/api/config",
204		Path:    "/api/config",
205		Method:  http.MethodGet,
206		Scheme:  "dumb",
207		Referer: "dumb://config",
208	})
209	require.NotNil(t, currentResp)
210	assert.Equal(t, http.StatusOK, currentResp.StatusCode)
211	assert.Equal(t, "application/json", currentResp.ContentType)
212	assert.Equal(t, []byte(`{"current":true}`), currentResp.Data)
213
214	defaultResp := defaultHandler.Handle(&SchemeRequest{
215		URI:     "dumb://config/api/config/default",
216		Path:    "/api/config/default",
217		Method:  http.MethodGet,
218		Scheme:  "dumb",
219		Referer: "dumb://config",
220	})
221	require.NotNil(t, defaultResp)
222	assert.Equal(t, http.StatusOK, defaultResp.StatusCode)
223	assert.Equal(t, "application/json", defaultResp.ContentType)
224	assert.Equal(t, []byte(`{"default":true}`), defaultResp.Data)
225}
226
227func TestConfigHandlersRejectUntrustedRequests(t *testing.T) {
228	h := NewDumbSchemeHandler(context.Background())
229	h.SetConfigPayloadBuilders(
230		func() ([]byte, error) { return []byte(`{"current":true}`), nil },
231		func() ([]byte, error) { return []byte(`{"default":true}`), nil },
232	)
233
234	h.mu.RLock()
235	currentHandler := h.handlers["/api/config"]
236	defaultHandler := h.handlers["/api/config/default"]
237	h.mu.RUnlock()
238
239	for _, handler := range []PageHandler{currentHandler, defaultHandler} {
240		resp := handler.Handle(&SchemeRequest{Method: http.MethodGet, Origin: "https://evil.example"})
241		require.NotNil(t, resp)
242		assert.Equal(t, http.StatusForbidden, resp.StatusCode)
243		assert.True(t, resp.SuppressDefaultHeaders)
244		assert.NotContains(t, resp.Headers, "Access-Control-Allow-Origin")
245	}
246}
247
248func TestConfigHandlersAllowOpaqueOriginWithTrustedReferer(t *testing.T) {
249	h := NewDumbSchemeHandler(context.Background())
250	h.SetConfigPayloadBuilders(
251		func() ([]byte, error) { return []byte(`{"current":true}`), nil },
252		func() ([]byte, error) { return []byte(`{"default":true}`), nil },
253	)
254
255	h.mu.RLock()
256	currentHandler := h.handlers["/api/config"]
257	h.mu.RUnlock()
258	require.NotNil(t, currentHandler)
259
260	resp := currentHandler.Handle(&SchemeRequest{
261		URI:     "dumb://config/api/config",
262		Path:    "/api/config",
263		Method:  http.MethodGet,
264		Scheme:  "dumb",
265		Origin:  "null",
266		Referer: "dumb://config",
267	})
268
269	require.NotNil(t, resp)
270	assert.Equal(t, http.StatusOK, resp.StatusCode)
271	assert.Equal(t, []byte(`{"current":true}`), resp.Data)
272}
273
274func TestConfigHandlersExposeTrustedResponsesToWebKitFetch(t *testing.T) {
275	h := NewDumbSchemeHandler(context.Background())
276	h.SetConfigPayloadBuilders(
277		func() ([]byte, error) { return []byte(`{"current":true}`), nil },
278		func() ([]byte, error) { return []byte(`{"default":true}`), nil },
279	)
280
281	h.mu.RLock()
282	currentHandler := h.handlers["/api/config"]
283	h.mu.RUnlock()
284	require.NotNil(t, currentHandler)
285
286	resp := currentHandler.Handle(&SchemeRequest{
287		URI:     "dumb://config/api/config",
288		Path:    "/api/config",
289		Method:  http.MethodGet,
290		Scheme:  "dumb",
291		Origin:  "null",
292		Referer: "dumb://config",
293	})
294
295	require.NotNil(t, resp)
296	assert.Equal(t, http.StatusOK, resp.StatusCode)
297	assert.Equal(t, "*", resp.Headers["Access-Control-Allow-Origin"])
298	assert.Equal(t, "GET, OPTIONS", resp.Headers["Access-Control-Allow-Methods"])
299	assert.Equal(t, "Content-Type", resp.Headers["Access-Control-Allow-Headers"])
300}
301
302func TestShouldAddCORSHeaders(t *testing.T) {
303	t.Parallel()
304
305	tests := []struct {
306		name string
307		path string
308		want bool
309	}{
310		{name: "private api route", path: "/api/config", want: false},
311		{name: "wasm asset", path: "/systemviews.wasm", want: true},
312		{name: "html shell", path: "/", want: false},
313		{name: "js asset", path: "/wasm_exec.js", want: false},
314	}
315
316	for _, tt := range tests {
317		t.Run(tt.name, func(t *testing.T) {
318			t.Parallel()
319			assert.Equal(t, tt.want, shouldAddCORSHeaders(tt.path))
320		})
321	}
322}
323
324func TestResponseHeadersForPath_WASMIncludesContentTypeAndCORS(t *testing.T) {
325	t.Parallel()
326
327	headers := responseHeadersForPath("/systemviews.wasm", "application/wasm")
328	assert.Equal(t, "application/wasm", headers["Content-Type"])
329	assert.Equal(t, "*", headers["Access-Control-Allow-Origin"])
330	assert.Equal(t, "GET, POST, OPTIONS", headers["Access-Control-Allow-Methods"])
331}
332
333func TestReadAssetWithEncodingDecompressesBrotliWASM(t *testing.T) {
334	t.Parallel()
335
336	wasm := []byte("\x00asmfixture")
337	fsys := fstest.MapFS{
338		"systemviews/systemviews.wasm.br": {Data: brotliCompressForTest(t, wasm)},
339	}
340
341	data, err := readAssetWithEncoding(fsys, "systemviews/systemviews.wasm", "systemviews.wasm")
342	require.NoError(t, err)
343	assert.Equal(t, wasm, data)
344}
345
346func brotliCompressForTest(t *testing.T, data []byte) []byte {
347	t.Helper()
348	var buf bytes.Buffer
349	w := brotli.NewWriter(&buf)
350	_, err := w.Write(data)
351	require.NoError(t, err)
352	require.NoError(t, w.Close())
353	return buf.Bytes()
354}
355
356func TestHandleAssetRejectsTraversalOutsideSystemviews(t *testing.T) {
357	h := NewDumbSchemeHandler(context.Background())
358	h.SetAssets(assets.WebUIAssets)
359
360	for _, raw := range []string{
361		"dumb://history/../logo.svg",
362		"dumb://history/nested/../../logo.svg",
363	} {
364		t.Run(raw, func(t *testing.T) {
365			u, err := url.Parse(raw)
366			require.NoError(t, err)
367			assert.Nil(t, h.handleAsset(u))
368		})
369	}
370}
371
372func TestSafeSystemviewsAssetPathRejectsTraversal(t *testing.T) {
373	t.Parallel()
374
375	fullPath, relPath, ok := safeSystemviewsAssetPath(systemviewsAssetDir, "nested/../systemviews.css")
376	require.True(t, ok)
377	assert.Equal(t, "systemviews/systemviews.css", fullPath)
378	assert.Equal(t, "systemviews.css", relPath)
379
380	invalid := []struct {
381		name     string
382		assetDir string
383		relPath  string
384	}{
385		{name: "parent escape", assetDir: systemviewsAssetDir, relPath: "../logo.svg"},
386		{name: "nested parent escape", assetDir: systemviewsAssetDir, relPath: "nested/../../logo.svg"},
387		{name: "absolute parent escape", assetDir: systemviewsAssetDir, relPath: "/../logo.svg"},
388		{name: "null byte", assetDir: systemviewsAssetDir, relPath: "systemviews.css\x00"},
389		{name: "wrong asset dir", assetDir: "logos", relPath: "logo.svg"},
390	}
391	for _, tt := range invalid {
392		t.Run(tt.name, func(t *testing.T) {
393			t.Parallel()
394			_, _, ok := safeSystemviewsAssetPath(tt.assetDir, tt.relPath)
395			assert.False(t, ok)
396		})
397	}
398}

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