package server import ( "net/http" "net/http/httptest" "strings" "testing" "time" ) // Тесты расширенной админ-панели инстанса (AGENT.md 3.2, 7.18, 11.5): // обычный пользователь не видит разделы, администратор получает данные, // чувствительные действия требуют step-up и попадают в аудит. // instanceGet вызывает ручку панели администратором и проверяет статус. func instanceGet(t *testing.T, srv *Server, cookie *http.Cookie, path string, want int) *httptest.ResponseRecorder { t.Helper() rec := doJSON(t, srv, http.MethodGet, path, "", cookie) if rec.Code != want { t.Fatalf("GET %s = %d, want %d (body: %s)", path, rec.Code, want, rec.Body.String()) } return rec } // TestInstancePanelRequiresAdmin: разделы расширенной панели закрыты для // обычного пользователя (AGENT.md 7.18, 11.5). func TestInstancePanelRequiresAdmin(t *testing.T) { srv, _ := newTestServer(t) userCookie := registerAndLogin(t, srv, "panel_user", "panel-user@example.com") target, err := srv.auth.UserByEmail(t.Context(), "panel-user@example.com") if err != nil { t.Fatalf("UserByEmail: %v", err) } targetPath := formatSnowflake(target.ID) for _, path := range []string{ "/api/v1/instance/stats", "/api/v1/instance/audit", "/api/v1/instance/audit/actions", "/api/v1/instance/audit/export", "/api/v1/instance/users/" + targetPath, "/api/v1/instance/guilds?limit=10", } { rec := doJSON(t, srv, http.MethodGet, path, "", userCookie) if rec.Code != http.StatusForbidden { t.Fatalf("GET %s обычным пользователем = %d, want 403", path, rec.Code) } } } // TestInstanceStatsVisibility: статистика инстанса считает пользователей, // сообщения и серверы, а обычному пользователю недоступна (AGENT.md 7.18). func TestInstanceStatsVisibility(t *testing.T) { srv, _ := newTestServer(t) adminCookie := registerAndLogin(t, srv, "stats_admin", "stats-admin@example.com") promoteAdmin(t, srv, "stats-admin@example.com") registerAndLogin(t, srv, "stats_plain", "stats-plain@example.com") rec := instanceGet(t, srv, adminCookie, "/api/v1/instance/stats", http.StatusOK) stats := decodeResponse[struct { Stats struct { Users struct { Total int `json:"total"` Admins int `json:"admins"` New7Days int `json:"new_7_days"` Daily []struct { Date string `json:"date"` Count int `json:"count"` } `json:"daily"` } `json:"users"` Messages struct { Total int `json:"total"` Today int `json:"today"` } `json:"messages"` Guilds int `json:"guilds"` DatabaseBytes int64 `json:"database_bytes"` StorageBytes int64 `json:"storage_bytes"` } `json:"stats"` }](t, rec) if stats.Stats.Users.Total < 2 || stats.Stats.Users.New7Days < 2 { t.Fatalf("пользователи в статистике: %+v", stats.Stats.Users) } if stats.Stats.Users.Admins != 1 { t.Fatalf("администраторов = %d, want 1", stats.Stats.Users.Admins) } if len(stats.Stats.Users.Daily) == 0 { t.Fatal("рост пользователей по дням пуст") } if stats.Stats.DatabaseBytes <= 0 || stats.Stats.StorageBytes < stats.Stats.DatabaseBytes { t.Fatalf("размеры данных: %+v", stats.Stats) } } // TestInstanceAuditFiltersAndExport: фильтры журнала, пагинация и выгрузка CSV // (AGENT.md 7.10, 7.18). func TestInstanceAuditFiltersAndExport(t *testing.T) { srv, _ := newTestServer(t) adminCookie := registerAndLogin(t, srv, "audit_admin", "audit-admin@example.com") promoteAdmin(t, srv, "audit-admin@example.com") registerAndLogin(t, srv, "audit_target", "audit-target@example.com") target, err := srv.auth.UserByEmail(t.Context(), "audit-target@example.com") if err != nil { t.Fatalf("UserByEmail: %v", err) } targetID := formatSnowflake(target.ID) // Действия администратора создают записи журнала. if rec := doJSON(t, srv, http.MethodPost, "/api/v1/instance/users/"+targetID+"/logout", "", adminCookie); rec.Code != http.StatusOK { t.Fatalf("logout = %d (%s)", rec.Code, rec.Body.String()) } if rec := doJSON(t, srv, http.MethodPatch, "/api/v1/instance/settings", `{"max_message_length":4001}`, adminCookie); rec.Code != http.StatusOK { t.Fatalf("settings = %d (%s)", rec.Code, rec.Body.String()) } listAudit := func(query string) ([]string, int) { t.Helper() path := "/api/v1/instance/audit" if query != "" { path += "?" + query } rec := instanceGet(t, srv, adminCookie, path, http.StatusOK) payload := decodeResponse[struct { Entries []struct { Action string `json:"action"` TargetID string `json:"target_id"` } `json:"entries"` Total int `json:"total"` }](t, rec) actions := make([]string, 0, len(payload.Entries)) for _, entry := range payload.Entries { actions = append(actions, entry.Action) } return actions, payload.Total } all, total := listAudit("") if total < 2 || len(all) < 2 { t.Fatalf("журнал пуст: %v (total %d)", all, total) } byAction, total := listAudit("action=instance.user_logout") if total != 1 || len(byAction) != 1 || byAction[0] != "instance.user_logout" { t.Fatalf("фильтр по действию: %v (total %d)", byAction, total) } byTarget, total := listAudit("target_id=" + targetID) if total != 1 || len(byTarget) != 1 { t.Fatalf("фильтр по цели: %v (total %d)", byTarget, total) } actorID := "" { admin, err := srv.auth.UserByEmail(t.Context(), "audit-admin@example.com") if err != nil { t.Fatalf("UserByEmail: %v", err) } actorID = formatSnowflake(admin.ID) } byActor, total := listAudit("actor_id=" + actorID) if total < 2 || len(byActor) < 2 { t.Fatalf("фильтр по актору: %v (total %d)", byActor, total) } today := time.Now().UTC().Format("2006-01-02") byDate, total := listAudit("since=" + today + "&until=" + today) if total < 2 { t.Fatalf("фильтр по дате: %v (total %d)", byDate, total) } if _, total := listAudit("since=2030-01-01"); total != 0 { t.Fatalf("фильтр «в будущем» вернул %d записей", total) } page1, _ := listAudit("limit=1&offset=0") page2, _ := listAudit("limit=1&offset=1") if len(page1) != 1 || len(page2) != 1 || page1[0] == page2[0] { t.Fatalf("пагинация журнала: %v / %v", page1, page2) } // Неверная дата — понятная ошибка валидации, а не пустой журнал. bad := doJSON(t, srv, http.MethodGet, "/api/v1/instance/audit?since=вчера", "", adminCookie) if bad.Code != http.StatusUnprocessableEntity { t.Fatalf("неверная дата = %d, want 422", bad.Code) } // Список действий для фильтра. actionsRec := instanceGet(t, srv, adminCookie, "/api/v1/instance/audit/actions", http.StatusOK) actions := decodeResponse[struct { Actions []string `json:"actions"` }](t, actionsRec) foundLogout := false for _, action := range actions.Actions { if action == "instance.user_logout" { foundLogout = true } } if !foundLogout { t.Fatalf("список действий не содержит instance.user_logout: %v", actions.Actions) } // Экспорт CSV: тот же фильтр, заголовок и строки. export := doJSON(t, srv, http.MethodGet, "/api/v1/instance/audit/export?action=instance.user_logout", "", adminCookie) if export.Code != http.StatusOK { t.Fatalf("экспорт = %d (%s)", export.Code, export.Body.String()) } if contentType := export.Header().Get("Content-Type"); !strings.HasPrefix(contentType, "text/csv") { t.Fatalf("Content-Type экспорта = %q", contentType) } body := export.Body.String() if !strings.Contains(body, "id,created_at,action") || !strings.Contains(body, "instance.user_logout") { t.Fatalf("тело экспорта: %s", body) } if strings.Contains(body, "instance.settings_update") { t.Fatal("экспорт не учёл фильтр по действию") } // Экспорт — тоже событие журнала. _, total = listAudit("action=instance.audit_export") if total != 1 { t.Fatalf("экспорт не записан в аудит: total %d", total) } } // TestInstanceUserCardShowsDetails: карточка пользователя отдаёт устройства, // серверы с ролями, события безопасности и аудит по нему (AGENT.md 7.18). func TestInstanceUserCardShowsDetails(t *testing.T) { srv, _ := newTestServer(t) adminCookie := registerAndLogin(t, srv, "card_admin", "card-admin@example.com") promoteAdmin(t, srv, "card-admin@example.com") targetCookie := registerAndLogin(t, srv, "card_target", "card-target@example.com") target, err := srv.auth.UserByEmail(t.Context(), "card-target@example.com") if err != nil { t.Fatalf("UserByEmail: %v", err) } targetID := formatSnowflake(target.ID) // У цели есть сервер (создаётся вместе с ролями по умолчанию) — карточка // должна показать участие и роли. created := doJSON(t, srv, http.MethodPost, "/api/v1/guilds", `{"name":"Карточка"}`, targetCookie) if created.Code != http.StatusOK { t.Fatalf("создать сервер = %d (%s)", created.Code, created.Body.String()) } rec := instanceGet(t, srv, adminCookie, "/api/v1/instance/users/"+targetID, http.StatusOK) card := decodeResponse[struct { User struct { ID string `json:"id"` Username string `json:"username"` } `json:"user"` TOTPEnabled bool `json:"totp_enabled"` Passkeys int `json:"passkeys"` Sessions []struct { ID string `json:"id"` UserAgent string `json:"user_agent"` } `json:"sessions"` Guilds []struct { GuildName string `json:"guild_name"` Roles []struct { Name string `json:"name"` } `json:"roles"` } `json:"guilds"` SecurityEvents []struct { Type string `json:"type"` } `json:"security_events"` Audit []any `json:"audit"` AuditTotal int `json:"audit_total"` }](t, rec) if card.User.ID != targetID || card.User.Username != "card_target" { t.Fatalf("профиль в карточке: %+v", card.User) } if len(card.Sessions) != 1 || card.Sessions[0].ID == "" { t.Fatalf("устройства в карточке: %+v", card.Sessions) } if len(card.SecurityEvents) == 0 { t.Fatal("события безопасности не попали в карточку") } if card.TOTPEnabled || card.Passkeys != 0 { t.Fatalf("второй фактор/ключи: %v/%d", card.TOTPEnabled, card.Passkeys) } if len(card.Guilds) != 1 || card.Guilds[0].GuildName != "Карточка" { t.Fatalf("серверы в карточке: %+v", card.Guilds) } if len(card.Guilds[0].Roles) == 0 { t.Fatalf("роли в карточке пусты: %+v", card.Guilds[0]) } // Несуществующий пользователь — 404, а не пустая карточка. missing := doJSON(t, srv, http.MethodGet, "/api/v1/instance/users/999999999", "", adminCookie) if missing.Code != http.StatusNotFound { t.Fatalf("карточка несуществующего = %d, want 404", missing.Code) } } // TestInstanceRevokeSession: отзыв одного устройства требует step-up, попадает // в аудит и в события безопасности, второе устройство продолжает работать // (AGENT.md 7.1, 7.18, 11.6). func TestInstanceRevokeSession(t *testing.T) { srv, _ := newTestServer(t) adminCookie := registerAndLogin(t, srv, "revoke_admin", "revoke-admin@example.com") promoteAdmin(t, srv, "revoke-admin@example.com") firstCookie := registerAndLogin(t, srv, "revoke_target", "revoke-target@example.com") // Второе устройство: обычный вход тем же паролем. second := doJSON(t, srv, http.MethodPost, "/api/v1/auth/login", `{"email":"revoke-target@example.com","password":"correct-horse-battery"}`) if second.Code != http.StatusOK { t.Fatalf("второй вход = %d (%s)", second.Code, second.Body.String()) } secondCookie := second.Result().Cookies()[0] target, err := srv.auth.UserByEmail(t.Context(), "revoke-target@example.com") if err != nil { t.Fatalf("UserByEmail: %v", err) } targetID := formatSnowflake(target.ID) cardRec := instanceGet(t, srv, adminCookie, "/api/v1/instance/users/"+targetID, http.StatusOK) card := decodeResponse[struct { Sessions []struct { ID string `json:"id"` } `json:"sessions"` }](t, cardRec) if len(card.Sessions) != 2 { t.Fatalf("устройств у цели = %d, want 2", len(card.Sessions)) } // Определяем, какая из сессий принадлежит второму устройству: отзываем // первую и проверяем, что хотя бы одно устройство продолжает работать. revokePath := "/api/v1/instance/users/" + targetID + "/sessions/" + card.Sessions[0].ID noStepUp := doJSON(t, srv, http.MethodDelete, revokePath, `{}`, adminCookie) if noStepUp.Code != http.StatusForbidden { t.Fatalf("отзыв без step-up = %d, want 403", noStepUp.Code) } if code := errorCodeOf(t, noStepUp); code != "auth.step_up_required" { t.Fatalf("код отказа без step-up = %q", code) } ok := doJSON(t, srv, http.MethodDelete, revokePath, `{"step_up_password":"correct-horse-battery"}`, adminCookie) if ok.Code != http.StatusOK { t.Fatalf("отзыв устройства = %d (%s)", ok.Code, ok.Body.String()) } // Отозванное устройство теряет доступ, второе продолжает работать. alive := 0 for _, cookie := range []*http.Cookie{firstCookie, secondCookie} { rec := doJSON(t, srv, http.MethodGet, "/api/v1/users/@me", "", cookie) if rec.Code == http.StatusOK { alive++ } } if alive != 1 { t.Fatalf("после отзыва работает устройств: %d, want 1", alive) } // Повторный отзыв той же сессии — 404. again := doJSON(t, srv, http.MethodDelete, revokePath, `{"step_up_password":"correct-horse-battery"}`, adminCookie) if again.Code != http.StatusNotFound { t.Fatalf("повторный отзыв = %d, want 404", again.Code) } // Аудит и событие безопасности записаны. auditRec := instanceGet(t, srv, adminCookie, "/api/v1/instance/audit?action=instance.user_session_revoke", http.StatusOK) audit := decodeResponse[struct { Total int `json:"total"` }](t, auditRec) if audit.Total != 1 { t.Fatalf("записей аудита об отзыве = %d, want 1", audit.Total) } cardRec = instanceGet(t, srv, adminCookie, "/api/v1/instance/users/"+targetID, http.StatusOK) card = decodeResponse[struct { Sessions []struct { ID string `json:"id"` } `json:"sessions"` }](t, cardRec) if len(card.Sessions) != 1 { t.Fatalf("устройств после отзыва = %d, want 1", len(card.Sessions)) } eventsRec := instanceGet(t, srv, adminCookie, "/api/v1/instance/users/"+targetID, http.StatusOK) events := decodeResponse[struct { SecurityEvents []struct { Type string `json:"type"` Metadata struct { By string `json:"by"` } `json:"metadata"` } `json:"security_events"` }](t, eventsRec) found := false for _, event := range events.SecurityEvents { if event.Type == "session_revoked" && event.Metadata.By == "instance_admin" { found = true } } if !found { t.Fatalf("событие session_revoked не записано: %+v", events.SecurityEvents) } } // TestInstanceReset2FA: сброс второго фактора требует step-up, защищает // администраторов и записывается в аудит и события безопасности (AGENT.md 7.18). func TestInstanceReset2FA(t *testing.T) { srv, _ := newTestServer(t) adminCookie := registerAndLogin(t, srv, "tfa_admin", "tfa-admin@example.com") promoteAdmin(t, srv, "tfa-admin@example.com") registerAndLogin(t, srv, "tfa_target", "tfa-target@example.com") target, err := srv.auth.UserByEmail(t.Context(), "tfa-target@example.com") if err != nil { t.Fatalf("UserByEmail: %v", err) } targetID := formatSnowflake(target.ID) resetPath := "/api/v1/instance/users/" + targetID + "/reset-2fa" // Без step-up сброс отклоняется: проверка свежести аутентификации идёт до // проверок цели (AGENT.md 7.1). noStepUp := doJSON(t, srv, http.MethodPost, resetPath, `{}`, adminCookie) if noStepUp.Code != http.StatusForbidden { t.Fatalf("сброс без step-up = %d, want 403", noStepUp.Code) } if code := errorCodeOf(t, noStepUp); code != "auth.step_up_required" { t.Fatalf("код отказа без step-up = %q", code) } // С подтверждением, но без включённого второго фактора сбрасывать нечего. empty := doJSON(t, srv, http.MethodPost, resetPath, `{"step_up_password":"correct-horse-battery"}`, adminCookie) if empty.Code != http.StatusUnprocessableEntity { t.Fatalf("сброс без 2FA = %d, want 422", empty.Code) } // Включаем второй фактор напрямую в хранилище (как это делает раздел // безопасности) и сбрасываем его администратором. if err := srv.store.UpsertTOTPSecret(t.Context(), target.ID, "encrypted-secret"); err != nil { t.Fatalf("записать секрет: %v", err) } if err := srv.store.EnableTOTP(t.Context(), target.ID, nil); err != nil { t.Fatalf("включить 2FA: %v", err) } ok := doJSON(t, srv, http.MethodPost, resetPath, `{"step_up_password":"correct-horse-battery"}`, adminCookie) if ok.Code != http.StatusOK { t.Fatalf("сброс 2FA = %d (%s)", ok.Code, ok.Body.String()) } secret, err := srv.store.GetTOTPSecret(t.Context(), target.ID) if err == nil && secret.Enabled { t.Fatal("второй фактор остался включённым после сброса") } // Аудит и событие безопасности. auditRec := instanceGet(t, srv, adminCookie, "/api/v1/instance/audit?action=instance.user_2fa_reset", http.StatusOK) audit := decodeResponse[struct { Total int `json:"total"` }](t, auditRec) if audit.Total != 1 { t.Fatalf("записей аудита о сбросе 2FA = %d, want 1", audit.Total) } cardRec := instanceGet(t, srv, adminCookie, "/api/v1/instance/users/"+targetID, http.StatusOK) card := decodeResponse[struct { TOTPEnabled bool `json:"totp_enabled"` SecurityEvents []struct { Type string `json:"type"` } `json:"security_events"` }](t, cardRec) if card.TOTPEnabled { t.Fatal("карточка всё ещё показывает включённый второй фактор") } foundEvent := false for _, event := range card.SecurityEvents { if event.Type == "2fa_change" { foundEvent = true } } if !foundEvent { t.Fatalf("событие 2fa_change не записано: %+v", card.SecurityEvents) } // Себя сбрасывать нельзя: свой второй фактор меняют в настройках. self := doJSON(t, srv, http.MethodPost, "/api/v1/instance/users/"+adminIDOf(t, srv, "tfa-admin@example.com")+"/reset-2fa", `{"step_up_password":"correct-horse-battery"}`, adminCookie) if self.Code != http.StatusUnprocessableEntity { t.Fatalf("сброс себе = %d, want 422", self.Code) } // Другого администратора — тоже: иначе сброс чужого ключа обходил бы // обязательный второй фактор инстанс-админа (AGENT.md 7.19). registerAndLogin(t, srv, "tfa_admin2", "tfa-admin2@example.com") promoteAdmin(t, srv, "tfa-admin2@example.com") protected := doJSON(t, srv, http.MethodPost, "/api/v1/instance/users/"+adminIDOf(t, srv, "tfa-admin2@example.com")+"/reset-2fa", `{"step_up_password":"correct-horse-battery"}`, adminCookie) if protected.Code != http.StatusForbidden { t.Fatalf("сброс 2FA другого администратора = %d, want 403", protected.Code) } } // adminIDOf возвращает snowflake-идентификатор пользователя по email. func adminIDOf(t *testing.T, srv *Server, email string) string { t.Helper() user, err := srv.auth.UserByEmail(t.Context(), email) if err != nil { t.Fatalf("UserByEmail(%s): %v", email, err) } return formatSnowflake(user.ID) } // TestInstanceGuildListFilters: список серверов панели ищет по названию без // учёта регистра, фильтрует по владельцу и листается (AGENT.md 7.18). func TestInstanceGuildListFilters(t *testing.T) { srv, _ := newTestServer(t) adminCookie := registerAndLogin(t, srv, "list_admin", "list-admin@example.com") promoteAdmin(t, srv, "list-admin@example.com") ownerCookie := registerAndLogin(t, srv, "list_owner", "list-owner@example.com") ownerID := adminIDOf(t, srv, "list-owner@example.com") for _, name := range []string{"Альфа", "Бета"} { rec := doJSON(t, srv, http.MethodPost, "/api/v1/guilds", `{"name":"`+name+`"}`, ownerCookie) if rec.Code != http.StatusOK { t.Fatalf("создать сервер %s = %d (%s)", name, rec.Code, rec.Body.String()) } } type guildList struct { Guilds []struct { ID string `json:"id"` Name string `json:"name"` OwnerID string `json:"owner_id"` OwnerName string `json:"owner_name"` MemberCount int `json:"member_count"` } `json:"guilds"` Total int `json:"total"` } allRec := instanceGet(t, srv, adminCookie, "/api/v1/instance/guilds?limit=200", http.StatusOK) list := decodeResponse[guildList](t, allRec) if list.Total < 2 || len(list.Guilds) < 2 { t.Fatalf("список серверов: %+v (total %d)", list.Guilds, list.Total) } found := false for _, guild := range list.Guilds { if guild.Name == "Альфа" { found = true if guild.OwnerName != "list_owner" || guild.MemberCount != 1 { t.Fatalf("строка сервера: %+v", guild) } } } if !found { t.Fatal("сервер «Альфа» не найден") } // Поиск без учёта регистра: кириллица в SQLite lower() не приводится, // поэтому сравнивается нормализованная копия названия. searchRec := instanceGet(t, srv, adminCookie, "/api/v1/instance/guilds?q=альф", http.StatusOK) search := decodeResponse[guildList](t, searchRec) if search.Total != 1 || len(search.Guilds) != 1 || search.Guilds[0].Name != "Альфа" { t.Fatalf("поиск по названию: %+v (total %d)", search.Guilds, search.Total) } ownerRec := instanceGet(t, srv, adminCookie, "/api/v1/instance/guilds?owner_id="+ownerID, http.StatusOK) byOwner := decodeResponse[guildList](t, ownerRec) if byOwner.Total != 2 { t.Fatalf("фильтр по владельцу: total %d, want 2", byOwner.Total) } pageRec := instanceGet(t, srv, adminCookie, "/api/v1/instance/guilds?limit=1&offset=1", http.StatusOK) page := decodeResponse[guildList](t, pageRec) if len(page.Guilds) != 1 || page.Total != byOwner.Total { t.Fatalf("пагинация серверов: %+v (total %d)", page.Guilds, page.Total) } // Переименование обновляет нормализованное название: старый поиск пуст. if _, adminErr := srv.auth.UserByEmail(t.Context(), "list-admin@example.com"); adminErr != nil { t.Fatalf("UserByEmail: %v", adminErr) } renameRec := doJSON(t, srv, http.MethodPatch, "/api/v1/instance/guilds/"+search.Guilds[0].ID, `{"name":"Гамма"}`, adminCookie) if renameRec.Code != http.StatusOK { t.Fatalf("переименование = %d (%s)", renameRec.Code, renameRec.Body.String()) } oldRec := instanceGet(t, srv, adminCookie, "/api/v1/instance/guilds?q=альф", http.StatusOK) old := decodeResponse[guildList](t, oldRec) if old.Total != 0 { t.Fatalf("после переименования старый поиск вернул %d записей", old.Total) } newRec := instanceGet(t, srv, adminCookie, "/api/v1/instance/guilds?q=гамма", http.StatusOK) renamed := decodeResponse[guildList](t, newRec) if renamed.Total != 1 { t.Fatalf("поиск по новому названию: total %d, want 1", renamed.Total) } }