Phase 2: the event core
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>
This commit is contained in:
tajniak81andClaude Opus 5.5 committed 2026-10-15 22:23:35 +02:00
1 parent 6004bb01de
commit 2fbd5f53ce
30 files changed
+8928 -9

No files matched your search

+245
View File
@@ -0,0 +1,245 @@
package stateres
import (
"container/heap"
"context"
"math"
"slices"
"strings"
"github.com/dariuszjaniak/warp/server/internal/matrix/event"
)
// reverseTopologicalPowerSort orders events earliest to latest, so that
// nothing comes before an event that authorises it, and where there is a
// choice the sender with the most power goes first.
//
// It is Kahn's algorithm with a priority queue: at every step, of the
// events whose auth events have all been placed, the one that sorts
// smallest is taken. Tie-breaking on timestamp and then on event ID makes
// the result identical on every server, which is the whole requirement —
// two servers that ordered these differently would resolve the state
// differently and the room would fork.
func (r *resolver) reverseTopologicalPowerSort(ctx context.Context, events map[string]*event.Event) ([]*event.Event, error) {
// Edges run from an auth event to the event it authorises, counted
// only within the set being sorted: a reference out of the set has
// already happened as far as this ordering goes.
authorises := make(map[string][]string, len(events))
remaining := make(map[string]int, len(events))
for id, e := range events {
for _, authID := range e.AuthEventIDs() {
if _, inSet := events[authID]; !inSet {
continue
}
authorises[authID] = append(authorises[authID], id)
remaining[id]++
}
}
keys := make(map[string]sortKey, len(events))
for id, e := range events {
level, err := r.senderPowerLevel(ctx, e)
if err != nil {
return nil, err
}
keys[id] = sortKey{rank: level, ts: e.OriginServerTS(), id: id}
}
ready := &sortHeap{keys: keys}
for id := range events {
if remaining[id] == 0 {
ready.ids = append(ready.ids, id)
}
}
heap.Init(ready)
out := make([]*event.Event, 0, len(events))
for ready.Len() > 0 {
id := heap.Pop(ready).(string)
out = append(out, events[id])
for _, next := range authorises[id] {
remaining[next]--
if remaining[next] == 0 {
heap.Push(ready, next)
}
}
}
// A cycle in the auth graph would leave events unplaced. It cannot
// happen in a well-formed room, where an event's auth events always
// precede it, but a malicious server can send anything and quietly
// dropping state events would be worse than an arbitrary but stable
// order.
if len(out) < len(events) {
leftover := make([]*event.Event, 0, len(events)-len(out))
for id, e := range events {
if remaining[id] > 0 {
leftover = append(leftover, e)
}
}
sortByKey(leftover, keys)
out = append(out, leftover...)
}
return out, nil
}
// mainlineSort orders events by where they hang off the room's succession
// of power levels events.
//
// The mainline is that succession: the given power levels event, the one
// in its auth events, and so on back to the first one in the room. An
// event's position is how far back along it the event depends, and an
// event depending on an older link sorts first. The effect is that a
// change made under an older set of power levels is applied before one
// made under a newer set, so the newer set has the last word.
func (r *resolver) mainlineSort(ctx context.Context, events map[string]*event.Event, powerLevels *event.Event) ([]*event.Event, error) {
// index maps each power levels event in the mainline to its place,
// 0 being the most recent.
index := map[string]int{}
for current := powerLevels; current != nil; {
if _, loop := index[current.EventID()]; loop {
break // a malformed room; stop rather than walk for ever
}
index[current.EventID()] = len(index)
next, err := r.powerLevelsInAuthEvents(ctx, current)
if err != nil {
return nil, err
}
current = next
}
keys := make(map[string]sortKey, len(events))
positions := map[string]int{}
for id, e := range events {
position, err := r.mainlinePosition(ctx, e, index, positions)
if err != nil {
return nil, err
}
keys[id] = sortKey{rank: int64(position), ts: e.OriginServerTS(), id: id}
}
out := make([]*event.Event, 0, len(events))
for _, e := range events {
out = append(out, e)
}
sortByKey(out, keys)
return out, nil
}
// noMainlinePosition is the spec's "infinity": an event with no power
// levels event behind it at all. It sorts before everything else, because
// it depends on a point in the room older than any power change.
const noMainlinePosition = math.MaxInt
// mainlinePosition returns how far back in the mainline an event depends.
//
// The event itself is not consulted, only the power levels events it
// reaches through its auth events — so a power levels event's own position
// is its predecessor's. That reading never comes up in practice: every
// power levels event is a power event, and power events are all settled in
// the first pass, so none of them reaches this sort.
//
// Each event cites at most one power levels event, so the walk follows a
// single path and every event along it shares the answer.
func (r *resolver) mainlinePosition(ctx context.Context, e *event.Event, index map[string]int, memo map[string]int) (int, error) {
var walked []string
current := e
for {
id := current.EventID()
if position, ok := memo[id]; ok {
return record(walked, position, memo), nil
}
next, err := r.powerLevelsInAuthEvents(ctx, current)
if err != nil {
return 0, err
}
if next == nil {
return record(append(walked, id), noMainlinePosition, memo), nil
}
if position, ok := index[next.EventID()]; ok {
return record(append(walked, id), position, memo), nil
}
walked = append(walked, id)
current = next
}
}
// record caches one position for every event on the path that led to it.
func record(walked []string, position int, memo map[string]int) int {
for _, id := range walked {
memo[id] = position
}
return position
}
// powerLevelsInAuthEvents returns the m.room.power_levels event an event
// cites, if it cites one.
func (r *resolver) powerLevelsInAuthEvents(ctx context.Context, e *event.Event) (*event.Event, error) {
for _, id := range e.AuthEventIDs() {
cited, err := r.get(ctx, id)
if err != nil {
return nil, err
}
if cited == nil {
continue
}
if sk, isState := cited.StateKey(); isState && sk == "" && cited.Type() == event.TypePowerLevels {
return cited, nil
}
}
return nil, nil
}
// sortKey is what both orderings compare.
//
// rank holds the sender's power level for the reverse topological power
// ordering and the mainline position for the mainline ordering. The two
// orderings are otherwise the same comparison, and in both of them a
// higher rank sorts first: more power goes first, and so does a position
// further back in the mainline.
type sortKey struct {
rank int64
ts int64
id string
}
func compareKeys(a, b sortKey) int {
switch {
case a.rank > b.rank:
return -1
case a.rank < b.rank:
return 1
case a.ts < b.ts:
return -1
case a.ts > b.ts:
return 1
default:
// Event IDs are unique, so the order is total: there is never a
// tie left for a server to break its own way.
return strings.Compare(a.id, b.id)
}
}
func sortByKey(events []*event.Event, keys map[string]sortKey) {
slices.SortFunc(events, func(a, b *event.Event) int {
return compareKeys(keys[a.EventID()], keys[b.EventID()])
})
}
// sortHeap is the priority queue Kahn's algorithm picks from.
type sortHeap struct {
ids []string
keys map[string]sortKey
}
func (h *sortHeap) Len() int { return len(h.ids) }
func (h *sortHeap) Less(i, j int) bool { return compareKeys(h.keys[h.ids[i]], h.keys[h.ids[j]]) < 0 }
func (h *sortHeap) Swap(i, j int) { h.ids[i], h.ids[j] = h.ids[j], h.ids[i] }
func (h *sortHeap) Push(x any) { h.ids = append(h.ids, x.(string)) }
func (h *sortHeap) Pop() any {
last := len(h.ids) - 1
out := h.ids[last]
h.ids = h.ids[:last]
return out
}
+609
View File
@@ -0,0 +1,609 @@
// Package stateres implements Matrix state resolution, version 2.
//
// Every event in a room names the events it follows, so a room is a graph
// rather than a line, and two servers can quite legitimately extend it at
// the same time. State resolution is the function that takes the differing
// views of the room's state at those points and produces one answer that
// every server computes identically. It is the reason two people who were
// both made moderator in the same second do not end up with the room in
// two different shapes.
//
// The algorithm is in the room version 2 specification, and room version
// 12 amends it. In outline:
//
// 1. State that every view agrees on is set aside as unconflicted.
// 2. The events in dispute, plus the events that authorise them, are
// sorted so that whoever had the most power goes first.
// 3. They are replayed through the authorisation rules; anything the
// rules refuse is dropped.
// 4. What is left is sorted again, this time by how far back in the
// room's history of power changes it hangs, and replayed.
// 5. The unconflicted state is put back over the top.
//
// Sorting by power first is what makes the result resistant to a
// misbehaving server: an event that grants power can only win if the event
// that granted *it* power already won.
//
// The algorithm is deliberately total — it always produces a state — so
// nothing here rejects an event. Events the rules refuse are simply not
// applied.
package stateres
import (
"context"
"fmt"
"slices"
"strings"
"github.com/dariuszjaniak/warp/server/internal/matrix/auth"
"github.com/dariuszjaniak/warp/server/internal/matrix/event"
"github.com/dariuszjaniak/warp/server/internal/matrix/roomversion"
)
// EventProvider supplies the events state resolution needs beyond the
// state sets themselves: the auth chains behind them, and the chain of
// power levels events used for the second sort.
//
// Events this server does not have, and events it rejected, must be left
// out of the result rather than reported as an error. That is not
// leniency: the spec requires rejected auth events to take no part in
// resolution, and leaving them out here is how a caller says so.
type EventProvider interface {
EventsByID(ctx context.Context, ids []string) ([]*event.Event, error)
}
// Resolve resolves a set of room states into one.
//
// stateSets are the differing views of the room's state — in the usual
// case, the state after each of an event's prev_events. The result is the
// state they resolve to.
//
// Resolving fewer than two states is not an error and does no work: there
// is nothing to disagree about.
func Resolve(ctx context.Context, rules roomversion.Rules, stateSets [][]*event.Event, provider EventProvider) (auth.State, error) {
switch len(stateSets) {
case 0:
return auth.State{}, nil
case 1:
return auth.NewState(stateSets[0]), nil
}
r := &resolver{
rules: rules,
provider: provider,
events: map[string]*event.Event{},
missing: map[string]bool{},
powerOf: map[string]int64{},
}
maps := make([]auth.State, len(stateSets))
for i, set := range stateSets {
maps[i] = auth.NewState(set)
for _, e := range set {
r.events[e.EventID()] = e
}
}
return r.resolve(ctx, maps)
}
// Sorted returns a state map as a slice in a deterministic order, for
// storing or comparing.
func Sorted(state auth.State) []*event.Event {
keys := make([]auth.StateKey, 0, len(state))
for k := range state {
keys = append(keys, k)
}
slices.SortFunc(keys, func(a, b auth.StateKey) int {
if a.Type != b.Type {
return strings.Compare(a.Type, b.Type)
}
return strings.Compare(a.StateKey, b.StateKey)
})
out := make([]*event.Event, 0, len(keys))
for _, k := range keys {
out = append(out, state[k])
}
return out
}
// resolver holds the caches one resolution needs. It is single-use.
type resolver struct {
rules roomversion.Rules
provider EventProvider
// events is every event fetched so far, and missing records the IDs
// that resolved to nothing, so that a dangling reference is looked up
// once rather than on every walk that reaches it.
events map[string]*event.Event
missing map[string]bool
// powerOf caches the sender's power level per event, which the first
// sort asks for repeatedly.
powerOf map[string]int64
}
func (r *resolver) resolve(ctx context.Context, stateSets []auth.State) (auth.State, error) {
unconflicted, conflicted := separate(stateSets)
// The full conflicted set is wider than the events actually in
// dispute: it also takes in the auth difference, the events that only
// *some* of the views rely on. Without them, a view could smuggle in a
// power grant by keeping it out of the state it declares.
full := map[string]*event.Event{}
for id, e := range conflicted {
full[id] = e
}
authDiff, err := r.authDifference(ctx, stateSets)
if err != nil {
return nil, err
}
for id, e := range authDiff {
full[id] = e
}
// Room version 12 adds the events that lie between conflicted events
// in the auth graph, which the auth difference can miss. Both this and
// the empty starting state below exist to make state resets rarer.
if r.rules.StateRes == roomversion.StateResV2Subgraph {
subgraph, err := r.conflictedSubgraph(ctx, conflicted)
if err != nil {
return nil, err
}
for id, e := range subgraph {
full[id] = e
}
}
// Step 1: the power events in the full conflicted set, together with
// whatever in their auth chains is also in dispute.
power, err := r.powerEventsToSort(ctx, full)
if err != nil {
return nil, err
}
sortedPower, err := r.reverseTopologicalPowerSort(ctx, power)
if err != nil {
return nil, err
}
// Step 2. Room version 12 starts from an empty state rather than the
// unconflicted one: an event then has to be authorised by the events
// it actually cites, which stops an unconflicted event from propping
// up a conflicted one that nothing else supports.
start := unconflicted
if r.rules.StateRes == roomversion.StateResV2Subgraph {
start = auth.State{}
}
partial, err := r.iterativeAuthChecks(ctx, start, sortedPower)
if err != nil {
return nil, err
}
// Step 3: everything else, ordered by where it hangs off the room's
// history of power levels events.
rest := make(map[string]*event.Event, len(full))
for id, e := range full {
if _, done := power[id]; !done {
rest[id] = e
}
}
sortedRest, err := r.mainlineSort(ctx, rest, partial.PowerLevels())
if err != nil {
return nil, err
}
// Step 4.
resolved, err := r.iterativeAuthChecks(ctx, partial, sortedRest)
if err != nil {
return nil, err
}
// Step 5: the unconflicted state was never in doubt, so it wins
// outright — including over anything the replay above put in its place.
for k, e := range unconflicted {
resolved[k] = e
}
return resolved, nil
}
// separate splits the state sets into the state they all agree on and the
// state they do not.
//
// A key missing from one view counts as a disagreement, not as a blank:
// one server thinking there is no topic and another thinking there is one
// is exactly the kind of conflict this algorithm exists to settle.
func separate(stateSets []auth.State) (auth.State, map[string]*event.Event) {
unconflicted := auth.State{}
conflicted := map[string]*event.Event{}
keys := map[auth.StateKey]bool{}
for _, set := range stateSets {
for k := range set {
keys[k] = true
}
}
for k := range keys {
var distinct []*event.Event
seen := map[string]bool{}
absent := false
for _, set := range stateSets {
e, ok := set[k]
if !ok {
absent = true
continue
}
if !seen[e.EventID()] {
seen[e.EventID()] = true
distinct = append(distinct, e)
}
}
if len(distinct) == 1 && !absent {
unconflicted[k] = distinct[0]
continue
}
for _, e := range distinct {
conflicted[e.EventID()] = e
}
}
return unconflicted, conflicted
}
// authDifference returns the events that are in some views' auth chains but
// not in all of them.
func (r *resolver) authDifference(ctx context.Context, stateSets []auth.State) (map[string]*event.Event, error) {
chains := make([]map[string]bool, len(stateSets))
for i, set := range stateSets {
ids := make([]string, 0, len(set))
for _, e := range set {
ids = append(ids, e.EventID())
}
chain, err := r.authChain(ctx, ids)
if err != nil {
return nil, err
}
chains[i] = chain
}
diff := map[string]*event.Event{}
for i, chain := range chains {
for id := range chain {
inAll := true
for j, other := range chains {
if i != j && !other[id] {
inAll = false
break
}
}
if inAll {
continue
}
if e := r.events[id]; e != nil {
diff[id] = e
}
}
}
return diff, nil
}
// authChain returns the transitive closure of the auth_events links from
// the given events, not including the events themselves.
func (r *resolver) authChain(ctx context.Context, ids []string) (map[string]bool, error) {
chain := map[string]bool{}
queue := make([]string, 0, len(ids))
for _, id := range ids {
e, err := r.get(ctx, id)
if err != nil {
return nil, err
}
if e != nil {
queue = append(queue, e.AuthEventIDs()...)
}
}
for len(queue) > 0 {
id := queue[len(queue)-1]
queue = queue[:len(queue)-1]
if chain[id] {
continue
}
e, err := r.get(ctx, id)
if err != nil {
return nil, err
}
if e == nil {
// A reference we cannot follow. It is not an error: the event
// may have been rejected, or we may simply never have seen it.
continue
}
chain[id] = true
queue = append(queue, e.AuthEventIDs()...)
}
return chain, nil
}
// conflictedSubgraph returns the events that lie on an auth_events path
// between two conflicted events, which room version 12 adds to the full
// conflicted set.
//
// It is computed in two passes: walk forwards from the conflicted events
// to find everything they reach, recording the edges in reverse, then walk
// those edges backwards from the conflicted events. A node the second walk
// reaches is one that both descends from a conflicted event and leads to
// one, which is what being on a path between two of them means.
func (r *resolver) conflictedSubgraph(ctx context.Context, conflicted map[string]*event.Event) (map[string]*event.Event, error) {
parents := map[string][]string{}
seen := map[string]bool{}
queue := make([]string, 0, len(conflicted))
for id := range conflicted {
queue = append(queue, id)
seen[id] = true
}
for len(queue) > 0 {
id := queue[len(queue)-1]
queue = queue[:len(queue)-1]
e, err := r.get(ctx, id)
if err != nil {
return nil, err
}
if e == nil {
continue
}
for _, child := range e.AuthEventIDs() {
parents[child] = append(parents[child], id)
if !seen[child] {
seen[child] = true
queue = append(queue, child)
}
}
}
subgraph := map[string]*event.Event{}
visited := map[string]bool{}
queue = queue[:0]
for id := range conflicted {
queue = append(queue, id)
visited[id] = true
}
for len(queue) > 0 {
id := queue[len(queue)-1]
queue = queue[:len(queue)-1]
if e := r.events[id]; e != nil {
subgraph[id] = e
}
for _, parent := range parents[id] {
if !visited[parent] {
visited[parent] = true
queue = append(queue, parent)
}
}
}
return subgraph, nil
}
// powerEventsToSort returns the events the first pass sorts and replays:
// the power events in the full conflicted set, plus anything in their auth
// chains that is also in the full conflicted set.
func (r *resolver) powerEventsToSort(ctx context.Context, full map[string]*event.Event) (map[string]*event.Event, error) {
out := map[string]*event.Event{}
for id, e := range full {
if !isPowerEvent(e) {
continue
}
out[id] = e
chain, err := r.authChain(ctx, []string{id})
if err != nil {
return nil, err
}
for chainID := range chain {
if e, ok := full[chainID]; ok {
out[chainID] = e
}
}
}
return out, nil
}
// isPowerEvent reports whether an event could take away somebody's ability
// to do something in the room, which is what makes it worth sorting first.
func isPowerEvent(e *event.Event) bool {
switch e.Type() {
case event.TypePowerLevels, event.TypeJoinRules:
_, isState := e.StateKey()
return isState
case event.TypeMember:
sk, ok := e.StateKey()
if !ok || sk == e.Sender() {
// Leaving or being banned under your own name takes nothing
// from anybody else.
return false
}
membership, err := e.Membership()
if err != nil {
return false
}
return membership == event.MembershipLeave || membership == event.MembershipBan
default:
return false
}
}
// iterativeAuthChecks replays events through the authorisation rules,
// applying the ones the rules allow and ignoring the rest.
//
// The state each event is judged against is its own auth_events, overlaid
// with whatever the replay has settled so far for the keys the rules will
// read. So an event is authorised by the room as it is being rebuilt,
// falling back to the events it cited for anything not yet decided.
func (r *resolver) iterativeAuthChecks(ctx context.Context, base auth.State, events []*event.Event) (auth.State, error) {
state := make(auth.State, len(base))
for k, e := range base {
state[k] = e
}
for _, e := range events {
checkState := auth.State{}
for _, id := range e.AuthEventIDs() {
cited, err := r.get(ctx, id)
if err != nil {
return nil, err
}
if cited == nil {
// Either we do not have it or we rejected it. A rejected
// auth event must not be used, and this is where that
// happens.
continue
}
if sk, ok := cited.StateKey(); ok {
checkState[auth.StateKey{Type: cited.Type(), StateKey: sk}] = cited
}
}
keys, err := auth.AuthEventKeys(e)
if err != nil {
// A malformed event cannot be authorised, which in this
// algorithm means it is skipped rather than fatal.
continue
}
for _, k := range keys {
if current, ok := state[k]; ok {
checkState[k] = current
}
}
// From room version 12 the create event is in neither the event's
// auth_events nor the keys above, but the rules still need it: it
// says who the creators are, and the room ID is checked against
// it. Every other version has it from auth_events already.
if checkState.Create() == nil {
create, err := r.createEvent(ctx, e)
if err != nil {
return nil, err
}
if create != nil {
checkState[auth.StateKey{Type: event.TypeCreate}] = create
}
}
if err := auth.Check(ctx, e, checkState, intakeVerified{}); err != nil {
continue
}
if sk, ok := e.StateKey(); ok {
state[auth.StateKey{Type: e.Type(), StateKey: sk}] = e
}
}
return state, nil
}
// intakeVerified stands in for the signature check the restricted join
// rule asks for.
//
// Every event that reaches state resolution was accepted into the room
// first, and that is where its signatures — including the one from the
// server authorising a restricted join — were checked. Checking it again
// here would mean fetching another server's keys in the middle of an
// algorithm that has to be deterministic, run identically on every server
// and return without doing any I/O it can avoid. Synapse draws the line in
// the same place.
//
// The consequence is that intake must not be skipped: an event inserted
// into storage without a signature check would be authorised here as
// though it had one.
type intakeVerified struct{}
func (intakeVerified) VerifyEventSignature(context.Context, string, *event.Event) error { return nil }
// createEvent returns the room's m.room.create event, as seen from one
// event in it.
//
// Up to room version 11 every event cites it in auth_events. From version
// 12 it is deliberately left out, because the room ID is its event ID with
// a different sigil — so it is looked up from there instead, which is the
// same guarantee by a shorter route.
func (r *resolver) createEvent(ctx context.Context, e *event.Event) (*event.Event, error) {
for _, id := range e.AuthEventIDs() {
cited, err := r.get(ctx, id)
if err != nil {
return nil, err
}
if cited == nil {
continue
}
if sk, isState := cited.StateKey(); isState && sk == "" && cited.Type() == event.TypeCreate {
return cited, nil
}
}
if !r.rules.RoomIDIsCreateEventID {
return nil, nil
}
roomID := e.RoomID()
if len(roomID) < 2 || roomID[0] != '!' {
return nil, nil
}
return r.get(ctx, "$"+roomID[1:])
}
// get returns an event by ID, from the cache or from the provider.
func (r *resolver) get(ctx context.Context, id string) (*event.Event, error) {
if e, ok := r.events[id]; ok {
return e, nil
}
if r.missing[id] {
return nil, nil
}
fetched, err := r.provider.EventsByID(ctx, []string{id})
if err != nil {
return nil, fmt.Errorf("state resolution: fetch %s: %w", id, err)
}
for _, e := range fetched {
r.events[e.EventID()] = e
}
if e, ok := r.events[id]; ok {
return e, nil
}
r.missing[id] = true
return nil, nil
}
// senderPowerLevel returns the power level of an event's sender, judged
// from the event's own auth_events rather than from the room as it is now.
//
// Judging it from the event's own view is the point: it is what the sender
// could do at the time, which is the only thing every server can agree on
// after the fact.
func (r *resolver) senderPowerLevel(ctx context.Context, e *event.Event) (int64, error) {
if level, ok := r.powerOf[e.EventID()]; ok {
return level, nil
}
var plEvent *event.Event
for _, id := range e.AuthEventIDs() {
cited, err := r.get(ctx, id)
if err != nil {
return 0, err
}
if cited == nil {
continue
}
if sk, isState := cited.StateKey(); isState && sk == "" && cited.Type() == event.TypePowerLevels {
plEvent = cited
break
}
}
createEvent, err := r.createEvent(ctx, e)
if err != nil {
return 0, err
}
level := int64(0)
if createEvent != nil || plEvent != nil {
pl, err := auth.ResolvePowerLevels(plEvent, createEvent, r.rules)
if err != nil {
// A power levels event we cannot read gives its sender
// nothing, which keeps the sort total.
level = 0
} else {
level = pl.UserLevel(e.Sender())
}
}
r.powerOf[e.EventID()] = level
return level, nil
}
@@ -0,0 +1,746 @@
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, " ")
}