generated from VLADIMIR/template
up
This commit is contained in:
@@ -0,0 +1,153 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"evening_detective_server/internal/modules/processor_jwt"
|
||||
results "evening_detective_server/internal/services/game_results_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"
|
||||
)
|
||||
|
||||
func (s *server) DownloadGameResultsPackage(ctx context.Context, req *proto.GameResultsPackageReq) (*httpbody.HttpBody, error) {
|
||||
claims, err := claimsFromContext(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !canExportGame(claims) {
|
||||
return nil, status.Error(codes.PermissionDenied, "permission denied")
|
||||
}
|
||||
if req.GetID() <= 0 {
|
||||
return nil, status.Error(codes.InvalidArgument, "Некорректный идентификатор игры")
|
||||
}
|
||||
var output bytes.Buffer
|
||||
if err := s.gameResultsService.Prepare(ctx, int(req.GetID()), &output); err != nil {
|
||||
return nil, gameResultsErrorStatus(err)
|
||||
}
|
||||
return &httpbody.HttpBody{ContentType: "application/zip", Data: output.Bytes()}, nil
|
||||
}
|
||||
|
||||
func (s *server) ApplyGameResultsPackage(ctx context.Context, req *proto.ApplyGameResultsPackageReq) (*proto.GameResultsPackageRsp, 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.GetPackage().GetContentType()) {
|
||||
return nil, status.Error(codes.InvalidArgument, "Требуется application/zip")
|
||||
}
|
||||
result, err := s.gameResultsApplyService.Apply(ctx, int(req.GetID()), bytes.NewReader(req.GetPackage().GetData()), claims.UserID)
|
||||
if err != nil {
|
||||
return nil, gameResultsErrorStatus(err)
|
||||
}
|
||||
return mapGameResultsPackage(result.GameID, result.PackageID, result.AppliedAt), nil
|
||||
}
|
||||
|
||||
// GameResultsDownloadHandler stages a fresh ZIP before sending headers.
|
||||
func GameResultsDownloadHandler(jwt processor_jwt.IProcessorJWT, service *results.GameResultsPackageService) http.Handler {
|
||||
return gameResultsHandler(jwt, func(w http.ResponseWriter, r *http.Request, id, actor int) error {
|
||||
file, err := os.CreateTemp("", "game-results-download-*.zip")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = file.Close(); _ = os.Remove(file.Name()) }()
|
||||
if err := service.Prepare(r.Context(), id, file); err != nil {
|
||||
return err
|
||||
}
|
||||
info, err := file.Stat()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := file.Seek(0, io.SeekStart); err != nil {
|
||||
return err
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/zip")
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="game-results-`+strconv.Itoa(id)+`.zip"`)
|
||||
w.Header().Set("Content-Length", strconv.FormatInt(info.Size(), 10))
|
||||
if _, err := io.Copy(w, file); err != nil && r.Context().Err() == nil {
|
||||
log.Printf("game results download: %v", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// GameResultsApplyHandler accepts ZIP streams without Content-Length.
|
||||
func GameResultsApplyHandler(jwt processor_jwt.IProcessorJWT, service *results.GameResultsApplyService) http.Handler {
|
||||
return gameResultsHandler(jwt, func(w http.ResponseWriter, r *http.Request, id, actor int) error {
|
||||
if !isZIP(r.Header.Get("Content-Type")) {
|
||||
return status.Error(codes.InvalidArgument, "Требуется application/zip")
|
||||
}
|
||||
if r.ContentLength > 1<<30 {
|
||||
return results.ErrLimitExceeded
|
||||
}
|
||||
result, err := service.Apply(r.Context(), id, r.Body, actor)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(mapGameResultsPackage(result.GameID, result.PackageID, result.AppliedAt)); err != nil {
|
||||
log.Printf("game results response: %v", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func gameResultsHandler(jwt processor_jwt.IProcessorJWT, action func(http.ResponseWriter, *http.Request, int, int) error) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
err := func() error {
|
||||
token, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
|
||||
if !ok || token == "" {
|
||||
return status.Error(codes.Unauthenticated, "Требуется авторизация")
|
||||
}
|
||||
claims, err := jwt.GetClaims(token)
|
||||
if err != nil {
|
||||
return status.Error(codes.Unauthenticated, "Недействительный токен")
|
||||
}
|
||||
if !canExportGame(claims) {
|
||||
return status.Error(codes.PermissionDenied, "permission denied")
|
||||
}
|
||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 32)
|
||||
if err != nil || id <= 0 {
|
||||
return status.Error(codes.InvalidArgument, "Некорректный идентификатор игры")
|
||||
}
|
||||
controller := http.NewResponseController(w)
|
||||
if err := controller.SetReadDeadline(time.Now().Add(30 * time.Minute)); err != nil && !errors.Is(err, http.ErrNotSupported) {
|
||||
return err
|
||||
}
|
||||
if err := controller.SetWriteDeadline(time.Now().Add(30 * time.Minute)); err != nil && !errors.Is(err, http.ErrNotSupported) {
|
||||
return err
|
||||
}
|
||||
return action(w, r, int(id), claims.UserID)
|
||||
}()
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
if status.Code(err) == codes.Unknown {
|
||||
err = gameResultsErrorStatus(err)
|
||||
}
|
||||
code := runtime.HTTPStatusFromCode(status.Code(err))
|
||||
if status.Code(err) == codes.ResourceExhausted {
|
||||
code = http.StatusRequestEntityTooLarge
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(code)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"error": status.Convert(err).Message()})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
results "evening_detective_server/internal/services/game_results_package_service"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
"log"
|
||||
"os"
|
||||
)
|
||||
|
||||
func gameResultsErrorStatus(err error) error {
|
||||
switch {
|
||||
case errors.Is(err, results.ErrInvalidPackage), errors.Is(err, results.ErrUnsupportedVersion), errors.Is(err, results.ErrSourceMismatch), errors.Is(err, results.ErrScenarioChanged):
|
||||
return status.Error(codes.InvalidArgument, "Некорректный пакет результатов или игра")
|
||||
case errors.Is(err, results.ErrLimitExceeded):
|
||||
return status.Error(codes.ResourceExhausted, "Пакет результатов превышает допустимый размер")
|
||||
case errors.Is(err, results.ErrReturnConflict):
|
||||
return status.Error(codes.Aborted, "Конфликт пакета результатов")
|
||||
case errors.Is(err, results.ErrReturnRetry):
|
||||
return status.Error(codes.Unavailable, "Повторите загрузку позже")
|
||||
case errors.Is(err, results.ErrNotAvailable), errors.Is(err, results.ErrReturnNotAvailable):
|
||||
return status.Error(codes.FailedPrecondition, err.Error())
|
||||
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.Unavailable, "Время операции истекло; повторите позже")
|
||||
default:
|
||||
log.Printf("game results package: %v (cause: %v)", err, errors.Unwrap(err))
|
||||
return status.Error(codes.Internal, "Не удалось обработать результаты")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"evening_detective_server/proto"
|
||||
"time"
|
||||
)
|
||||
|
||||
func mapGameResultsPackage(gameID int, packageID string, appliedAt time.Time) *proto.GameResultsPackageRsp {
|
||||
return &proto.GameResultsPackageRsp{
|
||||
GameID: int32(gameID),
|
||||
PackageID: packageID,
|
||||
AppliedAt: appliedAt.UTC().Format(time.RFC3339Nano),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"evening_detective_server/internal/modules/processor_jwt"
|
||||
results "evening_detective_server/internal/services/game_results_package_service"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
func TestResultsSafeErrors(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
err error
|
||||
code codes.Code
|
||||
}{
|
||||
{results.ErrInvalidPackage, codes.InvalidArgument},
|
||||
{results.ErrUnsupportedVersion, codes.InvalidArgument},
|
||||
{results.ErrSourceMismatch, codes.InvalidArgument},
|
||||
{results.ErrScenarioChanged, codes.InvalidArgument},
|
||||
{results.ErrLimitExceeded, codes.ResourceExhausted},
|
||||
{results.ErrReturnConflict, codes.Aborted},
|
||||
{results.ErrReturnRetry, codes.Unavailable},
|
||||
{results.ErrNotAvailable, codes.FailedPrecondition},
|
||||
{results.ErrReturnNotAvailable, codes.FailedPrecondition},
|
||||
{context.Canceled, codes.Canceled},
|
||||
{context.DeadlineExceeded, codes.Unavailable},
|
||||
{errors.New("private SQL and file path"), codes.Internal},
|
||||
} {
|
||||
err := gameResultsErrorStatus(tc.err)
|
||||
if status.Code(err) != tc.code || strings.Contains(err.Error(), "private SQL") {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResultsDownloadFailureDoesNotSendZIPOrLeakFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("TMPDIR", dir)
|
||||
jwt := processor_jwt.NewProcessor("results-test-secret")
|
||||
token, err := jwt.GenerateAccessToken(1, "test@test.invalid", []string{"organizer"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.SetPathValue("id", "1")
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
w := httptest.NewRecorder()
|
||||
GameResultsDownloadHandler(jwt, results.NewGameResultsPackageService(nil, "prod")).ServeHTTP(w, req)
|
||||
if w.Code != 400 || w.Header().Get("Content-Type") != "application/json" || w.Header().Get("Content-Disposition") != "" {
|
||||
t.Fatal(w)
|
||||
}
|
||||
files, err := os.ReadDir(dir)
|
||||
if err != nil || len(files) != 0 {
|
||||
t.Fatal("temporary file leaked", err)
|
||||
}
|
||||
}
|
||||
+43
-36
@@ -5,6 +5,7 @@ import (
|
||||
"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_results_package_service"
|
||||
"evening_detective_server/internal/services/game_review_service"
|
||||
"evening_detective_server/internal/services/game_service"
|
||||
"evening_detective_server/internal/services/gameplay_service"
|
||||
@@ -25,24 +26,26 @@ import (
|
||||
type server struct {
|
||||
proto.UnsafeEveningDetectiveServerServer
|
||||
|
||||
usersService *users_service.UsersService
|
||||
uiService *ui_service.UIService
|
||||
taskService *task_service.TaskService
|
||||
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
|
||||
reviewService *game_review_service.GameReviewService
|
||||
teamService *team_service.TeamService
|
||||
ticketService *ticket_service.TicketService
|
||||
participationService *participation_service.ParticipationService
|
||||
screenService *screen_service.ScreenService
|
||||
playlistService *playlist_service.Service
|
||||
screenPlanService *screen_plan_service.ScreenPlanService
|
||||
mapService *map_service.MapService
|
||||
usersService *users_service.UsersService
|
||||
uiService *ui_service.UIService
|
||||
taskService *task_service.TaskService
|
||||
fileService *file_service.FileService
|
||||
scenarioService *scenarios_service.ScenarioService
|
||||
gameExportService *game_export_service.GameExportService
|
||||
gameImportService *game_import_service.GameImportService
|
||||
gameResultsService *game_results_package_service.GameResultsPackageService
|
||||
gameResultsApplyService *game_results_package_service.GameResultsApplyService
|
||||
gameService *game_service.GameService
|
||||
gameplayService *gameplay_service.GameplayService
|
||||
answerService *answer_service.AnswerService
|
||||
reviewService *game_review_service.GameReviewService
|
||||
teamService *team_service.TeamService
|
||||
ticketService *ticket_service.TicketService
|
||||
participationService *participation_service.ParticipationService
|
||||
screenService *screen_service.ScreenService
|
||||
playlistService *playlist_service.Service
|
||||
screenPlanService *screen_plan_service.ScreenPlanService
|
||||
mapService *map_service.MapService
|
||||
}
|
||||
|
||||
// NewServer creates the application server.
|
||||
@@ -65,25 +68,29 @@ func NewServer(
|
||||
taskService *task_service.TaskService,
|
||||
gameExportService *game_export_service.GameExportService,
|
||||
gameImportService *game_import_service.GameImportService,
|
||||
gameResultsService *game_results_package_service.GameResultsPackageService,
|
||||
gameResultsApplyService *game_results_package_service.GameResultsApplyService,
|
||||
) proto.EveningDetectiveServerServer {
|
||||
return &server{
|
||||
gameImportService: gameImportService,
|
||||
taskService: taskService,
|
||||
gameExportService: gameExportService,
|
||||
usersService: usersService,
|
||||
uiService: uiService,
|
||||
fileService: fileService,
|
||||
scenarioService: scenarioService,
|
||||
gameService: gameService,
|
||||
gameplayService: gameplayService,
|
||||
answerService: answerService,
|
||||
reviewService: reviewService,
|
||||
teamService: teamService,
|
||||
ticketService: ticketService,
|
||||
participationService: participationService,
|
||||
screenService: screenService,
|
||||
playlistService: playlistService,
|
||||
screenPlanService: screenPlanService,
|
||||
mapService: mapService,
|
||||
gameImportService: gameImportService,
|
||||
gameResultsService: gameResultsService,
|
||||
gameResultsApplyService: gameResultsApplyService,
|
||||
taskService: taskService,
|
||||
gameExportService: gameExportService,
|
||||
usersService: usersService,
|
||||
uiService: uiService,
|
||||
fileService: fileService,
|
||||
scenarioService: scenarioService,
|
||||
gameService: gameService,
|
||||
gameplayService: gameplayService,
|
||||
answerService: answerService,
|
||||
reviewService: reviewService,
|
||||
teamService: teamService,
|
||||
ticketService: ticketService,
|
||||
participationService: participationService,
|
||||
screenService: screenService,
|
||||
playlistService: playlistService,
|
||||
screenPlanService: screenPlanService,
|
||||
mapService: mapService,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -377,7 +377,7 @@ func verifyExportedGameReads(t *testing.T, f serviceFixture, worker *game_export
|
||||
games := game_service.NewGameService(f.games, f.teams, f.scenarios, nil, "", actions_repo.NewActionsRepo(f.pool), applications_repo.NewApplicationsRepo(f.pool))
|
||||
mapRepo := game_map_repo.NewGameMapRepo(f.pool)
|
||||
server := app.NewServer(nil, nil, nil, nil, games, nil, nil, nil, nil, nil, nil,
|
||||
screen_service.NewScreenService(screen_sessions_repo.NewScreenSessionsRepo(f.pool)), nil, nil, map_service.NewMapService(mapRepo), nil, worker, nil)
|
||||
screen_service.NewScreenService(screen_sessions_repo.NewScreenSessionsRepo(f.pool)), nil, nil, map_service.NewMapService(mapRepo), nil, worker, nil, nil, nil)
|
||||
ctx := processor_jwt.ContextWithClaims(context.Background(), &processor_jwt.JWTClaims{UserID: f.userID, Roles: []string{"organizer"}})
|
||||
game, err := server.GetGame(ctx, &proto.GetGameReq{ID: int32(f.gameID)})
|
||||
check(t, err)
|
||||
|
||||
@@ -9,6 +9,12 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
|
||||
"evening_detective_server/internal/app"
|
||||
"evening_detective_server/internal/modules/processor_jwt"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"os"
|
||||
"reflect"
|
||||
@@ -101,7 +107,7 @@ func (f returnFixture) pack(t *testing.T) []byte {
|
||||
}
|
||||
func (f returnFixture) apply(raw []byte) (*game_returns_repo.Return, error) {
|
||||
service := results.NewGameResultsApplyService(game_returns_repo.NewGameReturnsRepo(f.pool), f.storage, "prod", "https://game.example")
|
||||
r, err := service.Apply(context.Background(), bytes.NewReader(raw), int64(len(raw)), f.userID)
|
||||
r, err := service.Apply(context.Background(), f.gameID, bytes.NewReader(raw), f.userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w (cause: %v)", err, errors.Unwrap(err))
|
||||
}
|
||||
@@ -506,3 +512,68 @@ func TestReturnOverwritesOnlineEditsAndIsolatesScenario(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReturnHTTPRealService(t *testing.T) {
|
||||
f := newReturnFixture(t, "online")
|
||||
raw := f.pack(t)
|
||||
service := results.NewGameResultsApplyService(game_returns_repo.NewGameReturnsRepo(f.pool), f.storage, "prod", "https://game.example")
|
||||
jwt := processor_jwt.NewProcessor("results-test-secret")
|
||||
temp := t.TempDir()
|
||||
t.Setenv("TMPDIR", temp)
|
||||
for _, tc := range []struct {
|
||||
name, role, mime string
|
||||
id int
|
||||
body []byte
|
||||
size int64
|
||||
want int
|
||||
}{
|
||||
{"wrong game before apply", "organizer", "application/zip", f.gameID + 1, raw, -1, 400},
|
||||
{"chunked apply", "organizer", "application/zip", f.gameID, raw, -1, 200},
|
||||
{"wrong game after apply", "organizer", "application/zip", f.gameID + 1, raw, -1, 400},
|
||||
{"admin repeat", "admin", "application/zip", f.gameID, raw, -1, 200},
|
||||
{"developer repeat", "developer", "application/zip", f.gameID, raw, -1, 200},
|
||||
{"author", "author", "application/zip", f.gameID, raw, -1, 403},
|
||||
{"missing auth", "", "application/zip", f.gameID, raw, -1, 401},
|
||||
{"invalid route", "organizer", "application/zip", 0, raw, -1, 400},
|
||||
{"MIME", "organizer", "application/json", f.gameID, raw, -1, 400},
|
||||
{"size", "organizer", "application/zip", f.gameID, raw, 1<<30 + 1, 413},
|
||||
{"invalid ZIP", "organizer", "application/zip", f.gameID, []byte("invalid"), -1, 400},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(tc.body))
|
||||
req.ContentLength = tc.size
|
||||
req.SetPathValue("id", fmt.Sprint(tc.id))
|
||||
req.Header.Set("Content-Type", tc.mime)
|
||||
if tc.role != "" {
|
||||
token, err := jwt.GenerateAccessToken(f.userID, "test@test.invalid", []string{tc.role})
|
||||
check(t, err)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
app.GameResultsApplyHandler(jwt, service).ServeHTTP(w, req)
|
||||
if w.Code != tc.want {
|
||||
t.Fatalf("HTTP %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if tc.want == 200 {
|
||||
var response map[string]any
|
||||
check(t, json.Unmarshal(w.Body.Bytes(), &response))
|
||||
if len(response) != 3 || response["gameID"] != float64(f.gameID) || response["packageID"] == nil || response["appliedAt"] == nil {
|
||||
t.Fatal("private registry exposed or bad response")
|
||||
}
|
||||
} else if strings.Contains(w.Body.String(), temp) {
|
||||
t.Fatal("private path exposed")
|
||||
}
|
||||
files, err := os.ReadDir(temp)
|
||||
check(t, err)
|
||||
if len(files) != 0 {
|
||||
t.Fatal("staging files leaked")
|
||||
}
|
||||
})
|
||||
}
|
||||
for _, id := range []int{0, -1, f.gameID + 1} {
|
||||
_, err := service.Apply(context.Background(), id, bytes.NewReader(raw), f.userID)
|
||||
if !errors.Is(err, results.ErrInvalidPackage) {
|
||||
t.Fatal("route mismatch bypassed repeat", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ var (
|
||||
ErrReturnFailed = errors.New("не удалось применить результаты")
|
||||
)
|
||||
|
||||
// GameResultsApplyService applies private result archives; transport is added separately.
|
||||
// GameResultsApplyService applies private result archives.
|
||||
type GameResultsApplyService struct {
|
||||
repo *game_returns_repo.GameReturnsRepo
|
||||
storage file_storage.IStreamStorage
|
||||
@@ -41,17 +41,23 @@ func NewGameResultsApplyService(repo *game_returns_repo.GameReturnsRepo, storage
|
||||
}
|
||||
|
||||
// Apply preserves recorded repeats, including older packages and deleted games.
|
||||
func (s *GameResultsApplyService) Apply(ctx context.Context, input io.ReaderAt, size int64, initiatorID int) (*game_returns_repo.Return, error) {
|
||||
func (s *GameResultsApplyService) Apply(ctx context.Context, expectedGameID int, input io.Reader, initiatorID int) (*game_returns_repo.Return, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s.mode != "prod" || s.repo == nil || s.storage == nil {
|
||||
return nil, ErrReturnNotAvailable
|
||||
}
|
||||
bundle, digest, err := readResultInput(ctx, input, size)
|
||||
if expectedGameID <= 0 {
|
||||
return nil, ErrInvalidPackage
|
||||
}
|
||||
bundle, digest, err := readResultInput(ctx, input)
|
||||
if err != nil {
|
||||
return nil, safeReturnError(ctx, err)
|
||||
}
|
||||
if bundle.Manifest.GameID != expectedGameID {
|
||||
return nil, ErrInvalidPackage
|
||||
}
|
||||
result, err := s.apply(ctx, bundle, digest, initiatorID)
|
||||
if err == nil || errors.Is(err, ErrSourceMismatch) || errors.Is(err, ErrReturnConflict) || errors.Is(err, ErrReturnRetry) || errors.Is(err, ErrInvalidPackage) {
|
||||
return result, err
|
||||
@@ -281,27 +287,27 @@ func safeReturnError(ctx context.Context, err error) error {
|
||||
}
|
||||
|
||||
// Stage once so the validated payload and idempotency digest always describe the same bytes.
|
||||
func readResultInput(ctx context.Context, input io.ReaderAt, size int64) (*Bundle, string, error) {
|
||||
if input == nil || size <= 0 {
|
||||
func readResultInput(ctx context.Context, input io.Reader) (*Bundle, string, error) {
|
||||
if input == nil {
|
||||
return nil, "", ErrInvalidPackage
|
||||
}
|
||||
if size > game_package_service.MaxArchiveSize {
|
||||
return nil, "", ErrLimitExceeded
|
||||
}
|
||||
file, err := os.CreateTemp("", "game-return-results-*.zip")
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
defer func() { _ = file.Close(); _ = os.Remove(file.Name()) }()
|
||||
hash := sha256.New()
|
||||
n, err := io.Copy(io.MultiWriter(file, hash), &contextReader{ctx: ctx, reader: io.NewSectionReader(input, 0, size)})
|
||||
n, err := io.Copy(io.MultiWriter(file, hash), &contextReader{ctx: ctx, reader: io.LimitReader(input, game_package_service.MaxArchiveSize+1)})
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if n != size {
|
||||
if n > game_package_service.MaxArchiveSize {
|
||||
return nil, "", ErrLimitExceeded
|
||||
}
|
||||
if n == 0 {
|
||||
return nil, "", ErrInvalidPackage
|
||||
}
|
||||
bundle, err := Unpack(ctx, file, size)
|
||||
bundle, err := Unpack(ctx, file, n)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
@@ -91,7 +91,7 @@ func TestRepeatReturnRejectsChangedBytes(t *testing.T) {
|
||||
func TestApplyModeAndSafeErrors(t *testing.T) {
|
||||
for _, mode := range []string{"venue", "", "unknown"} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
_, err := NewGameResultsApplyService(nil, nil, mode, "").Apply(context.Background(), nil, 0, 0)
|
||||
_, err := NewGameResultsApplyService(nil, nil, mode, "").Apply(context.Background(), 1, nil, 0)
|
||||
if !errors.Is(err, ErrReturnNotAvailable) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -166,12 +166,12 @@ func TestOriginalMaterialsRestoresSavedURLs(t *testing.T) {
|
||||
}
|
||||
|
||||
type inspectedResultReader struct {
|
||||
reader io.ReaderAt
|
||||
reader io.Reader
|
||||
inspect func()
|
||||
fail error
|
||||
}
|
||||
|
||||
func (r *inspectedResultReader) ReadAt(raw []byte, offset int64) (int, error) {
|
||||
func (r *inspectedResultReader) Read(raw []byte) (int, error) {
|
||||
if r.inspect != nil {
|
||||
r.inspect()
|
||||
r.inspect = nil
|
||||
@@ -179,7 +179,7 @@ func (r *inspectedResultReader) ReadAt(raw []byte, offset int64) (int, error) {
|
||||
if r.fail != nil {
|
||||
return 0, r.fail
|
||||
}
|
||||
return r.reader.ReadAt(raw, offset)
|
||||
return r.reader.Read(raw)
|
||||
}
|
||||
|
||||
func TestResultStagingIsPrivateAndAlwaysRemoved(t *testing.T) {
|
||||
@@ -230,7 +230,7 @@ func TestResultStagingIsPrivateAndAlwaysRemoved(t *testing.T) {
|
||||
}
|
||||
},
|
||||
}
|
||||
bundle, digest, err := readResultInput(ctx, reader, int64(len(raw)))
|
||||
bundle, digest, err := readResultInput(ctx, reader)
|
||||
if !errors.Is(err, tc.expected) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ func TestProdFlowReturns(t *testing.T) {
|
||||
} else if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
applied, err := service.Apply(ctx, bytes.NewReader(raw), int64(len(raw)), 0)
|
||||
applied, err := service.Apply(ctx, gameID, bytes.NewReader(raw), 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -103,14 +103,14 @@ func TestProdFlowReturns(t *testing.T) {
|
||||
} else if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
newer, err := service.Apply(ctx, bytes.NewReader(corrected), int64(len(corrected)), 0)
|
||||
newer, err := service.Apply(ctx, gameID, bytes.NewReader(corrected), 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !newer.PreparedAt.After(applied.PreparedAt) {
|
||||
t.Fatal("correction did not follow original")
|
||||
}
|
||||
repeated, err := service.Apply(ctx, bytes.NewReader(raw), int64(len(raw)), 0)
|
||||
repeated, err := service.Apply(ctx, gameID, bytes.NewReader(raw), 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user