mirror of
https://github.com/renorris/openfsd
synced 2026-08-13 04:55:42 +08:00
merge: pr-4 sweatbox mutations into pr-6 base
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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`, `/users`, `/config`, `/fsdconn`, `/sweatbox`, `/editor`) reject unknown/invalid pins with **400** envelope.
|
||||
|
||||
**Stability tiers:** enveloped user/users/config/fsdconn/editor routes are **Stable** (goldens under `testdata/api_v1/<pin>/`). New expansion routes may ship **Provisional** (shape may change without a microversion bump while Provisional). Design: `docs/design/rest-api-versioning.md`.
|
||||
**Stability tiers:** enveloped user/users/config/fsdconn/editor routes and sweatbox mutations/`session` are **Stable** (goldens under `testdata/api_v1/<pin>/`). Account JSON is **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
|
||||
@@ -710,6 +731,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.
|
||||
|
||||
|
||||
@@ -1,12 +1,65 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/renorris/openfsd/pkg/protocol"
|
||||
"github.com/renorris/openfsd/internal/auth"
|
||||
"github.com/renorris/openfsd/internal/serviceapi"
|
||||
)
|
||||
|
||||
// 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 || !canAccessSweatbox(claims.NetworkRating) {
|
||||
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 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.
|
||||
@@ -16,16 +69,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 +84,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 +96,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 +110,409 @@ 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 {
|
||||
writeSweatboxInvalidJSON(c)
|
||||
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 {
|
||||
writeSweatboxInvalidJSON(c)
|
||||
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 {
|
||||
writeSweatboxInvalidJSON(c)
|
||||
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
|
||||
}
|
||||
|
||||
limitSweatboxAPISmallBody(c)
|
||||
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 {
|
||||
writeSweatboxInvalidJSON(c)
|
||||
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) {
|
||||
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 {
|
||||
writeSweatboxProxyErr(c, "FSD HTTP service unreachable")
|
||||
return
|
||||
}
|
||||
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:
|
||||
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) {
|
||||
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 {
|
||||
writeSweatboxProxyErr(c, "FSD HTTP service unreachable")
|
||||
return
|
||||
}
|
||||
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:
|
||||
writeSweatboxDisabled(c)
|
||||
default:
|
||||
writeSweatboxProxyErr(c, "FSD service returned unexpected status")
|
||||
}
|
||||
}
|
||||
|
||||
// handleAPISweatboxDeleteAircraft DELETE /api/v1/sweatbox/aircraft/:callsign
|
||||
func (s *Server) handleAPISweatboxDeleteAircraft(c *gin.Context) {
|
||||
claims, ok := requireSweatboxI1(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
limitSweatboxAPISmallBody(c)
|
||||
_, _ = io.Copy(io.Discard, c.Request.Body)
|
||||
|
||||
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:
|
||||
slog.Info("sweatbox api delete aircraft", "cid", claims.CID, "callsign", cs)
|
||||
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) {
|
||||
claims, ok := requireSweatboxI1(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
// Always cap body (even when confirm is only via query).
|
||||
limitSweatboxAPISmallBody(c)
|
||||
|
||||
confirmed := queryTruthy(c.Query("confirm"))
|
||||
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
|
||||
}
|
||||
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:
|
||||
slog.Info("sweatbox api delete-all aircraft", "cid", claims.CID)
|
||||
res := newAPIV1Success(nil)
|
||||
writeAPIV1Response(c, http.StatusOK, &res)
|
||||
case http.StatusNotFound:
|
||||
writeSweatboxDisabled(c)
|
||||
default:
|
||||
writeSweatboxProxyErr(c, "FSD service returned unexpected status")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,3 +228,462 @@ 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 TestAPISweatboxMutationsForbiddenForObserver(t *testing.T) {
|
||||
env := setupTestAPI(t)
|
||||
access, _ := env.login(t, env.observer.CID, env.observerPass)
|
||||
|
||||
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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -593,6 +596,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:
|
||||
|
||||
@@ -270,14 +270,22 @@ 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
|
||||
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 {
|
||||
@@ -311,8 +319,13 @@ 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.airportTooLarge {
|
||||
w.WriteHeader(http.StatusRequestEntityTooLarge)
|
||||
return
|
||||
}
|
||||
if m.airportConflict {
|
||||
w.WriteHeader(http.StatusConflict)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
@@ -320,11 +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"))
|
||||
@@ -337,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,
|
||||
@@ -359,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
|
||||
}
|
||||
@@ -366,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
|
||||
}
|
||||
|
||||
@@ -101,14 +101,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) {
|
||||
|
||||
9
internal/web/testdata/api_v1/2026-07-28/sweatbox_airport_ok.json
vendored
Normal file
9
internal/web/testdata/api_v1/2026-07-28/sweatbox_airport_ok.json
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"version": "v1",
|
||||
"err": null,
|
||||
"data": {
|
||||
"icao": "KBTV",
|
||||
"surfaces": 3,
|
||||
"errors": []
|
||||
}
|
||||
}
|
||||
8
internal/web/testdata/api_v1/2026-07-28/sweatbox_command_ok.json
vendored
Normal file
8
internal/web/testdata/api_v1/2026-07-28/sweatbox_command_ok.json
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"version": "v1",
|
||||
"err": null,
|
||||
"data": {
|
||||
"ok": true,
|
||||
"message": "ok: taxi A"
|
||||
}
|
||||
}
|
||||
8
internal/web/testdata/api_v1/2026-07-28/sweatbox_command_softfail.json
vendored
Normal file
8
internal/web/testdata/api_v1/2026-07-28/sweatbox_command_softfail.json
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"version": "v1",
|
||||
"err": null,
|
||||
"data": {
|
||||
"ok": false,
|
||||
"message": "Unknown command: xyz"
|
||||
}
|
||||
}
|
||||
5
internal/web/testdata/api_v1/2026-07-28/sweatbox_delete_all_ok.json
vendored
Normal file
5
internal/web/testdata/api_v1/2026-07-28/sweatbox_delete_all_ok.json
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"version": "v1",
|
||||
"err": null,
|
||||
"data": null
|
||||
}
|
||||
5
internal/web/testdata/api_v1/2026-07-28/sweatbox_delete_ok.json
vendored
Normal file
5
internal/web/testdata/api_v1/2026-07-28/sweatbox_delete_ok.json
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"version": "v1",
|
||||
"err": null,
|
||||
"data": null
|
||||
}
|
||||
5
internal/web/testdata/api_v1/2026-07-28/sweatbox_pause_ok.json
vendored
Normal file
5
internal/web/testdata/api_v1/2026-07-28/sweatbox_pause_ok.json
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"version": "v1",
|
||||
"err": null,
|
||||
"data": null
|
||||
}
|
||||
8
internal/web/testdata/api_v1/2026-07-28/sweatbox_scenario_ok.json
vendored
Normal file
8
internal/web/testdata/api_v1/2026-07-28/sweatbox_scenario_ok.json
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"version": "v1",
|
||||
"err": null,
|
||||
"data": {
|
||||
"loaded": 3,
|
||||
"errors": ["line 9: skipped"]
|
||||
}
|
||||
}
|
||||
33
internal/web/testdata/api_v1/2026-07-28/sweatbox_session_ok.json
vendored
Normal file
33
internal/web/testdata/api_v1/2026-07-28/sweatbox_session_ok.json
vendored
Normal file
@@ -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": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
5
internal/web/testdata/api_v1/2026-07-28/sweatbox_unpause_ok.json
vendored
Normal file
5
internal/web/testdata/api_v1/2026-07-28/sweatbox_unpause_ok.json
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"version": "v1",
|
||||
"err": null,
|
||||
"data": null
|
||||
}
|
||||
Reference in New Issue
Block a user