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) } }