rastrillo / idear Public

Clone
git clone https://amadan.net/rastrillo/idear

Plain git — no account needed to clone.

Download

Download this file

1package ideartest
2
3import (
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.
44const (
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.
56const SignInPath = "/test/signin"
57
58// App is one instance, served.
59type 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.
70func 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.
83func 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.
166func 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
190func 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.
209func 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.
222type Client struct {
223 App *App
224 Subject string
225 HTTP *http.Client
226}
227
228// Visitor is a signed-out browser.
229func (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.
246func (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.
258func (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.
264type 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.
272func (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.
284func (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.
296func (c *Client) TryGet(path string) (*Result, error) {
297 return c.do(http.MethodGet, path, nil, c.App.Origin)
298}
299
300func (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.
306func (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.
340func (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
349func (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.
384func (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