mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-11 03:13:24 +00:00
Every approved pairing expires 30 days after approval (pairedDeviceTTL),
and nothing lets an operator change that: owners of a personal setup have
to re-pair their own Telegram account and browser every month. The schema
already treats a NULL paired_devices.expires_at as "never expires" (IsPaired
and the prune both check for it), only no code path ever writes NULL.
- store: SetPairingPermanent(senderID, channel, permanent) on PairingStore,
PG and SQLite. permanent=true clears expires_at, false restarts the
default TTL from now. An already expired pairing is not revived.
PairedDeviceData gains expires_at (Unix ms, null = never), returned by
ApprovePairing and ListPaired.
- gateway: device.pair.approve accepts `permanent`; new admin RPC
device.pair.update {senderId, channel, permanent} for existing pairings,
classified in permissions/policy.go next to the other pairing methods.
- mcp: pairing approve tool accepts `permanent`.
- web UI (Nodes): "Never expires" switch in the approve dialog, an
Expires column, and a Make permanent / Set expiry action per device.
ConfirmDialog takes optional children for the switch.
The default stays 30 days; permanence is an explicit operator choice.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
833 lines
23 KiB
Go
833 lines
23 KiB
Go
package channels
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/bus"
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
)
|
|
|
|
// mockPairingStore is a test implementation of store.PairingStore.
|
|
type mockPairingStore struct {
|
|
pairedDevices map[string]map[string]bool // senderID -> channel -> paired
|
|
failIsPaired bool // force IsPaired to return error
|
|
}
|
|
|
|
func newMockPairingStore() *mockPairingStore {
|
|
return &mockPairingStore{
|
|
pairedDevices: make(map[string]map[string]bool),
|
|
}
|
|
}
|
|
|
|
func (s *mockPairingStore) SetPairingPermanent(context.Context, string, string, bool) error {
|
|
return nil
|
|
}
|
|
|
|
func (m *mockPairingStore) IsPaired(ctx context.Context, senderID, channel string) (bool, error) {
|
|
if m.failIsPaired {
|
|
return false, errors.New("pairing service error")
|
|
}
|
|
if m.pairedDevices[senderID] == nil {
|
|
return false, nil
|
|
}
|
|
return m.pairedDevices[senderID][channel], nil
|
|
}
|
|
|
|
func (m *mockPairingStore) RequestPairing(ctx context.Context, senderID, channel, chatID, accountID string, metadata map[string]string) (string, error) {
|
|
return "code123", nil
|
|
}
|
|
|
|
func (m *mockPairingStore) ApprovePairing(ctx context.Context, code, approvedBy string) (*store.PairedDeviceData, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *mockPairingStore) DenyPairing(ctx context.Context, code string) error {
|
|
return nil
|
|
}
|
|
|
|
func (m *mockPairingStore) RevokePairing(ctx context.Context, senderID, channel string) error {
|
|
return nil
|
|
}
|
|
|
|
func (m *mockPairingStore) ListPending(ctx context.Context) []store.PairingRequestData {
|
|
return nil
|
|
}
|
|
|
|
func (m *mockPairingStore) ListPaired(ctx context.Context) []store.PairedDeviceData {
|
|
return nil
|
|
}
|
|
|
|
func (m *mockPairingStore) MigrateGroupChatID(ctx context.Context, channel, oldChatID, newChatID string) error {
|
|
return nil
|
|
}
|
|
|
|
// setPaired sets up a mock paired relationship.
|
|
func (m *mockPairingStore) setPaired(senderID, channel string) {
|
|
if m.pairedDevices[senderID] == nil {
|
|
m.pairedDevices[senderID] = make(map[string]bool)
|
|
}
|
|
m.pairedDevices[senderID][channel] = true
|
|
}
|
|
|
|
// setUnpaired removes a mock paired relationship.
|
|
func (m *mockPairingStore) setUnpaired(senderID, channel string) {
|
|
if m.pairedDevices[senderID] != nil {
|
|
delete(m.pairedDevices[senderID], channel)
|
|
}
|
|
}
|
|
|
|
// TestCheckDMPolicy_PolicyDisabled rejects all messages.
|
|
func TestCheckDMPolicy_PolicyDisabled(t *testing.T) {
|
|
bc := NewBaseChannel("test", nil, nil)
|
|
ctx := context.Background()
|
|
|
|
result := bc.CheckDMPolicy(ctx, "user123", "disabled")
|
|
|
|
if result != PolicyDeny {
|
|
t.Errorf("CheckDMPolicy(disabled) = %v; want PolicyDeny", result)
|
|
}
|
|
}
|
|
|
|
// TestCheckDMPolicy_PolicyOpen accepts all messages.
|
|
func TestCheckDMPolicy_PolicyOpen(t *testing.T) {
|
|
bc := NewBaseChannel("test", nil, nil)
|
|
ctx := context.Background()
|
|
|
|
result := bc.CheckDMPolicy(ctx, "unknown_user", "open")
|
|
|
|
if result != PolicyAllow {
|
|
t.Errorf("CheckDMPolicy(open) = %v; want PolicyAllow", result)
|
|
}
|
|
}
|
|
|
|
// TestCheckDMPolicy_PolicyAllowlist checks allowlist membership.
|
|
func TestCheckDMPolicy_PolicyAllowlist(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
senderID string
|
|
allowList []string
|
|
wantResult PolicyResult
|
|
}{
|
|
{
|
|
name: "Sender in allowlist is allowed",
|
|
senderID: "user123",
|
|
allowList: []string{"user123", "user456"},
|
|
wantResult: PolicyAllow,
|
|
},
|
|
{
|
|
name: "Sender not in allowlist is denied",
|
|
senderID: "user999",
|
|
allowList: []string{"user123", "user456"},
|
|
wantResult: PolicyDeny,
|
|
},
|
|
{
|
|
name: "Empty allowlist allows all (no restrictions)",
|
|
senderID: "anyone",
|
|
allowList: []string{},
|
|
wantResult: PolicyAllow, // empty allowlist = no allowlist restrictions
|
|
},
|
|
{
|
|
name: "Username with @ prefix is matched",
|
|
senderID: "user123",
|
|
allowList: []string{"@user123"},
|
|
wantResult: PolicyAllow,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
bc := NewBaseChannel("test", nil, tt.allowList)
|
|
ctx := context.Background()
|
|
|
|
result := bc.CheckDMPolicy(ctx, tt.senderID, "allowlist")
|
|
|
|
if result != tt.wantResult {
|
|
t.Errorf("CheckDMPolicy(allowlist) with allowList=%v = %v; want %v", tt.allowList, result, tt.wantResult)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestCheckDMPolicy_PolicyPairing checks pairing status.
|
|
func TestCheckDMPolicy_PolicyPairing(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
senderID string
|
|
allowList []string
|
|
paired bool
|
|
failPairingCheck bool
|
|
wantResult PolicyResult
|
|
}{
|
|
{
|
|
name: "Paired sender is allowed",
|
|
senderID: "user123",
|
|
allowList: []string{},
|
|
paired: true,
|
|
failPairingCheck: false,
|
|
wantResult: PolicyAllow,
|
|
},
|
|
{
|
|
name: "Unpaired sender needs pairing",
|
|
senderID: "user999",
|
|
allowList: []string{},
|
|
paired: false,
|
|
failPairingCheck: false,
|
|
wantResult: PolicyNeedsPairing,
|
|
},
|
|
{
|
|
name: "Sender in allowlist bypasses pairing",
|
|
senderID: "user456",
|
|
allowList: []string{"user456"},
|
|
paired: false,
|
|
failPairingCheck: false,
|
|
wantResult: PolicyAllow,
|
|
},
|
|
{
|
|
name: "Pairing service error denies message (fail-closed)",
|
|
senderID: "user999",
|
|
allowList: []string{},
|
|
paired: false,
|
|
failPairingCheck: true,
|
|
wantResult: PolicyDeny,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
bc := NewBaseChannel("test", nil, tt.allowList)
|
|
ps := newMockPairingStore()
|
|
ps.failIsPaired = tt.failPairingCheck
|
|
|
|
if tt.paired {
|
|
ps.setPaired(tt.senderID, "test")
|
|
}
|
|
|
|
bc.SetPairingService(ps)
|
|
ctx := context.Background()
|
|
|
|
result := bc.CheckDMPolicy(ctx, tt.senderID, "pairing")
|
|
|
|
if result != tt.wantResult {
|
|
t.Errorf("CheckDMPolicy(pairing) = %v; want %v", result, tt.wantResult)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestCheckDMPolicy_DefaultToPairing verifies empty policy defaults to "pairing".
|
|
func TestCheckDMPolicy_DefaultToPairing(t *testing.T) {
|
|
bc := NewBaseChannel("test", nil, nil)
|
|
ps := newMockPairingStore()
|
|
bc.SetPairingService(ps)
|
|
ctx := context.Background()
|
|
|
|
result := bc.CheckDMPolicy(ctx, "unpaired_user", "")
|
|
|
|
if result != PolicyNeedsPairing {
|
|
t.Errorf("CheckDMPolicy with empty policy defaults to pairing, got %v; want PolicyNeedsPairing", result)
|
|
}
|
|
}
|
|
|
|
func TestHandleAuthorizedMessage_PairedDirectMessageBypassesAllowlistSafetyNet(t *testing.T) {
|
|
msgBus := bus.New()
|
|
bc := NewBaseChannel(TypeZaloPersonal, msgBus, []string{"195835936795841454"})
|
|
bc.SetName("zalo-cppai-pm")
|
|
bc.SetAgentID("cppai-pm")
|
|
|
|
ps := newMockPairingStore()
|
|
ps.setPaired("648444320145379814", "zalo-cppai-pm")
|
|
bc.SetPairingService(ps)
|
|
|
|
if got := bc.CheckDMPolicy(context.Background(), "648444320145379814", "pairing"); got != PolicyAllow {
|
|
t.Fatalf("CheckDMPolicy(pairing) = %v; want PolicyAllow", got)
|
|
}
|
|
|
|
bc.HandleAuthorizedMessage("648444320145379814", "648444320145379814", "xin chao", nil, nil, "direct")
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
|
defer cancel()
|
|
msg, ok := msgBus.ConsumeInbound(ctx)
|
|
if !ok {
|
|
t.Fatal("expected paired direct message to publish inbound")
|
|
}
|
|
if msg.Channel != "zalo-cppai-pm" {
|
|
t.Fatalf("Channel = %q; want zalo-cppai-pm", msg.Channel)
|
|
}
|
|
if msg.SenderID != "648444320145379814" {
|
|
t.Fatalf("SenderID = %q; want 648444320145379814", msg.SenderID)
|
|
}
|
|
if msg.PeerKind != "direct" {
|
|
t.Fatalf("PeerKind = %q; want direct", msg.PeerKind)
|
|
}
|
|
}
|
|
|
|
func TestHandleMessage_PairedDirectMessageWithoutPolicyCheckStillDrops(t *testing.T) {
|
|
msgBus := bus.New()
|
|
bc := NewBaseChannel(TypeZaloPersonal, msgBus, []string{"195835936795841454"})
|
|
bc.SetName("zalo-cppai-pm")
|
|
|
|
ps := newMockPairingStore()
|
|
ps.setPaired("648444320145379814", "zalo-cppai-pm")
|
|
bc.SetPairingService(ps)
|
|
|
|
bc.HandleMessage("648444320145379814", "648444320145379814", "xin chao", nil, nil, "direct")
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
|
defer cancel()
|
|
if msg, ok := msgBus.ConsumeInbound(ctx); ok {
|
|
t.Fatalf("expected direct message without explicit policy check to drop, got %+v", msg)
|
|
}
|
|
}
|
|
|
|
func TestHandleMessageMedia_UnpairedDirectMessageOutsideAllowlistStillDrops(t *testing.T) {
|
|
msgBus := bus.New()
|
|
bc := NewBaseChannel(TypeZaloPersonal, msgBus, []string{"195835936795841454"})
|
|
bc.SetName("zalo-cppai-pm")
|
|
bc.SetPairingService(newMockPairingStore())
|
|
|
|
bc.HandleMessage("648444320145379814", "648444320145379814", "xin chao", nil, nil, "direct")
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
|
defer cancel()
|
|
if msg, ok := msgBus.ConsumeInbound(ctx); ok {
|
|
t.Fatalf("expected unpaired direct message outside allowlist to drop, got %+v", msg)
|
|
}
|
|
}
|
|
|
|
// TestCheckGroupPolicy_PolicyDisabled rejects group messages.
|
|
func TestCheckGroupPolicy_PolicyDisabled(t *testing.T) {
|
|
bc := NewBaseChannel("test", nil, nil)
|
|
ctx := context.Background()
|
|
|
|
result := bc.CheckGroupPolicy(ctx, "user123", "chat_group_1", "disabled")
|
|
|
|
if result != PolicyDeny {
|
|
t.Errorf("CheckGroupPolicy(disabled) = %v; want PolicyDeny", result)
|
|
}
|
|
}
|
|
|
|
// TestCheckGroupPolicy_PolicyOpen accepts all group messages.
|
|
func TestCheckGroupPolicy_PolicyOpen(t *testing.T) {
|
|
bc := NewBaseChannel("test", nil, nil)
|
|
ctx := context.Background()
|
|
|
|
result := bc.CheckGroupPolicy(ctx, "anyone", "chat_group_1", "open")
|
|
|
|
if result != PolicyAllow {
|
|
t.Errorf("CheckGroupPolicy(open) = %v; want PolicyAllow", result)
|
|
}
|
|
}
|
|
|
|
// TestCheckGroupPolicy_PolicyAllowlist checks sender's allowlist membership.
|
|
func TestCheckGroupPolicy_PolicyAllowlist(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
senderID string
|
|
allowList []string
|
|
wantResult PolicyResult
|
|
}{
|
|
{
|
|
name: "Sender in allowlist is allowed",
|
|
senderID: "user123",
|
|
allowList: []string{"user123", "user456"},
|
|
wantResult: PolicyAllow,
|
|
},
|
|
{
|
|
name: "Sender not in allowlist is denied",
|
|
senderID: "user999",
|
|
allowList: []string{"user123", "user456"},
|
|
wantResult: PolicyDeny,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
bc := NewBaseChannel("test", nil, tt.allowList)
|
|
ctx := context.Background()
|
|
|
|
result := bc.CheckGroupPolicy(ctx, tt.senderID, "chat_group_1", "allowlist")
|
|
|
|
if result != tt.wantResult {
|
|
t.Errorf("CheckGroupPolicy(allowlist) = %v; want %v", result, tt.wantResult)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestCheckGroupPolicy_PolicyPairing checks group pairing status.
|
|
func TestCheckGroupPolicy_PolicyPairing(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
senderID string
|
|
chatID string
|
|
allowList []string
|
|
groupApproved bool
|
|
paired bool
|
|
failPairingCheck bool
|
|
wantResult PolicyResult
|
|
}{
|
|
{
|
|
name: "Sender in allowlist bypasses pairing check",
|
|
senderID: "user123",
|
|
chatID: "chat_1",
|
|
allowList: []string{"user123"},
|
|
groupApproved: false,
|
|
paired: false,
|
|
wantResult: PolicyAllow,
|
|
},
|
|
{
|
|
name: "Group already approved allows message",
|
|
senderID: "user999",
|
|
chatID: "chat_1",
|
|
allowList: []string{},
|
|
groupApproved: true,
|
|
paired: false,
|
|
wantResult: PolicyAllow,
|
|
},
|
|
{
|
|
name: "Unpaired group needs pairing",
|
|
senderID: "user999",
|
|
chatID: "chat_new",
|
|
allowList: []string{},
|
|
groupApproved: false,
|
|
paired: false,
|
|
wantResult: PolicyNeedsPairing,
|
|
},
|
|
{
|
|
name: "Pairing service error denies (fail-closed)",
|
|
senderID: "user999",
|
|
chatID: "chat_2",
|
|
allowList: []string{},
|
|
groupApproved: false,
|
|
paired: false,
|
|
failPairingCheck: true,
|
|
wantResult: PolicyDeny,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
bc := NewBaseChannel("test", nil, tt.allowList)
|
|
ps := newMockPairingStore()
|
|
ps.failIsPaired = tt.failPairingCheck
|
|
|
|
// For pairing policy, groups are keyed as "group:<chatID>"
|
|
if tt.paired {
|
|
ps.setPaired("group:"+tt.chatID, "test")
|
|
}
|
|
|
|
bc.SetPairingService(ps)
|
|
|
|
if tt.groupApproved {
|
|
bc.MarkGroupApproved(tt.chatID)
|
|
}
|
|
|
|
ctx := context.Background()
|
|
result := bc.CheckGroupPolicy(ctx, tt.senderID, tt.chatID, "pairing")
|
|
|
|
if result != tt.wantResult {
|
|
t.Errorf("CheckGroupPolicy(pairing) = %v; want %v", result, tt.wantResult)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestCheckGroupPolicy_DefaultToOpen verifies empty policy defaults to "open".
|
|
func TestCheckGroupPolicy_DefaultToOpen(t *testing.T) {
|
|
bc := NewBaseChannel("test", nil, nil)
|
|
ctx := context.Background()
|
|
|
|
result := bc.CheckGroupPolicy(ctx, "anyone", "chat_1", "")
|
|
|
|
if result != PolicyAllow {
|
|
t.Errorf("CheckGroupPolicy with empty policy defaults to open, got %v; want PolicyAllow", result)
|
|
}
|
|
}
|
|
|
|
// TestCanSendPairingNotif_RespectDebounce tests debounce behavior.
|
|
func TestCanSendPairingNotif_RespectDebounce(t *testing.T) {
|
|
bc := NewBaseChannel("test", nil, nil)
|
|
senderID := "user123"
|
|
debounce := 100 * time.Millisecond
|
|
|
|
// First call should always return true (no prior send)
|
|
if !bc.CanSendPairingNotif(senderID, debounce) {
|
|
t.Error("First CanSendPairingNotif should return true")
|
|
}
|
|
|
|
// Record the send
|
|
bc.MarkPairingNotifSent(senderID)
|
|
|
|
// Immediate second call should return false (within debounce window)
|
|
if bc.CanSendPairingNotif(senderID, debounce) {
|
|
t.Error("Second CanSendPairingNotif within debounce window should return false")
|
|
}
|
|
|
|
// After debounce expires, should return true again
|
|
time.Sleep(debounce + 10*time.Millisecond)
|
|
if !bc.CanSendPairingNotif(senderID, debounce) {
|
|
t.Error("CanSendPairingNotif after debounce should return true")
|
|
}
|
|
}
|
|
|
|
// TestCanSendPairingNotif_IndependentPerSender tests debounce is per-sender.
|
|
func TestCanSendPairingNotif_IndependentPerSender(t *testing.T) {
|
|
bc := NewBaseChannel("test", nil, nil)
|
|
debounce := 100 * time.Millisecond
|
|
|
|
user1 := "user1"
|
|
user2 := "user2"
|
|
|
|
// Mark user1 as sent
|
|
bc.MarkPairingNotifSent(user1)
|
|
|
|
// user2 should not be affected
|
|
if !bc.CanSendPairingNotif(user2, debounce) {
|
|
t.Error("Different senders should have independent debounce windows")
|
|
}
|
|
|
|
// user1 should still be in debounce
|
|
if bc.CanSendPairingNotif(user1, debounce) {
|
|
t.Error("user1 should still be in debounce window")
|
|
}
|
|
}
|
|
|
|
// TestIsGroupApproved_CachesBehavior tests group approval cache.
|
|
func TestIsGroupApproved_CachesBehavior(t *testing.T) {
|
|
bc := NewBaseChannel("test", nil, nil)
|
|
chatID := "chat_group_123"
|
|
|
|
// Initially not approved
|
|
if bc.IsGroupApproved(chatID) {
|
|
t.Error("Group should not be approved initially")
|
|
}
|
|
|
|
// Mark as approved
|
|
bc.MarkGroupApproved(chatID)
|
|
|
|
// Now should be approved
|
|
if !bc.IsGroupApproved(chatID) {
|
|
t.Error("Group should be approved after MarkGroupApproved")
|
|
}
|
|
|
|
// Clear approval
|
|
bc.ClearGroupApproval(chatID)
|
|
|
|
// Should be not approved again
|
|
if bc.IsGroupApproved(chatID) {
|
|
t.Error("Group should not be approved after ClearGroupApproval")
|
|
}
|
|
}
|
|
|
|
// TestIsGroupApproved_MultipleGroups verifies independent cache per group.
|
|
func TestIsGroupApproved_MultipleGroups(t *testing.T) {
|
|
bc := NewBaseChannel("test", nil, nil)
|
|
|
|
chat1 := "chat_1"
|
|
chat2 := "chat_2"
|
|
|
|
// Approve only chat1
|
|
bc.MarkGroupApproved(chat1)
|
|
|
|
if !bc.IsGroupApproved(chat1) {
|
|
t.Error("chat1 should be approved")
|
|
}
|
|
|
|
if bc.IsGroupApproved(chat2) {
|
|
t.Error("chat2 should not be approved")
|
|
}
|
|
|
|
// Approve chat2 as well
|
|
bc.MarkGroupApproved(chat2)
|
|
|
|
if !bc.IsGroupApproved(chat1) {
|
|
t.Error("chat1 should still be approved")
|
|
}
|
|
|
|
if !bc.IsGroupApproved(chat2) {
|
|
t.Error("chat2 should now be approved")
|
|
}
|
|
}
|
|
|
|
// TestCheckGroupPolicy_PairingMarksGroupApproved verifies groups are cached after pairing.
|
|
func TestCheckGroupPolicy_PairingMarksGroupApproved(t *testing.T) {
|
|
bc := NewBaseChannel("test", nil, nil)
|
|
ps := newMockPairingStore()
|
|
bc.SetPairingService(ps)
|
|
|
|
chatID := "chat_group_1"
|
|
|
|
// Pair the group
|
|
ps.setPaired("group:"+chatID, "test")
|
|
|
|
ctx := context.Background()
|
|
|
|
// First check should succeed and cache the approval
|
|
result := bc.CheckGroupPolicy(ctx, "user999", chatID, "pairing")
|
|
if result != PolicyAllow {
|
|
t.Errorf("First CheckGroupPolicy with paired group = %v; want PolicyAllow", result)
|
|
}
|
|
|
|
// Verify group is now cached as approved
|
|
if !bc.IsGroupApproved(chatID) {
|
|
t.Error("Group should be cached as approved after successful pairing check")
|
|
}
|
|
}
|
|
|
|
// TestManagerClearGroupApproval verifies the Manager clears the per-channel
|
|
// in-memory approval cache used to short-circuit the pairing gate after a
|
|
// pairing is revoked.
|
|
func TestManagerClearGroupApproval(t *testing.T) {
|
|
bc := newApprovalChannel("telegram")
|
|
ps := newMockPairingStore()
|
|
bc.SetPairingService(ps)
|
|
|
|
mgr := NewManager(nil)
|
|
mgr.RegisterChannel("telegram", bc)
|
|
|
|
chatID := "chat_group_1"
|
|
ps.setPaired("group:"+chatID, "telegram")
|
|
|
|
ctx := context.Background()
|
|
if got := bc.CheckGroupPolicy(ctx, "user1", chatID, "pairing"); got != PolicyAllow {
|
|
t.Fatalf("paired group policy = %v; want PolicyAllow", got)
|
|
}
|
|
if !bc.IsGroupApproved(chatID) {
|
|
t.Fatal("group should be cached as approved before revocation")
|
|
}
|
|
|
|
// Revoke → cache must be cleared so the next message re-enters the pairing gate.
|
|
mgr.ClearGroupApproval("telegram", chatID)
|
|
if bc.IsGroupApproved(chatID) {
|
|
t.Fatal("group approval cache should be cleared after revocation")
|
|
}
|
|
|
|
// DB row is gone → next policy check must return PolicyNeedsPairing.
|
|
ps.setUnpaired("group:"+chatID, "telegram")
|
|
if got := bc.CheckGroupPolicy(ctx, "user1", chatID, "pairing"); got != PolicyNeedsPairing {
|
|
t.Fatalf("post-revoke group policy = %v; want PolicyNeedsPairing", got)
|
|
}
|
|
|
|
// Unknown channel names are a no-op (no panic).
|
|
mgr.ClearGroupApproval("nonexistent", chatID)
|
|
}
|
|
|
|
// approvalChannel wraps BaseChannel so it satisfies channels.Channel in tests.
|
|
type approvalChannel struct {
|
|
*BaseChannel
|
|
}
|
|
|
|
func newApprovalChannel(name string) *approvalChannel {
|
|
return &approvalChannel{BaseChannel: NewBaseChannel(name, nil, nil)}
|
|
}
|
|
|
|
func (c *approvalChannel) Send(context.Context, bus.OutboundMessage) error { return nil }
|
|
|
|
func (c *approvalChannel) Start(context.Context) error { return nil }
|
|
|
|
func (c *approvalChannel) Stop(context.Context) error { return nil }
|
|
|
|
// TestCheckDMPolicy_AllPolicies_TableDriven comprehensive table test.
|
|
func TestCheckDMPolicy_AllPolicies_TableDriven(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
policy string
|
|
senderID string
|
|
allowList []string
|
|
paired bool
|
|
failPairingCheck bool
|
|
wantResult PolicyResult
|
|
}{
|
|
{
|
|
name: "disabled policy always denies",
|
|
policy: "disabled",
|
|
senderID: "user1",
|
|
wantResult: PolicyDeny,
|
|
},
|
|
{
|
|
name: "open policy always allows",
|
|
policy: "open",
|
|
senderID: "user1",
|
|
wantResult: PolicyAllow,
|
|
},
|
|
{
|
|
name: "allowlist policy allows if in list",
|
|
policy: "allowlist",
|
|
senderID: "user1",
|
|
allowList: []string{"user1", "user2"},
|
|
wantResult: PolicyAllow,
|
|
},
|
|
{
|
|
name: "allowlist policy denies if not in list",
|
|
policy: "allowlist",
|
|
senderID: "user3",
|
|
allowList: []string{"user1", "user2"},
|
|
wantResult: PolicyDeny,
|
|
},
|
|
{
|
|
name: "pairing policy allows paired users",
|
|
policy: "pairing",
|
|
senderID: "user1",
|
|
allowList: []string{},
|
|
paired: true,
|
|
wantResult: PolicyAllow,
|
|
},
|
|
{
|
|
name: "pairing policy needs pairing for unpaired users",
|
|
policy: "pairing",
|
|
senderID: "user3",
|
|
allowList: []string{},
|
|
paired: false,
|
|
wantResult: PolicyNeedsPairing,
|
|
},
|
|
{
|
|
name: "pairing policy fail-closed on service error",
|
|
policy: "pairing",
|
|
senderID: "user3",
|
|
allowList: []string{},
|
|
paired: false,
|
|
failPairingCheck: true,
|
|
wantResult: PolicyDeny,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
bc := NewBaseChannel("test", nil, tt.allowList)
|
|
|
|
if tt.policy == "pairing" {
|
|
ps := newMockPairingStore()
|
|
ps.failIsPaired = tt.failPairingCheck
|
|
|
|
if tt.paired {
|
|
ps.setPaired(tt.senderID, "test")
|
|
}
|
|
|
|
bc.SetPairingService(ps)
|
|
}
|
|
|
|
ctx := context.Background()
|
|
result := bc.CheckDMPolicy(ctx, tt.senderID, tt.policy)
|
|
|
|
if result != tt.wantResult {
|
|
t.Errorf("CheckDMPolicy(%s) = %v; want %v", tt.policy, result, tt.wantResult)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestCheckGroupPolicy_AllPolicies_TableDriven comprehensive table test for group policies.
|
|
func TestCheckGroupPolicy_AllPolicies_TableDriven(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
policy string
|
|
senderID string
|
|
chatID string
|
|
allowList []string
|
|
groupApproved bool
|
|
paired bool
|
|
wantResult PolicyResult
|
|
}{
|
|
{
|
|
name: "disabled policy always denies",
|
|
policy: "disabled",
|
|
senderID: "user1",
|
|
chatID: "chat1",
|
|
wantResult: PolicyDeny,
|
|
},
|
|
{
|
|
name: "open policy always allows",
|
|
policy: "open",
|
|
senderID: "user1",
|
|
chatID: "chat1",
|
|
wantResult: PolicyAllow,
|
|
},
|
|
{
|
|
name: "allowlist policy allows if sender in list",
|
|
policy: "allowlist",
|
|
senderID: "user1",
|
|
chatID: "chat1",
|
|
allowList: []string{"user1", "user2"},
|
|
wantResult: PolicyAllow,
|
|
},
|
|
{
|
|
name: "allowlist policy denies if sender not in list",
|
|
policy: "allowlist",
|
|
senderID: "user3",
|
|
chatID: "chat1",
|
|
allowList: []string{"user1", "user2"},
|
|
wantResult: PolicyDeny,
|
|
},
|
|
{
|
|
name: "pairing policy allows if sender in allowlist",
|
|
policy: "pairing",
|
|
senderID: "user1",
|
|
chatID: "chat1",
|
|
allowList: []string{"user1"},
|
|
groupApproved: false,
|
|
paired: false,
|
|
wantResult: PolicyAllow,
|
|
},
|
|
{
|
|
name: "pairing policy allows if group already approved",
|
|
policy: "pairing",
|
|
senderID: "user3",
|
|
chatID: "chat1",
|
|
allowList: []string{},
|
|
groupApproved: true,
|
|
paired: false,
|
|
wantResult: PolicyAllow,
|
|
},
|
|
{
|
|
name: "pairing policy allows if group paired",
|
|
policy: "pairing",
|
|
senderID: "user3",
|
|
chatID: "chat1",
|
|
allowList: []string{},
|
|
groupApproved: false,
|
|
paired: true,
|
|
wantResult: PolicyAllow,
|
|
},
|
|
{
|
|
name: "pairing policy needs pairing if group not approved or paired",
|
|
policy: "pairing",
|
|
senderID: "user3",
|
|
chatID: "chat_new",
|
|
allowList: []string{},
|
|
groupApproved: false,
|
|
paired: false,
|
|
wantResult: PolicyNeedsPairing,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
bc := NewBaseChannel("test", nil, tt.allowList)
|
|
|
|
if tt.groupApproved {
|
|
bc.MarkGroupApproved(tt.chatID)
|
|
}
|
|
|
|
if tt.policy == "pairing" {
|
|
ps := newMockPairingStore()
|
|
|
|
if tt.paired {
|
|
ps.setPaired("group:"+tt.chatID, "test")
|
|
}
|
|
|
|
bc.SetPairingService(ps)
|
|
}
|
|
|
|
ctx := context.Background()
|
|
result := bc.CheckGroupPolicy(ctx, tt.senderID, tt.chatID, tt.policy)
|
|
|
|
if result != tt.wantResult {
|
|
t.Errorf("CheckGroupPolicy(%s) = %v; want %v", tt.policy, result, tt.wantResult)
|
|
}
|
|
})
|
|
}
|
|
}
|