Files
Warp/server/internal/roomserver/federated_test.go
T
tajniak81andClaude Opus 5.5 6aaf554336
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
Phase 5: federation
Implements PLAN.md Phase 5: the Server-Server API. Warp joins rooms on
other servers and lets their users join its rooms, invites across servers
with the invitee's server countersigning, rejects remote invites (locally
if the inviter is unreachable), and exchanges events, typing, receipts and
to-device messages with other homeservers — all behind the runtime switch,
which now starts and stops real outbound traffic.

The protocol lives in internal/federation with no knowledge of rooms:
X-Matrix signing (fuzzed parser), the spec's discovery algorithm keeping
the dial address and the TLS name apart, a signed client that refuses
every request while the switch is off or the policy denies the
destination, and a key ring that caches other servers' keys for at most a
week, with optional notaries.

The roomserver takes events from elsewhere through the spec's intake:
signatures and hashes (a hash mismatch keeps the redacted form), the rules
against the event's own auth events, against the state before it — with
/get_missing_events for small gaps and /state_ids for wide ones — and
against the current state, where a failure is a soft failure. Events held
without their state are outliers: a column on roomserver_events, because
the current state of a room joined over federation is almost all
outliers, and excluded from every timeline query. Forks are resolved at
the room's head. Membership across servers is make_join/send_join,
make_leave/send_leave and countersigned invites, in both directions;
m.room.server_acl is enforced on everything another server sends.
History before a remote join is backfilled when /messages pages past it,
stored at negative stream positions.

internal/federationapi is the running component: the inbound endpoints,
and an outbound queue that persists what it owes each destination, sends
it in transactions of 50 PDUs and 100 EDUs, backs off from ten seconds to
six hours, survives restarts, and drops events persisted while federation
is off rather than delivering a backlog in one burst. Its Module is what
the switch starts and stops.

Also: a TLS listener for port 8448, trusted key servers, key validity and
an outbound CA bundle in the config; per-destination health and backoff
reset in the admin API; remote profiles, aliases and directories in the
Client-Server API; a Complement federation profile. See
docs/federation.md, including its known gaps (no partial-state joins, no
key rotation).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 17:14:20 +02:00

1055 lines
35 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) 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
}