From 73dd51b12182ba5dde80e9f5ecae244dcc7be076 Mon Sep 17 00:00:00 2001 From: grendervill Date: Sat, 26 Sep 2026 14:15:49 +0300 Subject: [PATCH] =?UTF-8?q?fix(server):=20=D0=BF=D1=80=D0=B0=D0=B2=D0=BA?= =?UTF-8?q?=D0=B8=20=D1=81=D0=B5=D1=80=D0=B2=D0=B5=D1=80=D0=B0,=20=D0=BD?= =?UTF-8?q?=D0=B8=D0=BA=D0=B0=20=D0=B8=20=D0=BA=D0=BE=D0=BC=D0=BD=D0=B0?= =?UTF-8?q?=D1=82=20=D0=BF=D0=BE=D0=BF=D0=B0=D0=B4=D0=B0=D1=8E=D1=82=20?= =?UTF-8?q?=D0=B2=20=D0=B0=D1=83=D0=B4=D0=B8=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §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 проходит. --- internal/server/api_guilds.go | 16 +++ internal/server/audit_coverage_test.go | 147 +++++++++++++++++++++++++ web/e2e/instance-admin.spec.ts | 13 +-- 3 files changed, 169 insertions(+), 7 deletions(-) create mode 100644 internal/server/audit_coverage_test.go diff --git a/internal/server/api_guilds.go b/internal/server/api_guilds.go index 8c77899..8007b20 100644 --- a/internal/server/api_guilds.go +++ b/internal/server/api_guilds.go @@ -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), diff --git a/internal/server/audit_coverage_test.go b/internal/server/audit_coverage_test.go new file mode 100644 index 0000000..ee13b99 --- /dev/null +++ b/internal/server/audit_coverage_test.go @@ -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) + } +} diff --git a/web/e2e/instance-admin.spec.ts b/web/e2e/instance-admin.spec.ts index 876f8c3..bbc2ee5 100644 --- a/web/e2e/instance-admin.spec.ts +++ b/web/e2e/instance-admin.spec.ts @@ -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);