diff --git a/README.md b/README.md index 2a0321c..77be964 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ The fastest way to get set up is a single command: shelltime init ``` -`shelltime init` authenticates the CLI (in the browser, or pass `--token`), installs the bash, zsh and fish hooks and the daemon, and tries to wire up Claude Code and Codex OTEL integration for you. +`shelltime init` authenticates the CLI (in the browser, with `--email` to approve on your iPhone in the ShellTime iOS app, or with `--token`), installs the bash, zsh and fish hooks and the daemon, and tries to wire up Claude Code and Codex OTEL integration for you. Prefer to do it step by step? @@ -73,7 +73,7 @@ shelltime codex install | Command | Description | |---------|-------------| | `shelltime init` | Bootstrap auth, hooks, daemon, and AI-code integrations | -| `shelltime auth` | Authenticate with `shelltime.xyz` (`--token` skips the browser flow) | +| `shelltime auth` | Authenticate with `shelltime.xyz` (`--token` skips the browser flow; `--email you@example.com` asks the ShellTime iOS app to approve instead, with no browser, so agents can run it) | | `shelltime update` | Download and install the latest release in place (`--check`, `--force`, `--skip-daemon-reinstall`) | | `shelltime doctor` | Diagnose setup problems and show how to fix each one. `--fix` applies the safe fixes after asking (`--yes` skips the prompt), `--offline` skips network checks, `--format json` prints a machine-readable report. Exits non-zero when a check fails | | `shelltime web` | Open the ShellTime dashboard in a browser | diff --git a/cmd/cli/main.go b/cmd/cli/main.go index 311ca4a..141b106 100644 --- a/cmd/cli/main.go +++ b/cmd/cli/main.go @@ -125,7 +125,8 @@ func run() int { slog.Error("CLI error", slog.Any("err", err)) } commands.CloseLogger() - if doctorFailed { + // A sign-in through the iOS app that didn't complete already printed why. + if doctorFailed || errors.Is(err, commands.ErrAuthNotApproved) { return 1 } return 0 diff --git a/commands/auth.go b/commands/auth.go index f3962cb..b96a5a0 100644 --- a/commands/auth.go +++ b/commands/auth.go @@ -3,8 +3,11 @@ package commands import ( "context" "encoding/json" + "errors" "fmt" + "io" "log/slog" + "net/http" "os" "time" @@ -28,11 +31,37 @@ var AuthCommand *cli.Command = &cli.Command{ Usage: "Authentication token", Required: false, }, + emailFlag, }, Action: commandAuth, } +// emailFlag signs in by approving a request in the ShellTime iOS app instead +// of the browser. It needs no browser, so agents can run it. +var emailFlag = &cli.StringFlag{ + Name: "email", + Aliases: []string{"e"}, + Usage: "Sign in by approving a request on the iPhone of the account with this email (ShellTime iOS app)", + Required: false, +} + +// ErrAuthNotApproved means signing in through the iOS app did not complete: +// the request was denied, expired or refused. The CLI exits non-zero. +var ErrAuthNotApproved = errors.New("sign-in was not approved") + +// newAppApprovalService and appApprovalSleep are replaced in tests. +var ( + newAppApprovalService = model.NewAppApprovalService + appApprovalSleep = sleepContext +) + func commandAuth(c *cli.Context) error { + newToken := c.String("token") + email := c.String("email") + if newToken != "" && email != "" { + return errors.New("use either --token or --email, not both") + } + ctx, span := commandTracer.Start(c.Context, "auth") defer span.End() configDir := os.ExpandEnv("$HOME/" + model.COMMAND_BASE_STORAGE_FOLDER) @@ -73,9 +102,13 @@ func commandAuth(c *cli.Context) error { config = existingConfig } - newToken := c.String("token") - - if newToken == "" { + if newToken == "" && email != "" { + nt, err := ApplyTokenByAppApproval(ctx, config, email) + if err != nil { + return err + } + newToken = nt + } else if newToken == "" { nt, err := ApplyTokenByHandshake(ctx, config) if err != nil { return err @@ -146,6 +179,136 @@ func ApplyTokenByHandshake(_ctx context.Context, config model.ShellTimeConfig) ( } } +// ApplyTokenByAppApproval pushes a sign-in request to the ShellTime iOS app of +// the account with this email and waits for the user to approve it. Output is +// plain lines, so an agent driving the CLI can relay the code to the user. +func ApplyTokenByAppApproval(_ctx context.Context, config model.ShellTimeConfig, email string) (string, error) { + ctx, span := commandTracer.Start(_ctx, "appApproval", trace.WithSpanKind(trace.SpanKindClient)) + defer span.End() + + svc := newAppApprovalService(config) + ticket, err := svc.Request(ctx, email) + if err != nil { + var apiErr *model.AppApprovalError + if errors.As(err, &apiErr) { + color.Red.Println(" ❌ " + apiErr.Message) + if apiErr.StatusCode == http.StatusNotFound { + color.Yellow.Println("Run `shelltime auth` without --email to sign in with your browser instead.") + } + } else { + color.Red.Println(fmt.Sprintf(" ❌ Failed to send the sign-in request: %v", err)) + } + return "", fmt.Errorf("%w: %w", ErrAuthNotApproved, err) + } + + printAppApprovalIntro(os.Stdout, ticket) + stopIndicator := startWaitingIndicator() + token, err := waitForAppApproval(ctx, svc, ticket, appApprovalSleep) + stopIndicator() + if err != nil { + color.Red.Println(" ❌ " + err.Error()) + return "", err + } + + color.Green.Println(" ✅ You are ready to go!") + return token, nil +} + +func printAppApprovalIntro(out io.Writer, ticket model.AppApprovalTicket) { + fmt.Fprintln(out, "Sent a sign-in request to the ShellTime app on your iPhone.") + fmt.Fprintf(out, "Code: %s. Check that your iPhone shows the same code, then approve it.\n", ticket.MatchCode) + fmt.Fprintf(out, "Waiting for approval (expires in %s)...\n", time.Duration(ticket.ExpiresIn)*time.Second) +} + +// appApprovalPollGrace keeps polling a little past the expiry in case the +// server's clock is ahead; the server answers "expired" on its own anyway. +const appApprovalPollGrace = 30 * time.Second + +// waitForAppApproval polls until the request is approved, denied or expired. +// Network and server errors are retried until the request expires; a request +// the API refuses ends the wait. +func waitForAppApproval( + ctx context.Context, + svc model.AppApprovalService, + ticket model.AppApprovalTicket, + sleep func(context.Context, time.Duration) error, +) (string, error) { + // The server sends 2 s; keep a bad value from stalling or hammering it + interval := time.Duration(min(max(ticket.Interval, 1), 30)) * time.Second + giveUpAt := time.Now().Add(time.Duration(ticket.ExpiresIn)*time.Second + appApprovalPollGrace) + + for { + if err := sleep(ctx, interval); err != nil { + return "", err + } + + result, err := svc.Poll(ctx, ticket.RequestID, ticket.PollToken) + if err != nil { + if ctx.Err() != nil { + return "", ctx.Err() + } + var apiErr *model.AppApprovalError + if errors.As(err, &apiErr) && !retryableAppApprovalStatus(apiErr.StatusCode) { + return "", fmt.Errorf("%w: %s", ErrAuthNotApproved, apiErr.Message) + } + slog.Warn("app approval poll failed, retrying", slog.Any("err", err)) + } else { + switch result.Status { + case model.AppApprovalApproved: + if result.OpenToken == nil || result.OpenToken.Token == "" { + return "", fmt.Errorf("%w: the server approved the request but sent no token", ErrAuthNotApproved) + } + return result.OpenToken.Token, nil + case model.AppApprovalDenied: + return "", fmt.Errorf("%w: the request was denied on your iPhone", ErrAuthNotApproved) + case model.AppApprovalExpired: + return "", fmt.Errorf("%w: the request expired before it was approved", ErrAuthNotApproved) + } + } + + if time.Now().After(giveUpAt) { + return "", fmt.Errorf("%w: the request expired before it was approved", ErrAuthNotApproved) + } + } +} + +// retryableAppApprovalStatus: a poll that failed with this status may succeed +// next time (a server error, rate limiting, or a proxy timing out). +func retryableAppApprovalStatus(status int) bool { + switch status { + case http.StatusRequestTimeout, http.StatusTooEarly, http.StatusTooManyRequests: + return true + default: + return status >= http.StatusInternalServerError + } +} + +func sleepContext(ctx context.Context, d time.Duration) error { + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(d): + return nil + } +} + +// startWaitingIndicator shows a spinner when a person is watching the +// terminal; piped output (an agent, a script) stays plain. It returns the +// function that stops it. +func startWaitingIndicator() func() { + info, err := os.Stdout.Stat() + if err != nil || info.Mode()&os.ModeCharDevice == 0 { + return func() {} + } + l := stloader.NewLoader(stloader.LoaderConfig{ + Text: "Waiting for approval on your iPhone...", + EnableShining: true, + BaseColor: stloader.RGB{R: 100, G: 180, B: 255}, + }) + l.Start() + return l.Stop +} + // generateSchemaFile generates the JSON schema file for config autocompletion func generateSchemaFile(path string) error { reflector := &jsonschema.Reflector{ diff --git a/commands/auth_app_approval_test.go b/commands/auth_app_approval_test.go new file mode 100644 index 0000000..b894d28 --- /dev/null +++ b/commands/auth_app_approval_test.go @@ -0,0 +1,260 @@ +package commands + +import ( + "bytes" + "context" + "errors" + "net/http" + "os" + "path/filepath" + "testing" + "time" + + "github.com/malamtime/cli/model" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/urfave/cli/v2" +) + +type pollStep struct { + result model.AppApprovalPollResult + err error +} + +// fakeAppApproval answers polls from a script, repeating the last step. +type fakeAppApproval struct { + ticket model.AppApprovalTicket + err error + steps []pollStep + polls int +} + +func (f *fakeAppApproval) Request(context.Context, string) (model.AppApprovalTicket, error) { + return f.ticket, f.err +} + +func (f *fakeAppApproval) Poll(_ context.Context, requestID, pollToken string) (model.AppApprovalPollResult, error) { + if requestID != f.ticket.RequestID || pollToken != f.ticket.PollToken { + return model.AppApprovalPollResult{}, errors.New("unexpected request") + } + step := f.steps[min(f.polls, len(f.steps)-1)] + f.polls++ + return step.result, step.err +} + +func approvedWith(token string) pollStep { + result := model.AppApprovalPollResult{Status: model.AppApprovalApproved} + result.OpenToken = &struct { + ID int `json:"id"` + Token string `json:"token"` + }{ID: 1, Token: token} + return pollStep{result: result} +} + +func statusStep(status model.AppApprovalStatus) pollStep { + return pollStep{result: model.AppApprovalPollResult{Status: status}} +} + +var testTicket = model.AppApprovalTicket{RequestID: "rid", PollToken: "ptok", MatchCode: "42", ExpiresIn: 300, Interval: 3} + +// noSleep records the waits instead of sleeping. +func noSleep(waits *[]time.Duration) func(context.Context, time.Duration) error { + return func(ctx context.Context, d time.Duration) error { + *waits = append(*waits, d) + return ctx.Err() + } +} + +func TestWaitForAppApprovalApproved(t *testing.T) { + svc := &fakeAppApproval{ticket: testTicket, steps: []pollStep{ + statusStep(model.AppApprovalPending), + statusStep(model.AppApprovalPending), + approvedWith("st_v1_token"), + }} + var waits []time.Duration + + token, err := waitForAppApproval(context.Background(), svc, testTicket, noSleep(&waits)) + require.NoError(t, err) + assert.Equal(t, "st_v1_token", token) + assert.Equal(t, 3, svc.polls) + assert.Equal(t, []time.Duration{3 * time.Second, 3 * time.Second, 3 * time.Second}, waits, "uses the server's interval") +} + +func TestWaitForAppApprovalDenied(t *testing.T) { + svc := &fakeAppApproval{ticket: testTicket, steps: []pollStep{statusStep(model.AppApprovalDenied)}} + var waits []time.Duration + + _, err := waitForAppApproval(context.Background(), svc, testTicket, noSleep(&waits)) + assert.ErrorIs(t, err, ErrAuthNotApproved) + assert.Contains(t, err.Error(), "denied") +} + +func TestWaitForAppApprovalExpired(t *testing.T) { + svc := &fakeAppApproval{ticket: testTicket, steps: []pollStep{statusStep(model.AppApprovalExpired)}} + var waits []time.Duration + + _, err := waitForAppApproval(context.Background(), svc, testTicket, noSleep(&waits)) + assert.ErrorIs(t, err, ErrAuthNotApproved) + assert.Contains(t, err.Error(), "expired") +} + +func TestWaitForAppApprovalRetriesServerErrors(t *testing.T) { + svc := &fakeAppApproval{ticket: testTicket, steps: []pollStep{ + {err: errors.New("connection reset")}, + {err: &model.AppApprovalError{StatusCode: http.StatusBadGateway, Message: "Bad Gateway"}}, + approvedWith("st_v1_token"), + }} + var waits []time.Duration + + token, err := waitForAppApproval(context.Background(), svc, testTicket, noSleep(&waits)) + require.NoError(t, err) + assert.Equal(t, "st_v1_token", token) +} + +func TestWaitForAppApprovalRetriesProxyTimeouts(t *testing.T) { + svc := &fakeAppApproval{ticket: testTicket, steps: []pollStep{ + {err: &model.AppApprovalError{StatusCode: http.StatusRequestTimeout, Message: "Request Timeout"}}, + {err: &model.AppApprovalError{StatusCode: http.StatusTooManyRequests, Message: "slow down"}}, + approvedWith("st_v1_token"), + }} + var waits []time.Duration + + token, err := waitForAppApproval(context.Background(), svc, testTicket, noSleep(&waits)) + require.NoError(t, err) + assert.Equal(t, "st_v1_token", token) +} + +func TestWaitForAppApprovalClampsTheInterval(t *testing.T) { + ticket := testTicket + ticket.Interval = 3600 + svc := &fakeAppApproval{ticket: ticket, steps: []pollStep{approvedWith("st_v1_token")}} + var waits []time.Duration + + _, err := waitForAppApproval(context.Background(), svc, ticket, noSleep(&waits)) + require.NoError(t, err) + assert.Equal(t, []time.Duration{30 * time.Second}, waits) +} + +func TestWaitForAppApprovalStopsOnRefusal(t *testing.T) { + svc := &fakeAppApproval{ticket: testTicket, steps: []pollStep{ + {err: &model.AppApprovalError{StatusCode: http.StatusBadRequest, Message: "Invalid sign-in request"}}, + }} + var waits []time.Duration + + _, err := waitForAppApproval(context.Background(), svc, testTicket, noSleep(&waits)) + assert.ErrorIs(t, err, ErrAuthNotApproved) + assert.Contains(t, err.Error(), "Invalid sign-in request") + assert.Equal(t, 1, svc.polls) +} + +func TestWaitForAppApprovalCancelled(t *testing.T) { + svc := &fakeAppApproval{ticket: testTicket, steps: []pollStep{statusStep(model.AppApprovalPending)}} + ctx, cancel := context.WithCancel(context.Background()) + cancel() + var waits []time.Duration + + _, err := waitForAppApproval(ctx, svc, testTicket, noSleep(&waits)) + assert.ErrorIs(t, err, context.Canceled) + assert.Equal(t, 0, svc.polls) +} + +func TestWaitForAppApprovalGivesUpAfterExpiry(t *testing.T) { + ticket := testTicket + ticket.ExpiresIn = -int(appApprovalPollGrace/time.Second) - 1 + svc := &fakeAppApproval{ticket: ticket, steps: []pollStep{statusStep(model.AppApprovalPending)}} + var waits []time.Duration + + _, err := waitForAppApproval(context.Background(), svc, ticket, noSleep(&waits)) + assert.ErrorIs(t, err, ErrAuthNotApproved) + assert.Equal(t, 1, svc.polls) +} + +func TestPrintAppApprovalIntro(t *testing.T) { + var out bytes.Buffer + printAppApprovalIntro(&out, testTicket) + + assert.Contains(t, out.String(), "ShellTime app on your iPhone") + assert.Contains(t, out.String(), "Code: 42") + assert.Contains(t, out.String(), "expires in 5m0s") +} + +func TestApplyTokenByAppApprovalRefused(t *testing.T) { + original := newAppApprovalService + t.Cleanup(func() { newAppApprovalService = original }) + newAppApprovalService = func(model.ShellTimeConfig) model.AppApprovalService { + return &fakeAppApproval{err: &model.AppApprovalError{StatusCode: http.StatusNotFound, Message: "no iOS app"}} + } + + _, err := ApplyTokenByAppApproval(context.Background(), model.ShellTimeConfig{}, "me@example.com") + assert.ErrorIs(t, err, ErrAuthNotApproved) +} + +func TestAuthRejectsTokenWithEmail(t *testing.T) { + app := &cli.App{Commands: []*cli.Command{AuthCommand}} + err := app.Run([]string{"shelltime", "auth", "--token", "abc", "--email", "me@example.com"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "either --token or --email") +} + +// useFakeAppApproval swaps in svc and a sleep that returns at once. +func useFakeAppApproval(t *testing.T, svc *fakeAppApproval) { + originalService, originalSleep := newAppApprovalService, appApprovalSleep + t.Cleanup(func() { newAppApprovalService, appApprovalSleep = originalService, originalSleep }) + newAppApprovalService = func(model.ShellTimeConfig) model.AppApprovalService { return svc } + appApprovalSleep = func(ctx context.Context, _ time.Duration) error { return ctx.Err() } +} + +func TestApplyTokenByAppApprovalApproved(t *testing.T) { + svc := &fakeAppApproval{ticket: testTicket, steps: []pollStep{ + statusStep(model.AppApprovalPending), + approvedWith("st_v1_token"), + }} + useFakeAppApproval(t, svc) + + token, err := ApplyTokenByAppApproval(context.Background(), model.ShellTimeConfig{}, "me@example.com") + require.NoError(t, err) + assert.Equal(t, "st_v1_token", token) + assert.Equal(t, 2, svc.polls) +} + +func TestApplyTokenByAppApprovalDenied(t *testing.T) { + useFakeAppApproval(t, &fakeAppApproval{ticket: testTicket, steps: []pollStep{statusStep(model.AppApprovalDenied)}}) + + _, err := ApplyTokenByAppApproval(context.Background(), model.ShellTimeConfig{}, "me@example.com") + assert.ErrorIs(t, err, ErrAuthNotApproved) +} + +func TestApplyTokenByAppApprovalUnreachable(t *testing.T) { + useFakeAppApproval(t, &fakeAppApproval{err: errors.New("connection refused")}) + + _, err := ApplyTokenByAppApproval(context.Background(), model.ShellTimeConfig{}, "me@example.com") + assert.ErrorIs(t, err, ErrAuthNotApproved) + assert.Contains(t, err.Error(), "connection refused") +} + +func TestAuthWithEmailSavesTheToken(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + useFakeAppApproval(t, &fakeAppApproval{ticket: testTicket, steps: []pollStep{approvedWith("st_v1_saved")}}) + + app := &cli.App{Commands: []*cli.Command{AuthCommand}} + require.NoError(t, app.Run([]string{"shelltime", "auth", "--email", "me@example.com"})) + + content, err := os.ReadFile(filepath.Join(home, model.COMMAND_BASE_STORAGE_FOLDER, "config.yaml")) + require.NoError(t, err) + assert.Contains(t, string(content), "st_v1_saved") +} + +func TestSleepContext(t *testing.T) { + assert.NoError(t, sleepContext(context.Background(), time.Millisecond)) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + assert.ErrorIs(t, sleepContext(ctx, time.Hour), context.Canceled) +} + +func TestWaitingIndicatorIsQuietWhenPiped(t *testing.T) { + // go test's stdout is not a terminal, so this must be a no-op + stop := startWaitingIndicator() + stop() +} diff --git a/commands/init.go b/commands/init.go index dc57169..6842d34 100644 --- a/commands/init.go +++ b/commands/init.go @@ -15,6 +15,7 @@ var InitCommand *cli.Command = &cli.Command{ Usage: "Authentication token", Required: false, }, + emailFlag, }, Action: commandInit, } diff --git a/model/app_approval.go b/model/app_approval.go new file mode 100644 index 0000000..4eb019d --- /dev/null +++ b/model/app_approval.go @@ -0,0 +1,148 @@ +package model + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "os" + "time" +) + +// AppApprovalTicket is a sign-in request waiting for approval in the ShellTime +// iOS app. +type AppApprovalTicket struct { + RequestID string `json:"requestId"` + // PollToken authenticates our polls; only this CLI has it + PollToken string `json:"pollToken"` + // MatchCode is also shown in the iOS app, so the user can check the + // request they approve is this one + MatchCode string `json:"matchCode"` + ExpiresIn int `json:"expiresIn"` + // Interval is the number of seconds between polls + Interval int `json:"interval"` +} + +type AppApprovalStatus string + +const ( + AppApprovalPending AppApprovalStatus = "pending" + AppApprovalApproved AppApprovalStatus = "approved" + AppApprovalDenied AppApprovalStatus = "denied" + AppApprovalExpired AppApprovalStatus = "expired" +) + +type AppApprovalPollResult struct { + Status AppApprovalStatus `json:"status"` + ExpiresIn int `json:"expiresIn,omitempty"` + // OpenToken is set on the one poll that collects an approved request + OpenToken *struct { + ID int `json:"id"` + Token string `json:"token"` + } `json:"openToken,omitempty"` +} + +// AppApprovalError is the API refusing a request, with its message for the user. +type AppApprovalError struct { + StatusCode int + Message string +} + +func (e *AppApprovalError) Error() string { + return fmt.Sprintf("app approval: HTTP %d: %s", e.StatusCode, e.Message) +} + +// AppApprovalService signs the CLI in by having the user approve a push in the +// ShellTime iOS app. +type AppApprovalService interface { + // Request pushes a sign-in request to the iOS app of the account with this email. + Request(ctx context.Context, email string) (AppApprovalTicket, error) + // Poll reports the request's status; the first poll after approval carries the token. + Poll(ctx context.Context, requestID, pollToken string) (AppApprovalPollResult, error) +} + +type appApprovalService struct { + config ShellTimeConfig +} + +func NewAppApprovalService(config ShellTimeConfig) AppApprovalService { + return appApprovalService{config: config} +} + +type appApprovalRequestBody struct { + Email string `json:"email"` + Client string `json:"client"` + Hostname string `json:"hostname"` + OS string `json:"os"` + OSVersion string `json:"osVersion"` +} + +type appApprovalPollBody struct { + RequestID string `json:"requestId"` + PollToken string `json:"pollToken"` +} + +func (s appApprovalService) Request(ctx context.Context, email string) (AppApprovalTicket, error) { + sysInfo, err := GetOSAndVersion() + if err != nil { + slog.Error("failed to get OS version", slog.Any("err", err)) + sysInfo = &SysInfo{Os: "unknown", Version: "unknown"} + } + hostname, err := os.Hostname() + if err != nil { + hostname = "unknown" + } + + var ticket AppApprovalTicket + err = s.post(ctx, "/request", appApprovalRequestBody{ + Email: email, + Client: "cli", + Hostname: hostname, + OS: sysInfo.Os, + OSVersion: sysInfo.Version, + }, &ticket) + return ticket, err +} + +func (s appApprovalService) Poll(ctx context.Context, requestID, pollToken string) (AppApprovalPollResult, error) { + var result AppApprovalPollResult + err := s.post(ctx, "/poll", appApprovalPollBody{RequestID: requestID, PollToken: pollToken}, &result) + return result, err +} + +// post sends an unauthenticated request: the CLI has no token yet. +func (s appApprovalService) post(ctx context.Context, path string, payload, result any) error { + body, err := json.Marshal(payload) + if err != nil { + return err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.config.APIEndpoint+"/api/v1/auth/app-approval"+path, bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", fmt.Sprintf("shelltimeCLI@%s", commitID)) + + resp, err := NewHTTPClient(30 * time.Second).Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + buf, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + if resp.StatusCode != http.StatusOK { + message := http.StatusText(resp.StatusCode) + var errResp errorResponse + if json.Unmarshal(buf, &errResp) == nil && errResp.ErrorMessage != "" { + message = errResp.ErrorMessage + } + return &AppApprovalError{StatusCode: resp.StatusCode, Message: message} + } + return json.Unmarshal(buf, result) +} diff --git a/model/app_approval_test.go b/model/app_approval_test.go new file mode 100644 index 0000000..8cd4b1f --- /dev/null +++ b/model/app_approval_test.go @@ -0,0 +1,91 @@ +package model + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAppApprovalRequest(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "/api/v1/auth/app-approval/request", r.URL.Path) + assert.Equal(t, "application/json", r.Header.Get("Content-Type")) + assert.Contains(t, r.Header.Get("User-Agent"), "shelltimeCLI@") + assert.Empty(t, r.Header.Get("Authorization")) + + var payload appApprovalRequestBody + require.NoError(t, json.NewDecoder(r.Body).Decode(&payload)) + assert.Equal(t, "me@example.com", payload.Email) + assert.Equal(t, "cli", payload.Client) + assert.NotEmpty(t, payload.Hostname) + assert.NotEmpty(t, payload.OS) + + w.Write([]byte(`{"requestId":"rid","pollToken":"ptok","matchCode":"42","expiresIn":300,"interval":2}`)) + })) + defer server.Close() + + ticket, err := NewAppApprovalService(ShellTimeConfig{APIEndpoint: server.URL}).Request(context.Background(), "me@example.com") + require.NoError(t, err) + assert.Equal(t, AppApprovalTicket{RequestID: "rid", PollToken: "ptok", MatchCode: "42", ExpiresIn: 300, Interval: 2}, ticket) +} + +func TestAppApprovalRequestRefused(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + w.Write([]byte(`{"code":404,"error":"This account can't approve sign-ins in the ShellTime iOS app."}`)) + })) + defer server.Close() + + _, err := NewAppApprovalService(ShellTimeConfig{APIEndpoint: server.URL}).Request(context.Background(), "me@example.com") + var apiErr *AppApprovalError + require.True(t, errors.As(err, &apiErr)) + assert.Equal(t, http.StatusNotFound, apiErr.StatusCode) + assert.Equal(t, "This account can't approve sign-ins in the ShellTime iOS app.", apiErr.Message) + assert.Contains(t, err.Error(), "HTTP 404") +} + +func TestAppApprovalErrorWithoutEnvelope(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadGateway) + w.Write([]byte(`bad gateway`)) + })) + defer server.Close() + + _, err := NewAppApprovalService(ShellTimeConfig{APIEndpoint: server.URL}).Poll(context.Background(), "rid", "ptok") + var apiErr *AppApprovalError + require.True(t, errors.As(err, &apiErr)) + assert.Equal(t, http.StatusBadGateway, apiErr.StatusCode) + assert.Equal(t, "Bad Gateway", apiErr.Message) +} + +func TestAppApprovalPoll(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/v1/auth/app-approval/poll", r.URL.Path) + var payload appApprovalPollBody + require.NoError(t, json.NewDecoder(r.Body).Decode(&payload)) + assert.Equal(t, appApprovalPollBody{RequestID: "rid", PollToken: "ptok"}, payload) + + w.Write([]byte(`{"status":"approved","openToken":{"id":9,"token":"st_v1_abc"}}`)) + })) + defer server.Close() + + result, err := NewAppApprovalService(ShellTimeConfig{APIEndpoint: server.URL}).Poll(context.Background(), "rid", "ptok") + require.NoError(t, err) + assert.Equal(t, AppApprovalApproved, result.Status) + require.NotNil(t, result.OpenToken) + assert.Equal(t, "st_v1_abc", result.OpenToken.Token) +} + +func TestAppApprovalUnreachable(t *testing.T) { + _, err := NewAppApprovalService(ShellTimeConfig{APIEndpoint: "http://127.0.0.1:1"}).Request(context.Background(), "me@example.com") + assert.Error(t, err) + var apiErr *AppApprovalError + assert.False(t, errors.As(err, &apiErr)) +}