mirror of
https://github.com/zap-proto/zip.git
synced 2026-10-19 15:38:57 +00:00
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]>
234 lines
8.8 KiB
Go
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)
|
|
}
|
|
}
|