Compare commits
3 commits
2e60301a08
...
c1c7db325f
| Author | SHA1 | Date | |
|---|---|---|---|
| c1c7db325f | |||
| 798ea70b4e | |||
| 7f0921c82c |
8 changed files with 184 additions and 5 deletions
|
|
@ -27,11 +27,14 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/binary"
|
"encoding/binary"
|
||||||
|
"errors"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
"telesrv/internal/config"
|
"telesrv/internal/config"
|
||||||
|
|
@ -124,7 +127,7 @@ func main() {
|
||||||
|
|
||||||
var createdID int64
|
var createdID int64
|
||||||
if err := row.Scan(&createdID); err != nil {
|
if err := row.Scan(&createdID); err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "createuser: id %d already exists (or insert failed): %v\n", *id, err)
|
fmt.Fprintln(os.Stderr, describeInsertFailure(*id, *username, displayPhone, signupEmail, err))
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -132,6 +135,31 @@ func main() {
|
||||||
createdID, accessHash, *firstName, *lastName, *username, displayPhone, signupEmail)
|
createdID, accessHash, *firstName, *lastName, *username, displayPhone, signupEmail)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// describeInsertFailure turns the INSERT's failure into a message naming the
|
||||||
|
// actual thing that collided, instead of "id already exists" for every case:
|
||||||
|
// ON CONFLICT (id) DO NOTHING only covers the id itself, so a duplicate
|
||||||
|
// username/phone/signup_email surfaces here as a distinct unique-violation
|
||||||
|
// error (pgx.ErrNoRows only means the id itself was the conflict).
|
||||||
|
func describeInsertFailure(id int64, username, phone, signupEmail string, err error) string {
|
||||||
|
var pgErr *pgconn.PgError
|
||||||
|
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
||||||
|
switch pgErr.ConstraintName {
|
||||||
|
case "users_username_lower_unique_idx":
|
||||||
|
return fmt.Sprintf("createuser: username %q is already taken", username)
|
||||||
|
case "users_phone_unique_idx":
|
||||||
|
return fmt.Sprintf("createuser: phone %q is already in use", phone)
|
||||||
|
case "users_signup_email_lower_unique_idx":
|
||||||
|
return fmt.Sprintf("createuser: email %q is already in use by another account", signupEmail)
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("createuser: unique constraint %q violated: %v", pgErr.ConstraintName, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return fmt.Sprintf("createuser: id %d already exists", id)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("createuser: insert failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
// assignEmailSignupDisplayPhone mirrors internal/app/auth/service.go's method
|
// assignEmailSignupDisplayPhone mirrors internal/app/auth/service.go's method
|
||||||
// of the same name: pick a random "888"-prefixed display phone and re-roll on
|
// of the same name: pick a random "888"-prefixed display phone and re-roll on
|
||||||
// the astronomically unlikely collision with an existing account's phone.
|
// the astronomically unlikely collision with an existing account's phone.
|
||||||
|
|
|
||||||
|
|
@ -769,7 +769,16 @@ func (s *Service) putCachedUsers(ctx context.Context, users ...domain.User) {
|
||||||
// Collectible ownership may change inside the star-gift aggregate. Keep
|
// Collectible ownership may change inside the star-gift aggregate. Keep
|
||||||
// these uncommon users on the authoritative store path so the database
|
// these uncommon users on the authoritative store path so the database
|
||||||
// lifecycle trigger can never be masked by a stale base-user cache entry.
|
// lifecycle trigger can never be masked by a stale base-user cache entry.
|
||||||
if user.ID != 0 && user.EmojiStatusCollectible.Empty() {
|
//
|
||||||
|
// redisstore.userBaseValue has no field for Deleted/DeletedAt/Status: caching
|
||||||
|
// a deleted user silently resets Deleted to false (and Status to the zero
|
||||||
|
// UserStatusUnknown) on every round trip, which never self-heals -- each
|
||||||
|
// subsequent miss reloads the correctly tombstoned DB row and immediately
|
||||||
|
// re-corrupts it on write. That regressed a deleted account back to looking
|
||||||
|
// live (blank name, but "last seen recently" instead of "Deleted Account").
|
||||||
|
// Keep deleted users off the cache so lookups always hit the authoritative
|
||||||
|
// store, which encodes the tombstone correctly.
|
||||||
|
if user.ID != 0 && user.EmojiStatusCollectible.Empty() && !user.Deleted {
|
||||||
cacheable = append(cacheable, user)
|
cacheable = append(cacheable, user)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -397,6 +397,69 @@ func TestServiceUsesBaseCacheWithoutCachingViewerOverlay(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// deletedOverrideUserStore reports one user id as an already-tombstoned
|
||||||
|
// domain.User regardless of what the underlying memory store holds, so the
|
||||||
|
// test doesn't need a memory.UserStore deletion helper to exercise the
|
||||||
|
// base-user cache's handling of deleted users.
|
||||||
|
type deletedOverrideUserStore struct {
|
||||||
|
*memory.UserStore
|
||||||
|
deletedID int64
|
||||||
|
byIDsCalls int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *deletedOverrideUserStore) ByIDs(ctx context.Context, ids []int64) ([]domain.User, error) {
|
||||||
|
s.byIDsCalls++
|
||||||
|
users, err := s.UserStore.ByIDs(ctx, ids)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for i, u := range users {
|
||||||
|
if u.ID == s.deletedID {
|
||||||
|
users[i] = domain.User{ID: u.ID, Deleted: true, Status: domain.UserStatus{Kind: domain.UserStatusEmpty}}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return users, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestServiceNeverCachesDeletedUser guards the redisstore.UserCache cache
|
||||||
|
// schema gap: userBaseValue carries no Deleted/DeletedAt/Status field, so
|
||||||
|
// caching a deleted user silently resets Deleted to false (and Status to the
|
||||||
|
// zero UserStatusUnknown) on every round trip -- a bug that never self-heals,
|
||||||
|
// since each subsequent cache miss reloads the correctly tombstoned row and
|
||||||
|
// immediately re-corrupts it on write. It regressed a deleted account back to
|
||||||
|
// looking live: blank name (still blank, that part survives), but "last seen
|
||||||
|
// recently" instead of "Deleted Account". The service must keep deleted users
|
||||||
|
// off the base cache entirely so every lookup hits the authoritative store.
|
||||||
|
func TestServiceNeverCachesDeletedUser(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
base := memory.NewUserStore()
|
||||||
|
owner, err := base.Create(ctx, domain.User{AccessHash: 1, Phone: "15550000031", FirstName: "Owner"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create owner: %v", err)
|
||||||
|
}
|
||||||
|
target, err := base.Create(ctx, domain.User{AccessHash: 2, Phone: "15550000032", FirstName: "Target"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create target: %v", err)
|
||||||
|
}
|
||||||
|
store := &deletedOverrideUserStore{UserStore: base, deletedID: target.ID}
|
||||||
|
cache := newMemoryBaseUserCache()
|
||||||
|
svc := NewService(store, WithBaseUserCache(cache))
|
||||||
|
|
||||||
|
got, found, err := svc.ByID(ctx, owner.ID, target.ID)
|
||||||
|
if err != nil || !found || !got.Deleted {
|
||||||
|
t.Fatalf("ByID = %+v found=%v err=%v, want a deleted user", got, found, err)
|
||||||
|
}
|
||||||
|
if _, cached := cache.users[target.ID]; cached {
|
||||||
|
t.Fatalf("cache holds deleted user %d, want it kept off the cache entirely", target.ID)
|
||||||
|
}
|
||||||
|
if _, found, err := svc.ByID(ctx, owner.ID, target.ID); err != nil || !found {
|
||||||
|
t.Fatalf("second ByID found=%v err=%v", found, err)
|
||||||
|
}
|
||||||
|
if store.byIDsCalls != 2 {
|
||||||
|
t.Fatalf("store ByIDs calls = %d, want 2 (deleted user must never be served from cache)", store.byIDsCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestServiceRefreshesBaseCacheAfterProfileUpdate(t *testing.T) {
|
func TestServiceRefreshesBaseCacheAfterProfileUpdate(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
base := memory.NewUserStore()
|
base := memory.NewUserStore()
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import (
|
||||||
|
|
||||||
var (
|
var (
|
||||||
ErrPasswordHashInvalid = errors.New("password hash invalid")
|
ErrPasswordHashInvalid = errors.New("password hash invalid")
|
||||||
|
ErrPasswordMissing = errors.New("password missing")
|
||||||
ErrSRPIDInvalid = errors.New("srp id invalid")
|
ErrSRPIDInvalid = errors.New("srp id invalid")
|
||||||
ErrSRPPasswordChanged = errors.New("srp password changed")
|
ErrSRPPasswordChanged = errors.New("srp password changed")
|
||||||
ErrNewSettingsInvalid = errors.New("new password settings invalid")
|
ErrNewSettingsInvalid = errors.New("new password settings invalid")
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import (
|
||||||
"github.com/iamxvbaba/td/tgerr"
|
"github.com/iamxvbaba/td/tgerr"
|
||||||
"go.uber.org/zap/zaptest"
|
"go.uber.org/zap/zaptest"
|
||||||
|
|
||||||
|
appaccount "telesrv/internal/app/account"
|
||||||
appchannels "telesrv/internal/app/channels"
|
appchannels "telesrv/internal/app/channels"
|
||||||
appusers "telesrv/internal/app/users"
|
appusers "telesrv/internal/app/users"
|
||||||
"telesrv/internal/domain"
|
"telesrv/internal/domain"
|
||||||
|
|
@ -26,6 +27,10 @@ func (acceptPasswordAccountService) CheckPassword(_ context.Context, _ int64, ch
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (acceptPasswordAccountService) GetPassword(_ context.Context, _ int64) (domain.PasswordSettings, error) {
|
||||||
|
return domain.PasswordSettings{HasPassword: true}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func TestMessagesGetFutureChatCreatorAfterLeaveAndCreatorLeaveTransfers(t *testing.T) {
|
func TestMessagesGetFutureChatCreatorAfterLeaveAndCreatorLeaveTransfers(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
userStore := memory.NewUserStore()
|
userStore := memory.NewUserStore()
|
||||||
|
|
@ -242,6 +247,58 @@ func TestMessagesEditChatCreatorTransfersWithoutChannelPts(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestMessagesEditChatCreatorRequiresPasswordSetup covers an owner who has never
|
||||||
|
// enabled two-step verification: the client's transfer-ownership probe
|
||||||
|
// (inputUserEmpty + inputCheckPasswordEmpty) must get PASSWORD_MISSING, not
|
||||||
|
// PASSWORD_HASH_INVALID -- the desktop client only recognizes PASSWORD_MISSING
|
||||||
|
// to show its "enable 2FA first" box, and otherwise falls through into a real
|
||||||
|
// password-entry flow it can't satisfy.
|
||||||
|
func TestMessagesEditChatCreatorRequiresPasswordSetup(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
userStore := memory.NewUserStore()
|
||||||
|
owner, err := userStore.Create(ctx, domain.User{AccessHash: 9221, Phone: "15550009221", FirstName: "Owner"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create owner: %v", err)
|
||||||
|
}
|
||||||
|
member, err := userStore.Create(ctx, domain.User{AccessHash: 9222, Phone: "15550009222", FirstName: "Member"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create member: %v", err)
|
||||||
|
}
|
||||||
|
channelStore := memory.NewChannelStore()
|
||||||
|
channelService := appchannels.NewService(channelStore)
|
||||||
|
r := New(Config{}, Deps{
|
||||||
|
Account: appaccount.NewService(memory.NewPasswordStore()),
|
||||||
|
Users: appusers.NewService(userStore),
|
||||||
|
Channels: channelService,
|
||||||
|
}, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700009130, 0)})
|
||||||
|
created, err := channelService.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{
|
||||||
|
CreatorUserID: owner.ID,
|
||||||
|
Title: "no password owner",
|
||||||
|
Megagroup: true,
|
||||||
|
MemberUserIDs: []int64{member.ID},
|
||||||
|
Date: 1700009130,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create channel: %v", err)
|
||||||
|
}
|
||||||
|
ownerCtx := WithUserID(ctx, owner.ID)
|
||||||
|
peer := &tg.InputPeerChannel{ChannelID: created.Channel.ID, AccessHash: created.Channel.AccessHash}
|
||||||
|
if _, err := r.onMessagesEditChatCreator(ownerCtx, &tg.MessagesEditChatCreatorRequest{
|
||||||
|
Peer: peer,
|
||||||
|
UserID: &tg.InputUserEmpty{},
|
||||||
|
Password: &tg.InputCheckPasswordEmpty{},
|
||||||
|
}); err == nil || !tgerr.Is(err, "PASSWORD_MISSING") {
|
||||||
|
t.Fatalf("editChatCreator probe err = %v, want PASSWORD_MISSING", err)
|
||||||
|
}
|
||||||
|
if _, err := r.onMessagesEditChatCreator(ownerCtx, &tg.MessagesEditChatCreatorRequest{
|
||||||
|
Peer: peer,
|
||||||
|
UserID: &tg.InputUser{UserID: member.ID, AccessHash: member.AccessHash},
|
||||||
|
Password: &tg.InputCheckPasswordSRP{SRPID: 1, A: []byte{1}, M1: []byte{2}},
|
||||||
|
}); err == nil || !tgerr.Is(err, "PASSWORD_MISSING") {
|
||||||
|
t.Fatalf("editChatCreator transfer err = %v, want PASSWORD_MISSING", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestMessagesGetFutureChatCreatorAfterLeaveNoCandidate(t *testing.T) {
|
func TestMessagesGetFutureChatCreatorAfterLeaveNoCandidate(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
userStore := memory.NewUserStore()
|
userStore := memory.NewUserStore()
|
||||||
|
|
|
||||||
|
|
@ -339,6 +339,20 @@ func (r *Router) onMessagesEditChatCreator(ctx context.Context, req *tg.Messages
|
||||||
if req.Password == nil {
|
if req.Password == nil {
|
||||||
return nil, passwordHashInvalidErr()
|
return nil, passwordHashInvalidErr()
|
||||||
}
|
}
|
||||||
|
if r.deps.Account == nil {
|
||||||
|
return nil, passwordHashInvalidErr()
|
||||||
|
}
|
||||||
|
// 转让所有权无条件要求已开启两步验证:先探测账号是否设有密码,
|
||||||
|
// 让 messages.editChatCreator 的探测请求(inputUserEmpty + inputCheckPasswordEmpty)
|
||||||
|
// 拿到 PASSWORD_MISSING 而非 PASSWORD_HASH_INVALID —— 客户端只识别前者来展示
|
||||||
|
// “请先开启两步验证”提示,否则会误入真实密码校验流程并在没有密码可核对时崩溃。
|
||||||
|
passwordSettings, err := r.deps.Account.GetPassword(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, internalErr()
|
||||||
|
}
|
||||||
|
if !passwordSettings.HasPassword {
|
||||||
|
return nil, passwordMissingErr()
|
||||||
|
}
|
||||||
if _, ok := req.UserID.(*tg.InputUserEmpty); ok {
|
if _, ok := req.UserID.(*tg.InputUserEmpty); ok {
|
||||||
return nil, passwordHashInvalidErr()
|
return nil, passwordHashInvalidErr()
|
||||||
}
|
}
|
||||||
|
|
@ -355,9 +369,6 @@ func (r *Router) onMessagesEditChatCreator(ctx context.Context, req *tg.Messages
|
||||||
if target.Bot {
|
if target.Bot {
|
||||||
return nil, userIDInvalidErr()
|
return nil, userIDInvalidErr()
|
||||||
}
|
}
|
||||||
if r.deps.Account == nil {
|
|
||||||
return nil, passwordHashInvalidErr()
|
|
||||||
}
|
|
||||||
if err := r.deps.Account.CheckPassword(ctx, userID, domainPasswordCheck(req.Password)); err != nil {
|
if err := r.deps.Account.CheckPassword(ctx, userID, domainPasswordCheck(req.Password)); err != nil {
|
||||||
return nil, passwordErr(err)
|
return nil, passwordErr(err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -291,6 +291,7 @@ func scoreInvalidErr() error { return tgerr.New(400, "SCORE_INVALID") }
|
||||||
|
|
||||||
func sessionPasswordNeededErr() error { return tgerr.New(401, "SESSION_PASSWORD_NEEDED") }
|
func sessionPasswordNeededErr() error { return tgerr.New(401, "SESSION_PASSWORD_NEEDED") }
|
||||||
func passwordHashInvalidErr() error { return tgerr.New(400, "PASSWORD_HASH_INVALID") }
|
func passwordHashInvalidErr() error { return tgerr.New(400, "PASSWORD_HASH_INVALID") }
|
||||||
|
func passwordMissingErr() error { return tgerr.New(400, "PASSWORD_MISSING") }
|
||||||
func srpIDInvalidErr() error { return tgerr.New(400, "SRP_ID_INVALID") }
|
func srpIDInvalidErr() error { return tgerr.New(400, "SRP_ID_INVALID") }
|
||||||
func srpPasswordChangedErr() error { return tgerr.New(400, "SRP_PASSWORD_CHANGED") }
|
func srpPasswordChangedErr() error { return tgerr.New(400, "SRP_PASSWORD_CHANGED") }
|
||||||
func newSettingsInvalidErr() error { return tgerr.New(400, "NEW_SETTINGS_INVALID") }
|
func newSettingsInvalidErr() error { return tgerr.New(400, "NEW_SETTINGS_INVALID") }
|
||||||
|
|
@ -468,6 +469,8 @@ func passwordErr(err error) error {
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, domain.ErrPasswordHashInvalid):
|
case errors.Is(err, domain.ErrPasswordHashInvalid):
|
||||||
return passwordHashInvalidErr()
|
return passwordHashInvalidErr()
|
||||||
|
case errors.Is(err, domain.ErrPasswordMissing):
|
||||||
|
return passwordMissingErr()
|
||||||
case errors.Is(err, domain.ErrSRPIDInvalid):
|
case errors.Is(err, domain.ErrSRPIDInvalid):
|
||||||
return srpIDInvalidErr()
|
return srpIDInvalidErr()
|
||||||
case errors.Is(err, domain.ErrSRPPasswordChanged):
|
case errors.Is(err, domain.ErrSRPPasswordChanged):
|
||||||
|
|
|
||||||
|
|
@ -531,6 +531,13 @@ func (r *Router) withUserPresence(u domain.User) domain.User {
|
||||||
if u.Bot {
|
if u.Bot {
|
||||||
return u
|
return u
|
||||||
}
|
}
|
||||||
|
// 已注销账号同理不参与 presence:tgUser/tgSelfUser 对 Deleted 用户直接短路输出
|
||||||
|
// 精简 tombstone,从不读取 Status,这里覆盖与否本应无观测差异——但只靠那一层
|
||||||
|
// 短路里应外合:任何上游把 Deleted 弄丢的 bug(例如缓存往返丢字段)都会让这里
|
||||||
|
// 覆盖出的 Status 变成可见的“最近上线”,掩盖真正的 bug 而不是让它更早炸出来。
|
||||||
|
if u.Deleted {
|
||||||
|
return u
|
||||||
|
}
|
||||||
u.Status = r.userPresenceStatusForUser(u)
|
u.Status = r.userPresenceStatusForUser(u)
|
||||||
return u
|
return u
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue