penguin/homekey-go

homekey-go

drivers/sesame/bluez_test.go

raw ยท 7896 bytes

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
}

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()
	// 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")
	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")
	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.objects[testDevice]["org.bluez.Device1"]["ServicesResolved"] = dbus.MakeVariant(true)
	return nil
}
func (b *testBlueZ) Disconnect() *dbus.Error {
	b.mu.Lock()
	defer b.mu.Unlock()
	b.calls = append(b.calls, "disconnect")
	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) {
	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(`<busconfig><type>session</type><listen>tcp:host=127.0.0.1,port=0</listen><auth>ANONYMOUS</auth><allow_anonymous/><policy context="default"><allow send_destination="*"/><allow receive_sender="*"/><allow own="*"/></policy></busconfig>`), 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, 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(false), "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)}},
	}}
	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(), 5*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 !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)
	}
}