diff --git a/api/routes/auth/message.mjs b/api/routes/auth/message.mjs index 7e5119d9..3b779c22 100644 --- a/api/routes/auth/message.mjs +++ b/api/routes/auth/message.mjs @@ -36,62 +36,63 @@ router.post('/?', async (req, res) => { } = message if (version !== 1) { - return res.status(400).send('Invalid message version') + return res.status(400).json({ error: 'Invalid message version' }) } // entry_id must be null or 32 byte hash if (entry_id && entry_id.length !== 64) { - return res.status(400).send('Invalid entry_id') + return res.status(400).json({ error: 'Invalid entry_id' }) } // chain_id must be null or 32 byte hash if (chain_id && chain_id.length !== 64) { - return res.status(400).send('Invalid chain_id') + return res.status(400).json({ error: 'Invalid chain_id' }) } // entry_clock must be null or positive integer if (entry_clock && entry_clock < 0) { - return res.status(400).send('Invalid entry_clock') + return res.status(400).json({ error: 'Invalid entry_clock' }) } // chain_clock must be null or positive integer if (chain_clock && chain_clock < 0) { - return res.status(400).send('Invalid chain_clock') + return res.status(400).json({ error: 'Invalid chain_clock' }) } // public_key must be 32 byte hash if (public_key.length !== 64) { - return res.status(400).send('Invalid public_key') + return res.status(400).json({ error: 'Invalid public_key' }) } // operation must be SET or DELETE - if (operation !== 'SET' && operation !== 'DELETE') { - return res.status(400).send('Invalid operation') + const allowed_operations = ['SET', 'SET_ACCOUNT_META', 'SET_BLOCK_META'] + if (!allowed_operations.includes(operation)) { + return res.status(400).json({ error: 'Invalid operation' }) } // content must be null or string if (content && typeof content !== 'string') { - return res.status(400).send('Invalid content') + return res.status(400).json({ error: 'Invalid content' }) } // tags must be null or array of strings if (tags && !Array.isArray(tags)) { - return res.status(400).send('Invalid tags') + return res.status(400).json({ error: 'Invalid tags' }) } // references must be null or array of strings if (references && !Array.isArray(references)) { - return res.status(400).send('Invalid references') + return res.status(400).json({ error: 'Invalid references' }) } // created_at must be null or positive integer if (created_at && created_at < 0) { - return res.status(400).send('Invalid created_at') + return res.status(400).json({ error: 'Invalid created_at' }) } // signature must be 64 byte hash if (signature.length !== 128) { - return res.status(400).send('Invalid signature') + return res.status(400).json({ error: 'Invalid signature' }) } // validate signature @@ -109,7 +110,7 @@ router.post('/?', async (req, res) => { signature }) if (!is_valid_signature) { - return res.status(400).send('Invalid signature') + return res.status(400).json({ error: 'Invalid signature' }) } // public_key can be a linked keypair or an existing nano account @@ -189,7 +190,7 @@ router.post('/?', async (req, res) => { } catch (error) { console.log(error) logger(error) - res.status(500).send('Internal server error') + res.status(500).json({ error: 'Internal server error' }) } }) diff --git a/cli/index.mjs b/cli/index.mjs index 6729a0c2..8b2895d3 100644 --- a/cli/index.mjs +++ b/cli/index.mjs @@ -10,10 +10,18 @@ import nano from 'nanocurrency' import { sign_nano_community_link_key, sign_nano_community_revoke_key, - sign_nano_community_message + sign_nano_community_message, + request } from '#common' +import config from '#config' import ed25519 from '@trashman/ed25519-blake2b' +const is_test = process.env.NODE_ENV === 'test' + +const base_url = is_test + ? `http://localhost:${config.port}` + : 'https://nano.community' + const load_private_key = async () => { let private_key = process.env.NANO_PRIVATE_KEY if (private_key) { @@ -57,17 +65,30 @@ const add_signing_key = { nano_account_public_key: public_key }) + const payload = { + public_key: linked_public_key.toString('hex'), + signature: signature.toString('hex'), + account: nano_account_address + } + + try { + const response = await request({ + url: `${base_url}/api/auth/register/key`, + method: 'POST', + body: JSON.stringify(payload), + headers: { + 'Content-Type': 'application/json' + } + }) + console.log('Key registration successful:', response) + } catch (error) { + console.error(`Failed to register key: ${error.message || error}`) + } + console.log({ - private_key, - public_key, - nano_account_address, - signature, - linked_public_key, - linked_private_key + linked_public_key: linked_public_key.toString('hex'), + linked_private_key: linked_private_key.toString('hex') }) - - // TODO send signed message to API - // TODO print out the linked public key and private key } } @@ -110,117 +131,167 @@ const revoke_signing_key = { public_key }) - // TODO send signed message to API + const payload = { + account: nano_account_address, + public_key: linked_public_key.toString('hex'), + signature: signature.toString('hex') + } + + try { + const response = await request({ + url: `${base_url}/api/auth/revoke/key`, + method: 'POST', + body: JSON.stringify(payload), + headers: { + 'Content-Type': 'application/json' + } + }) + console.log('Key revocation successful:', response) + } catch (error) { + console.error(`Failed to revoke key: ${error.message || error}`) + } } else { console.log('Signing key revocation cancelled.') } } } -const send_message = { - command: 'send-message [block_hash]', - describe: 'Send a message', +const update_rep_meta = { + command: 'update-rep-meta', + describe: 'Update representative metadata', + handler: async () => await sendMessageHandler('update-rep-meta') +} + +const update_account_meta = { + command: 'update-account-meta', + describe: 'Update account metadata', + handler: async () => await sendMessageHandler('update-account-meta') +} + +const update_block_meta = { + command: 'update-block-meta ', + describe: 'Update block metadata', builder: (yargs) => - yargs - .positional('type', { - describe: 'Type of message to send', - choices: ['update-rep-meta', 'update-account-meta', 'update-block-meta'] - }) - .positional('block_hash', { - describe: 'Block hash for update-block-meta type', - type: 'string' - }), - handler: async (argv) => { - const { private_key, public_key } = await load_private_key() - - // TODO fetch current values from API - - let message_content_prompts = [] - console.log(`Sending message of type: ${argv.type}`) - switch (argv.type) { - case 'update-rep-meta': - message_content_prompts = [ - { name: 'alias', message: 'Alias:' }, - { name: 'description', message: 'Description:' }, - { name: 'donation_address', message: 'Donation Address:' }, - { name: 'cpu_model', message: 'CPU Model:' }, - { name: 'cpu_cores', message: 'CPU Cores:' }, - { name: 'ram_amount', message: 'RAM Amount:' }, - { name: 'reddit_username', message: 'Reddit Username:' }, - { name: 'twitter_username', message: 'Twitter Username:' }, - { name: 'discord_username', message: 'Discord Username:' }, - { name: 'github_username', message: 'GitHub Username:' }, - { name: 'email', message: 'Email:' }, - { name: 'website_url', message: 'Website URL:' } - ] - break - case 'update-account-meta': - message_content_prompts = [{ name: 'alias', message: 'Alias:' }] - break - case 'update-block-meta': - if (!argv.block_hash) { - console.error('Block hash is required for update-block-meta type') - return - } - message_content_prompts = [{ name: 'note', message: 'Note:' }] - break - default: - console.error('Unknown message type') + yargs.positional('block_hash', { + describe: 'Block hash for update-block-meta type', + type: 'string' + }), + handler: async ({ block_hash }) => + await sendMessageHandler('update-block-meta', block_hash) +} + +const sendMessageHandler = async (type, block_hash = null) => { + const { private_key, public_key } = await load_private_key() + + let message_content_prompts = [] + console.log(`Sending message of type: ${type}`) + switch (type) { + case 'update-rep-meta': + message_content_prompts = [ + { name: 'alias', message: 'Alias:' }, + { name: 'description', message: 'Description:' }, + { name: 'donation_address', message: 'Donation Address:' }, + { name: 'cpu_model', message: 'CPU Model:' }, + { name: 'cpu_cores', message: 'CPU Cores:' }, + { name: 'ram_amount', message: 'RAM Amount:' }, + { name: 'reddit_username', message: 'Reddit Username:' }, + { name: 'twitter_username', message: 'Twitter Username:' }, + { name: 'discord_username', message: 'Discord Username:' }, + { name: 'github_username', message: 'GitHub Username:' }, + { name: 'email', message: 'Email:' }, + { name: 'website_url', message: 'Website URL:' } + ] + break + case 'update-account-meta': + message_content_prompts = [{ name: 'alias', message: 'Alias:' }] + break + case 'update-block-meta': + if (!block_hash) { + console.error('Block hash is required for update-block-meta type') return - } + } + message_content_prompts = [{ name: 'note', message: 'Note:' }] + break + default: + console.error('Unknown message type') + return + } - const message_content = await inquirer.prompt(message_content_prompts) - let confirm_edit = false - do { - console.log('Please review your message content:', message_content) - confirm_edit = await inquirer.prompt([ + const message_content = await inquirer.prompt(message_content_prompts) + let confirm_edit = false + do { + console.log('Please review your message content:', message_content) + confirm_edit = await inquirer.prompt([ + { + type: 'confirm', + name: 'edit', + message: 'Would you like to edit any field?', + default: false + } + ]) + confirm_edit = confirm_edit.edit + if (confirm_edit) { + const field_to_edit = await inquirer.prompt([ { - type: 'confirm', - name: 'edit', - message: 'Would you like to edit any field?', - default: false + type: 'list', + name: 'field', + message: 'Which field would you like to edit?', + choices: message_content_prompts.map((prompt) => prompt.name) } ]) - confirm_edit = confirm_edit.edit - if (confirm_edit) { - const field_to_edit = await inquirer.prompt([ - { - type: 'list', - name: 'field', - message: 'Which field would you like to edit?', - choices: message_content_prompts.map((prompt) => prompt.name) - } - ]) - const new_value = await inquirer.prompt([ - { - name: 'new_value', - message: `Enter new value for ${field_to_edit.field}:` - } - ]) - message_content[field_to_edit.field] = new_value.new_value - } - } while (confirm_edit) - - // Include block_hash in message for update-block-meta type - if (argv.type === 'update-block-meta') { - message_content.block_hash = argv.block_hash + const new_value = await inquirer.prompt([ + { + name: 'new_value', + message: `Enter new value for ${field_to_edit.field}:` + } + ]) + message_content[field_to_edit.field] = new_value.new_value } + } while (confirm_edit) - const message = { - created_at: Math.floor(Date.now() / 1000), - public_key, // public key of signing key - operation: argv.type.replace('-', '_'), - content: message_content - } + // Include block_hash in message for update-block-meta type + if (type === 'update-block-meta') { + message_content.block_hash = block_hash + } - const signature = sign_nano_community_message(message, private_key) + // Adjust operation to match allowed operations + let operation = '' + switch (type) { + case 'update-rep-meta': + case 'update-account-meta': + operation = 'SET_ACCOUNT_META' + break + case 'update-block-meta': + operation = 'SET_BLOCK_META' + break + } - console.log({ - message, - signature - }) + const message = { + version: 1, + created_at: Math.floor(Date.now() / 1000), + public_key, // public key of signing key + operation, + content: JSON.stringify(message_content) + } + + const signature = sign_nano_community_message(message, private_key) + const payload = { + ...message, + signature: signature.toString('hex') + } - // TODO send signed message to API + try { + const response = await request({ + url: `${base_url}/api/auth/message`, + method: 'POST', + body: JSON.stringify({ message: payload }), + headers: { + 'Content-Type': 'application/json' + } + }) + console.log('Message sent successful:', response) + } catch (error) { + console.error(`Failed to send message: ${error.message || error}`) } } @@ -230,6 +301,8 @@ yargs(hideBin(process.argv)) .usage('$0 [args]') .command(add_signing_key) .command(revoke_signing_key) - .command(send_message) + .command(update_rep_meta) + .command(update_account_meta) + .command(update_block_meta) .help('h') .alias('h', 'help').argv diff --git a/test/cli.test.mjs b/test/cli.test.mjs new file mode 100644 index 00000000..8cc18cde --- /dev/null +++ b/test/cli.test.mjs @@ -0,0 +1,264 @@ +/* global describe, before, after, it */ +import chai from 'chai' +import { exec, spawn } from 'child_process' +import util from 'util' + +import server from '#api/server.mjs' +import config from '#config' + +const { port } = config +const exec_promise = util.promisify(exec) + +process.env.NODE_ENV = 'test' +const expect = chai.expect + +function strip_ansi_escape_codes(str) { + const last_newline_index = str.lastIndexOf('\n') + if (last_newline_index !== -1) { + str = str.substring(last_newline_index + 1) + } + return str + .replace( + // eslint-disable-next-line no-control-regex + /[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g, + '' + ) + .trim() +} + +describe('CLI', function () { + this.timeout(30000) + + let new_signing_key + + before(() => { + process.env.NANO_PRIVATE_KEY = + '1111111111111111111111111111111111111111111111111111111111111111' + + server.listen(port, () => console.log(`API listening on port ${port}`)) + }) + + after(() => { + server.close() + }) + + describe('add-signing-key command', () => { + it('should add a new signing key', async () => { + const { stdout, stderr } = await exec_promise( + 'node cli/index.mjs add-signing-key' + ) + + // eslint-disable-next-line no-unused-expressions + expect(stderr).to.be.empty + expect(stdout).to.include('Key registration successful') + + // Extract the new signing key from stdout + const match = stdout.match(/linked_public_key: '([a-f0-9]+)'/) + if (match) { + new_signing_key = match[1] // Simplified extraction logic + } + }) + }) + + describe('revoke-signing-key command', () => { + it('should revoke an existing signing key', async () => { + if (!new_signing_key) { + throw new Error('No new signing key found for revocation test') + } + const { stdout, stderr } = await exec_promise( + `echo y | node cli/index.mjs revoke-signing-key ${new_signing_key}` + ) + // eslint-disable-next-line no-unused-expressions + expect(stderr).to.be.empty + expect(stdout).to.include('Key revocation successful') + }) + }) + + describe('update-rep-meta operation', () => { + it('should send a message for update-rep-meta operation', async () => { + let stdout = '' + let stderr = '' + try { + const child = spawn('node', ['cli/index.mjs', 'update-rep-meta'], { + stdio: ['pipe', 'pipe', 'pipe'] + }) + child.stdin.setDefaultEncoding('utf-8') + child.stdout.on('data', (data) => { + const output = strip_ansi_escape_codes(data.toString()) + + switch (output) { + case '? Alias:': + child.stdin.write('TestNodeAlias\n') + break + case '? Description:': + child.stdin.write('A test node for development purposes.\n') + break + case '? Donation Address:': + child.stdin.write( + 'nano_3niceeeyiaaif5xoiqjvth5gqrypuwytrm867asbciw3ndz8j3mazqqk6cok\n' + ) + break + case '? CPU Model:': + child.stdin.write('Intel i7\n') + break + case '? CPU Cores:': + child.stdin.write('4\n') + break + case '? RAM Amount:': + child.stdin.write('16GB\n') + break + case '? Reddit Username:': + child.stdin.write('test_reddit_user\n') + break + case '? Twitter Username:': + child.stdin.write('test_twitter_user\n') + break + case '? Discord Username:': + child.stdin.write('test_discord_user#1234\n') + break + case '? GitHub Username:': + child.stdin.write('test_github_user\n') + break + case '? Email:': + child.stdin.write('test@example.com\n') + break + case '? Website URL:': + child.stdin.write('https://example.com\n') + break + case '? Would you like to edit any field? (y/N)': + child.stdin.write('N\n') + break + case '? Confirm? (y/n)': + child.stdin.write('y\n') + child.stdin.end() + break + } + }) + + child.stderr.on('data', (data) => { + stderr += data.toString() + }) + + child.stdout.on('data', (data) => { + stdout += data.toString() + }) + + const exit_code = await new Promise((resolve) => { + child.on('close', resolve) + }) + + // eslint-disable-next-line no-unused-expressions + expect(stderr).to.be.empty + expect(exit_code).to.equal(0) + } catch (err) { + console.log(err) + console.log(stderr) + console.log(stdout) + throw err + } + }) + }) + + describe('update-account-meta operation', () => { + it('should send a message for update-account-meta operation', async () => { + let stderr = '' + let stdout = '' + try { + const child = spawn('node', ['cli/index.mjs', 'update-account-meta'], { + stdio: ['pipe', 'pipe', 'pipe'] + }) + child.stdin.setDefaultEncoding('utf-8') + child.stdout.on('data', (data) => { + const output = strip_ansi_escape_codes(data.toString().trim()) + switch (output) { + case '? Alias:': + child.stdin.write('Alias\n') + break + case '? Would you like to edit any field? (y/N)': + child.stdin.write('n\n') + break + case '? Confirm? (y/n)': + child.stdin.write('n\n') + child.stdin.end() + break + } + }) + + child.stderr.on('data', (data) => { + stderr += data.toString() + }) + + child.stdout.on('data', (data) => { + stdout += data.toString() + }) + + const exit_code = await new Promise((resolve) => { + child.on('close', resolve) + }) + + // eslint-disable-next-line no-unused-expressions + expect(stderr).to.be.empty + expect(exit_code).to.equal(0) + } catch (err) { + console.log(err) + console.log(stdout) + console.log(stderr) + throw err + } + }) + }) + + describe('update-block-meta operation', () => { + it('should send a message for update-block-meta operation', async () => { + let stdout = '' + let stderr = '' + const block_hash = + '943E3EED4F340ECBF7E06FA2E74A3E17B1DC4148C6913403B8ACFE7FBB1C2139' + + try { + const child = spawn( + 'node', + ['cli/index.mjs', 'update-block-meta', block_hash], + { stdio: ['pipe', 'pipe', 'pipe'] } + ) + child.stdin.setDefaultEncoding('utf-8') + child.stdout.on('data', (data) => { + const output = strip_ansi_escape_codes(data.toString().trim()) + switch (output) { + case '? Note:': + child.stdin.write('Test note for update-block-meta operation\n') + break + case '? Would you like to edit any field? (y/N)': + child.stdin.write('n\n') + break + case '? Confirm? (y/n)': + child.stdin.write('n\n') + child.stdin.end() + break + } + }) + + child.stderr.on('data', (data) => { + stderr += data.toString() + }) + + child.stdout.on('data', (data) => { + stdout += data.toString() + }) + + const exit_code = await new Promise((resolve) => { + child.on('close', resolve) + }) + + // eslint-disable-next-line no-unused-expressions + expect(stderr).to.be.empty + expect(exit_code).to.equal(0) + } catch (err) { + console.log(err) + console.log(stderr) + console.log(stdout) + throw err + } + }) + }) +})