Files
Warp/server/internal/setup/phase8_test.go
T
tajniak81andClaude Opus 5.5 b507753172 Phase 8: push and notifications
Implements PLAN.md Phase 8. internal/pushrules is the rule language: the
spec's default ruleset, all six condition kinds, glob and word-boundary
matching, intentional mentions switching off the legacy body rules, and
rule ordering as Synapse has it. internal/pushserver keeps the rules,
pushers and notifications and talks to push gateways.

Push rules are the m.push_rules account data, written only by the push
server; reads keep the user's own rules and the enabled/actions of each
default and take the rest from the current defaults, so new default rules
reach every user without a migration. PUT account_data/m.push_rules is
refused with 405.

The push server listens on the roomserver's emit stage and evaluates each
event on its own worker for every local member and an invite's target.
Notifications and per-room unread counts are stored in migration 0010;
counts are a seventh sync token stream, so /sync reports
unread_notifications and picks up count changes made after the event or by
a receipt from another device. m.read and m.read.private receipts clear
the counts; leaving a room clears it.

Pushers (http kind only) push through a bounded sender pool with retries,
honour event_id_only, drop push keys the gateway rejects, and go away with
their device. GET /notifications pages the history. New config section
push: enabled, include_content, gateway_timeout, notification_retention.

Account data writes now wake the user's blocked syncs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 11:43:36 +02:00

416 lines
17 KiB
Go

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)
}
}