e71e40eef5
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 19s
CI / ui (pull_request) Successful in 57s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m39s
Add a second standalone promo bot to the bot container (answers /start with a localized message + a URL button into the main bot's Mini App) and gate write access in a channel's linked discussion chat: grant on join when the Telegram user is registered and neither admin-suspended nor holding a new chat_muted role, and revoke/grant on the matching moderation change for a member currently in the chat. Eligibility (registered AND NOT suspended AND NOT chat_muted; the game suspension dominates) is resolved once in the backend and reached two ways: the bot's join-time unary ResolveChatEligibility over the existing mTLS bot-link, and a backend chat_access_changed event -> gateway -> ChatGate command (idempotent; a temporary-block-expiry sweeper may over-emit). The bot guards the block/unblock path with getChatMember, since bots cannot list members. A web_app button cannot open another bot's Mini App (it signs initData with the sending bot's token), so the promo button is a t.me ?startapp URL reusing the UI's VITE_TELEGRAM_LINK. The bot must be a chat admin with the restrict-members right and chat_member in its allowed updates. No schema change: chat_muted reuses the data-driven account_roles table.
273 lines
9.5 KiB
Go
273 lines
9.5 KiB
Go
//go:build integration
|
|
|
|
package inttest
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"go.uber.org/zap/zaptest"
|
|
|
|
"scrabble/backend/internal/account"
|
|
"scrabble/backend/internal/notify"
|
|
"scrabble/backend/internal/server"
|
|
)
|
|
|
|
// chatAccessBody mirrors the backend's /internal/chat-access JSON for the test.
|
|
type chatAccessBody struct {
|
|
ExternalID string `json:"external_id"`
|
|
Registered bool `json:"registered"`
|
|
Eligible bool `json:"eligible"`
|
|
}
|
|
|
|
// chatAccess issues the gateway-internal chat-access query and asserts a 200.
|
|
func chatAccess(t *testing.T, srv *server.Server, body string) chatAccessBody {
|
|
t.Helper()
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/internal/chat-access", strings.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
srv.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("chat-access %s = %d: %s", body, rec.Code, rec.Body.String())
|
|
}
|
|
var b chatAccessBody
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &b); err != nil {
|
|
t.Fatalf("decode chat-access: %v", err)
|
|
}
|
|
return b
|
|
}
|
|
|
|
// TestChatAccessResolver drives the gateway-internal eligibility resolver over HTTP:
|
|
// the registered/suspended/chat_muted truth table by Telegram identity and by account
|
|
// id, the suspension dominating the chat_muted role, an unknown identity reported
|
|
// unregistered, and an account with no Telegram identity carrying an empty external_id.
|
|
func TestChatAccessResolver(t *testing.T) {
|
|
ctx := context.Background()
|
|
accounts := account.NewStore(testDB)
|
|
srv := server.New(":0", server.Deps{Logger: zaptest.NewLogger(t), DB: testDB, Accounts: accounts})
|
|
|
|
ext := "tg-" + uuid.NewString()
|
|
acc, err := accounts.ProvisionTelegram(ctx, ext, "en", "", "Chatter")
|
|
if err != nil {
|
|
t.Fatalf("provision: %v", err)
|
|
}
|
|
id := acc.ID
|
|
|
|
byExt := func() chatAccessBody { return chatAccess(t, srv, `{"external_id":"`+ext+`"}`) }
|
|
byUser := func() chatAccessBody { return chatAccess(t, srv, `{"user_id":"`+id.String()+`"}`) }
|
|
|
|
// A registered, unsuspended, unmuted account is eligible by either address, and the
|
|
// account-id query resolves back to its Telegram identity.
|
|
if b := byExt(); !b.Registered || !b.Eligible || b.ExternalID != ext {
|
|
t.Fatalf("fresh by external_id = %+v, want registered+eligible+ext", b)
|
|
}
|
|
if b := byUser(); !b.Registered || !b.Eligible || b.ExternalID != ext {
|
|
t.Fatalf("fresh by user_id = %+v, want registered+eligible+ext", b)
|
|
}
|
|
|
|
// A suspension mutes; a lift restores.
|
|
if _, err := accounts.Suspend(ctx, id, nil, "", "", nil); err != nil {
|
|
t.Fatalf("suspend: %v", err)
|
|
}
|
|
if b := byExt(); !b.Registered || b.Eligible {
|
|
t.Fatalf("suspended = %+v, want registered but not eligible", b)
|
|
}
|
|
if err := accounts.LiftSuspension(ctx, id); err != nil {
|
|
t.Fatalf("lift: %v", err)
|
|
}
|
|
if b := byExt(); !b.Eligible {
|
|
t.Fatalf("after lift = %+v, want eligible", b)
|
|
}
|
|
|
|
// The chat_muted role mutes independently; a revoke restores.
|
|
if err := accounts.GrantRole(ctx, id, account.RoleChatMuted); err != nil {
|
|
t.Fatalf("grant chat_muted: %v", err)
|
|
}
|
|
if b := byExt(); !b.Registered || b.Eligible {
|
|
t.Fatalf("chat_muted = %+v, want registered but not eligible", b)
|
|
}
|
|
|
|
// Suspension dominates: while chat_muted is set, lifting a concurrent suspension
|
|
// must not re-grant chat (the role still mutes).
|
|
if _, err := accounts.Suspend(ctx, id, nil, "", "", nil); err != nil {
|
|
t.Fatalf("suspend over mute: %v", err)
|
|
}
|
|
if b := byExt(); b.Eligible {
|
|
t.Fatalf("suspended+muted = %+v, want not eligible", b)
|
|
}
|
|
if err := accounts.LiftSuspension(ctx, id); err != nil {
|
|
t.Fatalf("lift over mute: %v", err)
|
|
}
|
|
if b := byExt(); b.Eligible {
|
|
t.Fatalf("lifted but still muted = %+v, want not eligible", b)
|
|
}
|
|
if err := accounts.RevokeRole(ctx, id, account.RoleChatMuted); err != nil {
|
|
t.Fatalf("revoke chat_muted: %v", err)
|
|
}
|
|
if b := byExt(); !b.Eligible {
|
|
t.Fatalf("after revoke = %+v, want eligible", b)
|
|
}
|
|
|
|
// An unknown Telegram identity is unregistered (and thus left muted).
|
|
if b := chatAccess(t, srv, `{"external_id":"tg-missing-`+uuid.NewString()+`"}`); b.Registered || b.Eligible {
|
|
t.Fatalf("unknown identity = %+v, want neither registered nor eligible", b)
|
|
}
|
|
|
|
// An account with no Telegram identity (a guest) carries an empty external_id, so
|
|
// the gateway has nothing to gate.
|
|
guest := provisionGuest(t)
|
|
if b := chatAccess(t, srv, `{"user_id":"`+guest.String()+`"}`); b.ExternalID != "" || b.Registered {
|
|
t.Fatalf("guest by user_id = %+v, want empty external_id and not registered", b)
|
|
}
|
|
|
|
// A request naming neither address is a bad request.
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/internal/chat-access", strings.NewReader(`{}`))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
srv.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("empty query = %d, want 400", rec.Code)
|
|
}
|
|
}
|
|
|
|
// captureNotifier records every published intent so a test can assert which live
|
|
// events a console action emitted.
|
|
type captureNotifier struct {
|
|
mu sync.Mutex
|
|
intents []notify.Intent
|
|
}
|
|
|
|
func (c *captureNotifier) Publish(in ...notify.Intent) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
c.intents = append(c.intents, in...)
|
|
}
|
|
|
|
// count returns how many intents of kind addressed to user were captured.
|
|
func (c *captureNotifier) count(user uuid.UUID, kind string) int {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
n := 0
|
|
for _, in := range c.intents {
|
|
if in.UserID == user && in.Kind == kind {
|
|
n++
|
|
}
|
|
}
|
|
return n
|
|
}
|
|
|
|
// TestChatAccessPublishedOnModeration drives the admin console and asserts each
|
|
// moderation action that can change chat eligibility — block, unblock, and the
|
|
// chat_muted role grant/revoke — emits the chat_access_changed signal the gateway
|
|
// turns into a chat-gate command.
|
|
func TestChatAccessPublishedOnModeration(t *testing.T) {
|
|
notifier := &captureNotifier{}
|
|
srv := server.New(":0", server.Deps{
|
|
Logger: zaptest.NewLogger(t),
|
|
DB: testDB,
|
|
Accounts: account.NewStore(testDB),
|
|
Games: newGameService(),
|
|
Registry: testRegistry,
|
|
DictDir: dictDir(),
|
|
Notifier: notifier,
|
|
})
|
|
h := srv.Handler()
|
|
id := provisionAccount(t)
|
|
base := "http://admin.test/_gm/users/" + id.String()
|
|
const origin = "http://admin.test"
|
|
|
|
steps := []struct {
|
|
name, path, body string
|
|
want string
|
|
}{
|
|
{"block", "/block", "duration=permanent", "Blocked"},
|
|
{"unblock", "/unblock", "", "Unblocked"},
|
|
{"grant chat_muted", "/grant-role", "role=chat_muted", "Role granted"},
|
|
{"revoke chat_muted", "/revoke-role", "role=chat_muted", "Role revoked"},
|
|
}
|
|
for i, s := range steps {
|
|
code, body := consoleDo(h, http.MethodPost, base+s.path, s.body, origin)
|
|
if code != http.StatusOK || !strings.Contains(body, s.want) {
|
|
t.Fatalf("%s = %d, has %q = %v", s.name, code, s.want, strings.Contains(body, s.want))
|
|
}
|
|
if got := notifier.count(id, notify.KindChatAccessChanged); got != i+1 {
|
|
t.Fatalf("after %s: chat_access_changed count = %d, want %d", s.name, got, i+1)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestSuspensionsExpiredBetween checks the sweeper's window query: a non-lifted
|
|
// temporary block whose expiry falls in the window is returned, while one outside the
|
|
// window, a permanent block, and a lifted block are not.
|
|
func TestSuspensionsExpiredBetween(t *testing.T) {
|
|
ctx := context.Background()
|
|
accounts := account.NewStore(testDB)
|
|
|
|
// A temporary block whose expiry already lapsed at a known instant.
|
|
tempID := provisionAccount(t)
|
|
expiry := time.Now().Add(-time.Hour).Truncate(time.Second)
|
|
if _, err := accounts.Suspend(ctx, tempID, &expiry, "", "", nil); err != nil {
|
|
t.Fatalf("suspend temp: %v", err)
|
|
}
|
|
|
|
contains := func(ids []uuid.UUID, want uuid.UUID) bool {
|
|
for _, id := range ids {
|
|
if id == want {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// A window straddling the expiry returns the account.
|
|
got, err := accounts.SuspensionsExpiredBetween(ctx, expiry.Add(-time.Minute), expiry.Add(time.Minute))
|
|
if err != nil {
|
|
t.Fatalf("expired between: %v", err)
|
|
}
|
|
if !contains(got, tempID) {
|
|
t.Fatalf("window over expiry missing the lapsed block %s", tempID)
|
|
}
|
|
// A window entirely after the expiry does not.
|
|
got, err = accounts.SuspensionsExpiredBetween(ctx, expiry.Add(time.Minute), expiry.Add(2*time.Minute))
|
|
if err != nil {
|
|
t.Fatalf("expired between (after): %v", err)
|
|
}
|
|
if contains(got, tempID) {
|
|
t.Fatalf("window after expiry should not return %s", tempID)
|
|
}
|
|
|
|
// A permanent block never appears, even in a wide window.
|
|
permID := provisionAccount(t)
|
|
if _, err := accounts.Suspend(ctx, permID, nil, "", "", nil); err != nil {
|
|
t.Fatalf("suspend perm: %v", err)
|
|
}
|
|
// A lifted block does not appear either. The block must still be in force when lifted
|
|
// (LiftSuspension only lifts in-force blocks), so its expiry is in the future and the
|
|
// wide window below still covers it — yet lifted_at excludes it.
|
|
liftID := provisionAccount(t)
|
|
liftExpiry := time.Now().Add(30 * time.Minute).Truncate(time.Second)
|
|
if _, err := accounts.Suspend(ctx, liftID, &liftExpiry, "", "", nil); err != nil {
|
|
t.Fatalf("suspend lift: %v", err)
|
|
}
|
|
if err := accounts.LiftSuspension(ctx, liftID); err != nil {
|
|
t.Fatalf("lift: %v", err)
|
|
}
|
|
wide, err := accounts.SuspensionsExpiredBetween(ctx, time.Now().Add(-2*time.Hour), time.Now().Add(time.Hour))
|
|
if err != nil {
|
|
t.Fatalf("expired between (wide): %v", err)
|
|
}
|
|
if contains(wide, permID) {
|
|
t.Fatalf("permanent block %s must not be reported as expired", permID)
|
|
}
|
|
if contains(wide, liftID) {
|
|
t.Fatalf("lifted block %s must not be reported as expired", liftID)
|
|
}
|
|
}
|