Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- 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: <successor>; 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

Expand Down
78 changes: 78 additions & 0 deletions backend/internal/handler/admin/legacyusage/handler.go
Original file line number Diff line number Diff line change
@@ -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,
})
}
184 changes: 184 additions & 0 deletions backend/internal/handler/admin/legacyusage/handler_test.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading