Files
yatulandClaude Opus 5 ea4890c257 fix(pairing): address review — docs, error mapping, approve feedback, unreadable expiry
- docs: device.pair.update and the `permanent` option on approve in
  docs/04-gateway-protocol.md, docs/19-websocket-rpc.md and
  websocket-protocol.md; the paired-device TTL row in docs/09-security.md
  now mentions the admin opt-out.
- store.ErrPairedDeviceNotFound: SetPairingPermanent wraps it in both stores.
  device.pair.update maps it to NOT_FOUND and any other store error to
  INTERNAL, so a DB failure no longer reads as "not found".
- web UI: approve and make-permanent/set-expiry now toast the server error
  and reload the list in `finally`. A partially applied approve (paired, but
  the permanent write failed) shows up in the table instead of leaving the
  dialog dead-ended.
- SQLite ListPaired: a stored expiry that fails to parse stays 0 (expires,
  date unknown) rather than being mistaken for permanent; the UI renders it
  as "--" instead of a 1970 date.

Tests: gateway handler error mapping (NOT_FOUND / INTERNAL / OK), sentinel
checks in the PG and SQLite store tests, SQLite unreadable-expiry case.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 19:26:43 +04:00

137 lines
4.1 KiB
Go

//go:build sqlite || sqliteonly
package sqlitestore
import (
"context"
"errors"
"path/filepath"
"testing"
"time"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
func newTestSQLitePairingStore(t *testing.T) (*SQLitePairingStore, context.Context) {
t.Helper()
db, err := OpenDB(filepath.Join(t.TempDir(), "pairing.db"))
if err != nil {
t.Fatalf("OpenDB error: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
if err := EnsureSchema(db); err != nil {
t.Fatalf("EnsureSchema error: %v", err)
}
ctx := store.WithTenantID(context.Background(), store.MasterTenantID)
return NewSQLitePairingStore(db), ctx
}
func pairTestDevice(t *testing.T, s *SQLitePairingStore, ctx context.Context, senderID string) *store.PairedDeviceData {
t.Helper()
code, err := s.RequestPairing(ctx, senderID, "telegram", "chat-1", "default", nil)
if err != nil {
t.Fatalf("RequestPairing: %v", err)
}
paired, err := s.ApprovePairing(ctx, code, "operator")
if err != nil {
t.Fatalf("ApprovePairing: %v", err)
}
return paired
}
func findPaired(list []store.PairedDeviceData, senderID string) *store.PairedDeviceData {
for i := range list {
if list[i].SenderID == senderID {
return &list[i]
}
}
return nil
}
func TestSQLitePairing_ApproveSetsDefaultTTL(t *testing.T) {
s, ctx := newTestSQLitePairingStore(t)
paired := pairTestDevice(t, s, ctx, "u1")
if paired.ExpiresAt == nil {
t.Fatal("ApprovePairing returned nil ExpiresAt, want default TTL")
}
got := findPaired(s.ListPaired(ctx), "u1")
if got == nil || got.ExpiresAt == nil {
t.Fatalf("ListPaired: want u1 with expiry, got %+v", got)
}
want := time.Now().Add(pairedDeviceTTL)
if d := time.UnixMilli(*got.ExpiresAt).Sub(want); d > time.Minute || d < -time.Minute {
t.Fatalf("expires_at off by %v from now+TTL", d)
}
}
func TestSQLitePairing_SetPermanentRoundTrip(t *testing.T) {
s, ctx := newTestSQLitePairingStore(t)
pairTestDevice(t, s, ctx, "u1")
if err := s.SetPairingPermanent(ctx, "u1", "telegram", true); err != nil {
t.Fatalf("SetPairingPermanent(true): %v", err)
}
got := findPaired(s.ListPaired(ctx), "u1")
if got == nil || got.ExpiresAt != nil {
t.Fatalf("after permanent: want u1 with nil ExpiresAt, got %+v", got)
}
if ok, err := s.IsPaired(ctx, "u1", "telegram"); err != nil || !ok {
t.Fatalf("IsPaired after permanent = %v, %v; want true", ok, err)
}
if err := s.SetPairingPermanent(ctx, "u1", "telegram", false); err != nil {
t.Fatalf("SetPairingPermanent(false): %v", err)
}
got = findPaired(s.ListPaired(ctx), "u1")
if got == nil || got.ExpiresAt == nil {
t.Fatalf("after reverting: want u1 with expiry, got %+v", got)
}
}
func TestSQLitePairing_SetPermanentDoesNotReviveExpired(t *testing.T) {
s, ctx := newTestSQLitePairingStore(t)
pairTestDevice(t, s, ctx, "u1")
if _, err := s.db.ExecContext(ctx, "UPDATE paired_devices SET expires_at = ? WHERE sender_id = ?",
time.Now().Add(-time.Hour).Round(0), "u1"); err != nil {
t.Fatalf("expire pairing: %v", err)
}
if err := s.SetPairingPermanent(ctx, "u1", "telegram", true); !errors.Is(err, store.ErrPairedDeviceNotFound) {
t.Fatalf("SetPairingPermanent on expired pairing: want ErrPairedDeviceNotFound, got %v", err)
}
if ok, _ := s.IsPaired(ctx, "u1", "telegram"); ok {
t.Fatal("expired pairing was revived")
}
}
func TestSQLitePairing_SetPermanentUnknownDevice(t *testing.T) {
s, ctx := newTestSQLitePairingStore(t)
if err := s.SetPairingPermanent(ctx, "nobody", "telegram", true); !errors.Is(err, store.ErrPairedDeviceNotFound) {
t.Fatalf("want ErrPairedDeviceNotFound for unknown device, got %v", err)
}
}
// An expiry the parser cannot read must not turn into "never expires".
func TestSQLitePairing_UnreadableExpiryIsNotPermanent(t *testing.T) {
s, ctx := newTestSQLitePairingStore(t)
pairTestDevice(t, s, ctx, "u1")
if _, err := s.db.ExecContext(ctx, "UPDATE paired_devices SET expires_at = ? WHERE sender_id = ?",
"9999-garbage", "u1"); err != nil {
t.Fatalf("corrupt expiry: %v", err)
}
got := findPaired(s.ListPaired(ctx), "u1")
if got == nil || got.ExpiresAt == nil || *got.ExpiresAt != 0 {
t.Fatalf("want u1 with ExpiresAt=0 (unknown date), got %+v", got)
}
}