From 26c65062e98edc856f0797961add5a47513d43f4 Mon Sep 17 00:00:00 2001 From: Fedorov Vladimir Date: Sun, 11 Oct 2026 12:08:25 +0700 Subject: [PATCH] up --- docs/GAME_PACKAGE.md | 90 ++++ internal/repos/game_results_repo/repo.go | 152 +++++++ .../game_results_package_service/codec.go | 195 ++++++++ .../codec_test.go | 417 ++++++++++++++++++ .../game_results_package_service/flow_test.go | 143 ++++++ .../game_results_package_service/model.go | 113 +++++ .../game_results_package_service/service.go | 47 ++ .../game_results_package_service/snapshot.go | 165 +++++++ .../snapshot_test.go | 186 ++++++++ .../validation.go | 195 ++++++++ 10 files changed, 1703 insertions(+) create mode 100644 internal/repos/game_results_repo/repo.go create mode 100644 internal/services/game_results_package_service/codec.go create mode 100644 internal/services/game_results_package_service/codec_test.go create mode 100644 internal/services/game_results_package_service/flow_test.go create mode 100644 internal/services/game_results_package_service/model.go create mode 100644 internal/services/game_results_package_service/service.go create mode 100644 internal/services/game_results_package_service/snapshot.go create mode 100644 internal/services/game_results_package_service/snapshot_test.go create mode 100644 internal/services/game_results_package_service/validation.go diff --git a/docs/GAME_PACKAGE.md b/docs/GAME_PACKAGE.md index 26d79a3..383d225 100644 --- a/docs/GAME_PACKAGE.md +++ b/docs/GAME_PACKAGE.md @@ -346,3 +346,93 @@ RustFS и создать `.continue` в течение минуты. Buc Интерфейс передачи остаётся этапом 7; полный билетный и игровой прогон — этапом 8. До готовности возврата результатов используются только тестовые игры. + +## Пакет результатов, версия 1 (ED-029, этап 9) + +Это отдельный контракт: ZIP содержит ровно `manifest.json` и `results.json`, +без сценария и файлов. Он ещё не применяется онлайн и не доступен через API/UI. + +`game_results_package_service.NewGameResultsPackageService(repo, mode).Prepare(ctx, +gameID, output)` доступен только в `venue`, для существующей игры из готового +импорта со статусом `finish` и заполненными датами проведения. Репозиторий +читает исходный реестр и текущие игровые данные в read-only транзакции +`REPEATABLE READ`. Подготовка ничего не меняет и не блокирует редактирование. +Повторный вызов создаёт новый ID и снимок, включая последующее оценивание. + +Для работы без БД используются `Pack(ctx, manifest, data, output)` и +`Unpack(ctx, input io.ReaderAt, size)`. `Pack` задаёт версию, новый 128-битный +случайный ID, UTC-время, размер и SHA-256 `results.json`. При любой ошибке caller +отбрасывает выходной файл. `Unpack` возвращает только полностью проверенный +`Bundle`, читает ограниченные JSON в память, не извлекает файлы и не обращается +к сети/БД. Отмена контекста сохраняется в цепочке ошибки. + +### manifest.json + +| Поле | Значение | +| --- | --- | +| `version` | `1` | +| `package_id`, `created_at` | ID именно пакета результатов, время подготовки | +| `source_server`, `game_id` | Онлайн-источник и исходный ID игры | +| `source_package_id`, `source_sha256` | ID и SHA-256 всего исходного ZIP игры из реестра импорта | +| `size`, `sha256` | Размер и SHA-256 байтов `results.json` | + +SHA-256 защищает от повреждения; это не подпись и не доказательство подлинности. +Онлайн-применение должно сверять источник и исходный экспорт на этапе 10. + +### results.json + +Все массивы обязательны и являются полным снимком; пустой массив — `[]`, +пропущенный или `null` массив не допускается. + +| Поле | Содержимое | +| --- | --- | +| `source` | Исходные `teams`/`tickets` из сохранённого `game.json`, `scenario_id`, SHA-256 сериализованного исходного `story` в `scenario_sha256`, `materials` и `question_codes` | +| `game` | `id`, `status=finish`, неизменённый `format`, `started_at`, `ended_at` | +| `teams` | Итоговые поля команды из контракта игры плюс `source_id` и необязательный `local_creator_id` | +| `tickets` | Итоговые поля билета из контракта игры плюс `source_id` и необязательный `local_used_by` | +| `deleted_team_ids`, `deleted_ticket_ids` | Все исходные ID, отсутствующие в итоговом снимке | +| `actions` | Локальные `id`, `team_id`, `code`, `via_mcp`, `created_at`; порядок по команде, времени и ID сохраняет повторные и неизвестные коды | +| `applications` | Локальные `id`, `team_id`, исходные `name`/`image`/`file_type`, `status` (`need`/`done`), `created_at` | +| `answers` | `team_id`, `question_code`, `answer`, nullable `score`, `created_at`, `updated_at` | + +`source_id` совпадает с `id` для исходной записи и равен `0` для новой локальной. +Все ссылки прогресса используют итоговый локальный `team_id`. Локальные ID новых +команд, билетов, ходов и улик не являются свободными ID онлайн-базы: следующий +этап должен выделять ID и сохранять соответствия. + +`creator_id` и `registration_delivery_id` итоговой команды берутся только из +исходного снимка; для новой команды они отсутствуют. Текущий локальный создатель +сохраняется отдельно как `local_creator_id`. `used_by` билета восстанавливается +из исходного снимка только для ранее использованного билета без локального +исполнителя; текущий локальный исполнитель хранится в `local_used_by`. +Эти пространства пользователей нельзя автоматически связывать или переносить +с ними права. Пароли QR и хеши билетов сохраняются; SMTP-задания, токены писем, +JWT, refresh-токены и локальные аккаунты в пакет не входят. + +Материал — тройка `name`, `image`, `file_type`; имя очищается штатным cleaner, +тип при отсутствии определяется штатным file storage. `image` указывает на +`files/...` исходного пакета игры, а не на адрес/ключ локального RustFS. +При сборке проверяется идентичность текущего игрового `story` исходному после +штатного переименования медиа при импорте, а также ID сценария и формат игры. +Изменённый сценарий и не связанные с ним материалы отклоняются. Музыка, карта, +экраны и оформление не возвращаются. Баллы не пересчитываются: сохраняются +выставленные значения, включая `0` и `null`, также в формате `offline`. + +### Проверка и ограничения + +Архив и распакованный объём ограничены 1 ГиБ; каждый из двух JSON — 8 МиБ +(фактический максимальный объём данных меньше общего предела). Проверяются +точный состав ZIP, обычные нешифрованные записи, отсутствие дублей, версия, +размеры, CRC и SHA-256, обязательные поля, исходные/локальные идентификаторы, +полнота удалений, уникальность хешей билетов, ссылки прогресса, материалы, +вопросы и порядок ходов. Неизвестные поля и лишние JSON-документы отклоняются. + +Проверки кодека: `go test ./internal/services/game_results_package_service`. +Полный PostgreSQL/RustFS-flow: `python3 tests/venue/flow.py --results` из корня +workspace. Стенд проверяет оба формата, свежую оценку и конкурентные снимки, +сохранность прежнего ZIP, повторную подготовку после перезапуска и отсутствие +изменений исходных игр. Пакеты и тестовые ресурсы удаляются после прогона. + +Применение онлайн, разрешение конфликтов и идемпотентность возврата — этап 10; +подготовка/скачивание и импорт через интерфейс — этап 11. До завершения возврата +используются только тестовые игры. diff --git a/internal/repos/game_results_repo/repo.go b/internal/repos/game_results_repo/repo.go new file mode 100644 index 0000000..5b455b5 --- /dev/null +++ b/internal/repos/game_results_repo/repo.go @@ -0,0 +1,152 @@ +// Package game_results_repo reads consistent, private results snapshots. +package game_results_repo + +import ( + "context" + "errors" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +var ErrNotImported = errors.New("игра не найдена или не импортирована") + +// Snapshot contains only explicitly selected data; account credentials and mail jobs are excluded. +type Snapshot struct { + ScenarioID int + ImportedScenarioID int + ImportID string + PackageID string + SourceServer string + SHA256 string + OriginalData []byte + Manifest []byte + Story []byte + Data []byte +} + +type GameResultsRepo struct{ pool *pgxpool.Pool } + +func NewGameResultsRepo(pool *pgxpool.Pool) *GameResultsRepo { return &GameResultsRepo{pool: pool} } + +// Capture uses one read-only snapshot, including metadata and all gameplay relations. +func (r *GameResultsRepo) Capture(ctx context.Context, gameID int) (*Snapshot, error) { + tx, err := r.pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.RepeatableRead, AccessMode: pgx.ReadOnly}) + if err != nil { + return nil, err + } + defer func() { _ = tx.Rollback(ctx) }() + snapshot := &Snapshot{} + err = tx.QueryRow( + ctx, + `SELECT + i.id, + i.package_id, + i.source_server, + i.sha256, + i.original_data, + i.manifest, + s.scenario, + g.scenario_id, + (i.mappings->>'scenario_id')::integer, + jsonb_build_object( + 'game', jsonb_build_object( + 'id', g.id, 'status', g.status, 'format', g.format, + 'started_at', g.started_at AT TIME ZONE 'UTC', + 'ended_at', g.ended_at AT TIME ZONE 'UTC' + ), + 'teams', COALESCE(( + SELECT + jsonb_agg(jsonb_build_object( + 'id', t.id, + 'game_id', t.game_id, + 'name', t.name, + 'status', t.status, + 'password', t.password, + 'creator_id', t.creator_id, + 'captain_email', t.captain_email, + 'participants_count', t.participants_count, + 'participation_status', t.participation_status, + 'participation_source', t.participation_source, + 'participation_answered_at', t.participation_answered_at, + 'registration_delivery_id', t.registration_delivery_id, + 'created_at', t.created_at AT TIME ZONE 'UTC' + ) ORDER BY t.id) + FROM teams t + WHERE t.game_id = g.id + ), '[]'::jsonb), + 'tickets', COALESCE(( + SELECT + jsonb_agg(jsonb_build_object( + 'id', t.id, + 'game_id', t.game_id, + 'token_hash', encode(t.token_hash, 'base64'), + 'status', t.status, + 'used_at', t.used_at, + 'used_by', t.used_by, + 'created_at', t.created_at + ) ORDER BY t.id) + FROM tickets t + WHERE t.game_id = g.id + ), '[]'::jsonb), + 'actions', COALESCE(( + SELECT + jsonb_agg(jsonb_build_object( + 'id', a.id, + 'team_id', a.team_id, + 'code', COALESCE(a.code, ''), + 'via_mcp', a.via_mcp, + 'created_at', a.created_at AT TIME ZONE 'UTC' + ) ORDER BY a.team_id, a.created_at, a.id) + FROM actions a + JOIN teams t ON t.id = a.team_id + WHERE t.game_id = g.id + ), '[]'::jsonb), + 'applications', COALESCE(( + SELECT + jsonb_agg(jsonb_build_object( + 'id', a.id, + 'team_id', a.team_id, + 'name', COALESCE(a.name, ''), + 'image', COALESCE(a.image, ''), + 'file_type', COALESCE(a.file_type, ''), + 'status', a.status, + 'created_at', a.created_at AT TIME ZONE 'UTC' + ) ORDER BY a.team_id, a.created_at, a.id) + FROM applications a + JOIN teams t ON t.id = a.team_id + WHERE t.game_id = g.id + ), '[]'::jsonb), + 'answers', COALESCE(( + SELECT + jsonb_agg(jsonb_build_object( + 'team_id', a.team_id, + 'question_code', a.question_code, + 'answer', a.answer, + 'score', a.score, + 'created_at', a.created_at AT TIME ZONE 'UTC', + 'updated_at', a.updated_at AT TIME ZONE 'UTC' + ) ORDER BY a.team_id, a.question_code) + FROM team_answers a + JOIN teams t ON t.id = a.team_id + WHERE t.game_id = g.id + ), '[]'::jsonb) + ) + FROM games g + JOIN scenarios s ON s.id = g.scenario_id + JOIN game_imports i ON i.source_game_id = g.id AND i.state = 'ready' + WHERE g.id = $1`, + gameID, + ).Scan(&snapshot.ImportID, &snapshot.PackageID, &snapshot.SourceServer, + &snapshot.SHA256, &snapshot.OriginalData, &snapshot.Manifest, &snapshot.Story, &snapshot.ScenarioID, &snapshot.ImportedScenarioID, &snapshot.Data) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotImported + } + if err != nil { + return nil, err + } + if err := tx.Commit(ctx); err != nil { + return nil, err + } + return snapshot, nil +} diff --git a/internal/services/game_results_package_service/codec.go b/internal/services/game_results_package_service/codec.go new file mode 100644 index 0000000..a8cb76c --- /dev/null +++ b/internal/services/game_results_package_service/codec.go @@ -0,0 +1,195 @@ +package game_results_package_service + +import ( + "archive/zip" + "bytes" + "context" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "io" + "time" + + "evening_detective_server/internal/services/game_package_service" +) + +// Pack assigns a fresh identity and writes a complete version 1 archive. +// Callers must discard output on error. Input data is never changed. +func Pack(ctx context.Context, manifest Manifest, data *Data, output io.Writer) error { + if err := ctx.Err(); err != nil { + return err + } + if output == nil { + return ErrInvalidPackage + } + if err := validateData(data); err != nil { + return err + } + raw, err := json.Marshal(data) + if err != nil { + return err + } + if int64(len(raw)) > game_package_service.MaxJSONSize { + return ErrLimitExceeded + } + id := make([]byte, 16) + if _, err := rand.Read(id); err != nil { + return err + } + manifest.Version = Version + manifest.PackageID = hex.EncodeToString(id) + manifest.CreatedAt = time.Now().UTC() + manifest.Size = int64(len(raw)) + manifest.SHA256 = digest(raw) + if err := validateManifest(manifest, data.Game.ID); err != nil { + return err + } + header, err := json.Marshal(manifest) + if err != nil { + return err + } + if int64(len(header)) > game_package_service.MaxJSONSize { + return ErrLimitExceeded + } + writer := zip.NewWriter(&contextWriter{ctx: ctx, writer: output}) + for _, entry := range []struct { + name string + raw []byte + }{{"manifest.json", header}, {"results.json", raw}} { + file, err := writer.Create(entry.name) + if err != nil { + return err + } + if _, err := file.Write(entry.raw); err != nil { + return err + } + } + if err := writer.Close(); err != nil { + return err + } + return ctx.Err() +} + +// Unpack validates bounded JSON in memory without extracting files or accessing the network. +func Unpack(ctx context.Context, input io.ReaderAt, size int64) (*Bundle, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + if input == nil || size <= 0 { + return nil, ErrInvalidPackage + } + if size > game_package_service.MaxArchiveSize { + return nil, ErrLimitExceeded + } + reader, err := zip.NewReader(input, size) + if ctx.Err() != nil { + return nil, ctx.Err() + } + if err != nil { + return nil, errors.Join(ErrInvalidPackage, err) + } + if len(reader.File) != 2 { + return nil, ErrInvalidPackage + } + entries := map[string][]byte{} + for _, file := range reader.File { + if (file.Name != "manifest.json" && file.Name != "results.json") || !file.Mode().IsRegular() || file.Flags&1 != 0 || entries[file.Name] != nil { + return nil, ErrInvalidPackage + } + if file.UncompressedSize64 > uint64(game_package_service.MaxJSONSize) { + return nil, ErrLimitExceeded + } + stream, err := file.Open() + if ctx.Err() != nil { + if stream != nil { + _ = stream.Close() + } + return nil, ctx.Err() + } + if err != nil { + return nil, errors.Join(ErrInvalidPackage, err) + } + raw, readErr := io.ReadAll(io.LimitReader(&contextReader{ctx: ctx, reader: stream}, game_package_service.MaxJSONSize+1)) + closeErr := stream.Close() + if err := ctx.Err(); err != nil { + return nil, err + } + if readErr != nil || closeErr != nil { + return nil, errors.Join(ErrInvalidPackage, readErr, closeErr) + } + if int64(len(raw)) > game_package_service.MaxJSONSize { + return nil, ErrLimitExceeded + } + if uint64(len(raw)) != file.UncompressedSize64 { + return nil, ErrInvalidPackage + } + entries[file.Name] = raw + } + bundle := &Bundle{} + if err := decodeJSON(entries["manifest.json"], &bundle.Manifest); err != nil { + return nil, err + } + if bundle.Manifest.Version != Version { + return nil, ErrUnsupportedVersion + } + if err := decodeJSON(entries["results.json"], &bundle.Data); err != nil { + return nil, err + } + if err := validateManifest(bundle.Manifest, bundle.Data.Game.ID); err != nil { + return nil, err + } + if bundle.Manifest.Size != int64(len(entries["results.json"])) || bundle.Manifest.SHA256 != digest(entries["results.json"]) { + return nil, ErrInvalidPackage + } + if err := validateData(&bundle.Data); err != nil { + return nil, err + } + if err := ctx.Err(); err != nil { + return nil, err + } + return bundle, nil +} + +func digest(raw []byte) string { + hash := sha256.Sum256(raw) + return hex.EncodeToString(hash[:]) +} + +func decodeJSON(raw []byte, value any) error { + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(value); err != nil { + return errors.Join(ErrInvalidPackage, err) + } + var extra any + if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) { + return ErrInvalidPackage + } + return nil +} + +type contextWriter struct { + ctx context.Context + writer io.Writer +} + +func (w *contextWriter) Write(raw []byte) (int, error) { + if err := w.ctx.Err(); err != nil { + return 0, err + } + return w.writer.Write(raw) +} + +type contextReader struct { + ctx context.Context + reader io.Reader +} + +func (r *contextReader) Read(raw []byte) (int, error) { + if err := r.ctx.Err(); err != nil { + return 0, err + } + return r.reader.Read(raw) +} diff --git a/internal/services/game_results_package_service/codec_test.go b/internal/services/game_results_package_service/codec_test.go new file mode 100644 index 0000000..68f1628 --- /dev/null +++ b/internal/services/game_results_package_service/codec_test.go @@ -0,0 +1,417 @@ +package game_results_package_service + +import ( + "archive/zip" + "bytes" + "context" + "encoding/json" + "errors" + "io" + "os" + "reflect" + "strings" + "testing" + "time" + + "evening_detective_server/internal/services/game_package_service" +) + +func sampleData(format string) *Data { + now := time.Date(2026, 10, 12, 10, 0, 0, 0, time.UTC) + end := now.Add(time.Hour) + owner, local, zero := 20, 2, 0 + hash := bytes.Repeat([]byte{1}, 32) + sourceTeam := game_package_service.Team{ + ID: 10, + GameID: 1, + Name: "Source", + Status: "register", + Password: "printed-qr", + CreatorID: &owner, + CaptainEmail: "captain@example.test", + ParticipantsCount: 4, + ParticipationStatus: "pending", + CreatedAt: now, + } + sourceTicket := game_package_service.Ticket{ + ID: 30, + GameID: 1, + TokenHash: hash, + Status: "active", + CreatedAt: now, + } + team := Team{ + Team: sourceTeam, + SourceID: 10, + } + team.ParticipationStatus = "confirmed" + team.ParticipationSource = "first_action" + team.ParticipationAnsweredAt = &now + newTeam := team + newTeam.ID = 11 + newTeam.SourceID = 0 + newTeam.CreatorID = nil + newTeam.LocalCreatorID = &local + newTeam.Password = "local-qr" + removedTeam := sourceTeam + removedTeam.ID = 12 + ticket := Ticket{ + Ticket: sourceTicket, + SourceID: 30, + LocalUsedBy: &local, + } + ticket.Status = "used" + ticket.UsedAt = &now + newTicket := ticket + newTicket.ID = 31 + newTicket.SourceID = 0 + newTicket.TokenHash = bytes.Repeat([]byte{2}, 32) + removedTicket := sourceTicket + removedTicket.ID = 32 + removedTicket.TokenHash = bytes.Repeat([]byte{3}, 32) + material := Material{ + Name: "Clue", + Image: "files/clue.pdf", + FileType: "pdf", + } + return &Data{ + Source: Source{ + ScenarioID: 5, + ScenarioSHA256: strings.Repeat("a", 64), + Teams: []game_package_service.Team{sourceTeam, removedTeam}, + Tickets: []game_package_service.Ticket{sourceTicket, removedTicket}, + Materials: []Material{material}, + QuestionCodes: []string{"q", "q2"}, + }, + Game: Game{ + ID: 1, + Status: "finish", + Format: format, + StartedAt: &now, + EndedAt: &end, + }, + Teams: []Team{team, newTeam}, + Tickets: []Ticket{ticket, newTicket}, + DeletedTeamIDs: []int{12}, + DeletedTicketIDs: []int{32}, + Actions: []Action{ + {ID: 1, TeamID: 10, Code: "unknown", CreatedAt: now}, + {ID: 2, TeamID: 10, Code: "unknown", ViaMCP: true, CreatedAt: now}, + }, + Applications: []Application{ + {ID: 1, TeamID: 10, Material: material, Status: "done", CreatedAt: now}, + }, + Answers: []Answer{ + {TeamID: 10, QuestionCode: "q", Text: "", Score: &zero, CreatedAt: now, UpdatedAt: end}, + {TeamID: 11, QuestionCode: "q2", Text: "Answer", CreatedAt: now, UpdatedAt: now}, + }, + } +} + +func sampleManifest() Manifest { + return Manifest{ + SourceServer: "https://online.example", + GameID: 1, + SourcePackageID: strings.Repeat("b", 32), + SourceSHA256: strings.Repeat("c", 64), + } +} + +func packSample(t *testing.T, data *Data) []byte { + t.Helper() + var output bytes.Buffer + if err := Pack(context.Background(), sampleManifest(), data, &output); err != nil { + t.Fatal(err) + } + return output.Bytes() +} + +func TestPackRoundTripPreservesResults(t *testing.T) { + for _, format := range []string{"online", "offline"} { + t.Run(format, func(t *testing.T) { + data := sampleData(format) + before, _ := json.Marshal(data) + first := packSample(t, data) + bundle, err := Unpack(context.Background(), bytes.NewReader(first), int64(len(first))) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(bundle.Data, *data) { + t.Fatal("results changed") + } + after, _ := json.Marshal(data) + if !bytes.Equal(before, after) { + t.Fatal("input changed") + } + second := packSample(t, data) + next, err := Unpack(context.Background(), bytes.NewReader(second), int64(len(second))) + if err != nil { + t.Fatal(err) + } + if bundle.Manifest.PackageID == next.Manifest.PackageID { + t.Fatal("identity reused") + } + }) + } +} + +func TestPackRoundTripSupportsEmptyResults(t *testing.T) { + for _, format := range []string{"online", "offline"} { + t.Run(format, func(t *testing.T) { + data := sampleData(format) + data.Source.Teams = []game_package_service.Team{} + data.Source.Tickets = []game_package_service.Ticket{} + data.Source.Materials = []Material{} + data.Source.QuestionCodes = []string{} + data.Teams = []Team{} + data.Tickets = []Ticket{} + data.DeletedTeamIDs = []int{} + data.DeletedTicketIDs = []int{} + data.Actions = []Action{} + data.Applications = []Application{} + data.Answers = []Answer{} + raw := packSample(t, data) + if _, err := Unpack(context.Background(), bytes.NewReader(raw), int64(len(raw))); err != nil { + t.Fatal(err) + } + }) + } +} + +func TestPackRejectsInvalidRelations(t *testing.T) { + cases := []struct { + name string + change func(*Data) + }{ + {"unfinished", func(d *Data) { d.Game.Status = "pause" }}, + {"missing start", func(d *Data) { d.Game.StartedAt = nil }}, + {"end before start", func(d *Data) { end := d.Game.StartedAt.Add(-time.Second); d.Game.EndedAt = &end }}, + {"missing complete array", func(d *Data) { d.Actions = nil }}, + {"duplicate team", func(d *Data) { d.Teams = append(d.Teams, d.Teams[0]) }}, + {"source identity", func(d *Data) { d.Teams[0].SourceID = 0 }}, + {"local source user", func(d *Data) { d.Teams[1].CreatorID = d.Teams[0].CreatorID }}, + {"missing deletion", func(d *Data) { d.DeletedTeamIDs = []int{} }}, + {"invalid deletion", func(d *Data) { d.DeletedTicketIDs = []int{30} }}, + {"duplicate deletion", func(d *Data) { d.DeletedTeamIDs = []int{12, 12} }}, + {"changed hash", func(d *Data) { d.Tickets[0].TokenHash = bytes.Repeat([]byte{9}, 32) }}, + {"duplicate ticket", func(d *Data) { d.Tickets = append(d.Tickets, d.Tickets[0]) }}, + {"active with actor", func(d *Data) { d.Tickets[1].Status = "active"; d.Tickets[1].UsedAt = nil }}, + {"source actor", func(d *Data) { d.Tickets[0].UsedBy = d.Teams[0].CreatorID }}, + {"unknown action team", func(d *Data) { d.Actions[0].TeamID = 999 }}, + {"duplicate action", func(d *Data) { d.Actions[1].ID = d.Actions[0].ID }}, + {"reordered actions", func(d *Data) { d.Actions[0], d.Actions[1] = d.Actions[1], d.Actions[0] }}, + {"unknown material", func(d *Data) { d.Applications[0].Image = "files/other.pdf" }}, + {"local material path", func(d *Data) { d.Source.Materials[0].Image = "game-import-private.pdf" }}, + {"duplicate application", func(d *Data) { d.Applications = append(d.Applications, d.Applications[0]) }}, + {"invalid application state", func(d *Data) { d.Applications[0].Status = "invalid" }}, + {"unknown question", func(d *Data) { d.Answers[0].QuestionCode = "missing" }}, + {"duplicate answer", func(d *Data) { d.Answers = append(d.Answers, d.Answers[0]) }}, + {"updated before creation", func(d *Data) { d.Answers[0].UpdatedAt = d.Answers[0].CreatedAt.Add(-time.Second) }}, + } + for _, test := range cases { + t.Run(test.name, func(t *testing.T) { + data := sampleData("online") + test.change(data) + var output bytes.Buffer + if err := Pack(context.Background(), sampleManifest(), data, &output); !errors.Is(err, ErrInvalidPackage) { + t.Fatalf("expected invalid package, got %v", err) + } + if output.Len() != 0 { + t.Fatal("wrote invalid data") + } + }) + } +} + +type zipEntry struct { + name string + raw []byte +} + +func zipEntries(t *testing.T, raw []byte) []zipEntry { + t.Helper() + reader, err := zip.NewReader(bytes.NewReader(raw), int64(len(raw))) + if err != nil { + t.Fatal(err) + } + entries := []zipEntry{} + for _, file := range reader.File { + stream, err := file.Open() + if err != nil { + t.Fatal(err) + } + data, err := io.ReadAll(stream) + _ = stream.Close() + if err != nil { + t.Fatal(err) + } + entries = append(entries, zipEntry{file.Name, data}) + } + return entries +} + +func TestUnpackRejectsDamagedArchive(t *testing.T) { + cases := []struct { + name string + change func([]zipEntry) []zipEntry + want error + }{ + {"version", func(e []zipEntry) []zipEntry { + e[0].raw = bytes.Replace(e[0].raw, []byte(`"version":1`), []byte(`"version":2`), 1) + return e + }, ErrUnsupportedVersion}, + {"missing", func(e []zipEntry) []zipEntry { return e[:1] }, ErrInvalidPackage}, + {"extra", func(e []zipEntry) []zipEntry { return append(e, e[0]) }, ErrInvalidPackage}, + {"duplicate", func(e []zipEntry) []zipEntry { e[1] = e[0]; return e }, ErrInvalidPackage}, + {"path", func(e []zipEntry) []zipEntry { e[1].name = "../results.json"; return e }, ErrInvalidPackage}, + {"checksum", func(e []zipEntry) []zipEntry { + e[1].raw = bytes.Replace(e[1].raw, []byte("printed-qr"), []byte("changed-qr"), 1) + return e + }, ErrInvalidPackage}, + {"trailing JSON", func(e []zipEntry) []zipEntry { e[0].raw = append(e[0].raw, []byte("{}")...); return e }, ErrInvalidPackage}, + {"unknown field", func(e []zipEntry) []zipEntry { + e[0].raw = bytes.Replace(e[0].raw, []byte(`"version":1`), []byte(`"version":1,"private":true`), 1) + return e + }, ErrInvalidPackage}, + {"oversized JSON", func(e []zipEntry) []zipEntry { + e[1].raw = bytes.Repeat([]byte{' '}, int(game_package_service.MaxJSONSize)+1) + return e + }, ErrLimitExceeded}, + } + for _, test := range cases { + t.Run(test.name, func(t *testing.T) { + entries := test.change(zipEntries(t, packSample(t, sampleData("online")))) + var output bytes.Buffer + writer := zip.NewWriter(&output) + for _, entry := range entries { + file, err := writer.Create(entry.name) + if err != nil { + t.Fatal(err) + } + if _, err := file.Write(entry.raw); err != nil { + t.Fatal(err) + } + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + if _, err := Unpack(context.Background(), bytes.NewReader(output.Bytes()), int64(output.Len())); !errors.Is(err, test.want) { + t.Fatalf("got %v", err) + } + }) + } +} + +func TestUnpackRejectsUnsafeEntries(t *testing.T) { + for _, name := range []string{"symlink", "encrypted", "CRC"} { + t.Run(name, func(t *testing.T) { + entries := zipEntries(t, packSample(t, sampleData("online"))) + var output bytes.Buffer + writer := zip.NewWriter(&output) + for _, entry := range entries { + header := &zip.FileHeader{Name: entry.name, Method: zip.Store} + if entry.name == "results.json" { + if name == "symlink" { + header.SetMode(os.ModeSymlink | 0600) + } + if name == "encrypted" { + header.Flags |= 1 + } + } + file, err := writer.CreateHeader(header) + if err != nil { + t.Fatal(err) + } + if _, err := file.Write(entry.raw); err != nil { + t.Fatal(err) + } + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + raw := bytes.Clone(output.Bytes()) + if name == "CRC" { + reader, err := zip.NewReader(bytes.NewReader(raw), int64(len(raw))) + if err != nil { + t.Fatal(err) + } + offset, err := reader.File[1].DataOffset() + if err != nil { + t.Fatal(err) + } + raw[offset] ^= 1 + } + if _, err := Unpack(context.Background(), bytes.NewReader(raw), int64(len(raw))); !errors.Is(err, ErrInvalidPackage) { + t.Fatalf("got %v", err) + } + }) + } +} + +type failingWriter struct{} + +func (failingWriter) Write([]byte) (int, error) { return 0, io.ErrClosedPipe } + +type cancelReaderAt struct{ cancel context.CancelFunc } + +func (r cancelReaderAt) ReadAt([]byte, int64) (int, error) { r.cancel(); return 0, io.EOF } + +type cancelWriter struct{ cancel context.CancelFunc } + +func (w cancelWriter) Write(raw []byte) (int, error) { w.cancel(); return len(raw), nil } + +func TestCodecLimitsCancellationAndWriterErrors(t *testing.T) { + for _, test := range []struct { + name string + check func() error + want error + }{ + {"writer", func() error { + return Pack(context.Background(), sampleManifest(), sampleData("online"), failingWriter{}) + }, io.ErrClosedPipe}, + {"pack cancellation", func() error { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + return Pack(ctx, sampleManifest(), sampleData("online"), io.Discard) + }, context.Canceled}, + {"unpack cancellation", func() error { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + _, err := Unpack(ctx, bytes.NewReader(nil), 0) + return err + }, context.Canceled}, + {"cancellation during directory read", func() error { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + _, err := Unpack(ctx, cancelReaderAt{cancel: cancel}, 1024) + return err + }, context.Canceled}, + {"cancellation during write", func() error { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + return Pack(ctx, sampleManifest(), sampleData("online"), cancelWriter{cancel: cancel}) + }, context.Canceled}, + {"archive limit", func() error { + _, err := Unpack(context.Background(), bytes.NewReader(nil), game_package_service.MaxArchiveSize+1) + return err + }, ErrLimitExceeded}, + {"pack JSON limit", func() error { + data := sampleData("online") + data.Answers[0].Text = strings.Repeat("x", int(game_package_service.MaxJSONSize)) + return Pack(context.Background(), sampleManifest(), data, io.Discard) + }, ErrLimitExceeded}, + {"manifest JSON limit", func() error { + manifest := sampleManifest() + manifest.SourceServer += "/" + strings.Repeat("x", int(game_package_service.MaxJSONSize)) + return Pack(context.Background(), manifest, sampleData("online"), io.Discard) + }, ErrLimitExceeded}, + {"empty archive", func() error { _, err := Unpack(context.Background(), bytes.NewReader(nil), 0); return err }, ErrInvalidPackage}, + {"invalid zip", func() error { _, err := Unpack(context.Background(), bytes.NewReader([]byte("bad")), 3); return err }, ErrInvalidPackage}, + } { + t.Run(test.name, func(t *testing.T) { + if err := test.check(); !errors.Is(err, test.want) { + t.Fatalf("got %v", err) + } + }) + } +} diff --git a/internal/services/game_results_package_service/flow_test.go b/internal/services/game_results_package_service/flow_test.go new file mode 100644 index 0000000..464d9b5 --- /dev/null +++ b/internal/services/game_results_package_service/flow_test.go @@ -0,0 +1,143 @@ +package game_results_package_service + +import ( + "bytes" + "context" + "errors" + "io" + "os" + "path/filepath" + "strconv" + "sync" + "testing" + + "evening_detective_server/internal/repos/game_results_repo" + "evening_detective_server/internal/repos/team_answers_repo" + + "github.com/jackc/pgx/v5/pgxpool" +) + +// TestVenueFlowResults uses the disposable, migrated fixtures owned by tests/venue/flow.py. +func TestVenueFlowResults(t *testing.T) { + for _, name := range []string{"finished imported game"} { + t.Run(name, func(t *testing.T) { + idText := os.Getenv("ED029_RESULTS_GAME_ID") + if idText == "" { + t.Skip("run python3 tests/venue/flow.py --results") + } + gameID, err := strconv.Atoi(idText) + if err != nil { + t.Fatal(err) + } + ctx := context.Background() + pool, err := pgxpool.New(ctx, os.Getenv("TEST_DATABASE_URL")) + if err != nil { + t.Fatal(err) + } + defer pool.Close() + service := NewGameResultsPackageService(game_results_repo.NewGameResultsRepo(pool), "venue") + prepare := func() ([]byte, *Bundle) { + t.Helper() + var output bytes.Buffer + if err := service.Prepare(ctx, gameID, &output); err != nil { + t.Fatal(err) + } + bundle, err := Unpack(ctx, bytes.NewReader(output.Bytes()), int64(output.Len())) + if err != nil { + t.Fatal(err) + } + return bytes.Clone(output.Bytes()), bundle + } + raw, first := prepare() + if first.Manifest.GameID != gameID || first.Manifest.SourcePackageID != os.Getenv("ED029_RESULTS_PACKAGE_ID") { + t.Fatal("source transfer lost") + } + if len(first.Data.Actions) < 2 || len(first.Data.Applications) == 0 || len(first.Data.Teams) <= len(first.Data.Source.Teams) || len(first.Data.Tickets) <= len(first.Data.Source.Tickets) { + t.Fatal("flow data missing") + } + if first.Data.Applications[0].Status != "done" { + t.Fatal("clue status missing") + } + if bytes.Contains(raw, []byte("game-import-")) { + t.Fatal("local material reference escaped") + } + var scored Answer + for _, answer := range first.Data.Answers { + if answer.Score != nil && *answer.Score == 5 { + scored = answer + break + } + } + if scored.TeamID == 0 { + t.Fatal("score missing") + } + answers := team_answers_repo.NewTeamAnswersRepo(pool) + zero := 0 + if err := answers.SetAnswerScore(ctx, scored.TeamID, scored.QuestionCode, &zero); err != nil { + t.Fatal(err) + } + defer func() { + if err := answers.SetAnswerScore(ctx, scored.TeamID, scored.QuestionCode, scored.Score); err != nil { + t.Error(err) + } + }() + _, second := prepare() + if first.Manifest.PackageID == second.Manifest.PackageID { + t.Fatal("package identity reused") + } + found := false + for _, answer := range second.Data.Answers { + if answer.TeamID == scored.TeamID && answer.QuestionCode == scored.QuestionCode && answer.Score != nil && *answer.Score == 0 { + found = true + } + } + if !found { + t.Fatal("new grading missing") + } + previous, err := Unpack(ctx, bytes.NewReader(raw), int64(len(raw))) + if err != nil { + t.Fatal(err) + } + if previous.Manifest.SHA256 != first.Manifest.SHA256 { + t.Fatal("saved snapshot changed") + } + // Concurrent grading must never make a snapshot invalid or modify a saved package. + var workers sync.WaitGroup + failures := make(chan error, 1) + workers.Add(1) + defer workers.Wait() + go func() { + defer workers.Done() + for i := 0; i < 10; i++ { + score := i % 2 + if err := answers.SetAnswerScore(ctx, scored.TeamID, scored.QuestionCode, &score); err != nil { + failures <- err + return + } + } + }() + for i := 0; i < 10; i++ { + prepare() + } + workers.Wait() + select { + case err := <-failures: + t.Fatal(err) + default: + } + if err := service.Prepare(ctx, -1, io.Discard); !errors.Is(err, ErrNotAvailable) { + t.Fatalf("invalid ID: %v", err) + } + if err := service.Prepare(ctx, 2147483647, io.Discard); !errors.Is(err, ErrNotAvailable) { + t.Fatalf("missing game: %v", err) + } + directory := os.Getenv("ED029_RESULTS_DIR") + if directory == "" { + t.Fatal("temporary results directory missing") + } + if err := os.WriteFile(filepath.Join(directory, "results-"+idText+".zip"), raw, 0600); err != nil { + t.Fatal(err) + } + }) + } +} diff --git a/internal/services/game_results_package_service/model.go b/internal/services/game_results_package_service/model.go new file mode 100644 index 0000000..3c0985f --- /dev/null +++ b/internal/services/game_results_package_service/model.go @@ -0,0 +1,113 @@ +// Package game_results_package_service creates and validates portable final results. +package game_results_package_service + +import ( + "errors" + "time" + + "evening_detective_server/internal/services/game_package_service" +) + +const Version = 1 + +var ( + ErrInvalidPackage = errors.New("некорректный пакет результатов") + ErrUnsupportedVersion = errors.New("неподдерживаемая версия пакета результатов") + ErrLimitExceeded = errors.New("превышен лимит пакета результатов") + ErrNotAvailable = errors.New("результаты доступны только для завершённой импортированной игры на площадке") + ErrScenarioChanged = errors.New("сценарий или материалы изменены после импорта") +) + +// Manifest binds the results to the exact original transfer, not merely the game ID. +type Manifest struct { + Version int `json:"version"` + PackageID string `json:"package_id"` + CreatedAt time.Time `json:"created_at"` + SourceServer string `json:"source_server"` + GameID int `json:"game_id"` + SourcePackageID string `json:"source_package_id"` + SourceSHA256 string `json:"source_sha256"` + Size int64 `json:"size"` + SHA256 string `json:"sha256"` +} + +// Source retains original identities and references without carrying the scenario or media. +type Source struct { + ScenarioID int `json:"scenario_id"` + ScenarioSHA256 string `json:"scenario_sha256"` + Teams []game_package_service.Team `json:"teams"` + Tickets []game_package_service.Ticket `json:"tickets"` + Materials []Material `json:"materials"` + QuestionCodes []string `json:"question_codes"` +} + +type Material struct { + Name string `json:"name"` + Image string `json:"image"` + FileType string `json:"file_type"` +} + +type Game struct { + ID int `json:"id"` + Status string `json:"status"` + Format string `json:"format"` + StartedAt *time.Time `json:"started_at"` + EndedAt *time.Time `json:"ended_at"` +} + +// SourceID is zero for venue-created rows; their ID is only a local reference. +type Team struct { + game_package_service.Team + SourceID int `json:"source_id"` + LocalCreatorID *int `json:"local_creator_id,omitempty"` +} + +type Ticket struct { + game_package_service.Ticket + SourceID int `json:"source_id"` + LocalUsedBy *int `json:"local_used_by,omitempty"` +} + +type Action struct { + ID int `json:"id"` + TeamID int `json:"team_id"` + Code string `json:"code"` + ViaMCP bool `json:"via_mcp"` + CreatedAt time.Time `json:"created_at"` +} + +// Application.Image is a portable path in the original game package. +type Application struct { + ID int `json:"id"` + TeamID int `json:"team_id"` + Material + Status string `json:"status"` + CreatedAt time.Time `json:"created_at"` +} + +type Answer struct { + TeamID int `json:"team_id"` + QuestionCode string `json:"question_code"` + Text string `json:"answer"` + Score *int `json:"score"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// Data is the version 1 results.json contract. Arrays are complete snapshots. +type Data struct { + Source Source `json:"source"` + Game Game `json:"game"` + Teams []Team `json:"teams"` + Tickets []Ticket `json:"tickets"` + DeletedTeamIDs []int `json:"deleted_team_ids"` + DeletedTicketIDs []int `json:"deleted_ticket_ids"` + Actions []Action `json:"actions"` + Applications []Application `json:"applications"` + Answers []Answer `json:"answers"` +} + +type Bundle struct { + Manifest Manifest + Data Data +} diff --git a/internal/services/game_results_package_service/service.go b/internal/services/game_results_package_service/service.go new file mode 100644 index 0000000..3c138bc --- /dev/null +++ b/internal/services/game_results_package_service/service.go @@ -0,0 +1,47 @@ +package game_results_package_service + +import ( + "context" + "errors" + "io" + + "evening_detective_server/internal/repos/game_results_repo" +) + +type GameResultsPackageService struct { + repo *game_results_repo.GameResultsRepo + mode string +} + +func NewGameResultsPackageService(repo *game_results_repo.GameResultsRepo, mode string) *GameResultsPackageService { + return &GameResultsPackageService{repo: repo, mode: mode} +} + +// Prepare reads a consistent snapshot and writes a fresh package without changing the game. +// The caller must discard output on any error, including writer or cancellation errors. +func (s *GameResultsPackageService) Prepare(ctx context.Context, gameID int, output io.Writer) error { + if err := ctx.Err(); err != nil { + return err + } + if s.mode != "venue" || gameID <= 0 || s.repo == nil { + return ErrNotAvailable + } + snapshot, err := s.repo.Capture(ctx, gameID) + if errors.Is(err, game_results_repo.ErrNotImported) { + return ErrNotAvailable + } + if err != nil { + return err + } + data, err := prepareData(snapshot) + if err != nil { + return err + } + manifest := Manifest{ + SourceServer: snapshot.SourceServer, + GameID: gameID, + SourcePackageID: snapshot.PackageID, + SourceSHA256: snapshot.SHA256, + } + return Pack(ctx, manifest, data, output) +} diff --git a/internal/services/game_results_package_service/snapshot.go b/internal/services/game_results_package_service/snapshot.go new file mode 100644 index 0000000..9b436e5 --- /dev/null +++ b/internal/services/game_results_package_service/snapshot.go @@ -0,0 +1,165 @@ +package game_results_package_service + +import ( + "encoding/json" + "path" + "reflect" + + "evening_detective_server/internal/modules/cleaner" + "evening_detective_server/internal/modules/file_storage" + "evening_detective_server/internal/repos/game_results_repo" + "evening_detective_server/internal/services/game_package_service" +) + +// prepareData restores portable references and keeps local actors out of source IDs. +func prepareData(snapshot *game_results_repo.Snapshot) (*Data, error) { + var original game_package_service.Data + var manifest game_package_service.Manifest + var data Data + if err := json.Unmarshal(snapshot.OriginalData, &original); err != nil { + return nil, err + } + if err := json.Unmarshal(snapshot.Manifest, &manifest); err != nil { + return nil, err + } + if err := json.Unmarshal(snapshot.Data, &data); err != nil { + return nil, err + } + if data.Game.Status != "finish" { + return nil, ErrNotAvailable + } + if snapshot.ScenarioID != snapshot.ImportedScenarioID || original.Game.ID != data.Game.ID || original.Game.Format != data.Game.Format || original.Scenario.Story == nil { + return nil, ErrScenarioChanged + } + sourceStory, err := json.Marshal(original.Scenario.Story) + if err != nil { + return nil, err + } + data.Source = Source{ + ScenarioID: original.Scenario.ID, + ScenarioSHA256: digest(sourceStory), + Teams: original.Teams, + Tickets: original.Tickets, + Materials: []Material{}, + QuestionCodes: []string{}, + } + textCleaner := cleaner.NewCleaner() + for _, place := range original.Scenario.Story.Places { + if place == nil { + return nil, ErrScenarioChanged + } + for _, material := range place.Applications { + if material == nil { + return nil, ErrScenarioChanged + } + portable := Material{ + Name: textCleaner.ClearText(material.Name), + Image: material.Image, + FileType: material.FileType, + } + if portable.FileType == "" { + portable.FileType = file_storage.FileType(portable.Image) + } + if !containsMaterial(data.Source.Materials, portable) { + data.Source.Materials = append(data.Source.Materials, portable) + } + } + } + for _, question := range original.Scenario.Story.Questions { + if question == nil { + return nil, ErrScenarioChanged + } + data.Source.QuestionCodes = append(data.Source.QuestionCodes, question.Code) + } + names := map[string]string{} + for _, file := range manifest.Files { + if file.Path != "game.json" { + names[file.Path] = "game-import-" + snapshot.ImportID + "-" + path.Base(file.Path) + } + } + if err := game_package_service.RewriteMedia(&original, names); err != nil { + return nil, err + } + expected, err := json.Marshal(original.Scenario.Story) + if err != nil { + return nil, err + } + var currentJSON, expectedJSON any + if err := json.Unmarshal(snapshot.Story, ¤tJSON); err != nil { + return nil, err + } + if err := json.Unmarshal(expected, &expectedJSON); err != nil { + return nil, err + } + if !reflect.DeepEqual(currentJSON, expectedJSON) { + return nil, ErrScenarioChanged + } + for i := range data.Applications { + application := &data.Applications[i] + found := false + for _, material := range data.Source.Materials { + if application.Name == material.Name && application.Image == names[material.Image] && application.FileType == material.FileType { + application.Material = material + found = true + break + } + } + if !found { + return nil, ErrScenarioChanged + } + } + sourceTeams := map[int]game_package_service.Team{} + sourceTickets := map[int]game_package_service.Ticket{} + for _, team := range data.Source.Teams { + sourceTeams[team.ID] = team + } + for _, ticket := range data.Source.Tickets { + sourceTickets[ticket.ID] = ticket + } + data.DeletedTeamIDs = []int{} + data.DeletedTicketIDs = []int{} + for i := range data.Teams { + team := &data.Teams[i] + team.LocalCreatorID = team.CreatorID + team.CreatorID = nil + team.RegistrationDeliveryID = nil + if source, ok := sourceTeams[team.ID]; ok { + team.SourceID = source.ID + team.CreatorID = source.CreatorID + team.RegistrationDeliveryID = source.RegistrationDeliveryID + delete(sourceTeams, team.ID) + } + } + for _, team := range data.Source.Teams { + if _, ok := sourceTeams[team.ID]; ok { + data.DeletedTeamIDs = append(data.DeletedTeamIDs, team.ID) + } + } + for i := range data.Tickets { + ticket := &data.Tickets[i] + ticket.LocalUsedBy = ticket.UsedBy + ticket.UsedBy = nil + if source, ok := sourceTickets[ticket.ID]; ok { + ticket.SourceID = source.ID + if source.Status == "used" && ticket.LocalUsedBy == nil { + ticket.UsedBy = source.UsedBy + } + delete(sourceTickets, ticket.ID) + } + } + for _, ticket := range data.Source.Tickets { + if _, ok := sourceTickets[ticket.ID]; ok { + data.DeletedTicketIDs = append(data.DeletedTicketIDs, ticket.ID) + } + } + return &data, nil +} + +func containsMaterial(materials []Material, candidate Material) bool { + for _, material := range materials { + if material == candidate { + return true + } + } + return false +} diff --git a/internal/services/game_results_package_service/snapshot_test.go b/internal/services/game_results_package_service/snapshot_test.go new file mode 100644 index 0000000..7b335e6 --- /dev/null +++ b/internal/services/game_results_package_service/snapshot_test.go @@ -0,0 +1,186 @@ +package game_results_package_service + +import ( + "context" + "encoding/json" + "errors" + "io" + "testing" + + "evening_detective_server/internal/modules/storytelling" + "evening_detective_server/internal/repos/game_results_repo" + "evening_detective_server/internal/services/game_package_service" +) + +func storedSample(t *testing.T) *game_results_repo.Snapshot { + t.Helper() + data := sampleData("online") + original := game_package_service.Data{ + Game: game_package_service.Game{ + ID: data.Game.ID, + Name: "Game", + StartAt: *data.Game.StartedAt, + Status: "draft", + Format: "online", + ScenarioID: data.Source.ScenarioID, + }, + Scenario: game_package_service.Scenario{ + ID: data.Source.ScenarioID, + Name: "Story", + Story: &storytelling.Story{ + Places: []*storytelling.Place{{Code: "p", Applications: []*storytelling.Application{{Name: "Clue", Image: "files/clue.pdf"}}}}, + Questions: []*storytelling.Question{{Code: "q"}, {Code: "q2"}}, + }, + }, + Teams: data.Source.Teams, + Tickets: data.Source.Tickets, + Playlists: []game_package_service.Playlist{}, + Map: []game_package_service.MapItem{}, + } + originalRaw, err := json.Marshal(original) + if err != nil { + t.Fatal(err) + } + manifestRaw, err := json.Marshal(game_package_service.Manifest{ + Files: []game_package_service.FileInfo{{Path: "files/clue.pdf"}}, + }) + if err != nil { + t.Fatal(err) + } + if err := game_package_service.RewriteMedia(&original, map[string]string{"files/clue.pdf": "game-import-attempt-clue.pdf"}); err != nil { + t.Fatal(err) + } + currentStory, err := json.Marshal(original.Scenario.Story) + if err != nil { + t.Fatal(err) + } + data.Source = Source{} + data.DeletedTeamIDs = nil + data.DeletedTicketIDs = nil + for i := range data.Teams { + data.Teams[i].CreatorID = data.Teams[i].LocalCreatorID + data.Teams[i].SourceID = 0 + } + for i := range data.Tickets { + data.Tickets[i].UsedBy = data.Tickets[i].LocalUsedBy + data.Tickets[i].SourceID = 0 + } + data.Applications[0].Image = "game-import-attempt-clue.pdf" + raw, err := json.Marshal(data) + if err != nil { + t.Fatal(err) + } + return &game_results_repo.Snapshot{ + ScenarioID: 50, + ImportedScenarioID: 50, + ImportID: "attempt", + OriginalData: originalRaw, + Manifest: manifestRaw, + Story: currentStory, + Data: raw, + } +} + +func TestPrepareDataRestoresSourceReferences(t *testing.T) { + for _, name := range []string{"restores portable data"} { + t.Run(name, func(t *testing.T) { + data, err := prepareData(storedSample(t)) + if err != nil { + t.Fatal(err) + } + if err := validateData(data); err != nil { + t.Fatal(err) + } + if data.Applications[0].Image != "files/clue.pdf" || data.Teams[0].CreatorID == nil || *data.Teams[0].CreatorID != 20 || data.Teams[1].CreatorID != nil || data.Tickets[0].UsedBy != nil || data.Tickets[0].LocalUsedBy == nil { + t.Fatal("source/local references mixed") + } + if len(data.DeletedTeamIDs) != 1 || data.DeletedTeamIDs[0] != 12 || len(data.DeletedTicketIDs) != 1 || data.DeletedTicketIDs[0] != 32 { + t.Fatal("deletions lost") + } + }) + } +} + +func TestPrepareDataPreservesPreviouslyUsedTicketActor(t *testing.T) { + for _, name := range []string{"source actor remains in source namespace"} { + t.Run(name, func(t *testing.T) { + snapshot := storedSample(t) + var original game_package_service.Data + var current Data + if err := json.Unmarshal(snapshot.OriginalData, &original); err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(snapshot.Data, ¤t); err != nil { + t.Fatal(err) + } + actor := 99 + original.Tickets[0].Status = "used" + original.Tickets[0].UsedAt = current.Tickets[0].UsedAt + original.Tickets[0].UsedBy = &actor + current.Tickets[0].UsedBy = nil + current.Tickets[0].LocalUsedBy = nil + var err error + snapshot.OriginalData, err = json.Marshal(original) + if err != nil { + t.Fatal(err) + } + snapshot.Data, err = json.Marshal(current) + if err != nil { + t.Fatal(err) + } + result, err := prepareData(snapshot) + if err != nil { + t.Fatal(err) + } + if err := validateData(result); err != nil { + t.Fatal(err) + } + if result.Tickets[0].UsedBy == nil || *result.Tickets[0].UsedBy != actor || result.Tickets[0].LocalUsedBy != nil { + t.Fatal("source actor lost or mapped locally") + } + }) + } +} + +func TestPrepareDataRejectsUnavailableOrChangedGame(t *testing.T) { + cases := []struct { + name string + change func(*game_results_repo.Snapshot) + want error + }{ + {"unfinished", func(s *game_results_repo.Snapshot) { + var data Data + _ = json.Unmarshal(s.Data, &data) + data.Game.Status = "pause" + s.Data, _ = json.Marshal(data) + }, ErrNotAvailable}, + {"scenario edits", func(s *game_results_repo.Snapshot) { s.Story = []byte(`{"places":[]}`) }, ErrScenarioChanged}, + {"different scenario", func(s *game_results_repo.Snapshot) { s.ScenarioID++ }, ErrScenarioChanged}, + {"unknown material", func(s *game_results_repo.Snapshot) { + var data Data + _ = json.Unmarshal(s.Data, &data) + data.Applications[0].Image = "new-local.pdf" + s.Data, _ = json.Marshal(data) + }, ErrScenarioChanged}, + } + for _, test := range cases { + t.Run(test.name, func(t *testing.T) { + snapshot := storedSample(t) + test.change(snapshot) + if _, err := prepareData(snapshot); !errors.Is(err, test.want) { + t.Fatalf("got %v", err) + } + }) + } +} + +func TestPrepareRejectsWrongEnvironment(t *testing.T) { + for _, mode := range []string{"prod", "", "unknown", "venue"} { + t.Run(mode, func(t *testing.T) { + service := NewGameResultsPackageService(nil, mode) + if err := service.Prepare(context.Background(), 1, io.Discard); !errors.Is(err, ErrNotAvailable) { + t.Fatalf("got %v", err) + } + }) + } +} diff --git a/internal/services/game_results_package_service/validation.go b/internal/services/game_results_package_service/validation.go new file mode 100644 index 0000000..59a58ca --- /dev/null +++ b/internal/services/game_results_package_service/validation.go @@ -0,0 +1,195 @@ +package game_results_package_service + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "net/url" + "path" + "strings" + + "evening_detective_server/internal/services/game_package_service" +) + +func validHex(value string, size int) bool { + raw, err := hex.DecodeString(value) + return err == nil && len(raw) == size && value == strings.ToLower(value) +} + +func validateManifest(m Manifest, gameID int) error { + server, err := url.Parse(m.SourceServer) + if m.Version != Version { + return ErrUnsupportedVersion + } + if err != nil || (server.Scheme != "http" && server.Scheme != "https") || server.Hostname() == "" || server.User != nil || server.RawQuery != "" || server.Fragment != "" || + m.GameID <= 0 || m.GameID != gameID || m.CreatedAt.IsZero() || !validHex(m.PackageID, 16) || !validHex(m.SourcePackageID, 16) || + !validHex(m.SourceSHA256, sha256.Size) || !validHex(m.SHA256, sha256.Size) || m.Size <= 0 { + return ErrInvalidPackage + } + if m.Size > game_package_service.MaxJSONSize { + return ErrLimitExceeded + } + return nil +} + +func validUser(id *int) bool { return id == nil || *id > 0 } + +func validTeam(team game_package_service.Team, gameID int) bool { + return team.ID > 0 && team.GameID == gameID && strings.TrimSpace(team.Name) != "" && team.Password != "" && team.Status == "register" && + team.ParticipantsCount >= 0 && validUser(team.CreatorID) && !team.CreatedAt.IsZero() && + (team.ParticipationStatus == "pending" || team.ParticipationStatus == "confirmed" || team.ParticipationStatus == "cancelled") +} + +func validTicket(ticket game_package_service.Ticket, gameID int) bool { + return ticket.ID > 0 && ticket.GameID == gameID && len(ticket.TokenHash) == sha256.Size && validUser(ticket.UsedBy) && !ticket.CreatedAt.IsZero() && + ((ticket.Status == "active" && ticket.UsedAt == nil && ticket.UsedBy == nil) || + (ticket.Status == "used" && ticket.UsedAt != nil && !ticket.UsedAt.IsZero())) +} + +func sameUser(a, b *int) bool { return (a == nil && b == nil) || (a != nil && b != nil && *a == *b) } +func sameDelivery(a, b *string) bool { + return (a == nil && b == nil) || (a != nil && b != nil && *a == *b) +} + +func validateData(data *Data) error { + if data == nil { + return ErrInvalidPackage + } + game := data.Game + if game.ID <= 0 || game.Status != "finish" || (game.Format != "online" && game.Format != "offline") || + game.StartedAt == nil || game.EndedAt == nil || game.StartedAt.IsZero() || game.EndedAt.IsZero() || game.EndedAt.Before(*game.StartedAt) || + data.Source.ScenarioID <= 0 || !validHex(data.Source.ScenarioSHA256, sha256.Size) { + return ErrInvalidPackage + } + // Complete arrays distinguish a deletion from an omitted or partial snapshot. + if data.Source.Teams == nil || data.Source.Tickets == nil || data.Source.Materials == nil || data.Source.QuestionCodes == nil || + data.Teams == nil || data.Tickets == nil || data.DeletedTeamIDs == nil || data.DeletedTicketIDs == nil || data.Actions == nil || data.Applications == nil || data.Answers == nil { + return ErrInvalidPackage + } + sourceTeams := map[int]game_package_service.Team{} + sourceTickets := map[int]game_package_service.Ticket{} + hashes := map[string]bool{} + for _, team := range data.Source.Teams { + if !validTeam(team, game.ID) || sourceTeams[team.ID].ID != 0 { + return ErrInvalidPackage + } + sourceTeams[team.ID] = team + } + for _, ticket := range data.Source.Tickets { + if !validTicket(ticket, game.ID) || sourceTickets[ticket.ID].ID != 0 || hashes[string(ticket.TokenHash)] { + return ErrInvalidPackage + } + sourceTickets[ticket.ID] = ticket + hashes[string(ticket.TokenHash)] = true + } + teams := map[int]bool{} + deliveries := map[string]bool{} + for _, team := range data.Teams { + source, exists := sourceTeams[team.ID] + if !validTeam(team.Team, game.ID) || teams[team.ID] || !validUser(team.LocalCreatorID) || + (exists && (team.SourceID != team.ID || !sameUser(team.CreatorID, source.CreatorID) || !sameDelivery(team.RegistrationDeliveryID, source.RegistrationDeliveryID))) || + (!exists && (team.SourceID != 0 || team.CreatorID != nil || team.RegistrationDeliveryID != nil)) { + return ErrInvalidPackage + } + if team.RegistrationDeliveryID != nil { + if deliveries[*team.RegistrationDeliveryID] { + return ErrInvalidPackage + } + deliveries[*team.RegistrationDeliveryID] = true + } + teams[team.ID] = true + } + tickets := map[int]bool{} + hashes = map[string]bool{} + for _, ticket := range data.Tickets { + source, exists := sourceTickets[ticket.ID] + if !validTicket(ticket.Ticket, game.ID) || tickets[ticket.ID] || hashes[string(ticket.TokenHash)] || !validUser(ticket.LocalUsedBy) || + (exists && (ticket.SourceID != ticket.ID || !bytes.Equal(ticket.TokenHash, source.TokenHash))) || + (!exists && (ticket.SourceID != 0 || ticket.UsedBy != nil)) || + (ticket.Status == "active" && ticket.LocalUsedBy != nil) || + (ticket.UsedBy != nil && (ticket.LocalUsedBy != nil || source.Status != "used" || !sameUser(ticket.UsedBy, source.UsedBy))) { + return ErrInvalidPackage + } + tickets[ticket.ID] = true + hashes[string(ticket.TokenHash)] = true + } + if !validDeletedTeams(data.DeletedTeamIDs, sourceTeams, teams) || !validDeletedTickets(data.DeletedTicketIDs, sourceTickets, tickets) { + return ErrInvalidPackage + } + materials := map[Material]bool{} + for _, material := range data.Source.Materials { + if materials[material] || (material.Image != "" && (!strings.HasPrefix(material.Image, "files/") || path.Clean(material.Image) != material.Image || strings.Count(material.Image, "/") != 1 || strings.ContainsAny(material.Image, "\\:"))) { + return ErrInvalidPackage + } + materials[material] = true + } + questions := map[string]bool{} + for _, code := range data.Source.QuestionCodes { + if code == "" || questions[code] { + return ErrInvalidPackage + } + questions[code] = true + } + actions := map[int]bool{} + previous := map[int]Action{} + for _, action := range data.Actions { + prev := previous[action.TeamID] + if action.ID <= 0 || actions[action.ID] || !teams[action.TeamID] || action.CreatedAt.IsZero() || + (prev.ID != 0 && (action.CreatedAt.Before(prev.CreatedAt) || (action.CreatedAt.Equal(prev.CreatedAt) && action.ID <= prev.ID))) { + return ErrInvalidPackage + } + actions[action.ID] = true + previous[action.TeamID] = action + } + applications := map[int]bool{} + for _, application := range data.Applications { + if application.ID <= 0 || applications[application.ID] || !teams[application.TeamID] || !materials[application.Material] || application.CreatedAt.IsZero() || + (application.Status != "need" && application.Status != "done") { + return ErrInvalidPackage + } + applications[application.ID] = true + } + answers := map[int]map[string]bool{} + for _, answer := range data.Answers { + if answers[answer.TeamID] == nil { + answers[answer.TeamID] = map[string]bool{} + } + if !teams[answer.TeamID] || !questions[answer.QuestionCode] || answers[answer.TeamID][answer.QuestionCode] || answer.CreatedAt.IsZero() || answer.UpdatedAt.IsZero() || answer.UpdatedAt.Before(answer.CreatedAt) { + return ErrInvalidPackage + } + answers[answer.TeamID][answer.QuestionCode] = true + } + return nil +} + +func validDeletedTeams(ids []int, source map[int]game_package_service.Team, current map[int]bool) bool { + seen := map[int]bool{} + for _, id := range ids { + if source[id].ID == 0 || current[id] || seen[id] { + return false + } + seen[id] = true + } + for id := range source { + if !current[id] && !seen[id] { + return false + } + } + return true +} + +func validDeletedTickets(ids []int, source map[int]game_package_service.Ticket, current map[int]bool) bool { + seen := map[int]bool{} + for _, id := range ids { + if source[id].ID == 0 || current[id] || seen[id] { + return false + } + seen[id] = true + } + for id := range source { + if !current[id] && !seen[id] { + return false + } + } + return true +}