| 1 | package daemon |
| 2 | |
| 3 | import ( |
| 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 | |
| 20 | func 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 | |
| 35 | func 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 | |
| 108 | func 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 | |
| 117 | func 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 | |
| 195 | type roundTripFunc func(*http.Request) (*http.Response, error) |
| 196 | |
| 197 | func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } |
| 198 | |
| 199 | func storageSave(tenant *Tenant) storage.SaveOptions { |
| 200 | return storage.SaveOptions{Now: 2, SearchMode: tenant.Policy().Features.Search} |
| 201 | } |