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 // Whether Invite succeeds: in the default build, and in a
224 // build with PeerGrants.
225 ok, okPeers bool
226 }{
227 {"owner invites admin", owner, idear.RoleAdmin, true, true},
228 {"owner invites member", owner, idear.RoleMember, true, true},
229 {"admin invites member", admin, idear.RoleMember, true, true},
230 // An admin who could mint a peer admin has escalated: MayActOn
231 // refuses acting on an equal rank, so the new admin would be
232 // beyond the granter's reach and every other admin's too.
233 // Under PeerGrants MayActOn allows equal rank, so the same
234 // grant leaves the new admin within every admin's reach.
235 {"admin invites admin", admin, idear.RoleAdmin, false, true},
236 // Ownership is never granted, peers or not.
237 {"owner invites owner", owner, idear.RoleOwner, false, false},
238 {"member invites member", member, idear.RoleMember, false, false},
239 // The actor is re-read inside the transaction, so a privilege
240 // revoked a moment ago is revoked for this call too.
241 {"deactivated admin invites member", gone, idear.RoleMember, false, false},
242 }
243 for _, tc := range cases {
244 t.Run(tc.name, func(t *testing.T) {
245 ok := tc.ok
246 if idear.PeerGrants {
247 ok = tc.okPeers
248 }
249 _, _, err := h.Roster.Invite(h.Ctx(), tc.actor, "invitee@example.test", tc.role)
250 if ok && err != nil {
251 t.Fatalf("Invite = %v, want success", err)
252 }
253 if !ok && !errors.Is(err, idear.ErrForbidden) {
254 t.Fatalf("Invite = %v, want ErrForbidden", err)
255 }
256 })
257 }
258}
259
260// TestInvite_RejectsBlankEmail is the sibling of
261// TestInvite_RejectsUnknownRole: the role field and the address field
262// of one invitation form must classify the same way, or a mistyped
263// address renders 500 while a mistyped role renders 400.
264func TestInvite_RejectsBlankEmail(t *testing.T) {
265 h := ideartest.New(t)
266 owner := h.Owner()
267 for _, email := range []string{"", " ", "\t\n"} {
268 _, _, err := h.Roster.Invite(h.Ctx(), owner, email, idear.RoleMember)
269 if !errors.Is(err, idear.ErrInvalidEmail) || !errors.Is(err, idear.ErrInvalid) {
270 t.Errorf("Invite(%q) = %v, want ErrInvalidEmail in the ErrInvalid class", email, err)
271 }
272 }
273 if invs, err := h.Roster.PendingInvitations(h.Ctx()); err != nil {
274 t.Fatalf("PendingInvitations: %v", err)
275 } else if len(invs) != 0 {
276 t.Errorf("%d invitations were minted from a blank address", len(invs))
277 }
278}
279
280func TestInvite_RejectsUnknownRole(t *testing.T) {
281 h := ideartest.New(t)
282 owner := h.Owner()
283 _, _, err := h.Roster.Invite(h.Ctx(), owner, "invitee@example.test", idear.Role("superuser"))
284 if !errors.Is(err, idear.ErrInvalidRole) {
285 t.Fatalf("Invite at role \"superuser\" = %v, want ErrInvalidRole", err)
286 }
287}
288
289// TestInvite_StoresOnlyTheHash pins the "hashed at rest" rule from the
290// design's §4: the plaintext exists in the return value and the emitted
291// link, and nowhere in the table.
292func TestInvite_StoresOnlyTheHash(t *testing.T) {
293 h := ideartest.New(t)
294 owner := h.Owner()
295
296 inv, token, err := h.Roster.Invite(h.Ctx(), owner, "Invitee@Example.TEST", idear.RoleMember)
297 if err != nil {
298 t.Fatalf("Invite: %v", err)
299 }
300 if token == "" {
301 t.Fatal("Invite returned an empty token")
302 }
303 stored := h.Invitation(inv.ID)
304 if stored.TokenHash == token {
305 t.Error("the plaintext token is what is stored; only its digest may be")
306 }
307 if strings.Contains(stored.TokenHash, token) || strings.Contains(stored.Email, token) {
308 t.Error("the plaintext token appears in a stored column")
309 }
310 if stored.Email != "invitee@example.test" {
311 t.Errorf("stored email = %q, want it normalised", stored.Email)
312 }
313 if stored.InvitedBy != owner.ID {
314 t.Errorf("InvitedBy = %d, want the inviting owner %d", stored.InvitedBy, owner.ID)
315 }
316 // The digest is only demonstrably the RIGHT digest if the token
317 // still redeems — asserting sha256(token) here would just restate
318 // the implementation back to itself.
319 if _, err := h.Roster.Accept(h.Ctx(), token, "invitee-subject", "Invitee"); err != nil {
320 t.Fatalf("Accept with the returned token: %v", err)
321 }
322}
323
324func TestInvite_ExpiresAtHonoursTTL(t *testing.T) {
325 h := ideartest.NewWith(t, idear.Config{InviteTTL: time.Minute})
326 owner := h.Owner()
327
328 before := time.Now().UTC()
329 inv, _, err := h.Roster.Invite(h.Ctx(), owner, "invitee@example.test", idear.RoleMember)
330 if err != nil {
331 t.Fatalf("Invite: %v", err)
332 }
333 after := time.Now().UTC()
334 if inv.ExpiresAt.Before(before.Add(time.Minute)) || inv.ExpiresAt.After(after.Add(time.Minute)) {
335 t.Errorf("ExpiresAt = %v, want about %v", inv.ExpiresAt, before.Add(time.Minute))
336 }
337}
338
339// --------------------------------------------------------------- Revoke
340
341func TestRevoke_KillsAPendingInvitation(t *testing.T) {
342 h := ideartest.New(t)
343 ctx := h.Ctx()
344 owner := h.Owner()
345
346 inv, token, err := h.Roster.Invite(ctx, owner, "invitee@example.test", idear.RoleMember)
347 if err != nil {
348 t.Fatalf("Invite: %v", err)
349 }
350 if err := h.Roster.Revoke(ctx, owner, inv.ID); err != nil {
351 t.Fatalf("Revoke: %v", err)
352 }
353 if h.Invitation(inv.ID).RevokedAt == nil {
354 t.Error("RevokedAt is still NULL after Revoke")
355 }
356 if _, err := h.Roster.Accept(ctx, token, "invitee-subject", "Invitee"); !errors.Is(err, idear.ErrNoInvitation) {
357 t.Errorf("Accept of a revoked invitation = %v, want ErrNoInvitation", err)
358 }
359}
360
361// TestInvite_SupersedesAPendingInvitationForTheSameAddress pins the
362// only semantics an admin would predict: re-inviting REPLACES.
363//
364// Without it two live invitations for one address coexist and the two
365// identity paths disagree about which one is spent. keymail redeems
366// the OLDEST redeemable row, so re-inviting Alice at a corrected
367// higher role is silently ignored — the admin's members page says
368// Admin and Alice arrives as Member — and a corrected LOWER role
369// leaves the stale higher one live for her to escalate past the
370// correction. password spends whichever of the several links the
371// invitee happens to click.
372//
373// Both redemption paths are driven, in two instances, because "both
374// land at the new role" is the claim and one path proving it is half
375// an answer.
376func TestInvite_SupersedesAPendingInvitationForTheSameAddress(t *testing.T) {
377 // The mistake being corrected: Member, then Admin. The second
378 // invite spells the address differently on purpose — invitations
379 // are stored normalised, so the supersede must match on the
380 // normalised form or it silently does nothing.
381 const address = "alice@example.test"
382 const retyped = " Alice@Example.Test "
383
384 t.Run("password redeems the new token", func(t *testing.T) {
385 h := ideartest.New(t)
386 ctx := h.Ctx()
387 owner := h.Owner()
388
389 stale, staleToken, err := h.Roster.Invite(ctx, owner, address, idear.RoleMember)
390 if err != nil {
391 t.Fatalf("first Invite: %v", err)
392 }
393 fresh, freshToken, err := h.Roster.Invite(ctx, owner, retyped, idear.RoleAdmin)
394 if err != nil {
395 t.Fatalf("second Invite: %v", err)
396 }
397
398 // Exactly one invitation is live, and it is the new one.
399 pending, err := h.Roster.PendingInvitations(ctx)
400 if err != nil {
401 t.Fatalf("PendingInvitations: %v", err)
402 }
403 if len(pending) != 1 || pending[0].ID != fresh.ID {
404 t.Fatalf("pending invitations = %+v, want only the re-invitation %d", pending, fresh.ID)
405 }
406 // The stale one is REVOKED — not accepted, not deleted: the
407 // record of what was offered and withdrawn stays true.
408 switch old := h.Invitation(stale.ID); {
409 case old.RevokedAt == nil:
410 t.Error("re-inviting left the earlier invitation live; the invitee can still land at the superseded role")
411 case old.AcceptedAt != nil:
412 t.Error("the superseded invitation was marked accepted; nobody accepted it")
413 }
414
415 // And the stale token is spent as far as anyone holding it is
416 // concerned.
417 if _, err := h.Roster.Accept(ctx, staleToken, "alice-subject", "Alice"); !errors.Is(err, idear.ErrNoInvitation) {
418 t.Fatalf("the superseded token was redeemable: %v", err)
419 }
420 m, err := h.Roster.Accept(ctx, freshToken, "alice-subject", "Alice")
421 if err != nil {
422 t.Fatalf("Accept of the re-invitation: %v", err)
423 }
424 if m.Role != idear.RoleAdmin {
425 t.Errorf("password admitted %s at %s, want the corrected admin", address, m.Role)
426 }
427 })
428
429 t.Run("keymail redeems by address", func(t *testing.T) {
430 h := ideartest.New(t)
431 ctx := h.Ctx()
432 owner := h.Owner()
433
434 if _, _, err := h.Roster.Invite(ctx, owner, address, idear.RoleMember); err != nil {
435 t.Fatalf("first Invite: %v", err)
436 }
437 if _, _, err := h.Roster.Invite(ctx, owner, retyped, idear.RoleAdmin); err != nil {
438 t.Fatalf("second Invite: %v", err)
439 }
440
441 // Authorize is keymail's whole admission: a verified address,
442 // no token, and acceptByAddress underneath it taking the
443 // oldest redeemable row. Before the supersede that row was the
444 // stale Member one.
445 if !h.Roster.Authorize(address) {
446 t.Fatal("Authorize refused an address holding a live invitation")
447 }
448 var admitted []idear.Member
449 if err := h.DB.G.Where("subject = ?", address).Find(&admitted).Error; err != nil {
450 t.Fatalf("looking up the admitted member: %v", err)
451 }
452 if len(admitted) != 1 {
453 t.Fatalf("roster holds %d rows for %q, want 1", len(admitted), address)
454 }
455 if admitted[0].Role != idear.RoleAdmin {
456 t.Errorf("keymail admitted %s at %s, want the corrected admin", address, admitted[0].Role)
457 }
458 })
459}
460
461func TestRevoke_Refusals(t *testing.T) {
462 h := ideartest.New(t)
463 ctx := h.Ctx()
464 owner := h.Owner()
465 member := h.Member(idear.RoleMember)
466
467 inv, _, err := h.Roster.Invite(ctx, owner, "invitee@example.test", idear.RoleMember)
468 if err != nil {
469 t.Fatalf("Invite: %v", err)
470 }
471
472 if err := h.Roster.Revoke(ctx, member, inv.ID); !errors.Is(err, idear.ErrForbidden) {
473 t.Errorf("a member revoking = %v, want ErrForbidden", err)
474 }
475 if err := h.Roster.Revoke(ctx, owner, inv.ID+9999); !errors.Is(err, idear.ErrNoInvitation) {
476 t.Errorf("revoking an unknown id = %v, want ErrNoInvitation", err)
477 }
478 if err := h.Roster.Revoke(ctx, owner, inv.ID); err != nil {
479 t.Fatalf("Revoke: %v", err)
480 }
481 if err := h.Roster.Revoke(ctx, owner, inv.ID); !errors.Is(err, idear.ErrNoInvitation) {
482 t.Errorf("revoking twice = %v, want ErrNoInvitation", err)
483 }
484}
485
486// --------------------------------------------------------------- Accept
487
488func TestAccept_AdmitsAtTheInvitedRole(t *testing.T) {
489 h := ideartest.New(t)
490 ctx := h.Ctx()
491 owner := h.Owner()
492
493 inv, token, err := h.Roster.Invite(ctx, owner, "invitee@example.test", idear.RoleAdmin)
494 if err != nil {
495 t.Fatalf("Invite: %v", err)
496 }
497 m, err := h.Roster.Accept(ctx, token, "invitee-subject", "Invitee")
498 if err != nil {
499 t.Fatalf("Accept: %v", err)
500 }
501 if m.Role != idear.RoleAdmin {
502 t.Errorf("admitted at %q, want the invited role admin", m.Role)
503 }
504 if m.Email != "invitee@example.test" {
505 t.Errorf("member email = %q, want the invitation's address", m.Email)
506 }
507 if h.Invitation(inv.ID).AcceptedAt == nil {
508 t.Error("AcceptedAt is still NULL after a successful Accept")
509 }
510}
511
512func TestAccept_Refusals(t *testing.T) {
513 h := ideartest.New(t)
514 ctx := h.Ctx()
515 owner := h.Owner()
516
517 t.Run("unknown token", func(t *testing.T) {
518 _, err := h.Roster.Accept(ctx, "not-a-token", "s1", "One")
519 if !errors.Is(err, idear.ErrNoInvitation) {
520 t.Fatalf("err = %v, want ErrNoInvitation", err)
521 }
522 })
523
524 t.Run("empty token", func(t *testing.T) {
525 _, err := h.Roster.Accept(ctx, "", "s2", "Two")
526 if !errors.Is(err, idear.ErrNoInvitation) {
527 t.Fatalf("err = %v, want ErrNoInvitation", err)
528 }
529 })
530
531 t.Run("replay", func(t *testing.T) {
532 _, token, err := h.Roster.Invite(ctx, owner, "replay@example.test", idear.RoleMember)
533 if err != nil {
534 t.Fatalf("Invite: %v", err)
535 }
536 if _, err := h.Roster.Accept(ctx, token, "replay-1", "One"); err != nil {
537 t.Fatalf("first Accept: %v", err)
538 }
539 _, err = h.Roster.Accept(ctx, token, "replay-2", "Two")
540 if !errors.Is(err, idear.ErrNoInvitation) {
541 t.Fatalf("replayed Accept = %v, want ErrNoInvitation", err)
542 }
543 if _, err := h.Roster.BySubject(ctx, "replay-2"); !errors.Is(err, idear.ErrNotFound) {
544 t.Fatalf("the replayer was admitted anyway: %v", err)
545 }
546 })
547
548 t.Run("expired", func(t *testing.T) {
549 inv, token, err := h.Roster.Invite(ctx, owner, "expired@example.test", idear.RoleMember)
550 if err != nil {
551 t.Fatalf("Invite: %v", err)
552 }
553 h.Expire(inv.ID)
554 _, err = h.Roster.Accept(ctx, token, "expired-subject", "Expired")
555 if !errors.Is(err, idear.ErrNoInvitation) {
556 t.Fatalf("Accept of an expired invitation = %v, want ErrNoInvitation", err)
557 }
558 if h.Invitation(inv.ID).AcceptedAt != nil {
559 t.Error("the expired invitation was consumed by the refused Accept")
560 }
561 })
562
563 t.Run("blank subject", func(t *testing.T) {
564 inv, token, err := h.Roster.Invite(ctx, owner, "blank@example.test", idear.RoleMember)
565 if err != nil {
566 t.Fatalf("Invite: %v", err)
567 }
568 _, err = h.Roster.Accept(ctx, token, " ", "Blank")
569 if !errors.Is(err, idear.ErrInvalidSubject) || !errors.Is(err, idear.ErrInvalid) {
570 t.Fatalf("Accept with a blank subject = %v, want ErrInvalidSubject in the ErrInvalid class", err)
571 }
572 if h.Invitation(inv.ID).AcceptedAt != nil {
573 t.Error("the invitation was consumed by the refused Accept")
574 }
575 })
576}
577
578// TestAccept_RefusesAForgedOwnerInvitation is defence in depth. Invite
579// cannot mint one, so a row carrying role='owner' is corruption — and
580// corruption must not become a second Owner.
581func TestAccept_RefusesAForgedOwnerInvitation(t *testing.T) {
582 h := ideartest.New(t)
583 ctx := h.Ctx()
584 owner := h.Owner()
585
586 inv, token, err := h.Roster.Invite(ctx, owner, "forged@example.test", idear.RoleMember)
587 if err != nil {
588 t.Fatalf("Invite: %v", err)
589 }
590 // Forge it behind the store's back, the way a stray migration or a
591 // hand-edited database would.
592 if err := h.DB.G.Model(&idear.Invitation{}).Where("id = ?", inv.ID).
593 Update("role", idear.RoleOwner).Error; err != nil {
594 t.Fatalf("forging the row: %v", err)
595 }
596
597 if _, err := h.Roster.Accept(ctx, token, "forged-subject", "Forged"); !errors.Is(err, idear.ErrForbidden) {
598 t.Fatalf("Accept of an owner-role invitation = %v, want ErrForbidden", err)
599 }
600 h.TheOwner() // still exactly one, and it is not the forger
601 if _, err := h.Roster.BySubject(ctx, "forged-subject"); !errors.Is(err, idear.ErrNotFound) {
602 t.Errorf("the forger was admitted: %v", err)
603 }
604 if h.Invitation(inv.ID).AcceptedAt != nil {
605 t.Error("the forged invitation was consumed; the whole transaction should have rolled back")
606 }
607}
608
609// TestAccept_ReturningMemberCollidesAndRollsBack pins the design's §4
610// corollary: a deactivated member cannot be readmitted by a fresh
611// invitation, because Subject is unique and their row is still there.
612// Reactivate is the path — and the invitation must survive the failure
613// intact rather than being burnt by it.
614func TestAccept_ReturningMemberCollidesAndRollsBack(t *testing.T) {
615 h := ideartest.New(t)
616 ctx := h.Ctx()
617 owner := h.Owner()
618 // Invited while still a member (Invite refuses a removed one),
619 // then removed: the invitation outlives the membership.
620 gone := h.Member(idear.RoleMember)
621 inv, token, err := h.Roster.Invite(ctx, owner, gone.Email, idear.RoleMember)
622 if err != nil {
623 t.Fatalf("Invite: %v", err)
624 }
625 if err := h.Roster.Deactivate(ctx, owner, gone); err != nil {
626 t.Fatalf("Deactivate: %v", err)
627 }
628 if _, err := h.Roster.Accept(ctx, token, gone.Subject, gone.Name); err == nil {
629 t.Fatal("Accept re-admitted a deactivated subject; the unique index should have refused it")
630 }
631 if h.Invitation(inv.ID).AcceptedAt != nil {
632 t.Error("the invitation was consumed by the failed Accept; the transaction did not roll back")
633 }
634 if h.Reload(gone.ID).Active() {
635 t.Error("the failed Accept reactivated the dormant row")
636 }
637}
638
639// -------------------------------------------------------------- SetRole
640
641func TestSetRole_RefusesOwner(t *testing.T) {
642 h := ideartest.New(t)
643 ctx := h.Ctx()
644 owner := h.Owner()
645 admin := h.Member(idear.RoleAdmin)
646
647 // As the new role: ownership moves only by Transfer.
648 if err := h.Roster.SetRole(ctx, owner, admin, idear.RoleOwner); !errors.Is(err, idear.ErrForbidden) {
649 t.Errorf("SetRole(..., owner) = %v, want ErrForbidden", err)
650 }
651 if got := h.Reload(admin.ID).Role; got != idear.RoleAdmin {
652 t.Errorf("target's role is now %q; a refused SetRole wrote anyway", got)
653 }
654 h.TheOwner()
655
656 // As the target: the Owner's role changes only by Transfer either,
657 // and this is an invariant rather than a permission — no rank can
658 // do it, so ErrLastOwner is the honest answer, not ErrForbidden.
659 if err := h.Roster.SetRole(ctx, admin, owner, idear.RoleMember); !errors.Is(err, idear.ErrLastOwner) {
660 t.Errorf("SetRole demoting the owner = %v, want ErrLastOwner", err)
661 }
662 if got := h.Reload(owner.ID).Role; got != idear.RoleOwner {
663 t.Errorf("owner's role is now %q", got)
664 }
665}
666
667// TestSetRole_AuthorityFloorComesFirst pins the ORDER of the checks. A
668// plain Member aiming at the Owner must be refused for lacking
669// authority — ErrForbidden — and not handed ErrLastOwner, which would
670// answer "which row is the owner" to someone who had not established
671// they may ask anything at all.
672func TestSetRole_AuthorityFloorComesFirst(t *testing.T) {
673 h := ideartest.New(t)
674 owner := h.Owner()
675 member := h.Member(idear.RoleMember)
676
677 err := h.Roster.SetRole(h.Ctx(), member, owner, idear.RoleMember)
678 if !errors.Is(err, idear.ErrForbidden) {
679 t.Fatalf("a member demoting the owner = %v, want ErrForbidden", err)
680 }
681 if errors.Is(err, idear.ErrLastOwner) {
682 t.Error("a member was told which row is the owner")
683 }
684}
685
686func TestSetRole_Matrix(t *testing.T) {
687 h := ideartest.New(t)
688 ctx := h.Ctx()
689 owner := h.Owner()
690 admin := h.Member(idear.RoleAdmin)
691 member := h.Member(idear.RoleMember)
692 other := h.Member(idear.RoleMember)
693
694 if err := h.Roster.SetRole(ctx, owner, member, idear.RoleAdmin); err != nil {
695 t.Fatalf("owner promoting a member to admin: %v", err)
696 }
697 if got := h.Reload(member.ID).Role; got != idear.RoleAdmin {
698 t.Errorf("role = %q, want admin", got)
699 }
700
701 // An admin minting a peer admin is an escalation: MayActOn refuses
702 // acting on an equal rank, so the new admin is beyond reach. Under
703 // PeerGrants it is allowed — and so is the demotion straight back,
704 // which is what keeps the new admin within reach.
705 err := h.Roster.SetRole(ctx, admin, other, idear.RoleAdmin)
706 if idear.PeerGrants {
707 if err != nil {
708 t.Errorf("admin promoting to admin under PeerGrants: %v", err)
709 }
710 if got := h.Reload(other.ID).Role; got != idear.RoleAdmin {
711 t.Errorf("role = %q, want admin", got)
712 }
713 if err := h.Roster.SetRole(ctx, admin, other, idear.RoleMember); err != nil {
714 t.Errorf("admin demoting a peer admin under PeerGrants: %v", err)
715 }
716 } else if !errors.Is(err, idear.ErrForbidden) {
717 t.Errorf("admin promoting to admin = %v, want ErrForbidden", err)
718 }
719 if got := h.Reload(other.ID).Role; got != idear.RoleMember {
720 t.Errorf("role = %q, want member", got)
721 }
722 if err := h.Roster.SetRole(ctx, admin, other, idear.RoleMember); err != nil {
723 t.Errorf("admin setting a member to member: %v", err)
724 }
725 // Ownership is never granted, not even by the Owner, whose own
726 // rank reaches owner under PeerGrants.
727 if err := h.Roster.SetRole(ctx, owner, other, idear.RoleOwner); !errors.Is(err, idear.ErrForbidden) {
728 t.Errorf("owner granting owner = %v, want ErrForbidden", err)
729 }
730 if err := h.Roster.SetRole(ctx, admin, admin, idear.RoleMember); !errors.Is(err, idear.ErrForbidden) {
731 t.Errorf("admin demoting themselves = %v, want ErrForbidden", err)
732 }
733 if err := h.Roster.SetRole(ctx, owner, other, idear.Role("root")); !errors.Is(err, idear.ErrInvalidRole) {
734 t.Errorf("SetRole to \"root\" = %v, want ErrInvalidRole", err)
735 }
736 if err := h.Roster.SetRole(ctx, owner, &idear.Member{ID: 9999}, idear.RoleMember); !errors.Is(err, idear.ErrNotFound) {
737 t.Errorf("SetRole on an unknown id = %v, want ErrNotFound", err)
738 }
739}
740
741// ----------------------------------------------------------- Deactivate
742
743// TestDeactivate_NeverRemovesTheOwner is the invariant that keeps an
744// instance administrable. It is checked against the row inside the
745// transaction, which is what also makes Transfer racing Deactivate safe
746// from this side.
747func TestDeactivate_NeverRemovesTheOwner(t *testing.T) {
748 h := ideartest.New(t)
749 owner := h.Owner()
750 admin := h.Member(idear.RoleAdmin)
751
752 if err := h.Roster.Deactivate(h.Ctx(), admin, owner); !errors.Is(err, idear.ErrLastOwner) {
753 t.Fatalf("an admin deactivating the owner = %v, want ErrLastOwner", err)
754 }
755 if !h.Reload(owner.ID).Active() {
756 t.Fatal("the owner was deactivated")
757 }
758 // The owner cannot do it to themselves either.
759 if err := h.Roster.Deactivate(h.Ctx(), owner, owner); err == nil {
760 t.Error("the owner deactivated themselves")
761 }
762 if !h.Reload(owner.ID).Active() {
763 t.Error("the owner was deactivated")
764 }
765}
766
767func TestDeactivate_Matrix(t *testing.T) {
768 h := ideartest.New(t)
769 ctx := h.Ctx()
770 owner := h.Owner()
771 admin := h.Member(idear.RoleAdmin)
772 otherAdmin := h.Member(idear.RoleAdmin)
773 member := h.Member(idear.RoleMember)
774
775 if err := h.Roster.Deactivate(ctx, admin, member); err != nil {
776 t.Fatalf("admin deactivating a member: %v", err)
777 }
778 if h.Reload(member.ID).Active() {
779 t.Error("the member is still active")
780 }
781 // Idempotent: removing someone already removed is not an error.
782 if err := h.Roster.Deactivate(ctx, admin, member); err != nil {
783 t.Errorf("deactivating twice = %v, want nil", err)
784 }
785 err := h.Roster.Deactivate(ctx, admin, otherAdmin)
786 switch {
787 case idear.PeerGrants && err != nil:
788 t.Errorf("admin deactivating a peer admin under PeerGrants: %v", err)
789 case idear.PeerGrants && h.Reload(otherAdmin.ID).Active():
790 t.Error("the peer admin is still active")
791 case !idear.PeerGrants && !errors.Is(err, idear.ErrForbidden):
792 t.Errorf("admin deactivating a peer admin = %v, want ErrForbidden", err)
793 }
794 if err := h.Roster.Deactivate(ctx, member, otherAdmin); !errors.Is(err, idear.ErrForbidden) {
795 t.Errorf("a deactivated member deactivating anyone = %v, want ErrForbidden", err)
796 }
797 if err := h.Roster.Deactivate(ctx, owner, admin); err != nil {
798 t.Errorf("owner deactivating an admin: %v", err)
799 }
800}
801
802// ----------------------------------------------------------- Reactivate
803
804func TestReactivate_RestoresPriorAccessAndNoMore(t *testing.T) {
805 h := ideartest.New(t)
806 ctx := h.Ctx()
807 owner := h.Owner()
808 gone := h.Deactivated(idear.RoleAdmin)
809
810 if err := h.Roster.Reactivate(ctx, owner, gone); err != nil {
811 t.Fatalf("Reactivate: %v", err)
812 }
813 back := h.Reload(gone.ID)
814 if !back.Active() {
815 t.Fatal("the member is still deactivated")
816 }
817 if back.Role != idear.RoleAdmin {
818 t.Errorf("role after reactivation = %q, want the admin they were", back.Role)
819 }
820 // Idempotent.
821 if err := h.Roster.Reactivate(ctx, owner, back); err != nil {
822 t.Errorf("reactivating an active member = %v, want nil", err)
823 }
824 h.TheOwner()
825}
826
827func TestReactivate_Refusals(t *testing.T) {
828 h := ideartest.New(t)
829 ctx := h.Ctx()
830 member := h.Member(idear.RoleMember)
831 admin := h.Member(idear.RoleAdmin)
832 goneAdmin := h.Deactivated(idear.RoleAdmin)
833
834 if err := h.Roster.Reactivate(ctx, member, goneAdmin); !errors.Is(err, idear.ErrForbidden) {
835 t.Errorf("a member reactivating = %v, want ErrForbidden", err)
836 }
837 if h.Reload(goneAdmin.ID).Active() {
838 t.Error("a refused Reactivate wrote anyway")
839 }
840
841 // A peer admin is refused in the default build, and reachable —
842 // so restorable — under PeerGrants.
843 err := h.Roster.Reactivate(ctx, admin, goneAdmin)
844 if idear.PeerGrants {
845 if err != nil {
846 t.Errorf("an admin reactivating a peer admin under PeerGrants: %v", err)
847 }
848 if !h.Reload(goneAdmin.ID).Active() {
849 t.Error("the peer admin is still deactivated")
850 }
851 return
852 }
853 if !errors.Is(err, idear.ErrForbidden) {
854 t.Errorf("an admin reactivating a peer admin = %v, want ErrForbidden", err)
855 }
856 if h.Reload(goneAdmin.ID).Active() {
857 t.Error("a refused Reactivate wrote anyway")
858 }
859}
860
861// ------------------------------------------------------------- Transfer
862
863func TestTransfer_MovesOwnershipExactlyOnce(t *testing.T) {
864 h := ideartest.New(t)
865 ctx := h.Ctx()
866 owner := h.Owner()
867 admin := h.Member(idear.RoleAdmin)
868
869 if err := h.Roster.Transfer(ctx, owner, admin); err != nil {
870 t.Fatalf("Transfer: %v", err)
871 }
872 if got := h.TheOwner(); got.ID != admin.ID {
873 t.Errorf("owner is member %d, want %d", got.ID, admin.ID)
874 }
875 if got := h.Reload(owner.ID).Role; got != idear.RoleAdmin {
876 t.Errorf("outgoing owner's role = %q, want admin", got)
877 }
878 // The outgoing owner is no longer the owner, so they cannot
879 // transfer again.
880 if err := h.Roster.Transfer(ctx, owner, h.Member(idear.RoleMember)); !errors.Is(err, idear.ErrForbidden) {
881 t.Errorf("a former owner transferring = %v, want ErrForbidden", err)
882 }
883 h.TheOwner()
884}
885
886func TestTransfer_Refusals(t *testing.T) {
887 h := ideartest.New(t)
888 ctx := h.Ctx()
889 owner := h.Owner()
890 admin := h.Member(idear.RoleAdmin)
891 gone := h.Deactivated(idear.RoleAdmin)
892
893 if err := h.Roster.Transfer(ctx, admin, owner); !errors.Is(err, idear.ErrForbidden) {
894 t.Errorf("a non-owner transferring = %v, want ErrForbidden", err)
895 }
896 if err := h.Roster.Transfer(ctx, owner, owner); !errors.Is(err, idear.ErrForbidden) {
897 t.Errorf("transferring to oneself = %v, want ErrForbidden", err)
898 }
899 // The one that matters: a deactivated Owner is an instance nobody
900 // can administer and nobody can be promoted into.
901 if err := h.Roster.Transfer(ctx, owner, gone); !errors.Is(err, idear.ErrForbidden) {
902 t.Errorf("transferring to a deactivated member = %v, want ErrForbidden", err)
903 }
904 if err := h.Roster.Transfer(ctx, owner, &idear.Member{ID: 9999}); !errors.Is(err, idear.ErrNotFound) {
905 t.Errorf("transferring to an unknown id = %v, want ErrNotFound", err)
906 }
907 if got := h.TheOwner(); got.ID != owner.ID {
908 t.Errorf("ownership moved despite every transfer being refused (now %d)", got.ID)
909 }
910}
911
912// -------------------------------------------------------------- Lookups
913
914func TestBySubjectAndByID(t *testing.T) {
915 h := ideartest.New(t)
916 ctx := h.Ctx()
917 owner := h.Owner()
918 gone := h.Deactivated(idear.RoleMember)
919
920 got, err := h.Roster.BySubject(ctx, owner.Subject)
921 if err != nil || got.ID != owner.ID {
922 t.Fatalf("BySubject = (%v, %v), want the owner", got, err)
923 }
924 // Deactivated members resolve: the middleware has to tell a
925 // dormant member from a stranger in order to log the difference,
926 // even though it answers both with the app's 404.
927 got, err = h.Roster.BySubject(ctx, gone.Subject)
928 if err != nil {
929 t.Fatalf("BySubject on a deactivated member = %v, want the row", err)
930 }
931 if got.Active() {
932 t.Error("the deactivated member reads as active")
933 }
934 if _, err := h.Roster.BySubject(ctx, "nobody"); !errors.Is(err, idear.ErrNotFound) {
935 t.Errorf("BySubject on a stranger = %v, want ErrNotFound", err)
936 }
937 // An empty subject is a request with no session, never a wildcard.
938 if _, err := h.Roster.BySubject(ctx, " "); !errors.Is(err, idear.ErrNotFound) {
939 t.Errorf("BySubject(\"\") = %v, want ErrNotFound", err)
940 }
941 if _, err := h.Roster.ByID(ctx, 9999); !errors.Is(err, idear.ErrNotFound) {
942 t.Errorf("ByID on an unknown id = %v, want ErrNotFound", err)
943 }
944 if m, err := h.Roster.ByID(ctx, owner.ID); err != nil || m.Role != idear.RoleOwner {
945 t.Errorf("ByID = (%v, %v), want the owner", m, err)
946 }
947}
948
949func TestMembers_RankedAndIncludesDeactivated(t *testing.T) {
950 h := ideartest.New(t)
951 member := h.Member(idear.RoleMember)
952 admin := h.Member(idear.RoleAdmin)
953 owner := h.Owner()
954 gone := h.Deactivated(idear.RoleMember)
955
956 got, err := h.Roster.Members(h.Ctx())
957 if err != nil {
958 t.Fatalf("Members: %v", err)
959 }
960 want := []int64{owner.ID, admin.ID, member.ID, gone.ID}
961 if len(got) != len(want) {
962 t.Fatalf("Members returned %d rows, want %d — deactivated members must be listed so they can be restored", len(got), len(want))
963 }
964 for i, id := range want {
965 if got[i].ID != id {
966 t.Errorf("Members[%d].ID = %d, want %d (owner, then admins, then members, then by id)", i, got[i].ID, id)
967 }
968 }
969}
970
971func TestPendingInvitations_HidesSpentAndExpired(t *testing.T) {
972 h := ideartest.New(t)
973 ctx := h.Ctx()
974 owner := h.Owner()
975
976 live, _, err := h.Roster.Invite(ctx, owner, "live@example.test", idear.RoleMember)
977 if err != nil {
978 t.Fatalf("Invite: %v", err)
979 }
980 revoked, _, err := h.Roster.Invite(ctx, owner, "revoked@example.test", idear.RoleMember)
981 if err != nil {
982 t.Fatalf("Invite: %v", err)
983 }
984 if err := h.Roster.Revoke(ctx, owner, revoked.ID); err != nil {
985 t.Fatalf("Revoke: %v", err)
986 }
987 accepted, token, err := h.Roster.Invite(ctx, owner, "accepted@example.test", idear.RoleMember)
988 if err != nil {
989 t.Fatalf("Invite: %v", err)
990 }
991 if _, err := h.Roster.Accept(ctx, token, "accepted-subject", "Accepted"); err != nil {
992 t.Fatalf("Accept: %v", err)
993 }
994 expired, _, err := h.Roster.Invite(ctx, owner, "expired@example.test", idear.RoleMember)
995 if err != nil {
996 t.Fatalf("Invite: %v", err)
997 }
998 h.Expire(expired.ID)
999
1000 got, err := h.Roster.PendingInvitations(ctx)
1001 if err != nil {
1002 t.Fatalf("PendingInvitations: %v", err)
1003 }
1004 if len(got) != 1 || got[0].ID != live.ID {
1005 ids := make([]int64, len(got))
1006 for i, inv := range got {
1007 ids[i] = inv.ID
1008 }
1009 t.Fatalf("PendingInvitations returned %v, want only the live one (%d); revoked=%d accepted=%d expired=%d",
1010 ids, live.ID, revoked.ID, accepted.ID, expired.ID)
1011 }
1012}
1013
1014// TestExpiryComparisonMatchesGo pins the assumption underneath Accept's
1015// CAS, which is load-bearing and invisible.
1016//
1017// Timestamps reach SQLite as text, in the driver's own rendering of
1018// time.Time.String(), in columns whose declared type is DATETIME. The
1019// CAS asks the database "expires_at > ?", and that is a TEXT
1020// comparison. It answers correctly only because the rendering is
1021// fixed-width through the seconds and sorts lexicographically the way
1022// the instants sort — including across the boundary where the
1023// fractional part is trimmed away entirely.
1024//
1025// Two things break it, and both are pinned below.
1026//
1027// A monotonic clock reading: String() appends " m=+0.0001" to any time
1028// that still carries one, so the stored text no longer has the shape
1029// the comparison assumes. .UTC() strips it; time.Now() alone does not,
1030// and neither does Add on top of it.
1031//
1032// A non-UTC location, which is the worse of the two because it is
1033// silent and seasonal: String() renders the zone, so a value built in
1034// Europe/Dublin stores as "... +0100 IST" and sorts against a UTC row
1035// by the offset characters — an answer with no relationship to which
1036// instant is later. An invitation minted in summer could outlive one
1037// minted in winter regardless of its actual expiry.
1038func TestExpiryComparisonMatchesGo(t *testing.T) {
1039 h := ideartest.New(t)
1040 ctx := h.Ctx()
1041 owner := h.Owner()
1042
1043 inv, _, err := h.Roster.Invite(ctx, owner, "clock@example.test", idear.RoleMember)
1044 if err != nil {
1045 t.Fatalf("Invite: %v", err)
1046 }
1047 if strings.Contains(inv.ExpiresAt.String(), "m=") {
1048 t.Fatalf("ExpiresAt carries a monotonic reading (%s); stored, it would break every expiry comparison",
1049 inv.ExpiresAt.String())
1050 }
1051 if inv.ExpiresAt.Location() != time.UTC {
1052 t.Fatalf("ExpiresAt is in %v, not UTC (%s); String() renders the zone, so a non-UTC row "+
1053 "sorts against a UTC one by its offset characters and the expiry comparison is meaningless",
1054 inv.ExpiresAt.Location(), inv.ExpiresAt.String())
1055 }
1056
1057 stored := h.Invitation(inv.ID)
1058 if stored.ExpiresAt.Location() != time.UTC {
1059 t.Fatalf("ExpiresAt came back in %v, not UTC (%s)", stored.ExpiresAt.Location(), stored.ExpiresAt.String())
1060 }
1061 if !stored.ExpiresAt.Equal(inv.ExpiresAt) {
1062 t.Errorf("ExpiresAt round-tripped as %v, want %v", stored.ExpiresAt, inv.ExpiresAt)
1063 }
1064 base := stored.ExpiresAt
1065 for _, delta := range []time.Duration{
1066 -time.Hour, -time.Second, -time.Millisecond, -time.Nanosecond,
1067 0, time.Nanosecond, time.Millisecond, time.Second, time.Hour,
1068 } {
1069 probe := base.Add(delta)
1070 var n int64
1071 if err := h.DB.G.Model(&idear.Invitation{}).
1072 Where("id = ? AND expires_at > ?", inv.ID, probe).Count(&n).Error; err != nil {
1073 t.Fatalf("counting at delta %v: %v", delta, err)
1074 }
1075 want := int64(0)
1076 if base.After(probe) {
1077 want = 1
1078 }
1079 if n != want {
1080 t.Errorf("delta %v: SQL says %d row(s) with expires_at > probe, Go says %v", delta, n, want == 1)
1081 }
1082 }
1083
1084 // And the trimmed-fraction boundary explicitly: a whole-second
1085 // timestamp renders without a fractional part at all, so the
1086 // character that follows the seconds changes from '.' to ' '.
1087 whole := base.Truncate(time.Second)
1088 if err := h.DB.G.Model(&idear.Invitation{}).Where("id = ?", inv.ID).
1089 Update("expires_at", whole).Error; err != nil {
1090 t.Fatalf("storing a whole-second expiry: %v", err)
1091 }
1092 for _, delta := range []time.Duration{-time.Nanosecond, time.Nanosecond} {
1093 probe := whole.Add(delta)
1094 var n int64
1095 if err := h.DB.G.Model(&idear.Invitation{}).
1096 Where("id = ? AND expires_at > ?", inv.ID, probe).Count(&n).Error; err != nil {
1097 t.Fatalf("counting at delta %v: %v", delta, err)
1098 }
1099 want := int64(0)
1100 if whole.After(probe) {
1101 want = 1
1102 }
1103 if n != want {
1104 t.Errorf("whole-second boundary, delta %v: SQL says %d, Go says %v", delta, n, want == 1)
1105 }
1106 }
1107}
1108