mirror of
https://github.com/MeshCore-Beacon/beacon-server.git
synced 2026-10-16 20:20:07 +00:00
release: v2.0.3 (#215)
* chore: add .github/CODEOWNERS for main branch policy Signed-off-by: MrAlders0n <MrAlders0n@smal.ca> * chore: add .github/workflows/main-source-guard.yml for main branch policy Signed-off-by: MrAlders0n <MrAlders0n@smal.ca> * feat(meshmapper): authenticate API imports (#211) * feat(observers): add traffic-sorted directory (#212) * fix(observers): rank available traffic over rolling seven days * chore: bump version to 2.1.0 * chore: set release version to 2.0.2 * feat(api): add /info with mobile min app version (#214) * chore: bump version to 2.0.3 --------- Signed-off-by: MrAlders0n <MrAlders0n@smal.ca>
This commit is contained in:
+5
-2
@@ -18,7 +18,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/MeshCore-Beacon/beacon-server/db"
|
||||
_ "github.com/MeshCore-Beacon/beacon-server/docs"
|
||||
"github.com/MeshCore-Beacon/beacon-server/docs"
|
||||
"github.com/MeshCore-Beacon/beacon-server/internal/api"
|
||||
"github.com/MeshCore-Beacon/beacon-server/internal/api/handlers"
|
||||
"github.com/MeshCore-Beacon/beacon-server/internal/api/router"
|
||||
@@ -45,7 +45,7 @@ import (
|
||||
var version = "dev"
|
||||
|
||||
// @title MeshCore Beacon API
|
||||
// @version 2.0.2
|
||||
// @version 2.0.3
|
||||
// @description MeshCore network observation backend. Ingests LoRa packets from MQTT brokers, stores in PostgreSQL, and streams live events via WebSocket.
|
||||
// @description REST requests share a configurable per-client rate limit (default 300/minute and a 300-request one-second burst cap). Exceeded limits return HTTP 429 with error.code=rate_limited and a Retry-After header in seconds. CORS preflights and WebSocket upgrades do not consume this API budget.
|
||||
// @description WebSocket upgrade attempts at /ws have a configurable per-client limit (default 10/minute, including failed handshakes). Rate exhaustion returns HTTP 429 with Retry-After before upgrade. An accepted socket exceeding the concurrent cap closes with code 1013 before hello; established connections remain open.
|
||||
@@ -84,6 +84,8 @@ var version = "dev"
|
||||
// @tag.description MQTT broker connection status
|
||||
// @tag.name Stats
|
||||
// @tag.description Network statistics and time series
|
||||
// @tag.name Info
|
||||
// @tag.description Server info for the BEACON Mobile app
|
||||
func main() {
|
||||
_ = godotenv.Load()
|
||||
addr := os.Getenv("LISTEN_ADDR")
|
||||
@@ -432,6 +434,7 @@ func main() {
|
||||
WSAllowedOrigins: cfg.WebSocket.AllowedOrigins,
|
||||
CORS: cfg.CORS, Server: cfg.Server, Auth: cfg.Auth, RateLimit: resolved.RateLimit,
|
||||
Scopes: scopes, RollupRetention: resolved.RollupRetention,
|
||||
MinAppVersion: cfg.Mobile.MinAppVersion, ServerVersion: docs.SwaggerInfo.Version,
|
||||
AdminRoutes: map[string]http.Handler{
|
||||
"/accounts": handlers.AccountsRouter(store),
|
||||
"/backup": handlers.BackupRouter(backupOpts, ctx),
|
||||
|
||||
@@ -319,3 +319,9 @@ cache:
|
||||
# view_refresh: 1h # rebuilds radio presets; default: 1h
|
||||
# reconfirm: 1h # default: 1h
|
||||
# cleanup: 1h # default: 1h
|
||||
|
||||
# Minimum BEACON Mobile version, served at GET /api/v1/info. Older apps show
|
||||
# "update required" for this server. Strict X.Y.Z (no v, no suffix); anything
|
||||
# else prevents startup. Default: empty, no requirement.
|
||||
#mobile:
|
||||
# min_app_version: "0.1.1"
|
||||
|
||||
+51
-1
@@ -897,6 +897,26 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"/info": {
|
||||
"get": {
|
||||
"description": "minAppVersion is null when the server sets no requirement.",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Info"
|
||||
],
|
||||
"summary": "Server info and minimum mobile app version",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.Info"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/messages": {
|
||||
"get": {
|
||||
"produces": [
|
||||
@@ -3639,6 +3659,9 @@ const docTemplate = `{
|
||||
},
|
||||
"ingest": {
|
||||
"$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.AdminIngestConfig"
|
||||
},
|
||||
"mobile": {
|
||||
"$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.AdminMobileConfig"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -3651,6 +3674,15 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"github_com_MeshCore-Beacon_beacon-server_internal_api.AdminMobileConfig": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"min_app_version": {
|
||||
"description": "empty means no requirement",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"github_com_MeshCore-Beacon_beacon-server_internal_api.AdvertObservation": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -3947,6 +3979,20 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"github_com_MeshCore-Beacon_beacon-server_internal_api.Info": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"minAppVersion": {
|
||||
"description": "MinAppVersion is the oldest allowed app version (X.Y.Z); null means no requirement.",
|
||||
"type": "string",
|
||||
"example": "0.1.1"
|
||||
},
|
||||
"serverVersion": {
|
||||
"type": "string",
|
||||
"example": "2.0.2"
|
||||
}
|
||||
}
|
||||
},
|
||||
"github_com_MeshCore-Beacon_beacon-server_internal_api.KnownRoute": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -6030,13 +6076,17 @@ const docTemplate = `{
|
||||
{
|
||||
"description": "Network statistics and time series",
|
||||
"name": "Stats"
|
||||
},
|
||||
{
|
||||
"description": "Server info for the BEACON Mobile app",
|
||||
"name": "Info"
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
// SwaggerInfo holds exported Swagger Info so clients can modify it
|
||||
var SwaggerInfo = &swag.Spec{
|
||||
Version: "2.0.2",
|
||||
Version: "2.0.3",
|
||||
Host: "localhost:8080",
|
||||
BasePath: "/api/v1",
|
||||
Schemes: []string{"http", "https"},
|
||||
|
||||
+51
-1
@@ -15,7 +15,7 @@
|
||||
"license": {
|
||||
"name": "AGPL-3-or-later"
|
||||
},
|
||||
"version": "2.0.2"
|
||||
"version": "2.0.3"
|
||||
},
|
||||
"host": "localhost:8080",
|
||||
"basePath": "/api/v1",
|
||||
@@ -895,6 +895,26 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/info": {
|
||||
"get": {
|
||||
"description": "minAppVersion is null when the server sets no requirement.",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Info"
|
||||
],
|
||||
"summary": "Server info and minimum mobile app version",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.Info"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/messages": {
|
||||
"get": {
|
||||
"produces": [
|
||||
@@ -3637,6 +3657,9 @@
|
||||
},
|
||||
"ingest": {
|
||||
"$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.AdminIngestConfig"
|
||||
},
|
||||
"mobile": {
|
||||
"$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.AdminMobileConfig"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -3649,6 +3672,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"github_com_MeshCore-Beacon_beacon-server_internal_api.AdminMobileConfig": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"min_app_version": {
|
||||
"description": "empty means no requirement",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"github_com_MeshCore-Beacon_beacon-server_internal_api.AdvertObservation": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -3945,6 +3977,20 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"github_com_MeshCore-Beacon_beacon-server_internal_api.Info": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"minAppVersion": {
|
||||
"description": "MinAppVersion is the oldest allowed app version (X.Y.Z); null means no requirement.",
|
||||
"type": "string",
|
||||
"example": "0.1.1"
|
||||
},
|
||||
"serverVersion": {
|
||||
"type": "string",
|
||||
"example": "2.0.2"
|
||||
}
|
||||
}
|
||||
},
|
||||
"github_com_MeshCore-Beacon_beacon-server_internal_api.KnownRoute": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -6028,6 +6074,10 @@
|
||||
{
|
||||
"description": "Network statistics and time series",
|
||||
"name": "Stats"
|
||||
},
|
||||
{
|
||||
"description": "Server info for the BEACON Mobile app",
|
||||
"name": "Info"
|
||||
}
|
||||
]
|
||||
}
|
||||
+35
-1
@@ -52,6 +52,8 @@ definitions:
|
||||
$ref: '#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.AdminCORSConfig'
|
||||
ingest:
|
||||
$ref: '#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.AdminIngestConfig'
|
||||
mobile:
|
||||
$ref: '#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.AdminMobileConfig'
|
||||
type: object
|
||||
github_com_MeshCore-Beacon_beacon-server_internal_api.AdminIngestConfig:
|
||||
properties:
|
||||
@@ -61,6 +63,12 @@ definitions:
|
||||
or a configurable processing-worker pool.
|
||||
type: integer
|
||||
type: object
|
||||
github_com_MeshCore-Beacon_beacon-server_internal_api.AdminMobileConfig:
|
||||
properties:
|
||||
min_app_version:
|
||||
description: empty means no requirement
|
||||
type: string
|
||||
type: object
|
||||
github_com_MeshCore-Beacon_beacon-server_internal_api.AdvertObservation:
|
||||
properties:
|
||||
heardAt:
|
||||
@@ -264,6 +272,17 @@ definitions:
|
||||
lon:
|
||||
type: number
|
||||
type: object
|
||||
github_com_MeshCore-Beacon_beacon-server_internal_api.Info:
|
||||
properties:
|
||||
minAppVersion:
|
||||
description: MinAppVersion is the oldest allowed app version (X.Y.Z); null
|
||||
means no requirement.
|
||||
example: 0.1.1
|
||||
type: string
|
||||
serverVersion:
|
||||
example: 2.0.2
|
||||
type: string
|
||||
type: object
|
||||
github_com_MeshCore-Beacon_beacon-server_internal_api.KnownRoute:
|
||||
properties:
|
||||
firstSeen:
|
||||
@@ -1708,7 +1727,7 @@ info:
|
||||
name: AGPL-3-or-later
|
||||
termsOfService: https://github.com/MeshCore-Beacon/beacon-server
|
||||
title: MeshCore Beacon API
|
||||
version: 2.0.2
|
||||
version: 2.0.3
|
||||
paths:
|
||||
/admin/accounts:
|
||||
get:
|
||||
@@ -2301,6 +2320,19 @@ paths:
|
||||
summary: Get an IATA's GeoJSON border, if configured
|
||||
tags:
|
||||
- IATAs
|
||||
/info:
|
||||
get:
|
||||
description: minAppVersion is null when the server sets no requirement.
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.Info'
|
||||
summary: Server info and minimum mobile app version
|
||||
tags:
|
||||
- Info
|
||||
/messages:
|
||||
get:
|
||||
parameters:
|
||||
@@ -4186,3 +4218,5 @@ tags:
|
||||
name: Brokers
|
||||
- description: Network statistics and time series
|
||||
name: Stats
|
||||
- description: Server info for the BEACON Mobile app
|
||||
name: Info
|
||||
|
||||
@@ -9,6 +9,11 @@ type AdminConfig struct {
|
||||
Auth AdminAuthConfig `json:"auth"`
|
||||
CORS AdminCORSConfig `json:"cors"`
|
||||
Ingest AdminIngestConfig `json:"ingest"`
|
||||
Mobile AdminMobileConfig `json:"mobile"`
|
||||
}
|
||||
|
||||
type AdminMobileConfig struct {
|
||||
MinAppVersion string `json:"min_app_version"` // empty means no requirement
|
||||
}
|
||||
|
||||
type AdminAuthConfig struct {
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
// Copyright 2026 Beacon Contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/MeshCore-Beacon/beacon-server/internal/api"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// InfoRouter mounts GET /info. The mobile app treats a 404 as "no requirement",
|
||||
// so this route must stay mounted once shipped.
|
||||
func InfoRouter(info api.Info) http.Handler {
|
||||
r := chi.NewRouter()
|
||||
r.Get("/", getInfo(info))
|
||||
return r
|
||||
}
|
||||
|
||||
// getInfo godoc
|
||||
//
|
||||
// @Summary Server info and minimum mobile app version
|
||||
// @Description minAppVersion is null when the server sets no requirement.
|
||||
// @Tags Info
|
||||
// @Produce json
|
||||
// @Success 200 {object} api.Info
|
||||
// @Router /info [get]
|
||||
func getInfo(info api.Info) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
respond(w, http.StatusOK, info)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
// Copyright 2026 Beacon Contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/MeshCore-Beacon/beacon-server/internal/api"
|
||||
)
|
||||
|
||||
func TestGetInfo(t *testing.T) {
|
||||
version := "0.1.1"
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
info api.Info
|
||||
want string
|
||||
}{
|
||||
{"unset", api.Info{ServerVersion: "2.0.2"}, `{"minAppVersion":null,"serverVersion":"2.0.2"}`},
|
||||
{"set", api.Info{MinAppVersion: &version, ServerVersion: "2.0.2"}, `{"minAppVersion":"0.1.1","serverVersion":"2.0.2"}`},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
InfoRouter(tc.info).ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/", nil))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
if got := w.Header().Get("Cache-Control"); got != "no-cache" {
|
||||
t.Fatalf("Cache-Control = %q, want no-cache", got)
|
||||
}
|
||||
if got := strings.TrimSpace(w.Body.String()); got != tc.want {
|
||||
t.Fatalf("body = %s, want %s", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
// Copyright 2026 Beacon Contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package api
|
||||
|
||||
// Info is the public server description the mobile app checks before using a server.
|
||||
type Info struct {
|
||||
// MinAppVersion is the oldest allowed app version (X.Y.Z); null means no requirement.
|
||||
MinAppVersion *string `json:"minAppVersion" example:"0.1.1"`
|
||||
ServerVersion string `json:"serverVersion" example:"2.0.2"`
|
||||
}
|
||||
@@ -56,7 +56,7 @@ func TestAdminConfig(t *testing.T) {
|
||||
if err := json.Unmarshal(response.Body.Bytes(), &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
expected := map[string]any{"cors": want, "auth": map[string]any{"configured": true}, "ingest": map[string]any{"broker_count": float64(2)}}
|
||||
expected := map[string]any{"cors": want, "auth": map[string]any{"configured": true}, "ingest": map[string]any{"broker_count": float64(2)}, "mobile": map[string]any{"min_app_version": ""}}
|
||||
if !reflect.DeepEqual(body, expected) {
|
||||
t.Fatalf("unexpected config shape or values: %v", body)
|
||||
}
|
||||
|
||||
@@ -23,6 +23,8 @@ type Options struct {
|
||||
RateLimit config.ResolvedRateLimitConfig
|
||||
Scopes handlers.ScopeMembership // region-filtered scope lists; nil lists none
|
||||
RollupRetention time.Duration // longest historical stats window
|
||||
MinAppVersion string // empty means no mobile app requirement
|
||||
ServerVersion string
|
||||
// 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.
|
||||
|
||||
@@ -27,6 +27,7 @@ import (
|
||||
//
|
||||
// /ws → WebSocket (public in v1)
|
||||
// /api/v1/ → public group
|
||||
// /info → server info for the mobile app
|
||||
// /packets → packets subrouter
|
||||
// /nodes → nodes subrouter
|
||||
// /brokers → brokers subrouter
|
||||
@@ -69,6 +70,11 @@ func New(h *hub.Hub, reader api.Reader, workers []*ingest.Worker, opts Options)
|
||||
MaxAge: maxAge,
|
||||
},
|
||||
Ingest: api.AdminIngestConfig{BrokerCount: len(workers)},
|
||||
Mobile: api.AdminMobileConfig{MinAppVersion: opts.MinAppVersion},
|
||||
}
|
||||
info := api.Info{ServerVersion: opts.ServerVersion}
|
||||
if opts.MinAppVersion != "" {
|
||||
info.MinAppVersion = &opts.MinAppVersion
|
||||
}
|
||||
runtimeConfig := mw.NewRuntimeConfig(adminConfig, []string{"Retry-After"})
|
||||
r.Use(runtimeConfig.CORS)
|
||||
@@ -110,6 +116,7 @@ func New(h *hub.Hub, reader api.Reader, workers []*ingest.Worker, opts Options)
|
||||
r.Mount("/scopes", handlers.ScopesRouter(reader, opts.Scopes))
|
||||
r.Mount("/stats", handlers.StatsRouter(reader, handlers.StatsOptions{Scopes: opts.Scopes, SeriesWindow: opts.RollupRetention}))
|
||||
r.Mount("/traces", handlers.TracesRouter(reader))
|
||||
r.Mount("/info", handlers.InfoRouter(info))
|
||||
})
|
||||
|
||||
// Protect the entire subtree, including its root and unknown paths.
|
||||
|
||||
@@ -90,3 +90,13 @@ func checkWebSocketLimit(t *testing.T, cfg config.ServerConfig, secondIP string,
|
||||
t.Fatalf("established client stopped responding: %s %v", pong, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Old servers 404 here and the app reads that as "no requirement", so the route must stay public.
|
||||
func TestInfoRoute(t *testing.T) {
|
||||
handler := New(nil, nil, nil, Options{MinAppVersion: "0.1.1", ServerVersion: "2.0.2"})
|
||||
response := httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/api/v1/info", nil))
|
||||
if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), `"minAppVersion":"0.1.1"`) {
|
||||
t.Fatalf("info: %d %s", response.Code, response.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -43,6 +44,13 @@ type Config struct {
|
||||
Nodes NodesConfig `yaml:"nodes"`
|
||||
Observers ObserversConfig `yaml:"observers"`
|
||||
Analytics AnalyticsConfig `yaml:"analytics"`
|
||||
Mobile MobileConfig `yaml:"mobile"`
|
||||
}
|
||||
|
||||
// MobileConfig holds settings the BEACON Mobile app reads from /info.
|
||||
type MobileConfig struct {
|
||||
// MinAppVersion is the oldest app version (X.Y.Z) allowed to use this server; empty means any.
|
||||
MinAppVersion string `yaml:"min_app_version"`
|
||||
}
|
||||
|
||||
// BackupConfig enables protected downloads. Disabled by default.
|
||||
@@ -450,6 +458,9 @@ func Load(path string) (*Config, error) {
|
||||
if err := cfg.validateScopes(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := cfg.validateMobile(); 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")
|
||||
}
|
||||
@@ -479,6 +490,17 @@ func (c *Config) validateAPIKey() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
var appVersionPattern = regexp.MustCompile(`^[0-9]+\.[0-9]+\.[0-9]+$`)
|
||||
|
||||
// validateMobile keeps min_app_version to plain X.Y.Z so every app can compare it.
|
||||
func (c *Config) validateMobile() error {
|
||||
c.Mobile.MinAppVersion = strings.TrimSpace(c.Mobile.MinAppVersion)
|
||||
if v := c.Mobile.MinAppVersion; v != "" && !appVersionPattern.MatchString(v) {
|
||||
return fmt.Errorf("mobile.min_app_version must be X.Y.Z (digits only), got %q", v)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateDurations rejects negative intervals, which would otherwise panic tickers or invert cutoffs.
|
||||
// observers.delete_after is exempt: nonpositive disables it.
|
||||
func (c *Config) validateDurations() error {
|
||||
|
||||
@@ -301,3 +301,34 @@ func TestLoad_NegativeDurationsRejected(t *testing.T) {
|
||||
t.Errorf("observers.delete_after: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadMinAppVersion(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name, yaml, want string
|
||||
wantError bool
|
||||
}{
|
||||
{"omitted", "{}", "", false},
|
||||
{"empty", "mobile: {min_app_version: ''}", "", false},
|
||||
{"valid", "mobile: {min_app_version: '0.1.1'}", "0.1.1", false},
|
||||
{"multi-digit", "mobile: {min_app_version: ' 10.20.30 '}", "10.20.30", false},
|
||||
{"v prefix", "mobile: {min_app_version: 'v0.1.1'}", "", true},
|
||||
{"two parts", "mobile: {min_app_version: '0.1'}", "", true},
|
||||
{"pre-release", "mobile: {min_app_version: '0.1.1-beta'}", "", true},
|
||||
{"four parts", "mobile: {min_app_version: '0.1.1.2'}", "", true},
|
||||
{"letters", "mobile: {min_app_version: 'a.b.c'}", "", true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "config.yaml")
|
||||
if err := os.WriteFile(path, []byte(tc.yaml), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := Load(path)
|
||||
if (err != nil) != tc.wantError {
|
||||
t.Fatalf("Load error = %v, want error = %v", err, tc.wantError)
|
||||
}
|
||||
if err == nil && cfg.Mobile.MinAppVersion != tc.want {
|
||||
t.Fatalf("min_app_version = %q, want %q", cfg.Mobile.MinAppVersion, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user