rastrillo / idear Public

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

Plain git — no account needed to clone.

Download

Download this file

1package idear_test
2
3import (
4 "context"
5 "database/sql"
6 "errors"
7 "fmt"
8 "net/http"
9 "net/http/httptest"
10 "net/url"
11 "strconv"
12 "strings"
13 "sync"
14 "testing"
15
16 "github.com/carlosframework/rastrillo/password"
17 "github.com/carlosframework/rastrillo/sessions"
18 "gorm.io/gorm"
19
20 "amadan.net/rastrillo/idear"
21 "amadan.net/rastrillo/idear/internal/ideartest"
22)
23
24// ------------------------------------------------------- the app half
25
26// users stands in for the app's own user table — the thing idear does
27// NOT own. It records every call, so a test can assert the strongest
28// property a refusal has: that no app user was created at all.
29//
30// Ids start at 42 rather than 1 on purpose. Member row ids also start
31// at 1, so a Subject built from the wrong integer would still match by
32// luck in every test if both sequences agreed.
33type users struct {
34 mu sync.Mutex
35 next int64
36 byEmail map[string]int64
37 calls []string
38 fail error // when set, Create fails the way a duplicate would
39}
40
41func newUsers() *users { return &users{next: 41, byEmail: map[string]int64{}} }
42
43func (u *users) create(ctx context.Context, email, hash string) (int64, error) {
44 u.mu.Lock()
45 defer u.mu.Unlock()
46 u.calls = append(u.calls, email)
47 if u.fail != nil {
48 return 0, u.fail
49 }
50 if _, dup := u.byEmail[email]; dup {
51 return 0, errors.New("users: that email is already registered")
52 }
53 u.next++
54 u.byEmail[email] = u.next
55 return u.next, nil
56}
57
58func (u *users) lookup(ctx context.Context, email string) (int64, string, error) {
59 return 0, "", sql.ErrNoRows
60}
61
62func (u *users) created() []string {
63 u.mu.Lock()
64 defer u.mu.Unlock()
65 return append([]string(nil), u.calls...)
66}
67
68// ------------------------------------------------------- the driver
69
70// attempt runs ONE signup the way a mounted app does: an HTTP POST
71// carrying the form, through CarryToken when carry is true, into a
72// handler that calls the admitted Create with r.Context() and nothing
73// else — because r.Context() is all password.Signup hands it.
74//
75// The email is lowercased and trimmed first, exactly as
76// password.Signup does before calling Create, so these tests exercise
77// the string admission will really see in a correctly-wired app.
78func attempt(t *testing.T, rs *idear.Roster, u *users, form url.Values, carry bool) (int64, error) {
79 t.Helper()
80 return runSignup(t, rs, u, form, carry, func(typed string) string {
81 return strings.ToLower(strings.TrimSpace(typed))
82 })
83}
84
85// attemptAsTyped is attempt with password's own normalisation REMOVED,
86// handing admission the address exactly as it came off the form.
87//
88// It exists because admission must not depend on its caller for that.
89// password/handlers.go's normalizeEmail happens to do the same folding
90// today, so nothing is broken — but Admitting's contract says it
91// normalises the submitted address, and a contract nothing exercises
92// is a comment. The next Create wrapper, or a password release that
93// stops folding, would find out in production.
94func attemptAsTyped(t *testing.T, rs *idear.Roster, u *users, form url.Values, carry bool) (int64, error) {
95 t.Helper()
96 return runSignup(t, rs, u, form, carry, func(typed string) string { return typed })
97}
98
99func runSignup(t *testing.T, rs *idear.Roster, u *users, form url.Values, carry bool, prepare func(string) string) (int64, error) {
100 t.Helper()
101 admitted := rs.Admitting(u.create)
102
103 var (
104 id int64
105 err error
106 )
107 inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
108 if perr := r.ParseForm(); perr != nil {
109 t.Fatalf("parsing the posted form: %v", perr)
110 }
111 id, err = admitted(r.Context(), prepare(r.FormValue("email")), "a-password-hash")
112 })
113
114 var h http.Handler = inner
115 if carry {
116 h = rs.CarryToken(inner)
117 }
118
119 req := httptest.NewRequest(http.MethodPost, "/signup", strings.NewReader(form.Encode()))
120 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
121 h.ServeHTTP(httptest.NewRecorder(), req)
122 return id, err
123}
124
125func signupForm(email, token string) url.Values {
126 v := url.Values{"email": {email}, "password": {"a-long-enough-password"}}
127 if token != "" {
128 v.Set("invite", token)
129 }
130 return v
131}
132
133// memberFor re-reads the roster row for an address, or nil.
134func memberFor(t *testing.T, h *ideartest.Harness, email string) *idear.Member {
135 t.Helper()
136 var out []idear.Member
137 if err := h.DB.G.Where("email = ?", email).Find(&out).Error; err != nil {
138 t.Fatalf("looking up %q: %v", email, err)
139 }
140 switch len(out) {
141 case 0:
142 return nil
143 case 1:
144 return &out[0]
145 default:
146 t.Fatalf("roster holds %d rows for %q, want at most 1", len(out), email)
147 return nil
148 }
149}
150
151func mustRefuse(t *testing.T, id int64, err error) {
152 t.Helper()
153 if err == nil {
154 t.Fatalf("admission returned id %d and no error, want a refusal", id)
155 }
156 if !errors.Is(err, password.ErrRefused) {
157 t.Fatalf("admission error %v does not wrap password.ErrRefused; password renders anything else as \"already registered\" at 422", err)
158 }
159 if id != 0 {
160 t.Errorf("a refusal returned id %d, want 0", id)
161 }
162}
163
164// invited seeds a claimed instance plus one pending invitation, and
165// returns the plaintext token.
166func invited(t *testing.T, h *ideartest.Harness, email string, role idear.Role) (*idear.Invitation, string) {
167 t.Helper()
168 owner := h.Owner()
169 inv, token, err := h.Roster.Invite(h.Ctx(), owner, email, role)
170 if err != nil {
171 t.Fatalf("Invite(%q, %s): %v", email, role, err)
172 }
173 return inv, token
174}
175
176// ------------------------------------------------------ the tests
177
178// TestAdmittingRequiresTheToken is THE regression test of this task.
179//
180// An invitation exists for admin@corp.test. Someone who merely LEARNED
181// that address signs up as it, with no invite field at all. If
182// admission ever regresses to email-match-only, this signup succeeds at
183// RoleAdmin and this test is the one that goes red.
184//
185// password.Signup never verifies an address, so email-match admission
186// would hand the invited role to whoever registers the address first.
187func TestAdmittingRequiresTheToken(t *testing.T) {
188 h := guardedHarness(t)
189 invited(t, h, "admin@corp.test", idear.RoleAdmin)
190
191 u := newUsers()
192 id, err := attempt(t, h.Roster, u, signupForm("admin@corp.test", ""), true)
193
194 mustRefuse(t, id, err)
195 if got := memberFor(t, h, "admin@corp.test"); got != nil {
196 t.Fatalf("an uninvited signup for an INVITED address created %+v; the token is the credential, not the address", got)
197 }
198 if calls := u.created(); len(calls) != 0 {
199 t.Errorf("the app's Create ran %v for a refused signup; a refusal must cost no app user", calls)
200 }
201}
202
203// TestAdmittingWithoutCarryTokenRefusesAnInvitedSignup pins the
204// failure mode of a mis-wired app.
205//
206// The form carries a perfectly valid token, but CarryToken is not
207// mounted, so the token never reaches the context and admission cannot
208// see it. The result must be a refusal — loud and safe — and never a
209// quiet fall-through that admits on the address alone.
210func TestAdmittingWithoutCarryTokenRefusesAnInvitedSignup(t *testing.T) {
211 h := guardedHarness(t)
212 _, token := invited(t, h, "admin@corp.test", idear.RoleAdmin)
213
214 u := newUsers()
215 id, err := attempt(t, h.Roster, u, signupForm("admin@corp.test", token), false)
216
217 mustRefuse(t, id, err)
218 if got := memberFor(t, h, "admin@corp.test"); got != nil {
219 t.Fatalf("admission created %+v with CarryToken unmounted", got)
220 }
221}
222
223// TestAdmittingAdmitsWithTheToken is the positive control the two
224// tests above need: without it they would both pass against an
225// implementation that refuses everything.
226func TestAdmittingAdmitsWithTheToken(t *testing.T) {
227 h := guardedHarness(t)
228 inv, token := invited(t, h, "admin@corp.test", idear.RoleAdmin)
229
230 u := newUsers()
231 id, err := attempt(t, h.Roster, u, signupForm("admin@corp.test", token), true)
232 if err != nil {
233 t.Fatalf("an invited signup holding its own token was refused: %v", err)
234 }
235
236 m := memberFor(t, h, "admin@corp.test")
237 if m == nil {
238 t.Fatal("admission succeeded but wrote no member row")
239 }
240 if m.Role != idear.RoleAdmin {
241 t.Errorf("role = %s, want %s — the role comes from the invitation, never from the form", m.Role, idear.RoleAdmin)
242 }
243 // Subject is what password mints as the session subject:
244 // strconv.FormatInt(id, 10) — see password/handlers.go's
245 // signInAndRedirect.
246 if want := strconv.FormatInt(id, 10); m.Subject != want {
247 t.Errorf("Subject = %q, want %q; a Subject password never mints is a member row no session can ever resolve", m.Subject, want)
248 }
249 if got := h.Invitation(inv.ID); got.AcceptedAt == nil {
250 t.Error("the invitation was not consumed; it must be single use")
251 }
252
253 // And it is single use: the same token cannot buy a second member.
254 u2 := newUsers()
255 id2, err2 := attempt(t, h.Roster, u2, signupForm("admin@corp.test", token), true)
256 mustRefuse(t, id2, err2)
257}
258
259// TestAdmittingRejectsMismatchedEmail: possession of a token is not a
260// wildcard. The token is valid; the address is somebody else's.
261func TestAdmittingRejectsMismatchedEmail(t *testing.T) {
262 h := guardedHarness(t)
263 inv, token := invited(t, h, "admin@corp.test", idear.RoleAdmin)
264
265 u := newUsers()
266 id, err := attempt(t, h.Roster, u, signupForm("attacker@corp.test", token), true)
267
268 mustRefuse(t, id, err)
269 if got := memberFor(t, h, "attacker@corp.test"); got != nil {
270 t.Fatalf("a stolen token admitted %+v", got)
271 }
272 if got := h.Invitation(inv.ID); got.AcceptedAt != nil {
273 t.Error("the invitation was consumed by a signup it did not match")
274 }
275}
276
277// TestAdmittingMatchesTheEmailAfterNormalisation is the other half of
278// the match rule: addresses are stored trimmed and lowercased, so an
279// invitation written as "Admin@Corp.Test" must still match the address
280// password hands over.
281func TestAdmittingMatchesTheEmailAfterNormalisation(t *testing.T) {
282 h := guardedHarness(t)
283 _, token := invited(t, h, " Admin@Corp.Test ", idear.RoleMember)
284
285 u := newUsers()
286 if _, err := attempt(t, h.Roster, u, signupForm("admin@corp.test", token), true); err != nil {
287 t.Fatalf("a normalised address failed to match its own invitation: %v", err)
288 }
289 if memberFor(t, h, "admin@corp.test") == nil {
290 t.Fatal("no member row for the admitted address")
291 }
292}
293
294func TestAdmittingRejectsExpiredAndRevokedTokens(t *testing.T) {
295 t.Run("expired", func(t *testing.T) {
296 h := guardedHarness(t)
297 inv, token := invited(t, h, "late@corp.test", idear.RoleAdmin)
298 h.Expire(inv.ID)
299
300 u := newUsers()
301 id, err := attempt(t, h.Roster, u, signupForm("late@corp.test", token), true)
302
303 mustRefuse(t, id, err)
304 if got := memberFor(t, h, "late@corp.test"); got != nil {
305 t.Fatalf("an expired invitation admitted %+v", got)
306 }
307 })
308
309 t.Run("revoked", func(t *testing.T) {
310 h := guardedHarness(t)
311 inv, token := invited(t, h, "gone@corp.test", idear.RoleAdmin)
312 if err := h.Roster.Revoke(h.Ctx(), h.TheOwner(), inv.ID); err != nil {
313 t.Fatalf("Revoke: %v", err)
314 }
315
316 u := newUsers()
317 id, err := attempt(t, h.Roster, u, signupForm("gone@corp.test", token), true)
318
319 mustRefuse(t, id, err)
320 if got := memberFor(t, h, "gone@corp.test"); got != nil {
321 t.Fatalf("a revoked invitation admitted %+v", got)
322 }
323 })
324}
325
326func TestAdmittingClaimsFirstAccountAsOwner(t *testing.T) {
327 h := guardedHarness(t)
328 if n := h.CountMembers(); n != 0 {
329 t.Fatalf("the roster starts with %d rows, want 0", n)
330 }
331
332 u := newUsers()
333 id, err := attempt(t, h.Roster, u, signupForm("first@corp.test", ""), true)
334 if err != nil {
335 t.Fatalf("the first signup into an empty roster was refused: %v", err)
336 }
337
338 owner := h.TheOwner()
339 if owner.Email != "first@corp.test" {
340 t.Errorf("owner email = %q, want first@corp.test", owner.Email)
341 }
342 if want := strconv.FormatInt(id, 10); owner.Subject != want {
343 t.Errorf("Subject = %q, want %q", owner.Subject, want)
344 }
345
346 // The claim closes behind them: the second arrival is refused,
347 // because the roster is no longer empty and they hold no token.
348 u2 := newUsers()
349 id2, err2 := attempt(t, h.Roster, u2, signupForm("second@corp.test", ""), true)
350 mustRefuse(t, id2, err2)
351 if n := h.CountMembers(); n != 1 {
352 t.Errorf("roster has %d rows after a refused second signup, want 1", n)
353 }
354}
355
356func TestAdmittingOpenSignUpJoinsAsMember(t *testing.T) {
357 h := ideartest.NewWith(t, idear.Config{OpenSignUp: true})
358 h.Owner() // the instance is already claimed
359
360 u := newUsers()
361 id, err := attempt(t, h.Roster, u, signupForm("anyone@corp.test", ""), true)
362 if err != nil {
363 t.Fatalf("open sign-up refused an uninvited address: %v", err)
364 }
365
366 m := memberFor(t, h, "anyone@corp.test")
367 if m == nil {
368 t.Fatal("open sign-up wrote no member row")
369 }
370 if m.Role != idear.RoleMember {
371 t.Errorf("role = %s, want %s", m.Role, idear.RoleMember)
372 }
373 if want := strconv.FormatInt(id, 10); m.Subject != want {
374 t.Errorf("Subject = %q, want %q", m.Subject, want)
375 }
376}
377
378// TestAdmittingOpenSignUpDoesNotHonourAMismatchedToken: an open
379// instance still refuses to read a role off somebody else's
380// invitation. Falling through to RoleMember is the whole point of the
381// ordering — falling through to the invitation's role would be an
382// escalation available to anyone who found a link.
383func TestAdmittingOpenSignUpDoesNotHonourAMismatchedToken(t *testing.T) {
384 h := ideartest.NewWith(t, idear.Config{OpenSignUp: true})
385 owner := h.Owner()
386 _, token, err := h.Roster.Invite(h.Ctx(), owner, "admin@corp.test", idear.RoleAdmin)
387 if err != nil {
388 t.Fatalf("Invite: %v", err)
389 }
390
391 u := newUsers()
392 if _, err := attempt(t, h.Roster, u, signupForm("attacker@corp.test", token), true); err != nil {
393 t.Fatalf("open sign-up refused: %v", err)
394 }
395 m := memberFor(t, h, "attacker@corp.test")
396 if m == nil {
397 t.Fatal("open sign-up wrote no member row")
398 }
399 if m.Role != idear.RoleMember {
400 t.Fatalf("role = %s, want %s; a token for another address must not set the role", m.Role, idear.RoleMember)
401 }
402}
403
404// TestAdmittingRefusalUsesConstantCopy: the 403 password renders is a
405// distinguishable outcome, so its COPY must not make it a finer one.
406// One string for every refused address, never interpolating the
407// address.
408func TestAdmittingRefusalUsesConstantCopy(t *testing.T) {
409 h := guardedHarness(t)
410 invited(t, h, "admin@corp.test", idear.RoleAdmin)
411
412 const (
413 one = "admin@corp.test" // invited, but holding no token
414 two = "stranger@corp.test" // never invited at all
415 )
416
417 u := newUsers()
418 _, err1 := attempt(t, h.Roster, u, signupForm(one, ""), true)
419 _, err2 := attempt(t, h.Roster, u, signupForm(two, ""), true)
420 mustRefuse(t, 0, err1)
421 mustRefuse(t, 0, err2)
422
423 // password renders the *refusal's own message, so that is the
424 // string a visitor reads.
425 m1, m2 := err1.Error(), err2.Error()
426 if m1 != m2 {
427 t.Fatalf("refusal copy differs: %q vs %q; two refused addresses must read identically", m1, m2)
428 }
429 for _, addr := range []string{one, two, "corp.test"} {
430 if strings.Contains(m1, addr) {
431 t.Errorf("refusal copy %q contains %q; an interpolated address turns the 403 into an oracle", m1, addr)
432 }
433 }
434 if m1 == "" {
435 t.Error("refusal copy is empty; password would fall back to its own generic string")
436 }
437}
438
439// TestAdmittingSubjectMatchesThePasswordSession is the evidence, not
440// the assertion: it runs the REAL password.Signup over a real sessions
441// core and checks that the session it mints resolves, through
442// idear.Require, to the member row admission wrote. Reading
443// strconv.FormatInt(id, 10) out of password/handlers.go proves what
444// the code says today; this proves the two agree.
445func TestAdmittingSubjectMatchesThePasswordSession(t *testing.T) {
446 h := ideartest.NewWith(t, idear.Config{OpenSignUp: true})
447
448 sess, err := sessions.New(sessions.Config{DB: h.DB.Writer(), Origin: "http://app.test"})
449 if err != nil {
450 t.Fatalf("sessions.New: %v", err)
451 }
452 u := newUsers()
453 render := func(w http.ResponseWriter, r *http.Request, d password.PageData) {
454 fmt.Fprintf(w, "form: %s", d.Error)
455 }
456 ph, err := password.New(password.Config{
457 Sessions: sess,
458 Lookup: u.lookup,
459 Create: h.Roster.Admitting(u.create),
460 RenderSignin: render,
461 RenderSignup: render,
462 })
463 if err != nil {
464 t.Fatalf("password.New: %v", err)
465 }
466
467 var seen struct{ session, member, role string }
468 mux := http.NewServeMux()
469 mux.Handle("POST /signup", h.Roster.CarryToken(http.HandlerFunc(ph.Signup)))
470 mux.Handle("GET /whoami", sess.Middleware(h.Roster.Require(http.HandlerFunc(
471 func(w http.ResponseWriter, r *http.Request) {
472 s, _ := sessions.Current(r)
473 m := idear.From(r)
474 seen.session, seen.member, seen.role = s.Subject, m.Subject, string(m.Role)
475 }))))
476
477 form := signupForm("first@corp.test", "")
478 req := httptest.NewRequest(http.MethodPost, "http://app.test/signup", strings.NewReader(form.Encode()))
479 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
480 w := httptest.NewRecorder()
481 mux.ServeHTTP(w, req)
482 if w.Code != http.StatusSeeOther {
483 t.Fatalf("signup status = %d, want 303; body %q", w.Code, w.Body.String())
484 }
485 cookies := w.Result().Cookies()
486 if len(cookies) == 0 {
487 t.Fatal("signup minted no session cookie")
488 }
489
490 who := httptest.NewRequest(http.MethodGet, "http://app.test/whoami", nil)
491 for _, c := range cookies {
492 who.AddCookie(c)
493 }
494 w2 := httptest.NewRecorder()
495 mux.ServeHTTP(w2, who)
496 if w2.Code != http.StatusOK {
497 t.Fatalf("whoami status = %d, want 200; the minted session did not resolve to a member", w2.Code)
498 }
499 if seen.session != seen.member {
500 t.Errorf("session Subject %q != member Subject %q", seen.session, seen.member)
501 }
502 if want := strconv.FormatInt(u.byEmail["first@corp.test"], 10); seen.member != want {
503 t.Errorf("member Subject = %q, want %q (the app id password formats)", seen.member, want)
504 }
505 if seen.role != string(idear.RoleOwner) {
506 t.Errorf("role = %q, want owner: the first account through the real handler claims the instance", seen.role)
507 }
508}
509
510// ---------------------------------------------------------- Authorize
511
512// TestAuthorizeAdmitsInvitedAddressOnce: under keymail the address IS
513// verified, so a pending invitation for it is the credential — and it
514// is spent exactly once. Deactivating the member and asking again must
515// be false: the consumed invitation cannot readmit them (readmission
516// is Reactivate's job), and nothing may quietly mint a second row.
517func TestAuthorizeAdmitsInvitedAddressOnce(t *testing.T) {
518 h := guardedHarness(t)
519 inv, _ := invited(t, h, "new@corp.test", idear.RoleMember)
520
521 if !h.Roster.Authorize("new@corp.test") {
522 t.Fatal("Authorize refused an address holding a pending invitation")
523 }
524 m := memberFor(t, h, "new@corp.test")
525 if m == nil {
526 t.Fatal("Authorize admitted the address but wrote no member row")
527 }
528 if m.Subject != "new@corp.test" {
529 t.Errorf("Subject = %q, want the address itself — keymail's session subject IS the verified address", m.Subject)
530 }
531 if m.Role != idear.RoleMember {
532 t.Errorf("role = %s, want %s", m.Role, idear.RoleMember)
533 }
534 spent := h.Invitation(inv.ID)
535 if spent.AcceptedAt == nil {
536 t.Fatal("the invitation was not consumed")
537 }
538
539 // An active member is admitted again without touching an
540 // invitation at all.
541 if !h.Roster.Authorize("new@corp.test") {
542 t.Fatal("Authorize refused an active member")
543 }
544
545 // Now remove them. The invitation is spent, so there is nothing
546 // left to readmit on.
547 if err := h.Roster.Deactivate(h.Ctx(), h.TheOwner(), m); err != nil {
548 t.Fatalf("Deactivate: %v", err)
549 }
550 if h.Roster.Authorize("new@corp.test") {
551 t.Fatal("Authorize admitted a deactivated member; a consumed invitation must not readmit them")
552 }
553 if n := h.CountMembers(); n != 2 {
554 t.Errorf("roster has %d rows, want 2 (the owner and the deactivated member)", n)
555 }
556 if again := h.Invitation(inv.ID); !again.AcceptedAt.Equal(*spent.AcceptedAt) {
557 t.Error("the invitation was consumed a second time")
558 }
559}
560
561func TestAuthorizeClaimsAnEmptyRoster(t *testing.T) {
562 h := guardedHarness(t)
563 if !h.Roster.Authorize("first@corp.test") {
564 t.Fatal("Authorize refused the first arrival into an empty roster")
565 }
566 owner := h.TheOwner()
567 if owner.Subject != "first@corp.test" || owner.Role != idear.RoleOwner {
568 t.Errorf("claimed %+v, want the address as Subject at owner", owner)
569 }
570 // And the claim is closed behind them.
571 if h.Roster.Authorize("second@corp.test") {
572 t.Fatal("Authorize claimed a second owner")
573 }
574 if n := h.CountMembers(); n != 1 {
575 t.Errorf("roster has %d rows, want 1", n)
576 }
577}
578
579func TestAuthorizeRefusesAStranger(t *testing.T) {
580 h := guardedHarness(t)
581 h.Owner()
582 if h.Roster.Authorize("stranger@corp.test") {
583 t.Fatal("Authorize admitted an address with no member row and no invitation")
584 }
585 if got := memberFor(t, h, "stranger@corp.test"); got != nil {
586 t.Fatalf("a refused address left %+v behind", got)
587 }
588}
589
590// TestAuthorizeNormalisesTheAddress: auth hands over whatever the
591// visitor typed into the magic-link form. Stored addresses are trimmed
592// and lowercased, so an untrimmed one must still resolve — otherwise a
593// member is locked out by their own capitalisation.
594func TestAuthorizeNormalisesTheAddress(t *testing.T) {
595 h := guardedHarness(t)
596 h.Owner()
597 h.MemberAs("member@corp.test", "member@corp.test", "", idear.RoleMember)
598 if !h.Roster.Authorize(" Member@Corp.Test ") {
599 t.Fatal("Authorize refused an active member whose address arrived unnormalised")
600 }
601 if h.Roster.Authorize("") {
602 t.Fatal("Authorize admitted an empty address")
603 }
604}
605
606// TestAuthorizeRejectsExpiredAndRevokedInvitations: the keymail path
607// admits on a verified address, so the invitation's own liveness is
608// the ONLY thing standing between a withdrawn offer and a session.
609func TestAuthorizeRejectsExpiredAndRevokedInvitations(t *testing.T) {
610 t.Run("expired", func(t *testing.T) {
611 h := guardedHarness(t)
612 inv, _ := invited(t, h, "late@corp.test", idear.RoleAdmin)
613 h.Expire(inv.ID)
614
615 if h.Roster.Authorize("late@corp.test") {
616 t.Fatal("Authorize admitted an expired invitation")
617 }
618 if got := memberFor(t, h, "late@corp.test"); got != nil {
619 t.Fatalf("an expired invitation wrote %+v", got)
620 }
621 if got := h.Invitation(inv.ID); got.AcceptedAt != nil {
622 t.Error("an expired invitation was marked accepted")
623 }
624 })
625
626 t.Run("revoked", func(t *testing.T) {
627 h := guardedHarness(t)
628 inv, _ := invited(t, h, "gone@corp.test", idear.RoleAdmin)
629 if err := h.Roster.Revoke(h.Ctx(), h.TheOwner(), inv.ID); err != nil {
630 t.Fatalf("Revoke: %v", err)
631 }
632
633 if h.Roster.Authorize("gone@corp.test") {
634 t.Fatal("Authorize admitted a revoked invitation")
635 }
636 if got := memberFor(t, h, "gone@corp.test"); got != nil {
637 t.Fatalf("a revoked invitation wrote %+v", got)
638 }
639 if got := h.Invitation(inv.ID); got.AcceptedAt != nil {
640 t.Error("a revoked invitation was marked accepted")
641 }
642 })
643}
644
645// TestConcurrentAuthorizeClaimsOneOwner races the keymail path's own
646// claim. Authorize has no error channel and swallows ErrOwnerExists to
647// carry on to the invitation check, so the loser must come out as a
648// plain refusal — and the roster must come out with exactly ONE owner,
649// not two.
650//
651// Twenty fresh instances, because a single scheduling of a race that
652// passes proves only that one scheduling passed.
653func TestConcurrentAuthorizeClaimsOneOwner(t *testing.T) {
654 for i := 0; i < 20; i++ {
655 h := ideartest.New(t)
656
657 const racers = 6
658 var (
659 wg sync.WaitGroup
660 mu sync.Mutex
661 admitted []string
662 )
663 start := make(chan struct{})
664 for j := 0; j < racers; j++ {
665 addr := fmt.Sprintf("racer-%d@corp.test", j)
666 wg.Add(1)
667 go func() {
668 defer wg.Done()
669 <-start
670 if h.Roster.Authorize(addr) {
671 mu.Lock()
672 admitted = append(admitted, addr)
673 mu.Unlock()
674 }
675 }()
676 }
677 close(start)
678 wg.Wait()
679
680 if len(admitted) != 1 {
681 t.Fatalf("round %d: %d racers were admitted (%v), want exactly 1", i, len(admitted), admitted)
682 }
683 owner := h.TheOwner()
684 if owner.Subject != admitted[0] {
685 t.Fatalf("round %d: the owner is %q but %q was the admitted racer", i, owner.Subject, admitted[0])
686 }
687 if n := h.CountMembers(); n != 1 {
688 t.Fatalf("round %d: roster has %d rows, want 1", i, n)
689 }
690 }
691}
692
693// TestAdmittingOpenSignUpStillHonoursAMatchingToken pins the ORDER of
694// rules 2 and 3, which is invisible until an instance is open.
695//
696// An open instance admits everyone at RoleMember, so it is tempting to
697// answer that first and skip the invitation lookup entirely. Doing so
698// silently demotes every invited Admin to Member on the day someone
699// flips OpenSignUp on, and leaves their invitation pending — a live
700// credential for a role its holder was told they already had.
701func TestAdmittingOpenSignUpStillHonoursAMatchingToken(t *testing.T) {
702 h := ideartest.NewWith(t, idear.Config{OpenSignUp: true})
703 owner := h.Owner()
704 inv, token, err := h.Roster.Invite(h.Ctx(), owner, "admin@corp.test", idear.RoleAdmin)
705 if err != nil {
706 t.Fatalf("Invite: %v", err)
707 }
708
709 u := newUsers()
710 if _, err := attempt(t, h.Roster, u, signupForm("admin@corp.test", token), true); err != nil {
711 t.Fatalf("an invited signup into an open instance was refused: %v", err)
712 }
713
714 m := memberFor(t, h, "admin@corp.test")
715 if m == nil {
716 t.Fatal("no member row")
717 }
718 if m.Role != idear.RoleAdmin {
719 t.Errorf("role = %s, want %s: the invitation is checked BEFORE open sign-up", m.Role, idear.RoleAdmin)
720 }
721 if got := h.Invitation(inv.ID); got.AcceptedAt == nil {
722 t.Error("the invitation was left pending by a signup that redeemed it")
723 }
724}
725
726// ------------------------------------------ the keymail round trip
727
728// TestAuthorizeRoundTripsTheSubjectAuthMints is the keymail twin of
729// TestAdmittingSubjectMatchesThePasswordSession, and it pins the
730// failure that broke this task's first round.
731//
732// rastrillo/auth mints the session as
733// sessions.Session{Subject: id.Address} (auth/handlers.go's admit),
734// and Identity.Address is the address THE VISITOR TYPED:
735// keymaildev/signin's SplitAddress lowercases only the domain and
736// deliberately keeps the local part's case, and flow.go stores the raw
737// typed string. An iOS keyboard capitalises the first letter of an
738// email field by default, so "Alice@Corp.Test" is an ordinary thing to
739// receive, not an edge case.
740//
741// If Authorize writes a Subject that the session auth then mints
742// cannot resolve, the first arrival claims the instance, signs in
743// successfully forever, and 404s on every guarded route forever —
744// /members included, so they can never invite anyone. The claim is
745// spent, everyone else is refused, and the instance is dead.
746//
747// TestAuthorizeNormalisesTheAddress does NOT cover this: it only asks
748// whether an unnormalised address finds an ALREADY-SEEDED lowercase
749// row. This asks whether the row Authorize ITSELF writes can be found
750// by the subject auth itself would mint.
751func TestAuthorizeRoundTripsTheSubjectAuthMints(t *testing.T) {
752 const typed = "Alice@Corp.Test" // exactly what auth passes on
753
754 h := guardedHarness(t)
755 if !h.Roster.Authorize(typed) {
756 t.Fatal("Authorize refused the first arrival into an empty roster")
757 }
758
759 // The session auth mints carries the typed address verbatim.
760 var s spy
761 w := as(typed, h.Roster.Require(s.handler()))
762 if w.Code != http.StatusOK {
763 t.Fatalf("Require answered %d for the very session auth would mint after this Authorize; the instance is bricked", w.Code)
764 }
765 if s.member == nil || s.member.Role != idear.RoleOwner {
766 t.Fatalf("From = %+v, want the owner", s.member)
767 }
768
769 // And the same human typing it differently later is the SAME row,
770 // not a second one — which is why both sides are folded rather
771 // than the raw string being stored.
772 var s2 spy
773 if got := as("alice@corp.test", h.Roster.Require(s2.handler())); got.Code != http.StatusOK {
774 t.Errorf("the same address in lower case answered %d, want 200", got.Code)
775 }
776 if s2.member == nil || s.member.ID != s2.member.ID {
777 t.Error("two spellings of one address resolved to different member rows")
778 }
779 if n := h.CountMembers(); n != 1 {
780 t.Errorf("roster has %d rows for one person, want 1", n)
781 }
782}
783
784// TestAuthorizeRoundTripsAnInvitedSubject is the same round trip on
785// the invitation path, where the Member is written by
786// acceptByAddress rather than by Claim.
787func TestAuthorizeRoundTripsAnInvitedSubject(t *testing.T) {
788 h := guardedHarness(t)
789 invited(t, h, "Bob@Corp.Test", idear.RoleAdmin)
790
791 const typed = "Bob@Corp.Test"
792 if !h.Roster.Authorize(typed) {
793 t.Fatal("Authorize refused an invited address")
794 }
795
796 var s spy
797 w := as(typed, h.Roster.Require(s.handler()))
798 if w.Code != http.StatusOK {
799 t.Fatalf("Require answered %d for the session auth would mint; the invited member is locked out", w.Code)
800 }
801 if s.member == nil || s.member.Role != idear.RoleAdmin {
802 t.Fatalf("From = %+v, want an admin", s.member)
803 }
804}
805
806// ---------------------------------------------------- fault injection
807
808// breakRoster drops the members table out from under the roster. It is
809// the bluntest possible storage failure and the only one that needs no
810// hook into gorm: every query idear makes about membership now errors.
811func breakRoster(t *testing.T, h *ideartest.Harness) {
812 t.Helper()
813 if err := h.DB.G.Exec("DROP TABLE idear_members").Error; err != nil {
814 t.Fatalf("dropping idear_members: %v", err)
815 }
816}
817
818// TestAuthorizeRefusesWhenTheStoreIsBroken: Authorize returns a bool
819// with no error channel, so "a database failure can never come back
820// true" is a property only a test can hold. Every early return in it
821// is a `return false`, and this is what stops one of them becoming a
822// `return true` in a later refactor.
823func TestAuthorizeRefusesWhenTheStoreIsBroken(t *testing.T) {
824 h := guardedHarness(t)
825 member := h.Owner()
826 breakRoster(t, h)
827
828 for _, addr := range []string{member.Email, "stranger@corp.test", ""} {
829 if h.Roster.Authorize(addr) {
830 t.Errorf("Authorize(%q) returned true against a broken store", addr)
831 }
832 }
833}
834
835// TestAdmittingRefusesWhenTheStoreIsBroken: admission's FIRST store
836// call is IsEmpty, so a broken roster is what proves that branch
837// refuses. It must also not reach the app's Create — a signup that
838// creates a user and then cannot write a member is the orphan this
839// design goes out of its way to avoid manufacturing.
840func TestAdmittingRefusesWhenTheStoreIsBroken(t *testing.T) {
841 h := guardedHarness(t)
842 h.Owner()
843 breakRoster(t, h)
844
845 u := newUsers()
846 id, err := attempt(t, h.Roster, u, signupForm("anyone@corp.test", ""), true)
847 if err == nil {
848 t.Fatalf("admission returned id %d and no error against a broken store", id)
849 }
850 if id != 0 {
851 t.Errorf("admission returned id %d, want 0", id)
852 }
853 if errors.Is(err, password.ErrRefused) {
854 t.Error("a storage failure was reported as a policy refusal; it must stay an error so password logs it")
855 }
856 if calls := u.created(); len(calls) != 0 {
857 t.Errorf("the app's Create ran %v before the roster was known to be writable; that manufactures an orphan", calls)
858 }
859}
860
861// ------------------------------------- admission's own normalisation
862
863// TestAdmittingNormalisesTheSubmittedAddress hands admission the
864// address EXACTLY as it came off the form, with password's own
865// folding removed.
866//
867// Nothing is broken today — password/handlers.go normalises identically
868// before calling Create — but Admitting's doc comment claims it
869// normalises both sides of the email match, and until now every test
870// pre-folded the address in the harness, so the claim was never
871// exercised. Admission must not depend on its caller for the property
872// its own security rule rests on.
873func TestAdmittingNormalisesTheSubmittedAddress(t *testing.T) {
874 h := guardedHarness(t)
875 inv, token := invited(t, h, "admin@corp.test", idear.RoleAdmin)
876
877 u := newUsers()
878 id, err := attemptAsTyped(t, h.Roster, u, signupForm(" Admin@Corp.Test ", token), true)
879 if err != nil {
880 t.Fatalf("an invited signup was refused because of how it was typed: %v", err)
881 }
882
883 m := memberFor(t, h, "admin@corp.test")
884 if m == nil {
885 t.Fatal("no member row at the normalised address; the row was written under the typed spelling")
886 }
887 if m.Role != idear.RoleAdmin {
888 t.Errorf("role = %s, want %s", m.Role, idear.RoleAdmin)
889 }
890 if want := strconv.FormatInt(id, 10); m.Subject != want {
891 t.Errorf("Subject = %q, want %q", m.Subject, want)
892 }
893 if got := h.Invitation(inv.ID); got.AcceptedAt == nil {
894 t.Error("the invitation was not consumed")
895 }
896}
897
898// TestAdmittingRefusesAnUnnormalisedUninvitedAddress is the other half:
899// folding the address must not accidentally admit anyone. A typed
900// address that matches no invitation is refused however it is spelled.
901func TestAdmittingRefusesAnUnnormalisedUninvitedAddress(t *testing.T) {
902 h := guardedHarness(t)
903 invited(t, h, "admin@corp.test", idear.RoleAdmin)
904
905 u := newUsers()
906 id, err := attemptAsTyped(t, h.Roster, u, signupForm(" Admin@Corp.Test ", ""), true)
907 mustRefuse(t, id, err)
908 if got := memberFor(t, h, "admin@corp.test"); got != nil {
909 t.Fatalf("a token-less signup for an invited address admitted %+v", got)
910 }
911}
912
913// -------------------------------------------------------- the wiring
914
915// TestAdmittingRefusesWithNoCreateFunction: Admitting(nil) is a wiring
916// bug, and it must fail closed as a STORAGE error rather than as a
917// policy refusal — a visitor must never be told they are not invited
918// because the app forgot to pass its own Create.
919func TestAdmittingRefusesWithNoCreateFunction(t *testing.T) {
920 h := guardedHarness(t)
921 h.Owner()
922
923 id, err := h.Roster.Admitting(nil)(context.Background(), "anyone@corp.test", "hash")
924 if err == nil {
925 t.Fatalf("Admitting(nil) returned id %d and no error", id)
926 }
927 if id != 0 {
928 t.Errorf("Admitting(nil) returned id %d, want 0", id)
929 }
930 if errors.Is(err, password.ErrRefused) {
931 t.Error("a nil Create was reported to the visitor as a policy refusal")
932 }
933 if n := h.CountMembers(); n != 1 {
934 t.Errorf("roster has %d rows, want 1: nothing may be written", n)
935 }
936}
937
938// TestCarryTokenIgnoresTheQueryString: the invitation token is a live
939// credential. Read from the query string it would ride in the URL, and
940// from there into access logs, browser history, and the Referer header
941// of every asset the signup page loads — leaking the credential to
942// third parties who were never sent it.
943//
944// r.PostFormValue reads the body only; r.FormValue would accept both,
945// and the two are one keystroke apart.
946func TestCarryTokenIgnoresTheQueryString(t *testing.T) {
947 h := guardedHarness(t)
948 _, token := invited(t, h, "admin@corp.test", idear.RoleAdmin)
949
950 u := newUsers()
951 admitted := h.Roster.Admitting(u.create)
952
953 var (
954 id int64
955 err error
956 )
957 inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
958 id, err = admitted(r.Context(), "admin@corp.test", "hash")
959 })
960 // The token is in the URL and NOWHERE else.
961 form := url.Values{"email": {"admin@corp.test"}, "password": {"a-long-enough-password"}}
962 req := httptest.NewRequest(http.MethodPost, "/signup?invite="+url.QueryEscape(token),
963 strings.NewReader(form.Encode()))
964 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
965 h.Roster.CarryToken(inner).ServeHTTP(httptest.NewRecorder(), req)
966
967 mustRefuse(t, id, err)
968 if got := memberFor(t, h, "admin@corp.test"); got != nil {
969 t.Fatalf("a token from the query string admitted %+v", got)
970 }
971}
972
973// breakCounting fails every COUNT this roster issues, and nothing
974// else.
975//
976// It exists because DROP TABLE is too blunt to reach one branch: with
977// the table gone, Authorize's BySubject fails FIRST and returns before
978// the roster is ever counted, so the IsEmpty failure path is
979// unreachable by that fault. This one discriminates on the
980// destination gorm is scanning into — Count's is *int64, and no other
981// query idear makes has that shape — so a lookup still cleanly MISSES
982// while the count fails. That is the exact state the branch needs.
983func breakCounting(t *testing.T, h *ideartest.Harness) {
984 t.Helper()
985 err := h.DB.G.Callback().Query().Before("gorm:query").
986 Register("ideartest:break_counting", func(tx *gorm.DB) {
987 if _, counting := tx.Statement.Dest.(*int64); counting {
988 tx.AddError(errors.New("ideartest: injected count failure"))
989 }
990 })
991 if err != nil {
992 t.Fatalf("registering the count fault: %v", err)
993 }
994}
995
996// TestAuthorizeRefusesWhenCountingFails covers the branch DROP TABLE
997// cannot reach: the address resolves to no member (a clean miss), and
998// then the roster cannot be counted.
999//
1000// Getting this wrong is the worst answer in the package. "Is the
1001// roster empty" failing OPEN means an arbitrary stranger is handed
1002// Owner of a populated instance the moment the database hiccups.
1003func TestAuthorizeRefusesWhenCountingFails(t *testing.T) {
1004 h := guardedHarness(t)
1005 h.Owner()
1006 breakCounting(t, h)
1007
1008 if h.Roster.Authorize("stranger@corp.test") {
1009 t.Fatal("Authorize returned true when it could not tell whether the roster was empty")
1010 }
1011 // Asserted through Members and Owners, not the harness's
1012 // CountMembers: the injected fault breaks every COUNT in this
1013 // process, the test's own included.
1014 rows, err := h.Roster.Members(h.Ctx())
1015 if err != nil {
1016 t.Fatalf("listing members: %v", err)
1017 }
1018 if len(rows) != 1 {
1019 t.Errorf("roster has %d rows, want 1: nothing may have been written", len(rows))
1020 }
1021 if owner := h.TheOwner(); owner.Email == "stranger@corp.test" {
1022 t.Fatal("a failed count handed ownership to a stranger")
1023 }
1024}
1025
1026// TestSubjectIsCanonicalWhicheverSpellingWritesIt pins the WRITE half
1027// of the Subject pair, which Authorize alone cannot pin because it
1028// lowercases the address before it ever reaches the store.
1029//
1030// The caller that will hand the store a raw typed Subject is Task 5's
1031// reconciliation route: POST /invitations/{token} writes a Member from
1032// the LIVE session Subject, which under keymail is the address as the
1033// visitor typed it. Without canonicalisation on the write, that route
1034// recreates the same lockout on a different day — a member row nothing
1035// can look up, or one human holding two rows past a unique index that
1036// cannot see they are the same person.
1037func TestSubjectIsCanonicalWhicheverSpellingWritesIt(t *testing.T) {
1038 t.Run("Claim", func(t *testing.T) {
1039 h := guardedHarness(t)
1040 m, err := h.Roster.Claim(h.Ctx(), " Alice@Corp.Test ", "Alice@Corp.Test", "Alice")
1041 if err != nil {
1042 t.Fatalf("Claim: %v", err)
1043 }
1044 if m.Subject != "alice@corp.test" {
1045 t.Errorf("stored Subject = %q, want the canonical form", m.Subject)
1046 }
1047 for _, spelling := range []string{"Alice@Corp.Test", "alice@corp.test", " ALICE@CORP.TEST "} {
1048 if _, err := h.Roster.BySubject(h.Ctx(), spelling); err != nil {
1049 t.Errorf("BySubject(%q) = %v, want the row Claim just wrote", spelling, err)
1050 }
1051 }
1052 })
1053
1054 t.Run("Accept", func(t *testing.T) {
1055 h := guardedHarness(t)
1056 _, token := invited(t, h, "bob@corp.test", idear.RoleAdmin)
1057 m, err := h.Roster.Accept(h.Ctx(), token, "Bob@Corp.Test", "Bob")
1058 if err != nil {
1059 t.Fatalf("Accept: %v", err)
1060 }
1061 if m.Subject != "bob@corp.test" {
1062 t.Errorf("stored Subject = %q, want the canonical form", m.Subject)
1063 }
1064 if _, err := h.Roster.BySubject(h.Ctx(), "Bob@Corp.Test"); err != nil {
1065 t.Errorf("BySubject(typed) = %v, want the row Accept just wrote", err)
1066 }
1067 })
1068}
1069
1070// TestTokenFromReadsWhatCarryTokenStashed pins the reader every app's
1071// RenderSignup uses to re-seed the hidden invite field after a failed
1072// signup — the workaround for password.PageData having nowhere to
1073// carry a token.
1074//
1075// The three cases are the three states an app can be in: mounted
1076// (the token comes back), NOT mounted (empty, and deliberately so —
1077// a body re-read here would hide the one misconfiguration that closes
1078// the instance), and mounted with nothing posted (empty).
1079func TestTokenFromReadsWhatCarryTokenStashed(t *testing.T) {
1080 h := ideartest.New(t)
1081 rs := h.Roster
1082
1083 post := func(form url.Values) *http.Request {
1084 req := httptest.NewRequest(http.MethodPost, "/signup", strings.NewReader(form.Encode()))
1085 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
1086 return req
1087 }
1088
1089 for _, tc := range []struct {
1090 name string
1091 form url.Values
1092 carry bool
1093 want string
1094 }{
1095 {"carried", url.Values{"invite": {"a-token"}}, true, "a-token"},
1096 {"not mounted", url.Values{"invite": {"a-token"}}, false, ""},
1097 {"nothing posted", url.Values{"email": {"who@corp.test"}}, true, ""},
1098 } {
1099 t.Run(tc.name, func(t *testing.T) {
1100 var got string
1101 inner := http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
1102 got = idear.TokenFrom(r)
1103 })
1104 var handler http.Handler = inner
1105 if tc.carry {
1106 handler = rs.CarryToken(inner)
1107 }
1108 handler.ServeHTTP(httptest.NewRecorder(), post(tc.form))
1109 if got != tc.want {
1110 t.Errorf("TokenFrom = %q, want %q", got, tc.want)
1111 }
1112 })
1113 }
1114}
1115
1116// TestTokenFromIgnoresTheQueryString is TestCarryTokenIgnoresTheQuery
1117// String's assertion at the reader: a token in the URL must not reach
1118// a re-rendered signup form either, or the page would hand back a
1119// credential that leaked through access logs and Referer headers as a
1120// value the next POST treats as carried.
1121func TestTokenFromIgnoresTheQueryString(t *testing.T) {
1122 h := ideartest.New(t)
1123 var got string
1124 inner := http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
1125 got = idear.TokenFrom(r)
1126 })
1127 req := httptest.NewRequest(http.MethodPost, "/signup?invite=from-the-url",
1128 strings.NewReader(url.Values{"email": {"who@corp.test"}}.Encode()))
1129 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
1130 h.Roster.CarryToken(inner).ServeHTTP(httptest.NewRecorder(), req)
1131 if got != "" {
1132 t.Errorf("TokenFrom = %q from the query string, want %q", got, "")
1133 }
1134}
1135