rastrillo / idear Public

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

Plain git — no account needed to clone.

Download

Download this file

1package idear_test
2
3import (
4 "errors"
5 "fmt"
6 "strings"
7 "testing"
8 "time"
9
10 "amadan.net/rastrillo/idear"
11 "amadan.net/rastrillo/idear/internal/ideartest"
12)
13
14// ---------------------------------------------------------------- New
15
16func TestNew_RequiresDB(t *testing.T) {
17 if _, err := idear.New(idear.Config{}); err == nil {
18 t.Fatal("idear.New with no DB returned no error; a Roster over a nil handle panics on first use")
19 }
20}
21
22func TestNew_Defaults(t *testing.T) {
23 h := ideartest.New(t)
24 if got := h.Roster.InviteTTL(); got != 7*24*time.Hour {
25 t.Errorf("default InviteTTL = %v, want 7 days", got)
26 }
27 if h.Roster.OpenSignUp() {
28 t.Error("OpenSignUp defaults to true; an instance must be invite-only unless it says otherwise")
29 }
30}
31
32// TestErrorClasses pins what a handler switches on.
33//
34// A sentinel that belongs to no class falls through to the default arm
35// of the obvious taxonomy — ErrInvalid 400, ErrForbidden 403,
36// ErrNotFound 404, default 500 — and is rendered as a server error. Two
37// of this package's sentinels were exactly that before this test
38// existed, including ErrLastOwner, which is its most
39// security-relevant refusal.
40func TestErrorClasses(t *testing.T) {
41 cases := []struct {
42 name string
43 err error
44 is []error // classes and identities errors.Is MUST find
45 isNot []error // and ones it must NOT
46 }{
47 {
48 name: "ErrLastOwner is a refusal, rendered 403",
49 err: idear.ErrLastOwner,
50 is: []error{idear.ErrLastOwner, idear.ErrForbidden},
51 isNot: []error{idear.ErrInvalid, idear.ErrNotFound},
52 },
53 {
54 // The specific reason must stay distinguishable, or the
55 // ordering TestSetRole_AuthorityFloorComesFirst asserts
56 // becomes untestable.
57 name: "an ordinary ErrForbidden is not ErrLastOwner",
58 err: idear.ErrForbidden,
59 is: []error{idear.ErrForbidden},
60 isNot: []error{idear.ErrLastOwner, idear.ErrInvalid},
61 },
62 {
63 name: "ErrInvalidRole is malformed input, rendered 400",
64 err: idear.ErrInvalidRole,
65 is: []error{idear.ErrInvalidRole, idear.ErrInvalid},
66 isNot: []error{idear.ErrForbidden, idear.ErrInvalidEmail, idear.ErrInvalidSubject},
67 },
68 {
69 name: "ErrInvalidEmail is malformed input, rendered 400",
70 err: idear.ErrInvalidEmail,
71 is: []error{idear.ErrInvalidEmail, idear.ErrInvalid},
72 isNot: []error{idear.ErrForbidden, idear.ErrInvalidRole, idear.ErrInvalidSubject},
73 },
74 {
75 name: "ErrInvalidSubject is malformed input, rendered 400",
76 err: idear.ErrInvalidSubject,
77 is: []error{idear.ErrInvalidSubject, idear.ErrInvalid},
78 isNot: []error{idear.ErrForbidden, idear.ErrInvalidRole, idear.ErrInvalidEmail},
79 },
80 }
81 for _, tc := range cases {
82 t.Run(tc.name, func(t *testing.T) {
83 // Wrapped the way the store wraps them, not bare: that is
84 // the form a handler actually receives.
85 wrapped := fmt.Errorf("%w: some context", tc.err)
86 for _, target := range tc.is {
87 if !errors.Is(wrapped, target) {
88 t.Errorf("errors.Is(%v, %v) = false, want true", wrapped, target)
89 }
90 }
91 for _, target := range tc.isNot {
92 if errors.Is(wrapped, target) {
93 t.Errorf("errors.Is(%v, %v) = true, want false", wrapped, target)
94 }
95 }
96 })
97 }
98}
99
100// -------------------------------------------------------- IsEmpty/Claim
101
102func TestClaim_FirstArrivalBecomesOwner(t *testing.T) {
103 h := ideartest.New(t)
104 ctx := h.Ctx()
105
106 empty, err := h.Roster.IsEmpty(ctx)
107 if err != nil {
108 t.Fatalf("IsEmpty: %v", err)
109 }
110 if !empty {
111 t.Fatal("a fresh roster is not empty")
112 }
113
114 m, err := h.Roster.Claim(ctx, "subject-1", " Founder@Example.TEST ", " Founder ")
115 if err != nil {
116 t.Fatalf("Claim: %v", err)
117 }
118 if m.Role != idear.RoleOwner {
119 t.Errorf("claimant's role = %q, want owner", m.Role)
120 }
121 if m.Email != "founder@example.test" {
122 t.Errorf("claimant's email = %q, want it trimmed and lowercased", m.Email)
123 }
124 if m.Name != "Founder" {
125 t.Errorf("claimant's name = %q, want it trimmed", m.Name)
126 }
127 if got := h.TheOwner(); got.ID != m.ID {
128 t.Errorf("stored owner id = %d, want %d", got.ID, m.ID)
129 }
130}
131
132func TestClaim_SecondClaimRefused(t *testing.T) {
133 h := ideartest.New(t)
134 ctx := h.Ctx()
135
136 if _, err := h.Roster.Claim(ctx, "first", "first@example.test", "First"); err != nil {
137 t.Fatalf("first Claim: %v", err)
138 }
139 _, err := h.Roster.Claim(ctx, "second", "second@example.test", "Second")
140 if !errors.Is(err, idear.ErrOwnerExists) {
141 t.Fatalf("second Claim error = %v, want ErrOwnerExists", err)
142 }
143 if n := h.CountMembers(); n != 1 {
144 t.Errorf("roster holds %d members after a refused claim, want 1", n)
145 }
146}
147
148// TestClaim_DeactivatedRosterDoesNotReopenTheClaim is the "zero rows,
149// not zero ACTIVE rows" rule. A roster whose every member has been
150// deactivated still holds their rows — and their history, and every
151// AuthorID in the app's own tables pointing at them. Reopening the
152// claim would hand a stranger Owner of all of it.
153func TestClaim_DeactivatedRosterDoesNotReopenTheClaim(t *testing.T) {
154 h := ideartest.New(t)
155 ctx := h.Ctx()
156
157 h.Deactivated(idear.RoleOwner)
158 h.Deactivated(idear.RoleMember)
159
160 empty, err := h.Roster.IsEmpty(ctx)
161 if err != nil {
162 t.Fatalf("IsEmpty: %v", err)
163 }
164 if empty {
165 t.Error("IsEmpty says a roster of deactivated members is empty; it counts active rows, not rows")
166 }
167
168 _, err = h.Roster.Claim(ctx, "stranger", "stranger@example.test", "Stranger")
169 if !errors.Is(err, idear.ErrOwnerExists) {
170 t.Fatalf("Claim on an all-deactivated roster = %v, want ErrOwnerExists", err)
171 }
172 if n := h.CountMembers(); n != 2 {
173 t.Errorf("roster holds %d members, want the 2 dormant ones and no stranger", n)
174 }
175}
176
177func TestClaim_RequiresSubject(t *testing.T) {
178 h := ideartest.New(t)
179 _, err := h.Roster.Claim(h.Ctx(), " ", "nobody@example.test", "")
180 if !errors.Is(err, idear.ErrInvalidSubject) || !errors.Is(err, idear.ErrInvalid) {
181 t.Fatalf("Claim with a blank subject = %v, want ErrInvalidSubject in the ErrInvalid class", err)
182 }
183 if n := h.CountMembers(); n != 0 {
184 t.Errorf("roster holds %d members after a refused claim, want 0", n)
185 }
186}
187
188// --------------------------------------------------------------- Invite
189
190// TestInvite_RefusesOwnerRoleForEveryActor is the flat rule: no actor,
191// at any rank, on any path, mints an owner-role invitation. Ownership
192// moves only by Transfer.
193func TestInvite_RefusesOwnerRoleForEveryActor(t *testing.T) {
194 h := ideartest.New(t)
195 owner := h.Owner()
196 admin := h.Member(idear.RoleAdmin)
197 member := h.Member(idear.RoleMember)
198
199 for _, actor := range []*idear.Member{owner, admin, member} {
200 _, _, err := h.Roster.Invite(h.Ctx(), actor, "new@example.test", idear.RoleOwner)
201 if !errors.Is(err, idear.ErrForbidden) {
202 t.Errorf("a %s inviting at owner: err = %v, want ErrForbidden", actor.Role, err)
203 }
204 }
205 if invs, err := h.Roster.PendingInvitations(h.Ctx()); err != nil {
206 t.Fatalf("PendingInvitations: %v", err)
207 } else if len(invs) != 0 {
208 t.Errorf("%d invitations were minted despite every call being refused", len(invs))
209 }
210}
211
212func TestInvite_RankRules(t *testing.T) {
213 h := ideartest.New(t)
214 owner := h.Owner()
215 admin := h.Member(idear.RoleAdmin)
216 member := h.Member(idear.RoleMember)
217 gone := h.Deactivated(idear.RoleAdmin)
218
219 cases := []struct {
220 name string
221 actor *idear.Member
222 role idear.Role
223 ok bool
224 }{
225 {"owner invites admin", owner, idear.RoleAdmin, true},
226 {"owner invites member", owner, idear.RoleMember, true},
227 {"admin invites member", admin, idear.RoleMember, true},
228 // An admin who could mint a peer admin has escalated: MayActOn
229 // refuses acting on an equal rank, so the new admin would be
230 // beyond the granter's reach and every other admin's too.
231 {"admin invites admin", admin, idear.RoleAdmin, false},
232 {"member invites member", member, idear.RoleMember, false},
233 // The actor is re-read inside the transaction, so a privilege
234 // revoked a moment ago is revoked for this call too.
235 {"deactivated admin invites member", gone, idear.RoleMember, false},
236 }
237 for _, tc := range cases {
238 t.Run(tc.name, func(t *testing.T) {
239 _, _, err := h.Roster.Invite(h.Ctx(), tc.actor, "invitee@example.test", tc.role)
240 if tc.ok && err != nil {
241 t.Fatalf("Invite = %v, want success", err)
242 }
243 if !tc.ok && !errors.Is(err, idear.ErrForbidden) {
244 t.Fatalf("Invite = %v, want ErrForbidden", err)
245 }
246 })
247 }
248}
249
250// TestInvite_RejectsBlankEmail is the sibling of
251// TestInvite_RejectsUnknownRole: the role field and the address field
252// of one invitation form must classify the same way, or a mistyped
253// address renders 500 while a mistyped role renders 400.
254func TestInvite_RejectsBlankEmail(t *testing.T) {
255 h := ideartest.New(t)
256 owner := h.Owner()
257 for _, email := range []string{"", " ", "\t\n"} {
258 _, _, err := h.Roster.Invite(h.Ctx(), owner, email, idear.RoleMember)
259 if !errors.Is(err, idear.ErrInvalidEmail) || !errors.Is(err, idear.ErrInvalid) {
260 t.Errorf("Invite(%q) = %v, want ErrInvalidEmail in the ErrInvalid class", email, err)
261 }
262 }
263 if invs, err := h.Roster.PendingInvitations(h.Ctx()); err != nil {
264 t.Fatalf("PendingInvitations: %v", err)
265 } else if len(invs) != 0 {
266 t.Errorf("%d invitations were minted from a blank address", len(invs))
267 }
268}
269
270func TestInvite_RejectsUnknownRole(t *testing.T) {
271 h := ideartest.New(t)
272 owner := h.Owner()
273 _, _, err := h.Roster.Invite(h.Ctx(), owner, "invitee@example.test", idear.Role("superuser"))
274 if !errors.Is(err, idear.ErrInvalidRole) {
275 t.Fatalf("Invite at role \"superuser\" = %v, want ErrInvalidRole", err)
276 }
277}
278
279// TestInvite_StoresOnlyTheHash pins the "hashed at rest" rule from the
280// design's §4: the plaintext exists in the return value and the emitted
281// link, and nowhere in the table.
282func TestInvite_StoresOnlyTheHash(t *testing.T) {
283 h := ideartest.New(t)
284 owner := h.Owner()
285
286 inv, token, err := h.Roster.Invite(h.Ctx(), owner, "Invitee@Example.TEST", idear.RoleMember)
287 if err != nil {
288 t.Fatalf("Invite: %v", err)
289 }
290 if token == "" {
291 t.Fatal("Invite returned an empty token")
292 }
293 stored := h.Invitation(inv.ID)
294 if stored.TokenHash == token {
295 t.Error("the plaintext token is what is stored; only its digest may be")
296 }
297 if strings.Contains(stored.TokenHash, token) || strings.Contains(stored.Email, token) {
298 t.Error("the plaintext token appears in a stored column")
299 }
300 if stored.Email != "invitee@example.test" {
301 t.Errorf("stored email = %q, want it normalised", stored.Email)
302 }
303 if stored.InvitedBy != owner.ID {
304 t.Errorf("InvitedBy = %d, want the inviting owner %d", stored.InvitedBy, owner.ID)
305 }
306 // The digest is only demonstrably the RIGHT digest if the token
307 // still redeems — asserting sha256(token) here would just restate
308 // the implementation back to itself.
309 if _, err := h.Roster.Accept(h.Ctx(), token, "invitee-subject", "Invitee"); err != nil {
310 t.Fatalf("Accept with the returned token: %v", err)
311 }
312}
313
314func TestInvite_ExpiresAtHonoursTTL(t *testing.T) {
315 h := ideartest.NewWith(t, idear.Config{InviteTTL: time.Minute})
316 owner := h.Owner()
317
318 before := time.Now().UTC()
319 inv, _, err := h.Roster.Invite(h.Ctx(), owner, "invitee@example.test", idear.RoleMember)
320 if err != nil {
321 t.Fatalf("Invite: %v", err)
322 }
323 after := time.Now().UTC()
324 if inv.ExpiresAt.Before(before.Add(time.Minute)) || inv.ExpiresAt.After(after.Add(time.Minute)) {
325 t.Errorf("ExpiresAt = %v, want about %v", inv.ExpiresAt, before.Add(time.Minute))
326 }
327}
328
329// --------------------------------------------------------------- Revoke
330
331func TestRevoke_KillsAPendingInvitation(t *testing.T) {
332 h := ideartest.New(t)
333 ctx := h.Ctx()
334 owner := h.Owner()
335
336 inv, token, err := h.Roster.Invite(ctx, owner, "invitee@example.test", idear.RoleMember)
337 if err != nil {
338 t.Fatalf("Invite: %v", err)
339 }
340 if err := h.Roster.Revoke(ctx, owner, inv.ID); err != nil {
341 t.Fatalf("Revoke: %v", err)
342 }
343 if h.Invitation(inv.ID).RevokedAt == nil {
344 t.Error("RevokedAt is still NULL after Revoke")
345 }
346 if _, err := h.Roster.Accept(ctx, token, "invitee-subject", "Invitee"); !errors.Is(err, idear.ErrNoInvitation) {
347 t.Errorf("Accept of a revoked invitation = %v, want ErrNoInvitation", err)
348 }
349}
350
351// TestInvite_SupersedesAPendingInvitationForTheSameAddress pins the
352// only semantics an admin would predict: re-inviting REPLACES.
353//
354// Without it two live invitations for one address coexist and the two
355// identity paths disagree about which one is spent. keymail redeems
356// the OLDEST redeemable row, so re-inviting Alice at a corrected
357// higher role is silently ignored — the admin's members page says
358// Admin and Alice arrives as Member — and a corrected LOWER role
359// leaves the stale higher one live for her to escalate past the
360// correction. password spends whichever of the several links the
361// invitee happens to click.
362//
363// Both redemption paths are driven, in two instances, because "both
364// land at the new role" is the claim and one path proving it is half
365// an answer.
366func TestInvite_SupersedesAPendingInvitationForTheSameAddress(t *testing.T) {
367 // The mistake being corrected: Member, then Admin. The second
368 // invite spells the address differently on purpose — invitations
369 // are stored normalised, so the supersede must match on the
370 // normalised form or it silently does nothing.
371 const address = "alice@example.test"
372 const retyped = " Alice@Example.Test "
373
374 t.Run("password redeems the new token", func(t *testing.T) {
375 h := ideartest.New(t)
376 ctx := h.Ctx()
377 owner := h.Owner()
378
379 stale, staleToken, err := h.Roster.Invite(ctx, owner, address, idear.RoleMember)
380 if err != nil {
381 t.Fatalf("first Invite: %v", err)
382 }
383 fresh, freshToken, err := h.Roster.Invite(ctx, owner, retyped, idear.RoleAdmin)
384 if err != nil {
385 t.Fatalf("second Invite: %v", err)
386 }
387
388 // Exactly one invitation is live, and it is the new one.
389 pending, err := h.Roster.PendingInvitations(ctx)
390 if err != nil {
391 t.Fatalf("PendingInvitations: %v", err)
392 }
393 if len(pending) != 1 || pending[0].ID != fresh.ID {
394 t.Fatalf("pending invitations = %+v, want only the re-invitation %d", pending, fresh.ID)
395 }
396 // The stale one is REVOKED — not accepted, not deleted: the
397 // record of what was offered and withdrawn stays true.
398 switch old := h.Invitation(stale.ID); {
399 case old.RevokedAt == nil:
400 t.Error("re-inviting left the earlier invitation live; the invitee can still land at the superseded role")
401 case old.AcceptedAt != nil:
402 t.Error("the superseded invitation was marked accepted; nobody accepted it")
403 }
404
405 // And the stale token is spent as far as anyone holding it is
406 // concerned.
407 if _, err := h.Roster.Accept(ctx, staleToken, "alice-subject", "Alice"); !errors.Is(err, idear.ErrNoInvitation) {
408 t.Fatalf("the superseded token was redeemable: %v", err)
409 }
410 m, err := h.Roster.Accept(ctx, freshToken, "alice-subject", "Alice")
411 if err != nil {
412 t.Fatalf("Accept of the re-invitation: %v", err)
413 }
414 if m.Role != idear.RoleAdmin {
415 t.Errorf("password admitted %s at %s, want the corrected admin", address, m.Role)
416 }
417 })
418
419 t.Run("keymail redeems by address", func(t *testing.T) {
420 h := ideartest.New(t)
421 ctx := h.Ctx()
422 owner := h.Owner()
423
424 if _, _, err := h.Roster.Invite(ctx, owner, address, idear.RoleMember); err != nil {
425 t.Fatalf("first Invite: %v", err)
426 }
427 if _, _, err := h.Roster.Invite(ctx, owner, retyped, idear.RoleAdmin); err != nil {
428 t.Fatalf("second Invite: %v", err)
429 }
430
431 // Authorize is keymail's whole admission: a verified address,
432 // no token, and acceptByAddress underneath it taking the
433 // oldest redeemable row. Before the supersede that row was the
434 // stale Member one.
435 if !h.Roster.Authorize(address) {
436 t.Fatal("Authorize refused an address holding a live invitation")
437 }
438 var admitted []idear.Member
439 if err := h.DB.G.Where("subject = ?", address).Find(&admitted).Error; err != nil {
440 t.Fatalf("looking up the admitted member: %v", err)
441 }
442 if len(admitted) != 1 {
443 t.Fatalf("roster holds %d rows for %q, want 1", len(admitted), address)
444 }
445 if admitted[0].Role != idear.RoleAdmin {
446 t.Errorf("keymail admitted %s at %s, want the corrected admin", address, admitted[0].Role)
447 }
448 })
449}
450
451func TestRevoke_Refusals(t *testing.T) {
452 h := ideartest.New(t)
453 ctx := h.Ctx()
454 owner := h.Owner()
455 member := h.Member(idear.RoleMember)
456
457 inv, _, err := h.Roster.Invite(ctx, owner, "invitee@example.test", idear.RoleMember)
458 if err != nil {
459 t.Fatalf("Invite: %v", err)
460 }
461
462 if err := h.Roster.Revoke(ctx, member, inv.ID); !errors.Is(err, idear.ErrForbidden) {
463 t.Errorf("a member revoking = %v, want ErrForbidden", err)
464 }
465 if err := h.Roster.Revoke(ctx, owner, inv.ID+9999); !errors.Is(err, idear.ErrNoInvitation) {
466 t.Errorf("revoking an unknown id = %v, want ErrNoInvitation", err)
467 }
468 if err := h.Roster.Revoke(ctx, owner, inv.ID); err != nil {
469 t.Fatalf("Revoke: %v", err)
470 }
471 if err := h.Roster.Revoke(ctx, owner, inv.ID); !errors.Is(err, idear.ErrNoInvitation) {
472 t.Errorf("revoking twice = %v, want ErrNoInvitation", err)
473 }
474}
475
476// --------------------------------------------------------------- Accept
477
478func TestAccept_AdmitsAtTheInvitedRole(t *testing.T) {
479 h := ideartest.New(t)
480 ctx := h.Ctx()
481 owner := h.Owner()
482
483 inv, token, err := h.Roster.Invite(ctx, owner, "invitee@example.test", idear.RoleAdmin)
484 if err != nil {
485 t.Fatalf("Invite: %v", err)
486 }
487 m, err := h.Roster.Accept(ctx, token, "invitee-subject", "Invitee")
488 if err != nil {
489 t.Fatalf("Accept: %v", err)
490 }
491 if m.Role != idear.RoleAdmin {
492 t.Errorf("admitted at %q, want the invited role admin", m.Role)
493 }
494 if m.Email != "invitee@example.test" {
495 t.Errorf("member email = %q, want the invitation's address", m.Email)
496 }
497 if h.Invitation(inv.ID).AcceptedAt == nil {
498 t.Error("AcceptedAt is still NULL after a successful Accept")
499 }
500}
501
502func TestAccept_Refusals(t *testing.T) {
503 h := ideartest.New(t)
504 ctx := h.Ctx()
505 owner := h.Owner()
506
507 t.Run("unknown token", func(t *testing.T) {
508 _, err := h.Roster.Accept(ctx, "not-a-token", "s1", "One")
509 if !errors.Is(err, idear.ErrNoInvitation) {
510 t.Fatalf("err = %v, want ErrNoInvitation", err)
511 }
512 })
513
514 t.Run("empty token", func(t *testing.T) {
515 _, err := h.Roster.Accept(ctx, "", "s2", "Two")
516 if !errors.Is(err, idear.ErrNoInvitation) {
517 t.Fatalf("err = %v, want ErrNoInvitation", err)
518 }
519 })
520
521 t.Run("replay", func(t *testing.T) {
522 _, token, err := h.Roster.Invite(ctx, owner, "replay@example.test", idear.RoleMember)
523 if err != nil {
524 t.Fatalf("Invite: %v", err)
525 }
526 if _, err := h.Roster.Accept(ctx, token, "replay-1", "One"); err != nil {
527 t.Fatalf("first Accept: %v", err)
528 }
529 _, err = h.Roster.Accept(ctx, token, "replay-2", "Two")
530 if !errors.Is(err, idear.ErrNoInvitation) {
531 t.Fatalf("replayed Accept = %v, want ErrNoInvitation", err)
532 }
533 if _, err := h.Roster.BySubject(ctx, "replay-2"); !errors.Is(err, idear.ErrNotFound) {
534 t.Fatalf("the replayer was admitted anyway: %v", err)
535 }
536 })
537
538 t.Run("expired", func(t *testing.T) {
539 inv, token, err := h.Roster.Invite(ctx, owner, "expired@example.test", idear.RoleMember)
540 if err != nil {
541 t.Fatalf("Invite: %v", err)
542 }
543 h.Expire(inv.ID)
544 _, err = h.Roster.Accept(ctx, token, "expired-subject", "Expired")
545 if !errors.Is(err, idear.ErrNoInvitation) {
546 t.Fatalf("Accept of an expired invitation = %v, want ErrNoInvitation", err)
547 }
548 if h.Invitation(inv.ID).AcceptedAt != nil {
549 t.Error("the expired invitation was consumed by the refused Accept")
550 }
551 })
552
553 t.Run("blank subject", func(t *testing.T) {
554 inv, token, err := h.Roster.Invite(ctx, owner, "blank@example.test", idear.RoleMember)
555 if err != nil {
556 t.Fatalf("Invite: %v", err)
557 }
558 _, err = h.Roster.Accept(ctx, token, " ", "Blank")
559 if !errors.Is(err, idear.ErrInvalidSubject) || !errors.Is(err, idear.ErrInvalid) {
560 t.Fatalf("Accept with a blank subject = %v, want ErrInvalidSubject in the ErrInvalid class", err)
561 }
562 if h.Invitation(inv.ID).AcceptedAt != nil {
563 t.Error("the invitation was consumed by the refused Accept")
564 }
565 })
566}
567
568// TestAccept_RefusesAForgedOwnerInvitation is defence in depth. Invite
569// cannot mint one, so a row carrying role='owner' is corruption — and
570// corruption must not become a second Owner.
571func TestAccept_RefusesAForgedOwnerInvitation(t *testing.T) {
572 h := ideartest.New(t)
573 ctx := h.Ctx()
574 owner := h.Owner()
575
576 inv, token, err := h.Roster.Invite(ctx, owner, "forged@example.test", idear.RoleMember)
577 if err != nil {
578 t.Fatalf("Invite: %v", err)
579 }
580 // Forge it behind the store's back, the way a stray migration or a
581 // hand-edited database would.
582 if err := h.DB.G.Model(&idear.Invitation{}).Where("id = ?", inv.ID).
583 Update("role", idear.RoleOwner).Error; err != nil {
584 t.Fatalf("forging the row: %v", err)
585 }
586
587 if _, err := h.Roster.Accept(ctx, token, "forged-subject", "Forged"); !errors.Is(err, idear.ErrForbidden) {
588 t.Fatalf("Accept of an owner-role invitation = %v, want ErrForbidden", err)
589 }
590 h.TheOwner() // still exactly one, and it is not the forger
591 if _, err := h.Roster.BySubject(ctx, "forged-subject"); !errors.Is(err, idear.ErrNotFound) {
592 t.Errorf("the forger was admitted: %v", err)
593 }
594 if h.Invitation(inv.ID).AcceptedAt != nil {
595 t.Error("the forged invitation was consumed; the whole transaction should have rolled back")
596 }
597}
598
599// TestAccept_ReturningMemberCollidesAndRollsBack pins the design's §4
600// corollary: a deactivated member cannot be readmitted by a fresh
601// invitation, because Subject is unique and their row is still there.
602// Reactivate is the path — and the invitation must survive the failure
603// intact rather than being burnt by it.
604func TestAccept_ReturningMemberCollidesAndRollsBack(t *testing.T) {
605 h := ideartest.New(t)
606 ctx := h.Ctx()
607 owner := h.Owner()
608 gone := h.Deactivated(idear.RoleMember)
609
610 inv, token, err := h.Roster.Invite(ctx, owner, gone.Email, idear.RoleMember)
611 if err != nil {
612 t.Fatalf("Invite: %v", err)
613 }
614 if _, err := h.Roster.Accept(ctx, token, gone.Subject, gone.Name); err == nil {
615 t.Fatal("Accept re-admitted a deactivated subject; the unique index should have refused it")
616 }
617 if h.Invitation(inv.ID).AcceptedAt != nil {
618 t.Error("the invitation was consumed by the failed Accept; the transaction did not roll back")
619 }
620 if h.Reload(gone.ID).Active() {
621 t.Error("the failed Accept reactivated the dormant row")
622 }
623}
624
625// -------------------------------------------------------------- SetRole
626
627func TestSetRole_RefusesOwner(t *testing.T) {
628 h := ideartest.New(t)
629 ctx := h.Ctx()
630 owner := h.Owner()
631 admin := h.Member(idear.RoleAdmin)
632
633 // As the new role: ownership moves only by Transfer.
634 if err := h.Roster.SetRole(ctx, owner, admin, idear.RoleOwner); !errors.Is(err, idear.ErrForbidden) {
635 t.Errorf("SetRole(..., owner) = %v, want ErrForbidden", err)
636 }
637 if got := h.Reload(admin.ID).Role; got != idear.RoleAdmin {
638 t.Errorf("target's role is now %q; a refused SetRole wrote anyway", got)
639 }
640 h.TheOwner()
641
642 // As the target: the Owner's role changes only by Transfer either,
643 // and this is an invariant rather than a permission — no rank can
644 // do it, so ErrLastOwner is the honest answer, not ErrForbidden.
645 if err := h.Roster.SetRole(ctx, admin, owner, idear.RoleMember); !errors.Is(err, idear.ErrLastOwner) {
646 t.Errorf("SetRole demoting the owner = %v, want ErrLastOwner", err)
647 }
648 if got := h.Reload(owner.ID).Role; got != idear.RoleOwner {
649 t.Errorf("owner's role is now %q", got)
650 }
651}
652
653// TestSetRole_AuthorityFloorComesFirst pins the ORDER of the checks. A
654// plain Member aiming at the Owner must be refused for lacking
655// authority — ErrForbidden — and not handed ErrLastOwner, which would
656// answer "which row is the owner" to someone who had not established
657// they may ask anything at all.
658func TestSetRole_AuthorityFloorComesFirst(t *testing.T) {
659 h := ideartest.New(t)
660 owner := h.Owner()
661 member := h.Member(idear.RoleMember)
662
663 err := h.Roster.SetRole(h.Ctx(), member, owner, idear.RoleMember)
664 if !errors.Is(err, idear.ErrForbidden) {
665 t.Fatalf("a member demoting the owner = %v, want ErrForbidden", err)
666 }
667 if errors.Is(err, idear.ErrLastOwner) {
668 t.Error("a member was told which row is the owner")
669 }
670}
671
672func TestSetRole_Matrix(t *testing.T) {
673 h := ideartest.New(t)
674 ctx := h.Ctx()
675 owner := h.Owner()
676 admin := h.Member(idear.RoleAdmin)
677 member := h.Member(idear.RoleMember)
678 other := h.Member(idear.RoleMember)
679
680 if err := h.Roster.SetRole(ctx, owner, member, idear.RoleAdmin); err != nil {
681 t.Fatalf("owner promoting a member to admin: %v", err)
682 }
683 if got := h.Reload(member.ID).Role; got != idear.RoleAdmin {
684 t.Errorf("role = %q, want admin", got)
685 }
686
687 // An admin minting a peer admin is an escalation: MayActOn refuses
688 // acting on an equal rank, so the new admin is beyond reach.
689 if err := h.Roster.SetRole(ctx, admin, other, idear.RoleAdmin); !errors.Is(err, idear.ErrForbidden) {
690 t.Errorf("admin promoting to admin = %v, want ErrForbidden", err)
691 }
692 if err := h.Roster.SetRole(ctx, admin, other, idear.RoleMember); err != nil {
693 t.Errorf("admin setting a member to member: %v", err)
694 }
695 if err := h.Roster.SetRole(ctx, admin, admin, idear.RoleMember); !errors.Is(err, idear.ErrForbidden) {
696 t.Errorf("admin demoting themselves = %v, want ErrForbidden", err)
697 }
698 if err := h.Roster.SetRole(ctx, owner, other, idear.Role("root")); !errors.Is(err, idear.ErrInvalidRole) {
699 t.Errorf("SetRole to \"root\" = %v, want ErrInvalidRole", err)
700 }
701 if err := h.Roster.SetRole(ctx, owner, &idear.Member{ID: 9999}, idear.RoleMember); !errors.Is(err, idear.ErrNotFound) {
702 t.Errorf("SetRole on an unknown id = %v, want ErrNotFound", err)
703 }
704}
705
706// ----------------------------------------------------------- Deactivate
707
708// TestDeactivate_NeverRemovesTheOwner is the invariant that keeps an
709// instance administrable. It is checked against the row inside the
710// transaction, which is what also makes Transfer racing Deactivate safe
711// from this side.
712func TestDeactivate_NeverRemovesTheOwner(t *testing.T) {
713 h := ideartest.New(t)
714 owner := h.Owner()
715 admin := h.Member(idear.RoleAdmin)
716
717 if err := h.Roster.Deactivate(h.Ctx(), admin, owner); !errors.Is(err, idear.ErrLastOwner) {
718 t.Fatalf("an admin deactivating the owner = %v, want ErrLastOwner", err)
719 }
720 if !h.Reload(owner.ID).Active() {
721 t.Fatal("the owner was deactivated")
722 }
723 // The owner cannot do it to themselves either.
724 if err := h.Roster.Deactivate(h.Ctx(), owner, owner); err == nil {
725 t.Error("the owner deactivated themselves")
726 }
727 if !h.Reload(owner.ID).Active() {
728 t.Error("the owner was deactivated")
729 }
730}
731
732func TestDeactivate_Matrix(t *testing.T) {
733 h := ideartest.New(t)
734 ctx := h.Ctx()
735 owner := h.Owner()
736 admin := h.Member(idear.RoleAdmin)
737 otherAdmin := h.Member(idear.RoleAdmin)
738 member := h.Member(idear.RoleMember)
739
740 if err := h.Roster.Deactivate(ctx, admin, member); err != nil {
741 t.Fatalf("admin deactivating a member: %v", err)
742 }
743 if h.Reload(member.ID).Active() {
744 t.Error("the member is still active")
745 }
746 // Idempotent: removing someone already removed is not an error.
747 if err := h.Roster.Deactivate(ctx, admin, member); err != nil {
748 t.Errorf("deactivating twice = %v, want nil", err)
749 }
750 if err := h.Roster.Deactivate(ctx, admin, otherAdmin); !errors.Is(err, idear.ErrForbidden) {
751 t.Errorf("admin deactivating a peer admin = %v, want ErrForbidden", err)
752 }
753 if err := h.Roster.Deactivate(ctx, member, otherAdmin); !errors.Is(err, idear.ErrForbidden) {
754 t.Errorf("a deactivated member deactivating anyone = %v, want ErrForbidden", err)
755 }
756 if err := h.Roster.Deactivate(ctx, owner, admin); err != nil {
757 t.Errorf("owner deactivating an admin: %v", err)
758 }
759}
760
761// ----------------------------------------------------------- Reactivate
762
763func TestReactivate_RestoresPriorAccessAndNoMore(t *testing.T) {
764 h := ideartest.New(t)
765 ctx := h.Ctx()
766 owner := h.Owner()
767 gone := h.Deactivated(idear.RoleAdmin)
768
769 if err := h.Roster.Reactivate(ctx, owner, gone); err != nil {
770 t.Fatalf("Reactivate: %v", err)
771 }
772 back := h.Reload(gone.ID)
773 if !back.Active() {
774 t.Fatal("the member is still deactivated")
775 }
776 if back.Role != idear.RoleAdmin {
777 t.Errorf("role after reactivation = %q, want the admin they were", back.Role)
778 }
779 // Idempotent.
780 if err := h.Roster.Reactivate(ctx, owner, back); err != nil {
781 t.Errorf("reactivating an active member = %v, want nil", err)
782 }
783 h.TheOwner()
784}
785
786func TestReactivate_Refusals(t *testing.T) {
787 h := ideartest.New(t)
788 ctx := h.Ctx()
789 member := h.Member(idear.RoleMember)
790 admin := h.Member(idear.RoleAdmin)
791 goneAdmin := h.Deactivated(idear.RoleAdmin)
792
793 if err := h.Roster.Reactivate(ctx, member, goneAdmin); !errors.Is(err, idear.ErrForbidden) {
794 t.Errorf("a member reactivating = %v, want ErrForbidden", err)
795 }
796 if err := h.Roster.Reactivate(ctx, admin, goneAdmin); !errors.Is(err, idear.ErrForbidden) {
797 t.Errorf("an admin reactivating a peer admin = %v, want ErrForbidden", err)
798 }
799 if h.Reload(goneAdmin.ID).Active() {
800 t.Error("a refused Reactivate wrote anyway")
801 }
802}
803
804// ------------------------------------------------------------- Transfer
805
806func TestTransfer_MovesOwnershipExactlyOnce(t *testing.T) {
807 h := ideartest.New(t)
808 ctx := h.Ctx()
809 owner := h.Owner()
810 admin := h.Member(idear.RoleAdmin)
811
812 if err := h.Roster.Transfer(ctx, owner, admin); err != nil {
813 t.Fatalf("Transfer: %v", err)
814 }
815 if got := h.TheOwner(); got.ID != admin.ID {
816 t.Errorf("owner is member %d, want %d", got.ID, admin.ID)
817 }
818 if got := h.Reload(owner.ID).Role; got != idear.RoleAdmin {
819 t.Errorf("outgoing owner's role = %q, want admin", got)
820 }
821 // The outgoing owner is no longer the owner, so they cannot
822 // transfer again.
823 if err := h.Roster.Transfer(ctx, owner, h.Member(idear.RoleMember)); !errors.Is(err, idear.ErrForbidden) {
824 t.Errorf("a former owner transferring = %v, want ErrForbidden", err)
825 }
826 h.TheOwner()
827}
828
829func TestTransfer_Refusals(t *testing.T) {
830 h := ideartest.New(t)
831 ctx := h.Ctx()
832 owner := h.Owner()
833 admin := h.Member(idear.RoleAdmin)
834 gone := h.Deactivated(idear.RoleAdmin)
835
836 if err := h.Roster.Transfer(ctx, admin, owner); !errors.Is(err, idear.ErrForbidden) {
837 t.Errorf("a non-owner transferring = %v, want ErrForbidden", err)
838 }
839 if err := h.Roster.Transfer(ctx, owner, owner); !errors.Is(err, idear.ErrForbidden) {
840 t.Errorf("transferring to oneself = %v, want ErrForbidden", err)
841 }
842 // The one that matters: a deactivated Owner is an instance nobody
843 // can administer and nobody can be promoted into.
844 if err := h.Roster.Transfer(ctx, owner, gone); !errors.Is(err, idear.ErrForbidden) {
845 t.Errorf("transferring to a deactivated member = %v, want ErrForbidden", err)
846 }
847 if err := h.Roster.Transfer(ctx, owner, &idear.Member{ID: 9999}); !errors.Is(err, idear.ErrNotFound) {
848 t.Errorf("transferring to an unknown id = %v, want ErrNotFound", err)
849 }
850 if got := h.TheOwner(); got.ID != owner.ID {
851 t.Errorf("ownership moved despite every transfer being refused (now %d)", got.ID)
852 }
853}
854
855// -------------------------------------------------------------- Lookups
856
857func TestBySubjectAndByID(t *testing.T) {
858 h := ideartest.New(t)
859 ctx := h.Ctx()
860 owner := h.Owner()
861 gone := h.Deactivated(idear.RoleMember)
862
863 got, err := h.Roster.BySubject(ctx, owner.Subject)
864 if err != nil || got.ID != owner.ID {
865 t.Fatalf("BySubject = (%v, %v), want the owner", got, err)
866 }
867 // Deactivated members resolve: the middleware has to tell a
868 // dormant member from a stranger in order to log the difference,
869 // even though it answers both with the app's 404.
870 got, err = h.Roster.BySubject(ctx, gone.Subject)
871 if err != nil {
872 t.Fatalf("BySubject on a deactivated member = %v, want the row", err)
873 }
874 if got.Active() {
875 t.Error("the deactivated member reads as active")
876 }
877 if _, err := h.Roster.BySubject(ctx, "nobody"); !errors.Is(err, idear.ErrNotFound) {
878 t.Errorf("BySubject on a stranger = %v, want ErrNotFound", err)
879 }
880 // An empty subject is a request with no session, never a wildcard.
881 if _, err := h.Roster.BySubject(ctx, " "); !errors.Is(err, idear.ErrNotFound) {
882 t.Errorf("BySubject(\"\") = %v, want ErrNotFound", err)
883 }
884 if _, err := h.Roster.ByID(ctx, 9999); !errors.Is(err, idear.ErrNotFound) {
885 t.Errorf("ByID on an unknown id = %v, want ErrNotFound", err)
886 }
887 if m, err := h.Roster.ByID(ctx, owner.ID); err != nil || m.Role != idear.RoleOwner {
888 t.Errorf("ByID = (%v, %v), want the owner", m, err)
889 }
890}
891
892func TestMembers_RankedAndIncludesDeactivated(t *testing.T) {
893 h := ideartest.New(t)
894 member := h.Member(idear.RoleMember)
895 admin := h.Member(idear.RoleAdmin)
896 owner := h.Owner()
897 gone := h.Deactivated(idear.RoleMember)
898
899 got, err := h.Roster.Members(h.Ctx())
900 if err != nil {
901 t.Fatalf("Members: %v", err)
902 }
903 want := []int64{owner.ID, admin.ID, member.ID, gone.ID}
904 if len(got) != len(want) {
905 t.Fatalf("Members returned %d rows, want %d — deactivated members must be listed so they can be restored", len(got), len(want))
906 }
907 for i, id := range want {
908 if got[i].ID != id {
909 t.Errorf("Members[%d].ID = %d, want %d (owner, then admins, then members, then by id)", i, got[i].ID, id)
910 }
911 }
912}
913
914func TestPendingInvitations_HidesSpentAndExpired(t *testing.T) {
915 h := ideartest.New(t)
916 ctx := h.Ctx()
917 owner := h.Owner()
918
919 live, _, err := h.Roster.Invite(ctx, owner, "live@example.test", idear.RoleMember)
920 if err != nil {
921 t.Fatalf("Invite: %v", err)
922 }
923 revoked, _, err := h.Roster.Invite(ctx, owner, "revoked@example.test", idear.RoleMember)
924 if err != nil {
925 t.Fatalf("Invite: %v", err)
926 }
927 if err := h.Roster.Revoke(ctx, owner, revoked.ID); err != nil {
928 t.Fatalf("Revoke: %v", err)
929 }
930 accepted, token, err := h.Roster.Invite(ctx, owner, "accepted@example.test", idear.RoleMember)
931 if err != nil {
932 t.Fatalf("Invite: %v", err)
933 }
934 if _, err := h.Roster.Accept(ctx, token, "accepted-subject", "Accepted"); err != nil {
935 t.Fatalf("Accept: %v", err)
936 }
937 expired, _, err := h.Roster.Invite(ctx, owner, "expired@example.test", idear.RoleMember)
938 if err != nil {
939 t.Fatalf("Invite: %v", err)
940 }
941 h.Expire(expired.ID)
942
943 got, err := h.Roster.PendingInvitations(ctx)
944 if err != nil {
945 t.Fatalf("PendingInvitations: %v", err)
946 }
947 if len(got) != 1 || got[0].ID != live.ID {
948 ids := make([]int64, len(got))
949 for i, inv := range got {
950 ids[i] = inv.ID
951 }
952 t.Fatalf("PendingInvitations returned %v, want only the live one (%d); revoked=%d accepted=%d expired=%d",
953 ids, live.ID, revoked.ID, accepted.ID, expired.ID)
954 }
955}
956
957// TestExpiryComparisonMatchesGo pins the assumption underneath Accept's
958// CAS, which is load-bearing and invisible.
959//
960// Timestamps reach SQLite as text, in the driver's own rendering of
961// time.Time.String(), in columns whose declared type is DATETIME. The
962// CAS asks the database "expires_at > ?", and that is a TEXT
963// comparison. It answers correctly only because the rendering is
964// fixed-width through the seconds and sorts lexicographically the way
965// the instants sort — including across the boundary where the
966// fractional part is trimmed away entirely.
967//
968// Two things break it, and both are pinned below.
969//
970// A monotonic clock reading: String() appends " m=+0.0001" to any time
971// that still carries one, so the stored text no longer has the shape
972// the comparison assumes. .UTC() strips it; time.Now() alone does not,
973// and neither does Add on top of it.
974//
975// A non-UTC location, which is the worse of the two because it is
976// silent and seasonal: String() renders the zone, so a value built in
977// Europe/Dublin stores as "... +0100 IST" and sorts against a UTC row
978// by the offset characters — an answer with no relationship to which
979// instant is later. An invitation minted in summer could outlive one
980// minted in winter regardless of its actual expiry.
981func TestExpiryComparisonMatchesGo(t *testing.T) {
982 h := ideartest.New(t)
983 ctx := h.Ctx()
984 owner := h.Owner()
985
986 inv, _, err := h.Roster.Invite(ctx, owner, "clock@example.test", idear.RoleMember)
987 if err != nil {
988 t.Fatalf("Invite: %v", err)
989 }
990 if strings.Contains(inv.ExpiresAt.String(), "m=") {
991 t.Fatalf("ExpiresAt carries a monotonic reading (%s); stored, it would break every expiry comparison",
992 inv.ExpiresAt.String())
993 }
994 if inv.ExpiresAt.Location() != time.UTC {
995 t.Fatalf("ExpiresAt is in %v, not UTC (%s); String() renders the zone, so a non-UTC row "+
996 "sorts against a UTC one by its offset characters and the expiry comparison is meaningless",
997 inv.ExpiresAt.Location(), inv.ExpiresAt.String())
998 }
999
1000 stored := h.Invitation(inv.ID)
1001 if stored.ExpiresAt.Location() != time.UTC {
1002 t.Fatalf("ExpiresAt came back in %v, not UTC (%s)", stored.ExpiresAt.Location(), stored.ExpiresAt.String())
1003 }
1004 if !stored.ExpiresAt.Equal(inv.ExpiresAt) {
1005 t.Errorf("ExpiresAt round-tripped as %v, want %v", stored.ExpiresAt, inv.ExpiresAt)
1006 }
1007 base := stored.ExpiresAt
1008 for _, delta := range []time.Duration{
1009 -time.Hour, -time.Second, -time.Millisecond, -time.Nanosecond,
1010 0, time.Nanosecond, time.Millisecond, time.Second, time.Hour,
1011 } {
1012 probe := base.Add(delta)
1013 var n int64
1014 if err := h.DB.G.Model(&idear.Invitation{}).
1015 Where("id = ? AND expires_at > ?", inv.ID, probe).Count(&n).Error; err != nil {
1016 t.Fatalf("counting at delta %v: %v", delta, err)
1017 }
1018 want := int64(0)
1019 if base.After(probe) {
1020 want = 1
1021 }
1022 if n != want {
1023 t.Errorf("delta %v: SQL says %d row(s) with expires_at > probe, Go says %v", delta, n, want == 1)
1024 }
1025 }
1026
1027 // And the trimmed-fraction boundary explicitly: a whole-second
1028 // timestamp renders without a fractional part at all, so the
1029 // character that follows the seconds changes from '.' to ' '.
1030 whole := base.Truncate(time.Second)
1031 if err := h.DB.G.Model(&idear.Invitation{}).Where("id = ?", inv.ID).
1032 Update("expires_at", whole).Error; err != nil {
1033 t.Fatalf("storing a whole-second expiry: %v", err)
1034 }
1035 for _, delta := range []time.Duration{-time.Nanosecond, time.Nanosecond} {
1036 probe := whole.Add(delta)
1037 var n int64
1038 if err := h.DB.G.Model(&idear.Invitation{}).
1039 Where("id = ? AND expires_at > ?", inv.ID, probe).Count(&n).Error; err != nil {
1040 t.Fatalf("counting at delta %v: %v", delta, err)
1041 }
1042 want := int64(0)
1043 if whole.After(probe) {
1044 want = 1
1045 }
1046 if n != want {
1047 t.Errorf("whole-second boundary, delta %v: SQL says %d, Go says %v", delta, n, want == 1)
1048 }
1049 }
1050}
1051