generated from VLADIMIR/template
fix
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user