package roomserver_test import ( "errors" "testing" "github.com/dariuszjaniak/warp/server/internal/roomserver" ) func TestAliasLifecycle(t *testing.T) { f := newFixture(t, nil) resp := f.createRoom(roomserver.CreateRoomRequest{Preset: roomserver.PresetPublicChat}) const alias = "#chat:" + testServerName if err := f.api.CreateAlias(t.Context(), alias, resp.RoomID, alice); err != nil { t.Fatalf("CreateAlias: %v", err) } resolved, err := f.api.ResolveAlias(t.Context(), 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) } // A client needs somewhere to join through, which with federation off // is this server. if len(resolved.Servers) != 1 || resolved.Servers[0] != testServerName { t.Errorf("the alias names servers %v, want [%s]", resolved.Servers, testServerName) } aliases, err := f.api.RoomAliases(t.Context(), resp.RoomID, alice) if err != nil { t.Fatalf("RoomAliases: %v", err) } if len(aliases) != 1 || aliases[0] != alias { t.Errorf("RoomAliases = %v, want [%s]", aliases, alias) } // The creator of an alias may remove it. if err := f.api.DeleteAlias(t.Context(), alias, alice, false); err != nil { t.Fatalf("DeleteAlias: %v", err) } if _, err := f.api.ResolveAlias(t.Context(), alias); !errors.Is(err, roomserver.ErrNoSuchAlias) { t.Errorf("the deleted alias still resolves: %v", err) } } func TestAliasPermissions(t *testing.T) { f := newFixture(t, nil) resp := f.createRoom(roomserver.CreateRoomRequest{Preset: roomserver.PresetPublicChat}) // Only a member may name a room, so that a stranger cannot squat names // against rooms they cannot see. err := f.api.CreateAlias(t.Context(), "#squat:"+testServerName, resp.RoomID, bob) if !errors.Is(err, roomserver.ErrForbidden) { t.Errorf("a stranger creating an alias = %v, want ErrForbidden", err) } if _, err := f.api.JoinRoom(t.Context(), roomserver.JoinRequest{ RoomID: resp.RoomID, User: bob, }); err != nil { t.Fatalf("bob joining: %v", err) } const bobsAlias = "#bobs:" + testServerName if err := f.api.CreateAlias(t.Context(), bobsAlias, resp.RoomID, bob); err != nil { t.Fatalf("CreateAlias as a member: %v", err) } // carol is nobody here and may not delete it. if err := f.api.DeleteAlias(t.Context(), bobsAlias, carol, false); !errors.Is(err, roomserver.ErrForbidden) { t.Errorf("a stranger deleting an alias = %v, want ErrForbidden", err) } // alice can, because she could set the room's canonical alias — which // is what lets a room's moderators clean up after a departed member. if err := f.api.DeleteAlias(t.Context(), bobsAlias, alice, false); err != nil { t.Errorf("a moderator deleting a member's alias: %v", err) } // An alias of another server is not ours to create or resolve. err = f.api.CreateAlias(t.Context(), "#elsewhere:other.example", resp.RoomID, alice) if !errors.Is(err, roomserver.ErrForbidden) { t.Errorf("creating a remote alias = %v, want ErrForbidden", err) } _, err = f.api.ResolveAlias(t.Context(), "#elsewhere:other.example") if !errors.Is(err, roomserver.ErrFederationDisabled) { t.Errorf("resolving a remote alias = %v, want ErrFederationDisabled", err) } // Malformed aliases are refused before anything is looked up. for _, bad := range []string{"", "general", "#", "#general", "#:" + testServerName} { if err := f.api.CreateAlias(t.Context(), bad, resp.RoomID, alice); !errors.Is(err, roomserver.ErrInvalidContent) { t.Errorf("CreateAlias(%q) = %v, want ErrInvalidContent", bad, err) } } } func TestPublicRoomDirectory(t *testing.T) { f := newFixture(t, nil) private := f.createRoom(roomserver.CreateRoomRequest{Name: "Private"}) public := f.createRoom(roomserver.CreateRoomRequest{ Preset: roomserver.PresetPublicChat, Visibility: roomserver.VisibilityPublic, Name: "Public Room", Topic: "Anybody may join", }) resp, err := f.api.PublicRooms(t.Context(), roomserver.PublicRoomsRequest{}) if err != nil { t.Fatalf("PublicRooms: %v", err) } if resp.TotalRoomCountEstimate != 1 || len(resp.Chunk) != 1 { t.Fatalf("the directory has %d of %d rooms, want 1 of 1", len(resp.Chunk), resp.TotalRoomCountEstimate) } entry := resp.Chunk[0] if entry.RoomID != public.RoomID { t.Errorf("the directory lists %q, want %q", entry.RoomID, public.RoomID) } if entry.Name != "Public Room" || entry.Topic != "Anybody may join" { t.Errorf("the directory entry is %+v", entry) } if entry.NumJoinedMembers != 1 { t.Errorf("num_joined_members = %d, want 1", entry.NumJoinedMembers) } if entry.WorldReadable { t.Error("a shared-history room is reported as world-readable") } // The public_chat preset makes the room joinable by anyone with an // account and closed to guests. The two are independent, which is // exactly why the pairing is worth pinning down. if entry.JoinRule != "public" { t.Errorf("join_rule = %q, want public", entry.JoinRule) } if entry.GuestCanJoin { t.Error("a public_chat room reports that guests may join") } // Publishing the private room puts it in, and the two then order by // member count. if err := f.api.SetRoomVisibility(t.Context(), private.RoomID, alice, roomserver.VisibilityPublic, false); err != nil { t.Fatalf("SetRoomVisibility: %v", err) } if visibility, err := f.api.RoomVisibility(t.Context(), private.RoomID); err != nil { t.Fatalf("RoomVisibility: %v", err) } else if visibility != roomserver.VisibilityPublic { t.Errorf("visibility = %q, want public", visibility) } resp, err = f.api.PublicRooms(t.Context(), roomserver.PublicRoomsRequest{}) if err != nil { t.Fatalf("PublicRooms: %v", err) } if resp.TotalRoomCountEstimate != 2 { t.Errorf("the directory has %d rooms, want 2", resp.TotalRoomCountEstimate) } // The search looks at the name. resp, err = f.api.PublicRooms(t.Context(), roomserver.PublicRoomsRequest{Search: "public"}) if err != nil { t.Fatalf("PublicRooms with a search: %v", err) } if len(resp.Chunk) != 1 || resp.Chunk[0].RoomID != public.RoomID { t.Errorf("searching for \"public\" returned %+v", resp.Chunk) } // Withdrawing a room takes it back out. if err := f.api.SetRoomVisibility(t.Context(), public.RoomID, alice, roomserver.VisibilityPrivate, false); err != nil { t.Fatalf("SetRoomVisibility: %v", err) } resp, err = f.api.PublicRooms(t.Context(), roomserver.PublicRoomsRequest{}) if err != nil { t.Fatalf("PublicRooms: %v", err) } if resp.TotalRoomCountEstimate != 1 { t.Errorf("the directory has %d rooms after a withdrawal, want 1", resp.TotalRoomCountEstimate) } } func TestPublicRoomDirectoryPaging(t *testing.T) { f := newFixture(t, nil) for range 5 { f.createRoom(roomserver.CreateRoomRequest{ Preset: roomserver.PresetPublicChat, Visibility: roomserver.VisibilityPublic, }) } first, err := f.api.PublicRooms(t.Context(), roomserver.PublicRoomsRequest{Limit: 2}) if err != nil { t.Fatalf("PublicRooms: %v", err) } if len(first.Chunk) != 2 || first.NextBatch == "" || first.PrevBatch != "" { t.Fatalf("the first page is %d rooms, next=%q prev=%q", len(first.Chunk), first.NextBatch, first.PrevBatch) } second, err := f.api.PublicRooms(t.Context(), roomserver.PublicRoomsRequest{ Limit: 2, Since: first.NextBatch, }) if err != nil { t.Fatalf("PublicRooms page two: %v", err) } if len(second.Chunk) != 2 || second.PrevBatch == "" { t.Fatalf("the second page is %d rooms, prev=%q", len(second.Chunk), second.PrevBatch) } if second.Chunk[0].RoomID == first.Chunk[0].RoomID { t.Error("the second page repeats the first") } last, err := f.api.PublicRooms(t.Context(), roomserver.PublicRoomsRequest{ Limit: 2, Since: second.NextBatch, }) if err != nil { t.Fatalf("PublicRooms page three: %v", err) } if len(last.Chunk) != 1 || last.NextBatch != "" { t.Errorf("the last page is %d rooms, next=%q", len(last.Chunk), last.NextBatch) } // A token this server did not issue is refused rather than clamped: a // client that made one up should hear about it. if _, err := f.api.PublicRooms(t.Context(), roomserver.PublicRoomsRequest{ Since: "not-a-token", }); !errors.Is(err, roomserver.ErrInvalidContent) { t.Errorf("an invented pagination token = %v, want ErrInvalidContent", err) } } func TestPublicRoomDirectoryPermissions(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) } // An ordinary member cannot publish the room: a room published by a // passer-by cannot be taken back by its moderators. err := f.api.SetRoomVisibility(t.Context(), resp.RoomID, bob, roomserver.VisibilityPublic, false) if !errors.Is(err, roomserver.ErrForbidden) { t.Errorf("a member publishing a room = %v, want ErrForbidden", err) } // An admin can, whoever they are. if err := f.api.SetRoomVisibility(t.Context(), resp.RoomID, bob, roomserver.VisibilityPublic, true); err != nil { t.Errorf("an admin publishing a room: %v", err) } // A value that is neither is a bad request, not a silent no-op. err = f.api.SetRoomVisibility(t.Context(), resp.RoomID, alice, "secret", false) if !errors.Is(err, roomserver.ErrInvalidContent) { t.Errorf("an invented visibility = %v, want ErrInvalidContent", err) } // Another server's directory needs federation. _, err = f.api.PublicRooms(t.Context(), roomserver.PublicRoomsRequest{Server: "other.example"}) if !errors.Is(err, roomserver.ErrFederationDisabled) { t.Errorf("reading a remote directory = %v, want ErrFederationDisabled", err) } } func TestAdminRoomListAndPurge(t *testing.T) { f := newFixture(t, nil) resp := f.createRoom(roomserver.CreateRoomRequest{ Preset: roomserver.PresetPublicChat, Name: "Doomed", RoomAliasName: "doomed", }) 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.sendMessage(resp.RoomID, alice, "hello", "m1"); err != nil { t.Fatalf("sending a message: %v", err) } details, err := f.api.RoomDetails(t.Context(), resp.RoomID) if err != nil { t.Fatalf("RoomDetails: %v", err) } if details.Name != "Doomed" || details.JoinedMembers != 2 || details.Members != 2 { t.Errorf("RoomDetails = %+v", details) } if len(details.Aliases) != 1 { t.Errorf("RoomDetails lists %v aliases, want one", details.Aliases) } // A room filled by one server has a single forward extremity; more // than one would mean it had forked. if len(details.ForwardExtremities) != 1 { t.Errorf("the room has %d forward extremities, want 1", len(details.ForwardExtremities)) } stats, err := f.api.Stats(t.Context()) if err != nil { t.Fatalf("Stats: %v", err) } if stats.Rooms != 1 || stats.Published != 0 || stats.Events == 0 { t.Errorf("Stats = %+v", stats) } if err := f.api.PurgeRoom(t.Context(), resp.RoomID); err != nil { t.Fatalf("PurgeRoom: %v", err) } if _, err := f.api.RoomDetails(t.Context(), resp.RoomID); !errors.Is(err, roomserver.ErrNoSuchRoom) { t.Errorf("the purged room is still there: %v", err) } // The alias went with it, so the name can be used again. if _, err := f.api.ResolveAlias(t.Context(), resp.Alias); !errors.Is(err, roomserver.ErrNoSuchAlias) { t.Errorf("the purged room's alias still resolves: %v", err) } if rooms, err := f.api.JoinedRooms(t.Context(), bob); err != nil { t.Fatalf("JoinedRooms: %v", err) } else if len(rooms) != 0 { t.Errorf("bob is still joined to %v after the purge", rooms) } if err := f.api.PurgeRoom(t.Context(), resp.RoomID); !errors.Is(err, roomserver.ErrNoSuchRoom) { t.Errorf("purging twice = %v, want ErrNoSuchRoom", err) } }