package server import ( "bytes" "mime/multipart" "net/http" "net/http/httptest" "strings" "testing" "glchat/internal/store" ) // Тесты вебхуков (AGENT.md 7.11): управление требует MANAGE_WEBHOOKS и step-up, // исполнение работает без сессии, уважает токен и лимит 30 сообщений в минуту. type webhookTestFixture struct { srv *Server owner *http.Cookie outsider *http.Cookie guildID string channelID string } func newWebhookFixture(t *testing.T) webhookTestFixture { t.Helper() srv, _ := newTestServer(t) owner := registerAndLogin(t, srv, "hook_owner", "hook-owner@example.com") outsider := registerAndLogin(t, srv, "hook_guest", "hook-guest@example.com") created := doJSON(t, srv, http.MethodPost, "/api/v1/guilds", `{"name":"Вебхуки"}`, owner) guild := decodeResponse[struct { Guild struct { ID string `json:"id"` } `json:"guild"` }](t, created) if guild.Guild.ID == "" { t.Fatalf("guild not created: %s", created.Body.String()) } channels := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/"+guild.Guild.ID+"/channels", "", owner) list := decodeResponse[struct { Channels []struct { ID string `json:"id"` Type string `json:"type"` } `json:"channels"` }](t, channels) channelID := "" for _, channel := range list.Channels { if channel.Type == "text" { channelID = channel.ID break } } if channelID == "" { t.Fatalf("text channel not found: %s", channels.Body.String()) } return webhookTestFixture{ srv: srv, owner: owner, outsider: outsider, guildID: guild.Guild.ID, channelID: channelID, } } // stepUp подтверждает личность: создание вебхука требует step-up (AGENT.md 9.3). func (f webhookTestFixture) stepUp(t *testing.T, cookie *http.Cookie) { t.Helper() rec := doJSON(t, f.srv, http.MethodPost, "/api/v1/auth/step-up", `{"password":"correct-horse-battery"}`, cookie) if rec.Code != http.StatusOK { t.Fatalf("step-up = %d (%s)", rec.Code, rec.Body.String()) } } // createWebhookFor создаёт вебхук и возвращает его id, токен и ссылку. func (f webhookTestFixture) createWebhookFor(t *testing.T, name string) (id, token, url string) { t.Helper() f.stepUp(t, f.owner) rec := doJSON(t, f.srv, http.MethodPost, "/api/v1/channels/"+f.channelID+"/webhooks", `{"name":"`+name+`"}`, f.owner) if rec.Code != http.StatusOK { t.Fatalf("create webhook = %d (%s)", rec.Code, rec.Body.String()) } payload := decodeResponse[struct { Webhook struct { ID string `json:"id"` Token string `json:"token"` URL string `json:"url"` } `json:"webhook"` }](t, rec) if payload.Webhook.ID == "" || payload.Webhook.Token == "" { t.Fatalf("webhook payload is incomplete: %s", rec.Body.String()) } return payload.Webhook.ID, payload.Webhook.Token, payload.Webhook.URL } func TestWebhookExecuteWithoutSession(t *testing.T) { f := newWebhookFixture(t) id, token, url := f.createWebhookFor(t, "Деплой") if !strings.HasSuffix(url, token) || !strings.Contains(url, "/api/v1/webhooks/") { t.Fatalf("unexpected webhook url: %s", url) } executePath := "/api/v1/webhooks/" + id + "/" + token // Исполнение без сессии: единственный секрет — токен в адресе. body := `{"content":"привет из CI","username":"Сборка"}` rec := doJSON(t, f.srv, http.MethodPost, executePath, body) if rec.Code != http.StatusOK { t.Fatalf("execute = %d (%s)", rec.Code, rec.Body.String()) } payload := decodeResponse[struct { Message struct { ID string `json:"id"` Content string `json:"content"` Type string `json:"type"` AuthorID string `json:"author_id"` WebhookName string `json:"webhook_name"` } `json:"message"` }](t, rec) if payload.Message.Type != "webhook" { t.Fatalf("type = %q, want webhook", payload.Message.Type) } if payload.Message.AuthorID != "" { t.Fatalf("webhook message must have no author, got %q", payload.Message.AuthorID) } if payload.Message.WebhookName != "Сборка" { t.Fatalf("webhook_name = %q, want Сборка", payload.Message.WebhookName) } // Сообщение видно в истории комнаты обычным участникам сервера. history := doJSON(t, f.srv, http.MethodGet, "/api/v1/channels/"+f.channelID+"/messages", "", f.owner) list := decodeResponse[struct { Messages []struct { ID string `json:"id"` WebhookName string `json:"webhook_name"` } `json:"messages"` }](t, history) found := false for _, message := range list.Messages { if message.ID == payload.Message.ID && message.WebhookName == "Сборка" { found = true } } if !found { t.Fatalf("webhook message not in history: %s", history.Body.String()) } } func TestWebhookRejectsWrongToken(t *testing.T) { f := newWebhookFixture(t) id, token, _ := f.createWebhookFor(t, "Секрет") rec := doJSON(t, f.srv, http.MethodPost, "/api/v1/webhooks/"+id+"/"+strings.Repeat("0", len(token)), `{"content":"подделка"}`) if rec.Code != http.StatusUnauthorized { t.Fatalf("wrong token = %d, want 401 (%s)", rec.Code, rec.Body.String()) } if code := errorCodeOf(t, rec); code != "webhook.unauthorized" { t.Fatalf("error code = %q", code) } } func TestWebhookRateLimit(t *testing.T) { f := newWebhookFixture(t) id, token, _ := f.createWebhookFor(t, "Лимит") path := "/api/v1/webhooks/" + id + "/" + token for i := 0; i < webhookRateLimit; i++ { if rec := doJSON(t, f.srv, http.MethodPost, path, `{"content":"строка"}`); rec.Code != http.StatusOK { t.Fatalf("message %d = %d (%s)", i+1, rec.Code, rec.Body.String()) } } rec := doJSON(t, f.srv, http.MethodPost, path, `{"content":"лишнее"}`) if rec.Code != http.StatusTooManyRequests { t.Fatalf("rate limit = %d, want 429 (%s)", rec.Code, rec.Body.String()) } if code := errorCodeOf(t, rec); code != "rate_limited" { t.Fatalf("error code = %q", code) } } func TestWebhookPermissionsAndStepUp(t *testing.T) { f := newWebhookFixture(t) // Посторонний не видит комнату и не может завести в ней вебхук. for _, method := range []string{http.MethodGet, http.MethodPost} { rec := doJSON(t, f.srv, method, "/api/v1/channels/"+f.channelID+"/webhooks", `{"name":"Чужой"}`, f.outsider) if rec.Code != http.StatusNotFound && rec.Code != http.StatusForbidden { t.Fatalf("outsider %s = %d (%s)", method, rec.Code, rec.Body.String()) } } // Свежая сессия step-up не проходила: создание отклоняется. rec := doJSON(t, f.srv, http.MethodPost, "/api/v1/channels/"+f.channelID+"/webhooks", `{"name":"Без подтверждения"}`, f.owner) if rec.Code != http.StatusForbidden { t.Fatalf("create without step-up = %d (%s)", rec.Code, rec.Body.String()) } if code := errorCodeOf(t, rec); code != "auth.step_up_required" { t.Fatalf("error code = %q", code) } // После подтверждения паролем вебхук создаётся. f.stepUp(t, f.owner) rec = doJSON(t, f.srv, http.MethodPost, "/api/v1/channels/"+f.channelID+"/webhooks", `{"name":"С подтверждением"}`, f.owner) if rec.Code != http.StatusOK { t.Fatalf("create after step-up = %d (%s)", rec.Code, rec.Body.String()) } } func TestWebhookUpdateRotatesTokenAndDelete(t *testing.T) { f := newWebhookFixture(t) id, token, _ := f.createWebhookFor(t, "Старое имя") renamed := doJSON(t, f.srv, http.MethodPatch, "/api/v1/webhooks/"+id, `{"name":"Новое имя"}`, f.owner) if renamed.Code != http.StatusOK { t.Fatalf("rename = %d (%s)", renamed.Code, renamed.Body.String()) } rotated := doJSON(t, f.srv, http.MethodPatch, "/api/v1/webhooks/"+id, `{"regenerate_token":true}`, f.owner) if rotated.Code != http.StatusOK { t.Fatalf("rotate = %d (%s)", rotated.Code, rotated.Body.String()) } payload := decodeResponse[struct { Webhook struct { Name string `json:"name"` Token string `json:"token"` } `json:"webhook"` }](t, rotated) if payload.Webhook.Name != "Новое имя" { t.Fatalf("name = %q", payload.Webhook.Name) } if payload.Webhook.Token == token { t.Fatal("token must be regenerated") } // Старый токен больше не работает, новый — работает. if rec := doJSON(t, f.srv, http.MethodPost, "/api/v1/webhooks/"+id+"/"+token, `{"content":"старый токен"}`); rec.Code != http.StatusUnauthorized { t.Fatalf("old token = %d, want 401", rec.Code) } if rec := doJSON(t, f.srv, http.MethodPost, "/api/v1/webhooks/"+id+"/"+payload.Webhook.Token, `{"content":"новый токен"}`); rec.Code != http.StatusOK { t.Fatalf("new token = %d (%s)", rec.Code, rec.Body.String()) } deleted := doJSON(t, f.srv, http.MethodDelete, "/api/v1/webhooks/"+id, "", f.owner) if deleted.Code != http.StatusOK { t.Fatalf("delete = %d (%s)", deleted.Code, deleted.Body.String()) } if rec := doJSON(t, f.srv, http.MethodPost, "/api/v1/webhooks/"+id+"/"+payload.Webhook.Token, `{"content":"после удаления"}`); rec.Code != http.StatusNotFound { t.Fatalf("execute after delete = %d, want 404", rec.Code) } } func TestWebhookValidation(t *testing.T) { f := newWebhookFixture(t) id, token, _ := f.createWebhookFor(t, "Проверки") path := "/api/v1/webhooks/" + id + "/" + token cases := []struct { name string body string want int }{ {"пустое сообщение", `{}`, http.StatusUnprocessableEntity}, {"слишком длинное", `{"content":"` + strings.Repeat("я", 5000) + `"}`, http.StatusUnprocessableEntity}, {"плохой аватар", `{"content":"текст","avatar_url":"javascript:alert(1)"}`, http.StatusUnprocessableEntity}, {"неизвестный файл", `{"content":"текст","file_ids":["1"]}`, http.StatusNotFound}, } for _, item := range cases { rec := doJSON(t, f.srv, http.MethodPost, path, item.body) if rec.Code != item.want { t.Fatalf("%s: статус %d, ожидался %d (%s)", item.name, rec.Code, item.want, rec.Body.String()) } } } func TestWebhookRejectsForeignFilesAndVoiceChannels(t *testing.T) { f := newWebhookFixture(t) id, token, _ := f.createWebhookFor(t, "Вложения") // Файл загружен другим пользователем: вложение вебхука отклоняется. me := doJSON(t, f.srv, http.MethodGet, "/api/v1/users/@me", "", f.outsider) outsiderID := decodeResponse[struct { User struct { ID string `json:"id"` } `json:"user"` }](t, me).User.ID outsider, err := parseID("user_id", outsiderID) if err != nil { t.Fatalf("parse outsider id: %v", err) } channel, err := parseID("channel_id", f.channelID) if err != nil { t.Fatalf("parse channel id: %v", err) } file, err := f.srv.store.CreateFile(t.Context(), store.CreateFileParams{ UploaderID: outsider, ChannelID: &channel, Filename: "чужой.txt", ContentType: "text/plain", SizeBytes: 3, StoragePath: "files/чужой.txt", }) if err != nil { t.Fatalf("create file: %v", err) } rec := doJSON(t, f.srv, http.MethodPost, "/api/v1/webhooks/"+id+"/"+token, `{"content":"вложение","file_ids":["`+formatSnowflake(file.ID)+`"]}`) if rec.Code != http.StatusForbidden { t.Fatalf("foreign file = %d, want 403 (%s)", rec.Code, rec.Body.String()) } // Вебхук можно завести только в текстовой комнате. voice := doJSON(t, f.srv, http.MethodPost, "/api/v1/guilds/"+f.guildID+"/channels", `{"name":"Голос","type":"voice"}`, f.owner) voiceID := decodeResponse[struct { Channel struct { ID string `json:"id"` } `json:"channel"` }](t, voice).Channel.ID if voiceID == "" { t.Fatalf("voice channel not created: %s", voice.Body.String()) } rec = doJSON(t, f.srv, http.MethodPost, "/api/v1/channels/"+voiceID+"/webhooks", `{"name":"В голосовой"}`, f.owner) if rec.Code != http.StatusUnprocessableEntity { t.Fatalf("webhook in voice channel = %d, want 422 (%s)", rec.Code, rec.Body.String()) } } func TestWebhookAvatarUploadAndUse(t *testing.T) { f := newWebhookFixture(t) // Загрузка аватара: право MANAGE_WEBHOOKS у владельца есть. var body bytes.Buffer writer := multipart.NewWriter(&body) part, err := writer.CreateFormFile("file", "hook.png") if err != nil { t.Fatalf("multipart: %v", err) } if _, err := part.Write(pngBytes()); err != nil { t.Fatalf("multipart write: %v", err) } if err := writer.Close(); err != nil { t.Fatalf("multipart close: %v", err) } request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/api/v1/channels/"+f.channelID+"/webhooks/avatar", &body) request.Header.Set("Content-Type", writer.FormDataContentType()) request.AddCookie(f.owner) rec := httptest.NewRecorder() f.srv.Handler().ServeHTTP(rec, request) if rec.Code != http.StatusOK { t.Fatalf("avatar upload = %d (%s)", rec.Code, rec.Body.String()) } uploaded := decodeResponse[struct { File struct { FileID string `json:"file_id"` } `json:"file"` }](t, rec) if uploaded.File.FileID == "" { t.Fatalf("avatar file id is empty: %s", rec.Body.String()) } f.stepUp(t, f.owner) created := doJSON(t, f.srv, http.MethodPost, "/api/v1/channels/"+f.channelID+"/webhooks", `{"name":"С аватаром","avatar_file_id":"`+uploaded.File.FileID+`"}`, f.owner) if created.Code != http.StatusOK { t.Fatalf("create with avatar = %d (%s)", created.Code, created.Body.String()) } webhook := decodeResponse[struct { Webhook struct { ID string `json:"id"` Token string `json:"token"` AvatarFileID string `json:"avatar_file_id"` } `json:"webhook"` }](t, created) if webhook.Webhook.AvatarFileID != uploaded.File.FileID { t.Fatalf("avatar_file_id = %q, want %q", webhook.Webhook.AvatarFileID, uploaded.File.FileID) } // Сообщение вебхука несёт ссылку на аватар: её видит клиент. sent := doJSON(t, f.srv, http.MethodPost, "/api/v1/webhooks/"+webhook.Webhook.ID+"/"+webhook.Webhook.Token, `{"content":"с аватаром"}`) if sent.Code != http.StatusOK { t.Fatalf("execute = %d (%s)", sent.Code, sent.Body.String()) } payload := decodeResponse[struct { Message struct { WebhookAvatar string `json:"webhook_avatar"` } `json:"message"` }](t, sent) if payload.Message.WebhookAvatar != "/files/"+uploaded.File.FileID { t.Fatalf("webhook_avatar = %q", payload.Message.WebhookAvatar) } // Аватар вебхука доступен участникам сервера: файл отдаётся по ссылке. avatar := doJSON(t, f.srv, http.MethodGet, "/files/"+uploaded.File.FileID, "", f.owner) if avatar.Code != http.StatusOK { t.Fatalf("avatar download = %d", avatar.Code) } }