fix(server): удаление и пины сообщений попадают в аудит сервера
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`, действие владельца в журнале есть, но без флага; личные сообщения в аудит не пишутся.
This commit is contained in:
@@ -375,7 +375,7 @@ func (s *Server) registerMessageRoutes(api huma.API) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -389,6 +389,7 @@ func (s *Server) registerMessageRoutes(api huma.API) {
|
|||||||
if err := s.store.DeleteMessage(ctx, message.ID); err != nil {
|
if err := s.store.DeleteMessage(ctx, message.ID); err != nil {
|
||||||
return nil, humaError(err)
|
return nil, humaError(err)
|
||||||
}
|
}
|
||||||
|
s.recordMessageAudit(ctx, user, channel, "message.delete", message.ID)
|
||||||
s.dispatchChannelEvent(ctx, channelID, "MESSAGE_DELETE", map[string]any{
|
s.dispatchChannelEvent(ctx, channelID, "MESSAGE_DELETE", map[string]any{
|
||||||
"id": formatSnowflake(message.ID),
|
"id": formatSnowflake(message.ID),
|
||||||
"channel_id": formatSnowflake(channelID),
|
"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")
|
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 ставит или снимает реакцию и рассылает событие.
|
// changeReaction ставит или снимает реакцию и рассылает событие.
|
||||||
func (s *Server) changeReaction(ctx context.Context, rawChannelID, rawMessageID, emoji string, add bool) (*okOutput, error) {
|
func (s *Server) changeReaction(ctx context.Context, rawChannelID, rawMessageID, emoji string, add bool) (*okOutput, error) {
|
||||||
user, _, err := requireUser(ctx)
|
user, _, err := requireUser(ctx)
|
||||||
@@ -835,7 +845,7 @@ func (s *Server) changePin(ctx context.Context, rawChannelID, rawMessageID strin
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
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 {
|
if err := s.store.SetMessagePinned(ctx, message.ID, pinned); err != nil {
|
||||||
return nil, humaError(err)
|
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{
|
s.dispatchChannelEvent(ctx, channelID, "CHANNEL_PINS_UPDATE", map[string]any{
|
||||||
"channel_id": formatSnowflake(channelID),
|
"channel_id": formatSnowflake(channelID),
|
||||||
"message_id": formatSnowflake(message.ID),
|
"message_id": formatSnowflake(message.ID),
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user