carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package edge
2
3import (
4 "io"
5 "net/http"
6 "net/http/httptest"
7 "net/url"
8 "strings"
9 "testing"
10 "time"
11
12 "github.com/carlosframework/platform/internal/gate"
13 "github.com/carlosframework/platform/internal/registry"
14)
15
16func gatedEdge(t *testing.T, act Activator, hibernate bool) (*Edge, *httptest.Server, string, *string) {
17 t.Helper()
18 backend, seenCookie := cookieEchoBackend(t, "secret page")
19 h, err := gate.Hash("correct horse")
20 if err != nil {
21 t.Fatal(err)
22 }
23 reg := openReg(t)
24 if err := reg.Upsert(registry.Route{Host: "gated.example.com", Kind: "instance", Addr: backend.Listener.Addr().String(),
25 Status: "ready", Hibernate: hibernate, Gate: h}); err != nil {
26 t.Fatal(err)
27 }
28 if err := reg.UpsertAlias(registry.RouteAlias{Alias: "www.custom.example", Host: "gated.example.com",
29 Account: "acct", App: "app", CreatedAt: 1}); err != nil {
30 t.Fatal(err)
31 }
32 e, err := New(reg, act)
33 if err != nil {
34 t.Fatal(err)
35 }
36 srv := httptest.NewServer(e)
37 t.Cleanup(srv.Close)
38 return e, srv, h, seenCookie
39}
40
41// cookieEchoBackend records the Cookie header it saw.
42func cookieEchoBackend(t *testing.T, body string) (*httptest.Server, *string) {
43 t.Helper()
44 seen := new(string)
45 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
46 *seen = r.Header.Get("Cookie")
47 io.WriteString(w, body)
48 }))
49 t.Cleanup(ts.Close)
50 return ts, seen
51}
52
53func doHost(t *testing.T, srv *httptest.Server, method, host, path, cookie string) *http.Response {
54 t.Helper()
55 req, _ := http.NewRequest(method, srv.URL+path, nil)
56 req.Host = host
57 if cookie != "" {
58 req.Header.Set("Cookie", cookie)
59 }
60 c := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
61 resp, err := c.Do(req)
62 if err != nil {
63 t.Fatal(err)
64 }
65 t.Cleanup(func() { resp.Body.Close() })
66 return resp
67}
68
69func TestGatedRouteWithoutCookieNeverWakesOrProxies(t *testing.T) {
70 // refusingActivator's Fatal message talks about caching — that is where
71 // it came from — but here it proves the gate: a route flagged
72 // Hibernate:true whose activator is never invoked is one a stranger
73 // could not have woken.
74 _, srv, _, seen := gatedEdge(t, refusingActivator{t: t}, true)
75 resp := doHost(t, srv, http.MethodGet, "gated.example.com", "/docs", "")
76 if resp.StatusCode != http.StatusUnauthorized {
77 t.Fatalf("status = %d, want 401", resp.StatusCode)
78 }
79 b, _ := io.ReadAll(resp.Body)
80 if !strings.Contains(string(b), "Password required") {
81 t.Fatalf("body: %s", b)
82 }
83 if resp.Header.Get("Cache-Control") != "no-store" {
84 t.Fatalf("Cache-Control = %q", resp.Header.Get("Cache-Control"))
85 }
86 if resp.Header.Get("X-Robots-Tag") != "noindex, nofollow" {
87 t.Fatalf("X-Robots-Tag = %q", resp.Header.Get("X-Robots-Tag"))
88 }
89 if *seen != "" || strings.Contains(string(b), "secret page") {
90 t.Fatal("upstream was reached without a cookie")
91 }
92}
93
94func TestGatedRouteCookieAdmitsAndIsStripped(t *testing.T) {
95 _, srv, h, seen := gatedEdge(t, &fakeActivator{}, false)
96 cookie := gate.CookieName + "=" + gate.Mint("gated.example.com", h, time.Now().Add(time.Hour))
97 resp := doHost(t, srv, http.MethodGet, "gated.example.com", "/", "a=1; "+cookie)
98 b, _ := io.ReadAll(resp.Body)
99 if resp.StatusCode != 200 || string(b) != "secret page" {
100 t.Fatalf("got %d %q", resp.StatusCode, b)
101 }
102 if *seen != "a=1" {
103 t.Fatalf("upstream Cookie = %q, want the gate cookie stripped", *seen)
104 }
105}
106
107func TestGatedAliasIsGatedToo(t *testing.T) {
108 _, srv, h, _ := gatedEdge(t, &fakeActivator{}, false)
109 if resp := doHost(t, srv, http.MethodGet, "www.custom.example", "/", ""); resp.StatusCode != http.StatusUnauthorized {
110 t.Fatalf("alias without cookie = %d, want 401", resp.StatusCode)
111 }
112 // The MAC is bound to the ROUTE host, so a cookie minted for the primary
113 // verifies when presented on the alias.
114 cookie := gate.CookieName + "=" + gate.Mint("gated.example.com", h, time.Now().Add(time.Hour))
115 if resp := doHost(t, srv, http.MethodGet, "www.custom.example", "/", cookie); resp.StatusCode != 200 {
116 t.Fatalf("alias with cookie = %d, want 200", resp.StatusCode)
117 }
118}
119
120func TestGatedLoginPostSetsCookie(t *testing.T) {
121 _, srv, h, _ := gatedEdge(t, &fakeActivator{}, false)
122 form := url.Values{"password": {"correct horse"}, "next": {"/docs"}}
123 req, _ := http.NewRequest(http.MethodPost, srv.URL+gate.LoginPath, strings.NewReader(form.Encode()))
124 req.Host = "gated.example.com"
125 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
126 c := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
127 resp, err := c.Do(req)
128 if err != nil {
129 t.Fatal(err)
130 }
131 defer resp.Body.Close()
132 if resp.StatusCode != http.StatusSeeOther || resp.Header.Get("Location") != "/docs" {
133 t.Fatalf("got %d -> %q", resp.StatusCode, resp.Header.Get("Location"))
134 }
135 var got string
136 for _, k := range resp.Cookies() {
137 if k.Name == gate.CookieName {
138 got = k.Value
139 }
140 }
141 if got == "" || !gate.Verify(got, "gated.example.com", h, time.Now()) {
142 t.Fatalf("cookie %q does not verify", got)
143 }
144}
145
146// TestGatedLoginPostOnAliasKeepsTheInboundHost is the pin on what the edge
147// hands Admit: the same-origin check compares Origin against the REQUEST's
148// Host, so a login posted to the attached hostname must arrive with that
149// hostname intact — while the cookie it mints is signed for the ROUTE's
150// host, the one every alias of the route shares. Rewriting r.Host to the
151// route before the gate step would 403 every alias login.
152func TestGatedLoginPostOnAliasKeepsTheInboundHost(t *testing.T) {
153 _, srv, h, _ := gatedEdge(t, &fakeActivator{}, false)
154 form := url.Values{"password": {"correct horse"}, "next": {"/docs"}}
155 req, _ := http.NewRequest(http.MethodPost, srv.URL+gate.LoginPath, strings.NewReader(form.Encode()))
156 req.Host = "www.custom.example"
157 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
158 req.Header.Set("Origin", "http://www.custom.example")
159 c := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
160 resp, err := c.Do(req)
161 if err != nil {
162 t.Fatal(err)
163 }
164 defer resp.Body.Close()
165 if resp.StatusCode != http.StatusSeeOther || resp.Header.Get("Location") != "/docs" {
166 t.Fatalf("got %d -> %q", resp.StatusCode, resp.Header.Get("Location"))
167 }
168 var got string
169 for _, k := range resp.Cookies() {
170 if k.Name == gate.CookieName {
171 got = k.Value
172 }
173 }
174 if got == "" || !gate.Verify(got, "gated.example.com", h, time.Now()) {
175 t.Fatalf("cookie %q does not verify for the route host", got)
176 }
177}
178
179// gatedPrefixEdge stands up the shape §4.2 is about: a hostname whose own
180// route is UNGATED and whose "paulca" prefix reaches a GATED route on
181// another host. A login POST to /.carlos/gate has no first segment that can
182// ever be a prefix, so the edge must route it by `next` to land on the
183// gated target rather than on the front.
184func gatedPrefixEdge(t *testing.T) (*httptest.Server, string, *string, *string) {
185 t.Helper()
186 front, frontSeen := hostEchoBackend(t, "the front app")
187 target, targetCookie := cookieEchoBackend(t, "secret page")
188 h, err := gate.Hash("correct horse")
189 if err != nil {
190 t.Fatal(err)
191 }
192 reg := openReg(t)
193 if err := reg.Upsert(registry.Route{Host: "front.example.com", Kind: "service",
194 Addr: front.Listener.Addr().String(), Status: "ready"}); err != nil {
195 t.Fatal(err)
196 }
197 if err := reg.Upsert(registry.Route{Host: "gated.example.com", Kind: "service",
198 Addr: target.Listener.Addr().String(), Status: "ready", Gate: h}); err != nil {
199 t.Fatal(err)
200 }
201 upsertPath(t, reg, "front.example.com", "paulca", "gated.example.com")
202 e, err := New(reg, &fakeActivator{})
203 if err != nil {
204 t.Fatal(err)
205 }
206 srv := httptest.NewServer(e)
207 t.Cleanup(srv.Close)
208 return srv, h, frontSeen, targetCookie
209}
210
211// TestGatedPrefixTargetCanBeLoggedInto is the §4.2 finding: routing the
212// login POST by its own path would resolve it to the FRONT host's ungated
213// route, which either proxies the password to an app that never asked for
214// it or mints a cookie signed for the wrong host. Routed by `next`, the
215// POST reaches the target whose hash judges it and whose host signs the
216// cookie — and that cookie then admits the visitor on the front hostname,
217// because the prefix resolves to the same route either way.
218func TestGatedPrefixTargetCanBeLoggedInto(t *testing.T) {
219 srv, h, frontSeen, targetCookie := gatedPrefixEdge(t)
220
221 // The refusal itself: the prefix reaches the gated route, so the front
222 // app never sees the request.
223 resp := doHost(t, srv, http.MethodGet, "front.example.com", "/paulca/x", "")
224 b, _ := io.ReadAll(resp.Body)
225 if resp.StatusCode != http.StatusUnauthorized || !strings.Contains(string(b), "Password required") {
226 t.Fatalf("prefix GET without a cookie = %d %s, want the 401 login page", resp.StatusCode, b)
227 }
228 if *frontSeen != "" {
229 t.Fatalf("the front app was reached: Host %q", *frontSeen)
230 }
231
232 post := postLogin(t, srv, "front.example.com", "correct horse", "/paulca/x")
233 if post.StatusCode != http.StatusSeeOther || post.Header.Get("Location") != "/paulca/x" {
234 t.Fatalf("login POST = %d -> %q, want 303 -> /paulca/x", post.StatusCode, post.Header.Get("Location"))
235 }
236 if *frontSeen != "" {
237 t.Fatalf("the login POST reached the front app: Host %q — the password must never be proxied", *frontSeen)
238 }
239 got := gateCookie(post)
240 if got == "" || !gate.Verify(got, "gated.example.com", h, time.Now()) {
241 t.Fatalf("cookie %q does not verify for the target host", got)
242 }
243
244 resp = doHost(t, srv, http.MethodGet, "front.example.com", "/paulca/x", gate.CookieName+"="+got)
245 b, _ = io.ReadAll(resp.Body)
246 if resp.StatusCode != 200 || string(b) != "secret page" {
247 t.Fatalf("prefix GET with the cookie = %d %q", resp.StatusCode, b)
248 }
249 if *targetCookie != "" {
250 t.Fatalf("target Cookie = %q, want the gate cookie stripped", *targetCookie)
251 }
252
253 // A refusal on a prefix path that carries a QUERY. The 401's next is
254 // SafeNext(r.URL.RequestURI()), which keeps the query, so the browser
255 // posts next=/paulca?tab=1 — and resolving the POST on that raw string
256 // makes the first segment "paulca?tab=1", which matches no prefix and
257 // drops the login onto the FRONT route: the wrong hash judges the
258 // password and the cookie is signed for the wrong host. The lookup must
259 // see the PATH only.
260 resp = doHost(t, srv, http.MethodGet, "front.example.com", "/paulca?tab=1", "")
261 b, _ = io.ReadAll(resp.Body)
262 if resp.StatusCode != http.StatusUnauthorized {
263 t.Fatalf("prefix GET with a query = %d, want the 401 login page", resp.StatusCode)
264 }
265 if !strings.Contains(string(b), `name="next" value="/paulca?tab=1"`) {
266 t.Fatalf("the 401 page does not carry next=/paulca?tab=1:\n%s", b)
267 }
268
269 post = postLogin(t, srv, "front.example.com", "correct horse", "/paulca?tab=1")
270 if post.StatusCode != http.StatusSeeOther || post.Header.Get("Location") != "/paulca?tab=1" {
271 t.Fatalf("login POST with a query in next = %d -> %q, want 303 -> /paulca?tab=1",
272 post.StatusCode, post.Header.Get("Location"))
273 }
274 if *frontSeen != "" {
275 t.Fatalf("the login POST reached the front app: Host %q — it resolved on the raw next, query and all", *frontSeen)
276 }
277 got = gateCookie(post)
278 if got == "" || !gate.Verify(got, "gated.example.com", h, time.Now()) {
279 t.Fatalf("cookie %q does not verify for the target host — the POST was judged by the front route", got)
280 }
281
282 // And a percent-encoded first segment. RequestURI is raw, r.URL.Path is
283 // decoded: "/pau%6Cca/x" is "/paulca/x" once parsed, so the POST belongs
284 // to the target too.
285 post = postLogin(t, srv, "front.example.com", "correct horse", "/pau%6Cca/x")
286 if post.StatusCode != http.StatusSeeOther {
287 t.Fatalf("login POST with a percent-encoded segment = %d, want 303", post.StatusCode)
288 }
289 if *frontSeen != "" {
290 t.Fatalf("the login POST reached the front app: Host %q — the encoded segment was not decoded", *frontSeen)
291 }
292 got = gateCookie(post)
293 if got == "" || !gate.Verify(got, "gated.example.com", h, time.Now()) {
294 t.Fatalf("cookie %q does not verify for the target host", got)
295 }
296}
297
298// postLogin submits the gate's login form to srv as host, exactly as the
299// 401 page's form would, and returns the response with redirects left
300// unfollowed.
301func postLogin(t *testing.T, srv *httptest.Server, host, password, next string) *http.Response {
302 t.Helper()
303 form := url.Values{"password": {password}, "next": {next}}
304 req, _ := http.NewRequest(http.MethodPost, srv.URL+gate.LoginPath, strings.NewReader(form.Encode()))
305 req.Host = host
306 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
307 c := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
308 resp, err := c.Do(req)
309 if err != nil {
310 t.Fatal(err)
311 }
312 t.Cleanup(func() { resp.Body.Close() })
313 return resp
314}
315
316func gateCookie(resp *http.Response) string {
317 for _, k := range resp.Cookies() {
318 if k.Name == gate.CookieName {
319 return k.Value
320 }
321 }
322 return ""
323}
324
325// TestLoginPostToUngatedRouteIs404 is the other half of §4.2: the form is
326// only ever rendered by a gated route, so a login POST that lands on an
327// ungated one is a stray or a probe. It is answered 404 and the body — a
328// field literally named "password" — is never forwarded to an app that did
329// not ask for it.
330func TestLoginPostToUngatedRouteIs404(t *testing.T) {
331 backend, seenHost := hostEchoBackend(t, "open page")
332 reg := openReg(t)
333 if err := reg.Upsert(registry.Route{Host: "open.example.com", Kind: "service",
334 Addr: backend.Listener.Addr().String(), Status: "ready"}); err != nil {
335 t.Fatal(err)
336 }
337 e, err := New(reg, &fakeActivator{})
338 if err != nil {
339 t.Fatal(err)
340 }
341 srv := httptest.NewServer(e)
342 defer srv.Close()
343
344 form := url.Values{"password": {"correct horse"}, "next": {"/"}}
345 req, _ := http.NewRequest(http.MethodPost, srv.URL+gate.LoginPath, strings.NewReader(form.Encode()))
346 req.Host = "open.example.com"
347 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
348 resp, err := http.DefaultClient.Do(req)
349 if err != nil {
350 t.Fatal(err)
351 }
352 defer resp.Body.Close()
353 if resp.StatusCode != http.StatusNotFound {
354 t.Fatalf("login POST to an ungated route = %d, want 404", resp.StatusCode)
355 }
356 if *seenHost != "" {
357 t.Fatalf("the app was reached with the password form: Host %q", *seenHost)
358 }
359 // Every other method on that path stays ordinary and reaches the app.
360 if resp := doHost(t, srv, http.MethodGet, "open.example.com", gate.LoginPath, ""); resp.StatusCode != 200 {
361 t.Fatalf("GET %s on an ungated route = %d, want the app's 200", gate.LoginPath, resp.StatusCode)
362 }
363}
364
365// TestGatedRefusalIsMeteredToTheRoute pins the meter neighbour of the gate
366// step: the 401 is the tenant's page, served by the route, and its bytes
367// bill to the route like any other response it caused. Placing the gate
368// above the meter's wrap would make refusals free.
369func TestGatedRefusalIsMeteredToTheRoute(t *testing.T) {
370 e, srv, _, _ := gatedEdge(t, &fakeActivator{}, false)
371 m := NewUsageMeter()
372 e.EnableUsage(m)
373 resp := doHost(t, srv, http.MethodGet, "gated.example.com", "/docs", "")
374 if resp.StatusCode != http.StatusUnauthorized {
375 t.Fatalf("status = %d, want 401", resp.StatusCode)
376 }
377 // Drain only once the whole body is in hand: the login page is over
378 // 2 KiB, so the headers reach the client before the handler returns and
379 // the meter's deferred Record has run.
380 io.ReadAll(resp.Body)
381 row, ok := m.Drain()["gated.example.com"]
382 if !ok {
383 t.Fatal("no usage row for the gated route — the refusal was not metered")
384 }
385 if row.Requests != 1 {
386 t.Fatalf("Requests = %d, want 1", row.Requests)
387 }
388 if row.BytesOut <= 0 {
389 t.Fatalf("BytesOut = %d, want the login page's bytes", row.BytesOut)
390 }
391}
392
393// TestLoginPostForAnUnknownHostLeavesTheBodyUnread is the backhaul pin. A
394// host this box does not serve may be homed on a sibling edge, and
395// e.backhaul reverse-proxies THIS request there — so nothing on the way to
396// that branch may read the body. Parsing the login form before the route
397// lookup drained it while leaving ContentLength alone, and the hop 502'd
398// with "ContentLength=41 with Body length 0". The form is parsed only once
399// the host is known to be ours.
400func TestLoginPostForAnUnknownHostLeavesTheBodyUnread(t *testing.T) {
401 e, _, _, _ := gatedEdge(t, &fakeActivator{}, false)
402 form := url.Values{"password": {"correct horse"}, "next": {"/docs"}}.Encode()
403 body := strings.NewReader(form)
404 req := httptest.NewRequest(http.MethodPost, gate.LoginPath, body)
405 req.Host = "not.served.here.example"
406 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
407 rec := httptest.NewRecorder()
408 e.ServeHTTP(rec, req)
409 if body.Len() != len(form) {
410 t.Fatalf("%d of %d body bytes were consumed before the route lookup — a backhauled hop would 502",
411 len(form)-body.Len(), len(form))
412 }
413 if rec.Code != http.StatusNotFound {
414 t.Fatalf("unknown host = %d, want the unchanged unknown-host answer (404)", rec.Code)
415 }
416}
417
418// TestOversizedLoginPostIs413AndSpendsNoAttempt: a body over
419// gate.MaxFormBody is refused at the edge, before Admit is ever called, so
420// it costs neither a bcrypt comparison nor a limiter token. The proof that
421// no token was spent is the ten wrong-password attempts that follow from
422// the same address: the bucket holds exactly ten, so if the junk body had
423// taken one, the last would be 429 instead of 401.
424func TestOversizedLoginPostIs413AndSpendsNoAttempt(t *testing.T) {
425 _, srv, _, seen := gatedEdge(t, &fakeActivator{}, false)
426 c := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
427 post := func(t *testing.T, form string) int {
428 t.Helper()
429 req, _ := http.NewRequest(http.MethodPost, srv.URL+gate.LoginPath, strings.NewReader(form))
430 req.Host = "gated.example.com"
431 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
432 resp, err := c.Do(req)
433 if err != nil {
434 t.Fatal(err)
435 }
436 defer resp.Body.Close()
437 io.Copy(io.Discard, resp.Body)
438 return resp.StatusCode
439 }
440 if code := post(t, "password="+strings.Repeat("a", 64<<10)); code != http.StatusRequestEntityTooLarge {
441 t.Fatalf("oversized login POST = %d, want 413", code)
442 }
443 if *seen != "" {
444 t.Fatal("the oversized body reached the upstream")
445 }
446 for i := range 10 {
447 if code := post(t, url.Values{"password": {"wrong horse"}}.Encode()); code != http.StatusUnauthorized {
448 t.Fatalf("wrong-password attempt %d = %d, want 401 — the 413 spent a token", i+1, code)
449 }
450 }
451}
452
453func TestGatedRouteCacheIsNeverConsultedBeforeTheGate(t *testing.T) {
454 e, srv, _, _ := gatedEdge(t, &fakeActivator{}, false)
455 e.EnableCache(NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes))
456 e.cache.Put(CacheKey{Host: "gated.example.com", Method: "GET", Path: "/"}, "", &CachedResponse{
457 StatusCode: 200, Header: http.Header{"Content-Type": {"text/plain"}}, Body: []byte("cached secret"),
458 StoredAt: time.Now(), TTL: time.Minute,
459 })
460 resp := doHost(t, srv, http.MethodGet, "gated.example.com", "/", "")
461 b, _ := io.ReadAll(resp.Body)
462 if resp.StatusCode != http.StatusUnauthorized || strings.Contains(string(b), "cached secret") {
463 t.Fatalf("cache served past the gate: %d %s", resp.StatusCode, b)
464 }
465}
466
467func TestGatedWebSocketHandshakeIsGated(t *testing.T) {
468 _, srv, h, _ := gatedEdge(t, &fakeActivator{}, false)
469 req, _ := http.NewRequest(http.MethodGet, srv.URL+"/ws", nil)
470 req.Host = "gated.example.com"
471 req.Header.Set("Connection", "Upgrade")
472 req.Header.Set("Upgrade", "websocket")
473 resp, err := http.DefaultClient.Do(req)
474 if err != nil {
475 t.Fatal(err)
476 }
477 resp.Body.Close()
478 if resp.StatusCode != http.StatusUnauthorized {
479 t.Fatalf("upgrade without cookie = %d, want 401", resp.StatusCode)
480 }
481 req.Header.Set("Cookie", gate.CookieName+"="+gate.Mint("gated.example.com", h, time.Now().Add(time.Hour)))
482 resp, err = http.DefaultClient.Do(req)
483 if err != nil {
484 t.Fatal(err)
485 }
486 resp.Body.Close()
487 if resp.StatusCode == http.StatusUnauthorized {
488 t.Fatal("upgrade with cookie was refused")
489 }
490}
491
492func TestUngatedRouteUnchanged(t *testing.T) {
493 backend, seen := cookieEchoBackend(t, "open page")
494 reg := openReg(t)
495 if err := reg.Upsert(registry.Route{Host: "open.example.com", Kind: "instance", Addr: backend.Listener.Addr().String(), Status: "ready"}); err != nil {
496 t.Fatal(err)
497 }
498 e, err := New(reg, &fakeActivator{})
499 if err != nil {
500 t.Fatal(err)
501 }
502 srv := httptest.NewServer(e)
503 defer srv.Close()
504 resp := doHost(t, srv, http.MethodGet, "open.example.com", "/", gate.CookieName+"=whatever; a=1")
505 if resp.StatusCode != 200 || *seen != gate.CookieName+"=whatever; a=1" {
506 t.Fatalf("ungated route touched: %d cookie=%q", resp.StatusCode, *seen)
507 }
508}
509