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>
389 lines
18 KiB
Go
389 lines
18 KiB
Go
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")
|
|
}
|
|
}
|