diff --git a/README.md b/README.md index dfcec34f..e198987d 100644 --- a/README.md +++ b/README.md @@ -231,9 +231,13 @@ channel_keys: # Regional transport scopes for matching TRANSPORT_FLOOD packets. # Plain names have # prepended automatically (e.g. "bc" → "#bc"). +# region (required) is a configured region slug; region-filtered scope lists +# and scope stats show the scope under that region's IATAs. Matching stays global. scopes: - name: bc + region: western-canada - name: "#west" + region: western-canada # Observer telemetry storage settings. telemetry: @@ -500,7 +504,7 @@ Not yet implemented — see the Authentication section above. | `GET` | `/routes` | List known routes (all hops high confidence) | | `GET` | `/routes/search` | Search routes by source and destination hash | | `GET` | `/routes/cross` | Search for routes crossing IATA boundaries | -| `GET` | `/scopes` | List transport scopes | +| `GET` | `/scopes` | List transport scope names; IATA/region filters use configured regions and MeshMapper catalogues | | `GET` | `/scopes/{name}` | Get scope detail | | `GET` | `/stats/observations` | Hourly observation time series (last 7 days by default) | | `GET` | `/stats/overview` | Network overview stats | diff --git a/cmd/beacon/main.go b/cmd/beacon/main.go index 5017dffb..6a28fcd4 100644 --- a/cmd/beacon/main.go +++ b/cmd/beacon/main.go @@ -196,6 +196,7 @@ func main() { os.Exit(1) } scopes.Load(scopeEntries) + scopes.SetManualMembers(cfg.ManualScopeMembers()) slog.Info(fmt.Sprintf("loaded %d transport scopes", len(scopeEntries)), "component", "startup") var scopeImporter *meshmapper.Importer if cfg.MeshMapper.Scopes.Enabled { @@ -371,6 +372,7 @@ func main() { MaxConnectsPerMinute: resolved.MaxConnectsPerMinute, WSAllowedOrigins: cfg.WebSocket.AllowedOrigins, CORS: cfg.CORS, Server: cfg.Server, Auth: cfg.Auth, RateLimit: resolved.RateLimit, + Scopes: scopes, AdminRoutes: map[string]http.Handler{ "/accounts": handlers.AccountsRouter(store), "/backup": handlers.BackupRouter(backupOpts, ctx), diff --git a/config.yaml.example b/config.yaml.example index 0c6eb6e2..45088531 100644 --- a/config.yaml.example +++ b/config.yaml.example @@ -105,9 +105,13 @@ channel_keys: # Regional transport scopes for matching TRANSPORT_FLOOD packets. # Plain names have # prepended automatically (e.g. "bc" → "#bc"). +# region (required) is a configured region slug; region-filtered scope lists +# and scope stats show the scope under that region's IATAs. Matching stays global. scopes: - name: bc + region: western-canada - name: "#west" + region: western-canada telemetry: retention: 672h # 4 weeks @@ -224,6 +228,8 @@ cache: # Refresh success/failure/freshness is logged under component=meshmapper.scopes. # Removing/turning off a source deactivates its imported matching keys on restart; # historical scope identities and recorded evidence are retained. +# Region-filtered scope lists and scope stats show imported names only under the +# IATAs whose catalogue lists them; regions without a source show manual scopes only. #meshmapper: # scopes: # enabled: false diff --git a/db/paths_integration_test.go b/db/paths_integration_test.go index 76a96628..a49f8ccd 100644 --- a/db/paths_integration_test.go +++ b/db/paths_integration_test.go @@ -115,7 +115,7 @@ SELECT int4send(n),'00000000-0000-0000-0000-000000000001',heard_at,iata,payload_ } } w := httptest.NewRecorder() - handlers.StatsRouter(store).ServeHTTP(w, httptest.NewRequest("GET", fmt.Sprintf("/paths?since=%d&until=%d&iatas=YVR", since.UnixMilli()+123, until.UnixMilli()+123), nil).WithContext(ctx)) + handlers.StatsRouter(store, nil).ServeHTTP(w, httptest.NewRequest("GET", fmt.Sprintf("/paths?since=%d&until=%d&iatas=YVR", since.UnixMilli()+123, until.UnixMilli()+123), nil).WithContext(ctx)) var response api.PathStats if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil || w.Code != 200 || response.Receptions != 14 || response.Hashed != 5 { t.Fatalf("HTTP %d: %s (%v)", w.Code, w.Body.String(), err) diff --git a/db/queries/queries.sql b/db/queries/queries.sql index ca7584c8..afcd8c5d 100644 --- a/db/queries/queries.sql +++ b/db/queries/queries.sql @@ -64,21 +64,6 @@ SELECT id FROM transport_scopes WHERE name = $1; -- name: GetScopeNames :many SELECT name FROM transport_scopes ORDER BY name; --- name: GetScopesByIATAs :many -SELECT - ts.name, - COUNT(DISTINCT os.observer_id) AS observer_count, - COUNT(DISTINCT n.id) AS node_count, - COUNT(DISTINCT po.iata) AS iata_count -FROM transport_scopes ts -LEFT JOIN observer_scopes os ON os.scope_id = ts.id -LEFT JOIN observers o ON o.id = os.observer_id -LEFT JOIN packet_observations po ON po.observer_id = o.id -LEFT JOIN nodes n ON n.default_scope_id = ts.id -WHERE (COALESCE(cardinality($1::bpchar[]), 0) = 0 OR po.iata = ANY($1::bpchar[])) -GROUP BY ts.name -ORDER BY ts.name; - -- name: GetScopeByName :one SELECT ts.name, diff --git a/db/scopes.go b/db/scopes.go index af5cf9a2..a9b1cb20 100644 --- a/db/scopes.go +++ b/db/scopes.go @@ -49,24 +49,6 @@ func (s *Store) GetScopeNames(ctx context.Context) ([]string, error) { return s.q.GetScopeNames(ctx) } -// GetScopesByIATAs returns scope summaries filtered by the given IATA codes. -func (s *Store) GetScopesByIATAs(ctx context.Context, iatas []string) ([]api.ScopeSummary, error) { - rows, err := s.q.GetScopesByIATAs(ctx, iatas) - if err != nil { - return nil, err - } - items := make([]api.ScopeSummary, 0, len(rows)) - for _, r := range rows { - items = append(items, api.ScopeSummary{ - Name: r.Name, - ObserverCount: r.ObserverCount, - NodeCount: r.NodeCount, - IATACount: r.IataCount, - }) - } - return items, nil -} - // GetScopeByName returns full detail for a single scope by its normalized name. func (s *Store) GetScopeByName(ctx context.Context, name string) (*api.ScopeDetail, error) { row, err := s.q.GetScopeByName(ctx, name) diff --git a/db/scopes_test.go b/db/scopes_test.go index 890e93ad..c581deea 100644 --- a/db/scopes_test.go +++ b/db/scopes_test.go @@ -43,29 +43,6 @@ func TestGetTransportScopes(t *testing.T) { } } -func TestGetScopesByIATAs(t *testing.T) { - ctrl := gomock.NewController(t) - mock := mockdb.NewMockQuerier(ctrl) - - mock.EXPECT(). - GetScopesByIATAs(gomock.Any(), []string{"YVR", "YYJ"}). - Return([]sqlc.GetScopesByIATAsRow{ - {Name: "default", ObserverCount: 3, NodeCount: 10, IataCount: 2}, - }, nil) - - store := &Store{q: mock} - items, err := store.GetScopesByIATAs(context.Background(), []string{"YVR", "YYJ"}) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if len(items) != 1 { - t.Fatalf("expected 1 item, got %d", len(items)) - } - if items[0].IATACount != 2 { - t.Errorf("expected IATACount 2, got %d", items[0].IATACount) - } -} - func TestGetScopeByName(t *testing.T) { ctrl := gomock.NewController(t) mock := mockdb.NewMockQuerier(ctrl) diff --git a/db/signal_integration_test.go b/db/signal_integration_test.go index b41af9f5..73bc8252 100644 --- a/db/signal_integration_test.go +++ b/db/signal_integration_test.go @@ -138,7 +138,7 @@ SELECT int4send(n),'00000000-0000-0000-0000-000000000001',heard_at,iata,snr,rssi } w := httptest.NewRecorder() request := httptest.NewRequest("GET", fmt.Sprintf("/signal?since=%d&until=%d&iatas=YVR", since.UnixMilli()+123, until.UnixMilli()+123), nil).WithContext(ctx) - handlers.StatsRouter(store).ServeHTTP(w, request) + handlers.StatsRouter(store, nil).ServeHTTP(w, request) var response api.SignalStats if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil || w.Code != 200 || response.Receptions != 13 || response.SNR.Samples != 5 { t.Fatalf("PostgreSQL HTTP response: status=%d body=%s error=%v", w.Code, w.Body.String(), err) diff --git a/db/sqlc/mock/querier.go b/db/sqlc/mock/querier.go index f9914cf0..a0aca7b5 100644 --- a/db/sqlc/mock/querier.go +++ b/db/sqlc/mock/querier.go @@ -760,21 +760,6 @@ func (mr *MockQuerierMockRecorder) GetScopeStats(ctx, iatas any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetScopeStats", reflect.TypeOf((*MockQuerier)(nil).GetScopeStats), ctx, iatas) } -// GetScopesByIATAs mocks base method. -func (m *MockQuerier) GetScopesByIATAs(ctx context.Context, dollar_1 []string) ([]db.GetScopesByIATAsRow, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetScopesByIATAs", ctx, dollar_1) - ret0, _ := ret[0].([]db.GetScopesByIATAsRow) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// GetScopesByIATAs indicates an expected call of GetScopesByIATAs. -func (mr *MockQuerierMockRecorder) GetScopesByIATAs(ctx, dollar_1 any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetScopesByIATAs", reflect.TypeOf((*MockQuerier)(nil).GetScopesByIATAs), ctx, dollar_1) -} - // GetSignalStats mocks base method. func (m *MockQuerier) GetSignalStats(ctx context.Context, arg db.GetSignalStatsParams) ([]db.GetSignalStatsRow, error) { m.ctrl.T.Helper() diff --git a/db/sqlc/querier.go b/db/sqlc/querier.go index ef53cb36..7a64a312 100644 --- a/db/sqlc/querier.go +++ b/db/sqlc/querier.go @@ -97,7 +97,6 @@ type Querier interface { // a cross-join. Empty IATAs keep the original global counts, including associations // whose observations have expired; the filtered aggregates are empty in that case. GetScopeStats(ctx context.Context, iatas []string) ([]GetScopeStatsRow, error) - GetScopesByIATAs(ctx context.Context, dollar_1 []string) ([]GetScopesByIATAsRow, error) // Read compact hourly snapshots, never observations on an HTTP request. // Weight averages by sample counts instead of averaging regional/hourly means. GetSignalStats(ctx context.Context, arg GetSignalStatsParams) ([]GetSignalStatsRow, error) diff --git a/db/sqlc/queries.sql.go b/db/sqlc/queries.sql.go index 9ea06a5b..e8b52ff7 100644 --- a/db/sqlc/queries.sql.go +++ b/db/sqlc/queries.sql.go @@ -1596,54 +1596,6 @@ func (q *Queries) GetScopeStats(ctx context.Context, iatas []string) ([]GetScope return items, nil } -const getScopesByIATAs = `-- name: GetScopesByIATAs :many -SELECT - ts.name, - COUNT(DISTINCT os.observer_id) AS observer_count, - COUNT(DISTINCT n.id) AS node_count, - COUNT(DISTINCT po.iata) AS iata_count -FROM transport_scopes ts -LEFT JOIN observer_scopes os ON os.scope_id = ts.id -LEFT JOIN observers o ON o.id = os.observer_id -LEFT JOIN packet_observations po ON po.observer_id = o.id -LEFT JOIN nodes n ON n.default_scope_id = ts.id -WHERE (COALESCE(cardinality($1::bpchar[]), 0) = 0 OR po.iata = ANY($1::bpchar[])) -GROUP BY ts.name -ORDER BY ts.name -` - -type GetScopesByIATAsRow struct { - Name string `json:"name"` - ObserverCount int64 `json:"observer_count"` - NodeCount int64 `json:"node_count"` - IataCount int64 `json:"iata_count"` -} - -func (q *Queries) GetScopesByIATAs(ctx context.Context, dollar_1 []string) ([]GetScopesByIATAsRow, error) { - rows, err := q.db.Query(ctx, getScopesByIATAs, dollar_1) - if err != nil { - return nil, err - } - defer rows.Close() - items := []GetScopesByIATAsRow{} - for rows.Next() { - var i GetScopesByIATAsRow - if err := rows.Scan( - &i.Name, - &i.ObserverCount, - &i.NodeCount, - &i.IataCount, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - const getStatsClockDrift = `-- name: GetStatsClockDrift :many WITH page AS ( SELECT diff --git a/docs/docs.go b/docs/docs.go index df1ccc9e..31dab86c 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -2219,7 +2219,7 @@ const docTemplate = `{ }, "/scopes": { "get": { - "description": "The unfiltered list includes stored imported names after an importer is disabled or a source is removed. Those historical identities are retained indefinitely; listing them does not establish observed traffic or current source membership.", + "description": "Without filters, lists every stored scope name, including imported names retained after an importer is disabled or a source is removed. With IATA or region filters, lists only manual scopes configured for a matching region and imported scopes whose current MeshMapper catalogue includes a matching IATA; observed traffic does not add scopes.", "produces": [ "application/json" ], @@ -2251,7 +2251,16 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "type": "object" + "type": "array", + "items": { + "type": "string" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/internal_api_handlers.APIError" } }, "500": { @@ -2780,7 +2789,7 @@ const docTemplate = `{ }, "/stats/scopes": { "get": { - "description": "Counts each packet, observer and node once per scope. IATA filters use retained observations for packets/observers and node IATA memberships for nodes. Without filters, returns global totals. Scopes with zero matching counts remain listed; an empty region returns an empty array.", + "description": "Counts each packet, observer and node once per scope. IATA filters use retained observations for packets/observers and node IATA memberships for nodes. Without filters, returns global totals for every stored scope. With filters, lists only manual scopes configured for a matching region and imported scopes whose current MeshMapper catalogue includes a matching IATA; those remain listed with zero counts. An empty region returns an empty array.", "produces": [ "application/json" ], diff --git a/docs/swagger.json b/docs/swagger.json index 183611af..7b2e8f81 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -2217,7 +2217,7 @@ }, "/scopes": { "get": { - "description": "The unfiltered list includes stored imported names after an importer is disabled or a source is removed. Those historical identities are retained indefinitely; listing them does not establish observed traffic or current source membership.", + "description": "Without filters, lists every stored scope name, including imported names retained after an importer is disabled or a source is removed. With IATA or region filters, lists only manual scopes configured for a matching region and imported scopes whose current MeshMapper catalogue includes a matching IATA; observed traffic does not add scopes.", "produces": [ "application/json" ], @@ -2249,7 +2249,16 @@ "200": { "description": "OK", "schema": { - "type": "object" + "type": "array", + "items": { + "type": "string" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/internal_api_handlers.APIError" } }, "500": { @@ -2778,7 +2787,7 @@ }, "/stats/scopes": { "get": { - "description": "Counts each packet, observer and node once per scope. IATA filters use retained observations for packets/observers and node IATA memberships for nodes. Without filters, returns global totals. Scopes with zero matching counts remain listed; an empty region returns an empty array.", + "description": "Counts each packet, observer and node once per scope. IATA filters use retained observations for packets/observers and node IATA memberships for nodes. Without filters, returns global totals for every stored scope. With filters, lists only manual scopes configured for a matching region and imported scopes whose current MeshMapper catalogue includes a matching IATA; those remain listed with zero counts. An empty region returns an empty array.", "produces": [ "application/json" ], diff --git a/docs/swagger.yaml b/docs/swagger.yaml index ca79d350..e45b6762 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -3013,10 +3013,11 @@ paths: - Routes /scopes: get: - description: The unfiltered list includes stored imported names after an importer - is disabled or a source is removed. Those historical identities are retained - indefinitely; listing them does not establish observed traffic or current - source membership. + description: Without filters, lists every stored scope name, including imported + names retained after an importer is disabled or a source is removed. With + IATA or region filters, lists only manual scopes configured for a matching + region and imported scopes whose current MeshMapper catalogue includes a matching + IATA; observed traffic does not add scopes. parameters: - description: Filter by IATA code(s), comma-separated in: query @@ -3036,7 +3037,13 @@ paths: "200": description: OK schema: - type: object + items: + type: string + type: array + "400": + description: Bad Request + schema: + $ref: '#/definitions/internal_api_handlers.APIError' "500": description: Internal Server Error schema: @@ -3400,8 +3407,10 @@ paths: get: description: Counts each packet, observer and node once per scope. IATA filters use retained observations for packets/observers and node IATA memberships - for nodes. Without filters, returns global totals. Scopes with zero matching - counts remain listed; an empty region returns an empty array. + for nodes. Without filters, returns global totals for every stored scope. + With filters, lists only manual scopes configured for a matching region and + imported scopes whose current MeshMapper catalogue includes a matching IATA; + those remain listed with zero counts. An empty region returns an empty array. parameters: - description: Comma-separated IATA codes in: query diff --git a/internal/api/handlers/limits_test.go b/internal/api/handlers/limits_test.go index 991a2076..75fc8cbe 100644 --- a/internal/api/handlers/limits_test.go +++ b/internal/api/handlers/limits_test.go @@ -113,7 +113,7 @@ func TestListLimits(t *testing.T) { router.Mount("/messages", MessagesRouter(reader)) router.Mount("/routes", RoutesRouter(reader)) router.Mount("/traces", TracesRouter(reader)) - router.Mount("/stats", StatsRouter(reader)) + router.Mount("/stats", StatsRouter(reader, nil)) for _, endpoint := range []struct { path string defaultLimit int32 diff --git a/internal/api/handlers/observer_comparison_test.go b/internal/api/handlers/observer_comparison_test.go index 3a46da7e..0b11e6d1 100644 --- a/internal/api/handlers/observer_comparison_test.go +++ b/internal/api/handlers/observer_comparison_test.go @@ -28,7 +28,7 @@ func TestObserverComparisonRejectsInvalidQuery(t *testing.T) { } { t.Run(query, func(t *testing.T) { w := httptest.NewRecorder() - StatsRouter(nil).ServeHTTP(w, httptest.NewRequest("GET", "/observer-comparison?"+query, nil)) + StatsRouter(nil, nil).ServeHTTP(w, httptest.NewRequest("GET", "/observer-comparison?"+query, nil)) if w.Code != 400 { t.Fatalf("status %d; wanted validation error", w.Code) } @@ -42,7 +42,7 @@ func TestObserverComparisonHTTP(t *testing.T) { query := "observerA=" + a.String() + "&observerB=" + b.String() + "&since=0&until=1000" for _, suffix := range []string{"&since=2", "&observerA=" + b.String(), "&until=253402300800000", "&observerB=00000000-0000-0000-0000-000000000000"} { w := httptest.NewRecorder() - StatsRouter(nil).ServeHTTP(w, httptest.NewRequest("GET", "/observer-comparison?"+query+suffix, nil)) + StatsRouter(nil, nil).ServeHTTP(w, httptest.NewRequest("GET", "/observer-comparison?"+query+suffix, nil)) if w.Code != 400 { t.Fatalf("invalid query returned %d", w.Code) } @@ -80,7 +80,7 @@ func TestObserverComparisonHTTP(t *testing.T) { }, } w := httptest.NewRecorder() - StatsRouter(reader).ServeHTTP(w, httptest.NewRequest("GET", "/observer-comparison?"+query+tc.suffix, nil)) + StatsRouter(reader, nil).ServeHTTP(w, httptest.NewRequest("GET", "/observer-comparison?"+query+tc.suffix, nil)) if w.Code != tc.status { t.Fatalf("status = %d, body = %s", w.Code, w.Body.String()) } diff --git a/internal/api/handlers/paths_test.go b/internal/api/handlers/paths_test.go index 64473463..190fe69b 100644 --- a/internal/api/handlers/paths_test.go +++ b/internal/api/handlers/paths_test.go @@ -28,7 +28,7 @@ func (s pathReader) GetPathStats(ctx context.Context, since, until time.Time, ia func TestPathsRejectInvalidWindow(t *testing.T) { for _, query := range []string{"", "since=0", "since=x&until=1", "since=-1&until=1", "since=1&until=1", "since=2&until=1", "since=0&until=2592000001", "since=0&since=0&until=1", "since=0&until=1&until=2", "since=253402300799998&until=253402300800000"} { w := httptest.NewRecorder() - StatsRouter(nil).ServeHTTP(w, httptest.NewRequest("GET", "/paths?"+query, nil)) + StatsRouter(nil, nil).ServeHTTP(w, httptest.NewRequest("GET", "/paths?"+query, nil)) if w.Code != 400 { t.Fatalf("query %q: %d %s", query, w.Code, w.Body.String()) } @@ -66,7 +66,7 @@ func TestPathsFiltersErrorsAndCancellation(t *testing.T) { return &api.PathStats{Until: until.UnixMilli(), Receptions: 4, Hashed: 1, Empty: 1, Trace: 1, Unclassified: 1}, tc.err }} w := httptest.NewRecorder() - StatsRouter(reader).ServeHTTP(w, httptest.NewRequest("GET", "/paths?since=0&until=2592000000"+tc.query, nil)) + StatsRouter(reader, nil).ServeHTTP(w, httptest.NewRequest("GET", "/paths?since=0&until=2592000000"+tc.query, nil)) if w.Code != tc.status || strings.Contains(w.Body.String(), "private detail") { t.Fatalf("%d %s", w.Code, w.Body.String()) } @@ -86,7 +86,7 @@ func TestPathsFiltersErrorsAndCancellation(t *testing.T) { return nil, ctx.Err() }} w := httptest.NewRecorder() - StatsRouter(r).ServeHTTP(w, httptest.NewRequest("GET", "/paths?since=0&until=1", nil).WithContext(ctx)) + StatsRouter(r, nil).ServeHTTP(w, httptest.NewRequest("GET", "/paths?since=0&until=1", nil).WithContext(ctx)) if !called || w.Body.Len() != 0 { t.Fatal("cancellation did not reach reader") } diff --git a/internal/api/handlers/scope_stats_test.go b/internal/api/handlers/scope_stats_test.go index d63ba21b..111681dd 100644 --- a/internal/api/handlers/scope_stats_test.go +++ b/internal/api/handlers/scope_stats_test.go @@ -13,6 +13,7 @@ import ( "testing" "github.com/MeshCore-Beacon/beacon-server/internal/api" + "github.com/MeshCore-Beacon/beacon-server/internal/scopestore" ) func TestGetStatsScopes_RegionErrors(t *testing.T) { @@ -27,7 +28,7 @@ func TestGetStatsScopes_RegionErrors(t *testing.T) { }, } w := httptest.NewRecorder() - getStatsScopes(reader)(w, httptest.NewRequest(http.MethodGet, "/stats/scopes?"+query, nil)) + getStatsScopes(reader, nil)(w, httptest.NewRequest(http.MethodGet, "/stats/scopes?"+query, nil)) if w.Code != http.StatusBadRequest { t.Fatalf("got status %d, want 400", w.Code) } @@ -79,7 +80,7 @@ func TestGetStatsScopes_Filters(t *testing.T) { }, } w := httptest.NewRecorder() - getStatsScopes(reader)(w, httptest.NewRequest(http.MethodGet, "/stats/scopes?"+tc.query, nil)) + getStatsScopes(reader, testScopes(map[string][]string{"YVR": {"#test"}, "YYJ": {"#test"}}))(w, httptest.NewRequest(http.MethodGet, "/stats/scopes?"+tc.query, nil)) if w.Code != http.StatusOK { t.Fatalf("status = %d: %s", w.Code, w.Body.String()) } @@ -105,8 +106,45 @@ func TestGetStatsScopes_ReaderError(t *testing.T) { w := httptest.NewRecorder() getStatsScopes(stubReader{getScopeStats: func(context.Context, []string) ([]api.ScopeStats, error) { return nil, errors.New("database unavailable") - }})(w, httptest.NewRequest(http.MethodGet, "/stats/scopes?iata=YVR", nil)) + }}, nil)(w, httptest.NewRequest(http.MethodGet, "/stats/scopes?iata=YVR", nil)) if w.Code != http.StatusInternalServerError { t.Fatalf("status = %d, want 500", w.Code) } } + +func testScopes(byIATA map[string][]string) *scopestore.ScopeStore { + s := scopestore.New() + s.SetManualMembers(byIATA) + return s +} + +func TestGetStatsScopes_FiltersToRegionMembers(t *testing.T) { + reader := stubReader{getScopeStats: func(context.Context, []string) ([]api.ScopeStats, error) { + return []api.ScopeStats{{Name: "#elsewhere", PacketCount: 9}, {Name: "#on"}, {Name: "#yow", PacketCount: 4}}, nil + }} + members := testScopes(map[string][]string{"YOW": {"#yow", "#on"}}) + for _, tc := range []struct { + query string + members ScopeMembership + want []string + }{ + {"", members, []string{"#elsewhere", "#on", "#yow"}}, + {"iata=YOW", members, []string{"#on", "#yow"}}, + {"iata=YUL", members, []string{}}, + {"iata=YOW", nil, []string{}}, + } { + w := httptest.NewRecorder() + getStatsScopes(reader, tc.members)(w, httptest.NewRequest(http.MethodGet, "/stats/scopes?"+tc.query, nil)) + var rows []api.ScopeStats + if err := json.Unmarshal(w.Body.Bytes(), &rows); err != nil || rows == nil { + t.Fatalf("%q: body %s, err %v", tc.query, w.Body.String(), err) + } + got := []string{} + for _, row := range rows { + got = append(got, row.Name) + } + if !slices.Equal(got, tc.want) { + t.Fatalf("%q: got %v, want %v", tc.query, got, tc.want) + } + } +} diff --git a/internal/api/handlers/scopes.go b/internal/api/handlers/scopes.go index c86de6fa..41cca54d 100644 --- a/internal/api/handlers/scopes.go +++ b/internal/api/handlers/scopes.go @@ -10,13 +10,25 @@ import ( "github.com/go-chi/chi/v5" ) +// ScopeMembership lists scopes by configured region and imported catalogue membership. +type ScopeMembership interface { + NamesForIATAs(iatas []string) []string // sorted +} + +func scopeNamesFor(scopes ScopeMembership, iatas []string) []string { + if scopes == nil { + return []string{} + } + return scopes.NamesForIATAs(iatas) +} + // ScopesRouter mounts all /scopes routes onto a subrouter. // // GET /scopes → listScopes // GET /scopes/{name} → getScope -func ScopesRouter(reader api.Reader) http.Handler { +func ScopesRouter(reader api.Reader, scopes ScopeMembership) http.Handler { r := chi.NewRouter() - r.Get("/", listScopes(reader)) + r.Get("/", listScopes(reader, scopes)) r.Get("/{name}", getScope(reader)) return r } @@ -24,27 +36,28 @@ func ScopesRouter(reader api.Reader) http.Handler { // listScopes godoc // // @Summary List transport scopes -// @Description The unfiltered list includes stored imported names after an importer is disabled or a source is removed. Those historical identities are retained indefinitely; listing them does not establish observed traffic or current source membership. +// @Description Without filters, lists every stored scope name, including imported names retained after an importer is disabled or a source is removed. With IATA or region filters, lists only manual scopes configured for a matching region and imported scopes whose current MeshMapper catalogue includes a matching IATA; observed traffic does not add scopes. // @Tags Scopes // @Produce json // @Param iatas query string false "Filter by IATA code(s), comma-separated" // @Param region query string false "Filter by region slug" // @Param regionId query int false "Filter by region ID" -// @Success 200 {object} object +// @Success 200 {array} string +// @Failure 400 {object} handlers.APIError // @Failure 500 {object} handlers.APIError // @Router /scopes [get] -func listScopes(reader api.Reader) http.HandlerFunc { +func listScopes(reader api.Reader, scopes ScopeMembership) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { iatas := parseIATAs(r) - if regionIDStr := r.URL.Query().Get("regionId"); regionIDStr != "" || r.URL.Query().Get("region") != "" { - regionIATAs, err := resolveRegionIATAs(r.Context(), r.URL.Query().Get("regionId"), r.URL.Query().Get("region"), reader) + regionID, region := r.URL.Query().Get("regionId"), r.URL.Query().Get("region") + if regionID != "" || region != "" { + regionIATAs, err := resolveRegionIATAs(r.Context(), regionID, region, reader) if err != nil { respondError(w, http.StatusBadRequest, err.Error()) return } iatas = append(iatas, regionIATAs...) - } - if len(iatas) == 0 { + } else if len(iatas) == 0 { names, err := reader.GetScopeNames(r.Context()) if err != nil { respondError(w, http.StatusInternalServerError, "internal server error") @@ -53,12 +66,7 @@ func listScopes(reader api.Reader) http.HandlerFunc { respond(w, http.StatusOK, names) return } - scopes, err := reader.GetScopesByIATAs(r.Context(), iatas) - if err != nil { - respondError(w, http.StatusInternalServerError, "internal server error") - return - } - respond(w, http.StatusOK, scopes) + respond(w, http.StatusOK, scopeNamesFor(scopes, iatas)) } } diff --git a/internal/api/handlers/scopes_test.go b/internal/api/handlers/scopes_test.go index 29abb392..f83262ee 100644 --- a/internal/api/handlers/scopes_test.go +++ b/internal/api/handlers/scopes_test.go @@ -5,8 +5,10 @@ package handlers import ( "context" + "encoding/json" "net/http" "net/http/httptest" + "slices" "testing" "github.com/MeshCore-Beacon/beacon-server/internal/api" @@ -19,7 +21,7 @@ func TestListScopes_NoIATAs_OK(t *testing.T) { getScopeNames: func(_ context.Context) ([]string, error) { return []string{"#bc", "#west"}, nil }, - })) + }, nil)) req := httptest.NewRequest(http.MethodGet, "/scopes", nil) w := httptest.NewRecorder() r.ServeHTTP(w, req) @@ -42,3 +44,40 @@ func TestGetScope_OK(t *testing.T) { t.Errorf("expected 200, got %d", w.Code) } } + +func TestListScopes_FiltersByMembership(t *testing.T) { + reader := stubReader{ + getScopeNames: func(context.Context) ([]string, error) { + t.Fatal("filtered list must not fall back to every stored name") + return nil, nil + }, + getRegionBySlug: func(_ context.Context, slug string) (*api.Region, error) { + if slug == "empty" { + return &api.Region{}, nil + } + return &api.Region{IATAs: []string{"YOW", "YGK"}}, nil + }, + } + members := testScopes(map[string][]string{"YOW": {"#on", "#yow"}, "YGK": {"#on"}, "YUL": {"#qc"}}) + for _, tc := range []struct { + query string + members ScopeMembership + want []string + }{ + {"iatas=yow", members, []string{"#on", "#yow"}}, + {"iatas=YUL,YYZ", members, []string{"#qc"}}, + {"region=ottawa", members, []string{"#on", "#yow"}}, + {"region=empty", members, []string{}}, + {"iatas=YOW", nil, []string{}}, + } { + w := httptest.NewRecorder() + listScopes(reader, tc.members)(w, httptest.NewRequest(http.MethodGet, "/scopes?"+tc.query, nil)) + var got []string + if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil || w.Code != http.StatusOK || got == nil { + t.Fatalf("%q: status %d body %s", tc.query, w.Code, w.Body.String()) + } + if !slices.Equal(got, tc.want) { + t.Fatalf("%q: got %v, want %v", tc.query, got, tc.want) + } + } +} diff --git a/internal/api/handlers/signal_test.go b/internal/api/handlers/signal_test.go index 405db1ca..f9146fa8 100644 --- a/internal/api/handlers/signal_test.go +++ b/internal/api/handlers/signal_test.go @@ -28,7 +28,7 @@ func (s signalReader) GetSignalStats(ctx context.Context, since, until time.Time func TestSignalRejectsInvalidWindow(t *testing.T) { for _, query := range []string{"", "since=0", "until=1", "since=x&until=1", "since=-1&until=1", "since=0&until=0", "since=2&until=1", "since=0&until=2592000001", "since=0&since=0&until=1", "since=0&until=1&until=2", "since=253402300799998&until=253402300800000"} { w := httptest.NewRecorder() - StatsRouter(nil).ServeHTTP(w, httptest.NewRequest("GET", "/signal?"+query, nil)) + StatsRouter(nil, nil).ServeHTTP(w, httptest.NewRequest("GET", "/signal?"+query, nil)) if w.Code != 400 { t.Fatalf("query %q: %d %s", query, w.Code, w.Body.String()) } @@ -70,7 +70,7 @@ func TestSignalFiltersAndErrors(t *testing.T) { return &api.SignalStats{Until: until.UnixMilli(), Receptions: 5, Hourly: []api.SignalHour{}}, tc.err }} w := httptest.NewRecorder() - StatsRouter(reader).ServeHTTP(w, httptest.NewRequest("GET", "/signal?since=0&until=2592000000"+tc.query, nil)) + StatsRouter(reader, nil).ServeHTTP(w, httptest.NewRequest("GET", "/signal?since=0&until=2592000000"+tc.query, nil)) if w.Code != tc.status || strings.Contains(w.Body.String(), "private detail") { t.Fatalf("%d %s", w.Code, w.Body.String()) } @@ -93,7 +93,7 @@ func TestSignalCancellationReachesReader(t *testing.T) { return nil, ctx.Err() }} w := httptest.NewRecorder() - StatsRouter(reader).ServeHTTP(w, httptest.NewRequest("GET", "/signal?since=0&until=1", nil).WithContext(ctx)) + StatsRouter(reader, nil).ServeHTTP(w, httptest.NewRequest("GET", "/signal?since=0&until=1", nil).WithContext(ctx)) if !called || w.Body.Len() != 0 { t.Fatalf("cancellation: called=%v body=%s", called, w.Body.String()) } diff --git a/internal/api/handlers/stats.go b/internal/api/handlers/stats.go index 1309efda..ca064233 100644 --- a/internal/api/handlers/stats.go +++ b/internal/api/handlers/stats.go @@ -6,6 +6,7 @@ package handlers import ( "log/slog" "net/http" + "slices" "strconv" "time" @@ -26,7 +27,7 @@ import ( // GET /stats/scopes → GetStatsScopes // // All endpoints accept an optional iata= filter (case-insensitive). -func StatsRouter(reader api.Reader) http.Handler { +func StatsRouter(reader api.Reader, scopes ScopeMembership) http.Handler { r := chi.NewRouter() r.Get("/overview", getStatsOverview(reader)) r.Get("/observations", getStatsObservations(reader)) @@ -40,7 +41,7 @@ func StatsRouter(reader api.Reader) http.Handler { r.Get("/clock-drift", getStatsClockDrift(reader)) r.Get("/top-talkers", getStatsTopTalkers(reader)) r.Get("/radio-presets", getStatsRadioPresets(reader)) - r.Get("/scopes", getStatsScopes(reader)) + r.Get("/scopes", getStatsScopes(reader, scopes)) r.Get("/node-types", getStatsNodeTypes(reader)) return r } @@ -417,7 +418,7 @@ func getStatsRadioPresets(reader api.Reader) http.HandlerFunc { // getStatsScopes godoc // // @Summary Scope statistics -// @Description Counts each packet, observer and node once per scope. IATA filters use retained observations for packets/observers and node IATA memberships for nodes. Without filters, returns global totals. Scopes with zero matching counts remain listed; an empty region returns an empty array. +// @Description Counts each packet, observer and node once per scope. IATA filters use retained observations for packets/observers and node IATA memberships for nodes. Without filters, returns global totals for every stored scope. With filters, lists only manual scopes configured for a matching region and imported scopes whose current MeshMapper catalogue includes a matching IATA; those remain listed with zero counts. An empty region returns an empty array. // @Tags Stats // @Produce json // @Param iatas query string false "Comma-separated IATA codes" @@ -428,7 +429,7 @@ func getStatsRadioPresets(reader api.Reader) http.HandlerFunc { // @Failure 400 {object} handlers.APIError // @Failure 500 {object} handlers.APIError // @Router /stats/scopes [get] -func getStatsScopes(reader api.Reader) http.HandlerFunc { +func getStatsScopes(reader api.Reader, scopes ScopeMembership) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { iatas := parseIATAs(r) if regionID := r.URL.Query().Get("regionId"); regionID != "" || r.URL.Query().Get("region") != "" { @@ -448,6 +449,13 @@ func getStatsScopes(reader api.Reader) http.HandlerFunc { respondError(w, http.StatusInternalServerError, "internal server error") return } + if len(iatas) > 0 { + members := scopeNamesFor(scopes, iatas) + stats = slices.DeleteFunc(slices.Clone(stats), func(s api.ScopeStats) bool { + _, found := slices.BinarySearch(members, s.Name) + return !found + }) + } respond(w, http.StatusOK, stats) } } diff --git a/internal/api/handlers/stats_test.go b/internal/api/handlers/stats_test.go index 8225fc8c..86d9d9b9 100644 --- a/internal/api/handlers/stats_test.go +++ b/internal/api/handlers/stats_test.go @@ -285,7 +285,7 @@ func TestGetStatsScopes_OK(t *testing.T) { getScopeStats: func(_ context.Context, _ []string) ([]api.ScopeStats, error) { return []api.ScopeStats{{Name: "#bc", PacketCount: 100}}, nil }, - })) + }, nil)) req := httptest.NewRequest(http.MethodGet, "/stats/scopes", nil) w := httptest.NewRecorder() r.ServeHTTP(w, req) diff --git a/internal/api/handlers/stub_reader_test.go b/internal/api/handlers/stub_reader_test.go index f9f885a6..0c719aca 100644 --- a/internal/api/handlers/stub_reader_test.go +++ b/internal/api/handlers/stub_reader_test.go @@ -55,7 +55,6 @@ type stubReader struct { getScopeStats func(ctx context.Context, iatas []string) ([]api.ScopeStats, error) getStatsNodeTypes func(ctx context.Context, iatas []string) ([]api.NodeTypeCount, error) getScopeNames func(ctx context.Context) ([]string, error) - getScopesByIATAs func(ctx context.Context, iatas []string) ([]api.ScopeSummary, error) getScopeByName func(ctx context.Context, name string) (*api.ScopeDetail, error) listTraceTags func(ctx context.Context, iatas []string, scope, traceType string, since, until time.Time, cursor time.Time, limit int32) ([]api.TraceTagSummary, error) getTraceByTag func(ctx context.Context, tag string) (*api.TraceDetail, error) @@ -341,13 +340,6 @@ func (s stubReader) GetScopeNames(ctx context.Context) ([]string, error) { return nil, nil } -func (s stubReader) GetScopesByIATAs(ctx context.Context, iatas []string) ([]api.ScopeSummary, error) { - if s.getScopesByIATAs != nil { - return s.getScopesByIATAs(ctx, iatas) - } - return nil, nil -} - func (s stubReader) GetScopeByName(ctx context.Context, name string) (*api.ScopeDetail, error) { if s.getScopeByName != nil { return s.getScopeByName(ctx, name) diff --git a/internal/api/reader.go b/internal/api/reader.go index d46ec2d9..21d65403 100644 --- a/internal/api/reader.go +++ b/internal/api/reader.go @@ -210,10 +210,6 @@ type Reader interface { // Use when no geographic filter is applied — returns names only for a lightweight response. GetScopeNames(ctx context.Context) ([]string, error) - // GetScopesByIATAs returns scope summaries filtered by the given IATA codes, - // including observer, node and IATA counts. Expands region/regionId to IATAs automatically. - GetScopesByIATAs(ctx context.Context, iatas []string) ([]ScopeSummary, error) - // GetScopeByName returns full detail for a single scope by its normalized name (e.g. "#bc"), // including packet count, observer count, node count, and the list of IATAs it is active in. // Returns nil if the scope is not found. diff --git a/internal/api/router/options.go b/internal/api/router/options.go index 757a6593..0d9076e1 100644 --- a/internal/api/router/options.go +++ b/internal/api/router/options.go @@ -6,6 +6,7 @@ package router import ( "net/http" + "github.com/MeshCore-Beacon/beacon-server/internal/api/handlers" "github.com/MeshCore-Beacon/beacon-server/internal/config" ) @@ -19,6 +20,7 @@ type Options struct { Server config.ServerConfig Auth config.AuthConfig RateLimit config.ResolvedRateLimitConfig + Scopes handlers.ScopeMembership // region-filtered scope lists; nil lists none // AdminRoutes mounts operator-only subrouters at literal paths such as // "/accounts". Each feature owns its dependencies, methods and handlers. // The router applies authentication to every path and method in this group. diff --git a/internal/api/router/router.go b/internal/api/router/router.go index 58bbfac5..b8a5ceab 100644 --- a/internal/api/router/router.go +++ b/internal/api/router/router.go @@ -107,8 +107,8 @@ func New(h *hub.Hub, reader api.Reader, workers []*ingest.Worker, opts Options) r.Mount("/iatas", handlers.IATAsRouter(reader)) r.Mount("/regions", handlers.RegionsRouter(reader)) r.Mount("/routes", handlers.RoutesRouter(reader)) - r.Mount("/scopes", handlers.ScopesRouter(reader)) - r.Mount("/stats", handlers.StatsRouter(reader)) + r.Mount("/scopes", handlers.ScopesRouter(reader, opts.Scopes)) + r.Mount("/stats", handlers.StatsRouter(reader, opts.Scopes)) r.Mount("/traces", handlers.TracesRouter(reader)) }) diff --git a/internal/api/scopes.go b/internal/api/scopes.go index c4b23c1b..58a1fc28 100644 --- a/internal/api/scopes.go +++ b/internal/api/scopes.go @@ -3,14 +3,6 @@ package api -// ScopeSummary is the minimal scope representation used in filtered list responses. -type ScopeSummary struct { - Name string `json:"name"` - ObserverCount int64 `json:"observerCount"` - NodeCount int64 `json:"nodeCount"` - IATACount int64 `json:"iataCount"` -} - // ScopeDetail is the full scope representation including packet count and IATA list. type ScopeDetail struct { Name string `json:"name"` diff --git a/internal/cache/cache_test.go b/internal/cache/cache_test.go index 147b9da2..41dde518 100644 --- a/internal/cache/cache_test.go +++ b/internal/cache/cache_test.go @@ -76,10 +76,6 @@ func (s *stubReader) GetScopeNames(_ context.Context) ([]string, error) { return func (s *stubReader) GetScopeStats(_ context.Context, _ []string) ([]api.ScopeStats, error) { return nil, nil } -func (s *stubReader) GetScopesByIATAs(_ context.Context, _ []string) ([]api.ScopeSummary, error) { - return nil, nil -} - func (s *stubReader) GetScopeByName(_ context.Context, _ string) (*api.ScopeDetail, error) { return nil, nil } @@ -386,7 +382,7 @@ func TestCachedReader_IATASortingForStableKey(t *testing.T) { func TestInvalidateScopeNamesClearsDerivedKeys(t *testing.T) { c, mr := newTestClient(t) cr := &CachedReader{inner: &stubReader{}, c: c, ttl: CacheTTLs{}} - for _, k := range []string{keyScopeNames, keyScopesByIATAsPrefix + "YOW", keyScopesByIATAsPrefix + "YOW,YVR", keyScopeByNamePrefix + "#yow"} { + for _, k := range []string{keyScopeNames, keyScopeByNamePrefix + "#yow"} { mr.Set(k, "x") } mr.Set("beacon:iatas", "keep") diff --git a/internal/cache/reader.go b/internal/cache/reader.go index 2273447a..cb49338f 100644 --- a/internal/cache/reader.go +++ b/internal/cache/reader.go @@ -25,7 +25,6 @@ const ( keyRegionSlugPrefix = "beacon:region:slug:" keyScopeNames = "beacon:scope:names" keyScopeStats = "beacon:scope:stats" - keyScopesByIATAsPrefix = "beacon:scopes:iatas:" keyScopeByNamePrefix = "beacon:scope:name:" keyStatsOverviewPrefix = "beacon:stats:overview:" keyStatsObservationsPrefix = "beacon:stats:observations:" @@ -83,7 +82,6 @@ func NewCachedReader(inner api.Reader, c *Client, ttl CacheTTLs) api.Reader { // InvalidateScopeNames makes newly committed catalogue names visible to every scope read. func (cr *CachedReader) InvalidateScopeNames(ctx context.Context) { cr.c.del(ctx, keyScopeNames) - cr.c.delPrefix(ctx, keyScopesByIATAsPrefix) cr.c.delPrefix(ctx, keyScopeByNamePrefix) } @@ -174,17 +172,6 @@ func (cr *CachedReader) GetScopeStats(ctx context.Context, iatas []string) ([]ap }) } -// GetScopesByIATAs implements [api.Reader]. -func (cr *CachedReader) GetScopesByIATAs(ctx context.Context, iatas []string) ([]api.ScopeSummary, error) { - sorted := make([]string, len(iatas)) - copy(sorted, iatas) - sort.Strings(sorted) - key := keyScopesByIATAsPrefix + strings.Join(sorted, ",") - return getOrSet(ctx, cr.c, key, cr.ttl.Reference, func() ([]api.ScopeSummary, error) { - return cr.inner.GetScopesByIATAs(ctx, iatas) - }) -} - // GetScopeByName implements [api.Reader]. func (cr *CachedReader) GetScopeByName(ctx context.Context, name string) (*api.ScopeDetail, error) { return getOrSet(ctx, cr.c, keyScopeByNamePrefix+name, cr.ttl.Reference, func() (*api.ScopeDetail, error) { diff --git a/internal/config/config.go b/internal/config/config.go index 85df4692..95ebfcbb 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -221,7 +221,8 @@ type CacheTTLsConfig struct { // Name can be provided with or without the # or $ prefix. // Beacon normalizes plain names by prepending #. type ScopeConfig struct { - Name string `yaml:"name"` // e.g. "bc", "#west", "$private" + Name string `yaml:"name"` // e.g. "bc", "#west", "$private" + Region string `yaml:"region"` // configured region slug; lists the scope under that region's IATAs } // TelemetryConfig controls observer telemetry storage behaviour. @@ -413,6 +414,9 @@ func Load(path string) (*Config, error) { if err := cfg.validateMeshMapper(); err != nil { return nil, err } + if err := cfg.validateScopes(); err != nil { + return nil, err + } if cfg.Nodes.MarkForeign && !cfg.MeshMapper.Zones.Enabled && !cfg.hasBorderFile() { return nil, fmt.Errorf("nodes.mark_foreign requires an iatas.*.borderFile or meshmapper.zones") } diff --git a/internal/config/scopes.go b/internal/config/scopes.go new file mode 100644 index 00000000..87c05a56 --- /dev/null +++ b/internal/config/scopes.go @@ -0,0 +1,52 @@ +// Copyright 2026 Beacon Contributors +// SPDX-License-Identifier: AGPL-3.0-or-later + +package config + +import ( + "fmt" + "strings" + + "github.com/MeshCore-Beacon/beacon-server/internal/scopestore" +) + +func (c *Config) validateScopes() error { + for i, s := range c.Scopes { + if strings.TrimSpace(s.Name) == "" { + return fmt.Errorf("scopes[%d].name is required", i) + } + if s.Region == "" { + return fmt.Errorf("scope %q needs a region", s.Name) + } + if c.region(s.Region) == nil { + return fmt.Errorf("scope %q region %q is not a configured region", s.Name, s.Region) + } + } + return nil +} + +func (c *Config) region(slug string) *RegionConfig { + for i := range c.Regions { + if c.Regions[i].Slug == slug { + return &c.Regions[i] + } + } + return nil +} + +// ManualScopeMembers maps each region IATA to the normalized names of its configured scopes. +func (c *Config) ManualScopeMembers() map[string][]string { + members := map[string][]string{} + for _, s := range c.Scopes { + region := c.region(s.Region) + if region == nil { + continue + } + name := scopestore.FromName(s.Name).Name + for _, iata := range region.IATAs { + iata = strings.ToUpper(iata) + members[iata] = append(members[iata], name) + } + } + return members +} diff --git a/internal/config/scopes_test.go b/internal/config/scopes_test.go new file mode 100644 index 00000000..6be61e4f --- /dev/null +++ b/internal/config/scopes_test.go @@ -0,0 +1,43 @@ +// Copyright 2026 Beacon Contributors +// SPDX-License-Identifier: AGPL-3.0-or-later + +package config + +import ( + "os" + "path/filepath" + "reflect" + "strings" + "testing" +) + +func TestScopesConfig(t *testing.T) { + valid := "regions:\n - slug: ottawa\n iatas: [YOW, YGK]\n - slug: bc\n iatas: [YVR]\nscopes:\n - name: on\n region: ottawa\n - name: \"#west\"\n region: bc\n - name: \"$ottawa\"\n region: ottawa\n" + for _, tc := range []struct { + name, text string + bad bool + }{ + {"valid", valid, false}, + {"missing region", strings.Replace(valid, " region: bc\n", "", 1), true}, + {"unknown region", strings.Replace(valid, "region: bc", "region: prairies", 1), true}, + {"empty name", strings.Replace(valid, "name: on", "name: \"\"", 1), true}, + } { + t.Run(tc.name, func(t *testing.T) { + p := filepath.Join(t.TempDir(), "config.yaml") + if err := os.WriteFile(p, []byte(tc.text), 0600); err != nil { + t.Fatal(err) + } + cfg, err := Load(p) + if (err != nil) != tc.bad { + t.Fatalf("error=%v", err) + } + if tc.bad { + return + } + want := map[string][]string{"YOW": {"#on", "$ottawa"}, "YGK": {"#on", "$ottawa"}, "YVR": {"#west"}} + if got := cfg.ManualScopeMembers(); !reflect.DeepEqual(got, want) { + t.Fatalf("ManualScopeMembers = %v, want %v", got, want) + } + }) + } +} diff --git a/internal/meshmapper/scopes.go b/internal/meshmapper/scopes.go index ac389a14..1c267679 100644 --- a/internal/meshmapper/scopes.go +++ b/internal/meshmapper/scopes.go @@ -233,8 +233,10 @@ func (i *Importer) publish() { for _, e := range i.manual { byName[e.Name] = e } + members := make(map[string][]string, len(i.sources)) for _, s := range i.sources { for _, candidate := range s.entries { + members[s.iata] = append(members[s.iata], candidate.Name) entry, exists := byName[candidate.Name] if exists && entry.IATAs == nil { continue @@ -256,6 +258,7 @@ func (i *Importer) publish() { entries = append(entries, byName[name]) } i.scopes.Load(entries) + i.scopes.SetCatalogueMembers(members) } func (i *Importer) log(s source, action string) { diff --git a/internal/meshmapper/scopes_test.go b/internal/meshmapper/scopes_test.go index b8e1b24d..95ac0b96 100644 --- a/internal/meshmapper/scopes_test.go +++ b/internal/meshmapper/scopes_test.go @@ -111,6 +111,9 @@ func TestCatalogueRefreshFallbackAndRestart(t *testing.T) { if !reflect.DeepEqual(scopes.Entries()[1].IATAs, []string{"YOW"}) { t.Fatal("import not bound to region") } + if got := scopes.NamesForIATAs([]string{"YOW"}); !reflect.DeepEqual(got, []string{"#manual", "#yow"}) { + t.Fatal("catalogue membership must include manually overridden names", got) + } generated := imp.sources[0].generated status = 304 check() @@ -243,8 +246,11 @@ func TestImportedSourcesOverlapWithoutGlobalMembership(t *testing.T) { if err != nil || len(scopes.Entries()) != 1 || !reflect.DeepEqual(scopes.Entries()[0].IATAs, []string{"YOW", "YVR"}) { t.Fatal(scopes.Entries(), err) } + if got := scopes.NamesForIATAs([]string{"YVR"}); !reflect.DeepEqual(got, []string{"#can"}) { + t.Fatal(got) + } _, err = New(ctx, config.MeshMapperScopesConfig{Enabled: true, Sources: map[string]string{"YOW": "changed-source"}}, store, scopes, nil) - if err != nil || len(scopes.Entries()) != 0 { + if err != nil || len(scopes.Entries()) != 0 || len(scopes.NamesForIATAs([]string{"YOW", "YVR"})) != 0 { t.Fatal("removed/changed source remained active", err) } // Disabled mode never calls the store, even if URLs remain configured. diff --git a/internal/scopestore/scopestore.go b/internal/scopestore/scopestore.go index da5c80f4..55d6cd04 100644 --- a/internal/scopestore/scopestore.go +++ b/internal/scopestore/scopestore.go @@ -7,6 +7,7 @@ package scopestore import ( "crypto/sha256" + "slices" "strings" "sync" ) @@ -34,6 +35,8 @@ func FromName(name string) Entry { type ScopeStore struct { mu sync.RWMutex entries []Entry + // IATA -> scope names, for region-filtered listings only; matching uses entries. + manual, catalogue map[string][]string } // New creates an empty ScopeStore. @@ -54,3 +57,30 @@ func (s *ScopeStore) Entries() []Entry { defer s.mu.RUnlock() return s.entries } + +// SetManualMembers publishes configured region membership of manual scopes. +func (s *ScopeStore) SetManualMembers(byIATA map[string][]string) { + s.mu.Lock() + defer s.mu.Unlock() + s.manual = byIATA +} + +// SetCatalogueMembers publishes imported catalogue membership, including names a manual scope overrides. +func (s *ScopeStore) SetCatalogueMembers(byIATA map[string][]string) { + s.mu.Lock() + defer s.mu.Unlock() + s.catalogue = byIATA +} + +// NamesForIATAs returns the sorted names of scopes that belong to any of the given IATAs. +func (s *ScopeStore) NamesForIATAs(iatas []string) []string { + s.mu.RLock() + defer s.mu.RUnlock() + names := []string{} + for _, iata := range iatas { + names = append(names, s.manual[iata]...) + names = append(names, s.catalogue[iata]...) + } + slices.Sort(names) + return slices.Compact(names) +} diff --git a/internal/scopestore/scopestore_test.go b/internal/scopestore/scopestore_test.go index 3e2f3592..08eef4c4 100644 --- a/internal/scopestore/scopestore_test.go +++ b/internal/scopestore/scopestore_test.go @@ -4,6 +4,7 @@ package scopestore import ( + "slices" "testing" ) @@ -54,3 +55,21 @@ func TestEntriesRetainsImmutableSnapshotWithoutAllocation(t *testing.T) { t.Fatal("replacement mutated the previous reader snapshot") } } + +func TestNamesForIATAs(t *testing.T) { + s := New() + if got := s.NamesForIATAs([]string{"YOW"}); got == nil || len(got) != 0 { + t.Fatalf("empty store = %#v, want empty non-nil slice", got) + } + s.SetManualMembers(map[string][]string{"YOW": {"#ottawa", "#on"}, "YVR": {"#bc"}}) + s.SetCatalogueMembers(map[string][]string{"YOW": {"#on", "#gatineau"}, "YUL": {"#qc"}}) + got := s.NamesForIATAs([]string{"YOW", "YUL", "YYZ"}) + want := []string{"#gatineau", "#on", "#ottawa", "#qc"} + if !slices.Equal(got, want) { + t.Fatalf("NamesForIATAs = %v, want %v", got, want) + } + s.SetCatalogueMembers(nil) + if got := s.NamesForIATAs([]string{"YUL"}); len(got) != 0 { + t.Fatalf("cleared catalogue still lists %v", got) + } +}