package calendartest import ( "context" "fmt" "io" "log/slog" "net/http" "net/http/httptest" "net/url" "path/filepath" "strings" "sync" "testing" "time" calendar "amadan.net/oficina/calendar/internal/calendar" ) // pickyMailer delivers everything except messages whose body contains // refuse, and remembers what it actually delivered. // // A recorder alone cannot answer these tests: the app's own development // recorder stores the body BEFORE forwarding, so it holds the last // message ATTEMPTED rather than the last one delivered — which is // exactly the distinction a test about failed sends has to make. type pickyMailer struct { refuse string mu sync.Mutex delivered []string recipients []string attempts int } func (m *pickyMailer) Send(_ context.Context, to, subject, body string) error { m.mu.Lock() defer m.mu.Unlock() m.attempts++ if m.refuse != "" && strings.Contains(to+subject+body, m.refuse) { // Shaped like the real thing: rastrillo's mailer formats the // recipient into its error, which is what scrub exists for. return fmt.Errorf("send to nobody@example.com: connection refused") } m.delivered = append(m.delivered, subject+"\n"+body) m.recipients = append(m.recipients, to) return nil } func (m *pickyMailer) count() int { m.mu.Lock() defer m.mu.Unlock() return len(m.delivered) } func (m *pickyMailer) to(i int) string { m.mu.Lock() defer m.mu.Unlock() if i < 0 || i >= len(m.recipients) { return "" } return m.recipients[i] } func (m *pickyMailer) body(i int) string { m.mu.Lock() defer m.mu.Unlock() if i < 0 || i >= len(m.delivered) { return "" } return m.delivered[i] } func (m *pickyMailer) reset() { m.mu.Lock() defer m.mu.Unlock() m.delivered, m.recipients = nil, nil } func (m *pickyMailer) deliveredContaining(needle string) bool { m.mu.Lock() defer m.mu.Unlock() for _, d := range m.delivered { if strings.Contains(d, needle) { return true } } return false } // newAppWithMailer builds an app whose outgoing mail goes through // mailer. Env stays "development", so the sign-in recorder still works // and SignInForTest can drive a real sign-in. func newAppWithMailer(t *testing.T, mailer *pickyMailer) *calendar.App { t.Helper() app, err := calendar.New(calendar.Config{ DBPath: filepath.Join(t.TempDir(), "calendar.db"), Env: "development", Origin: testOrigin, Mailer: mailer, Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), }) if err != nil { t.Fatalf("calendar.New: %v", err) } t.Cleanup(func() { app.Close() }) return app } func tick(t *testing.T, h http.Handler) *httptest.ResponseRecorder { t.Helper() req := httptest.NewRequest(http.MethodPost, "/jobs/reminders", nil) req.Header.Set("Authorization", "Bearer test-token") rec := httptest.NewRecorder() h.ServeHTTP(rec, req) return rec } // ONE undeliverable address must not hold up everybody else's // reminders. // // The bug this pins: FailReminder used to CLEAR claimed_at, which put // the failing row straight back at the head of ClaimDueReminder's // "ORDER BY due_at ASC". The next claim in the same pass returned the // same row, the drain's seen-guard tripped, and the pass returned with // every reminder behind it unsent — so one bounced mailbox silently // delayed the whole team's reminders until the next tick, and the log // blamed a double claim rather than the address. func TestOneUndeliverableReminderDoesNotBlockTheRest(t *testing.T) { t.Setenv("CARLOS_ADMIN_TOKEN", "test-token") mailer := &pickyMailer{refuse: "Wedged"} app := newAppWithMailer(t, mailer) session := app.SignInForTest(t, "someone@example.com") h := app.Handler() // Both already inside their ten-minute default warning, and the // wedged one is due FIRST so it is claimed first. UTC because that // is the test person's zone. now := time.Now().UTC() for _, e := range []struct { title string at time.Time }{ {"Wedged meeting", now.Add(1 * time.Minute)}, {"Meeting behind it", now.Add(3 * time.Minute)}, } { createEvent(t, h, session, url.Values{ "title": {e.title}, "start": {e.at.Format("2006-01-02T15:04")}, "end": {e.at.Add(15 * time.Minute).Format("2006-01-02T15:04")}, "kind": {"meeting"}, "status": {"confirmed"}, "repeat": {"none"}, }) } if rec := tick(t, h); rec.Code != http.StatusNoContent { t.Fatalf("drain: status %d, want 204\n%s", rec.Code, rec.Body.String()) } if !mailer.deliveredContaining("Meeting behind it") { t.Error("the reminder behind an undeliverable one was never sent — " + "one bad address is blocking the queue") } if mailer.deliveredContaining("Wedged meeting") { t.Error("the mailer reported refusing this message but it was recorded as delivered") } } // Giving up on a recurring reminder must not kill the series. // // The bug this pins: on the final attempt FailReminder marked the queue // row fired and stopped. Nothing queued the next occurrence, NextDueAt // ignored a fired row, and a daily standup that hit a relay outage lost // its reminders permanently and silently. The undeliverable fact now // lives on the RULE (Reminder.UndeliverableAt) so the queue row is free // to move on. func TestGivingUpOnARecurringReminderKeepsTheSeriesAlive(t *testing.T) { t.Setenv("CARLOS_ADMIN_TOKEN", "test-token") mailer := &pickyMailer{refuse: "Doomed"} app := newAppWithMailer(t, mailer) session := app.SignInForTest(t, "someone@example.com") h := app.Handler() start := time.Now().UTC().Add(1 * time.Minute) createEvent(t, h, session, url.Values{ "title": {"Doomed standup"}, "start": {start.Format("2006-01-02T15:04")}, "end": {start.Add(15 * time.Minute).Format("2006-01-02T15:04")}, "kind": {"meeting"}, "status": {"confirmed"}, "repeat": {"daily"}, }) // Stand the row up on its LAST attempt rather than waiting out five // five-minute claim leases. The lease is the backoff the fix above // introduced, so burning the attempts honestly would take ~25 // minutes of wall clock. db := app.DBForTest() if _, err := db.Exec( `UPDATE reminder_queues SET attempts = ?, claimed_at = NULL`, calendar.MaxReminderAttempts-1); err != nil { t.Fatalf("priming the queue row: %v", err) } if rec := tick(t, h); rec.Code != http.StatusNoContent { t.Fatalf("drain: status %d, want 204", rec.Code) } // The series is still scheduled: the next occurrence is queued, // roughly a day out. due := app.NextDue() if due.IsZero() { t.Fatal("giving up on one occurrence unqueued the whole series — " + "this rule will never remind anybody again") } if until := time.Until(due); until < 20*time.Hour || until > 26*time.Hour { t.Errorf("the requeued reminder is %v away, want about a day", until) } // And the failure is still visible rather than erased by the // reschedule. var n int if err := db.QueryRow( `SELECT COUNT(*) FROM reminders WHERE undeliverable_at IS NOT NULL`).Scan(&n); err != nil { t.Fatalf("counting undeliverable rules: %v", err) } if n != 1 { t.Errorf("%d rules marked undeliverable, want 1 — "+ "rescheduling erased the record of the failure", n) } } // An address that is not on the allowlist must leave a trace in the // LOG, even though it deliberately leaves none in the response. // // The two rules that meet here were pulling the same way by accident: // the response must be byte-identical to a real send (anti-enumeration) // and no address may be written to a log line (AGENTS.md §Logs). Obeying // both by staying completely silent made a misconfigured allowlist // undiagnosable from outside AND from `carlos logs` — which is how a // real person came to sit waiting for a link that was never sent. func TestARefusedSignInIsCountedInTheLog(t *testing.T) { var log strings.Builder app, err := calendar.New(calendar.Config{ DBPath: filepath.Join(t.TempDir(), "calendar.db"), Env: "development", Origin: testOrigin, AllowedAddresses: "allowed@example.com", Logger: slog.New(slog.NewTextHandler(&log, nil)), }) if err != nil { t.Fatalf("calendar.New: %v", err) } t.Cleanup(func() { app.Close() }) post(t, app.Handler(), "/signin", url.Values{"address": {"stranger@example.com"}}) app.FlushSigninMailForTest() // refusals are logged after the response out := log.String() if !strings.Contains(out, "signin refused by allowlist") { t.Errorf("a refused sign-in left no trace in the log:\n%s", out) } // The trace must not be the thing the logs rule forbids. if strings.Contains(out, "stranger@example.com") { t.Errorf("the refusal log names the address, which AGENTS.md forbids:\n%s", out) } } // A list written with the wrong separator must refuse to boot rather // than admit nobody. // // Splitting on "," alone turns "a@x.com;b@y.com" into ONE entry that is // nobody's address, which is a non-empty allowlist and therefore a // successful boot — the exact "quietly admits nobody" state the empty // check was written to prevent, reached by a different route. func TestAnAllowlistWithTheWrongSeparatorRefusesToStart(t *testing.T) { for _, list := range []string{ "a@example.com;b@example.com", "a@example.com b@example.com", "nonsense", } { _, err := calendar.New(calendar.Config{ DBPath: filepath.Join(t.TempDir(), "calendar.db"), Env: "production", Origin: testOrigin, InstanceKey: "test-instance-key", Mailer: &pickyMailer{}, AllowedAddresses: list, Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), }) if err == nil { t.Errorf("$CALENDAR_ALLOWED_ADDRESSES = %q booted successfully; "+ "it admits nobody and looks perfectly healthy", list) } } }