Files
Warp/server/internal/matrix/auth/auth_test.go
T
tajniak81andClaude Opus 5.5 2fbd5f53ce
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 2: the event core
Implements PLAN.md Phase 2: the library every later phase is built on,
as pure code under server/internal/matrix with no HTTP and no storage.

canonicaljson: the canonical JSON encoding, hand-written because
encoding/json escapes <, > and & and accepts floats. Integers only, in
[-(2^53)+1, 2^53-1]; lone surrogate escapes rejected rather than turned
into U+FFFD.

signing: the Signing JSON algorithm alongside the existing key handling,
and one lenient base64 decoder shared by the packages above it.

id: identifier grammars as they appear inside events, deliberately laxer
than userapi's account rules because rooms carry historical user IDs.

roomversion: the registry. Versions 3 to 12 are supported, each written
out in full as a Rules value with the flags the other packages read.
Versions 1 and 2 are known but refused: their event format and state
resolution v1 wait for federation. createRoom will default to 11, as the
spec asks for a while after 1.16, with 12 available.

event: PDU parsing and validation, content and reference hashes, event
IDs, the redaction algorithm for every supported version, signing and
verification, and a Builder.

auth: the authorisation rules in spec order, power levels with their
defaults, v12 creators with infinite power, the auth events selection
algorithm, rule 3's auth_events checks, and the separate test for
whether a redaction applies. Comments use the room version 12 rule
numbering, with the version 11 numbers given where they differ.

stateres: state resolution v2, and version 12's amendment (an empty
starting state and the conflicted state subgraph).

Tested against the spec's own vectors byte for byte: the canonical JSON
examples, both JSON signing vectors and both event signing vectors.
State resolution ports scenarios from Synapse's suite. canonicaljson
and event.Parse have fuzz targets.

The capabilities endpoint now advertises the registry instead of a
hard-coded "11". docs/event-core.md describes the packages and the
known gaps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 22:23:35 +02:00

1593 lines
53 KiB
Go

package auth
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/dariuszjaniak/warp/server/internal/matrix/canonicaljson"
"github.com/dariuszjaniak/warp/server/internal/matrix/event"
"github.com/dariuszjaniak/warp/server/internal/matrix/roomversion"
"github.com/dariuszjaniak/warp/server/internal/matrix/signing"
)
// The users the tests below use. Alice creates every room; dave is on
// another server, which matters for the m.federate rule.
const (
alice = "@alice:warp.test"
bob = "@bob:warp.test"
carol = "@carol:warp.test"
dave = "@dave:other.test"
)
func ptr[T any](v T) *T { return &v }
// roomCounter gives every test room a distinct ID. Two rooms that shared
// one would make the rule about auth events from another room untestable,
// and would hide a real confusion if the code ever stopped checking it.
var roomCounter atomic.Int64
func mustJSON(t *testing.T, v any) json.RawMessage {
t.Helper()
raw, err := canonicaljson.Marshal(v)
if err != nil {
t.Fatalf("encode test content: %v", err)
}
return raw
}
// room drives a room forward one event at a time, filling in the
// prev_events, auth_events and depth that the rules expect so that the
// tests can say only what they are actually about.
type room struct {
t *testing.T
rules roomversion.Rules
key *signing.KeyPair
roomID string
state State
prev []string
depth int64
clock int64
}
// newRoom creates a room and nothing else: the create event is in state,
// but nobody has joined yet.
func newRoom(t *testing.T, v roomversion.Version, createContent map[string]any) *room {
t.Helper()
key, err := signing.Generate("test")
if err != nil {
t.Fatalf("generate a signing key: %v", err)
}
r := &room{
t: t,
rules: roomversion.MustGet(v),
key: key,
state: State{},
depth: 1,
clock: 1700000000000,
}
content := map[string]any{"room_version": string(v)}
if r.rules.CreateEventRequiresCreator {
content["creator"] = alice
}
for k, value := range createContent {
content[k] = value
}
if !r.rules.RoomIDIsCreateEventID {
r.roomID = fmt.Sprintf("!room%d:warp.test", roomCounter.Add(1))
}
create := r.build(event.Builder{
Type: event.TypeCreate,
Sender: alice,
StateKey: ptr(""),
Content: mustJSON(t, content),
})
if err := Check(context.Background(), create, r.state, nil); err != nil {
t.Fatalf("the create event should be allowed: %v", err)
}
if r.rules.RoomIDIsCreateEventID {
if r.roomID, err = event.RoomIDOfCreateEvent(create); err != nil {
t.Fatalf("derive the room ID: %v", err)
}
}
r.add(create)
return r
}
// newJoinedRoom is newRoom plus Alice's join, which is where most tests
// want to start.
func newJoinedRoom(t *testing.T, v roomversion.Version) *room {
t.Helper()
r := newRoom(t, v, nil)
r.mustSend(event.Builder{
Type: event.TypeMember,
Sender: alice,
StateKey: ptr(alice),
Content: mustJSON(t, map[string]any{"membership": "join"}),
})
return r
}
// build fills in everything about an event that follows from the room
// rather than from the test.
func (r *room) build(b event.Builder) *event.Event {
r.t.Helper()
derivesRoomID := r.rules.RoomIDIsCreateEventID && b.Type == event.TypeCreate
if b.RoomID == "" && !derivesRoomID {
b.RoomID = r.roomID
}
if b.PrevEvents == nil && b.Type != event.TypeCreate {
b.PrevEvents = r.prev
}
if len(b.Content) == 0 {
b.Content = json.RawMessage("{}")
}
if b.AuthEvents == nil && b.Type != event.TypeCreate {
selected, err := SelectAuthEvents(r.rules, b.Type, b.Sender, b.StateKey, b.Content, r.state)
if err != nil {
r.t.Fatalf("select auth events for %s: %v", b.Type, err)
}
for _, e := range selected {
b.AuthEvents = append(b.AuthEvents, e.EventID())
}
}
if b.Depth == 0 {
b.Depth = r.depth
}
r.clock++
e, err := b.Build(time.UnixMilli(r.clock), r.key, r.rules)
if err != nil {
r.t.Fatalf("build a %s event: %v", b.Type, err)
}
return e
}
// check builds an event and runs the rules over it without adding it to
// the room.
func (r *room) check(b event.Builder) error {
r.t.Helper()
return Check(context.Background(), r.build(b), r.state, nil)
}
// checkWith is check with a Verifier, for the restricted join rule.
func (r *room) checkWith(b event.Builder, verifier Verifier) error {
r.t.Helper()
return Check(context.Background(), r.build(b), r.state, verifier)
}
// mustSend requires an event to be allowed and adds it to the room.
func (r *room) mustSend(b event.Builder) *event.Event {
r.t.Helper()
e := r.build(b)
if err := Check(context.Background(), e, r.state, nil); err != nil {
r.t.Fatalf("a %s event from %s should be allowed: %v", b.Type, b.Sender, err)
}
r.add(e)
return e
}
// add puts an event into the room's state and makes it the latest event.
func (r *room) add(e *event.Event) {
if sk, ok := e.StateKey(); ok {
r.state[StateKey{Type: e.Type(), StateKey: sk}] = e
}
r.prev = []string{e.EventID()}
r.depth++
}
// setPowerLevels sends a power levels event as Alice, who can always send
// the first one.
func (r *room) setPowerLevels(content map[string]any) *event.Event {
r.t.Helper()
if r.rules.CreatorsHaveInfinitePower {
// From room version 12 naming a creator here is a rejection in
// itself, and there is no need to: their level is already above
// anything the event could say. Dropping them lets the tests that
// run against every version write one power levels event.
content = withoutCreators(r.t, content, r.state.Create())
}
return r.mustSend(event.Builder{
Type: event.TypePowerLevels,
Sender: alice,
StateKey: ptr(""),
Content: mustJSON(r.t, content),
})
}
// withoutCreators copies content with the room's creators removed from
// the users map.
func withoutCreators(t *testing.T, content map[string]any, create *event.Event) map[string]any {
t.Helper()
users, ok := content["users"].(map[string]any)
if !ok {
return content
}
creators, err := Creators(create)
if err != nil {
t.Fatalf("read the room's creators: %v", err)
}
trimmed := map[string]any{}
for k, v := range users {
trimmed[k] = v
}
for _, creator := range creators {
delete(trimmed, creator)
}
out := map[string]any{}
for k, v := range content {
out[k] = v
}
out["users"] = trimmed
return out
}
func (r *room) setJoinRule(rule string, extra map[string]any) *event.Event {
r.t.Helper()
content := map[string]any{"join_rule": rule}
for k, v := range extra {
content[k] = v
}
return r.mustSend(event.Builder{
Type: event.TypeJoinRules,
Sender: alice,
StateKey: ptr(""),
Content: mustJSON(r.t, content),
})
}
func (r *room) member(sender, target, membership string, extra map[string]any) event.Builder {
content := map[string]any{"membership": membership}
for k, v := range extra {
content[k] = v
}
return event.Builder{
Type: event.TypeMember,
Sender: sender,
StateKey: ptr(target),
Content: mustJSON(r.t, content),
}
}
// message is a plain message event, the thing rule 6 and rule 8 guard.
func (r *room) message(sender string) event.Builder {
return event.Builder{
Type: "m.room.message",
Sender: sender,
Content: mustJSON(r.t, map[string]any{"body": "hello", "msgtype": "m.text"}),
}
}
// wantRejected asserts that err is a rejection mentioning want.
func wantRejected(t *testing.T, err error, want string) {
t.Helper()
if err == nil {
t.Fatalf("the event was allowed; expected a rejection mentioning %q", want)
}
if !errors.Is(err, ErrRejected) {
t.Fatalf("error = %v, want it to wrap ErrRejected", err)
}
if want != "" && !strings.Contains(err.Error(), want) {
t.Errorf("rejection = %q, want it to mention %q", err, want)
}
}
func wantAllowed(t *testing.T, err error) {
t.Helper()
if err != nil {
t.Fatalf("the event should have been allowed: %v", err)
}
}
// supportedVersions is every version the tests run the shared rules
// against, so that a rule that changed by accident in one version shows up.
func supportedVersions() []roomversion.Version { return roomversion.Supported() }
// --- Rule 1: m.room.create ---
func TestCreateEventRules(t *testing.T) {
t.Run("a create event with prev_events is rejected", func(t *testing.T) {
r := newRoom(t, roomversion.V11, nil)
e := r.build(event.Builder{
Type: event.TypeCreate,
Sender: alice,
StateKey: ptr(""),
RoomID: "!other:warp.test",
PrevEvents: []string{"$something"},
Content: mustJSON(t, map[string]any{"room_version": "11"}),
})
wantRejected(t, Check(context.Background(), e, State{}, nil), "no prev_events")
})
t.Run("the room_id domain must match the sender", func(t *testing.T) {
r := newRoom(t, roomversion.V11, nil)
e := r.build(event.Builder{
Type: event.TypeCreate,
Sender: alice,
StateKey: ptr(""),
RoomID: "!room:elsewhere.test",
Content: mustJSON(t, map[string]any{"room_version": "11"}),
})
wantRejected(t, Check(context.Background(), e, State{}, nil), "does not match the sender")
})
t.Run("an unrecognised room version is rejected", func(t *testing.T) {
r := newRoom(t, roomversion.V11, nil)
e := r.build(event.Builder{
Type: event.TypeCreate,
Sender: alice,
StateKey: ptr(""),
RoomID: "!room:warp.test",
Content: mustJSON(t, map[string]any{"room_version": "99-not-real"}),
})
wantRejected(t, Check(context.Background(), e, State{}, nil), "not one this server recognises")
})
t.Run("a room version 1 room is rejected because we cannot join it", func(t *testing.T) {
r := newRoom(t, roomversion.V11, nil)
e := r.build(event.Builder{
Type: event.TypeCreate,
Sender: alice,
StateKey: ptr(""),
RoomID: "!room:warp.test",
Content: mustJSON(t, map[string]any{"room_version": "1"}),
})
wantRejected(t, Check(context.Background(), e, State{}, nil), "not one this server recognises")
})
t.Run("version 10 requires a creator", func(t *testing.T) {
r := newRoom(t, roomversion.V10, nil)
e := r.build(event.Builder{
Type: event.TypeCreate,
Sender: alice,
StateKey: ptr(""),
RoomID: "!room:warp.test",
Content: mustJSON(t, map[string]any{"room_version": "10"}),
})
wantRejected(t, Check(context.Background(), e, State{}, nil), "must have a creator")
})
t.Run("version 11 does not require a creator", func(t *testing.T) {
newRoom(t, roomversion.V11, nil) // newRoom checks the create event
})
t.Run("version 12 rejects additional_creators that are not user IDs", func(t *testing.T) {
r := newRoom(t, roomversion.V12, nil)
e := r.build(event.Builder{
Type: event.TypeCreate,
Sender: alice,
StateKey: ptr(""),
Content: mustJSON(t, map[string]any{"room_version": "12", "additional_creators": []string{"not-a-user"}}),
})
wantRejected(t, Check(context.Background(), e, State{}, nil), "additional_creators")
})
t.Run("version 11 ignores additional_creators", func(t *testing.T) {
// The property has no meaning before version 12, so it is content
// like any other and must not be validated.
newRoom(t, roomversion.V11, map[string]any{"additional_creators": []string{"not-a-user"}})
})
}
// --- Rule 2 (room version 12): the room ID is the create event ---
func TestVersion12RoomIDMustMatchCreateEvent(t *testing.T) {
r := newJoinedRoom(t, roomversion.V12)
wrong := r.build(event.Builder{
Type: "m.room.message",
Sender: alice,
RoomID: "!NotTheCreateEventsID",
Content: mustJSON(t, map[string]any{"body": "hi"}),
})
wantRejected(t, Check(context.Background(), wrong, r.state, nil), "is not the ID of this room")
}
// --- Rule 3: auth_events ---
func TestCheckAuthEvents(t *testing.T) {
r := newJoinedRoom(t, roomversion.V11)
r.setPowerLevels(map[string]any{"users": map[string]any{alice: 100}})
authFor := func(e *event.Event) []*event.Event {
var out []*event.Event
for _, want := range e.AuthEventIDs() {
for _, candidate := range r.state {
if candidate.EventID() == want {
out = append(out, candidate)
}
}
}
return out
}
t.Run("a well-formed event passes", func(t *testing.T) {
e := r.build(r.message(alice))
if err := CheckAuthEvents(e, authFor(e)); err != nil {
t.Errorf("CheckAuthEvents: %v", err)
}
})
t.Run("a create event must cite nothing", func(t *testing.T) {
create := r.state.Create()
if err := CheckAuthEvents(create, nil); err != nil {
t.Errorf("CheckAuthEvents on the create event: %v", err)
}
})
t.Run("two events for one piece of state are rejected", func(t *testing.T) {
// Not the same event twice: two *different* member events for
// Alice, which leaves it ambiguous which one authorised this.
second := r.build(r.member(alice, alice, "join", map[string]any{"displayname": "Alice again"}))
e := r.build(r.message(alice))
wantRejected(t, CheckAuthEvents(e, append(authFor(e), second)), "two entries")
})
t.Run("an entry the selection algorithm does not ask for is rejected", func(t *testing.T) {
joinRules := r.setJoinRule(JoinRulePublic, nil)
e := r.build(r.message(alice))
// A message event never needs the join rules.
wantRejected(t, CheckAuthEvents(e, append(authFor(e), joinRules)),
"the auth events selection algorithm does not ask for")
})
t.Run("an entry that resolved to nothing is rejected", func(t *testing.T) {
e := r.build(r.message(alice))
auth := authFor(e)
for i, a := range auth {
if a.Type() != event.TypeCreate {
auth[i] = nil // a storage lookup that found nothing
break
}
}
wantRejected(t, CheckAuthEvents(e, auth), "missing or were themselves rejected")
})
t.Run("a missing create event is rejected", func(t *testing.T) {
e := r.build(r.message(alice))
var withoutCreate []*event.Event
for _, auth := range authFor(e) {
if auth.Type() != event.TypeCreate {
withoutCreate = append(withoutCreate, auth)
}
}
wantRejected(t, CheckAuthEvents(e, withoutCreate), "no m.room.create event")
})
t.Run("an auth event from another room is rejected", func(t *testing.T) {
other := newJoinedRoom(t, roomversion.V11)
e := r.build(r.message(alice))
auth := authFor(e)
for i, a := range auth {
if a.Type() == event.TypeMember {
auth[i] = other.state.Member(alice)
}
}
wantRejected(t, CheckAuthEvents(e, auth), "belongs to room")
})
}
func TestAuthEventKeys(t *testing.T) {
rules := roomversion.MustGet(roomversion.V11)
t.Run("a message event", func(t *testing.T) {
keys, err := AuthEventKeysFor(rules, "m.room.message", alice, nil, json.RawMessage(`{}`))
if err != nil {
t.Fatal(err)
}
want := []StateKey{
{Type: event.TypeCreate},
{Type: event.TypePowerLevels},
{Type: event.TypeMember, StateKey: alice},
}
if fmt.Sprint(keys) != fmt.Sprint(want) {
t.Errorf("keys = %v, want %v", keys, want)
}
})
t.Run("a join also needs the join rules", func(t *testing.T) {
keys, err := AuthEventKeysFor(rules, event.TypeMember, bob, ptr(bob),
json.RawMessage(`{"membership":"join"}`))
if err != nil {
t.Fatal(err)
}
// The sender and target are the same user, so their member event
// is cited once, not twice.
want := []StateKey{
{Type: event.TypeCreate},
{Type: event.TypePowerLevels},
{Type: event.TypeMember, StateKey: bob},
{Type: event.TypeJoinRules},
}
if fmt.Sprint(keys) != fmt.Sprint(want) {
t.Errorf("keys = %v, want %v", keys, want)
}
})
t.Run("a restricted join cites the authorising user", func(t *testing.T) {
keys, err := AuthEventKeysFor(rules, event.TypeMember, bob, ptr(bob),
json.RawMessage(`{"membership":"join","join_authorised_via_users_server":"`+carol+`"}`))
if err != nil {
t.Fatal(err)
}
if !containsKey(keys, StateKey{Type: event.TypeMember, StateKey: carol}) {
t.Errorf("keys = %v, want the authorising user's member event", keys)
}
})
t.Run("a third-party invite cites the invite event", func(t *testing.T) {
keys, err := AuthEventKeysFor(rules, event.TypeMember, alice, ptr(bob),
json.RawMessage(`{"membership":"invite","third_party_invite":{"signed":{"token":"tok"}}}`))
if err != nil {
t.Fatal(err)
}
if !containsKey(keys, StateKey{Type: event.TypeThirdPartyInvite, StateKey: "tok"}) {
t.Errorf("keys = %v, want the third-party invite event", keys)
}
})
t.Run("a create event cites nothing", func(t *testing.T) {
keys, err := AuthEventKeysFor(rules, event.TypeCreate, alice, ptr(""), json.RawMessage(`{}`))
if err != nil {
t.Fatal(err)
}
if len(keys) != 0 {
t.Errorf("keys = %v, want none", keys)
}
})
t.Run("version 12 leaves out the create event", func(t *testing.T) {
keys, err := AuthEventKeysFor(roomversion.MustGet(roomversion.V12),
"m.room.message", alice, nil, json.RawMessage(`{}`))
if err != nil {
t.Fatal(err)
}
if containsKey(keys, StateKey{Type: event.TypeCreate}) {
t.Errorf("keys = %v, want no create event in a version 12 room", keys)
}
})
}
func containsKey(keys []StateKey, want StateKey) bool {
for _, k := range keys {
if k == want {
return true
}
}
return false
}
// --- Rule 4: m.federate ---
func TestFederateFalseKeepsOtherServersOut(t *testing.T) {
r := newRoom(t, roomversion.V11, map[string]any{"m.federate": false})
r.mustSend(r.member(alice, alice, "join", nil))
r.setJoinRule(JoinRulePublic, nil)
wantRejected(t, r.check(r.member(dave, dave, "join", nil)), "does not federate")
wantAllowed(t, r.check(r.member(bob, bob, "join", nil)))
}
func TestFederateTrueLetsOtherServersIn(t *testing.T) {
r := newRoom(t, roomversion.V11, map[string]any{"m.federate": true})
r.mustSend(r.member(alice, alice, "join", nil))
r.setJoinRule(JoinRulePublic, nil)
wantAllowed(t, r.check(r.member(dave, dave, "join", nil)))
}
// --- Rule 5.3: joining ---
func TestJoinRules(t *testing.T) {
for _, v := range supportedVersions() {
t.Run(string(v), func(t *testing.T) {
t.Run("public lets anybody in", func(t *testing.T) {
r := newJoinedRoom(t, v)
r.setJoinRule(JoinRulePublic, nil)
wantAllowed(t, r.check(r.member(bob, bob, "join", nil)))
})
t.Run("invite keeps the uninvited out", func(t *testing.T) {
r := newJoinedRoom(t, v)
r.setJoinRule(JoinRuleInvite, nil)
wantRejected(t, r.check(r.member(bob, bob, "join", nil)), "has not been invited")
})
t.Run("an invited user may join", func(t *testing.T) {
r := newJoinedRoom(t, v)
r.setJoinRule(JoinRuleInvite, nil)
r.mustSend(r.member(alice, bob, "invite", nil))
wantAllowed(t, r.check(r.member(bob, bob, "join", nil)))
})
t.Run("a banned user may not join a public room", func(t *testing.T) {
r := newJoinedRoom(t, v)
r.setPowerLevels(map[string]any{"users": map[string]any{alice: 100}})
r.setJoinRule(JoinRulePublic, nil)
r.mustSend(r.member(alice, bob, "ban", nil))
wantRejected(t, r.check(r.member(bob, bob, "join", nil)), "banned")
})
t.Run("you cannot join on somebody else's behalf", func(t *testing.T) {
r := newJoinedRoom(t, v)
r.setJoinRule(JoinRulePublic, nil)
wantRejected(t, r.check(r.member(alice, bob, "join", nil)), "on their behalf")
})
t.Run("a room with no join rules event is invite only", func(t *testing.T) {
r := newJoinedRoom(t, v)
wantRejected(t, r.check(r.member(bob, bob, "join", nil)), "has not been invited")
})
t.Run("an unknown join rule keeps people out", func(t *testing.T) {
r := newJoinedRoom(t, v)
r.setJoinRule("com.example.secret_handshake", nil)
wantRejected(t, r.check(r.member(bob, bob, "join", nil)), "does not allow joining")
})
})
}
}
// TestCreatorJoinIsAllowedWithNoState covers rule 5.3.1, the join that has
// nothing but the create event behind it.
func TestCreatorJoinIsAllowedWithNoState(t *testing.T) {
for _, v := range supportedVersions() {
t.Run(string(v), func(t *testing.T) {
r := newRoom(t, v, nil)
// The room is invite-only by default and Alice has not been
// invited, so only the special case can allow this.
wantAllowed(t, r.check(r.member(alice, alice, "join", nil)))
})
}
}
func TestCreatorJoinSpecialCaseIsNarrow(t *testing.T) {
r := newRoom(t, roomversion.V11, nil)
t.Run("it does not apply to another user", func(t *testing.T) {
wantRejected(t, r.check(r.member(bob, bob, "join", nil)), "has not been invited")
})
t.Run("it does not apply once there is another event", func(t *testing.T) {
r := newJoinedRoom(t, roomversion.V11)
r.mustSend(r.member(alice, alice, "leave", nil))
// Alice has left; her join must now go through the join rules like
// anybody else's, and the room is invite-only.
wantRejected(t, r.check(r.member(alice, alice, "join", nil)), "has not been invited")
})
}
// --- Rule 5.3.5: restricted rooms ---
type fakeVerifier struct{ err error }
func (f fakeVerifier) VerifyEventSignature(context.Context, string, *event.Event) error {
return f.err
}
func TestRestrictedJoinRule(t *testing.T) {
setup := func(t *testing.T) *room {
t.Helper()
r := newJoinedRoom(t, roomversion.V11)
r.setPowerLevels(map[string]any{"users": map[string]any{alice: 100}, "invite": 50})
r.setJoinRule(JoinRuleRestricted, map[string]any{
"allow": []any{map[string]any{"type": "m.room_membership", "room_id": "!space:warp.test"}},
})
return r
}
t.Run("an unauthorised join is rejected", func(t *testing.T) {
r := setup(t)
wantRejected(t, r.check(r.member(bob, bob, "join", nil)), "not authorised by any user")
})
t.Run("a join authorised by a member who can invite is allowed", func(t *testing.T) {
r := setup(t)
wantAllowed(t, r.checkWith(
r.member(bob, bob, "join", map[string]any{"join_authorised_via_users_server": alice}),
fakeVerifier{}))
})
t.Run("the authorising user must be in the room", func(t *testing.T) {
r := setup(t)
wantRejected(t, r.checkWith(
r.member(bob, bob, "join", map[string]any{"join_authorised_via_users_server": carol}),
fakeVerifier{}), "is not joined to the room")
})
t.Run("the authorising user must be able to invite", func(t *testing.T) {
r := setup(t)
r.setJoinRule(JoinRuleInvite, nil)
r.mustSend(r.member(alice, carol, "invite", nil))
r.mustSend(r.member(carol, carol, "join", nil))
r.setJoinRule(JoinRuleRestricted, map[string]any{"allow": []any{}})
wantRejected(t, r.checkWith(
r.member(bob, bob, "join", map[string]any{"join_authorised_via_users_server": carol}),
fakeVerifier{}), "below the invite level")
})
t.Run("a bad signature on the authorisation is rejected", func(t *testing.T) {
r := setup(t)
wantRejected(t, r.checkWith(
r.member(bob, bob, "join", map[string]any{"join_authorised_via_users_server": alice}),
fakeVerifier{err: errors.New("no such key")}), "no valid signature")
})
t.Run("without a verifier the join is not allowed", func(t *testing.T) {
r := setup(t)
err := r.check(r.member(bob, bob, "join", map[string]any{"join_authorised_via_users_server": alice}))
if !errors.Is(err, ErrNoVerifier) {
t.Errorf("error = %v, want ErrNoVerifier", err)
}
})
t.Run("an existing invite is enough on its own", func(t *testing.T) {
r := setup(t)
r.mustSend(r.member(alice, bob, "invite", nil))
wantAllowed(t, r.check(r.member(bob, bob, "join", nil)))
})
t.Run("version 7 has no restricted join rule", func(t *testing.T) {
r := newJoinedRoom(t, roomversion.V7)
r.setPowerLevels(map[string]any{"users": map[string]any{alice: 100}})
r.setJoinRule(JoinRuleRestricted, nil)
wantRejected(t, r.check(r.member(bob, bob, "join", nil)),
"does not have the restricted join rule")
})
t.Run("version 9 has no knock_restricted join rule", func(t *testing.T) {
r := newJoinedRoom(t, roomversion.V9)
r.setPowerLevels(map[string]any{"users": map[string]any{alice: 100}})
r.setJoinRule(JoinRuleKnockRestricted, nil)
wantRejected(t, r.check(r.member(bob, bob, "join", nil)),
"does not have the knock_restricted join rule")
})
t.Run("version 10 has knock_restricted", func(t *testing.T) {
r := newJoinedRoom(t, roomversion.V10)
r.setPowerLevels(map[string]any{"users": map[string]any{alice: 100}, "invite": 0})
r.setJoinRule(JoinRuleKnockRestricted, nil)
wantAllowed(t, r.checkWith(
r.member(bob, bob, "join", map[string]any{"join_authorised_via_users_server": alice}),
fakeVerifier{}))
})
}
// --- Rule 5.4: inviting ---
func TestInviteRules(t *testing.T) {
t.Run("a stranger cannot invite", func(t *testing.T) {
r := newJoinedRoom(t, roomversion.V11)
wantRejected(t, r.check(r.member(bob, carol, "invite", nil)), "not joined to the room")
})
t.Run("inviting somebody already joined is rejected", func(t *testing.T) {
r := newJoinedRoom(t, roomversion.V11)
r.setJoinRule(JoinRulePublic, nil)
r.mustSend(r.member(bob, bob, "join", nil))
wantRejected(t, r.check(r.member(alice, bob, "invite", nil)), "already")
})
t.Run("inviting somebody banned is rejected", func(t *testing.T) {
r := newJoinedRoom(t, roomversion.V11)
r.setPowerLevels(map[string]any{"users": map[string]any{alice: 100}})
r.mustSend(r.member(alice, bob, "ban", nil))
wantRejected(t, r.check(r.member(alice, bob, "invite", nil)), "already")
})
t.Run("inviting needs the invite level", func(t *testing.T) {
r := newJoinedRoom(t, roomversion.V11)
r.setPowerLevels(map[string]any{"users": map[string]any{alice: 100, bob: 0}, "invite": 50})
r.setJoinRule(JoinRulePublic, nil)
r.mustSend(r.member(bob, bob, "join", nil))
wantRejected(t, r.check(r.member(bob, carol, "invite", nil)), "below the invite level")
wantAllowed(t, r.check(r.member(alice, carol, "invite", nil)))
})
}
// --- Rule 5.5 and 5.6: leaving, kicking and banning ---
func TestLeaveRules(t *testing.T) {
t.Run("a joined user may leave", func(t *testing.T) {
r := newJoinedRoom(t, roomversion.V11)
wantAllowed(t, r.check(r.member(alice, alice, "leave", nil)))
})
t.Run("an invited user may turn the invitation down", func(t *testing.T) {
r := newJoinedRoom(t, roomversion.V11)
r.mustSend(r.member(alice, bob, "invite", nil))
wantAllowed(t, r.check(r.member(bob, bob, "leave", nil)))
})
t.Run("a stranger cannot leave a room they were never in", func(t *testing.T) {
r := newJoinedRoom(t, roomversion.V11)
wantRejected(t, r.check(r.member(bob, bob, "leave", nil)), "cannot leave from membership")
})
t.Run("a knocking user may withdraw", func(t *testing.T) {
r := newJoinedRoom(t, roomversion.V11)
r.setJoinRule(JoinRuleKnock, nil)
r.mustSend(r.member(bob, bob, "knock", nil))
wantAllowed(t, r.check(r.member(bob, bob, "leave", nil)))
})
}
func TestKickRules(t *testing.T) {
setup := func(t *testing.T, levels map[string]any) *room {
t.Helper()
r := newJoinedRoom(t, roomversion.V11)
r.setPowerLevels(levels)
r.setJoinRule(JoinRulePublic, nil)
r.mustSend(r.member(bob, bob, "join", nil))
r.mustSend(r.member(carol, carol, "join", nil))
return r
}
t.Run("a moderator may kick somebody below them", func(t *testing.T) {
r := setup(t, map[string]any{"users": map[string]any{alice: 100, bob: 50}, "kick": 50})
wantAllowed(t, r.check(r.member(bob, carol, "leave", nil)))
})
t.Run("below the kick level is a rejection", func(t *testing.T) {
r := setup(t, map[string]any{"users": map[string]any{alice: 100, bob: 25}, "kick": 50})
wantRejected(t, r.check(r.member(bob, carol, "leave", nil)), "below the kick level")
})
t.Run("you cannot kick somebody at your own level", func(t *testing.T) {
r := setup(t, map[string]any{"users": map[string]any{alice: 100, bob: 50, carol: 50}, "kick": 50})
wantRejected(t, r.check(r.member(bob, carol, "leave", nil)), "does not exceed")
})
t.Run("you cannot kick somebody above you", func(t *testing.T) {
r := setup(t, map[string]any{"users": map[string]any{alice: 100, bob: 50, carol: 75}, "kick": 50})
wantRejected(t, r.check(r.member(bob, carol, "leave", nil)), "does not exceed")
})
t.Run("a non-member cannot kick", func(t *testing.T) {
r := setup(t, map[string]any{"users": map[string]any{alice: 100}, "kick": 0})
wantRejected(t, r.check(r.member(dave, carol, "leave", nil)), "not joined to the room")
})
}
func TestBanRules(t *testing.T) {
setup := func(t *testing.T, levels map[string]any) *room {
t.Helper()
r := newJoinedRoom(t, roomversion.V11)
r.setPowerLevels(levels)
r.setJoinRule(JoinRulePublic, nil)
r.mustSend(r.member(bob, bob, "join", nil))
r.mustSend(r.member(carol, carol, "join", nil))
return r
}
t.Run("a moderator may ban somebody below them", func(t *testing.T) {
r := setup(t, map[string]any{"users": map[string]any{alice: 100, bob: 50}, "ban": 50})
wantAllowed(t, r.check(r.member(bob, carol, "ban", nil)))
})
t.Run("below the ban level is a rejection", func(t *testing.T) {
r := setup(t, map[string]any{"users": map[string]any{alice: 100, bob: 25}, "ban": 50})
wantRejected(t, r.check(r.member(bob, carol, "ban", nil)), "below the ban level")
})
t.Run("you cannot ban a peer", func(t *testing.T) {
r := setup(t, map[string]any{"users": map[string]any{alice: 100, bob: 50, carol: 50}, "ban": 50})
wantRejected(t, r.check(r.member(bob, carol, "ban", nil)), "does not exceed")
})
// The spec spells this consequence out: unbanning takes both the kick
// and the ban level, because it is a leave event over a ban.
t.Run("unbanning needs both the kick and the ban level", func(t *testing.T) {
r := setup(t, map[string]any{
"users": map[string]any{alice: 100, bob: 50},
"ban": 50, "kick": 100,
})
r.mustSend(r.member(alice, carol, "ban", nil))
wantRejected(t, r.check(r.member(bob, carol, "leave", nil)), "below the kick level")
r2 := setup(t, map[string]any{
"users": map[string]any{alice: 100, bob: 50},
"ban": 100, "kick": 50,
})
r2.mustSend(r2.member(alice, carol, "ban", nil))
wantRejected(t, r2.check(r2.member(bob, carol, "leave", nil)), "below the ban level")
})
}
// --- Rule 5.7: knocking ---
func TestKnockRules(t *testing.T) {
t.Run("knocking works in a knock room", func(t *testing.T) {
r := newJoinedRoom(t, roomversion.V11)
r.setJoinRule(JoinRuleKnock, nil)
wantAllowed(t, r.check(r.member(bob, bob, "knock", nil)))
})
t.Run("knocking on an invite-only room is rejected", func(t *testing.T) {
r := newJoinedRoom(t, roomversion.V11)
r.setJoinRule(JoinRuleInvite, nil)
wantRejected(t, r.check(r.member(bob, bob, "knock", nil)), "cannot be knocked on")
})
t.Run("you cannot knock for somebody else", func(t *testing.T) {
r := newJoinedRoom(t, roomversion.V11)
r.setJoinRule(JoinRuleKnock, nil)
wantRejected(t, r.check(r.member(bob, carol, "knock", nil)), "on behalf of")
})
t.Run("a joined user cannot knock", func(t *testing.T) {
r := newJoinedRoom(t, roomversion.V11)
r.setJoinRule(JoinRuleKnock, nil)
wantRejected(t, r.check(r.member(alice, alice, "knock", nil)), "while their membership is")
})
t.Run("a banned user cannot knock", func(t *testing.T) {
r := newJoinedRoom(t, roomversion.V11)
r.setPowerLevels(map[string]any{"users": map[string]any{alice: 100}})
r.setJoinRule(JoinRuleKnock, nil)
r.mustSend(r.member(alice, bob, "ban", nil))
wantRejected(t, r.check(r.member(bob, bob, "knock", nil)), "while their membership is")
})
t.Run("version 6 does not allow knocking", func(t *testing.T) {
r := newJoinedRoom(t, roomversion.V6)
r.setJoinRule(JoinRuleKnock, nil)
wantRejected(t, r.check(r.member(bob, bob, "knock", nil)), "does not allow knocking")
})
}
func TestUnknownMembershipIsRejected(t *testing.T) {
r := newJoinedRoom(t, roomversion.V11)
wantRejected(t, r.check(r.member(alice, alice, "sideways", nil)), "unknown membership")
}
func TestMemberEventNeedsStateKeyAndMembership(t *testing.T) {
r := newJoinedRoom(t, roomversion.V11)
// The auth events selection algorithm cannot run on this event either,
// so the harness is told not to try.
wantRejected(t, r.check(event.Builder{
Type: event.TypeMember,
Sender: alice,
AuthEvents: []string{},
Content: mustJSON(t, map[string]any{"membership": "join"}),
}), "must have a state_key")
wantRejected(t, r.check(event.Builder{
Type: event.TypeMember,
Sender: alice,
StateKey: ptr(alice),
Content: mustJSON(t, map[string]any{"displayname": "Alice"}),
}), "must have a membership")
}
// --- Rules 6 to 9 ---
func TestOnlyMembersMaySendEvents(t *testing.T) {
r := newJoinedRoom(t, roomversion.V11)
wantAllowed(t, r.check(r.message(alice)))
wantRejected(t, r.check(r.message(bob)), "not joined to the room")
}
func TestSendLevels(t *testing.T) {
r := newJoinedRoom(t, roomversion.V11)
r.setPowerLevels(map[string]any{
"users": map[string]any{alice: 100, bob: 0},
"events_default": 25,
"events": map[string]any{"com.example.special": 75},
})
r.setJoinRule(JoinRulePublic, nil)
r.mustSend(r.member(bob, bob, "join", nil))
wantRejected(t, r.check(r.message(bob)), "below the 25 needed")
wantAllowed(t, r.check(r.message(alice)))
wantRejected(t, r.check(event.Builder{Type: "com.example.special", Sender: bob}), "below the 75 needed")
wantAllowed(t, r.check(event.Builder{Type: "com.example.special", Sender: alice}))
}
func TestStateDefaultAppliesWithNoPowerLevelsEvent(t *testing.T) {
// With no power levels event the creator has 100 and state_default is
// 50, so the creator can send state and nobody else can.
r := newJoinedRoom(t, roomversion.V11)
r.setJoinRule(JoinRulePublic, nil)
r.mustSend(r.member(bob, bob, "join", nil))
wantAllowed(t, r.check(event.Builder{Type: "m.room.topic", Sender: alice, StateKey: ptr("")}))
wantRejected(t, r.check(event.Builder{Type: "m.room.topic", Sender: bob, StateKey: ptr("")}),
"below the 50 needed")
}
func TestStateKeyedByUserIDBelongsToThatUser(t *testing.T) {
r := newJoinedRoom(t, roomversion.V11)
r.setPowerLevels(map[string]any{"users": map[string]any{alice: 100}, "state_default": 0})
r.setJoinRule(JoinRulePublic, nil)
r.mustSend(r.member(bob, bob, "join", nil))
wantAllowed(t, r.check(event.Builder{Type: "com.example.profile", Sender: bob, StateKey: ptr(bob)}))
wantRejected(t, r.check(event.Builder{Type: "com.example.profile", Sender: bob, StateKey: ptr(alice)}),
"is a user ID that is not the sender")
// A state key that is not a user ID is not affected.
wantAllowed(t, r.check(event.Builder{Type: "com.example.thing", Sender: bob, StateKey: ptr("anything")}))
}
func TestThirdPartyInviteEventNeedsInviteLevel(t *testing.T) {
r := newJoinedRoom(t, roomversion.V11)
r.setPowerLevels(map[string]any{"users": map[string]any{alice: 100, bob: 0}, "invite": 50})
r.setJoinRule(JoinRulePublic, nil)
r.mustSend(r.member(bob, bob, "join", nil))
invite := func(sender string) event.Builder {
return event.Builder{
Type: event.TypeThirdPartyInvite,
Sender: sender,
StateKey: ptr("token"),
Content: mustJSON(t, map[string]any{"display_name": "someone", "public_key": "abc"}),
}
}
wantAllowed(t, r.check(invite(alice)))
wantRejected(t, r.check(invite(bob)), "below the invite level")
}
// --- Rule 10: power levels ---
func TestPowerLevelChanges(t *testing.T) {
setup := func(t *testing.T) *room {
t.Helper()
r := newJoinedRoom(t, roomversion.V11)
r.setPowerLevels(map[string]any{
"users": map[string]any{alice: 100, bob: 50, carol: 50},
"events_default": 0, "state_default": 50, "ban": 50, "kick": 50,
})
r.setJoinRule(JoinRulePublic, nil)
r.mustSend(r.member(bob, bob, "join", nil))
r.mustSend(r.member(carol, carol, "join", nil))
return r
}
change := func(r *room, sender string, content map[string]any) event.Builder {
return event.Builder{
Type: event.TypePowerLevels,
Sender: sender,
StateKey: ptr(""),
Content: mustJSON(t, content),
}
}
t.Run("the first power levels event is always allowed", func(t *testing.T) {
r := newJoinedRoom(t, roomversion.V11)
// Alice's level is 100 by default, so she could not otherwise give
// herself 100 and set state_default to 100.
wantAllowed(t, r.check(change(r, alice, map[string]any{
"users": map[string]any{alice: 100}, "state_default": 100,
})))
})
t.Run("you cannot raise yourself above your own level", func(t *testing.T) {
r := setup(t)
wantRejected(t, r.check(change(r, bob, map[string]any{
"users": map[string]any{alice: 100, bob: 75, carol: 50},
})), "would become 75")
})
t.Run("you may lower your own level", func(t *testing.T) {
r := setup(t)
wantAllowed(t, r.check(change(r, bob, map[string]any{
"users": map[string]any{alice: 100, bob: 25, carol: 50},
})))
})
t.Run("you may promote somebody up to your own level", func(t *testing.T) {
r := setup(t)
wantAllowed(t, r.check(change(r, alice, map[string]any{
"users": map[string]any{alice: 100, bob: 100, carol: 50},
})))
})
t.Run("you cannot demote a peer", func(t *testing.T) {
r := setup(t)
wantRejected(t, r.check(change(r, bob, map[string]any{
"users": map[string]any{alice: 100, bob: 50, carol: 0},
})), "at the sender's own power level")
})
t.Run("you cannot remove a peer", func(t *testing.T) {
r := setup(t)
wantRejected(t, r.check(change(r, bob, map[string]any{
"users": map[string]any{alice: 100, bob: 50},
})), "at the sender's own power level")
})
t.Run("you cannot demote somebody above you", func(t *testing.T) {
r := setup(t)
wantRejected(t, r.check(change(r, bob, map[string]any{
"users": map[string]any{alice: 50, bob: 50, carol: 50},
})), "above the sender's power level")
})
t.Run("an admin may demote anybody", func(t *testing.T) {
r := setup(t)
wantAllowed(t, r.check(change(r, alice, map[string]any{
"users": map[string]any{alice: 100, bob: 0, carol: 0},
})))
})
t.Run("you cannot raise a named level above your own", func(t *testing.T) {
r := setup(t)
wantRejected(t, r.check(change(r, bob, map[string]any{
"users": map[string]any{alice: 100, bob: 50, carol: 50}, "ban": 75,
})), "ban would become 75")
})
t.Run("you cannot lower a level that is above your own", func(t *testing.T) {
r := setup(t)
r.setPowerLevels(map[string]any{
"users": map[string]any{alice: 100, bob: 50, carol: 50}, "ban": 90,
})
wantRejected(t, r.check(change(r, bob, map[string]any{
"users": map[string]any{alice: 100, bob: 50, carol: 50}, "ban": 50,
})), "ban is 90")
})
t.Run("the events map is protected the same way", func(t *testing.T) {
r := setup(t)
wantRejected(t, r.check(change(r, bob, map[string]any{
"users": map[string]any{alice: 100, bob: 50, carol: 50},
"events": map[string]any{"m.room.name": 75},
})), "events.m.room.name would become 75")
wantAllowed(t, r.check(change(r, bob, map[string]any{
"users": map[string]any{alice: 100, bob: 50, carol: 50},
"events": map[string]any{"m.room.name": 50},
})))
})
t.Run("the notifications map is protected the same way", func(t *testing.T) {
r := setup(t)
wantRejected(t, r.check(change(r, bob, map[string]any{
"users": map[string]any{alice: 100, bob: 50, carol: 50},
"notifications": map[string]any{"room": 75},
})), "notifications.room would become 75")
})
t.Run("an unchanged event is allowed", func(t *testing.T) {
r := setup(t)
wantAllowed(t, r.check(change(r, bob, map[string]any{
"users": map[string]any{alice: 100, bob: 50, carol: 50},
"events_default": 0, "state_default": 50, "ban": 50, "kick": 50,
})))
})
}
func TestPowerLevelContentValidation(t *testing.T) {
t.Run("version 10 requires integers", func(t *testing.T) {
r := newJoinedRoom(t, roomversion.V10)
wantRejected(t, r.check(event.Builder{
Type: event.TypePowerLevels, Sender: alice, StateKey: ptr(""),
Content: mustJSON(t, map[string]any{"ban": "50"}),
}), "is not an integer")
})
t.Run("version 9 accepts a level written as a string", func(t *testing.T) {
r := newJoinedRoom(t, roomversion.V9)
wantAllowed(t, r.check(event.Builder{
Type: event.TypePowerLevels, Sender: alice, StateKey: ptr(""),
Content: mustJSON(t, map[string]any{"ban": "50"}),
}))
})
t.Run("version 9 still rejects a string that is not a number", func(t *testing.T) {
r := newJoinedRoom(t, roomversion.V9)
wantRejected(t, r.check(event.Builder{
Type: event.TypePowerLevels, Sender: alice, StateKey: ptr(""),
Content: mustJSON(t, map[string]any{"ban": "high"}),
}), "not an integer")
})
t.Run("a users key that is not a user ID is rejected", func(t *testing.T) {
r := newJoinedRoom(t, roomversion.V11)
wantRejected(t, r.check(event.Builder{
Type: event.TypePowerLevels, Sender: alice, StateKey: ptr(""),
Content: mustJSON(t, map[string]any{"users": map[string]any{"alice": 50}}),
}), "users")
})
t.Run("an events map with a non-integer value is rejected", func(t *testing.T) {
r := newJoinedRoom(t, roomversion.V11)
wantRejected(t, r.check(event.Builder{
Type: event.TypePowerLevels, Sender: alice, StateKey: ptr(""),
Content: mustJSON(t, map[string]any{"events": map[string]any{"m.room.name": true}}),
}), "events")
})
}
// --- Room version 12: creators ---
func TestVersion12CreatorsHaveInfinitePower(t *testing.T) {
r := newRoom(t, roomversion.V12, map[string]any{"additional_creators": []string{bob}})
r.mustSend(r.member(alice, alice, "join", nil))
r.setJoinRule(JoinRulePublic, nil)
r.mustSend(r.member(bob, bob, "join", nil))
r.mustSend(r.member(carol, carol, "join", nil))
// A power levels event that would strip everybody's power does not
// touch the creators.
r.setPowerLevels(map[string]any{
"users": map[string]any{carol: 100},
"state_default": 100, "events_default": 100, "kick": 100, "ban": 100,
})
t.Run("a creator can still send state", func(t *testing.T) {
wantAllowed(t, r.check(event.Builder{Type: "m.room.topic", Sender: alice, StateKey: ptr("")}))
})
t.Run("an additional creator can too", func(t *testing.T) {
wantAllowed(t, r.check(event.Builder{Type: "m.room.topic", Sender: bob, StateKey: ptr("")}))
})
t.Run("a creator outranks a user with level 100", func(t *testing.T) {
wantAllowed(t, r.check(r.member(alice, carol, "ban", nil)))
})
t.Run("a user with level 100 cannot kick a creator", func(t *testing.T) {
wantRejected(t, r.check(r.member(carol, bob, "leave", nil)), "does not exceed")
})
t.Run("one creator cannot kick another", func(t *testing.T) {
wantRejected(t, r.check(r.member(alice, bob, "leave", nil)), "does not exceed")
})
t.Run("a creator cannot be given a level", func(t *testing.T) {
wantRejected(t, r.check(event.Builder{
Type: event.TypePowerLevels, Sender: alice, StateKey: ptr(""),
Content: mustJSON(t, map[string]any{"users": map[string]any{alice: 100}}),
}), "must not give a level to the room creator")
wantRejected(t, r.check(event.Builder{
Type: event.TypePowerLevels, Sender: alice, StateKey: ptr(""),
Content: mustJSON(t, map[string]any{"users": map[string]any{bob: 50}}),
}), "must not give a level to the room creator")
})
}
func TestVersion11CreatorIsOrdinaryOnceLevelsExist(t *testing.T) {
r := newJoinedRoom(t, roomversion.V11)
r.setJoinRule(JoinRulePublic, nil)
r.mustSend(r.member(carol, carol, "join", nil))
// The creator's 100 only applies while there is no power levels
// event. Here Alice writes herself out of it.
r.setPowerLevels(map[string]any{"users": map[string]any{carol: 100}, "state_default": 50})
wantRejected(t, r.check(event.Builder{Type: "m.room.topic", Sender: alice, StateKey: ptr("")}),
"below the 50 needed")
}
// --- Third-party invites ---
func TestThirdPartyInviteFlow(t *testing.T) {
identityKey, err := signing.Generate("1")
if err != nil {
t.Fatal(err)
}
// The identity server signs a statement binding the token to a user.
signedJSON, err := canonicaljson.Marshal(map[string]any{"mxid": bob, "token": "tok"})
if err != nil {
t.Fatal(err)
}
signed, err := signing.SignJSON("identity.test", identityKey, signedJSON)
if err != nil {
t.Fatal(err)
}
setup := func(t *testing.T, publicKey string) *room {
t.Helper()
r := newJoinedRoom(t, roomversion.V11)
r.mustSend(event.Builder{
Type: event.TypeThirdPartyInvite,
Sender: alice,
StateKey: ptr("tok"),
Content: mustJSON(t, map[string]any{
"display_name": "bob@example.org",
"key_validity_url": "https://identity.test/_matrix/identity/v2/pubkey/isvalid",
"public_key": publicKey,
}),
})
return r
}
goodKey := base64.RawStdEncoding.EncodeToString(identityKey.Public)
t.Run("a correctly signed invite is allowed", func(t *testing.T) {
r := setup(t, goodKey)
wantAllowed(t, r.check(r.member(alice, bob, "invite", map[string]any{
"third_party_invite": map[string]any{"display_name": "bob@example.org", "signed": json.RawMessage(signed)},
})))
})
t.Run("a signature from another key is rejected", func(t *testing.T) {
other, err := signing.Generate("2")
if err != nil {
t.Fatal(err)
}
r := setup(t, base64.RawStdEncoding.EncodeToString(other.Public))
wantRejected(t, r.check(r.member(alice, bob, "invite", map[string]any{
"third_party_invite": map[string]any{"signed": json.RawMessage(signed)},
})), "no signature on the third-party invite matches")
})
t.Run("public_keys is accepted as well as public_key", func(t *testing.T) {
r := newJoinedRoom(t, roomversion.V11)
r.mustSend(event.Builder{
Type: event.TypeThirdPartyInvite,
Sender: alice,
StateKey: ptr("tok"),
Content: mustJSON(t, map[string]any{
"display_name": "bob@example.org",
"public_keys": []any{
map[string]any{"public_key": "AAAA"},
map[string]any{"public_key": goodKey},
},
}),
})
wantAllowed(t, r.check(r.member(alice, bob, "invite", map[string]any{
"third_party_invite": map[string]any{"signed": json.RawMessage(signed)},
})))
})
t.Run("the mxid must match the invited user", func(t *testing.T) {
r := setup(t, goodKey)
wantRejected(t, r.check(r.member(alice, carol, "invite", map[string]any{
"third_party_invite": map[string]any{"signed": json.RawMessage(signed)},
})), "but the event invites")
})
t.Run("there must be a matching invite event in state", func(t *testing.T) {
r := newJoinedRoom(t, roomversion.V11)
wantRejected(t, r.check(r.member(alice, bob, "invite", map[string]any{
"third_party_invite": map[string]any{"signed": json.RawMessage(signed)},
})), "no m.room.third_party_invite in the room state")
})
t.Run("the redeemer must be the issuer", func(t *testing.T) {
r := setup(t, goodKey)
r.setJoinRule(JoinRulePublic, nil)
r.mustSend(r.member(carol, carol, "join", nil))
wantRejected(t, r.check(r.member(carol, bob, "invite", map[string]any{
"third_party_invite": map[string]any{"signed": json.RawMessage(signed)},
})), "was issued by")
})
t.Run("signed needs both mxid and token", func(t *testing.T) {
r := setup(t, goodKey)
wantRejected(t, r.check(r.member(alice, bob, "invite", map[string]any{
"third_party_invite": map[string]any{"signed": map[string]any{"mxid": bob}},
})), "needs both mxid and token")
})
t.Run("there must be a signed property", func(t *testing.T) {
r := setup(t, goodKey)
wantRejected(t, r.check(r.member(alice, bob, "invite", map[string]any{
"third_party_invite": map[string]any{"display_name": "x"},
})), "no signed property")
})
t.Run("a banned user cannot be invited this way either", func(t *testing.T) {
r := setup(t, goodKey)
r.setPowerLevels(map[string]any{"users": map[string]any{alice: 100}})
r.mustSend(r.member(alice, bob, "ban", nil))
wantRejected(t, r.check(r.member(alice, bob, "invite", map[string]any{
"third_party_invite": map[string]any{"signed": json.RawMessage(signed)},
})), "banned")
})
}
// --- Redactions ---
func TestRedactionsPassTheRulesButNeedASecondTest(t *testing.T) {
r := newJoinedRoom(t, roomversion.V11)
r.setPowerLevels(map[string]any{
"users": map[string]any{alice: 100, bob: 0}, "redact": 50, "events_default": 0,
})
r.setJoinRule(JoinRulePublic, nil)
r.mustSend(r.member(bob, bob, "join", nil))
target := r.build(r.message(alice))
// Rule-wise a redaction from anybody in the room is fine: the redact
// level is not an authorisation rule.
redaction := event.Builder{
Type: event.TypeRedaction,
Sender: bob,
Redacts: target.EventID(),
Content: mustJSON(t, map[string]any{"reason": "spam"}),
}
wantAllowed(t, r.check(redaction))
// Dave is in the room too, from another server, and also below the
// redact level.
r.mustSend(r.member(dave, dave, "join", nil))
pl, err := ResolvePowerLevels(r.state.PowerLevels(), r.state.Create(), r.rules)
if err != nil {
t.Fatal(err)
}
t.Run("whether it applies is a separate question", func(t *testing.T) {
fromDave := r.build(event.Builder{
Type: event.TypeRedaction, Sender: dave, Redacts: target.EventID(),
})
if ShouldApplyRedaction(fromDave, target, pl) {
t.Error("Dave is below the redact level and is on another server, so his redaction should not apply")
}
})
t.Run("the redact level is enough", func(t *testing.T) {
fromAlice := r.build(event.Builder{
Type: event.TypeRedaction, Sender: alice, Redacts: target.EventID(),
})
if !ShouldApplyRedaction(fromAlice, target, pl) {
t.Error("Alice is above the redact level, so her redaction should apply")
}
})
t.Run("the same server is enough", func(t *testing.T) {
// Bob is on the same server as the event's sender, so his server
// may take the event back whatever his power level.
own := r.build(event.Builder{
Type: event.TypeRedaction, Sender: bob, Redacts: target.EventID(),
})
if !ShouldApplyRedaction(own, target, pl) {
t.Error("a redaction from the same server as the original should apply")
}
})
t.Run("another server without the level is not", func(t *testing.T) {
remote := r.build(event.Builder{
Type: event.TypeRedaction, Sender: dave, Redacts: target.EventID(),
})
if ShouldApplyRedaction(remote, target, pl) {
t.Error("a redaction from another server below the redact level should not apply")
}
})
}
// --- Power level resolution ---
func TestResolvePowerLevels(t *testing.T) {
t.Run("defaults with no power levels event", func(t *testing.T) {
r := newJoinedRoom(t, roomversion.V11)
pl, err := ResolvePowerLevels(nil, r.state.Create(), r.rules)
if err != nil {
t.Fatal(err)
}
if got := pl.UserLevel(alice); got != CreatorPowerLevel {
t.Errorf("the creator's level = %d, want %d", got, CreatorPowerLevel)
}
if got := pl.UserLevel(bob); got != 0 {
t.Errorf("another user's level = %d, want 0", got)
}
// state_default is 50 even here, which is what stops a new member
// rewriting the room's state.
if got := pl.SendLevel("m.room.topic", true); got != 50 {
t.Errorf("state_default = %d, want 50", got)
}
if got := pl.SendLevel("m.room.message", false); got != 0 {
t.Errorf("events_default = %d, want 0", got)
}
for name, got := range map[string]int64{"ban": pl.Ban, "kick": pl.Kick, "redact": pl.Redact} {
if got != 50 {
t.Errorf("%s = %d, want 50", name, got)
}
}
if pl.Invite != 0 {
t.Errorf("invite = %d, want 0", pl.Invite)
}
if got := pl.NotificationLevel("room"); got != 50 {
t.Errorf("notifications.room = %d, want 50", got)
}
})
t.Run("defaults fill in around what the event sets", func(t *testing.T) {
r := newJoinedRoom(t, roomversion.V11)
plEvent := r.setPowerLevels(map[string]any{"users": map[string]any{alice: 42}})
pl, err := ResolvePowerLevels(plEvent, r.state.Create(), r.rules)
if err != nil {
t.Fatal(err)
}
if got := pl.UserLevel(alice); got != 42 {
t.Errorf("alice = %d, want 42", got)
}
// The creator's 100 is gone: the event is authoritative.
if got := pl.UserLevel(bob); got != 0 {
t.Errorf("bob = %d, want 0", got)
}
if pl.Ban != 50 || pl.StateDefault != 50 || pl.Invite != 0 {
t.Errorf("defaults were not filled in: ban=%d state_default=%d invite=%d",
pl.Ban, pl.StateDefault, pl.Invite)
}
})
t.Run("version 12 creators are infinite", func(t *testing.T) {
r := newRoom(t, roomversion.V12, map[string]any{"additional_creators": []string{bob}})
pl, err := ResolvePowerLevels(nil, r.state.Create(), r.rules)
if err != nil {
t.Fatal(err)
}
for _, creator := range []string{alice, bob} {
if got := pl.UserLevel(creator); got != InfinitePowerLevel {
t.Errorf("%s = %d, want InfinitePowerLevel", creator, got)
}
if !pl.IsCreator(creator) {
t.Errorf("IsCreator(%s) = false", creator)
}
}
if pl.IsCreator(carol) {
t.Error("IsCreator(carol) = true")
}
})
}
func TestCreators(t *testing.T) {
t.Run("version 11 has one creator", func(t *testing.T) {
r := newRoom(t, roomversion.V11, map[string]any{"additional_creators": []string{bob}})
creators, err := Creators(r.state.Create())
if err != nil {
t.Fatal(err)
}
if len(creators) != 1 || creators[0] != alice {
t.Errorf("creators = %v, want just %s", creators, alice)
}
})
t.Run("version 12 includes the additional creators", func(t *testing.T) {
r := newRoom(t, roomversion.V12, map[string]any{"additional_creators": []string{bob, carol}})
creators, err := Creators(r.state.Create())
if err != nil {
t.Fatal(err)
}
if fmt.Sprint(creators) != fmt.Sprint([]string{alice, bob, carol}) {
t.Errorf("creators = %v", creators)
}
})
}
func TestStateHelpers(t *testing.T) {
r := newJoinedRoom(t, roomversion.V11)
if got := r.state.Membership(alice); got != event.MembershipJoin {
t.Errorf("Membership(alice) = %q, want join", got)
}
// A user with no member event is treated exactly like one who left.
if got := r.state.Membership(dave); got != event.MembershipLeave {
t.Errorf("Membership(dave) = %q, want leave", got)
}
if got := r.state.JoinRule(); got != JoinRuleInvite {
t.Errorf("JoinRule() = %q, want invite by default", got)
}
r.setJoinRule(JoinRulePublic, nil)
if got := r.state.JoinRule(); got != JoinRulePublic {
t.Errorf("JoinRule() = %q, want public", got)
}
if r.state.PowerLevels() != nil {
t.Error("PowerLevels() should be nil in a room with no power levels event")
}
if r.state.Create() == nil {
t.Error("Create() should not be nil")
}
}
func TestCheckWithoutACreateEvent(t *testing.T) {
r := newJoinedRoom(t, roomversion.V11)
e := r.build(r.message(alice))
wantRejected(t, Check(context.Background(), e, State{}, nil), "no m.room.create event")
}