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 9. internal/appservice reads registration files (appservice.config_files), matches namespaces the way Synapse does (anchored at the start), and delivers events to each service as transactions read from the roomserver's event stream at a cursor kept in appservice_state. A transaction in flight is recorded, so after a restart it is resent under the same ID; failures back off from 2s to 5m, and a successful ping cuts the backoff short. In the Client-Server API an as_token acts as the service's sender or, with ?user_id=, as any existing user in its namespace. Services register and log in their users with m.login.application_service; exclusive user and alias namespaces are refused to everybody else with M_EXCLUSIVE. Unknown aliases and users in a namespace are asked of the service first. The ping endpoint reports M_URL_NOT_SET, M_BAD_STATUS, M_CONNECTION_FAILED and M_CONNECTION_TIMEOUT. Services' own requests, and their users' when rate_limited is false, skip the rate limiter. Users can change their password (POST /account/password) and deactivate their account (POST /account/deactivate), both behind the password. Deactivation revokes tokens, deletes devices, optionally erases the profile and leaves every room through a new userapi.AccountObserver. The admin API is complete: user detail, the admin flag, deactivation and reactivation, password resets, devices, media quotas (media.max_user_storage_bytes plus per-user overrides), registration tokens with use limits and expiry (registration.require_token), server notices (internal/servernotices, server_notices.*), an editor for the federation allow/deny lists persisted in server_settings, a delivery view of the application services, and an audit log of every change. Migration 0011 adds the registration token, server notice room, media quota, audit log and appservice cursor tables. Policy lists are now returned sorted. The Complement entrypoint loads the registrations Complement mounts in /complement/appservice. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
712 lines
31 KiB
Go
712 lines
31 KiB
Go
package setup_test
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/dariuszjaniak/warp/server/internal/config"
|
|
"github.com/dariuszjaniak/warp/server/internal/setup"
|
|
"github.com/dariuszjaniak/warp/server/internal/userapi"
|
|
)
|
|
|
|
// The Phase 9 tests: application services, account management and the
|
|
// completed admin API, through the whole stack.
|
|
|
|
// adminToken creates an admin account and returns a token for it.
|
|
func adminToken(t *testing.T, srv *setup.Server, localpart string) string {
|
|
t.Helper()
|
|
account, err := srv.Users().CreateAccount(t.Context(), userapi.NewAccountRequest{
|
|
Localpart: localpart, Password: "sUp3rs3kr1t", Admin: true,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateAccount: %v", err)
|
|
}
|
|
session, err := srv.Users().CreateSession(t.Context(), userapi.SessionRequest{Localpart: account.Localpart})
|
|
if err != nil {
|
|
t.Fatalf("CreateSession: %v", err)
|
|
}
|
|
return session.AccessToken
|
|
}
|
|
|
|
// errcode reads the Matrix error code of a response.
|
|
func errcode(t *testing.T, rec *httptest.ResponseRecorder) string {
|
|
t.Helper()
|
|
code, _ := decode[map[string]any](t, rec)["errcode"].(string)
|
|
return code
|
|
}
|
|
|
|
func expectError(t *testing.T, rec *httptest.ResponseRecorder, status int, code, what string) {
|
|
t.Helper()
|
|
if rec.Code != status || errcode(t, rec) != code {
|
|
t.Errorf("%s: %d %s, want %d %s", what, rec.Code, rec.Body.String(), status, code)
|
|
}
|
|
}
|
|
|
|
func adminPath(parts ...string) string {
|
|
return "/_warp/admin/v1/" + strings.Join(parts, "/")
|
|
}
|
|
|
|
// fakeBridge is an application service: it records the transactions it is
|
|
// sent, and answers user and alias queries by creating what was asked for,
|
|
// through the homeserver's own API, the way a bridge does.
|
|
type fakeBridge struct {
|
|
t *testing.T
|
|
srv *setup.Server
|
|
asToken string
|
|
|
|
mu sync.Mutex
|
|
txnIDs []string
|
|
events []map[string]any
|
|
pings int
|
|
}
|
|
|
|
func (b *fakeBridge) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
if r.Header.Get("Authorization") != "Bearer hs-bridge" {
|
|
http.Error(w, `{"errcode":"M_FORBIDDEN"}`, http.StatusForbidden)
|
|
return
|
|
}
|
|
switch {
|
|
case strings.HasPrefix(r.URL.Path, "/_matrix/app/v1/transactions/"):
|
|
var body struct {
|
|
Events []map[string]any `json:"events"`
|
|
}
|
|
raw, _ := io.ReadAll(r.Body)
|
|
_ = json.Unmarshal(raw, &body)
|
|
b.mu.Lock()
|
|
b.txnIDs = append(b.txnIDs, strings.TrimPrefix(r.URL.Path, "/_matrix/app/v1/transactions/"))
|
|
b.events = append(b.events, body.Events...)
|
|
b.mu.Unlock()
|
|
_, _ = w.Write([]byte(`{}`))
|
|
|
|
case strings.HasPrefix(r.URL.Path, "/_matrix/app/v1/users/"):
|
|
userID, _ := url.PathUnescape(strings.TrimPrefix(r.URL.Path, "/_matrix/app/v1/users/"))
|
|
localpart := strings.TrimPrefix(strings.Split(userID, ":")[0], "@")
|
|
rec := post(b.t, b.srv, "/_matrix/client/v3/register", map[string]any{
|
|
"type": "m.login.application_service", "username": localpart, "inhibit_login": true,
|
|
}, b.asToken)
|
|
if rec.Code != http.StatusOK {
|
|
http.Error(w, rec.Body.String(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
_, _ = w.Write([]byte(`{}`))
|
|
|
|
case strings.HasPrefix(r.URL.Path, "/_matrix/app/v1/rooms/"):
|
|
alias, _ := url.PathUnescape(strings.TrimPrefix(r.URL.Path, "/_matrix/app/v1/rooms/"))
|
|
localpart := strings.TrimPrefix(strings.Split(alias, ":")[0], "#")
|
|
rec := post(b.t, b.srv, "/_matrix/client/v3/createRoom", map[string]any{
|
|
"preset": "public_chat", "room_alias_name": localpart,
|
|
}, b.asToken)
|
|
if rec.Code != http.StatusOK {
|
|
http.Error(w, rec.Body.String(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
_, _ = w.Write([]byte(`{}`))
|
|
|
|
case r.URL.Path == "/_matrix/app/v1/ping":
|
|
b.mu.Lock()
|
|
b.pings++
|
|
b.mu.Unlock()
|
|
_, _ = w.Write([]byte(`{}`))
|
|
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}
|
|
|
|
// eventIDs lists the event IDs the bridge has been sent, of one type.
|
|
func (b *fakeBridge) eventIDs(eventType string) []string {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
var out []string
|
|
for _, e := range b.events {
|
|
if e["type"] == eventType {
|
|
id, _ := e["event_id"].(string)
|
|
out = append(out, id)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
const bridgeRegistration = `
|
|
id: bridge
|
|
url: %s
|
|
as_token: as-bridge
|
|
hs_token: hs-bridge
|
|
sender_localpart: bridgebot
|
|
rate_limited: false
|
|
namespaces:
|
|
users:
|
|
- exclusive: true
|
|
regex: '@_bridge_.*:warp\.example'
|
|
aliases:
|
|
- exclusive: true
|
|
regex: '#_bridge_.*:warp\.example'
|
|
`
|
|
|
|
// newBridgedServer starts a server with the fake bridge registered.
|
|
func newBridgedServer(t *testing.T, tweak func(*config.Config)) (*setup.Server, *fakeBridge) {
|
|
t.Helper()
|
|
bridge := &fakeBridge{t: t, asToken: "as-bridge"}
|
|
hs := httptest.NewServer(bridge)
|
|
t.Cleanup(hs.Close)
|
|
|
|
regPath := filepath.Join(t.TempDir(), "bridge.yaml")
|
|
if err := os.WriteFile(regPath, []byte(strings.Replace(bridgeRegistration, "%s", hs.URL, 1)), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
srv := newSigningServer(t, func(c *config.Config) {
|
|
c.AppService.ConfigFiles = []string{regPath}
|
|
c.AppService.Timeout = 5 * time.Second
|
|
if tweak != nil {
|
|
tweak(c)
|
|
}
|
|
})
|
|
bridge.srv = srv
|
|
return srv, bridge
|
|
}
|
|
|
|
func waitForAppServices(t *testing.T, srv *setup.Server) {
|
|
t.Helper()
|
|
ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second)
|
|
defer cancel()
|
|
if err := srv.AppServices().WaitIdle(ctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func TestAppServicesThroughTheAPI(t *testing.T) {
|
|
srv, bridge := newBridgedServer(t, nil)
|
|
const as = "as-bridge"
|
|
|
|
// The sender exists from the start, and is who the token is.
|
|
who := mustOK(t, getWithToken(t, srv, "/_matrix/client/v3/account/whoami", as), "whoami as the sender")
|
|
if who["user_id"] != "@bridgebot:warp.example" {
|
|
t.Errorf("whoami = %v", who)
|
|
}
|
|
|
|
// Registering a puppet: the service's token, no password, no flow.
|
|
reg := mustOK(t, post(t, srv, "/_matrix/client/v3/register", map[string]any{
|
|
"type": "m.login.application_service", "username": "_bridge_alice", "inhibit_login": true,
|
|
}, as), "register a puppet")
|
|
if reg["user_id"] != "@_bridge_alice:warp.example" || reg["access_token"] != nil {
|
|
t.Errorf("register = %v", reg)
|
|
}
|
|
expectError(t, post(t, srv, "/_matrix/client/v3/register", map[string]any{
|
|
"type": "m.login.application_service", "username": "carol",
|
|
}, as), http.StatusBadRequest, "M_EXCLUSIVE", "register outside the namespace")
|
|
expectError(t, post(t, srv, "/_matrix/client/v3/register", map[string]any{
|
|
"type": "m.login.application_service", "username": "_bridge_x",
|
|
}, "nope"), http.StatusUnauthorized, "M_UNKNOWN_TOKEN", "register with a bad token")
|
|
|
|
// Nobody else may have a name in the exclusive namespace.
|
|
expectError(t, post(t, srv, "/_matrix/client/v3/register", map[string]any{
|
|
"username": "_bridge_bob", "password": "sUp3rs3kr1t",
|
|
}, ""), http.StatusBadRequest, "M_EXCLUSIVE", "a user registering in the namespace")
|
|
expectError(t, getWithToken(t, srv, "/_matrix/client/v3/register/available?username=_bridge_bob", ""),
|
|
http.StatusBadRequest, "M_EXCLUSIVE", "register/available in the namespace")
|
|
|
|
// Acting as a puppet.
|
|
asAlice := "?user_id=" + url.QueryEscape("@_bridge_alice:warp.example")
|
|
who = mustOK(t, getWithToken(t, srv, "/_matrix/client/v3/account/whoami"+asAlice, as), "whoami as a puppet")
|
|
if who["user_id"] != "@_bridge_alice:warp.example" {
|
|
t.Errorf("whoami = %v", who)
|
|
}
|
|
expectError(t, getWithToken(t, srv, "/_matrix/client/v3/account/whoami?user_id=%40carol%3Awarp.example", as),
|
|
http.StatusForbidden, "M_FORBIDDEN", "acting as a user outside the namespace")
|
|
expectError(t, getWithToken(t, srv, "/_matrix/client/v3/account/whoami?user_id=%40_bridge_nobody%3Awarp.example", as),
|
|
http.StatusForbidden, "M_FORBIDDEN", "acting as a puppet that does not exist")
|
|
|
|
// A real user talks to the puppet; the bridge hears about it.
|
|
alice := registerThrough(t, srv, "alice")
|
|
roomID := createPublicRoom(t, srv, alice)
|
|
mustOK(t, post(t, srv, "/_matrix/client/v3/rooms/"+url.PathEscape(roomID)+"/invite",
|
|
map[string]any{"user_id": "@_bridge_alice:warp.example"}, alice), "invite the puppet")
|
|
mustOK(t, post(t, srv, "/_matrix/client/v3/rooms/"+url.PathEscape(roomID)+"/join"+asAlice,
|
|
map[string]any{}, as), "the puppet joins")
|
|
msgID := sendText(t, srv, alice, roomID, "t1", map[string]any{"msgtype": "m.text", "body": "hello bridge"})
|
|
|
|
// A room the bridge has nothing to do with stays private.
|
|
otherRoom := createPublicRoom(t, srv, alice)
|
|
privateID := sendText(t, srv, alice, otherRoom, "t2", map[string]any{"msgtype": "m.text", "body": "not for you"})
|
|
|
|
waitForAppServices(t, srv)
|
|
messages := bridge.eventIDs("m.room.message")
|
|
if !contains(messages, msgID) || contains(messages, privateID) {
|
|
t.Errorf("the bridge was sent %v; want %s and not %s", messages, msgID, privateID)
|
|
}
|
|
|
|
// Inviting a puppet that does not exist yet asks the bridge, which
|
|
// registers it.
|
|
mustOK(t, post(t, srv, "/_matrix/client/v3/rooms/"+url.PathEscape(roomID)+"/invite",
|
|
map[string]any{"user_id": "@_bridge_carol:warp.example"}, alice), "invite a puppet on demand")
|
|
if ok, _ := srv.Users().AccountExists(t.Context(), "_bridge_carol"); !ok {
|
|
t.Error("the bridge was not asked to create the puppet")
|
|
}
|
|
|
|
// Aliases: reserved for the bridge, and created by it on demand.
|
|
expectError(t, put(t, srv, "/_matrix/client/v3/directory/room/"+url.PathEscape("#_bridge_mine:warp.example"),
|
|
map[string]any{"room_id": roomID}, alice), http.StatusBadRequest, "M_EXCLUSIVE", "an alias in the namespace")
|
|
expectError(t, post(t, srv, "/_matrix/client/v3/createRoom", map[string]any{"room_alias_name": "_bridge_x"}, alice),
|
|
http.StatusBadRequest, "M_EXCLUSIVE", "createRoom with an alias in the namespace")
|
|
resolved := mustOK(t, getWithToken(t, srv, "/_matrix/client/v3/directory/room/"+url.PathEscape("#_bridge_portal:warp.example"), ""),
|
|
"resolve a portal alias")
|
|
if id, _ := resolved["room_id"].(string); !strings.HasPrefix(id, "!") {
|
|
t.Errorf("resolved = %v", resolved)
|
|
}
|
|
|
|
// Logging in as a puppet, with the service's token as the credential.
|
|
login := mustOK(t, post(t, srv, "/_matrix/client/v3/login", map[string]any{
|
|
"type": "m.login.application_service",
|
|
"identifier": map[string]any{"type": "m.id.user", "user": "_bridge_alice"},
|
|
}, as), "application service login")
|
|
if login["user_id"] != "@_bridge_alice:warp.example" || login["access_token"] == nil {
|
|
t.Errorf("login = %v", login)
|
|
}
|
|
expectError(t, post(t, srv, "/_matrix/client/v3/login", map[string]any{
|
|
"type": "m.login.application_service",
|
|
"identifier": map[string]any{"type": "m.id.user", "user": "alice"},
|
|
}, as), http.StatusForbidden, "M_FORBIDDEN", "application service login outside the namespace")
|
|
flows := mustOK(t, getWithToken(t, srv, "/_matrix/client/v3/login", ""), "login flows")
|
|
if !strings.Contains(mustJSON(t, flows), "m.login.application_service") {
|
|
t.Errorf("login flows = %v", flows)
|
|
}
|
|
|
|
// The ping.
|
|
ping := mustOK(t, post(t, srv, "/_matrix/client/v1/appservice/bridge/ping",
|
|
map[string]any{"transaction_id": "p1"}, as), "ping")
|
|
if _, ok := ping["duration_ms"]; !ok {
|
|
t.Errorf("ping = %v", ping)
|
|
}
|
|
expectError(t, post(t, srv, "/_matrix/client/v1/appservice/bridge/ping", map[string]any{}, alice),
|
|
http.StatusForbidden, "M_FORBIDDEN", "a user asking for the ping")
|
|
|
|
// The admin view.
|
|
root := adminToken(t, srv, "root")
|
|
list := mustOK(t, getWithToken(t, srv, adminPath("appservices"), root), "GET /appservices")
|
|
services, _ := list["appservices"].([]any)
|
|
if len(services) != 1 {
|
|
t.Fatalf("appservices = %v", list)
|
|
}
|
|
svc := services[0].(map[string]any)
|
|
delivery, _ := svc["delivery"].(map[string]any)
|
|
if svc["id"] != "bridge" || svc["sender"] != "@bridgebot:warp.example" || delivery == nil ||
|
|
delivery["failures"] != float64(0) || strings.Contains(mustJSON(t, list), "as-bridge") {
|
|
t.Errorf("appservice = %v", svc)
|
|
}
|
|
}
|
|
|
|
func mustJSON(t *testing.T, v any) string {
|
|
t.Helper()
|
|
raw, err := json.Marshal(v)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return string(raw)
|
|
}
|
|
|
|
// passwordAuth answers a UIA challenge with a password stage.
|
|
func passwordAuth(t *testing.T, rec *httptest.ResponseRecorder, user, password string) map[string]any {
|
|
t.Helper()
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("want a UIA challenge, got %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
return map[string]any{
|
|
"type": "m.login.password",
|
|
"session": decode[map[string]any](t, rec)["session"],
|
|
"identifier": map[string]any{"type": "m.id.user", "user": user},
|
|
"password": password,
|
|
}
|
|
}
|
|
|
|
func login(t *testing.T, srv *setup.Server, user, password string) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
return post(t, srv, "/_matrix/client/v3/login", map[string]any{
|
|
"type": "m.login.password",
|
|
"identifier": map[string]any{"type": "m.id.user", "user": user},
|
|
"password": password,
|
|
}, "")
|
|
}
|
|
|
|
func TestPasswordChangeAndDeactivation(t *testing.T) {
|
|
srv := newSigningServer(t, nil)
|
|
alice := registerThrough(t, srv, "alice")
|
|
other := decode[map[string]any](t, login(t, srv, "alice", "sUp3rs3kr1t"))["access_token"].(string)
|
|
|
|
caps := mustOK(t, getWithToken(t, srv, "/_matrix/client/v3/capabilities", alice), "capabilities")
|
|
if !strings.Contains(mustJSON(t, caps), `"m.change_password":{"enabled":true}`) {
|
|
t.Errorf("capabilities = %v", caps)
|
|
}
|
|
|
|
// Changing the password asks for the old one first.
|
|
body := map[string]any{"new_password": "n3w-s3cr3t"}
|
|
rec := post(t, srv, "/_matrix/client/v3/account/password", body, alice)
|
|
body["auth"] = passwordAuth(t, rec, "alice", "wrong")
|
|
if rec := post(t, srv, "/_matrix/client/v3/account/password", body, alice); rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("a wrong password: %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
body["auth"] = passwordAuth(t, post(t, srv, "/_matrix/client/v3/account/password", map[string]any{"new_password": "x"}, alice),
|
|
"alice", "sUp3rs3kr1t")
|
|
mustOK(t, post(t, srv, "/_matrix/client/v3/account/password", body, alice), "change password")
|
|
|
|
// The old password is gone, the other session with it; this one stays.
|
|
expectError(t, login(t, srv, "alice", "sUp3rs3kr1t"), http.StatusForbidden, "M_FORBIDDEN", "the old password")
|
|
mustOK(t, login(t, srv, "alice", "n3w-s3cr3t"), "the new password")
|
|
expectError(t, getWithToken(t, srv, "/_matrix/client/v3/account/whoami", other),
|
|
http.StatusUnauthorized, "M_UNKNOWN_TOKEN", "another session after the change")
|
|
mustOK(t, getWithToken(t, srv, "/_matrix/client/v3/account/whoami", alice), "this session after the change")
|
|
|
|
// Deactivation: same re-authentication, then the user is gone from
|
|
// their rooms and cannot log in again.
|
|
bob := registerThrough(t, srv, "bob")
|
|
roomID := createPublicRoom(t, srv, bob)
|
|
mustOK(t, post(t, srv, "/_matrix/client/v3/join/"+url.PathEscape(roomID), map[string]any{}, alice), "join")
|
|
mustOK(t, put(t, srv, "/_matrix/client/v3/profile/@alice:warp.example/displayname",
|
|
map[string]any{"displayname": "Alice"}, alice), "set displayname")
|
|
|
|
deactivate := map[string]any{"erase": true}
|
|
deactivate["auth"] = passwordAuth(t, post(t, srv, "/_matrix/client/v3/account/deactivate", deactivate, alice),
|
|
"alice", "n3w-s3cr3t")
|
|
resp := mustOK(t, post(t, srv, "/_matrix/client/v3/account/deactivate", deactivate, alice), "deactivate")
|
|
if resp["id_server_unbind_result"] != "no-support" {
|
|
t.Errorf("deactivate = %v", resp)
|
|
}
|
|
|
|
expectError(t, getWithToken(t, srv, "/_matrix/client/v3/account/whoami", alice),
|
|
http.StatusUnauthorized, "M_UNKNOWN_TOKEN", "a token of a deactivated account")
|
|
expectError(t, login(t, srv, "alice", "n3w-s3cr3t"), http.StatusForbidden, "M_USER_DEACTIVATED", "logging in when deactivated")
|
|
members := mustOK(t, getWithToken(t, srv, "/_matrix/client/v3/rooms/"+url.PathEscape(roomID)+"/joined_members", bob), "joined_members")
|
|
if joined, _ := members["joined"].(map[string]any); joined["@alice:warp.example"] != nil {
|
|
t.Errorf("a deactivated user is still in the room: %v", members)
|
|
}
|
|
profile := getWithToken(t, srv, "/_matrix/client/v3/profile/@alice:warp.example", bob)
|
|
if strings.Contains(profile.Body.String(), "Alice") {
|
|
t.Errorf("an erased profile = %s", profile.Body.String())
|
|
}
|
|
expectError(t, getWithToken(t, srv, "/_matrix/client/v3/register/available?username=alice", ""),
|
|
http.StatusBadRequest, "M_USER_IN_USE", "the name of a deactivated account")
|
|
}
|
|
|
|
func TestAdminUserManagement(t *testing.T) {
|
|
srv := newSigningServer(t, func(c *config.Config) {
|
|
c.Media.BasePath = t.TempDir()
|
|
c.Media.MaxUserStorageBytes = 1000
|
|
})
|
|
root := adminToken(t, srv, "root")
|
|
alice := registerThrough(t, srv, "alice")
|
|
aliceID := url.PathEscape("@alice:warp.example")
|
|
|
|
user := mustOK(t, getWithToken(t, srv, adminPath("users", aliceID), root), "GET user")
|
|
if user["user_id"] != "@alice:warp.example" || user["devices"] != float64(1) || user["joined_rooms"] != float64(0) {
|
|
t.Errorf("user = %v", user)
|
|
}
|
|
if media, _ := user["media"].(map[string]any); media["quota_bytes"] != float64(1000) || media["custom_quota"] != false {
|
|
t.Errorf("media = %v", user["media"])
|
|
}
|
|
expectError(t, getWithToken(t, srv, adminPath("users", url.PathEscape("@nobody:warp.example")), root),
|
|
http.StatusNotFound, "M_NOT_FOUND", "an unknown user")
|
|
expectError(t, getWithToken(t, srv, adminPath("users", url.PathEscape("@x:elsewhere")), root),
|
|
http.StatusBadRequest, "M_INVALID_PARAM", "a user of another server")
|
|
expectError(t, getWithToken(t, srv, adminPath("users", aliceID), alice),
|
|
http.StatusForbidden, "M_FORBIDDEN", "a non-admin")
|
|
|
|
// The admin flag, and the one change an admin may not make to themselves.
|
|
if got := mustOK(t, put(t, srv, adminPath("users", aliceID, "admin"), map[string]any{"admin": true}, root), "grant"); got["admin"] != true {
|
|
t.Errorf("grant = %v", got)
|
|
}
|
|
mustOK(t, getWithToken(t, srv, adminPath("stats"), alice), "alice is an admin now")
|
|
mustOK(t, put(t, srv, adminPath("users", aliceID, "admin"), map[string]any{"admin": false}, root), "revoke")
|
|
expectError(t, put(t, srv, adminPath("users", url.PathEscape("@root:warp.example"), "admin"), map[string]any{"admin": false}, root),
|
|
http.StatusForbidden, "M_FORBIDDEN", "demoting yourself")
|
|
expectError(t, post(t, srv, adminPath("users", url.PathEscape("@root:warp.example"), "deactivate"), map[string]any{}, root),
|
|
http.StatusForbidden, "M_FORBIDDEN", "deactivating yourself")
|
|
|
|
// Devices.
|
|
devices := mustOK(t, getWithToken(t, srv, adminPath("users", aliceID, "devices"), root), "devices")
|
|
list, _ := devices["devices"].([]any)
|
|
if len(list) != 1 {
|
|
t.Fatalf("devices = %v", devices)
|
|
}
|
|
deviceID := list[0].(map[string]any)["device_id"].(string)
|
|
expectError(t, sendJSON(t, srv, http.MethodDelete, adminPath("users", aliceID, "devices", "NOPE"), nil, root),
|
|
http.StatusNotFound, "M_NOT_FOUND", "deleting an unknown device")
|
|
mustOK(t, sendJSON(t, srv, http.MethodDelete, adminPath("users", aliceID, "devices", deviceID), nil, root), "delete device")
|
|
expectError(t, getWithToken(t, srv, "/_matrix/client/v3/account/whoami", alice),
|
|
http.StatusUnauthorized, "M_UNKNOWN_TOKEN", "the deleted device")
|
|
|
|
// A password reset logs everything out by default.
|
|
alice = decode[map[string]any](t, login(t, srv, "alice", "sUp3rs3kr1t"))["access_token"].(string)
|
|
mustOK(t, post(t, srv, adminPath("users", aliceID, "password"), map[string]any{"password": "r3s3t-pw"}, root), "reset password")
|
|
expectError(t, getWithToken(t, srv, "/_matrix/client/v3/account/whoami", alice),
|
|
http.StatusUnauthorized, "M_UNKNOWN_TOKEN", "a session after the reset")
|
|
alice = decode[map[string]any](t, login(t, srv, "alice", "r3s3t-pw"))["access_token"].(string)
|
|
|
|
// Media quota: the configured one, then one of her own.
|
|
if rec := send(t, srv, http.MethodPost, "/_matrix/media/v3/upload", "text/plain", make([]byte, 600), alice, nil); rec.Code != http.StatusOK {
|
|
t.Fatalf("first upload: %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
expectError(t, send(t, srv, http.MethodPost, "/_matrix/media/v3/upload", "text/plain", make([]byte, 600), alice, nil),
|
|
http.StatusRequestEntityTooLarge, "M_TOO_LARGE", "an upload over the quota")
|
|
quota := mustOK(t, put(t, srv, adminPath("users", aliceID, "media", "quota"), map[string]any{"max_bytes": 0}, root), "lift the quota")
|
|
if quota["quota_bytes"] != float64(0) || quota["custom_quota"] != true || quota["bytes"] != float64(600) {
|
|
t.Errorf("quota = %v", quota)
|
|
}
|
|
if rec := send(t, srv, http.MethodPost, "/_matrix/media/v3/upload", "text/plain", make([]byte, 600), alice, nil); rec.Code != http.StatusOK {
|
|
t.Errorf("upload with no limit: %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
quota = mustOK(t, sendJSON(t, srv, http.MethodDelete, adminPath("users", aliceID, "media", "quota"), nil, root), "reset the quota")
|
|
if quota["quota_bytes"] != float64(1000) || quota["custom_quota"] != false || quota["uploads"] != float64(2) {
|
|
t.Errorf("quota = %v", quota)
|
|
}
|
|
|
|
// Deactivation and reactivation.
|
|
user = mustOK(t, post(t, srv, adminPath("users", aliceID, "deactivate"), map[string]any{"erase": false}, root), "deactivate")
|
|
if user["deactivated"] != true || user["devices"] != float64(0) {
|
|
t.Errorf("deactivated user = %v", user)
|
|
}
|
|
expectError(t, login(t, srv, "alice", "r3s3t-pw"), http.StatusForbidden, "M_USER_DEACTIVATED", "logging in when deactivated")
|
|
user = mustOK(t, post(t, srv, adminPath("users", aliceID, "reactivate"), map[string]any{}, root), "reactivate")
|
|
if user["deactivated"] != false {
|
|
t.Errorf("reactivated user = %v", user)
|
|
}
|
|
mustOK(t, login(t, srv, "alice", "r3s3t-pw"), "logging in after reactivation")
|
|
|
|
// Every change is in the audit log, newest first.
|
|
logResp := mustOK(t, getWithToken(t, srv, adminPath("audit_log")+"?actor="+url.QueryEscape("@root:warp.example"), root), "audit log")
|
|
var actions []string
|
|
for _, e := range logResp["entries"].([]any) {
|
|
entry := e.(map[string]any)
|
|
actions = append(actions, entry["action"].(string))
|
|
if entry["actor"] != "@root:warp.example" {
|
|
t.Errorf("entry = %v", entry)
|
|
}
|
|
}
|
|
want := []string{"user.reactivate", "user.deactivate", "user.media_quota", "user.media_quota",
|
|
"user.password", "user.device_delete", "user.admin", "user.admin"}
|
|
if strings.Join(actions, ",") != strings.Join(want, ",") {
|
|
t.Errorf("audit actions = %v, want %v", actions, want)
|
|
}
|
|
page := mustOK(t, getWithToken(t, srv, adminPath("audit_log")+"?limit=2", root), "a page of the audit log")
|
|
if entries, _ := page["entries"].([]any); len(entries) != 2 || page["next_before"] == nil {
|
|
t.Errorf("page = %v", page)
|
|
}
|
|
}
|
|
|
|
func TestRegistrationTokensThroughTheAdminAPI(t *testing.T) {
|
|
srv := newSigningServer(t, func(c *config.Config) { c.Registration.RequireToken = true })
|
|
root := adminToken(t, srv, "root")
|
|
|
|
created := httptestCreated(t, post(t, srv, adminPath("registration_tokens"),
|
|
map[string]any{"token": "invite-1", "uses_allowed": 1}, root))
|
|
if created["token"] != "invite-1" || created["uses_allowed"] != float64(1) || created["valid"] != true || created["expiry_time_ms"] != nil {
|
|
t.Errorf("created = %v", created)
|
|
}
|
|
generated := httptestCreated(t, post(t, srv, adminPath("registration_tokens"), map[string]any{"length": 24}, root))
|
|
if tok, _ := generated["token"].(string); len(tok) != 24 || generated["uses_allowed"] != nil {
|
|
t.Errorf("generated = %v", generated)
|
|
}
|
|
expectError(t, post(t, srv, adminPath("registration_tokens"), map[string]any{"token": "invite-1"}, root),
|
|
http.StatusBadRequest, "M_INVALID_PARAM", "a duplicate token")
|
|
expectError(t, post(t, srv, adminPath("registration_tokens"), map[string]any{"token": "not valid!"}, root),
|
|
http.StatusBadRequest, "M_INVALID_PARAM", "a malformed token")
|
|
|
|
validity := func() any {
|
|
return mustOK(t, getWithToken(t, srv, "/_matrix/client/v1/register/m.login.registration_token/validity?token=invite-1", ""),
|
|
"validity")["valid"]
|
|
}
|
|
if validity() != true {
|
|
t.Error("a fresh token is not valid")
|
|
}
|
|
|
|
// Signing up with it spends its one use.
|
|
register := func(username string) *httptest.ResponseRecorder {
|
|
body := map[string]any{"username": username, "password": "sUp3rs3kr1t"}
|
|
rec := post(t, srv, "/_matrix/client/v3/register", body, "")
|
|
session := decode[map[string]any](t, rec)["session"]
|
|
body["auth"] = map[string]any{"type": "m.login.registration_token", "token": "invite-1", "session": session}
|
|
rec = post(t, srv, "/_matrix/client/v3/register", body, "")
|
|
if rec.Code != http.StatusUnauthorized {
|
|
return rec
|
|
}
|
|
body["auth"] = map[string]any{"type": "m.login.dummy", "session": session}
|
|
return post(t, srv, "/_matrix/client/v3/register", body, "")
|
|
}
|
|
mustOK(t, register("alice"), "register with the token")
|
|
if validity() != false {
|
|
t.Error("a used-up token is still valid")
|
|
}
|
|
if rec := register("bob"); rec.Code == http.StatusOK {
|
|
t.Errorf("a used-up token registered bob: %s", rec.Body.String())
|
|
}
|
|
|
|
got := mustOK(t, getWithToken(t, srv, adminPath("registration_tokens", "invite-1"), root), "GET token")
|
|
if got["completed"] != float64(1) || got["valid"] != false {
|
|
t.Errorf("token = %v", got)
|
|
}
|
|
updated := mustOK(t, put(t, srv, adminPath("registration_tokens", "invite-1"), map[string]any{"uses_allowed": nil}, root), "lift the limit")
|
|
if updated["uses_allowed"] != nil || updated["valid"] != true || updated["completed"] != float64(1) {
|
|
t.Errorf("updated = %v", updated)
|
|
}
|
|
mustOK(t, register("bob"), "register after the limit was lifted")
|
|
|
|
list := mustOK(t, getWithToken(t, srv, adminPath("registration_tokens"), root), "list tokens")
|
|
if tokens, _ := list["registration_tokens"].([]any); len(tokens) != 2 || list["required"] != true {
|
|
t.Errorf("list = %v", list)
|
|
}
|
|
mustOK(t, sendJSON(t, srv, http.MethodDelete, adminPath("registration_tokens", "invite-1"), nil, root), "delete")
|
|
expectError(t, getWithToken(t, srv, adminPath("registration_tokens", "invite-1"), root),
|
|
http.StatusNotFound, "M_NOT_FOUND", "a deleted token")
|
|
if validity() != false {
|
|
t.Error("a deleted token is still valid")
|
|
}
|
|
}
|
|
|
|
func httptestCreated(t *testing.T, rec *httptest.ResponseRecorder) map[string]any {
|
|
t.Helper()
|
|
if rec.Code != http.StatusCreated {
|
|
t.Fatalf("want 201, got %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
return decode[map[string]any](t, rec)
|
|
}
|
|
|
|
// noticeSync is the part of a sync the server notice test reads.
|
|
type noticeSync struct {
|
|
Rooms struct {
|
|
Invite map[string]json.RawMessage `json:"invite"`
|
|
Join map[string]struct {
|
|
AccountData struct {
|
|
Events []struct {
|
|
Type string `json:"type"`
|
|
Content json.RawMessage `json:"content"`
|
|
} `json:"events"`
|
|
} `json:"account_data"`
|
|
Timeline struct {
|
|
Events []struct {
|
|
Sender string `json:"sender"`
|
|
Content map[string]any `json:"content"`
|
|
} `json:"events"`
|
|
} `json:"timeline"`
|
|
} `json:"join"`
|
|
} `json:"rooms"`
|
|
}
|
|
|
|
func TestServerNotices(t *testing.T) {
|
|
srv := newSigningServer(t, func(c *config.Config) { c.ServerNotices.Enabled = true })
|
|
root := adminToken(t, srv, "root")
|
|
alice := registerThrough(t, srv, "alice")
|
|
|
|
notice := func(body string) map[string]any {
|
|
return mustOK(t, post(t, srv, adminPath("server_notices"), map[string]any{
|
|
"user_id": "@alice:warp.example",
|
|
"content": map[string]any{"body": body},
|
|
}, root), "send a notice")
|
|
}
|
|
first := notice("Maintenance tonight")
|
|
roomID, _ := first["room_id"].(string)
|
|
|
|
var s noticeSync
|
|
rec := getWithToken(t, srv, "/_matrix/client/v3/sync?timeout=0", alice)
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &s); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, ok := s.Rooms.Invite[roomID]; !ok {
|
|
t.Fatalf("alice has no invite to %s: %s", roomID, rec.Body.String())
|
|
}
|
|
|
|
// A second notice goes to the same room.
|
|
if second := notice("Done"); second["room_id"] != roomID {
|
|
t.Errorf("second notice went to %v, want %s", second["room_id"], roomID)
|
|
}
|
|
|
|
mustOK(t, post(t, srv, "/_matrix/client/v3/join/"+url.PathEscape(roomID), map[string]any{}, alice), "join the notice room")
|
|
rec = getWithToken(t, srv, "/_matrix/client/v3/sync?timeout=0", alice)
|
|
s = noticeSync{}
|
|
_ = json.Unmarshal(rec.Body.Bytes(), &s)
|
|
joined := s.Rooms.Join[roomID]
|
|
var bodies []string
|
|
for _, e := range joined.Timeline.Events {
|
|
if body, _ := e.Content["body"].(string); body != "" {
|
|
bodies = append(bodies, body)
|
|
if e.Sender != "@server-notices:warp.example" || e.Content["msgtype"] != "m.text" {
|
|
t.Errorf("notice event = %+v", e)
|
|
}
|
|
}
|
|
}
|
|
if strings.Join(bodies, "|") != "Maintenance tonight|Done" {
|
|
t.Errorf("notices = %v (%s)", bodies, rec.Body.String())
|
|
}
|
|
var tagged bool
|
|
for _, e := range joined.AccountData.Events {
|
|
if e.Type == "m.tag" && strings.Contains(string(e.Content), "m.server_notice") {
|
|
tagged = true
|
|
}
|
|
}
|
|
if !tagged {
|
|
t.Errorf("the notice room has no m.server_notice tag: %s", rec.Body.String())
|
|
}
|
|
|
|
// The user cannot answer, and a user who leaves is invited back.
|
|
expectError(t, put(t, srv, "/_matrix/client/v3/rooms/"+url.PathEscape(roomID)+"/send/m.room.message/x",
|
|
map[string]any{"msgtype": "m.text", "body": "hi"}, alice), http.StatusForbidden, "M_FORBIDDEN", "replying to a notice")
|
|
mustOK(t, post(t, srv, "/_matrix/client/v3/rooms/"+url.PathEscape(roomID)+"/leave", map[string]any{}, alice), "leave")
|
|
notice("Again")
|
|
if membership, _ := srv.Rooms().Membership(t.Context(), roomID, "@alice:warp.example"); membership != "invite" {
|
|
t.Errorf("after leaving, membership = %q", membership)
|
|
}
|
|
|
|
expectError(t, post(t, srv, adminPath("server_notices"), map[string]any{
|
|
"user_id": "@nobody:warp.example", "content": map[string]any{"body": "x"},
|
|
}, root), http.StatusNotFound, "M_NOT_FOUND", "a notice to nobody")
|
|
expectError(t, post(t, srv, adminPath("server_notices"), map[string]any{
|
|
"user_id": "@alice:warp.example", "content": map[string]any{},
|
|
}, root), http.StatusBadRequest, "M_MISSING_PARAM", "a notice without a body")
|
|
}
|
|
|
|
func TestServerNoticesOffByDefault(t *testing.T) {
|
|
srv := newSigningServer(t, nil)
|
|
root := adminToken(t, srv, "root")
|
|
expectError(t, post(t, srv, adminPath("server_notices"), map[string]any{
|
|
"user_id": "@root:warp.example", "content": map[string]any{"body": "x"},
|
|
}, root), http.StatusConflict, "M_FORBIDDEN", "server notices when they are off")
|
|
if ok, _ := srv.Users().AccountExists(t.Context(), "server-notices"); ok {
|
|
t.Error("the notices user exists although notices are off")
|
|
}
|
|
}
|
|
|
|
func TestFederationListsThroughTheAdminAPI(t *testing.T) {
|
|
srv := newSigningServer(t, func(c *config.Config) { c.Federation.Denylist = []string{"bad.example"} })
|
|
root := adminToken(t, srv, "root")
|
|
|
|
got := mustOK(t, getWithToken(t, srv, adminPath("federation"), root), "GET federation")
|
|
if got["lists_edited"] != false || mustJSON(t, got["denylist"]) != `["bad.example"]` {
|
|
t.Errorf("federation = %v", got)
|
|
}
|
|
|
|
got = mustOK(t, put(t, srv, adminPath("federation", "lists"), map[string]any{
|
|
"allowlist": []string{"friend.example", "Other.Example"}, "denylist": []string{},
|
|
}, root), "PUT lists")
|
|
if got["lists_edited"] != true || mustJSON(t, got["allowlist"]) != `["friend.example","other.example"]` ||
|
|
mustJSON(t, got["denylist"]) != `[]` {
|
|
t.Errorf("after PUT = %v", got)
|
|
}
|
|
expectError(t, put(t, srv, adminPath("federation", "lists"), map[string]any{"allowlist": []string{}}, root),
|
|
http.StatusBadRequest, "M_MISSING_PARAM", "only one list")
|
|
expectError(t, put(t, srv, adminPath("federation", "lists"), map[string]any{
|
|
"allowlist": []string{"not a name"}, "denylist": []string{},
|
|
}, root), http.StatusBadRequest, "M_INVALID_PARAM", "a bad server name")
|
|
|
|
got = mustOK(t, sendJSON(t, srv, http.MethodDelete, adminPath("federation", "lists"), nil, root), "DELETE lists")
|
|
if got["lists_edited"] != false || mustJSON(t, got["allowlist"]) != `[]` || mustJSON(t, got["denylist"]) != `["bad.example"]` {
|
|
t.Errorf("after DELETE = %v", got)
|
|
}
|
|
}
|