diff --git a/internal/device/device.go b/internal/device/device.go index 9d1e8cf..d8e604f 100644 --- a/internal/device/device.go +++ b/internal/device/device.go @@ -146,6 +146,103 @@ func List(invocation api.Invocation, arguments []string) error { return nil } +func Pair(invocation api.Invocation, arguments []string) error { + if len(arguments) != 2 { + return errors.New("device pair takes an IMEI and a fleet id") + } + + imei := arguments[0] + + if !validImei(imei) { + return errors.New("the IMEI is the 15-digit number printed on the device") + } + + fleetId, err := strconv.ParseInt(arguments[1], 10, 64) + + if err != nil || fleetId < 1 { + return errors.New("the fleet id is the number shown by fleet list") + } + + fleets, err := api.FetchFleets(invocation) + + if err != nil { + return err + } + + fleetName := "" + + for _, fleet := range fleets { + if fleet.Id == fleetId { + fleetName = fleet.Name + } + } + + if fleetName == "" { + return errors.New("no such fleet, fleet list shows yours") + } + + body, err := json.Marshal(map[string]int64{"fleet_id": fleetId}) + + if err != nil { + return err + } + + request, err := api.AuthenticatedRequest(invocation, http.MethodPost, "/devices/"+imei+"/pair", bytes.NewReader(body)) + + if err != nil { + return err + } + + request.Header.Set("Content-Type", "application/json") + + response, err := invocation.Client.Do(request) + + if err != nil { + return errors.New("the server could not be reached, check your internet access") + } + + defer response.Body.Close() + + if response.StatusCode != http.StatusAccepted { + return api.ServerError(response) + } + + fmt.Fprintln(invocation.Out, "Press the pairing button on the device.") + + // The server holds the pairing open for one minute. + deadline := time.Now().Add(75 * time.Second) + + for { + time.Sleep(2 * time.Second) + + devices, err := api.FetchDevices(invocation) + + if err != nil { + return err + } + + for _, device := range devices { + if device.Imei != imei || device.FleetId != fleetId { + continue + } + + label := imei + + if device.Name != nil && *device.Name != "" { + label = *device.Name + } + + fmt.Fprintf(invocation.Out, "Paired device %q into fleet %q.\n", api.Printable(label), api.Printable(fleetName)) + + return nil + } + + if time.Now().After(deadline) { + return errors.New("the pairing button was not pressed in time, run device pair again") + } + } +} + func Rename(invocation api.Invocation, arguments []string) error { if len(arguments) != 2 { return errors.New("device rename takes an IMEI and a new name, quoted if it has spaces") diff --git a/internal/device/device_test.go b/internal/device/device_test.go index d88b319..f98de35 100644 --- a/internal/device/device_test.go +++ b/internal/device/device_test.go @@ -3,6 +3,7 @@ package device import ( "encoding/json" "fmt" + "io" "net/http" "strings" "testing" @@ -12,6 +13,107 @@ import ( "github.com/siliconwitchery/superstack-cli/internal/api/apitest" ) +func TestDevicePair(t *testing.T) { + fleets := `[{"id":3,"name":"pilot","owner":true}]` + unpaired := `[{"imei":"111111111111111","name":null,"fleet_id":0,"last_seen_at":null}]` + paired := `[{"imei":"111111111111111","name":"roof","fleet_id":3,"last_seen_at":null}]` + + tests := []struct { + name string + arguments []string + pairStatus int + pairRefusal string + pollsToPair int + wantShown []string + wantError string + wantPairCalls int + }{ + { + name: "pairing completes when the button is pressed", + arguments: []string{"111111111111111", "3"}, + pairStatus: http.StatusAccepted, + pollsToPair: 2, + wantShown: []string{"Press the pairing button on the device.", `Paired device "roof" into fleet "pilot".`}, + wantPairCalls: 1, + }, + { + name: "a server refusal is shown", + arguments: []string{"111111111111111", "3"}, + pairStatus: http.StatusConflict, + pairRefusal: "the device is already paired, unpair it first", + wantError: "already paired", + wantPairCalls: 1, + }, + {name: "an unknown fleet is refused", arguments: []string{"111111111111111", "9"}, wantError: "no such fleet"}, + {name: "a malformed IMEI is refused", arguments: []string{"roof", "3"}, wantError: "printed on the device"}, + {name: "a wordy fleet id is refused", arguments: []string{"111111111111111", "pilot"}, wantError: "shown by fleet list"}, + {name: "missing arguments", arguments: []string{"111111111111111"}, wantError: "takes an IMEI and a fleet id"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + pairCalls := 0 + polls := 0 + + mux := http.NewServeMux() + + mux.HandleFunc("GET /fleets", func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, fleets) }) + + mux.HandleFunc("POST /devices/{imei}/pair", func(w http.ResponseWriter, r *http.Request) { + pairCalls++ + + body, err := io.ReadAll(r.Body) + + if err != nil || string(body) != `{"fleet_id":3}` { + t.Errorf("the pairing sent body %q", body) + } + + if test.pairRefusal != "" { + http.Error(w, test.pairRefusal, test.pairStatus) + return + } + + w.WriteHeader(test.pairStatus) + }) + + mux.HandleFunc("GET /devices", func(w http.ResponseWriter, r *http.Request) { + polls++ + + if polls >= test.pollsToPair { + fmt.Fprint(w, paired) + return + } + + fmt.Fprint(w, unpaired) + }) + + invocation, out := apitest.LoggedInInvocation(t, mux) + + err := Pair(invocation, test.arguments) + + printed := out.String() + + if test.wantError != "" { + if err == nil || !strings.Contains(err.Error(), test.wantError) { + t.Fatalf("error = %v", err) + } + } else if err != nil { + t.Fatal(err) + } + + for _, want := range test.wantShown { + if !strings.Contains(printed, want) { + t.Errorf("output %q omits %q", printed, want) + } + } + + if pairCalls != test.wantPairCalls { + t.Errorf("the pairing route was called %d times, want %d", pairCalls, test.wantPairCalls) + } + }) + } +} + func TestDeviceList(t *testing.T) { now := time.Now() devices := fmt.Sprintf(`[{"imei":"111111111111111","name":"roof","fleet_id":3,"last_seen_at":%q},`+ diff --git a/main.go b/main.go index 85632a9..b3085c4 100644 --- a/main.go +++ b/main.go @@ -37,6 +37,7 @@ var sections = []dispatch.Section{ Title: "Devices", Commands: []dispatch.Command{ {Name: "device list", Arguments: "[fleet_id] [--json]", Summary: "List devices and when they were last seen", Run: device.List}, + {Name: "device pair", Arguments: " ", Summary: "Pair a device into a fleet", Run: device.Pair}, {Name: "device rename", Arguments: " ", Summary: "Rename a device", Run: device.Rename}, {Name: "device unpair", Arguments: "", Summary: "Remove a device from its fleet", Run: device.Unpair}, {Name: "device start", Arguments: "", Summary: "Start the code on a device"}, diff --git a/main_test.go b/main_test.go index 096fb2e..42b986f 100644 --- a/main_test.go +++ b/main_test.go @@ -161,7 +161,7 @@ func TestNoPartImportsAnother(t *testing.T) { func TestTheTableWiresEveryCommandOffered(t *testing.T) { wired := []string{ "account balance", "account delete", "account topup", - "device list", "device rename", "device unpair", + "device list", "device pair", "device rename", "device unpair", "fleet create", "fleet delete", "fleet list", "fleet rename", "fleet transfer", "key create", "key list", "key revoke", "login", "logout",