| new file mode 100644 |
| index 000000000..24645886b |
| --- /dev/null |
| +++ b/internal/edge/gate_test.go |
| @@ -0,0 +1,227 @@ |
| +package edge |
| + |
| +import ( |
| + "io" |
| + "net/http" |
| + "net/http/httptest" |
| + "net/url" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/gate" |
| + "github.com/carlosframework/platform/internal/registry" |
| +) |
| + |
| +func gatedEdge(t *testing.T, act Activator, hibernate bool) (*Edge, *httptest.Server, string, *string) { |
| + t.Helper() |
| + backend, seenCookie := cookieEchoBackend(t, "secret page") |
| + h, err := gate.Hash("correct horse") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + reg := openReg(t) |
| + if err := reg.Upsert(registry.Route{Host: "gated.example.com", Kind: "instance", Addr: backend.Listener.Addr().String(), |
| + Status: "ready", Hibernate: hibernate, Gate: h}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := reg.UpsertAlias(registry.RouteAlias{Alias: "www.custom.example", Host: "gated.example.com", |
| + Account: "acct", App: "app", CreatedAt: 1}); err != nil { |
| + t.Fatal(err) |
| + } |
| + e, err := New(reg, act) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + srv := httptest.NewServer(e) |
| + t.Cleanup(srv.Close) |
| + return e, srv, h, seenCookie |
| +} |
| + |
| +// cookieEchoBackend records the Cookie header it saw. |
| +func cookieEchoBackend(t *testing.T, body string) (*httptest.Server, *string) { |
| + t.Helper() |
| + seen := new(string) |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + *seen = r.Header.Get("Cookie") |
| + io.WriteString(w, body) |
| + })) |
| + t.Cleanup(ts.Close) |
| + return ts, seen |
| +} |
| + |
| +func doHost(t *testing.T, srv *httptest.Server, method, host, path, cookie string) *http.Response { |
| + t.Helper() |
| + req, _ := http.NewRequest(method, srv.URL+path, nil) |
| + req.Host = host |
| + if cookie != "" { |
| + req.Header.Set("Cookie", cookie) |
| + } |
| + c := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} |
| + resp, err := c.Do(req) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { resp.Body.Close() }) |
| + return resp |
| +} |
| + |
| +func TestGatedRouteWithoutCookieNeverWakesOrProxies(t *testing.T) { |
| + _, srv, _, seen := gatedEdge(t, refusingActivator{t: t}, true) |
| + resp := doHost(t, srv, http.MethodGet, "gated.example.com", "/docs", "") |
| + if resp.StatusCode != http.StatusUnauthorized { |
| + t.Fatalf("status = %d, want 401", resp.StatusCode) |
| + } |
| + b, _ := io.ReadAll(resp.Body) |
| + if !strings.Contains(string(b), "Password required") { |
| + t.Fatalf("body: %s", b) |
| + } |
| + if resp.Header.Get("Cache-Control") != "no-store" { |
| + t.Fatalf("Cache-Control = %q", resp.Header.Get("Cache-Control")) |
| + } |
| + if *seen != "" || strings.Contains(string(b), "secret page") { |
| + t.Fatal("upstream was reached without a cookie") |
| + } |
| +} |
| + |
| +func TestGatedRouteCookieAdmitsAndIsStripped(t *testing.T) { |
| + _, srv, h, seen := gatedEdge(t, &fakeActivator{}, false) |
| + cookie := gate.CookieName + "=" + gate.Mint("gated.example.com", h, time.Now().Add(time.Hour)) |
| + resp := doHost(t, srv, http.MethodGet, "gated.example.com", "/", "a=1; "+cookie) |
| + b, _ := io.ReadAll(resp.Body) |
| + if resp.StatusCode != 200 || string(b) != "secret page" { |
| + t.Fatalf("got %d %q", resp.StatusCode, b) |
| + } |
| + if *seen != "a=1" { |
| + t.Fatalf("upstream Cookie = %q, want the gate cookie stripped", *seen) |
| + } |
| +} |
| + |
| +func TestGatedAliasIsGatedToo(t *testing.T) { |
| + _, srv, h, _ := gatedEdge(t, &fakeActivator{}, false) |
| + if resp := doHost(t, srv, http.MethodGet, "www.custom.example", "/", ""); resp.StatusCode != http.StatusUnauthorized { |
| + t.Fatalf("alias without cookie = %d, want 401", resp.StatusCode) |
| + } |
| + // The MAC is bound to the ROUTE host, so a cookie minted for the primary |
| + // verifies when presented on the alias. |
| + cookie := gate.CookieName + "=" + gate.Mint("gated.example.com", h, time.Now().Add(time.Hour)) |
| + if resp := doHost(t, srv, http.MethodGet, "www.custom.example", "/", cookie); resp.StatusCode != 200 { |
| + t.Fatalf("alias with cookie = %d, want 200", resp.StatusCode) |
| + } |
| +} |
| + |
| +func TestGatedLoginPostSetsCookie(t *testing.T) { |
| + _, srv, h, _ := gatedEdge(t, &fakeActivator{}, false) |
| + form := url.Values{"password": {"correct horse"}, "next": {"/docs"}} |
| + req, _ := http.NewRequest(http.MethodPost, srv.URL+gate.LoginPath, strings.NewReader(form.Encode())) |
| + req.Host = "gated.example.com" |
| + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| + c := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} |
| + resp, err := c.Do(req) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer resp.Body.Close() |
| + if resp.StatusCode != http.StatusSeeOther || resp.Header.Get("Location") != "/docs" { |
| + t.Fatalf("got %d -> %q", resp.StatusCode, resp.Header.Get("Location")) |
| + } |
| + var got string |
| + for _, k := range resp.Cookies() { |
| + if k.Name == gate.CookieName { |
| + got = k.Value |
| + } |
| + } |
| + if got == "" || !gate.Verify(got, "gated.example.com", h, time.Now()) { |
| + t.Fatalf("cookie %q does not verify", got) |
| + } |
| +} |
| + |
| +// TestGatedLoginPostOnAliasKeepsTheInboundHost is the pin on what the edge |
| +// hands Admit: the same-origin check compares Origin against the REQUEST's |
| +// Host, so a login posted to the attached hostname must arrive with that |
| +// hostname intact — while the cookie it mints is signed for the ROUTE's |
| +// host, the one every alias of the route shares. Rewriting r.Host to the |
| +// route before the gate step would 403 every alias login. |
| +func TestGatedLoginPostOnAliasKeepsTheInboundHost(t *testing.T) { |
| + _, srv, h, _ := gatedEdge(t, &fakeActivator{}, false) |
| + form := url.Values{"password": {"correct horse"}, "next": {"/docs"}} |
| + req, _ := http.NewRequest(http.MethodPost, srv.URL+gate.LoginPath, strings.NewReader(form.Encode())) |
| + req.Host = "www.custom.example" |
| + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| + req.Header.Set("Origin", "http://www.custom.example") |
| + c := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} |
| + resp, err := c.Do(req) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer resp.Body.Close() |
| + if resp.StatusCode != http.StatusSeeOther || resp.Header.Get("Location") != "/docs" { |
| + t.Fatalf("got %d -> %q", resp.StatusCode, resp.Header.Get("Location")) |
| + } |
| + var got string |
| + for _, k := range resp.Cookies() { |
| + if k.Name == gate.CookieName { |
| + got = k.Value |
| + } |
| + } |
| + if got == "" || !gate.Verify(got, "gated.example.com", h, time.Now()) { |
| + t.Fatalf("cookie %q does not verify for the route host", got) |
| + } |
| +} |
| + |
| +func TestGatedRouteCacheIsNeverConsultedBeforeTheGate(t *testing.T) { |
| + e, srv, _, _ := gatedEdge(t, &fakeActivator{}, false) |
| + e.EnableCache(NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes)) |
| + e.cache.Put(CacheKey{Host: "gated.example.com", Method: "GET", Path: "/"}, "", &CachedResponse{ |
| + StatusCode: 200, Header: http.Header{"Content-Type": {"text/plain"}}, Body: []byte("cached secret"), |
| + StoredAt: time.Now(), TTL: time.Minute, |
| + }) |
| + resp := doHost(t, srv, http.MethodGet, "gated.example.com", "/", "") |
| + b, _ := io.ReadAll(resp.Body) |
| + if resp.StatusCode != http.StatusUnauthorized || strings.Contains(string(b), "cached secret") { |
| + t.Fatalf("cache served past the gate: %d %s", resp.StatusCode, b) |
| + } |
| +} |
| + |
| +func TestGatedWebSocketHandshakeIsGated(t *testing.T) { |
| + _, srv, h, _ := gatedEdge(t, &fakeActivator{}, false) |
| + req, _ := http.NewRequest(http.MethodGet, srv.URL+"/ws", nil) |
| + req.Host = "gated.example.com" |
| + req.Header.Set("Connection", "Upgrade") |
| + req.Header.Set("Upgrade", "websocket") |
| + resp, err := http.DefaultClient.Do(req) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + resp.Body.Close() |
| + if resp.StatusCode != http.StatusUnauthorized { |
| + t.Fatalf("upgrade without cookie = %d, want 401", resp.StatusCode) |
| + } |
| + req.Header.Set("Cookie", gate.CookieName+"="+gate.Mint("gated.example.com", h, time.Now().Add(time.Hour))) |
| + resp, err = http.DefaultClient.Do(req) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + resp.Body.Close() |
| + if resp.StatusCode == http.StatusUnauthorized { |
| + t.Fatal("upgrade with cookie was refused") |
| + } |
| +} |
| + |
| +func TestUngatedRouteUnchanged(t *testing.T) { |
| + backend, seen := cookieEchoBackend(t, "open page") |
| + reg := openReg(t) |
| + if err := reg.Upsert(registry.Route{Host: "open.example.com", Kind: "instance", Addr: backend.Listener.Addr().String(), Status: "ready"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + e, err := New(reg, &fakeActivator{}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + srv := httptest.NewServer(e) |
| + defer srv.Close() |
| + resp := doHost(t, srv, http.MethodGet, "open.example.com", "/", gate.CookieName+"=whatever; a=1") |
| + if resp.StatusCode != 200 || *seen != gate.CookieName+"=whatever; a=1" { |
| + t.Fatalf("ungated route touched: %d cookie=%q", resp.StatusCode, *seen) |
| + } |
| +} |