diff --git a/solo/jest.config.mjs b/solo/jest.config.mjs index ab20d9c9c..3cb258013 100644 --- a/solo/jest.config.mjs +++ b/solo/jest.config.mjs @@ -15,9 +15,20 @@ * */ const config = { - testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.(mjs?)$', moduleFileExtensions: ['js', 'mjs'], - verbose: true + verbose: true, + testSequencer: './test/e2e/jestCustomSequencer.cjs', + projects: [ + { + rootDir: '/test/e2e', + displayName: 'end-to-end', + testMatch: ['/**/*.test.mjs'] + }, + { + rootDir: '/test/unit', + displayName: 'unit', + testMatch: ['/**/*.test.mjs'] + } + ] } - export default config diff --git a/solo/package-lock.json b/solo/package-lock.json index c702f1268..f8094ad6d 100644 --- a/solo/package-lock.json +++ b/solo/package-lock.json @@ -37,6 +37,7 @@ }, "devDependencies": { "@jest/globals": "^29.7.0", + "@jest/test-sequencer": "^29.7.0", "eslint": "^8.53.0", "eslint-config-standard": "^17.1.0", "eslint-plugin-headers": "^1.1.0", diff --git a/solo/package.json b/solo/package.json index 5a2061aba..20968d273 100644 --- a/solo/package.json +++ b/solo/package.json @@ -46,6 +46,7 @@ }, "devDependencies": { "@jest/globals": "^29.7.0", + "@jest/test-sequencer": "^29.7.0", "eslint": "^8.53.0", "eslint-config-standard": "^17.1.0", "eslint-plugin-headers": "^1.1.0", diff --git a/solo/src/commands/account.mjs b/solo/src/commands/account.mjs new file mode 100644 index 000000000..b594dde50 --- /dev/null +++ b/solo/src/commands/account.mjs @@ -0,0 +1,388 @@ +/** + * Copyright (C) 2024 Hedera Hashgraph, LLC + * + * 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. + * + */ +import { BaseCommand } from './base.mjs' +import { FullstackTestingError, IllegalArgumentError } from '../core/errors.mjs' +import { flags } from './index.mjs' +import { Listr } from 'listr2' +import * as prompts from './prompts.mjs' +import { constants } from '../core/index.mjs' +import { sleep } from '../core/helpers.mjs' +import { HbarUnit, PrivateKey } from '@hashgraph/sdk' + +export class AccountCommand extends BaseCommand { + constructor (opts) { + super(opts) + + if (!opts || !opts.accountManager) throw new IllegalArgumentError('An instance of core/AccountManager is required', opts.accountManager) + + this.accountManager = opts.accountManager + this.nodeClient = null + this.accountInfo = null + } + + async closeConnections () { + if (this.nodeClient) { + this.nodeClient.close() + await sleep(5) // sleep a couple of ticks for connections to close + } + await this.accountManager.stopPortForwards() + await sleep(5) // sleep a couple of ticks for connections to close + } + + async buildAccountInfo (accountInfo, namespace, shouldRetrievePrivateKey) { + const newAccountInfo = { + accountId: accountInfo.accountId.toString(), + publicKey: accountInfo.key.toString(), + balance: accountInfo.balance.to(HbarUnit.Hbar).toNumber() + } + + if (shouldRetrievePrivateKey) { + const accountKeys = await this.accountManager.getAccountKeysFromSecret(newAccountInfo.accountId, namespace) + newAccountInfo.privateKey = accountKeys.privateKey + } + + return newAccountInfo + } + + async createNewAccount (ctx) { + if (ctx.config.privateKey) { + ctx.privateKey = PrivateKey.fromStringED25519(ctx.config.privateKey) + } else { + ctx.privateKey = PrivateKey.generateED25519() + } + + return await this.accountManager.createNewAccount(ctx.config.namespace, + ctx.nodeClient, ctx.privateKey, ctx.config.amount) + } + + async loadNodeClient (ctx) { + const serviceMap = await this.accountManager.getNodeServiceMap(ctx.config.namespace) + + ctx.nodeClient = await this.accountManager.getNodeClient(ctx.config.namespace, + serviceMap, ctx.treasuryAccountId, ctx.treasuryPrivateKey) + this.nodeClient = ctx.nodeClient // store in class so that we can make sure connections are closed + } + + async loadTreasuryAccount (ctx) { + ctx.treasuryAccountId = constants.TREASURY_ACCOUNT_ID + // check to see if the treasure account is in the secrets + const accountInfo = await this.accountManager.getAccountKeysFromSecret(ctx.treasuryAccountId, ctx.config.namespace) + + // if it isn't in the secrets we can load genesis key + if (accountInfo) { + ctx.treasuryPrivateKey = accountInfo.privateKey + } else { + ctx.treasuryPrivateKey = constants.GENESIS_KEY + } + } + + async getAccountInfo (ctx) { + return this.accountManager.accountInfoQuery(ctx.config.accountId, ctx.nodeClient) + } + + async updateAccountInfo (ctx) { + let amount = ctx.config.amount + if (ctx.config.privateKey) { + if (!(await this.accountManager.sendAccountKeyUpdate(ctx.accountInfo.accountId, ctx.config.privateKey, ctx.nodeClient, ctx.accountInfo.privateKey))) { + this.logger.error(`failed to update account keys for accountId ${ctx.accountInfo.accountId}`) + return false + } + this.logger.debug(`sent account key update for account ${ctx.accountInfo.accountId}`) + } else { + amount = amount || flags.amount.definition.defaultValue + } + + const hbarAmount = Number.parseFloat(amount) + if (amount && isNaN(hbarAmount)) { + throw new FullstackTestingError(`The HBAR amount was invalid: ${amount}`) + } + + if (hbarAmount > 0) { + if (!(await this.transferAmountFromOperator(ctx.nodeClient, ctx.accountInfo.accountId, hbarAmount))) { + this.logger.error(`failed to transfer amount for accountId ${ctx.accountInfo.accountId}`) + return false + } + this.logger.debug(`sent transfer amount for account ${ctx.accountInfo.accountId}`) + } + return true + } + + async transferAmountFromOperator (nodeClient, toAccountId, amount) { + return await this.accountManager.transferAmount(nodeClient, constants.TREASURY_ACCOUNT_ID, toAccountId, amount) + } + + async create (argv) { + const self = this + + const tasks = new Listr([ + { + title: 'Initialize', + task: async (ctx, task) => { + self.configManager.update(argv) + await prompts.execute(task, self.configManager, [ + flags.namespace + ]) + + const config = { + namespace: self.configManager.getFlag(flags.namespace), + privateKey: self.configManager.getFlag(flags.privateKey), + amount: self.configManager.getFlag(flags.amount) + } + + if (!config.amount) { + config.amount = flags.amount.definition.defaultValue + } + + if (!await this.k8.hasNamespace(config.namespace)) { + throw new FullstackTestingError(`namespace ${config.namespace} does not exist`) + } + + // set config in the context for later tasks to use + ctx.config = config + + self.logger.debug('Initialized config', { config }) + + await self.loadTreasuryAccount(ctx) + await self.loadNodeClient(ctx) + } + }, + { + title: 'create the new account', + task: async (ctx, task) => { + self.accountInfo = await self.createNewAccount(ctx) + const accountInfoCopy = { ...self.accountInfo } + delete accountInfoCopy.privateKey + + this.logger.showJSON('new account created', accountInfoCopy) + } + } + ], { + concurrent: false, + rendererOptions: constants.LISTR_DEFAULT_RENDERER_OPTION + }) + + try { + await tasks.run() + } catch (e) { + throw new FullstackTestingError(`Error in creating account: ${e.message}`, e) + } finally { + await this.closeConnections() + } + + return true + } + + async update (argv) { + const self = this + + const tasks = new Listr([ + { + title: 'Initialize', + task: async (ctx, task) => { + self.configManager.update(argv) + await prompts.execute(task, self.configManager, [ + flags.namespace, + flags.accountId + ]) + + const config = { + namespace: self.configManager.getFlag(flags.namespace), + accountId: self.configManager.getFlag(flags.accountId), + privateKey: self.configManager.getFlag(flags.privateKey), + amount: self.configManager.getFlag(flags.amount) + } + + if (!await this.k8.hasNamespace(config.namespace)) { + throw new FullstackTestingError(`namespace ${config.namespace} does not exist`) + } + + // set config in the context for later tasks to use + ctx.config = config + + self.logger.debug('Initialized config', { config }) + } + }, + { + title: 'get the account info', + task: async (ctx, task) => { + await self.loadTreasuryAccount(ctx) + await self.loadNodeClient(ctx) + ctx.accountInfo = await self.buildAccountInfo(await self.getAccountInfo(ctx), ctx.config.namespace, ctx.config.privateKey) + } + }, + { + title: 'update the account', + task: async (ctx, task) => { + if (!(await self.updateAccountInfo(ctx))) { + throw new FullstackTestingError(`An error occurred updating account ${ctx.accountInfo.accountId}`) + } + } + }, + { + title: 'get the updated account info', + task: async (ctx, task) => { + self.accountInfo = await self.buildAccountInfo(await self.getAccountInfo(ctx), ctx.config.namespace, false) + this.logger.showJSON('account info', self.accountInfo) + } + } + ], { + concurrent: false, + rendererOptions: constants.LISTR_DEFAULT_RENDERER_OPTION + }) + + try { + await tasks.run() + } catch (e) { + throw new FullstackTestingError(`Error in updating account: ${e.message}`, e) + } finally { + await this.closeConnections() + } + + return true + } + + async get (argv) { + const self = this + + const tasks = new Listr([ + { + title: 'Initialize', + task: async (ctx, task) => { + self.configManager.update(argv) + await prompts.execute(task, self.configManager, [ + flags.namespace, + flags.accountId + ]) + + const config = { + namespace: self.configManager.getFlag(flags.namespace), + accountId: self.configManager.getFlag(flags.accountId) + } + + if (!await this.k8.hasNamespace(config.namespace)) { + throw new FullstackTestingError(`namespace ${config.namespace} does not exist`) + } + + // set config in the context for later tasks to use + ctx.config = config + + self.logger.debug('Initialized config', { config }) + } + }, + { + title: 'get the account info', + task: async (ctx, task) => { + await self.loadTreasuryAccount(ctx) + await self.loadNodeClient(ctx) + self.accountInfo = await self.buildAccountInfo(await self.getAccountInfo(ctx), ctx.config.namespace, false) + this.logger.showJSON('account info', self.accountInfo) + } + } + ], { + concurrent: false, + rendererOptions: constants.LISTR_DEFAULT_RENDERER_OPTION + }) + + try { + await tasks.run() + } catch (e) { + throw new FullstackTestingError(`Error in getting account info: ${e.message}`, e) + } finally { + await this.closeConnections() + } + + return true + } + + /** + * Return Yargs command definition for 'node' command + * @param accountCmd an instance of NodeCommand + */ + static getCommandDefinition (accountCmd) { + return { + command: 'account', + desc: 'Manage Hedera accounts in fullstack testing network', + builder: yargs => { + return yargs + .command({ + command: 'create', + desc: 'Creates a new account with a new key and stores the key in the Kubernetes secrets', + builder: y => flags.setCommandFlags(y, + flags.namespace, + flags.privateKey, + flags.amount + ), + handler: argv => { + accountCmd.logger.debug("==== Running 'account create' ===") + accountCmd.logger.debug(argv) + + accountCmd.create(argv).then(r => { + accountCmd.logger.debug("==== Finished running 'account create' ===") + if (!r) process.exit(1) + }).catch(err => { + accountCmd.logger.showUserError(err) + process.exit(1) + }) + } + }) + .command({ + command: 'update', + desc: 'Updates an existing account with the provided info\n', + builder: y => flags.setCommandFlags(y, + flags.namespace, + flags.accountId, + flags.privateKey, + flags.amount + ), + handler: argv => { + accountCmd.logger.debug("==== Running 'account update' ===") + accountCmd.logger.debug(argv) + + accountCmd.update(argv).then(r => { + accountCmd.logger.debug("==== Finished running 'account update' ===") + if (!r) process.exit(1) + }).catch(err => { + accountCmd.logger.showUserError(err) + process.exit(1) + }) + } + }) + .command({ + command: 'get', + desc: 'Gets the account info including the current amount of HBAR', + builder: y => flags.setCommandFlags(y, + flags.namespace, + flags.accountId + ), + handler: argv => { + accountCmd.logger.debug("==== Running 'account get' ===") + accountCmd.logger.debug(argv) + + accountCmd.get(argv).then(r => { + accountCmd.logger.debug("==== Finished running 'account get' ===") + if (!r) process.exit(1) + }).catch(err => { + accountCmd.logger.showUserError(err) + process.exit(1) + }) + } + }) + .demandCommand(1, 'Select an account command') + } + } + } +} diff --git a/solo/src/commands/flags.mjs b/solo/src/commands/flags.mjs index 781641702..9c2dae8ba 100644 --- a/solo/src/commands/flags.mjs +++ b/solo/src/commands/flags.mjs @@ -388,6 +388,33 @@ export const updateAccountKeys = { } } +export const privateKey = { + name: 'private-key', + definition: { + describe: 'private key for the Hedera account', + defaultValue: '', + type: 'string' + } +} + +export const accountId = { + name: 'account-id', + definition: { + describe: 'The Hedera account id, e.g.: 0.0.1001', + defaultValue: '', + type: 'string' + } +} + +export const amount = { + name: 'hbar-amount', + definition: { + describe: 'Amount of HBAR to add', + defaultValue: 100, + type: 'number' + } +} + export const allFlags = [ devMode, clusterName, @@ -425,7 +452,10 @@ export const allFlags = [ bootstrapProperties, settingTxt, log4j2Xml, - updateAccountKeys + updateAccountKeys, + privateKey, + accountId, + amount ] export const allFlagsMap = new Map(allFlags.map(f => [f.name, f])) diff --git a/solo/src/commands/index.mjs b/solo/src/commands/index.mjs index 8682951cf..c6028e486 100644 --- a/solo/src/commands/index.mjs +++ b/solo/src/commands/index.mjs @@ -19,6 +19,7 @@ import { InitCommand } from './init.mjs' import { NetworkCommand } from './network.mjs' import { NodeCommand } from './node.mjs' import { RelayCommand } from './relay.mjs' +import { AccountCommand } from './account.mjs' import * as flags from './flags.mjs' /* @@ -31,13 +32,15 @@ function Initialize (opts) { const networkCommand = new NetworkCommand(opts) const nodeCmd = new NodeCommand(opts) const relayCmd = new RelayCommand(opts) + const accountCmd = new AccountCommand(opts) return [ InitCommand.getCommandDefinition(initCmd), ClusterCommand.getCommandDefinition(clusterCmd), NetworkCommand.getCommandDefinition(networkCommand), NodeCommand.getCommandDefinition(nodeCmd), - RelayCommand.getCommandDefinition(relayCmd) + RelayCommand.getCommandDefinition(relayCmd), + AccountCommand.getCommandDefinition(accountCmd) ] } diff --git a/solo/src/commands/init.mjs b/solo/src/commands/init.mjs index 893274ea0..f7316c5b3 100644 --- a/solo/src/commands/init.mjs +++ b/solo/src/commands/init.mjs @@ -159,8 +159,8 @@ export class InitCommand extends BaseCommand { flags.clusterSetupNamespace, flags.cacheDir, flags.chartDirectory, - flags.keyFormat, - ) + flags.keyFormat + ) }, handler: (argv) => { initCmd.init(argv).then(r => { diff --git a/solo/src/commands/prompts.mjs b/solo/src/commands/prompts.mjs index 56e487b75..7a3dc1321 100644 --- a/solo/src/commands/prompts.mjs +++ b/solo/src/commands/prompts.mjs @@ -23,7 +23,9 @@ import * as helpers from '../core/helpers.mjs' async function prompt (type, task, input, defaultValue, promptMessage, emptyCheckMessage, flagName) { try { - const needsPrompt = type === 'toggle' ? (input === undefined || typeof input !== 'boolean') : !input + let needsPrompt = type === 'toggle' ? (input === undefined || typeof input !== 'boolean') : !input + needsPrompt = type === 'number' ? typeof input !== 'number' : needsPrompt + if (needsPrompt) { input = await task.prompt(ListrEnquirerPromptAdapter).run({ type, @@ -262,19 +264,11 @@ export async function promptOperatorKey (task, input) { } export async function promptReplicaCount (task, input) { - try { - if (typeof input !== 'number') { - input = await task.prompt(ListrEnquirerPromptAdapter).run({ - type: 'number', - default: flags.replicaCount.definition.defaultValue, - message: 'How many replica do you want?' - }) - } - - return input - } catch (e) { - throw new FullstackTestingError(`input failed: ${flags.replicaCount.name}`, e) - } + return await prompt('number', task, input, + flags.replicaCount.definition.defaultValue, + 'How many replica do you want? ', + null, + flags.replicaCount.name) } export async function promptGenerateGossipKeys (task, input) { @@ -341,6 +335,30 @@ export async function promptUpdateAccountKeys (task, input) { flags.updateAccountKeys.name) } +export async function promptPrivateKey (task, input) { + return await promptText(task, input, + flags.privateKey.definition.defaultValue, + 'Enter the private key: ', + null, + flags.privateKey.name) +} + +export async function promptAccountId (task, input) { + return await promptText(task, input, + flags.accountId.definition.defaultValue, + 'Enter the account id: ', + null, + flags.accountId.name) +} + +export async function promptAmount (task, input) { + return await prompt('number', task, input, + flags.amount.definition.defaultValue, + 'How much HBAR do you want to add? ', + null, + flags.amount.name) +} + export function getPromptMap () { return new Map() .set(flags.nodeIDs.name, promptNodeIds) @@ -372,6 +390,9 @@ export function getPromptMap () { .set(flags.keyFormat.name, promptKeyFormat) .set(flags.fstChartVersion.name, promptFstChartVersion) .set(flags.updateAccountKeys.name, promptUpdateAccountKeys) + .set(flags.privateKey.name, promptPrivateKey) + .set(flags.accountId.name, promptAccountId) + .set(flags.amount.name, promptAmount) } // build the prompt registry diff --git a/solo/src/core/account_manager.mjs b/solo/src/core/account_manager.mjs index 33b26e86a..7419cdcc9 100644 --- a/solo/src/core/account_manager.mjs +++ b/solo/src/core/account_manager.mjs @@ -16,11 +16,17 @@ */ import * as constants from './constants.mjs' import { + AccountCreateTransaction, AccountId, - AccountInfoQuery, AccountUpdateTransaction, + AccountInfoQuery, + AccountUpdateTransaction, Client, + Hbar, + HbarUnit, KeyList, - PrivateKey, Status + PrivateKey, + Status, + TransferTransaction } from '@hashgraph/sdk' import { FullstackTestingError } from './errors.mjs' import { sleep } from './helpers.mjs' @@ -97,7 +103,7 @@ export class AccountManager { await this.updateSpecialAccountsKeys(namespace, nodeClient, constants.SYSTEM_ACCOUNTS) // update the treasury account last - await this.updateSpecialAccountsKeys(namespace, nodeClient, constants.TREASURY_ACCOUNT) + await this.updateSpecialAccountsKeys(namespace, nodeClient, constants.TREASURY_ACCOUNTS) nodeClient.close() await this.stopPortForwards() @@ -136,7 +142,7 @@ export class AccountManager { `Expected service ${serviceObject.name} to have a loadBalancerIP set for basepath ${this.k8.kubeClient.basePath}`) } const host = this.isLocalhost() ? '127.0.0.1' : serviceObject.loadBalancerIp - const port = serviceObject.grpcPort // TODO: add grpcs logic in https://github.com/hashgraph/full-stack-testing/issues/752 + const port = serviceObject.grpcPort const targetPort = this.isLocalhost() ? localPort : port if (this.isLocalhost()) { @@ -155,7 +161,7 @@ export class AccountManager { return nodeClient } catch (e) { - throw new FullstackTestingError('failed to setup node client', e) + throw new FullstackTestingError(`failed to setup node client: ${e.message}`, e) } } @@ -338,6 +344,18 @@ export class AccountManager { } } + /** + * gets the account info from Hedera network + * @param accountId the account + * @param nodeClient the active and configured node client + * @returns {AccountInfo} the private key of the account + */ + async accountInfoQuery (accountId, nodeClient) { + return await new AccountInfoQuery() + .setAccountId(accountId) + .execute(nodeClient) + } + /** * gets the account private and public key from the Kubernetes secret from which it is stored * @param accountId the account @@ -345,9 +363,7 @@ export class AccountManager { * @returns {Promise} the private key of the account */ async getAccountKeys (accountId, nodeClient) { - const accountInfo = await new AccountInfoQuery() - .setAccountId(accountId) - .execute(nodeClient) + const accountInfo = await this.accountInfoQuery(accountId, nodeClient) let keys if (accountInfo.key instanceof KeyList) { @@ -365,13 +381,21 @@ export class AccountManager { * @param accountId the account that will get it's keys updated * @param newPrivateKey the new private key * @param nodeClient the active and configured node client - * @param genesisKey the genesis key that is the current key + * @param oldPrivateKey the genesis key that is the current key * @returns {Promise} whether the update was successful */ - async sendAccountKeyUpdate (accountId, newPrivateKey, nodeClient, genesisKey) { + async sendAccountKeyUpdate (accountId, newPrivateKey, nodeClient, oldPrivateKey) { this.logger.debug( `Updating account ${accountId.toString()} with new public and private keys`) + if (typeof newPrivateKey === 'string') { + newPrivateKey = PrivateKey.fromStringED25519(newPrivateKey) + } + + if (typeof oldPrivateKey === 'string') { + oldPrivateKey = PrivateKey.fromStringED25519(oldPrivateKey) + } + // Create the transaction to update the key on the account const transaction = await new AccountUpdateTransaction() .setAccountId(accountId) @@ -379,7 +403,7 @@ export class AccountManager { .freezeWith(nodeClient) // Sign the transaction with the old key and new key - const signTx = await (await transaction.sign(genesisKey)).sign( + const signTx = await (await transaction.sign(oldPrivateKey)).sign( newPrivateKey) // SIgn the transaction with the client operator private key and submit to a Hedera network @@ -422,4 +446,73 @@ export class AccountManager { socket.destroy() await sleep(1) // gives a few ticks for connections to close } + + /** + * creates a new Hedera account + * @param namespace the namespace to store the Kubernetes key secret into + * @param nodeClient the active and network configured node client + * @param privateKey the private key of type PrivateKey + * @param amount the amount of HBAR to add to the account + * @returns {{accountId: AccountId, privateKey: string, publicKey: string, balance: number}} a + * custom object with the account information in it + */ + async createNewAccount (namespace, nodeClient, privateKey, amount) { + const newAccount = await new AccountCreateTransaction() + .setKey(privateKey) + .setInitialBalance(Hbar.from(amount, HbarUnit.Hbar)) + .execute(nodeClient) + + // Get the new account ID + const getReceipt = await newAccount.getReceipt(nodeClient) + const accountInfo = { + accountId: getReceipt.accountId.toString(), + privateKey: privateKey.toString(), + publicKey: privateKey.publicKey.toString(), + balance: amount + } + + if (!(await this.k8.createSecret( + Templates.renderAccountKeySecretName(accountInfo.accountId), + namespace, 'Opaque', { + privateKey: accountInfo.privateKey, + publicKey: accountInfo.publicKey + }, + Templates.renderAccountKeySecretLabelObject(accountInfo.accountId), true)) + ) { + this.logger.error(`new account created [accountId=${accountInfo.accountId}, amount=${amount} HBAR, publicKey=${accountInfo.publicKey}, privateKey=${accountInfo.privateKey}] but failed to create secret in Kubernetes`) + + throw new FullstackTestingError(`failed to create secret for accountId ${accountInfo.accountId.toString()}, keys were sent to log file`) + } + this.logger.debug(`created k8s secret for account ${accountInfo.accountId}`) + + return accountInfo + } + + /** + * transfer the specified amount of HBAR from one account to another + * @param nodeClient the configured and active network node client + * @param fromAccountId the account to pull the HBAR from + * @param toAccountId the account to put the HBAR + * @param hbarAmount the amount of HBAR + * @returns {Promise} if the transaction was successfully posted + */ + async transferAmount (nodeClient, fromAccountId, toAccountId, hbarAmount) { + try { + const transaction = new TransferTransaction() + .addHbarTransfer(fromAccountId, new Hbar(-1 * hbarAmount)) + .addHbarTransfer(toAccountId, new Hbar(hbarAmount)) + + const txResponse = await transaction.execute(nodeClient) + + const receipt = await txResponse.getReceipt(nodeClient) + + this.logger.debug(`The transfer from account ${fromAccountId} to account ${toAccountId} for amount ${hbarAmount} was ${receipt.status.toString()} `) + + return receipt.status === Status.Success + } catch (e) { + const errorMessage = `transfer amount failed with an error: ${e.toString()}` + this.logger.error(errorMessage) + throw new FullstackTestingError(errorMessage, e) + } + } } diff --git a/solo/src/core/constants.mjs b/solo/src/core/constants.mjs index 241a7c553..2213836f3 100644 --- a/solo/src/core/constants.mjs +++ b/solo/src/core/constants.mjs @@ -76,8 +76,10 @@ export const DEFAULT_CHART_REPO = new Map() export const OPERATOR_ID = process.env.SOLO_OPERATOR_ID || '0.0.2' export const OPERATOR_KEY = process.env.SOLO_OPERATOR_KEY || '302e020100300506032b65700422042091132178e72057a1d7528025956fe39b0b847f200ab59b2fdd367017f3087137' export const OPERATOR_PUBLIC_KEY = process.env.SOLO_OPERATOR_PUBLIC_KEY || '302a300506032b65700321000aa8e21064c61eab86e2a9c164565b4e7a9a4146106e0a6cd03a8c395a110e92' +export const TREASURY_ACCOUNT_ID = `${HEDERA_NODE_ACCOUNT_ID_START.realm}.${HEDERA_NODE_ACCOUNT_ID_START.shard}.2` +export const GENESIS_KEY = process.env.GENESIS_KEY || '302e020100300506032b65700422042091132178e72057a1d7528025956fe39b0b847f200ab59b2fdd367017f3087137' export const SYSTEM_ACCOUNTS = [[3, 100], [200, 349], [400, 750], [900, 1000]] // do account 0.0.2 last and outside the loop -export const TREASURY_ACCOUNT = [[2, 2]] +export const TREASURY_ACCOUNTS = [[2, 2]] export const LOCAL_NODE_START_PORT = process.env.LOCAL_NODE_START_PORT || 30212 export const ACCOUNT_KEYS_UPDATE_PAUSE = process.env.ACCOUNT_KEYS_UPDATE_PAUSE || 5 diff --git a/solo/test/e2e/commands/node.test.mjs b/solo/test/e2e/commands/01_node.test.mjs similarity index 100% rename from solo/test/e2e/commands/node.test.mjs rename to solo/test/e2e/commands/01_node.test.mjs diff --git a/solo/test/e2e/commands/02_account.test.mjs b/solo/test/e2e/commands/02_account.test.mjs new file mode 100644 index 000000000..916d89a7c --- /dev/null +++ b/solo/test/e2e/commands/02_account.test.mjs @@ -0,0 +1,210 @@ +/** + * Copyright (C) 2024 Hedera Hashgraph, LLC + * + * 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. + * + */ +import { + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it +} from '@jest/globals' +import { + ChartManager, + ConfigManager, + constants, + DependencyManager, + Helm, + K8 +} from '../../../src/core/index.mjs' +import { getTestCacheDir, testLogger } from '../../test_util.js' +import path from 'path' +import { AccountManager } from '../../../src/core/account_manager.mjs' +import { AccountCommand } from '../../../src/commands/account.mjs' +import { flags } from '../../../src/commands/index.mjs' +import { sleep } from '../../../src/core/helpers.mjs' + +describe('account commands should work correctly', () => { + const defaultTimeout = 20000 + let accountCmd + let accountManager + let configManager + let k8 + let helm + let chartManager + let depManager + let argv = {} + let accountId1 + let accountId2 + + beforeAll(() => { + configManager = new ConfigManager(testLogger, path.join(getTestCacheDir('accountCmd'), 'solo.config')) + k8 = new K8(configManager, testLogger) + accountManager = new AccountManager(testLogger, k8, constants) + helm = new Helm(testLogger) + chartManager = new ChartManager(helm, testLogger) + depManager = new DependencyManager(testLogger) + accountCmd = new AccountCommand({ + logger: testLogger, + helm, + k8, + chartManager, + configManager, + depManager, + accountManager + }) + }) + + beforeEach(() => { + configManager.reset() + argv = {} + argv[flags.cacheDir.name] = getTestCacheDir('accountCmd') + argv[flags.namespace.name] = 'solo-e2e' + argv[flags.clusterName.name] = 'kind-solo-e2e' + argv[flags.clusterSetupNamespace.name] = 'solo-e2e-cluster' + configManager.update(argv, true) + }) + + afterEach(() => { + sleep(5).then().catch() // give a few ticks so that connections can close + }) + + it('account create with no options', async () => { + try { + await expect(accountCmd.create(argv)).resolves.toBeTruthy() + + const accountInfo = accountCmd.accountInfo + expect(accountInfo).not.toBeNull() + expect(accountInfo.accountId).not.toBeNull() + accountId1 = accountInfo.accountId + expect(accountInfo.privateKey).not.toBeNull() + expect(accountInfo.publicKey).not.toBeNull() + expect(accountInfo.balance).toEqual(flags.amount.definition.defaultValue) + } catch (e) { + testLogger.showUserError(e) + expect(e).toBeNull() + } finally { + await accountCmd.closeConnections() + } + }, defaultTimeout) + + it('account create with private key and hbar amount options', async () => { + try { + argv[flags.privateKey.name] = constants.GENESIS_KEY + argv[flags.amount.name] = 777 + configManager.update(argv, true) + + await expect(accountCmd.create(argv)).resolves.toBeTruthy() + + const accountInfo = accountCmd.accountInfo + expect(accountInfo).not.toBeNull() + expect(accountInfo.accountId).not.toBeNull() + accountId2 = accountInfo.accountId + expect(accountInfo.privateKey.toString()).toEqual(constants.GENESIS_KEY) + expect(accountInfo.publicKey).not.toBeNull() + expect(accountInfo.balance).toEqual(777) + } catch (e) { + testLogger.showUserError(e) + expect(e).toBeNull() + } finally { + await accountCmd.closeConnections() + } + }, defaultTimeout) + + it('account update with account', async () => { + try { + argv[flags.accountId.name] = accountId1 + configManager.update(argv, true) + + await expect(accountCmd.update(argv)).resolves.toBeTruthy() + + const accountInfo = accountCmd.accountInfo + expect(accountInfo).not.toBeNull() + expect(accountInfo.accountId).toEqual(argv[flags.accountId.name]) + expect(accountInfo.privateKey).toBeUndefined() + expect(accountInfo.publicKey).not.toBeNull() + expect(accountInfo.balance).toEqual(200) + } catch (e) { + testLogger.showUserError(e) + expect(e).toBeNull() + } finally { + await accountCmd.closeConnections() + } + }, defaultTimeout) + + it('account update with account, amount, new private key, and standard out options', async () => { + try { + argv[flags.accountId.name] = accountId2 + argv[flags.privateKey.name] = constants.GENESIS_KEY + argv[flags.amount.name] = 333 + configManager.update(argv, true) + + await expect(accountCmd.update(argv)).resolves.toBeTruthy() + + const accountInfo = accountCmd.accountInfo + expect(accountInfo).not.toBeNull() + expect(accountInfo.accountId).toEqual(argv[flags.accountId.name]) + expect(accountInfo.privateKey).toBeUndefined() + expect(accountInfo.publicKey).not.toBeNull() + expect(accountInfo.balance).toEqual(1110) + } catch (e) { + testLogger.showUserError(e) + expect(e).toBeNull() + } finally { + await accountCmd.closeConnections() + } + }, defaultTimeout) + + it('account get with account option', async () => { + try { + argv[flags.accountId.name] = accountId1 + configManager.update(argv, true) + + await expect(accountCmd.get(argv)).resolves.toBeTruthy() + const accountInfo = accountCmd.accountInfo + expect(accountInfo).not.toBeNull() + expect(accountInfo.accountId).toEqual(argv[flags.accountId.name]) + expect(accountInfo.privateKey).toBeUndefined() + expect(accountInfo.publicKey).toBeTruthy() + expect(accountInfo.balance).toEqual(200) + } catch (e) { + testLogger.showUserError(e) + expect(e).toBeNull() + } finally { + await accountCmd.closeConnections() + } + }, defaultTimeout) + + it('account get with account id option', async () => { + try { + argv[flags.accountId.name] = accountId2 + configManager.update(argv, true) + + await expect(accountCmd.get(argv)).resolves.toBeTruthy() + const accountInfo = accountCmd.accountInfo + expect(accountInfo).not.toBeNull() + expect(accountInfo.accountId).toEqual(argv[flags.accountId.name]) + expect(accountInfo.privateKey).toBeUndefined() + expect(accountInfo.publicKey).toBeTruthy() + expect(accountInfo.balance).toEqual(1110) + } catch (e) { + testLogger.showUserError(e) + expect(e).toBeNull() + } finally { + await accountCmd.closeConnections() + } + }, defaultTimeout) +}) diff --git a/solo/test/e2e/core/k8_e2e.test.mjs b/solo/test/e2e/core/k8_e2e.test.mjs index a2cd671dc..2158ced04 100644 --- a/solo/test/e2e/core/k8_e2e.test.mjs +++ b/solo/test/e2e/core/k8_e2e.test.mjs @@ -99,7 +99,7 @@ describe('K8', () => { await expect(k8.execContainer(podName, containerName, ['rm', '-f', destPath])).resolves fs.rmdirSync(tmpDir, { recursive: true }) - }, 50000) + }, 120000) it('should be able to port forward gossip port', (done) => { const podName = Templates.renderNetworkPodName('node0') diff --git a/solo/test/e2e/jestCustomSequencer.cjs b/solo/test/e2e/jestCustomSequencer.cjs new file mode 100644 index 000000000..282666cc6 --- /dev/null +++ b/solo/test/e2e/jestCustomSequencer.cjs @@ -0,0 +1,33 @@ +/** + * Copyright (C) 2024 Hedera Hashgraph, LLC + * + * 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. + * + */ +const Sequencer = require('@jest/test-sequencer').default + +const isEndToEnd = (test) => { + const contextConfig = test.context.config + return contextConfig.displayName.name === 'end-to-end' +} + +class CustomSequencer extends Sequencer { + sort (tests) { + const copyTests = Array.from(tests) + const normalTests = copyTests.filter((t) => !isEndToEnd(t)) + const endToEndTests = copyTests.filter((t) => isEndToEnd(t)) + return super.sort(normalTests).concat(endToEndTests.sort((a, b) => (a.path > b.path ? 1 : -1))) + } +} + +module.exports = CustomSequencer diff --git a/solo/test/test_util.js b/solo/test/test_util.js index b7b35d294..a75100759 100644 --- a/solo/test/test_util.js +++ b/solo/test/test_util.js @@ -21,8 +21,10 @@ import { logging } from '../src/core/index.mjs' export const testLogger = logging.NewLogger('debug') -export function getTestCacheDir () { - const d = 'test/data/tmp' +export function getTestCacheDir (appendDir) { + const baseDir = 'test/data/tmp' + const d = appendDir ? path.join(baseDir, appendDir) : baseDir + if (!fs.existsSync(d)) { fs.mkdirSync(d) }