oficina / meet Public

fix: nextExpiry covers sessions, retry deadline on error, test fixtures

Codex round 3 (pinned to 9e067e4, before c5981ca's auth_links fix) had
C3-1 as its blocker. c5981ca closed the auth_links half; this closes
the rest:

- store.nextExpiry now also reads MIN(expires_at) over rastrillo's
  sessions table (same RFC3339 text shape as auth_links), since
  auth.Sweep cleans both in one call. nextDue returns a one-hour retry
  deadline instead of the zero time on a query error, so a transient
  read failure delays the wake rather than cancelling it outright.
- TestNextExpiryIsTheEarliestExpiryNotTheLatestRow rewritten with three
  rows (expected minimum in the middle) so neither an ascending nor a
  descending id sort can pass it by accident (C3-3).
- meettest's App(context.Background(), ...) call sites now use a new
  testCtx(t) helper (WithCancel + t.Cleanup) so no hourly sweep ticker
  outlives its test. The expired non-member /continue subtest asserts
  pendingJoins(t) equals 1 before advancing the clock, since the same
  sent=1 redirect is also the answer to a request that wrote nothing.

Not fixed: auth.Sweep itself is context-free in rastrillo
(auth/store.go:117), with no seam to bound from Meet's side - noted in
the findings record rather than solved.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 19ccf42c9a88fe782df183ca7cea56f1842ecfdf parent c5981ca
9 files changed, +203 −40
  • docs/superpowers/reviews/2026-09-09-codex-code-r1.md +98 −0
  • internal/meet/app.go +17 −7
  • internal/meet/store.go +35 −22
  • internal/meet/store_test.go +16 −5
  • internal/meettest/config_test.go +14 −3
  • internal/meettest/harness_test.go +14 −0
  • internal/meettest/join_test.go +7 −0
  • internal/meettest/schema_test.go +1 −2
  • internal/meettest/signin_test.go +1 −1

CI passed — run details

diff --git a/docs/superpowers/reviews/2026-09-09-codex-code-r1.md b/docs/superpowers/reviews/2026-09-09-codex-code-r1.md
index f371613..9a43526 100644
--- a/docs/superpowers/reviews/2026-09-09-codex-code-r1.md
+++ b/docs/superpowers/reviews/2026-09-09-codex-code-r1.md
@@ -270,3 +270,101 @@ logic. This is the same position round 1 took and round 2 flagged as
still open; it is being named again rather than resolved, because doing
it properly needs a harness for deterministic interleaving (e.g. hooks
around the transaction, or a mock DB) that does not exist yet.
+
+## Round 3
+
+Reviewer: GPT-6 (Codex), read-only sandbox. Pinned to `9e067e4` (round
+2's fix commit, before `c5981ca` and the further commit this section
+describes). Verdict: request changes. Verbatim below.
+
+### The review, verbatim
+
+**Verdict: request changes.** Reviewed `9e067e41c2586cfbee41de42cffa43044f7c45fe`. No files modified; no network used. Tests were inspected, not executed in the read-only sandbox.
+
+| Finding | Status | Code assessment |
+|---|---|---|
+| C2-1 | **Closed** | `mailredact.go:87` logs only the root cause’s type and `address_seen`; the returned error is fixed text without an unwrap chain. `config.go:116` wraps configured mailers, including the development fallback. |
+| C2-2 | **Half-closed** | Main correctly assigns `opts.NextDue = rt.NextDue`. The callback overlooks auth links and sessions, so cleanup can still remain unscheduled: C3-1. |
+| C2-3 | **Half-closed** | Boot cleanup is synchronous; `NewForTest` starts no ticker; pending deletion is limited to 1000 rows per call. The auth cleanup remains unbounded and uncancellable, and some tests still create immortal tickers: C3-2. |
+| C2-4 | **Half-closed** | Production mail logging, auth-link deletion, cross-meeting stale continuation, expired continuation and primary-file checks now have coverage. Slow/failed person-query log coverage remains absent. Some new assertions are narrower than claimed; see below. |
+
+C1-1 remains explicitly deferred. The declined transaction-interleaving and concurrent-budget tests remain recorded as uncovered; their absence does not independently block this verdict.
+
+**C3-1 — P2 — The wake callback reports “nothing scheduled” while auth records need cleanup.**
+
+Location: [internal/meet/app.go:249](/home/paulca/amadan.net/oficina/meet/.claude/worktrees/slice-1/internal/meet/app.go:249), backed by `internal/meet/store.go:397`.
+
+`nextExpiry` examines only `pending_joins`. A member requesting sign-in without a join cookie reaches auth without writing a pending row (`auth.go:83`). Signin writes an `auth_links` row before attempting delivery, but `NextDue` returns zero. If the app hibernates before expiry, that unclicked link has no scheduled cleanup wake.
+
+The new `TestSweepClearsUnclickedMagicLinks` constructs precisely this situation, but invokes cleanup manually rather than checking its scheduling.
+
+This also affects outsider requests: multiple magic links can exist for one address, while consuming one continuation deletes every pending row for that address. Remaining unclicked auth links then cease to have a wake deadline. Sessions are likewise absent from the calculation.
+
+Rastrillo’s contract and implementation agree: zero means no schedule (`serve.go:122`, `:554`). **Returning a past pending expiry is correct:** the framework preserves its Unix timestamp, and the locally inspected platform selects positive deadlines at or before now. The defect is the incomplete calculation of “nothing scheduled.” Returning zero on a query error (`app.go:247`) also abandons the cleanup retry.
+
+Correction: include all records that cleanup owns in the deadline calculation, or maintain a bounded recurring cleanup deadline independently of pending rows. Use a finite retry deadline on lookup failure. Test auth-only state, remaining links after pending consumption, empty state and overdue work through `Runtime.NextDue`.
+
+**C3-2 — P3 — The timeout does not bound the whole sweep, and teardown remains incomplete.**
+
+Location: [internal/meet/app.go:84](/home/paulca/amadan.net/oficina/meet/.claude/worktrees/slice-1/internal/meet/app.go:84), also `:109`, `:187`; `internal/meettest/signin_test.go:90`.
+
+The 30-second context reaches `store.sweep`, but `auth.Sweep(now)` receives no context. Rastrillo’s `auth/store.go:119` and `sessions/sessions.go:447` use context-free `DB.Exec` and delete all matching rows. A wait for the writer connection therefore ignores Meet’s timeout. Cancellation during an hourly pass also cannot interrupt this part.
+
+The 1000-row pending limit is correctly implemented. It does not bound auth deletion or the hourly pass’s total duration.
+
+Moving ticker creation out of `build` fixes the normal harness leak. However, the new production-mail test and the successful development/schema tests still call `App(context.Background(), …)` and close the database without cancelling the ticker. There is also no completion handle for callers to await before closing the database.
+
+Correction: make auth cleanup context-aware and bounded; provide cancellation plus completion waiting. Use cancellable contexts in tests that intentionally exercise `App`. Add deadline and shutdown tests.
+
+**C3-3 — P3 — The expiry-ordering test also passes when selecting the latest row.**
+
+Location: [internal/meet/store_test.go:311](/home/paulca/amadan.net/oficina/meet/.claude/worktrees/slice-1/internal/meet/store_test.go:311).
+
+The earliest-expiring row is deliberately inserted last. Consequently, replacing `Order("expires_at ASC")` with `Order("id DESC")` still returns the expected row. The test does not establish its named distinction, “earliest expiry, not latest row.”
+
+Correction: include a later-inserted row whose expiry is later than the expected minimum. A three-row fixture can distinguish expiry ordering from both ascending and descending ID ordering.
+
+The new and substantively changed tests establish the following:
+
+| Test | Does it prove its claim? |
+|---|---|
+| `TestMailErrorsCarryNoAddress` | **Yes, for its fixture.** Checks fixed returned text, absence of the local part, root type and `address_seen=true`. |
+| `TestNoFragmentOfAnAddressReachesTheLog` | **Partly.** Checks selected fragments of both fixture addresses. It does not reproduce C2-1’s Kelvin-prefix offset trap: its `K`s are ASCII, and dotted I occurs after the matched recipient. The current implementation is nevertheless safe by inspection. |
+| `TestAddressSeenIsCaseInsensitiveAndNeedsNoSlicing` | **Yes for the boolean cases.** “Needs no slicing” is established by inspecting the implementation, not these assertions. |
+| `TestTheWrapperForwardsDeliversAndSendList` | **Partly.** Checks interface preservation, the logged sender’s false delivery status, successful list invocation and sanitized list failure. It does not verify forwarding the actual message fields or context. |
+| `TestSigninOverAFailingMailerLeavesNoAddressInTheLog` | **Yes for the complete fixture address through the app path.** Removing the wrapping would expose it and fail the test. Contrary to the disposition, it does not assert absence of address fragments or exercise person-query logging. |
+| `TestConsumingOneMeetingsPendingRowLeavesAnothersIDStale` | **Yes for sequential stale rejection.** Directly checks B’s grant, A’s empty destination and absence of A’s grant. It does not directly assert deletion of all pending rows; setup errors are also discarded. |
+| `TestNextExpiryIsTheEarliestExpiryNotTheLatestRow` | **No for the ordering distinction:** C3-3. The empty-table zero assertion is sound. It does not exercise `Runtime.NextDue`. |
+| `TestSweepClearsUnclickedMagicLinks` | **Yes for explicit auth-link cleanup.** Direct row counts establish preservation at +11 minutes and deletion at +16. Removing `auth.Sweep` would fail it. Scheduling, restart, sessions and execution bounds remain untested. |
+| Expired non-member `/continue` subtest | **Substantially.** Exercises an existing guest session, expired pending lookup, `/welcome`, denied B access and retained A access. It should assert the pending row exists before advancing time: `sent=1` alone also represents refusal. |
+| `TestTheDatabaseNeverHoldsAPlaintextTitle` | **Yes for its creation fixture.** Missing primary files now fail; only missing optional sidecars are tolerated. Scans run before and after checkpoint. |
+| `TestAReplacedLinkMintsNoGrant` | **Improved.** The negative grant lookup now fails on database errors. Sequential scope is unchanged. |
+| `TestSharedClientAllowanceIsExhaustedByOneClient` | **Yes.** The renamed test accurately describes 21 different addresses exhausting one client’s allowance. |
+
+`TestASuccessfulSendIsUntouched` still checks only successful return, silence and nil preservation—not argument forwarding. Existing small sweep fixtures establish expiry filtering, but cannot establish the new 1000-row limit or multi-page loop.
+
+The remaining deployment blocker is C3-1: cleanup scheduling still misses the auth records it was introduced to remove.
+
+### Dispositions, C3-1 to C3-3
+
+| Finding | Disposition |
+|---|---|
+| C3-1 — `NextDue` blind to `auth_links` and `sessions`; zero on query error | **Closed**, in two commits. `c5981ca` first closed the `auth_links` half: `store.nextExpiry` took the earlier of `pending_joins` and a new `MIN(expires_at)` read over `auth_links`. This section's own commit finished it: `nextExpiry` now also reads `MIN(expires_at)` over rastrillo's `sessions` table (same RFC3339-text shape, confirmed in `sessions/migrations/0001_init.sql` and `sessions/sessions.go:157` — `auth.Sweep` cleans both `auth_links` and `sessions` in one call, `auth/store.go:117` delegating to `sessions.Sweep`, so both need the same wake guarantee), and takes the earliest of all three sources. `app.nextDue` no longer returns the zero time on a query error: it now answers `a.store.now().Add(sweepEvery)` — the same one-hour retry deadline the healthy "nothing due sooner than the next sweep" path already uses — and still logs the error. `TestNextExpiryAlsoReadsAuthLinks` (round 2) and this round's fixes are covered by `TestNextDueWakesForAPlainMemberSignin` (`internal/meettest/signin_test.go`), which drives a real plain `POST /signin` with no pending row and checks `TestHandle.NextDue()` (new, forwarding to `a.nextDue()`) directly. Not added: a dedicated unit test for the query-error retry-deadline branch itself (would need a store whose `nextExpiry` can be made to fail on demand, which the current store has no seam for) and a `sessions`-only scenario through the full HTTP path (session rows come from `sessions.SignIn`, which every signed-in request already exercises, so it is covered incidentally by every other test but not asserted on directly). |
+| C3-2 — `auth.Sweep` is context-free; some tests still leak an immortal ticker | **Partial**, as the coordinator's brief for this round anticipated. `auth.Sweep(now)` (rastrillo's own method, `auth/store.go:117`) uses `a.cfg.DB.Exec` with no context parameter at all — there is no seam in rastrillo to bound it with, short of forking or wrapping the whole `*rauth.Auth`, which is out of scope for a Meet-side fix. This is noted here rather than solved. What *is* fixed: the tests C3-2 named — `internal/meettest/signin_test.go`'s production-mail test, `config_test.go`'s three `meet.App` call sites, and `schema_test.go`'s one — now build their context with a new `testCtx(t)` helper (`context.WithCancel` plus `t.Cleanup(cancel)`), so `App`'s hourly ticker is cancelled when the test ends rather than continuing to poll a database the test is about to close. `harness_test.go`'s own `newHarness` already used `meet.NewForTest`, which starts no ticker at all (round 2), so it needed no change. |
+| C3-3 — the expiry-ordering test also passes under `Order("id DESC")` | **Closed.** `TestNextExpiryIsTheEarliestExpiryNotTheLatestRow` now writes three rows — first-inserted expiring at +20m, second-inserted (the expected minimum) at +5m, third/last-inserted at +30m — so neither an ascending nor a descending id sort would find the right answer; only sorting on `expires_at` does. |
+
+Also addressed from the test-assessment table, though not itself a
+lettered finding: the expired non-member `/continue` subtest
+(`internal/meettest/join_test.go`) now asserts `pendingJoins(t) == 1`
+right after the outsider's second `POST /signin` and before advancing
+the clock, closing the gap the table named — "?sent=1" alone is also
+the answer to a request that wrote nothing at all (spec §3.2's
+same-answer rule), so the row's existence needed a direct assertion,
+not an inference from the redirect.
+
+Not addressed, named rather than claimed: `auth.Sweep`'s own
+unboundedness (the context-free half of C3-2, rastrillo's surface, not
+Meet's, per above); a unit test for `nextDue`'s query-error retry
+branch; and every item C2-4/C1-8 already recorded as uncovered
+(transaction interleavings, concurrent budget consumption, `auth.Sweep`
+execution-time bounds, `Runtime.NextDue`'s platform-side poll route).
diff --git a/internal/meet/app.go b/internal/meet/app.go
index 90fb8cd..5419246 100644
--- a/internal/meet/app.go
+++ b/internal/meet/app.go
@@ -240,23 +240,33 @@ type Runtime struct {
}
// nextDue is Runtime.NextDue's implementation: the earliest of the next
-// pending_joins.expires_at and one hour from now — the hourly sweep's
-// own interval, so the platform never lets the app sleep past a sweep
-// it would otherwise have run — or the zero time when no pending row
-// exists at all, meaning there is nothing to wake early for.
+// nextExpiry (across pending_joins, auth_links and sessions) and one
+// hour from now — the hourly sweep's own interval, so the platform
+// never lets the app sleep past a sweep it would otherwise have run —
+// or the zero time when nothing at all is outstanding, meaning there is
+// nothing to wake early for.
+//
+// A read failure does NOT answer the zero time: zero means "nothing
+// scheduled" to the platform (serve.go's contract), so returning it on
+// a transient error would cancel the wake entirely rather than merely
+// delay it. An hour out is the same retry deadline the healthy path
+// already uses for "nothing due sooner than the next sweep," so a
+// flaky read gets one more try at the next poll instead of going quiet
+// (Codex round 3, C3-1).
func (a *app) nextDue() time.Time {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
+ retry := a.store.now().Add(sweepEvery)
exp, err := a.store.nextExpiry(ctx)
if err != nil {
a.logger.Error("meet: next due: read next expiry", "err", err)
- return time.Time{}
+ return retry
}
if exp.IsZero() {
return time.Time{}
}
- if hourly := a.store.now().Add(sweepEvery); hourly.Before(exp) {
- return hourly
+ if retry.Before(exp) {
+ return retry
}
return exp
}
diff --git a/internal/meet/store.go b/internal/meet/store.go
index b1b27e6..f6833a8 100644
--- a/internal/meet/store.go
+++ b/internal/meet/store.go
@@ -392,10 +392,10 @@ func (s *store) sweep(ctx context.Context, now time.Time) (int64, error) {
return res.RowsAffected, res.Error
}
-// nextExpiry answers the earliest expires_at across pending_joins AND
-// rastrillo's own auth_links, or the zero time when both are empty. It
-// is the platform wake-up input for cmd/meet/main.go's Options.NextDue
-// (review C2-2).
+// nextExpiry answers the earliest expires_at across pending_joins,
+// rastrillo's own auth_links, and rastrillo's own sessions, or the zero
+// time when all three are empty. It is the platform wake-up input for
+// cmd/meet/main.go's Options.NextDue (review C2-2).
//
// auth_links matters here too: a plain member sign-in writes no pending
// row at all (that table exists for the outsider/pending-join path,
@@ -404,6 +404,12 @@ func (s *store) sweep(ctx context.Context, now time.Time) (int64, error) {
// but one unclicked member link would never be woken to sweep it — the
// platform sees a zero NextDue and assumes there is nothing to do
// (round 2 follow-up, caught on re-review of 9e067e4).
+//
+// sessions is here for the same reason, one level further out:
+// auth.Sweep cleans auth_links AND sessions in one call
+// (auth/store.go:117 delegates to sessions.Sweep), so a session sitting
+// alone with nothing else outstanding needs the same wake-up guarantee
+// an unclicked link does (Codex round 3, C3-1).
func (s *store) nextExpiry(ctx context.Context) (time.Time, error) {
var p PendingJoin
err := s.g.WithContext(ctx).Order("expires_at ASC").Take(&p).Error
@@ -417,35 +423,42 @@ func (s *store) nextExpiry(ctx context.Context) (time.Time, error) {
pending = p.ExpiresAt
}
- authLink, err := s.nextAuthLinkExpiry(ctx)
+ authLink, err := s.rastrilloTextExpiry(ctx, "auth_links")
+ if err != nil {
+ return time.Time{}, err
+ }
+ session, err := s.rastrilloTextExpiry(ctx, "sessions")
if err != nil {
return time.Time{}, err
}
- switch {
- case pending.IsZero():
- return authLink, nil
- case authLink.IsZero():
- return pending, nil
- case authLink.Before(pending):
- return authLink, nil
- default:
- return pending, nil
+ next := time.Time{}
+ for _, t := range []time.Time{pending, authLink, session} {
+ if t.IsZero() {
+ continue
+ }
+ if next.IsZero() || t.Before(next) {
+ next = t
+ }
}
+ return next, nil
}
-// nextAuthLinkExpiry reads rastrillo's own auth_links table directly —
-// it is rastrillo/auth's table, not a store model, and rastrillo writes
-// to it over database/sql rather than gorm, storing expires_at as
-// RFC3339 text via now.UTC().Format(time.RFC3339)
-// (auth/store.go:83, auth/migrations/0001_init.sql). MIN() over that
+// rastrilloTextExpiry reads the earliest expires_at from one of
+// rastrillo's own tables — auth_links or sessions, the only two callers
+// pass, never anything caller-supplied. Both are rastrillo's tables, not
+// store models: rastrillo writes to them over database/sql rather than
+// gorm, storing expires_at as RFC3339 text via
+// now.UTC().Format(time.RFC3339) (auth/store.go:83,152,
+// sessions/sessions.go:157; see auth/migrations/0001_init.sql and
+// sessions/migrations/0001_init.sql for the column). MIN() over that
// text column sorts correctly because every row is formatted the same
// fixed-width, UTC way, so lexicographic and chronological order agree.
// The zero time means no row; an empty table's MIN() is a SQL NULL, not
// an empty string, hence sql.NullString rather than a bare string.
-func (s *store) nextAuthLinkExpiry(ctx context.Context) (time.Time, error) {
+func (s *store) rastrilloTextExpiry(ctx context.Context, table string) (time.Time, error) {
var raw sql.NullString
- if err := s.g.WithContext(ctx).Raw("SELECT MIN(expires_at) FROM auth_links").Row().Scan(&raw); err != nil {
+ if err := s.g.WithContext(ctx).Raw("SELECT MIN(expires_at) FROM " + table).Row().Scan(&raw); err != nil {
return time.Time{}, err
}
if !raw.Valid || raw.String == "" {
@@ -453,7 +466,7 @@ func (s *store) nextAuthLinkExpiry(ctx context.Context) (time.Time, error) {
}
t, err := time.Parse(time.RFC3339, raw.String)
if err != nil {
- return time.Time{}, fmt.Errorf("meet: parse auth_links.expires_at: %w", err)
+ return time.Time{}, fmt.Errorf("meet: parse %s.expires_at: %w", table, err)
}
return t, nil
}
diff --git a/internal/meet/store_test.go b/internal/meet/store_test.go
index 776517c..e235894 100644
--- a/internal/meet/store_test.go
+++ b/internal/meet/store_test.go
@@ -293,6 +293,14 @@ func TestConsumingOneMeetingsPendingRowLeavesAnothersIDStale(t *testing.T) {
// 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()
@@ -305,16 +313,19 @@ func TestNextExpiryIsTheEarliestExpiryNotTheLatestRow(t *testing.T) {
base := *now
*now = base.Add(20 * time.Minute)
- if err := s.writePending(ctx, "later@example.com", l); err != nil {
+ if err := s.writePending(ctx, "first@example.com", l); err != nil {
t.Fatal(err)
}
- // Written last (the higher id), but its expiry is earlier than the
- // row above — the only way this test can fail is if nextExpiry
- // sorted on something other than expires_at.
+ // 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 {
@@ -322,7 +333,7 @@ func TestNextExpiryIsTheEarliestExpiryNotTheLatestRow(t *testing.T) {
}
want := base.Add(5 * time.Minute).Add(pendingLife).UTC()
if !got.Equal(want) {
- t.Fatalf("nextExpiry = %v, want %v (the sooner row's expiry)", got, want)
+ t.Fatalf("nextExpiry = %v, want %v (the middle row's expiry, neither first nor last inserted)", got, want)
}
}
diff --git a/internal/meettest/config_test.go b/internal/meettest/config_test.go
index 2a48a10..5339d83 100644
--- a/internal/meettest/config_test.go
+++ b/internal/meettest/config_test.go
@@ -23,6 +23,17 @@ func openDB(t *testing.T) *db.DB {
return d
}
+// testCtx returns a context cancelled at the end of the test, so a
+// successful meet.App build's hourly sweep ticker does not outlive it
+// (Codex round 3, C3-2: App(context.Background(), ...) in a test leaves
+// a goroutine running against a database the test is about to close).
+func testCtx(t *testing.T) context.Context {
+ t.Helper()
+ ctx, cancel := context.WithCancel(context.Background())
+ t.Cleanup(cancel)
+ return ctx
+}
+
func TestProductionRefusesToStartWithoutSecrets(t *testing.T) {
// validDEK is a valid 32-byte base64 DEK, used in the "no mailer"
// and "no allowlist" cases so each refuses for its own named
@@ -36,20 +47,20 @@ func TestProductionRefusesToStartWithoutSecrets(t *testing.T) {
"no allowlist": {Env: "production", Origin: "https://x.test", InstanceKey: "k", DEK: validDEK, Mailer: nopMailer{}},
}
for name, cfg := range cases {
- if _, _, err := meet.App(context.Background(), openDB(t), cfg); err == nil {
+ if _, _, err := meet.App(testCtx(t), openDB(t), cfg); err == nil {
t.Errorf("%s: production started without it", name)
}
}
}
func TestDevelopmentStartsWithNothing(t *testing.T) {
- if _, _, err := meet.App(context.Background(), openDB(t), meet.Config{Env: meet.EnvDevelopment}); err != nil {
+ if _, _, err := meet.App(testCtx(t), openDB(t), meet.Config{Env: meet.EnvDevelopment}); err != nil {
t.Fatalf("development refused: %v", err)
}
}
func TestBadDEKIsRefusedEvenInDevelopment(t *testing.T) {
- _, _, err := meet.App(context.Background(), openDB(t), meet.Config{Env: meet.EnvDevelopment, DEK: "not base64!"})
+ _, _, err := meet.App(testCtx(t), openDB(t), meet.Config{Env: meet.EnvDevelopment, DEK: "not base64!"})
if err == nil || !strings.Contains(err.Error(), "base64") {
t.Fatalf("want a base64 refusal, got %v", err)
}
diff --git a/internal/meettest/harness_test.go b/internal/meettest/harness_test.go
index f775a21..ecb8576 100644
--- a/internal/meettest/harness_test.go
+++ b/internal/meettest/harness_test.go
@@ -68,6 +68,20 @@ func (x *harness) authLinks(t *testing.T) int {
return n
}
+// pendingJoins counts Meet's own pending_joins rows directly — used to
+// confirm a scenario actually wrote the row it means to test the expiry
+// of, rather than trusting a same-answer redirect alone: POST /signin's
+// "?sent=1" also covers a request that never wrote anything (Codex
+// round 3, C3-3's test-assessment table).
+func (x *harness) pendingJoins(t *testing.T) int {
+ t.Helper()
+ var n int
+ if err := x.db.G.Raw("SELECT count(*) FROM pending_joins").Scan(&n).Error; err != nil {
+ t.Fatal(err)
+ }
+ return n
+}
+
type recordingMailer struct {
mu sync.Mutex
to []string
diff --git a/internal/meettest/join_test.go b/internal/meettest/join_test.go
index cba8471..e38d3f1 100644
--- a/internal/meettest/join_test.go
+++ b/internal/meettest/join_test.go
@@ -319,6 +319,13 @@ func TestReplacedLinkFallbackForSignedInPerson(t *testing.T) {
if rec := other.post("/signin", url.Values{"address": {"guest@example.com"}}); rec.Header().Get("Location") != "/signin?sent=1" {
t.Fatalf("outsider's request: %s", rec.Header().Get("Location"))
}
+ // "?sent=1" alone is also the answer to a refusal (spec §3.2's
+ // same-answer rule) — confirm the pending row this subtest means
+ // to expire was actually written, not just that the request was
+ // accepted-looking (Codex round 3, C3-3).
+ if n := x.pendingJoins(t); n != 1 {
+ t.Fatalf("pending_joins rows after the outsider's second POST /signin: %d, want 1", n)
+ }
// The link stays live; only the row runs out (fifteen minutes).
x.advance(16 * time.Minute)
diff --git a/internal/meettest/schema_test.go b/internal/meettest/schema_test.go
index 8d1097a..33401f2 100644
--- a/internal/meettest/schema_test.go
+++ b/internal/meettest/schema_test.go
@@ -1,7 +1,6 @@
package meettest
import (
- "context"
"testing"
"amadan.net/oficina/meet/internal/meet"
@@ -9,7 +8,7 @@ import (
func TestBootCreatesEveryTable(t *testing.T) {
d := openDB(t)
- if _, _, err := meet.App(context.Background(), d, meet.Config{Env: meet.EnvDevelopment}); err != nil {
+ if _, _, err := meet.App(testCtx(t), d, meet.Config{Env: meet.EnvDevelopment}); err != nil {
t.Fatal(err)
}
for _, table := range []string{"persons", "meetings", "meeting_links", "guest_grants", "pending_joins", "sessions", "auth_links"} {
diff --git a/internal/meettest/signin_test.go b/internal/meettest/signin_test.go
index 6a35cc9..e2e055f 100644
--- a/internal/meettest/signin_test.go
+++ b/internal/meettest/signin_test.go
@@ -88,7 +88,7 @@ func TestSigninOverAFailingMailerLeavesNoAddressInTheLog(t *testing.T) {
t.Cleanup(func() { d.Close() })
failing := failingMailer{err: fmt.Errorf("rastrillo/mail: send to %s: %w", address, errFakeSMTPFailure)}
- mux, _, err := meet.App(context.Background(), d, meet.Config{
+ mux, _, err := meet.App(testCtx(t), d, meet.Config{
Env: meet.EnvDevelopment, Origin: testOrigin, AllowedAddresses: []string{address},
Mailer: failing, Logger: logger,
})