Files
Warp/server/internal/matrix/stateres/stateres_test.go
T
tajniak81andClaude Opus 5.5 2fbd5f53ce
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
Phase 2: the event core
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>
2026-10-15 22:23:35 +02:00

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