From cb48c8ace4915e164a4816e4890954dd436d077d Mon Sep 17 00:00:00 2001 From: Erick Shaffer Date: Thu, 16 Jul 2026 20:19:53 -0600 Subject: [PATCH 1/2] fix: preserve Amazon authentication cookies --- docs/bug-fixes.md | 20 ++++++ .../adapters/providers/amazon/provider.go | 10 +++ .../providers/amazon/provider_test.go | 67 +++++++++++++++++++ internal/cli/amazon_auth.go | 3 + internal/cli/amazon_auth_test.go | 10 +++ 5 files changed, 110 insertions(+) diff --git a/docs/bug-fixes.md b/docs/bug-fixes.md index e9dc214..2bc7429 100644 --- a/docs/bug-fixes.md +++ b/docs/bug-fixes.md @@ -42,6 +42,26 @@ Upgraded to `walmart-client-go/v2 v2.2.1`, which captures the live browser reque - A second live client sequence fetched purchase history and then a completed in-store order with 4 items, confirming path-scoped history cookies no longer poison detail requests. - `./itemize walmart -dry-run -days 14 -max 1 -verbose` skipped the active `PLACED` summary, issued exactly one completed-order detail request, exited successfully, and logged no HTTP 456 response. +--- + +### 2026-07-16: Amazon setup rejected fresh logins and failed auth checks corrupted saved cookies + +**Description:** +`itemize amazon setup -account ` could open an authenticated Amazon browser profile but fail before saving its cookies with `unexpected end of JSON input`. A later sync with stale cookies then allowed `amazon-go` to auto-save cookies from Amazon's sign-in response before the health check reported the authentication failure, overwriting part of the previously saved cookie set. + +**Root Cause:** +The setup validator created an empty temporary file and passed that existing file to `amazon-go`, whose cookie store attempted to parse it as JSON. The provider also used `amazon-go`'s default auto-save behavior during health checks, even though a sign-in response is not a successful cookie refresh. + +**Fix Applied:** +The validator now removes the reserved temporary file before constructing its isolated validation client. The provider disables automatic cookie persistence and explicitly saves updated cookies only after successful order or order-detail fetches, leaving failed health checks read-only. + +**Verification:** +- `TestValidateImportedAmazonCookies_ReachesAuthCheck` reproduces the empty-file parse failure. +- `TestProvider_FailedHealthCheckDoesNotOverwriteSavedCookies` simulates an Amazon sign-in response that attempts to replace the session token. +- Successful fetch and detail tests verify that legitimate cookie refreshes are still persisted. + +--- + ### 2026-07-10: Walmart refunds lacked item-level categorization **Description:** diff --git a/internal/adapters/providers/amazon/provider.go b/internal/adapters/providers/amazon/provider.go index 54311e9..31d976f 100644 --- a/internal/adapters/providers/amazon/provider.go +++ b/internal/adapters/providers/amazon/provider.go @@ -22,6 +22,7 @@ type amazonClient interface { FetchOrderWithTransactions(ctx context.Context, orderID string) (*amazongo.Order, []*amazongo.Transaction, error) FetchTransactions(ctx context.Context, orderID string) ([]*amazongo.Transaction, error) HealthCheck() error + SaveCookies() error } // isValidProfile checks if an account name is safe to use as an Amazon cookie account. @@ -136,6 +137,7 @@ func (p *Provider) FetchOrders(ctx context.Context, opts providers.FetchOptions) } p.logger.Info("processed orders", slog.Int("count", len(orders))) + p.saveCookies(client) return orders, nil } @@ -154,6 +156,7 @@ func (p *Provider) GetOrderDetails(ctx context.Context, orderID string) (provide if amazonOrder == nil { return nil, fmt.Errorf("amazon order %q not found", orderID) } + p.saveCookies(client) return NewOrder(convertGoOrder(amazonOrder, transactions), p.logger), nil } @@ -218,6 +221,7 @@ func (p *Provider) getClient() (amazonClient, error) { opts := []amazongo.Option{ amazongo.WithLogger(p.logger), amazongo.WithRateLimit(p.rateLimit), + amazongo.WithAutoSave(false), } if p.profile != "" { opts = append(opts, amazongo.WithAccount(p.profile)) @@ -234,6 +238,12 @@ func (p *Provider) getClient() (amazonClient, error) { return client, nil } +func (p *Provider) saveCookies(client amazonClient) { + if err := client.SaveCookies(); err != nil { + p.logger.Warn("failed to save Amazon cookies", slog.String("error", err.Error())) + } +} + func (p *Provider) loginCommand() string { accountArg := "" if p.profile != "" { diff --git a/internal/adapters/providers/amazon/provider_test.go b/internal/adapters/providers/amazon/provider_test.go index 149386a..3a8f81d 100644 --- a/internal/adapters/providers/amazon/provider_test.go +++ b/internal/adapters/providers/amazon/provider_test.go @@ -2,7 +2,13 @@ package amazon import ( "context" + "encoding/json" "errors" + "io" + "net/http" + "os" + "path/filepath" + "strings" "testing" "time" @@ -128,6 +134,7 @@ func TestProvider_FetchOrdersUsesAmazonGoClientAndTransactions(t *testing.T) { require.NoError(t, err) assert.Equal(t, []float64{44.91}, charges) assert.Equal(t, []time.Time{txDate}, amazonOrder.GetTransactionDates()) + assert.True(t, client.cookiesSaved) } func TestProvider_FetchOrdersKeepsOrderWhenTransactionsFail(t *testing.T) { @@ -174,6 +181,52 @@ func TestProvider_FetchOrdersRewordsExpiredCookieError(t *testing.T) { assert.NotContains(t, err.Error(), "cookies are expired") } +func TestProvider_FailedHealthCheckDoesNotOverwriteSavedCookies(t *testing.T) { + cookieFile := filepath.Join(t.TempDir(), "cookies-wife.json") + stored := amazongo.CookieFile{ + Cookies: []*amazongo.Cookie{ + {Name: "session-id", Value: "original-session-id", Domain: ".amazon.com", Path: "/"}, + {Name: "session-token", Value: "original-session-token", Domain: ".amazon.com", Path: "/"}, + {Name: "ubid-main", Value: "original-ubid", Domain: ".amazon.com", Path: "/"}, + {Name: "at-main", Value: "original-at", Domain: ".amazon.com", Path: "/"}, + }, + UpdatedAt: time.Now(), + } + data, err := json.Marshal(stored) + require.NoError(t, err) + require.NoError(t, os.WriteFile(cookieFile, data, 0o600)) + + originalTransport := http.DefaultTransport + http.DefaultTransport = roundTripFunc(func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{ + "Set-Cookie": []string{"session-token=poisoned-by-sign-in; Path=/; Domain=.amazon.com"}, + }, + Body: io.NopCloser(strings.NewReader(`Amazon Sign-In`)), + Request: req, + }, nil + }) + t.Cleanup(func() { http.DefaultTransport = originalTransport }) + + provider := NewProvider(nil, &ProviderConfig{Profile: "wife", CookieFile: cookieFile}) + _, err = provider.FetchOrders(context.Background(), providers.FetchOptions{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "amazon auth check failed") + + after, err := os.ReadFile(cookieFile) + require.NoError(t, err) + var saved amazongo.CookieFile + require.NoError(t, json.Unmarshal(after, &saved)) + for _, cookie := range saved.Cookies { + if cookie.Name == "session-token" { + assert.Equal(t, "original-session-token", cookie.Value) + return + } + } + t.Fatal("saved session-token cookie not found") +} + func TestProvider_HealthCheckRewordsExpiredCookieError(t *testing.T) { client := &fakeAmazonClient{healthErr: errors.New("authentication failed: cookies are expired, please re-import cookies from browser")} provider := NewProviderWithClient(nil, &ProviderConfig{Profile: "wife"}, client) @@ -209,6 +262,7 @@ func TestProvider_GetOrderDetailsFetchesOrderWithTransactions(t *testing.T) { charges, err := order.(*Order).GetFinalCharges() require.NoError(t, err) assert.Equal(t, []float64{12.34}, charges) + assert.True(t, client.cookiesSaved) } func TestProvider_HealthCheckUsesAmazonGoClient(t *testing.T) { @@ -259,6 +313,14 @@ type fakeAmazonClient struct { fetchOrderWithTxErr error healthChecked bool healthErr error + cookiesSaved bool + saveCookiesErr error +} + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) } func (f *fakeAmazonClient) FetchOrders(_ context.Context, opts amazongo.FetchOptions) ([]*amazongo.Order, error) { @@ -271,6 +333,11 @@ func (f *fakeAmazonClient) FetchOrderWithTransactions(_ context.Context, orderID return f.detailOrder, f.detailTransactions, f.fetchOrderWithTxErr } +func (f *fakeAmazonClient) SaveCookies() error { + f.cookiesSaved = true + return f.saveCookiesErr +} + func (f *fakeAmazonClient) FetchTransactions(_ context.Context, orderID string) ([]*amazongo.Transaction, error) { if f.fetchTransactionsErr != nil { return nil, f.fetchTransactionsErr diff --git a/internal/cli/amazon_auth.go b/internal/cli/amazon_auth.go index 805aa6c..21bdb89 100644 --- a/internal/cli/amazon_auth.go +++ b/internal/cli/amazon_auth.go @@ -186,6 +186,9 @@ func validateImportedAmazonCookies(cookies []*amazon.Cookie) error { return fmt.Errorf("failed to close temporary Amazon auth file: %w", err) } defer func() { _ = os.Remove(tempPath) }() + if err := os.Remove(tempPath); err != nil { + return fmt.Errorf("failed to prepare temporary Amazon auth path: %w", err) + } client, err := newAmazonClient(tempPath, "") if err != nil { diff --git a/internal/cli/amazon_auth_test.go b/internal/cli/amazon_auth_test.go index 585d3b0..fd43418 100644 --- a/internal/cli/amazon_auth_test.go +++ b/internal/cli/amazon_auth_test.go @@ -43,6 +43,16 @@ func TestSaveImportedAmazonCookies_DoesNotOverwriteDestinationWhenValidationFail assert.JSONEq(t, string(original), string(after)) } +func TestValidateImportedAmazonCookies_ReachesAuthCheck(t *testing.T) { + err := validateImportedAmazonCookies([]*amazon.Cookie{ + {Name: "session-id", Value: "not-enough-cookies", Domain: ".amazon.com", Path: "/"}, + }) + + require.Error(t, err) + assert.Contains(t, err.Error(), "missing essential cookies") + assert.NotContains(t, err.Error(), "unexpected end of JSON input") +} + func TestSaveImportedAmazonCookies_SavesDestinationWhenAuthCheckSkipped(t *testing.T) { cookieFile := filepath.Join(t.TempDir(), "cookies-amazon-wife.json") From e786221f7c64b300294f8ce3e5c1f36a580ac519 Mon Sep 17 00:00:00 2001 From: Erick Shaffer Date: Fri, 17 Jul 2026 07:03:13 -0600 Subject: [PATCH 2/2] test: cover Amazon cookie save failures --- internal/adapters/providers/amazon/provider_test.go | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/internal/adapters/providers/amazon/provider_test.go b/internal/adapters/providers/amazon/provider_test.go index 3a8f81d..1d8467e 100644 --- a/internal/adapters/providers/amazon/provider_test.go +++ b/internal/adapters/providers/amazon/provider_test.go @@ -265,6 +265,15 @@ func TestProvider_GetOrderDetailsFetchesOrderWithTransactions(t *testing.T) { assert.True(t, client.cookiesSaved) } +func TestProvider_SaveCookiesTreatsPersistenceFailureAsRecoverable(t *testing.T) { + client := &fakeAmazonClient{saveCookiesErr: errors.New("read-only cookie file")} + provider := NewProviderWithClient(nil, &ProviderConfig{Profile: "wife"}, client) + + provider.saveCookies(client) + + assert.True(t, client.cookiesSaved) +} + func TestProvider_HealthCheckUsesAmazonGoClient(t *testing.T) { client := &fakeAmazonClient{} provider := NewProviderWithClient(nil, nil, client)