package setup_test import ( "context" "encoding/json" "io" "net/http" "net/http/httptest" "net/url" "strconv" "sync" "testing" "time" "github.com/dariuszjaniak/warp/server/internal/setup" ) // The Phase 8 tests: push rules, notification counts, pushers and the // push gateway, through the whole stack. // pushSync is the part of a sync these tests read. type pushSync struct { NextBatch string `json:"next_batch"` AccountData struct { Events []struct { Type string `json:"type"` Content json.RawMessage `json:"content"` } `json:"events"` } `json:"account_data"` Rooms struct { Join map[string]struct { UnreadNotifications *struct { NotificationCount int `json:"notification_count"` HighlightCount int `json:"highlight_count"` } `json:"unread_notifications"` } `json:"join"` } `json:"rooms"` } func pushSyncOnce(t *testing.T, srv *setup.Server, token, since string, timeout time.Duration) pushSync { t.Helper() path := "/_matrix/client/v3/sync?timeout=" + strconv.FormatInt(timeout.Milliseconds(), 10) if since != "" { path += "&since=" + url.QueryEscape(since) } rec := getWithToken(t, srv, path, token) if rec.Code != http.StatusOK { t.Fatalf("sync: %d %s", rec.Code, rec.Body.String()) } return decode[pushSync](t, rec) } // waitForPush lets the push worker catch up with what has been sent. func waitForPush(t *testing.T, srv *setup.Server) { t.Helper() ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) defer cancel() if err := srv.Push().WaitIdle(ctx); err != nil { t.Fatalf("waiting for the push server: %v", err) } } func sendText(t *testing.T, srv *setup.Server, token, roomID, txn string, content map[string]any) string { t.Helper() resp := mustOK(t, put(t, srv, "/_matrix/client/v3/rooms/"+url.PathEscape(roomID)+"/send/m.room.message/"+txn, content, token), "send") eventID, _ := resp["event_id"].(string) return eventID } func TestPushRulesThroughTheAPI(t *testing.T) { srv := newSigningServer(t, nil) alice := registerThrough(t, srv, "alice") // The defaults, both from /pushrules/ and as account data in the // first sync, although nothing has been stored. all := mustOK(t, getWithToken(t, srv, "/_matrix/client/v3/pushrules/", alice), "pushrules/") global, _ := all["global"].(map[string]any) if override, _ := global["override"].([]any); len(override) == 0 { t.Fatalf("pushrules/ = %v", all) } first := pushSyncOnce(t, srv, alice, "", 0) var found bool for _, e := range first.AccountData.Events { if e.Type == "m.push_rules" { found = true } } if !found { t.Error("the initial sync has no m.push_rules") } // A new rule: created enabled, readable alone and by attribute. mustOK(t, put(t, srv, "/_matrix/client/v3/pushrules/global/content/cake", map[string]any{ "pattern": "cake*", "actions": []any{"notify"}, }, alice), "put rule") rule := mustOK(t, getWithToken(t, srv, "/_matrix/client/v3/pushrules/global/content/cake", alice), "get rule") if rule["pattern"] != "cake*" || rule["enabled"] != true || rule["default"] != false { t.Errorf("rule = %v", rule) } mustOK(t, put(t, srv, "/_matrix/client/v3/pushrules/global/content/cake/enabled", map[string]any{"enabled": false}, alice), "disable") if enabled := mustOK(t, getWithToken(t, srv, "/_matrix/client/v3/pushrules/global/content/cake/enabled", alice), "get enabled"); enabled["enabled"] != false { t.Errorf("enabled = %v", enabled) } // A default rule's actions and enabled flag are the user's to change. mustOK(t, put(t, srv, "/_matrix/client/v3/pushrules/global/override/.m.rule.master/enabled", map[string]any{"enabled": true}, alice), "enable master") mustOK(t, put(t, srv, "/_matrix/client/v3/pushrules/global/underride/.m.rule.message/actions", map[string]any{"actions": []any{}}, alice), "silence messages") if actions := mustOK(t, getWithToken(t, srv, "/_matrix/client/v3/pushrules/global/underride/.m.rule.message/actions", alice), "get actions"); len(actions["actions"].([]any)) != 0 { t.Errorf("actions = %v", actions) } // The refusals. for _, tc := range []struct { name string rec *httptest.ResponseRecorder status int }{ {"a reserved rule ID", put(t, srv, "/_matrix/client/v3/pushrules/global/override/.m.rule.mine", map[string]any{"conditions": []any{}, "actions": []any{}}, alice), http.StatusBadRequest}, {"a content rule without a pattern", put(t, srv, "/_matrix/client/v3/pushrules/global/content/x", map[string]any{"actions": []any{}}, alice), http.StatusBadRequest}, {"an unknown kind", put(t, srv, "/_matrix/client/v3/pushrules/global/sideways/x", map[string]any{"actions": []any{}}, alice), http.StatusBadRequest}, {"another scope", getWithToken(t, srv, "/_matrix/client/v3/pushrules/device/content/cake", alice), http.StatusBadRequest}, {"a rule that is not there", getWithToken(t, srv, "/_matrix/client/v3/pushrules/global/content/pie", alice), http.StatusNotFound}, {"before a rule that is not there", put(t, srv, "/_matrix/client/v3/pushrules/global/content/x?before=pie", map[string]any{"pattern": "x", "actions": []any{}}, alice), http.StatusNotFound}, {"deleting a default rule", send(t, srv, http.MethodDelete, "/_matrix/client/v3/pushrules/global/override/.m.rule.master", "", nil, alice, nil), http.StatusNotFound}, {"writing the account data", put(t, srv, "/_matrix/client/v3/user/@alice:warp.example/account_data/m.push_rules", map[string]any{"global": map[string]any{}}, alice), http.StatusMethodNotAllowed}, } { if tc.rec.Code != tc.status { t.Errorf("%s: status %d, want %d: %s", tc.name, tc.rec.Code, tc.status, tc.rec.Body.String()) } } // A change wakes a blocked sync and reaches it as account data. since := pushSyncOnce(t, srv, alice, "", 0).NextBatch done := make(chan pushSync, 1) go func() { done <- pushSyncOnce(t, srv, alice, since, 10*time.Second) }() time.Sleep(100 * time.Millisecond) start := time.Now() if rec := send(t, srv, http.MethodDelete, "/_matrix/client/v3/pushrules/global/content/cake", "", nil, alice, nil); rec.Code != http.StatusOK { t.Fatalf("delete: %d %s", rec.Code, rec.Body.String()) } woken := <-done if time.Since(start) > 5*time.Second { t.Error("the sync was not woken by the push rule change") } var rules json.RawMessage for _, e := range woken.AccountData.Events { if e.Type == "m.push_rules" { rules = e.Content } } var doc struct { Global struct { Content []struct { RuleID string `json:"rule_id"` } `json:"content"` } `json:"global"` } _ = json.Unmarshal(rules, &doc) if rules == nil || len(doc.Global.Content) != 1 { t.Errorf("the woken sync's push rules = %s", rules) } // The account data endpoint serves the same document. viaAccountData := mustOK(t, getWithToken(t, srv, "/_matrix/client/v3/user/@alice:warp.example/account_data/m.push_rules", alice), "account_data") if _, ok := viaAccountData["global"]; !ok { t.Errorf("account_data m.push_rules = %v", viaAccountData) } } func TestNotificationCountsThroughTheAPI(t *testing.T) { srv := newSigningServer(t, nil) alice := registerThrough(t, srv, "alice") bob := registerThrough(t, srv, "bob") roomID := createPublicRoom(t, srv, alice) mustOK(t, post(t, srv, "/_matrix/client/v3/rooms/"+url.PathEscape(roomID)+"/join", map[string]any{}, bob), "join") waitForPush(t, srv) since := pushSyncOnce(t, srv, alice, "", 0).NextBatch // Two plain messages and one that mentions Alice. sendText(t, srv, bob, roomID, "t1", map[string]any{"msgtype": "m.text", "body": "hello"}) sendText(t, srv, bob, roomID, "t2", map[string]any{"msgtype": "m.text", "body": "anyone?"}) mentionID := sendText(t, srv, bob, roomID, "t3", map[string]any{"msgtype": "m.text", "body": "hey", "m.mentions": map[string]any{"user_ids": []string{"@alice:warp.example"}}}) // Bob's own messages do not notify Bob. waitForPush(t, srv) got := pushSyncOnce(t, srv, alice, since, 0) counts := got.Rooms.Join[roomID].UnreadNotifications if counts == nil || counts.NotificationCount != 3 || counts.HighlightCount != 1 { t.Fatalf("alice's counts = %+v", counts) } bobCounts := pushSyncOnce(t, srv, bob, "", 0).Rooms.Join[roomID].UnreadNotifications if bobCounts == nil || bobCounts.NotificationCount != 0 { t.Errorf("bob's counts = %+v", bobCounts) } // The notification list has them, newest first. list := mustOK(t, getWithToken(t, srv, "/_matrix/client/v3/notifications", alice), "notifications") notes, _ := list["notifications"].([]any) if len(notes) != 3 { t.Fatalf("notifications = %v", list) } newest, _ := notes[0].(map[string]any) if ev, _ := newest["event"].(map[string]any); ev["event_id"] != mentionID || newest["read"] != false || newest["room_id"] != roomID { t.Errorf("newest notification = %v", newest) } highlights := mustOK(t, getWithToken(t, srv, "/_matrix/client/v3/notifications?only=highlight", alice), "highlights") if notes, _ := highlights["notifications"].([]any); len(notes) != 1 { t.Errorf("highlights = %v", highlights) } page := mustOK(t, getWithToken(t, srv, "/_matrix/client/v3/notifications?limit=2", alice), "page") next, _ := page["next_token"].(string) rest := mustOK(t, getWithToken(t, srv, "/_matrix/client/v3/notifications?limit=2&from="+next, alice), "rest") if notes, _ := rest["notifications"].([]any); next == "" || len(notes) != 1 { t.Errorf("paging: next %q, then %v", next, rest) } // A private receipt on the newest event clears the room, and the room // comes down the next sync with zero although nothing else happened // in it. since = got.NextBatch mustOK(t, post(t, srv, "/_matrix/client/v3/rooms/"+url.PathEscape(roomID)+"/receipt/m.read.private/"+url.PathEscape(mentionID), map[string]any{}, alice), "receipt") cleared := pushSyncOnce(t, srv, alice, since, 0).Rooms.Join[roomID].UnreadNotifications if cleared == nil || cleared.NotificationCount != 0 || cleared.HighlightCount != 0 { t.Errorf("after the receipt: %+v", cleared) } if notes, _ := mustOK(t, getWithToken(t, srv, "/_matrix/client/v3/notifications", alice), "notifications")["notifications"].([]any); len(notes) != 3 || notes[0].(map[string]any)["read"] != true { t.Errorf("after the receipt the list is %v", notes) } // Silencing the message rules stops plain messages counting. In a // room of two that is the one-to-one rule as well, which comes first. for _, rule := range []string{".m.rule.room_one_to_one", ".m.rule.message"} { mustOK(t, put(t, srv, "/_matrix/client/v3/pushrules/global/underride/"+rule+"/enabled", map[string]any{"enabled": false}, alice), "disable "+rule) } sendText(t, srv, bob, roomID, "t4", map[string]any{"msgtype": "m.text", "body": "quiet now"}) waitForPush(t, srv) if c := pushSyncOnce(t, srv, alice, "", 0).Rooms.Join[roomID].UnreadNotifications; c == nil || c.NotificationCount != 0 { t.Errorf("with the message rule off: %+v", c) } } // fakeGateway records the pushes it is sent, and rejects push keys on // request. type fakeGateway struct { mu sync.Mutex pushes []map[string]any reject string got chan struct{} } func newFakeGateway(t *testing.T) (*fakeGateway, string) { g := &fakeGateway{got: make(chan struct{}, 16)} ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/_matrix/push/v1/notify" { http.NotFound(w, r) return } body, _ := io.ReadAll(r.Body) var push map[string]any _ = json.Unmarshal(body, &push) g.mu.Lock() g.pushes = append(g.pushes, push) reject := g.reject g.mu.Unlock() rejected := []string{} if reject != "" { rejected = append(rejected, reject) } _ = json.NewEncoder(w).Encode(map[string]any{"rejected": rejected}) g.got <- struct{}{} })) t.Cleanup(ts.Close) return g, ts.URL + "/_matrix/push/v1/notify" } func (g *fakeGateway) wait(t *testing.T) map[string]any { t.Helper() select { case <-g.got: case <-time.After(10 * time.Second): t.Fatal("the gateway was not pushed to") } g.mu.Lock() defer g.mu.Unlock() return g.pushes[len(g.pushes)-1] } func TestPushersThroughTheAPI(t *testing.T) { srv := newSigningServer(t, nil) alice := registerThrough(t, srv, "alice") bob := registerThrough(t, srv, "bob") gateway, gatewayURL := newFakeGateway(t) pusher := func(key string, data map[string]any) map[string]any { return map[string]any{ "pushkey": key, "kind": "http", "app_id": "org.example.app", "app_display_name": "Example", "device_display_name": "Phone", "lang": "en", "data": data, } } // The refusals first. for _, tc := range []struct { name string body map[string]any }{ {"a gateway URL with the wrong path", pusher("k", map[string]any{"url": "https://push.example/notify"})}, {"no gateway URL", pusher("k", map[string]any{})}, {"an email pusher", func() map[string]any { p := pusher("k", map[string]any{}); p["kind"] = "email"; return p }()}, {"no kind", func() map[string]any { p := pusher("k", map[string]any{"url": gatewayURL}) delete(p, "kind") return p }()}, } { if rec := post(t, srv, "/_matrix/client/v3/pushers/set", tc.body, alice); rec.Code != http.StatusBadRequest { t.Errorf("%s: status %d: %s", tc.name, rec.Code, rec.Body.String()) } } mustOK(t, post(t, srv, "/_matrix/client/v3/pushers/set", pusher("key1", map[string]any{"url": gatewayURL, "custom": "x"}), alice), "pushers/set") listed := mustOK(t, getWithToken(t, srv, "/_matrix/client/v3/pushers", alice), "pushers") if pushers, _ := listed["pushers"].([]any); len(pushers) != 1 || pushers[0].(map[string]any)["pushkey"] != "key1" { t.Fatalf("pushers = %v", listed) } roomID := createPublicRoom(t, srv, alice) mustOK(t, post(t, srv, "/_matrix/client/v3/rooms/"+url.PathEscape(roomID)+"/join", map[string]any{}, bob), "join") mustOK(t, put(t, srv, "/_matrix/client/v3/rooms/"+url.PathEscape(roomID)+"/state/m.room.name/", map[string]any{"name": "Tea"}, alice), "name") waitForPush(t, srv) eventID := sendText(t, srv, bob, roomID, "p1", map[string]any{"msgtype": "m.text", "body": "hello"}) push := gateway.wait(t) note, _ := push["notification"].(map[string]any) devices, _ := note["devices"].([]any) device, _ := devices[0].(map[string]any) data, _ := device["data"].(map[string]any) counts, _ := note["counts"].(map[string]any) if note["event_id"] != eventID || note["room_id"] != roomID || note["sender"] != "@bob:warp.example" || note["room_name"] != "Tea" || note["type"] != "m.room.message" || counts["unread"] != float64(1) { t.Errorf("notification = %v", note) } if device["pushkey"] != "key1" || device["app_id"] != "org.example.app" || data["custom"] != "x" || data["url"] != nil { t.Errorf("device = %v", device) } if content, _ := note["content"].(map[string]any); content["body"] != "hello" { t.Errorf("content = %v", note["content"]) } // event_id_only sends the IDs and nothing of the event. mustOK(t, post(t, srv, "/_matrix/client/v3/pushers/set", pusher("key1", map[string]any{"url": gatewayURL, "format": "event_id_only"}), alice), "pushers/set") sendText(t, srv, bob, roomID, "p2", map[string]any{"msgtype": "m.text", "body": "secret"}) note, _ = gateway.wait(t)["notification"].(map[string]any) if note["content"] != nil || note["sender"] != nil || note["event_id"] == nil { t.Errorf("event_id_only notification = %v", note) } // A push key the gateway rejects is removed. gateway.mu.Lock() gateway.reject = "key1" gateway.mu.Unlock() sendText(t, srv, bob, roomID, "p3", map[string]any{"msgtype": "m.text", "body": "again"}) gateway.wait(t) deadline := time.Now().Add(5 * time.Second) for { pushers, _ := mustOK(t, getWithToken(t, srv, "/_matrix/client/v3/pushers", alice), "pushers")["pushers"].([]any) if len(pushers) == 0 { break } if time.Now().After(deadline) { t.Fatalf("the rejected pusher is still there: %v", pushers) } time.Sleep(20 * time.Millisecond) } // Logging out takes the session's pushers with it, and kind null // removes one. mustOK(t, post(t, srv, "/_matrix/client/v3/pushers/set", pusher("key2", map[string]any{"url": gatewayURL}), alice), "pushers/set") removal := pusher("key2", nil) removal["kind"] = nil mustOK(t, post(t, srv, "/_matrix/client/v3/pushers/set", removal, alice), "remove pusher") if pushers, _ := mustOK(t, getWithToken(t, srv, "/_matrix/client/v3/pushers", alice), "pushers")["pushers"].([]any); len(pushers) != 0 { t.Errorf("after kind null: %v", pushers) } mustOK(t, post(t, srv, "/_matrix/client/v3/pushers/set", pusher("key3", map[string]any{"url": gatewayURL}), alice), "pushers/set") if pushers, _ := srv.Push().Pushers(t.Context(), "alice"); len(pushers) != 1 { t.Fatalf("before logout: %v", pushers) } mustOK(t, post(t, srv, "/_matrix/client/v3/logout", map[string]any{}, alice), "logout") if pushers, _ := srv.Push().Pushers(t.Context(), "alice"); len(pushers) != 0 { t.Errorf("after logout: %v", pushers) } }