rastrillo / aviso Public

Clone
git clone https://amadan.net/rastrillo/aviso

Plain git — no account needed to clone.

Download

Download this file

1package aviso
2
3import (
4 "context"
5 "encoding/base64"
6 "encoding/json"
7 "errors"
8 "net/http"
9 "net/http/httptest"
10 "strings"
11 "sync"
12 "sync/atomic"
13 "testing"
14 "time"
15)
16
17// pushRecorder is a stand-in push service: it records each request's
18// headers and answers with whatever status the test sets.
19type pushRecorder struct {
20 srv *httptest.Server
21 status atomic.Int32
22 hdr chan http.Header
23 retry string
24 hold chan struct{} // when non-nil, handlers block until it closes
25 inFlt atomic.Int32
26 peak atomic.Int32
27}
28
29func newPushRecorder(t *testing.T) *pushRecorder {
30 t.Helper()
31 p := &pushRecorder{hdr: make(chan http.Header, 256)}
32 p.status.Store(201)
33 p.srv = httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
34 n := p.inFlt.Add(1)
35 for {
36 old := p.peak.Load()
37 if n <= old || p.peak.CompareAndSwap(old, n) {
38 break
39 }
40 }
41 defer p.inFlt.Add(-1)
42 p.hdr <- r.Header.Clone()
43 if p.hold != nil {
44 <-p.hold
45 }
46 if p.retry != "" {
47 w.Header().Set("Retry-After", p.retry)
48 }
49 w.WriteHeader(int(p.status.Load()))
50 }))
51 t.Cleanup(p.srv.Close)
52 return p
53}
54
55// url is the recorder's address as an endpoint Send will accept: a
56// hostname, because validateEndpoint refuses a literal loopback IP
57// before any HTTP happens. Only the swapped-in client (below) lets the
58// name reach loopback at all.
59func (p *pushRecorder) url(path string) string {
60 return strings.Replace(p.srv.URL, "127.0.0.1", "localhost", 1) + path
61}
62
63// serviceAgainst is the private seam the spec names: the SSRF guard
64// would refuse httptest's loopback server, so the test swaps in the
65// server's own client, pinned to the certificate's name so "localhost"
66// verifies. newClient's guards are tested on their own.
67func serviceAgainst(t *testing.T, p *pushRecorder) *Service {
68 t.Helper()
69 s := newInternalService(t)
70 c := p.srv.Client()
71 tr := c.Transport.(*http.Transport).Clone()
72 tr.TLSClientConfig.ServerName = "example.com"
73 c.Transport = tr
74 s.client = c
75 return s
76}
77
78func jwtSub(t *testing.T, authorization string) string {
79 t.Helper()
80 // "vapid t=<jwt>, k=<key>"
81 i := strings.Index(authorization, "t=")
82 j := strings.Index(authorization, ",")
83 if i < 0 || j < i {
84 t.Fatalf("authorization %q", authorization)
85 }
86 parts := strings.Split(authorization[i+2:j], ".")
87 if len(parts) != 3 {
88 t.Fatalf("jwt %q", authorization)
89 }
90 raw, err := base64.RawURLEncoding.DecodeString(parts[1])
91 if err != nil {
92 t.Fatal(err)
93 }
94 var claims struct{ Sub string }
95 if err := json.Unmarshal(raw, &claims); err != nil {
96 t.Fatal(err)
97 }
98 return claims.Sub
99}
100
101func TestSendToSetsHeadersAndConfirms(t *testing.T) {
102 p := newPushRecorder(t)
103 s := serviceAgainst(t, p)
104 ctx := context.Background()
105 _ = s.put(ctx, "alice", sub(p.url("/one")), "")
106 s.now = func() time.Time { return time.Unix(1_800_000_000, 0) }
107 res, err := s.SendTo(ctx, "alice", []byte(`{"title":"hi"}`), Options{TTL: 90 * time.Second, Urgency: "high", Topic: "t1"})
108 if err != nil || len(res) != 1 || res[0].Err != nil || res[0].Status != 201 {
109 t.Fatalf("res=%+v err=%v", res, err)
110 }
111 h := <-p.hdr
112 if h.Get("TTL") != "90" || h.Get("Urgency") != "high" || h.Get("Topic") != "t1" ||
113 h.Get("Content-Encoding") != "aes128gcm" {
114 t.Fatalf("headers: %v", h)
115 }
116 if sub := jwtSub(t, h.Get("Authorization")); sub != "mailto:x@y" {
117 t.Fatalf("VAPID sub = %q, want mailto:x@y (webpush-go adds the prefix itself)", sub)
118 }
119 var confirmed int64
120 _ = s.cfg.DB.QueryRow(`SELECT last_confirmed_at FROM aviso_subscriptions WHERE subject='alice'`).Scan(&confirmed)
121 if confirmed != 1_800_000_000 {
122 t.Fatalf("2xx did not confirm: %d", confirmed)
123 }
124}
125
126func TestSendDefaultsTTLToADayAndUrgencyToNormal(t *testing.T) {
127 p := newPushRecorder(t)
128 s := serviceAgainst(t, p)
129 ctx := context.Background()
130 _ = s.put(ctx, "alice", sub(p.url("/one")), "")
131 if _, err := s.SendTo(ctx, "alice", []byte("x"), Options{}); err != nil {
132 t.Fatal(err)
133 }
134 h := <-p.hdr
135 if h.Get("TTL") != "86400" || h.Get("Urgency") != "normal" || h.Get("Topic") != "" {
136 t.Fatalf("headers: TTL=%q Urgency=%q Topic=%q", h.Get("TTL"), h.Get("Urgency"), h.Get("Topic"))
137 }
138}
139
140func TestSendPrunesOnGoneOnlyAtSameRevision(t *testing.T) {
141 p := newPushRecorder(t)
142 s := serviceAgainst(t, p)
143 ctx := context.Background()
144 _ = s.put(ctx, "alice", sub(p.url("/one")), "")
145 rows, _ := s.List(ctx, "alice")
146 stale := rows[0]
147 _ = s.put(ctx, "alice", sub(p.url("/one")), "") // revision 2
148 p.status.Store(410)
149 res, _ := s.Send(ctx, []Stored{stale}, []byte("x"), Options{})
150 if res[0].Status != 410 || res[0].Err == nil {
151 t.Fatalf("res=%+v", res)
152 }
153 if rows, _ := s.List(ctx, "alice"); len(rows) != 1 {
154 t.Fatal("410 at a stale revision pruned a refreshed row")
155 }
156 current, _ := s.List(ctx, "alice")
157 _, _ = s.Send(ctx, current, []byte("x"), Options{})
158 if rows, _ := s.List(ctx, "alice"); len(rows) != 0 {
159 t.Fatal("410 at the current revision did not prune")
160 }
161}
162
163func TestSendReportsRetryAfter(t *testing.T) {
164 p := newPushRecorder(t)
165 p.retry = "120"
166 p.status.Store(429)
167 s := serviceAgainst(t, p)
168 ctx := context.Background()
169 _ = s.put(ctx, "alice", sub(p.url("/one")), "")
170 res, _ := s.SendTo(ctx, "alice", []byte("x"), Options{})
171 if res[0].RetryAfter != 120*time.Second || res[0].Err == nil || res[0].Status != 429 {
172 t.Fatalf("res=%+v", res)
173 }
174 if rows, _ := s.List(ctx, "alice"); len(rows) != 1 {
175 t.Fatal("429 pruned")
176 }
177}
178
179func TestParseRetryAfter(t *testing.T) {
180 now := time.Date(2026, 9, 7, 12, 0, 0, 0, time.UTC)
181 for in, want := range map[string]time.Duration{
182 "": 0,
183 "120": 120 * time.Second,
184 "-5": 0,
185 "nonsense": 0,
186 "9223372037": maxRetryAfter, // would overflow time.Duration
187 "99999999999999999999": maxRetryAfter,
188 "Mon, 07 Sep 2026 12:05:00 GMT": 5 * time.Minute,
189 "Mon, 07 Sep 2026 11:00:00 GMT": 0, // in the past
190 "Wed, 07 Oct 2026 12:00:00 GMT": maxRetryAfter,
191 } {
192 if got := parseRetryAfter(in, now); got != want {
193 t.Errorf("%q: got %v, want %v", in, got, want)
194 }
195 }
196}
197
198// A payload at the bound must actually encrypt and travel, not merely
199// pass validation with no recipients.
200func TestSendDeliversAPayloadAtTheBound(t *testing.T) {
201 p := newPushRecorder(t)
202 s := serviceAgainst(t, p)
203 ctx := context.Background()
204 _ = s.put(ctx, "alice", sub(p.url("/one")), "")
205 res, err := s.SendTo(ctx, "alice", make([]byte, maxPayload), Options{})
206 if err != nil || len(res) != 1 || res[0].Err != nil || res[0].Status != 201 {
207 t.Fatalf("res=%+v err=%v", res, err)
208 }
209 <-p.hdr
210}
211
212// A failed selection is a batch error, never "zero devices".
213func TestSendToReportsASelectionFailure(t *testing.T) {
214 s := newInternalService(t)
215 ctx := context.Background()
216 _ = s.put(ctx, "alice", sub("https://push.example/one"), "")
217 if _, err := s.cfg.DB.Exec(`DROP TABLE aviso_subscriptions`); err != nil {
218 t.Fatal(err)
219 }
220 res, err := s.SendTo(ctx, "alice", []byte("x"), Options{})
221 if err == nil || res != nil {
222 t.Fatalf("res=%+v err=%v; want nil results and a batch error", res, err)
223 }
224}
225
226func TestSendReportsServiceUnavailableAndPrunesNotFound(t *testing.T) {
227 p := newPushRecorder(t)
228 s := serviceAgainst(t, p)
229 ctx := context.Background()
230 _ = s.put(ctx, "alice", sub(p.url("/one")), "")
231 p.retry = "Mon, 07 Sep 2026 12:05:00 GMT"
232 p.status.Store(503)
233 s.now = func() time.Time { return time.Date(2026, 9, 7, 12, 0, 0, 0, time.UTC) }
234 res, _ := s.SendTo(ctx, "alice", []byte("x"), Options{})
235 if res[0].Status != 503 || res[0].RetryAfter != 5*time.Minute || res[0].Err == nil {
236 t.Fatalf("503: %+v", res[0])
237 }
238 if rows, _ := s.List(ctx, "alice"); len(rows) != 1 {
239 t.Fatal("503 pruned")
240 }
241 p.status.Store(404)
242 res, _ = s.SendTo(ctx, "alice", []byte("x"), Options{})
243 if res[0].Status != 404 || res[0].Err == nil {
244 t.Fatalf("404: %+v", res[0])
245 }
246 if rows, _ := s.List(ctx, "alice"); len(rows) != 0 {
247 t.Fatal("404 did not prune")
248 }
249}
250
251func TestSendSkipsRowsUnderAnotherKey(t *testing.T) {
252 p := newPushRecorder(t)
253 s := serviceAgainst(t, p)
254 ctx := context.Background()
255 _ = s.put(ctx, "alice", sub(p.url("/one")), "")
256 _, _ = s.cfg.DB.Exec(`UPDATE aviso_subscriptions SET vapid_key_id = 'other'`)
257 res, err := s.SendTo(ctx, "alice", []byte("x"), Options{})
258 if err != nil || len(res) != 1 || !errors.Is(res[0].Err, ErrKeyMismatch) {
259 t.Fatalf("res=%+v err=%v", res, err)
260 }
261 select {
262 case <-p.hdr:
263 t.Fatal("sent despite key mismatch")
264 default:
265 }
266}
267
268func TestSendRefusesAnEditedEndpoint(t *testing.T) {
269 p := newPushRecorder(t)
270 s := serviceAgainst(t, p)
271 ctx := context.Background()
272 _ = s.put(ctx, "alice", sub(p.url("/one")), "")
273 rows, _ := s.List(ctx, "alice")
274 rows[0].Endpoint = strings.Replace(rows[0].Endpoint, "https://", "http://", 1)
275 res, err := s.Send(ctx, rows, []byte("x"), Options{})
276 if err != nil || !errors.Is(res[0].Err, ErrBadEndpoint) {
277 t.Fatalf("res=%+v err=%v", res, err)
278 }
279 select {
280 case <-p.hdr:
281 t.Fatal("sent to an http endpoint")
282 default:
283 }
284}
285
286func TestSendValidatesPayloadAndOptions(t *testing.T) {
287 s := newInternalService(t)
288 ctx := context.Background()
289 if _, err := s.Send(ctx, nil, make([]byte, 3994), Options{}); !errors.Is(err, ErrPayloadTooLarge) {
290 t.Errorf("oversize payload: %v", err)
291 }
292 if _, err := s.Send(ctx, nil, make([]byte, 3993), Options{}); err != nil {
293 t.Errorf("3993 bytes refused: %v", err)
294 }
295 for name, o := range map[string]Options{
296 "neg ttl": {TTL: -time.Second},
297 "frac ttl": {TTL: 1500 * time.Millisecond},
298 "urgency": {Urgency: "urgent"},
299 "topic chars": {Topic: "a b"},
300 "topic long": {Topic: strings.Repeat("a", 33)},
301 } {
302 if _, err := s.Send(ctx, nil, []byte("x"), o); !errors.Is(err, ErrBadOptions) {
303 t.Errorf("%s: %v", name, err)
304 }
305 }
306}
307
308func TestSendHonoursCancellation(t *testing.T) {
309 p := newPushRecorder(t)
310 s := serviceAgainst(t, p)
311 ctx, cancel := context.WithCancel(context.Background())
312 cancel()
313 _ = s.put(context.Background(), "alice", sub(p.url("/one")), "")
314 _, err := s.SendTo(ctx, "alice", []byte("x"), Options{})
315 if !errors.Is(err, context.Canceled) {
316 t.Fatalf("got %v, want context.Canceled", err)
317 }
318}
319
320// Cancellation while sends are in flight: rows never started carry the
321// context error, started ones finish, the batch error is the context's,
322// and Send returns only after every goroutine it started has stopped
323// writing — which is what -race checks here.
324func TestSendCancelledMidBatchWaitsForStartedGoroutines(t *testing.T) {
325 p := newPushRecorder(t)
326 p.hold = make(chan struct{})
327 s := serviceAgainst(t, p)
328 s.sem = make(chan struct{}, 2) // room for two in flight, the rest must wait
329 ctx, cancel := context.WithCancel(context.Background())
330 var rows []Stored
331 for i := 0; i < 5; i++ {
332 _ = s.put(context.Background(), "alice", sub(p.url("/"+string(rune('a'+i)))), "")
333 }
334 rows, _ = s.List(context.Background(), "alice")
335 var wg sync.WaitGroup
336 wg.Add(1)
337 var res []Result
338 var err error
339 go func() {
340 defer wg.Done()
341 res, err = s.Send(ctx, rows, []byte("x"), Options{})
342 }()
343 <-p.hdr
344 <-p.hdr // two are in flight and blocked
345 cancel()
346 close(p.hold)
347 wg.Wait()
348 if !errors.Is(err, context.Canceled) {
349 t.Fatalf("batch err = %v", err)
350 }
351 // A row never started carries the bare context error. A row that
352 // was in flight either got cut short (wrapped context error) or
353 // legitimately completed before the cancellation reached the
354 // transport — both are "started"; neither is the bare error.
355 started, skipped := 0, 0
356 for _, r := range res {
357 switch {
358 case r.Err == context.Canceled:
359 skipped++
360 case r.Err != nil || r.Status != 0:
361 started++
362 default:
363 t.Fatalf("result neither started nor skipped: %+v", r)
364 }
365 }
366 if started != 2 || skipped != 3 {
367 t.Fatalf("started=%d skipped=%d, want 2/3", started, skipped)
368 }
369 if extra := len(p.hdr); extra != 0 { // the two we drained were the only requests
370 t.Fatalf("recorder saw %d requests beyond the two in flight", extra)
371 }
372}
373
374// Two batches on one Service share one bound: a per-call semaphore
375// would let this reach 6 in flight.
376func TestConcurrencyBoundIsPerService(t *testing.T) {
377 p := newPushRecorder(t)
378 p.hold = make(chan struct{})
379 s := serviceAgainst(t, p)
380 s.sem = make(chan struct{}, 3)
381 ctx := context.Background()
382 for i := 0; i < 6; i++ {
383 _ = s.put(ctx, "alice", sub(p.url("/a"+string(rune('a'+i)))), "")
384 _ = s.put(ctx, "bob", sub(p.url("/b"+string(rune('a'+i)))), "")
385 }
386 done := make(chan struct{}, 2)
387 go func() { _, _ = s.SendTo(ctx, "alice", []byte("x"), Options{}); done <- struct{}{} }()
388 go func() { _, _ = s.SendTo(ctx, "bob", []byte("x"), Options{}); done <- struct{}{} }()
389 for i := 0; i < 3; i++ {
390 <-p.hdr
391 }
392 time.Sleep(50 * time.Millisecond)
393 if got := p.inFlt.Load(); got != 3 {
394 t.Fatalf("in flight across two batches = %d, want 3", got)
395 }
396 close(p.hold)
397 <-done
398 <-done
399 if p.peak.Load() > 3 {
400 t.Fatalf("peak %d exceeded the Service bound", p.peak.Load())
401 }
402}
403
404// webpush-go pads in place into the payload's spare capacity; one
405// payload fanned out to many devices must not share a backing array.
406// -race is what makes this test bite.
407func TestSendDoesNotAliasThePayloadAcrossDevices(t *testing.T) {
408 p := newPushRecorder(t)
409 s := serviceAgainst(t, p)
410 ctx := context.Background()
411 for i := 0; i < 8; i++ {
412 _ = s.put(ctx, "alice", sub(p.url("/"+string(rune('a'+i)))), "")
413 }
414 payload := make([]byte, 1, 4096)
415 payload[0] = 'x'
416 res, err := s.SendTo(ctx, "alice", payload, Options{})
417 if err != nil {
418 t.Fatal(err)
419 }
420 for _, r := range res {
421 if r.Err != nil {
422 t.Fatalf("%+v", r)
423 }
424 }
425 if payload[0] != 'x' || len(payload) != 1 {
426 t.Fatal("caller's payload was modified")
427 }
428}
429
430// A 410 the push service already answered prunes even though the
431// caller's context was cancelled by the time the answer is settled.
432func TestSettlePrunesUnderACancelledContext(t *testing.T) {
433 s := newInternalService(t)
434 ctx, cancel := context.WithCancel(context.Background())
435 _ = s.put(ctx, "alice", sub("https://push.example/one"), "")
436 rows, _ := s.List(ctx, "alice")
437 cancel()
438 res := s.settle(ctx, rows[0], http.StatusGone, "")
439 if res.Status != 410 || res.Err == nil {
440 t.Fatalf("res=%+v", res)
441 }
442 if left, _ := s.List(context.Background(), "alice"); len(left) != 0 {
443 t.Fatal("prune skipped under a cancelled context")
444 }
445 // And the same for confirm.
446 _ = s.put(context.Background(), "alice", sub("https://push.example/two"), "")
447 rows, _ = s.List(context.Background(), "alice")
448 s.now = func() time.Time { return time.Unix(1_800_000_000, 0) }
449 _ = s.settle(ctx, rows[0], http.StatusCreated, "")
450 var confirmed int64
451 _ = s.cfg.DB.QueryRow(`SELECT last_confirmed_at FROM aviso_subscriptions WHERE id=?`, rows[0].ID).Scan(&confirmed)
452 if confirmed != 1_800_000_000 {
453 t.Fatal("confirm skipped under a cancelled context")
454 }
455}
456
457// The store update after an answer has its own bound: a transaction
458// holding the single writer connection must not pin the send forever.
459func TestSettleGivesUpOnABlockedWriter(t *testing.T) {
460 s := newInternalService(t)
461 s.dbTimeout = 100 * time.Millisecond
462 ctx := context.Background()
463 _ = s.put(ctx, "alice", sub("https://push.example/one"), "")
464 rows, _ := s.List(ctx, "alice")
465 tx, err := s.cfg.DB.BeginTx(ctx, nil)
466 if err != nil {
467 t.Fatal(err)
468 }
469 defer tx.Rollback()
470 if _, err := tx.Exec(`UPDATE aviso_subscriptions SET revision = revision`); err != nil { // hold the write lock
471 t.Fatal(err)
472 }
473 start := time.Now()
474 done := make(chan Result, 1)
475 go func() { done <- s.settle(ctx, rows[0], http.StatusGone, "") }()
476 select {
477 case res := <-done:
478 if time.Since(start) > 2*time.Second {
479 t.Fatalf("settle took %v with a 100ms bound", time.Since(start))
480 }
481 if res.Status != 410 {
482 t.Fatalf("res=%+v", res)
483 }
484 case <-time.After(5 * time.Second):
485 t.Fatal("settle blocked on the held writer")
486 }
487}
488
489// A 3xx with an unparseable Location makes net/http quote the header
490// verbatim in its error, and a push service could reflect the endpoint
491// there. Nothing of it may survive into the Result.
492func TestSendRedactsMalformedRedirects(t *testing.T) {
493 p := newPushRecorder(t)
494 srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
495 w.Header().Set("Location", "https://push.example/secret-path/%zz")
496 w.WriteHeader(http.StatusFound)
497 }))
498 defer srv.Close()
499 p.srv = srv
500 s := serviceAgainst(t, p)
501 ctx := context.Background()
502 _ = s.put(ctx, "alice", sub(p.url("/secret-path")), "")
503 res, _ := s.SendTo(ctx, "alice", []byte("x"), Options{})
504 if res[0].Err == nil || strings.Contains(res[0].Err.Error(), "secret") || strings.Contains(res[0].Err.Error(), "zz") {
505 t.Fatalf("error leaks the redirect: %v", res[0].Err)
506 }
507 if !errors.Is(res[0].Err, ErrTransport) {
508 t.Fatalf("want ErrTransport, got %v", res[0].Err)
509 }
510}
511
512func TestSendBoundsConcurrency(t *testing.T) {
513 p := newPushRecorder(t)
514 p.hold = make(chan struct{})
515 s := serviceAgainst(t, p)
516 s.sem = make(chan struct{}, 3)
517 ctx := context.Background()
518 for i := 0; i < 8; i++ {
519 _ = s.put(ctx, "alice", sub(p.url("/"+string(rune('a'+i)))), "")
520 }
521 done := make(chan struct{})
522 go func() { _, _ = s.SendTo(ctx, "alice", []byte("x"), Options{}); close(done) }()
523 for i := 0; i < 3; i++ {
524 <-p.hdr
525 }
526 time.Sleep(50 * time.Millisecond) // give a fourth a chance to (wrongly) start
527 if got := p.inFlt.Load(); got != 3 {
528 t.Fatalf("in flight = %d, want 3", got)
529 }
530 close(p.hold)
531 <-done
532 if p.peak.Load() > 3 {
533 t.Fatalf("peak concurrency %d exceeded bound 3", p.peak.Load())
534 }
535}
536
537func TestSendRedactsEndpointFromErrors(t *testing.T) {
538 s := newInternalService(t)
539 ctx := context.Background()
540 // Routable-looking host, refused at dial by the guard once it
541 // resolves — or unreachable; either way the error must not carry
542 // the URL's path, which is the secret part of an endpoint.
543 _ = s.put(ctx, "alice", sub("https://127.0.0.1:9/secret-path"), "")
544 rows, _ := s.List(ctx, "alice")
545 rows[0].Endpoint = "https://localhost:9/secret-path" // literal IP would fail validation; a name reaches the dialer
546 res, _ := s.Send(ctx, rows, []byte("x"), Options{})
547 if res[0].Err == nil || strings.Contains(res[0].Err.Error(), "secret-path") {
548 t.Fatalf("error carries the endpoint: %v", res[0].Err)
549 }
550 if !strings.Contains(res[0].Err.Error(), "aviso") {
551 t.Fatalf("error not package-prefixed: %v", res[0].Err)
552 }
553}
554