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
47 changes: 47 additions & 0 deletions src/api/cloud-account.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/**********************************************************************
* Copyright (C) 2025 Red Hat, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* SPDX-License-Identifier: Apache-2.0
***********************************************************************/

import { z } from 'zod';

const CloudAccountResourceSchema = z.object({
metadata: z.object({
guid: z.string(),
}),
entity: z.object({
name: z.string(),
primary_owner: z.object({
ibmid: z.string(),
}),
}),
});

/**
* Schema for validating account data.
*/
export const CloudAccountSchema = z.object({
next_url: z.string().nullable(),
resources: z.array(CloudAccountResourceSchema),
});

export type CloudAccountType = z.infer<typeof CloudAccountSchema>;

export interface CloudAccount {
guid: string;
name: string;
ibmid: string;
}
117 changes: 117 additions & 0 deletions src/helper/account-select-helper.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
/**********************************************************************
* Copyright (C) 2025 Red Hat, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* SPDX-License-Identifier: Apache-2.0
***********************************************************************/

import { beforeAll, beforeEach, expect, test, vi } from 'vitest';
import { Container } from 'inversify';

import { AccountSelectHelper } from './account-select-helper';
import type { IAMSession } from '../api/iam-session';
import type { CloudAccountType } from '../api/cloud-account';

let accountSelectHelper: AccountSelectHelper;

beforeAll(() => {
// Mock the fetch function globally
Object.defineProperty(globalThis, 'fetch', {
writable: true,
value: vi.fn<(input: string | URL | globalThis.Request, init?: RequestInit) => Promise<Response>>(),
});
});

beforeEach(async () => {
vi.restoreAllMocks();
vi.resetAllMocks();

// Create fresh instance each time
const container = new Container();
container.bind(AccountSelectHelper).toSelf().inSingletonScope();

accountSelectHelper = await container.getAsync(AccountSelectHelper);
});

test('should fetch and return valid CloudAccount list', async () => {
expect.assertions(2);

const session: IAMSession = {
access_token: 'mock-access-token',
} as unknown as IAMSession;

const validAccountResponse: CloudAccountType = {
next_url: '',
resources: [
{
metadata: { guid: 'account-1' },
entity: {
name: 'Account One',
primary_owner: { ibmid: '[email protected]' },
},
},
{
metadata: { guid: 'account-2' },
entity: {
name: 'Account Two',
primary_owner: { ibmid: '[email protected]' },
},
},
],
};

vi.mocked(fetch).mockResolvedValue({
json: async () => validAccountResponse,
} as Response);

const result = await accountSelectHelper.getAccounts(session);

expect(fetch).toHaveBeenCalledWith(new URL(AccountSelectHelper.ACCOUNT_URL), {
method: 'GET',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${session.access_token}`,
},
});

expect(result).toStrictEqual([
{ guid: 'account-1', name: 'Account One', ibmid: '[email protected]' },
{ guid: 'account-2', name: 'Account Two', ibmid: '[email protected]' },
]);
});

test('should throw error on invalid account response', async () => {
expect.assertions(2);

const session: IAMSession = {
access_token: 'mock-access-token',
} as unknown as IAMSession;

const invalidResponse = { foo: 'bar' };

vi.mocked(fetch).mockResolvedValue({
json: async () => invalidResponse,
} as Response);

const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});

await expect(accountSelectHelper.getAccounts(session)).rejects.toThrow('Invalid account');

expect(errorSpy).toHaveBeenCalledWith(
'Invalid account',
expect.anything(), // Zod error details
);

errorSpy.mockRestore();
});
50 changes: 50 additions & 0 deletions src/helper/account-select-helper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { injectable } from 'inversify';
import { type CloudAccount, CloudAccountSchema, type CloudAccountType } from '../api/cloud-account';
import type { IAMSession } from '../api/iam-session';

/**
* Helper class to list all accounts
*/
@injectable()
export class AccountSelectHelper {
static readonly ACCOUNT_URL = 'https://accounts.cloud.ibm.com/v1/accounts';

async getAccounts(session: IAMSession): Promise<CloudAccount[]> {
// Get the accounts from the given session

const accountInfoUrl = new URL(AccountSelectHelper.ACCOUNT_URL);

// Use the fetch API to get the accounts
const response = await fetch(accountInfoUrl, {
method: 'GET',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${session.access_token}`,
},
});

const jsonBody = await response.json();
console.log('AccountSelectHelper json is', JSON.stringify(jsonBody));
const cloudData = this.validateJson(jsonBody);

// Map the accounts to the CloudAccount type
const accounts: CloudAccount[] = cloudData.resources.map(account => {
return {
guid: account.metadata.guid,
name: account.entity.name,
ibmid: account.entity.primary_owner.ibmid,
};
});

return accounts;
}

protected validateJson(json: unknown): CloudAccountType {
const result = CloudAccountSchema.safeParse(json);
if (!result.success) {
console.error('Invalid account', result.error);
throw new Error('Invalid account');
}
return result.data;
}
}
2 changes: 2 additions & 0 deletions src/helper/helper-module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,10 @@ import { IamSessionRefreshTokenHelper } from './iam-session-refresh-token-helper
import { PasscodeEndpointHelper } from './passcode-endpoint-helper';
import { PersistentSessionHelper } from './persistent-session-helper';
import { TokenEndpointHelper } from './token-endpoint-helper';
import { AccountSelectHelper } from './account-select-helper';

const helpersModule = new ContainerModule(options => {
options.bind<AccountSelectHelper>(AccountSelectHelper).toSelf().inSingletonScope();
options.bind<IamSessionAccessTokenHelper>(IamSessionAccessTokenHelper).toSelf().inSingletonScope();
options.bind<IamSessionConverterHelper>(IamSessionConverterHelper).toSelf().inSingletonScope();
options.bind<IamSessionRefreshTokenHelper>(IamSessionRefreshTokenHelper).toSelf().inSingletonScope();
Expand Down
50 changes: 48 additions & 2 deletions src/helper/iam-session-refresh-token-helper.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,15 @@ import type { IAMSession } from '../api/iam-session';
import { Container, injectFromBase, injectable } from 'inversify';
import { IamSessionRefreshTokenHelper } from './iam-session-refresh-token-helper';
import { TokenEndpointHelper } from './token-endpoint-helper';
import type { CloudAccount } from '../api/cloud-account';

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

@injectable()
@injectFromBase()
class TestIamSessionRefreshTokenHelper extends IamSessionRefreshTokenHelper {
createBody(refreshToken: string): string {
return super.createBody(refreshToken);
createBody(refreshToken: string, cloudAccount?: CloudAccount): string {
return super.createBody(refreshToken, cloudAccount);
}
}

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

test('should refresh token successfully with a custom account', async () => {
expect.assertions(3);

const mockSession: IAMSession = {
refresh_token: 'refresh-abc',
} as IAMSession;

const newSession: IAMSession = {
access_token: 'new-token',
refresh_token: 'new-refresh-token',
} as IAMSession;

const account: CloudAccount = {
guid: 'account-guid',
name: 'account-name',
ibmid: 'account-ibmid',
};

const expectedBody = 'grant_type=refresh_token&refresh_token=refresh-abc&account=account-guid';

vi.mocked(TokenEndpointHelper.prototype.getToken).mockResolvedValue(newSession);

const spyCreateBody = vi.spyOn(iamSessionRefreshTokenHelper, 'createBody');

const result = await iamSessionRefreshTokenHelper.refreshToken(mockSession, account);

expect(TokenEndpointHelper.prototype.getToken).toHaveBeenCalledWith(expectedBody);
expect(result).toStrictEqual(newSession);
expect(spyCreateBody).toHaveBeenCalledWith('refresh-abc', account);
});

test('should throw error if no refresh token is present', async () => {
expect.assertions(1);

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

expect(body).toBe('grant_type=refresh_token&refresh_token=refresh-xyz');
});

test('should build correct request body from refresh token and cloudAccount', () => {
expect.assertions(1);

const cloudAccount: CloudAccount = {
guid: 'account-guid',
name: 'account-name',
ibmid: 'account-ibmid',
};

const body = iamSessionRefreshTokenHelper.createBody('refresh-xyz', cloudAccount);

expect(body).toBe('grant_type=refresh_token&refresh_token=refresh-xyz&account=account-guid');
});
11 changes: 8 additions & 3 deletions src/helper/iam-session-refresh-token-helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import { inject } from 'inversify';
import type { IAMSession } from '../api/iam-session';
import { TokenEndpointHelper } from './token-endpoint-helper';
import type { CloudAccount } from '../api/cloud-account';

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

async refreshToken(session: IAMSession): Promise<IAMSession> {
async refreshToken(session: IAMSession, cloudAccount?: CloudAccount): Promise<IAMSession> {
const refreshToken = session.refresh_token;
if (!refreshToken) {
throw new Error('No refresh token found');
}
const body = this.createBody(refreshToken);
const body = this.createBody(refreshToken, cloudAccount);

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

protected createBody(refreshToken: string): string {
protected createBody(refreshToken: string, cloudAccount?: CloudAccount): string {
const body = new URLSearchParams();
body.append('grant_type', 'refresh_token');
body.append('refresh_token', refreshToken);
// Add the account guid if any
if (cloudAccount) {
body.append('account', cloudAccount.guid);
}
return body.toString();
}
}
Loading