Files
zip/static_test.go
zeekayandHanzo Dev 5f71d2496b The router is three nouns, and every one of them is concrete
App, Group, Operation. A group is a prefix and what holds under it, an operation
is one typed route declared there, and the verb methods are generic methods —
which Go 1.27 allows on a concrete type and still refuses on an interface. So In
and Out come from the handler and the caller writes no type arguments:

	users := app.Group("/v1/users")
	users.Post("/", api.Create)

	mint := users.Group("/mint").With(middleware.Mint(gate))
	mint.Post("/deposit", api.Deposit)

A Group is a CLOSED HANDLE: two unexported fields, and no method that hands back
the app, the parent or the fiber underneath. A subsystem given the group at its
own prefix cannot reach above it, so confinement is a property of the type
instead of a rule something has to police — which is what a host needs in order
to hand out a bounded surface and know what comes back.

That is bought by giving up the router interface, deliberately. Router and
OpTarget are gone, and with them the ability to implement a route table outside
this package: a decorator is no longer a router, a gate is middleware on a group,
and a bound is a path. The five package-level verb functions go the same way —
a registration is a method on the thing it registers into.

Raw carries what is not a typed operation: a stream, an upgrade, a probe, a
foreign subtree. It names its method, and it is the longer spelling because
opting out of the typed model is the exception and should read like one.



Co-authored-by: Hanzo Dev <[email protected]>
2026-09-17 01:39:17 -07:00

234 lines
8.8 KiB
Go

package zip_test
import (
"embed"
"io"
"io/fs"
"net/http"
"os"
"strconv"
"strings"
"testing"
"github.com/zap-proto/zip"
)
// These tests pin zip.Static: it serves files from any fs.FS (embed.FS AND
// os.DirFS are exercised), rejects traversal fail-closed, and — the load-
// bearing property — a missing file yields c.Next() so a later route or SPA
// catch-all still wins, while a more-specific route beats the wildcard by
// specificity, never by registration order.
//go:embed testdata/assets
var assetsEmbed embed.FS
func embedAssets(t *testing.T) fs.FS {
t.Helper()
sub, err := fs.Sub(assetsEmbed, "testdata/assets")
if err != nil {
t.Fatalf("fs.Sub: %v", err)
}
return sub
}
func dirAssets() fs.FS { return os.DirFS("testdata/assets") }
// serve issues an in-memory request (optionally with headers) and returns the
// full response plus the body it read.
func serve(t *testing.T, app *zip.App, method, target string, headers map[string]string) (*http.Response, string) {
t.Helper()
req, err := http.NewRequest(method, target, nil)
if err != nil {
t.Fatalf("NewRequest(%s %s): %v", method, target, err)
}
for k, v := range headers {
req.Header.Set(k, v)
}
resp, err := app.Fiber().Test(req)
if err != nil {
t.Fatalf("Test(%s %s): %v", method, target, err)
}
b, _ := io.ReadAll(resp.Body)
_ = resp.Body.Close()
return resp, string(b)
}
// bothFS runs fn against Static backed by an embed.FS and an os.DirFS, proving
// the handler is fs.FS-generic.
func bothFS(t *testing.T, fn func(t *testing.T, assets fs.FS)) {
t.Helper()
t.Run("embed.FS", func(t *testing.T) { fn(t, embedAssets(t)) })
t.Run("os.DirFS", func(t *testing.T) { fn(t, dirAssets()) })
}
func TestStatic_ServesFile(t *testing.T) {
bothFS(t, func(t *testing.T, assets fs.FS) {
app := zip.New(zip.Config{DisableStartupMessage: true})
app.Raw("GET", "/assets/*", zip.Static(assets))
want, _ := fs.ReadFile(assets, "main.css")
resp, body := serve(t, app, "GET", "/assets/main.css", nil)
if resp.StatusCode != 200 {
t.Fatalf("status %d, want 200", resp.StatusCode)
}
if body != string(want) {
t.Fatalf("body %q, want %q", body, want)
}
if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/css") {
t.Fatalf("Content-Type %q, want text/css…", ct)
}
if cl := resp.Header.Get("Content-Length"); cl != strconv.Itoa(len(want)) {
t.Fatalf("Content-Length %q, want %d", cl, len(want))
}
})
}
func TestStatic_Head(t *testing.T) {
bothFS(t, func(t *testing.T, assets fs.FS) {
app := zip.New(zip.Config{DisableStartupMessage: true})
app.Raw("GET", "/assets/*", zip.Static(assets))
want, _ := fs.ReadFile(assets, "main.css")
resp, body := serve(t, app, "HEAD", "/assets/main.css", nil)
if resp.StatusCode != 200 {
t.Fatalf("status %d, want 200", resp.StatusCode)
}
if body != "" {
t.Fatalf("HEAD body %q, want empty", body)
}
if cl := resp.Header.Get("Content-Length"); cl != strconv.Itoa(len(want)) {
t.Fatalf("HEAD Content-Length %q, want %d (must report the size GET would send)", cl, len(want))
}
if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/css") {
t.Fatalf("HEAD Content-Type %q, want text/css…", ct)
}
})
}
// TestStatic_MissingFallsThroughToNext is the load-bearing behaviour: a missing
// file must NOT 500 or hard-404 — it calls c.Next() so a later route wins. Here
// a SPA catch-all registered after the wildcard serves the miss.
func TestStatic_MissingFallsThroughToNext(t *testing.T) {
bothFS(t, func(t *testing.T, assets fs.FS) {
app := zip.New(zip.Config{DisableStartupMessage: true})
app.Raw("GET", "/assets/*", zip.Static(assets))
app.Raw("GET", "/*", func(c *zip.Ctx) error { return c.String(200, "spa") })
resp, body := serve(t, app, "GET", "/assets/does-not-exist.css", nil)
if resp.StatusCode != 200 || body != "spa" {
t.Fatalf("missing file: status=%d body=%q, want 200 \"spa\" (Static must Next() to the catch-all)", resp.StatusCode, body)
}
// The real file still serves — Static didn't just always-Next.
if resp, body := serve(t, app, "GET", "/assets/main.css", nil); resp.StatusCode != 200 || strings.Contains(body, "spa") {
t.Fatalf("real file: status=%d body=%q, want the file, not the SPA", resp.StatusCode, body)
}
})
}
// With no later route, a missing file is a clean 404 (Next() with c.matched
// set), never a 500.
func TestStatic_Missing404WhenNoCatchAll(t *testing.T) {
app := zip.New(zip.Config{DisableStartupMessage: true})
app.Raw("GET", "/assets/*", zip.Static(embedAssets(t)))
resp, _ := serve(t, app, "GET", "/assets/nope.css", nil)
if resp.StatusCode != 404 {
t.Fatalf("status %d, want 404", resp.StatusCode)
}
}
// TestStatic_TraversalBlocked proves no request can escape fsys to read the
// sibling secret, across several traversal encodings. os.DirFS is rooted at
// assets and Static's fs.ValidPath gate rejects "..", so the parent's
// secret.txt is unreachable — the response never carries its content and is
// never a 200.
func TestStatic_TraversalBlocked(t *testing.T) {
app := zip.New(zip.Config{DisableStartupMessage: true})
app.Raw("GET", "/assets/*", zip.Static(dirAssets()))
for _, target := range []string{
"/assets/../secret.txt",
"/assets/../../secret.txt",
"/assets/%2e%2e/secret.txt",
"/assets/..%2fsecret.txt",
"/assets/sub/../../secret.txt",
} {
resp, body := serve(t, app, "GET", target, nil)
if strings.Contains(body, "TOP-SECRET") {
t.Fatalf("%s LEAKED the sibling secret: %q", target, body)
}
if resp.StatusCode == 200 {
t.Fatalf("%s returned 200 (%q); traversal must fail closed", target, body)
}
}
}
// TestStatic_LaterStaticRouteWins: a specific route registered AFTER the
// wildcard Static still wins for its exact path (specificity, not order),
// while every other subpath is served by Static.
func TestStatic_LaterStaticRouteWins(t *testing.T) {
app := zip.New(zip.Config{DisableStartupMessage: true})
app.Raw("GET", "/assets/*", zip.Static(embedAssets(t)))
app.Raw("GET", "/assets/special.txt", func(c *zip.Ctx) error { return c.String(200, "special-route") })
if resp, body := serve(t, app, "GET", "/assets/special.txt", nil); resp.StatusCode != 200 || body != "special-route" {
t.Fatalf("/assets/special.txt: status=%d body=%q, want the specific route to win over Static", resp.StatusCode, body)
}
if resp, body := serve(t, app, "GET", "/assets/main.css", nil); resp.StatusCode != 200 || !strings.HasPrefix(body, "body{") {
t.Fatalf("/assets/main.css: status=%d body=%q, want Static to serve it", resp.StatusCode, body)
}
}
// TestStatic_WithIndex serves the index document for directory and root
// requests, and still serves nested files directly.
func TestStatic_WithIndex(t *testing.T) {
bothFS(t, func(t *testing.T, assets fs.FS) {
app := zip.New(zip.Config{DisableStartupMessage: true})
app.Raw("GET", "/app/*", zip.Static(assets, zip.WithIndex("index.html")))
idx, _ := fs.ReadFile(assets, "index.html")
if resp, body := serve(t, app, "GET", "/app/", nil); resp.StatusCode != 200 || body != string(idx) {
t.Fatalf("/app/: status=%d body=%q, want index.html %q", resp.StatusCode, body, idx)
}
page, _ := fs.ReadFile(assets, "sub/page.html")
if resp, body := serve(t, app, "GET", "/app/sub/page.html", nil); resp.StatusCode != 200 || body != string(page) {
t.Fatalf("/app/sub/page.html: status=%d body=%q, want the nested file", resp.StatusCode, body)
}
})
}
// TestStatic_WithStripPrefix derives the fs path from the request path minus a
// prefix instead of the "*" capture: a versioned URL served from an
// unversioned tree. The default "*" capture ("v2/main.css") would miss; the
// strip maps it to "main.css".
func TestStatic_WithStripPrefix(t *testing.T) {
app := zip.New(zip.Config{DisableStartupMessage: true})
app.Raw("GET", "/static/*", zip.Static(embedAssets(t), zip.WithStripPrefix("/static/v2/")))
if resp, body := serve(t, app, "GET", "/static/v2/main.css", nil); resp.StatusCode != 200 || !strings.HasPrefix(body, "body{") {
t.Fatalf("/static/v2/main.css: status=%d body=%q, want main.css via strip-prefix", resp.StatusCode, body)
}
}
// TestStatic_IfModifiedSince304 exercises the conditional-GET path against
// os.DirFS (real mod times; embed.FS mod times are zero so Last-Modified is
// omitted there by design).
func TestStatic_IfModifiedSince304(t *testing.T) {
app := zip.New(zip.Config{DisableStartupMessage: true})
app.Raw("GET", "/assets/*", zip.Static(dirAssets()))
resp, _ := serve(t, app, "GET", "/assets/main.css", nil)
lastMod := resp.Header.Get("Last-Modified")
if lastMod == "" {
t.Fatal("os.DirFS serve set no Last-Modified")
}
// Re-request with the exact Last-Modified: must be 304 with no body.
resp2, body2 := serve(t, app, "GET", "/assets/main.css", map[string]string{"If-Modified-Since": lastMod})
if resp2.StatusCode != 304 {
t.Fatalf("conditional GET: status %d, want 304", resp2.StatusCode)
}
if body2 != "" {
t.Fatalf("304 body %q, want empty", body2)
}
}