| 1 | package ideartest |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "fmt" |
| 6 | "io" |
| 7 | "mime/multipart" |
| 8 | "net/http" |
| 9 | "net/http/cookiejar" |
| 10 | "net/http/httptest" |
| 11 | "net/url" |
| 12 | "reflect" |
| 13 | "strings" |
| 14 | "testing" |
| 15 | "time" |
| 16 | |
| 17 | "github.com/carlosframework/rastrillo/csrf" |
| 18 | "github.com/carlosframework/rastrillo/sessions" |
| 19 | "github.com/go-chi/chi/v5" |
| 20 | |
| 21 | "amadan.net/rastrillo/idear" |
| 22 | ) |
| 23 | |
| 24 | // App is idear mounted the way an app mounts it: a real chi router |
| 25 | // over a real HTTP server, with rastrillo's real session store and its |
| 26 | // real CSRF middleware, driven by a real http.Client with a cookie |
| 27 | // jar. |
| 28 | // |
| 29 | // None of that is ceremony. The authorization rules this module exists |
| 30 | // for are enforced by a STACK — chi's routing, the app's session |
| 31 | // guard, idear's Require, idear's RequireRole, then the handler, then |
| 32 | // the store's own transaction — and a test that calls a handler |
| 33 | // function directly proves only the last layer of it. The refusals |
| 34 | // that matter (a non-member's byte-identical 404, a member's 403, a |
| 35 | // posted role that must never land) are all decided somewhere in the |
| 36 | // middle. |
| 37 | // |
| 38 | // AppNotFound and AppForbidden are the app's OWN pages, deliberately |
| 39 | // unlike http.NotFound's and http.Error's defaults: idear's 404 must |
| 40 | // be byte-identical to the app's, and a harness that left the stdlib |
| 41 | // defaults in place could not tell whether the hook was consulted at |
| 42 | // all. chi's own NotFound is given the same renderer, which is the |
| 43 | // mounting contract the design states. |
| 44 | const ( |
| 45 | AppNotFound = "the app's own 404 page" |
| 46 | AppForbidden = "the app's own 403 page" |
| 47 | ) |
| 48 | |
| 49 | // SignInPath is the test app's stand-in for whatever identity plugin |
| 50 | // the real app chose: GET /test/signin?subject=X mints a real session |
| 51 | // row for X and sets the real cookie. |
| 52 | // |
| 53 | // It is a GET so that signing in never needs the CSRF dance, and it is |
| 54 | // the ONLY route in this harness that idear does not own. Everything a |
| 55 | // test does after it goes through idear's own mounted routes. |
| 56 | const SignInPath = "/test/signin" |
| 57 | |
| 58 | // App is one instance, served. |
| 59 | type App struct { |
| 60 | T *testing.T |
| 61 | H *Harness |
| 62 | Handlers *idear.Handlers |
| 63 | Sessions *sessions.Sessions |
| 64 | Server *httptest.Server |
| 65 | Origin string |
| 66 | Router *chi.Mux |
| 67 | } |
| 68 | |
| 69 | // NewApp mounts idear over a fresh instance with the default config. |
| 70 | func NewApp(t *testing.T) *App { |
| 71 | t.Helper() |
| 72 | return NewAppWith(t, idear.Config{}, idear.HandlerConfig{}) |
| 73 | } |
| 74 | |
| 75 | // NewAppWith is NewApp with the caller's Config and HandlerConfig. |
| 76 | // |
| 77 | // Anything the caller leaves unset gets the harness's own: the app's |
| 78 | // 404 and 403 pages, the two recording renderers below, and a rate |
| 79 | // limit wide enough that an ordinary test does not trip it (the test |
| 80 | // that PROVES the limiter sets its own narrow one). DB, Roster and |
| 81 | // ClientKey are always the harness's — the point of the harness is |
| 82 | // that those are the harness's. |
| 83 | func NewAppWith(t *testing.T, cfg idear.Config, hcfg idear.HandlerConfig) *App { |
| 84 | t.Helper() |
| 85 | |
| 86 | if cfg.NotFound == nil { |
| 87 | cfg.NotFound = func(w http.ResponseWriter, r *http.Request) { |
| 88 | w.WriteHeader(http.StatusNotFound) |
| 89 | io.WriteString(w, AppNotFound) |
| 90 | } |
| 91 | } |
| 92 | if cfg.Forbidden == nil { |
| 93 | cfg.Forbidden = func(w http.ResponseWriter, r *http.Request) { |
| 94 | w.WriteHeader(http.StatusForbidden) |
| 95 | io.WriteString(w, AppForbidden) |
| 96 | } |
| 97 | } |
| 98 | h := NewWith(t, cfg) |
| 99 | |
| 100 | hcfg.Roster = h.Roster |
| 101 | if hcfg.RenderMembers == nil { |
| 102 | hcfg.RenderMembers = RenderMembers |
| 103 | } |
| 104 | if hcfg.RenderInvitation == nil { |
| 105 | hcfg.RenderInvitation = RenderInvitation |
| 106 | } |
| 107 | if hcfg.RateLimit == (idear.RateLimit{}) { |
| 108 | // Wide enough that the race tests below, which post hundreds |
| 109 | // of public requests from one loopback address, are limited by |
| 110 | // the store and not by the bucket. TestRateLimit sets its own. |
| 111 | hcfg.RateLimit = idear.RateLimit{Burst: 100000, Every: time.Millisecond} |
| 112 | } |
| 113 | hs, err := idear.NewHandlers(hcfg) |
| 114 | if err != nil { |
| 115 | t.Fatalf("idear.NewHandlers: %v", err) |
| 116 | } |
| 117 | |
| 118 | // The listener exists before Start, so the origin is knowable |
| 119 | // before the handler that has to be configured with it. |
| 120 | srv := httptest.NewUnstartedServer(nil) |
| 121 | origin := "http://" + srv.Listener.Addr().String() |
| 122 | |
| 123 | sess, err := sessions.New(sessions.Config{DB: h.DB.Writer(), Origin: origin}) |
| 124 | if err != nil { |
| 125 | t.Fatalf("sessions.New: %v", err) |
| 126 | } |
| 127 | |
| 128 | r := chi.NewRouter() |
| 129 | // The SAME renderer idear's Config.NotFound got. This is the |
| 130 | // mounting contract: two different 404 pages are a membership |
| 131 | // oracle, and this line is what makes "byte-identical" testable. |
| 132 | r.NotFound(cfg.NotFound) |
| 133 | r.Use(csrf.Protect(origin)) |
| 134 | // Middleware, not Require: a signed-out request must reach idear's |
| 135 | // own Require and be answered 404 like any other non-member, |
| 136 | // rather than being redirected to a sign-in page by the layer |
| 137 | // above. A real app is free to stack sessions.Require outside |
| 138 | // this; the refusals under test are the same either way. |
| 139 | r.Use(sess.Middleware) |
| 140 | r.Get(SignInPath, func(w http.ResponseWriter, r *http.Request) { |
| 141 | subject := r.URL.Query().Get("subject") |
| 142 | if err := sess.SignIn(w, r, sessions.Session{ |
| 143 | Subject: subject, |
| 144 | Method: "test", |
| 145 | AuthTime: time.Now(), |
| 146 | }); err != nil { |
| 147 | http.Error(w, err.Error(), http.StatusInternalServerError) |
| 148 | return |
| 149 | } |
| 150 | io.WriteString(w, "signed in as "+subject) |
| 151 | }) |
| 152 | for _, rt := range hs.Routes() { |
| 153 | r.Method(rt.Method, rt.Pattern, rt.Handler) |
| 154 | } |
| 155 | |
| 156 | srv.Config.Handler = r |
| 157 | srv.Start() |
| 158 | t.Cleanup(srv.Close) |
| 159 | |
| 160 | return &App{T: t, H: h, Handlers: hs, Sessions: sess, Server: srv, Origin: origin, Router: r} |
| 161 | } |
| 162 | |
| 163 | // RenderMembers writes the members page as deterministic lines, so a |
| 164 | // test can assert on what the handler ACTUALLY passed rather than on |
| 165 | // what a template chose to show. |
| 166 | func RenderMembers(w http.ResponseWriter, r *http.Request, d idear.MembersPage) { |
| 167 | var b strings.Builder |
| 168 | b.WriteString("=== members ===\n") |
| 169 | if d.Viewer != nil { |
| 170 | fmt.Fprintf(&b, "viewer %d %s %s %s\n", d.Viewer.ID, d.Viewer.Role, state(d.Viewer), d.Viewer.Email) |
| 171 | } |
| 172 | for _, role := range d.Grantable { |
| 173 | fmt.Fprintf(&b, "grantable %s\n", role) |
| 174 | } |
| 175 | for _, m := range d.Members { |
| 176 | fmt.Fprintf(&b, "member %d %s %s %s %s\n", m.ID, m.Role, state(&m), m.Subject, m.Email) |
| 177 | } |
| 178 | for _, inv := range d.Invitations { |
| 179 | fmt.Fprintf(&b, "invitation %d %s %s\n", inv.ID, inv.Role, inv.Email) |
| 180 | } |
| 181 | if d.Error != "" { |
| 182 | fmt.Fprintf(&b, "error %s\n", d.Error) |
| 183 | } |
| 184 | if d.Notice != "" { |
| 185 | fmt.Fprintf(&b, "notice %s\n", d.Notice) |
| 186 | } |
| 187 | io.WriteString(w, b.String()) |
| 188 | } |
| 189 | |
| 190 | func state(m *idear.Member) string { |
| 191 | if m.Active() { |
| 192 | return "active" |
| 193 | } |
| 194 | return "deactivated" |
| 195 | } |
| 196 | |
| 197 | // RenderInvitation writes EVERY FIELD of the InvitationPage it is |
| 198 | // given, by reflection. |
| 199 | // |
| 200 | // Reflection, and not a hand-written line per field, is the whole |
| 201 | // point. The public GET must not disclose the invited address, and it |
| 202 | // does not because InvitationPage has no field for one — but a test |
| 203 | // that searched a hand-written template's output for that address |
| 204 | // would pass just as well against a template that simply forgot to |
| 205 | // print a field it was handed. This renderer prints whatever it is |
| 206 | // given, so the day somebody adds an Email to InvitationPage and fills |
| 207 | // it in, the disclosure test goes red instead of staying green over a |
| 208 | // new leak. |
| 209 | func RenderInvitation(w http.ResponseWriter, r *http.Request, d idear.InvitationPage) { |
| 210 | var b strings.Builder |
| 211 | b.WriteString("=== invitation ===\n") |
| 212 | v := reflect.ValueOf(d) |
| 213 | t := v.Type() |
| 214 | for i := range t.NumField() { |
| 215 | fmt.Fprintf(&b, "%s %v\n", strings.ToLower(t.Field(i).Name), v.Field(i).Interface()) |
| 216 | } |
| 217 | io.WriteString(w, b.String()) |
| 218 | } |
| 219 | |
| 220 | // Client is one browser: a cookie jar, and whatever session it signed |
| 221 | // in with. |
| 222 | type Client struct { |
| 223 | App *App |
| 224 | Subject string |
| 225 | HTTP *http.Client |
| 226 | } |
| 227 | |
| 228 | // Visitor is a signed-out browser. |
| 229 | func (a *App) Visitor() *Client { |
| 230 | a.T.Helper() |
| 231 | jar, err := cookiejar.New(nil) |
| 232 | if err != nil { |
| 233 | a.T.Fatalf("cookiejar.New: %v", err) |
| 234 | } |
| 235 | return &Client{App: a, HTTP: &http.Client{ |
| 236 | Jar: jar, |
| 237 | // Redirects are NOT followed: a 303 to the members page is |
| 238 | // the assertion in every successful mutation, and a client |
| 239 | // that chased it would report the members page's 200 instead. |
| 240 | CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, |
| 241 | }} |
| 242 | } |
| 243 | |
| 244 | // SignIn is a browser holding a real session for subject — the app's |
| 245 | // identity plugin, stood in for. |
| 246 | func (a *App) SignIn(subject string) *Client { |
| 247 | a.T.Helper() |
| 248 | c := a.Visitor() |
| 249 | res := c.Get(SignInPath + "?subject=" + url.QueryEscape(subject)) |
| 250 | if res.Status != http.StatusOK { |
| 251 | a.T.Fatalf("signing in as %q: status %d, body %q", subject, res.Status, res.Body) |
| 252 | } |
| 253 | c.Subject = subject |
| 254 | return c |
| 255 | } |
| 256 | |
| 257 | // As is SignIn for a seeded member. |
| 258 | func (a *App) As(m *idear.Member) *Client { |
| 259 | a.T.Helper() |
| 260 | return a.SignIn(m.Subject) |
| 261 | } |
| 262 | |
| 263 | // Result is one response, read to the end. |
| 264 | type Result struct { |
| 265 | Status int |
| 266 | Body string |
| 267 | Location string |
| 268 | Header http.Header |
| 269 | } |
| 270 | |
| 271 | // Get issues a GET and fails the test if the transport does. |
| 272 | func (c *Client) Get(path string) *Result { |
| 273 | c.App.T.Helper() |
| 274 | res, err := c.TryGet(path) |
| 275 | if err != nil { |
| 276 | c.App.T.Fatalf("GET %s: %v", path, err) |
| 277 | } |
| 278 | return res |
| 279 | } |
| 280 | |
| 281 | // Post issues a same-origin form POST — the Origin header a browser |
| 282 | // sends, which is what rastrillo's CSRF middleware checks — and fails |
| 283 | // the test if the transport does. |
| 284 | func (c *Client) Post(path string, form url.Values) *Result { |
| 285 | c.App.T.Helper() |
| 286 | res, err := c.TryPost(path, form) |
| 287 | if err != nil { |
| 288 | c.App.T.Fatalf("POST %s: %v", path, err) |
| 289 | } |
| 290 | return res |
| 291 | } |
| 292 | |
| 293 | // TryGet and TryPost are the GOROUTINE-SAFE halves: they return the |
| 294 | // transport error instead of calling t.Fatalf, which is legal only on |
| 295 | // the test goroutine. Every race test below drives these. |
| 296 | func (c *Client) TryGet(path string) (*Result, error) { |
| 297 | return c.do(http.MethodGet, path, nil, c.App.Origin) |
| 298 | } |
| 299 | |
| 300 | func (c *Client) TryPost(path string, form url.Values) (*Result, error) { |
| 301 | return c.do(http.MethodPost, path, form, c.App.Origin) |
| 302 | } |
| 303 | |
| 304 | // PostMultipart submits the same fields as a multipart/form-data body |
| 305 | // — the encoding a browser uses the moment a form grows a file input. |
| 306 | func (c *Client) PostMultipart(path string, fields url.Values) *Result { |
| 307 | c.App.T.Helper() |
| 308 | var body bytes.Buffer |
| 309 | w := multipart.NewWriter(&body) |
| 310 | for name, values := range fields { |
| 311 | for _, v := range values { |
| 312 | if err := w.WriteField(name, v); err != nil { |
| 313 | c.App.T.Fatalf("writing multipart field %q: %v", name, err) |
| 314 | } |
| 315 | } |
| 316 | } |
| 317 | if err := w.Close(); err != nil { |
| 318 | c.App.T.Fatalf("closing the multipart body: %v", err) |
| 319 | } |
| 320 | req, err := http.NewRequest(http.MethodPost, c.App.Origin+path, &body) |
| 321 | if err != nil { |
| 322 | c.App.T.Fatalf("POST %s: %v", path, err) |
| 323 | } |
| 324 | req.Header.Set("Content-Type", w.FormDataContentType()) |
| 325 | req.Header.Set("Origin", c.App.Origin) |
| 326 | res, err := c.HTTP.Do(req) |
| 327 | if err != nil { |
| 328 | c.App.T.Fatalf("POST %s: %v", path, err) |
| 329 | } |
| 330 | defer res.Body.Close() |
| 331 | b, err := io.ReadAll(res.Body) |
| 332 | if err != nil { |
| 333 | c.App.T.Fatalf("reading %s: %v", path, err) |
| 334 | } |
| 335 | return &Result{Status: res.StatusCode, Body: string(b), Location: res.Header.Get("Location"), Header: res.Header} |
| 336 | } |
| 337 | |
| 338 | // PostFrom is TryPost with a chosen Origin header: the cross-origin |
| 339 | // form submission a CSRF attack actually looks like. |
| 340 | func (c *Client) PostFrom(origin, path string, form url.Values) *Result { |
| 341 | c.App.T.Helper() |
| 342 | res, err := c.do(http.MethodPost, path, form, origin) |
| 343 | if err != nil { |
| 344 | c.App.T.Fatalf("POST %s from %s: %v", path, origin, err) |
| 345 | } |
| 346 | return res |
| 347 | } |
| 348 | |
| 349 | func (c *Client) do(method, path string, form url.Values, origin string) (*Result, error) { |
| 350 | var body io.Reader |
| 351 | if form != nil { |
| 352 | body = strings.NewReader(form.Encode()) |
| 353 | } |
| 354 | req, err := http.NewRequest(method, c.App.Origin+path, body) |
| 355 | if err != nil { |
| 356 | return nil, err |
| 357 | } |
| 358 | if form != nil { |
| 359 | req.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| 360 | // The evidence a browser sends on a form POST. csrf.SameOrigin |
| 361 | // prefers Sec-Fetch-Site and falls back to this; sending only |
| 362 | // Origin exercises the header an attacker's page cannot forge. |
| 363 | req.Header.Set("Origin", origin) |
| 364 | } |
| 365 | res, err := c.HTTP.Do(req) |
| 366 | if err != nil { |
| 367 | return nil, err |
| 368 | } |
| 369 | defer res.Body.Close() |
| 370 | b, err := io.ReadAll(res.Body) |
| 371 | if err != nil { |
| 372 | return nil, err |
| 373 | } |
| 374 | return &Result{ |
| 375 | Status: res.StatusCode, |
| 376 | Body: string(b), |
| 377 | Location: res.Header.Get("Location"), |
| 378 | Header: res.Header, |
| 379 | }, nil |
| 380 | } |
| 381 | |
| 382 | // Follow chases a 303 the way a browser would — the flash notice is |
| 383 | // only readable on the page the redirect lands on. |
| 384 | func (c *Client) Follow(res *Result) *Result { |
| 385 | c.App.T.Helper() |
| 386 | if res.Location == "" { |
| 387 | c.App.T.Fatalf("nothing to follow: status %d, body %q", res.Status, res.Body) |
| 388 | } |
| 389 | return c.Get(res.Location) |
| 390 | } |
| 391 | |