Files
geniusrun/backend/internal/garmin/client_test.go
Christophe Vila 9cbcf62aed 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 <noreply@anthropic.com>
2026-07-25 20:54:54 +02:00

279 lines
8.5 KiB
Go

package garmin
import (
"bufio"
"context"
"encoding/json"
"io"
"os/exec"
"strings"
"testing"
"time"
)
// wireResponsePayload is what a fake wrapper handler returns for one
// request; the harness fills in the response ID.
type wireResponsePayload struct {
result json.RawMessage
err string
}
func fakeResult(v any) wireResponsePayload {
b, err := json.Marshal(v)
if err != nil {
panic(err)
}
return wireResponsePayload{result: b}
}
func fakeError(msg string) wireResponsePayload {
return wireResponsePayload{err: msg}
}
// newFakeWrapperClient wires a subprocessClient to an in-process goroutine
// that plays the Python wrapper's role, so protocol-level Go logic can be
// tested without python3/garminconnect installed.
func newFakeWrapperClient(t *testing.T, handle func(cmd string, params json.RawMessage) wireResponsePayload) *subprocessClient {
t.Helper()
reqR, reqW := io.Pipe()
respR, respW := io.Pipe()
t.Cleanup(func() {
reqW.Close()
respW.Close()
})
go func() {
scanner := bufio.NewScanner(reqR)
scanner.Buffer(make([]byte, 0, 64*1024), maxWrapperLineBytes)
enc := json.NewEncoder(respW)
for scanner.Scan() {
var req struct {
ID int `json:"id"`
Cmd string `json:"cmd"`
Params json.RawMessage `json:"params"`
}
if err := json.Unmarshal(scanner.Bytes(), &req); err != nil {
continue
}
payload := handle(req.Cmd, req.Params)
resp := wireResponse{ID: req.ID, Result: payload.result, Error: payload.err}
if err := enc.Encode(resp); err != nil {
return
}
}
}()
scanner := bufio.NewScanner(respR)
scanner.Buffer(make([]byte, 0, 64*1024), maxWrapperLineBytes)
return &subprocessClient{
started: true,
enc: json.NewEncoder(reqW),
scanner: scanner,
}
}
func TestSubprocessClient_RoundTrip_DetectsIDMismatch(t *testing.T) {
reqR, reqW := io.Pipe()
respR, respW := io.Pipe()
defer reqW.Close()
defer respW.Close()
go func() {
scanner := bufio.NewScanner(reqR)
scanner.Buffer(make([]byte, 0, 64*1024), maxWrapperLineBytes)
scanner.Scan() // read and discard the one request
json.NewEncoder(respW).Encode(wireResponse{ID: 999, Result: json.RawMessage(`{}`)})
}()
scanner := bufio.NewScanner(respR)
scanner.Buffer(make([]byte, 0, 64*1024), maxWrapperLineBytes)
c := &subprocessClient{started: true, enc: json.NewEncoder(reqW), scanner: scanner}
_, err := c.roundTrip("authenticate", nil)
if err == nil || !strings.Contains(err.Error(), "id mismatch") {
t.Fatalf("roundTrip error = %v, want an id mismatch error", err)
}
}
func TestSubprocessClient_RoundTrip_SubprocessClosedIsError(t *testing.T) {
reqR, reqW := io.Pipe()
respR, respW := io.Pipe()
defer reqW.Close()
go func() {
scanner := bufio.NewScanner(reqR)
scanner.Buffer(make([]byte, 0, 64*1024), maxWrapperLineBytes)
scanner.Scan()
respW.Close() // subprocess "exited": stdout closes with no response
}()
scanner := bufio.NewScanner(respR)
scanner.Buffer(make([]byte, 0, 64*1024), maxWrapperLineBytes)
c := &subprocessClient{started: true, enc: json.NewEncoder(reqW), scanner: scanner}
_, err := c.roundTrip("authenticate", nil)
if err == nil || !strings.Contains(err.Error(), "closed") {
t.Fatalf("roundTrip error = %v, want a subprocess-closed error", err)
}
}
func TestSubprocessClient_RoundTrip_WrapperErrorPropagates(t *testing.T) {
c := newFakeWrapperClient(t, func(cmd string, params json.RawMessage) wireResponsePayload {
return fakeError("boom")
})
_, err := c.roundTrip("authenticate", nil)
if err == nil || !strings.Contains(err.Error(), "boom") {
t.Fatalf("roundTrip error = %v, want it to mention %q", err, "boom")
}
}
func TestSubprocessClient_UpdateCredentials_ResetsStartedState(t *testing.T) {
c := &subprocessClient{cfg: Config{GarminEmail: "old@example.com", GarminPassword: "old"}}
c.started = true // simulate an already-spawned subprocess, no real cmd/pipes
c.UpdateCredentials("new@example.com", "new")
if c.cfg.GarminEmail != "new@example.com" || c.cfg.GarminPassword != "new" {
t.Errorf("cfg after update = %+v, want new@example.com/new", c.cfg)
}
if c.started {
t.Error("started should be reset to false so the next call respawns the subprocess")
}
}
func TestSubprocessClient_Close_NoOpWhenNotStarted(t *testing.T) {
c := &subprocessClient{}
if err := c.Close(); err != nil {
t.Errorf("Close on an unstarted client = %v, want nil", err)
}
}
// TestSubprocessClient_Close_WaitsForStderrCopyGoroutine exercises close()
// against a real subprocess that writes to stderr, mirroring how
// ensureStarted wires up the stderr-copy goroutine. Per os/exec's
// StderrPipe docs it is incorrect to call Wait before all reads from the
// pipe have completed; this guards against close() calling cmd.Wait()
// before the copy goroutine has drained the pipe (which previously risked a
// race / truncated stderr / spurious "file already closed" errors).
func TestSubprocessClient_Close_WaitsForStderrCopyGoroutine(t *testing.T) {
cmd := exec.Command("sh", "-c", "for i in 1 2 3 4 5; do echo line$i 1>&2; done; sleep 5")
stderr, err := cmd.StderrPipe()
if err != nil {
t.Fatalf("StderrPipe: %v", err)
}
if err := cmd.Start(); err != nil {
t.Fatalf("Start: %v", err)
}
stderrDone := make(chan struct{})
go func() {
io.Copy(io.Discard, stderr)
close(stderrDone)
}()
c := &subprocessClient{started: true, cmd: cmd, stderrDone: stderrDone}
done := make(chan error, 1)
go func() { done <- c.close() }()
select {
case <-done:
// close() returned -- since it killed the process and waited on
// stderrDone before Wait-ing, this proves the ordering held without
// deadlocking.
case <-time.After(5 * time.Second):
t.Fatal("close() did not return in time -- likely blocked waiting on stderrDone")
}
if c.stderrDone != nil {
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)
}
}