package infra import ( "context" "encoding/json" "io" "net/http" "net/http/httptest" "strings" "testing" ) func newSynapseTestServer(t *testing.T) *httptest.Server { t.Helper() mux := http.NewServeMux() // GET /_synapse/admin/v2/users (list) // Note: exact path match (no trailing slash) catches the list endpoint only. mux.HandleFunc("/_synapse/admin/v2/users", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, `{"errcode":"M_UNKNOWN","error":"bad method"}`, http.StatusMethodNotAllowed) return } if r.Header.Get("Authorization") == "Bearer bad" { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusForbidden) w.Write([]byte(`{"errcode":"M_FORBIDDEN","error":"not admin"}`)) return } w.Header().Set("Content-Type", "application/json") nextToken := 2 json.NewEncoder(w).Encode(map[string]interface{}{ "users": []map[string]interface{}{ {"name": "@alice:server", "admin": true, "deactivated": false, "creation_ts": 1000}, {"name": "@bob:server", "admin": false, "deactivated": false, "creation_ts": 2000}, }, "total": 2, "next_token": nextToken, }) }) // GET /_synapse/admin/v2/users/{userID} (single user + devices) mux.HandleFunc("/_synapse/admin/v2/users/", func(w http.ResponseWriter, r *http.Request) { suffix := strings.TrimPrefix(r.URL.Path, "/_synapse/admin/v2/users/") // devices sub-path if strings.HasSuffix(suffix, "/devices") { if r.Method != http.MethodGet { http.Error(w, `{"errcode":"M_UNKNOWN","error":"bad method"}`, http.StatusMethodNotAllowed) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "devices": []map[string]interface{}{ {"device_id": "AABBCC", "display_name": "Alice's phone", "last_seen_ip": "1.2.3.4", "last_seen_ts": 9999}, {"device_id": "DDEEFF", "display_name": "Alice's laptop", "last_seen_ip": "5.6.7.8", "last_seen_ts": 8888}, }, "total": 2, }) return } // single device delete sub-path: /{userID}/devices/{deviceID} if strings.Contains(suffix, "/devices/") { if r.Method != http.MethodDelete { http.Error(w, `{"errcode":"M_UNKNOWN","error":"bad method"}`, http.StatusMethodNotAllowed) return } w.WriteHeader(http.StatusOK) w.Write([]byte(`{}`)) return } // single user GET if r.Method != http.MethodGet { http.Error(w, `{"errcode":"M_UNKNOWN","error":"bad method"}`, http.StatusMethodNotAllowed) return } // 404 for missing user if strings.Contains(suffix, "missing") { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusNotFound) w.Write([]byte(`{"errcode":"M_NOT_FOUND","error":"User not found"}`)) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(AdminUser{ UserID: "@alice:server", DisplayName: "Alice", Admin: true, }) }) // POST /_synapse/admin/v1/deactivate/{userID} mux.HandleFunc("/_synapse/admin/v1/deactivate/", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, `{"errcode":"M_UNKNOWN","error":"bad method"}`, http.StatusMethodNotAllowed) return } body, _ := io.ReadAll(r.Body) var req map[string]interface{} if err := json.Unmarshal(body, &req); err != nil { http.Error(w, `{"errcode":"M_BAD_JSON","error":"bad json"}`, http.StatusBadRequest) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{"id_server_unbind_result": "success"}) }) // GET /_synapse/admin/v1/rooms (list) mux.HandleFunc("/_synapse/admin/v1/rooms", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, `{"errcode":"M_UNKNOWN","error":"bad method"}`, http.StatusMethodNotAllowed) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "rooms": []map[string]interface{}{ {"room_id": "!abc:server", "name": "general", "joined_members": 5}, {"room_id": "!xyz:server", "name": "off-topic", "joined_members": 3}, }, "total_rooms": 2, }) }) // GET /_synapse/admin/v1/rooms/{roomID} mux.HandleFunc("/_synapse/admin/v1/rooms/", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, `{"errcode":"M_UNKNOWN","error":"bad method"}`, http.StatusMethodNotAllowed) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(AdminRoom{RoomID: "!abc:server", Name: "general", JoinedMembers: 5}) }) // DELETE /_synapse/admin/v2/rooms/{roomID} (async delete) mux.HandleFunc("/_synapse/admin/v2/rooms/", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodDelete { http.Error(w, `{"errcode":"M_UNKNOWN","error":"bad method"}`, http.StatusMethodNotAllowed) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{"delete_id": "del_001"}) }) return httptest.NewServer(mux) } func TestSynapseAdminClient(t *testing.T) { srv := newSynapseTestServer(t) defer srv.Close() cl := NewSynapseAdminClient(srv.URL, "mxat_test_token") ctx := context.Background() t.Run("ListUsers parses + counts", func(t *testing.T) { res, err := cl.ListUsers(ctx, ListUsersFilter{From: 0, Limit: 50}) if err != nil { t.Fatalf("ListUsers: %v", err) } if res.TotalCount != 2 { t.Errorf("TotalCount: got %d, want 2", res.TotalCount) } if len(res.Users) != 2 { t.Fatalf("len(Users): got %d, want 2", len(res.Users)) } if res.Users[0].UserID != "@alice:server" { t.Errorf("Users[0].UserID: got %q, want @alice:server", res.Users[0].UserID) } if !res.Users[0].Admin { t.Error("Users[0].Admin should be true") } if res.NextToken == nil { t.Error("NextToken should be non-nil (test server returns next_token=2)") } else if *res.NextToken != 2 { t.Errorf("NextToken: got %d, want 2", *res.NextToken) } }) t.Run("GetUser inexistente -> error contiene M_NOT_FOUND", func(t *testing.T) { _, err := cl.GetUser(ctx, "@missing:server") if err == nil { t.Fatal("expected error, got nil") } if !strings.Contains(err.Error(), "M_NOT_FOUND") { t.Errorf("error should contain M_NOT_FOUND, got: %v", err) } }) t.Run("DeactivateUser ok", func(t *testing.T) { // Verify via a targeted server that erase=true reaches the body. var gotErase bool deactivateSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { body, _ := io.ReadAll(r.Body) var req map[string]interface{} json.Unmarshal(body, &req) if v, ok := req["erase"].(bool); ok { gotErase = v } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{"id_server_unbind_result": "success"}) })) defer deactivateSrv.Close() clDe := NewSynapseAdminClient(deactivateSrv.URL, "tok") if err := clDe.DeactivateUser(ctx, "@user:server", true); err != nil { t.Fatalf("DeactivateUser: %v", err) } if !gotErase { t.Error("erase=true not forwarded in request body") } }) t.Run("DeleteRoom devuelve delete_id", func(t *testing.T) { var gotPurge, gotBlock bool deleteSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodDelete { http.Error(w, `{}`, 405) return } body, _ := io.ReadAll(r.Body) var req map[string]interface{} json.Unmarshal(body, &req) if v, ok := req["purge"].(bool); ok { gotPurge = v } if v, ok := req["block"].(bool); ok { gotBlock = v } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{"delete_id": "del_007"}) })) defer deleteSrv.Close() clDel := NewSynapseAdminClient(deleteSrv.URL, "tok") deleteID, err := clDel.DeleteRoom(ctx, "!room:server", "cleanup", true, true) if err != nil { t.Fatalf("DeleteRoom: %v", err) } if deleteID != "del_007" { t.Errorf("deleteID: got %q, want del_007", deleteID) } if !gotPurge { t.Error("purge=true not forwarded in request body") } if !gotBlock { t.Error("block=true not forwarded in request body") } }) t.Run("ListUserDevices parses array", func(t *testing.T) { devices, err := cl.ListUserDevices(ctx, "@alice:server") if err != nil { t.Fatalf("ListUserDevices: %v", err) } if len(devices) != 2 { t.Fatalf("len(devices): got %d, want 2", len(devices)) } if devices[0].DeviceID != "AABBCC" { t.Errorf("devices[0].DeviceID: got %q, want AABBCC", devices[0].DeviceID) } if devices[0].LastSeenIP != "1.2.3.4" { t.Errorf("devices[0].LastSeenIP: got %q, want 1.2.3.4", devices[0].LastSeenIP) } }) t.Run("HTTP 403 -> error con errcode M_FORBIDDEN", func(t *testing.T) { badCl := NewSynapseAdminClient(srv.URL, "bad") _, err := badCl.ListUsers(ctx, ListUsersFilter{}) if err == nil { t.Fatal("expected error for 403, got nil") } if !strings.Contains(err.Error(), "M_FORBIDDEN") { t.Errorf("error should contain M_FORBIDDEN, got: %v", err) } }) }