Skip to content

Commit d74c6ac

Browse files
authored
Merge branch 'main' into feat/WPN-1473-assets-controller-get-asset
2 parents 3873b2b + ef48273 commit d74c6ac

11 files changed

Lines changed: 536 additions & 34 deletions

packages/chomp-api-service/CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- 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))
13+
- Also adds the `ProfileAddressEntry` type describing each returned entry and the `ChompApiServiceGetAssociatedAddressesAction` type
14+
- Returned addresses are parsed into canonical lowercase form, entries are guaranteed to have `status: 'active'`, and results are never served from cache
15+
- 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
16+
1017
### Changed
1118

19+
- **BREAKING:** `associateAddress` now throws an `HttpError` on a 409 response instead of returning the parsed body ([#9387](https://github.com/MetaMask/core/pull/9387))
20+
- 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.
1221
- Bump `@metamask/utils` from `^11.9.0` to `^11.11.0` ([#9074](https://github.com/MetaMask/core/pull/9074))
1322
- 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))
1423
- Bump `@metamask/base-data-service` from `^0.1.2` to `^0.1.3` ([#8799](https://github.com/MetaMask/core/pull/8799))

packages/chomp-api-service/src/chomp-api-service-method-action-types.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,35 @@ import type { ChompApiService } from './chomp-api-service';
1212
*
1313
* @param params - The association params containing signature, timestamp,
1414
* and address.
15-
* @returns The profile association result. Returns on both 201 and 409.
15+
* @returns The profile association result: `status: 'created'` for a new
16+
* association, `status: 'active'` when the address was already associated
17+
* with the authenticated profile. Throws on 409, which indicates the
18+
* address is associated with a different profile.
1619
*/
1720
export type ChompApiServiceAssociateAddressAction = {
1821
type: `ChompApiService:associateAddress`;
1922
handler: ChompApiService['associateAddress'];
2023
};
2124

25+
/**
26+
* Fetches the addresses associated with the authenticated profile.
27+
*
28+
* GET /v1/auth/address
29+
*
30+
* The result is scoped to the authenticated profile and consumers use it to
31+
* decide whether an association already exists, so it is always fetched
32+
* fresh (`staleTime: 0`, `cacheTime: 0`) and the query key is scoped to the
33+
* profile via a digest of the bearer token — concurrent calls only share a
34+
* request when they are for the same profile.
35+
*
36+
* @returns The active address associations; empty array if none exist.
37+
* Addresses are lowercased.
38+
*/
39+
export type ChompApiServiceGetAssociatedAddressesAction = {
40+
type: `ChompApiService:getAssociatedAddresses`;
41+
handler: ChompApiService['getAssociatedAddresses'];
42+
};
43+
2244
/**
2345
* Creates an account upgrade request.
2446
*
@@ -120,6 +142,7 @@ export type ChompApiServiceGetServiceDetailsAction = {
120142
*/
121143
export type ChompApiServiceMethodActions =
122144
| ChompApiServiceAssociateAddressAction
145+
| ChompApiServiceGetAssociatedAddressesAction
123146
| ChompApiServiceCreateUpgradeAction
124147
| ChompApiServiceGetUpgradesAction
125148
| ChompApiServiceVerifyDelegationAction

packages/chomp-api-service/src/chomp-api-service.test.ts

Lines changed: 192 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,8 @@ describe('ChompApiService', () => {
4545
});
4646
});
4747

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

63-
it('throws on non-201/409 status', async () => {
63+
it('throws when the address is associated with another profile (409)', async () => {
64+
nock(BASE_URL).post('/v1/auth/address').reply(409, {
65+
statusCode: 409,
66+
message: 'Address is already associated with another profile',
67+
});
68+
const { service } = createService();
69+
70+
await expect(service.associateAddress(associateParams)).rejects.toThrow(
71+
"POST /v1/auth/address failed with status '409'",
72+
);
73+
});
74+
75+
it('throws on non-OK status', async () => {
6476
nock(BASE_URL)
6577
.post('/v1/auth/address')
6678
.times(DEFAULT_MAX_RETRIES + 1)
@@ -84,6 +96,177 @@ describe('ChompApiService', () => {
8496
});
8597
});
8698

99+
describe('getAssociatedAddresses', () => {
100+
const addressEntry = {
101+
profileId: 'p1',
102+
address: '0xabc',
103+
status: 'active',
104+
};
105+
106+
it('sends a GET with auth headers and returns the address entries', async () => {
107+
nock(BASE_URL)
108+
.get('/v1/auth/address')
109+
.matchHeader('Authorization', `Bearer ${MOCK_TOKEN}`)
110+
.reply(200, [addressEntry]);
111+
const { rootMessenger } = createService();
112+
113+
const result = await rootMessenger.call(
114+
'ChompApiService:getAssociatedAddresses',
115+
);
116+
117+
expect(result).toStrictEqual([addressEntry]);
118+
});
119+
120+
it('returns an empty array when no addresses are associated', async () => {
121+
nock(BASE_URL).get('/v1/auth/address').reply(200, []);
122+
const { service } = createService();
123+
124+
const result = await service.getAssociatedAddresses();
125+
126+
expect(result).toStrictEqual([]);
127+
});
128+
129+
it('lowercases returned addresses', async () => {
130+
nock(BASE_URL)
131+
.get('/v1/auth/address')
132+
.reply(200, [
133+
{
134+
profileId: 'p1',
135+
address: '0xABCdef1234567890ABCdef1234567890ABCdef12',
136+
status: 'active',
137+
},
138+
]);
139+
const { service } = createService();
140+
141+
const result = await service.getAssociatedAddresses();
142+
143+
expect(result).toStrictEqual([
144+
{
145+
profileId: 'p1',
146+
address: '0xabcdef1234567890abcdef1234567890abcdef12',
147+
status: 'active',
148+
},
149+
]);
150+
});
151+
152+
it('rejects entries with a non-active status', async () => {
153+
nock(BASE_URL)
154+
.get('/v1/auth/address')
155+
.reply(200, [{ profileId: 'p1', address: '0xabc', status: 'deleted' }]);
156+
const { service } = createService();
157+
158+
await expect(service.getAssociatedAddresses()).rejects.toThrow(
159+
'At path: 0.status',
160+
);
161+
});
162+
163+
it('does not serve results from cache', async () => {
164+
nock(BASE_URL).get('/v1/auth/address').reply(200, []);
165+
nock(BASE_URL).get('/v1/auth/address').reply(200, [addressEntry]);
166+
const { service } = createService();
167+
168+
const first = await service.getAssociatedAddresses();
169+
const second = await service.getAssociatedAddresses();
170+
171+
expect(first).toStrictEqual([]);
172+
expect(second).toStrictEqual([addressEntry]);
173+
});
174+
175+
it('does not share an in-flight request across different bearer tokens', async () => {
176+
const tokens = ['profile-a-token', 'profile-b-token'];
177+
const { service } = createService({
178+
getBearerToken: async () => tokens.shift() ?? 'exhausted',
179+
});
180+
// The first profile's request is still in flight when the second
181+
// profile's request is issued; the second must not be deduplicated
182+
// onto the first, or it would receive the first profile's addresses.
183+
nock(BASE_URL)
184+
.get('/v1/auth/address')
185+
.matchHeader('Authorization', 'Bearer profile-a-token')
186+
.delay(100)
187+
.reply(200, []);
188+
nock(BASE_URL)
189+
.get('/v1/auth/address')
190+
.matchHeader('Authorization', 'Bearer profile-b-token')
191+
.reply(200, [addressEntry]);
192+
193+
const [first, second] = await Promise.all([
194+
service.getAssociatedAddresses(),
195+
service.getAssociatedAddresses(),
196+
]);
197+
198+
expect(first).toStrictEqual([]);
199+
expect(second).toStrictEqual([addressEntry]);
200+
});
201+
202+
it('shares an in-flight request across calls with the same bearer token', async () => {
203+
// A single interceptor: both concurrent same-profile calls must be
204+
// served by one HTTP request.
205+
nock(BASE_URL)
206+
.get('/v1/auth/address')
207+
.delay(100)
208+
.reply(200, [addressEntry]);
209+
const { service } = createService();
210+
211+
const [first, second] = await Promise.all([
212+
service.getAssociatedAddresses(),
213+
service.getAssociatedAddresses(),
214+
]);
215+
216+
expect(first).toStrictEqual([addressEntry]);
217+
expect(second).toStrictEqual([addressEntry]);
218+
});
219+
220+
it('does not leak the bearer token through cache update events', async () => {
221+
nock(BASE_URL).get('/v1/auth/address').reply(200, [addressEntry]);
222+
const { service, messenger } = createService();
223+
const publishSpy = jest.spyOn(messenger, 'publish');
224+
225+
await service.getAssociatedAddresses();
226+
227+
expect(publishSpy).toHaveBeenCalled();
228+
expect(JSON.stringify(publishSpy.mock.calls)).not.toContain(MOCK_TOKEN);
229+
});
230+
231+
it('evicts the result from the cache once the call settles', async () => {
232+
nock(BASE_URL).get('/v1/auth/address').reply(200, [addressEntry]);
233+
const { service, messenger } = createService();
234+
const publishSpy = jest.spyOn(messenger, 'publish');
235+
236+
await service.getAssociatedAddresses();
237+
// Eviction (`cacheTime: 0`) is scheduled on a macrotask; let it run.
238+
await new Promise((resolve) => setTimeout(resolve, 0));
239+
240+
expect(publishSpy).toHaveBeenCalledWith(
241+
'ChompApiService:cacheUpdated',
242+
expect.objectContaining({ type: 'removed' }),
243+
);
244+
});
245+
246+
it('throws on non-OK status', async () => {
247+
nock(BASE_URL)
248+
.get('/v1/auth/address')
249+
.times(DEFAULT_MAX_RETRIES + 1)
250+
.reply(500);
251+
const { service } = createService();
252+
253+
await expect(service.getAssociatedAddresses()).rejects.toThrow(
254+
"GET /v1/auth/address failed with status '500'",
255+
);
256+
});
257+
258+
it('throws on malformed response', async () => {
259+
nock(BASE_URL)
260+
.get('/v1/auth/address')
261+
.reply(200, JSON.stringify([{ bad: 'data' }]));
262+
const { service } = createService();
263+
264+
await expect(service.getAssociatedAddresses()).rejects.toThrow(
265+
'At path: 0.profileId',
266+
);
267+
});
268+
});
269+
87270
describe('createUpgrade', () => {
88271
const upgradeParams = {
89272
r: '0x1' as const,
@@ -663,12 +846,17 @@ function createServiceMessenger(
663846
* @param args.options - The options that the service constructor takes. All are
664847
* optional and will be filled in with defaults as needed (including
665848
* `messenger`).
849+
* @param args.getBearerToken - The handler for the
850+
* `AuthenticationController:getBearerToken` action. Defaults to returning
851+
* `MOCK_TOKEN`.
666852
* @returns The new service, root messenger, and service messenger.
667853
*/
668854
function createService({
669855
options = {},
856+
getBearerToken = async (): Promise<string> => MOCK_TOKEN,
670857
}: {
671858
options?: Partial<ConstructorParameters<typeof ChompApiService>[0]>;
859+
getBearerToken?: () => Promise<string>;
672860
} = {}): {
673861
service: ChompApiService;
674862
rootMessenger: RootMessenger;
@@ -677,7 +865,7 @@ function createService({
677865
const rootMessenger = createRootMessenger();
678866
rootMessenger.registerActionHandler(
679867
'AuthenticationController:getBearerToken',
680-
async () => MOCK_TOKEN,
868+
getBearerToken,
681869
);
682870
const messenger = createServiceMessenger(rootMessenger);
683871
rootMessenger.delegate({

0 commit comments

Comments
 (0)