feat(api): расширенная админ-панель инстанса (Фаза 7)

Статистика, карточка пользователя и работа с журналом для администратора
инстанса (AGENT.md 3.2, 7.18):

- GET /instance/stats: рост пользователей и сообщений по дням, активность,
  размеры базы и файлов, топы серверов по участникам и сообщениям;
- GET /instance/users/{id}: профиль, активные устройства, серверы с ролями,
  события безопасности и аудит по пользователю;
- DELETE /instance/users/{id}/sessions/{sid} и POST .../reset-2fa: отзыв
  одного устройства и сброс второго фактора со step-up, аудитом и записью
  в события безопасности; чужой ключ администратора не сбрасывается;
- журнал инстанса: фильтры по действию, актору, цели, серверу и датам,
  пагинация с общим числом, список действий и выгрузка CSV (лимит 5/мин);
- список серверов: поиск по названию, владелец, главный сервер, пагинация;
- миграция 00025: нормализованное название сервера `name_lower` — SQLite
  lower() не знает кириллицу, поэтому регистр приводит приложение (как для
  текста сообщений), старые записи дополняются backfill'ом при старте.
This commit is contained in:
2026-09-26 16:44:49 +03:00
parent 09b4f6878f
commit 3dc200c196
12 changed files with 2323 additions and 47 deletions
+364
View File
@@ -0,0 +1,364 @@
package store
import (
"context"
"errors"
"path/filepath"
"testing"
"time"
"glchat/internal/database"
)
// newInstanceStore открывает временную БД с миграциями: расширенная
// админ-панель (AGENT.md 7.18) читает пользователей, серверы, сообщения и
// аудит, поэтому нужен настоящий набор таблиц.
func newInstanceStore(t *testing.T) *Store {
t.Helper()
db, err := database.Open(context.Background(), database.Options{
Path: filepath.Join(t.TempDir(), "glchat.db"),
ReadPool: 2,
Migrate: true,
})
if err != nil {
t.Fatalf("открыть тестовую БД: %v", err)
}
t.Cleanup(func() {
if err := db.Close(); err != nil {
t.Errorf("закрыть тестовую БД: %v", err)
}
})
return New(db)
}
func createInstanceUser(t *testing.T, st *Store, username string) *User {
t.Helper()
user, err := st.CreateUser(context.Background(), CreateUserParams{
Username: username,
EmailEnc: "enc-" + username,
EmailIndex: "idx-" + username,
PasswordHash: "hash",
})
if err != nil {
t.Fatalf("создать пользователя %s: %v", username, err)
}
return user
}
// TestInstanceStatsAggregates проверяет сводку панели: пользователи, сообщения,
// файлы, серверы и топы (AGENT.md 7.18).
func TestInstanceStatsAggregates(t *testing.T) {
ctx := context.Background()
st := newInstanceStore(t)
owner := createInstanceUser(t, st, "stats_owner")
member := createInstanceUser(t, st, "stats_member")
if err := st.BanInstanceUser(ctx, member.ID, owner.ID, "спам"); err != nil {
t.Fatalf("забанить пользователя: %v", err)
}
if err := st.SetInstanceAdmin(ctx, member.ID, true); err != nil {
t.Fatalf("выдать админа: %v", err)
}
guild, err := st.CreateGuild(ctx, CreateGuildParams{Name: "Статистика", OwnerID: owner.ID})
if err != nil {
t.Fatalf("создать сервер: %v", err)
}
for _, userID := range []uint64{owner.ID, member.ID} {
if _, err := st.AddGuildMember(ctx, guild.ID, userID, ""); err != nil {
t.Fatalf("добавить участника: %v", err)
}
}
channel, err := st.CreateChannel(ctx, CreateChannelParams{
GuildID: &guild.ID, Type: ChannelText, Name: "общий",
})
if err != nil {
t.Fatalf("создать комнату: %v", err)
}
for i := 0; i < 3; i++ {
if _, err := st.CreateMessage(ctx, CreateMessageParams{
ChannelID: channel.ID, AuthorID: owner.ID, Content: "привет",
}); err != nil {
t.Fatalf("создать сообщение: %v", err)
}
}
// Вложение лежит в files и считается размером хранилища.
if _, err := st.CreateFile(ctx, CreateFileParams{
UploaderID: owner.ID, Filename: "a.png", ContentType: "image/png",
SizeBytes: 2048, StoragePath: "files/a", SHA256: "sha-stats", Purpose: "attachment",
}); err != nil {
t.Fatalf("создать файл: %v", err)
}
if _, err := st.CreateInvite(ctx, CreateInviteParams{
Code: "stats-invite", GuildID: guild.ID, CreatorID: owner.ID, MaxAgeSec: 3600,
}); err != nil {
t.Fatalf("создать инвайт: %v", err)
}
// Отозванный инвайт действующим не считается.
if _, err := st.CreateInvite(ctx, CreateInviteParams{
Code: "stats-revoked", GuildID: guild.ID, CreatorID: owner.ID,
}); err != nil {
t.Fatalf("создать инвайт: %v", err)
}
if err := st.DeleteInvite(ctx, "stats-revoked"); err != nil {
t.Fatalf("отозвать инвайт: %v", err)
}
stats, err := st.InstanceStats(ctx)
if err != nil {
t.Fatalf("InstanceStats: %v", err)
}
if stats.Users.Total < 2 || stats.Users.New7Days < 2 || stats.Users.New30Days < 2 {
t.Fatalf("пользователи в сводке: %+v", stats.Users)
}
if stats.Users.Admins != 1 {
t.Fatalf("администраторов = %d, want 1", stats.Users.Admins)
}
if stats.Bans != 1 || stats.Users.Banned != 1 {
t.Fatalf("банов = %d/%d, want 1", stats.Bans, stats.Users.Banned)
}
if len(stats.Users.Daily) == 0 {
t.Fatal("рост пользователей по дням пуст")
}
if stats.Messages.Total != 3 || stats.Messages.Today != 3 || stats.Messages.Month != 3 {
t.Fatalf("сообщения в сводке: %+v", stats.Messages)
}
if len(stats.Messages.Daily) == 0 {
t.Fatal("рост сообщений по дням пуст")
}
if stats.Files.Count != 1 || stats.Files.Bytes != 2048 {
t.Fatalf("файлы в сводке: %+v", stats.Files)
}
if stats.Guilds != 1 {
t.Fatalf("серверов = %d, want 1", stats.Guilds)
}
if stats.Invites != 1 {
t.Fatalf("действующих инвайтов = %d, want 1", stats.Invites)
}
if len(stats.TopGuilds) != 1 || stats.TopGuilds[0].Members != 2 {
t.Fatalf("топ по участникам: %+v", stats.TopGuilds)
}
if len(stats.BusiestGuilds) != 1 || stats.BusiestGuilds[0].Messages != 3 {
t.Fatalf("топ по сообщениям: %+v", stats.BusiestGuilds)
}
}
// TestInstanceAuditFilters: журнал инстанса фильтруется по действию, актору,
// цели, серверу и периоду, а выдача листается (AGENT.md 7.10, 7.18).
func TestInstanceAuditFilters(t *testing.T) {
ctx := context.Background()
st := newInstanceStore(t)
admin := createInstanceUser(t, st, "audit_admin")
target := createInstanceUser(t, st, "audit_target")
if err := st.SetInstanceAdmin(ctx, admin.ID, true); err != nil {
t.Fatalf("выдать админа: %v", err)
}
record := func(action, targetType string, targetID *uint64, reason string) {
t.Helper()
if err := st.RecordAudit(ctx, AuditParams{
ActorID: &admin.ID, ActorInstanceAdmin: true, Action: action,
TargetType: targetType, TargetID: targetID, Reason: reason,
}); err != nil {
t.Fatalf("записать аудит %s: %v", action, err)
}
}
record("instance.user_ban", "user", &target.ID, "спам")
record("instance.user_unban", "user", &target.ID, "")
record("instance.settings_update", "instance", nil, "")
// Запись обычного пользователя внутри сервера в журнал инстанса не
// попадает, а запись без сервера (например, ключ доступа) — попадает:
// так устроена область журнала (AGENT.md 7.18).
someone := createInstanceUser(t, st, "audit_someone")
guild, err := st.CreateGuild(ctx, CreateGuildParams{Name: "Аудит", OwnerID: someone.ID})
if err != nil {
t.Fatalf("создать сервер: %v", err)
}
if err := st.RecordAudit(ctx, AuditParams{
ActorID: &someone.ID, GuildID: &guild.ID, Action: "channel.create", TargetType: "channel",
}); err != nil {
t.Fatalf("записать аудит сервера: %v", err)
}
if err := st.RecordAudit(ctx, AuditParams{
ActorID: &someone.ID, Action: "user.passkey_add", TargetType: "user", TargetID: &someone.ID,
}); err != nil {
t.Fatalf("записать аудит без сервера: %v", err)
}
all, total, err := st.ListInstanceAuditFiltered(ctx, InstanceAuditFilter{})
if err != nil {
t.Fatalf("журнал целиком: %v", err)
}
if total != 4 || len(all) != 4 {
t.Fatalf("журнал = %d строк (total %d), want 4", len(all), total)
}
byAction, total, err := st.ListInstanceAuditFiltered(ctx, InstanceAuditFilter{Action: "instance.user_ban"})
if err != nil || total != 1 || len(byAction) != 1 {
t.Fatalf("фильтр по действию: %d строк (total %d), err = %v", len(byAction), total, err)
}
byTarget, total, err := st.ListInstanceAuditFiltered(ctx, InstanceAuditFilter{TargetID: target.ID})
if err != nil || total != 2 {
t.Fatalf("фильтр по цели: %d (total %d), err = %v", len(byTarget), total, err)
}
byActor, actorTotal, err := st.ListInstanceAuditFiltered(ctx, InstanceAuditFilter{ActorID: someone.ID})
if err != nil || actorTotal != 1 || len(byActor) != 1 {
t.Fatalf("записи без сервера по актору: %d (total %d), err = %v", len(byActor), actorTotal, err)
}
_, guildTotal, err := st.ListInstanceAuditFiltered(ctx, InstanceAuditFilter{GuildID: guild.ID})
if err != nil || guildTotal != 0 {
t.Fatalf("аудит сервера не должен попадать в журнал инстанса: total %d, err = %v", guildTotal, err)
}
// Период: сегодняшние записи видны, «завтра» — нет.
tomorrow := time.Now().UTC().AddDate(0, 0, 1)
future, total, err := st.ListInstanceAuditFiltered(ctx, InstanceAuditFilter{Since: &tomorrow})
if err != nil || total != 0 || len(future) != 0 {
t.Fatalf("фильтр «с завтра»: %d (total %d), err = %v", len(future), total, err)
}
yesterday := time.Now().UTC().AddDate(0, 0, -1)
recent, total, err := st.ListInstanceAuditFiltered(ctx, InstanceAuditFilter{Since: &yesterday})
if err != nil || total != 4 {
t.Fatalf("фильтр «с вчера»: %d (total %d), err = %v", len(recent), total, err)
}
// Пагинация: страницы не пересекаются, total не зависит от страницы.
first, total, err := st.ListInstanceAuditFiltered(ctx, InstanceAuditFilter{Limit: 2})
if err != nil || len(first) != 2 || total != 4 {
t.Fatalf("первая страница: %d (total %d), err = %v", len(first), total, err)
}
second, _, err := st.ListInstanceAuditFiltered(ctx, InstanceAuditFilter{Limit: 2, Offset: 3})
if err != nil || len(second) != 1 {
t.Fatalf("вторая страница: %d, err = %v", len(second), err)
}
if first[0].ID == second[0].ID {
t.Fatal("страницы журнала пересекаются")
}
actions, err := st.ListInstanceAuditActions(ctx)
if err != nil {
t.Fatalf("список действий: %v", err)
}
if len(actions) != 4 || actions[0] != "instance.settings_update" || actions[3] != "user.passkey_add" {
t.Fatalf("действия журнала = %v", actions)
}
// История по пользователю: он цель в двух записях.
userEntries, userTotal, err := st.ListUserAudit(ctx, target.ID, 10, 0)
if err != nil || userTotal != 2 || len(userEntries) != 2 {
t.Fatalf("аудит пользователя: %d (total %d), err = %v", len(userEntries), userTotal, err)
}
}
// TestInstanceGuildListFiltersAndMemberships: список серверов панели
// фильтруется и листается, а карточка участника показывает роли (AGENT.md 7.18).
func TestInstanceGuildListFiltersAndMemberships(t *testing.T) {
ctx := context.Background()
st := newInstanceStore(t)
owner := createInstanceUser(t, st, "list_owner")
other := createInstanceUser(t, st, "list_other")
first, err := st.CreateGuild(ctx, CreateGuildParams{Name: "Альфа", OwnerID: owner.ID})
if err != nil {
t.Fatalf("создать сервер: %v", err)
}
second, err := st.CreateGuild(ctx, CreateGuildParams{Name: "Бета", OwnerID: other.ID})
if err != nil {
t.Fatalf("создать сервер: %v", err)
}
if _, err := st.AddGuildMember(ctx, second.ID, other.ID, ""); err != nil {
t.Fatalf("добавить владельца: %v", err)
}
main, err := st.CreateGuild(ctx, CreateGuildParams{Name: "Главный", OwnerID: owner.ID, IsMain: true})
if err != nil {
t.Fatalf("создать главный сервер: %v", err)
}
if _, err := st.AddGuildMember(ctx, first.ID, owner.ID, ""); err != nil {
t.Fatalf("добавить владельца: %v", err)
}
if _, err := st.AddGuildMember(ctx, first.ID, other.ID, "гость"); err != nil {
t.Fatalf("добавить участника: %v", err)
}
role, err := st.CreateRole(ctx, CreateRoleParams{
GuildID: first.ID, Name: "Модератор", Color: 0xFF0000, Position: 5, Permissions: 8,
})
if err != nil {
t.Fatalf("создать роль: %v", err)
}
if err := st.AssignRole(ctx, first.ID, other.ID, role.ID); err != nil {
t.Fatalf("выдать роль: %v", err)
}
byName, total, err := st.ListInstanceGuilds(ctx, InstanceGuildFilter{Query: "альф"})
if err != nil || total != 1 || len(byName) != 1 {
t.Fatalf("поиск по названию: %d (total %d), err = %v", len(byName), total, err)
}
if byName[0].MemberCount != 2 {
t.Fatalf("участников в списке = %d, want 2", byName[0].MemberCount)
}
byOwner, total, err := st.ListInstanceGuilds(ctx, InstanceGuildFilter{OwnerID: owner.ID})
if err != nil || total != 2 {
t.Fatalf("фильтр по владельцу: %d (total %d), err = %v", len(byOwner), total, err)
}
mainOnly, total, err := st.ListInstanceGuilds(ctx, InstanceGuildFilter{MainOnly: true})
if err != nil || total != 1 || mainOnly[0].ID != main.ID {
t.Fatalf("главный сервер: %+v (total %d), err = %v", mainOnly, total, err)
}
page, total, err := st.ListInstanceGuilds(ctx, InstanceGuildFilter{Limit: 2, Offset: 2})
if err != nil || len(page) != 1 || total != 3 {
t.Fatalf("страница серверов: %d (total %d), err = %v", len(page), total, err)
}
memberships, err := st.ListUserGuildMemberships(ctx, other.ID)
if err != nil {
t.Fatalf("серверы пользователя: %v", err)
}
if len(memberships) != 2 {
t.Fatalf("серверов у пользователя = %d, want 2", len(memberships))
}
found := false
for _, membership := range memberships {
if membership.GuildID != first.ID {
continue
}
found = true
if membership.Nickname != "гость" || len(membership.Roles) != 1 || membership.Roles[0].Name != "Модератор" {
t.Fatalf("участие в сервере = %+v", membership)
}
}
if !found {
t.Fatal("сервер с ролью не найден в карточке пользователя")
}
}
// TestUserSessionRevokeChecksOwner: отозвать можно только сессию самого
// пользователя, чужая не удаляется (AGENT.md 7.18).
func TestUserSessionRevokeChecksOwner(t *testing.T) {
ctx := context.Background()
st := newInstanceStore(t)
owner := createInstanceUser(t, st, "session_owner")
other := createInstanceUser(t, st, "session_other")
session, err := st.CreateSession(ctx, owner.ID, CreateSessionParams{
TokenHash: "hash-session", UserAgent: "тест", IP: "127.0.0.1",
})
if err != nil {
t.Fatalf("создать сессию: %v", err)
}
if _, err := st.UserSession(ctx, other.ID, session.ID); !errors.Is(err, ErrNotFound) {
t.Fatalf("чужая сессия = %v, want ErrNotFound", err)
}
deleted, err := st.DeleteUserSession(ctx, other.ID, session.ID)
if err != nil || deleted {
t.Fatalf("чужое удаление: deleted = %v, err = %v", deleted, err)
}
if _, err := st.UserSession(ctx, owner.ID, session.ID); err != nil {
t.Fatalf("своя сессия недоступна: %v", err)
}
deleted, err = st.DeleteUserSession(ctx, owner.ID, session.ID)
if err != nil || !deleted {
t.Fatalf("своё удаление: deleted = %v, err = %v", deleted, err)
}
if _, err := st.UserSession(ctx, owner.ID, session.ID); !errors.Is(err, ErrNotFound) {
t.Fatalf("сессия после отзыва = %v, want ErrNotFound", err)
}
}