From 8e096ca6b2e41ce4e926733049ee3ec828169b14 Mon Sep 17 00:00:00 2001 From: grendervill Date: Sat, 26 Sep 2026 15:06:29 +0300 Subject: [PATCH] =?UTF-8?q?fix(server):=20=D1=83=D0=B4=D0=B0=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D0=B8=20=D0=BF=D0=B8=D0=BD=D1=8B=20=D1=81?= =?UTF-8?q?=D0=BE=D0=BE=D0=B1=D1=89=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=BF=D0=B0=D0=B4=D0=B0=D1=8E=D1=82=20=D0=B2=20=D0=B0=D1=83?= =?UTF-8?q?=D0=B4=D0=B8=D1=82=20=D1=81=D0=B5=D1=80=D0=B2=D0=B5=D1=80=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AGENT.md 7.10 требует, чтобы действия с сообщениями были в журнале сервера, а §11.5 перечисляет удаление и закрепление как действия инстанс-админа на чужом сервере. Обе ручки рассылали только события Gateway, записей в аудите не было. `message.edit` намеренно не пишется: история правок не хранится (7.6), а правка своего сообщения — не модерационное действие и не входит в §11.5. Личные беседы в аудит не попадают: у них нет сервера, а журнал ведётся по серверам (7.10). Тест: internal/server/audit_messages_test.go — удаление, pin и unpin чужого сообщения инстанс-админом помечены `actor_instance_admin`, действие владельца в журнале есть, но без флага; личные сообщения в аудит не пишутся. --- internal/server/api_messages.go | 19 +++- internal/server/audit_messages_test.go | 137 +++++++++++++++++++++++++ 2 files changed, 154 insertions(+), 2 deletions(-) create mode 100644 internal/server/audit_messages_test.go diff --git a/internal/server/api_messages.go b/internal/server/api_messages.go index 59a0475..03e5d77 100644 --- a/internal/server/api_messages.go +++ b/internal/server/api_messages.go @@ -375,7 +375,7 @@ func (s *Server) registerMessageRoutes(api huma.API) { if err != nil { return nil, err } - channelID, resolved, _, err := s.requireChannelPermission(ctx, input.ChannelID, user, permissions.ViewChannel) + channelID, resolved, channel, err := s.requireChannelPermission(ctx, input.ChannelID, user, permissions.ViewChannel) if err != nil { return nil, err } @@ -389,6 +389,7 @@ func (s *Server) registerMessageRoutes(api huma.API) { if err := s.store.DeleteMessage(ctx, message.ID); err != nil { return nil, humaError(err) } + s.recordMessageAudit(ctx, user, channel, "message.delete", message.ID) s.dispatchChannelEvent(ctx, channelID, "MESSAGE_DELETE", map[string]any{ "id": formatSnowflake(message.ID), "channel_id": formatSnowflake(channelID), @@ -754,6 +755,15 @@ func (s *Server) requireMessageAuthor(user *store.User, resolved permissions.Res return humaErrorStatus(http.StatusForbidden, "perm.denied", "permission denied") } +// recordMessageAudit пишет действие с сообщением в аудит сервера (AGENT.md 7.10). +// Личные беседы серверу не принадлежат — в журнал они не попадают. +func (s *Server) recordMessageAudit(ctx context.Context, actor *store.User, channel *store.Channel, action string, messageID uint64) { + if channel.GuildID == nil { + return + } + s.recordAudit(ctx, actor, *channel.GuildID, action, "message", &messageID, "") +} + // changeReaction ставит или снимает реакцию и рассылает событие. func (s *Server) changeReaction(ctx context.Context, rawChannelID, rawMessageID, emoji string, add bool) (*okOutput, error) { user, _, err := requireUser(ctx) @@ -835,7 +845,7 @@ func (s *Server) changePin(ctx context.Context, rawChannelID, rawMessageID strin if err != nil { return nil, err } - channelID, _, _, err := s.requireChannelPermission(ctx, rawChannelID, user, permissions.ManageMessages) + channelID, _, channel, err := s.requireChannelPermission(ctx, rawChannelID, user, permissions.ManageMessages) if err != nil { return nil, err } @@ -856,6 +866,11 @@ func (s *Server) changePin(ctx context.Context, rawChannelID, rawMessageID strin if err := s.store.SetMessagePinned(ctx, message.ID, pinned); err != nil { return nil, humaError(err) } + action := "message.unpin" + if pinned { + action = "message.pin" + } + s.recordMessageAudit(ctx, user, channel, action, message.ID) s.dispatchChannelEvent(ctx, channelID, "CHANNEL_PINS_UPDATE", map[string]any{ "channel_id": formatSnowflake(channelID), "message_id": formatSnowflake(message.ID), diff --git a/internal/server/audit_messages_test.go b/internal/server/audit_messages_test.go new file mode 100644 index 0000000..d812f92 --- /dev/null +++ b/internal/server/audit_messages_test.go @@ -0,0 +1,137 @@ +package server + +import ( + "net/http" + "testing" +) + +// postMessage отправляет сообщение от имени владельца и возвращает его id. +func postMessage(t *testing.T, f *messagingFixture, content string) string { + t.Helper() + rec := doJSON(t, f.srv, http.MethodPost, "/api/v1/channels/"+f.openChannel+"/messages", + `{"content":"`+content+`"}`, f.ownerCookie) + if rec.Code != http.StatusOK { + t.Fatalf("отправка сообщения = %d, body = %s", rec.Code, rec.Body.String()) + } + return decodeResponse[struct { + Message struct { + ID string `json:"id"` + } `json:"message"` + }](t, rec).Message.ID +} + +// TestAuditCoversMessageDeleteAndPins проверяет AGENT.md 7.10 и §11.5: +// удаление и закрепление/открепление сообщения попадают в журнал сервера, а +// действия инстанс-админа на чужом сервере помечены actor_instance_admin. +// Правки сообщения в аудит не пишутся: история не хранится (7.6), а действие +// автора не является модерационным. +func TestAuditCoversMessageDeleteAndPins(t *testing.T) { + f := newMessagingFixture(t) + + adminCookie := registerAndLogin(t, f.srv, "audit_msg_admin", "audit-msg-admin@example.com") + promoteAdmin(t, f.srv, "audit-msg-admin@example.com") + + toDelete := postMessage(t, f, "сообщение под удаление") + toPin := postMessage(t, f, "сообщение под закрепление") + + // Инстанс-админ не участник сервера, но права ADMINISTRATOR обходят иерархию. + if rec := doJSON(t, f.srv, http.MethodDelete, + "/api/v1/channels/"+f.openChannel+"/messages/"+toDelete, "", adminCookie); rec.Code != http.StatusOK { + t.Fatalf("удаление сообщения админом = %d, body = %s", rec.Code, rec.Body.String()) + } + if rec := doJSON(t, f.srv, http.MethodPut, + "/api/v1/channels/"+f.openChannel+"/pins/"+toPin, "", adminCookie); rec.Code != http.StatusOK { + t.Fatalf("закрепление сообщения админом = %d, body = %s", rec.Code, rec.Body.String()) + } + if rec := doJSON(t, f.srv, http.MethodDelete, + "/api/v1/channels/"+f.openChannel+"/pins/"+toPin, "", adminCookie); rec.Code != http.StatusOK { + t.Fatalf("открепление сообщения админом = %d, body = %s", rec.Code, rec.Body.String()) + } + + entries := auditActions(t, f.srv, f.guildID, f.ownerCookie) + + want := map[string]string{ + "message.delete": toDelete, + "message.pin": toPin, + "message.unpin": toPin, + } + for action, target := range want { + found := false + unflagged := false + for _, entry := range entries { + if entry.Action != action || entry.TargetID != target { + continue + } + if entry.TargetType != "message" { + t.Fatalf("%s: тип цели = %q, ожидали message", action, entry.TargetType) + } + 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) + } + } + + // Действия владельца в журнале есть, но без флага инстанс-админа: пины + // обычного модератора не должны выглядеть как действия администратора. + ownPin := postMessage(t, f, "сообщение владельца") + if rec := doJSON(t, f.srv, http.MethodPut, + "/api/v1/channels/"+f.openChannel+"/pins/"+ownPin, "", f.ownerCookie); rec.Code != http.StatusOK { + t.Fatalf("закрепление сообщения владельцем = %d, body = %s", rec.Code, rec.Body.String()) + } + if rec := doJSON(t, f.srv, http.MethodDelete, + "/api/v1/channels/"+f.openChannel+"/messages/"+ownPin, "", f.ownerCookie); rec.Code != http.StatusOK { + t.Fatalf("удаление своего сообщения владельцем = %d, body = %s", rec.Code, rec.Body.String()) + } + ownerFlagged := false + for _, entry := range auditActions(t, f.srv, f.guildID, f.ownerCookie) { + if entry.Action == "message.delete" && entry.TargetID == ownPin && !entry.ActorInstanceAdmin { + ownerFlagged = true + } + } + if !ownerFlagged { + t.Fatalf("в журнале нет удаления сообщения владельцем без флага админа") + } +} + +// TestAuditSkipsDirectMessages проверяет, что личные беседы не попадают в аудит +// сервера: у них нет сервера, а журнал ведётся по серверам (AGENT.md 7.10). +func TestAuditSkipsDirectMessages(t *testing.T) { + f := newMessagingFixture(t) + + dm := doJSON(t, f.srv, http.MethodPost, "/api/v1/users/@me/channels", + `{"recipient_id":"`+f.memberID+`"}`, f.ownerCookie) + dmChannelID := decodeResponse[struct { + Channel struct { + ID string `json:"id"` + } `json:"channel"` + }](t, dm).Channel.ID + if dmChannelID == "" { + t.Fatalf("личная беседа не создана: %s", dm.Body.String()) + } + + sent := doJSON(t, f.srv, http.MethodPost, "/api/v1/channels/"+dmChannelID+"/messages", + `{"content":"личное"}`, f.ownerCookie) + messageID := decodeResponse[struct { + Message struct { + ID string `json:"id"` + } `json:"message"` + }](t, sent).Message.ID + if rec := doJSON(t, f.srv, http.MethodDelete, + "/api/v1/channels/"+dmChannelID+"/messages/"+messageID, "", f.ownerCookie); rec.Code != http.StatusOK { + t.Fatalf("удаление личного сообщения = %d, body = %s", rec.Code, rec.Body.String()) + } + + for _, entry := range auditActions(t, f.srv, f.guildID, f.ownerCookie) { + if entry.Action == "message.delete" { + t.Fatalf("личное сообщение попало в аудит сервера: %+v", entry) + } + } +}