Files
glchat/internal/server/social_test.go
T
grendervill d02ef7527a fix(files): аватары и баннеры доступны авторизованным пользователям
Аватар нельзя было показать в списке друзей: файл без комнаты был доступен
только загрузившему. Добавлено назначение файла (`files.purpose`: attachment |
avatar | banner, миграция 00006): аватары и баннеры отдаются любому
авторизованному пользователю, вложения по-прежнему проверяются по правам
комнаты, а непривязанные загрузки остаются приватными.
2026-09-20 01:39:04 +03:00

397 lines
16 KiB
Go
Raw 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"
"encoding/json"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"net/textproto"
"testing"
"time"
)
// TestFriendRequestFlow проверяет заявки в друзья и поиск пользователей.
func TestFriendRequestFlow(t *testing.T) {
srv, _ := newTestServer(t)
aliceCookie := registerAndLogin(t, srv, "alice_friend", "alice-friend@example.com")
bobCookie := registerAndLogin(t, srv, "bob_friend", "bob-friend@example.com")
bob, err := srv.auth.UserByEmail(t.Context(), "bob-friend@example.com")
if err != nil {
t.Fatalf("UserByEmail: %v", err)
}
// Поиск по логину находит Боба и показывает состояние связи.
search := doJSON(t, srv, http.MethodGet, "/api/v1/users/search?q=bob_fr", "", aliceCookie)
if search.Code != http.StatusOK {
t.Fatalf("search = %d, body = %s", search.Code, search.Body.String())
}
found := decodeResponse[struct {
Users []struct {
Username string `json:"username"`
Relationship string `json:"relationship"`
} `json:"users"`
}](t, search)
if len(found.Users) != 1 || found.Users[0].Username != "bob_friend" || found.Users[0].Relationship != "none" {
t.Fatalf("search results = %+v", found.Users)
}
// Алиса отправляет заявку: у неё outgoing, у Боба incoming.
sent := doJSON(t, srv, http.MethodPost, "/api/v1/users/@me/relationships", `{"username":"bob_friend"}`, aliceCookie)
if sent.Code != http.StatusOK {
t.Fatalf("send request = %d, body = %s", sent.Code, sent.Body.String())
}
bobList := doJSON(t, srv, http.MethodGet, "/api/v1/users/@me/relationships", "", bobCookie)
bobPayload := decodeResponse[struct {
Incoming []struct {
Username string `json:"username"`
Relationship string `json:"relationship"`
} `json:"incoming"`
Friends []struct {
Username string `json:"username"`
} `json:"friends"`
}](t, bobList)
if len(bobPayload.Incoming) != 1 || bobPayload.Incoming[0].Username != "alice_friend" {
t.Fatalf("incoming = %+v", bobPayload.Incoming)
}
if len(bobPayload.Friends) != 0 {
t.Fatalf("friends must be empty before accept: %+v", bobPayload.Friends)
}
// Боб принимает: оба видят друг друга в друзьях.
accept := doJSON(t, srv, http.MethodPost,
"/api/v1/users/@me/relationships/"+formatSnowflake(mustUserID(t, srv, "alice-friend@example.com"))+"/accept",
"", bobCookie)
if accept.Code != http.StatusOK {
t.Fatalf("accept = %d, body = %s", accept.Code, accept.Body.String())
}
aliceFriends := doJSON(t, srv, http.MethodGet, "/api/v1/users/@me/relationships", "", aliceCookie)
payload := decodeResponse[struct {
Friends []struct {
Username string `json:"username"`
VisibleStatus string `json:"visible_status"`
Timezone string `json:"timezone"`
} `json:"friends"`
}](t, aliceFriends)
if len(payload.Friends) != 1 || payload.Friends[0].Username != "bob_friend" {
t.Fatalf("friends = %+v", payload.Friends)
}
if payload.Friends[0].Timezone != "Europe/Moscow" {
t.Fatalf("default timezone = %q, want Europe/Moscow", payload.Friends[0].Timezone)
}
// Повторная заявка ничего не ломает.
repeat := doJSON(t, srv, http.MethodPost, "/api/v1/users/@me/relationships", `{"user_id":"`+formatSnowflake(bob.ID)+`"}`, aliceCookie)
if repeat.Code != http.StatusOK {
t.Fatalf("repeat request = %d, body = %s", repeat.Code, repeat.Body.String())
}
}
// TestDirectChannelMessaging проверяет личные беседы: доступ только участникам.
func TestDirectChannelMessaging(t *testing.T) {
srv, _ := newTestServer(t)
aliceCookie := registerAndLogin(t, srv, "alice_dm", "alice-dm@example.com")
bobCookie := registerAndLogin(t, srv, "bob_dm", "bob-dm@example.com")
strangerCookie := registerAndLogin(t, srv, "eve_dm", "eve-dm@example.com")
bobID := formatSnowflake(mustUserID(t, srv, "bob-dm@example.com"))
// Алиса открывает беседу с Бобом.
opened := doJSON(t, srv, http.MethodPost, "/api/v1/users/@me/channels",
`{"recipient_id":"`+bobID+`"}`, aliceCookie)
if opened.Code != http.StatusOK {
t.Fatalf("open dm = %d, body = %s", opened.Code, opened.Body.String())
}
channel := decodeResponse[struct {
Channel struct {
ID string `json:"id"`
Type string `json:"type"`
CanSend bool `json:"can_send"`
Recipient struct {
UserID string `json:"user_id"`
DisplayName string `json:"display_name"`
VisibleStatus string `json:"visible_status"`
} `json:"recipient"`
} `json:"channel"`
}](t, opened)
if channel.Channel.Type != "dm" || !channel.Channel.CanSend {
t.Fatalf("unexpected dm channel: %+v", channel.Channel)
}
if channel.Channel.Recipient.UserID != bobID {
t.Fatalf("recipient = %q, want %q", channel.Channel.Recipient.UserID, bobID)
}
// Повторное открытие возвращает ту же беседу.
again := doJSON(t, srv, http.MethodPost, "/api/v1/users/@me/channels",
`{"recipient_id":"`+bobID+`"}`, aliceCookie)
reopened := decodeResponse[struct {
Channel struct {
ID string `json:"id"`
} `json:"channel"`
}](t, again)
if reopened.Channel.ID != channel.Channel.ID {
t.Fatalf("dm channel changed: %s → %s", channel.Channel.ID, reopened.Channel.ID)
}
// Сообщение из беседы и список бесед у обоих участников.
sent := doJSON(t, srv, http.MethodPost, "/api/v1/channels/"+channel.Channel.ID+"/messages",
`{"content":"привет в личке"}`, aliceCookie)
if sent.Code != http.StatusOK {
t.Fatalf("dm message = %d, body = %s", sent.Code, sent.Body.String())
}
list := doJSON(t, srv, http.MethodGet, "/api/v1/users/@me/channels", "", bobCookie)
channels := decodeResponse[struct {
Channels []struct {
ID string `json:"id"`
LastMessageID string `json:"last_message_id"`
} `json:"channels"`
}](t, list)
if len(channels.Channels) != 1 || channels.Channels[0].ID != channel.Channel.ID || channels.Channels[0].LastMessageID == "" {
t.Fatalf("dm list = %+v", channels.Channels)
}
// Посторонний не видит беседу и не может писать.
forbidden := doJSON(t, srv, http.MethodGet, "/api/v1/channels/"+channel.Channel.ID+"/messages", "", strangerCookie)
if forbidden.Code != http.StatusNotFound {
t.Fatalf("stranger dm read = %d, want 404", forbidden.Code)
}
forbiddenSend := doJSON(t, srv, http.MethodPost, "/api/v1/channels/"+channel.Channel.ID+"/messages",
`{"content":"я тут лишний"}`, strangerCookie)
if forbiddenSend.Code != http.StatusNotFound {
t.Fatalf("stranger dm write = %d, want 404", forbiddenSend.Code)
}
}
// TestInvisibleStatusHiddenFromFriends проверяет, что «невидимка» виден как офлайн.
func TestInvisibleStatusHiddenFromFriends(t *testing.T) {
srv, _ := newTestServer(t)
aliceCookie := registerAndLogin(t, srv, "alice_inv", "alice-inv@example.com")
bobCookie := registerAndLogin(t, srv, "bob_inv", "bob-inv@example.com")
doJSON(t, srv, http.MethodPost, "/api/v1/users/@me/relationships", `{"username":"bob_inv"}`, aliceCookie)
accept := doJSON(t, srv, http.MethodPost,
"/api/v1/users/@me/relationships/"+formatSnowflake(mustUserID(t, srv, "alice-inv@example.com"))+"/accept", "", bobCookie)
if accept.Code != http.StatusOK {
t.Fatalf("accept = %d", accept.Code)
}
// Боб уходит в невидимку.
hidden := doJSON(t, srv, http.MethodPatch, "/api/v1/users/@me", `{"status":"invisible"}`, bobCookie)
if hidden.Code != http.StatusOK {
t.Fatalf("set invisible = %d, body = %s", hidden.Code, hidden.Body.String())
}
friends := doJSON(t, srv, http.MethodGet, "/api/v1/users/@me/relationships", "", aliceCookie)
payload := decodeResponse[struct {
Friends []struct {
Username string `json:"username"`
Status string `json:"status"`
VisibleStatus string `json:"visible_status"`
} `json:"friends"`
}](t, friends)
if len(payload.Friends) != 1 {
t.Fatalf("friends = %+v", payload.Friends)
}
if payload.Friends[0].VisibleStatus != "offline" {
t.Fatalf("invisible user must look offline, got %q", payload.Friends[0].VisibleStatus)
}
// Обычный статус виден как есть.
doJSON(t, srv, http.MethodPatch, "/api/v1/users/@me", `{"status":"idle"}`, bobCookie)
friends = doJSON(t, srv, http.MethodGet, "/api/v1/users/@me/relationships", "", aliceCookie)
payload = decodeResponse[struct {
Friends []struct {
Username string `json:"username"`
Status string `json:"status"`
VisibleStatus string `json:"visible_status"`
} `json:"friends"`
}](t, friends)
if payload.Friends[0].VisibleStatus != "idle" {
t.Fatalf("idle status must be visible, got %q", payload.Friends[0].VisibleStatus)
}
}
// TestTimezoneAndAvatarUpdate проверяет часовой пояс и загрузку аватара.
func TestTimezoneAndAvatarUpdate(t *testing.T) {
srv, _ := newTestServer(t)
httpServer := httptest.NewServer(srv.Handler())
t.Cleanup(httpServer.Close)
cookie := registerAndLogin(t, srv, "tz_user", "tz-user@example.com")
// Часовой пояс по умолчанию — МСК, можно сменить.
type mePayload struct {
User struct {
Timezone string `json:"timezone"`
AvatarFileID string `json:"avatar_file_id"`
} `json:"user"`
}
me := doJSON(t, srv, http.MethodGet, "/api/v1/users/@me", "", cookie)
profile := decodeResponse[mePayload](t, me)
if profile.User.Timezone != "Europe/Moscow" {
t.Fatalf("default timezone = %q", profile.User.Timezone)
}
updated := doJSON(t, srv, http.MethodPatch, "/api/v1/users/@me", `{"timezone":"Europe/Berlin"}`, cookie)
if updated.Code != http.StatusOK {
t.Fatalf("set timezone = %d, body = %s", updated.Code, updated.Body.String())
}
after := decodeResponse[mePayload](t, updated)
if after.User.Timezone != "Europe/Berlin" {
t.Fatalf("timezone = %q, want Europe/Berlin", after.User.Timezone)
}
bad := doJSON(t, srv, http.MethodPatch, "/api/v1/users/@me", `{"timezone":"Марс/Олимп"}`, cookie)
if bad.Code != http.StatusUnprocessableEntity {
t.Fatalf("invalid timezone = %d, want 422", bad.Code)
}
// Аватар: загрузка картинки и удаление.
fileID := uploadAvatar(t, httpServer, cookie, "me.png",
append([]byte("\x89PNG\r\n\x1a\n"), []byte("аватар-данные")...))
if fileID == "" {
t.Fatal("avatar upload must return a file id")
}
me = doJSON(t, srv, http.MethodGet, "/api/v1/users/@me", "", cookie)
profile = decodeResponse[mePayload](t, me)
if profile.User.AvatarFileID != fileID {
t.Fatalf("avatar_file_id = %q, want %q", profile.User.AvatarFileID, fileID)
}
// Аватар виден другому авторизованному пользователю (он показывается в
// списках друзей и участников), но не анонимному.
otherCookie := registerAndLogin(t, srv, "tz_other", "tz-other@example.com")
_ = otherCookie
downloadReq, err := http.NewRequestWithContext(t.Context(), http.MethodGet,
httpServer.URL+"/files/"+fileID, nil)
if err != nil {
t.Fatalf("new request: %v", err)
}
downloadReq.AddCookie(otherCookie)
downloadResp, err := httpServer.Client().Do(downloadReq)
if err != nil {
t.Fatalf("avatar download: %v", err)
}
_ = downloadResp.Body.Close()
if downloadResp.StatusCode != http.StatusOK {
t.Fatalf("другой пользователь не видит аватар: %d", downloadResp.StatusCode)
}
removed := doJSON(t, srv, http.MethodDelete, "/api/v1/users/@me/avatar", "", cookie)
if removed.Code != http.StatusOK {
t.Fatalf("delete avatar = %d, body = %s", removed.Code, removed.Body.String())
}
me = doJSON(t, srv, http.MethodGet, "/api/v1/users/@me", "", cookie)
profile = decodeResponse[mePayload](t, me)
if profile.User.AvatarFileID != "" {
t.Fatalf("avatar must be cleared, got %q", profile.User.AvatarFileID)
}
}
// mustUserID находит пользователя по email и возвращает его идентификатор.
func mustUserID(t *testing.T, srv *Server, email string) uint64 {
t.Helper()
user, err := srv.auth.UserByEmail(t.Context(), email)
if err != nil {
t.Fatalf("UserByEmail(%s): %v", email, err)
}
return user.ID
}
// TestPresenceUpdateReachesFriends проверяет, что смена статуса видна другу
// через Gateway без перезагрузки (AGENT.md 7.16).
func TestPresenceUpdateReachesFriends(t *testing.T) {
srv, _ := newTestServer(t)
httpServer := httptest.NewServer(srv.Handler())
t.Cleanup(httpServer.Close)
aliceCookie := registerAndLogin(t, srv, "alice_pres", "alice-pres@example.com")
bobCookie := registerAndLogin(t, srv, "bob_pres", "bob-pres@example.com")
doJSON(t, srv, http.MethodPost, "/api/v1/users/@me/relationships", `{"username":"bob_pres"}`, aliceCookie)
doJSON(t, srv, http.MethodPost,
"/api/v1/users/@me/relationships/"+formatSnowflake(mustUserID(t, srv, "alice-pres@example.com"))+"/accept", "", bobCookie)
alice := dialGateway(t, httpServer, aliceCookie)
doJSON(t, srv, http.MethodPatch, "/api/v1/users/@me", `{"status":"dnd"}`, bobCookie)
frame := alice.expectEvent("PRESENCE_UPDATE")
var payload struct {
Status string `json:"status"`
VisibleStatus string `json:"visible_status"`
}
if err := json.Unmarshal(frame.D, &payload); err != nil {
t.Fatalf("decode PRESENCE_UPDATE: %v", err)
}
if payload.Status != "dnd" || payload.VisibleStatus != "dnd" {
t.Fatalf("presence payload = %+v", payload)
}
}
// TestDirectChannelEventsOnlyForParticipants проверяет фильтрацию событий DM.
func TestDirectChannelEventsOnlyForParticipants(t *testing.T) {
srv, _ := newTestServer(t)
httpServer := httptest.NewServer(srv.Handler())
t.Cleanup(httpServer.Close)
aliceCookie := registerAndLogin(t, srv, "alice_dmev", "alice-dmev@example.com")
bobCookie := registerAndLogin(t, srv, "bob_dmev", "bob-dmev@example.com")
eveCookie := registerAndLogin(t, srv, "eve_dmev", "eve-dmev@example.com")
bobID := formatSnowflake(mustUserID(t, srv, "bob-dmev@example.com"))
opened := doJSON(t, srv, http.MethodPost, "/api/v1/users/@me/channels", `{"recipient_id":"`+bobID+`"}`, aliceCookie)
channel := decodeResponse[struct {
Channel struct {
ID string `json:"id"`
} `json:"channel"`
}](t, opened)
bob := dialGateway(t, httpServer, bobCookie)
eve := dialGateway(t, httpServer, eveCookie)
doJSON(t, srv, http.MethodPost, "/api/v1/channels/"+channel.Channel.ID+"/messages",
`{"content":"личное сообщение"}`, aliceCookie)
bob.expectEvent("MESSAGE_CREATE")
eve.expectNoEvent("MESSAGE_CREATE", 700*time.Millisecond)
}
// uploadAvatar загружает аватар через multipart и возвращает file_id.
func uploadAvatar(t *testing.T, server *httptest.Server, cookie *http.Cookie, filename string, content []byte) string {
t.Helper()
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
partHeader := textproto.MIMEHeader{}
partHeader.Set("Content-Disposition", `form-data; name="file"; filename="`+filename+`"`)
partHeader.Set("Content-Type", "image/png")
part, err := writer.CreatePart(partHeader)
if err != nil {
t.Fatalf("CreatePart: %v", err)
}
if _, err := part.Write(content); err != nil {
t.Fatalf("write avatar: %v", err)
}
if err := writer.Close(); err != nil {
t.Fatalf("close writer: %v", err)
}
req, err := http.NewRequestWithContext(t.Context(), http.MethodPost,
server.URL+"/api/v1/users/@me/avatar", bytes.NewReader(body.Bytes()))
if err != nil {
t.Fatalf("new request: %v", err)
}
req.Header.Set("Content-Type", writer.FormDataContentType())
req.AddCookie(cookie)
resp, err := server.Client().Do(req)
if err != nil {
t.Fatalf("upload avatar: %v", err)
}
payload, _ := io.ReadAll(resp.Body)
_ = resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("upload avatar = %d, body = %s", resp.StatusCode, payload)
}
var decoded struct {
User struct {
AvatarFileID string `json:"avatar_file_id"`
} `json:"user"`
}
if err := json.Unmarshal(payload, &decoded); err != nil {
t.Fatalf("decode avatar response: %v", err)
}
return decoded.User.AvatarFileID
}