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, `