Files
yatulandClaude Opus 5 5379cc1163 feat(pairing): let an operator make a device pairing permanent
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>
2026-09-18 12:12:23 +04:00

115 lines
3.4 KiB
Go

package slack
import (
"context"
"testing"
"time"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
type slackPolicyPairingStore struct {
paired map[string]map[string]bool
}
func newSlackPolicyPairingStore() *slackPolicyPairingStore {
return &slackPolicyPairingStore{paired: make(map[string]map[string]bool)}
}
func (s *slackPolicyPairingStore) RequestPairing(context.Context, string, string, string, string, map[string]string) (string, error) {
return "code123", nil
}
func (s *slackPolicyPairingStore) ApprovePairing(context.Context, string, string) (*store.PairedDeviceData, error) {
return nil, nil
}
func (s *slackPolicyPairingStore) DenyPairing(context.Context, string) error { return nil }
func (s *slackPolicyPairingStore) RevokePairing(context.Context, string, string) error { return nil }
func (s *slackPolicyPairingStore) SetPairingPermanent(context.Context, string, string, bool) error {
return nil
}
func (s *slackPolicyPairingStore) IsPaired(_ context.Context, senderID, channel string) (bool, error) {
if s.paired[senderID] == nil {
return false, nil
}
return s.paired[senderID][channel], nil
}
func (s *slackPolicyPairingStore) ListPending(context.Context) []store.PairingRequestData {
return nil
}
func (s *slackPolicyPairingStore) ListPaired(context.Context) []store.PairedDeviceData {
return nil
}
func (s *slackPolicyPairingStore) MigrateGroupChatID(context.Context, string, string, string) error {
return nil
}
func (s *slackPolicyPairingStore) setPaired(senderID, channel string) {
if s.paired[senderID] == nil {
s.paired[senderID] = make(map[string]bool)
}
s.paired[senderID][channel] = true
}
func TestSlackPairedDirectMessageBypassesAllowlistAfterPolicyCheck(t *testing.T) {
msgBus := bus.New()
pairingStore := newSlackPolicyPairingStore()
pairingStore.setPaired("U_PAIRED", "slack-test")
ch, err := New(config.SlackConfig{
BotToken: "xoxb-test",
AppToken: "xapp-test",
AllowFrom: []string{"U_ALLOWED"},
DMPolicy: "pairing",
}, msgBus, pairingStore, nil)
if err != nil {
t.Fatalf("New() error = %v", err)
}
ch.SetName("slack-test")
if !ch.checkDMPolicy(context.Background(), "U_PAIRED", "D123") {
t.Fatal("expected paired Slack sender to pass DM policy")
}
ch.HandleAuthorizedMessage("U_PAIRED", "D123", "hello", nil, map[string]string{"username": "paired"}, "direct")
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
msg, ok := msgBus.ConsumeInbound(ctx)
if !ok {
t.Fatal("expected paired Slack DM to publish inbound")
}
if msg.SenderID != "U_PAIRED" || msg.ChatID != "D123" || msg.PeerKind != "direct" {
t.Fatalf("unexpected inbound message: %+v", msg)
}
}
func TestSlackHandleMessageWithoutPolicyCheckStillUsesAllowlistSafetyNet(t *testing.T) {
msgBus := bus.New()
ch, err := New(config.SlackConfig{
BotToken: "xoxb-test",
AppToken: "xapp-test",
AllowFrom: []string{"U_ALLOWED"},
DMPolicy: "pairing",
}, msgBus, newSlackPolicyPairingStore(), nil)
if err != nil {
t.Fatalf("New() error = %v", err)
}
ch.HandleMessage("U_PAIRED", "D123", "hello", nil, map[string]string{"username": "paired"}, "direct")
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
if msg, ok := msgBus.ConsumeInbound(ctx); ok {
t.Fatalf("expected unverified Slack DM to drop, got %+v", msg)
}
}