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. Under
347// PeerGrants the Admin half is lifted; the Owner half never is.
348// ---------------------------------------------------------------
349
350func TestAdminCannotActOnAdminOrOwner(t *testing.T) {
351 app, _ := newApp(t)
352 owner := app.H.Owner()
353 actor := app.H.Member(idear.RoleAdmin)
354 peer := app.H.Member(idear.RoleAdmin)
355 goneAdmin := app.H.Deactivated(idear.RoleAdmin)
356 c := app.As(actor)
357
358 // Every target an Admin may not act on, including themselves —
359 // self-management is how an instance ends up with nobody able to
360 // administer it. Peer admins are on the list only in the default
361 // build; TestAdminActsOnPeerAdmin covers them under PeerGrants.
362 targets := map[string]*idear.Member{
363 "the owner": owner,
364 "themselves": actor,
365 }
366 if !idear.PeerGrants {
367 targets["a peer admin"] = peer
368 targets["a deactivated admin"] = goneAdmin
369 }
370 // The three target-shaped mutations, derived from the route table
371 // rather than listed: every management route that carries an {id}.
372 var byID []idear.Route
373 for _, rt := range management(app) {
374 if strings.Contains(rt.Pattern, "{id}") && !strings.Contains(rt.Pattern, "invitations") {
375 byID = append(byID, rt)
376 }
377 }
378 if len(byID) == 0 {
379 t.Fatal("no id-addressed management routes were derived; the derivation is broken")
380 }
381
382 for what, target := range targets {
383 for _, rt := range byID {
384 path := instantiate(rt.Pattern, fmt.Sprint(target.ID), "0")
385 res := c.Post(path, url.Values{"role": {string(idear.RoleMember)}})
386 if res.Status != http.StatusForbidden {
387 t.Errorf("admin → %s: %s %s → %d, want 403; body %q", what, rt.Method, path, res.Status, res.Body)
388 }
389 }
390 }
391
392 // The rows, not the responses.
393 if got := app.H.Reload(owner.ID); got.Role != idear.RoleOwner || !got.Active() {
394 t.Errorf("the owner is now %s/%v after an admin's attempts", got.Role, got.Active())
395 }
396 if got := app.H.Reload(peer.ID); got.Role != idear.RoleAdmin || !got.Active() {
397 t.Errorf("the peer admin is now %s/%v after an admin's attempts", got.Role, got.Active())
398 }
399 if got := app.H.Reload(goneAdmin.ID); got.Active() {
400 t.Error("an admin restored a deactivated admin; only the owner may act on an admin")
401 }
402 if got := app.H.Reload(actor.ID); got.Role != idear.RoleAdmin || !got.Active() {
403 t.Errorf("the acting admin acted on themselves: now %s/%v", got.Role, got.Active())
404 }
405
406 // A member id that resolves to NOTHING is answered by the same
407 // hook, with the same bytes, as a non-member's refusal — which is
408 // what refuse()'s "same hook, same bytes" comment claims and
409 // nothing else asserted.
410 for _, rt := range byID {
411 path := instantiate(rt.Pattern, "999999999", "0")
412 res := c.Post(path, url.Values{"role": {string(idear.RoleMember)}})
413 if res.Status != http.StatusNotFound {
414 t.Errorf("%s on a nonexistent member → %d, want 404; body %q", path, res.Status, res.Body)
415 }
416 if res.Body != ideartest.AppNotFound {
417 t.Errorf("%s answered 404 with %q, want the app's own 404 page", path, res.Body)
418 }
419 }
420
421 // What the admin CAN do, so the test is not green because the
422 // admin can do nothing at all.
423 plain := app.H.Member(idear.RoleMember)
424 res := c.Post(fmt.Sprintf("/members/%d/remove", plain.ID), url.Values{})
425 if res.Status != http.StatusSeeOther {
426 t.Fatalf("an admin removing a member → %d, want 303; body %q", res.Status, res.Body)
427 }
428 if app.H.Reload(plain.ID).Active() {
429 t.Error("an admin's legitimate removal did not land")
430 }
431}
432
433// TestAdminActsOnPeerAdmin walks one Admin through the whole peer
434// lifecycle over HTTP — invite an Admin, promote a Member to Admin,
435// demote them, remove them, restore them — and states the answer for
436// each build by hand: every step 403 in the default build, every step
437// landing under PeerGrants.
438func TestAdminActsOnPeerAdmin(t *testing.T) {
439 app, _ := newApp(t)
440 actor := app.H.Member(idear.RoleAdmin)
441 peer := app.H.Member(idear.RoleMember)
442 c := app.As(actor)
443
444 want := http.StatusForbidden
445 if idear.PeerGrants {
446 want = http.StatusSeeOther
447 }
448 steps := []struct {
449 path string
450 form url.Values
451 }{
452 {"/members/invitations", url.Values{"email": {"peer@example.test"}, "role": {string(idear.RoleAdmin)}}},
453 {fmt.Sprintf("/members/%d/role", peer.ID), url.Values{"role": {string(idear.RoleAdmin)}}},
454 {fmt.Sprintf("/members/%d/role", peer.ID), url.Values{"role": {string(idear.RoleMember)}}},
455 {fmt.Sprintf("/members/%d/role", peer.ID), url.Values{"role": {string(idear.RoleAdmin)}}},
456 {fmt.Sprintf("/members/%d/remove", peer.ID), url.Values{}},
457 {fmt.Sprintf("/members/%d/restore", peer.ID), url.Values{}},
458 }
459 for i, st := range steps {
460 // In the default build step 2 is refused, so the peer is still
461 // a Member at steps 3-6 and within reach after all. Owner-made
462 // promotion puts them where the refusals are meaningful.
463 if i == 2 && !idear.PeerGrants {
464 if err := app.H.Roster.SetRole(app.H.Ctx(), app.H.Owner(), peer, idear.RoleAdmin); err != nil {
465 t.Fatalf("the owner promoting the peer: %v", err)
466 }
467 }
468 res := c.Post(st.path, st.form)
469 if res.Status != want {
470 t.Errorf("step %d: admin POST %s %v → %d, want %d; body %q", i, st.path, st.form, res.Status, want, res.Body)
471 }
472 }
473
474 // Both builds end with the peer an active Admin: under PeerGrants
475 // by the admin's own last promote and restore, in the default build
476 // because the owner promoted them and every admin step bounced.
477 if got := app.H.Reload(peer.ID); got.Role != idear.RoleAdmin || !got.Active() {
478 t.Errorf("the peer is now %s/%v, want an active admin", got.Role, got.Active())
479 }
480 invs, err := app.H.Roster.PendingInvitations(app.H.Ctx())
481 if err != nil {
482 t.Fatalf("listing invitations: %v", err)
483 }
484 var adminInvites int
485 for _, inv := range invs {
486 if inv.Role == idear.RoleAdmin {
487 adminInvites++
488 }
489 }
490 if wantInvites := map[bool]int{false: 0, true: 1}[idear.PeerGrants]; adminInvites != wantInvites {
491 t.Errorf("%d admin invitations pending, want %d", adminInvites, wantInvites)
492 }
493}
494
495// ---------------------------------------------------------------
496// §7.4 — a posted role=owner never lands, on any path, for any actor.
497// ---------------------------------------------------------------
498
499func TestPostedOwnerRoleNeverLands(t *testing.T) {
500 // One instance per actor, so an attempt by one cannot be masked by
501 // a refusal for another.
502 actors := []struct {
503 name string
504 role idear.Role
505 }{
506 {"the owner", idear.RoleOwner},
507 {"an admin", idear.RoleAdmin},
508 {"a member", idear.RoleMember},
509 {"a stranger", ""},
510 }
511 for _, actor := range actors {
512 t.Run(actor.name, func(t *testing.T) {
513 app, _ := newApp(t)
514 owner := app.H.Owner()
515 admin := app.H.Member(idear.RoleAdmin)
516 plain := app.H.Member(idear.RoleMember)
517 gone := app.H.Deactivated(idear.RoleMember)
518
519 var c *ideartest.Client
520 switch actor.role {
521 case idear.RoleOwner:
522 c = app.As(owner)
523 case idear.RoleAdmin:
524 c = app.As(admin)
525 case idear.RoleMember:
526 c = app.As(plain)
527 default:
528 c = app.SignIn("stranger-with-no-row")
529 }
530
531 // The payload is ONE form for every route: every field
532 // idear reads, all of them carrying the escalation.
533 //
534 // "member" names a row that does not exist, so the ONE
535 // route that may legitimately mint an owner cannot
536 // succeed here and no 303 below can be a real transfer.
537 // (That Transfer works at all, and reads no role while
538 // doing it, is TestTransferIsTheOnlyPathToOwner.)
539 attack := url.Values{
540 "role": {string(idear.RoleOwner)},
541 "email": {"escalation@example.test"},
542 "member": {"999999999"},
543 }
544 // Every route the app mounts, public ones included, at
545 // every interesting target. Some of these legitimately
546 // answer 303 — an admin really may remove a member — so
547 // the status is not the assertion; the rows below are.
548 for _, rt := range app.Handlers.Routes() {
549 if rt.Method != http.MethodPost {
550 continue
551 }
552 for _, target := range []*idear.Member{owner, admin, plain, gone} {
553 path := instantiate(rt.Pattern, fmt.Sprint(target.ID), "0")
554 c.Post(path, attack)
555 }
556 }
557
558 // THE ROWS. Exactly one owner, and it is the row that was
559 // seeded as owner — no promotion, and no new row.
560 if got := app.H.TheOwner(); got.ID != owner.ID {
561 t.Fatalf("the owner is now member %d (%s), want the seeded owner %d", got.ID, got.Subject, owner.ID)
562 }
563 for _, m := range []*idear.Member{admin, plain, gone} {
564 if got := app.H.Reload(m.ID); got.Role == idear.RoleOwner {
565 t.Errorf("member %d was promoted to owner by a posted role", m.ID)
566 }
567 }
568 // And no invitation carries the role either: an
569 // owner-role invitation is a delayed escalation, so the
570 // refusal has to hold at the mint and not only at the
571 // redemption.
572 var invs []idear.Invitation
573 if err := app.H.DB.G.Where("role = ?", idear.RoleOwner).Find(&invs).Error; err != nil {
574 t.Fatalf("listing owner invitations: %v", err)
575 }
576 if len(invs) != 0 {
577 t.Errorf("%d invitations were minted at role owner", len(invs))
578 }
579 })
580 }
581}
582
583// TestTransferIsTheOnlyPathToOwner is the other half of §7.4: the one
584// route that MAY mint an owner does so from its own rules and never
585// from the posted role, and the instance still has exactly one.
586func TestTransferIsTheOnlyPathToOwner(t *testing.T) {
587 app, _ := newApp(t)
588 owner := app.H.Owner()
589 admin := app.H.Member(idear.RoleAdmin)
590 c := app.As(owner)
591
592 res := c.Post("/members/transfer", url.Values{
593 "member": {fmt.Sprint(admin.ID)},
594 // Posted, and irrelevant: Transfer reads no role at all.
595 "role": {string(idear.RoleMember)},
596 })
597 if res.Status != http.StatusSeeOther {
598 t.Fatalf("transfer → %d, want 303; body %q", res.Status, res.Body)
599 }
600 if got := app.H.TheOwner(); got.ID != admin.ID {
601 t.Fatalf("ownership landed on member %d, want %d", got.ID, admin.ID)
602 }
603 if got := app.H.Reload(owner.ID); got.Role != idear.RoleAdmin {
604 t.Errorf("the outgoing owner is %s, want admin", got.Role)
605 }
606}
607
608// ---------------------------------------------------------------
609// §7.5 — the single-owner invariant across concurrent transfers.
610// ---------------------------------------------------------------
611
612func TestConcurrentTransfersThroughHTTPLeaveOneOwner(t *testing.T) {
613 const (
614 n = 6
615 rounds = 5
616 )
617 for round := range rounds {
618 app, _ := newApp(t)
619 owner := app.H.Owner()
620 targets := make([]*idear.Member, n)
621 for i := range targets {
622 targets[i] = app.H.Member(idear.RoleAdmin)
623 }
624 c := app.As(owner)
625
626 gate, wait := release()
627 codes := make([]int, n)
628 errs := make([]error, n)
629 var wg sync.WaitGroup
630 for i := range n {
631 wg.Add(1)
632 // The spawn order alternates with i's parity for the same
633 // reason pair() flips: closing a channel readies waiters
634 // FIFO and the last one readied runs first, so a fixed
635 // order biases which racer wins.
636 go func() {
637 defer wg.Done()
638 wait()
639 res, err := c.TryPost("/members/transfer", url.Values{"member": {fmt.Sprint(targets[i].ID)}})
640 if err != nil {
641 errs[i] = err
642 return
643 }
644 codes[i] = res.Status
645 }()
646 }
647 close(gate)
648 wg.Wait()
649
650 won := 0
651 for i, code := range codes {
652 if errs[i] != nil {
653 t.Fatalf("round %d: transfer %d failed in transport: %v", round, i, errs[i])
654 }
655 if code == http.StatusSeeOther {
656 won++
657 }
658 }
659 if won != 1 {
660 t.Fatalf("round %d: %d of %d concurrent transfers were accepted, want exactly 1 (codes %v)", round, won, n, codes)
661 }
662 got := app.H.TheOwner()
663 if !got.Active() {
664 t.Fatalf("round %d: the surviving owner %d is deactivated", round, got.ID)
665 }
666 if got.ID == owner.ID {
667 t.Fatalf("round %d: a transfer was accepted but ownership did not move", round)
668 }
669 }
670}
671
672// ---------------------------------------------------------------
673// §7.6 — an invited address cannot be claimed without the token.
674// ---------------------------------------------------------------
675
676func TestInvitedAddressCannotBeClaimedWithoutTheToken(t *testing.T) {
677 app, col := newApp(t)
678 owner := app.H.Owner()
679 oc := app.As(owner)
680
681 const invited = "admin@corp.test"
682 token := invite(t, app, col, oc, invited, idear.RoleAdmin)
683
684 // The attacker knows the address — it is the whole premise — and
685 // signs in as it (which is what keymail's subject looks like).
686 // Every public route, without the token, must leave them out.
687 attacker := app.SignIn(invited)
688 for _, rt := range app.Handlers.Routes() {
689 if !rt.Public {
690 continue
691 }
692 for _, tok := range []string{"", "not-a-token", strings.Repeat("0", 64)} {
693 path := instantiate(rt.Pattern, "0", url.PathEscape(tok))
694 var res *ideartest.Result
695 if rt.Method == http.MethodGet {
696 res = attacker.Get(path)
697 } else {
698 res = attacker.Post(path, url.Values{"role": {"owner"}, "email": {invited}})
699 }
700 if res.Status == http.StatusSeeOther {
701 t.Errorf("%s %s admitted an address with no token", rt.Method, path)
702 }
703 }
704 }
705 if m := memberBySubject(t, app, invited); m != nil {
706 t.Fatalf("the invited address was admitted at %s with no token", m.Role)
707 }
708 if got := app.H.CountMembers(); got != 1 {
709 t.Fatalf("the roster holds %d rows, want just the owner", got)
710 }
711
712 // The token still works afterwards — the refusals above did not
713 // consume it, which is what makes them refusals rather than a
714 // broken flow.
715 res := attacker.Post("/invitations/"+token, url.Values{})
716 if res.Status != http.StatusSeeOther {
717 t.Fatalf("redeeming the real token → %d, want 303; body %q", res.Status, res.Body)
718 }
719 m := memberBySubject(t, app, invited)
720 if m == nil || m.Role != idear.RoleAdmin {
721 t.Fatalf("after redeeming the token the member is %+v, want an admin", m)
722 }
723}
724
725// ---------------------------------------------------------------
726// §7.7 — Revoke racing Accept never admits.
727// ---------------------------------------------------------------
728
729func TestRevokeRacingAcceptThroughHTTPNeverAdmits(t *testing.T) {
730 const rounds = 24
731 admitted, killed := 0, 0
732
733 for round := range rounds {
734 app, col := newApp(t)
735 owner := app.H.Owner()
736 oc := app.As(owner)
737
738 const invitee = "racer@example.test"
739 token := invite(t, app, col, oc, invitee, idear.RoleMember)
740 invs, err := app.H.Roster.PendingInvitations(app.H.Ctx())
741 if err != nil || len(invs) != 1 {
742 t.Fatalf("round %d: pending invitations = %v, %v", round, invs, err)
743 }
744 inv := invs[0]
745
746 // The orphan: an app user with a session and no member row,
747 // which is exactly who this route is for.
748 orphan := app.SignIn(fmt.Sprintf("orphan-%d", round))
749
750 var acceptRes, revokeRes *ideartest.Result
751 var acceptErr, revokeErr error
752 pair(round%2 == 0,
753 func() {
754 acceptRes, acceptErr = orphan.TryPost("/invitations/"+token, url.Values{})
755 },
756 func() {
757 revokeRes, revokeErr = oc.TryPost(fmt.Sprintf("/members/invitations/%d/revoke", inv.ID), url.Values{})
758 })
759 if acceptErr != nil || revokeErr != nil {
760 t.Fatalf("round %d: transport failed: accept %v, revoke %v", round, acceptErr, revokeErr)
761 }
762
763 row := app.H.Invitation(inv.ID)
764 member := memberBySubject(t, app, orphan.Subject)
765
766 switch {
767 case acceptRes.Status == http.StatusSeeOther:
768 admitted++
769 if member == nil {
770 t.Fatalf("round %d: acceptance answered 303 but wrote no member row", round)
771 }
772 if row.AcceptedAt == nil {
773 t.Fatalf("round %d: a member was admitted from an invitation that was never marked accepted", round)
774 }
775 if row.RevokedAt != nil {
776 t.Fatalf("round %d: THE INVARIANT BROKE — a REVOKED invitation admitted a member", round)
777 }
778 if revokeRes.Status == http.StatusSeeOther {
779 t.Fatalf("round %d: both the accept and the revoke were accepted", round)
780 }
781 default:
782 killed++
783 if member != nil {
784 t.Fatalf("round %d: acceptance was refused (%d) but a member row exists: %+v", round, acceptRes.Status, member)
785 }
786 if revokeRes.Status != http.StatusSeeOther {
787 t.Fatalf("round %d: neither side won: accept %d, revoke %d", round, acceptRes.Status, revokeRes.Status)
788 }
789 if row.RevokedAt == nil {
790 t.Fatalf("round %d: the revoke was accepted but the row is not revoked", round)
791 }
792 }
793 }
794
795 // Both branches must actually have been exercised. A race test
796 // that only ever resolves one way is green for a reason unrelated
797 // to the property it claims to check.
798 t.Logf("the race split %d admitted / %d revoked over %d rounds", admitted, killed, rounds)
799 if admitted == 0 || killed == 0 {
800 t.Fatalf("the race never split: %d admitted, %d revoked over %d rounds", admitted, killed, rounds)
801 }
802}
803
804// ---------------------------------------------------------------
805// §7.8 — expiry is refused, and an acceptance cannot be replayed.
806// ---------------------------------------------------------------
807
808func TestExpiredInvitationIsRefused(t *testing.T) {
809 app, col := newApp(t)
810 oc := app.As(app.H.Owner())
811 token := invite(t, app, col, oc, "late@example.test", idear.RoleMember)
812
813 invs, err := app.H.Roster.PendingInvitations(app.H.Ctx())
814 if err != nil || len(invs) != 1 {
815 t.Fatalf("pending invitations = %v, %v", invs, err)
816 }
817 app.H.Expire(invs[0].ID)
818
819 // The public GET stops offering it...
820 visitor := app.Visitor()
821 if res := visitor.Get("/invitations/" + token); res.Status != http.StatusNotFound {
822 t.Errorf("GET an expired invitation → %d, want 404; body %q", res.Status, res.Body)
823 }
824 // ...and the redemption is refused, with no row written.
825 orphan := app.SignIn("late-orphan")
826 res := orphan.Post("/invitations/"+token, url.Values{})
827 if res.Status != http.StatusNotFound {
828 t.Errorf("POST an expired invitation → %d, want 404; body %q", res.Status, res.Body)
829 }
830 if m := memberBySubject(t, app, "late-orphan"); m != nil {
831 t.Fatalf("an expired invitation admitted %+v", m)
832 }
833}
834
835func TestAcceptedInvitationCannotBeReplayed(t *testing.T) {
836 app, col := newApp(t)
837 oc := app.As(app.H.Owner())
838 token := invite(t, app, col, oc, "first@example.test", idear.RoleMember)
839
840 first := app.SignIn("first-orphan")
841 if res := first.Post("/invitations/"+token, url.Values{}); res.Status != http.StatusSeeOther {
842 t.Fatalf("the first redemption → %d, want 303; body %q", res.Status, res.Body)
843 }
844 if m := memberBySubject(t, app, "first-orphan"); m == nil {
845 t.Fatal("the first redemption wrote no member row")
846 }
847
848 // A DIFFERENT session replaying the same link gets nothing.
849 second := app.SignIn("second-orphan")
850 res := second.Post("/invitations/"+token, url.Values{})
851 if res.Status != http.StatusNotFound {
852 t.Errorf("replaying a spent invitation → %d, want 404; body %q", res.Status, res.Body)
853 }
854 if m := memberBySubject(t, app, "second-orphan"); m != nil {
855 t.Fatalf("a spent invitation admitted a second person: %+v", m)
856 }
857
858 // And the ORIGINAL redeemer replaying it changes nothing: they are
859 // already a member, and no second row appears.
860 if res := first.Post("/invitations/"+token, url.Values{}); res.Status != http.StatusSeeOther {
861 t.Errorf("a member reopening their own link → %d, want 303", res.Status)
862 }
863 if got := app.H.CountMembers(); got != 2 {
864 t.Fatalf("the roster holds %d rows, want the owner and one redeemer", got)
865 }
866}
867
868// ---------------------------------------------------------------
869// §7.9 — the orphan, healed by POST /invitations/{token}.
870// ---------------------------------------------------------------
871
872func TestOrphanIs404edEverywhereUntilReconciled(t *testing.T) {
873 app, col := newApp(t)
874 owner := app.H.Owner()
875 oc := app.As(owner)
876
877 // The orphan's subject is MIXED CASE and address-shaped, which is
878 // what keymail mints — auth takes the address the visitor typed.
879 // The member row this route writes must be canonicalised by the
880 // store, or the person signs in forever and 404s forever.
881 const typed = "Orphan@Example.Test"
882 orphan := app.SignIn(typed)
883
884 // Every guarded route, derived: 404, byte-identical, before.
885 for _, body := range checkAccess(t, app, orphan, nil, "an orphan") {
886 if body != ideartest.AppNotFound {
887 t.Fatalf("an orphan's 404 body = %q, want the app's own page", body)
888 }
889 }
890
891 // Somebody invites them. Only then can the route heal them — it
892 // needs a valid token, which is the spec's corrected wording.
893 token := invite(t, app, col, oc, "orphan@example.test", idear.RoleAdmin)
894
895 // The invitation page has to SAY they are the orphan, because
896 // SignedIn and Reconcile are what a template keys the accept
897 // control off. Stuck at false, item 9's healing path is
898 // unreachable from any real UI while the redemption below still
899 // answers 303 — so the flags are asserted, not assumed.
900 standing(t, orphan.Get("/invitations/"+token), "the orphan", true, true)
901 standing(t, app.Visitor().Get("/invitations/"+token), "a signed-out visitor", false, false)
902
903 res := orphan.Post("/invitations/"+token, url.Values{})
904 if res.Status != http.StatusSeeOther {
905 t.Fatalf("reconciliation → %d, want 303; body %q", res.Status, res.Body)
906 }
907 if res.Location != "/members" {
908 t.Errorf("reconciliation redirected to %q, want /members", res.Location)
909 }
910
911 // The row: written from the LIVE SESSION SUBJECT, through the
912 // store, and therefore canonicalised.
913 m := memberBySubject(t, app, typed)
914 if m == nil {
915 t.Fatal("reconciliation wrote no member row")
916 }
917 if m.Subject != strings.ToLower(typed) {
918 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))
919 }
920 if m.Role != idear.RoleAdmin {
921 t.Errorf("the healed member is %s, want the invitation's admin", m.Role)
922 }
923
924 // And afterwards they have exactly the access their role earns —
925 // derived, so "exactly" means every route.
926 checkAccess(t, app, orphan, m, "a healed orphan")
927
928 // A member is signed in and has nothing left to reconcile, so the
929 // accept control must be gone. Their own spent token is no longer
930 // a valid invitation, so this asks about a fresh one.
931 fresh := invite(t, app, col, oc, "somebody.else@example.test", idear.RoleMember)
932 standing(t, orphan.Get("/invitations/"+fresh), "a healed member", true, false)
933}
934
935func TestReconciliationClaimsAnUnclaimedInstance(t *testing.T) {
936 // The other orphan: admission created the app user and its Claim
937 // did not commit, so the roster is still empty and there is nobody
938 // to invite them.
939 app, _ := newApp(t)
940 if got := app.H.CountMembers(); got != 0 {
941 t.Fatalf("a fresh instance holds %d rows", got)
942 }
943 orphan := app.SignIn("1") // password's subject: a decimal user id
944
945 res := orphan.Post("/invitations/anything", url.Values{})
946 if res.Status != http.StatusSeeOther {
947 t.Fatalf("reconciliation on an empty roster → %d, want 303; body %q", res.Status, res.Body)
948 }
949 got := app.H.TheOwner()
950 if got.Subject != "1" {
951 t.Fatalf("the claim wrote subject %q, want the live session's %q", got.Subject, "1")
952 }
953
954 // And it does not reopen: the next signed-in stranger is not an
955 // owner, and holds no token.
956 stranger := app.SignIn("2")
957 if res := stranger.Post("/invitations/anything", url.Values{}); res.Status == http.StatusSeeOther {
958 t.Fatal("a second signed-in stranger was admitted by the reconciliation route")
959 }
960 app.H.TheOwner()
961 if got := app.H.CountMembers(); got != 1 {
962 t.Fatalf("the roster holds %d rows, want 1", got)
963 }
964}
965
966func TestReconciliationRefusesAnotherAddressesInvitation(t *testing.T) {
967 // Under keymail the session subject IS a verified address, so idear
968 // knows who the viewer is and the invitation must be theirs.
969 app, col := newApp(t)
970 oc := app.As(app.H.Owner())
971 token := invite(t, app, col, oc, "intended@example.test", idear.RoleAdmin)
972
973 interloper := app.SignIn("someone.else@example.test")
974 res := interloper.Post("/invitations/"+token, url.Values{})
975 if res.Status != http.StatusNotFound {
976 t.Errorf("redeeming another address's invitation → %d, want 404; body %q", res.Status, res.Body)
977 }
978 if m := memberBySubject(t, app, "someone.else@example.test"); m != nil {
979 t.Fatalf("another address's invitation admitted %+v", m)
980 }
981 // The invitation is untouched, so the intended recipient can still
982 // use it.
983 intended := app.SignIn("intended@example.test")
984 if res := intended.Post("/invitations/"+token, url.Values{}); res.Status != http.StatusSeeOther {
985 t.Fatalf("the intended recipient → %d, want 303; body %q", res.Status, res.Body)
986 }
987}
988
989func TestReconciliationRefusesASignedOutVisitor(t *testing.T) {
990 // The route writes a member row FOR THE SESSION IN HAND. With no
991 // session there is nobody to write it for, and a token holder who
992 // is merely holding a link must not be able to conjure a
993 // membership out of it — under password they would have no app
994 // user either, and the row would join to nothing.
995 app, col := newApp(t)
996 oc := app.As(app.H.Owner())
997 token := invite(t, app, col, oc, "expected@example.test", idear.RoleMember)
998
999 res := app.Visitor().Post("/invitations/"+token, url.Values{})
1000 if res.Status != http.StatusForbidden {
1001 t.Fatalf("a signed-out redemption → %d, want a 403 refusal; body %q", res.Status, res.Body)
1002 }
1003 if got := app.H.CountMembers(); got != 1 {
1004 t.Fatalf("the roster holds %d rows, want just the owner", got)
1005 }
1006
1007 // And the token was not spent by the refusal: the person it was
1008 // meant for can still redeem it once they are signed in.
1009 invs, err := app.H.Roster.PendingInvitations(app.H.Ctx())
1010 if err != nil || len(invs) != 1 {
1011 t.Fatalf("pending invitations = %v, %v; the refusal consumed the invitation", invs, err)
1012 }
1013 signedIn := app.SignIn("expected@example.test")
1014 if res := signedIn.Post("/invitations/"+token, url.Values{}); res.Status != http.StatusSeeOther {
1015 t.Fatalf("the intended recipient → %d, want 303; body %q", res.Status, res.Body)
1016 }
1017}
1018
1019func TestReconciliationRefusesADeactivatedMember(t *testing.T) {
1020 app, col := newApp(t)
1021 owner := app.H.Owner()
1022 oc := app.As(owner)
1023 // Invited while still a member (Invite refuses a removed one),
1024 // then removed: the invitation outlives the membership.
1025 gone := app.H.Member(idear.RoleAdmin)
1026 token := invite(t, app, col, oc, gone.Email, idear.RoleMember)
1027 if err := app.H.Roster.Deactivate(app.H.Ctx(), owner, gone); err != nil {
1028 t.Fatalf("Deactivate: %v", err)
1029 }
1030
1031 c := app.As(gone)
1032 if res := c.Post("/invitations/"+token, url.Values{}); res.Status != http.StatusNotFound {
1033 t.Errorf("a removed member redeeming an invitation → %d, want 404; body %q", res.Status, res.Body)
1034 }
1035 if app.H.Reload(gone.ID).Active() {
1036 t.Fatal("a removed member let themselves back in with an invitation; readmission is Restore")
1037 }
1038}
1039
1040// ---------------------------------------------------------------
1041// Reconciliation under the PASSWORD plugin: the token, and whether
1042// anything else is asked of the viewer.
1043//
1044// Under keymail the session Subject is the verified address, so idear
1045// matches it against the invitation itself (the test above). Under
1046// password the Subject is an opaque decimal user id and idear can only
1047// resolve it through Config.EmailForSubject. These two tests are the
1048// two configurations, and the difference between them is the whole
1049// point: one refuses a token issued to somebody else, the other — the
1050// documented permissive path — does not.
1051// ---------------------------------------------------------------
1052
1053// directory is an app's own user table, as idear sees it through
1054// Config.EmailForSubject: subject → address, and an error the app
1055// cannot answer through.
1056type directory struct {
1057 byID map[string]string
1058 err error
1059}
1060
1061func (d *directory) emailForSubject(ctx context.Context, subject string) (string, error) {
1062 if d.err != nil {
1063 return "", d.err
1064 }
1065 // The app's own miss: not one of this app's subjects, or an id
1066 // with no row behind it. "" with a NIL error, which idear must
1067 // read as a refusal and not as a storage failure.
1068 return d.byID[subject], nil
1069}
1070
1071// passwordApp is an instance whose subjects are password's — decimal
1072// user ids — with dir standing in for the app's user table.
1073func passwordApp(t *testing.T, dir *directory) (*ideartest.App, *collector) {
1074 t.Helper()
1075 col := &collector{}
1076 cfg := idear.Config{}
1077 if dir != nil {
1078 cfg.EmailForSubject = dir.emailForSubject
1079 }
1080 return ideartest.NewAppWith(t, cfg, idear.HandlerConfig{Deliver: col.deliver}), col
1081}
1082
1083// TestReconciliationWithEmailForSubjectRefusesAnotherAddressesToken is
1084// the F1 regression test.
1085//
1086// The orphan is real and signed in: their app user row exists and
1087// their member row does not, which is what a create-succeeded-then-
1088// member-write-failed admission leaves behind, and what the loser of a
1089// first-signup claim race is. They are not entitled to a token issued
1090// to somebody else — tokens leak into browser flash cookies (with
1091// Deliver nil), into URL history, referrers and logs — and with the
1092// resolver wired, idear applies the rule admission applies: possession
1093// of the token AND an email match.
1094func TestReconciliationWithEmailForSubjectRefusesAnotherAddressesToken(t *testing.T) {
1095 dir := &directory{byID: map[string]string{
1096 "7": "orphan@example.test",
1097 // The intended recipient's spelling in the app's table is not
1098 // the invitation's: the comparison is normalised on both
1099 // sides, or a correctly-invited person is locked out.
1100 "9": "Intended@Example.Test",
1101 }}
1102 app, col := passwordApp(t, dir)
1103 oc := app.As(app.H.Owner())
1104 token := invite(t, app, col, oc, "intended@example.test", idear.RoleAdmin)
1105
1106 orphan := app.SignIn("7")
1107 res := orphan.Post("/invitations/"+token, url.Values{})
1108 if res.Status != http.StatusNotFound {
1109 t.Fatalf("an orphan redeeming another address's token → %d, want 404; body %q", res.Status, res.Body)
1110 }
1111 if m := memberBySubject(t, app, "7"); m != nil {
1112 t.Fatalf("another address's invitation admitted %+v at %s", m, m.Role)
1113 }
1114
1115 // The refusal did not spend it: the person it was issued to still
1116 // has it, and lands at the invited role.
1117 intended := app.SignIn("9")
1118 if res := intended.Post("/invitations/"+token, url.Values{}); res.Status != http.StatusSeeOther {
1119 t.Fatalf("the intended recipient → %d, want 303; body %q", res.Status, res.Body)
1120 }
1121 m := memberBySubject(t, app, "9")
1122 if m == nil {
1123 t.Fatal("the intended recipient redeemed the invitation but no member row was written")
1124 }
1125 if m.Role != idear.RoleAdmin {
1126 t.Errorf("the healed orphan is %s, want the invitation's admin", m.Role)
1127 }
1128}
1129
1130// TestReconciliationWithoutEmailForSubjectTrustsTheTokenAlone is the
1131// OTHER half of F1, and it is deliberately an assertion of the
1132// permissive behaviour rather than a gap left untested.
1133//
1134// With no Config.EmailForSubject, idear has no way to turn a decimal
1135// user id into an address, so reconciliation asks only for a live
1136// token. A signed-in orphan holding one issued to somebody else
1137// redeems it, at that token's role. That is what SKILL.md §5 says
1138// happens, and this test is what keeps the two of them honest: change
1139// the behaviour and this goes red, which is the moment to change the
1140// documentation with it.
1141func TestReconciliationWithoutEmailForSubjectTrustsTheTokenAlone(t *testing.T) {
1142 app, col := passwordApp(t, nil)
1143 oc := app.As(app.H.Owner())
1144 token := invite(t, app, col, oc, "intended@example.test", idear.RoleAdmin)
1145
1146 orphan := app.SignIn("7")
1147 res := orphan.Post("/invitations/"+token, url.Values{})
1148 if res.Status != http.StatusSeeOther {
1149 t.Fatalf("the documented permissive path → %d, want 303; body %q", res.Status, res.Body)
1150 }
1151 m := memberBySubject(t, app, "7")
1152 if m == nil {
1153 t.Fatal("the permissive path admitted nobody; SKILL.md §5 says the token alone is enough here")
1154 }
1155 if m.Role != idear.RoleAdmin {
1156 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)
1157 }
1158}
1159
1160// TestReconciliationRefusesAViewerTheResolverCannotPlace covers the
1161// resolver's two non-answers, which must not be confused with each
1162// other.
1163//
1164// ("", nil) is the app saying "that subject is nobody I know" — a
1165// REFUSAL, because a viewer idear cannot identify must not spend an
1166// invitation issued to one it can. A non-nil error is the app's
1167// database failing, which is a 500 and must never read as policy: the
1168// invitation stays live and the person can try again.
1169func TestReconciliationRefusesAViewerTheResolverCannotPlace(t *testing.T) {
1170 t.Run("unknown subject", func(t *testing.T) {
1171 app, col := passwordApp(t, &directory{byID: map[string]string{}})
1172 oc := app.As(app.H.Owner())
1173 token := invite(t, app, col, oc, "intended@example.test", idear.RoleMember)
1174
1175 res := app.SignIn("7").Post("/invitations/"+token, url.Values{})
1176 if res.Status != http.StatusNotFound {
1177 t.Fatalf("a subject the app cannot place → %d, want 404; body %q", res.Status, res.Body)
1178 }
1179 if m := memberBySubject(t, app, "7"); m != nil {
1180 t.Fatalf("an unplaceable subject was admitted: %+v", m)
1181 }
1182 })
1183
1184 t.Run("the resolver fails", func(t *testing.T) {
1185 app, col := passwordApp(t, &directory{err: errors.New("the app's user table is unreadable")})
1186 oc := app.As(app.H.Owner())
1187 token := invite(t, app, col, oc, "intended@example.test", idear.RoleMember)
1188
1189 res := app.SignIn("7").Post("/invitations/"+token, url.Values{})
1190 if res.Status != http.StatusInternalServerError {
1191 t.Fatalf("a failing resolver → %d, want 500; a storage failure must not render as a policy refusal. Body %q", res.Status, res.Body)
1192 }
1193 if m := memberBySubject(t, app, "7"); m != nil {
1194 t.Fatalf("a failing resolver admitted %+v", m)
1195 }
1196 // And it cost nobody their invitation.
1197 invs, err := app.H.Roster.PendingInvitations(app.H.Ctx())
1198 if err != nil || len(invs) != 1 {
1199 t.Fatalf("pending invitations = %v, %v; a failed lookup consumed the invitation", invs, err)
1200 }
1201 })
1202}
1203
1204// ---------------------------------------------------------------
1205// §7.10 — Transfer racing Deactivate never yields a deactivated Owner.
1206// ---------------------------------------------------------------
1207
1208func TestTransferRacingRemoveThroughHTTP(t *testing.T) {
1209 const rounds = 24
1210 transferred, removed := 0, 0
1211
1212 for round := range rounds {
1213 app, _ := newApp(t)
1214 owner := app.H.Owner()
1215 admin := app.H.Member(idear.RoleAdmin)
1216 target := app.H.Member(idear.RoleMember)
1217
1218 oc := app.As(owner)
1219 ac := app.As(admin)
1220
1221 var transferRes, removeRes *ideartest.Result
1222 var transferErr, removeErr error
1223 pair(round%2 == 0,
1224 func() {
1225 transferRes, transferErr = oc.TryPost("/members/transfer", url.Values{"member": {fmt.Sprint(target.ID)}})
1226 },
1227 func() {
1228 removeRes, removeErr = ac.TryPost(fmt.Sprintf("/members/%d/remove", target.ID), url.Values{})
1229 })
1230 if transferErr != nil || removeErr != nil {
1231 t.Fatalf("round %d: transport failed: transfer %v, remove %v", round, transferErr, removeErr)
1232 }
1233
1234 // The invariant, whichever way it resolved: exactly one owner,
1235 // and that owner is ACTIVE. A deactivated owner is an instance
1236 // nobody can administer and nobody can be promoted out of.
1237 got := app.H.TheOwner()
1238 if !got.Active() {
1239 t.Fatalf("round %d: THE INVARIANT BROKE — the owner (member %d) is deactivated", round, got.ID)
1240 }
1241 row := app.H.Reload(target.ID)
1242 if transferRes.Status == http.StatusSeeOther {
1243 transferred++
1244 if got.ID != target.ID {
1245 t.Fatalf("round %d: the transfer was accepted but ownership is on member %d", round, got.ID)
1246 }
1247 if !row.Active() {
1248 t.Fatalf("round %d: the new owner is deactivated", round)
1249 }
1250 } else {
1251 removed++
1252 if got.ID != owner.ID {
1253 t.Fatalf("round %d: the transfer was refused (%d) but ownership moved to %d", round, transferRes.Status, got.ID)
1254 }
1255 if removeRes.Status != http.StatusSeeOther {
1256 t.Fatalf("round %d: neither side won: transfer %d, remove %d", round, transferRes.Status, removeRes.Status)
1257 }
1258 if row.Active() {
1259 t.Fatalf("round %d: the removal was accepted but the target is still active", round)
1260 }
1261 }
1262 }
1263 t.Logf("the race split %d transfers / %d removals over %d rounds", transferred, removed, rounds)
1264 if transferred == 0 || removed == 0 {
1265 t.Fatalf("the race never split: %d transfers, %d removals over %d rounds", transferred, removed, rounds)
1266 }
1267}
1268
1269// ---------------------------------------------------------------
1270// §7.11 — reactivation restores exactly the prior access.
1271// ---------------------------------------------------------------
1272
1273func TestReactivatedMemberRegainsExactlyTheirPriorAccess(t *testing.T) {
1274 app, _ := newApp(t)
1275 owner := app.H.Owner()
1276 admin := app.H.Member(idear.RoleAdmin)
1277 oc := app.As(owner)
1278 c := app.As(admin)
1279
1280 // Before: the whole route table, recorded.
1281 before := map[string]int{}
1282 for _, rt := range guarded(app) {
1283 res := probe(c, rt)
1284 before[rt.Method+" "+rt.Pattern] = res.Status
1285 if want := wantStatus(rt, admin); res.Status != want {
1286 t.Fatalf("before removal: %s %s → %d, want %d", rt.Method, rt.Pattern, res.Status, want)
1287 }
1288 }
1289
1290 // Removed: nothing at all, and the session still exists — under
1291 // password a removed member can still hold a session, and what
1292 // stops them is Require on every route.
1293 if res := oc.Post(fmt.Sprintf("/members/%d/remove", admin.ID), url.Values{}); res.Status != http.StatusSeeOther {
1294 t.Fatalf("removing the admin → %d; body %q", res.Status, res.Body)
1295 }
1296 checkAccess(t, app, c, nil, "a removed admin")
1297
1298 // Restored: the SAME statuses as before, route for route. More
1299 // would be an escalation; fewer would make Restore useless.
1300 if res := oc.Post(fmt.Sprintf("/members/%d/restore", admin.ID), url.Values{}); res.Status != http.StatusSeeOther {
1301 t.Fatalf("restoring the admin → %d; body %q", res.Status, res.Body)
1302 }
1303 restored := app.H.Reload(admin.ID)
1304 if !restored.Active() || restored.Role != idear.RoleAdmin {
1305 t.Fatalf("the restored member is %s/%v", restored.Role, restored.Active())
1306 }
1307 for _, rt := range guarded(app) {
1308 res := probe(c, rt)
1309 key := rt.Method + " " + rt.Pattern
1310 if res.Status != before[key] {
1311 t.Errorf("after restore: %s → %d, but before removal it was %d", key, res.Status, before[key])
1312 }
1313 }
1314
1315 // "And no more": the one thing an admin could never do is still
1316 // refused, and the owner is still the owner.
1317 if res := c.Post("/members/transfer", url.Values{"member": {fmt.Sprint(admin.ID)}}); res.Status != http.StatusForbidden {
1318 t.Errorf("a restored admin transferring ownership → %d, want 403", res.Status)
1319 }
1320 if got := app.H.TheOwner(); got.ID != owner.ID {
1321 t.Fatalf("ownership moved to %d", got.ID)
1322 }
1323}
1324
1325// ---------------------------------------------------------------
1326// §7.12 — the public GET is not an address oracle.
1327// ---------------------------------------------------------------
1328
1329func TestInvitationPageDoesNotDiscloseTheAddress(t *testing.T) {
1330 app, col := newApp(t)
1331 oc := app.As(app.H.Owner())
1332
1333 const (
1334 local = "secret.person"
1335 domain = "hidden.example"
1336 invited = local + "@" + domain
1337 )
1338 token := invite(t, app, col, oc, invited, idear.RoleAdmin)
1339
1340 // The renderer prints every field of InvitationPage by reflection,
1341 // so this is a test of what the HANDLER passed and not of what a
1342 // template chose to show.
1343 for what, c := range map[string]*ideartest.Client{
1344 "an anonymous visitor": app.Visitor(),
1345 "a signed-in stranger": app.SignIn("nosy@example.test"),
1346 } {
1347 res := c.Get("/invitations/" + token)
1348 if res.Status != http.StatusOK {
1349 t.Fatalf("%s: GET the invitation → %d; body %q", what, res.Status, res.Body)
1350 }
1351 for _, secret := range []string{invited, local, domain} {
1352 if strings.Contains(strings.ToLower(res.Body), secret) {
1353 t.Errorf("%s: the invitation page disclosed %q; body %q", what, secret, res.Body)
1354 }
1355 }
1356 // It does say what the holder is entitled to know.
1357 if !strings.Contains(res.Body, string(idear.RoleAdmin)) {
1358 t.Errorf("%s: the invitation page does not name the role; body %q", what, res.Body)
1359 }
1360 if !strings.Contains(res.Body, app.Server.Listener.Addr().String()) {
1361 t.Errorf("%s: the invitation page does not name the instance; body %q", what, res.Body)
1362 }
1363 }
1364
1365 // An unusable token answers the SAME way whichever way it is
1366 // unusable, so the page cannot be used to tell a real token from a
1367 // spent one.
1368 visitor := app.Visitor()
1369 bodies := map[string]bool{}
1370 for _, tok := range []string{"never-existed", strings.Repeat("a", 64)} {
1371 res := visitor.Get("/invitations/" + tok)
1372 if res.Status != http.StatusNotFound {
1373 t.Errorf("GET %q → %d, want 404", tok, res.Status)
1374 }
1375 bodies[res.Body] = true
1376 }
1377 // ...including one that WAS real and has been revoked.
1378 invs, err := app.H.Roster.PendingInvitations(app.H.Ctx())
1379 if err != nil || len(invs) != 1 {
1380 t.Fatalf("pending invitations = %v, %v", invs, err)
1381 }
1382 if res := oc.Post(fmt.Sprintf("/members/invitations/%d/revoke", invs[0].ID), url.Values{}); res.Status != http.StatusSeeOther {
1383 t.Fatalf("revoking → %d", res.Status)
1384 }
1385 res := visitor.Get("/invitations/" + token)
1386 if res.Status != http.StatusNotFound {
1387 t.Errorf("GET a revoked invitation → %d, want 404", res.Status)
1388 }
1389 bodies[res.Body] = true
1390 if len(bodies) != 1 {
1391 t.Fatalf("unusable invitations rendered %d distinct bodies, want 1: %v", len(bodies), bodies)
1392 }
1393}
1394
1395// ---------------------------------------------------------------
1396// The role selector, CSRF, and the wiring.
1397// ---------------------------------------------------------------
1398
1399// TestGrantableMatchesTheDesign pins WHAT the rule is, which no
1400// amount of derivation can do for itself.
1401//
1402// TestGrantableIsTheAllowList below checks that the page, the store
1403// and Grantable agree. They agree by construction — Grantable asks
1404// checkInviteRole, the test asks Grantable — so relaxing the rule
1405// moves the expectation with it: changing checkInviteRole's
1406// `rank(role) >= rank(actor.Role)` to `>`, which lets an Admin mint a
1407// PEER ADMIN while MayActOn still puts the new admin beyond every
1408// other admin's reach, left every test in this file green. That is the brief's anti-pattern
1409// in mirror form: total derivation buys drift-resistance and loses
1410// rule-change detection.
1411//
1412// So this states the matrix from
1413// docs/superpowers/specs/2026-08-23-idear-design.md §5 — "Admins
1414// manage Members only", "the target's rank must be strictly below the
1415// actor's", "ownership moves only by Transfer" — and compares it in
1416// BOTH directions, exactly as TestRoutesMatchTheDesign does for the
1417// route table. It quotes the spec, never the implementation.
1418//
1419// PeerGrants is the one sanctioned departure from §5, and it changes
1420// one row: an Admin may also grant Admin. That row is stated by hand
1421// too, not derived from the constant's value.
1422func TestGrantableMatchesTheDesign(t *testing.T) {
1423 design := map[idear.Role][]idear.Role{
1424 // An Owner may grant Admin and Member. Not Owner: ownership
1425 // moves only by Transfer.
1426 idear.RoleOwner: {idear.RoleAdmin, idear.RoleMember},
1427 // An Admin may grant Member ONLY. An Admin who could mint a
1428 // peer Admin has escalated — MayActOn refuses acting on an
1429 // equal rank, so the new Admin is beyond the granter's reach
1430 // and beyond every other Admin's too.
1431 idear.RoleAdmin: {idear.RoleMember},
1432 // A Member manages nothing.
1433 idear.RoleMember: nil,
1434 }
1435 if idear.PeerGrants {
1436 // Reach extends to the actor's own rank, for granting and
1437 // managing alike, so the new Admin is within every Admin's
1438 // reach. The Owner's row is unchanged: owner is never granted.
1439 design[idear.RoleAdmin] = []idear.Role{idear.RoleAdmin, idear.RoleMember}
1440 }
1441
1442 for actorRole, want := range design {
1443 actor := &idear.Member{ID: 1, Role: actorRole}
1444 got := idear.Grantable(actor)
1445
1446 inGot := map[idear.Role]bool{}
1447 for _, role := range got {
1448 inGot[role] = true
1449 }
1450 inWant := map[idear.Role]bool{}
1451 for _, role := range want {
1452 inWant[role] = true
1453 }
1454 for _, role := range want {
1455 if !inGot[role] {
1456 t.Errorf("a %s may grant %s by the design, and Grantable does not offer it", actorRole, role)
1457 }
1458 }
1459 for _, role := range got {
1460 if !inWant[role] {
1461 t.Errorf("Grantable offers %s to a %s; the design says %v", role, actorRole, want)
1462 }
1463 }
1464 if len(got) != len(want) {
1465 t.Errorf("Grantable(%s) = %v, the design says %v", actorRole, got, want)
1466 }
1467 }
1468
1469 // A deactivated actor grants nothing, whatever rank the row still
1470 // carries: removal is never a delete, so the row outlives the
1471 // privileges.
1472 past := time.Now().UTC()
1473 for actorRole := range design {
1474 gone := &idear.Member{ID: 1, Role: actorRole, DeactivatedAt: &past}
1475 if got := idear.Grantable(gone); len(got) != 0 {
1476 t.Errorf("a deactivated %s is offered %v", actorRole, got)
1477 }
1478 }
1479 if got := idear.Grantable(nil); len(got) != 0 {
1480 t.Errorf("Grantable(nil) = %v, want nothing", got)
1481 }
1482}
1483
1484// TestGrantableIsTheAllowList checks the role selector against the
1485// store, in both directions, for every actor.
1486//
1487// The payloads are DERIVED: the roles it tries are the ones Grantable
1488// offers and the ones it does not, and the expectation flips on
1489// membership of that list rather than on a hand-written table. An
1490// Admin offered Admin would 403 on every submit — the selector and the
1491// store have to agree, and this is what makes them.
1492func TestGrantableIsTheAllowList(t *testing.T) {
1493 all := []idear.Role{idear.RoleOwner, idear.RoleAdmin, idear.RoleMember}
1494
1495 for _, actorRole := range []idear.Role{idear.RoleOwner, idear.RoleAdmin} {
1496 t.Run(string(actorRole), func(t *testing.T) {
1497 app, _ := newApp(t)
1498 owner := app.H.Owner()
1499 actor := owner
1500 if actorRole != idear.RoleOwner {
1501 actor = app.H.Member(actorRole)
1502 }
1503 c := app.As(actor)
1504
1505 offered := map[idear.Role]bool{}
1506 for _, role := range idear.Grantable(actor) {
1507 offered[role] = true
1508 }
1509 if offered[idear.RoleOwner] {
1510 t.Error("the selector offers Owner; ownership moves only by Transfer")
1511 }
1512
1513 // The page must show exactly what Grantable says.
1514 page := c.Get("/members")
1515 for _, role := range all {
1516 line := "grantable " + string(role)
1517 if got := strings.Contains(page.Body, line); got != offered[role] {
1518 t.Errorf("the members page %s %s, but Grantable says %v", map[bool]string{true: "offers", false: "does not offer"}[got], role, offered[role])
1519 }
1520 }
1521
1522 // And the store agrees with the page, both ways.
1523 for i, role := range all {
1524 email := fmt.Sprintf("candidate-%d@example.test", i)
1525 res := c.Post("/members/invitations", url.Values{"email": {email}, "role": {string(role)}})
1526 if offered[role] {
1527 if res.Status != http.StatusSeeOther {
1528 t.Errorf("inviting at an OFFERED role %s → %d, want 303; body %q", role, res.Status, res.Body)
1529 }
1530 continue
1531 }
1532 if res.Status != http.StatusForbidden {
1533 t.Errorf("inviting at an UNOFFERED role %s → %d, want 403; body %q", role, res.Status, res.Body)
1534 }
1535 }
1536
1537 // Nothing was minted above the actor's own rank.
1538 var invs []idear.Invitation
1539 if err := app.H.DB.G.Find(&invs).Error; err != nil {
1540 t.Fatalf("listing invitations: %v", err)
1541 }
1542 for _, inv := range invs {
1543 if !offered[inv.Role] {
1544 t.Errorf("an invitation was minted at %s, which %s may not grant", inv.Role, actorRole)
1545 }
1546 }
1547 })
1548 }
1549}
1550
1551// TestCrossOriginPostsAreRefused drives every mutating route from
1552// another origin — the shape a CSRF attack actually takes against an
1553// origin-checking framework — and demands that none of them run.
1554func TestCrossOriginPostsAreRefused(t *testing.T) {
1555 app, _ := newApp(t)
1556 owner := app.H.Owner()
1557 victim := app.H.Member(idear.RoleMember)
1558 c := app.As(owner)
1559
1560 for _, rt := range app.Handlers.Routes() {
1561 if rt.Method != http.MethodPost {
1562 continue
1563 }
1564 path := instantiate(rt.Pattern, fmt.Sprint(victim.ID), "0")
1565 res := c.PostFrom("https://evil.example", path, url.Values{
1566 "role": {string(idear.RoleAdmin)},
1567 "email": {"attacker@evil.example"},
1568 "member": {fmt.Sprint(victim.ID)},
1569 })
1570 if res.Status != http.StatusForbidden {
1571 t.Errorf("cross-origin %s %s → %d, want 403; body %q", rt.Method, path, res.Status, res.Body)
1572 }
1573 }
1574 if got := app.H.Reload(victim.ID); got.Role != idear.RoleMember || !got.Active() {
1575 t.Errorf("a cross-origin post landed: the victim is %s/%v", got.Role, got.Active())
1576 }
1577 invs, err := app.H.Roster.PendingInvitations(app.H.Ctx())
1578 if err != nil {
1579 t.Fatalf("listing invitations: %v", err)
1580 }
1581 if len(invs) != 0 {
1582 t.Errorf("a cross-origin post minted %d invitations", len(invs))
1583 }
1584}
1585
1586func TestNewHandlersRequiresItsSeams(t *testing.T) {
1587 h := ideartest.New(t)
1588 renderMembers := func(http.ResponseWriter, *http.Request, idear.MembersPage) {}
1589 renderInvitation := func(http.ResponseWriter, *http.Request, idear.InvitationPage) {}
1590
1591 for what, cfg := range map[string]idear.HandlerConfig{
1592 "no roster": {RenderMembers: renderMembers, RenderInvitation: renderInvitation},
1593 "no members": {Roster: h.Roster, RenderInvitation: renderInvitation},
1594 "no invitation": {Roster: h.Roster, RenderMembers: renderMembers},
1595 } {
1596 if _, err := idear.NewHandlers(cfg); err == nil {
1597 t.Errorf("NewHandlers with %s returned no error", what)
1598 }
1599 }
1600 if _, err := idear.NewHandlers(idear.HandlerConfig{
1601 Roster: h.Roster, RenderMembers: renderMembers, RenderInvitation: renderInvitation,
1602 }); err != nil {
1603 t.Errorf("NewHandlers with everything set: %v", err)
1604 }
1605}
1606
1607// TestInviteWithoutDeliverFlashesTheLink covers the default wiring: an
1608// app with no Deliver hook still gets a usable link, exactly once, and
1609// it works.
1610func TestInviteWithoutDeliverFlashesTheLink(t *testing.T) {
1611 app := ideartest.NewApp(t)
1612 c := app.As(app.H.Owner())
1613
1614 res := c.Post("/members/invitations", url.Values{
1615 "email": {"linked@example.test"},
1616 "role": {string(idear.RoleMember)},
1617 })
1618 if res.Status != http.StatusSeeOther {
1619 t.Fatalf("invite → %d; body %q", res.Status, res.Body)
1620 }
1621 page := c.Follow(res)
1622 link := ""
1623 for _, f := range strings.Fields(page.Body) {
1624 if strings.HasPrefix(f, "/invitations/") {
1625 link = f
1626 }
1627 }
1628 if link == "" {
1629 t.Fatalf("the members page carries no invitation link; body %q", page.Body)
1630 }
1631 if got := c.Get("/members"); strings.Contains(got.Body, link) {
1632 t.Error("the flash notice survived a second page load; it must be one-shot")
1633 }
1634 if got := app.Visitor().Get(link); got.Status != http.StatusOK {
1635 t.Fatalf("the flashed link answers %d, so it is not usable; body %q", got.Status, got.Body)
1636 }
1637}
1638
1639// TestPublicRoutesAreRateLimited proves the limiter is mounted on BOTH
1640// public routes and on neither guarded one.
1641func TestPublicRoutesAreRateLimited(t *testing.T) {
1642 const burst = 4
1643 app := ideartest.NewAppWith(t, idear.Config{}, idear.HandlerConfig{
1644 // One token an hour back, so nothing refills mid-test.
1645 RateLimit: idear.RateLimit{Burst: burst, Every: time.Hour},
1646 })
1647 owner := app.H.Owner()
1648 c := app.As(owner)
1649
1650 var public []idear.Route
1651 for _, rt := range app.Handlers.Routes() {
1652 if rt.Public {
1653 public = append(public, rt)
1654 }
1655 }
1656 if len(public) != 2 {
1657 t.Fatalf("derived %d public routes, want the 2 the design lists", len(public))
1658 }
1659
1660 // The budget is shared across both public routes, because it is
1661 // per-client and not per-route.
1662 spent := 0
1663 for _, rt := range public {
1664 path := instantiate(rt.Pattern, "0", "no-such-token")
1665 for range burst {
1666 var res *ideartest.Result
1667 if rt.Method == http.MethodGet {
1668 res = c.Get(path)
1669 } else {
1670 res = c.Post(path, url.Values{})
1671 }
1672 spent++
1673 if spent <= burst && res.Status == http.StatusTooManyRequests {
1674 t.Fatalf("request %d of a burst of %d was rate-limited", spent, burst)
1675 }
1676 if spent > burst && res.Status != http.StatusTooManyRequests {
1677 t.Fatalf("request %d → %d, want 429 once the burst is spent", spent, res.Status)
1678 }
1679 }
1680 }
1681
1682 // The guarded routes are untouched by the limiter: a signed-in
1683 // owner is not throttled off the members page by somebody else's
1684 // guessing.
1685 if res := c.Get("/members"); res.Status != http.StatusOK {
1686 t.Errorf("the members page → %d while the public budget is spent", res.Status)
1687 }
1688}
1689
1690// TestRoutesMatchTheDesign pins the route table to the DESIGN, which
1691// is the one thing the derived tests above cannot do for themselves.
1692//
1693// Everything else in this file reads Route.Min and asks whether the
1694// mounted middleware agrees with it. That catches a guard that drifts
1695// from its declaration — but not a floor LOWERED in both places at
1696// once, which is the change that quietly turns "admins invite" into
1697// "anybody invites". So this test states the floors from
1698// docs/superpowers/specs/2026-08-23-idear-design.md §5 and compares
1699// them exhaustively, in both directions: a route the design does not
1700// list is a failure, and a route the design lists that is not mounted
1701// is a failure too.
1702//
1703// It is the one place in the suite that quotes a list, and what it
1704// quotes is the spec rather than the implementation.
1705func TestRoutesMatchTheDesign(t *testing.T) {
1706 type pin struct {
1707 min idear.Role
1708 public bool
1709 }
1710 design := map[string]pin{
1711 "GET /members": {min: idear.RoleMember},
1712 "POST /members/invitations": {min: idear.RoleAdmin},
1713 "POST /members/invitations/{id}/revoke": {min: idear.RoleAdmin},
1714 "POST /members/{id}/role": {min: idear.RoleAdmin},
1715 "POST /members/{id}/remove": {min: idear.RoleAdmin},
1716 "POST /members/{id}/restore": {min: idear.RoleAdmin},
1717 "POST /members/transfer": {min: idear.RoleOwner},
1718 "GET /invitations/{token}": {public: true},
1719 "POST /invitations/{token}": {public: true},
1720 }
1721
1722 app := ideartest.NewApp(t)
1723 seen := map[string]bool{}
1724 for _, rt := range app.Handlers.Routes() {
1725 key := rt.Method + " " + rt.Pattern
1726 want, ok := design[key]
1727 if !ok {
1728 t.Errorf("%s is mounted but the design does not list it", key)
1729 continue
1730 }
1731 seen[key] = true
1732 if rt.Public != want.public {
1733 t.Errorf("%s: Public = %v, the design says %v", key, rt.Public, want.public)
1734 }
1735 if rt.Min != want.min {
1736 t.Errorf("%s: rank floor = %q, the design says %q", key, rt.Min, want.min)
1737 }
1738 }
1739 for key := range design {
1740 if !seen[key] {
1741 t.Errorf("%s is in the design and is not mounted", key)
1742 }
1743 }
1744}
1745
1746// TestFieldsComeFromTheBodyNotTheQuery pins where a mutation's fields
1747// are allowed to come from.
1748//
1749// idear reads PostForm and not Form. The difference is a real
1750// escalation route: with Form, a link ending "?role=admin" would
1751// supply the field for a POST whose body never mentioned one, so a
1752// crafted link plus any submitted form on that page would grant a
1753// rank the submitter never typed.
1754func TestFieldsComeFromTheBodyNotTheQuery(t *testing.T) {
1755 app, _ := newApp(t)
1756 owner := app.H.Owner()
1757 target := app.H.Member(idear.RoleMember)
1758 c := app.As(owner)
1759
1760 path := fmt.Sprintf("/members/%d/role?role=%s", target.ID, idear.RoleAdmin)
1761
1762 // The body wins: it says member, the query says admin.
1763 if res := c.Post(path, url.Values{"role": {string(idear.RoleMember)}}); res.Status != http.StatusSeeOther {
1764 t.Fatalf("setting a role → %d; body %q", res.Status, res.Body)
1765 }
1766 if got := app.H.Reload(target.ID); got.Role != idear.RoleMember {
1767 t.Fatalf("the target is %s; the query string supplied the role", got.Role)
1768 }
1769
1770 // And with no role in the body it is a malformed request, not one
1771 // the query string may complete.
1772 res := c.Post(path, url.Values{})
1773 if res.Status != http.StatusBadRequest {
1774 t.Errorf("a POST with no role in its body → %d, want 400; body %q", res.Status, res.Body)
1775 }
1776 if got := app.H.Reload(target.ID); got.Role != idear.RoleMember {
1777 t.Fatalf("the target is %s; the query string completed a bodyless post", got.Role)
1778 }
1779}
1780
1781// TestMultipartFormsAreRead covers the encoding an app reaches the
1782// moment its members form grows a file input. ParseForm alone does not
1783// populate PostForm for multipart/form-data, so every field would read
1784// empty and every submit would 400 with nothing to explain it — a
1785// fail-closed failure, and an undiagnosable one.
1786func TestMultipartFormsAreRead(t *testing.T) {
1787 app, _ := newApp(t)
1788 target := app.H.Member(idear.RoleMember)
1789 c := app.As(app.H.Owner())
1790
1791 res := c.PostMultipart(fmt.Sprintf("/members/%d/role", target.ID),
1792 url.Values{"role": {string(idear.RoleAdmin)}})
1793 if res.Status != http.StatusSeeOther {
1794 t.Fatalf("a multipart role change → %d, want 303; body %q", res.Status, res.Body)
1795 }
1796 if got := app.H.Reload(target.ID); got.Role != idear.RoleAdmin {
1797 t.Fatalf("the target is %s; the multipart body was not read", got.Role)
1798 }
1799
1800 // And a multipart body with no role in it is still a 400, not a
1801 // path around the field check.
1802 if res := c.PostMultipart(fmt.Sprintf("/members/%d/role", target.ID), url.Values{}); res.Status != http.StatusBadRequest {
1803 t.Errorf("a multipart post with no role → %d, want 400", res.Status)
1804 }
1805}
1806
1807// TestEveryHandlerIsMounted closes the hole under the whole suite:
1808// Routes() is what every derived test walks, so an exported handler
1809// added WITHOUT a Routes() entry is invisible to all of them — it
1810// would ship with no membership gate and no test would notice.
1811//
1812// The count is the assertion because the names cannot be: a func value
1813// in a Route carries no method name to compare against. Reflection
1814// over *Handlers finds every exported method with a handler's
1815// signature; there must be exactly as many as there are routes.
1816func TestEveryHandlerIsMounted(t *testing.T) {
1817 app := ideartest.NewApp(t)
1818
1819 handlerish := reflect.TypeOf(func(http.ResponseWriter, *http.Request) {})
1820 typ := reflect.TypeOf(app.Handlers)
1821 handlers := 0
1822 var names []string
1823 for i := range typ.NumMethod() {
1824 m := typ.Method(i)
1825 // A method's own type has the receiver first; compare what is
1826 // left against the handler signature.
1827 if m.Type.NumIn() == handlerish.NumIn()+1 && m.Type.NumOut() == 0 &&
1828 m.Type.In(1) == handlerish.In(0) && m.Type.In(2) == handlerish.In(1) {
1829 handlers++
1830 names = append(names, m.Name)
1831 }
1832 }
1833 if handlers == 0 {
1834 t.Fatal("reflection found no handler methods at all; the check is broken")
1835 }
1836 if got := len(app.Handlers.Routes()); got != handlers {
1837 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)
1838 }
1839}
1840
1841// ---------------------------------------------------------------
1842// The invitation page starts the invitee's sign-in.
1843// ---------------------------------------------------------------
1844
1845// TestInvitationBeginsTheInviteesSignIn: with HandlerConfig.Begin set,
1846// a visitor with no session who presses the button on a live
1847// invitation has the app's sign-in link sent to the INVITED address —
1848// not signed in, and the token untouched, so holding the invitation
1849// link alone still opens nothing.
1850func TestInvitationBeginsTheInviteesSignIn(t *testing.T) {
1851 col := &collector{}
1852 var began []string
1853 app := ideartest.NewAppWith(t, idear.Config{}, idear.HandlerConfig{
1854 Deliver: col.deliver,
1855 Begin: func(w http.ResponseWriter, r *http.Request, address string) error {
1856 began = append(began, address)
1857 http.Redirect(w, r, "/invitations/sent", http.StatusSeeOther)
1858 return nil
1859 },
1860 })
1861 owner := app.H.Owner()
1862 const invitee = "new@example.test"
1863 token := invite(t, app, col, app.As(owner), invitee, idear.RoleMember)
1864
1865 visitor := app.Visitor()
1866 page := visitor.Get("/invitations/" + token)
1867 if page.Status != http.StatusOK || !strings.Contains(page.Body, "join true") {
1868 t.Fatalf("the invitation page does not offer to begin: %d %q", page.Status, page.Body)
1869 }
1870 if strings.Contains(page.Body, invitee) {
1871 t.Fatalf("the invitation page names the invitee to whoever holds the token: %q", page.Body)
1872 }
1873
1874 res := visitor.Post("/invitations/"+token, url.Values{})
1875 if res.Status != http.StatusSeeOther || res.Location != "/invitations/sent" {
1876 t.Fatalf("Accept: %d -> %q, want the app's answer", res.Status, res.Location)
1877 }
1878 if len(began) != 1 || began[0] != invitee {
1879 t.Fatalf("the app was asked to begin for %v, want [%s]", began, invitee)
1880 }
1881 if memberBySubject(t, app, invitee) != nil {
1882 t.Fatal("pressing the button admitted the invitee; only the link they follow may")
1883 }
1884 // The token is untouched: the link can be asked for again, and the
1885 // invitation is redeemed when the invitee signs in, as always.
1886 if again := app.Visitor().Post("/invitations/"+token, url.Values{}); again.Status != http.StatusSeeOther {
1887 t.Fatalf("a second press answered %d", again.Status)
1888 }
1889 if !app.H.Roster.Authorize(invitee) {
1890 t.Fatal("the invitation could not be redeemed by the invitee's sign-in")
1891 }
1892 if memberBySubject(t, app, invitee) == nil {
1893 t.Fatal("admission did not write the member")
1894 }
1895}
1896
1897// TestInvitationWithoutBeginHookStillAsksToSignInFirst pins the older
1898// contract for apps that have not set the hook.
1899func TestInvitationWithoutBeginHookStillAsksToSignInFirst(t *testing.T) {
1900 app, col := newApp(t)
1901 token := invite(t, app, col, app.As(app.H.Owner()), "new@example.test", idear.RoleMember)
1902 page := app.Visitor().Get("/invitations/" + token)
1903 if !strings.Contains(page.Body, "join false") {
1904 t.Fatalf("the page offers the button with no hook: %q", page.Body)
1905 }
1906 if res := app.Visitor().Post("/invitations/"+token, url.Values{}); res.Status != http.StatusForbidden {
1907 t.Fatalf("Accept with no hook: %d, want 403", res.Status)
1908 }
1909}
1910
1911// TestInvitingARemovedMemberIsRefused: admission refuses a deactivated
1912// member whatever invitation they hold, so an invitation to one is a
1913// dead end; Invite refuses it up front and points at Restore.
1914func TestInvitingARemovedMemberIsRefused(t *testing.T) {
1915 app, _ := newApp(t)
1916 gone := app.H.Deactivated(idear.RoleMember)
1917 oc := app.As(app.H.Owner())
1918 before, _ := app.H.Roster.PendingInvitations(app.H.Ctx())
1919 res := oc.Post("/members/invitations", url.Values{"email": {gone.Email}, "role": {string(idear.RoleMember)}})
1920 if res.Status != http.StatusBadRequest || !strings.Contains(res.Body, "removed from the team") {
1921 t.Fatalf("inviting a removed member: %d %q", res.Status, res.Body)
1922 }
1923 after, _ := app.H.Roster.PendingInvitations(app.H.Ctx())
1924 if len(after) != len(before) {
1925 t.Fatal("an invitation was created for a removed member")
1926 }
1927}
1928