package idear_test import ( "context" "database/sql" "errors" "fmt" "net/http" "net/http/httptest" "net/url" "strconv" "strings" "sync" "testing" "github.com/carlosframework/rastrillo/password" "github.com/carlosframework/rastrillo/sessions" "gorm.io/gorm" "amadan.net/rastrillo/idear" "amadan.net/rastrillo/idear/internal/ideartest" ) // ------------------------------------------------------- the app half // users stands in for the app's own user table — the thing idear does // NOT own. It records every call, so a test can assert the strongest // property a refusal has: that no app user was created at all. // // Ids start at 42 rather than 1 on purpose. Member row ids also start // at 1, so a Subject built from the wrong integer would still match by // luck in every test if both sequences agreed. type users struct { mu sync.Mutex next int64 byEmail map[string]int64 calls []string fail error // when set, Create fails the way a duplicate would } func newUsers() *users { return &users{next: 41, byEmail: map[string]int64{}} } func (u *users) create(ctx context.Context, email, hash string) (int64, error) { u.mu.Lock() defer u.mu.Unlock() u.calls = append(u.calls, email) if u.fail != nil { return 0, u.fail } if _, dup := u.byEmail[email]; dup { return 0, errors.New("users: that email is already registered") } u.next++ u.byEmail[email] = u.next return u.next, nil } func (u *users) lookup(ctx context.Context, email string) (int64, string, error) { return 0, "", sql.ErrNoRows } func (u *users) created() []string { u.mu.Lock() defer u.mu.Unlock() return append([]string(nil), u.calls...) } // ------------------------------------------------------- the driver // attempt runs ONE signup the way a mounted app does: an HTTP POST // carrying the form, through CarryToken when carry is true, into a // handler that calls the admitted Create with r.Context() and nothing // else — because r.Context() is all password.Signup hands it. // // The email is lowercased and trimmed first, exactly as // password.Signup does before calling Create, so these tests exercise // the string admission will really see in a correctly-wired app. func attempt(t *testing.T, rs *idear.Roster, u *users, form url.Values, carry bool) (int64, error) { t.Helper() return runSignup(t, rs, u, form, carry, func(typed string) string { return strings.ToLower(strings.TrimSpace(typed)) }) } // attemptAsTyped is attempt with password's own normalisation REMOVED, // handing admission the address exactly as it came off the form. // // It exists because admission must not depend on its caller for that. // password/handlers.go's normalizeEmail happens to do the same folding // today, so nothing is broken — but Admitting's contract says it // normalises the submitted address, and a contract nothing exercises // is a comment. The next Create wrapper, or a password release that // stops folding, would find out in production. func attemptAsTyped(t *testing.T, rs *idear.Roster, u *users, form url.Values, carry bool) (int64, error) { t.Helper() return runSignup(t, rs, u, form, carry, func(typed string) string { return typed }) } func runSignup(t *testing.T, rs *idear.Roster, u *users, form url.Values, carry bool, prepare func(string) string) (int64, error) { t.Helper() admitted := rs.Admitting(u.create) var ( id int64 err error ) inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if perr := r.ParseForm(); perr != nil { t.Fatalf("parsing the posted form: %v", perr) } id, err = admitted(r.Context(), prepare(r.FormValue("email")), "a-password-hash") }) var h http.Handler = inner if carry { h = rs.CarryToken(inner) } req := httptest.NewRequest(http.MethodPost, "/signup", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") h.ServeHTTP(httptest.NewRecorder(), req) return id, err } func signupForm(email, token string) url.Values { v := url.Values{"email": {email}, "password": {"a-long-enough-password"}} if token != "" { v.Set("invite", token) } return v } // memberFor re-reads the roster row for an address, or nil. func memberFor(t *testing.T, h *ideartest.Harness, email string) *idear.Member { t.Helper() var out []idear.Member if err := h.DB.G.Where("email = ?", email).Find(&out).Error; err != nil { t.Fatalf("looking up %q: %v", email, err) } switch len(out) { case 0: return nil case 1: return &out[0] default: t.Fatalf("roster holds %d rows for %q, want at most 1", len(out), email) return nil } } func mustRefuse(t *testing.T, id int64, err error) { t.Helper() if err == nil { t.Fatalf("admission returned id %d and no error, want a refusal", id) } if !errors.Is(err, password.ErrRefused) { t.Fatalf("admission error %v does not wrap password.ErrRefused; password renders anything else as \"already registered\" at 422", err) } if id != 0 { t.Errorf("a refusal returned id %d, want 0", id) } } // invited seeds a claimed instance plus one pending invitation, and // returns the plaintext token. func invited(t *testing.T, h *ideartest.Harness, email string, role idear.Role) (*idear.Invitation, string) { t.Helper() owner := h.Owner() inv, token, err := h.Roster.Invite(h.Ctx(), owner, email, role) if err != nil { t.Fatalf("Invite(%q, %s): %v", email, role, err) } return inv, token } // ------------------------------------------------------ the tests // TestAdmittingRequiresTheToken is THE regression test of this task. // // An invitation exists for admin@corp.test. Someone who merely LEARNED // that address signs up as it, with no invite field at all. If // admission ever regresses to email-match-only, this signup succeeds at // RoleAdmin and this test is the one that goes red. // // password.Signup never verifies an address, so email-match admission // would hand the invited role to whoever registers the address first. func TestAdmittingRequiresTheToken(t *testing.T) { h := guardedHarness(t) invited(t, h, "admin@corp.test", idear.RoleAdmin) u := newUsers() id, err := attempt(t, h.Roster, u, signupForm("admin@corp.test", ""), true) mustRefuse(t, id, err) if got := memberFor(t, h, "admin@corp.test"); got != nil { t.Fatalf("an uninvited signup for an INVITED address created %+v; the token is the credential, not the address", got) } if calls := u.created(); len(calls) != 0 { t.Errorf("the app's Create ran %v for a refused signup; a refusal must cost no app user", calls) } } // TestAdmittingWithoutCarryTokenRefusesAnInvitedSignup pins the // failure mode of a mis-wired app. // // The form carries a perfectly valid token, but CarryToken is not // mounted, so the token never reaches the context and admission cannot // see it. The result must be a refusal — loud and safe — and never a // quiet fall-through that admits on the address alone. func TestAdmittingWithoutCarryTokenRefusesAnInvitedSignup(t *testing.T) { h := guardedHarness(t) _, token := invited(t, h, "admin@corp.test", idear.RoleAdmin) u := newUsers() id, err := attempt(t, h.Roster, u, signupForm("admin@corp.test", token), false) mustRefuse(t, id, err) if got := memberFor(t, h, "admin@corp.test"); got != nil { t.Fatalf("admission created %+v with CarryToken unmounted", got) } } // TestAdmittingAdmitsWithTheToken is the positive control the two // tests above need: without it they would both pass against an // implementation that refuses everything. func TestAdmittingAdmitsWithTheToken(t *testing.T) { h := guardedHarness(t) inv, token := invited(t, h, "admin@corp.test", idear.RoleAdmin) u := newUsers() id, err := attempt(t, h.Roster, u, signupForm("admin@corp.test", token), true) if err != nil { t.Fatalf("an invited signup holding its own token was refused: %v", err) } m := memberFor(t, h, "admin@corp.test") if m == nil { t.Fatal("admission succeeded but wrote no member row") } if m.Role != idear.RoleAdmin { t.Errorf("role = %s, want %s — the role comes from the invitation, never from the form", m.Role, idear.RoleAdmin) } // Subject is what password mints as the session subject: // strconv.FormatInt(id, 10) — see password/handlers.go's // signInAndRedirect. if want := strconv.FormatInt(id, 10); m.Subject != want { t.Errorf("Subject = %q, want %q; a Subject password never mints is a member row no session can ever resolve", m.Subject, want) } if got := h.Invitation(inv.ID); got.AcceptedAt == nil { t.Error("the invitation was not consumed; it must be single use") } // And it is single use: the same token cannot buy a second member. u2 := newUsers() id2, err2 := attempt(t, h.Roster, u2, signupForm("admin@corp.test", token), true) mustRefuse(t, id2, err2) } // TestAdmittingRejectsMismatchedEmail: possession of a token is not a // wildcard. The token is valid; the address is somebody else's. func TestAdmittingRejectsMismatchedEmail(t *testing.T) { h := guardedHarness(t) inv, token := invited(t, h, "admin@corp.test", idear.RoleAdmin) u := newUsers() id, err := attempt(t, h.Roster, u, signupForm("attacker@corp.test", token), true) mustRefuse(t, id, err) if got := memberFor(t, h, "attacker@corp.test"); got != nil { t.Fatalf("a stolen token admitted %+v", got) } if got := h.Invitation(inv.ID); got.AcceptedAt != nil { t.Error("the invitation was consumed by a signup it did not match") } } // TestAdmittingMatchesTheEmailAfterNormalisation is the other half of // the match rule: addresses are stored trimmed and lowercased, so an // invitation written as "Admin@Corp.Test" must still match the address // password hands over. func TestAdmittingMatchesTheEmailAfterNormalisation(t *testing.T) { h := guardedHarness(t) _, token := invited(t, h, " Admin@Corp.Test ", idear.RoleMember) u := newUsers() if _, err := attempt(t, h.Roster, u, signupForm("admin@corp.test", token), true); err != nil { t.Fatalf("a normalised address failed to match its own invitation: %v", err) } if memberFor(t, h, "admin@corp.test") == nil { t.Fatal("no member row for the admitted address") } } func TestAdmittingRejectsExpiredAndRevokedTokens(t *testing.T) { t.Run("expired", func(t *testing.T) { h := guardedHarness(t) inv, token := invited(t, h, "late@corp.test", idear.RoleAdmin) h.Expire(inv.ID) u := newUsers() id, err := attempt(t, h.Roster, u, signupForm("late@corp.test", token), true) mustRefuse(t, id, err) if got := memberFor(t, h, "late@corp.test"); got != nil { t.Fatalf("an expired invitation admitted %+v", got) } }) t.Run("revoked", func(t *testing.T) { h := guardedHarness(t) inv, token := invited(t, h, "gone@corp.test", idear.RoleAdmin) if err := h.Roster.Revoke(h.Ctx(), h.TheOwner(), inv.ID); err != nil { t.Fatalf("Revoke: %v", err) } u := newUsers() id, err := attempt(t, h.Roster, u, signupForm("gone@corp.test", token), true) mustRefuse(t, id, err) if got := memberFor(t, h, "gone@corp.test"); got != nil { t.Fatalf("a revoked invitation admitted %+v", got) } }) } func TestAdmittingClaimsFirstAccountAsOwner(t *testing.T) { h := guardedHarness(t) if n := h.CountMembers(); n != 0 { t.Fatalf("the roster starts with %d rows, want 0", n) } u := newUsers() id, err := attempt(t, h.Roster, u, signupForm("first@corp.test", ""), true) if err != nil { t.Fatalf("the first signup into an empty roster was refused: %v", err) } owner := h.TheOwner() if owner.Email != "first@corp.test" { t.Errorf("owner email = %q, want first@corp.test", owner.Email) } if want := strconv.FormatInt(id, 10); owner.Subject != want { t.Errorf("Subject = %q, want %q", owner.Subject, want) } // The claim closes behind them: the second arrival is refused, // because the roster is no longer empty and they hold no token. u2 := newUsers() id2, err2 := attempt(t, h.Roster, u2, signupForm("second@corp.test", ""), true) mustRefuse(t, id2, err2) if n := h.CountMembers(); n != 1 { t.Errorf("roster has %d rows after a refused second signup, want 1", n) } } func TestAdmittingOpenSignUpJoinsAsMember(t *testing.T) { h := ideartest.NewWith(t, idear.Config{OpenSignUp: true}) h.Owner() // the instance is already claimed u := newUsers() id, err := attempt(t, h.Roster, u, signupForm("anyone@corp.test", ""), true) if err != nil { t.Fatalf("open sign-up refused an uninvited address: %v", err) } m := memberFor(t, h, "anyone@corp.test") if m == nil { t.Fatal("open sign-up wrote no member row") } if m.Role != idear.RoleMember { t.Errorf("role = %s, want %s", m.Role, idear.RoleMember) } if want := strconv.FormatInt(id, 10); m.Subject != want { t.Errorf("Subject = %q, want %q", m.Subject, want) } } // TestAdmittingOpenSignUpDoesNotHonourAMismatchedToken: an open // instance still refuses to read a role off somebody else's // invitation. Falling through to RoleMember is the whole point of the // ordering — falling through to the invitation's role would be an // escalation available to anyone who found a link. func TestAdmittingOpenSignUpDoesNotHonourAMismatchedToken(t *testing.T) { h := ideartest.NewWith(t, idear.Config{OpenSignUp: true}) owner := h.Owner() _, token, err := h.Roster.Invite(h.Ctx(), owner, "admin@corp.test", idear.RoleAdmin) if err != nil { t.Fatalf("Invite: %v", err) } u := newUsers() if _, err := attempt(t, h.Roster, u, signupForm("attacker@corp.test", token), true); err != nil { t.Fatalf("open sign-up refused: %v", err) } m := memberFor(t, h, "attacker@corp.test") if m == nil { t.Fatal("open sign-up wrote no member row") } if m.Role != idear.RoleMember { t.Fatalf("role = %s, want %s; a token for another address must not set the role", m.Role, idear.RoleMember) } } // TestAdmittingRefusalUsesConstantCopy: the 403 password renders is a // distinguishable outcome, so its COPY must not make it a finer one. // One string for every refused address, never interpolating the // address. func TestAdmittingRefusalUsesConstantCopy(t *testing.T) { h := guardedHarness(t) invited(t, h, "admin@corp.test", idear.RoleAdmin) const ( one = "admin@corp.test" // invited, but holding no token two = "stranger@corp.test" // never invited at all ) u := newUsers() _, err1 := attempt(t, h.Roster, u, signupForm(one, ""), true) _, err2 := attempt(t, h.Roster, u, signupForm(two, ""), true) mustRefuse(t, 0, err1) mustRefuse(t, 0, err2) // password renders the *refusal's own message, so that is the // string a visitor reads. m1, m2 := err1.Error(), err2.Error() if m1 != m2 { t.Fatalf("refusal copy differs: %q vs %q; two refused addresses must read identically", m1, m2) } for _, addr := range []string{one, two, "corp.test"} { if strings.Contains(m1, addr) { t.Errorf("refusal copy %q contains %q; an interpolated address turns the 403 into an oracle", m1, addr) } } if m1 == "" { t.Error("refusal copy is empty; password would fall back to its own generic string") } } // TestAdmittingSubjectMatchesThePasswordSession is the evidence, not // the assertion: it runs the REAL password.Signup over a real sessions // core and checks that the session it mints resolves, through // idear.Require, to the member row admission wrote. Reading // strconv.FormatInt(id, 10) out of password/handlers.go proves what // the code says today; this proves the two agree. func TestAdmittingSubjectMatchesThePasswordSession(t *testing.T) { h := ideartest.NewWith(t, idear.Config{OpenSignUp: true}) sess, err := sessions.New(sessions.Config{DB: h.DB.Writer(), Origin: "http://app.test"}) if err != nil { t.Fatalf("sessions.New: %v", err) } u := newUsers() render := func(w http.ResponseWriter, r *http.Request, d password.PageData) { fmt.Fprintf(w, "form: %s", d.Error) } ph, err := password.New(password.Config{ Sessions: sess, Lookup: u.lookup, Create: h.Roster.Admitting(u.create), RenderSignin: render, RenderSignup: render, }) if err != nil { t.Fatalf("password.New: %v", err) } var seen struct{ session, member, role string } mux := http.NewServeMux() mux.Handle("POST /signup", h.Roster.CarryToken(http.HandlerFunc(ph.Signup))) mux.Handle("GET /whoami", sess.Middleware(h.Roster.Require(http.HandlerFunc( func(w http.ResponseWriter, r *http.Request) { s, _ := sessions.Current(r) m := idear.From(r) seen.session, seen.member, seen.role = s.Subject, m.Subject, string(m.Role) })))) form := signupForm("first@corp.test", "") req := httptest.NewRequest(http.MethodPost, "http://app.test/signup", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") w := httptest.NewRecorder() mux.ServeHTTP(w, req) if w.Code != http.StatusSeeOther { t.Fatalf("signup status = %d, want 303; body %q", w.Code, w.Body.String()) } cookies := w.Result().Cookies() if len(cookies) == 0 { t.Fatal("signup minted no session cookie") } who := httptest.NewRequest(http.MethodGet, "http://app.test/whoami", nil) for _, c := range cookies { who.AddCookie(c) } w2 := httptest.NewRecorder() mux.ServeHTTP(w2, who) if w2.Code != http.StatusOK { t.Fatalf("whoami status = %d, want 200; the minted session did not resolve to a member", w2.Code) } if seen.session != seen.member { t.Errorf("session Subject %q != member Subject %q", seen.session, seen.member) } if want := strconv.FormatInt(u.byEmail["first@corp.test"], 10); seen.member != want { t.Errorf("member Subject = %q, want %q (the app id password formats)", seen.member, want) } if seen.role != string(idear.RoleOwner) { t.Errorf("role = %q, want owner: the first account through the real handler claims the instance", seen.role) } } // ---------------------------------------------------------- Authorize // TestAuthorizeAdmitsInvitedAddressOnce: under keymail the address IS // verified, so a pending invitation for it is the credential — and it // is spent exactly once. Deactivating the member and asking again must // be false: the consumed invitation cannot readmit them (readmission // is Reactivate's job), and nothing may quietly mint a second row. func TestAuthorizeAdmitsInvitedAddressOnce(t *testing.T) { h := guardedHarness(t) inv, _ := invited(t, h, "new@corp.test", idear.RoleMember) if !h.Roster.Authorize("new@corp.test") { t.Fatal("Authorize refused an address holding a pending invitation") } m := memberFor(t, h, "new@corp.test") if m == nil { t.Fatal("Authorize admitted the address but wrote no member row") } if m.Subject != "new@corp.test" { t.Errorf("Subject = %q, want the address itself — keymail's session subject IS the verified address", m.Subject) } if m.Role != idear.RoleMember { t.Errorf("role = %s, want %s", m.Role, idear.RoleMember) } spent := h.Invitation(inv.ID) if spent.AcceptedAt == nil { t.Fatal("the invitation was not consumed") } // An active member is admitted again without touching an // invitation at all. if !h.Roster.Authorize("new@corp.test") { t.Fatal("Authorize refused an active member") } // Now remove them. The invitation is spent, so there is nothing // left to readmit on. if err := h.Roster.Deactivate(h.Ctx(), h.TheOwner(), m); err != nil { t.Fatalf("Deactivate: %v", err) } if h.Roster.Authorize("new@corp.test") { t.Fatal("Authorize admitted a deactivated member; a consumed invitation must not readmit them") } if n := h.CountMembers(); n != 2 { t.Errorf("roster has %d rows, want 2 (the owner and the deactivated member)", n) } if again := h.Invitation(inv.ID); !again.AcceptedAt.Equal(*spent.AcceptedAt) { t.Error("the invitation was consumed a second time") } } func TestAuthorizeClaimsAnEmptyRoster(t *testing.T) { h := guardedHarness(t) if !h.Roster.Authorize("first@corp.test") { t.Fatal("Authorize refused the first arrival into an empty roster") } owner := h.TheOwner() if owner.Subject != "first@corp.test" || owner.Role != idear.RoleOwner { t.Errorf("claimed %+v, want the address as Subject at owner", owner) } // And the claim is closed behind them. if h.Roster.Authorize("second@corp.test") { t.Fatal("Authorize claimed a second owner") } if n := h.CountMembers(); n != 1 { t.Errorf("roster has %d rows, want 1", n) } } func TestAuthorizeRefusesAStranger(t *testing.T) { h := guardedHarness(t) h.Owner() if h.Roster.Authorize("stranger@corp.test") { t.Fatal("Authorize admitted an address with no member row and no invitation") } if got := memberFor(t, h, "stranger@corp.test"); got != nil { t.Fatalf("a refused address left %+v behind", got) } } // TestAuthorizeNormalisesTheAddress: auth hands over whatever the // visitor typed into the magic-link form. Stored addresses are trimmed // and lowercased, so an untrimmed one must still resolve — otherwise a // member is locked out by their own capitalisation. func TestAuthorizeNormalisesTheAddress(t *testing.T) { h := guardedHarness(t) h.Owner() h.MemberAs("member@corp.test", "member@corp.test", "", idear.RoleMember) if !h.Roster.Authorize(" Member@Corp.Test ") { t.Fatal("Authorize refused an active member whose address arrived unnormalised") } if h.Roster.Authorize("") { t.Fatal("Authorize admitted an empty address") } } // TestAuthorizeRejectsExpiredAndRevokedInvitations: the keymail path // admits on a verified address, so the invitation's own liveness is // the ONLY thing standing between a withdrawn offer and a session. func TestAuthorizeRejectsExpiredAndRevokedInvitations(t *testing.T) { t.Run("expired", func(t *testing.T) { h := guardedHarness(t) inv, _ := invited(t, h, "late@corp.test", idear.RoleAdmin) h.Expire(inv.ID) if h.Roster.Authorize("late@corp.test") { t.Fatal("Authorize admitted an expired invitation") } if got := memberFor(t, h, "late@corp.test"); got != nil { t.Fatalf("an expired invitation wrote %+v", got) } if got := h.Invitation(inv.ID); got.AcceptedAt != nil { t.Error("an expired invitation was marked accepted") } }) t.Run("revoked", func(t *testing.T) { h := guardedHarness(t) inv, _ := invited(t, h, "gone@corp.test", idear.RoleAdmin) if err := h.Roster.Revoke(h.Ctx(), h.TheOwner(), inv.ID); err != nil { t.Fatalf("Revoke: %v", err) } if h.Roster.Authorize("gone@corp.test") { t.Fatal("Authorize admitted a revoked invitation") } if got := memberFor(t, h, "gone@corp.test"); got != nil { t.Fatalf("a revoked invitation wrote %+v", got) } if got := h.Invitation(inv.ID); got.AcceptedAt != nil { t.Error("a revoked invitation was marked accepted") } }) } // TestConcurrentAuthorizeClaimsOneOwner races the keymail path's own // claim. Authorize has no error channel and swallows ErrOwnerExists to // carry on to the invitation check, so the loser must come out as a // plain refusal — and the roster must come out with exactly ONE owner, // not two. // // Twenty fresh instances, because a single scheduling of a race that // passes proves only that one scheduling passed. func TestConcurrentAuthorizeClaimsOneOwner(t *testing.T) { for i := 0; i < 20; i++ { h := ideartest.New(t) const racers = 6 var ( wg sync.WaitGroup mu sync.Mutex admitted []string ) start := make(chan struct{}) for j := 0; j < racers; j++ { addr := fmt.Sprintf("racer-%d@corp.test", j) wg.Add(1) go func() { defer wg.Done() <-start if h.Roster.Authorize(addr) { mu.Lock() admitted = append(admitted, addr) mu.Unlock() } }() } close(start) wg.Wait() if len(admitted) != 1 { t.Fatalf("round %d: %d racers were admitted (%v), want exactly 1", i, len(admitted), admitted) } owner := h.TheOwner() if owner.Subject != admitted[0] { t.Fatalf("round %d: the owner is %q but %q was the admitted racer", i, owner.Subject, admitted[0]) } if n := h.CountMembers(); n != 1 { t.Fatalf("round %d: roster has %d rows, want 1", i, n) } } } // TestAdmittingOpenSignUpStillHonoursAMatchingToken pins the ORDER of // rules 2 and 3, which is invisible until an instance is open. // // An open instance admits everyone at RoleMember, so it is tempting to // answer that first and skip the invitation lookup entirely. Doing so // silently demotes every invited Admin to Member on the day someone // flips OpenSignUp on, and leaves their invitation pending — a live // credential for a role its holder was told they already had. func TestAdmittingOpenSignUpStillHonoursAMatchingToken(t *testing.T) { h := ideartest.NewWith(t, idear.Config{OpenSignUp: true}) owner := h.Owner() inv, token, err := h.Roster.Invite(h.Ctx(), owner, "admin@corp.test", idear.RoleAdmin) if err != nil { t.Fatalf("Invite: %v", err) } u := newUsers() if _, err := attempt(t, h.Roster, u, signupForm("admin@corp.test", token), true); err != nil { t.Fatalf("an invited signup into an open instance was refused: %v", err) } m := memberFor(t, h, "admin@corp.test") if m == nil { t.Fatal("no member row") } if m.Role != idear.RoleAdmin { t.Errorf("role = %s, want %s: the invitation is checked BEFORE open sign-up", m.Role, idear.RoleAdmin) } if got := h.Invitation(inv.ID); got.AcceptedAt == nil { t.Error("the invitation was left pending by a signup that redeemed it") } } // ------------------------------------------ the keymail round trip // TestAuthorizeRoundTripsTheSubjectAuthMints is the keymail twin of // TestAdmittingSubjectMatchesThePasswordSession, and it pins the // failure that broke this task's first round. // // rastrillo/auth mints the session as // sessions.Session{Subject: id.Address} (auth/handlers.go's admit), // and Identity.Address is the address THE VISITOR TYPED: // keymaildev/signin's SplitAddress lowercases only the domain and // deliberately keeps the local part's case, and flow.go stores the raw // typed string. An iOS keyboard capitalises the first letter of an // email field by default, so "Alice@Corp.Test" is an ordinary thing to // receive, not an edge case. // // If Authorize writes a Subject that the session auth then mints // cannot resolve, the first arrival claims the instance, signs in // successfully forever, and 404s on every guarded route forever — // /members included, so they can never invite anyone. The claim is // spent, everyone else is refused, and the instance is dead. // // TestAuthorizeNormalisesTheAddress does NOT cover this: it only asks // whether an unnormalised address finds an ALREADY-SEEDED lowercase // row. This asks whether the row Authorize ITSELF writes can be found // by the subject auth itself would mint. func TestAuthorizeRoundTripsTheSubjectAuthMints(t *testing.T) { const typed = "Alice@Corp.Test" // exactly what auth passes on h := guardedHarness(t) if !h.Roster.Authorize(typed) { t.Fatal("Authorize refused the first arrival into an empty roster") } // The session auth mints carries the typed address verbatim. var s spy w := as(typed, h.Roster.Require(s.handler())) if w.Code != http.StatusOK { t.Fatalf("Require answered %d for the very session auth would mint after this Authorize; the instance is bricked", w.Code) } if s.member == nil || s.member.Role != idear.RoleOwner { t.Fatalf("From = %+v, want the owner", s.member) } // And the same human typing it differently later is the SAME row, // not a second one — which is why both sides are folded rather // than the raw string being stored. var s2 spy if got := as("alice@corp.test", h.Roster.Require(s2.handler())); got.Code != http.StatusOK { t.Errorf("the same address in lower case answered %d, want 200", got.Code) } if s2.member == nil || s.member.ID != s2.member.ID { t.Error("two spellings of one address resolved to different member rows") } if n := h.CountMembers(); n != 1 { t.Errorf("roster has %d rows for one person, want 1", n) } } // TestAuthorizeRoundTripsAnInvitedSubject is the same round trip on // the invitation path, where the Member is written by // acceptByAddress rather than by Claim. func TestAuthorizeRoundTripsAnInvitedSubject(t *testing.T) { h := guardedHarness(t) invited(t, h, "Bob@Corp.Test", idear.RoleAdmin) const typed = "Bob@Corp.Test" if !h.Roster.Authorize(typed) { t.Fatal("Authorize refused an invited address") } var s spy w := as(typed, h.Roster.Require(s.handler())) if w.Code != http.StatusOK { t.Fatalf("Require answered %d for the session auth would mint; the invited member is locked out", w.Code) } if s.member == nil || s.member.Role != idear.RoleAdmin { t.Fatalf("From = %+v, want an admin", s.member) } } // ---------------------------------------------------- fault injection // breakRoster drops the members table out from under the roster. It is // the bluntest possible storage failure and the only one that needs no // hook into gorm: every query idear makes about membership now errors. func breakRoster(t *testing.T, h *ideartest.Harness) { t.Helper() if err := h.DB.G.Exec("DROP TABLE idear_members").Error; err != nil { t.Fatalf("dropping idear_members: %v", err) } } // TestAuthorizeRefusesWhenTheStoreIsBroken: Authorize returns a bool // with no error channel, so "a database failure can never come back // true" is a property only a test can hold. Every early return in it // is a `return false`, and this is what stops one of them becoming a // `return true` in a later refactor. func TestAuthorizeRefusesWhenTheStoreIsBroken(t *testing.T) { h := guardedHarness(t) member := h.Owner() breakRoster(t, h) for _, addr := range []string{member.Email, "stranger@corp.test", ""} { if h.Roster.Authorize(addr) { t.Errorf("Authorize(%q) returned true against a broken store", addr) } } } // TestAdmittingRefusesWhenTheStoreIsBroken: admission's FIRST store // call is IsEmpty, so a broken roster is what proves that branch // refuses. It must also not reach the app's Create — a signup that // creates a user and then cannot write a member is the orphan this // design goes out of its way to avoid manufacturing. func TestAdmittingRefusesWhenTheStoreIsBroken(t *testing.T) { h := guardedHarness(t) h.Owner() breakRoster(t, h) u := newUsers() id, err := attempt(t, h.Roster, u, signupForm("anyone@corp.test", ""), true) if err == nil { t.Fatalf("admission returned id %d and no error against a broken store", id) } if id != 0 { t.Errorf("admission returned id %d, want 0", id) } if errors.Is(err, password.ErrRefused) { t.Error("a storage failure was reported as a policy refusal; it must stay an error so password logs it") } if calls := u.created(); len(calls) != 0 { t.Errorf("the app's Create ran %v before the roster was known to be writable; that manufactures an orphan", calls) } } // ------------------------------------- admission's own normalisation // TestAdmittingNormalisesTheSubmittedAddress hands admission the // address EXACTLY as it came off the form, with password's own // folding removed. // // Nothing is broken today — password/handlers.go normalises identically // before calling Create — but Admitting's doc comment claims it // normalises both sides of the email match, and until now every test // pre-folded the address in the harness, so the claim was never // exercised. Admission must not depend on its caller for the property // its own security rule rests on. func TestAdmittingNormalisesTheSubmittedAddress(t *testing.T) { h := guardedHarness(t) inv, token := invited(t, h, "admin@corp.test", idear.RoleAdmin) u := newUsers() id, err := attemptAsTyped(t, h.Roster, u, signupForm(" Admin@Corp.Test ", token), true) if err != nil { t.Fatalf("an invited signup was refused because of how it was typed: %v", err) } m := memberFor(t, h, "admin@corp.test") if m == nil { t.Fatal("no member row at the normalised address; the row was written under the typed spelling") } if m.Role != idear.RoleAdmin { t.Errorf("role = %s, want %s", m.Role, idear.RoleAdmin) } if want := strconv.FormatInt(id, 10); m.Subject != want { t.Errorf("Subject = %q, want %q", m.Subject, want) } if got := h.Invitation(inv.ID); got.AcceptedAt == nil { t.Error("the invitation was not consumed") } } // TestAdmittingRefusesAnUnnormalisedUninvitedAddress is the other half: // folding the address must not accidentally admit anyone. A typed // address that matches no invitation is refused however it is spelled. func TestAdmittingRefusesAnUnnormalisedUninvitedAddress(t *testing.T) { h := guardedHarness(t) invited(t, h, "admin@corp.test", idear.RoleAdmin) u := newUsers() id, err := attemptAsTyped(t, h.Roster, u, signupForm(" Admin@Corp.Test ", ""), true) mustRefuse(t, id, err) if got := memberFor(t, h, "admin@corp.test"); got != nil { t.Fatalf("a token-less signup for an invited address admitted %+v", got) } } // -------------------------------------------------------- the wiring // TestAdmittingRefusesWithNoCreateFunction: Admitting(nil) is a wiring // bug, and it must fail closed as a STORAGE error rather than as a // policy refusal — a visitor must never be told they are not invited // because the app forgot to pass its own Create. func TestAdmittingRefusesWithNoCreateFunction(t *testing.T) { h := guardedHarness(t) h.Owner() id, err := h.Roster.Admitting(nil)(context.Background(), "anyone@corp.test", "hash") if err == nil { t.Fatalf("Admitting(nil) returned id %d and no error", id) } if id != 0 { t.Errorf("Admitting(nil) returned id %d, want 0", id) } if errors.Is(err, password.ErrRefused) { t.Error("a nil Create was reported to the visitor as a policy refusal") } if n := h.CountMembers(); n != 1 { t.Errorf("roster has %d rows, want 1: nothing may be written", n) } } // TestCarryTokenIgnoresTheQueryString: the invitation token is a live // credential. Read from the query string it would ride in the URL, and // from there into access logs, browser history, and the Referer header // of every asset the signup page loads — leaking the credential to // third parties who were never sent it. // // r.PostFormValue reads the body only; r.FormValue would accept both, // and the two are one keystroke apart. func TestCarryTokenIgnoresTheQueryString(t *testing.T) { h := guardedHarness(t) _, token := invited(t, h, "admin@corp.test", idear.RoleAdmin) u := newUsers() admitted := h.Roster.Admitting(u.create) var ( id int64 err error ) inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { id, err = admitted(r.Context(), "admin@corp.test", "hash") }) // The token is in the URL and NOWHERE else. form := url.Values{"email": {"admin@corp.test"}, "password": {"a-long-enough-password"}} req := httptest.NewRequest(http.MethodPost, "/signup?invite="+url.QueryEscape(token), strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") h.Roster.CarryToken(inner).ServeHTTP(httptest.NewRecorder(), req) mustRefuse(t, id, err) if got := memberFor(t, h, "admin@corp.test"); got != nil { t.Fatalf("a token from the query string admitted %+v", got) } } // breakCounting fails every COUNT this roster issues, and nothing // else. // // It exists because DROP TABLE is too blunt to reach one branch: with // the table gone, Authorize's BySubject fails FIRST and returns before // the roster is ever counted, so the IsEmpty failure path is // unreachable by that fault. This one discriminates on the // destination gorm is scanning into — Count's is *int64, and no other // query idear makes has that shape — so a lookup still cleanly MISSES // while the count fails. That is the exact state the branch needs. func breakCounting(t *testing.T, h *ideartest.Harness) { t.Helper() err := h.DB.G.Callback().Query().Before("gorm:query"). Register("ideartest:break_counting", func(tx *gorm.DB) { if _, counting := tx.Statement.Dest.(*int64); counting { tx.AddError(errors.New("ideartest: injected count failure")) } }) if err != nil { t.Fatalf("registering the count fault: %v", err) } } // TestAuthorizeRefusesWhenCountingFails covers the branch DROP TABLE // cannot reach: the address resolves to no member (a clean miss), and // then the roster cannot be counted. // // Getting this wrong is the worst answer in the package. "Is the // roster empty" failing OPEN means an arbitrary stranger is handed // Owner of a populated instance the moment the database hiccups. func TestAuthorizeRefusesWhenCountingFails(t *testing.T) { h := guardedHarness(t) h.Owner() breakCounting(t, h) if h.Roster.Authorize("stranger@corp.test") { t.Fatal("Authorize returned true when it could not tell whether the roster was empty") } // Asserted through Members and Owners, not the harness's // CountMembers: the injected fault breaks every COUNT in this // process, the test's own included. rows, err := h.Roster.Members(h.Ctx()) if err != nil { t.Fatalf("listing members: %v", err) } if len(rows) != 1 { t.Errorf("roster has %d rows, want 1: nothing may have been written", len(rows)) } if owner := h.TheOwner(); owner.Email == "stranger@corp.test" { t.Fatal("a failed count handed ownership to a stranger") } } // TestSubjectIsCanonicalWhicheverSpellingWritesIt pins the WRITE half // of the Subject pair, which Authorize alone cannot pin because it // lowercases the address before it ever reaches the store. // // The caller that will hand the store a raw typed Subject is Task 5's // reconciliation route: POST /invitations/{token} writes a Member from // the LIVE session Subject, which under keymail is the address as the // visitor typed it. Without canonicalisation on the write, that route // recreates the same lockout on a different day — a member row nothing // can look up, or one human holding two rows past a unique index that // cannot see they are the same person. func TestSubjectIsCanonicalWhicheverSpellingWritesIt(t *testing.T) { t.Run("Claim", func(t *testing.T) { h := guardedHarness(t) m, err := h.Roster.Claim(h.Ctx(), " Alice@Corp.Test ", "Alice@Corp.Test", "Alice") if err != nil { t.Fatalf("Claim: %v", err) } if m.Subject != "alice@corp.test" { t.Errorf("stored Subject = %q, want the canonical form", m.Subject) } for _, spelling := range []string{"Alice@Corp.Test", "alice@corp.test", " ALICE@CORP.TEST "} { if _, err := h.Roster.BySubject(h.Ctx(), spelling); err != nil { t.Errorf("BySubject(%q) = %v, want the row Claim just wrote", spelling, err) } } }) t.Run("Accept", func(t *testing.T) { h := guardedHarness(t) _, token := invited(t, h, "bob@corp.test", idear.RoleAdmin) m, err := h.Roster.Accept(h.Ctx(), token, "Bob@Corp.Test", "Bob") if err != nil { t.Fatalf("Accept: %v", err) } if m.Subject != "bob@corp.test" { t.Errorf("stored Subject = %q, want the canonical form", m.Subject) } if _, err := h.Roster.BySubject(h.Ctx(), "Bob@Corp.Test"); err != nil { t.Errorf("BySubject(typed) = %v, want the row Accept just wrote", err) } }) } // TestTokenFromReadsWhatCarryTokenStashed pins the reader every app's // RenderSignup uses to re-seed the hidden invite field after a failed // signup — the workaround for password.PageData having nowhere to // carry a token. // // The three cases are the three states an app can be in: mounted // (the token comes back), NOT mounted (empty, and deliberately so — // a body re-read here would hide the one misconfiguration that closes // the instance), and mounted with nothing posted (empty). func TestTokenFromReadsWhatCarryTokenStashed(t *testing.T) { h := ideartest.New(t) rs := h.Roster post := func(form url.Values) *http.Request { req := httptest.NewRequest(http.MethodPost, "/signup", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") return req } for _, tc := range []struct { name string form url.Values carry bool want string }{ {"carried", url.Values{"invite": {"a-token"}}, true, "a-token"}, {"not mounted", url.Values{"invite": {"a-token"}}, false, ""}, {"nothing posted", url.Values{"email": {"who@corp.test"}}, true, ""}, } { t.Run(tc.name, func(t *testing.T) { var got string inner := http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { got = idear.TokenFrom(r) }) var handler http.Handler = inner if tc.carry { handler = rs.CarryToken(inner) } handler.ServeHTTP(httptest.NewRecorder(), post(tc.form)) if got != tc.want { t.Errorf("TokenFrom = %q, want %q", got, tc.want) } }) } } // TestTokenFromIgnoresTheQueryString is TestCarryTokenIgnoresTheQuery // String's assertion at the reader: a token in the URL must not reach // a re-rendered signup form either, or the page would hand back a // credential that leaked through access logs and Referer headers as a // value the next POST treats as carried. func TestTokenFromIgnoresTheQueryString(t *testing.T) { h := ideartest.New(t) var got string inner := http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { got = idear.TokenFrom(r) }) req := httptest.NewRequest(http.MethodPost, "/signup?invite=from-the-url", strings.NewReader(url.Values{"email": {"who@corp.test"}}.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") h.Roster.CarryToken(inner).ServeHTTP(httptest.NewRecorder(), req) if got != "" { t.Errorf("TokenFrom = %q from the query string, want %q", got, "") } }