ef2c2d1eb9
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 18s
CI / ui (pull_request) Successful in 54s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m18s
A new account's time_zone defaulted to 'UTC' until the player saved a profile, so the robot's sleep window and the turn-timeout away-window sweeper — both anchored to the account zone via account.ResolveZone — ran on UTC for every fresh player, skewing robot-game timing until a manual Settings save. Seed the zone at creation instead, from the client's detected "±HH:MM" offset. - Carry browser_tz on the three account-creating auth requests (TelegramLoginRequest, GuestLoginRequest, EmailRequestRequest — the email account is provisioned at the code-request step, not at login) through the fbs envelope (+ Go/TS codegen), the gateway transcode + backend client, and the backend auth handlers into ProvisionTelegram / ProvisionGuest / ProvisionEmail. - create() now writes time_zone explicitly: the validated detected offset, or 'UTC' (equal to the column default) when absent or malformed — deterministic, never guessed. The column is already NOT NULL DEFAULT 'UTC', so no migration is needed and existing accounts keep 'UTC'. An existing account is never overwritten on re-login. - A detected zero offset is stored as "+00:00" (the zone is known and equals UTC), distinct from the "UTC" default that means "unknown" — which the feedback console's three-zone Filed display already reflects. - Guard the guest handler against an empty payload (the bootstrap historically carried none) so it degrades to no-seed rather than panicking in GetRootAs*. - Tests: zone seeding across Telegram/guest/email plus the "+00:00"/malformed/empty cases and the not-overwrite rule; codec round-trip for the three auth encoders. ARCHITECTURE + FUNCTIONAL(+ru) updated.
152 lines
4.7 KiB
Go
152 lines
4.7 KiB
Go
//go:build integration
|
|
|
|
package inttest
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"go.uber.org/zap/zaptest"
|
|
|
|
"scrabble/backend/internal/account"
|
|
"scrabble/backend/internal/server"
|
|
)
|
|
|
|
// blockStatusBody mirrors the backend's /block-status JSON for the test.
|
|
type blockStatusBody struct {
|
|
Blocked bool `json:"blocked"`
|
|
Permanent bool `json:"permanent"`
|
|
Until string `json:"until"`
|
|
Reason string `json:"reason"`
|
|
}
|
|
|
|
// TestSuspensionGate drives the gate end-to-end through the assembled HTTP server: a blocked
|
|
// account is refused on a normal user route with 403 + account_blocked, the block-status probe
|
|
// stays reachable and resolves the reason to the account's language, and an unblock restores
|
|
// access.
|
|
func TestSuspensionGate(t *testing.T) {
|
|
ctx := context.Background()
|
|
accounts := account.NewStore(testDB)
|
|
srv := server.New(":0", server.Deps{
|
|
Logger: zaptest.NewLogger(t),
|
|
DB: testDB,
|
|
Accounts: accounts,
|
|
})
|
|
|
|
acc, _, err := accounts.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "ru", "", "Blocked", "")
|
|
if err != nil {
|
|
t.Fatalf("provision: %v", err)
|
|
}
|
|
id := acc.ID
|
|
|
|
// Not blocked: a normal route passes and block-status reports false.
|
|
if rec := userGet(t, srv, "/api/v1/user/profile", id); rec.Code != http.StatusOK {
|
|
t.Fatalf("profile before block = %d, want 200", rec.Code)
|
|
}
|
|
if bs := blockStatus(t, srv, id); bs.Blocked {
|
|
t.Fatal("fresh account should not be blocked")
|
|
}
|
|
|
|
// Block permanently with a reason.
|
|
if _, err := accounts.Suspend(ctx, id, nil, "Spam", "Спам", nil); err != nil {
|
|
t.Fatalf("suspend: %v", err)
|
|
}
|
|
|
|
// A normal route is now refused with the stable code.
|
|
rec := userGet(t, srv, "/api/v1/user/profile", id)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("profile while blocked = %d, want 403", rec.Code)
|
|
}
|
|
if code := errorCode(t, rec); code != "account_blocked" {
|
|
t.Fatalf("error code = %q, want account_blocked", code)
|
|
}
|
|
|
|
// The block-status probe stays reachable and resolves the reason to the account's language.
|
|
bs := blockStatus(t, srv, id)
|
|
if !bs.Blocked || !bs.Permanent {
|
|
t.Fatalf("block-status = %+v, want blocked+permanent", bs)
|
|
}
|
|
if bs.Reason != "Спам" {
|
|
t.Errorf("reason = %q, want the Russian snapshot Спам", bs.Reason)
|
|
}
|
|
if bs.Until != "" {
|
|
t.Errorf("until = %q, want empty for a permanent block", bs.Until)
|
|
}
|
|
|
|
// Unblock restores access.
|
|
if err := accounts.LiftSuspension(ctx, id); err != nil {
|
|
t.Fatalf("lift: %v", err)
|
|
}
|
|
if rec := userGet(t, srv, "/api/v1/user/profile", id); rec.Code != http.StatusOK {
|
|
t.Fatalf("profile after unblock = %d, want 200", rec.Code)
|
|
}
|
|
}
|
|
|
|
// TestSuspensionGateTemporaryUntil checks a temporary block reports its expiry through
|
|
// block-status as a future RFC3339 instant and not permanent.
|
|
func TestSuspensionGateTemporaryUntil(t *testing.T) {
|
|
ctx := context.Background()
|
|
accounts := account.NewStore(testDB)
|
|
srv := server.New(":0", server.Deps{Logger: zaptest.NewLogger(t), DB: testDB, Accounts: accounts})
|
|
id := provisionAccount(t)
|
|
|
|
until := time.Now().Add(24 * time.Hour)
|
|
if _, err := accounts.Suspend(ctx, id, &until, "", "", nil); err != nil {
|
|
t.Fatalf("suspend: %v", err)
|
|
}
|
|
bs := blockStatus(t, srv, id)
|
|
if !bs.Blocked || bs.Permanent {
|
|
t.Fatalf("block-status = %+v, want blocked, not permanent", bs)
|
|
}
|
|
parsed, err := time.Parse(time.RFC3339, bs.Until)
|
|
if err != nil {
|
|
t.Fatalf("until %q is not RFC3339: %v", bs.Until, err)
|
|
}
|
|
if !parsed.After(time.Now()) {
|
|
t.Errorf("until = %v, want a future instant", parsed)
|
|
}
|
|
}
|
|
|
|
// userGet issues an authenticated GET (X-User-ID) against the assembled server.
|
|
func userGet(t *testing.T, srv *server.Server, path string, id uuid.UUID) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, path, nil)
|
|
req.Header.Set("X-User-ID", id.String())
|
|
srv.Handler().ServeHTTP(rec, req)
|
|
return rec
|
|
}
|
|
|
|
// blockStatus fetches and decodes the /block-status payload, asserting a 200.
|
|
func blockStatus(t *testing.T, srv *server.Server, id uuid.UUID) blockStatusBody {
|
|
t.Helper()
|
|
rec := userGet(t, srv, "/api/v1/user/block-status", id)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("block-status status = %d, want 200", rec.Code)
|
|
}
|
|
var b blockStatusBody
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &b); err != nil {
|
|
t.Fatalf("decode block-status: %v", err)
|
|
}
|
|
return b
|
|
}
|
|
|
|
// errorCode extracts the stable code from the backend error envelope.
|
|
func errorCode(t *testing.T, rec *httptest.ResponseRecorder) string {
|
|
t.Helper()
|
|
var e struct {
|
|
Error struct {
|
|
Code string `json:"code"`
|
|
} `json:"error"`
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &e); err != nil {
|
|
t.Fatalf("decode error envelope: %v", err)
|
|
}
|
|
return e.Error.Code
|
|
}
|