Skip to content

Commit fc023d7

Browse files
sarg3ntclaude
andcommitted
fix(#103): address Copilot review findings on PR #104
JS: - metrics-layout.js: getServerID was missing the single-server fallback (#default-server-id) that metrics.templ's inline helper consults. Single-box installs silently no-op'd every layout GET/PATCH/DELETE because serverID came back empty. Now matches the inline helper byte-for-byte: select → default-server-id → URL. - metrics-layout.js: saveLayout now takes a {flush: true} option used by the edit-mode exit handler. Without flush, a tab-close right after "Done editing" could drop the layout because the debounce was still pending. Drag/resize calls still use the debounced path so a multi-tile drag flushes once, not N times. - metrics-layout.js: resetLayout now cancels any pending debounced PATCH AND suppresses further saves before issuing the DELETE. Without this a fast drag-then-reset race could land a PATCH after the DELETE and resurrect the row the user just threw away. - metrics-layout.js: GridStack margin 8 → 16. Comment had claimed 16 (matches Tailwind gap-4 and Home's gridstack init); value was the typo. Matched to Home now so the visual idiom is consistent across pages. Handler: - api_metrics_layout.go: PATCH now runs validateLayoutTiles before persisting. Previously a malformed client could land tiles with empty IDs, negative coords, zero/negative dimensions, or duplicate IDs that would silently corrupt the saved blob. New validator enforces: non-empty id, x/y in [0, maxCoord], w/h in [1, maxDim], unique ids across the array, tile count in [1, maxTilesPerLayout=64]. First failure returned with an actionable message (one good error beats a list when the source is a JS caller, not a hand-edited file). - api_metrics_layout_test.go (new): table-driven coverage of every validateLayoutTiles rejection path plus the happy path. - metrics_layouts_test.go (new): DB round-trip coverage for GetMetricsLayout (empty → ErrNoMetricsLayout sentinel), Save upsert behaviour, isolation across (user, box) pairs, Delete resets-to-default flow, Delete no-op on absent row. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent ed77ce3 commit fc023d7

4 files changed

Lines changed: 403 additions & 38 deletions

File tree

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
package database
2+
3+
import (
4+
"bytes"
5+
"errors"
6+
"testing"
7+
)
8+
9+
func TestGetMetricsLayoutMissingReturnsSentinel(t *testing.T) {
10+
// First read on an empty table must return ErrNoMetricsLayout so
11+
// the handler can translate to 204 No Content — anything else
12+
// would mask the "no saved layout, use template defaults" path.
13+
db := setupTestDB(t)
14+
15+
_, err := db.GetMetricsLayout("user-1", "box-1")
16+
if !errors.Is(err, ErrNoMetricsLayout) {
17+
t.Errorf("expected ErrNoMetricsLayout, got %v", err)
18+
}
19+
}
20+
21+
func TestSaveAndGetMetricsLayout(t *testing.T) {
22+
// Round-trip the JSON blob byte-for-byte — the storage layer is
23+
// agnostic to GridStack's payload shape, so the bytes coming back
24+
// out must match the bytes going in.
25+
db := setupTestDB(t)
26+
27+
payload := []byte(`[{"id":"card-cpu","x":0,"y":0,"w":6,"h":4},{"id":"card-memory","x":6,"y":0,"w":6,"h":4}]`)
28+
if err := db.SaveMetricsLayout("user-1", "box-1", payload); err != nil {
29+
t.Fatalf("save: %v", err)
30+
}
31+
32+
got, err := db.GetMetricsLayout("user-1", "box-1")
33+
if err != nil {
34+
t.Fatalf("get: %v", err)
35+
}
36+
if !bytes.Equal(got.Layout, payload) {
37+
t.Errorf("layout round-trip = %s, want %s", got.Layout, payload)
38+
}
39+
if got.UserID != "user-1" || got.ServerID != "box-1" {
40+
t.Errorf("metadata wrong: %+v", got)
41+
}
42+
if got.UpdatedAt.IsZero() {
43+
t.Error("UpdatedAt should be set by SaveMetricsLayout")
44+
}
45+
}
46+
47+
func TestSaveMetricsLayoutUpserts(t *testing.T) {
48+
// PK is (user_id, server_id); a second save for the same pair
49+
// must replace the layout, not error or create a second row.
50+
db := setupTestDB(t)
51+
52+
first := []byte(`[{"id":"card-cpu","x":0,"y":0,"w":6,"h":4}]`)
53+
second := []byte(`[{"id":"card-cpu","x":6,"y":0,"w":6,"h":4}]`)
54+
55+
if err := db.SaveMetricsLayout("u", "b", first); err != nil {
56+
t.Fatalf("first save: %v", err)
57+
}
58+
if err := db.SaveMetricsLayout("u", "b", second); err != nil {
59+
t.Fatalf("second save: %v", err)
60+
}
61+
got, err := db.GetMetricsLayout("u", "b")
62+
if err != nil {
63+
t.Fatalf("get: %v", err)
64+
}
65+
if !bytes.Equal(got.Layout, second) {
66+
t.Errorf("expected second save to win, got %s", got.Layout)
67+
}
68+
}
69+
70+
func TestMetricsLayoutIsolatedPerUserAndBox(t *testing.T) {
71+
// (user_id, server_id) is the PK — different users on the same
72+
// box, or the same user on different boxes, must keep separate
73+
// layouts.
74+
db := setupTestDB(t)
75+
76+
aliceBox1 := []byte(`[{"id":"card-cpu","x":1,"y":0,"w":6,"h":4}]`)
77+
bobBox1 := []byte(`[{"id":"card-cpu","x":2,"y":0,"w":6,"h":4}]`)
78+
aliceBox2 := []byte(`[{"id":"card-cpu","x":3,"y":0,"w":6,"h":4}]`)
79+
80+
if err := db.SaveMetricsLayout("alice", "box-1", aliceBox1); err != nil {
81+
t.Fatal(err)
82+
}
83+
if err := db.SaveMetricsLayout("bob", "box-1", bobBox1); err != nil {
84+
t.Fatal(err)
85+
}
86+
if err := db.SaveMetricsLayout("alice", "box-2", aliceBox2); err != nil {
87+
t.Fatal(err)
88+
}
89+
90+
cases := []struct {
91+
user, box string
92+
want []byte
93+
}{
94+
{"alice", "box-1", aliceBox1},
95+
{"bob", "box-1", bobBox1},
96+
{"alice", "box-2", aliceBox2},
97+
}
98+
for _, tc := range cases {
99+
got, err := db.GetMetricsLayout(tc.user, tc.box)
100+
if err != nil {
101+
t.Errorf("(%s,%s) get: %v", tc.user, tc.box, err)
102+
continue
103+
}
104+
if !bytes.Equal(got.Layout, tc.want) {
105+
t.Errorf("(%s,%s) layout = %s, want %s", tc.user, tc.box, got.Layout, tc.want)
106+
}
107+
}
108+
}
109+
110+
func TestDeleteMetricsLayoutResetsToDefault(t *testing.T) {
111+
// Delete must drop the row so the subsequent Get returns
112+
// ErrNoMetricsLayout — that's what drives the "fall back to
113+
// template defaults" path after the operator hits Reset.
114+
db := setupTestDB(t)
115+
116+
payload := []byte(`[{"id":"card-cpu","x":0,"y":0,"w":6,"h":4}]`)
117+
if err := db.SaveMetricsLayout("u", "b", payload); err != nil {
118+
t.Fatal(err)
119+
}
120+
if _, err := db.GetMetricsLayout("u", "b"); err != nil {
121+
t.Fatalf("pre-delete get: %v", err)
122+
}
123+
if err := db.DeleteMetricsLayout("u", "b"); err != nil {
124+
t.Fatalf("delete: %v", err)
125+
}
126+
_, err := db.GetMetricsLayout("u", "b")
127+
if !errors.Is(err, ErrNoMetricsLayout) {
128+
t.Errorf("expected ErrNoMetricsLayout after delete, got %v", err)
129+
}
130+
}
131+
132+
func TestDeleteMetricsLayoutIsNoOpWhenAbsent(t *testing.T) {
133+
// Reset on a box with no saved layout shouldn't surface as an
134+
// error — the user clicked the button; the desired state is
135+
// "no row", which is already true.
136+
db := setupTestDB(t)
137+
138+
if err := db.DeleteMetricsLayout("u", "never-saved"); err != nil {
139+
t.Errorf("delete of absent row should be no-op, got %v", err)
140+
}
141+
}

gearbox/internal/framework/handler/api_metrics_layout.go

Lines changed: 68 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ package handler
2525
import (
2626
"encoding/json"
2727
"errors"
28+
"fmt"
2829
"io"
2930
"net/http"
3031

@@ -40,14 +41,30 @@ import (
4041
// dashboard PATCH endpoints accept.
4142
const maxLayoutBytes = 16 * 1024
4243

43-
// layoutTile is the minimum shape we require each entry in the
44-
// posted layout array to carry. GridStack's `save()` includes
45-
// these four fields for every node; the `id` is the stable DOM id
46-
// of the tile (e.g. "card-cpu"). We don't enforce the id's value
47-
// against the known set of cards — the dashboard renders cards by
48-
// id and ignores anything it doesn't recognise, so an unknown id
49-
// in the saved layout is a no-op at render time rather than a
50-
// failure mode worth rejecting here.
44+
// maxTilesPerLayout caps the per-PATCH tile count. The page has 11
45+
// known cards today (7 baseline + 4 per-source); 64 is a comfortable
46+
// ceiling that survives future growth without letting a misbehaving
47+
// client commit a thousand-tile blob.
48+
const maxTilesPerLayout = 64
49+
50+
// maxCoord / maxDim bound the GridStack coordinate space. The grid
51+
// renders at 12 columns wide; the metrics page typically reaches
52+
// y ~ 24 on the default layout. Caps a couple of orders of magnitude
53+
// higher so a wide future layout still fits while a "garbage value"
54+
// like `x: 9_999_999` gets rejected.
55+
const (
56+
maxCoord = 1000
57+
maxDim = 100
58+
)
59+
60+
// layoutTile is the shape we require each entry in the posted
61+
// layout array to carry. GridStack's `save()` includes these four
62+
// fields for every node; the `id` is the stable DOM id of the tile
63+
// (e.g. "card-cpu"). We don't enforce the id's value against the
64+
// known set of cards — the dashboard renders cards by id and
65+
// ignores anything it doesn't recognise, so an unknown id in the
66+
// saved layout is a no-op at render time rather than a failure
67+
// mode worth rejecting here.
5168
type layoutTile struct {
5269
ID string `json:"id"`
5370
X int `json:"x"`
@@ -56,6 +73,47 @@ type layoutTile struct {
5673
H int `json:"h"`
5774
}
5875

76+
// validateLayoutTiles enforces the per-tile invariants we need to
77+
// trust the stored blob on read-back: non-empty IDs, non-negative
78+
// coordinates, positive dimensions, bounded values, unique IDs.
79+
// Without this a misbehaving client could persist tiles with
80+
// negative coords / zero dimensions / duplicate IDs that would
81+
// surface as confusing render bugs later. Returns the first
82+
// problem found rather than aggregating — one good error is more
83+
// actionable than a list when the source is a misbehaving JS
84+
// caller, not a hand-edited file.
85+
func validateLayoutTiles(tiles []layoutTile) error {
86+
if len(tiles) == 0 {
87+
return errors.New("layout must contain at least one tile")
88+
}
89+
if len(tiles) > maxTilesPerLayout {
90+
return fmt.Errorf("layout has %d tiles; maximum %d", len(tiles), maxTilesPerLayout)
91+
}
92+
seen := make(map[string]struct{}, len(tiles))
93+
for i, t := range tiles {
94+
if t.ID == "" {
95+
return fmt.Errorf("tile %d: id is empty", i)
96+
}
97+
if _, dup := seen[t.ID]; dup {
98+
return fmt.Errorf("tile %d: duplicate id %q", i, t.ID)
99+
}
100+
seen[t.ID] = struct{}{}
101+
if t.X < 0 || t.Y < 0 {
102+
return fmt.Errorf("tile %q: x/y must be non-negative (got x=%d, y=%d)", t.ID, t.X, t.Y)
103+
}
104+
if t.X > maxCoord || t.Y > maxCoord {
105+
return fmt.Errorf("tile %q: x/y exceed %d (got x=%d, y=%d)", t.ID, maxCoord, t.X, t.Y)
106+
}
107+
if t.W <= 0 || t.H <= 0 {
108+
return fmt.Errorf("tile %q: w/h must be positive (got w=%d, h=%d)", t.ID, t.W, t.H)
109+
}
110+
if t.W > maxDim || t.H > maxDim {
111+
return fmt.Errorf("tile %q: w/h exceed %d (got w=%d, h=%d)", t.ID, maxDim, t.W, t.H)
112+
}
113+
}
114+
return nil
115+
}
116+
59117
// APIMetricsLayoutGetHandler returns the user's saved metrics
60118
// layout for one box. Returns 204 No Content when nothing's saved
61119
// — that's the signal for the front-end to use the template's
@@ -136,8 +194,8 @@ func (h *Handler) APIMetricsLayoutPatchHandler(w http.ResponseWriter, r *http.Re
136194
http.Error(w, "Invalid layout JSON: "+err.Error(), http.StatusBadRequest)
137195
return
138196
}
139-
if len(tiles) == 0 {
140-
http.Error(w, "Layout must contain at least one tile", http.StatusBadRequest)
197+
if err := validateLayoutTiles(tiles); err != nil {
198+
http.Error(w, "Invalid layout: "+err.Error(), http.StatusBadRequest)
141199
return
142200
}
143201

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
package handler
2+
3+
import (
4+
"strings"
5+
"testing"
6+
)
7+
8+
// validateLayoutTiles is a pure function — these tests cover every
9+
// rejection path the PATCH handler relies on (issue #103 review on
10+
// PR #104). Full HTTP-level handler tests would need a wired Handler
11+
// with db + authManager; for the validation surface specifically the
12+
// table tests below match the handler's behaviour 1:1 because the
13+
// handler calls validateLayoutTiles directly and forwards its error
14+
// message verbatim.
15+
func TestValidateLayoutTiles(t *testing.T) {
16+
good := []layoutTile{
17+
{ID: "card-cpu", X: 0, Y: 0, W: 6, H: 4},
18+
{ID: "card-memory", X: 6, Y: 0, W: 6, H: 4},
19+
}
20+
if err := validateLayoutTiles(good); err != nil {
21+
t.Errorf("good tiles rejected: %v", err)
22+
}
23+
24+
cases := []struct {
25+
name string
26+
tiles []layoutTile
27+
wantMatch string
28+
}{
29+
{
30+
name: "empty array",
31+
tiles: nil,
32+
wantMatch: "at least one tile",
33+
},
34+
{
35+
name: "empty id",
36+
tiles: []layoutTile{{ID: "", X: 0, Y: 0, W: 1, H: 1}},
37+
wantMatch: "id is empty",
38+
},
39+
{
40+
name: "duplicate id",
41+
tiles: []layoutTile{
42+
{ID: "card-cpu", X: 0, Y: 0, W: 6, H: 4},
43+
{ID: "card-cpu", X: 6, Y: 0, W: 6, H: 4},
44+
},
45+
wantMatch: "duplicate id",
46+
},
47+
{
48+
name: "negative x",
49+
tiles: []layoutTile{{ID: "card-cpu", X: -1, Y: 0, W: 1, H: 1}},
50+
wantMatch: "must be non-negative",
51+
},
52+
{
53+
name: "negative y",
54+
tiles: []layoutTile{{ID: "card-cpu", X: 0, Y: -5, W: 1, H: 1}},
55+
wantMatch: "must be non-negative",
56+
},
57+
{
58+
name: "x exceeds bound",
59+
tiles: []layoutTile{{ID: "card-cpu", X: maxCoord + 1, Y: 0, W: 1, H: 1}},
60+
wantMatch: "exceed",
61+
},
62+
{
63+
name: "zero width",
64+
tiles: []layoutTile{{ID: "card-cpu", X: 0, Y: 0, W: 0, H: 1}},
65+
wantMatch: "w/h must be positive",
66+
},
67+
{
68+
name: "negative height",
69+
tiles: []layoutTile{{ID: "card-cpu", X: 0, Y: 0, W: 1, H: -1}},
70+
wantMatch: "w/h must be positive",
71+
},
72+
{
73+
name: "w exceeds bound",
74+
tiles: []layoutTile{{ID: "card-cpu", X: 0, Y: 0, W: maxDim + 1, H: 1}},
75+
wantMatch: "w/h exceed",
76+
},
77+
{
78+
name: "too many tiles",
79+
tiles: buildOversizedTiles(),
80+
wantMatch: "maximum",
81+
},
82+
}
83+
for _, tc := range cases {
84+
t.Run(tc.name, func(t *testing.T) {
85+
err := validateLayoutTiles(tc.tiles)
86+
if err == nil {
87+
t.Fatalf("expected validation error containing %q, got nil", tc.wantMatch)
88+
}
89+
if !strings.Contains(err.Error(), tc.wantMatch) {
90+
t.Errorf("error %q does not contain %q", err.Error(), tc.wantMatch)
91+
}
92+
})
93+
}
94+
}
95+
96+
// buildOversizedTiles returns one more tile than the cap so the
97+
// "maximum" rejection branch fires.
98+
func buildOversizedTiles() []layoutTile {
99+
tiles := make([]layoutTile, maxTilesPerLayout+1)
100+
for i := range tiles {
101+
tiles[i] = layoutTile{
102+
ID: pseudoTileID(i),
103+
X: 0,
104+
Y: i * 4,
105+
W: 1,
106+
H: 1,
107+
}
108+
}
109+
return tiles
110+
}
111+
112+
// pseudoTileID returns a deterministic unique-ish id for the
113+
// over-cap test. Using a simple letter cycle avoids the duplicate-
114+
// id rejection firing first (which would shadow the cap check).
115+
func pseudoTileID(i int) string {
116+
const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789"
117+
if i < len(alphabet) {
118+
return "card-" + string(alphabet[i])
119+
}
120+
// Two-char IDs for indices past 36; up to 36*36 = 1296 unique
121+
// values which more than covers maxTilesPerLayout+1.
122+
a := alphabet[i/len(alphabet)]
123+
b := alphabet[i%len(alphabet)]
124+
return "card-" + string([]byte{a, b})
125+
}

0 commit comments

Comments
 (0)