package idear_test import ( "context" "errors" "fmt" "net/http" "net/url" "reflect" "strings" "sync" "testing" "time" "amadan.net/rastrillo/idear" "amadan.net/rastrillo/idear/internal/ideartest" ) // This file is the authorization suite the design calls the // deliverable. Every test drives REAL HTTP — chi, a cookie jar, real // sessions, rastrillo's real CSRF middleware — because the rules under // test are enforced by a stack and not by a function: routing, the // session guard, Require, RequireRole, the handler, and then the // store's own transaction. A test that called a handler directly would // prove the last layer and assume the rest. // // TWO RULES SHAPE HOW IT IS WRITTEN. // // First, nothing here restates the route table or the role matrix. The // expectations are DERIVED from idear.Handlers.Routes() and from // Role.AtLeast — the same list the app mounts and the same predicate // the middleware enforces. Round 1 of the bake-off found a test // written to whitelist the very payload it listed, and a suite that // quotes the implementation proves only that the implementation equals // itself. Add a tenth route and these tests exercise it without being // edited; change its rank floor and they change what they demand. // // Second, every claim of coverage here was checked by MUTATING the // handler and confirming the test goes red. The table is in // .superpowers/sdd/2026-08-23-idear/task-5-report.md. // --------------------------------------------------------------- // Derivation helpers: everything below reads the mounted route table. // --------------------------------------------------------------- // instantiate fills a route pattern's wildcards. It is the one place // a URL is built, so a route that grows a new wildcard fails loudly // here rather than quietly matching nothing. func instantiate(pattern, id, token string) string { s := strings.ReplaceAll(pattern, "{id}", id) return strings.ReplaceAll(s, "{token}", token) } // guarded is the routes behind the membership gate — everything that // is not public, taken from the mounted table. func guarded(app *ideartest.App) []idear.Route { var out []idear.Route for _, rt := range app.Handlers.Routes() { if !rt.Public { out = append(out, rt) } } return out } // management is the routes that require a rank ABOVE plain membership: // derived with Role.AtLeast, the same comparison RequireRole makes, so // a route whose floor is raised or lowered moves between these sets on // its own. func management(app *ideartest.App) []idear.Route { var out []idear.Route for _, rt := range guarded(app) { if !idear.RoleMember.AtLeast(rt.Min) { out = append(out, rt) } } return out } // wantStatus is what a viewer must get from rt when the request is // well-formed but its PAYLOAD IS NOT — an id of "0", an empty form. // // The invalid payload is what makes this a pure authorization probe. // An authorized actor gets 400 because the request is malformed, and // nothing in the database moves; an unauthorized one never gets far // enough to be told, so 403 and 404 still mean exactly what they mean. // That lets one probe be run against every route for every actor // without any of them mutating state the next probe depends on. func wantStatus(rt idear.Route, viewer *idear.Member) int { switch { case viewer == nil || !viewer.Active(): return http.StatusNotFound case !viewer.Role.AtLeast(rt.Min): return http.StatusForbidden case rt.Method == http.MethodGet: return http.StatusOK default: return http.StatusBadRequest } } // probe issues the invalid-payload request for rt. func probe(c *ideartest.Client, rt idear.Route) *ideartest.Result { path := instantiate(rt.Pattern, "0", "0") if rt.Method == http.MethodGet { return c.Get(path) } return c.Post(path, url.Values{}) } // checkAccess walks every guarded route with the invalid payload and // demands exactly the status wantStatus derives for this viewer — no // more access and no less. It returns the bodies of every 404 it saw, // for the byte-identity assertion. func checkAccess(t *testing.T, app *ideartest.App, c *ideartest.Client, viewer *idear.Member, what string) []string { t.Helper() var notFound []string for _, rt := range guarded(app) { res := probe(c, rt) want := wantStatus(rt, viewer) if res.Status != want { t.Errorf("%s: %s %s → %d, want %d; body %q", what, rt.Method, rt.Pattern, res.Status, want, res.Body) } switch res.Status { case http.StatusNotFound: notFound = append(notFound, res.Body) case http.StatusForbidden: if res.Body != ideartest.AppForbidden { t.Errorf("%s: %s %s answered 403 with %q, want the app's own 403 page", what, rt.Method, rt.Pattern, res.Body) } } } return notFound } // --------------------------------------------------------------- // Invitation plumbing: a Deliver hook, so a test can hold the token. // --------------------------------------------------------------- // collector is the app's mail: it records the links idear hands it. // Mutex-guarded because the race tests invite from several goroutines. type collector struct { mu sync.Mutex links []string } func (c *collector) deliver(r *http.Request, inv *idear.Invitation, link string) error { c.mu.Lock() defer c.mu.Unlock() c.links = append(c.links, link) return nil } // last is the token from the most recent link. func (c *collector) last(t *testing.T) string { t.Helper() c.mu.Lock() defer c.mu.Unlock() if len(c.links) == 0 { t.Fatal("no invitation was delivered") } return tokenOf(t, c.links[len(c.links)-1]) } func tokenOf(t *testing.T, link string) string { t.Helper() const prefix = "/invitations/" if !strings.HasPrefix(link, prefix) { t.Fatalf("invitation link %q does not start with %q", link, prefix) } return strings.TrimPrefix(link, prefix) } // newApp is an instance whose invitations are delivered to col. func newApp(t *testing.T) (*ideartest.App, *collector) { t.Helper() col := &collector{} app := ideartest.NewAppWith(t, idear.Config{}, idear.HandlerConfig{Deliver: col.deliver}) return app, col } // invite posts an invitation through the HTTP route and returns the // plaintext token, failing the test if the invite did not take. func invite(t *testing.T, app *ideartest.App, col *collector, c *ideartest.Client, email string, role idear.Role) string { t.Helper() res := c.Post("/members/invitations", url.Values{"email": {email}, "role": {string(role)}}) if res.Status != http.StatusSeeOther { t.Fatalf("inviting %s as %s: status %d, body %q", email, role, res.Status, res.Body) } return col.last(t) } // memberBySubject reads the row straight out of the database. Every // assertion about what a request DID goes through here: a 303 says the // handler thought it worked, and only the row says whether it did. func memberBySubject(t *testing.T, app *ideartest.App, subject string) *idear.Member { t.Helper() m, err := app.H.Roster.BySubject(app.H.Ctx(), subject) if err != nil { return nil } return m } // standing asserts the two flags the invitation page carries about the // viewer. The harness renderer prints every field of InvitationPage by // reflection, so these read what the HANDLER decided rather than what // a template chose to show. func standing(t *testing.T, res *ideartest.Result, who string, signedIn, reconcile bool) { t.Helper() if res.Status != http.StatusOK { t.Fatalf("%s: GET the invitation → %d; body %q", who, res.Status, res.Body) } for field, want := range map[string]bool{"signedin": signedIn, "reconcile": reconcile} { line := fmt.Sprintf("%s %v", field, want) if !strings.Contains(res.Body, line) { t.Errorf("%s: the invitation page does not say %q; body %q", who, line, res.Body) } } } // --------------------------------------------------------------- // §7.1 — a non-member is refused read and write on every route. // --------------------------------------------------------------- func TestNonMemberIsRefusedEveryRouteWithIdenticalNotFounds(t *testing.T) { app, _ := newApp(t) owner := app.H.Owner() admin := app.H.Member(idear.RoleAdmin) gone := app.H.Deactivated(idear.RoleMember) // Three ways of not being an active member, all of which must be // answered identically: never a member, signed out entirely, and // removed. strangers := map[string]*ideartest.Client{ "a signed-in stranger": app.SignIn("stranger-with-no-row"), "a signed-out visitor": app.Visitor(), "a removed member": app.As(gone), } var bodies []string for what, c := range strangers { bodies = append(bodies, checkAccess(t, app, c, nil, what)...) } // Deeply nested and oversized ids, on every route that takes one: // a stranger must not be able to tell a real id from a fabricated // one, or a route that exists from a path that does not. stranger := strangers["a signed-in stranger"] for _, rt := range guarded(app) { for _, id := range []string{ fmt.Sprint(owner.ID), fmt.Sprint(admin.ID), "999999999999", "-1", "1/2/3", "..%2F..%2Fmembers", } { path := instantiate(rt.Pattern, id, id) var res *ideartest.Result if rt.Method == http.MethodGet { res = stranger.Get(path) } else { res = stranger.Post(path, url.Values{"role": {"owner"}, "member": {fmt.Sprint(owner.ID)}}) } if res.Status != http.StatusNotFound { t.Errorf("stranger: %s %s → %d, want 404; body %q", rt.Method, path, res.Status, res.Body) } bodies = append(bodies, res.Body) } } // Byte-identical: one distinct body across every refusal above, // and it is the app's own 404 page. A 404 that varies with WHY it // was refused is the membership oracle the design forbids. distinct := map[string]int{} for _, b := range bodies { distinct[b]++ } if len(distinct) != 1 { t.Fatalf("refusals rendered %d distinct bodies, want 1: %v", len(distinct), distinct) } for b := range distinct { if b != ideartest.AppNotFound { t.Fatalf("refusal body = %q, want the app's own 404 page %q", b, ideartest.AppNotFound) } } // And nothing the stranger posted moved a row. if got := app.H.CountMembers(); got != 3 { t.Errorf("the roster holds %d rows, want the 3 it was seeded with", got) } app.H.TheOwner() } // --------------------------------------------------------------- // §7.2 — a Member is refused every management action. // --------------------------------------------------------------- func TestMemberIsRefusedEveryManagementAction(t *testing.T) { app, _ := newApp(t) owner := app.H.Owner() victim := app.H.Member(idear.RoleMember) plain := app.H.Member(idear.RoleMember) c := app.As(plain) // The whole guarded table, derived: a Member may read the members // page and may do nothing else. checkAccess(t, app, c, plain, "a plain member") if len(management(app)) == 0 { t.Fatal("no management routes were derived from the route table; the derivation is broken") } // The same routes again with REAL ids and real payloads, so the // refusal is not an artifact of the malformed probe. for _, rt := range management(app) { path := instantiate(rt.Pattern, fmt.Sprint(victim.ID), "0") res := c.Post(path, url.Values{ "role": {string(idear.RoleAdmin)}, "email": {"newcomer@example.test"}, "member": {fmt.Sprint(victim.ID)}, }) if res.Status != http.StatusForbidden { t.Errorf("%s %s by a member → %d, want 403; body %q", rt.Method, path, res.Status, res.Body) } } // Nothing moved: same roles, same activity, no invitations. if got := app.H.Reload(victim.ID); got.Role != idear.RoleMember || !got.Active() { t.Errorf("the victim is now %s/%s; a member's refused actions still landed", got.Role, ideartest.AppNotFound) } if got := app.H.Reload(owner.ID); got.Role != idear.RoleOwner { t.Errorf("the owner is now %s", got.Role) } invs, err := app.H.Roster.PendingInvitations(app.H.Ctx()) if err != nil { t.Fatalf("listing invitations: %v", err) } if len(invs) != 0 { t.Errorf("a member's refused invite created %d invitations", len(invs)) } } // --------------------------------------------------------------- // §7.3 — an Admin cannot touch an Admin or the Owner. // --------------------------------------------------------------- func TestAdminCannotActOnAdminOrOwner(t *testing.T) { app, _ := newApp(t) owner := app.H.Owner() actor := app.H.Member(idear.RoleAdmin) peer := app.H.Member(idear.RoleAdmin) goneAdmin := app.H.Deactivated(idear.RoleAdmin) c := app.As(actor) // Every target an Admin may not act on, including themselves — // self-management is how an instance ends up with nobody able to // administer it. targets := map[string]*idear.Member{ "the owner": owner, "a peer admin": peer, "a deactivated admin": goneAdmin, "themselves": actor, } // The three target-shaped mutations, derived from the route table // rather than listed: every management route that carries an {id}. var byID []idear.Route for _, rt := range management(app) { if strings.Contains(rt.Pattern, "{id}") && !strings.Contains(rt.Pattern, "invitations") { byID = append(byID, rt) } } if len(byID) == 0 { t.Fatal("no id-addressed management routes were derived; the derivation is broken") } for what, target := range targets { for _, rt := range byID { path := instantiate(rt.Pattern, fmt.Sprint(target.ID), "0") res := c.Post(path, url.Values{"role": {string(idear.RoleMember)}}) if res.Status != http.StatusForbidden { t.Errorf("admin → %s: %s %s → %d, want 403; body %q", what, rt.Method, path, res.Status, res.Body) } } } // The rows, not the responses. if got := app.H.Reload(owner.ID); got.Role != idear.RoleOwner || !got.Active() { t.Errorf("the owner is now %s/%v after an admin's attempts", got.Role, got.Active()) } if got := app.H.Reload(peer.ID); got.Role != idear.RoleAdmin || !got.Active() { t.Errorf("the peer admin is now %s/%v after an admin's attempts", got.Role, got.Active()) } if got := app.H.Reload(goneAdmin.ID); got.Active() { t.Error("an admin restored a deactivated admin; only the owner may act on an admin") } if got := app.H.Reload(actor.ID); got.Role != idear.RoleAdmin || !got.Active() { t.Errorf("the acting admin acted on themselves: now %s/%v", got.Role, got.Active()) } // A member id that resolves to NOTHING is answered by the same // hook, with the same bytes, as a non-member's refusal — which is // what refuse()'s "same hook, same bytes" comment claims and // nothing else asserted. for _, rt := range byID { path := instantiate(rt.Pattern, "999999999", "0") res := c.Post(path, url.Values{"role": {string(idear.RoleMember)}}) if res.Status != http.StatusNotFound { t.Errorf("%s on a nonexistent member → %d, want 404; body %q", path, res.Status, res.Body) } if res.Body != ideartest.AppNotFound { t.Errorf("%s answered 404 with %q, want the app's own 404 page", path, res.Body) } } // What the admin CAN do, so the test is not green because the // admin can do nothing at all. plain := app.H.Member(idear.RoleMember) res := c.Post(fmt.Sprintf("/members/%d/remove", plain.ID), url.Values{}) if res.Status != http.StatusSeeOther { t.Fatalf("an admin removing a member → %d, want 303; body %q", res.Status, res.Body) } if app.H.Reload(plain.ID).Active() { t.Error("an admin's legitimate removal did not land") } } // --------------------------------------------------------------- // §7.4 — a posted role=owner never lands, on any path, for any actor. // --------------------------------------------------------------- func TestPostedOwnerRoleNeverLands(t *testing.T) { // One instance per actor, so an attempt by one cannot be masked by // a refusal for another. actors := []struct { name string role idear.Role }{ {"the owner", idear.RoleOwner}, {"an admin", idear.RoleAdmin}, {"a member", idear.RoleMember}, {"a stranger", ""}, } for _, actor := range actors { t.Run(actor.name, func(t *testing.T) { app, _ := newApp(t) owner := app.H.Owner() admin := app.H.Member(idear.RoleAdmin) plain := app.H.Member(idear.RoleMember) gone := app.H.Deactivated(idear.RoleMember) var c *ideartest.Client switch actor.role { case idear.RoleOwner: c = app.As(owner) case idear.RoleAdmin: c = app.As(admin) case idear.RoleMember: c = app.As(plain) default: c = app.SignIn("stranger-with-no-row") } // The payload is ONE form for every route: every field // idear reads, all of them carrying the escalation. // // "member" names a row that does not exist, so the ONE // route that may legitimately mint an owner cannot // succeed here and no 303 below can be a real transfer. // (That Transfer works at all, and reads no role while // doing it, is TestTransferIsTheOnlyPathToOwner.) attack := url.Values{ "role": {string(idear.RoleOwner)}, "email": {"escalation@example.test"}, "member": {"999999999"}, } // Every route the app mounts, public ones included, at // every interesting target. Some of these legitimately // answer 303 — an admin really may remove a member — so // the status is not the assertion; the rows below are. for _, rt := range app.Handlers.Routes() { if rt.Method != http.MethodPost { continue } for _, target := range []*idear.Member{owner, admin, plain, gone} { path := instantiate(rt.Pattern, fmt.Sprint(target.ID), "0") c.Post(path, attack) } } // THE ROWS. Exactly one owner, and it is the row that was // seeded as owner — no promotion, and no new row. if got := app.H.TheOwner(); got.ID != owner.ID { t.Fatalf("the owner is now member %d (%s), want the seeded owner %d", got.ID, got.Subject, owner.ID) } for _, m := range []*idear.Member{admin, plain, gone} { if got := app.H.Reload(m.ID); got.Role == idear.RoleOwner { t.Errorf("member %d was promoted to owner by a posted role", m.ID) } } // And no invitation carries the role either: an // owner-role invitation is a delayed escalation, so the // refusal has to hold at the mint and not only at the // redemption. var invs []idear.Invitation if err := app.H.DB.G.Where("role = ?", idear.RoleOwner).Find(&invs).Error; err != nil { t.Fatalf("listing owner invitations: %v", err) } if len(invs) != 0 { t.Errorf("%d invitations were minted at role owner", len(invs)) } }) } } // TestTransferIsTheOnlyPathToOwner is the other half of §7.4: the one // route that MAY mint an owner does so from its own rules and never // from the posted role, and the instance still has exactly one. func TestTransferIsTheOnlyPathToOwner(t *testing.T) { app, _ := newApp(t) owner := app.H.Owner() admin := app.H.Member(idear.RoleAdmin) c := app.As(owner) res := c.Post("/members/transfer", url.Values{ "member": {fmt.Sprint(admin.ID)}, // Posted, and irrelevant: Transfer reads no role at all. "role": {string(idear.RoleMember)}, }) if res.Status != http.StatusSeeOther { t.Fatalf("transfer → %d, want 303; body %q", res.Status, res.Body) } if got := app.H.TheOwner(); got.ID != admin.ID { t.Fatalf("ownership landed on member %d, want %d", got.ID, admin.ID) } if got := app.H.Reload(owner.ID); got.Role != idear.RoleAdmin { t.Errorf("the outgoing owner is %s, want admin", got.Role) } } // --------------------------------------------------------------- // §7.5 — the single-owner invariant across concurrent transfers. // --------------------------------------------------------------- func TestConcurrentTransfersThroughHTTPLeaveOneOwner(t *testing.T) { const ( n = 6 rounds = 5 ) for round := range rounds { app, _ := newApp(t) owner := app.H.Owner() targets := make([]*idear.Member, n) for i := range targets { targets[i] = app.H.Member(idear.RoleAdmin) } c := app.As(owner) gate, wait := release() codes := make([]int, n) errs := make([]error, n) var wg sync.WaitGroup for i := range n { wg.Add(1) // The spawn order alternates with i's parity for the same // reason pair() flips: closing a channel readies waiters // FIFO and the last one readied runs first, so a fixed // order biases which racer wins. go func() { defer wg.Done() wait() res, err := c.TryPost("/members/transfer", url.Values{"member": {fmt.Sprint(targets[i].ID)}}) if err != nil { errs[i] = err return } codes[i] = res.Status }() } close(gate) wg.Wait() won := 0 for i, code := range codes { if errs[i] != nil { t.Fatalf("round %d: transfer %d failed in transport: %v", round, i, errs[i]) } if code == http.StatusSeeOther { won++ } } if won != 1 { t.Fatalf("round %d: %d of %d concurrent transfers were accepted, want exactly 1 (codes %v)", round, won, n, codes) } got := app.H.TheOwner() if !got.Active() { t.Fatalf("round %d: the surviving owner %d is deactivated", round, got.ID) } if got.ID == owner.ID { t.Fatalf("round %d: a transfer was accepted but ownership did not move", round) } } } // --------------------------------------------------------------- // §7.6 — an invited address cannot be claimed without the token. // --------------------------------------------------------------- func TestInvitedAddressCannotBeClaimedWithoutTheToken(t *testing.T) { app, col := newApp(t) owner := app.H.Owner() oc := app.As(owner) const invited = "admin@corp.test" token := invite(t, app, col, oc, invited, idear.RoleAdmin) // The attacker knows the address — it is the whole premise — and // signs in as it (which is what keymail's subject looks like). // Every public route, without the token, must leave them out. attacker := app.SignIn(invited) for _, rt := range app.Handlers.Routes() { if !rt.Public { continue } for _, tok := range []string{"", "not-a-token", strings.Repeat("0", 64)} { path := instantiate(rt.Pattern, "0", url.PathEscape(tok)) var res *ideartest.Result if rt.Method == http.MethodGet { res = attacker.Get(path) } else { res = attacker.Post(path, url.Values{"role": {"owner"}, "email": {invited}}) } if res.Status == http.StatusSeeOther { t.Errorf("%s %s admitted an address with no token", rt.Method, path) } } } if m := memberBySubject(t, app, invited); m != nil { t.Fatalf("the invited address was admitted at %s with no token", m.Role) } if got := app.H.CountMembers(); got != 1 { t.Fatalf("the roster holds %d rows, want just the owner", got) } // The token still works afterwards — the refusals above did not // consume it, which is what makes them refusals rather than a // broken flow. res := attacker.Post("/invitations/"+token, url.Values{}) if res.Status != http.StatusSeeOther { t.Fatalf("redeeming the real token → %d, want 303; body %q", res.Status, res.Body) } m := memberBySubject(t, app, invited) if m == nil || m.Role != idear.RoleAdmin { t.Fatalf("after redeeming the token the member is %+v, want an admin", m) } } // --------------------------------------------------------------- // §7.7 — Revoke racing Accept never admits. // --------------------------------------------------------------- func TestRevokeRacingAcceptThroughHTTPNeverAdmits(t *testing.T) { const rounds = 24 admitted, killed := 0, 0 for round := range rounds { app, col := newApp(t) owner := app.H.Owner() oc := app.As(owner) const invitee = "racer@example.test" token := invite(t, app, col, oc, invitee, idear.RoleMember) invs, err := app.H.Roster.PendingInvitations(app.H.Ctx()) if err != nil || len(invs) != 1 { t.Fatalf("round %d: pending invitations = %v, %v", round, invs, err) } inv := invs[0] // The orphan: an app user with a session and no member row, // which is exactly who this route is for. orphan := app.SignIn(fmt.Sprintf("orphan-%d", round)) var acceptRes, revokeRes *ideartest.Result var acceptErr, revokeErr error pair(round%2 == 0, func() { acceptRes, acceptErr = orphan.TryPost("/invitations/"+token, url.Values{}) }, func() { revokeRes, revokeErr = oc.TryPost(fmt.Sprintf("/members/invitations/%d/revoke", inv.ID), url.Values{}) }) if acceptErr != nil || revokeErr != nil { t.Fatalf("round %d: transport failed: accept %v, revoke %v", round, acceptErr, revokeErr) } row := app.H.Invitation(inv.ID) member := memberBySubject(t, app, orphan.Subject) switch { case acceptRes.Status == http.StatusSeeOther: admitted++ if member == nil { t.Fatalf("round %d: acceptance answered 303 but wrote no member row", round) } if row.AcceptedAt == nil { t.Fatalf("round %d: a member was admitted from an invitation that was never marked accepted", round) } if row.RevokedAt != nil { t.Fatalf("round %d: THE INVARIANT BROKE — a REVOKED invitation admitted a member", round) } if revokeRes.Status == http.StatusSeeOther { t.Fatalf("round %d: both the accept and the revoke were accepted", round) } default: killed++ if member != nil { t.Fatalf("round %d: acceptance was refused (%d) but a member row exists: %+v", round, acceptRes.Status, member) } if revokeRes.Status != http.StatusSeeOther { t.Fatalf("round %d: neither side won: accept %d, revoke %d", round, acceptRes.Status, revokeRes.Status) } if row.RevokedAt == nil { t.Fatalf("round %d: the revoke was accepted but the row is not revoked", round) } } } // Both branches must actually have been exercised. A race test // that only ever resolves one way is green for a reason unrelated // to the property it claims to check. t.Logf("the race split %d admitted / %d revoked over %d rounds", admitted, killed, rounds) if admitted == 0 || killed == 0 { t.Fatalf("the race never split: %d admitted, %d revoked over %d rounds", admitted, killed, rounds) } } // --------------------------------------------------------------- // §7.8 — expiry is refused, and an acceptance cannot be replayed. // --------------------------------------------------------------- func TestExpiredInvitationIsRefused(t *testing.T) { app, col := newApp(t) oc := app.As(app.H.Owner()) token := invite(t, app, col, oc, "late@example.test", idear.RoleMember) invs, err := app.H.Roster.PendingInvitations(app.H.Ctx()) if err != nil || len(invs) != 1 { t.Fatalf("pending invitations = %v, %v", invs, err) } app.H.Expire(invs[0].ID) // The public GET stops offering it... visitor := app.Visitor() if res := visitor.Get("/invitations/" + token); res.Status != http.StatusNotFound { t.Errorf("GET an expired invitation → %d, want 404; body %q", res.Status, res.Body) } // ...and the redemption is refused, with no row written. orphan := app.SignIn("late-orphan") res := orphan.Post("/invitations/"+token, url.Values{}) if res.Status != http.StatusNotFound { t.Errorf("POST an expired invitation → %d, want 404; body %q", res.Status, res.Body) } if m := memberBySubject(t, app, "late-orphan"); m != nil { t.Fatalf("an expired invitation admitted %+v", m) } } func TestAcceptedInvitationCannotBeReplayed(t *testing.T) { app, col := newApp(t) oc := app.As(app.H.Owner()) token := invite(t, app, col, oc, "first@example.test", idear.RoleMember) first := app.SignIn("first-orphan") if res := first.Post("/invitations/"+token, url.Values{}); res.Status != http.StatusSeeOther { t.Fatalf("the first redemption → %d, want 303; body %q", res.Status, res.Body) } if m := memberBySubject(t, app, "first-orphan"); m == nil { t.Fatal("the first redemption wrote no member row") } // A DIFFERENT session replaying the same link gets nothing. second := app.SignIn("second-orphan") res := second.Post("/invitations/"+token, url.Values{}) if res.Status != http.StatusNotFound { t.Errorf("replaying a spent invitation → %d, want 404; body %q", res.Status, res.Body) } if m := memberBySubject(t, app, "second-orphan"); m != nil { t.Fatalf("a spent invitation admitted a second person: %+v", m) } // And the ORIGINAL redeemer replaying it changes nothing: they are // already a member, and no second row appears. if res := first.Post("/invitations/"+token, url.Values{}); res.Status != http.StatusSeeOther { t.Errorf("a member reopening their own link → %d, want 303", res.Status) } if got := app.H.CountMembers(); got != 2 { t.Fatalf("the roster holds %d rows, want the owner and one redeemer", got) } } // --------------------------------------------------------------- // §7.9 — the orphan, healed by POST /invitations/{token}. // --------------------------------------------------------------- func TestOrphanIs404edEverywhereUntilReconciled(t *testing.T) { app, col := newApp(t) owner := app.H.Owner() oc := app.As(owner) // The orphan's subject is MIXED CASE and address-shaped, which is // what keymail mints — auth takes the address the visitor typed. // The member row this route writes must be canonicalised by the // store, or the person signs in forever and 404s forever. const typed = "Orphan@Example.Test" orphan := app.SignIn(typed) // Every guarded route, derived: 404, byte-identical, before. for _, body := range checkAccess(t, app, orphan, nil, "an orphan") { if body != ideartest.AppNotFound { t.Fatalf("an orphan's 404 body = %q, want the app's own page", body) } } // Somebody invites them. Only then can the route heal them — it // needs a valid token, which is the spec's corrected wording. token := invite(t, app, col, oc, "orphan@example.test", idear.RoleAdmin) // The invitation page has to SAY they are the orphan, because // SignedIn and Reconcile are what a template keys the accept // control off. Stuck at false, item 9's healing path is // unreachable from any real UI while the redemption below still // answers 303 — so the flags are asserted, not assumed. standing(t, orphan.Get("/invitations/"+token), "the orphan", true, true) standing(t, app.Visitor().Get("/invitations/"+token), "a signed-out visitor", false, false) res := orphan.Post("/invitations/"+token, url.Values{}) if res.Status != http.StatusSeeOther { t.Fatalf("reconciliation → %d, want 303; body %q", res.Status, res.Body) } if res.Location != "/members" { t.Errorf("reconciliation redirected to %q, want /members", res.Location) } // The row: written from the LIVE SESSION SUBJECT, through the // store, and therefore canonicalised. m := memberBySubject(t, app, typed) if m == nil { t.Fatal("reconciliation wrote no member row") } if m.Subject != strings.ToLower(typed) { 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)) } if m.Role != idear.RoleAdmin { t.Errorf("the healed member is %s, want the invitation's admin", m.Role) } // And afterwards they have exactly the access their role earns — // derived, so "exactly" means every route. checkAccess(t, app, orphan, m, "a healed orphan") // A member is signed in and has nothing left to reconcile, so the // accept control must be gone. Their own spent token is no longer // a valid invitation, so this asks about a fresh one. fresh := invite(t, app, col, oc, "somebody.else@example.test", idear.RoleMember) standing(t, orphan.Get("/invitations/"+fresh), "a healed member", true, false) } func TestReconciliationClaimsAnUnclaimedInstance(t *testing.T) { // The other orphan: admission created the app user and its Claim // did not commit, so the roster is still empty and there is nobody // to invite them. app, _ := newApp(t) if got := app.H.CountMembers(); got != 0 { t.Fatalf("a fresh instance holds %d rows", got) } orphan := app.SignIn("1") // password's subject: a decimal user id res := orphan.Post("/invitations/anything", url.Values{}) if res.Status != http.StatusSeeOther { t.Fatalf("reconciliation on an empty roster → %d, want 303; body %q", res.Status, res.Body) } got := app.H.TheOwner() if got.Subject != "1" { t.Fatalf("the claim wrote subject %q, want the live session's %q", got.Subject, "1") } // And it does not reopen: the next signed-in stranger is not an // owner, and holds no token. stranger := app.SignIn("2") if res := stranger.Post("/invitations/anything", url.Values{}); res.Status == http.StatusSeeOther { t.Fatal("a second signed-in stranger was admitted by the reconciliation route") } app.H.TheOwner() if got := app.H.CountMembers(); got != 1 { t.Fatalf("the roster holds %d rows, want 1", got) } } func TestReconciliationRefusesAnotherAddressesInvitation(t *testing.T) { // Under keymail the session subject IS a verified address, so idear // knows who the viewer is and the invitation must be theirs. app, col := newApp(t) oc := app.As(app.H.Owner()) token := invite(t, app, col, oc, "intended@example.test", idear.RoleAdmin) interloper := app.SignIn("someone.else@example.test") res := interloper.Post("/invitations/"+token, url.Values{}) if res.Status != http.StatusNotFound { t.Errorf("redeeming another address's invitation → %d, want 404; body %q", res.Status, res.Body) } if m := memberBySubject(t, app, "someone.else@example.test"); m != nil { t.Fatalf("another address's invitation admitted %+v", m) } // The invitation is untouched, so the intended recipient can still // use it. intended := app.SignIn("intended@example.test") if res := intended.Post("/invitations/"+token, url.Values{}); res.Status != http.StatusSeeOther { t.Fatalf("the intended recipient → %d, want 303; body %q", res.Status, res.Body) } } func TestReconciliationRefusesASignedOutVisitor(t *testing.T) { // The route writes a member row FOR THE SESSION IN HAND. With no // session there is nobody to write it for, and a token holder who // is merely holding a link must not be able to conjure a // membership out of it — under password they would have no app // user either, and the row would join to nothing. app, col := newApp(t) oc := app.As(app.H.Owner()) token := invite(t, app, col, oc, "expected@example.test", idear.RoleMember) res := app.Visitor().Post("/invitations/"+token, url.Values{}) if res.Status != http.StatusForbidden { t.Fatalf("a signed-out redemption → %d, want a 403 refusal; body %q", res.Status, res.Body) } if got := app.H.CountMembers(); got != 1 { t.Fatalf("the roster holds %d rows, want just the owner", got) } // And the token was not spent by the refusal: the person it was // meant for can still redeem it once they are signed in. invs, err := app.H.Roster.PendingInvitations(app.H.Ctx()) if err != nil || len(invs) != 1 { t.Fatalf("pending invitations = %v, %v; the refusal consumed the invitation", invs, err) } signedIn := app.SignIn("expected@example.test") if res := signedIn.Post("/invitations/"+token, url.Values{}); res.Status != http.StatusSeeOther { t.Fatalf("the intended recipient → %d, want 303; body %q", res.Status, res.Body) } } func TestReconciliationRefusesADeactivatedMember(t *testing.T) { app, col := newApp(t) oc := app.As(app.H.Owner()) gone := app.H.Deactivated(idear.RoleAdmin) token := invite(t, app, col, oc, gone.Email, idear.RoleMember) c := app.As(gone) if res := c.Post("/invitations/"+token, url.Values{}); res.Status != http.StatusNotFound { t.Errorf("a removed member redeeming an invitation → %d, want 404; body %q", res.Status, res.Body) } if app.H.Reload(gone.ID).Active() { t.Fatal("a removed member let themselves back in with an invitation; readmission is Restore") } } // --------------------------------------------------------------- // Reconciliation under the PASSWORD plugin: the token, and whether // anything else is asked of the viewer. // // Under keymail the session Subject is the verified address, so idear // matches it against the invitation itself (the test above). Under // password the Subject is an opaque decimal user id and idear can only // resolve it through Config.EmailForSubject. These two tests are the // two configurations, and the difference between them is the whole // point: one refuses a token issued to somebody else, the other — the // documented permissive path — does not. // --------------------------------------------------------------- // directory is an app's own user table, as idear sees it through // Config.EmailForSubject: subject → address, and an error the app // cannot answer through. type directory struct { byID map[string]string err error } func (d *directory) emailForSubject(ctx context.Context, subject string) (string, error) { if d.err != nil { return "", d.err } // The app's own miss: not one of this app's subjects, or an id // with no row behind it. "" with a NIL error, which idear must // read as a refusal and not as a storage failure. return d.byID[subject], nil } // passwordApp is an instance whose subjects are password's — decimal // user ids — with dir standing in for the app's user table. func passwordApp(t *testing.T, dir *directory) (*ideartest.App, *collector) { t.Helper() col := &collector{} cfg := idear.Config{} if dir != nil { cfg.EmailForSubject = dir.emailForSubject } return ideartest.NewAppWith(t, cfg, idear.HandlerConfig{Deliver: col.deliver}), col } // TestReconciliationWithEmailForSubjectRefusesAnotherAddressesToken is // the F1 regression test. // // The orphan is real and signed in: their app user row exists and // their member row does not, which is what a create-succeeded-then- // member-write-failed admission leaves behind, and what the loser of a // first-signup claim race is. They are not entitled to a token issued // to somebody else — tokens leak into browser flash cookies (with // Deliver nil), into URL history, referrers and logs — and with the // resolver wired, idear applies the rule admission applies: possession // of the token AND an email match. func TestReconciliationWithEmailForSubjectRefusesAnotherAddressesToken(t *testing.T) { dir := &directory{byID: map[string]string{ "7": "orphan@example.test", // The intended recipient's spelling in the app's table is not // the invitation's: the comparison is normalised on both // sides, or a correctly-invited person is locked out. "9": "Intended@Example.Test", }} app, col := passwordApp(t, dir) oc := app.As(app.H.Owner()) token := invite(t, app, col, oc, "intended@example.test", idear.RoleAdmin) orphan := app.SignIn("7") res := orphan.Post("/invitations/"+token, url.Values{}) if res.Status != http.StatusNotFound { t.Fatalf("an orphan redeeming another address's token → %d, want 404; body %q", res.Status, res.Body) } if m := memberBySubject(t, app, "7"); m != nil { t.Fatalf("another address's invitation admitted %+v at %s", m, m.Role) } // The refusal did not spend it: the person it was issued to still // has it, and lands at the invited role. intended := app.SignIn("9") if res := intended.Post("/invitations/"+token, url.Values{}); res.Status != http.StatusSeeOther { t.Fatalf("the intended recipient → %d, want 303; body %q", res.Status, res.Body) } m := memberBySubject(t, app, "9") if m == nil { t.Fatal("the intended recipient redeemed the invitation but no member row was written") } if m.Role != idear.RoleAdmin { t.Errorf("the healed orphan is %s, want the invitation's admin", m.Role) } } // TestReconciliationWithoutEmailForSubjectTrustsTheTokenAlone is the // OTHER half of F1, and it is deliberately an assertion of the // permissive behaviour rather than a gap left untested. // // With no Config.EmailForSubject, idear has no way to turn a decimal // user id into an address, so reconciliation asks only for a live // token. A signed-in orphan holding one issued to somebody else // redeems it, at that token's role. That is what SKILL.md §5 says // happens, and this test is what keeps the two of them honest: change // the behaviour and this goes red, which is the moment to change the // documentation with it. func TestReconciliationWithoutEmailForSubjectTrustsTheTokenAlone(t *testing.T) { app, col := passwordApp(t, nil) oc := app.As(app.H.Owner()) token := invite(t, app, col, oc, "intended@example.test", idear.RoleAdmin) orphan := app.SignIn("7") res := orphan.Post("/invitations/"+token, url.Values{}) if res.Status != http.StatusSeeOther { t.Fatalf("the documented permissive path → %d, want 303; body %q", res.Status, res.Body) } m := memberBySubject(t, app, "7") if m == nil { t.Fatal("the permissive path admitted nobody; SKILL.md §5 says the token alone is enough here") } if m.Role != idear.RoleAdmin { 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) } } // TestReconciliationRefusesAViewerTheResolverCannotPlace covers the // resolver's two non-answers, which must not be confused with each // other. // // ("", nil) is the app saying "that subject is nobody I know" — a // REFUSAL, because a viewer idear cannot identify must not spend an // invitation issued to one it can. A non-nil error is the app's // database failing, which is a 500 and must never read as policy: the // invitation stays live and the person can try again. func TestReconciliationRefusesAViewerTheResolverCannotPlace(t *testing.T) { t.Run("unknown subject", func(t *testing.T) { app, col := passwordApp(t, &directory{byID: map[string]string{}}) oc := app.As(app.H.Owner()) token := invite(t, app, col, oc, "intended@example.test", idear.RoleMember) res := app.SignIn("7").Post("/invitations/"+token, url.Values{}) if res.Status != http.StatusNotFound { t.Fatalf("a subject the app cannot place → %d, want 404; body %q", res.Status, res.Body) } if m := memberBySubject(t, app, "7"); m != nil { t.Fatalf("an unplaceable subject was admitted: %+v", m) } }) t.Run("the resolver fails", func(t *testing.T) { app, col := passwordApp(t, &directory{err: errors.New("the app's user table is unreadable")}) oc := app.As(app.H.Owner()) token := invite(t, app, col, oc, "intended@example.test", idear.RoleMember) res := app.SignIn("7").Post("/invitations/"+token, url.Values{}) if res.Status != http.StatusInternalServerError { t.Fatalf("a failing resolver → %d, want 500; a storage failure must not render as a policy refusal. Body %q", res.Status, res.Body) } if m := memberBySubject(t, app, "7"); m != nil { t.Fatalf("a failing resolver admitted %+v", m) } // And it cost nobody their invitation. invs, err := app.H.Roster.PendingInvitations(app.H.Ctx()) if err != nil || len(invs) != 1 { t.Fatalf("pending invitations = %v, %v; a failed lookup consumed the invitation", invs, err) } }) } // --------------------------------------------------------------- // §7.10 — Transfer racing Deactivate never yields a deactivated Owner. // --------------------------------------------------------------- func TestTransferRacingRemoveThroughHTTP(t *testing.T) { const rounds = 24 transferred, removed := 0, 0 for round := range rounds { app, _ := newApp(t) owner := app.H.Owner() admin := app.H.Member(idear.RoleAdmin) target := app.H.Member(idear.RoleMember) oc := app.As(owner) ac := app.As(admin) var transferRes, removeRes *ideartest.Result var transferErr, removeErr error pair(round%2 == 0, func() { transferRes, transferErr = oc.TryPost("/members/transfer", url.Values{"member": {fmt.Sprint(target.ID)}}) }, func() { removeRes, removeErr = ac.TryPost(fmt.Sprintf("/members/%d/remove", target.ID), url.Values{}) }) if transferErr != nil || removeErr != nil { t.Fatalf("round %d: transport failed: transfer %v, remove %v", round, transferErr, removeErr) } // The invariant, whichever way it resolved: exactly one owner, // and that owner is ACTIVE. A deactivated owner is an instance // nobody can administer and nobody can be promoted out of. got := app.H.TheOwner() if !got.Active() { t.Fatalf("round %d: THE INVARIANT BROKE — the owner (member %d) is deactivated", round, got.ID) } row := app.H.Reload(target.ID) if transferRes.Status == http.StatusSeeOther { transferred++ if got.ID != target.ID { t.Fatalf("round %d: the transfer was accepted but ownership is on member %d", round, got.ID) } if !row.Active() { t.Fatalf("round %d: the new owner is deactivated", round) } } else { removed++ if got.ID != owner.ID { t.Fatalf("round %d: the transfer was refused (%d) but ownership moved to %d", round, transferRes.Status, got.ID) } if removeRes.Status != http.StatusSeeOther { t.Fatalf("round %d: neither side won: transfer %d, remove %d", round, transferRes.Status, removeRes.Status) } if row.Active() { t.Fatalf("round %d: the removal was accepted but the target is still active", round) } } } t.Logf("the race split %d transfers / %d removals over %d rounds", transferred, removed, rounds) if transferred == 0 || removed == 0 { t.Fatalf("the race never split: %d transfers, %d removals over %d rounds", transferred, removed, rounds) } } // --------------------------------------------------------------- // §7.11 — reactivation restores exactly the prior access. // --------------------------------------------------------------- func TestReactivatedMemberRegainsExactlyTheirPriorAccess(t *testing.T) { app, _ := newApp(t) owner := app.H.Owner() admin := app.H.Member(idear.RoleAdmin) oc := app.As(owner) c := app.As(admin) // Before: the whole route table, recorded. before := map[string]int{} for _, rt := range guarded(app) { res := probe(c, rt) before[rt.Method+" "+rt.Pattern] = res.Status if want := wantStatus(rt, admin); res.Status != want { t.Fatalf("before removal: %s %s → %d, want %d", rt.Method, rt.Pattern, res.Status, want) } } // Removed: nothing at all, and the session still exists — under // password a removed member can still hold a session, and what // stops them is Require on every route. if res := oc.Post(fmt.Sprintf("/members/%d/remove", admin.ID), url.Values{}); res.Status != http.StatusSeeOther { t.Fatalf("removing the admin → %d; body %q", res.Status, res.Body) } checkAccess(t, app, c, nil, "a removed admin") // Restored: the SAME statuses as before, route for route. More // would be an escalation; fewer would make Restore useless. if res := oc.Post(fmt.Sprintf("/members/%d/restore", admin.ID), url.Values{}); res.Status != http.StatusSeeOther { t.Fatalf("restoring the admin → %d; body %q", res.Status, res.Body) } restored := app.H.Reload(admin.ID) if !restored.Active() || restored.Role != idear.RoleAdmin { t.Fatalf("the restored member is %s/%v", restored.Role, restored.Active()) } for _, rt := range guarded(app) { res := probe(c, rt) key := rt.Method + " " + rt.Pattern if res.Status != before[key] { t.Errorf("after restore: %s → %d, but before removal it was %d", key, res.Status, before[key]) } } // "And no more": the one thing an admin could never do is still // refused, and the owner is still the owner. if res := c.Post("/members/transfer", url.Values{"member": {fmt.Sprint(admin.ID)}}); res.Status != http.StatusForbidden { t.Errorf("a restored admin transferring ownership → %d, want 403", res.Status) } if got := app.H.TheOwner(); got.ID != owner.ID { t.Fatalf("ownership moved to %d", got.ID) } } // --------------------------------------------------------------- // §7.12 — the public GET is not an address oracle. // --------------------------------------------------------------- func TestInvitationPageDoesNotDiscloseTheAddress(t *testing.T) { app, col := newApp(t) oc := app.As(app.H.Owner()) const ( local = "secret.person" domain = "hidden.example" invited = local + "@" + domain ) token := invite(t, app, col, oc, invited, idear.RoleAdmin) // The renderer prints every field of InvitationPage by reflection, // so this is a test of what the HANDLER passed and not of what a // template chose to show. for what, c := range map[string]*ideartest.Client{ "an anonymous visitor": app.Visitor(), "a signed-in stranger": app.SignIn("nosy@example.test"), } { res := c.Get("/invitations/" + token) if res.Status != http.StatusOK { t.Fatalf("%s: GET the invitation → %d; body %q", what, res.Status, res.Body) } for _, secret := range []string{invited, local, domain} { if strings.Contains(strings.ToLower(res.Body), secret) { t.Errorf("%s: the invitation page disclosed %q; body %q", what, secret, res.Body) } } // It does say what the holder is entitled to know. if !strings.Contains(res.Body, string(idear.RoleAdmin)) { t.Errorf("%s: the invitation page does not name the role; body %q", what, res.Body) } if !strings.Contains(res.Body, app.Server.Listener.Addr().String()) { t.Errorf("%s: the invitation page does not name the instance; body %q", what, res.Body) } } // An unusable token answers the SAME way whichever way it is // unusable, so the page cannot be used to tell a real token from a // spent one. visitor := app.Visitor() bodies := map[string]bool{} for _, tok := range []string{"never-existed", strings.Repeat("a", 64)} { res := visitor.Get("/invitations/" + tok) if res.Status != http.StatusNotFound { t.Errorf("GET %q → %d, want 404", tok, res.Status) } bodies[res.Body] = true } // ...including one that WAS real and has been revoked. invs, err := app.H.Roster.PendingInvitations(app.H.Ctx()) if err != nil || len(invs) != 1 { t.Fatalf("pending invitations = %v, %v", invs, err) } if res := oc.Post(fmt.Sprintf("/members/invitations/%d/revoke", invs[0].ID), url.Values{}); res.Status != http.StatusSeeOther { t.Fatalf("revoking → %d", res.Status) } res := visitor.Get("/invitations/" + token) if res.Status != http.StatusNotFound { t.Errorf("GET a revoked invitation → %d, want 404", res.Status) } bodies[res.Body] = true if len(bodies) != 1 { t.Fatalf("unusable invitations rendered %d distinct bodies, want 1: %v", len(bodies), bodies) } } // --------------------------------------------------------------- // The role selector, CSRF, and the wiring. // --------------------------------------------------------------- // TestGrantableMatchesTheDesign pins WHAT the rule is, which no // amount of derivation can do for itself. // // TestGrantableIsTheAllowList below checks that the page, the store // and Grantable agree. They agree by construction — Grantable asks // checkInviteRole, the test asks Grantable — so relaxing the rule // moves the expectation with it: changing checkInviteRole's // `rank(role) >= rank(actor.Role)` to `>`, which lets an Admin mint a // PEER ADMIN and puts the new admin beyond every other admin's reach, // left every test in this file green. That is the brief's anti-pattern // in mirror form: total derivation buys drift-resistance and loses // rule-change detection. // // So this states the matrix from // docs/superpowers/specs/2026-08-23-idear-design.md §5 — "Admins // manage Members only", "the target's rank must be strictly below the // actor's", "ownership moves only by Transfer" — and compares it in // BOTH directions, exactly as TestRoutesMatchTheDesign does for the // route table. It quotes the spec, never the implementation. func TestGrantableMatchesTheDesign(t *testing.T) { design := map[idear.Role][]idear.Role{ // An Owner may grant Admin and Member. Not Owner: ownership // moves only by Transfer. idear.RoleOwner: {idear.RoleAdmin, idear.RoleMember}, // An Admin may grant Member ONLY. An Admin who could mint a // peer Admin has escalated — MayActOn refuses acting on an // equal rank, so the new Admin is beyond the granter's reach // and beyond every other Admin's too. idear.RoleAdmin: {idear.RoleMember}, // A Member manages nothing. idear.RoleMember: nil, } for actorRole, want := range design { actor := &idear.Member{ID: 1, Role: actorRole} got := idear.Grantable(actor) inGot := map[idear.Role]bool{} for _, role := range got { inGot[role] = true } inWant := map[idear.Role]bool{} for _, role := range want { inWant[role] = true } for _, role := range want { if !inGot[role] { t.Errorf("a %s may grant %s by the design, and Grantable does not offer it", actorRole, role) } } for _, role := range got { if !inWant[role] { t.Errorf("Grantable offers %s to a %s; the design says %v", role, actorRole, want) } } if len(got) != len(want) { t.Errorf("Grantable(%s) = %v, the design says %v", actorRole, got, want) } } // A deactivated actor grants nothing, whatever rank the row still // carries: removal is never a delete, so the row outlives the // privileges. past := time.Now().UTC() for actorRole := range design { gone := &idear.Member{ID: 1, Role: actorRole, DeactivatedAt: &past} if got := idear.Grantable(gone); len(got) != 0 { t.Errorf("a deactivated %s is offered %v", actorRole, got) } } if got := idear.Grantable(nil); len(got) != 0 { t.Errorf("Grantable(nil) = %v, want nothing", got) } } // TestGrantableIsTheAllowList checks the role selector against the // store, in both directions, for every actor. // // The payloads are DERIVED: the roles it tries are the ones Grantable // offers and the ones it does not, and the expectation flips on // membership of that list rather than on a hand-written table. An // Admin offered Admin would 403 on every submit — the selector and the // store have to agree, and this is what makes them. func TestGrantableIsTheAllowList(t *testing.T) { all := []idear.Role{idear.RoleOwner, idear.RoleAdmin, idear.RoleMember} for _, actorRole := range []idear.Role{idear.RoleOwner, idear.RoleAdmin} { t.Run(string(actorRole), func(t *testing.T) { app, _ := newApp(t) owner := app.H.Owner() actor := owner if actorRole != idear.RoleOwner { actor = app.H.Member(actorRole) } c := app.As(actor) offered := map[idear.Role]bool{} for _, role := range idear.Grantable(actor) { offered[role] = true } if offered[idear.RoleOwner] { t.Error("the selector offers Owner; ownership moves only by Transfer") } // The page must show exactly what Grantable says. page := c.Get("/members") for _, role := range all { line := "grantable " + string(role) if got := strings.Contains(page.Body, line); got != offered[role] { t.Errorf("the members page %s %s, but Grantable says %v", map[bool]string{true: "offers", false: "does not offer"}[got], role, offered[role]) } } // And the store agrees with the page, both ways. for i, role := range all { email := fmt.Sprintf("candidate-%d@example.test", i) res := c.Post("/members/invitations", url.Values{"email": {email}, "role": {string(role)}}) if offered[role] { if res.Status != http.StatusSeeOther { t.Errorf("inviting at an OFFERED role %s → %d, want 303; body %q", role, res.Status, res.Body) } continue } if res.Status != http.StatusForbidden { t.Errorf("inviting at an UNOFFERED role %s → %d, want 403; body %q", role, res.Status, res.Body) } } // Nothing was minted above the actor's own rank. var invs []idear.Invitation if err := app.H.DB.G.Find(&invs).Error; err != nil { t.Fatalf("listing invitations: %v", err) } for _, inv := range invs { if !offered[inv.Role] { t.Errorf("an invitation was minted at %s, which %s may not grant", inv.Role, actorRole) } } }) } } // TestCrossOriginPostsAreRefused drives every mutating route from // another origin — the shape a CSRF attack actually takes against an // origin-checking framework — and demands that none of them run. func TestCrossOriginPostsAreRefused(t *testing.T) { app, _ := newApp(t) owner := app.H.Owner() victim := app.H.Member(idear.RoleMember) c := app.As(owner) for _, rt := range app.Handlers.Routes() { if rt.Method != http.MethodPost { continue } path := instantiate(rt.Pattern, fmt.Sprint(victim.ID), "0") res := c.PostFrom("https://evil.example", path, url.Values{ "role": {string(idear.RoleAdmin)}, "email": {"attacker@evil.example"}, "member": {fmt.Sprint(victim.ID)}, }) if res.Status != http.StatusForbidden { t.Errorf("cross-origin %s %s → %d, want 403; body %q", rt.Method, path, res.Status, res.Body) } } if got := app.H.Reload(victim.ID); got.Role != idear.RoleMember || !got.Active() { t.Errorf("a cross-origin post landed: the victim is %s/%v", got.Role, got.Active()) } invs, err := app.H.Roster.PendingInvitations(app.H.Ctx()) if err != nil { t.Fatalf("listing invitations: %v", err) } if len(invs) != 0 { t.Errorf("a cross-origin post minted %d invitations", len(invs)) } } func TestNewHandlersRequiresItsSeams(t *testing.T) { h := ideartest.New(t) renderMembers := func(http.ResponseWriter, *http.Request, idear.MembersPage) {} renderInvitation := func(http.ResponseWriter, *http.Request, idear.InvitationPage) {} for what, cfg := range map[string]idear.HandlerConfig{ "no roster": {RenderMembers: renderMembers, RenderInvitation: renderInvitation}, "no members": {Roster: h.Roster, RenderInvitation: renderInvitation}, "no invitation": {Roster: h.Roster, RenderMembers: renderMembers}, } { if _, err := idear.NewHandlers(cfg); err == nil { t.Errorf("NewHandlers with %s returned no error", what) } } if _, err := idear.NewHandlers(idear.HandlerConfig{ Roster: h.Roster, RenderMembers: renderMembers, RenderInvitation: renderInvitation, }); err != nil { t.Errorf("NewHandlers with everything set: %v", err) } } // TestInviteWithoutDeliverFlashesTheLink covers the default wiring: an // app with no Deliver hook still gets a usable link, exactly once, and // it works. func TestInviteWithoutDeliverFlashesTheLink(t *testing.T) { app := ideartest.NewApp(t) c := app.As(app.H.Owner()) res := c.Post("/members/invitations", url.Values{ "email": {"linked@example.test"}, "role": {string(idear.RoleMember)}, }) if res.Status != http.StatusSeeOther { t.Fatalf("invite → %d; body %q", res.Status, res.Body) } page := c.Follow(res) link := "" for _, f := range strings.Fields(page.Body) { if strings.HasPrefix(f, "/invitations/") { link = f } } if link == "" { t.Fatalf("the members page carries no invitation link; body %q", page.Body) } if got := c.Get("/members"); strings.Contains(got.Body, link) { t.Error("the flash notice survived a second page load; it must be one-shot") } if got := app.Visitor().Get(link); got.Status != http.StatusOK { t.Fatalf("the flashed link answers %d, so it is not usable; body %q", got.Status, got.Body) } } // TestPublicRoutesAreRateLimited proves the limiter is mounted on BOTH // public routes and on neither guarded one. func TestPublicRoutesAreRateLimited(t *testing.T) { const burst = 4 app := ideartest.NewAppWith(t, idear.Config{}, idear.HandlerConfig{ // One token an hour back, so nothing refills mid-test. RateLimit: idear.RateLimit{Burst: burst, Every: time.Hour}, }) owner := app.H.Owner() c := app.As(owner) var public []idear.Route for _, rt := range app.Handlers.Routes() { if rt.Public { public = append(public, rt) } } if len(public) != 2 { t.Fatalf("derived %d public routes, want the 2 the design lists", len(public)) } // The budget is shared across both public routes, because it is // per-client and not per-route. spent := 0 for _, rt := range public { path := instantiate(rt.Pattern, "0", "no-such-token") for range burst { var res *ideartest.Result if rt.Method == http.MethodGet { res = c.Get(path) } else { res = c.Post(path, url.Values{}) } spent++ if spent <= burst && res.Status == http.StatusTooManyRequests { t.Fatalf("request %d of a burst of %d was rate-limited", spent, burst) } if spent > burst && res.Status != http.StatusTooManyRequests { t.Fatalf("request %d → %d, want 429 once the burst is spent", spent, res.Status) } } } // The guarded routes are untouched by the limiter: a signed-in // owner is not throttled off the members page by somebody else's // guessing. if res := c.Get("/members"); res.Status != http.StatusOK { t.Errorf("the members page → %d while the public budget is spent", res.Status) } } // TestRoutesMatchTheDesign pins the route table to the DESIGN, which // is the one thing the derived tests above cannot do for themselves. // // Everything else in this file reads Route.Min and asks whether the // mounted middleware agrees with it. That catches a guard that drifts // from its declaration — but not a floor LOWERED in both places at // once, which is the change that quietly turns "admins invite" into // "anybody invites". So this test states the floors from // docs/superpowers/specs/2026-08-23-idear-design.md §5 and compares // them exhaustively, in both directions: a route the design does not // list is a failure, and a route the design lists that is not mounted // is a failure too. // // It is the one place in the suite that quotes a list, and what it // quotes is the spec rather than the implementation. func TestRoutesMatchTheDesign(t *testing.T) { type pin struct { min idear.Role public bool } design := map[string]pin{ "GET /members": {min: idear.RoleMember}, "POST /members/invitations": {min: idear.RoleAdmin}, "POST /members/invitations/{id}/revoke": {min: idear.RoleAdmin}, "POST /members/{id}/role": {min: idear.RoleAdmin}, "POST /members/{id}/remove": {min: idear.RoleAdmin}, "POST /members/{id}/restore": {min: idear.RoleAdmin}, "POST /members/transfer": {min: idear.RoleOwner}, "GET /invitations/{token}": {public: true}, "POST /invitations/{token}": {public: true}, } app := ideartest.NewApp(t) seen := map[string]bool{} for _, rt := range app.Handlers.Routes() { key := rt.Method + " " + rt.Pattern want, ok := design[key] if !ok { t.Errorf("%s is mounted but the design does not list it", key) continue } seen[key] = true if rt.Public != want.public { t.Errorf("%s: Public = %v, the design says %v", key, rt.Public, want.public) } if rt.Min != want.min { t.Errorf("%s: rank floor = %q, the design says %q", key, rt.Min, want.min) } } for key := range design { if !seen[key] { t.Errorf("%s is in the design and is not mounted", key) } } } // TestFieldsComeFromTheBodyNotTheQuery pins where a mutation's fields // are allowed to come from. // // idear reads PostForm and not Form. The difference is a real // escalation route: with Form, a link ending "?role=admin" would // supply the field for a POST whose body never mentioned one, so a // crafted link plus any submitted form on that page would grant a // rank the submitter never typed. func TestFieldsComeFromTheBodyNotTheQuery(t *testing.T) { app, _ := newApp(t) owner := app.H.Owner() target := app.H.Member(idear.RoleMember) c := app.As(owner) path := fmt.Sprintf("/members/%d/role?role=%s", target.ID, idear.RoleAdmin) // The body wins: it says member, the query says admin. if res := c.Post(path, url.Values{"role": {string(idear.RoleMember)}}); res.Status != http.StatusSeeOther { t.Fatalf("setting a role → %d; body %q", res.Status, res.Body) } if got := app.H.Reload(target.ID); got.Role != idear.RoleMember { t.Fatalf("the target is %s; the query string supplied the role", got.Role) } // And with no role in the body it is a malformed request, not one // the query string may complete. res := c.Post(path, url.Values{}) if res.Status != http.StatusBadRequest { t.Errorf("a POST with no role in its body → %d, want 400; body %q", res.Status, res.Body) } if got := app.H.Reload(target.ID); got.Role != idear.RoleMember { t.Fatalf("the target is %s; the query string completed a bodyless post", got.Role) } } // TestMultipartFormsAreRead covers the encoding an app reaches the // moment its members form grows a file input. ParseForm alone does not // populate PostForm for multipart/form-data, so every field would read // empty and every submit would 400 with nothing to explain it — a // fail-closed failure, and an undiagnosable one. func TestMultipartFormsAreRead(t *testing.T) { app, _ := newApp(t) target := app.H.Member(idear.RoleMember) c := app.As(app.H.Owner()) res := c.PostMultipart(fmt.Sprintf("/members/%d/role", target.ID), url.Values{"role": {string(idear.RoleAdmin)}}) if res.Status != http.StatusSeeOther { t.Fatalf("a multipart role change → %d, want 303; body %q", res.Status, res.Body) } if got := app.H.Reload(target.ID); got.Role != idear.RoleAdmin { t.Fatalf("the target is %s; the multipart body was not read", got.Role) } // And a multipart body with no role in it is still a 400, not a // path around the field check. if res := c.PostMultipart(fmt.Sprintf("/members/%d/role", target.ID), url.Values{}); res.Status != http.StatusBadRequest { t.Errorf("a multipart post with no role → %d, want 400", res.Status) } } // TestEveryHandlerIsMounted closes the hole under the whole suite: // Routes() is what every derived test walks, so an exported handler // added WITHOUT a Routes() entry is invisible to all of them — it // would ship with no membership gate and no test would notice. // // The count is the assertion because the names cannot be: a func value // in a Route carries no method name to compare against. Reflection // over *Handlers finds every exported method with a handler's // signature; there must be exactly as many as there are routes. func TestEveryHandlerIsMounted(t *testing.T) { app := ideartest.NewApp(t) handlerish := reflect.TypeOf(func(http.ResponseWriter, *http.Request) {}) typ := reflect.TypeOf(app.Handlers) handlers := 0 var names []string for i := range typ.NumMethod() { m := typ.Method(i) // A method's own type has the receiver first; compare what is // left against the handler signature. if m.Type.NumIn() == handlerish.NumIn()+1 && m.Type.NumOut() == 0 && m.Type.In(1) == handlerish.In(0) && m.Type.In(2) == handlerish.In(1) { handlers++ names = append(names, m.Name) } } if handlers == 0 { t.Fatal("reflection found no handler methods at all; the check is broken") } if got := len(app.Handlers.Routes()); got != handlers { 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) } }