package roomserver_test import ( "encoding/json" "errors" "testing" "github.com/dariuszjaniak/warp/server/internal/matrix/auth" "github.com/dariuszjaniak/warp/server/internal/matrix/event" "github.com/dariuszjaniak/warp/server/internal/roomserver" ) func TestJoinPublicRoom(t *testing.T) { f := newFixture(t, nil) resp := f.createRoom(roomserver.CreateRoomRequest{Preset: roomserver.PresetPublicChat}) joined, err := f.api.JoinRoom(t.Context(), roomserver.JoinRequest{ RoomID: resp.RoomID, User: bob, }) if err != nil { t.Fatalf("JoinRoom: %v", err) } if joined != resp.RoomID { t.Errorf("JoinRoom returned %q, want %q", joined, resp.RoomID) } if got := f.membership(resp.RoomID, bob); got != event.MembershipJoin { t.Errorf("bob's membership is %q, want join", got) } // A second join is a no-op, not a second event: clients call /join to // make sure, and every extra membership event lands in everybody's // timeline. before, err := f.store.Event(t.Context(), resp.RoomID, f.memberEventID(resp.RoomID, bob)) if err != nil { t.Fatalf("Event: %v", err) } if _, err := f.api.JoinRoom(t.Context(), roomserver.JoinRequest{ RoomID: resp.RoomID, User: bob, }); err != nil { t.Fatalf("joining twice: %v", err) } after, err := f.store.Event(t.Context(), resp.RoomID, f.memberEventID(resp.RoomID, bob)) if err != nil { t.Fatalf("Event: %v", err) } if after.EventID != before.EventID { t.Errorf("joining twice produced a second membership event") } // The joined-member count follows. summary, err := f.store.RoomSummary(t.Context(), resp.RoomID) if err != nil { t.Fatalf("RoomSummary: %v", err) } if summary.JoinedMembers != 2 { t.Errorf("the room has %d joined members, want 2", summary.JoinedMembers) } } // memberEventID is the event ID of a user's current membership event. func (f *fixture) memberEventID(roomID, userID string) string { f.t.Helper() entry, err := f.store.Membership(f.t.Context(), roomID, userID) if err != nil { f.t.Fatalf("Membership: %v", err) } return entry.EventID } func TestJoinInviteOnlyRoom(t *testing.T) { f := newFixture(t, nil) resp := f.createRoom(roomserver.CreateRoomRequest{Preset: roomserver.PresetPrivateChat}) // Without an invite the rules refuse the join. The refusal comes from // them, not from a check in this package. _, err := f.api.JoinRoom(t.Context(), roomserver.JoinRequest{ RoomID: resp.RoomID, User: bob, }) if !errors.Is(err, roomserver.ErrRejected) { t.Fatalf("joining an invite-only room = %v, want ErrRejected", err) } if err := f.api.InviteUser(t.Context(), roomserver.InviteRequest{ RoomID: resp.RoomID, Sender: alice, Target: bob, }); err != nil { t.Fatalf("InviteUser: %v", err) } if got := f.membership(resp.RoomID, bob); got != event.MembershipInvite { t.Errorf("bob's membership after the invite is %q, want invite", got) } if _, err := f.api.JoinRoom(t.Context(), roomserver.JoinRequest{ RoomID: resp.RoomID, User: bob, }); err != nil { t.Fatalf("joining after an invite: %v", err) } if got := f.membership(resp.RoomID, bob); got != event.MembershipJoin { t.Errorf("bob's membership after joining is %q, want join", got) } } // TestInviteRequiresPower is the rule a room's moderators rely on. func TestInviteRequiresPower(t *testing.T) { f := newFixture(t, nil) resp := f.createRoom(roomserver.CreateRoomRequest{Preset: roomserver.PresetPublicChat}) if _, err := f.api.JoinRoom(t.Context(), roomserver.JoinRequest{ RoomID: resp.RoomID, User: bob, }); err != nil { t.Fatalf("bob joining: %v", err) } // The default invite level is 0, so bob can invite. Raise it and he // cannot. if _, err := f.api.SendEvent(t.Context(), roomserver.SendEventRequest{ RoomID: resp.RoomID, Sender: alice, Type: event.TypePowerLevels, StateKey: stringPtr(""), Content: json.RawMessage(`{"invite":50,"users":{"` + alice + `":100}}`), }); err != nil { t.Fatalf("raising the invite level: %v", err) } err := f.api.InviteUser(t.Context(), roomserver.InviteRequest{ RoomID: resp.RoomID, Sender: bob, Target: carol, }) if !errors.Is(err, roomserver.ErrRejected) { t.Errorf("an invite without the power = %v, want ErrRejected", err) } } func TestLeaveAndRejectInvite(t *testing.T) { f := newFixture(t, nil) resp := f.createRoom(roomserver.CreateRoomRequest{Preset: roomserver.PresetPublicChat}) if _, err := f.api.JoinRoom(t.Context(), roomserver.JoinRequest{ RoomID: resp.RoomID, User: bob, }); err != nil { t.Fatalf("bob joining: %v", err) } if err := f.api.LeaveRoom(t.Context(), resp.RoomID, bob, "bye"); err != nil { t.Fatalf("LeaveRoom: %v", err) } if got := f.membership(resp.RoomID, bob); got != event.MembershipLeave { t.Errorf("bob's membership after leaving is %q, want leave", got) } // The same endpoint rejects an invite, which is why it has to work on // a room the caller was never joined to. if err := f.api.InviteUser(t.Context(), roomserver.InviteRequest{ RoomID: resp.RoomID, Sender: alice, Target: carol, }); err != nil { t.Fatalf("InviteUser: %v", err) } if err := f.api.LeaveRoom(t.Context(), resp.RoomID, carol, "no thanks"); err != nil { t.Fatalf("rejecting an invite: %v", err) } if got := f.membership(resp.RoomID, carol); got != event.MembershipLeave { t.Errorf("carol's membership after rejecting is %q, want leave", got) } // Leaving a room you were never in is refused by the rules. err := f.api.LeaveRoom(t.Context(), resp.RoomID, "@dave:"+testServerName, "") if !errors.Is(err, roomserver.ErrRejected) { t.Errorf("leaving a room never joined = %v, want ErrRejected", err) } } func TestKickBanAndUnban(t *testing.T) { f := newFixture(t, nil) resp := f.createRoom(roomserver.CreateRoomRequest{Preset: roomserver.PresetPublicChat}) join := func(user string) { t.Helper() if _, err := f.api.JoinRoom(t.Context(), roomserver.JoinRequest{ RoomID: resp.RoomID, User: user, }); err != nil { t.Fatalf("%s joining: %v", user, err) } } join(bob) if err := f.api.KickUser(t.Context(), resp.RoomID, alice, bob, "behave"); err != nil { t.Fatalf("KickUser: %v", err) } if got := f.membership(resp.RoomID, bob); got != event.MembershipLeave { t.Errorf("bob's membership after the kick is %q, want leave", got) } // A kick is not a ban: bob can come back. join(bob) if err := f.api.BanUser(t.Context(), resp.RoomID, alice, bob, "enough"); err != nil { t.Fatalf("BanUser: %v", err) } if got := f.membership(resp.RoomID, bob); got != event.MembershipBan { t.Errorf("bob's membership after the ban is %q, want ban", got) } _, err := f.api.JoinRoom(t.Context(), roomserver.JoinRequest{ RoomID: resp.RoomID, User: bob, }) if !errors.Is(err, roomserver.ErrRejected) { t.Errorf("a banned user joining = %v, want ErrRejected", err) } if err := f.api.UnbanUser(t.Context(), resp.RoomID, alice, bob, ""); err != nil { t.Fatalf("UnbanUser: %v", err) } // Unbanning leaves the user outside the room, not in it. if got := f.membership(resp.RoomID, bob); got != event.MembershipLeave { t.Errorf("bob's membership after the unban is %q, want leave", got) } join(bob) // Unbanning somebody who is not banned says so rather than writing an // event that means nothing. err = f.api.UnbanUser(t.Context(), resp.RoomID, alice, bob, "") if !errors.Is(err, roomserver.ErrForbidden) { t.Errorf("unbanning a member = %v, want ErrForbidden", err) } } // TestKickRequiresPower checks that a member cannot remove a peer. func TestKickRequiresPower(t *testing.T) { f := newFixture(t, nil) resp := f.createRoom(roomserver.CreateRoomRequest{Preset: roomserver.PresetPublicChat}) for _, user := range []string{bob, carol} { if _, err := f.api.JoinRoom(t.Context(), roomserver.JoinRequest{ RoomID: resp.RoomID, User: user, }); err != nil { t.Fatalf("%s joining: %v", user, err) } } err := f.api.KickUser(t.Context(), resp.RoomID, bob, carol, "") if !errors.Is(err, roomserver.ErrRejected) { t.Errorf("a kick without the power = %v, want ErrRejected", err) } } // TestMembershipRemoteUsersNeedFederation is the behaviour PLAN.md section 3 // asks for: a clear refusal rather than a bare M_FORBIDDEN. func TestMembershipRemoteUsersNeedFederation(t *testing.T) { f := newFixture(t, nil) resp := f.createRoom(roomserver.CreateRoomRequest{}) err := f.api.InviteUser(t.Context(), roomserver.InviteRequest{ RoomID: resp.RoomID, Sender: alice, Target: "@eve:elsewhere.example", }) if !errors.Is(err, roomserver.ErrFederationDisabled) { t.Errorf("inviting a remote user = %v, want ErrFederationDisabled", err) } _, err = f.api.JoinRoom(t.Context(), roomserver.JoinRequest{ RoomID: "!elsewhere:elsewhere.example", User: alice, }) if !errors.Is(err, roomserver.ErrFederationDisabled) { t.Errorf("joining a remote room = %v, want ErrFederationDisabled", err) } // With federation on it is still not implemented, and says so — but it // says it as a federation problem rather than as a missing room. g := newFixture(t, func(opts *roomserver.Options) { opts.FederationEnabled = func() bool { return true } }) _, err = g.api.JoinRoom(t.Context(), roomserver.JoinRequest{ RoomID: "!elsewhere:elsewhere.example", User: alice, }) if !errors.Is(err, roomserver.ErrFederationDisabled) { t.Errorf("joining a remote room with federation on = %v, want ErrFederationDisabled", err) } } // TestRestrictedJoin is the one authorisation rule that needs a signature // checked, and so the one that needs this server to vouch for a join. func TestRestrictedJoin(t *testing.T) { f := newFixture(t, nil) // The space whose membership grants entry. space := f.createRoom(roomserver.CreateRoomRequest{Preset: roomserver.PresetPublicChat}) restricted := f.createRoom(roomserver.CreateRoomRequest{ Version: "11", InitialState: []roomserver.StateEvent{{ Type: event.TypeJoinRules, Content: json.RawMessage(`{"join_rule":"restricted","allow":[` + `{"type":"m.room_membership","room_id":"` + space.RoomID + `"}]}`), }}, }) // Not in the space: no entry. _, err := f.api.JoinRoom(t.Context(), roomserver.JoinRequest{ RoomID: restricted.RoomID, User: bob, }) if !errors.Is(err, roomserver.ErrForbidden) { t.Fatalf("joining a restricted room from outside = %v, want ErrForbidden", err) } if _, err := f.api.JoinRoom(t.Context(), roomserver.JoinRequest{ RoomID: space.RoomID, User: bob, }); err != nil { t.Fatalf("bob joining the space: %v", err) } // In the space: the join is authorised via a local user with the power // to invite, and the event carries their server's signature. if _, err := f.api.JoinRoom(t.Context(), roomserver.JoinRequest{ RoomID: restricted.RoomID, User: bob, }); err != nil { t.Fatalf("joining a restricted room from the space: %v", err) } if got := f.membership(restricted.RoomID, bob); got != event.MembershipJoin { t.Errorf("bob's membership is %q, want join", got) } st := f.state(restricted.RoomID) member := st.Member(bob) if member == nil { t.Fatal("bob has no membership event") } var content struct { AuthorisedVia string `json:"join_authorised_via_users_server"` } if err := json.Unmarshal(member.Content(), &content); err != nil { t.Fatalf("the membership content is not an object: %v", err) } if content.AuthorisedVia != alice { t.Errorf("join_authorised_via_users_server = %q, want %q", content.AuthorisedVia, alice) } } func TestJoinedRoomsAndMembers(t *testing.T) { f := newFixture(t, nil) first := f.createRoom(roomserver.CreateRoomRequest{Preset: roomserver.PresetPublicChat}) second := f.createRoom(roomserver.CreateRoomRequest{Preset: roomserver.PresetPublicChat}) if _, err := f.api.JoinRoom(t.Context(), roomserver.JoinRequest{ RoomID: first.RoomID, User: bob, }); err != nil { t.Fatalf("bob joining: %v", err) } rooms, err := f.api.JoinedRooms(t.Context(), bob) if err != nil { t.Fatalf("JoinedRooms: %v", err) } if len(rooms) != 1 || rooms[0] != first.RoomID { t.Errorf("bob's joined rooms = %v, want [%s]", rooms, first.RoomID) } aliceRooms, err := f.api.JoinedRooms(t.Context(), alice) if err != nil { t.Fatalf("JoinedRooms: %v", err) } if len(aliceRooms) != 2 { t.Errorf("alice is joined to %v, want both rooms", aliceRooms) } joined, err := f.api.JoinedMembers(t.Context(), first.RoomID, alice) if err != nil { t.Fatalf("JoinedMembers: %v", err) } if len(joined) != 2 { t.Fatalf("JoinedMembers returned %d members, want 2: %+v", len(joined), joined) } if joined[bob].DisplayName != "Bob" { t.Errorf("bob's display name is %q, want Bob", joined[bob].DisplayName) } // /joined_members is for members: a stranger is refused. if _, err := f.api.JoinedMembers(t.Context(), second.RoomID, bob); !errors.Is(err, roomserver.ErrForbidden) { t.Errorf("JoinedMembers for a stranger = %v, want ErrForbidden", err) } // /members includes the users who have left, unless filtered. if err := f.api.LeaveRoom(t.Context(), first.RoomID, bob, ""); err != nil { t.Fatalf("LeaveRoom: %v", err) } all, err := f.api.Members(t.Context(), roomserver.MembersRequest{ RoomID: first.RoomID, User: alice, }) if err != nil { t.Fatalf("Members: %v", err) } if len(all) != 2 { t.Errorf("Members returned %d events, want 2 (one of them a leave)", len(all)) } onlyJoined, err := f.api.Members(t.Context(), roomserver.MembersRequest{ RoomID: first.RoomID, User: alice, Membership: event.MembershipJoin, }) if err != nil { t.Fatalf("Members with a filter: %v", err) } if len(onlyJoined) != 1 { t.Errorf("filtering by join returned %d events, want 1", len(onlyJoined)) } notJoined, err := f.api.Members(t.Context(), roomserver.MembersRequest{ RoomID: first.RoomID, User: alice, NotMembership: event.MembershipJoin, }) if err != nil { t.Fatalf("Members with not_membership: %v", err) } if len(notJoined) != 1 { t.Errorf("not_membership=join returned %d events, want 1", len(notJoined)) } } // TestStateVisibility is the room-level visibility check the read endpoints // make: a member sees the room, a stranger does not, and anyone sees a // world-readable one. func TestStateVisibility(t *testing.T) { f := newFixture(t, nil) private := f.createRoom(roomserver.CreateRoomRequest{}) if _, err := f.api.RoomState(t.Context(), private.RoomID, alice); err != nil { t.Errorf("the creator cannot read their own room's state: %v", err) } if _, err := f.api.RoomState(t.Context(), private.RoomID, bob); !errors.Is(err, roomserver.ErrForbidden) { t.Errorf("a stranger reading state = %v, want ErrForbidden", err) } readable := f.createRoom(roomserver.CreateRoomRequest{ InitialState: []roomserver.StateEvent{{ Type: event.TypeHistoryVisibility, Content: json.RawMessage(`{"history_visibility":"world_readable"}`), }}, }) if _, err := f.api.RoomState(t.Context(), readable.RoomID, bob); err != nil { t.Errorf("a stranger cannot read a world-readable room's state: %v", err) } } // TestForceLeave is the admin escape hatch: it adds a real membership // event, so the room's other members see what happened. func TestForceLeave(t *testing.T) { f := newFixture(t, nil) resp := f.createRoom(roomserver.CreateRoomRequest{Preset: roomserver.PresetPublicChat}) if _, err := f.api.JoinRoom(t.Context(), roomserver.JoinRequest{ RoomID: resp.RoomID, User: bob, }); err != nil { t.Fatalf("bob joining: %v", err) } if err := f.api.ForceLeave(t.Context(), resp.RoomID, bob); err != nil { t.Fatalf("ForceLeave: %v", err) } if got := f.membership(resp.RoomID, bob); got != event.MembershipLeave { t.Errorf("bob's membership is %q, want leave", got) } // A user who is not in the room has nothing to leave. if err := f.api.ForceLeave(t.Context(), resp.RoomID, carol); !errors.Is(err, roomserver.ErrForbidden) { t.Errorf("force-leaving a stranger = %v, want ErrForbidden", err) } } // TestPowerLevelsCannotBeRaisedAboveYourOwn is the rule that keeps a // moderator from promoting themselves. func TestPowerLevelsCannotBeRaisedAboveYourOwn(t *testing.T) { f := newFixture(t, nil) resp := f.createRoom(roomserver.CreateRoomRequest{ Version: "11", Preset: roomserver.PresetPublicChat, PowerLevelContentOverride: json.RawMessage( `{"users":{"` + alice + `":100,"` + bob + `":50}}`), }) if _, err := f.api.JoinRoom(t.Context(), roomserver.JoinRequest{ RoomID: resp.RoomID, User: bob, }); err != nil { t.Fatalf("bob joining: %v", err) } _, err := f.api.SendEvent(t.Context(), roomserver.SendEventRequest{ RoomID: resp.RoomID, Sender: bob, Type: event.TypePowerLevels, StateKey: stringPtr(""), Content: json.RawMessage(`{"users":{"` + alice + `":100,"` + bob + `":100}}`), }) if !errors.Is(err, roomserver.ErrRejected) { t.Errorf("promoting yourself = %v, want ErrRejected", err) } 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 != 50 { t.Errorf("bob's power level is %d, want 50", got) } } // TestKickRequiresTheTargetToBeInTheRoom keeps a kick from adding a leave // event for somebody who was never there — which the authorisation rules // would accept, and which every member of the room would then see. func TestKickRequiresTheTargetToBeInTheRoom(t *testing.T) { f := newFixture(t, nil) resp := f.createRoom(roomserver.CreateRoomRequest{Preset: roomserver.PresetPublicChat}) err := f.api.KickUser(t.Context(), resp.RoomID, alice, bob, "") if !errors.Is(err, roomserver.ErrForbidden) { t.Errorf("kicking a stranger = %v, want ErrForbidden", err) } // An invite can be withdrawn with a kick, which is what the endpoint // is for as much as removing a member is. if err := f.api.InviteUser(t.Context(), roomserver.InviteRequest{ RoomID: resp.RoomID, Sender: alice, Target: bob, }); err != nil { t.Fatalf("InviteUser: %v", err) } if err := f.api.KickUser(t.Context(), resp.RoomID, alice, bob, ""); err != nil { t.Fatalf("withdrawing an invite with a kick: %v", err) } if got := f.membership(resp.RoomID, bob); got != event.MembershipLeave { t.Errorf("bob's membership is %q, want leave", got) } // Lifting a ban is /unban, not a kick. if err := f.api.BanUser(t.Context(), resp.RoomID, alice, bob, ""); err != nil { t.Fatalf("BanUser: %v", err) } if err := f.api.KickUser(t.Context(), resp.RoomID, alice, bob, ""); !errors.Is(err, roomserver.ErrForbidden) { t.Errorf("kicking a banned user = %v, want ErrForbidden", err) } }