owpengram-server/internal/store/postgres/telegram_login_integration_test.go

434 lines
16 KiB
Go

package postgres
import (
"context"
"crypto/sha256"
"errors"
"fmt"
"strings"
"sync"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"telesrv/internal/domain"
)
func telegramLoginPGHash(value string) []byte {
sum := sha256.Sum256([]byte(value))
return sum[:]
}
func TestTelegramLoginStorePostgresAtomicStateAndCodeConsumption(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
now := time.Now().UTC().Truncate(time.Microsecond)
suffix := now.UnixNano() % 1_000_000_000
users := NewUserStore(pool)
bots := NewBotStore(pool)
owner, err := users.Create(ctx, domain.User{
AccessHash: suffix + 101,
Phone: fmt.Sprintf("1777%09d", suffix),
FirstName: "OIDC Owner",
})
if err != nil {
t.Fatalf("create oidc owner: %v", err)
}
bot, _, err := bots.CreateBotAccount(ctx, domain.User{
AccessHash: suffix + 102,
FirstName: "OIDC Test Bot",
Username: fmt.Sprintf("oidc_%09d_bot", suffix),
}, domain.BotProfile{OwnerUserID: owner.ID, TokenSecret: "bot-secret"})
if err != nil {
t.Fatalf("create oidc bot: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id IN ($1,$2)", owner.ID, bot.ID)
})
store := NewTelegramLoginStore(pool)
client, err := store.UpsertTelegramLoginClient(ctx, domain.TelegramLoginClient{
BotUserID: bot.ID,
ClientID: fmt.Sprintf("%d", bot.ID),
SecretHash: telegramLoginPGHash("client-secret"),
SecretVersion: 1,
SigningAlgorithm: domain.TelegramLoginSigningRS256,
Enabled: true,
CreatedAt: now,
UpdatedAt: now,
})
if err != nil {
t.Fatalf("upsert oidc client: %v", err)
}
redirectURI := fmt.Sprintf("https://rp-%d.example/callback", suffix)
origin := fmt.Sprintf("https://rp-%d.example", suffix)
if _, err := store.AddTelegramLoginAllowedURL(ctx, domain.TelegramLoginAllowedURL{
BotUserID: client.BotUserID, Kind: domain.TelegramLoginAllowedRedirectURI,
NormalizedURL: redirectURI, CreatedAt: now,
}); err != nil {
t.Fatalf("add redirect: %v", err)
}
if _, err := store.AddTelegramLoginAllowedURL(ctx, domain.TelegramLoginAllowedURL{
BotUserID: client.BotUserID, Kind: domain.TelegramLoginAllowedWebOrigin,
NormalizedURL: origin, CreatedAt: now,
}); err != nil {
t.Fatalf("add web origin: %v", err)
}
newRequest := func(label string) domain.TelegramLoginRequest {
request, err := store.CreateTelegramLoginRequest(ctx, domain.TelegramLoginRequest{
RequestTokenHash: telegramLoginPGHash("request-" + label),
BrowserTokenHash: telegramLoginPGHash("browser-" + label),
BotUserID: bot.ID,
ClientID: client.ClientID,
SigningAlgorithm: client.SigningAlgorithm,
Source: domain.TelegramLoginRequestWeb,
ResponseType: "code",
RedirectURI: redirectURI,
Origin: origin,
Domain: fmt.Sprintf("rp-%d.example", suffix),
Scopes: []domain.TelegramLoginScope{domain.TelegramLoginScopeOpenID, domain.TelegramLoginScopeProfile, domain.TelegramLoginScopeBotAccess},
State: "state",
Nonce: "nonce",
CodeChallenge: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
CodeChallengeMethod: "S256",
Browser: "Firefox",
Platform: "Windows",
IP: "192.0.2.10",
Region: "Test Region",
MatchCodes: []string{"🟢", "🔵", "🟠"},
MatchCode: "🔵",
MatchCodesFirst: true,
Status: domain.TelegramLoginRequestPending,
CreatedAt: now,
ExpiresAt: now.Add(5 * time.Minute),
})
if err != nil {
t.Fatalf("create request %s: %v", label, err)
}
return request
}
request := newRequest(fmt.Sprintf("race-%d", suffix))
start := make(chan struct{})
errs := make(chan error, 2)
go func() {
<-start
_, _, err := store.ApproveTelegramLoginRequest(ctx, domain.TelegramLoginApproval{
RequestID: request.ID,
Identity: domain.TelegramLoginIdentitySnapshot{UserID: owner.ID, Name: owner.FirstName, GivenName: owner.FirstName},
WriteAllowed: true,
MatchCode: request.MatchCode, ApprovedAt: now.Add(time.Second),
}, suffix+10_000)
errs <- err
}()
go func() {
<-start
_, err := store.DeclineTelegramLoginRequest(ctx, request.ID, owner.ID, now.Add(time.Second))
errs <- err
}()
close(start)
var success, conflict int
for range 2 {
err := <-errs
switch {
case err == nil:
success++
case errors.Is(err, domain.ErrTelegramLoginRequestConflict):
conflict++
default:
t.Fatalf("accept/decline race error: %v", err)
}
}
if success != 1 || conflict != 1 {
t.Fatalf("accept/decline success=%d conflict=%d, want 1/1", success, conflict)
}
codeRequest := newRequest(fmt.Sprintf("code-%d", suffix))
_, web, err := store.ApproveTelegramLoginRequest(ctx, domain.TelegramLoginApproval{
RequestID: codeRequest.ID,
Identity: domain.TelegramLoginIdentitySnapshot{UserID: owner.ID, Name: owner.FirstName, GivenName: owner.FirstName},
WriteAllowed: true,
MatchCode: codeRequest.MatchCode, ApprovedAt: now.Add(2 * time.Second),
}, suffix+20_000)
if err != nil {
t.Fatalf("approve code request: %v", err)
}
canSend, err := bots.CanBotSendMessage(ctx, bot.ID, owner.ID)
if err != nil || !canSend {
t.Fatalf("bot access after atomic approval = %v,%v", canSend, err)
}
code := domain.TelegramLoginAuthorizationCode{
RequestID: codeRequest.ID,
CodeHash: telegramLoginPGHash(fmt.Sprintf("code-%d", suffix)),
SealedCode: append(make([]byte, 32), 1),
SealNonce: make([]byte, 12),
SealKeyID: "integration-key",
IssuedAt: now.Add(3 * time.Second),
ExpiresAt: now.Add(time.Minute),
}
if _, err := store.PutTelegramLoginAuthorizationCode(ctx, code); err != nil {
t.Fatalf("put code: %v", err)
}
exchange := domain.TelegramLoginCodeExchange{
CodeHash: code.CodeHash, ClientID: client.ClientID, ClientSecretVersion: client.SecretVersion,
RedirectURI: codeRequest.RedirectURI, CodeChallenge: codeRequest.CodeChallenge, Now: now.Add(4 * time.Second),
}
start = make(chan struct{})
errs = make(chan error, 8)
var wg sync.WaitGroup
for range 8 {
wg.Add(1)
go func() {
defer wg.Done()
<-start
_, _, _, err := store.ConsumeTelegramLoginAuthorizationCode(ctx, exchange)
errs <- err
}()
}
close(start)
wg.Wait()
close(errs)
success, conflict = 0, 0
for err := range errs {
switch {
case err == nil:
success++
case errors.Is(err, domain.ErrTelegramLoginCodeConsumed):
conflict++
default:
t.Fatalf("code consume race error: %v", err)
}
}
if success != 1 || conflict != 7 {
t.Fatalf("code consume success=%d consumed=%d, want 1/7", success, conflict)
}
miniRequest, err := store.CreateTelegramLoginRequest(ctx, domain.TelegramLoginRequest{
RequestTokenHash: telegramLoginPGHash(fmt.Sprintf("mini-request-%d", suffix)),
BrowserTokenHash: telegramLoginPGHash(fmt.Sprintf("mini-browser-%d", suffix)),
BotUserID: bot.ID, ClientID: client.ClientID, SigningAlgorithm: client.SigningAlgorithm,
Source: domain.TelegramLoginRequestMiniApp, ResponseType: "post_message",
RedirectURI: origin + "/", Origin: origin, InAppOrigin: origin, Domain: fmt.Sprintf("rp-%d.example", suffix),
Scopes: []domain.TelegramLoginScope{domain.TelegramLoginScopeOpenID, domain.TelegramLoginScopeProfile},
Browser: "Telegram Mini App", Platform: "Telegram Mini App", IP: "192.0.2.11", Region: "Test Region",
MatchCodes: []string{"🟢", "🔵", "🟠"}, MatchCode: "🔵", MatchCodesFirst: true,
Status: domain.TelegramLoginRequestPending, CreatedAt: now, ExpiresAt: now.Add(5 * time.Minute),
})
if err != nil {
t.Fatalf("create mini-app request: %v", err)
}
if _, _, err := store.ApproveTelegramLoginRequest(ctx, domain.TelegramLoginApproval{
RequestID: miniRequest.ID,
Identity: domain.TelegramLoginIdentitySnapshot{UserID: owner.ID, Name: owner.FirstName, GivenName: owner.FirstName},
MatchCode: miniRequest.MatchCode, ApprovedAt: now.Add(5 * time.Second),
}, suffix+25_000); err != nil {
t.Fatalf("approve mini-app request: %v", err)
}
directToken := domain.TelegramLoginAuthorizationCode{
RequestID: miniRequest.ID, CodeHash: telegramLoginPGHash(fmt.Sprintf("mini-token-%d", suffix)),
SealedCode: append(make([]byte, 32), 1), SealNonce: make([]byte, 12), SealKeyID: "integration-key",
IssuedAt: now.Add(6 * time.Second), ExpiresAt: now.Add(time.Minute),
}
if _, err := store.PutTelegramLoginAuthorizationCode(ctx, directToken); err != nil {
t.Fatalf("put mini-app token: %v", err)
}
start = make(chan struct{})
errs = make(chan error, 8)
for range 8 {
wg.Add(1)
go func() {
defer wg.Done()
<-start
_, _, _, err := store.ConsumeTelegramLoginDirectToken(ctx, directToken.CodeHash, origin, now.Add(7*time.Second))
errs <- err
}()
}
close(start)
wg.Wait()
close(errs)
success, conflict = 0, 0
for err := range errs {
switch {
case err == nil:
success++
case errors.Is(err, domain.ErrTelegramLoginCodeConsumed):
conflict++
default:
t.Fatalf("mini-app token consume race error: %v", err)
}
}
if success != 1 || conflict != 7 {
t.Fatalf("mini-app token consume success=%d consumed=%d, want 1/7", success, conflict)
}
if revoked, err := store.RevokeTelegramLoginWebAuthorization(ctx, owner.ID, web.Hash, now.Add(5*time.Second)); err != nil || !revoked {
t.Fatalf("revoke web authorization = %v,%v", revoked, err)
}
if listed, err := store.ListTelegramLoginWebAuthorizations(ctx, owner.ID); err != nil {
t.Fatalf("list web authorizations: %v", err)
} else {
for _, got := range listed {
if got.Hash == web.Hash {
t.Fatalf("revoked web authorization still listed: %#v", got)
}
}
}
assertTelegramLoginConfigDeleteTakesClientLock(t, pool, client.BotUserID, func() (bool, error) {
return store.DeleteTelegramLoginAllowedURL(ctx, client.BotUserID, domain.TelegramLoginAllowedRedirectURI, redirectURI)
})
}
func TestTelegramLoginStorePostgresNativeCallbackAndRetention(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
now := time.Now().UTC().Truncate(time.Microsecond)
suffix := now.UnixNano() % 1_000_000_000
users := NewUserStore(pool)
bots := NewBotStore(pool)
owner, err := users.Create(ctx, domain.User{
AccessHash: suffix + 301, Phone: fmt.Sprintf("1666%09d", suffix), FirstName: "Native Owner",
})
if err != nil {
t.Fatal(err)
}
bot, _, err := bots.CreateBotAccount(ctx, domain.User{
AccessHash: suffix + 302, FirstName: "Native Login Bot", Username: fmt.Sprintf("native_%09d_bot", suffix),
}, domain.BotProfile{OwnerUserID: owner.ID, TokenSecret: "native-bot-secret"})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id IN ($1,$2)", owner.ID, bot.ID) })
store := NewTelegramLoginStore(pool)
client, err := store.CreateTelegramLoginClient(ctx, domain.TelegramLoginClient{
BotUserID: bot.ID, ClientID: fmt.Sprintf("%d", bot.ID), SecretHash: telegramLoginPGHash("native-secret"),
SecretVersion: 1, SigningAlgorithm: domain.TelegramLoginSigningRS256, Enabled: true,
CreatedAt: now, UpdatedAt: now,
})
if err != nil {
t.Fatal(err)
}
const callbackURI = "bedolaga://telegram-login"
nativeApp, err := store.UpsertTelegramLoginNativeApp(ctx, domain.TelegramLoginNativeApp{
BotUserID: bot.ID, Platform: domain.TelegramLoginNativeAndroid, ApplicationID: "dev.bedolaga.demo",
VerificationID: strings.Repeat("A", 64), CallbackURI: callbackURI, VerifiedDisplayName: "Bedolaga Demo",
Enabled: true, CreatedAt: now, UpdatedAt: now,
})
if err != nil {
t.Fatal(err)
}
createRequest := func(label string) domain.TelegramLoginRequest {
t.Helper()
request, err := store.CreateTelegramLoginRequest(ctx, domain.TelegramLoginRequest{
RequestTokenHash: telegramLoginPGHash("native-request-" + label), BrowserTokenHash: telegramLoginPGHash("native-browser-" + label),
BotUserID: bot.ID, ClientID: client.ClientID, SigningAlgorithm: client.SigningAlgorithm,
Source: domain.TelegramLoginRequestNative, ResponseType: "code", RedirectURI: callbackURI,
Domain: "dev.bedolaga.demo", Scopes: []domain.TelegramLoginScope{domain.TelegramLoginScopeOpenID, domain.TelegramLoginScopeProfile},
CodeChallenge: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", CodeChallengeMethod: "S256",
Browser: "TelegramLogin/Android", Platform: "Android", IP: "192.0.2.20", Region: "Test Region",
IsApp: true, VerifiedAppName: "Bedolaga Demo", MatchCodes: []string{}, Status: domain.TelegramLoginRequestPending,
CreatedAt: now, ExpiresAt: now.Add(5 * time.Minute),
})
if err != nil {
t.Fatalf("create native request: %v", err)
}
return request
}
approve := func(request domain.TelegramLoginRequest, hash int64) domain.TelegramLoginWebAuthorization {
t.Helper()
_, web, err := store.ApproveTelegramLoginRequest(ctx, domain.TelegramLoginApproval{
RequestID: request.ID, Identity: domain.TelegramLoginIdentitySnapshot{UserID: owner.ID, Name: "Native Owner", GivenName: "Native"},
ApprovedAt: now.Add(time.Second),
}, hash)
if err != nil {
t.Fatalf("approve native request: %v", err)
}
return web
}
revokedRequest := createRequest(fmt.Sprintf("revoked-%d", suffix))
revokedWeb := approve(revokedRequest, suffix+30_000)
code := domain.TelegramLoginAuthorizationCode{
RequestID: revokedRequest.ID, CodeHash: telegramLoginPGHash(fmt.Sprintf("native-code-%d", suffix)),
SealedCode: append(make([]byte, 32), 1), SealNonce: make([]byte, 12), SealKeyID: "integration-key",
IssuedAt: now.Add(2 * time.Second), ExpiresAt: now.Add(time.Minute),
}
if _, err := store.PutTelegramLoginAuthorizationCode(ctx, code); err != nil {
t.Fatal(err)
}
if _, _, _, err := store.ConsumeTelegramLoginAuthorizationCode(ctx, domain.TelegramLoginCodeExchange{
CodeHash: code.CodeHash, ClientID: client.ClientID, ClientSecretVersion: client.SecretVersion,
RedirectURI: callbackURI, CodeChallenge: revokedRequest.CodeChallenge, Now: now.Add(3 * time.Second),
}); err != nil {
t.Fatalf("consume native code: %v", err)
}
if ok, err := store.RevokeTelegramLoginWebAuthorization(ctx, owner.ID, revokedWeb.Hash, now.Add(4*time.Second)); err != nil || !ok {
t.Fatalf("revoke native authorization = %v,%v", ok, err)
}
activeRequest := createRequest(fmt.Sprintf("active-%d", suffix))
activeWeb := approve(activeRequest, suffix+40_000)
deleted, err := store.DeleteExpiredTelegramLoginArtifacts(ctx, now.Add(2*time.Hour), 100)
if err != nil {
t.Fatal(err)
}
if deleted < 2 {
t.Fatalf("retention deleted=%d, want at least code and revoked request", deleted)
}
if _, found, _ := store.GetTelegramLoginRequest(ctx, revokedRequest.ID); found {
t.Fatal("revoked native request survived retention")
}
if _, found, _ := store.GetTelegramLoginRequest(ctx, activeRequest.ID); !found {
t.Fatal("active native request was deleted")
}
listed, err := store.ListTelegramLoginWebAuthorizations(ctx, owner.ID)
if err != nil || len(listed) != 1 || listed[0].Hash != activeWeb.Hash {
t.Fatalf("active authorization list=%#v err=%v", listed, err)
}
assertTelegramLoginConfigDeleteTakesClientLock(t, pool, client.BotUserID, func() (bool, error) {
return store.DeleteTelegramLoginNativeApp(ctx, client.BotUserID, nativeApp.ID)
})
}
func assertTelegramLoginConfigDeleteTakesClientLock(t *testing.T, pool *pgxpool.Pool, botUserID int64, remove func() (bool, error)) {
t.Helper()
ctx := context.Background()
tx, err := pool.Begin(ctx)
if err != nil {
t.Fatal(err)
}
defer func() { _ = tx.Rollback(ctx) }()
var lockedID int64
if err := tx.QueryRow(ctx, `SELECT bot_user_id FROM bot_login_clients WHERE bot_user_id = $1 FOR UPDATE`, botUserID).Scan(&lockedID); err != nil {
t.Fatal(err)
}
result := make(chan error, 1)
go func() {
deleted, err := remove()
if err == nil && !deleted {
err = errors.New("configuration row was not deleted")
}
result <- err
}()
select {
case err := <-result:
t.Fatalf("configuration delete bypassed client serialization lock: %v", err)
case <-time.After(150 * time.Millisecond):
}
if err := tx.Commit(ctx); err != nil {
t.Fatal(err)
}
select {
case err := <-result:
if err != nil {
t.Fatal(err)
}
case <-time.After(5 * time.Second):
t.Fatal("configuration delete remained blocked after client lock committed")
}
}