fix(server): правки сервера, ника и комнат попадают в аудит

§11.5 требует, чтобы каждое мутирующее действие писалось в журнал сервера
с `actor.instance_admin=true`, но у PATCH /guilds/{id},
PATCH /guilds/{id}/members/{user_id} и у ручек комнат вызова `recordAudit`
не было: переименование сервера, смена ника, тайм-аут и создание, правка и
удаление комнаты не оставляли следа ни у владельца, ни у инстанс-админа.

- guild.update, member.update, member.timeout (в причине — срок тайм-аута),
  channel.create, channel.update, channel.delete;
- Go-тест TestAuditCoversGuildMemberAndChannelChanges проверяет записи на
  чужом сервере и флаг actor_instance_admin, а также что действие владельца
  этим флагом не помечается;
- e2e 19b в instance-admin.spec.ts включён вместо test.fixme с диагностикой.

Проверки: go test -tags sqlite_fts5 -race -count=1 ./internal/... — ok;
make go-lint — 0 issues; живьём на стенде тест 19b проходит.
This commit is contained in:
2026-09-26 14:15:49 +03:00
parent 5a419112e0
commit 73dd51b121
3 changed files with 169 additions and 7 deletions
+16
View File
@@ -355,6 +355,9 @@ func (s *Server) registerGuildCoreRoutes(api huma.API) {
}
s.invalidateGuild(guildID)
s.dispatchGuildUpdate(*guild)
// Правка сервера попадает в журнал: раньше её не было ни у владельца,
// ни у инстанс-админа (AGENT.md 11.5).
s.recordAudit(ctx, user, guildID, "guild.update", "guild", &guildID, "")
return s.guildOutput(ctx, user, *guild)
})
@@ -596,6 +599,8 @@ func (s *Server) registerChannelRoutes(api huma.API) {
"name": channel.Name,
"type": string(channel.Type),
})
// Создание комнаты (в том числе категории) — в журнале сервера.
s.recordAudit(ctx, user, guildID, "channel.create", "channel", &channel.ID, "")
s.invalidateGuild(guildID)
// Видимость комнаты считается персонально, поэтому вместо тела комнаты
// (в нём can_view был бы нулевым) просим клиентов перечитать список.
@@ -659,6 +664,7 @@ func (s *Server) registerChannelRoutes(api huma.API) {
return nil, humaError(err)
}
s.invalidateGuild(guildID)
s.recordAudit(ctx, user, guildID, "channel.update", "channel", &channelID, "")
s.dispatchGuildEvent(guildID, "CHANNEL_UPDATE", channelEventPayload(*channel))
s.dispatchGuildEvent(guildID, "GUILD_CHANNELS_SYNC", map[string]any{
"guild_id": formatSnowflake(guildID),
@@ -838,6 +844,7 @@ func (s *Server) registerChannelRoutes(api huma.API) {
return nil, humaError(err)
}
s.invalidateGuild(guildID)
s.recordAudit(ctx, user, guildID, "channel.delete", "channel", &channelID, "")
s.dispatchGuildEvent(guildID, "CHANNEL_DELETE", map[string]any{
"channel_id": formatSnowflake(channelID),
"guild_id": formatSnowflake(guildID),
@@ -1108,6 +1115,15 @@ func (s *Server) registerMemberRoutes(api huma.API) {
return nil, humaError(err)
}
s.invalidateGuild(guildID)
// Ник и тайм-аут участника тоже действия модерации: журнал сервера
// должен показывать и их (AGENT.md 11.5).
if nickname != nil {
s.recordAudit(ctx, user, guildID, "member.update", "user", &targetID, "")
}
if timeoutUntil != nil {
s.recordAudit(ctx, user, guildID, "member.timeout", "user", &targetID,
"until "+timeoutUntil.UTC().Format(time.RFC3339))
}
payload := map[string]any{
"guild_id": formatSnowflake(guildID),
"user_id": formatSnowflake(targetID),
+147
View File
@@ -0,0 +1,147 @@
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)
}
}
+6 -7
View File
@@ -671,14 +671,13 @@ test.describe('инстанс-админ: чужой сервер (§11.5, §7.1
});
/**
* Известный пробел §11.5: «каждое действие пишется в аудит» выполняется не для
* всех мутаций. Переименование сервера, смена ника участника и создание,
* правка и удаление комнаты не пишут запись в аудит ни администратору, ни
* владельцу (в `internal/server/api_guilds.go` у этих ручек нет `recordAudit`).
* Тест оставлен как fixme с точной диагностикой: когда записи появятся, его
* достаточно раскомментировать.
* Пробел §11.5 был закрыт правкой сервера: «каждое действие пишется в аудит»
* раньше не выполнялось для переименования сервера, смены ника участника и
* создания/правки/удаления комнаты — у этих ручек не было `recordAudit`
* (`internal/server/api_guilds.go`). Теперь записи есть у владельца и у
* инстанс-админа (с `actor_instance_admin=true`).
*/
test.fixme('19b. Аудит: переименование сервера, ник и комнаты тоже попадают в журнал', async ({
test('19b. Аудит: переименование сервера, ник и комнаты тоже попадают в журнал', async ({
playwright,
}) => {
const ownerApi = await apiAs(playwright, owner.state);