Skip to content

Commit c712360

Browse files
sarg3ntclaude
andcommitted
feat(handler): unify ?server= / ?box_id= via resolveActiveBox helper (#112)
Three resolution conventions co-existed pre-#112 Phase 4: 1. `?server=<id>` — gear-settings deep links and the OS-Updates page. 2. `?box_id=<id>` — InjectIntegrationStatus middleware (the header-pill URL persistence). 3. `gearbox_active_box` cookie — the sticky pill selection. Handlers that wanted to honor the pill had to write the same "check ?server=, then cookie, then default" cascade. Handlers that followed the middleware convention got `?box_id=` for free but lost gear-settings links. The resolveBoxIDFromRequest helper only knew `?server=`. Changes: - resolveBoxIDFromRequest now accepts BOTH `?server=` and `?box_id=` as synonyms (precedence: server > box_id > cookie > default). Handlers can use whichever name fits the surrounding code without losing pill-aware routing. - New resolveActiveBox(r) returns the full *models.BoxConfig along with a presence boolean, so handlers that need an agent URL or APIKey don't have to chase a separate getServerConfig call. Folds the "single helper for active box" invariant from the issue proposal into one call site. Four tests cover the new contract: ?server= path, ?box_id= path, precedence when both supplied, and the full-BoxConfig return. Phase 4 of #112. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 0799f82 commit c712360

2 files changed

Lines changed: 109 additions & 2 deletions

File tree

gearbox/internal/framework/handler/handler.go

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -277,19 +277,29 @@ func (h *Handler) getDefaultServerID() string {
277277
}
278278

279279
// resolveBoxIDFromRequest picks the box ID to operate on, in priority:
280-
// 1. `?server=<id>` query param — explicit per-link override.
280+
// 1. `?server=<id>` or `?box_id=<id>` query param — explicit per-link
281+
// override. Both names accepted as synonyms so handlers can use
282+
// whichever convention the surrounding code prefers; the middleware
283+
// in InjectIntegrationStatus uses `?box_id=` for URL-persisted
284+
// pill switches, while older gear-settings links use `?server=`.
281285
// 2. `gearbox_active_box` cookie — the header pill's selection.
282286
// 3. The first enabled server — first-login fallback.
283287
//
284288
// Previously the Gears settings handlers only consulted `?server=` and fell
285289
// straight through to the first server when it was missing, which made the
286290
// page show the first box's gears even while the header pill was on a
287291
// different box (issue #71 item 1). Reading the cookie aligns these
288-
// handlers with the pill that's actually visible to the user.
292+
// handlers with the pill that's actually visible to the user. Recognizing
293+
// `?box_id=` as a synonym keeps the box-resolver consistent across the
294+
// dashboard's handler / middleware / gear-plugin layers (issue #112
295+
// Phase 4).
289296
func (h *Handler) resolveBoxIDFromRequest(r *http.Request) string {
290297
if id := r.URL.Query().Get("server"); id != "" {
291298
return id
292299
}
300+
if id := r.URL.Query().Get("box_id"); id != "" {
301+
return id
302+
}
293303
if c, err := r.Cookie(activeBoxCookieName); err == nil && c.Value != "" {
294304
// Only honor the cookie if it still resolves to an enabled server —
295305
// stale cookies (deleted/disabled boxes) shouldn't dictate behavior.
@@ -302,6 +312,24 @@ func (h *Handler) resolveBoxIDFromRequest(r *http.Request) string {
302312
return h.getDefaultServerID()
303313
}
304314

315+
// resolveActiveBox returns the full BoxConfig for the box the request is
316+
// acting on, using resolveBoxIDFromRequest for resolution. Returns
317+
// (nil, false) when no enabled box matches the resolved ID — this is
318+
// the all-boxes / no-box context (e.g. /bx, /settings).
319+
//
320+
// Handlers that need an agent client, an agent URL, or other BoxConfig
321+
// fields should prefer this over resolveBoxIDFromRequest + a separate
322+
// getServerConfig call: it makes the "single helper for active box"
323+
// invariant from issue #112 Phase 4 visible in the code, and it folds
324+
// the existence check into one call site.
325+
func (h *Handler) resolveActiveBox(r *http.Request) (*models.BoxConfig, bool) {
326+
boxID := h.resolveBoxIDFromRequest(r)
327+
if boxID == "" {
328+
return nil, false
329+
}
330+
return h.getServerConfig(boxID)
331+
}
332+
305333
// activeBoxCookieName is the cookie key that persists the user's selected
306334
// box across navigations. Lets gear links (e.g. /haproxy) drop the verbose
307335
// `?box_id=` query string and still resolve the active context.
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
package handler
2+
3+
import (
4+
"net/http/httptest"
5+
"testing"
6+
7+
"github.com/sarg3nt/gearbox/internal/framework/models"
8+
)
9+
10+
// newResolveTestHandler builds the minimum Handler state the resolver
11+
// needs for the URL-query-param paths: a static `servers` slice (so the
12+
// cookie path can validate against enabled servers if exercised). Tests
13+
// that exercise the cookie or first-server fallback would also need a
14+
// DB; those branches aren't covered here.
15+
func newResolveTestHandler(servers ...models.BoxConfig) *Handler {
16+
return &Handler{servers: servers}
17+
}
18+
19+
// TestResolveBoxIDFromRequest_ServerParam covers the historical
20+
// `?server=<id>` path — gear-settings links and the OS-Updates page
21+
// still emit this form.
22+
func TestResolveBoxIDFromRequest_ServerParam(t *testing.T) {
23+
h := newResolveTestHandler()
24+
r := httptest.NewRequest("GET", "/anything?server=mjolnir", nil)
25+
if got := h.resolveBoxIDFromRequest(r); got != "mjolnir" {
26+
t.Errorf("resolveBoxIDFromRequest with ?server=mjolnir = %q, want %q", got, "mjolnir")
27+
}
28+
}
29+
30+
// TestResolveBoxIDFromRequest_BoxIDParam covers the synonym alias added
31+
// in issue #112 Phase 4. `?box_id=` is the form the header-pill
32+
// middleware writes; pre-Phase-4 the handler-level resolver ignored it
33+
// because it only knew `?server=`. Recognizing both keeps the resolver
34+
// consistent across the handler / middleware / gear-plugin layers.
35+
func TestResolveBoxIDFromRequest_BoxIDParam(t *testing.T) {
36+
h := newResolveTestHandler()
37+
r := httptest.NewRequest("GET", "/anything?box_id=mjolnir", nil)
38+
if got := h.resolveBoxIDFromRequest(r); got != "mjolnir" {
39+
t.Errorf("resolveBoxIDFromRequest with ?box_id=mjolnir = %q, want %q", got, "mjolnir")
40+
}
41+
}
42+
43+
// TestResolveBoxIDFromRequest_ServerWinsOverBoxID is the precedence
44+
// guard: when a request happens to carry both (rare — typically only
45+
// when an operator hand-edits a URL), `?server=` wins because it's the
46+
// older, more-explicit convention used by gear-settings deep links.
47+
func TestResolveBoxIDFromRequest_ServerWinsOverBoxID(t *testing.T) {
48+
h := newResolveTestHandler()
49+
r := httptest.NewRequest("GET", "/anything?server=alpha&box_id=beta", nil)
50+
if got := h.resolveBoxIDFromRequest(r); got != "alpha" {
51+
t.Errorf("resolveBoxIDFromRequest with both params = %q, want %q", got, "alpha")
52+
}
53+
}
54+
55+
// TestResolveActiveBox_ReturnsServerConfig verifies that the new
56+
// resolveActiveBox returns the full BoxConfig (not just an ID) by
57+
// matching against the handler's static `servers` slice.
58+
func TestResolveActiveBox_ReturnsServerConfig(t *testing.T) {
59+
h := newResolveTestHandler(
60+
models.BoxConfig{ID: "mjolnir", Name: "Mjolnir", AgentURL: "https://10.0.0.1:8405"},
61+
)
62+
r := httptest.NewRequest("GET", "/anything?box_id=mjolnir", nil)
63+
box, ok := h.resolveActiveBox(r)
64+
if !ok {
65+
t.Fatalf("resolveActiveBox = (_, false), want a BoxConfig")
66+
}
67+
if box.ID != "mjolnir" || box.AgentURL != "https://10.0.0.1:8405" {
68+
t.Errorf("resolveActiveBox returned %+v, want {ID:mjolnir, AgentURL:https://10.0.0.1:8405}", box)
69+
}
70+
}
71+
72+
// The "resolved ID doesn't match any configured server" branch isn't
73+
// covered here because exercising it would require a DB stub —
74+
// getServerConfig falls back to getEnabledServers (a DB read) when the
75+
// static list misses, and the DB layer panics on nil. The static-list
76+
// happy path above guards the helper's primary contract (an ID that
77+
// matches a configured server returns the right BoxConfig); the
78+
// dynamic-server path is exercised by existing handler integration
79+
// tests that run against the full Handler fixture.

0 commit comments

Comments
 (0)