148 lines
6.2 KiB
Go
148 lines
6.2 KiB
Go
|
|
package server
|
||
|
|
|
||
|
|
import (
|
||
|
|
"net/http"
|
||
|
|
"strings"
|
||
|
|
"testing"
|
||
|
|
"time"
|
||
|
|
)
|
||
|
|
|
||
|
|
// auditEntry — запись журнала в том виде, в каком её отдаёт API.
|
||
|
|
type auditEntry struct {
|
||
|
|
Action string `json:"action"`
|
||
|
|
TargetType string `json:"target_type"`
|
||
|
|
TargetID string `json:"target_id,omitempty"`
|
||
|
|
Reason string `json:"reason,omitempty"`
|
||
|
|
ActorInstanceAdmin bool `json:"actor_instance_admin"`
|
||
|
|
}
|
||
|
|
|
||
|
|
// auditActions читает журнал сервера от имени владельца.
|
||
|
|
func auditActions(t *testing.T, srv *Server, guildID string, cookie *http.Cookie) []auditEntry {
|
||
|
|
t.Helper()
|
||
|
|
rec := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/"+guildID+"/audit-log?limit=100", "", cookie)
|
||
|
|
if rec.Code != http.StatusOK {
|
||
|
|
t.Fatalf("журнал сервера = %d, body = %s", rec.Code, rec.Body.String())
|
||
|
|
}
|
||
|
|
return decodeResponse[struct {
|
||
|
|
Entries []auditEntry `json:"entries"`
|
||
|
|
}](t, rec).Entries
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestAuditCoversGuildMemberAndChannelChanges проверяет §11.5: переименование
|
||
|
|
// сервера, ник и тайм-аут участника, создание/правка/удаление комнаты попадают
|
||
|
|
// в журнал сервера, а действия инстанс-админа помечены actor_instance_admin.
|
||
|
|
// Раньше этих записей не было вовсе.
|
||
|
|
func TestAuditCoversGuildMemberAndChannelChanges(t *testing.T) {
|
||
|
|
srv, _ := newTestServer(t)
|
||
|
|
|
||
|
|
ownerCookie := registerAndLogin(t, srv, "audit_owner", "audit-owner@example.com")
|
||
|
|
adminCookie := registerAndLogin(t, srv, "audit_admin", "audit-admin@example.com")
|
||
|
|
promoteAdmin(t, srv, "audit-admin@example.com")
|
||
|
|
|
||
|
|
owner, err := srv.auth.UserByEmail(t.Context(), "audit-owner@example.com")
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("UserByEmail: %v", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
created := doJSON(t, srv, http.MethodPost, "/api/v1/guilds", `{"name":"Журнал"}`, ownerCookie)
|
||
|
|
guild := decodeResponse[struct {
|
||
|
|
Guild struct {
|
||
|
|
ID string `json:"id"`
|
||
|
|
} `json:"guild"`
|
||
|
|
}](t, created)
|
||
|
|
guildPath := "/api/v1/guilds/" + guild.Guild.ID
|
||
|
|
|
||
|
|
// Владелец заводит комнату: создание тоже должно оставить запись.
|
||
|
|
channelRec := doJSON(t, srv, http.MethodPost, guildPath+"/channels",
|
||
|
|
`{"name":"общая","type":"text"}`, ownerCookie)
|
||
|
|
channel := decodeResponse[struct {
|
||
|
|
Channel struct {
|
||
|
|
ID string `json:"id"`
|
||
|
|
} `json:"channel"`
|
||
|
|
}](t, channelRec)
|
||
|
|
if channel.Channel.ID == "" {
|
||
|
|
t.Fatalf("комната не создана: %s", channelRec.Body.String())
|
||
|
|
}
|
||
|
|
|
||
|
|
// Дальше всё делает инстанс-админ на чужом сервере, где он не участник.
|
||
|
|
if rec := doJSON(t, srv, http.MethodPatch, guildPath, `{"name":"Журнал E2E"}`, adminCookie); rec.Code != http.StatusOK {
|
||
|
|
t.Fatalf("правка сервера админом = %d, body = %s", rec.Code, rec.Body.String())
|
||
|
|
}
|
||
|
|
if rec := doJSON(t, srv, http.MethodPatch,
|
||
|
|
guildPath+"/members/"+formatSnowflake(owner.ID), `{"nickname":"в журнале"}`, adminCookie); rec.Code != http.StatusOK {
|
||
|
|
t.Fatalf("ник участника админом = %d, body = %s", rec.Code, rec.Body.String())
|
||
|
|
}
|
||
|
|
until := time.Now().UTC().Add(time.Hour).Truncate(time.Second).Format(time.RFC3339)
|
||
|
|
if rec := doJSON(t, srv, http.MethodPatch,
|
||
|
|
guildPath+"/members/"+formatSnowflake(owner.ID), `{"timeout_until":"`+until+`"}`, adminCookie); rec.Code != http.StatusOK {
|
||
|
|
t.Fatalf("тайм-аут админом = %d, body = %s", rec.Code, rec.Body.String())
|
||
|
|
}
|
||
|
|
adminChannel := doJSON(t, srv, http.MethodPost, guildPath+"/channels",
|
||
|
|
`{"name":"комната-админа","type":"text"}`, adminCookie)
|
||
|
|
adminChannelID := decodeResponse[struct {
|
||
|
|
Channel struct {
|
||
|
|
ID string `json:"id"`
|
||
|
|
} `json:"channel"`
|
||
|
|
}](t, adminChannel).Channel.ID
|
||
|
|
if rec := doJSON(t, srv, http.MethodPatch, guildPath+"/channels/"+adminChannelID,
|
||
|
|
`{"description":"правка админа","slowmode_seconds":5}`, adminCookie); rec.Code != http.StatusOK {
|
||
|
|
t.Fatalf("правка комнаты админом = %d, body = %s", rec.Code, rec.Body.String())
|
||
|
|
}
|
||
|
|
if rec := doJSON(t, srv, http.MethodDelete, guildPath+"/channels/"+adminChannelID, "", adminCookie); rec.Code != http.StatusOK {
|
||
|
|
t.Fatalf("удаление комнаты админом = %d, body = %s", rec.Code, rec.Body.String())
|
||
|
|
}
|
||
|
|
|
||
|
|
entries := auditActions(t, srv, guild.Guild.ID, ownerCookie)
|
||
|
|
|
||
|
|
// Каждое из действий админа должно быть в журнале с флагом инстанс-админа.
|
||
|
|
want := map[string]string{
|
||
|
|
"guild.update": guild.Guild.ID,
|
||
|
|
"member.update": formatSnowflake(owner.ID),
|
||
|
|
"member.timeout": formatSnowflake(owner.ID),
|
||
|
|
"channel.create": adminChannelID,
|
||
|
|
"channel.update": adminChannelID,
|
||
|
|
"channel.delete": adminChannelID,
|
||
|
|
}
|
||
|
|
for action, target := range want {
|
||
|
|
found := false
|
||
|
|
unflagged := false
|
||
|
|
for _, entry := range entries {
|
||
|
|
if entry.Action != action || entry.TargetID != target {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
if entry.ActorInstanceAdmin {
|
||
|
|
found = true
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
unflagged = true
|
||
|
|
}
|
||
|
|
if !found {
|
||
|
|
if unflagged {
|
||
|
|
t.Fatalf("%s по цели %s записан без actor_instance_admin: %+v", action, target, entries)
|
||
|
|
}
|
||
|
|
t.Fatalf("в журнале нет действия %s по цели %s: %+v", action, target, entries)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Тайм-аут пишет причину со сроком: по журналу видно, до когда.
|
||
|
|
for _, entry := range entries {
|
||
|
|
if entry.Action == "member.timeout" && !strings.Contains(entry.Reason, until) {
|
||
|
|
t.Fatalf("причина тайм-аута = %q, ожидали срок %q", entry.Reason, until)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Действие владельца в журнале есть, но флаг инстанс-админа у него снят.
|
||
|
|
ownerFlagged := false
|
||
|
|
for _, entry := range entries {
|
||
|
|
if entry.Action == "channel.create" && entry.TargetID == channel.Channel.ID {
|
||
|
|
if entry.ActorInstanceAdmin {
|
||
|
|
t.Fatalf("действие владельца помечено как действие админа: %+v", entry)
|
||
|
|
}
|
||
|
|
ownerFlagged = true
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if !ownerFlagged {
|
||
|
|
t.Fatalf("в журнале нет создания комнаты владельцем: %+v", entries)
|
||
|
|
}
|
||
|
|
}
|