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
16 changes: 16 additions & 0 deletions packages/ramps-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Add `RampsService.getDefaultRedirectCallbackUrl()` and the matching `RampsService:getDefaultRedirectCallbackUrl` messenger action (plus the exported `RampsServiceGetDefaultRedirectCallbackUrlAction` type), which return the widened Headless Buy default redirect ("fake callback") URL for the environment the service was constructed with. The method is synchronous. ([#9752](https://github.com/MetaMask/core/pull/9752))
- `baseUrlOverride` deliberately does not apply. It overrides the ramps API host for local development, which in production and staging is not the host that serves `/regions/fake-callback` (`on-ramp-content` versus `on-ramp{-cache}`), and the redirect URL is matched by client UI to detect flow completion. For development the callback already shares the API host family (`on-ramp.dev-api`). Use `RampsEnvironment.Local` for a localhost callback, noting it is pinned to `http://localhost:3000` and does not follow a non-3000 `baseUrlOverride`.
- Add the exported `getDefaultRedirectCallbackUrl(environment)` helper, the canonical environment-to-callback map that `RampsService` uses: `on-ramp-content` hosts for production and staging, `on-ramp.dev-api` for development (there is no `on-ramp-content.dev-api` deployment), and `localhost:3000` for local. Client code that needs the value synchronously, without the messenger, can call it directly with the same environment the service was given. ([#9752](https://github.com/MetaMask/core/pull/9752))

### Changed

- **BREAKING:** `RampsController` now calls `RampsService:getDefaultRedirectCallbackUrl` on the widened quote path, so hosts must delegate that action to the controller's messenger. It is included in the exported `RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS` list; hosts that spell out their delegated action list instead of spreading that constant have to add it, or the entire `RampsController:getQuotes` call rejects with a messenger "handler has not been delegated" error (including MM Pay's fiat quote path, which omits `redirectUrl` and relies on widening). ([#9752](https://github.com/MetaMask/core/pull/9752))
- The action is only called when the `moneyHeadlessAllProviders` widening is in effect and the caller omitted `redirectUrl`. An explicit `redirectUrl` and the native-only path never reach the service.

### Removed

- **BREAKING:** Remove the `getDefaultRedirectUrl` callback option from `RampsControllerOptions`. The controller asks `RampsService` for the default redirect URL instead, which keeps the environment a single runtime source of truth so the callback host cannot drift from the API host the service is talking to. ([#9752](https://github.com/MetaMask/core/pull/9752))
- Mobile should drop the `getDefaultRedirectUrl: () => getRampCallbackBaseUrl()` argument from its `RampsController` init once it upgrades, and reimplement `getRampCallbackBaseUrl()` as `getDefaultRedirectCallbackUrl(getRampsEnvironment())` so the UI callback matcher and the controller default resolve from the same environment source.

## [18.0.1]

### Changed
Expand Down
166 changes: 123 additions & 43 deletions packages/ramps-controller/src/RampsController.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,14 @@ import type {
PatchUserRequestBody,
} from './TransakService.js';

/**
* The default redirect ("fake callback") URL a staging `RampsService` returns.
* Written out in full so the tests pin the exact host the widened quote path
* forwards, rather than re-deriving it from the code under test.
*/
const STAGING_REDIRECT_CALLBACK_URL =
'https://on-ramp-content.uat-api.cx.metamask.io/regions/fake-callback';

describe('RampsController', () => {
const circuitBreakerOpenErrorMessage =
'Execution prevented because the circuit breaker is open';
Expand Down Expand Up @@ -1387,24 +1395,24 @@ describe('RampsController', () => {
);
});

it('forwards the injected default redirectUrl on the widened path when the caller omits one', async () => {
it("forwards the service's default redirectUrl on the widened path when the caller omits one", async () => {
const response: QuotesResponse = {
success: [appBrowserQuote(MOONPAY, 90)],
sorted: [{ sortBy: 'reliability', ids: [MOONPAY] }],
error: [],
customActions: [],
};
const DEFAULT_REDIRECT = 'https://default.example/callback';

await withController(
{
options: {
getDefaultRedirectUrl: () => DEFAULT_REDIRECT,
state: scopeState([buildScopeProvider(MOONPAY, 'aggregator')]),
},
},
async ({ messenger, rootMessenger }) => {
registerFeatureFlagState(rootMessenger);
const getDefaultRedirectCallbackUrlSpy =
spyOnDefaultRedirectCallbackUrl(rootMessenger);
let forwardedRedirectUrl: string | undefined;
rootMessenger.registerActionHandler(
'RampsService:getQuotes',
Expand All @@ -1416,74 +1424,69 @@ describe('RampsController', () => {

await callScopedGetQuotes(messenger);

// The caller omitted redirectUrl, so the widened path supplies the
// injected default and forwards it to the service.
expect(forwardedRedirectUrl).toBe(DEFAULT_REDIRECT);
// The caller omitted redirectUrl, so the widened path asks the
// service for the callback URL of its environment and forwards it.
expect(getDefaultRedirectCallbackUrlSpy).toHaveBeenCalledTimes(1);
expect(forwardedRedirectUrl).toBe(
'https://on-ramp-content.uat-api.cx.metamask.io/regions/fake-callback',
);
},
);
});

it('prefers an explicit caller redirectUrl over the injected default on the widened path', async () => {
it('rejects the entire getQuotes call when the default-redirect service action is not delegated', async () => {
const response: QuotesResponse = {
success: [appBrowserQuote(MOONPAY, 90)],
sorted: [{ sortBy: 'reliability', ids: [MOONPAY] }],
error: [],
customActions: [],
};
const DEFAULT_REDIRECT = 'https://default.example/callback';
const EXPLICIT_REDIRECT = 'https://explicit.example/callback';

await withController(
{
options: {
getDefaultRedirectUrl: () => DEFAULT_REDIRECT,
state: scopeState([buildScopeProvider(MOONPAY, 'aggregator')]),
},
},
async ({ messenger, rootMessenger }) => {
registerFeatureFlagState(rootMessenger);
let forwardedRedirectUrl: string | undefined;
// Simulate a host that upgraded without adding the new action to
// its hand-written messenger delegation list.
rootMessenger.unregisterActionHandler(
'RampsService:getDefaultRedirectCallbackUrl',
);
rootMessenger.registerActionHandler(
'RampsService:getQuotes',
async (params: { redirectUrl?: string }) => {
forwardedRedirectUrl = params.redirectUrl;
return response;
},
async () => response,
);

await callScopedGetQuotes(messenger, {
redirectUrl: EXPLICIT_REDIRECT,
});

// An explicit caller redirectUrl always wins; the default is not
// applied.
expect(forwardedRedirectUrl).toBe(EXPLICIT_REDIRECT);
await expect(callScopedGetQuotes(messenger)).rejects.toThrow(
/A handler for RampsService:getDefaultRedirectCallbackUrl has not been (registered|delegated)/u,
);
},
);
});

it('does not inject the default redirectUrl when the flag is disabled', async () => {
it('forwards whichever callback URL the service reports, without rederiving it', async () => {
const response: QuotesResponse = {
success: [appBrowserQuote(NATIVE, 70)],
sorted: [{ sortBy: 'reliability', ids: [NATIVE] }],
success: [appBrowserQuote(MOONPAY, 90)],
sorted: [{ sortBy: 'reliability', ids: [MOONPAY] }],
error: [],
customActions: [],
};
const DEFAULT_REDIRECT = 'https://default.example/callback';

await withController(
{
options: {
getDefaultRedirectUrl: () => DEFAULT_REDIRECT,
state: scopeState([buildScopeProvider(NATIVE, 'native')]),
state: scopeState([buildScopeProvider(MOONPAY, 'aggregator')]),
},
},
async ({ messenger, rootMessenger }) => {
registerFeatureFlagState(rootMessenger, {
remoteFeatureFlags: {
[MONEY_HEADLESS_ALL_PROVIDERS_FLAG_KEY]: false,
},
});
registerFeatureFlagState(rootMessenger);
spyOnDefaultRedirectCallbackUrl(
rootMessenger,
'https://on-ramp-content.api.cx.metamask.io/regions/fake-callback',
);
let forwardedRedirectUrl: string | undefined;
rootMessenger.registerActionHandler(
'RampsService:getQuotes',
Expand All @@ -1495,20 +1498,23 @@ describe('RampsController', () => {

await callScopedGetQuotes(messenger);

// The disabled flag never widens, so the default is not injected
// even when a `getDefaultRedirectUrl` callback is present.
expect(forwardedRedirectUrl).toBeUndefined();
// A production service reports the production host, and the
// controller passes it through untouched.
expect(forwardedRedirectUrl).toBe(
'https://on-ramp-content.api.cx.metamask.io/regions/fake-callback',
);
},
);
});

it('forwards undefined on the widened path when no getDefaultRedirectUrl option is provided', async () => {
it('prefers an explicit caller redirectUrl and never asks the service on the widened path', async () => {
const response: QuotesResponse = {
success: [appBrowserQuote(MOONPAY, 90)],
sorted: [{ sortBy: 'reliability', ids: [MOONPAY] }],
error: [],
customActions: [],
};
const EXPLICIT_REDIRECT = 'https://explicit.example/callback';

await withController(
{
Expand All @@ -1518,23 +1524,65 @@ describe('RampsController', () => {
},
async ({ messenger, rootMessenger }) => {
registerFeatureFlagState(rootMessenger);
const getDefaultRedirectCallbackUrlSpy =
spyOnDefaultRedirectCallbackUrl(rootMessenger);
let forwardedRedirectUrl: string | undefined;
rootMessenger.registerActionHandler(
'RampsService:getQuotes',
async (params: { redirectUrl?: string }) => {
forwardedRedirectUrl = params.redirectUrl;
return response;
},
);

await callScopedGetQuotes(messenger, {
redirectUrl: EXPLICIT_REDIRECT,
});

// An explicit caller redirectUrl always wins, and the controller
// short-circuits before reaching the service.
expect(forwardedRedirectUrl).toBe(EXPLICIT_REDIRECT);
expect(getDefaultRedirectCallbackUrlSpy).not.toHaveBeenCalled();
},
);
});

it('does not inject the default redirectUrl or ask the service when the flag is disabled', async () => {
const response: QuotesResponse = {
success: [appBrowserQuote(NATIVE, 70)],
sorted: [{ sortBy: 'reliability', ids: [NATIVE] }],
error: [],
customActions: [],
};
await withController(
{
options: {
state: scopeState([buildScopeProvider(NATIVE, 'native')]),
},
},
async ({ messenger, rootMessenger }) => {
registerFeatureFlagState(rootMessenger, {
remoteFeatureFlags: {
[MONEY_HEADLESS_ALL_PROVIDERS_FLAG_KEY]: false,
},
});
const getDefaultRedirectCallbackUrlSpy =
spyOnDefaultRedirectCallbackUrl(rootMessenger);
let forwardedRedirectUrl: string | undefined;
let redirectUrlWasSeen = false;
rootMessenger.registerActionHandler(
'RampsService:getQuotes',
async (params: { redirectUrl?: string }) => {
forwardedRedirectUrl = params.redirectUrl;
redirectUrlWasSeen = true;
return response;
},
);

await callScopedGetQuotes(messenger);

// With no injected callback, the constructor default returns
// undefined, so the widened path forwards undefined.
expect(redirectUrlWasSeen).toBe(true);
// The disabled flag never widens, so nothing is injected and the
// service is not consulted.
expect(forwardedRedirectUrl).toBeUndefined();
expect(getDefaultRedirectCallbackUrlSpy).not.toHaveBeenCalled();
},
);
});
Expand Down Expand Up @@ -11422,7 +11470,39 @@ type WithControllerOptions = {
* @returns The root messenger.
*/
function getRootMessenger(): RootMessenger {
return new Messenger({ namespace: MOCK_ANY_NAMESPACE });
const rootMessenger: RootMessenger = new Messenger({
namespace: MOCK_ANY_NAMESPACE,
});
// Stands in for the real service, which derives this from its environment.
rootMessenger.registerActionHandler(
'RampsService:getDefaultRedirectCallbackUrl',
() => STAGING_REDIRECT_CALLBACK_URL,
);
return rootMessenger;
}

/**
* Replaces the default `RampsService:getDefaultRedirectCallbackUrl` handler
* with a spy, so a test can assert whether the controller asked the service
* for the default redirect URL at all.
*
* @param rootMessenger - The root messenger to re-register the handler on.
* @param url - The URL the spy returns. Defaults to the staging callback URL.
* @returns The spy standing in for the service method.
*/
function spyOnDefaultRedirectCallbackUrl(
rootMessenger: RootMessenger,
url: string = STAGING_REDIRECT_CALLBACK_URL,
): jest.Mock<string, []> {
const handler = jest.fn<string, []>(() => url);
rootMessenger.unregisterActionHandler(
'RampsService:getDefaultRedirectCallbackUrl',
);
rootMessenger.registerActionHandler(
'RampsService:getDefaultRedirectCallbackUrl',
handler,
);
return handler;
}

/**
Expand Down
Loading