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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 58 additions & 2 deletions internal/api/client.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -105,12 +105,40 @@ func (c *Client) post(ctx context.Context, path string, body any) (int, []byte,
return 0, nil, fmt.Errorf("building request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
c.setAuth(req)
resp, err := c.HTTP.Do(req)
if err != nil {
return 0, nil, fmt.Errorf("POST %s: %w", url, err)
}
defer func() { _ = resp.Body.Close() }()
raw, err := io.ReadAll(resp.Body)
if err != nil {
return resp.StatusCode, nil, fmt.Errorf("reading response from %s: %w", url, err)
}
return resp.StatusCode, raw, nil
}

// setAuth attaches the stored token as a Bearer credential. The login token is
// a ClientAccessToken, authenticated by the backend's
// ClientAccessTokenAuthentication (keyword "Bearer", backend#835) — NOT the
// legacy DRF "Token" scheme.
func (c *Client) setAuth(req *http.Request) {
if c.Token != "" {
req.Header.Set("Authorization", "Token "+c.Token)
req.Header.Set("Authorization", "Bearer "+c.Token)
}
}

// get sends an authenticated GET and returns the status code + raw response.
func (c *Client) get(ctx context.Context, path string) (int, []byte, error) {
url := c.BaseURL + path
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return 0, nil, fmt.Errorf("building request: %w", err)
}
c.setAuth(req)
resp, err := c.HTTP.Do(req)
if err != nil {
return 0, nil, fmt.Errorf("POST %s: %w", url, err)
return 0, nil, fmt.Errorf("GET %s: %w", url, err)
}
defer func() { _ = resp.Body.Close() }()
raw, err := io.ReadAll(resp.Body)
Expand DownExpand Up@@ -193,3 +221,31 @@ func (c *Client) PollToken(ctx context.Context, deviceCode string) (string, erro
}
return "", &APIError{StatusCode: status, Body: string(raw), URL: url}
}

// ── Authenticated calls (Bearer ClientAccessToken) ──

// Identity is the signed-in user, from GET /userinfo/.
type Identity struct {
Email string `json:"email"`
Type string `json:"type"`
Account string `json:"account"`
}

// WhoAmI fetches the signed-in user from the backend, authenticating with the
// stored token (Bearer). It confirms the token is live and returns the account
// — `login` uses it to verify the credential it just obtained. Requires Token.
func (c *Client) WhoAmI(ctx context.Context) (*Identity, error) {
url := c.BaseURL + "/userinfo/"
status, raw, err := c.get(ctx, "/userinfo/")
if err != nil {
return nil, err
}
if status < 200 || status >= 300 {
return nil, &APIError{StatusCode: status, Body: string(raw), URL: url}
}
var id Identity
if err := json.Unmarshal(raw, &id); err != nil {
return nil, fmt.Errorf("decoding userinfo response: %w", err)
}
return &id, nil
}
38 changes: 38 additions & 0 deletions internal/api/client_test.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -100,3 +100,41 @@ func TestPollTokenDenied(t *testing.T) {
t.Errorf("want access_denied, got %v", err)
}
}

func TestWhoAmI(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/userinfo/" || r.Method != http.MethodGet {
t.Errorf("unexpected %s %s", r.Method, r.URL.Path)
}
if got := r.Header.Get("Authorization"); got != "Bearer usertoken123" {
t.Errorf("auth header = %q, want %q", got, "Bearer usertoken123")
}
_, _ = w.Write([]byte(`{"email":"ds@tracebloc.io","type":"DS","account":"Acme"}`))
}))
defer srv.Close()
c := New("prod")
c.BaseURL = srv.URL
c.Token = "usertoken123"
id, err := c.WhoAmI(context.Background())
if err != nil {
t.Fatal(err)
}
if id.Email != "ds@tracebloc.io" || id.Account != "Acme" {
t.Errorf("got %+v", id)
}
}

func TestWhoAmIUnauthorized(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"detail":"Invalid token."}`))
}))
defer srv.Close()
c := New("prod")
c.BaseURL = srv.URL
c.Token = "bad"
var ae *APIError
if _, err := c.WhoAmI(context.Background()); !errors.As(err, &ae) || ae.StatusCode != http.StatusUnauthorized {
t.Errorf("want APIError 401, got %v", err)
}
}
25 changes: 22 additions & 3 deletions internal/cli/auth.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -42,13 +42,21 @@ machine. Honors HTTP(S)_PROXY / NO_PROXY for corporate-proxy networks.`,
return cmd
}

// Test seams: the device flow makes real HTTP calls on a timer, so tests
// override the client factory (point it at an httptest server) and the poll
// clock (fire immediately) rather than hitting the network / wall clock.
var (
newAPIClient = api.New
pollAfter = time.After
)

func runLogin(ctx context.Context, p *ui.Printer, envFlag string) error {
cfg, err := config.Load()
if err != nil {
return &exitError{code: 1, err: err}
}
env := api.ResolveEnv(envFlag)
client := api.New(env)
client := newAPIClient(env)

dc, err := client.RequestDeviceCode(ctx)
if err != nil {
Expand DownExpand Up@@ -87,19 +95,30 @@ func runLogin(ctx context.Context, p *ui.Printer, envFlag string) error {
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(time.Duration(interval) * time.Second):
case <-pollAfter(time.Duration(interval) * time.Second):
}

tok, err := client.PollToken(ctx, dc.DeviceCode)
switch {
case err == nil:
cfg.Env = env
cfg.Token = tok
// Confirm the freshly-issued token actually authenticates, and
// capture the account to show + store. Best-effort: don't fail a
// successful sign-in just because this lookup couldn't run.
client.Token = tok
if id, werr := client.WhoAmI(ctx); werr == nil {
cfg.Email = id.Email
}
if err := cfg.Save(); err != nil {
return &exitError{code: 1, err: err}
}
p.Newline()
p.Successf("Signed in. Token saved to ~/.tracebloc (0600).")
if cfg.Email != "" {
p.Successf("Signed in as %s. Token saved to ~/.tracebloc (0600).", cfg.Email)
} else {
p.Successf("Signed in. Token saved to ~/.tracebloc (0600).")
}
return nil
case errors.Is(err, api.ErrAuthorizationPending):
// not approved yet — keep polling
Expand Down
163 changes: 163 additions & 0 deletions internal/cli/auth_test.go
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
package cli

import (
"bytes"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"

"github.com/tracebloc/cli/internal/api"
"github.com/tracebloc/cli/internal/config"
)

// withTestBackend points the login command at an httptest server (via the
// newAPIClient seam), makes polling instant (pollAfter seam), and isolates the
// on-disk config to a temp dir. All are restored on cleanup.
func withTestBackend(t *testing.T, h http.HandlerFunc) {
t.Helper()
srv := httptest.NewServer(h)
t.Cleanup(srv.Close)
t.Setenv("TRACEBLOC_CONFIG_DIR", t.TempDir())

origClient, origAfter := newAPIClient, pollAfter
newAPIClient = func(string) *api.Client {
return &api.Client{BaseURL: srv.URL, HTTP: srv.Client()}
}
pollAfter = func(time.Duration) <-chan time.Time {
ch := make(chan time.Time, 1)
ch <- time.Time{}
return ch
}
t.Cleanup(func() { newAPIClient = origClient; pollAfter = origAfter })
}

func runCmd(t *testing.T, args ...string) (string, error) {
t.Helper()
root := NewRootCmd(BuildInfo{Version: "test"})
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs(args)
err := root.Execute()
return out.String(), err
}

func TestLogin_FullFlow(t *testing.T) {
var polls int
withTestBackend(t, func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/device/code":
_, _ = w.Write([]byte(`{"device_code":"dc","user_code":"WDJB-MJHT","verification_uri":"https://x/activate","expires_in":600,"interval":5}`))
case "/device/token":
polls++
if polls == 1 {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"error":"authorization_pending"}`))
return
}
_, _ = w.Write([]byte(`{"token":"cat_abc"}`))
case "/userinfo/":
if got := r.Header.Get("Authorization"); got != "Bearer cat_abc" {
t.Errorf("userinfo auth header = %q, want %q", got, "Bearer cat_abc")
}
_, _ = w.Write([]byte(`{"email":"ds@tracebloc.io","account":"Acme"}`))
default:
t.Errorf("unexpected request path %s", r.URL.Path)
}
})

out, err := runCmd(t, "login")
if err != nil {
t.Fatalf("login: %v", err)
}
if polls != 2 {
t.Errorf("expected 2 polls (pending then token), got %d", polls)
}
cfg, _ := config.Load()
if cfg.Token != "cat_abc" {
t.Errorf("stored token = %q, want cat_abc", cfg.Token)
}
if cfg.Email != "ds@tracebloc.io" {
t.Errorf("stored email = %q, want ds@tracebloc.io", cfg.Email)
}
if !strings.Contains(out, "ds@tracebloc.io") {
t.Errorf("expected output to show the account, got:\n%s", out)
}
}

func TestLogin_BackendUnsupported(t *testing.T) {
withTestBackend(t, func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNotFound)
})
_, err := runCmd(t, "login")
if err == nil || !strings.Contains(err.Error(), "doesn't support browser login") {
t.Errorf("want unsupported-backend error, got %v", err)
}
cfg, _ := config.Load()
if cfg.SignedIn() {
t.Error("must not store a token when the backend has no device endpoints")
}
}

func TestLogin_Denied(t *testing.T) {
withTestBackend(t, func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/device/code":
_, _ = w.Write([]byte(`{"device_code":"dc","user_code":"X","interval":5}`))
default:
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"error":"access_denied"}`))
}
})
_, err := runCmd(t, "login")
if err == nil || !strings.Contains(err.Error(), "denied") {
t.Errorf("want access-denied error, got %v", err)
}
}

func TestLogout(t *testing.T) {
t.Setenv("TRACEBLOC_CONFIG_DIR", t.TempDir())
if err := (&config.Config{Token: "x", Email: "e@co"}).Save(); err != nil {
t.Fatal(err)
}
out, err := runCmd(t, "logout")
if err != nil {
t.Fatal(err)
}
cfg, _ := config.Load()
if cfg.SignedIn() {
t.Error("expected to be signed out")
}
if !strings.Contains(out, "Signed out") {
t.Errorf("got:\n%s", out)
}
}

func TestAuthStatus_SignedIn(t *testing.T) {
t.Setenv("TRACEBLOC_CONFIG_DIR", t.TempDir())
if err := (&config.Config{Env: "dev", Token: "x", Email: "ds@co"}).Save(); err != nil {
t.Fatal(err)
}
out, err := runCmd(t, "auth", "status")
if err != nil {
t.Fatal(err)
}
for _, want := range []string{"signed in", "ds@co", "dev"} {
if !strings.Contains(out, want) {
t.Errorf("status output missing %q, got:\n%s", want, out)
}
}
}

func TestAuthStatus_NotSignedIn(t *testing.T) {
t.Setenv("TRACEBLOC_CONFIG_DIR", t.TempDir())
out, err := runCmd(t, "auth", "status")
if err != nil {
t.Fatal(err)
}
if !strings.Contains(out, "Not signed in") {
t.Errorf("got:\n%s", out)
}
}