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