feat: sync privacy read-model rules
This commit is contained in:
parent
9f467f4be7
commit
5d5883a3d1
20 changed files with 1011 additions and 20 deletions
|
|
@ -363,6 +363,21 @@ func (s *Service) CanContactForFreeBatch(ctx context.Context, ownerUserIDs []int
|
|||
return out, nil
|
||||
}
|
||||
|
||||
// ViewerIsPremium reads the same bounded viewer-facts read model used by
|
||||
// AllowPremium privacy rules. Contact permission checks must not bypass that
|
||||
// cache with a per-send users-table query.
|
||||
func (s *Service) ViewerIsPremium(ctx context.Context, viewerUserID int64) (bool, error) {
|
||||
if viewerUserID == 0 {
|
||||
return false, nil
|
||||
}
|
||||
facts, err := s.loadViewerFacts(ctx, []int64{viewerUserID})
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
fact := facts[viewerUserID]
|
||||
return fact.Found && !fact.Bot && fact.PremiumUntil > s.now().Unix(), nil
|
||||
}
|
||||
|
||||
// CanSeeMatrix 批量评估 owners × viewers × keys 的可见性矩阵,结果等价于逐 (owner,viewer,key)
|
||||
// 调 CanSee,但只用一次 ListPrivacyRules + 每 owner 一次 GetMany(owner,viewers) + 内存 Evaluate
|
||||
// (把 fan-out 投影从 O(viewer) 次 privacy 查询降到 O(owner))。返回 map[owner]map[viewer]map[key]bool。
|
||||
|
|
|
|||
|
|
@ -3,11 +3,44 @@ package privacy
|
|||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
type countingBaseUsers struct {
|
||||
calls int
|
||||
users map[int64]domain.User
|
||||
}
|
||||
|
||||
func (p *countingBaseUsers) PrivacyBaseUsers(_ context.Context, userIDs []int64) ([]domain.User, error) {
|
||||
p.calls++
|
||||
out := make([]domain.User, 0, len(userIDs))
|
||||
for _, userID := range userIDs {
|
||||
if user, ok := p.users[userID]; ok {
|
||||
out = append(out, user)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
type countingMemberships struct {
|
||||
calls int
|
||||
active map[int64]map[int64]bool
|
||||
}
|
||||
|
||||
func (p *countingMemberships) FilterActiveChannelMemberIDs(_ context.Context, channelID int64, userIDs []int64) ([]int64, error) {
|
||||
p.calls++
|
||||
out := make([]int64, 0, len(userIDs))
|
||||
for _, userID := range userIDs {
|
||||
if p.active[channelID][userID] {
|
||||
out = append(out, userID)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func TestDefaultPrivacyRules(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := NewService(memory.NewPrivacyStore(), memory.NewContactStore())
|
||||
|
|
@ -192,3 +225,111 @@ func TestCanSeeMatrixEquivalentToCanSee(t *testing.T) {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestViewerFactsReadModelBatchesCachesAndInvalidates(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
rules := memory.NewPrivacyStore()
|
||||
users := &countingBaseUsers{users: map[int64]domain.User{
|
||||
2001: {ID: 2001, PremiumUntil: 2000},
|
||||
2002: {ID: 2002, Bot: true},
|
||||
}}
|
||||
svc := NewService(rules, memory.NewContactStore()).ConfigureReadModels(users, nil)
|
||||
svc.now = func() time.Time { return time.Unix(1000, 0) }
|
||||
|
||||
if _, err := svc.SetRules(ctx, 1001, domain.PrivacyKeyNoPaidMessages, []domain.PrivacyRule{
|
||||
{Kind: domain.PrivacyRuleAllowPremium},
|
||||
{Kind: domain.PrivacyRuleDisallowAll},
|
||||
}); err != nil {
|
||||
t.Fatalf("set premium rules: %v", err)
|
||||
}
|
||||
if _, err := svc.SetRules(ctx, 1002, domain.PrivacyKeyNoPaidMessages, []domain.PrivacyRule{
|
||||
{Kind: domain.PrivacyRuleAllowBots},
|
||||
{Kind: domain.PrivacyRuleDisallowAll},
|
||||
}); err != nil {
|
||||
t.Fatalf("set bot rules: %v", err)
|
||||
}
|
||||
|
||||
got, err := svc.CanSeeMatrix(
|
||||
ctx,
|
||||
[]int64{1001, 1002},
|
||||
[]int64{2001, 2002},
|
||||
[]domain.PrivacyKey{domain.PrivacyKeyNoPaidMessages},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("CanSeeMatrix: %v", err)
|
||||
}
|
||||
if !got[1001][2001][domain.PrivacyKeyNoPaidMessages] ||
|
||||
got[1001][2002][domain.PrivacyKeyNoPaidMessages] ||
|
||||
got[1002][2001][domain.PrivacyKeyNoPaidMessages] ||
|
||||
!got[1002][2002][domain.PrivacyKeyNoPaidMessages] {
|
||||
t.Fatalf("unexpected premium/bot visibility matrix: %+v", got)
|
||||
}
|
||||
if users.calls != 1 {
|
||||
t.Fatalf("base user cold loads = %d, want one batched load", users.calls)
|
||||
}
|
||||
|
||||
if premium, err := svc.ViewerIsPremium(ctx, 2001); err != nil || !premium {
|
||||
t.Fatalf("warm ViewerIsPremium = %v, err=%v; want true", premium, err)
|
||||
}
|
||||
if users.calls != 1 {
|
||||
t.Fatalf("warm viewer facts hit called backend: calls=%d", users.calls)
|
||||
}
|
||||
|
||||
users.users[2001] = domain.User{ID: 2001}
|
||||
svc.InvalidateViewerFacts(2001)
|
||||
if premium, err := svc.ViewerIsPremium(ctx, 2001); err != nil || premium {
|
||||
t.Fatalf("invalidated ViewerIsPremium = %v, err=%v; want false", premium, err)
|
||||
}
|
||||
if users.calls != 2 {
|
||||
t.Fatalf("invalidated viewer facts cold loads = %d, want 2", users.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMembershipReadModelCachesNegativeFactsAndInvalidatesPair(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
rules := memory.NewPrivacyStore()
|
||||
memberships := &countingMemberships{active: map[int64]map[int64]bool{
|
||||
9001: {2001: true},
|
||||
9002: {},
|
||||
}}
|
||||
svc := NewService(rules, memory.NewContactStore()).ConfigureReadModels(nil, memberships)
|
||||
if _, err := svc.SetRules(ctx, 1001, domain.PrivacyKeyChatInvite, []domain.PrivacyRule{
|
||||
{Kind: domain.PrivacyRuleAllowChatParticipants, ChatIDs: []int64{9001, 9002}},
|
||||
{Kind: domain.PrivacyRuleDisallowAll},
|
||||
}); err != nil {
|
||||
t.Fatalf("set participant rules: %v", err)
|
||||
}
|
||||
|
||||
got, err := svc.CanSeeMatrix(
|
||||
ctx,
|
||||
[]int64{1001},
|
||||
[]int64{2001, 2002},
|
||||
[]domain.PrivacyKey{domain.PrivacyKeyChatInvite},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("CanSeeMatrix: %v", err)
|
||||
}
|
||||
if !got[1001][2001][domain.PrivacyKeyChatInvite] ||
|
||||
got[1001][2002][domain.PrivacyKeyChatInvite] {
|
||||
t.Fatalf("unexpected membership visibility matrix: %+v", got)
|
||||
}
|
||||
if memberships.calls != 2 {
|
||||
t.Fatalf("membership cold loads = %d, want one batch per referenced chat", memberships.calls)
|
||||
}
|
||||
|
||||
if allowed, err := svc.CanSee(ctx, 1001, 2002, domain.PrivacyKeyChatInvite); err != nil || allowed {
|
||||
t.Fatalf("warm negative membership = %v, err=%v; want false", allowed, err)
|
||||
}
|
||||
if memberships.calls != 2 {
|
||||
t.Fatalf("negative cache missed: calls=%d", memberships.calls)
|
||||
}
|
||||
|
||||
memberships.active[9002][2002] = true
|
||||
svc.InvalidateMembership(9002, 2002)
|
||||
if allowed, err := svc.CanSee(ctx, 1001, 2002, domain.PrivacyKeyChatInvite); err != nil || !allowed {
|
||||
t.Fatalf("invalidated membership = %v, err=%v; want true", allowed, err)
|
||||
}
|
||||
if memberships.calls != 3 {
|
||||
t.Fatalf("pair invalidation reloads = %d, want 3", memberships.calls)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue