package calls import ( "bytes" "context" "errors" "io" "log/slog" "path/filepath" "testing" "time" "amadan.net/rastrillo/rastrillo/db" "amadan.net/rastrillo/rastrillo/migrate" "amadan.net/oficina/calls/internal/calls/seal" ) func testStore(t *testing.T) (*store, *time.Time) { t.Helper() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) d, err := db.Open(filepath.Join(t.TempDir(), "s.db"), logger) if err != nil { t.Fatal(err) } t.Cleanup(func() { d.Close() }) if _, err := migrate.Apply(context.Background(), d, BootSchema); err != nil { t.Fatal(err) } sl, _ := seal.New(bytes.Repeat([]byte{9}, 32)) now := time.Date(2026, 9, 8, 10, 0, 0, 0, time.UTC) return &store{g: d.G, sealer: sl, clk: newClock(func() time.Time { return now }), logger: logger}, &now } func TestCreateMeetingSealsTitleAndMintsOneLiveLink(t *testing.T) { s, _ := testStore(t) ctx := context.Background() m, token, err := s.createMeeting(ctx, "Board meeting", nil, "person:1") if err != nil { t.Fatal(err) } if bytes.Contains(m.TitleSealed, []byte("Board")) { t.Fatal("title stored in plaintext") } if s.title(m) != "Board meeting" { t.Fatalf("title opened as %q", s.title(m)) } l, m2, ok, err := s.liveLink(ctx, token) if err != nil || !ok || m2.ID != m.ID || l.MeetingID != m.ID { t.Fatalf("live link not found: ok=%v err=%v", ok, err) } if _, _, ok, _ := s.liveLink(ctx, "nope"); ok { t.Fatal("unknown token answered live") } } func TestReplaceLinkRevokesTheOldOne(t *testing.T) { s, _ := testStore(t) ctx := context.Background() m, old, _ := s.createMeeting(ctx, "x", nil, "person:1") fresh, err := s.replaceLink(ctx, m.ID, "person:1") if err != nil { t.Fatal(err) } if _, _, ok, _ := s.liveLink(ctx, old); ok { t.Fatal("old link still live") } if _, _, ok, _ := s.liveLink(ctx, fresh); !ok { t.Fatal("new link not live") } } func TestEndMeetingKillsItsLinks(t *testing.T) { s, _ := testStore(t) ctx := context.Background() m, tok, _ := s.createMeeting(ctx, "x", nil, "person:1") ended, err := s.endMeeting(ctx, m.ID, "person:1") if err != nil { t.Fatal(err) } if !ended { t.Fatal("the first end reports it changed nothing") } if _, _, ok, _ := s.liveLink(ctx, tok); ok { t.Fatal("link live after end") } } // Review S5-2: the ended check belongs inside the write, not in the // handler's earlier read of the row. endMeeting's conditional UPDATE // is the authority — the second end changes no row and says so, which // is what the API's 409 is built on — and replaceLink re-reads // ended_at inside its own transaction, so a replacement cannot land an // unrevoked link on a meeting that ended while it was being prepared. // // Sequential rather than concurrent on purpose: the interleaving that // matters (read, pause, other request ends, write) is exactly what // calling the second method after the first reproduces, and it does so // deterministically. The API-level concurrent case is // TestAPIConcurrentEndsAndReplacesSeeTheEndedRow in callstest. func TestASecondEndChangesNothingAndAReplaceAfterEndIsRefused(t *testing.T) { s, _ := testStore(t) ctx := context.Background() m, _, _ := s.createMeeting(ctx, "x", nil, "person:1") first, err := s.endMeeting(ctx, m.ID, "person:1") if err != nil || !first { t.Fatalf("the first end: ended=%v err=%v", first, err) } // The stored end is the first one's, not the second's: the second // changes no row at all, so ended_by is not overwritten. second, err := s.endMeeting(ctx, m.ID, "person:2") if err != nil { t.Fatal(err) } if second { t.Fatal("a second end reports it ended the meeting") } after, ok, err := s.meetingBySqid(ctx, m.Sqid) if err != nil || !ok { t.Fatalf("re-read: ok=%v err=%v", ok, err) } if after.EndedBy != "person:1" { t.Fatalf("ended_by %q, want the end that won", after.EndedBy) } // And a replacement that arrives after the end is refused by the // store itself, with no link row minted. tok, err := s.replaceLink(ctx, m.ID, "person:1") if !errors.Is(err, errMeetingEnded) { t.Fatalf("replace after end: token=%q err=%v, want errMeetingEnded", tok, err) } var live int64 if err := s.g.Model(&MeetingLink{}).Where("meeting_id = ? AND revoked_at IS NULL", m.ID).Count(&live).Error; err != nil { t.Fatal(err) } if live != 0 { t.Fatalf("%d live links on an ended meeting, want none", live) } } func TestGrantExpiresTwoHoursAfterStartOrCreation(t *testing.T) { s, now := testStore(t) ctx := context.Background() start := now.Add(3 * time.Hour) scheduled, _, _ := s.createMeeting(ctx, "later", &start, "person:1") immediate, _, _ := s.createMeeting(ctx, "now", nil, "person:1") g1, _ := s.mintGrant(ctx, scheduled, 7) g2, _ := s.mintGrant(ctx, immediate, 7) if !g1.ExpiresAt.Equal(start.Add(grantLife)) { t.Errorf("scheduled grant expires %v, want %v", g1.ExpiresAt, start.Add(grantLife)) } if !g2.ExpiresAt.Equal(now.Add(grantLife)) { t.Errorf("immediate grant expires %v, want %v", g2.ExpiresAt, now.Add(grantLife)) } again, _ := s.mintGrant(ctx, immediate, 7) if again.ID != g2.ID { t.Fatal("minting twice made a second grant") } } // Review C1-3: followLink reads the link, then mints. A member may // replace the link in between, and the grant must not be minted through // the link that no longer exists. func TestAReplacedLinkMintsNoGrant(t *testing.T) { s, _ := testStore(t) ctx := context.Background() m, tok, _ := s.createMeeting(ctx, "x", nil, "person:1") old, _, _, _ := s.liveLink(ctx, tok) if _, err := s.replaceLink(ctx, m.ID, "person:1"); err != nil { t.Fatal(err) } g, live, err := s.mintGrantViaLink(ctx, old.ID, 7) if err != nil { t.Fatal(err) } if live { t.Fatal("a replaced link answered live") } if g.ID != 0 { t.Fatal("a replaced link minted a grant") } if _, ok, err := s.grantFor(ctx, m.ID, 7); ok || err != nil { t.Fatalf("a grant row exists after minting through a replaced link: ok=%v err=%v", ok, err) } // The link that replaced it still mints, once. l2 := MeetingLink{} if err := s.g.Where("meeting_id = ? AND revoked_at IS NULL", m.ID).Take(&l2).Error; err != nil { t.Fatal(err) } g1, live, err := s.mintGrantViaLink(ctx, l2.ID, 7) if err != nil || !live { t.Fatalf("live link: live=%v err=%v", live, err) } g2, _, err := s.mintGrantViaLink(ctx, l2.ID, 7) if err != nil { t.Fatal(err) } if g1.ID != g2.ID { t.Fatal("minting twice through the link made a second grant") } } // Review C1-4: latestPending runs before consumePending's transaction, // so the row the caller holds may already be stale. A stale row must // never mint, and must not delete the row that superseded it. func TestAStalePendingRowMintsNothingAndDeletesNothing(t *testing.T) { s, now := testStore(t) ctx := context.Background() m, tok, _ := s.createMeeting(ctx, "x", nil, "person:1") l, _, _, _ := s.liveLink(ctx, tok) const address = "guest@example.com" if err := s.writePending(ctx, address, l); err != nil { t.Fatal(err) } older, ok, err := s.latestPending(ctx, address) if err != nil || !ok { t.Fatalf("first pending row: ok=%v err=%v", ok, err) } *now = now.Add(time.Second) if err := s.writePending(ctx, address, l); err != nil { t.Fatal(err) } newer, _, _ := s.latestPending(ctx, address) if newer.ID == older.ID { t.Fatal("the second write did not make a second row") } p := Person{ID: 7, Email: address} dest, err := s.consumePending(ctx, p, older, false) if err != nil { t.Fatal(err) } if dest != "" { t.Fatalf("a stale pending row led to %q", dest) } if _, ok, _ := s.grantFor(ctx, m.ID, p.ID); ok { t.Fatal("a stale pending row minted a grant") } var n int64 if err := s.g.Model(&PendingJoin{}).Count(&n).Error; err != nil { t.Fatal(err) } if n != 2 { t.Fatalf("pending rows after a stale consume: %d, want 2", n) } // The newest row still works, and takes both rows with it. if dest, err := s.consumePending(ctx, p, newer, false); err != nil || dest != "/m/"+m.Sqid+"/join" { t.Fatalf("the newest row led to %q (err %v)", dest, err) } if err := s.g.Model(&PendingJoin{}).Count(&n).Error; err != nil { t.Fatal(err) } if n != 0 { t.Fatalf("pending rows after consuming: %d, want 0", n) } } // Review C1-7: expired rows were excluded from reads but never deleted. func TestSweepDeletesOnlyExpiredPendingRows(t *testing.T) { s, now := testStore(t) ctx := context.Background() _, tok, _ := s.createMeeting(ctx, "x", nil, "person:1") l, _, _, _ := s.liveLink(ctx, tok) if err := s.writePending(ctx, "stale@example.com", l); err != nil { t.Fatal(err) } *now = now.Add(pendingLife + time.Minute) if err := s.writePending(ctx, "live@example.com", l); err != nil { t.Fatal(err) } n, err := s.sweep(ctx, *now) if err != nil { t.Fatal(err) } if n != 1 { t.Fatalf("swept %d rows, want 1", n) } var left []PendingJoin if err := s.g.Find(&left).Error; err != nil { t.Fatal(err) } if len(left) != 1 { t.Fatalf("%d rows left, want 1", len(left)) } fp, _ := s.fingerprint("live@example.com") if left[0].AddressFingerprint != fp { t.Fatal("the sweep kept the wrong row") } } // Codex round 2, C2-4: TestAStalePendingRowMintsNothingAndDeletesNothing // covers one meeting only. The cross-meeting case is the one // deletePending's address-wide (not id-scoped) delete actually protects // against: two pending rows for the same address across two different // meetings, A then B. Consuming B (the newer) mints B's grant and takes // every pending row for the address, A's included. The caller who still // holds A's stale id must then get no grant and no error — not an // attempt to resurrect a row that is already gone. func TestConsumingOneMeetingsPendingRowLeavesAnothersIDStale(t *testing.T) { s, now := testStore(t) ctx := context.Background() const address = "guest@example.com" mA, tokA, _ := s.createMeeting(ctx, "a", nil, "person:1") lA, _, _, _ := s.liveLink(ctx, tokA) if err := s.writePending(ctx, address, lA); err != nil { t.Fatal(err) } pendA, ok, err := s.latestPending(ctx, address) if err != nil || !ok { t.Fatalf("meeting A's pending row: ok=%v err=%v", ok, err) } *now = now.Add(time.Second) mB, tokB, _ := s.createMeeting(ctx, "b", nil, "person:1") lB, _, _, _ := s.liveLink(ctx, tokB) if err := s.writePending(ctx, address, lB); err != nil { t.Fatal(err) } pendB, ok, err := s.latestPending(ctx, address) if err != nil || !ok || pendB.ID == pendA.ID { t.Fatalf("meeting B's pending row: ok=%v err=%v same-id=%v", ok, err, pendB.ID == pendA.ID) } p := Person{ID: 9, Email: address} dest, err := s.consumePending(ctx, p, pendB, false) if err != nil || dest != "/m/"+mB.Sqid+"/join" { t.Fatalf("consuming B's row: dest=%q err=%v", dest, err) } if _, ok, err := s.grantFor(ctx, mB.ID, p.ID); !ok || err != nil { t.Fatalf("no grant for B after consuming its row: ok=%v err=%v", ok, err) } dest, err = s.consumePending(ctx, p, pendA, false) if err != nil { t.Fatalf("consuming A's now-stale row answered an error: %v", err) } if dest != "" { t.Fatalf("consuming A's now-stale row led to %q", dest) } if _, ok, err := s.grantFor(ctx, mA.ID, p.ID); ok || err != nil { t.Fatalf("a grant exists for A after its row was already consumed via B: ok=%v err=%v", ok, err) } } // Review C2-2: main.go's Options.NextDue reads this. The ordering is on // expires_at, deliberately not on id or created_at (which is what // latestPending sorts on, the other way): the row written last is not // necessarily the one that expires first. // // Codex round 3, C3-3: the first version of this test put the // earliest-expiring row last (only two rows), so it also passed under // Order("id DESC") — it never actually distinguished "sort by expiry" // from "sort by id, descending." Three rows here, with the expected // minimum in the middle: the first-written row and the last-written row // both expire later than it, so neither an ascending nor a descending // id sort would find it — only sorting on expires_at does. func TestNextExpiryIsTheEarliestExpiryNotTheLatestRow(t *testing.T) { s, now := testStore(t) ctx := context.Background() if got, err := s.nextExpiry(ctx); err != nil || !got.IsZero() { t.Fatalf("nextExpiry with no pending rows: %v (err %v), want the zero time", got, err) } _, tok, _ := s.createMeeting(ctx, "x", nil, "person:1") l, _, _, _ := s.liveLink(ctx, tok) base := *now *now = base.Add(20 * time.Minute) if err := s.writePending(ctx, "first@example.com", l); err != nil { t.Fatal(err) } // The minimum: written second (neither the lowest nor the highest // id) and expiring soonest. *now = base.Add(5 * time.Minute) if err := s.writePending(ctx, "sooner@example.com", l); err != nil { t.Fatal(err) } *now = base.Add(30 * time.Minute) if err := s.writePending(ctx, "last@example.com", l); err != nil { t.Fatal(err) } got, err := s.nextExpiry(ctx) if err != nil { t.Fatal(err) } want := base.Add(5 * time.Minute).Add(pendingLife).UTC() if !got.Equal(want) { t.Fatalf("nextExpiry = %v, want %v (the middle row's expiry, neither first nor last inserted)", got, want) } } // Re-review of 9e067e4: a plain member sign-in (no meeting-link cookie // in play) writes no pending_joins row at all — that table exists for // the outsider/pending-join path, spec §3.2 — only rastrillo's own // auth_links row for the magic link it mailed. nextExpiry has to read // that table too, or an app that hibernates with nothing outstanding // but one unclicked member link is never woken to sweep it. rastrillo // writes auth_links.expires_at as RFC3339 text, UTC // (auth/store.go:83); this test writes a row the same way, bypassing // gorm entirely since auth_links is rastrillo's table, not a model // here. func TestNextExpiryAlsoReadsAuthLinks(t *testing.T) { s, now := testStore(t) ctx := context.Background() if got, err := s.nextExpiry(ctx); err != nil || !got.IsZero() { t.Fatalf("nextExpiry with nothing outstanding: %v (err %v), want the zero time", got, err) } authExpiry := now.Add(20 * time.Minute).UTC() if err := s.g.WithContext(ctx).Exec( `INSERT INTO auth_links (hash, address, purpose, expires_at) VALUES (?, ?, ?, ?)`, "h1", "a@b.test", "signin", authExpiry.Format(time.RFC3339), ).Error; err != nil { t.Fatal(err) } if got, err := s.nextExpiry(ctx); err != nil || !got.Equal(authExpiry) { t.Fatalf("nextExpiry with only an auth_links row: %v (err %v), want %v", got, err, authExpiry) } // A pending row that expires sooner (15 minutes out) than the // auth_links row (20 minutes out) must win. _, tok, _ := s.createMeeting(ctx, "x", nil, "person:1") l, _, _, _ := s.liveLink(ctx, tok) if err := s.writePending(ctx, "guest@example.com", l); err != nil { t.Fatal(err) } pendingExpiry := now.Add(pendingLife).UTC() if got, err := s.nextExpiry(ctx); err != nil || !got.Equal(pendingExpiry) { t.Fatalf("nextExpiry with a sooner pending row: %v (err %v), want %v (the pending row)", got, err, pendingExpiry) } // Move the auth_links row to be the sooner one instead; it must win // back. soonerAuth := now.Add(time.Minute).UTC() if err := s.g.WithContext(ctx).Exec( `UPDATE auth_links SET expires_at = ? WHERE hash = ?`, soonerAuth.Format(time.RFC3339), "h1", ).Error; err != nil { t.Fatal(err) } if got, err := s.nextExpiry(ctx); err != nil || !got.Equal(soonerAuth) { t.Fatalf("nextExpiry with a sooner auth_links row: %v (err %v), want %v", got, err, soonerAuth) } } func TestPendingIsKeyedFingerprintAndFollowsItsLink(t *testing.T) { s, now := testStore(t) ctx := context.Background() m, tok, _ := s.createMeeting(ctx, "x", nil, "person:1") l, _, _, _ := s.liveLink(ctx, tok) if err := s.writePending(ctx, "Guest@Example.com", l); err != nil { t.Fatal(err) } // Row(), not Scan(): gorm's own Scan treats a []byte destination as // a slice of per-row uint8 results, not one column's raw bytes. var raw []byte if err := s.g.Raw("SELECT address_fingerprint FROM pending_joins").Row().Scan(&raw); err != nil { t.Fatal(err) } if bytes.Contains(bytes.ToLower(raw), []byte("guest@")) { t.Fatal("address stored in plaintext") } if live, _ := s.pendingLive(ctx, "guest@example.com"); !live { t.Fatal("pending not live") } if _, err := s.replaceLink(ctx, m.ID, "person:1"); err != nil { t.Fatal(err) } if live, _ := s.pendingLive(ctx, "guest@example.com"); live { t.Fatal("pending still live after its link was replaced") } *now = now.Add(pendingLife + time.Minute) if _, ok, _ := s.latestPending(ctx, "guest@example.com"); ok { t.Fatal("expired pending returned") } }