From 8d80e8710e89cc83560ea26681ed80eb6c4b169d Mon Sep 17 00:00:00 2001 From: Eugene Blikh Date: Sun, 26 Apr 2026 17:05:00 +0300 Subject: [PATCH] savedsearch: reject ?owner= on DELETE; cover all write paths in test (IV2) --- internal/domain/savedsearch/handler.go | 9 +++++ internal/domain/savedsearch/handler_test.go | 39 +++++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/internal/domain/savedsearch/handler.go b/internal/domain/savedsearch/handler.go index 513d181aae0107237a64dc6a3a6f3d2af9c881c8..cd5a3bdc437a4d4bbc25211ff4e9885979222067 100644 --- a/internal/domain/savedsearch/handler.go +++ b/internal/domain/savedsearch/handler.go @@ -225,6 +225,15 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) { // Delete handles DELETE /saved-searches/{name}. Returns 204 No Content on // success, 404 when the row does not exist. func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) { + // Defensive: reject any explicit ?owner= param to prevent confusion (IV2). + if r.URL.Query().Get("owner") != "" { + apierror.Render(w, r, culpa.WithCode( + culpa.WithPublic(culpa.New("?owner= not accepted on write paths"), "?owner= is not accepted on saved-search write paths"), + "INVALID", + )) + return + } + owner := h.ownerOf(r) name := chi.URLParam(r, "name") diff --git a/internal/domain/savedsearch/handler_test.go b/internal/domain/savedsearch/handler_test.go index 93ca78570eb33fc6461f07595e6bb0c7faf3d145..5b3f93aa59ca30e382da3e8c0cd7cc981bd365be 100644 --- a/internal/domain/savedsearch/handler_test.go +++ b/internal/domain/savedsearch/handler_test.go @@ -330,3 +330,42 @@ func TestHandler_Delete_Sequence(t *testing.T) { t.Fatalf("expected NOT_FOUND; got %q", p.Code) } } + +// TestHandler_WritePaths_OwnerParamRejected verifies IV2 — every write path +// (POST/PUT/DELETE) returns 400 INVALID when ?owner= is set, regardless of +// the value. Owner derivation is exclusively from the auth identity. +func TestHandler_WritePaths_OwnerParamRejected(t *testing.T) { + h := newHandler(t) + router := mountWithIdentity(h, auth.Identity{User: "alice"}) + + // Seed a row so the PUT/DELETE paths have a target name. + doPOST(t, router, "/api/v1/saved-searches", map[string]string{"name": "x", "query": "q"}) + + cases := []struct { + name string + send func() *httptest.ResponseRecorder + }{ + {"POST", func() *httptest.ResponseRecorder { + return doPOST(t, router, "/api/v1/saved-searches?owner=alice", map[string]string{"name": "y", "query": "q"}) + }}, + {"PUT", func() *httptest.ResponseRecorder { + return doPUT(t, router, "/api/v1/saved-searches/x?owner=bob", map[string]*string{"query": ptrStr("q2")}) + }}, + {"DELETE", func() *httptest.ResponseRecorder { + return doDELETE(t, router, "/api/v1/saved-searches/x?owner=*") + }}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + rec := tc.send() + if rec.Code != http.StatusBadRequest { + t.Fatalf("status=%d; want 400; body=%s", rec.Code, rec.Body.String()) + } + var p problemBody + _ = json.Unmarshal(rec.Body.Bytes(), &p) + if p.Code != "INVALID" { + t.Fatalf("expected INVALID; got %q (body=%s)", p.Code, rec.Body.String()) + } + }) + } +}