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) } }