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 "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.
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 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.
367func 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.
424func 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.
458func 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.
482func 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.
519func 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.
546func 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.
595func 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.
679func 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.
725func 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.
746func 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.
777func 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.
820func 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.
834func 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