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:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user