oficina / calendar Public

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

Plain git — no account needed to clone.

Download

Download this file

1package calendartest
2
3import (
4 "context"
5 "fmt"
6 "io"
7 "log/slog"
8 "net/http"
9 "net/http/httptest"
10 "net/url"
11 "path/filepath"
12 "strings"
13 "sync"
14 "testing"
15 "time"
16
17 calendar "amadan.net/oficina/calendar/internal/calendar"
18)
19
20// pickyMailer delivers everything except messages whose body contains
21// refuse, and remembers what it actually delivered.
22//
23// A recorder alone cannot answer these tests: the app's own development
24// recorder stores the body BEFORE forwarding, so it holds the last
25// message ATTEMPTED rather than the last one delivered — which is
26// exactly the distinction a test about failed sends has to make.
27type pickyMailer struct {
28 refuse string
29
30 mu sync.Mutex
31 delivered []string
32 recipients []string
33 attempts int
34}
35
36func (m *pickyMailer) Send(_ context.Context, to, subject, body string) error {
37 m.mu.Lock()
38 defer m.mu.Unlock()
39 m.attempts++
40 if m.refuse != "" && strings.Contains(to+subject+body, m.refuse) {
41 // Shaped like the real thing: rastrillo's mailer formats the
42 // recipient into its error, which is what scrub exists for.
43 return fmt.Errorf("send to nobody@example.com: connection refused")
44 }
45 m.delivered = append(m.delivered, subject+"\n"+body)
46 m.recipients = append(m.recipients, to)
47 return nil
48}
49
50func (m *pickyMailer) count() int {
51 m.mu.Lock()
52 defer m.mu.Unlock()
53 return len(m.delivered)
54}
55
56func (m *pickyMailer) to(i int) string {
57 m.mu.Lock()
58 defer m.mu.Unlock()
59 if i < 0 || i >= len(m.recipients) {
60 return ""
61 }
62 return m.recipients[i]
63}
64
65func (m *pickyMailer) body(i int) string {
66 m.mu.Lock()
67 defer m.mu.Unlock()
68 if i < 0 || i >= len(m.delivered) {
69 return ""
70 }
71 return m.delivered[i]
72}
73
74func (m *pickyMailer) reset() {
75 m.mu.Lock()
76 defer m.mu.Unlock()
77 m.delivered, m.recipients = nil, nil
78}
79
80func (m *pickyMailer) deliveredContaining(needle string) bool {
81 m.mu.Lock()
82 defer m.mu.Unlock()
83 for _, d := range m.delivered {
84 if strings.Contains(d, needle) {
85 return true
86 }
87 }
88 return false
89}
90
91// newAppWithMailer builds an app whose outgoing mail goes through
92// mailer. Env stays "development", so the sign-in recorder still works
93// and SignInForTest can drive a real sign-in.
94func newAppWithMailer(t *testing.T, mailer *pickyMailer) *calendar.App {
95 t.Helper()
96 app, err := calendar.New(calendar.Config{
97 DBPath: filepath.Join(t.TempDir(), "calendar.db"),
98 Env: "development",
99 Origin: testOrigin,
100 Mailer: mailer,
101 Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
102 })
103 if err != nil {
104 t.Fatalf("calendar.New: %v", err)
105 }
106 t.Cleanup(func() { app.Close() })
107 return app
108}
109
110func tick(t *testing.T, h http.Handler) *httptest.ResponseRecorder {
111 t.Helper()
112 req := httptest.NewRequest(http.MethodPost, "/jobs/reminders", nil)
113 req.Header.Set("Authorization", "Bearer test-token")
114 rec := httptest.NewRecorder()
115 h.ServeHTTP(rec, req)
116 return rec
117}
118
119// ONE undeliverable address must not hold up everybody else's
120// reminders.
121//
122// The bug this pins: FailReminder used to CLEAR claimed_at, which put
123// the failing row straight back at the head of ClaimDueReminder's
124// "ORDER BY due_at ASC". The next claim in the same pass returned the
125// same row, the drain's seen-guard tripped, and the pass returned with
126// every reminder behind it unsent — so one bounced mailbox silently
127// delayed the whole team's reminders until the next tick, and the log
128// blamed a double claim rather than the address.
129func TestOneUndeliverableReminderDoesNotBlockTheRest(t *testing.T) {
130 t.Setenv("CARLOS_ADMIN_TOKEN", "test-token")
131
132 mailer := &pickyMailer{refuse: "Wedged"}
133 app := newAppWithMailer(t, mailer)
134 session := app.SignInForTest(t, "someone@example.com")
135 h := app.Handler()
136
137 // Both already inside their ten-minute default warning, and the
138 // wedged one is due FIRST so it is claimed first. UTC because that
139 // is the test person's zone.
140 now := time.Now().UTC()
141 for _, e := range []struct {
142 title string
143 at time.Time
144 }{
145 {"Wedged meeting", now.Add(1 * time.Minute)},
146 {"Meeting behind it", now.Add(3 * time.Minute)},
147 } {
148 createEvent(t, h, session, url.Values{
149 "title": {e.title},
150 "start": {e.at.Format("2006-01-02T15:04")},
151 "end": {e.at.Add(15 * time.Minute).Format("2006-01-02T15:04")},
152 "kind": {"meeting"},
153 "status": {"confirmed"},
154 "repeat": {"none"},
155 })
156 }
157
158 if rec := tick(t, h); rec.Code != http.StatusNoContent {
159 t.Fatalf("drain: status %d, want 204\n%s", rec.Code, rec.Body.String())
160 }
161
162 if !mailer.deliveredContaining("Meeting behind it") {
163 t.Error("the reminder behind an undeliverable one was never sent — " +
164 "one bad address is blocking the queue")
165 }
166 if mailer.deliveredContaining("Wedged meeting") {
167 t.Error("the mailer reported refusing this message but it was recorded as delivered")
168 }
169}
170
171// Giving up on a recurring reminder must not kill the series.
172//
173// The bug this pins: on the final attempt FailReminder marked the queue
174// row fired and stopped. Nothing queued the next occurrence, NextDueAt
175// ignored a fired row, and a daily standup that hit a relay outage lost
176// its reminders permanently and silently. The undeliverable fact now
177// lives on the RULE (Reminder.UndeliverableAt) so the queue row is free
178// to move on.
179func TestGivingUpOnARecurringReminderKeepsTheSeriesAlive(t *testing.T) {
180 t.Setenv("CARLOS_ADMIN_TOKEN", "test-token")
181
182 mailer := &pickyMailer{refuse: "Doomed"}
183 app := newAppWithMailer(t, mailer)
184 session := app.SignInForTest(t, "someone@example.com")
185 h := app.Handler()
186
187 start := time.Now().UTC().Add(1 * time.Minute)
188 createEvent(t, h, session, url.Values{
189 "title": {"Doomed standup"},
190 "start": {start.Format("2006-01-02T15:04")},
191 "end": {start.Add(15 * time.Minute).Format("2006-01-02T15:04")},
192 "kind": {"meeting"},
193 "status": {"confirmed"},
194 "repeat": {"daily"},
195 })
196
197 // Stand the row up on its LAST attempt rather than waiting out five
198 // five-minute claim leases. The lease is the backoff the fix above
199 // introduced, so burning the attempts honestly would take ~25
200 // minutes of wall clock.
201 db := app.DBForTest()
202 if _, err := db.Exec(
203 `UPDATE reminder_queues SET attempts = ?, claimed_at = NULL`,
204 calendar.MaxReminderAttempts-1); err != nil {
205 t.Fatalf("priming the queue row: %v", err)
206 }
207
208 if rec := tick(t, h); rec.Code != http.StatusNoContent {
209 t.Fatalf("drain: status %d, want 204", rec.Code)
210 }
211
212 // The series is still scheduled: the next occurrence is queued,
213 // roughly a day out.
214 due := app.NextDue()
215 if due.IsZero() {
216 t.Fatal("giving up on one occurrence unqueued the whole series — " +
217 "this rule will never remind anybody again")
218 }
219 if until := time.Until(due); until < 20*time.Hour || until > 26*time.Hour {
220 t.Errorf("the requeued reminder is %v away, want about a day", until)
221 }
222
223 // And the failure is still visible rather than erased by the
224 // reschedule.
225 var n int
226 if err := db.QueryRow(
227 `SELECT COUNT(*) FROM reminders WHERE undeliverable_at IS NOT NULL`).Scan(&n); err != nil {
228 t.Fatalf("counting undeliverable rules: %v", err)
229 }
230 if n != 1 {
231 t.Errorf("%d rules marked undeliverable, want 1 — "+
232 "rescheduling erased the record of the failure", n)
233 }
234}
235
236// An address that is not on the allowlist must leave a trace in the
237// LOG, even though it deliberately leaves none in the response.
238//
239// The two rules that meet here were pulling the same way by accident:
240// the response must be byte-identical to a real send (anti-enumeration)
241// and no address may be written to a log line (AGENTS.md §Logs). Obeying
242// both by staying completely silent made a misconfigured allowlist
243// undiagnosable from outside AND from `carlos logs` — which is how a
244// real person came to sit waiting for a link that was never sent.
245func TestARefusedSignInIsCountedInTheLog(t *testing.T) {
246 var log strings.Builder
247 app, err := calendar.New(calendar.Config{
248 DBPath: filepath.Join(t.TempDir(), "calendar.db"),
249 Env: "development",
250 Origin: testOrigin,
251 AllowedAddresses: "allowed@example.com",
252 Logger: slog.New(slog.NewTextHandler(&log, nil)),
253 })
254 if err != nil {
255 t.Fatalf("calendar.New: %v", err)
256 }
257 t.Cleanup(func() { app.Close() })
258
259 post(t, app.Handler(), "/signin", url.Values{"address": {"stranger@example.com"}})
260 app.FlushSigninMailForTest() // refusals are logged after the response
261
262 out := log.String()
263 if !strings.Contains(out, "signin refused by allowlist") {
264 t.Errorf("a refused sign-in left no trace in the log:\n%s", out)
265 }
266 // The trace must not be the thing the logs rule forbids.
267 if strings.Contains(out, "stranger@example.com") {
268 t.Errorf("the refusal log names the address, which AGENTS.md forbids:\n%s", out)
269 }
270}
271
272// A list written with the wrong separator must refuse to boot rather
273// than admit nobody.
274//
275// Splitting on "," alone turns "a@x.com;b@y.com" into ONE entry that is
276// nobody's address, which is a non-empty allowlist and therefore a
277// successful boot — the exact "quietly admits nobody" state the empty
278// check was written to prevent, reached by a different route.
279func TestAnAllowlistWithTheWrongSeparatorRefusesToStart(t *testing.T) {
280 for _, list := range []string{
281 "a@example.com;b@example.com",
282 "a@example.com b@example.com",
283 "nonsense",
284 } {
285 _, err := calendar.New(calendar.Config{
286 DBPath: filepath.Join(t.TempDir(), "calendar.db"),
287 Env: "production",
288 Origin: testOrigin,
289 InstanceKey: "test-instance-key",
290 Mailer: &pickyMailer{},
291 AllowedAddresses: list,
292 Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
293 })
294 if err == nil {
295 t.Errorf("$CALENDAR_ALLOWED_ADDRESSES = %q booted successfully; "+
296 "it admits nobody and looks perfectly healthy", list)
297 }
298 }
299}
300