From 9cbcf62aed7a9aebe3741cde8c2f15d7ef5367ae Mon Sep 17 00:00:00 2001 From: Christophe Vila Date: Sat, 25 Jul 2026 20:54:54 +0200 Subject: [PATCH] feat(garmin): implement Authenticate/CompleteMFA on subprocessClient Structured {status, message} responses replace the old string pattern-matching (parseAuthResult) -- both sides of the protocol are now owned by this repo, so there's no need to guess at phrasing anymore. Co-Authored-By: Claude Sonnet 5 --- backend/internal/garmin/client.go | 36 +++++++++++ backend/internal/garmin/client_test.go | 85 ++++++++++++++++++++++++++ 2 files changed, 121 insertions(+) diff --git a/backend/internal/garmin/client.go b/backend/internal/garmin/client.go index e39e9c9..fb4f9bb 100644 --- a/backend/internal/garmin/client.go +++ b/backend/internal/garmin/client.go @@ -225,6 +225,42 @@ func (c *subprocessClient) roundTrip(cmdName string, params any) (json.RawMessag return resp.Result, nil } +func (c *subprocessClient) Authenticate(ctx context.Context) (AuthResult, error) { + c.mu.Lock() + defer c.mu.Unlock() + + if err := c.ensureStarted(); err != nil { + return AuthResult{}, err + } + raw, err := c.roundTrip("authenticate", nil) + if err != nil { + return AuthResult{}, err + } + var wire authResultWire + if err := json.Unmarshal(raw, &wire); err != nil { + return AuthResult{}, fmt.Errorf("parse authenticate result: %w", err) + } + return AuthResult{Status: mapAuthStatus(wire.Status), Message: wire.Message}, nil +} + +func (c *subprocessClient) CompleteMFA(ctx context.Context, code string) (AuthResult, error) { + c.mu.Lock() + defer c.mu.Unlock() + + if err := c.ensureStarted(); err != nil { + return AuthResult{}, err + } + raw, err := c.roundTrip("complete_mfa", map[string]any{"code": code}) + if err != nil { + return AuthResult{}, err + } + var wire authResultWire + if err := json.Unmarshal(raw, &wire); err != nil { + return AuthResult{}, fmt.Errorf("parse complete_mfa result: %w", err) + } + return AuthResult{Status: mapAuthStatus(wire.Status), Message: wire.Message}, nil +} + // UpdateCredentials implements Client. func (c *subprocessClient) UpdateCredentials(email, password string) { c.mu.Lock() diff --git a/backend/internal/garmin/client_test.go b/backend/internal/garmin/client_test.go index bdaec6c..2261f06 100644 --- a/backend/internal/garmin/client_test.go +++ b/backend/internal/garmin/client_test.go @@ -2,6 +2,7 @@ package garmin import ( "bufio" + "context" "encoding/json" "io" "os/exec" @@ -191,3 +192,87 @@ func TestSubprocessClient_Close_WaitsForStderrCopyGoroutine(t *testing.T) { t.Error("stderrDone should be reset to nil after close()") } } + +func TestSubprocessClient_Authenticate_Success(t *testing.T) { + c := newFakeWrapperClient(t, func(cmd string, params json.RawMessage) wireResponsePayload { + if cmd != "authenticate" { + t.Fatalf("unexpected cmd %q", cmd) + } + return fakeResult(authResultWire{Status: "success", Message: "Authenticated successfully."}) + }) + + res, err := c.Authenticate(context.Background()) + if err != nil { + t.Fatalf("Authenticate: %v", err) + } + if res.Status != AuthSuccess || res.Message != "Authenticated successfully." { + t.Errorf("Authenticate result = %+v", res) + } +} + +func TestSubprocessClient_Authenticate_MFARequired(t *testing.T) { + c := newFakeWrapperClient(t, func(cmd string, params json.RawMessage) wireResponsePayload { + return fakeResult(authResultWire{Status: "mfa_required", Message: "MFA required. ..."}) + }) + + res, err := c.Authenticate(context.Background()) + if err != nil { + t.Fatalf("Authenticate: %v", err) + } + if res.Status != AuthMFARequired { + t.Errorf("Authenticate status = %v, want AuthMFARequired", res.Status) + } +} + +func TestSubprocessClient_Authenticate_WrapperErrorPropagates(t *testing.T) { + c := newFakeWrapperClient(t, func(cmd string, params json.RawMessage) wireResponsePayload { + return fakeError("subprocess exploded") + }) + + _, err := c.Authenticate(context.Background()) + if err == nil || !strings.Contains(err.Error(), "subprocess exploded") { + t.Fatalf("Authenticate error = %v, want it to mention the wrapper error", err) + } +} + +func TestSubprocessClient_CompleteMFA_SendsCodeAndReturnsStatus(t *testing.T) { + var gotCode string + c := newFakeWrapperClient(t, func(cmd string, params json.RawMessage) wireResponsePayload { + if cmd != "complete_mfa" { + t.Fatalf("unexpected cmd %q", cmd) + } + var p struct { + Code string `json:"code"` + } + if err := json.Unmarshal(params, &p); err != nil { + t.Fatalf("unmarshal params: %v", err) + } + gotCode = p.Code + return fakeResult(authResultWire{Status: "success", Message: "MFA accepted. Authenticated successfully."}) + }) + + res, err := c.CompleteMFA(context.Background(), "123456") + if err != nil { + t.Fatalf("CompleteMFA: %v", err) + } + if gotCode != "123456" { + t.Errorf("code sent to wrapper = %q, want 123456", gotCode) + } + if res.Status != AuthSuccess { + t.Errorf("CompleteMFA status = %v, want AuthSuccess", res.Status) + } +} + +func TestSubprocessClient_CompleteMFA_Failed(t *testing.T) { + c := newFakeWrapperClient(t, func(cmd string, params json.RawMessage) wireResponsePayload { + return fakeResult(authResultWire{Status: "failed", Message: "Authentication failed after MFA: bad code"}) + }) + + res, err := c.CompleteMFA(context.Background(), "000000") + if err != nil { + t.Fatalf("CompleteMFA: %v", err) + } + if res.Status != AuthFailed { + t.Errorf("CompleteMFA status = %v, want AuthFailed", res.Status) + } +}