package main import ( "context" "database/sql" "fmt" "io" "log/slog" "net/http" "net/http/cookiejar" "net/http/httptest" "net/url" "os" "path/filepath" "regexp" "strconv" "strings" "testing" "github.com/carlosframework/rastrillo/db" "github.com/carlosframework/rastrillo/migrate" "github.com/carlosframework/rastrillo/password" "amadan.net/rastrillo/idear" ) // The reviewers' standing complaint about idear was that nothing had // been proven against a real app shell — every earlier test drove // idear's own handlers over idear's own harness. These tests drive THE // EXAMPLE: its router, its templates, its identity plugin, its // migrations, over a real HTTP server with a real cookie jar. If the // mount is wrong, they go red; that is the point of them. // testApp is the example, served. type testApp struct { t *testing.T app *app db *db.DB server *httptest.Server origin string } func newTestApp(t *testing.T) *testApp { t.Helper() d, err := db.Open(filepath.Join(t.TempDir(), "board.db"), nil) if err != nil { t.Fatalf("db.Open: %v", err) } t.Cleanup(func() { d.Close() }) // The listener exists before Start, so the origin — which decides // the CSRF check and the cookie attributes — is knowable before // the app that has to be configured with it. srv := httptest.NewUnstartedServer(nil) origin := "http://" + srv.Listener.Addr().String() a, err := newApp(d, origin, "The Example Board", slog.New(slog.NewTextHandler(io.Discard, nil))) if err != nil { t.Fatalf("newApp: %v", err) } srv.Config.Handler = a.mux srv.Start() t.Cleanup(srv.Close) return &testApp{t: t, app: a, db: d, server: srv, origin: origin} } // client is one browser: a cookie jar, and whatever session it holds. type client struct { ta *testApp http *http.Client } func (ta *testApp) visitor() *client { ta.t.Helper() jar, err := cookiejar.New(nil) if err != nil { ta.t.Fatalf("cookiejar.New: %v", err) } return &client{ta: ta, http: &http.Client{ Jar: jar, // Redirects are not followed: the 303 IS the assertion on // every successful mutation, and a client that chased it would // report the destination's 200 instead. CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, }} } type result struct { Status int Body string Location string } func (c *client) get(path string) *result { return c.do(http.MethodGet, path, nil) } func (c *client) post(path string, form url.Values) *result { return c.do(http.MethodPost, path, form) } func (c *client) do(method, path string, form url.Values) *result { c.ta.t.Helper() var body io.Reader if form != nil { body = strings.NewReader(form.Encode()) } req, err := http.NewRequest(method, c.ta.origin+path, body) if err != nil { c.ta.t.Fatalf("%s %s: %v", method, path, err) } if form != nil { req.Header.Set("Content-Type", "application/x-www-form-urlencoded") } if method != http.MethodGet { // The evidence a browser sends on a same-origin form // submission. csrf.Protect is mounted app-wide, so a mutation // without it is refused 403 — which is exactly what a // cross-site forgery looks like. It is set for every mutation, // body or no body: several of idear's routes (remove, restore, // revoke) are buttons with nothing in the form at all. req.Header.Set("Origin", c.ta.origin) } res, err := c.http.Do(req) if err != nil { c.ta.t.Fatalf("%s %s: %v", method, path, err) } defer res.Body.Close() b, err := io.ReadAll(res.Body) if err != nil { c.ta.t.Fatalf("reading %s: %v", path, err) } return &result{Status: res.StatusCode, Body: string(b), Location: res.Header.Get("Location")} } // follow chases a redirect the way a browser would: the flash notice // is only readable on the page the 303 lands on. func (c *client) follow(res *result) *result { c.ta.t.Helper() if res.Location == "" { c.ta.t.Fatalf("nothing to follow: status %d, body %q", res.Status, res.Body) } return c.get(res.Location) } // signUp posts the signup form, with an invitation token when there is // one. The "invite" field is what rs.CarryToken reads. func (c *client) signUp(email, invite string) *result { c.ta.t.Helper() return c.signUpWith(email, invite, "demo-password") } func (c *client) signUpWith(email, invite, pw string) *result { c.ta.t.Helper() form := url.Values{"email": {email}, "password": {pw}} if invite != "" { form.Set("invite", invite) } return c.post("/signup", form) } func (c *client) signIn(email string) *result { c.ta.t.Helper() return c.post("/signin", url.Values{"email": {email}, "password": {"demo-password"}}) } // tokenPattern pulls the invitation link out of the flash notice. // HandlerConfig.Deliver is nil in this example, so idear puts the link // itself in the notice the inviting admin sees — see app.go. var tokenPattern = regexp.MustCompile(`/invitations/([0-9a-f]{64})`) func (ta *testApp) tokenFrom(res *result) string { ta.t.Helper() m := tokenPattern.FindStringSubmatch(res.Body) if m == nil { ta.t.Fatalf("no invitation link in the page: %q", res.Body) } return m[1] } // memberID resolves an address to its roster id, for building the // management URLs a real admin clicks. func (ta *testApp) memberID(email string) int64 { ta.t.Helper() members, err := ta.app.roster.Members(context.Background()) if err != nil { ta.t.Fatalf("listing members: %v", err) } for _, m := range members { if m.Email == email { return m.ID } } ta.t.Fatalf("no member with address %q in %+v", email, members) return 0 } func (ta *testApp) member(email string) idear.Member { ta.t.Helper() m, err := ta.app.roster.ByID(context.Background(), ta.memberID(email)) if err != nil { ta.t.Fatalf("loading member %q: %v", email, err) } return *m } func want(t *testing.T, res *result, status int, what string) { t.Helper() if res.Status != status { t.Fatalf("%s: status %d, want %d; body %q", what, res.Status, status, res.Body) } } // TestSignUpClaimInviteAcceptMembersRoleChange is the whole flow the // example exists to prove, through real HTTP: sign up (claiming the // instance), invite, accept the invitation as a second browser, read // the members page, and change a role. func TestSignUpClaimInviteAcceptMembersRoleChange(t *testing.T) { ta := newTestApp(t) // Signed out, "/" is the app's session guard's problem, not // idear's: a redirect to the sign-in page, never a 404. idear's // Require never redirects — that division is why it mounts INSIDE // this guard. res := ta.visitor().get("/") want(t, res, http.StatusSeeOther, "signed-out GET /") if !strings.HasPrefix(res.Location, "/signin") { t.Fatalf("signed-out GET / went to %q, want /signin", res.Location) } // The claim: the first account on an empty roster is the Owner, // with no invitation involved. ada := ta.visitor() res = ada.signUp("ada@example.test", "") want(t, res, http.StatusSeeOther, "first signup") if owner := ta.member("ada@example.test"); owner.Role != idear.RoleOwner { t.Fatalf("first account is %q, want owner", owner.Role) } // And now the instance is closed. A stranger with no token is // refused at 403 with idear's one constant refusal copy — no // mention of the address, and the same words whatever the reason. res = ta.visitor().signUp("mallory@example.test", "") want(t, res, http.StatusForbidden, "uninvited signup") if !strings.Contains(res.Body, "Sign-up here is by invitation.") { t.Fatalf("uninvited signup body %q, want the refusal copy", res.Body) } // The Owner reaches the board and the members page. want(t, ada.get("/"), http.StatusOK, "owner GET /") page := ada.get("/members") want(t, page, http.StatusOK, "owner GET /members") if !strings.Contains(page.Body, "ada@example.test") { t.Fatalf("members page does not list the owner: %q", page.Body) } // Grantable for an Owner is Admin and Member, never Owner: // ownership moves only by Transfer. if !strings.Contains(page.Body, `