3471d40576
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 16s
CI / ui (pull_request) Successful in 1m4s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m27s
The finished-game export now works identically on every platform: the client mints a signed relative URL (game.export_url) carrying its date locale, IANA time zone and localized non-play labels, resolves it against its own origin and hands it to the platform's native download — Telegram downloadFile, VKWebAppDownloadFile, or a plain browser anchor (also the desktop VK iframe). Both artifacts ride the route: the .gcg text (no more clipboard mode, except the legacy pre-8.0 Telegram fallback) and the PNG of the final position. The PNG is rasterized by a new internal 'renderer' sidecar (node:22-slim + skia-canvas + baked Liberation/Noto Color Emoji fonts) executing the SAME ui/src/lib/gameimage.ts the web project unit-tests, bundled at image build time — one renderer, no drift; the browser no longer draws or delivers bytes itself. The backend rebuilds the render payload from the journal + engine.AlphabetTable, verifies the HMAC (10-minute TTL, BACKEND_EXPORT_SIGN_KEY, constant-time, uniform 404s) on its public group, and streams the artifact as a named attachment; the gateway forwards /dl/* behind the per-IP public rate limiter (caddy @gateway matcher extended — the landing catch-all trap). Deploy: renderer service (compose + prod overlay + roll before backend + prod push list), EXPORT_SIGN_KEY env (TEST_/PROD_ secrets), CI runs the sidecar smoke in the ui job. Docs: ARCHITECTURE export-delivery section, FUNCTIONAL(+_ru), UI_DESIGN, TESTING, deploy/README.
149 lines
5.5 KiB
Go
149 lines
5.5 KiB
Go
package server
|
||
|
||
import (
|
||
"net/http"
|
||
"strconv"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/google/uuid"
|
||
|
||
"scrabble/backend/internal/account"
|
||
"scrabble/backend/internal/engine"
|
||
"scrabble/backend/internal/game"
|
||
"scrabble/backend/internal/session"
|
||
)
|
||
|
||
// newExportServer is the routing harness with a signing key installed, so the
|
||
// pre-service branches of the export endpoints are reachable.
|
||
func newExportServer() *Server {
|
||
return New(":0", Deps{
|
||
Sessions: &session.Service{},
|
||
Accounts: &account.Store{},
|
||
Games: &game.Service{},
|
||
ExportSignKey: "test-key",
|
||
})
|
||
}
|
||
|
||
func TestSignExportIsDeterministicAndTamperEvident(t *testing.T) {
|
||
s := newExportServer()
|
||
sig := s.signExport("g1", "png", "123", "ru", "Europe/Moscow", "пас,обмен")
|
||
if sig != s.signExport("g1", "png", "123", "ru", "Europe/Moscow", "пас,обмен") {
|
||
t.Fatal("same inputs produced different signatures")
|
||
}
|
||
for _, tampered := range []string{
|
||
s.signExport("g2", "png", "123", "ru", "Europe/Moscow", "пас,обмен"),
|
||
s.signExport("g1", "gcg", "123", "ru", "Europe/Moscow", "пас,обмен"),
|
||
s.signExport("g1", "png", "124", "ru", "Europe/Moscow", "пас,обмен"),
|
||
s.signExport("g1", "png", "123", "en", "Europe/Moscow", "пас,обмен"),
|
||
s.signExport("g1", "png", "123", "ru", "UTC", "пас,обмен"),
|
||
s.signExport("g1", "png", "123", "ru", "Europe/Moscow", "пас"),
|
||
} {
|
||
if tampered == sig {
|
||
t.Fatal("tampered input produced the same signature")
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestExportURLWithoutKeyIs503(t *testing.T) {
|
||
s := New(":0", Deps{Sessions: &session.Service{}, Accounts: &account.Store{}, Games: &game.Service{}})
|
||
rec := do(t, s, http.MethodGet, "/api/v1/user/games/"+uuid.NewString()+"/export-url?kind=png", "",
|
||
map[string]string{"X-User-ID": uuid.NewString()})
|
||
if rec.Code != http.StatusServiceUnavailable {
|
||
t.Fatalf("mint without key = %d, want 503", rec.Code)
|
||
}
|
||
}
|
||
|
||
func TestExportURLRejectsBadParams(t *testing.T) {
|
||
s := newExportServer()
|
||
uid := map[string]string{"X-User-ID": uuid.NewString()}
|
||
gid := uuid.NewString()
|
||
|
||
if rec := do(t, s, http.MethodGet, "/api/v1/user/games/"+gid+"/export-url?kind=exe", "", uid); rec.Code != http.StatusBadRequest {
|
||
t.Fatalf("bad kind = %d, want 400", rec.Code)
|
||
}
|
||
long := strings.Repeat("x", maxLabelsLen+1)
|
||
if rec := do(t, s, http.MethodGet, "/api/v1/user/games/"+gid+"/export-url?kind=png&t="+long, "", uid); rec.Code != http.StatusBadRequest {
|
||
t.Fatalf("oversized labels = %d, want 400", rec.Code)
|
||
}
|
||
}
|
||
|
||
func TestExportDownloadRejectsBadSignatures(t *testing.T) {
|
||
s := newExportServer()
|
||
gid := uuid.NewString()
|
||
exp := strconv.FormatInt(time.Now().Add(time.Minute).Unix(), 10)
|
||
|
||
// A wrong signature never reaches the domain (404 before any service call).
|
||
url := "/api/v1/public/dl/" + gid + "/png?e=" + exp + "&s=forged"
|
||
if rec := do(t, s, http.MethodGet, url, "", nil); rec.Code != http.StatusNotFound {
|
||
t.Fatalf("forged signature = %d, want 404", rec.Code)
|
||
}
|
||
|
||
// A valid signature over an EXPIRED timestamp is refused the same way.
|
||
old := strconv.FormatInt(time.Now().Add(-time.Minute).Unix(), 10)
|
||
url = "/api/v1/public/dl/" + gid + "/png?e=" + old + "&s=" + s.signExport(gid, "png", old, "", "", "")
|
||
if rec := do(t, s, http.MethodGet, url, "", nil); rec.Code != http.StatusNotFound {
|
||
t.Fatalf("expired link = %d, want 404", rec.Code)
|
||
}
|
||
|
||
// An unknown kind is refused before signature checks.
|
||
url = "/api/v1/public/dl/" + gid + "/exe?e=" + exp + "&s=x"
|
||
if rec := do(t, s, http.MethodGet, url, "", nil); rec.Code != http.StatusNotFound {
|
||
t.Fatalf("bad kind = %d, want 404", rec.Code)
|
||
}
|
||
}
|
||
|
||
func TestRenderPayloadMapsTheExportView(t *testing.T) {
|
||
gid := uuid.New()
|
||
finished := time.Unix(1_782_997_629, 0)
|
||
g := game.Game{
|
||
ID: gid,
|
||
Variant: engine.VariantRussianScrabble,
|
||
Status: game.StatusFinished,
|
||
Players: 2,
|
||
FinishedAt: &finished,
|
||
Seats: []game.Seat{
|
||
{Seat: 0, Score: 42, IsWinner: true, DisplayName: "snapshot"},
|
||
{Seat: 1, Score: 30},
|
||
},
|
||
}
|
||
moves := []game.HistoryMove{
|
||
{
|
||
Seat: 0, Action: "play", Dir: "H", MainRow: 7, MainCol: 4,
|
||
Tiles: []engine.TileRecord{{Row: 7, Col: 4, Letter: "ч", Blank: false}},
|
||
Words: []string{"чика"}, Score: 9, RunningTotal: 9,
|
||
},
|
||
{Seat: 1, Action: "exchange", Exchanged: []string{"а", "б"}, RunningTotal: 0},
|
||
}
|
||
names := []string{"Аня", "Боб"}
|
||
|
||
p, err := renderPayload(g, moves, names, "ru", "Europe/Moscow", "пас,обмен,сдался,таймаут", "erudit-game.ru")
|
||
if err != nil {
|
||
t.Fatalf("renderPayload: %v", err)
|
||
}
|
||
if p.Game.LastActivityUnix != finished.Unix() {
|
||
t.Fatalf("lastActivityUnix = %d, want %d", p.Game.LastActivityUnix, finished.Unix())
|
||
}
|
||
if p.Game.Seats[0].DisplayName != "Аня" || !p.Game.Seats[0].IsWinner {
|
||
t.Fatalf("seat 0 = %+v, want the resolved name and the winner flag", p.Game.Seats[0])
|
||
}
|
||
if p.Moves[0].Words[0] != "ЧИКА" || p.Moves[0].Tiles[0].Letter != "Ч" {
|
||
t.Fatalf("letters not upper-cased: %+v", p.Moves[0])
|
||
}
|
||
if p.Moves[1].Count != 2 {
|
||
t.Fatalf("exchange count = %d, want 2", p.Moves[1].Count)
|
||
}
|
||
if p.Labels["pass"] != "пас" || p.Labels["timeout"] != "таймаут" {
|
||
t.Fatalf("labels not mapped positionally: %v", p.Labels)
|
||
}
|
||
if len(p.Alphabet) == 0 || p.Hostname != "erudit-game.ru" || p.TimeZone != "Europe/Moscow" {
|
||
t.Fatalf("alphabet/hostname/zone missing: %d entries, host %q, tz %q", len(p.Alphabet), p.Hostname, p.TimeZone)
|
||
}
|
||
for _, a := range p.Alphabet {
|
||
if a.Letter != strings.ToUpper(a.Letter) {
|
||
t.Fatalf("alphabet letter %q not upper-cased", a.Letter)
|
||
}
|
||
}
|
||
}
|