From eecc145a45d766d00d9f9679d03f76be78e58fd7 Mon Sep 17 00:00:00 2001 From: Reese Norris Date: Thu, 23 Jul 2026 19:36:33 -0400 Subject: [PATCH] web: Admin validate-apt/air JSON API via pkg/twrfiles Stateless POST validators with Admin 403 JSON (never redirect). APIV1 envelope; data.errors only; no geometry return. --- internal/web/api_editor.go | 133 +++++++++++++ internal/web/api_editor_test.go | 330 ++++++++++++++++++++++++++++++++ internal/web/routes.go | 1 + 3 files changed, 464 insertions(+) create mode 100644 internal/web/api_editor.go create mode 100644 internal/web/api_editor_test.go diff --git a/internal/web/api_editor.go b/internal/web/api_editor.go new file mode 100644 index 0000000..bd2974f --- /dev/null +++ b/internal/web/api_editor.go @@ -0,0 +1,133 @@ +package web + +import ( + "log/slog" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/renorris/openfsd/pkg/protocol" + "github.com/renorris/openfsd/pkg/twrfiles" +) + +// editorValidateRequest is the JSON body for POST /api/v1/editor/validate-*. +// Contract: Content-Type application/json, field "text" holds the full .apt/.air file text. +// Max raw request body size is sweatboxWebMaxBody (2 MiB). +type editorValidateRequest struct { + Text string `json:"text"` +} + +// editorValidateAPTData is the success data payload for validate-apt. +// Soft parse errors are listed in Errors; full geometry is never returned. +type editorValidateAPTData struct { + Errors []string `json:"errors"` + ICAO string `json:"icao"` + SurfaceCount int `json:"surface_count"` +} + +// editorValidateAIRData is the success data payload for validate-air. +// Soft parse errors are listed in Errors; aircraft rows are never returned. +type editorValidateAIRData struct { + Errors []string `json:"errors"` + AircraftCount int `json:"aircraft_count"` +} + +// setupEditorAPIRoutes mounts Admin-only APT/AIR validate endpoints under /api/v1/editor. +// Dual-accept Bearer | session cookie; CSRF when cookie-authenticated. +// Authz is inline Admin → 403 JSON (never HTML redirect). +func (s *Server) setupEditorAPIRoutes(parent *gin.RouterGroup) { + g := parent.Group("/editor") + g.Use(s.jwtBearerMiddleware, s.csrfIfCookieSession) + g.POST("/validate-apt", s.handleAPIValidateAPT) + g.POST("/validate-air", s.handleAPIValidateAIR) +} + +// handleAPIValidateAPT POST /api/v1/editor/validate-apt +// +// Stateless ParseAPT of JSON {"text":"…"}. Admin only; 403 JSON when rating too low. +// Soft validation errors are returned in data.errors (HTTP 200); never returns geometry. +func (s *Server) handleAPIValidateAPT(c *gin.Context) { + claims := getJwtContext(c) + if claims == nil || claims.NetworkRating < protocol.NetworkRatingAdministator { + writeAPIV1Response(c, http.StatusForbidden, &genericAPIV1Forbidden) + return + } + + text, ok := readEditorValidateText(c) + if !ok { + return + } + + apt, errs := twrfiles.ParseAPT(text) + if errs == nil { + errs = []string{} + } + + slog.Info("editor validate-apt", + "cid", claims.CID, + "content_length", len(text), + "icao", apt.ICAO, + "surface_count", len(apt.Surfaces), + "error_count", len(errs), + ) + + res := newAPIV1Success(&editorValidateAPTData{ + Errors: errs, + ICAO: apt.ICAO, + SurfaceCount: len(apt.Surfaces), + }) + writeAPIV1Response(c, http.StatusOK, &res) +} + +// handleAPIValidateAIR POST /api/v1/editor/validate-air +// +// Stateless ParseAIR of JSON {"text":"…"}. Admin only; 403 JSON when rating too low. +// Soft validation errors are returned in data.errors (HTTP 200); never returns aircraft rows. +func (s *Server) handleAPIValidateAIR(c *gin.Context) { + claims := getJwtContext(c) + if claims == nil || claims.NetworkRating < protocol.NetworkRatingAdministator { + writeAPIV1Response(c, http.StatusForbidden, &genericAPIV1Forbidden) + return + } + + text, ok := readEditorValidateText(c) + if !ok { + return + } + + rows, errs := twrfiles.ParseAIR(text) + if errs == nil { + errs = []string{} + } + + slog.Info("editor validate-air", + "cid", claims.CID, + "content_length", len(text), + "aircraft_count", len(rows), + "error_count", len(errs), + ) + + res := newAPIV1Success(&editorValidateAIRData{ + Errors: errs, + AircraftCount: len(rows), + }) + writeAPIV1Response(c, http.StatusOK, &res) +} + +// readEditorValidateText binds JSON {"text":"…"} with a 2 MiB raw body cap. +// On failure it writes an APIV1 error response and returns ok=false. +func readEditorValidateText(c *gin.Context) (text string, ok bool) { + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, sweatboxWebMaxBody) + + var req editorValidateRequest + if err := c.ShouldBindJSON(&req); err != nil { + if isRequestTooLarge(err) { + res := newAPIV1Failure("request body too large (max 2 MiB)") + writeAPIV1Response(c, http.StatusRequestEntityTooLarge, &res) + return "", false + } + res := newAPIV1Failure("invalid JSON body") + writeAPIV1Response(c, http.StatusBadRequest, &res) + return "", false + } + return req.Text, true +} diff --git a/internal/web/api_editor_test.go b/internal/web/api_editor_test.go new file mode 100644 index 0000000..409afe7 --- /dev/null +++ b/internal/web/api_editor_test.go @@ -0,0 +1,330 @@ +package web + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/renorris/openfsd/pkg/protocol" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAPIValidateAPTUnauth(t *testing.T) { + env := setupTestAPI(t) + w := env.doJSON(t, http.MethodPost, "/api/v1/editor/validate-apt", map[string]any{ + "text": "icao=KBTV\n", + }, "") + require.Equal(t, http.StatusUnauthorized, w.Code, w.Body.String()) + res := decodeAPIV1(t, w) + require.NotNil(t, res.Err) +} + +func TestAPIValidateAIRUnauth(t *testing.T) { + env := setupTestAPI(t) + w := env.doJSON(t, http.MethodPost, "/api/v1/editor/validate-air", map[string]any{ + "text": "", + }, "") + require.Equal(t, http.StatusUnauthorized, w.Code, w.Body.String()) +} + +func TestAPIValidateAPTForbiddenForObserver(t *testing.T) { + env := setupTestAPI(t) + access, _ := env.login(t, env.observer.CID, env.observerPass) + w := env.doJSON(t, http.MethodPost, "/api/v1/editor/validate-apt", map[string]any{ + "text": "icao=KBTV\n", + }, access) + require.Equal(t, http.StatusForbidden, w.Code, w.Body.String()) + // Must be JSON 403 envelope — never an HTML redirect. + assert.Empty(t, w.Header().Get("Location")) + res := decodeAPIV1(t, w) + require.NotNil(t, res.Err) + assert.Equal(t, "forbidden", *res.Err) + assert.Equal(t, "v1", res.Version) +} + +func TestAPIValidateAIRForbiddenForObserver(t *testing.T) { + env := setupTestAPI(t) + access, _ := env.login(t, env.observer.CID, env.observerPass) + w := env.doJSON(t, http.MethodPost, "/api/v1/editor/validate-air", map[string]any{ + "text": "", + }, 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 TestAPIValidateAPTAdminOKFixture(t *testing.T) { + env := setupTestAPI(t) + access, _ := env.login(t, env.admin.CID, env.adminPass) + + aptText := readTwrfilesFixture(t, "KBTV_example.apt") + w := env.doJSON(t, http.MethodPost, "/api/v1/editor/validate-apt", map[string]any{ + "text": aptText, + }, access) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + + res := decodeAPIV1(t, w) + require.Nil(t, res.Err) + assert.Equal(t, "v1", res.Version) + + data := decodeEditorAPTData(t, res.Data) + assert.Equal(t, "KBTV", data.ICAO) + assert.Greater(t, data.SurfaceCount, 0) + require.NotNil(t, data.Errors) + // Fixture should parse cleanly (errors array present, typically empty). + assert.IsType(t, []string{}, data.Errors) + + // Must not leak geometry in the raw body. + raw := w.Body.String() + assert.NotContains(t, raw, "Surfaces") + assert.NotContains(t, raw, "points") + assert.NotContains(t, raw, "44.46893") +} + +func TestAPIValidateAIRAdminOKFixture(t *testing.T) { + env := setupTestAPI(t) + access, _ := env.login(t, env.admin.CID, env.adminPass) + + airText := readTwrfilesFixture(t, "KBTV_example.air") + w := env.doJSON(t, http.MethodPost, "/api/v1/editor/validate-air", map[string]any{ + "text": airText, + }, access) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + + res := decodeAPIV1(t, w) + require.Nil(t, res.Err) + + data := decodeEditorAIRData(t, res.Data) + assert.Equal(t, 3, data.AircraftCount) + require.NotNil(t, data.Errors) + assert.Empty(t, data.Errors) + + // Must not return full aircraft rows. + raw := w.Body.String() + assert.NotContains(t, raw, "AAL123") + assert.NotContains(t, raw, "callsign") +} + +func TestAPIValidateAPTWithSoftErrors(t *testing.T) { + env := setupTestAPI(t) + access, _ := env.login(t, env.admin.CID, env.adminPass) + + // Invalid ICAO length → soft error, still HTTP 200. + w := env.doJSON(t, http.MethodPost, "/api/v1/editor/validate-apt", map[string]any{ + "text": "icao=XX\n", + }, access) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + + res := decodeAPIV1(t, w) + require.Nil(t, res.Err) + data := decodeEditorAPTData(t, res.Data) + assert.Equal(t, "XX", data.ICAO) + assert.Equal(t, 0, data.SurfaceCount) + require.NotEmpty(t, data.Errors) + assert.Contains(t, data.Errors[0], "ICAO") +} + +func TestAPIValidateAIRWithSoftErrors(t *testing.T) { + env := setupTestAPI(t) + access, _ := env.login(t, env.admin.CID, env.adminPass) + + w := env.doJSON(t, http.MethodPost, "/api/v1/editor/validate-air", map[string]any{ + "text": "ONLY:THREE:FIELDS\n", + }, access) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + + res := decodeAPIV1(t, w) + require.Nil(t, res.Err) + data := decodeEditorAIRData(t, res.Data) + assert.Equal(t, 0, data.AircraftCount) + require.NotEmpty(t, data.Errors) + assert.Contains(t, data.Errors[0], "fields") +} + +func TestAPIValidateAPTEmptyText(t *testing.T) { + env := setupTestAPI(t) + access, _ := env.login(t, env.admin.CID, env.adminPass) + + w := env.doJSON(t, http.MethodPost, "/api/v1/editor/validate-apt", map[string]any{ + "text": "", + }, access) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + + res := decodeAPIV1(t, w) + require.Nil(t, res.Err) + data := decodeEditorAPTData(t, res.Data) + assert.Equal(t, "", data.ICAO) + assert.Equal(t, 0, data.SurfaceCount) + require.NotNil(t, data.Errors) +} + +func TestAPIValidateAIREmptyText(t *testing.T) { + env := setupTestAPI(t) + access, _ := env.login(t, env.admin.CID, env.adminPass) + + w := env.doJSON(t, http.MethodPost, "/api/v1/editor/validate-air", map[string]any{ + "text": "", + }, access) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + + res := decodeAPIV1(t, w) + require.Nil(t, res.Err) + data := decodeEditorAIRData(t, res.Data) + assert.Equal(t, 0, data.AircraftCount) + require.NotNil(t, data.Errors) + assert.Empty(t, data.Errors) +} + +func TestAPIValidateAPTInvalidJSON(t *testing.T) { + env := setupTestAPI(t) + access, _ := env.login(t, env.admin.CID, env.adminPass) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/editor/validate-apt", strings.NewReader("not-json")) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+access) + w := httptest.NewRecorder() + env.router.ServeHTTP(w, req) + require.Equal(t, http.StatusBadRequest, w.Code, w.Body.String()) + res := decodeAPIV1(t, w) + require.NotNil(t, res.Err) + assert.Contains(t, *res.Err, "invalid JSON") +} + +func TestAPIValidateAPTOversizedBody(t *testing.T) { + env := setupTestAPI(t) + access, _ := env.login(t, env.admin.CID, env.adminPass) + + // text alone exceeds 2 MiB → raw JSON body also exceeds MaxBytesReader. + big := strings.Repeat("x", sweatboxWebMaxBody+1) + payload, err := json.Marshal(map[string]any{"text": big}) + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/editor/validate-apt", bytes.NewReader(payload)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+access) + w := httptest.NewRecorder() + env.router.ServeHTTP(w, req) + require.Equal(t, http.StatusRequestEntityTooLarge, w.Code, w.Body.String()) + res := decodeAPIV1(t, w) + require.NotNil(t, res.Err) + assert.Contains(t, *res.Err, "2 MiB") +} + +func TestAPIValidateAIROversizedBody(t *testing.T) { + env := setupTestAPI(t) + access, _ := env.login(t, env.admin.CID, env.adminPass) + + big := strings.Repeat("y", sweatboxWebMaxBody+1) + payload, err := json.Marshal(map[string]any{"text": big}) + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/editor/validate-air", bytes.NewReader(payload)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+access) + w := httptest.NewRecorder() + env.router.ServeHTTP(w, req) + require.Equal(t, http.StatusRequestEntityTooLarge, w.Code, w.Body.String()) +} + +func TestAPIValidateAPTCookieSessionCSRF(t *testing.T) { + ts := newTestServer(t) + admin := createTestUser(t, ts, "admin-pass", int(protocol.NetworkRatingAdministator)) + cookies := formLogin(t, ts, admin.CID, "admin-pass") + + // Ensure CSRF cookie is issued (dashboard sets it). + _, cookies = authedGET(t, ts, "/dashboard", cookies) + csrf := csrfFromCookies(cookies) + require.NotEmpty(t, csrf) + + payload := `{"text":"icao=KBTV\n"}` + + // Cookie session without CSRF → 403 JSON. + req := httptest.NewRequest(http.MethodPost, "/api/v1/editor/validate-apt", strings.NewReader(payload)) + req.Header.Set("Content-Type", "application/json") + 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") + + // Cookie session with CSRF header → 200. + req = httptest.NewRequest(http.MethodPost, "/api/v1/editor/validate-apt", strings.NewReader(payload)) + req.Header.Set("Content-Type", "application/json") + 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) + data := decodeEditorAPTData(t, res.Data) + assert.Equal(t, "KBTV", data.ICAO) +} + +func TestAPIValidateAIRCookieSessionCSRF(t *testing.T) { + ts := newTestServer(t) + 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) + + payload := `{"text":""}` + + req := httptest.NewRequest(http.MethodPost, "/api/v1/editor/validate-air", strings.NewReader(payload)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Cookie", cookieHeader(cookies)) + w := httptest.NewRecorder() + ts.engine.ServeHTTP(w, req) + require.Equal(t, http.StatusForbidden, w.Code, w.Body.String()) + + req = httptest.NewRequest(http.MethodPost, "/api/v1/editor/validate-air", strings.NewReader(payload)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Cookie", cookieHeader(cookies)) + req.Header.Set(csrfHeaderName, csrf) + w = httptest.NewRecorder() + ts.engine.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) +} + +// --- helpers --- + +func readTwrfilesFixture(t *testing.T, name string) string { + t.Helper() + // Tests run with package dir as CWD; fixtures live in pkg/twrfiles/testdata. + path := filepath.Join("..", "..", "pkg", "twrfiles", "testdata", name) + b, err := os.ReadFile(path) + require.NoError(t, err, "fixture %s", path) + return string(b) +} + +func decodeEditorAPTData(t *testing.T, data any) editorValidateAPTData { + t.Helper() + raw, err := json.Marshal(data) + require.NoError(t, err) + var out editorValidateAPTData + require.NoError(t, json.Unmarshal(raw, &out), string(raw)) + return out +} + +func decodeEditorAIRData(t *testing.T, data any) editorValidateAIRData { + t.Helper() + raw, err := json.Marshal(data) + require.NoError(t, err) + var out editorValidateAIRData + require.NoError(t, json.Unmarshal(raw, &out), string(raw)) + return out +} diff --git a/internal/web/routes.go b/internal/web/routes.go index facb4ab..daf4b2e 100644 --- a/internal/web/routes.go +++ b/internal/web/routes.go @@ -40,6 +40,7 @@ func (s *Server) setupRoutes() (*gin.Engine, error) { s.setupDataRoutes(apiV1Group) s.setupFsdConnRoutes(apiV1Group) s.setupSweatboxAPIRoutes(apiV1Group) + s.setupEditorAPIRoutes(apiV1Group) // Frontend groups s.setupFrontendRoutes(e.Group(""))