Files
grendervill 2e038a1d3f feat(webhooks): вебхуки комнат — бэкенд, клиент и настройки комнаты
- миграция 00012: таблица webhooks, снимок имени и аватара в messages (AGENT.md 7.11)
- API: список/создание/правка/удаление и пересоздание токена (MANAGE_WEBHOOKS,
  step-up при создании, аудит), загрузка аватара отдельной multipart-ручкой
- исполнение POST /webhooks/{id}/{token} без сессии: content, username,
  avatar_url, файлы создателя вебхука, лимит 30/мин на вебхук
- клиент: пункт настроек «Комната» со списком вебхуков, копированием ссылки,
  пересозданием токена и удалением
- сообщения вебхуков: имя, аватар и значок в ленте вместо «неизвестного автора»
- fix: неполный ответ REST больше не затирает данные READY-снапшота
2026-09-21 00:26:50 +03:00

405 lines
15 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}