package pushrules_test import ( "encoding/json" "errors" "slices" "testing" "github.com/dariuszjaniak/warp/server/internal/pushrules" ) const alice = "@alice:warp.example" func mustEvent(t *testing.T, raw string) *pushrules.Event { t.Helper() ev, err := pushrules.NewEvent([]byte(raw)) if err != nil { t.Fatal(err) } return ev } func ruleIDs(rules []pushrules.Rule) []string { var out []string for _, r := range rules { out = append(out, r.RuleID) } return out } func TestSplitKey(t *testing.T) { for key, want := range map[string][]string{ "content.body": {"content", "body"}, `content.m\.mentions.user_ids`: {"content", "m.mentions", "user_ids"}, `a\\.b`: {`a\`, "b"}, `a\b`: {`a\b`}, "type": {"type"}, } { if got := pushrules.SplitKey(key); !slices.Equal(got, want) { t.Errorf("SplitKey(%q) = %q, want %q", key, got, want) } } } func TestDefaultsAreTheSpecsOrder(t *testing.T) { rs := pushrules.Defaults(alice) wantOverride := []string{ ".m.rule.master", ".m.rule.suppress_notices", ".m.rule.invite_for_me", ".m.rule.member_event", ".m.rule.is_user_mention", ".m.rule.contains_display_name", ".m.rule.is_room_mention", ".m.rule.roomnotif", ".m.rule.tombstone", ".m.rule.reaction", ".m.rule.server_acl", ".m.rule.suppress_edits", } if got := ruleIDs(rs.Override); !slices.Equal(got, wantOverride) { t.Errorf("override = %v", got) } wantUnderride := []string{".m.rule.call", ".m.rule.encrypted_room_one_to_one", ".m.rule.room_one_to_one", ".m.rule.message", ".m.rule.encrypted"} if got := ruleIDs(rs.Underride); !slices.Equal(got, wantUnderride) { t.Errorf("underride = %v", got) } if len(rs.Content) != 1 || *rs.Content[0].Pattern != "alice" { t.Errorf("content = %+v", rs.Content) } // Every kind is an array on the wire, empty ones included, and the // master rule has an empty condition list rather than none. raw, _ := json.Marshal(rs) var wire map[string]json.RawMessage _ = json.Unmarshal(raw, &wire) if string(wire["room"]) != "[]" || string(wire["sender"]) != "[]" { t.Errorf("empty kinds = %s, %s", wire["room"], wire["sender"]) } var override []map[string]json.RawMessage _ = json.Unmarshal(wire["override"], &override) if string(override[0]["conditions"]) != "[]" || string(override[0]["actions"]) != "[]" { t.Errorf("master = %s", raw) } } func evaluate(t *testing.T, rs pushrules.Ruleset, raw string, ctx pushrules.Context) (string, pushrules.Outcome) { t.Helper() if ctx.UserID == "" { ctx.UserID = alice } rule, _, ok := rs.Evaluate(mustEvent(t, raw), ctx) if !ok { return "", pushrules.Outcome{} } return rule.RuleID, pushrules.ParseActions(rule.Actions) } func TestDefaultEvaluation(t *testing.T) { rs := pushrules.Defaults(alice) mayNotify := func(string) bool { return true } group := pushrules.Context{MemberCount: 5, DisplayName: "Alice Liddell", SenderMayNotify: mayNotify} dm := pushrules.Context{MemberCount: 2} cases := []struct { name string event string ctx pushrules.Context rule string notify bool highlight bool }{ {"a message in a group", `{"type":"m.room.message","sender":"@bob:x","content":{"body":"hello"}}`, group, ".m.rule.message", true, false}, {"a message in a DM", `{"type":"m.room.message","sender":"@bob:x","content":{"body":"hello"}}`, dm, ".m.rule.room_one_to_one", true, false}, {"an encrypted DM", `{"type":"m.room.encrypted","sender":"@bob:x","content":{}}`, dm, ".m.rule.encrypted_room_one_to_one", true, false}, {"a notice", `{"type":"m.room.message","sender":"@bob:x","content":{"msgtype":"m.notice","body":"alice"}}`, group, ".m.rule.suppress_notices", false, false}, {"the user's name at a word boundary", `{"type":"m.room.message","sender":"@bob:x","content":{"body":"hi ALICE!"}}`, group, ".m.rule.contains_user_name", true, true}, {"the user's name inside a word", `{"type":"m.room.message","sender":"@bob:x","content":{"body":"malice"}}`, group, ".m.rule.message", true, false}, {"the display name", `{"type":"m.room.message","sender":"@bob:x","content":{"body":"ask alice liddell."}}`, group, ".m.rule.contains_display_name", true, true}, {"an intentional mention", `{"type":"m.room.message","sender":"@bob:x","content":{"body":"x","m.mentions":{"user_ids":["@alice:warp.example"]}}}`, group, ".m.rule.is_user_mention", true, true}, {"m.mentions turns the body rules off", `{"type":"m.room.message","sender":"@bob:x","content":{"body":"alice","m.mentions":{}}}`, group, ".m.rule.message", true, false}, {"@room by somebody with the power", `{"type":"m.room.message","sender":"@bob:x","content":{"body":"@room look"}}`, group, ".m.rule.roomnotif", true, true}, {"@room by somebody without it", `{"type":"m.room.message","sender":"@bob:x","content":{"body":"@room look"}}`, pushrules.Context{MemberCount: 5}, ".m.rule.message", true, false}, {"a room mention", `{"type":"m.room.message","sender":"@bob:x","content":{"body":"x","m.mentions":{"room":true}}}`, group, ".m.rule.is_room_mention", true, true}, {"an invite for the user", `{"type":"m.room.member","state_key":"@alice:warp.example","sender":"@bob:x","content":{"membership":"invite"}}`, group, ".m.rule.invite_for_me", true, false}, {"somebody else's invite", `{"type":"m.room.member","state_key":"@carol:x","sender":"@bob:x","content":{"membership":"invite"}}`, group, ".m.rule.member_event", false, false}, {"an edit", `{"type":"m.room.message","sender":"@bob:x","content":{"body":"alice","m.relates_to":{"rel_type":"m.replace"}}}`, group, ".m.rule.suppress_edits", false, false}, {"a tombstone", `{"type":"m.room.tombstone","state_key":"","sender":"@bob:x","content":{}}`, group, ".m.rule.tombstone", true, true}, {"a call", `{"type":"m.call.invite","sender":"@bob:x","content":{}}`, group, ".m.rule.call", true, false}, {"a reaction", `{"type":"m.reaction","sender":"@bob:x","content":{}}`, group, ".m.rule.reaction", false, false}, {"something no rule is about", `{"type":"m.room.topic","state_key":"","sender":"@bob:x","content":{}}`, group, "", false, false}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { rule, outcome := evaluate(t, rs, tc.event, tc.ctx) if rule != tc.rule || outcome.Notify != tc.notify || outcome.Highlight != tc.highlight { t.Errorf("got %q notify=%v highlight=%v, want %q notify=%v highlight=%v", rule, outcome.Notify, outcome.Highlight, tc.rule, tc.notify, tc.highlight) } }) } } func TestConditions(t *testing.T) { pattern := func(s string) *string { return &s } rule := func(conds ...pushrules.Condition) pushrules.Ruleset { return pushrules.Ruleset{Override: []pushrules.Rule{{ RuleID: "r", Enabled: true, Actions: []json.RawMessage{json.RawMessage(`"notify"`)}, Conditions: conds, }}} } ev := `{"type":"org.example","sender":"@bob:x","room_id":"!r:x","content":{"n":3,"b":true,"z":null,"s":"Hello World","list":["a",1,true],"m.dotted":"yes"}}` for _, tc := range []struct { name string cond pushrules.Condition want bool }{ {"glob star", pushrules.Condition{Kind: "event_match", Key: "type", Pattern: pattern("org.*")}, true}, {"glob question mark", pushrules.Condition{Kind: "event_match", Key: "type", Pattern: pattern("org.exampl?")}, true}, {"globs match the whole value", pushrules.Condition{Kind: "event_match", Key: "type", Pattern: pattern("org")}, false}, {"globs are case-insensitive", pushrules.Condition{Kind: "event_match", Key: "content.s", Pattern: pattern("hello world")}, true}, {"globs only match strings", pushrules.Condition{Kind: "event_match", Key: "content.n", Pattern: pattern("*")}, false}, {"a missing key", pushrules.Condition{Kind: "event_match", Key: "content.nope", Pattern: pattern("*")}, false}, {"an escaped dot", pushrules.Condition{Kind: "event_match", Key: `content.m\.dotted`, Pattern: pattern("yes")}, true}, {"property is an integer", pushrules.Condition{Kind: "event_property_is", Key: "content.n", Value: json.RawMessage(`3`)}, true}, {"property is another integer", pushrules.Condition{Kind: "event_property_is", Key: "content.n", Value: json.RawMessage(`4`)}, false}, {"property is a string, exactly", pushrules.Condition{Kind: "event_property_is", Key: "content.s", Value: json.RawMessage(`"hello world"`)}, false}, {"property is true", pushrules.Condition{Kind: "event_property_is", Key: "content.b", Value: json.RawMessage(`true`)}, true}, {"property is null", pushrules.Condition{Kind: "event_property_is", Key: "content.z", Value: json.RawMessage(`null`)}, true}, {"null is not a missing key", pushrules.Condition{Kind: "event_property_is", Key: "content.nope", Value: json.RawMessage(`null`)}, false}, {"property contains", pushrules.Condition{Kind: "event_property_contains", Key: "content.list", Value: json.RawMessage(`1`)}, true}, {"property does not contain", pushrules.Condition{Kind: "event_property_contains", Key: "content.list", Value: json.RawMessage(`"b"`)}, false}, {"contains on a non-list", pushrules.Condition{Kind: "event_property_contains", Key: "content.s", Value: json.RawMessage(`"H"`)}, false}, {"member count equal", pushrules.Condition{Kind: "room_member_count", Is: "5"}, true}, {"member count ==", pushrules.Condition{Kind: "room_member_count", Is: "==5"}, true}, {"member count <", pushrules.Condition{Kind: "room_member_count", Is: "<5"}, false}, {"member count >=", pushrules.Condition{Kind: "room_member_count", Is: ">=5"}, true}, {"member count garbage", pushrules.Condition{Kind: "room_member_count", Is: "lots"}, false}, {"an unknown condition", pushrules.Condition{Kind: "org.example.condition"}, false}, } { t.Run(tc.name, func(t *testing.T) { got, _ := evaluate(t, rule(tc.cond), ev, pushrules.Context{MemberCount: 5}) if (got == "r") != tc.want { t.Errorf("matched = %v, want %v", got == "r", tc.want) } }) } // A display name is a literal, not a glob. got, _ := evaluate(t, rule(pushrules.Condition{Kind: "contains_display_name"}), `{"type":"m.room.message","content":{"body":"anything"}}`, pushrules.Context{DisplayName: "*"}) if got != "" { t.Error("a display name of * matched an unrelated body") } } func TestRoomAndSenderRules(t *testing.T) { rs := pushrules.Defaults(alice) quiet := pushrules.Rule{RuleID: "!quiet:x", Enabled: true, Actions: []json.RawMessage{}} if err := rs.Put(pushrules.KindRoom, quiet, "", ""); err != nil { t.Fatal(err) } loud := pushrules.Rule{RuleID: "@boss:x", Enabled: true, Actions: []json.RawMessage{json.RawMessage(`"notify"`), json.RawMessage(`{"set_tweak":"highlight"}`)}} if err := rs.Put(pushrules.KindSender, loud, "", ""); err != nil { t.Fatal(err) } if rule, out := evaluate(t, rs, `{"type":"m.room.message","room_id":"!quiet:x","sender":"@boss:x","content":{"body":"x"}}`, pushrules.Context{MemberCount: 3}); rule != "!quiet:x" || out.Notify { t.Errorf("room rule: %q %+v", rule, out) } if rule, out := evaluate(t, rs, `{"type":"m.room.message","room_id":"!other:x","sender":"@boss:x","content":{"body":"x"}}`, pushrules.Context{MemberCount: 3}); rule != "@boss:x" || !out.Highlight { t.Errorf("sender rule: %q %+v", rule, out) } } func TestMergeKeepsUserRulesAndDefaultOverrides(t *testing.T) { defaults := pushrules.Defaults(alice) stored := defaults.Clone() if err := stored.SetEnabled(pushrules.KindOverride, pushrules.RuleMaster, true); err != nil { t.Fatal(err) } if err := stored.SetActions(pushrules.KindUnderride, pushrules.RuleMessage, []json.RawMessage{}); err != nil { t.Fatal(err) } mine := pushrules.Rule{RuleID: "mine", Enabled: true, Actions: []json.RawMessage{}, Conditions: []pushrules.Condition{}} if err := stored.Put(pushrules.KindOverride, mine, "", ""); err != nil { t.Fatal(err) } // A default rule the stored copy does not have — one a newer server // added — appears; one it has that the server dropped disappears. stored.Override = slices.DeleteFunc(stored.Override, func(r pushrules.Rule) bool { return r.RuleID == pushrules.RuleTombstone }) stored.Override = append(stored.Override, pushrules.Rule{RuleID: ".m.rule.gone", Default: true}) merged := pushrules.Merge(stored, defaults) ids := ruleIDs(merged.Override) if ids[0] != pushrules.RuleMaster || ids[1] != "mine" || !slices.Contains(ids, pushrules.RuleTombstone) || slices.Contains(ids, ".m.rule.gone") { t.Errorf("override = %v", ids) } if master, _ := merged.Find(pushrules.KindOverride, pushrules.RuleMaster); !master.Enabled { t.Error("the master rule lost its enabled flag") } if msg, _ := merged.Find(pushrules.KindUnderride, pushrules.RuleMessage); len(msg.Actions) != 0 { t.Errorf("the message rule's actions = %s", msg.Actions) } // The master rule silences everything once on. if rule, out := evaluate(t, merged, `{"type":"m.room.message","content":{"body":"alice"}}`, pushrules.Context{}); rule != pushrules.RuleMaster || out.Notify { t.Errorf("with master on: %q %+v", rule, out) } } func TestPutOrdering(t *testing.T) { rs := pushrules.Defaults(alice) mk := func(id string) pushrules.Rule { return pushrules.Rule{RuleID: id, Enabled: true, Actions: []json.RawMessage{}, Conditions: []pushrules.Condition{}} } for _, id := range []string{"a", "b"} { if err := rs.Put(pushrules.KindOverride, mk(id), "", ""); err != nil { t.Fatal(err) } } if err := rs.Put(pushrules.KindOverride, mk("c"), "", "a"); err != nil { t.Fatal(err) } if err := rs.Put(pushrules.KindOverride, mk("d"), "b", ""); err != nil { t.Fatal(err) } if got := ruleIDs(rs.Override)[:5]; !slices.Equal(got, []string{pushrules.RuleMaster, "d", "b", "a", "c"}) { t.Errorf("order = %v", got) } // Replacing keeps the place and the enabled flag. if err := rs.SetEnabled(pushrules.KindOverride, "b", false); err != nil { t.Fatal(err) } replacement := mk("b") replacement.Actions = []json.RawMessage{json.RawMessage(`"notify"`)} if err := rs.Put(pushrules.KindOverride, replacement, "", ""); err != nil { t.Fatal(err) } b, _ := rs.Find(pushrules.KindOverride, "b") if got := ruleIDs(rs.Override)[2]; got != "b" || b.Enabled || len(b.Actions) != 1 { t.Errorf("after replacing: %v, %+v", ruleIDs(rs.Override), b) } if err := rs.Put(pushrules.KindOverride, mk("e"), "nope", ""); !errors.Is(err, pushrules.ErrNoSuchAnchor) { t.Errorf("before an unknown rule: %v", err) } if err := rs.Put(pushrules.KindOverride, mk("e"), pushrules.RuleMaster, ""); !errors.Is(err, pushrules.ErrNoSuchAnchor) { t.Errorf("before a default rule: %v", err) } if err := rs.Delete(pushrules.KindOverride, pushrules.RuleMaster); !errors.Is(err, pushrules.ErrNoSuchRule) { t.Errorf("deleting a default rule: %v", err) } if err := rs.Delete(pushrules.KindOverride, "a"); err != nil { t.Errorf("deleting a user rule: %v", err) } } func TestBuildAndValidate(t *testing.T) { notify := []json.RawMessage{json.RawMessage(`"notify"`)} conds := []pushrules.Condition{} pattern := "cake" for _, tc := range []struct { name string kind pushrules.Kind id string rule pushrules.NewRule ok bool }{ {"an override rule", pushrules.KindOverride, "x", pushrules.NewRule{Actions: notify, Conditions: &conds}, true}, {"an override rule without conditions", pushrules.KindOverride, "x", pushrules.NewRule{Actions: notify}, false}, {"a content rule", pushrules.KindContent, "x", pushrules.NewRule{Actions: notify, Pattern: &pattern}, true}, {"a content rule without a pattern", pushrules.KindContent, "x", pushrules.NewRule{Actions: notify}, false}, {"a room rule", pushrules.KindRoom, "!r:x", pushrules.NewRule{Actions: notify}, true}, {"a reserved ID", pushrules.KindRoom, ".m.rule.x", pushrules.NewRule{Actions: notify}, false}, {"no actions", pushrules.KindRoom, "!r:x", pushrules.NewRule{}, false}, {"an unknown action", pushrules.KindRoom, "!r:x", pushrules.NewRule{Actions: []json.RawMessage{json.RawMessage(`"shout"`)}}, false}, {"a legacy action", pushrules.KindRoom, "!r:x", pushrules.NewRule{Actions: []json.RawMessage{json.RawMessage(`"dont_notify"`)}}, true}, {"a tweak", pushrules.KindRoom, "!r:x", pushrules.NewRule{Actions: []json.RawMessage{json.RawMessage(`{"set_tweak":"sound","value":"x"}`)}}, true}, {"a tweak without a name", pushrules.KindRoom, "!r:x", pushrules.NewRule{Actions: []json.RawMessage{json.RawMessage(`{"value":"x"}`)}}, false}, } { t.Run(tc.name, func(t *testing.T) { rule, err := tc.rule.Build(tc.kind, tc.id) if (err == nil) != tc.ok { t.Fatalf("Build = %v", err) } if err != nil && !errors.Is(err, pushrules.ErrInvalidRule) { t.Errorf("error %v is not ErrInvalidRule", err) } if err == nil && !rule.Enabled { t.Error("a new rule is not enabled") } }) } } func TestParseActions(t *testing.T) { out := pushrules.ParseActions([]json.RawMessage{ json.RawMessage(`"notify"`), json.RawMessage(`{"set_tweak":"sound","value":"default"}`), json.RawMessage(`{"set_tweak":"highlight"}`), json.RawMessage(`{"set_tweak":"org.example","value":{"a":1}}`), }) if !out.Notify || !out.Highlight || string(out.Tweaks["sound"]) != `"default"` || string(out.Tweaks["highlight"]) != "true" || string(out.Tweaks["org.example"]) != `{"a":1}` { t.Errorf("ParseActions = %+v", out) } if out := pushrules.ParseActions([]json.RawMessage{json.RawMessage(`{"set_tweak":"highlight","value":true}`)}); out.Notify || out.Highlight { t.Errorf("a highlight that does not notify = %+v", out) } if out := pushrules.ParseActions([]json.RawMessage{json.RawMessage(`"dont_notify"`)}); out.Notify { t.Errorf("dont_notify = %+v", out) } } func TestDocumentRoundTrip(t *testing.T) { raw, err := pushrules.MarshalDocument(pushrules.Defaults(alice)) if err != nil { t.Fatal(err) } rs, err := pushrules.ParseDocument(raw) if err != nil { t.Fatal(err) } if len(rs.Override) != 12 || len(rs.Underride) != 5 { t.Errorf("round trip lost rules: %s", raw) } if _, err := pushrules.ParseDocument([]byte(`nope`)); err == nil { t.Error("a malformed document parsed") } }