diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 1fcee9e..0b306c1 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -15,6 +15,13 @@ "5173": { "label": "Vite Dev", "onAutoForward": "notify" } }, + "containerEnv": { + // Keep pnpm's virtual store off the workspace mount (9p doesn't + // support the rename/link calls pnpm needs). /home/vscode is on the + // container overlay and survives /tmp wipes. CI runners ignore this. + "NPM_CONFIG_VIRTUAL_STORE_DIR": "/home/vscode/.cache/pnpm-vstore/openscanner-frontend" + }, + "postCreateCommand": "bash .devcontainer/post-create.sh", "customizations": { diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh index 0856af6..e3742c4 100644 --- a/.devcontainer/post-create.sh +++ b/.devcontainer/post-create.sh @@ -5,6 +5,10 @@ set -euo pipefail sudo chown -R vscode:vscode /home/vscode/go sudo chown -R vscode:vscode /home/vscode/.local/share/pnpm +# pnpm virtual-store-dir (see NPM_CONFIG_VIRTUAL_STORE_DIR in +# devcontainer.json) — persistent across /tmp wipes +mkdir -p /home/vscode/.cache/pnpm-vstore + echo "==> Installing backend Go dependencies..." cd /workspaces/OpenScanner/backend go mod download diff --git a/CHANGELOG.md b/CHANGELOG.md index 999fd03..424e07d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.3.0] — 2026-04-29 + +### Added + +- Native `/api/v1/*` REST surface alongside the existing legacy routes. All v1 responses use a structured error envelope (`{"error":{"code","message","details"}}`) with stable string codes (`validation_failed`, `unauthorized`, `forbidden`, `not_found`, `conflict`, `unprocessable`, `rate_limited`, `internal`); 5xx envelopes include the request ID under `details.requestId`. +- v1 call-upload endpoint (`POST /api/v1/calls`) with native multipart field names (`systemId`, `talkgroupId`, `startedAt`, `frequencyHz`, `durationMs`, `unitId`) and RFC 3339 `startedAt` enforcement (unix timestamps no longer accepted on v1). Companion `POST /api/v1/calls/test` returns 204 on a valid API key. +- v1 listener endpoints: `GET/PUT /api/v1/listener/tg-selection` (renamed from `/api/auth/tg-selection`), `GET /api/v1/calls`, `GET /api/v1/calls/:id/audio`, `GET /api/v1/calls/:id/transcript`, share/bookmark endpoints, and unauthenticated `/api/v1/health`, `/api/v1/setup/*`, `/api/v1/auth/{login,refresh,logout,password,me}`. +- v1 admin endpoints under `/api/v1/admin/*` for talkgroup/unit/group/tag imports, RadioReference preview (path simplified — no `/csv` suffix), transcription status, and Swagger session bootstrap. +- Native JSON-object framed WebSocket protocol on `GET /api/v1/ws/listener` and `GET /api/v1/ws/admin`. Frames carry a `type` discriminator (`connection.welcome`, `scanner.config`, `call.new`, `call.transcript`, `listener.count`, `listener.feedMap.snapshot`/`update`, `session.expired`, `connection.rejected`, `admin.event`, `admin.request`, `admin.response`) instead of the legacy 3-letter array opcodes. Admin error responses mirror the REST `{code,message,details?}` envelope. The frontend connects to the v1 paths; legacy `/ws`, `/api/ws`, and `/api/admin/ws` keep emitting the array-framed protocol unchanged for in-the-wild clients. +- RFC 8594 deprecation headers (`Deprecation: true`, `Sunset`, `Link: ; rel="successor-version"`, `Cache-Control: no-store`) on every legacy `/api/*` and legacy WebSocket route, pointing at the native `/api/v1/*` successor. Per-request structured warn log (`legacy endpoint hit`) records method, path, and a truncated API-key identifier — never the raw key. +- Admin endpoint `GET /api/v1/admin/legacy-usage` returning a 24-hour aggregate of legacy-endpoint hits (`{method, path, apiKeyIdent, count, lastSeen}`), backed by an in-memory ring buffer (no schema change). +- Admin dashboard banner that surfaces legacy-API usage from the new endpoint, with an expandable details table (method, path, API key, count, last seen) and per-session dismiss. + +### Changed + +- Frontend now talks to the native `/api/v1/*` surface for every REST call (RTK Query base URL, raw `fetch()` for audio downloads and silent token refresh, the service-worker passthrough rules, the dev-server proxy, and the Swagger UI bootstrap). Tg-selection moves from `/api/auth/tg-selection` to `/api/v1/listener/tg-selection`; RadioReference CSV preview moves to `/api/v1/admin/radioreference/preview`; legacy-usage report is consumed at `/api/v1/admin/legacy-usage`. Legacy `/api/*` routes remain available for non-frontend clients with the existing deprecation headers. +- API-key authentication on `/api/v1/*` upload routes accepts only `Authorization: Bearer `; the legacy `X-API-Key` header, `?key=` query parameter, and `key=` form field continue to work on legacy routes only. JWT-shaped Bearer tokens on v1 API-key routes are rejected with `invalid_credentials`. +- Swagger UI now documents every native `/api/v1/*` endpoint, not just the three previously annotated handlers. Legacy `/api/*` annotations remain in place until those routes are retired. + ## [1.2.1] — 2026-04-25 ### Security @@ -103,6 +122,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Swagger UI now opens correctly from the admin Tools panel. The short-lived `os_swagger` session cookie was scoped to `/api/admin/docs`, so the browser refused to send it on the v1 docs URL (`/api/v1/admin/docs/index.html`) and the docs route returned `swagger session required`. The cookie path is now `/api`, covering both legacy and v1 docs routes. - Lock the primary admin's Allowed Systems selector in the user editor; the first user always has access to every system and the badges are now read-only with all systems shown as allowed. - Default `audioEncodingPreset` seeded into the settings table is now `mp3_32k` (matching the dropdown's "(default)" label and the Go diff --git a/backend/internal/auth/auth.go b/backend/internal/auth/auth.go index 57ee415..d749e18 100644 --- a/backend/internal/auth/auth.go +++ b/backend/internal/auth/auth.go @@ -411,7 +411,9 @@ func SetSwaggerCookie(c interface { value := fmt.Sprintf("%d.%s", expiry, sig) c.SetSameSite(http.SameSiteStrictMode) - c.SetCookie(SwaggerCookieName, value, maxAge, "/api/admin/docs", "", secure, true) + // Path "/api" so the cookie is sent on both the legacy + // /api/admin/docs/* route and the v1 /api/v1/admin/docs/* route. + c.SetCookie(SwaggerCookieName, value, maxAge, "/api", "", secure, true) } // ValidateSwaggerCookie checks that the swagger cookie value is valid and diff --git a/backend/internal/dirmonitor/watcher.go b/backend/internal/dirmonitor/watcher.go index 0753497..21c9b82 100644 --- a/backend/internal/dirmonitor/watcher.go +++ b/backend/internal/dirmonitor/watcher.go @@ -714,7 +714,8 @@ func (s *Service) ingestCall(ctx context.Context, dw db.Dirmonitor, parsed *Pars if err != nil { slog.Error("dirmonitor: failed to build CAL message", "error", err) } else { - s.hub.BroadcastCAL(calMsg, func(cl *ws.Client) bool { + _ = calMsg + s.hub.BroadcastCAL(calPayload, func(cl *ws.Client) bool { return cl.CanReceive(system.ID, talkgroup.ID) }) } diff --git a/backend/internal/handler/admin/imports/imports.go b/backend/internal/handler/admin/imports/imports.go index e11304b..f96c8b3 100644 --- a/backend/internal/handler/admin/imports/imports.go +++ b/backend/internal/handler/admin/imports/imports.go @@ -114,7 +114,7 @@ func col(record []string, i int) string { // // @Summary Import talkgroups from CSV // @Description Accepts a multipart CSV file with talkgroup data and a system_id form field. Supports OpenScanner format (talkgroup_id, label, name, tag_id, group_id, frequency, led, order) and rdio-scanner format (dec, hex, alpha_tag, description, tag, group, priority). Header rows are auto-detected; tag/group names are resolved to IDs automatically. Use mode=overwrite (default) to update existing talkgroups or mode=skip to leave existing talkgroups unchanged. -// @Tags Admin +// @Tags Admin,v1-Admin // @Accept multipart/form-data // @Produce json // @Param system_id formData int true "System ID to import talkgroups into" @@ -125,6 +125,7 @@ func col(record []string, i int) string { // @Failure 500 {object} shared.ErrorResponse // @Security BearerAuth // @Router /admin/import/talkgroups [post] +// @Router /v1/admin/import/talkgroups [post] func (h *Handler) ImportTalkgroups(c *gin.Context) { ctx := c.Request.Context() @@ -328,7 +329,7 @@ func (h *Handler) ImportTalkgroups(c *gin.Context) { // // @Summary Import units from CSV // @Description Accepts a multipart CSV file with unit data and a system_id form field. Columns: unit_id, label, order. Header rows are auto-skipped. Use mode=overwrite (default) to update existing units or mode=skip to leave existing units unchanged. -// @Tags Admin +// @Tags Admin,v1-Admin // @Accept multipart/form-data // @Produce json // @Param system_id formData int true "System ID to import units into" @@ -339,6 +340,7 @@ func (h *Handler) ImportTalkgroups(c *gin.Context) { // @Failure 500 {object} shared.ErrorResponse // @Security BearerAuth // @Router /admin/import/units [post] +// @Router /v1/admin/import/units [post] func (h *Handler) ImportUnits(c *gin.Context) { ctx := c.Request.Context() @@ -617,7 +619,7 @@ func (h *Handler) importLabelOnly(c *gin.Context, kind string, // // @Summary Import groups from CSV // @Description Accepts a multipart CSV file with a single 'label' column (header optional). Existing labels are skipped; new labels are inserted. -// @Tags Admin +// @Tags Admin,v1-Admin // @Accept multipart/form-data // @Produce json // @Param file formData file true "CSV file" @@ -626,6 +628,7 @@ func (h *Handler) importLabelOnly(c *gin.Context, kind string, // @Failure 500 {object} shared.ErrorResponse // @Security BearerAuth // @Router /admin/import/groups [post] +// @Router /v1/admin/import/groups [post] func (h *Handler) ImportGroups(c *gin.Context) { h.importLabelOnly(c, "groups", func(ctx context.Context, label string) (int64, bool, error) { @@ -649,7 +652,7 @@ func (h *Handler) ImportGroups(c *gin.Context) { // // @Summary Import tags from CSV // @Description Accepts a multipart CSV file with a single 'label' column (header optional). Existing labels are skipped; new labels are inserted. -// @Tags Admin +// @Tags Admin,v1-Admin // @Accept multipart/form-data // @Produce json // @Param file formData file true "CSV file" @@ -658,6 +661,7 @@ func (h *Handler) ImportGroups(c *gin.Context) { // @Failure 500 {object} shared.ErrorResponse // @Security BearerAuth // @Router /admin/import/tags [post] +// @Router /v1/admin/import/tags [post] func (h *Handler) ImportTags(c *gin.Context) { h.importLabelOnly(c, "tags", func(ctx context.Context, label string) (int64, bool, error) { diff --git a/backend/internal/handler/admin/imports/legacy_contract_test.go b/backend/internal/handler/admin/imports/legacy_contract_test.go new file mode 100644 index 0000000..075fe8d --- /dev/null +++ b/backend/internal/handler/admin/imports/legacy_contract_test.go @@ -0,0 +1,200 @@ +package imports_test + +// Phase N-0 — legacy contract freeze for the admin imports package. +// +// Pins today's response shape for the four admin CSV import endpoints: +// POST /api/admin/import/talkgroups +// POST /api/admin/import/units +// POST /api/admin/import/groups +// POST /api/admin/import/tags +// +// Plan reference: docs/plans/native-api-design-plan.md §4.1. + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "mime/multipart" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/gin-gonic/gin" + + "github.com/openscanner/openscanner/internal/audio" + "github.com/openscanner/openscanner/internal/auth" + "github.com/openscanner/openscanner/internal/db" + "github.com/openscanner/openscanner/internal/handler/routes" + "github.com/openscanner/openscanner/internal/logging" + "github.com/openscanner/openscanner/internal/ws" +) + +func init() { + gin.SetMode(gin.TestMode) + logging.Configure(true, "") +} + +func importsFixture(t *testing.T) (*gin.Engine, *db.Queries, string) { + t.Helper() + sqlDB, err := db.Open(":memory:") + if err != nil { + t.Fatalf("db.Open: %v", err) + } + t.Cleanup(func() { _ = sqlDB.Close() }) + q := db.New(sqlDB) + + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + pool := audio.NewWorkerPool(ctx) + proc := audio.NewProcessor(t.TempDir(), pool) + + hub := ws.NewHub(q, "test") + r := gin.New() + rl := auth.NewRateLimiter(context.Background()) + routes.RegisterRoutes(r, routes.Deps{ + Queries: q, RateLimiter: rl, Processor: proc, Hub: hub, SQLDB: sqlDB, Version: "test", + }) + + hash, _ := auth.HashPassword("pw") + now := time.Now().Unix() + uid, err := q.CreateUser(context.Background(), db.CreateUserParams{ + Username: "admin", PasswordHash: hash, Role: auth.RoleAdmin, + CreatedAt: now, UpdatedAt: now, + }) + if err != nil { + t.Fatalf("CreateUser: %v", err) + } + tok, _, err := auth.GenerateToken(uid, "admin", auth.RoleAdmin, 0) + if err != nil { + t.Fatalf("GenerateToken: %v", err) + } + return r, q, tok +} + +func multipartCSV(t *testing.T, fields map[string]string, csvContent string) (*bytes.Buffer, string) { + t.Helper() + var buf bytes.Buffer + w := multipart.NewWriter(&buf) + for k, v := range fields { + if err := w.WriteField(k, v); err != nil { + t.Fatalf("WriteField %q: %v", k, err) + } + } + fw, err := w.CreateFormFile("file", "import.csv") + if err != nil { + t.Fatalf("CreateFormFile: %v", err) + } + if _, err := fw.Write([]byte(csvContent)); err != nil { + t.Fatalf("write csv: %v", err) + } + if err := w.Close(); err != nil { + t.Fatalf("close writer: %v", err) + } + return &buf, w.FormDataContentType() +} + +// TestImportsLegacyContract pins the response envelope for each of the four +// import endpoints. The talkgroups + units endpoints return +// {inserted, updated, skipped, failed[, message]}, while groups + tags +// return {inserted, skipped, failed[, message]} (no `updated` key — they are +// label-only insert-or-skip). +func TestImportsLegacyContract(t *testing.T) { + engine, q, tok := importsFixture(t) + bearer := func(req *http.Request) { req.Header.Set("Authorization", "Bearer "+tok) } + + sysID, err := q.CreateSystem(context.Background(), db.CreateSystemParams{SystemID: 42, Label: "S"}) + if err != nil { + t.Fatalf("CreateSystem: %v", err) + } + + tests := []struct { + name string + path string + fields map[string]string + csv string + wantKeys []string + notWantKeys []string + }{ + { + name: "POST /api/admin/import/talkgroups", + path: "/api/admin/import/talkgroups", + fields: map[string]string{"system_id": fmt.Sprintf("%d", sysID)}, + csv: "talkgroup_id,label,name\n100,Fire,Fire Dispatch\n200,EMS,EMS Dispatch\n", + wantKeys: []string{"inserted", "updated", "skipped", "failed"}, + }, + { + name: "POST /api/admin/import/units", + path: "/api/admin/import/units", + fields: map[string]string{"system_id": fmt.Sprintf("%d", sysID)}, + csv: "unit_id,label\n1001,Alpha\n1002,Beta\n", + wantKeys: []string{"inserted", "updated", "skipped", "failed"}, + }, + { + name: "POST /api/admin/import/groups", + path: "/api/admin/import/groups", + fields: nil, + csv: "label\nPolice\nFire\n", + wantKeys: []string{"inserted", "skipped", "failed"}, + notWantKeys: []string{"updated"}, + }, + { + name: "POST /api/admin/import/tags", + path: "/api/admin/import/tags", + fields: nil, + csv: "label\nDispatch\nTac\n", + wantKeys: []string{"inserted", "skipped", "failed"}, + notWantKeys: []string{"updated"}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + body, ct := multipartCSV(t, tc.fields, tc.csv) + req := httptest.NewRequest(http.MethodPost, tc.path, body) + req.Header.Set("Content-Type", ct) + bearer(req) + w := httptest.NewRecorder() + engine.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String()) + } + + var resp map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v\nbody: %s", err, w.Body.String()) + } + for _, k := range tc.wantKeys { + if _, ok := resp[k]; !ok { + t.Errorf("response missing key %q (got: %s)", k, w.Body.String()) + } + } + for _, k := range tc.notWantKeys { + if _, ok := resp[k]; ok { + t.Errorf("response unexpectedly has key %q (got: %s)", k, w.Body.String()) + } + } + }) + } +} + +// TestImports_RequireAdmin pins the 401/403 gates on the import endpoints. +func TestImports_RequireAdmin(t *testing.T) { + engine, _, _ := importsFixture(t) + + for _, p := range []string{ + "/api/admin/import/talkgroups", + "/api/admin/import/units", + "/api/admin/import/groups", + "/api/admin/import/tags", + } { + req := httptest.NewRequest(http.MethodPost, p, nil) + w := httptest.NewRecorder() + engine.ServeHTTP(w, req) + if w.Code != http.StatusUnauthorized { + t.Errorf("%s no auth: status = %d, want 401", p, w.Code) + } + } +} diff --git a/backend/internal/handler/admin/legacyusage/handler.go b/backend/internal/handler/admin/legacyusage/handler.go new file mode 100644 index 0000000..bf884cf --- /dev/null +++ b/backend/internal/handler/admin/legacyusage/handler.go @@ -0,0 +1,78 @@ +// Package legacyusage exposes the 24-hour legacy /api/* hit aggregate to +// admins. Backed by the in-memory ring buffer in +// middleware.DefaultLegacyUsageStore — see Phase N-3 in +// docs/plans/native-api-design-plan.md. +package legacyusage + +import ( + "net/http" + "time" + + "github.com/gin-gonic/gin" + + "github.com/openscanner/openscanner/internal/middleware" +) + +// LegacyUsageEntry mirrors middleware.LegacyUsageEntry for swagger generation. +type LegacyUsageEntry struct { + Path string `json:"path"` + Method string `json:"method"` + APIKeyIdent string `json:"apiKeyIdent"` + Count int `json:"count"` + LastSeen time.Time `json:"lastSeen"` +} // @name LegacyUsageEntry + +// LegacyUsageResponse is the v1 admin envelope. +type LegacyUsageResponse struct { + WindowSeconds int `json:"windowSeconds"` + GeneratedAt time.Time `json:"generatedAt"` + Entries []LegacyUsageEntry `json:"entries"` +} // @name LegacyUsageResponse + +// Handler serves the legacy-usage report. +type Handler struct { + store *middleware.LegacyUsageStore + now func() time.Time +} + +// New constructs a Handler. store may be nil to use the package singleton; +// now may be nil to use time.Now. +func New(store *middleware.LegacyUsageStore, now func() time.Time) *Handler { + if store == nil { + store = middleware.DefaultLegacyUsageStore + } + if now == nil { + now = time.Now + } + return &Handler{store: store, now: now} +} + +// GetUsage handles GET /api/v1/admin/legacy-usage. +// +// @Summary Legacy /api/* usage report (24h) +// @Description Returns one entry per (path, method, apiKeyIdent) tuple seen on the legacy /api/* surface in the last 24 hours, sourced from an in-memory ring buffer. Used by the admin dashboard to surface clients that still need to migrate to /api/v1. +// @Tags v1-Admin +// @Produce json +// @Security BearerAuth +// @Success 200 {object} LegacyUsageResponse +// @Failure 401 {object} shared.APIErrorResponse +// @Failure 403 {object} shared.APIErrorResponse +// @Router /v1/admin/legacy-usage [get] +func (h *Handler) GetUsage(c *gin.Context) { + raw := h.store.Aggregate24h() + entries := make([]LegacyUsageEntry, len(raw)) + for i, e := range raw { + entries[i] = LegacyUsageEntry{ + Path: e.Path, + Method: e.Method, + APIKeyIdent: e.APIKeyIdent, + Count: e.Count, + LastSeen: e.LastSeen.UTC(), + } + } + c.JSON(http.StatusOK, LegacyUsageResponse{ + WindowSeconds: int((24 * time.Hour).Seconds()), + GeneratedAt: h.now().UTC(), + Entries: entries, + }) +} diff --git a/backend/internal/handler/admin/legacyusage/handler_test.go b/backend/internal/handler/admin/legacyusage/handler_test.go new file mode 100644 index 0000000..f4ea509 --- /dev/null +++ b/backend/internal/handler/admin/legacyusage/handler_test.go @@ -0,0 +1,184 @@ +package legacyusage_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/gin-gonic/gin" + + "github.com/openscanner/openscanner/internal/auth" + "github.com/openscanner/openscanner/internal/db" + "github.com/openscanner/openscanner/internal/handler/routes" + "github.com/openscanner/openscanner/internal/logging" + "github.com/openscanner/openscanner/internal/middleware" + _ "modernc.org/sqlite" +) + +func init() { + gin.SetMode(gin.TestMode) + logging.Configure(true, "") +} + +func newEngine(t *testing.T) (*gin.Engine, *db.Queries) { + t.Helper() + sqlDB, err := db.Open(":memory:") + if err != nil { + t.Fatalf("db.Open: %v", err) + } + t.Cleanup(func() { _ = sqlDB.Close() }) + q := db.New(sqlDB) + + r := gin.New() + rl := auth.NewRateLimiter(context.Background()) + routes.RegisterRoutes(r, routes.Deps{Queries: q, RateLimiter: rl, Version: "test"}) + return r, q +} + +func makeUser(t *testing.T, q *db.Queries, role string) (int64, string) { + t.Helper() + hash, _ := auth.HashPassword("pw") + now := time.Now().Unix() + uid, err := q.CreateUser(context.Background(), db.CreateUserParams{ + Username: "u-" + role, PasswordHash: hash, Role: role, + CreatedAt: now, UpdatedAt: now, + }) + if err != nil { + t.Fatalf("CreateUser: %v", err) + } + tok, _, err := auth.GenerateToken(uid, "u-"+role, role, 0) + if err != nil { + t.Fatalf("GenerateToken: %v", err) + } + return uid, tok +} + +// TestGetLegacyUsage_EmptyReturnsEmptyArray asserts the entries field +// serialises as `[]`, not `null`, when the ring buffer has no records. +func TestGetLegacyUsage_EmptyReturnsEmptyArray(t *testing.T) { + // Reset the singleton so this test sees no leakage. + middleware.DefaultLegacyUsageStore = middleware.NewLegacyUsageStore(nil) + + r, q := newEngine(t) + _, tok := makeUser(t, q, auth.RoleAdmin) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/legacy-usage", nil) + req.Header.Set("Authorization", "Bearer "+tok) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", w.Code, w.Body.String()) + } + body := w.Body.String() + if !contains(body, `"entries":[]`) { + t.Errorf("expected entries:[] in body, got: %s", body) + } + if !contains(body, `"windowSeconds":86400`) { + t.Errorf("expected windowSeconds:86400, got: %s", body) + } +} + +// TestGetLegacyUsage_PopulatedAfterLegacyHit ingests one hit on a legacy +// route and verifies it appears in the v1 aggregate report. +func TestGetLegacyUsage_PopulatedAfterLegacyHit(t *testing.T) { + middleware.DefaultLegacyUsageStore = middleware.NewLegacyUsageStore(nil) + + r, q := newEngine(t) + _, tok := makeUser(t, q, auth.RoleAdmin) + + // Hit a public legacy route — /api/health is unauthenticated. + for i := 0; i < 2; i++ { + hreq := httptest.NewRequest(http.MethodGet, "/api/health", nil) + hw := httptest.NewRecorder() + r.ServeHTTP(hw, hreq) + } + + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/legacy-usage", nil) + req.Header.Set("Authorization", "Bearer "+tok) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", w.Code, w.Body.String()) + } + var resp struct { + WindowSeconds int `json:"windowSeconds"` + Entries []struct { + Path string `json:"path"` + Method string `json:"method"` + APIKeyIdent string `json:"apiKeyIdent"` + Count int `json:"count"` + } `json:"entries"` + } + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v; body=%s", err, w.Body.String()) + } + if resp.WindowSeconds != 86400 { + t.Errorf("windowSeconds = %d, want 86400", resp.WindowSeconds) + } + + // Find the /api/health row. + var found bool + for _, e := range resp.Entries { + if e.Path == "/api/health" && e.Method == http.MethodGet { + found = true + if e.Count != 2 { + t.Errorf("count for /api/health = %d, want 2", e.Count) + } + if e.APIKeyIdent != "" { + t.Errorf("apiKeyIdent for unauth /api/health = %q, want \"\"", e.APIKeyIdent) + } + } + } + if !found { + t.Errorf("expected /api/health entry in aggregate, got: %+v", resp.Entries) + } +} + +// TestGetLegacyUsage_ListenerForbidden — JWT must have admin role. +func TestGetLegacyUsage_ListenerForbidden(t *testing.T) { + middleware.DefaultLegacyUsageStore = middleware.NewLegacyUsageStore(nil) + + r, q := newEngine(t) + _, tok := makeUser(t, q, auth.RoleListener) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/legacy-usage", nil) + req.Header.Set("Authorization", "Bearer "+tok) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403; body = %s", w.Code, w.Body.String()) + } +} + +// TestGetLegacyUsage_Unauthenticated — without a JWT, returns 401. +func TestGetLegacyUsage_Unauthenticated(t *testing.T) { + middleware.DefaultLegacyUsageStore = middleware.NewLegacyUsageStore(nil) + + r, _ := newEngine(t) + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/legacy-usage", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401; body = %s", w.Code, w.Body.String()) + } +} + +func contains(haystack, needle string) bool { + return len(haystack) >= len(needle) && (indexOf(haystack, needle) >= 0) +} + +func indexOf(haystack, needle string) int { + for i := 0; i+len(needle) <= len(haystack); i++ { + if haystack[i:i+len(needle)] == needle { + return i + } + } + return -1 +} diff --git a/backend/internal/handler/admin/radioreference/legacy_contract_test.go b/backend/internal/handler/admin/radioreference/legacy_contract_test.go new file mode 100644 index 0000000..7516f4f --- /dev/null +++ b/backend/internal/handler/admin/radioreference/legacy_contract_test.go @@ -0,0 +1,99 @@ +package radioreference_test + +// Phase N-0 — legacy contract freeze for the admin/radioreference handler package. +// +// Pins POST /api/admin/radioreference/preview/csv response envelope. +// Plan reference: docs/plans/native-api-design-plan.md §4.1. + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "mime/multipart" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/gin-gonic/gin" + + "github.com/openscanner/openscanner/internal/audio" + "github.com/openscanner/openscanner/internal/auth" + "github.com/openscanner/openscanner/internal/db" + "github.com/openscanner/openscanner/internal/handler/routes" + "github.com/openscanner/openscanner/internal/logging" +) + +func init() { + gin.SetMode(gin.TestMode) + logging.Configure(true, "") +} + +// TestPreviewCSV_LegacyResponseShape pins the keys returned by the +// RadioReference CSV preview endpoint. +func TestPreviewCSV_LegacyResponseShape(t *testing.T) { + sqlDB, err := db.Open(":memory:") + if err != nil { + t.Fatalf("db.Open: %v", err) + } + t.Cleanup(func() { _ = sqlDB.Close() }) + q := db.New(sqlDB) + + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + pool := audio.NewWorkerPool(ctx) + proc := audio.NewProcessor(t.TempDir(), pool) + + r := gin.New() + rl := auth.NewRateLimiter(context.Background()) + routes.RegisterRoutes(r, routes.Deps{Queries: q, RateLimiter: rl, Processor: proc, Version: "test"}) + + hash, _ := auth.HashPassword("pw") + now := time.Now().Unix() + uid, err := q.CreateUser(context.Background(), db.CreateUserParams{ + Username: "admin", PasswordHash: hash, Role: auth.RoleAdmin, + CreatedAt: now, UpdatedAt: now, + }) + if err != nil { + t.Fatalf("CreateUser: %v", err) + } + tok, _, err := auth.GenerateToken(uid, "admin", auth.RoleAdmin, 0) + if err != nil { + t.Fatalf("GenerateToken: %v", err) + } + + sysID, err := q.CreateSystem(context.Background(), db.CreateSystemParams{SystemID: 1, Label: "S"}) + if err != nil { + t.Fatalf("CreateSystem: %v", err) + } + + csv := "Decimal,Alpha Tag,Description,Category,Tag\n100,Fire,Fire Dispatch,Public Safety,Dispatch\n" + var buf bytes.Buffer + w := multipart.NewWriter(&buf) + _ = w.WriteField("system_id", fmt.Sprintf("%d", sysID)) + fw, _ := w.CreateFormFile("file", "rr.csv") + _, _ = fw.Write([]byte(csv)) + _ = w.Close() + + req := httptest.NewRequest(http.MethodPost, "/api/admin/radioreference/preview/csv", &buf) + req.Header.Set("Content-Type", w.FormDataContentType()) + req.Header.Set("Authorization", "Bearer "+tok) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body: %s", rec.Code, rec.Body.String()) + } + var resp map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v\nbody: %s", err, rec.Body.String()) + } + for _, k := range []string{ + "processed", "matched", "wouldUpdate", "skipped", "errors", "rowErrors", "rows", + } { + if _, ok := resp[k]; !ok { + t.Errorf("missing key %q in preview response (got: %s)", k, rec.Body.String()) + } + } +} diff --git a/backend/internal/handler/admin/radioreference/radioreference.go b/backend/internal/handler/admin/radioreference/radioreference.go index 3f5b1ae..b510446 100644 --- a/backend/internal/handler/admin/radioreference/radioreference.go +++ b/backend/internal/handler/admin/radioreference/radioreference.go @@ -71,7 +71,7 @@ func New(queries *db.Queries) *Handler { // // @Summary Preview RadioReference CSV enrichment // @Description Upload a RadioReference CSV export and preview which local talkgroups would be enriched. Frequency is never updated. Columns: talkgroup id (decimal/tgid), alpha tag, description, group/category, tag/service type, led, order. -// @Tags Admin - RadioReference +// @Tags Admin - RadioReference,v1-Admin // @Accept multipart/form-data // @Produce json // @Param system_id formData int true "Local system ID to match talkgroups against" @@ -80,6 +80,7 @@ func New(queries *db.Queries) *Handler { // @Failure 400 {object} shared.ErrorResponse // @Security BearerAuth // @Router /admin/radioreference/preview/csv [post] +// @Router /v1/admin/radioreference/preview [post] func (h *Handler) PreviewCSV(c *gin.Context) { ctx := c.Request.Context() diff --git a/backend/internal/handler/admin/transcriptions/legacy_contract_test.go b/backend/internal/handler/admin/transcriptions/legacy_contract_test.go new file mode 100644 index 0000000..77adf53 --- /dev/null +++ b/backend/internal/handler/admin/transcriptions/legacy_contract_test.go @@ -0,0 +1,110 @@ +package transcriptions_test + +// Phase N-0 — legacy contract freeze for the admin/transcriptions handler package. +// +// Pins GET /api/admin/transcriptions/status response envelope: +// {enabled, url, model, diarize, totalTranscriptions, whisperAvailable}. +// Plan reference: docs/plans/native-api-design-plan.md §4.1. + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/gin-gonic/gin" + + "github.com/openscanner/openscanner/internal/audio" + "github.com/openscanner/openscanner/internal/auth" + "github.com/openscanner/openscanner/internal/db" + "github.com/openscanner/openscanner/internal/handler/routes" + "github.com/openscanner/openscanner/internal/logging" +) + +func init() { + gin.SetMode(gin.TestMode) + logging.Configure(true, "") +} + +func TestGetTranscriptionStatus_LegacyResponseShape(t *testing.T) { + sqlDB, err := db.Open(":memory:") + if err != nil { + t.Fatalf("db.Open: %v", err) + } + t.Cleanup(func() { _ = sqlDB.Close() }) + q := db.New(sqlDB) + + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + pool := audio.NewWorkerPool(ctx) + proc := audio.NewProcessor(t.TempDir(), pool) + + r := gin.New() + rl := auth.NewRateLimiter(context.Background()) + routes.RegisterRoutes(r, routes.Deps{Queries: q, RateLimiter: rl, Processor: proc, Version: "test"}) + + hash, _ := auth.HashPassword("pw") + now := time.Now().Unix() + uid, err := q.CreateUser(context.Background(), db.CreateUserParams{ + Username: "admin", PasswordHash: hash, Role: auth.RoleAdmin, + CreatedAt: now, UpdatedAt: now, + }) + if err != nil { + t.Fatalf("CreateUser: %v", err) + } + tok, _, err := auth.GenerateToken(uid, "admin", auth.RoleAdmin, 0) + if err != nil { + t.Fatalf("GenerateToken: %v", err) + } + + // Seed the transcription settings. + for k, v := range map[string]string{ + "transcriptionEnabled": "true", + "transcriptionUrl": "http://whisper:9000", + "transcriptionModel": "tiny.en", + "transcriptionDiarize": "false", + } { + if err := q.UpsertSetting(context.Background(), db.UpsertSettingParams{Key: k, Value: v}); err != nil { + t.Fatalf("UpsertSetting %q: %v", k, err) + } + } + + req := httptest.NewRequest(http.MethodGet, "/api/admin/transcriptions/status", nil) + req.Header.Set("Authorization", "Bearer "+tok) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String()) + } + + var resp struct { + Enabled bool `json:"enabled"` + URL string `json:"url"` + Model string `json:"model"` + Diarize bool `json:"diarize"` + TotalTranscriptions int64 `json:"totalTranscriptions"` + WhisperAvailable bool `json:"whisperAvailable"` + } + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v\nbody: %s", err, w.Body.String()) + } + + if !resp.Enabled || resp.URL != "http://whisper:9000" || resp.Model != "tiny.en" || resp.Diarize { + t.Errorf("status = %+v, want enabled=true url=http://whisper:9000 model=tiny.en diarize=false", resp) + } + + // Also verify all expected keys are present in the marshalled output + // (struct tag drift would change wire shape silently). + var raw map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &raw); err != nil { + t.Fatalf("raw decode: %v", err) + } + for _, k := range []string{"enabled", "url", "model", "diarize", "totalTranscriptions", "whisperAvailable"} { + if _, ok := raw[k]; !ok { + t.Errorf("response missing key %q (got: %s)", k, w.Body.String()) + } + } +} diff --git a/backend/internal/handler/admin/transcriptions/transcriptions.go b/backend/internal/handler/admin/transcriptions/transcriptions.go index 47a3949..560a8f0 100644 --- a/backend/internal/handler/admin/transcriptions/transcriptions.go +++ b/backend/internal/handler/admin/transcriptions/transcriptions.go @@ -34,12 +34,13 @@ func New(queries *db.Queries, whisperAvailable bool) *Handler { // // @Summary Get transcription status // @Description Returns transcription settings, total count, and whisper availability. -// @Tags Admin +// @Tags Admin,v1-Admin // @Produce json // @Security BearerAuth // @Success 200 {object} transcriptionStatusResponse // @Failure 500 {object} shared.ErrorResponse // @Router /admin/transcriptions/status [get] +// @Router /v1/admin/transcriptions/status [get] func (h *Handler) GetStatus(c *gin.Context) { ctx := c.Request.Context() diff --git a/backend/internal/handler/auth/auth.go b/backend/internal/handler/auth/auth.go index 9758dd2..a56e458 100644 --- a/backend/internal/handler/auth/auth.go +++ b/backend/internal/handler/auth/auth.go @@ -61,7 +61,7 @@ type loginResponse struct { // // @Summary Log in // @Description Authenticate with username and password, returns a JWT token. -// @Tags Auth +// @Tags Auth,v1-Auth // @Accept json // @Produce json // @Param body body loginRequest true "Login credentials" @@ -71,6 +71,7 @@ type loginResponse struct { // @Failure 429 {object} ErrorResponse // @Failure 500 {object} ErrorResponse // @Router /auth/login [post] +// @Router /v1/auth/login [post] func (h *Handler) PostLogin(c *gin.Context) { ip := c.ClientIP() @@ -218,13 +219,14 @@ func (h *Handler) logAuthEvent(ctx context.Context, level, message, ip string) { // // @Summary Log out // @Description Revoke the current JWT token. -// @Tags Auth +// @Tags Auth,v1-Auth // @Produce json // @Security BearerAuth // @Success 200 {object} object{ok=bool} // @Failure 401 {object} ErrorResponse // @Failure 500 {object} ErrorResponse // @Router /auth/logout [post] +// @Router /v1/auth/logout [post] func (h *Handler) PostLogout(c *gin.Context) { if jtiVal, ok := c.Get("jti"); ok { if jti, ok := jtiVal.(string); ok { @@ -259,12 +261,13 @@ type refreshResponse struct { // // @Summary Refresh access token // @Description Exchange a valid refresh token cookie for a new access token and rotated refresh token. -// @Tags Auth +// @Tags Auth,v1-Auth // @Produce json // @Success 200 {object} refreshResponse // @Failure 401 {object} ErrorResponse // @Failure 500 {object} ErrorResponse // @Router /auth/refresh [post] +// @Router /v1/auth/refresh [post] func (h *Handler) PostRefresh(c *gin.Context) { rawToken, err := c.Cookie(auth.RefreshCookieName) if err != nil || rawToken == "" { @@ -380,7 +383,7 @@ type changePasswordRequest struct { // // @Summary Change password // @Description Change the current user's password. Requires the current password for verification. -// @Tags Auth +// @Tags Auth,v1-Auth // @Accept json // @Produce json // @Security BearerAuth @@ -390,6 +393,7 @@ type changePasswordRequest struct { // @Failure 401 {object} ErrorResponse // @Failure 500 {object} ErrorResponse // @Router /auth/password [put] +// @Router /v1/auth/password [put] func (h *Handler) PutPassword(c *gin.Context) { userIDVal, _ := c.Get("userID") userID, _ := userIDVal.(int64) @@ -459,13 +463,14 @@ func (h *Handler) PutPassword(c *gin.Context) { // // @Summary Current user // @Description Return the authenticated user's profile. -// @Tags Auth +// @Tags Auth,v1-Auth // @Produce json // @Security BearerAuth // @Success 200 {object} loginUserResponse // @Failure 401 {object} ErrorResponse // @Failure 500 {object} ErrorResponse // @Router /auth/me [get] +// @Router /v1/auth/me [get] func (h *Handler) GetMe(c *gin.Context) { userID, _ := c.Get("userID") username, _ := c.Get("username") @@ -502,13 +507,14 @@ type avoidTGEntry struct { // // @Summary Get talkgroup selection // @Description Return the authenticated user's disabled talkgroup IDs. -// @Tags Auth +// @Tags Auth,v1-Listener // @Produce json // @Security BearerAuth // @Success 200 {object} tgSelectionResponse // @Failure 401 {object} ErrorResponse // @Failure 500 {object} ErrorResponse // @Router /auth/tg-selection [get] +// @Router /v1/listener/tg-selection [get] func (h *Handler) GetTGSelection(c *gin.Context) { userIDVal, _ := c.Get("userID") userID, _ := userIDVal.(int64) @@ -551,7 +557,7 @@ func (h *Handler) GetTGSelection(c *gin.Context) { // // @Summary Update talkgroup selection // @Description Save the authenticated user's disabled talkgroup IDs. -// @Tags Auth +// @Tags Auth,v1-Listener // @Accept json // @Produce json // @Security BearerAuth @@ -561,6 +567,7 @@ func (h *Handler) GetTGSelection(c *gin.Context) { // @Failure 401 {object} ErrorResponse // @Failure 500 {object} ErrorResponse // @Router /auth/tg-selection [put] +// @Router /v1/listener/tg-selection [put] func (h *Handler) PutTGSelection(c *gin.Context) { userIDVal, _ := c.Get("userID") userID, _ := userIDVal.(int64) @@ -603,11 +610,12 @@ func (h *Handler) PutTGSelection(c *gin.Context) { // // @Summary Create Swagger docs session cookie // @Description Issues a short-lived HTTP-only cookie used to access /api/admin/docs. -// @Tags Admin +// @Tags Admin,v1-Admin // @Produce json // @Success 200 {object} object{ok=bool} // @Security BearerAuth // @Router /admin/docs/session [post] +// @Router /v1/admin/docs/session [post] func PostDocsSession(c *gin.Context) { secure := c.Request.TLS != nil || c.GetHeader("X-Forwarded-Proto") == "https" auth.SetSwaggerCookie(c, secure) diff --git a/backend/internal/handler/auth/legacy_contract_test.go b/backend/internal/handler/auth/legacy_contract_test.go new file mode 100644 index 0000000..1c77a26 --- /dev/null +++ b/backend/internal/handler/auth/legacy_contract_test.go @@ -0,0 +1,348 @@ +package auth_test + +// Phase N-0 — legacy contract freeze for the auth handler package (REST endpoints). +// +// Pins today's wire format for /api/auth/login, /api/auth/refresh, +// /api/auth/logout, /api/auth/password, /api/auth/me, /api/auth/tg-selection +// (GET + PUT) and the os_session cookie issuance / clearance behaviour the +// frontend relies on. Plan reference: +// docs/plans/native-api-design-plan.md §4.1 (auth row group). + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/gin-gonic/gin" + + "github.com/openscanner/openscanner/internal/audio" + pkgauth "github.com/openscanner/openscanner/internal/auth" + "github.com/openscanner/openscanner/internal/db" + "github.com/openscanner/openscanner/internal/handler/routes" + "github.com/openscanner/openscanner/internal/logging" +) + +func init() { + gin.SetMode(gin.TestMode) + logging.Configure(true, "") +} + +func authFixture(t *testing.T) (*gin.Engine, *db.Queries) { + t.Helper() + sqlDB, err := db.Open(":memory:") + if err != nil { + t.Fatalf("db.Open: %v", err) + } + t.Cleanup(func() { _ = sqlDB.Close() }) + q := db.New(sqlDB) + + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + pool := audio.NewWorkerPool(ctx) + proc := audio.NewProcessor(t.TempDir(), pool) + + r := gin.New() + rl := pkgauth.NewRateLimiter(context.Background()) + routes.RegisterRoutes(r, routes.Deps{Queries: q, RateLimiter: rl, Processor: proc, Version: "test"}) + + hash, _ := pkgauth.HashPassword("password123") + now := time.Now().Unix() + if _, err := q.CreateUser(context.Background(), db.CreateUserParams{ + Username: "alice", PasswordHash: hash, Role: pkgauth.RoleAdmin, + CreatedAt: now, UpdatedAt: now, + }); err != nil { + t.Fatalf("CreateUser: %v", err) + } + return r, q +} + +func loginAlice(t *testing.T, engine http.Handler) *httptest.ResponseRecorder { + t.Helper() + body, _ := json.Marshal(map[string]string{"username": "alice", "password": "password123"}) + req := httptest.NewRequest(http.MethodPost, "/api/auth/login", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + engine.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("login: status = %d, want 200; body: %s", w.Code, w.Body.String()) + } + return w +} + +func cookieByName(cs []*http.Cookie, name string) *http.Cookie { + for _, c := range cs { + if c.Name == name { + return c + } + } + return nil +} + +// TestPostLogin_LegacyResponseShape pins the JSON envelope of POST +// /api/auth/login: {token, user{id,username,role}, passwordNeedChange} and +// the os_session + refresh_token cookies. +func TestPostLogin_LegacyResponseShape(t *testing.T) { + engine, _ := authFixture(t) + w := loginAlice(t, engine) + + var resp struct { + Token string `json:"token"` + User struct { + ID int64 `json:"id"` + Username string `json:"username"` + Role string `json:"role"` + } `json:"user"` + PasswordNeedChange bool `json:"passwordNeedChange"` + } + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v\nbody: %s", err, w.Body.String()) + } + if resp.Token == "" { + t.Error("token: empty") + } + if resp.User.Username != "alice" || resp.User.Role != pkgauth.RoleAdmin || resp.User.ID <= 0 { + t.Errorf("user = %+v, want {id>0,alice,admin}", resp.User) + } + + cookies := w.Result().Cookies() + if c := cookieByName(cookies, pkgauth.SessionCookieName); c == nil || c.Value != resp.Token { + t.Errorf("os_session cookie: %v, want value=%q", c, resp.Token) + } + if c := cookieByName(cookies, pkgauth.RefreshCookieName); c == nil || c.Value == "" { + t.Errorf("refresh_token cookie missing or empty: %v", c) + } +} + +// TestPostLogin_InvalidCreds_LegacyShape pins the 401 response body. +func TestPostLogin_InvalidCreds_LegacyShape(t *testing.T) { + engine, _ := authFixture(t) + body, _ := json.Marshal(map[string]string{"username": "alice", "password": "WRONG"}) + req := httptest.NewRequest(http.MethodPost, "/api/auth/login", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + engine.ServeHTTP(w, req) + if w.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", w.Code) + } + var env map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &env); err != nil { + t.Fatalf("decode: %v", err) + } + if _, ok := env["error"]; !ok { + t.Errorf("missing 'error' key in 401 body: %s", w.Body.String()) + } +} + +// TestPostRefresh_LegacyResponseShape pins {token, user{...}} and rotated +// cookies on POST /api/auth/refresh. +func TestPostRefresh_LegacyResponseShape(t *testing.T) { + engine, _ := authFixture(t) + loginW := loginAlice(t, engine) + refreshCk := cookieByName(loginW.Result().Cookies(), pkgauth.RefreshCookieName) + if refreshCk == nil { + t.Fatal("login did not set refresh_token cookie") + } + + req := httptest.NewRequest(http.MethodPost, "/api/auth/refresh", nil) + req.AddCookie(refreshCk) + w := httptest.NewRecorder() + engine.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String()) + } + var resp struct { + Token string `json:"token"` + User struct { + ID int64 `json:"id"` + Username string `json:"username"` + Role string `json:"role"` + } `json:"user"` + } + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v", err) + } + if resp.Token == "" || resp.User.Username != "alice" { + t.Errorf("refresh resp = %+v, want token!=\"\" + user.alice", resp) + } + if c := cookieByName(w.Result().Cookies(), pkgauth.RefreshCookieName); c == nil || c.Value == "" { + t.Error("refresh: did not rotate refresh_token cookie") + } + if c := cookieByName(w.Result().Cookies(), pkgauth.SessionCookieName); c == nil || c.Value == "" { + t.Error("refresh: did not rotate os_session cookie") + } +} + +// TestGetMe_LegacyShape pins {id, username, role} on GET /api/auth/me. +func TestGetMe_LegacyShape(t *testing.T) { + engine, _ := authFixture(t) + loginW := loginAlice(t, engine) + var l struct { + Token string `json:"token"` + } + _ = json.Unmarshal(loginW.Body.Bytes(), &l) + + req := httptest.NewRequest(http.MethodGet, "/api/auth/me", nil) + req.Header.Set("Authorization", "Bearer "+l.Token) + w := httptest.NewRecorder() + engine.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", w.Code) + } + var resp struct { + ID int64 `json:"id"` + Username string `json:"username"` + Role string `json:"role"` + } + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v", err) + } + if resp.Username != "alice" || resp.Role != pkgauth.RoleAdmin || resp.ID <= 0 { + t.Errorf("me = %+v, want id>0,alice,admin", resp) + } +} + +// TestPostLogout_LegacyShape pins the {ok:true} body and that both auth +// cookies are cleared (Max-Age <= 0, empty value). +func TestPostLogout_LegacyShape(t *testing.T) { + engine, _ := authFixture(t) + loginW := loginAlice(t, engine) + var l struct { + Token string `json:"token"` + } + _ = json.Unmarshal(loginW.Body.Bytes(), &l) + + req := httptest.NewRequest(http.MethodPost, "/api/auth/logout", nil) + req.Header.Set("Authorization", "Bearer "+l.Token) + w := httptest.NewRecorder() + engine.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", w.Code) + } + var resp map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v", err) + } + if ok, _ := resp["ok"].(bool); !ok { + t.Errorf("body = %v, want ok:true", resp) + } + for _, name := range []string{pkgauth.SessionCookieName, pkgauth.RefreshCookieName} { + c := cookieByName(w.Result().Cookies(), name) + if c == nil { + t.Errorf("%s: cookie not cleared (missing Set-Cookie)", name) + continue + } + if c.Value != "" || c.MaxAge > 0 { + t.Errorf("%s: not cleared, value=%q maxAge=%d", name, c.Value, c.MaxAge) + } + } +} + +// TestPutPassword_LegacyShape pins {ok:true} on PUT /api/auth/password. +func TestPutPassword_LegacyShape(t *testing.T) { + engine, _ := authFixture(t) + loginW := loginAlice(t, engine) + var l struct { + Token string `json:"token"` + } + _ = json.Unmarshal(loginW.Body.Bytes(), &l) + + body, _ := json.Marshal(map[string]string{ + "currentPassword": "password123", + "newPassword": "newpassword123", + }) + req := httptest.NewRequest(http.MethodPut, "/api/auth/password", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+l.Token) + w := httptest.NewRecorder() + engine.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String()) + } + var resp map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v", err) + } + if ok, _ := resp["ok"].(bool); !ok { + t.Errorf("body = %v, want ok:true", resp) + } +} + +// TestTGSelection_LegacyRoundTrip pins the GET / PUT /api/auth/tg-selection +// envelope: {disabledTGs:int64[], avoidList:[{talkgroupId,expiresAt}]}. +func TestTGSelection_LegacyRoundTrip(t *testing.T) { + engine, _ := authFixture(t) + loginW := loginAlice(t, engine) + var l struct { + Token string `json:"token"` + } + _ = json.Unmarshal(loginW.Body.Bytes(), &l) + bearer := "Bearer " + l.Token + + // GET — empty default. + req := httptest.NewRequest(http.MethodGet, "/api/auth/tg-selection", nil) + req.Header.Set("Authorization", bearer) + w := httptest.NewRecorder() + engine.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("GET: status = %d, want 200", w.Code) + } + var initial struct { + DisabledTGs []int64 `json:"disabledTGs"` + AvoidList []map[string]any `json:"avoidList"` + } + if err := json.Unmarshal(w.Body.Bytes(), &initial); err != nil { + t.Fatalf("GET decode: %v\nbody: %s", err, w.Body.String()) + } + if initial.DisabledTGs == nil || initial.AvoidList == nil { + t.Errorf("GET defaults must be empty arrays not null: got %+v", initial) + } + + // PUT — round-trip. + put, _ := json.Marshal(map[string]any{ + "disabledTGs": []int64{1, 2, 3}, + "avoidList": []map[string]any{ + {"talkgroupId": 99, "expiresAt": int64(1700000000)}, + }, + }) + req = httptest.NewRequest(http.MethodPut, "/api/auth/tg-selection", bytes.NewReader(put)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", bearer) + w = httptest.NewRecorder() + engine.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("PUT: status = %d, want 200; body: %s", w.Code, w.Body.String()) + } + var ok map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &ok); err != nil { + t.Fatalf("PUT decode: %v", err) + } + if v, _ := ok["ok"].(bool); !v { + t.Errorf("PUT body = %v, want ok:true", ok) + } + + // GET again — verify persistence. + req = httptest.NewRequest(http.MethodGet, "/api/auth/tg-selection", nil) + req.Header.Set("Authorization", bearer) + w = httptest.NewRecorder() + engine.ServeHTTP(w, req) + var final struct { + DisabledTGs []int64 `json:"disabledTGs"` + AvoidList []struct { + TalkgroupID int64 `json:"talkgroupId"` + ExpiresAt int64 `json:"expiresAt"` + } `json:"avoidList"` + } + if err := json.Unmarshal(w.Body.Bytes(), &final); err != nil { + t.Fatalf("GET2 decode: %v", err) + } + if len(final.DisabledTGs) != 3 || final.DisabledTGs[0] != 1 { + t.Errorf("disabledTGs = %v, want [1 2 3]", final.DisabledTGs) + } + if len(final.AvoidList) != 1 || final.AvoidList[0].TalkgroupID != 99 || final.AvoidList[0].ExpiresAt != 1700000000 { + t.Errorf("avoidList = %+v, want [{99,1700000000}]", final.AvoidList) + } +} diff --git a/backend/internal/handler/bookmarks/bookmarks.go b/backend/internal/handler/bookmarks/bookmarks.go index 0f4056f..cfcbec5 100644 --- a/backend/internal/handler/bookmarks/bookmarks.go +++ b/backend/internal/handler/bookmarks/bookmarks.go @@ -49,7 +49,7 @@ type BookmarkCallsResponse struct { // // @Summary Toggle bookmark on a call // @Description Creates a bookmark if one does not exist for the given call and user, or removes it if it already exists. -// @Tags Bookmarks +// @Tags Bookmarks,v1-Calls // @Accept json // @Produce json // @Security BearerAuth @@ -59,6 +59,7 @@ type BookmarkCallsResponse struct { // @Failure 401 {object} ErrorResponse "Authentication required" // @Failure 500 {object} ErrorResponse "Internal server error" // @Router /bookmarks [post] +// @Router /v1/bookmarks [post] func (h *Handler) PostToggleBookmark(c *gin.Context) { var req struct { CallID int64 `json:"callId"` @@ -145,13 +146,14 @@ func (h *Handler) PostToggleBookmark(c *gin.Context) { // // @Summary List bookmarked call IDs // @Description Returns an array of call IDs that the authenticated user has bookmarked. -// @Tags Bookmarks +// @Tags Bookmarks,v1-Calls // @Produce json // @Security BearerAuth // @Success 200 {object} BookmarkIDsResponse // @Failure 401 {object} ErrorResponse "Authentication required" // @Failure 500 {object} ErrorResponse "Internal server error" // @Router /bookmarks [get] +// @Router /v1/bookmarks [get] func (h *Handler) GetBookmarkIDs(c *gin.Context) { uid, _ := c.Get("userID") userID := uid.(int64) @@ -171,13 +173,14 @@ func (h *Handler) GetBookmarkIDs(c *gin.Context) { // // @Summary List bookmarked calls with metadata // @Description Returns full call details for all calls bookmarked by the authenticated user. -// @Tags Bookmarks +// @Tags Bookmarks,v1-Calls // @Produce json // @Security BearerAuth // @Success 200 {object} BookmarkCallsResponse // @Failure 401 {object} ErrorResponse "Authentication required" // @Failure 500 {object} ErrorResponse "Internal server error" // @Router /bookmarks/calls [get] +// @Router /v1/bookmarks/calls [get] func (h *Handler) GetBookmarkCalls(c *gin.Context) { uid, _ := c.Get("userID") userID := uid.(int64) diff --git a/backend/internal/handler/bookmarks/legacy_contract_test.go b/backend/internal/handler/bookmarks/legacy_contract_test.go new file mode 100644 index 0000000..5f10603 --- /dev/null +++ b/backend/internal/handler/bookmarks/legacy_contract_test.go @@ -0,0 +1,188 @@ +package bookmarks_test + +// Phase N-0 — legacy contract freeze for the bookmarks package. +// +// Pins today's wire format for /api/bookmarks, /api/bookmarks/calls and +// POST /api/bookmarks (toggle). Plan reference: +// docs/plans/native-api-design-plan.md §4.1. + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/gin-gonic/gin" + + "github.com/openscanner/openscanner/internal/audio" + "github.com/openscanner/openscanner/internal/auth" + "github.com/openscanner/openscanner/internal/db" + "github.com/openscanner/openscanner/internal/handler/routes" + "github.com/openscanner/openscanner/internal/logging" +) + +func init() { + gin.SetMode(gin.TestMode) + logging.Configure(true, "") +} + +func bmFixture(t *testing.T) (*gin.Engine, *db.Queries, string, int64) { + t.Helper() + sqlDB, err := db.Open(":memory:") + if err != nil { + t.Fatalf("db.Open: %v", err) + } + t.Cleanup(func() { _ = sqlDB.Close() }) + q := db.New(sqlDB) + + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + pool := audio.NewWorkerPool(ctx) + proc := audio.NewProcessor(t.TempDir(), pool) + + r := gin.New() + rl := auth.NewRateLimiter(context.Background()) + routes.RegisterRoutes(r, routes.Deps{Queries: q, RateLimiter: rl, Processor: proc, Version: "test"}) + + hash, _ := auth.HashPassword("pw") + now := time.Now().Unix() + uid, err := q.CreateUser(context.Background(), db.CreateUserParams{ + Username: "alice", PasswordHash: hash, Role: auth.RoleAdmin, + CreatedAt: now, UpdatedAt: now, + }) + if err != nil { + t.Fatalf("CreateUser: %v", err) + } + tok, _, err := auth.GenerateToken(uid, "alice", auth.RoleAdmin, 0) + if err != nil { + t.Fatalf("GenerateToken: %v", err) + } + + sysID, err := q.CreateSystem(context.Background(), db.CreateSystemParams{SystemID: 1, Label: "S"}) + if err != nil { + t.Fatalf("CreateSystem: %v", err) + } + callID, err := q.CreateCall(context.Background(), db.CreateCallParams{ + AudioPath: "x/a.wav", AudioName: "a.wav", AudioType: "audio/wav", + DateTime: now, SystemID: sysID, + }) + if err != nil { + t.Fatalf("CreateCall: %v", err) + } + return r, q, tok, callID +} + +// TestBookmarksLegacyContract pins request/response shapes for all three +// legacy bookmark endpoints. +func TestBookmarksLegacyContract(t *testing.T) { + engine, _, tok, callID := bmFixture(t) + bearer := func(req *http.Request) { req.Header.Set("Authorization", "Bearer "+tok) } + + // 1) POST /api/bookmarks — create. + body, _ := json.Marshal(map[string]int64{"callId": callID}) + req := httptest.NewRequest(http.MethodPost, "/api/bookmarks", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + bearer(req) + w := httptest.NewRecorder() + engine.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("POST: status = %d, want 200; body: %s", w.Code, w.Body.String()) + } + var toggled struct { + Bookmarked bool `json:"bookmarked"` + ID int64 `json:"id"` + } + if err := json.Unmarshal(w.Body.Bytes(), &toggled); err != nil { + t.Fatalf("POST decode: %v", err) + } + if !toggled.Bookmarked || toggled.ID <= 0 { + t.Errorf("POST toggle = %+v, want bookmarked=true id>0", toggled) + } + + // 2) GET /api/bookmarks — IDs. + req = httptest.NewRequest(http.MethodGet, "/api/bookmarks", nil) + bearer(req) + w = httptest.NewRecorder() + engine.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("GET ids: status = %d, want 200; body: %s", w.Code, w.Body.String()) + } + var ids struct { + CallIDs []int64 `json:"callIds"` + } + if err := json.Unmarshal(w.Body.Bytes(), &ids); err != nil { + t.Fatalf("GET ids decode: %v", err) + } + if len(ids.CallIDs) != 1 || ids.CallIDs[0] != callID { + t.Errorf("GET ids = %+v, want [%d]", ids.CallIDs, callID) + } + + // 3) GET /api/bookmarks/calls — hydrated. + req = httptest.NewRequest(http.MethodGet, "/api/bookmarks/calls", nil) + bearer(req) + w = httptest.NewRecorder() + engine.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("GET calls: status = %d, want 200; body: %s", w.Code, w.Body.String()) + } + var hydrated struct { + Calls []map[string]any `json:"calls"` + } + if err := json.Unmarshal(w.Body.Bytes(), &hydrated); err != nil { + t.Fatalf("GET calls decode: %v", err) + } + if len(hydrated.Calls) != 1 { + t.Fatalf("GET calls len = %d, want 1", len(hydrated.Calls)) + } + for _, k := range []string{"id", "dateTime"} { + if _, ok := hydrated.Calls[0][k]; !ok { + t.Errorf("GET calls row missing key %q", k) + } + } + + // 4) POST /api/bookmarks again — toggle off. + req = httptest.NewRequest(http.MethodPost, "/api/bookmarks", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + bearer(req) + w = httptest.NewRecorder() + engine.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("POST off: status = %d, want 200; body: %s", w.Code, w.Body.String()) + } + var off struct { + Bookmarked bool `json:"bookmarked"` + } + if err := json.Unmarshal(w.Body.Bytes(), &off); err != nil { + t.Fatalf("POST off decode: %v", err) + } + if off.Bookmarked { + t.Errorf("POST off = %+v, want bookmarked=false", off) + } +} + +// TestBookmarks_Unauthenticated_Returns401 pins the auth gate on the +// bookmark endpoints. +func TestBookmarks_Unauthenticated_Returns401(t *testing.T) { + engine, _, _, _ := bmFixture(t) + + for _, p := range []string{"/api/bookmarks", "/api/bookmarks/calls"} { + req := httptest.NewRequest(http.MethodGet, p, nil) + w := httptest.NewRecorder() + engine.ServeHTTP(w, req) + if w.Code != http.StatusUnauthorized { + t.Errorf("%s: status = %d, want 401", p, w.Code) + } + } + + body, _ := json.Marshal(map[string]int64{"callId": 1}) + req := httptest.NewRequest(http.MethodPost, "/api/bookmarks", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + engine.ServeHTTP(w, req) + if w.Code != http.StatusUnauthorized { + t.Errorf("POST /api/bookmarks: status = %d, want 401", w.Code) + } +} diff --git a/backend/internal/handler/calls/audio.go b/backend/internal/handler/calls/audio.go index de088c5..65470c8 100644 --- a/backend/internal/handler/calls/audio.go +++ b/backend/internal/handler/calls/audio.go @@ -18,7 +18,7 @@ import ( // // @Summary Get call audio file // @Description Stream the audio file for a specific call. Authentication is optional when the publicAccess setting is enabled; otherwise a valid JWT is required. -// @Tags Calls +// @Tags Calls,v1-Calls // @Security BearerAuth // @Produce application/octet-stream // @Param id path int true "Call ID" @@ -28,6 +28,7 @@ import ( // @Failure 404 {object} ErrorResponse "Call or audio not found" // @Failure 500 {object} ErrorResponse "Internal server error" // @Router /calls/{id}/audio [get] +// @Router /v1/calls/{id}/audio [get] func (h *Handler) GetCallAudio(c *gin.Context) { ctx := c.Request.Context() id, err := strconv.ParseInt(c.Param("id"), 10, 64) diff --git a/backend/internal/handler/calls/legacy_contract_test.go b/backend/internal/handler/calls/legacy_contract_test.go new file mode 100644 index 0000000..45027cd --- /dev/null +++ b/backend/internal/handler/calls/legacy_contract_test.go @@ -0,0 +1,458 @@ +package calls_test + +// Phase N-0 — legacy contract freeze for the calls handler package. +// +// These tests pin today's wire format on the rdio-scanner-shaped legacy REST +// surface so the upcoming /api/v1/* native API work cannot drift the legacy +// shape without breaking a regression. Plan reference: +// docs/plans/native-api-design-plan.md §4.1 (REST endpoints) and §5 +// (multipart call-upload field map). +// +// Test-only file. No production code is touched. + +import ( + "bytes" + "context" + "database/sql" + "encoding/json" + "fmt" + "mime/multipart" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + "github.com/gin-gonic/gin" + + "github.com/openscanner/openscanner/internal/audio" + "github.com/openscanner/openscanner/internal/auth" + "github.com/openscanner/openscanner/internal/db" + "github.com/openscanner/openscanner/internal/handler/routes" + "github.com/openscanner/openscanner/internal/logging" +) + +func init() { + gin.SetMode(gin.TestMode) + logging.Configure(true, "") +} + +// engineWithCalls wires a real Gin engine with audio Processor and a fresh +// in-memory DB. No Hub/notifier/transcriber — those code paths are nil-safe +// in the upload handler. +func engineWithCalls(t *testing.T) (*gin.Engine, *db.Queries, string) { + t.Helper() + sqlDB, err := db.Open(":memory:") + if err != nil { + t.Fatalf("db.Open: %v", err) + } + t.Cleanup(func() { _ = sqlDB.Close() }) + q := db.New(sqlDB) + + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + pool := audio.NewWorkerPool(ctx) + recordingsDir := t.TempDir() + proc := audio.NewProcessor(recordingsDir, pool) + + r := gin.New() + rl := auth.NewRateLimiter(context.Background()) + routes.RegisterRoutes(r, routes.Deps{ + Queries: q, + RateLimiter: rl, + Processor: proc, + Version: "test", + }) + return r, q, recordingsDir +} + +func seedAPIKey(t *testing.T, q *db.Queries, raw string) { + t.Helper() + if _, err := q.CreateAPIKey(context.Background(), db.CreateAPIKeyParams{ + Key: auth.HashAPIKey(raw), + Disabled: 0, + }); err != nil { + t.Fatalf("CreateAPIKey: %v", err) + } +} + +func enableAutoPopulate(t *testing.T, q *db.Queries) { + t.Helper() + ctx := context.Background() + for k, v := range map[string]string{ + "autoPopulateSystems": "true", + "audioConversion": "0", + "disableDuplicateDetection": "true", + } { + if err := q.UpsertSetting(ctx, db.UpsertSettingParams{Key: k, Value: v}); err != nil { + t.Fatalf("UpsertSetting %q: %v", k, err) + } + } +} + +// buildUpload writes the canonical happy-path multipart body. Caller picks +// auth transport (header / query / form). +func buildUpload(t *testing.T) (body *bytes.Buffer, contentType string) { + t.Helper() + var buf bytes.Buffer + w := multipart.NewWriter(&buf) + for k, v := range map[string]string{ + "systemId": "1", + "talkgroupId": "100", + "dateTime": strconv.FormatInt(time.Now().Unix(), 10), + } { + if err := w.WriteField(k, v); err != nil { + t.Fatalf("WriteField %q: %v", k, err) + } + } + fw, err := w.CreateFormFile("audio", "test.wav") + if err != nil { + t.Fatalf("CreateFormFile: %v", err) + } + _, _ = fw.Write([]byte("RIFF\x24\x00\x00\x00WAVEfmt ")) + _ = w.Close() + return &buf, w.FormDataContentType() +} + +// canonJSON returns a stable, key-sorted JSON byte representation of v so +// expected/actual bodies can be byte-compared without map-key-order flake. +func canonJSON(t *testing.T, v any) []byte { + t.Helper() + if s, ok := v.([]byte); ok { + var raw any + if err := json.Unmarshal(s, &raw); err != nil { + t.Fatalf("canonJSON unmarshal: %v\nbody: %s", err, s) + } + v = raw + } + b, err := json.Marshal(v) + if err != nil { + t.Fatalf("canonJSON marshal: %v", err) + } + return b +} + +// TestPostCallUpload_APIKeyTransports — Phase N-0 contract freeze for +// middleware.APIKeyAuth: all three legacy transports (X-API-Key header, +// ?key=… query, key=… form field) must succeed with the same key value and +// produce the same response body. This pins the precedence header → query → +// form documented in backend/internal/middleware/auth.go. +func TestPostCallUpload_APIKeyTransports(t *testing.T) { + const apiKey = "super-secret-key" + + tests := []struct { + name string + transport string // "header" | "query" | "form" + }{ + {"X-API-Key header", "header"}, + {"key query param", "query"}, + {"key form field", "form"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + engine, queries, _ := engineWithCalls(t) + seedAPIKey(t, queries, apiKey) + enableAutoPopulate(t, queries) + + body, ct := buildUpload(t) + + urlPath := "/api/call-upload" + if tc.transport == "query" { + urlPath += "?key=" + url.QueryEscape(apiKey) + } + if tc.transport == "form" { + // Rebuild the multipart with the key field included. + var buf bytes.Buffer + w := multipart.NewWriter(&buf) + _ = w.WriteField("key", apiKey) + _ = w.WriteField("systemId", "1") + _ = w.WriteField("talkgroupId", "100") + _ = w.WriteField("dateTime", strconv.FormatInt(time.Now().Unix(), 10)) + fw, _ := w.CreateFormFile("audio", "test.wav") + _, _ = fw.Write([]byte("RIFF\x24\x00\x00\x00WAVEfmt ")) + _ = w.Close() + body, ct = &buf, w.FormDataContentType() + } + + req := httptest.NewRequest(http.MethodPost, urlPath, body) + req.Header.Set("Content-Type", ct) + if tc.transport == "header" { + req.Header.Set("X-API-Key", apiKey) + } + w := httptest.NewRecorder() + engine.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("transport=%s: status = %d, want 200; body: %s", tc.transport, w.Code, w.Body.String()) + } + if got, want := w.Header().Get("Content-Type"), "application/json"; !strings.HasPrefix(got, want) { + t.Errorf("transport=%s: Content-Type = %q, want prefix %q", tc.transport, got, want) + } + + var resp struct { + ID int64 `json:"id"` + Message string `json:"message"` + } + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("transport=%s: decode: %v\nbody: %s", tc.transport, err, w.Body.String()) + } + if resp.ID <= 0 { + t.Errorf("transport=%s: id = %d, want > 0", tc.transport, resp.ID) + } + if resp.Message != "Call imported successfully." { + t.Errorf("transport=%s: message = %q, want %q", + tc.transport, resp.Message, "Call imported successfully.") + } + }) + } +} + +// TestPostCallUpload_LegacyAlias pins that POST /api/trunk-recorder-call-upload +// is wire-equivalent to POST /api/call-upload. +func TestPostCallUpload_LegacyAlias(t *testing.T) { + engine, queries, _ := engineWithCalls(t) + seedAPIKey(t, queries, "tr-key") + enableAutoPopulate(t, queries) + + body, ct := buildUpload(t) + req := httptest.NewRequest(http.MethodPost, "/api/trunk-recorder-call-upload", body) + req.Header.Set("Content-Type", ct) + req.Header.Set("X-API-Key", "tr-key") + w := httptest.NewRecorder() + engine.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String()) + } + + var resp struct { + ID int64 `json:"id"` + Message string `json:"message"` + } + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v", err) + } + if resp.ID <= 0 || resp.Message != "Call imported successfully." { + t.Errorf("alias response = %+v, want id>0 + message=\"Call imported successfully.\"", resp) + } +} + +// TestPostCallUpload_TestConnectionCheck_PlainText pins the rdio-scanner / +// SDRTrunk connectivity-check shape: test=1 returns plain text 200 with the +// exact "Incomplete call data: no talkgroup\n" body. +func TestPostCallUpload_TestConnectionCheck_PlainText(t *testing.T) { + engine, queries, _ := engineWithCalls(t) + seedAPIKey(t, queries, "k1") + + var buf bytes.Buffer + w := multipart.NewWriter(&buf) + _ = w.WriteField("test", "1") + _ = w.WriteField("system", "1") + _ = w.Close() + + req := httptest.NewRequest(http.MethodPost, "/api/call-upload", &buf) + req.Header.Set("Content-Type", w.FormDataContentType()) + req.Header.Set("X-API-Key", "k1") + rec := httptest.NewRecorder() + engine.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body: %s", rec.Code, rec.Body.String()) + } + if got, want := rec.Body.String(), "Incomplete call data: no talkgroup\n"; got != want { + t.Errorf("body = %q, want %q", got, want) + } + if got := rec.Header().Get("Content-Type"); !strings.HasPrefix(got, "text/plain") { + t.Errorf("Content-Type = %q, want text/plain prefix", got) + } +} + +// TestGetCalls_LegacyResponseShape pins the legacy GET /api/calls envelope: +// {"calls":[…], "total": }. Does not pin per-row volatile fields (id, +// dateTime); only the envelope keys and types. +func TestGetCalls_LegacyResponseShape(t *testing.T) { + engine, queries, _ := engineWithCalls(t) + ctx := context.Background() + + // Public access on so this works without a JWT. + _ = queries.UpsertSetting(ctx, db.UpsertSettingParams{Key: "publicAccess", Value: "true"}) + + sysID, err := queries.CreateSystem(ctx, db.CreateSystemParams{SystemID: 1, Label: "S"}) + if err != nil { + t.Fatalf("CreateSystem: %v", err) + } + if _, err := queries.CreateCall(ctx, db.CreateCallParams{ + AudioPath: "x/a.wav", AudioName: "a.wav", AudioType: "audio/wav", + DateTime: time.Now().Unix(), SystemID: sysID, + }); err != nil { + t.Fatalf("CreateCall: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "/api/calls?limit=1", nil) + w := httptest.NewRecorder() + engine.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String()) + } + + var env struct { + Calls []map[string]any `json:"calls"` + Total int64 `json:"total"` + } + if err := json.Unmarshal(w.Body.Bytes(), &env); err != nil { + t.Fatalf("decode: %v\nbody: %s", err, w.Body.String()) + } + if env.Total < 1 { + t.Errorf("total = %d, want >= 1", env.Total) + } + if len(env.Calls) != 1 { + t.Fatalf("len(calls) = %d, want 1", len(env.Calls)) + } + row := env.Calls[0] + for _, k := range []string{"id", "dateTime", "systemId"} { + if _, ok := row[k]; !ok { + t.Errorf("call row missing key %q (got keys: %v)", k, mapKeys(row)) + } + } +} + +// TestGetCallAudio_LegacyContentDisposition pins the inline disposition and +// streaming behaviour of GET /api/calls/:id/audio when the caller carries a +// valid Bearer JWT. +func TestGetCallAudio_LegacyContentDisposition(t *testing.T) { + engine, queries, recordingsDir := engineWithCalls(t) + ctx := context.Background() + + // Seed a real audio file + call row. + relPath := filepath.Join("legacy", "audio.wav") + abs := filepath.Join(recordingsDir, relPath) + if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + const body = "FAKE_AUDIO" + if err := os.WriteFile(abs, []byte(body), 0o644); err != nil { + t.Fatalf("write audio: %v", err) + } + + sysID, err := queries.CreateSystem(ctx, db.CreateSystemParams{SystemID: 1, Label: "S"}) + if err != nil { + t.Fatalf("CreateSystem: %v", err) + } + callID, err := queries.CreateCall(ctx, db.CreateCallParams{ + AudioPath: relPath, AudioName: "audio.wav", AudioType: "audio/wav", + DateTime: time.Now().Unix(), SystemID: sysID, + }) + if err != nil { + t.Fatalf("CreateCall: %v", err) + } + + // Seed an admin user and mint a JWT for them. + hash, _ := auth.HashPassword("pw") + uid, err := queries.CreateUser(ctx, db.CreateUserParams{ + Username: "alice", PasswordHash: hash, Role: auth.RoleAdmin, + CreatedAt: time.Now().Unix(), UpdatedAt: time.Now().Unix(), + }) + if err != nil { + t.Fatalf("CreateUser: %v", err) + } + tok, _, err := auth.GenerateToken(uid, "alice", auth.RoleAdmin, 0) + if err != nil { + t.Fatalf("GenerateToken: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/calls/%d/audio", callID), nil) + req.Header.Set("Authorization", "Bearer "+tok) + w := httptest.NewRecorder() + engine.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String()) + } + if got, want := w.Body.String(), body; got != want { + t.Errorf("body = %q, want %q", got, want) + } + if got := w.Header().Get("Content-Disposition"); !strings.HasPrefix(got, "inline") { + t.Errorf("Content-Disposition = %q, want inline prefix", got) + } + if got, want := w.Header().Get("Content-Type"), "audio/wav"; got != want { + t.Errorf("Content-Type = %q, want %q", got, want) + } +} + +// TestGetCallTranscript_LegacyResponseShape pins the JSON keys returned by +// GET /api/calls/:id/transcript: {text, segments[], language, model}. +func TestGetCallTranscript_LegacyResponseShape(t *testing.T) { + engine, queries, _ := engineWithCalls(t) + ctx := context.Background() + _ = queries.UpsertSetting(ctx, db.UpsertSettingParams{Key: "publicAccess", Value: "true"}) + + sysID, err := queries.CreateSystem(ctx, db.CreateSystemParams{SystemID: 1, Label: "S"}) + if err != nil { + t.Fatalf("CreateSystem: %v", err) + } + callID, err := queries.CreateCall(ctx, db.CreateCallParams{ + AudioPath: "x/a.wav", AudioName: "a.wav", AudioType: "audio/wav", + DateTime: time.Now().Unix(), SystemID: sysID, + }) + if err != nil { + t.Fatalf("CreateCall: %v", err) + } + if _, err := queries.CreateTranscription(ctx, db.CreateTranscriptionParams{ + CallID: callID, + Text: "hello world", + Language: sql.NullString{String: "en", Valid: true}, + Model: sql.NullString{String: "tiny.en", Valid: true}, + Segments: sql.NullString{String: `[{"start":0,"end":1,"text":"hello"}]`, Valid: true}, + CreatedAt: time.Now().Unix(), + }); err != nil { + t.Fatalf("CreateTranscription: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/calls/%d/transcript", callID), nil) + w := httptest.NewRecorder() + engine.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String()) + } + var resp map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v", err) + } + for _, k := range []string{"text", "segments", "language", "model"} { + if _, ok := resp[k]; !ok { + t.Errorf("transcript response missing key %q (got keys: %v)", k, mapKeys(resp)) + } + } + if resp["text"] != "hello world" { + t.Errorf("text = %v, want \"hello world\"", resp["text"]) + } + if resp["language"] != "en" { + t.Errorf("language = %v, want \"en\"", resp["language"]) + } + if resp["model"] != "tiny.en" { + t.Errorf("model = %v, want \"tiny.en\"", resp["model"]) + } + + // The segments array must round-trip through canonical JSON without + // shape drift. + wantSegs := canonJSON(t, []map[string]any{{"start": 0, "end": 1, "text": "hello"}}) + gotSegs := canonJSON(t, resp["segments"]) + if !bytes.Equal(gotSegs, wantSegs) { + t.Errorf("segments shape drift\n got: %s\n want: %s", gotSegs, wantSegs) + } +} + +func mapKeys(m map[string]any) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + return out +} diff --git a/backend/internal/handler/calls/search.go b/backend/internal/handler/calls/search.go index 56a3c5f..7f5d710 100644 --- a/backend/internal/handler/calls/search.go +++ b/backend/internal/handler/calls/search.go @@ -38,6 +38,7 @@ import ( // @Failure 400 {object} ErrorResponse "Invalid query parameter" // @Failure 500 {object} ErrorResponse "Internal server error" // @Router /calls [get] +// @Router /v1/calls [get] func (h *Handler) GetCalls(c *gin.Context) { ctx := c.Request.Context() diff --git a/backend/internal/handler/calls/transcript.go b/backend/internal/handler/calls/transcript.go index 2ee1063..2b9b760 100644 --- a/backend/internal/handler/calls/transcript.go +++ b/backend/internal/handler/calls/transcript.go @@ -26,7 +26,7 @@ type transcriptResponse struct { // // @Summary Get call transcript // @Description Returns the transcription text, segments, language and model for a call. Authentication is optional when the publicAccess setting is enabled; otherwise a valid JWT is required. -// @Tags Calls +// @Tags Calls,v1-Calls // @Produce json // @Security BearerAuth // @Param id path int true "Call ID" @@ -35,6 +35,7 @@ type transcriptResponse struct { // @Failure 404 {object} ErrorResponse // @Failure 500 {object} ErrorResponse // @Router /calls/{id}/transcript [get] +// @Router /v1/calls/{id}/transcript [get] func (h *Handler) GetCallTranscript(c *gin.Context) { ctx := c.Request.Context() id, err := strconv.ParseInt(c.Param("id"), 10, 64) diff --git a/backend/internal/handler/calls/upload.go b/backend/internal/handler/calls/upload.go index 9e752ad..73a6914 100644 --- a/backend/internal/handler/calls/upload.go +++ b/backend/internal/handler/calls/upload.go @@ -566,7 +566,8 @@ func (h *Handler) PostCallUpload(c *gin.Context) { if err != nil { slog.Error("failed to build CAL message", "error", err) } else { - h.hub.BroadcastCAL(calMsg, func(cl *ws.Client) bool { + _ = calMsg + h.hub.BroadcastCAL(calPayload, func(cl *ws.Client) bool { return cl.CanReceive(system.ID, talkgroup.ID) }) slog.Debug("call-upload: ws broadcast sent", "call_id", callID) diff --git a/backend/internal/handler/calls/upload_v1.go b/backend/internal/handler/calls/upload_v1.go new file mode 100644 index 0000000..c4d67be --- /dev/null +++ b/backend/internal/handler/calls/upload_v1.go @@ -0,0 +1,605 @@ +// Phase N-1 — native /api/v1/calls upload handler. +// +// Differs from the legacy /api/call-upload handler in three ways: +// +// 1. Multipart field names are the canonical native set per +// docs/plans/native-api-design-plan.md §5: systemId, talkgroupId, +// startedAt, frequencyHz, durationMs, unitId, talkerAlias, etc. +// The legacy aliases (system, talkgroup, dateTime, frequency, duration, +// source) are NOT accepted on v1. +// +// 2. startedAt MUST be RFC 3339; unix-timestamp values are rejected with +// a 400 validation_failed envelope. +// +// 3. All 4xx/5xx responses use the v1 error envelope (shared.WriteAPIError). +// The form-field `key=` auth transport is not honoured (Bearer only — +// enforced by APIKeyAuth + V1Marker). +// +// The post-validation ingest pipeline (system/talkgroup resolve, blacklist +// check, duplicate detection, audio store, DB insert, WS broadcast, +// downstream notify, transcription enqueue) is intentionally kept structurally +// equivalent to the legacy handler so a future refactor can extract a shared +// core without behavioural drift between the two paths. +package calls + +import ( + "database/sql" + "errors" + "log/slog" + "net/http" + "path/filepath" + "strconv" + "time" + + "github.com/gin-gonic/gin" + + "github.com/openscanner/openscanner/internal/audio" + "github.com/openscanner/openscanner/internal/db" + "github.com/openscanner/openscanner/internal/downstream" + "github.com/openscanner/openscanner/internal/handler/shared" + "github.com/openscanner/openscanner/internal/ws" +) + +// PostCallUploadV1 handles POST /api/v1/calls — the native upload endpoint. +// +// @Summary Upload a call recording (native v1) +// @Description Ingest a radio call with audio and metadata using the native field names. Requires Authorization: Bearer . The startedAt field must be RFC 3339; unix timestamps are rejected. +// @Tags v1-Calls +// @Accept multipart/form-data +// @Produce json +// @Security BearerAPIKey +// @Param audio formData file true "Audio file" +// @Param startedAt formData string true "RFC 3339 start timestamp" +// @Param systemId formData int true "Radio system ID" +// @Param talkgroupId formData int true "Talkgroup ID" +// @Param unitId formData int false "Source unit ID" +// @Param frequencyHz formData int false "Frequency in Hz" +// @Param durationMs formData int false "Call duration in milliseconds" +// @Param talkgroupLabel formData string false "Talkgroup label" +// @Param talkgroupTag formData string false "Talkgroup tag name" +// @Param talkgroupGroup formData string false "Talkgroup group name" +// @Param talkgroupName formData string false "Talkgroup display name" +// @Param systemLabel formData string false "System label" +// @Param talkerAlias formData string false "OTA talker alias" +// @Param site formData string false "Site identifier" +// @Param channel formData string false "Channel identifier" +// @Param decoder formData string false "Decoder software name" +// @Param errorCount formData int false "Decoding error count" +// @Param spikeCount formData int false "Signal spike count" +// @Param sources formData string false "JSON array of per-segment source units" +// @Param frequencies formData string false "JSON array of per-segment frequencies" +// @Param patches formData string false "JSON array of patched talkgroup IDs" +// @Success 200 {object} object{id=int64,message=string} "Call ingested" +// @Failure 400 {object} shared.APIErrorResponse "Validation failed" +// @Failure 401 {object} shared.APIErrorResponse "Invalid credentials" +// @Failure 422 {object} shared.APIErrorResponse "Unprocessable entity (system/talkgroup not configured)" +// @Failure 429 {object} shared.APIErrorResponse "Rate limit exceeded" +// @Failure 500 {object} shared.APIErrorResponse "Internal error" +// @Router /v1/calls [post] +func (h *Handler) PostCallUploadV1(c *gin.Context) { + apiKeyIDVal, exists := c.Get("apiKeyID") + if !exists { + shared.WriteAPIError(c, http.StatusUnauthorized, shared.CodeInvalidCredentials, "API key required", nil) + return + } + apiKeyID, ok := apiKeyIDVal.(int64) + if !ok { + shared.WriteAPIError(c, http.StatusInternalServerError, shared.CodeInternalError, "internal error", nil) + return + } + + // Per-API-key rate limiting (mirrors the legacy handler). + rateLimit := defaultCallRatePerMin + apiKeyRateOverride := false + if apiKeyRateVal, ok := c.Get("apiKeyCallRate"); ok { + if apiKeyRate, ok := apiKeyRateVal.(int64); ok && apiKeyRate > 0 { + rateLimit = int(apiKeyRate) + apiKeyRateOverride = true + } + } + if rStr := shared.GetSettingValue(c, h.queries, "apiKeyCallRate"); rStr != "" { + if r, err := strconv.Atoi(rStr); err == nil && r > 0 && !apiKeyRateOverride { + rateLimit = r + } + } + if rateLimit > maxCallRatePerMin { + rateLimit = maxCallRatePerMin + } + if !h.getLimiter(apiKeyID, rateLimit).allow() { + slog.Warn("v1 call upload rate limit exceeded", "api_key_id", apiKeyID) + shared.WriteAPIError(c, http.StatusTooManyRequests, shared.CodeRateLimited, + "rate limit exceeded", map[string]any{"retryAfterSeconds": 60}) + return + } + + // Parse and validate native multipart fields. + startedAt := c.PostForm("startedAt") + if startedAt == "" { + shared.WriteAPIError(c, http.StatusBadRequest, shared.CodeValidationFailed, + "startedAt is required", map[string]any{"field": "startedAt"}) + return + } + // Native rejects unix timestamps explicitly: a value that parses as a + // pure int64 is reported back so the recorder operator can fix their + // integration before silently sending bad data. + if _, intErr := strconv.ParseInt(startedAt, 10, 64); intErr == nil { + shared.WriteAPIError(c, http.StatusBadRequest, shared.CodeValidationFailed, + "startedAt must be an RFC 3339 timestamp", + map[string]any{"field": "startedAt", "got": startedAt}) + return + } + var callTime time.Time + if t, err := time.Parse(time.RFC3339Nano, startedAt); err == nil { + callTime = t + } else if t, err := time.Parse(time.RFC3339, startedAt); err == nil { + callTime = t + } else { + shared.WriteAPIError(c, http.StatusBadRequest, shared.CodeValidationFailed, + "startedAt must be an RFC 3339 timestamp", + map[string]any{"field": "startedAt", "got": startedAt}) + return + } + dateTimeUnix := callTime.Unix() + + systemIDStr := c.PostForm("systemId") + if systemIDStr == "" { + shared.WriteAPIError(c, http.StatusBadRequest, shared.CodeValidationFailed, + "systemId is required", map[string]any{"field": "systemId"}) + return + } + systemIDRaw, err := strconv.ParseInt(systemIDStr, 10, 64) + if err != nil { + shared.WriteAPIError(c, http.StatusBadRequest, shared.CodeValidationFailed, + "systemId must be an integer", map[string]any{"field": "systemId"}) + return + } + + talkgroupIDStr := c.PostForm("talkgroupId") + if talkgroupIDStr == "" { + shared.WriteAPIError(c, http.StatusBadRequest, shared.CodeValidationFailed, + "talkgroupId is required", map[string]any{"field": "talkgroupId"}) + return + } + talkgroupIDRaw, err := strconv.ParseInt(talkgroupIDStr, 10, 64) + if err != nil { + shared.WriteAPIError(c, http.StatusBadRequest, shared.CodeValidationFailed, + "talkgroupId must be an integer", map[string]any{"field": "talkgroupId"}) + return + } + + fh, err := c.FormFile("audio") + if err != nil { + shared.WriteAPIError(c, http.StatusBadRequest, shared.CodeValidationFailed, + "audio file is required", map[string]any{"field": "audio"}) + return + } + + // Optional native fields. + var frequency, duration, source sql.NullInt64 + if v := c.PostForm("frequencyHz"); v != "" { + if n, err := strconv.ParseInt(v, 10, 64); err == nil { + frequency = sql.NullInt64{Int64: n, Valid: true} + } + } + if v := c.PostForm("durationMs"); v != "" { + if n, err := strconv.ParseInt(v, 10, 64); err == nil { + duration = sql.NullInt64{Int64: n, Valid: true} + } + } + if v := c.PostForm("unitId"); v != "" { + if n, err := strconv.ParseInt(v, 10, 64); err == nil { + source = sql.NullInt64{Int64: n, Valid: true} + } + } + + var errorCount, spikeCount sql.NullInt64 + if v := c.PostForm("errorCount"); v != "" { + if n, err := strconv.ParseInt(v, 10, 64); err == nil { + errorCount = sql.NullInt64{Int64: n, Valid: true} + } + } + if v := c.PostForm("spikeCount"); v != "" { + if n, err := strconv.ParseInt(v, 10, 64); err == nil { + spikeCount = sql.NullInt64{Int64: n, Valid: true} + } + } + + var sourcesJSON, frequenciesJSON, patchesJSON sql.NullString + if v := c.PostForm("sources"); v != "" { + sourcesJSON = sql.NullString{String: v, Valid: true} + } + if v := c.PostForm("frequencies"); v != "" { + frequenciesJSON = sql.NullString{String: v, Valid: true} + } + if v := c.PostForm("patches"); v != "" { + patchesJSON = sql.NullString{String: v, Valid: true} + } + if !source.Valid && sourcesJSON.Valid { + source = extractPrimarySource(sourcesJSON.String) + } + if !errorCount.Valid && !spikeCount.Valid && frequenciesJSON.Valid { + errorCount, spikeCount = aggregateErrorSpikeCounts(frequenciesJSON.String) + } + + var siteCol, channelCol, decoderCol sql.NullString + if v := c.PostForm("site"); v != "" { + siteCol = sql.NullString{String: v, Valid: true} + } + if v := c.PostForm("channel"); v != "" { + channelCol = sql.NullString{String: v, Valid: true} + } + if v := c.PostForm("decoder"); v != "" { + decoderCol = sql.NullString{String: v, Valid: true} + } + + talkgroupLabel := c.PostForm("talkgroupLabel") + talkgroupTag := c.PostForm("talkgroupTag") + talkgroupGroup := c.PostForm("talkgroupGroup") + talkgroupName := c.PostForm("talkgroupName") + + var talkerAliasCol sql.NullString + if v := c.PostForm("talkerAlias"); v != "" { + talkerAliasCol = sql.NullString{String: v, Valid: true} + } + if !talkerAliasCol.Valid && sourcesJSON.Valid { + talkerAliasCol = extractPrimarySourceTag(sourcesJSON.String) + } + + ctx := c.Request.Context() + autoPopulateSystems := shared.GetSettingValue(c, h.queries, "autoPopulateSystems") == "true" + + // Resolve system. + system, err := h.queries.GetSystemBySystemID(ctx, systemIDRaw) + if err != nil { + if !errors.Is(err, sql.ErrNoRows) { + slog.Error("v1 upload: failed to query system", "system_id", systemIDRaw, "error", err) + shared.WriteAPIError(c, http.StatusInternalServerError, shared.CodeInternalError, "internal error", nil) + return + } + if !autoPopulateSystems { + shared.WriteAPIError(c, http.StatusUnprocessableEntity, shared.CodeSystemNotFound, + "system is not configured and autoPopulateSystems is disabled", + map[string]any{"systemId": systemIDRaw}) + return + } + label := strconv.FormatInt(systemIDRaw, 10) + if sl := c.PostForm("systemLabel"); sl != "" { + label = sl + } + newID, cerr := h.queries.CreateSystem(ctx, db.CreateSystemParams{ + SystemID: systemIDRaw, + Label: label, + AutoPopulateTalkgroups: 1, + }) + if cerr != nil { + slog.Error("v1 upload: failed to auto-create system", "system_id", systemIDRaw, "error", cerr) + shared.WriteAPIError(c, http.StatusInternalServerError, shared.CodeInternalError, "internal error", nil) + return + } + slog.Info("v1 upload: auto-populated system", "system_id", systemIDRaw, "label", label, "db_id", newID) + system = db.System{ID: newID, SystemID: systemIDRaw, Label: label, AutoPopulateTalkgroups: 1} + h.hub.BroadcastCFG(ctx) + } + + // Blacklist check — same observable behaviour as legacy (200 with a hint). + if isBlacklistedTG(system.BlacklistsJson, talkgroupIDRaw) { + slog.Info("v1 upload: talkgroup blacklisted", + "system_id", systemIDRaw, "talkgroup_id", talkgroupIDRaw) + c.JSON(http.StatusOK, gin.H{"status": "blacklisted"}) + return + } + + // Resolve talkgroup. + talkgroup, err := h.queries.GetTalkgroupBySystemAndTGID(ctx, db.GetTalkgroupBySystemAndTGIDParams{ + SystemID: system.ID, + TalkgroupID: talkgroupIDRaw, + }) + if err != nil { + if !errors.Is(err, sql.ErrNoRows) { + slog.Error("v1 upload: failed to query talkgroup", + "system_id", system.ID, "talkgroup_id", talkgroupIDRaw, "error", err) + shared.WriteAPIError(c, http.StatusInternalServerError, shared.CodeInternalError, "internal error", nil) + return + } + if system.AutoPopulateTalkgroups == 0 { + shared.WriteAPIError(c, http.StatusUnprocessableEntity, shared.CodeTalkgroupNotFound, + "talkgroup is not configured for this system", + map[string]any{"systemId": systemIDRaw, "talkgroupId": talkgroupIDRaw}) + return + } + var tgLabel, tgName sql.NullString + if talkgroupLabel != "" { + tgLabel = sql.NullString{String: talkgroupLabel, Valid: true} + } + if talkgroupName != "" { + tgName = sql.NullString{String: talkgroupName, Valid: true} + } + var groupID sql.NullInt64 + if talkgroupGroup != "" { + groupID = shared.ResolveGroupID(ctx, h.queries, talkgroupGroup) + } + var tagID sql.NullInt64 + if talkgroupTag != "" { + tagID = shared.ResolveTagID(ctx, h.queries, talkgroupTag) + } + newID, cerr := h.queries.CreateTalkgroup(ctx, db.CreateTalkgroupParams{ + SystemID: system.ID, + TalkgroupID: talkgroupIDRaw, + Label: tgLabel, + Name: tgName, + GroupID: groupID, + TagID: tagID, + }) + if cerr != nil { + slog.Error("v1 upload: failed to auto-create talkgroup", + "talkgroup_id", talkgroupIDRaw, "error", cerr) + shared.WriteAPIError(c, http.StatusInternalServerError, shared.CodeInternalError, "internal error", nil) + return + } + slog.Info("v1 upload: auto-populated talkgroup", + "system_id", system.SystemID, "talkgroup_id", talkgroupIDRaw, "label", tgLabel.String, "db_id", newID) + talkgroup = db.Talkgroup{ID: newID, SystemID: system.ID, TalkgroupID: talkgroupIDRaw, Label: tgLabel, Name: tgName, GroupID: groupID, TagID: tagID} + h.hub.BroadcastCFG(ctx) + } else if needsBackfill(talkgroup, talkgroupLabel, talkgroupName, talkgroupTag, talkgroupGroup) { + if !talkgroup.Label.Valid && talkgroupLabel != "" { + talkgroup.Label = sql.NullString{String: talkgroupLabel, Valid: true} + } + if !talkgroup.Name.Valid && talkgroupName != "" { + talkgroup.Name = sql.NullString{String: talkgroupName, Valid: true} + } + if !talkgroup.GroupID.Valid && talkgroupGroup != "" { + talkgroup.GroupID = shared.ResolveGroupID(ctx, h.queries, talkgroupGroup) + } + if !talkgroup.TagID.Valid && talkgroupTag != "" { + talkgroup.TagID = shared.ResolveTagID(ctx, h.queries, talkgroupTag) + } + if uerr := h.queries.UpdateTalkgroup(ctx, db.UpdateTalkgroupParams{ + ID: talkgroup.ID, + TalkgroupID: talkgroup.TalkgroupID, + Label: talkgroup.Label, + Name: talkgroup.Name, + Frequency: talkgroup.Frequency, + Led: talkgroup.Led, + GroupID: talkgroup.GroupID, + TagID: talkgroup.TagID, + Order: talkgroup.Order, + }); uerr != nil { + slog.Warn("v1 upload: failed to backfill talkgroup", + "talkgroup_id", talkgroup.TalkgroupID, "error", uerr) + } else { + h.hub.BroadcastCFG(ctx) + } + } + + // Duplicate detection — same window + same observable response shape as + // the legacy handler, but signalled via 409 to match the v1 contract. + if shared.GetSettingValue(c, h.queries, "disableDuplicateDetection") != "true" { + windowMs := int64(2000) + if v := shared.GetSettingValue(c, h.queries, "duplicateDetectionTimeFrame"); v != "" { + if wm, err := strconv.ParseInt(v, 10, 64); err == nil { + windowMs = wm + } + } + dup, derr := audio.IsDuplicate(ctx, h.queries, system.ID, talkgroup.ID, callTime, windowMs) + if derr != nil { + slog.Error("v1 upload: duplicate detection failed", "error", derr) + } else if dup { + slog.Info("v1 upload: duplicate rejected", + "system_id", systemIDRaw, "talkgroup_id", talkgroupIDRaw) + shared.WriteAPIError(c, http.StatusConflict, shared.CodeDuplicateCall, + "a call with the same system, talkgroup, and startedAt already exists", + map[string]any{"systemId": systemIDRaw, "talkgroupId": talkgroupIDRaw}) + return + } + } + + // Audio storage. + convMode := audio.ConversionDisabled + if mStr := shared.GetSettingValue(c, h.queries, "audioConversion"); mStr != "" { + if m, err := strconv.Atoi(mStr); err == nil { + convMode = audio.ConversionMode(m) + } + } + convPreset := audio.ParseEncodingPreset(shared.GetSettingValue(c, h.queries, "audioEncodingPreset")) + + relPath, err := h.processor.Store(ctx, fh, convMode, convPreset) + if err != nil { + slog.Error("v1 upload: failed to store audio", + "system_id", systemIDRaw, "talkgroup_id", talkgroupIDRaw, "error", err) + shared.WriteAPIError(c, http.StatusInternalServerError, shared.CodeInternalError, "failed to store audio", nil) + return + } + + if !duration.Valid { + absPath := filepath.Join(h.processor.RecordingsDir(), relPath) + if d := audio.ProbeDuration(ctx, absPath); d > 0 { + duration = sql.NullInt64{Int64: d, Valid: true} + } + } + + var audioType string + if convMode != audio.ConversionDisabled { + audioType = audio.OutputMIME(convPreset) + } else { + switch fh.Header.Get("Content-Type") { + case "audio/mpeg", "audio/mp3", "audio/wav", "audio/x-wav", + "audio/ogg", "audio/aac", "audio/m4a", "audio/mp4", + "audio/x-m4a", "audio/opus": + audioType = fh.Header.Get("Content-Type") + default: + audioType = "application/octet-stream" + } + } + + callID, err := h.queries.CreateCall(ctx, db.CreateCallParams{ + AudioPath: relPath, + AudioName: filepath.Base(relPath), + AudioType: audioType, + DateTime: dateTimeUnix, + Frequency: frequency, + Duration: duration, + Source: source, + SourcesJson: sourcesJSON, + FrequenciesJson: frequenciesJSON, + PatchesJson: patchesJSON, + SystemID: system.ID, + TalkgroupID: sql.NullInt64{Int64: talkgroup.ID, Valid: true}, + Site: siteCol, + Channel: channelCol, + Decoder: decoderCol, + ErrorCount: errorCount, + SpikeCount: spikeCount, + TalkerAlias: talkerAliasCol, + }) + if err != nil { + slog.Error("v1 upload: failed to insert call", "error", err) + shared.WriteAPIError(c, http.StatusInternalServerError, shared.CodeInternalError, "internal error", nil) + return + } + + if sourcesJSON.Valid { + upsertUnitsFromSources(ctx, h.queries, system.ID, sourcesJSON.String) + } + if source.Valid && talkerAliasCol.Valid { + if err := h.queries.UpsertUnit(ctx, db.UpsertUnitParams{ + SystemID: system.ID, + UnitID: source.Int64, + Label: sql.NullString{String: talkerAliasCol.String, Valid: true}, + }); err != nil { + slog.Warn("v1 upload: failed to upsert unit from talkerAlias", + "unit_id", source.Int64, "error", err) + } + } + + // Broadcast over the legacy CAL channel — Phase N-2 will introduce the + // native call.new JSON-object shape on /api/v1/ws/listener. + if h.hub != nil { + calPayload := map[string]any{ + "id": callID, + "audioName": filepath.Base(relPath), + "audioType": audioType, + "dateTime": dateTimeUnix, + "systemId": system.SystemID, + "system": system.ID, + "talkgroupId": talkgroup.TalkgroupID, + "talkgroup": talkgroup.ID, + } + if frequency.Valid { + calPayload["frequency"] = frequency.Int64 + } + if duration.Valid { + calPayload["duration"] = duration.Int64 + } + if source.Valid { + calPayload["source"] = source.Int64 + } + if siteCol.Valid { + calPayload["site"] = siteCol.String + } + if channelCol.Valid { + calPayload["channel"] = channelCol.String + } + if decoderCol.Valid { + calPayload["decoder"] = decoderCol.String + } + if errorCount.Valid { + calPayload["errorCount"] = errorCount.Int64 + } + if spikeCount.Valid { + calPayload["spikeCount"] = spikeCount.Int64 + } + if talkerAliasCol.Valid { + calPayload["talkerAlias"] = talkerAliasCol.String + } + if sourcesJSON.Valid { + calPayload["sources"] = sourcesJSON.String + } + if frequenciesJSON.Valid { + calPayload["frequencies"] = frequenciesJSON.String + } + if calMsg, err := ws.NewCALMessage(calPayload); err == nil { + _ = calMsg + h.hub.BroadcastCAL(calPayload, func(cl *ws.Client) bool { + return cl.CanReceive(system.ID, talkgroup.ID) + }) + } else { + slog.Error("v1 upload: build CAL message failed", "error", err) + } + } + + slog.Info("v1 upload: complete", + "call_id", callID, + "system_id", systemIDRaw, + "talkgroup_id", talkgroupIDRaw, + "audio_path", relPath, + "api_key_id", apiKeyID, + ) + + c.JSON(http.StatusOK, gin.H{"id": callID, "message": "Call imported successfully."}) + + // Downstream + transcription — mirrors legacy handler. + if h.dsNotifier != nil { + var groupLabel, tagLabel string + if talkgroup.GroupID.Valid { + if g, err := h.queries.GetGroup(ctx, talkgroup.GroupID.Int64); err == nil { + groupLabel = g.Label + } + } + if talkgroup.TagID.Valid { + if t, err := h.queries.GetTag(ctx, talkgroup.TagID.Int64); err == nil { + tagLabel = t.Label + } + } + h.dsNotifier.Notify(downstream.CallEvent{ + CallID: callID, + AudioPath: relPath, + AudioName: filepath.Base(relPath), + AudioType: audioType, + DateTime: dateTimeUnix, + SystemID: system.SystemID, + System: system.ID, + TalkgroupID: talkgroup.TalkgroupID, + Talkgroup: talkgroup.ID, + Frequency: frequency.Int64, + Duration: duration.Int64, + Source: source.Int64, + Sources: sourcesJSON.String, + Frequencies: frequenciesJSON.String, + Patches: patchesJSON.String, + SystemLabel: system.Label, + TalkgroupLabel: talkgroup.Label.String, + TalkgroupName: talkgroup.Name.String, + TalkgroupGroup: groupLabel, + TalkgroupTag: tagLabel, + TalkerAlias: talkerAliasCol.String, + }) + } + if h.transcriber != nil { + absPath := filepath.Join(h.processor.RecordingsDir(), relPath) + if err := h.transcriber.Submit(ctx, audio.TranscriptionJob{ + CallID: callID, + AudioPath: absPath, + }); err != nil { + slog.Warn("v1 upload: failed to enqueue transcription", + "call_id", callID, "error", err) + } + } +} + +// PostCallsTestV1 handles POST /api/v1/calls/test — a connectivity check used +// by recorders to validate their upload config without uploading audio. +// +// @Summary Connectivity check +// @Description Validates the Bearer API key and returns 204 No Content. Used by recorder plugins to verify their upload configuration without uploading audio. +// @Tags v1-Calls +// @Security BearerAPIKey +// @Success 204 "OK — credentials valid" +// @Failure 401 {object} shared.APIErrorResponse "Invalid credentials" +// @Router /v1/calls/test [post] +func (h *Handler) PostCallsTestV1(c *gin.Context) { + if _, ok := c.Get("apiKeyID"); !ok { + shared.WriteAPIError(c, http.StatusUnauthorized, shared.CodeInvalidCredentials, "API key required", nil) + return + } + c.Status(http.StatusNoContent) +} diff --git a/backend/internal/handler/calls/v1_test.go b/backend/internal/handler/calls/v1_test.go new file mode 100644 index 0000000..b07f41f --- /dev/null +++ b/backend/internal/handler/calls/v1_test.go @@ -0,0 +1,540 @@ +package calls_test + +// Phase N-1 — integration tests for the native /api/v1/* call surface. +// +// Covers: +// - Happy path POST /api/v1/calls with native field names + Bearer API key. +// - Each validation failure mode of POST /api/v1/calls (missing fields, +// unix-timestamp startedAt, RFC 3339 happy path, missing audio). +// - The connectivity check POST /api/v1/calls/test → 204. +// - The renamed listener tg-selection endpoint at /api/v1/listener/tg-selection. +// - The native error envelope shape on at least one 400, 401, 403, and 404 +// response. + +import ( + "bytes" + "context" + "database/sql" + "encoding/json" + "fmt" + "mime/multipart" + "net/http" + "net/http/httptest" + "strconv" + "testing" + "time" + + "github.com/gin-gonic/gin" + + "github.com/openscanner/openscanner/internal/audio" + "github.com/openscanner/openscanner/internal/auth" + "github.com/openscanner/openscanner/internal/db" + "github.com/openscanner/openscanner/internal/handler/routes" + "github.com/openscanner/openscanner/internal/logging" +) + +func init() { + gin.SetMode(gin.TestMode) + logging.Configure(true, "") +} + +// engineV1 is a v1-flavoured copy of engineWithCalls used by the legacy +// contract tests. Same wiring; only renamed for clarity. +func engineV1(t *testing.T) (*gin.Engine, *db.Queries) { + t.Helper() + sqlDB, err := db.Open(":memory:") + if err != nil { + t.Fatalf("db.Open: %v", err) + } + t.Cleanup(func() { _ = sqlDB.Close() }) + q := db.New(sqlDB) + + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + pool := audio.NewWorkerPool(ctx) + proc := audio.NewProcessor(t.TempDir(), pool) + + r := gin.New() + rl := auth.NewRateLimiter(context.Background()) + routes.RegisterRoutes(r, routes.Deps{ + Queries: q, + RateLimiter: rl, + Processor: proc, + Version: "test", + }) + return r, q +} + +// seedV1Settings enables auto-populate so the upload happy-path doesn't 422. +func seedV1Settings(t *testing.T, q *db.Queries) { + t.Helper() + ctx := context.Background() + for k, v := range map[string]string{ + "autoPopulateSystems": "true", + "audioConversion": "0", + "disableDuplicateDetection": "true", + } { + if err := q.UpsertSetting(ctx, db.UpsertSettingParams{Key: k, Value: v}); err != nil { + t.Fatalf("UpsertSetting %q: %v", k, err) + } + } +} + +func seedV1APIKey(t *testing.T, q *db.Queries, raw string) { + t.Helper() + if _, err := q.CreateAPIKey(context.Background(), db.CreateAPIKeyParams{ + Key: auth.HashAPIKey(raw), + Disabled: 0, + }); err != nil { + t.Fatalf("CreateAPIKey: %v", err) + } +} + +// buildV1Upload writes a native-shaped multipart body. Caller adds the audio +// file plus any additional fields they need. +func buildV1Upload(t *testing.T, fields map[string]string, withAudio bool) (*bytes.Buffer, string) { + t.Helper() + var buf bytes.Buffer + w := multipart.NewWriter(&buf) + for k, v := range fields { + if err := w.WriteField(k, v); err != nil { + t.Fatalf("WriteField %q: %v", k, err) + } + } + if withAudio { + fw, err := w.CreateFormFile("audio", "test.wav") + if err != nil { + t.Fatalf("CreateFormFile: %v", err) + } + _, _ = fw.Write([]byte("RIFF\x24\x00\x00\x00WAVEfmt ")) + } + _ = w.Close() + return &buf, w.FormDataContentType() +} + +// TestPostV1Calls_HappyPath verifies the native upload happy path: +// Authorization: Bearer , native field names, RFC 3339 startedAt. +func TestPostV1Calls_HappyPath(t *testing.T) { + const apiKey = "v1-bearer-key" + engine, q := engineV1(t) + seedV1APIKey(t, q, apiKey) + seedV1Settings(t, q) + + body, ct := buildV1Upload(t, map[string]string{ + "systemId": "1", + "talkgroupId": "100", + "startedAt": time.Now().UTC().Format(time.RFC3339), + }, true) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/calls", body) + req.Header.Set("Content-Type", ct) + req.Header.Set("Authorization", "Bearer "+apiKey) + w := httptest.NewRecorder() + engine.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String()) + } + var resp struct { + ID int64 `json:"id"` + Message string `json:"message"` + } + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v\nbody: %s", err, w.Body.String()) + } + if resp.ID <= 0 { + t.Errorf("id = %d, want > 0", resp.ID) + } +} + +// TestPostV1Calls_RejectsLegacyAuthTransports — only Authorization: Bearer is +// honoured on the v1 surface. X-API-Key header, ?key= query param, and form +// field "key" must all 401. +func TestPostV1Calls_RejectsLegacyAuthTransports(t *testing.T) { + const apiKey = "v1-key" + engine, q := engineV1(t) + seedV1APIKey(t, q, apiKey) + seedV1Settings(t, q) + + tests := []struct { + name string + setup func(req *http.Request) + }{ + { + name: "X-API-Key header rejected", + setup: func(req *http.Request) { + req.Header.Set("X-API-Key", apiKey) + }, + }, + { + name: "?key= query rejected", + setup: func(req *http.Request) { + req.URL.RawQuery = "key=" + apiKey + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + body, ct := buildV1Upload(t, map[string]string{ + "systemId": "1", + "talkgroupId": "100", + "startedAt": time.Now().UTC().Format(time.RFC3339), + }, true) + req := httptest.NewRequest(http.MethodPost, "/api/v1/calls", body) + req.Header.Set("Content-Type", ct) + tc.setup(req) + w := httptest.NewRecorder() + engine.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401; body: %s", w.Code, w.Body.String()) + } + assertV1ErrorEnvelope(t, w.Body.Bytes()) + }) + } +} + +// TestPostV1Calls_ValidationFailed_StartedAtUnix pins that a unix-timestamp +// startedAt is rejected with the v1 envelope. +func TestPostV1Calls_ValidationFailed_StartedAtUnix(t *testing.T) { + const apiKey = "v1-key" + engine, q := engineV1(t) + seedV1APIKey(t, q, apiKey) + seedV1Settings(t, q) + + body, ct := buildV1Upload(t, map[string]string{ + "systemId": "1", + "talkgroupId": "100", + "startedAt": strconv.FormatInt(time.Now().Unix(), 10), + }, true) + req := httptest.NewRequest(http.MethodPost, "/api/v1/calls", body) + req.Header.Set("Content-Type", ct) + req.Header.Set("Authorization", "Bearer "+apiKey) + w := httptest.NewRecorder() + engine.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400; body: %s", w.Code, w.Body.String()) + } + env := assertV1ErrorEnvelope(t, w.Body.Bytes()) + if env["code"] != "validation_failed" { + t.Errorf("code = %v, want validation_failed", env["code"]) + } + details, _ := env["details"].(map[string]any) + if details["field"] != "startedAt" { + t.Errorf("details.field = %v, want startedAt", details["field"]) + } + if _, ok := details["got"]; !ok { + t.Errorf("details.got missing; want the offending value to be echoed back") + } +} + +// TestPostV1Calls_ValidationFailed_MissingFields covers each required-field +// failure path of the native upload. +func TestPostV1Calls_ValidationFailed_MissingFields(t *testing.T) { + const apiKey = "v1-key" + engine, q := engineV1(t) + seedV1APIKey(t, q, apiKey) + seedV1Settings(t, q) + + cases := []struct { + name string + fields map[string]string + withAudio bool + wantField string + }{ + { + name: "missing startedAt", + fields: map[string]string{"systemId": "1", "talkgroupId": "100"}, + withAudio: true, + wantField: "startedAt", + }, + { + name: "missing systemId", + fields: map[string]string{"talkgroupId": "100", "startedAt": time.Now().UTC().Format(time.RFC3339)}, + withAudio: true, + wantField: "systemId", + }, + { + name: "missing talkgroupId", + fields: map[string]string{"systemId": "1", "startedAt": time.Now().UTC().Format(time.RFC3339)}, + withAudio: true, + wantField: "talkgroupId", + }, + { + name: "missing audio", + fields: map[string]string{"systemId": "1", "talkgroupId": "100", "startedAt": time.Now().UTC().Format(time.RFC3339)}, + withAudio: false, + wantField: "audio", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + body, ct := buildV1Upload(t, tc.fields, tc.withAudio) + req := httptest.NewRequest(http.MethodPost, "/api/v1/calls", body) + req.Header.Set("Content-Type", ct) + req.Header.Set("Authorization", "Bearer "+apiKey) + w := httptest.NewRecorder() + engine.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400; body: %s", w.Code, w.Body.String()) + } + env := assertV1ErrorEnvelope(t, w.Body.Bytes()) + details, _ := env["details"].(map[string]any) + if details["field"] != tc.wantField { + t.Errorf("details.field = %v, want %q", details["field"], tc.wantField) + } + }) + } +} + +// TestPostV1Calls_SystemNotFound returns 422 when auto-populate is disabled. +func TestPostV1Calls_SystemNotFound(t *testing.T) { + const apiKey = "v1-key" + engine, q := engineV1(t) + seedV1APIKey(t, q, apiKey) + // Don't seed autoPopulateSystems=true. + _ = q.UpsertSetting(context.Background(), db.UpsertSettingParams{Key: "audioConversion", Value: "0"}) + + body, ct := buildV1Upload(t, map[string]string{ + "systemId": "999", + "talkgroupId": "100", + "startedAt": time.Now().UTC().Format(time.RFC3339), + }, true) + req := httptest.NewRequest(http.MethodPost, "/api/v1/calls", body) + req.Header.Set("Content-Type", ct) + req.Header.Set("Authorization", "Bearer "+apiKey) + w := httptest.NewRecorder() + engine.ServeHTTP(w, req) + + if w.Code != http.StatusUnprocessableEntity { + t.Fatalf("status = %d, want 422; body: %s", w.Code, w.Body.String()) + } + env := assertV1ErrorEnvelope(t, w.Body.Bytes()) + if env["code"] != "system_not_found" { + t.Errorf("code = %v, want system_not_found", env["code"]) + } +} + +// TestPostV1Calls_RejectJWTBearer pins that a JWT-shaped Bearer token sent to +// an API-key endpoint returns invalid_credentials, not "API key required". +func TestPostV1Calls_RejectJWTBearer(t *testing.T) { + engine, q := engineV1(t) + seedV1APIKey(t, q, "real-key") + seedV1Settings(t, q) + + // Mint a real user JWT — the same shape an interactive client would + // use. Should be refused on this Bearer-API-key route. + hash, _ := auth.HashPassword("pw") + uid, err := q.CreateUser(context.Background(), db.CreateUserParams{ + Username: "alice", PasswordHash: hash, Role: auth.RoleAdmin, + CreatedAt: time.Now().Unix(), UpdatedAt: time.Now().Unix(), + }) + if err != nil { + t.Fatalf("CreateUser: %v", err) + } + tok, _, err := auth.GenerateToken(uid, "alice", auth.RoleAdmin, 0) + if err != nil { + t.Fatalf("GenerateToken: %v", err) + } + + body, ct := buildV1Upload(t, map[string]string{ + "systemId": "1", + "talkgroupId": "100", + "startedAt": time.Now().UTC().Format(time.RFC3339), + }, true) + req := httptest.NewRequest(http.MethodPost, "/api/v1/calls", body) + req.Header.Set("Content-Type", ct) + req.Header.Set("Authorization", "Bearer "+tok) + w := httptest.NewRecorder() + engine.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401; body: %s", w.Code, w.Body.String()) + } + env := assertV1ErrorEnvelope(t, w.Body.Bytes()) + if env["code"] != "invalid_credentials" { + t.Errorf("code = %v, want invalid_credentials", env["code"]) + } +} + +// TestPostV1Calls_TestEndpoint_204 — connectivity check returns 204 with no +// body when the Bearer key is valid, 401 otherwise. +func TestPostV1Calls_TestEndpoint(t *testing.T) { + const apiKey = "v1-key" + engine, q := engineV1(t) + seedV1APIKey(t, q, apiKey) + + // Valid: 204. + req := httptest.NewRequest(http.MethodPost, "/api/v1/calls/test", nil) + req.Header.Set("Authorization", "Bearer "+apiKey) + w := httptest.NewRecorder() + engine.ServeHTTP(w, req) + if w.Code != http.StatusNoContent { + t.Fatalf("status = %d, want 204; body: %s", w.Code, w.Body.String()) + } + if w.Body.Len() != 0 { + t.Errorf("body = %q, want empty", w.Body.String()) + } + + // Missing auth: 401 with v1 envelope. + req = httptest.NewRequest(http.MethodPost, "/api/v1/calls/test", nil) + w = httptest.NewRecorder() + engine.ServeHTTP(w, req) + if w.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401; body: %s", w.Code, w.Body.String()) + } + assertV1ErrorEnvelope(t, w.Body.Bytes()) +} + +// TestV1ErrorEnvelope_403_AdminRequired pins the v1 forbidden envelope shape. +func TestV1ErrorEnvelope_403_AdminRequired(t *testing.T) { + engine, q := engineV1(t) + hash, _ := auth.HashPassword("pw") + uid, err := q.CreateUser(context.Background(), db.CreateUserParams{ + Username: "bob", PasswordHash: hash, Role: auth.RoleListener, + CreatedAt: time.Now().Unix(), UpdatedAt: time.Now().Unix(), + }) + if err != nil { + t.Fatalf("CreateUser: %v", err) + } + tok, _, err := auth.GenerateToken(uid, "bob", auth.RoleListener, 0) + if err != nil { + t.Fatalf("GenerateToken: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/transcriptions/status", nil) + req.Header.Set("Authorization", "Bearer "+tok) + w := httptest.NewRecorder() + engine.ServeHTTP(w, req) + + if w.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403; body: %s", w.Code, w.Body.String()) + } + env := assertV1ErrorEnvelope(t, w.Body.Bytes()) + if env["code"] != "forbidden" { + t.Errorf("code = %v, want forbidden", env["code"]) + } +} + +// TestV1ErrorEnvelope_401_MissingJWT pins the v1 unauthorized envelope on a +// JWT-protected route reached without credentials. +func TestV1ErrorEnvelope_401_MissingJWT(t *testing.T) { + engine, _ := engineV1(t) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil) + w := httptest.NewRecorder() + engine.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401; body: %s", w.Code, w.Body.String()) + } + env := assertV1ErrorEnvelope(t, w.Body.Bytes()) + if env["code"] != "invalid_credentials" { + t.Errorf("code = %v, want invalid_credentials", env["code"]) + } +} + +// TestV1ListenerTGSelection_RenamedPath — the renamed v1 endpoint reaches the +// same handler body as the legacy /api/auth/tg-selection. +func TestV1ListenerTGSelection_RenamedPath(t *testing.T) { + engine, q := engineV1(t) + hash, _ := auth.HashPassword("pw") + uid, err := q.CreateUser(context.Background(), db.CreateUserParams{ + Username: "carol", PasswordHash: hash, Role: auth.RoleAdmin, + CreatedAt: time.Now().Unix(), UpdatedAt: time.Now().Unix(), + }) + if err != nil { + t.Fatalf("CreateUser: %v", err) + } + tok, _, err := auth.GenerateToken(uid, "carol", auth.RoleAdmin, 0) + if err != nil { + t.Fatalf("GenerateToken: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "/api/v1/listener/tg-selection", nil) + req.Header.Set("Authorization", "Bearer "+tok) + w := httptest.NewRecorder() + engine.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String()) + } +} + +// TestV1Health_Unauthenticated pins that GET /api/v1/health is reachable +// without auth and returns the same shape as the legacy health route. +func TestV1Health_Unauthenticated(t *testing.T) { + engine, _ := engineV1(t) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/health", nil) + w := httptest.NewRecorder() + engine.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String()) + } + var resp struct { + Status string `json:"status"` + } + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v", err) + } + if resp.Status != "ok" { + t.Errorf("status = %q, want ok", resp.Status) + } +} + +// TestV1Calls_GetSearch_NotFound — GET /api/v1/calls/{id}/audio for a +// non-existent id surfaces a v1 envelope from the shared handler via the +// rewriter middleware. +func TestV1Calls_GetAudio_NotFound(t *testing.T) { + engine, q := engineV1(t) + _ = q.UpsertSetting(context.Background(), db.UpsertSettingParams{Key: "publicAccess", Value: "true"}) + + req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/calls/%d/audio", 9999999), nil) + w := httptest.NewRecorder() + engine.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404; body: %s", w.Code, w.Body.String()) + } + env := assertV1ErrorEnvelope(t, w.Body.Bytes()) + if env["code"] == nil { + t.Errorf("v1 404 envelope missing code: %v", env) + } +} + +// assertV1ErrorEnvelope decodes a body that is expected to match +// {"error": {"code":..., "message":..., "details"?:...}}, returning the +// inner error object for further assertions. +func assertV1ErrorEnvelope(t *testing.T, body []byte) map[string]any { + t.Helper() + var resp map[string]any + if err := json.Unmarshal(body, &resp); err != nil { + t.Fatalf("envelope decode: %v\nbody: %s", err, body) + } + errVal, ok := resp["error"].(map[string]any) + if !ok { + t.Fatalf("missing/non-object error field; body: %s", body) + } + if _, ok := errVal["code"].(string); !ok { + t.Fatalf("envelope missing string code; got %v", errVal) + } + if _, ok := errVal["message"].(string); !ok { + t.Fatalf("envelope missing string message; got %v", errVal) + } + // details is optional and may be a map or absent. + if d, ok := errVal["details"]; ok { + if _, isMap := d.(map[string]any); !isMap && d != nil { + t.Fatalf("details must be object or absent; got %T", d) + } + } + return errVal +} + +// silence unused-imports lint when test compilation skips a branch above +var _ = sql.ErrNoRows diff --git a/backend/internal/handler/health/health.go b/backend/internal/handler/health/health.go index add6ce3..62b7962 100644 --- a/backend/internal/handler/health/health.go +++ b/backend/internal/handler/health/health.go @@ -21,10 +21,11 @@ func New(version string) *Handler { // // @Summary Health check // @Description Returns server status and version for readiness probes and Docker HEALTHCHECK. -// @Tags Health +// @Tags Health,v1-Health // @Produce json // @Success 200 {object} object{status=string,version=string} "Server is healthy" // @Router /health [get] +// @Router /v1/health [get] func (h *Handler) Get(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ "status": "ok", diff --git a/backend/internal/handler/routes/routes.go b/backend/internal/handler/routes/routes.go index 24c2be0..c36b122 100644 --- a/backend/internal/handler/routes/routes.go +++ b/backend/internal/handler/routes/routes.go @@ -22,10 +22,11 @@ import ( "github.com/openscanner/openscanner/internal/auth" "github.com/openscanner/openscanner/internal/db" "github.com/openscanner/openscanner/internal/downstream" - authhandler "github.com/openscanner/openscanner/internal/handler/auth" "github.com/openscanner/openscanner/internal/handler/admin/imports" + "github.com/openscanner/openscanner/internal/handler/admin/legacyusage" "github.com/openscanner/openscanner/internal/handler/admin/radioreference" "github.com/openscanner/openscanner/internal/handler/admin/transcriptions" + authhandler "github.com/openscanner/openscanner/internal/handler/auth" "github.com/openscanner/openscanner/internal/handler/bookmarks" "github.com/openscanner/openscanner/internal/handler/calls" "github.com/openscanner/openscanner/internal/handler/health" @@ -87,6 +88,7 @@ func RegisterRoutes(r *gin.Engine, deps Deps) { importsHandler := imports.New(deps.Queries, deps.Hub) rrHandler := radioreference.New(deps.Queries) transcriptionsHandler := transcriptions.New(deps.Queries, deps.WhisperAvailable) + legacyUsageHandler := legacyusage.New(nil, nil) // Global middleware applied to every request. r.Use(middleware.RequestID()) @@ -95,50 +97,59 @@ func RegisterRoutes(r *gin.Engine, deps Deps) { api := r.Group("/api") + // Phase N-3 — RFC 8594 deprecation signalling for legacy /api/* routes. + // Each registration below carries a `dep("/api/v1/...")` middleware that + // emits the Deprecation/Sunset/Link headers, records an entry in the + // 24-hour usage ring buffer, and emits a structured warn log. The map + // is hand-built so the successor URI on the Link header is exact. + dep := func(successor string) gin.HandlerFunc { + return middleware.Deprecated(successor, middleware.LegacyAPISunset) + } + // Health check — unauthenticated. - api.GET("/health", healthHandler.Get) + api.GET("/health", dep("/api/v1/health"), healthHandler.Get) // First-run setup — unauthenticated. - api.GET("/setup/status", setupHandler.GetSetupStatus) - api.POST("/setup", middleware.MaxBodySize(1<<20), setupHandler.PostSetup) + api.GET("/setup/status", dep("/api/v1/setup/status"), setupHandler.GetSetupStatus) + api.POST("/setup", dep("/api/v1/setup"), middleware.MaxBodySize(1<<20), setupHandler.PostSetup) // Auth — login and refresh are unauthenticated; the rest require a valid JWT. - api.POST("/auth/login", middleware.MaxBodySize(1<<20), middleware.RateLimit(deps.RateLimiter), authH.PostLogin) - api.POST("/auth/refresh", middleware.MaxBodySize(1<<20), middleware.RateLimit(deps.RateLimiter), authH.PostRefresh) + api.POST("/auth/login", dep("/api/v1/auth/login"), middleware.MaxBodySize(1<<20), middleware.RateLimit(deps.RateLimiter), authH.PostLogin) + api.POST("/auth/refresh", dep("/api/v1/auth/refresh"), middleware.MaxBodySize(1<<20), middleware.RateLimit(deps.RateLimiter), authH.PostRefresh) authRequired := api.Group("/auth") authRequired.Use(middleware.JWTAuth()) { - authRequired.POST("/logout", authH.PostLogout) - authRequired.PUT("/password", authH.PutPassword) - authRequired.GET("/me", authH.GetMe) - authRequired.GET("/tg-selection", authH.GetTGSelection) - authRequired.PUT("/tg-selection", authH.PutTGSelection) + authRequired.POST("/logout", dep("/api/v1/auth/logout"), authH.PostLogout) + authRequired.PUT("/password", dep("/api/v1/auth/password"), authH.PutPassword) + authRequired.GET("/me", dep("/api/v1/auth/me"), authH.GetMe) + authRequired.GET("/tg-selection", dep("/api/v1/listener/tg-selection"), authH.GetTGSelection) + authRequired.PUT("/tg-selection", dep("/api/v1/listener/tg-selection"), authH.PutTGSelection) } // Call search — public access with optional auth for bookmarks. - api.GET("/calls", middleware.OptionalJWTAuth(), callHandler.GetCalls) - api.GET("/calls/:id/audio", middleware.OptionalJWTOrSessionAuth(), callHandler.GetCallAudio) - api.GET("/calls/:id/transcript", middleware.OptionalJWTAuth(), callHandler.GetCallTranscript) + api.GET("/calls", dep("/api/v1/calls"), middleware.OptionalJWTAuth(), callHandler.GetCalls) + api.GET("/calls/:id/audio", dep("/api/v1/calls/:id/audio"), middleware.OptionalJWTOrSessionAuth(), callHandler.GetCallAudio) + api.GET("/calls/:id/transcript", dep("/api/v1/calls/:id/transcript"), middleware.OptionalJWTAuth(), callHandler.GetCallTranscript) // Shared calls — token-based public access (no auth required). // Rate-limited to 30 req/min per IP to prevent bandwidth exhaustion. sharedRateLimit := middleware.RateLimitByIP(30) - api.GET("/shared/:token", sharedRateLimit, shareHandler.GetSharedCallByToken) - api.GET("/shared/:token/audio", sharedRateLimit, shareHandler.GetSharedCallAudio) + api.GET("/shared/:token", dep("/api/v1/shared/:token"), sharedRateLimit, shareHandler.GetSharedCallByToken) + api.GET("/shared/:token/audio", dep("/api/v1/shared/:token/audio"), sharedRateLimit, shareHandler.GetSharedCallAudio) // Share management — JWT required. - api.POST("/calls/:id/share", middleware.JWTAuth(), shareHandler.PostShareCall) - api.DELETE("/calls/:id/share", middleware.JWTAuth(), shareHandler.DeleteShareCall) - api.GET("/calls/:id/share", middleware.JWTAuth(), shareHandler.GetCallShare) + api.POST("/calls/:id/share", dep("/api/v1/calls/:id/share"), middleware.JWTAuth(), shareHandler.PostShareCall) + api.DELETE("/calls/:id/share", dep("/api/v1/calls/:id/share"), middleware.JWTAuth(), shareHandler.DeleteShareCall) + api.GET("/calls/:id/share", dep("/api/v1/calls/:id/share"), middleware.JWTAuth(), shareHandler.GetCallShare) // Bookmarks — JWT required. bookmarksGroup := api.Group("/bookmarks") bookmarksGroup.Use(middleware.JWTAuth()) { - bookmarksGroup.GET("", bookmarkHandler.GetBookmarkIDs) - bookmarksGroup.GET("/calls", bookmarkHandler.GetBookmarkCalls) - bookmarksGroup.POST("", bookmarkHandler.PostToggleBookmark) + bookmarksGroup.GET("", dep("/api/v1/bookmarks"), bookmarkHandler.GetBookmarkIDs) + bookmarksGroup.GET("/calls", dep("/api/v1/bookmarks/calls"), bookmarkHandler.GetBookmarkCalls) + bookmarksGroup.POST("", dep("/api/v1/bookmarks"), bookmarkHandler.PostToggleBookmark) } // Call upload — API key auth. @@ -147,8 +158,8 @@ func RegisterRoutes(r *gin.Engine, deps Deps) { upload := r.Group("/") upload.Use(middleware.MaxBodySize(50<<20), middleware.APIKeyAuth(deps.Queries)) { - upload.POST("/api/call-upload", callHandler.PostCallUpload) - upload.POST("/api/trunk-recorder-call-upload", callHandler.PostCallUpload) + upload.POST("/api/call-upload", dep("/api/v1/calls"), callHandler.PostCallUpload) + upload.POST("/api/trunk-recorder-call-upload", dep("/api/v1/calls"), callHandler.PostCallUpload) } // Admin routes — JWT + admin role required. @@ -156,20 +167,20 @@ func RegisterRoutes(r *gin.Engine, deps Deps) { admin.Use(middleware.JWTAuth(), middleware.RequireAdmin(), middleware.MaxBodySize(2<<20)) // 2 MiB JSON body limit { // Import (file uploads — must stay REST) - admin.POST("/import/talkgroups", importsHandler.ImportTalkgroups) - admin.POST("/import/units", importsHandler.ImportUnits) - admin.POST("/import/groups", importsHandler.ImportGroups) - admin.POST("/import/tags", importsHandler.ImportTags) + admin.POST("/import/talkgroups", dep("/api/v1/admin/import/talkgroups"), importsHandler.ImportTalkgroups) + admin.POST("/import/units", dep("/api/v1/admin/import/units"), importsHandler.ImportUnits) + admin.POST("/import/groups", dep("/api/v1/admin/import/groups"), importsHandler.ImportGroups) + admin.POST("/import/tags", dep("/api/v1/admin/import/tags"), importsHandler.ImportTags) // RadioReference CSV preview (file upload — must stay REST) - admin.POST("/radioreference/preview/csv", rrHandler.PreviewCSV) + admin.POST("/radioreference/preview/csv", dep("/api/v1/admin/radioreference/preview"), rrHandler.PreviewCSV) // Transcription status - admin.GET("/transcriptions/status", transcriptionsHandler.GetStatus) + admin.GET("/transcriptions/status", dep("/api/v1/admin/transcriptions/status"), transcriptionsHandler.GetStatus) // Swagger: issue a short-lived HTTP-only cookie so Swagger UI // can be opened in a new browser tab without exposing the JWT. - admin.POST("/docs/session", authhandler.PostDocsSession) + admin.POST("/docs/session", dep("/api/v1/admin/docs/session"), authhandler.PostDocsSession) } // Swagger API documentation — protected by the HTTP-only cookie @@ -177,7 +188,7 @@ func RegisterRoutes(r *gin.Engine, deps Deps) { swaggerDocs := api.Group("/admin/docs") swaggerDocs.Use(middleware.SwaggerCookieAuth()) { - swaggerDocs.GET("/*any", ginSwagger.WrapHandler(swaggerFiles.Handler)) + swaggerDocs.GET("/*any", dep("/api/v1/admin/docs/*any"), ginSwagger.WrapHandler(swaggerFiles.Handler)) } // WebSocket endpoints. @@ -185,9 +196,85 @@ func RegisterRoutes(r *gin.Engine, deps Deps) { // compatibility alias that delegates to the same handler so existing // rdio-scanner-shaped clients keep working during the legacy-API transition. listenerWS := gin.WrapF(ws.HandleListenerWS(deps.Hub, deps.Queries)) - r.GET("/api/ws", listenerWS) - r.GET("/ws", listenerWS) - r.GET("/api/admin/ws", gin.WrapF(ws.HandleAdminWS(deps.Hub, deps.Queries))) + r.GET("/api/ws", dep("/api/v1/ws/listener"), listenerWS) + r.GET("/ws", dep("/api/v1/ws/listener"), listenerWS) + r.GET("/api/admin/ws", dep("/api/v1/ws/admin"), gin.WrapF(ws.HandleAdminWS(deps.Hub, deps.Queries))) + + // Native (v1) WebSocket endpoints. Registered on the root router rather + // than inside the /api/v1 group because the V1ErrorEnvelope middleware + // buffers HTTP response bodies, which would corrupt the WebSocket upgrade. + r.GET("/api/v1/ws/listener", gin.WrapF(ws.HandleListenerWSv1(deps.Hub, deps.Queries))) + r.GET("/api/v1/ws/admin", gin.WrapF(ws.HandleAdminWSv1(deps.Hub, deps.Queries))) + + // ----- Native API (Phase N-1, plan §4.1) --------------------------------- + // All v1 routes carry the V1Marker so version-aware middleware can branch, + // and the V1ErrorEnvelope rewriter normalises any 4xx/5xx body emitted by + // shared handlers into the native {error:{code,message,details}} shape. + v1 := r.Group("/api/v1") + v1.Use(middleware.V1Marker(), middleware.V1ErrorEnvelope()) + + // Unauthenticated. + v1.GET("/health", healthHandler.Get) + v1.GET("/setup/status", setupHandler.GetSetupStatus) + v1.POST("/setup", middleware.MaxBodySize(1<<20), setupHandler.PostSetup) + v1.POST("/auth/login", middleware.MaxBodySize(1<<20), middleware.RateLimit(deps.RateLimiter), authH.PostLogin) + v1.POST("/auth/refresh", middleware.MaxBodySize(1<<20), middleware.RateLimit(deps.RateLimiter), authH.PostRefresh) + + // Public call surfaces (optional auth, share links). + v1.GET("/calls", middleware.OptionalJWTAuth(), callHandler.GetCalls) + v1.GET("/calls/:id/audio", middleware.OptionalJWTOrSessionAuth(), callHandler.GetCallAudio) + v1.GET("/calls/:id/transcript", middleware.OptionalJWTAuth(), callHandler.GetCallTranscript) + v1SharedRateLimit := middleware.RateLimitByIP(30) + v1.GET("/shared/:token", v1SharedRateLimit, shareHandler.GetSharedCallByToken) + v1.GET("/shared/:token/audio", v1SharedRateLimit, shareHandler.GetSharedCallAudio) + + // JWT-protected v1 routes. + v1Auth := v1.Group("") + v1Auth.Use(middleware.JWTAuth()) + { + v1Auth.POST("/auth/logout", authH.PostLogout) + v1Auth.PUT("/auth/password", authH.PutPassword) + v1Auth.GET("/auth/me", authH.GetMe) + // /api/auth/tg-selection is renamed to /api/v1/listener/tg-selection + // per plan §4.1; the handler body is reused unchanged. + v1Auth.GET("/listener/tg-selection", authH.GetTGSelection) + v1Auth.PUT("/listener/tg-selection", authH.PutTGSelection) + v1Auth.POST("/calls/:id/share", shareHandler.PostShareCall) + v1Auth.DELETE("/calls/:id/share", shareHandler.DeleteShareCall) + v1Auth.GET("/calls/:id/share", shareHandler.GetCallShare) + v1Auth.GET("/bookmarks", bookmarkHandler.GetBookmarkIDs) + v1Auth.GET("/bookmarks/calls", bookmarkHandler.GetBookmarkCalls) + v1Auth.POST("/bookmarks", bookmarkHandler.PostToggleBookmark) + } + + // Native upload — Authorization: Bearer only (enforced by + // APIKeyAuth's v1 branch, keyed off V1Marker). + v1Upload := v1.Group("") + v1Upload.Use(middleware.MaxBodySize(50<<20), middleware.APIKeyAuth(deps.Queries)) + { + v1Upload.POST("/calls", callHandler.PostCallUploadV1) + v1Upload.POST("/calls/test", callHandler.PostCallsTestV1) + } + + // Admin v1 routes. + v1Admin := v1.Group("/admin") + v1Admin.Use(middleware.JWTAuth(), middleware.RequireAdmin(), middleware.MaxBodySize(2<<20)) + { + v1Admin.POST("/import/talkgroups", importsHandler.ImportTalkgroups) + v1Admin.POST("/import/units", importsHandler.ImportUnits) + v1Admin.POST("/import/groups", importsHandler.ImportGroups) + v1Admin.POST("/import/tags", importsHandler.ImportTags) + // Plan §4.1 drops the trailing `/csv` segment on the v1 path. + v1Admin.POST("/radioreference/preview", rrHandler.PreviewCSV) + v1Admin.GET("/transcriptions/status", transcriptionsHandler.GetStatus) + v1Admin.GET("/legacy-usage", legacyUsageHandler.GetUsage) + v1Admin.POST("/docs/session", authhandler.PostDocsSession) + } + v1SwaggerDocs := v1.Group("/admin/docs") + v1SwaggerDocs.Use(middleware.SwaggerCookieAuth()) + { + v1SwaggerDocs.GET("/*any", ginSwagger.WrapHandler(swaggerFiles.Handler)) + } // Serve embedded frontend (SPA mode). serveFrontend(r) diff --git a/backend/internal/handler/setup/setup.go b/backend/internal/handler/setup/setup.go index db56d1b..d599084 100644 --- a/backend/internal/handler/setup/setup.go +++ b/backend/internal/handler/setup/setup.go @@ -34,11 +34,12 @@ type setupStatusResponse struct { // // @Summary Get setup status // @Description Returns whether initial setup is needed and whether public access is active. Always unauthenticated. -// @Tags Setup +// @Tags Setup,v1-Setup // @Produce json // @Success 200 {object} setupStatusResponse // @Failure 500 {object} ErrorResponse // @Router /setup/status [get] +// @Router /v1/setup/status [get] func (h *Handler) GetSetupStatus(c *gin.Context) { ctx := c.Request.Context() requestID, _ := c.Get("requestID") @@ -75,7 +76,7 @@ type setupRequest struct { // // @Summary Complete initial setup // @Description Creates the initial admin user and marks setup as complete. Returns 409 if setup is already done, 400 for invalid input. -// @Tags Setup +// @Tags Setup,v1-Setup // @Accept json // @Produce json // @Param request body setupRequest true "Admin credentials" @@ -84,6 +85,7 @@ type setupRequest struct { // @Failure 409 {object} ErrorResponse // @Failure 500 {object} ErrorResponse // @Router /setup [post] +// @Router /v1/setup [post] func (h *Handler) PostSetup(c *gin.Context) { // Serialise concurrent setup requests to prevent TOCTOU race (OWASP A01). h.mu.Lock() diff --git a/backend/internal/handler/share/legacy_contract_test.go b/backend/internal/handler/share/legacy_contract_test.go new file mode 100644 index 0000000..6585b9e --- /dev/null +++ b/backend/internal/handler/share/legacy_contract_test.go @@ -0,0 +1,224 @@ +package share_test + +// Phase N-0 — legacy contract freeze for the share handler package. +// +// Pins today's wire format for the rdio-scanner-shaped share endpoints under +// /api/calls/:id/share, /api/shared/:token and /api/shared/:token/audio so +// the upcoming /api/v1/* native API work cannot drift the legacy surface. +// Plan reference: docs/plans/native-api-design-plan.md §4.1. + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + + "github.com/openscanner/openscanner/internal/audio" + "github.com/openscanner/openscanner/internal/auth" + "github.com/openscanner/openscanner/internal/db" + "github.com/openscanner/openscanner/internal/handler/routes" + "github.com/openscanner/openscanner/internal/logging" +) + +func init() { + gin.SetMode(gin.TestMode) + logging.Configure(true, "") +} + +type shareFixture struct { + engine *gin.Engine + q *db.Queries + dir string + token string // bearer JWT + userID int64 + callID int64 +} + +func newShareFixture(t *testing.T) *shareFixture { + t.Helper() + sqlDB, err := db.Open(":memory:") + if err != nil { + t.Fatalf("db.Open: %v", err) + } + t.Cleanup(func() { _ = sqlDB.Close() }) + q := db.New(sqlDB) + + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + pool := audio.NewWorkerPool(ctx) + dir := t.TempDir() + proc := audio.NewProcessor(dir, pool) + + r := gin.New() + rl := auth.NewRateLimiter(context.Background()) + routes.RegisterRoutes(r, routes.Deps{ + Queries: q, RateLimiter: rl, Processor: proc, Version: "test", + }) + + if err := q.UpsertSetting(context.Background(), db.UpsertSettingParams{Key: "shareableLinks", Value: "true"}); err != nil { + t.Fatalf("UpsertSetting: %v", err) + } + + hash, _ := auth.HashPassword("pw") + now := time.Now().Unix() + uid, err := q.CreateUser(context.Background(), db.CreateUserParams{ + Username: "alice", PasswordHash: hash, Role: auth.RoleAdmin, + CreatedAt: now, UpdatedAt: now, + }) + if err != nil { + t.Fatalf("CreateUser: %v", err) + } + tok, _, err := auth.GenerateToken(uid, "alice", auth.RoleAdmin, 0) + if err != nil { + t.Fatalf("GenerateToken: %v", err) + } + + // Audio file + call row. + rel := filepath.Join("share", "a.wav") + abs := filepath.Join(dir, rel) + if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(abs, []byte("AUDIO"), 0o644); err != nil { + t.Fatalf("write: %v", err) + } + + sysID, err := q.CreateSystem(context.Background(), db.CreateSystemParams{SystemID: 1, Label: "S"}) + if err != nil { + t.Fatalf("CreateSystem: %v", err) + } + tgID, err := q.CreateTalkgroup(context.Background(), db.CreateTalkgroupParams{ + SystemID: sysID, TalkgroupID: 100, + Label: sql.NullString{String: "Fire", Valid: true}, + }) + if err != nil { + t.Fatalf("CreateTalkgroup: %v", err) + } + callID, err := q.CreateCall(context.Background(), db.CreateCallParams{ + AudioPath: rel, AudioName: "a.wav", AudioType: "audio/wav", + DateTime: now, + SystemID: sysID, + TalkgroupID: sql.NullInt64{Int64: tgID, Valid: true}, + }) + if err != nil { + t.Fatalf("CreateCall: %v", err) + } + + return &shareFixture{engine: r, q: q, dir: dir, token: tok, userID: uid, callID: callID} +} + +func (f *shareFixture) bearer(req *http.Request) { + req.Header.Set("Authorization", "Bearer "+f.token) +} + +// TestShareLegacyContract pins the request/response shapes for the four +// share endpoints + the public token endpoints. Each step asserts the keys +// and types that the legacy surface promises. +func TestShareLegacyContract(t *testing.T) { + f := newShareFixture(t) + + // 1) POST /api/calls/:id/share — create. + req := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/api/calls/%d/share", f.callID), nil) + f.bearer(req) + w := httptest.NewRecorder() + f.engine.ServeHTTP(w, req) + + if w.Code != http.StatusOK && w.Code != http.StatusCreated { + t.Fatalf("POST share: status = %d, want 200/201; body: %s", w.Code, w.Body.String()) + } + var created struct { + Token string `json:"token"` + URL string `json:"url"` + } + if err := json.Unmarshal(w.Body.Bytes(), &created); err != nil { + t.Fatalf("POST share decode: %v", err) + } + if created.Token == "" { + t.Fatal("POST share: empty token") + } + if _, err := uuid.Parse(created.Token); err != nil { + t.Errorf("POST share: token = %q, want UUID: %v", created.Token, err) + } + wantURL := "/call/" + created.Token + if created.URL != wantURL { + t.Errorf("POST share: url = %q, want %q", created.URL, wantURL) + } + + // 2) GET /api/calls/:id/share — read existing. + req = httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/calls/%d/share", f.callID), nil) + f.bearer(req) + w = httptest.NewRecorder() + f.engine.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("GET share: status = %d, want 200; body: %s", w.Code, w.Body.String()) + } + var got struct { + Token string `json:"token"` + } + if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { + t.Fatalf("GET share decode: %v", err) + } + if got.Token != created.Token { + t.Errorf("GET share: token = %q, want %q", got.Token, created.Token) + } + + // 3) GET /api/shared/:token — public payload shape. + req = httptest.NewRequest(http.MethodGet, "/api/shared/"+created.Token, nil) + w = httptest.NewRecorder() + f.engine.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("GET shared: status = %d, want 200; body: %s", w.Code, w.Body.String()) + } + var public map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &public); err != nil { + t.Fatalf("GET shared decode: %v", err) + } + for _, k := range []string{ + "token", "dateTime", "systemLabel", "talkgroupLabel", "talkgroupName", + "frequency", "duration", "source", "audioUrl", + } { + if _, ok := public[k]; !ok { + t.Errorf("GET shared: missing key %q", k) + } + } + if public["token"] != created.Token { + t.Errorf("GET shared: token = %v, want %q", public["token"], created.Token) + } + + // 4) GET /api/shared/:token/audio — public stream. + req = httptest.NewRequest(http.MethodGet, "/api/shared/"+created.Token+"/audio", nil) + w = httptest.NewRecorder() + f.engine.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("GET shared/audio: status = %d, want 200; body: %s", w.Code, w.Body.String()) + } + if w.Body.String() != "AUDIO" { + t.Errorf("GET shared/audio: body = %q, want %q", w.Body.String(), "AUDIO") + } + + // 5) DELETE /api/calls/:id/share — revoke. + req = httptest.NewRequest(http.MethodDelete, fmt.Sprintf("/api/calls/%d/share", f.callID), nil) + f.bearer(req) + w = httptest.NewRecorder() + f.engine.ServeHTTP(w, req) + if w.Code != http.StatusOK && w.Code != http.StatusNoContent { + t.Fatalf("DELETE share: status = %d, want 200/204; body: %s", w.Code, w.Body.String()) + } + + // 6) Subsequent GET /api/shared/:token must 404. + req = httptest.NewRequest(http.MethodGet, "/api/shared/"+created.Token, nil) + w = httptest.NewRecorder() + f.engine.ServeHTTP(w, req) + if w.Code != http.StatusNotFound { + t.Errorf("GET shared after delete: status = %d, want 404", w.Code) + } +} diff --git a/backend/internal/handler/share/share.go b/backend/internal/handler/share/share.go index b30f6cf..98294a5 100644 --- a/backend/internal/handler/share/share.go +++ b/backend/internal/handler/share/share.go @@ -119,7 +119,7 @@ type ShareCreateResponse struct { // // @Summary Share a call // @Description Creates a shared_links record for the call and returns the token + URL. -// @Tags Sharing +// @Tags Sharing,v1-Calls // @Produce json // @Security BearerAuth // @Param id path int true "Call ID" @@ -131,6 +131,7 @@ type ShareCreateResponse struct { // @Failure 404 {object} ErrorResponse // @Failure 500 {object} ErrorResponse // @Router /calls/{id}/share [post] +// @Router /v1/calls/{id}/share [post] func (h *Handler) PostShareCall(c *gin.Context) { ctx := c.Request.Context() @@ -220,7 +221,7 @@ func (h *Handler) PostShareCall(c *gin.Context) { // // @Summary Unshare a call // @Description Removes the shared_links record. Only the original sharer or an admin can unshare. -// @Tags Sharing +// @Tags Sharing,v1-Calls // @Produce json // @Security BearerAuth // @Param id path int true "Call ID" @@ -230,6 +231,7 @@ func (h *Handler) PostShareCall(c *gin.Context) { // @Failure 404 {object} ErrorResponse // @Failure 500 {object} ErrorResponse // @Router /calls/{id}/share [delete] +// @Router /v1/calls/{id}/share [delete] func (h *Handler) DeleteShareCall(c *gin.Context) { ctx := c.Request.Context() @@ -290,7 +292,7 @@ func (h *Handler) isSharedLinkExpired(c *gin.Context, expiresAt sql.NullInt64, c // // @Summary Get shared call by token // @Description Returns call metadata as JSON for public viewing. No authentication required. -// @Tags Sharing +// @Tags Sharing,v1-Calls // @Produce json // @Param token path string true "Share token" // @Failure 400 {object} ErrorResponse @@ -298,6 +300,7 @@ func (h *Handler) isSharedLinkExpired(c *gin.Context, expiresAt sql.NullInt64, c // @Failure 404 {object} ErrorResponse // @Failure 500 {object} ErrorResponse // @Router /shared/{token} [get] +// @Router /v1/shared/{token} [get] func (h *Handler) GetSharedCallByToken(c *gin.Context) { ctx := c.Request.Context() token := c.Param("token") @@ -349,7 +352,7 @@ func (h *Handler) GetSharedCallByToken(c *gin.Context) { // // @Summary Get shared call audio // @Description Serves the audio file for a shared call. No authentication required. -// @Tags Sharing +// @Tags Sharing,v1-Calls // @Produce application/octet-stream // @Param token path string true "Share token" // @Failure 400 {object} ErrorResponse @@ -357,6 +360,7 @@ func (h *Handler) GetSharedCallByToken(c *gin.Context) { // @Failure 404 {object} ErrorResponse // @Failure 500 {object} ErrorResponse // @Router /shared/{token}/audio [get] +// @Router /v1/shared/{token}/audio [get] func (h *Handler) GetSharedCallAudio(c *gin.Context) { token := c.Param("token") if token == "" { @@ -436,7 +440,7 @@ func (h *Handler) GetSharedCallAudio(c *gin.Context) { // // @Summary Get call share status // @Description Returns the share token for a call if it exists, for authenticated users. -// @Tags Sharing +// @Tags Sharing,v1-Calls // @Produce json // @Security BearerAuth // @Param id path int true "Call ID" @@ -445,6 +449,7 @@ func (h *Handler) GetSharedCallAudio(c *gin.Context) { // @Failure 404 {object} ErrorResponse // @Failure 500 {object} ErrorResponse // @Router /calls/{id}/share [get] +// @Router /v1/calls/{id}/share [get] func (h *Handler) GetCallShare(c *gin.Context) { ctx := c.Request.Context() diff --git a/backend/internal/handler/shared/errors.go b/backend/internal/handler/shared/errors.go new file mode 100644 index 0000000..6021fa8 --- /dev/null +++ b/backend/internal/handler/shared/errors.go @@ -0,0 +1,104 @@ +// Phase N-1 — RFC-style error envelope used by every /api/v1/* handler. +// +// All native v1 error responses share the shape: +// +// { +// "error": { +// "code": "validation_failed", +// "message": "human-readable english", +// "details": { ... } // optional, endpoint-specific +// } +// } +// +// See docs/plans/native-api-design-plan.md §7 for the full contract. +package shared + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +// Native v1 error codes. Not exhaustive — handlers may emit endpoint-specific +// codes — but these are the canonical ones used by the middleware-level +// envelope rewriter and the most common 4xx/5xx branches. +const ( + CodeValidationFailed = "validation_failed" + CodeInvalidCredentials = "invalid_credentials" + CodeForbidden = "forbidden" + CodeNotFound = "not_found" + CodeCallNotFound = "call_not_found" + CodeConflict = "conflict" + CodeDuplicateCall = "duplicate_call" + CodeUnprocessable = "unprocessable" + CodeSystemNotFound = "system_not_found" + CodeTalkgroupNotFound = "talkgroup_not_found" + CodeRateLimited = "rate_limited" + CodeInternalError = "internal_error" +) + +// APIError is the inner object of the native v1 error envelope. +type APIError struct { + Code string `json:"code"` // stable machine identifier + Message string `json:"message"` // human-readable English + Details map[string]any `json:"details,omitempty"` // endpoint-specific +} // @name APIError + +// APIErrorResponse is the outer JSON envelope: `{"error": {...}}`. +type APIErrorResponse struct { + Error APIError `json:"error"` +} // @name APIErrorResponse + +// WriteAPIError emits the native error envelope and aborts the gin context. +// +// For 5xx responses, the request id (set by middleware.RequestID) is auto- +// injected into details.requestId so operators can correlate the response +// with server logs without leaking other internals. +func WriteAPIError(c *gin.Context, status int, code, message string, details map[string]any) { + if status >= http.StatusInternalServerError { + if rid, ok := c.Get("requestID"); ok { + if ridStr, ok := rid.(string); ok && ridStr != "" { + if details == nil { + details = map[string]any{} + } + if _, exists := details["requestId"]; !exists { + details["requestId"] = ridStr + } + } + } + } + c.AbortWithStatusJSON(status, APIErrorResponse{ + Error: APIError{ + Code: code, + Message: message, + Details: details, + }, + }) +} + +// DefaultCodeForStatus returns the canonical v1 error code for a status code. +// Used by the envelope-rewriter middleware to translate legacy +// `{"error": "..."}` responses emitted by shared handlers into the native shape. +func DefaultCodeForStatus(status int) string { + switch status { + case http.StatusBadRequest: + return CodeValidationFailed + case http.StatusUnauthorized: + return CodeInvalidCredentials + case http.StatusForbidden: + return CodeForbidden + case http.StatusNotFound: + return CodeNotFound + case http.StatusConflict: + return CodeConflict + case http.StatusUnprocessableEntity: + return CodeUnprocessable + case http.StatusTooManyRequests: + return CodeRateLimited + default: + if status >= 500 { + return CodeInternalError + } + return CodeValidationFailed + } +} diff --git a/backend/internal/handler/shared/errors_test.go b/backend/internal/handler/shared/errors_test.go new file mode 100644 index 0000000..d8cfc0f --- /dev/null +++ b/backend/internal/handler/shared/errors_test.go @@ -0,0 +1,91 @@ +package shared + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" +) + +func TestWriteAPIError_TableDriven(t *testing.T) { + gin.SetMode(gin.TestMode) + + tests := []struct { + name string + status int + code string + message string + details map[string]any + injectReqID string + wantReqID bool + }{ + {"400 validation_failed", http.StatusBadRequest, CodeValidationFailed, "talkgroupId required", map[string]any{"field": "talkgroupId"}, "", false}, + {"401 invalid_credentials", http.StatusUnauthorized, CodeInvalidCredentials, "bearer missing", nil, "", false}, + {"403 forbidden", http.StatusForbidden, CodeForbidden, "admin required", nil, "", false}, + {"404 call_not_found", http.StatusNotFound, CodeCallNotFound, "no call with id 1", map[string]any{"id": 1}, "", false}, + {"409 duplicate_call", http.StatusConflict, CodeDuplicateCall, "dup", map[string]any{"existingId": 9001}, "", false}, + {"422 unprocessable", http.StatusUnprocessableEntity, CodeSystemNotFound, "system 502 not configured", map[string]any{"systemId": 502}, "", false}, + {"429 rate_limited", http.StatusTooManyRequests, CodeRateLimited, "slow down", map[string]any{"retryAfterSeconds": 30}, "", false}, + {"500 internal_error injects requestId", http.StatusInternalServerError, CodeInternalError, "boom", nil, "req-xyz", true}, + {"500 keeps existing requestId in details", http.StatusInternalServerError, CodeInternalError, "boom", map[string]any{"requestId": "preset"}, "req-xyz", true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + if tc.injectReqID != "" { + c.Set("requestID", tc.injectReqID) + } + WriteAPIError(c, tc.status, tc.code, tc.message, tc.details) + + if w.Code != tc.status { + t.Fatalf("status = %d, want %d", w.Code, tc.status) + } + var env APIErrorResponse + if err := json.Unmarshal(w.Body.Bytes(), &env); err != nil { + t.Fatalf("decode: %v\nbody: %s", err, w.Body.String()) + } + if env.Error.Code != tc.code { + t.Errorf("code = %q, want %q", env.Error.Code, tc.code) + } + if env.Error.Message != tc.message { + t.Errorf("message = %q, want %q", env.Error.Message, tc.message) + } + if tc.wantReqID { + rid, ok := env.Error.Details["requestId"].(string) + if !ok || rid == "" { + t.Errorf("expected details.requestId to be set; got %v", env.Error.Details) + } + // Existing preset values must be preserved (not overwritten). + if pre, ok := tc.details["requestId"].(string); ok && pre != "" { + if rid != pre { + t.Errorf("requestId overwrote preset value: got %q, want %q", rid, pre) + } + } + } + }) + } +} + +func TestDefaultCodeForStatus(t *testing.T) { + cases := map[int]string{ + http.StatusBadRequest: CodeValidationFailed, + http.StatusUnauthorized: CodeInvalidCredentials, + http.StatusForbidden: CodeForbidden, + http.StatusNotFound: CodeNotFound, + http.StatusConflict: CodeConflict, + http.StatusUnprocessableEntity: CodeUnprocessable, + http.StatusTooManyRequests: CodeRateLimited, + http.StatusInternalServerError: CodeInternalError, + http.StatusServiceUnavailable: CodeInternalError, + 418: CodeValidationFailed, // unknown 4xx → validation_failed default + } + for status, want := range cases { + if got := DefaultCodeForStatus(status); got != want { + t.Errorf("DefaultCodeForStatus(%d) = %q, want %q", status, got, want) + } + } +} diff --git a/backend/internal/middleware/auth.go b/backend/internal/middleware/auth.go index 051a2f1..5389db0 100644 --- a/backend/internal/middleware/auth.go +++ b/backend/internal/middleware/auth.go @@ -182,24 +182,62 @@ func RequireAdmin() gin.HandlerFunc { } } -// APIKeyAuth reads the API key from the X-API-Key header, ?key= query param, -// or (for Trunk Recorder compatibility) a multipart "key" form field — in that -// order. It looks up the key in the database and sets "apiKeyID" in the Gin -// context. Aborts with 401 if the key is missing, not found, or disabled. +// APIKeyAuth reads the API key from the API-key transports allowed for the +// active API version, then looks up the key in the database and sets +// "apiKeyID" in the Gin context. Aborts with 401 if the key is missing, not +// found, or disabled. +// +// On legacy paths the three rdio-scanner-style transports are accepted, in +// priority order: X-API-Key header, ?key= query param, and (for Trunk +// Recorder's rdioscanner_uploader plugin) a multipart "key" form field. +// +// On the native /api/v1/* surface — identified by the gin context flag +// "apiVersion" == "v1", set by V1Marker() on the v1 route group — only +// Authorization: Bearer is honoured. JWT-shaped Bearer values +// (three base64url segments separated by dots) are rejected with the +// invalid_credentials envelope so the client surfaces the right error. +// +// The API-key value format itself is unchanged; only the wire transport +// differs between the two surfaces. func APIKeyAuth(queries *db.Queries) gin.HandlerFunc { return func(c *gin.Context) { requestID, _ := c.Get("requestID") + isV1 := c.GetString("apiVersion") == "v1" - // Prefer header, then query string. Only fall back to PostForm - // (which parses the entire multipart body) when both are empty. - key := c.GetHeader("X-API-Key") - if key == "" { - key = c.Query("key") - } - if key == "" { - // Trunk Recorder's rdioscanner_uploader plugin sends the API key - // as a multipart form field named "key" rather than a header. - key = c.PostForm("key") + // Resolve the API key string from the transports allowed for this + // API version. Legacy: header → query → form (rdio-scanner-shaped). + // Native v1: Authorization: Bearer only. + var key string + if isV1 { + header := c.GetHeader("Authorization") + if strings.HasPrefix(header, "Bearer ") { + key = strings.TrimSpace(strings.TrimPrefix(header, "Bearer ")) + } + // Reject JWT-shaped tokens here so Bearer-JWT-on-API-key-route + // returns the canonical invalid_credentials envelope rather than + // "API key required". + if key != "" && looksLikeJWT(key) { + slog.Warn("api key auth (v1): rejected JWT-shaped bearer", + "request_id", requestID, + "ip", c.ClientIP(), + "path", c.Request.URL.Path, + ) + c.AbortWithStatusJSON(401, gin.H{ + "error": gin.H{ + "code": "invalid_credentials", + "message": "API key required (Authorization: Bearer)", + }, + }) + return + } + } else { + key = c.GetHeader("X-API-Key") + if key == "" { + key = c.Query("key") + } + if key == "" { + key = c.PostForm("key") + } } if key == "" { slog.Warn("api key auth: missing X-API-Key header", @@ -247,6 +285,9 @@ func APIKeyAuth(queries *db.Queries) gin.HandlerFunc { } c.Set("apiKeyID", apiKey.ID) + if apiKey.Ident.Valid { + c.Set("apiKeyIdent", apiKey.Ident.String) + } if apiKey.CallRateLimit.Valid { c.Set("apiKeyCallRate", apiKey.CallRateLimit.Int64) } @@ -259,6 +300,26 @@ func APIKeyAuth(queries *db.Queries) gin.HandlerFunc { } } +// looksLikeJWT returns true when s has the structural shape of a JWT: +// three non-empty base64url segments separated by two dots. Used by the v1 +// APIKeyAuth path to surface a clearer error when a caller mistakenly sends +// a user JWT to an API-key-protected endpoint. +func looksLikeJWT(s string) bool { + if len(s) < 20 { + return false + } + if strings.Count(s, ".") != 2 { + return false + } + parts := strings.Split(s, ".") + for _, p := range parts { + if p == "" { + return false + } + } + return true +} + // SwaggerCookieAuth validates the short-lived docs session cookie. func SwaggerCookieAuth() gin.HandlerFunc { return func(c *gin.Context) { diff --git a/backend/internal/middleware/deprecation.go b/backend/internal/middleware/deprecation.go new file mode 100644 index 0000000..fa22ee9 --- /dev/null +++ b/backend/internal/middleware/deprecation.go @@ -0,0 +1,244 @@ +// Phase N-3 — RFC 8594 deprecation signalling for legacy /api/* routes. +// +// Deprecated() emits the Deprecation, Sunset, Link, and Cache-Control headers +// on every response of the route it wraps, records a per-request entry into a +// 24-hour ring buffer for the admin "legacy-usage" report, and emits a +// structured slog warn line for operators. Functional behaviour is unchanged. +// +// See docs/plans/native-api-design-plan.md §10 "Phase N-3". +package middleware + +import ( + "fmt" + "log/slog" + "net/http" + "sync" + "time" + + "github.com/gin-gonic/gin" +) + +// LegacyAPISunset is the date after which the legacy /api/* surface will be +// removed. Sent in the RFC 8594 Sunset header on every legacy response. +// +// One year out from Phase N-3 implementation. Bumped only when the project +// formally rescheduled Phase N-6 ("410 Gone"). +var LegacyAPISunset = time.Date(2027, time.April, 26, 0, 0, 0, 0, time.UTC) + +// LegacyUsageEntry is one row in the 24-hour aggregate report produced by +// LegacyUsageStore.Aggregate24h. JSON tags match the admin endpoint contract. +type LegacyUsageEntry struct { + Path string `json:"path"` + Method string `json:"method"` + APIKeyIdent string `json:"apiKeyIdent"` + Count int `json:"count"` + LastSeen time.Time `json:"lastSeen"` +} + +// legacyUsageRecord is one ring-buffer slot. Stored as a value type so the +// slice is contiguous and aggregation is cache-friendly. +type legacyUsageRecord struct { + at time.Time + path string + method string + status int + apiKeyIdent string + used bool +} + +// LegacyUsageStore is a fixed-capacity, concurrency-safe ring buffer of +// recent legacy-route hits. A single instance is shared by every Deprecated +// middleware via DefaultLegacyUsageStore. +type LegacyUsageStore struct { + mu sync.Mutex + buf []legacyUsageRecord + head int // next write slot + full bool // whether the buffer has wrapped at least once + clock func() time.Time + maxAge time.Duration +} + +const legacyUsageCapacity = 4096 + +// NewLegacyUsageStore returns a new ring buffer with capacity 4096 entries +// and a 24-hour retention window. clock may be nil to use time.Now. +func NewLegacyUsageStore(clock func() time.Time) *LegacyUsageStore { + if clock == nil { + clock = time.Now + } + return &LegacyUsageStore{ + buf: make([]legacyUsageRecord, legacyUsageCapacity), + clock: clock, + maxAge: 24 * time.Hour, + } +} + +// DefaultLegacyUsageStore is the package-level singleton used by Deprecated() +// and surfaced by the GET /api/v1/admin/legacy-usage handler. Tests that need +// isolation can construct their own instance via NewLegacyUsageStore. +var DefaultLegacyUsageStore = NewLegacyUsageStore(nil) + +// Record appends one legacy-hit observation. Never blocks; never errors. +func (s *LegacyUsageStore) Record(path, method, apiKeyIdent string, status int) { + s.mu.Lock() + defer s.mu.Unlock() + s.buf[s.head] = legacyUsageRecord{ + at: s.clock(), + path: path, + method: method, + status: status, + apiKeyIdent: apiKeyIdent, + used: true, + } + s.head = (s.head + 1) % len(s.buf) + if s.head == 0 { + s.full = true + } +} + +// Aggregate24h returns one entry per (path, method, apiKeyIdent) tuple seen +// in the last 24 hours, with the count and most recent timestamp. Older +// records are pruned at read time. Result is sorted by lastSeen descending. +func (s *LegacyUsageStore) Aggregate24h() []LegacyUsageEntry { + s.mu.Lock() + defer s.mu.Unlock() + + cutoff := s.clock().Add(-s.maxAge) + type aggKey struct{ path, method, ident string } + agg := make(map[aggKey]*LegacyUsageEntry) + + walk := func(rec legacyUsageRecord) { + if !rec.used || rec.at.Before(cutoff) { + return + } + k := aggKey{rec.path, rec.method, rec.apiKeyIdent} + e, ok := agg[k] + if !ok { + agg[k] = &LegacyUsageEntry{ + Path: rec.path, + Method: rec.method, + APIKeyIdent: rec.apiKeyIdent, + Count: 1, + LastSeen: rec.at, + } + return + } + e.Count++ + if rec.at.After(e.LastSeen) { + e.LastSeen = rec.at + } + } + for _, rec := range s.buf { + walk(rec) + } + + out := make([]LegacyUsageEntry, 0, len(agg)) + for _, e := range agg { + out = append(out, *e) + } + // Sort by lastSeen desc, then path asc for deterministic output. + for i := 1; i < len(out); i++ { + for j := i; j > 0; j-- { + a, b := out[j-1], out[j] + if a.LastSeen.Before(b.LastSeen) || + (a.LastSeen.Equal(b.LastSeen) && a.Path > b.Path) { + out[j-1], out[j] = out[j], out[j-1] + continue + } + break + } + } + return out +} + +// Deprecated returns a Gin middleware that: +// +// 1. Adds the RFC 8594 deprecation headers (Deprecation, Sunset, Link) and +// Cache-Control: no-store BEFORE the handler runs, so the headers are +// present even when the downstream handler aborts. +// +// 2. After the handler completes, records the request into the +// DefaultLegacyUsageStore ring buffer and emits a structured slog warn +// line so operators can drive the migration. +// +// successor — the v1 path that replaces this legacy route +// (e.g. "/api/v1/calls"). Sent verbatim in the Link header. +// sunset — the date after which the legacy route will be removed. +// Sent in RFC 1123 form (Sunset header). +func Deprecated(successor string, sunset time.Time) gin.HandlerFunc { + sunsetHeader := sunset.UTC().Format(http.TimeFormat) + linkHeader := fmt.Sprintf(`<%s>; rel="successor-version"`, successor) + return func(c *gin.Context) { + c.Header("Deprecation", "true") + c.Header("Sunset", sunsetHeader) + c.Header("Link", linkHeader) + c.Header("Cache-Control", "no-store") + + c.Next() + + ident := resolveLegacyUsageIdent(c) + path := c.FullPath() + if path == "" { + path = c.Request.URL.Path + } + status := c.Writer.Status() + DefaultLegacyUsageStore.Record(path, c.Request.Method, ident, status) + slog.WarnContext(c.Request.Context(), "legacy endpoint hit", + "path", path, + "method", c.Request.Method, + "apiKeyIdent", ident, + "status", status, + ) + } +} + +// resolveLegacyUsageIdent returns a short identifier for the caller without +// ever exposing a raw API key. Lookup priority: +// +// 1. apiKeyIdent set by APIKeyAuth (truncated to 6 chars). +// 2. apiKeyID set by APIKeyAuth (decimal string). +// 3. userID set by JWTAuth (formatted as "u:"). +// 4. empty string for unauthenticated public requests. +func resolveLegacyUsageIdent(c *gin.Context) string { + if v, ok := c.Get("apiKeyIdent"); ok { + if s, ok := v.(string); ok && s != "" { + return truncateIdent(s, 6) + } + } + if v, ok := c.Get("apiKeyID"); ok { + switch id := v.(type) { + case int64: + if id != 0 { + return fmt.Sprintf("%d", id) + } + case int: + if id != 0 { + return fmt.Sprintf("%d", id) + } + } + } + if v, ok := c.Get("userID"); ok { + switch id := v.(type) { + case int64: + if id != 0 { + return fmt.Sprintf("u:%d", id) + } + case int: + if id != 0 { + return fmt.Sprintf("u:%d", id) + } + } + } + return "" +} + +// truncateIdent returns the first n runes of s. Used to keep API-key idents +// from leaking sensitive length information into log lines and the admin +// usage report. +func truncateIdent(s string, n int) string { + if n <= 0 || len(s) <= n { + return s + } + // Byte-truncate is fine here — idents are ASCII in practice. + return s[:n] +} diff --git a/backend/internal/middleware/deprecation_test.go b/backend/internal/middleware/deprecation_test.go new file mode 100644 index 0000000..6191e3b --- /dev/null +++ b/backend/internal/middleware/deprecation_test.go @@ -0,0 +1,228 @@ +package middleware_test + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/gin-gonic/gin" + + "github.com/openscanner/openscanner/internal/middleware" +) + +// TestDeprecatedHeaders asserts the four RFC 8594 headers are set on every +// response, including aborted handler chains, and use the configured sunset. +func TestDeprecatedHeaders(t *testing.T) { + sunset := time.Date(2027, time.April, 26, 0, 0, 0, 0, time.UTC) + wantSunset := sunset.UTC().Format(http.TimeFormat) + + tests := []struct { + name string + path string + handle gin.HandlerFunc + }{ + { + name: "200 ok handler", + path: "/api/calls", + handle: func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"ok": true}) + }, + }, + { + name: "handler aborts with 401", + path: "/api/calls", + handle: func(c *gin.Context) { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "nope"}) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gin.SetMode(gin.TestMode) + r := gin.New() + r.GET(tt.path, middleware.Deprecated("/api/v1/calls", sunset), tt.handle) + + req := httptest.NewRequest(http.MethodGet, tt.path, nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if got := w.Header().Get("Deprecation"); got != "true" { + t.Errorf("Deprecation header = %q, want %q", got, "true") + } + if got := w.Header().Get("Sunset"); got != wantSunset { + t.Errorf("Sunset header = %q, want %q", got, wantSunset) + } + wantLink := `; rel="successor-version"` + if got := w.Header().Get("Link"); got != wantLink { + t.Errorf("Link header = %q, want %q", got, wantLink) + } + if got := w.Header().Get("Cache-Control"); got != "no-store" { + t.Errorf("Cache-Control header = %q, want %q", got, "no-store") + } + }) + } +} + +// TestDeprecatedRecordsToDefaultStore confirms the singleton ring buffer +// observes hits so the admin /legacy-usage endpoint sees real traffic. +func TestDeprecatedRecordsToDefaultStore(t *testing.T) { + gin.SetMode(gin.TestMode) + // Reset the singleton so a parallel test run is deterministic. + middleware.DefaultLegacyUsageStore = middleware.NewLegacyUsageStore(nil) + + r := gin.New() + r.GET("/api/calls", middleware.Deprecated("/api/v1/calls", time.Now().Add(24*time.Hour)), + func(c *gin.Context) { c.Status(http.StatusOK) }) + + for i := 0; i < 3; i++ { + req := httptest.NewRequest(http.MethodGet, "/api/calls", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + } + + got := middleware.DefaultLegacyUsageStore.Aggregate24h() + if len(got) != 1 { + t.Fatalf("aggregate len = %d, want 1; got: %+v", len(got), got) + } + e := got[0] + if e.Path != "/api/calls" || e.Method != http.MethodGet || e.Count != 3 { + t.Errorf("unexpected entry: %+v", e) + } +} + +// TestLegacyUsageStoreRecordAndAggregate exercises the ring buffer directly: +// distinct tuples remain distinct, repeated tuples are counted, and stale +// records (>24h) are pruned at read time. +func TestLegacyUsageStoreRecordAndAggregate(t *testing.T) { + now := time.Date(2026, time.April, 26, 12, 0, 0, 0, time.UTC) + clock := now + store := middleware.NewLegacyUsageStore(func() time.Time { return clock }) + + // Five fresh hits, three of which share a tuple. + store.Record("/api/calls", "GET", "k1", 200) + clock = clock.Add(time.Second) + store.Record("/api/calls", "GET", "k1", 200) + clock = clock.Add(time.Second) + store.Record("/api/calls", "GET", "k1", 200) + clock = clock.Add(time.Second) + store.Record("/api/calls", "POST", "k1", 200) + clock = clock.Add(time.Second) + store.Record("/api/health", "GET", "", 200) + + // One stale hit older than the 24h window — must be pruned. + clock = now.Add(-25 * time.Hour) + store.Record("/api/old", "GET", "k1", 200) + clock = now.Add(time.Minute) + + entries := store.Aggregate24h() + if len(entries) != 3 { + t.Fatalf("aggregate len = %d, want 3; got: %+v", len(entries), entries) + } + + byKey := map[string]middleware.LegacyUsageEntry{} + for _, e := range entries { + byKey[e.Method+" "+e.Path+" "+e.APIKeyIdent] = e + } + if got := byKey["GET /api/calls k1"].Count; got != 3 { + t.Errorf("count for GET /api/calls k1 = %d, want 3", got) + } + if got := byKey["POST /api/calls k1"].Count; got != 1 { + t.Errorf("count for POST /api/calls k1 = %d, want 1", got) + } + if got := byKey["GET /api/health "].Count; got != 1 { + t.Errorf("count for GET /api/health (anon) = %d, want 1", got) + } + if _, present := byKey["GET /api/old k1"]; present { + t.Errorf("expected stale /api/old entry to be pruned, but was present") + } +} + +// TestLegacyUsageStoreRingWrap asserts that once the buffer wraps, the +// oldest records are overwritten and not returned. +func TestLegacyUsageStoreRingWrap(t *testing.T) { + store := middleware.NewLegacyUsageStore(nil) + // 4096 + 10 hits with rotating idents — only the last 4096 may aggregate. + const overflow = 10 + const cap = 4096 + for i := 0; i < cap+overflow; i++ { + store.Record("/api/calls", "GET", "k0", 200) + } + got := store.Aggregate24h() + if len(got) != 1 { + t.Fatalf("aggregate len = %d, want 1", len(got)) + } + if got[0].Count != cap { + t.Errorf("aggregate count = %d, want %d (ring capacity)", got[0].Count, cap) + } +} + +// TestDeprecatedIdentResolution covers the priority chain: +// apiKeyIdent → apiKeyID → userID → "". +func TestDeprecatedIdentResolution(t *testing.T) { + gin.SetMode(gin.TestMode) + tests := []struct { + name string + setup func(c *gin.Context) + wantIdent string + wantPrefix string + }{ + { + name: "no auth → empty", + setup: func(*gin.Context) {}, + wantIdent: "", + }, + { + name: "apiKeyIdent truncates to 6", + setup: func(c *gin.Context) { + c.Set("apiKeyIdent", "abcdef-very-long-name") + c.Set("apiKeyID", int64(7)) + }, + wantIdent: "abcdef", + }, + { + name: "apiKeyID fallback when no ident", + setup: func(c *gin.Context) { + c.Set("apiKeyID", int64(42)) + }, + wantIdent: "42", + }, + { + name: "userID fallback formatted u:N", + setup: func(c *gin.Context) { + c.Set("userID", int64(99)) + }, + wantPrefix: "u:99", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + store := middleware.NewLegacyUsageStore(nil) + middleware.DefaultLegacyUsageStore = store + + r := gin.New() + r.GET("/api/calls", func(c *gin.Context) { tt.setup(c) }, + middleware.Deprecated("/api/v1/calls", time.Now().Add(time.Hour)), + func(c *gin.Context) { c.Status(http.StatusOK) }, + ) + req := httptest.NewRequest(http.MethodGet, "/api/calls", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + got := store.Aggregate24h() + if len(got) != 1 { + t.Fatalf("aggregate len = %d, want 1", len(got)) + } + if tt.wantPrefix != "" { + if !strings.HasPrefix(got[0].APIKeyIdent, tt.wantPrefix) { + t.Errorf("ident = %q, want prefix %q", got[0].APIKeyIdent, tt.wantPrefix) + } + } else if got[0].APIKeyIdent != tt.wantIdent { + t.Errorf("ident = %q, want %q", got[0].APIKeyIdent, tt.wantIdent) + } + }) + } +} diff --git a/backend/internal/middleware/v1.go b/backend/internal/middleware/v1.go new file mode 100644 index 0000000..5fc335a --- /dev/null +++ b/backend/internal/middleware/v1.go @@ -0,0 +1,152 @@ +// Phase N-1 — middleware specific to the native /api/v1/* surface. +// +// V1Marker tags the gin context so version-aware middleware (currently +// APIKeyAuth) can branch without doing URL-prefix matching. +// +// V1ErrorEnvelope post-processes responses written by handlers that still +// use the legacy `{"error": ""}` shape (notably JWTAuth, RequireAdmin, +// and routes shared between legacy and v1) so that v1 callers always observe +// the canonical {error: {code, message, details}} envelope. +package middleware + +import ( + "bytes" + "encoding/json" + "net/http" + + "github.com/gin-gonic/gin" + + "github.com/openscanner/openscanner/internal/handler/shared" +) + +// V1Marker tags the gin context as part of the /api/v1/* surface. +// +// Other middleware (notably APIKeyAuth) reads c.GetString("apiVersion") to +// branch behaviour by API version without resorting to URL-prefix matching. +func V1Marker() gin.HandlerFunc { + return func(c *gin.Context) { + c.Set("apiVersion", "v1") + c.Next() + } +} + +// envelopeRewriter is a gin.ResponseWriter that buffers the body so it can be +// optionally rewritten before being flushed to the client. +type envelopeRewriter struct { + gin.ResponseWriter + buf bytes.Buffer + status int +} + +func (w *envelopeRewriter) WriteHeader(status int) { + w.status = status + // Defer to embedded writer when we flush. Don't propagate yet. +} + +func (w *envelopeRewriter) Write(p []byte) (int, error) { + return w.buf.Write(p) +} + +func (w *envelopeRewriter) WriteString(s string) (int, error) { + return w.buf.WriteString(s) +} + +// V1ErrorEnvelope rewrites legacy `{"error":""}` 4xx/5xx response +// bodies into the native v1 envelope. Bodies already in the native shape +// (`{"error":{"code":...}}`) are passed through untouched, so handlers that +// emit the native envelope directly (the v1 upload handler, etc.) are not +// double-wrapped. +// +// 2xx responses are never rewritten. +func V1ErrorEnvelope() gin.HandlerFunc { + return func(c *gin.Context) { + // Wrap the writer so we can inspect/rewrite the body after the + // handler chain runs. + orig := c.Writer + rw := &envelopeRewriter{ResponseWriter: orig, status: 0} + c.Writer = rw + defer func() { + c.Writer = orig + }() + + c.Next() + + status := rw.status + if status == 0 { + status = orig.Status() + } + if status == 0 { + status = http.StatusOK + } + + body := rw.buf.Bytes() + + // Pass through 2xx untouched. + if status < 400 { + orig.WriteHeader(status) + if len(body) > 0 { + _, _ = orig.Write(body) + } + return + } + + // Try to detect the legacy `{"error":""}` shape. If anything + // else (already native, plain text, empty), pass through unchanged. + rewritten, ok := rewriteLegacyError(body, status) + if ok { + orig.Header().Set("Content-Type", "application/json; charset=utf-8") + orig.WriteHeader(status) + _, _ = orig.Write(rewritten) + return + } + + orig.WriteHeader(status) + if len(body) > 0 { + _, _ = orig.Write(body) + } + } +} + +// rewriteLegacyError attempts to translate a legacy `{"error":""}` +// body into the v1 envelope. Returns (newBody, true) on success, (nil,false) +// when the body is not a legacy error shape and should be left alone. +func rewriteLegacyError(body []byte, status int) ([]byte, bool) { + trimmed := bytes.TrimSpace(body) + if len(trimmed) == 0 || trimmed[0] != '{' { + return nil, false + } + var raw map[string]json.RawMessage + if err := json.Unmarshal(trimmed, &raw); err != nil { + return nil, false + } + errVal, ok := raw["error"] + if !ok { + return nil, false + } + // Already-native shape: error is an object with a "code" field. Don't + // touch. + if len(errVal) > 0 && errVal[0] == '{' { + var inner map[string]json.RawMessage + if err := json.Unmarshal(errVal, &inner); err == nil { + if _, hasCode := inner["code"]; hasCode { + return nil, false + } + } + } + // Legacy shape: error is a JSON string. + var msg string + if err := json.Unmarshal(errVal, &msg); err != nil { + return nil, false + } + env := shared.APIErrorResponse{ + Error: shared.APIError{ + Code: shared.DefaultCodeForStatus(status), + Message: msg, + }, + } + out, err := json.Marshal(env) + if err != nil { + return nil, false + } + return out, true +} diff --git a/backend/internal/ws/client.go b/backend/internal/ws/client.go index a067d45..d17e773 100644 --- a/backend/internal/ws/client.go +++ b/backend/internal/ws/client.go @@ -40,6 +40,15 @@ const ( revalidatePeriod = 5 * time.Minute ) +// Protocol version markers attached to a Client at connect time. The hub +// fan-out uses this to pick the correct wire encoding (legacy 3-letter +// array frames vs. native JSON-object frames). The empty string defaults +// to legacy for back-compat with code paths that don't set the field. +const ( + protocolLegacy = "" + protocolV1 = "v1" +) + // systemGrant represents a system-level grant with optional talkgroup filtering. type systemGrant struct { ID int64 `json:"id"` @@ -57,6 +66,11 @@ type Client struct { jti string // JWT token ID, for single-session disconnect queries *db.Queries // for periodic account revalidation + // protocolVersion selects the on-wire encoding for messages sent to + // this client. Set once at connect time by the handler that accepted + // the upgrade; never mutated afterwards. + protocolVersion string + // Drop counter for slow-client telemetry. Incremented whenever a // broadcast / send-site drops a message because the send buffer is full. dropCount atomic.Int64 @@ -105,6 +119,20 @@ type adminRequest struct { Params json.RawMessage `json:"params,omitempty"` } +// isV1 reports whether this client negotiated the native v1 protocol. +func (c *Client) isV1() bool { return c.protocolVersion == protocolV1 } + +// encodeSessionExpired returns the wire bytes for a session-expired +// notification in the protocol negotiated by this client. +func (c *Client) encodeSessionExpired() []byte { + if c.isV1() { + b, _ := NewSessionExpiredV1() + return b + } + b, _ := NewXPRMessage() + return b +} + // CanReceive reports whether this client is authorized to receive a call for // the given system and talkgroup. If grants is nil/empty, everything is allowed. func (c *Client) CanReceive(systemID, talkgroupID int64) bool { @@ -164,8 +192,25 @@ func wsAcceptOptions(r *http.Request) *websocket.AcceptOptions { } } -// HandleListenerWS upgrades the HTTP connection for a listener WebSocket. +// HandleListenerWS upgrades the HTTP connection for a legacy (3-letter +// array-framed) listener WebSocket. Used on /ws and /api/ws. func HandleListenerWS(hub *Hub, queries *db.Queries) http.HandlerFunc { + return handleListenerWS(hub, queries, false) +} + +// HandleListenerWSv1 upgrades the HTTP connection for a native (JSON-object +// framed) listener WebSocket. Used on /api/v1/ws/listener. The auth +// handshake is identical to the legacy path; only the per-client encoder +// differs (selected via Client.protocolVersion). +func HandleListenerWSv1(hub *Hub, queries *db.Queries) http.HandlerFunc { + return handleListenerWS(hub, queries, true) +} + +func handleListenerWS(hub *Hub, queries *db.Queries, isV1 bool) http.HandlerFunc { + protoVer := protocolLegacy + if isV1 { + protoVer = protocolV1 + } return func(w http.ResponseWriter, r *http.Request) { conn, err := websocket.Accept(w, r, wsAcceptOptions(r)) if err != nil { @@ -173,11 +218,12 @@ func HandleListenerWS(hub *Hub, queries *db.Queries) http.HandlerFunc { "error", err, "origin", r.Header.Get("Origin"), "host", r.Host, + "v1", isV1, ) return } - slog.Debug("ws: listener connection accepted", "ip", r.RemoteAddr) + slog.Debug("ws: listener connection accepted", "ip", r.RemoteAddr, "v1", isV1) ctx := r.Context() @@ -185,9 +231,7 @@ func HandleListenerWS(hub *Hub, queries *db.Queries) http.HandlerFunc { if maxStr, err := queries.GetSetting(ctx, "maxClients"); err == nil { if maxClients, err := strconv.Atoi(maxStr.Value); err == nil && maxClients > 0 { if hub.ClientCount() >= maxClients { - msg, _ := NewMAXMessage() - _ = conn.Write(ctx, websocket.MessageText, msg) - conn.Close(websocket.StatusNormalClosure, "max clients reached") + writeMaxAndClose(ctx, conn, isV1) return } } @@ -200,16 +244,17 @@ func HandleListenerWS(hub *Hub, queries *db.Queries) http.HandlerFunc { } client := &Client{ - hub: hub, - conn: conn, - send: make(chan []byte, sendBufSize), - queries: queries, + hub: hub, + conn: conn, + send: make(chan []byte, sendBufSize), + queries: queries, + protocolVersion: protoVer, } if publicAccess { slog.Debug("ws: listener authenticated via public access") // Public access — no auth required, receive all. - if err := sendWelcome(ctx, conn, hub, queries); err != nil { + if err := sendWelcome(ctx, conn, hub, queries, isV1); err != nil { slog.Error("ws: failed to send welcome", "error", err) conn.Close(websocket.StatusInternalError, "") return @@ -235,66 +280,56 @@ func HandleListenerWS(hub *Hub, queries *db.Queries) http.HandlerFunc { return } - cmd, payload, err := ParseCommand(data) - if err != nil { + tokenStr, ok := extractAuthToken(data, isV1) + if !ok { conn.Close(websocket.StatusPolicyViolation, "invalid message") return } - // Try as JWT token string (the entire message may be just a token). - // First try to parse as a JSON string from payload, otherwise use raw cmd. - tokenStr := cmd - if payload != nil { - // The message might be [""] where cmd is the token. - tokenStr = cmd - } - // Also handle case where client sends raw token as first array element. claims, err := auth.ParseToken(tokenStr) if err != nil { slog.Info("ws: invalid JWT on listener WS") - sendExpiredAndClose(ctx, conn) + sendExpiredAndClose(ctx, conn, isV1) return } if auth.Tokens.IsRevoked(claims.ID) { slog.Info("ws: revoked JWT on listener WS", "jti", claims.ID) - sendExpiredAndClose(ctx, conn) + sendExpiredAndClose(ctx, conn, isV1) return } if claims.Role != auth.RoleListener && claims.Role != auth.RoleAdmin { slog.Info("ws: invalid role on listener WS", "role", claims.Role) - sendExpiredAndClose(ctx, conn) + sendExpiredAndClose(ctx, conn, isV1) return } // Load user grants. user, err := queries.GetUser(ctx, claims.UserID) if err != nil || user.Disabled != 0 { slog.Info("ws: user not found or disabled on listener WS", "user_id", claims.UserID) - sendExpiredAndClose(ctx, conn) + sendExpiredAndClose(ctx, conn, isV1) return } // Enforce account expiration on WS connections. if user.Expiration.Valid && user.Expiration.Int64 > 0 { if time.Now().Unix() > user.Expiration.Int64 { slog.Info("ws: expired user on listener WS", "user_id", claims.UserID) - sendExpiredAndClose(ctx, conn) + sendExpiredAndClose(ctx, conn, isV1) return } } // Check user connection limit. if user.Limit.Valid && user.Limit.Int64 > 0 { if int64(hub.countByUser(user.ID)) >= user.Limit.Int64 { - msg, _ := NewMAXMessage() - _ = conn.Write(ctx, websocket.MessageText, msg) - conn.Close(websocket.StatusNormalClosure, "connection limit") + writeMaxAndClose(ctx, conn, isV1) return } } client.userID = user.ID client.jti = claims.ID client.grants = parseGrants(user.SystemsJson) - slog.Debug("ws: listener authenticated via jwt", "user_id", user.ID, "grants", len(client.grants)) + slog.Debug("ws: listener authenticated via jwt", "user_id", user.ID, "grants", len(client.grants), "v1", isV1) - if err := sendWelcome(ctx, conn, hub, queries); err != nil { + if err := sendWelcome(ctx, conn, hub, queries, isV1); err != nil { slog.Error("ws: failed to send welcome", "error", err) conn.Close(websocket.StatusInternalError, "") return @@ -306,10 +341,51 @@ func HandleListenerWS(hub *Hub, queries *db.Queries) http.HandlerFunc { } } -// HandleAdminWS upgrades the HTTP connection for an admin WebSocket. +// writeMaxAndClose emits the version-appropriate "rejected" frame and +// closes the connection cleanly. +func writeMaxAndClose(ctx context.Context, conn *websocket.Conn, isV1 bool) { + var msg []byte + if isV1 { + msg, _ = NewRejectedV1("max_clients") + } else { + msg, _ = NewMAXMessage() + } + _ = conn.Write(ctx, websocket.MessageText, msg) + conn.Close(websocket.StatusNormalClosure, "max clients reached") +} + +// extractAuthToken pulls the JWT bearer token out of the first message in +// the auth handshake. Both legacy and v1 currently accept the same wire +// shape — a JSON array whose first element is the token string — keyed by +// ParseCommand. The isV1 flag is reserved for a future divergence (e.g. +// {"type":"auth","token":"..."}); today it is unused but kept on the +// signature to make the intent explicit. +func extractAuthToken(data []byte, _ bool) (string, bool) { + cmd, _, err := ParseCommand(data) + if err != nil { + return "", false + } + return cmd, true +} + +// HandleAdminWS upgrades the HTTP connection for a legacy admin WebSocket. // Auth is performed via the first message (JWT token) after upgrade, // matching the listener WS pattern — token never appears in the URL. func HandleAdminWS(hub *Hub, queries *db.Queries) http.HandlerFunc { + return handleAdminWS(hub, queries, false) +} + +// HandleAdminWSv1 upgrades the HTTP connection for a native (JSON-object +// framed) admin WebSocket. Used on /api/v1/ws/admin. +func HandleAdminWSv1(hub *Hub, queries *db.Queries) http.HandlerFunc { + return handleAdminWS(hub, queries, true) +} + +func handleAdminWS(hub *Hub, queries *db.Queries, isV1 bool) http.HandlerFunc { + protoVer := protocolLegacy + if isV1 { + protoVer = protocolV1 + } return func(w http.ResponseWriter, r *http.Request) { conn, err := websocket.Accept(w, r, wsAcceptOptions(r)) if err != nil { @@ -317,6 +393,7 @@ func HandleAdminWS(hub *Hub, queries *db.Queries) http.HandlerFunc { "error", err, "origin", r.Header.Get("Origin"), "host", r.Host, + "v1", isV1, ) return } @@ -338,22 +415,21 @@ func HandleAdminWS(hub *Hub, queries *db.Queries) http.HandlerFunc { return } - // Parse the first message as a JWT token. - cmd, _, err := ParseCommand(data) - if err != nil { + tokenStr, ok := extractAuthToken(data, isV1) + if !ok { conn.Close(websocket.StatusPolicyViolation, "invalid message") return } - claims, err := auth.ParseToken(cmd) + claims, err := auth.ParseToken(tokenStr) if err != nil || auth.Tokens.IsRevoked(claims.ID) { slog.Info("ws: invalid or revoked JWT on admin WS") - sendExpiredAndClose(ctx, conn) + sendExpiredAndClose(ctx, conn, isV1) return } if claims.Role != auth.RoleAdmin { slog.Info("ws: non-admin JWT on admin WS", "role", claims.Role) - sendExpiredAndClose(ctx, conn) + sendExpiredAndClose(ctx, conn, isV1) return } @@ -361,27 +437,28 @@ func HandleAdminWS(hub *Hub, queries *db.Queries) http.HandlerFunc { user, err := queries.GetUser(ctx, claims.UserID) if err != nil || user.Disabled != 0 { slog.Info("ws: admin user not found or disabled", "user_id", claims.UserID) - sendExpiredAndClose(ctx, conn) + sendExpiredAndClose(ctx, conn, isV1) return } if user.Expiration.Valid && user.Expiration.Int64 > 0 { if time.Now().Unix() > user.Expiration.Int64 { slog.Info("ws: expired admin user", "user_id", claims.UserID) - sendExpiredAndClose(ctx, conn) + sendExpiredAndClose(ctx, conn, isV1) return } } - slog.Debug("ws: admin authenticated via first-message JWT", "user_id", claims.UserID) + slog.Debug("ws: admin authenticated via first-message JWT", "user_id", claims.UserID, "v1", isV1) client := &Client{ - hub: hub, - conn: conn, - send: make(chan []byte, sendBufSize), - isAdmin: true, - userID: claims.UserID, - jti: claims.ID, - queries: queries, + hub: hub, + conn: conn, + send: make(chan []byte, sendBufSize), + isAdmin: true, + userID: claims.UserID, + jti: claims.ID, + queries: queries, + protocolVersion: protoVer, } hub.Register(client) @@ -420,6 +497,11 @@ func (c *Client) readPump(ctx context.Context) { continue } + if c.isV1() { + c.dispatchV1(ctx, data) + continue + } + cmd, payload, err := ParseCommand(data) if err != nil { continue @@ -470,24 +552,88 @@ func (c *Client) handleAdminRequest(ctx context.Context, req adminRequest) { handlers := c.adminOpHandlers() handler, ok := handlers[req.Op] if !ok { - msg, _ := NewADMRESErrorMessage(req.ReqID, "unknown op: "+req.Op) - c.trySend(msg) + c.trySend(c.encodeAdminError(req.ReqID, NativeErrCodeUnknownOp, "unknown op: "+req.Op)) return } data, err := handler(ctx, req.Params, c.userID) - var msg []byte if err != nil { if errMsg, isUser := errorString(err); isUser { - msg, _ = NewADMRESErrorMessage(req.ReqID, errMsg) + c.trySend(c.encodeAdminError(req.ReqID, NativeErrCodeValidation, errMsg)) } else { slog.Error("ws: admin op failed", "op", req.Op, "reqId", req.ReqID, "error", err) - msg, _ = NewADMRESErrorMessage(req.ReqID, errMsg) + c.trySend(c.encodeAdminError(req.ReqID, NativeErrCodeInternal, errMsg)) } - } else { - msg, _ = NewADMRESMessage(req.ReqID, data) + return + } + c.trySend(c.encodeAdminResponse(req.ReqID, data)) +} + +// encodeAdminResponse builds a successful admin response in the protocol +// version negotiated by this client. +func (c *Client) encodeAdminResponse(reqID string, data any) []byte { + if c.isV1() { + b, _ := NewAdminResponseV1(reqID, data) + return b + } + b, _ := NewADMRESMessage(reqID, data) + return b +} + +// encodeAdminError builds an error admin response in the protocol version +// negotiated by this client. The legacy frame ignores the error code and +// only carries the message. +func (c *Client) encodeAdminError(reqID, code, message string) []byte { + if c.isV1() { + b, _ := NewAdminResponseErrorV1(reqID, code, message, nil) + return b + } + b, _ := NewADMRESErrorMessage(reqID, message) + return b +} + +// dispatchV1 routes a single inbound v1 (JSON-object framed) message from +// this client. Mirrors the legacy switch in readPump but keys off the +// "type" discriminator instead of the array opcode. +func (c *Client) dispatchV1(ctx context.Context, data []byte) { + var env nativeEnvelope + if err := json.Unmarshal(data, &env); err != nil { + slog.Warn("ws: failed to parse v1 envelope", "error", err) + return + } + switch env.Type { + case TypeFeedMapUpdate: + var u nativeFeedMapUpdate + if err := json.Unmarshal(data, &u); err != nil { + slog.Warn("ws: failed to parse listener.feedMap.update", "error", err) + return + } + // Echo back as a snapshot so the client confirms its own state. + if msg, err := NewFeedMapSnapshotV1(u.FeedMap); err == nil { + c.trySend(msg) + } + case TypeAdminRequest: + if !c.isAdmin { + slog.Warn("ws: non-admin client sent admin.request") + return + } + var req nativeAdminRequest + if err := json.Unmarshal(data, &req); err != nil { + slog.Warn("ws: failed to parse admin.request", "error", err) + return + } + if req.ReqID == "" { + slog.Warn("ws: admin.request missing reqId") + return + } + c.handleAdminRequest(ctx, adminRequest{ + ReqID: req.ReqID, + Op: req.Op, + Params: req.Params, + }) + default: + slog.Warn("ws: received unknown v1 message type", "type", env.Type) } - c.trySend(msg) } func (c *Client) opActivityStats(ctx context.Context, _ json.RawMessage) (any, error) { @@ -631,13 +777,13 @@ func (c *Client) writePump(ctx context.Context) { user, err := c.queries.GetUser(ctx, c.userID) if err != nil || user.Disabled != 0 { slog.Info("ws: revalidation failed, disconnecting", "user_id", c.userID, "reason", "disabled or not found") - sendExpiredAndClose(ctx, c.conn) + sendExpiredAndClose(ctx, c.conn, c.isV1()) return } if user.Expiration.Valid && user.Expiration.Int64 > 0 { if time.Now().Unix() > user.Expiration.Int64 { slog.Info("ws: revalidation failed, disconnecting", "user_id", c.userID, "reason", "expired") - sendExpiredAndClose(ctx, c.conn) + sendExpiredAndClose(ctx, c.conn, c.isV1()) return } } @@ -645,17 +791,25 @@ func (c *Client) writePump(ctx context.Context) { } } -// sendExpiredAndClose sends an XPR message and closes the connection. -func sendExpiredAndClose(ctx context.Context, conn *websocket.Conn) { - msg, _ := NewXPRMessage() +// sendExpiredAndClose sends a session-expired frame in the protocol +// version negotiated by the handler and closes the connection. It runs +// before a Client struct exists (during the auth handshake), so the +// caller passes isV1 explicitly. +func sendExpiredAndClose(ctx context.Context, conn *websocket.Conn, isV1 bool) { + var msg []byte + if isV1 { + msg, _ = NewSessionExpiredV1() + } else { + msg, _ = NewXPRMessage() + } _ = conn.Write(ctx, websocket.MessageText, msg) conn.Close(websocket.StatusPolicyViolation, "auth failed") } -// sendWelcome sends VER and CFG messages to a newly authenticated client. -func sendWelcome(ctx context.Context, conn *websocket.Conn, hub *Hub, queries *db.Queries) error { - slog.Debug("ws: sending welcome (VER+CFG)") - // Build VER message. +// sendWelcome sends the post-auth welcome frames (legacy VER+CFG, native +// connection.welcome + scanner.config) on the given connection. +func sendWelcome(ctx context.Context, conn *websocket.Conn, hub *Hub, queries *db.Queries, isV1 bool) error { + slog.Debug("ws: sending welcome", "v1", isV1) branding := "" if s, err := queries.GetSetting(ctx, "branding"); err == nil { branding = s.Value @@ -664,24 +818,53 @@ func sendWelcome(ctx context.Context, conn *websocket.Conn, hub *Hub, queries *d if s, err := queries.GetSetting(ctx, "email"); err == nil { email = s.Value } - verMsg, err := NewVERMessage(hub.version, branding, email) + + var welcome []byte + var err error + if isV1 { + welcome, err = NewWelcomeV1(hub.version, branding, email) + } else { + welcome, err = NewVERMessage(hub.version, branding, email) + } if err != nil { return err } - if err := conn.Write(ctx, websocket.MessageText, verMsg); err != nil { + if err := conn.Write(ctx, websocket.MessageText, welcome); err != nil { return err } - cfgMsg, err := buildCFGMessage(ctx, queries) + legacyCFG, v1CFG, err := buildCFGFrames(ctx, queries) if err != nil { return err } - return conn.Write(ctx, websocket.MessageText, cfgMsg) + if isV1 { + return conn.Write(ctx, websocket.MessageText, v1CFG) + } + return conn.Write(ctx, websocket.MessageText, legacyCFG) +} + +// buildCFGFrames returns the legacy and native (v1) CFG frames for the +// current database state. Both frames carry the same config payload, only +// the wire envelope differs. +func buildCFGFrames(ctx context.Context, queries *db.Queries) (legacy, v1 []byte, err error) { + payload, err := buildCFGPayload(ctx, queries) + if err != nil { + return nil, nil, err + } + legacy, err = NewCFGMessage(payload) + if err != nil { + return nil, nil, err + } + v1, err = NewScannerConfigV1(payload) + if err != nil { + return nil, nil, err + } + return legacy, v1, nil } -// buildCFGMessage constructs the CFG WebSocket message from the current -// database state (systems, talkgroups, groups, tags, settings). -func buildCFGMessage(ctx context.Context, queries *db.Queries) ([]byte, error) { +// buildCFGPayload constructs the CFG payload (without any framing) from +// the current database state (systems, talkgroups, groups, tags, settings). +func buildCFGPayload(ctx context.Context, queries *db.Queries) (map[string]any, error) { // Resolve group and tag labels first so talkgroups carry string labels, // matching the TalkgroupConfig type expected by the frontend. groups, _ := queries.ListGroups(ctx) @@ -780,5 +963,5 @@ func buildCFGMessage(ctx context.Context, queries *db.Queries) ([]byte, error) { cfgPayload["liveTranscriptDisplay"] = s.Value == "true" } - return NewCFGMessage(cfgPayload) + return cfgPayload, nil } diff --git a/backend/internal/ws/hub.go b/backend/internal/ws/hub.go index 7ece84a..47f3d15 100644 --- a/backend/internal/ws/hub.go +++ b/backend/internal/ws/hub.go @@ -49,7 +49,14 @@ type Hub struct { const lscDebounceDuration = 3 * time.Second type broadcastMsg struct { - data []byte + // data is the legacy 3-letter array-framed payload, sent to every + // matching client whose protocolVersion is the legacy default. + data []byte + // v1 is the optional native JSON-object framed payload, sent to every + // matching client whose protocolVersion == "v1". When nil, v1 clients + // receive the legacy bytes (used by callers that have not been + // migrated to dual-encoding yet). + v1 []byte filter func(*Client) bool } @@ -100,13 +107,17 @@ func (h *Hub) Run(ctx context.Context) { h.debounceLSC() } case msg := <-h.broadcast: - slog.Debug("ws: broadcasting message", "size", len(msg.data), "has_filter", msg.filter != nil) + slog.Debug("ws: broadcasting message", "size", len(msg.data), "has_filter", msg.filter != nil, "has_v1", msg.v1 != nil) h.mu.RLock() for c := range h.clients { if msg.filter != nil && !msg.filter(c) { continue } - c.trySend(msg.data) + data := msg.data + if c.protocolVersion == protocolV1 && msg.v1 != nil { + data = msg.v1 + } + c.trySend(data) } h.mu.RUnlock() } @@ -123,12 +134,42 @@ func (h *Hub) Broadcast(data []byte, filter func(*Client) bool) { } } -// BroadcastCAL sends a metadata-only CAL message to matching clients. -// Audio bytes are no longer embedded — clients fetch them on demand from -// GET /api/calls/:id/audio. Also notifies admin clients so the activity -// dashboard can refresh. -func (h *Hub) BroadcastCAL(calMsg []byte, filter func(*Client) bool) { - h.Broadcast(calMsg, filter) +// broadcastBoth enqueues a broadcast carrying both the legacy and the +// native (v1) encoding of the same logical event. Each client receives the +// frame matching its negotiated protocol version. Non-blocking. +func (h *Hub) broadcastBoth(legacy, v1 []byte, filter func(*Client) bool) { + select { + case h.broadcast <- broadcastMsg{data: legacy, v1: v1, filter: filter}: + default: + slog.Warn("ws: broadcast channel full, dropping message") + } +} + +// BroadcastCAL fans out a new-call event to matching clients in both the +// legacy and native (v1) wire formats. The payload map is the same one +// already produced by the upload and dirmonitor handlers — its camelCase +// fields (id, audioName, audioType, dateTime, systemId, talkgroupId, +// frequency, duration, source, sources, frequencies, errorCount, +// spikeCount, talkerAlias, site, channel, decoder) are reused verbatim +// inside the native call.new envelope. Also notifies admin clients so the +// activity dashboard can refresh. +func (h *Hub) BroadcastCAL(payload map[string]any, filter func(*Client) bool) { + legacy, err := NewCALMessage(payload) + if err != nil { + slog.Error("ws: failed to build legacy CAL", "error", err) + return + } + v1, err := NewCallNewV1(payload) + if err != nil { + slog.Error("ws: failed to build native call.new", "error", err) + // Fall back to legacy-only — v1 clients will receive the legacy + // bytes, which is wrong but fails closed rather than dropping the + // call entirely. + h.Broadcast(legacy, filter) + h.BroadcastAdminEvent("activity.updated", nil) + return + } + h.broadcastBoth(legacy, v1, filter) h.BroadcastAdminEvent("activity.updated", nil) } @@ -141,39 +182,51 @@ func (h *Hub) BroadcastCFG(ctx context.Context) { return } slog.Debug("ws: rebuilding and broadcasting CFG") - cfgMsg, err := buildCFGMessage(ctx, h.queries) + legacy, v1, err := buildCFGFrames(ctx, h.queries) if err != nil { slog.Error("ws: failed to build CFG for broadcast", "error", err) return } - h.Broadcast(cfgMsg, nil) + h.broadcastBoth(legacy, v1, nil) slog.Debug("ws: cfg broadcast complete", "clients", h.ClientCount()) } -// BroadcastAdminEvent sends an ADM_EVT to all connected admin clients. +// BroadcastAdminEvent sends an admin event (legacy ADM_EVT / native +// admin.event) to all connected admin clients in both wire formats. func (h *Hub) BroadcastAdminEvent(topic string, data any) { - msg, err := NewADMEVTMessage(topic, data) + legacy, err := NewADMEVTMessage(topic, data) if err != nil { slog.Error("ws: failed to build admin event", "topic", topic, "error", err) return } - h.Broadcast(msg, func(c *Client) bool { - return c.isAdmin - }) + v1, err := NewAdminEventV1(topic, data) + if err != nil { + slog.Error("ws: failed to build native admin.event", "topic", topic, "error", err) + h.Broadcast(legacy, func(c *Client) bool { return c.isAdmin }) + return + } + h.broadcastBoth(legacy, v1, func(c *Client) bool { return c.isAdmin }) } -// BroadcastTRN sends a transcript-ready message to all connected listener -// clients. segments may be nil when diarization is disabled. +// BroadcastTRN sends a transcript-ready message (legacy TRN / native +// call.transcript) to all connected listener clients in both wire formats. +// segments may be nil when diarization is disabled. func (h *Hub) BroadcastTRN(callID int64, text string, segments any) { if h == nil { return } - msg, err := NewTRNMessage(callID, text, segments) + legacy, err := NewTRNMessage(callID, text, segments) if err != nil { slog.Error("ws: failed to build TRN message", "call_id", callID, "error", err) return } - h.Broadcast(msg, nil) + v1, err := NewCallTranscriptV1(callID, text, segments) + if err != nil { + slog.Error("ws: failed to build native call.transcript", "call_id", callID, "error", err) + h.Broadcast(legacy, nil) + return + } + h.broadcastBoth(legacy, v1, nil) } // ClientCount returns the number of non-admin (listener) clients. @@ -233,8 +286,7 @@ func (h *Hub) DisconnectByUser(userID int64) { for _, c := range targets { slog.Info("ws: disconnecting user session", "user_id", userID, "is_admin", c.isAdmin) - msg, _ := NewXPRMessage() - c.trySend(msg) + c.trySend(c.encodeSessionExpired()) h.Unregister(c) } } @@ -253,8 +305,7 @@ func (h *Hub) DisconnectByJTI(jti string) { if target != nil { slog.Info("ws: disconnecting session by JTI", "jti", jti, "user_id", target.userID) - msg, _ := NewXPRMessage() - target.trySend(msg) + target.trySend(target.encodeSessionExpired()) h.Unregister(target) } } @@ -278,12 +329,18 @@ func (h *Hub) debounceLSC() { } h.lscTimer = time.AfterFunc(lscDebounceDuration, func() { count := h.ClientCount() - msg, err := NewLSCMessage(count) + legacy, err := NewLSCMessage(count) if err != nil { slog.Error("ws: failed to build LSC message", "error", err) return } - h.Broadcast(msg, nil) + v1, err := NewListenerCountV1(count) + if err != nil { + slog.Error("ws: failed to build native listener.count", "error", err) + h.Broadcast(legacy, nil) + return + } + h.broadcastBoth(legacy, v1, nil) }) } diff --git a/backend/internal/ws/messages_test.go b/backend/internal/ws/messages_test.go index c917964..9eb1fa3 100644 --- a/backend/internal/ws/messages_test.go +++ b/backend/internal/ws/messages_test.go @@ -236,3 +236,208 @@ func TestNewMAXMessage(t *testing.T) { t.Errorf("command = %q, want %q", cmd, "MAX") } } + +// TestLegacyWireFormat_ByteEqual is a Phase N-0 contract-freeze guard for the +// rdio-scanner-shaped legacy WebSocket protocol. Each row pins the exact JSON +// bytes produced by a server-emitted constructor. Any future change to the +// array-framed wire shape (element order, key order, opcode, type +// representation) breaks this test loudly so the upcoming /api/v1/* native API +// work cannot accidentally drift the legacy surface. +// +// Plan reference: docs/plans/native-api-design-plan.md §4.2 (WebSocket +// surface) — the tabled "Legacy command" column is what these bytes pin. +func TestLegacyWireFormat_ByteEqual(t *testing.T) { + tests := []struct { + name string + got func(t *testing.T) []byte + want []byte + }{ + { + // CAL — payload is metadata only; audio bytes are fetched over HTTP. + name: "CAL with simple payload", + got: func(t *testing.T) []byte { + // json.Marshal of map[string]any sorts keys alphabetically, so + // the byte output is deterministic for a fixed input. + b, err := NewCALMessage(map[string]any{ + "id": float64(42), + "systemId": float64(10), + "talkgroupId": float64(100), + }) + if err != nil { + t.Fatalf("NewCALMessage: %v", err) + } + return b + }, + want: []byte(`["CAL",{"id":42,"systemId":10,"talkgroupId":100}]`), + }, + { + // CFG — wraps an opaque config payload. + name: "CFG with config payload", + got: func(t *testing.T) []byte { + b, err := NewCFGMessage(map[string]any{ + "systems": []any{}, + }) + if err != nil { + t.Fatalf("NewCFGMessage: %v", err) + } + return b + }, + want: []byte(`["CFG",{"systems":[]}]`), + }, + { + // VER — fixed 3-string struct in fixed key order. + name: "VER welcome", + got: func(t *testing.T) []byte { + b, err := NewVERMessage("1.2.3", "OpenScanner", "admin@example.com") + if err != nil { + t.Fatalf("NewVERMessage: %v", err) + } + return b + }, + want: []byte(`["VER",{"branding":"OpenScanner","email":"admin@example.com","version":"1.2.3"}]`), + }, + { + name: "LSC listener count", + got: func(t *testing.T) []byte { + b, err := NewLSCMessage(7) + if err != nil { + t.Fatalf("NewLSCMessage: %v", err) + } + return b + }, + want: []byte(`["LSC",7]`), + }, + { + name: "XPR session expired", + got: func(t *testing.T) []byte { + b, err := NewXPRMessage() + if err != nil { + t.Fatalf("NewXPRMessage: %v", err) + } + return b + }, + want: []byte(`["XPR"]`), + }, + { + name: "MAX max clients reached", + got: func(t *testing.T) []byte { + b, err := NewMAXMessage() + if err != nil { + t.Fatalf("NewMAXMessage: %v", err) + } + return b + }, + want: []byte(`["MAX"]`), + }, + { + name: "LFM live feed map", + got: func(t *testing.T) []byte { + b, err := NewLFMMessage(map[string]any{ + "1": map[string]any{"100": true}, + }) + if err != nil { + t.Fatalf("NewLFMMessage: %v", err) + } + return b + }, + want: []byte(`["LFM",{"1":{"100":true}}]`), + }, + { + name: "TRN transcript without segments", + got: func(t *testing.T) []byte { + b, err := NewTRNMessage(42, "hello world", nil) + if err != nil { + t.Fatalf("NewTRNMessage: %v", err) + } + return b + }, + want: []byte(`["TRN",{"callId":42,"text":"hello world"}]`), + }, + { + name: "TRN transcript with segments", + got: func(t *testing.T) []byte { + b, err := NewTRNMessage(7, "two", []any{ + map[string]any{"start": float64(0), "end": float64(1), "text": "two"}, + }) + if err != nil { + t.Fatalf("NewTRNMessage: %v", err) + } + return b + }, + want: []byte(`["TRN",{"callId":7,"segments":[{"end":1,"start":0,"text":"two"}],"text":"two"}]`), + }, + { + name: "ADM_RES success response", + got: func(t *testing.T) []byte { + b, err := NewADMRESMessage("req-1", map[string]any{"ok": true}) + if err != nil { + t.Fatalf("NewADMRESMessage: %v", err) + } + return b + }, + want: []byte(`["ADM_RES",{"data":{"ok":true},"ok":true,"reqId":"req-1"}]`), + }, + { + name: "ADM_RES error response", + got: func(t *testing.T) []byte { + b, err := NewADMRESErrorMessage("req-2", "boom") + if err != nil { + t.Fatalf("NewADMRESErrorMessage: %v", err) + } + return b + }, + want: []byte(`["ADM_RES",{"error":"boom","ok":false,"reqId":"req-2"}]`), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.got(t) + if !bytes.Equal(got, tt.want) { + t.Errorf("legacy wire shape drift\n got: %s\n want: %s", got, tt.want) + } + }) + } +} + +// TestLegacyWireFormat_ADMEVT_ByteEqual pins the ADM_EVT shape but excludes +// the volatile "at" timestamp. The constructor stamps time.Now().Unix() into +// the payload, so we round-trip through the parser and assert the stable +// keys byte-for-byte while only requiring "at" to be a positive integer. +// +// Plan reference: docs/plans/native-api-design-plan.md §4.2 — ADM_EVT row. +func TestLegacyWireFormat_ADMEVT_ByteEqual(t *testing.T) { + data, err := NewADMEVTMessage("systems.updated", map[string]any{"id": float64(1)}) + if err != nil { + t.Fatalf("NewADMEVTMessage: %v", err) + } + + var arr []json.RawMessage + if err := json.Unmarshal(data, &arr); err != nil { + t.Fatalf("not valid JSON array: %v", err) + } + if len(arr) != 2 { + t.Fatalf("expected 2 elements, got %d", len(arr)) + } + if !bytes.Equal(arr[0], []byte(`"ADM_EVT"`)) { + t.Errorf("opcode = %s, want \"ADM_EVT\"", arr[0]) + } + + var body struct { + Topic string `json:"topic"` + At int64 `json:"at"` + Data map[string]any `json:"data"` + } + if err := json.Unmarshal(arr[1], &body); err != nil { + t.Fatalf("unmarshal payload: %v", err) + } + if body.Topic != "systems.updated" { + t.Errorf("topic = %q, want %q", body.Topic, "systems.updated") + } + if body.At <= 0 { + t.Errorf("at = %d, want positive unix seconds", body.At) + } + if body.Data["id"] != float64(1) { + t.Errorf("data.id = %v, want 1", body.Data["id"]) + } +} diff --git a/backend/internal/ws/messages_v1.go b/backend/internal/ws/messages_v1.go new file mode 100644 index 0000000..fc98f5c --- /dev/null +++ b/backend/internal/ws/messages_v1.go @@ -0,0 +1,185 @@ +// Package ws — native (v1) WebSocket message constructors. +// +// These produce the JSON-object framed messages described in +// docs/plans/native-api-design-plan.md §4.2. They live alongside the +// legacy 3-letter array-framed constructors in messages.go; the per-client +// encoder selection happens at connect time based on which handler accepted +// the upgrade (legacy /ws, /api/ws, /api/admin/ws vs native +// /api/v1/ws/listener, /api/v1/ws/admin). +package ws + +import ( + "encoding/json" + "time" +) + +// Native message type discriminators. +const ( + TypeWelcome = "connection.welcome" + TypeRejected = "connection.rejected" + TypeScannerConfig = "scanner.config" + TypeCallNew = "call.new" + TypeCallTranscript = "call.transcript" + TypeSessionExpired = "session.expired" + TypeListenerCount = "listener.count" + TypeFeedMapSnapshot = "listener.feedMap.snapshot" + TypeFeedMapUpdate = "listener.feedMap.update" + TypeAdminEvent = "admin.event" + TypeAdminRequest = "admin.request" + TypeAdminResponse = "admin.response" +) + +// Native admin error codes used inside admin.response.error.code. +// These mirror the REST error envelope code vocabulary (§7) so the frontend +// can share a single discriminator across REST and WS error surfaces. +const ( + NativeErrCodeValidation = "validation_failed" + NativeErrCodeUnknownOp = "unknown_op" + NativeErrCodeInternal = "internal_error" +) + +// connectionWelcomeV1 is the v1 welcome envelope sent on connect. +type connectionWelcomeV1 struct { + Type string `json:"type"` + Version string `json:"version"` + Branding string `json:"branding"` + Email string `json:"email"` +} + +// NewWelcomeV1 builds a connection.welcome JSON-object frame. +func NewWelcomeV1(version, branding, email string) ([]byte, error) { + return json.Marshal(connectionWelcomeV1{ + Type: TypeWelcome, + Version: version, + Branding: branding, + Email: email, + }) +} + +// NewScannerConfigV1 builds a scanner.config JSON-object frame. +func NewScannerConfigV1(config any) ([]byte, error) { + return json.Marshal(map[string]any{ + "type": TypeScannerConfig, + "config": config, + }) +} + +// NewCallNewV1 builds a call.new JSON-object frame. The call object carries +// the same camelCase metadata fields as the legacy CAL payload (id, +// systemId, talkgroupId, dateTime, audioName, audioType, frequency, +// duration, source, sources, frequencies, errorCount, spikeCount, +// talkerAlias, site, channel, decoder). +func NewCallNewV1(call any) ([]byte, error) { + return json.Marshal(map[string]any{ + "type": TypeCallNew, + "call": call, + }) +} + +// NewSessionExpiredV1 builds a session.expired JSON-object frame. +func NewSessionExpiredV1() ([]byte, error) { + return json.Marshal(map[string]string{"type": TypeSessionExpired}) +} + +// NewListenerCountV1 builds a listener.count JSON-object frame. +func NewListenerCountV1(count int) ([]byte, error) { + return json.Marshal(map[string]any{ + "type": TypeListenerCount, + "count": count, + }) +} + +// NewFeedMapSnapshotV1 builds a listener.feedMap.snapshot JSON-object frame +// (server → client). The client → server counterpart is parsed inline as +// listener.feedMap.update. +func NewFeedMapSnapshotV1(feedMap map[string]any) ([]byte, error) { + return json.Marshal(map[string]any{ + "type": TypeFeedMapSnapshot, + "feedMap": feedMap, + }) +} + +// NewRejectedV1 builds a connection.rejected JSON-object frame, sent +// immediately before the server closes the connection (e.g. max clients). +func NewRejectedV1(reason string) ([]byte, error) { + return json.Marshal(map[string]string{ + "type": TypeRejected, + "reason": reason, + }) +} + +// NewCallTranscriptV1 builds a call.transcript JSON-object frame. segments +// is omitted when nil to mirror the legacy TRN behaviour. +func NewCallTranscriptV1(callID int64, text string, segments any) ([]byte, error) { + payload := map[string]any{ + "type": TypeCallTranscript, + "callId": callID, + "text": text, + } + if segments != nil { + payload["segments"] = segments + } + return json.Marshal(payload) +} + +// NewAdminEventV1 builds an admin.event JSON-object frame. +func NewAdminEventV1(topic string, data any) ([]byte, error) { + return json.Marshal(map[string]any{ + "type": TypeAdminEvent, + "topic": topic, + "at": time.Now().Unix(), + "data": data, + }) +} + +// NewAdminResponseV1 builds an admin.response JSON-object frame for a +// successful op. +func NewAdminResponseV1(reqID string, data any) ([]byte, error) { + return json.Marshal(map[string]any{ + "type": TypeAdminResponse, + "reqId": reqID, + "ok": true, + "data": data, + }) +} + +// NativeErrorEnvelope mirrors the REST error envelope's error object so the +// frontend can share a single discriminator across REST and WS error +// surfaces (plan §7). +type NativeErrorEnvelope struct { + Code string `json:"code"` + Message string `json:"message"` + Details any `json:"details,omitempty"` +} + +// NewAdminResponseErrorV1 builds an admin.response error JSON-object frame. +// The error object mirrors the REST error envelope shape. +func NewAdminResponseErrorV1(reqID, code, message string, details any) ([]byte, error) { + env := NativeErrorEnvelope{Code: code, Message: message, Details: details} + return json.Marshal(map[string]any{ + "type": TypeAdminResponse, + "reqId": reqID, + "ok": false, + "error": env, + }) +} + +// nativeAdminRequest is the inbound shape for v1 admin request frames. +type nativeAdminRequest struct { + Type string `json:"type"` + ReqID string `json:"reqId"` + Op string `json:"op"` + Params json.RawMessage `json:"params,omitempty"` +} + +// nativeFeedMapUpdate is the inbound shape for v1 listener.feedMap.update +// frames. +type nativeFeedMapUpdate struct { + Type string `json:"type"` + FeedMap map[string]any `json:"feedMap"` +} + +// nativeEnvelope sniffs the type discriminator on inbound v1 frames. +type nativeEnvelope struct { + Type string `json:"type"` +} diff --git a/backend/internal/ws/messages_v1_test.go b/backend/internal/ws/messages_v1_test.go new file mode 100644 index 0000000..7e004a2 --- /dev/null +++ b/backend/internal/ws/messages_v1_test.go @@ -0,0 +1,447 @@ +package ws + +import ( + "context" + "encoding/json" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/coder/websocket" + "github.com/openscanner/openscanner/internal/auth" + "github.com/openscanner/openscanner/internal/db" +) + +// --- v1 message constructor shape tests --- + +func TestNewWelcomeV1(t *testing.T) { + b, err := NewWelcomeV1("1.2.3", "OpenScanner", "ops@example.com") + if err != nil { + t.Fatalf("NewWelcomeV1: %v", err) + } + var m map[string]any + if err := json.Unmarshal(b, &m); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if m["type"] != TypeWelcome { + t.Errorf("type = %v, want %s", m["type"], TypeWelcome) + } + if m["version"] != "1.2.3" { + t.Errorf("version = %v", m["version"]) + } + if m["branding"] != "OpenScanner" { + t.Errorf("branding = %v", m["branding"]) + } + if m["email"] != "ops@example.com" { + t.Errorf("email = %v", m["email"]) + } +} + +func TestNewScannerConfigV1(t *testing.T) { + cfg := map[string]any{"systems": []any{}, "time12hFormat": true} + b, err := NewScannerConfigV1(cfg) + if err != nil { + t.Fatalf("NewScannerConfigV1: %v", err) + } + var m map[string]any + if err := json.Unmarshal(b, &m); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if m["type"] != TypeScannerConfig { + t.Errorf("type = %v", m["type"]) + } + inner, ok := m["config"].(map[string]any) + if !ok { + t.Fatalf("config not an object: %T", m["config"]) + } + if inner["time12hFormat"] != true { + t.Errorf("time12hFormat lost in round-trip") + } +} + +func TestNewCallNewV1(t *testing.T) { + call := map[string]any{ + "id": int64(42), + "systemId": int64(1), + "talkgroupId": int64(100), + "audioName": "abc.m4a", + "audioType": "audio/mp4", + } + b, err := NewCallNewV1(call) + if err != nil { + t.Fatalf("NewCallNewV1: %v", err) + } + var m map[string]any + if err := json.Unmarshal(b, &m); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if m["type"] != TypeCallNew { + t.Errorf("type = %v, want %s", m["type"], TypeCallNew) + } + c, ok := m["call"].(map[string]any) + if !ok { + t.Fatalf("call not an object") + } + for _, k := range []string{"id", "systemId", "talkgroupId", "audioName", "audioType"} { + if _, ok := c[k]; !ok { + t.Errorf("call.%s missing", k) + } + } +} + +func TestNewSessionExpiredV1_BytePin(t *testing.T) { + b, err := NewSessionExpiredV1() + if err != nil { + t.Fatalf("NewSessionExpiredV1: %v", err) + } + const want = `{"type":"session.expired"}` + if string(b) != want { + t.Errorf("session.expired bytes = %s, want %s", b, want) + } +} + +func TestNewListenerCountV1(t *testing.T) { + b, err := NewListenerCountV1(7) + if err != nil { + t.Fatalf("NewListenerCountV1: %v", err) + } + var m map[string]any + _ = json.Unmarshal(b, &m) + if m["type"] != TypeListenerCount { + t.Errorf("type = %v", m["type"]) + } + // JSON numbers decode to float64 + if n, _ := m["count"].(float64); n != 7 { + t.Errorf("count = %v, want 7", m["count"]) + } +} + +func TestNewFeedMapSnapshotV1(t *testing.T) { + b, err := NewFeedMapSnapshotV1(map[string]any{"1": map[string]bool{"100": true}}) + if err != nil { + t.Fatalf("NewFeedMapSnapshotV1: %v", err) + } + var m map[string]any + _ = json.Unmarshal(b, &m) + if m["type"] != TypeFeedMapSnapshot { + t.Errorf("type = %v", m["type"]) + } + if _, ok := m["feedMap"]; !ok { + t.Errorf("feedMap missing") + } +} + +func TestNewRejectedV1(t *testing.T) { + b, err := NewRejectedV1("max clients reached") + if err != nil { + t.Fatalf("NewRejectedV1: %v", err) + } + const want = `{"reason":"max clients reached","type":"connection.rejected"}` + if string(b) != want { + t.Errorf("rejected bytes = %s, want %s", b, want) + } +} + +func TestNewCallTranscriptV1(t *testing.T) { + // Without segments + b, err := NewCallTranscriptV1(99, "hello world", nil) + if err != nil { + t.Fatalf("NewCallTranscriptV1: %v", err) + } + var m map[string]any + _ = json.Unmarshal(b, &m) + if m["type"] != TypeCallTranscript { + t.Errorf("type = %v", m["type"]) + } + if _, ok := m["segments"]; ok { + t.Errorf("segments must be omitted when nil") + } + + // With segments + b2, _ := NewCallTranscriptV1(99, "hi", []any{map[string]any{"start": 0.0, "end": 1.0, "text": "hi"}}) + var m2 map[string]any + _ = json.Unmarshal(b2, &m2) + if _, ok := m2["segments"]; !ok { + t.Errorf("segments must be present when non-nil") + } +} + +func TestNewAdminEventV1(t *testing.T) { + b, err := NewAdminEventV1("activity.updated", map[string]any{"foo": "bar"}) + if err != nil { + t.Fatalf("NewAdminEventV1: %v", err) + } + var m map[string]any + _ = json.Unmarshal(b, &m) + if m["type"] != TypeAdminEvent { + t.Errorf("type = %v", m["type"]) + } + if m["topic"] != "activity.updated" { + t.Errorf("topic = %v", m["topic"]) + } + if _, ok := m["at"]; !ok { + t.Errorf("at timestamp missing") + } + if _, ok := m["data"]; !ok { + t.Errorf("data missing") + } +} + +func TestNewAdminResponseV1(t *testing.T) { + b, err := NewAdminResponseV1("req-1", map[string]any{"x": 1}) + if err != nil { + t.Fatalf("NewAdminResponseV1: %v", err) + } + var m map[string]any + _ = json.Unmarshal(b, &m) + if m["type"] != TypeAdminResponse { + t.Errorf("type = %v", m["type"]) + } + if m["reqId"] != "req-1" { + t.Errorf("reqId = %v", m["reqId"]) + } + if m["ok"] != true { + t.Errorf("ok = %v, want true", m["ok"]) + } + if _, ok := m["data"]; !ok { + t.Errorf("data missing") + } +} + +func TestNewAdminResponseErrorV1(t *testing.T) { + b, err := NewAdminResponseErrorV1("req-2", NativeErrCodeValidation, "bad input", map[string]any{"field": "name"}) + if err != nil { + t.Fatalf("NewAdminResponseErrorV1: %v", err) + } + var m map[string]any + if err := json.Unmarshal(b, &m); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if m["type"] != TypeAdminResponse { + t.Errorf("type = %v", m["type"]) + } + if m["reqId"] != "req-2" { + t.Errorf("reqId = %v", m["reqId"]) + } + if m["ok"] != false { + t.Errorf("ok = %v, want false", m["ok"]) + } + envObj, ok := m["error"].(map[string]any) + if !ok { + t.Fatalf("error not an object") + } + if envObj["code"] != NativeErrCodeValidation { + t.Errorf("error.code = %v", envObj["code"]) + } + if envObj["message"] != "bad input" { + t.Errorf("error.message = %v", envObj["message"]) + } + if _, ok := envObj["details"]; !ok { + t.Errorf("error.details missing") + } +} + +func TestNewAdminResponseErrorV1_OmitsDetailsWhenNil(t *testing.T) { + b, err := NewAdminResponseErrorV1("r", NativeErrCodeUnknownOp, "no", nil) + if err != nil { + t.Fatalf("err: %v", err) + } + if strings.Contains(string(b), `"details"`) { + t.Errorf("details must be omitted when nil; got %s", b) + } +} + +// --- v1 handler integration tests --- + +func readV1Frame(ctx context.Context, t *testing.T, conn *websocket.Conn, timeout time.Duration) map[string]any { + t.Helper() + data := readTextMessage(ctx, t, conn, timeout) + var m map[string]any + if err := json.Unmarshal(data, &m); err != nil { + t.Fatalf("unmarshal v1 frame %q: %v", data, err) + } + return m +} + +func TestHandleListenerWSv1_PublicAccess_WelcomeAndConfig(t *testing.T) { + queries := newWSTestDB(t) + if err := queries.UpsertSetting(context.Background(), db.UpsertSettingParams{ + Key: "publicAccess", Value: "true", + }); err != nil { + t.Fatalf("upsert publicAccess: %v", err) + } + + hub := NewHub(queries, "test-version") + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go hub.Run(ctx) + + srv := httptest.NewServer(HandleListenerWSv1(hub, queries)) + defer srv.Close() + wsURL := "ws" + srv.URL[len("http"):] + + conn, _, err := websocket.Dial(ctx, wsURL, nil) + if err != nil { + t.Fatalf("dial: %v", err) + } + defer func() { _ = conn.CloseNow() }() + + welcome := readV1Frame(ctx, t, conn, 5*time.Second) + if welcome["type"] != TypeWelcome { + t.Errorf("first frame type = %v, want %s", welcome["type"], TypeWelcome) + } + if welcome["version"] != "test-version" { + t.Errorf("welcome.version = %v", welcome["version"]) + } + + cfg := readV1Frame(ctx, t, conn, 5*time.Second) + if cfg["type"] != TypeScannerConfig { + t.Errorf("second frame type = %v, want %s", cfg["type"], TypeScannerConfig) + } + if _, ok := cfg["config"].(map[string]any); !ok { + t.Errorf("scanner.config.config must be an object, got %T", cfg["config"]) + } +} + +func TestHandleListenerWSv1_BroadcastCallNew(t *testing.T) { + queries := newWSTestDB(t) + if err := queries.UpsertSetting(context.Background(), db.UpsertSettingParams{ + Key: "publicAccess", Value: "true", + }); err != nil { + t.Fatalf("upsert publicAccess: %v", err) + } + + hub := NewHub(queries, "test-version") + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go hub.Run(ctx) + + srv := httptest.NewServer(HandleListenerWSv1(hub, queries)) + defer srv.Close() + wsURL := "ws" + srv.URL[len("http"):] + + conn, _, err := websocket.Dial(ctx, wsURL, nil) + if err != nil { + t.Fatalf("dial: %v", err) + } + defer func() { _ = conn.CloseNow() }() + + // Drain welcome + config. + _ = readV1Frame(ctx, t, conn, 5*time.Second) + _ = readV1Frame(ctx, t, conn, 5*time.Second) + + // Wait for the client to be registered with the hub before broadcasting. + deadline := time.Now().Add(2 * time.Second) + for hub.ClientCount() == 0 && time.Now().Before(deadline) { + time.Sleep(10 * time.Millisecond) + } + + // Push a call through the hub. + payload := map[string]any{ + "id": int64(7), + "systemId": int64(1), + "talkgroupId": int64(100), + "audioName": "x.m4a", + "audioType": "audio/mp4", + "dateTime": int64(1700000000), + } + hub.BroadcastCAL(payload, nil) + + got := readV1Frame(ctx, t, conn, 5*time.Second) + if got["type"] != TypeCallNew { + t.Fatalf("broadcast frame type = %v, want %s", got["type"], TypeCallNew) + } + c, ok := got["call"].(map[string]any) + if !ok { + t.Fatalf("call payload not an object") + } + if id, _ := c["id"].(float64); id != 7 { + t.Errorf("call.id = %v, want 7", c["id"]) + } + if c["audioName"] != "x.m4a" { + t.Errorf("call.audioName = %v", c["audioName"]) + } +} + +func TestHandleAdminWSv1_AuthAndUnknownOp(t *testing.T) { + queries := newWSTestDB(t) + + now := time.Now().Unix() + userID, err := queries.CreateUser(context.Background(), db.CreateUserParams{ + Username: "admin1", + PasswordHash: "unused", + Role: auth.RoleAdmin, + Disabled: 0, + CreatedAt: now, + UpdatedAt: now, + }) + if err != nil { + t.Fatalf("create user: %v", err) + } + token, _, err := auth.GenerateToken(userID, "admin1", auth.RoleAdmin, 0) + if err != nil { + t.Fatalf("generate token: %v", err) + } + + hub := NewHub(queries, "test-version") + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go hub.Run(ctx) + + srv := httptest.NewServer(HandleAdminWSv1(hub, queries)) + defer srv.Close() + wsURL := "ws" + srv.URL[len("http"):] + + conn, _, err := websocket.Dial(ctx, wsURL, nil) + if err != nil { + t.Fatalf("dial: %v", err) + } + defer func() { _ = conn.CloseNow() }() + + // First-message JWT auth — wire shape is JSON array carrying the token, + // matching the legacy admin auth handshake. + authMsg, _ := json.Marshal([]any{token}) + if err := conn.Write(ctx, websocket.MessageText, authMsg); err != nil { + t.Fatalf("write auth: %v", err) + } + + // Send an admin.request with an unknown op and assert the v1 error envelope. + req := map[string]any{ + "type": TypeAdminRequest, + "reqId": "rq-1", + "op": "this.does.not.exist", + } + body, _ := json.Marshal(req) + if err := conn.Write(ctx, websocket.MessageText, body); err != nil { + t.Fatalf("write request: %v", err) + } + + // The hub may emit admin.event frames (e.g. activity ticks) before the + // response — skip until we see admin.response with our reqId. + deadline := time.Now().Add(5 * time.Second) + var resp map[string]any + for time.Now().Before(deadline) { + frame := readV1Frame(ctx, t, conn, 5*time.Second) + if frame["type"] == TypeAdminResponse && frame["reqId"] == "rq-1" { + resp = frame + break + } + } + if resp == nil { + t.Fatalf("did not receive admin.response for rq-1") + } + if resp["ok"] != false { + t.Errorf("ok = %v, want false", resp["ok"]) + } + envObj, ok := resp["error"].(map[string]any) + if !ok { + t.Fatalf("error not an object: %v", resp["error"]) + } + if envObj["code"] != NativeErrCodeUnknownOp { + t.Errorf("error.code = %v, want %s", envObj["code"], NativeErrCodeUnknownOp) + } + if msg, _ := envObj["message"].(string); !strings.Contains(msg, "this.does.not.exist") { + t.Errorf("error.message = %v", envObj["message"]) + } +} diff --git a/frontend/.npmrc b/frontend/.npmrc index 1377eff..a079e26 100644 --- a/frontend/.npmrc +++ b/frontend/.npmrc @@ -1,2 +1 @@ package-import-method=copy -virtual-store-dir=/tmp/pnpm-vstore diff --git a/frontend/src/app/api.ts b/frontend/src/app/api.ts index 9680916..87f394b 100644 --- a/frontend/src/app/api.ts +++ b/frontend/src/app/api.ts @@ -5,10 +5,14 @@ import { type FetchArgs, type FetchBaseQueryError, } from "@reduxjs/toolkit/query/react"; -import type { SetupStatus, RefreshResponse } from "@/types"; +import type { + SetupStatus, + RefreshResponse, + LegacyUsageResponse, +} from "@/types"; const rawBaseQuery = fetchBaseQuery({ - baseUrl: "/api", + baseUrl: "/api/v1", prepareHeaders: (headers, { getState }) => { const state = getState() as { auth: { token: string | null } }; const token = state.auth?.token; @@ -86,6 +90,7 @@ export const api = createApi({ "Bookmarks", "Setup", "SharedLinks", + "LegacyUsage", ], baseQuery: baseQueryWithRefresh, endpoints: (builder) => ({ @@ -143,6 +148,10 @@ export const api = createApi({ query: () => "/bookmarks/calls", providesTags: ["Bookmarks"], }), + getLegacyUsage: builder.query({ + query: () => "/admin/legacy-usage", + providesTags: ["LegacyUsage"], + }), }), }); @@ -152,4 +161,5 @@ export const { useGetBookmarkIDsQuery, useToggleBookmarkMutation, useGetBookmarkCallsQuery, + useGetLegacyUsageQuery, } = api; diff --git a/frontend/src/app/slices/admin/adminSlice.ts b/frontend/src/app/slices/admin/adminSlice.ts index 180b3be..2dc357f 100644 --- a/frontend/src/app/slices/admin/adminSlice.ts +++ b/frontend/src/app/slices/admin/adminSlice.ts @@ -75,7 +75,7 @@ const adminApi = api.injectEndpoints({ // ── RadioReference CSV preview (multipart file upload) ── rrPreviewCSV: builder.mutation({ query: (body) => ({ - url: "/admin/radioreference/preview/csv", + url: "/admin/radioreference/preview", method: "POST", body, }), diff --git a/frontend/src/app/slices/shared/authSlice.ts b/frontend/src/app/slices/shared/authSlice.ts index 69c9aad..88f4c4b 100644 --- a/frontend/src/app/slices/shared/authSlice.ts +++ b/frontend/src/app/slices/shared/authSlice.ts @@ -110,14 +110,14 @@ const authApi = api.injectEndpoints({ { disabledTGs: number[]; avoidList?: AvoidEntry[] }, void >({ - query: () => "/auth/tg-selection", + query: () => "/listener/tg-selection", }), updateTGSelection: builder.mutation< { ok: boolean }, { disabledTGs: number[]; avoidList: AvoidEntry[] } >({ query: (body) => ({ - url: "/auth/tg-selection", + url: "/listener/tg-selection", method: "PUT", body, }), diff --git a/frontend/src/components/admin/LegacyUsageBanner.test.tsx b/frontend/src/components/admin/LegacyUsageBanner.test.tsx new file mode 100644 index 0000000..b2e03ef --- /dev/null +++ b/frontend/src/components/admin/LegacyUsageBanner.test.tsx @@ -0,0 +1,161 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { configureStore } from "@reduxjs/toolkit"; +import { Provider } from "react-redux"; +import LegacyUsageBanner from "@/components/admin/LegacyUsageBanner"; +import { api } from "@/app/api"; +import { scannerSlice } from "@/app/slices/scanner/scannerSlice"; +import { authSlice } from "@/app/slices/shared/authSlice"; +import { callsSlice } from "@/app/slices/scanner/callsSlice"; +import type { LegacyUsageResponse } from "@/types"; + +// ── Mocks ──────────────────────────────────────────────────────────────── + +type QueryResult = { + data?: LegacyUsageResponse; + isLoading: boolean; + isError: boolean; +}; + +let mockResult: QueryResult = { + data: undefined, + isLoading: false, + isError: false, +}; + +vi.mock("@/app/api", async () => { + const actual = await vi.importActual("@/app/api"); + return { + ...actual, + useGetLegacyUsageQuery: () => mockResult, + }; +}); + +// ── Harness ────────────────────────────────────────────────────────────── + +function makeStore() { + return configureStore({ + reducer: { + scanner: scannerSlice.reducer, + auth: authSlice.reducer, + calls: callsSlice.reducer, + [api.reducerPath]: api.reducer, + }, + middleware: (gDM) => gDM().concat(api.middleware), + }); +} + +function renderBanner() { + return render( + + + , + ); +} + +const sampleResponse: LegacyUsageResponse = { + windowSeconds: 86400, + generatedAt: "2026-04-26T23:59:00Z", + entries: [ + { + path: "/api/call-upload", + method: "POST", + apiKeyIdent: "abc123", + count: 47, + lastSeen: new Date(Date.now() - 3 * 60_000).toISOString(), + }, + { + path: "/api/call", + method: "GET", + apiKeyIdent: "", + count: 5, + lastSeen: new Date(Date.now() - 30_000).toISOString(), + }, + ], +}; + +describe("LegacyUsageBanner", () => { + beforeEach(() => { + sessionStorage.clear(); + mockResult = { data: undefined, isLoading: false, isError: false }; + }); + + it("renders nothing when entries are empty", () => { + mockResult = { + data: { + windowSeconds: 86400, + generatedAt: "2026-04-26T23:59:00Z", + entries: [], + }, + isLoading: false, + isError: false, + }; + const { container } = renderBanner(); + expect(container).toBeEmptyDOMElement(); + }); + + it("renders nothing while the query is loading", () => { + mockResult = { data: undefined, isLoading: true, isError: false }; + const { container } = renderBanner(); + expect(container).toBeEmptyDOMElement(); + }); + + it("renders nothing when the query errors (e.g. 401/403)", () => { + mockResult = { data: undefined, isLoading: false, isError: true }; + const { container } = renderBanner(); + expect(container).toBeEmptyDOMElement(); + }); + + it("renders summary and entries when data is present", async () => { + mockResult = { + data: sampleResponse, + isLoading: false, + isError: false, + }; + const user = userEvent.setup(); + renderBanner(); + + expect(screen.getByText(/Legacy API in use/i)).toBeInTheDocument(); + // 52 total requests across 2 distinct keys ("abc123" + unauthenticated). + expect( + screen.getByText(/52 requests across 2 API keys/i), + ).toBeInTheDocument(); + + // Expand details. + await user.click(screen.getByText(/Show details/i)); + + expect(screen.getByText("/api/call-upload")).toBeInTheDocument(); + expect(screen.getByText("abc123")).toBeInTheDocument(); + expect(screen.getByText("(unauthenticated)")).toBeInTheDocument(); + expect(screen.getByText("47")).toBeInTheDocument(); + }); + + it("hides the banner and persists dismissal in sessionStorage on dismiss", async () => { + mockResult = { + data: sampleResponse, + isLoading: false, + isError: false, + }; + const user = userEvent.setup(); + renderBanner(); + + await user.click( + screen.getByRole("button", { name: /dismiss legacy api warning/i }), + ); + + expect(screen.queryByText(/Legacy API in use/i)).not.toBeInTheDocument(); + expect(sessionStorage.getItem("os.legacyUsageBanner.dismissed")).toBe("1"); + }); + + it("stays hidden if sessionStorage already records dismissal", () => { + sessionStorage.setItem("os.legacyUsageBanner.dismissed", "1"); + mockResult = { + data: sampleResponse, + isLoading: false, + isError: false, + }; + const { container } = renderBanner(); + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/frontend/src/components/admin/LegacyUsageBanner.tsx b/frontend/src/components/admin/LegacyUsageBanner.tsx new file mode 100644 index 0000000..1b5fd01 --- /dev/null +++ b/frontend/src/components/admin/LegacyUsageBanner.tsx @@ -0,0 +1,118 @@ +import { useState } from "react"; +import { AlertTriangle, X } from "lucide-react"; +import { useGetLegacyUsageQuery } from "@/app/api"; +import type { LegacyUsageEntry } from "@/types"; + +const DISMISS_KEY = "os.legacyUsageBanner.dismissed"; +const POLL_INTERVAL_MS = 60_000; + +function formatRelative(iso: string, now: number = Date.now()): string { + const t = Date.parse(iso); + if (Number.isNaN(t)) return iso; + const deltaSec = Math.max(0, Math.round((now - t) / 1000)); + if (deltaSec < 60) return `${deltaSec}s ago`; + const deltaMin = Math.round(deltaSec / 60); + if (deltaMin < 60) + return `${deltaMin} minute${deltaMin === 1 ? "" : "s"} ago`; + const deltaHr = Math.round(deltaMin / 60); + if (deltaHr < 24) return `${deltaHr} hour${deltaHr === 1 ? "" : "s"} ago`; + const deltaDay = Math.round(deltaHr / 24); + return `${deltaDay} day${deltaDay === 1 ? "" : "s"} ago`; +} + +function readDismissed(): boolean { + try { + return sessionStorage.getItem(DISMISS_KEY) === "1"; + } catch { + return false; + } +} + +function writeDismissed(): void { + try { + sessionStorage.setItem(DISMISS_KEY, "1"); + } catch { + // sessionStorage may be disabled — best-effort. + } +} + +export default function LegacyUsageBanner() { + const [dismissed, setDismissed] = useState(() => readDismissed()); + + const { data, isLoading, isError } = useGetLegacyUsageQuery(undefined, { + pollingInterval: POLL_INTERVAL_MS, + refetchOnFocus: true, + refetchOnMountOrArgChange: true, + }); + + if (dismissed) return null; + if (isLoading || isError) return null; + + const entries: LegacyUsageEntry[] = data?.entries ?? []; + if (entries.length === 0) return null; + + const totalRequests = entries.reduce((sum, e) => sum + e.count, 0); + const distinctKeys = new Set( + entries.map((e) => e.apiKeyIdent || "(unauthenticated)"), + ).size; + + const handleDismiss = () => { + writeDismissed(); + setDismissed(true); + }; + + return ( +
+ +
+

Legacy API in use

+

+ {totalRequests} request{totalRequests === 1 ? "" : "s"} across{" "} + {distinctKeys} API key{distinctKeys === 1 ? "" : "s"} in the last 24h + to deprecated /api/* endpoints. + Migrate to /api/v1/*. +

+
+ + Show details ({entries.length} endpoint + {entries.length === 1 ? "" : "s"}) + +
+ + + + + + + + + + + + {entries.map((e, i) => ( + + + + + + + + ))} + +
MethodPathAPI keyCountLast seen
{e.method}{e.path} + {e.apiKeyIdent || "(unauthenticated)"} + {e.count}{formatRelative(e.lastSeen)}
+
+
+
+ +
+ ); +} diff --git a/frontend/src/components/admin/ToolsPanel.tsx b/frontend/src/components/admin/ToolsPanel.tsx index 7c27db8..0c57e39 100644 --- a/frontend/src/components/admin/ToolsPanel.tsx +++ b/frontend/src/components/admin/ToolsPanel.tsx @@ -25,7 +25,7 @@ import { selectToken } from "@/app/slices/shared/authSlice"; import { useAppSelector } from "@/app/store"; import RadioReferenceCard from "@/components/admin/RadioReferenceCard"; -const SWAGGER_URL = "/api/admin/docs/index.html"; +const SWAGGER_URL = "/api/v1/admin/docs/index.html"; export default function ToolsPanel() { const token = useAppSelector(selectToken); @@ -611,7 +611,7 @@ export default function ToolsPanel() {