fix(07): WR-01 NewRouter returns error instead of panicking on bad static FS

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Arthur Belleville 2026-05-15 18:54:49 +02:00
parent fbda7cbe5e
commit b61f36f17e
No known key found for this signature in database
8 changed files with 55 additions and 14 deletions

View file

@ -122,7 +122,11 @@ func main() {
fileDeps := web.FilesDeps{Queries: q, Files: filesStore, MaxUploadMB: maxUploadMB}
// D-09: pass the embedded static FS — binary has zero runtime file dependencies.
router := web.NewRouter(pool, assets.Static, deps, tabloDeps, taskDeps, fileDeps, csrfKey, env)
router, err := web.NewRouter(pool, assets.Static, deps, tabloDeps, taskDeps, fileDeps, csrfKey, env)
if err != nil {
slog.Error("router init failed", "err", err)
os.Exit(1)
}
srv := &http.Server{
Addr: ":" + port,

View file

@ -22,7 +22,11 @@ func newTestRouterWithCSRF(q *sqlc.Queries, store *auth.Store) http.Handler {
csrfKey[i] = byte(i + 1)
}
deps := AuthDeps{Queries: q, Store: store, Secure: false}
return NewRouter(stubPinger{}, os.DirFS("./static"), deps, TablosDeps{Queries: q}, TasksDeps{Queries: q}, FilesDeps{Queries: q}, csrfKey, "dev", "localhost")
router, err := NewRouter(stubPinger{}, os.DirFS("./static"), deps, TablosDeps{Queries: q}, TasksDeps{Queries: q}, FilesDeps{Queries: q}, csrfKey, "dev", "localhost")
if err != nil {
panic("newTestRouterWithCSRF: " + err.Error())
}
return router
}
// extractCSRFToken performs a GET request and extracts the _csrf token from the

View file

@ -34,14 +34,22 @@ var testCSRFKey = func() []byte {
// Referer header are accepted.
func newTestRouter(q *sqlc.Queries, store *auth.Store) http.Handler {
deps := AuthDeps{Queries: q, Store: store, Secure: false}
return NewRouter(stubPinger{}, os.DirFS("./static"), deps, TablosDeps{Queries: q}, TasksDeps{Queries: q}, FilesDeps{Queries: q}, testCSRFKey, "dev", "localhost")
router, err := NewRouter(stubPinger{}, os.DirFS("./static"), deps, TablosDeps{Queries: q}, TasksDeps{Queries: q}, FilesDeps{Queries: q}, testCSRFKey, "dev", "localhost")
if err != nil {
panic("newTestRouter: " + err.Error())
}
return router
}
// newTestRouterWithLimiter builds a router with an injected LimiterStore,
// enabling rate-limit tests to use a fake clock.
func newTestRouterWithLimiter(q *sqlc.Queries, store *auth.Store, rl *auth.LimiterStore) http.Handler {
deps := AuthDeps{Queries: q, Store: store, Secure: false, Limiter: rl}
return NewRouter(stubPinger{}, os.DirFS("./static"), deps, TablosDeps{Queries: q}, TasksDeps{Queries: q}, FilesDeps{Queries: q}, testCSRFKey, "dev", "localhost")
router, err := NewRouter(stubPinger{}, os.DirFS("./static"), deps, TablosDeps{Queries: q}, TasksDeps{Queries: q}, FilesDeps{Queries: q}, testCSRFKey, "dev", "localhost")
if err != nil {
panic("newTestRouterWithLimiter: " + err.Error())
}
return router
}
// getCSRFToken performs a GET request to path and extracts the CSRF token

View file

@ -60,7 +60,11 @@ func newFileTestRouter(q *sqlc.Queries, store *auth.Store, fileStore files.FileS
tabloDeps := TablosDeps{Queries: q}
taskDeps := TasksDeps{Queries: q}
fileDeps := FilesDeps{Queries: q, Files: fileStore, MaxUploadMB: 25}
return NewRouter(stubPinger{}, os.DirFS("./static"), authDeps, tabloDeps, taskDeps, fileDeps, testCSRFKey, "dev", "localhost")
router, err := NewRouter(stubPinger{}, os.DirFS("./static"), authDeps, tabloDeps, taskDeps, fileDeps, testCSRFKey, "dev", "localhost")
if err != nil {
panic("newFileTestRouter: " + err.Error())
}
return router
}
// ---- TestFileUpload (FILE-01, FILE-02) ----
@ -165,7 +169,10 @@ func TestFileUploadTooLarge(t *testing.T) {
tabloDeps := TablosDeps{Queries: q}
taskDeps := TasksDeps{Queries: q}
fileDeps := FilesDeps{Queries: q, Files: stub, MaxUploadMB: 1}
router := NewRouter(stubPinger{}, os.DirFS("./static"), authDeps, tabloDeps, taskDeps, fileDeps, testCSRFKey, "dev", "localhost")
router, err := NewRouter(stubPinger{}, os.DirFS("./static"), authDeps, tabloDeps, taskDeps, fileDeps, testCSRFKey, "dev", "localhost")
if err != nil {
t.Fatalf("NewRouter: %v", err)
}
user := preInsertUser(t, ctx, q, "filelarge@example.com", "correct-horse-12")
tablo, err := q.InsertTablo(ctx, sqlc.InsertTabloParams{

View file

@ -27,7 +27,11 @@ import (
func newTabloTestRouter(q *sqlc.Queries, store *auth.Store) http.Handler {
authDeps := AuthDeps{Queries: q, Store: store, Secure: false}
tabloDeps := TablosDeps{Queries: q}
return NewRouter(stubPinger{}, os.DirFS("./static"), authDeps, tabloDeps, TasksDeps{Queries: q}, FilesDeps{Queries: q}, testCSRFKey, "dev", "localhost")
router, err := NewRouter(stubPinger{}, os.DirFS("./static"), authDeps, tabloDeps, TasksDeps{Queries: q}, FilesDeps{Queries: q}, testCSRFKey, "dev", "localhost")
if err != nil {
panic("newTabloTestRouter: " + err.Error())
}
return router
}
// loginUser signs up a user and returns the session cookie set after signup.

View file

@ -30,7 +30,11 @@ func newTaskTestRouter(q *sqlc.Queries, store *auth.Store) http.Handler {
authDeps := AuthDeps{Queries: q, Store: store, Secure: false}
tabloDeps := TablosDeps{Queries: q}
taskDeps := TasksDeps{Queries: q}
return NewRouter(stubPinger{}, os.DirFS("./static"), authDeps, tabloDeps, taskDeps, FilesDeps{Queries: q}, testCSRFKey, "dev", "localhost")
router, err := NewRouter(stubPinger{}, os.DirFS("./static"), authDeps, tabloDeps, taskDeps, FilesDeps{Queries: q}, testCSRFKey, "dev", "localhost")
if err != nil {
panic("newTaskTestRouter: " + err.Error())
}
return router
}
// ---- TestTasksKanbanRenders (TASK-01) ----

View file

@ -92,7 +92,10 @@ func TestReadyz_Down(t *testing.T) {
// was public. The HTMX demo content is tested by
// TestProtected_HomeAuthRendersUserEmail in handlers_auth_test.go.
func TestIndex_UnauthRedirects(t *testing.T) {
router := NewRouter(stubPinger{}, os.DirFS("./static"), AuthDeps{}, TablosDeps{}, TasksDeps{}, FilesDeps{}, testCSRFKey, "dev")
router, err := NewRouter(stubPinger{}, os.DirFS("./static"), AuthDeps{}, TablosDeps{}, TasksDeps{}, FilesDeps{}, testCSRFKey, "dev", "localhost")
if err != nil {
t.Fatalf("NewRouter: %v", err)
}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/", nil)
@ -107,7 +110,10 @@ func TestIndex_UnauthRedirects(t *testing.T) {
}
func TestDemoTime_Fragment(t *testing.T) {
router := NewRouter(stubPinger{}, os.DirFS("./static"), AuthDeps{}, TablosDeps{}, TasksDeps{}, FilesDeps{}, testCSRFKey, "dev")
router, err := NewRouter(stubPinger{}, os.DirFS("./static"), AuthDeps{}, TablosDeps{}, TasksDeps{}, FilesDeps{}, testCSRFKey, "dev", "localhost")
if err != nil {
t.Fatalf("NewRouter: %v", err)
}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/demo/time", nil)
@ -130,7 +136,10 @@ func TestDemoTime_Fragment(t *testing.T) {
}
func TestRequestID_HeaderSet(t *testing.T) {
router := NewRouter(stubPinger{}, os.DirFS("./static"), AuthDeps{}, TablosDeps{}, TasksDeps{}, FilesDeps{}, testCSRFKey, "dev")
router, err := NewRouter(stubPinger{}, os.DirFS("./static"), AuthDeps{}, TablosDeps{}, TasksDeps{}, FilesDeps{}, testCSRFKey, "dev", "localhost")
if err != nil {
t.Fatalf("NewRouter: %v", err)
}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)

View file

@ -2,6 +2,7 @@ package web
import (
"context"
"fmt"
"io/fs"
"log/slog"
"net/http"
@ -45,7 +46,7 @@ type Pinger interface {
// trustedOrigins is an optional list of additional origins for the CSRF
// referer check (used in integration tests to allow localhost requests without
// a Referer header). In production, pass no extra args — leave empty.
func NewRouter(pinger Pinger, staticFS fs.FS, deps AuthDeps, tabloDeps TablosDeps, taskDeps TasksDeps, fileDeps FilesDeps, csrfKey []byte, env string, trustedOrigins ...string) http.Handler {
func NewRouter(pinger Pinger, staticFS fs.FS, deps AuthDeps, tabloDeps TablosDeps, taskDeps TasksDeps, fileDeps FilesDeps, csrfKey []byte, env string, trustedOrigins ...string) (http.Handler, error) {
r := chi.NewRouter()
r.Use(RequestIDMiddleware)
r.Use(chimw.RealIP)
@ -126,10 +127,10 @@ func NewRouter(pinger Pinger, staticFS fs.FS, deps AuthDeps, tabloDeps TablosDep
// the FS. The "fs" local name is avoided to prevent shadowing the "io/fs" import.
sub, err := fs.Sub(staticFS, "static")
if err != nil {
panic("router: failed to sub static FS: " + err.Error())
return nil, fmt.Errorf("router: failed to sub static FS: %w", err)
}
fileHandler := http.StripPrefix("/static/", http.FileServer(http.FS(sub)))
r.Get("/static/*", fileHandler.ServeHTTP)
return r
return r, nil
}