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

## [Unreleased]

### Added

- Add `getAssociatedAddresses` method, exposed as the `ChompApiService:getAssociatedAddresses` messenger action, which fetches the active address associations of the authenticated profile via `GET /v1/auth/address` ([#9387](https://github.com/MetaMask/core/pull/9387))
- Also adds the `ProfileAddressEntry` type describing each returned entry and the `ChompApiServiceGetAssociatedAddressesAction` type
- Returned addresses are parsed into canonical lowercase form, entries are guaranteed to have `status: 'active'`, and results are never served from cache
- The query cache key is scoped to the authenticated profile via a SHA-256 digest of the bearer token, so concurrent calls only share an in-flight request when they are for the same profile and one profile's associations are never cached under another's key

### Changed

- **BREAKING:** `associateAddress` now throws an `HttpError` on a 409 response instead of returning the parsed body ([#9387](https://github.com/MetaMask/core/pull/9387))
- A 409 from `POST /v1/auth/address` indicates the address is associated with a _different_ profile; the previous handling attempted to parse the error body as an association result and failed with a confusing validation error. An address already associated with the authenticated profile is reported via a 201 response with `status: 'active'`, which is unchanged.
- Bump `@metamask/utils` from `^11.9.0` to `^11.11.0` ([#9074](https://github.com/MetaMask/core/pull/9074))
- Bump `@metamask/controller-utils` from `^12.0.0` to `^12.3.0` ([#8774](https://github.com/MetaMask/core/pull/8774), [#9058](https://github.com/MetaMask/core/pull/9058), [#9083](https://github.com/MetaMask/core/pull/9083), [#9218](https://github.com/MetaMask/core/pull/9218))
- Bump `@metamask/base-data-service` from `^0.1.2` to `^0.1.3` ([#8799](https://github.com/MetaMask/core/pull/8799))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,35 @@ import type { ChompApiService } from './chomp-api-service';
*
* @param params - The association params containing signature, timestamp,
* and address.
* @returns The profile association result. Returns on both 201 and 409.
* @returns The profile association result: `status: 'created'` for a new
* association, `status: 'active'` when the address was already associated
* with the authenticated profile. Throws on 409, which indicates the
* address is associated with a different profile.
*/
export type ChompApiServiceAssociateAddressAction = {
type: `ChompApiService:associateAddress`;
handler: ChompApiService['associateAddress'];
};

/**
* Fetches the addresses associated with the authenticated profile.
*
* GET /v1/auth/address
*
* The result is scoped to the authenticated profile and consumers use it to
* decide whether an association already exists, so it is always fetched
* fresh (`staleTime: 0`, `cacheTime: 0`) and the query key is scoped to the
* profile via a digest of the bearer token — concurrent calls only share a
* request when they are for the same profile.
*
* @returns The active address associations; empty array if none exist.
* Addresses are lowercased.
*/
export type ChompApiServiceGetAssociatedAddressesAction = {
type: `ChompApiService:getAssociatedAddresses`;
handler: ChompApiService['getAssociatedAddresses'];
};

/**
* Creates an account upgrade request.
*
Expand Down Expand Up @@ -120,6 +142,7 @@ export type ChompApiServiceGetServiceDetailsAction = {
*/
export type ChompApiServiceMethodActions =
| ChompApiServiceAssociateAddressAction
| ChompApiServiceGetAssociatedAddressesAction
| ChompApiServiceCreateUpgradeAction
| ChompApiServiceGetUpgradesAction
| ChompApiServiceVerifyDelegationAction
Expand Down
196 changes: 192 additions & 4 deletions packages/chomp-api-service/src/chomp-api-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,8 @@ describe('ChompApiService', () => {
});
});

it('returns the response on 409 without throwing', async () => {
nock(BASE_URL).post('/v1/auth/address').reply(409, {
it('returns the response when the address is already associated with the profile', async () => {
nock(BASE_URL).post('/v1/auth/address').reply(201, {
address: '0xabc',
status: 'active',
});
Expand All @@ -60,7 +60,19 @@ describe('ChompApiService', () => {
});
});

it('throws on non-201/409 status', async () => {
it('throws when the address is associated with another profile (409)', async () => {
nock(BASE_URL).post('/v1/auth/address').reply(409, {
statusCode: 409,
message: 'Address is already associated with another profile',
});
const { service } = createService();

await expect(service.associateAddress(associateParams)).rejects.toThrow(
"POST /v1/auth/address failed with status '409'",
);
});

it('throws on non-OK status', async () => {
nock(BASE_URL)
.post('/v1/auth/address')
.times(DEFAULT_MAX_RETRIES + 1)
Expand All @@ -84,6 +96,177 @@ describe('ChompApiService', () => {
});
});

describe('getAssociatedAddresses', () => {
const addressEntry = {
profileId: 'p1',
address: '0xabc',
status: 'active',
};

it('sends a GET with auth headers and returns the address entries', async () => {
nock(BASE_URL)
.get('/v1/auth/address')
.matchHeader('Authorization', `Bearer ${MOCK_TOKEN}`)
.reply(200, [addressEntry]);
const { rootMessenger } = createService();

const result = await rootMessenger.call(
'ChompApiService:getAssociatedAddresses',
);

expect(result).toStrictEqual([addressEntry]);
});

it('returns an empty array when no addresses are associated', async () => {
nock(BASE_URL).get('/v1/auth/address').reply(200, []);
const { service } = createService();

const result = await service.getAssociatedAddresses();

expect(result).toStrictEqual([]);
});

it('lowercases returned addresses', async () => {
nock(BASE_URL)
.get('/v1/auth/address')
.reply(200, [
{
profileId: 'p1',
address: '0xABCdef1234567890ABCdef1234567890ABCdef12',
status: 'active',
},
]);
const { service } = createService();

const result = await service.getAssociatedAddresses();

expect(result).toStrictEqual([
{
profileId: 'p1',
address: '0xabcdef1234567890abcdef1234567890abcdef12',
status: 'active',
},
]);
});

it('rejects entries with a non-active status', async () => {
nock(BASE_URL)
.get('/v1/auth/address')
.reply(200, [{ profileId: 'p1', address: '0xabc', status: 'deleted' }]);
const { service } = createService();

await expect(service.getAssociatedAddresses()).rejects.toThrow(
'At path: 0.status',
);
});

it('does not serve results from cache', async () => {
nock(BASE_URL).get('/v1/auth/address').reply(200, []);
nock(BASE_URL).get('/v1/auth/address').reply(200, [addressEntry]);
const { service } = createService();

const first = await service.getAssociatedAddresses();
const second = await service.getAssociatedAddresses();

expect(first).toStrictEqual([]);
expect(second).toStrictEqual([addressEntry]);
});

it('does not share an in-flight request across different bearer tokens', async () => {
const tokens = ['profile-a-token', 'profile-b-token'];
const { service } = createService({
getBearerToken: async () => tokens.shift() ?? 'exhausted',
});
// The first profile's request is still in flight when the second
// profile's request is issued; the second must not be deduplicated
// onto the first, or it would receive the first profile's addresses.
nock(BASE_URL)
.get('/v1/auth/address')
.matchHeader('Authorization', 'Bearer profile-a-token')
.delay(100)
.reply(200, []);
nock(BASE_URL)
.get('/v1/auth/address')
.matchHeader('Authorization', 'Bearer profile-b-token')
.reply(200, [addressEntry]);

const [first, second] = await Promise.all([
service.getAssociatedAddresses(),
service.getAssociatedAddresses(),
]);

expect(first).toStrictEqual([]);
expect(second).toStrictEqual([addressEntry]);
});

it('shares an in-flight request across calls with the same bearer token', async () => {
// A single interceptor: both concurrent same-profile calls must be
// served by one HTTP request.
nock(BASE_URL)
.get('/v1/auth/address')
.delay(100)
.reply(200, [addressEntry]);
const { service } = createService();

const [first, second] = await Promise.all([
service.getAssociatedAddresses(),
service.getAssociatedAddresses(),
]);

expect(first).toStrictEqual([addressEntry]);
expect(second).toStrictEqual([addressEntry]);
});

it('does not leak the bearer token through cache update events', async () => {
nock(BASE_URL).get('/v1/auth/address').reply(200, [addressEntry]);
const { service, messenger } = createService();
const publishSpy = jest.spyOn(messenger, 'publish');

await service.getAssociatedAddresses();

expect(publishSpy).toHaveBeenCalled();
expect(JSON.stringify(publishSpy.mock.calls)).not.toContain(MOCK_TOKEN);
});

it('evicts the result from the cache once the call settles', async () => {
nock(BASE_URL).get('/v1/auth/address').reply(200, [addressEntry]);
const { service, messenger } = createService();
const publishSpy = jest.spyOn(messenger, 'publish');

await service.getAssociatedAddresses();
// Eviction (`cacheTime: 0`) is scheduled on a macrotask; let it run.
await new Promise((resolve) => setTimeout(resolve, 0));

expect(publishSpy).toHaveBeenCalledWith(
'ChompApiService:cacheUpdated',
expect.objectContaining({ type: 'removed' }),
);
});

it('throws on non-OK status', async () => {
nock(BASE_URL)
.get('/v1/auth/address')
.times(DEFAULT_MAX_RETRIES + 1)
.reply(500);
const { service } = createService();

await expect(service.getAssociatedAddresses()).rejects.toThrow(
"GET /v1/auth/address failed with status '500'",
);
});

it('throws on malformed response', async () => {
nock(BASE_URL)
.get('/v1/auth/address')
.reply(200, JSON.stringify([{ bad: 'data' }]));
const { service } = createService();

await expect(service.getAssociatedAddresses()).rejects.toThrow(
'At path: 0.profileId',
);
});
});

describe('createUpgrade', () => {
const upgradeParams = {
r: '0x1' as const,
Expand Down Expand Up @@ -663,12 +846,17 @@ function createServiceMessenger(
* @param args.options - The options that the service constructor takes. All are
* optional and will be filled in with defaults as needed (including
* `messenger`).
* @param args.getBearerToken - The handler for the
* `AuthenticationController:getBearerToken` action. Defaults to returning
* `MOCK_TOKEN`.
* @returns The new service, root messenger, and service messenger.
*/
function createService({
options = {},
getBearerToken = async (): Promise<string> => MOCK_TOKEN,
}: {
options?: Partial<ConstructorParameters<typeof ChompApiService>[0]>;
getBearerToken?: () => Promise<string>;
} = {}): {
service: ChompApiService;
rootMessenger: RootMessenger;
Expand All @@ -677,7 +865,7 @@ function createService({
const rootMessenger = createRootMessenger();
rootMessenger.registerActionHandler(
'AuthenticationController:getBearerToken',
async () => MOCK_TOKEN,
getBearerToken,
);
const messenger = createServiceMessenger(rootMessenger);
rootMessenger.delegate({
Expand Down
Loading