perf: sync protocol and core hardening updates
This commit is contained in:
parent
152fed3b87
commit
4390ebf5a9
283 changed files with 29231 additions and 2295 deletions
68
internal/store/memory/album_group.go
Normal file
68
internal/store/memory/album_group.go
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
type albumGroupKey struct {
|
||||
senderUserID int64
|
||||
peerType domain.PeerType
|
||||
peerID int64
|
||||
randomID int64
|
||||
}
|
||||
|
||||
type albumGroupRecord struct {
|
||||
groupedID int64
|
||||
intentHash [32]byte
|
||||
}
|
||||
|
||||
// ReserveAlbumGroup 在 MessageStore 的同一把互斥锁下完成读旧组、选胜者与补齐绑定,
|
||||
// 因而并发重叠批次不会产生拆组。
|
||||
func (s *MessageStore) ReserveAlbumGroup(_ context.Context, req domain.AlbumGroupReservationRequest) (int64, error) {
|
||||
if err := req.Validate(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.albumGroups == nil {
|
||||
s.albumGroups = make(map[albumGroupKey]albumGroupRecord)
|
||||
}
|
||||
|
||||
groupedID := int64(0)
|
||||
type pendingBinding struct {
|
||||
key albumGroupKey
|
||||
intentHash [32]byte
|
||||
}
|
||||
bindings := make([]pendingBinding, 0, len(req.Items))
|
||||
for _, item := range req.Items {
|
||||
key := albumGroupKey{
|
||||
senderUserID: req.SenderUserID,
|
||||
peerType: req.Peer.Type,
|
||||
peerID: req.Peer.ID,
|
||||
randomID: item.RandomID,
|
||||
}
|
||||
var intentHash [32]byte
|
||||
copy(intentHash[:], item.IntentHash)
|
||||
bindings = append(bindings, pendingBinding{key: key, intentHash: intentHash})
|
||||
if existing, exists := s.albumGroups[key]; exists {
|
||||
if !bytes.Equal(existing.intentHash[:], item.IntentHash) {
|
||||
return 0, domain.ErrMessageRandomIDDuplicate
|
||||
}
|
||||
if groupedID != 0 && groupedID != existing.groupedID {
|
||||
return 0, domain.ErrMessageRandomIDDuplicate
|
||||
}
|
||||
groupedID = existing.groupedID
|
||||
}
|
||||
}
|
||||
if groupedID == 0 {
|
||||
groupedID = req.ProposedGroupedID
|
||||
}
|
||||
for _, binding := range bindings {
|
||||
s.albumGroups[binding.key] = albumGroupRecord{groupedID: groupedID, intentHash: binding.intentHash}
|
||||
}
|
||||
return groupedID, nil
|
||||
}
|
||||
131
internal/store/memory/album_group_test.go
Normal file
131
internal/store/memory/album_group_test.go
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func albumIntent(label string) []byte {
|
||||
sum := sha256.Sum256([]byte(label))
|
||||
return sum[:]
|
||||
}
|
||||
|
||||
func albumReq(sender int64, peer domain.Peer, groupedID int64, items ...domain.AlbumGroupReservationItem) domain.AlbumGroupReservationRequest {
|
||||
return domain.AlbumGroupReservationRequest{
|
||||
SenderUserID: sender,
|
||||
Peer: peer,
|
||||
Items: items,
|
||||
ProposedGroupedID: groupedID,
|
||||
}
|
||||
}
|
||||
|
||||
func albumItem(randomID int64, label string) domain.AlbumGroupReservationItem {
|
||||
return domain.AlbumGroupReservationItem{RandomID: randomID, IntentHash: albumIntent(label)}
|
||||
}
|
||||
|
||||
func TestAlbumGroupReservationFullThenSubsetAndIntentConflict(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
messages := NewMessageStore()
|
||||
peer := domain.Peer{Type: domain.PeerTypeUser, ID: 2002}
|
||||
full := []domain.AlbumGroupReservationItem{albumItem(1, "one"), albumItem(2, "two"), albumItem(3, "three")}
|
||||
|
||||
groupedID, err := messages.ReserveAlbumGroup(ctx, albumReq(1001, peer, 101, full...))
|
||||
if err != nil || groupedID != 101 {
|
||||
t.Fatalf("reserve full = %d err=%v, want 101", groupedID, err)
|
||||
}
|
||||
replayed, err := messages.ReserveAlbumGroup(ctx, albumReq(1001, peer, 202, full[1:]...))
|
||||
if err != nil || replayed != groupedID {
|
||||
t.Fatalf("reserve subset = %d err=%v, want original %d", replayed, err, groupedID)
|
||||
}
|
||||
for _, item := range full {
|
||||
got, err := messages.ReserveAlbumGroup(ctx, albumReq(1001, peer, 303, item))
|
||||
if err != nil || got != groupedID {
|
||||
t.Fatalf("single random_id %d = %d err=%v, want %d", item.RandomID, got, err, groupedID)
|
||||
}
|
||||
}
|
||||
changed := albumItem(2, "changed payload")
|
||||
if _, err := messages.ReserveAlbumGroup(ctx, albumReq(1001, peer, 404, changed)); !errors.Is(err, domain.ErrMessageRandomIDDuplicate) {
|
||||
t.Fatalf("changed intent err=%v, want ErrMessageRandomIDDuplicate", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlbumGroupReservationConcurrentOverlapConverges(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
messages := NewMessageStore()
|
||||
peer := domain.Peer{Type: domain.PeerTypeChannel, ID: 9001}
|
||||
requests := []domain.AlbumGroupReservationRequest{
|
||||
albumReq(1001, peer, 111, albumItem(11, "one"), albumItem(12, "shared")),
|
||||
albumReq(1001, peer, 222, albumItem(12, "shared"), albumItem(13, "three")),
|
||||
}
|
||||
results := make([]int64, 2)
|
||||
errs := make([]error, 2)
|
||||
start := make(chan struct{})
|
||||
var wg sync.WaitGroup
|
||||
for i := range requests {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
results[i], errs[i] = messages.ReserveAlbumGroup(ctx, requests[i])
|
||||
}(i)
|
||||
}
|
||||
close(start)
|
||||
wg.Wait()
|
||||
if errs[0] != nil || errs[1] != nil || results[0] == 0 || results[0] != results[1] {
|
||||
t.Fatalf("concurrent results=%v errs=%v, want same non-zero group", results, errs)
|
||||
}
|
||||
for _, item := range []domain.AlbumGroupReservationItem{albumItem(11, "one"), albumItem(12, "shared"), albumItem(13, "three")} {
|
||||
got, err := messages.ReserveAlbumGroup(ctx, albumReq(1001, peer, 333, item))
|
||||
if err != nil || got != results[0] {
|
||||
t.Fatalf("converged random_id %d = %d err=%v, want %d", item.RandomID, got, err, results[0])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlbumGroupReservationRejectsMixedOldGroupsAtomically(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
messages := NewMessageStore()
|
||||
peer := domain.Peer{Type: domain.PeerTypeUser, ID: 2002}
|
||||
one := albumItem(21, "one")
|
||||
two := albumItem(22, "two")
|
||||
three := albumItem(23, "three")
|
||||
if _, err := messages.ReserveAlbumGroup(ctx, albumReq(1001, peer, 121, one)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := messages.ReserveAlbumGroup(ctx, albumReq(1001, peer, 122, two)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := messages.ReserveAlbumGroup(ctx, albumReq(1001, peer, 123, one, two, three)); !errors.Is(err, domain.ErrMessageRandomIDDuplicate) {
|
||||
t.Fatalf("mixed old groups err=%v, want ErrMessageRandomIDDuplicate", err)
|
||||
}
|
||||
// 失败批次不能把尚未存在的 random_id 23 偷绑到任一旧组。
|
||||
got, err := messages.ReserveAlbumGroup(ctx, albumReq(1001, peer, 124, three))
|
||||
if err != nil || got != 124 {
|
||||
t.Fatalf("post-conflict unbound item = %d err=%v, want fresh 124", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlbumGroupReservationScopeIncludesPeer(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
messages := NewMessageStore()
|
||||
item := albumItem(31, "same intent")
|
||||
tests := []struct {
|
||||
peer domain.Peer
|
||||
group int64
|
||||
}{
|
||||
{peer: domain.Peer{Type: domain.PeerTypeUser, ID: 2002}, group: 131},
|
||||
{peer: domain.Peer{Type: domain.PeerTypeUser, ID: 2003}, group: 132},
|
||||
{peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 2002}, group: 133},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
got, err := messages.ReserveAlbumGroup(ctx, albumReq(1001, tc.peer, tc.group, item))
|
||||
if err != nil || got != tc.group {
|
||||
t.Fatalf("peer %+v = %d err=%v, want isolated %d", tc.peer, got, err, tc.group)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -276,6 +276,11 @@ func NewCodeStore() *CodeStore {
|
|||
}
|
||||
|
||||
func (s *CodeStore) Set(_ context.Context, hash string, code store.PhoneCode, ttl time.Duration) error {
|
||||
revision, err := store.NewPhoneCodeRevisionToken()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
code.Revision = revision
|
||||
s.mu.Lock()
|
||||
scope := code.Scope()
|
||||
if scope.Valid() {
|
||||
|
|
@ -303,6 +308,11 @@ func (s *CodeStore) Get(_ context.Context, hash string) (store.PhoneCode, bool,
|
|||
}
|
||||
|
||||
func (s *CodeStore) Update(_ context.Context, hash string, code store.PhoneCode) error {
|
||||
revision, err := store.NewPhoneCodeRevisionToken()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
code.Revision = revision
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
e, ok := s.m[hash]
|
||||
|
|
@ -343,7 +353,13 @@ func (s *CodeStore) ConsumeScoped(_ context.Context, hash string, scope store.Ph
|
|||
}
|
||||
return store.PhoneCode{}, false, nil
|
||||
}
|
||||
if e.code.Scope() != scope {
|
||||
if e.code.Version != store.PhoneCodeVersionCurrent || e.code.Scope() != scope {
|
||||
delete(s.m, hash)
|
||||
delete(s.scopes, scope)
|
||||
actualScope := e.code.Scope()
|
||||
if actualScope.Valid() && s.scopes[actualScope] == hash {
|
||||
delete(s.scopes, actualScope)
|
||||
}
|
||||
return store.PhoneCode{}, false, nil
|
||||
}
|
||||
s.deleteCodeLocked(hash, e.code)
|
||||
|
|
|
|||
|
|
@ -67,10 +67,15 @@ func (s *BootstrapUpdateJobStore) MarkReadyForSession(_ context.Context, userID
|
|||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for id, job := range s.jobs {
|
||||
if job.UserID != userID || job.AuthKeyID != authKeyID || job.SessionID != sessionID || job.Status != domain.BootstrapUpdateJobPending {
|
||||
if job.UserID != userID || job.AuthKeyID != authKeyID || job.Status != domain.BootstrapUpdateJobPending {
|
||||
continue
|
||||
}
|
||||
job.Status = domain.BootstrapUpdateJobReady
|
||||
// A reconnect on the same physical/business auth key is the same
|
||||
// verified device. Transfer the pending baseline fence to the session
|
||||
// that actually completed getState/getDifference instead of orphaning
|
||||
// the job on the disconnected signup session.
|
||||
job.SessionID = sessionID
|
||||
job.ReadyAt = now
|
||||
job.UpdatedAt = now
|
||||
s.jobs[id] = job
|
||||
|
|
|
|||
|
|
@ -319,14 +319,8 @@ func (s *ChannelStore) ListDirtyActiveChannelsForUser(_ context.Context, userID
|
|||
if !ok || member.Status != domain.ChannelMemberActive {
|
||||
continue
|
||||
}
|
||||
dirty := false
|
||||
for _, event := range s.events[channelID] {
|
||||
if event.Date > sinceDate {
|
||||
dirty = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if dirty {
|
||||
checkpoint := s.channelUpdateCheckpointLocked(channelID, channel)
|
||||
if checkpoint.LatestEventDate > sinceDate {
|
||||
out = append(out, domain.DirtyChannel{ChannelID: channelID, Pts: channel.Pts})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -240,7 +240,16 @@ func (s *ChannelStore) deleteChannelMessagesLocked(channel domain.Channel, membe
|
|||
MessageIDs: append([]int(nil), deleted...),
|
||||
SenderUserID: actorUserID,
|
||||
}
|
||||
s.events[channel.ID] = append(s.events[channel.ID], event)
|
||||
s.appendChannelEventLocked(event)
|
||||
for _, id := range deleted {
|
||||
msg, ok := s.findMessageLocked(channel.ID, id)
|
||||
if !ok || msg.RandomID == 0 || msg.SenderUserID == 0 {
|
||||
continue
|
||||
}
|
||||
key := channelMessageReplayKey{channelID: channel.ID, messageID: msg.ID}
|
||||
cloned := cloneChannelEvent(event)
|
||||
s.deleteReceipts[key] = &cloned
|
||||
}
|
||||
return deleted, event, channel, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ func (s *ChannelStore) EditChannelMessage(_ context.Context, req domain.EditChan
|
|||
Message: cloneChannelMessage(msg),
|
||||
SenderUserID: req.UserID,
|
||||
}
|
||||
s.events[req.ChannelID] = append(s.events[req.ChannelID], event)
|
||||
s.appendChannelEventLocked(event)
|
||||
return domain.EditChannelMessageResult{
|
||||
Channel: channel,
|
||||
Message: cloneChannelMessage(msg),
|
||||
|
|
@ -108,7 +108,7 @@ func (s *ChannelStore) EditChannelMessage(_ context.Context, req domain.EditChan
|
|||
Message: cloneChannelMessage(msg),
|
||||
SenderUserID: req.UserID,
|
||||
}
|
||||
s.events[req.ChannelID] = append(s.events[req.ChannelID], event)
|
||||
s.appendChannelEventLocked(event)
|
||||
s.appendChannelAdminLogLocked(domain.ChannelAdminLogEvent{
|
||||
ChannelID: req.ChannelID,
|
||||
UserID: req.UserID,
|
||||
|
|
@ -150,7 +150,7 @@ func (s *ChannelStore) EditChannelMessage(_ context.Context, req domain.EditChan
|
|||
SenderUserID: req.UserID,
|
||||
}
|
||||
s.messages[req.ChannelID] = append(s.messages[req.ChannelID], serviceMsg)
|
||||
s.events[req.ChannelID] = append(s.events[req.ChannelID], serviceEvent)
|
||||
s.appendChannelEventLocked(serviceEvent)
|
||||
s.updateForumTopicTopMessageLocked(req.ChannelID, serviceMsg)
|
||||
channel.TopMessageID = serviceMsg.ID
|
||||
channel.Pts = servicePts
|
||||
|
|
@ -303,7 +303,7 @@ func (s *ChannelStore) UpdatePinnedMessage(_ context.Context, req domain.UpdateC
|
|||
SenderUserID: req.UserID,
|
||||
Pinned: req.Pinned,
|
||||
}
|
||||
s.events[req.ChannelID] = append(s.events[req.ChannelID], event)
|
||||
s.appendChannelEventLocked(event)
|
||||
logMsg := msg
|
||||
logMsg.Pinned = req.Pinned
|
||||
s.appendChannelAdminLogLocked(domain.ChannelAdminLogEvent{
|
||||
|
|
@ -372,7 +372,7 @@ func (s *ChannelStore) UnpinAllChannelMessages(_ context.Context, req domain.Unp
|
|||
SenderUserID: req.UserID,
|
||||
Pinned: false,
|
||||
}
|
||||
s.events[req.ChannelID] = append(s.events[req.ChannelID], event)
|
||||
s.appendChannelEventLocked(event)
|
||||
return domain.UpdateChannelPinnedMessageResult{
|
||||
Channel: channel,
|
||||
Event: cloneChannelEvent(event),
|
||||
|
|
|
|||
|
|
@ -2,8 +2,10 @@ package memory
|
|||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
"time"
|
||||
)
|
||||
|
||||
|
|
@ -14,8 +16,27 @@ func (s *ChannelStore) SendChannelMessage(_ context.Context, req domain.SendChan
|
|||
if strings.TrimSpace(req.Message) == "" && req.Action == nil && req.Media.IsZero() && req.RichMessage.IsZero() {
|
||||
return domain.SendChannelMessageResult{}, domain.ErrChannelInvalid
|
||||
}
|
||||
var fingerprint []byte
|
||||
var err error
|
||||
if req.RandomID != 0 {
|
||||
fingerprint, err = store.ChannelSendFingerprint(req)
|
||||
if err != nil {
|
||||
return domain.SendChannelMessageResult{}, err
|
||||
}
|
||||
req.IdempotencyFingerprint = fingerprint
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if req.RandomID != 0 {
|
||||
if replay, found, replayErr := s.lookupChannelSendReplayLocked(domain.ChannelSendReplayRequest{
|
||||
ChannelID: req.ChannelID,
|
||||
SenderUserID: req.UserID,
|
||||
RandomID: req.RandomID,
|
||||
IdempotencyFingerprint: fingerprint,
|
||||
}); replayErr != nil || found {
|
||||
return replay, replayErr
|
||||
}
|
||||
}
|
||||
channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID)
|
||||
if err != nil {
|
||||
return domain.SendChannelMessageResult{}, err
|
||||
|
|
@ -34,23 +55,6 @@ func (s *ChannelStore) SendChannelMessage(_ context.Context, req domain.SendChan
|
|||
if !canSendChannelMessageWithBoost(channel, member, fromBoostsApplied) {
|
||||
return domain.SendChannelMessageResult{}, domain.ErrChannelWriteForbidden
|
||||
}
|
||||
if req.RandomID != 0 {
|
||||
if id, ok := s.randomToID[channelRandomKey{channelID: req.ChannelID, userID: req.UserID, randomID: req.RandomID}]; ok {
|
||||
msg, ok := s.findMessageLocked(req.ChannelID, id)
|
||||
if ok {
|
||||
event := s.eventForMessageLocked(req.ChannelID, id)
|
||||
if event.Message.ID != 0 {
|
||||
msg = event.Message
|
||||
}
|
||||
return domain.SendChannelMessageResult{
|
||||
Channel: channel,
|
||||
Message: cloneChannelMessage(msg),
|
||||
Event: event,
|
||||
Duplicate: true,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
if wait := channelSlowModeWait(channel, member, req.Date); wait > 0 {
|
||||
return domain.SendChannelMessageResult{}, domain.NewSlowModeWaitError(wait)
|
||||
}
|
||||
|
|
@ -99,7 +103,7 @@ func (s *ChannelStore) SendChannelMessage(_ context.Context, req domain.SendChan
|
|||
Message: cloneChannelMessage(discussionMsg),
|
||||
}
|
||||
s.messages[linked.ID] = append(s.messages[linked.ID], discussionMsg)
|
||||
s.events[linked.ID] = append(s.events[linked.ID], discussionEvent)
|
||||
s.appendChannelEventLocked(discussionEvent)
|
||||
linked.TopMessageID = discussionMsgID
|
||||
linked.Pts = discussionPts
|
||||
s.channels[linked.ID] = linked
|
||||
|
|
@ -145,6 +149,13 @@ func (s *ChannelStore) SendChannelMessage(_ context.Context, req domain.SendChan
|
|||
Pts: pts,
|
||||
}
|
||||
msg.Replies = s.channelMessageRepliesLocked(req.UserID, req.ChannelID, msg)
|
||||
var sendSnapshot []byte
|
||||
if req.RandomID != 0 {
|
||||
sendSnapshot, err = store.EncodeChannelSendSnapshot(msg)
|
||||
if err != nil {
|
||||
return domain.SendChannelMessageResult{}, err
|
||||
}
|
||||
}
|
||||
event := domain.ChannelUpdateEvent{
|
||||
ChannelID: req.ChannelID,
|
||||
Type: domain.ChannelUpdateNewMessage,
|
||||
|
|
@ -155,7 +166,7 @@ func (s *ChannelStore) SendChannelMessage(_ context.Context, req domain.SendChan
|
|||
SenderUserID: req.UserID,
|
||||
}
|
||||
s.messages[req.ChannelID] = append(s.messages[req.ChannelID], msg)
|
||||
s.events[req.ChannelID] = append(s.events[req.ChannelID], event)
|
||||
s.appendChannelEventLocked(event)
|
||||
if !channel.Broadcast || channel.Megagroup {
|
||||
mentionTargets := req.MentionUserIDs
|
||||
if msg.ReplyTo != nil && msg.ReplyTo.MessageID > 0 {
|
||||
|
|
@ -178,7 +189,11 @@ func (s *ChannelStore) SendChannelMessage(_ context.Context, req domain.SendChan
|
|||
})
|
||||
}
|
||||
if req.RandomID != 0 {
|
||||
s.randomToID[channelRandomKey{channelID: req.ChannelID, userID: req.UserID, randomID: req.RandomID}] = msg.ID
|
||||
key := channelRandomKey{channelID: req.ChannelID, userID: req.UserID, randomID: req.RandomID}
|
||||
s.randomToID[key] = msg.ID
|
||||
replayKey := channelMessageReplayKey{channelID: req.ChannelID, messageID: msg.ID}
|
||||
s.sendSnapshots[replayKey] = sendSnapshot
|
||||
s.sendFingerprints[replayKey] = append([]byte(nil), fingerprint...)
|
||||
}
|
||||
channel.TopMessageID = msg.ID
|
||||
channel.Pts = pts
|
||||
|
|
@ -209,6 +224,85 @@ func (s *ChannelStore) SendChannelMessage(_ context.Context, req domain.SendChan
|
|||
}, nil
|
||||
}
|
||||
|
||||
// LookupChannelSendReplay returns a committed regular-channel or monoforum send receipt without
|
||||
// evaluating current membership, write permissions, slow mode or any message allocation path.
|
||||
func (s *ChannelStore) LookupChannelSendReplay(_ context.Context, req domain.ChannelSendReplayRequest) (domain.SendChannelMessageResult, bool, error) {
|
||||
if req.ChannelID == 0 || req.SenderUserID == 0 || req.RandomID == 0 {
|
||||
return domain.SendChannelMessageResult{}, false, fmt.Errorf("memory channel send replay: invalid scope")
|
||||
}
|
||||
if req.SavedPeer.ID == 0 {
|
||||
if req.SavedPeer.Type != "" {
|
||||
return domain.SendChannelMessageResult{}, false, fmt.Errorf("memory channel send replay: incomplete saved peer scope")
|
||||
}
|
||||
} else if req.SavedPeer.Type != domain.PeerTypeUser {
|
||||
return domain.SendChannelMessageResult{}, false, fmt.Errorf("memory channel send replay: invalid saved peer scope")
|
||||
}
|
||||
if err := store.ValidateSendFingerprint(req.IdempotencyFingerprint, "channel send replay"); err != nil {
|
||||
return domain.SendChannelMessageResult{}, false, err
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.lookupChannelSendReplayLocked(req)
|
||||
}
|
||||
|
||||
func (s *ChannelStore) lookupChannelSendReplayLocked(req domain.ChannelSendReplayRequest) (domain.SendChannelMessageResult, bool, error) {
|
||||
var id int
|
||||
if req.SavedPeer.ID == 0 {
|
||||
var found bool
|
||||
id, found = s.randomToID[channelRandomKey{channelID: req.ChannelID, userID: req.SenderUserID, randomID: req.RandomID}]
|
||||
if !found {
|
||||
return domain.SendChannelMessageResult{}, false, nil
|
||||
}
|
||||
} else {
|
||||
msg, found := s.findMonoforumDuplicateLocked(req.ChannelID, req.SenderUserID, req.SavedPeer, req.RandomID)
|
||||
if !found {
|
||||
return domain.SendChannelMessageResult{}, false, nil
|
||||
}
|
||||
id = msg.ID
|
||||
}
|
||||
replayKey := channelMessageReplayKey{channelID: req.ChannelID, messageID: id}
|
||||
if !store.SameSendFingerprint(s.sendFingerprints[replayKey], req.IdempotencyFingerprint) {
|
||||
return domain.SendChannelMessageResult{}, false, domain.ErrMessageRandomIDDuplicate
|
||||
}
|
||||
first, err := store.DecodeChannelSendSnapshot(s.sendSnapshots[replayKey])
|
||||
if err != nil {
|
||||
return domain.SendChannelMessageResult{}, false, fmt.Errorf("memory duplicate channel message snapshot: %w", err)
|
||||
}
|
||||
if first.ID != id || first.ChannelID != req.ChannelID || first.SenderUserID != req.SenderUserID || first.RandomID != req.RandomID || first.SavedPeer != req.SavedPeer {
|
||||
return domain.SendChannelMessageResult{}, false, fmt.Errorf("memory duplicate channel message snapshot disagrees with random_id receipt")
|
||||
}
|
||||
replay := first
|
||||
var replayDelete *domain.ChannelUpdateEvent
|
||||
if current, found := s.findMessageLocked(req.ChannelID, id); found && !current.Deleted {
|
||||
replay = cloneChannelMessage(current)
|
||||
} else if receipt := s.deleteReceipts[replayKey]; receipt != nil {
|
||||
cloned := cloneChannelEvent(*receipt)
|
||||
replayDelete = &cloned
|
||||
} else {
|
||||
return domain.SendChannelMessageResult{}, false, fmt.Errorf("memory duplicate channel message %d is absent without a durable delete receipt", id)
|
||||
}
|
||||
channel, ok := s.channels[req.ChannelID]
|
||||
if !ok {
|
||||
return domain.SendChannelMessageResult{}, false, fmt.Errorf("memory duplicate channel message %d has no channel", id)
|
||||
}
|
||||
event := domain.ChannelUpdateEvent{
|
||||
ChannelID: first.ChannelID,
|
||||
Type: domain.ChannelUpdateNewMessage,
|
||||
Pts: first.Pts,
|
||||
PtsCount: 1,
|
||||
Date: first.Date,
|
||||
Message: cloneChannelMessage(replay),
|
||||
SenderUserID: first.SenderUserID,
|
||||
}
|
||||
return domain.SendChannelMessageResult{
|
||||
Channel: cloneChannel(channel),
|
||||
Message: cloneChannelMessage(replay),
|
||||
Event: event,
|
||||
Duplicate: true,
|
||||
ReplayDeleteEvent: replayDelete,
|
||||
}, true, nil
|
||||
}
|
||||
|
||||
func channelDeliverySkipSet(ids []int64) map[int64]struct{} {
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
|
|
@ -267,7 +361,7 @@ func (s *ChannelStore) appendChannelServiceMessageLocked(channelID, senderUserID
|
|||
UserIDs: append([]int64(nil), action.UserIDs...),
|
||||
}
|
||||
s.messages[channelID] = append(s.messages[channelID], msg)
|
||||
s.events[channelID] = append(s.events[channelID], event)
|
||||
s.appendChannelEventLocked(event)
|
||||
return msg, event
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import (
|
|||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
// SendMonoforumMessage 向 monoforum(频道私信)虚拟频道发一条消息,按 saved_peer 分订阅者子会话。
|
||||
|
|
@ -16,8 +17,28 @@ func (s *ChannelStore) SendMonoforumMessage(_ context.Context, req domain.SendMo
|
|||
req.SavedPeer.Type != domain.PeerTypeUser || strings.TrimSpace(req.Message) == "" {
|
||||
return domain.SendChannelMessageResult{}, domain.ErrChannelInvalid
|
||||
}
|
||||
var fingerprint []byte
|
||||
var err error
|
||||
if req.RandomID != 0 {
|
||||
fingerprint, err = store.MonoforumSendFingerprint(req)
|
||||
if err != nil {
|
||||
return domain.SendChannelMessageResult{}, err
|
||||
}
|
||||
req.IdempotencyFingerprint = fingerprint
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if req.RandomID != 0 {
|
||||
if replay, found, replayErr := s.lookupChannelSendReplayLocked(domain.ChannelSendReplayRequest{
|
||||
ChannelID: req.MonoforumID,
|
||||
SenderUserID: req.SenderUserID,
|
||||
SavedPeer: req.SavedPeer,
|
||||
RandomID: req.RandomID,
|
||||
IdempotencyFingerprint: fingerprint,
|
||||
}); replayErr != nil || found {
|
||||
return replay, replayErr
|
||||
}
|
||||
}
|
||||
channel, ok := s.channels[req.MonoforumID]
|
||||
if !ok || channel.Deleted || !channel.Monoforum {
|
||||
return domain.SendChannelMessageResult{}, domain.ErrChannelInvalid
|
||||
|
|
@ -25,17 +46,6 @@ func (s *ChannelStore) SendMonoforumMessage(_ context.Context, req domain.SendMo
|
|||
if req.Date == 0 {
|
||||
req.Date = int(time.Now().Unix())
|
||||
}
|
||||
if req.RandomID != 0 {
|
||||
// 去重维度 = (sender, saved_peer, random_id),与 postgres 迁移 0022 的唯一索引一致;
|
||||
// 不复用账号级 randomToID(其按 channel+sender+random_id 三元组,会被跨子会话同 random_id 互相覆盖)。
|
||||
if dup, ok := s.findMonoforumDuplicateLocked(req.MonoforumID, req.SenderUserID, req.SavedPeer, req.RandomID); ok {
|
||||
event := s.eventForMessageLocked(req.MonoforumID, dup.ID)
|
||||
if event.Message.ID != 0 {
|
||||
dup = event.Message
|
||||
}
|
||||
return domain.SendChannelMessageResult{Channel: cloneChannel(channel), Message: cloneChannelMessage(dup), Event: event, Duplicate: true}, nil
|
||||
}
|
||||
}
|
||||
pts := s.nextChannelPtsLocked(req.MonoforumID)
|
||||
msgID := s.nextChannelMessageIDLocked(req.MonoforumID)
|
||||
msg := domain.ChannelMessage{
|
||||
|
|
@ -50,6 +60,14 @@ func (s *ChannelStore) SendMonoforumMessage(_ context.Context, req domain.SendMo
|
|||
Entities: append([]domain.MessageEntity(nil), req.Entities...),
|
||||
Pts: pts,
|
||||
}
|
||||
var sendSnapshot []byte
|
||||
if req.RandomID != 0 {
|
||||
var snapshotErr error
|
||||
sendSnapshot, snapshotErr = store.EncodeChannelSendSnapshot(msg)
|
||||
if snapshotErr != nil {
|
||||
return domain.SendChannelMessageResult{}, snapshotErr
|
||||
}
|
||||
}
|
||||
event := domain.ChannelUpdateEvent{
|
||||
ChannelID: req.MonoforumID,
|
||||
Type: domain.ChannelUpdateNewMessage,
|
||||
|
|
@ -60,7 +78,12 @@ func (s *ChannelStore) SendMonoforumMessage(_ context.Context, req domain.SendMo
|
|||
SenderUserID: req.SenderUserID,
|
||||
}
|
||||
s.messages[req.MonoforumID] = append(s.messages[req.MonoforumID], msg)
|
||||
s.events[req.MonoforumID] = append(s.events[req.MonoforumID], event)
|
||||
if req.RandomID != 0 {
|
||||
replayKey := channelMessageReplayKey{channelID: req.MonoforumID, messageID: msg.ID}
|
||||
s.sendSnapshots[replayKey] = sendSnapshot
|
||||
s.sendFingerprints[replayKey] = append([]byte(nil), fingerprint...)
|
||||
}
|
||||
s.appendChannelEventLocked(event)
|
||||
channel.TopMessageID = msgID
|
||||
channel.Pts = pts
|
||||
s.channels[req.MonoforumID] = channel
|
||||
|
|
@ -75,7 +98,7 @@ func (s *ChannelStore) findMonoforumDuplicateLocked(monoforumID, senderUserID in
|
|||
msgs := s.messages[monoforumID]
|
||||
for i := len(msgs) - 1; i >= 0; i-- {
|
||||
m := msgs[i]
|
||||
if !m.Deleted && m.RandomID == randomID && m.SenderUserID == senderUserID && m.SavedPeer == savedPeer {
|
||||
if m.RandomID == randomID && m.SenderUserID == senderUserID && m.SavedPeer == savedPeer {
|
||||
return m, true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package memory
|
|||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
|
|
@ -69,6 +70,9 @@ func TestSendMonoforumMessageAndHistory(t *testing.T) {
|
|||
if !dup.Duplicate || dup.Message.ID != m1.Message.ID {
|
||||
t.Fatalf("dup = %+v, want duplicate of m1 id %d", dup.Message, m1.Message.ID)
|
||||
}
|
||||
if _, err := store.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: 42, SavedPeer: sub, RandomID: 111, Message: "changed", Date: 1_700_001_004}); !errors.Is(err, domain.ErrMessageRandomIDDuplicate) {
|
||||
t.Fatalf("conflicting monoforum replay err=%v, want ErrMessageRandomIDDuplicate", err)
|
||||
}
|
||||
|
||||
hist, err := store.ListMonoforumHistory(ctx, domain.MonoforumHistoryFilter{MonoforumID: monoID, SavedPeer: sub, Limit: 10})
|
||||
if err != nil {
|
||||
|
|
@ -131,4 +135,21 @@ func TestSendMonoforumMessageAndHistory(t *testing.T) {
|
|||
if dialogs.Dialogs[1].SavedPeer != sub || dialogs.Dialogs[1].TopMessageID == 0 {
|
||||
t.Fatalf("dialogs[1] = %+v, want sub with top message", dialogs.Dialogs[1])
|
||||
}
|
||||
store.mu.Lock()
|
||||
_, deleteEvent, _, err := store.deleteChannelMessagesLocked(store.channels[monoID], domain.ChannelMember{ChannelID: monoID, UserID: 1, Role: domain.ChannelRoleCreator, Status: domain.ChannelMemberActive}, []int{a.Message.ID}, 1, 1_700_001_013)
|
||||
store.mu.Unlock()
|
||||
if err != nil {
|
||||
t.Fatalf("delete monoforum message: %v", err)
|
||||
}
|
||||
ptsBeforeReplay, eventsBeforeReplay := store.ptsSeq[monoID], len(store.events[monoID])
|
||||
deletedReplay, err := store.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: 1, SavedPeer: sub, RandomID: 9001, Message: "to sub", Date: 1_700_001_014})
|
||||
if err != nil {
|
||||
t.Fatalf("replay deleted monoforum message: %v", err)
|
||||
}
|
||||
if !deletedReplay.Duplicate || deletedReplay.Message.ID != a.Message.ID || deletedReplay.Message.Body != "to sub" || deletedReplay.ReplayDeleteEvent == nil || deletedReplay.ReplayDeleteEvent.Pts != deleteEvent.Pts {
|
||||
t.Fatalf("deleted monoforum replay = %+v, want first snapshot + durable delete %+v", deletedReplay, deleteEvent)
|
||||
}
|
||||
if store.ptsSeq[monoID] != ptsBeforeReplay || len(store.events[monoID]) != eventsBeforeReplay {
|
||||
t.Fatalf("deleted monoforum replay mutated pts/events = %d/%d, want %d/%d", store.ptsSeq[monoID], len(store.events[monoID]), ptsBeforeReplay, eventsBeforeReplay)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
22
internal/store/memory/channel_recovery_test.go
Normal file
22
internal/store/memory/channel_recovery_test.go
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMaxChannelPtsBatchUsesOneSnapshotAndOmitsMissing(t *testing.T) {
|
||||
channels := NewChannelStore()
|
||||
channels.mu.Lock()
|
||||
channels.ptsSeq[10] = 7
|
||||
channels.ptsSeq[20] = 11
|
||||
channels.mu.Unlock()
|
||||
|
||||
got, err := channels.MaxChannelPtsBatch(context.Background(), []int64{20, 999, 10, 20})
|
||||
if err != nil {
|
||||
t.Fatalf("MaxChannelPtsBatch: %v", err)
|
||||
}
|
||||
if len(got) != 2 || got[10] != 7 || got[20] != 11 {
|
||||
t.Fatalf("batch pts = %v, want map[10:7 20:11] with missing id omitted", got)
|
||||
}
|
||||
}
|
||||
76
internal/store/memory/channel_send_idempotency_test.go
Normal file
76
internal/store/memory/channel_send_idempotency_test.go
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func TestChannelRandomIDReplayUsesCurrentSnapshotAndDurableDeleteMemory(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
channels := NewChannelStore()
|
||||
created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{
|
||||
CreatorUserID: 1,
|
||||
Title: "replay convergence",
|
||||
Megagroup: true,
|
||||
Date: 1_700_001_000,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create channel: %v", err)
|
||||
}
|
||||
req := domain.SendChannelMessageRequest{
|
||||
UserID: 1, ChannelID: created.Channel.ID, RandomID: 77001,
|
||||
Message: "original", Date: 1_700_001_001,
|
||||
}
|
||||
first, err := channels.SendChannelMessage(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("send channel message: %v", err)
|
||||
}
|
||||
conflict := req
|
||||
conflict.Message = "same random id, different intent"
|
||||
if _, err := channels.SendChannelMessage(ctx, conflict); !errors.Is(err, domain.ErrMessageRandomIDDuplicate) {
|
||||
t.Fatalf("conflicting channel replay err=%v, want ErrMessageRandomIDDuplicate", err)
|
||||
}
|
||||
edited, err := channels.EditChannelMessage(ctx, domain.EditChannelMessageRequest{
|
||||
UserID: 1, ChannelID: created.Channel.ID, ID: first.Message.ID,
|
||||
Message: "edited", EditDate: 1_700_001_002,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("edit channel message: %v", err)
|
||||
}
|
||||
ptsBeforeReplay := channels.ptsSeq[created.Channel.ID]
|
||||
eventsBeforeReplay := len(channels.events[created.Channel.ID])
|
||||
replay, err := channels.SendChannelMessage(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("replay after edit: %v", err)
|
||||
}
|
||||
if !replay.Duplicate || replay.Message.Body != "edited" || replay.Message.Pts != edited.Message.Pts || replay.Event.Pts != first.Event.Pts || replay.ReplayDeleteEvent != nil {
|
||||
t.Fatalf("replay after edit = %+v, want current snapshot with first-send pts", replay)
|
||||
}
|
||||
if channels.ptsSeq[created.Channel.ID] != ptsBeforeReplay || len(channels.events[created.Channel.ID]) != eventsBeforeReplay {
|
||||
t.Fatalf("edit replay mutated channel pts/events = %d/%d, want %d/%d", channels.ptsSeq[created.Channel.ID], len(channels.events[created.Channel.ID]), ptsBeforeReplay, eventsBeforeReplay)
|
||||
}
|
||||
deleted, err := channels.DeleteChannelMessages(ctx, domain.DeleteChannelMessagesRequest{
|
||||
UserID: 1, ChannelID: created.Channel.ID, IDs: []int{first.Message.ID}, Date: 1_700_001_003,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("delete channel message: %v", err)
|
||||
}
|
||||
ptsBeforeReplay = channels.ptsSeq[created.Channel.ID]
|
||||
eventsBeforeReplay = len(channels.events[created.Channel.ID])
|
||||
replay, err = channels.SendChannelMessage(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("replay after delete: %v", err)
|
||||
}
|
||||
if !replay.Duplicate || replay.Message.Body != "original" || replay.Message.Pts != first.Message.Pts || replay.Event.Pts != first.Event.Pts {
|
||||
t.Fatalf("replay after delete = %+v, want immutable first snapshot", replay)
|
||||
}
|
||||
if replay.ReplayDeleteEvent == nil || replay.ReplayDeleteEvent.Pts != deleted.Event.Pts || len(replay.ReplayDeleteEvent.MessageIDs) != 1 || replay.ReplayDeleteEvent.MessageIDs[0] != first.Message.ID {
|
||||
t.Fatalf("replay delete = %+v, want durable event %+v", replay.ReplayDeleteEvent, deleted.Event)
|
||||
}
|
||||
if channels.ptsSeq[created.Channel.ID] != ptsBeforeReplay || len(channels.events[created.Channel.ID]) != eventsBeforeReplay {
|
||||
t.Fatalf("delete replay mutated channel pts/events = %d/%d, want %d/%d", channels.ptsSeq[created.Channel.ID], len(channels.events[created.Channel.ID]), ptsBeforeReplay, eventsBeforeReplay)
|
||||
}
|
||||
}
|
||||
|
|
@ -13,6 +13,11 @@ type channelRandomKey struct {
|
|||
randomID int64
|
||||
}
|
||||
|
||||
type channelMessageReplayKey struct {
|
||||
channelID int64
|
||||
messageID int
|
||||
}
|
||||
|
||||
type boostSlotKey struct {
|
||||
userID int64
|
||||
slot int
|
||||
|
|
@ -58,33 +63,37 @@ func (w channelReadWatermark) advance(userID int64, maxID int) channelReadWaterm
|
|||
|
||||
// ChannelStore is an in-memory channel/supergroup store for tests and local development.
|
||||
type ChannelStore struct {
|
||||
mu sync.RWMutex
|
||||
nextID int64
|
||||
nextHash int64
|
||||
channels map[int64]domain.Channel
|
||||
members map[int64]map[int64]domain.ChannelMember
|
||||
dialogs map[int64]map[int64]domain.ChannelDialog
|
||||
topics map[int64]map[int]domain.ChannelForumTopic
|
||||
messages map[int64][]domain.ChannelMessage
|
||||
reactions map[int64]map[int]map[int64][]domain.ChannelMessagePeerReaction
|
||||
mu sync.RWMutex
|
||||
nextID int64
|
||||
nextHash int64
|
||||
channels map[int64]domain.Channel
|
||||
members map[int64]map[int64]domain.ChannelMember
|
||||
dialogs map[int64]map[int64]domain.ChannelDialog
|
||||
topics map[int64]map[int]domain.ChannelForumTopic
|
||||
messages map[int64][]domain.ChannelMessage
|
||||
reactions map[int64]map[int]map[int64][]domain.ChannelMessagePeerReaction
|
||||
// paidReactions 是 per-(channel,message,user) 付费 reaction 累计星数 + 匿名标志。
|
||||
paidReactions map[int64]map[int]map[int64]memoryPaidReaction
|
||||
top map[int64]map[string]domain.TopMessageReaction
|
||||
recent map[int64]map[string]domain.RecentMessageReaction
|
||||
savedTags map[int64]map[string]domain.SavedReactionTag
|
||||
mentions map[int64]map[int64]map[int]memoryMention
|
||||
msgViews map[int64]map[int]int
|
||||
msgViewers map[int64]map[int]map[int64]struct{}
|
||||
events map[int64][]domain.ChannelUpdateEvent
|
||||
adminLogs map[int64][]domain.ChannelAdminLogEvent
|
||||
invites map[string]domain.ChannelInvite
|
||||
importers map[int64]map[int64]domain.ChannelInviteImporter
|
||||
msgSeq map[int64]int
|
||||
ptsSeq map[int64]int
|
||||
logSeq map[int64]int64
|
||||
randomToID map[channelRandomKey]int
|
||||
boostSlots map[boostSlotKey]domain.PremiumBoostSlot
|
||||
readMarks map[int64]channelReadWatermark
|
||||
paidReactions map[int64]map[int]map[int64]memoryPaidReaction
|
||||
top map[int64]map[string]domain.TopMessageReaction
|
||||
recent map[int64]map[string]domain.RecentMessageReaction
|
||||
savedTags map[int64]map[string]domain.SavedReactionTag
|
||||
mentions map[int64]map[int64]map[int]memoryMention
|
||||
msgViews map[int64]map[int]int
|
||||
msgViewers map[int64]map[int]map[int64]struct{}
|
||||
events map[int64][]domain.ChannelUpdateEvent
|
||||
retention map[int64]domain.ChannelUpdateRetentionCheckpoint
|
||||
adminLogs map[int64][]domain.ChannelAdminLogEvent
|
||||
invites map[string]domain.ChannelInvite
|
||||
importers map[int64]map[int64]domain.ChannelInviteImporter
|
||||
msgSeq map[int64]int
|
||||
ptsSeq map[int64]int
|
||||
logSeq map[int64]int64
|
||||
randomToID map[channelRandomKey]int
|
||||
sendSnapshots map[channelMessageReplayKey][]byte
|
||||
sendFingerprints map[channelMessageReplayKey][]byte
|
||||
deleteReceipts map[channelMessageReplayKey]*domain.ChannelUpdateEvent
|
||||
boostSlots map[boostSlotKey]domain.PremiumBoostSlot
|
||||
readMarks map[int64]channelReadWatermark
|
||||
// topicReads 是 per-(channel,user,topic) 已读水位(forum 话题独立已读,不碰频道级 member 水位)。
|
||||
topicReads map[int64]map[int64]map[int]memoryTopicRead
|
||||
// polls 是共享 poll 权威(与 MessageStore 同一实例);nil 时 poll 链路按未接入处理。
|
||||
|
|
@ -99,31 +108,35 @@ func (s *ChannelStore) AttachPollStore(polls *PollStore) {
|
|||
// NewChannelStore creates an in-memory ChannelStore.
|
||||
func NewChannelStore() *ChannelStore {
|
||||
return &ChannelStore{
|
||||
nextID: firstMemoryChannelID,
|
||||
nextHash: 900000000000,
|
||||
channels: make(map[int64]domain.Channel),
|
||||
members: make(map[int64]map[int64]domain.ChannelMember),
|
||||
dialogs: make(map[int64]map[int64]domain.ChannelDialog),
|
||||
topics: make(map[int64]map[int]domain.ChannelForumTopic),
|
||||
messages: make(map[int64][]domain.ChannelMessage),
|
||||
reactions: make(map[int64]map[int]map[int64][]domain.ChannelMessagePeerReaction),
|
||||
paidReactions: make(map[int64]map[int]map[int64]memoryPaidReaction),
|
||||
top: make(map[int64]map[string]domain.TopMessageReaction),
|
||||
recent: make(map[int64]map[string]domain.RecentMessageReaction),
|
||||
savedTags: make(map[int64]map[string]domain.SavedReactionTag),
|
||||
mentions: make(map[int64]map[int64]map[int]memoryMention),
|
||||
msgViews: make(map[int64]map[int]int),
|
||||
msgViewers: make(map[int64]map[int]map[int64]struct{}),
|
||||
events: make(map[int64][]domain.ChannelUpdateEvent),
|
||||
adminLogs: make(map[int64][]domain.ChannelAdminLogEvent),
|
||||
invites: make(map[string]domain.ChannelInvite),
|
||||
importers: make(map[int64]map[int64]domain.ChannelInviteImporter),
|
||||
msgSeq: make(map[int64]int),
|
||||
ptsSeq: make(map[int64]int),
|
||||
logSeq: make(map[int64]int64),
|
||||
randomToID: make(map[channelRandomKey]int),
|
||||
boostSlots: make(map[boostSlotKey]domain.PremiumBoostSlot),
|
||||
readMarks: make(map[int64]channelReadWatermark),
|
||||
topicReads: make(map[int64]map[int64]map[int]memoryTopicRead),
|
||||
nextID: firstMemoryChannelID,
|
||||
nextHash: 900000000000,
|
||||
channels: make(map[int64]domain.Channel),
|
||||
members: make(map[int64]map[int64]domain.ChannelMember),
|
||||
dialogs: make(map[int64]map[int64]domain.ChannelDialog),
|
||||
topics: make(map[int64]map[int]domain.ChannelForumTopic),
|
||||
messages: make(map[int64][]domain.ChannelMessage),
|
||||
reactions: make(map[int64]map[int]map[int64][]domain.ChannelMessagePeerReaction),
|
||||
paidReactions: make(map[int64]map[int]map[int64]memoryPaidReaction),
|
||||
top: make(map[int64]map[string]domain.TopMessageReaction),
|
||||
recent: make(map[int64]map[string]domain.RecentMessageReaction),
|
||||
savedTags: make(map[int64]map[string]domain.SavedReactionTag),
|
||||
mentions: make(map[int64]map[int64]map[int]memoryMention),
|
||||
msgViews: make(map[int64]map[int]int),
|
||||
msgViewers: make(map[int64]map[int]map[int64]struct{}),
|
||||
events: make(map[int64][]domain.ChannelUpdateEvent),
|
||||
retention: make(map[int64]domain.ChannelUpdateRetentionCheckpoint),
|
||||
adminLogs: make(map[int64][]domain.ChannelAdminLogEvent),
|
||||
invites: make(map[string]domain.ChannelInvite),
|
||||
importers: make(map[int64]map[int64]domain.ChannelInviteImporter),
|
||||
msgSeq: make(map[int64]int),
|
||||
ptsSeq: make(map[int64]int),
|
||||
logSeq: make(map[int64]int64),
|
||||
randomToID: make(map[channelRandomKey]int),
|
||||
sendSnapshots: make(map[channelMessageReplayKey][]byte),
|
||||
sendFingerprints: make(map[channelMessageReplayKey][]byte),
|
||||
deleteReceipts: make(map[channelMessageReplayKey]*domain.ChannelUpdateEvent),
|
||||
boostSlots: make(map[boostSlotKey]domain.PremiumBoostSlot),
|
||||
readMarks: make(map[int64]channelReadWatermark),
|
||||
topicReads: make(map[int64]map[int64]map[int]memoryTopicRead),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1093,8 +1093,12 @@ func TestChannelMessageReplyMarkupSurvivesReadPaths(t *testing.T) {
|
|||
UserID: 1,
|
||||
ChannelID: created.Channel.ID,
|
||||
RandomID: 38_001,
|
||||
Message: "duplicate must not replace",
|
||||
Date: 1_700_000_382,
|
||||
Message: "via inline keyboard",
|
||||
ViaBotID: 99,
|
||||
ReplyMarkup: &domain.MessageReplyMarkup{Inline: [][]domain.MarkupButton{{
|
||||
{Type: domain.MarkupButtonCallback, Text: "Open", Data: []byte{0x00, 0xff, 0x42}},
|
||||
}}},
|
||||
Date: 1_700_000_382,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("duplicate send: %v", err)
|
||||
|
|
|
|||
150
internal/store/memory/channel_update_retention_test.go
Normal file
150
internal/store/memory/channel_update_retention_test.go
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func TestChannelUpdateRetentionFloorDifferenceAndDirtyCheckpointMemory(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := NewChannelStore()
|
||||
const ownerID int64 = 701
|
||||
created, err := store.CreateChannel(ctx, domain.CreateChannelRequest{
|
||||
CreatorUserID: ownerID,
|
||||
Title: "retention memory",
|
||||
Megagroup: true,
|
||||
Date: 1_700_010_000,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create channel: %v", err)
|
||||
}
|
||||
channelID := created.Channel.ID
|
||||
sent := make([]domain.SendChannelMessageResult, 0, 3)
|
||||
for i := 1; i <= 3; i++ {
|
||||
result, err := store.SendChannelMessage(ctx, domain.SendChannelMessageRequest{
|
||||
UserID: ownerID,
|
||||
ChannelID: channelID,
|
||||
RandomID: int64(9000 + i),
|
||||
Message: "retention",
|
||||
Date: 1_700_010_000 + i,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("send message %d: %v", i, err)
|
||||
}
|
||||
sent = append(sent, result)
|
||||
}
|
||||
|
||||
// Delete create + first two messages. The third message remains as the normal incremental page.
|
||||
pruned, err := store.PruneChannelUpdateEvents(ctx, channelID, sent[1].Event.Pts, 100)
|
||||
if err != nil {
|
||||
t.Fatalf("prune channel updates: %v", err)
|
||||
}
|
||||
if pruned.Deleted != 3 || pruned.Checkpoint.RetainedThroughPts != sent[1].Event.Pts {
|
||||
t.Fatalf("prune result = %+v, want deleted=3 floor=%d", pruned, sent[1].Event.Pts)
|
||||
}
|
||||
if pruned.Checkpoint.LatestPts != sent[2].Event.Pts || pruned.Checkpoint.LatestEventDate != sent[2].Event.Date {
|
||||
t.Fatalf("checkpoint latest = %+v, want pts/date %d/%d", pruned.Checkpoint, sent[2].Event.Pts, sent[2].Event.Date)
|
||||
}
|
||||
|
||||
below, err := store.ListChannelDifference(ctx, domain.ChannelDifferenceRequest{
|
||||
UserID: ownerID, ChannelID: channelID, Pts: pruned.Checkpoint.RetainedThroughPts - 1, Limit: 100,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("difference below retained floor: %v", err)
|
||||
}
|
||||
if !below.TooLong || below.Pts != sent[2].Event.Pts || below.Dialog.ChannelID != channelID {
|
||||
t.Fatalf("difference below floor = %+v, want complete too-long snapshot at pts %d", below, sent[2].Event.Pts)
|
||||
}
|
||||
atFloor, err := store.ListChannelDifference(ctx, domain.ChannelDifferenceRequest{
|
||||
UserID: ownerID, ChannelID: channelID, Pts: pruned.Checkpoint.RetainedThroughPts, Limit: 100,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("difference at retained floor: %v", err)
|
||||
}
|
||||
if atFloor.TooLong || len(atFloor.Events) != 1 || atFloor.Events[0].Pts != sent[2].Event.Pts {
|
||||
t.Fatalf("difference at floor = %+v, want one normal incremental event at pts %d", atFloor, sent[2].Event.Pts)
|
||||
}
|
||||
|
||||
// Remove the remaining row. Dirty-channel recovery must still use checkpoint.latest_event_date.
|
||||
allPruned, err := store.PruneChannelUpdateEvents(ctx, channelID, sent[2].Event.Pts, 100)
|
||||
if err != nil {
|
||||
t.Fatalf("prune remaining channel updates: %v", err)
|
||||
}
|
||||
if allPruned.Deleted != 1 || len(store.events[channelID]) != 0 {
|
||||
t.Fatalf("remaining prune = %+v events=%v, want empty event log", allPruned, store.events[channelID])
|
||||
}
|
||||
dirty, err := store.ListDirtyActiveChannelsForUser(ctx, ownerID, sent[2].Event.Date-1, 0, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("list dirty channels after prune: %v", err)
|
||||
}
|
||||
if len(dirty) != 1 || dirty[0].ChannelID != channelID || dirty[0].Pts != sent[2].Event.Pts {
|
||||
t.Fatalf("dirty channels after prune = %+v, want channel %d pts %d", dirty, channelID, sent[2].Event.Pts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPruneChannelUpdateEventsRejectsInvalidPtsCountMemory(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := NewChannelStore()
|
||||
created, err := store.CreateChannel(ctx, domain.CreateChannelRequest{
|
||||
CreatorUserID: 901,
|
||||
Title: "invalid retention event",
|
||||
Megagroup: true,
|
||||
Date: 1_700_020_000,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create channel: %v", err)
|
||||
}
|
||||
channelID := created.Channel.ID
|
||||
channel := store.channels[channelID]
|
||||
channel.Pts++
|
||||
store.channels[channelID] = channel
|
||||
store.ptsSeq[channelID] = channel.Pts
|
||||
store.appendChannelEventLocked(domain.ChannelUpdateEvent{
|
||||
ChannelID: channelID,
|
||||
Type: domain.ChannelUpdateNoop,
|
||||
Pts: channel.Pts,
|
||||
PtsCount: 0,
|
||||
Date: 1_700_020_001,
|
||||
})
|
||||
|
||||
_, err = store.PruneChannelUpdateEvents(ctx, channelID, channel.Pts, 100)
|
||||
if err == nil || !strings.Contains(err.Error(), "invalid pts_count=0") {
|
||||
t.Fatalf("prune invalid event err = %v, want fail-fast pts_count error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteExpiredChannelUpdateEventsIsBoundedMemory(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := NewChannelStore()
|
||||
created, err := store.CreateChannel(ctx, domain.CreateChannelRequest{
|
||||
CreatorUserID: 801,
|
||||
Title: "expired retention memory",
|
||||
Megagroup: true,
|
||||
Date: 1_600_000_000,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create channel: %v", err)
|
||||
}
|
||||
for i := 1; i <= 3; i++ {
|
||||
if _, err := store.SendChannelMessage(ctx, domain.SendChannelMessageRequest{
|
||||
UserID: 801, ChannelID: created.Channel.ID, RandomID: int64(8000 + i), Message: "old", Date: 1_600_000_000 + i,
|
||||
}); err != nil {
|
||||
t.Fatalf("send old message %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
deleted, err := store.DeleteExpiredChannelUpdateEvents(ctx, time.Hour, 2)
|
||||
if err != nil {
|
||||
t.Fatalf("delete expired channel updates: %v", err)
|
||||
}
|
||||
if deleted != 2 {
|
||||
t.Fatalf("deleted = %d, want bounded batch 2", deleted)
|
||||
}
|
||||
checkpoint := store.retention[created.Channel.ID]
|
||||
if checkpoint.RetainedThroughPts != 2 || len(store.events[created.Channel.ID]) != 2 {
|
||||
t.Fatalf("after bounded prune checkpoint=%+v events=%d, want floor=2 and 2 rows", checkpoint, len(store.events[created.Channel.ID]))
|
||||
}
|
||||
}
|
||||
|
|
@ -2,7 +2,11 @@ package memory
|
|||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
|
|
@ -37,7 +41,8 @@ func (s *ChannelStore) ListChannelDifference(_ context.Context, req domain.Chann
|
|||
Dialog: dialog,
|
||||
}, nil
|
||||
}
|
||||
if channel.Pts-req.Pts > limit {
|
||||
checkpoint := s.channelUpdateCheckpointLocked(req.ChannelID, channel)
|
||||
if req.Pts < checkpoint.RetainedThroughPts || channel.Pts-req.Pts > limit {
|
||||
messages := make([]domain.ChannelMessage, 0, domain.MaxChannelDifferenceTooLongMessages)
|
||||
for i := len(s.messages[req.ChannelID]) - 1; i >= 0 && len(messages) < domain.MaxChannelDifferenceTooLongMessages; i-- {
|
||||
msg := s.messages[req.ChannelID][i]
|
||||
|
|
@ -122,6 +127,170 @@ func (s *ChannelStore) MaxChannelPts(_ context.Context, channelID int64) (int, e
|
|||
return s.ptsSeq[channelID], nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) MaxChannelPtsBatch(_ context.Context, channelIDs []int64) (map[int64]int, error) {
|
||||
out := make(map[int64]int, len(channelIDs))
|
||||
s.mu.RLock()
|
||||
for _, channelID := range channelIDs {
|
||||
if pts, ok := s.ptsSeq[channelID]; ok {
|
||||
out[channelID] = pts
|
||||
}
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// appendChannelEventLocked is the only memory-store append boundary for channel-scoped durable
|
||||
// events. Keeping the checkpoint current here mirrors the PostgreSQL event+checkpoint transaction.
|
||||
func (s *ChannelStore) appendChannelEventLocked(event domain.ChannelUpdateEvent) {
|
||||
s.events[event.ChannelID] = append(s.events[event.ChannelID], event)
|
||||
checkpoint := s.retention[event.ChannelID]
|
||||
checkpoint.ChannelID = event.ChannelID
|
||||
if event.Pts > checkpoint.LatestPts {
|
||||
checkpoint.LatestPts = event.Pts
|
||||
}
|
||||
if event.Date > checkpoint.LatestEventDate {
|
||||
checkpoint.LatestEventDate = event.Date
|
||||
}
|
||||
s.retention[event.ChannelID] = checkpoint
|
||||
}
|
||||
|
||||
func (s *ChannelStore) channelUpdateCheckpointLocked(channelID int64, channel domain.Channel) domain.ChannelUpdateRetentionCheckpoint {
|
||||
checkpoint := s.retention[channelID]
|
||||
checkpoint.ChannelID = channelID
|
||||
if channel.Pts > checkpoint.LatestPts {
|
||||
checkpoint.LatestPts = channel.Pts
|
||||
}
|
||||
for _, event := range s.events[channelID] {
|
||||
if event.Pts > checkpoint.LatestPts {
|
||||
checkpoint.LatestPts = event.Pts
|
||||
}
|
||||
if event.Date > checkpoint.LatestEventDate {
|
||||
checkpoint.LatestEventDate = event.Date
|
||||
}
|
||||
}
|
||||
return checkpoint
|
||||
}
|
||||
|
||||
// PruneChannelUpdateEvents removes a bounded contiguous prefix and advances the retained floor in
|
||||
// the same memory-store critical section. throughPts may land inside a pts_count interval; that row
|
||||
// is retained because channel event rows are indivisible.
|
||||
func (s *ChannelStore) PruneChannelUpdateEvents(_ context.Context, channelID int64, throughPts, limit int) (domain.ChannelUpdateRetentionResult, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.pruneChannelUpdateEventsLocked(channelID, throughPts, 0, limit)
|
||||
}
|
||||
|
||||
// DeleteExpiredChannelUpdateEvents uses the oldest retained event of each channel as an indexed-seek
|
||||
// analogue, then prunes candidates oldest-first. There is no offset scan and total deleted rows never
|
||||
// exceeds limit.
|
||||
func (s *ChannelStore) DeleteExpiredChannelUpdateEvents(_ context.Context, olderThan time.Duration, limit int) (int, error) {
|
||||
if olderThan <= 0 {
|
||||
return 0, nil
|
||||
}
|
||||
limit = normalizeChannelRetentionLimit(limit)
|
||||
cutoff := int(time.Now().Add(-olderThan).Unix())
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
type candidate struct {
|
||||
channelID int64
|
||||
date int
|
||||
}
|
||||
candidates := make([]candidate, 0)
|
||||
for channelID, channel := range s.channels {
|
||||
checkpoint := s.channelUpdateCheckpointLocked(channelID, channel)
|
||||
for _, event := range s.events[channelID] {
|
||||
if event.Pts <= checkpoint.RetainedThroughPts {
|
||||
continue
|
||||
}
|
||||
if event.Date < cutoff {
|
||||
candidates = append(candidates, candidate{channelID: channelID, date: event.Date})
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
sort.Slice(candidates, func(i, j int) bool {
|
||||
if candidates[i].date == candidates[j].date {
|
||||
return candidates[i].channelID < candidates[j].channelID
|
||||
}
|
||||
return candidates[i].date < candidates[j].date
|
||||
})
|
||||
|
||||
deleted := 0
|
||||
for _, item := range candidates {
|
||||
if deleted >= limit {
|
||||
break
|
||||
}
|
||||
channel := s.channels[item.channelID]
|
||||
result, err := s.pruneChannelUpdateEventsLocked(item.channelID, channel.Pts, cutoff, limit-deleted)
|
||||
if err != nil {
|
||||
return deleted, err
|
||||
}
|
||||
deleted += result.Deleted
|
||||
}
|
||||
return deleted, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) pruneChannelUpdateEventsLocked(channelID int64, throughPts, beforeDate, limit int) (domain.ChannelUpdateRetentionResult, error) {
|
||||
channel, ok := s.channels[channelID]
|
||||
if !ok || channelID == 0 || throughPts < 0 {
|
||||
return domain.ChannelUpdateRetentionResult{}, domain.ErrChannelInvalid
|
||||
}
|
||||
limit = normalizeChannelRetentionLimit(limit)
|
||||
checkpoint := s.channelUpdateCheckpointLocked(channelID, channel)
|
||||
if throughPts > checkpoint.LatestPts {
|
||||
throughPts = checkpoint.LatestPts
|
||||
}
|
||||
if throughPts <= checkpoint.RetainedThroughPts {
|
||||
s.retention[channelID] = checkpoint
|
||||
return domain.ChannelUpdateRetentionResult{Checkpoint: checkpoint}, nil
|
||||
}
|
||||
|
||||
cursor := checkpoint.RetainedThroughPts
|
||||
deleted := 0
|
||||
keep := make([]domain.ChannelUpdateEvent, 0, len(s.events[channelID]))
|
||||
canPrune := true
|
||||
for _, event := range s.events[channelID] {
|
||||
if event.Pts <= checkpoint.RetainedThroughPts {
|
||||
keep = append(keep, event)
|
||||
continue
|
||||
}
|
||||
if !canPrune || deleted >= limit || event.Pts > throughPts || (beforeDate > 0 && event.Date >= beforeDate) {
|
||||
canPrune = false
|
||||
keep = append(keep, event)
|
||||
continue
|
||||
}
|
||||
ptsCount := event.PtsCount
|
||||
if ptsCount <= 0 {
|
||||
return domain.ChannelUpdateRetentionResult{}, fmt.Errorf(
|
||||
"prune channel update events: channel %d has invalid pts_count=%d at pts=%d",
|
||||
channelID, ptsCount, event.Pts,
|
||||
)
|
||||
}
|
||||
if event.Pts != cursor+ptsCount {
|
||||
return domain.ChannelUpdateRetentionResult{}, fmt.Errorf(
|
||||
"prune channel update events: channel %d has gap after pts %d: event pts=%d pts_count=%d",
|
||||
channelID, cursor, event.Pts, ptsCount,
|
||||
)
|
||||
}
|
||||
cursor = event.Pts
|
||||
deleted++
|
||||
}
|
||||
if deleted > 0 {
|
||||
s.events[channelID] = keep
|
||||
checkpoint.RetainedThroughPts = cursor
|
||||
}
|
||||
s.retention[channelID] = checkpoint
|
||||
return domain.ChannelUpdateRetentionResult{Checkpoint: checkpoint, Deleted: deleted}, nil
|
||||
}
|
||||
|
||||
func normalizeChannelRetentionLimit(limit int) int {
|
||||
if limit <= 0 || limit > domain.MaxChannelUpdateRetentionBatch {
|
||||
return domain.MaxChannelUpdateRetentionBatch
|
||||
}
|
||||
return limit
|
||||
}
|
||||
|
||||
func (s *ChannelStore) nextChannelPtsLocked(channelID int64) int {
|
||||
s.ptsSeq[channelID]++
|
||||
return s.ptsSeq[channelID]
|
||||
|
|
|
|||
70
internal/store/memory/code_cas.go
Normal file
70
internal/store/memory/code_cas.go
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
func (s *CodeStore) GetSnapshot(_ context.Context, hash string) (store.PhoneCodeSnapshot, bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
entry, found := s.liveCodeLocked(hash)
|
||||
if !found {
|
||||
return store.PhoneCodeSnapshot{}, false, nil
|
||||
}
|
||||
if entry.code.Version != store.PhoneCodeVersionCurrent || entry.code.Revision == "" {
|
||||
s.deleteCodeLocked(hash, entry.code)
|
||||
return store.PhoneCodeSnapshot{}, false, nil
|
||||
}
|
||||
return store.PhoneCodeSnapshot{Record: entry.code, Revision: entry.code.Revision}, true, nil
|
||||
}
|
||||
|
||||
func (s *CodeStore) CompareAndUpdate(_ context.Context, hash, expectedRevision string, next store.PhoneCode) (bool, error) {
|
||||
if expectedRevision == "" || next.Version != store.PhoneCodeVersionCurrent || next.Purpose != "" {
|
||||
return false, nil
|
||||
}
|
||||
revision, err := store.NewPhoneCodeRevisionToken()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
next.Revision = revision
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
entry, found := s.liveCodeLocked(hash)
|
||||
if !found {
|
||||
return false, nil
|
||||
}
|
||||
if entry.code.Version != store.PhoneCodeVersionCurrent || entry.code.Revision == "" {
|
||||
s.deleteCodeLocked(hash, entry.code)
|
||||
return false, nil
|
||||
}
|
||||
if entry.code.Purpose != "" || entry.code.Revision != expectedRevision {
|
||||
return false, nil
|
||||
}
|
||||
entry.code = next
|
||||
s.m[hash] = entry
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (s *CodeStore) CompareAndDelete(_ context.Context, hash, expectedRevision string) (bool, error) {
|
||||
if expectedRevision == "" {
|
||||
return false, nil
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
entry, found := s.liveCodeLocked(hash)
|
||||
if !found {
|
||||
return false, nil
|
||||
}
|
||||
if entry.code.Version != store.PhoneCodeVersionCurrent || entry.code.Revision == "" {
|
||||
s.deleteCodeLocked(hash, entry.code)
|
||||
return false, nil
|
||||
}
|
||||
if entry.code.Purpose != "" || entry.code.Revision != expectedRevision {
|
||||
return false, nil
|
||||
}
|
||||
s.deleteCodeLocked(hash, entry.code)
|
||||
return true, nil
|
||||
}
|
||||
213
internal/store/memory/code_cas_test.go
Normal file
213
internal/store/memory/code_cas_test.go
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
func TestCodeStoreRevisionCAS(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
codes := NewCodeStore()
|
||||
record := store.PhoneCode{
|
||||
Version: store.PhoneCodeVersionCurrent,
|
||||
Phone: "15550016201",
|
||||
Code: "111111",
|
||||
Channel: "email_setup",
|
||||
PendingEmail: "first@example.test",
|
||||
MaxAttempts: 5,
|
||||
}
|
||||
if err := codes.Set(ctx, "email-fixed", record, time.Minute); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
snapshot, found, err := codes.GetSnapshot(ctx, "email-fixed")
|
||||
if err != nil || !found || snapshot.Revision == "" || snapshot.Record.Revision != snapshot.Revision {
|
||||
t.Fatalf("snapshot=%+v found=%v err=%v", snapshot, found, err)
|
||||
}
|
||||
originalExpiry := codes.m["email-fixed"].expires
|
||||
|
||||
next := snapshot.Record
|
||||
next.Code = "222222"
|
||||
next.Attempts = 1
|
||||
if applied, err := codes.CompareAndUpdate(ctx, "email-fixed", "stale-token", next); err != nil || applied {
|
||||
t.Fatalf("wrong-token update applied=%v err=%v", applied, err)
|
||||
}
|
||||
unchanged, found, err := codes.GetSnapshot(ctx, "email-fixed")
|
||||
if err != nil || !found || unchanged.Revision != snapshot.Revision || unchanged.Record.Code != record.Code {
|
||||
t.Fatalf("after wrong-token snapshot=%+v found=%v err=%v", unchanged, found, err)
|
||||
}
|
||||
if applied, err := codes.CompareAndUpdate(ctx, "email-fixed", snapshot.Revision, next); err != nil || !applied {
|
||||
t.Fatalf("current-token update applied=%v err=%v", applied, err)
|
||||
}
|
||||
updated, found, err := codes.GetSnapshot(ctx, "email-fixed")
|
||||
if err != nil || !found || updated.Record.Code != next.Code || updated.Record.Attempts != 1 || updated.Revision == snapshot.Revision {
|
||||
t.Fatalf("updated snapshot=%+v found=%v err=%v", updated, found, err)
|
||||
}
|
||||
if expiry := codes.m["email-fixed"].expires; !expiry.Equal(originalExpiry) {
|
||||
t.Fatalf("CAS update expiry=%v, want unchanged %v", expiry, originalExpiry)
|
||||
}
|
||||
if applied, err := codes.CompareAndDelete(ctx, "email-fixed", snapshot.Revision); err != nil || applied {
|
||||
t.Fatalf("stale delete applied=%v err=%v", applied, err)
|
||||
}
|
||||
if applied, err := codes.CompareAndDelete(ctx, "email-fixed", updated.Revision); err != nil || !applied {
|
||||
t.Fatalf("current delete applied=%v err=%v", applied, err)
|
||||
}
|
||||
if _, found, _ := codes.GetSnapshot(ctx, "email-fixed"); found {
|
||||
t.Fatal("CAS-deleted code remains")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodeStoreRevisionCASFailClosedAndScopeIsolation(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
codes := NewCodeStore()
|
||||
now := time.Now().Add(time.Minute)
|
||||
codes.m["legacy"] = codeEntry{
|
||||
code: store.PhoneCode{Version: 0, Phone: "15550016202", Code: "12345"},
|
||||
expires: now,
|
||||
}
|
||||
if _, found, err := codes.GetSnapshot(ctx, "legacy"); err != nil || found {
|
||||
t.Fatalf("legacy snapshot found=%v err=%v", found, err)
|
||||
}
|
||||
if _, found := codes.m["legacy"]; found {
|
||||
t.Fatal("legacy snapshot record was not deleted")
|
||||
}
|
||||
codes.m["no-revision"] = codeEntry{
|
||||
code: store.PhoneCode{
|
||||
Version: store.PhoneCodeVersionCurrent,
|
||||
Phone: "15550016202",
|
||||
Code: "12345",
|
||||
},
|
||||
expires: now,
|
||||
}
|
||||
if _, found, err := codes.GetSnapshot(ctx, "no-revision"); err != nil || found {
|
||||
t.Fatalf("revisionless snapshot found=%v err=%v", found, err)
|
||||
}
|
||||
if _, found := codes.m["no-revision"]; found {
|
||||
t.Fatal("revisionless snapshot record was not deleted")
|
||||
}
|
||||
|
||||
scoped := store.PhoneCode{
|
||||
Version: store.PhoneCodeVersionCurrent,
|
||||
Phone: "15550016203",
|
||||
Code: "12345",
|
||||
Purpose: store.PhoneCodePurposeChangePhone,
|
||||
UserID: 42,
|
||||
AuthKeyID: [8]byte{1},
|
||||
}
|
||||
if err := codes.Set(ctx, "scoped-cas", scoped, time.Minute); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
snapshot, found, err := codes.GetSnapshot(ctx, "scoped-cas")
|
||||
if err != nil || !found {
|
||||
t.Fatalf("scoped snapshot found=%v err=%v", found, err)
|
||||
}
|
||||
if applied, err := codes.CompareAndUpdate(ctx, "scoped-cas", snapshot.Revision, snapshot.Record); err != nil || applied {
|
||||
t.Fatalf("scoped update applied=%v err=%v", applied, err)
|
||||
}
|
||||
if applied, err := codes.CompareAndDelete(ctx, "scoped-cas", snapshot.Revision); err != nil || applied {
|
||||
t.Fatalf("scoped delete applied=%v err=%v", applied, err)
|
||||
}
|
||||
if _, found, _ := codes.Get(ctx, "scoped-cas"); !found {
|
||||
t.Fatal("generic CAS mutated scoped code")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodeStoreRevisionCASPreventsABAAndHasSingleWinner(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
codes := NewCodeStore()
|
||||
record := store.PhoneCode{
|
||||
Version: store.PhoneCodeVersionCurrent,
|
||||
Phone: "15550016204",
|
||||
Code: "123456",
|
||||
Channel: "email_change",
|
||||
}
|
||||
if err := codes.Set(ctx, "aba", record, time.Minute); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
old, found, err := codes.GetSnapshot(ctx, "aba")
|
||||
if err != nil || !found {
|
||||
t.Fatalf("old snapshot found=%v err=%v", found, err)
|
||||
}
|
||||
if err := codes.Set(ctx, "aba", record, time.Minute); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
current, found, err := codes.GetSnapshot(ctx, "aba")
|
||||
if err != nil || !found || current.Revision == old.Revision {
|
||||
t.Fatalf("replacement snapshot=%+v old=%+v found=%v err=%v", current, old, found, err)
|
||||
}
|
||||
if applied, err := codes.CompareAndDelete(ctx, "aba", old.Revision); err != nil || applied {
|
||||
t.Fatalf("ABA stale delete applied=%v err=%v", applied, err)
|
||||
}
|
||||
|
||||
const workers = 64
|
||||
results := make(chan bool, workers)
|
||||
errs := make(chan error, workers)
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < workers; i++ {
|
||||
wg.Add(1)
|
||||
go func(index int) {
|
||||
defer wg.Done()
|
||||
next := current.Record
|
||||
next.Code = fmt.Sprintf("%06d", index)
|
||||
applied, err := codes.CompareAndUpdate(ctx, "aba", current.Revision, next)
|
||||
if err != nil {
|
||||
errs <- err
|
||||
return
|
||||
}
|
||||
results <- applied
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
close(results)
|
||||
close(errs)
|
||||
for err := range errs {
|
||||
t.Fatalf("concurrent CAS update: %v", err)
|
||||
}
|
||||
winners := 0
|
||||
for applied := range results {
|
||||
if applied {
|
||||
winners++
|
||||
}
|
||||
}
|
||||
if winners != 1 {
|
||||
t.Fatalf("concurrent CAS update winners=%d, want 1", winners)
|
||||
}
|
||||
winner, found, err := codes.GetSnapshot(ctx, "aba")
|
||||
if err != nil || !found || winner.Revision == current.Revision {
|
||||
t.Fatalf("winner snapshot=%+v found=%v err=%v", winner, found, err)
|
||||
}
|
||||
|
||||
results = make(chan bool, workers)
|
||||
errs = make(chan error, workers)
|
||||
for i := 0; i < workers; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
applied, err := codes.CompareAndDelete(ctx, "aba", winner.Revision)
|
||||
if err != nil {
|
||||
errs <- err
|
||||
return
|
||||
}
|
||||
results <- applied
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
close(results)
|
||||
close(errs)
|
||||
for err := range errs {
|
||||
t.Fatalf("concurrent CAS delete: %v", err)
|
||||
}
|
||||
winners = 0
|
||||
for applied := range results {
|
||||
if applied {
|
||||
winners++
|
||||
}
|
||||
}
|
||||
if winners != 1 {
|
||||
t.Fatalf("concurrent CAS delete winners=%d, want 1", winners)
|
||||
}
|
||||
}
|
||||
|
|
@ -13,6 +13,7 @@ func TestCodeStoreScopedRotationAndSingleConsume(t *testing.T) {
|
|||
ctx := context.Background()
|
||||
codes := NewCodeStore()
|
||||
rec := store.PhoneCode{
|
||||
Version: store.PhoneCodeVersionCurrent,
|
||||
Phone: "15550015001",
|
||||
Code: "12345",
|
||||
Purpose: store.PhoneCodePurposeChangePhone,
|
||||
|
|
@ -68,7 +69,7 @@ func TestCodeStoreScopedRotationAndSingleConsume(t *testing.T) {
|
|||
func TestCodeStoreScopedIsolation(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
codes := NewCodeStore()
|
||||
a := store.PhoneCode{Phone: "15550015002", Code: "12345", Purpose: store.PhoneCodePurposeChangePhone, UserID: 42, AuthKeyID: [8]byte{1}}
|
||||
a := store.PhoneCode{Version: store.PhoneCodeVersionCurrent, Phone: "15550015002", Code: "12345", Purpose: store.PhoneCodePurposeChangePhone, UserID: 42, AuthKeyID: [8]byte{1}}
|
||||
b := a
|
||||
b.AuthKeyID = [8]byte{2}
|
||||
if err := codes.Set(ctx, "hash-a", a, time.Minute); err != nil {
|
||||
|
|
@ -90,3 +91,24 @@ func TestCodeStoreScopedIsolation(t *testing.T) {
|
|||
t.Fatal("other scope was removed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodeStoreConsumeScopedRejectsAndDeletesLegacyVersion(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
codes := NewCodeStore()
|
||||
rec := store.PhoneCode{
|
||||
Version: 0, Phone: "15550015003", Code: "12345",
|
||||
Purpose: store.PhoneCodePurposeChangePhone, UserID: 43, AuthKeyID: [8]byte{3},
|
||||
}
|
||||
if err := codes.Set(ctx, "legacy-scope", rec, time.Minute); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, found, err := codes.ConsumeScoped(ctx, "legacy-scope", rec.Scope()); err != nil || found {
|
||||
t.Fatalf("legacy scoped consume found=%v err=%v, want false/nil", found, err)
|
||||
}
|
||||
if _, found, _ := codes.Get(ctx, "legacy-scope"); found {
|
||||
t.Fatal("legacy scoped code remains after fail-closed consume")
|
||||
}
|
||||
if _, ok := codes.scopes[rec.Scope()]; ok {
|
||||
t.Fatal("legacy scoped index remains after fail-closed consume")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
211
internal/store/memory/login_code.go
Normal file
211
internal/store/memory/login_code.go
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
func (s *CodeStore) VerifyLogin(_ context.Context, hash, phone, code string, keepForSignUp bool, defaultMaxAttempts int) (store.LoginCodeVerifyResult, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
entry, ok := s.liveCodeLocked(hash)
|
||||
if !ok {
|
||||
return store.LoginCodeVerifyResult{Status: store.LoginCodeVerifyMissing}, nil
|
||||
}
|
||||
record := entry.code
|
||||
if record.Version != store.PhoneCodeVersionCurrent {
|
||||
s.deleteCodeLocked(hash, record)
|
||||
return store.LoginCodeVerifyResult{Status: store.LoginCodeVerifyMissing}, nil
|
||||
}
|
||||
// Sign-up verification is a terminal state for VerifyLogin. Keep the marker
|
||||
// for the one caller that already received signUpRequired, but do not report
|
||||
// a second Accepted result or let later wrong-code calls exhaust it.
|
||||
if record.SignUpVerified {
|
||||
return store.LoginCodeVerifyResult{Status: store.LoginCodeVerifyMissing}, nil
|
||||
}
|
||||
if record.Purpose != "" || record.Phone != phone || !loginCodeVerifiable(record) || record.Code == "" || code == "" {
|
||||
return store.LoginCodeVerifyResult{Status: store.LoginCodeVerifyInvalid, Record: record}, nil
|
||||
}
|
||||
if subtle.ConstantTimeCompare([]byte(record.Code), []byte(code)) != 1 {
|
||||
revision, err := store.NewPhoneCodeRevisionToken()
|
||||
if err != nil {
|
||||
return store.LoginCodeVerifyResult{}, err
|
||||
}
|
||||
record.Attempts++
|
||||
record.Revision = revision
|
||||
entry.code = record
|
||||
maxAttempts := record.MaxAttempts
|
||||
if maxAttempts <= 0 {
|
||||
maxAttempts = defaultMaxAttempts
|
||||
}
|
||||
if maxAttempts <= 0 {
|
||||
maxAttempts = 1
|
||||
}
|
||||
if record.Attempts >= maxAttempts {
|
||||
s.deleteCodeLocked(hash, record)
|
||||
} else {
|
||||
s.m[hash] = entry
|
||||
}
|
||||
return store.LoginCodeVerifyResult{Status: store.LoginCodeVerifyInvalid, Record: record}, nil
|
||||
}
|
||||
|
||||
if keepForSignUp {
|
||||
if record.IssuedUserID != 0 {
|
||||
return store.LoginCodeVerifyResult{Status: store.LoginCodeVerifyInvalid, Record: record}, nil
|
||||
}
|
||||
revision, err := store.NewPhoneCodeRevisionToken()
|
||||
if err != nil {
|
||||
return store.LoginCodeVerifyResult{}, err
|
||||
}
|
||||
record.SignUpVerified = true
|
||||
record.Revision = revision
|
||||
entry.code = record
|
||||
s.m[hash] = entry
|
||||
} else {
|
||||
s.deleteCodeLocked(hash, record)
|
||||
}
|
||||
return store.LoginCodeVerifyResult{Status: store.LoginCodeVerifyAccepted, Record: record}, nil
|
||||
}
|
||||
|
||||
func (s *CodeStore) VerifyScoped(_ context.Context, hash string, scope store.PhoneCodeScope, code string, defaultMaxAttempts int) (store.LoginCodeVerifyResult, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if !scope.Valid() || s.scopes[scope] != hash {
|
||||
return store.LoginCodeVerifyResult{Status: store.LoginCodeVerifyMissing}, nil
|
||||
}
|
||||
entry, ok := s.liveCodeLocked(hash)
|
||||
if !ok {
|
||||
// liveCodeLocked removes the index when it can decode the stored scope;
|
||||
// also close the stale-index-only case.
|
||||
if s.scopes[scope] == hash {
|
||||
delete(s.scopes, scope)
|
||||
}
|
||||
return store.LoginCodeVerifyResult{Status: store.LoginCodeVerifyMissing}, nil
|
||||
}
|
||||
record := entry.code
|
||||
if record.Version != store.PhoneCodeVersionCurrent || record.Scope() != scope || record.SignUpVerified || record.Code == "" {
|
||||
// Fail closed on a legacy or internally inconsistent record. Clean both
|
||||
// the scope encoded in the record and the scope that selected this hash.
|
||||
s.deleteCodeLocked(hash, record)
|
||||
if s.scopes[scope] == hash {
|
||||
delete(s.scopes, scope)
|
||||
}
|
||||
return store.LoginCodeVerifyResult{Status: store.LoginCodeVerifyMissing}, nil
|
||||
}
|
||||
if code == "" {
|
||||
return store.LoginCodeVerifyResult{Status: store.LoginCodeVerifyInvalid, Record: record}, nil
|
||||
}
|
||||
if subtle.ConstantTimeCompare([]byte(record.Code), []byte(code)) != 1 {
|
||||
revision, err := store.NewPhoneCodeRevisionToken()
|
||||
if err != nil {
|
||||
return store.LoginCodeVerifyResult{}, err
|
||||
}
|
||||
record.Attempts++
|
||||
record.Revision = revision
|
||||
entry.code = record
|
||||
maxAttempts := record.MaxAttempts
|
||||
if maxAttempts <= 0 {
|
||||
maxAttempts = defaultMaxAttempts
|
||||
}
|
||||
if maxAttempts <= 0 {
|
||||
maxAttempts = 1
|
||||
}
|
||||
if record.Attempts >= maxAttempts {
|
||||
s.deleteCodeLocked(hash, record)
|
||||
} else {
|
||||
s.m[hash] = entry
|
||||
}
|
||||
return store.LoginCodeVerifyResult{Status: store.LoginCodeVerifyInvalid, Record: record}, nil
|
||||
}
|
||||
|
||||
s.deleteCodeLocked(hash, record)
|
||||
return store.LoginCodeVerifyResult{Status: store.LoginCodeVerifyAccepted, Record: record}, nil
|
||||
}
|
||||
|
||||
func (s *CodeStore) ConsumeSignUpVerified(_ context.Context, hash, phone string) (store.PhoneCode, bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
entry, ok := s.liveCodeLocked(hash)
|
||||
if !ok {
|
||||
return store.PhoneCode{}, false, nil
|
||||
}
|
||||
record := entry.code
|
||||
if record.Version != store.PhoneCodeVersionCurrent {
|
||||
s.deleteCodeLocked(hash, record)
|
||||
return store.PhoneCode{}, false, nil
|
||||
}
|
||||
if record.Purpose != "" || record.Phone != phone || !loginCodeVerifiable(record) || record.IssuedUserID != 0 || !record.SignUpVerified {
|
||||
return store.PhoneCode{}, false, nil
|
||||
}
|
||||
s.deleteCodeLocked(hash, record)
|
||||
return record, true, nil
|
||||
}
|
||||
|
||||
func (s *CodeStore) TakeLoginCode(_ context.Context, hash, phone string) (store.PhoneCode, bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
entry, ok := s.liveCodeLocked(hash)
|
||||
if !ok {
|
||||
return store.PhoneCode{}, false, nil
|
||||
}
|
||||
record := entry.code
|
||||
if record.Version != store.PhoneCodeVersionCurrent {
|
||||
s.deleteCodeLocked(hash, record)
|
||||
return store.PhoneCode{}, false, nil
|
||||
}
|
||||
if record.SignUpVerified {
|
||||
return store.PhoneCode{}, false, nil
|
||||
}
|
||||
if record.Purpose != "" || record.Phone != phone || !loginCodeTakeable(record) {
|
||||
return store.PhoneCode{}, false, nil
|
||||
}
|
||||
s.deleteCodeLocked(hash, record)
|
||||
return record, true, nil
|
||||
}
|
||||
|
||||
func (s *CodeStore) InvalidateLoginCode(_ context.Context, hash, phone string) (bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
entry, ok := s.liveCodeLocked(hash)
|
||||
if !ok {
|
||||
return false, nil
|
||||
}
|
||||
record := entry.code
|
||||
if record.Version != store.PhoneCodeVersionCurrent {
|
||||
s.deleteCodeLocked(hash, record)
|
||||
return false, nil
|
||||
}
|
||||
if record.Purpose != "" || record.Phone != phone || !loginCodeTakeable(record) {
|
||||
return false, nil
|
||||
}
|
||||
s.deleteCodeLocked(hash, record)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func loginCodeVerifiable(record store.PhoneCode) bool {
|
||||
return record.Channel == store.PhoneCodeChannelPhone || record.Channel == store.PhoneCodeChannelEmailLogin
|
||||
}
|
||||
|
||||
func loginCodeTakeable(record store.PhoneCode) bool {
|
||||
return loginCodeVerifiable(record) || record.Channel == store.PhoneCodeChannelEmailSetupRequired
|
||||
}
|
||||
|
||||
func (s *CodeStore) liveCodeLocked(hash string) (codeEntry, bool) {
|
||||
entry, ok := s.m[hash]
|
||||
if !ok {
|
||||
return codeEntry{}, false
|
||||
}
|
||||
if time.Now().After(entry.expires) {
|
||||
s.deleteCodeLocked(hash, entry.code)
|
||||
return codeEntry{}, false
|
||||
}
|
||||
return entry, true
|
||||
}
|
||||
129
internal/store/memory/login_code_delivery.go
Normal file
129
internal/store/memory/login_code_delivery.go
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
type loginCodeDeliveryRecord struct {
|
||||
userID int64
|
||||
codeFingerprint [32]byte
|
||||
privateMessageID int64
|
||||
messageBoxID int
|
||||
pts int
|
||||
messageDate int
|
||||
}
|
||||
|
||||
// LoginCodeDeliveryStore composes the message projection with the same
|
||||
// durable update-event store consumed by updates.getDifference. Keeping this
|
||||
// as an explicit dependency prevents tests (and future in-memory runtimes)
|
||||
// from accidentally creating a visible message without its pts event.
|
||||
type LoginCodeDeliveryStore struct {
|
||||
messages *MessageStore
|
||||
events *UpdateEventStore
|
||||
}
|
||||
|
||||
func NewLoginCodeDeliveryStore(messages *MessageStore, events *UpdateEventStore) *LoginCodeDeliveryStore {
|
||||
return &LoginCodeDeliveryStore{messages: messages, events: events}
|
||||
}
|
||||
|
||||
// DeliverLoginCodeMessage is the in-memory equivalent of the PostgreSQL
|
||||
// transaction. Message, dialog, pts, durable event and immutable receipt are
|
||||
// published under the two backing stores' locks; a repeated phone_code_hash
|
||||
// returns the first snapshot.
|
||||
func (s *LoginCodeDeliveryStore) DeliverLoginCodeMessage(_ context.Context, req domain.LoginCodeDeliveryRequest) (domain.LoginCodeDeliveryResult, error) {
|
||||
if s == nil || s.messages == nil || s.events == nil {
|
||||
return domain.LoginCodeDeliveryResult{}, fmt.Errorf("memory login code delivery: %w: message and update stores are required", domain.ErrLoginCodeDeliveryInvalid)
|
||||
}
|
||||
deliveryKey, err := store.LoginCodeDeliveryKey(req.PhoneCodeHash)
|
||||
if err != nil {
|
||||
return domain.LoginCodeDeliveryResult{}, err
|
||||
}
|
||||
codeFingerprint, err := store.LoginCodeFingerprint(req.PhoneCodeHash, req.Code)
|
||||
if err != nil {
|
||||
return domain.LoginCodeDeliveryResult{}, err
|
||||
}
|
||||
if req.Date == 0 {
|
||||
req.Date = int(time.Now().Unix())
|
||||
}
|
||||
if req.ExpiresAt <= int64(req.Date) {
|
||||
return domain.LoginCodeDeliveryResult{}, fmt.Errorf("memory login code receipt expiry: %w: date=%d expires_at=%d", domain.ErrLoginCodeDeliveryInvalid, req.Date, req.ExpiresAt)
|
||||
}
|
||||
base, err := domain.OfficialLoginCodeMessage(req.UserID, req.Code, req.Date)
|
||||
if err != nil {
|
||||
return domain.LoginCodeDeliveryResult{}, err
|
||||
}
|
||||
|
||||
// Lock ordering is local and fixed: MessageStore -> UpdateEventStore ->
|
||||
// DialogStore. No other memory operation takes the first two together.
|
||||
s.messages.mu.Lock()
|
||||
defer s.messages.mu.Unlock()
|
||||
s.events.mu.Lock()
|
||||
defer s.events.mu.Unlock()
|
||||
if receipt, ok := s.messages.loginCodeDeliveries[deliveryKey]; ok {
|
||||
if receipt.userID != req.UserID || !store.SameLoginCodeFingerprint(receipt.codeFingerprint[:], codeFingerprint) {
|
||||
return domain.LoginCodeDeliveryResult{}, fmt.Errorf("memory login code delivery: %w", domain.ErrLoginCodeDeliveryConflict)
|
||||
}
|
||||
msg, err := store.RestoreLoginCodeDeliveryMessage(
|
||||
receipt.userID,
|
||||
req.Code,
|
||||
receipt.messageDate,
|
||||
receipt.privateMessageID,
|
||||
receipt.messageBoxID,
|
||||
receipt.pts,
|
||||
)
|
||||
if err != nil {
|
||||
return domain.LoginCodeDeliveryResult{}, fmt.Errorf("memory login code delivery replay: %w", err)
|
||||
}
|
||||
return domain.LoginCodeDeliveryResult{Message: msg, Created: false}, nil
|
||||
}
|
||||
|
||||
currentEventPts := 0
|
||||
for _, event := range s.events.events[req.UserID] {
|
||||
if event.Pts > currentEventPts {
|
||||
currentEventPts = event.Pts
|
||||
}
|
||||
}
|
||||
if messagePts := s.messages.nextPts[req.UserID]; messagePts > currentEventPts {
|
||||
return domain.LoginCodeDeliveryResult{}, fmt.Errorf("memory login code delivery: %w: message pts %d exceeds durable event pts %d", domain.ErrLoginCodeDeliveryInvalid, messagePts, currentEventPts)
|
||||
}
|
||||
|
||||
base.ID = s.messages.nextBoxIDLocked(req.UserID)
|
||||
base.UID = s.messages.nextUID
|
||||
s.messages.nextUID++
|
||||
base.Pts = currentEventPts + 1
|
||||
|
||||
s.messages.nextPts[req.UserID] = base.Pts
|
||||
s.messages.m[req.UserID] = append(s.messages.m[req.UserID], cloneMessage(base))
|
||||
if s.messages.dialogs != nil {
|
||||
s.messages.dialogs.mu.Lock()
|
||||
list := s.messages.dialogs.m[req.UserID]
|
||||
list = upsertMemoryDialog(list, domain.Dialog{
|
||||
Peer: base.Peer,
|
||||
TopMessage: base.ID,
|
||||
TopMessageDate: base.Date,
|
||||
UnreadCount: s.messages.privateUnreadCountLocked(req.UserID, base.Peer),
|
||||
})
|
||||
if !hasUser(list.Users, domain.OfficialSystemUserID) {
|
||||
list.Users = append(list.Users, domain.OfficialSystemUser())
|
||||
}
|
||||
list.Messages = append(list.Messages, cloneMessage(base))
|
||||
s.messages.dialogs.m[req.UserID] = list
|
||||
s.messages.dialogs.mu.Unlock()
|
||||
}
|
||||
event := newMessageEvent(base)
|
||||
s.events.events[req.UserID] = append(s.events.events[req.UserID], cloneUpdateEvent(event))
|
||||
s.messages.loginCodeDeliveries[deliveryKey] = loginCodeDeliveryRecord{
|
||||
userID: req.UserID,
|
||||
codeFingerprint: codeFingerprint,
|
||||
privateMessageID: base.UID,
|
||||
messageBoxID: base.ID,
|
||||
pts: base.Pts,
|
||||
messageDate: base.Date,
|
||||
}
|
||||
return domain.LoginCodeDeliveryResult{Message: cloneMessage(base), Created: true}, nil
|
||||
}
|
||||
165
internal/store/memory/login_code_delivery_test.go
Normal file
165
internal/store/memory/login_code_delivery_test.go
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"reflect"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func TestLoginCodeDeliveryStoreCommitsMessageEventDialogAndReplay(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
const userID int64 = 1000000001
|
||||
dialogs := NewDialogStore()
|
||||
messages := NewMessageStore(dialogs)
|
||||
events := NewUpdateEventStore()
|
||||
deliveries := NewLoginCodeDeliveryStore(messages, events)
|
||||
req := domain.LoginCodeDeliveryRequest{
|
||||
UserID: userID,
|
||||
PhoneCodeHash: "phone-code-hash-one",
|
||||
Code: "12345",
|
||||
Date: 1700000000,
|
||||
ExpiresAt: 1700000300,
|
||||
}
|
||||
|
||||
first, err := deliveries.DeliverLoginCodeMessage(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("DeliverLoginCodeMessage: %v", err)
|
||||
}
|
||||
if !first.Created || first.Message.ID != 1 || first.Message.UID != 1 || first.Message.Pts != 1 || first.Message.Out ||
|
||||
first.Message.OwnerUserID != userID || first.Message.Peer.ID != domain.OfficialSystemUserID || first.Message.From.ID != domain.OfficialSystemUserID {
|
||||
t.Fatalf("first delivery = %+v, want first incoming 777000 message", first)
|
||||
}
|
||||
if len(messages.m[userID]) != 1 || !reflect.DeepEqual(messages.m[userID][0], first.Message) {
|
||||
t.Fatalf("message projection = %+v, want committed message", messages.m[userID])
|
||||
}
|
||||
if len(events.events[userID]) != 1 {
|
||||
t.Fatalf("durable events = %+v, want one new_message", events.events[userID])
|
||||
}
|
||||
event := events.events[userID][0]
|
||||
if event.Type != domain.UpdateEventNewMessage || event.Pts != first.Message.Pts || event.PtsCount != 1 || !reflect.DeepEqual(event.Message, first.Message) {
|
||||
t.Fatalf("event = %+v, want message-identical new_message", event)
|
||||
}
|
||||
list := dialogs.m[userID]
|
||||
if len(list.Dialogs) != 1 || list.Dialogs[0].Peer.ID != domain.OfficialSystemUserID || list.Dialogs[0].TopMessage != first.Message.ID || list.Dialogs[0].UnreadCount != 1 {
|
||||
t.Fatalf("dialog projection = %+v, want unread 777000 dialog", list.Dialogs)
|
||||
}
|
||||
if len(list.Users) != 1 || list.Users[0].ID != domain.OfficialSystemUserID {
|
||||
t.Fatalf("dialog users = %+v, want official system user", list.Users)
|
||||
}
|
||||
|
||||
replayReq := req
|
||||
replayReq.Date++
|
||||
replay, err := deliveries.DeliverLoginCodeMessage(ctx, replayReq)
|
||||
if err != nil {
|
||||
t.Fatalf("replay DeliverLoginCodeMessage: %v", err)
|
||||
}
|
||||
if replay.Created || !reflect.DeepEqual(replay.Message, first.Message) {
|
||||
t.Fatalf("replay = %+v, want immutable first result %+v", replay, first)
|
||||
}
|
||||
if len(messages.m[userID]) != 1 || len(events.events[userID]) != 1 || len(messages.loginCodeDeliveries) != 1 {
|
||||
t.Fatalf("replay created facts: messages=%d events=%d receipts=%d", len(messages.m[userID]), len(events.events[userID]), len(messages.loginCodeDeliveries))
|
||||
}
|
||||
|
||||
second, err := deliveries.DeliverLoginCodeMessage(ctx, domain.LoginCodeDeliveryRequest{
|
||||
UserID: userID,
|
||||
PhoneCodeHash: "phone-code-hash-two",
|
||||
Code: "67890",
|
||||
Date: 1700000010,
|
||||
ExpiresAt: 1700000310,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("second distinct delivery: %v", err)
|
||||
}
|
||||
if !second.Created || second.Message.ID != 2 || second.Message.UID != 2 || second.Message.Pts != 2 || len(events.events[userID]) != 2 {
|
||||
t.Fatalf("second delivery = %+v events=%+v, want contiguous allocations", second, events.events[userID])
|
||||
}
|
||||
if got := dialogs.m[userID].Dialogs[0].UnreadCount; got != 2 {
|
||||
t.Fatalf("dialog unread = %d, want 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginCodeDeliveryStoreConcurrentReplayAndConflict(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
const userID int64 = 1000000002
|
||||
messages := NewMessageStore(NewDialogStore())
|
||||
events := NewUpdateEventStore()
|
||||
deliveries := NewLoginCodeDeliveryStore(messages, events)
|
||||
req := domain.LoginCodeDeliveryRequest{
|
||||
UserID: userID,
|
||||
PhoneCodeHash: "concurrent-phone-code-hash",
|
||||
Code: "24680",
|
||||
Date: 1700000100,
|
||||
ExpiresAt: 1700000400,
|
||||
}
|
||||
|
||||
const workers = 32
|
||||
var created atomic.Int32
|
||||
results := make(chan domain.LoginCodeDeliveryResult, workers)
|
||||
errs := make(chan error, workers)
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < workers; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
got, err := deliveries.DeliverLoginCodeMessage(ctx, req)
|
||||
if err != nil {
|
||||
errs <- err
|
||||
return
|
||||
}
|
||||
if got.Created {
|
||||
created.Add(1)
|
||||
}
|
||||
results <- got
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
close(errs)
|
||||
close(results)
|
||||
for err := range errs {
|
||||
t.Fatalf("concurrent delivery: %v", err)
|
||||
}
|
||||
if created.Load() != 1 {
|
||||
t.Fatalf("created calls = %d, want exactly 1", created.Load())
|
||||
}
|
||||
for got := range results {
|
||||
if got.Message.ID != 1 || got.Message.UID != 1 || got.Message.Pts != 1 {
|
||||
t.Fatalf("concurrent result = %+v, want the same first allocation", got)
|
||||
}
|
||||
}
|
||||
if len(messages.m[userID]) != 1 || len(events.events[userID]) != 1 || len(messages.loginCodeDeliveries) != 1 {
|
||||
t.Fatalf("concurrent facts: messages=%d events=%d receipts=%d", len(messages.m[userID]), len(events.events[userID]), len(messages.loginCodeDeliveries))
|
||||
}
|
||||
|
||||
changedCode := req
|
||||
changedCode.Code = "13579"
|
||||
if _, err := deliveries.DeliverLoginCodeMessage(ctx, changedCode); !errors.Is(err, domain.ErrLoginCodeDeliveryConflict) {
|
||||
t.Fatalf("changed-code replay err = %v, want ErrLoginCodeDeliveryConflict", err)
|
||||
}
|
||||
changedUser := req
|
||||
changedUser.UserID++
|
||||
if _, err := deliveries.DeliverLoginCodeMessage(ctx, changedUser); !errors.Is(err, domain.ErrLoginCodeDeliveryConflict) {
|
||||
t.Fatalf("changed-user replay err = %v, want ErrLoginCodeDeliveryConflict", err)
|
||||
}
|
||||
if len(messages.m[userID]) != 1 || len(events.events[userID]) != 1 {
|
||||
t.Fatal("conflicting replay changed committed facts")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginCodeDeliveryStoreRequiresSharedEventStore(t *testing.T) {
|
||||
messages := NewMessageStore()
|
||||
_, err := NewLoginCodeDeliveryStore(messages, nil).DeliverLoginCodeMessage(context.Background(), domain.LoginCodeDeliveryRequest{
|
||||
UserID: 1000000003,
|
||||
PhoneCodeHash: "missing-event-store",
|
||||
Code: "12345",
|
||||
Date: 1700000200,
|
||||
ExpiresAt: 1700000500,
|
||||
})
|
||||
if !errors.Is(err, domain.ErrLoginCodeDeliveryInvalid) {
|
||||
t.Fatalf("missing event store err = %v, want ErrLoginCodeDeliveryInvalid", err)
|
||||
}
|
||||
}
|
||||
106
internal/store/memory/login_code_invalidate_test.go
Normal file
106
internal/store/memory/login_code_invalidate_test.go
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
func TestCodeStoreAtomicLoginInvalidation(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
const phone = "15550016011"
|
||||
newRecord := func() store.PhoneCode {
|
||||
return store.PhoneCode{
|
||||
Version: store.PhoneCodeVersionCurrent,
|
||||
Phone: phone,
|
||||
Code: "12345",
|
||||
Channel: store.PhoneCodeChannelPhone,
|
||||
MaxAttempts: 5,
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("owner cleanup may delete a terminal sign-up marker", func(t *testing.T) {
|
||||
codes := NewCodeStore()
|
||||
if err := codes.Set(ctx, "invalidate-marker", newRecord(), time.Minute); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
verified, err := codes.VerifyLogin(ctx, "invalidate-marker", phone, "12345", true, 5)
|
||||
if err != nil || verified.Status != store.LoginCodeVerifyAccepted || !verified.Record.SignUpVerified {
|
||||
t.Fatalf("mark sign-up = %+v err=%v", verified, err)
|
||||
}
|
||||
if removed, err := codes.InvalidateLoginCode(ctx, "invalidate-marker", "15550016999"); err != nil || removed {
|
||||
t.Fatalf("cross-phone invalidate removed=%v err=%v", removed, err)
|
||||
}
|
||||
if _, found, err := codes.ConsumeSignUpVerified(ctx, "invalidate-marker", "15550016999"); err != nil || found {
|
||||
t.Fatalf("cross-phone consume found=%v err=%v", found, err)
|
||||
}
|
||||
if removed, err := codes.InvalidateLoginCode(ctx, "invalidate-marker", phone); err != nil || !removed {
|
||||
t.Fatalf("owner invalidate removed=%v err=%v", removed, err)
|
||||
}
|
||||
if _, found, err := codes.ConsumeSignUpVerified(ctx, "invalidate-marker", phone); err != nil || found {
|
||||
t.Fatalf("consume after invalidate found=%v err=%v", found, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("legacy records fail closed", func(t *testing.T) {
|
||||
codes := NewCodeStore()
|
||||
legacy := newRecord()
|
||||
legacy.Version = 0
|
||||
if err := codes.Set(ctx, "invalidate-legacy", legacy, time.Minute); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if removed, err := codes.InvalidateLoginCode(ctx, "invalidate-legacy", phone); err != nil || removed {
|
||||
t.Fatalf("legacy invalidate removed=%v err=%v, want false", removed, err)
|
||||
}
|
||||
if _, found, err := codes.Get(ctx, "invalidate-legacy"); err != nil || found {
|
||||
t.Fatalf("legacy record found=%v err=%v after fail-closed invalidate", found, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalidate and sign-up consume have one winner", func(t *testing.T) {
|
||||
codes := NewCodeStore()
|
||||
if err := codes.Set(ctx, "invalidate-race", newRecord(), time.Minute); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if verified, err := codes.VerifyLogin(ctx, "invalidate-race", phone, "12345", true, 5); err != nil || verified.Status != store.LoginCodeVerifyAccepted {
|
||||
t.Fatalf("mark sign-up = %+v err=%v", verified, err)
|
||||
}
|
||||
|
||||
const workers = 64
|
||||
results := make(chan bool, workers)
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < workers; i++ {
|
||||
wg.Add(1)
|
||||
go func(invalidate bool) {
|
||||
defer wg.Done()
|
||||
if invalidate {
|
||||
removed, err := codes.InvalidateLoginCode(ctx, "invalidate-race", phone)
|
||||
if err != nil {
|
||||
t.Errorf("InvalidateLoginCode: %v", err)
|
||||
}
|
||||
results <- removed
|
||||
return
|
||||
}
|
||||
_, found, err := codes.ConsumeSignUpVerified(ctx, "invalidate-race", phone)
|
||||
if err != nil {
|
||||
t.Errorf("ConsumeSignUpVerified: %v", err)
|
||||
}
|
||||
results <- found
|
||||
}(i%2 == 0)
|
||||
}
|
||||
wg.Wait()
|
||||
close(results)
|
||||
winners := 0
|
||||
for won := range results {
|
||||
if won {
|
||||
winners++
|
||||
}
|
||||
}
|
||||
if winners != 1 {
|
||||
t.Fatalf("invalidate/consume winners=%d, want 1", winners)
|
||||
}
|
||||
})
|
||||
}
|
||||
447
internal/store/memory/login_code_state_test.go
Normal file
447
internal/store/memory/login_code_state_test.go
Normal file
|
|
@ -0,0 +1,447 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
func TestCodeStoreAtomicLoginStateMachine(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
const phone = "15550016001"
|
||||
newRecord := func() store.PhoneCode {
|
||||
return store.PhoneCode{
|
||||
Version: store.PhoneCodeVersionCurrent,
|
||||
IssuedUserID: 1000000001,
|
||||
Phone: phone,
|
||||
Code: "12345",
|
||||
Channel: "phone",
|
||||
MaxAttempts: 2,
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("version mismatch fails closed for every atomic entry", func(t *testing.T) {
|
||||
codes := NewCodeStore()
|
||||
legacy := newRecord()
|
||||
legacy.Version = 0
|
||||
if err := codes.Set(ctx, "legacy-verify", legacy, time.Minute); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, err := codes.VerifyLogin(ctx, "legacy-verify", phone, legacy.Code, false, 5)
|
||||
if err != nil || result.Status != store.LoginCodeVerifyMissing {
|
||||
t.Fatalf("legacy VerifyLogin = %+v err=%v, want Missing", result, err)
|
||||
}
|
||||
if _, found, _ := codes.Get(ctx, "legacy-verify"); found {
|
||||
t.Fatal("legacy VerifyLogin record was not deleted")
|
||||
}
|
||||
|
||||
unknown := newRecord()
|
||||
unknown.Version = store.PhoneCodeVersionCurrent + 1
|
||||
if err := codes.Set(ctx, "unknown-take", unknown, time.Minute); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, found, err := codes.TakeLoginCode(ctx, "unknown-take", phone); err != nil || found {
|
||||
t.Fatalf("unknown TakeLoginCode found=%v err=%v, want false", found, err)
|
||||
}
|
||||
if _, found, _ := codes.Get(ctx, "unknown-take"); found {
|
||||
t.Fatal("unknown TakeLoginCode record was not deleted")
|
||||
}
|
||||
|
||||
legacy.SignUpVerified = true
|
||||
if err := codes.Set(ctx, "legacy-signup", legacy, time.Minute); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, found, err := codes.ConsumeSignUpVerified(ctx, "legacy-signup", phone); err != nil || found {
|
||||
t.Fatalf("legacy ConsumeSignUpVerified found=%v err=%v, want false", found, err)
|
||||
}
|
||||
if _, found, _ := codes.Get(ctx, "legacy-signup"); found {
|
||||
t.Fatal("legacy sign-up marker was not deleted")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("scope mismatch does not burn victim attempts", func(t *testing.T) {
|
||||
codes := NewCodeStore()
|
||||
record := newRecord()
|
||||
if err := codes.Set(ctx, "scope", record, time.Minute); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, err := codes.VerifyLogin(ctx, "scope", "15550016999", record.Code, false, 5)
|
||||
if err != nil || result.Status != store.LoginCodeVerifyInvalid || result.Record.Attempts != 0 {
|
||||
t.Fatalf("wrong-phone VerifyLogin = %+v err=%v", result, err)
|
||||
}
|
||||
stored, found, err := codes.Get(ctx, "scope")
|
||||
if err != nil || !found || stored.Attempts != 0 {
|
||||
t.Fatalf("wrong-phone stored=%+v found=%v err=%v", stored, found, err)
|
||||
}
|
||||
if _, found, err := codes.TakeLoginCode(ctx, "scope", "15550016999"); err != nil || found {
|
||||
t.Fatalf("cross-phone TakeLoginCode found=%v err=%v", found, err)
|
||||
}
|
||||
|
||||
scoped := newRecord()
|
||||
scoped.Purpose = store.PhoneCodePurposeChangePhone
|
||||
scoped.UserID = 42
|
||||
scoped.AuthKeyID = [8]byte{1}
|
||||
if err := codes.Set(ctx, "scoped", scoped, time.Minute); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, err = codes.VerifyLogin(ctx, "scoped", phone, scoped.Code, false, 5)
|
||||
if err != nil || result.Status != store.LoginCodeVerifyInvalid {
|
||||
t.Fatalf("scoped VerifyLogin = %+v err=%v, want Invalid", result, err)
|
||||
}
|
||||
if _, found, err := codes.TakeLoginCode(ctx, "scoped", phone); err != nil || found {
|
||||
t.Fatalf("scoped TakeLoginCode found=%v err=%v", found, err)
|
||||
}
|
||||
if _, found, _ := codes.Get(ctx, "scoped"); !found {
|
||||
t.Fatal("login operations deleted a scoped change-phone code")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("wrong code increments atomically and threshold deletes", func(t *testing.T) {
|
||||
codes := NewCodeStore()
|
||||
record := newRecord()
|
||||
if err := codes.Set(ctx, "wrong", record, time.Minute); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first, err := codes.VerifyLogin(ctx, "wrong", phone, "00000", false, 9)
|
||||
if err != nil || first.Status != store.LoginCodeVerifyInvalid || first.Record.Attempts != 1 {
|
||||
t.Fatalf("first wrong code = %+v err=%v", first, err)
|
||||
}
|
||||
stored, found, err := codes.Get(ctx, "wrong")
|
||||
if err != nil || !found || stored.Attempts != 1 {
|
||||
t.Fatalf("stored after first wrong = %+v found=%v err=%v", stored, found, err)
|
||||
}
|
||||
second, err := codes.VerifyLogin(ctx, "wrong", phone, "00000", false, 9)
|
||||
if err != nil || second.Status != store.LoginCodeVerifyInvalid || second.Record.Attempts != 2 {
|
||||
t.Fatalf("threshold wrong code = %+v err=%v", second, err)
|
||||
}
|
||||
if _, found, _ := codes.Get(ctx, "wrong"); found {
|
||||
t.Fatal("threshold-exhausted code remains")
|
||||
}
|
||||
after, err := codes.VerifyLogin(ctx, "wrong", phone, record.Code, false, 9)
|
||||
if err != nil || after.Status != store.LoginCodeVerifyMissing {
|
||||
t.Fatalf("verify after exhaustion = %+v err=%v, want Missing", after, err)
|
||||
}
|
||||
|
||||
fallback := newRecord()
|
||||
fallback.MaxAttempts = 0
|
||||
if err := codes.Set(ctx, "fallback", fallback, time.Minute); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, err := codes.VerifyLogin(ctx, "fallback", phone, "bad", false, 1); err != nil || got.Status != store.LoginCodeVerifyInvalid {
|
||||
t.Fatalf("default threshold verify = %+v err=%v", got, err)
|
||||
}
|
||||
if _, found, _ := codes.Get(ctx, "fallback"); found {
|
||||
t.Fatal("default threshold did not delete code")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("accepted consume and sign-up marker are terminal", func(t *testing.T) {
|
||||
codes := NewCodeStore()
|
||||
record := newRecord()
|
||||
if err := codes.Set(ctx, "consume", record, time.Minute); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
accepted, err := codes.VerifyLogin(ctx, "consume", phone, record.Code, false, 5)
|
||||
if err != nil || accepted.Status != store.LoginCodeVerifyAccepted || accepted.Record.SignUpVerified {
|
||||
t.Fatalf("consume verify = %+v err=%v", accepted, err)
|
||||
}
|
||||
if _, found, _ := codes.Get(ctx, "consume"); found {
|
||||
t.Fatal("accepted existing-user code remains")
|
||||
}
|
||||
|
||||
if err := codes.Set(ctx, "issued-existing", record, time.Minute); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wrongScope, err := codes.VerifyLogin(ctx, "issued-existing", phone, record.Code, true, 5)
|
||||
if err != nil || wrongScope.Status != store.LoginCodeVerifyInvalid {
|
||||
t.Fatalf("existing-issued keep-for-signup = %+v err=%v, want Invalid", wrongScope, err)
|
||||
}
|
||||
if _, found, err := codes.ConsumeSignUpVerified(ctx, "issued-existing", phone); err != nil || found {
|
||||
t.Fatalf("existing-issued sign-up consume found=%v err=%v", found, err)
|
||||
}
|
||||
|
||||
signUpRecord := record
|
||||
signUpRecord.IssuedUserID = 0
|
||||
if err := codes.Set(ctx, "signup", signUpRecord, time.Minute); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
expires := codes.m["signup"].expires
|
||||
marked, err := codes.VerifyLogin(ctx, "signup", phone, record.Code, true, 5)
|
||||
if err != nil || marked.Status != store.LoginCodeVerifyAccepted || !marked.Record.SignUpVerified {
|
||||
t.Fatalf("sign-up verify = %+v err=%v", marked, err)
|
||||
}
|
||||
if got := codes.m["signup"]; !got.code.SignUpVerified || !got.expires.Equal(expires) {
|
||||
t.Fatalf("sign-up marker=%+v expiry=%v, want marker with unchanged %v", got.code, got.expires, expires)
|
||||
}
|
||||
repeated, err := codes.VerifyLogin(ctx, "signup", phone, record.Code, true, 5)
|
||||
if err != nil || repeated.Status != store.LoginCodeVerifyMissing {
|
||||
t.Fatalf("repeated sign-up verify = %+v err=%v, want terminal Missing", repeated, err)
|
||||
}
|
||||
if _, found, err := codes.ConsumeSignUpVerified(ctx, "signup", "15550016999"); err != nil || found {
|
||||
t.Fatalf("cross-phone sign-up consume found=%v err=%v", found, err)
|
||||
}
|
||||
if _, found, err := codes.TakeLoginCode(ctx, "signup", phone); err != nil || found {
|
||||
t.Fatalf("terminal marker take found=%v err=%v, want false", found, err)
|
||||
}
|
||||
consumed, found, err := codes.ConsumeSignUpVerified(ctx, "signup", phone)
|
||||
if err != nil || !found || !consumed.SignUpVerified || consumed.Code != signUpRecord.Code || consumed.IssuedUserID != 0 {
|
||||
t.Fatalf("sign-up consume = %+v found=%v err=%v", consumed, found, err)
|
||||
}
|
||||
if _, found, err := codes.ConsumeSignUpVerified(ctx, "signup", phone); err != nil || found {
|
||||
t.Fatalf("second sign-up consume found=%v err=%v", found, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("take returns the removed record exactly once", func(t *testing.T) {
|
||||
codes := NewCodeStore()
|
||||
record := newRecord()
|
||||
if err := codes.Set(ctx, "take", record, time.Minute); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
expected, found, err := codes.Get(ctx, "take")
|
||||
if err != nil || !found {
|
||||
t.Fatalf("load take record found=%v err=%v", found, err)
|
||||
}
|
||||
if _, found, err := codes.TakeLoginCode(ctx, "take", "15550016999"); err != nil || found {
|
||||
t.Fatalf("cross-phone take found=%v err=%v", found, err)
|
||||
}
|
||||
taken, found, err := codes.TakeLoginCode(ctx, "take", phone)
|
||||
if err != nil || !found || taken != expected {
|
||||
t.Fatalf("take = %+v found=%v err=%v, want %+v", taken, found, err, expected)
|
||||
}
|
||||
if _, found, err := codes.TakeLoginCode(ctx, "take", phone); err != nil || found {
|
||||
t.Fatalf("second take found=%v err=%v", found, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCodeStoreAtomicLoginConcurrency(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
const (
|
||||
phone = "15550016002"
|
||||
workers = 64
|
||||
)
|
||||
newRecord := func() store.PhoneCode {
|
||||
return store.PhoneCode{
|
||||
Version: store.PhoneCodeVersionCurrent,
|
||||
Phone: phone,
|
||||
Code: "12345",
|
||||
Channel: "phone",
|
||||
MaxAttempts: 7,
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("consume verify has one accepted", func(t *testing.T) {
|
||||
codes := NewCodeStore()
|
||||
if err := codes.Set(ctx, "verify-race", newRecord(), time.Minute); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
statuses := concurrentMemoryVerify(t, codes, "verify-race", phone, "12345", false, workers)
|
||||
if statuses[store.LoginCodeVerifyAccepted] != 1 || statuses[store.LoginCodeVerifyMissing] != workers-1 || statuses[store.LoginCodeVerifyInvalid] != 0 {
|
||||
t.Fatalf("verify race statuses = %+v", statuses)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("mark and consume each have one winner", func(t *testing.T) {
|
||||
codes := NewCodeStore()
|
||||
if err := codes.Set(ctx, "signup-race", newRecord(), time.Minute); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
statuses := concurrentMemoryVerify(t, codes, "signup-race", phone, "12345", true, workers)
|
||||
if statuses[store.LoginCodeVerifyAccepted] != 1 || statuses[store.LoginCodeVerifyMissing] != workers-1 {
|
||||
t.Fatalf("sign-up verify race statuses = %+v", statuses)
|
||||
}
|
||||
found := concurrentMemoryConsumeSignUp(t, codes, "signup-race", phone, workers)
|
||||
if found != 1 {
|
||||
t.Fatalf("sign-up consumes = %d, want 1", found)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("take has one winner", func(t *testing.T) {
|
||||
codes := NewCodeStore()
|
||||
if err := codes.Set(ctx, "take-race", newRecord(), time.Minute); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
found := concurrentMemoryTake(t, codes, "take-race", phone, workers)
|
||||
if found != 1 {
|
||||
t.Fatalf("takes = %d, want 1", found)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("wrong attempts cannot be lost", func(t *testing.T) {
|
||||
codes := NewCodeStore()
|
||||
if err := codes.Set(ctx, "wrong-race", newRecord(), time.Minute); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
statuses := concurrentMemoryVerify(t, codes, "wrong-race", phone, "00000", false, workers)
|
||||
if statuses[store.LoginCodeVerifyInvalid] != 7 || statuses[store.LoginCodeVerifyMissing] != workers-7 {
|
||||
t.Fatalf("wrong-code race statuses = %+v, want 7 Invalid then Missing", statuses)
|
||||
}
|
||||
if _, found, _ := codes.Get(ctx, "wrong-race"); found {
|
||||
t.Fatal("wrong-code race left an exhausted code")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("verify and cancel-resend take share one winner", func(t *testing.T) {
|
||||
codes := NewCodeStore()
|
||||
if err := codes.Set(ctx, "mixed-race", newRecord(), time.Minute); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
results := make(chan bool, workers)
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < workers; i++ {
|
||||
wg.Add(1)
|
||||
go func(take bool) {
|
||||
defer wg.Done()
|
||||
if take {
|
||||
_, found, err := codes.TakeLoginCode(ctx, "mixed-race", phone)
|
||||
if err != nil {
|
||||
t.Errorf("TakeLoginCode: %v", err)
|
||||
}
|
||||
results <- found
|
||||
return
|
||||
}
|
||||
verified, err := codes.VerifyLogin(ctx, "mixed-race", phone, "12345", false, 5)
|
||||
if err != nil {
|
||||
t.Errorf("VerifyLogin: %v", err)
|
||||
}
|
||||
results <- verified.Status == store.LoginCodeVerifyAccepted
|
||||
}(i%2 == 0)
|
||||
}
|
||||
wg.Wait()
|
||||
close(results)
|
||||
winners := 0
|
||||
for won := range results {
|
||||
if won {
|
||||
winners++
|
||||
}
|
||||
}
|
||||
if winners != 1 {
|
||||
t.Fatalf("mixed verify/take winners = %d, want 1", winners)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("sign-up mark and take share one winner", func(t *testing.T) {
|
||||
codes := NewCodeStore()
|
||||
if err := codes.Set(ctx, "mixed-signup-race", newRecord(), time.Minute); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
results := make(chan bool, workers)
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < workers; i++ {
|
||||
wg.Add(1)
|
||||
go func(take bool) {
|
||||
defer wg.Done()
|
||||
if take {
|
||||
_, found, err := codes.TakeLoginCode(ctx, "mixed-signup-race", phone)
|
||||
if err != nil {
|
||||
t.Errorf("TakeLoginCode: %v", err)
|
||||
}
|
||||
results <- found
|
||||
return
|
||||
}
|
||||
verified, err := codes.VerifyLogin(ctx, "mixed-signup-race", phone, "12345", true, 5)
|
||||
if err != nil {
|
||||
t.Errorf("VerifyLogin: %v", err)
|
||||
}
|
||||
results <- verified.Status == store.LoginCodeVerifyAccepted
|
||||
}(i%2 == 0)
|
||||
}
|
||||
wg.Wait()
|
||||
close(results)
|
||||
winners := 0
|
||||
for won := range results {
|
||||
if won {
|
||||
winners++
|
||||
}
|
||||
}
|
||||
if winners != 1 {
|
||||
t.Fatalf("mixed sign-up/take winners = %d, want 1", winners)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func concurrentMemoryVerify(t *testing.T, codes *CodeStore, hash, phone, code string, keep bool, workers int) map[store.LoginCodeVerifyStatus]int {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
results := make(chan store.LoginCodeVerifyStatus, workers)
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < workers; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
result, err := codes.VerifyLogin(ctx, hash, phone, code, keep, 5)
|
||||
if err != nil {
|
||||
t.Errorf("VerifyLogin: %v", err)
|
||||
return
|
||||
}
|
||||
results <- result.Status
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
close(results)
|
||||
counts := make(map[store.LoginCodeVerifyStatus]int)
|
||||
for status := range results {
|
||||
counts[status]++
|
||||
}
|
||||
return counts
|
||||
}
|
||||
|
||||
func concurrentMemoryTake(t *testing.T, codes *CodeStore, hash, phone string, workers int) int {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
results := make(chan bool, workers)
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < workers; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_, found, err := codes.TakeLoginCode(ctx, hash, phone)
|
||||
if err != nil {
|
||||
t.Errorf("TakeLoginCode: %v", err)
|
||||
return
|
||||
}
|
||||
results <- found
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
close(results)
|
||||
foundCount := 0
|
||||
for found := range results {
|
||||
if found {
|
||||
foundCount++
|
||||
}
|
||||
}
|
||||
return foundCount
|
||||
}
|
||||
|
||||
func concurrentMemoryConsumeSignUp(t *testing.T, codes *CodeStore, hash, phone string, workers int) int {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
results := make(chan bool, workers)
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < workers; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_, found, err := codes.ConsumeSignUpVerified(ctx, hash, phone)
|
||||
if err != nil {
|
||||
t.Errorf("ConsumeSignUpVerified: %v", err)
|
||||
return
|
||||
}
|
||||
results <- found
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
close(results)
|
||||
foundCount := 0
|
||||
for found := range results {
|
||||
if found {
|
||||
foundCount++
|
||||
}
|
||||
}
|
||||
return foundCount
|
||||
}
|
||||
|
|
@ -37,9 +37,12 @@ func (s *MessageStore) DeleteMessages(_ context.Context, req domain.DeleteMessag
|
|||
}
|
||||
|
||||
type deletedMemoryMessage struct {
|
||||
userID int64
|
||||
peer domain.Peer
|
||||
id int
|
||||
userID int64
|
||||
peer domain.Peer
|
||||
id int
|
||||
privateMessageID int64
|
||||
messageSenderID int64
|
||||
randomID int64
|
||||
}
|
||||
|
||||
func (s *MessageStore) finishMemoryDeleteLocked(res domain.DeleteMessagesResult, deleted []deletedMemoryMessage, date int, preserveEmptyDialogs bool) domain.DeleteMessagesResult {
|
||||
|
|
@ -83,6 +86,19 @@ func (s *MessageStore) finishMemoryDeleteLocked(res domain.DeleteMessagesResult,
|
|||
Date: date,
|
||||
MessageIDs: ids,
|
||||
}
|
||||
for _, row := range deleted {
|
||||
if row.userID != userID || row.messageSenderID != userID || row.randomID == 0 || row.privateMessageID == 0 {
|
||||
continue
|
||||
}
|
||||
key := privateSendDedupKey{senderUserID: userID, randomID: row.randomID}
|
||||
record, ok := s.privateSendDedup[key]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
cloned := cloneUpdateEvent(event)
|
||||
record.senderDeleteEvent = &cloned
|
||||
s.privateSendDedup[key] = record
|
||||
}
|
||||
res.Deleted = append(res.Deleted, domain.DeletedMessagesForUser{
|
||||
UserID: userID,
|
||||
MessageIDs: ids,
|
||||
|
|
|
|||
|
|
@ -21,7 +21,14 @@ func (s *MessageStore) deleteMemoryMessagesLocked(userID int64, limit int, match
|
|||
more = true
|
||||
continue
|
||||
}
|
||||
deleted = append(deleted, deletedMemoryMessage{userID: userID, peer: msg.Peer, id: msg.ID})
|
||||
deleted = append(deleted, deletedMemoryMessage{
|
||||
userID: userID,
|
||||
peer: msg.Peer,
|
||||
id: msg.ID,
|
||||
privateMessageID: msg.UID,
|
||||
messageSenderID: msg.From.ID,
|
||||
randomID: msg.RandomID,
|
||||
})
|
||||
if msg.UID != 0 {
|
||||
revokeUIDs[msg.UID] = struct{}{}
|
||||
}
|
||||
|
|
@ -45,7 +52,14 @@ func (s *MessageStore) deleteMemoryMessagesByUIDLocked(uids map[int64]struct{},
|
|||
kept := messages[:0]
|
||||
for _, msg := range messages {
|
||||
if _, ok := uids[msg.UID]; ok {
|
||||
deleted = append(deleted, deletedMemoryMessage{userID: userID, peer: msg.Peer, id: msg.ID})
|
||||
deleted = append(deleted, deletedMemoryMessage{
|
||||
userID: userID,
|
||||
peer: msg.Peer,
|
||||
id: msg.ID,
|
||||
privateMessageID: msg.UID,
|
||||
messageSenderID: msg.From.ID,
|
||||
randomID: msg.RandomID,
|
||||
})
|
||||
continue
|
||||
}
|
||||
kept = append(kept, msg)
|
||||
|
|
|
|||
164
internal/store/memory/message_idempotency_test.go
Normal file
164
internal/store/memory/message_idempotency_test.go
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func TestMessageStorePrivateRandomIDConflictAndReplayFacts(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
messages := NewMessageStore()
|
||||
base := domain.SendPrivateTextRequest{
|
||||
SenderUserID: 1001, RecipientUserID: 1002, RandomID: 501,
|
||||
Message: "immutable", Date: 1700000000,
|
||||
}
|
||||
first, err := messages.SendPrivateText(ctx, base)
|
||||
if err != nil {
|
||||
t.Fatalf("first send: %v", err)
|
||||
}
|
||||
replay := base
|
||||
replay.Date++
|
||||
replay.OriginSessionID = 77
|
||||
replay.RecipientBlocked = true
|
||||
duplicate, err := messages.SendPrivateText(ctx, replay)
|
||||
if err != nil {
|
||||
t.Fatalf("exact replay: %v", err)
|
||||
}
|
||||
if !duplicate.Duplicate || duplicate.SenderMessage.ID != first.SenderMessage.ID || duplicate.RecipientMessage.ID != first.RecipientMessage.ID {
|
||||
t.Fatalf("exact replay = %+v, want original delivered boxes", duplicate)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(*domain.SendPrivateTextRequest)
|
||||
}{
|
||||
{name: "peer", mutate: func(req *domain.SendPrivateTextRequest) { req.RecipientUserID = 1003 }},
|
||||
{name: "body", mutate: func(req *domain.SendPrivateTextRequest) { req.Message = "changed" }},
|
||||
{name: "media", mutate: func(req *domain.SendPrivateTextRequest) {
|
||||
req.Media = &domain.MessageMedia{
|
||||
Kind: domain.MessageMediaKindContact,
|
||||
Contact: &domain.MessageContact{
|
||||
PhoneNumber: "+10000000000",
|
||||
FirstName: "Changed",
|
||||
},
|
||||
}
|
||||
}},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
req := base
|
||||
tc.mutate(&req)
|
||||
if _, err := messages.SendPrivateText(ctx, req); !errors.Is(err, domain.ErrMessageRandomIDDuplicate) {
|
||||
t.Fatalf("conflicting replay err = %v, want ErrMessageRandomIDDuplicate", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageStorePrivateRandomIDSelfAndBlockedReplay(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
messages := NewMessageStore()
|
||||
selfReq := domain.SendPrivateTextRequest{
|
||||
SenderUserID: 2001, RecipientUserID: 2001, RandomID: 601,
|
||||
Message: "saved note", Date: 1700000100,
|
||||
}
|
||||
self, err := messages.SendPrivateText(ctx, selfReq)
|
||||
if err != nil {
|
||||
t.Fatalf("self first: %v", err)
|
||||
}
|
||||
selfReq.Date++
|
||||
selfReplay, err := messages.SendPrivateText(ctx, selfReq)
|
||||
if err != nil {
|
||||
t.Fatalf("self replay: %v", err)
|
||||
}
|
||||
if !selfReplay.Duplicate || selfReplay.SenderMessage.ID != self.SenderMessage.ID || selfReplay.RecipientMessage.ID != self.SenderMessage.ID {
|
||||
t.Fatalf("self replay = %+v, want original single box", selfReplay)
|
||||
}
|
||||
|
||||
blockedReq := domain.SendPrivateTextRequest{
|
||||
SenderUserID: 2002, RecipientUserID: 2003, RandomID: 602,
|
||||
Message: "blocked", Date: 1700000110, RecipientBlocked: true,
|
||||
}
|
||||
blocked, err := messages.SendPrivateText(ctx, blockedReq)
|
||||
if err != nil {
|
||||
t.Fatalf("blocked first: %v", err)
|
||||
}
|
||||
if blocked.RecipientMessage.ID != 0 {
|
||||
t.Fatalf("blocked recipient = %+v, want empty", blocked.RecipientMessage)
|
||||
}
|
||||
blockedReq.Date++
|
||||
blockedReq.RecipientBlocked = false
|
||||
blockedReplay, err := messages.SendPrivateText(ctx, blockedReq)
|
||||
if err != nil {
|
||||
t.Fatalf("blocked replay: %v", err)
|
||||
}
|
||||
if !blockedReplay.Duplicate || blockedReplay.SenderMessage.ID != blocked.SenderMessage.ID || blockedReplay.RecipientMessage.ID != 0 {
|
||||
t.Fatalf("blocked replay = %+v, want original sender-only result", blockedReplay)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageStorePrivateRandomIDReplayUsesCurrentSnapshotAndDurableDeleteMemory(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
messages := NewMessageStore()
|
||||
req := domain.SendPrivateTextRequest{
|
||||
SenderUserID: 3001, RecipientUserID: 3002, RandomID: 701,
|
||||
Message: "original", Date: 1700000200,
|
||||
}
|
||||
first, err := messages.SendPrivateText(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("first send: %v", err)
|
||||
}
|
||||
edited, err := messages.EditMessage(ctx, domain.EditMessageRequest{
|
||||
OwnerUserID: req.SenderUserID,
|
||||
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: req.RecipientUserID},
|
||||
ID: first.SenderMessage.ID,
|
||||
Message: "edited projection",
|
||||
EditDate: 1700000201,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("edit message: %v", err)
|
||||
}
|
||||
senderPtsBeforeReplay := messages.nextPts[req.SenderUserID]
|
||||
recipientPtsBeforeReplay := messages.nextPts[req.RecipientUserID]
|
||||
replay, err := messages.SendPrivateText(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("replay after edit: %v", err)
|
||||
}
|
||||
if replay.SenderMessage.ID != first.SenderMessage.ID || replay.SenderMessage.Pts != edited.Self().Message.Pts || replay.SenderMessage.Body != "edited projection" {
|
||||
t.Fatalf("replay after edit = %+v, want current visible snapshot", replay.SenderMessage)
|
||||
}
|
||||
if replay.SenderEvent.Pts != first.SenderEvent.Pts || replay.ReplayDeleteEvent != nil {
|
||||
t.Fatalf("replay after edit event = %+v delete=%+v, want original send pts and no delete", replay.SenderEvent, replay.ReplayDeleteEvent)
|
||||
}
|
||||
if messages.nextPts[req.SenderUserID] != senderPtsBeforeReplay || messages.nextPts[req.RecipientUserID] != recipientPtsBeforeReplay {
|
||||
t.Fatalf("edit replay advanced pts sender/recipient = %d/%d, want %d/%d", messages.nextPts[req.SenderUserID], messages.nextPts[req.RecipientUserID], senderPtsBeforeReplay, recipientPtsBeforeReplay)
|
||||
}
|
||||
deleted, err := messages.DeleteMessages(ctx, domain.DeleteMessagesRequest{
|
||||
OwnerUserID: req.SenderUserID,
|
||||
IDs: []int{first.SenderMessage.ID},
|
||||
Revoke: true,
|
||||
Date: 1700000202,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("delete message: %v", err)
|
||||
}
|
||||
senderPtsBeforeReplay = messages.nextPts[req.SenderUserID]
|
||||
recipientPtsBeforeReplay = messages.nextPts[req.RecipientUserID]
|
||||
replay, err = messages.SendPrivateText(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("replay after delete: %v", err)
|
||||
}
|
||||
if replay.SenderMessage.ID != first.SenderMessage.ID || replay.SenderMessage.Pts != first.SenderMessage.Pts || replay.SenderMessage.Body != "original" {
|
||||
t.Fatalf("replay after delete = %+v, want immutable first snapshot", replay.SenderMessage)
|
||||
}
|
||||
if replay.ReplayDeleteEvent == nil || replay.ReplayDeleteEvent.Pts != deleted.Self().Event.Pts ||
|
||||
len(replay.ReplayDeleteEvent.MessageIDs) != 1 || replay.ReplayDeleteEvent.MessageIDs[0] != first.SenderMessage.ID {
|
||||
t.Fatalf("replay delete event = %+v, want durable delete %+v", replay.ReplayDeleteEvent, deleted.Self().Event)
|
||||
}
|
||||
if messages.nextPts[req.SenderUserID] != senderPtsBeforeReplay || messages.nextPts[req.RecipientUserID] != recipientPtsBeforeReplay {
|
||||
t.Fatalf("delete replay advanced pts sender/recipient = %d/%d, want %d/%d", messages.nextPts[req.SenderUserID], messages.nextPts[req.RecipientUserID], senderPtsBeforeReplay, recipientPtsBeforeReplay)
|
||||
}
|
||||
}
|
||||
|
|
@ -2,10 +2,25 @@ package memory
|
|||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
"time"
|
||||
)
|
||||
|
||||
type privateSendDedupKey struct {
|
||||
senderUserID int64
|
||||
randomID int64
|
||||
}
|
||||
|
||||
type privateSendDedupRecord struct {
|
||||
recipientUserID int64
|
||||
senderSnapshot []byte
|
||||
recipientMessage domain.Message
|
||||
fingerprint []byte
|
||||
senderDeleteEvent *domain.UpdateEvent
|
||||
}
|
||||
|
||||
func (s *MessageStore) Create(_ context.Context, msg domain.Message) (domain.Message, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
|
@ -30,29 +45,19 @@ func (s *MessageStore) Create(_ context.Context, msg domain.Message) (domain.Mes
|
|||
}
|
||||
|
||||
func (s *MessageStore) SendPrivateText(_ context.Context, req domain.SendPrivateTextRequest) (domain.SendPrivateTextResult, error) {
|
||||
fingerprint, err := store.PrivateSendFingerprint(req)
|
||||
if err != nil {
|
||||
return domain.SendPrivateTextResult{}, err
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for _, msg := range s.m[req.SenderUserID] {
|
||||
if msg.RandomID != 0 && msg.RandomID == req.RandomID {
|
||||
recipient := domain.Message{}
|
||||
if req.SenderUserID != req.RecipientUserID {
|
||||
for _, peerMsg := range s.m[req.RecipientUserID] {
|
||||
if peerMsg.UID == msg.UID {
|
||||
recipient = peerMsg
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
recipient = msg
|
||||
}
|
||||
return domain.SendPrivateTextResult{
|
||||
SenderMessage: cloneMessage(msg),
|
||||
RecipientMessage: cloneMessage(recipient),
|
||||
SenderEvent: newMessageEvent(msg),
|
||||
RecipientEvent: newMessageEvent(recipient),
|
||||
Duplicate: true,
|
||||
}, nil
|
||||
}
|
||||
if replay, found, err := s.lookupPrivateSendReplayLocked(domain.PrivateSendReplayRequest{
|
||||
SenderUserID: req.SenderUserID,
|
||||
RecipientUserID: req.RecipientUserID,
|
||||
RandomID: req.RandomID,
|
||||
IdempotencyFingerprint: fingerprint,
|
||||
}); err != nil || found {
|
||||
return replay, err
|
||||
}
|
||||
if req.Date == 0 {
|
||||
req.Date = int(time.Now().Unix())
|
||||
|
|
@ -109,10 +114,25 @@ func (s *MessageStore) SendPrivateText(_ context.Context, req domain.SendPrivate
|
|||
recipient.Pts = s.nextPtsLocked(req.RecipientUserID)
|
||||
recipient.MediaUnread = req.Media.HasUnreadPayload()
|
||||
}
|
||||
var senderSnapshot []byte
|
||||
if req.RandomID != 0 {
|
||||
senderSnapshot, err = store.EncodePrivateSendSnapshot(sender)
|
||||
if err != nil {
|
||||
return domain.SendPrivateTextResult{}, err
|
||||
}
|
||||
}
|
||||
s.m[req.SenderUserID] = append(s.m[req.SenderUserID], sender)
|
||||
if req.SenderUserID != req.RecipientUserID && !req.RecipientBlocked {
|
||||
s.m[req.RecipientUserID] = append(s.m[req.RecipientUserID], recipient)
|
||||
}
|
||||
if req.RandomID != 0 {
|
||||
s.privateSendDedup[privateSendDedupKey{senderUserID: req.SenderUserID, randomID: req.RandomID}] = privateSendDedupRecord{
|
||||
recipientUserID: req.RecipientUserID,
|
||||
senderSnapshot: senderSnapshot,
|
||||
recipientMessage: immutablePrivateSendReceipt(recipient),
|
||||
fingerprint: append([]byte(nil), fingerprint...),
|
||||
}
|
||||
}
|
||||
if s.dialogs != nil {
|
||||
if recipient.ID != 0 {
|
||||
s.upsertMemoryDialogsLocked(sender, recipient)
|
||||
|
|
@ -128,6 +148,81 @@ func (s *MessageStore) SendPrivateText(_ context.Context, req domain.SendPrivate
|
|||
}, nil
|
||||
}
|
||||
|
||||
// LookupPrivateSendReplay returns an existing immutable/current replay receipt without running
|
||||
// any send permission, reply resolution or allocation path.
|
||||
func (s *MessageStore) LookupPrivateSendReplay(_ context.Context, req domain.PrivateSendReplayRequest) (domain.SendPrivateTextResult, bool, error) {
|
||||
if req.SenderUserID == 0 || req.RecipientUserID == 0 || req.RandomID == 0 {
|
||||
return domain.SendPrivateTextResult{}, false, fmt.Errorf("memory private send replay: invalid scope")
|
||||
}
|
||||
if err := store.ValidateSendFingerprint(req.IdempotencyFingerprint, "private send replay"); err != nil {
|
||||
return domain.SendPrivateTextResult{}, false, err
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.lookupPrivateSendReplayLocked(req)
|
||||
}
|
||||
|
||||
func (s *MessageStore) lookupPrivateSendReplayLocked(req domain.PrivateSendReplayRequest) (domain.SendPrivateTextResult, bool, error) {
|
||||
record, ok := s.privateSendDedup[privateSendDedupKey{senderUserID: req.SenderUserID, randomID: req.RandomID}]
|
||||
if !ok {
|
||||
return domain.SendPrivateTextResult{}, false, nil
|
||||
}
|
||||
if record.recipientUserID != req.RecipientUserID || !store.SameSendFingerprint(record.fingerprint, req.IdempotencyFingerprint) {
|
||||
return domain.SendPrivateTextResult{}, false, domain.ErrMessageRandomIDDuplicate
|
||||
}
|
||||
firstSender, err := store.DecodePrivateSendSnapshot(record.senderSnapshot)
|
||||
if err != nil {
|
||||
return domain.SendPrivateTextResult{}, false, fmt.Errorf("memory duplicate private message snapshot: %w", err)
|
||||
}
|
||||
sender := firstSender
|
||||
visible := false
|
||||
for _, current := range s.m[req.SenderUserID] {
|
||||
if current.UID == firstSender.UID && current.ID == firstSender.ID {
|
||||
sender = cloneMessage(current)
|
||||
sender.RandomID = firstSender.RandomID
|
||||
visible = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !visible && record.senderDeleteEvent == nil {
|
||||
return domain.SendPrivateTextResult{}, false, fmt.Errorf("memory duplicate private message %d is absent without a durable sender delete receipt", firstSender.UID)
|
||||
}
|
||||
recipient := cloneMessage(record.recipientMessage)
|
||||
var replayDelete *domain.UpdateEvent
|
||||
if record.senderDeleteEvent != nil {
|
||||
cloned := cloneUpdateEvent(*record.senderDeleteEvent)
|
||||
replayDelete = &cloned
|
||||
}
|
||||
return domain.SendPrivateTextResult{
|
||||
SenderMessage: sender,
|
||||
RecipientMessage: recipient,
|
||||
SenderEvent: newMessageEvent(firstSender),
|
||||
RecipientEvent: newMessageEvent(recipient),
|
||||
Duplicate: true,
|
||||
ReplayDeleteEvent: replayDelete,
|
||||
}, true, nil
|
||||
}
|
||||
|
||||
// immutablePrivateSendReceipt keeps the recipient allocation facts used by
|
||||
// store-level idempotency tests. The sender response snapshot is stored as a
|
||||
// versioned JSON value above so all nested media/reply graphs are immutable.
|
||||
func immutablePrivateSendReceipt(msg domain.Message) domain.Message {
|
||||
if msg.ID == 0 {
|
||||
return domain.Message{}
|
||||
}
|
||||
return domain.Message{
|
||||
ID: msg.ID,
|
||||
UID: msg.UID,
|
||||
RandomID: msg.RandomID,
|
||||
OwnerUserID: msg.OwnerUserID,
|
||||
Peer: msg.Peer,
|
||||
From: msg.From,
|
||||
Date: msg.Date,
|
||||
Out: msg.Out,
|
||||
Pts: msg.Pts,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *MessageStore) resolveMemoryReplyLocked(req domain.SendPrivateTextRequest) (*domain.MessageReply, *domain.MessageReply, error) {
|
||||
if req.ReplyTo == nil {
|
||||
return nil, nil, nil
|
||||
|
|
|
|||
|
|
@ -7,14 +7,17 @@ import (
|
|||
|
||||
// MessageStore 是 store.MessageStore 的内存实现。
|
||||
type MessageStore struct {
|
||||
mu sync.RWMutex
|
||||
m map[int64][]domain.Message
|
||||
nextUID int64
|
||||
nextBox map[int64]int
|
||||
nextPts map[int64]int
|
||||
readOutboxDates map[readOutboxDateKey]int
|
||||
privateReactions map[int64]map[int64][]domain.ChannelMessagePeerReaction
|
||||
dialogs *DialogStore
|
||||
mu sync.RWMutex
|
||||
m map[int64][]domain.Message
|
||||
nextUID int64
|
||||
nextBox map[int64]int
|
||||
nextPts map[int64]int
|
||||
readOutboxDates map[readOutboxDateKey]int
|
||||
privateReactions map[int64]map[int64][]domain.ChannelMessagePeerReaction
|
||||
privateSendDedup map[privateSendDedupKey]privateSendDedupRecord
|
||||
loginCodeDeliveries map[[32]byte]loginCodeDeliveryRecord
|
||||
albumGroups map[albumGroupKey]albumGroupRecord
|
||||
dialogs *DialogStore
|
||||
// polls 是共享 poll 权威(投票校验与读路径 enrichment);nil 时 poll 链路按未接入处理。
|
||||
polls *PollStore
|
||||
// savedPins 是收藏夹子会话置顶顺序(下标即 pinned_order,越小越前)。
|
||||
|
|
@ -35,13 +38,16 @@ type readOutboxDateKey struct {
|
|||
// NewMessageStore 创建内存 MessageStore。
|
||||
func NewMessageStore(dialogs ...*DialogStore) *MessageStore {
|
||||
s := &MessageStore{
|
||||
m: make(map[int64][]domain.Message),
|
||||
nextUID: 1,
|
||||
nextBox: make(map[int64]int),
|
||||
nextPts: make(map[int64]int),
|
||||
readOutboxDates: make(map[readOutboxDateKey]int),
|
||||
privateReactions: make(map[int64]map[int64][]domain.ChannelMessagePeerReaction),
|
||||
savedPins: make(map[int64][]domain.Peer),
|
||||
m: make(map[int64][]domain.Message),
|
||||
nextUID: 1,
|
||||
nextBox: make(map[int64]int),
|
||||
nextPts: make(map[int64]int),
|
||||
readOutboxDates: make(map[readOutboxDateKey]int),
|
||||
privateReactions: make(map[int64]map[int64][]domain.ChannelMessagePeerReaction),
|
||||
privateSendDedup: make(map[privateSendDedupKey]privateSendDedupRecord),
|
||||
loginCodeDeliveries: make(map[[32]byte]loginCodeDeliveryRecord),
|
||||
albumGroups: make(map[albumGroupKey]albumGroupRecord),
|
||||
savedPins: make(map[int64][]domain.Peer),
|
||||
}
|
||||
if len(dialogs) > 0 {
|
||||
s.dialogs = dialogs[0]
|
||||
|
|
|
|||
|
|
@ -162,7 +162,7 @@ func TestMessageStoreWebViewDataServiceActionRoundTrip(t *testing.T) {
|
|||
assertWebViewData("sender", got.SenderMessage)
|
||||
assertWebViewData("recipient", got.RecipientMessage)
|
||||
|
||||
dup, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
|
||||
_, err = messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
|
||||
SenderUserID: req.SenderUserID,
|
||||
RecipientUserID: req.RecipientUserID,
|
||||
RandomID: req.RandomID,
|
||||
|
|
@ -178,13 +178,9 @@ func TestMessageStoreWebViewDataServiceActionRoundTrip(t *testing.T) {
|
|||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SendPrivateText duplicate: %v", err)
|
||||
if !errors.Is(err, domain.ErrMessageRandomIDDuplicate) {
|
||||
t.Fatalf("changed-media duplicate err = %v, want ErrMessageRandomIDDuplicate", err)
|
||||
}
|
||||
if !dup.Duplicate || dup.SenderMessage.ID != got.SenderMessage.ID || dup.RecipientMessage.ID != got.RecipientMessage.ID {
|
||||
t.Fatalf("duplicate = %+v, want original boxes", dup)
|
||||
}
|
||||
assertWebViewData("duplicate sender", dup.SenderMessage)
|
||||
|
||||
recipientHistory, err := messages.ListByUser(ctx, req.RecipientUserID, domain.MessageFilter{
|
||||
HasPeer: true,
|
||||
|
|
|
|||
240
internal/store/memory/scoped_code_state_test.go
Normal file
240
internal/store/memory/scoped_code_state_test.go
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
func TestCodeStoreAtomicScopedVerification(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
newRecord := func() store.PhoneCode {
|
||||
return store.PhoneCode{
|
||||
Version: store.PhoneCodeVersionCurrent,
|
||||
Phone: "15550016021",
|
||||
Code: "12345",
|
||||
Channel: store.PhoneCodeChannelPhone,
|
||||
Purpose: store.PhoneCodePurposeChangePhone,
|
||||
UserID: 420021,
|
||||
AuthKeyID: [8]byte{1, 2, 3, 4},
|
||||
MaxAttempts: 2,
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("only the active hash and exact scope can mutate", func(t *testing.T) {
|
||||
codes := NewCodeStore()
|
||||
record := newRecord()
|
||||
if err := codes.Set(ctx, "scoped-old", record, time.Minute); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := codes.Set(ctx, "scoped-current", record, time.Minute); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result, err := codes.VerifyScoped(ctx, "scoped-old", record.Scope(), record.Code, 5); err != nil || result.Status != store.LoginCodeVerifyMissing {
|
||||
t.Fatalf("old-hash verify=%+v err=%v", result, err)
|
||||
}
|
||||
|
||||
otherScope := record.Scope()
|
||||
otherScope.AuthKeyID = [8]byte{9}
|
||||
if result, err := codes.VerifyScoped(ctx, "scoped-current", otherScope, "00000", 5); err != nil || result.Status != store.LoginCodeVerifyMissing {
|
||||
t.Fatalf("cross-scope verify=%+v err=%v", result, err)
|
||||
}
|
||||
stored, found, err := codes.Get(ctx, "scoped-current")
|
||||
if err != nil || !found || stored.Attempts != 0 {
|
||||
t.Fatalf("victim after cross-scope verify=%+v found=%v err=%v", stored, found, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("wrong attempts preserve ttl then delete code and index", func(t *testing.T) {
|
||||
codes := NewCodeStore()
|
||||
record := newRecord()
|
||||
if err := codes.Set(ctx, "scoped-wrong", record, time.Minute); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before := codes.m["scoped-wrong"]
|
||||
first, err := codes.VerifyScoped(ctx, "scoped-wrong", record.Scope(), "00000", 9)
|
||||
if err != nil || first.Status != store.LoginCodeVerifyInvalid || first.Record.Attempts != 1 {
|
||||
t.Fatalf("first wrong=%+v err=%v", first, err)
|
||||
}
|
||||
after := codes.m["scoped-wrong"]
|
||||
if !after.expires.Equal(before.expires) || after.code.Revision == before.code.Revision {
|
||||
t.Fatalf("wrong attempt expiry/revision before=%+v after=%+v", before, after)
|
||||
}
|
||||
second, err := codes.VerifyScoped(ctx, "scoped-wrong", record.Scope(), "00000", 9)
|
||||
if err != nil || second.Status != store.LoginCodeVerifyInvalid || second.Record.Attempts != 2 {
|
||||
t.Fatalf("threshold wrong=%+v err=%v", second, err)
|
||||
}
|
||||
if _, found, _ := codes.Get(ctx, "scoped-wrong"); found {
|
||||
t.Fatal("threshold-exhausted scoped code remains")
|
||||
}
|
||||
if got := codes.scopes[record.Scope()]; got != "" {
|
||||
t.Fatalf("threshold-exhausted scope index=%q, want missing", got)
|
||||
}
|
||||
if result, err := codes.VerifyScoped(ctx, "scoped-wrong", record.Scope(), record.Code, 9); err != nil || result.Status != store.LoginCodeVerifyMissing {
|
||||
t.Fatalf("verify after exhaustion=%+v err=%v", result, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("correct code consumes both keys exactly once", func(t *testing.T) {
|
||||
codes := NewCodeStore()
|
||||
record := newRecord()
|
||||
if err := codes.Set(ctx, "scoped-correct", record, time.Minute); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
expected, found, err := codes.Get(ctx, "scoped-correct")
|
||||
if err != nil || !found {
|
||||
t.Fatalf("get expected found=%v err=%v", found, err)
|
||||
}
|
||||
accepted, err := codes.VerifyScoped(ctx, "scoped-correct", record.Scope(), record.Code, 5)
|
||||
if err != nil || accepted.Status != store.LoginCodeVerifyAccepted || accepted.Record != expected {
|
||||
t.Fatalf("accepted=%+v err=%v, want %+v", accepted, err, expected)
|
||||
}
|
||||
if _, found, _ := codes.Get(ctx, "scoped-correct"); found || codes.scopes[record.Scope()] != "" {
|
||||
t.Fatal("accepted scoped code or index remains")
|
||||
}
|
||||
if repeated, err := codes.VerifyScoped(ctx, "scoped-correct", record.Scope(), record.Code, 5); err != nil || repeated.Status != store.LoginCodeVerifyMissing {
|
||||
t.Fatalf("repeated verify=%+v err=%v", repeated, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("legacy and inconsistent records fail closed", func(t *testing.T) {
|
||||
codes := NewCodeStore()
|
||||
legacy := newRecord()
|
||||
legacy.Version = 0
|
||||
if err := codes.Set(ctx, "scoped-legacy", legacy, time.Minute); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result, err := codes.VerifyScoped(ctx, "scoped-legacy", legacy.Scope(), legacy.Code, 5); err != nil || result.Status != store.LoginCodeVerifyMissing {
|
||||
t.Fatalf("legacy verify=%+v err=%v", result, err)
|
||||
}
|
||||
if _, found, _ := codes.Get(ctx, "scoped-legacy"); found || codes.scopes[legacy.Scope()] != "" {
|
||||
t.Fatal("legacy code or index remains")
|
||||
}
|
||||
|
||||
inconsistent := newRecord()
|
||||
if err := codes.Set(ctx, "scoped-inconsistent", inconsistent, time.Minute); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
entry := codes.m["scoped-inconsistent"]
|
||||
entry.code.Phone = "15550016999"
|
||||
codes.m["scoped-inconsistent"] = entry
|
||||
if result, err := codes.VerifyScoped(ctx, "scoped-inconsistent", inconsistent.Scope(), inconsistent.Code, 5); err != nil || result.Status != store.LoginCodeVerifyMissing {
|
||||
t.Fatalf("inconsistent verify=%+v err=%v", result, err)
|
||||
}
|
||||
if _, found, _ := codes.Get(ctx, "scoped-inconsistent"); found || codes.scopes[inconsistent.Scope()] != "" {
|
||||
t.Fatal("inconsistent code or selecting index remains")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCodeStoreAtomicScopedConcurrency(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
const workers = 64
|
||||
newRecord := func(maxAttempts int) store.PhoneCode {
|
||||
return store.PhoneCode{
|
||||
Version: store.PhoneCodeVersionCurrent,
|
||||
Phone: "15550016022",
|
||||
Code: "12345",
|
||||
Channel: store.PhoneCodeChannelPhone,
|
||||
Purpose: store.PhoneCodePurposeChangePhone,
|
||||
UserID: 420022,
|
||||
AuthKeyID: [8]byte{5, 6, 7, 8},
|
||||
MaxAttempts: maxAttempts,
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("correct verification has one winner", func(t *testing.T) {
|
||||
codes := NewCodeStore()
|
||||
record := newRecord(7)
|
||||
if err := codes.Set(ctx, "scoped-verify-race", record, time.Minute); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
statuses := concurrentMemoryScopedVerify(t, codes, "scoped-verify-race", record.Scope(), record.Code, workers)
|
||||
if statuses[store.LoginCodeVerifyAccepted] != 1 || statuses[store.LoginCodeVerifyMissing] != workers-1 || statuses[store.LoginCodeVerifyInvalid] != 0 {
|
||||
t.Fatalf("correct race statuses=%+v", statuses)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("wrong attempts cannot be lost", func(t *testing.T) {
|
||||
codes := NewCodeStore()
|
||||
record := newRecord(7)
|
||||
if err := codes.Set(ctx, "scoped-wrong-race", record, time.Minute); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
statuses := concurrentMemoryScopedVerify(t, codes, "scoped-wrong-race", record.Scope(), "00000", workers)
|
||||
if statuses[store.LoginCodeVerifyInvalid] != 7 || statuses[store.LoginCodeVerifyMissing] != workers-7 {
|
||||
t.Fatalf("wrong race statuses=%+v", statuses)
|
||||
}
|
||||
if _, found, _ := codes.Get(ctx, "scoped-wrong-race"); found || codes.scopes[record.Scope()] != "" {
|
||||
t.Fatal("wrong race left code or scope index")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("verification and cancellation share one winner", func(t *testing.T) {
|
||||
codes := NewCodeStore()
|
||||
record := newRecord(7)
|
||||
if err := codes.Set(ctx, "scoped-mixed-race", record, time.Minute); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
results := make(chan bool, workers)
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < workers; i++ {
|
||||
wg.Add(1)
|
||||
go func(cancel bool) {
|
||||
defer wg.Done()
|
||||
if cancel {
|
||||
_, found, err := codes.ConsumeScoped(ctx, "scoped-mixed-race", record.Scope())
|
||||
if err != nil {
|
||||
t.Errorf("ConsumeScoped: %v", err)
|
||||
}
|
||||
results <- found
|
||||
return
|
||||
}
|
||||
result, err := codes.VerifyScoped(ctx, "scoped-mixed-race", record.Scope(), record.Code, 5)
|
||||
if err != nil {
|
||||
t.Errorf("VerifyScoped: %v", err)
|
||||
}
|
||||
results <- result.Status == store.LoginCodeVerifyAccepted
|
||||
}(i%2 == 0)
|
||||
}
|
||||
wg.Wait()
|
||||
close(results)
|
||||
winners := 0
|
||||
for won := range results {
|
||||
if won {
|
||||
winners++
|
||||
}
|
||||
}
|
||||
if winners != 1 {
|
||||
t.Fatalf("verify/cancel winners=%d, want 1", winners)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func concurrentMemoryScopedVerify(t *testing.T, codes *CodeStore, hash string, scope store.PhoneCodeScope, code string, workers int) map[store.LoginCodeVerifyStatus]int {
|
||||
t.Helper()
|
||||
results := make(chan store.LoginCodeVerifyStatus, workers)
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < workers; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
result, err := codes.VerifyScoped(context.Background(), hash, scope, code, 5)
|
||||
if err != nil {
|
||||
t.Errorf("VerifyScoped: %v", err)
|
||||
return
|
||||
}
|
||||
results <- result.Status
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
close(results)
|
||||
statuses := make(map[store.LoginCodeVerifyStatus]int)
|
||||
for status := range results {
|
||||
statuses[status]++
|
||||
}
|
||||
return statuses
|
||||
}
|
||||
|
|
@ -8,8 +8,9 @@ import (
|
|||
|
||||
// UpdateStateStore 是 store.UpdateStateStore 的内存实现。
|
||||
type UpdateStateStore struct {
|
||||
mu sync.RWMutex
|
||||
states map[updateStateKey]domain.UpdateState
|
||||
mu sync.RWMutex
|
||||
states map[updateStateKey]domain.UpdateState
|
||||
observed map[updateStateKey]domain.UpdateState
|
||||
}
|
||||
|
||||
// UpdateEventStore 是 store.UpdateEventStore 的内存实现。
|
||||
|
|
@ -157,7 +158,10 @@ func (s *UpdateEventStore) MaxContiguousPts(_ context.Context, userID int64) (in
|
|||
|
||||
// NewUpdateStateStore 创建内存 UpdateStateStore。
|
||||
func NewUpdateStateStore() *UpdateStateStore {
|
||||
return &UpdateStateStore{states: make(map[updateStateKey]domain.UpdateState)}
|
||||
return &UpdateStateStore{
|
||||
states: make(map[updateStateKey]domain.UpdateState),
|
||||
observed: make(map[updateStateKey]domain.UpdateState),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *UpdateStateStore) Get(_ context.Context, id [8]byte, userID int64) (domain.UpdateState, bool, error) {
|
||||
|
|
@ -191,9 +195,39 @@ func (s *UpdateStateStore) Save(_ context.Context, id [8]byte, userID int64, st
|
|||
return nil
|
||||
}
|
||||
|
||||
func (s *UpdateStateStore) ObserveClientState(_ context.Context, id [8]byte, userID int64, st domain.UpdateState) error {
|
||||
s.mu.Lock()
|
||||
key := updateStateKey{authKeyID: id, userID: userID}
|
||||
prev := s.observed[key]
|
||||
if st.Pts < prev.Pts {
|
||||
st.Pts = prev.Pts
|
||||
}
|
||||
if st.Qts < prev.Qts {
|
||||
st.Qts = prev.Qts
|
||||
}
|
||||
if st.Date < prev.Date {
|
||||
st.Date = prev.Date
|
||||
}
|
||||
if st.Seq < prev.Seq {
|
||||
st.Seq = prev.Seq
|
||||
}
|
||||
s.observed[key] = st
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// ObservedClientState 暴露给同包/服务测试验证 retention 安全水位;业务读路径仍用 Get。
|
||||
func (s *UpdateStateStore) ObservedClientState(id [8]byte, userID int64) (domain.UpdateState, bool) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
st, ok := s.observed[updateStateKey{authKeyID: id, userID: userID}]
|
||||
return st, ok
|
||||
}
|
||||
|
||||
func (s *UpdateStateStore) Delete(_ context.Context, id [8]byte, userID int64) error {
|
||||
s.mu.Lock()
|
||||
delete(s.states, updateStateKey{authKeyID: id, userID: userID})
|
||||
delete(s.observed, updateStateKey{authKeyID: id, userID: userID})
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
|
@ -203,6 +237,7 @@ func (s *UpdateStateStore) DeleteAuthKey(_ context.Context, id [8]byte) error {
|
|||
for k := range s.states {
|
||||
if k.authKeyID == id {
|
||||
delete(s.states, k)
|
||||
delete(s.observed, k)
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue