From eaa2399070d604963cbc9523471430ba0d7ba9ce Mon Sep 17 00:00:00 2001 From: Reese Norris Date: Tue, 28 Jul 2026 11:46:33 -0400 Subject: [PATCH 1/2] web: /api/v1/sweatbox mutations + GET /session (enveloped) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement design §D mapping table: JSON-in mutations with FSD text/plain for airport/scenario; soft-fail command stays 200+ok:false; FSD 204 maps to public 200 envelope. Parallel GET /session; raw /state and /ops unchanged. Stable contract with goldens, OpenAPI, and README. Instructor1+ dual-accept. --- internal/serviceapi/sweatbox.go | 8 + internal/web/README.md | 28 +- internal/web/api_sweatbox.go | 457 +++++++++++++++++- internal/web/api_sweatbox_test.go | 303 ++++++++++++ internal/web/openapi/openapi.v1.yaml | 290 ++++++++++- internal/web/pages_sweatbox_test.go | 20 +- internal/web/routes.go | 20 +- .../2026-07-28/sweatbox_airport_ok.json | 9 + .../2026-07-28/sweatbox_command_ok.json | 8 + .../2026-07-28/sweatbox_command_softfail.json | 8 + .../2026-07-28/sweatbox_delete_all_ok.json | 5 + .../api_v1/2026-07-28/sweatbox_delete_ok.json | 5 + .../api_v1/2026-07-28/sweatbox_pause_ok.json | 5 + .../2026-07-28/sweatbox_scenario_ok.json | 8 + .../2026-07-28/sweatbox_session_ok.json | 33 ++ .../2026-07-28/sweatbox_unpause_ok.json | 5 + 16 files changed, 1182 insertions(+), 30 deletions(-) create mode 100644 internal/web/testdata/api_v1/2026-07-28/sweatbox_airport_ok.json create mode 100644 internal/web/testdata/api_v1/2026-07-28/sweatbox_command_ok.json create mode 100644 internal/web/testdata/api_v1/2026-07-28/sweatbox_command_softfail.json create mode 100644 internal/web/testdata/api_v1/2026-07-28/sweatbox_delete_all_ok.json create mode 100644 internal/web/testdata/api_v1/2026-07-28/sweatbox_delete_ok.json create mode 100644 internal/web/testdata/api_v1/2026-07-28/sweatbox_pause_ok.json create mode 100644 internal/web/testdata/api_v1/2026-07-28/sweatbox_scenario_ok.json create mode 100644 internal/web/testdata/api_v1/2026-07-28/sweatbox_session_ok.json create mode 100644 internal/web/testdata/api_v1/2026-07-28/sweatbox_unpause_ok.json diff --git a/internal/serviceapi/sweatbox.go b/internal/serviceapi/sweatbox.go index 06ca7f0..88819da 100644 --- a/internal/serviceapi/sweatbox.go +++ b/internal/serviceapi/sweatbox.go @@ -21,6 +21,14 @@ type SweatboxScenarioResponse struct { Errors []string `json:"errors"` } +// SweatboxAirportLoadResponse is the public/FSD airport load result shape +// (POST /api/v1/sweatbox/airport envelope data and FSD POST /sweatbox/airport body). +type SweatboxAirportLoadResponse struct { + ICAO string `json:"icao"` + Surfaces int `json:"surfaces"` + Errors []string `json:"errors"` +} + // SweatboxOpsJSON is returned for GET /sweatbox/ops. type SweatboxOpsJSON struct { ElapsedSec float64 `json:"elapsed_sec"` diff --git a/internal/web/README.md b/internal/web/README.md index 6c78255..50b6d9c 100644 --- a/internal/web/README.md +++ b/internal/web/README.md @@ -16,7 +16,7 @@ JSON under `/api/v1` for **operator automation** and map polling. First-party UI | Account | `GET /account`, `POST /account/password`, `POST /account/delete` | any session; change password (current required) + soft-delete account (optional hard-delete via `ALLOW_PERMANENT_ACCOUNT_DELETE`, default false). CSRF; password step-up on delete | | Users (directory) | `GET /usereditor[?q&rating&sort&dir&page&cid&new&flash]`, `POST /usereditor/create`, `POST /usereditor/update` | **Supervisor+**; create + name/password + ratings (network ceiling ≤ actor; full pilot scale). CSRF; URL-owned filters; `dir_*` on POST for PRG | | Config editor | `GET/POST /configeditor`, `POST /configeditor/create-token`, `POST /configeditor/reset-secret` | Administrator; CSRF on mutations | -| Sweatbox | `GET /sweatbox`, form POSTs under `/sweatbox/*` | **Instructor1+**; CSRF on mutations; proxies FSD service HTTP | +| Sweatbox | `GET /sweatbox`, form POSTs under `/sweatbox/*` | **Instructor1+**; CSRF on mutations; proxies FSD service HTTP. Operator JSON: `/api/v1/sweatbox/*` (mutations + `/session`) | | Airport editor | `GET /airport-editor`, `POST /airport-editor/download-apt`, `POST /airport-editor/download-air` | **Instructor1+**; CSRF on download; **echo-only** (no disk/DB persistence of `.apt`/`.air`). Validate API: `POST /api/v1/editor/validate-*` also I1+ | JSON under `/api/v1` remains for external consumers and map polling. Session dual-accept mutations work with **cookie + CSRF only** (no `Authorization` header required). @@ -103,10 +103,31 @@ Canonical OpenAPI file: `internal/web/openapi/openapi.v1.yaml` (`//go:embed`). N **Outside microversion reject:** `/api/v1/data/*`, `/api/v1/fsd-jwt`, auth login/refresh, discovery, OpenAPI. Resource groups (`/user`, `/config`, `/fsdconn`, `/sweatbox`, `/editor`) reject unknown/invalid pins with **400** envelope. -**Stability tiers:** existing enveloped user/config/fsdconn/editor routes are **Stable** (goldens under `testdata/api_v1//`). New expansion routes may ship **Provisional** (shape may change without a microversion bump while Provisional). Design: `docs/design/rest-api-versioning.md`. +**Stability tiers:** existing enveloped user/config/fsdconn/editor routes and sweatbox mutations/`session` are **Stable** (goldens under `testdata/api_v1//`). New expansion routes may ship **Provisional** (shape may change without a microversion bump while Provisional). Design: `docs/design/rest-api-versioning.md`. Baseline pin (first supported): **`2026-07-28`**. +### Sweatbox operator JSON (Stable) + +Instructor1+ dual-accept (Bearer API token recommended for automation; cookie + CSRF for browser). Proxies FSD service HTTP only — web never imports `internal/sweatbox` / `internal/server`. + +| Method | Path | Notes | +|--------|------|-------| +| GET | `/api/v1/sweatbox/state` | **Raw** FSD body (PE poll; non-envelope) | +| GET | `/api/v1/sweatbox/ops` | **Raw** FSD body (PE; non-envelope) | +| GET | `/api/v1/sweatbox/session` | **Envelope**; `data` = state snapshot | +| POST | `/api/v1/sweatbox/airport` | JSON `{"text":"…","replace":false}` → FSD `text/plain` + optional `?replace=1` | +| POST | `/api/v1/sweatbox/scenario` | JSON `{"text":"…"}` → FSD `text/plain` | +| POST | `/api/v1/sweatbox/command` | JSON `{callsign,command}`; soft-fail stays **200** + `data.ok=false` | +| POST | `/api/v1/sweatbox/pause` | FSD 204 → public **200** envelope `data: null` | +| POST | `/api/v1/sweatbox/unpause` | same | +| DELETE | `/api/v1/sweatbox/aircraft/:callsign` | FSD 204 → public **200** | +| DELETE | `/api/v1/sweatbox/aircraft` | requires `confirm=true` (JSON body or query `confirm=1`) | + +Max body for airport/scenario: **2 MiB**. Status mapping is locked in `docs/design/rest-api-versioning.md` §D (404 disabled, 409 conflict, 413 too large, 502 unreachable). + +**Blast radius:** admin-minted API tokens are Administrator-equivalent until scopes exist. Prefer short TTLs; store tokens as secrets. + --- ## Network Ratings @@ -646,6 +667,9 @@ Retrieve all servers in JSON format (same as openfsd-servers.json). --- +#### GET /api/v1/sweatbox/session +Instructor1+ enveloped state snapshot (`data` = same fields as raw `/state`). Prefer this over raw `/state` for third-party clients. See **Sweatbox operator JSON** above for mutations and status mapping. + #### GET /api/v1/data/openfsd-data.json Retrieve cached datafeed of online pilots and ATC. diff --git a/internal/web/api_sweatbox.go b/internal/web/api_sweatbox.go index 722d1c2..956f4f2 100644 --- a/internal/web/api_sweatbox.go +++ b/internal/web/api_sweatbox.go @@ -1,12 +1,57 @@ package web import ( + "bytes" + "encoding/json" + "io" + "log/slog" "net/http" + "net/url" + "strings" "github.com/gin-gonic/gin" + "github.com/renorris/openfsd/internal/auth" + "github.com/renorris/openfsd/internal/serviceapi" "github.com/renorris/openfsd/pkg/protocol" ) +// Public JSON request bodies for sweatbox mutations (design §D). +// Airport/scenario are JSON on the public API; FSD receives text/plain of the text field. + +type sweatboxAirportJSONRequest struct { + Text string `json:"text"` + Replace bool `json:"replace"` +} + +type sweatboxScenarioJSONRequest struct { + Text string `json:"text"` +} + +type sweatboxDeleteAllJSONRequest struct { + Confirm bool `json:"confirm"` +} + +// requireSweatboxI1 returns claims when the actor is Instructor1+; otherwise writes 403 and ok=false. +func requireSweatboxI1(c *gin.Context) (claims *auth.CustomClaims, ok bool) { + claims = getJwtContext(c) + if claims == nil || claims.NetworkRating < protocol.NetworkRatingInstructor1 { + writeAPIV1Response(c, http.StatusForbidden, &genericAPIV1Forbidden) + return nil, false + } + return claims, true +} + +// writeSweatboxProxyErr maps transport / unexpected FSD statuses to enveloped 502. +func writeSweatboxProxyErr(c *gin.Context, msg string) { + res := newAPIV1Failure(msg) + writeAPIV1Response(c, http.StatusBadGateway, &res) +} + +func writeSweatboxDisabled(c *gin.Context) { + res := newAPIV1Failure("Sweatbox is not enabled on the FSD server") + writeAPIV1Response(c, http.StatusNotFound, &res) +} + // handleAPISweatboxState GET /api/v1/sweatbox/state // // Authenticated read proxy of FSD service HTTP GET /sweatbox/state. @@ -16,16 +61,13 @@ import ( // On success (and FSD 404 disabled), the FSD JSON body is passed through so // the PE client can use the same shape as the service control plane. func (s *Server) handleAPISweatboxState(c *gin.Context) { - claims := getJwtContext(c) - if claims == nil || claims.NetworkRating < protocol.NetworkRatingInstructor1 { - writeAPIV1Response(c, http.StatusForbidden, &genericAPIV1Forbidden) + if _, ok := requireSweatboxI1(c); !ok { return } status, body, err := s.fsdSweatboxDo(http.MethodGet, "/sweatbox/state", "", nil) if err != nil { - res := newAPIV1Failure("FSD HTTP service unreachable") - writeAPIV1Response(c, http.StatusBadGateway, &res) + writeSweatboxProxyErr(c, "FSD HTTP service unreachable") return } @@ -34,11 +76,9 @@ func (s *Server) handleAPISweatboxState(c *gin.Context) { // 404 = sweatbox disabled on FSD (no /sweatbox/* routes). c.Data(status, "application/json", body) case http.StatusUnauthorized, http.StatusForbidden: - res := newAPIV1Failure("FSD service rejected the request") - writeAPIV1Response(c, http.StatusBadGateway, &res) + writeSweatboxProxyErr(c, "FSD service rejected the request") default: - res := newAPIV1Failure("FSD service returned unexpected status") - writeAPIV1Response(c, http.StatusBadGateway, &res) + writeSweatboxProxyErr(c, "FSD service returned unexpected status") } } @@ -48,16 +88,13 @@ func (s *Server) handleAPISweatboxState(c *gin.Context) { // Optional for PE; state already includes most of these fields. // Min rating Instructor1+. func (s *Server) handleAPISweatboxOps(c *gin.Context) { - claims := getJwtContext(c) - if claims == nil || claims.NetworkRating < protocol.NetworkRatingInstructor1 { - writeAPIV1Response(c, http.StatusForbidden, &genericAPIV1Forbidden) + if _, ok := requireSweatboxI1(c); !ok { return } status, body, err := s.fsdSweatboxDo(http.MethodGet, "/sweatbox/ops", "", nil) if err != nil { - res := newAPIV1Failure("FSD HTTP service unreachable") - writeAPIV1Response(c, http.StatusBadGateway, &res) + writeSweatboxProxyErr(c, "FSD HTTP service unreachable") return } @@ -65,7 +102,395 @@ func (s *Server) handleAPISweatboxOps(c *gin.Context) { case http.StatusOK, http.StatusNotFound: c.Data(status, "application/json", body) default: - res := newAPIV1Failure("FSD service returned unexpected status") - writeAPIV1Response(c, http.StatusBadGateway, &res) + writeSweatboxProxyErr(c, "FSD service returned unexpected status") + } +} + +// handleAPISweatboxSession GET /api/v1/sweatbox/session +// +// Enveloped parallel of GET /state for third-party / operator clients. +// data = serviceapi.SweatboxStateJSON. Raw /state remains for PE. +func (s *Server) handleAPISweatboxSession(c *gin.Context) { + if _, ok := requireSweatboxI1(c); !ok { + return + } + + status, body, err := s.fsdSweatboxDo(http.MethodGet, "/sweatbox/state", "", nil) + if err != nil { + writeSweatboxProxyErr(c, "FSD HTTP service unreachable") + return + } + switch status { + case http.StatusOK: + var st serviceapi.SweatboxStateJSON + if err := json.Unmarshal(body, &st); err != nil { + writeSweatboxProxyErr(c, "FSD service returned unexpected status") + return + } + if st.Aircraft == nil { + st.Aircraft = []serviceapi.SweatboxAircraftJSON{} + } + res := newAPIV1Success(&st) + writeAPIV1Response(c, http.StatusOK, &res) + case http.StatusNotFound: + writeSweatboxDisabled(c) + case http.StatusUnauthorized, http.StatusForbidden: + writeSweatboxProxyErr(c, "FSD service rejected the request") + default: + writeSweatboxProxyErr(c, "FSD service returned unexpected status") + } +} + +// handleAPISweatboxAirport POST /api/v1/sweatbox/airport +// +// JSON {"text","replace"} → FSD POST /sweatbox/airport[?replace=1] text/plain. +// Status mapping per design §D. +func (s *Server) handleAPISweatboxAirport(c *gin.Context) { + claims, ok := requireSweatboxI1(c) + if !ok { + return + } + + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, sweatboxWebMaxBody) + var req sweatboxAirportJSONRequest + if err := c.ShouldBindJSON(&req); err != nil { + if isRequestTooLarge(err) { + res := newAPIV1Failure("Airport payload too large (max 2 MiB)") + writeAPIV1Response(c, http.StatusRequestEntityTooLarge, &res) + return + } + res := newAPIV1Failure("invalid JSON body") + writeAPIV1Response(c, http.StatusBadRequest, &res) + return + } + text := req.Text + if len(bytes.TrimSpace([]byte(text))) == 0 { + res := newAPIV1Failure("Airport file or text is required") + writeAPIV1Response(c, http.StatusBadRequest, &res) + return + } + if len(text) > sweatboxWebMaxBody { + res := newAPIV1Failure("Airport payload too large (max 2 MiB)") + writeAPIV1Response(c, http.StatusRequestEntityTooLarge, &res) + return + } + + path := "/sweatbox/airport" + if req.Replace { + path += "?replace=1" + } + + status, respBody, err := s.fsdSweatboxDo(http.MethodPost, path, "text/plain; charset=utf-8", strings.NewReader(text)) + if err != nil { + writeSweatboxProxyErr(c, "FSD HTTP service unreachable") + return + } + + switch status { + case http.StatusOK: + var data serviceapi.SweatboxAirportLoadResponse + if err := json.Unmarshal(respBody, &data); err != nil { + writeSweatboxProxyErr(c, "FSD service returned unexpected status") + return + } + if data.Errors == nil { + data.Errors = []string{} + } + slog.Info("sweatbox api airport load", + "cid", claims.CID, + "icao", data.ICAO, + "surfaces", data.Surfaces, + "replace", req.Replace, + "content_length", len(text), + ) + res := newAPIV1Success(&data) + writeAPIV1Response(c, http.StatusOK, &res) + case http.StatusBadRequest: + res := newAPIV1Failure(firstJSONError(respBody, "Invalid airport file")) + writeAPIV1Response(c, http.StatusBadRequest, &res) + case http.StatusConflict: + res := newAPIV1Failure(firstJSONError(respBody, "Aircraft present — check Replace to clear them, or delete aircraft first")) + writeAPIV1Response(c, http.StatusConflict, &res) + case http.StatusNotFound: + writeSweatboxDisabled(c) + case http.StatusRequestEntityTooLarge: + res := newAPIV1Failure("Airport payload too large (max 2 MiB)") + writeAPIV1Response(c, http.StatusRequestEntityTooLarge, &res) + default: + writeSweatboxProxyErr(c, "FSD service returned unexpected status") + } +} + +// handleAPISweatboxScenario POST /api/v1/sweatbox/scenario +// +// JSON {"text"} → FSD POST /sweatbox/scenario text/plain. +func (s *Server) handleAPISweatboxScenario(c *gin.Context) { + claims, ok := requireSweatboxI1(c) + if !ok { + return + } + + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, sweatboxWebMaxBody) + var req sweatboxScenarioJSONRequest + if err := c.ShouldBindJSON(&req); err != nil { + if isRequestTooLarge(err) { + res := newAPIV1Failure("Scenario payload too large (max 2 MiB)") + writeAPIV1Response(c, http.StatusRequestEntityTooLarge, &res) + return + } + res := newAPIV1Failure("invalid JSON body") + writeAPIV1Response(c, http.StatusBadRequest, &res) + return + } + text := req.Text + if len(bytes.TrimSpace([]byte(text))) == 0 { + res := newAPIV1Failure("Scenario file or text is required") + writeAPIV1Response(c, http.StatusBadRequest, &res) + return + } + if len(text) > sweatboxWebMaxBody { + res := newAPIV1Failure("Scenario payload too large (max 2 MiB)") + writeAPIV1Response(c, http.StatusRequestEntityTooLarge, &res) + return + } + + status, respBody, err := s.fsdSweatboxDo(http.MethodPost, "/sweatbox/scenario", "text/plain; charset=utf-8", strings.NewReader(text)) + if err != nil { + writeSweatboxProxyErr(c, "FSD HTTP service unreachable") + return + } + + switch status { + case http.StatusOK: + var data serviceapi.SweatboxScenarioResponse + if err := json.Unmarshal(respBody, &data); err != nil { + writeSweatboxProxyErr(c, "FSD service returned unexpected status") + return + } + if data.Errors == nil { + data.Errors = []string{} + } + slog.Info("sweatbox api scenario load", + "cid", claims.CID, + "loaded", data.Loaded, + "error_count", len(data.Errors), + "content_length", len(text), + ) + res := newAPIV1Success(&data) + writeAPIV1Response(c, http.StatusOK, &res) + case http.StatusBadRequest: + res := newAPIV1Failure(firstJSONError(respBody, "Invalid scenario file")) + writeAPIV1Response(c, http.StatusBadRequest, &res) + case http.StatusConflict: + res := newAPIV1Failure(firstJSONError(respBody, "No airport loaded — load a .apt first")) + writeAPIV1Response(c, http.StatusConflict, &res) + case http.StatusNotFound: + writeSweatboxDisabled(c) + case http.StatusRequestEntityTooLarge: + res := newAPIV1Failure("Scenario payload too large (max 2 MiB)") + writeAPIV1Response(c, http.StatusRequestEntityTooLarge, &res) + default: + writeSweatboxProxyErr(c, "FSD service returned unexpected status") + } +} + +// handleAPISweatboxCommand POST /api/v1/sweatbox/command +// +// JSON SweatboxCommandRequest → FSD. Soft-fail (ok:false) stays HTTP 200 + envelope data. +func (s *Server) handleAPISweatboxCommand(c *gin.Context) { + claims, ok := requireSweatboxI1(c) + if !ok { + return + } + + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, sweatboxWebSmallFormMaxBody) + var req serviceapi.SweatboxCommandRequest + if err := c.ShouldBindJSON(&req); err != nil { + if isRequestTooLarge(err) { + res := newAPIV1Failure("Request body too large") + writeAPIV1Response(c, http.StatusRequestEntityTooLarge, &res) + return + } + res := newAPIV1Failure("invalid JSON body") + writeAPIV1Response(c, http.StatusBadRequest, &res) + return + } + req.Command = strings.TrimSpace(req.Command) + req.Callsign = strings.TrimSpace(req.Callsign) + if req.Command == "" { + res := newAPIV1Failure("Command is required") + writeAPIV1Response(c, http.StatusBadRequest, &res) + return + } + + payload, err := json.Marshal(req) + if err != nil { + res := newAPIV1Failure("Unable to encode command") + writeAPIV1Response(c, http.StatusInternalServerError, &res) + return + } + + status, respBody, err := s.fsdSweatboxDo(http.MethodPost, "/sweatbox/command", "application/json", bytes.NewReader(payload)) + if err != nil { + writeSweatboxProxyErr(c, "FSD HTTP service unreachable") + return + } + + switch status { + case http.StatusOK: + var data serviceapi.SweatboxCommandResponse + if err := json.Unmarshal(respBody, &data); err != nil { + writeSweatboxProxyErr(c, "FSD service returned unexpected status") + return + } + slog.Info("sweatbox api command", + "cid", claims.CID, + "ok", data.OK, + "callsign", req.Callsign, + ) + // Soft-fail (ok:false) stays 200 with data — do not elevate to 4xx. + res := newAPIV1Success(&data) + writeAPIV1Response(c, http.StatusOK, &res) + case http.StatusBadRequest: + res := newAPIV1Failure("Invalid command request") + writeAPIV1Response(c, http.StatusBadRequest, &res) + case http.StatusConflict: + res := newAPIV1Failure(firstJSONError(respBody, "No airport loaded")) + writeAPIV1Response(c, http.StatusConflict, &res) + case http.StatusNotFound: + writeSweatboxDisabled(c) + default: + writeSweatboxProxyErr(c, "FSD service returned unexpected status") + } +} + +// handleAPISweatboxPause POST /api/v1/sweatbox/pause — FSD 204/200 → public 200 envelope. +func (s *Server) handleAPISweatboxPause(c *gin.Context) { + if _, ok := requireSweatboxI1(c); !ok { + return + } + + status, _, err := s.fsdSweatboxDo(http.MethodPost, "/sweatbox/pause", "", nil) + if err != nil { + writeSweatboxProxyErr(c, "FSD HTTP service unreachable") + return + } + switch status { + case http.StatusNoContent, http.StatusOK: + res := newAPIV1Success(nil) + writeAPIV1Response(c, http.StatusOK, &res) + case http.StatusNotFound: + writeSweatboxDisabled(c) + default: + writeSweatboxProxyErr(c, "FSD service returned unexpected status") + } +} + +// handleAPISweatboxUnpause POST /api/v1/sweatbox/unpause — FSD 204/200 → public 200 envelope. +func (s *Server) handleAPISweatboxUnpause(c *gin.Context) { + if _, ok := requireSweatboxI1(c); !ok { + return + } + + status, _, err := s.fsdSweatboxDo(http.MethodPost, "/sweatbox/unpause", "", nil) + if err != nil { + writeSweatboxProxyErr(c, "FSD HTTP service unreachable") + return + } + switch status { + case http.StatusNoContent, http.StatusOK: + res := newAPIV1Success(nil) + writeAPIV1Response(c, http.StatusOK, &res) + case http.StatusNotFound: + writeSweatboxDisabled(c) + default: + writeSweatboxProxyErr(c, "FSD service returned unexpected status") + } +} + +// handleAPISweatboxDeleteAircraft DELETE /api/v1/sweatbox/aircraft/:callsign +func (s *Server) handleAPISweatboxDeleteAircraft(c *gin.Context) { + if _, ok := requireSweatboxI1(c); !ok { + return + } + + cs := strings.TrimSpace(c.Param("callsign")) + if cs == "" { + res := newAPIV1Failure("Callsign is required") + writeAPIV1Response(c, http.StatusBadRequest, &res) + return + } + + path := "/sweatbox/aircraft/" + url.PathEscape(cs) + status, _, err := s.fsdSweatboxDo(http.MethodDelete, path, "", nil) + if err != nil { + writeSweatboxProxyErr(c, "FSD HTTP service unreachable") + return + } + switch status { + case http.StatusNoContent, http.StatusOK: + res := newAPIV1Success(nil) + writeAPIV1Response(c, http.StatusOK, &res) + case http.StatusNotFound: + // Unknown callsign or sweatbox disabled — same HTTP 404 from FSD. + res := newAPIV1Failure("Aircraft not found (or sweatbox disabled): " + cs) + writeAPIV1Response(c, http.StatusNotFound, &res) + default: + writeSweatboxProxyErr(c, "FSD service returned unexpected status") + } +} + +// handleAPISweatboxDeleteAllAircraft DELETE /api/v1/sweatbox/aircraft +// +// Requires confirm=true (JSON body or query confirm=1). +func (s *Server) handleAPISweatboxDeleteAllAircraft(c *gin.Context) { + if _, ok := requireSweatboxI1(c); !ok { + return + } + + confirmed := queryTruthy(c.Query("confirm")) + if !confirmed { + // Optional JSON body {"confirm":true}; empty body is fine when query used. + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, sweatboxWebSmallFormMaxBody) + body, err := io.ReadAll(c.Request.Body) + if err != nil { + if isRequestTooLarge(err) { + res := newAPIV1Failure("Request body too large") + writeAPIV1Response(c, http.StatusRequestEntityTooLarge, &res) + return + } + res := newAPIV1Failure("invalid JSON body") + writeAPIV1Response(c, http.StatusBadRequest, &res) + return + } + if len(bytes.TrimSpace(body)) > 0 { + var req sweatboxDeleteAllJSONRequest + if err := json.Unmarshal(body, &req); err != nil { + res := newAPIV1Failure("invalid JSON body") + writeAPIV1Response(c, http.StatusBadRequest, &res) + return + } + confirmed = req.Confirm + } + } + if !confirmed { + res := newAPIV1Failure("confirm required") + writeAPIV1Response(c, http.StatusBadRequest, &res) + return + } + + status, _, err := s.fsdSweatboxDo(http.MethodDelete, "/sweatbox/aircraft", "", nil) + if err != nil { + writeSweatboxProxyErr(c, "FSD HTTP service unreachable") + return + } + switch status { + case http.StatusNoContent, http.StatusOK: + res := newAPIV1Success(nil) + writeAPIV1Response(c, http.StatusOK, &res) + case http.StatusNotFound: + writeSweatboxDisabled(c) + default: + writeSweatboxProxyErr(c, "FSD service returned unexpected status") } } diff --git a/internal/web/api_sweatbox_test.go b/internal/web/api_sweatbox_test.go index 8f16dbf..fa161b5 100644 --- a/internal/web/api_sweatbox_test.go +++ b/internal/web/api_sweatbox_test.go @@ -228,3 +228,306 @@ func TestSweatboxPageNoPEScriptWhenUnavailable(t *testing.T) { t.Fatal("should not load sweatbox.js when control plane unavailable") } } + +func createInstructor1(t *testing.T, env *testAPIEnv) (cid int, access string) { + t.Helper() + pass := "i1pass123" + u := &db.User{ + Password: pass, + FirstName: strPtr("Inst"), + LastName: strPtr("One"), + NetworkRating: int(protocol.NetworkRatingInstructor1), + } + require.NoError(t, env.server.dbRepo.UserRepo.CreateUser(u)) + access, _ = env.login(t, u.CID, pass) + return u.CID, access +} + +func TestAPISweatboxSessionEnveloped(t *testing.T) { + m := &sweatboxMock{} + fsd := httptest.NewServer(m.handler()) + t.Cleanup(fsd.Close) + + env := setupTestAPI(t) + env.server.cfg.FsdHttpServiceAddress = fsd.URL + _, access := createInstructor1(t, env) + + w := env.doJSON(t, http.MethodGet, "/api/v1/sweatbox/session", nil, access) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + + var res APIV1Response + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &res)) + require.Nil(t, res.Err) + require.Equal(t, "v1", res.Version) + + data, err := json.Marshal(res.Data) + require.NoError(t, err) + var st serviceapi.SweatboxStateJSON + require.NoError(t, json.Unmarshal(data, &st)) + assert.Equal(t, "KBTV", st.ICAO) + require.Len(t, st.Aircraft, 1) + assert.Equal(t, "AAL123", st.Aircraft[0].Callsign) + + assertGoldenJSON(t, "2026-07-28/sweatbox_session_ok.json", w.Body.Bytes()) +} + +func TestAPISweatboxSessionDisabled404(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + http.NotFound(w, r) + }) + fsd := httptest.NewServer(mux) + t.Cleanup(fsd.Close) + + env := setupTestAPI(t) + env.server.cfg.FsdHttpServiceAddress = fsd.URL + access, _ := env.login(t, env.admin.CID, env.adminPass) + + w := env.doJSON(t, http.MethodGet, "/api/v1/sweatbox/session", nil, access) + require.Equal(t, http.StatusNotFound, w.Code, w.Body.String()) + var res APIV1Response + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &res)) + require.NotNil(t, res.Err) + assert.Contains(t, *res.Err, "not enabled") +} + +func TestAPISweatboxSessionForbiddenForObserver(t *testing.T) { + env := setupTestAPI(t) + access, _ := env.login(t, env.observer.CID, env.observerPass) + w := env.doJSON(t, http.MethodGet, "/api/v1/sweatbox/session", nil, access) + require.Equal(t, http.StatusForbidden, w.Code, w.Body.String()) +} + +func TestAPISweatboxAirportJSONProxiesTextPlain(t *testing.T) { + m := &sweatboxMock{} + fsd := httptest.NewServer(m.handler()) + t.Cleanup(fsd.Close) + + env := setupTestAPI(t) + env.server.cfg.FsdHttpServiceAddress = fsd.URL + _, access := createInstructor1(t, env) + + apt := "icao=KBTV\n//test apt" + w := env.doJSON(t, http.MethodPost, "/api/v1/sweatbox/airport", map[string]any{ + "text": apt, + "replace": true, + }, access) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + + // FSD received text/plain body and replace query + assert.Equal(t, apt, m.airportBody) + assert.Contains(t, m.airportPath, "replace=1") + assert.Equal(t, "text/plain; charset=utf-8", m.airportContentType) + + var res APIV1Response + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &res)) + require.Nil(t, res.Err) + data, err := json.Marshal(res.Data) + require.NoError(t, err) + var loaded serviceapi.SweatboxAirportLoadResponse + require.NoError(t, json.Unmarshal(data, &loaded)) + assert.Equal(t, "KBTV", loaded.ICAO) + assert.Equal(t, 3, loaded.Surfaces) + assertGoldenJSON(t, "2026-07-28/sweatbox_airport_ok.json", w.Body.Bytes()) +} + +func TestAPISweatboxAirportConflict409(t *testing.T) { + m := &sweatboxMock{airportConflict: true} + fsd := httptest.NewServer(m.handler()) + t.Cleanup(fsd.Close) + + env := setupTestAPI(t) + env.server.cfg.FsdHttpServiceAddress = fsd.URL + access, _ := env.login(t, env.admin.CID, env.adminPass) + + w := env.doJSON(t, http.MethodPost, "/api/v1/sweatbox/airport", map[string]any{ + "text": "icao=KBTV\n", + }, access) + require.Equal(t, http.StatusConflict, w.Code, w.Body.String()) + var res APIV1Response + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &res)) + require.NotNil(t, res.Err) + assert.Contains(t, *res.Err, "aircraft") +} + +func TestAPISweatboxAirportEmptyText400(t *testing.T) { + env := setupTestAPI(t) + access, _ := env.login(t, env.admin.CID, env.adminPass) + w := env.doJSON(t, http.MethodPost, "/api/v1/sweatbox/airport", map[string]any{ + "text": " ", + }, access) + require.Equal(t, http.StatusBadRequest, w.Code, w.Body.String()) +} + +func TestAPISweatboxScenarioOK(t *testing.T) { + m := &sweatboxMock{} + fsd := httptest.NewServer(m.handler()) + t.Cleanup(fsd.Close) + + env := setupTestAPI(t) + env.server.cfg.FsdHttpServiceAddress = fsd.URL + access, _ := env.login(t, env.admin.CID, env.adminPass) + + air := "AAL123 B738 ..." + w := env.doJSON(t, http.MethodPost, "/api/v1/sweatbox/scenario", map[string]any{ + "text": air, + }, access) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + assert.Equal(t, air, m.scenarioBody) + assert.Equal(t, "text/plain; charset=utf-8", m.scenarioContentType) + + var res APIV1Response + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &res)) + require.Nil(t, res.Err) + assertGoldenJSON(t, "2026-07-28/sweatbox_scenario_ok.json", w.Body.Bytes()) +} + +func TestAPISweatboxCommandSoftFailStays200(t *testing.T) { + m := &sweatboxMock{commandSoftFail: true} + fsd := httptest.NewServer(m.handler()) + t.Cleanup(fsd.Close) + + env := setupTestAPI(t) + env.server.cfg.FsdHttpServiceAddress = fsd.URL + access, _ := env.login(t, env.admin.CID, env.adminPass) + + w := env.doJSON(t, http.MethodPost, "/api/v1/sweatbox/command", map[string]any{ + "callsign": "AAL123", + "command": "xyz", + }, access) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + + var res APIV1Response + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &res)) + require.Nil(t, res.Err, "soft-fail must not set envelope err") + data, err := json.Marshal(res.Data) + require.NoError(t, err) + var cmd serviceapi.SweatboxCommandResponse + require.NoError(t, json.Unmarshal(data, &cmd)) + assert.False(t, cmd.OK) + assert.Contains(t, cmd.Message, "Unknown command") + assertGoldenJSON(t, "2026-07-28/sweatbox_command_softfail.json", w.Body.Bytes()) +} + +func TestAPISweatboxCommandOK(t *testing.T) { + m := &sweatboxMock{} + fsd := httptest.NewServer(m.handler()) + t.Cleanup(fsd.Close) + + env := setupTestAPI(t) + env.server.cfg.FsdHttpServiceAddress = fsd.URL + access, _ := env.login(t, env.admin.CID, env.adminPass) + + w := env.doJSON(t, http.MethodPost, "/api/v1/sweatbox/command", map[string]any{ + "callsign": "AAL123", + "command": "taxi A", + }, access) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + assert.Equal(t, "AAL123", m.lastCommand.Callsign) + assert.Equal(t, "taxi A", m.lastCommand.Command) + + var res APIV1Response + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &res)) + require.Nil(t, res.Err) + assertGoldenJSON(t, "2026-07-28/sweatbox_command_ok.json", w.Body.Bytes()) +} + +func TestAPISweatboxCommandMissing400(t *testing.T) { + env := setupTestAPI(t) + access, _ := env.login(t, env.admin.CID, env.adminPass) + w := env.doJSON(t, http.MethodPost, "/api/v1/sweatbox/command", map[string]any{ + "callsign": "AAL123", + "command": " ", + }, access) + require.Equal(t, http.StatusBadRequest, w.Code, w.Body.String()) +} + +func TestAPISweatboxPauseUnpause204To200(t *testing.T) { + m := &sweatboxMock{} + fsd := httptest.NewServer(m.handler()) + t.Cleanup(fsd.Close) + + env := setupTestAPI(t) + env.server.cfg.FsdHttpServiceAddress = fsd.URL + access, _ := env.login(t, env.admin.CID, env.adminPass) + + w := env.doJSON(t, http.MethodPost, "/api/v1/sweatbox/pause", nil, access) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + assert.True(t, m.paused) + assertGoldenJSON(t, "2026-07-28/sweatbox_pause_ok.json", w.Body.Bytes()) + + w = env.doJSON(t, http.MethodPost, "/api/v1/sweatbox/unpause", nil, access) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + assert.False(t, m.paused) + assertGoldenJSON(t, "2026-07-28/sweatbox_unpause_ok.json", w.Body.Bytes()) +} + +func TestAPISweatboxDeleteAircraft(t *testing.T) { + m := &sweatboxMock{} + fsd := httptest.NewServer(m.handler()) + t.Cleanup(fsd.Close) + + env := setupTestAPI(t) + env.server.cfg.FsdHttpServiceAddress = fsd.URL + access, _ := env.login(t, env.admin.CID, env.adminPass) + + w := env.doJSON(t, http.MethodDelete, "/api/v1/sweatbox/aircraft/AAL123", nil, access) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + assertGoldenJSON(t, "2026-07-28/sweatbox_delete_ok.json", w.Body.Bytes()) +} + +func TestAPISweatboxDeleteAllRequiresConfirm(t *testing.T) { + m := &sweatboxMock{} + fsd := httptest.NewServer(m.handler()) + t.Cleanup(fsd.Close) + + env := setupTestAPI(t) + env.server.cfg.FsdHttpServiceAddress = fsd.URL + access, _ := env.login(t, env.admin.CID, env.adminPass) + + // Missing confirm → 400 + w := env.doJSON(t, http.MethodDelete, "/api/v1/sweatbox/aircraft", nil, access) + require.Equal(t, http.StatusBadRequest, w.Code, w.Body.String()) + var res APIV1Response + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &res)) + require.NotNil(t, res.Err) + assert.Contains(t, *res.Err, "confirm") + + // JSON confirm + w = env.doJSON(t, http.MethodDelete, "/api/v1/sweatbox/aircraft", map[string]any{ + "confirm": true, + }, access) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + assertGoldenJSON(t, "2026-07-28/sweatbox_delete_all_ok.json", w.Body.Bytes()) +} + +func TestAPISweatboxDeleteAllQueryConfirm(t *testing.T) { + m := &sweatboxMock{} + fsd := httptest.NewServer(m.handler()) + t.Cleanup(fsd.Close) + + env := setupTestAPI(t) + env.server.cfg.FsdHttpServiceAddress = fsd.URL + access, _ := env.login(t, env.admin.CID, env.adminPass) + + w := env.doJSON(t, http.MethodDelete, "/api/v1/sweatbox/aircraft?confirm=1", nil, access) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) +} + +func TestAPISweatboxMutationsUnreachable502(t *testing.T) { + env := setupTestAPI(t) + access, _ := env.login(t, env.admin.CID, env.adminPass) + + w := env.doJSON(t, http.MethodPost, "/api/v1/sweatbox/pause", nil, access) + require.Equal(t, http.StatusBadGateway, w.Code, w.Body.String()) + assert.Contains(t, w.Body.String(), "unreachable") +} + +func TestAPISweatboxAirportForbiddenForObserver(t *testing.T) { + env := setupTestAPI(t) + access, _ := env.login(t, env.observer.CID, env.observerPass) + w := env.doJSON(t, http.MethodPost, "/api/v1/sweatbox/airport", map[string]any{ + "text": "icao=KBTV\n", + }, access) + require.Equal(t, http.StatusForbidden, w.Code, w.Body.String()) +} diff --git a/internal/web/openapi/openapi.v1.yaml b/internal/web/openapi/openapi.v1.yaml index 8e740ff..739cc82 100644 --- a/internal/web/openapi/openapi.v1.yaml +++ b/internal/web/openapi/openapi.v1.yaml @@ -38,7 +38,10 @@ tags: - name: Editor description: APT/AIR validate (Stable; Instructor1+) - name: Sweatbox - description: Read-only PE proxies (raw; outside envelope goldens) + description: | + Instructor1+ sweatbox control plane proxies. + GET /state and /ops are raw FSD bodies for PE polls (outside envelope goldens). + Mutations and GET /session use the APIV1 envelope (Stable; design §D status mapping). # Data feeds (/data/*) are public and outside microversion reject; documented in # internal/web/README.md. Not listed here until a full inventory is needed. @@ -467,6 +470,291 @@ paths: "400": $ref: "#/components/responses/BadAPIVersion" + /sweatbox/session: + get: + tags: [Sweatbox] + summary: Enveloped sweatbox state (operator / third-party) + description: | + Parallel of GET /sweatbox/state with APIV1 envelope. + `data` is serviceapi.SweatboxStateJSON. Instructor1+. + operationId: getSweatboxSession + x-openfsd-stability: stable + security: + - bearerAuth: [] + - cookieAuth: [] + parameters: + - $ref: "#/components/parameters/OpenFSDAPIVersion" + responses: + "200": + description: Envelope with session state + content: + application/json: + schema: + $ref: "#/components/schemas/APIV1Envelope" + "403": + description: Below Instructor1 + "404": + description: Sweatbox disabled on FSD + "400": + $ref: "#/components/responses/BadAPIVersion" + "502": + description: FSD service unreachable or unexpected + + /sweatbox/airport: + post: + tags: [Sweatbox] + summary: Load airport (.apt) into sweatbox + description: | + Public JSON `{"text","replace"}`. Proxied to FSD as text/plain of `text` + with optional `?replace=1`. Max body 2 MiB. Instructor1+. + operationId: postSweatboxAirport + x-openfsd-stability: stable + security: + - bearerAuth: [] + - cookieAuth: [] + parameters: + - $ref: "#/components/parameters/OpenFSDAPIVersion" + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [text] + properties: + text: { type: string, description: Full .apt file text } + replace: { type: boolean, default: false } + responses: + "200": + description: Airport loaded (data = SweatboxAirportLoadResponse) + content: + application/json: + schema: + $ref: "#/components/schemas/APIV1Envelope" + "400": + description: Invalid JSON, empty text, or invalid airport + "403": + description: Below Instructor1 + "404": + description: Sweatbox disabled + "409": + description: Aircraft present without replace + "413": + description: Payload too large + "502": + description: FSD unreachable / unexpected + + /sweatbox/scenario: + post: + tags: [Sweatbox] + summary: Load scenario (.air) into sweatbox + description: | + Public JSON `{"text"}`. Proxied to FSD as text/plain. Max 2 MiB. Instructor1+. + operationId: postSweatboxScenario + x-openfsd-stability: stable + security: + - bearerAuth: [] + - cookieAuth: [] + parameters: + - $ref: "#/components/parameters/OpenFSDAPIVersion" + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [text] + properties: + text: { type: string, description: Full .air file text } + responses: + "200": + description: Scenario load result (data = SweatboxScenarioResponse) + content: + application/json: + schema: + $ref: "#/components/schemas/APIV1Envelope" + "400": + description: Invalid JSON, empty text, or invalid scenario + "403": + description: Below Instructor1 + "404": + description: Sweatbox disabled + "409": + description: No airport loaded + "413": + description: Payload too large + "502": + description: FSD unreachable / unexpected + + /sweatbox/command: + post: + tags: [Sweatbox] + summary: Run instructor command + description: | + Soft validation failures return HTTP 200 with `data.ok=false` (TWRTrainer-style). + Do not treat ok:false as a 4xx. Instructor1+. + operationId: postSweatboxCommand + x-openfsd-stability: stable + security: + - bearerAuth: [] + - cookieAuth: [] + parameters: + - $ref: "#/components/parameters/OpenFSDAPIVersion" + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [command] + properties: + callsign: { type: string } + command: { type: string } + responses: + "200": + description: | + Command processed. `data` is SweatboxCommandResponse with ok/message. + Soft-fail: ok=false still HTTP 200, envelope err null. + content: + application/json: + schema: + $ref: "#/components/schemas/APIV1Envelope" + "400": + description: Invalid JSON or empty command + "403": + description: Below Instructor1 + "404": + description: Sweatbox disabled + "409": + description: No airport / conflict + "502": + description: FSD unreachable / unexpected + + /sweatbox/pause: + post: + tags: [Sweatbox] + summary: Pause simulation + description: FSD 204 is mapped to public 200 envelope with data null. Instructor1+. + operationId: postSweatboxPause + x-openfsd-stability: stable + security: + - bearerAuth: [] + - cookieAuth: [] + parameters: + - $ref: "#/components/parameters/OpenFSDAPIVersion" + responses: + "200": + description: Paused (data null) + content: + application/json: + schema: + $ref: "#/components/schemas/APIV1Envelope" + "403": + description: Below Instructor1 + "404": + description: Sweatbox disabled + "502": + description: FSD unreachable / unexpected + + /sweatbox/unpause: + post: + tags: [Sweatbox] + summary: Unpause simulation + description: FSD 204 is mapped to public 200 envelope with data null. Instructor1+. + operationId: postSweatboxUnpause + x-openfsd-stability: stable + security: + - bearerAuth: [] + - cookieAuth: [] + parameters: + - $ref: "#/components/parameters/OpenFSDAPIVersion" + responses: + "200": + description: Unpaused (data null) + content: + application/json: + schema: + $ref: "#/components/schemas/APIV1Envelope" + "403": + description: Below Instructor1 + "404": + description: Sweatbox disabled + "502": + description: FSD unreachable / unexpected + + /sweatbox/aircraft/{callsign}: + delete: + tags: [Sweatbox] + summary: Delete one sweatbox aircraft + description: FSD 204 → public 200 envelope. Instructor1+. + operationId: deleteSweatboxAircraft + x-openfsd-stability: stable + security: + - bearerAuth: [] + - cookieAuth: [] + parameters: + - $ref: "#/components/parameters/OpenFSDAPIVersion" + - name: callsign + in: path + required: true + schema: { type: string } + responses: + "200": + description: Deleted (data null) + content: + application/json: + schema: + $ref: "#/components/schemas/APIV1Envelope" + "403": + description: Below Instructor1 + "404": + description: Aircraft not found or sweatbox disabled + "502": + description: FSD unreachable / unexpected + + /sweatbox/aircraft: + delete: + tags: [Sweatbox] + summary: Delete all sweatbox aircraft + description: | + Requires confirmation via JSON `{"confirm":true}` or query `confirm=1`. + FSD 204 → public 200 envelope. Instructor1+. + operationId: deleteSweatboxAllAircraft + x-openfsd-stability: stable + security: + - bearerAuth: [] + - cookieAuth: [] + parameters: + - $ref: "#/components/parameters/OpenFSDAPIVersion" + - name: confirm + in: query + required: false + schema: { type: string, enum: ["1", "true", "yes", "on"] } + description: Alternative to JSON body confirm + requestBody: + required: false + content: + application/json: + schema: + type: object + properties: + confirm: { type: boolean } + responses: + "200": + description: All deleted (data null) + content: + application/json: + schema: + $ref: "#/components/schemas/APIV1Envelope" + "400": + description: confirm required / invalid JSON + "403": + description: Below Instructor1 + "404": + description: Sweatbox disabled + "502": + description: FSD unreachable / unexpected + components: responses: BadAPIVersion: diff --git a/internal/web/pages_sweatbox_test.go b/internal/web/pages_sweatbox_test.go index 02e4904..188f8f0 100644 --- a/internal/web/pages_sweatbox_test.go +++ b/internal/web/pages_sweatbox_test.go @@ -270,14 +270,16 @@ func TestSweatboxCommandEmptyRedirectsErrorFlash(t *testing.T) { // sweatboxMock tracks FSD service HTTP interactions for instructor UI tests. type sweatboxMock struct { - paused bool - lastCommand serviceapi.SweatboxCommandRequest - airportBody string - airportPath string - scenarioBody string - commandSoftFail bool - airportConflict bool - scenarioBadJSON bool + paused bool + lastCommand serviceapi.SweatboxCommandRequest + airportBody string + airportPath string + airportContentType string + scenarioBody string + scenarioContentType string + commandSoftFail bool + airportConflict bool + scenarioBadJSON bool } func (m *sweatboxMock) handler() http.Handler { @@ -311,6 +313,7 @@ func (m *sweatboxMock) handler() http.Handler { }) mux.HandleFunc("/sweatbox/airport", func(w http.ResponseWriter, r *http.Request) { m.airportPath = r.URL.RequestURI() + m.airportContentType = r.Header.Get("Content-Type") b, _ := io.ReadAll(r.Body) m.airportBody = string(b) if m.airportConflict { @@ -323,6 +326,7 @@ func (m *sweatboxMock) handler() http.Handler { _ = json.NewEncoder(w).Encode(map[string]any{"icao": "KBTV", "surfaces": 3, "errors": []string{}}) }) mux.HandleFunc("/sweatbox/scenario", func(w http.ResponseWriter, r *http.Request) { + m.scenarioContentType = r.Header.Get("Content-Type") b, _ := io.ReadAll(r.Body) m.scenarioBody = string(b) if m.scenarioBadJSON { diff --git a/internal/web/routes.go b/internal/web/routes.go index f63d295..642bdcf 100644 --- a/internal/web/routes.go +++ b/internal/web/routes.go @@ -93,14 +93,28 @@ func (s *Server) setupFsdConnRoutes(parent *gin.RouterGroup) { fsdConnGroup.POST("/kickuser", s.handleKickActiveConnection) } -// setupSweatboxAPIRoutes mounts read-only PE proxies under /api/v1/sweatbox. -// Mutations stay on HTML form POSTs (CSRF + PRG) — not on this JSON surface. -// Raw GET bodies are outside envelope goldens; microversion middleware still applies. +// setupSweatboxAPIRoutes mounts /api/v1/sweatbox (Instructor1+ dual-accept). +// +// Raw GET /state and /ops stay non-envelope for PE polls. Mutations and GET +// /session use the APIV1 envelope with the design §D FSD status mapping. +// HTML form POSTs under /sweatbox/* remain for the MPA (CSRF + PRG). func (s *Server) setupSweatboxAPIRoutes(parent *gin.RouterGroup) { g := parent.Group("/sweatbox") s.useAPIV1Protected(g) + + // Raw PE reads (non-envelope) g.GET("/state", s.handleAPISweatboxState) g.GET("/ops", s.handleAPISweatboxOps) + + // Enveloped operator surface (Stable; design §D) + g.GET("/session", s.handleAPISweatboxSession) + g.POST("/airport", s.handleAPISweatboxAirport) + g.POST("/scenario", s.handleAPISweatboxScenario) + g.POST("/command", s.handleAPISweatboxCommand) + g.POST("/pause", s.handleAPISweatboxPause) + g.POST("/unpause", s.handleAPISweatboxUnpause) + g.DELETE("/aircraft/:callsign", s.handleAPISweatboxDeleteAircraft) + g.DELETE("/aircraft", s.handleAPISweatboxDeleteAllAircraft) } func (s *Server) setupDataRoutes(parent *gin.RouterGroup) { diff --git a/internal/web/testdata/api_v1/2026-07-28/sweatbox_airport_ok.json b/internal/web/testdata/api_v1/2026-07-28/sweatbox_airport_ok.json new file mode 100644 index 0000000..2140011 --- /dev/null +++ b/internal/web/testdata/api_v1/2026-07-28/sweatbox_airport_ok.json @@ -0,0 +1,9 @@ +{ + "version": "v1", + "err": null, + "data": { + "icao": "KBTV", + "surfaces": 3, + "errors": [] + } +} diff --git a/internal/web/testdata/api_v1/2026-07-28/sweatbox_command_ok.json b/internal/web/testdata/api_v1/2026-07-28/sweatbox_command_ok.json new file mode 100644 index 0000000..9c6d6df --- /dev/null +++ b/internal/web/testdata/api_v1/2026-07-28/sweatbox_command_ok.json @@ -0,0 +1,8 @@ +{ + "version": "v1", + "err": null, + "data": { + "ok": true, + "message": "ok: taxi A" + } +} diff --git a/internal/web/testdata/api_v1/2026-07-28/sweatbox_command_softfail.json b/internal/web/testdata/api_v1/2026-07-28/sweatbox_command_softfail.json new file mode 100644 index 0000000..a7c7d2f --- /dev/null +++ b/internal/web/testdata/api_v1/2026-07-28/sweatbox_command_softfail.json @@ -0,0 +1,8 @@ +{ + "version": "v1", + "err": null, + "data": { + "ok": false, + "message": "Unknown command: xyz" + } +} diff --git a/internal/web/testdata/api_v1/2026-07-28/sweatbox_delete_all_ok.json b/internal/web/testdata/api_v1/2026-07-28/sweatbox_delete_all_ok.json new file mode 100644 index 0000000..f5e0090 --- /dev/null +++ b/internal/web/testdata/api_v1/2026-07-28/sweatbox_delete_all_ok.json @@ -0,0 +1,5 @@ +{ + "version": "v1", + "err": null, + "data": null +} diff --git a/internal/web/testdata/api_v1/2026-07-28/sweatbox_delete_ok.json b/internal/web/testdata/api_v1/2026-07-28/sweatbox_delete_ok.json new file mode 100644 index 0000000..f5e0090 --- /dev/null +++ b/internal/web/testdata/api_v1/2026-07-28/sweatbox_delete_ok.json @@ -0,0 +1,5 @@ +{ + "version": "v1", + "err": null, + "data": null +} diff --git a/internal/web/testdata/api_v1/2026-07-28/sweatbox_pause_ok.json b/internal/web/testdata/api_v1/2026-07-28/sweatbox_pause_ok.json new file mode 100644 index 0000000..f5e0090 --- /dev/null +++ b/internal/web/testdata/api_v1/2026-07-28/sweatbox_pause_ok.json @@ -0,0 +1,5 @@ +{ + "version": "v1", + "err": null, + "data": null +} diff --git a/internal/web/testdata/api_v1/2026-07-28/sweatbox_scenario_ok.json b/internal/web/testdata/api_v1/2026-07-28/sweatbox_scenario_ok.json new file mode 100644 index 0000000..029f9e4 --- /dev/null +++ b/internal/web/testdata/api_v1/2026-07-28/sweatbox_scenario_ok.json @@ -0,0 +1,8 @@ +{ + "version": "v1", + "err": null, + "data": { + "loaded": 3, + "errors": ["line 9: skipped"] + } +} diff --git a/internal/web/testdata/api_v1/2026-07-28/sweatbox_session_ok.json b/internal/web/testdata/api_v1/2026-07-28/sweatbox_session_ok.json new file mode 100644 index 0000000..e675454 --- /dev/null +++ b/internal/web/testdata/api_v1/2026-07-28/sweatbox_session_ok.json @@ -0,0 +1,33 @@ +{ + "version": "v1", + "err": null, + "data": { + "icao": "KBTV", + "paused": false, + "elapsed_sec": 65, + "arr_count": 1, + "dep_count": 2, + "aircraft": [ + { + "callsign": "AAL123", + "type": "B738", + "rules": "I", + "squawk": "2200", + "xpdr_mode": "", + "lat": 0, + "lon": 0, + "alt": 335, + "speed": 0, + "heading": 360, + "status": "parked", + "instruction": "", + "flight_plan": "", + "dep": "", + "arr": "", + "cruise_alt": 0, + "route": "", + "remarks": "" + } + ] + } +} diff --git a/internal/web/testdata/api_v1/2026-07-28/sweatbox_unpause_ok.json b/internal/web/testdata/api_v1/2026-07-28/sweatbox_unpause_ok.json new file mode 100644 index 0000000..f5e0090 --- /dev/null +++ b/internal/web/testdata/api_v1/2026-07-28/sweatbox_unpause_ok.json @@ -0,0 +1,5 @@ +{ + "version": "v1", + "err": null, + "data": null +} From a9d94d55c5b5403752acbc77109a8a7b2903a34b Mon Sep 17 00:00:00 2001 From: Reese Norris Date: Tue, 28 Jul 2026 12:01:06 -0400 Subject: [PATCH 2/2] fix: address review feedback for sweatbox mutation JSON API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cookie+CSRF dual-accept mutation tests; table-driven §D error statuses; AirportLoadResult aliases serviceapi DTO; small-form body caps on pause/unpause/delete; canAccessSweatbox; invalid-JSON FSD message; slog on remaining mutations; observer 403 matrix. --- internal/server/sweatbox_host.go | 8 +- internal/web/api_sweatbox.go | 82 +++++++++----- internal/web/api_sweatbox_test.go | 166 +++++++++++++++++++++++++++- internal/web/pages_sweatbox_test.go | 39 +++++++ 4 files changed, 255 insertions(+), 40 deletions(-) diff --git a/internal/server/sweatbox_host.go b/internal/server/sweatbox_host.go index 975c4aa..fe094e1 100644 --- a/internal/server/sweatbox_host.go +++ b/internal/server/sweatbox_host.go @@ -139,11 +139,9 @@ func (h *SweatboxHost) tickOnce(dt time.Duration) { } // AirportLoadResult is the structured outcome of loading .apt text (HTTP / tests). -type AirportLoadResult struct { - ICAO string `json:"icao"` - Surfaces int `json:"surfaces"` - Errors []string `json:"errors"` -} +// Type alias of serviceapi.SweatboxAirportLoadResponse so FSD service HTTP and +// the public /api/v1 envelope share a single wire shape (design §D). +type AirportLoadResult = serviceapi.SweatboxAirportLoadResponse // LoadAirport parses .apt text and installs it on the engine. // Does not remove existing aircraft; use LoadAirportReplace for that flow. diff --git a/internal/web/api_sweatbox.go b/internal/web/api_sweatbox.go index 956f4f2..a668d2b 100644 --- a/internal/web/api_sweatbox.go +++ b/internal/web/api_sweatbox.go @@ -12,7 +12,6 @@ import ( "github.com/gin-gonic/gin" "github.com/renorris/openfsd/internal/auth" "github.com/renorris/openfsd/internal/serviceapi" - "github.com/renorris/openfsd/pkg/protocol" ) // Public JSON request bodies for sweatbox mutations (design §D). @@ -34,7 +33,7 @@ type sweatboxDeleteAllJSONRequest struct { // requireSweatboxI1 returns claims when the actor is Instructor1+; otherwise writes 403 and ok=false. func requireSweatboxI1(c *gin.Context) (claims *auth.CustomClaims, ok bool) { claims = getJwtContext(c) - if claims == nil || claims.NetworkRating < protocol.NetworkRatingInstructor1 { + if claims == nil || !canAccessSweatbox(claims.NetworkRating) { writeAPIV1Response(c, http.StatusForbidden, &genericAPIV1Forbidden) return nil, false } @@ -47,11 +46,20 @@ func writeSweatboxProxyErr(c *gin.Context, msg string) { writeAPIV1Response(c, http.StatusBadGateway, &res) } +func writeSweatboxInvalidJSON(c *gin.Context) { + writeSweatboxProxyErr(c, "FSD service returned invalid JSON") +} + func writeSweatboxDisabled(c *gin.Context) { res := newAPIV1Failure("Sweatbox is not enabled on the FSD server") writeAPIV1Response(c, http.StatusNotFound, &res) } +// limitSweatboxAPISmallBody caps non-upload mutation bodies (pause/delete/command). +func limitSweatboxAPISmallBody(c *gin.Context) { + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, sweatboxWebSmallFormMaxBody) +} + // handleAPISweatboxState GET /api/v1/sweatbox/state // // Authenticated read proxy of FSD service HTTP GET /sweatbox/state. @@ -124,7 +132,7 @@ func (s *Server) handleAPISweatboxSession(c *gin.Context) { case http.StatusOK: var st serviceapi.SweatboxStateJSON if err := json.Unmarshal(body, &st); err != nil { - writeSweatboxProxyErr(c, "FSD service returned unexpected status") + writeSweatboxInvalidJSON(c) return } if st.Aircraft == nil { @@ -190,7 +198,7 @@ func (s *Server) handleAPISweatboxAirport(c *gin.Context) { case http.StatusOK: var data serviceapi.SweatboxAirportLoadResponse if err := json.Unmarshal(respBody, &data); err != nil { - writeSweatboxProxyErr(c, "FSD service returned unexpected status") + writeSweatboxInvalidJSON(c) return } if data.Errors == nil { @@ -264,7 +272,7 @@ func (s *Server) handleAPISweatboxScenario(c *gin.Context) { case http.StatusOK: var data serviceapi.SweatboxScenarioResponse if err := json.Unmarshal(respBody, &data); err != nil { - writeSweatboxProxyErr(c, "FSD service returned unexpected status") + writeSweatboxInvalidJSON(c) return } if data.Errors == nil { @@ -303,7 +311,7 @@ func (s *Server) handleAPISweatboxCommand(c *gin.Context) { return } - c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, sweatboxWebSmallFormMaxBody) + limitSweatboxAPISmallBody(c) var req serviceapi.SweatboxCommandRequest if err := c.ShouldBindJSON(&req); err != nil { if isRequestTooLarge(err) { @@ -340,7 +348,7 @@ func (s *Server) handleAPISweatboxCommand(c *gin.Context) { case http.StatusOK: var data serviceapi.SweatboxCommandResponse if err := json.Unmarshal(respBody, &data); err != nil { - writeSweatboxProxyErr(c, "FSD service returned unexpected status") + writeSweatboxInvalidJSON(c) return } slog.Info("sweatbox api command", @@ -366,9 +374,13 @@ func (s *Server) handleAPISweatboxCommand(c *gin.Context) { // handleAPISweatboxPause POST /api/v1/sweatbox/pause — FSD 204/200 → public 200 envelope. func (s *Server) handleAPISweatboxPause(c *gin.Context) { - if _, ok := requireSweatboxI1(c); !ok { + claims, ok := requireSweatboxI1(c) + if !ok { return } + limitSweatboxAPISmallBody(c) + // Drain (and enforce) body cap; body is unused. + _, _ = io.Copy(io.Discard, c.Request.Body) status, _, err := s.fsdSweatboxDo(http.MethodPost, "/sweatbox/pause", "", nil) if err != nil { @@ -377,6 +389,7 @@ func (s *Server) handleAPISweatboxPause(c *gin.Context) { } switch status { case http.StatusNoContent, http.StatusOK: + slog.Info("sweatbox api pause", "cid", claims.CID) res := newAPIV1Success(nil) writeAPIV1Response(c, http.StatusOK, &res) case http.StatusNotFound: @@ -388,9 +401,12 @@ func (s *Server) handleAPISweatboxPause(c *gin.Context) { // handleAPISweatboxUnpause POST /api/v1/sweatbox/unpause — FSD 204/200 → public 200 envelope. func (s *Server) handleAPISweatboxUnpause(c *gin.Context) { - if _, ok := requireSweatboxI1(c); !ok { + claims, ok := requireSweatboxI1(c) + if !ok { return } + limitSweatboxAPISmallBody(c) + _, _ = io.Copy(io.Discard, c.Request.Body) status, _, err := s.fsdSweatboxDo(http.MethodPost, "/sweatbox/unpause", "", nil) if err != nil { @@ -399,6 +415,7 @@ func (s *Server) handleAPISweatboxUnpause(c *gin.Context) { } switch status { case http.StatusNoContent, http.StatusOK: + slog.Info("sweatbox api unpause", "cid", claims.CID) res := newAPIV1Success(nil) writeAPIV1Response(c, http.StatusOK, &res) case http.StatusNotFound: @@ -410,9 +427,12 @@ func (s *Server) handleAPISweatboxUnpause(c *gin.Context) { // handleAPISweatboxDeleteAircraft DELETE /api/v1/sweatbox/aircraft/:callsign func (s *Server) handleAPISweatboxDeleteAircraft(c *gin.Context) { - if _, ok := requireSweatboxI1(c); !ok { + claims, ok := requireSweatboxI1(c) + if !ok { return } + limitSweatboxAPISmallBody(c) + _, _ = io.Copy(io.Discard, c.Request.Body) cs := strings.TrimSpace(c.Param("callsign")) if cs == "" { @@ -429,6 +449,7 @@ func (s *Server) handleAPISweatboxDeleteAircraft(c *gin.Context) { } switch status { case http.StatusNoContent, http.StatusOK: + slog.Info("sweatbox api delete aircraft", "cid", claims.CID, "callsign", cs) res := newAPIV1Success(nil) writeAPIV1Response(c, http.StatusOK, &res) case http.StatusNotFound: @@ -444,34 +465,34 @@ func (s *Server) handleAPISweatboxDeleteAircraft(c *gin.Context) { // // Requires confirm=true (JSON body or query confirm=1). func (s *Server) handleAPISweatboxDeleteAllAircraft(c *gin.Context) { - if _, ok := requireSweatboxI1(c); !ok { + claims, ok := requireSweatboxI1(c) + if !ok { return } + // Always cap body (even when confirm is only via query). + limitSweatboxAPISmallBody(c) + confirmed := queryTruthy(c.Query("confirm")) - if !confirmed { - // Optional JSON body {"confirm":true}; empty body is fine when query used. - c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, sweatboxWebSmallFormMaxBody) - body, err := io.ReadAll(c.Request.Body) - if err != nil { - if isRequestTooLarge(err) { - res := newAPIV1Failure("Request body too large") - writeAPIV1Response(c, http.StatusRequestEntityTooLarge, &res) - return - } + body, err := io.ReadAll(c.Request.Body) + if err != nil { + if isRequestTooLarge(err) { + res := newAPIV1Failure("Request body too large") + writeAPIV1Response(c, http.StatusRequestEntityTooLarge, &res) + return + } + res := newAPIV1Failure("invalid JSON body") + writeAPIV1Response(c, http.StatusBadRequest, &res) + return + } + if !confirmed && len(bytes.TrimSpace(body)) > 0 { + var req sweatboxDeleteAllJSONRequest + if err := json.Unmarshal(body, &req); err != nil { res := newAPIV1Failure("invalid JSON body") writeAPIV1Response(c, http.StatusBadRequest, &res) return } - if len(bytes.TrimSpace(body)) > 0 { - var req sweatboxDeleteAllJSONRequest - if err := json.Unmarshal(body, &req); err != nil { - res := newAPIV1Failure("invalid JSON body") - writeAPIV1Response(c, http.StatusBadRequest, &res) - return - } - confirmed = req.Confirm - } + confirmed = req.Confirm } if !confirmed { res := newAPIV1Failure("confirm required") @@ -486,6 +507,7 @@ func (s *Server) handleAPISweatboxDeleteAllAircraft(c *gin.Context) { } switch status { case http.StatusNoContent, http.StatusOK: + slog.Info("sweatbox api delete-all aircraft", "cid", claims.CID) res := newAPIV1Success(nil) writeAPIV1Response(c, http.StatusOK, &res) case http.StatusNotFound: diff --git a/internal/web/api_sweatbox_test.go b/internal/web/api_sweatbox_test.go index fa161b5..c017f5a 100644 --- a/internal/web/api_sweatbox_test.go +++ b/internal/web/api_sweatbox_test.go @@ -523,11 +523,167 @@ func TestAPISweatboxMutationsUnreachable502(t *testing.T) { assert.Contains(t, w.Body.String(), "unreachable") } -func TestAPISweatboxAirportForbiddenForObserver(t *testing.T) { +func TestAPISweatboxMutationsForbiddenForObserver(t *testing.T) { env := setupTestAPI(t) access, _ := env.login(t, env.observer.CID, env.observerPass) - w := env.doJSON(t, http.MethodPost, "/api/v1/sweatbox/airport", map[string]any{ - "text": "icao=KBTV\n", - }, access) - require.Equal(t, http.StatusForbidden, w.Code, w.Body.String()) + + cases := []struct { + method string + path string + body any + }{ + {http.MethodGet, "/api/v1/sweatbox/session", nil}, + {http.MethodPost, "/api/v1/sweatbox/airport", map[string]any{"text": "icao=KBTV\n"}}, + {http.MethodPost, "/api/v1/sweatbox/scenario", map[string]any{"text": "AAL1"}}, + {http.MethodPost, "/api/v1/sweatbox/command", map[string]any{"command": "ops"}}, + {http.MethodPost, "/api/v1/sweatbox/pause", nil}, + {http.MethodPost, "/api/v1/sweatbox/unpause", nil}, + {http.MethodDelete, "/api/v1/sweatbox/aircraft/AAL123", nil}, + {http.MethodDelete, "/api/v1/sweatbox/aircraft?confirm=1", nil}, + } + for _, tc := range cases { + t.Run(tc.method+" "+tc.path, func(t *testing.T) { + w := env.doJSON(t, tc.method, tc.path, tc.body, access) + require.Equal(t, http.StatusForbidden, w.Code, w.Body.String()) + assert.Empty(t, w.Header().Get("Location")) + res := decodeAPIV1(t, w) + require.NotNil(t, res.Err) + assert.Equal(t, "forbidden", *res.Err) + }) + } +} + +func TestAPISweatboxPauseCookieSessionCSRF(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, "admin-pass", int(protocol.NetworkRatingAdministator)) + cookies := formLogin(t, ts, admin.CID, "admin-pass") + _, cookies = authedGET(t, ts, "/dashboard", cookies) + csrf := csrfFromCookies(cookies) + require.NotEmpty(t, csrf) + + // Cookie session without CSRF → 403 JSON. + req := httptest.NewRequest(http.MethodPost, "/api/v1/sweatbox/pause", nil) + req.Header.Set("Cookie", cookieHeader(cookies)) + req.Header.Set("Accept", "application/json") + w := httptest.NewRecorder() + ts.engine.ServeHTTP(w, req) + require.Equal(t, http.StatusForbidden, w.Code, w.Body.String()) + assert.Empty(t, w.Header().Get("Location")) + res := decodeAPIV1(t, w) + require.NotNil(t, res.Err) + assert.Contains(t, *res.Err, "CSRF") + assert.False(t, m.paused) + + // Cookie session with CSRF header → 200. + req = httptest.NewRequest(http.MethodPost, "/api/v1/sweatbox/pause", nil) + req.Header.Set("Cookie", cookieHeader(cookies)) + req.Header.Set(csrfHeaderName, csrf) + req.Header.Set("Accept", "application/json") + w = httptest.NewRecorder() + ts.engine.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + res = decodeAPIV1(t, w) + require.Nil(t, res.Err) + assert.True(t, m.paused) + assertGoldenJSON(t, "2026-07-28/sweatbox_pause_ok.json", w.Body.Bytes()) +} + +func TestAPISweatboxErrorStatusMapping(t *testing.T) { + // Table-driven Stable §D error statuses not covered by success goldens. + cases := []struct { + name string + mock sweatboxMock + method string + path string + body any + wantStatus int + errSubstr string + }{ + { + name: "airport FSD 413", + mock: sweatboxMock{airportTooLarge: true}, + method: http.MethodPost, + path: "/api/v1/sweatbox/airport", + body: map[string]any{"text": "icao=KBTV\n"}, + wantStatus: http.StatusRequestEntityTooLarge, + errSubstr: "too large", + }, + { + name: "airport FSD 200 invalid JSON → 502", + mock: sweatboxMock{airportBadJSON: true}, + method: http.MethodPost, + path: "/api/v1/sweatbox/airport", + body: map[string]any{"text": "icao=KBTV\n"}, + wantStatus: http.StatusBadGateway, + errSubstr: "invalid JSON", + }, + { + name: "scenario 409 no airport", + mock: sweatboxMock{scenarioConflict: true}, + method: http.MethodPost, + path: "/api/v1/sweatbox/scenario", + body: map[string]any{"text": "AAL1 B738"}, + wantStatus: http.StatusConflict, + errSubstr: "airport", + }, + { + name: "scenario 200 invalid JSON → 502", + mock: sweatboxMock{scenarioBadJSON: true}, + method: http.MethodPost, + path: "/api/v1/sweatbox/scenario", + body: map[string]any{"text": "AAL1"}, + wantStatus: http.StatusBadGateway, + errSubstr: "invalid JSON", + }, + { + name: "command 409 no airport", + mock: sweatboxMock{commandConflict: true}, + method: http.MethodPost, + path: "/api/v1/sweatbox/command", + body: map[string]any{"command": "add"}, + wantStatus: http.StatusConflict, + errSubstr: "airport", + }, + { + name: "delete-one 404", + mock: sweatboxMock{deleteNotFound: true}, + method: http.MethodDelete, + path: "/api/v1/sweatbox/aircraft/NOEXIST", + body: nil, + wantStatus: http.StatusNotFound, + errSubstr: "not found", + }, + { + name: "delete-all 404 disabled", + mock: sweatboxMock{deleteAllNotFound: true}, + method: http.MethodDelete, + path: "/api/v1/sweatbox/aircraft", + body: map[string]any{"confirm": true}, + wantStatus: http.StatusNotFound, + errSubstr: "not enabled", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + m := tc.mock + fsd := httptest.NewServer(m.handler()) + t.Cleanup(fsd.Close) + + env := setupTestAPI(t) + env.server.cfg.FsdHttpServiceAddress = fsd.URL + access, _ := env.login(t, env.admin.CID, env.adminPass) + + w := env.doJSON(t, tc.method, tc.path, tc.body, access) + require.Equal(t, tc.wantStatus, w.Code, w.Body.String()) + res := decodeAPIV1(t, w) + require.NotNil(t, res.Err) + assert.Contains(t, strings.ToLower(*res.Err), strings.ToLower(tc.errSubstr)) + assert.Nil(t, res.Data) + }) + } } diff --git a/internal/web/pages_sweatbox_test.go b/internal/web/pages_sweatbox_test.go index 188f8f0..aa43f00 100644 --- a/internal/web/pages_sweatbox_test.go +++ b/internal/web/pages_sweatbox_test.go @@ -279,7 +279,13 @@ type sweatboxMock struct { scenarioContentType string commandSoftFail bool airportConflict bool + airportTooLarge bool // FSD 413 + airportBadJSON bool // FSD 200 with non-JSON body + scenarioConflict bool scenarioBadJSON bool + commandConflict bool + deleteNotFound bool + deleteAllNotFound bool } func (m *sweatboxMock) handler() http.Handler { @@ -316,6 +322,10 @@ func (m *sweatboxMock) handler() http.Handler { m.airportContentType = r.Header.Get("Content-Type") b, _ := io.ReadAll(r.Body) m.airportBody = string(b) + if m.airportTooLarge { + w.WriteHeader(http.StatusRequestEntityTooLarge) + return + } if m.airportConflict { w.WriteHeader(http.StatusConflict) _ = json.NewEncoder(w).Encode(map[string]any{ @@ -323,12 +333,25 @@ func (m *sweatboxMock) handler() http.Handler { }) return } + if m.airportBadJSON { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("not-json")) + 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) { m.scenarioContentType = r.Header.Get("Content-Type") b, _ := io.ReadAll(r.Body) m.scenarioBody = string(b) + if m.scenarioConflict { + w.WriteHeader(http.StatusConflict) + _ = json.NewEncoder(w).Encode(serviceapi.SweatboxScenarioResponse{ + Loaded: 0, + Errors: []string{"No airport loaded."}, + }) + return + } if m.scenarioBadJSON { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte("not-json")) @@ -341,6 +364,14 @@ func (m *sweatboxMock) handler() http.Handler { }) mux.HandleFunc("/sweatbox/command", func(w http.ResponseWriter, r *http.Request) { _ = json.NewDecoder(r.Body).Decode(&m.lastCommand) + if m.commandConflict { + w.WriteHeader(http.StatusConflict) + _ = json.NewEncoder(w).Encode(serviceapi.SweatboxCommandResponse{ + OK: false, + Message: "No airport loaded", + }) + return + } if m.commandSoftFail { _ = json.NewEncoder(w).Encode(serviceapi.SweatboxCommandResponse{ OK: false, @@ -363,6 +394,10 @@ func (m *sweatboxMock) handler() http.Handler { }) mux.HandleFunc("/sweatbox/aircraft/", func(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodDelete { + if m.deleteNotFound { + w.WriteHeader(http.StatusNotFound) + return + } w.WriteHeader(http.StatusNoContent) return } @@ -370,6 +405,10 @@ func (m *sweatboxMock) handler() http.Handler { }) mux.HandleFunc("/sweatbox/aircraft", func(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodDelete { + if m.deleteAllNotFound { + w.WriteHeader(http.StatusNotFound) + return + } w.WriteHeader(http.StatusNoContent) return }