584 lines
24 KiB
Go
584 lines
24 KiB
Go
|
|
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)
|
|||
|
|
}
|
|||
|
|
}
|