Files
glchat/internal/server/webhooks_test.go
T

405 lines
15 KiB
Go
Raw Normal View History

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