rastrillo / idear Public

Clone
git clone https://amadan.net/rastrillo/idear

Plain git — no account needed to clone.

Download

Download this file

1package main
2
3import (
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 "amadan.net/rastrillo/rastrillo/db"
21 "amadan.net/rastrillo/rastrillo/migrate"
22 "amadan.net/rastrillo/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.
35type testApp struct {
36 t *testing.T
37 app *app
38 db *db.DB
39 server *httptest.Server
40 origin string
41}
42
43func 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.
69type client struct {
70 ta *testApp
71 http *http.Client
72}
73
74func (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
89type result struct {
90 Status int
91 Body string
92 Location string
93}
94
95func (c *client) get(path string) *result { return c.do(http.MethodGet, path, nil) }
96
97func (c *client) post(path string, form url.Values) *result {
98 return c.do(http.MethodPost, path, form)
99}
100
101func (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.
137func (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.
147func (c *client) signUp(email, invite string) *result {
148 c.ta.t.Helper()
149 return c.signUpWith(email, invite, "demo-password")
150}
151
152func (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
161func (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.
169var tokenPattern = regexp.MustCompile(`/invitations/([0-9a-f]{64})`)
170
171func (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.
182func (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
197func (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
206func 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.
217func 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 // unless idear was built with PeerGrants.
303 res = kim.post("/members/invitations", url.Values{
304 "email": {"sam@example.test"}, "role": {"member"},
305 })
306 want(t, res, http.StatusSeeOther, "admin invites a member")
307 memberToken := ta.tokenFrom(kim.follow(res))
308
309 sam := ta.visitor()
310 res = sam.signUp("sam@example.test", memberToken)
311 want(t, res, http.StatusSeeOther, "member signup")
312 if got := ta.member("sam@example.test"); got.Role != idear.RoleMember {
313 t.Fatalf("second invited account is %q, want member", got.Role)
314 }
315
316 // A plain Member sees the roster and is offered nothing to do to
317 // it: Grantable is empty for anyone below Admin.
318 page = sam.get("/members")
319 want(t, page, http.StatusOK, "member GET /members")
320 if strings.Contains(page.Body, "Invite someone") {
321 t.Fatalf("a plain member is offered the invite form: %q", page.Body)
322 }
323 // And the management routes refuse them at 403 — they may see the
324 // page, they may not act on it.
325 res = sam.post("/members/invitations", url.Values{
326 "email": {"eve@example.test"}, "role": {"member"},
327 })
328 want(t, res, http.StatusForbidden, "member tries to invite")
329
330 samID := ta.memberID("sam@example.test")
331
332 // THE ROLE CHANGE. The Admin cannot make a peer: idear refuses a
333 // grant that is not strictly below the granter's rank, and it does
334 // so at 403 rather than silently doing nothing. Built with
335 // PeerGrants it can, and the demotion back is allowed too.
336 res = kim.post("/members/"+strconv.FormatInt(samID, 10)+"/role", url.Values{"role": {"admin"}})
337 if idear.PeerGrants {
338 want(t, res, http.StatusSeeOther, "admin promotes to admin under PeerGrants")
339 if got := ta.member("sam@example.test"); got.Role != idear.RoleAdmin {
340 t.Fatalf("peer promotion did not land: role is %q", got.Role)
341 }
342 res = kim.post("/members/"+strconv.FormatInt(samID, 10)+"/role", url.Values{"role": {"member"}})
343 want(t, res, http.StatusSeeOther, "admin demotes a peer admin under PeerGrants")
344 } else {
345 want(t, res, http.StatusForbidden, "admin promotes to admin")
346 }
347 if got := ta.member("sam@example.test"); got.Role != idear.RoleMember {
348 t.Fatalf("sam is %q, want member", got.Role)
349 }
350
351 // The Owner can.
352 res = ada.post("/members/"+strconv.FormatInt(samID, 10)+"/role", url.Values{"role": {"admin"}})
353 want(t, res, http.StatusSeeOther, "owner promotes to admin")
354 if got := ta.member("sam@example.test"); got.Role != idear.RoleAdmin {
355 t.Fatalf("owner's promotion did not land: role is %q", got.Role)
356 }
357 if body := ada.follow(res).Body; !strings.Contains(body, "Role updated.") {
358 t.Fatalf("members page after a role change: %q", body)
359 }
360
361 // role=owner never lands, on any path, for any actor — including
362 // the Owner.
363 res = ada.post("/members/"+strconv.FormatInt(samID, 10)+"/role", url.Values{"role": {"owner"}})
364 want(t, res, http.StatusForbidden, "owner posts role=owner")
365 if got := ta.member("sam@example.test"); got.Role != idear.RoleAdmin {
366 t.Fatalf("role=owner landed: role is %q", got.Role)
367 }
368}
369
370// TestDeactivationIsEnforcedPerRequestNotAtSignIn is the reason "/" is
371// behind Require, stated as a test.
372//
373// password.Signin runs Lookup, Verify and mint with no idear
374// involvement at all, so a removed member still signs in successfully.
375// What refuses them is Require, on every route, per request. An
376// ungated landing page is the one place this design leaks, and this
377// test is what would notice one appearing.
378func TestDeactivationIsEnforcedPerRequestNotAtSignIn(t *testing.T) {
379 ta := newTestApp(t)
380
381 ada := ta.visitor()
382 want(t, ada.signUp("ada@example.test", ""), http.StatusSeeOther, "claim")
383
384 res := ada.post("/members/invitations", url.Values{
385 "email": {"sam@example.test"}, "role": {"member"},
386 })
387 want(t, res, http.StatusSeeOther, "invite")
388 token := ta.tokenFrom(ada.follow(res))
389
390 sam := ta.visitor()
391 want(t, sam.signUp("sam@example.test", token), http.StatusSeeOther, "accept")
392 want(t, sam.get("/"), http.StatusOK, "member GET / before removal")
393
394 samID := ta.memberID("sam@example.test")
395 want(t, ada.post("/members/"+strconv.FormatInt(samID, 10)+"/remove", nil),
396 http.StatusSeeOther, "remove")
397
398 // The removed member's LIVE session stops resolving on the very
399 // next request.
400 want(t, sam.get("/"), http.StatusNotFound, "removed member GET / on the old session")
401
402 // And a fresh sign-in still SUCCEEDS — the password plugin knows
403 // nothing about the roster — yet buys nothing at all.
404 back := ta.visitor()
405 res = back.signIn("sam@example.test")
406 want(t, res, http.StatusSeeOther, "removed member signs in")
407 gated := back.get("/")
408 want(t, gated, http.StatusNotFound, "removed member GET / on a fresh session")
409 want(t, back.get("/members"), http.StatusNotFound, "removed member GET /members")
410
411 // THE 404 IS THE APP'S OWN, and it is byte-identical to the 404
412 // for a path that simply does not exist. Two different renderers
413 // here would let a stranger tell "this exists but not for you"
414 // from "no such route", which is the membership oracle the whole
415 // design is arranged around — and it is the one misconfiguration
416 // idear cannot detect at runtime.
417 nowhere := back.get("/no-such-path-at-all")
418 want(t, nowhere, http.StatusNotFound, "chi's own NotFound")
419 if gated.Body != nowhere.Body {
420 t.Fatalf("idear's 404 and chi's 404 differ:\nidear: %q\nchi: %q", gated.Body, nowhere.Body)
421 }
422 if !strings.Contains(nowhere.Body, "There is nothing here.") {
423 t.Fatalf("the 404 is not the app's own page: %q", nowhere.Body)
424 }
425
426 // Restored, they are back — at the role their row still carried.
427 want(t, ada.post("/members/"+strconv.FormatInt(samID, 10)+"/restore", nil),
428 http.StatusSeeOther, "restore")
429 want(t, back.get("/"), http.StatusOK, "restored member GET /")
430}
431
432// TestAppRouteRoleGate proves the app's OWN role-gated route, which is
433// the stacking every app has to get right: RequireRole inside Require,
434// never bare.
435func TestAppRouteRoleGate(t *testing.T) {
436 ta := newTestApp(t)
437 if err := Seed(context.Background(), ta.app.db, ta.app.roster); err != nil {
438 t.Fatalf("Seed: %v", err)
439 }
440
441 member := ta.visitor()
442 want(t, member.signIn(SeedMember), http.StatusSeeOther, "member signs in")
443 board := member.get("/")
444 want(t, board, http.StatusOK, "member GET /")
445 // The template hides the control...
446 if strings.Contains(board.Body, "/delete") {
447 t.Fatalf("a plain member is shown a delete button: %q", board.Body)
448 }
449 // ...and the route refuses it anyway, which is the half that
450 // counts.
451 var post Post
452 if err := ta.app.db.First(&post).Error; err != nil {
453 t.Fatalf("loading the seeded post: %v", err)
454 }
455 id := strconv.FormatInt(post.ID, 10)
456 want(t, member.post("/posts/"+id+"/delete", nil), http.StatusForbidden, "member deletes a post")
457
458 admin := ta.visitor()
459 want(t, admin.signIn(SeedAdmin), http.StatusSeeOther, "admin signs in")
460 want(t, admin.post("/posts/"+id+"/delete", nil), http.StatusSeeOther, "admin deletes a post")
461
462 // A member may still post: Require admits them, RequireRole is
463 // only on the delete route.
464 want(t, member.post("/posts", url.Values{"body": {"hello"}}), http.StatusSeeOther, "member posts")
465}
466
467// TestCSRFRefusesACrossOriginMutation pins that csrf.Protect is
468// actually mounted app-wide, over idear's routes as well as the app's.
469func TestCSRFRefusesACrossOriginMutation(t *testing.T) {
470 ta := newTestApp(t)
471 ada := ta.visitor()
472 want(t, ada.signUp("ada@example.test", ""), http.StatusSeeOther, "claim")
473
474 req, err := http.NewRequest(http.MethodPost, ta.origin+"/members/invitations",
475 strings.NewReader(url.Values{"email": {"eve@example.test"}, "role": {"admin"}}.Encode()))
476 if err != nil {
477 t.Fatalf("building the request: %v", err)
478 }
479 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
480 req.Header.Set("Origin", "https://attacker.example")
481 res, err := ada.http.Do(req)
482 if err != nil {
483 t.Fatalf("posting: %v", err)
484 }
485 defer res.Body.Close()
486 if res.StatusCode != http.StatusForbidden {
487 t.Fatalf("cross-origin POST to an idear route: status %d, want 403", res.StatusCode)
488 }
489}
490
491// TestSeedProducesThreeRoles: the seed is what makes the role gates
492// clickable, and it is idempotent.
493func TestSeedProducesThreeRoles(t *testing.T) {
494 ta := newTestApp(t)
495 ctx := context.Background()
496 if err := Seed(ctx, ta.app.db, ta.app.roster); err != nil {
497 t.Fatalf("Seed: %v", err)
498 }
499 if err := Seed(ctx, ta.app.db, ta.app.roster); err != nil {
500 t.Fatalf("Seed again: %v", err)
501 }
502 members, err := ta.app.roster.Members(ctx)
503 if err != nil {
504 t.Fatalf("Members: %v", err)
505 }
506 if len(members) != 3 {
507 t.Fatalf("seed produced %d members, want 3: %+v", len(members), members)
508 }
509 byRole := map[idear.Role]string{}
510 for _, m := range members {
511 byRole[m.Role] = m.Email
512 }
513 for role, email := range map[idear.Role]string{
514 idear.RoleOwner: SeedOwner,
515 idear.RoleAdmin: SeedAdmin,
516 idear.RoleMember: SeedMember,
517 } {
518 if byRole[role] != email {
519 t.Errorf("%s is %q, want %q", role, byRole[role], email)
520 }
521 }
522}
523
524// TestSchemaAndModelsAgree is `rastrillo migration check` in test form:
525// replay this app's OWN Schema and diff the result against this app's
526// OWN Models. It goes red the moment a model changes without a
527// migration — and it is also what would catch an idear model being
528// added to Models, since idear's tables reach the database through
529// BootSchema and are not in Schema at all.
530func TestSchemaAndModelsAgree(t *testing.T) {
531 changes, err := migrate.Generate(context.Background(), Schema.All(), Models)
532 if err != nil {
533 t.Fatalf("migrate.Generate: %v", err)
534 }
535 if len(changes) != 0 {
536 for _, c := range changes {
537 t.Errorf("pending change: %+v", c)
538 }
539 t.Fatalf("Schema and Models disagree: %d pending change(s)", len(changes))
540 }
541}
542
543// TestInvitedSignupSurvivesAValidationFailure is the workaround in
544// renderSignup, pinned.
545//
546// password.PageData has Error, Email and ReturnTo and NOWHERE to put
547// an invitation token, so a signup that fails validation re-renders a
548// form whose hidden "invite" field comes back empty unless the app
549// puts it back. The invitee's FIRST attempt then looks fine and their
550// SECOND is refused for holding no token — "invited people can never
551// join", surfacing one step later than the mistake.
552//
553// This is the one piece of copy-this-code an app cannot get from
554// idear's mount, and rewriting the signup page is the likeliest thing
555// a copier does. Deleting idear.TokenFrom(r) from render.go turns the
556// 422 assertion below red.
557func TestInvitedSignupSurvivesAValidationFailure(t *testing.T) {
558 ta := newTestApp(t)
559
560 ada := ta.visitor()
561 want(t, ada.signUp("ada@example.test", ""), http.StatusSeeOther, "claim")
562 res := ada.post("/members/invitations", url.Values{
563 "email": {"kim@example.test"}, "role": {"admin"},
564 })
565 want(t, res, http.StatusSeeOther, "invite")
566 token := ta.tokenFrom(ada.follow(res))
567
568 // A password too short for password's own rule: 422, and the form
569 // comes back. The token must come back WITH it.
570 kim := ta.visitor()
571 res = kim.signUpWith("kim@example.test", token, "short")
572 want(t, res, http.StatusUnprocessableEntity, "signup with a short password")
573 if !strings.Contains(res.Body, `name="invite" value="`+token+`"`) {
574 t.Fatalf("the re-rendered signup form dropped the invitation token: %q", res.Body)
575 }
576
577 // The second attempt — the one a real invitee makes — still works,
578 // and still lands at the invited role rather than being refused.
579 res = kim.signUpWith("kim@example.test", token, "demo-password")
580 want(t, res, http.StatusSeeOther, "second signup attempt")
581 if got := ta.member("kim@example.test"); got.Role != idear.RoleAdmin {
582 t.Fatalf("second attempt admitted at %q, want admin", got.Role)
583 }
584}
585
586// ---------------------------------------------------------------
587// SKILL.md §6 break-glass: rebinding a subject.
588// ---------------------------------------------------------------
589
590// TestBreakGlassRebindsASubject executes the rebind block from
591// SKILL.md §6 — READ OUT OF THE FILE, not restated here — against a
592// database built by this app's own migrations, and then proves the
593// result end to end: sign in over real HTTP as the new subject and
594// perform an Owner-only action with it.
595//
596// Documented recovery SQL that nobody has run is a guess. §6's other
597// block, the ownership transfer, was verified this way and the review
598// found `deactivated_at = NULL` in it load-bearing; this one carries
599// the same clause for the same reason, plus the unique index on
600// subject, which is what turns a careless rebind into a failed UPDATE.
601//
602// Reading the SQL from the document rather than copying it here is the
603// point of the test. A copy can go stale silently; this cannot — edit
604// §6's block into something that does not work and this test is what
605// says so.
606func TestBreakGlassRebindsASubject(t *testing.T) {
607 ta := newTestApp(t)
608 ctx := context.Background()
609 if err := Seed(ctx, ta.app.db, ta.app.roster); err != nil {
610 t.Fatalf("Seed: %v", err)
611 }
612 owner := ta.member(SeedOwner)
613 if owner.Role != idear.RoleOwner {
614 t.Fatalf("the seeded owner is %s", owner.Role)
615 }
616 successorID := ta.memberID(SeedAdmin)
617
618 // The Owner's identity changed. Under keymail that is a new
619 // address; under password — this app — it is a new user row, and
620 // the subject is that row's decimal id. Either way the roster row
621 // still points at the old one, and the person is now a stranger to
622 // every guarded route.
623 const newAddress = "ada.new@example.test"
624 hash, err := password.Hash(SeedPassword)
625 if err != nil {
626 t.Fatalf("password.Hash: %v", err)
627 }
628 newID, err := createUser(ta.app.db)(ctx, newAddress, hash)
629 if err != nil {
630 t.Fatalf("creating the replacement user: %v", err)
631 }
632
633 stranger := ta.visitor()
634 want(t, stranger.signIn(newAddress), http.StatusSeeOther, "sign in as the new identity")
635 if res := stranger.get("/"); res.Status != http.StatusNotFound {
636 t.Fatalf("the new identity reached the board before any rebind: status %d", res.Status)
637 }
638
639 // The break-glass, verbatim: only the three placeholders are
640 // filled in, exactly as an operator editing the block would.
641 script := breakGlassRebind(t, map[string]string{
642 "OLD-SUBJECT": owner.Subject,
643 "NEW-SUBJECT": subjectFor(newID),
644 "NEW-EMAIL": newAddress,
645 })
646 t.Log("\n" + runSQLScript(t, ta, script))
647
648 // End to end, through the front door: a real sign-in, a real
649 // session, and the membership gate.
650 ada := ta.visitor()
651 want(t, ada.signIn(newAddress), http.StatusSeeOther, "sign in as the rebound owner")
652 want(t, ada.get("/"), http.StatusOK, "the board, as the rebound owner")
653
654 // The OWNER-ONLY action. RequireRole(RoleOwner) 403s everybody
655 // else, and Transfer is the only route behind it — so a 303 here
656 // is the rebind having restored ownership and not merely
657 // membership.
658 res := ada.post("/members/transfer", url.Values{
659 "member": {strconv.FormatInt(successorID, 10)},
660 })
661 want(t, res, http.StatusSeeOther, "transfer ownership as the rebound owner")
662 if got := ta.member(SeedAdmin); got.Role != idear.RoleOwner {
663 t.Fatalf("after the transfer %s is %s, want owner", SeedAdmin, got.Role)
664 }
665 if got := ta.member(newAddress); got.ID != owner.ID || got.Role != idear.RoleAdmin {
666 t.Fatalf("the rebound row is %+v, want member %d demoted to admin by its own transfer", got, owner.ID)
667 }
668
669 // And the OLD subject is nobody: the old user row still signs in —
670 // credentials are the app's table, not idear's — and 404s on every
671 // guarded route, which is what a rebind means.
672 old := ta.visitor()
673 want(t, old.signIn(SeedOwner), http.StatusSeeOther, "sign in as the old identity")
674 if res := old.get("/"); res.Status != http.StatusNotFound {
675 t.Fatalf("the old subject still reached the board after the rebind: status %d", res.Status)
676 }
677}
678
679// TestBreakGlassRebindClearsDeactivation is the OTHER half of the §6
680// rebind, and it is why `deactivated_at = NULL` is in that statement.
681//
682// The realistic shape: somebody was offboarded (correctly — §7 says
683// offboarding under keymail MUST deactivate), then came back under a
684// new identity. The row still carries their role and their removal.
685// Rebound without clearing it, the new subject resolves to a
686// deactivated member and 404s on every guarded route — which is
687// indistinguishable, from the operator's side, from the rebind not
688// having worked at all. Delete that clause from SKILL.md and the GET
689// below turns red.
690func TestBreakGlassRebindClearsDeactivation(t *testing.T) {
691 ta := newTestApp(t)
692 ctx := context.Background()
693 if err := Seed(ctx, ta.app.db, ta.app.roster); err != nil {
694 t.Fatalf("Seed: %v", err)
695 }
696 kim := ta.member(SeedAdmin)
697
698 // Removed through the real route, by the real Owner.
699 ada := ta.visitor()
700 want(t, ada.signIn(SeedOwner), http.StatusSeeOther, "sign in as the owner")
701 want(t, ada.post(fmt.Sprintf("/members/%d/remove", kim.ID), url.Values{}),
702 http.StatusSeeOther, "remove the admin")
703 if removed := ta.member(SeedAdmin); removed.Active() {
704 t.Fatal("the admin is still active after remove")
705 }
706
707 const newAddress = "kim.new@example.test"
708 hash, err := password.Hash(SeedPassword)
709 if err != nil {
710 t.Fatalf("password.Hash: %v", err)
711 }
712 newID, err := createUser(ta.app.db)(ctx, newAddress, hash)
713 if err != nil {
714 t.Fatalf("creating the replacement user: %v", err)
715 }
716
717 t.Log("\n" + runSQLScript(t, ta, breakGlassRebind(t, map[string]string{
718 "OLD-SUBJECT": kim.Subject,
719 "NEW-SUBJECT": subjectFor(newID),
720 "NEW-EMAIL": newAddress,
721 })))
722
723 back := ta.visitor()
724 want(t, back.signIn(newAddress), http.StatusSeeOther, "sign in as the rebound admin")
725 want(t, back.get("/"), http.StatusOK, "the board, as the rebound admin")
726 got := ta.member(newAddress)
727 if got.ID != kim.ID || got.Role != idear.RoleAdmin || !got.Active() {
728 t.Fatalf("the rebound row is %+v, want member %d, admin, active", got, kim.ID)
729 }
730}
731
732// breakGlassRebind returns SKILL.md §6's rebind block with its
733// placeholders replaced. The block is located by a placeholder rather
734// than by a heading or an index, so reordering the document does not
735// silently point this at some other SQL.
736func breakGlassRebind(t *testing.T, subs map[string]string) string {
737 t.Helper()
738 doc, err := os.ReadFile(filepath.Join("..", "SKILL.md"))
739 if err != nil {
740 t.Fatalf("reading SKILL.md: %v", err)
741 }
742 block := sqlBlock(t, string(doc), "OLD-SUBJECT")
743 for name, value := range subs {
744 if !strings.Contains(block, "'"+name+"'") {
745 t.Fatalf("SKILL.md's rebind block has no '%s' placeholder to fill in:\n%s", name, block)
746 }
747 // Only the QUOTED placeholder is filled in — the same edit an
748 // operator makes — so the prose above each statement is left
749 // as the document wrote it.
750 block = strings.ReplaceAll(block, "'"+name+"'", "'"+value+"'")
751 }
752 return block
753}
754
755// sqlBlock is the one ```sql fence in doc that mentions needle.
756// Exactly one: two would mean the test is guessing.
757func sqlBlock(t *testing.T, doc, needle string) string {
758 t.Helper()
759 var found []string
760 var cur *strings.Builder
761 for _, line := range strings.Split(doc, "\n") {
762 switch {
763 case cur == nil && strings.TrimSpace(line) == "```sql":
764 cur = &strings.Builder{}
765 case cur != nil && strings.TrimSpace(line) == "```":
766 if strings.Contains(cur.String(), needle) {
767 found = append(found, cur.String())
768 }
769 cur = nil
770 case cur != nil:
771 cur.WriteString(line + "\n")
772 }
773 }
774 if len(found) != 1 {
775 t.Fatalf("SKILL.md has %d sql blocks mentioning %q, want exactly 1", len(found), needle)
776 }
777 return found[0]
778}
779
780// runSQLScript executes the script one statement at a time on a SINGLE
781// writer connection — the sqlite3 session an operator would have — and
782// returns a transcript of what each statement did.
783//
784// One connection is not a detail: the block wraps its UPDATE in
785// BEGIN/COMMIT, and a pool that handed those three statements to
786// different connections would prove nothing about the transaction the
787// document tells an operator to run.
788func runSQLScript(t *testing.T, ta *testApp, script string) string {
789 t.Helper()
790 ctx := context.Background()
791 conn, err := ta.db.Writer().Conn(ctx)
792 if err != nil {
793 t.Fatalf("taking the writer connection: %v", err)
794 }
795 defer conn.Close()
796
797 var out strings.Builder
798 for _, stmt := range strings.Split(script, ";") {
799 stmt = strings.TrimSpace(stmt)
800 if stmt == "" {
801 continue
802 }
803 fmt.Fprintf(&out, "sqlite> %s;\n", stmt)
804 if strings.HasPrefix(strings.ToUpper(bare(stmt)), "SELECT") {
805 fmt.Fprint(&out, query(t, ctx, conn, stmt))
806 continue
807 }
808 res, err := conn.ExecContext(ctx, stmt)
809 if err != nil {
810 t.Fatalf("SKILL.md §6 statement failed: %v\n%s", err, stmt)
811 }
812 switch verb := strings.ToUpper(bare(stmt)); {
813 case strings.HasPrefix(verb, "BEGIN"), strings.HasPrefix(verb, "COMMIT"):
814 // RowsAffected means nothing for these, and the driver
815 // answers 1 for both — a number in the transcript that
816 // looked like a result would be worse than no number.
817 out.WriteString("-- ok\n")
818 default:
819 n, err := res.RowsAffected()
820 if err != nil {
821 t.Fatalf("reading rows affected: %v", err)
822 }
823 fmt.Fprintf(&out, "-- %d row(s)\n", n)
824 }
825 }
826 return out.String()
827}
828
829// bare strips the leading comment lines from a statement, so the
830// SELECT/other decision reads the SQL and not the prose above it.
831func bare(stmt string) string {
832 for _, line := range strings.Split(stmt, "\n") {
833 line = strings.TrimSpace(line)
834 if line != "" && !strings.HasPrefix(line, "--") {
835 return line
836 }
837 }
838 return ""
839}
840
841// query runs a SELECT and renders its rows. Values are scanned into
842// any, not into a typed column list: this prints whatever the schema
843// holds, including the NULL deactivated_at the verification step is
844// looking for.
845func query(t *testing.T, ctx context.Context, conn *sql.Conn, stmt string) string {
846 t.Helper()
847 rows, err := conn.QueryContext(ctx, stmt)
848 if err != nil {
849 t.Fatalf("SKILL.md §6 query failed: %v\n%s", err, stmt)
850 }
851 defer rows.Close()
852 cols, err := rows.Columns()
853 if err != nil {
854 t.Fatalf("reading columns: %v", err)
855 }
856
857 var out strings.Builder
858 fmt.Fprintf(&out, "%s\n", strings.Join(cols, "|"))
859 n := 0
860 for rows.Next() {
861 cells := make([]any, len(cols))
862 into := make([]any, len(cols))
863 for i := range cells {
864 into[i] = &cells[i]
865 }
866 if err := rows.Scan(into...); err != nil {
867 t.Fatalf("scanning a row: %v", err)
868 }
869 parts := make([]string, len(cells))
870 for i, c := range cells {
871 parts[i] = fmt.Sprintf("%v", c)
872 }
873 fmt.Fprintf(&out, "%s\n", strings.Join(parts, "|"))
874 n++
875 }
876 if err := rows.Err(); err != nil {
877 t.Fatalf("reading rows: %v", err)
878 }
879 if n == 0 {
880 out.WriteString("-- no rows\n")
881 }
882 return out.String()
883}
884