Skip to content

Commit 79f7bb9

Browse files
sarg3ntclaude
andauthored
feat(handler): unify ?server= / ?box_id= via resolveActiveBox helper (#134)
* 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> * fix(handler): address Copilot review on resolveActiveBox docstring (#112) Copilot flagged that the docstring for resolveActiveBox claimed (nil, false) for the "all-boxes / no-box context", but resolveBoxIDFromRequest falls back to getDefaultServerID() and getServerConfig accepts entries from the static h.servers list regardless of DB-enabled state — so any time at least one server is configured, this helper resolves to one. Update the docstring to match actual behavior: (nil, false) only fires when no servers are configured at all OR when an explicit ?server= / ?box_id= references a missing box. Handlers that need to distinguish "no active box" from "first enabled box" should consult the auth-context active-box set by InjectIntegrationStatus, not call this helper. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 99e73f6 commit 79f7bb9

2 files changed

Lines changed: 123 additions & 2 deletions

File tree

gearbox/internal/framework/handler/handler.go

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -287,19 +287,29 @@ func (h *Handler) getDefaultServerID() string {
287287
}
288288

289289
// resolveBoxIDFromRequest picks the box ID to operate on, in priority:
290-
// 1. `?server=<id>` query param — explicit per-link override.
290+
// 1. `?server=<id>` or `?box_id=<id>` query param — explicit per-link
291+
// override. Both names accepted as synonyms so handlers can use
292+
// whichever convention the surrounding code prefers; the middleware
293+
// in InjectIntegrationStatus uses `?box_id=` for URL-persisted
294+
// pill switches, while older gear-settings links use `?server=`.
291295
// 2. `gearbox_active_box` cookie — the header pill's selection.
292296
// 3. The first enabled server — first-login fallback.
293297
//
294298
// Previously the Gears settings handlers only consulted `?server=` and fell
295299
// straight through to the first server when it was missing, which made the
296300
// page show the first box's gears even while the header pill was on a
297301
// different box (issue #71 item 1). Reading the cookie aligns these
298-
// handlers with the pill that's actually visible to the user.
302+
// handlers with the pill that's actually visible to the user. Recognizing
303+
// `?box_id=` as a synonym keeps the box-resolver consistent across the
304+
// dashboard's handler / middleware / gear-plugin layers (issue #112
305+
// Phase 4).
299306
func (h *Handler) resolveBoxIDFromRequest(r *http.Request) string {
300307
if id := r.URL.Query().Get("server"); id != "" {
301308
return id
302309
}
310+
if id := r.URL.Query().Get("box_id"); id != "" {
311+
return id
312+
}
303313
if c, err := r.Cookie(activeBoxCookieName); err == nil && c.Value != "" {
304314
// Only honor the cookie if it still resolves to an enabled server —
305315
// stale cookies (deleted/disabled boxes) shouldn't dictate behavior.
@@ -312,6 +322,38 @@ func (h *Handler) resolveBoxIDFromRequest(r *http.Request) string {
312322
return h.getDefaultServerID()
313323
}
314324

325+
// resolveActiveBox returns the full BoxConfig for the box the request is
326+
// acting on, using resolveBoxIDFromRequest for resolution. Returns
327+
// (nil, false) when:
328+
//
329+
// - There are no servers configured at all (resolveBoxIDFromRequest's
330+
// getDefaultServerID fallback has nothing to return), OR
331+
// - The resolved ID doesn't match any entry in the static
332+
// h.servers list or the database — e.g. a stale link with
333+
// ?server=<deleted-box-id>.
334+
//
335+
// Note: this does NOT return (nil, false) for an "all-boxes
336+
// dashboard context". When at least one server is configured,
337+
// resolveBoxIDFromRequest falls back to the first enabled box, and
338+
// getServerConfig accepts entries from the static h.servers list
339+
// whether or not they're DB-enabled, so any time at least one
340+
// server exists this helper resolves to one. Handlers that need to
341+
// distinguish "no active box" from "first enabled box" should
342+
// consult the auth-context active-box set by
343+
// InjectIntegrationStatus, not call this helper.
344+
//
345+
// Handlers that need an agent client, an agent URL, or other
346+
// BoxConfig fields should prefer this over resolveBoxIDFromRequest +
347+
// a separate getServerConfig call: it folds the existence check
348+
// into one call site (issue #112 Phase 4).
349+
func (h *Handler) resolveActiveBox(r *http.Request) (*models.BoxConfig, bool) {
350+
boxID := h.resolveBoxIDFromRequest(r)
351+
if boxID == "" {
352+
return nil, false
353+
}
354+
return h.getServerConfig(boxID)
355+
}
356+
315357
// activeBoxCookieName is the cookie key that persists the user's selected
316358
// box across navigations. Lets gear links (e.g. /haproxy) drop the verbose
317359
// `?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)