generated from VLADIMIR/template
up
This commit is contained in:
@@ -346,3 +346,93 @@ RustFS и создать `<marker>.continue` в течение минуты. Buc
|
|||||||
|
|
||||||
Интерфейс передачи остаётся этапом 7; полный билетный и игровой прогон — этапом 8.
|
Интерфейс передачи остаётся этапом 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. До завершения возврата
|
||||||
|
используются только тестовые игры.
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user