Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 97 additions & 0 deletions internal/device/device.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
102 changes: 102 additions & 0 deletions internal/device/device_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package device
import (
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"testing"
Expand All @@ -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},`+
Expand Down
1 change: 1 addition & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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: "<imei> <fleet_id>", Summary: "Pair a device into a fleet", Run: device.Pair},
{Name: "device rename", Arguments: "<imei> <new_name>", Summary: "Rename a device", Run: device.Rename},
{Name: "device unpair", Arguments: "<imei>", Summary: "Remove a device from its fleet", Run: device.Unpair},
{Name: "device start", Arguments: "<imei>", Summary: "Start the code on a device"},
Expand Down
2 changes: 1 addition & 1 deletion main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down