feat(files): загрузка и выдача вложений
- POST /api/v1/channels/{id}/files (multipart, ATTACH_FILES): файл пишется на
диск под идентификатором, в БД хранятся метаданные и sha256; имя файла
очищается от путей и управляющих символов, объём ограничен MAX_UPLOAD_SIZE;
- GET/HEAD /files/{id}: содержимое с проверкой прав на комнату сообщения
(невидимая комната и чужие вложения → 404), ETag, immutable-кэш,
Content-Disposition с RFC 5987 для не-ASCII имён;
- вложения привязываются к сообщению при отправке (attachment_ids), сироты
ищутся через store.ListOrphanFiles для обслуживания;
- GET /api/v1/files/{id} — метаданные файла для клиента;
- chi-ручки теперь умеют отдавать huma-ошибки в общем конверте
(writeHumaAPIError), иначе 404 превращался в 500;
- READY отдаёт реальные read states пользователя.
Тесты: загрузка, скачивание участником, скрытая комната → 404, вложение в
сообщении, проверка подписи ETag.
This commit is contained in:
@@ -1,7 +1,12 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"glchat/internal/permissions"
|
||||
@@ -371,3 +376,145 @@ func TestTypingAndReadState(t *testing.T) {
|
||||
t.Fatalf("read states = %+v", states)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileUploadAndAccess(t *testing.T) {
|
||||
f := newMessagingFixture(t)
|
||||
httpServer := httptest.NewServer(f.srv.Handler())
|
||||
t.Cleanup(httpServer.Close)
|
||||
|
||||
content := []byte("вложение: проверка загрузки")
|
||||
body := &bytes.Buffer{}
|
||||
writer := multipart.NewWriter(body)
|
||||
part, err := writer.CreateFormFile("file", "документ.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateFormFile: %v", err)
|
||||
}
|
||||
if _, err := part.Write(content); err != nil {
|
||||
t.Fatalf("write part: %v", err)
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatalf("close writer: %v", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(t.Context(), http.MethodPost,
|
||||
httpServer.URL+"/api/v1/channels/"+f.openChannel+"/files", bytes.NewReader(body.Bytes()))
|
||||
if err != nil {
|
||||
t.Fatalf("new request: %v", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
req.AddCookie(f.memberCookie)
|
||||
resp, err := httpServer.Client().Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("upload: %v", err)
|
||||
}
|
||||
payload, _ := io.ReadAll(resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("upload = %d, body = %s", resp.StatusCode, payload)
|
||||
}
|
||||
var decoded struct {
|
||||
File struct {
|
||||
FileID string `json:"file_id"`
|
||||
Filename string `json:"filename"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
} `json:"file"`
|
||||
}
|
||||
if err := json.Unmarshal(payload, &decoded); err != nil {
|
||||
t.Fatalf("decode upload: %v (%s)", err, payload)
|
||||
}
|
||||
if decoded.File.FileID == "" || decoded.File.Filename != "документ.txt" || decoded.File.SizeBytes != int64(len(content)) {
|
||||
t.Fatalf("unexpected upload payload: %s", payload)
|
||||
}
|
||||
|
||||
// Скачивание участником комнаты.
|
||||
downloadReq, err := http.NewRequestWithContext(t.Context(), http.MethodGet,
|
||||
httpServer.URL+"/files/"+decoded.File.FileID, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("new download request: %v", err)
|
||||
}
|
||||
downloadReq.AddCookie(f.memberCookie)
|
||||
downloadResp, err := httpServer.Client().Do(downloadReq)
|
||||
if err != nil {
|
||||
t.Fatalf("download: %v", err)
|
||||
}
|
||||
downloaded, _ := io.ReadAll(downloadResp.Body)
|
||||
_ = downloadResp.Body.Close()
|
||||
if downloadResp.StatusCode != http.StatusOK || !bytes.Equal(downloaded, content) {
|
||||
t.Fatalf("download = %d, body = %q", downloadResp.StatusCode, downloaded)
|
||||
}
|
||||
if downloadResp.Header.Get("ETag") == "" {
|
||||
t.Error("ETag header is missing")
|
||||
}
|
||||
|
||||
// Файл из скрытой комнаты недоступен тому, кто её не видит: загружаем
|
||||
// тот же контент владельцем в «тайную» и проверяем участника.
|
||||
secretBody := &bytes.Buffer{}
|
||||
secretWriter := multipart.NewWriter(secretBody)
|
||||
secretPart, err := secretWriter.CreateFormFile("file", "секрет.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateFormFile secret: %v", err)
|
||||
}
|
||||
if _, err := secretPart.Write(content); err != nil {
|
||||
t.Fatalf("write secret part: %v", err)
|
||||
}
|
||||
if err := secretWriter.Close(); err != nil {
|
||||
t.Fatalf("close secret writer: %v", err)
|
||||
}
|
||||
secretReq, err := http.NewRequestWithContext(t.Context(), http.MethodPost,
|
||||
httpServer.URL+"/api/v1/channels/"+f.secretID+"/files", bytes.NewReader(secretBody.Bytes()))
|
||||
if err != nil {
|
||||
t.Fatalf("new secret request: %v", err)
|
||||
}
|
||||
secretReq.Header.Set("Content-Type", secretWriter.FormDataContentType())
|
||||
secretReq.AddCookie(f.ownerCookie)
|
||||
secretResp, err := httpServer.Client().Do(secretReq)
|
||||
if err != nil {
|
||||
t.Fatalf("secret upload: %v", err)
|
||||
}
|
||||
secretPayload, _ := io.ReadAll(secretResp.Body)
|
||||
_ = secretResp.Body.Close()
|
||||
if secretResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("secret upload = %d, body = %s", secretResp.StatusCode, secretPayload)
|
||||
}
|
||||
var secretFile struct {
|
||||
File struct {
|
||||
FileID string `json:"file_id"`
|
||||
} `json:"file"`
|
||||
}
|
||||
if err := json.Unmarshal(secretPayload, &secretFile); err != nil {
|
||||
t.Fatalf("decode secret upload: %v", err)
|
||||
}
|
||||
|
||||
hiddenReq, err := http.NewRequestWithContext(t.Context(), http.MethodGet,
|
||||
httpServer.URL+"/files/"+secretFile.File.FileID, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("new hidden request: %v", err)
|
||||
}
|
||||
hiddenReq.AddCookie(f.memberCookie)
|
||||
hiddenResp, err := httpServer.Client().Do(hiddenReq)
|
||||
if err != nil {
|
||||
t.Fatalf("hidden download: %v", err)
|
||||
}
|
||||
_ = hiddenResp.Body.Close()
|
||||
if hiddenResp.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("hidden channel file = %d, want 404", hiddenResp.StatusCode)
|
||||
}
|
||||
|
||||
// Вложение прикрепляется к сообщению и попадает в ответ API.
|
||||
sent := doJSON(t, f.srv, http.MethodPost, "/api/v1/channels/"+f.openChannel+"/messages",
|
||||
`{"content":"","attachment_ids":["`+decoded.File.FileID+`"]}`, f.memberCookie)
|
||||
if sent.Code != http.StatusOK {
|
||||
t.Fatalf("message with attachment = %d, body = %s", sent.Code, sent.Body.String())
|
||||
}
|
||||
message := decodeResponse[struct {
|
||||
Message struct {
|
||||
Attachments []struct {
|
||||
FileID string `json:"file_id"`
|
||||
Filename string `json:"filename"`
|
||||
} `json:"attachments"`
|
||||
} `json:"message"`
|
||||
}](t, sent)
|
||||
if len(message.Message.Attachments) != 1 || message.Message.Attachments[0].FileID != decoded.File.FileID {
|
||||
t.Fatalf("attachments = %+v", message.Message.Attachments)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user