tinyrelayfile
tinyrelay/internal/daemon/webpush_test.go/

internal/daemon/webpush_test.go raw
1package daemon
2
3import (
4 "context"
5 "crypto/ecdh"
6 "crypto/rand"
7 "encoding/base64"
8 "encoding/json"
9 "net/http"
10 "net/http/httptest"
11 "strings"
12 "testing"
13
14 "github.com/FelineStateMachine/tinyrelay/internal/event"
15 "github.com/FelineStateMachine/tinyrelay/internal/storage"
16 "github.com/FelineStateMachine/tinyrelay/internal/webpush"
17 "github.com/FelineStateMachine/tinyrelay/internal/work"
18)
19
20func testSubscription(t *testing.T, endpoint string) webpush.Subscription {
21 t.Helper()
22 private, err := ecdh.P256().GenerateKey(rand.Reader)
23 if err != nil {
24 t.Fatal(err)
25 }
26 auth := make([]byte, 16)
27 rand.Read(auth)
28 var subscription webpush.Subscription
29 subscription.Endpoint = endpoint
30 subscription.Keys.P256dh = base64.RawURLEncoding.EncodeToString(private.PublicKey().Bytes())
31 subscription.Keys.Auth = base64.RawURLEncoding.EncodeToString(auth)
32 return subscription
33}
34
35func TestPushSubscriptionRequiresSignatureAndDeliversNotifications(t *testing.T) {
36 a, tenant := testTenant(t)
37 received := make(chan http.Header, 4)
38 status := http.StatusCreated
39 service := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
40 received <- r.Header.Clone()
41 w.WriteHeader(status)
42 }))
43 defer service.Close()
44 tenant.pushClient = service.Client()
45 subscription := testSubscription(t, service.URL+"/send/device-1")
46 body, _ := json.Marshal(subscription)
47
48 keyReq := httptest.NewRequest("GET", "http://relay.test/push/key", nil)
49 keyRes := httptest.NewRecorder()
50 a.ServeHTTP(keyRes, keyReq)
51 var key map[string]string
52 if keyRes.Code != 200 || json.NewDecoder(keyRes.Body).Decode(&key) != nil || len(key["key"]) < 80 {
53 t.Fatalf("push key %d %s", keyRes.Code, keyRes.Body.String())
54 }
55
56 unsigned := httptest.NewRequest("POST", "http://relay.test/push/subscribe", strings.NewReader(string(body)))
57 unsignedRes := httptest.NewRecorder()
58 a.ServeHTTP(unsignedRes, unsigned)
59 if unsignedRes.Code != 401 {
60 t.Fatalf("unsigned subscribe %d", unsignedRes.Code)
61 }
62
63 signed := httptest.NewRequest("POST", "http://relay.test/push/subscribe", strings.NewReader(string(body)))
64 signRequest(t, signed, string(body))
65 signedRes := httptest.NewRecorder()
66 a.ServeHTTP(signedRes, signed)
67 if signedRes.Code != 200 {
68 t.Fatalf("subscribe %d %s", signedRes.Code, signedRes.Body.String())
69 }
70
71 ctx := context.Background()
72 owner := tenant.Policy().Owner
73 if err := tenant.enqueuePush(ctx, owner, "test", "relay", "hello from the relay"); err != nil {
74 t.Fatal(err)
75 }
76 var payload string
77 if err := tenant.store.DB().QueryRowContext(ctx, "SELECT payload FROM work_intents WHERE kind=? AND target=?", notificationPush, owner).Scan(&payload); err != nil {
78 t.Fatalf("push intent not queued: %v", err)
79 }
80 if err := tenant.handleNotificationPush(ctx, work.Intent{Kind: notificationPush, Target: owner, Payload: payload}); err != nil {
81 t.Fatal(err)
82 }
83 headers := <-received
84 if headers.Get("Content-Encoding") != "aes128gcm" || !strings.HasPrefix(headers.Get("Authorization"), "vapid t=") || headers.Get("TTL") == "" {
85 t.Fatalf("push headers %v", headers)
86 }
87
88 // A subscription the push service reports gone is removed.
89 status = http.StatusGone
90 if err := tenant.handleNotificationPush(ctx, work.Intent{Kind: notificationPush, Target: owner, Payload: payload}); err != nil {
91 t.Fatalf("gone device should not fail the intent: %v", err)
92 }
93 <-received
94 var remaining int
95 if err := tenant.store.DB().QueryRowContext(ctx, "SELECT count(*) FROM web_push").Scan(&remaining); err != nil || remaining != 0 {
96 t.Fatalf("gone subscription kept: %d %v", remaining, err)
97 }
98 // Without devices, summaries are not queued at all.
99 before := countIntents(t, tenant, notificationPush)
100 if err := tenant.enqueuePush(ctx, owner, "test", "relay", ""); err != nil {
101 t.Fatal(err)
102 }
103 if countIntents(t, tenant, notificationPush) != before {
104 t.Fatal("push intent queued without a device")
105 }
106}
107
108func countIntents(t *testing.T, tenant *Tenant, kind string) int {
109 t.Helper()
110 var count int
111 if err := tenant.store.DB().QueryRowContext(context.Background(), "SELECT count(*) FROM work_intents WHERE kind=?", kind).Scan(&count); err != nil {
112 t.Fatal(err)
113 }
114 return count
115}
116
117func TestDeviceNotificationsFollowCategoriesAndBadge(t *testing.T) {
118 a, tenant := testTenant(t)
119 ctx := context.Background()
120 owner := tenant.Policy().Owner
121 other := strings.Repeat("b", 64)
122 // Register one device that only wants replies and relay notices.
123 subscription := testSubscription(t, "https://push.example/send/device-2")
124 body, _ := json.Marshal(map[string]any{"subscription": subscription, "categories": []string{"replies", "relay", "bogus"}})
125 req := httptest.NewRequest("POST", "http://relay.test/push/subscribe", strings.NewReader(string(body)))
126 signRequest(t, req, string(body))
127 res := httptest.NewRecorder()
128 a.ServeHTTP(res, req)
129 if res.Code != 200 {
130 t.Fatalf("subscribe %d %s", res.Code, res.Body.String())
131 }
132 var categories string
133 if err := tenant.store.DB().QueryRowContext(ctx, "SELECT categories FROM web_push").Scan(&categories); err != nil || categories != `["replies","relay"]` {
134 t.Fatalf("categories %q %v", categories, err)
135 }
136
137 mention := event.Event{Kind: 1, PubKey: other, CreatedAt: 1, Tags: [][]string{{"p", owner}}, Content: "hello there\n friend"}
138 notices := tenant.pushNotices(ctx, mention)
139 if len(notices) != 1 || notices[0].category != pushMentions || notices[0].recipient != owner || notices[0].body != "Mentioned you: hello there friend" {
140 t.Fatalf("mention notices %+v", notices)
141 }
142 wrap := event.Event{Kind: 1059, PubKey: other, CreatedAt: 1, Tags: [][]string{{"p", owner}, {"p", other}}}
143 if notices := tenant.pushNotices(ctx, wrap); len(notices) != 1 || notices[0].category != pushMessages {
144 t.Fatalf("message notices %+v", notices)
145 }
146 issue := event.Event{Kind: 1621, PubKey: other, CreatedAt: 1, Tags: [][]string{{"a", "30617:" + owner + ":notes"}, {"subject", "Broken build"}}}
147 if notices := tenant.pushNotices(ctx, issue); len(notices) != 1 || notices[0].category != pushReplies || notices[0].body != "New issue: Broken build" || !strings.Contains(notices[0].url, "view=issue") {
148 t.Fatalf("issue notices %+v", notices)
149 }
150
151 // Coalescing: the second mention within the window is dropped, and a
152 // device limited to replies is skipped by the handler for mentions.
153 tenant.notifyDevices(ctx, mention)
154 tenant.notifyDevices(ctx, mention)
155 if got := countIntents(t, tenant, notificationPush); got != 1 {
156 t.Fatalf("coalesced intents = %d", got)
157 }
158 var payload string
159 if err := tenant.store.DB().QueryRowContext(ctx, "SELECT payload FROM work_intents WHERE kind=?", notificationPush).Scan(&payload); err != nil {
160 t.Fatal(err)
161 }
162 tenant.pushClient = &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
163 t.Fatal("mention reached a replies-only device")
164 return nil, nil
165 })}
166 if err := tenant.handleNotificationPush(ctx, work.Intent{Kind: notificationPush, Target: owner, Payload: payload}); err != nil {
167 t.Fatal(err)
168 }
169
170 // The badge counts inbox events since the last visit and the visit
171 // endpoint accepts the browser session from this origin.
172 if _, err := tenant.store.Save(ctx, mention, storageSave(tenant)); err != nil {
173 t.Fatal(err)
174 }
175 if got := tenant.inboxUnread(ctx, owner); got != 1 {
176 t.Fatalf("unread = %d", got)
177 }
178 login := httptest.NewRequest("POST", "http://relay.test/session", strings.NewReader(""))
179 signRequest(t, login, "")
180 loginRes := httptest.NewRecorder()
181 a.ServeHTTP(loginRes, login)
182 seen := httptest.NewRequest("POST", "http://relay.test/inbox/seen", nil)
183 seen.Header.Set("Origin", "http://relay.test")
184 seen.AddCookie(loginRes.Result().Cookies()[0])
185 seenRes := httptest.NewRecorder()
186 a.ServeHTTP(seenRes, seen)
187 if seenRes.Code != 200 {
188 t.Fatalf("inbox seen %d %s", seenRes.Code, seenRes.Body.String())
189 }
190 if got := tenant.inboxUnread(ctx, owner); got != 0 {
191 t.Fatalf("unread after visit = %d", got)
192 }
193}
194
195type roundTripFunc func(*http.Request) (*http.Response, error)
196
197func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
198
199func storageSave(tenant *Tenant) storage.SaveOptions {
200 return storage.SaveOptions{Now: 2, SearchMode: tenant.Policy().Features.Search}
201}

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs