oficina / meet Public

Clone
git clone https://amadan.net/oficina/meet

Plain git — no account needed to clone.

Download

Download this file

1package calls
2
3import (
4 "bytes"
5 "context"
6 "errors"
7 "io"
8 "log/slog"
9 "path/filepath"
10 "testing"
11 "time"
12
13 "amadan.net/rastrillo/rastrillo/db"
14 "amadan.net/rastrillo/rastrillo/migrate"
15
16 "amadan.net/oficina/calls/internal/calls/seal"
17)
18
19func testStore(t *testing.T) (*store, *time.Time) {
20 t.Helper()
21 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
22 d, err := db.Open(filepath.Join(t.TempDir(), "s.db"), logger)
23 if err != nil {
24 t.Fatal(err)
25 }
26 t.Cleanup(func() { d.Close() })
27 if _, err := migrate.Apply(context.Background(), d, BootSchema); err != nil {
28 t.Fatal(err)
29 }
30 sl, _ := seal.New(bytes.Repeat([]byte{9}, 32))
31 now := time.Date(2026, 9, 8, 10, 0, 0, 0, time.UTC)
32 return &store{g: d.G, sealer: sl, clk: newClock(func() time.Time { return now }), logger: logger}, &now
33}
34
35func TestCreateMeetingSealsTitleAndMintsOneLiveLink(t *testing.T) {
36 s, _ := testStore(t)
37 ctx := context.Background()
38 m, token, err := s.createMeeting(ctx, "Board meeting", nil, "person:1")
39 if err != nil {
40 t.Fatal(err)
41 }
42 if bytes.Contains(m.TitleSealed, []byte("Board")) {
43 t.Fatal("title stored in plaintext")
44 }
45 if s.title(m) != "Board meeting" {
46 t.Fatalf("title opened as %q", s.title(m))
47 }
48 l, m2, ok, err := s.liveLink(ctx, token)
49 if err != nil || !ok || m2.ID != m.ID || l.MeetingID != m.ID {
50 t.Fatalf("live link not found: ok=%v err=%v", ok, err)
51 }
52 if _, _, ok, _ := s.liveLink(ctx, "nope"); ok {
53 t.Fatal("unknown token answered live")
54 }
55}
56
57func TestReplaceLinkRevokesTheOldOne(t *testing.T) {
58 s, _ := testStore(t)
59 ctx := context.Background()
60 m, old, _ := s.createMeeting(ctx, "x", nil, "person:1")
61 fresh, err := s.replaceLink(ctx, m.ID, "person:1")
62 if err != nil {
63 t.Fatal(err)
64 }
65 if _, _, ok, _ := s.liveLink(ctx, old); ok {
66 t.Fatal("old link still live")
67 }
68 if _, _, ok, _ := s.liveLink(ctx, fresh); !ok {
69 t.Fatal("new link not live")
70 }
71}
72
73func TestEndMeetingKillsItsLinks(t *testing.T) {
74 s, _ := testStore(t)
75 ctx := context.Background()
76 m, tok, _ := s.createMeeting(ctx, "x", nil, "person:1")
77 ended, err := s.endMeeting(ctx, m.ID, "person:1")
78 if err != nil {
79 t.Fatal(err)
80 }
81 if !ended {
82 t.Fatal("the first end reports it changed nothing")
83 }
84 if _, _, ok, _ := s.liveLink(ctx, tok); ok {
85 t.Fatal("link live after end")
86 }
87}
88
89// Review S5-2: the ended check belongs inside the write, not in the
90// handler's earlier read of the row. endMeeting's conditional UPDATE
91// is the authority — the second end changes no row and says so, which
92// is what the API's 409 is built on — and replaceLink re-reads
93// ended_at inside its own transaction, so a replacement cannot land an
94// unrevoked link on a meeting that ended while it was being prepared.
95//
96// Sequential rather than concurrent on purpose: the interleaving that
97// matters (read, pause, other request ends, write) is exactly what
98// calling the second method after the first reproduces, and it does so
99// deterministically. The API-level concurrent case is
100// TestAPIConcurrentEndsAndReplacesSeeTheEndedRow in callstest.
101func TestASecondEndChangesNothingAndAReplaceAfterEndIsRefused(t *testing.T) {
102 s, _ := testStore(t)
103 ctx := context.Background()
104 m, _, _ := s.createMeeting(ctx, "x", nil, "person:1")
105
106 first, err := s.endMeeting(ctx, m.ID, "person:1")
107 if err != nil || !first {
108 t.Fatalf("the first end: ended=%v err=%v", first, err)
109 }
110 // The stored end is the first one's, not the second's: the second
111 // changes no row at all, so ended_by is not overwritten.
112 second, err := s.endMeeting(ctx, m.ID, "person:2")
113 if err != nil {
114 t.Fatal(err)
115 }
116 if second {
117 t.Fatal("a second end reports it ended the meeting")
118 }
119 after, ok, err := s.meetingBySqid(ctx, m.Sqid)
120 if err != nil || !ok {
121 t.Fatalf("re-read: ok=%v err=%v", ok, err)
122 }
123 if after.EndedBy != "person:1" {
124 t.Fatalf("ended_by %q, want the end that won", after.EndedBy)
125 }
126
127 // And a replacement that arrives after the end is refused by the
128 // store itself, with no link row minted.
129 tok, err := s.replaceLink(ctx, m.ID, "person:1")
130 if !errors.Is(err, errMeetingEnded) {
131 t.Fatalf("replace after end: token=%q err=%v, want errMeetingEnded", tok, err)
132 }
133 var live int64
134 if err := s.g.Model(&MeetingLink{}).Where("meeting_id = ? AND revoked_at IS NULL", m.ID).Count(&live).Error; err != nil {
135 t.Fatal(err)
136 }
137 if live != 0 {
138 t.Fatalf("%d live links on an ended meeting, want none", live)
139 }
140}
141
142func TestGrantExpiresTwoHoursAfterStartOrCreation(t *testing.T) {
143 s, now := testStore(t)
144 ctx := context.Background()
145 start := now.Add(3 * time.Hour)
146 scheduled, _, _ := s.createMeeting(ctx, "later", &start, "person:1")
147 immediate, _, _ := s.createMeeting(ctx, "now", nil, "person:1")
148 g1, _ := s.mintGrant(ctx, scheduled, 7)
149 g2, _ := s.mintGrant(ctx, immediate, 7)
150 if !g1.ExpiresAt.Equal(start.Add(grantLife)) {
151 t.Errorf("scheduled grant expires %v, want %v", g1.ExpiresAt, start.Add(grantLife))
152 }
153 if !g2.ExpiresAt.Equal(now.Add(grantLife)) {
154 t.Errorf("immediate grant expires %v, want %v", g2.ExpiresAt, now.Add(grantLife))
155 }
156 again, _ := s.mintGrant(ctx, immediate, 7)
157 if again.ID != g2.ID {
158 t.Fatal("minting twice made a second grant")
159 }
160}
161
162// Review C1-3: followLink reads the link, then mints. A member may
163// replace the link in between, and the grant must not be minted through
164// the link that no longer exists.
165func TestAReplacedLinkMintsNoGrant(t *testing.T) {
166 s, _ := testStore(t)
167 ctx := context.Background()
168 m, tok, _ := s.createMeeting(ctx, "x", nil, "person:1")
169 old, _, _, _ := s.liveLink(ctx, tok)
170 if _, err := s.replaceLink(ctx, m.ID, "person:1"); err != nil {
171 t.Fatal(err)
172 }
173 g, live, err := s.mintGrantViaLink(ctx, old.ID, 7)
174 if err != nil {
175 t.Fatal(err)
176 }
177 if live {
178 t.Fatal("a replaced link answered live")
179 }
180 if g.ID != 0 {
181 t.Fatal("a replaced link minted a grant")
182 }
183 if _, ok, err := s.grantFor(ctx, m.ID, 7); ok || err != nil {
184 t.Fatalf("a grant row exists after minting through a replaced link: ok=%v err=%v", ok, err)
185 }
186
187 // The link that replaced it still mints, once.
188 l2 := MeetingLink{}
189 if err := s.g.Where("meeting_id = ? AND revoked_at IS NULL", m.ID).Take(&l2).Error; err != nil {
190 t.Fatal(err)
191 }
192 g1, live, err := s.mintGrantViaLink(ctx, l2.ID, 7)
193 if err != nil || !live {
194 t.Fatalf("live link: live=%v err=%v", live, err)
195 }
196 g2, _, err := s.mintGrantViaLink(ctx, l2.ID, 7)
197 if err != nil {
198 t.Fatal(err)
199 }
200 if g1.ID != g2.ID {
201 t.Fatal("minting twice through the link made a second grant")
202 }
203}
204
205// Review C1-4: latestPending runs before consumePending's transaction,
206// so the row the caller holds may already be stale. A stale row must
207// never mint, and must not delete the row that superseded it.
208func TestAStalePendingRowMintsNothingAndDeletesNothing(t *testing.T) {
209 s, now := testStore(t)
210 ctx := context.Background()
211 m, tok, _ := s.createMeeting(ctx, "x", nil, "person:1")
212 l, _, _, _ := s.liveLink(ctx, tok)
213 const address = "guest@example.com"
214
215 if err := s.writePending(ctx, address, l); err != nil {
216 t.Fatal(err)
217 }
218 older, ok, err := s.latestPending(ctx, address)
219 if err != nil || !ok {
220 t.Fatalf("first pending row: ok=%v err=%v", ok, err)
221 }
222 *now = now.Add(time.Second)
223 if err := s.writePending(ctx, address, l); err != nil {
224 t.Fatal(err)
225 }
226 newer, _, _ := s.latestPending(ctx, address)
227 if newer.ID == older.ID {
228 t.Fatal("the second write did not make a second row")
229 }
230
231 p := Person{ID: 7, Email: address}
232 dest, err := s.consumePending(ctx, p, older, false)
233 if err != nil {
234 t.Fatal(err)
235 }
236 if dest != "" {
237 t.Fatalf("a stale pending row led to %q", dest)
238 }
239 if _, ok, _ := s.grantFor(ctx, m.ID, p.ID); ok {
240 t.Fatal("a stale pending row minted a grant")
241 }
242 var n int64
243 if err := s.g.Model(&PendingJoin{}).Count(&n).Error; err != nil {
244 t.Fatal(err)
245 }
246 if n != 2 {
247 t.Fatalf("pending rows after a stale consume: %d, want 2", n)
248 }
249
250 // The newest row still works, and takes both rows with it.
251 if dest, err := s.consumePending(ctx, p, newer, false); err != nil || dest != "/m/"+m.Sqid+"/join" {
252 t.Fatalf("the newest row led to %q (err %v)", dest, err)
253 }
254 if err := s.g.Model(&PendingJoin{}).Count(&n).Error; err != nil {
255 t.Fatal(err)
256 }
257 if n != 0 {
258 t.Fatalf("pending rows after consuming: %d, want 0", n)
259 }
260}
261
262// Review C1-7: expired rows were excluded from reads but never deleted.
263func TestSweepDeletesOnlyExpiredPendingRows(t *testing.T) {
264 s, now := testStore(t)
265 ctx := context.Background()
266 _, tok, _ := s.createMeeting(ctx, "x", nil, "person:1")
267 l, _, _, _ := s.liveLink(ctx, tok)
268 if err := s.writePending(ctx, "stale@example.com", l); err != nil {
269 t.Fatal(err)
270 }
271 *now = now.Add(pendingLife + time.Minute)
272 if err := s.writePending(ctx, "live@example.com", l); err != nil {
273 t.Fatal(err)
274 }
275 n, err := s.sweep(ctx, *now)
276 if err != nil {
277 t.Fatal(err)
278 }
279 if n != 1 {
280 t.Fatalf("swept %d rows, want 1", n)
281 }
282 var left []PendingJoin
283 if err := s.g.Find(&left).Error; err != nil {
284 t.Fatal(err)
285 }
286 if len(left) != 1 {
287 t.Fatalf("%d rows left, want 1", len(left))
288 }
289 fp, _ := s.fingerprint("live@example.com")
290 if left[0].AddressFingerprint != fp {
291 t.Fatal("the sweep kept the wrong row")
292 }
293}
294
295// Codex round 2, C2-4: TestAStalePendingRowMintsNothingAndDeletesNothing
296// covers one meeting only. The cross-meeting case is the one
297// deletePending's address-wide (not id-scoped) delete actually protects
298// against: two pending rows for the same address across two different
299// meetings, A then B. Consuming B (the newer) mints B's grant and takes
300// every pending row for the address, A's included. The caller who still
301// holds A's stale id must then get no grant and no error — not an
302// attempt to resurrect a row that is already gone.
303func TestConsumingOneMeetingsPendingRowLeavesAnothersIDStale(t *testing.T) {
304 s, now := testStore(t)
305 ctx := context.Background()
306 const address = "guest@example.com"
307
308 mA, tokA, _ := s.createMeeting(ctx, "a", nil, "person:1")
309 lA, _, _, _ := s.liveLink(ctx, tokA)
310 if err := s.writePending(ctx, address, lA); err != nil {
311 t.Fatal(err)
312 }
313 pendA, ok, err := s.latestPending(ctx, address)
314 if err != nil || !ok {
315 t.Fatalf("meeting A's pending row: ok=%v err=%v", ok, err)
316 }
317
318 *now = now.Add(time.Second)
319 mB, tokB, _ := s.createMeeting(ctx, "b", nil, "person:1")
320 lB, _, _, _ := s.liveLink(ctx, tokB)
321 if err := s.writePending(ctx, address, lB); err != nil {
322 t.Fatal(err)
323 }
324 pendB, ok, err := s.latestPending(ctx, address)
325 if err != nil || !ok || pendB.ID == pendA.ID {
326 t.Fatalf("meeting B's pending row: ok=%v err=%v same-id=%v", ok, err, pendB.ID == pendA.ID)
327 }
328
329 p := Person{ID: 9, Email: address}
330 dest, err := s.consumePending(ctx, p, pendB, false)
331 if err != nil || dest != "/m/"+mB.Sqid+"/join" {
332 t.Fatalf("consuming B's row: dest=%q err=%v", dest, err)
333 }
334 if _, ok, err := s.grantFor(ctx, mB.ID, p.ID); !ok || err != nil {
335 t.Fatalf("no grant for B after consuming its row: ok=%v err=%v", ok, err)
336 }
337
338 dest, err = s.consumePending(ctx, p, pendA, false)
339 if err != nil {
340 t.Fatalf("consuming A's now-stale row answered an error: %v", err)
341 }
342 if dest != "" {
343 t.Fatalf("consuming A's now-stale row led to %q", dest)
344 }
345 if _, ok, err := s.grantFor(ctx, mA.ID, p.ID); ok || err != nil {
346 t.Fatalf("a grant exists for A after its row was already consumed via B: ok=%v err=%v", ok, err)
347 }
348}
349
350// Review C2-2: main.go's Options.NextDue reads this. The ordering is on
351// expires_at, deliberately not on id or created_at (which is what
352// latestPending sorts on, the other way): the row written last is not
353// necessarily the one that expires first.
354//
355// Codex round 3, C3-3: the first version of this test put the
356// earliest-expiring row last (only two rows), so it also passed under
357// Order("id DESC") — it never actually distinguished "sort by expiry"
358// from "sort by id, descending." Three rows here, with the expected
359// minimum in the middle: the first-written row and the last-written row
360// both expire later than it, so neither an ascending nor a descending
361// id sort would find it — only sorting on expires_at does.
362func TestNextExpiryIsTheEarliestExpiryNotTheLatestRow(t *testing.T) {
363 s, now := testStore(t)
364 ctx := context.Background()
365 if got, err := s.nextExpiry(ctx); err != nil || !got.IsZero() {
366 t.Fatalf("nextExpiry with no pending rows: %v (err %v), want the zero time", got, err)
367 }
368
369 _, tok, _ := s.createMeeting(ctx, "x", nil, "person:1")
370 l, _, _, _ := s.liveLink(ctx, tok)
371 base := *now
372
373 *now = base.Add(20 * time.Minute)
374 if err := s.writePending(ctx, "first@example.com", l); err != nil {
375 t.Fatal(err)
376 }
377 // The minimum: written second (neither the lowest nor the highest
378 // id) and expiring soonest.
379 *now = base.Add(5 * time.Minute)
380 if err := s.writePending(ctx, "sooner@example.com", l); err != nil {
381 t.Fatal(err)
382 }
383 *now = base.Add(30 * time.Minute)
384 if err := s.writePending(ctx, "last@example.com", l); err != nil {
385 t.Fatal(err)
386 }
387
388 got, err := s.nextExpiry(ctx)
389 if err != nil {
390 t.Fatal(err)
391 }
392 want := base.Add(5 * time.Minute).Add(pendingLife).UTC()
393 if !got.Equal(want) {
394 t.Fatalf("nextExpiry = %v, want %v (the middle row's expiry, neither first nor last inserted)", got, want)
395 }
396}
397
398// Re-review of 9e067e4: a plain member sign-in (no meeting-link cookie
399// in play) writes no pending_joins row at all — that table exists for
400// the outsider/pending-join path, spec §3.2 — only rastrillo's own
401// auth_links row for the magic link it mailed. nextExpiry has to read
402// that table too, or an app that hibernates with nothing outstanding
403// but one unclicked member link is never woken to sweep it. rastrillo
404// writes auth_links.expires_at as RFC3339 text, UTC
405// (auth/store.go:83); this test writes a row the same way, bypassing
406// gorm entirely since auth_links is rastrillo's table, not a model
407// here.
408func TestNextExpiryAlsoReadsAuthLinks(t *testing.T) {
409 s, now := testStore(t)
410 ctx := context.Background()
411
412 if got, err := s.nextExpiry(ctx); err != nil || !got.IsZero() {
413 t.Fatalf("nextExpiry with nothing outstanding: %v (err %v), want the zero time", got, err)
414 }
415
416 authExpiry := now.Add(20 * time.Minute).UTC()
417 if err := s.g.WithContext(ctx).Exec(
418 `INSERT INTO auth_links (hash, address, purpose, expires_at) VALUES (?, ?, ?, ?)`,
419 "h1", "a@b.test", "signin", authExpiry.Format(time.RFC3339),
420 ).Error; err != nil {
421 t.Fatal(err)
422 }
423 if got, err := s.nextExpiry(ctx); err != nil || !got.Equal(authExpiry) {
424 t.Fatalf("nextExpiry with only an auth_links row: %v (err %v), want %v", got, err, authExpiry)
425 }
426
427 // A pending row that expires sooner (15 minutes out) than the
428 // auth_links row (20 minutes out) must win.
429 _, tok, _ := s.createMeeting(ctx, "x", nil, "person:1")
430 l, _, _, _ := s.liveLink(ctx, tok)
431 if err := s.writePending(ctx, "guest@example.com", l); err != nil {
432 t.Fatal(err)
433 }
434 pendingExpiry := now.Add(pendingLife).UTC()
435 if got, err := s.nextExpiry(ctx); err != nil || !got.Equal(pendingExpiry) {
436 t.Fatalf("nextExpiry with a sooner pending row: %v (err %v), want %v (the pending row)", got, err, pendingExpiry)
437 }
438
439 // Move the auth_links row to be the sooner one instead; it must win
440 // back.
441 soonerAuth := now.Add(time.Minute).UTC()
442 if err := s.g.WithContext(ctx).Exec(
443 `UPDATE auth_links SET expires_at = ? WHERE hash = ?`, soonerAuth.Format(time.RFC3339), "h1",
444 ).Error; err != nil {
445 t.Fatal(err)
446 }
447 if got, err := s.nextExpiry(ctx); err != nil || !got.Equal(soonerAuth) {
448 t.Fatalf("nextExpiry with a sooner auth_links row: %v (err %v), want %v", got, err, soonerAuth)
449 }
450}
451
452func TestPendingIsKeyedFingerprintAndFollowsItsLink(t *testing.T) {
453 s, now := testStore(t)
454 ctx := context.Background()
455 m, tok, _ := s.createMeeting(ctx, "x", nil, "person:1")
456 l, _, _, _ := s.liveLink(ctx, tok)
457 if err := s.writePending(ctx, "Guest@Example.com", l); err != nil {
458 t.Fatal(err)
459 }
460 // Row(), not Scan(): gorm's own Scan treats a []byte destination as
461 // a slice of per-row uint8 results, not one column's raw bytes.
462 var raw []byte
463 if err := s.g.Raw("SELECT address_fingerprint FROM pending_joins").Row().Scan(&raw); err != nil {
464 t.Fatal(err)
465 }
466 if bytes.Contains(bytes.ToLower(raw), []byte("guest@")) {
467 t.Fatal("address stored in plaintext")
468 }
469 if live, _ := s.pendingLive(ctx, "guest@example.com"); !live {
470 t.Fatal("pending not live")
471 }
472 if _, err := s.replaceLink(ctx, m.ID, "person:1"); err != nil {
473 t.Fatal(err)
474 }
475 if live, _ := s.pendingLive(ctx, "guest@example.com"); live {
476 t.Fatal("pending still live after its link was replaced")
477 }
478 *now = now.Add(pendingLife + time.Minute)
479 if _, ok, _ := s.latestPending(ctx, "guest@example.com"); ok {
480 t.Fatal("expired pending returned")
481 }
482}
483