From 5391901efb0ca157d74b5cfe081443cb5a75fd54 Mon Sep 17 00:00:00 2001 From: tela Date: Mon, 9 Nov 2026 19:55:50 -0800 Subject: [PATCH 1/2] fix(demo): flag a seeded world an app emulator no longer holds Status reported ready from health checks alone. A restarted Plaid or Xero answers health but has lost the saved access token and invoices, so the page showed three green Ready badges over a run that failed at Connect with INVALID_ACCESS_TOKEN. Check the saved Stripe account, Plaid token and Xero invoice count, and report a vendor that answers with a definite 4xx as stale. A timeout or 5xx is not called stale. --- .../internal/api/admin.go | 7 +- .../acme-commerce-lenders/internal/api/api.go | 5 +- .../internal/api/api_test.go | 53 ++++++++++++ demos/acme-commerce-lenders/world/plaid.go | 8 +- demos/acme-commerce-lenders/world/verify.go | 57 ++++++++++++ .../world/verify_test.go | 86 +++++++++++++++++++ 6 files changed, 213 insertions(+), 3 deletions(-) create mode 100644 demos/acme-commerce-lenders/world/verify.go create mode 100644 demos/acme-commerce-lenders/world/verify_test.go diff --git a/demos/acme-commerce-lenders/internal/api/admin.go b/demos/acme-commerce-lenders/internal/api/admin.go index a873656..cfcbe36 100644 --- a/demos/acme-commerce-lenders/internal/api/admin.go +++ b/demos/acme-commerce-lenders/internal/api/admin.go @@ -33,7 +33,12 @@ func (a WorldAdmin) Status(ctx context.Context) (Status, error) { case !errors.Is(err, core.ErrNotFound): return st, err } - st.Ready = healthy && st.Seeded + // Only a healthy app emulator can be asked what it holds. An unhealthy one is + // already reported, and its saved world may still be there when it is back. + if healthy && st.Seeded { + st.Stale = world.Verify(ctx, a.HC, a.Targets, facts) + } + st.Ready = healthy && st.Seeded && len(st.Stale) == 0 return st, nil } diff --git a/demos/acme-commerce-lenders/internal/api/api.go b/demos/acme-commerce-lenders/internal/api/api.go index 634d6f8..dcd41f2 100644 --- a/demos/acme-commerce-lenders/internal/api/api.go +++ b/demos/acme-commerce-lenders/internal/api/api.go @@ -36,10 +36,13 @@ type Admin interface { // Status is what the UI needs to know before it lets a presenter start. type Status struct { - Ready bool `json:"ready"` // every app emulator is healthy and the world is seeded + Ready bool `json:"ready"` // every app emulator is healthy and holds the seeded world Seeded bool `json:"seeded"` Emulators []seed.Status `json:"emulators"` Facts *world.Facts `json:"facts,omitempty"` + // Stale lists each app emulator that is healthy but no longer holds the + // world that was seeded, such as one that restarted. A reset fixes it. + Stale []world.Stale `json:"stale,omitempty"` } // New returns the API handler, to be mounted at /api/. diff --git a/demos/acme-commerce-lenders/internal/api/api_test.go b/demos/acme-commerce-lenders/internal/api/api_test.go index c1844f2..98fd556 100644 --- a/demos/acme-commerce-lenders/internal/api/api_test.go +++ b/demos/acme-commerce-lenders/internal/api/api_test.go @@ -6,6 +6,8 @@ import ( "errors" "net/http" "net/http/httptest" + "net/http/httputil" + "net/url" "strings" "sync" "sync/atomic" @@ -297,3 +299,54 @@ func TestTargetsReportAndChangeWhichBaseURLEachVendorIsPointedAt(t *testing.T) { } } } + +func TestAStaleWorldIsNotReadyUntilItIsReset(t *testing.T) { + // Plaid restarts: it answers health, but the access token saved at seeding + // is gone. The status must not say ready, and a reset must put it right. + var resets atomic.Int32 + vendors := fakevendors.New(t, &resets) + var plaidRestarted atomic.Bool + front := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/item/get" && plaidRestarted.Load() { + http.Error(w, `{"error_code":"INVALID_ACCESS_TOKEN"}`, http.StatusBadRequest) + return + } + if r.URL.Path == "/admin/reset" { + plaidRestarted.Store(false) // a reset gives it a fresh Item + } + httputil.NewSingleHostReverseProxy(mustURL(t, vendors.URL)).ServeHTTP(w, r) + })) + defer front.Close() + tg := world.Targets{Stripe: wire.Direct("stripe", front.URL), Plaid: wire.Direct("plaid", front.URL), Xero: wire.Direct("xero", front.URL)} + admin := WorldAdmin{Targets: tg, Store: core.NewMemStore(), Rec: wire.NewRecorder(10)} + ctx := context.Background() + + if _, err := admin.Reset(ctx); err != nil { + t.Fatal(err) + } + if st, err := admin.Status(ctx); err != nil || !st.Ready || len(st.Stale) != 0 { + t.Fatalf("a freshly seeded world is ready: %+v, %v", st, err) + } + + plaidRestarted.Store(true) + st, err := admin.Status(ctx) + if err != nil || st.Ready || !st.Seeded || len(st.Stale) == 0 || st.Stale[0].Vendor != "plaid" { + t.Fatalf("a restarted Plaid makes the world stale, not ready: %+v, %v", st, err) + } + + if _, err := admin.Reset(ctx); err != nil { + t.Fatal(err) + } + if st, err = admin.Status(ctx); err != nil || !st.Ready || len(st.Stale) != 0 { + t.Fatalf("a reset makes it ready again: %+v, %v", st, err) + } +} + +func mustURL(t *testing.T, s string) *url.URL { + t.Helper() + u, err := url.Parse(s) + if err != nil { + t.Fatal(err) + } + return u +} diff --git a/demos/acme-commerce-lenders/world/plaid.go b/demos/acme-commerce-lenders/world/plaid.go index 4214495..cb30dec 100644 --- a/demos/acme-commerce-lenders/world/plaid.go +++ b/demos/acme-commerce-lenders/world/plaid.go @@ -28,6 +28,12 @@ type PlaidFacts struct { const plaidInstitution = "ins_109508" +// The sandbox credentials the lender sends Plaid. The app emulator accepts any. +const ( + plaidClientID = "acme-demo-client" + plaidSecret = "acme-demo-secret" +) + // SeedPlaid connects a bank account for the merchant the way a Plaid Link // session would, through the sandbox. The app emulator's clock is moved to the // world's date first, so the fixture's transactions fall in the last few days @@ -35,7 +41,7 @@ const plaidInstitution = "ins_109508" func SeedPlaid(ctx context.Context, hc *http.Client, t wire.Target) (PlaidFacts, error) { var facts PlaidFacts api := seed.API{HC: hc, Target: t} - creds := map[string]any{"client_id": "acme-demo-client", "secret": "acme-demo-secret"} + creds := map[string]any{"client_id": plaidClientID, "secret": plaidSecret} body := func(extra map[string]any) map[string]any { m := map[string]any{} for k, v := range creds { diff --git a/demos/acme-commerce-lenders/world/verify.go b/demos/acme-commerce-lenders/world/verify.go new file mode 100644 index 0000000..5fccc1d --- /dev/null +++ b/demos/acme-commerce-lenders/world/verify.go @@ -0,0 +1,57 @@ +package world + +import ( + "context" + "errors" + "fmt" + "net/http" + + "github.com/localstack-samples/localstack-app-emulators/shared/seed" +) + +// Stale says one app emulator no longer holds the world the lender saved. It +// happens when an app emulator restarts or is reset by something else: it +// answers its health check, but the Plaid access token, the connected Stripe +// account or the Xero invoices the facts name are gone. +type Stale struct { + Vendor string `json:"vendor"` + Reason string `json:"reason"` +} + +// Verify checks that each app emulator still holds what f says seeding put in +// it, and returns one Stale for each that does not. +// +// Only a definite answer counts. An app emulator that cannot be reached, or +// answers with a server error, is not reported here, because the readiness +// check already says so and this one must not call a slow app emulator stale. +func Verify(ctx context.Context, hc *http.Client, tg Targets, f Facts) []Stale { + var out []Stale + add := func(vendor string, err error) { + var e *seed.Error + if errors.As(err, &e) && e.Status >= 400 && e.Status < 500 { + out = append(out, Stale{Vendor: vendor, Reason: fmt.Sprintf("the %s app emulator no longer holds the seeded world (%v)", vendor, err)}) + } + } + + stripe := seed.API{HC: hc, Target: tg.Stripe} + add("stripe", stripe.Do(ctx, http.MethodGet, "/v1/accounts/"+f.Stripe.AccountID, + map[string]string{"Authorization": "Bearer " + StripeKey}, "", nil, nil)) + + plaid := seed.API{HC: hc, Target: tg.Plaid} + add("plaid", plaid.JSON(ctx, http.MethodPost, "/item/get", nil, map[string]any{ + "client_id": plaidClientID, "secret": plaidSecret, "access_token": f.Plaid.AccessToken, + }, nil)) + + xero := seed.API{HC: hc, Target: tg.Xero} + var invoices struct { + Invoices []struct{ InvoiceID string } + } + err := xero.Do(ctx, http.MethodGet, "/api.xro/2.0/Invoices", + map[string]string{"Authorization": "Bearer demo", "Xero-Tenant-Id": f.Xero.TenantID}, "", nil, &invoices) + add("xero", err) + if err == nil && len(invoices.Invoices) != f.Xero.Invoices { + out = append(out, Stale{Vendor: "xero", Reason: fmt.Sprintf( + "the xero app emulator holds %d invoices, and seeding made %d", len(invoices.Invoices), f.Xero.Invoices)}) + } + return out +} diff --git a/demos/acme-commerce-lenders/world/verify_test.go b/demos/acme-commerce-lenders/world/verify_test.go new file mode 100644 index 0000000..2b4cdd4 --- /dev/null +++ b/demos/acme-commerce-lenders/world/verify_test.go @@ -0,0 +1,86 @@ +package world + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/localstack-samples/localstack-app-emulators/shared/wire" +) + +// verifyRig is three app emulators that each hold the seeded world, and the +// facts that describe it. A test removes what one of them holds. +func verifyRig(t *testing.T) (Targets, Facts, map[string]*fake) { + t.Helper() + fakes := map[string]*fake{ + "stripe": newFake(t, map[string]string{"GET /v1/accounts/acct_1": `{"id":"acct_1"}`}), + "plaid": newFake(t, map[string]string{"POST /item/get": `{"item":{}}`}), + "xero": newFake(t, map[string]string{"GET /api.xro/2.0/Invoices": `{"Invoices":[{"InvoiceID":"a"},{"InvoiceID":"b"}]}`}), + } + tg := Targets{ + Stripe: wire.Direct("stripe", fakes["stripe"].srv.URL), + Plaid: wire.Direct("plaid", fakes["plaid"].srv.URL), + Xero: wire.Direct("xero", fakes["xero"].srv.URL), + } + f := Facts{ + Stripe: StripeFacts{AccountID: "acct_1"}, + Plaid: PlaidFacts{AccessToken: "access-sandbox-1"}, + Xero: XeroFacts{TenantID: XeroTenantID, Invoices: 2}, + } + return tg, f, fakes +} + +func TestVerifyFindsNothingWhenEveryAppEmulatorHoldsTheWorld(t *testing.T) { + tg, f, _ := verifyRig(t) + if got := Verify(context.Background(), nil, tg, f); len(got) != 0 { + t.Fatalf("want a world that checks out, got %+v", got) + } +} + +func TestVerifyReportsAPlaidTokenTheAppEmulatorNoLongerKnows(t *testing.T) { + // The case that showed "Ready" over a broken world: Plaid restarted, so the + // access token saved at seeding is unknown to it. + tg, f, fakes := verifyRig(t) + delete(fakes["plaid"].routes, "POST /item/get") + + got := Verify(context.Background(), nil, tg, f) + if len(got) != 1 || got[0].Vendor != "plaid" { + t.Fatalf("want plaid reported, got %+v", got) + } + if calls := fakes["plaid"].to("POST", "/item/get"); len(calls) != 1 || !strings.Contains(calls[0].Body, "access-sandbox-1") { + t.Fatalf("want one /item/get call carrying the saved token, got %+v", calls) + } +} + +func TestVerifyReportsAMissingStripeAccountAndShortXeroInvoices(t *testing.T) { + tg, f, fakes := verifyRig(t) + delete(fakes["stripe"].routes, "GET /v1/accounts/acct_1") + fakes["xero"].routes["GET /api.xro/2.0/Invoices"] = `{"Invoices":[{"InvoiceID":"a"}]}` + + got := Verify(context.Background(), nil, tg, f) + vendors := []string{} + for _, s := range got { + vendors = append(vendors, s.Vendor) + } + if strings.Join(vendors, ",") != "stripe,xero" { + t.Fatalf("want stripe and xero reported, got %+v", got) + } +} + +func TestVerifyDoesNotCallASlowOrDownAppEmulatorStale(t *testing.T) { + // A server error or a refused connection says nothing about what the app + // emulator holds, and the readiness check already reports it. + tg, f, fakes := verifyRig(t) + broken := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "boom", http.StatusInternalServerError) + })) + defer broken.Close() + tg.Stripe = wire.Direct("stripe", broken.URL) + fakes["plaid"].srv.Close() + + if got := Verify(context.Background(), nil, tg, f); len(got) != 0 { + t.Fatalf("want nothing reported for a 500 or a refused connection, got %+v", got) + } +} From dd0a86083513fd73f28c3cd2b5a6d292908a385a Mon Sep 17 00:00:00 2001 From: tela Date: Mon, 9 Nov 2026 19:55:50 -0800 Subject: [PATCH 2/2] fix(demo): say when an app emulator lost the seeded world Show an Out of date badge on the vendor, name it in the readiness card and next to the disabled Apply button, and tell the presenter to press Reset. Add the matching row to the troubleshooting guide. --- .../docs/troubleshooting.md | 1 + .../acme-commerce-lenders/src/api/types.ts | 5 ++++ .../src/components/readiness.tsx | 21 ++++++++++++---- .../src/demo/demo-page.test.tsx | 24 +++++++++++++++++++ .../src/demo/demo-page.tsx | 4 +++- 5 files changed, 50 insertions(+), 5 deletions(-) diff --git a/demos/acme-commerce-lenders/docs/troubleshooting.md b/demos/acme-commerce-lenders/docs/troubleshooting.md index b7ef8ec..020fa28 100644 --- a/demos/acme-commerce-lenders/docs/troubleshooting.md +++ b/demos/acme-commerce-lenders/docs/troubleshooting.md @@ -10,6 +10,7 @@ Check first: `make kit APP=acme-commerce-lenders ARGS=ready`. It lists each app | An app emulator served by LocalStack shows `unreachable`: "LocalStack is not answering on its gateway" | LocalStack is not running. | Start it with `lstk`. | | An app emulator you run shows `unreachable`: "nothing is listening at http://127.0.0.1:5101" | The process stopped, or the port is not the one in the variable. | Start it again on that port, or unset the variable to use LocalStack. | | **Apply for working capital** is disabled | An app emulator is not ready, or the world is not seeded. The page says which. | Fix the app emulator, then press **Reset**. | +| An app emulator shows **Out of date**, and the page says it "no longer holds the seeded world" | The app emulator answers its health check, but it restarted or was reset by something else, so the connected Stripe account, the Plaid access token or the Xero invoices the demo saved are gone. Plaid and Xero keep their data in memory, so a restart loses it. | Press **Reset**. It wipes the app emulators and seeds the world again. | | `the world is not seeded; run seed or reset first` | The demo started before the world was built, or a reset failed partway. | Press **Reset**, or run `make kit APP=acme-commerce-lenders ARGS=seed`. | | `the world is already seeded; reset it first (run: reset)` | `seed` was run twice. It never adds to a world, so the second run stops. | Run `make kit APP=acme-commerce-lenders ARGS=reset`. | | Settling fails with `xero PUT /api.xro/2.0/Accounts: HTTP 405: Method Not Allowed` | The Xero app emulator is older than the change that lets it create accounts and bank transactions. The earlier steps work, so funding succeeds and only the booking fails. | Rebuild `twin-xero` from `WonderTwin-AI/wondertwin` `main`, restart it and press **Reset**. | diff --git a/web/demos/acme-commerce-lenders/src/api/types.ts b/web/demos/acme-commerce-lenders/src/api/types.ts index 9fac836..c304eea 100644 --- a/web/demos/acme-commerce-lenders/src/api/types.ts +++ b/web/demos/acme-commerce-lenders/src/api/types.ts @@ -92,6 +92,11 @@ export interface Status { seeded: boolean emulators: EmulatorStatus[] facts?: Facts + /** + * Each app emulator that is healthy but no longer holds the seeded world, + * such as one that restarted. Reset puts it right. + */ + stale?: { vendor: string; reason: string }[] } export interface WireBody { diff --git a/web/demos/acme-commerce-lenders/src/components/readiness.tsx b/web/demos/acme-commerce-lenders/src/components/readiness.tsx index cf52ee7..9ed1874 100644 --- a/web/demos/acme-commerce-lenders/src/components/readiness.tsx +++ b/web/demos/acme-commerce-lenders/src/components/readiness.tsx @@ -15,8 +15,8 @@ const sourceNames: Record = { local: 'Local', } -function Pill({ e }: { e: EmulatorStatus }) { - const ok = e.state === 'healthy' +function Pill({ e, stale }: { e: EmulatorStatus; stale: boolean }) { + const ok = e.state === 'healthy' && !stale return (
- {ok ? 'Ready' : e.state} + {ok ? 'Ready' : stale ? 'Out of date' : e.state} ) @@ -64,7 +64,11 @@ export function Readiness() {
{data.emulators.map((e) => ( - + s.vendor === e.vendor) ?? false} + /> ))}
{problems.map((e) => ( @@ -72,6 +76,15 @@ export function Readiness() { {vendorNames[e.vendor] ?? e.vendor}: {e.hint ?? e.state}

))} + {problems.length === 0 && (data.stale?.length ?? 0) > 0 && ( +

+ {data.stale + ?.map((s) => vendorNames[s.vendor] ?? s.vendor) + .join(' and ')}{' '} + no longer holds the seeded world, as if it restarted. Press Reset to + seed it again. +

+ )} {problems.length === 0 && !data.seeded && (

The world is not seeded yet. Press Reset to seed it. diff --git a/web/demos/acme-commerce-lenders/src/demo/demo-page.test.tsx b/web/demos/acme-commerce-lenders/src/demo/demo-page.test.tsx index 3a4a963..877b736 100644 --- a/web/demos/acme-commerce-lenders/src/demo/demo-page.test.tsx +++ b/web/demos/acme-commerce-lenders/src/demo/demo-page.test.tsx @@ -215,6 +215,30 @@ describe('DemoPage', () => { expect(await screen.findByRole('status')).toHaveTextContent('Press Reset') }) + it('says what is out of date, and holds Apply back, when an app emulator lost the seeded world', async () => { + mocks.getStatus.mockResolvedValue({ + ...readyStatus, + ready: false, + stale: [ + { + vendor: 'plaid', + reason: 'the plaid app emulator no longer holds the seeded world', + }, + ], + }) + renderPage() + expect(await screen.findByTestId('emulator-plaid')).toHaveTextContent( + 'Out of date', + ) + expect(screen.getByRole('alert')).toHaveTextContent( + 'Plaid no longer holds the seeded world', + ) + expect(screen.getByRole('alert')).toHaveTextContent('Press Reset') + expect( + screen.getByRole('button', { name: /apply for working capital/i }), + ).toBeDisabled() + }) + it('runs apply, connect, review and offer on the first click, in order', async () => { const user = userEvent.setup() renderPage() diff --git a/web/demos/acme-commerce-lenders/src/demo/demo-page.tsx b/web/demos/acme-commerce-lenders/src/demo/demo-page.tsx index 415b915..d49cf8e 100644 --- a/web/demos/acme-commerce-lenders/src/demo/demo-page.tsx +++ b/web/demos/acme-commerce-lenders/src/demo/demo-page.tsx @@ -70,7 +70,9 @@ export function DemoPage() { {badUrl ? 'Fix the base URL above to run the demo.' - : 'Waiting for the app emulators and the seeded world.'} + : status.data?.stale?.length + ? 'The seeded world is out of date. Press Reset.' + : 'Waiting for the app emulators and the seeded world.'} )}