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