feat: sync ephemeral transient messages
Sync telesrv 570ccf8 (feat(ephemeral): implement Layer 228 transient messages). Skipped telesrv docs changes per public sync rules; normalized the public appearance seed label.
This commit is contained in:
parent
3f78eaa2c6
commit
f49c817def
53 changed files with 5793 additions and 112 deletions
385
internal/app/ephemeral/service_test.go
Normal file
385
internal/app/ephemeral/service_test.go
Normal file
|
|
@ -0,0 +1,385 @@
|
|||
package ephemeral
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
const (
|
||||
testHumanID int64 = 1001
|
||||
testBotID int64 = 2001
|
||||
testChannel int64 = 3001
|
||||
testSession int64 = 4001
|
||||
)
|
||||
|
||||
var testDeviceKey = [8]byte{1, 2, 3, 4}
|
||||
|
||||
type testChannels struct {
|
||||
roles map[int64]domain.ChannelMemberRole
|
||||
status map[int64]domain.ChannelMemberStatus
|
||||
channel domain.Channel
|
||||
}
|
||||
|
||||
func (c *testChannels) ResolveChannel(_ context.Context, userID, channelID int64) (domain.ChannelView, error) {
|
||||
if channelID != c.channel.ID {
|
||||
return domain.ChannelView{}, domain.ErrChannelInvalid
|
||||
}
|
||||
return domain.ChannelView{Channel: c.channel, Self: domain.ChannelMember{
|
||||
ChannelID: channelID, UserID: userID, Role: c.roles[userID], Status: c.status[userID],
|
||||
}}, nil
|
||||
}
|
||||
|
||||
func (c *testChannels) GetParticipant(_ context.Context, _ int64, channelID, participantUserID int64) (domain.ChannelMember, error) {
|
||||
if channelID != c.channel.ID {
|
||||
return domain.ChannelMember{}, domain.ErrChannelInvalid
|
||||
}
|
||||
return domain.ChannelMember{ChannelID: channelID, UserID: participantUserID, Role: c.roles[participantUserID], Status: c.status[participantUserID]}, nil
|
||||
}
|
||||
|
||||
func (c *testChannels) GetForumTopicsByID(_ context.Context, _ int64, channelID int64, ids []int) (domain.ChannelForumTopicList, error) {
|
||||
if channelID != c.channel.ID {
|
||||
return domain.ChannelForumTopicList{}, domain.ErrChannelInvalid
|
||||
}
|
||||
out := domain.ChannelForumTopicList{Channel: c.channel}
|
||||
for _, id := range ids {
|
||||
if id > 0 {
|
||||
out.Topics = append(out.Topics, domain.ChannelForumTopic{ChannelID: channelID, TopicID: id})
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
type testUsers map[int64]domain.User
|
||||
|
||||
func (u testUsers) ByID(_ context.Context, _ int64, userID int64) (domain.User, bool, error) {
|
||||
user, found := u[userID]
|
||||
return user, found, nil
|
||||
}
|
||||
|
||||
type testBots map[int64][]domain.BotCommand
|
||||
|
||||
func (b testBots) GetBotCommands(_ context.Context, botUserID int64) ([]domain.BotCommand, error) {
|
||||
return append([]domain.BotCommand(nil), b[botUserID]...), nil
|
||||
}
|
||||
|
||||
type serviceFixture struct {
|
||||
service *Service
|
||||
store *memory.EphemeralMessageStore
|
||||
now time.Time
|
||||
nextID int
|
||||
channels *testChannels
|
||||
}
|
||||
|
||||
func newServiceFixture() *serviceFixture {
|
||||
f := &serviceFixture{
|
||||
store: memory.NewEphemeralMessageStore(),
|
||||
now: time.Unix(1_900_000_000, 0),
|
||||
nextID: 10,
|
||||
channels: &testChannels{
|
||||
roles: map[int64]domain.ChannelMemberRole{testHumanID: domain.ChannelRoleMember, testBotID: domain.ChannelRoleMember},
|
||||
status: map[int64]domain.ChannelMemberStatus{testHumanID: domain.ChannelMemberActive, testBotID: domain.ChannelMemberActive},
|
||||
channel: domain.Channel{ID: testChannel, Megagroup: true},
|
||||
},
|
||||
}
|
||||
f.service = NewService(f.store, f.channels, testUsers{
|
||||
testHumanID: {ID: testHumanID, Username: "alice"},
|
||||
testBotID: {ID: testBotID, Username: "private_bot", Bot: true, BotInfoVersion: 1},
|
||||
}, testBots{testBotID: {{Command: "private", Description: "private", Ephemeral: true}, {Command: "public", Description: "public"}}},
|
||||
WithClock(func() time.Time { return f.now }),
|
||||
WithIDGenerator(func() (int, error) { f.nextID++; return f.nextID, nil }))
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *serviceFixture) clientRequest() domain.SendClientEphemeralRequest {
|
||||
return domain.SendClientEphemeralRequest{
|
||||
SenderUserID: testHumanID, ReceiverBotID: testBotID,
|
||||
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: testChannel},
|
||||
RandomID: 91, Content: domain.EphemeralContent{Message: "/private@private_bot hello"},
|
||||
OriginDevice: domain.EphemeralDevice{UserID: testHumanID, BusinessAuthKeyID: testDeviceKey, SessionID: testSession},
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendFromClientRequiresEphemeralCommandAndPreservesDevice(t *testing.T) {
|
||||
f := newServiceFixture()
|
||||
message, fresh, err := f.service.SendFromClient(context.Background(), f.clientRequest())
|
||||
if err != nil || !fresh {
|
||||
t.Fatalf("send = %+v fresh=%v err=%v", message, fresh, err)
|
||||
}
|
||||
if message.SenderUserID != testHumanID || message.ReceiverUserID != testBotID || message.OriginDevice.BusinessAuthKeyID != testDeviceKey {
|
||||
t.Fatalf("message = %+v", message)
|
||||
}
|
||||
request := f.clientRequest()
|
||||
request.RandomID++
|
||||
request.Content.Message = "/public"
|
||||
if _, _, err := f.service.SendFromClient(context.Background(), request); !errors.Is(err, domain.ErrEphemeralCommandInvalid) {
|
||||
t.Fatalf("ordinary command err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeletedCreateReplayReturnsTombstoneWithoutResurrection(t *testing.T) {
|
||||
f := newServiceFixture()
|
||||
request := f.clientRequest()
|
||||
message, fresh, err := f.service.SendFromClient(context.Background(), request)
|
||||
if err != nil || !fresh {
|
||||
t.Fatalf("create fresh=%v err=%v", fresh, err)
|
||||
}
|
||||
device := request.OriginDevice
|
||||
if _, changed, err := f.service.DeleteFromDevice(context.Background(), testHumanID, testBotID, device, message.Peer, message.ID); err != nil || !changed {
|
||||
t.Fatalf("delete changed=%v err=%v", changed, err)
|
||||
}
|
||||
replayed, fresh, err := f.service.SendFromClient(context.Background(), request)
|
||||
if err != nil || fresh || !replayed.Deleted || replayed.ID != message.ID || replayed.Version != 2 {
|
||||
t.Fatalf("replay=%+v fresh=%v err=%v", replayed, fresh, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientReplyMustMatchTargetDevice(t *testing.T) {
|
||||
f := newServiceFixture()
|
||||
incoming := f.putIncoming(t, testDeviceKey, f.now)
|
||||
request := f.clientRequest()
|
||||
request.Content.Message = "reply"
|
||||
request.ReplyToEphemeralID = incoming.ID
|
||||
reply, fresh, err := f.service.SendFromClient(context.Background(), request)
|
||||
if err != nil || !fresh || reply.BotAPIReply == nil || reply.BotAPIReply.ID != incoming.ID {
|
||||
t.Fatalf("reply=%+v fresh=%v err=%v", reply, fresh, err)
|
||||
}
|
||||
request.RandomID++
|
||||
request.OriginDevice.BusinessAuthKeyID = [8]byte{9}
|
||||
if _, _, err := f.service.SendFromClient(context.Background(), request); !errors.Is(err, domain.ErrEphemeralDeviceMismatch) {
|
||||
t.Fatalf("other device reply err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotReplyWindowAndAdminBroadcast(t *testing.T) {
|
||||
f := newServiceFixture()
|
||||
action, _, err := f.service.SendFromClient(context.Background(), f.clientRequest())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.now = f.now.Add(14 * time.Second)
|
||||
reply, fresh, err := f.service.SendFromBot(context.Background(), domain.SendBotEphemeralRequest{
|
||||
BotUserID: testBotID, ReceiverUserID: testHumanID,
|
||||
Peer: action.Peer, RandomID: 92, Content: domain.EphemeralContent{Message: "answer"}, ActionMessageID: action.ID,
|
||||
})
|
||||
if err != nil || !fresh || reply.OriginDevice.BusinessAuthKeyID != testDeviceKey || reply.ReplyToEphemeralID != action.ID ||
|
||||
reply.BotAPIReply == nil || reply.BotAPIReply.ID != action.ID {
|
||||
t.Fatalf("bot reply = %+v fresh=%v err=%v", reply, fresh, err)
|
||||
}
|
||||
f.now = f.now.Add(2 * time.Second)
|
||||
if _, _, err := f.service.SendFromBot(context.Background(), domain.SendBotEphemeralRequest{
|
||||
BotUserID: testBotID, ReceiverUserID: testHumanID, Peer: action.Peer,
|
||||
RandomID: 93, Content: domain.EphemeralContent{Message: "late"}, ActionMessageID: action.ID,
|
||||
}); !errors.Is(err, domain.ErrEphemeralReplyExpired) {
|
||||
t.Fatalf("late bot reply err=%v", err)
|
||||
}
|
||||
f.channels.roles[testBotID] = domain.ChannelRoleAdmin
|
||||
broadcast, _, err := f.service.SendFromBot(context.Background(), domain.SendBotEphemeralRequest{
|
||||
BotUserID: testBotID, ReceiverUserID: testHumanID, Peer: action.Peer,
|
||||
RandomID: 94, Content: domain.EphemeralContent{Message: "admin"},
|
||||
})
|
||||
if err != nil || broadcast.OriginDevice.BusinessAuthKeyID != ([8]byte{}) {
|
||||
t.Fatalf("admin broadcast = %+v err=%v", broadcast, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallbackAndDeleteEnforceParticipantsAndDevice(t *testing.T) {
|
||||
f := newServiceFixture()
|
||||
incoming := f.putIncoming(t, testDeviceKey, f.now)
|
||||
device := domain.EphemeralDevice{UserID: testHumanID, BusinessAuthKeyID: testDeviceKey, SessionID: testSession}
|
||||
callback, err := f.service.Callback(context.Background(), testHumanID, device, incoming.Peer, incoming.ID, []byte("ok"))
|
||||
if err != nil || callback.BotUserID != testBotID || string(callback.Data) != "ok" {
|
||||
t.Fatalf("callback = %+v err=%v", callback, err)
|
||||
}
|
||||
device.BusinessAuthKeyID = [8]byte{7}
|
||||
if _, err := f.service.Callback(context.Background(), testHumanID, device, incoming.Peer, incoming.ID, []byte("ok")); !errors.Is(err, domain.ErrEphemeralDeviceMismatch) {
|
||||
t.Fatalf("other device callback err=%v", err)
|
||||
}
|
||||
if _, _, err := f.service.DeleteFromDevice(context.Background(), testHumanID, testHumanID, device, incoming.Peer, incoming.ID); !errors.Is(err, domain.ErrEphemeralDeviceMismatch) {
|
||||
t.Fatalf("other device delete err=%v", err)
|
||||
}
|
||||
device.BusinessAuthKeyID = testDeviceKey
|
||||
deleted, changed, err := f.service.DeleteFromDevice(context.Background(), testHumanID, testHumanID, device, incoming.Peer, incoming.ID)
|
||||
if err != nil || !changed || !deleted.Deleted {
|
||||
t.Fatalf("delete = %+v changed=%v err=%v", deleted, changed, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallbackActionTargetsExactDeviceAndExpiresAtFifteenSeconds(t *testing.T) {
|
||||
f := newServiceFixture()
|
||||
incoming := f.putIncoming(t, testDeviceKey, f.now)
|
||||
device := domain.EphemeralDevice{UserID: testHumanID, BusinessAuthKeyID: testDeviceKey, SessionID: testSession}
|
||||
callback, err := f.service.Callback(context.Background(), testHumanID, device, incoming.Peer, incoming.ID, []byte("ok"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const queryID = int64(777)
|
||||
created, err := f.service.PutCallbackAction(context.Background(), domain.EphemeralCallbackAction{
|
||||
QueryID: queryID, BotUserID: testBotID, UserID: testHumanID, Peer: incoming.Peer,
|
||||
MessageID: incoming.ID, Device: callback.Device, CreatedAt: f.now,
|
||||
ExpiresAt: f.now.Add(domain.EphemeralReplyWindow),
|
||||
})
|
||||
if err != nil || !created {
|
||||
t.Fatalf("put callback action created=%v err=%v", created, err)
|
||||
}
|
||||
reply, fresh, err := f.service.SendFromBot(context.Background(), domain.SendBotEphemeralRequest{
|
||||
BotUserID: testBotID, ReceiverUserID: testHumanID, Peer: incoming.Peer,
|
||||
CallbackQueryID: queryID, Content: domain.EphemeralContent{Message: "callback response"},
|
||||
})
|
||||
if err != nil || !fresh || reply.OriginDevice.BusinessAuthKeyID != testDeviceKey {
|
||||
t.Fatalf("callback reply=%+v fresh=%v err=%v", reply, fresh, err)
|
||||
}
|
||||
f.now = f.now.Add(domain.EphemeralReplyWindow)
|
||||
if _, _, err := f.service.SendFromBot(context.Background(), domain.SendBotEphemeralRequest{
|
||||
BotUserID: testBotID, ReceiverUserID: testHumanID, Peer: incoming.Peer,
|
||||
CallbackQueryID: queryID, Content: domain.EphemeralContent{Message: "too late"},
|
||||
}); !errors.Is(err, domain.ErrEphemeralReplyExpired) {
|
||||
t.Fatalf("expired callback action err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestForumRepliesInheritTopicAndNonForumRejectsTopic(t *testing.T) {
|
||||
f := newServiceFixture()
|
||||
f.channels.channel.Forum = true
|
||||
incoming := f.putIncomingInTopic(t, testDeviceKey, f.now, 42)
|
||||
request := f.clientRequest()
|
||||
request.Content.Message = "topic reply"
|
||||
request.ReplyToEphemeralID = incoming.ID
|
||||
reply, _, err := f.service.SendFromClient(context.Background(), request)
|
||||
if err != nil || reply.TopMessageID != 42 {
|
||||
t.Fatalf("topic reply=%+v err=%v", reply, err)
|
||||
}
|
||||
|
||||
f = newServiceFixture()
|
||||
request = f.clientRequest()
|
||||
request.TopMessageID = 42
|
||||
if _, _, err := f.service.SendFromClient(context.Background(), request); !errors.Is(err, domain.ErrEphemeralPeerInvalid) {
|
||||
t.Fatalf("non-forum topic err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEphemeralTextLimitCountsUnicodeCharacters(t *testing.T) {
|
||||
f := newServiceFixture()
|
||||
request := f.clientRequest()
|
||||
request.Content.Message = "/private " + strings.Repeat("界", domain.MaxMessageTextLength-len("/private "))
|
||||
if _, _, err := f.service.SendFromClient(context.Background(), request); err != nil {
|
||||
t.Fatalf("4096 Unicode characters rejected: %v", err)
|
||||
}
|
||||
request.RandomID++
|
||||
request.Content.Message += "界"
|
||||
if _, _, err := f.service.SendFromClient(context.Background(), request); !errors.Is(err, domain.ErrEphemeralInvalid) {
|
||||
t.Fatalf("overlong Unicode text err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotEditModesCannotCrossTextAndMediaShapes(t *testing.T) {
|
||||
f := newServiceFixture()
|
||||
textMessage := f.putIncoming(t, testDeviceKey, f.now)
|
||||
if _, err := f.service.EditFieldsFromBot(context.Background(), testBotID, testHumanID, textMessage.Peer, textMessage.ID,
|
||||
domain.EphemeralEditText, domain.EditEphemeralFields{SetMessage: true, Message: "edited"}); err != nil {
|
||||
t.Fatalf("text edit: %v", err)
|
||||
}
|
||||
if _, err := f.service.EditFieldsFromBot(context.Background(), testBotID, testHumanID, textMessage.Peer, textMessage.ID,
|
||||
domain.EphemeralEditCaption, domain.EditEphemeralFields{SetMessage: true, Message: "caption"}); !errors.Is(err, domain.ErrEphemeralInvalid) {
|
||||
t.Fatalf("caption edit on text err=%v", err)
|
||||
}
|
||||
|
||||
mediaMessage := f.putIncoming(t, testDeviceKey, f.now)
|
||||
mediaContent := domain.EphemeralContent{
|
||||
Message: "caption",
|
||||
Media: &domain.MessageMedia{Kind: domain.MessageMediaKindPhoto, Photo: &domain.Photo{ID: 99}},
|
||||
}
|
||||
mediaMessage, err := f.store.EditEphemeralMessage(context.Background(), mediaMessage.Peer, mediaMessage.ID, mediaMessage.Version, mediaContent, int(f.now.Unix()), f.now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := f.service.EditFieldsFromBot(context.Background(), testBotID, testHumanID, mediaMessage.Peer, mediaMessage.ID,
|
||||
domain.EphemeralEditCaption, domain.EditEphemeralFields{SetMessage: true, Message: "new caption"}); err != nil {
|
||||
t.Fatalf("media caption edit: %v", err)
|
||||
}
|
||||
if _, err := f.service.EditFieldsFromBot(context.Background(), testBotID, testHumanID, mediaMessage.Peer, mediaMessage.ID,
|
||||
domain.EphemeralEditText, domain.EditEphemeralFields{SetMessage: true, Message: "turn into text"}); !errors.Is(err, domain.ErrEphemeralInvalid) {
|
||||
t.Fatalf("text edit on media err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotLazyBuildersRunOnlyAfterAuthorization(t *testing.T) {
|
||||
f := newServiceFixture()
|
||||
builds := 0
|
||||
buildText := func(context.Context) (domain.EphemeralContent, error) {
|
||||
builds++
|
||||
return domain.EphemeralContent{Message: "authorized"}, nil
|
||||
}
|
||||
request := domain.SendBotEphemeralRequest{
|
||||
BotUserID: testBotID, ReceiverUserID: testHumanID + 99,
|
||||
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: testChannel},
|
||||
}
|
||||
if _, _, err := f.service.SendFromBotLazy(context.Background(), request, buildText); err == nil {
|
||||
t.Fatal("unknown receiver was accepted")
|
||||
}
|
||||
if builds != 0 {
|
||||
t.Fatalf("unauthorized send materialized content %d times", builds)
|
||||
}
|
||||
|
||||
f.channels.roles[testBotID] = domain.ChannelRoleAdmin
|
||||
request.ReceiverUserID = testHumanID
|
||||
if _, fresh, err := f.service.SendFromBotLazy(context.Background(), request, buildText); err != nil || !fresh {
|
||||
t.Fatalf("authorized lazy send fresh=%v err=%v", fresh, err)
|
||||
}
|
||||
if builds != 1 {
|
||||
t.Fatalf("authorized send materialized content %d times", builds)
|
||||
}
|
||||
|
||||
incoming := f.putIncoming(t, testDeviceKey, f.now)
|
||||
editBuilds := 0
|
||||
buildEdit := func(context.Context) (domain.EditEphemeralFields, error) {
|
||||
editBuilds++
|
||||
return domain.EditEphemeralFields{SetMessage: true, Message: "edited"}, nil
|
||||
}
|
||||
if _, err := f.service.EditFieldsFromBotLazy(context.Background(), testBotID+99, testHumanID, incoming.Peer, incoming.ID,
|
||||
domain.EphemeralEditText, buildEdit); !errors.Is(err, domain.ErrEphemeralForbidden) {
|
||||
t.Fatalf("unauthorized lazy edit err=%v", err)
|
||||
}
|
||||
if editBuilds != 0 {
|
||||
t.Fatalf("unauthorized edit materialized content %d times", editBuilds)
|
||||
}
|
||||
if _, err := f.service.EditFieldsFromBotLazy(context.Background(), testBotID, testHumanID, incoming.Peer, incoming.ID,
|
||||
domain.EphemeralEditText, buildEdit); err != nil {
|
||||
t.Fatalf("authorized lazy edit: %v", err)
|
||||
}
|
||||
if editBuilds != 1 {
|
||||
t.Fatalf("authorized edit materialized content %d times", editBuilds)
|
||||
}
|
||||
}
|
||||
|
||||
func (f *serviceFixture) putIncoming(t *testing.T, deviceKey [8]byte, createdAt time.Time) domain.EphemeralMessage {
|
||||
return f.putIncomingInTopic(t, deviceKey, createdAt, 0)
|
||||
}
|
||||
|
||||
func (f *serviceFixture) putIncomingInTopic(t *testing.T, deviceKey [8]byte, createdAt time.Time, topMessageID int) domain.EphemeralMessage {
|
||||
t.Helper()
|
||||
f.nextID++
|
||||
message := domain.EphemeralMessage{
|
||||
ID: f.nextID, Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: testChannel},
|
||||
SenderUserID: testBotID, ReceiverUserID: testHumanID, Date: int(createdAt.Unix()), RandomID: int64(f.nextID),
|
||||
TopMessageID: topMessageID,
|
||||
Content: domain.EphemeralContent{Message: "incoming", ReplyMarkup: &domain.MessageReplyMarkup{
|
||||
Type: domain.MessageReplyMarkupInline,
|
||||
Inline: [][]domain.MarkupButton{{{Type: domain.MarkupButtonCallback, Text: "OK", Data: []byte("ok")}}},
|
||||
}},
|
||||
OriginDevice: domain.EphemeralDevice{UserID: testHumanID, BusinessAuthKeyID: deviceKey, SessionID: testSession},
|
||||
PayloadHash: sha256.Sum256([]byte("incoming")), Version: 1,
|
||||
CreatedAt: createdAt, ExpiresAt: createdAt.Add(domain.EphemeralMessageRetention),
|
||||
}
|
||||
stored, _, err := f.store.CreateEphemeralMessage(context.Background(), message)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return stored
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue