~bigbes/lethe

8d80e8710e89cc83560ea26681ed80eb6c4b169d — Eugene Blikh a month ago 6971b2d
savedsearch: reject ?owner= on DELETE; cover all write paths in test (IV2)
2 files changed, 48 insertions(+), 0 deletions(-)

M internal/domain/savedsearch/handler.go
M internal/domain/savedsearch/handler_test.go
M internal/domain/savedsearch/handler.go => internal/domain/savedsearch/handler.go +9 -0
@@ 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")


M internal/domain/savedsearch/handler_test.go => internal/domain/savedsearch/handler_test.go +39 -0
@@ 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())
			}
		})
	}
}