Skip to content

Commit c2a8e20

Browse files
committed
feat: allow to select account
1 parent cd06f33 commit c2a8e20

8 files changed

+403
-26
lines changed

src/api/cloud-account.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
/**********************************************************************
2+
* Copyright (C) 2025 Red Hat, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*
16+
* SPDX-License-Identifier: Apache-2.0
17+
***********************************************************************/
18+
19+
import { z } from 'zod';
20+
21+
const CloudAccountResourceSchema = z.object({
22+
metadata: z.object({
23+
guid: z.string(),
24+
}),
25+
entity: z.object({
26+
name: z.string(),
27+
primary_owner: z.object({
28+
ibmid: z.string(),
29+
}),
30+
}),
31+
});
32+
33+
/**
34+
* Schema for validating account data.
35+
*/
36+
export const CloudAccountSchema = z.object({
37+
next_url: z.string().nullable(),
38+
resources: z.array(CloudAccountResourceSchema),
39+
});
40+
41+
export type CloudAccountType = z.infer<typeof CloudAccountSchema>;
42+
43+
export interface CloudAccount {
44+
guid: string;
45+
name: string;
46+
ibmid: string;
47+
}
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
/**********************************************************************
2+
* Copyright (C) 2025 Red Hat, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*
16+
* SPDX-License-Identifier: Apache-2.0
17+
***********************************************************************/
18+
19+
import { beforeAll, beforeEach, expect, test, vi } from 'vitest';
20+
import { Container } from 'inversify';
21+
22+
import { AccountSelectHelper } from './account-select-helper';
23+
import type { IAMSession } from '../api/iam-session';
24+
import type { CloudAccountType } from '../api/cloud-account';
25+
26+
let accountSelectHelper: AccountSelectHelper;
27+
28+
beforeAll(() => {
29+
// Mock the fetch function globally
30+
Object.defineProperty(globalThis, 'fetch', {
31+
writable: true,
32+
value: vi.fn<(input: string | URL | globalThis.Request, init?: RequestInit) => Promise<Response>>(),
33+
});
34+
});
35+
36+
beforeEach(async () => {
37+
vi.restoreAllMocks();
38+
vi.resetAllMocks();
39+
40+
// Create fresh instance each time
41+
const container = new Container();
42+
container.bind(AccountSelectHelper).toSelf().inSingletonScope();
43+
44+
accountSelectHelper = await container.getAsync(AccountSelectHelper);
45+
});
46+
47+
test('should fetch and return valid CloudAccount list', async () => {
48+
expect.assertions(2);
49+
50+
const session: IAMSession = {
51+
access_token: 'mock-access-token',
52+
} as unknown as IAMSession;
53+
54+
const validAccountResponse: CloudAccountType = {
55+
next_url: '',
56+
resources: [
57+
{
58+
metadata: { guid: 'account-1' },
59+
entity: {
60+
name: 'Account One',
61+
primary_owner: { ibmid: '[email protected]' },
62+
},
63+
},
64+
{
65+
metadata: { guid: 'account-2' },
66+
entity: {
67+
name: 'Account Two',
68+
primary_owner: { ibmid: '[email protected]' },
69+
},
70+
},
71+
],
72+
};
73+
74+
vi.mocked(fetch).mockResolvedValue({
75+
json: async () => validAccountResponse,
76+
} as Response);
77+
78+
const result = await accountSelectHelper.getAccounts(session);
79+
80+
expect(fetch).toHaveBeenCalledWith(new URL(AccountSelectHelper.ACCOUNT_URL), {
81+
method: 'GET',
82+
headers: {
83+
Accept: 'application/json',
84+
Authorization: `Bearer ${session.access_token}`,
85+
},
86+
});
87+
88+
expect(result).toStrictEqual([
89+
{ guid: 'account-1', name: 'Account One', ibmid: '[email protected]' },
90+
{ guid: 'account-2', name: 'Account Two', ibmid: '[email protected]' },
91+
]);
92+
});
93+
94+
test('should throw error on invalid account response', async () => {
95+
expect.assertions(2);
96+
97+
const session: IAMSession = {
98+
access_token: 'mock-access-token',
99+
} as unknown as IAMSession;
100+
101+
const invalidResponse = { foo: 'bar' };
102+
103+
vi.mocked(fetch).mockResolvedValue({
104+
json: async () => invalidResponse,
105+
} as Response);
106+
107+
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
108+
109+
await expect(accountSelectHelper.getAccounts(session)).rejects.toThrow('Invalid account');
110+
111+
expect(errorSpy).toHaveBeenCalledWith(
112+
'Invalid account',
113+
expect.anything(), // Zod error details
114+
);
115+
116+
errorSpy.mockRestore();
117+
});
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { injectable } from 'inversify';
2+
import { type CloudAccount, CloudAccountSchema, type CloudAccountType } from '../api/cloud-account';
3+
import type { IAMSession } from '../api/iam-session';
4+
5+
/**
6+
* Helper class to list all accounts
7+
*/
8+
@injectable()
9+
export class AccountSelectHelper {
10+
static readonly ACCOUNT_URL = 'https://accounts.cloud.ibm.com/v1/accounts';
11+
12+
async getAccounts(session: IAMSession): Promise<CloudAccount[]> {
13+
// Get the accounts from the given session
14+
15+
const accountInfoUrl = new URL(AccountSelectHelper.ACCOUNT_URL);
16+
17+
// Use the fetch API to get the accounts
18+
const response = await fetch(accountInfoUrl, {
19+
method: 'GET',
20+
headers: {
21+
Accept: 'application/json',
22+
Authorization: `Bearer ${session.access_token}`,
23+
},
24+
});
25+
26+
const jsonBody = await response.json();
27+
console.log('AccountSelectHelper json is', JSON.stringify(jsonBody));
28+
const cloudData = this.validateJson(jsonBody);
29+
30+
// Map the accounts to the CloudAccount type
31+
const accounts: CloudAccount[] = cloudData.resources.map(account => {
32+
return {
33+
guid: account.metadata.guid,
34+
name: account.entity.name,
35+
ibmid: account.entity.primary_owner.ibmid,
36+
};
37+
});
38+
39+
return accounts;
40+
}
41+
42+
protected validateJson(json: unknown): CloudAccountType {
43+
const result = CloudAccountSchema.safeParse(json);
44+
if (!result.success) {
45+
console.error('Invalid account', result.error);
46+
throw new Error('Invalid account');
47+
}
48+
return result.data;
49+
}
50+
}

src/helper/helper-module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,10 @@ import { IamSessionRefreshTokenHelper } from './iam-session-refresh-token-helper
2424
import { PasscodeEndpointHelper } from './passcode-endpoint-helper';
2525
import { PersistentSessionHelper } from './persistent-session-helper';
2626
import { TokenEndpointHelper } from './token-endpoint-helper';
27+
import { AccountSelectHelper } from './account-select-helper';
2728

2829
const helpersModule = new ContainerModule(options => {
30+
options.bind<AccountSelectHelper>(AccountSelectHelper).toSelf().inSingletonScope();
2931
options.bind<IamSessionAccessTokenHelper>(IamSessionAccessTokenHelper).toSelf().inSingletonScope();
3032
options.bind<IamSessionConverterHelper>(IamSessionConverterHelper).toSelf().inSingletonScope();
3133
options.bind<IamSessionRefreshTokenHelper>(IamSessionRefreshTokenHelper).toSelf().inSingletonScope();

src/helper/iam-session-refresh-token-helper.spec.ts

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,14 +21,15 @@ import type { IAMSession } from '../api/iam-session';
2121
import { Container, injectFromBase, injectable } from 'inversify';
2222
import { IamSessionRefreshTokenHelper } from './iam-session-refresh-token-helper';
2323
import { TokenEndpointHelper } from './token-endpoint-helper';
24+
import type { CloudAccount } from '../api/cloud-account';
2425

2526
vi.mock(import('./token-endpoint-helper'));
2627

2728
@injectable()
2829
@injectFromBase()
2930
class TestIamSessionRefreshTokenHelper extends IamSessionRefreshTokenHelper {
30-
createBody(refreshToken: string): string {
31-
return super.createBody(refreshToken);
31+
createBody(refreshToken: string, cloudAccount?: CloudAccount): string {
32+
return super.createBody(refreshToken, cloudAccount);
3233
}
3334
}
3435

@@ -67,6 +68,37 @@ test('should refresh token successfully using the refresh token', async () => {
6768
expect(result).toStrictEqual(newSession);
6869
});
6970

71+
test('should refresh token successfully with a custom account', async () => {
72+
expect.assertions(3);
73+
74+
const mockSession: IAMSession = {
75+
refresh_token: 'refresh-abc',
76+
} as IAMSession;
77+
78+
const newSession: IAMSession = {
79+
access_token: 'new-token',
80+
refresh_token: 'new-refresh-token',
81+
} as IAMSession;
82+
83+
const account: CloudAccount = {
84+
guid: 'account-guid',
85+
name: 'account-name',
86+
ibmid: 'account-ibmid',
87+
};
88+
89+
const expectedBody = 'grant_type=refresh_token&refresh_token=refresh-abc&account=account-guid';
90+
91+
vi.mocked(TokenEndpointHelper.prototype.getToken).mockResolvedValue(newSession);
92+
93+
const spyCreateBody = vi.spyOn(iamSessionRefreshTokenHelper, 'createBody');
94+
95+
const result = await iamSessionRefreshTokenHelper.refreshToken(mockSession, account);
96+
97+
expect(TokenEndpointHelper.prototype.getToken).toHaveBeenCalledWith(expectedBody);
98+
expect(result).toStrictEqual(newSession);
99+
expect(spyCreateBody).toHaveBeenCalledWith('refresh-abc', account);
100+
});
101+
70102
test('should throw error if no refresh token is present', async () => {
71103
expect.assertions(1);
72104

@@ -82,3 +114,17 @@ test('should build correct request body from refresh token', () => {
82114

83115
expect(body).toBe('grant_type=refresh_token&refresh_token=refresh-xyz');
84116
});
117+
118+
test('should build correct request body from refresh token and cloudAccount', () => {
119+
expect.assertions(1);
120+
121+
const cloudAccount: CloudAccount = {
122+
guid: 'account-guid',
123+
name: 'account-name',
124+
ibmid: 'account-ibmid',
125+
};
126+
127+
const body = iamSessionRefreshTokenHelper.createBody('refresh-xyz', cloudAccount);
128+
129+
expect(body).toBe('grant_type=refresh_token&refresh_token=refresh-xyz&account=account-guid');
130+
});

src/helper/iam-session-refresh-token-helper.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import { inject } from 'inversify';
2020
import type { IAMSession } from '../api/iam-session';
2121
import { TokenEndpointHelper } from './token-endpoint-helper';
22+
import type { CloudAccount } from '../api/cloud-account';
2223

2324
/**
2425
* Helper class for getting a new IAM sessions using the refresh token.
@@ -27,21 +28,25 @@ export class IamSessionRefreshTokenHelper {
2728
@inject(TokenEndpointHelper)
2829
private readonly tokenEndpointHelper: TokenEndpointHelper;
2930

30-
async refreshToken(session: IAMSession): Promise<IAMSession> {
31+
async refreshToken(session: IAMSession, cloudAccount?: CloudAccount): Promise<IAMSession> {
3132
const refreshToken = session.refresh_token;
3233
if (!refreshToken) {
3334
throw new Error('No refresh token found');
3435
}
35-
const body = this.createBody(refreshToken);
36+
const body = this.createBody(refreshToken, cloudAccount);
3637

3738
// Call the token endpoint with the refresh token
3839
return this.tokenEndpointHelper.getToken(body);
3940
}
4041

41-
protected createBody(refreshToken: string): string {
42+
protected createBody(refreshToken: string, cloudAccount?: CloudAccount): string {
4243
const body = new URLSearchParams();
4344
body.append('grant_type', 'refresh_token');
4445
body.append('refresh_token', refreshToken);
46+
// Add the account guid if any
47+
if (cloudAccount) {
48+
body.append('account', cloudAccount.guid);
49+
}
4550
return body.toString();
4651
}
4752
}

0 commit comments

Comments
 (0)