diff --git a/README.md b/README.md index 7b499c8..11221af 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,8 @@ # Superstack CLI -`superstack` is the command line interface to Superstack: sign in, claim -devices, push Lua code, and stream logs from your fleet. It is a single static -binary talking to the Superstack server's JSON API. - - +`superstack` is the command line interface to Superstack: log in, claim +devices, upload Lua code, and stream logs from your fleet. It is a single +static binary for managing Superstack from a terminal. ## Install @@ -62,6 +60,23 @@ binary talking to the Superstack server's JSON API. ./superstack ``` +1. Run the fast tests while you work. The login tests wait real seconds for + the poll interval, and `-short` skips them: + + ```sh + CGO_ENABLED=0 go test -short ./... + ``` + +1. Run every check before opening a pull request: + + ```sh + gofmt -l . + go mod tidy + git status --porcelain -- go.mod go.sum + CGO_ENABLED=0 go vet ./... + CGO_ENABLED=0 go test ./... + ``` + ## Releasing 1. Create `dev` fresh from `main`: @@ -74,6 +89,9 @@ binary talking to the Superstack server's JSON API. 1. Change `version` in `main.go`, run the checks, commit, and push: ```sh + gofmt -l . + go mod tidy + git status --porcelain -- go.mod go.sum CGO_ENABLED=0 go vet ./... CGO_ENABLED=0 go test ./... git diff --check diff --git a/internal/account/account.go b/internal/account/account.go new file mode 100644 index 0000000..6b18a44 --- /dev/null +++ b/internal/account/account.go @@ -0,0 +1,204 @@ +package account + +import ( + "bufio" + "encoding/json" + "errors" + "fmt" + "io/fs" + "net/http" + "os" + "strconv" + "strings" + + "github.com/siliconwitchery/superstack-cli/internal/api" + "github.com/siliconwitchery/superstack-cli/internal/dispatch" +) + +func Balance(session api.Session, arguments []string) error { + positionals, jsonOutput := dispatch.TakeJsonFlag(arguments) + + if len(positionals) > 1 { + return errors.New("account balance takes at most one fleet id") + } + + chosenFleetId := int64(0) + + if len(positionals) == 1 { + parsed, err := strconv.ParseInt(positionals[0], 10, 64) + + if err != nil || parsed < 1 { + return errors.New("the fleet id is the number shown by fleet list") + } + + chosenFleetId = parsed + } + + fleets, err := api.FetchFleets(session) + + if err != nil { + return err + } + + fleetNames := map[int64]string{} + + for _, fleet := range fleets { + fleetNames[fleet.Id] = fleet.Name + } + + if chosenFleetId != 0 { + if _, found := fleetNames[chosenFleetId]; !found { + return errors.New("no such fleet") + } + } + + fetched, err := api.FetchBalances(session) + + if err != nil { + return err + } + + balances := []api.BalanceEntry{} + + for _, balance := range fetched { + if chosenFleetId == 0 || balance.Fleet == chosenFleetId { + balances = append(balances, balance) + } + } + + if jsonOutput { + return json.NewEncoder(session.Out).Encode(balances) + } + + if len(balances) == 0 { + if chosenFleetId == 0 { + fmt.Fprintln(session.Out, "No fleets yet. Create one with fleet create.") + } else { + fmt.Fprintln(session.Out, "No credit on that fleet yet.") + } + + return nil + } + + idWidth := len("ID") + nameWidth := len("NAME") + + for _, balance := range balances { + idWidth = max(idWidth, len(strconv.FormatInt(balance.Fleet, 10))) + nameWidth = max(nameWidth, len(fleetNames[balance.Fleet])) + } + + fmt.Fprintf(session.Out, "%-*s %-*s %s\n", idWidth, "ID", nameWidth, "NAME", "BALANCE") + + for _, balance := range balances { + formatted, _, _ := api.FormatBalance(balance) + + fmt.Fprintf(session.Out, "%-*d %-*s %s\n", idWidth, balance.Fleet, nameWidth, fleetNames[balance.Fleet], formatted) + } + + return nil +} + +func Topup(session api.Session, arguments []string) error { + if len(arguments) != 1 { + return errors.New("account topup takes a fleet id") + } + + fleetId, err := strconv.ParseInt(arguments[0], 10, 64) + + if err != nil || fleetId < 1 { + return errors.New("the fleet id is the number shown by fleet list") + } + + request, err := api.AuthenticatedRequest(session, http.MethodPost, + "/fleets/"+strconv.FormatInt(fleetId, 10)+"/topup", nil) + + if err != nil { + return err + } + + response, err := session.Client.Do(request) + + if err != nil { + return errors.New("the server could not be reached, check your connection") + } + + defer response.Body.Close() + + if response.StatusCode != http.StatusOK { + return api.ServerError(response) + } + + opened := struct { + Url string `json:"url"` + }{} + + err = json.NewDecoder(response.Body).Decode(&opened) + + if err != nil || opened.Url == "" { + return errors.New("could not open the top-up page, try again") + } + + fmt.Fprintf(session.Out, "Open this link to choose an amount and pay:\n\n %s\n\nThe credit appears on the balance once the top-up completes.\nPress enter to open the browser.\n", opened.Url) + + _, err = bufio.NewReader(session.In).ReadString('\n') + + if err != nil { + return nil + } + + session.OpenBrowser(opened.Url) + + return nil +} + +func Delete(session api.Session, arguments []string) error { + if len(arguments) != 0 { + return errors.New("account delete takes no arguments") + } + + fmt.Fprint(session.Out, "Delete your account, its logins, and your access to every fleet? This cannot be undone. [y/N] ") + + answer, _ := bufio.NewReader(session.In).ReadString('\n') + + answer = strings.ToLower(strings.TrimSpace(answer)) + + if answer != "y" && answer != "yes" { + fmt.Fprintln(session.Out, "Nothing deleted.") + return nil + } + + request, err := api.AuthenticatedRequest(session, http.MethodDelete, "/account", nil) + + if err != nil { + return err + } + + response, err := session.Client.Do(request) + + if err != nil { + return errors.New("the server could not be reached, check your connection") + } + + defer response.Body.Close() + + if response.StatusCode != http.StatusNoContent { + return api.ServerError(response) + } + + path, err := api.KeyPath() + + if err != nil { + return err + } + + err = os.Remove(path) + + if err != nil && !errors.Is(err, fs.ErrNotExist) { + return err + } + + fmt.Fprintln(session.Out, "Account deleted.") + + return nil +} diff --git a/internal/account/account_test.go b/internal/account/account_test.go new file mode 100644 index 0000000..eee7575 --- /dev/null +++ b/internal/account/account_test.go @@ -0,0 +1,376 @@ +package account + +import ( + "fmt" + "net/http" + "os" + "strings" + "testing" + + "github.com/siliconwitchery/superstack-cli/internal/api" + "github.com/siliconwitchery/superstack-cli/internal/api/apitest" +) + +func TestAccountBalance(t *testing.T) { + tests := []struct { + name string + arguments []string + fleets string + balances string + wantLines []string + wantAbsent []string + wantExact string + wantError string + }{ + { + name: "every fleet", + arguments: []string{}, + fleets: `[{"id":1,"name":"crew","owner":true},{"id":2,"name":"pilot","owner":false}]`, + balances: `[{"fleet":1,"balance":"15.000000","currency":"eur"},{"fleet":2,"balance":"0","currency":"eur"}]`, + wantLines: []string{"ID", "NAME", "BALANCE", "crew", "€15.00", "pilot", "€0.00"}, + }, + { + name: "one fleet", + arguments: []string{"2"}, + fleets: `[{"id":1,"name":"crew","owner":true},{"id":2,"name":"pilot","owner":false}]`, + balances: `[{"fleet":1,"balance":"15.000000","currency":"eur"},{"fleet":2,"balance":"0","currency":"eur"}]`, + wantLines: []string{"pilot", "€0.00"}, + wantAbsent: []string{"crew"}, + }, + { + name: "machine readable", + arguments: []string{"--json"}, + fleets: `[{"id":1,"name":"crew","owner":true}]`, + balances: `[{"fleet":1,"balance":"15.000000","currency":"eur"}]`, + wantExact: `[{"fleet":1,"balance":"15.000000","currency":"eur"}]` + "\n", + }, + { + name: "machine readable for one fleet", + arguments: []string{"2", "--json"}, + fleets: `[{"id":1,"name":"crew","owner":true},{"id":2,"name":"pilot","owner":false}]`, + balances: `[{"fleet":1,"balance":"15.000000","currency":"eur"},{"fleet":2,"balance":"0","currency":"eur"}]`, + wantExact: `[{"fleet":2,"balance":"0","currency":"eur"}]` + "\n", + }, + { + name: "machine readable with no fleets", + arguments: []string{"--json"}, + fleets: `[]`, + balances: `[]`, + wantExact: "[]\n", + }, + { + name: "no fleets", + arguments: []string{}, + fleets: `[]`, + balances: `[]`, + wantLines: []string{"No fleets yet"}, + }, + { + name: "a fleet without credit", + arguments: []string{"1"}, + fleets: `[{"id":1,"name":"crew","owner":true}]`, + balances: `[]`, + wantExact: "No credit on that fleet yet.\n", + }, + { + name: "an unknown fleet", + arguments: []string{"9"}, + fleets: `[{"id":1,"name":"crew","owner":true}]`, + balances: `[]`, + wantError: "no such fleet", + }, + { + name: "a wordy id", + arguments: []string{"crew"}, + wantError: "shown by fleet list", + }, + { + name: "too many arguments", + arguments: []string{"1", "2"}, + wantError: "at most one fleet id", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + mux := http.NewServeMux() + + mux.HandleFunc("GET /fleets", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, test.fleets) + }) + + mux.HandleFunc("GET /balance", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, test.balances) + }) + + session, out := apitest.LoggedInSession(t, mux) + + err := Balance(session, test.arguments) + + printed := out.String() + + if test.wantError != "" { + if err == nil || !strings.Contains(err.Error(), test.wantError) { + t.Fatalf("error = %v, want it to mention %q", err, test.wantError) + } + + return + } + + if err != nil { + t.Fatal(err) + } + + if test.wantExact != "" && printed != test.wantExact { + t.Errorf("the output is %q, want exactly %q", printed, test.wantExact) + } + + for _, want := range test.wantLines { + if !strings.Contains(printed, want) { + t.Errorf("the output %q does not show %q", printed, want) + } + } + + for _, absent := range test.wantAbsent { + if strings.Contains(printed, absent) { + t.Errorf("the output %q shows %q although it was filtered out", printed, absent) + } + } + }) + } +} + +func TestAccountTopup(t *testing.T) { + tests := []struct { + name string + arguments []string + stdin string + wantPath string + wantBrowser bool + emptyBody bool + refusal string + wantError string + }{ + { + name: "a top-up link opened on enter", + arguments: []string{"3"}, + stdin: "\n", + wantPath: "/fleets/3/topup", + wantBrowser: true, + }, + { + name: "a top-up link left alone", + arguments: []string{"3"}, + wantPath: "/fleets/3/topup", + }, + { + name: "no fleet id", + arguments: []string{}, + wantError: "takes a fleet id", + }, + { + name: "too many arguments", + arguments: []string{"3", "4"}, + wantError: "takes a fleet id", + }, + { + name: "a wordy id", + arguments: []string{"pilot"}, + wantError: "shown by fleet list", + }, + { + name: "response has no url", + arguments: []string{"3"}, + wantPath: "/fleets/3/topup", + emptyBody: true, + wantError: "could not open the top-up page, try again", + }, + { + name: "the server refuses", + arguments: []string{"9"}, + wantPath: "/fleets/9/topup", + refusal: "no such fleet", + wantError: "no such fleet", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + mux := http.NewServeMux() + + mux.HandleFunc("POST /fleets/{id}/topup", func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != test.wantPath { + t.Errorf("the request went to %s, want %s", r.URL.Path, test.wantPath) + } + + if test.refusal != "" { + http.Error(w, test.refusal, http.StatusNotFound) + return + } + + if test.emptyBody { + fmt.Fprint(w, `{}`) + return + } + + fmt.Fprint(w, `{"url":"https://checkout.stripe.com/c/pay/cs_test_1"}`) + }) + + session, out := apitest.LoggedInSession(t, mux) + session.In = strings.NewReader(test.stdin) + + browserOpens := make(chan string, 1) + session.OpenBrowser = func(url string) { browserOpens <- url } + + err := Topup(session, test.arguments) + + printed := out.String() + + if test.wantError != "" { + if err == nil || !strings.Contains(err.Error(), test.wantError) { + t.Fatalf("error = %v, want it to mention %q", err, test.wantError) + } + + return + } + + if err != nil { + t.Fatal(err) + } + + if !strings.Contains(printed, "https://checkout.stripe.com/c/pay/cs_test_1") { + t.Errorf("the output %q does not show the payment link", printed) + } + + if !strings.Contains(printed, "The credit appears on the balance once the top-up completes.") { + t.Errorf("the output %q does not explain when the top-up appears", printed) + } + + select { + case url := <-browserOpens: + if !test.wantBrowser { + t.Errorf("the browser opened %q although enter was never pressed", url) + } else if url != "https://checkout.stripe.com/c/pay/cs_test_1" { + t.Errorf("the browser opened %q, want the payment link", url) + } + + default: + if test.wantBrowser { + t.Error("the browser never opened") + } + } + }) + } +} + +func TestAccountDelete(t *testing.T) { + tests := []struct { + name string + arguments []string + answer string + refusal string + refusalCode int + wantDeleted bool + wantShown string + wantError string + }{ + { + name: "confirmed with y", + answer: "y\n", + wantDeleted: true, + wantShown: "Account deleted", + }, + { + name: "confirmed with yes", + answer: "YES\n", + wantDeleted: true, + wantShown: "Account deleted", + }, + { + name: "declined by default", + answer: "\n", + wantShown: "Nothing deleted", + }, + { + name: "declined with n", + answer: "n\n", + wantShown: "Nothing deleted", + }, + { + name: "closed input", + wantShown: "Nothing deleted", + }, + { + name: "the server refuses while a fleet is owned", + answer: "y\n", + refusal: "you still own fleets, hand each one over or delete it first", + refusalCode: http.StatusConflict, + wantDeleted: true, + wantError: "you still own fleets, hand each one over or delete it first", + }, + { + name: "arguments are refused", + arguments: []string{"everything"}, + wantError: "takes no arguments", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + deleted := false + + mux := http.NewServeMux() + + mux.HandleFunc("DELETE /account", func(w http.ResponseWriter, r *http.Request) { + deleted = true + + if test.refusal != "" { + http.Error(w, test.refusal, test.refusalCode) + return + } + + w.WriteHeader(http.StatusNoContent) + }) + + session, out := apitest.LoggedInSession(t, mux) + + path, err := api.KeyPath() + + if err != nil { + t.Fatal(err) + } + + session.In = strings.NewReader(test.answer) + + err = Delete(session, test.arguments) + + printed := out.String() + + if test.wantError != "" { + if err == nil || !strings.Contains(err.Error(), test.wantError) { + t.Fatalf("error = %v, want it to mention %q", err, test.wantError) + } + } else if err != nil { + t.Fatal(err) + } + + if deleted != test.wantDeleted { + t.Errorf("the server saw the account deleted = %v, want %v", deleted, test.wantDeleted) + } + + if test.wantShown != "" && !strings.Contains(printed, test.wantShown) { + t.Errorf("the output %q does not show %q", printed, test.wantShown) + } + + _, statErr := os.Stat(path) + + switch { + case test.wantDeleted && test.wantError == "" && statErr == nil: + t.Error("the login is still stored although the account was deleted") + + case (!test.wantDeleted || test.wantError != "") && statErr != nil: + t.Errorf("the login was removed although the account was not deleted: %v", statErr) + } + }) + } +} diff --git a/internal/api/api_test.go b/internal/api/api_test.go new file mode 100644 index 0000000..4ca247b --- /dev/null +++ b/internal/api/api_test.go @@ -0,0 +1,283 @@ +package api_test + +import ( + "bytes" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/siliconwitchery/superstack-cli/internal/api" + "github.com/siliconwitchery/superstack-cli/internal/api/apitest" +) + +func TestKeyPathStaysOutOfPublishedDotfiles(t *testing.T) { + temporary := apitest.IsolateKeyStorage(t) + + if runtime.GOOS != "linux" { + path, err := api.KeyPath() + + if err != nil { + t.Fatal(err) + } + + if !strings.HasPrefix(path, temporary) { + t.Fatalf("api.KeyPath() = %q, want it under the isolated home", path) + } + + return + } + + tests := []struct { + name string + stateHome string + wantPath string + }{ + {name: "absolute state home", stateHome: temporary, wantPath: filepath.Join(temporary, "superstack", "key")}, + {name: "empty state home", stateHome: "", wantPath: filepath.Join(temporary, ".local", "state", "superstack", "key")}, + {name: "relative state home", stateHome: ".state", wantPath: filepath.Join(temporary, ".local", "state", "superstack", "key")}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Setenv("XDG_STATE_HOME", test.stateHome) + + path, err := api.KeyPath() + + if err != nil { + t.Fatal(err) + } + + if path != test.wantPath { + t.Errorf("api.KeyPath() = %q, want %q", path, test.wantPath) + } + + if strings.Contains(path, ".config") { + t.Errorf("api.KeyPath() = %q, must never sit in ~/.config", path) + } + }) + } +} + +func TestApiRequestBase(t *testing.T) { + tests := []struct { + name string + chosenBase string + wantUrl string + }{ + { + name: "the default", + wantUrl: api.DefaultBase + "/login", + }, + { + name: "the flag overrides the default", + chosenBase: "http://localhost:8888", + wantUrl: "http://localhost:8888/login", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + base := test.chosenBase + + if base == "" { + base = api.DefaultBase + } + + session := api.NewSession(base, "1.2.3", strings.NewReader(""), &bytes.Buffer{}) + + request, err := api.Request(session, http.MethodGet, "/login", nil) + + if err != nil { + t.Fatal(err) + } + + if request.URL.String() != test.wantUrl { + t.Errorf("url = %q, want %q", request.URL.String(), test.wantUrl) + } + + // The server's version gate parses this exact User-Agent shape. + if request.Header.Get("User-Agent") != "superstack/1.2.3" { + t.Errorf("User-Agent = %q, want superstack/1.2.3", request.Header.Get("User-Agent")) + } + }) + } +} + +func TestCheckServer(t *testing.T) { + reachable := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + t.Cleanup(reachable.Close) + + unreachable := httptest.NewServer(http.NotFoundHandler()) + unreachable.Close() + tests := []struct { + name string + base string + wantError string + }{ + {name: "reachable", base: reachable.URL}, + {name: "unreachable", base: unreachable.URL, wantError: "the server could not be reached, check your connection"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + session := api.NewSession(test.base, "test", strings.NewReader(""), &bytes.Buffer{}) + + err := api.CheckServer(session) + + if test.wantError != "" { + if err == nil || err.Error() != test.wantError { + t.Fatalf("error = %v, want %q", err, test.wantError) + } + } else if err != nil { + t.Fatal(err) + } + }) + } +} + +func TestFetchFleetsFailures(t *testing.T) { + tests := []struct { + name string + loggedIn bool + storedKey string + status int + body string + wantError string + }{ + {name: "not logged in", wantError: "not logged in"}, + {name: "empty key file", loggedIn: true, storedKey: " \n", wantError: "not logged in"}, + {name: "server refusal", loggedIn: true, status: http.StatusServiceUnavailable, body: "fleets unavailable", wantError: "fleets unavailable"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + apitest.IsolateKeyStorage(t) + + session := api.Session{} + + if test.loggedIn { + mux := http.NewServeMux() + mux.HandleFunc("GET /fleets", func(w http.ResponseWriter, r *http.Request) { + if test.status != 0 { + w.WriteHeader(test.status) + } + + fmt.Fprint(w, test.body) + }) + + session, _ = apitest.LoggedInSession(t, mux) + + if test.storedKey != "" { + path, err := api.KeyPath() + + if err != nil { + t.Fatal(err) + } + + err = os.WriteFile(path, []byte(test.storedKey), 0o600) + + if err != nil { + t.Fatal(err) + } + } + } + + _, err := api.FetchFleets(session) + + if err == nil || !strings.Contains(err.Error(), test.wantError) { + t.Fatalf("error = %v, want it to mention %q", err, test.wantError) + } + }) + } +} + +func TestFetchKeysFailures(t *testing.T) { + tests := []struct { + name string + status int + body string + wantError string + }{ + {name: "server refusal", status: http.StatusServiceUnavailable, body: "keys unavailable", wantError: "keys unavailable"}, + {name: "undecodable body", status: http.StatusOK, body: `{`, wantError: "unexpected EOF"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("GET /keys", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(test.status) + fmt.Fprint(w, test.body) + }) + + session, _ := apitest.LoggedInSession(t, mux) + + _, err := api.FetchKeys(session) + + if err == nil || !strings.Contains(err.Error(), test.wantError) { + t.Fatalf("error = %v, want it to mention %q", err, test.wantError) + } + }) + } +} + +func TestFetchBalancesFailures(t *testing.T) { + tests := []struct { + name string + status int + body string + wantError string + }{ + {name: "server refusal", status: http.StatusServiceUnavailable, body: "balances unavailable", wantError: "balances unavailable"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("GET /balance", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(test.status) + fmt.Fprint(w, test.body) + }) + + session, _ := apitest.LoggedInSession(t, mux) + + _, err := api.FetchBalances(session) + + if err == nil || !strings.Contains(err.Error(), test.wantError) { + t.Fatalf("error = %v, want it to mention %q", err, test.wantError) + } + }) + } +} + +func TestFormatBalance(t *testing.T) { + tests := []struct { + balance string + want string + wantValue float64 + wantValid bool + }{ + {balance: "15.000000", want: "\u20ac15.00", wantValue: 15, wantValid: true}, + {balance: "0", want: "\u20ac0.00", wantValue: 0, wantValid: true}, + {balance: "0.004", want: "\u20ac0.00", wantValue: 0.004, wantValid: true}, + {balance: "-2.50", want: "\u20ac-2.50", wantValue: -2.5, wantValid: true}, + {balance: "", want: "", wantValue: 0, wantValid: false}, + {balance: "not a number", want: "not a number", wantValue: 0, wantValid: false}, + } + + for _, test := range tests { + t.Run(test.balance, func(t *testing.T) { + formatted, value, valid := api.FormatBalance(api.BalanceEntry{Balance: test.balance}) + + if formatted != test.want || value != test.wantValue || valid != test.wantValid { + t.Errorf("api.FormatBalance() = %q, %v, %v, want %q, %v, %v", + formatted, value, valid, test.want, test.wantValue, test.wantValid) + } + }) + } +} diff --git a/internal/api/apitest/apitest.go b/internal/api/apitest/apitest.go new file mode 100644 index 0000000..b2cc7ee --- /dev/null +++ b/internal/api/apitest/apitest.go @@ -0,0 +1,68 @@ +package apitest + +import ( + "bytes" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/siliconwitchery/superstack-cli/internal/api" +) + +func IsolateKeyStorage(t *testing.T) string { + t.Helper() + + temporary := t.TempDir() + + t.Setenv("HOME", temporary) + t.Setenv("XDG_STATE_HOME", temporary) + t.Setenv("AppData", temporary) + + return temporary +} + +func LoggedInSession(t *testing.T, handler http.Handler) (api.Session, *bytes.Buffer) { + t.Helper() + + IsolateKeyStorage(t) + + path, err := api.KeyPath() + + if err != nil { + t.Fatal(err) + } + + err = os.MkdirAll(filepath.Dir(path), 0o700) + + if err != nil { + t.Fatal(err) + } + + err = os.WriteFile(path, []byte("ssk_test\n"), 0o600) + + if err != nil { + t.Fatal(err) + } + + authorized := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer ssk_test" { + t.Errorf("%s %s carried authorization %q, want the stored key", + r.Method, r.URL.Path, r.Header.Get("Authorization")) + } + + handler.ServeHTTP(w, r) + }) + + server := httptest.NewServer(authorized) + + t.Cleanup(server.Close) + + out := &bytes.Buffer{} + session := api.NewSession(server.URL, "test", strings.NewReader(""), out) + session.OpenBrowser = func(url string) {} + + return session, out +} diff --git a/internal/api/balances.go b/internal/api/balances.go new file mode 100644 index 0000000..5f6bcb4 --- /dev/null +++ b/internal/api/balances.go @@ -0,0 +1,55 @@ +package api + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "strconv" +) + +type BalanceEntry struct { + Fleet int64 `json:"fleet"` + Balance string `json:"balance"` + Currency string `json:"currency"` +} + +func FetchBalances(session Session) ([]BalanceEntry, error) { + request, err := AuthenticatedRequest(session, http.MethodGet, "/balance", nil) + + if err != nil { + return nil, err + } + + response, err := session.Client.Do(request) + + if err != nil { + return nil, errors.New("the server could not be reached, check your connection") + } + + defer response.Body.Close() + + if response.StatusCode != http.StatusOK { + return nil, ServerError(response) + } + + balances := []BalanceEntry{} + + err = json.NewDecoder(response.Body).Decode(&balances) + + if err != nil { + return nil, err + } + + return balances, nil +} + +func FormatBalance(entry BalanceEntry) (string, float64, bool) { + value, err := strconv.ParseFloat(entry.Balance, 64) + + if err != nil { + return entry.Balance, 0, false + } + + return fmt.Sprintf("€%.2f", value), value, true +} diff --git a/internal/api/client.go b/internal/api/client.go new file mode 100644 index 0000000..50f933b --- /dev/null +++ b/internal/api/client.go @@ -0,0 +1,115 @@ +package api + +import ( + "errors" + "io" + "io/fs" + "net/http" + "os" + "path/filepath" + "runtime" + "strings" +) + +func CheckServer(session Session) error { + request, err := Request(session, http.MethodGet, "/", nil) + + if err != nil { + return err + } + + response, err := session.Client.Do(request) + + if err != nil { + return errors.New("the server could not be reached, check your connection") + } + + response.Body.Close() + + return nil +} + +func Request(session Session, method string, path string, body io.Reader) (*http.Request, error) { + request, err := http.NewRequest(method, strings.TrimSuffix(session.Base, "/")+path, body) + + if err != nil { + return nil, err + } + + request.Header.Set("User-Agent", "superstack/"+session.Version) + + return request, nil +} + +func AuthenticatedRequest(session Session, method string, path string, body io.Reader) (*http.Request, error) { + storedKeyPath, err := KeyPath() + + if err != nil { + return nil, err + } + + keyBytes, err := os.ReadFile(storedKeyPath) + + if errors.Is(err, fs.ErrNotExist) { + return nil, errors.New("you are not logged in, run login first") + } + + if err != nil { + return nil, err + } + + key := strings.TrimSpace(string(keyBytes)) + + if key == "" { + return nil, errors.New("you are not logged in, run login first") + } + + request, err := Request(session, method, path, body) + + if err != nil { + return nil, err + } + + request.Header.Set("Authorization", "Bearer "+key) + + return request, nil +} + +func ServerError(response *http.Response) error { + message, err := io.ReadAll(io.LimitReader(response.Body, 4096)) + + detail := strings.TrimSpace(string(message)) + + if err != nil || detail == "" { + return errors.New("that did not go through, try again in a moment") + } + + return errors.New(detail) +} + +func KeyPath() (string, error) { + if runtime.GOOS == "linux" { + stateHome := os.Getenv("XDG_STATE_HOME") + + // The xdg base directory spec says to ignore a relative XDG_STATE_HOME. + if !filepath.IsAbs(stateHome) { + home, err := os.UserHomeDir() + + if err != nil { + return "", err + } + + stateHome = filepath.Join(home, ".local", "state") + } + + return filepath.Join(stateHome, "superstack", "key"), nil + } + + configDirectory, err := os.UserConfigDir() + + if err != nil { + return "", err + } + + return filepath.Join(configDirectory, "superstack", "key"), nil +} diff --git a/internal/api/devices.go b/internal/api/devices.go new file mode 100644 index 0000000..60ebd62 --- /dev/null +++ b/internal/api/devices.go @@ -0,0 +1,61 @@ +package api + +import ( + "encoding/json" + "errors" + "net/http" +) + +type DeviceEntry struct { + Imei string `json:"imei"` + Name *string `json:"name"` + FleetId int64 `json:"fleet_id"` + LastSeenAt *string `json:"last_seen_at"` + ReportedState *int `json:"reported_state"` + StorageUsed *int64 `json:"storage_used"` + StorageTotal *int64 `json:"storage_total"` +} + +func FetchDevices(session Session) ([]DeviceEntry, error) { + request, err := AuthenticatedRequest(session, http.MethodGet, "/devices", nil) + + if err != nil { + return nil, err + } + + response, err := session.Client.Do(request) + + if err != nil { + return nil, errors.New("the server could not be reached, check your connection") + } + + defer response.Body.Close() + + if response.StatusCode != http.StatusOK { + return nil, ServerError(response) + } + + devices := []DeviceEntry{} + + err = json.NewDecoder(response.Body).Decode(&devices) + + if err != nil { + return nil, err + } + + return devices, nil +} + +func ValidImei(imei string) bool { + if len(imei) != 15 { + return false + } + + for _, digit := range imei { + if digit < '0' || digit > '9' { + return false + } + } + + return true +} diff --git a/internal/api/fleets.go b/internal/api/fleets.go new file mode 100644 index 0000000..06e05b6 --- /dev/null +++ b/internal/api/fleets.go @@ -0,0 +1,43 @@ +package api + +import ( + "encoding/json" + "errors" + "net/http" +) + +type FleetEntry struct { + Id int64 `json:"id"` + Name string `json:"name"` + Owner bool `json:"owner"` +} + +func FetchFleets(session Session) ([]FleetEntry, error) { + request, err := AuthenticatedRequest(session, http.MethodGet, "/fleets", nil) + + if err != nil { + return nil, err + } + + response, err := session.Client.Do(request) + + if err != nil { + return nil, errors.New("the server could not be reached, check your connection") + } + + defer response.Body.Close() + + if response.StatusCode != http.StatusOK { + return nil, ServerError(response) + } + + fleets := []FleetEntry{} + + err = json.NewDecoder(response.Body).Decode(&fleets) + + if err != nil { + return nil, err + } + + return fleets, nil +} diff --git a/internal/api/keys.go b/internal/api/keys.go new file mode 100644 index 0000000..a84c9c9 --- /dev/null +++ b/internal/api/keys.go @@ -0,0 +1,44 @@ +package api + +import ( + "encoding/json" + "errors" + "net/http" +) + +type KeyEntry struct { + Id int64 `json:"id"` + Fleet int64 `json:"fleet"` + Label string `json:"label"` + Suffix string `json:"suffix"` +} + +func FetchKeys(session Session) ([]KeyEntry, error) { + request, err := AuthenticatedRequest(session, http.MethodGet, "/keys", nil) + + if err != nil { + return nil, err + } + + response, err := session.Client.Do(request) + + if err != nil { + return nil, errors.New("the server could not be reached, check your connection") + } + + defer response.Body.Close() + + if response.StatusCode != http.StatusOK { + return nil, ServerError(response) + } + + keys := []KeyEntry{} + + err = json.NewDecoder(response.Body).Decode(&keys) + + if err != nil { + return nil, err + } + + return keys, nil +} diff --git a/internal/api/session.go b/internal/api/session.go new file mode 100644 index 0000000..c58fce0 --- /dev/null +++ b/internal/api/session.go @@ -0,0 +1,54 @@ +package api + +import ( + "io" + "net/http" + "os/exec" + "runtime" + "time" +) + +const DefaultBase = "https://supernext.siliconwitchery.com" +const defaultGithubBase = "https://github.com" +const defaultGitlabBase = "https://gitlab.com" + +type Session struct { + Base string + GithubBase string + GitlabBase string + Version string + Client *http.Client + In io.Reader + Out io.Writer + OpenBrowser func(url string) +} + +func NewSession(base string, version string, in io.Reader, out io.Writer) Session { + return Session{ + Base: base, + GithubBase: defaultGithubBase, + GitlabBase: defaultGitlabBase, + Version: version, + Client: &http.Client{Timeout: 30 * time.Second}, + In: in, + Out: out, + OpenBrowser: openBrowser, + } +} + +func openBrowser(url string) { + var command *exec.Cmd + + switch runtime.GOOS { + case "darwin": + command = exec.Command("open", url) + + case "windows": + command = exec.Command("rundll32", "url.dll,FileProtocolHandler", url) + + default: + command = exec.Command("xdg-open", url) + } + + _ = command.Start() +} diff --git a/internal/commands/account_balance.go b/internal/commands/account_balance.go deleted file mode 100644 index 643d184..0000000 --- a/internal/commands/account_balance.go +++ /dev/null @@ -1,98 +0,0 @@ -package commands - -import ( - "encoding/json" - "errors" - "fmt" - "os" - "strconv" -) - -func AccountBalance(arguments []string) error { - - jsonOutput := false - - positionals := []string{} - - for _, argument := range arguments { - if argument == "--json" { - jsonOutput = true - continue - } - - positionals = append(positionals, argument) - } - - if len(positionals) > 1 { - return errors.New("account balance takes at most one fleet id") - } - - chosenFleetId := int64(0) - - if len(positionals) == 1 { - parsed, err := strconv.ParseInt(positionals[0], 10, 64) - - if err != nil || parsed < 1 { - return errors.New("the fleet id is the number shown by fleet list") - } - - chosenFleetId = parsed - } - - fleets, err := fetchFleets() - - if err != nil { - return err - } - - fleetNames := map[int64]string{} - - for _, fleet := range fleets { - fleetNames[fleet.Id] = fleet.Name - } - - if chosenFleetId != 0 { - if _, found := fleetNames[chosenFleetId]; !found { - return errors.New("no such fleet") - } - } - - fetched, err := fetchBalances() - - if err != nil { - return err - } - - balances := []balanceEntry{} - - for _, balance := range fetched { - if chosenFleetId == 0 || balance.Fleet == chosenFleetId { - balances = append(balances, balance) - } - } - - if jsonOutput { - return json.NewEncoder(os.Stdout).Encode(balances) - } - - if len(balances) == 0 { - fmt.Println("No fleets yet. Create one with fleet create.") - return nil - } - - idWidth := len("ID") - nameWidth := len("NAME") - - for _, balance := range balances { - idWidth = max(idWidth, len(strconv.FormatInt(balance.Fleet, 10))) - nameWidth = max(nameWidth, len(fleetNames[balance.Fleet])) - } - - fmt.Printf("%-*s %-*s %s\n", idWidth, "ID", nameWidth, "NAME", "BALANCE") - - for _, balance := range balances { - fmt.Printf("%-*d %-*s %s\n", idWidth, balance.Fleet, nameWidth, fleetNames[balance.Fleet], formatBalance(balance)) - } - - return nil -} diff --git a/internal/commands/account_balance_test.go b/internal/commands/account_balance_test.go deleted file mode 100644 index 8686d0b..0000000 --- a/internal/commands/account_balance_test.go +++ /dev/null @@ -1,130 +0,0 @@ -package commands - -import ( - "fmt" - "net/http" - "strings" - "testing" -) - -func TestAccountBalance(t *testing.T) { - tests := []struct { - name string - arguments []string - fleets string - balances string - wantLines []string - wantAbsent []string - wantExact string - wantError string - }{ - { - name: "every fleet", - arguments: []string{}, - fleets: `[{"id":1,"name":"crew","owner":true},{"id":2,"name":"pilot","owner":false}]`, - balances: `[{"fleet":1,"balance":"15.000000","currency":"eur"},{"fleet":2,"balance":"0","currency":"eur"}]`, - wantLines: []string{"ID", "NAME", "BALANCE", "crew", "€15.00", "pilot", "€0.00"}, - }, - { - name: "one fleet", - arguments: []string{"2"}, - fleets: `[{"id":1,"name":"crew","owner":true},{"id":2,"name":"pilot","owner":false}]`, - balances: `[{"fleet":1,"balance":"15.000000","currency":"eur"},{"fleet":2,"balance":"0","currency":"eur"}]`, - wantLines: []string{"pilot", "€0.00"}, - wantAbsent: []string{"crew"}, - }, - { - name: "machine readable", - arguments: []string{"--json"}, - fleets: `[{"id":1,"name":"crew","owner":true}]`, - balances: `[{"fleet":1,"balance":"15.000000","currency":"eur"}]`, - wantExact: `[{"fleet":1,"balance":"15.000000","currency":"eur"}]` + "\n", - }, - { - name: "machine readable for one fleet", - arguments: []string{"2", "--json"}, - fleets: `[{"id":1,"name":"crew","owner":true},{"id":2,"name":"pilot","owner":false}]`, - balances: `[{"fleet":1,"balance":"15.000000","currency":"eur"},{"fleet":2,"balance":"0","currency":"eur"}]`, - wantExact: `[{"fleet":2,"balance":"0","currency":"eur"}]` + "\n", - }, - { - name: "machine readable with no fleets", - arguments: []string{"--json"}, - fleets: `[]`, - balances: `[]`, - wantExact: "[]\n", - }, - { - name: "no fleets", - arguments: []string{}, - fleets: `[]`, - balances: `[]`, - wantLines: []string{"No fleets yet"}, - }, - { - name: "an unknown fleet", - arguments: []string{"9"}, - fleets: `[{"id":1,"name":"crew","owner":true}]`, - balances: `[]`, - wantError: "no such fleet", - }, - { - name: "a wordy id", - arguments: []string{"crew"}, - wantError: "shown by fleet list", - }, - { - name: "too many arguments", - arguments: []string{"1", "2"}, - wantError: "at most one fleet id", - }, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - mux := http.NewServeMux() - - mux.HandleFunc("GET /fleets", func(w http.ResponseWriter, r *http.Request) { - fmt.Fprint(w, test.fleets) - }) - - mux.HandleFunc("GET /balance", func(w http.ResponseWriter, r *http.Request) { - fmt.Fprint(w, test.balances) - }) - - loggedInTestServer(t, mux) - - printed, err := captureStdout(t, func() error { - return AccountBalance(test.arguments) - }) - - if test.wantError != "" { - if err == nil || !strings.Contains(err.Error(), test.wantError) { - t.Fatalf("error = %v, want it to mention %q", err, test.wantError) - } - - return - } - - if err != nil { - t.Fatal(err) - } - - if test.wantExact != "" && printed != test.wantExact { - t.Errorf("the output is %q, want exactly %q", printed, test.wantExact) - } - - for _, want := range test.wantLines { - if !strings.Contains(printed, want) { - t.Errorf("the output %q does not show %q", printed, want) - } - } - - for _, absent := range test.wantAbsent { - if strings.Contains(printed, absent) { - t.Errorf("the output %q shows %q although it was filtered out", printed, absent) - } - } - }) - } -} diff --git a/internal/commands/account_delete.go b/internal/commands/account_delete.go deleted file mode 100644 index fba83bf..0000000 --- a/internal/commands/account_delete.go +++ /dev/null @@ -1,68 +0,0 @@ -package commands - -import ( - "bufio" - "errors" - "fmt" - "io" - "io/fs" - "net/http" - "os" - "strings" -) - -func AccountDelete(arguments []string) error { - - if len(arguments) != 0 { - return errors.New("account delete takes no arguments") - } - - fmt.Print("Delete your account, its logins, and your access to every fleet? This cannot be undone. [y/N] ") - - answer, _ := bufio.NewReader(os.Stdin).ReadString('\n') - - answer = strings.ToLower(strings.TrimSpace(answer)) - - if answer != "y" && answer != "yes" { - fmt.Println("Nothing deleted.") - return nil - } - - request, err := authenticatedRequest(http.MethodDelete, "/account", nil) - - if err != nil { - return err - } - - response, err := apiClient.Do(request) - - if err != nil { - return fmt.Errorf("the server could not be reached: %w", err) - } - - defer response.Body.Close() - - if response.StatusCode != http.StatusNoContent { - message, _ := io.ReadAll(io.LimitReader(response.Body, 4096)) - - return fmt.Errorf("the server said: %s", strings.TrimSpace(string(message))) - } - - // The stored login died with the account, so it goes whether or not the - // file is still there - path, err := keyPath() - - if err != nil { - return err - } - - err = os.Remove(path) - - if err != nil && !errors.Is(err, fs.ErrNotExist) { - return err - } - - fmt.Println("Account deleted.") - - return nil -} diff --git a/internal/commands/account_delete_test.go b/internal/commands/account_delete_test.go deleted file mode 100644 index 734dad5..0000000 --- a/internal/commands/account_delete_test.go +++ /dev/null @@ -1,122 +0,0 @@ -package commands - -import ( - "net/http" - "os" - "strings" - "testing" -) - -func TestAccountDelete(t *testing.T) { - tests := []struct { - name string - arguments []string - answer string - refusal string - refusalCode int - wantDeleted bool - wantShown string - wantError string - }{ - { - name: "confirmed with y", - answer: "y\n", - wantDeleted: true, - wantShown: "Account deleted", - }, - { - name: "confirmed with yes", - answer: "YES\n", - wantDeleted: true, - wantShown: "Account deleted", - }, - { - name: "declined by default", - answer: "\n", - wantShown: "Nothing deleted", - }, - { - name: "declined with n", - answer: "n\n", - wantShown: "Nothing deleted", - }, - { - name: "closed input", - wantShown: "Nothing deleted", - }, - { - name: "the server refuses while a fleet is owned", - answer: "y\n", - refusal: "you still own fleets, hand each one over or delete it first", - refusalCode: http.StatusConflict, - wantDeleted: true, - wantError: "you still own fleets, hand each one over or delete it first", - }, - { - name: "arguments are refused", - arguments: []string{"everything"}, - wantError: "takes no arguments", - }, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - deleted := false - - mux := http.NewServeMux() - - mux.HandleFunc("DELETE /account", func(w http.ResponseWriter, r *http.Request) { - deleted = true - - if test.refusal != "" { - http.Error(w, test.refusal, test.refusalCode) - return - } - - w.WriteHeader(http.StatusNoContent) - }) - - loggedInTestServer(t, mux) - - path, err := keyPath() - - if err != nil { - t.Fatal(err) - } - - answerOnStdin(t, test.answer) - - printed, err := captureStdout(t, func() error { - return AccountDelete(test.arguments) - }) - - if test.wantError != "" { - if err == nil || !strings.Contains(err.Error(), test.wantError) { - t.Fatalf("error = %v, want it to mention %q", err, test.wantError) - } - } else if err != nil { - t.Fatal(err) - } - - if deleted != test.wantDeleted { - t.Errorf("the server saw the account deleted = %v, want %v", deleted, test.wantDeleted) - } - - if test.wantShown != "" && !strings.Contains(printed, test.wantShown) { - t.Errorf("the output %q does not show %q", printed, test.wantShown) - } - - // The stored login is worthless once the account is gone, and must - // survive anything short of a completed delete - _, statErr := os.Stat(path) - - switch { - case test.wantDeleted && test.wantError == "" && statErr == nil: - t.Error("the login is still stored although the account was deleted") - - case (!test.wantDeleted || test.wantError != "") && statErr != nil: - t.Errorf("the login was removed although the account was not deleted: %v", statErr) - } - }) - } -} diff --git a/internal/commands/account_topup.go b/internal/commands/account_topup.go deleted file mode 100644 index ad79c97..0000000 --- a/internal/commands/account_topup.go +++ /dev/null @@ -1,69 +0,0 @@ -package commands - -import ( - "bufio" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "os" - "strconv" - "strings" -) - -func AccountTopup(arguments []string) error { - - if len(arguments) != 1 { - return errors.New("account topup takes a fleet id") - } - - fleetId, err := strconv.ParseInt(arguments[0], 10, 64) - - if err != nil || fleetId < 1 { - return errors.New("the fleet id is the number shown by fleet list") - } - - request, err := authenticatedRequest(http.MethodPost, - "/fleets/"+strconv.FormatInt(fleetId, 10)+"/topup", nil) - - if err != nil { - return err - } - - response, err := apiClient.Do(request) - - if err != nil { - return fmt.Errorf("the server could not be reached: %w", err) - } - - defer response.Body.Close() - - if response.StatusCode != http.StatusOK { - message, _ := io.ReadAll(io.LimitReader(response.Body, 4096)) - - return fmt.Errorf("the server said: %s", strings.TrimSpace(string(message))) - } - - opened := struct { - Url string `json:"url"` - }{} - - err = json.NewDecoder(response.Body).Decode(&opened) - - if err != nil || opened.Url == "" { - return errors.New("the payment page could not be opened, try again") - } - - fmt.Printf("Open this link to choose an amount and pay:\n\n %s\n\nThe credit appears on the balance once the payment completes.\nPress enter to open the browser.\n", opened.Url) - - _, err = bufio.NewReader(os.Stdin).ReadString('\n') - - if err != nil { - return nil - } - - openBrowser(opened.Url) - - return nil -} diff --git a/internal/commands/account_topup_test.go b/internal/commands/account_topup_test.go deleted file mode 100644 index cb028a1..0000000 --- a/internal/commands/account_topup_test.go +++ /dev/null @@ -1,114 +0,0 @@ -package commands - -import ( - "fmt" - "net/http" - "strings" - "testing" -) - -func TestAccountTopup(t *testing.T) { - tests := []struct { - name string - arguments []string - stdin string - wantPath string - wantBrowser bool - refusal string - wantError string - }{ - { - name: "a top-up link opened on enter", - arguments: []string{"3"}, - stdin: "\n", - wantPath: "/fleets/3/topup", - wantBrowser: true, - }, - { - name: "a top-up link left alone", - arguments: []string{"3"}, - wantPath: "/fleets/3/topup", - }, - { - name: "no fleet id", - arguments: []string{}, - wantError: "takes a fleet id", - }, - { - name: "too many arguments", - arguments: []string{"3", "4"}, - wantError: "takes a fleet id", - }, - { - name: "a wordy id", - arguments: []string{"pilot"}, - wantError: "shown by fleet list", - }, - { - name: "the server refuses", - arguments: []string{"9"}, - wantPath: "/fleets/9/topup", - refusal: "no such fleet", - wantError: "the server said: no such fleet", - }, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - mux := http.NewServeMux() - - mux.HandleFunc("POST /fleets/{id}/topup", func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != test.wantPath { - t.Errorf("the request went to %s, want %s", r.URL.Path, test.wantPath) - } - - if test.refusal != "" { - http.Error(w, test.refusal, http.StatusNotFound) - return - } - - fmt.Fprint(w, `{"url":"https://checkout.stripe.com/c/pay/cs_test_1"}`) - }) - - loggedInTestServer(t, mux) - - answerOnStdin(t, test.stdin) - - browserOpens := captureBrowserOpens(t) - - printed, err := captureStdout(t, func() error { - return AccountTopup(test.arguments) - }) - - if test.wantError != "" { - if err == nil || !strings.Contains(err.Error(), test.wantError) { - t.Fatalf("error = %v, want it to mention %q", err, test.wantError) - } - - return - } - - if err != nil { - t.Fatal(err) - } - - if !strings.Contains(printed, "https://checkout.stripe.com/c/pay/cs_test_1") { - t.Errorf("the output %q does not show the payment link", printed) - } - - select { - case url := <-browserOpens: - if !test.wantBrowser { - t.Errorf("the browser opened %q although enter was never pressed", url) - } else if url != "https://checkout.stripe.com/c/pay/cs_test_1" { - t.Errorf("the browser opened %q, want the payment link", url) - } - - default: - if test.wantBrowser { - t.Error("the browser never opened") - } - } - }) - } -} diff --git a/internal/commands/balances.go b/internal/commands/balances.go deleted file mode 100644 index fb24614..0000000 --- a/internal/commands/balances.go +++ /dev/null @@ -1,58 +0,0 @@ -package commands - -import ( - "encoding/json" - "fmt" - "io" - "net/http" - "strconv" - "strings" -) - -type balanceEntry struct { - Fleet int64 `json:"fleet"` - Balance string `json:"balance"` - Currency string `json:"currency"` -} - -func fetchBalances() ([]balanceEntry, error) { - request, err := authenticatedRequest(http.MethodGet, "/balance", nil) - - if err != nil { - return nil, err - } - - response, err := apiClient.Do(request) - - if err != nil { - return nil, fmt.Errorf("the server could not be reached: %w", err) - } - - defer response.Body.Close() - - if response.StatusCode != http.StatusOK { - message, _ := io.ReadAll(io.LimitReader(response.Body, 4096)) - - return nil, fmt.Errorf("the server said: %s", strings.TrimSpace(string(message))) - } - - balances := []balanceEntry{} - - err = json.NewDecoder(response.Body).Decode(&balances) - - if err != nil { - return nil, err - } - - return balances, nil -} - -func formatBalance(entry balanceEntry) string { - value, err := strconv.ParseFloat(entry.Balance, 64) - - if err != nil { - return entry.Balance - } - - return fmt.Sprintf("€%.2f", value) -} diff --git a/internal/commands/browser.go b/internal/commands/browser.go deleted file mode 100644 index d5bfab8..0000000 --- a/internal/commands/browser.go +++ /dev/null @@ -1,21 +0,0 @@ -package commands - -import ( - "os/exec" - "runtime" -) - -// A variable so tests can swap in a recorder instead of reaching a real -// browser. Opening is best effort: the link is already on screen. -var openBrowser = func(url string) { - switch runtime.GOOS { - case "darwin": - exec.Command("open", url).Start() - - case "windows": - exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start() - - default: - exec.Command("xdg-open", url).Start() - } -} diff --git a/internal/commands/browser_test.go b/internal/commands/browser_test.go deleted file mode 100644 index de7b52a..0000000 --- a/internal/commands/browser_test.go +++ /dev/null @@ -1,19 +0,0 @@ -package commands - -import ( - "testing" -) - -func captureBrowserOpens(t *testing.T) chan string { - t.Helper() - - opens := make(chan string, 8) - - previousOpenBrowser := openBrowser - - openBrowser = func(url string) { opens <- url } - - t.Cleanup(func() { openBrowser = previousOpenBrowser }) - - return opens -} diff --git a/internal/commands/client.go b/internal/commands/client.go deleted file mode 100644 index 1a36e3c..0000000 --- a/internal/commands/client.go +++ /dev/null @@ -1,158 +0,0 @@ -package commands - -import ( - "errors" - "fmt" - "io" - "io/fs" - "net/http" - "os" - "path/filepath" - "runtime" - "strings" - "time" -) - -const defaultApiBase = "https://supernext.siliconwitchery.com" - -var CliVersion = "unknown" - -var chosenApiBase = "" - -var apiClient = &http.Client{Timeout: 30 * time.Second} - -// Deliberately absent from the help: development needs to point a command at -// another server, users never do. -func TakeServerFlag(arguments []string) ([]string, error) { - remaining := []string{} - - server := "" - - for index := 0; index < len(arguments); index++ { - switch { - case arguments[index] == "--server": - if index+1 == len(arguments) || arguments[index+1] == "" { - return nil, errors.New("--server needs a url") - } - - index++ - - server = arguments[index] - - case strings.HasPrefix(arguments[index], "--server="): - server = strings.TrimPrefix(arguments[index], "--server=") - - if server == "" { - return nil, errors.New("--server needs a url") - } - - default: - remaining = append(remaining, arguments[index]) - } - } - - chosenApiBase = strings.TrimSuffix(server, "/") - - return remaining, nil -} - -func CheckServer() error { - request, err := apiRequest(http.MethodGet, "/", nil) - - if err != nil { - return err - } - - response, err := apiClient.Do(request) - - if err != nil { - return fmt.Errorf("the server at %s cannot be reached", strings.TrimSuffix(request.URL.String(), "/")) - } - - response.Body.Close() - - return nil -} - -func apiRequest(method string, path string, body io.Reader) (*http.Request, error) { - base := chosenApiBase - - if base == "" { - base = defaultApiBase - } - - request, err := http.NewRequest(method, strings.TrimSuffix(base, "/")+path, body) - - if err != nil { - return nil, err - } - - request.Header.Set("User-Agent", "superstack/"+CliVersion) - - return request, nil -} - -func authenticatedRequest(method string, path string, body io.Reader) (*http.Request, error) { - storedKeyPath, err := keyPath() - - if err != nil { - return nil, err - } - - keyBytes, err := os.ReadFile(storedKeyPath) - - if errors.Is(err, fs.ErrNotExist) { - return nil, errors.New("you are not logged in, run login first") - } - - if err != nil { - return nil, err - } - - key := strings.TrimSpace(string(keyBytes)) - - if key == "" { - return nil, errors.New("you are not logged in, run login first") - } - - request, err := apiRequest(method, path, body) - - if err != nil { - return nil, err - } - - request.Header.Set("Authorization", "Bearer "+key) - - return request, nil -} - -func keyPath() (string, error) { - // The key is state, not configuration: linux dotfile repos routinely - // publish all of ~/.config, so the key must never live there. The mac - // and windows config directories are not published like that. - if runtime.GOOS == "linux" { - stateHome := os.Getenv("XDG_STATE_HOME") - - // The spec says to ignore a relative value, and honoring one could - // drop the key inside a repository the user later pushes. - if !filepath.IsAbs(stateHome) { - home, err := os.UserHomeDir() - - if err != nil { - return "", err - } - - stateHome = filepath.Join(home, ".local", "state") - } - - return filepath.Join(stateHome, "superstack", "key"), nil - } - - configDirectory, err := os.UserConfigDir() - - if err != nil { - return "", err - } - - return filepath.Join(configDirectory, "superstack", "key"), nil -} diff --git a/internal/commands/client_test.go b/internal/commands/client_test.go deleted file mode 100644 index 68de4af..0000000 --- a/internal/commands/client_test.go +++ /dev/null @@ -1,310 +0,0 @@ -package commands - -import ( - "io" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "runtime" - "strings" - "testing" -) - -func captureStdout(t *testing.T, run func() error) (string, error) { - t.Helper() - - readEnd, writeEnd, err := os.Pipe() - - if err != nil { - t.Fatal(err) - } - - stdout := os.Stdout - - os.Stdout = writeEnd - - runError := run() - - os.Stdout = stdout - - writeEnd.Close() - - printed, err := io.ReadAll(readEnd) - - if err != nil { - t.Fatal(err) - } - - return string(printed), runError -} - -func isolateKeyStorage(t *testing.T) string { - t.Helper() - - temporary := t.TempDir() - - t.Setenv("HOME", temporary) - t.Setenv("XDG_STATE_HOME", temporary) - t.Setenv("AppData", temporary) - - return temporary -} - -func loggedInTestServer(t *testing.T, handler http.Handler) { - t.Helper() - - isolateKeyStorage(t) - - path, err := keyPath() - - if err != nil { - t.Fatal(err) - } - - err = os.MkdirAll(filepath.Dir(path), 0o700) - - if err != nil { - t.Fatal(err) - } - - err = os.WriteFile(path, []byte("ssk_test\n"), 0o600) - - if err != nil { - t.Fatal(err) - } - - // Every command reaching a logged-in server must carry the stored key, so - // the fixture proves it once rather than each command remembering to - authorized := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Header.Get("Authorization") != "Bearer ssk_test" { - t.Errorf("%s %s carried authorization %q, want the stored key", - r.Method, r.URL.Path, r.Header.Get("Authorization")) - } - - handler.ServeHTTP(w, r) - }) - - server := httptest.NewServer(authorized) - - t.Cleanup(server.Close) - - chosenApiBase = server.URL - - t.Cleanup(func() { chosenApiBase = "" }) -} - -func TestKeyPathStaysOutOfPublishedDotfiles(t *testing.T) { - temporary := isolateKeyStorage(t) - - path, err := keyPath() - - if err != nil { - t.Fatal(err) - } - - if !strings.HasPrefix(path, temporary) { - t.Fatalf("keyPath() = %q, want it under the isolated home", path) - } - - if runtime.GOOS != "linux" { - return - } - - if path != filepath.Join(temporary, "superstack", "key") { - t.Errorf("keyPath() = %q, want it directly under XDG_STATE_HOME", path) - } - - t.Setenv("XDG_STATE_HOME", "") - - path, err = keyPath() - - if err != nil { - t.Fatal(err) - } - - if path != filepath.Join(temporary, ".local", "state", "superstack", "key") { - t.Errorf("keyPath() = %q, want the ~/.local/state fallback", path) - } - - if strings.Contains(path, ".config") { - t.Errorf("keyPath() = %q, must never sit in ~/.config: dotfile repos publish it", path) - } - - t.Setenv("XDG_STATE_HOME", ".state") - - path, err = keyPath() - - if err != nil { - t.Fatal(err) - } - - if path != filepath.Join(temporary, ".local", "state", "superstack", "key") { - t.Errorf("keyPath() = %q: a relative XDG_STATE_HOME must be ignored, never joined to the working directory", path) - } -} - -func TestTakeServerFlag(t *testing.T) { - tests := []struct { - name string - arguments []string - wantRemaining string - wantBase string - wantError string - }{ - { - name: "no flag", - arguments: []string{"login"}, - wantRemaining: "login", - }, - { - name: "a url", - arguments: []string{"--server", "http://localhost:8080", "login"}, - wantRemaining: "login", - wantBase: "http://localhost:8080", - }, - { - name: "a url in equals form", - arguments: []string{"logout", "--server=https://staging.example.com"}, - wantRemaining: "logout", - wantBase: "https://staging.example.com", - }, - { - name: "a trailing slash is trimmed", - arguments: []string{"--server", "http://localhost:8080/", "login"}, - wantRemaining: "login", - wantBase: "http://localhost:8080", - }, - { - name: "the flag between command words", - arguments: []string{"fleet", "--server=http://localhost:9999", "list"}, - wantRemaining: "fleet list", - wantBase: "http://localhost:9999", - }, - { - name: "a missing value", - arguments: []string{"login", "--server"}, - wantError: "needs a url", - }, - { - name: "an empty value", - arguments: []string{"login", "--server="}, - wantError: "needs a url", - }, - { - name: "an empty value from an unset shell variable", - arguments: []string{"--server", "", "login"}, - wantError: "needs a url", - }, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - chosenApiBase = "" - - t.Cleanup(func() { chosenApiBase = "" }) - - remaining, err := TakeServerFlag(test.arguments) - - if test.wantError != "" { - if err == nil || !strings.Contains(err.Error(), test.wantError) { - t.Fatalf("error = %v, want it to mention %q", err, test.wantError) - } - - return - } - - if err != nil { - t.Fatal(err) - } - - if strings.Join(remaining, " ") != test.wantRemaining { - t.Errorf("remaining = %q, want %q", strings.Join(remaining, " "), test.wantRemaining) - } - - if chosenApiBase != test.wantBase { - t.Errorf("chosenApiBase = %q, want %q", chosenApiBase, test.wantBase) - } - }) - } -} - -func TestApiRequestBase(t *testing.T) { - previousVersion := CliVersion - - CliVersion = "1.2.3" - - t.Cleanup(func() { CliVersion = previousVersion }) - - tests := []struct { - name string - chosenBase string - wantUrl string - }{ - { - name: "the default", - wantUrl: defaultApiBase + "/login", - }, - { - name: "the flag overrides the default", - chosenBase: "http://localhost:8888", - wantUrl: "http://localhost:8888/login", - }, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - chosenApiBase = test.chosenBase - - t.Cleanup(func() { chosenApiBase = "" }) - - request, err := apiRequest(http.MethodGet, "/login", nil) - - if err != nil { - t.Fatal(err) - } - - if request.URL.String() != test.wantUrl { - t.Errorf("url = %q, want %q", request.URL.String(), test.wantUrl) - } - - // Pinned to a literal, not to CliVersion: the server's gate parses - // this exact shape, so deriving it here would agree with any value - if request.Header.Get("User-Agent") != "superstack/1.2.3" { - t.Errorf("User-Agent = %q, want superstack/1.2.3", request.Header.Get("User-Agent")) - } - }) - } -} - -func TestCheckServer(t *testing.T) { - reachable := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) - - defer reachable.Close() - - chosenApiBase = reachable.URL - - t.Cleanup(func() { chosenApiBase = "" }) - - err := CheckServer() - - if err != nil { - t.Fatalf("a reachable server reported: %v", err) - } - - unreachable := httptest.NewServer(http.NotFoundHandler()) - - unreachable.Close() - - chosenApiBase = unreachable.URL - - err = CheckServer() - - if err == nil || !strings.Contains(err.Error(), "cannot be reached") { - t.Fatalf("error = %v, want the consistent cannot-be-reached message", err) - } - - if !strings.Contains(err.Error(), unreachable.URL) { - t.Errorf("error = %v, want it to name the server address", err) - } -} diff --git a/internal/commands/device_claim.go b/internal/commands/device_claim.go deleted file mode 100644 index 27f82bc..0000000 --- a/internal/commands/device_claim.go +++ /dev/null @@ -1,92 +0,0 @@ -package commands - -import ( - "bytes" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "strconv" - "strings" - "time" -) - -var claimClient = &http.Client{Timeout: 90 * time.Second} - -func DeviceClaim(arguments []string) error { - if len(arguments) != 2 && len(arguments) != 3 { - return errors.New("device claim takes an IMEI, a fleet id, and an optional name") - } - - 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 := fetchFleets() - - if err != nil { - return err - } - - fleetName := "" - - for _, fleet := range fleets { - if fleet.Id == fleetId { - fleetName = fleet.Name - } - } - - if fleetName == "" { - return errors.New("the fleet id is the number shown by fleet list") - } - - fmt.Println("Press the button on the device to finish claiming it.") - - payload := map[string]string{"imei": imei} - - if len(arguments) == 3 { - payload["name"] = arguments[2] - } - - body, err := json.Marshal(payload) - - if err != nil { - return err - } - - request, err := authenticatedRequest(http.MethodPost, - "/fleets/"+strconv.FormatInt(fleetId, 10)+"/devices", bytes.NewReader(body)) - - if err != nil { - return err - } - - request.Header.Set("Content-Type", "application/json") - - response, err := claimClient.Do(request) - - if err != nil { - return fmt.Errorf("the server could not be reached: %w", err) - } - - defer response.Body.Close() - - if response.StatusCode != http.StatusNoContent { - message, _ := io.ReadAll(io.LimitReader(response.Body, 4096)) - - return fmt.Errorf("the server said: %s", strings.TrimSpace(string(message))) - } - - fmt.Printf("Claimed the device into %q.\n", fleetName) - - return nil -} diff --git a/internal/commands/device_claim_test.go b/internal/commands/device_claim_test.go deleted file mode 100644 index 52dc69c..0000000 --- a/internal/commands/device_claim_test.go +++ /dev/null @@ -1,153 +0,0 @@ -package commands - -import ( - "encoding/json" - "fmt" - "net/http" - "strings" - "testing" -) - -func TestDeviceClaim(t *testing.T) { - tests := []struct { - name string - statusCode int - message string - wantOutput string - wantError string - }{ - { - name: "button pressed", - statusCode: http.StatusNoContent, - wantOutput: "Press the button on the device to finish claiming it.\nClaimed the device into \"pilot\".\n", - }, - { - name: "button not pressed", - statusCode: http.StatusRequestTimeout, - message: "the button was not pressed in time", - wantOutput: "Press the button on the device to finish claiming it.\n", - wantError: "the server said: the button was not pressed in time", - }, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - claimedImei := "" - claimedName := "" - - mux := http.NewServeMux() - mux.HandleFunc("GET /fleets", func(w http.ResponseWriter, r *http.Request) { - fmt.Fprint(w, `[{"id":3,"name":"pilot","owner":true}]`) - }) - mux.HandleFunc("POST /fleets/{id}/devices", func(w http.ResponseWriter, r *http.Request) { - body := struct { - Imei string `json:"imei"` - Name string `json:"name"` - }{} - - json.NewDecoder(r.Body).Decode(&body) - claimedImei = body.Imei - claimedName = body.Name - - if r.Header.Get("Content-Type") != "application/json" { - t.Errorf("Content-Type = %q, want application/json", r.Header.Get("Content-Type")) - } - - if test.message != "" { - http.Error(w, test.message, test.statusCode) - - return - } - - w.WriteHeader(test.statusCode) - }) - - loggedInTestServer(t, mux) - - printed, err := captureStdout(t, func() error { - return DeviceClaim([]string{"354820091234567", "3", "roof sensor"}) - }) - - if test.wantError == "" && err != nil { - t.Fatal(err) - } - - if test.wantError != "" && (err == nil || err.Error() != test.wantError) { - t.Fatalf("error = %v, want %q", err, test.wantError) - } - - if claimedImei != "354820091234567" || claimedName != "roof sensor" { - t.Errorf("the server received IMEI %q and name %q", claimedImei, claimedName) - } - - if printed != test.wantOutput { - t.Errorf("output = %q, want %q", printed, test.wantOutput) - } - }) - } -} - -func TestDeviceClaimOmitsAnAbsentName(t *testing.T) { - nameWasPresent := false - - mux := http.NewServeMux() - mux.HandleFunc("GET /fleets", func(w http.ResponseWriter, r *http.Request) { - fmt.Fprint(w, `[{"id":3,"name":"pilot","owner":true}]`) - }) - mux.HandleFunc("POST /fleets/{id}/devices", func(w http.ResponseWriter, r *http.Request) { - body := map[string]string{} - json.NewDecoder(r.Body).Decode(&body) - _, nameWasPresent = body["name"] - w.WriteHeader(http.StatusNoContent) - }) - - loggedInTestServer(t, mux) - - err := DeviceClaim([]string{"354820091234567", "3"}) - - if err != nil { - t.Fatal(err) - } - - if nameWasPresent { - t.Error("the request included a name although none was given") - } -} - -func TestDeviceClaimArguments(t *testing.T) { - tests := []struct { - name string - arguments []string - wantError string - }{ - {"no arguments", nil, "takes an IMEI"}, - {"too many arguments", []string{"354820091234567", "3", "one", "two"}, "takes an IMEI"}, - {"short IMEI", []string{"123", "3"}, "15-digit"}, - {"non-digit IMEI", []string{"35482009123456x", "3"}, "15-digit"}, - {"wordy fleet", []string{"354820091234567", "pilot"}, "shown by fleet list"}, - {"zero fleet", []string{"354820091234567", "0"}, "shown by fleet list"}, - } - - for _, test := range tests { - err := DeviceClaim(test.arguments) - - if err == nil || !strings.Contains(err.Error(), test.wantError) { - t.Errorf("%s: error = %v, want it to mention %q", test.name, err, test.wantError) - } - } -} - -func TestDeviceClaimUnknownFleetUsesFleetIdGuidance(t *testing.T) { - mux := http.NewServeMux() - mux.HandleFunc("GET /fleets", func(w http.ResponseWriter, r *http.Request) { - fmt.Fprint(w, `[]`) - }) - - loggedInTestServer(t, mux) - - err := DeviceClaim([]string{"354820091234567", "9"}) - - if err == nil || !strings.Contains(err.Error(), "shown by fleet list") { - t.Fatalf("error = %v", err) - } -} diff --git a/internal/commands/device_list.go b/internal/commands/device_list.go deleted file mode 100644 index 3e6bb34..0000000 --- a/internal/commands/device_list.go +++ /dev/null @@ -1,153 +0,0 @@ -package commands - -import ( - "encoding/json" - "errors" - "fmt" - "os" - "strconv" - "time" -) - -func DeviceList(arguments []string) error { - jsonOutput := false - positionals := []string{} - - for _, argument := range arguments { - if argument == "--json" { - jsonOutput = true - continue - } - - positionals = append(positionals, argument) - } - - if len(positionals) > 1 { - return errors.New("device list takes at most one fleet id") - } - - chosenFleetId := int64(0) - - if len(positionals) == 1 { - parsed, err := strconv.ParseInt(positionals[0], 10, 64) - - if err != nil || parsed < 1 { - return errors.New("the fleet id is the number shown by fleet list") - } - - chosenFleetId = parsed - } - - devices, err := fetchDevices() - - if err != nil { - return err - } - - fleets, err := fetchFleets() - - if err != nil { - return err - } - - fleetNames := map[int64]string{} - - for _, fleet := range fleets { - fleetNames[fleet.Id] = fleet.Name - } - - if chosenFleetId != 0 { - if _, found := fleetNames[chosenFleetId]; !found { - return errors.New("no such fleet") - } - } - - filtered := []deviceEntry{} - - for _, device := range devices { - if chosenFleetId == 0 || device.FleetId == chosenFleetId { - filtered = append(filtered, device) - } - } - - if jsonOutput { - return json.NewEncoder(os.Stdout).Encode(filtered) - } - - if len(filtered) == 0 { - if chosenFleetId == 0 { - fmt.Println("No devices yet. Claim one with device claim.") - } else { - fmt.Println("No devices in that fleet.") - } - - return nil - } - - imeiWidth := len("IMEI") - nameWidth := len("NAME") - fleetWidth := len("FLEET") - stateWidth := len("STATE") - storageWidth := len("STORAGE") - imeiValues := make([]string, len(filtered)) - nameValues := make([]string, len(filtered)) - fleetValues := make([]string, len(filtered)) - stateValues := make([]string, len(filtered)) - storageValues := make([]string, len(filtered)) - lastSeenValues := make([]string, len(filtered)) - - for index, device := range filtered { - name := "-" - - if device.Name != nil { - name = *device.Name - } - - lastSeen := "never" - - if device.LastSeenAt != nil { - seenAt, err := time.Parse(time.RFC3339, *device.LastSeenAt) - - if err != nil { - return err - } - - age := time.Since(seenAt) - - switch { - case age < 2*time.Minute: - lastSeen = "just now" - case age < time.Hour: - lastSeen = fmt.Sprintf("%d min ago", int(age.Minutes())) - case age < 24*time.Hour: - lastSeen = fmt.Sprintf("%d h ago", int(age.Hours())) - default: - lastSeen = fmt.Sprintf("%d d ago", int(age.Hours()/24)) - } - } - - imeiValues[index] = device.Imei - nameValues[index] = name - fleetValues[index] = fleetNames[device.FleetId] - stateValues[index] = formatRunState(device.ReportedState) - storageValues[index] = formatStorage(device.StorageUsed, device.StorageTotal) - lastSeenValues[index] = lastSeen - imeiWidth = max(imeiWidth, len(imeiValues[index])) - nameWidth = max(nameWidth, len(nameValues[index])) - fleetWidth = max(fleetWidth, len(fleetValues[index])) - stateWidth = max(stateWidth, len(stateValues[index])) - storageWidth = max(storageWidth, len(storageValues[index])) - } - - fmt.Printf("%-*s %-*s %-*s %-*s %-*s %s\n", - imeiWidth, "IMEI", nameWidth, "NAME", fleetWidth, "FLEET", - stateWidth, "STATE", storageWidth, "STORAGE", "LAST SEEN") - - for index := range filtered { - fmt.Printf("%-*s %-*s %-*s %-*s %-*s %s\n", - imeiWidth, imeiValues[index], nameWidth, nameValues[index], fleetWidth, fleetValues[index], - stateWidth, stateValues[index], storageWidth, storageValues[index], lastSeenValues[index]) - } - - return nil -} diff --git a/internal/commands/device_list_test.go b/internal/commands/device_list_test.go deleted file mode 100644 index f603690..0000000 --- a/internal/commands/device_list_test.go +++ /dev/null @@ -1,156 +0,0 @@ -package commands - -import ( - "fmt" - "net/http" - "strings" - "testing" - "time" -) - -func TestDeviceList(t *testing.T) { - now := time.Now() - devices := fmt.Sprintf(`[{"imei":"111111111111111","name":"roof","fleet_id":3,"last_seen_at":%q,"reported_state":2,"storage_used":1240,"storage_total":57344},`+ - `{"imei":"222222222222222","name":null,"fleet_id":4,"last_seen_at":%q,"reported_state":4,"storage_used":2500000,"storage_total":8000000},`+ - `{"imei":"333333333333333","name":"shed","fleet_id":3,"last_seen_at":null,"reported_state":null,"storage_used":null,"storage_total":null}]`, - now.Add(-time.Minute).Format(time.RFC3339), now.Add(-3*time.Hour).Format(time.RFC3339)) - fleets := `[{"id":3,"name":"pilot","owner":true},{"id":4,"name":"workshop","owner":true},{"id":5,"name":"empty","owner":true}]` - - tests := []struct { - name string - arguments []string - wantShown []string - wantHidden []string - wantExact string - wantError string - }{ - {"table", nil, []string{"IMEI NAME FLEET STATE STORAGE LAST SEEN", "roof", "pilot", "running", "1.2 kB of 57.3 kB", "just now", "-", "workshop", "crashed", "2.5 MB of 8.0 MB", "3 h ago", "unknown", "never"}, nil, "", ""}, - {"filtered", []string{"3"}, []string{"111111111111111", "333333333333333"}, []string{"222222222222222", "workshop"}, "", ""}, - {"json flag anywhere", []string{"3", "--json"}, []string{`"imei":"111111111111111"`, `"fleet_id":3`}, []string{"LAST SEEN", "222222222222222"}, "", ""}, - {"empty fleet", []string{"5"}, nil, nil, "No devices in that fleet.\n", ""}, - {"unknown fleet", []string{"9"}, nil, nil, "", "no such fleet"}, - {"two ids", []string{"3", "4"}, nil, nil, "", "takes at most one fleet id"}, - {"wordy id", []string{"pilot"}, nil, nil, "", "shown by fleet list"}, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - mux := http.NewServeMux() - mux.HandleFunc("GET /devices", func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, devices) }) - mux.HandleFunc("GET /fleets", func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, fleets) }) - loggedInTestServer(t, mux) - - printed, err := captureStdout(t, func() error { return DeviceList(test.arguments) }) - - if test.wantError != "" { - if err == nil || !strings.Contains(err.Error(), test.wantError) { - t.Fatalf("error = %v", err) - } - return - } - - if err != nil { - t.Fatal(err) - } - - if test.wantExact != "" && printed != test.wantExact { - t.Errorf("output = %q", printed) - } - - for _, want := range test.wantShown { - if !strings.Contains(printed, want) { - t.Errorf("output %q omits %q", printed, want) - } - } - - for _, hidden := range test.wantHidden { - if strings.Contains(printed, hidden) { - t.Errorf("output %q includes %q", printed, hidden) - } - } - }) - } -} - -func TestFormatRunState(t *testing.T) { - running := 2 - stopped := 3 - crashed := 4 - undefined := 1 - tests := []struct { - name string - state *int - want string - }{ - {"running", &running, "running"}, - {"stopped", &stopped, "stopped"}, - {"crashed", &crashed, "crashed"}, - {"undefined", &undefined, "unknown"}, - {"nil", nil, "unknown"}, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - if got := formatRunState(test.state); got != test.want { - t.Errorf("formatRunState() = %q, want %q", got, test.want) - } - }) - } -} - -func TestFormatStorage(t *testing.T) { - bytes := int64(999) - kilobytes := int64(1240) - megabytes := int64(2500000) - total := int64(57344) - tests := []struct { - name string - used *int64 - total *int64 - want string - }{ - {"bytes", &bytes, &bytes, "999 B of 999 B"}, - {"kilobytes", &kilobytes, &total, "1.2 kB of 57.3 kB"}, - {"megabytes", &megabytes, &megabytes, "2.5 MB of 2.5 MB"}, - {"nil used", nil, &total, "-"}, - {"nil total", &kilobytes, nil, "-"}, - {"both nil", nil, nil, "-"}, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - if got := formatStorage(test.used, test.total); got != test.want { - t.Errorf("formatStorage() = %q, want %q", got, test.want) - } - }) - } -} - -func TestDeviceListEmptyAndServerError(t *testing.T) { - mux := http.NewServeMux() - mux.HandleFunc("GET /devices", func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, `[]`) }) - mux.HandleFunc("GET /fleets", func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, `[]`) }) - loggedInTestServer(t, mux) - - printed, err := captureStdout(t, func() error { return DeviceList(nil) }) - - if err != nil { - t.Fatal(err) - } - - if printed != "No devices yet. Claim one with device claim.\n" { - t.Errorf("output = %q", printed) - } - - errorMux := http.NewServeMux() - errorMux.HandleFunc("GET /devices", func(w http.ResponseWriter, r *http.Request) { - http.Error(w, "devices unavailable", http.StatusServiceUnavailable) - }) - loggedInTestServer(t, errorMux) - - err = DeviceList(nil) - - if err == nil || err.Error() != "the server said: devices unavailable" { - t.Fatalf("error = %v", err) - } -} diff --git a/internal/commands/device_release.go b/internal/commands/device_release.go deleted file mode 100644 index a04065e..0000000 --- a/internal/commands/device_release.go +++ /dev/null @@ -1,94 +0,0 @@ -package commands - -import ( - "bufio" - "errors" - "fmt" - "io" - "net/http" - "os" - "strings" -) - -func DeviceRelease(arguments []string) error { - if len(arguments) != 1 { - return errors.New("device release takes an IMEI") - } - - imei := arguments[0] - - if !validImei(imei) { - return errors.New("the IMEI is the 15-digit number printed on the device") - } - - devices, err := fetchDevices() - - if err != nil { - return err - } - - fleetId := int64(0) - - for _, device := range devices { - if device.Imei == imei { - fleetId = device.FleetId - } - } - - if fleetId == 0 { - return errors.New("no such device, device list shows yours") - } - - fleets, err := fetchFleets() - - if err != nil { - return err - } - - fleetName := "" - - for _, fleet := range fleets { - if fleet.Id == fleetId { - fleetName = fleet.Name - } - } - - if fleetName == "" { - return errors.New("no such device, device list shows yours") - } - - fmt.Printf("Release the device from %q? It erases everything on the device, and claiming it again means pressing its button in person. [y/N] ", fleetName) - - answer, _ := bufio.NewReader(os.Stdin).ReadString('\n') - - answer = strings.ToLower(strings.TrimSpace(answer)) - - if answer != "y" && answer != "yes" { - fmt.Println("Nothing released.") - return nil - } - - request, err := authenticatedRequest(http.MethodDelete, "/devices/"+imei, nil) - - if err != nil { - return err - } - - response, err := apiClient.Do(request) - - if err != nil { - return fmt.Errorf("the server could not be reached: %w", err) - } - - defer response.Body.Close() - - if response.StatusCode != http.StatusNoContent { - message, _ := io.ReadAll(io.LimitReader(response.Body, 4096)) - - return fmt.Errorf("the server said: %s", strings.TrimSpace(string(message))) - } - - fmt.Println("Released the device.") - - return nil -} diff --git a/internal/commands/device_release_test.go b/internal/commands/device_release_test.go deleted file mode 100644 index 4d41310..0000000 --- a/internal/commands/device_release_test.go +++ /dev/null @@ -1,106 +0,0 @@ -package commands - -import ( - "fmt" - "net/http" - "strings" - "testing" -) - -func TestDeviceRelease(t *testing.T) { - tests := []struct { - name string - answer string - refusal string - wantReleased bool - wantOutput string - wantError string - }{ - {"confirmed", "yes\n", "", true, "Release the device from \"pilot\"? It erases everything on the device, and claiming it again means pressing its button in person. [y/N] Released the device.\n", ""}, - {"declined", "n\n", "", false, "Release the device from \"pilot\"? It erases everything on the device, and claiming it again means pressing its button in person. [y/N] Nothing released.\n", ""}, - {"server refuses", "y\n", "no such device", true, "", "the server said: no such device"}, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - releasedPath := "" - mux := http.NewServeMux() - mux.HandleFunc("GET /devices", func(w http.ResponseWriter, r *http.Request) { - fmt.Fprint(w, `[{"imei":"354820091234567","name":null,"fleet_id":3,"last_seen_at":null}]`) - }) - mux.HandleFunc("GET /fleets", func(w http.ResponseWriter, r *http.Request) { - fmt.Fprint(w, `[{"id":3,"name":"pilot","owner":true}]`) - }) - mux.HandleFunc("DELETE /devices/{imei}", func(w http.ResponseWriter, r *http.Request) { - releasedPath = r.URL.Path - - if test.refusal != "" { - http.Error(w, test.refusal, http.StatusNotFound) - return - } - - w.WriteHeader(http.StatusNoContent) - }) - - loggedInTestServer(t, mux) - answerOnStdin(t, test.answer) - - printed, err := captureStdout(t, func() error { - return DeviceRelease([]string{"354820091234567"}) - }) - - if test.wantError != "" { - if err == nil || err.Error() != test.wantError { - t.Fatalf("error = %v", err) - } - } else if err != nil { - t.Fatal(err) - } - - if test.wantOutput != "" && printed != test.wantOutput { - t.Errorf("output = %q", printed) - } - - if test.wantReleased && releasedPath != "/devices/354820091234567" { - t.Errorf("released path = %q", releasedPath) - } - - if !test.wantReleased && releasedPath != "" { - t.Errorf("released path = %q after decline", releasedPath) - } - }) - } -} - -func TestDeviceReleaseArgumentsAndUnknownDevice(t *testing.T) { - tests := []struct { - name string - arguments []string - wantError string - }{ - {"no arguments", nil, "takes an IMEI"}, - {"two arguments", []string{"354820091234567", "extra"}, "takes an IMEI"}, - {"short IMEI", []string{"123"}, "15-digit"}, - {"non-digit IMEI", []string{"35482009123456x"}, "15-digit"}, - } - - for _, test := range tests { - err := DeviceRelease(test.arguments) - - if err == nil || !strings.Contains(err.Error(), test.wantError) { - t.Errorf("%s: error = %v", test.name, err) - } - } - - mux := http.NewServeMux() - mux.HandleFunc("GET /devices", func(w http.ResponseWriter, r *http.Request) { - fmt.Fprint(w, `[]`) - }) - loggedInTestServer(t, mux) - - err := DeviceRelease([]string{"354820091234567"}) - - if err == nil || err.Error() != "no such device, device list shows yours" { - t.Fatalf("error = %v", err) - } -} diff --git a/internal/commands/device_rename.go b/internal/commands/device_rename.go deleted file mode 100644 index dbb0441..0000000 --- a/internal/commands/device_rename.go +++ /dev/null @@ -1,61 +0,0 @@ -package commands - -import ( - "bytes" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "strings" -) - -func DeviceRename(arguments []string) error { - if len(arguments) != 2 { - return errors.New("device rename takes an IMEI and a name, quoted if it has spaces") - } - - imei := arguments[0] - - if !validImei(imei) { - return errors.New("the IMEI is the 15-digit number printed on the device") - } - - name := strings.TrimSpace(arguments[1]) - - if name == "" { - return errors.New("device rename takes an IMEI and a name, quoted if it has spaces") - } - - body, err := json.Marshal(map[string]string{"name": name}) - - if err != nil { - return err - } - - request, err := authenticatedRequest(http.MethodPatch, "/devices/"+imei, bytes.NewReader(body)) - - if err != nil { - return err - } - - request.Header.Set("Content-Type", "application/json") - - response, err := apiClient.Do(request) - - if err != nil { - return fmt.Errorf("the server could not be reached: %w", err) - } - - defer response.Body.Close() - - if response.StatusCode != http.StatusNoContent { - message, _ := io.ReadAll(io.LimitReader(response.Body, 4096)) - - return fmt.Errorf("the server said: %s", strings.TrimSpace(string(message))) - } - - fmt.Printf("Renamed the device to %q.\n", name) - - return nil -} diff --git a/internal/commands/device_rename_test.go b/internal/commands/device_rename_test.go deleted file mode 100644 index 184a9e6..0000000 --- a/internal/commands/device_rename_test.go +++ /dev/null @@ -1,82 +0,0 @@ -package commands - -import ( - "encoding/json" - "net/http" - "strings" - "testing" -) - -func TestDeviceRename(t *testing.T) { - renamedPath := "" - renamedTo := "" - - mux := http.NewServeMux() - mux.HandleFunc("PATCH /devices/{imei}", func(w http.ResponseWriter, r *http.Request) { - body := struct { - Name string `json:"name"` - }{} - - json.NewDecoder(r.Body).Decode(&body) - renamedPath = r.URL.Path - renamedTo = body.Name - w.WriteHeader(http.StatusNoContent) - }) - - loggedInTestServer(t, mux) - - printed, err := captureStdout(t, func() error { - return DeviceRename([]string{"354820091234567", " pilot "}) - }) - - if err != nil { - t.Fatal(err) - } - - if renamedPath != "/devices/354820091234567" || renamedTo != "pilot" { - t.Errorf("the server saw %q renamed to %q", renamedPath, renamedTo) - } - - if printed != "Renamed the device to \"pilot\".\n" { - t.Errorf("output = %q", printed) - } -} - -func TestDeviceRenameServerError(t *testing.T) { - mux := http.NewServeMux() - mux.HandleFunc("PATCH /devices/{imei}", func(w http.ResponseWriter, r *http.Request) { - http.Error(w, "no such device", http.StatusNotFound) - }) - - loggedInTestServer(t, mux) - - err := DeviceRename([]string{"354820091234567", "pilot"}) - - if err == nil || err.Error() != "the server said: no such device" { - t.Fatalf("error = %v", err) - } -} - -func TestDeviceRenameArguments(t *testing.T) { - tests := []struct { - name string - arguments []string - wantError string - }{ - {"no arguments", nil, "takes an IMEI and a name"}, - {"one argument", []string{"354820091234567"}, "takes an IMEI and a name"}, - {"three arguments", []string{"354820091234567", "roof", "sensor"}, "takes an IMEI and a name"}, - {"short IMEI", []string{"123", "pilot"}, "15-digit"}, - {"non-digit IMEI", []string{"35482009123456x", "pilot"}, "15-digit"}, - {"empty name", []string{"354820091234567", ""}, "takes an IMEI and a name"}, - {"whitespace name", []string{"354820091234567", " "}, "takes an IMEI and a name"}, - } - - for _, test := range tests { - err := DeviceRename(test.arguments) - - if err == nil || !strings.Contains(err.Error(), test.wantError) { - t.Errorf("%s: error = %v, want it to mention %q", test.name, err, test.wantError) - } - } -} diff --git a/internal/commands/devices.go b/internal/commands/devices.go deleted file mode 100644 index acbf7a7..0000000 --- a/internal/commands/devices.go +++ /dev/null @@ -1,101 +0,0 @@ -package commands - -import ( - "encoding/json" - "fmt" - "io" - "net/http" - "strings" -) - -type deviceEntry struct { - Imei string `json:"imei"` - Name *string `json:"name"` - FleetId int64 `json:"fleet_id"` - LastSeenAt *string `json:"last_seen_at"` - ReportedState *int `json:"reported_state"` - StorageUsed *int64 `json:"storage_used"` - StorageTotal *int64 `json:"storage_total"` -} - -func fetchDevices() ([]deviceEntry, error) { - request, err := authenticatedRequest(http.MethodGet, "/devices", nil) - - if err != nil { - return nil, err - } - - response, err := apiClient.Do(request) - - if err != nil { - return nil, fmt.Errorf("the server could not be reached: %w", err) - } - - defer response.Body.Close() - - if response.StatusCode != http.StatusOK { - message, _ := io.ReadAll(io.LimitReader(response.Body, 4096)) - - return nil, fmt.Errorf("the server said: %s", strings.TrimSpace(string(message))) - } - - devices := []deviceEntry{} - - err = json.NewDecoder(response.Body).Decode(&devices) - - if err != nil { - return nil, err - } - - return devices, nil -} - -func formatRunState(state *int) string { - if state == nil { - return "unknown" - } - - switch *state { - case 2: - return "running" - case 3: - return "stopped" - case 4: - return "crashed" - default: - return "unknown" - } -} - -func formatStorage(used *int64, total *int64) string { - if used == nil || total == nil { - return "-" - } - - formatBytes := func(bytes int64) string { - switch { - case bytes < 1000: - return fmt.Sprintf("%d B", bytes) - case bytes < 1000*1000: - return fmt.Sprintf("%.1f kB", float64(bytes)/1000) - default: - return fmt.Sprintf("%.1f MB", float64(bytes)/(1000*1000)) - } - } - - return fmt.Sprintf("%s of %s", formatBytes(*used), formatBytes(*total)) -} - -func validImei(imei string) bool { - if len(imei) != 15 { - return false - } - - for _, digit := range imei { - if digit < '0' || digit > '9' { - return false - } - } - - return true -} diff --git a/internal/commands/fleet_create.go b/internal/commands/fleet_create.go deleted file mode 100644 index e8a8f41..0000000 --- a/internal/commands/fleet_create.go +++ /dev/null @@ -1,61 +0,0 @@ -package commands - -import ( - "bytes" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "strings" -) - -func FleetCreate(arguments []string) error { - - if len(arguments) != 1 || arguments[0] == "" { - return errors.New("fleet create takes one name, quoted if it has spaces") - } - - body, err := json.Marshal(map[string]string{"name": arguments[0]}) - - if err != nil { - return err - } - - request, err := authenticatedRequest(http.MethodPost, "/fleets", bytes.NewReader(body)) - - if err != nil { - return err - } - - request.Header.Set("Content-Type", "application/json") - - response, err := apiClient.Do(request) - - if err != nil { - return fmt.Errorf("the server could not be reached: %w", err) - } - - defer response.Body.Close() - - if response.StatusCode != http.StatusOK { - message, _ := io.ReadAll(io.LimitReader(response.Body, 4096)) - - return fmt.Errorf("the server said: %s", strings.TrimSpace(string(message))) - } - - created := struct { - Id int64 `json:"id"` - Name string `json:"name"` - }{} - - err = json.NewDecoder(response.Body).Decode(&created) - - if err != nil { - return err - } - - fmt.Printf("Created fleet %q with id %d.\n", created.Name, created.Id) - - return nil -} diff --git a/internal/commands/fleet_create_test.go b/internal/commands/fleet_create_test.go deleted file mode 100644 index 04c9b45..0000000 --- a/internal/commands/fleet_create_test.go +++ /dev/null @@ -1,74 +0,0 @@ -package commands - -import ( - "encoding/json" - "fmt" - "net/http" - "strings" - "testing" -) - -func TestFleetCreate(t *testing.T) { - created := "" - - mux := http.NewServeMux() - - mux.HandleFunc("POST /fleets", func(w http.ResponseWriter, r *http.Request) { - body := struct { - Name string `json:"name"` - }{} - - json.NewDecoder(r.Body).Decode(&body) - - created = body.Name - - fmt.Fprintf(w, `{"id": 5, "name": %q}`, body.Name) - }) - - loggedInTestServer(t, mux) - - err := FleetCreate([]string{"field trial"}) - - if err != nil { - t.Fatal(err) - } - - if created != "field trial" { - t.Errorf("the server saw %q created, want %q", created, "field trial") - } -} - -func TestFleetCreateRelaysARefusal(t *testing.T) { - mux := http.NewServeMux() - - mux.HandleFunc("POST /fleets", func(w http.ResponseWriter, r *http.Request) { - http.Error(w, "the body must carry a name", http.StatusBadRequest) - }) - - loggedInTestServer(t, mux) - - err := FleetCreate([]string{"field trial"}) - - if err == nil || !strings.Contains(err.Error(), "the server said: the body must carry a name") { - t.Fatalf("error = %v, want the relayed refusal", err) - } -} - -func TestFleetCreateTakesOneName(t *testing.T) { - tests := []struct { - name string - arguments []string - }{ - {"no arguments", nil}, - {"two words", []string{"field", "trial"}}, - {"an empty name", []string{""}}, - } - - for _, test := range tests { - err := FleetCreate(test.arguments) - - if err == nil || !strings.Contains(err.Error(), "takes one name") { - t.Errorf("%s: error = %v, want the one-name hint", test.name, err) - } - } -} diff --git a/internal/commands/fleet_delete.go b/internal/commands/fleet_delete.go deleted file mode 100644 index 222e2d8..0000000 --- a/internal/commands/fleet_delete.go +++ /dev/null @@ -1,107 +0,0 @@ -package commands - -import ( - "bufio" - "errors" - "fmt" - "io" - "net/http" - "os" - "strconv" - "strings" -) - -func FleetDelete(arguments []string) error { - - if len(arguments) != 1 { - return errors.New("fleet delete takes a fleet id") - } - - fleetId, err := strconv.ParseInt(arguments[0], 10, 64) - - if err != nil || fleetId < 1 { - return errors.New("the fleet id is the number shown by fleet list") - } - - fleets, err := fetchFleets() - - if err != nil { - return err - } - - name := "" - found := false - - for _, fleet := range fleets { - if fleet.Id == fleetId { - name = fleet.Name - found = true - } - } - - if !found { - return errors.New("no such fleet") - } - - balances, err := fetchBalances() - - if err != nil { - return err - } - - forfeited := "" - - for _, balance := range balances { - if balance.Fleet != fleetId { - continue - } - - value, err := strconv.ParseFloat(balance.Balance, 64) - - if err == nil && value > 0 { - forfeited = formatBalance(balance) - } - } - - consequence := "It erases them all, and claiming one again means pressing its button in person." - - if forfeited == "" { - fmt.Printf("Delete %q and release its devices? %s [y/N] ", name, consequence) - } else { - fmt.Printf("Delete %q, release its devices, and forfeit its remaining %s of credit? %s [y/N] ", name, forfeited, consequence) - } - - answer, _ := bufio.NewReader(os.Stdin).ReadString('\n') - - answer = strings.ToLower(strings.TrimSpace(answer)) - - if answer != "y" && answer != "yes" { - fmt.Println("Nothing deleted.") - return nil - } - - request, err := authenticatedRequest(http.MethodDelete, - "/fleets/"+strconv.FormatInt(fleetId, 10), nil) - - if err != nil { - return err - } - - response, err := apiClient.Do(request) - - if err != nil { - return fmt.Errorf("the server could not be reached: %w", err) - } - - defer response.Body.Close() - - if response.StatusCode != http.StatusNoContent { - message, _ := io.ReadAll(io.LimitReader(response.Body, 4096)) - - return fmt.Errorf("the server said: %s", strings.TrimSpace(string(message))) - } - - fmt.Printf("Deleted %q.\n", name) - - return nil -} diff --git a/internal/commands/fleet_delete_test.go b/internal/commands/fleet_delete_test.go deleted file mode 100644 index 223684c..0000000 --- a/internal/commands/fleet_delete_test.go +++ /dev/null @@ -1,245 +0,0 @@ -package commands - -import ( - "fmt" - "net/http" - "os" - "strings" - "testing" -) - -func answerOnStdin(t *testing.T, answer string) { - t.Helper() - - readEnd, writeEnd, err := os.Pipe() - - if err != nil { - t.Fatal(err) - } - - originalStdin := os.Stdin - - os.Stdin = readEnd - - t.Cleanup(func() { os.Stdin = originalStdin }) - - if answer != "" { - _, err = writeEnd.WriteString(answer) - - if err != nil { - t.Fatal(err) - } - } - - writeEnd.Close() -} - -func TestFleetDelete(t *testing.T) { - tests := []struct { - name string - answer string - refusal string - wantDeleted bool - wantError string - }{ - {name: "confirmed with y", answer: "y\n", wantDeleted: true}, - {name: "confirmed with yes", answer: "YES\n", wantDeleted: true}, - {name: "declined with n", answer: "n\n"}, - {name: "declined by default", answer: "\n"}, - {name: "closed input", answer: ""}, - { - name: "the server refuses after the confirmation", - answer: "y\n", - refusal: "only the fleet's owner can delete it", - wantDeleted: true, - wantError: "only the fleet's owner can delete it", - }, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - deletedPath := "" - - mux := http.NewServeMux() - - mux.HandleFunc("GET /fleets", func(w http.ResponseWriter, r *http.Request) { - fmt.Fprint(w, `[{"id":3,"name":"pilot","owner":true}]`) - }) - - mux.HandleFunc("GET /balance", func(w http.ResponseWriter, r *http.Request) { - fmt.Fprint(w, `[{"fleet":3,"balance":"0","currency":"eur"}]`) - }) - - mux.HandleFunc("DELETE /fleets/{id}", func(w http.ResponseWriter, r *http.Request) { - deletedPath = r.URL.Path - - if test.refusal != "" { - http.Error(w, test.refusal, http.StatusForbidden) - return - } - - w.WriteHeader(http.StatusNoContent) - }) - - loggedInTestServer(t, mux) - - answerOnStdin(t, test.answer) - - printed, err := captureStdout(t, func() error { - return FleetDelete([]string{"3"}) - }) - - switch { - case test.wantError != "": - if err == nil || !strings.Contains(err.Error(), test.wantError) { - t.Fatalf("error = %v, want it to mention %q", err, test.wantError) - } - - // A refused delete must never claim the fleet is gone - if strings.Contains(printed, "Deleted") { - t.Errorf("the output %q says the fleet was deleted although the server refused", printed) - } - - case err != nil: - t.Fatal(err) - } - - if test.wantDeleted && deletedPath != "/fleets/3" { - t.Errorf("the server saw %q deleted, want %q", deletedPath, "/fleets/3") - } - - if !test.wantDeleted && deletedPath != "" { - t.Errorf("the server saw %q deleted although the confirmation was declined", deletedPath) - } - }) - } -} - -func TestFleetDeletePromptStatesForfeitedCredit(t *testing.T) { - tests := []struct { - name string - balance string - wantPrompt string - wantAbsent string - }{ - { - name: "remaining credit is stated", - balance: `[{"fleet":3,"balance":"12.340000","currency":"eur"}]`, - wantPrompt: "forfeit its remaining €12.34 of credit", - }, - { - name: "an empty balance stays quiet", - balance: `[{"fleet":3,"balance":"0","currency":"eur"}]`, - wantAbsent: "forfeit", - }, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - mux := http.NewServeMux() - - mux.HandleFunc("GET /fleets", func(w http.ResponseWriter, r *http.Request) { - fmt.Fprint(w, `[{"id":3,"name":"pilot","owner":true}]`) - }) - - mux.HandleFunc("GET /balance", func(w http.ResponseWriter, r *http.Request) { - fmt.Fprint(w, test.balance) - }) - - loggedInTestServer(t, mux) - - answerOnStdin(t, "n\n") - - printed, err := captureStdout(t, func() error { - return FleetDelete([]string{"3"}) - }) - - if err != nil { - t.Fatal(err) - } - - if test.wantPrompt != "" && !strings.Contains(printed, test.wantPrompt) { - t.Errorf("the prompt %q does not state %q", printed, test.wantPrompt) - } - - // Both prompts warn what the delete does to the devices - if !strings.Contains(printed, "It erases them all, and claiming one again means pressing its button in person.") { - t.Errorf("the prompt %q does not say the devices are erased", printed) - } - - if test.wantAbsent != "" && strings.Contains(printed, test.wantAbsent) { - t.Errorf("the prompt %q mentions %q although nothing is forfeited", printed, test.wantAbsent) - } - }) - } -} - -func TestFleetDeleteRefusesWhenTheBalanceIsUnknown(t *testing.T) { - deletedPath := "" - - mux := http.NewServeMux() - - mux.HandleFunc("GET /fleets", func(w http.ResponseWriter, r *http.Request) { - fmt.Fprint(w, `[{"id":3,"name":"pilot","owner":true}]`) - }) - - mux.HandleFunc("GET /balance", func(w http.ResponseWriter, r *http.Request) { - http.Error(w, "the server could not read the balances", http.StatusServiceUnavailable) - }) - - mux.HandleFunc("DELETE /fleets/{id}", func(w http.ResponseWriter, r *http.Request) { - deletedPath = r.URL.Path - - w.WriteHeader(http.StatusNoContent) - }) - - loggedInTestServer(t, mux) - - answerOnStdin(t, "y\n") - - err := FleetDelete([]string{"3"}) - - if err == nil || !strings.Contains(err.Error(), "could not read the balances") { - t.Fatalf("error = %v, want the server's balance refusal", err) - } - - if deletedPath != "" { - t.Errorf("the server saw %q deleted although the credit could not be stated", deletedPath) - } -} - -func TestFleetDeleteUnknownFleet(t *testing.T) { - mux := http.NewServeMux() - - mux.HandleFunc("GET /fleets", func(w http.ResponseWriter, r *http.Request) { - fmt.Fprint(w, `[]`) - }) - - loggedInTestServer(t, mux) - - err := FleetDelete([]string{"9"}) - - if err == nil || !strings.Contains(err.Error(), "no such fleet") { - t.Fatalf("error = %v, want no such fleet", err) - } -} - -func TestFleetDeleteArguments(t *testing.T) { - tests := []struct { - name string - arguments []string - wantError string - }{ - {"no arguments", nil, "takes a fleet id"}, - {"two arguments", []string{"3", "4"}, "takes a fleet id"}, - {"a wordy id", []string{"pilot"}, "shown by fleet list"}, - } - - for _, test := range tests { - err := FleetDelete(test.arguments) - - if err == nil || !strings.Contains(err.Error(), test.wantError) { - t.Errorf("%s: error = %v, want it to mention %q", test.name, err, test.wantError) - } - } -} diff --git a/internal/commands/fleet_list.go b/internal/commands/fleet_list.go deleted file mode 100644 index ee12986..0000000 --- a/internal/commands/fleet_list.go +++ /dev/null @@ -1,58 +0,0 @@ -package commands - -import ( - "encoding/json" - "fmt" - "os" - "strconv" -) - -func FleetList(arguments []string) error { - - jsonOutput := false - - for _, argument := range arguments { - if argument != "--json" { - return fmt.Errorf("fleet list takes no arguments, only --json") - } - - jsonOutput = true - } - - fleets, err := fetchFleets() - - if err != nil { - return err - } - - if jsonOutput { - return json.NewEncoder(os.Stdout).Encode(fleets) - } - - if len(fleets) == 0 { - fmt.Println("No fleets yet. Create one with fleet create.") - return nil - } - - idWidth := len("ID") - nameWidth := len("NAME") - - for _, fleet := range fleets { - idWidth = max(idWidth, len(strconv.FormatInt(fleet.Id, 10))) - nameWidth = max(nameWidth, len(fleet.Name)) - } - - fmt.Printf("%-*s %-*s %s\n", idWidth, "ID", nameWidth, "NAME", "ROLE") - - for _, fleet := range fleets { - role := "member" - - if fleet.Owner { - role = "owner" - } - - fmt.Printf("%-*d %-*s %s\n", idWidth, fleet.Id, nameWidth, fleet.Name, role) - } - - return nil -} diff --git a/internal/commands/fleet_list_test.go b/internal/commands/fleet_list_test.go deleted file mode 100644 index 2091f2f..0000000 --- a/internal/commands/fleet_list_test.go +++ /dev/null @@ -1,87 +0,0 @@ -package commands - -import ( - "fmt" - "net/http" - "strings" - "testing" -) - -func TestFleetList(t *testing.T) { - tests := []struct { - name string - arguments []string - fleets string - wantShown []string - wantAbsent []string - wantExact string - wantError string - }{ - { - name: "some fleets", - fleets: `[{"id":1,"name":"field trial","owner":true},{"id":2,"name":"rooftop","owner":false}]`, - wantShown: []string{"ID", "NAME", "ROLE", "field trial", "owner", "rooftop", "member"}, - }, - { - name: "no fleets", - fleets: `[]`, - wantShown: []string{"No fleets yet"}, - wantAbsent: []string{"ID", "NAME"}, - }, - { - name: "machine-readable output", - arguments: []string{"--json"}, - fleets: `[{"id":1,"name":"field trial","owner":true}]`, - wantExact: `[{"id":1,"name":"field trial","owner":true}]` + "\n", - }, - { - name: "an unknown argument", - arguments: []string{"--verbose"}, - wantError: "only --json", - }, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - mux := http.NewServeMux() - - mux.HandleFunc("GET /fleets", func(w http.ResponseWriter, r *http.Request) { - fmt.Fprint(w, test.fleets) - }) - - loggedInTestServer(t, mux) - - printed, err := captureStdout(t, func() error { - return FleetList(test.arguments) - }) - - if test.wantError != "" { - if err == nil || !strings.Contains(err.Error(), test.wantError) { - t.Fatalf("error = %v, want it to mention %q", err, test.wantError) - } - - return - } - - if err != nil { - t.Fatal(err) - } - - if test.wantExact != "" && printed != test.wantExact { - t.Errorf("the output is %q, want exactly %q", printed, test.wantExact) - } - - for _, want := range test.wantShown { - if !strings.Contains(printed, want) { - t.Errorf("the output %q does not show %q", printed, want) - } - } - - for _, absent := range test.wantAbsent { - if strings.Contains(printed, absent) { - t.Errorf("the output %q shows %q although there is nothing to list", printed, absent) - } - } - }) - } -} diff --git a/internal/commands/fleet_rename.go b/internal/commands/fleet_rename.go deleted file mode 100644 index 65357f2..0000000 --- a/internal/commands/fleet_rename.go +++ /dev/null @@ -1,64 +0,0 @@ -package commands - -import ( - "bytes" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "strconv" - "strings" -) - -func FleetRename(arguments []string) error { - - if len(arguments) != 2 { - return errors.New("fleet rename takes a fleet id and a name, quoted if it has spaces") - } - - fleetId, err := strconv.ParseInt(arguments[0], 10, 64) - - if err != nil || fleetId < 1 { - return errors.New("the fleet id is the number shown by fleet list") - } - - name := strings.TrimSpace(arguments[1]) - - if name == "" { - return errors.New("fleet rename takes a fleet id and a name, quoted if it has spaces") - } - - body, err := json.Marshal(map[string]string{"name": name}) - - if err != nil { - return err - } - - request, err := authenticatedRequest(http.MethodPatch, - "/fleets/"+strconv.FormatInt(fleetId, 10), bytes.NewReader(body)) - - if err != nil { - return err - } - - request.Header.Set("Content-Type", "application/json") - - response, err := apiClient.Do(request) - - if err != nil { - return fmt.Errorf("the server could not be reached: %w", err) - } - - defer response.Body.Close() - - if response.StatusCode != http.StatusNoContent { - message, _ := io.ReadAll(io.LimitReader(response.Body, 4096)) - - return fmt.Errorf("the server said: %s", strings.TrimSpace(string(message))) - } - - fmt.Printf("Renamed the fleet to %q.\n", name) - - return nil -} diff --git a/internal/commands/fleet_rename_test.go b/internal/commands/fleet_rename_test.go deleted file mode 100644 index 6399e9e..0000000 --- a/internal/commands/fleet_rename_test.go +++ /dev/null @@ -1,64 +0,0 @@ -package commands - -import ( - "encoding/json" - "net/http" - "strings" - "testing" -) - -func TestFleetRename(t *testing.T) { - renamedPath := "" - renamedTo := "" - - mux := http.NewServeMux() - - mux.HandleFunc("PATCH /fleets/{id}", func(w http.ResponseWriter, r *http.Request) { - body := struct { - Name string `json:"name"` - }{} - - json.NewDecoder(r.Body).Decode(&body) - - renamedPath = r.URL.Path - renamedTo = body.Name - - w.WriteHeader(http.StatusNoContent) - }) - - loggedInTestServer(t, mux) - - err := FleetRename([]string{"9", " pilot "}) - - if err != nil { - t.Fatal(err) - } - - if renamedPath != "/fleets/9" || renamedTo != "pilot" { - t.Errorf("the server saw %q renamed to %q, want %q renamed to %q", - renamedPath, renamedTo, "/fleets/9", "pilot") - } -} - -func TestFleetRenameArguments(t *testing.T) { - tests := []struct { - name string - arguments []string - wantError string - }{ - {"no arguments", nil, "takes a fleet id and a name"}, - {"only an id", []string{"3"}, "takes a fleet id and a name"}, - {"three arguments", []string{"3", "field", "trial"}, "takes a fleet id and a name"}, - {"a wordy id", []string{"pilot", "rooftop"}, "shown by fleet list"}, - {"an empty name", []string{"3", ""}, "takes a fleet id and a name"}, - {"a whitespace name", []string{"3", " "}, "takes a fleet id and a name"}, - } - - for _, test := range tests { - err := FleetRename(test.arguments) - - if err == nil || !strings.Contains(err.Error(), test.wantError) { - t.Errorf("%s: error = %v, want it to mention %q", test.name, err, test.wantError) - } - } -} diff --git a/internal/commands/fleet_transfer.go b/internal/commands/fleet_transfer.go deleted file mode 100644 index 94ce714..0000000 --- a/internal/commands/fleet_transfer.go +++ /dev/null @@ -1,60 +0,0 @@ -package commands - -import ( - "bytes" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "strconv" - "strings" -) - -func FleetTransfer(arguments []string) error { - - if len(arguments) != 2 || arguments[1] == "" { - return errors.New("fleet transfer takes a fleet id and an email address") - } - - fleetId, err := strconv.ParseInt(arguments[0], 10, 64) - - if err != nil || fleetId < 1 { - return errors.New("the fleet id is the number shown by fleet list") - } - - email := arguments[1] - - body, err := json.Marshal(map[string]string{"email": email}) - - if err != nil { - return err - } - - request, err := authenticatedRequest(http.MethodPost, - "/fleets/"+strconv.FormatInt(fleetId, 10)+"/owner", bytes.NewReader(body)) - - if err != nil { - return err - } - - request.Header.Set("Content-Type", "application/json") - - response, err := apiClient.Do(request) - - if err != nil { - return fmt.Errorf("the server could not be reached: %w", err) - } - - defer response.Body.Close() - - if response.StatusCode != http.StatusNoContent { - message, _ := io.ReadAll(io.LimitReader(response.Body, 4096)) - - return fmt.Errorf("the server said: %s", strings.TrimSpace(string(message))) - } - - fmt.Printf("Transferred the fleet to %s.\n", email) - - return nil -} diff --git a/internal/commands/fleet_transfer_test.go b/internal/commands/fleet_transfer_test.go deleted file mode 100644 index fdcbed1..0000000 --- a/internal/commands/fleet_transfer_test.go +++ /dev/null @@ -1,62 +0,0 @@ -package commands - -import ( - "encoding/json" - "net/http" - "strings" - "testing" -) - -func TestFleetTransfer(t *testing.T) { - transferredPath := "" - transferredTo := "" - - mux := http.NewServeMux() - - mux.HandleFunc("POST /fleets/{id}/owner", func(w http.ResponseWriter, r *http.Request) { - body := struct { - Email string `json:"email"` - }{} - - json.NewDecoder(r.Body).Decode(&body) - - transferredPath = r.URL.Path - transferredTo = body.Email - - w.WriteHeader(http.StatusNoContent) - }) - - loggedInTestServer(t, mux) - - err := FleetTransfer([]string{"3", "successor@example.com"}) - - if err != nil { - t.Fatal(err) - } - - if transferredPath != "/fleets/3/owner" || transferredTo != "successor@example.com" { - t.Errorf("the server saw %q handed to %q, want %q handed to %q", - transferredPath, transferredTo, "/fleets/3/owner", "successor@example.com") - } -} - -func TestFleetTransferArguments(t *testing.T) { - tests := []struct { - name string - arguments []string - wantError string - }{ - {"no arguments", nil, "takes a fleet id and an email address"}, - {"only an id", []string{"3"}, "takes a fleet id and an email address"}, - {"an empty address", []string{"3", ""}, "takes a fleet id and an email address"}, - {"a wordy id", []string{"pilot", "successor@example.com"}, "shown by fleet list"}, - } - - for _, test := range tests { - err := FleetTransfer(test.arguments) - - if err == nil || !strings.Contains(err.Error(), test.wantError) { - t.Errorf("%s: error = %v, want it to mention %q", test.name, err, test.wantError) - } - } -} diff --git a/internal/commands/fleets.go b/internal/commands/fleets.go deleted file mode 100644 index 6179c75..0000000 --- a/internal/commands/fleets.go +++ /dev/null @@ -1,47 +0,0 @@ -package commands - -import ( - "encoding/json" - "fmt" - "io" - "net/http" - "strings" -) - -type fleetEntry struct { - Id int64 `json:"id"` - Name string `json:"name"` - Owner bool `json:"owner"` -} - -func fetchFleets() ([]fleetEntry, error) { - request, err := authenticatedRequest(http.MethodGet, "/fleets", nil) - - if err != nil { - return nil, err - } - - response, err := apiClient.Do(request) - - if err != nil { - return nil, fmt.Errorf("the server could not be reached: %w", err) - } - - defer response.Body.Close() - - if response.StatusCode != http.StatusOK { - message, _ := io.ReadAll(io.LimitReader(response.Body, 4096)) - - return nil, fmt.Errorf("the server said: %s", strings.TrimSpace(string(message))) - } - - fleets := []fleetEntry{} - - err = json.NewDecoder(response.Body).Decode(&fleets) - - if err != nil { - return nil, err - } - - return fleets, nil -} diff --git a/internal/commands/fleets_test.go b/internal/commands/fleets_test.go deleted file mode 100644 index 7da86d2..0000000 --- a/internal/commands/fleets_test.go +++ /dev/null @@ -1,40 +0,0 @@ -package commands - -import ( - "net/http" - "os" - "strings" - "testing" -) - -func TestFetchFleetsNotLoggedIn(t *testing.T) { - isolateKeyStorage(t) - - _, err := fetchFleets() - - if err == nil || !strings.Contains(err.Error(), "not logged in") { - t.Fatalf("error = %v, want the not-logged-in hint", err) - } -} - -func TestFetchFleetsEmptyKeyFile(t *testing.T) { - loggedInTestServer(t, http.NotFoundHandler()) - - path, err := keyPath() - - if err != nil { - t.Fatal(err) - } - - err = os.WriteFile(path, []byte(" \n"), 0o600) - - if err != nil { - t.Fatal(err) - } - - _, err = fetchFleets() - - if err == nil || !strings.Contains(err.Error(), "not logged in") { - t.Fatalf("error = %v, want the not-logged-in hint for an empty key file", err) - } -} diff --git a/internal/commands/key_create.go b/internal/commands/key_create.go deleted file mode 100644 index 7131c7b..0000000 --- a/internal/commands/key_create.go +++ /dev/null @@ -1,69 +0,0 @@ -package commands - -import ( - "bytes" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "strconv" - "strings" -) - -func KeyCreate(arguments []string) error { - - if len(arguments) != 2 || arguments[1] == "" { - return errors.New("key create takes a fleet id and a label, quoted if it has spaces") - } - - fleetId, err := strconv.ParseInt(arguments[0], 10, 64) - - if err != nil || fleetId < 1 { - return errors.New("the fleet id is the number shown by fleet list") - } - - body, err := json.Marshal(map[string]string{"label": arguments[1]}) - - if err != nil { - return err - } - - request, err := authenticatedRequest(http.MethodPost, - "/fleets/"+strconv.FormatInt(fleetId, 10)+"/keys", bytes.NewReader(body)) - - if err != nil { - return err - } - - request.Header.Set("Content-Type", "application/json") - - response, err := apiClient.Do(request) - - if err != nil { - return fmt.Errorf("the server could not be reached: %w", err) - } - - defer response.Body.Close() - - if response.StatusCode != http.StatusOK { - message, _ := io.ReadAll(io.LimitReader(response.Body, 4096)) - - return fmt.Errorf("the server said: %s", strings.TrimSpace(string(message))) - } - - created := struct { - Id int64 `json:"id"` - Key string `json:"key"` - }{} - - err = json.NewDecoder(response.Body).Decode(&created) - - if err != nil { - return err - } - - fmt.Printf("Created key %d.\n\n %s\n\nAnyone holding it can send data to the fleet, and it is shown only this once.\n", created.Id, created.Key) - - return nil -} diff --git a/internal/commands/key_create_test.go b/internal/commands/key_create_test.go deleted file mode 100644 index 2dd82ac..0000000 --- a/internal/commands/key_create_test.go +++ /dev/null @@ -1,114 +0,0 @@ -package commands - -import ( - "encoding/json" - "fmt" - "net/http" - "strings" - "testing" -) - -func TestKeyCreate(t *testing.T) { - tests := []struct { - name string - arguments []string - wantPath string - wantLabel string - refusal string - wantError string - }{ - { - name: "a labelled key", - arguments: []string{"3", "deploy server"}, - wantPath: "/fleets/3/keys", - wantLabel: "deploy server", - }, - { - name: "no label", - arguments: []string{"3"}, - wantError: "takes a fleet id and a label", - }, - { - name: "an empty label", - arguments: []string{"3", ""}, - wantError: "takes a fleet id and a label", - }, - { - name: "no fleet id", - arguments: []string{}, - wantError: "takes a fleet id and a label", - }, - { - name: "too many words", - arguments: []string{"3", "deploy", "server"}, - wantError: "takes a fleet id and a label", - }, - { - name: "a wordy id", - arguments: []string{"pilot", "deploy server"}, - wantError: "shown by fleet list", - }, - { - name: "the server refuses", - arguments: []string{"9", "doomed"}, - wantPath: "/fleets/9/keys", - refusal: "no such fleet", - wantError: "the server said: no such fleet", - }, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - mux := http.NewServeMux() - - mux.HandleFunc("POST /fleets/{id}/keys", func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != test.wantPath { - t.Errorf("the request went to %s, want %s", r.URL.Path, test.wantPath) - } - - if test.refusal != "" { - http.Error(w, test.refusal, http.StatusNotFound) - return - } - - sent := struct { - Label string `json:"label"` - }{} - - err := json.NewDecoder(r.Body).Decode(&sent) - - if err != nil { - t.Errorf("the request body could not be decoded: %v", err) - } - - if sent.Label != test.wantLabel { - t.Errorf("the request carried label %q, want %q", sent.Label, test.wantLabel) - } - - fmt.Fprint(w, `{"id":1,"key":"ssf_testtesttestab2de"}`) - }) - - loggedInTestServer(t, mux) - - printed, err := captureStdout(t, func() error { - return KeyCreate(test.arguments) - }) - - if test.wantError != "" { - if err == nil || !strings.Contains(err.Error(), test.wantError) { - t.Fatalf("error = %v, want it to mention %q", err, test.wantError) - } - - return - } - - if err != nil { - t.Fatal(err) - } - - if !strings.Contains(printed, "ssf_testtesttestab2de") { - t.Errorf("the output %q does not show the key", printed) - } - }) - } -} diff --git a/internal/commands/key_list.go b/internal/commands/key_list.go deleted file mode 100644 index 295dd30..0000000 --- a/internal/commands/key_list.go +++ /dev/null @@ -1,102 +0,0 @@ -package commands - -import ( - "encoding/json" - "errors" - "fmt" - "os" - "strconv" -) - -func KeyList(arguments []string) error { - - jsonOutput := false - - positionals := []string{} - - for _, argument := range arguments { - if argument == "--json" { - jsonOutput = true - continue - } - - positionals = append(positionals, argument) - } - - if len(positionals) > 1 { - return errors.New("key list takes at most one fleet id") - } - - chosenFleetId := int64(0) - - if len(positionals) == 1 { - parsed, err := strconv.ParseInt(positionals[0], 10, 64) - - if err != nil || parsed < 1 { - return errors.New("the fleet id is the number shown by fleet list") - } - - chosenFleetId = parsed - } - - fleets, err := fetchFleets() - - if err != nil { - return err - } - - fleetNames := map[int64]string{} - - for _, fleet := range fleets { - fleetNames[fleet.Id] = fleet.Name - } - - if chosenFleetId != 0 { - if _, found := fleetNames[chosenFleetId]; !found { - return errors.New("no such fleet") - } - } - - fetched, err := fetchKeys() - - if err != nil { - return err - } - - keys := []keyEntry{} - - for _, key := range fetched { - if chosenFleetId == 0 || key.Fleet == chosenFleetId { - keys = append(keys, key) - } - } - - if jsonOutput { - return json.NewEncoder(os.Stdout).Encode(keys) - } - - if len(keys) == 0 { - fmt.Println("No keys yet. Create one with key create.") - return nil - } - - idWidth := len("ID") - fleetIdWidth := len("FLEET") - fleetNameWidth := len("FLEET NAME") - - for _, key := range keys { - idWidth = max(idWidth, len(strconv.FormatInt(key.Id, 10))) - fleetIdWidth = max(fleetIdWidth, len(strconv.FormatInt(key.Fleet, 10))) - fleetNameWidth = max(fleetNameWidth, len(fleetNames[key.Fleet])) - } - - fmt.Printf("%-*s %-*s %-*s %-8s %s\n", - idWidth, "ID", fleetIdWidth, "FLEET", fleetNameWidth, "FLEET NAME", "KEY", "LABEL") - - for _, key := range keys { - fmt.Printf("%-*d %-*d %-*s ...%s %s\n", - idWidth, key.Id, fleetIdWidth, key.Fleet, fleetNameWidth, fleetNames[key.Fleet], key.Suffix, key.Label) - } - - return nil -} diff --git a/internal/commands/key_list_test.go b/internal/commands/key_list_test.go deleted file mode 100644 index 709e485..0000000 --- a/internal/commands/key_list_test.go +++ /dev/null @@ -1,114 +0,0 @@ -package commands - -import ( - "fmt" - "net/http" - "strings" - "testing" -) - -func TestKeyList(t *testing.T) { - fleets := `[{"id":3,"name":"crew","owner":true},` + - `{"id":4,"name":"skunkworks","owner":false},` + - `{"id":5,"name":"spares","owner":true}]` - - keys := `[{"id":1,"fleet":3,"label":"deploy server","suffix":"ab2de"},` + - `{"id":2,"fleet":4,"label":"lab sensor","suffix":"f9hjk"}]` - - tests := []struct { - name string - arguments []string - wantShown []string - wantHidden []string - wantError string - }{ - { - name: "every fleet's keys", - arguments: []string{}, - wantShown: []string{"ID FLEET FLEET NAME", "crew", "skunkworks", "...ab2de", "...f9hjk", "deploy server", "lab sensor"}, - }, - { - name: "one fleet's keys", - arguments: []string{"3"}, - wantShown: []string{"ID FLEET FLEET NAME", "crew", "...ab2de"}, - wantHidden: []string{"skunkworks", "f9hjk", "lab sensor"}, - }, - { - name: "a fleet without keys", - arguments: []string{"5"}, - wantShown: []string{"No keys yet"}, - wantHidden: []string{"ID FLEET"}, - }, - { - name: "machine-readable output", - arguments: []string{"--json"}, - wantShown: []string{`"suffix":"ab2de"`, `"fleet":4`}, - wantHidden: []string{"ID FLEET"}, - }, - { - name: "the flag before the id", - arguments: []string{"--json", "3"}, - wantShown: []string{`"id":1`}, - wantHidden: []string{`"id":2`, "ID FLEET"}, - }, - { - name: "a fleet out of reach", - arguments: []string{"9"}, - wantError: "no such fleet", - }, - { - name: "two fleet ids", - arguments: []string{"3", "4"}, - wantError: "takes at most one fleet id", - }, - { - name: "a wordy id", - arguments: []string{"pilot"}, - wantError: "shown by fleet list", - }, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - mux := http.NewServeMux() - - mux.HandleFunc("GET /fleets", func(w http.ResponseWriter, r *http.Request) { - fmt.Fprint(w, fleets) - }) - - mux.HandleFunc("GET /keys", func(w http.ResponseWriter, r *http.Request) { - fmt.Fprint(w, keys) - }) - - loggedInTestServer(t, mux) - - printed, err := captureStdout(t, func() error { - return KeyList(test.arguments) - }) - - if test.wantError != "" { - if err == nil || !strings.Contains(err.Error(), test.wantError) { - t.Fatalf("error = %v, want it to mention %q", err, test.wantError) - } - - return - } - - if err != nil { - t.Fatal(err) - } - - for _, want := range test.wantShown { - if !strings.Contains(printed, want) { - t.Errorf("the output %q leaves out %q", printed, want) - } - } - - for _, hidden := range test.wantHidden { - if strings.Contains(printed, hidden) { - t.Errorf("the output %q shows %q, want it filtered out", printed, hidden) - } - } - }) - } -} diff --git a/internal/commands/key_revoke.go b/internal/commands/key_revoke.go deleted file mode 100644 index edf4364..0000000 --- a/internal/commands/key_revoke.go +++ /dev/null @@ -1,81 +0,0 @@ -package commands - -import ( - "bufio" - "errors" - "fmt" - "io" - "net/http" - "os" - "strconv" - "strings" -) - -func KeyRevoke(arguments []string) error { - - if len(arguments) != 1 { - return errors.New("key revoke takes a key id") - } - - keyId, err := strconv.ParseInt(arguments[0], 10, 64) - - if err != nil || keyId < 1 { - return errors.New("the key id is the number shown by key list") - } - - keys, err := fetchKeys() - - if err != nil { - return err - } - - label := "" - found := false - - for _, key := range keys { - if key.Id == keyId { - label = key.Label - found = true - } - } - - if !found { - return errors.New("no such key") - } - - fmt.Printf("Revoke %q? Anything still using it stops reaching the fleet. [y/N] ", label) - - answer, _ := bufio.NewReader(os.Stdin).ReadString('\n') - - answer = strings.ToLower(strings.TrimSpace(answer)) - - if answer != "y" && answer != "yes" { - fmt.Println("Nothing revoked.") - return nil - } - - request, err := authenticatedRequest(http.MethodDelete, - "/keys/"+strconv.FormatInt(keyId, 10), nil) - - if err != nil { - return err - } - - response, err := apiClient.Do(request) - - if err != nil { - return fmt.Errorf("the server could not be reached: %w", err) - } - - defer response.Body.Close() - - if response.StatusCode != http.StatusNoContent { - message, _ := io.ReadAll(io.LimitReader(response.Body, 4096)) - - return fmt.Errorf("the server said: %s", strings.TrimSpace(string(message))) - } - - fmt.Printf("Revoked key %d.\n", keyId) - - return nil -} diff --git a/internal/commands/key_revoke_test.go b/internal/commands/key_revoke_test.go deleted file mode 100644 index 37ac2f3..0000000 --- a/internal/commands/key_revoke_test.go +++ /dev/null @@ -1,121 +0,0 @@ -package commands - -import ( - "fmt" - "net/http" - "strings" - "testing" -) - -func TestKeyRevoke(t *testing.T) { - tests := []struct { - name string - arguments []string - answer string - refusal string - wantRevoked string - wantShown string - wantError string - }{ - { - name: "revoke a key", - arguments: []string{"3"}, - answer: "y\n", - wantRevoked: "/keys/3", - wantShown: "production", - }, - { - name: "declined by default", - arguments: []string{"3"}, - answer: "\n", - wantShown: "Nothing revoked", - }, - { - name: "declined with n", - arguments: []string{"3"}, - answer: "n\n", - wantShown: "Nothing revoked", - }, - { - name: "closed input", - arguments: []string{"3"}, - wantShown: "Nothing revoked", - }, - { - name: "the server refuses after the confirmation", - arguments: []string{"3"}, - answer: "y\n", - refusal: "no such key", - wantRevoked: "/keys/3", - wantError: "the server said: no such key", - }, - { - name: "a key that is not yours", - arguments: []string{"9"}, - answer: "y\n", - wantError: "no such key", - }, - { - name: "no key id", - arguments: []string{}, - wantError: "takes a key id", - }, - { - name: "two key ids", - arguments: []string{"3", "4"}, - wantError: "takes a key id", - }, - { - name: "a wordy id", - arguments: []string{"pilot"}, - wantError: "shown by key list", - }, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - revokedPath := "" - - mux := http.NewServeMux() - - mux.HandleFunc("GET /keys", func(w http.ResponseWriter, r *http.Request) { - fmt.Fprint(w, `[{"id":3,"fleet":1,"label":"production","suffix":"a1b2c"}]`) - }) - - mux.HandleFunc("DELETE /keys/{id}", func(w http.ResponseWriter, r *http.Request) { - revokedPath = r.URL.Path - - if test.refusal != "" { - http.Error(w, test.refusal, http.StatusNotFound) - return - } - - w.WriteHeader(http.StatusNoContent) - }) - - loggedInTestServer(t, mux) - - answerOnStdin(t, test.answer) - - printed, err := captureStdout(t, func() error { - return KeyRevoke(test.arguments) - }) - - if test.wantError != "" { - if err == nil || !strings.Contains(err.Error(), test.wantError) { - t.Fatalf("error = %v, want it to mention %q", err, test.wantError) - } - } else if err != nil { - t.Fatal(err) - } - - if revokedPath != test.wantRevoked { - t.Errorf("the server saw %q revoked, want %q", revokedPath, test.wantRevoked) - } - - if test.wantShown != "" && !strings.Contains(printed, test.wantShown) { - t.Errorf("the output %q does not show %q", printed, test.wantShown) - } - }) - } -} diff --git a/internal/commands/keys.go b/internal/commands/keys.go deleted file mode 100644 index 6b64800..0000000 --- a/internal/commands/keys.go +++ /dev/null @@ -1,48 +0,0 @@ -package commands - -import ( - "encoding/json" - "fmt" - "io" - "net/http" - "strings" -) - -type keyEntry struct { - Id int64 `json:"id"` - Fleet int64 `json:"fleet"` - Label string `json:"label"` - Suffix string `json:"suffix"` -} - -func fetchKeys() ([]keyEntry, error) { - request, err := authenticatedRequest(http.MethodGet, "/keys", nil) - - if err != nil { - return nil, err - } - - response, err := apiClient.Do(request) - - if err != nil { - return nil, fmt.Errorf("the server could not be reached: %w", err) - } - - defer response.Body.Close() - - if response.StatusCode != http.StatusOK { - message, _ := io.ReadAll(io.LimitReader(response.Body, 4096)) - - return nil, fmt.Errorf("the server said: %s", strings.TrimSpace(string(message))) - } - - keys := []keyEntry{} - - err = json.NewDecoder(response.Body).Decode(&keys) - - if err != nil { - return nil, err - } - - return keys, nil -} diff --git a/internal/commands/logout.go b/internal/commands/logout.go deleted file mode 100644 index 93dd681..0000000 --- a/internal/commands/logout.go +++ /dev/null @@ -1,75 +0,0 @@ -package commands - -import ( - "errors" - "fmt" - "io" - "io/fs" - "net/http" - "os" - "strings" -) - -func Logout(arguments []string) error { - - if len(arguments) != 0 { - return errors.New("logout takes no arguments") - } - - path, err := keyPath() - - if err != nil { - return err - } - - keyBytes, err := os.ReadFile(path) - - if errors.Is(err, fs.ErrNotExist) { - fmt.Println("Not logged in.") - return nil - } - - if err != nil { - return err - } - - // Revoke on the server first, keeping the key on any failure so another - // logout can retry; a forgotten key can never be revoked - revokeRequest, err := apiRequest(http.MethodPost, "/logout", nil) - - if err != nil { - return err - } - - revokeRequest.Header.Set("Authorization", "Bearer "+strings.TrimSpace(string(keyBytes))) - - revokeResponse, err := apiClient.Do(revokeRequest) - - if err != nil { - return fmt.Errorf("you are still logged in: the server could not be reached: %w", err) - } - - defer revokeResponse.Body.Close() - - if revokeResponse.StatusCode != http.StatusNoContent { - message, _ := io.ReadAll(io.LimitReader(revokeResponse.Body, 4096)) - - detail := strings.TrimSpace(string(message)) - - if detail == "" { - detail = revokeResponse.Status - } - - return fmt.Errorf("you are still logged in: the server said: %s", detail) - } - - err = os.Remove(path) - - if err != nil { - return err - } - - fmt.Println("Logged out.") - - return nil -} diff --git a/internal/commands/logout_test.go b/internal/commands/logout_test.go deleted file mode 100644 index 12f6205..0000000 --- a/internal/commands/logout_test.go +++ /dev/null @@ -1,123 +0,0 @@ -package commands - -import ( - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "strings" - "testing" -) - -func TestLogout(t *testing.T) { - tests := []struct { - name string - storedKey string - serverDown bool - revokeStatus int - wantError string - wantRevocation bool - wantKeyKept bool - }{ - { - name: "revokes and forgets the stored key", - storedKey: "ssk_test", - revokeStatus: http.StatusNoContent, - wantRevocation: true, - }, - { - name: "nothing stored", - }, - { - name: "server refuses the revocation", - storedKey: "ssk_test", - revokeStatus: http.StatusServiceUnavailable, - wantError: "still logged in", - wantRevocation: true, - wantKeyKept: true, - }, - { - name: "server unreachable", - storedKey: "ssk_test", - serverDown: true, - wantError: "still logged in", - wantKeyKept: true, - }, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - isolateKeyStorage(t) - - revokedKey := "" - - mux := http.NewServeMux() - - mux.HandleFunc("POST /logout", func(w http.ResponseWriter, r *http.Request) { - revokedKey = strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") - - w.WriteHeader(test.revokeStatus) - }) - - server := httptest.NewServer(mux) - - defer server.Close() - - if test.serverDown { - server.Close() - } - - chosenApiBase = server.URL - - t.Cleanup(func() { chosenApiBase = "" }) - - path, err := keyPath() - - if err != nil { - t.Fatal(err) - } - - if test.storedKey != "" { - err = os.MkdirAll(filepath.Dir(path), 0o700) - - if err != nil { - t.Fatal(err) - } - - err = os.WriteFile(path, []byte(test.storedKey+"\n"), 0o600) - - if err != nil { - t.Fatal(err) - } - } - - err = Logout(nil) - - if test.wantError != "" { - if err == nil || !strings.Contains(err.Error(), test.wantError) { - t.Fatalf("error = %v, want it to mention %q", err, test.wantError) - } - } else if err != nil { - t.Fatal(err) - } - - if test.wantRevocation && revokedKey != test.storedKey { - t.Errorf("the server saw %q revoked, want %q", revokedKey, test.storedKey) - } - - if !test.wantRevocation && revokedKey != "" { - t.Errorf("the server saw a revocation for %q, want none", revokedKey) - } - - _, statError := os.Stat(path) - - if test.wantKeyKept && statError != nil { - t.Error("the stored key is gone although the revocation failed") - } - - if !test.wantKeyKept && !os.IsNotExist(statError) { - t.Error("the stored key still exists after logout") - } - }) - } -} diff --git a/internal/commands/member_add.go b/internal/commands/member_add.go deleted file mode 100644 index c21b2e9..0000000 --- a/internal/commands/member_add.go +++ /dev/null @@ -1,60 +0,0 @@ -package commands - -import ( - "bytes" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "strconv" - "strings" -) - -func MemberAdd(arguments []string) error { - - if len(arguments) != 2 || arguments[0] == "" { - return errors.New("member add takes an email address and a fleet id") - } - - email := arguments[0] - - 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") - } - - body, err := json.Marshal(map[string]string{"email": email}) - - if err != nil { - return err - } - - request, err := authenticatedRequest(http.MethodPost, - "/fleets/"+strconv.FormatInt(fleetId, 10)+"/members", bytes.NewReader(body)) - - if err != nil { - return err - } - - request.Header.Set("Content-Type", "application/json") - - response, err := apiClient.Do(request) - - if err != nil { - return fmt.Errorf("the server could not be reached: %w", err) - } - - defer response.Body.Close() - - if response.StatusCode != http.StatusNoContent { - message, _ := io.ReadAll(io.LimitReader(response.Body, 4096)) - - return fmt.Errorf("the server said: %s", strings.TrimSpace(string(message))) - } - - fmt.Printf("Gave %s access.\n", email) - - return nil -} diff --git a/internal/commands/member_add_test.go b/internal/commands/member_add_test.go deleted file mode 100644 index 91a8030..0000000 --- a/internal/commands/member_add_test.go +++ /dev/null @@ -1,62 +0,0 @@ -package commands - -import ( - "encoding/json" - "net/http" - "strings" - "testing" -) - -func TestMemberAdd(t *testing.T) { - addedPath := "" - addedEmail := "" - - mux := http.NewServeMux() - - mux.HandleFunc("POST /fleets/{id}/members", func(w http.ResponseWriter, r *http.Request) { - body := struct { - Email string `json:"email"` - }{} - - json.NewDecoder(r.Body).Decode(&body) - - addedPath = r.URL.Path - addedEmail = body.Email - - w.WriteHeader(http.StatusNoContent) - }) - - loggedInTestServer(t, mux) - - err := MemberAdd([]string{"member@example.com", "3"}) - - if err != nil { - t.Fatal(err) - } - - if addedPath != "/fleets/3/members" || addedEmail != "member@example.com" { - t.Errorf("the server saw %q added at %q, want %q at %q", - addedEmail, addedPath, "member@example.com", "/fleets/3/members") - } -} - -func TestMemberAddArguments(t *testing.T) { - tests := []struct { - name string - arguments []string - wantError string - }{ - {"no arguments", nil, "takes an email address and a fleet id"}, - {"only an address", []string{"member@example.com"}, "takes an email address and a fleet id"}, - {"an empty address", []string{"", "3"}, "takes an email address and a fleet id"}, - {"a wordy id", []string{"member@example.com", "pilot"}, "shown by fleet list"}, - } - - for _, test := range tests { - err := MemberAdd(test.arguments) - - if err == nil || !strings.Contains(err.Error(), test.wantError) { - t.Errorf("%s: error = %v, want it to mention %q", test.name, err, test.wantError) - } - } -} diff --git a/internal/commands/member_list.go b/internal/commands/member_list.go deleted file mode 100644 index bc79cdd..0000000 --- a/internal/commands/member_list.go +++ /dev/null @@ -1,94 +0,0 @@ -package commands - -import ( - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "os" - "strconv" - "strings" -) - -func MemberList(arguments []string) error { - - jsonOutput := false - - positionals := []string{} - - for _, argument := range arguments { - if argument == "--json" { - jsonOutput = true - continue - } - - positionals = append(positionals, argument) - } - - if len(positionals) != 1 { - return errors.New("member list takes a fleet id") - } - - fleetId, err := strconv.ParseInt(positionals[0], 10, 64) - - if err != nil || fleetId < 1 { - return errors.New("the fleet id is the number shown by fleet list") - } - - request, err := authenticatedRequest(http.MethodGet, - "/fleets/"+strconv.FormatInt(fleetId, 10)+"/members", nil) - - if err != nil { - return err - } - - response, err := apiClient.Do(request) - - if err != nil { - return fmt.Errorf("the server could not be reached: %w", err) - } - - defer response.Body.Close() - - body, err := io.ReadAll(response.Body) - - if err != nil { - return err - } - - if response.StatusCode != http.StatusOK { - return fmt.Errorf("the server said: %s", strings.TrimSpace(string(body))) - } - - people := struct { - Owner string `json:"owner"` - Members []string `json:"members"` - }{} - - err = json.Unmarshal(body, &people) - - if err != nil { - return err - } - - if jsonOutput { - return json.NewEncoder(os.Stdout).Encode(people) - } - - emailWidth := max(len("EMAIL"), len(people.Owner)) - - for _, email := range people.Members { - emailWidth = max(emailWidth, len(email)) - } - - fmt.Printf("%-*s %s\n", emailWidth, "EMAIL", "ROLE") - - fmt.Printf("%-*s owner\n", emailWidth, people.Owner) - - for _, email := range people.Members { - fmt.Printf("%-*s member\n", emailWidth, email) - } - - return nil -} diff --git a/internal/commands/member_list_test.go b/internal/commands/member_list_test.go deleted file mode 100644 index 9867d1b..0000000 --- a/internal/commands/member_list_test.go +++ /dev/null @@ -1,110 +0,0 @@ -package commands - -import ( - "fmt" - "net/http" - "strings" - "testing" -) - -func TestMemberList(t *testing.T) { - tests := []struct { - name string - arguments []string - people string - wantFleet string - wantShown []string - wantExact string - wantError string - }{ - { - name: "the people table", - arguments: []string{"3"}, - people: `{"owner":"owner@example.com","members":["member@example.com"]}`, - wantFleet: "3", - wantShown: []string{"EMAIL", "ROLE", "owner@example.com", "owner", "member@example.com", "member"}, - }, - { - name: "nobody but the owner", - arguments: []string{"7"}, - people: `{"owner":"owner@example.com","members":[]}`, - wantFleet: "7", - wantShown: []string{"owner@example.com", "owner"}, - }, - { - name: "machine-readable output", - arguments: []string{"3", "--json"}, - people: `{"owner":"owner@example.com","members":["member@example.com"]}`, - wantFleet: "3", - wantExact: `{"owner":"owner@example.com","members":["member@example.com"]}` + "\n", - }, - { - name: "the flag before the id", - arguments: []string{"--json", "5"}, - people: `{"owner":"owner@example.com","members":[]}`, - wantFleet: "5", - wantExact: `{"owner":"owner@example.com","members":[]}` + "\n", - }, - { - name: "no fleet id", - arguments: []string{"--json"}, - wantError: "takes a fleet id", - }, - { - name: "two fleet ids", - arguments: []string{"3", "4"}, - wantError: "takes a fleet id", - }, - { - name: "a wordy id", - arguments: []string{"pilot"}, - wantError: "shown by fleet list", - }, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - mux := http.NewServeMux() - - askedFleet := "" - - mux.HandleFunc("GET /fleets/{id}/members", func(w http.ResponseWriter, r *http.Request) { - askedFleet = r.PathValue("id") - - fmt.Fprint(w, test.people) - }) - - loggedInTestServer(t, mux) - - printed, err := captureStdout(t, func() error { - return MemberList(test.arguments) - }) - - if test.wantError != "" { - if err == nil || !strings.Contains(err.Error(), test.wantError) { - t.Fatalf("error = %v, want it to mention %q", err, test.wantError) - } - - return - } - - if err != nil { - t.Fatal(err) - } - - if askedFleet != test.wantFleet { - t.Errorf("the people of fleet %q were listed, want fleet %q", askedFleet, test.wantFleet) - } - - if test.wantExact != "" && printed != test.wantExact { - t.Errorf("the output is %q, want exactly %q", printed, test.wantExact) - } - - for _, want := range test.wantShown { - if !strings.Contains(printed, want) { - t.Errorf("the output %q does not show %q", printed, want) - } - } - }) - } -} diff --git a/internal/commands/member_remove.go b/internal/commands/member_remove.go deleted file mode 100644 index 1b81a37..0000000 --- a/internal/commands/member_remove.go +++ /dev/null @@ -1,84 +0,0 @@ -package commands - -import ( - "bufio" - "errors" - "fmt" - "io" - "net/http" - "net/url" - "os" - "strconv" - "strings" -) - -func MemberRemove(arguments []string) error { - - if len(arguments) != 2 || arguments[0] == "" { - return errors.New("member remove takes an email address and a fleet id") - } - - email := arguments[0] - - 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 := fetchFleets() - - if err != nil { - return err - } - - name := "" - found := false - - for _, fleet := range fleets { - if fleet.Id == fleetId { - name = fleet.Name - found = true - } - } - - if !found { - return errors.New("no such fleet") - } - - fmt.Printf("Take away %s's access to %q? [y/N] ", email, name) - - answer, _ := bufio.NewReader(os.Stdin).ReadString('\n') - - answer = strings.ToLower(strings.TrimSpace(answer)) - - if answer != "y" && answer != "yes" { - fmt.Println("Nothing changed.") - return nil - } - - request, err := authenticatedRequest(http.MethodDelete, - "/fleets/"+strconv.FormatInt(fleetId, 10)+"/members/"+url.PathEscape(email), nil) - - if err != nil { - return err - } - - response, err := apiClient.Do(request) - - if err != nil { - return fmt.Errorf("the server could not be reached: %w", err) - } - - defer response.Body.Close() - - if response.StatusCode != http.StatusNoContent { - message, _ := io.ReadAll(io.LimitReader(response.Body, 4096)) - - return fmt.Errorf("the server said: %s", strings.TrimSpace(string(message))) - } - - fmt.Printf("Removed access for %s.\n", email) - - return nil -} diff --git a/internal/commands/member_remove_test.go b/internal/commands/member_remove_test.go deleted file mode 100644 index 1d4f34b..0000000 --- a/internal/commands/member_remove_test.go +++ /dev/null @@ -1,91 +0,0 @@ -package commands - -import ( - "fmt" - "net/http" - "strings" - "testing" -) - -func TestMemberRemove(t *testing.T) { - tests := []struct { - name string - email string - answer string - wantRemoved bool - wantShown string - }{ - {name: "a plain address", email: "member@example.com", answer: "y\n", wantRemoved: true}, - {name: "an address with a hash", email: "a#b@example.com", answer: "yes\n", wantRemoved: true}, - {name: "the prompt names the fleet", email: "member@example.com", answer: "y\n", wantRemoved: true, wantShown: `access to "pilot"`}, - {name: "declined by default", email: "member@example.com", answer: "\n", wantShown: "Nothing changed"}, - {name: "declined with n", email: "member@example.com", answer: "n\n", wantShown: "Nothing changed"}, - {name: "closed input", email: "member@example.com", wantShown: "Nothing changed"}, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - removedFleet := "" - removedEmail := "" - - mux := http.NewServeMux() - - mux.HandleFunc("GET /fleets", func(w http.ResponseWriter, r *http.Request) { - fmt.Fprint(w, `[{"id":3,"name":"pilot","owner":true}]`) - }) - - mux.HandleFunc("DELETE /fleets/{id}/members/{email}", func(w http.ResponseWriter, r *http.Request) { - removedFleet = r.PathValue("id") - removedEmail = r.PathValue("email") - - w.WriteHeader(http.StatusNoContent) - }) - - loggedInTestServer(t, mux) - - answerOnStdin(t, test.answer) - - printed, err := captureStdout(t, func() error { - return MemberRemove([]string{test.email, "3"}) - }) - - if err != nil { - t.Fatal(err) - } - - switch { - case test.wantRemoved && (removedFleet != "3" || removedEmail != test.email): - t.Errorf("the server saw %q removed from fleet %q, want %q from fleet %q", - removedEmail, removedFleet, test.email, "3") - - case !test.wantRemoved && removedEmail != "": - t.Errorf("the server saw %q removed although the confirmation was declined", removedEmail) - } - - if test.wantShown != "" && !strings.Contains(printed, test.wantShown) { - t.Errorf("the output %q does not show %q", printed, test.wantShown) - } - }) - } -} - -func TestMemberRemoveArguments(t *testing.T) { - tests := []struct { - name string - arguments []string - wantError string - }{ - {"no arguments", nil, "takes an email address and a fleet id"}, - {"only an address", []string{"member@example.com"}, "takes an email address and a fleet id"}, - {"an empty address", []string{"", "3"}, "takes an email address and a fleet id"}, - {"a wordy id", []string{"member@example.com", "pilot"}, "shown by fleet list"}, - } - - for _, test := range tests { - err := MemberRemove(test.arguments) - - if err == nil || !strings.Contains(err.Error(), test.wantError) { - t.Errorf("%s: error = %v, want it to mention %q", test.name, err, test.wantError) - } - } -} diff --git a/internal/device/device.go b/internal/device/device.go new file mode 100644 index 0000000..1edbe68 --- /dev/null +++ b/internal/device/device.go @@ -0,0 +1,385 @@ +package device + +import ( + "bufio" + "bytes" + "encoding/json" + "errors" + "fmt" + "net/http" + "strconv" + "strings" + "time" + + "github.com/siliconwitchery/superstack-cli/internal/api" + "github.com/siliconwitchery/superstack-cli/internal/dispatch" +) + +func Claim(session api.Session, arguments []string) error { + claimClient := &http.Client{Timeout: 90 * time.Second} + + if len(arguments) != 2 && len(arguments) != 3 { + return errors.New("device claim takes an IMEI, a fleet id, and an optional name") + } + + imei := arguments[0] + + if !api.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(session) + + 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") + } + + fmt.Fprintln(session.Out, "Press the pairing button on the device to finish claiming it.") + + payload := map[string]string{"imei": imei} + + if len(arguments) == 3 { + payload["name"] = arguments[2] + } + + body, err := json.Marshal(payload) + + if err != nil { + return err + } + + request, err := api.AuthenticatedRequest(session, http.MethodPost, + "/fleets/"+strconv.FormatInt(fleetId, 10)+"/devices", bytes.NewReader(body)) + + if err != nil { + return err + } + + request.Header.Set("Content-Type", "application/json") + + response, err := claimClient.Do(request) + + if err != nil { + return errors.New("the server could not be reached, check your connection") + } + + defer response.Body.Close() + + if response.StatusCode != http.StatusNoContent { + return api.ServerError(response) + } + + fmt.Fprintf(session.Out, "Claimed the device into %q.\n", fleetName) + + return nil +} + +func List(session api.Session, arguments []string) error { + positionals, jsonOutput := dispatch.TakeJsonFlag(arguments) + + if len(positionals) > 1 { + return errors.New("device list takes at most one fleet id") + } + + chosenFleetId := int64(0) + + if len(positionals) == 1 { + parsed, err := strconv.ParseInt(positionals[0], 10, 64) + + if err != nil || parsed < 1 { + return errors.New("the fleet id is the number shown by fleet list") + } + + chosenFleetId = parsed + } + + devices, err := api.FetchDevices(session) + + if err != nil { + return err + } + + fleets, err := api.FetchFleets(session) + + if err != nil { + return err + } + + fleetNames := map[int64]string{} + + for _, fleet := range fleets { + fleetNames[fleet.Id] = fleet.Name + } + + if chosenFleetId != 0 { + if _, found := fleetNames[chosenFleetId]; !found { + return errors.New("no such fleet") + } + } + + filtered := []api.DeviceEntry{} + + for _, device := range devices { + if chosenFleetId == 0 || device.FleetId == chosenFleetId { + filtered = append(filtered, device) + } + } + + if jsonOutput { + return json.NewEncoder(session.Out).Encode(filtered) + } + + if len(filtered) == 0 { + if chosenFleetId == 0 { + fmt.Fprintln(session.Out, "No devices yet. Claim one with device claim.") + } else { + fmt.Fprintln(session.Out, "No devices in that fleet.") + } + + return nil + } + + imeiWidth := len("IMEI") + nameWidth := len("NAME") + fleetWidth := len("FLEET") + stateWidth := len("STATE") + storageWidth := len("STORAGE") + imeiValues := make([]string, len(filtered)) + nameValues := make([]string, len(filtered)) + fleetValues := make([]string, len(filtered)) + stateValues := make([]string, len(filtered)) + storageValues := make([]string, len(filtered)) + lastSeenValues := make([]string, len(filtered)) + + for index, device := range filtered { + name := "-" + + if device.Name != nil { + name = *device.Name + } + + lastSeen := "never" + + if device.LastSeenAt != nil { + seenAt, err := time.Parse(time.RFC3339, *device.LastSeenAt) + + if err != nil { + return err + } + + age := time.Since(seenAt) + + switch { + case age < 2*time.Minute: + lastSeen = "just now" + case age < time.Hour: + lastSeen = fmt.Sprintf("%d min ago", int(age.Minutes())) + case age < 24*time.Hour: + lastSeen = fmt.Sprintf("%d h ago", int(age.Hours())) + default: + lastSeen = fmt.Sprintf("%d d ago", int(age.Hours()/24)) + } + } + + state := "unknown" + + if device.ReportedState != nil { + switch *device.ReportedState { + case 2: + state = "running" + case 3: + state = "stopped" + case 4: + state = "crashed" + } + } + + storage := "-" + + if device.StorageUsed != nil && device.StorageTotal != nil { + formatBytes := func(bytes int64) string { + switch { + case bytes < 1000: + return fmt.Sprintf("%d B", bytes) + case bytes < 1000*1000: + return fmt.Sprintf("%.1f kB", float64(bytes)/1000) + default: + return fmt.Sprintf("%.1f MB", float64(bytes)/(1000*1000)) + } + } + + storage = fmt.Sprintf("%s of %s", formatBytes(*device.StorageUsed), formatBytes(*device.StorageTotal)) + } + + imeiValues[index] = device.Imei + nameValues[index] = name + fleetValues[index] = fleetNames[device.FleetId] + stateValues[index] = state + storageValues[index] = storage + lastSeenValues[index] = lastSeen + imeiWidth = max(imeiWidth, len(imeiValues[index])) + nameWidth = max(nameWidth, len(nameValues[index])) + fleetWidth = max(fleetWidth, len(fleetValues[index])) + stateWidth = max(stateWidth, len(stateValues[index])) + storageWidth = max(storageWidth, len(storageValues[index])) + } + + fmt.Fprintf(session.Out, "%-*s %-*s %-*s %-*s %-*s %s\n", + imeiWidth, "IMEI", nameWidth, "NAME", fleetWidth, "FLEET", + stateWidth, "STATE", storageWidth, "STORAGE", "LAST SEEN") + + for index := range filtered { + fmt.Fprintf(session.Out, "%-*s %-*s %-*s %-*s %-*s %s\n", + imeiWidth, imeiValues[index], nameWidth, nameValues[index], fleetWidth, fleetValues[index], + stateWidth, stateValues[index], storageWidth, storageValues[index], lastSeenValues[index]) + } + + return nil +} + +func Rename(session api.Session, arguments []string) error { + if len(arguments) != 2 { + return errors.New("device rename takes an IMEI and a new name, quoted if it has spaces") + } + + imei := arguments[0] + + if !api.ValidImei(imei) { + return errors.New("the IMEI is the 15-digit number printed on the device") + } + + name := strings.TrimSpace(arguments[1]) + + if name == "" { + return errors.New("device rename takes an IMEI and a new name, quoted if it has spaces") + } + + body, err := json.Marshal(map[string]string{"name": name}) + + if err != nil { + return err + } + + request, err := api.AuthenticatedRequest(session, http.MethodPatch, "/devices/"+imei, bytes.NewReader(body)) + + if err != nil { + return err + } + + request.Header.Set("Content-Type", "application/json") + + response, err := session.Client.Do(request) + + if err != nil { + return errors.New("the server could not be reached, check your connection") + } + + defer response.Body.Close() + + if response.StatusCode != http.StatusNoContent { + return api.ServerError(response) + } + + fmt.Fprintf(session.Out, "Renamed the device to %q.\n", name) + + return nil +} + +func Release(session api.Session, arguments []string) error { + if len(arguments) != 1 { + return errors.New("device release takes an IMEI") + } + + imei := arguments[0] + + if !api.ValidImei(imei) { + return errors.New("the IMEI is the 15-digit number printed on the device") + } + + devices, err := api.FetchDevices(session) + + if err != nil { + return err + } + + fleetId := int64(0) + + for _, device := range devices { + if device.Imei == imei { + fleetId = device.FleetId + } + } + + if fleetId == 0 { + return errors.New("no such device, device list shows yours") + } + + fleets, err := api.FetchFleets(session) + + if err != nil { + return err + } + + fleetName := "" + + for _, fleet := range fleets { + if fleet.Id == fleetId { + fleetName = fleet.Name + } + } + + if fleetName == "" { + return errors.New("no such device, device list shows yours") + } + + fmt.Fprintf(session.Out, "Release the device from %q? It erases everything on the device, and claiming it again means pressing its pairing button in person. [y/N] ", fleetName) + + answer, _ := bufio.NewReader(session.In).ReadString('\n') + + answer = strings.ToLower(strings.TrimSpace(answer)) + + if answer != "y" && answer != "yes" { + fmt.Fprintln(session.Out, "Nothing released.") + return nil + } + + request, err := api.AuthenticatedRequest(session, http.MethodDelete, "/devices/"+imei, nil) + + if err != nil { + return err + } + + response, err := session.Client.Do(request) + + if err != nil { + return errors.New("the server could not be reached, check your connection") + } + + defer response.Body.Close() + + if response.StatusCode != http.StatusNoContent { + return api.ServerError(response) + } + + fmt.Fprintln(session.Out, "Released the device.") + + return nil +} diff --git a/internal/device/device_test.go b/internal/device/device_test.go new file mode 100644 index 0000000..a33bf42 --- /dev/null +++ b/internal/device/device_test.go @@ -0,0 +1,444 @@ +package device + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + "testing" + "time" + + "github.com/siliconwitchery/superstack-cli/internal/api" + "github.com/siliconwitchery/superstack-cli/internal/api/apitest" +) + +func TestDeviceClaim(t *testing.T) { + tests := []struct { + name string + statusCode int + message string + wantOutput string + wantError string + }{ + { + name: "button pressed", + statusCode: http.StatusNoContent, + wantOutput: "Press the pairing button on the device to finish claiming it.\nClaimed the device into \"pilot\".\n", + }, + { + name: "button not pressed", + statusCode: http.StatusRequestTimeout, + message: "the button was not pressed in time", + wantOutput: "Press the pairing button on the device to finish claiming it.\n", + wantError: "the button was not pressed in time", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + claimedImei := "" + claimedName := "" + + mux := http.NewServeMux() + mux.HandleFunc("GET /fleets", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `[{"id":3,"name":"pilot","owner":true}]`) + }) + mux.HandleFunc("POST /fleets/{id}/devices", func(w http.ResponseWriter, r *http.Request) { + body := struct { + Imei string `json:"imei"` + Name string `json:"name"` + }{} + + json.NewDecoder(r.Body).Decode(&body) + claimedImei = body.Imei + claimedName = body.Name + + if r.Header.Get("Content-Type") != "application/json" { + t.Errorf("Content-Type = %q, want application/json", r.Header.Get("Content-Type")) + } + + if test.message != "" { + http.Error(w, test.message, test.statusCode) + + return + } + + w.WriteHeader(test.statusCode) + }) + + session, out := apitest.LoggedInSession(t, mux) + + err := Claim(session, []string{"354820091234567", "3", "roof sensor"}) + + printed := out.String() + + if test.wantError == "" && err != nil { + t.Fatal(err) + } + + if test.wantError != "" && (err == nil || err.Error() != test.wantError) { + t.Fatalf("error = %v, want %q", err, test.wantError) + } + + if claimedImei != "354820091234567" || claimedName != "roof sensor" { + t.Errorf("the server received IMEI %q and name %q", claimedImei, claimedName) + } + + if printed != test.wantOutput { + t.Errorf("output = %q, want %q", printed, test.wantOutput) + } + }) + } +} + +func TestDeviceClaimOmitsAnAbsentName(t *testing.T) { + nameWasPresent := false + + mux := http.NewServeMux() + mux.HandleFunc("GET /fleets", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `[{"id":3,"name":"pilot","owner":true}]`) + }) + mux.HandleFunc("POST /fleets/{id}/devices", func(w http.ResponseWriter, r *http.Request) { + body := map[string]string{} + json.NewDecoder(r.Body).Decode(&body) + _, nameWasPresent = body["name"] + w.WriteHeader(http.StatusNoContent) + }) + + session, out := apitest.LoggedInSession(t, mux) + + err := Claim(session, []string{"354820091234567", "3"}) + + if err != nil { + t.Fatal(err) + } + + if nameWasPresent { + t.Error("the request included a name although none was given") + } + + if out.String() != "Press the pairing button on the device to finish claiming it.\nClaimed the device into \"pilot\".\n" { + t.Errorf("output = %q", out.String()) + } +} + +func TestDeviceClaimArguments(t *testing.T) { + tests := []struct { + name string + arguments []string + wantError string + }{ + {"no arguments", nil, "takes an IMEI"}, + {"too many arguments", []string{"354820091234567", "3", "one", "two"}, "takes an IMEI"}, + {"short IMEI", []string{"123", "3"}, "15-digit"}, + {"non-digit IMEI", []string{"35482009123456x", "3"}, "15-digit"}, + {"wordy fleet", []string{"354820091234567", "pilot"}, "shown by fleet list"}, + {"zero fleet", []string{"354820091234567", "0"}, "shown by fleet list"}, + } + + for _, test := range tests { + err := Claim(api.Session{}, test.arguments) + + if err == nil || !strings.Contains(err.Error(), test.wantError) { + t.Errorf("%s: error = %v, want it to mention %q", test.name, err, test.wantError) + } + } +} + +func TestDeviceClaimUnknownFleet(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("GET /fleets", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `[]`) + }) + + session, _ := apitest.LoggedInSession(t, mux) + + err := Claim(session, []string{"354820091234567", "9"}) + + if err == nil || err.Error() != "no such fleet" { + t.Fatalf("error = %v", err) + } +} + +func TestDeviceList(t *testing.T) { + now := time.Now() + devices := fmt.Sprintf(`[{"imei":"111111111111111","name":"roof","fleet_id":3,"last_seen_at":%q,"reported_state":2,"storage_used":1240,"storage_total":57344},`+ + `{"imei":"222222222222222","name":null,"fleet_id":4,"last_seen_at":%q,"reported_state":4,"storage_used":2500000,"storage_total":8000000},`+ + `{"imei":"333333333333333","name":"shed","fleet_id":3,"last_seen_at":null,"reported_state":null,"storage_used":null,"storage_total":null}]`, + now.Add(-time.Minute).Format(time.RFC3339), now.Add(-3*time.Hour).Format(time.RFC3339)) + fleets := `[{"id":3,"name":"pilot","owner":true},{"id":4,"name":"workshop","owner":true},{"id":5,"name":"empty","owner":true}]` + + tests := []struct { + name string + arguments []string + wantShown []string + wantHidden []string + wantExact string + wantError string + devices string + fleets string + refusal string + }{ + {name: "table", wantShown: []string{"IMEI NAME FLEET STATE STORAGE LAST SEEN", "roof", "pilot", "running", "1.2 kB of 57.3 kB", "just now", "-", "workshop", "crashed", "2.5 MB of 8.0 MB", "3 h ago", "unknown", "never"}}, + {name: "filtered", arguments: []string{"3"}, wantShown: []string{"111111111111111", "333333333333333"}, wantHidden: []string{"222222222222222", "workshop"}}, + {name: "json flag anywhere", arguments: []string{"3", "--json"}, wantShown: []string{`"imei":"111111111111111"`, `"fleet_id":3`}, wantHidden: []string{"LAST SEEN", "222222222222222"}}, + {name: "empty fleet", arguments: []string{"5"}, wantExact: "No devices in that fleet.\n"}, + {name: "no devices", devices: `[]`, fleets: `[]`, wantExact: "No devices yet. Claim one with device claim.\n"}, + {name: "server refusal", refusal: "devices unavailable", wantError: "devices unavailable"}, + {name: "unknown fleet", arguments: []string{"9"}, wantError: "no such fleet"}, + {name: "two ids", arguments: []string{"3", "4"}, wantError: "takes at most one fleet id"}, + {name: "wordy id", arguments: []string{"pilot"}, wantError: "shown by fleet list"}, + {name: "bad last seen time", devices: `[{"imei":"111111111111111","name":"roof","fleet_id":3,"last_seen_at":"yesterday"}]`, wantError: `cannot parse "yesterday"`}, + {name: "minutes ago", devices: fmt.Sprintf(`[{"imei":"111111111111111","name":"roof","fleet_id":3,"last_seen_at":%q}]`, now.Add(-12*time.Minute).Format(time.RFC3339)), wantShown: []string{"12 min ago"}}, + {name: "days ago", devices: fmt.Sprintf(`[{"imei":"111111111111111","name":"roof","fleet_id":3,"last_seen_at":%q}]`, now.Add(-49*time.Hour).Format(time.RFC3339)), wantShown: []string{"2 d ago"}}, + {name: "stopped and undefined states", devices: `[{"imei":"444444444444444","name":"halted","fleet_id":3,"reported_state":3},{"imei":"555555555555555","name":"odd","fleet_id":3,"reported_state":1}]`, wantShown: []string{"stopped", "unknown"}}, + {name: "byte storage", devices: `[{"imei":"666666666666666","name":"bytes","fleet_id":3,"storage_used":999,"storage_total":999}]`, wantShown: []string{"999 B of 999 B"}}, + {name: "missing used storage", devices: `[{"imei":"777777777777777","name":"nil-used","fleet_id":3,"storage_used":null,"storage_total":57344}]`, wantShown: []string{"777777777777777 nil-used pilot unknown - never"}}, + {name: "missing total storage", devices: `[{"imei":"888888888888888","name":"nil-total","fleet_id":3,"storage_used":1240,"storage_total":null}]`, wantShown: []string{"888888888888888 nil-total pilot unknown - never"}}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + servedDevices := test.devices + + if servedDevices == "" { + servedDevices = devices + } + + servedFleets := test.fleets + + if servedFleets == "" { + servedFleets = fleets + } + + mux := http.NewServeMux() + mux.HandleFunc("GET /devices", func(w http.ResponseWriter, r *http.Request) { + if test.refusal != "" { + http.Error(w, test.refusal, http.StatusServiceUnavailable) + return + } + + fmt.Fprint(w, servedDevices) + }) + mux.HandleFunc("GET /fleets", func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, servedFleets) }) + session, out := apitest.LoggedInSession(t, mux) + + err := List(session, test.arguments) + + printed := out.String() + + if test.wantError != "" { + if err == nil || !strings.Contains(err.Error(), test.wantError) { + t.Fatalf("error = %v", err) + } + return + } + + if err != nil { + t.Fatal(err) + } + + if test.wantExact != "" && printed != test.wantExact { + t.Errorf("output = %q", printed) + } + + for _, want := range test.wantShown { + if !strings.Contains(printed, want) { + t.Errorf("output %q omits %q", printed, want) + } + } + + for _, hidden := range test.wantHidden { + if strings.Contains(printed, hidden) { + t.Errorf("output %q includes %q", printed, hidden) + } + } + }) + } +} + +func TestDeviceRename(t *testing.T) { + tests := []struct { + name string + refusal string + wantOutput string + wantError string + }{ + {name: "renamed", wantOutput: "Renamed the device to \"pilot\".\n"}, + {name: "server refusal", refusal: "no such device", wantError: "no such device"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + renamedPath := "" + renamedTo := "" + mux := http.NewServeMux() + mux.HandleFunc("PATCH /devices/{imei}", func(w http.ResponseWriter, r *http.Request) { + body := struct { + Name string `json:"name"` + }{} + + json.NewDecoder(r.Body).Decode(&body) + renamedPath = r.URL.Path + renamedTo = body.Name + + if test.refusal != "" { + http.Error(w, test.refusal, http.StatusNotFound) + return + } + + w.WriteHeader(http.StatusNoContent) + }) + + session, out := apitest.LoggedInSession(t, mux) + + err := Rename(session, []string{"354820091234567", " pilot "}) + + if test.wantError != "" { + if err == nil || err.Error() != test.wantError { + t.Fatalf("error = %v, want %q", err, test.wantError) + } + } else if err != nil { + t.Fatal(err) + } + + if renamedPath != "/devices/354820091234567" || renamedTo != "pilot" { + t.Errorf("the server saw %q renamed to %q", renamedPath, renamedTo) + } + + if out.String() != test.wantOutput { + t.Errorf("output = %q, want %q", out.String(), test.wantOutput) + } + }) + } +} + +func TestDeviceRenameArguments(t *testing.T) { + tests := []struct { + name string + arguments []string + wantError string + }{ + {"no arguments", nil, "takes an IMEI and a new name"}, + {"one argument", []string{"354820091234567"}, "takes an IMEI and a new name"}, + {"three arguments", []string{"354820091234567", "roof", "sensor"}, "takes an IMEI and a new name"}, + {"short IMEI", []string{"123", "pilot"}, "15-digit"}, + {"non-digit IMEI", []string{"35482009123456x", "pilot"}, "15-digit"}, + {"empty name", []string{"354820091234567", ""}, "takes an IMEI and a new name"}, + {"whitespace name", []string{"354820091234567", " "}, "takes an IMEI and a new name"}, + } + + for _, test := range tests { + err := Rename(api.Session{}, test.arguments) + + if err == nil || !strings.Contains(err.Error(), test.wantError) { + t.Errorf("%s: error = %v, want it to mention %q", test.name, err, test.wantError) + } + } +} + +func TestDeviceRelease(t *testing.T) { + tests := []struct { + name string + answer string + fleets string + refusal string + wantReleased bool + wantOutput string + wantError string + }{ + {name: "confirmed", answer: "yes\n", wantReleased: true, wantOutput: "Release the device from \"pilot\"? It erases everything on the device, and claiming it again means pressing its pairing button in person. [y/N] Released the device.\n"}, + {name: "declined", answer: "n\n", wantOutput: "Release the device from \"pilot\"? It erases everything on the device, and claiming it again means pressing its pairing button in person. [y/N] Nothing released.\n"}, + {name: "server refuses", answer: "y\n", refusal: "no such device", wantReleased: true, wantError: "no such device"}, + {name: "device belongs to an inaccessible fleet", fleets: `[]`, wantError: "no such device, device list shows yours"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + releasedPath := "" + fleets := test.fleets + + if fleets == "" { + fleets = `[{"id":3,"name":"pilot","owner":true}]` + } + + mux := http.NewServeMux() + mux.HandleFunc("GET /devices", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `[{"imei":"354820091234567","name":null,"fleet_id":3,"last_seen_at":null}]`) + }) + mux.HandleFunc("GET /fleets", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, fleets) + }) + mux.HandleFunc("DELETE /devices/{imei}", func(w http.ResponseWriter, r *http.Request) { + releasedPath = r.URL.Path + + if test.refusal != "" { + http.Error(w, test.refusal, http.StatusNotFound) + return + } + + w.WriteHeader(http.StatusNoContent) + }) + + session, out := apitest.LoggedInSession(t, mux) + session.In = strings.NewReader(test.answer) + + err := Release(session, []string{"354820091234567"}) + + printed := out.String() + + if test.wantError != "" { + if err == nil || err.Error() != test.wantError { + t.Fatalf("error = %v", err) + } + } else if err != nil { + t.Fatal(err) + } + + if test.wantOutput != "" && printed != test.wantOutput { + t.Errorf("output = %q", printed) + } + + if test.wantReleased && releasedPath != "/devices/354820091234567" { + t.Errorf("released path = %q", releasedPath) + } + + if !test.wantReleased && releasedPath != "" { + t.Errorf("released path = %q after decline", releasedPath) + } + }) + } +} + +func TestDeviceReleaseArgumentsAndUnknownDevice(t *testing.T) { + tests := []struct { + name string + arguments []string + wantError string + }{ + {"no arguments", nil, "takes an IMEI"}, + {"two arguments", []string{"354820091234567", "extra"}, "takes an IMEI"}, + {"short IMEI", []string{"123"}, "15-digit"}, + {"non-digit IMEI", []string{"35482009123456x"}, "15-digit"}, + } + + for _, test := range tests { + err := Release(api.Session{}, test.arguments) + + if err == nil || !strings.Contains(err.Error(), test.wantError) { + t.Errorf("%s: error = %v", test.name, err) + } + } + + mux := http.NewServeMux() + mux.HandleFunc("GET /devices", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `[]`) + }) + session, _ := apitest.LoggedInSession(t, mux) + + err := Release(session, []string{"354820091234567"}) + + if err == nil || err.Error() != "no such device, device list shows yours" { + t.Fatalf("error = %v", err) + } +} diff --git a/internal/dispatch/dispatch.go b/internal/dispatch/dispatch.go new file mode 100644 index 0000000..0c3fcdb --- /dev/null +++ b/internal/dispatch/dispatch.go @@ -0,0 +1,213 @@ +package dispatch + +import ( + "errors" + "fmt" + "io" + "strings" + + "github.com/siliconwitchery/superstack-cli/internal/api" +) + +func TakeServerFlag(arguments []string) ([]string, string, error) { + remaining := []string{} + + base := api.DefaultBase + + for index := 0; index < len(arguments); index++ { + switch { + case arguments[index] == "--server": + if index+1 == len(arguments) || arguments[index+1] == "" { + return nil, "", errors.New("--server needs an address") + } + + index++ + + base = arguments[index] + + case strings.HasPrefix(arguments[index], "--server="): + base = strings.TrimPrefix(arguments[index], "--server=") + + if base == "" { + return nil, "", errors.New("--server needs an address") + } + + default: + remaining = append(remaining, arguments[index]) + } + } + + return remaining, strings.TrimSuffix(base, "/"), nil +} + +func TakeJsonFlag(arguments []string) ([]string, bool) { + positionals := []string{} + jsonOutput := false + + for _, argument := range arguments { + if argument == "--json" { + jsonOutput = true + continue + } + + positionals = append(positionals, argument) + } + + return positionals, jsonOutput +} + +type Command struct { + Name string + Arguments string + Summary string + Run func(session api.Session, arguments []string) error +} + +type Section struct { + Title string + Commands []Command +} + +func resolve(sections []Section, arguments []string) (Command, []string, bool) { + longest := Command{} + longestWords := 0 + + for _, section := range sections { + for _, candidate := range section.Commands { + words := strings.Fields(candidate.Name) + + if len(words) > len(arguments) || len(words) <= longestWords { + continue + } + + matches := true + + for index, word := range words { + if arguments[index] != word { + matches = false + break + } + } + + if !matches { + continue + } + + longest = candidate + longestWords = len(words) + } + } + + if longestWords == 0 { + return Command{}, nil, false + } + + return longest, arguments[longestWords:], true +} + +func printHelp(session api.Session, sections []Section) { + widest := 0 + + for _, section := range sections { + for _, entry := range section.Commands { + width := len(entry.Name) + + if entry.Arguments != "" { + width += 1 + len(entry.Arguments) + } + + if width > widest { + widest = width + } + } + } + + fmt.Fprintf(session.Out, "superstack %s\n\n", session.Version) + fmt.Fprint(session.Out, "Usage: superstack [arguments]\n") + + for _, section := range sections { + fmt.Fprintf(session.Out, "\n%s\n", section.Title) + + for _, entry := range section.Commands { + signature := entry.Name + + if entry.Arguments != "" { + signature += " " + entry.Arguments + } + + fmt.Fprintf(session.Out, " %-*s %s\n", widest, signature, entry.Summary) + } + } +} + +func Dispatch(sections []Section, version string, arguments []string, in io.Reader, out io.Writer) error { + arguments, base, err := TakeServerFlag(arguments) + + if err != nil { + return err + } + + session := api.NewSession(base, version, in, out) + + if len(arguments) == 0 { + printHelp(session, sections) + return nil + } + + switch arguments[0] { + case "-h", "--help": + printHelp(session, sections) + return nil + + case "-v", "--version": + fmt.Fprintln(session.Out, session.Version) + return nil + } + + entry, rest, found := resolve(sections, arguments) + + if !found { + return fmt.Errorf("unknown command %q\nRun 'superstack help' for the list.", strings.Join(arguments, " ")) + } + + switch entry.Name { + case "version": + fmt.Fprintln(session.Out, session.Version) + return nil + + case "help": + if len(rest) == 0 { + printHelp(session, sections) + return nil + } + + topic, _, topicFound := resolve(sections, rest) + + if !topicFound { + return fmt.Errorf("unknown command %q\nRun 'superstack help' for the list.", strings.Join(rest, " ")) + } + + signature := topic.Name + + if topic.Arguments != "" { + signature += " " + topic.Arguments + } + + fmt.Fprintf(session.Out, "superstack %s\n\n %s\n", signature, topic.Summary) + return nil + } + + if entry.Run == nil { + return fmt.Errorf("%s is not available yet", entry.Name) + } + + err = api.CheckServer(session) + + if err != nil { + return err + } + + err = entry.Run(session, rest) + + return err +} diff --git a/internal/dispatch/dispatch_test.go b/internal/dispatch/dispatch_test.go new file mode 100644 index 0000000..1829742 --- /dev/null +++ b/internal/dispatch/dispatch_test.go @@ -0,0 +1,254 @@ +package dispatch + +import ( + "bytes" + "fmt" + "net/http" + "net/http/httptest" + "slices" + "strings" + "testing" + + "github.com/siliconwitchery/superstack-cli/internal/api" +) + +func TestTakeServerFlag(t *testing.T) { + tests := []struct { + name string + arguments []string + wantRemaining string + wantBase string + wantError string + }{ + { + name: "no flag", + arguments: []string{"login"}, + wantRemaining: "login", + }, + { + name: "a url", + arguments: []string{"--server", "http://localhost:8080", "login"}, + wantRemaining: "login", + wantBase: "http://localhost:8080", + }, + { + name: "a url in equals form", + arguments: []string{"logout", "--server=https://staging.example.com"}, + wantRemaining: "logout", + wantBase: "https://staging.example.com", + }, + { + name: "a trailing slash is trimmed", + arguments: []string{"--server", "http://localhost:8080/", "login"}, + wantRemaining: "login", + wantBase: "http://localhost:8080", + }, + { + name: "the flag between command words", + arguments: []string{"fleet", "--server=http://localhost:9999", "list"}, + wantRemaining: "fleet list", + wantBase: "http://localhost:9999", + }, + { + name: "a missing value", + arguments: []string{"login", "--server"}, + wantError: "needs an address", + }, + { + name: "an empty value", + arguments: []string{"login", "--server="}, + wantError: "needs an address", + }, + { + name: "an empty value from an unset shell variable", + arguments: []string{"--server", "", "login"}, + wantError: "needs an address", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + remaining, base, err := TakeServerFlag(test.arguments) + + if test.wantError != "" { + if err == nil || !strings.Contains(err.Error(), test.wantError) { + t.Fatalf("error = %v, want it to mention %q", err, test.wantError) + } + + return + } + + if err != nil { + t.Fatal(err) + } + + if strings.Join(remaining, " ") != test.wantRemaining { + t.Errorf("remaining = %q, want %q", strings.Join(remaining, " "), test.wantRemaining) + } + + wantBase := test.wantBase + + if wantBase == "" { + wantBase = api.DefaultBase + } + + if base != wantBase { + t.Errorf("base = %q, want %q", base, wantBase) + } + }) + } +} + +func TestResolve(t *testing.T) { + sections := []Section{{Commands: []Command{ + {Name: "login"}, + {Name: "device list"}, + {Name: "device claim"}, + {Name: "fleet create"}, + {Name: "member add"}, + {Name: "key create"}, + {Name: "account balance"}, + {Name: "account topup"}, + {Name: "upload"}, + }}} + + tests := []struct { + arguments []string + name string + rest []string + found bool + }{ + {arguments: []string{"login"}, name: "login", rest: []string{}, found: true}, + {arguments: []string{"device", "list"}, name: "device list", rest: []string{}, found: true}, + {arguments: []string{"device", "claim", "354820091234567", "sensor-01"}, name: "device claim", rest: []string{"354820091234567", "sensor-01"}, found: true}, + {arguments: []string{"fleet", "create", "thermostats"}, name: "fleet create", rest: []string{"thermostats"}, found: true}, + {arguments: []string{"member", "add", "member@example.com"}, name: "member add", rest: []string{"member@example.com"}, found: true}, + {arguments: []string{"key", "create", "42", "production"}, name: "key create", rest: []string{"42", "production"}, found: true}, + {arguments: []string{"account", "balance"}, name: "account balance", rest: []string{}, found: true}, + {arguments: []string{"account", "topup", "42"}, name: "account topup", rest: []string{"42"}, found: true}, + {arguments: []string{"upload", "./main.lua", "--device", "sensor-01"}, name: "upload", rest: []string{"./main.lua", "--device", "sensor-01"}, found: true}, + {arguments: []string{"fleet"}, found: false}, + {arguments: []string{"member"}, found: false}, + {arguments: []string{"device"}, found: false}, + {arguments: []string{"key"}, found: false}, + {arguments: []string{"account"}, found: false}, + {arguments: []string{"deploy"}, found: false}, + {arguments: []string{}, found: false}, + } + + for _, test := range tests { + entry, rest, found := resolve(sections, test.arguments) + + if found != test.found { + t.Errorf("resolve(%q) found = %v, want %v", test.arguments, found, test.found) + continue + } + + if !found { + continue + } + + if entry.Name != test.name { + t.Errorf("resolve(%q) name = %q, want %q", test.arguments, entry.Name, test.name) + } + + if !slices.Equal(rest, test.rest) { + t.Errorf("resolve(%q) rest = %q, want %q", test.arguments, rest, test.rest) + } + } +} + +func TestDispatch(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + + t.Cleanup(server.Close) + + sections := []Section{ + {Title: "Things", Commands: []Command{ + {Name: "thing list", Arguments: "", Summary: "List a thing", Run: func(session api.Session, arguments []string) error { + fmt.Fprintln(session.Out, "Listed.") + return nil + }}, + {Name: "thing pending", Summary: "Wait for a thing"}, + }}, + {Title: "Superstack", Commands: []Command{ + {Name: "version", Summary: "Show the version"}, + {Name: "help", Arguments: "[command]", Summary: "Show help"}, + }}, + } + wantHelp := "superstack 1.2.3\n\n" + + "Usage: superstack [arguments]\n\n" + + "Things\n" + + " thing list List a thing\n" + + " thing pending Wait for a thing\n\n" + + "Superstack\n" + + " version Show the version\n" + + " help [command] Show help\n" + tests := []struct { + name string + arguments []string + wantOutput string + wantError string + }{ + {name: "no arguments", wantOutput: wantHelp}, + {name: "short help flag", arguments: []string{"-h"}, wantOutput: wantHelp}, + {name: "long help flag", arguments: []string{"--help"}, wantOutput: wantHelp}, + {name: "short version flag", arguments: []string{"-v"}, wantOutput: "1.2.3\n"}, + {name: "long version flag", arguments: []string{"--version"}, wantOutput: "1.2.3\n"}, + {name: "version command", arguments: []string{"version"}, wantOutput: "1.2.3\n"}, + {name: "help command", arguments: []string{"help"}, wantOutput: wantHelp}, + {name: "topic help", arguments: []string{"help", "thing", "list"}, wantOutput: "superstack thing list \n\n List a thing\n"}, + {name: "unknown help topic", arguments: []string{"help", "missing"}, wantError: "unknown command \"missing\"\nRun 'superstack help' for the list."}, + {name: "unknown command", arguments: []string{"missing"}, wantError: "unknown command \"missing\"\nRun 'superstack help' for the list."}, + {name: "unavailable command", arguments: []string{"thing", "pending"}, wantError: "thing pending is not available yet"}, + {name: "runnable command", arguments: []string{"thing", "list", "3"}, wantOutput: "Listed.\n"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + arguments := append([]string{"--server", server.URL}, test.arguments...) + out := &bytes.Buffer{} + + err := Dispatch(sections, "1.2.3", arguments, strings.NewReader(""), out) + + if test.wantError != "" { + if err == nil || err.Error() != test.wantError { + t.Fatalf("error = %v, want %q", err, test.wantError) + } + } else if err != nil { + t.Fatal(err) + } + + if out.String() != test.wantOutput { + t.Errorf("output = %q, want %q", out.String(), test.wantOutput) + } + }) + } +} + +func TestHelpListsEveryCommand(t *testing.T) { + sections := []Section{ + {Title: "Things", Commands: []Command{{Name: "thing list", Arguments: "[--json]", Summary: "List things"}}}, + {Title: "Account", Commands: []Command{{Name: "account delete", Summary: "Delete the account"}}}, + } + out := &bytes.Buffer{} + session := api.NewSession(api.DefaultBase, "1.2.3", strings.NewReader(""), out) + + printHelp(session, sections) + + for _, section := range sections { + if !strings.Contains(out.String(), section.Title) { + t.Errorf("help is missing the section %q", section.Title) + } + + for _, entry := range section.Commands { + if !strings.Contains(out.String(), entry.Name) { + t.Errorf("help is missing the command %q", entry.Name) + } + + if !strings.Contains(out.String(), entry.Summary) { + t.Errorf("help is missing the summary for %q", entry.Name) + } + } + } +} diff --git a/internal/fleet/fleet.go b/internal/fleet/fleet.go new file mode 100644 index 0000000..8c1f3db --- /dev/null +++ b/internal/fleet/fleet.go @@ -0,0 +1,301 @@ +package fleet + +import ( + "bufio" + "bytes" + "encoding/json" + "errors" + "fmt" + "net/http" + "strconv" + "strings" + + "github.com/siliconwitchery/superstack-cli/internal/api" + "github.com/siliconwitchery/superstack-cli/internal/dispatch" +) + +func Create(session api.Session, arguments []string) error { + if len(arguments) != 1 || arguments[0] == "" { + return errors.New("fleet create takes one name, quoted if it has spaces") + } + + body, err := json.Marshal(map[string]string{"name": arguments[0]}) + + if err != nil { + return err + } + + request, err := api.AuthenticatedRequest(session, http.MethodPost, "/fleets", bytes.NewReader(body)) + + if err != nil { + return err + } + + request.Header.Set("Content-Type", "application/json") + + response, err := session.Client.Do(request) + + if err != nil { + return errors.New("the server could not be reached, check your connection") + } + + defer response.Body.Close() + + if response.StatusCode != http.StatusOK { + return api.ServerError(response) + } + + created := struct { + Id int64 `json:"id"` + Name string `json:"name"` + }{} + + err = json.NewDecoder(response.Body).Decode(&created) + + if err != nil { + return err + } + + fmt.Fprintf(session.Out, "Created fleet %q with id %d.\n", created.Name, created.Id) + + return nil +} + +func List(session api.Session, arguments []string) error { + positionals, jsonOutput := dispatch.TakeJsonFlag(arguments) + + if len(positionals) != 0 { + return errors.New("fleet list takes no arguments") + } + + fleets, err := api.FetchFleets(session) + + if err != nil { + return err + } + + if jsonOutput { + return json.NewEncoder(session.Out).Encode(fleets) + } + + if len(fleets) == 0 { + fmt.Fprintln(session.Out, "No fleets yet. Create one with fleet create.") + return nil + } + + idWidth := len("ID") + nameWidth := len("NAME") + + for _, fleet := range fleets { + idWidth = max(idWidth, len(strconv.FormatInt(fleet.Id, 10))) + nameWidth = max(nameWidth, len(fleet.Name)) + } + + fmt.Fprintf(session.Out, "%-*s %-*s %s\n", idWidth, "ID", nameWidth, "NAME", "ROLE") + + for _, fleet := range fleets { + role := "member" + + if fleet.Owner { + role = "owner" + } + + fmt.Fprintf(session.Out, "%-*d %-*s %s\n", idWidth, fleet.Id, nameWidth, fleet.Name, role) + } + + return nil +} + +func Rename(session api.Session, arguments []string) error { + if len(arguments) != 2 { + return errors.New("fleet rename takes a fleet id and a new name, quoted if it has spaces") + } + + fleetId, err := strconv.ParseInt(arguments[0], 10, 64) + + if err != nil || fleetId < 1 { + return errors.New("the fleet id is the number shown by fleet list") + } + + name := strings.TrimSpace(arguments[1]) + + if name == "" { + return errors.New("fleet rename takes a fleet id and a new name, quoted if it has spaces") + } + + body, err := json.Marshal(map[string]string{"name": name}) + + if err != nil { + return err + } + + request, err := api.AuthenticatedRequest(session, http.MethodPatch, + "/fleets/"+strconv.FormatInt(fleetId, 10), bytes.NewReader(body)) + + if err != nil { + return err + } + + request.Header.Set("Content-Type", "application/json") + + response, err := session.Client.Do(request) + + if err != nil { + return errors.New("the server could not be reached, check your connection") + } + + defer response.Body.Close() + + if response.StatusCode != http.StatusNoContent { + return api.ServerError(response) + } + + fmt.Fprintf(session.Out, "Renamed the fleet to %q.\n", name) + + return nil +} + +func Transfer(session api.Session, arguments []string) error { + if len(arguments) != 2 || arguments[1] == "" { + return errors.New("fleet transfer takes a fleet id and an email address") + } + + fleetId, err := strconv.ParseInt(arguments[0], 10, 64) + + if err != nil || fleetId < 1 { + return errors.New("the fleet id is the number shown by fleet list") + } + + email := arguments[1] + + body, err := json.Marshal(map[string]string{"email": email}) + + if err != nil { + return err + } + + request, err := api.AuthenticatedRequest(session, http.MethodPost, + "/fleets/"+strconv.FormatInt(fleetId, 10)+"/owner", bytes.NewReader(body)) + + if err != nil { + return err + } + + request.Header.Set("Content-Type", "application/json") + + response, err := session.Client.Do(request) + + if err != nil { + return errors.New("the server could not be reached, check your connection") + } + + defer response.Body.Close() + + if response.StatusCode != http.StatusNoContent { + return api.ServerError(response) + } + + fmt.Fprintf(session.Out, "Transferred the fleet to %s.\n", email) + + return nil +} + +func Delete(session api.Session, arguments []string) error { + if len(arguments) != 1 { + return errors.New("fleet delete takes a fleet id") + } + + fleetId, err := strconv.ParseInt(arguments[0], 10, 64) + + if err != nil || fleetId < 1 { + return errors.New("the fleet id is the number shown by fleet list") + } + + fleets, err := api.FetchFleets(session) + + if err != nil { + return err + } + + name := "" + found := false + + for _, fleet := range fleets { + if fleet.Id == fleetId { + name = fleet.Name + found = true + } + } + + if !found { + return errors.New("no such fleet") + } + + balances, err := api.FetchBalances(session) + + if err != nil { + return err + } + + forfeited := "" + forfeitUnknown := false + + for _, balance := range balances { + if balance.Fleet != fleetId { + continue + } + + formatted, value, parsed := api.FormatBalance(balance) + + if !parsed { + forfeitUnknown = true + continue + } + + if value > 0 { + forfeited = formatted + } + } + + consequence := "It erases them all, and claiming one again means pressing its pairing button in person." + + if forfeitUnknown { + fmt.Fprintf(session.Out, "Delete %q, release its devices, and forfeit its remaining credit? %s [y/N] ", name, consequence) + } else if forfeited == "" { + fmt.Fprintf(session.Out, "Delete %q and release its devices? %s [y/N] ", name, consequence) + } else { + fmt.Fprintf(session.Out, "Delete %q, release its devices, and forfeit its remaining %s of credit? %s [y/N] ", name, forfeited, consequence) + } + + answer, _ := bufio.NewReader(session.In).ReadString('\n') + + answer = strings.ToLower(strings.TrimSpace(answer)) + + if answer != "y" && answer != "yes" { + fmt.Fprintln(session.Out, "Nothing deleted.") + return nil + } + + request, err := api.AuthenticatedRequest(session, http.MethodDelete, + "/fleets/"+strconv.FormatInt(fleetId, 10), nil) + + if err != nil { + return err + } + + response, err := session.Client.Do(request) + + if err != nil { + return errors.New("the server could not be reached, check your connection") + } + + defer response.Body.Close() + + if response.StatusCode != http.StatusNoContent { + return api.ServerError(response) + } + + fmt.Fprintf(session.Out, "Deleted %q.\n", name) + + return nil +} diff --git a/internal/fleet/fleet_test.go b/internal/fleet/fleet_test.go new file mode 100644 index 0000000..52a5c5d --- /dev/null +++ b/internal/fleet/fleet_test.go @@ -0,0 +1,540 @@ +package fleet + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + "testing" + + "github.com/siliconwitchery/superstack-cli/internal/api" + "github.com/siliconwitchery/superstack-cli/internal/api/apitest" +) + +func TestFleetCreate(t *testing.T) { + tests := []struct { + name string + refusal string + wantOutput string + wantError string + }{ + {name: "created", wantOutput: "Created fleet \"field trial\" with id 5.\n"}, + {name: "server refusal", refusal: "the body must carry a name", wantError: "the body must carry a name"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + created := "" + mux := http.NewServeMux() + + mux.HandleFunc("POST /fleets", func(w http.ResponseWriter, r *http.Request) { + body := struct { + Name string `json:"name"` + }{} + + json.NewDecoder(r.Body).Decode(&body) + created = body.Name + + if test.refusal != "" { + http.Error(w, test.refusal, http.StatusBadRequest) + return + } + + fmt.Fprintf(w, `{"id": 5, "name": %q}`, body.Name) + }) + + session, out := apitest.LoggedInSession(t, mux) + + err := Create(session, []string{"field trial"}) + + if test.wantError != "" { + if err == nil || err.Error() != test.wantError { + t.Fatalf("error = %v, want %q", err, test.wantError) + } + } else if err != nil { + t.Fatal(err) + } + + if created != "field trial" { + t.Errorf("the server saw %q created, want %q", created, "field trial") + } + + if out.String() != test.wantOutput { + t.Errorf("output = %q, want %q", out.String(), test.wantOutput) + } + }) + } +} + +func TestFleetCreateTakesOneName(t *testing.T) { + tests := []struct { + name string + arguments []string + }{ + {"no arguments", nil}, + {"two words", []string{"field", "trial"}}, + {"an empty name", []string{""}}, + } + + for _, test := range tests { + err := Create(api.Session{}, test.arguments) + + if err == nil || !strings.Contains(err.Error(), "takes one name") { + t.Errorf("%s: error = %v, want the one-name hint", test.name, err) + } + } +} + +func TestFleetList(t *testing.T) { + tests := []struct { + name string + arguments []string + fleets string + wantShown []string + wantAbsent []string + wantExact string + wantError string + }{ + { + name: "some fleets", + fleets: `[{"id":1,"name":"field trial","owner":true},{"id":2,"name":"rooftop","owner":false}]`, + wantShown: []string{"ID", "NAME", "ROLE", "field trial", "owner", "rooftop", "member"}, + }, + { + name: "no fleets", + fleets: `[]`, + wantShown: []string{"No fleets yet"}, + wantAbsent: []string{"ID", "NAME"}, + }, + { + name: "machine-readable output", + arguments: []string{"--json"}, + fleets: `[{"id":1,"name":"field trial","owner":true}]`, + wantExact: `[{"id":1,"name":"field trial","owner":true}]` + "\n", + }, + { + name: "an unknown argument", + arguments: []string{"--verbose"}, + wantError: "fleet list takes no arguments", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + mux := http.NewServeMux() + + mux.HandleFunc("GET /fleets", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, test.fleets) + }) + + session, out := apitest.LoggedInSession(t, mux) + + err := List(session, test.arguments) + + printed := out.String() + + if test.wantError != "" { + if err == nil || !strings.Contains(err.Error(), test.wantError) { + t.Fatalf("error = %v, want it to mention %q", err, test.wantError) + } + + return + } + + if err != nil { + t.Fatal(err) + } + + if test.wantExact != "" && printed != test.wantExact { + t.Errorf("the output is %q, want exactly %q", printed, test.wantExact) + } + + for _, want := range test.wantShown { + if !strings.Contains(printed, want) { + t.Errorf("the output %q does not show %q", printed, want) + } + } + + for _, absent := range test.wantAbsent { + if strings.Contains(printed, absent) { + t.Errorf("the output %q shows %q although there is nothing to list", printed, absent) + } + } + }) + } +} + +func TestFleetRename(t *testing.T) { + tests := []struct { + name string + refusal string + wantOutput string + wantError string + }{ + {name: "renamed", wantOutput: "Renamed the fleet to \"pilot\".\n"}, + {name: "server refusal", refusal: "no such fleet", wantError: "no such fleet"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + renamedPath := "" + renamedTo := "" + mux := http.NewServeMux() + + mux.HandleFunc("PATCH /fleets/{id}", func(w http.ResponseWriter, r *http.Request) { + body := struct { + Name string `json:"name"` + }{} + + json.NewDecoder(r.Body).Decode(&body) + renamedPath = r.URL.Path + renamedTo = body.Name + + if test.refusal != "" { + http.Error(w, test.refusal, http.StatusNotFound) + return + } + + w.WriteHeader(http.StatusNoContent) + }) + + session, out := apitest.LoggedInSession(t, mux) + + err := Rename(session, []string{"9", " pilot "}) + + if test.wantError != "" { + if err == nil || err.Error() != test.wantError { + t.Fatalf("error = %v, want %q", err, test.wantError) + } + } else if err != nil { + t.Fatal(err) + } + + if renamedPath != "/fleets/9" || renamedTo != "pilot" { + t.Errorf("the server saw %q renamed to %q, want %q renamed to %q", + renamedPath, renamedTo, "/fleets/9", "pilot") + } + + if out.String() != test.wantOutput { + t.Errorf("output = %q, want %q", out.String(), test.wantOutput) + } + }) + } +} + +func TestFleetRenameArguments(t *testing.T) { + tests := []struct { + name string + arguments []string + wantError string + }{ + {"no arguments", nil, "takes a fleet id and a new name"}, + {"only an id", []string{"3"}, "takes a fleet id and a new name"}, + {"three arguments", []string{"3", "field", "trial"}, "takes a fleet id and a new name"}, + {"a wordy id", []string{"pilot", "rooftop"}, "shown by fleet list"}, + {"an empty name", []string{"3", ""}, "takes a fleet id and a new name"}, + {"a whitespace name", []string{"3", " "}, "takes a fleet id and a new name"}, + } + + for _, test := range tests { + err := Rename(api.Session{}, test.arguments) + + if err == nil || !strings.Contains(err.Error(), test.wantError) { + t.Errorf("%s: error = %v, want it to mention %q", test.name, err, test.wantError) + } + } +} + +func TestFleetTransfer(t *testing.T) { + tests := []struct { + name string + refusal string + wantOutput string + wantError string + }{ + {name: "transferred", wantOutput: "Transferred the fleet to successor@example.com.\n"}, + {name: "server refusal", refusal: "the new owner has no account", wantError: "the new owner has no account"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + transferredPath := "" + transferredTo := "" + mux := http.NewServeMux() + + mux.HandleFunc("POST /fleets/{id}/owner", func(w http.ResponseWriter, r *http.Request) { + body := struct { + Email string `json:"email"` + }{} + + json.NewDecoder(r.Body).Decode(&body) + transferredPath = r.URL.Path + transferredTo = body.Email + + if test.refusal != "" { + http.Error(w, test.refusal, http.StatusNotFound) + return + } + + w.WriteHeader(http.StatusNoContent) + }) + + session, out := apitest.LoggedInSession(t, mux) + + err := Transfer(session, []string{"3", "successor@example.com"}) + + if test.wantError != "" { + if err == nil || err.Error() != test.wantError { + t.Fatalf("error = %v, want %q", err, test.wantError) + } + } else if err != nil { + t.Fatal(err) + } + + if transferredPath != "/fleets/3/owner" || transferredTo != "successor@example.com" { + t.Errorf("the server saw %q handed to %q, want %q handed to %q", + transferredPath, transferredTo, "/fleets/3/owner", "successor@example.com") + } + + if out.String() != test.wantOutput { + t.Errorf("output = %q, want %q", out.String(), test.wantOutput) + } + }) + } +} + +func TestFleetTransferArguments(t *testing.T) { + tests := []struct { + name string + arguments []string + wantError string + }{ + {"no arguments", nil, "takes a fleet id and an email address"}, + {"only an id", []string{"3"}, "takes a fleet id and an email address"}, + {"an empty address", []string{"3", ""}, "takes a fleet id and an email address"}, + {"a wordy id", []string{"pilot", "successor@example.com"}, "shown by fleet list"}, + } + + for _, test := range tests { + err := Transfer(api.Session{}, test.arguments) + + if err == nil || !strings.Contains(err.Error(), test.wantError) { + t.Errorf("%s: error = %v, want it to mention %q", test.name, err, test.wantError) + } + } +} + +func TestFleetDelete(t *testing.T) { + tests := []struct { + name string + answer string + refusal string + wantDeleted bool + wantError string + }{ + {name: "confirmed with y", answer: "y\n", wantDeleted: true}, + {name: "confirmed with yes", answer: "YES\n", wantDeleted: true}, + {name: "declined with n", answer: "n\n"}, + {name: "declined by default", answer: "\n"}, + {name: "closed input", answer: ""}, + { + name: "the server refuses after the confirmation", + answer: "y\n", + refusal: "only the fleet's owner can delete it", + wantDeleted: true, + wantError: "only the fleet's owner can delete it", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + deletedPath := "" + + mux := http.NewServeMux() + + mux.HandleFunc("GET /fleets", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `[{"id":3,"name":"pilot","owner":true}]`) + }) + + mux.HandleFunc("GET /balance", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `[{"fleet":3,"balance":"0","currency":"eur"}]`) + }) + + mux.HandleFunc("DELETE /fleets/{id}", func(w http.ResponseWriter, r *http.Request) { + deletedPath = r.URL.Path + + if test.refusal != "" { + http.Error(w, test.refusal, http.StatusForbidden) + return + } + + w.WriteHeader(http.StatusNoContent) + }) + + session, out := apitest.LoggedInSession(t, mux) + + session.In = strings.NewReader(test.answer) + + err := Delete(session, []string{"3"}) + + printed := out.String() + + switch { + case test.wantError != "": + if err == nil || !strings.Contains(err.Error(), test.wantError) { + t.Fatalf("error = %v, want it to mention %q", err, test.wantError) + } + + if strings.Contains(printed, "Deleted") { + t.Errorf("the output %q says the fleet was deleted although the server refused", printed) + } + + case err != nil: + t.Fatal(err) + } + + if test.wantDeleted && deletedPath != "/fleets/3" { + t.Errorf("the server saw %q deleted, want %q", deletedPath, "/fleets/3") + } + + if !test.wantDeleted && deletedPath != "" { + t.Errorf("the server saw %q deleted although the confirmation was declined", deletedPath) + } + }) + } +} + +func TestFleetDeletePromptStatesForfeitedCredit(t *testing.T) { + tests := []struct { + name string + balance string + wantOutput string + wantAbsent string + }{ + { + name: "remaining credit is stated", + balance: `[{"fleet":3,"balance":"12.340000","currency":"eur"}]`, + wantOutput: "Delete \"pilot\", release its devices, and forfeit its remaining €12.34 of credit? It erases them all, and claiming one again means pressing its pairing button in person. [y/N] Nothing deleted.\n", + }, + { + name: "an empty balance stays quiet", + balance: `[{"fleet":3,"balance":"0","currency":"eur"}]`, + wantOutput: "Delete \"pilot\" and release its devices? It erases them all, and claiming one again means pressing its pairing button in person. [y/N] Nothing deleted.\n", + wantAbsent: "forfeit", + }, + { + name: "an unparseable balance warns without an amount", + balance: `[{"fleet":3,"balance":"15,00","currency":"eur"}]`, + wantOutput: "Delete \"pilot\", release its devices, and forfeit its remaining credit? It erases them all, and claiming one again means pressing its pairing button in person. [y/N] Nothing deleted.\n", + wantAbsent: "€", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + mux := http.NewServeMux() + + mux.HandleFunc("GET /fleets", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `[{"id":3,"name":"pilot","owner":true}]`) + }) + + mux.HandleFunc("GET /balance", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, test.balance) + }) + + session, out := apitest.LoggedInSession(t, mux) + + session.In = strings.NewReader("n\n") + + err := Delete(session, []string{"3"}) + + printed := out.String() + + if err != nil { + t.Fatal(err) + } + + if printed != test.wantOutput { + t.Errorf("output = %q, want %q", printed, test.wantOutput) + } + + if !strings.Contains(printed, "It erases them all, and claiming one again means pressing its pairing button in person.") { + t.Errorf("the prompt %q does not say the devices are erased", printed) + } + + if test.wantAbsent != "" && strings.Contains(printed, test.wantAbsent) { + t.Errorf("the prompt %q mentions %q although nothing is forfeited", printed, test.wantAbsent) + } + }) + } +} + +func TestFleetDeleteRefusesWhenTheBalanceIsUnknown(t *testing.T) { + deletedPath := "" + + mux := http.NewServeMux() + + mux.HandleFunc("GET /fleets", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `[{"id":3,"name":"pilot","owner":true}]`) + }) + + mux.HandleFunc("GET /balance", func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "the server could not read the balances", http.StatusServiceUnavailable) + }) + + mux.HandleFunc("DELETE /fleets/{id}", func(w http.ResponseWriter, r *http.Request) { + deletedPath = r.URL.Path + + w.WriteHeader(http.StatusNoContent) + }) + + session, _ := apitest.LoggedInSession(t, mux) + + session.In = strings.NewReader("y\n") + + err := Delete(session, []string{"3"}) + + if err == nil || !strings.Contains(err.Error(), "could not read the balances") { + t.Fatalf("error = %v, want the server's balance refusal", err) + } + + if deletedPath != "" { + t.Errorf("the server saw %q deleted although the credit could not be stated", deletedPath) + } +} + +func TestFleetDeleteUnknownFleet(t *testing.T) { + mux := http.NewServeMux() + + mux.HandleFunc("GET /fleets", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `[]`) + }) + + session, _ := apitest.LoggedInSession(t, mux) + + err := Delete(session, []string{"9"}) + + if err == nil || !strings.Contains(err.Error(), "no such fleet") { + t.Fatalf("error = %v, want no such fleet", err) + } +} + +func TestFleetDeleteArguments(t *testing.T) { + tests := []struct { + name string + arguments []string + wantError string + }{ + {"no arguments", nil, "takes a fleet id"}, + {"two arguments", []string{"3", "4"}, "takes a fleet id"}, + {"a wordy id", []string{"pilot"}, "shown by fleet list"}, + } + + for _, test := range tests { + err := Delete(api.Session{}, test.arguments) + + if err == nil || !strings.Contains(err.Error(), test.wantError) { + t.Errorf("%s: error = %v, want it to mention %q", test.name, err, test.wantError) + } + } +} diff --git a/internal/key/key.go b/internal/key/key.go new file mode 100644 index 0000000..067a05e --- /dev/null +++ b/internal/key/key.go @@ -0,0 +1,221 @@ +package key + +import ( + "bufio" + "bytes" + "encoding/json" + "errors" + "fmt" + "net/http" + "strconv" + "strings" + + "github.com/siliconwitchery/superstack-cli/internal/api" + "github.com/siliconwitchery/superstack-cli/internal/dispatch" +) + +func Create(session api.Session, arguments []string) error { + if len(arguments) != 2 || arguments[1] == "" { + return errors.New("key create takes a fleet id and a label, quoted if it has spaces") + } + + fleetId, err := strconv.ParseInt(arguments[0], 10, 64) + + if err != nil || fleetId < 1 { + return errors.New("the fleet id is the number shown by fleet list") + } + + body, err := json.Marshal(map[string]string{"label": arguments[1]}) + + if err != nil { + return err + } + + request, err := api.AuthenticatedRequest(session, http.MethodPost, + "/fleets/"+strconv.FormatInt(fleetId, 10)+"/keys", bytes.NewReader(body)) + + if err != nil { + return err + } + + request.Header.Set("Content-Type", "application/json") + + response, err := session.Client.Do(request) + + if err != nil { + return errors.New("the server could not be reached, check your connection") + } + + defer response.Body.Close() + + if response.StatusCode != http.StatusOK { + return api.ServerError(response) + } + + created := struct { + Id int64 `json:"id"` + Key string `json:"key"` + }{} + + err = json.NewDecoder(response.Body).Decode(&created) + + if err != nil { + return err + } + + fmt.Fprintf(session.Out, "Created key %d.\n\n %s\n\nAnyone holding it can send data to the fleet, and you will not see it again.\n", created.Id, created.Key) + + return nil +} + +func List(session api.Session, arguments []string) error { + positionals, jsonOutput := dispatch.TakeJsonFlag(arguments) + + if len(positionals) > 1 { + return errors.New("key list takes at most one fleet id") + } + + chosenFleetId := int64(0) + + if len(positionals) == 1 { + parsed, err := strconv.ParseInt(positionals[0], 10, 64) + + if err != nil || parsed < 1 { + return errors.New("the fleet id is the number shown by fleet list") + } + + chosenFleetId = parsed + } + + fleets, err := api.FetchFleets(session) + + if err != nil { + return err + } + + fleetNames := map[int64]string{} + + for _, fleet := range fleets { + fleetNames[fleet.Id] = fleet.Name + } + + if chosenFleetId != 0 { + if _, found := fleetNames[chosenFleetId]; !found { + return errors.New("no such fleet") + } + } + + fetched, err := api.FetchKeys(session) + + if err != nil { + return err + } + + keys := []api.KeyEntry{} + + for _, key := range fetched { + if chosenFleetId == 0 || key.Fleet == chosenFleetId { + keys = append(keys, key) + } + } + + if jsonOutput { + return json.NewEncoder(session.Out).Encode(keys) + } + + if len(keys) == 0 { + if chosenFleetId == 0 { + fmt.Fprintln(session.Out, "No keys yet. Create one with key create.") + } else { + fmt.Fprintln(session.Out, "No keys in that fleet.") + } + + return nil + } + + idWidth := len("ID") + fleetIdWidth := len("FLEET") + fleetNameWidth := len("FLEET NAME") + + for _, key := range keys { + idWidth = max(idWidth, len(strconv.FormatInt(key.Id, 10))) + fleetIdWidth = max(fleetIdWidth, len(strconv.FormatInt(key.Fleet, 10))) + fleetNameWidth = max(fleetNameWidth, len(fleetNames[key.Fleet])) + } + + fmt.Fprintf(session.Out, "%-*s %-*s %-*s %-8s %s\n", + idWidth, "ID", fleetIdWidth, "FLEET", fleetNameWidth, "FLEET NAME", "KEY", "LABEL") + + for _, key := range keys { + fmt.Fprintf(session.Out, "%-*d %-*d %-*s ...%s %s\n", + idWidth, key.Id, fleetIdWidth, key.Fleet, fleetNameWidth, fleetNames[key.Fleet], key.Suffix, key.Label) + } + + return nil +} + +func Revoke(session api.Session, arguments []string) error { + if len(arguments) != 1 { + return errors.New("key revoke takes a key id") + } + + keyId, err := strconv.ParseInt(arguments[0], 10, 64) + + if err != nil || keyId < 1 { + return errors.New("the key id is the number shown by key list") + } + + keys, err := api.FetchKeys(session) + + if err != nil { + return err + } + + label := "" + found := false + + for _, key := range keys { + if key.Id == keyId { + label = key.Label + found = true + } + } + + if !found { + return errors.New("no such key") + } + + fmt.Fprintf(session.Out, "Revoke %q? Anything still using it stops reaching the fleet. [y/N] ", label) + + answer, _ := bufio.NewReader(session.In).ReadString('\n') + + answer = strings.ToLower(strings.TrimSpace(answer)) + + if answer != "y" && answer != "yes" { + fmt.Fprintln(session.Out, "Nothing revoked.") + return nil + } + + request, err := api.AuthenticatedRequest(session, http.MethodDelete, + "/keys/"+strconv.FormatInt(keyId, 10), nil) + + if err != nil { + return err + } + + response, err := session.Client.Do(request) + + if err != nil { + return errors.New("the server could not be reached, check your connection") + } + + defer response.Body.Close() + + if response.StatusCode != http.StatusNoContent { + return api.ServerError(response) + } + + fmt.Fprintf(session.Out, "Revoked key %d.\n", keyId) + + return nil +} diff --git a/internal/key/key_test.go b/internal/key/key_test.go new file mode 100644 index 0000000..14ca451 --- /dev/null +++ b/internal/key/key_test.go @@ -0,0 +1,351 @@ +package key + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + "testing" + + "github.com/siliconwitchery/superstack-cli/internal/api/apitest" +) + +func TestKeyCreate(t *testing.T) { + tests := []struct { + name string + arguments []string + wantPath string + wantLabel string + refusal string + wantError string + }{ + { + name: "a labelled key", + arguments: []string{"3", "deploy server"}, + wantPath: "/fleets/3/keys", + wantLabel: "deploy server", + }, + { + name: "no label", + arguments: []string{"3"}, + wantError: "takes a fleet id and a label", + }, + { + name: "an empty label", + arguments: []string{"3", ""}, + wantError: "takes a fleet id and a label", + }, + { + name: "no fleet id", + arguments: []string{}, + wantError: "takes a fleet id and a label", + }, + { + name: "too many words", + arguments: []string{"3", "deploy", "server"}, + wantError: "takes a fleet id and a label", + }, + { + name: "a wordy id", + arguments: []string{"pilot", "deploy server"}, + wantError: "shown by fleet list", + }, + { + name: "the server refuses", + arguments: []string{"9", "doomed"}, + wantPath: "/fleets/9/keys", + refusal: "no such fleet", + wantError: "no such fleet", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + mux := http.NewServeMux() + + mux.HandleFunc("POST /fleets/{id}/keys", func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != test.wantPath { + t.Errorf("the request went to %s, want %s", r.URL.Path, test.wantPath) + } + + if test.refusal != "" { + http.Error(w, test.refusal, http.StatusNotFound) + return + } + + sent := struct { + Label string `json:"label"` + }{} + + err := json.NewDecoder(r.Body).Decode(&sent) + + if err != nil { + t.Errorf("the request body could not be decoded: %v", err) + } + + if sent.Label != test.wantLabel { + t.Errorf("the request carried label %q, want %q", sent.Label, test.wantLabel) + } + + fmt.Fprint(w, `{"id":1,"key":"ssf_testtesttestab2de"}`) + }) + + session, out := apitest.LoggedInSession(t, mux) + + err := Create(session, test.arguments) + + printed := out.String() + + if test.wantError != "" { + if err == nil || !strings.Contains(err.Error(), test.wantError) { + t.Fatalf("error = %v, want it to mention %q", err, test.wantError) + } + + return + } + + if err != nil { + t.Fatal(err) + } + + if !strings.Contains(printed, "ssf_testtesttestab2de") { + t.Errorf("the output %q does not show the key", printed) + } + + if !strings.Contains(printed, "you will not see it again") { + t.Errorf("the output %q does not warn that the key cannot be shown again", printed) + } + }) + } +} + +func TestKeyList(t *testing.T) { + fleets := `[{"id":3,"name":"crew","owner":true},` + + `{"id":4,"name":"skunkworks","owner":false},` + + `{"id":5,"name":"spares","owner":true}]` + + keys := `[{"id":1,"fleet":3,"label":"deploy server","suffix":"ab2de"},` + + `{"id":2,"fleet":4,"label":"lab sensor","suffix":"f9hjk"}]` + + tests := []struct { + name string + arguments []string + wantShown []string + wantHidden []string + wantError string + keys string + }{ + { + name: "every fleet's keys", + arguments: []string{}, + wantShown: []string{"ID FLEET FLEET NAME", "crew", "skunkworks", "...ab2de", "...f9hjk", "deploy server", "lab sensor"}, + }, + { + name: "one fleet's keys", + arguments: []string{"3"}, + wantShown: []string{"ID FLEET FLEET NAME", "crew", "...ab2de"}, + wantHidden: []string{"skunkworks", "f9hjk", "lab sensor"}, + }, + { + name: "no keys", + arguments: []string{}, + wantShown: []string{"No keys yet. Create one with key create."}, + keys: `[]`, + }, + { + name: "a fleet without keys", + arguments: []string{"5"}, + wantShown: []string{"No keys in that fleet."}, + wantHidden: []string{"ID FLEET"}, + }, + { + name: "machine-readable output", + arguments: []string{"--json"}, + wantShown: []string{`"suffix":"ab2de"`, `"fleet":4`}, + wantHidden: []string{"ID FLEET"}, + }, + { + name: "the flag before the id", + arguments: []string{"--json", "3"}, + wantShown: []string{`"id":1`}, + wantHidden: []string{`"id":2`, "ID FLEET"}, + }, + { + name: "a fleet out of reach", + arguments: []string{"9"}, + wantError: "no such fleet", + }, + { + name: "two fleet ids", + arguments: []string{"3", "4"}, + wantError: "takes at most one fleet id", + }, + { + name: "a wordy id", + arguments: []string{"pilot"}, + wantError: "shown by fleet list", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + servedKeys := test.keys + + if servedKeys == "" { + servedKeys = keys + } + + mux := http.NewServeMux() + + mux.HandleFunc("GET /fleets", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, fleets) + }) + + mux.HandleFunc("GET /keys", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, servedKeys) + }) + + session, out := apitest.LoggedInSession(t, mux) + + err := List(session, test.arguments) + + printed := out.String() + + if test.wantError != "" { + if err == nil || !strings.Contains(err.Error(), test.wantError) { + t.Fatalf("error = %v, want it to mention %q", err, test.wantError) + } + + return + } + + if err != nil { + t.Fatal(err) + } + + for _, want := range test.wantShown { + if !strings.Contains(printed, want) { + t.Errorf("the output %q leaves out %q", printed, want) + } + } + + for _, hidden := range test.wantHidden { + if strings.Contains(printed, hidden) { + t.Errorf("the output %q shows %q, want it filtered out", printed, hidden) + } + } + }) + } +} + +func TestKeyRevoke(t *testing.T) { + tests := []struct { + name string + arguments []string + answer string + refusal string + wantRevoked string + wantShown string + wantError string + }{ + { + name: "revoke a key", + arguments: []string{"3"}, + answer: "y\n", + wantRevoked: "/keys/3", + wantShown: "production", + }, + { + name: "declined by default", + arguments: []string{"3"}, + answer: "\n", + wantShown: "Nothing revoked", + }, + { + name: "declined with n", + arguments: []string{"3"}, + answer: "n\n", + wantShown: "Nothing revoked", + }, + { + name: "closed input", + arguments: []string{"3"}, + wantShown: "Nothing revoked", + }, + { + name: "the server refuses after the confirmation", + arguments: []string{"3"}, + answer: "y\n", + refusal: "no such key", + wantRevoked: "/keys/3", + wantError: "no such key", + }, + { + name: "a key that is not yours", + arguments: []string{"9"}, + answer: "y\n", + wantError: "no such key", + }, + { + name: "no key id", + arguments: []string{}, + wantError: "takes a key id", + }, + { + name: "two key ids", + arguments: []string{"3", "4"}, + wantError: "takes a key id", + }, + { + name: "a wordy id", + arguments: []string{"pilot"}, + wantError: "shown by key list", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + revokedPath := "" + + mux := http.NewServeMux() + + mux.HandleFunc("GET /keys", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `[{"id":3,"fleet":1,"label":"production","suffix":"a1b2c"}]`) + }) + + mux.HandleFunc("DELETE /keys/{id}", func(w http.ResponseWriter, r *http.Request) { + revokedPath = r.URL.Path + + if test.refusal != "" { + http.Error(w, test.refusal, http.StatusNotFound) + return + } + + w.WriteHeader(http.StatusNoContent) + }) + + session, out := apitest.LoggedInSession(t, mux) + session.In = strings.NewReader(test.answer) + + err := Revoke(session, test.arguments) + + printed := out.String() + + if test.wantError != "" { + if err == nil || !strings.Contains(err.Error(), test.wantError) { + t.Fatalf("error = %v, want it to mention %q", err, test.wantError) + } + } else if err != nil { + t.Fatal(err) + } + + if revokedPath != test.wantRevoked { + t.Errorf("the server saw %q revoked, want %q", revokedPath, test.wantRevoked) + } + + if test.wantShown != "" && !strings.Contains(printed, test.wantShown) { + t.Errorf("the output %q does not show %q", printed, test.wantShown) + } + }) + } +} diff --git a/internal/commands/login.go b/internal/login/login.go similarity index 58% rename from internal/commands/login.go rename to internal/login/login.go index e206872..8752288 100644 --- a/internal/commands/login.go +++ b/internal/login/login.go @@ -1,4 +1,4 @@ -package commands +package login import ( "bufio" @@ -6,23 +6,19 @@ import ( "encoding/json" "errors" "fmt" - "io" + "io/fs" "net/http" "net/url" "os" "path/filepath" "strings" "time" -) - -var githubBase = "https://github.com" -var gitlabBase = "https://gitlab.com" - -var oauthClient = &http.Client{Timeout: 30 * time.Second} -var minimumPollInterval = 5 + "github.com/siliconwitchery/superstack-cli/internal/api" +) -func Login(arguments []string) error { +func Login(session api.Session, arguments []string) error { + oauthClient := &http.Client{Timeout: 30 * time.Second} if len(arguments) != 1 || (arguments[0] != "github" && arguments[0] != "gitlab") { return errors.New("login takes a provider: github or gitlab") @@ -30,25 +26,22 @@ func Login(arguments []string) error { provider := arguments[0] - // Ask the server which oauth apps to log in against - providersRequest, err := apiRequest(http.MethodGet, "/login", nil) + providersRequest, err := api.Request(session, http.MethodGet, "/login", nil) if err != nil { return err } - providersResponse, err := apiClient.Do(providersRequest) + providersResponse, err := session.Client.Do(providersRequest) if err != nil { - return fmt.Errorf("the server could not be reached: %w", err) + return errors.New("the server could not be reached, check your connection") } defer providersResponse.Body.Close() if providersResponse.StatusCode != http.StatusOK { - message, _ := io.ReadAll(io.LimitReader(providersResponse.Body, 4096)) - - return fmt.Errorf("the server said: %s", strings.TrimSpace(string(message))) + return api.ServerError(providersResponse) } providers := struct { @@ -62,20 +55,19 @@ func Login(arguments []string) error { return err } - // Pick the provider's endpoints var clientId, deviceCodeUrl, pollUrl, scope string switch provider { case "github": clientId = providers.GithubClientId - deviceCodeUrl = githubBase + "/login/device/code" - pollUrl = githubBase + "/login/oauth/access_token" + deviceCodeUrl = session.GithubBase + "/login/device/code" + pollUrl = session.GithubBase + "/login/oauth/access_token" scope = "user:email" case "gitlab": clientId = providers.GitlabClientId - deviceCodeUrl = gitlabBase + "/oauth/authorize_device" - pollUrl = gitlabBase + "/oauth/token" + deviceCodeUrl = session.GitlabBase + "/oauth/authorize_device" + pollUrl = session.GitlabBase + "/oauth/token" scope = "read_user" } @@ -83,7 +75,6 @@ func Login(arguments []string) error { return fmt.Errorf("the server offers no %s login", provider) } - // Ask for a one-time code codeForm := url.Values{ "client_id": {clientId}, "scope": {scope}, @@ -102,7 +93,7 @@ func Login(arguments []string) error { codeResponse, err := oauthClient.Do(codeRequest) if err != nil { - return fmt.Errorf("%s could not be reached: %w", provider, err) + return fmt.Errorf("%s could not be reached, check your connection", provider) } defer codeResponse.Body.Close() @@ -120,11 +111,11 @@ func Login(arguments []string) error { err = json.NewDecoder(codeResponse.Body).Decode(&code) if err != nil { - return fmt.Errorf("%s answered %s to the device code request", provider, codeResponse.Status) + return fmt.Errorf("%s would not start the login, try again", provider) } if code.Error != "" || code.DeviceCode == "" { - return fmt.Errorf("%s would not start the login: %s", provider, code.Error) + return fmt.Errorf("%s would not start the login, try again", provider) } enterAt := code.VerificationUri @@ -133,34 +124,31 @@ func Login(arguments []string) error { enterAt = code.VerificationUriComplete } - fmt.Printf("Copy your one-time code: %s\n", code.UserCode) - fmt.Printf("Then enter it at %s\n", enterAt) - fmt.Println("Press enter to open the browser.") - - // The read sits in a goroutine so an unpressed key never stalls the - // poll: the code may just as well be entered on another device. The - // stream is captured here, because the goroutine outlives the read and - // must not touch os.Stdin once something else may have replaced it. - prompt := os.Stdin + fmt.Fprintf(session.Out, "Copy your one-time code: %s\n", code.UserCode) + fmt.Fprintf(session.Out, "Then enter it at %s\n", enterAt) + fmt.Fprintln(session.Out, "Press enter to open the browser.") go func() { - _, err := bufio.NewReader(prompt).ReadString('\n') + _, err := bufio.NewReader(session.In).ReadString('\n') if err == nil { - openBrowser(enterAt) + session.OpenBrowser(enterAt) } }() - // Poll until the code is entered deadline := time.Now().Add(time.Duration(code.ExpiresIn) * time.Second) + const defaultPollInterval = 5 interval := code.Interval + if interval <= 0 { + interval = defaultPollInterval + } + accessToken := "" for accessToken == "" { - - time.Sleep(time.Duration(max(interval, minimumPollInterval)) * time.Second) + time.Sleep(time.Duration(interval) * time.Second) if time.Now().After(deadline) { return errors.New("the code expired before it was entered, run login again") @@ -185,13 +173,12 @@ func Login(arguments []string) error { pollResponse, err := oauthClient.Do(pollRequest) if err != nil { - return fmt.Errorf("%s could not be reached: %w", provider, err) + return fmt.Errorf("%s could not be reached, check your connection", provider) } poll := struct { AccessToken string `json:"access_token"` Error string `json:"error"` - Interval int `json:"interval"` }{} err = json.NewDecoder(pollResponse.Body).Decode(&poll) @@ -199,13 +186,13 @@ func Login(arguments []string) error { pollResponse.Body.Close() if err != nil { - return fmt.Errorf("%s answered %s while polling", provider, pollResponse.Status) + return fmt.Errorf("%s stopped answering, run login again", provider) } switch poll.Error { case "": if poll.AccessToken == "" { - return fmt.Errorf("%s approved the login but it did not complete", provider) + return fmt.Errorf("the login did not complete on %s, run login again", provider) } accessToken = poll.AccessToken @@ -213,11 +200,7 @@ func Login(arguments []string) error { case "authorization_pending": case "slow_down": - if poll.Interval > 0 { - interval = poll.Interval - } else { - interval += 5 - } + interval += 5 case "expired_token": return errors.New("the code expired before it was entered, run login again") @@ -226,11 +209,10 @@ func Login(arguments []string) error { return fmt.Errorf("the login was declined on %s", provider) default: - return fmt.Errorf("%s answered %q while polling", provider, poll.Error) + return fmt.Errorf("the login did not complete on %s, run login again", provider) } } - // Trade the provider's token for a superstack key loginBody, err := json.Marshal(map[string]string{ "provider": provider, "access_token": accessToken, @@ -240,7 +222,7 @@ func Login(arguments []string) error { return err } - loginRequest, err := apiRequest(http.MethodPost, "/login", bytes.NewReader(loginBody)) + loginRequest, err := api.Request(session, http.MethodPost, "/login", bytes.NewReader(loginBody)) if err != nil { return err @@ -248,18 +230,16 @@ func Login(arguments []string) error { loginRequest.Header.Set("Content-Type", "application/json") - loginResponse, err := apiClient.Do(loginRequest) + loginResponse, err := session.Client.Do(loginRequest) if err != nil { - return fmt.Errorf("the server could not be reached: %w", err) + return errors.New("the server could not be reached, check your connection") } defer loginResponse.Body.Close() if loginResponse.StatusCode != http.StatusOK { - message, _ := io.ReadAll(io.LimitReader(loginResponse.Body, 4096)) - - return fmt.Errorf("the server said: %s", strings.TrimSpace(string(message))) + return api.ServerError(loginResponse) } login := struct { @@ -277,8 +257,7 @@ func Login(arguments []string) error { return errors.New("the login did not complete") } - // Store the key - path, err := keyPath() + path, err := api.KeyPath() if err != nil { return err @@ -296,7 +275,60 @@ func Login(arguments []string) error { return err } - fmt.Printf("Logged in as %s.\n", login.Email) + fmt.Fprintf(session.Out, "Logged in as %s.\n", login.Email) + + return nil +} + +func Logout(session api.Session, arguments []string) error { + if len(arguments) != 0 { + return errors.New("logout takes no arguments") + } + + path, err := api.KeyPath() + + if err != nil { + return err + } + + keyBytes, err := os.ReadFile(path) + + if errors.Is(err, fs.ErrNotExist) { + fmt.Fprintln(session.Out, "Not logged in.") + return nil + } + + if err != nil { + return err + } + + revokeRequest, err := api.Request(session, http.MethodPost, "/logout", nil) + + if err != nil { + return err + } + + revokeRequest.Header.Set("Authorization", "Bearer "+strings.TrimSpace(string(keyBytes))) + + revokeResponse, err := session.Client.Do(revokeRequest) + + if err != nil { + return errors.New("you are still logged in, the server could not be reached") + } + + defer revokeResponse.Body.Close() + + if revokeResponse.StatusCode != http.StatusNoContent { + return fmt.Errorf("you are still logged in: %s", api.ServerError(revokeResponse)) + } + + err = os.Remove(path) + + if err != nil { + return err + } + + fmt.Fprintln(session.Out, "Logged out.") return nil } diff --git a/internal/commands/login_test.go b/internal/login/login_test.go similarity index 55% rename from internal/commands/login_test.go rename to internal/login/login_test.go index 40ea715..8c80944 100644 --- a/internal/commands/login_test.go +++ b/internal/login/login_test.go @@ -1,17 +1,22 @@ -package commands +package login import ( + "bytes" "encoding/json" "fmt" "net/http" "net/http/httptest" "os" + "path/filepath" "strings" "testing" "time" + + "github.com/siliconwitchery/superstack-cli/internal/api" + "github.com/siliconwitchery/superstack-cli/internal/api/apitest" ) -func fakeProviderForLogin(t *testing.T, provider string, deviceInterval int, pollAnswers []string) *[]time.Time { +func fakeProviderForLogin(t *testing.T, provider string, deviceInterval int, deviceAnswer string, pollAnswers []string) (*[]time.Time, string) { t.Helper() devicePath := "/login/device/code" @@ -31,8 +36,7 @@ func fakeProviderForLogin(t *testing.T, provider string, deviceInterval int, pol mux := http.NewServeMux() mux.HandleFunc("POST "+devicePath, func(w http.ResponseWriter, r *http.Request) { - // Real github answers form-encoded without this header, so a client - // that drops it must fail here too. + // Real github answers form-encoded unless this header is present. if r.Header.Get("Accept") != "application/json" { t.Error("the device code request does not accept json") http.Error(w, "not acceptable", http.StatusNotAcceptable) @@ -46,6 +50,11 @@ func fakeProviderForLogin(t *testing.T, provider string, deviceInterval int, pol return } + if deviceAnswer != "" { + fmt.Fprint(w, deviceAnswer) + return + } + verificationUriComplete := "" if provider == "gitlab" { @@ -100,29 +109,20 @@ func fakeProviderForLogin(t *testing.T, provider string, deviceInterval int, pol t.Cleanup(server.Close) - if provider == "gitlab" { - previousBase := gitlabBase - - gitlabBase = server.URL - - t.Cleanup(func() { gitlabBase = previousBase }) - } else { - previousBase := githubBase - - githubBase = server.URL - - t.Cleanup(func() { githubBase = previousBase }) - } - - return polledAt + return polledAt, server.URL } -func fakeSuperstack(t *testing.T) { +func fakeSuperstack(t *testing.T, providersRefusal string, loginAnswer string) (api.Session, *bytes.Buffer) { t.Helper() mux := http.NewServeMux() mux.HandleFunc("GET /login", func(w http.ResponseWriter, r *http.Request) { + if providersRefusal != "" { + http.Error(w, providersRefusal, http.StatusServiceUnavailable) + return + } + fmt.Fprint(w, `{ "github_client_id": "test-github-client", "gitlab_client_id": "test-gitlab-client" @@ -145,6 +145,11 @@ func fakeSuperstack(t *testing.T) { return } + if loginAnswer != "" { + fmt.Fprint(w, loginAnswer) + return + } + fmt.Fprint(w, `{"key": "ssk_test", "email": "someone@example.com"}`) }) @@ -152,9 +157,11 @@ func fakeSuperstack(t *testing.T) { t.Cleanup(server.Close) - chosenApiBase = server.URL + out := &bytes.Buffer{} + session := api.NewSession(server.URL, "test", strings.NewReader(""), out) + session.OpenBrowser = func(url string) {} - t.Cleanup(func() { chosenApiBase = "" }) + return session, out } func TestLogin(t *testing.T) { @@ -162,10 +169,50 @@ func TestLogin(t *testing.T) { name string provider string deviceInterval int + deviceAnswer string pollAnswers []string + providersError string + loginAnswer string wantError string wantPollGap time.Duration }{ + { + name: "superstack refuses the provider list", + provider: "github", + providersError: "login unavailable", + wantError: "login unavailable", + }, + { + name: "provider refuses the device code", + provider: "github", + deviceAnswer: `{"error":"access_denied"}`, + wantError: "github would not start the login, try again", + }, + { + name: "deadline passes before approval", + provider: "github", + deviceAnswer: `{"device_code":"test-device-code","user_code":"WDJB-MJHT","verification_uri":"https://example.com/device","expires_in":0,"interval":1}`, + wantError: "the code expired before it was entered, run login again", + }, + { + name: "poll has neither an error nor a token", + provider: "github", + pollAnswers: []string{`{}`}, + wantError: "the login did not complete on github, run login again", + }, + { + name: "poll has an unrecognised error", + provider: "github", + pollAnswers: []string{`{"error":"server_error"}`}, + wantError: "the login did not complete on github, run login again", + }, + { + name: "superstack returns an empty key", + provider: "github", + pollAnswers: []string{`{"access_token":"gho_test"}`}, + loginAnswer: `{"key":"","email":"someone@example.com"}`, + wantError: "the login did not complete", + }, { name: "github approved on the first poll", provider: "github", @@ -200,7 +247,7 @@ func TestLogin(t *testing.T) { `{"error": "slow_down", "interval": 1}`, `{"access_token": "gho_test"}`, }, - wantPollGap: time.Second, + wantPollGap: 6 * time.Second, }, { name: "gitlab slowed down without an interval", @@ -209,7 +256,7 @@ func TestLogin(t *testing.T) { `{"error": "slow_down"}`, `{"access_token": "glpat-test"}`, }, - wantPollGap: 5 * time.Second, + wantPollGap: 6 * time.Second, }, { name: "the device interval is honored", @@ -249,23 +296,28 @@ func TestLogin(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { - isolateKeyStorage(t) - - answerOnStdin(t, "") - - captureBrowserOpens(t) + if testing.Short() && (len(test.pollAnswers) > 0 || strings.Contains(test.deviceAnswer, `"expires_in":0`)) { + t.Skip("the poll loop waits real seconds") + } - previousMinimum := minimumPollInterval + apitest.IsolateKeyStorage(t) - minimumPollInterval = 0 + deviceInterval := test.deviceInterval - t.Cleanup(func() { minimumPollInterval = previousMinimum }) + if deviceInterval == 0 { + deviceInterval = 1 + } - polledAt := fakeProviderForLogin(t, test.provider, test.deviceInterval, test.pollAnswers) + polledAt, providerBase := fakeProviderForLogin(t, test.provider, deviceInterval, test.deviceAnswer, test.pollAnswers) + session, _ := fakeSuperstack(t, test.providersError, test.loginAnswer) - fakeSuperstack(t) + if test.provider == "gitlab" { + session.GitlabBase = providerBase + } else { + session.GithubBase = providerBase + } - err := Login([]string{test.provider}) + err := Login(session, []string{test.provider}) if test.wantPollGap > 0 { if len(*polledAt) < 2 { @@ -284,7 +336,7 @@ func TestLogin(t *testing.T) { t.Fatalf("error = %v, want it to mention %q", err, test.wantError) } - path, _ := keyPath() + path, _ := api.KeyPath() if _, statError := os.Stat(path); statError == nil { t.Fatal("a key was stored despite the failed login") @@ -297,7 +349,7 @@ func TestLogin(t *testing.T) { t.Fatal(err) } - path, err := keyPath() + path, err := api.KeyPath() if err != nil { t.Fatal(err) @@ -327,25 +379,16 @@ func TestLogin(t *testing.T) { } func TestLoginOpensTheBrowserOnEnter(t *testing.T) { - isolateKeyStorage(t) - - previousMinimum := minimumPollInterval - - minimumPollInterval = 0 - - t.Cleanup(func() { minimumPollInterval = previousMinimum }) + apitest.IsolateKeyStorage(t) - fakeProviderForLogin(t, "gitlab", 0, []string{`{"access_token": "glpat-test"}`}) + _, providerBase := fakeProviderForLogin(t, "gitlab", 1, "", []string{`{"access_token": "glpat-test"}`}) + session, _ := fakeSuperstack(t, "", "") + session.GitlabBase = providerBase + session.In = strings.NewReader("\n") + browserOpens := make(chan string, 1) + session.OpenBrowser = func(url string) { browserOpens <- url } - fakeSuperstack(t) - - browserOpens := captureBrowserOpens(t) - - answerOnStdin(t, "\n") - - _, err := captureStdout(t, func() error { - return Login([]string{"gitlab"}) - }) + err := Login(session, []string{"gitlab"}) if err != nil { t.Fatal(err) @@ -374,7 +417,7 @@ func TestLoginRequiresAProvider(t *testing.T) { } for _, test := range tests { - err := Login(test.arguments) + err := Login(api.Session{}, test.arguments) if err == nil || !strings.Contains(err.Error(), "a provider: github or gitlab") { t.Errorf("%s: error = %v, want the provider hint", test.name, err) @@ -383,7 +426,7 @@ func TestLoginRequiresAProvider(t *testing.T) { } func TestLoginProviderNotOffered(t *testing.T) { - isolateKeyStorage(t) + apitest.IsolateKeyStorage(t) mux := http.NewServeMux() @@ -395,13 +438,137 @@ func TestLoginProviderNotOffered(t *testing.T) { t.Cleanup(server.Close) - chosenApiBase = server.URL + out := &bytes.Buffer{} + session := api.NewSession(server.URL, "test", strings.NewReader(""), out) - t.Cleanup(func() { chosenApiBase = "" }) - - err := Login([]string{"gitlab"}) + err := Login(session, []string{"gitlab"}) if err == nil || !strings.Contains(err.Error(), "offers no gitlab login") { t.Fatalf("error = %v, want it to say the server offers no gitlab login", err) } } + +func TestLogout(t *testing.T) { + tests := []struct { + name string + arguments []string + storedKey string + serverDown bool + revokeStatus int + wantError string + wantRevocation bool + wantKeyKept bool + wantShown string + }{ + { + name: "arguments are refused", + arguments: []string{"now"}, + wantError: "logout takes no arguments", + }, + { + name: "revokes and forgets the stored key", + storedKey: "ssk_test", + revokeStatus: http.StatusNoContent, + wantRevocation: true, + wantShown: "Logged out.\n", + }, + { + name: "nothing stored", + wantShown: "Not logged in.\n", + }, + { + name: "server refuses the revocation", + storedKey: "ssk_test", + revokeStatus: http.StatusServiceUnavailable, + wantError: "still logged in", + wantRevocation: true, + wantKeyKept: true, + }, + { + name: "server unreachable", + storedKey: "ssk_test", + serverDown: true, + wantError: "still logged in", + wantKeyKept: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + apitest.IsolateKeyStorage(t) + + revokedKey := "" + + mux := http.NewServeMux() + + mux.HandleFunc("POST /logout", func(w http.ResponseWriter, r *http.Request) { + revokedKey = strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") + + w.WriteHeader(test.revokeStatus) + }) + + server := httptest.NewServer(mux) + + defer server.Close() + + if test.serverDown { + server.Close() + } + + out := &bytes.Buffer{} + session := api.NewSession(server.URL, "test", strings.NewReader(""), out) + + path, err := api.KeyPath() + + if err != nil { + t.Fatal(err) + } + + if test.storedKey != "" { + err = os.MkdirAll(filepath.Dir(path), 0o700) + + if err != nil { + t.Fatal(err) + } + + err = os.WriteFile(path, []byte(test.storedKey+"\n"), 0o600) + + if err != nil { + t.Fatal(err) + } + } + + err = Logout(session, test.arguments) + + if test.wantError != "" { + if err == nil || !strings.Contains(err.Error(), test.wantError) { + t.Fatalf("error = %v, want it to mention %q", err, test.wantError) + } + } else if err != nil { + t.Fatal(err) + } + + if test.wantRevocation && revokedKey != test.storedKey { + t.Errorf("the server saw %q revoked, want %q", revokedKey, test.storedKey) + } + + if !test.wantRevocation && revokedKey != "" { + t.Errorf("the server saw a revocation for %q, want none", revokedKey) + } + + _, statError := os.Stat(path) + + if test.wantKeyKept && statError != nil { + t.Error("the stored key is gone although the revocation failed") + } + + if !test.wantKeyKept && !os.IsNotExist(statError) { + t.Error("the stored key still exists after logout") + } + + if out.String() != test.wantShown { + t.Errorf("output = %q, want %q", out.String(), test.wantShown) + } + }) + } +} diff --git a/internal/member/member.go b/internal/member/member.go new file mode 100644 index 0000000..0ef4636 --- /dev/null +++ b/internal/member/member.go @@ -0,0 +1,193 @@ +package member + +import ( + "bufio" + "bytes" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "strconv" + "strings" + + "github.com/siliconwitchery/superstack-cli/internal/api" + "github.com/siliconwitchery/superstack-cli/internal/dispatch" +) + +func Add(session api.Session, arguments []string) error { + if len(arguments) != 2 || arguments[0] == "" { + return errors.New("member add takes an email address and a fleet id") + } + + email := arguments[0] + + 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") + } + + body, err := json.Marshal(map[string]string{"email": email}) + + if err != nil { + return err + } + + request, err := api.AuthenticatedRequest(session, http.MethodPost, + "/fleets/"+strconv.FormatInt(fleetId, 10)+"/members", bytes.NewReader(body)) + + if err != nil { + return err + } + + request.Header.Set("Content-Type", "application/json") + + response, err := session.Client.Do(request) + + if err != nil { + return errors.New("the server could not be reached, check your connection") + } + + defer response.Body.Close() + + if response.StatusCode != http.StatusNoContent { + return api.ServerError(response) + } + + fmt.Fprintf(session.Out, "Gave %s access.\n", email) + + return nil +} + +func List(session api.Session, arguments []string) error { + positionals, jsonOutput := dispatch.TakeJsonFlag(arguments) + + if len(positionals) != 1 { + return errors.New("member list takes a fleet id") + } + + fleetId, err := strconv.ParseInt(positionals[0], 10, 64) + + if err != nil || fleetId < 1 { + return errors.New("the fleet id is the number shown by fleet list") + } + + request, err := api.AuthenticatedRequest(session, http.MethodGet, + "/fleets/"+strconv.FormatInt(fleetId, 10)+"/members", nil) + + if err != nil { + return err + } + + response, err := session.Client.Do(request) + + if err != nil { + return errors.New("the server could not be reached, check your connection") + } + + defer response.Body.Close() + + if response.StatusCode != http.StatusOK { + return api.ServerError(response) + } + + people := struct { + Owner string `json:"owner"` + Members []string `json:"members"` + }{} + + err = json.NewDecoder(response.Body).Decode(&people) + + if err != nil { + return err + } + + if jsonOutput { + return json.NewEncoder(session.Out).Encode(people) + } + + emailWidth := max(len("EMAIL"), len(people.Owner)) + + for _, email := range people.Members { + emailWidth = max(emailWidth, len(email)) + } + + fmt.Fprintf(session.Out, "%-*s %s\n", emailWidth, "EMAIL", "ROLE") + + fmt.Fprintf(session.Out, "%-*s owner\n", emailWidth, people.Owner) + + for _, email := range people.Members { + fmt.Fprintf(session.Out, "%-*s member\n", emailWidth, email) + } + + return nil +} + +func Remove(session api.Session, arguments []string) error { + if len(arguments) != 2 || arguments[0] == "" { + return errors.New("member remove takes an email address and a fleet id") + } + + email := arguments[0] + + 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(session) + + if err != nil { + return err + } + + name := "" + found := false + + for _, fleet := range fleets { + if fleet.Id == fleetId { + name = fleet.Name + found = true + } + } + + if !found { + return errors.New("no such fleet") + } + + fmt.Fprintf(session.Out, "Take away %s's access to %q? [y/N] ", email, name) + + answer, _ := bufio.NewReader(session.In).ReadString('\n') + + answer = strings.ToLower(strings.TrimSpace(answer)) + + if answer != "y" && answer != "yes" { + fmt.Fprintln(session.Out, "Nothing removed.") + return nil + } + + request, err := api.AuthenticatedRequest(session, http.MethodDelete, + "/fleets/"+strconv.FormatInt(fleetId, 10)+"/members/"+url.PathEscape(email), nil) + + if err != nil { + return err + } + + response, err := session.Client.Do(request) + + if err != nil { + return errors.New("the server could not be reached, check your connection") + } + + defer response.Body.Close() + + if response.StatusCode != http.StatusNoContent { + return api.ServerError(response) + } + + fmt.Fprintf(session.Out, "Removed access for %s.\n", email) + + return nil +} diff --git a/internal/member/member_test.go b/internal/member/member_test.go new file mode 100644 index 0000000..76dc2c8 --- /dev/null +++ b/internal/member/member_test.go @@ -0,0 +1,301 @@ +package member + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + "testing" + + "github.com/siliconwitchery/superstack-cli/internal/api" + "github.com/siliconwitchery/superstack-cli/internal/api/apitest" +) + +func TestMemberAdd(t *testing.T) { + tests := []struct { + name string + refusal string + wantOutput string + wantError string + }{ + {name: "added", wantOutput: "Gave member@example.com access.\n"}, + {name: "server refusal", refusal: "no such account", wantError: "no such account"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + addedPath := "" + addedEmail := "" + mux := http.NewServeMux() + + mux.HandleFunc("POST /fleets/{id}/members", func(w http.ResponseWriter, r *http.Request) { + body := struct { + Email string `json:"email"` + }{} + + json.NewDecoder(r.Body).Decode(&body) + addedPath = r.URL.Path + addedEmail = body.Email + + if test.refusal != "" { + http.Error(w, test.refusal, http.StatusNotFound) + return + } + + w.WriteHeader(http.StatusNoContent) + }) + + session, out := apitest.LoggedInSession(t, mux) + + err := Add(session, []string{"member@example.com", "3"}) + + if test.wantError != "" { + if err == nil || err.Error() != test.wantError { + t.Fatalf("error = %v, want %q", err, test.wantError) + } + } else if err != nil { + t.Fatal(err) + } + + if addedPath != "/fleets/3/members" || addedEmail != "member@example.com" { + t.Errorf("the server saw %q added at %q, want %q at %q", + addedEmail, addedPath, "member@example.com", "/fleets/3/members") + } + + if out.String() != test.wantOutput { + t.Errorf("output = %q, want %q", out.String(), test.wantOutput) + } + }) + } +} + +func TestMemberAddArguments(t *testing.T) { + tests := []struct { + name string + arguments []string + wantError string + }{ + {"no arguments", nil, "takes an email address and a fleet id"}, + {"only an address", []string{"member@example.com"}, "takes an email address and a fleet id"}, + {"an empty address", []string{"", "3"}, "takes an email address and a fleet id"}, + {"a wordy id", []string{"member@example.com", "pilot"}, "shown by fleet list"}, + } + + for _, test := range tests { + err := Add(api.Session{}, test.arguments) + + if err == nil || !strings.Contains(err.Error(), test.wantError) { + t.Errorf("%s: error = %v, want it to mention %q", test.name, err, test.wantError) + } + } +} + +func TestMemberList(t *testing.T) { + tests := []struct { + name string + arguments []string + people string + wantFleet string + wantShown []string + wantExact string + refusal string + wantError string + }{ + { + name: "the people table", + arguments: []string{"3"}, + people: `{"owner":"owner@example.com","members":["member@example.com"]}`, + wantFleet: "3", + wantShown: []string{"EMAIL", "ROLE", "owner@example.com", "owner", "member@example.com", "member"}, + }, + { + name: "nobody but the owner", + arguments: []string{"7"}, + people: `{"owner":"owner@example.com","members":[]}`, + wantFleet: "7", + wantShown: []string{"owner@example.com", "owner"}, + }, + { + name: "machine-readable output", + arguments: []string{"3", "--json"}, + people: `{"owner":"owner@example.com","members":["member@example.com"]}`, + wantFleet: "3", + wantExact: `{"owner":"owner@example.com","members":["member@example.com"]}` + "\n", + }, + { + name: "the flag before the id", + arguments: []string{"--json", "5"}, + people: `{"owner":"owner@example.com","members":[]}`, + wantFleet: "5", + wantExact: `{"owner":"owner@example.com","members":[]}` + "\n", + }, + { + name: "server refusal", + arguments: []string{"3"}, + wantFleet: "3", + refusal: "only members can see this fleet", + wantError: "only members can see this fleet", + }, + { + name: "no fleet id", + arguments: []string{"--json"}, + wantError: "takes a fleet id", + }, + { + name: "two fleet ids", + arguments: []string{"3", "4"}, + wantError: "takes a fleet id", + }, + { + name: "a wordy id", + arguments: []string{"pilot"}, + wantError: "shown by fleet list", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + mux := http.NewServeMux() + + askedFleet := "" + + mux.HandleFunc("GET /fleets/{id}/members", func(w http.ResponseWriter, r *http.Request) { + askedFleet = r.PathValue("id") + + if test.refusal != "" { + http.Error(w, test.refusal, http.StatusForbidden) + return + } + + fmt.Fprint(w, test.people) + }) + + session, out := apitest.LoggedInSession(t, mux) + + err := List(session, test.arguments) + + printed := out.String() + + if test.wantError != "" { + if err == nil || !strings.Contains(err.Error(), test.wantError) { + t.Fatalf("error = %v, want it to mention %q", err, test.wantError) + } + + return + } + + if err != nil { + t.Fatal(err) + } + + if askedFleet != test.wantFleet { + t.Errorf("the people of fleet %q were listed, want fleet %q", askedFleet, test.wantFleet) + } + + if test.wantExact != "" && printed != test.wantExact { + t.Errorf("the output is %q, want exactly %q", printed, test.wantExact) + } + + for _, want := range test.wantShown { + if !strings.Contains(printed, want) { + t.Errorf("the output %q does not show %q", printed, want) + } + } + }) + } +} + +func TestMemberRemove(t *testing.T) { + tests := []struct { + name string + email string + answer string + refusal string + wantRemoved bool + wantShown string + wantError string + }{ + {name: "a plain address", email: "member@example.com", answer: "y\n", wantRemoved: true}, + {name: "an address with a hash", email: "a#b@example.com", answer: "yes\n", wantRemoved: true}, + {name: "the prompt names the fleet", email: "member@example.com", answer: "y\n", wantRemoved: true, wantShown: `access to "pilot"`}, + {name: "declined by default", email: "member@example.com", answer: "\n", wantShown: "Nothing removed"}, + {name: "declined with n", email: "member@example.com", answer: "n\n", wantShown: "Nothing removed"}, + {name: "closed input", email: "member@example.com", wantShown: "Nothing removed"}, + {name: "server refusal", email: "member@example.com", answer: "y\n", refusal: "only an owner can remove members", wantRemoved: true, wantError: "only an owner can remove members"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + removedFleet := "" + removedEmail := "" + + mux := http.NewServeMux() + + mux.HandleFunc("GET /fleets", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `[{"id":3,"name":"pilot","owner":true}]`) + }) + + mux.HandleFunc("DELETE /fleets/{id}/members/{email}", func(w http.ResponseWriter, r *http.Request) { + removedFleet = r.PathValue("id") + removedEmail = r.PathValue("email") + + if test.refusal != "" { + http.Error(w, test.refusal, http.StatusForbidden) + return + } + + w.WriteHeader(http.StatusNoContent) + }) + + session, out := apitest.LoggedInSession(t, mux) + + session.In = strings.NewReader(test.answer) + + err := Remove(session, []string{test.email, "3"}) + + printed := out.String() + + if test.wantError != "" { + if err == nil || err.Error() != test.wantError { + t.Fatalf("error = %v, want %q", err, test.wantError) + } + } else if err != nil { + t.Fatal(err) + } + + switch { + case test.wantRemoved && (removedFleet != "3" || removedEmail != test.email): + t.Errorf("the server saw %q removed from fleet %q, want %q from fleet %q", + removedEmail, removedFleet, test.email, "3") + + case !test.wantRemoved && removedEmail != "": + t.Errorf("the server saw %q removed although the confirmation was declined", removedEmail) + } + + if test.wantShown != "" && !strings.Contains(printed, test.wantShown) { + t.Errorf("the output %q does not show %q", printed, test.wantShown) + } + }) + } +} + +func TestMemberRemoveArguments(t *testing.T) { + tests := []struct { + name string + arguments []string + wantError string + }{ + {"no arguments", nil, "takes an email address and a fleet id"}, + {"only an address", []string{"member@example.com"}, "takes an email address and a fleet id"}, + {"an empty address", []string{"", "3"}, "takes an email address and a fleet id"}, + {"a wordy id", []string{"member@example.com", "pilot"}, "shown by fleet list"}, + } + + for _, test := range tests { + err := Remove(api.Session{}, test.arguments) + + if err == nil || !strings.Contains(err.Error(), test.wantError) { + t.Errorf("%s: error = %v, want it to mention %q", test.name, err, test.wantError) + } + } +} diff --git a/main.go b/main.go index 2080939..a69464a 100644 --- a/main.go +++ b/main.go @@ -2,249 +2,98 @@ package main import ( "fmt" - "io" "os" - "strings" - "github.com/siliconwitchery/superstack-cli/internal/commands" + "github.com/siliconwitchery/superstack-cli/internal/account" + "github.com/siliconwitchery/superstack-cli/internal/device" + "github.com/siliconwitchery/superstack-cli/internal/dispatch" + "github.com/siliconwitchery/superstack-cli/internal/fleet" + "github.com/siliconwitchery/superstack-cli/internal/key" + "github.com/siliconwitchery/superstack-cli/internal/login" + "github.com/siliconwitchery/superstack-cli/internal/member" ) const version = "0.0.3" -type command struct { - name string - arguments string - summary string - run func(arguments []string) error -} - -type section struct { - title string - commands []command -} - -var sections = []section{ +var sections = []dispatch.Section{ { - title: "Getting started", - commands: []command{ - {name: "login", arguments: "", summary: "Log in with the selected provider", run: commands.Login}, - {name: "logout", summary: "Log out of your account", run: commands.Logout}, + Title: "Getting started", + Commands: []dispatch.Command{ + {Name: "login", Arguments: "", Summary: "Log in with the selected provider", Run: login.Login}, + {Name: "logout", Summary: "Log out of your account", Run: login.Logout}, }, }, { - title: "Fleets", - commands: []command{ - {name: "fleet create", arguments: "", summary: "Create a fleet", run: commands.FleetCreate}, - {name: "fleet list", arguments: "[--json]", summary: "List the fleets you can reach", run: commands.FleetList}, - {name: "fleet rename", arguments: " ", summary: "Rename a fleet", run: commands.FleetRename}, - {name: "fleet transfer", arguments: " ", summary: "Hand a fleet to a new owner", run: commands.FleetTransfer}, - {name: "fleet delete", arguments: "", summary: "Delete a fleet and factory reset its devices", run: commands.FleetDelete}, + Title: "Fleets", + Commands: []dispatch.Command{ + {Name: "fleet create", Arguments: "", Summary: "Create a fleet", Run: fleet.Create}, + {Name: "fleet list", Arguments: "[--json]", Summary: "List the fleets you can reach", Run: fleet.List}, + {Name: "fleet rename", Arguments: " ", Summary: "Rename a fleet", Run: fleet.Rename}, + {Name: "fleet transfer", Arguments: " ", Summary: "Hand a fleet to a new owner", Run: fleet.Transfer}, + {Name: "fleet delete", Arguments: "", Summary: "Delete a fleet and release its devices", Run: fleet.Delete}, }, }, { - title: "Devices", - commands: []command{ - {name: "device claim", arguments: " [name]", summary: "Claim a device into a fleet, then press its button", run: commands.DeviceClaim}, - {name: "device list", arguments: "[fleet_id] [--json]", summary: "List devices, their state, and when they were last seen", run: commands.DeviceList}, - {name: "device rename", arguments: " ", summary: "Rename a device", run: commands.DeviceRename}, - {name: "device release", arguments: "", summary: "Unpair a device from its fleet and factory reset it", run: commands.DeviceRelease}, - {name: "device start", arguments: "", summary: "Run the code on the target"}, - {name: "device stop", arguments: "", summary: "Halt the code on the target"}, - {name: "device restart", arguments: "", summary: "Restart the code on the target"}, + Title: "Devices", + Commands: []dispatch.Command{ + {Name: "device claim", Arguments: " [name]", Summary: "Claim a device into a fleet, then press its pairing button", Run: device.Claim}, + {Name: "device list", Arguments: "[fleet_id] [--json]", Summary: "List devices, their state, and when they were last seen", Run: device.List}, + {Name: "device rename", Arguments: " ", Summary: "Rename a device", Run: device.Rename}, + {Name: "device release", Arguments: "", Summary: "Release a device from its fleet and erase everything on it", Run: device.Release}, + {Name: "device start", Arguments: "", Summary: "Start the code on a device"}, + {Name: "device stop", Arguments: "", Summary: "Stop the code on a device"}, + {Name: "device restart", Arguments: "", Summary: "Restart the code on a device"}, }, }, { - title: "Files", - commands: []command{ - {name: "upload", arguments: " ...", summary: "Upload files or directories to the target"}, - {name: "download", arguments: " ", summary: "Download the target's files into "}, - {name: "dev", arguments: " ... [--log-file ]", summary: "Upload on every change, and tail"}, + Title: "Files", + Commands: []dispatch.Command{ + {Name: "upload", Arguments: " ...", Summary: "Upload files or directories to a device or fleet"}, + {Name: "download", Arguments: " ", Summary: "Download a device or fleet's files into "}, + {Name: "dev", Arguments: " ... [--log-file ]", Summary: "Upload on every change, and tail"}, }, }, { - title: "Logs", - commands: []command{ - {name: "tail", arguments: " [-n num] [--log-file ]", summary: "Stream the target's log as it arrives"}, + Title: "Logs", + Commands: []dispatch.Command{ + {Name: "tail", Arguments: " [-n num] [--log-file ]", Summary: "Stream a device or fleet's log as it arrives"}, }, }, { - title: "People", - commands: []command{ - {name: "member add", arguments: " ", summary: "Give someone access to a fleet", run: commands.MemberAdd}, - {name: "member list", arguments: " [--json]", summary: "List the people who can reach a fleet", run: commands.MemberList}, - {name: "member remove", arguments: " ", summary: "Take away someone's access", run: commands.MemberRemove}, + Title: "People", + Commands: []dispatch.Command{ + {Name: "member add", Arguments: " ", Summary: "Give someone access to a fleet", Run: member.Add}, + {Name: "member list", Arguments: " [--json]", Summary: "List the people who can reach a fleet", Run: member.List}, + {Name: "member remove", Arguments: " ", Summary: "Take away someone's access", Run: member.Remove}, }, }, { - title: "Keys", - commands: []command{ - {name: "key create", arguments: "