d5fbaa3034
CI / changes (push) Successful in 1s
CI / unit (push) Successful in 9s
CI / integration (push) Successful in 15s
CI / ui (push) Successful in 1m2s
CI / conformance (push) Successful in 9s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m42s
The finished-game export (GCG + a new PNG of the final position) is one signed, short-lived relative URL (game.export_url; HMAC-SHA256, 10-min TTL, BACKEND_EXPORT_SIGN_KEY) resolved against the client's own origin and delivered by the best affordance each platform has (five on-device review rounds): - TG Android/desktop: native showPopup chooser -> native downloadFile dialog (bridge-only chain, activation-safe). - TG iOS: app-modal chooser -> OS share sheet with the fetched file (a popup callback cannot supply the activation the sheet needs). - VK iOS: VKWebAppDownloadFile for both formats. - VK Android: the PNG opens in VK's native image viewer, the GCG copies to the clipboard (the VK Android downloader hangs on any download, Content-Length/Range notwithstanding). - VK desktop iframe / desktop browsers: plain anchor downloads. - Mobile browsers: the OS share sheet (fetch-then-share). - Legacy TG (< Bot API 8.0): app modal + GCG clipboard, no image option. The PNG is rasterized on demand by the new internal `renderer` sidecar (node:22-slim + skia-canvas + baked Liberation/Noto Color Emoji fonts) executing the SAME ui/src/lib/gameimage.ts the ui project unit-tests; the backend rebuilds the render payload from the journal + engine.AlphabetTable, and the device date locale, IANA time zone and localized non-play labels ride the signed URL. Nothing is stored — the artifact re-derives from the immutable journal on each GET. The gateway forwards /dl/* (caddy @gateway matcher extended) behind the per-IP public rate limiter and serves bytes via http.ServeContent. Deploy: renderer service in compose + prod overlay + rolling order + prod push list; TEST_/PROD_EXPORT_SIGN_KEY secrets; the sidecar smoke runs in the ui CI job. Docs: ARCHITECTURE, FUNCTIONAL(+_ru), UI_DESIGN, TESTING, deploy/README, renderer/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)
|
||
}
|
||
}
|
||
}
|