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.
This commit is contained in:
Reese Norris
2026-07-17 20:26:45 -04:00
parent d9084f866e
commit cd5a5f36c0
4 changed files with 375 additions and 38 deletions

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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: `<script>alert(1)</script>`,
Callsign: `"><script>alert(1)</script>`,
Type: `B738"><img src=x>`,
Status: `parked`,
Instruction: `<b>bad</b>`,
@@ -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, "<script>alert(1)</script>") {
t.Fatal("unescaped callsign script in HTML")
}
if !strings.Contains(body, "&lt;script&gt;") && !strings.Contains(body, "&#34;") {
// html/template escapes < as &lt;
if strings.Contains(body, "<script>alert") {
t.Fatal("executable script present")
}
if strings.Contains(body, "<script>alert") {
t.Fatal("executable script present in table HTML")
}
// Positive assertions: html/template entity-escapes both content and attribute contexts.
if !strings.Contains(body, "&lt;script&gt;") {
t.Fatalf("expected &lt;script&gt; entity escape in body: %s", clip(body, 800))
}
// Callsign is also used in delete form value="…"; quote must be escaped.
if !strings.Contains(body, "&#34;") && !strings.Contains(body, "&quot;") {
t.Fatalf("expected quote entity escape for attribute-safe callsign, body=%s", clip(body, 800))
}
// Hidden callsign input should not contain raw quote breakout.
if strings.Contains(body, `name="callsign" value=""><script>`) {
t.Fatal("unescaped callsign broke out of value attribute")
}
form := url.Values{}
@@ -399,6 +597,89 @@ func TestSweatboxXSSEscapedInFlashAndTable(t *testing.T) {
if strings.Contains(body, `<script>alert("xss")</script>`) {
t.Fatal("unescaped flash script")
}
if !strings.Contains(body, "&lt;script&gt;") {
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) {

View File

@@ -126,7 +126,7 @@
<div class="mb-2">
<label for="sbx-command" class="form-label">Command</label>
<input type="text" class="form-control" id="sbx-command" name="command" required
value="{{ .Command }}" autocomplete="off" spellcheck="false"
value="" autocomplete="off" spellcheck="false"
placeholder="add AAL1 B738 J I KBTV KBOS … or p / un / ops / del AAL1">
</div>
<button type="submit" class="btn btn-primary">Run command</button>
@@ -147,7 +147,7 @@
<div class="mb-2">
<label for="sbx-apt-text" class="form-label">Or paste .apt text</label>
<textarea class="form-control form-control-sm" id="sbx-apt-text" name="apt_text" rows="6"
placeholder="icao=KBTV&#10;…">{{ .AptText }}</textarea>
placeholder="icao=KBTV&#10;…"></textarea>
</div>
<div class="form-check mb-2">
<input class="form-check-input" type="checkbox" name="replace" value="on" id="sbx-apt-replace">
@@ -171,7 +171,7 @@
<div class="mb-2">
<label for="sbx-air-text" class="form-label">Or paste .air text</label>
<textarea class="form-control form-control-sm" id="sbx-air-text" name="air_text" rows="6"
placeholder="AAL123:B738/F:J:I:…">{{ .AirText }}</textarea>
placeholder="AAL123:B738/F:J:I:…"></textarea>
</div>
<button type="submit" class="btn btn-outline-primary">Load scenario</button>
</form>