Feature 01 task 3: Embed the PWA build with SPA fallback (#4)
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com> Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
This commit was merged in pull request #4.
This commit is contained in:
@@ -8,6 +8,11 @@ deploy/secrets/
|
|||||||
# Build artifacts and dependencies
|
# Build artifacts and dependencies
|
||||||
node_modules
|
node_modules
|
||||||
dist
|
dist
|
||||||
|
bin/
|
||||||
|
# Placeholder so the Go embed compiles on a fresh clone; built assets stay ignored.
|
||||||
|
!internal/frontend/dist/
|
||||||
|
internal/frontend/dist/*
|
||||||
|
!internal/frontend/dist/index.html
|
||||||
|
|
||||||
# Local databases
|
# Local databases
|
||||||
*.db
|
*.db
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Build entry point: build the PWA, embed it, then build the server binary.
|
||||||
|
|
||||||
|
FRONTEND_DIST := internal/frontend/dist
|
||||||
|
|
||||||
|
.PHONY: build web embed check-embed-clean
|
||||||
|
|
||||||
|
build: embed
|
||||||
|
go build -o bin/mealquest ./cmd/server
|
||||||
|
|
||||||
|
web:
|
||||||
|
cd web && npm ci && npm run build
|
||||||
|
|
||||||
|
# Replace the committed placeholder with the built PWA so go:embed picks it up.
|
||||||
|
# Recreate the whole directory: a glob would skip dotfiles, which all:dist embeds.
|
||||||
|
embed: web
|
||||||
|
rm -rf $(FRONTEND_DIST)
|
||||||
|
mkdir -p $(FRONTEND_DIST)
|
||||||
|
cp -R web/dist/. $(FRONTEND_DIST)/
|
||||||
|
|
||||||
|
# Regression check: a stale hidden file must not survive an embed.
|
||||||
|
check-embed-clean:
|
||||||
|
touch $(FRONTEND_DIST)/.stale-embed-check
|
||||||
|
$(MAKE) embed
|
||||||
|
test ! -e $(FRONTEND_DIST)/.stale-embed-check
|
||||||
+6
-1
@@ -13,6 +13,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/config"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/config"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/frontend"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/server"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/server"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -37,9 +38,13 @@ func run(cfg config.Config, logger *slog.Logger) error {
|
|||||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||||
defer stop()
|
defer stop()
|
||||||
|
|
||||||
|
router, err := server.NewRouter(logger, version, frontend.Assets())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
Addr: cfg.HTTPAddr,
|
Addr: cfg.HTTPAddr,
|
||||||
Handler: server.NewRouter(logger, version),
|
Handler: router,
|
||||||
ReadHeaderTimeout: 10 * time.Second,
|
ReadHeaderTimeout: 10 * time.Second,
|
||||||
ErrorLog: slog.NewLogLogger(logger.Handler(), slog.LevelError),
|
ErrorLog: slog.NewLogLogger(logger.Handler(), slog.LevelError),
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+11
@@ -0,0 +1,11 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<title>MealQuest</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- Placeholder so the Go embed compiles. The build replaces this directory with web/dist. -->
|
||||||
|
<p>MealQuest web assets have not been built.</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
// Package frontend serves the embedded PWA build with an SPA fallback.
|
||||||
|
package frontend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"path"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// embedded holds the web build. The build copies web/dist over the committed
|
||||||
|
// placeholder index.html.
|
||||||
|
//
|
||||||
|
//go:embed all:dist
|
||||||
|
var embedded embed.FS
|
||||||
|
|
||||||
|
const (
|
||||||
|
indexFile = "index.html"
|
||||||
|
|
||||||
|
cacheImmutable = "public, max-age=31536000, immutable"
|
||||||
|
cacheNoCache = "no-cache"
|
||||||
|
)
|
||||||
|
|
||||||
|
// noCacheFiles must be revalidated on every load: they name the hashed assets
|
||||||
|
// (or, for the service worker, control their caching), so a stale copy pins an
|
||||||
|
// old release.
|
||||||
|
var noCacheFiles = map[string]bool{
|
||||||
|
indexFile: true,
|
||||||
|
"sw.js": true,
|
||||||
|
"manifest.webmanifest": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assets returns the embedded web build rooted at its dist directory.
|
||||||
|
func Assets() fs.FS {
|
||||||
|
sub, err := fs.Sub(embedded, "dist")
|
||||||
|
if err != nil {
|
||||||
|
panic(err) // "dist" is a valid path, so Sub cannot fail
|
||||||
|
}
|
||||||
|
return sub
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewHandler returns a handler that serves files from fsys. A GET or HEAD for a
|
||||||
|
// path that is not a file in fsys is answered with index.html so client-side
|
||||||
|
// routes load the app. Paths equal to or under a reserved prefix (for example
|
||||||
|
// "/api") are never answered with index.html, whether before or after the path
|
||||||
|
// is cleaned; they are passed to notFound.
|
||||||
|
// fsys must contain index.html.
|
||||||
|
func NewHandler(fsys fs.FS, logger *slog.Logger, notFound http.Handler, reserved ...string) (http.Handler, error) {
|
||||||
|
if _, err := fs.Stat(fsys, indexFile); err != nil {
|
||||||
|
return nil, fmt.Errorf("frontend assets: %w", err)
|
||||||
|
}
|
||||||
|
return &handler{fsys: fsys, logger: logger, notFound: notFound, reserved: reserved}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type handler struct {
|
||||||
|
fsys fs.FS
|
||||||
|
logger *slog.Logger
|
||||||
|
notFound http.Handler
|
||||||
|
reserved []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
urlPath := path.Clean("/" + r.URL.Path)
|
||||||
|
// Check the uncleaned path too, so dot segments cannot escape a reserved
|
||||||
|
// prefix into the fallback ("/auth/../quests/42" cleans to "/quests/42").
|
||||||
|
if h.isReserved(r.URL.Path) || h.isReserved(urlPath) {
|
||||||
|
h.notFound.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
name := strings.TrimPrefix(urlPath, "/")
|
||||||
|
if name == "" || !h.isFile(name) {
|
||||||
|
name = indexFile
|
||||||
|
}
|
||||||
|
h.serveFile(w, r, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handler) isReserved(urlPath string) bool {
|
||||||
|
for _, prefix := range h.reserved {
|
||||||
|
if urlPath == prefix || strings.HasPrefix(urlPath, prefix+"/") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handler) isFile(name string) bool {
|
||||||
|
info, err := fs.Stat(h.fsys, name)
|
||||||
|
return err == nil && info.Mode().IsRegular()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handler) serveFile(w http.ResponseWriter, r *http.Request, name string) {
|
||||||
|
f, err := h.fsys.Open(name)
|
||||||
|
if err != nil {
|
||||||
|
h.fail(w, r, name, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
content, ok := f.(io.ReadSeeker)
|
||||||
|
if !ok {
|
||||||
|
h.fail(w, r, name, errors.New("file does not implement io.Seeker"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if noCacheFiles[name] {
|
||||||
|
w.Header().Set("Cache-Control", cacheNoCache)
|
||||||
|
} else {
|
||||||
|
w.Header().Set("Cache-Control", cacheImmutable)
|
||||||
|
}
|
||||||
|
if path.Ext(name) == ".webmanifest" {
|
||||||
|
w.Header().Set("Content-Type", "application/manifest+json")
|
||||||
|
}
|
||||||
|
// Embedded files carry no modification time; ServeContent omits
|
||||||
|
// Last-Modified for the zero time.
|
||||||
|
http.ServeContent(w, r, name, time.Time{}, content)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handler) fail(w http.ResponseWriter, r *http.Request, name string, err error) {
|
||||||
|
h.logger.ErrorContext(r.Context(), "serve frontend asset", "file", name, "error", err)
|
||||||
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
package frontend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"testing/fstest"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
indexBody = "<!doctype html><title>fixture index</title>"
|
||||||
|
assetBody = "console.log('fixture asset')"
|
||||||
|
robotsBody = "User-agent: *"
|
||||||
|
swBody = "self.addEventListener('fetch', () => {})"
|
||||||
|
manifestBody = `{"name":"MealQuest"}`
|
||||||
|
notFoundBody = "reserved not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
func fixture() fstest.MapFS {
|
||||||
|
return fstest.MapFS{
|
||||||
|
"index.html": {Data: []byte(indexBody)},
|
||||||
|
"sw.js": {Data: []byte(swBody)},
|
||||||
|
"manifest.webmanifest": {Data: []byte(manifestBody)},
|
||||||
|
"assets/index-AbC123.js": {Data: []byte(assetBody)},
|
||||||
|
"robots.txt": {Data: []byte(robotsBody)},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestHandler(t *testing.T) http.Handler {
|
||||||
|
t.Helper()
|
||||||
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
|
notFound := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Error(w, notFoundBody, http.StatusNotFound)
|
||||||
|
})
|
||||||
|
h, err := NewHandler(fixture(), logger, notFound, "/api", "/auth", "/media", "/health")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServe(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
method string
|
||||||
|
path string
|
||||||
|
wantBody string
|
||||||
|
wantCache string
|
||||||
|
}{
|
||||||
|
{"hashed asset", http.MethodGet, "/assets/index-AbC123.js", assetBody, cacheImmutable},
|
||||||
|
{"unhashed asset", http.MethodGet, "/robots.txt", robotsBody, cacheImmutable},
|
||||||
|
{"root", http.MethodGet, "/", indexBody, cacheNoCache},
|
||||||
|
{"index.html", http.MethodGet, "/index.html", indexBody, cacheNoCache},
|
||||||
|
{"service worker", http.MethodGet, "/sw.js", swBody, cacheNoCache},
|
||||||
|
{"manifest", http.MethodGet, "/manifest.webmanifest", manifestBody, cacheNoCache},
|
||||||
|
{"client route", http.MethodGet, "/quests/42", indexBody, cacheNoCache},
|
||||||
|
{"missing asset", http.MethodGet, "/assets/missing.js", indexBody, cacheNoCache},
|
||||||
|
{"directory", http.MethodGet, "/assets", indexBody, cacheNoCache},
|
||||||
|
{"dot segments", http.MethodGet, "/x/../../sw.js", swBody, cacheNoCache},
|
||||||
|
{"prefix lookalike", http.MethodGet, "/apiary", indexBody, cacheNoCache},
|
||||||
|
{"head", http.MethodHead, "/assets/index-AbC123.js", "", cacheImmutable},
|
||||||
|
}
|
||||||
|
h := newTestHandler(t)
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, httptest.NewRequest(tt.method, tt.path, nil))
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Errorf("status = %d, want 200", rec.Code)
|
||||||
|
}
|
||||||
|
if got := rec.Body.String(); got != tt.wantBody {
|
||||||
|
t.Errorf("body = %q, want %q", got, tt.wantBody)
|
||||||
|
}
|
||||||
|
if got := rec.Header().Get("Cache-Control"); got != tt.wantCache {
|
||||||
|
t.Errorf("Cache-Control = %q, want %q", got, tt.wantCache)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManifestContentType(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
newTestHandler(t).ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/manifest.webmanifest", nil))
|
||||||
|
|
||||||
|
if got := rec.Header().Get("Content-Type"); got != "application/manifest+json" {
|
||||||
|
t.Errorf("Content-Type = %q, want application/manifest+json", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReservedPrefixesNotServed(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
h := newTestHandler(t)
|
||||||
|
for _, path := range []string{"/api", "/api/unknown", "/auth/login", "/media/x.jpg", "/health", "/health/deep", "/x/../api/unknown", "/auth/../quests/42", "/api%2F..%2Fquests/42"} {
|
||||||
|
t.Run(path, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
|
||||||
|
|
||||||
|
if rec.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("status = %d, want 404", rec.Code)
|
||||||
|
}
|
||||||
|
if got := rec.Body.String(); got != notFoundBody+"\n" {
|
||||||
|
t.Errorf("body = %q, want the notFound handler's body", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewHandlerRequiresIndex(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
|
fsys := fstest.MapFS{"sw.js": {Data: []byte(swBody)}}
|
||||||
|
if _, err := NewHandler(fsys, logger, http.NotFoundHandler()); err == nil {
|
||||||
|
t.Fatal("NewHandler without index.html: want error, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAssetsContainsIndex(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
|
if _, err := NewHandler(Assets(), logger, http.NotFoundHandler()); err != nil {
|
||||||
|
t.Fatalf("embedded assets: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,20 +3,44 @@ package server
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"io/fs"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/frontend"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// reservedPrefixes are owned by the API router and are never answered with the
|
||||||
|
// SPA's index.html.
|
||||||
|
var reservedPrefixes = []string{"/api", "/auth", "/media", "/health"}
|
||||||
|
|
||||||
// NewRouter returns the HTTP handler for the service. version is reported by
|
// NewRouter returns the HTTP handler for the service. version is reported by
|
||||||
// GET /health.
|
// GET /health; assets is the web build served for every other GET.
|
||||||
func NewRouter(logger *slog.Logger, version string) http.Handler {
|
func NewRouter(logger *slog.Logger, version string, assets fs.FS) (http.Handler, error) {
|
||||||
|
notFound := apiErrorHandler(logger, http.StatusNotFound, "not_found", "not found")
|
||||||
|
methodNotAllowed := apiErrorHandler(logger, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
|
||||||
|
|
||||||
|
web, err := frontend.NewHandler(assets, logger, notFound, reservedPrefixes...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
|
r.MethodNotAllowed(methodNotAllowed)
|
||||||
|
r.NotFound(notFound)
|
||||||
|
|
||||||
r.Get("/health", health(logger, version))
|
r.Get("/health", health(logger, version))
|
||||||
r.MethodNotAllowed(apiErrorHandler(logger, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed"))
|
r.Route("/api", func(r chi.Router) {
|
||||||
r.NotFound(apiErrorHandler(logger, http.StatusNotFound, "not_found", "not found"))
|
r.MethodNotAllowed(methodNotAllowed)
|
||||||
return r
|
r.NotFound(notFound)
|
||||||
|
})
|
||||||
|
// Catch-all for the PWA. /auth and /media have no routes yet; the frontend
|
||||||
|
// handler passes them to notFound rather than the SPA fallback.
|
||||||
|
r.Method(http.MethodGet, "/*", web)
|
||||||
|
r.Method(http.MethodHead, "/*", web)
|
||||||
|
return r, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type errorBody struct {
|
type errorBody struct {
|
||||||
|
|||||||
+107
-10
@@ -7,14 +7,35 @@ import (
|
|||||||
"maps"
|
"maps"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"testing/fstest"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
fixtureIndex = "<!doctype html><title>fixture index</title>"
|
||||||
|
fixtureAsset = "console.log('fixture asset')"
|
||||||
|
notFoundJSON = `{"error":{"code":"not_found","message":"not found","retryable":false}}`
|
||||||
|
)
|
||||||
|
|
||||||
|
func newTestRouter(t *testing.T, version string) http.Handler {
|
||||||
|
t.Helper()
|
||||||
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
|
assets := fstest.MapFS{
|
||||||
|
"index.html": {Data: []byte(fixtureIndex)},
|
||||||
|
"assets/index-AbC123.js": {Data: []byte(fixtureAsset)},
|
||||||
|
}
|
||||||
|
r, err := NewRouter(logger, version, assets)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
func TestHealth(t *testing.T) {
|
func TestHealth(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
srv := httptest.NewServer(newTestRouter(t, "1.2.3"))
|
||||||
srv := httptest.NewServer(NewRouter(logger, "1.2.3"))
|
|
||||||
t.Cleanup(srv.Close)
|
t.Cleanup(srv.Close)
|
||||||
|
|
||||||
resp, err := http.Get(srv.URL + "/health")
|
resp, err := http.Get(srv.URL + "/health")
|
||||||
@@ -42,9 +63,8 @@ func TestHealth(t *testing.T) {
|
|||||||
func TestHealthRejectsOtherMethods(t *testing.T) {
|
func TestHealthRejectsOtherMethods(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
NewRouter(logger, "dev").ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/health", nil))
|
newTestRouter(t, "dev").ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/health", nil))
|
||||||
|
|
||||||
if rec.Code != http.StatusMethodNotAllowed {
|
if rec.Code != http.StatusMethodNotAllowed {
|
||||||
t.Errorf("status = %d, want 405", rec.Code)
|
t.Errorf("status = %d, want 405", rec.Code)
|
||||||
@@ -68,12 +88,29 @@ func TestHealthRejectsOtherMethods(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestUnknownPathNotFound(t *testing.T) {
|
func TestReservedPathsNotFound(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
router := newTestRouter(t, "dev")
|
||||||
|
tests := []struct {
|
||||||
|
method string
|
||||||
|
path string
|
||||||
|
}{
|
||||||
|
{http.MethodGet, "/api"},
|
||||||
|
{http.MethodGet, "/api/unknown"},
|
||||||
|
{http.MethodPost, "/api/unknown"},
|
||||||
|
{http.MethodGet, "/auth/callback"},
|
||||||
|
{http.MethodGet, "/media/photo.jpg"},
|
||||||
|
{http.MethodGet, "/health/deep"},
|
||||||
|
{http.MethodGet, "/auth/../quests/42"},
|
||||||
|
{http.MethodGet, "/api%2F..%2Fquests/42"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.method+" "+tt.path, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
NewRouter(logger, "dev").ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/unknown", nil))
|
router.ServeHTTP(rec, httptest.NewRequest(tt.method, tt.path, nil))
|
||||||
|
|
||||||
if rec.Code != http.StatusNotFound {
|
if rec.Code != http.StatusNotFound {
|
||||||
t.Errorf("status = %d, want 404", rec.Code)
|
t.Errorf("status = %d, want 404", rec.Code)
|
||||||
@@ -81,8 +118,68 @@ func TestUnknownPathNotFound(t *testing.T) {
|
|||||||
if ct := rec.Header().Get("Content-Type"); ct != "application/json" {
|
if ct := rec.Header().Get("Content-Type"); ct != "application/json" {
|
||||||
t.Errorf("Content-Type = %q, want application/json", ct)
|
t.Errorf("Content-Type = %q, want application/json", ct)
|
||||||
}
|
}
|
||||||
const want = `{"error":{"code":"not_found","message":"not found","retryable":false}}`
|
if got := rec.Body.String(); got != notFoundJSON {
|
||||||
if got := rec.Body.String(); got != want {
|
t.Errorf("body = %s, want %s", got, notFoundJSON)
|
||||||
t.Errorf("body = %s, want %s", got, want)
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFrontend(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
router := newTestRouter(t, "dev")
|
||||||
|
tests := []struct {
|
||||||
|
path string
|
||||||
|
wantBody string
|
||||||
|
wantCache string
|
||||||
|
}{
|
||||||
|
{"/", fixtureIndex, "no-cache"},
|
||||||
|
{"/assets/index-AbC123.js", fixtureAsset, "public, max-age=31536000, immutable"},
|
||||||
|
{"/quests/42", fixtureIndex, "no-cache"},
|
||||||
|
{"/quests/42/edit", fixtureIndex, "no-cache"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.path, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, tt.path, nil))
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Errorf("status = %d, want 200", rec.Code)
|
||||||
|
}
|
||||||
|
if got := rec.Body.String(); got != tt.wantBody {
|
||||||
|
t.Errorf("body = %q, want %q", got, tt.wantBody)
|
||||||
|
}
|
||||||
|
if got := rec.Header().Get("Cache-Control"); got != tt.wantCache {
|
||||||
|
t.Errorf("Cache-Control = %q, want %q", got, tt.wantCache)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHealthNotShadowed(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
newTestRouter(t, "dev").ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/health", nil))
|
||||||
|
|
||||||
|
if body := rec.Body.String(); rec.Code != http.StatusOK || strings.Contains(body, fixtureIndex) {
|
||||||
|
t.Errorf("GET /health = %d %q, want 200 health JSON", rec.Code, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFrontendRejectsOtherMethods(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
newTestRouter(t, "dev").ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/quests/42", nil))
|
||||||
|
|
||||||
|
if rec.Code != http.StatusMethodNotAllowed {
|
||||||
|
t.Errorf("status = %d, want 405", rec.Code)
|
||||||
|
}
|
||||||
|
if strings.Contains(rec.Body.String(), fixtureIndex) {
|
||||||
|
t.Errorf("body = %q, want no index.html", rec.Body.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user