package ideartest import ( "bytes" "fmt" "io" "mime/multipart" "net/http" "net/http/cookiejar" "net/http/httptest" "net/url" "reflect" "strings" "testing" "time" "github.com/carlosframework/rastrillo/csrf" "github.com/carlosframework/rastrillo/sessions" "github.com/go-chi/chi/v5" "amadan.net/rastrillo/idear" ) // App is idear mounted the way an app mounts it: a real chi router // over a real HTTP server, with rastrillo's real session store and its // real CSRF middleware, driven by a real http.Client with a cookie // jar. // // None of that is ceremony. The authorization rules this module exists // for are enforced by a STACK — chi's routing, the app's session // guard, idear's Require, idear's RequireRole, then the handler, then // the store's own transaction — and a test that calls a handler // function directly proves only the last layer of it. The refusals // that matter (a non-member's byte-identical 404, a member's 403, a // posted role that must never land) are all decided somewhere in the // middle. // // AppNotFound and AppForbidden are the app's OWN pages, deliberately // unlike http.NotFound's and http.Error's defaults: idear's 404 must // be byte-identical to the app's, and a harness that left the stdlib // defaults in place could not tell whether the hook was consulted at // all. chi's own NotFound is given the same renderer, which is the // mounting contract the design states. const ( AppNotFound = "the app's own 404 page" AppForbidden = "the app's own 403 page" ) // SignInPath is the test app's stand-in for whatever identity plugin // the real app chose: GET /test/signin?subject=X mints a real session // row for X and sets the real cookie. // // It is a GET so that signing in never needs the CSRF dance, and it is // the ONLY route in this harness that idear does not own. Everything a // test does after it goes through idear's own mounted routes. const SignInPath = "/test/signin" // App is one instance, served. type App struct { T *testing.T H *Harness Handlers *idear.Handlers Sessions *sessions.Sessions Server *httptest.Server Origin string Router *chi.Mux } // NewApp mounts idear over a fresh instance with the default config. func NewApp(t *testing.T) *App { t.Helper() return NewAppWith(t, idear.Config{}, idear.HandlerConfig{}) } // NewAppWith is NewApp with the caller's Config and HandlerConfig. // // Anything the caller leaves unset gets the harness's own: the app's // 404 and 403 pages, the two recording renderers below, and a rate // limit wide enough that an ordinary test does not trip it (the test // that PROVES the limiter sets its own narrow one). DB, Roster and // ClientKey are always the harness's — the point of the harness is // that those are the harness's. func NewAppWith(t *testing.T, cfg idear.Config, hcfg idear.HandlerConfig) *App { t.Helper() if cfg.NotFound == nil { cfg.NotFound = func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) io.WriteString(w, AppNotFound) } } if cfg.Forbidden == nil { cfg.Forbidden = func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusForbidden) io.WriteString(w, AppForbidden) } } h := NewWith(t, cfg) hcfg.Roster = h.Roster if hcfg.RenderMembers == nil { hcfg.RenderMembers = RenderMembers } if hcfg.RenderInvitation == nil { hcfg.RenderInvitation = RenderInvitation } if hcfg.RateLimit == (idear.RateLimit{}) { // Wide enough that the race tests below, which post hundreds // of public requests from one loopback address, are limited by // the store and not by the bucket. TestRateLimit sets its own. hcfg.RateLimit = idear.RateLimit{Burst: 100000, Every: time.Millisecond} } hs, err := idear.NewHandlers(hcfg) if err != nil { t.Fatalf("idear.NewHandlers: %v", err) } // The listener exists before Start, so the origin is knowable // before the handler that has to be configured with it. srv := httptest.NewUnstartedServer(nil) origin := "http://" + srv.Listener.Addr().String() sess, err := sessions.New(sessions.Config{DB: h.DB.Writer(), Origin: origin}) if err != nil { t.Fatalf("sessions.New: %v", err) } r := chi.NewRouter() // The SAME renderer idear's Config.NotFound got. This is the // mounting contract: two different 404 pages are a membership // oracle, and this line is what makes "byte-identical" testable. r.NotFound(cfg.NotFound) r.Use(csrf.Protect(origin)) // Middleware, not Require: a signed-out request must reach idear's // own Require and be answered 404 like any other non-member, // rather than being redirected to a sign-in page by the layer // above. A real app is free to stack sessions.Require outside // this; the refusals under test are the same either way. r.Use(sess.Middleware) r.Get(SignInPath, func(w http.ResponseWriter, r *http.Request) { subject := r.URL.Query().Get("subject") if err := sess.SignIn(w, r, sessions.Session{ Subject: subject, Method: "test", AuthTime: time.Now(), }); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } io.WriteString(w, "signed in as "+subject) }) for _, rt := range hs.Routes() { r.Method(rt.Method, rt.Pattern, rt.Handler) } srv.Config.Handler = r srv.Start() t.Cleanup(srv.Close) return &App{T: t, H: h, Handlers: hs, Sessions: sess, Server: srv, Origin: origin, Router: r} } // RenderMembers writes the members page as deterministic lines, so a // test can assert on what the handler ACTUALLY passed rather than on // what a template chose to show. func RenderMembers(w http.ResponseWriter, r *http.Request, d idear.MembersPage) { var b strings.Builder b.WriteString("=== members ===\n") if d.Viewer != nil { fmt.Fprintf(&b, "viewer %d %s %s %s\n", d.Viewer.ID, d.Viewer.Role, state(d.Viewer), d.Viewer.Email) } for _, role := range d.Grantable { fmt.Fprintf(&b, "grantable %s\n", role) } for _, m := range d.Members { fmt.Fprintf(&b, "member %d %s %s %s %s\n", m.ID, m.Role, state(&m), m.Subject, m.Email) } for _, inv := range d.Invitations { fmt.Fprintf(&b, "invitation %d %s %s\n", inv.ID, inv.Role, inv.Email) } if d.Error != "" { fmt.Fprintf(&b, "error %s\n", d.Error) } if d.Notice != "" { fmt.Fprintf(&b, "notice %s\n", d.Notice) } io.WriteString(w, b.String()) } func state(m *idear.Member) string { if m.Active() { return "active" } return "deactivated" } // RenderInvitation writes EVERY FIELD of the InvitationPage it is // given, by reflection. // // Reflection, and not a hand-written line per field, is the whole // point. The public GET must not disclose the invited address, and it // does not because InvitationPage has no field for one — but a test // that searched a hand-written template's output for that address // would pass just as well against a template that simply forgot to // print a field it was handed. This renderer prints whatever it is // given, so the day somebody adds an Email to InvitationPage and fills // it in, the disclosure test goes red instead of staying green over a // new leak. func RenderInvitation(w http.ResponseWriter, r *http.Request, d idear.InvitationPage) { var b strings.Builder b.WriteString("=== invitation ===\n") v := reflect.ValueOf(d) t := v.Type() for i := range t.NumField() { fmt.Fprintf(&b, "%s %v\n", strings.ToLower(t.Field(i).Name), v.Field(i).Interface()) } io.WriteString(w, b.String()) } // Client is one browser: a cookie jar, and whatever session it signed // in with. type Client struct { App *App Subject string HTTP *http.Client } // Visitor is a signed-out browser. func (a *App) Visitor() *Client { a.T.Helper() jar, err := cookiejar.New(nil) if err != nil { a.T.Fatalf("cookiejar.New: %v", err) } return &Client{App: a, HTTP: &http.Client{ Jar: jar, // Redirects are NOT followed: a 303 to the members page is // the assertion in every successful mutation, and a client // that chased it would report the members page's 200 instead. CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, }} } // SignIn is a browser holding a real session for subject — the app's // identity plugin, stood in for. func (a *App) SignIn(subject string) *Client { a.T.Helper() c := a.Visitor() res := c.Get(SignInPath + "?subject=" + url.QueryEscape(subject)) if res.Status != http.StatusOK { a.T.Fatalf("signing in as %q: status %d, body %q", subject, res.Status, res.Body) } c.Subject = subject return c } // As is SignIn for a seeded member. func (a *App) As(m *idear.Member) *Client { a.T.Helper() return a.SignIn(m.Subject) } // Result is one response, read to the end. type Result struct { Status int Body string Location string Header http.Header } // Get issues a GET and fails the test if the transport does. func (c *Client) Get(path string) *Result { c.App.T.Helper() res, err := c.TryGet(path) if err != nil { c.App.T.Fatalf("GET %s: %v", path, err) } return res } // Post issues a same-origin form POST — the Origin header a browser // sends, which is what rastrillo's CSRF middleware checks — and fails // the test if the transport does. func (c *Client) Post(path string, form url.Values) *Result { c.App.T.Helper() res, err := c.TryPost(path, form) if err != nil { c.App.T.Fatalf("POST %s: %v", path, err) } return res } // TryGet and TryPost are the GOROUTINE-SAFE halves: they return the // transport error instead of calling t.Fatalf, which is legal only on // the test goroutine. Every race test below drives these. func (c *Client) TryGet(path string) (*Result, error) { return c.do(http.MethodGet, path, nil, c.App.Origin) } func (c *Client) TryPost(path string, form url.Values) (*Result, error) { return c.do(http.MethodPost, path, form, c.App.Origin) } // PostMultipart submits the same fields as a multipart/form-data body // — the encoding a browser uses the moment a form grows a file input. func (c *Client) PostMultipart(path string, fields url.Values) *Result { c.App.T.Helper() var body bytes.Buffer w := multipart.NewWriter(&body) for name, values := range fields { for _, v := range values { if err := w.WriteField(name, v); err != nil { c.App.T.Fatalf("writing multipart field %q: %v", name, err) } } } if err := w.Close(); err != nil { c.App.T.Fatalf("closing the multipart body: %v", err) } req, err := http.NewRequest(http.MethodPost, c.App.Origin+path, &body) if err != nil { c.App.T.Fatalf("POST %s: %v", path, err) } req.Header.Set("Content-Type", w.FormDataContentType()) req.Header.Set("Origin", c.App.Origin) res, err := c.HTTP.Do(req) if err != nil { c.App.T.Fatalf("POST %s: %v", path, err) } defer res.Body.Close() b, err := io.ReadAll(res.Body) if err != nil { c.App.T.Fatalf("reading %s: %v", path, err) } return &Result{Status: res.StatusCode, Body: string(b), Location: res.Header.Get("Location"), Header: res.Header} } // PostFrom is TryPost with a chosen Origin header: the cross-origin // form submission a CSRF attack actually looks like. func (c *Client) PostFrom(origin, path string, form url.Values) *Result { c.App.T.Helper() res, err := c.do(http.MethodPost, path, form, origin) if err != nil { c.App.T.Fatalf("POST %s from %s: %v", path, origin, err) } return res } func (c *Client) do(method, path string, form url.Values, origin string) (*Result, error) { var body io.Reader if form != nil { body = strings.NewReader(form.Encode()) } req, err := http.NewRequest(method, c.App.Origin+path, body) if err != nil { return nil, err } if form != nil { req.Header.Set("Content-Type", "application/x-www-form-urlencoded") // The evidence a browser sends on a form POST. csrf.SameOrigin // prefers Sec-Fetch-Site and falls back to this; sending only // Origin exercises the header an attacker's page cannot forge. req.Header.Set("Origin", origin) } res, err := c.HTTP.Do(req) if err != nil { return nil, err } defer res.Body.Close() b, err := io.ReadAll(res.Body) if err != nil { return nil, err } return &Result{ Status: res.StatusCode, Body: string(b), Location: res.Header.Get("Location"), Header: res.Header, }, nil } // Follow chases a 303 the way a browser would — the flash notice is // only readable on the page the redirect lands on. func (c *Client) Follow(res *Result) *Result { c.App.T.Helper() if res.Location == "" { c.App.T.Fatalf("nothing to follow: status %d, body %q", res.Status, res.Body) } return c.Get(res.Location) }