143 lines
4.8 KiB
Go
143 lines
4.8 KiB
Go
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
|||
|
|
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||
|
|
|
||
|
|
package web
|
||
|
|
|
||
|
|
import (
|
||
|
|
"io"
|
||
|
|
"io/fs"
|
||
|
|
"net/http"
|
||
|
|
"net/http/httptest"
|
||
|
|
"regexp"
|
||
|
|
"strings"
|
||
|
|
"testing"
|
||
|
|
)
|
||
|
|
|
||
|
|
func TestAssetHandlerServesThePackagedFiles(t *testing.T) {
|
||
|
|
cases := map[string]string{
|
||
|
|
"/admin/assets/admin.css": "text/css",
|
||
|
|
"/admin/assets/fonts.css": "text/css",
|
||
|
|
"/admin/assets/graphis.svg": "image/svg+xml",
|
||
|
|
"/admin/assets/fonts/ubuntu-normal-400-latin.woff2": "font/woff2",
|
||
|
|
}
|
||
|
|
for path, wantType := range cases {
|
||
|
|
rec := httptest.NewRecorder()
|
||
|
|
AssetHandler(rec, httptest.NewRequest(http.MethodGet, path, nil))
|
||
|
|
response := rec.Result()
|
||
|
|
if response.StatusCode != http.StatusOK {
|
||
|
|
t.Fatalf("%s: status %d", path, response.StatusCode)
|
||
|
|
}
|
||
|
|
if got := response.Header.Get("Content-Type"); !strings.HasPrefix(got, wantType) {
|
||
|
|
t.Errorf("%s: content type %q, want %q", path, got, wantType)
|
||
|
|
}
|
||
|
|
body, err := io.ReadAll(response.Body)
|
||
|
|
if err != nil || len(body) == 0 {
|
||
|
|
t.Fatalf("%s: empty body: %v", path, err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestAssetHandlerRejectsUnknownAndTraversal(t *testing.T) {
|
||
|
|
for _, path := range []string{
|
||
|
|
"/admin/assets/",
|
||
|
|
"/admin/assets/nope.css",
|
||
|
|
"/admin/assets/../etc/passwd",
|
||
|
|
"/admin/assets/fonts/../admin.css",
|
||
|
|
} {
|
||
|
|
rec := httptest.NewRecorder()
|
||
|
|
AssetHandler(rec, httptest.NewRequest(http.MethodGet, path, nil))
|
||
|
|
if code := rec.Result().StatusCode; code != http.StatusOK && code != http.StatusNotFound {
|
||
|
|
t.Fatalf("%s: status %d", path, code)
|
||
|
|
}
|
||
|
|
// Only the packaged files are a 200; a re-served parent is a hit
|
||
|
|
// only if the tail resolves inside the set after Clean.
|
||
|
|
if path == "/admin/assets/" || path == "/admin/assets/nope.css" {
|
||
|
|
if rec.Result().StatusCode != http.StatusNotFound {
|
||
|
|
t.Errorf("%s: want 404, got %d", path, rec.Result().StatusCode)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestAssetHandlerRevalidatesWithTheETag(t *testing.T) {
|
||
|
|
rec := httptest.NewRecorder()
|
||
|
|
AssetHandler(rec, httptest.NewRequest(http.MethodGet, "/admin/assets/admin.css", nil))
|
||
|
|
etag := rec.Result().Header.Get("ETag")
|
||
|
|
if etag == "" {
|
||
|
|
t.Fatal("no ETag on the asset")
|
||
|
|
}
|
||
|
|
if got := rec.Result().Header.Get("Cache-Control"); !strings.Contains(got, "must-revalidate") {
|
||
|
|
t.Errorf("cache-control %q, want revalidation", got)
|
||
|
|
}
|
||
|
|
|
||
|
|
second := httptest.NewRecorder()
|
||
|
|
request := httptest.NewRequest(http.MethodGet, "/admin/assets/admin.css", nil)
|
||
|
|
request.Header.Set("If-None-Match", etag)
|
||
|
|
AssetHandler(second, request)
|
||
|
|
if code := second.Result().StatusCode; code != http.StatusNotModified {
|
||
|
|
t.Fatalf("revalidation status %d, want 304", code)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestEveryTemplateGlyphReferenceResolves(t *testing.T) {
|
||
|
|
sprite := assets["graphis.svg"]
|
||
|
|
if sprite == nil {
|
||
|
|
t.Fatal("the sprite is not packaged")
|
||
|
|
}
|
||
|
|
symbol := regexp.MustCompile(`<symbol id="([^"]+)"`)
|
||
|
|
defined := map[string]bool{}
|
||
|
|
for _, m := range symbol.FindAllStringSubmatch(string(sprite.body), -1) {
|
||
|
|
defined[m[1]] = true
|
||
|
|
}
|
||
|
|
|
||
|
|
ref := regexp.MustCompile(`icons\.svg#([a-z0-9-]+)"`)
|
||
|
|
// The date-picker builds its glyphs in JavaScript, so its ids are not
|
||
|
|
// literal in the template; they are named here to keep them covered.
|
||
|
|
jsRefs := []string{"chevron-left", "chevron-right", "calendar"}
|
||
|
|
|
||
|
|
tpls, err := fs.ReadDir(TemplateFS(), "templates")
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("templates unreadable: %v", err)
|
||
|
|
}
|
||
|
|
for _, entry := range tpls {
|
||
|
|
body, err := TemplateFS().ReadFile("templates/" + entry.Name())
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("%s: %v", entry.Name(), err)
|
||
|
|
}
|
||
|
|
for _, m := range ref.FindAllStringSubmatch(string(body), -1) {
|
||
|
|
if !defined[m[1]] {
|
||
|
|
t.Errorf("%s: references the glyph %q, which the sprite does not define", entry.Name(), m[1])
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
for _, name := range jsRefs {
|
||
|
|
if !defined[name] {
|
||
|
|
t.Errorf("the date picker references the glyph %q, which the sprite does not define", name)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestSecurityHeadersLetAssetsBeCached(t *testing.T) {
|
||
|
|
handler := SecurityHeaders(false)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||
|
|
_, _ = io.WriteString(w, "ok")
|
||
|
|
}))
|
||
|
|
|
||
|
|
rec := httptest.NewRecorder()
|
||
|
|
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/admin/assets/admin.css", nil))
|
||
|
|
if got := rec.Result().Header.Get("Cache-Control"); got != "" {
|
||
|
|
t.Errorf("asset cache-control = %q, want the handler to own it", got)
|
||
|
|
}
|
||
|
|
if strings.Contains(rec.Result().Header.Get("Content-Security-Policy"), "nonce-") {
|
||
|
|
t.Error("asset response carries a page CSP with a nonce")
|
||
|
|
}
|
||
|
|
|
||
|
|
rec = httptest.NewRecorder()
|
||
|
|
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/admin/", nil))
|
||
|
|
if got := rec.Result().Header.Get("Cache-Control"); got != "no-store" {
|
||
|
|
t.Errorf("admin page cache-control = %q, want no-store", got)
|
||
|
|
}
|
||
|
|
if !strings.Contains(rec.Result().Header.Get("Content-Security-Policy"), "nonce-") {
|
||
|
|
t.Error("admin page CSP lost its nonce")
|
||
|
|
}
|
||
|
|
}
|