1package daemon23import (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"1314 "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)1920func 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.Subscription29 subscription.Endpoint = endpoint30 subscription.Keys.P256dh = base64.RawURLEncoding.EncodeToString(private.PublicKey().Bytes())31 subscription.Keys.Auth = base64.RawURLEncoding.EncodeToString(auth)32 return subscription33}3435func TestPushSubscriptionRequiresSignatureAndDeliversNotifications(t *testing.T) {36 a, tenant := testTenant(t)37 received := make(chan http.Header, 4)38 status := http.StatusCreated39 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)4748 keyReq := httptest.NewRequest("GET", "http://relay.test/push/key", nil)49 keyRes := httptest.NewRecorder()50 a.ServeHTTP(keyRes, keyReq)51 var key map[string]string52 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 }5556 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 }6263 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 }7071 ctx := context.Background()72 owner := tenant.Policy().Owner73 if err := tenant.enqueuePush(ctx, owner, "test", "relay", "hello from the relay"); err != nil {74 t.Fatal(err)75 }76 var payload string77 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 := <-received84 if headers.Get("Content-Encoding") != "aes128gcm" || !strings.HasPrefix(headers.Get("Authorization"), "vapid t=") || headers.Get("TTL") == "" {85 t.Fatalf("push headers %v", headers)86 }8788 // A subscription the push service reports gone is removed.89 status = http.StatusGone90 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 <-received94 var remaining int95 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}107108func countIntents(t *testing.T, tenant *Tenant, kind string) int {109 t.Helper()110 var count int111 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 count115}116117func TestDeviceNotificationsFollowCategoriesAndBadge(t *testing.T) {118 a, tenant := testTenant(t)119 ctx := context.Background()120 owner := tenant.Policy().Owner121 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 string133 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 }136137 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, "/repos/"+owner+"/notes/issues/") {148 t.Fatalf("issue notices %+v", notices)149 }150151 // Coalescing: the second mention within the window is dropped, and a152 // 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 string159 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, nil165 })}166 if err := tenant.handleNotificationPush(ctx, work.Intent{Kind: notificationPush, Target: owner, Payload: payload}); err != nil {167 t.Fatal(err)168 }169170 // The badge counts inbox events since the last visit and the visit171 // 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}194195type roundTripFunc func(*http.Request) (*http.Response, error)196197func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }198199func storageSave(tenant *Tenant) storage.SaveOptions {200 return storage.SaveOptions{Now: 2, SearchMode: tenant.Policy().Features.Search}201}