d1ba666495
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 12s
CI / ui (pull_request) Successful in 45s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m10s
Operator-driven hard block, the counterpart to the soft high-rate flag: permanent or until a date, with an optional reason chosen from an editable en+ru picklist (snapshotted onto the block). A block forfeits the player's active games (opponent wins, as a resignation) and cancels their open matchmaking games. A backend gate refuses a blocked account on every /api/v1/user/* route except the block-status probe with 403 account_blocked, which threads through the gateway as the Execute result_code; the UI surfaces it as a terminal blocked screen and stops all push/poll. Temporary blocks self-expire; the operator can unblock at any time (lost games stay lost). Sessions are not revoked, so the blocked client can still reach the exempt block-status endpoint. Backend: migration 00003 (account_suspensions + suspension_reasons) + jet regen; account suspension store; game.ForfeitAllForAccount; requireNotSuspended gate + block-status endpoint; admin console block/unblock + Reasons CRUD. Wire: fbs BlockStatus + account.block_status gateway op. UI: blocked screen, app state, transport/codec, i18n. Docs: ARCHITECTURE, FUNCTIONAL(+ru), PRERELEASE (AB).
122 lines
4.2 KiB
Go
122 lines
4.2 KiB
Go
//go:build integration
|
|
|
|
package inttest
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"strings"
|
|
"testing"
|
|
|
|
"go.uber.org/zap/zaptest"
|
|
|
|
"scrabble/backend/internal/account"
|
|
"scrabble/backend/internal/game"
|
|
"scrabble/backend/internal/server"
|
|
)
|
|
|
|
// TestConsoleBlockAndUnblock drives the admin console block flow through HTTP: blocking a user
|
|
// records the suspension and forfeits their active game, the user card renders both branches,
|
|
// and unblocking lifts it.
|
|
func TestConsoleBlockAndUnblock(t *testing.T) {
|
|
ctx := context.Background()
|
|
accounts := account.NewStore(testDB)
|
|
games := newGameService()
|
|
srv := consoleServer(t, accounts, games)
|
|
|
|
gid, seats := newGameWithSeats(t, 2)
|
|
blocked := seats[0]
|
|
|
|
// The card renders the not-blocked branch before any block.
|
|
if rec := consoleGet(t, srv, "/_gm/users/"+blocked.String()); rec.Code != http.StatusOK {
|
|
t.Fatalf("user card before block = %d, want 200", rec.Code)
|
|
}
|
|
|
|
// Block permanently via the console.
|
|
if rec := consolePost(t, srv, "/_gm/users/"+blocked.String()+"/block", url.Values{"duration": {"permanent"}}); rec.Code != http.StatusOK {
|
|
t.Fatalf("console block = %d, want 200", rec.Code)
|
|
}
|
|
if _, ok, err := accounts.CurrentSuspension(ctx, blocked); err != nil || !ok {
|
|
t.Fatalf("after console block: blocked=%v err=%v, want blocked", ok, err)
|
|
}
|
|
if g, err := games.GameByID(ctx, gid); err != nil || g.Status != game.StatusFinished {
|
|
t.Fatalf("game after block: status=%q err=%v, want finished", g.Status, err)
|
|
}
|
|
|
|
// The card renders the blocked branch.
|
|
if rec := consoleGet(t, srv, "/_gm/users/"+blocked.String()); rec.Code != http.StatusOK {
|
|
t.Fatalf("user card after block = %d, want 200", rec.Code)
|
|
}
|
|
|
|
// Unblock via the console.
|
|
if rec := consolePost(t, srv, "/_gm/users/"+blocked.String()+"/unblock", url.Values{}); rec.Code != http.StatusOK {
|
|
t.Fatalf("console unblock = %d, want 200", rec.Code)
|
|
}
|
|
if _, ok, err := accounts.CurrentSuspension(ctx, blocked); err != nil || ok {
|
|
t.Fatalf("after console unblock: blocked=%v err=%v, want not blocked", ok, err)
|
|
}
|
|
}
|
|
|
|
// TestConsoleReasonsCRUD drives reason creation through the console and checks the page renders.
|
|
func TestConsoleReasonsCRUD(t *testing.T) {
|
|
ctx := context.Background()
|
|
accounts := account.NewStore(testDB)
|
|
srv := consoleServer(t, accounts, newGameService())
|
|
|
|
if rec := consoleGet(t, srv, "/_gm/reasons"); rec.Code != http.StatusOK {
|
|
t.Fatalf("reasons page = %d, want 200", rec.Code)
|
|
}
|
|
form := url.Values{"text_en": {"Console reason"}, "text_ru": {"Причина из консоли"}}
|
|
if rec := consolePost(t, srv, "/_gm/reasons", form); rec.Code != http.StatusOK {
|
|
t.Fatalf("console create reason = %d, want 200", rec.Code)
|
|
}
|
|
reasons, err := accounts.ListReasons(ctx)
|
|
if err != nil {
|
|
t.Fatalf("list reasons: %v", err)
|
|
}
|
|
found := false
|
|
for _, r := range reasons {
|
|
if r.TextEn == "Console reason" && r.TextRu == "Причина из консоли" {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Error("reason created via the console was not found")
|
|
}
|
|
}
|
|
|
|
// consoleServer assembles a server with the admin console mounted (it needs accounts, games and a
|
|
// registry).
|
|
func consoleServer(t *testing.T, accounts *account.Store, games *game.Service) *server.Server {
|
|
t.Helper()
|
|
return server.New(":0", server.Deps{
|
|
Logger: zaptest.NewLogger(t),
|
|
DB: testDB,
|
|
Accounts: accounts,
|
|
Games: games,
|
|
Registry: testRegistry,
|
|
DictDir: dictDir(),
|
|
})
|
|
}
|
|
|
|
// consoleGet issues a console GET (a safe method, so the same-origin guard passes).
|
|
func consoleGet(t *testing.T, srv *server.Server, path string) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
rec := httptest.NewRecorder()
|
|
srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "http://example.com"+path, nil))
|
|
return rec
|
|
}
|
|
|
|
// consolePost issues a same-origin form POST to the console.
|
|
func consolePost(t *testing.T, srv *server.Server, path string, form url.Values) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
req := httptest.NewRequest(http.MethodPost, "http://example.com"+path, strings.NewReader(form.Encode()))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
req.Header.Set("Origin", "http://example.com")
|
|
rec := httptest.NewRecorder()
|
|
srv.Handler().ServeHTTP(rec, req)
|
|
return rec
|
|
}
|