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}