Files
scrabble-game/backend/internal/inttest/suspension_gate_test.go
T
Ilia Denisov a404513037
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 18s
CI / ui (pull_request) Successful in 56s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m0s
feat(telegram): chat-gate observability + grant on first registration
Two follow-ups from a contour test where a user joined the chat, then
registered, and got no write access — with silent logs.

Observability: log every chat_member update (chat id, configured id, user,
old->new status), the eligibility result and the grant outcome; plus a startup
self-check that warns loudly when the bot is not an administrator in the chat
with the restrict-members ("Ban users") right — the common misconfiguration,
previously invisible in the logs.

Grant on first registration: a user who joins the moderated chat BEFORE
registering is covered by no chat_member event, so the join-time grant never
fires for them. ProvisionTelegram now reports first contact, and the Telegram
auth handler emits chat_access_changed on it, so the gateway re-evaluates and
grants write access if the user is already in the chat.
2026-06-21 15:19:21 +02:00

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
}