mirror of
https://github.com/hanzoai/ingress.git
synced 2026-10-26 02:47:15 +00:00
A 404 that named no cache policy took the CDN's browser lifetime (four hours), so a chunk requested while a publish was still uploading stayed missing in that browser long after it landed.
313 lines
12 KiB
Go
313 lines
12 KiB
Go
package staticfiles
|
|
|
|
import (
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/hanzoai/ingress/pkg/config/dynamic"
|
|
)
|
|
|
|
// exportRoot writes the two shapes a static-site generator emits for a route
|
|
// that is not the root: "pricing.html" (the default) and "docs/index.html"
|
|
// (trailingSlash). "blog" is both at once — a page with children — which is what
|
|
// a section index looks like on disk.
|
|
func exportRoot(t *testing.T) string {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
|
|
for _, sub := range []string{"docs", "blog", "models", "_next/static/chunks"} {
|
|
if err := os.MkdirAll(filepath.Join(dir, sub), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
files := map[string]string{
|
|
"index.html": "<!doctype html><title>home</title>",
|
|
"models/glm-5.2.html": "<!doctype html><title>glm</title>",
|
|
"models/llama-3.2-1b.html": "<!doctype html><title>llama</title>",
|
|
"pricing.html": "<!doctype html><title>pricing</title>",
|
|
"docs/index.html": "<!doctype html><title>docs</title>",
|
|
"blog.html": "<!doctype html><title>blog</title>",
|
|
"blog/first-post.html": "<!doctype html><title>first</title>",
|
|
"_next/static/chunks/main.js": "console.log('main')\n",
|
|
"404.html": "<!doctype html><title>gone</title>",
|
|
}
|
|
for name, body := range files {
|
|
if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
return dir
|
|
}
|
|
|
|
// get returns status, the Location header, and the body.
|
|
func get(t *testing.T, srv *httptest.Server, path string) (int, string, string) {
|
|
t.Helper()
|
|
req, err := http.NewRequest(http.MethodGet, srv.URL+path, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// No redirect following: a redirect is itself a result under test.
|
|
client := &http.Client{
|
|
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
|
|
}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
body, _ := io.ReadAll(resp.Body)
|
|
return resp.StatusCode, resp.Header.Get("Location"), string(body)
|
|
}
|
|
|
|
// getFollow returns the final status and body after redirects, which is what a
|
|
// browser experiences.
|
|
func getFollow(t *testing.T, srv *httptest.Server, path string) (int, string) {
|
|
t.Helper()
|
|
resp, err := srv.Client().Get(srv.URL + path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
body, _ := io.ReadAll(resp.Body)
|
|
return resp.StatusCode, string(body)
|
|
}
|
|
|
|
// TestStaticExportRoutesResolve is the regression this plane was missing: a
|
|
// prerendered site has no router, so "/pricing" is a FILE, and serving only the
|
|
// literal key 404s every route but the root. hanzo.ai (772 routes) answered 404
|
|
// on all of them from an origin holding the complete site.
|
|
//
|
|
// Asserted after redirects because the two origins canonicalize differently and
|
|
// both are correct: on a local directory "/docs" is a real dir, so it 301s to
|
|
// "/docs/" the way every static server does; on the object store there are no
|
|
// directories, so the ladder resolves "docs/index.html" in one hop. What must be
|
|
// identical is what the reader ends up with.
|
|
func TestStaticExportRoutesResolve(t *testing.T) {
|
|
srv := httptest.NewServer(newLocal(t, dynamic.StaticFiles{Root: exportRoot(t)}))
|
|
defer srv.Close()
|
|
|
|
for _, tc := range []struct {
|
|
path string
|
|
want string
|
|
}{
|
|
{"/pricing", "<title>pricing</title>"}, // pricing.html
|
|
{"/pricing/", "<title>pricing</title>"}, // trailing slash, same page
|
|
{"/docs", "<title>docs</title>"}, // docs/index.html
|
|
{"/docs/", "<title>docs</title>"}, // directory index
|
|
{"/blog", "<title>blog</title>"}, // page that also has children
|
|
{"/blog/", "<title>blog</title>"}, // dir with no index, blog.html
|
|
{"/blog/first-post", "<title>first</title>"}, // nested route
|
|
} {
|
|
status, body := getFollow(t, srv, tc.path)
|
|
if status != http.StatusOK {
|
|
t.Errorf("GET %s = %d, want 200 — a prerendered route the origin holds", tc.path, status)
|
|
continue
|
|
}
|
|
if !strings.Contains(body, tc.want) {
|
|
t.Errorf("GET %s served the wrong page: want %q in body, got %q", tc.path, tc.want, truncate(body))
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestFileBackedRouteDoesNotRedirect pins the no-directory case — the exact shape
|
|
// a Next.js export writes and the one the object store presents for every route.
|
|
// It must resolve in ONE hop, with no Location bouncing the reader through a URL
|
|
// that is not the one they asked for.
|
|
func TestFileBackedRouteDoesNotRedirect(t *testing.T) {
|
|
srv := httptest.NewServer(newLocal(t, dynamic.StaticFiles{Root: exportRoot(t)}))
|
|
defer srv.Close()
|
|
|
|
status, location, body := get(t, srv, "/pricing")
|
|
if status != http.StatusOK {
|
|
t.Fatalf("GET /pricing = %d (Location: %q), want 200 straight from pricing.html", status, location)
|
|
}
|
|
if location != "" {
|
|
t.Errorf("GET /pricing sent Location: %q — a file-backed route needs no redirect", location)
|
|
}
|
|
if !strings.Contains(body, "<title>pricing</title>") {
|
|
t.Errorf("GET /pricing body = %q", truncate(body))
|
|
}
|
|
}
|
|
|
|
// TestDottedRouteSegmentIsAPage is the bug the first cut of this shipped with.
|
|
// A version number in the last segment makes path.Ext non-empty — ".2" for
|
|
// "glm-5.2", ".2-1b" for "llama-3.2-1b" — so an extension-based "is this an
|
|
// asset" guess treats a page as a file and skips the ladder. On hanzo.ai that
|
|
// was 181 of 773 routes: every model page carrying a version, 404 while the
|
|
// plain routes served.
|
|
func TestDottedRouteSegmentIsAPage(t *testing.T) {
|
|
srv := httptest.NewServer(newLocal(t, dynamic.StaticFiles{Root: exportRoot(t)}))
|
|
defer srv.Close()
|
|
|
|
for path, want := range map[string]string{
|
|
"/models/glm-5.2": "<title>glm</title>",
|
|
"/models/llama-3.2-1b": "<title>llama</title>",
|
|
} {
|
|
status, _, body := get(t, srv, path)
|
|
if status != http.StatusOK {
|
|
t.Errorf("GET %s = %d, want 200 — a dot in a segment is not an extension", path, status)
|
|
continue
|
|
}
|
|
if !strings.Contains(body, want) {
|
|
t.Errorf("GET %s served %q, want %q", path, truncate(body), want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestRootServesIndexWithoutRedirect pins that "/" is answered, not moved. The
|
|
// index loop used to 301 to "/index.html", which relocates every site's
|
|
// canonical URL onto a path nothing links to.
|
|
func TestRootServesIndexWithoutRedirect(t *testing.T) {
|
|
srv := httptest.NewServer(newLocal(t, dynamic.StaticFiles{Root: exportRoot(t)}))
|
|
defer srv.Close()
|
|
|
|
status, location, body := get(t, srv, "/")
|
|
if status != http.StatusOK {
|
|
t.Fatalf("GET / = %d (Location: %q), want 200 serving index.html", status, location)
|
|
}
|
|
if location != "" {
|
|
t.Errorf("GET / sent Location: %q — the root must be served, not redirected", location)
|
|
}
|
|
if !strings.Contains(body, "<title>home</title>") {
|
|
t.Errorf("GET / body = %q, want the index", truncate(body))
|
|
}
|
|
}
|
|
|
|
// TestMissingAssetStaysBare is the guard on the fix: the candidate ladder must
|
|
// never answer a missing content-hashed chunk with a page. A chunk answered by
|
|
// HTML is how a stale asset graph turns into an unreadable ChunkLoadError.
|
|
func TestMissingAssetStaysBare(t *testing.T) {
|
|
srv := httptest.NewServer(newLocal(t, dynamic.StaticFiles{Root: exportRoot(t)}))
|
|
defer srv.Close()
|
|
|
|
for _, p := range []string{
|
|
"/_next/static/chunks/nope.js",
|
|
"/_next/static/chunks/nope.css",
|
|
"/missing.woff2",
|
|
} {
|
|
status, _, body := get(t, srv, p)
|
|
if status != http.StatusNotFound {
|
|
t.Errorf("GET %s = %d, want 404", p, status)
|
|
}
|
|
if strings.Contains(body, "<title>") {
|
|
t.Errorf("GET %s answered with a page (%q) — an asset miss must stay bare", p, truncate(body))
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestUnknownRouteIsNotFound proves the ladder does not invent pages: a route
|
|
// with no file behind it is still a 404, not somebody else's page.
|
|
func TestUnknownRouteIsNotFound(t *testing.T) {
|
|
srv := httptest.NewServer(newLocal(t, dynamic.StaticFiles{Root: exportRoot(t)}))
|
|
defer srv.Close()
|
|
|
|
if status, _, _ := get(t, srv, "/no-such-route"); status != http.StatusNotFound {
|
|
t.Errorf("GET /no-such-route = %d, want 404", status)
|
|
}
|
|
}
|
|
|
|
// TestSPAModeStillFallsBack proves the SPA plane is untouched: an extensionless
|
|
// miss still reaches the shell, and an asset miss still does not.
|
|
func TestSPAModeStillFallsBack(t *testing.T) {
|
|
srv := httptest.NewServer(newLocal(t, dynamic.StaticFiles{Root: exportRoot(t), SPAMode: true}))
|
|
defer srv.Close()
|
|
|
|
status, _, body := get(t, srv, "/client/route")
|
|
if status != http.StatusOK || !strings.Contains(body, "<title>home</title>") {
|
|
t.Errorf("SPA route = %d body=%q, want 200 shell", status, truncate(body))
|
|
}
|
|
|
|
if status, _, _ := get(t, srv, "/_next/static/chunks/nope.js"); status != http.StatusNotFound {
|
|
t.Errorf("SPA asset miss = %d, want 404", status)
|
|
}
|
|
|
|
// A dotted client route is a route: ".3" is no media type.
|
|
if status, _, body := get(t, srv, "/models/glm-9.3"); status != http.StatusOK || !strings.Contains(body, "<title>home</title>") {
|
|
t.Errorf("SPA dotted route = %d body=%q, want 200 shell", status, truncate(body))
|
|
}
|
|
|
|
// A data file is answered as data, never with the shell.
|
|
if status, _, body := get(t, srv, "/config.json"); status != http.StatusNotFound || !strings.Contains(body, `"not found"`) {
|
|
t.Errorf("SPA JSON miss = %d body=%q, want a JSON 404", status, truncate(body))
|
|
}
|
|
}
|
|
|
|
// TestMissAnswersByKind pins cloud's Sites contract (apps/sites notFound): a
|
|
// page miss gets the site's 404 page with status 404, a JSON miss a JSON 404, and
|
|
// any other file a bare 404. What decides is the media type the path names, so a
|
|
// dotted route is a page and a missing chunk is never answered with markup.
|
|
func TestMissAnswersByKind(t *testing.T) {
|
|
root := exportRoot(t)
|
|
if err := os.MkdirAll(filepath.Join(root, "empty"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
srv := httptest.NewServer(newLocal(t, dynamic.StaticFiles{Root: root, ErrorPage404: "404.html"}))
|
|
defer srv.Close()
|
|
|
|
for _, tc := range []struct {
|
|
path, want, ctype string
|
|
}{
|
|
{"/no-such-route", "<title>gone</title>", "text/html"},
|
|
{"/models/glm-9.3", "<title>gone</title>", "text/html"}, // dotted route, missing
|
|
{"/models/llama-3.2-1b-x", "<title>gone</title>", "text/html"}, // ".2-1b-x" is no type
|
|
{"/empty/", "<title>gone</title>", "text/html"}, // directory, no index
|
|
{"/data/nope.json", `{"error":"not found","path":"/data/nope.json"}`, "application/json"},
|
|
{"/_next/static/chunks/nope.js", "404 page not found", "text/plain"},
|
|
{"/missing.woff2", "404 page not found", "text/plain"},
|
|
{"/robots.txt", "404 page not found", "text/plain"},
|
|
} {
|
|
status, _, body := get(t, srv, tc.path)
|
|
if status != http.StatusNotFound {
|
|
t.Errorf("GET %s = %d, want 404", tc.path, status)
|
|
}
|
|
if !strings.Contains(body, tc.want) {
|
|
t.Errorf("GET %s body = %q, want %q", tc.path, truncate(body), tc.want)
|
|
}
|
|
resp, err := srv.Client().Head(srv.URL + tc.path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
resp.Body.Close()
|
|
if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, tc.ctype) || resp.StatusCode != http.StatusNotFound {
|
|
t.Errorf("HEAD %s = %d %q, want 404 %s", tc.path, resp.StatusCode, ct, tc.ctype)
|
|
}
|
|
// No cache may keep a 404: a chunk asked for mid-publish must load once it lands.
|
|
if cc := resp.Header.Get("Cache-Control"); cc != "no-store" {
|
|
t.Errorf("HEAD %s Cache-Control = %q, want no-store", tc.path, cc)
|
|
}
|
|
}
|
|
|
|
// The ladder still wins over the 404 page.
|
|
if status, _, body := get(t, srv, "/models/glm-5.2"); status != http.StatusOK || !strings.Contains(body, "<title>glm</title>") {
|
|
t.Errorf("GET /models/glm-5.2 = %d %q, want the page", status, truncate(body))
|
|
}
|
|
}
|
|
|
|
// TestMissWithoutErrorPage: no configured page, or a configured page the root
|
|
// does not hold, is a bare 404 and never a 500.
|
|
func TestMissWithoutErrorPage(t *testing.T) {
|
|
for name, cfg := range map[string]dynamic.StaticFiles{
|
|
"unset": {Root: exportRoot(t)},
|
|
"absent": {Root: exportRoot(t), ErrorPage404: "missing-404.html"},
|
|
} {
|
|
srv := httptest.NewServer(newLocal(t, cfg))
|
|
if status, _, body := get(t, srv, "/no-such-route"); status != http.StatusNotFound || strings.Contains(body, "<title>") {
|
|
t.Errorf("%s: GET /no-such-route = %d %q, want a bare 404", name, status, truncate(body))
|
|
}
|
|
srv.Close()
|
|
}
|
|
}
|
|
|
|
func truncate(s string) string {
|
|
if len(s) > 120 {
|
|
return s[:120] + "..."
|
|
}
|
|
return s
|
|
}
|