mirror of
https://github.com/renorris/openfsd
synced 2026-08-12 20:47:41 +08:00
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.
This commit is contained in:
133
internal/web/api_editor.go
Normal file
133
internal/web/api_editor.go
Normal 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
|
||||
}
|
||||
330
internal/web/api_editor_test.go
Normal file
330
internal/web/api_editor_test.go
Normal 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
|
||||
}
|
||||
@@ -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(""))
|
||||
|
||||
Reference in New Issue
Block a user