package roomserver_test import ( "encoding/json" "errors" "strconv" "testing" "github.com/dariuszjaniak/warp/server/internal/matrix/event" "github.com/dariuszjaniak/warp/server/internal/roomserver" "github.com/dariuszjaniak/warp/server/internal/storage" ) // The read model: timeline windows, the state at a position, and the // visible range that bounds both. The visible range is the interesting part // — it is where history visibility is decided, and the one place in the // component where getting it wrong shows somebody a room they left. // fill sends n messages into a room and returns their event IDs in order. // The bodies are "line 0", "line 1" and so on, counting from zero within // each call. // // Every send gets its own transaction ID, counted across the whole // fixture. Reusing one would make the second send an idempotent retry of // the first, and a test would then be asserting about a room with half the // events it thought it had. func (f *fixture) fill(roomID, sender string, n int) []string { f.t.Helper() out := make([]string, 0, n) for i := range n { f.sends++ eventID, err := f.sendMessage(roomID, sender, "line "+strconv.Itoa(i), "fill-"+strconv.Itoa(f.sends)) if err != nil { f.t.Fatalf("sendMessage: %v", err) } out = append(out, eventID) } return out } // publicRoom creates a room anybody may join. func (f *fixture) publicRoom() string { f.t.Helper() return f.createRoom(roomserver.CreateRoomRequest{Preset: "public_chat"}).RoomID } func (f *fixture) join(roomID, userID string) { f.t.Helper() if _, err := f.api.JoinRoom(f.t.Context(), roomserver.JoinRequest{ RoomID: roomID, User: userID, }); err != nil { f.t.Fatalf("JoinRoom(%s): %v", userID, err) } } func (f *fixture) leave(roomID, userID string) { f.t.Helper() if err := f.api.LeaveRoom(f.t.Context(), roomID, userID, ""); err != nil { f.t.Fatalf("LeaveRoom(%s): %v", userID, err) } } // bodies projects client events onto their message bodies. func bodies(events []roomserver.ClientEvent) []string { out := make([]string, 0, len(events)) for _, e := range events { var content struct { Body string `json:"body"` } if err := json.Unmarshal(e.Content, &content); err == nil && content.Body != "" { out = append(out, content.Body) } } return out } func TestTimelineIsOldestFirstWhicheverWayItWasRead(t *testing.T) { f := newFixture(t, nil) roomID := f.publicRoom() f.fill(roomID, alice, 4) backwards, err := f.api.Timeline(t.Context(), roomserver.TimelineRequest{ RoomID: roomID, User: alice, Direction: storage.DirectionBackward, Limit: 2, }) if err != nil { t.Fatalf("Timeline: %v", err) } // Reading backwards keeps the newest two, and hands them back oldest // first: a client renders a timeline forwards whichever way it paged. got := bodies(backwards.Events) if len(got) != 2 || got[0] != "line 2" || got[1] != "line 3" { t.Errorf("backwards timeline = %v, want [line 2 line 3]", got) } if !backwards.Limited { t.Error("a window the limit cut short must say so") } if backwards.Start >= backwards.End { t.Errorf("Start %d must sit below End %d", backwards.Start, backwards.End) } // Start is below the oldest event, so handing it back as a High bound // returns the page before this one, with nothing repeated. earlier, err := f.api.Timeline(t.Context(), roomserver.TimelineRequest{ RoomID: roomID, User: alice, High: backwards.Start, Direction: storage.DirectionBackward, Limit: 2, }) if err != nil { t.Fatalf("Timeline: %v", err) } if got := bodies(earlier.Events); len(got) != 2 || got[1] != "line 1" { t.Errorf("the page before = %v, want it to end at \"line 1\"", got) } } func TestTimelineEndIsUsableAsTheNextLowBound(t *testing.T) { f := newFixture(t, nil) roomID := f.publicRoom() f.fill(roomID, alice, 3) first, err := f.api.Timeline(t.Context(), roomserver.TimelineRequest{ RoomID: roomID, User: alice, Direction: storage.DirectionForward, Limit: 2, }) if err != nil { t.Fatalf("Timeline: %v", err) } next, err := f.api.Timeline(t.Context(), roomserver.TimelineRequest{ RoomID: roomID, User: alice, Low: first.End, Direction: storage.DirectionForward, Limit: 2, }) if err != nil { t.Fatalf("Timeline: %v", err) } // Nothing may appear in both pages: End is a position already seen. seen := map[string]bool{} for _, e := range first.Events { seen[e.EventID] = true } for _, e := range next.Events { if seen[e.EventID] { t.Errorf("%s appeared in two consecutive pages", e.EventID) } } } func TestVisibleRangeOfAMember(t *testing.T) { f := newFixture(t, nil) roomID := f.publicRoom() visible, err := f.api.VisibleRange(t.Context(), roomID, alice) if err != nil { t.Fatalf("VisibleRange: %v", err) } if !visible.Allowed { t.Fatal("a joined member must be allowed to read the room") } // The default preset is shared history, so a member sees all of it and // is not bounded above. if visible.From != 0 || visible.To != 0 { t.Errorf("range = %+v, want it unbounded for shared history", visible) } if visible.Membership != event.MembershipJoin { t.Errorf("membership = %q, want join", visible.Membership) } } func TestVisibleRangeOfAStranger(t *testing.T) { f := newFixture(t, nil) // private_chat is invite-only with shared history: a stranger sees // nothing of it. roomID := f.createRoom(roomserver.CreateRoomRequest{Preset: "private_chat"}).RoomID visible, err := f.api.VisibleRange(t.Context(), roomID, bob) if err != nil { t.Fatalf("VisibleRange: %v", err) } if visible.Allowed { t.Error("a stranger must not be allowed to read a private room") } if _, err := f.api.Timeline(t.Context(), roomserver.TimelineRequest{ RoomID: roomID, User: bob, Direction: storage.DirectionBackward, Limit: 10, }); !errors.Is(err, roomserver.ErrForbidden) { t.Errorf("Timeline for a stranger = %v, want ErrForbidden", err) } } func TestVisibleRangeOfADepartedMemberEndsAtTheirLeave(t *testing.T) { f := newFixture(t, nil) roomID := f.publicRoom() f.join(roomID, bob) f.fill(roomID, alice, 2) f.leave(roomID, bob) f.fill(roomID, alice, 2) visible, err := f.api.VisibleRange(t.Context(), roomID, bob) if err != nil { t.Fatalf("VisibleRange: %v", err) } if !visible.Allowed { t.Fatal("a departed member keeps what they saw while they were in the room") } if visible.To == 0 { t.Fatal("a departed member must be bounded above at their own leave") } timeline, err := f.api.Timeline(t.Context(), roomserver.TimelineRequest{ RoomID: roomID, User: bob, Direction: storage.DirectionBackward, Limit: 50, }) if err != nil { t.Fatalf("Timeline: %v", err) } // They see the two messages sent while they were in the room and // neither of the two sent afterwards. Each fill counts its bodies from // zero, so what is asserted is the count: four messages exist in the // room and Bob may read two of them. if got := bodies(timeline.Events); len(got) != 2 { t.Errorf("a departed member's timeline = %v, want the two messages from before they left", got) } } func TestVisibleRangeWithJoinedHistoryStartsAtTheJoin(t *testing.T) { f := newFixture(t, nil) roomID := f.createRoom(roomserver.CreateRoomRequest{ Preset: "public_chat", InitialState: []roomserver.StateEvent{{ Type: event.TypeHistoryVisibility, Content: []byte(`{"history_visibility":"joined"}`), }}, }).RoomID f.fill(roomID, alice, 2) f.join(roomID, bob) f.fill(roomID, alice, 2) timeline, err := f.api.Timeline(t.Context(), roomserver.TimelineRequest{ RoomID: roomID, User: bob, Direction: storage.DirectionBackward, Limit: 50, }) if err != nil { t.Fatalf("Timeline: %v", err) } // Only the messages sent after Bob joined. The two sent before carry // the same bodies — each fill counts from zero — so what is asserted // is the count: four messages exist and Bob may read two of them. got := bodies(timeline.Events) want := []string{"line 0", "line 1"} if len(got) != len(want) { t.Fatalf("timeline = %v, want %v", got, want) } for i := range got { if got[i] != want[i] { t.Fatalf("timeline = %v, want %v", got, want) } } // Bob's own member event is the first thing he sees, which is how a // client renders "you joined". var sawOwnJoin bool for _, e := range timeline.Events { if e.Type == event.TypeMember && e.StateKey != nil && *e.StateKey == bob { sawOwnJoin = true } } if !sawOwnJoin { t.Error("a user with joined history visibility must see their own join") } } func TestVisibleRangeOfAWorldReadableRoom(t *testing.T) { f := newFixture(t, nil) roomID := f.createRoom(roomserver.CreateRoomRequest{ Preset: "public_chat", InitialState: []roomserver.StateEvent{{ Type: event.TypeHistoryVisibility, Content: []byte(`{"history_visibility":"world_readable"}`), }}, }).RoomID f.fill(roomID, alice, 2) visible, err := f.api.VisibleRange(t.Context(), roomID, bob) if err != nil { t.Fatalf("VisibleRange: %v", err) } if !visible.Allowed { t.Fatal("a world-readable room is readable by anybody") } timeline, err := f.api.Timeline(t.Context(), roomserver.TimelineRequest{ RoomID: roomID, User: bob, Direction: storage.DirectionBackward, Limit: 50, }) if err != nil { t.Fatalf("Timeline: %v", err) } if got := bodies(timeline.Events); len(got) != 2 { t.Errorf("a stranger's timeline of a world-readable room = %v, want both messages", got) } } func TestRoomStateOfADepartedMemberIsAsOfTheirLeave(t *testing.T) { f := newFixture(t, nil) roomID := f.publicRoom() f.join(roomID, bob) if _, err := f.api.SendEvent(t.Context(), roomserver.SendEventRequest{ RoomID: roomID, Sender: alice, Type: roomserver.TypeTopic, StateKey: stringPtr(""), Content: []byte(`{"topic":"while bob was here"}`), }); err != nil { t.Fatalf("setting the topic: %v", err) } f.leave(roomID, bob) if _, err := f.api.SendEvent(t.Context(), roomserver.SendEventRequest{ RoomID: roomID, Sender: alice, Type: roomserver.TypeTopic, StateKey: stringPtr(""), Content: []byte(`{"topic":"after bob left"}`), }); err != nil { t.Fatalf("setting the topic again: %v", err) } // Phase 3 refused a departed member outright and said so in a comment; // they are now served the room as it was when they left. topic, err := f.api.StateEventContent(t.Context(), roomID, bob, roomserver.TypeTopic, "") if err != nil { t.Fatalf("StateEventContent for a departed member: %v", err) } var content struct { Topic string `json:"topic"` } if err := json.Unmarshal(topic, &content); err != nil { t.Fatalf("unmarshal the topic: %v", err) } if content.Topic != "while bob was here" { t.Errorf("topic = %q, want the one in force when they left", content.Topic) } } func TestEventOutsideTheVisibleRangeIsUnknown(t *testing.T) { f := newFixture(t, nil) roomID := f.publicRoom() f.join(roomID, bob) f.leave(roomID, bob) later := f.fill(roomID, alice, 1)[0] // Not "forbidden": whether a particular event ID exists in a room is // itself something a departed member is not entitled to know. if _, err := f.api.Event(t.Context(), roomID, bob, later); !errors.Is(err, roomserver.ErrNoSuchEvent) { t.Errorf("Event after a leave = %v, want ErrNoSuchEvent", err) } } func TestRoomSummaryCountsAndHeroes(t *testing.T) { f := newFixture(t, nil) roomID := f.publicRoom() f.join(roomID, bob) if err := f.api.InviteUser(t.Context(), roomserver.InviteRequest{ RoomID: roomID, Sender: alice, Target: carol, }); err != nil { t.Fatalf("InviteUser: %v", err) } summary, err := f.api.RoomSummaryFor(t.Context(), roomID, alice) if err != nil { t.Fatalf("RoomSummaryFor: %v", err) } if summary.JoinedMemberCount != 2 { t.Errorf("joined = %d, want 2", summary.JoinedMemberCount) } if summary.InvitedMemberCount != 1 { t.Errorf("invited = %d, want 1", summary.InvitedMemberCount) } // The heroes are everybody but the user asking: a client names a room // after the other people in it. for _, hero := range summary.Heroes { if hero == alice { t.Errorf("heroes = %v, want it to leave out the user asking", summary.Heroes) } } if len(summary.Heroes) != 2 { t.Errorf("heroes = %v, want both the other members", summary.Heroes) } } func TestStrippedStateIsEnoughToRenderAnInvite(t *testing.T) { f := newFixture(t, nil) roomID := f.createRoom(roomserver.CreateRoomRequest{ Preset: "private_chat", Name: "The Study", }).RoomID if err := f.api.InviteUser(t.Context(), roomserver.InviteRequest{ RoomID: roomID, Sender: alice, Target: bob, }); err != nil { t.Fatalf("InviteUser: %v", err) } stripped, err := f.api.StrippedState(t.Context(), roomID, []string{bob, alice}) if err != nil { t.Fatalf("StrippedState: %v", err) } types := map[string]bool{} for _, e := range stripped { types[e.Type] = true // Stripped state is explicitly not an event, so it carries no ID // and no timestamp: a client must not put it in a timeline. if e.Sender == "" { t.Errorf("stripped %s has no sender", e.Type) } } for _, want := range []string{event.TypeCreate, event.TypeJoinRules, roomserver.TypeName, event.TypeMember} { if !types[want] { t.Errorf("stripped state is missing %s (have %v)", want, types) } } }