This commit is contained in:
2026-10-03 13:22:11 +07:00
parent 05090722a0
commit 2caa2c1a88
74 changed files with 44 additions and 676 deletions
+2 -24
View File
@@ -26,7 +26,6 @@ import (
"google.golang.org/grpc/status"
)
// testStub — минимальный gRPC-сервер, реализующий архивные и файловые RPC.
type testStub struct {
proto.UnimplementedEveningDetectiveServerServer
@@ -128,8 +127,6 @@ func TestGatewayDownloadTeamQR(t *testing.T) {
}
}
// newTestGateway поднимает gRPC-сервер со stub и grpc-gateway с теми же
// опциями, что и в main.go (outgoing matcher, error handler, лимиты,
// rawBodyMarshaler).
func newTestGateway(t *testing.T, stub proto.EveningDetectiveServerServer) *httptest.Server {
return newTestGatewayWithLimit(t, stub, maxFileUploadBody)
@@ -237,8 +234,6 @@ func TestGatewayDownloadZipWithContentDisposition(t *testing.T) {
}
}
// TestGatewayDownloadFileNosniff — скачивание файла отдаёт содержимое,
// Content-Type и X-Content-Type-Options: nosniff.
func TestGatewayDownloadFileNosniff(t *testing.T) {
stub := &testStub{}
ts := newTestGateway(t, stub)
@@ -264,7 +259,6 @@ func TestGatewayDownloadFileNosniff(t *testing.T) {
}
}
// uploadJSONBody собирает JSON-тело загрузки файла (base64 data).
func uploadJSONBody(filename string, data []byte) []byte {
body, _ := json.Marshal(proto.UploadFileReq{
Filename: filename,
@@ -273,8 +267,6 @@ func uploadJSONBody(filename string, data []byte) []byte {
return body
}
// TestGatewayUploadArchiveOverLimit — тело архива больше HTTP-лимита
// (MaxBytesReader) → 413 с понятным текстом (связка middleware → декодер →
// customErrorHandler).
func TestGatewayUploadArchiveOverLimit(t *testing.T) {
stub := &testStub{}
@@ -296,14 +288,11 @@ func TestGatewayUploadArchiveOverLimit(t *testing.T) {
}
}
// TestGatewayUploadFileOverHTTPLimit — тело загрузки больше HTTP-лимита
// (MaxBytesReader) → 413 с понятным текстом.
func TestGatewayUploadFileOverHTTPLimit(t *testing.T) {
stub := &testStub{}
const limit = 1 << 20 // 1 MiB
ts := newTestGatewayWithLimit(t, stub, limit)
// 1 MiB данных в base64 ~1.33 MiB > лимит 1 MiB.
data := bytes.Repeat([]byte("x"), limit)
resp, err := http.Post(ts.URL+"/api/files/upload", "application/json", bytes.NewReader(uploadJSONBody("clue.pdf", data)))
if err != nil {
@@ -320,14 +309,12 @@ func TestGatewayUploadFileOverHTTPLimit(t *testing.T) {
}
}
// TestGatewayUploadFileExactLimit — тело ровно в пределах лимита проходит
// через HTTP-слой и gRPC-транспорт → 200.
func TestGatewayUploadFileExactLimit(t *testing.T) {
stub := &testStub{}
const limit = 2 << 20 // 2 MiB — лимит HTTP-тела
const limit = 2 << 20
ts := newTestGatewayWithLimit(t, stub, limit)
data := bytes.Repeat([]byte("x"), 1<<20) // 1 MiB данных → тело ~1.33 MiB < 2 MiB
data := bytes.Repeat([]byte("x"), 1<<20)
resp, err := http.Post(ts.URL+"/api/files/upload", "application/json", bytes.NewReader(uploadJSONBody("clue.pdf", data)))
if err != nil {
t.Fatalf("POST: %v", err)
@@ -343,8 +330,6 @@ func TestGatewayUploadFileExactLimit(t *testing.T) {
}
}
// TestGatewayUploadFileResourceExhausted — бизнес-ветка: сервис отклоняет
// файл больше лимита (ResourceExhausted) → 413.
func TestGatewayUploadFileResourceExhausted(t *testing.T) {
stub := &testStub{}
stub.uploadErr = status.Error(codes.ResourceExhausted, "файл слишком большой (лимит 64 МБ)")
@@ -365,7 +350,6 @@ func TestGatewayUploadFileResourceExhausted(t *testing.T) {
}
}
// TestCustomErrorHandler — маппинг ошибок превышения размера в 413.
func TestCustomErrorHandler(t *testing.T) {
mux := runtime.NewServeMux()
marshaler := &runtime.JSONPb{}
@@ -403,15 +387,12 @@ func TestCustomErrorHandler(t *testing.T) {
t.Run("ResourceExhausted on other route delegates", func(t *testing.T) {
rec := do(t, httptest.NewRequest(http.MethodPost, "/api/test/echo", nil),
status.Error(codes.ResourceExhausted, "rate limit"))
// DefaultHTTPErrorHandler мапит ResourceExhausted в 429.
if rec.Code != http.StatusTooManyRequests {
t.Errorf("status = %d, want 429 (дефолтный маппинг)", rec.Code)
}
})
}
// TestLimitUploadBody — тело загрузки ограничено на HTTP-слое
// (защита от OOM), прочие маршруты не затронуты.
func TestLimitUploadBody(t *testing.T) {
limited := func(route string, limit int64) http.Handler {
return limitUploadBody(route, limit, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -467,9 +448,6 @@ func TestLimitUploadBody(t *testing.T) {
})
}
// TestCORSHeaders — фронт через fetch должен читать Content-Disposition
// (имя файла архива) из ответа, а браузерный MCP-клиент — заголовки
// streamable HTTP /mcp (Mcp-Session-Id, Mcp-Protocol-Version) в allow/expose.
func TestCORSHeaders(t *testing.T) {
h := cors(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
+1 -50
View File
@@ -66,17 +66,10 @@ import (
//go:embed main.swagger.json
var swaggerJSON []byte
// maxFileSize — максимальный размер файла, передаваемого через gRPC
const maxFileSize = 64 << 20 // 64 МБ
const maxFileSize = 64 << 20
// maxFileUploadBody — лимит HTTP-тела POST /api/files/upload (JSON с base64):
// ceil(maxFileSize·4/3) — точный размер base64, +2 КБ запаса на JSON-обёртку
// {"filename":...,"data":...} с коротким именем файла.
const maxFileUploadBody = (int64(maxFileSize)+2)/3*4 + 2*1024
// grpcMsgLimit — лимит размера gRPC-сообщения (сервер и клиент gateway):
// maxFileSize + 1 MiB на прото-оверхед (tag+varint) поверх ровно 64 MiB
// данных; авторитетная проверка размера — в file_service.
const grpcMsgLimit = maxFileSize + (1 << 20)
func main() {
@@ -240,14 +233,6 @@ func main() {
log.Fatalln("Failed to dial server:", err)
}
// X-Real-IP пробрасывается от доверенного обратного прокси (если фронт-
// контур развёрнут перед gateway, см. docs/COMPLIANCE.md п. 6.6) и
// используется в clientInfoFromContext для фиксации IP клиента при
// акцепте соглашений (ст. 9 152-ФЗ). X-Password — подтверждение паролем
// при удалении аккаунта (не в query, чтобы не светить креденшел в URL
// и логах).
// Сырая загрузка архива (без base64) для распространённых Content-Type;
// JSON-вариант {"data": "<base64>"} работает через application/json.
rawBody := &rawBodyMarshaler{
Marshaler: &runtime.HTTPBodyMarshaler{
Marshaler: &runtime.JSONPb{
@@ -265,8 +250,6 @@ func main() {
}
return runtime.DefaultHeaderMatcher(key)
}),
// Проброс Content-Disposition и X-Content-Type-Options (nosniff)
// от сервиса в HTTP-ответ.
runtime.WithOutgoingHeaderMatcher(func(key string) (string, bool) {
switch {
case strings.EqualFold(key, "Content-Disposition"):
@@ -299,9 +282,6 @@ func main() {
log.Printf("write swagger document: %v", err)
}
})
// MaxBytesReader на HTTP-слое: gateway буферизует тело целиком, без лимита
// проверки в хендлере не спасут от OOM. Лимиты по маршрутам: файлы —
// maxFileUploadBody, архивы — MaxArchiveSize().
mainMux.Handle("/api/", limitUploadBody(
"/api/files/upload",
maxFileUploadBody,
@@ -312,14 +292,6 @@ func main() {
),
))
// MCP-сервер (Model Context Protocol): эндпоинт /api/mcp (streamable HTTP).
// Инструменты MCP вызывают игровые сервисы напрямую (см. mcp_service).
// WithDisableLocalhostProtection: mcp-go отклоняет loopback-запросы с
// не-localhost Host-заголовком (защита от DNS-rebinding), что ломает
// работу за reverse-proxy (nginx/caddy) — контур деплоя проекта.
// Эндпоинт публичный, секрет — пароль команды (аналогично публичному
// REST /api/teams/{id}/story). Путь /api/mcp длиннее /api/, поэтому
// http.ServeMux отдаёт MCP-запросы этому хендлеру, а не gateway.
mcpHTTP := server.NewStreamableHTTPServer(
mcp_service.NewMCPService(gameplayService, answerService).Server(),
server.WithDisableLocalhostProtection(true),
@@ -353,13 +325,7 @@ func cors(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE")
// Mcp-Session-Id / Mcp-Protocol-Version / Last-Event-ID — заголовки
// streamable HTTP MCP-эндпоинта /api/mcp (сессия, версия протокола,
// SSE-реконнект); без них браузерные MCP-клиенты заблокируются preflight.
w.Header().Set("Access-Control-Allow-Headers", "Accept, Content-Type, Content-Length, Accept-Encoding, Authorization, ResponseType, X-Id, X-Password, Mcp-Session-Id, Mcp-Protocol-Version, Last-Event-ID")
// Content-Disposition нужен фронту, чтобы прочитать имя файла архива;
// Mcp-Session-Id / Mcp-Protocol-Version — браузерному MCP-клиенту,
// чтобы читать сессионные заголовки ответов /api/mcp.
w.Header().Set("Access-Control-Expose-Headers", "Content-Disposition, Mcp-Session-Id, Mcp-Protocol-Version")
if r.Method == "OPTIONS" {
return
@@ -368,8 +334,6 @@ func cors(h http.Handler) http.Handler {
})
}
// limitUploadBody ограничивает тело POST-запроса на заданном маршруте
// (gateway читает тело в память целиком).
func limitUploadBody(route string, limit int64, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost && r.URL.Path == route {
@@ -379,16 +343,6 @@ func limitUploadBody(route string, limit int64, next http.Handler) http.Handler
})
}
// customErrorHandler мапит ошибки превышения размера тела в HTTP 413
// с понятным текстом:
// - *http.MaxBytesError (raw) — только для юнит-тестов: сгенерированный
// gateway-код строкифицирует ошибку декодера через %v и тип теряется;
// - gRPC ResourceExhausted на путях загрузки (файл больше лимита);
// - InvalidArgument с текстом MaxBytesError ("http: request body too
// large") — единственная реальная защита 413 на HTTP-слое, ветку нельзя
// удалять при «упрощении» обработчика.
//
// Остальные ошибки обрабатываются как обычно (DefaultHTTPErrorHandler).
func customErrorHandler(
ctx context.Context,
mux *runtime.ServeMux,
@@ -417,9 +371,6 @@ func customErrorHandler(
runtime.DefaultHTTPErrorHandler(ctx, mux, marshaler, w, r, err)
}
// writeTooLargeError — ответ 413 в том же формате {"error": ...}, что и
// DefaultHTTPErrorHandler. Текст лимита формируется из maxFileSize, чтобы
// не расходиться с реальным лимитом при его изменении.
func writeTooLargeError(w http.ResponseWriter, marshaler runtime.Marshaler) {
w.Header().Set("Content-Type", marshaler.ContentType(nil))
w.WriteHeader(http.StatusRequestEntityTooLarge)
-5
View File
@@ -7,9 +7,6 @@ import (
"google.golang.org/genproto/googleapis/api/httpbody"
)
// rawBodyMarshaler — маршалер для бинарного тела (application/zip и др.):
// запрос попадает в HttpBody.Data без base64-JSON, для остального делегирует
// JSON-маршалеру (работает и {"data": "<base64>"}).
type rawBodyMarshaler struct {
runtime.Marshaler
}
@@ -21,8 +18,6 @@ func (m *rawBodyMarshaler) NewDecoder(r io.Reader) runtime.Decoder {
}
}
// rawBodyDecoder — Decoder: для *httpbody.HttpBody читает тело целиком в Data,
// для protobuf-сообщений декодирует JSON как обычно.
type rawBodyDecoder struct {
runtime.Decoder
r io.Reader
@@ -35,7 +35,6 @@ func TestRawBodyDecoderDecodesHttpBody(t *testing.T) {
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)