| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "database/sql" |
| 6 | "fmt" |
| 7 | "io" |
| 8 | "log/slog" |
| 9 | "net/http" |
| 10 | "net/http/cookiejar" |
| 11 | "net/http/httptest" |
| 12 | "net/url" |
| 13 | "os" |
| 14 | "path/filepath" |
| 15 | "regexp" |
| 16 | "strconv" |
| 17 | "strings" |
| 18 | "testing" |
| 19 | |
| 20 | "github.com/carlosframework/rastrillo/db" |
| 21 | "github.com/carlosframework/rastrillo/migrate" |
| 22 | "github.com/carlosframework/rastrillo/password" |
| 23 | |
| 24 | "amadan.net/rastrillo/idear" |
| 25 | ) |
| 26 | |
| 27 | // The reviewers' standing complaint about idear was that nothing had |
| 28 | // been proven against a real app shell — every earlier test drove |
| 29 | // idear's own handlers over idear's own harness. These tests drive THE |
| 30 | // EXAMPLE: its router, its templates, its identity plugin, its |
| 31 | // migrations, over a real HTTP server with a real cookie jar. If the |
| 32 | // mount is wrong, they go red; that is the point of them. |
| 33 | |
| 34 | // testApp is the example, served. |
| 35 | type testApp struct { |
| 36 | t *testing.T |
| 37 | app *app |
| 38 | db *db.DB |
| 39 | server *httptest.Server |
| 40 | origin string |
| 41 | } |
| 42 | |
| 43 | func newTestApp(t *testing.T) *testApp { |
| 44 | t.Helper() |
| 45 | d, err := db.Open(filepath.Join(t.TempDir(), "board.db"), nil) |
| 46 | if err != nil { |
| 47 | t.Fatalf("db.Open: %v", err) |
| 48 | } |
| 49 | t.Cleanup(func() { d.Close() }) |
| 50 | |
| 51 | // The listener exists before Start, so the origin — which decides |
| 52 | // the CSRF check and the cookie attributes — is knowable before |
| 53 | // the app that has to be configured with it. |
| 54 | srv := httptest.NewUnstartedServer(nil) |
| 55 | origin := "http://" + srv.Listener.Addr().String() |
| 56 | |
| 57 | a, err := newApp(d, origin, "The Example Board", slog.New(slog.NewTextHandler(io.Discard, nil))) |
| 58 | if err != nil { |
| 59 | t.Fatalf("newApp: %v", err) |
| 60 | } |
| 61 | srv.Config.Handler = a.mux |
| 62 | srv.Start() |
| 63 | t.Cleanup(srv.Close) |
| 64 | |
| 65 | return &testApp{t: t, app: a, db: d, server: srv, origin: origin} |
| 66 | } |
| 67 | |
| 68 | // client is one browser: a cookie jar, and whatever session it holds. |
| 69 | type client struct { |
| 70 | ta *testApp |
| 71 | http *http.Client |
| 72 | } |
| 73 | |
| 74 | func (ta *testApp) visitor() *client { |
| 75 | ta.t.Helper() |
| 76 | jar, err := cookiejar.New(nil) |
| 77 | if err != nil { |
| 78 | ta.t.Fatalf("cookiejar.New: %v", err) |
| 79 | } |
| 80 | return &client{ta: ta, http: &http.Client{ |
| 81 | Jar: jar, |
| 82 | // Redirects are not followed: the 303 IS the assertion on |
| 83 | // every successful mutation, and a client that chased it would |
| 84 | // report the destination's 200 instead. |
| 85 | CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, |
| 86 | }} |
| 87 | } |
| 88 | |
| 89 | type result struct { |
| 90 | Status int |
| 91 | Body string |
| 92 | Location string |
| 93 | } |
| 94 | |
| 95 | func (c *client) get(path string) *result { return c.do(http.MethodGet, path, nil) } |
| 96 | |
| 97 | func (c *client) post(path string, form url.Values) *result { |
| 98 | return c.do(http.MethodPost, path, form) |
| 99 | } |
| 100 | |
| 101 | func (c *client) do(method, path string, form url.Values) *result { |
| 102 | c.ta.t.Helper() |
| 103 | var body io.Reader |
| 104 | if form != nil { |
| 105 | body = strings.NewReader(form.Encode()) |
| 106 | } |
| 107 | req, err := http.NewRequest(method, c.ta.origin+path, body) |
| 108 | if err != nil { |
| 109 | c.ta.t.Fatalf("%s %s: %v", method, path, err) |
| 110 | } |
| 111 | if form != nil { |
| 112 | req.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| 113 | } |
| 114 | if method != http.MethodGet { |
| 115 | // The evidence a browser sends on a same-origin form |
| 116 | // submission. csrf.Protect is mounted app-wide, so a mutation |
| 117 | // without it is refused 403 — which is exactly what a |
| 118 | // cross-site forgery looks like. It is set for every mutation, |
| 119 | // body or no body: several of idear's routes (remove, restore, |
| 120 | // revoke) are buttons with nothing in the form at all. |
| 121 | req.Header.Set("Origin", c.ta.origin) |
| 122 | } |
| 123 | res, err := c.http.Do(req) |
| 124 | if err != nil { |
| 125 | c.ta.t.Fatalf("%s %s: %v", method, path, err) |
| 126 | } |
| 127 | defer res.Body.Close() |
| 128 | b, err := io.ReadAll(res.Body) |
| 129 | if err != nil { |
| 130 | c.ta.t.Fatalf("reading %s: %v", path, err) |
| 131 | } |
| 132 | return &result{Status: res.StatusCode, Body: string(b), Location: res.Header.Get("Location")} |
| 133 | } |
| 134 | |
| 135 | // follow chases a redirect the way a browser would: the flash notice |
| 136 | // is only readable on the page the 303 lands on. |
| 137 | func (c *client) follow(res *result) *result { |
| 138 | c.ta.t.Helper() |
| 139 | if res.Location == "" { |
| 140 | c.ta.t.Fatalf("nothing to follow: status %d, body %q", res.Status, res.Body) |
| 141 | } |
| 142 | return c.get(res.Location) |
| 143 | } |
| 144 | |
| 145 | // signUp posts the signup form, with an invitation token when there is |
| 146 | // one. The "invite" field is what rs.CarryToken reads. |
| 147 | func (c *client) signUp(email, invite string) *result { |
| 148 | c.ta.t.Helper() |
| 149 | return c.signUpWith(email, invite, "demo-password") |
| 150 | } |
| 151 | |
| 152 | func (c *client) signUpWith(email, invite, pw string) *result { |
| 153 | c.ta.t.Helper() |
| 154 | form := url.Values{"email": {email}, "password": {pw}} |
| 155 | if invite != "" { |
| 156 | form.Set("invite", invite) |
| 157 | } |
| 158 | return c.post("/signup", form) |
| 159 | } |
| 160 | |
| 161 | func (c *client) signIn(email string) *result { |
| 162 | c.ta.t.Helper() |
| 163 | return c.post("/signin", url.Values{"email": {email}, "password": {"demo-password"}}) |
| 164 | } |
| 165 | |
| 166 | // tokenPattern pulls the invitation link out of the flash notice. |
| 167 | // HandlerConfig.Deliver is nil in this example, so idear puts the link |
| 168 | // itself in the notice the inviting admin sees — see app.go. |
| 169 | var tokenPattern = regexp.MustCompile(`/invitations/([0-9a-f]{64})`) |
| 170 | |
| 171 | func (ta *testApp) tokenFrom(res *result) string { |
| 172 | ta.t.Helper() |
| 173 | m := tokenPattern.FindStringSubmatch(res.Body) |
| 174 | if m == nil { |
| 175 | ta.t.Fatalf("no invitation link in the page: %q", res.Body) |
| 176 | } |
| 177 | return m[1] |
| 178 | } |
| 179 | |
| 180 | // memberID resolves an address to its roster id, for building the |
| 181 | // management URLs a real admin clicks. |
| 182 | func (ta *testApp) memberID(email string) int64 { |
| 183 | ta.t.Helper() |
| 184 | members, err := ta.app.roster.Members(context.Background()) |
| 185 | if err != nil { |
| 186 | ta.t.Fatalf("listing members: %v", err) |
| 187 | } |
| 188 | for _, m := range members { |
| 189 | if m.Email == email { |
| 190 | return m.ID |
| 191 | } |
| 192 | } |
| 193 | ta.t.Fatalf("no member with address %q in %+v", email, members) |
| 194 | return 0 |
| 195 | } |
| 196 | |
| 197 | func (ta *testApp) member(email string) idear.Member { |
| 198 | ta.t.Helper() |
| 199 | m, err := ta.app.roster.ByID(context.Background(), ta.memberID(email)) |
| 200 | if err != nil { |
| 201 | ta.t.Fatalf("loading member %q: %v", email, err) |
| 202 | } |
| 203 | return *m |
| 204 | } |
| 205 | |
| 206 | func want(t *testing.T, res *result, status int, what string) { |
| 207 | t.Helper() |
| 208 | if res.Status != status { |
| 209 | t.Fatalf("%s: status %d, want %d; body %q", what, res.Status, status, res.Body) |
| 210 | } |
| 211 | } |
| 212 | |
| 213 | // TestSignUpClaimInviteAcceptMembersRoleChange is the whole flow the |
| 214 | // example exists to prove, through real HTTP: sign up (claiming the |
| 215 | // instance), invite, accept the invitation as a second browser, read |
| 216 | // the members page, and change a role. |
| 217 | func TestSignUpClaimInviteAcceptMembersRoleChange(t *testing.T) { |
| 218 | ta := newTestApp(t) |
| 219 | |
| 220 | // Signed out, "/" is the app's session guard's problem, not |
| 221 | // idear's: a redirect to the sign-in page, never a 404. idear's |
| 222 | // Require never redirects — that division is why it mounts INSIDE |
| 223 | // this guard. |
| 224 | res := ta.visitor().get("/") |
| 225 | want(t, res, http.StatusSeeOther, "signed-out GET /") |
| 226 | if !strings.HasPrefix(res.Location, "/signin") { |
| 227 | t.Fatalf("signed-out GET / went to %q, want /signin", res.Location) |
| 228 | } |
| 229 | |
| 230 | // The claim: the first account on an empty roster is the Owner, |
| 231 | // with no invitation involved. |
| 232 | ada := ta.visitor() |
| 233 | res = ada.signUp("ada@example.test", "") |
| 234 | want(t, res, http.StatusSeeOther, "first signup") |
| 235 | if owner := ta.member("ada@example.test"); owner.Role != idear.RoleOwner { |
| 236 | t.Fatalf("first account is %q, want owner", owner.Role) |
| 237 | } |
| 238 | |
| 239 | // And now the instance is closed. A stranger with no token is |
| 240 | // refused at 403 with idear's one constant refusal copy — no |
| 241 | // mention of the address, and the same words whatever the reason. |
| 242 | res = ta.visitor().signUp("mallory@example.test", "") |
| 243 | want(t, res, http.StatusForbidden, "uninvited signup") |
| 244 | if !strings.Contains(res.Body, "Sign-up here is by invitation.") { |
| 245 | t.Fatalf("uninvited signup body %q, want the refusal copy", res.Body) |
| 246 | } |
| 247 | |
| 248 | // The Owner reaches the board and the members page. |
| 249 | want(t, ada.get("/"), http.StatusOK, "owner GET /") |
| 250 | page := ada.get("/members") |
| 251 | want(t, page, http.StatusOK, "owner GET /members") |
| 252 | if !strings.Contains(page.Body, "ada@example.test") { |
| 253 | t.Fatalf("members page does not list the owner: %q", page.Body) |
| 254 | } |
| 255 | // Grantable for an Owner is Admin and Member, never Owner: |
| 256 | // ownership moves only by Transfer. |
| 257 | if !strings.Contains(page.Body, `<option value="admin">`) || |
| 258 | !strings.Contains(page.Body, `<option value="member">`) { |
| 259 | t.Fatalf("owner's role selector is missing admin/member: %q", page.Body) |
| 260 | } |
| 261 | if strings.Contains(page.Body, `<option value="owner">`) { |
| 262 | t.Fatalf("owner's role selector offers owner: %q", page.Body) |
| 263 | } |
| 264 | |
| 265 | // Invite an Admin. The link comes back in the flash notice. |
| 266 | res = ada.post("/members/invitations", url.Values{ |
| 267 | "email": {"kim@example.test"}, "role": {"admin"}, |
| 268 | }) |
| 269 | want(t, res, http.StatusSeeOther, "invite") |
| 270 | if res.Location != "/members" { |
| 271 | t.Fatalf("invite redirected to %q, want /members", res.Location) |
| 272 | } |
| 273 | adminToken := ta.tokenFrom(ada.follow(res)) |
| 274 | |
| 275 | // The PUBLIC invitation page: it names the role and the instance, |
| 276 | // and it must not name the invited address. |
| 277 | kim := ta.visitor() |
| 278 | invitePage := kim.get("/invitations/" + adminToken) |
| 279 | want(t, invitePage, http.StatusOK, "public invitation page") |
| 280 | if !strings.Contains(invitePage.Body, "Admin") { |
| 281 | t.Fatalf("invitation page does not name the role: %q", invitePage.Body) |
| 282 | } |
| 283 | if strings.Contains(invitePage.Body, "kim@example.test") { |
| 284 | t.Fatalf("invitation page leaked the invited address: %q", invitePage.Body) |
| 285 | } |
| 286 | if !strings.Contains(invitePage.Body, `name="invite" value="`+adminToken+`"`) { |
| 287 | t.Fatalf("invitation page has no hidden invite field: %q", invitePage.Body) |
| 288 | } |
| 289 | |
| 290 | // Accept it by signing up with the token, which is what the hidden |
| 291 | // field above posts and what rs.CarryToken lifts off the form. |
| 292 | res = kim.signUp("kim@example.test", adminToken) |
| 293 | want(t, res, http.StatusSeeOther, "invited signup") |
| 294 | if got := ta.member("kim@example.test"); got.Role != idear.RoleAdmin { |
| 295 | t.Fatalf("invited account is %q, want admin", got.Role) |
| 296 | } |
| 297 | want(t, kim.get("/"), http.StatusOK, "admin GET /") |
| 298 | want(t, kim.get("/members"), http.StatusOK, "admin GET /members") |
| 299 | |
| 300 | // The Admin invites a plain Member. checkInviteRole is |
| 301 | // strictly-below, so this is the only role an Admin may grant. |
| 302 | res = kim.post("/members/invitations", url.Values{ |
| 303 | "email": {"sam@example.test"}, "role": {"member"}, |
| 304 | }) |
| 305 | want(t, res, http.StatusSeeOther, "admin invites a member") |
| 306 | memberToken := ta.tokenFrom(kim.follow(res)) |
| 307 | |
| 308 | sam := ta.visitor() |
| 309 | res = sam.signUp("sam@example.test", memberToken) |
| 310 | want(t, res, http.StatusSeeOther, "member signup") |
| 311 | if got := ta.member("sam@example.test"); got.Role != idear.RoleMember { |
| 312 | t.Fatalf("second invited account is %q, want member", got.Role) |
| 313 | } |
| 314 | |
| 315 | // A plain Member sees the roster and is offered nothing to do to |
| 316 | // it: Grantable is empty for anyone below Admin. |
| 317 | page = sam.get("/members") |
| 318 | want(t, page, http.StatusOK, "member GET /members") |
| 319 | if strings.Contains(page.Body, "Invite someone") { |
| 320 | t.Fatalf("a plain member is offered the invite form: %q", page.Body) |
| 321 | } |
| 322 | // And the management routes refuse them at 403 — they may see the |
| 323 | // page, they may not act on it. |
| 324 | res = sam.post("/members/invitations", url.Values{ |
| 325 | "email": {"eve@example.test"}, "role": {"member"}, |
| 326 | }) |
| 327 | want(t, res, http.StatusForbidden, "member tries to invite") |
| 328 | |
| 329 | samID := ta.memberID("sam@example.test") |
| 330 | |
| 331 | // THE ROLE CHANGE. The Admin cannot make a peer: idear refuses a |
| 332 | // grant that is not strictly below the granter's rank, and it does |
| 333 | // so at 403 rather than silently doing nothing. |
| 334 | res = kim.post("/members/"+strconv.FormatInt(samID, 10)+"/role", url.Values{"role": {"admin"}}) |
| 335 | want(t, res, http.StatusForbidden, "admin promotes to admin") |
| 336 | if got := ta.member("sam@example.test"); got.Role != idear.RoleMember { |
| 337 | t.Fatalf("refused promotion still landed: role is %q", got.Role) |
| 338 | } |
| 339 | |
| 340 | // The Owner can. |
| 341 | res = ada.post("/members/"+strconv.FormatInt(samID, 10)+"/role", url.Values{"role": {"admin"}}) |
| 342 | want(t, res, http.StatusSeeOther, "owner promotes to admin") |
| 343 | if got := ta.member("sam@example.test"); got.Role != idear.RoleAdmin { |
| 344 | t.Fatalf("owner's promotion did not land: role is %q", got.Role) |
| 345 | } |
| 346 | if body := ada.follow(res).Body; !strings.Contains(body, "Role updated.") { |
| 347 | t.Fatalf("members page after a role change: %q", body) |
| 348 | } |
| 349 | |
| 350 | // role=owner never lands, on any path, for any actor — including |
| 351 | // the Owner. |
| 352 | res = ada.post("/members/"+strconv.FormatInt(samID, 10)+"/role", url.Values{"role": {"owner"}}) |
| 353 | want(t, res, http.StatusForbidden, "owner posts role=owner") |
| 354 | if got := ta.member("sam@example.test"); got.Role != idear.RoleAdmin { |
| 355 | t.Fatalf("role=owner landed: role is %q", got.Role) |
| 356 | } |
| 357 | } |
| 358 | |
| 359 | // TestDeactivationIsEnforcedPerRequestNotAtSignIn is the reason "/" is |
| 360 | // behind Require, stated as a test. |
| 361 | // |
| 362 | // password.Signin runs Lookup, Verify and mint with no idear |
| 363 | // involvement at all, so a removed member still signs in successfully. |
| 364 | // What refuses them is Require, on every route, per request. An |
| 365 | // ungated landing page is the one place this design leaks, and this |
| 366 | // test is what would notice one appearing. |
| 367 | func TestDeactivationIsEnforcedPerRequestNotAtSignIn(t *testing.T) { |
| 368 | ta := newTestApp(t) |
| 369 | |
| 370 | ada := ta.visitor() |
| 371 | want(t, ada.signUp("ada@example.test", ""), http.StatusSeeOther, "claim") |
| 372 | |
| 373 | res := ada.post("/members/invitations", url.Values{ |
| 374 | "email": {"sam@example.test"}, "role": {"member"}, |
| 375 | }) |
| 376 | want(t, res, http.StatusSeeOther, "invite") |
| 377 | token := ta.tokenFrom(ada.follow(res)) |
| 378 | |
| 379 | sam := ta.visitor() |
| 380 | want(t, sam.signUp("sam@example.test", token), http.StatusSeeOther, "accept") |
| 381 | want(t, sam.get("/"), http.StatusOK, "member GET / before removal") |
| 382 | |
| 383 | samID := ta.memberID("sam@example.test") |
| 384 | want(t, ada.post("/members/"+strconv.FormatInt(samID, 10)+"/remove", nil), |
| 385 | http.StatusSeeOther, "remove") |
| 386 | |
| 387 | // The removed member's LIVE session stops resolving on the very |
| 388 | // next request. |
| 389 | want(t, sam.get("/"), http.StatusNotFound, "removed member GET / on the old session") |
| 390 | |
| 391 | // And a fresh sign-in still SUCCEEDS — the password plugin knows |
| 392 | // nothing about the roster — yet buys nothing at all. |
| 393 | back := ta.visitor() |
| 394 | res = back.signIn("sam@example.test") |
| 395 | want(t, res, http.StatusSeeOther, "removed member signs in") |
| 396 | gated := back.get("/") |
| 397 | want(t, gated, http.StatusNotFound, "removed member GET / on a fresh session") |
| 398 | want(t, back.get("/members"), http.StatusNotFound, "removed member GET /members") |
| 399 | |
| 400 | // THE 404 IS THE APP'S OWN, and it is byte-identical to the 404 |
| 401 | // for a path that simply does not exist. Two different renderers |
| 402 | // here would let a stranger tell "this exists but not for you" |
| 403 | // from "no such route", which is the membership oracle the whole |
| 404 | // design is arranged around — and it is the one misconfiguration |
| 405 | // idear cannot detect at runtime. |
| 406 | nowhere := back.get("/no-such-path-at-all") |
| 407 | want(t, nowhere, http.StatusNotFound, "chi's own NotFound") |
| 408 | if gated.Body != nowhere.Body { |
| 409 | t.Fatalf("idear's 404 and chi's 404 differ:\nidear: %q\nchi: %q", gated.Body, nowhere.Body) |
| 410 | } |
| 411 | if !strings.Contains(nowhere.Body, "There is nothing here.") { |
| 412 | t.Fatalf("the 404 is not the app's own page: %q", nowhere.Body) |
| 413 | } |
| 414 | |
| 415 | // Restored, they are back — at the role their row still carried. |
| 416 | want(t, ada.post("/members/"+strconv.FormatInt(samID, 10)+"/restore", nil), |
| 417 | http.StatusSeeOther, "restore") |
| 418 | want(t, back.get("/"), http.StatusOK, "restored member GET /") |
| 419 | } |
| 420 | |
| 421 | // TestAppRouteRoleGate proves the app's OWN role-gated route, which is |
| 422 | // the stacking every app has to get right: RequireRole inside Require, |
| 423 | // never bare. |
| 424 | func TestAppRouteRoleGate(t *testing.T) { |
| 425 | ta := newTestApp(t) |
| 426 | if err := Seed(context.Background(), ta.app.db, ta.app.roster); err != nil { |
| 427 | t.Fatalf("Seed: %v", err) |
| 428 | } |
| 429 | |
| 430 | member := ta.visitor() |
| 431 | want(t, member.signIn(SeedMember), http.StatusSeeOther, "member signs in") |
| 432 | board := member.get("/") |
| 433 | want(t, board, http.StatusOK, "member GET /") |
| 434 | // The template hides the control... |
| 435 | if strings.Contains(board.Body, "/delete") { |
| 436 | t.Fatalf("a plain member is shown a delete button: %q", board.Body) |
| 437 | } |
| 438 | // ...and the route refuses it anyway, which is the half that |
| 439 | // counts. |
| 440 | var post Post |
| 441 | if err := ta.app.db.First(&post).Error; err != nil { |
| 442 | t.Fatalf("loading the seeded post: %v", err) |
| 443 | } |
| 444 | id := strconv.FormatInt(post.ID, 10) |
| 445 | want(t, member.post("/posts/"+id+"/delete", nil), http.StatusForbidden, "member deletes a post") |
| 446 | |
| 447 | admin := ta.visitor() |
| 448 | want(t, admin.signIn(SeedAdmin), http.StatusSeeOther, "admin signs in") |
| 449 | want(t, admin.post("/posts/"+id+"/delete", nil), http.StatusSeeOther, "admin deletes a post") |
| 450 | |
| 451 | // A member may still post: Require admits them, RequireRole is |
| 452 | // only on the delete route. |
| 453 | want(t, member.post("/posts", url.Values{"body": {"hello"}}), http.StatusSeeOther, "member posts") |
| 454 | } |
| 455 | |
| 456 | // TestCSRFRefusesACrossOriginMutation pins that csrf.Protect is |
| 457 | // actually mounted app-wide, over idear's routes as well as the app's. |
| 458 | func TestCSRFRefusesACrossOriginMutation(t *testing.T) { |
| 459 | ta := newTestApp(t) |
| 460 | ada := ta.visitor() |
| 461 | want(t, ada.signUp("ada@example.test", ""), http.StatusSeeOther, "claim") |
| 462 | |
| 463 | req, err := http.NewRequest(http.MethodPost, ta.origin+"/members/invitations", |
| 464 | strings.NewReader(url.Values{"email": {"eve@example.test"}, "role": {"admin"}}.Encode())) |
| 465 | if err != nil { |
| 466 | t.Fatalf("building the request: %v", err) |
| 467 | } |
| 468 | req.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| 469 | req.Header.Set("Origin", "https://attacker.example") |
| 470 | res, err := ada.http.Do(req) |
| 471 | if err != nil { |
| 472 | t.Fatalf("posting: %v", err) |
| 473 | } |
| 474 | defer res.Body.Close() |
| 475 | if res.StatusCode != http.StatusForbidden { |
| 476 | t.Fatalf("cross-origin POST to an idear route: status %d, want 403", res.StatusCode) |
| 477 | } |
| 478 | } |
| 479 | |
| 480 | // TestSeedProducesThreeRoles: the seed is what makes the role gates |
| 481 | // clickable, and it is idempotent. |
| 482 | func TestSeedProducesThreeRoles(t *testing.T) { |
| 483 | ta := newTestApp(t) |
| 484 | ctx := context.Background() |
| 485 | if err := Seed(ctx, ta.app.db, ta.app.roster); err != nil { |
| 486 | t.Fatalf("Seed: %v", err) |
| 487 | } |
| 488 | if err := Seed(ctx, ta.app.db, ta.app.roster); err != nil { |
| 489 | t.Fatalf("Seed again: %v", err) |
| 490 | } |
| 491 | members, err := ta.app.roster.Members(ctx) |
| 492 | if err != nil { |
| 493 | t.Fatalf("Members: %v", err) |
| 494 | } |
| 495 | if len(members) != 3 { |
| 496 | t.Fatalf("seed produced %d members, want 3: %+v", len(members), members) |
| 497 | } |
| 498 | byRole := map[idear.Role]string{} |
| 499 | for _, m := range members { |
| 500 | byRole[m.Role] = m.Email |
| 501 | } |
| 502 | for role, email := range map[idear.Role]string{ |
| 503 | idear.RoleOwner: SeedOwner, |
| 504 | idear.RoleAdmin: SeedAdmin, |
| 505 | idear.RoleMember: SeedMember, |
| 506 | } { |
| 507 | if byRole[role] != email { |
| 508 | t.Errorf("%s is %q, want %q", role, byRole[role], email) |
| 509 | } |
| 510 | } |
| 511 | } |
| 512 | |
| 513 | // TestSchemaAndModelsAgree is `rastrillo migration check` in test form: |
| 514 | // replay this app's OWN Schema and diff the result against this app's |
| 515 | // OWN Models. It goes red the moment a model changes without a |
| 516 | // migration — and it is also what would catch an idear model being |
| 517 | // added to Models, since idear's tables reach the database through |
| 518 | // BootSchema and are not in Schema at all. |
| 519 | func TestSchemaAndModelsAgree(t *testing.T) { |
| 520 | changes, err := migrate.Generate(context.Background(), Schema.All(), Models) |
| 521 | if err != nil { |
| 522 | t.Fatalf("migrate.Generate: %v", err) |
| 523 | } |
| 524 | if len(changes) != 0 { |
| 525 | for _, c := range changes { |
| 526 | t.Errorf("pending change: %+v", c) |
| 527 | } |
| 528 | t.Fatalf("Schema and Models disagree: %d pending change(s)", len(changes)) |
| 529 | } |
| 530 | } |
| 531 | |
| 532 | // TestInvitedSignupSurvivesAValidationFailure is the workaround in |
| 533 | // renderSignup, pinned. |
| 534 | // |
| 535 | // password.PageData has Error, Email and ReturnTo and NOWHERE to put |
| 536 | // an invitation token, so a signup that fails validation re-renders a |
| 537 | // form whose hidden "invite" field comes back empty unless the app |
| 538 | // puts it back. The invitee's FIRST attempt then looks fine and their |
| 539 | // SECOND is refused for holding no token — "invited people can never |
| 540 | // join", surfacing one step later than the mistake. |
| 541 | // |
| 542 | // This is the one piece of copy-this-code an app cannot get from |
| 543 | // idear's mount, and rewriting the signup page is the likeliest thing |
| 544 | // a copier does. Deleting idear.TokenFrom(r) from render.go turns the |
| 545 | // 422 assertion below red. |
| 546 | func TestInvitedSignupSurvivesAValidationFailure(t *testing.T) { |
| 547 | ta := newTestApp(t) |
| 548 | |
| 549 | ada := ta.visitor() |
| 550 | want(t, ada.signUp("ada@example.test", ""), http.StatusSeeOther, "claim") |
| 551 | res := ada.post("/members/invitations", url.Values{ |
| 552 | "email": {"kim@example.test"}, "role": {"admin"}, |
| 553 | }) |
| 554 | want(t, res, http.StatusSeeOther, "invite") |
| 555 | token := ta.tokenFrom(ada.follow(res)) |
| 556 | |
| 557 | // A password too short for password's own rule: 422, and the form |
| 558 | // comes back. The token must come back WITH it. |
| 559 | kim := ta.visitor() |
| 560 | res = kim.signUpWith("kim@example.test", token, "short") |
| 561 | want(t, res, http.StatusUnprocessableEntity, "signup with a short password") |
| 562 | if !strings.Contains(res.Body, `name="invite" value="`+token+`"`) { |
| 563 | t.Fatalf("the re-rendered signup form dropped the invitation token: %q", res.Body) |
| 564 | } |
| 565 | |
| 566 | // The second attempt — the one a real invitee makes — still works, |
| 567 | // and still lands at the invited role rather than being refused. |
| 568 | res = kim.signUpWith("kim@example.test", token, "demo-password") |
| 569 | want(t, res, http.StatusSeeOther, "second signup attempt") |
| 570 | if got := ta.member("kim@example.test"); got.Role != idear.RoleAdmin { |
| 571 | t.Fatalf("second attempt admitted at %q, want admin", got.Role) |
| 572 | } |
| 573 | } |
| 574 | |
| 575 | // --------------------------------------------------------------- |
| 576 | // SKILL.md §6 break-glass: rebinding a subject. |
| 577 | // --------------------------------------------------------------- |
| 578 | |
| 579 | // TestBreakGlassRebindsASubject executes the rebind block from |
| 580 | // SKILL.md §6 — READ OUT OF THE FILE, not restated here — against a |
| 581 | // database built by this app's own migrations, and then proves the |
| 582 | // result end to end: sign in over real HTTP as the new subject and |
| 583 | // perform an Owner-only action with it. |
| 584 | // |
| 585 | // Documented recovery SQL that nobody has run is a guess. §6's other |
| 586 | // block, the ownership transfer, was verified this way and the review |
| 587 | // found `deactivated_at = NULL` in it load-bearing; this one carries |
| 588 | // the same clause for the same reason, plus the unique index on |
| 589 | // subject, which is what turns a careless rebind into a failed UPDATE. |
| 590 | // |
| 591 | // Reading the SQL from the document rather than copying it here is the |
| 592 | // point of the test. A copy can go stale silently; this cannot — edit |
| 593 | // §6's block into something that does not work and this test is what |
| 594 | // says so. |
| 595 | func TestBreakGlassRebindsASubject(t *testing.T) { |
| 596 | ta := newTestApp(t) |
| 597 | ctx := context.Background() |
| 598 | if err := Seed(ctx, ta.app.db, ta.app.roster); err != nil { |
| 599 | t.Fatalf("Seed: %v", err) |
| 600 | } |
| 601 | owner := ta.member(SeedOwner) |
| 602 | if owner.Role != idear.RoleOwner { |
| 603 | t.Fatalf("the seeded owner is %s", owner.Role) |
| 604 | } |
| 605 | successorID := ta.memberID(SeedAdmin) |
| 606 | |
| 607 | // The Owner's identity changed. Under keymail that is a new |
| 608 | // address; under password — this app — it is a new user row, and |
| 609 | // the subject is that row's decimal id. Either way the roster row |
| 610 | // still points at the old one, and the person is now a stranger to |
| 611 | // every guarded route. |
| 612 | const newAddress = "ada.new@example.test" |
| 613 | hash, err := password.Hash(SeedPassword) |
| 614 | if err != nil { |
| 615 | t.Fatalf("password.Hash: %v", err) |
| 616 | } |
| 617 | newID, err := createUser(ta.app.db)(ctx, newAddress, hash) |
| 618 | if err != nil { |
| 619 | t.Fatalf("creating the replacement user: %v", err) |
| 620 | } |
| 621 | |
| 622 | stranger := ta.visitor() |
| 623 | want(t, stranger.signIn(newAddress), http.StatusSeeOther, "sign in as the new identity") |
| 624 | if res := stranger.get("/"); res.Status != http.StatusNotFound { |
| 625 | t.Fatalf("the new identity reached the board before any rebind: status %d", res.Status) |
| 626 | } |
| 627 | |
| 628 | // The break-glass, verbatim: only the three placeholders are |
| 629 | // filled in, exactly as an operator editing the block would. |
| 630 | script := breakGlassRebind(t, map[string]string{ |
| 631 | "OLD-SUBJECT": owner.Subject, |
| 632 | "NEW-SUBJECT": subjectFor(newID), |
| 633 | "NEW-EMAIL": newAddress, |
| 634 | }) |
| 635 | t.Log("\n" + runSQLScript(t, ta, script)) |
| 636 | |
| 637 | // End to end, through the front door: a real sign-in, a real |
| 638 | // session, and the membership gate. |
| 639 | ada := ta.visitor() |
| 640 | want(t, ada.signIn(newAddress), http.StatusSeeOther, "sign in as the rebound owner") |
| 641 | want(t, ada.get("/"), http.StatusOK, "the board, as the rebound owner") |
| 642 | |
| 643 | // The OWNER-ONLY action. RequireRole(RoleOwner) 403s everybody |
| 644 | // else, and Transfer is the only route behind it — so a 303 here |
| 645 | // is the rebind having restored ownership and not merely |
| 646 | // membership. |
| 647 | res := ada.post("/members/transfer", url.Values{ |
| 648 | "member": {strconv.FormatInt(successorID, 10)}, |
| 649 | }) |
| 650 | want(t, res, http.StatusSeeOther, "transfer ownership as the rebound owner") |
| 651 | if got := ta.member(SeedAdmin); got.Role != idear.RoleOwner { |
| 652 | t.Fatalf("after the transfer %s is %s, want owner", SeedAdmin, got.Role) |
| 653 | } |
| 654 | if got := ta.member(newAddress); got.ID != owner.ID || got.Role != idear.RoleAdmin { |
| 655 | t.Fatalf("the rebound row is %+v, want member %d demoted to admin by its own transfer", got, owner.ID) |
| 656 | } |
| 657 | |
| 658 | // And the OLD subject is nobody: the old user row still signs in — |
| 659 | // credentials are the app's table, not idear's — and 404s on every |
| 660 | // guarded route, which is what a rebind means. |
| 661 | old := ta.visitor() |
| 662 | want(t, old.signIn(SeedOwner), http.StatusSeeOther, "sign in as the old identity") |
| 663 | if res := old.get("/"); res.Status != http.StatusNotFound { |
| 664 | t.Fatalf("the old subject still reached the board after the rebind: status %d", res.Status) |
| 665 | } |
| 666 | } |
| 667 | |
| 668 | // TestBreakGlassRebindClearsDeactivation is the OTHER half of the §6 |
| 669 | // rebind, and it is why `deactivated_at = NULL` is in that statement. |
| 670 | // |
| 671 | // The realistic shape: somebody was offboarded (correctly — §7 says |
| 672 | // offboarding under keymail MUST deactivate), then came back under a |
| 673 | // new identity. The row still carries their role and their removal. |
| 674 | // Rebound without clearing it, the new subject resolves to a |
| 675 | // deactivated member and 404s on every guarded route — which is |
| 676 | // indistinguishable, from the operator's side, from the rebind not |
| 677 | // having worked at all. Delete that clause from SKILL.md and the GET |
| 678 | // below turns red. |
| 679 | func TestBreakGlassRebindClearsDeactivation(t *testing.T) { |
| 680 | ta := newTestApp(t) |
| 681 | ctx := context.Background() |
| 682 | if err := Seed(ctx, ta.app.db, ta.app.roster); err != nil { |
| 683 | t.Fatalf("Seed: %v", err) |
| 684 | } |
| 685 | kim := ta.member(SeedAdmin) |
| 686 | |
| 687 | // Removed through the real route, by the real Owner. |
| 688 | ada := ta.visitor() |
| 689 | want(t, ada.signIn(SeedOwner), http.StatusSeeOther, "sign in as the owner") |
| 690 | want(t, ada.post(fmt.Sprintf("/members/%d/remove", kim.ID), url.Values{}), |
| 691 | http.StatusSeeOther, "remove the admin") |
| 692 | if removed := ta.member(SeedAdmin); removed.Active() { |
| 693 | t.Fatal("the admin is still active after remove") |
| 694 | } |
| 695 | |
| 696 | const newAddress = "kim.new@example.test" |
| 697 | hash, err := password.Hash(SeedPassword) |
| 698 | if err != nil { |
| 699 | t.Fatalf("password.Hash: %v", err) |
| 700 | } |
| 701 | newID, err := createUser(ta.app.db)(ctx, newAddress, hash) |
| 702 | if err != nil { |
| 703 | t.Fatalf("creating the replacement user: %v", err) |
| 704 | } |
| 705 | |
| 706 | t.Log("\n" + runSQLScript(t, ta, breakGlassRebind(t, map[string]string{ |
| 707 | "OLD-SUBJECT": kim.Subject, |
| 708 | "NEW-SUBJECT": subjectFor(newID), |
| 709 | "NEW-EMAIL": newAddress, |
| 710 | }))) |
| 711 | |
| 712 | back := ta.visitor() |
| 713 | want(t, back.signIn(newAddress), http.StatusSeeOther, "sign in as the rebound admin") |
| 714 | want(t, back.get("/"), http.StatusOK, "the board, as the rebound admin") |
| 715 | got := ta.member(newAddress) |
| 716 | if got.ID != kim.ID || got.Role != idear.RoleAdmin || !got.Active() { |
| 717 | t.Fatalf("the rebound row is %+v, want member %d, admin, active", got, kim.ID) |
| 718 | } |
| 719 | } |
| 720 | |
| 721 | // breakGlassRebind returns SKILL.md §6's rebind block with its |
| 722 | // placeholders replaced. The block is located by a placeholder rather |
| 723 | // than by a heading or an index, so reordering the document does not |
| 724 | // silently point this at some other SQL. |
| 725 | func breakGlassRebind(t *testing.T, subs map[string]string) string { |
| 726 | t.Helper() |
| 727 | doc, err := os.ReadFile(filepath.Join("..", "SKILL.md")) |
| 728 | if err != nil { |
| 729 | t.Fatalf("reading SKILL.md: %v", err) |
| 730 | } |
| 731 | block := sqlBlock(t, string(doc), "OLD-SUBJECT") |
| 732 | for name, value := range subs { |
| 733 | if !strings.Contains(block, "'"+name+"'") { |
| 734 | t.Fatalf("SKILL.md's rebind block has no '%s' placeholder to fill in:\n%s", name, block) |
| 735 | } |
| 736 | // Only the QUOTED placeholder is filled in — the same edit an |
| 737 | // operator makes — so the prose above each statement is left |
| 738 | // as the document wrote it. |
| 739 | block = strings.ReplaceAll(block, "'"+name+"'", "'"+value+"'") |
| 740 | } |
| 741 | return block |
| 742 | } |
| 743 | |
| 744 | // sqlBlock is the one ```sql fence in doc that mentions needle. |
| 745 | // Exactly one: two would mean the test is guessing. |
| 746 | func sqlBlock(t *testing.T, doc, needle string) string { |
| 747 | t.Helper() |
| 748 | var found []string |
| 749 | var cur *strings.Builder |
| 750 | for _, line := range strings.Split(doc, "\n") { |
| 751 | switch { |
| 752 | case cur == nil && strings.TrimSpace(line) == "```sql": |
| 753 | cur = &strings.Builder{} |
| 754 | case cur != nil && strings.TrimSpace(line) == "```": |
| 755 | if strings.Contains(cur.String(), needle) { |
| 756 | found = append(found, cur.String()) |
| 757 | } |
| 758 | cur = nil |
| 759 | case cur != nil: |
| 760 | cur.WriteString(line + "\n") |
| 761 | } |
| 762 | } |
| 763 | if len(found) != 1 { |
| 764 | t.Fatalf("SKILL.md has %d sql blocks mentioning %q, want exactly 1", len(found), needle) |
| 765 | } |
| 766 | return found[0] |
| 767 | } |
| 768 | |
| 769 | // runSQLScript executes the script one statement at a time on a SINGLE |
| 770 | // writer connection — the sqlite3 session an operator would have — and |
| 771 | // returns a transcript of what each statement did. |
| 772 | // |
| 773 | // One connection is not a detail: the block wraps its UPDATE in |
| 774 | // BEGIN/COMMIT, and a pool that handed those three statements to |
| 775 | // different connections would prove nothing about the transaction the |
| 776 | // document tells an operator to run. |
| 777 | func runSQLScript(t *testing.T, ta *testApp, script string) string { |
| 778 | t.Helper() |
| 779 | ctx := context.Background() |
| 780 | conn, err := ta.db.Writer().Conn(ctx) |
| 781 | if err != nil { |
| 782 | t.Fatalf("taking the writer connection: %v", err) |
| 783 | } |
| 784 | defer conn.Close() |
| 785 | |
| 786 | var out strings.Builder |
| 787 | for _, stmt := range strings.Split(script, ";") { |
| 788 | stmt = strings.TrimSpace(stmt) |
| 789 | if stmt == "" { |
| 790 | continue |
| 791 | } |
| 792 | fmt.Fprintf(&out, "sqlite> %s;\n", stmt) |
| 793 | if strings.HasPrefix(strings.ToUpper(bare(stmt)), "SELECT") { |
| 794 | fmt.Fprint(&out, query(t, ctx, conn, stmt)) |
| 795 | continue |
| 796 | } |
| 797 | res, err := conn.ExecContext(ctx, stmt) |
| 798 | if err != nil { |
| 799 | t.Fatalf("SKILL.md §6 statement failed: %v\n%s", err, stmt) |
| 800 | } |
| 801 | switch verb := strings.ToUpper(bare(stmt)); { |
| 802 | case strings.HasPrefix(verb, "BEGIN"), strings.HasPrefix(verb, "COMMIT"): |
| 803 | // RowsAffected means nothing for these, and the driver |
| 804 | // answers 1 for both — a number in the transcript that |
| 805 | // looked like a result would be worse than no number. |
| 806 | out.WriteString("-- ok\n") |
| 807 | default: |
| 808 | n, err := res.RowsAffected() |
| 809 | if err != nil { |
| 810 | t.Fatalf("reading rows affected: %v", err) |
| 811 | } |
| 812 | fmt.Fprintf(&out, "-- %d row(s)\n", n) |
| 813 | } |
| 814 | } |
| 815 | return out.String() |
| 816 | } |
| 817 | |
| 818 | // bare strips the leading comment lines from a statement, so the |
| 819 | // SELECT/other decision reads the SQL and not the prose above it. |
| 820 | func bare(stmt string) string { |
| 821 | for _, line := range strings.Split(stmt, "\n") { |
| 822 | line = strings.TrimSpace(line) |
| 823 | if line != "" && !strings.HasPrefix(line, "--") { |
| 824 | return line |
| 825 | } |
| 826 | } |
| 827 | return "" |
| 828 | } |
| 829 | |
| 830 | // query runs a SELECT and renders its rows. Values are scanned into |
| 831 | // any, not into a typed column list: this prints whatever the schema |
| 832 | // holds, including the NULL deactivated_at the verification step is |
| 833 | // looking for. |
| 834 | func query(t *testing.T, ctx context.Context, conn *sql.Conn, stmt string) string { |
| 835 | t.Helper() |
| 836 | rows, err := conn.QueryContext(ctx, stmt) |
| 837 | if err != nil { |
| 838 | t.Fatalf("SKILL.md §6 query failed: %v\n%s", err, stmt) |
| 839 | } |
| 840 | defer rows.Close() |
| 841 | cols, err := rows.Columns() |
| 842 | if err != nil { |
| 843 | t.Fatalf("reading columns: %v", err) |
| 844 | } |
| 845 | |
| 846 | var out strings.Builder |
| 847 | fmt.Fprintf(&out, "%s\n", strings.Join(cols, "|")) |
| 848 | n := 0 |
| 849 | for rows.Next() { |
| 850 | cells := make([]any, len(cols)) |
| 851 | into := make([]any, len(cols)) |
| 852 | for i := range cells { |
| 853 | into[i] = &cells[i] |
| 854 | } |
| 855 | if err := rows.Scan(into...); err != nil { |
| 856 | t.Fatalf("scanning a row: %v", err) |
| 857 | } |
| 858 | parts := make([]string, len(cells)) |
| 859 | for i, c := range cells { |
| 860 | parts[i] = fmt.Sprintf("%v", c) |
| 861 | } |
| 862 | fmt.Fprintf(&out, "%s\n", strings.Join(parts, "|")) |
| 863 | n++ |
| 864 | } |
| 865 | if err := rows.Err(); err != nil { |
| 866 | t.Fatalf("reading rows: %v", err) |
| 867 | } |
| 868 | if n == 0 { |
| 869 | out.WriteString("-- no rows\n") |
| 870 | } |
| 871 | return out.String() |
| 872 | } |
| 873 | |