CI / Test (ubuntu-latest) (push) Canceled after 0s
CI / Test (windows-latest) (push) Canceled after 0s
CI / Storage tests on PostgreSQL (push) Canceled after 0s
CI / Complement (federation off) (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
CI / Smoke test the binary (push) Canceled after 0s
Implements PLAN.md Phase 10. Simplified Sliding Sync (MSC4186) is served at the unstable org.matrix.simplified_msc3575 endpoint and advertised in /versions. Lists are sorted by activity and filtered by DM, invite, encryption, room type, space and tag; room subscriptions, required_state with wildcards, $ME and $LAZY, and the to_device, e2ee, account_data, receipts and typing extensions are supported. Per-connection state is kept in memory and an unknown pos answers M_UNKNOWN_POS. Threads and relations: m.relates_to is indexed on roomserver_events (migration 0012), /relations (with recursion) and /threads are served, and every event a timeline read returns carries its m.thread, m.replace and m.reference summaries in unsigned.m.relations. Notifications carry a thread ID (migration 0013): threaded receipts clear only their thread, and sync reports unread_thread_notifications when the filter asks. Spaces: /hierarchy walks a space breadth first, asking other servers about rooms this one is not in through the new federation hierarchy endpoint; room_summary describes one room. /search runs on FTS5 (sqlite, pocketbase) or tsvector (postgres), bounded to what each user may see, with context, profiles, state, grouping and paging. The PocketBase driver is real: the sqlite schema and store on an embedded PocketBase app's database, with media in its filesystem, local or S3 (database.pocketbase.s3). It runs the full storage test suite. Ops: OpenTelemetry tracing over OTLP/HTTP (tracing.*), with server spans per route, component spans, and trace propagation on federation, push and application service calls. New metrics for persisted events, waiting syncs, sliding sync connections and the database pool, with a Grafana dashboard. deploy/ adds a Compose setup (PostgreSQL, Prometheus, Grafana, Jaeger) and a Helm chart; cmd/warp-loadtest loads a server and reports request and delivery latencies. The load tester found a long-standing bug, fixed here: on a server where no account data had been written, every incremental /sync re-sent the push rules and returned at once, and an all-zero token was taken for an initial sync, so long polls became busy loops. The PostgreSQL migrations and search dialect compile but were not run against a live PostgreSQL in this change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1133 lines
38 KiB
Go
1133 lines
38 KiB
Go
package roomserver_test
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"path/filepath"
|
|
"slices"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/dariuszjaniak/warp/server/internal/federation"
|
|
"github.com/dariuszjaniak/warp/server/internal/matrix/event"
|
|
"github.com/dariuszjaniak/warp/server/internal/matrix/id"
|
|
"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"
|
|
)
|
|
|
|
// The federation tests run several roomservers in one process, each on its
|
|
// own database, connected by a transport that calls the other server's
|
|
// federation methods directly. Nothing about HTTP or X-Matrix is under
|
|
// test here — the federation package has its own tests for that — only
|
|
// whether two roomservers that can reach each other end up agreeing about
|
|
// a room.
|
|
|
|
// mesh is a set of servers that can reach each other.
|
|
type mesh struct {
|
|
t *testing.T
|
|
servers map[string]*peer
|
|
|
|
// disabled turns federation off everywhere, for the switch tests.
|
|
mu sync.Mutex
|
|
disabled bool
|
|
}
|
|
|
|
// peer is one server of the mesh.
|
|
type peer struct {
|
|
name string
|
|
api *roomserver.API
|
|
store storage.RoomStore
|
|
key *signing.KeyPair
|
|
mesh *mesh
|
|
|
|
mu sync.Mutex
|
|
// outbox is every event this server persisted that it would send:
|
|
// its own, and the ones it relays.
|
|
outbox []roomserver.Notification
|
|
}
|
|
|
|
func newMesh(t *testing.T, names ...string) *mesh {
|
|
t.Helper()
|
|
m := &mesh{t: t, servers: map[string]*peer{}}
|
|
for _, name := range names {
|
|
key, err := signing.Generate("k_" + name[:1])
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
m.servers[name] = &peer{name: name, key: key, mesh: m}
|
|
}
|
|
for _, name := range names {
|
|
m.servers[name].start(t)
|
|
}
|
|
return m
|
|
}
|
|
|
|
func (p *peer) start(t *testing.T) {
|
|
t.Helper()
|
|
path := filepath.ToSlash(filepath.Join(t.TempDir(), p.name+".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)
|
|
}
|
|
|
|
api, err := roomserver.New(roomserver.Options{
|
|
ServerName: p.name,
|
|
Store: db.Rooms(),
|
|
SigningKey: p.key,
|
|
Profiles: anyProfile{},
|
|
FederationEnabled: func() bool { return !p.mesh.isDisabled() },
|
|
Notifier: p,
|
|
Remote: transport{from: p},
|
|
Keys: keys{m: p.mesh},
|
|
Logger: slog.New(slog.DiscardHandler),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("roomserver.New: %v", err)
|
|
}
|
|
p.api, p.store = api, db.Rooms()
|
|
}
|
|
|
|
func (m *mesh) isDisabled() bool {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
return m.disabled
|
|
}
|
|
|
|
// anyProfile gives every local user a display name.
|
|
type anyProfile struct{}
|
|
|
|
func (anyProfile) Account(_ context.Context, localpart string) (storage.Account, error) {
|
|
return storage.Account{Localpart: localpart, DisplayName: "User " + localpart}, nil
|
|
}
|
|
|
|
// EventPersisted implements roomserver.Notifier: it fills the outbox the
|
|
// way the real outbound queue picks its events.
|
|
func (p *peer) EventPersisted(_ context.Context, n roomserver.Notification) {
|
|
if n.Origin != "" && !n.Relay {
|
|
return
|
|
}
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
p.outbox = append(p.outbox, n)
|
|
}
|
|
|
|
// takeOutbox empties the outbox.
|
|
func (p *peer) takeOutbox() []roomserver.Notification {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
out := p.outbox
|
|
p.outbox = nil
|
|
return out
|
|
}
|
|
|
|
// flush delivers every outbox to the servers in each event's room, the
|
|
// way the queue would, until nothing is left to send. It returns the
|
|
// errors the receivers reported, keyed by event ID.
|
|
func (m *mesh) flush() map[string]error {
|
|
m.t.Helper()
|
|
failures := map[string]error{}
|
|
for range 20 {
|
|
sent := false
|
|
for _, name := range slices.Sorted(mapsKeys(m.servers)) {
|
|
p := m.servers[name]
|
|
for _, n := range p.takeOutbox() {
|
|
sent = true
|
|
m.deliver(p, n, failures)
|
|
}
|
|
}
|
|
if !sent {
|
|
return failures
|
|
}
|
|
}
|
|
m.t.Fatal("flush did not settle")
|
|
return nil
|
|
}
|
|
|
|
// deliver sends one event from a server to the others in its room.
|
|
func (m *mesh) deliver(from *peer, n roomserver.Notification, failures map[string]error) {
|
|
m.t.Helper()
|
|
servers, err := from.api.JoinedServers(m.t.Context(), n.RoomID)
|
|
if err != nil {
|
|
m.t.Fatalf("JoinedServers: %v", err)
|
|
}
|
|
// A membership event also goes to the server of the user it is
|
|
// about, so that a kicked or banned server hears of it.
|
|
if key, ok := n.Event.StateKey(); ok && n.Event.Type() == event.TypeMember {
|
|
if server, err := id.ServerNameOf(key); err == nil && !slices.Contains(servers, server) {
|
|
servers = append(servers, server)
|
|
}
|
|
}
|
|
for _, server := range servers {
|
|
if server == from.name || server == n.Origin {
|
|
continue
|
|
}
|
|
to, ok := m.servers[server]
|
|
if !ok {
|
|
continue
|
|
}
|
|
if _, err := to.api.ReceivePDU(m.t.Context(), from.name, n.Event.JSON()); err != nil {
|
|
failures[n.Event.EventID()] = err
|
|
}
|
|
}
|
|
}
|
|
|
|
// deliverOnly sends one specific event, bypassing the outbox: the tests
|
|
// that leave gaps use it.
|
|
func (m *mesh) deliverOnly(from, to string, e *event.Event) error {
|
|
_, err := m.servers[to].api.ReceivePDU(m.t.Context(), from, e.JSON())
|
|
return err
|
|
}
|
|
|
|
func mapsKeys[V any](m map[string]V) func(func(string) bool) {
|
|
return func(yield func(string) bool) {
|
|
for k := range m {
|
|
if !yield(k) {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// keys verifies signatures with the mesh's keys, standing in for the key
|
|
// ring.
|
|
type keys struct{ m *mesh }
|
|
|
|
func (k keys) VerifyEventSignature(_ context.Context, serverName string, e *event.Event) error {
|
|
p, ok := k.m.servers[serverName]
|
|
if !ok {
|
|
return fmt.Errorf("no key for %s", serverName)
|
|
}
|
|
return e.VerifySignature(serverName, p.key.KeyID(), p.key.Public)
|
|
}
|
|
|
|
// transport is RemoteServers for one peer: every call goes straight to
|
|
// the destination's federation-side method, with this peer as the origin.
|
|
type transport struct{ from *peer }
|
|
|
|
func (t transport) to(destination string) (*roomserver.API, error) {
|
|
if t.from.mesh.isDisabled() {
|
|
return nil, federation.ErrDisabled
|
|
}
|
|
p, ok := t.from.mesh.servers[destination]
|
|
if !ok {
|
|
return nil, fmt.Errorf("%s is unreachable", destination)
|
|
}
|
|
return p.api, nil
|
|
}
|
|
|
|
// httpError turns a roomserver error into what the HTTP layer would have
|
|
// sent back, so that the caller's error mapping is exercised.
|
|
func httpError(err error) error {
|
|
switch {
|
|
case err == nil:
|
|
return nil
|
|
case errors.Is(err, roomserver.ErrIncompatibleRoomVersion):
|
|
return &federation.HTTPError{Status: http.StatusBadRequest, Code: "M_INCOMPATIBLE_ROOM_VERSION", Message: err.Error()}
|
|
case errors.Is(err, roomserver.ErrForbidden), errors.Is(err, roomserver.ErrRejected), errors.Is(err, roomserver.ErrNotInRoom):
|
|
return &federation.HTTPError{Status: http.StatusForbidden, Code: "M_FORBIDDEN", Message: err.Error()}
|
|
case errors.Is(err, roomserver.ErrNoSuchRoom), errors.Is(err, roomserver.ErrNoSuchEvent), errors.Is(err, roomserver.ErrNoSuchAlias):
|
|
return &federation.HTTPError{Status: http.StatusNotFound, Code: "M_NOT_FOUND", Message: err.Error()}
|
|
}
|
|
return &federation.HTTPError{Status: http.StatusInternalServerError, Code: "M_UNKNOWN", Message: err.Error()}
|
|
}
|
|
|
|
func (t transport) MakeJoin(ctx context.Context, dest, roomID, userID string, versions []string) (federation.MakeJoinResponse, error) {
|
|
api, err := t.to(dest)
|
|
if err != nil {
|
|
return federation.MakeJoinResponse{}, err
|
|
}
|
|
version, tmpl, err := api.MakeJoin(ctx, t.from.name, roomID, userID, versions)
|
|
return federation.MakeJoinResponse{RoomVersion: version, Event: tmpl}, httpError(err)
|
|
}
|
|
|
|
func (t transport) SendJoin(ctx context.Context, dest, roomID, eventID string, raw json.RawMessage) (federation.SendJoinResponse, error) {
|
|
api, err := t.to(dest)
|
|
if err != nil {
|
|
return federation.SendJoinResponse{}, err
|
|
}
|
|
res, err := api.SendJoin(ctx, t.from.name, roomID, eventID, raw)
|
|
return federation.SendJoinResponse{Origin: dest, State: res.State, AuthChain: res.AuthChain, Event: res.Event}, httpError(err)
|
|
}
|
|
|
|
func (t transport) MakeLeave(ctx context.Context, dest, roomID, userID string) (federation.MakeJoinResponse, error) {
|
|
api, err := t.to(dest)
|
|
if err != nil {
|
|
return federation.MakeJoinResponse{}, err
|
|
}
|
|
version, tmpl, err := api.MakeLeave(ctx, t.from.name, roomID, userID)
|
|
return federation.MakeJoinResponse{RoomVersion: version, Event: tmpl}, httpError(err)
|
|
}
|
|
|
|
func (t transport) SendLeave(ctx context.Context, dest, roomID, eventID string, raw json.RawMessage) error {
|
|
api, err := t.to(dest)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return httpError(api.SendLeave(ctx, t.from.name, roomID, eventID, raw))
|
|
}
|
|
|
|
func (t transport) Invite(ctx context.Context, dest, _, _ string, req federation.InviteRequest) (federation.InviteResponse, error) {
|
|
api, err := t.to(dest)
|
|
if err != nil {
|
|
return federation.InviteResponse{}, err
|
|
}
|
|
signed, err := api.ReceiveInvite(ctx, t.from.name, req.RoomVersion, req.Event, req.InviteRoomState)
|
|
return federation.InviteResponse{Event: signed}, httpError(err)
|
|
}
|
|
|
|
func (t transport) Event(ctx context.Context, dest, eventID string) (federation.Transaction, error) {
|
|
api, err := t.to(dest)
|
|
if err != nil {
|
|
return federation.Transaction{}, err
|
|
}
|
|
raw, err := api.EventForServer(ctx, t.from.name, eventID)
|
|
if err != nil {
|
|
return federation.Transaction{}, httpError(err)
|
|
}
|
|
return federation.Transaction{Origin: dest, PDUs: []json.RawMessage{raw}}, nil
|
|
}
|
|
|
|
func (t transport) EventAuth(ctx context.Context, dest, roomID, eventID string) (federation.EventAuthResponse, error) {
|
|
api, err := t.to(dest)
|
|
if err != nil {
|
|
return federation.EventAuthResponse{}, err
|
|
}
|
|
chain, err := api.EventAuthForServer(ctx, t.from.name, roomID, eventID)
|
|
return federation.EventAuthResponse{AuthChain: chain}, httpError(err)
|
|
}
|
|
|
|
func (t transport) StateIDs(ctx context.Context, dest, roomID, eventID string) (federation.StateIDsResponse, error) {
|
|
api, err := t.to(dest)
|
|
if err != nil {
|
|
return federation.StateIDsResponse{}, err
|
|
}
|
|
state, chain, err := api.StateIDsForServer(ctx, t.from.name, roomID, eventID)
|
|
return federation.StateIDsResponse{PDUIDs: state, AuthChainIDs: chain}, httpError(err)
|
|
}
|
|
|
|
func (t transport) State(ctx context.Context, dest, roomID, eventID string) (federation.StateResponse, error) {
|
|
api, err := t.to(dest)
|
|
if err != nil {
|
|
return federation.StateResponse{}, err
|
|
}
|
|
state, chain, err := api.StateForServer(ctx, t.from.name, roomID, eventID)
|
|
return federation.StateResponse{PDUs: state, AuthChain: chain}, httpError(err)
|
|
}
|
|
|
|
func (t transport) MissingEvents(ctx context.Context, dest, roomID string, req federation.MissingEventsRequest) (federation.MissingEventsResponse, error) {
|
|
api, err := t.to(dest)
|
|
if err != nil {
|
|
return federation.MissingEventsResponse{}, err
|
|
}
|
|
events, err := api.MissingEventsForServer(ctx, t.from.name, roomID, req.EarliestEvents, req.LatestEvents, req.Limit, req.MinDepth)
|
|
return federation.MissingEventsResponse{Events: events}, httpError(err)
|
|
}
|
|
|
|
func (t transport) QueryDirectory(ctx context.Context, dest, alias string) (federation.DirectoryResponse, error) {
|
|
api, err := t.to(dest)
|
|
if err != nil {
|
|
return federation.DirectoryResponse{}, err
|
|
}
|
|
resolved, err := api.QueryAliasForServer(ctx, alias)
|
|
return federation.DirectoryResponse{RoomID: resolved.RoomID, Servers: resolved.Servers}, httpError(err)
|
|
}
|
|
|
|
func (t transport) Hierarchy(ctx context.Context, dest, roomID string, suggestedOnly bool) (json.RawMessage, error) {
|
|
api, err := t.to(dest)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
resp, err := api.FederatedHierarchy(ctx, t.from.name, roomID, suggestedOnly)
|
|
if err != nil {
|
|
return nil, httpError(err)
|
|
}
|
|
return json.Marshal(resp)
|
|
}
|
|
|
|
func (t transport) PublicRooms(ctx context.Context, dest string, limit int, since, search string) (json.RawMessage, error) {
|
|
api, err := t.to(dest)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
resp, err := api.PublicRooms(ctx, roomserver.PublicRoomsRequest{Limit: limit, Since: since, Search: search})
|
|
if err != nil {
|
|
return nil, httpError(err)
|
|
}
|
|
return json.Marshal(resp)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const (
|
|
serverA = "a.example"
|
|
serverB = "b.example"
|
|
serverC = "c.example"
|
|
|
|
userA = "@alice:" + serverA
|
|
userA2 = "@anne:" + serverA
|
|
userB = "@bob:" + serverB
|
|
userC = "@cath:" + serverC
|
|
)
|
|
|
|
// send sends a message and returns its event ID.
|
|
func (p *peer) send(t *testing.T, roomID, sender, body string) string {
|
|
t.Helper()
|
|
eventID, err := p.api.SendEvent(t.Context(), roomserver.SendEventRequest{
|
|
RoomID: roomID, Sender: sender, Type: "m.room.message",
|
|
Content: json.RawMessage(`{"msgtype":"m.text","body":"` + body + `"}`),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("%s: SendEvent: %v", p.name, err)
|
|
}
|
|
return eventID
|
|
}
|
|
|
|
// timelineIDs returns the event IDs of a room's timeline on a server,
|
|
// oldest first.
|
|
func (p *peer) timelineIDs(t *testing.T, roomID string) []string {
|
|
t.Helper()
|
|
events, err := p.store.Timeline(t.Context(), storage.TimelineRequest{
|
|
RoomID: roomID, Limit: 1000, Direction: storage.DirectionForward,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Timeline: %v", err)
|
|
}
|
|
ids := make([]string, 0, len(events))
|
|
for _, e := range events {
|
|
ids = append(ids, e.EventID)
|
|
}
|
|
return ids
|
|
}
|
|
|
|
// membership reads a user's membership on a server.
|
|
func (p *peer) membership(t *testing.T, roomID, userID string) string {
|
|
t.Helper()
|
|
m, err := p.api.Membership(t.Context(), roomID, userID)
|
|
if err != nil && !errors.Is(err, roomserver.ErrNoSuchRoom) {
|
|
t.Fatalf("Membership: %v", err)
|
|
}
|
|
return m
|
|
}
|
|
|
|
// currentState returns a server's current state of a room as
|
|
// type/state_key -> event ID.
|
|
func (p *peer) currentState(t *testing.T, roomID string) map[string]string {
|
|
t.Helper()
|
|
entries, err := p.store.CurrentState(t.Context(), roomID)
|
|
if err != nil {
|
|
t.Fatalf("CurrentState: %v", err)
|
|
}
|
|
out := map[string]string{}
|
|
for _, e := range entries {
|
|
out[e.Type+"|"+e.StateKey] = e.EventID
|
|
}
|
|
return out
|
|
}
|
|
|
|
// publicRoom creates a public room on a server.
|
|
func (p *peer) publicRoom(t *testing.T, creator string) string {
|
|
t.Helper()
|
|
resp, err := p.api.CreateRoom(t.Context(), roomserver.CreateRoomRequest{
|
|
Creator: creator, Preset: roomserver.PresetPublicChat, Name: "Federated",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateRoom: %v", err)
|
|
}
|
|
return resp.RoomID
|
|
}
|
|
|
|
func TestFederatedJoinAndConversation(t *testing.T) {
|
|
for _, version := range []string{"10", "11", "12"} {
|
|
t.Run("v"+version, func(t *testing.T) {
|
|
m := newMesh(t, serverA, serverB)
|
|
a, b := m.servers[serverA], m.servers[serverB]
|
|
|
|
resp, err := a.api.CreateRoom(t.Context(), roomserver.CreateRoomRequest{
|
|
Creator: userA, Preset: roomserver.PresetPublicChat, Name: "Federated", Version: version,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateRoom: %v", err)
|
|
}
|
|
roomID := resp.RoomID
|
|
a.takeOutbox() // nobody else is in the room yet
|
|
|
|
if _, err := b.api.JoinRoom(t.Context(), roomserver.JoinRequest{
|
|
RoomID: roomID, User: userB, ServerNames: []string{serverA},
|
|
}); err != nil {
|
|
t.Fatalf("remote join: %v", err)
|
|
}
|
|
if got := b.membership(t, roomID, userB); got != event.MembershipJoin {
|
|
t.Fatalf("B sees its user as %q", got)
|
|
}
|
|
if got := a.membership(t, roomID, userB); got != event.MembershipJoin {
|
|
t.Fatalf("A sees B's user as %q", got)
|
|
}
|
|
if failures := m.flush(); len(failures) > 0 {
|
|
t.Fatalf("delivery failures: %v", failures)
|
|
}
|
|
|
|
// Both servers hold the same room.
|
|
if sa, sb := a.currentState(t, roomID), b.currentState(t, roomID); !mapsEqual(sa, sb) {
|
|
t.Fatalf("current state differs:\nA: %v\nB: %v", sa, sb)
|
|
}
|
|
summary, err := b.store.RoomSummary(t.Context(), roomID)
|
|
if err != nil {
|
|
t.Fatalf("RoomSummary: %v", err)
|
|
}
|
|
if summary.Name != "Federated" || summary.JoinedMembers != 2 {
|
|
t.Errorf("B's listing of the room = %+v", summary.RoomProfile)
|
|
}
|
|
|
|
// A conversation in both directions.
|
|
m1 := a.send(t, roomID, userA, "hello from A")
|
|
m2 := b.send(t, roomID, userB, "hello from B")
|
|
if failures := m.flush(); len(failures) > 0 {
|
|
t.Fatalf("delivery failures: %v", failures)
|
|
}
|
|
for _, p := range []*peer{a, b} {
|
|
ids := p.timelineIDs(t, roomID)
|
|
if !slices.Contains(ids, m1) || !slices.Contains(ids, m2) {
|
|
t.Errorf("%s's timeline lacks a message: %v", p.name, ids)
|
|
}
|
|
}
|
|
|
|
// The two messages were sent concurrently, so each server now
|
|
// has two forward extremities; the next event joins them.
|
|
m3 := a.send(t, roomID, userA, "merge")
|
|
if failures := m.flush(); len(failures) > 0 {
|
|
t.Fatalf("delivery failures: %v", failures)
|
|
}
|
|
for _, p := range []*peer{a, b} {
|
|
exts, err := p.store.Extremities(t.Context(), roomID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(exts) != 1 || exts[0].EventID != m3 {
|
|
t.Errorf("%s's extremities after the merge = %v", p.name, eventIDsOf(exts))
|
|
}
|
|
}
|
|
if sa, sb := a.currentState(t, roomID), b.currentState(t, roomID); !mapsEqual(sa, sb) {
|
|
t.Errorf("current state differs after the conversation:\nA: %v\nB: %v", sa, sb)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRemoteJoinFailsOverToTheNextServer(t *testing.T) {
|
|
m := newMesh(t, serverA, serverB)
|
|
a, b := m.servers[serverA], m.servers[serverB]
|
|
roomID := a.publicRoom(t, userA)
|
|
|
|
if _, err := b.api.JoinRoom(t.Context(), roomserver.JoinRequest{
|
|
RoomID: roomID, User: userB, ServerNames: []string{"gone.example", serverA},
|
|
}); err != nil {
|
|
t.Fatalf("join with an unreachable first server: %v", err)
|
|
}
|
|
if got := b.membership(t, roomID, userB); got != event.MembershipJoin {
|
|
t.Errorf("membership = %q", got)
|
|
}
|
|
}
|
|
|
|
func TestRemoteJoinToAnInviteOnlyRoomIsRefused(t *testing.T) {
|
|
m := newMesh(t, serverA, serverB)
|
|
a, b := m.servers[serverA], m.servers[serverB]
|
|
resp, err := a.api.CreateRoom(t.Context(), roomserver.CreateRoomRequest{Creator: userA, Preset: roomserver.PresetPrivateChat})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
_, err = b.api.JoinRoom(t.Context(), roomserver.JoinRequest{
|
|
RoomID: resp.RoomID, User: userB, ServerNames: []string{serverA},
|
|
})
|
|
if !errors.Is(err, roomserver.ErrForbidden) {
|
|
t.Fatalf("join to an invite-only room = %v, want ErrForbidden", err)
|
|
}
|
|
if _, err := b.store.Room(t.Context(), resp.RoomID); !errors.Is(err, storage.ErrNoSuchRoom) {
|
|
t.Errorf("a refused join left the room behind on B: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRemoteInviteJoinAndReject(t *testing.T) {
|
|
m := newMesh(t, serverA, serverB)
|
|
a, b := m.servers[serverA], m.servers[serverB]
|
|
resp, err := a.api.CreateRoom(t.Context(), roomserver.CreateRoomRequest{
|
|
Creator: userA, Preset: roomserver.PresetPrivateChat, Name: "Secret",
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
roomID := resp.RoomID
|
|
|
|
if err := a.api.InviteUser(t.Context(), roomserver.InviteRequest{RoomID: roomID, Sender: userA, Target: userB}); err != nil {
|
|
t.Fatalf("remote invite: %v", err)
|
|
}
|
|
if got := b.membership(t, roomID, userB); got != event.MembershipInvite {
|
|
t.Fatalf("B sees its user as %q after the invite", got)
|
|
}
|
|
// The invite on A carries both servers' signatures.
|
|
stored, err := a.store.CurrentStateEvent(t.Context(), roomID, event.TypeMember, userB)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var sigs struct {
|
|
Signatures map[string]any `json:"signatures"`
|
|
}
|
|
_ = json.Unmarshal(stored.JSON, &sigs)
|
|
if _, ok := sigs.Signatures[serverB]; !ok {
|
|
t.Error("the invite A holds is not countersigned by B")
|
|
}
|
|
|
|
// B shows the invite with the state A sent along.
|
|
stripped, err := b.api.StrippedState(t.Context(), roomID, []string{userB})
|
|
if err != nil {
|
|
t.Fatalf("StrippedState: %v", err)
|
|
}
|
|
var sawName, sawInvite bool
|
|
for _, s := range stripped {
|
|
if s.Type == "m.room.name" {
|
|
sawName = true
|
|
}
|
|
if s.Type == event.TypeMember && s.StateKey == userB {
|
|
sawInvite = true
|
|
}
|
|
}
|
|
if !sawName || !sawInvite {
|
|
t.Errorf("B's invite state = %+v", stripped)
|
|
}
|
|
|
|
// Accepting goes through A, which is the inviter.
|
|
if _, err := b.api.JoinRoom(t.Context(), roomserver.JoinRequest{RoomID: roomID, User: userB}); err != nil {
|
|
t.Fatalf("accepting the invite: %v", err)
|
|
}
|
|
if got := a.membership(t, roomID, userB); got != event.MembershipJoin {
|
|
t.Errorf("A sees B's user as %q after the join", got)
|
|
}
|
|
if got, _ := b.store.InviteState(t.Context(), roomID, userB); got != nil {
|
|
t.Error("the invite state outlived the invite")
|
|
}
|
|
|
|
// B is in the room now, so a second invite to one of its users is
|
|
// taken in as an ordinary event of the room rather than out of band.
|
|
m.flush()
|
|
if err := a.api.InviteUser(t.Context(), roomserver.InviteRequest{RoomID: roomID, Sender: userA, Target: "@zed:" + serverB}); err != nil {
|
|
t.Fatalf("second invite: %v", err)
|
|
}
|
|
m.flush()
|
|
if got := b.membership(t, roomID, "@zed:"+serverB); got != event.MembershipInvite {
|
|
t.Fatalf("B sees zed as %q", got)
|
|
}
|
|
}
|
|
|
|
func TestRejectingARemoteInvite(t *testing.T) {
|
|
m := newMesh(t, serverA, serverB)
|
|
a, b := m.servers[serverA], m.servers[serverB]
|
|
resp, err := a.api.CreateRoom(t.Context(), roomserver.CreateRoomRequest{Creator: userA, Preset: roomserver.PresetPrivateChat})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
roomID := resp.RoomID
|
|
if err := a.api.InviteUser(t.Context(), roomserver.InviteRequest{RoomID: roomID, Sender: userA, Target: userB}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if err := b.api.LeaveRoom(t.Context(), roomID, userB, "no thanks"); err != nil {
|
|
t.Fatalf("rejecting: %v", err)
|
|
}
|
|
if got := b.membership(t, roomID, userB); got != event.MembershipLeave {
|
|
t.Errorf("B sees its user as %q after rejecting", got)
|
|
}
|
|
if got := a.membership(t, roomID, userB); got != event.MembershipLeave {
|
|
t.Errorf("A sees B's user as %q after the rejection", got)
|
|
}
|
|
}
|
|
|
|
func TestRejectingAnInviteFromAnUnreachableServer(t *testing.T) {
|
|
m := newMesh(t, serverA, serverB)
|
|
a, b := m.servers[serverA], m.servers[serverB]
|
|
resp, err := a.api.CreateRoom(t.Context(), roomserver.CreateRoomRequest{Creator: userA, Preset: roomserver.PresetPrivateChat})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := a.api.InviteUser(t.Context(), roomserver.InviteRequest{RoomID: resp.RoomID, Sender: userA, Target: userB}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
delete(m.servers, serverA)
|
|
|
|
if err := b.api.LeaveRoom(t.Context(), resp.RoomID, userB, ""); err != nil {
|
|
t.Fatalf("rejecting locally: %v", err)
|
|
}
|
|
if got := b.membership(t, resp.RoomID, userB); got != event.MembershipLeave {
|
|
t.Errorf("membership after a local rejection = %q", got)
|
|
}
|
|
}
|
|
|
|
func TestMissingEventsAreFetched(t *testing.T) {
|
|
m := newMesh(t, serverA, serverB)
|
|
a, b := m.servers[serverA], m.servers[serverB]
|
|
roomID := a.publicRoom(t, userA)
|
|
if _, err := b.api.JoinRoom(t.Context(), roomserver.JoinRequest{RoomID: roomID, User: userB, ServerNames: []string{serverA}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
m.flush()
|
|
|
|
var sent []string
|
|
for i := range 4 {
|
|
sent = append(sent, a.send(t, roomID, userA, fmt.Sprint("m", i)))
|
|
}
|
|
outbox := a.takeOutbox()
|
|
// Only the last one gets through; B has to ask for the other three.
|
|
last := outbox[len(outbox)-1]
|
|
if err := m.deliverOnly(serverA, serverB, last.Event); err != nil {
|
|
t.Fatalf("delivering the last message: %v", err)
|
|
}
|
|
ids := b.timelineIDs(t, roomID)
|
|
for _, id := range sent {
|
|
if !slices.Contains(ids, id) {
|
|
t.Errorf("B's timeline lacks %s", id)
|
|
}
|
|
}
|
|
// In order.
|
|
var positions []int
|
|
for _, id := range sent {
|
|
positions = append(positions, slices.Index(ids, id))
|
|
}
|
|
if !slices.IsSorted(positions) {
|
|
t.Errorf("fetched events are out of order: %v", positions)
|
|
}
|
|
}
|
|
|
|
func TestAWideGapIsClosedWithState(t *testing.T) {
|
|
m := newMesh(t, serverA, serverB)
|
|
a, b := m.servers[serverA], m.servers[serverB]
|
|
roomID := a.publicRoom(t, userA)
|
|
if _, err := b.api.JoinRoom(t.Context(), roomserver.JoinRequest{RoomID: roomID, User: userB, ServerNames: []string{serverA}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
m.flush()
|
|
|
|
// More than get_missing_events will be asked for, with a state change
|
|
// in the part that is not fetched.
|
|
for i := range 6 {
|
|
a.send(t, roomID, userA, fmt.Sprint("early", i))
|
|
}
|
|
if _, err := a.api.SendEvent(t.Context(), roomserver.SendEventRequest{
|
|
RoomID: roomID, Sender: userA, Type: "m.room.topic", StateKey: ptrTo(""),
|
|
Content: json.RawMessage(`{"topic":"changed in the gap"}`),
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for i := range 15 {
|
|
a.send(t, roomID, userA, fmt.Sprint("late", i))
|
|
}
|
|
outbox := a.takeOutbox()
|
|
last := outbox[len(outbox)-1]
|
|
if err := m.deliverOnly(serverA, serverB, last.Event); err != nil {
|
|
t.Fatalf("delivering across the gap: %v", err)
|
|
}
|
|
|
|
if !slices.Contains(b.timelineIDs(t, roomID), last.Event.EventID()) {
|
|
t.Error("the event across the gap is not in B's timeline")
|
|
}
|
|
if sa, sb := a.currentState(t, roomID), b.currentState(t, roomID); !mapsEqual(sa, sb) {
|
|
t.Errorf("current state differs after the gap:\nA: %v\nB: %v", sa, sb)
|
|
}
|
|
}
|
|
|
|
func TestForgedEventIsRejected(t *testing.T) {
|
|
m := newMesh(t, serverA, serverB)
|
|
a, b := m.servers[serverA], m.servers[serverB]
|
|
roomID := a.publicRoom(t, userA)
|
|
if _, err := b.api.JoinRoom(t.Context(), roomserver.JoinRequest{RoomID: roomID, User: userB, ServerNames: []string{serverA}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
m.flush()
|
|
|
|
// An event in alice's name, signed by a key that is not A's.
|
|
exts, err := b.store.Extremities(t.Context(), roomID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
impostor, _ := signing.Generate("k_a")
|
|
room, _ := b.store.Room(t.Context(), roomID)
|
|
rules := mustRules(t, room.Version)
|
|
state := b.currentState(t, roomID)
|
|
authEvents := []string{state[event.TypePowerLevels+"|"], state[event.TypeMember+"|"+userA]}
|
|
if !rules.CreateEventExcludedFromAuthEvents {
|
|
authEvents = append(authEvents, state[event.TypeCreate+"|"])
|
|
}
|
|
forged, err := (&event.Builder{
|
|
RoomID: roomID, Type: "m.room.message", Sender: userA,
|
|
Content: json.RawMessage(`{"body":"not really alice"}`),
|
|
PrevEvents: []string{exts[0].EventID}, AuthEvents: authEvents, Depth: exts[0].Depth + 1,
|
|
}).Build(fixedNow(), impostor, rules)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := m.deliverOnly(serverA, serverB, forged); !errors.Is(err, roomserver.ErrBadEvent) {
|
|
t.Errorf("a forged event = %v, want ErrBadEvent", err)
|
|
}
|
|
if slices.Contains(b.timelineIDs(t, roomID), forged.EventID()) {
|
|
t.Error("the forged event is in the timeline")
|
|
}
|
|
}
|
|
|
|
func TestEventFailingOnlyAgainstCurrentStateIsSoftFailed(t *testing.T) {
|
|
m := newMesh(t, serverA, serverB)
|
|
a, b := m.servers[serverA], m.servers[serverB]
|
|
roomID := a.publicRoom(t, userA)
|
|
if _, err := b.api.JoinRoom(t.Context(), roomserver.JoinRequest{RoomID: roomID, User: userB, ServerNames: []string{serverA}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
m.flush()
|
|
|
|
// B's user speaks, and the message is held back; meanwhile A bans
|
|
// them, and the ban reaches B.
|
|
b.send(t, roomID, userB, "last words")
|
|
late := b.takeOutbox()[0].Event
|
|
if err := a.api.BanUser(t.Context(), roomID, userA, userB, "spam"); err != nil {
|
|
t.Fatalf("BanUser: %v", err)
|
|
}
|
|
if failures := m.flush(); len(failures) > 0 {
|
|
t.Fatalf("delivery failures: %v", failures)
|
|
}
|
|
|
|
// The message was valid when it was sent, and is not now.
|
|
if err := m.deliverOnly(serverB, serverA, late); err != nil {
|
|
t.Fatalf("a soft-failed event is not an error to its sender: %v", err)
|
|
}
|
|
stored, err := a.store.Event(t.Context(), roomID, late.EventID())
|
|
if err != nil {
|
|
t.Fatalf("the soft-failed event was not kept: %v", err)
|
|
}
|
|
if !stored.Outlier {
|
|
t.Error("the soft-failed event is not an outlier")
|
|
}
|
|
if slices.Contains(a.timelineIDs(t, roomID), late.EventID()) {
|
|
t.Error("the soft-failed event is in A's timeline")
|
|
}
|
|
}
|
|
|
|
func TestFederationSwitchRefusesRemoteWork(t *testing.T) {
|
|
m := newMesh(t, serverA, serverB)
|
|
a, b := m.servers[serverA], m.servers[serverB]
|
|
roomID := a.publicRoom(t, userA)
|
|
|
|
m.setDisabled(true)
|
|
if _, err := b.api.JoinRoom(t.Context(), roomserver.JoinRequest{RoomID: roomID, User: userB, ServerNames: []string{serverA}}); !errors.Is(err, roomserver.ErrFederationDisabled) {
|
|
t.Errorf("remote join while disabled = %v, want ErrFederationDisabled", err)
|
|
}
|
|
if err := a.api.InviteUser(t.Context(), roomserver.InviteRequest{RoomID: roomID, Sender: userA, Target: userB}); !errors.Is(err, roomserver.ErrFederationDisabled) {
|
|
t.Errorf("remote invite while disabled = %v, want ErrFederationDisabled", err)
|
|
}
|
|
if _, _, err := a.api.MakeJoin(t.Context(), serverB, roomID, userB, []string{"11"}); !errors.Is(err, roomserver.ErrFederationDisabled) {
|
|
t.Errorf("make_join while disabled = %v, want ErrFederationDisabled", err)
|
|
}
|
|
|
|
m.setDisabled(false)
|
|
if _, err := b.api.JoinRoom(t.Context(), roomserver.JoinRequest{RoomID: roomID, User: userB, ServerNames: []string{serverA}}); err != nil {
|
|
t.Errorf("remote join after re-enabling: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestThreeServersAgree(t *testing.T) {
|
|
m := newMesh(t, serverA, serverB, serverC)
|
|
a, b, c := m.servers[serverA], m.servers[serverB], m.servers[serverC]
|
|
roomID := a.publicRoom(t, userA)
|
|
|
|
if _, err := b.api.JoinRoom(t.Context(), roomserver.JoinRequest{RoomID: roomID, User: userB, ServerNames: []string{serverA}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
m.flush()
|
|
// C joins through B, which is not the room's creator: B relays the
|
|
// join to A.
|
|
if _, err := c.api.JoinRoom(t.Context(), roomserver.JoinRequest{RoomID: roomID, User: userC, ServerNames: []string{serverB}}); err != nil {
|
|
t.Fatalf("C joining through B: %v", err)
|
|
}
|
|
if failures := m.flush(); len(failures) > 0 {
|
|
t.Fatalf("delivery failures: %v", failures)
|
|
}
|
|
if got := a.membership(t, roomID, userC); got != event.MembershipJoin {
|
|
t.Fatalf("A did not hear of C's join through B's relay: %q", got)
|
|
}
|
|
|
|
a.send(t, roomID, userA2Join(t, a, roomID), "from A")
|
|
b.send(t, roomID, userB, "from B")
|
|
c.send(t, roomID, userC, "from C")
|
|
if failures := m.flush(); len(failures) > 0 {
|
|
t.Fatalf("delivery failures: %v", failures)
|
|
}
|
|
final := a.send(t, roomID, userA, "settle")
|
|
m.flush()
|
|
|
|
sa, sb, sc := a.currentState(t, roomID), b.currentState(t, roomID), c.currentState(t, roomID)
|
|
if !mapsEqual(sa, sb) || !mapsEqual(sa, sc) {
|
|
t.Errorf("the three servers disagree:\nA: %v\nB: %v\nC: %v", sa, sb, sc)
|
|
}
|
|
for _, p := range []*peer{a, b, c} {
|
|
if !slices.Contains(p.timelineIDs(t, roomID), final) {
|
|
t.Errorf("%s lacks the final message", p.name)
|
|
}
|
|
}
|
|
}
|
|
|
|
// userA2Join joins a second local user on A, so that local joins in a
|
|
// federated room are covered too, and returns them.
|
|
func userA2Join(t *testing.T, a *peer, roomID string) string {
|
|
t.Helper()
|
|
if _, err := a.api.JoinRoom(t.Context(), roomserver.JoinRequest{RoomID: roomID, User: userA2}); err != nil {
|
|
t.Fatalf("local join in a federated room: %v", err)
|
|
}
|
|
return userA2
|
|
}
|
|
|
|
func TestRemoteAliasAndDirectory(t *testing.T) {
|
|
m := newMesh(t, serverA, serverB)
|
|
a, b := m.servers[serverA], m.servers[serverB]
|
|
resp, err := a.api.CreateRoom(t.Context(), roomserver.CreateRoomRequest{
|
|
Creator: userA, Preset: roomserver.PresetPublicChat, Visibility: roomserver.VisibilityPublic,
|
|
RoomAliasName: "lobby", Name: "Lobby",
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
resolved, err := b.api.ResolveAlias(t.Context(), "#lobby:"+serverA)
|
|
if err != nil {
|
|
t.Fatalf("ResolveAlias: %v", err)
|
|
}
|
|
if resolved.RoomID != resp.RoomID || !slices.Contains(resolved.Servers, serverA) {
|
|
t.Errorf("ResolveAlias = %+v", resolved)
|
|
}
|
|
if _, err := b.api.ResolveAlias(t.Context(), "#nothing:"+serverA); !errors.Is(err, roomserver.ErrNoSuchAlias) {
|
|
t.Errorf("unknown remote alias = %v, want ErrNoSuchAlias", err)
|
|
}
|
|
|
|
dir, err := b.api.PublicRooms(t.Context(), roomserver.PublicRoomsRequest{Server: serverA})
|
|
if err != nil {
|
|
t.Fatalf("PublicRooms: %v", err)
|
|
}
|
|
if len(dir.Chunk) != 1 || dir.Chunk[0].RoomID != resp.RoomID || dir.Chunk[0].Name != "Lobby" {
|
|
t.Errorf("remote directory = %+v", dir)
|
|
}
|
|
}
|
|
|
|
func TestServersOutsideARoomCannotReadIt(t *testing.T) {
|
|
m := newMesh(t, serverA, serverB)
|
|
a := m.servers[serverA]
|
|
resp, err := a.api.CreateRoom(t.Context(), roomserver.CreateRoomRequest{Creator: userA, Preset: roomserver.PresetPrivateChat})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
exts, err := a.store.Extremities(t.Context(), resp.RoomID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := a.api.EventForServer(t.Context(), serverB, exts[0].EventID); !errors.Is(err, roomserver.ErrForbidden) {
|
|
t.Errorf("/event from outside = %v, want ErrForbidden", err)
|
|
}
|
|
if _, _, err := a.api.StateIDsForServer(t.Context(), serverB, resp.RoomID, exts[0].EventID); !errors.Is(err, roomserver.ErrForbidden) {
|
|
t.Errorf("/state_ids from outside = %v, want ErrForbidden", err)
|
|
}
|
|
if _, err := a.api.BackfillForServer(t.Context(), serverB, resp.RoomID, []string{exts[0].EventID}, 10); !errors.Is(err, roomserver.ErrForbidden) {
|
|
t.Errorf("/backfill from outside = %v, want ErrForbidden", err)
|
|
}
|
|
}
|
|
|
|
func TestBackfillWalksBackwards(t *testing.T) {
|
|
m := newMesh(t, serverA, serverB)
|
|
a, b := m.servers[serverA], m.servers[serverB]
|
|
roomID := a.publicRoom(t, userA)
|
|
if _, err := b.api.JoinRoom(t.Context(), roomserver.JoinRequest{RoomID: roomID, User: userB, ServerNames: []string{serverA}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var sent []string
|
|
for i := range 5 {
|
|
sent = append(sent, a.send(t, roomID, userA, fmt.Sprint(i)))
|
|
}
|
|
events, err := a.api.BackfillForServer(t.Context(), serverB, roomID, []string{sent[4]}, 3)
|
|
if err != nil {
|
|
t.Fatalf("BackfillForServer: %v", err)
|
|
}
|
|
if len(events) != 3 {
|
|
t.Fatalf("backfill returned %d events, want 3", len(events))
|
|
}
|
|
var got []string
|
|
for _, raw := range events {
|
|
var probe struct {
|
|
Content struct {
|
|
Body string `json:"body"`
|
|
} `json:"content"`
|
|
}
|
|
_ = json.Unmarshal(raw, &probe)
|
|
got = append(got, probe.Content.Body)
|
|
}
|
|
if !slices.Equal(got, []string{"4", "3", "2"}) {
|
|
t.Errorf("backfill bodies = %v, want newest first from the starting event", got)
|
|
}
|
|
}
|
|
|
|
func mapsEqual(a, b map[string]string) bool {
|
|
if len(a) != len(b) {
|
|
return false
|
|
}
|
|
for k, v := range a {
|
|
if b[k] != v {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func eventIDsOf(events []storage.StoredEvent) []string {
|
|
out := make([]string, 0, len(events))
|
|
for _, e := range events {
|
|
out = append(out, e.EventID)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func ptrTo[T any](v T) *T { return &v }
|
|
|
|
func mustRules(t *testing.T, version string) roomversion.Rules {
|
|
t.Helper()
|
|
rules, err := roomversion.Get(roomversion.Version(version))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return rules
|
|
}
|
|
|
|
func fixedNow() time.Time { return time.Unix(1_700_000_000, 0) }
|
|
|
|
func TestServerACLKeepsAServerOut(t *testing.T) {
|
|
m := newMesh(t, serverA, serverB, serverC)
|
|
a, b, c := m.servers[serverA], m.servers[serverB], m.servers[serverC]
|
|
roomID := a.publicRoom(t, userA)
|
|
for _, p := range []*peer{b, c} {
|
|
user := userB
|
|
if p == c {
|
|
user = userC
|
|
}
|
|
if _, err := p.api.JoinRoom(t.Context(), roomserver.JoinRequest{RoomID: roomID, User: user, ServerNames: []string{serverA}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
m.flush()
|
|
|
|
if _, err := a.api.SendEvent(t.Context(), roomserver.SendEventRequest{
|
|
RoomID: roomID, Sender: userA, Type: roomserver.TypeServerACL, StateKey: ptrTo(""),
|
|
Content: json.RawMessage(`{"allow":["*"],"deny":["` + serverC + `"]}`),
|
|
}); err != nil {
|
|
t.Fatalf("setting the ACL: %v", err)
|
|
}
|
|
if failures := m.flush(); len(failures) > 0 {
|
|
t.Fatalf("delivery failures: %v", failures)
|
|
}
|
|
|
|
c.send(t, roomID, userC, "from a denied server")
|
|
failures := m.flush()
|
|
if len(failures) == 0 {
|
|
t.Fatal("the denied server's event was accepted")
|
|
}
|
|
for _, err := range failures {
|
|
if !errors.Is(err, roomserver.ErrForbidden) {
|
|
t.Errorf("refusal = %v, want ErrForbidden", err)
|
|
}
|
|
}
|
|
if _, _, err := a.api.MakeJoin(t.Context(), serverC, roomID, "@other:"+serverC, []string{"11", "12", "10"}); !errors.Is(err, roomserver.ErrForbidden) {
|
|
t.Errorf("make_join from a denied server = %v, want ErrForbidden", err)
|
|
}
|
|
|
|
// B, which the ACL allows, is unaffected.
|
|
b.send(t, roomID, userB, "from an allowed server")
|
|
if failures := m.flush(); len(failures) > 0 {
|
|
t.Errorf("an allowed server's event was refused: %v", failures)
|
|
}
|
|
}
|
|
|
|
func (m *mesh) setDisabled(disabled bool) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.disabled = disabled
|
|
}
|
|
|
|
func TestFederatedSpaceHierarchy(t *testing.T) {
|
|
m := newMesh(t, serverA, serverB)
|
|
a, b := m.servers[serverA], m.servers[serverB]
|
|
ctx := t.Context()
|
|
|
|
space, err := a.api.CreateRoom(ctx, roomserver.CreateRoomRequest{
|
|
Creator: userA, Preset: roomserver.PresetPublicChat, Name: "Space",
|
|
CreationContent: json.RawMessage(`{"type":"m.space"}`),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateRoom space: %v", err)
|
|
}
|
|
public := a.publicRoom(t, userA)
|
|
private, err := a.api.CreateRoom(ctx, roomserver.CreateRoomRequest{Creator: userA, Name: "Private"})
|
|
if err != nil {
|
|
t.Fatalf("CreateRoom private: %v", err)
|
|
}
|
|
for _, child := range []string{public, private.RoomID} {
|
|
key := child
|
|
if _, err := a.api.SendEvent(ctx, roomserver.SendEventRequest{
|
|
RoomID: space.RoomID, Sender: userA, Type: roomserver.TypeSpaceChild, StateKey: &key,
|
|
Content: json.RawMessage(`{"via":["` + serverA + `"]}`),
|
|
}); err != nil {
|
|
t.Fatalf("add child: %v", err)
|
|
}
|
|
}
|
|
a.takeOutbox()
|
|
|
|
if _, err := b.api.JoinRoom(ctx, roomserver.JoinRequest{
|
|
RoomID: space.RoomID, User: userB, ServerNames: []string{serverA},
|
|
}); err != nil {
|
|
t.Fatalf("remote join: %v", err)
|
|
}
|
|
if failures := m.flush(); len(failures) > 0 {
|
|
t.Fatalf("delivery failures: %v", failures)
|
|
}
|
|
|
|
// B holds the space itself and asks A about the rooms it is not in:
|
|
// the public one comes back, the private one is A's to keep.
|
|
page, err := b.api.Hierarchy(ctx, roomserver.HierarchyRequest{RoomID: space.RoomID, User: userB, MaxDepth: -1})
|
|
if err != nil {
|
|
t.Fatalf("Hierarchy: %v", err)
|
|
}
|
|
var got []string
|
|
for _, room := range page.Rooms {
|
|
got = append(got, room.RoomID)
|
|
}
|
|
if len(got) != 2 || got[0] != space.RoomID || got[1] != public {
|
|
t.Fatalf("B's walk of the space = %v, want the space and the public room", got)
|
|
}
|
|
if page.Rooms[1].Name != "Federated" || page.Rooms[1].NumJoinedMembers != 1 {
|
|
t.Errorf("the remote child = %+v", page.Rooms[1])
|
|
}
|
|
|
|
answer, err := a.api.FederatedHierarchy(ctx, serverB, space.RoomID, false)
|
|
if err != nil {
|
|
t.Fatalf("FederatedHierarchy: %v", err)
|
|
}
|
|
if len(answer.InaccessibleChildren) != 1 || answer.InaccessibleChildren[0] != private.RoomID {
|
|
t.Errorf("inaccessible_children = %v, want the private room", answer.InaccessibleChildren)
|
|
}
|
|
if _, err := a.api.FederatedHierarchy(ctx, serverB, private.RoomID, false); !errors.Is(err, roomserver.ErrNoSuchRoom) {
|
|
t.Errorf("FederatedHierarchy of the private room = %v, want ErrNoSuchRoom", err)
|
|
}
|
|
}
|