package roomserver_test import ( "context" "encoding/json" "errors" "log/slog" "path/filepath" "strconv" "sync" "testing" "time" "github.com/dariuszjaniak/warp/server/internal/matrix/auth" "github.com/dariuszjaniak/warp/server/internal/matrix/event" "github.com/dariuszjaniak/warp/server/internal/matrix/roomversion" "github.com/dariuszjaniak/warp/server/internal/matrix/signing" "github.com/dariuszjaniak/warp/server/internal/roomserver" "github.com/dariuszjaniak/warp/server/internal/storage" _ "github.com/dariuszjaniak/warp/server/internal/storage/drivers" ) const testServerName = "warp.example" const ( alice = "@alice:" + testServerName bob = "@bob:" + testServerName carol = "@carol:" + testServerName ) // fixture is a roomserver on a throwaway SQLite database. // // It is built from the real event core and the real store rather than from // fakes, because what these tests are about is whether an event this server // signs is one the authorisation rules accept — and a fake of either would // only prove that the pipeline calls what the fake expects. type fixture struct { t *testing.T api *roomserver.API store storage.RoomStore db storage.Database // sends counts the messages the fill helper has sent, so that each // one gets its own transaction ID; see fill in stream_test.go. sends int } // profiles is a stand-in for the userapi, so that these tests do not need // an account table to put a display name on a membership event. type profiles map[string]storage.Account func (p profiles) Account(_ context.Context, localpart string) (storage.Account, error) { account, ok := p[localpart] if !ok { return storage.Account{}, storage.ErrNoSuchUser } return account, nil } func newFixture(t *testing.T, tweak func(*roomserver.Options)) *fixture { t.Helper() path := filepath.ToSlash(filepath.Join(t.TempDir(), "warp.db")) db, err := storage.Open(t.Context(), storage.Config{ Driver: storage.DriverSQLite, ConnectionString: "file:" + path + "?_pragma=busy_timeout(5000)", }, slog.New(slog.DiscardHandler)) if err != nil { t.Fatalf("open storage: %v", err) } t.Cleanup(func() { _ = db.Close() }) if err := db.Migrate(t.Context()); err != nil { t.Fatalf("migrate: %v", err) } key, err := signing.Generate("test") if err != nil { t.Fatalf("signing.Generate: %v", err) } opts := roomserver.Options{ ServerName: testServerName, Store: db.Rooms(), SigningKey: key, Profiles: profiles{ "alice": {Localpart: "alice", DisplayName: "Alice"}, "bob": {Localpart: "bob", DisplayName: "Bob"}, "carol": {Localpart: "carol", DisplayName: "Carol"}, }, Logger: slog.New(slog.DiscardHandler), } if tweak != nil { tweak(&opts) } api, err := roomserver.New(opts) if err != nil { t.Fatalf("roomserver.New: %v", err) } return &fixture{t: t, api: api, store: db.Rooms(), db: db} } // createRoom creates a room and fails the test if it cannot. func (f *fixture) createRoom(req roomserver.CreateRoomRequest) roomserver.CreateRoomResponse { f.t.Helper() if req.Creator == "" { req.Creator = alice } resp, err := f.api.CreateRoom(f.t.Context(), req) if err != nil { f.t.Fatalf("CreateRoom: %v", err) } return resp } // state returns a room's current state as the authorisation rules see it. func (f *fixture) state(roomID string) auth.State { f.t.Helper() room, err := f.store.Room(f.t.Context(), roomID) if err != nil { f.t.Fatalf("Room: %v", err) } rules, err := roomversion.Get(roomversion.Version(room.Version)) if err != nil { f.t.Fatalf("roomversion.Get: %v", err) } stored, err := f.store.CurrentStateEvents(f.t.Context(), roomID) if err != nil { f.t.Fatalf("CurrentStateEvents: %v", err) } events := make([]*event.Event, 0, len(stored)) for _, s := range stored { e, err := event.Parse(s.JSON, rules) if err != nil { f.t.Fatalf("parsing stored event %s: %v", s.EventID, err) } events = append(events, e) } return auth.NewState(events) } // membership is a user's membership in a room, or "". func (f *fixture) membership(roomID, userID string) string { f.t.Helper() got, err := f.api.Membership(f.t.Context(), roomID, userID) if err != nil { f.t.Fatalf("Membership: %v", err) } return got } // sendMessage sends an m.room.message, which is the ordinary case. func (f *fixture) sendMessage(roomID, sender, body, txnID string) (string, error) { return f.api.SendEvent(f.t.Context(), roomserver.SendEventRequest{ RoomID: roomID, Sender: sender, Type: "m.room.message", Content: json.RawMessage(`{"msgtype":"m.text","body":"` + body + `"}`), Transaction: &storage.TransactionRef{ Localpart: localpartOf(sender), DeviceID: "DEV", TxnID: txnID, }, }) } func localpartOf(userID string) string { for i := 1; i < len(userID); i++ { if userID[i] == ':' { return userID[1:i] } } return userID } // TestCreateRoomBuildsAWorkingRoom is the whole of Phase 3 in one test: if // the events createRoom sends are not authorised by the rules against each // other, nothing else in this package can work. func TestCreateRoomBuildsAWorkingRoom(t *testing.T) { f := newFixture(t, nil) resp := f.createRoom(roomserver.CreateRoomRequest{ Name: "Test Room", Topic: "A topic", }) if resp.RoomID == "" || resp.RoomID[0] != '!' { t.Fatalf("createRoom returned room ID %q", resp.RoomID) } st := f.state(resp.RoomID) for _, want := range []struct{ eventType, stateKey string }{ {event.TypeCreate, ""}, {event.TypeMember, alice}, {event.TypePowerLevels, ""}, {event.TypeJoinRules, ""}, {event.TypeHistoryVisibility, ""}, {roomserver.TypeGuestAccess, ""}, {roomserver.TypeName, ""}, {roomserver.TypeTopic, ""}, } { if st.Get(want.eventType, want.stateKey) == nil { t.Errorf("the new room has no %s/%q in its state", want.eventType, want.stateKey) } } if got := st.Membership(alice); got != event.MembershipJoin { t.Errorf("the creator's membership is %q, want join", got) } if got := st.JoinRule(); got != auth.JoinRuleInvite { t.Errorf("a private room's join rule is %q, want invite", got) } // The creator's membership event carries their profile, which is what // lets a client render old messages with the name of the day. member := st.Member(alice) if member == nil { t.Fatal("the creator has no membership event") } var content map[string]any if err := json.Unmarshal(member.Content(), &content); err != nil { t.Fatalf("the membership content is not an object: %v", err) } if content["displayname"] != "Alice" { t.Errorf("the membership event carries displayname %v, want Alice", content["displayname"]) } // The denormalised listing columns followed the state. summary, err := f.store.RoomSummary(t.Context(), resp.RoomID) if err != nil { t.Fatalf("RoomSummary: %v", err) } if summary.Name != "Test Room" || summary.Topic != "A topic" { t.Errorf("the stored room profile is %+v", summary.RoomProfile) } if summary.JoinedMembers != 1 { t.Errorf("the new room has %d joined members, want 1", summary.JoinedMembers) } } // TestCreateRoomVersions checks every supported version end to end. Version // 12 is the one worth the trouble: its room ID is the create event's own ID // and its create event has no room_id at all, so a pipeline that assumed // otherwise would fail here and nowhere else. func TestCreateRoomVersions(t *testing.T) { for _, version := range roomversion.Supported() { t.Run(string(version), func(t *testing.T) { f := newFixture(t, nil) resp := f.createRoom(roomserver.CreateRoomRequest{Version: string(version)}) room, err := f.store.Room(t.Context(), resp.RoomID) if err != nil { t.Fatalf("Room: %v", err) } if room.Version != string(version) { t.Errorf("the room was created at version %q, want %q", room.Version, version) } rules, err := roomversion.Get(version) if err != nil { t.Fatalf("roomversion.Get: %v", err) } if rules.RoomIDIsCreateEventID { stored, err := f.store.CurrentStateEvent(t.Context(), resp.RoomID, event.TypeCreate, "") if err != nil { t.Fatalf("CurrentStateEvent: %v", err) } if want := "!" + stored.EventID[1:]; resp.RoomID != want { t.Errorf("room ID %q is not derived from the create event %q", resp.RoomID, stored.EventID) } } // Whatever the version, a message has to be sendable: that is // what proves the auth events the pipeline selected are the // ones the rules want. if _, err := f.sendMessage(resp.RoomID, alice, "hello", "m1"); err != nil { t.Errorf("sending a message into a version %s room: %v", version, err) } }) } } func TestCreateRoomUnknownVersion(t *testing.T) { f := newFixture(t, nil) _, err := f.api.CreateRoom(t.Context(), roomserver.CreateRoomRequest{ Creator: alice, Version: "99", }) if !errors.Is(err, roomserver.ErrUnknownRoomVersion) { t.Errorf("createRoom at version 99 = %v, want ErrUnknownRoomVersion", err) } // Versions 1 and 2 exist and are deliberately not supported: their // event format is a separate piece of work that waits for federation. _, err = f.api.CreateRoom(t.Context(), roomserver.CreateRoomRequest{ Creator: alice, Version: "1", }) if !errors.Is(err, roomserver.ErrUnsupportedRoomVersion) { t.Errorf("createRoom at version 1 = %v, want ErrUnsupportedRoomVersion", err) } } func TestCreateRoomPresets(t *testing.T) { tests := []struct { preset, visibility string wantJoinRule string wantGuestAccess string }{ {preset: roomserver.PresetPublicChat, wantJoinRule: auth.JoinRulePublic, wantGuestAccess: roomserver.GuestAccessForbidden}, {preset: roomserver.PresetPrivateChat, wantJoinRule: auth.JoinRuleInvite, wantGuestAccess: roomserver.GuestAccessCanJoin}, {preset: roomserver.PresetTrustedPrivateChat, wantJoinRule: auth.JoinRuleInvite, wantGuestAccess: roomserver.GuestAccessCanJoin}, // With no preset the directory visibility decides, as the spec says. {visibility: roomserver.VisibilityPublic, wantJoinRule: auth.JoinRulePublic, wantGuestAccess: roomserver.GuestAccessForbidden}, {visibility: roomserver.VisibilityPrivate, wantJoinRule: auth.JoinRuleInvite, wantGuestAccess: roomserver.GuestAccessCanJoin}, } for _, tc := range tests { name := tc.preset if name == "" { name = "visibility=" + tc.visibility } t.Run(name, func(t *testing.T) { f := newFixture(t, nil) resp := f.createRoom(roomserver.CreateRoomRequest{ Preset: tc.preset, Visibility: tc.visibility, }) st := f.state(resp.RoomID) if got := st.JoinRule(); got != tc.wantJoinRule { t.Errorf("join rule = %q, want %q", got, tc.wantJoinRule) } summary, err := f.store.RoomSummary(t.Context(), resp.RoomID) if err != nil { t.Fatalf("RoomSummary: %v", err) } if summary.GuestAccess != tc.wantGuestAccess { t.Errorf("guest access = %q, want %q", summary.GuestAccess, tc.wantGuestAccess) } if want := tc.visibility == roomserver.VisibilityPublic; summary.Published != want { t.Errorf("published = %v, want %v", summary.Published, want) } }) } } // TestCreateRoomTrustedPrivateChatPromotesInvitees is the one thing the // preset is for: everybody invited comes in as an equal. func TestCreateRoomTrustedPrivateChatPromotesInvitees(t *testing.T) { f := newFixture(t, nil) resp := f.createRoom(roomserver.CreateRoomRequest{ Preset: roomserver.PresetTrustedPrivateChat, Invite: []string{bob}, }) st := f.state(resp.RoomID) powerLevels, err := auth.ResolvePowerLevels(st.PowerLevels(), st.Create(), st.Create().Rules()) if err != nil { t.Fatalf("ResolvePowerLevels: %v", err) } if got := powerLevels.UserLevel(bob); got != auth.CreatorPowerLevel { t.Errorf("an invitee's power level is %d, want %d", got, auth.CreatorPowerLevel) } if got := f.membership(resp.RoomID, bob); got != event.MembershipInvite { t.Errorf("bob's membership is %q, want invite", got) } } func TestCreateRoomPowerLevelOverride(t *testing.T) { f := newFixture(t, nil) resp := f.createRoom(roomserver.CreateRoomRequest{ PowerLevelContentOverride: json.RawMessage(`{"events_default": 25, "invite": 75}`), }) st := f.state(resp.RoomID) powerLevels, err := auth.ResolvePowerLevels(st.PowerLevels(), st.Create(), st.Create().Rules()) if err != nil { t.Fatalf("ResolvePowerLevels: %v", err) } if powerLevels.EventsDefault != 25 || powerLevels.Invite != 75 { t.Errorf("the override was not applied: events_default=%d invite=%d", powerLevels.EventsDefault, powerLevels.Invite) } // The keys the override did not mention keep the defaults, which is // what makes a shallow merge the right one. if powerLevels.Ban != auth.DefaultBan { t.Errorf("ban = %d, want the default %d", powerLevels.Ban, auth.DefaultBan) } } func TestCreateRoomInitialStateAndAlias(t *testing.T) { f := newFixture(t, nil) resp := f.createRoom(roomserver.CreateRoomRequest{ RoomAliasName: "general", InitialState: []roomserver.StateEvent{{ Type: event.TypeHistoryVisibility, Content: json.RawMessage(`{"history_visibility":"world_readable"}`), }}, }) if want := "#general:" + testServerName; resp.Alias != want { t.Errorf("createRoom returned alias %q, want %q", resp.Alias, want) } resolved, err := f.api.ResolveAlias(t.Context(), resp.Alias) if err != nil { t.Fatalf("ResolveAlias: %v", err) } if resolved.RoomID != resp.RoomID { t.Errorf("the alias resolves to %q, want %q", resolved.RoomID, resp.RoomID) } // initial_state overwrites the server's own setup event, which is what // makes a world-readable room creatable in one call. summary, err := f.store.RoomSummary(t.Context(), resp.RoomID) if err != nil { t.Fatalf("RoomSummary: %v", err) } if summary.HistoryVisibility != roomserver.HistoryVisibilityWorldReadable { t.Errorf("history visibility = %q, want world_readable", summary.HistoryVisibility) } if summary.CanonicalAlias != resp.Alias { t.Errorf("canonical alias = %q, want %q", summary.CanonicalAlias, resp.Alias) } } // TestCreateRoomAliasAlreadyTaken checks the alias is claimed before the // room is built, so that a clash does not leave a room behind. func TestCreateRoomAliasAlreadyTaken(t *testing.T) { f := newFixture(t, nil) f.createRoom(roomserver.CreateRoomRequest{RoomAliasName: "general"}) before, _, err := f.store.ListRooms(t.Context(), storage.RoomFilter{}) if err != nil { t.Fatalf("ListRooms: %v", err) } _, err = f.api.CreateRoom(t.Context(), roomserver.CreateRoomRequest{ Creator: bob, RoomAliasName: "general", }) if !errors.Is(err, roomserver.ErrAliasExists) { t.Fatalf("createRoom with a taken alias = %v, want ErrAliasExists", err) } after, _, err := f.store.ListRooms(t.Context(), storage.RoomFilter{}) if err != nil { t.Fatalf("ListRooms: %v", err) } if len(after) != len(before) { t.Errorf("a failed createRoom left %d extra rooms behind", len(after)-len(before)) } } func TestSendEventAndStreamOrder(t *testing.T) { f := newFixture(t, nil) resp := f.createRoom(roomserver.CreateRoomRequest{}) first, err := f.sendMessage(resp.RoomID, alice, "one", "m1") if err != nil { t.Fatalf("sending the first message: %v", err) } second, err := f.sendMessage(resp.RoomID, alice, "two", "m2") if err != nil { t.Fatalf("sending the second message: %v", err) } one, err := f.store.Event(t.Context(), resp.RoomID, first) if err != nil { t.Fatalf("Event: %v", err) } two, err := f.store.Event(t.Context(), resp.RoomID, second) if err != nil { t.Fatalf("Event: %v", err) } if two.StreamOrdering <= one.StreamOrdering { t.Errorf("stream ordering did not advance: %d then %d", one.StreamOrdering, two.StreamOrdering) } if two.Depth <= one.Depth { t.Errorf("depth did not advance: %d then %d", one.Depth, two.Depth) } // The second event descends from the first, so the room stays a chain // rather than forking. extremities, err := f.store.Extremities(t.Context(), resp.RoomID) if err != nil { t.Fatalf("Extremities: %v", err) } if len(extremities) != 1 || extremities[0].EventID != second { t.Errorf("extremities = %v, want just the last event", extremities) } } // TestSendEventIsIdempotentPerTransaction is what a client relies on when // its connection drops mid-send. func TestSendEventIsIdempotentPerTransaction(t *testing.T) { f := newFixture(t, nil) resp := f.createRoom(roomserver.CreateRoomRequest{}) first, err := f.sendMessage(resp.RoomID, alice, "hello", "same") if err != nil { t.Fatalf("first send: %v", err) } again, err := f.sendMessage(resp.RoomID, alice, "hello", "same") if err != nil { t.Fatalf("retried send: %v", err) } if again != first { t.Errorf("a retry produced a second event: %q then %q", first, again) } } func TestSendEventRefusals(t *testing.T) { f := newFixture(t, nil) resp := f.createRoom(roomserver.CreateRoomRequest{}) // A non-member cannot send into the room: the rules say so, not a // check in the handler. _, err := f.api.SendEvent(t.Context(), roomserver.SendEventRequest{ RoomID: resp.RoomID, Sender: bob, Type: "m.room.message", Content: json.RawMessage(`{"msgtype":"m.text","body":"hi"}`), }) if !errors.Is(err, roomserver.ErrRejected) { t.Errorf("a stranger sending a message = %v, want ErrRejected", err) } // A second create event is refused outright, with a message about the // room rather than about a rule number. _, err = f.api.SendEvent(t.Context(), roomserver.SendEventRequest{ RoomID: resp.RoomID, Sender: alice, Type: event.TypeCreate, StateKey: stringPtr(""), Content: json.RawMessage(`{"room_version":"11"}`), }) if !errors.Is(err, roomserver.ErrForbidden) { t.Errorf("re-creating a room = %v, want ErrForbidden", err) } // A user of another server cannot be sent as: this server can only // sign as itself. _, err = f.api.SendEvent(t.Context(), roomserver.SendEventRequest{ RoomID: resp.RoomID, Sender: "@eve:elsewhere.example", Type: "m.room.message", Content: json.RawMessage(`{"msgtype":"m.text","body":"hi"}`), }) if !errors.Is(err, roomserver.ErrForbidden) { t.Errorf("sending as a remote user = %v, want ErrForbidden", err) } // Content that is not an object is refused before anything is signed. _, err = f.api.SendEvent(t.Context(), roomserver.SendEventRequest{ RoomID: resp.RoomID, Sender: alice, Type: "m.room.message", Content: json.RawMessage(`"not an object"`), }) if !errors.Is(err, roomserver.ErrInvalidContent) { t.Errorf("sending a string as content = %v, want ErrInvalidContent", err) } // A room this server does not have is not a room. _, err = f.sendMessage("!nope:"+testServerName, alice, "hi", "x") if !errors.Is(err, roomserver.ErrNoSuchRoom) { t.Errorf("sending into an unknown room = %v, want ErrNoSuchRoom", err) } } func TestSendStateEvent(t *testing.T) { f := newFixture(t, nil) resp := f.createRoom(roomserver.CreateRoomRequest{}) if _, err := f.api.SendEvent(t.Context(), roomserver.SendEventRequest{ RoomID: resp.RoomID, Sender: alice, Type: roomserver.TypeName, StateKey: stringPtr(""), Content: json.RawMessage(`{"name":"Renamed"}`), }); err != nil { t.Fatalf("setting the room name: %v", err) } content, err := f.api.StateEventContent(t.Context(), resp.RoomID, alice, roomserver.TypeName, "") if err != nil { t.Fatalf("StateEventContent: %v", err) } var body struct { Name string `json:"name"` } if err := json.Unmarshal(content, &body); err != nil { t.Fatalf("the state content is not an object: %v", err) } if body.Name != "Renamed" { t.Errorf("the room name is %q, want Renamed", body.Name) } // The denormalised listing column followed, which is what the public // room directory is searched by. summary, err := f.store.RoomSummary(t.Context(), resp.RoomID) if err != nil { t.Fatalf("RoomSummary: %v", err) } if summary.Name != "Renamed" { t.Errorf("the stored room name is %q, want Renamed", summary.Name) } // A member event through the state endpoint needs a real membership. _, err = f.api.SendEvent(t.Context(), roomserver.SendEventRequest{ RoomID: resp.RoomID, Sender: alice, Type: event.TypeMember, StateKey: stringPtr(bob), Content: json.RawMessage(`{"membership":"nonsense"}`), }) if !errors.Is(err, roomserver.ErrInvalidContent) { t.Errorf("an invented membership = %v, want ErrInvalidContent", err) } } func stringPtr(s string) *string { return &s } // TestClockIsUsedForTimestamps checks that origin_server_ts comes from the // injected clock, which is what makes a test able to reason about ages. func TestClockIsUsedForTimestamps(t *testing.T) { at := time.Date(2026, 3, 1, 12, 0, 0, 0, time.UTC) f := newFixture(t, func(opts *roomserver.Options) { opts.Now = func() time.Time { return at } }) resp := f.createRoom(roomserver.CreateRoomRequest{}) stored, err := f.store.CurrentStateEvent(t.Context(), resp.RoomID, event.TypeCreate, "") if err != nil { t.Fatalf("CurrentStateEvent: %v", err) } if stored.OriginServerTS != at.UnixMilli() { t.Errorf("origin_server_ts = %d, want %d", stored.OriginServerTS, at.UnixMilli()) } } // TestConcurrentSendsStayALinearChain is what the per-room lock exists for. // // Without it two events racing into one room would each read the same // forward extremities, both become one, and fork the room — which would // then need state resolution to put back together for no reason at all. func TestConcurrentSendsStayALinearChain(t *testing.T) { f := newFixture(t, nil) resp := f.createRoom(roomserver.CreateRoomRequest{Preset: roomserver.PresetPublicChat}) const senders = 8 var wg sync.WaitGroup errs := make(chan error, senders) for i := range senders { wg.Add(1) go func() { defer wg.Done() _, err := f.api.SendEvent(context.Background(), roomserver.SendEventRequest{ RoomID: resp.RoomID, Sender: alice, Type: "m.room.message", Content: json.RawMessage(`{"msgtype":"m.text","body":"concurrent"}`), Transaction: &storage.TransactionRef{ Localpart: "alice", DeviceID: "DEV", TxnID: "txn" + strconv.Itoa(i), }, }) errs <- err }() } wg.Wait() close(errs) for err := range errs { if err != nil { t.Fatalf("a concurrent send failed: %v", err) } } extremities, err := f.store.Extremities(t.Context(), resp.RoomID) if err != nil { t.Fatalf("Extremities: %v", err) } if len(extremities) != 1 { t.Errorf("the room has %d forward extremities, want 1: the sends forked it", len(extremities)) } // Every send produced an event: the lock serialises them, it does not // drop any. stats, err := f.store.RoomStats(t.Context()) if err != nil { t.Fatalf("RoomStats: %v", err) } if stats.Events < senders { t.Errorf("the room holds %d events, want at least the %d sent", stats.Events, senders) } }