package sesame import ( "bufio" "context" "errors" "os" "os/exec" "path/filepath" "reflect" "sync" "testing" "time" "github.com/godbus/dbus/v5" ) // A private bus exercises the actual transport, without a controller or BlueZ. type testBlueZ struct { mu sync.Mutex conn *dbus.Conn objects objects calls []string attempts int resolutionDelay time.Duration readyAt time.Time disconnectDelay time.Duration disconnectAt time.Time connectDuringScan bool } const testDevice dbus.ObjectPath = "/org/bluez/hci0/dev_FF_FF_FF_FF_FF_FF" const testService dbus.ObjectPath = testDevice + "/service0001" const testWrite dbus.ObjectPath = testService + "/char0002" const testNotify dbus.ObjectPath = testService + "/char0003" func (b *testBlueZ) GetManagedObjects() (objects, *dbus.Error) { b.mu.Lock() defer b.mu.Unlock() if !b.disconnectAt.IsZero() && !time.Now().Before(b.disconnectAt) { b.objects[testDevice]["org.bluez.Device1"]["Connected"] = dbus.MakeVariant(false) b.disconnectAt = time.Time{} } if !b.readyAt.IsZero() && !time.Now().Before(b.readyAt) { b.objects[testDevice]["org.bluez.Device1"]["ServicesResolved"] = dbus.MakeVariant(true) } // Copy through the D-Bus encoder before unlocking is not guaranteed, so return // a fresh device map while other objects are immutable. out := objects{} for path, ifs := range b.objects { out[path] = map[string]map[string]dbus.Variant{} for iface, props := range ifs { p := map[string]dbus.Variant{} for k, v := range props { p[k] = v } out[path][iface] = p } } return out, nil } func (b *testBlueZ) SetDiscoveryFilter(filter map[string]dbus.Variant) *dbus.Error { if value(filter, "Transport") != "le" || !reflect.DeepEqual(filter["UUIDs"].Value(), []string{ServiceUUID}) || filter["DuplicateData"].Value() != true { return dbus.NewError("org.bluez.Error.InvalidArguments", []interface{}{"unexpected discovery filter"}) } return nil } func (b *testBlueZ) StartDiscovery() *dbus.Error { b.mu.Lock() b.calls = append(b.calls, "scan") if b.connectDuringScan { b.objects[testDevice]["org.bluez.Device1"]["Connected"] = dbus.MakeVariant(true) b.connectDuringScan = false } b.mu.Unlock() // Report unchanged data: a stale BlueZ entry must become eligible only here. _ = b.conn.Emit(testDevice, "org.freedesktop.DBus.Properties.PropertiesChanged", "org.bluez.Device1", map[string]dbus.Variant{"RSSI": dbus.MakeVariant(int16(-45))}, []string{}) return nil } func (b *testBlueZ) StopDiscovery() *dbus.Error { return nil } func (b *testBlueZ) Connect() *dbus.Error { b.mu.Lock() defer b.mu.Unlock() b.calls = append(b.calls, "connect") if connected, _ := b.objects[testDevice]["org.bluez.Device1"]["Connected"].Value().(bool); connected { return dbus.NewError("org.bluez.Error.Failed", []interface{}{"Connect called before previous link disconnected"}) } b.attempts++ if b.attempts == 1 { return dbus.NewError("org.bluez.Error.Failed", []interface{}{"le-connection-abort-by-local"}) } b.objects[testDevice]["org.bluez.Device1"]["Connected"] = dbus.MakeVariant(true) b.readyAt = time.Now().Add(b.resolutionDelay) b.objects[testDevice]["org.bluez.Device1"]["ServicesResolved"] = dbus.MakeVariant(false) return nil } func (b *testBlueZ) Disconnect() *dbus.Error { b.mu.Lock() defer b.mu.Unlock() b.calls = append(b.calls, "disconnect") if connected, _ := b.objects[testDevice]["org.bluez.Device1"]["Connected"].Value().(bool); connected && b.disconnectDelay > 0 { b.disconnectAt = time.Now().Add(b.disconnectDelay) } else { b.objects[testDevice]["org.bluez.Device1"]["Connected"] = dbus.MakeVariant(false) } return nil } func (b *testBlueZ) StartNotify() *dbus.Error { b.mu.Lock() b.calls = append(b.calls, "notify") b.mu.Unlock() // The lock may publish its initial token before StartNotify returns. _ = b.conn.Emit(testNotify, "org.freedesktop.DBus.Properties.PropertiesChanged", "org.bluez.GattCharacteristic1", map[string]dbus.Variant{"Value": dbus.MakeVariant([]byte{3, 8, 14, 1, 2, 3, 4})}, []string{}) return nil } func (b *testBlueZ) StopNotify() *dbus.Error { b.mu.Lock() defer b.mu.Unlock() b.calls = append(b.calls, "stop-notify") return nil } func (b *testBlueZ) WriteValue(data []byte, options map[string]dbus.Variant) *dbus.Error { if value(options, "type") != "command" { return dbus.NewError("org.bluez.Error.Failed", []interface{}{"expected write without response"}) } b.mu.Lock() defer b.mu.Unlock() b.calls = append(b.calls, "write") return nil } func TestBlueZWorkflow(t *testing.T) { t.Run("immediate services", func(t *testing.T) { testBlueZWorkflow(t, 0, "") }) t.Run("discovery exceeds old eight-second limit", func(t *testing.T) { testBlueZWorkflow(t, 8500*time.Millisecond, "") }) } func TestBlueZRecoversAlreadyConnectedLink(t *testing.T) { testBlueZWorkflow(t, 0, "existing") } func TestBlueZRecoversConnectionCompletedDuringScan(t *testing.T) { testBlueZWorkflow(t, 0, "during-scan") } func testBlueZWorkflow(t *testing.T, resolutionDelay time.Duration, mode string) { alreadyConnected := mode == "existing" daemon, e := exec.LookPath("dbus-daemon") if e != nil { t.Skip("dbus-daemon unavailable") } config := filepath.Join(t.TempDir(), "bus.conf") if e := os.WriteFile(config, []byte(`sessiontcp:host=127.0.0.1,port=0ANONYMOUS`), 0600); e != nil { t.Fatal(e) } cmd := exec.Command(daemon, "--config-file="+config, "--nofork", "--print-address=1") cmd.Stderr = os.Stderr stdout, e := cmd.StdoutPipe() if e != nil { t.Fatal(e) } if e = cmd.Start(); e != nil { t.Fatal(e) } t.Cleanup(func() { _ = cmd.Process.Kill(); _ = cmd.Wait() }) scanner := bufio.NewScanner(stdout) if !scanner.Scan() { t.Fatal("no private bus address") } address := scanner.Text() conn, e := dbus.Connect(address, dbus.WithAuth(dbus.AuthAnonymous())) if e != nil { t.Fatal(e) } defer conn.Close() if _, e = conn.RequestName("org.bluez", dbus.NameFlagDoNotQueue); e != nil { t.Fatal(e) } advertisement := append([]byte{21, 0, 1}, unhex("00112233445566778899aabbccddeeff")...) b := &testBlueZ{conn: conn, resolutionDelay: resolutionDelay, objects: objects{ testDevice: {"org.bluez.Device1": { "ManufacturerData": dbus.MakeVariant(map[uint16]dbus.Variant{0x055a: dbus.MakeVariant(advertisement)}), "Address": dbus.MakeVariant("FF:FF:FF:FF:FF:FF"), "Adapter": dbus.MakeVariant(dbus.ObjectPath("/org/bluez/hci0")), "Connected": dbus.MakeVariant(alreadyConnected), "ServicesResolved": dbus.MakeVariant(false), }}, testService: {"org.bluez.GattService1": {"UUID": dbus.MakeVariant(ServiceUUID), "Device": dbus.MakeVariant(testDevice)}}, testWrite: {"org.bluez.GattCharacteristic1": {"UUID": dbus.MakeVariant(WriteUUID), "Service": dbus.MakeVariant(testService), "Flags": dbus.MakeVariant([]string{"write", "write-without-response"})}}, testNotify: {"org.bluez.GattCharacteristic1": {"UUID": dbus.MakeVariant(NotifyUUID), "Service": dbus.MakeVariant(testService)}}, }} if mode != "" { b.disconnectDelay = 150 * time.Millisecond } b.connectDuringScan = mode == "during-scan" for path, iface := range map[dbus.ObjectPath]string{"/": "org.freedesktop.DBus.ObjectManager", "/org/bluez/hci0": "org.bluez.Adapter1", testDevice: "org.bluez.Device1", testWrite: "org.bluez.GattCharacteristic1", testNotify: "org.bluez.GattCharacteristic1"} { if e := conn.Export(b, path, iface); e != nil { t.Fatal(e) } } ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() link, actual, e := dialSetup(ctx, testTarget, func(ctx context.Context, target Target) (Link, Target, error) { c, err := dbus.Connect(address, dbus.WithAuth(dbus.AuthAnonymous())) if err != nil { return nil, Target{}, err } return dialBlueZConn(ctx, target, c) }) if e != nil { t.Fatal(e) } defer link.Close() if actual.Address != "FF:FF:FF:FF:FF:FF" { t.Fatal(actual) } if token, e := (&session{link: link}).initial(ctx); e != nil || !reflect.DeepEqual(token, []byte{1, 2, 3, 4}) { t.Fatalf("initial token %x: %v", token, e) } if e := link.Write(ctx, []byte{3, 2}); e != nil { t.Fatal(e) } link.Close() b.mu.Lock() defer b.mu.Unlock() want := []string{"scan", "connect", "disconnect", "scan", "connect", "notify", "write", "stop-notify", "disconnect"} if alreadyConnected { want = append([]string{"disconnect"}, want...) } if mode == "during-scan" { want = append([]string{"scan", "disconnect"}, want...) } if !reflect.DeepEqual(b.calls, want) { t.Fatalf("calls %v; want %v", b.calls, want) } } func TestSetupDoesNotRetryPermanentError(t *testing.T) { calls := 0 expected := errors.New("already connected") _, _, e := dialSetup(context.Background(), testTarget, func(context.Context, Target) (Link, Target, error) { calls++; return nil, Target{}, expected }) if calls != 1 || !errors.Is(e, expected) { t.Fatal(calls, e) } } func TestSetupRetryCancellation(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) calls := 0 _, _, e := dialSetup(ctx, testTarget, func(context.Context, Target) (Link, Target, error) { calls++ cancel() return nil, Target{}, &setupRetryError{context.Canceled} }) if calls != 1 || !errors.Is(e, context.Canceled) { t.Fatal(calls, e) } } func TestSetupRetriesAreBounded(t *testing.T) { calls := 0 expected := errors.New("connection failed") _, _, e := dialSetup(context.Background(), testTarget, func(context.Context, Target) (Link, Target, error) { calls++ return nil, Target{}, &setupRetryError{expected} }) if calls != 3 || !errors.Is(e, expected) { t.Fatal(calls, e) } }