Skip to content

Commit 4f5ac60

Browse files
vishrclaude
andauthored
test: lock in v5 group route method-handling (405 + OPTIONS) (#3003)
* test: guard group catch-all against 405-masking and route shadowing Adds regression tests around automatic group catch-all (404) route registration. v5 removed that auto-registration; if it is restored (e.g. PR #2996) these tests ensure it does not bring back the issues that motivated the removal: - wrong HTTP method on an existing group route must still return 405 (with Allow header), not be masked to 404 by the catch-all; - the group catch-all must not shadow concrete sibling routes, root routes, or other sibling groups' routes. All four pass on current master (v5). The 405 test fails against the restore-v4-behavior approach in #2996, pinning down that tradeoff. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test: rewrite group method-handling tests after review Addresses review of the original regression file (PR #3003): its comments described an automatic group catch-all "triggered by middleware" that does not exist on master (v5), so the tests passed for the wrong reason and the no-op middleware was inert. Rewrite to assert v5's actual, verified behavior: - method mismatch on a group route -> 405 with full Allow header - OPTIONS on a registered group route -> 204 with Allow (preflight-relevant) - concrete routes resolve; group prefix does not affect root routes The 405 and OPTIONS tests are real gates: a group-level catch-all (manual or the auto-registration proposed in #2996) masks both as 404, which these tests would then catch. Drops the false premise, the inert middleware, and the in-comment PR reference. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test: add rewritten group method-handling tests The previous commit recorded only the deletion of the old file; this adds the rewritten suite (group_method_handling_test.go). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test: add companion test demonstrating group catch-all masking Addresses final review: converts the "verified empirically" comment into an actual test. TestGroupRoute_catchAllMasksMethodHandling registers a group-wide catch-all and asserts it masks both the 405 method-mismatch and the automatic OPTIONS (204) response as 404 — the regression the 405/OPTIONS gate tests guard against. Makes the rationale self-proving in-repo. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent b0a3916 commit 4f5ac60

1 file changed

Lines changed: 115 additions & 0 deletions

File tree

group_method_handling_test.go

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
// SPDX-License-Identifier: MIT
2+
// SPDX-FileCopyrightText: © 2015 LabStack LLC and Echo contributors
3+
4+
package echo
5+
6+
import (
7+
"net/http"
8+
"net/http/httptest"
9+
"testing"
10+
11+
"github.com/stretchr/testify/assert"
12+
)
13+
14+
// These tests lock in v5's method-handling semantics for routes registered through
15+
// a Group. v5 resolves method mismatches (405) and OPTIONS at the router level and
16+
// does NOT register any implicit per-group catch-all route.
17+
//
18+
// They double as a regression gate. Registering a group-level catch-all — whether
19+
// manually via g.RouteNotFound("/*", ...) or automatically (as proposed in #2996 to
20+
// fix CORS-on-group preflight) — makes that catch-all match every method, which masks
21+
// both 405 and v5's automatic OPTIONS response as 404 — demonstrated directly by
22+
// TestGroupRoute_catchAllMasksMethodHandling below. If that masking becomes the
23+
// default (e.g. #2996 lands), the first two tests below fail.
24+
25+
// A method mismatch on an existing group route must return 405 with the allowed
26+
// methods, not be masked to 404.
27+
func TestGroupRoute_methodMismatchReturns405(t *testing.T) {
28+
e := New()
29+
g := e.Group("/api")
30+
g.GET("/users", func(c *Context) error { return c.String(http.StatusOK, "users") })
31+
32+
req := httptest.NewRequest(http.MethodPost, "/api/users", nil)
33+
rec := httptest.NewRecorder()
34+
e.ServeHTTP(rec, req)
35+
36+
assert.Equal(t, http.StatusMethodNotAllowed, rec.Code,
37+
"POST to a GET-only group route must be 405, not masked to 404")
38+
assert.Equal(t, "OPTIONS, GET", rec.Header().Get(HeaderAllow),
39+
"405 response must advertise the allowed methods")
40+
}
41+
42+
// OPTIONS on an existing group route is answered automatically by Echo (204 +
43+
// Allow). This is the behavior CORS preflight relies on, so it must not be masked.
44+
func TestGroupRoute_automaticOPTIONS(t *testing.T) {
45+
e := New()
46+
g := e.Group("/api")
47+
g.GET("/users", func(c *Context) error { return c.String(http.StatusOK, "users") })
48+
49+
req := httptest.NewRequest(http.MethodOptions, "/api/users", nil)
50+
rec := httptest.NewRecorder()
51+
e.ServeHTTP(rec, req)
52+
53+
assert.Equal(t, http.StatusNoContent, rec.Code,
54+
"OPTIONS on a registered group route must be auto-answered (204), not masked to 404")
55+
assert.Equal(t, "OPTIONS, GET", rec.Header().Get(HeaderAllow),
56+
"automatic OPTIONS response must advertise the allowed methods")
57+
}
58+
59+
// A matched concrete route resolves to its own handler; only a genuinely unmatched
60+
// path under the prefix is a 404.
61+
func TestGroupRoute_concreteRoutesResolve(t *testing.T) {
62+
e := New()
63+
g := e.Group("/api")
64+
g.GET("/users", func(c *Context) error { return c.String(http.StatusOK, "users") })
65+
66+
status, body := request(http.MethodGet, "/api/users", e)
67+
assert.Equal(t, http.StatusOK, status)
68+
assert.Equal(t, "users", body)
69+
70+
status, _ = request(http.MethodGet, "/api/nope", e)
71+
assert.Equal(t, http.StatusNotFound, status)
72+
}
73+
74+
// A group prefix must not affect routing of routes registered outside the group.
75+
func TestGroup_doesNotAffectRootRoutes(t *testing.T) {
76+
e := New()
77+
e.GET("/health", func(c *Context) error { return c.String(http.StatusOK, "root") })
78+
g := e.Group("/api")
79+
g.GET("/users", func(c *Context) error { return c.String(http.StatusOK, "users") })
80+
81+
status, body := request(http.MethodGet, "/health", e)
82+
assert.Equal(t, http.StatusOK, status)
83+
assert.Equal(t, "root", body)
84+
}
85+
86+
// Characterization of the regression the 405/OPTIONS tests above guard against:
87+
// registering a group-wide catch-all (the manual equivalent of #2996's auto-
88+
// registration) makes it match every method, so method mismatches and the automatic
89+
// OPTIONS response are masked as 404 even though the concrete route still resolves.
90+
// If a future change teaches the catch-all to preserve method semantics, update this.
91+
func TestGroupRoute_catchAllMasksMethodHandling(t *testing.T) {
92+
e := New()
93+
g := e.Group("/api")
94+
g.GET("/users", func(c *Context) error { return c.String(http.StatusOK, "users") })
95+
g.RouteNotFound("/*", func(c *Context) error { return c.NoContent(http.StatusNotFound) })
96+
97+
// The concrete route still resolves.
98+
status, body := request(http.MethodGet, "/api/users", e)
99+
assert.Equal(t, http.StatusOK, status)
100+
assert.Equal(t, "users", body)
101+
102+
// But the catch-all masks the method mismatch (would be 405) ...
103+
post := httptest.NewRequest(http.MethodPost, "/api/users", nil)
104+
postRec := httptest.NewRecorder()
105+
e.ServeHTTP(postRec, post)
106+
assert.Equal(t, http.StatusNotFound, postRec.Code,
107+
"a group-wide catch-all masks the 405 method-mismatch as 404")
108+
109+
// ... and the automatic OPTIONS response (would be 204).
110+
opts := httptest.NewRequest(http.MethodOptions, "/api/users", nil)
111+
optsRec := httptest.NewRecorder()
112+
e.ServeHTTP(optsRec, opts)
113+
assert.Equal(t, http.StatusNotFound, optsRec.Code,
114+
"a group-wide catch-all masks the automatic OPTIONS (204) response as 404")
115+
}

0 commit comments

Comments
 (0)