This commit is contained in:
2026-10-11 13:26:38 +07:00
parent 74a45da402
commit 6f2b63511c
17 changed files with 1254 additions and 304 deletions
@@ -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)
}