This commit is contained in:
2026-08-20 23:17:52 +07:00
parent 6d37610348
commit 7656100fe6
19 changed files with 2808 additions and 455 deletions
@@ -0,0 +1,183 @@
package main
import (
"bytes"
"context"
"io"
"net"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"evening_detective_server/internal/modules/scenario_archive"
proto "evening_detective_server/proto"
"github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
"google.golang.org/genproto/googleapis/api/httpbody"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/metadata"
)
// archiveStub — минимальный gRPC-сервер, реализующий только архивные RPC.
type archiveStub struct {
proto.UnimplementedEveningDetectiveServerServer
mu sync.Mutex
uploaded []byte
}
func (s *archiveStub) UploadScenarioArchive(_ context.Context, req *httpbody.HttpBody) (*proto.UploadScenarioArchiveRsp, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.uploaded = append([]byte(nil), req.Data...)
return &proto.UploadScenarioArchiveRsp{Id: 42}, nil
}
func (s *archiveStub) DownloadScenarioArchive(ctx context.Context, _ *proto.DownloadScenarioArchiveReq) (*httpbody.HttpBody, error) {
_ = grpc.SetHeader(ctx, metadata.Pairs("Content-Disposition", `attachment; filename="test.zip"`))
return &httpbody.HttpBody{
Data: []byte("zip-data"),
ContentType: "application/zip",
}, nil
}
func (s *archiveStub) getUploaded() []byte {
s.mu.Lock()
defer s.mu.Unlock()
return append([]byte(nil), s.uploaded...)
}
// newTestGateway поднимает gRPC-сервер со stub и grpc-gateway с теми же
// опциями, что и в main.go (outgoing matcher + rawBodyMarshaler).
func newTestGateway(t *testing.T, stub proto.EveningDetectiveServerServer) *httptest.Server {
t.Helper()
lis, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
gs := grpc.NewServer()
proto.RegisterEveningDetectiveServerServer(gs, stub)
go func() { _ = gs.Serve(lis) }()
t.Cleanup(gs.Stop)
conn, err := grpc.NewClient(lis.Addr().String(), grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
t.Fatalf("dial: %v", err)
}
t.Cleanup(func() { _ = conn.Close() })
mux := runtime.NewServeMux(
runtime.WithOutgoingHeaderMatcher(func(key string) (string, bool) {
if strings.EqualFold(key, "Content-Disposition") {
return "Content-Disposition", true
}
return runtime.DefaultHeaderMatcher(key)
}),
runtime.WithMarshalerOption("application/zip", &rawBodyMarshaler{
Marshaler: &runtime.HTTPBodyMarshaler{Marshaler: &runtime.JSONPb{}},
}),
)
if err := proto.RegisterEveningDetectiveServerHandler(context.Background(), mux, conn); err != nil {
t.Fatalf("register gateway: %v", err)
}
ts := httptest.NewServer(mux)
t.Cleanup(ts.Close)
return ts
}
func TestGatewayUploadRawZip(t *testing.T) {
stub := &archiveStub{}
ts := newTestGateway(t, stub)
raw := []byte("PK\x03\x04raw-zip-bytes")
resp, err := http.Post(ts.URL+"/api/scenarios/archive", "application/zip", bytes.NewReader(raw))
if err != nil {
t.Fatalf("POST: %v", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, body = %s", resp.StatusCode, body)
}
if !strings.Contains(string(body), `"id":42`) {
t.Errorf("ответ = %s, want id 42", body)
}
if got := stub.getUploaded(); !bytes.Equal(got, raw) {
t.Errorf("сервер получил %q, want %q", got, raw)
}
}
func TestGatewayDownloadZipWithContentDisposition(t *testing.T) {
stub := &archiveStub{}
ts := newTestGateway(t, stub)
resp, err := http.Get(ts.URL + "/api/scenarios/1/archive")
if err != nil {
t.Fatalf("GET: %v", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d", resp.StatusCode)
}
if ct := resp.Header.Get("Content-Type"); ct != "application/zip" {
t.Errorf("Content-Type = %q, want application/zip", ct)
}
if cd := resp.Header.Get("Content-Disposition"); cd != `attachment; filename="test.zip"` {
t.Errorf("Content-Disposition = %q", cd)
}
if string(body) != "zip-data" {
t.Errorf("body = %q, want zip-data", body)
}
}
// TestCORSExposesContentDisposition — фронт через fetch должен читать
// Content-Disposition (имя файла архива) из ответа.
func TestCORSExposesContentDisposition(t *testing.T) {
h := cors(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/scenarios/1/archive", nil))
if got := rec.Header().Get("Access-Control-Expose-Headers"); got != "Content-Disposition" {
t.Errorf("Access-Control-Expose-Headers = %q, want Content-Disposition", got)
}
}
// TestLimitArchiveUploadBody — тело загрузки архива ограничено на HTTP-слое
// (защита от OOM), прочие маршруты не затронуты.
func TestLimitArchiveUploadBody(t *testing.T) {
h := limitArchiveUploadBody(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, err := io.ReadAll(r.Body)
if err != nil {
w.WriteHeader(http.StatusRequestEntityTooLarge)
return
}
w.WriteHeader(http.StatusOK)
}))
t.Run("upload route rejected when over limit", func(t *testing.T) {
big := bytes.Repeat([]byte("x"), scenario_archive.MaxArchiveSize()+1)
req := httptest.NewRequest(http.MethodPost, "/api/scenarios/archive", bytes.NewReader(big))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusRequestEntityTooLarge {
t.Errorf("status = %d, want %d", rec.Code, http.StatusRequestEntityTooLarge)
}
})
t.Run("other routes not limited", func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/files/upload", bytes.NewReader([]byte("small")))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("status = %d, want %d", rec.Code, http.StatusOK)
}
})
}
+38 -1
View File
@@ -9,6 +9,7 @@ import (
"evening_detective_server/internal/modules/file_storage"
"evening_detective_server/internal/modules/password_generator"
"evening_detective_server/internal/modules/processor_jwt"
"evening_detective_server/internal/modules/scenario_archive"
"evening_detective_server/internal/modules/storytelling"
"evening_detective_server/internal/repos/actions_repo"
"evening_detective_server/internal/repos/applications_repo"
@@ -35,6 +36,7 @@ import (
"github.com/joho/godotenv"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/protobuf/encoding/protojson"
"github.com/swaggest/swgui/v5emb"
)
@@ -95,6 +97,7 @@ func main() {
scenariosRepo,
cleaner,
os.Getenv("FILE_PREFIX_DOMAIN"),
fileStorage,
)
gameRepo := games_repo.NewGamesRepo(dbpool)
teamRepo := teams_repo.NewTeamsRepo(dbpool)
@@ -178,6 +181,15 @@ func main() {
// акцепте соглашений (ст. 9 152-ФЗ). X-Password — подтверждение паролем
// при удалении аккаунта (не в query, чтобы не светить креденшел в URL
// и логах).
// Сырая загрузка архива (без base64) для распространённых Content-Type;
// JSON-вариант {"data": "<base64>"} работает через application/json.
rawBody := &rawBodyMarshaler{
Marshaler: &runtime.HTTPBodyMarshaler{
Marshaler: &runtime.JSONPb{
UnmarshalOptions: protojson.UnmarshalOptions{DiscardUnknown: true},
},
},
}
gwmux := runtime.NewServeMux(
runtime.WithIncomingHeaderMatcher(func(key string) (string, bool) {
switch {
@@ -188,6 +200,16 @@ func main() {
}
return runtime.DefaultHeaderMatcher(key)
}),
// Проброс Content-Disposition от сервиса в HTTP-ответ.
runtime.WithOutgoingHeaderMatcher(func(key string) (string, bool) {
if strings.EqualFold(key, "Content-Disposition") {
return "Content-Disposition", true
}
return runtime.DefaultHeaderMatcher(key)
}),
runtime.WithMarshalerOption("application/zip", rawBody),
runtime.WithMarshalerOption("application/octet-stream", rawBody),
runtime.WithMarshalerOption("application/x-zip-compressed", rawBody),
)
// Register Greeter
err = proto.RegisterEveningDetectiveServerHandler(context.Background(), gwmux, conn)
@@ -203,7 +225,9 @@ func main() {
w.Header().Set("Content-Type", "application/json")
w.Write(swaggerJSON)
})
mainMux.Handle("/api/", gwmux)
// MaxBytesReader на HTTP-слое: gateway буферизует тело целиком, без лимита
// проверки в хендлере не спасут от OOM.
mainMux.Handle("/api/", limitArchiveUploadBody(gwmux))
gwServer := &http.Server{
Addr: ":8090",
@@ -220,9 +244,22 @@ func cors(h http.Handler) http.Handler {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE")
w.Header().Set("Access-Control-Allow-Headers", "Accept, Content-Type, Content-Length, Accept-Encoding, Authorization, ResponseType, X-Id, X-Password")
// Content-Disposition нужен фронту, чтобы прочитать имя файла архива.
w.Header().Set("Access-Control-Expose-Headers", "Content-Disposition")
if r.Method == "OPTIONS" {
return
}
h.ServeHTTP(w, r)
})
}
// limitArchiveUploadBody ограничивает тело запроса загрузки архива
// (см. rawBodyDecoder: gateway читает тело в память целиком).
func limitArchiveUploadBody(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost && r.URL.Path == "/api/scenarios/archive" {
r.Body = http.MaxBytesReader(w, r.Body, int64(scenario_archive.MaxArchiveSize()))
}
next.ServeHTTP(w, r)
})
}
@@ -735,6 +735,40 @@
]
}
},
"/api/scenarios/archive": {
"post": {
"summary": "Создать сценарий из архива",
"operationId": "EveningDetectiveServer_UploadScenarioArchive",
"responses": {
"200": {
"description": "A successful response.",
"schema": {
"$ref": "#/definitions/evening_detective_serverUploadScenarioArchiveRsp"
}
},
"default": {
"description": "An unexpected error response.",
"schema": {
"$ref": "#/definitions/rpcStatus"
}
}
},
"parameters": [
{
"name": "body",
"description": "Message that represents an arbitrary HTTP body. It should only be used for\npayload formats that can't be represented as JSON, such as raw binary or\nan HTML page.\n\n\nThis message can be used both in streaming and non-streaming API methods in\nthe request as well as the response.\n\nIt can be used as a top-level request field, which is convenient if one\nwants to extract parameters from either the URL or HTTP template into the\nrequest fields and also want access to the raw HTTP body.\n\nExample:\n\n message GetResourceRequest {\n // A unique request id.\n string request_id = 1;\n\n // The raw HTTP body is bound to this field.\n google.api.HttpBody http_body = 2;\n\n }\n\n service ResourceService {\n rpc GetResource(GetResourceRequest)\n returns (google.api.HttpBody);\n rpc UpdateResource(google.api.HttpBody)\n returns (google.protobuf.Empty);\n\n }\n\nExample with streaming methods:\n\n service CaldavService {\n rpc GetCalendar(stream google.api.HttpBody)\n returns (stream google.api.HttpBody);\n rpc UpdateCalendar(stream google.api.HttpBody)\n returns (stream google.api.HttpBody);\n\n }\n\nUse of this type only changes how the request and response bodies are\nhandled, all other features will continue to work unchanged.",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/apiHttpBody"
}
}
],
"tags": [
"Сценарии"
]
}
},
"/api/scenarios/{id}": {
"get": {
"summary": "Получить сценарий по id",
@@ -835,6 +869,38 @@
]
}
},
"/api/scenarios/{id}/archive": {
"get": {
"summary": "Скачать сценарий архивом (со всеми материалами и картинками)",
"operationId": "EveningDetectiveServer_DownloadScenarioArchive",
"responses": {
"200": {
"description": "A successful response.",
"schema": {
"$ref": "#/definitions/apiHttpBody"
}
},
"default": {
"description": "An unexpected error response.",
"schema": {
"$ref": "#/definitions/rpcStatus"
}
}
},
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"type": "integer",
"format": "int32"
}
],
"tags": [
"Сценарии"
]
}
},
"/api/scenarios/{id}/draft": {
"put": {
"summary": "Снять с публикации сценарий",
@@ -2477,6 +2543,18 @@
}
}
},
"evening_detective_serverUploadScenarioArchiveRsp": {
"type": "object",
"properties": {
"error": {
"type": "string"
},
"id": {
"type": "integer",
"format": "int32"
}
}
},
"evening_detective_serverUser": {
"type": "object",
"properties": {
+41
View File
@@ -0,0 +1,41 @@
package main
import (
"io"
"github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
"google.golang.org/genproto/googleapis/api/httpbody"
)
// rawBodyMarshaler — маршалер для бинарного тела (application/zip и др.):
// запрос попадает в HttpBody.Data без base64-JSON, для остального делегирует
// JSON-маршалеру (работает и {"data": "<base64>"}).
type rawBodyMarshaler struct {
runtime.Marshaler
}
func (m *rawBodyMarshaler) NewDecoder(r io.Reader) runtime.Decoder {
return rawBodyDecoder{
Decoder: m.Marshaler.NewDecoder(r),
r: r,
}
}
// rawBodyDecoder — Decoder: для *httpbody.HttpBody читает тело целиком в Data,
// для protobuf-сообщений декодирует JSON как обычно.
type rawBodyDecoder struct {
runtime.Decoder
r io.Reader
}
func (d rawBodyDecoder) Decode(v interface{}) error {
if body, ok := v.(*httpbody.HttpBody); ok {
data, err := io.ReadAll(d.r)
if err != nil {
return err
}
body.Data = data
return nil
}
return d.Decoder.Decode(v)
}
@@ -0,0 +1,46 @@
package main
import (
"bytes"
"strings"
"testing"
proto "evening_detective_server/proto"
"github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
"google.golang.org/genproto/googleapis/api/httpbody"
)
func newTestRawBodyMarshaler() *rawBodyMarshaler {
return &rawBodyMarshaler{
Marshaler: &runtime.HTTPBodyMarshaler{
Marshaler: &runtime.JSONPb{},
},
}
}
func TestRawBodyDecoderDecodesHttpBody(t *testing.T) {
m := newTestRawBodyMarshaler()
raw := []byte("PK\x03\x04fake-zip-bytes")
protoReq := &httpbody.HttpBody{}
if err := m.NewDecoder(bytes.NewReader(raw)).Decode(protoReq); err != nil {
t.Fatalf("Decode: %v", err)
}
if !bytes.Equal(protoReq.Data, raw) {
t.Errorf("Data = %q, want %q", protoReq.Data, raw)
}
}
func TestRawBodyMarshalerDelegatesToJSONForProtos(t *testing.T) {
m := newTestRawBodyMarshaler()
// Для protobuf-сообщения (не HttpBody) декодирование идёт через JSON.
req := &proto.EchoReq{}
if err := m.NewDecoder(strings.NewReader(`{"text":"hi"}`)).Decode(req); err != nil {
t.Fatalf("Decode: %v", err)
}
if req.Text != "hi" {
t.Errorf("Text = %q, want hi", req.Text)
}
}