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