Skip to content

Commit 39a3861

Browse files
committed
feat: Implement feature gating for OpenBridge and IPSC, update related tests and UI components
1 parent 8f6c57c commit 39a3861

File tree

10 files changed

+478
-29
lines changed

10 files changed

+478
-29
lines changed

internal/http/api/controllers/v1/peers/peers_test.go

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import (
2828
"testing"
2929
"time"
3030

31+
"github.com/USA-RedDragon/DMRHub/internal/config"
3132
"github.com/USA-RedDragon/DMRHub/internal/db/models"
3233
"github.com/USA-RedDragon/DMRHub/internal/http/api/apimodels"
3334
"github.com/USA-RedDragon/DMRHub/internal/testutils"
@@ -708,3 +709,95 @@ func TestPOSTPeerInvalidPort(t *testing.T) {
708709
assert.Equal(t, http.StatusBadRequest, w.Code)
709710
assert.Equal(t, "Port must be between 1 and 65535", resp.Error)
710711
}
712+
713+
func TestGETPeersOpenBridgeDisabledNotRegistered(t *testing.T) {
714+
t.Parallel()
715+
716+
router, tdb, err := testutils.CreateTestDBRouterWithOptions(func(cfg *config.Config) {
717+
cfg.DMR.OpenBridge.Enabled = false
718+
})
719+
require.NoError(t, err)
720+
defer tdb.CloseDB()
721+
722+
_, _, adminJar := testutils.LoginAdmin(t, router)
723+
724+
w := httptest.NewRecorder()
725+
726+
ctx, cancel := context.WithTimeout(context.Background(), testTimeout)
727+
defer cancel()
728+
729+
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, "/api/v1/peers", nil)
730+
for _, cookie := range adminJar.Cookies() {
731+
req.Header.Add("Cookie", cookie.String())
732+
}
733+
router.ServeHTTP(w, req)
734+
735+
// When OpenBridge is disabled the peer API routes are not registered.
736+
// The request may return 404 or be caught by the SPA wildcard (200 + text/html).
737+
// Either way, the response must NOT be JSON from the peers controller.
738+
ct := w.Header().Get("Content-Type")
739+
assert.NotContains(t, ct, "application/json",
740+
"Peers API endpoint should not be registered when OpenBridge is disabled")
741+
}
742+
743+
func TestPOSTPeerOpenBridgeDisabledReturns404(t *testing.T) {
744+
t.Parallel()
745+
746+
router, tdb, err := testutils.CreateTestDBRouterWithOptions(func(cfg *config.Config) {
747+
cfg.DMR.OpenBridge.Enabled = false
748+
})
749+
require.NoError(t, err)
750+
defer tdb.CloseDB()
751+
752+
_, _, adminJar := testutils.LoginAdmin(t, router)
753+
754+
peer := apimodels.PeerPost{
755+
ID: 400001,
756+
IP: "10.0.0.1",
757+
Port: 62035,
758+
Ingress: true,
759+
Egress: true,
760+
}
761+
762+
jsonBody, err := json.Marshal(peer)
763+
require.NoError(t, err)
764+
765+
w := httptest.NewRecorder()
766+
767+
ctx, cancel := context.WithTimeout(context.Background(), testTimeout)
768+
defer cancel()
769+
770+
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, "/api/v1/peers", bytes.NewBuffer(jsonBody))
771+
req.Header.Set("Content-Type", "application/json")
772+
for _, cookie := range adminJar.Cookies() {
773+
req.Header.Add("Cookie", cookie.String())
774+
}
775+
router.ServeHTTP(w, req)
776+
777+
assert.Equal(t, http.StatusNotFound, w.Code)
778+
}
779+
780+
func TestGETMyPeersOpenBridgeDisabledReturns404(t *testing.T) {
781+
t.Parallel()
782+
783+
router, tdb, err := testutils.CreateTestDBRouterWithOptions(func(cfg *config.Config) {
784+
cfg.DMR.OpenBridge.Enabled = false
785+
})
786+
require.NoError(t, err)
787+
defer tdb.CloseDB()
788+
789+
_, _, adminJar := testutils.LoginAdmin(t, router)
790+
791+
w := httptest.NewRecorder()
792+
793+
ctx, cancel := context.WithTimeout(context.Background(), testTimeout)
794+
defer cancel()
795+
796+
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, "/api/v1/peers/my", nil)
797+
for _, cookie := range adminJar.Cookies() {
798+
req.Header.Add("Cookie", cookie.String())
799+
}
800+
router.ServeHTTP(w, req)
801+
802+
assert.Equal(t, http.StatusNotFound, w.Code)
803+
}

internal/http/api/controllers/v1/repeaters/repeaters.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,7 @@ func POSTRepeaterTalkgroups(c *gin.Context) {
271271
c.JSON(http.StatusOK, gin.H{"message": "Repeater talkgroups updated"})
272272
}
273273

274+
//nolint:gocyclo
274275
func POSTRepeater(c *gin.Context) {
275276
session := sessions.Default(c)
276277
usID := session.Get("user_id")
@@ -287,10 +288,12 @@ func POSTRepeater(c *gin.Context) {
287288
}
288289
db, ok := utils.GetDB(c)
289290
if !ok {
291+
c.JSON(http.StatusInternalServerError, gin.H{"error": "Try again later"})
290292
return
291293
}
292294
config, ok := utils.GetConfig(c)
293295
if !ok {
296+
c.JSON(http.StatusInternalServerError, gin.H{"error": "Try again later"})
294297
return
295298
}
296299

@@ -306,6 +309,7 @@ func POSTRepeater(c *gin.Context) {
306309
if err != nil {
307310
slog.Error("JSON data is invalid", "function", "POSTRepeater", "error", err)
308311
c.JSON(http.StatusBadRequest, gin.H{"error": "JSON data is invalid"})
312+
return
309313
} else {
310314
// Default type to MMDVM if not specified
311315
if json.Type == "" {
@@ -315,6 +319,10 @@ func POSTRepeater(c *gin.Context) {
315319
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid repeater type"})
316320
return
317321
}
322+
if json.Type == models.RepeaterTypeIPSC && !config.DMR.IPSC.Enabled {
323+
c.JSON(http.StatusBadRequest, gin.H{"error": "IPSC is not enabled on this server"})
324+
return
325+
}
318326

319327
var repeater models.Repeater
320328
repeater.Type = json.Type

internal/http/api/controllers/v1/repeaters/repeaters_test.go

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import (
2828
"testing"
2929
"time"
3030

31+
"github.com/USA-RedDragon/DMRHub/internal/config"
3132
"github.com/USA-RedDragon/DMRHub/internal/db/models"
3233
"github.com/USA-RedDragon/DMRHub/internal/http/api/apimodels"
3334
"github.com/USA-RedDragon/DMRHub/internal/testutils"
@@ -520,3 +521,61 @@ func TestPATCHRepeaterNonexistent(t *testing.T) {
520521
assert.Equal(t, http.StatusBadRequest, w.Code)
521522
assert.Equal(t, "repeater does not exist", resp.Error)
522523
}
524+
525+
func TestPOSTRepeaterIPSCDisabledRejects(t *testing.T) {
526+
t.Parallel()
527+
528+
router, tdb, err := testutils.CreateTestDBRouterWithOptions(func(cfg *config.Config) {
529+
cfg.DMR.IPSC.Enabled = false
530+
})
531+
require.NoError(t, err)
532+
defer tdb.CloseDB()
533+
534+
user := apimodels.UserRegistration{
535+
DMRId: 3191868,
536+
Callsign: "KI5VMF",
537+
Username: "testuser",
538+
Password: "password",
539+
}
540+
541+
_, _, userJar := testutils.CreateAndLoginUser(t, router, user)
542+
543+
repeaterPost := apimodels.RepeaterPost{
544+
RadioID: 319186803,
545+
Type: "ipsc",
546+
}
547+
548+
resp, w := testutils.CreateRepeater(t, router, userJar, repeaterPost)
549+
550+
assert.Equal(t, http.StatusBadRequest, w.Code)
551+
assert.Equal(t, "IPSC is not enabled on this server", resp.Error)
552+
}
553+
554+
func TestPOSTRepeaterIPSCDisabledAllowsMMDVM(t *testing.T) {
555+
t.Parallel()
556+
557+
router, tdb, err := testutils.CreateTestDBRouterWithOptions(func(cfg *config.Config) {
558+
cfg.DMR.IPSC.Enabled = false
559+
})
560+
require.NoError(t, err)
561+
defer tdb.CloseDB()
562+
563+
user := apimodels.UserRegistration{
564+
DMRId: 3191868,
565+
Callsign: "KI5VMF",
566+
Username: "testuser",
567+
Password: "password",
568+
}
569+
570+
_, _, userJar := testutils.CreateAndLoginUser(t, router, user)
571+
572+
repeaterPost := apimodels.RepeaterPost{
573+
RadioID: 319186804,
574+
}
575+
576+
resp, w := testutils.CreateRepeater(t, router, userJar, repeaterPost)
577+
578+
assert.Equal(t, http.StatusOK, w.Code)
579+
assert.Equal(t, "Repeater created", resp.Message)
580+
assert.NotEmpty(t, resp.Password)
581+
}

internal/http/api/routes.go

Lines changed: 19 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -57,17 +57,19 @@ func ApplyRoutes(config *configPkg.Config, router *gin.Engine, dmrHub *hub.Hub,
5757

5858
apiV1 := router.Group("/api/v1")
5959
apiV1.Use(ratelimit)
60-
v1(apiV1, userSuspension)
60+
v1(config, apiV1, userSuspension)
6161

6262
ws := router.Group("/ws")
6363
ws.Use(ratelimit)
6464
ws.GET("/repeaters", middleware.RequireLogin(), userSuspension, websocket.CreateHandler(config, websocketControllers.CreateRepeatersWebsocket(db, pubsub)))
6565
ws.GET("/calls", middleware.RequireLogin(), userSuspension, websocket.CreateHandler(config, websocketControllers.CreateCallsWebsocket(dmrHub, db, pubsub)))
66-
ws.GET("/peers", middleware.RequireLogin(), userSuspension, websocket.CreateHandler(config, websocketControllers.CreatePeersWebsocket(db, pubsub)))
66+
if config.DMR.OpenBridge.Enabled {
67+
ws.GET("/peers", middleware.RequireLogin(), userSuspension, websocket.CreateHandler(config, websocketControllers.CreatePeersWebsocket(db, pubsub)))
68+
}
6769
ws.GET("/nets", middleware.RequireLogin(), userSuspension, websocket.CreateHandler(config, websocketControllers.CreateNetsWebsocket(pubsub)))
6870
}
6971

70-
func v1(group *gin.RouterGroup, userSuspension gin.HandlerFunc) {
72+
func v1(config *configPkg.Config, group *gin.RouterGroup, userSuspension gin.HandlerFunc) {
7173
v1Auth := group.Group("/auth")
7274
v1Auth.POST("/login", v1AuthControllers.POSTLogin)
7375
v1Auth.POST("/logout", v1AuthControllers.POSTLogout)
@@ -117,18 +119,20 @@ func v1(group *gin.RouterGroup, userSuspension gin.HandlerFunc) {
117119
v1Users.PATCH("/:id", middleware.RequireSelfOrAdmin(), userSuspension, v1UsersControllers.PATCHUser)
118120
v1Users.DELETE("/:id", middleware.RequireSuperAdmin(), userSuspension, v1UsersControllers.DELETEUser)
119121

120-
v1Peers := group.Group("/peers")
121-
// Paginated
122-
v1Peers.GET("", middleware.RequireAdmin(), v1PeersControllers.GETPeers)
123-
// Paginated
124-
v1Peers.GET("/my", middleware.RequireLogin(), v1PeersControllers.GETMyPeers)
125-
v1Peers.POST("", middleware.RequireAdmin(), v1PeersControllers.POSTPeer)
126-
v1Peers.GET("/:id", middleware.RequirePeerOwnerOrAdmin(), v1PeersControllers.GETPeer)
127-
v1Peers.PATCH("/:id", middleware.RequirePeerOwnerOrAdmin(), v1PeersControllers.PATCHPeer)
128-
v1Peers.DELETE("/:id", middleware.RequirePeerOwnerOrAdmin(), v1PeersControllers.DELETEPeer)
129-
v1Peers.GET("/:id/rules", middleware.RequirePeerOwnerOrAdmin(), v1PeersControllers.GETPeerRules)
130-
v1Peers.POST("/:id/rules", middleware.RequirePeerOwnerOrAdmin(), v1PeersControllers.POSTPeerRule)
131-
v1Peers.DELETE("/:id/rules/:ruleId", middleware.RequirePeerOwnerOrAdmin(), v1PeersControllers.DELETEPeerRule)
122+
if config.DMR.OpenBridge.Enabled {
123+
v1Peers := group.Group("/peers")
124+
// Paginated
125+
v1Peers.GET("", middleware.RequireAdmin(), v1PeersControllers.GETPeers)
126+
// Paginated
127+
v1Peers.GET("/my", middleware.RequireLogin(), v1PeersControllers.GETMyPeers)
128+
v1Peers.POST("", middleware.RequireAdmin(), v1PeersControllers.POSTPeer)
129+
v1Peers.GET("/:id", middleware.RequirePeerOwnerOrAdmin(), v1PeersControllers.GETPeer)
130+
v1Peers.PATCH("/:id", middleware.RequirePeerOwnerOrAdmin(), v1PeersControllers.PATCHPeer)
131+
v1Peers.DELETE("/:id", middleware.RequirePeerOwnerOrAdmin(), v1PeersControllers.DELETEPeer)
132+
v1Peers.GET("/:id/rules", middleware.RequirePeerOwnerOrAdmin(), v1PeersControllers.GETPeerRules)
133+
v1Peers.POST("/:id/rules", middleware.RequirePeerOwnerOrAdmin(), v1PeersControllers.POSTPeerRule)
134+
v1Peers.DELETE("/:id/rules/:ruleId", middleware.RequirePeerOwnerOrAdmin(), v1PeersControllers.DELETEPeerRule)
135+
}
132136

133137
v1Lastheard := group.Group("/lastheard")
134138
// Returns the lastheard data for the server, adds personal data if logged in

internal/http/frontend/src/components/AppHeader.vue

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,7 @@
134134
</template>
135135

136136
<!-- OpenBridge Peers -->
137-
<template v-if="openBridgeFeature">
137+
<template v-if="settingsStore.openBridgeEnabled">
138138
<Separator class="my-1" />
139139
<router-link
140140
to="/peers"
@@ -178,6 +178,16 @@
178178
<Antenna class="h-4 w-4" />
179179
Repeaters
180180
</router-link>
181+
<router-link
182+
v-if="settingsStore.openBridgeEnabled"
183+
to="/admin/peers"
184+
class="mobile-nav-link"
185+
:class="{ active: route.path === '/admin/peers' }"
186+
@click="mobileOpen = false"
187+
>
188+
<Globe class="h-4 w-4" />
189+
Peers
190+
</router-link>
181191
<router-link
182192
to="/admin/users"
183193
class="mobile-nav-link"
@@ -342,7 +352,7 @@
342352

343353
<!-- OpenBridge Peers -->
344354
<router-link
345-
v-if="openBridgeFeature"
355+
v-if="settingsStore.openBridgeEnabled"
346356
to="/peers"
347357
class="desktop-nav-link"
348358
:class="{ active: route.path === '/peers' }"
@@ -404,6 +414,15 @@
404414
Repeaters
405415
</router-link>
406416
</DropdownMenuItem>
417+
<DropdownMenuItem v-if="settingsStore.openBridgeEnabled" as-child>
418+
<router-link
419+
to="/admin/peers"
420+
class="dropdown-link"
421+
@click="closeMenus"
422+
>
423+
Peers
424+
</router-link>
425+
</DropdownMenuItem>
407426
<DropdownMenuItem as-child>
408427
<router-link
409428
to="/admin/users"
@@ -495,14 +514,14 @@ import {
495514
SheetTrigger,
496515
} from '@/components/ui/sheet';
497516
import API from '@/services/API';
498-
import { useUserStore } from '@/store';
517+
import { useUserStore, useSettingsStore } from '@/store';
499518
500519
const userStore = useUserStore();
520+
const settingsStore = useSettingsStore();
501521
const route = useRoute();
502522
const router = useRouter();
503523
504524
const title = ref(localStorage.getItem('title') || 'DMRHub');
505-
const openBridgeFeature = ref(false);
506525
const openTalkgroupsMenu = ref(false);
507526
const openAdminMenu = ref(false);
508527
const mobileOpen = ref(false);
@@ -584,6 +603,7 @@ const logout = () => {
584603
585604
onMounted(() => {
586605
getTitle();
606+
settingsStore.fetchConfig();
587607
document.addEventListener('click', handleOutsideClick);
588608
});
589609

internal/http/frontend/src/router/routes.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,11 @@ export function routes(): DMRRoute[] {
162162
name: 'NetDetails',
163163
component: () => import('@/views/nets/NetDetailsPage.vue'),
164164
},
165+
{
166+
path: '/peers',
167+
name: 'Peers',
168+
component: () => import('@/views/peers/OpenBridgePeersPage.vue'),
169+
},
165170
{
166171
path: '/admin/nets',
167172
name: 'AdminNets',

internal/http/frontend/src/store/index.ts

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,11 @@ interface UserState {
3030
hasOpenBridgePeers: boolean;
3131
}
3232

33-
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
34-
interface SettingsState {}
33+
interface SettingsState {
34+
ipscEnabled: boolean;
35+
openBridgeEnabled: boolean;
36+
loaded: boolean;
37+
}
3538

3639
export const useUserStore = defineStore('user', {
3740
state: (): UserState => ({
@@ -50,7 +53,23 @@ export const useUserStore = defineStore('user', {
5053

5154
export const useSettingsStore = defineStore('settings', {
5255
state: (): SettingsState => ({
56+
ipscEnabled: false,
57+
openBridgeEnabled: false,
58+
loaded: false,
5359
}),
5460
getters: {},
55-
actions: {},
61+
actions: {
62+
async fetchConfig() {
63+
if (this.loaded) return;
64+
try {
65+
const { default: API } = await import('@/services/API');
66+
const res = await API.get('/config');
67+
this.ipscEnabled = res.data?.dmr?.ipsc?.enabled ?? false;
68+
this.openBridgeEnabled = res.data?.dmr?.openbridge?.enabled ?? false;
69+
this.loaded = true;
70+
} catch {
71+
// Config endpoint unavailable; leave defaults
72+
}
73+
},
74+
},
5675
});

0 commit comments

Comments
 (0)