owpengram-server/internal/app/userprojection/projection_test.go
2026-07-24 11:56:58 +08:00

379 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package userprojection
import (
"context"
"reflect"
"testing"
privacyapp "telesrv/internal/app/privacy"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
)
func TestProjectorCombinesProfilePhotosAndViewerContacts(t *testing.T) {
ctx := context.Background()
const viewerID int64 = 1001
const friendID int64 = 1002
const strangerID int64 = 1003
contacts := memory.NewContactStore()
if _, err := contacts.Upsert(ctx, viewerID, domain.ContactInput{
ContactUserID: friendID,
Phone: "1111",
FirstName: "Alice",
LastName: "Contact",
Note: "private note",
NoteEntities: []domain.MessageEntity{{Type: domain.MessageEntityBold, Offset: 0, Length: 7}},
}); err != nil {
t.Fatalf("upsert contact: %v", err)
}
projector := New(
WithContactStore(contacts),
WithPhotoProvider(fakeProfilePhotos{
profile: map[int64]domain.ProfilePhotoRef{
friendID: {PhotoID: 9001, DCID: 2, Stripped: []byte{1, 2}},
strangerID: {PhotoID: 9002, DCID: 3, Stripped: []byte{3, 4}},
},
}),
)
users, err := projector.ForViewer(ctx, viewerID, []domain.User{
{ID: viewerID, Phone: "15550000001", FirstName: "Owner"},
{ID: friendID, AccessHash: 22, Phone: "15550000002", FirstName: "Public", LastName: "Name"},
{ID: strangerID, AccessHash: 33, Phone: "15550000003", FirstName: "Stranger"},
})
if err != nil {
t.Fatalf("ForViewer: %v", err)
}
friend := projectionUser(t, users, friendID)
if friend.FirstName != "Alice" || friend.LastName != "Contact" || friend.Phone != "1111" || !friend.Contact {
t.Fatalf("friend projection = %+v, want contact name/phone", friend)
}
if friend.ContactNote != "private note" || len(friend.ContactNoteEntities) != 1 || friend.ContactNoteEntities[0].Type != domain.MessageEntityBold {
t.Fatalf("friend contact note = %q %+v, want owner-scoped note", friend.ContactNote, friend.ContactNoteEntities)
}
if friend.PhotoID != 9001 || friend.PhotoDCID != 2 || string(friend.PhotoStripped) != string([]byte{1, 2}) {
t.Fatalf("friend photo = id %d dc %d stripped %v, want 9001/2/[1 2]", friend.PhotoID, friend.PhotoDCID, friend.PhotoStripped)
}
stranger := projectionUser(t, users, strangerID)
if stranger.Phone != "" || stranger.Contact || stranger.ContactNote != "" || len(stranger.ContactNoteEntities) != 0 {
t.Fatalf("stranger projection = %+v, want hidden phone and no contact note", stranger)
}
if stranger.PhotoID != 9002 || stranger.PhotoDCID != 3 {
t.Fatalf("stranger photo = id %d dc %d, want 9002/3", stranger.PhotoID, stranger.PhotoDCID)
}
}
func TestProjectorPersonalPhotoWinsOverProfile(t *testing.T) {
ctx := context.Background()
const viewerID int64 = 2001
const friendID int64 = 2002
contacts := memory.NewContactStore()
if _, err := contacts.Upsert(ctx, viewerID, domain.ContactInput{ContactUserID: friendID, FirstName: "Friend"}); err != nil {
t.Fatalf("upsert contact: %v", err)
}
if _, _, err := contacts.SetPersonalPhoto(ctx, viewerID, friendID, 9100, 100); err != nil {
t.Fatalf("set personal photo: %v", err)
}
projector := New(
WithContactStore(contacts),
WithPhotoProvider(fakeProfilePhotos{
profile: map[int64]domain.ProfilePhotoRef{friendID: {PhotoID: 9001, DCID: 2}},
}),
)
users, err := projector.ForViewer(ctx, viewerID, []domain.User{{ID: friendID, FirstName: "Public"}})
if err != nil {
t.Fatalf("ForViewer: %v", err)
}
friend := projectionUser(t, users, friendID)
if friend.PhotoID != 9100 || !friend.PhotoPersonal {
t.Fatalf("friend photo = id %d personal %v, want personal 9100", friend.PhotoID, friend.PhotoPersonal)
}
}
func TestProjectorUsesFallbackWhenProfilePhotoHidden(t *testing.T) {
ctx := context.Background()
const viewerID int64 = 3001
const ownerID int64 = 3002
contacts := memory.NewContactStore()
rules := memory.NewPrivacyStore()
privacy := privacyapp.NewService(rules, contacts)
if _, err := privacy.SetRules(ctx, ownerID, domain.PrivacyKeyProfilePhoto, []domain.PrivacyRule{{Kind: domain.PrivacyRuleDisallowAll}}); err != nil {
t.Fatalf("set privacy: %v", err)
}
projector := New(
WithContactStore(contacts),
WithPrivacyEvaluator(privacy),
WithPhotoProvider(fakeProfilePhotos{
profile: map[int64]domain.ProfilePhotoRef{ownerID: {PhotoID: 9001, DCID: 2}},
fallback: map[int64]domain.ProfilePhotoRef{ownerID: {PhotoID: 9002, DCID: 3}},
}),
)
users, err := projector.ForViewer(ctx, viewerID, []domain.User{{ID: ownerID, Phone: "15550003002", FirstName: "Owner"}})
if err != nil {
t.Fatalf("ForViewer: %v", err)
}
owner := projectionUser(t, users, ownerID)
if owner.PhotoID != 9002 || owner.PhotoDCID != 3 || owner.Phone != "" {
t.Fatalf("owner projection = %+v, want fallback photo and hidden phone", owner)
}
}
func TestProjectorContactWithoutKnownPhoneCannotBypassPhonePrivacy(t *testing.T) {
ctx := context.Background()
const (
viewerID = int64(3101)
ownerID = int64(3102)
)
contacts := memory.NewContactStore()
if _, err := contacts.Upsert(ctx, viewerID, domain.ContactInput{
ContactUserID: ownerID,
FirstName: "Saved",
Phone: "",
}); err != nil {
t.Fatalf("upsert contact: %v", err)
}
privacy := privacyapp.NewService(memory.NewPrivacyStore(), contacts)
projector := New(
WithContactStore(contacts),
WithPrivacyEvaluator(privacy),
)
users, err := projector.ForViewer(ctx, viewerID, []domain.User{{
ID: ownerID,
Phone: "15550003102",
FirstName: "Owner",
}})
if err != nil {
t.Fatalf("ForViewer: %v", err)
}
owner := projectionUser(t, users, ownerID)
if !owner.Contact || owner.Phone != "" {
t.Fatalf("owner projection = %+v, want contact=true with hidden phone", owner)
}
batch, err := projector.ForViewers(ctx, []int64{viewerID}, []domain.User{{
ID: ownerID,
Phone: "15550003102",
FirstName: "Owner",
}})
if err != nil {
t.Fatalf("ForViewers: %v", err)
}
batchOwner := projectionUser(t, batch[viewerID], ownerID)
if !batchOwner.Contact || batchOwner.Phone != "" {
t.Fatalf("batch owner projection = %+v, want contact=true with hidden phone", batchOwner)
}
}
func TestProjectorAccountFreezeIsViewerScopedAndReversible(t *testing.T) {
ctx := context.Background()
const (
frozenUserID = int64(4001)
otherViewer = int64(4002)
)
freezes := &fakeAccountFreezes{items: map[int64]domain.AccountFreeze{
frozenUserID: {UserID: frozenUserID, Frozen: true, Version: 3},
}}
projector := New(WithAccountFreezeProvider(freezes))
base := []domain.User{{
ID: frozenUserID,
FirstName: "Frozen",
// Viewer-scoped fields must never be trusted from a reused base object.
RestrictionReasons: []domain.UserRestrictionReason{{Platform: "all", Reason: "stale", Text: "stale"}},
}}
otherView, err := projector.ForViewer(ctx, otherViewer, base)
if err != nil {
t.Fatalf("ForViewer(other): %v", err)
}
got := projectionUser(t, otherView, frozenUserID)
if !reflect.DeepEqual(got.RestrictionReasons, domain.AccountFrozenRestrictionReasons()) {
t.Fatalf("other-view restriction = %+v, want frozen restriction", got.RestrictionReasons)
}
if base[0].RestrictionReasons[0].Reason != "stale" {
t.Fatalf("projection mutated base user: %+v", base[0])
}
selfView, err := projector.ForViewer(ctx, frozenUserID, base)
if err != nil {
t.Fatalf("ForViewer(self): %v", err)
}
if reasons := projectionUser(t, selfView, frozenUserID).RestrictionReasons; len(reasons) != 0 {
t.Fatalf("self-view restriction = %+v, want none", reasons)
}
batch, err := projector.ForViewers(ctx, []int64{otherViewer, frozenUserID}, base)
if err != nil {
t.Fatalf("ForViewers: %v", err)
}
if reasons := projectionUser(t, batch[otherViewer], frozenUserID).RestrictionReasons; !reflect.DeepEqual(reasons, domain.AccountFrozenRestrictionReasons()) {
t.Fatalf("batch other-view restriction = %+v", reasons)
}
if reasons := projectionUser(t, batch[frozenUserID], frozenUserID).RestrictionReasons; len(reasons) != 0 {
t.Fatalf("batch self-view restriction = %+v, want none", reasons)
}
freezes.items = nil
unfrozenView, err := projector.ForViewer(ctx, otherViewer, otherView)
if err != nil {
t.Fatalf("ForViewer(after unfreeze): %v", err)
}
if reasons := projectionUser(t, unfrozenView, frozenUserID).RestrictionReasons; len(reasons) != 0 {
t.Fatalf("unfrozen projection retained restriction = %+v", reasons)
}
}
// TestForViewersEquivalentToForViewer 锁定 fan-out 模板化的核心安全网ForViewers(viewers, users)
// 的每个 viewer 切片必须与逐 viewer 的 ForViewer(viewer, users) 字节等价(隐私/改名/头像投影
// 不能因 O(owner) 模板化而漂移泄漏)。**唯一允许的差异是 personal photo overlay**v1 模板不做
// per-viewer personal photo故对「该 viewer 给该 owner 设过 personal photo」的对比较前 mask 掉
// 5 个头像字段;其余对做完整字节比较。覆盖:默认规则陌生人/联系人改名+电话/status 隐藏/profile
// 头像隐藏走 fallback/self/bot/系统账号/viewer 自身也作为 owner 出现。
func TestForViewersEquivalentToForViewer(t *testing.T) {
ctx := context.Background()
const (
v1 = int64(5001)
v2 = int64(5002)
o1 = int64(5101) // 陌生人,默认规则
o2 = int64(5102) // v1 的联系人(改名+电话),且 v1 给 o2 设了 personal photo
o3 = int64(5103) // status 隐藏
o4 = int64(5104) // profile photo 隐藏 → 走 fallback
bot = int64(5105)
)
viewers := []int64{v1, v2}
contacts := memory.NewContactStore()
rules := memory.NewPrivacyStore()
privacy := privacyapp.NewService(rules, contacts)
if _, err := contacts.Upsert(ctx, v1, domain.ContactInput{ContactUserID: o2, Phone: "1111", FirstName: "Alice", LastName: "Friend"}); err != nil {
t.Fatalf("upsert contact: %v", err)
}
// v1 给 o2 设 personal photo仅 v1 视角生效 → ForViewer 会带它ForViewers v1 跳过 → 该对需 mask
if _, _, err := contacts.SetPersonalPhoto(ctx, v1, o2, 9300, 300); err != nil {
t.Fatalf("set personal photo: %v", err)
}
if _, err := privacy.SetRules(ctx, o3, domain.PrivacyKeyStatusTimestamp, []domain.PrivacyRule{{Kind: domain.PrivacyRuleDisallowAll}}); err != nil {
t.Fatalf("set o3 status: %v", err)
}
if _, err := privacy.SetRules(ctx, o4, domain.PrivacyKeyProfilePhoto, []domain.PrivacyRule{{Kind: domain.PrivacyRuleDisallowAll}}); err != nil {
t.Fatalf("set o4 photo: %v", err)
}
projector := New(
WithContactStore(contacts),
WithPrivacyEvaluator(privacy),
WithPhotoProvider(fakeProfilePhotos{
profile: map[int64]domain.ProfilePhotoRef{
o1: {PhotoID: 9001, DCID: 1, Stripped: []byte{1}},
o2: {PhotoID: 9002, DCID: 2, Stripped: []byte{2}},
o3: {PhotoID: 9003, DCID: 3, Stripped: []byte{3}},
o4: {PhotoID: 9004, DCID: 4, Stripped: []byte{4}},
v1: {PhotoID: 9005, DCID: 5, Stripped: []byte{5}},
v2: {PhotoID: 9006, DCID: 6, Stripped: []byte{6}},
},
fallback: map[int64]domain.ProfilePhotoRef{
o4: {PhotoID: 9404, DCID: 4, Stripped: []byte{44}},
},
}),
)
users := []domain.User{
{ID: o1, AccessHash: 11, Phone: "15550000001", FirstName: "Stranger", Status: domain.UserStatus{Kind: domain.UserStatusOnline}},
{ID: o2, AccessHash: 12, Phone: "15550000002", FirstName: "PublicO2", Status: domain.UserStatus{Kind: domain.UserStatusOnline}},
{ID: o3, AccessHash: 13, Phone: "15550000003", FirstName: "O3", Status: domain.UserStatus{Kind: domain.UserStatusOnline}, LastSeenAt: 123},
{ID: o4, AccessHash: 14, Phone: "15550000004", FirstName: "O4"},
{ID: bot, AccessHash: 15, FirstName: "Bot", Bot: true},
{ID: domain.OfficialSystemUserID, FirstName: "System"},
{ID: v1, AccessHash: 16, Phone: "15550000016", FirstName: "Viewer1"}, // viewer 自身也作为 owner 出现
}
// 哪些 (viewer, owner) 对存在 personal photo —— 比较时需 mask 头像字段v1 模板有意跳过)。
personalPairs := map[[2]int64]bool{{v1, o2}: true}
batch, err := projector.ForViewers(ctx, viewers, users)
if err != nil {
t.Fatalf("ForViewers: %v", err)
}
for _, viewer := range viewers {
want, err := projector.ForViewer(ctx, viewer, users)
if err != nil {
t.Fatalf("ForViewer(%d): %v", viewer, err)
}
got, ok := batch[viewer]
if !ok {
t.Fatalf("ForViewers missing viewer %d", viewer)
}
if len(got) != len(want) {
t.Fatalf("viewer %d len(got)=%d len(want)=%d", viewer, len(got), len(want))
}
for i := range want {
w, g := want[i], got[i]
if w.ID != g.ID {
t.Fatalf("viewer %d idx %d id mismatch got=%d want=%d", viewer, i, g.ID, w.ID)
}
if personalPairs[[2]int64{viewer, w.ID}] {
maskPhoto(&w)
maskPhoto(&g)
}
if !reflect.DeepEqual(w, g) {
t.Fatalf("viewer %d owner %d: ForViewers != ForViewer\n got=%+v\nwant=%+v", viewer, w.ID, g, w)
}
}
}
}
func maskPhoto(u *domain.User) {
u.PhotoID = 0
u.PhotoDCID = 0
u.PhotoStripped = nil
u.PhotoPersonal = false
u.PhotoHasVideo = false
}
func projectionUser(t *testing.T, users []domain.User, id int64) domain.User {
t.Helper()
for _, user := range users {
if user.ID == id {
return user
}
}
t.Fatalf("user %d not found in %+v", id, users)
return domain.User{}
}
type fakeProfilePhotos struct {
profile map[int64]domain.ProfilePhotoRef
fallback map[int64]domain.ProfilePhotoRef
}
type fakeAccountFreezes struct {
items map[int64]domain.AccountFreeze
}
func (f *fakeAccountFreezes) AccountFreezes(_ context.Context, ids []int64) (map[int64]domain.AccountFreeze, error) {
out := make(map[int64]domain.AccountFreeze)
for _, id := range ids {
if freeze, ok := f.items[id]; ok {
out[id] = freeze
}
}
return out, nil
}
func (p fakeProfilePhotos) CurrentProfilePhotos(_ context.Context, _ domain.PeerType, ids []int64) (map[int64]domain.ProfilePhotoRef, error) {
return p.CurrentProfilePhotosKind(context.Background(), domain.PeerTypeUser, ids, domain.ProfilePhotoKindProfile)
}
func (p fakeProfilePhotos) CurrentProfilePhotosKind(_ context.Context, _ domain.PeerType, ids []int64, kind domain.ProfilePhotoKind) (map[int64]domain.ProfilePhotoRef, error) {
source := p.profile
if kind == domain.ProfilePhotoKindFallback {
source = p.fallback
}
out := make(map[int64]domain.ProfilePhotoRef, len(ids))
for _, id := range ids {
if ref, ok := source[id]; ok {
out[id] = ref
}
}
return out, nil
}