v0.33.0 62a09f5

scheme_handler_assets_p3_test.go RawHistoryFolder

1package cef
2
3// P3.2 tests: the served shell carries content-versioned references from
4// its own captured bundle while the on-disk shell keeps usable relative
5// URLs. The shell itself stays noncacheable; an invalid manifest fails
6// closed with a clear noncacheable error.
7
8import (
9	"crypto/sha256"
10	"encoding/hex"
11	"net/http"
12	"strings"
13	"testing"
14	"testing/fstest"
15
16	cefmocks "github.com/bnema/purego-cef/cef/mocks"
17	"github.com/stretchr/testify/require"
18
19	"github.com/bnema/dumber/assets"
20)
21
22const versionedShellFixture = `<html><head><link rel="stylesheet" href="./systemviews.css"><script src="./wasm_exec.js"></script></head><body><script>instantiateWasm(go, './systemviews.wasm')</script><img src="./other.png"></body></html>`
23
24func shellBundleFS(t *testing.T, shell string) fstest.MapFS {
25	t.Helper()
26	return fstest.MapFS{
27		"systemviews/index.html":             {Data: []byte(shell)},
28		"systemviews/" + assets.ManifestName: {Data: testManifestBytes(t)},
29	}
30}
31
32func TestAssetP32_ShellRefsVersionedFromBundleManifest(t *testing.T) {
33	stubIdentityResourceHandler(t)
34	h := newTestDumbSchemeHandler(t)
35	h.setAssets(shellBundleFS(t, versionedShellFixture))
36
37	rh := staticHandlerOf(t, h.handleAsset(mustParseURL(t, "https://dumber.invalid/history")))
38	require.Equal(t, http.StatusOK, rh.statusCode)
39	require.Equal(t, "text/html; charset=utf-8", rh.contentType)
40	require.Equal(t, noStoreCacheControl, rh.headers["Cache-Control"],
41		"P3.3 policy: the shell itself stays noncacheable")
42	served := string(rh.data)
43	require.Contains(t, served, "./systemviews.wasm?v="+strings.Repeat("b", 64))
44	require.Contains(t, served, "./wasm_exec.js?v="+strings.Repeat("c", 64))
45	require.Contains(t, served, "./systemviews.css?v="+strings.Repeat("a", 64))
46	require.Contains(t, served, `"./other.png"`, "unpinned references pass through untouched")
47}
48
49func TestAssetP32_ShellVersionedAcrossRoutes(t *testing.T) {
50	stubIdentityResourceHandler(t)
51	h := newTestDumbSchemeHandler(t)
52	h.setAssets(shellBundleFS(t, versionedShellFixture))
53
54	for _, raw := range []string{
55		"https://dumber.invalid/history",
56		"https://dumber.invalid/history/",
57		"https://dumber.invalid/favorites",
58		"https://dumber.invalid/favorites/",
59		"https://dumber.invalid/config",
60		"https://dumber.invalid/config/",
61		"https://dumber.invalid/index.html",
62	} {
63		rh := staticHandlerOf(t, h.handleAsset(mustParseURL(t, raw)))
64		require.Equal(t, http.StatusOK, rh.statusCode, raw)
65		require.Contains(t, string(rh.data), "?v=", "%s must serve the versioned shell", raw)
66	}
67
68	// The bare domain root has no page mapping: existing routing rejects
69	// it before the asset layer, unchanged by versioning.
70	bare := staticHandlerOf(t, h.handleAsset(mustParseURL(t, "https://dumber.invalid/")))
71	require.Equal(t, http.StatusNotFound, bare.statusCode)
72}
73
74func TestAssetP32_AlreadyVersionedRefsUntouched(t *testing.T) {
75	out := versionShellRefs(
76		[]byte(`<script src="./wasm_exec.js?v=old"></script><script src="./wasm_exec.js"></script>`),
77		assets.Manifest{Version: assets.ManifestVersion, Files: map[string]assets.FileEntry{
78			"systemviews.css":  {SHA256: "a", Size: 1},
79			"systemviews.wasm": {SHA256: "b", Size: 2},
80			"wasm_exec.js":     {SHA256: "c", Size: 3},
81		}},
82	)
83	require.NotContains(t, string(out), "?v=c?v=", "must not double-version")
84	require.Contains(t, string(out), `"./wasm_exec.js?v=old"`, "pre-versioned reference wins")
85}
86
87func TestAssetP32_InvalidManifestFailsClosedNoncacheable(t *testing.T) {
88	stubIdentityResourceHandler(t)
89	h := newTestDumbSchemeHandler(t)
90	h.setAssets(fstest.MapFS{
91		"systemviews/index.html":             {Data: []byte("<html>shell</html>")},
92		"systemviews/" + assets.ManifestName: {Data: []byte("not json")},
93	})
94
95	rh := staticHandlerOf(t, h.handleAsset(mustParseURL(t, "https://dumber.invalid/history")))
96	require.Equal(t, http.StatusInternalServerError, rh.statusCode)
97	require.Equal(t, "no-store", rh.headers["Cache-Control"], "manifest failure must be noncacheable")
98	require.Contains(t, string(rh.data), "Asset manifest invalid")
99}
100
101func TestAssetP32_MissingManifestFailsClosed(t *testing.T) {
102	stubIdentityResourceHandler(t)
103	h := newTestDumbSchemeHandler(t)
104	h.setAssets(fstest.MapFS{
105		"systemviews/index.html": {Data: []byte("<html>shell</html>")},
106	})
107
108	rh := staticHandlerOf(t, h.handleAsset(mustParseURL(t, "https://dumber.invalid/history")))
109	require.Equal(t, http.StatusInternalServerError, rh.statusCode)
110	require.Equal(t, "no-store", rh.headers["Cache-Control"])
111}
112
113func TestAssetP32_ReplacementSwapsShellVersions(t *testing.T) {
114	stubIdentityResourceHandler(t)
115	digestA := strings.Repeat("a", 64)
116	manifestA, err := assets.Manifest{Version: assets.ManifestVersion, Files: map[string]assets.FileEntry{
117		"systemviews.css":  {SHA256: digestA, Size: 1},
118		"systemviews.wasm": {SHA256: digestA, Size: 2},
119		"wasm_exec.js":     {SHA256: digestA, Size: 3},
120	}}.Bytes()
121	require.NoError(t, err)
122	h := newTestDumbSchemeHandler(t)
123	h.setAssets(fstest.MapFS{
124		"systemviews/index.html":             {Data: []byte(versionedShellFixture)},
125		"systemviews/" + assets.ManifestName: {Data: manifestA},
126	})
127	u := mustParseURL(t, "https://dumber.invalid/history")
128	require.Contains(t, string(staticHandlerOf(t, h.handleAsset(u)).data), "?v="+digestA)
129
130	digestB := strings.Repeat("b", 64)
131	manifestB, err := assets.Manifest{Version: assets.ManifestVersion, Files: map[string]assets.FileEntry{
132		"systemviews.css":  {SHA256: digestB, Size: 1},
133		"systemviews.wasm": {SHA256: digestB, Size: 2},
134		"wasm_exec.js":     {SHA256: digestB, Size: 3},
135	}}.Bytes()
136	require.NoError(t, err)
137	h.setAssets(fstest.MapFS{
138		"systemviews/index.html":             {Data: []byte(versionedShellFixture)},
139		"systemviews/" + assets.ManifestName: {Data: manifestB},
140	})
141	served := string(staticHandlerOf(t, h.handleAsset(u)).data)
142	require.Contains(t, served, "?v="+digestB)
143	require.NotContains(t, served, "?v="+digestA, "shell versions follow the captured bundle")
144}
145
146func TestAssetP32_OnDiskShellKeepsRelativeURLs(t *testing.T) {
147	// The committed shell must stay directly usable by alternate shells:
148	// concrete relative URLs, no template placeholders, no baked versions.
149	data, err := assets.WebUIAssets.ReadFile("systemviews/index.html")
150	require.NoError(t, err)
151	shell := string(data)
152	for _, ref := range []string{"./systemviews.wasm", "./wasm_exec.js", "./systemviews.css"} {
153		require.Contains(t, shell, ref)
154		require.NotContains(t, shell, ref+"?v=", "on-disk shell must not bake versions")
155	}
156	require.NotContains(t, shell, "{{", "no unresolved template placeholders")
157}
158
159// ---------------------------------------------------------------------------
160// P3.3: immutable headers bound to served bytes
161// ---------------------------------------------------------------------------
162
163// trueManifestFS builds a bundle whose manifest pins the true digests of
164// the given files, plus a compressed WASM agreeing with the raw bytes.
165func trueManifestFS(t *testing.T, wasm, js, css []byte) fstest.MapFS {
166	t.Helper()
167	entries := make(map[string]assets.FileEntry, len(assets.ManifestFiles))
168	for name, data := range map[string][]byte{
169		"systemviews.wasm": wasm,
170		"wasm_exec.js":     js,
171		"systemviews.css":  css,
172	} {
173		sum := sha256.Sum256(data)
174		entries[name] = assets.FileEntry{SHA256: hex.EncodeToString(sum[:]), Size: int64(len(data))}
175	}
176	manifest, err := assets.Manifest{Version: assets.ManifestVersion, Files: entries}.Bytes()
177	require.NoError(t, err)
178	return fstest.MapFS{
179		"systemviews/systemviews.wasm":       {Data: wasm},
180		"systemviews/systemviews.wasm.br":    {Data: brotliCompressForTest(t, wasm)},
181		"systemviews/wasm_exec.js":           {Data: js},
182		"systemviews/systemviews.css":        {Data: css},
183		"systemviews/" + assets.ManifestName: {Data: manifest},
184	}
185}
186
187func digestOf(data []byte) string {
188	sum := sha256.Sum256(data)
189	return hex.EncodeToString(sum[:])
190}
191
192func TestAssetP33_VersionedCSSServesImmutable(t *testing.T) {
193	stubIdentityResourceHandler(t)
194	css := []byte("/* immutable css */")
195	h := newTestDumbSchemeHandler(t)
196	h.setAssets(trueManifestFS(t, []byte("\x00asm"), []byte("/* js */"), css))
197
198	rh := staticHandlerOf(t, h.handleAsset(mustParseURL(t, "https://dumber.invalid/systemviews.css?v="+digestOf(css))))
199	require.Equal(t, http.StatusOK, rh.statusCode)
200	require.Equal(t, css, rh.data)
201	require.Equal(t, immutableCacheControl, rh.headers["Cache-Control"])
202}
203
204func TestAssetP33_UnversionedCSSServesNoStore(t *testing.T) {
205	stubIdentityResourceHandler(t)
206	css := []byte("/* plain css */")
207	h := newTestDumbSchemeHandler(t)
208	h.setAssets(trueManifestFS(t, []byte("\x00asm"), []byte("/* js */"), css))
209
210	rh := staticHandlerOf(t, h.handleAsset(mustParseURL(t, "https://dumber.invalid/systemviews.css")))
211	require.Equal(t, http.StatusOK, rh.statusCode)
212	require.Equal(t, noStoreCacheControl, rh.headers["Cache-Control"])
213	require.NotContains(t, rh.headers["Cache-Control"], "immutable")
214}
215
216func TestAssetP33_WrongVersionRejectedNoncacheable(t *testing.T) {
217	stubIdentityResourceHandler(t)
218	css := []byte("/* real css */")
219	h := newTestDumbSchemeHandler(t)
220	h.setAssets(trueManifestFS(t, []byte("\x00asm"), []byte("/* js */"), css))
221
222	rh := staticHandlerOf(t, h.handleAsset(mustParseURL(t, "https://dumber.invalid/systemviews.css?v="+strings.Repeat("0", 64))))
223	require.Equal(t, http.StatusNotFound, rh.statusCode)
224	require.Equal(t, noStoreCacheControl, rh.headers["Cache-Control"])
225}
226
227func TestAssetP33_DuplicateAndEmptyVersionsRejected(t *testing.T) {
228	stubIdentityResourceHandler(t)
229	css := []byte("/* css */")
230	fsys := trueManifestFS(t, []byte("\x00asm"), []byte("/* js */"), css)
231	h := newTestDumbSchemeHandler(t)
232	h.setAssets(fsys)
233	digest := digestOf(css)
234
235	for _, raw := range []string{
236		"https://dumber.invalid/systemviews.css?v=" + digest + "&v=" + digest,
237		"https://dumber.invalid/systemviews.css?v=",
238		"https://dumber.invalid/systemviews.css?v=a&v=b",
239		// Malformed escapes fail closed instead of degrading to
240		// unversioned or single-version handling.
241		"https://dumber.invalid/systemviews.css?v=" + digest + "&v=%ZZ",
242		"https://dumber.invalid/systemviews.css?v=%ZZ",
243	} {
244		rh := staticHandlerOf(t, h.handleAsset(mustParseURL(t, raw)))
245		require.Equal(t, http.StatusNotFound, rh.statusCode, raw)
246		require.Equal(t, noStoreCacheControl, rh.headers["Cache-Control"], raw)
247	}
248
249	// A malformed escape on an unrelated parameter is not a version:
250	// the request serves unversioned and noncacheable.
251	plain := staticHandlerOf(t, h.handleAsset(mustParseURL(t, "https://dumber.invalid/systemviews.css?x=%ZZ")))
252	require.Equal(t, http.StatusOK, plain.statusCode)
253	require.Equal(t, noStoreCacheControl, plain.headers["Cache-Control"])
254}
255
256func TestAssetP33_StaleManifestRejectedNoncacheable(t *testing.T) {
257	stubIdentityResourceHandler(t)
258	// The manifest pins dummy digests while the files carry real bytes:
259	// v matches the pin, but the served bytes do not. URL-to-manifest
260	// equality alone must never earn immutable headers.
261	h := newTestDumbSchemeHandler(t)
262	h.setAssets(fstest.MapFS{
263		"systemviews/systemviews.css":        {Data: []byte("/* real css */")},
264		"systemviews/systemviews.wasm":       {Data: []byte("\x00asm")},
265		"systemviews/systemviews.wasm.br":    {Data: brotliCompressForTest(t, []byte("\x00asm"))},
266		"systemviews/wasm_exec.js":           {Data: []byte("/* js */")},
267		"systemviews/" + assets.ManifestName: {Data: testManifestBytes(t)},
268	})
269
270	rh := staticHandlerOf(t, h.handleAsset(mustParseURL(t, "https://dumber.invalid/systemviews.css?v="+strings.Repeat("a", 64))))
271	require.Equal(t, http.StatusNotFound, rh.statusCode, "stale pin must fail closed")
272	require.Equal(t, noStoreCacheControl, rh.headers["Cache-Control"])
273}
274
275func TestAssetP33_MissingVersionedArtifactRejected(t *testing.T) {
276	stubIdentityResourceHandler(t)
277	h := newTestDumbSchemeHandler(t)
278	h.setAssets(trueManifestFS(t, []byte("\x00asm"), []byte("/* js */"), []byte("/* css */")))
279
280	// wasm_exec.js is pinned but absent from this bundle variant.
281	bundle := fstest.MapFS{
282		"systemviews/systemviews.css": {Data: []byte("/* css */")},
283	}
284	manifest, err := assets.Manifest{Version: assets.ManifestVersion, Files: map[string]assets.FileEntry{
285		"systemviews.css":  {SHA256: digestOf([]byte("/* css */")), Size: int64(len("/* css */"))},
286		"systemviews.wasm": {SHA256: strings.Repeat("b", 64), Size: 2},
287		"wasm_exec.js":     {SHA256: strings.Repeat("c", 64), Size: 3},
288	}}.Bytes()
289	require.NoError(t, err)
290	bundle["systemviews/"+assets.ManifestName] = &fstest.MapFile{Data: manifest}
291	h.setAssets(bundle)
292
293	rh := staticHandlerOf(t, h.handleAsset(mustParseURL(t, "https://dumber.invalid/wasm_exec.js?v="+strings.Repeat("c", 64))))
294	require.Equal(t, http.StatusNotFound, rh.statusCode)
295	require.Equal(t, noStoreCacheControl, rh.headers["Cache-Control"])
296}
297
298func TestAssetP33_VersionedWASMServesImmutableOnWire(t *testing.T) {
299	stubIdentityResourceHandler(t)
300	wasm := []byte("\x00asm-wire-immutable")
301	h := newTestDumbSchemeHandler(t)
302	h.setAssets(trueManifestFS(t, wasm, []byte("/* js */"), []byte("/* css */")))
303
304	wh := awaitWASMHandler(t, h.handleAsset(mustParseURL(t, wasmURL(t)+"?v="+digestOf(wasm))))
305	require.Equal(t, http.StatusOK, wh.statusCode)
306	require.Equal(t, wasm, wh.data)
307
308	response := cefmocks.NewMockResponse(t)
309	response.EXPECT().SetStatus(int32(http.StatusOK)).Once()
310	response.EXPECT().SetStatusText(http.StatusText(http.StatusOK)).Once()
311	response.EXPECT().SetMimeType("application/wasm").Once()
312	response.EXPECT().SetHeaderByName("Cache-Control", immutableCacheControl, int32(1)).Once()
313	var responseLength int64
314	wh.GetResponseHeaders(response, &responseLength, 0)
315	require.Equal(t, int64(len(wasm)), responseLength)
316}
317
318func TestAssetP33_WrongVersionWASMDecodesNothing(t *testing.T) {
319	stubIdentityResourceHandler(t)
320	wasm := []byte("\x00asm-no-decode")
321	fsys := &countingFS{inner: fstest.MapFS{
322		"systemviews/systemviews.wasm.br": {Data: brotliCompressForTest(t, wasm)},
323	}}
324	_ = fsys
325	h := newTestDumbSchemeHandler(t)
326	inner := fstest.MapFS{
327		"systemviews/systemviews.wasm.br": {Data: brotliCompressForTest(t, wasm)},
328	}
329	manifest, err := assets.Manifest{Version: assets.ManifestVersion, Files: map[string]assets.FileEntry{
330		"systemviews.css":  {SHA256: strings.Repeat("a", 64), Size: 1},
331		"systemviews.wasm": {SHA256: digestOf(wasm), Size: int64(len(wasm))},
332		"wasm_exec.js":     {SHA256: strings.Repeat("c", 64), Size: 3},
333	}}.Bytes()
334	require.NoError(t, err)
335	inner["systemviews/"+assets.ManifestName] = &fstest.MapFile{Data: manifest}
336	counting := &countingFS{inner: inner}
337	h.setAssets(counting)
338
339	rh := staticHandlerOf(t, h.handleAsset(mustParseURL(t, wasmURL(t)+"?v="+strings.Repeat("0", 64))))
340	require.Equal(t, http.StatusNotFound, rh.statusCode)
341	require.Equal(t, noStoreCacheControl, rh.headers["Cache-Control"])
342	require.Zero(t, counting.compressedReads(), "rejected version must not start a decode")
343}
344
345func TestAssetP33_StaleWASMBytesFailClosedOnWire(t *testing.T) {
346	stubIdentityResourceHandler(t)
347	wasm := []byte("\x00asm-stale-wire")
348	other := []byte("\x00asm-other-wire")
349	otherManifest, err := assets.Manifest{Version: assets.ManifestVersion, Files: map[string]assets.FileEntry{
350		"systemviews.css":  {SHA256: strings.Repeat("a", 64), Size: 1},
351		"systemviews.wasm": {SHA256: digestOf(other), Size: int64(len(other))},
352		"wasm_exec.js":     {SHA256: strings.Repeat("c", 64), Size: 3},
353	}}.Bytes()
354	require.NoError(t, err)
355	h := newTestDumbSchemeHandler(t)
356	h.setAssets(fstest.MapFS{
357		"systemviews/systemviews.wasm":       {Data: wasm},
358		"systemviews/systemviews.wasm.br":    {Data: brotliCompressForTest(t, wasm)},
359		"systemviews/" + assets.ManifestName: {Data: otherManifest},
360	})
361
362	// v matches the stale pin, but the decoded bytes hash differently.
363	wh := awaitWASMHandler(t, h.handleAsset(mustParseURL(t, wasmURL(t)+"?v="+digestOf(other))))
364	require.Equal(t, http.StatusNotFound, wh.statusCode)
365
366	response := cefmocks.NewMockResponse(t)
367	response.EXPECT().SetStatus(int32(http.StatusNotFound)).Once()
368	response.EXPECT().SetStatusText(http.StatusText(http.StatusNotFound)).Once()
369	response.EXPECT().SetMimeType("text/html").Once()
370	response.EXPECT().SetCharset("utf-8").Once()
371	response.EXPECT().SetHeaderByName("Cache-Control", noStoreCacheControl, int32(1)).Once()
372	var responseLength int64
373	wh.GetResponseHeaders(response, &responseLength, 0)
374	require.Equal(t, int64(len(wh.data)), responseLength)
375}
376
377func TestAssetP33_ReplacementSwapsValidatedVersions(t *testing.T) {
378	stubIdentityResourceHandler(t)
379	cssA := []byte("/* css a */")
380	h := newTestDumbSchemeHandler(t)
381	h.setAssets(trueManifestFS(t, []byte("\x00asm"), []byte("/* js */"), cssA))
382	u := "https://dumber.invalid/systemviews.css?v=" + digestOf(cssA)
383	require.Equal(t, immutableCacheControl, staticHandlerOf(t, h.handleAsset(mustParseURL(t, u))).headers["Cache-Control"])
384
385	cssB := []byte("/* css b */")
386	h.setAssets(trueManifestFS(t, []byte("\x00asm"), []byte("/* js */"), cssB))
387	// The old version no longer validates against the captured bundle.
388	old := staticHandlerOf(t, h.handleAsset(mustParseURL(t, u)))
389	require.Equal(t, http.StatusNotFound, old.statusCode)
390	require.Equal(t, noStoreCacheControl, old.headers["Cache-Control"])
391	fresh := staticHandlerOf(t, h.handleAsset(mustParseURL(t, "https://dumber.invalid/systemviews.css?v="+digestOf(cssB))))
392	require.Equal(t, http.StatusOK, fresh.statusCode)
393	require.Equal(t, immutableCacheControl, fresh.headers["Cache-Control"])
394}
395
396func TestAssetP33_ManifestItselfServesNoStore(t *testing.T) {
397	stubIdentityResourceHandler(t)
398	css := []byte("/* css */")
399	h := newTestDumbSchemeHandler(t)
400	h.setAssets(trueManifestFS(t, []byte("\x00asm"), []byte("/* js */"), css))
401
402	// The manifest is servable but never allowlisted: it stays fresh and
403	// noncacheable even with a version query.
404	for _, raw := range []string{
405		"https://dumber.invalid/asset-manifest.json",
406		"https://dumber.invalid/asset-manifest.json?v=anything",
407	} {
408		rh := staticHandlerOf(t, h.handleAsset(mustParseURL(t, raw)))
409		require.Equal(t, http.StatusOK, rh.statusCode, raw)
410		require.Equal(t, noStoreCacheControl, rh.headers["Cache-Control"], raw)
411	}
412}
413
414func TestAssetP33_ShellVersionQueryIgnored(t *testing.T) {
415	stubIdentityResourceHandler(t)
416	h := newTestDumbSchemeHandler(t)
417	h.setAssets(shellBundleFS(t, versionedShellFixture))
418
419	// The shell document is never immutable: a ?v= on the shell serves the
420	// normal noncacheable shell instead of erroring or pinning.
421	rh := staticHandlerOf(t, h.handleAsset(mustParseURL(t, "https://dumber.invalid/history?v=whatever")))
422	require.Equal(t, http.StatusOK, rh.statusCode)
423	require.Equal(t, noStoreCacheControl, rh.headers["Cache-Control"])
424	require.Contains(t, string(rh.data), "?v=")
425}

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