From cd5a5f36c082f5db8d39419930cc315e779c6427 Mon Sep 17 00:00:00 2001 From: Reese Norris Date: Fri, 17 Jul 2026 20:26:45 -0400 Subject: [PATCH] fix: address review feedback for sweatbox instructor page Cover scenario/unpause/multipart/error paths; fail closed on scenario JSON parse; cap flash msg length; remove dead sticky form fields; tighten XSS assertions; MaxBytesReader on small form POSTs. --- internal/web/pagemodel.go | 5 - internal/web/pages_sweatbox.go | 75 +++++- internal/web/pages_sweatbox_test.go | 327 +++++++++++++++++++++++++-- internal/web/templates/sweatbox.html | 6 +- 4 files changed, 375 insertions(+), 38 deletions(-) diff --git a/internal/web/pagemodel.go b/internal/web/pagemodel.go index 5ff6404..12e53ef 100644 --- a/internal/web/pagemodel.go +++ b/internal/web/pagemodel.go @@ -142,11 +142,6 @@ type sweatboxPage struct { ArrCount int DepCount int Aircraft []sweatboxAircraftRow - - // Sticky form values (re-render on soft failure) - Command string - AptText string - AirText string } func pageUserFromClaims(claims *auth.CustomClaims) *pageUser { diff --git a/internal/web/pages_sweatbox.go b/internal/web/pages_sweatbox.go index d54e02a..1c4df2d 100644 --- a/internal/web/pages_sweatbox.go +++ b/internal/web/pages_sweatbox.go @@ -20,6 +20,12 @@ import ( // Max body for airport/scenario form payloads (mirrors FSD service HTTP 2 MiB). const sweatboxWebMaxBody = 2 << 20 +// Max body for small sweatbox form POSTs (command/pause/delete — not file uploads). +const sweatboxWebSmallFormMaxBody = 64 << 10 + +// Max freeform flash message length embedded in redirect Location query. +const sweatboxFlashMsgMaxRunes = 240 + // handleFrontendSweatbox GET /sweatbox — server-rendered instructor page. // Works with JS disabled: aircraft table from FSD GET /sweatbox/state + forms. func (s *Server) handleFrontendSweatbox(c *gin.Context) { @@ -41,7 +47,7 @@ func (s *Server) newSweatboxPage(c *gin.Context) sweatboxPage { } func (s *Server) applySweatboxFlash(c *gin.Context, page *sweatboxPage) { - msg := strings.TrimSpace(c.Query("msg")) + msg := truncateRunes(strings.TrimSpace(c.Query("msg")), sweatboxFlashMsgMaxRunes) switch c.Query("flash") { case "ok": if msg == "" { @@ -141,6 +147,8 @@ func (s *Server) populateSweatboxState(c *gin.Context, page *sweatboxPage) { // handleFrontendSweatboxAirport POST /sweatbox/airport func (s *Server) handleFrontendSweatboxAirport(c *gin.Context) { + // Cap before CSRF form parse so oversized bodies fail closed early. + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, sweatboxWebMaxBody+4096) if !s.validateCSRF(c) { c.AbortWithStatus(http.StatusForbidden) return @@ -193,6 +201,8 @@ func (s *Server) handleFrontendSweatboxAirport(c *gin.Context) { // handleFrontendSweatboxScenario POST /sweatbox/scenario func (s *Server) handleFrontendSweatboxScenario(c *gin.Context) { + // Cap before CSRF form parse so oversized bodies fail closed early. + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, sweatboxWebMaxBody+4096) if !s.validateCSRF(c) { c.AbortWithStatus(http.StatusForbidden) return @@ -220,7 +230,10 @@ func (s *Server) handleFrontendSweatboxScenario(c *gin.Context) { switch status { case http.StatusOK: var res server.SweatboxScenarioResponse - _ = json.Unmarshal(respBody, &res) + if err := json.Unmarshal(respBody, &res); err != nil { + s.redirectSweatboxFlash(c, "err", "Unable to parse scenario response") + return + } msg := fmt.Sprintf("Scenario loaded: %d aircraft", res.Loaded) if len(res.Errors) > 0 { msg = fmt.Sprintf("%s (%d warning(s))", msg, len(res.Errors)) @@ -241,6 +254,9 @@ func (s *Server) handleFrontendSweatboxScenario(c *gin.Context) { // handleFrontendSweatboxCommand POST /sweatbox/command func (s *Server) handleFrontendSweatboxCommand(c *gin.Context) { + if !s.limitSweatboxSmallForm(c) { + return + } if !s.validateCSRF(c) { c.AbortWithStatus(http.StatusForbidden) return @@ -300,6 +316,9 @@ func (s *Server) handleFrontendSweatboxCommand(c *gin.Context) { // handleFrontendSweatboxPause POST /sweatbox/pause func (s *Server) handleFrontendSweatboxPause(c *gin.Context) { + if !s.limitSweatboxSmallForm(c) { + return + } if !s.validateCSRF(c) { c.AbortWithStatus(http.StatusForbidden) return @@ -321,6 +340,9 @@ func (s *Server) handleFrontendSweatboxPause(c *gin.Context) { // handleFrontendSweatboxUnpause POST /sweatbox/unpause func (s *Server) handleFrontendSweatboxUnpause(c *gin.Context) { + if !s.limitSweatboxSmallForm(c) { + return + } if !s.validateCSRF(c) { c.AbortWithStatus(http.StatusForbidden) return @@ -342,6 +364,9 @@ func (s *Server) handleFrontendSweatboxUnpause(c *gin.Context) { // handleFrontendSweatboxDelete POST /sweatbox/delete func (s *Server) handleFrontendSweatboxDelete(c *gin.Context) { + if !s.limitSweatboxSmallForm(c) { + return + } if !s.validateCSRF(c) { c.AbortWithStatus(http.StatusForbidden) return @@ -372,6 +397,9 @@ func (s *Server) handleFrontendSweatboxDelete(c *gin.Context) { // handleFrontendSweatboxDeleteAll POST /sweatbox/delete-all func (s *Server) handleFrontendSweatboxDeleteAll(c *gin.Context) { + if !s.limitSweatboxSmallForm(c) { + return + } if !s.validateCSRF(c) { c.AbortWithStatus(http.StatusForbidden) return @@ -396,6 +424,21 @@ func (s *Server) handleFrontendSweatboxDeleteAll(c *gin.Context) { } } +// limitSweatboxSmallForm caps POST body for non-upload sweatbox forms. +// Returns false when the body is too large (flash + redirect already issued). +func (s *Server) limitSweatboxSmallForm(c *gin.Context) bool { + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, sweatboxWebSmallFormMaxBody) + // Force parse so MaxBytesReader surfaces early for form-urlencoded. + if err := c.Request.ParseForm(); err != nil { + if isRequestTooLarge(err) { + s.redirectSweatboxFlash(c, "err", "Request body too large") + return false + } + // Leave form empty on other parse errors; handlers validate required fields. + } + return true +} + // --------------------------------------------------------------------------- // FSD service HTTP proxy helpers // --------------------------------------------------------------------------- @@ -428,16 +471,34 @@ func (s *Server) fsdSweatboxDo(method, path, contentType string, body io.Reader) func (s *Server) redirectSweatboxFlash(c *gin.Context, flash, msg string) { u := "/sweatbox?flash=" + url.QueryEscape(flash) if msg != "" { - u += "&msg=" + url.QueryEscape(msg) + u += "&msg=" + url.QueryEscape(truncateRunes(msg, sweatboxFlashMsgMaxRunes)) } c.Redirect(http.StatusSeeOther, u) } -// readSweatboxFormPayload prefers an uploaded file, else named text fields. -func readSweatboxFormPayload(c *gin.Context, fileField string, textFields ...string) ([]byte, error) { - // Cap body before parsing multipart/urlencoded. - c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, sweatboxWebMaxBody+4096) +// truncateRunes shortens s to at most max runes, appending "…" when truncated. +func truncateRunes(s string, max int) string { + if max <= 0 || s == "" { + return "" + } + if max == 1 { + // Single-rune budget: prefer ellipsis over a partial character. + r := []rune(s) + if len(r) <= 1 { + return s + } + return "…" + } + r := []rune(s) + if len(r) <= max { + return s + } + return string(r[:max-1]) + "…" +} +// readSweatboxFormPayload prefers an uploaded file, else named text fields. +// Callers should already wrap the request body with MaxBytesReader. +func readSweatboxFormPayload(c *gin.Context, fileField string, textFields ...string) ([]byte, error) { ct := c.ContentType() if strings.HasPrefix(ct, "multipart/form-data") { if err := c.Request.ParseMultipartForm(sweatboxWebMaxBody); err != nil { diff --git a/internal/web/pages_sweatbox_test.go b/internal/web/pages_sweatbox_test.go index 00b6f56..583e1ec 100644 --- a/internal/web/pages_sweatbox_test.go +++ b/internal/web/pages_sweatbox_test.go @@ -1,13 +1,17 @@ package web import ( + "bytes" "encoding/json" + "fmt" "io" + "mime/multipart" "net/http" "net/http/httptest" "net/url" "strings" "testing" + "unicode/utf8" "github.com/renorris/openfsd/internal/server" "github.com/renorris/openfsd/pkg/protocol" @@ -146,11 +150,19 @@ func TestSweatboxCommandEmptyRedirectsErrorFlash(t *testing.T) { } } -func TestSweatboxWithMockFSDStateAndForms(t *testing.T) { - // Mock FSD service HTTP sweatbox surface. - var lastCommand server.SweatboxCommandRequest - var airportBody string - var paused bool +// sweatboxMock tracks FSD service HTTP interactions for instructor UI tests. +type sweatboxMock struct { + paused bool + lastCommand server.SweatboxCommandRequest + airportBody string + airportPath string + scenarioBody string + commandSoftFail bool + airportConflict bool + scenarioBadJSON bool +} + +func (m *sweatboxMock) handler() http.Handler { mux := http.NewServeMux() mux.HandleFunc("/sweatbox/state", func(w http.ResponseWriter, r *http.Request) { if r.Header.Get("Authorization") == "" { @@ -159,7 +171,7 @@ func TestSweatboxWithMockFSDStateAndForms(t *testing.T) { } st := server.SweatboxStateJSON{ ICAO: "KBTV", - Paused: paused, + Paused: m.paused, Elapsed: 65, ArrCount: 1, DepCount: 2, @@ -180,20 +192,51 @@ func TestSweatboxWithMockFSDStateAndForms(t *testing.T) { _ = json.NewEncoder(w).Encode(st) }) mux.HandleFunc("/sweatbox/airport", func(w http.ResponseWriter, r *http.Request) { + m.airportPath = r.URL.RequestURI() b, _ := io.ReadAll(r.Body) - airportBody = string(b) + m.airportBody = string(b) + if m.airportConflict { + w.WriteHeader(http.StatusConflict) + _ = json.NewEncoder(w).Encode(map[string]any{ + "errors": []string{"aircraft are present; use replace"}, + }) + return + } _ = json.NewEncoder(w).Encode(map[string]any{"icao": "KBTV", "surfaces": 3, "errors": []string{}}) }) + mux.HandleFunc("/sweatbox/scenario", func(w http.ResponseWriter, r *http.Request) { + b, _ := io.ReadAll(r.Body) + m.scenarioBody = string(b) + if m.scenarioBadJSON { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("not-json")) + return + } + _ = json.NewEncoder(w).Encode(server.SweatboxScenarioResponse{ + Loaded: 3, + Errors: []string{"line 9: skipped"}, + }) + }) mux.HandleFunc("/sweatbox/command", func(w http.ResponseWriter, r *http.Request) { - _ = json.NewDecoder(r.Body).Decode(&lastCommand) - _ = json.NewEncoder(w).Encode(server.SweatboxCommandResponse{OK: true, Message: "ok: " + lastCommand.Command}) + _ = json.NewDecoder(r.Body).Decode(&m.lastCommand) + if m.commandSoftFail { + _ = json.NewEncoder(w).Encode(server.SweatboxCommandResponse{ + OK: false, + Message: "Unknown command: xyz", + }) + return + } + _ = json.NewEncoder(w).Encode(server.SweatboxCommandResponse{ + OK: true, + Message: "ok: " + m.lastCommand.Command, + }) }) mux.HandleFunc("/sweatbox/pause", func(w http.ResponseWriter, r *http.Request) { - paused = true + m.paused = true w.WriteHeader(http.StatusNoContent) }) mux.HandleFunc("/sweatbox/unpause", func(w http.ResponseWriter, r *http.Request) { - paused = false + m.paused = false w.WriteHeader(http.StatusNoContent) }) mux.HandleFunc("/sweatbox/aircraft/", func(w http.ResponseWriter, r *http.Request) { @@ -210,7 +253,12 @@ func TestSweatboxWithMockFSDStateAndForms(t *testing.T) { } w.WriteHeader(http.StatusMethodNotAllowed) }) - fsd := httptest.NewServer(mux) + return mux +} + +func TestSweatboxWithMockFSDStateAndForms(t *testing.T) { + m := &sweatboxMock{} + fsd := httptest.NewServer(m.handler()) t.Cleanup(fsd.Close) ts := newTestServer(t) @@ -256,8 +304,8 @@ func TestSweatboxWithMockFSDStateAndForms(t *testing.T) { if !strings.Contains(loc, "flash=ok") { t.Fatalf("Location=%q", loc) } - if lastCommand.Command != "ops" || lastCommand.Callsign != "AAL123" { - t.Fatalf("lastCommand=%+v", lastCommand) + if m.lastCommand.Command != "ops" || m.lastCommand.Callsign != "AAL123" { + t.Fatalf("lastCommand=%+v", m.lastCommand) } w, cookies = authedGET(t, ts, loc, cookies) if !strings.Contains(w.Body.String(), "ok: ops") { @@ -274,8 +322,41 @@ func TestSweatboxWithMockFSDStateAndForms(t *testing.T) { if !strings.Contains(w.Header().Get("Location"), "flash=airport_ok") { t.Fatalf("Location=%q", w.Header().Get("Location")) } - if !strings.Contains(airportBody, "icao=KBTV") { - t.Fatalf("airportBody=%q", airportBody) + if !strings.Contains(m.airportBody, "icao=KBTV") { + t.Fatalf("airportBody=%q", m.airportBody) + } + + // Airport with replace=1 proxy query + form = url.Values{} + form.Set("apt_text", "icao=KBTV\n") + form.Set("replace", "on") + w, cookies = formPOST(t, ts, "/sweatbox/airport", form, cookies) + if w.Code != http.StatusSeeOther { + t.Fatalf("airport replace status %d", w.Code) + } + if !strings.Contains(m.airportPath, "replace=1") { + t.Fatalf("airportPath=%q want ?replace=1", m.airportPath) + } + + // Scenario paste + form = url.Values{} + form.Set("air_text", "AAL123:B738/F:J:I:KBTV:KBOS:29000:DCT:rmk:2200:S:44.4:-73.1:335:0:360\n") + w, cookies = formPOST(t, ts, "/sweatbox/scenario", form, cookies) + if w.Code != http.StatusSeeOther { + t.Fatalf("scenario status %d", w.Code) + } + if !strings.Contains(w.Header().Get("Location"), "flash=scenario_ok") { + t.Fatalf("Location=%q", w.Header().Get("Location")) + } + if !strings.Contains(m.scenarioBody, "AAL123") { + t.Fatalf("scenarioBody=%q", m.scenarioBody) + } + w, cookies = authedGET(t, ts, w.Header().Get("Location"), cookies) + if !strings.Contains(w.Body.String(), "Scenario loaded: 3 aircraft") { + t.Fatalf("expected scenario flash, body=%s", clip(w.Body.String(), 400)) + } + if !strings.Contains(w.Body.String(), "1 warning") { + t.Fatalf("expected warning count in flash, body=%s", clip(w.Body.String(), 400)) } // Pause @@ -287,10 +368,23 @@ func TestSweatboxWithMockFSDStateAndForms(t *testing.T) { if !strings.Contains(w.Header().Get("Location"), "flash=paused") { t.Fatalf("Location=%q", w.Header().Get("Location")) } - if !paused { + if !m.paused { t.Fatal("expected mock paused") } + // Unpause + form = url.Values{} + w, cookies = formPOST(t, ts, "/sweatbox/unpause", form, cookies) + if w.Code != http.StatusSeeOther { + t.Fatalf("unpause status %d", w.Code) + } + if !strings.Contains(w.Header().Get("Location"), "flash=unpaused") { + t.Fatalf("Location=%q", w.Header().Get("Location")) + } + if m.paused { + t.Fatal("expected mock unpaused") + } + // Delete aircraft form = url.Values{} form.Set("callsign", "AAL123") @@ -324,6 +418,99 @@ func TestSweatboxWithMockFSDStateAndForms(t *testing.T) { } } +func TestSweatboxMultipartFileUploadAndErrors(t *testing.T) { + m := &sweatboxMock{} + fsd := httptest.NewServer(m.handler()) + t.Cleanup(fsd.Close) + + ts := newTestServer(t) + ts.cfg.FsdHttpServiceAddress = fsd.URL + admin := createTestUser(t, ts, "pw", int(protocol.NetworkRatingAdministator)) + cookies := formLogin(t, ts, admin.CID, "pw") + // Ensure CSRF cookie + _, cookies = authedGET(t, ts, "/sweatbox", cookies) + + // Multipart airport file upload + var buf bytes.Buffer + mw := multipart.NewWriter(&buf) + _ = mw.WriteField("csrf_token", csrfFromCookies(cookies)) + fw, err := mw.CreateFormFile("file", "KBTV.apt") + if err != nil { + t.Fatal(err) + } + if _, err := fw.Write([]byte("icao=KBTV\nmagnetic variation=16\n")); err != nil { + t.Fatal(err) + } + if err := mw.Close(); err != nil { + t.Fatal(err) + } + req := httptest.NewRequest(http.MethodPost, "/sweatbox/airport", &buf) + req.Header.Set("Content-Type", mw.FormDataContentType()) + req.Header.Set("Cookie", cookieHeader(cookies)) + w := httptest.NewRecorder() + ts.engine.ServeHTTP(w, req) + if w.Code != http.StatusSeeOther { + t.Fatalf("multipart airport status %d body %s", w.Code, w.Body.String()) + } + if !strings.Contains(w.Header().Get("Location"), "flash=airport_ok") { + t.Fatalf("Location=%q", w.Header().Get("Location")) + } + if !strings.Contains(m.airportBody, "icao=KBTV") { + t.Fatalf("airportBody from multipart=%q", m.airportBody) + } + cookies = mergeCookies(cookies, w.Result()) + + // Soft-fail command + m.commandSoftFail = true + form := url.Values{} + form.Set("command", "xyz") + w, cookies = formPOST(t, ts, "/sweatbox/command", form, cookies) + if w.Code != http.StatusSeeOther { + t.Fatalf("soft-fail command status %d", w.Code) + } + loc := w.Header().Get("Location") + if !strings.Contains(loc, "flash=err") { + t.Fatalf("Location=%q want flash=err", loc) + } + w, cookies = authedGET(t, ts, loc, cookies) + if !strings.Contains(w.Body.String(), "Unknown command: xyz") { + t.Fatalf("expected soft-fail message, body=%s", clip(w.Body.String(), 400)) + } + + // Airport conflict surfaces firstJSONError from errors[] + m.airportConflict = true + form = url.Values{} + form.Set("apt_text", "icao=KBTV\n") + w, cookies = formPOST(t, ts, "/sweatbox/airport", form, cookies) + if w.Code != http.StatusSeeOther { + t.Fatalf("conflict airport status %d", w.Code) + } + loc = w.Header().Get("Location") + if !strings.Contains(loc, "flash=err") { + t.Fatalf("Location=%q", loc) + } + w, cookies = authedGET(t, ts, loc, cookies) + if !strings.Contains(w.Body.String(), "aircraft are present") { + t.Fatalf("expected conflict error flash, body=%s", clip(w.Body.String(), 400)) + } + + // Scenario bad JSON → parse error flash + m.scenarioBadJSON = true + form = url.Values{} + form.Set("air_text", "AAL1:B738:J:I:KBTV:KBOS:100:DCT::2200:S:0:0:0:0:0\n") + w, cookies = formPOST(t, ts, "/sweatbox/scenario", form, cookies) + if w.Code != http.StatusSeeOther { + t.Fatalf("bad scenario JSON status %d", w.Code) + } + if !strings.Contains(w.Header().Get("Location"), "flash=err") { + t.Fatalf("Location=%q want flash=err", w.Header().Get("Location")) + } + w, _ = authedGET(t, ts, w.Header().Get("Location"), cookies) + if !strings.Contains(w.Body.String(), "Unable to parse scenario response") { + t.Fatalf("expected parse error, body=%s", clip(w.Body.String(), 400)) + } +} + func TestSweatboxDisabledMessagingWhenFSD404(t *testing.T) { mux := http.NewServeMux() // No /sweatbox routes → 404 @@ -356,7 +543,7 @@ func TestSweatboxXSSEscapedInFlashAndTable(t *testing.T) { ICAO: "KBTV", Aircraft: []server.SweatboxAircraftJSON{ { - Callsign: ``, + Callsign: `">`, Type: `B738">`, Status: `parked`, Instruction: `bad`, @@ -381,14 +568,25 @@ func TestSweatboxXSSEscapedInFlashAndTable(t *testing.T) { w, cookies := authedGET(t, ts, "/sweatbox", cookies) body := w.Body.String() + + // No raw executable script tags. if strings.Contains(body, "") { t.Fatal("unescaped callsign script in HTML") } - if !strings.Contains(body, "<script>") && !strings.Contains(body, """) { - // html/template escapes < as < - if strings.Contains(body, "`) { t.Fatal("unescaped flash script") } + if !strings.Contains(body, "<script>") { + t.Fatalf("expected escaped flash script entities, body=%s", clip(body, 500)) + } +} + +func TestSweatboxFlashMsgTruncated(t *testing.T) { + // Unit-level: truncateRunes keeps Location headers bounded. + long := strings.Repeat("a", sweatboxFlashMsgMaxRunes+50) + got := truncateRunes(long, sweatboxFlashMsgMaxRunes) + if utf8.RuneCountInString(got) != sweatboxFlashMsgMaxRunes { + t.Fatalf("rune count %d want %d", utf8.RuneCountInString(got), sweatboxFlashMsgMaxRunes) + } + if !strings.HasSuffix(got, "…") { + t.Fatalf("want ellipsis suffix, got %q", got[len(got)-3:]) + } + + // Integration: oversized FSD command message is truncated in redirect. + mux := http.NewServeMux() + mux.HandleFunc("/sweatbox/state", func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(server.SweatboxStateJSON{ICAO: "KBTV", Aircraft: []server.SweatboxAircraftJSON{}}) + }) + mux.HandleFunc("/sweatbox/command", func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(server.SweatboxCommandResponse{ + OK: true, + Message: strings.Repeat("x", 500), + }) + }) + fsd := httptest.NewServer(mux) + t.Cleanup(fsd.Close) + + ts := newTestServer(t) + ts.cfg.FsdHttpServiceAddress = fsd.URL + admin := createTestUser(t, ts, "pw", int(protocol.NetworkRatingAdministator)) + cookies := formLogin(t, ts, admin.CID, "pw") + + form := url.Values{} + form.Set("command", "ops") + w, _ := formPOST(t, ts, "/sweatbox/command", form, cookies) + loc := w.Header().Get("Location") + u, err := url.Parse(loc) + if err != nil { + t.Fatal(err) + } + msg := u.Query().Get("msg") + if utf8.RuneCountInString(msg) > sweatboxFlashMsgMaxRunes { + t.Fatalf("msg runes %d > max %d", utf8.RuneCountInString(msg), sweatboxFlashMsgMaxRunes) + } + if len(loc) > 2048 { + t.Fatalf("Location header too long: %d", len(loc)) + } +} + +func TestFirstJSONError(t *testing.T) { + if got := firstJSONError([]byte(`{"message":" hello "}`), "fb"); got != "hello" { + t.Fatalf("message field: %q", got) + } + if got := firstJSONError([]byte(`{"errors":["e1","e2"]}`), "fb"); got != "e1" { + t.Fatalf("errors[0]: %q", got) + } + if got := firstJSONError([]byte(`{"error":"boom"}`), "fb"); got != "boom" { + t.Fatalf("error field: %q", got) + } + if got := firstJSONError([]byte(`not-json`), "fb"); got != "fb" { + t.Fatalf("fallback: %q", got) + } + if got := firstJSONError([]byte(`{}`), "fb"); got != "fb" { + t.Fatalf("empty object fallback: %q", got) + } +} + +func TestIsRequestTooLarge(t *testing.T) { + if !isRequestTooLarge(&http.MaxBytesError{Limit: 10}) { + t.Fatal("MaxBytesError should match") + } + if !isRequestTooLarge(fmt.Errorf("wrap: %w", &http.MaxBytesError{Limit: 1})) { + t.Fatal("wrapped MaxBytesError should match") + } + if isRequestTooLarge(fmt.Errorf("other")) { + t.Fatal("unrelated error should not match") + } + if isRequestTooLarge(nil) { + t.Fatal("nil should not match") + } } func TestFormatSweatboxElapsed(t *testing.T) { diff --git a/internal/web/templates/sweatbox.html b/internal/web/templates/sweatbox.html index 30b921d..e5a6f80 100644 --- a/internal/web/templates/sweatbox.html +++ b/internal/web/templates/sweatbox.html @@ -126,7 +126,7 @@
@@ -147,7 +147,7 @@
+ placeholder="icao=KBTV …">
@@ -171,7 +171,7 @@
+ placeholder="AAL123:B738/F:J:I:…">