merge: combine validate API (PR5) with editor edit modes (PR7)

This commit is contained in:
Reese Norris
2026-07-23 20:24:58 -04:00
3 changed files with 464 additions and 0 deletions

133
internal/web/api_editor.go Normal file
View File

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

View File

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

View File

@@ -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(""))