generated from VLADIMIR/template
up
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log"
|
||||
"mime"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"evening_detective_server/internal/modules/processor_jwt"
|
||||
"evening_detective_server/internal/services/game_import_service"
|
||||
"evening_detective_server/internal/services/game_package_service"
|
||||
"evening_detective_server/proto"
|
||||
"github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
|
||||
"google.golang.org/genproto/googleapis/api/httpbody"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
// IGameImporter is shared by the streaming HTTP and bounded gRPC transports.
|
||||
type IGameImporter interface {
|
||||
Import(context.Context, io.Reader, int) (*game_import_service.Result, error)
|
||||
}
|
||||
|
||||
func (s *server) ImportGame(ctx context.Context, req *httpbody.HttpBody) (*proto.GameImportRsp, error) {
|
||||
claims, err := claimsFromContext(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !canExportGame(claims) {
|
||||
return nil, status.Error(codes.PermissionDenied, "permission denied")
|
||||
}
|
||||
if !isZIP(req.GetContentType()) {
|
||||
return nil, status.Error(codes.InvalidArgument, "Требуется application/zip")
|
||||
}
|
||||
result, err := s.gameImportService.Import(ctx, bytes.NewReader(req.GetData()), claims.UserID)
|
||||
if err != nil {
|
||||
return nil, gameImportErrorStatus(err)
|
||||
}
|
||||
return mapGameImport(result), nil
|
||||
}
|
||||
|
||||
// GameImportHandler receives ZIPs directly, without the gRPC message buffer or multipart encoding.
|
||||
func GameImportHandler(jwt processor_jwt.IProcessorJWT, importer IGameImporter) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
fail := func(err error) {
|
||||
code := runtime.HTTPStatusFromCode(status.Code(err))
|
||||
if status.Code(err) == codes.ResourceExhausted {
|
||||
code = http.StatusRequestEntityTooLarge
|
||||
}
|
||||
w.WriteHeader(code)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"error": status.Convert(err).Message()})
|
||||
}
|
||||
token, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
|
||||
if !ok || token == "" {
|
||||
fail(status.Error(codes.Unauthenticated, "Требуется авторизация"))
|
||||
return
|
||||
}
|
||||
claims, err := jwt.GetClaims(token)
|
||||
if err != nil {
|
||||
fail(status.Error(codes.Unauthenticated, "Недействительный токен"))
|
||||
return
|
||||
}
|
||||
if !canExportGame(claims) {
|
||||
fail(status.Error(codes.PermissionDenied, "permission denied"))
|
||||
return
|
||||
}
|
||||
if !isZIP(r.Header.Get("Content-Type")) {
|
||||
fail(status.Error(codes.InvalidArgument, "Требуется application/zip"))
|
||||
return
|
||||
}
|
||||
if r.ContentLength > game_package_service.MaxArchiveSize {
|
||||
fail(gameImportErrorStatus(game_package_service.ErrLimitExceeded))
|
||||
return
|
||||
}
|
||||
if err := http.NewResponseController(w).SetReadDeadline(time.Now().Add(30 * time.Minute)); err != nil && !errors.Is(err, http.ErrNotSupported) {
|
||||
fail(gameImportErrorStatus(err))
|
||||
return
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, game_package_service.MaxArchiveSize)
|
||||
result, err := importer.Import(r.Context(), r.Body, claims.UserID)
|
||||
if err != nil {
|
||||
fail(gameImportErrorStatus(err))
|
||||
return
|
||||
}
|
||||
if err := json.NewEncoder(w).Encode(map[string]any{
|
||||
"gameID": result.GameID,
|
||||
"packageID": result.PackageID,
|
||||
"repeated": result.Repeated,
|
||||
}); err != nil {
|
||||
log.Printf("game import response: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func isZIP(contentType string) bool {
|
||||
value, _, err := mime.ParseMediaType(contentType)
|
||||
return err == nil && value == "application/zip"
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"evening_detective_server/internal/services/game_import_service"
|
||||
"evening_detective_server/internal/services/game_package_service"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
func gameImportErrorStatus(err error) error {
|
||||
var sizeError *http.MaxBytesError
|
||||
switch {
|
||||
case errors.Is(err, game_import_service.ErrOnlineMode):
|
||||
return status.Error(codes.FailedPrecondition, "Импорт игры доступен только на площадке")
|
||||
case errors.Is(err, game_import_service.ErrConflict):
|
||||
return status.Error(codes.Aborted, "Пакет конфликтует с локальными данными. Существующие записи не изменены.")
|
||||
case errors.Is(err, game_import_service.ErrInProgress), errors.Is(err, game_import_service.ErrInterrupted):
|
||||
return status.Error(codes.Aborted, "Импорт выполняется или был прерван. Повторите загрузку позже.")
|
||||
case errors.Is(err, game_package_service.ErrLimitExceeded), errors.As(err, &sizeError):
|
||||
return status.Error(codes.ResourceExhausted, "Пакет игры превышает допустимый размер")
|
||||
case errors.Is(err, game_package_service.ErrInvalidPackage), errors.Is(err, game_package_service.ErrUnsupportedVersion):
|
||||
return status.Error(codes.InvalidArgument, "Некорректный или неподдерживаемый пакет игры")
|
||||
case errors.Is(err, context.Canceled):
|
||||
return status.Error(codes.Canceled, "Импорт отменён")
|
||||
case errors.Is(err, context.DeadlineExceeded), errors.Is(err, os.ErrDeadlineExceeded):
|
||||
return status.Error(codes.DeadlineExceeded, "Время импорта истекло")
|
||||
default:
|
||||
log.Printf("game import: %v", err)
|
||||
return status.Error(codes.Internal, "Не удалось импортировать игру")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"evening_detective_server/internal/services/game_import_service"
|
||||
"evening_detective_server/proto"
|
||||
)
|
||||
|
||||
func mapGameImport(result *game_import_service.Result) *proto.GameImportRsp {
|
||||
return &proto.GameImportRsp{
|
||||
GameID: int32(result.GameID),
|
||||
PackageID: result.PackageID,
|
||||
Repeated: result.Repeated,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"evening_detective_server/internal/modules/processor_jwt"
|
||||
"evening_detective_server/internal/services/game_import_service"
|
||||
"evening_detective_server/internal/services/game_package_service"
|
||||
"google.golang.org/genproto/googleapis/api/httpbody"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
type importStub struct {
|
||||
calls int
|
||||
err error
|
||||
actor int
|
||||
}
|
||||
|
||||
func (s *importStub) Import(ctx context.Context, body io.Reader, actor int) (*game_import_service.Result, error) {
|
||||
s.calls++
|
||||
s.actor = actor
|
||||
if s.err != nil {
|
||||
return nil, s.err
|
||||
}
|
||||
raw, err := io.ReadAll(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if string(raw) != "ZIP bytes" {
|
||||
return nil, errors.New("wrong body")
|
||||
}
|
||||
return &game_import_service.Result{GameID: 100, PackageID: "package", Repeated: true}, nil
|
||||
}
|
||||
|
||||
func TestImportHTTPAccessAndResponse(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name, role, auth, mime string
|
||||
size int64
|
||||
want int
|
||||
called bool
|
||||
}{
|
||||
{"organizer", "organizer", "valid", "application/zip", 9, 200, true},
|
||||
{"admin", "admin", "valid", "application/zip", 9, 200, true},
|
||||
{"developer", "developer", "valid", "application/zip", 9, 200, true},
|
||||
{"author", "author", "valid", "application/zip", 9, 403, false},
|
||||
{"user", "user", "valid", "application/zip", 9, 403, false},
|
||||
{"missing JWT", "", "", "application/zip", 9, 401, false},
|
||||
{"bad JWT", "", "Bearer bad", "application/zip", 9, 401, false},
|
||||
{"missing bearer prefix", "organizer", "no-prefix", "application/zip", 9, 401, false},
|
||||
{"wrong MIME", "organizer", "valid", "application/json", 9, 400, false},
|
||||
{"oversized", "organizer", "valid", "application/zip", game_package_service.MaxArchiveSize + 1, 413, false},
|
||||
{"chunked upload", "organizer", "valid", "application/zip", -1, 200, true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
jwt := processor_jwt.NewProcessor("import-test-secret")
|
||||
token, err := jwt.GenerateAccessToken(17, "organizer@test.invalid", []string{tc.role})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stub := &importStub{}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/games/import", strings.NewReader("ZIP bytes"))
|
||||
req.ContentLength = tc.size
|
||||
req.Header.Set("Content-Type", tc.mime)
|
||||
switch tc.auth {
|
||||
case "valid":
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
case "no-prefix":
|
||||
req.Header.Set("Authorization", token)
|
||||
default:
|
||||
req.Header.Set("Authorization", tc.auth)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
GameImportHandler(jwt, stub).ServeHTTP(w, req)
|
||||
if w.Code != tc.want || (stub.calls == 1) != tc.called {
|
||||
t.Fatalf("status %d, calls %d", w.Code, stub.calls)
|
||||
}
|
||||
if w.Header().Get("Cache-Control") != "no-store" {
|
||||
t.Fatal("response cacheable")
|
||||
}
|
||||
if tc.called {
|
||||
var result struct {
|
||||
GameID int `json:"gameID"`
|
||||
PackageID string `json:"packageID"`
|
||||
Repeated bool `json:"repeated"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stub.actor != 17 || result.GameID != 100 || result.PackageID != "package" || !result.Repeated {
|
||||
t.Fatal("incorrect response or actor")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportErrorMapping(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
err error
|
||||
code codes.Code
|
||||
http int
|
||||
}{
|
||||
{"online", game_import_service.ErrOnlineMode, codes.FailedPrecondition, 400},
|
||||
{"conflict", game_import_service.ErrConflict, codes.Aborted, 409},
|
||||
{"busy", game_import_service.ErrInProgress, codes.Aborted, 409},
|
||||
{"interrupted", game_import_service.ErrInterrupted, codes.Aborted, 409},
|
||||
{"invalid", game_package_service.ErrInvalidPackage, codes.InvalidArgument, 400},
|
||||
{"version", game_package_service.ErrUnsupportedVersion, codes.InvalidArgument, 400},
|
||||
{"limit", game_package_service.ErrLimitExceeded, codes.ResourceExhausted, 413},
|
||||
{"body limit", &http.MaxBytesError{Limit: 1}, codes.ResourceExhausted, 413},
|
||||
{"cancel", context.Canceled, codes.Canceled, 499},
|
||||
{"deadline", context.DeadlineExceeded, codes.DeadlineExceeded, 504},
|
||||
{"read deadline", os.ErrDeadlineExceeded, codes.DeadlineExceeded, 504},
|
||||
{"unknown", errors.New("private SQL secret path"), codes.Internal, 500},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
mapped := gameImportErrorStatus(fmt.Errorf("private detail: %w", tc.err))
|
||||
if status.Code(mapped) != tc.code || strings.Contains(status.Convert(mapped).Message(), "private") {
|
||||
t.Fatalf("unsafe or wrong mapping: %v", mapped)
|
||||
}
|
||||
jwt := processor_jwt.NewProcessor("import-test-secret")
|
||||
token, err := jwt.GenerateAccessToken(1, "test@invalid", []string{"organizer"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/games/import", strings.NewReader("ZIP bytes"))
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Content-Type", "application/zip")
|
||||
w := httptest.NewRecorder()
|
||||
GameImportHandler(jwt, &importStub{err: tc.err}).ServeHTTP(w, req)
|
||||
if w.Code != tc.http || strings.Contains(w.Body.String(), "private") {
|
||||
t.Fatalf("unsafe or wrong HTTP response: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportRPCRejectsUnauthorizedCallers(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name, role, mime string
|
||||
want codes.Code
|
||||
}{
|
||||
{"guest", "", "application/zip", codes.Unauthenticated},
|
||||
{"user", "user", "application/zip", codes.PermissionDenied},
|
||||
{"author", "author", "application/zip", codes.PermissionDenied},
|
||||
{"invalid content type", "organizer", "text/plain", codes.InvalidArgument},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
if tc.role != "" {
|
||||
ctx = processor_jwt.ContextWithClaims(ctx, &processor_jwt.JWTClaims{UserID: 1, Roles: []string{tc.role}})
|
||||
}
|
||||
_, err := (&server{}).ImportGame(ctx, &httpbody.HttpBody{ContentType: tc.mime})
|
||||
if status.Code(err) != tc.want {
|
||||
t.Fatalf("got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"evening_detective_server/internal/services/answer_service"
|
||||
"evening_detective_server/internal/services/file_service"
|
||||
"evening_detective_server/internal/services/game_export_service"
|
||||
"evening_detective_server/internal/services/game_import_service"
|
||||
"evening_detective_server/internal/services/game_review_service"
|
||||
"evening_detective_server/internal/services/game_service"
|
||||
"evening_detective_server/internal/services/gameplay_service"
|
||||
@@ -30,6 +31,7 @@ type server struct {
|
||||
fileService *file_service.FileService
|
||||
scenarioService *scenarios_service.ScenarioService
|
||||
gameExportService *game_export_service.GameExportService
|
||||
gameImportService *game_import_service.GameImportService
|
||||
gameService *game_service.GameService
|
||||
gameplayService *gameplay_service.GameplayService
|
||||
answerService *answer_service.AnswerService
|
||||
@@ -62,8 +64,10 @@ func NewServer(
|
||||
mapService *map_service.MapService,
|
||||
taskService *task_service.TaskService,
|
||||
gameExportService *game_export_service.GameExportService,
|
||||
gameImportService *game_import_service.GameImportService,
|
||||
) proto.EveningDetectiveServerServer {
|
||||
return &server{
|
||||
gameImportService: gameImportService,
|
||||
taskService: taskService,
|
||||
gameExportService: gameExportService,
|
||||
usersService: usersService,
|
||||
|
||||
Reference in New Issue
Block a user