Squash integration of the reviewed Go core, frontend audit fixes, release tooling, and deployment hygiene. CI passed all Server/Client jobs.
228 lines
10 KiB
Go
228 lines
10 KiB
Go
package app
|
|
|
|
import (
|
|
"bytes"
|
|
"io"
|
|
"io/fs"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"testing/fstest"
|
|
)
|
|
|
|
const staticNotFoundBody = `{"error":"Not found"}`
|
|
|
|
// staticNotFound is app.ts's `sendJson(response, 404, { error: "Not found" })`,
|
|
// the next() callback sirv fell through to.
|
|
var staticNotFound = http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
|
|
writer.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
writer.WriteHeader(http.StatusNotFound)
|
|
io.WriteString(writer, staticNotFoundBody)
|
|
})
|
|
|
|
// staticFixture mirrors the tree evidence/runtime.md served through real
|
|
// sirv@3.0.2, byte lengths included, so the parity table below is comparable.
|
|
func staticFixture() fs.FS {
|
|
filled := func(size int) *fstest.MapFile {
|
|
return &fstest.MapFile{Data: bytes.Repeat([]byte("x"), size)}
|
|
}
|
|
return fstest.MapFS{
|
|
"index.html": filled(100),
|
|
"assets/app.js": filled(20),
|
|
"assets/app.css": filled(15),
|
|
"third-party-licenses.txt": filled(21),
|
|
"favicon.svg": filled(41),
|
|
"fonts/inter.woff2": filled(300),
|
|
"manifest.webmanifest": filled(19),
|
|
"data.json": filled(7),
|
|
"nomime.bin": filled(64),
|
|
"noext": filled(23),
|
|
".gitkeep": filled(0),
|
|
".vite/manifest.json": filled(7),
|
|
".well-known/x.txt": filled(10),
|
|
}
|
|
}
|
|
|
|
// TestStaticHandlerMatchesSirv replays the 26-row parity table measured against
|
|
// sirv@3.0.2 in evidence/runtime.md section 4.4.
|
|
func TestStaticHandlerMatchesSirv(t *testing.T) {
|
|
handler := staticHandler(staticFixture(), staticNotFound)
|
|
|
|
const html = "text/html;charset=utf-8"
|
|
for _, testCase := range []struct {
|
|
method string
|
|
path string
|
|
headerName string
|
|
headerValue string
|
|
status int
|
|
contentType string
|
|
contentLength string
|
|
contentRange string
|
|
bodyLength int
|
|
}{
|
|
{method: "GET", path: "/", status: 200, contentType: html, contentLength: "100", bodyLength: 100},
|
|
{method: "GET", path: "/index.html", status: 200, contentType: html, contentLength: "100", bodyLength: 100},
|
|
{method: "GET", path: "/host", status: 200, contentType: html, contentLength: "100", bodyLength: 100},
|
|
{method: "GET", path: "/room/1234", status: 200, contentType: html, contentLength: "100", bodyLength: 100},
|
|
{method: "GET", path: "/assets/app.js", status: 200, contentType: "text/javascript", contentLength: "20", bodyLength: 20},
|
|
{method: "GET", path: "/assets/app.css", status: 200, contentType: "text/css", contentLength: "15", bodyLength: 15},
|
|
{method: "GET", path: "/third-party-licenses.txt", status: 200, contentType: "text/plain", contentLength: "21", bodyLength: 21},
|
|
{method: "GET", path: "/favicon.svg", status: 200, contentType: "image/svg+xml", contentLength: "41", bodyLength: 41},
|
|
{method: "GET", path: "/fonts/inter.woff2", status: 200, contentType: "font/woff2", contentLength: "300", bodyLength: 300},
|
|
{method: "GET", path: "/manifest.webmanifest", status: 200, contentType: "application/manifest+json", contentLength: "19", bodyLength: 19},
|
|
{method: "GET", path: "/data.json", status: 200, contentType: "application/json", contentLength: "7", bodyLength: 7},
|
|
{method: "GET", path: "/nomime.bin", status: 200, contentType: "application/octet-stream", contentLength: "64", bodyLength: 64},
|
|
// mrmime knows no extension here, so sirv sends an empty Content-Type.
|
|
{method: "GET", path: "/noext", status: 200, contentType: "", contentLength: "23", bodyLength: 23},
|
|
{method: "GET", path: "/assets/missing.js", status: 404, contentType: "application/json; charset=utf-8", bodyLength: len(staticNotFoundBody)},
|
|
{method: "GET", path: "/missing.css", status: 404, contentType: "application/json; charset=utf-8", bodyLength: len(staticNotFoundBody)},
|
|
{method: "GET", path: "/.well-known/x.txt", status: 200, contentType: "text/plain", contentLength: "10", bodyLength: 10},
|
|
// The dotfile is not in sirv's FILES and does not match the "any extn"
|
|
// ignore, so it falls back to index.html.
|
|
{method: "GET", path: "/.gitkeep", status: 200, contentType: html, contentLength: "100", bodyLength: 100},
|
|
{method: "GET", path: "/.vite/manifest.json", status: 404, contentType: "application/json; charset=utf-8", bodyLength: len(staticNotFoundBody)},
|
|
{method: "GET", path: "/assets", status: 200, contentType: html, contentLength: "100", bodyLength: 100},
|
|
{method: "GET", path: "/assets/", status: 200, contentType: html, contentLength: "100", bodyLength: 100},
|
|
{method: "HEAD", path: "/index.html", status: 200, contentType: html, contentLength: "100", bodyLength: 0},
|
|
{
|
|
method: "GET", path: "/index.html", headerName: "Range", headerValue: "bytes=0-9",
|
|
status: 206, contentType: html, contentLength: "10", contentRange: "bytes 0-9/100", bodyLength: 10,
|
|
},
|
|
{
|
|
method: "GET", path: "/index.html", headerName: "Accept", headerValue: "application/json",
|
|
status: 200, contentType: html, contentLength: "100", bodyLength: 100,
|
|
},
|
|
{method: "GET", path: "/../go.mod", status: 404, contentType: "application/json; charset=utf-8", bodyLength: len(staticNotFoundBody)},
|
|
// sirv ignores req.method entirely; app.ts routes /api/** first.
|
|
{method: "POST", path: "/", status: 200, contentType: html, contentLength: "100", bodyLength: 100},
|
|
// The "any extn" class is ASCII-only, so this is not ignored.
|
|
{method: "GET", path: "/文件.js", status: 200, contentType: html, contentLength: "100", bodyLength: 100},
|
|
} {
|
|
request := httptest.NewRequest(testCase.method, testCase.path, nil)
|
|
if testCase.headerName != "" {
|
|
request.Header.Set(testCase.headerName, testCase.headerValue)
|
|
}
|
|
recorder := httptest.NewRecorder()
|
|
handler.ServeHTTP(recorder, request)
|
|
|
|
label := testCase.method + " " + testCase.path
|
|
if recorder.Code != testCase.status {
|
|
t.Errorf("%s: status = %d, want %d", label, recorder.Code, testCase.status)
|
|
}
|
|
contentType, present := recorder.Header()["Content-Type"]
|
|
if !present {
|
|
t.Errorf("%s: Content-Type header is absent", label)
|
|
} else if len(contentType) != 1 || contentType[0] != testCase.contentType {
|
|
t.Errorf("%s: Content-Type = %q, want [%q]", label, contentType, testCase.contentType)
|
|
}
|
|
if got := recorder.Header().Get("Content-Length"); testCase.contentLength != "" && got != testCase.contentLength {
|
|
t.Errorf("%s: Content-Length = %q, want %q", label, got, testCase.contentLength)
|
|
}
|
|
if got := recorder.Header().Get("Content-Range"); got != testCase.contentRange {
|
|
t.Errorf("%s: Content-Range = %q, want %q", label, got, testCase.contentRange)
|
|
}
|
|
if got := recorder.Body.Len(); got != testCase.bodyLength {
|
|
t.Errorf("%s: body length = %d, want %d", label, got, testCase.bodyLength)
|
|
}
|
|
// sirv never emits either, and a fabricated Last-Modified would add
|
|
// 304 responses Node never sent.
|
|
if got := recorder.Header().Get("Last-Modified"); got != "" {
|
|
t.Errorf("%s: unexpected Last-Modified %q", label, got)
|
|
}
|
|
if got := recorder.Header().Get("Cache-Control"); got != "" {
|
|
t.Errorf("%s: unexpected Cache-Control %q", label, got)
|
|
}
|
|
if got := recorder.Header().Get("Etag"); got != "" {
|
|
t.Errorf("%s: unexpected ETag %q", label, got)
|
|
}
|
|
// app.ts setHeaders runs for served files only, not for the JSON 404.
|
|
wantSecurityHeaders := testCase.status != http.StatusNotFound
|
|
for header, want := range map[string]string{
|
|
"X-Content-Type-Options": "nosniff",
|
|
"Referrer-Policy": "no-referrer",
|
|
} {
|
|
got := recorder.Header().Get(header)
|
|
if wantSecurityHeaders && got != want {
|
|
t.Errorf("%s: %s = %q, want %q", label, header, got, want)
|
|
}
|
|
if !wantSecurityHeaders && got != "" {
|
|
t.Errorf("%s: unexpected %s %q on the 404", label, header, got)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestStaticHandlerRefusesTraversal(t *testing.T) {
|
|
handler := staticHandler(staticFixture(), staticNotFound)
|
|
|
|
for _, target := range []string{
|
|
"/../go.mod", "/../../go.mod", "/assets/../../go.mod",
|
|
"/..%2fgo.mod", "/./../package.json", "//../go.mod",
|
|
} {
|
|
recorder := httptest.NewRecorder()
|
|
handler.ServeHTTP(recorder, httptest.NewRequest("GET", target, nil))
|
|
if recorder.Code != http.StatusNotFound {
|
|
t.Errorf("GET %s: status = %d, want 404", target, recorder.Code)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestStaticHandlerWithoutIndexHTML(t *testing.T) {
|
|
// The former frontend mode "none": nothing can be served, everything 404s.
|
|
handler := staticHandler(fstest.MapFS{".gitkeep": &fstest.MapFile{}}, staticNotFound)
|
|
|
|
for _, target := range []string{"/", "/host", "/assets/app.js", "/.gitkeep"} {
|
|
recorder := httptest.NewRecorder()
|
|
handler.ServeHTTP(recorder, httptest.NewRequest("GET", target, nil))
|
|
if recorder.Code != http.StatusNotFound {
|
|
t.Errorf("GET %s: status = %d, want 404", target, recorder.Code)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestLooksLikeFileMatchesSirvIgnore replays the Node oracle for sirv's
|
|
// "any extn" ignore recorded in evidence/runtime.md section 4.1.
|
|
func TestLooksLikeFileMatchesSirvIgnore(t *testing.T) {
|
|
for pathname, want := range map[string]bool{
|
|
"/": false,
|
|
"/host": false,
|
|
"/room/1234": false,
|
|
"/assets": false,
|
|
"/assets/": false,
|
|
"/index.html": true,
|
|
"/assets/index-abc123.js": true,
|
|
"/a.b.c.js": true,
|
|
"/favicon.ico": true,
|
|
"/my file.txt": true,
|
|
"/.gitkeep": false,
|
|
"/x.": false,
|
|
"/.env": false,
|
|
"/a.b-c": false,
|
|
"/文件.js": false,
|
|
"/café.css": false,
|
|
"/v1.2/thing": false,
|
|
} {
|
|
if got := looksLikeFile(pathname); got != want {
|
|
t.Errorf("looksLikeFile(%q) = %v, want %v", pathname, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestHasDotSegment(t *testing.T) {
|
|
for name, want := range map[string]bool{
|
|
"index.html": false,
|
|
"assets/app.js": false,
|
|
".gitkeep": true,
|
|
".vite/manifest.json": true,
|
|
"assets/.hidden": true,
|
|
".well-known/x.txt": false,
|
|
"deep/.well-known/x.txt": false,
|
|
"well-known/x.txt": false,
|
|
".well-knownish/file.txt": true,
|
|
} {
|
|
if got := hasDotSegment(name); got != want {
|
|
t.Errorf("hasDotSegment(%q) = %v, want %v", name, got, want)
|
|
}
|
|
}
|
|
}
|