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 "errors"
6 "fmt"
7 "net/http"
8 "net/url"
9 "reflect"
10 "strings"
11 "sync"
12 "testing"
13 "time"
14
15 "amadan.net/rastrillo/idear"
16 "amadan.net/rastrillo/idear/internal/ideartest"
17)
18
19// This file is the authorization suite the design calls the
20// deliverable. Every test drives REAL HTTP — chi, a cookie jar, real
21// sessions, rastrillo's real CSRF middleware — because the rules under
22// test are enforced by a stack and not by a function: routing, the
23// session guard, Require, RequireRole, the handler, and then the
24// store's own transaction. A test that called a handler directly would
25// prove the last layer and assume the rest.
26//
27// TWO RULES SHAPE HOW IT IS WRITTEN.
28//
29// First, nothing here restates the route table or the role matrix. The
30// expectations are DERIVED from idear.Handlers.Routes() and from
31// Role.AtLeast — the same list the app mounts and the same predicate
32// the middleware enforces. Round 1 of the bake-off found a test
33// written to whitelist the very payload it listed, and a suite that
34// quotes the implementation proves only that the implementation equals
35// itself. Add a tenth route and these tests exercise it without being
36// edited; change its rank floor and they change what they demand.
37//
38// Second, every claim of coverage here was checked by MUTATING the
39// handler and confirming the test goes red. The table is in
40// .superpowers/sdd/2026-08-23-idear/task-5-report.md.
41
42// ---------------------------------------------------------------
43// Derivation helpers: everything below reads the mounted route table.
44// ---------------------------------------------------------------
45
46// instantiate fills a route pattern's wildcards. It is the one place
47// a URL is built, so a route that grows a new wildcard fails loudly
48// here rather than quietly matching nothing.
49func instantiate(pattern, id, token string) string {
50 s := strings.ReplaceAll(pattern, "{id}", id)
51 return strings.ReplaceAll(s, "{token}", token)
52}
53
54// guarded is the routes behind the membership gate — everything that
55// is not public, taken from the mounted table.
56func guarded(app *ideartest.App) []idear.Route {
57 var out []idear.Route
58 for _, rt := range app.Handlers.Routes() {
59 if !rt.Public {
60 out = append(out, rt)
61 }
62 }
63 return out
64}
65
66// management is the routes that require a rank ABOVE plain membership:
67// derived with Role.AtLeast, the same comparison RequireRole makes, so
68// a route whose floor is raised or lowered moves between these sets on
69// its own.
70func management(app *ideartest.App) []idear.Route {
71 var out []idear.Route
72 for _, rt := range guarded(app) {
73 if !idear.RoleMember.AtLeast(rt.Min) {
74 out = append(out, rt)
75 }
76 }
77 return out
78}
79
80// wantStatus is what a viewer must get from rt when the request is
81// well-formed but its PAYLOAD IS NOT — an id of "0", an empty form.
82//
83// The invalid payload is what makes this a pure authorization probe.
84// An authorized actor gets 400 because the request is malformed, and
85// nothing in the database moves; an unauthorized one never gets far
86// enough to be told, so 403 and 404 still mean exactly what they mean.
87// That lets one probe be run against every route for every actor
88// without any of them mutating state the next probe depends on.
89func wantStatus(rt idear.Route, viewer *idear.Member) int {
90 switch {
91 case viewer == nil || !viewer.Active():
92 return http.StatusNotFound
93 case !viewer.Role.AtLeast(rt.Min):
94 return http.StatusForbidden
95 case rt.Method == http.MethodGet:
96 return http.StatusOK
97 default:
98 return http.StatusBadRequest
99 }
100}
101
102// probe issues the invalid-payload request for rt.
103func probe(c *ideartest.Client, rt idear.Route) *ideartest.Result {
104 path := instantiate(rt.Pattern, "0", "0")
105 if rt.Method == http.MethodGet {
106 return c.Get(path)
107 }
108 return c.Post(path, url.Values{})
109}
110
111// checkAccess walks every guarded route with the invalid payload and
112// demands exactly the status wantStatus derives for this viewer — no
113// more access and no less. It returns the bodies of every 404 it saw,
114// for the byte-identity assertion.
115func checkAccess(t *testing.T, app *ideartest.App, c *ideartest.Client, viewer *idear.Member, what string) []string {
116 t.Helper()
117 var notFound []string
118 for _, rt := range guarded(app) {
119 res := probe(c, rt)
120 want := wantStatus(rt, viewer)
121 if res.Status != want {
122 t.Errorf("%s: %s %s → %d, want %d; body %q", what, rt.Method, rt.Pattern, res.Status, want, res.Body)
123 }
124 switch res.Status {
125 case http.StatusNotFound:
126 notFound = append(notFound, res.Body)
127 case http.StatusForbidden:
128 if res.Body != ideartest.AppForbidden {
129 t.Errorf("%s: %s %s answered 403 with %q, want the app's own 403 page", what, rt.Method, rt.Pattern, res.Body)
130 }
131 }
132 }
133 return notFound
134}
135
136// ---------------------------------------------------------------
137// Invitation plumbing: a Deliver hook, so a test can hold the token.
138// ---------------------------------------------------------------
139
140// collector is the app's mail: it records the links idear hands it.
141// Mutex-guarded because the race tests invite from several goroutines.
142type collector struct {
143 mu sync.Mutex
144 links []string
145}
146
147func (c *collector) deliver(r *http.Request, inv *idear.Invitation, link string) error {
148 c.mu.Lock()
149 defer c.mu.Unlock()
150 c.links = append(c.links, link)
151 return nil
152}
153
154// last is the token from the most recent link.
155func (c *collector) last(t *testing.T) string {
156 t.Helper()
157 c.mu.Lock()
158 defer c.mu.Unlock()
159 if len(c.links) == 0 {
160 t.Fatal("no invitation was delivered")
161 }
162 return tokenOf(t, c.links[len(c.links)-1])
163}
164
165func tokenOf(t *testing.T, link string) string {
166 t.Helper()
167 const prefix = "/invitations/"
168 if !strings.HasPrefix(link, prefix) {
169 t.Fatalf("invitation link %q does not start with %q", link, prefix)
170 }
171 return strings.TrimPrefix(link, prefix)
172}
173
174// newApp is an instance whose invitations are delivered to col.
175func newApp(t *testing.T) (*ideartest.App, *collector) {
176 t.Helper()
177 col := &collector{}
178 app := ideartest.NewAppWith(t, idear.Config{}, idear.HandlerConfig{Deliver: col.deliver})
179 return app, col
180}
181
182// invite posts an invitation through the HTTP route and returns the
183// plaintext token, failing the test if the invite did not take.
184func invite(t *testing.T, app *ideartest.App, col *collector, c *ideartest.Client, email string, role idear.Role) string {
185 t.Helper()
186 res := c.Post("/members/invitations", url.Values{"email": {email}, "role": {string(role)}})
187 if res.Status != http.StatusSeeOther {
188 t.Fatalf("inviting %s as %s: status %d, body %q", email, role, res.Status, res.Body)
189 }
190 return col.last(t)
191}
192
193// memberBySubject reads the row straight out of the database. Every
194// assertion about what a request DID goes through here: a 303 says the
195// handler thought it worked, and only the row says whether it did.
196func memberBySubject(t *testing.T, app *ideartest.App, subject string) *idear.Member {
197 t.Helper()
198 m, err := app.H.Roster.BySubject(app.H.Ctx(), subject)
199 if err != nil {
200 return nil
201 }
202 return m
203}
204
205// standing asserts the two flags the invitation page carries about the
206// viewer. The harness renderer prints every field of InvitationPage by
207// reflection, so these read what the HANDLER decided rather than what
208// a template chose to show.
209func standing(t *testing.T, res *ideartest.Result, who string, signedIn, reconcile bool) {
210 t.Helper()
211 if res.Status != http.StatusOK {
212 t.Fatalf("%s: GET the invitation → %d; body %q", who, res.Status, res.Body)
213 }
214 for field, want := range map[string]bool{"signedin": signedIn, "reconcile": reconcile} {
215 line := fmt.Sprintf("%s %v", field, want)
216 if !strings.Contains(res.Body, line) {
217 t.Errorf("%s: the invitation page does not say %q; body %q", who, line, res.Body)
218 }
219 }
220}
221
222// ---------------------------------------------------------------
223// §7.1 — a non-member is refused read and write on every route.
224// ---------------------------------------------------------------
225
226func TestNonMemberIsRefusedEveryRouteWithIdenticalNotFounds(t *testing.T) {
227 app, _ := newApp(t)
228 owner := app.H.Owner()
229 admin := app.H.Member(idear.RoleAdmin)
230 gone := app.H.Deactivated(idear.RoleMember)
231
232 // Three ways of not being an active member, all of which must be
233 // answered identically: never a member, signed out entirely, and
234 // removed.
235 strangers := map[string]*ideartest.Client{
236 "a signed-in stranger": app.SignIn("stranger-with-no-row"),
237 "a signed-out visitor": app.Visitor(),
238 "a removed member": app.As(gone),
239 }
240
241 var bodies []string
242 for what, c := range strangers {
243 bodies = append(bodies, checkAccess(t, app, c, nil, what)...)
244 }
245
246 // Deeply nested and oversized ids, on every route that takes one:
247 // a stranger must not be able to tell a real id from a fabricated
248 // one, or a route that exists from a path that does not.
249 stranger := strangers["a signed-in stranger"]
250 for _, rt := range guarded(app) {
251 for _, id := range []string{
252 fmt.Sprint(owner.ID),
253 fmt.Sprint(admin.ID),
254 "999999999999",
255 "-1",
256 "1/2/3",
257 "..%2F..%2Fmembers",
258 } {
259 path := instantiate(rt.Pattern, id, id)
260 var res *ideartest.Result
261 if rt.Method == http.MethodGet {
262 res = stranger.Get(path)
263 } else {
264 res = stranger.Post(path, url.Values{"role": {"owner"}, "member": {fmt.Sprint(owner.ID)}})
265 }
266 if res.Status != http.StatusNotFound {
267 t.Errorf("stranger: %s %s → %d, want 404; body %q", rt.Method, path, res.Status, res.Body)
268 }
269 bodies = append(bodies, res.Body)
270 }
271 }
272
273 // Byte-identical: one distinct body across every refusal above,
274 // and it is the app's own 404 page. A 404 that varies with WHY it
275 // was refused is the membership oracle the design forbids.
276 distinct := map[string]int{}
277 for _, b := range bodies {
278 distinct[b]++
279 }
280 if len(distinct) != 1 {
281 t.Fatalf("refusals rendered %d distinct bodies, want 1: %v", len(distinct), distinct)
282 }
283 for b := range distinct {
284 if b != ideartest.AppNotFound {
285 t.Fatalf("refusal body = %q, want the app's own 404 page %q", b, ideartest.AppNotFound)
286 }
287 }
288
289 // And nothing the stranger posted moved a row.
290 if got := app.H.CountMembers(); got != 3 {
291 t.Errorf("the roster holds %d rows, want the 3 it was seeded with", got)
292 }
293 app.H.TheOwner()
294}
295
296// ---------------------------------------------------------------
297// §7.2 — a Member is refused every management action.
298// ---------------------------------------------------------------
299
300func TestMemberIsRefusedEveryManagementAction(t *testing.T) {
301 app, _ := newApp(t)
302 owner := app.H.Owner()
303 victim := app.H.Member(idear.RoleMember)
304 plain := app.H.Member(idear.RoleMember)
305 c := app.As(plain)
306
307 // The whole guarded table, derived: a Member may read the members
308 // page and may do nothing else.
309 checkAccess(t, app, c, plain, "a plain member")
310
311 if len(management(app)) == 0 {
312 t.Fatal("no management routes were derived from the route table; the derivation is broken")
313 }
314
315 // The same routes again with REAL ids and real payloads, so the
316 // refusal is not an artifact of the malformed probe.
317 for _, rt := range management(app) {
318 path := instantiate(rt.Pattern, fmt.Sprint(victim.ID), "0")
319 res := c.Post(path, url.Values{
320 "role": {string(idear.RoleAdmin)},
321 "email": {"newcomer@example.test"},
322 "member": {fmt.Sprint(victim.ID)},
323 })
324 if res.Status != http.StatusForbidden {
325 t.Errorf("%s %s by a member → %d, want 403; body %q", rt.Method, path, res.Status, res.Body)
326 }
327 }
328
329 // Nothing moved: same roles, same activity, no invitations.
330 if got := app.H.Reload(victim.ID); got.Role != idear.RoleMember || !got.Active() {
331 t.Errorf("the victim is now %s/%s; a member's refused actions still landed", got.Role, ideartest.AppNotFound)
332 }
333 if got := app.H.Reload(owner.ID); got.Role != idear.RoleOwner {
334 t.Errorf("the owner is now %s", got.Role)
335 }
336 invs, err := app.H.Roster.PendingInvitations(app.H.Ctx())
337 if err != nil {
338 t.Fatalf("listing invitations: %v", err)
339 }
340 if len(invs) != 0 {
341 t.Errorf("a member's refused invite created %d invitations", len(invs))
342 }
343}
344
345// ---------------------------------------------------------------
346// §7.3 — an Admin cannot touch an Admin or the Owner.
347// ---------------------------------------------------------------
348
349func TestAdminCannotActOnAdminOrOwner(t *testing.T) {
350 app, _ := newApp(t)
351 owner := app.H.Owner()
352 actor := app.H.Member(idear.RoleAdmin)
353 peer := app.H.Member(idear.RoleAdmin)
354 goneAdmin := app.H.Deactivated(idear.RoleAdmin)
355 c := app.As(actor)
356
357 // Every target an Admin may not act on, including themselves —
358 // self-management is how an instance ends up with nobody able to
359 // administer it.
360 targets := map[string]*idear.Member{
361 "the owner": owner,
362 "a peer admin": peer,
363 "a deactivated admin": goneAdmin,
364 "themselves": actor,
365 }
366 // The three target-shaped mutations, derived from the route table
367 // rather than listed: every management route that carries an {id}.
368 var byID []idear.Route
369 for _, rt := range management(app) {
370 if strings.Contains(rt.Pattern, "{id}") && !strings.Contains(rt.Pattern, "invitations") {
371 byID = append(byID, rt)
372 }
373 }
374 if len(byID) == 0 {
375 t.Fatal("no id-addressed management routes were derived; the derivation is broken")
376 }
377
378 for what, target := range targets {
379 for _, rt := range byID {
380 path := instantiate(rt.Pattern, fmt.Sprint(target.ID), "0")
381 res := c.Post(path, url.Values{"role": {string(idear.RoleMember)}})
382 if res.Status != http.StatusForbidden {
383 t.Errorf("admin → %s: %s %s → %d, want 403; body %q", what, rt.Method, path, res.Status, res.Body)
384 }
385 }
386 }
387
388 // The rows, not the responses.
389 if got := app.H.Reload(owner.ID); got.Role != idear.RoleOwner || !got.Active() {
390 t.Errorf("the owner is now %s/%v after an admin's attempts", got.Role, got.Active())
391 }
392 if got := app.H.Reload(peer.ID); got.Role != idear.RoleAdmin || !got.Active() {
393 t.Errorf("the peer admin is now %s/%v after an admin's attempts", got.Role, got.Active())
394 }
395 if got := app.H.Reload(goneAdmin.ID); got.Active() {
396 t.Error("an admin restored a deactivated admin; only the owner may act on an admin")
397 }
398 if got := app.H.Reload(actor.ID); got.Role != idear.RoleAdmin || !got.Active() {
399 t.Errorf("the acting admin acted on themselves: now %s/%v", got.Role, got.Active())
400 }
401
402 // A member id that resolves to NOTHING is answered by the same
403 // hook, with the same bytes, as a non-member's refusal — which is
404 // what refuse()'s "same hook, same bytes" comment claims and
405 // nothing else asserted.
406 for _, rt := range byID {
407 path := instantiate(rt.Pattern, "999999999", "0")
408 res := c.Post(path, url.Values{"role": {string(idear.RoleMember)}})
409 if res.Status != http.StatusNotFound {
410 t.Errorf("%s on a nonexistent member → %d, want 404; body %q", path, res.Status, res.Body)
411 }
412 if res.Body != ideartest.AppNotFound {
413 t.Errorf("%s answered 404 with %q, want the app's own 404 page", path, res.Body)
414 }
415 }
416
417 // What the admin CAN do, so the test is not green because the
418 // admin can do nothing at all.
419 plain := app.H.Member(idear.RoleMember)
420 res := c.Post(fmt.Sprintf("/members/%d/remove", plain.ID), url.Values{})
421 if res.Status != http.StatusSeeOther {
422 t.Fatalf("an admin removing a member → %d, want 303; body %q", res.Status, res.Body)
423 }
424 if app.H.Reload(plain.ID).Active() {
425 t.Error("an admin's legitimate removal did not land")
426 }
427}
428
429// ---------------------------------------------------------------
430// §7.4 — a posted role=owner never lands, on any path, for any actor.
431// ---------------------------------------------------------------
432
433func TestPostedOwnerRoleNeverLands(t *testing.T) {
434 // One instance per actor, so an attempt by one cannot be masked by
435 // a refusal for another.
436 actors := []struct {
437 name string
438 role idear.Role
439 }{
440 {"the owner", idear.RoleOwner},
441 {"an admin", idear.RoleAdmin},
442 {"a member", idear.RoleMember},
443 {"a stranger", ""},
444 }
445 for _, actor := range actors {
446 t.Run(actor.name, func(t *testing.T) {
447 app, _ := newApp(t)
448 owner := app.H.Owner()
449 admin := app.H.Member(idear.RoleAdmin)
450 plain := app.H.Member(idear.RoleMember)
451 gone := app.H.Deactivated(idear.RoleMember)
452
453 var c *ideartest.Client
454 switch actor.role {
455 case idear.RoleOwner:
456 c = app.As(owner)
457 case idear.RoleAdmin:
458 c = app.As(admin)
459 case idear.RoleMember:
460 c = app.As(plain)
461 default:
462 c = app.SignIn("stranger-with-no-row")
463 }
464
465 // The payload is ONE form for every route: every field
466 // idear reads, all of them carrying the escalation.
467 //
468 // "member" names a row that does not exist, so the ONE
469 // route that may legitimately mint an owner cannot
470 // succeed here and no 303 below can be a real transfer.
471 // (That Transfer works at all, and reads no role while
472 // doing it, is TestTransferIsTheOnlyPathToOwner.)
473 attack := url.Values{
474 "role": {string(idear.RoleOwner)},
475 "email": {"escalation@example.test"},
476 "member": {"999999999"},
477 }
478 // Every route the app mounts, public ones included, at
479 // every interesting target. Some of these legitimately
480 // answer 303 — an admin really may remove a member — so
481 // the status is not the assertion; the rows below are.
482 for _, rt := range app.Handlers.Routes() {
483 if rt.Method != http.MethodPost {
484 continue
485 }
486 for _, target := range []*idear.Member{owner, admin, plain, gone} {
487 path := instantiate(rt.Pattern, fmt.Sprint(target.ID), "0")
488 c.Post(path, attack)
489 }
490 }
491
492 // THE ROWS. Exactly one owner, and it is the row that was
493 // seeded as owner — no promotion, and no new row.
494 if got := app.H.TheOwner(); got.ID != owner.ID {
495 t.Fatalf("the owner is now member %d (%s), want the seeded owner %d", got.ID, got.Subject, owner.ID)
496 }
497 for _, m := range []*idear.Member{admin, plain, gone} {
498 if got := app.H.Reload(m.ID); got.Role == idear.RoleOwner {
499 t.Errorf("member %d was promoted to owner by a posted role", m.ID)
500 }
501 }
502 // And no invitation carries the role either: an
503 // owner-role invitation is a delayed escalation, so the
504 // refusal has to hold at the mint and not only at the
505 // redemption.
506 var invs []idear.Invitation
507 if err := app.H.DB.G.Where("role = ?", idear.RoleOwner).Find(&invs).Error; err != nil {
508 t.Fatalf("listing owner invitations: %v", err)
509 }
510 if len(invs) != 0 {
511 t.Errorf("%d invitations were minted at role owner", len(invs))
512 }
513 })
514 }
515}
516
517// TestTransferIsTheOnlyPathToOwner is the other half of §7.4: the one
518// route that MAY mint an owner does so from its own rules and never
519// from the posted role, and the instance still has exactly one.
520func TestTransferIsTheOnlyPathToOwner(t *testing.T) {
521 app, _ := newApp(t)
522 owner := app.H.Owner()
523 admin := app.H.Member(idear.RoleAdmin)
524 c := app.As(owner)
525
526 res := c.Post("/members/transfer", url.Values{
527 "member": {fmt.Sprint(admin.ID)},
528 // Posted, and irrelevant: Transfer reads no role at all.
529 "role": {string(idear.RoleMember)},
530 })
531 if res.Status != http.StatusSeeOther {
532 t.Fatalf("transfer → %d, want 303; body %q", res.Status, res.Body)
533 }
534 if got := app.H.TheOwner(); got.ID != admin.ID {
535 t.Fatalf("ownership landed on member %d, want %d", got.ID, admin.ID)
536 }
537 if got := app.H.Reload(owner.ID); got.Role != idear.RoleAdmin {
538 t.Errorf("the outgoing owner is %s, want admin", got.Role)
539 }
540}
541
542// ---------------------------------------------------------------
543// §7.5 — the single-owner invariant across concurrent transfers.
544// ---------------------------------------------------------------
545
546func TestConcurrentTransfersThroughHTTPLeaveOneOwner(t *testing.T) {
547 const (
548 n = 6
549 rounds = 5
550 )
551 for round := range rounds {
552 app, _ := newApp(t)
553 owner := app.H.Owner()
554 targets := make([]*idear.Member, n)
555 for i := range targets {
556 targets[i] = app.H.Member(idear.RoleAdmin)
557 }
558 c := app.As(owner)
559
560 gate, wait := release()
561 codes := make([]int, n)
562 errs := make([]error, n)
563 var wg sync.WaitGroup
564 for i := range n {
565 wg.Add(1)
566 // The spawn order alternates with i's parity for the same
567 // reason pair() flips: closing a channel readies waiters
568 // FIFO and the last one readied runs first, so a fixed
569 // order biases which racer wins.
570 go func() {
571 defer wg.Done()
572 wait()
573 res, err := c.TryPost("/members/transfer", url.Values{"member": {fmt.Sprint(targets[i].ID)}})
574 if err != nil {
575 errs[i] = err
576 return
577 }
578 codes[i] = res.Status
579 }()
580 }
581 close(gate)
582 wg.Wait()
583
584 won := 0
585 for i, code := range codes {
586 if errs[i] != nil {
587 t.Fatalf("round %d: transfer %d failed in transport: %v", round, i, errs[i])
588 }
589 if code == http.StatusSeeOther {
590 won++
591 }
592 }
593 if won != 1 {
594 t.Fatalf("round %d: %d of %d concurrent transfers were accepted, want exactly 1 (codes %v)", round, won, n, codes)
595 }
596 got := app.H.TheOwner()
597 if !got.Active() {
598 t.Fatalf("round %d: the surviving owner %d is deactivated", round, got.ID)
599 }
600 if got.ID == owner.ID {
601 t.Fatalf("round %d: a transfer was accepted but ownership did not move", round)
602 }
603 }
604}
605
606// ---------------------------------------------------------------
607// §7.6 — an invited address cannot be claimed without the token.
608// ---------------------------------------------------------------
609
610func TestInvitedAddressCannotBeClaimedWithoutTheToken(t *testing.T) {
611 app, col := newApp(t)
612 owner := app.H.Owner()
613 oc := app.As(owner)
614
615 const invited = "admin@corp.test"
616 token := invite(t, app, col, oc, invited, idear.RoleAdmin)
617
618 // The attacker knows the address — it is the whole premise — and
619 // signs in as it (which is what keymail's subject looks like).
620 // Every public route, without the token, must leave them out.
621 attacker := app.SignIn(invited)
622 for _, rt := range app.Handlers.Routes() {
623 if !rt.Public {
624 continue
625 }
626 for _, tok := range []string{"", "not-a-token", strings.Repeat("0", 64)} {
627 path := instantiate(rt.Pattern, "0", url.PathEscape(tok))
628 var res *ideartest.Result
629 if rt.Method == http.MethodGet {
630 res = attacker.Get(path)
631 } else {
632 res = attacker.Post(path, url.Values{"role": {"owner"}, "email": {invited}})
633 }
634 if res.Status == http.StatusSeeOther {
635 t.Errorf("%s %s admitted an address with no token", rt.Method, path)
636 }
637 }
638 }
639 if m := memberBySubject(t, app, invited); m != nil {
640 t.Fatalf("the invited address was admitted at %s with no token", m.Role)
641 }
642 if got := app.H.CountMembers(); got != 1 {
643 t.Fatalf("the roster holds %d rows, want just the owner", got)
644 }
645
646 // The token still works afterwards — the refusals above did not
647 // consume it, which is what makes them refusals rather than a
648 // broken flow.
649 res := attacker.Post("/invitations/"+token, url.Values{})
650 if res.Status != http.StatusSeeOther {
651 t.Fatalf("redeeming the real token → %d, want 303; body %q", res.Status, res.Body)
652 }
653 m := memberBySubject(t, app, invited)
654 if m == nil || m.Role != idear.RoleAdmin {
655 t.Fatalf("after redeeming the token the member is %+v, want an admin", m)
656 }
657}
658
659// ---------------------------------------------------------------
660// §7.7 — Revoke racing Accept never admits.
661// ---------------------------------------------------------------
662
663func TestRevokeRacingAcceptThroughHTTPNeverAdmits(t *testing.T) {
664 const rounds = 24
665 admitted, killed := 0, 0
666
667 for round := range rounds {
668 app, col := newApp(t)
669 owner := app.H.Owner()
670 oc := app.As(owner)
671
672 const invitee = "racer@example.test"
673 token := invite(t, app, col, oc, invitee, idear.RoleMember)
674 invs, err := app.H.Roster.PendingInvitations(app.H.Ctx())
675 if err != nil || len(invs) != 1 {
676 t.Fatalf("round %d: pending invitations = %v, %v", round, invs, err)
677 }
678 inv := invs[0]
679
680 // The orphan: an app user with a session and no member row,
681 // which is exactly who this route is for.
682 orphan := app.SignIn(fmt.Sprintf("orphan-%d", round))
683
684 var acceptRes, revokeRes *ideartest.Result
685 var acceptErr, revokeErr error
686 pair(round%2 == 0,
687 func() {
688 acceptRes, acceptErr = orphan.TryPost("/invitations/"+token, url.Values{})
689 },
690 func() {
691 revokeRes, revokeErr = oc.TryPost(fmt.Sprintf("/members/invitations/%d/revoke", inv.ID), url.Values{})
692 })
693 if acceptErr != nil || revokeErr != nil {
694 t.Fatalf("round %d: transport failed: accept %v, revoke %v", round, acceptErr, revokeErr)
695 }
696
697 row := app.H.Invitation(inv.ID)
698 member := memberBySubject(t, app, orphan.Subject)
699
700 switch {
701 case acceptRes.Status == http.StatusSeeOther:
702 admitted++
703 if member == nil {
704 t.Fatalf("round %d: acceptance answered 303 but wrote no member row", round)
705 }
706 if row.AcceptedAt == nil {
707 t.Fatalf("round %d: a member was admitted from an invitation that was never marked accepted", round)
708 }
709 if row.RevokedAt != nil {
710 t.Fatalf("round %d: THE INVARIANT BROKE — a REVOKED invitation admitted a member", round)
711 }
712 if revokeRes.Status == http.StatusSeeOther {
713 t.Fatalf("round %d: both the accept and the revoke were accepted", round)
714 }
715 default:
716 killed++
717 if member != nil {
718 t.Fatalf("round %d: acceptance was refused (%d) but a member row exists: %+v", round, acceptRes.Status, member)
719 }
720 if revokeRes.Status != http.StatusSeeOther {
721 t.Fatalf("round %d: neither side won: accept %d, revoke %d", round, acceptRes.Status, revokeRes.Status)
722 }
723 if row.RevokedAt == nil {
724 t.Fatalf("round %d: the revoke was accepted but the row is not revoked", round)
725 }
726 }
727 }
728
729 // Both branches must actually have been exercised. A race test
730 // that only ever resolves one way is green for a reason unrelated
731 // to the property it claims to check.
732 t.Logf("the race split %d admitted / %d revoked over %d rounds", admitted, killed, rounds)
733 if admitted == 0 || killed == 0 {
734 t.Fatalf("the race never split: %d admitted, %d revoked over %d rounds", admitted, killed, rounds)
735 }
736}
737
738// ---------------------------------------------------------------
739// §7.8 — expiry is refused, and an acceptance cannot be replayed.
740// ---------------------------------------------------------------
741
742func TestExpiredInvitationIsRefused(t *testing.T) {
743 app, col := newApp(t)
744 oc := app.As(app.H.Owner())
745 token := invite(t, app, col, oc, "late@example.test", idear.RoleMember)
746
747 invs, err := app.H.Roster.PendingInvitations(app.H.Ctx())
748 if err != nil || len(invs) != 1 {
749 t.Fatalf("pending invitations = %v, %v", invs, err)
750 }
751 app.H.Expire(invs[0].ID)
752
753 // The public GET stops offering it...
754 visitor := app.Visitor()
755 if res := visitor.Get("/invitations/" + token); res.Status != http.StatusNotFound {
756 t.Errorf("GET an expired invitation → %d, want 404; body %q", res.Status, res.Body)
757 }
758 // ...and the redemption is refused, with no row written.
759 orphan := app.SignIn("late-orphan")
760 res := orphan.Post("/invitations/"+token, url.Values{})
761 if res.Status != http.StatusNotFound {
762 t.Errorf("POST an expired invitation → %d, want 404; body %q", res.Status, res.Body)
763 }
764 if m := memberBySubject(t, app, "late-orphan"); m != nil {
765 t.Fatalf("an expired invitation admitted %+v", m)
766 }
767}
768
769func TestAcceptedInvitationCannotBeReplayed(t *testing.T) {
770 app, col := newApp(t)
771 oc := app.As(app.H.Owner())
772 token := invite(t, app, col, oc, "first@example.test", idear.RoleMember)
773
774 first := app.SignIn("first-orphan")
775 if res := first.Post("/invitations/"+token, url.Values{}); res.Status != http.StatusSeeOther {
776 t.Fatalf("the first redemption → %d, want 303; body %q", res.Status, res.Body)
777 }
778 if m := memberBySubject(t, app, "first-orphan"); m == nil {
779 t.Fatal("the first redemption wrote no member row")
780 }
781
782 // A DIFFERENT session replaying the same link gets nothing.
783 second := app.SignIn("second-orphan")
784 res := second.Post("/invitations/"+token, url.Values{})
785 if res.Status != http.StatusNotFound {
786 t.Errorf("replaying a spent invitation → %d, want 404; body %q", res.Status, res.Body)
787 }
788 if m := memberBySubject(t, app, "second-orphan"); m != nil {
789 t.Fatalf("a spent invitation admitted a second person: %+v", m)
790 }
791
792 // And the ORIGINAL redeemer replaying it changes nothing: they are
793 // already a member, and no second row appears.
794 if res := first.Post("/invitations/"+token, url.Values{}); res.Status != http.StatusSeeOther {
795 t.Errorf("a member reopening their own link → %d, want 303", res.Status)
796 }
797 if got := app.H.CountMembers(); got != 2 {
798 t.Fatalf("the roster holds %d rows, want the owner and one redeemer", got)
799 }
800}
801
802// ---------------------------------------------------------------
803// §7.9 — the orphan, healed by POST /invitations/{token}.
804// ---------------------------------------------------------------
805
806func TestOrphanIs404edEverywhereUntilReconciled(t *testing.T) {
807 app, col := newApp(t)
808 owner := app.H.Owner()
809 oc := app.As(owner)
810
811 // The orphan's subject is MIXED CASE and address-shaped, which is
812 // what keymail mints — auth takes the address the visitor typed.
813 // The member row this route writes must be canonicalised by the
814 // store, or the person signs in forever and 404s forever.
815 const typed = "Orphan@Example.Test"
816 orphan := app.SignIn(typed)
817
818 // Every guarded route, derived: 404, byte-identical, before.
819 for _, body := range checkAccess(t, app, orphan, nil, "an orphan") {
820 if body != ideartest.AppNotFound {
821 t.Fatalf("an orphan's 404 body = %q, want the app's own page", body)
822 }
823 }
824
825 // Somebody invites them. Only then can the route heal them — it
826 // needs a valid token, which is the spec's corrected wording.
827 token := invite(t, app, col, oc, "orphan@example.test", idear.RoleAdmin)
828
829 // The invitation page has to SAY they are the orphan, because
830 // SignedIn and Reconcile are what a template keys the accept
831 // control off. Stuck at false, item 9's healing path is
832 // unreachable from any real UI while the redemption below still
833 // answers 303 — so the flags are asserted, not assumed.
834 standing(t, orphan.Get("/invitations/"+token), "the orphan", true, true)
835 standing(t, app.Visitor().Get("/invitations/"+token), "a signed-out visitor", false, false)
836
837 res := orphan.Post("/invitations/"+token, url.Values{})
838 if res.Status != http.StatusSeeOther {
839 t.Fatalf("reconciliation → %d, want 303; body %q", res.Status, res.Body)
840 }
841 if res.Location != "/members" {
842 t.Errorf("reconciliation redirected to %q, want /members", res.Location)
843 }
844
845 // The row: written from the LIVE SESSION SUBJECT, through the
846 // store, and therefore canonicalised.
847 m := memberBySubject(t, app, typed)
848 if m == nil {
849 t.Fatal("reconciliation wrote no member row")
850 }
851 if m.Subject != strings.ToLower(typed) {
852 t.Errorf("the member's subject is %q, want the canonical %q; a Member built in the handler would carry the typed form", m.Subject, strings.ToLower(typed))
853 }
854 if m.Role != idear.RoleAdmin {
855 t.Errorf("the healed member is %s, want the invitation's admin", m.Role)
856 }
857
858 // And afterwards they have exactly the access their role earns —
859 // derived, so "exactly" means every route.
860 checkAccess(t, app, orphan, m, "a healed orphan")
861
862 // A member is signed in and has nothing left to reconcile, so the
863 // accept control must be gone. Their own spent token is no longer
864 // a valid invitation, so this asks about a fresh one.
865 fresh := invite(t, app, col, oc, "somebody.else@example.test", idear.RoleMember)
866 standing(t, orphan.Get("/invitations/"+fresh), "a healed member", true, false)
867}
868
869func TestReconciliationClaimsAnUnclaimedInstance(t *testing.T) {
870 // The other orphan: admission created the app user and its Claim
871 // did not commit, so the roster is still empty and there is nobody
872 // to invite them.
873 app, _ := newApp(t)
874 if got := app.H.CountMembers(); got != 0 {
875 t.Fatalf("a fresh instance holds %d rows", got)
876 }
877 orphan := app.SignIn("1") // password's subject: a decimal user id
878
879 res := orphan.Post("/invitations/anything", url.Values{})
880 if res.Status != http.StatusSeeOther {
881 t.Fatalf("reconciliation on an empty roster → %d, want 303; body %q", res.Status, res.Body)
882 }
883 got := app.H.TheOwner()
884 if got.Subject != "1" {
885 t.Fatalf("the claim wrote subject %q, want the live session's %q", got.Subject, "1")
886 }
887
888 // And it does not reopen: the next signed-in stranger is not an
889 // owner, and holds no token.
890 stranger := app.SignIn("2")
891 if res := stranger.Post("/invitations/anything", url.Values{}); res.Status == http.StatusSeeOther {
892 t.Fatal("a second signed-in stranger was admitted by the reconciliation route")
893 }
894 app.H.TheOwner()
895 if got := app.H.CountMembers(); got != 1 {
896 t.Fatalf("the roster holds %d rows, want 1", got)
897 }
898}
899
900func TestReconciliationRefusesAnotherAddressesInvitation(t *testing.T) {
901 // Under keymail the session subject IS a verified address, so idear
902 // knows who the viewer is and the invitation must be theirs.
903 app, col := newApp(t)
904 oc := app.As(app.H.Owner())
905 token := invite(t, app, col, oc, "intended@example.test", idear.RoleAdmin)
906
907 interloper := app.SignIn("someone.else@example.test")
908 res := interloper.Post("/invitations/"+token, url.Values{})
909 if res.Status != http.StatusNotFound {
910 t.Errorf("redeeming another address's invitation → %d, want 404; body %q", res.Status, res.Body)
911 }
912 if m := memberBySubject(t, app, "someone.else@example.test"); m != nil {
913 t.Fatalf("another address's invitation admitted %+v", m)
914 }
915 // The invitation is untouched, so the intended recipient can still
916 // use it.
917 intended := app.SignIn("intended@example.test")
918 if res := intended.Post("/invitations/"+token, url.Values{}); res.Status != http.StatusSeeOther {
919 t.Fatalf("the intended recipient → %d, want 303; body %q", res.Status, res.Body)
920 }
921}
922
923func TestReconciliationRefusesASignedOutVisitor(t *testing.T) {
924 // The route writes a member row FOR THE SESSION IN HAND. With no
925 // session there is nobody to write it for, and a token holder who
926 // is merely holding a link must not be able to conjure a
927 // membership out of it — under password they would have no app
928 // user either, and the row would join to nothing.
929 app, col := newApp(t)
930 oc := app.As(app.H.Owner())
931 token := invite(t, app, col, oc, "expected@example.test", idear.RoleMember)
932
933 res := app.Visitor().Post("/invitations/"+token, url.Values{})
934 if res.Status != http.StatusForbidden {
935 t.Fatalf("a signed-out redemption → %d, want a 403 refusal; body %q", res.Status, res.Body)
936 }
937 if got := app.H.CountMembers(); got != 1 {
938 t.Fatalf("the roster holds %d rows, want just the owner", got)
939 }
940
941 // And the token was not spent by the refusal: the person it was
942 // meant for can still redeem it once they are signed in.
943 invs, err := app.H.Roster.PendingInvitations(app.H.Ctx())
944 if err != nil || len(invs) != 1 {
945 t.Fatalf("pending invitations = %v, %v; the refusal consumed the invitation", invs, err)
946 }
947 signedIn := app.SignIn("expected@example.test")
948 if res := signedIn.Post("/invitations/"+token, url.Values{}); res.Status != http.StatusSeeOther {
949 t.Fatalf("the intended recipient → %d, want 303; body %q", res.Status, res.Body)
950 }
951}
952
953func TestReconciliationRefusesADeactivatedMember(t *testing.T) {
954 app, col := newApp(t)
955 oc := app.As(app.H.Owner())
956 gone := app.H.Deactivated(idear.RoleAdmin)
957 token := invite(t, app, col, oc, gone.Email, idear.RoleMember)
958
959 c := app.As(gone)
960 if res := c.Post("/invitations/"+token, url.Values{}); res.Status != http.StatusNotFound {
961 t.Errorf("a removed member redeeming an invitation → %d, want 404; body %q", res.Status, res.Body)
962 }
963 if app.H.Reload(gone.ID).Active() {
964 t.Fatal("a removed member let themselves back in with an invitation; readmission is Restore")
965 }
966}
967
968// ---------------------------------------------------------------
969// Reconciliation under the PASSWORD plugin: the token, and whether
970// anything else is asked of the viewer.
971//
972// Under keymail the session Subject is the verified address, so idear
973// matches it against the invitation itself (the test above). Under
974// password the Subject is an opaque decimal user id and idear can only
975// resolve it through Config.EmailForSubject. These two tests are the
976// two configurations, and the difference between them is the whole
977// point: one refuses a token issued to somebody else, the other — the
978// documented permissive path — does not.
979// ---------------------------------------------------------------
980
981// directory is an app's own user table, as idear sees it through
982// Config.EmailForSubject: subject → address, and an error the app
983// cannot answer through.
984type directory struct {
985 byID map[string]string
986 err error
987}
988
989func (d *directory) emailForSubject(ctx context.Context, subject string) (string, error) {
990 if d.err != nil {
991 return "", d.err
992 }
993 // The app's own miss: not one of this app's subjects, or an id
994 // with no row behind it. "" with a NIL error, which idear must
995 // read as a refusal and not as a storage failure.
996 return d.byID[subject], nil
997}
998
999// passwordApp is an instance whose subjects are password's — decimal
1000// user ids — with dir standing in for the app's user table.
1001func passwordApp(t *testing.T, dir *directory) (*ideartest.App, *collector) {
1002 t.Helper()
1003 col := &collector{}
1004 cfg := idear.Config{}
1005 if dir != nil {
1006 cfg.EmailForSubject = dir.emailForSubject
1007 }
1008 return ideartest.NewAppWith(t, cfg, idear.HandlerConfig{Deliver: col.deliver}), col
1009}
1010
1011// TestReconciliationWithEmailForSubjectRefusesAnotherAddressesToken is
1012// the F1 regression test.
1013//
1014// The orphan is real and signed in: their app user row exists and
1015// their member row does not, which is what a create-succeeded-then-
1016// member-write-failed admission leaves behind, and what the loser of a
1017// first-signup claim race is. They are not entitled to a token issued
1018// to somebody else — tokens leak into browser flash cookies (with
1019// Deliver nil), into URL history, referrers and logs — and with the
1020// resolver wired, idear applies the rule admission applies: possession
1021// of the token AND an email match.
1022func TestReconciliationWithEmailForSubjectRefusesAnotherAddressesToken(t *testing.T) {
1023 dir := &directory{byID: map[string]string{
1024 "7": "orphan@example.test",
1025 // The intended recipient's spelling in the app's table is not
1026 // the invitation's: the comparison is normalised on both
1027 // sides, or a correctly-invited person is locked out.
1028 "9": "Intended@Example.Test",
1029 }}
1030 app, col := passwordApp(t, dir)
1031 oc := app.As(app.H.Owner())
1032 token := invite(t, app, col, oc, "intended@example.test", idear.RoleAdmin)
1033
1034 orphan := app.SignIn("7")
1035 res := orphan.Post("/invitations/"+token, url.Values{})
1036 if res.Status != http.StatusNotFound {
1037 t.Fatalf("an orphan redeeming another address's token → %d, want 404; body %q", res.Status, res.Body)
1038 }
1039 if m := memberBySubject(t, app, "7"); m != nil {
1040 t.Fatalf("another address's invitation admitted %+v at %s", m, m.Role)
1041 }
1042
1043 // The refusal did not spend it: the person it was issued to still
1044 // has it, and lands at the invited role.
1045 intended := app.SignIn("9")
1046 if res := intended.Post("/invitations/"+token, url.Values{}); res.Status != http.StatusSeeOther {
1047 t.Fatalf("the intended recipient → %d, want 303; body %q", res.Status, res.Body)
1048 }
1049 m := memberBySubject(t, app, "9")
1050 if m == nil {
1051 t.Fatal("the intended recipient redeemed the invitation but no member row was written")
1052 }
1053 if m.Role != idear.RoleAdmin {
1054 t.Errorf("the healed orphan is %s, want the invitation's admin", m.Role)
1055 }
1056}
1057
1058// TestReconciliationWithoutEmailForSubjectTrustsTheTokenAlone is the
1059// OTHER half of F1, and it is deliberately an assertion of the
1060// permissive behaviour rather than a gap left untested.
1061//
1062// With no Config.EmailForSubject, idear has no way to turn a decimal
1063// user id into an address, so reconciliation asks only for a live
1064// token. A signed-in orphan holding one issued to somebody else
1065// redeems it, at that token's role. That is what SKILL.md §5 says
1066// happens, and this test is what keeps the two of them honest: change
1067// the behaviour and this goes red, which is the moment to change the
1068// documentation with it.
1069func TestReconciliationWithoutEmailForSubjectTrustsTheTokenAlone(t *testing.T) {
1070 app, col := passwordApp(t, nil)
1071 oc := app.As(app.H.Owner())
1072 token := invite(t, app, col, oc, "intended@example.test", idear.RoleAdmin)
1073
1074 orphan := app.SignIn("7")
1075 res := orphan.Post("/invitations/"+token, url.Values{})
1076 if res.Status != http.StatusSeeOther {
1077 t.Fatalf("the documented permissive path → %d, want 303; body %q", res.Status, res.Body)
1078 }
1079 m := memberBySubject(t, app, "7")
1080 if m == nil {
1081 t.Fatal("the permissive path admitted nobody; SKILL.md §5 says the token alone is enough here")
1082 }
1083 if m.Role != idear.RoleAdmin {
1084 t.Errorf("the orphan landed at %s, want the token's own admin — the risk being documented is exactly that it is the TOKEN's role", m.Role)
1085 }
1086}
1087
1088// TestReconciliationRefusesAViewerTheResolverCannotPlace covers the
1089// resolver's two non-answers, which must not be confused with each
1090// other.
1091//
1092// ("", nil) is the app saying "that subject is nobody I know" — a
1093// REFUSAL, because a viewer idear cannot identify must not spend an
1094// invitation issued to one it can. A non-nil error is the app's
1095// database failing, which is a 500 and must never read as policy: the
1096// invitation stays live and the person can try again.
1097func TestReconciliationRefusesAViewerTheResolverCannotPlace(t *testing.T) {
1098 t.Run("unknown subject", func(t *testing.T) {
1099 app, col := passwordApp(t, &directory{byID: map[string]string{}})
1100 oc := app.As(app.H.Owner())
1101 token := invite(t, app, col, oc, "intended@example.test", idear.RoleMember)
1102
1103 res := app.SignIn("7").Post("/invitations/"+token, url.Values{})
1104 if res.Status != http.StatusNotFound {
1105 t.Fatalf("a subject the app cannot place → %d, want 404; body %q", res.Status, res.Body)
1106 }
1107 if m := memberBySubject(t, app, "7"); m != nil {
1108 t.Fatalf("an unplaceable subject was admitted: %+v", m)
1109 }
1110 })
1111
1112 t.Run("the resolver fails", func(t *testing.T) {
1113 app, col := passwordApp(t, &directory{err: errors.New("the app's user table is unreadable")})
1114 oc := app.As(app.H.Owner())
1115 token := invite(t, app, col, oc, "intended@example.test", idear.RoleMember)
1116
1117 res := app.SignIn("7").Post("/invitations/"+token, url.Values{})
1118 if res.Status != http.StatusInternalServerError {
1119 t.Fatalf("a failing resolver → %d, want 500; a storage failure must not render as a policy refusal. Body %q", res.Status, res.Body)
1120 }
1121 if m := memberBySubject(t, app, "7"); m != nil {
1122 t.Fatalf("a failing resolver admitted %+v", m)
1123 }
1124 // And it cost nobody their invitation.
1125 invs, err := app.H.Roster.PendingInvitations(app.H.Ctx())
1126 if err != nil || len(invs) != 1 {
1127 t.Fatalf("pending invitations = %v, %v; a failed lookup consumed the invitation", invs, err)
1128 }
1129 })
1130}
1131
1132// ---------------------------------------------------------------
1133// §7.10 — Transfer racing Deactivate never yields a deactivated Owner.
1134// ---------------------------------------------------------------
1135
1136func TestTransferRacingRemoveThroughHTTP(t *testing.T) {
1137 const rounds = 24
1138 transferred, removed := 0, 0
1139
1140 for round := range rounds {
1141 app, _ := newApp(t)
1142 owner := app.H.Owner()
1143 admin := app.H.Member(idear.RoleAdmin)
1144 target := app.H.Member(idear.RoleMember)
1145
1146 oc := app.As(owner)
1147 ac := app.As(admin)
1148
1149 var transferRes, removeRes *ideartest.Result
1150 var transferErr, removeErr error
1151 pair(round%2 == 0,
1152 func() {
1153 transferRes, transferErr = oc.TryPost("/members/transfer", url.Values{"member": {fmt.Sprint(target.ID)}})
1154 },
1155 func() {
1156 removeRes, removeErr = ac.TryPost(fmt.Sprintf("/members/%d/remove", target.ID), url.Values{})
1157 })
1158 if transferErr != nil || removeErr != nil {
1159 t.Fatalf("round %d: transport failed: transfer %v, remove %v", round, transferErr, removeErr)
1160 }
1161
1162 // The invariant, whichever way it resolved: exactly one owner,
1163 // and that owner is ACTIVE. A deactivated owner is an instance
1164 // nobody can administer and nobody can be promoted out of.
1165 got := app.H.TheOwner()
1166 if !got.Active() {
1167 t.Fatalf("round %d: THE INVARIANT BROKE — the owner (member %d) is deactivated", round, got.ID)
1168 }
1169 row := app.H.Reload(target.ID)
1170 if transferRes.Status == http.StatusSeeOther {
1171 transferred++
1172 if got.ID != target.ID {
1173 t.Fatalf("round %d: the transfer was accepted but ownership is on member %d", round, got.ID)
1174 }
1175 if !row.Active() {
1176 t.Fatalf("round %d: the new owner is deactivated", round)
1177 }
1178 } else {
1179 removed++
1180 if got.ID != owner.ID {
1181 t.Fatalf("round %d: the transfer was refused (%d) but ownership moved to %d", round, transferRes.Status, got.ID)
1182 }
1183 if removeRes.Status != http.StatusSeeOther {
1184 t.Fatalf("round %d: neither side won: transfer %d, remove %d", round, transferRes.Status, removeRes.Status)
1185 }
1186 if row.Active() {
1187 t.Fatalf("round %d: the removal was accepted but the target is still active", round)
1188 }
1189 }
1190 }
1191 t.Logf("the race split %d transfers / %d removals over %d rounds", transferred, removed, rounds)
1192 if transferred == 0 || removed == 0 {
1193 t.Fatalf("the race never split: %d transfers, %d removals over %d rounds", transferred, removed, rounds)
1194 }
1195}
1196
1197// ---------------------------------------------------------------
1198// §7.11 — reactivation restores exactly the prior access.
1199// ---------------------------------------------------------------
1200
1201func TestReactivatedMemberRegainsExactlyTheirPriorAccess(t *testing.T) {
1202 app, _ := newApp(t)
1203 owner := app.H.Owner()
1204 admin := app.H.Member(idear.RoleAdmin)
1205 oc := app.As(owner)
1206 c := app.As(admin)
1207
1208 // Before: the whole route table, recorded.
1209 before := map[string]int{}
1210 for _, rt := range guarded(app) {
1211 res := probe(c, rt)
1212 before[rt.Method+" "+rt.Pattern] = res.Status
1213 if want := wantStatus(rt, admin); res.Status != want {
1214 t.Fatalf("before removal: %s %s → %d, want %d", rt.Method, rt.Pattern, res.Status, want)
1215 }
1216 }
1217
1218 // Removed: nothing at all, and the session still exists — under
1219 // password a removed member can still hold a session, and what
1220 // stops them is Require on every route.
1221 if res := oc.Post(fmt.Sprintf("/members/%d/remove", admin.ID), url.Values{}); res.Status != http.StatusSeeOther {
1222 t.Fatalf("removing the admin → %d; body %q", res.Status, res.Body)
1223 }
1224 checkAccess(t, app, c, nil, "a removed admin")
1225
1226 // Restored: the SAME statuses as before, route for route. More
1227 // would be an escalation; fewer would make Restore useless.
1228 if res := oc.Post(fmt.Sprintf("/members/%d/restore", admin.ID), url.Values{}); res.Status != http.StatusSeeOther {
1229 t.Fatalf("restoring the admin → %d; body %q", res.Status, res.Body)
1230 }
1231 restored := app.H.Reload(admin.ID)
1232 if !restored.Active() || restored.Role != idear.RoleAdmin {
1233 t.Fatalf("the restored member is %s/%v", restored.Role, restored.Active())
1234 }
1235 for _, rt := range guarded(app) {
1236 res := probe(c, rt)
1237 key := rt.Method + " " + rt.Pattern
1238 if res.Status != before[key] {
1239 t.Errorf("after restore: %s → %d, but before removal it was %d", key, res.Status, before[key])
1240 }
1241 }
1242
1243 // "And no more": the one thing an admin could never do is still
1244 // refused, and the owner is still the owner.
1245 if res := c.Post("/members/transfer", url.Values{"member": {fmt.Sprint(admin.ID)}}); res.Status != http.StatusForbidden {
1246 t.Errorf("a restored admin transferring ownership → %d, want 403", res.Status)
1247 }
1248 if got := app.H.TheOwner(); got.ID != owner.ID {
1249 t.Fatalf("ownership moved to %d", got.ID)
1250 }
1251}
1252
1253// ---------------------------------------------------------------
1254// §7.12 — the public GET is not an address oracle.
1255// ---------------------------------------------------------------
1256
1257func TestInvitationPageDoesNotDiscloseTheAddress(t *testing.T) {
1258 app, col := newApp(t)
1259 oc := app.As(app.H.Owner())
1260
1261 const (
1262 local = "secret.person"
1263 domain = "hidden.example"
1264 invited = local + "@" + domain
1265 )
1266 token := invite(t, app, col, oc, invited, idear.RoleAdmin)
1267
1268 // The renderer prints every field of InvitationPage by reflection,
1269 // so this is a test of what the HANDLER passed and not of what a
1270 // template chose to show.
1271 for what, c := range map[string]*ideartest.Client{
1272 "an anonymous visitor": app.Visitor(),
1273 "a signed-in stranger": app.SignIn("nosy@example.test"),
1274 } {
1275 res := c.Get("/invitations/" + token)
1276 if res.Status != http.StatusOK {
1277 t.Fatalf("%s: GET the invitation → %d; body %q", what, res.Status, res.Body)
1278 }
1279 for _, secret := range []string{invited, local, domain} {
1280 if strings.Contains(strings.ToLower(res.Body), secret) {
1281 t.Errorf("%s: the invitation page disclosed %q; body %q", what, secret, res.Body)
1282 }
1283 }
1284 // It does say what the holder is entitled to know.
1285 if !strings.Contains(res.Body, string(idear.RoleAdmin)) {
1286 t.Errorf("%s: the invitation page does not name the role; body %q", what, res.Body)
1287 }
1288 if !strings.Contains(res.Body, app.Server.Listener.Addr().String()) {
1289 t.Errorf("%s: the invitation page does not name the instance; body %q", what, res.Body)
1290 }
1291 }
1292
1293 // An unusable token answers the SAME way whichever way it is
1294 // unusable, so the page cannot be used to tell a real token from a
1295 // spent one.
1296 visitor := app.Visitor()
1297 bodies := map[string]bool{}
1298 for _, tok := range []string{"never-existed", strings.Repeat("a", 64)} {
1299 res := visitor.Get("/invitations/" + tok)
1300 if res.Status != http.StatusNotFound {
1301 t.Errorf("GET %q → %d, want 404", tok, res.Status)
1302 }
1303 bodies[res.Body] = true
1304 }
1305 // ...including one that WAS real and has been revoked.
1306 invs, err := app.H.Roster.PendingInvitations(app.H.Ctx())
1307 if err != nil || len(invs) != 1 {
1308 t.Fatalf("pending invitations = %v, %v", invs, err)
1309 }
1310 if res := oc.Post(fmt.Sprintf("/members/invitations/%d/revoke", invs[0].ID), url.Values{}); res.Status != http.StatusSeeOther {
1311 t.Fatalf("revoking → %d", res.Status)
1312 }
1313 res := visitor.Get("/invitations/" + token)
1314 if res.Status != http.StatusNotFound {
1315 t.Errorf("GET a revoked invitation → %d, want 404", res.Status)
1316 }
1317 bodies[res.Body] = true
1318 if len(bodies) != 1 {
1319 t.Fatalf("unusable invitations rendered %d distinct bodies, want 1: %v", len(bodies), bodies)
1320 }
1321}
1322
1323// ---------------------------------------------------------------
1324// The role selector, CSRF, and the wiring.
1325// ---------------------------------------------------------------
1326
1327// TestGrantableMatchesTheDesign pins WHAT the rule is, which no
1328// amount of derivation can do for itself.
1329//
1330// TestGrantableIsTheAllowList below checks that the page, the store
1331// and Grantable agree. They agree by construction — Grantable asks
1332// checkInviteRole, the test asks Grantable — so relaxing the rule
1333// moves the expectation with it: changing checkInviteRole's
1334// `rank(role) >= rank(actor.Role)` to `>`, which lets an Admin mint a
1335// PEER ADMIN and puts the new admin beyond every other admin's reach,
1336// left every test in this file green. That is the brief's anti-pattern
1337// in mirror form: total derivation buys drift-resistance and loses
1338// rule-change detection.
1339//
1340// So this states the matrix from
1341// docs/superpowers/specs/2026-08-23-idear-design.md §5 — "Admins
1342// manage Members only", "the target's rank must be strictly below the
1343// actor's", "ownership moves only by Transfer" — and compares it in
1344// BOTH directions, exactly as TestRoutesMatchTheDesign does for the
1345// route table. It quotes the spec, never the implementation.
1346func TestGrantableMatchesTheDesign(t *testing.T) {
1347 design := map[idear.Role][]idear.Role{
1348 // An Owner may grant Admin and Member. Not Owner: ownership
1349 // moves only by Transfer.
1350 idear.RoleOwner: {idear.RoleAdmin, idear.RoleMember},
1351 // An Admin may grant Member ONLY. An Admin who could mint a
1352 // peer Admin has escalated — MayActOn refuses acting on an
1353 // equal rank, so the new Admin is beyond the granter's reach
1354 // and beyond every other Admin's too.
1355 idear.RoleAdmin: {idear.RoleMember},
1356 // A Member manages nothing.
1357 idear.RoleMember: nil,
1358 }
1359
1360 for actorRole, want := range design {
1361 actor := &idear.Member{ID: 1, Role: actorRole}
1362 got := idear.Grantable(actor)
1363
1364 inGot := map[idear.Role]bool{}
1365 for _, role := range got {
1366 inGot[role] = true
1367 }
1368 inWant := map[idear.Role]bool{}
1369 for _, role := range want {
1370 inWant[role] = true
1371 }
1372 for _, role := range want {
1373 if !inGot[role] {
1374 t.Errorf("a %s may grant %s by the design, and Grantable does not offer it", actorRole, role)
1375 }
1376 }
1377 for _, role := range got {
1378 if !inWant[role] {
1379 t.Errorf("Grantable offers %s to a %s; the design says %v", role, actorRole, want)
1380 }
1381 }
1382 if len(got) != len(want) {
1383 t.Errorf("Grantable(%s) = %v, the design says %v", actorRole, got, want)
1384 }
1385 }
1386
1387 // A deactivated actor grants nothing, whatever rank the row still
1388 // carries: removal is never a delete, so the row outlives the
1389 // privileges.
1390 past := time.Now().UTC()
1391 for actorRole := range design {
1392 gone := &idear.Member{ID: 1, Role: actorRole, DeactivatedAt: &past}
1393 if got := idear.Grantable(gone); len(got) != 0 {
1394 t.Errorf("a deactivated %s is offered %v", actorRole, got)
1395 }
1396 }
1397 if got := idear.Grantable(nil); len(got) != 0 {
1398 t.Errorf("Grantable(nil) = %v, want nothing", got)
1399 }
1400}
1401
1402// TestGrantableIsTheAllowList checks the role selector against the
1403// store, in both directions, for every actor.
1404//
1405// The payloads are DERIVED: the roles it tries are the ones Grantable
1406// offers and the ones it does not, and the expectation flips on
1407// membership of that list rather than on a hand-written table. An
1408// Admin offered Admin would 403 on every submit — the selector and the
1409// store have to agree, and this is what makes them.
1410func TestGrantableIsTheAllowList(t *testing.T) {
1411 all := []idear.Role{idear.RoleOwner, idear.RoleAdmin, idear.RoleMember}
1412
1413 for _, actorRole := range []idear.Role{idear.RoleOwner, idear.RoleAdmin} {
1414 t.Run(string(actorRole), func(t *testing.T) {
1415 app, _ := newApp(t)
1416 owner := app.H.Owner()
1417 actor := owner
1418 if actorRole != idear.RoleOwner {
1419 actor = app.H.Member(actorRole)
1420 }
1421 c := app.As(actor)
1422
1423 offered := map[idear.Role]bool{}
1424 for _, role := range idear.Grantable(actor) {
1425 offered[role] = true
1426 }
1427 if offered[idear.RoleOwner] {
1428 t.Error("the selector offers Owner; ownership moves only by Transfer")
1429 }
1430
1431 // The page must show exactly what Grantable says.
1432 page := c.Get("/members")
1433 for _, role := range all {
1434 line := "grantable " + string(role)
1435 if got := strings.Contains(page.Body, line); got != offered[role] {
1436 t.Errorf("the members page %s %s, but Grantable says %v", map[bool]string{true: "offers", false: "does not offer"}[got], role, offered[role])
1437 }
1438 }
1439
1440 // And the store agrees with the page, both ways.
1441 for i, role := range all {
1442 email := fmt.Sprintf("candidate-%d@example.test", i)
1443 res := c.Post("/members/invitations", url.Values{"email": {email}, "role": {string(role)}})
1444 if offered[role] {
1445 if res.Status != http.StatusSeeOther {
1446 t.Errorf("inviting at an OFFERED role %s → %d, want 303; body %q", role, res.Status, res.Body)
1447 }
1448 continue
1449 }
1450 if res.Status != http.StatusForbidden {
1451 t.Errorf("inviting at an UNOFFERED role %s → %d, want 403; body %q", role, res.Status, res.Body)
1452 }
1453 }
1454
1455 // Nothing was minted above the actor's own rank.
1456 var invs []idear.Invitation
1457 if err := app.H.DB.G.Find(&invs).Error; err != nil {
1458 t.Fatalf("listing invitations: %v", err)
1459 }
1460 for _, inv := range invs {
1461 if !offered[inv.Role] {
1462 t.Errorf("an invitation was minted at %s, which %s may not grant", inv.Role, actorRole)
1463 }
1464 }
1465 })
1466 }
1467}
1468
1469// TestCrossOriginPostsAreRefused drives every mutating route from
1470// another origin — the shape a CSRF attack actually takes against an
1471// origin-checking framework — and demands that none of them run.
1472func TestCrossOriginPostsAreRefused(t *testing.T) {
1473 app, _ := newApp(t)
1474 owner := app.H.Owner()
1475 victim := app.H.Member(idear.RoleMember)
1476 c := app.As(owner)
1477
1478 for _, rt := range app.Handlers.Routes() {
1479 if rt.Method != http.MethodPost {
1480 continue
1481 }
1482 path := instantiate(rt.Pattern, fmt.Sprint(victim.ID), "0")
1483 res := c.PostFrom("https://evil.example", path, url.Values{
1484 "role": {string(idear.RoleAdmin)},
1485 "email": {"attacker@evil.example"},
1486 "member": {fmt.Sprint(victim.ID)},
1487 })
1488 if res.Status != http.StatusForbidden {
1489 t.Errorf("cross-origin %s %s → %d, want 403; body %q", rt.Method, path, res.Status, res.Body)
1490 }
1491 }
1492 if got := app.H.Reload(victim.ID); got.Role != idear.RoleMember || !got.Active() {
1493 t.Errorf("a cross-origin post landed: the victim is %s/%v", got.Role, got.Active())
1494 }
1495 invs, err := app.H.Roster.PendingInvitations(app.H.Ctx())
1496 if err != nil {
1497 t.Fatalf("listing invitations: %v", err)
1498 }
1499 if len(invs) != 0 {
1500 t.Errorf("a cross-origin post minted %d invitations", len(invs))
1501 }
1502}
1503
1504func TestNewHandlersRequiresItsSeams(t *testing.T) {
1505 h := ideartest.New(t)
1506 renderMembers := func(http.ResponseWriter, *http.Request, idear.MembersPage) {}
1507 renderInvitation := func(http.ResponseWriter, *http.Request, idear.InvitationPage) {}
1508
1509 for what, cfg := range map[string]idear.HandlerConfig{
1510 "no roster": {RenderMembers: renderMembers, RenderInvitation: renderInvitation},
1511 "no members": {Roster: h.Roster, RenderInvitation: renderInvitation},
1512 "no invitation": {Roster: h.Roster, RenderMembers: renderMembers},
1513 } {
1514 if _, err := idear.NewHandlers(cfg); err == nil {
1515 t.Errorf("NewHandlers with %s returned no error", what)
1516 }
1517 }
1518 if _, err := idear.NewHandlers(idear.HandlerConfig{
1519 Roster: h.Roster, RenderMembers: renderMembers, RenderInvitation: renderInvitation,
1520 }); err != nil {
1521 t.Errorf("NewHandlers with everything set: %v", err)
1522 }
1523}
1524
1525// TestInviteWithoutDeliverFlashesTheLink covers the default wiring: an
1526// app with no Deliver hook still gets a usable link, exactly once, and
1527// it works.
1528func TestInviteWithoutDeliverFlashesTheLink(t *testing.T) {
1529 app := ideartest.NewApp(t)
1530 c := app.As(app.H.Owner())
1531
1532 res := c.Post("/members/invitations", url.Values{
1533 "email": {"linked@example.test"},
1534 "role": {string(idear.RoleMember)},
1535 })
1536 if res.Status != http.StatusSeeOther {
1537 t.Fatalf("invite → %d; body %q", res.Status, res.Body)
1538 }
1539 page := c.Follow(res)
1540 link := ""
1541 for _, f := range strings.Fields(page.Body) {
1542 if strings.HasPrefix(f, "/invitations/") {
1543 link = f
1544 }
1545 }
1546 if link == "" {
1547 t.Fatalf("the members page carries no invitation link; body %q", page.Body)
1548 }
1549 if got := c.Get("/members"); strings.Contains(got.Body, link) {
1550 t.Error("the flash notice survived a second page load; it must be one-shot")
1551 }
1552 if got := app.Visitor().Get(link); got.Status != http.StatusOK {
1553 t.Fatalf("the flashed link answers %d, so it is not usable; body %q", got.Status, got.Body)
1554 }
1555}
1556
1557// TestPublicRoutesAreRateLimited proves the limiter is mounted on BOTH
1558// public routes and on neither guarded one.
1559func TestPublicRoutesAreRateLimited(t *testing.T) {
1560 const burst = 4
1561 app := ideartest.NewAppWith(t, idear.Config{}, idear.HandlerConfig{
1562 // One token an hour back, so nothing refills mid-test.
1563 RateLimit: idear.RateLimit{Burst: burst, Every: time.Hour},
1564 })
1565 owner := app.H.Owner()
1566 c := app.As(owner)
1567
1568 var public []idear.Route
1569 for _, rt := range app.Handlers.Routes() {
1570 if rt.Public {
1571 public = append(public, rt)
1572 }
1573 }
1574 if len(public) != 2 {
1575 t.Fatalf("derived %d public routes, want the 2 the design lists", len(public))
1576 }
1577
1578 // The budget is shared across both public routes, because it is
1579 // per-client and not per-route.
1580 spent := 0
1581 for _, rt := range public {
1582 path := instantiate(rt.Pattern, "0", "no-such-token")
1583 for range burst {
1584 var res *ideartest.Result
1585 if rt.Method == http.MethodGet {
1586 res = c.Get(path)
1587 } else {
1588 res = c.Post(path, url.Values{})
1589 }
1590 spent++
1591 if spent <= burst && res.Status == http.StatusTooManyRequests {
1592 t.Fatalf("request %d of a burst of %d was rate-limited", spent, burst)
1593 }
1594 if spent > burst && res.Status != http.StatusTooManyRequests {
1595 t.Fatalf("request %d → %d, want 429 once the burst is spent", spent, res.Status)
1596 }
1597 }
1598 }
1599
1600 // The guarded routes are untouched by the limiter: a signed-in
1601 // owner is not throttled off the members page by somebody else's
1602 // guessing.
1603 if res := c.Get("/members"); res.Status != http.StatusOK {
1604 t.Errorf("the members page → %d while the public budget is spent", res.Status)
1605 }
1606}
1607
1608// TestRoutesMatchTheDesign pins the route table to the DESIGN, which
1609// is the one thing the derived tests above cannot do for themselves.
1610//
1611// Everything else in this file reads Route.Min and asks whether the
1612// mounted middleware agrees with it. That catches a guard that drifts
1613// from its declaration — but not a floor LOWERED in both places at
1614// once, which is the change that quietly turns "admins invite" into
1615// "anybody invites". So this test states the floors from
1616// docs/superpowers/specs/2026-08-23-idear-design.md §5 and compares
1617// them exhaustively, in both directions: a route the design does not
1618// list is a failure, and a route the design lists that is not mounted
1619// is a failure too.
1620//
1621// It is the one place in the suite that quotes a list, and what it
1622// quotes is the spec rather than the implementation.
1623func TestRoutesMatchTheDesign(t *testing.T) {
1624 type pin struct {
1625 min idear.Role
1626 public bool
1627 }
1628 design := map[string]pin{
1629 "GET /members": {min: idear.RoleMember},
1630 "POST /members/invitations": {min: idear.RoleAdmin},
1631 "POST /members/invitations/{id}/revoke": {min: idear.RoleAdmin},
1632 "POST /members/{id}/role": {min: idear.RoleAdmin},
1633 "POST /members/{id}/remove": {min: idear.RoleAdmin},
1634 "POST /members/{id}/restore": {min: idear.RoleAdmin},
1635 "POST /members/transfer": {min: idear.RoleOwner},
1636 "GET /invitations/{token}": {public: true},
1637 "POST /invitations/{token}": {public: true},
1638 }
1639
1640 app := ideartest.NewApp(t)
1641 seen := map[string]bool{}
1642 for _, rt := range app.Handlers.Routes() {
1643 key := rt.Method + " " + rt.Pattern
1644 want, ok := design[key]
1645 if !ok {
1646 t.Errorf("%s is mounted but the design does not list it", key)
1647 continue
1648 }
1649 seen[key] = true
1650 if rt.Public != want.public {
1651 t.Errorf("%s: Public = %v, the design says %v", key, rt.Public, want.public)
1652 }
1653 if rt.Min != want.min {
1654 t.Errorf("%s: rank floor = %q, the design says %q", key, rt.Min, want.min)
1655 }
1656 }
1657 for key := range design {
1658 if !seen[key] {
1659 t.Errorf("%s is in the design and is not mounted", key)
1660 }
1661 }
1662}
1663
1664// TestFieldsComeFromTheBodyNotTheQuery pins where a mutation's fields
1665// are allowed to come from.
1666//
1667// idear reads PostForm and not Form. The difference is a real
1668// escalation route: with Form, a link ending "?role=admin" would
1669// supply the field for a POST whose body never mentioned one, so a
1670// crafted link plus any submitted form on that page would grant a
1671// rank the submitter never typed.
1672func TestFieldsComeFromTheBodyNotTheQuery(t *testing.T) {
1673 app, _ := newApp(t)
1674 owner := app.H.Owner()
1675 target := app.H.Member(idear.RoleMember)
1676 c := app.As(owner)
1677
1678 path := fmt.Sprintf("/members/%d/role?role=%s", target.ID, idear.RoleAdmin)
1679
1680 // The body wins: it says member, the query says admin.
1681 if res := c.Post(path, url.Values{"role": {string(idear.RoleMember)}}); res.Status != http.StatusSeeOther {
1682 t.Fatalf("setting a role → %d; body %q", res.Status, res.Body)
1683 }
1684 if got := app.H.Reload(target.ID); got.Role != idear.RoleMember {
1685 t.Fatalf("the target is %s; the query string supplied the role", got.Role)
1686 }
1687
1688 // And with no role in the body it is a malformed request, not one
1689 // the query string may complete.
1690 res := c.Post(path, url.Values{})
1691 if res.Status != http.StatusBadRequest {
1692 t.Errorf("a POST with no role in its body → %d, want 400; body %q", res.Status, res.Body)
1693 }
1694 if got := app.H.Reload(target.ID); got.Role != idear.RoleMember {
1695 t.Fatalf("the target is %s; the query string completed a bodyless post", got.Role)
1696 }
1697}
1698
1699// TestMultipartFormsAreRead covers the encoding an app reaches the
1700// moment its members form grows a file input. ParseForm alone does not
1701// populate PostForm for multipart/form-data, so every field would read
1702// empty and every submit would 400 with nothing to explain it — a
1703// fail-closed failure, and an undiagnosable one.
1704func TestMultipartFormsAreRead(t *testing.T) {
1705 app, _ := newApp(t)
1706 target := app.H.Member(idear.RoleMember)
1707 c := app.As(app.H.Owner())
1708
1709 res := c.PostMultipart(fmt.Sprintf("/members/%d/role", target.ID),
1710 url.Values{"role": {string(idear.RoleAdmin)}})
1711 if res.Status != http.StatusSeeOther {
1712 t.Fatalf("a multipart role change → %d, want 303; body %q", res.Status, res.Body)
1713 }
1714 if got := app.H.Reload(target.ID); got.Role != idear.RoleAdmin {
1715 t.Fatalf("the target is %s; the multipart body was not read", got.Role)
1716 }
1717
1718 // And a multipart body with no role in it is still a 400, not a
1719 // path around the field check.
1720 if res := c.PostMultipart(fmt.Sprintf("/members/%d/role", target.ID), url.Values{}); res.Status != http.StatusBadRequest {
1721 t.Errorf("a multipart post with no role → %d, want 400", res.Status)
1722 }
1723}
1724
1725// TestEveryHandlerIsMounted closes the hole under the whole suite:
1726// Routes() is what every derived test walks, so an exported handler
1727// added WITHOUT a Routes() entry is invisible to all of them — it
1728// would ship with no membership gate and no test would notice.
1729//
1730// The count is the assertion because the names cannot be: a func value
1731// in a Route carries no method name to compare against. Reflection
1732// over *Handlers finds every exported method with a handler's
1733// signature; there must be exactly as many as there are routes.
1734func TestEveryHandlerIsMounted(t *testing.T) {
1735 app := ideartest.NewApp(t)
1736
1737 handlerish := reflect.TypeOf(func(http.ResponseWriter, *http.Request) {})
1738 typ := reflect.TypeOf(app.Handlers)
1739 handlers := 0
1740 var names []string
1741 for i := range typ.NumMethod() {
1742 m := typ.Method(i)
1743 // A method's own type has the receiver first; compare what is
1744 // left against the handler signature.
1745 if m.Type.NumIn() == handlerish.NumIn()+1 && m.Type.NumOut() == 0 &&
1746 m.Type.In(1) == handlerish.In(0) && m.Type.In(2) == handlerish.In(1) {
1747 handlers++
1748 names = append(names, m.Name)
1749 }
1750 }
1751 if handlers == 0 {
1752 t.Fatal("reflection found no handler methods at all; the check is broken")
1753 }
1754 if got := len(app.Handlers.Routes()); got != handlers {
1755 t.Fatalf("*Handlers has %d handler methods (%v) but Routes() mounts %d; an unmounted handler is invisible to every derived test in this file", handlers, names, got)
1756 }
1757}
1758