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
20 changes: 20 additions & 0 deletions docs/bug-fixes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name>` 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:**
Expand Down
10 changes: 10 additions & 0 deletions internal/adapters/providers/amazon/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
}
Expand All @@ -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
}
Expand Down Expand Up @@ -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))
Expand All @@ -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 != "" {
Expand Down
76 changes: 76 additions & 0 deletions internal/adapters/providers/amazon/provider_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,13 @@ package amazon

import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"time"

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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(`<html><title>Amazon Sign-In</title><input id="ap_email"></html>`)),
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)
Expand Down Expand Up @@ -209,6 +262,16 @@ 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_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) {
Expand Down Expand Up @@ -259,6 +322,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) {
Expand All @@ -271,6 +342,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
Expand Down
3 changes: 3 additions & 0 deletions internal/cli/amazon_auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
10 changes: 10 additions & 0 deletions internal/cli/amazon_auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down