packages.wenpai.net/internal/http/router_test.go
Ben Word 7cb8fef01b
WP Packages rename (#42)
* Update all import paths

* Rename directory cmd/wpcomposer/ → cmd/wppackages/

* Rename import alias wpcomposergo → wppackagesgo in main.go and migrate_test.go

* Makefile — binary name wpcomposer → wppackages

* Update Air path

* Global replace repo.wp-composer.com → repo.wp-packages.org

* Global replace cdn.wp-composer.com → cdn.wp-packages.org

* Global replace wp-composer.com → wp-packages.org (remaining)

* Composer repo key in templates/docs: repositories.wp-composer → repositories.wp-packages

* Rename columns on the existing schema

* Update all Go code referencing these column names

* Routes & SEO

* Templates & front-end

* Admin UI

* Documentation

* CI/CD

* Config defaults

* Rename role directory

* Rename all systemd template files inside the role

* Update contents of all .j2 templates — service names, binary paths, descriptions

* Update tasks/main.yml and handlers/main.yml in the role

* Update deploy/ansible/roles/app/tasks/main.yml and deploy.yml

* Update deploy/ansible/group_vars/production/main.yml

* Additional renames/fixes

* Additional renames/fixes

* Additional renames/fixes

* not needed
2026-03-19 11:50:12 -05:00

281 lines
7.9 KiB
Go

package http
import (
"context"
"log/slog"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
"github.com/roots/wp-packages/internal/app"
"github.com/roots/wp-packages/internal/auth"
"github.com/roots/wp-packages/internal/config"
"github.com/roots/wp-packages/internal/db"
"github.com/roots/wp-packages/internal/packagist"
)
func newTestApp(t *testing.T) *app.App {
t.Helper()
database, err := db.Open(":memory:")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = database.Close() })
return &app.App{
Config: &config.Config{},
DB: database,
Logger: slog.Default(),
Packagist: packagist.NewDownloadsCache(slog.Default()),
}
}
// TestNewRouter_NoPanic verifies that all ServeMux patterns are valid and
// route registration does not panic.
func TestNewRouter_NoPanic(t *testing.T) {
a := newTestApp(t)
handler := NewRouter(a)
if handler == nil {
t.Fatal("NewRouter returned nil")
}
}
func TestRouter_MethodNotAllowed(t *testing.T) {
a := newTestApp(t)
handler := NewRouter(a)
// POST /health should return 405, not 404
req := httptest.NewRequest("POST", "/health", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusMethodNotAllowed {
t.Errorf("POST /health: got %d, want 405", w.Code)
}
if allow := w.Header().Get("Allow"); allow == "" {
t.Error("POST /health: missing Allow header")
}
}
func TestRouter_NotFound(t *testing.T) {
a := newTestApp(t)
handler := NewRouter(a)
req := httptest.NewRequest("GET", "/nonexistent", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("GET /nonexistent: got %d, want 404", w.Code)
}
}
func TestRouter_HandlerGenerated404PreservesBody(t *testing.T) {
// A registered handler that returns 404 with its own body should not
// have that body replaced by the custom not-found template.
mux := http.NewServeMux()
mux.Handle("GET /pkg/{name}", routeMarker(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "package not found", http.StatusNotFound)
})))
a := newTestApp(t)
tmpl := loadTemplates("")
handler := appHandler(mux, tmpl, a, nil)
req := httptest.NewRequest("GET", "/pkg/nope", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("GET /pkg/nope: got %d, want 404", w.Code)
}
body := w.Body.String()
if body != "package not found\n" {
t.Errorf("handler-generated 404 body was replaced: got %q", body)
}
}
func TestRouter_UnmatchedRouteRendersTemplate(t *testing.T) {
// An unmatched route should render the custom 404 template, not the
// default "404 page not found" text from ServeMux.
mux := http.NewServeMux()
mux.Handle("GET /health", routeMarker(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("ok"))
})))
a := newTestApp(t)
tmpl := loadTemplates("")
handler := appHandler(mux, tmpl, a, nil)
req := httptest.NewRequest("GET", "/nonexistent", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("GET /nonexistent: got %d, want 404", w.Code)
}
body := w.Body.String()
if body == "404 page not found\n" {
t.Error("unmatched route got default ServeMux 404 body instead of custom template")
}
}
func TestTimeoutBypass_AppliesTimeoutToNormalPaths(t *testing.T) {
slow := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(200 * time.Millisecond)
_, _ = w.Write([]byte("ok"))
})
handler := timeoutBypass(slow, 50*time.Millisecond)
req := httptest.NewRequest("GET", "/health", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusServiceUnavailable {
t.Errorf("GET /health (slow): got %d, want 503 (timeout)", w.Code)
}
}
func TestTimeoutBypass_SkipsTimeoutForStreamingPaths(t *testing.T) {
var mu sync.Mutex
var flusherAvailable bool
handler := timeoutBypass(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, ok := w.(http.Flusher)
mu.Lock()
flusherAvailable = ok
mu.Unlock()
_, _ = w.Write([]byte("streaming"))
}), 50*time.Millisecond)
req := httptest.NewRequest("GET", "/admin/logs/stream", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("GET /admin/logs/stream: got %d, want 200", w.Code)
}
mu.Lock()
if !flusherAvailable {
t.Error("GET /admin/logs/stream: http.Flusher not available (timeout handler was not bypassed)")
}
mu.Unlock()
}
func TestStatusRecorder_ForwardsFlusher(t *testing.T) {
inner := httptest.NewRecorder() // implements http.Flusher
rec := &statusRecorder{ResponseWriter: inner, dispatched: true}
// Assert via interface variable to test dynamic dispatch
var w http.ResponseWriter = rec
flusher, ok := w.(http.Flusher)
if !ok {
t.Fatal("statusRecorder does not implement http.Flusher")
}
// Should not panic
flusher.Flush()
}
func TestRouter_SitemapPackagesRoutes(t *testing.T) {
a := newTestApp(t)
handler := NewRouter(a)
// Should not 404 — the route should be matched even though
// it can't be a ServeMux pattern.
req := httptest.NewRequest("GET", "/sitemap-packages-0.xml", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
// 200 (if sitemap data generates) or 500 (no DB tables) are both acceptable;
// 404 means the route wasn't matched.
if w.Code == http.StatusNotFound {
t.Error("GET /sitemap-packages-0.xml returned 404 — route not matched")
}
}
// newTestAppWithAuth creates a test app with users/sessions tables and an
// admin user+session. Returns the app and a valid session cookie value.
func newTestAppWithAuth(t *testing.T) (*app.App, string) {
t.Helper()
database, err := db.Open(":memory:")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = database.Close() })
_, err = database.Exec(`
CREATE TABLE users (
id INTEGER PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
password_hash TEXT NOT NULL,
is_admin INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE sessions (
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
`)
if err != nil {
t.Fatal(err)
}
ctx := context.Background()
user, err := auth.CreateUser(ctx, database, "test@example.com", "Test", "hash", true)
if err != nil {
t.Fatal(err)
}
session, err := auth.CreateSession(ctx, database, user.ID, 60)
if err != nil {
t.Fatal(err)
}
a := &app.App{
Config: &config.Config{Session: config.SessionConfig{LifetimeMinutes: 60}},
DB: database,
Logger: slog.Default(),
Packagist: packagist.NewDownloadsCache(slog.Default()),
}
return a, session
}
// TestRouter_LogStreamBypassesTimeout is an integration test that verifies
// /admin/logs/stream through the full NewRouter handler chain (RealIP →
// Sentry → Recoverer → timeoutBypass → appHandler → mux → StripPrefix →
// SessionAuth → RequireAdmin → handler) gets SSE headers and isn't cut off
// by http.TimeoutHandler.
func TestRouter_LogStreamBypassesTimeout(t *testing.T) {
a, session := newTestAppWithAuth(t)
handler := NewRouter(a)
srv := httptest.NewServer(handler)
defer srv.Close()
client := srv.Client()
client.Timeout = 2 * time.Second
httpReq, _ := http.NewRequest("GET", srv.URL+"/admin/logs/stream?file=wppackages", nil)
httpReq.AddCookie(&http.Cookie{Name: "session", Value: session})
resp, err := client.Do(httpReq)
if err != nil {
// Client timeout is expected since the stream never ends and the log
// file doesn't exist. But we should get a response before that.
t.Fatalf("request failed: %v", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
t.Fatalf("GET /admin/logs/stream: got %d, want 200", resp.StatusCode)
}
if ct := resp.Header.Get("Content-Type"); ct != "text/event-stream" {
t.Errorf("Content-Type: got %q, want text/event-stream", ct)
}
}