Skip to content

Commit 65592c4

Browse files
test(pay-card): add mobile renewal test controls (LIVE-34741)
Rebased onto the refreshed Baanx session renewal branch and merged develop navigation changes with the Card session DevTool controls. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 63784d5 commit 65592c4

32 files changed

Lines changed: 1952 additions & 263 deletions

.changeset/olive-donkeys-listen.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
---
2+
"live-mobile": patch
3+
"@devtools/pay-card": minor
4+
"@devtools/bindings": minor
5+
"@features/flow-pay-card-auth": minor
6+
---
7+
8+
Add Card session controls to the Card / Pay DevTool
9+
10+
The panel gains an "Auth session", a "Device secure storage", a "Send API requests" and an "MSW Auth
11+
Renewal Mock" section: the stored tokens, and buttons that call the real session accessors to break a
12+
token, renew or fetch. An MSW handler decides what the Baanx renewal grant answers, and
13+
publishes its counters to the panel.
14+
15+
The mock offers one button per documented response of `POST /v1/auth/oauth2/token`, named by status
16+
code — 200, 400, 422, 498, 499, 500 — plus a slow 200, a 200 the wire schema rejects, and a transport
17+
failure. Each carries the body the Baanx reference documents for it, so a tester matches the panel
18+
against the API docs rather than against a nickname.
19+
20+
The panel follows the renewal contract: it sends the epoch of the session it read, and names a
21+
`session-replaced` answer when a login or a logout got in first.
22+
23+
"Auth session" tells a store it could not read apart from an empty one. The native store rejects a
24+
read the OS refused, so a locked keychain shows "Unreadable" with the reason rather than reporting
25+
the tester as signed out.
26+
27+
A "Secure browser" section closes the panel on mobile. It takes a URL and opens it in the secure
28+
browser the hosted login uses, so a tester reaches an authorize page, or a redirect, without the
29+
login flow that builds the URL. The mobile host passes the Pay tab deep link, which is what ends
30+
such a session, and the panel prints the redirect the session answered.
31+
32+
The panel works without MSW, so it runs on a device. The handler stays behind `MSW_ENABLED`.
33+
34+
The mock reports one counter, `renewals`, and counts only the renewals it answered. On React Native
35+
MSW installs two interceptors, one on `fetch` and one on the `XMLHttpRequest` that React Native's
36+
`fetch` is built on, so a handler runs twice for every request it passes through. A "user requests"
37+
counter therefore reported two for one, and it is removed: the `[card api]` trace in
38+
`@shared/api-services` runs in the client and prints one line per request.

apps/ledger-live-mobile/src/mocks/README.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,3 +34,37 @@ Verify
3434
Disable
3535

3636
- Or start normally: `pnpm start -- --reset-cache`.
37+
38+
## Pay Card session renewal (LIVE-34741)
39+
40+
`src/mocks/card/handler.ts` mocks the OAuth2 renewal grant, so every branch of the Pay Card session
41+
renewal can be driven by hand. It only intercepts `grant_type=refresh_token`; the login grant reaches
42+
the real provider, so you can still sign in. It also answers the Card reads once its own mock tokens
43+
are in play, because the provider rejects tokens it never issued.
44+
45+
Drive it from **Settings > Debug > DevTools > Card / Pay**, under "MSW Auth Renewal Mock". One button
46+
per documented answer of `POST /v1/auth/oauth2/token`, named by status code so each matches the Baanx
47+
API reference: 200, 400, 422, 498, 499 and 500, plus a slow 200, a 200 the schema rejects, and a
48+
transport failure.
49+
50+
Only **200** and **200 slow** keep the session. Every other button ends it, which is the one renewal
51+
rule — see "Renewal" in `@features/platform-card`. The buttons still differ, because a tester must
52+
see that each documented status reaches that end, and by which route.
53+
54+
The panel works without MSW too: the buttons still call the real session accessors, and every request
55+
reaches the real provider. Only the answer buttons and the renewal counter need `MSW_ENABLED=true`.
56+
57+
> [!IMPORTANT]
58+
>
59+
> **A handler runs twice for every request it passes through.** `msw/native` installs two
60+
> interceptors, one on `fetch` and one on `XMLHttpRequest`, and React Native's `fetch` is
61+
> `whatwg-fetch`, which is built on `XMLHttpRequest`. So a pass-through is performed with the real
62+
> `fetch`, that `fetch` opens an `XMLHttpRequest`, and the second interceptor hands the same request
63+
> back to the handler. A request the handler answers arrives once.
64+
>
65+
> So a Pay Card handler counts only what it answers. Do not count a pass-through here: count it in
66+
> the client. The `[card api]` trace in `@shared/api-services` prints one line per request.
67+
68+
`src/mocks/card/state.ts` holds the switchboard the panel and the handler share. It lives on
69+
`globalThis`, because the panel's props are built in `@devtools/bindings`, which cannot import from
70+
an app.
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { server } from "@tests/server";
2+
import handlers from "./handler";
3+
import { readCardMockState, type CardTokenResponseId } from "./state";
4+
5+
const TOKEN_URL = "https://card.test/v1/auth/oauth2/token";
6+
7+
function renew(response: CardTokenResponseId) {
8+
const state = readCardMockState();
9+
if (!state) throw new Error("Card mock state was not initialized");
10+
state.tokenResponse = response;
11+
return fetch(TOKEN_URL, {
12+
method: "POST",
13+
headers: { "content-type": "application/json" },
14+
body: JSON.stringify({ grant_type: "refresh_token" }),
15+
});
16+
}
17+
18+
describe("Card renewal mock handlers", () => {
19+
beforeEach(() => {
20+
const state = readCardMockState();
21+
if (!state) throw new Error("Card mock state was not initialized");
22+
state.tokenResponse = "pass";
23+
state.userUnauthorizedOnce = false;
24+
state.refreshCount = 0;
25+
server.use(...handlers);
26+
});
27+
28+
it.each([
29+
["200", 200],
30+
["200-bad-body", 200],
31+
["400", 400],
32+
["422", 422],
33+
["498", 498],
34+
["499", 499],
35+
["500", 500],
36+
] as const)("answers the %s renewal mode", async (mode, status) => {
37+
const response = await renew(mode);
38+
39+
expect(response.status).toBe(status);
40+
expect(readCardMockState()?.refreshCount).toBe(1);
41+
});
42+
43+
it("returns rotated tokens for a successful renewal", async () => {
44+
const response = await renew("200");
45+
46+
await expect(response.json()).resolves.toEqual({
47+
access_token: "at_mock_1",
48+
refresh_token: "rt_mock_1",
49+
expires_in: 3600,
50+
});
51+
});
52+
53+
it("simulates a renewal network failure", async () => {
54+
await expect(renew("network-error")).rejects.toBeDefined();
55+
expect(readCardMockState()?.refreshCount).toBe(1);
56+
});
57+
58+
it("passes through requests the mock does not own", async () => {
59+
await expect(renew("pass")).rejects.toBeDefined();
60+
await expect(
61+
fetch(TOKEN_URL, {
62+
method: "POST",
63+
headers: { "content-type": "application/json" },
64+
body: JSON.stringify({ grant_type: "authorization_code" }),
65+
}),
66+
).rejects.toBeDefined();
67+
await expect(fetch("https://card.test/v1/user")).rejects.toBeDefined();
68+
await expect(fetch("https://card.test/v1/card/status")).rejects.toBeDefined();
69+
});
70+
71+
it("arms one unauthorized user response", async () => {
72+
const state = readCardMockState();
73+
if (!state) throw new Error("Card mock state was not initialized");
74+
state.userUnauthorizedOnce = true;
75+
76+
const response = await fetch("https://card.test/v1/user");
77+
78+
expect(response.status).toBe(401);
79+
expect(state.userUnauthorizedOnce).toBe(false);
80+
});
81+
82+
it("answers user and card reads for mock access tokens", async () => {
83+
const headers = { authorization: "Bearer at_mock_1" };
84+
85+
const [user, card] = await Promise.all([
86+
fetch("https://card.test/v1/user", { headers }),
87+
fetch("https://card.test/v1/card/status", { headers }),
88+
]);
89+
90+
await expect(user.json()).resolves.toMatchObject({
91+
id: expect.any(String),
92+
verificationState: "VERIFIED",
93+
});
94+
await expect(card.json()).resolves.toMatchObject({
95+
holderName: "JOHN DOE",
96+
status: "ACTIVE",
97+
});
98+
});
99+
});
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import { http, HttpResponse, passthrough, delay } from "msw";
2+
import { createCardMockState } from "./state";
3+
4+
const state = createCardMockState();
5+
6+
const SLOW_MS = 5_000;
7+
8+
const MOCK_TOKEN_PREFIX = "at_mock_";
9+
10+
function usesMockToken(request: Request): boolean {
11+
return request.headers.get("authorization")?.includes(MOCK_TOKEN_PREFIX) ?? false;
12+
}
13+
14+
const MOCK_USER = {
15+
id: "6f1c9a52-3d4e-4b7a-9c81-2f0d5e7a1b34",
16+
verificationState: "VERIFIED",
17+
};
18+
19+
const MOCK_CARD_STATUS = {
20+
id: "000000000050277836",
21+
holderName: "JOHN DOE",
22+
expiryDate: "2028/01",
23+
panLast4: "1234",
24+
status: "ACTIVE",
25+
type: "VIRTUAL",
26+
orderedAt: "2023-03-27T17:07:12.662Z",
27+
};
28+
29+
function rotatedSession(serial: number) {
30+
return HttpResponse.json({
31+
access_token: `${MOCK_TOKEN_PREFIX}${serial}`,
32+
refresh_token: `rt_mock_${serial}`,
33+
expires_in: 3600,
34+
});
35+
}
36+
37+
const OAUTH_ERROR_BODY = {
38+
error: "invalid_grant",
39+
error_description: "The refresh token is invalid, expired or revoked",
40+
};
41+
42+
async function answerTokenRequest(id: string, serial: number) {
43+
switch (id) {
44+
case "200":
45+
return rotatedSession(serial);
46+
47+
case "200-slow":
48+
await delay(SLOW_MS);
49+
return rotatedSession(serial);
50+
51+
case "200-bad-body":
52+
return HttpResponse.json({ access_token: `${MOCK_TOKEN_PREFIX}${serial}`, expires_in: 3600 });
53+
54+
case "400":
55+
return HttpResponse.json(OAUTH_ERROR_BODY, { status: 400 });
56+
57+
case "422":
58+
return HttpResponse.json({ message: "x field is not allowed" }, { status: 422 });
59+
60+
case "498":
61+
return HttpResponse.json({ message: "Invalid client key" }, { status: 498 });
62+
63+
case "499":
64+
return HttpResponse.json({ message: "Missing client key" }, { status: 499 });
65+
66+
case "500":
67+
return HttpResponse.json({ message: "Internal server error" }, { status: 500 });
68+
69+
case "network-error":
70+
return HttpResponse.error();
71+
72+
default:
73+
return passthrough();
74+
}
75+
}
76+
77+
const handlers = [
78+
http.post("*/v1/auth/oauth2/token", async ({ request }) => {
79+
const body = (await request
80+
.clone()
81+
.json()
82+
.catch(() => ({}))) as { grant_type?: string };
83+
84+
if (body.grant_type !== "refresh_token") {
85+
return passthrough();
86+
}
87+
88+
if (state.tokenResponse === "pass") {
89+
return passthrough();
90+
}
91+
92+
state.refreshCount += 1;
93+
// eslint-disable-next-line no-console
94+
console.log(`[card-msw] renewal #${state.refreshCount} answers ${state.tokenResponse}`);
95+
96+
return answerTokenRequest(state.tokenResponse, state.refreshCount);
97+
}),
98+
99+
http.get("*/v1/user", ({ request }) => {
100+
if (state.userUnauthorizedOnce) {
101+
state.userUnauthorizedOnce = false;
102+
// eslint-disable-next-line no-console
103+
console.log("[card-msw] answering one /v1/user with 401");
104+
return HttpResponse.json({ message: "unauthorized" }, { status: 401 });
105+
}
106+
107+
if (!usesMockToken(request)) {
108+
return passthrough();
109+
}
110+
111+
// eslint-disable-next-line no-console
112+
console.log("[card-msw] answering /v1/user from the mock");
113+
return HttpResponse.json(MOCK_USER);
114+
}),
115+
116+
http.get("*/v1/card/status", ({ request }) => {
117+
if (!usesMockToken(request)) {
118+
return passthrough();
119+
}
120+
return HttpResponse.json(MOCK_CARD_STATUS);
121+
}),
122+
];
123+
124+
export default handlers;
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
export type CardTokenResponseId =
2+
| "pass"
3+
| "200"
4+
| "200-slow"
5+
| "200-bad-body"
6+
| "400"
7+
| "422"
8+
| "498"
9+
| "499"
10+
| "500"
11+
| "network-error";
12+
13+
export type CardTokenResponse = {
14+
readonly id: CardTokenResponseId;
15+
readonly label: string;
16+
readonly hint: string;
17+
};
18+
19+
function response(id: CardTokenResponseId, label: string, hint: string): CardTokenResponse {
20+
return { id, label, hint };
21+
}
22+
23+
export const CARD_TOKEN_RESPONSES: readonly CardTokenResponse[] = [
24+
response("pass", "Off", "The mock stands aside. The real provider answers the renewal."),
25+
response(
26+
"200",
27+
"200",
28+
"Token exchange successful. A new access token and a new refresh token. The one answer that keeps the session.",
29+
),
30+
response(
31+
"200-slow",
32+
"200 slow",
33+
"The same body, 5 s later. Holds one renewal open so every waiting caller must share it.",
34+
),
35+
response(
36+
"200-bad-body",
37+
"200 bad body",
38+
"200 with no refresh_token. The wire schema rejects it, so no session is stored, so the session ends.",
39+
),
40+
response(
41+
"400",
42+
"400",
43+
"OAuth 2.0 error (RFC 6749): invalid_grant. A refresh token the provider will not accept again.",
44+
),
45+
response(
46+
"422",
47+
"422",
48+
"Data validation error. Our request was wrong, and the session ends all the same.",
49+
),
50+
response(
51+
"498",
52+
"498",
53+
"Invalid x-client-key header. A build fault, and the session ends all the same.",
54+
),
55+
response(
56+
"499",
57+
"499",
58+
"Missing x-client-key header. A build fault, and the session ends all the same.",
59+
),
60+
response(
61+
"500",
62+
"500",
63+
"Internal server error. A Baanx outage signs the user out. That is the accepted trade.",
64+
),
65+
response(
66+
"network-error",
67+
"Network fail",
68+
"No answer at all. The client cannot know whether Baanx consumed the token, and ends the session.",
69+
),
70+
];
71+
72+
export type CardMockState = {
73+
tokenResponse: CardTokenResponseId;
74+
readonly responses: readonly CardTokenResponse[];
75+
userUnauthorizedOnce: boolean;
76+
refreshCount: number;
77+
};
78+
79+
type MockHost = { payCardMockState?: CardMockState };
80+
81+
export function readCardMockState(): CardMockState | undefined {
82+
return (globalThis as MockHost).payCardMockState;
83+
}
84+
85+
export function createCardMockState(): CardMockState {
86+
const state: CardMockState = {
87+
tokenResponse: "pass",
88+
responses: CARD_TOKEN_RESPONSES,
89+
userUnauthorizedOnce: false,
90+
refreshCount: 0,
91+
};
92+
(globalThis as MockHost).payCardMockState = state;
93+
return state;
94+
}

0 commit comments

Comments
 (0)