3dc200c196
Статистика, карточка пользователя и работа с журналом для администратора
инстанса (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'ом при старте.
365 lines
15 KiB
Go
365 lines
15 KiB
Go
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)
|
|
}
|
|
}
|