2e038a1d3f
- миграция 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-снапшота
405 lines
15 KiB
Go
405 lines
15 KiB
Go
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)
|
||
}
|
||
}
|