CI / Test (ubuntu-latest) (push) Canceled after 0s
CI / Test (windows-latest) (push) Canceled after 0s
CI / Storage tests on PostgreSQL (push) Canceled after 0s
CI / Complement (federation off) (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
CI / Smoke test the binary (push) Canceled after 0s
Implements PLAN.md Phase 2: the library every later phase is built on, as pure code under server/internal/matrix with no HTTP and no storage. canonicaljson: the canonical JSON encoding, hand-written because encoding/json escapes <, > and & and accepts floats. Integers only, in [-(2^53)+1, 2^53-1]; lone surrogate escapes rejected rather than turned into U+FFFD. signing: the Signing JSON algorithm alongside the existing key handling, and one lenient base64 decoder shared by the packages above it. id: identifier grammars as they appear inside events, deliberately laxer than userapi's account rules because rooms carry historical user IDs. roomversion: the registry. Versions 3 to 12 are supported, each written out in full as a Rules value with the flags the other packages read. Versions 1 and 2 are known but refused: their event format and state resolution v1 wait for federation. createRoom will default to 11, as the spec asks for a while after 1.16, with 12 available. event: PDU parsing and validation, content and reference hashes, event IDs, the redaction algorithm for every supported version, signing and verification, and a Builder. auth: the authorisation rules in spec order, power levels with their defaults, v12 creators with infinite power, the auth events selection algorithm, rule 3's auth_events checks, and the separate test for whether a redaction applies. Comments use the room version 12 rule numbering, with the version 11 numbers given where they differ. stateres: state resolution v2, and version 12's amendment (an empty starting state and the conflicted state subgraph). Tested against the spec's own vectors byte for byte: the canonical JSON examples, both JSON signing vectors and both event signing vectors. State resolution ports scenarios from Synapse's suite. canonicaljson and event.Parse have fuzz targets. The capabilities endpoint now advertises the registry instead of a hard-coded "11". docs/event-core.md describes the packages and the known gaps. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
747 lines
25 KiB
Go
747 lines
25 KiB
Go
package stateres
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/dariuszjaniak/warp/server/internal/matrix/auth"
|
|
"github.com/dariuszjaniak/warp/server/internal/matrix/canonicaljson"
|
|
"github.com/dariuszjaniak/warp/server/internal/matrix/event"
|
|
"github.com/dariuszjaniak/warp/server/internal/matrix/roomversion"
|
|
"github.com/dariuszjaniak/warp/server/internal/matrix/signing"
|
|
)
|
|
|
|
const (
|
|
alice = "@alice:example.com"
|
|
bob = "@bob:example.com"
|
|
charlie = "@charlie:example.com"
|
|
evelyn = "@evelyn:example.com"
|
|
zara = "@zara:example.com"
|
|
)
|
|
|
|
func ptr[T any](v T) *T { return &v }
|
|
|
|
// spec describes one event to put in the graph. Events are identified by a
|
|
// short name so that a test reads like the scenario it describes.
|
|
type spec struct {
|
|
name string
|
|
sender string
|
|
typ string
|
|
stateKey *string
|
|
content map[string]any
|
|
}
|
|
|
|
// dag builds a room whose shape the test controls, and serves it back as
|
|
// an EventProvider.
|
|
//
|
|
// The model is the one Synapse's state resolution tests use, which is also
|
|
// where several of the scenarios below come from: events are listed with
|
|
// the paths that connect them, each event's auth_events are taken from the
|
|
// state resolved at its own prev_events, and timestamps increase in
|
|
// declaration order so that no outcome rests on an event ID tie-break.
|
|
type dag struct {
|
|
t *testing.T
|
|
rules roomversion.Rules
|
|
key *signing.KeyPair
|
|
roomID string
|
|
|
|
byName map[string]*event.Event
|
|
byID map[string]*event.Event
|
|
after map[string]auth.State
|
|
depth map[string]int64
|
|
clock int64
|
|
}
|
|
|
|
func (d *dag) EventsByID(_ context.Context, ids []string) ([]*event.Event, error) {
|
|
out := make([]*event.Event, 0, len(ids))
|
|
for _, id := range ids {
|
|
if e := d.byID[id]; e != nil {
|
|
out = append(out, e)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// newDAG starts a room: Alice creates it, joins, takes power level 100,
|
|
// makes it public, and Bob, Charlie, Evelyn and Zara join. Everything
|
|
// after that is up to the test.
|
|
func newDAG(t *testing.T, v roomversion.Version) *dag {
|
|
t.Helper()
|
|
key, err := signing.Generate("test")
|
|
if err != nil {
|
|
t.Fatalf("generate a signing key: %v", err)
|
|
}
|
|
d := &dag{
|
|
t: t,
|
|
rules: roomversion.MustGet(v),
|
|
key: key,
|
|
byName: map[string]*event.Event{},
|
|
byID: map[string]*event.Event{},
|
|
after: map[string]auth.State{},
|
|
depth: map[string]int64{},
|
|
clock: 1700000000000,
|
|
}
|
|
if !d.rules.RoomIDIsCreateEventID {
|
|
d.roomID = "!room:example.com"
|
|
}
|
|
|
|
create := map[string]any{"room_version": string(v)}
|
|
if d.rules.CreateEventRequiresCreator {
|
|
create["creator"] = alice
|
|
}
|
|
|
|
join := map[string]any{"membership": "join"}
|
|
initial := []spec{
|
|
{name: "CREATE", sender: alice, typ: event.TypeCreate, stateKey: ptr(""), content: create},
|
|
{name: "IMA", sender: alice, typ: event.TypeMember, stateKey: ptr(alice), content: join},
|
|
{name: "IPOWER", sender: alice, typ: event.TypePowerLevels, stateKey: ptr(""),
|
|
content: map[string]any{"users": map[string]any{alice: 100}}},
|
|
{name: "IJR", sender: alice, typ: event.TypeJoinRules, stateKey: ptr(""),
|
|
content: map[string]any{"join_rule": auth.JoinRulePublic}},
|
|
{name: "IMB", sender: bob, typ: event.TypeMember, stateKey: ptr(bob), content: join},
|
|
{name: "IMC", sender: charlie, typ: event.TypeMember, stateKey: ptr(charlie), content: join},
|
|
{name: "IMZ", sender: zara, typ: event.TypeMember, stateKey: ptr(zara), content: join},
|
|
{name: "START", sender: zara, typ: "m.room.message", content: map[string]any{}},
|
|
}
|
|
// A linear chain: each initial event follows the one before it.
|
|
edges := make([][]string, 0, len(initial))
|
|
for i := 1; i < len(initial); i++ {
|
|
edges = append(edges, []string{initial[i].name, initial[i-1].name})
|
|
}
|
|
d.build(initial, edges)
|
|
return d
|
|
}
|
|
|
|
// build adds events to the graph.
|
|
//
|
|
// edges are paths listed newest first, so ["END", "MB", "PA"] means PA is
|
|
// followed by MB, which is followed by END. specs must be in dependency
|
|
// order; the one place that is not obvious, build says so.
|
|
func (d *dag) build(specs []spec, edges [][]string) {
|
|
d.t.Helper()
|
|
|
|
prevs := map[string][]string{}
|
|
for _, path := range edges {
|
|
for i := 0; i < len(path)-1; i++ {
|
|
child, parent := path[i], path[i+1]
|
|
if !contains(prevs[child], parent) {
|
|
prevs[child] = append(prevs[child], parent)
|
|
}
|
|
}
|
|
}
|
|
|
|
for _, s := range specs {
|
|
state := auth.State{}
|
|
var prevIDs []string
|
|
var depth int64
|
|
|
|
parents := prevs[s.name]
|
|
stateSets := make([][]*event.Event, 0, len(parents))
|
|
for _, parent := range parents {
|
|
e, ok := d.byName[parent]
|
|
if !ok {
|
|
d.t.Fatalf("%s names %s as a previous event, but %s has not been built yet",
|
|
s.name, parent, parent)
|
|
}
|
|
prevIDs = append(prevIDs, e.EventID())
|
|
if dep := d.depth[parent]; dep > depth {
|
|
depth = dep
|
|
}
|
|
var set []*event.Event
|
|
for _, stateEvent := range d.after[parent] {
|
|
set = append(set, stateEvent)
|
|
}
|
|
stateSets = append(stateSets, set)
|
|
}
|
|
|
|
switch len(stateSets) {
|
|
case 0:
|
|
case 1:
|
|
state = d.after[parents[0]]
|
|
default:
|
|
// A fork's own events have to be authorised against something,
|
|
// and state resolution is what the room's own servers would
|
|
// use here too.
|
|
resolved, err := Resolve(context.Background(), d.rules, stateSets, d)
|
|
if err != nil {
|
|
d.t.Fatalf("resolve the state before %s: %v", s.name, err)
|
|
}
|
|
state = resolved
|
|
}
|
|
|
|
content := mustJSON(d.t, s.content)
|
|
selected, err := auth.SelectAuthEvents(d.rules, s.typ, s.sender, s.stateKey, content, state)
|
|
if err != nil {
|
|
d.t.Fatalf("select auth events for %s: %v", s.name, err)
|
|
}
|
|
authIDs := make([]string, 0, len(selected))
|
|
for _, e := range selected {
|
|
authIDs = append(authIDs, e.EventID())
|
|
}
|
|
|
|
b := event.Builder{
|
|
Type: s.typ,
|
|
Sender: s.sender,
|
|
StateKey: s.stateKey,
|
|
Content: content,
|
|
PrevEvents: prevIDs,
|
|
AuthEvents: authIDs,
|
|
Depth: depth + 1,
|
|
}
|
|
derivesRoomID := d.rules.RoomIDIsCreateEventID && s.typ == event.TypeCreate
|
|
if !derivesRoomID {
|
|
b.RoomID = d.roomID
|
|
}
|
|
|
|
d.clock++
|
|
e, err := b.Build(time.UnixMilli(d.clock), d.key, d.rules)
|
|
if err != nil {
|
|
d.t.Fatalf("build %s: %v", s.name, err)
|
|
}
|
|
if s.typ == event.TypeCreate && d.rules.RoomIDIsCreateEventID {
|
|
if d.roomID, err = event.RoomIDOfCreateEvent(e); err != nil {
|
|
d.t.Fatalf("derive the room ID: %v", err)
|
|
}
|
|
}
|
|
|
|
d.byName[s.name] = e
|
|
d.byID[e.EventID()] = e
|
|
d.depth[s.name] = depth + 1
|
|
|
|
next := make(auth.State, len(state)+1)
|
|
for k, v := range state {
|
|
next[k] = v
|
|
}
|
|
if sk, ok := e.StateKey(); ok {
|
|
next[auth.StateKey{Type: e.Type(), StateKey: sk}] = e
|
|
}
|
|
d.after[s.name] = next
|
|
}
|
|
}
|
|
|
|
// resolveAt resolves the states after each of a node's previous events,
|
|
// which is the state that node would be sent in.
|
|
func (d *dag) resolveAt(name string) auth.State {
|
|
d.t.Helper()
|
|
e, ok := d.byName[name]
|
|
if !ok {
|
|
d.t.Fatalf("no event named %s", name)
|
|
}
|
|
var stateSets [][]*event.Event
|
|
for _, prevID := range e.PrevEventIDs() {
|
|
prevName := d.nameOf(prevID)
|
|
var set []*event.Event
|
|
for _, stateEvent := range d.after[prevName] {
|
|
set = append(set, stateEvent)
|
|
}
|
|
stateSets = append(stateSets, set)
|
|
}
|
|
resolved, err := Resolve(context.Background(), d.rules, stateSets, d)
|
|
if err != nil {
|
|
d.t.Fatalf("resolve at %s: %v", name, err)
|
|
}
|
|
return resolved
|
|
}
|
|
|
|
func (d *dag) nameOf(eventID string) string {
|
|
for name, e := range d.byName {
|
|
if e.EventID() == eventID {
|
|
return name
|
|
}
|
|
}
|
|
d.t.Fatalf("no name for event %s", eventID)
|
|
return ""
|
|
}
|
|
|
|
// wantState asserts which named event holds each of the given state keys.
|
|
func (d *dag) wantState(state auth.State, want map[auth.StateKey]string) {
|
|
d.t.Helper()
|
|
for key, wantName := range want {
|
|
got, ok := state[key]
|
|
if !ok {
|
|
d.t.Errorf("(%s, %q) is missing from the resolved state, want %s", key.Type, key.StateKey, wantName)
|
|
continue
|
|
}
|
|
if gotName := d.nameOf(got.EventID()); gotName != wantName {
|
|
d.t.Errorf("(%s, %q) resolved to %s, want %s", key.Type, key.StateKey, gotName, wantName)
|
|
}
|
|
}
|
|
}
|
|
|
|
func mustJSON(t *testing.T, v any) json.RawMessage {
|
|
t.Helper()
|
|
raw, err := canonicaljson.Marshal(v)
|
|
if err != nil {
|
|
t.Fatalf("encode test content: %v", err)
|
|
}
|
|
return raw
|
|
}
|
|
|
|
func contains(s []string, v string) bool {
|
|
for _, x := range s {
|
|
if x == v {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
var (
|
|
powerKey = auth.StateKey{Type: event.TypePowerLevels}
|
|
joinRulesKey = auth.StateKey{Type: event.TypeJoinRules}
|
|
topicKey = auth.StateKey{Type: "m.room.topic"}
|
|
)
|
|
|
|
func memberKey(userID string) auth.StateKey {
|
|
return auth.StateKey{Type: event.TypeMember, StateKey: userID}
|
|
}
|
|
|
|
// --- Trivial cases ---
|
|
|
|
func TestResolveFewerThanTwoStateSets(t *testing.T) {
|
|
d := newDAG(t, roomversion.V11)
|
|
|
|
resolved, err := Resolve(context.Background(), d.rules, nil, d)
|
|
if err != nil {
|
|
t.Fatalf("Resolve with no state sets: %v", err)
|
|
}
|
|
if len(resolved) != 0 {
|
|
t.Errorf("resolving nothing gave %d events, want none", len(resolved))
|
|
}
|
|
|
|
var one []*event.Event
|
|
for _, e := range d.after["START"] {
|
|
one = append(one, e)
|
|
}
|
|
resolved, err = Resolve(context.Background(), d.rules, [][]*event.Event{one}, d)
|
|
if err != nil {
|
|
t.Fatalf("Resolve with one state set: %v", err)
|
|
}
|
|
if len(resolved) != len(one) {
|
|
t.Errorf("resolving one state set gave %d events, want %d", len(resolved), len(one))
|
|
}
|
|
}
|
|
|
|
func TestResolveIdenticalStateSets(t *testing.T) {
|
|
d := newDAG(t, roomversion.V11)
|
|
var set []*event.Event
|
|
for _, e := range d.after["START"] {
|
|
set = append(set, e)
|
|
}
|
|
|
|
resolved, err := Resolve(context.Background(), d.rules, [][]*event.Event{set, set}, d)
|
|
if err != nil {
|
|
t.Fatalf("Resolve: %v", err)
|
|
}
|
|
if len(resolved) != len(set) {
|
|
t.Fatalf("resolved %d events, want %d", len(resolved), len(set))
|
|
}
|
|
for _, e := range set {
|
|
sk, _ := e.StateKey()
|
|
if got := resolved[auth.StateKey{Type: e.Type(), StateKey: sk}]; got != e {
|
|
t.Errorf("(%s, %q) changed, though nothing was in conflict", e.Type(), sk)
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Scenarios ported from Synapse's state resolution tests ---
|
|
|
|
// TestBanBeatsPowerLevels: Alice bans Bob on one fork while Bob sends a
|
|
// power levels event on the other. Bob's event loses, because by the time
|
|
// it is replayed he is banned.
|
|
func TestBanBeatsPowerLevels(t *testing.T) {
|
|
d := newDAG(t, roomversion.V11)
|
|
d.build([]spec{
|
|
{name: "PA", sender: alice, typ: event.TypePowerLevels, stateKey: ptr(""),
|
|
content: map[string]any{"users": map[string]any{alice: 100, bob: 50}}},
|
|
{name: "MA", sender: alice, typ: event.TypeMember, stateKey: ptr(alice),
|
|
content: map[string]any{"membership": "join"}},
|
|
{name: "MB", sender: alice, typ: event.TypeMember, stateKey: ptr(bob),
|
|
content: map[string]any{"membership": "ban"}},
|
|
{name: "PB", sender: bob, typ: event.TypePowerLevels, stateKey: ptr(""),
|
|
content: map[string]any{"users": map[string]any{alice: 100, bob: 50}}},
|
|
{name: "END", sender: zara, typ: "m.room.message", content: map[string]any{}},
|
|
}, [][]string{
|
|
{"END", "MB", "MA", "PA", "START"},
|
|
{"END", "PB", "PA"},
|
|
})
|
|
|
|
d.wantState(d.resolveAt("END"), map[auth.StateKey]string{
|
|
powerKey: "PA",
|
|
memberKey(alice): "MA",
|
|
memberKey(bob): "MB",
|
|
})
|
|
}
|
|
|
|
// TestJoinRuleEvasion: Alice makes the room private on one fork while
|
|
// Evelyn joins on the other. Evelyn's join loses, so closing a room cannot
|
|
// be dodged by joining through an older part of the graph.
|
|
func TestJoinRuleEvasion(t *testing.T) {
|
|
d := newDAG(t, roomversion.V11)
|
|
d.build([]spec{
|
|
{name: "JR", sender: alice, typ: event.TypeJoinRules, stateKey: ptr(""),
|
|
content: map[string]any{"join_rule": auth.JoinRulePrivate}},
|
|
{name: "ME", sender: evelyn, typ: event.TypeMember, stateKey: ptr(evelyn),
|
|
content: map[string]any{"membership": "join"}},
|
|
{name: "END", sender: zara, typ: "m.room.message", content: map[string]any{}},
|
|
}, [][]string{
|
|
{"END", "JR", "START"},
|
|
{"END", "ME", "START"},
|
|
})
|
|
|
|
resolved := d.resolveAt("END")
|
|
d.wantState(resolved, map[auth.StateKey]string{joinRulesKey: "JR"})
|
|
if _, ok := resolved[memberKey(evelyn)]; ok {
|
|
t.Error("Evelyn's join survived a join rule change to private")
|
|
}
|
|
}
|
|
|
|
// TestTopicAndPowerLevelFork: Alice demotes Bob on one fork; on the other,
|
|
// Bob promotes himself back and sets the topic. Alice's demotion wins
|
|
// because it was made under power levels Bob's events were not.
|
|
func TestTopicAndPowerLevelFork(t *testing.T) {
|
|
d := newDAG(t, roomversion.V11)
|
|
d.build([]spec{
|
|
{name: "T1", sender: alice, typ: "m.room.topic", stateKey: ptr(""), content: map[string]any{}},
|
|
{name: "PA1", sender: alice, typ: event.TypePowerLevels, stateKey: ptr(""),
|
|
content: map[string]any{"users": map[string]any{alice: 100, bob: 50}}},
|
|
{name: "T2", sender: alice, typ: "m.room.topic", stateKey: ptr(""), content: map[string]any{}},
|
|
{name: "PA2", sender: alice, typ: event.TypePowerLevels, stateKey: ptr(""),
|
|
content: map[string]any{"users": map[string]any{alice: 100, bob: 0}}},
|
|
{name: "PB", sender: bob, typ: event.TypePowerLevels, stateKey: ptr(""),
|
|
content: map[string]any{"users": map[string]any{alice: 100, bob: 50}}},
|
|
{name: "T3", sender: bob, typ: "m.room.topic", stateKey: ptr(""), content: map[string]any{}},
|
|
{name: "END", sender: zara, typ: "m.room.message", content: map[string]any{}},
|
|
}, [][]string{
|
|
{"END", "PA2", "T2", "PA1", "T1", "START"},
|
|
{"END", "T3", "PB", "PA1"},
|
|
})
|
|
|
|
d.wantState(d.resolveAt("END"), map[auth.StateKey]string{
|
|
powerKey: "PA2",
|
|
topicKey: "T2",
|
|
})
|
|
}
|
|
|
|
// TestTopicReset is the known rough edge of state resolution v2, kept here
|
|
// because it is behaviour rather than a bug to fix: a fork from before a
|
|
// topic was set, merged after the author was banned, loses the topic. The
|
|
// ban stands and the power levels stand; only the topic reverts.
|
|
func TestTopicReset(t *testing.T) {
|
|
d := newDAG(t, roomversion.V11)
|
|
d.build([]spec{
|
|
{name: "T1", sender: alice, typ: "m.room.topic", stateKey: ptr(""), content: map[string]any{}},
|
|
{name: "PA", sender: alice, typ: event.TypePowerLevels, stateKey: ptr(""),
|
|
content: map[string]any{"users": map[string]any{alice: 100, bob: 50}}},
|
|
{name: "T2", sender: bob, typ: "m.room.topic", stateKey: ptr(""), content: map[string]any{}},
|
|
{name: "MB", sender: alice, typ: event.TypeMember, stateKey: ptr(bob),
|
|
content: map[string]any{"membership": "ban"}},
|
|
{name: "END", sender: zara, typ: "m.room.message", content: map[string]any{}},
|
|
}, [][]string{
|
|
{"END", "MB", "T2", "PA", "T1", "START"},
|
|
{"END", "T1"},
|
|
})
|
|
|
|
d.wantState(d.resolveAt("END"), map[auth.StateKey]string{
|
|
topicKey: "T1",
|
|
memberKey(bob): "MB",
|
|
powerKey: "PA",
|
|
})
|
|
}
|
|
|
|
// --- Properties that must hold whatever the scenario ---
|
|
|
|
// TestResolutionDoesNotDependOnStateSetOrder is the property that makes
|
|
// the algorithm usable at all: two servers that hold the same views in a
|
|
// different order must reach the same state.
|
|
func TestResolutionDoesNotDependOnStateSetOrder(t *testing.T) {
|
|
for _, v := range []roomversion.Version{roomversion.V11, roomversion.V12} {
|
|
t.Run(string(v), func(t *testing.T) {
|
|
d := newDAG(t, v)
|
|
d.build([]spec{
|
|
{name: "PA", sender: alice, typ: event.TypePowerLevels, stateKey: ptr(""),
|
|
content: map[string]any{"users": map[string]any{bob: 50}}},
|
|
{name: "T1", sender: bob, typ: "m.room.topic", stateKey: ptr(""),
|
|
content: map[string]any{"topic": "one"}},
|
|
{name: "T2", sender: alice, typ: "m.room.topic", stateKey: ptr(""),
|
|
content: map[string]any{"topic": "two"}},
|
|
{name: "MC", sender: alice, typ: event.TypeMember, stateKey: ptr(charlie),
|
|
content: map[string]any{"membership": "ban"}},
|
|
{name: "END", sender: zara, typ: "m.room.message", content: map[string]any{}},
|
|
}, [][]string{
|
|
{"END", "T1", "PA", "START"},
|
|
{"END", "MC", "T2", "START"},
|
|
})
|
|
|
|
sets := func() [][]*event.Event {
|
|
var out [][]*event.Event
|
|
for _, name := range []string{"T1", "MC"} {
|
|
var set []*event.Event
|
|
for _, e := range d.after[name] {
|
|
set = append(set, e)
|
|
}
|
|
out = append(out, set)
|
|
}
|
|
return out
|
|
}
|
|
|
|
forwards := sets()
|
|
backwards := [][]*event.Event{forwards[1], forwards[0]}
|
|
|
|
a, err := Resolve(context.Background(), d.rules, forwards, d)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
b, err := Resolve(context.Background(), d.rules, backwards, d)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got, want := describe(d, a), describe(d, b); got != want {
|
|
t.Errorf("the order of the state sets changed the result:\n %s\n %s", got, want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestResolutionIsIdempotent: resolving a state against itself is the
|
|
// identity, so repeating the work cannot drift.
|
|
func TestResolutionIsIdempotent(t *testing.T) {
|
|
d := newDAG(t, roomversion.V11)
|
|
d.build([]spec{
|
|
{name: "PA", sender: alice, typ: event.TypePowerLevels, stateKey: ptr(""),
|
|
content: map[string]any{"users": map[string]any{alice: 100, bob: 50}}},
|
|
{name: "MB", sender: alice, typ: event.TypeMember, stateKey: ptr(bob),
|
|
content: map[string]any{"membership": "ban"}},
|
|
{name: "T1", sender: alice, typ: "m.room.topic", stateKey: ptr(""), content: map[string]any{}},
|
|
{name: "END", sender: zara, typ: "m.room.message", content: map[string]any{}},
|
|
}, [][]string{
|
|
{"END", "MB", "PA", "START"},
|
|
{"END", "T1", "PA"},
|
|
})
|
|
|
|
once := d.resolveAt("END")
|
|
var set []*event.Event
|
|
for _, e := range once {
|
|
set = append(set, e)
|
|
}
|
|
twice, err := Resolve(context.Background(), d.rules, [][]*event.Event{set, set}, d)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got, want := describe(d, twice), describe(d, once); got != want {
|
|
t.Errorf("resolving the result again changed it:\n %s\n %s", got, want)
|
|
}
|
|
}
|
|
|
|
// TestUnconflictedStateAlwaysWins: the last step of the algorithm puts the
|
|
// unconflicted state back, so an event nobody disputed cannot be displaced
|
|
// by the replay.
|
|
func TestUnconflictedStateAlwaysWins(t *testing.T) {
|
|
d := newDAG(t, roomversion.V11)
|
|
d.build([]spec{
|
|
{name: "T1", sender: alice, typ: "m.room.topic", stateKey: ptr(""),
|
|
content: map[string]any{"topic": "one"}},
|
|
{name: "T2", sender: alice, typ: "m.room.topic", stateKey: ptr(""),
|
|
content: map[string]any{"topic": "two"}},
|
|
{name: "END", sender: zara, typ: "m.room.message", content: map[string]any{}},
|
|
}, [][]string{
|
|
{"END", "T1", "START"},
|
|
{"END", "T2", "START"},
|
|
})
|
|
|
|
resolved := d.resolveAt("END")
|
|
// The join rules, the create event and everybody's membership were
|
|
// never in dispute and must come through untouched.
|
|
for _, name := range []string{"CREATE", "IJR", "IMA", "IMB", "IMC", "IMZ"} {
|
|
e := d.byName[name]
|
|
sk, _ := e.StateKey()
|
|
if got := resolved[auth.StateKey{Type: e.Type(), StateKey: sk}]; got != e {
|
|
t.Errorf("%s was displaced, though nothing disputed it", name)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestVersion12ResolvesARoom checks the version 12 path end to end: the
|
|
// create event is found through the room ID rather than through
|
|
// auth_events, and the amended algorithm still settles a conflict.
|
|
func TestVersion12ResolvesARoom(t *testing.T) {
|
|
d := newDAG(t, roomversion.V12)
|
|
d.build([]spec{
|
|
{name: "PA", sender: alice, typ: event.TypePowerLevels, stateKey: ptr(""),
|
|
content: map[string]any{"users": map[string]any{bob: 50}}},
|
|
{name: "MB", sender: alice, typ: event.TypeMember, stateKey: ptr(bob),
|
|
content: map[string]any{"membership": "ban"}},
|
|
{name: "PB", sender: bob, typ: event.TypePowerLevels, stateKey: ptr(""),
|
|
content: map[string]any{"users": map[string]any{bob: 50, charlie: 50}}},
|
|
{name: "END", sender: zara, typ: "m.room.message", content: map[string]any{}},
|
|
}, [][]string{
|
|
{"END", "MB", "PA", "START"},
|
|
{"END", "PB", "PA"},
|
|
})
|
|
|
|
resolved := d.resolveAt("END")
|
|
d.wantState(resolved, map[auth.StateKey]string{
|
|
powerKey: "PA",
|
|
memberKey(bob): "MB",
|
|
})
|
|
if resolved[auth.StateKey{Type: event.TypeCreate}] == nil {
|
|
t.Error("the create event is missing from the resolved state")
|
|
}
|
|
}
|
|
|
|
// TestMissingEventsAreSkipped: a reference the provider cannot resolve,
|
|
// which over federation means an event we never received or one we
|
|
// rejected, must not stop resolution.
|
|
func TestMissingEventsAreSkipped(t *testing.T) {
|
|
d := newDAG(t, roomversion.V11)
|
|
d.build([]spec{
|
|
{name: "T1", sender: alice, typ: "m.room.topic", stateKey: ptr(""),
|
|
content: map[string]any{"topic": "one"}},
|
|
{name: "T2", sender: alice, typ: "m.room.topic", stateKey: ptr(""),
|
|
content: map[string]any{"topic": "two"}},
|
|
{name: "END", sender: zara, typ: "m.room.message", content: map[string]any{}},
|
|
}, [][]string{
|
|
{"END", "T1", "START"},
|
|
{"END", "T2", "START"},
|
|
})
|
|
|
|
var sets [][]*event.Event
|
|
for _, name := range []string{"T1", "T2"} {
|
|
var set []*event.Event
|
|
for _, e := range d.after[name] {
|
|
set = append(set, e)
|
|
}
|
|
sets = append(sets, set)
|
|
}
|
|
|
|
resolved, err := Resolve(context.Background(), d.rules, sets, emptyProvider{})
|
|
if err != nil {
|
|
t.Fatalf("Resolve with a provider that knows nothing: %v", err)
|
|
}
|
|
if len(resolved) == 0 {
|
|
t.Error("resolution produced no state at all")
|
|
}
|
|
// The topic was in conflict and its auth events cannot be fetched, so
|
|
// neither candidate can be authorised; what matters is that the
|
|
// unconflicted state still comes through.
|
|
if resolved[joinRulesKey] == nil {
|
|
t.Error("the unconflicted join rules were lost")
|
|
}
|
|
}
|
|
|
|
type emptyProvider struct{}
|
|
|
|
func (emptyProvider) EventsByID(context.Context, []string) ([]*event.Event, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func TestProviderErrorsAreReported(t *testing.T) {
|
|
d := newDAG(t, roomversion.V11)
|
|
// PA replaces IPOWER, so IPOWER is in the auth chain of the state but
|
|
// not in the state itself — which is exactly when resolution has to go
|
|
// to the provider.
|
|
d.build([]spec{
|
|
{name: "PA", sender: alice, typ: event.TypePowerLevels, stateKey: ptr(""),
|
|
content: map[string]any{"users": map[string]any{alice: 100, bob: 50}}},
|
|
{name: "T1", sender: alice, typ: "m.room.topic", stateKey: ptr(""),
|
|
content: map[string]any{"topic": "one"}},
|
|
{name: "T2", sender: bob, typ: "m.room.topic", stateKey: ptr(""),
|
|
content: map[string]any{"topic": "two"}},
|
|
{name: "END", sender: zara, typ: "m.room.message", content: map[string]any{}},
|
|
}, [][]string{
|
|
{"END", "T1", "PA", "START"},
|
|
{"END", "T2", "PA"},
|
|
})
|
|
|
|
var sets [][]*event.Event
|
|
for _, name := range []string{"T1", "T2"} {
|
|
var set []*event.Event
|
|
for _, e := range d.after[name] {
|
|
set = append(set, e)
|
|
}
|
|
sets = append(sets, set)
|
|
}
|
|
|
|
_, err := Resolve(context.Background(), d.rules, sets, failingProvider{})
|
|
if err == nil {
|
|
t.Fatal("Resolve succeeded with a provider that always fails")
|
|
}
|
|
if !strings.Contains(err.Error(), "state resolution") {
|
|
t.Errorf("error = %q, want it to say where it came from", err)
|
|
}
|
|
}
|
|
|
|
type failingProvider struct{}
|
|
|
|
func (failingProvider) EventsByID(context.Context, []string) ([]*event.Event, error) {
|
|
return nil, fmt.Errorf("the database is on fire")
|
|
}
|
|
|
|
// --- Units ---
|
|
|
|
func TestIsPowerEvent(t *testing.T) {
|
|
d := newDAG(t, roomversion.V11)
|
|
d.build([]spec{
|
|
{name: "PL", sender: alice, typ: event.TypePowerLevels, stateKey: ptr(""),
|
|
content: map[string]any{"users": map[string]any{alice: 100}}},
|
|
{name: "JR", sender: alice, typ: event.TypeJoinRules, stateKey: ptr(""),
|
|
content: map[string]any{"join_rule": auth.JoinRulePublic}},
|
|
{name: "BAN", sender: alice, typ: event.TypeMember, stateKey: ptr(bob),
|
|
content: map[string]any{"membership": "ban"}},
|
|
{name: "KICK", sender: alice, typ: event.TypeMember, stateKey: ptr(charlie),
|
|
content: map[string]any{"membership": "leave"}},
|
|
{name: "SELF_LEAVE", sender: charlie, typ: event.TypeMember, stateKey: ptr(charlie),
|
|
content: map[string]any{"membership": "leave"}},
|
|
{name: "JOIN", sender: evelyn, typ: event.TypeMember, stateKey: ptr(evelyn),
|
|
content: map[string]any{"membership": "join"}},
|
|
{name: "INVITE", sender: alice, typ: event.TypeMember, stateKey: ptr(evelyn),
|
|
content: map[string]any{"membership": "invite"}},
|
|
{name: "MESSAGE", sender: alice, typ: "m.room.message", content: map[string]any{}},
|
|
{name: "TOPIC", sender: alice, typ: "m.room.topic", stateKey: ptr(""), content: map[string]any{}},
|
|
}, [][]string{
|
|
{"TOPIC", "MESSAGE", "INVITE", "JOIN", "SELF_LEAVE", "KICK", "BAN", "JR", "PL", "START"},
|
|
})
|
|
|
|
want := map[string]bool{
|
|
"PL": true, "JR": true, "BAN": true, "KICK": true,
|
|
"SELF_LEAVE": false, "JOIN": false, "INVITE": false,
|
|
"MESSAGE": false, "TOPIC": false,
|
|
}
|
|
for name, expected := range want {
|
|
if got := isPowerEvent(d.byName[name]); got != expected {
|
|
t.Errorf("isPowerEvent(%s) = %v, want %v", name, got, expected)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSorted(t *testing.T) {
|
|
d := newDAG(t, roomversion.V11)
|
|
sorted := Sorted(d.after["START"])
|
|
if len(sorted) == 0 {
|
|
t.Fatal("Sorted returned nothing")
|
|
}
|
|
for i := 1; i < len(sorted); i++ {
|
|
prev, next := sorted[i-1], sorted[i]
|
|
prevKey, _ := prev.StateKey()
|
|
nextKey, _ := next.StateKey()
|
|
if prev.Type() > next.Type() || (prev.Type() == next.Type() && prevKey > nextKey) {
|
|
t.Errorf("Sorted is out of order at %d: (%s, %q) before (%s, %q)",
|
|
i, prev.Type(), prevKey, next.Type(), nextKey)
|
|
}
|
|
}
|
|
}
|
|
|
|
// describe renders a resolved state as a stable string for comparison.
|
|
func describe(d *dag, state auth.State) string {
|
|
var parts []string
|
|
for _, e := range Sorted(state) {
|
|
sk, _ := e.StateKey()
|
|
parts = append(parts, fmt.Sprintf("(%s,%s)=%s", e.Type(), sk, d.nameOf(e.EventID())))
|
|
}
|
|
return strings.Join(parts, " ")
|
|
}
|