new message on login

This commit is contained in:
onysd 2026-07-13 23:09:11 +03:00
parent 3d9b0f1de7
commit cee960fea0
9 changed files with 492 additions and 90 deletions

View file

@ -58,6 +58,41 @@ func TestEmailSignupSendCodeRoutesFreshSignupToEmail(t *testing.T) {
}
}
// Regression test: TELESRV_LOGIN_EMAIL_REQUIRE_SETUP=true (a real deployment
// combo, not just EMAIL_SIGNUP_ENABLE alone) used to permanently reject
// SignUp for every email-signup account with ErrCodeInvalid, because SignUp's
// "must have a verified/pending login email" gate only recognized the legacy
// VerifiedEmail/PendingEmail fields, which the email-signup path never sets.
func TestEmailSignupSignUpSucceedsWithLoginEmailRequireSetupAlsoOn(t *testing.T) {
ctx := context.Background()
users := memory.NewUserStore()
authz := memory.NewAuthorizationStore()
sender := &testMailSender{}
svc := NewService(users, authz, memory.NewCodeStore(), nil, nil, "12345",
WithLoginEmail(LoginEmailOptions{Sender: sender, RequireSetup: true, Enabled: true}),
WithEmailSignup(true))
phone, ok := domain.EncodeEmailPhone("requiresetup@owpengram.local")
if !ok {
t.Fatalf("EncodeEmailPhone: ok=false")
}
hash, err := svc.SendCode(ctx, phone)
if err != nil {
t.Fatalf("SendCode: %v", err)
}
if _, _, needSignUp, err := svc.SignInWithEmail(ctx, domain.Authorization{}, phone, hash, sender.code); err != nil || !needSignUp {
t.Fatalf("SignInWithEmail: needSignUp=%v err=%v", needSignUp, err)
}
created, _, err := svc.SignUp(ctx, domain.Authorization{}, phone, hash, "Needs", "Setup")
if err != nil {
t.Fatalf("SignUp: %v (this is the loop bug if it fails with ErrCodeInvalid)", err)
}
if created.Phone != phone {
t.Fatalf("created.Phone = %q, want %q", created.Phone, phone)
}
}
func TestEmailSignupSendCodeIgnoredWhenPhoneIsNotEncoded(t *testing.T) {
ctx := context.Background()
users := memory.NewUserStore()

View file

@ -291,7 +291,17 @@ func (s *Service) CompletePasswordSignIn(ctx context.Context, authKeyID [8]byte)
if s == nil || s.auths == nil {
return nil
}
return s.auths.MarkPasswordPassed(ctx, authKeyID)
if err := s.auths.MarkPasswordPassed(ctx, authKeyID); err != nil {
return err
}
// This is where a 2FA account's sign-in actually finishes — finishSignIn
// deliberately skipped the welcome message while password_pending.
if a, found, err := s.auths.ByAuthKey(ctx, authKeyID); err == nil && found {
if u, found, err := s.users.ByID(ctx, a.UserID); err == nil && found {
s.recordWelcomeMessage(ctx, u.ID, u.Phone)
}
}
return nil
}
// SendCode 为 phone 生成 phone_code_hash按配置选择开发 app code、登录邮箱 code
@ -893,6 +903,10 @@ func (s *Service) finishSignIn(ctx context.Context, auth domain.Authorization, e
if passwordNeeded {
return existing, domain.Message{}, false, domain.ErrSessionPasswordNeeded
}
// 2FA accounts only really finish authorizing in CompletePasswordSignIn;
// firing the welcome message here too would notify about an attempt that
// never actually got past the password check.
s.recordWelcomeMessage(ctx, existing.ID, existing.Phone)
return existing, domain.Message{}, false, nil
}
@ -936,7 +950,14 @@ func (s *Service) SignUp(ctx context.Context, auth domain.Authorization, phone,
if rec.Channel != codeChannelPhone && rec.Channel != codeChannelEmailLogin {
return domain.User{}, domain.Message{}, ErrCodeInvalid
}
if s.loginEmailRequireSetup && !rec.VerifiedEmail && strings.TrimSpace(rec.PendingEmail) == "" {
// Email-signup accounts (888-encoded phone) already proved ownership of
// their email through the code they just entered — their whole identity
// is that email. The separate loginEmailRequireSetup gate exists to force
// a *phone*-based account to additionally configure a recovery/login
// email via the legacy VerifiedEmail/PendingEmail flow; it does not apply
// here and would otherwise permanently block SignUp for every
// email-signup account.
if s.loginEmailRequireSetup && !rec.VerifiedEmail && strings.TrimSpace(rec.PendingEmail) == "" && !domain.IsEmailSignupPhone(phone) {
return domain.User{}, domain.Message{}, ErrCodeInvalid
}
if current, currentFound, err := s.currentPhoneOwner(ctx, phone); err != nil {
@ -991,13 +1012,16 @@ func (s *Service) SignUp(ctx context.Context, auth domain.Authorization, phone,
}
loginMessage := domain.Message{}
// SMTP setup/login codes are secret factors, not 777000 app messages. Only
// the normal phone/app-code registration path creates the bootstrap dialog.
// the normal phone/app-code registration path creates the bootstrap dialog
// carrying the actual code; every account additionally gets the
// welcome message below regardless of channel.
if rec.Channel == codeChannelPhone {
loginMessage, err = s.recordLoginMessage(ctx, u.ID, rec.Code)
if err != nil {
return domain.User{}, domain.Message{}, err
}
}
s.recordWelcomeMessage(ctx, u.ID, phone)
return u, loginMessage, nil
}
@ -1300,6 +1324,30 @@ func (s *Service) recordLoginMessage(ctx context.Context, userID int64, code str
return msg, nil
}
// recordWelcomeMessage writes the unconditional "Welcome to OwpenGram!"
// 777000 message for every completed sign-in (SignUp and every subsequent
// SignIn/SignInWithEmail), regardless of channel. Best-effort: a failure here
// must never fail the sign-in itself, since unlike recordLoginMessage it
// carries no secret the caller needs.
func (s *Service) recordWelcomeMessage(ctx context.Context, userID int64, phone string) {
if s == nil || s.messages == nil || s.dialogs == nil {
return
}
msg, err := domain.OfficialWelcomeMessage(userID, domain.SignInMethodLabel(phone), int(time.Now().Unix()))
if err != nil {
return
}
created, err := s.messages.Create(ctx, msg)
if err != nil {
return
}
_ = s.dialogs.UpsertInbox(ctx, userID, domain.Dialog{
Peer: created.Peer,
TopMessage: created.ID,
TopMessageDate: created.Date,
})
}
func (s *Service) validateBindTempAuthKey(ctx context.Context, sessionID int64, binding domain.TempAuthKeyBinding) (mtcrypto.BindAuthKeyInner, error) {
if binding.ExpiresAt <= int(time.Now().Unix()) {
return mtcrypto.BindAuthKeyInner{}, ErrEncryptedMessageInvalid

View file

@ -490,11 +490,15 @@ func TestSignUpWritesOfficialLoginMessage(t *testing.T) {
if msg.ID == 0 || !strings.Contains(msg.Body, "Login code: 12345") {
t.Fatalf("login message = %+v, want returned official login code message", msg)
}
if len(list.Messages) != 1 || !strings.Contains(list.Messages[0].Body, "Login code: 12345") {
t.Fatalf("messages = %+v, want login code message", list.Messages)
// SignUp now also writes an unconditional welcome message alongside the
// phone channel's login-code message, created after it — so it becomes
// the dialog's new top message (ListByUser surfaces the top message per
// dialog, not full history).
if len(list.Messages) != 1 || !strings.Contains(list.Messages[0].Body, "Welcome to OwpenGram") {
t.Fatalf("messages = %+v, want welcome message as new dialog top message", list.Messages)
}
if list.Dialogs[0].TopMessage != list.Messages[0].ID || list.Dialogs[0].UnreadCount != 1 {
t.Fatalf("dialog top/unread = %+v, message = %+v", list.Dialogs[0], list.Messages[0])
if list.Dialogs[0].TopMessage != list.Messages[0].ID || list.Dialogs[0].UnreadCount != 2 {
t.Fatalf("dialog top/unread = %+v, message = %+v, want unread=2 (code + welcome)", list.Dialogs[0], list.Messages[0])
}
}
@ -509,85 +513,83 @@ func TestSendCodeLoginMessagePreservesOfficialDialogReadWatermark(t *testing.T)
WithLoginCodeDelivery(delivery),
)
phone := "+15550004312"
peer := domain.Peer{Type: domain.PeerTypeUser, ID: domain.OfficialSystemUserID}
hash, err := svc.SendCode(ctx, phone)
if err != nil {
t.Fatalf("SendCode signup: %v", err)
}
verifyCodeForSignUp(t, svc, phone, hash, "12345")
u, first, err := svc.SignUp(ctx, domain.Authorization{}, phone, hash, "Test", "User")
u, _, err := svc.SignUp(ctx, domain.Authorization{}, phone, hash, "Test", "User")
if err != nil {
t.Fatalf("SignUp: %v", err)
}
peer := domain.Peer{Type: domain.PeerTypeUser, ID: domain.OfficialSystemUserID}
if read, err := dialogs.MarkRead(ctx, u.ID, peer, domain.MaxMessageBoxID); err != nil {
t.Fatalf("MarkRead first login message: %v", err)
} else if read.MaxID != first.ID || read.StillUnreadCount != 0 {
t.Fatalf("read first login message = %+v, want max_id %d unread 0", read, first.ID)
}
assertOfficialDialog := func(wantTop, wantRead, wantUnread int) {
dialogState := func() domain.Dialog {
t.Helper()
list, err := dialogs.ListByUser(ctx, u.ID, domain.DialogFilter{Limit: 10})
if err != nil {
t.Fatalf("ListByUser: %v", err)
if err != nil || len(list.Dialogs) != 1 {
t.Fatalf("ListByUser: dialogs=%+v err=%v, want exactly one official dialog", list.Dialogs, err)
}
if len(list.Dialogs) != 1 {
t.Fatalf("dialogs = %+v, want official dialog", list.Dialogs)
}
got := list.Dialogs[0]
if got.TopMessage != wantTop || got.ReadInboxMaxID != wantRead || got.UnreadCount != wantUnread {
t.Fatalf("dialog = %+v, want top=%d read=%d unread=%d", got, wantTop, wantRead, wantUnread)
}
}
latestLoginMessage := func(wantCount int) domain.Message {
t.Helper()
history, err := messages.ListByUser(ctx, u.ID, domain.MessageFilter{
HasPeer: true,
Peer: peer,
Limit: 10,
})
if err != nil || len(history.Messages) != wantCount {
t.Fatalf("official history count=%d err=%v, want %d", len(history.Messages), err, wantCount)
}
latest := history.Messages[0]
for _, msg := range history.Messages[1:] {
if msg.ID > latest.ID {
latest = msg
}
}
return latest
return list.Dialogs[0]
}
// SignUp writes the code-echo message, then the unconditional welcome
// message: two unread messages, dialog top is the welcome message.
afterSignUp := dialogState()
if afterSignUp.UnreadCount != 2 {
t.Fatalf("dialog after SignUp = %+v, want 2 unread (code + welcome)", afterSignUp)
}
readWatermark := afterSignUp.TopMessage
if read, err := dialogs.MarkRead(ctx, u.ID, peer, domain.MaxMessageBoxID); err != nil {
t.Fatalf("MarkRead after SignUp: %v", err)
} else if read.MaxID != readWatermark || read.StillUnreadCount != 0 {
t.Fatalf("read after SignUp = %+v, want max_id %d unread 0", read, readWatermark)
}
// A repeat SendCode on an existing account delivers a new code message
// before SignIn even runs. This must not reset the read watermark just
// established above — only the fresh message should count as unread.
hash, err = svc.SendCode(ctx, phone)
if err != nil {
t.Fatalf("SendCode signin second: %v", err)
}
second := latestLoginMessage(2)
// 核心时序SendCode 返回时 message/dialog/unread 已提交,尚未 SignIn。
assertOfficialDialog(second.ID, first.ID, 1)
_, signInMessage, needSignUp, err := svc.SignIn(ctx, domain.Authorization{}, phone, hash, "12345")
if err != nil || needSignUp {
t.Fatalf("SignIn second needSignUp=%v err=%v", needSignUp, err)
afterSecondSendCode := dialogState()
if afterSecondSendCode.ReadInboxMaxID != readWatermark || afterSecondSendCode.UnreadCount != 1 {
t.Fatalf("dialog after second SendCode = %+v, want read=%d unread=1", afterSecondSendCode, readWatermark)
}
if signInMessage.ID != 0 {
// Completing SignIn adds its own welcome message (a second, independent
// source of new messages) without touching the read watermark either.
if _, signInMessage, needSignUp, err := svc.SignIn(ctx, domain.Authorization{}, phone, hash, "12345"); err != nil || needSignUp {
t.Fatalf("SignIn second needSignUp=%v err=%v", needSignUp, err)
} else if signInMessage.ID != 0 {
t.Fatalf("SignIn second returned a late login message %+v", signInMessage)
}
assertOfficialDialog(second.ID, first.ID, 1)
afterSecondSignIn := dialogState()
if afterSecondSignIn.ReadInboxMaxID != readWatermark || afterSecondSignIn.UnreadCount != 2 {
t.Fatalf("dialog after second SignIn = %+v, want read=%d unread=2 (new code + its own welcome message)", afterSecondSignIn, readWatermark)
}
// One more full round trip to make sure the watermark keeps holding
// across repeated cycles, not just the first one.
hash, err = svc.SendCode(ctx, phone)
if err != nil {
t.Fatalf("SendCode signin third: %v", err)
}
third := latestLoginMessage(3)
assertOfficialDialog(third.ID, first.ID, 2)
_, signInMessage, needSignUp, err = svc.SignIn(ctx, domain.Authorization{}, phone, hash, "12345")
if err != nil || needSignUp {
t.Fatalf("SignIn third needSignUp=%v err=%v", needSignUp, err)
afterThirdSendCode := dialogState()
if afterThirdSendCode.ReadInboxMaxID != readWatermark || afterThirdSendCode.UnreadCount != 3 {
t.Fatalf("dialog after third SendCode = %+v, want read=%d unread=3", afterThirdSendCode, readWatermark)
}
if signInMessage.ID != 0 {
if _, signInMessage, needSignUp, err := svc.SignIn(ctx, domain.Authorization{}, phone, hash, "12345"); err != nil || needSignUp {
t.Fatalf("SignIn third needSignUp=%v err=%v", needSignUp, err)
} else if signInMessage.ID != 0 {
t.Fatalf("SignIn third returned a late login message %+v", signInMessage)
}
assertOfficialDialog(third.ID, first.ID, 2)
afterThirdSignIn := dialogState()
if afterThirdSignIn.ReadInboxMaxID != readWatermark || afterThirdSignIn.UnreadCount != 4 {
t.Fatalf("dialog after third SignIn = %+v, want read=%d unread=4", afterThirdSignIn, readWatermark)
}
}
func TestSignInExistingTwoFactorAccountNeedsPassword(t *testing.T) {

View file

@ -3,6 +3,7 @@ package auth
import (
"context"
"errors"
"strings"
"sync"
"testing"
"time"
@ -311,7 +312,7 @@ func TestOwnerTransferAwayAndBackCannotReviveLoginHash(t *testing.T) {
})
}
func TestEmailSetupVerificationAuthorizesSignUpWithout777000Message(t *testing.T) {
func TestEmailSetupVerificationAuthorizesSignUpWithWelcomeMessageOnlyNoCodeEcho(t *testing.T) {
ctx := context.Background()
users := memory.NewUserStore()
codes := memory.NewCodeStore()
@ -364,6 +365,11 @@ func TestEmailSetupVerificationAuthorizesSignUpWithout777000Message(t *testing.T
if err != nil {
t.Fatalf("SignUp after email setup: %v", err)
}
// The SMTP setup code itself is still never echoed back as a 777000
// message (it's a secret factor) — SignUp's own explicit return stays
// empty for the email channel. The unconditional welcome message (added
// for every completed sign-in, regardless of channel) is a separate,
// non-secret message verified below.
if msg.ID != 0 || msg.Body != "" {
t.Fatalf("email SignUp returned SMTP code message: %+v", msg)
}
@ -371,8 +377,8 @@ func TestEmailSetupVerificationAuthorizesSignUpWithout777000Message(t *testing.T
if err != nil {
t.Fatalf("ListByUser: %v", err)
}
if len(list.Dialogs) != 0 || len(list.Messages) != 0 {
t.Fatalf("email SignUp created 777000 bootstrap state: dialogs=%+v messages=%+v", list.Dialogs, list.Messages)
if len(list.Dialogs) != 1 || len(list.Messages) != 1 || !strings.Contains(list.Messages[0].Body, "Welcome to OwpenGram") {
t.Fatalf("email SignUp welcome message = dialogs=%+v messages=%+v, want exactly one welcome message", list.Dialogs, list.Messages)
}
if email, found, err := accountSvc.LoginEmailByPhone(ctx, phone); err != nil || !found || email != "new@example.test" {
t.Fatalf("LoginEmailByPhone email=%q found=%v err=%v", email, found, err)

View file

@ -0,0 +1,168 @@
package auth
import (
"context"
"strings"
"testing"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
)
func TestEmailSignupSignUpWritesWelcomeMessageMentioningEmail(t *testing.T) {
ctx := context.Background()
dialogs := memory.NewDialogStore()
messages := memory.NewMessageStore(dialogs)
sender := &testMailSender{}
svc := NewService(memory.NewUserStore(), memory.NewAuthorizationStore(), memory.NewCodeStore(), nil, nil, "12345",
WithLoginMessages(messages, dialogs),
WithLoginEmail(LoginEmailOptions{Sender: sender}),
WithEmailSignup(true))
phone, ok := domain.EncodeEmailPhone("welcome@owpengram.local")
if !ok {
t.Fatalf("EncodeEmailPhone: ok=false")
}
hash, err := svc.SendCode(ctx, phone)
if err != nil {
t.Fatalf("SendCode: %v", err)
}
if _, _, needSignUp, err := svc.SignInWithEmail(ctx, domain.Authorization{}, phone, hash, sender.code); err != nil || !needSignUp {
t.Fatalf("SignInWithEmail: needSignUp=%v err=%v", needSignUp, err)
}
u, _, err := svc.SignUp(ctx, domain.Authorization{}, phone, hash, "Welcome", "User")
if err != nil {
t.Fatalf("SignUp: %v", err)
}
list, err := dialogs.ListByUser(ctx, u.ID, domain.DialogFilter{Limit: 10})
if err != nil {
t.Fatalf("ListByUser: %v", err)
}
if len(list.Messages) != 1 {
t.Fatalf("messages = %+v, want exactly the welcome message (email channel skips the code-echo message)", list.Messages)
}
if !strings.Contains(list.Messages[0].Body, "Welcome to OwpenGram") || !strings.Contains(list.Messages[0].Body, "via email") {
t.Fatalf("welcome message body = %q, want greeting mentioning email", list.Messages[0].Body)
}
}
func TestSignInWritesWelcomeMessageOnEveryLogin(t *testing.T) {
ctx := context.Background()
dialogs := memory.NewDialogStore()
messages := memory.NewMessageStore(dialogs)
delivery := memory.NewLoginCodeDeliveryStore(messages, memory.NewUpdateEventStore())
svc := NewService(memory.NewUserStore(), memory.NewAuthorizationStore(), memory.NewCodeStore(), nil, nil, "12345",
WithLoginMessages(messages, dialogs),
WithLoginCodeDelivery(delivery),
)
var key [8]byte
key[0] = 42
hash, err := svc.SendCode(ctx, "+15550009911")
if err != nil {
t.Fatalf("SendCode signup: %v", err)
}
verifyCodeForSignUp(t, svc, "+15550009911", hash, "12345")
u, _, err := svc.SignUp(ctx, domain.Authorization{AuthKeyID: key}, "+15550009911", hash, "Repeat", "Login")
if err != nil {
t.Fatalf("SignUp: %v", err)
}
if err := svc.LogOut(ctx, key); err != nil {
t.Fatalf("LogOut: %v", err)
}
// A second, independent login (different device/session) must also get a
// fresh welcome message, not just the original SignUp.
hash, err = svc.SendCode(ctx, "+15550009911")
if err != nil {
t.Fatalf("SendCode signin: %v", err)
}
var key2 [8]byte
key2[0] = 43
if _, _, _, err := svc.SignIn(ctx, domain.Authorization{AuthKeyID: key2}, "+15550009911", hash, "12345"); err != nil {
t.Fatalf("SignIn: %v", err)
}
list, err := dialogs.ListByUser(ctx, u.ID, domain.DialogFilter{Limit: 10})
if err != nil {
t.Fatalf("ListByUser: %v", err)
}
if len(list.Messages) != 1 || !strings.Contains(list.Messages[0].Body, "Welcome to OwpenGram") {
t.Fatalf("messages = %+v, want the second sign-in's fresh welcome message as new top message", list.Messages)
}
// SignUp's welcome + login-code message, plus SendCode's re-delivered code
// message, plus the second sign-in's welcome message.
if list.Dialogs[0].UnreadCount < 3 {
t.Fatalf("dialog unread = %+v, want at least 3 accumulated messages across both logins", list.Dialogs[0])
}
}
func TestTwoFactorSignInDefersWelcomeMessageUntilPasswordCompletes(t *testing.T) {
ctx := context.Background()
passwords := memory.NewPasswordStore()
dialogs := memory.NewDialogStore()
messages := memory.NewMessageStore(dialogs)
delivery := memory.NewLoginCodeDeliveryStore(messages, memory.NewUpdateEventStore())
svc := NewService(memory.NewUserStore(), memory.NewAuthorizationStore(), memory.NewCodeStore(), nil, nil, "12345",
WithPasswords(passwords),
WithLoginMessages(messages, dialogs),
WithLoginCodeDelivery(delivery),
)
var key [8]byte
key[0] = 9
hash, err := svc.SendCode(ctx, "+15550009922")
if err != nil {
t.Fatalf("SendCode signup: %v", err)
}
verifyCodeForSignUp(t, svc, "+15550009922", hash, "12345")
u, _, err := svc.SignUp(ctx, domain.Authorization{AuthKeyID: key}, "+15550009922", hash, "Two", "Factor")
if err != nil {
t.Fatalf("SignUp: %v", err)
}
if err := svc.LogOut(ctx, key); err != nil {
t.Fatalf("LogOut: %v", err)
}
if err := passwords.Save(ctx, u.ID, domain.PasswordSettings{HasPassword: true}); err != nil {
t.Fatalf("save password settings: %v", err)
}
afterSignUp, err := dialogs.ListByUser(ctx, u.ID, domain.DialogFilter{Limit: 10})
if err != nil {
t.Fatalf("ListByUser after signup: %v", err)
}
unreadAfterSignUp := afterSignUp.Dialogs[0].UnreadCount
hash, err = svc.SendCode(ctx, "+15550009922")
if err != nil {
t.Fatalf("SendCode signin: %v", err)
}
if _, _, _, err := svc.SignIn(ctx, domain.Authorization{AuthKeyID: key}, "+15550009922", hash, "12345"); err == nil {
t.Fatalf("SignIn err = nil, want ErrSessionPasswordNeeded")
}
// Still pending 2FA: no welcome message yet, only the re-delivered code.
pending, err := dialogs.ListByUser(ctx, u.ID, domain.DialogFilter{Limit: 10})
if err != nil {
t.Fatalf("ListByUser pending: %v", err)
}
if strings.Contains(pending.Messages[0].Body, "Welcome to OwpenGram") {
t.Fatalf("welcome message fired before password check completed: %+v", pending.Messages[0])
}
if err := svc.CompletePasswordSignIn(ctx, key); err != nil {
t.Fatalf("CompletePasswordSignIn: %v", err)
}
done, err := dialogs.ListByUser(ctx, u.ID, domain.DialogFilter{Limit: 10})
if err != nil {
t.Fatalf("ListByUser done: %v", err)
}
if len(done.Messages) != 1 || !strings.Contains(done.Messages[0].Body, "Welcome to OwpenGram") {
t.Fatalf("messages after CompletePasswordSignIn = %+v, want fresh welcome message", done.Messages)
}
if done.Dialogs[0].UnreadCount <= unreadAfterSignUp {
t.Fatalf("unread did not grow after CompletePasswordSignIn: before=%d after=%d", unreadAfterSignUp, done.Dialogs[0].UnreadCount)
}
}