idear: the HTTP handlers, the reconciliation route, and the authorization suite (Task 5)
The nine handlers the design lists, mounted through Routes(), which
hands out each one ALREADY wrapped in Require(RequireRole(min)(...)) —
both mis-mounts the design warns about are then unreachable, and the
Min/Public fields give the suite (and an app's route audit) a shared
list to derive from instead of a restated one.
POST /invitations/{token} is the reconciliation route: for a signed-in
viewer with no member row it claims an empty roster or spends a valid
token, always through the store, so the Subject is canonicalised on the
way in. A deactivated member is refused (readmission is Restore), and a
keymail-shaped Subject must match the invitation's address.
GET /invitations/{token} names the role and the instance and nothing
else — InvitationPage has no address field — and both public routes go
through a bounded in-memory token bucket that fails closed when its
table is full.
Mutations read each permitted field by name from PostForm, never a
struct and never the query string; ids come from the URL; success
flashes a notice and 303s back.
The suite is spec §7 in full, driving real HTTP through chi with a
cookie jar and rastrillo's real CSRF middleware. Every expectation is
derived from the mounted route table and Role.AtLeast; the one list it
quotes is the design's own, in TestRoutesMatchTheDesign, which is what
catches a rank floor lowered in both the guard and its declaration.
Fourteen mutations were run against the shipped code and the table of
which test caught which is in the task-5 report.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
7 files changed,
+2902
−1
errors.go+7 −0go.mod+1 −1handlers.go+815 −0handlers_test.go+1411 −0internal/ideartest/app.go+354 −0ratelimit.go+181 −0ratelimit_internal_test.go+133 −0
diff --git a/errors.go b/errors.go| index c016a07..eb1c3c4 100644 |
| --- a/errors.go |
| +++ b/errors.go |
| @@ -84,6 +84,13 @@ var ( |
| // while a mistyped role renders 400. |
| ErrInvalidEmail error = classErr{"idear: not an email address", ErrInvalid} |
| + // ErrInvalidID is an id that is not a positive decimal integer — |
| + // a {id} wildcard carrying "1; DROP", "-1", or nothing at all. It |
| + // is malformed input and renders 400, like its siblings above: a |
| + // handler that let it through would hand the store a zero id and |
| + // get ErrNotFound, rendering a typo as "no such member". |
| + ErrInvalidID error = classErr{"idear: not an id", ErrInvalid} |
| + |
| // ErrInvalidSubject is a blank session Subject where one is |
| // required. Subject is the join to the app's own identity, so an |
| // empty one is a caller bug — usually idear's middleware mounted |
diff --git a/go.mod b/go.mod| index 8f9b00f..ac1cc1b 100644 |
| --- a/go.mod |
| +++ b/go.mod |
| @@ -4,12 +4,12 @@ go 1.25.0 |
| require ( |
| github.com/carlosframework/rastrillo v0.18.1-0.20260823225238-7439afc0d687 |
| + github.com/go-chi/chi/v5 v5.3.2 |
| gorm.io/gorm v1.31.2 |
| ) |
| require ( |
| github.com/dustin/go-humanize v1.0.1 // indirect |
| - github.com/go-chi/chi/v5 v5.3.2 // indirect |
| github.com/google/uuid v1.6.0 // indirect |
| github.com/jinzhu/inflection v1.0.0 // indirect |
| github.com/jinzhu/now v1.1.5 // indirect |
diff --git a/handlers.go b/handlers.go| new file mode 100644 |
| index 0000000..b79e3d4 |
| --- /dev/null |
| +++ b/handlers.go |
| @@ -0,0 +1,815 @@ |
| +package idear |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "log/slog" |
| + "net/http" |
| + "strconv" |
| + "strings" |
| + |
| + "github.com/carlosframework/rastrillo/flash" |
| +) |
| + |
| +// The copy every refusal renders. Fixed strings, never the error's own |
| +// text: a store error can carry a subject, an address or a driver |
| +// message, and a page that echoed it would publish them to whoever |
| +// provoked it. The log line gets the detail; the page gets the class. |
| +const ( |
| + invalidCopy = "That request was not valid." |
| + forbiddenCopy = "You may not do that." |
| + lastOwnerCopy = "The owner cannot be removed. Transfer ownership first." |
| + noInvitationCopy = "That invitation is no longer available." |
| + failedCopy = "Something went wrong. Please try again." |
| + signInFirstCopy = "Sign in first, then open this invitation link again." |
| + rateLimitedCopy = "Too many requests. Please wait a moment and try again." |
| +) |
| + |
| +// The notices a successful mutation flashes. They are one-shot display |
| +// state (rastrillo/flash), read back by Members on the redirect that |
| +// follows. |
| +const ( |
| + invitedNotice = "Invitation created." |
| + deliveredNotice = "Invitation sent." |
| + undeliveredNotice = "The invitation was created but could not be sent. Revoke it and try again." |
| + revokedNotice = "Invitation revoked." |
| + roleNotice = "Role updated." |
| + removedNotice = "Member removed." |
| + restoredNotice = "Member restored." |
| + transferNotice = "Ownership transferred." |
| + joinedNotice = "Welcome — your membership is set up." |
| + alreadyNotice = "You are already a member here." |
| +) |
| + |
| +// flashError is the flash Kind that lands in MembersPage.Error rather |
| +// than MembersPage.Notice. Anything else is a notice. |
| +const flashError = "error" |
| + |
| +// MembersPage is what RenderMembers receives. |
| +// |
| +// Grantable is the roles the VIEWER may hand out, and a role selector |
| +// must be built from it rather than from the three constants. An Admin |
| +// may grant only Member — checkInviteRole refuses a grant that is not |
| +// STRICTLY BELOW the actor's own rank — so a form that offered Admin |
| +// to an Admin would 403 on every submit, and one that offered Owner |
| +// would 403 for everybody including the Owner. It is computed by |
| +// Grantable, which asks the same predicate the store enforces, so the |
| +// selector cannot drift from the rule. |
| +type MembersPage struct { |
| + Viewer *Member |
| + Members []Member |
| + Invitations []Invitation |
| + Grantable []Role |
| + Error string |
| + Notice string |
| +} |
| + |
| +// InvitationPage is what RenderInvitation receives, on the public |
| +// invitation routes. |
| +// |
| +// THERE IS NO ADDRESS FIELD, and that is the design rather than an |
| +// omission. GET /invitations/{token} is an unauthenticated lookup of a |
| +// secret: it names the instance and the role so the holder knows what |
| +// they are accepting, and it must not echo the invited address, which |
| +// would turn a leaked or guessed link into a disclosure. A renderer |
| +// cannot print what it is never handed. |
| +// |
| +// SignedIn says the viewer has a session; Reconcile says they have one |
| +// AND no member row — the orphan POST is for. A template shows the |
| +// "accept" button to a Reconcile viewer, and a "sign up with this |
| +// link" form to everybody else. |
| +type InvitationPage struct { |
| + Role Role |
| + Site string |
| + Token string |
| + Error string |
| + SignedIn bool |
| + Reconcile bool |
| +} |
| + |
| +// HandlerConfig wires the flows to the app's own shell. Roster and |
| +// both renderers are required; everything else has a serviceable |
| +// default. |
| +// |
| +// There is deliberately NO NotFound here, though the design sketch |
| +// shows one: the 404 renderer lives on idear.Config, because Require |
| +// is a method on the Roster and answers non-members long before a |
| +// handler runs. Two hooks would mean two 404 pages, and a 404 that |
| +// varies with which layer refused is the membership oracle the whole |
| +// design is arranged to avoid. Set Config.NotFound to the same |
| +// renderer the app gives chi's own NotFound and every refusal in idear |
| +// renders through it. |
| +type HandlerConfig struct { |
| + Roster *Roster |
| + RenderMembers func(w http.ResponseWriter, r *http.Request, d MembersPage) |
| + RenderInvitation func(w http.ResponseWriter, r *http.Request, d InvitationPage) |
| + |
| + // Site names this instance on the invitation page — "Acme's |
| + // board". Default: the request's Host, which is honest but |
| + // unlovely. |
| + Site string |
| + |
| + // MembersPath is where a successful mutation redirects. Default |
| + // "/members". An app that mounts the members page elsewhere must |
| + // say so here, or every 303 lands on a 404. |
| + MembersPath string |
| + |
| + // InvitationPath is the prefix a redeemable link is built from: |
| + // InvitationPath + token. Default "/invitations/". |
| + InvitationPath string |
| + |
| + // Deliver mails the invitation link. It is given the request (for |
| + // an origin, a locale, a logger), the invitation and the link. |
| + // |
| + // When it is nil idear puts the LINK ITSELF in the flash notice, |
| + // so a bare mount is usable: the token is shown exactly once, to |
| + // the admin who minted it, and can then be pasted into whatever |
| + // channel the team uses. That is also a credential in a cookie |
| + // for sixty seconds, so an app that can send mail should set this |
| + // and keep the token out of the browser entirely. |
| + Deliver func(r *http.Request, inv *Invitation, link string) error |
| + |
| + // RateLimit bounds the two public routes. See RateLimit — the |
| + // zero value is the documented default, and there is no way to |
| + // turn the limiter off. |
| + RateLimit RateLimit |
| + |
| + // ClientKey is the rate-limit key for a request. Default: the IP |
| + // half of RemoteAddr. Behind a reverse proxy, set this to read |
| + // the app's own TRUSTED forwarding header — see clientIP for why |
| + // idear will not guess at one. |
| + ClientKey func(r *http.Request) string |
| +} |
| + |
| +// Handlers is idear's HTTP surface: the members page, the six |
| +// management mutations, and the two public invitation routes. Build |
| +// one at boot and mount Routes. |
| +type Handlers struct { |
| + cfg HandlerConfig |
| + limit *limiter |
| +} |
| + |
| +// NewHandlers validates cfg and returns the handlers. It errors unless |
| +// the Roster and BOTH renderers are set, following jobs.NewHandlers: |
| +// a nil renderer is a nil call in a request, and a boot error is a |
| +// better place to learn about it than a production panic. |
| +func NewHandlers(cfg HandlerConfig) (*Handlers, error) { |
| + if cfg.Roster == nil { |
| + return nil, errors.New("idear: HandlerConfig.Roster is required") |
| + } |
| + if cfg.RenderMembers == nil { |
| + return nil, errors.New("idear: HandlerConfig.RenderMembers is required") |
| + } |
| + if cfg.RenderInvitation == nil { |
| + return nil, errors.New("idear: HandlerConfig.RenderInvitation is required") |
| + } |
| + if cfg.MembersPath == "" { |
| + cfg.MembersPath = "/members" |
| + } |
| + if cfg.InvitationPath == "" { |
| + cfg.InvitationPath = "/invitations/" |
| + } |
| + if cfg.ClientKey == nil { |
| + cfg.ClientKey = clientIP |
| + } |
| + return &Handlers{cfg: cfg, limit: newLimiter(cfg.RateLimit)}, nil |
| +} |
| + |
| +// Route is one mounted handler: the method and pattern idear suggests, |
| +// the handler ALREADY WRAPPED in the middleware it requires, and the |
| +// rank that wrapping enforces. |
| +// |
| +// The handler is pre-guarded on purpose. Require and RequireRole have |
| +// a stacking order that is wrong in two different ways when it is got |
| +// wrong (RequireRole mounted bare 403s a stranger, which tells them |
| +// the route exists; Require mounted outside the app's session guard |
| +// 404s everyone including the Owner), so Routes hands out the correct |
| +// composition rather than a bare handler and a comment. What an app |
| +// still owns is the paths and the session guard these are mounted |
| +// inside. |
| +// |
| +// Min is the rank floor the handler enforces, and Public says the |
| +// route is unauthenticated. They are not decoration: they let a |
| +// caller — an app's route audit, or idear's own authorization suite — |
| +// DERIVE what each route should refuse instead of restating a list |
| +// that can drift from the one being mounted. |
| +type Route struct { |
| + Method string |
| + Pattern string |
| + Handler http.Handler |
| + Min Role |
| + Public bool |
| +} |
| + |
| +// Routes is the whole HTTP surface, in the order the design lists it. |
| +// |
| +// The patterns are defaults; paths belong to the app. An app that |
| +// mounts them elsewhere must set MembersPath and InvitationPath to |
| +// match, since those are what the redirects and the invitation links |
| +// are built from. |
| +func (h *Handlers) Routes() []Route { |
| + rs := h.cfg.Roster |
| + guard := func(min Role, fn http.HandlerFunc) http.Handler { |
| + return rs.Require(rs.RequireRole(min)(fn)) |
| + } |
| + return []Route{ |
| + {http.MethodGet, "/members", guard(RoleMember, h.Members), RoleMember, false}, |
| + {http.MethodPost, "/members/invitations", guard(RoleAdmin, h.Invite), RoleAdmin, false}, |
| + {http.MethodPost, "/members/invitations/{id}/revoke", guard(RoleAdmin, h.Revoke), RoleAdmin, false}, |
| + {http.MethodPost, "/members/{id}/role", guard(RoleAdmin, h.SetRole), RoleAdmin, false}, |
| + {http.MethodPost, "/members/{id}/remove", guard(RoleAdmin, h.Remove), RoleAdmin, false}, |
| + {http.MethodPost, "/members/{id}/restore", guard(RoleAdmin, h.Restore), RoleAdmin, false}, |
| + {http.MethodPost, "/members/transfer", guard(RoleOwner, h.Transfer), RoleOwner, false}, |
| + {http.MethodGet, "/invitations/{token}", http.HandlerFunc(h.Invitation), "", true}, |
| + {http.MethodPost, "/invitations/{token}", http.HandlerFunc(h.Accept), "", true}, |
| + } |
| +} |
| + |
| +// Grantable is the roles actor may hand out, highest first — what a |
| +// role selector must be built from. |
| +// |
| +// It is derived from checkInviteRole, the same predicate Invite and |
| +// SetRole enforce inside their transactions, so the form and the store |
| +// cannot disagree. RoleOwner is never in it for anybody: ownership |
| +// moves only by Transfer. |
| +func Grantable(actor *Member) []Role { |
| + if actor == nil { |
| + return nil |
| + } |
| + var out []Role |
| + for _, role := range []Role{RoleOwner, RoleAdmin, RoleMember} { |
| + if checkInviteRole(actor, role) == nil { |
| + out = append(out, role) |
| + } |
| + } |
| + return out |
| +} |
| + |
| +// Members is GET /members: the roster, the pending invitations, and |
| +// whatever the last mutation flashed. |
| +func (h *Handlers) Members(w http.ResponseWriter, r *http.Request) { |
| + viewer, ok := h.viewer(w, r) |
| + if !ok { |
| + return |
| + } |
| + d := h.membersPage(r.Context(), viewer) |
| + if f, ok := flash.Take(w, r); ok { |
| + if f.Kind == flashError { |
| + d.Error = f.Message |
| + } else { |
| + d.Notice = f.Message |
| + } |
| + } |
| + h.cfg.RenderMembers(w, r, d) |
| +} |
| + |
| +// Invite is POST /members/invitations. |
| +// |
| +// The role IS read from the form here, and that is safe for exactly |
| +// one reason: checkInviteRole refuses RoleOwner outright, for every |
| +// actor including the Owner, and refuses anything not strictly below |
| +// the actor's own rank. A posted role can therefore only ever be worth |
| +// LESS than the poster already holds. Every other field is read by |
| +// name too — there is no struct binding anywhere in this file, so a |
| +// field nobody named cannot arrive. |
| +func (h *Handlers) Invite(w http.ResponseWriter, r *http.Request) { |
| + viewer, ok := h.viewer(w, r) |
| + if !ok { |
| + return |
| + } |
| + role, valid := ParseRole(field(r, "role")) |
| + if !valid { |
| + h.refuse(w, r, viewer, ErrInvalidRole) |
| + return |
| + } |
| + inv, token, err := h.cfg.Roster.Invite(r.Context(), viewer, field(r, "email"), role) |
| + if err != nil { |
| + h.refuse(w, r, viewer, err) |
| + return |
| + } |
| + |
| + link := h.cfg.InvitationPath + token |
| + if h.cfg.Deliver == nil { |
| + // No delivery hook: the link is the notice. See |
| + // HandlerConfig.Deliver. |
| + h.done(w, r, invitedNotice+" "+link) |
| + return |
| + } |
| + if err := h.cfg.Deliver(r, inv, link); err != nil { |
| + h.log().Error("idear: an invitation was created but could not be delivered", |
| + "invitation_id", inv.ID, "err", err) |
| + h.flash(w, r, flashError, undeliveredNotice) |
| + return |
| + } |
| + h.done(w, r, deliveredNotice) |
| +} |
| + |
| +// Revoke is POST /members/invitations/{id}/revoke. The id comes from |
| +// the URL, never from the body. |
| +func (h *Handlers) Revoke(w http.ResponseWriter, r *http.Request) { |
| + viewer, ok := h.viewer(w, r) |
| + if !ok { |
| + return |
| + } |
| + id, err := pathID(r) |
| + if err != nil { |
| + h.refuse(w, r, viewer, err) |
| + return |
| + } |
| + if err := h.cfg.Roster.Revoke(r.Context(), viewer, id); err != nil { |
| + h.refuse(w, r, viewer, err) |
| + return |
| + } |
| + h.done(w, r, revokedNotice) |
| +} |
| + |
| +// SetRole is POST /members/{id}/role — the target from the URL, the |
| +// new role from the form. See Invite for why a posted role is safe |
| +// here and cannot reach Owner. |
| +func (h *Handlers) SetRole(w http.ResponseWriter, r *http.Request) { |
| + viewer, target, ok := h.target(w, r) |
| + if !ok { |
| + return |
| + } |
| + role, valid := ParseRole(field(r, "role")) |
| + if !valid { |
| + h.refuse(w, r, viewer, ErrInvalidRole) |
| + return |
| + } |
| + if err := h.cfg.Roster.SetRole(r.Context(), viewer, target, role); err != nil { |
| + h.refuse(w, r, viewer, err) |
| + return |
| + } |
| + h.done(w, r, roleNotice) |
| +} |
| + |
| +// Remove is POST /members/{id}/remove: deactivation, never a delete. |
| +func (h *Handlers) Remove(w http.ResponseWriter, r *http.Request) { |
| + viewer, target, ok := h.target(w, r) |
| + if !ok { |
| + return |
| + } |
| + if err := h.cfg.Roster.Deactivate(r.Context(), viewer, target); err != nil { |
| + h.refuse(w, r, viewer, err) |
| + return |
| + } |
| + h.done(w, r, removedNotice) |
| +} |
| + |
| +// Restore is POST /members/{id}/restore: readmission at the role the |
| +// row still carries. It is the only way back in for someone who was |
| +// removed — see Roster.Reactivate. |
| +func (h *Handlers) Restore(w http.ResponseWriter, r *http.Request) { |
| + viewer, target, ok := h.target(w, r) |
| + if !ok { |
| + return |
| + } |
| + if err := h.cfg.Roster.Reactivate(r.Context(), viewer, target); err != nil { |
| + h.refuse(w, r, viewer, err) |
| + return |
| + } |
| + h.done(w, r, restoredNotice) |
| +} |
| + |
| +// Transfer is POST /members/transfer, the one mutation whose target id |
| +// comes from the FORM and not the URL: the path has no id in it, |
| +// because the actor is always the current Owner and the route is not |
| +// addressed by them. |
| +// |
| +// No role is read here at all. Transfer is the only path to RoleOwner, |
| +// and it decides both roles itself. |
| +func (h *Handlers) Transfer(w http.ResponseWriter, r *http.Request) { |
| + viewer, ok := h.viewer(w, r) |
| + if !ok { |
| + return |
| + } |
| + id, err := parseID(field(r, "member")) |
| + if err != nil { |
| + h.refuse(w, r, viewer, err) |
| + return |
| + } |
| + target, err := h.cfg.Roster.ByID(r.Context(), id) |
| + if err != nil { |
| + h.refuse(w, r, viewer, err) |
| + return |
| + } |
| + if err := h.cfg.Roster.Transfer(r.Context(), viewer, target); err != nil { |
| + h.refuse(w, r, viewer, err) |
| + return |
| + } |
| + h.done(w, r, transferNotice) |
| +} |
| + |
| +// Invitation is GET /invitations/{token}: PUBLIC, unauthenticated, and |
| +// a lookup of a secret. |
| +// |
| +// It renders the ROLE and the INSTANCE, and never the invited address: |
| +// InvitationPage has no field for one. Every unusable token — no such |
| +// token, spent, revoked, expired — renders the SAME copy at the same |
| +// status, because ErrNoInvitation is deliberately one error and not |
| +// four, and a page that told them apart would answer questions the |
| +// holder is not entitled to ask. |
| +// |
| +// It is rate-limited. See RateLimit. |
| +func (h *Handlers) Invitation(w http.ResponseWriter, r *http.Request) { |
| + if !h.allow(w, r) { |
| + return |
| + } |
| + token := r.PathValue("token") |
| + signedIn, reconcile := h.standing(r) |
| + |
| + inv, err := h.cfg.Roster.pendingInvitation(r.Context(), token) |
| + if err != nil { |
| + h.deadInvitation(w, r, err, signedIn) |
| + return |
| + } |
| + h.cfg.RenderInvitation(w, r, InvitationPage{ |
| + Role: inv.Role, |
| + Site: h.site(r), |
| + Token: token, |
| + SignedIn: signedIn, |
| + Reconcile: reconcile, |
| + }) |
| +} |
| + |
| +// Accept is POST /invitations/{token}: the RECONCILIATION route, and |
| +// the only path by which a member row is created from an already-live |
| +// session. |
| +// |
| +// It exists because admission cannot be one transaction. Roster. |
| +// Admitting calls the app's opaque Create and then writes the member; |
| +// a failure between them leaves a user row with no membership, and a |
| +// retry does NOT heal it — the retry's Create fails on the now |
| +// duplicate email and password.Signup renders that as 422 without ever |
| +// reaching the member write. The orphan can sign IN and 404s on every |
| +// guarded route forever. Two concurrent first signups reach the same |
| +// place with no failure at all: one wins the Claim and the |
| +// ErrOwnerExists loser is an orphan. |
| +// |
| +// What it does, in order, for a viewer who is SIGNED IN: |
| +// |
| +// 1. already a member: nothing to reconcile — a notice and a |
| +// redirect, and the token is NOT spent. |
| +// 2. a DEACTIVATED member: refused. Readmission is Restore, by an |
| +// admin; an invitation must not be a way for a removed person to |
| +// let themselves back in. |
| +// 3. the roster has ZERO ROWS: Claim, exactly as admission would |
| +// have. An unclaimed instance's first account is its Owner, and |
| +// the only way to be signed in against an empty roster is to be |
| +// the orphan whose Claim did not commit. |
| +// 4. otherwise: Accept the token — the CAS in the store, so a Revoke |
| +// racing this never admits. |
| +// |
| +// The member row is written by the STORE, from the live session |
| +// Subject, and never assembled here: Subject is canonicalised on every |
| +// write (normalizeSubject), and under keymail the raw subject is the |
| +// address as the visitor typed it. A Member literal built in this |
| +// handler would reintroduce exactly the bug that canonicalisation |
| +// exists to close. |
| +// |
| +// It needs a VALID TOKEN. A claim-race loser holds none, so they are |
| +// healed only after somebody invites them — at which point they redeem |
| +// here rather than through sign-up, which would fail on the duplicate |
| +// email. That is the design spec's corrected wording and it is the |
| +// reason this route is not a general "make me a member" button. |
| +// |
| +// It is rate-limited. See RateLimit. |
| +func (h *Handlers) Accept(w http.ResponseWriter, r *http.Request) { |
| + if !h.allow(w, r) { |
| + return |
| + } |
| + token := r.PathValue("token") |
| + rs := h.cfg.Roster |
| + ctx := r.Context() |
| + |
| + subject, ok := rs.cfg.Subject(r) |
| + subject = strings.TrimSpace(subject) |
| + if !ok || subject == "" { |
| + // Not signed in. Reconciliation writes a member for the |
| + // session in hand; there is no session in hand. The page says |
| + // so rather than pretending the token is bad — and it says it |
| + // at 403, because this is a refusal and a 200 would let a |
| + // caller read "sign in first" as "accepted". The token is not |
| + // touched, so the invitee can redeem it once they have a |
| + // session. |
| + w.WriteHeader(http.StatusForbidden) |
| + h.cfg.RenderInvitation(w, r, InvitationPage{ |
| + Site: h.site(r), |
| + Token: token, |
| + Error: signInFirstCopy, |
| + }) |
| + return |
| + } |
| + |
| + // 1 and 2: an existing row, active or not. |
| + switch m, err := rs.BySubject(ctx, subject); { |
| + case err == nil && m.Active(): |
| + h.done(w, r, alreadyNotice) |
| + return |
| + case err == nil: |
| + h.log().Info("idear: a deactivated member tried to redeem an invitation; readmission is Restore", |
| + "subject", subject, "member_id", m.ID) |
| + h.deadInvitation(w, r, ErrNoInvitation, true) |
| + return |
| + case errors.Is(err, ErrNotFound): |
| + // The orphan. Carry on. |
| + default: |
| + h.log().Error("idear: reconciliation could not resolve the viewer", "subject", subject, "err", err) |
| + h.failInvitation(w, r, token) |
| + return |
| + } |
| + |
| + // 3: the unclaimed instance. |
| + empty, err := rs.IsEmpty(ctx) |
| + if err != nil { |
| + h.log().Error("idear: reconciliation could not count the roster", "subject", subject, "err", err) |
| + h.failInvitation(w, r, token) |
| + return |
| + } |
| + if empty { |
| + switch _, err := rs.Claim(ctx, subject, addressOf(subject), ""); { |
| + case err == nil: |
| + h.log().Info("idear: reconciliation claimed the instance for a signed-in orphan", "subject", subject) |
| + h.done(w, r, joinedNotice) |
| + return |
| + case errors.Is(err, ErrOwnerExists): |
| + // Somebody claimed it between the count and here. They |
| + // may still hold an invitation of their own; fall through. |
| + default: |
| + h.log().Error("idear: reconciliation could not claim the instance", "subject", subject, "err", err) |
| + h.failInvitation(w, r, token) |
| + return |
| + } |
| + } |
| + |
| + // 4: the token. |
| + // |
| + // When the session Subject IS an address — which is what keymail |
| + // mints — the invitation must be THAT address's. idear knows the |
| + // viewer's address only in that case; under password the Subject |
| + // is an opaque user id and the token alone is the credential, |
| + // which is the same rule admission applies (possession of the |
| + // token, and an email match wherever there is an email to match). |
| + if addr := addressOf(subject); addr != "" { |
| + inv, err := rs.pendingInvitation(ctx, token) |
| + if err != nil || normalizeEmail(inv.Email) != normalizeEmail(addr) { |
| + if err == nil { |
| + h.log().Warn("idear: reconciliation presented an invitation issued to another address", |
| + "subject", subject) |
| + } |
| + h.deadInvitation(w, r, ErrNoInvitation, true) |
| + return |
| + } |
| + } |
| + |
| + m, err := rs.Accept(ctx, token, subject, "") |
| + if err != nil { |
| + if !errors.Is(err, ErrNoInvitation) { |
| + h.log().Error("idear: reconciliation could not redeem an invitation", "subject", subject, "err", err) |
| + h.failInvitation(w, r, token) |
| + return |
| + } |
| + h.deadInvitation(w, r, err, true) |
| + return |
| + } |
| + h.log().Info("idear: reconciliation healed an orphan", "subject", subject, "member_id", m.ID, "role", string(m.Role)) |
| + h.done(w, r, joinedNotice) |
| +} |
| + |
| +// viewer is the guarded handlers' first line: the member Require |
| +// resolved. |
| +// |
| +// A nil viewer here is a MOUNT BUG — the handler ran outside Require — |
| +// and it is answered with the app's 404 and a loud log line rather |
| +// than a panic or, worse, a nil actor handed to the store. Every store |
| +// mutation refuses a nil actor, so this is defence in depth; what it |
| +// buys is a log line that names the cause. |
| +func (h *Handlers) viewer(w http.ResponseWriter, r *http.Request) (*Member, bool) { |
| + m := From(r) |
| + if m == nil { |
| + h.log().Error("idear: a handler ran with no viewer; it must be mounted INSIDE Roster.Require", |
| + "path", r.URL.Path) |
| + h.cfg.Roster.cfg.NotFound(w, r) |
| + return nil, false |
| + } |
| + return m, true |
| +} |
| + |
| +// target resolves the viewer and the {id} in the path to a member row. |
| +// A miss renders the app's own 404 — the same page a non-member gets, |
| +// through the same hook. |
| +func (h *Handlers) target(w http.ResponseWriter, r *http.Request) (*Member, *Member, bool) { |
| + viewer, ok := h.viewer(w, r) |
| + if !ok { |
| + return nil, nil, false |
| + } |
| + id, err := pathID(r) |
| + if err != nil { |
| + h.refuse(w, r, viewer, err) |
| + return nil, nil, false |
| + } |
| + target, err := h.cfg.Roster.ByID(r.Context(), id) |
| + if err != nil { |
| + h.refuse(w, r, viewer, err) |
| + return nil, nil, false |
| + } |
| + return viewer, target, true |
| +} |
| + |
| +// done is the success path: flash a notice, then 303 back to the |
| +// members page so a refresh cannot repost the mutation. |
| +func (h *Handlers) done(w http.ResponseWriter, r *http.Request, notice string) { |
| + h.flash(w, r, "notice", notice) |
| +} |
| + |
| +func (h *Handlers) flash(w http.ResponseWriter, r *http.Request, kind, msg string) { |
| + flash.Set(w, kind, msg) |
| + http.Redirect(w, r, h.cfg.MembersPath, http.StatusSeeOther) |
| +} |
| + |
| +// refuse renders a store refusal at the status its CLASS earns. |
| +// |
| +// The classes are the whole point of errors.go: ErrInvalid is 400, |
| +// ErrForbidden is 403 — and ErrLastOwner unwraps to ErrForbidden, so |
| +// this package's most security-relevant refusal renders 403 and not |
| +// 500 — ErrNotFound is the app's own 404 page, and anything left is a |
| +// 500 with no detail on it. Matching the CLASS with errors.Is rather |
| +// than the sentinel is what keeps a new sentinel from silently |
| +// becoming a server error. |
| +func (h *Handlers) refuse(w http.ResponseWriter, r *http.Request, viewer *Member, err error) { |
| + switch { |
| + case errors.Is(err, ErrNotFound): |
| + // A member id that resolves to nothing is answered exactly as |
| + // a non-member is: same hook, same bytes. |
| + h.cfg.Roster.cfg.NotFound(w, r) |
| + case errors.Is(err, ErrNoInvitation): |
| + h.page(w, r, viewer, http.StatusNotFound, noInvitationCopy) |
| + case errors.Is(err, ErrLastOwner): |
| + // Checked before the ErrForbidden arm it unwraps to, because |
| + // the reason is true for every actor at every rank and the |
| + // generic copy would imply somebody senior could do it. |
| + h.page(w, r, viewer, http.StatusForbidden, lastOwnerCopy) |
| + case errors.Is(err, ErrInvalid): |
| + h.page(w, r, viewer, http.StatusBadRequest, invalidCopy) |
| + case errors.Is(err, ErrForbidden): |
| + h.page(w, r, viewer, http.StatusForbidden, forbiddenCopy) |
| + default: |
| + h.log().Error("idear: a members mutation failed", "path", r.URL.Path, "err", err) |
| + h.page(w, r, viewer, http.StatusInternalServerError, failedCopy) |
| + } |
| +} |
| + |
| +// page renders the members page carrying an error, at status. |
| +// |
| +// The status is written BEFORE the renderer runs, so an app's renderer |
| +// must not write its own — the first WriteHeader wins and a second one |
| +// is a logged no-op. That is the cost of keeping Status off MembersPage; |
| +// what it buys is that a renderer cannot accidentally answer 200 to a |
| +// refusal. |
| +func (h *Handlers) page(w http.ResponseWriter, r *http.Request, viewer *Member, status int, msg string) { |
| + d := h.membersPage(r.Context(), viewer) |
| + d.Error = msg |
| + w.WriteHeader(status) |
| + h.cfg.RenderMembers(w, r, d) |
| +} |
| + |
| +// membersPage gathers what the members page shows. A listing failure |
| +// is logged and rendered as an EMPTY list rather than as a 500: this |
| +// is also the failure path's own renderer, and a refusal that turned |
| +// into a server error because the list behind it could not be read |
| +// would report the wrong problem. |
| +func (h *Handlers) membersPage(ctx context.Context, viewer *Member) MembersPage { |
| + d := MembersPage{Viewer: viewer, Grantable: Grantable(viewer)} |
| + members, err := h.cfg.Roster.Members(ctx) |
| + if err != nil { |
| + h.log().Error("idear: listing the roster failed", "err", err) |
| + } |
| + d.Members = members |
| + invitations, err := h.cfg.Roster.PendingInvitations(ctx) |
| + if err != nil { |
| + h.log().Error("idear: listing pending invitations failed", "err", err) |
| + } |
| + d.Invitations = invitations |
| + return d |
| +} |
| + |
| +// deadInvitation renders the one answer every unusable invitation |
| +// gets. A storage failure is NOT routed here — it renders failedCopy |
| +// instead, because telling someone their live invitation is dead |
| +// because the database hiccuped sends them to an admin for a new one |
| +// that will fail the same way. |
| +func (h *Handlers) deadInvitation(w http.ResponseWriter, r *http.Request, err error, signedIn bool) { |
| + if !errors.Is(err, ErrNoInvitation) { |
| + h.log().Error("idear: looking up an invitation failed", "err", err) |
| + w.WriteHeader(http.StatusInternalServerError) |
| + h.cfg.RenderInvitation(w, r, InvitationPage{Site: h.site(r), Error: failedCopy, SignedIn: signedIn}) |
| + return |
| + } |
| + w.WriteHeader(http.StatusNotFound) |
| + h.cfg.RenderInvitation(w, r, InvitationPage{Site: h.site(r), Error: noInvitationCopy, SignedIn: signedIn}) |
| +} |
| + |
| +// failInvitation is the invitation routes' 500: a storage failure, with |
| +// nothing about it on the page. |
| +func (h *Handlers) failInvitation(w http.ResponseWriter, r *http.Request, token string) { |
| + w.WriteHeader(http.StatusInternalServerError) |
| + h.cfg.RenderInvitation(w, r, InvitationPage{ |
| + Site: h.site(r), |
| + Token: token, |
| + Error: failedCopy, |
| + SignedIn: true, |
| + }) |
| +} |
| + |
| +// standing answers the two questions the invitation page asks about |
| +// the viewer: are they signed in, and are they the orphan this route |
| +// is for. A deactivated member is NOT a reconcile candidate — they |
| +// have a row, and Accept refuses them. |
| +func (h *Handlers) standing(r *http.Request) (signedIn, reconcile bool) { |
| + rs := h.cfg.Roster |
| + subject, ok := rs.cfg.Subject(r) |
| + if !ok || strings.TrimSpace(subject) == "" { |
| + return false, false |
| + } |
| + switch _, err := rs.BySubject(r.Context(), subject); { |
| + case err == nil: |
| + return true, false |
| + case errors.Is(err, ErrNotFound): |
| + return true, true |
| + default: |
| + h.log().Error("idear: resolving the invitation viewer failed", "err", err) |
| + return true, false |
| + } |
| +} |
| + |
| +// allow spends one rate-limit token, answering 429 itself when there |
| +// is none. Plain text: this is the response an abusive client gets, |
| +// and rendering the app's own page for it would put a template render |
| +// on the cheapest path an attacker has. |
| +func (h *Handlers) allow(w http.ResponseWriter, r *http.Request) bool { |
| + if h.limit.allow(h.cfg.ClientKey(r)) { |
| + return true |
| + } |
| + h.log().Warn("idear: rate-limited a public invitation request", "path", r.URL.Path) |
| + w.Header().Set("Retry-After", strconv.Itoa(int(h.limit.every.Seconds()))) |
| + http.Error(w, rateLimitedCopy, http.StatusTooManyRequests) |
| + return false |
| +} |
| + |
| +// site names the instance on the invitation page. |
| +func (h *Handlers) site(r *http.Request) string { |
| + if h.cfg.Site != "" { |
| + return h.cfg.Site |
| + } |
| + return r.Host |
| +} |
| + |
| +// log is the Roster's logger. The handlers deliberately share it |
| +// rather than taking one of their own: the distinctions idear refuses |
| +// to render — which member was refused, whether a 404 was a stranger |
| +// or a deactivated member — are only ever visible in the log, and they |
| +// must all land in the same place. |
| +func (h *Handlers) log() *slog.Logger { return h.cfg.Roster.cfg.Logger } |
| + |
| +// field reads ONE named field from the POSTED BODY. |
| +// |
| +// By name, one at a time, never by binding a struct: a struct binding |
| +// accepts every field the struct has, and the fields idear's structs |
| +// have include Role and DeactivatedAt. It reads PostForm and not Form |
| +// as well, so a value in the QUERY STRING cannot stand in for a body |
| +// field — otherwise a link could carry ?role=owner into a POST whose |
| +// body never mentioned one. |
| +func field(r *http.Request, name string) string { |
| + if r.PostForm == nil { |
| + // An unparseable body leaves an empty form behind, which every |
| + // caller here treats as a missing field: 400, not a default. |
| + _ = r.ParseForm() |
| + } |
| + return r.PostForm.Get(name) |
| +} |
| + |
| +// pathID is the {id} wildcard, as an int64. Ids come from the URL on |
| +// every route that has one. |
| +func pathID(r *http.Request) (int64, error) { |
| + return parseID(r.PathValue("id")) |
| +} |
| + |
| +func parseID(s string) (int64, error) { |
| + id, err := strconv.ParseInt(strings.TrimSpace(s), 10, 64) |
| + if err != nil || id <= 0 { |
| + return 0, ErrInvalidID |
| + } |
| + return id, nil |
| +} |
| + |
| +// addressOf returns subject when it is an ADDRESS — which is what |
| +// keymail mints as the session Subject — and "" when it is anything |
| +// else, such as password's decimal user id. It is the one place idear |
| +// decides whether it knows the viewer's address from the session |
| +// alone. |
| +func addressOf(subject string) string { |
| + if strings.Contains(subject, "@") { |
| + return subject |
| + } |
| + return "" |
| +} |
diff --git a/handlers_test.go b/handlers_test.go| new file mode 100644 |
| index 0000000..69f7ce2 |
| --- /dev/null |
| +++ b/handlers_test.go |
| @@ -0,0 +1,1411 @@ |
| +package idear_test |
| + |
| +import ( |
| + "fmt" |
| + "net/http" |
| + "net/url" |
| + "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 |
| +} |
| + |
| +// --------------------------------------------------------------- |
| +// §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, |
| + "themselves, demotion": actor, |
| + "the owner, restoration": owner, |
| + } |
| + // 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()) |
| + } |
| + |
| + // 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) |
| + |
| + 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") |
| +} |
| + |
| +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") |
| + } |
| +} |
| + |
| +// --------------------------------------------------------------- |
| +// §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. |
| +// --------------------------------------------------------------- |
| + |
| +// 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) |
| + } |
| +} |
diff --git a/internal/ideartest/app.go b/internal/ideartest/app.go| new file mode 100644 |
| index 0000000..3242f0c |
| --- /dev/null |
| +++ b/internal/ideartest/app.go |
| @@ -0,0 +1,354 @@ |
| +package ideartest |
| + |
| +import ( |
| + "fmt" |
| + "io" |
| + "net/http" |
| + "net/http/cookiejar" |
| + "net/http/httptest" |
| + "net/url" |
| + "reflect" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/rastrillo/csrf" |
| + "github.com/carlosframework/rastrillo/sessions" |
| + "github.com/go-chi/chi/v5" |
| + |
| + "amadan.net/rastrillo/idear" |
| +) |
| + |
| +// App is idear mounted the way an app mounts it: a real chi router |
| +// over a real HTTP server, with rastrillo's real session store and its |
| +// real CSRF middleware, driven by a real http.Client with a cookie |
| +// jar. |
| +// |
| +// None of that is ceremony. The authorization rules this module exists |
| +// for are enforced by a STACK — chi's routing, the app's session |
| +// guard, idear's Require, idear's RequireRole, then the handler, then |
| +// the store's own transaction — and a test that calls a handler |
| +// function directly proves only the last layer of it. The refusals |
| +// that matter (a non-member's byte-identical 404, a member's 403, a |
| +// posted role that must never land) are all decided somewhere in the |
| +// middle. |
| +// |
| +// AppNotFound and AppForbidden are the app's OWN pages, deliberately |
| +// unlike http.NotFound's and http.Error's defaults: idear's 404 must |
| +// be byte-identical to the app's, and a harness that left the stdlib |
| +// defaults in place could not tell whether the hook was consulted at |
| +// all. chi's own NotFound is given the same renderer, which is the |
| +// mounting contract the design states. |
| +const ( |
| + AppNotFound = "the app's own 404 page" |
| + AppForbidden = "the app's own 403 page" |
| +) |
| + |
| +// SignInPath is the test app's stand-in for whatever identity plugin |
| +// the real app chose: GET /test/signin?subject=X mints a real session |
| +// row for X and sets the real cookie. |
| +// |
| +// It is a GET so that signing in never needs the CSRF dance, and it is |
| +// the ONLY route in this harness that idear does not own. Everything a |
| +// test does after it goes through idear's own mounted routes. |
| +const SignInPath = "/test/signin" |
| + |
| +// App is one instance, served. |
| +type App struct { |
| + T *testing.T |
| + H *Harness |
| + Handlers *idear.Handlers |
| + Sessions *sessions.Sessions |
| + Server *httptest.Server |
| + Origin string |
| + Router *chi.Mux |
| +} |
| + |
| +// NewApp mounts idear over a fresh instance with the default config. |
| +func NewApp(t *testing.T) *App { |
| + t.Helper() |
| + return NewAppWith(t, idear.Config{}, idear.HandlerConfig{}) |
| +} |
| + |
| +// NewAppWith is NewApp with the caller's Config and HandlerConfig. |
| +// |
| +// Anything the caller leaves unset gets the harness's own: the app's |
| +// 404 and 403 pages, the two recording renderers below, and a rate |
| +// limit wide enough that an ordinary test does not trip it (the test |
| +// that PROVES the limiter sets its own narrow one). DB, Roster and |
| +// ClientKey are always the harness's — the point of the harness is |
| +// that those are the harness's. |
| +func NewAppWith(t *testing.T, cfg idear.Config, hcfg idear.HandlerConfig) *App { |
| + t.Helper() |
| + |
| + if cfg.NotFound == nil { |
| + cfg.NotFound = func(w http.ResponseWriter, r *http.Request) { |
| + w.WriteHeader(http.StatusNotFound) |
| + io.WriteString(w, AppNotFound) |
| + } |
| + } |
| + if cfg.Forbidden == nil { |
| + cfg.Forbidden = func(w http.ResponseWriter, r *http.Request) { |
| + w.WriteHeader(http.StatusForbidden) |
| + io.WriteString(w, AppForbidden) |
| + } |
| + } |
| + h := NewWith(t, cfg) |
| + |
| + hcfg.Roster = h.Roster |
| + if hcfg.RenderMembers == nil { |
| + hcfg.RenderMembers = RenderMembers |
| + } |
| + if hcfg.RenderInvitation == nil { |
| + hcfg.RenderInvitation = RenderInvitation |
| + } |
| + if hcfg.RateLimit == (idear.RateLimit{}) { |
| + // Wide enough that the race tests below, which post hundreds |
| + // of public requests from one loopback address, are limited by |
| + // the store and not by the bucket. TestRateLimit sets its own. |
| + hcfg.RateLimit = idear.RateLimit{Burst: 100000, Every: time.Millisecond} |
| + } |
| + hs, err := idear.NewHandlers(hcfg) |
| + if err != nil { |
| + t.Fatalf("idear.NewHandlers: %v", err) |
| + } |
| + |
| + // The listener exists before Start, so the origin is knowable |
| + // before the handler that has to be configured with it. |
| + srv := httptest.NewUnstartedServer(nil) |
| + origin := "http://" + srv.Listener.Addr().String() |
| + |
| + sess, err := sessions.New(sessions.Config{DB: h.DB.Writer(), Origin: origin}) |
| + if err != nil { |
| + t.Fatalf("sessions.New: %v", err) |
| + } |
| + |
| + r := chi.NewRouter() |
| + // The SAME renderer idear's Config.NotFound got. This is the |
| + // mounting contract: two different 404 pages are a membership |
| + // oracle, and this line is what makes "byte-identical" testable. |
| + r.NotFound(cfg.NotFound) |
| + r.Use(csrf.Protect(origin)) |
| + // Middleware, not Require: a signed-out request must reach idear's |
| + // own Require and be answered 404 like any other non-member, |
| + // rather than being redirected to a sign-in page by the layer |
| + // above. A real app is free to stack sessions.Require outside |
| + // this; the refusals under test are the same either way. |
| + r.Use(sess.Middleware) |
| + r.Get(SignInPath, func(w http.ResponseWriter, r *http.Request) { |
| + subject := r.URL.Query().Get("subject") |
| + if err := sess.SignIn(w, r, sessions.Session{ |
| + Subject: subject, |
| + Method: "test", |
| + AuthTime: time.Now(), |
| + }); err != nil { |
| + http.Error(w, err.Error(), http.StatusInternalServerError) |
| + return |
| + } |
| + io.WriteString(w, "signed in as "+subject) |
| + }) |
| + for _, rt := range hs.Routes() { |
| + r.Method(rt.Method, rt.Pattern, rt.Handler) |
| + } |
| + |
| + srv.Config.Handler = r |
| + srv.Start() |
| + t.Cleanup(srv.Close) |
| + |
| + return &App{T: t, H: h, Handlers: hs, Sessions: sess, Server: srv, Origin: origin, Router: r} |
| +} |
| + |
| +// RenderMembers writes the members page as deterministic lines, so a |
| +// test can assert on what the handler ACTUALLY passed rather than on |
| +// what a template chose to show. |
| +func RenderMembers(w http.ResponseWriter, r *http.Request, d idear.MembersPage) { |
| + var b strings.Builder |
| + b.WriteString("=== members ===\n") |
| + if d.Viewer != nil { |
| + fmt.Fprintf(&b, "viewer %d %s %s %s\n", d.Viewer.ID, d.Viewer.Role, state(d.Viewer), d.Viewer.Email) |
| + } |
| + for _, role := range d.Grantable { |
| + fmt.Fprintf(&b, "grantable %s\n", role) |
| + } |
| + for _, m := range d.Members { |
| + fmt.Fprintf(&b, "member %d %s %s %s %s\n", m.ID, m.Role, state(&m), m.Subject, m.Email) |
| + } |
| + for _, inv := range d.Invitations { |
| + fmt.Fprintf(&b, "invitation %d %s %s\n", inv.ID, inv.Role, inv.Email) |
| + } |
| + if d.Error != "" { |
| + fmt.Fprintf(&b, "error %s\n", d.Error) |
| + } |
| + if d.Notice != "" { |
| + fmt.Fprintf(&b, "notice %s\n", d.Notice) |
| + } |
| + io.WriteString(w, b.String()) |
| +} |
| + |
| +func state(m *idear.Member) string { |
| + if m.Active() { |
| + return "active" |
| + } |
| + return "deactivated" |
| +} |
| + |
| +// RenderInvitation writes EVERY FIELD of the InvitationPage it is |
| +// given, by reflection. |
| +// |
| +// Reflection, and not a hand-written line per field, is the whole |
| +// point. The public GET must not disclose the invited address, and it |
| +// does not because InvitationPage has no field for one — but a test |
| +// that searched a hand-written template's output for that address |
| +// would pass just as well against a template that simply forgot to |
| +// print a field it was handed. This renderer prints whatever it is |
| +// given, so the day somebody adds an Email to InvitationPage and fills |
| +// it in, the disclosure test goes red instead of staying green over a |
| +// new leak. |
| +func RenderInvitation(w http.ResponseWriter, r *http.Request, d idear.InvitationPage) { |
| + var b strings.Builder |
| + b.WriteString("=== invitation ===\n") |
| + v := reflect.ValueOf(d) |
| + t := v.Type() |
| + for i := range t.NumField() { |
| + fmt.Fprintf(&b, "%s %v\n", strings.ToLower(t.Field(i).Name), v.Field(i).Interface()) |
| + } |
| + io.WriteString(w, b.String()) |
| +} |
| + |
| +// Client is one browser: a cookie jar, and whatever session it signed |
| +// in with. |
| +type Client struct { |
| + App *App |
| + Subject string |
| + HTTP *http.Client |
| +} |
| + |
| +// Visitor is a signed-out browser. |
| +func (a *App) Visitor() *Client { |
| + a.T.Helper() |
| + jar, err := cookiejar.New(nil) |
| + if err != nil { |
| + a.T.Fatalf("cookiejar.New: %v", err) |
| + } |
| + return &Client{App: a, HTTP: &http.Client{ |
| + Jar: jar, |
| + // Redirects are NOT followed: a 303 to the members page is |
| + // the assertion in every successful mutation, and a client |
| + // that chased it would report the members page's 200 instead. |
| + CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, |
| + }} |
| +} |
| + |
| +// SignIn is a browser holding a real session for subject — the app's |
| +// identity plugin, stood in for. |
| +func (a *App) SignIn(subject string) *Client { |
| + a.T.Helper() |
| + c := a.Visitor() |
| + res := c.Get(SignInPath + "?subject=" + url.QueryEscape(subject)) |
| + if res.Status != http.StatusOK { |
| + a.T.Fatalf("signing in as %q: status %d, body %q", subject, res.Status, res.Body) |
| + } |
| + c.Subject = subject |
| + return c |
| +} |
| + |
| +// As is SignIn for a seeded member. |
| +func (a *App) As(m *idear.Member) *Client { |
| + a.T.Helper() |
| + return a.SignIn(m.Subject) |
| +} |
| + |
| +// Result is one response, read to the end. |
| +type Result struct { |
| + Status int |
| + Body string |
| + Location string |
| + Header http.Header |
| +} |
| + |
| +// Get issues a GET and fails the test if the transport does. |
| +func (c *Client) Get(path string) *Result { |
| + c.App.T.Helper() |
| + res, err := c.TryGet(path) |
| + if err != nil { |
| + c.App.T.Fatalf("GET %s: %v", path, err) |
| + } |
| + return res |
| +} |
| + |
| +// Post issues a same-origin form POST — the Origin header a browser |
| +// sends, which is what rastrillo's CSRF middleware checks — and fails |
| +// the test if the transport does. |
| +func (c *Client) Post(path string, form url.Values) *Result { |
| + c.App.T.Helper() |
| + res, err := c.TryPost(path, form) |
| + if err != nil { |
| + c.App.T.Fatalf("POST %s: %v", path, err) |
| + } |
| + return res |
| +} |
| + |
| +// TryGet and TryPost are the GOROUTINE-SAFE halves: they return the |
| +// transport error instead of calling t.Fatalf, which is legal only on |
| +// the test goroutine. Every race test below drives these. |
| +func (c *Client) TryGet(path string) (*Result, error) { |
| + return c.do(http.MethodGet, path, nil, c.App.Origin) |
| +} |
| + |
| +func (c *Client) TryPost(path string, form url.Values) (*Result, error) { |
| + return c.do(http.MethodPost, path, form, c.App.Origin) |
| +} |
| + |
| +// PostFrom is TryPost with a chosen Origin header: the cross-origin |
| +// form submission a CSRF attack actually looks like. |
| +func (c *Client) PostFrom(origin, path string, form url.Values) *Result { |
| + c.App.T.Helper() |
| + res, err := c.do(http.MethodPost, path, form, origin) |
| + if err != nil { |
| + c.App.T.Fatalf("POST %s from %s: %v", path, origin, err) |
| + } |
| + return res |
| +} |
| + |
| +func (c *Client) do(method, path string, form url.Values, origin string) (*Result, error) { |
| + var body io.Reader |
| + if form != nil { |
| + body = strings.NewReader(form.Encode()) |
| + } |
| + req, err := http.NewRequest(method, c.App.Origin+path, body) |
| + if err != nil { |
| + return nil, err |
| + } |
| + if form != nil { |
| + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| + // The evidence a browser sends on a form POST. csrf.SameOrigin |
| + // prefers Sec-Fetch-Site and falls back to this; sending only |
| + // Origin exercises the header an attacker's page cannot forge. |
| + req.Header.Set("Origin", origin) |
| + } |
| + res, err := c.HTTP.Do(req) |
| + if err != nil { |
| + return nil, err |
| + } |
| + defer res.Body.Close() |
| + b, err := io.ReadAll(res.Body) |
| + if err != nil { |
| + return nil, err |
| + } |
| + return &Result{ |
| + Status: res.StatusCode, |
| + Body: string(b), |
| + Location: res.Header.Get("Location"), |
| + Header: res.Header, |
| + }, nil |
| +} |
| + |
| +// Follow chases a 303 the way a browser would — the flash notice is |
| +// only readable on the page the redirect lands on. |
| +func (c *Client) Follow(res *Result) *Result { |
| + c.App.T.Helper() |
| + if res.Location == "" { |
| + c.App.T.Fatalf("nothing to follow: status %d, body %q", res.Status, res.Body) |
| + } |
| + return c.Get(res.Location) |
| +} |
diff --git a/ratelimit.go b/ratelimit.go| new file mode 100644 |
| index 0000000..024038f |
| --- /dev/null |
| +++ b/ratelimit.go |
| @@ -0,0 +1,181 @@ |
| +package idear |
| + |
| +import ( |
| + "net" |
| + "net/http" |
| + "sync" |
| + "time" |
| +) |
| + |
| +// RateLimit bounds how often ONE client may hit idear's two public |
| +// routes. Both of them read a secret: GET /invitations/{token} answers |
| +// questions about an invitation to anyone holding the token, and POST |
| +// /invitations/{token} spends one. An unauthenticated lookup of a |
| +// secret that answers as fast as the network allows is a free oracle, |
| +// so the limiter is not optional and there is no way to switch it off |
| +// — only to widen it. |
| +// |
| +// The defaults are a token bucket of Burst requests that refills one |
| +// token every Every: twenty at once, then one every three seconds |
| +// (twenty a minute sustained). A person opening their invitation link |
| +// and posting it never notices; a script walking the 2^256 token space |
| +// gets twenty guesses a minute out of one address, which is not a |
| +// meaningful improvement on none. |
| +type RateLimit struct { |
| + // Burst is how many requests a client may make back to back. |
| + // Default 20. |
| + Burst int |
| + |
| + // Every is how long one token takes to come back. Default 3s. |
| + Every time.Duration |
| + |
| + // Max is the ceiling on how many clients are tracked at once — |
| + // the memory bound. Default 4096. |
| + // |
| + // When the table is full, idear first drops every bucket that has |
| + // refilled to Burst (such a bucket is indistinguishable from a |
| + // client that has never been seen, so dropping it costs nothing |
| + // and forgives nobody). If it is STILL full, the request is |
| + // REFUSED rather than admitted: on a route whose whole job is to |
| + // slow down guessing, an overflowing table is evidence of the |
| + // attack the limiter exists for, and failing open there would |
| + // make Max the way around the limit. The cost is that a wide |
| + // enough spray can lock out real invitees for as long as it lasts |
| + // — stated so it is a decision and not a surprise. |
| + Max int |
| +} |
| + |
| +const ( |
| + defaultRateBurst = 20 |
| + defaultRateEvery = 3 * time.Second |
| + defaultRateMax = 4096 |
| +) |
| + |
| +// limiter is the bounded in-memory token bucket behind RateLimit. One |
| +// per *Handlers, so it is per-process and NOT shared between replicas: |
| +// two instances behind a load balancer give a client two budgets. That |
| +// is a real weakening and it is accepted, because the alternative is a |
| +// database write on every unauthenticated request — which is the |
| +// resource the limiter is trying to protect. |
| +type limiter struct { |
| + burst float64 |
| + every time.Duration |
| + max int |
| + |
| + // now is the clock, injectable so a test can prove the refill |
| + // without sleeping through it. |
| + now func() time.Time |
| + |
| + mu sync.Mutex |
| + buckets map[string]*bucket |
| +} |
| + |
| +type bucket struct { |
| + tokens float64 |
| + seen time.Time |
| +} |
| + |
| +func newLimiter(rl RateLimit) *limiter { |
| + if rl.Burst <= 0 { |
| + rl.Burst = defaultRateBurst |
| + } |
| + if rl.Every <= 0 { |
| + rl.Every = defaultRateEvery |
| + } |
| + if rl.Max <= 0 { |
| + rl.Max = defaultRateMax |
| + } |
| + return &limiter{ |
| + burst: float64(rl.Burst), |
| + every: rl.Every, |
| + max: rl.Max, |
| + now: func() time.Time { return time.Now() }, |
| + buckets: make(map[string]*bucket), |
| + } |
| +} |
| + |
| +// allow spends one token for key, reporting whether the request may |
| +// proceed. It is safe for concurrent use: the two public routes are |
| +// exactly where concurrent unauthenticated traffic arrives. |
| +func (l *limiter) allow(key string) bool { |
| + l.mu.Lock() |
| + defer l.mu.Unlock() |
| + |
| + now := l.now() |
| + b, ok := l.buckets[key] |
| + if !ok { |
| + if len(l.buckets) >= l.max { |
| + l.sweep(now) |
| + } |
| + if len(l.buckets) >= l.max { |
| + // Fail closed. See RateLimit.Max. |
| + return false |
| + } |
| + b = &bucket{tokens: l.burst, seen: now} |
| + l.buckets[key] = b |
| + } else { |
| + l.refill(b, now) |
| + } |
| + if b.tokens < 1 { |
| + return false |
| + } |
| + b.tokens-- |
| + return true |
| +} |
| + |
| +// refill credits a bucket for the time since it was last touched, |
| +// capped at burst. |
| +func (l *limiter) refill(b *bucket, now time.Time) { |
| + if elapsed := now.Sub(b.seen); elapsed > 0 { |
| + b.tokens += float64(elapsed) / float64(l.every) |
| + if b.tokens > l.burst { |
| + b.tokens = l.burst |
| + } |
| + } |
| + b.seen = now |
| +} |
| + |
| +// sweep drops every bucket that has refilled to full. A full bucket |
| +// carries no state a fresh one would not have, so this frees memory |
| +// without forgiving anybody: a client mid-penalty is never swept. |
| +// |
| +// Called only when the table is at Max, which is why there is no |
| +// background goroutine here — a limiter with no traffic needs no |
| +// sweeper, and a *Handlers must not leak one for the life of the |
| +// process. |
| +func (l *limiter) sweep(now time.Time) { |
| + for k, b := range l.buckets { |
| + l.refill(b, now) |
| + if b.tokens >= l.burst { |
| + delete(l.buckets, k) |
| + } |
| + } |
| +} |
| + |
| +// size is the number of tracked clients — for the test that proves the |
| +// table is actually bounded. |
| +func (l *limiter) size() int { |
| + l.mu.Lock() |
| + defer l.mu.Unlock() |
| + return len(l.buckets) |
| +} |
| + |
| +// clientIP is the default rate-limit key: the IP half of RemoteAddr, |
| +// so a client behind one address shares one budget across ports. |
| +// |
| +// It is "per-IP-ish" and not per-person on purpose — there is nobody |
| +// to identify on an unauthenticated route. Behind a reverse proxy |
| +// EVERY request arrives from the proxy's address and they would share |
| +// a single bucket, which turns the limiter into a global one and locks |
| +// out real invitees; an app in that shape must set |
| +// HandlerConfig.ClientKey to read its own trusted forwarding header. |
| +// idear does not read X-Forwarded-For itself, because a header idear |
| +// cannot verify is a header an attacker can spoof to mint unlimited |
| +// budgets. |
| +func clientIP(r *http.Request) string { |
| + host, _, err := net.SplitHostPort(r.RemoteAddr) |
| + if err != nil { |
| + return r.RemoteAddr |
| + } |
| + return host |
| +} |
diff --git a/ratelimit_internal_test.go b/ratelimit_internal_test.go| new file mode 100644 |
| index 0000000..71c4872 |
| --- /dev/null |
| +++ b/ratelimit_internal_test.go |
| @@ -0,0 +1,133 @@ |
| +package idear |
| + |
| +import ( |
| + "fmt" |
| + "net/http/httptest" |
| + "sync" |
| + "testing" |
| + "time" |
| +) |
| + |
| +// The limiter's own tests live inside the package because the thing |
| +// worth proving about it — that the table is BOUNDED — is not visible |
| +// from outside: a caller can only observe 429s, and a limiter that |
| +// leaked a bucket per client would answer exactly the same 429s while |
| +// growing until the process died. |
| + |
| +func TestLimiterSpendsAndRefills(t *testing.T) { |
| + now := time.Now() |
| + l := newLimiter(RateLimit{Burst: 3, Every: time.Second}) |
| + l.now = func() time.Time { return now } |
| + |
| + for i := range 3 { |
| + if !l.allow("a") { |
| + t.Fatalf("request %d of a burst of 3 was refused", i+1) |
| + } |
| + } |
| + if l.allow("a") { |
| + t.Fatal("the fourth request of a burst of 3 was allowed") |
| + } |
| + // Another client has their own budget: the limit is per client and |
| + // not global, or one prober would lock out every invitee. |
| + if !l.allow("b") { |
| + t.Fatal("a second client was refused on their first request") |
| + } |
| + |
| + // One token comes back per Every, and no more than Burst ever |
| + // accumulates. |
| + now = now.Add(time.Second) |
| + if !l.allow("a") { |
| + t.Fatal("no token came back after one refill interval") |
| + } |
| + if l.allow("a") { |
| + t.Fatal("more than one token came back in one interval") |
| + } |
| + now = now.Add(time.Hour) |
| + for i := range 3 { |
| + if !l.allow("a") { |
| + t.Fatalf("request %d after a long idle was refused", i+1) |
| + } |
| + } |
| + if l.allow("a") { |
| + t.Fatal("an idle client accumulated more than Burst tokens") |
| + } |
| +} |
| + |
| +func TestLimiterTableIsBounded(t *testing.T) { |
| + now := time.Now() |
| + const max = 8 |
| + l := newLimiter(RateLimit{Burst: 2, Every: time.Second, Max: max}) |
| + l.now = func() time.Time { return now } |
| + |
| + // Far more clients than the table may hold, each spending their |
| + // whole burst so none of them is sweepable. |
| + for i := range max * 10 { |
| + key := fmt.Sprintf("client-%d", i) |
| + l.allow(key) |
| + l.allow(key) |
| + if got := l.size(); got > max { |
| + t.Fatalf("the table holds %d buckets, above the bound of %d", got, max) |
| + } |
| + } |
| + if got := l.size(); got != max { |
| + t.Fatalf("the table holds %d buckets, want it filled to %d", got, max) |
| + } |
| + |
| + // A full table FAILS CLOSED for an unseen client — see RateLimit.Max. |
| + if l.allow("someone-new") { |
| + t.Error("a full table admitted an unseen client; it must fail closed") |
| + } |
| + // And a client already in the table is unaffected by the crowd. |
| + if l.allow("client-0") { |
| + t.Error("client-0 had spent its burst and was allowed anyway") |
| + } |
| + |
| + // Once the crowd's buckets refill they are swept, and the table |
| + // takes new clients again — the bound is on live clients, not a |
| + // permanent cap on how many the process may ever see. |
| + now = now.Add(time.Hour) |
| + if !l.allow("someone-new") { |
| + t.Error("a swept table still refused a new client") |
| + } |
| + if got := l.size(); got > max { |
| + t.Fatalf("the table holds %d buckets after a sweep", got) |
| + } |
| +} |
| + |
| +func TestLimiterIsConcurrencySafe(t *testing.T) { |
| + l := newLimiter(RateLimit{Burst: 1000, Every: time.Hour, Max: 16}) |
| + var wg sync.WaitGroup |
| + for i := range 8 { |
| + wg.Add(1) |
| + go func() { |
| + defer wg.Done() |
| + for j := range 50 { |
| + l.allow(fmt.Sprintf("client-%d", (i+j)%16)) |
| + } |
| + }() |
| + } |
| + wg.Wait() |
| + if got := l.size(); got > 16 { |
| + t.Fatalf("the table holds %d buckets, above the bound of 16", got) |
| + } |
| +} |
| + |
| +func TestClientIPKeysByAddressNotPort(t *testing.T) { |
| + r := httptest.NewRequest("GET", "/invitations/x", nil) |
| + r.RemoteAddr = "203.0.113.9:51234" |
| + if got := clientIP(r); got != "203.0.113.9" { |
| + t.Errorf("clientIP = %q, want the address without the port", got) |
| + } |
| + // A browser making a second request from a new source port must |
| + // share the first one's budget, or the limit is no limit at all. |
| + r.RemoteAddr = "203.0.113.9:51235" |
| + if got := clientIP(r); got != "203.0.113.9" { |
| + t.Errorf("clientIP = %q for a second port, want the same key", got) |
| + } |
| + // An address with no port at all (a unix socket, a test) is used |
| + // whole rather than dropped, so it still keys to something. |
| + r.RemoteAddr = "@" |
| + if got := clientIP(r); got != "@" { |
| + t.Errorf("clientIP = %q, want the raw RemoteAddr when it has no port", got) |
| + } |
| +} |