diff --git a/lib/profile.js b/lib/profile.js index a29837c75559c..24f026ce85ec4 100644 --- a/lib/profile.js +++ b/lib/profile.js @@ -1,35 +1,36 @@ -const ansistyles = require('ansistyles') const inspect = require('util').inspect +const { URL } = require('url') +const ansistyles = require('ansistyles') const log = require('npmlog') +const npmProfile = require('npm-profile') +const qrcodeTerminal = require('qrcode-terminal') +const Table = require('cli-table3') + const npm = require('./npm.js') const otplease = require('./utils/otplease.js') const output = require('./utils/output.js') -const profile = require('npm-profile') const pulseTillDone = require('./utils/pulse-till-done.js') -const qrcodeTerminal = require('qrcode-terminal') const readUserInfo = require('./utils/read-user-info.js') -const Table = require('cli-table3') -const { URL } = require('url') +const usageUtil = require('./utils/usage.js') -module.exports = profileCmd - -profileCmd.usage = - 'npm profile enable-2fa [auth-only|auth-and-writes]\n' + - 'npm profile disable-2fa\n' + - 'npm profile get []\n' + +const usage = usageUtil( + 'npm profile enable-2fa [auth-only|auth-and-writes]\n', + 'npm profile disable-2fa\n', + 'npm profile get []\n', 'npm profile set ' +) -profileCmd.subcommands = ['enable-2fa', 'disable-2fa', 'get', 'set'] - -profileCmd.completion = function (opts, cb) { +const completion = (opts, cb) => { var argv = opts.conf.argv.remain + const subcommands = ['enable-2fa', 'disable-2fa', 'get', 'set'] + + if (!argv[2]) + return cb(null, subcommands) + switch (argv[2]) { case 'enable-2fa': case 'enable-tfa': - if (argv.length === 3) - return cb(null, ['auth-and-writes', 'auth-only']) - else - return cb(null, []) + return cb(null, ['auth-and-writes', 'auth-only']) case 'disable-2fa': case 'disable-tfa': @@ -41,35 +42,33 @@ profileCmd.completion = function (opts, cb) { } } -function withCb (prom, cb) { - prom.then((value) => cb(null, value), cb) -} +const cmd = (args, cb) => profile(args).then(() => cb()).catch(cb) -function profileCmd (args, cb) { +const profile = async (args) => { if (args.length === 0) - return cb(new Error(profileCmd.usage)) + throw new Error(usage) + log.gauge.show('profile') - switch (args[0]) { + + const [subcmd, ...opts] = args + + switch (subcmd) { case 'enable-2fa': case 'enable-tfa': case 'enable2fa': case 'enabletfa': - withCb(enable2fa(args.slice(1)), cb) - break + return enable2fa(opts) case 'disable-2fa': case 'disable-tfa': case 'disable2fa': case 'disabletfa': - withCb(disable2fa(), cb) - break + return disable2fa() case 'get': - withCb(get(args.slice(1)), cb) - break + return get(opts) case 'set': - withCb(set(args.slice(1)), cb) - break + return set(opts) default: - cb(new Error('Unknown profile command: ' + args[0])) + throw new Error('Unknown profile command: ' + subcmd) } } @@ -86,53 +85,62 @@ const knownProfileKeys = [ 'updated', ] -function get (args) { +const get = async args => { const tfa = 'two-factor auth' - const conf = npm.flatOptions - return pulseTillDone.withPromise(profile.get(conf)).then((info) => { - if (!info.cidr_whitelist) - delete info.cidr_whitelist - if (conf.json) { - output(JSON.stringify(info, null, 2)) - return - } - const cleaned = {} - knownProfileKeys.forEach((k) => { - cleaned[k] = info[k] || '' - }) - Object.keys(info).filter((k) => !(k in cleaned)).forEach((k) => { - cleaned[k] = info[k] || '' - }) - delete cleaned.tfa - delete cleaned.email_verified - cleaned.email += info.email_verified ? ' (verified)' : '(unverified)' - if (info.tfa && !info.tfa.pending) - cleaned[tfa] = info.tfa.mode - else - cleaned[tfa] = 'disabled' - - if (args.length) { - const values = args // comma or space separated ↓ - .join(',').split(/,/).map((arg) => arg.trim()).filter((arg) => arg !== '') - .map((arg) => cleaned[arg]) - .join('\t') - output(values) - } else { - if (conf.parseable) { - Object.keys(info).forEach((key) => { - if (key === 'tfa') - output(`${key}\t${cleaned[tfa]}`) - else - output(`${key}\t${info[key]}`) - }) - } else { - const table = new Table() - for (const k of Object.keys(cleaned)) - table.push({ [ansistyles.bright(k)]: cleaned[k] }) - output(table.toString()) + const conf = { ...npm.flatOptions } + + const info = await pulseTillDone.withPromise(npmProfile.get(conf)) + + if (!info.cidr_whitelist) + delete info.cidr_whitelist + + if (conf.json) { + output(JSON.stringify(info, null, 2)) + return + } + + // clean up and format key/values for output + const cleaned = {} + for (const key of knownProfileKeys) + cleaned[key] = info[key] || '' + + const unknownProfileKeys = Object.keys(info).filter((k) => !(k in cleaned)) + for (const key of unknownProfileKeys) + cleaned[key] = info[key] || '' + + delete cleaned.tfa + delete cleaned.email_verified + cleaned.email += info.email_verified ? ' (verified)' : '(unverified)' + + if (info.tfa && !info.tfa.pending) + cleaned[tfa] = info.tfa.mode + else + cleaned[tfa] = 'disabled' + + if (args.length) { + const values = args // comma or space separated + .join(',') + .split(/,/) + .filter((arg) => arg.trim() !== '') + .map((arg) => cleaned[arg]) + .join('\t') + output(values) + } else { + if (conf.parseable) { + for (const key of Object.keys(info)) { + if (key === 'tfa') + output(`${key}\t${cleaned[tfa]}`) + else + output(`${key}\t${info[key]}`) } + } else { + const table = new Table() + for (const key of Object.keys(cleaned)) + table.push({ [ansistyles.bright(key)]: cleaned[key] }) + + output(table.toString()) } - }) + } } const writableProfileKeys = [ @@ -145,83 +153,87 @@ const writableProfileKeys = [ 'github', ] -function set (args) { - const conf = npm.flatOptions +const set = async (args) => { + const conf = { ...npm.flatOptions } const prop = (args[0] || '').toLowerCase().trim() + let value = args.length > 1 ? args.slice(1).join(' ') : null + + const readPasswords = async () => { + const newpassword = await readUserInfo.password('New password: ') + const confirmedpassword = await readUserInfo.password(' Again: ') + + if (newpassword !== confirmedpassword) { + log.warn('profile', 'Passwords do not match, please try again.') + return readPasswords() + } + + return newpassword + } + if (prop !== 'password' && value === null) - return Promise.reject(Error('npm profile set ')) + throw new Error('npm profile set ') if (prop === 'password' && value !== null) { - return Promise.reject(Error( + throw new Error( 'npm profile set password\n' + - 'Do not include your current or new passwords on the command line.')) + 'Do not include your current or new passwords on the command line.') } - if (writableProfileKeys.indexOf(prop) === -1) - return Promise.reject(Error(`"${prop}" is not a property we can set. Valid properties are: ` + writableProfileKeys.join(', '))) - - return Promise.resolve().then(() => { - if (prop === 'password') { - return readUserInfo.password('Current password: ').then((current) => { - return readPasswords().then((newpassword) => { - value = { old: current, new: newpassword } - }) - }) - } else if (prop === 'email') { - return readUserInfo.password('Password: ').then((current) => { - return { password: current, email: value } - }) - } - function readPasswords () { - return readUserInfo.password('New password: ').then((password1) => { - return readUserInfo.password(' Again: ').then((password2) => { - if (password1 !== password2) { - log.warn('profile', 'Passwords do not match, please try again.') - return readPasswords() - } - return password1 - }) - }) - } - }).then(() => { - // FIXME: Work around to not clear everything other than what we're setting - return pulseTillDone.withPromise(profile.get(conf).then((user) => { - const newUser = {} - writableProfileKeys.forEach((k) => { - newUser[k] = user[k] - }) - newUser[prop] = value - return otplease(conf, conf => profile.set(newUser, conf)) - .then((result) => { - if (conf.json) - output(JSON.stringify({ [prop]: result[prop] }, null, 2)) - else if (conf.parseable) - output(prop + '\t' + result[prop]) - else if (result[prop] != null) - output('Set', prop, 'to', result[prop]) - else - output('Set', prop) - }) - })) - }) + + if (writableProfileKeys.indexOf(prop) === -1) { + throw new Error(`"${prop}" is not a property we can set. ` + + `Valid properties are: ` + writableProfileKeys.join(', ')) + } + + if (prop === 'password') { + const current = await readUserInfo.password('Current password: ') + const newpassword = await readPasswords() + + value = { old: current, new: newpassword } + } + + // FIXME: Work around to not clear everything other than what we're setting + const user = await pulseTillDone.withPromise(npmProfile.get(conf)) + const newUser = {} + + for (const key of writableProfileKeys) + newUser[key] = user[key] + + newUser[prop] = value + + const result = await otplease(conf, conf => npmProfile.set(newUser, conf)) + + if (conf.json) + output(JSON.stringify({ [prop]: result[prop] }, null, 2)) + else if (conf.parseable) + output(prop + '\t' + result[prop]) + else if (result[prop] != null) + output('Set', prop, 'to', result[prop]) + else + output('Set', prop) } -function enable2fa (args) { +const enable2fa = async (args) => { if (args.length > 1) - return Promise.reject(new Error('npm profile enable-2fa [auth-and-writes|auth-only]')) + throw new Error('npm profile enable-2fa [auth-and-writes|auth-only]') const mode = args[0] || 'auth-and-writes' if (mode !== 'auth-only' && mode !== 'auth-and-writes') { - return Promise.reject(new Error(`Invalid two-factor authentication mode "${mode}".\n` + + throw new Error( + `Invalid two-factor authentication mode "${mode}".\n` + 'Valid modes are:\n' + ' auth-only - Require two-factor authentication only when logging in\n' + - ' auth-and-writes - Require two-factor authentication when logging in AND when publishing')) + ' auth-and-writes - Require two-factor authentication when logging in ' + + 'AND when publishing' + ) } - const conf = npm.flatOptions + + const conf = { ...npm.flatOptions } if (conf.json || conf.parseable) { - return Promise.reject(new Error( + throw new Error( 'Enabling two-factor authentication is an interactive operation and ' + - (conf.json ? 'JSON' : 'parseable') + ' output mode is not available')) + (conf.json ? 'JSON' : 'parseable') + ' output mode is not available' + ) } const info = { @@ -230,119 +242,153 @@ function enable2fa (args) { }, } - return Promise.resolve().then(() => { - // if they're using legacy auth currently then we have to update them to a - // bearer token before continuing. - const auth = getAuth(conf) - if (auth.basic) { - log.info('profile', 'Updating authentication to bearer token') - return profile.createToken( - auth.basic.password, false, [], conf - ).then((result) => { - if (!result.token) { - throw new Error(`Your registry ${conf.registry} does not seem to ` + - 'support bearer tokens. Bearer tokens are required for ' + - 'two-factor authentication') - } - npm.config.setCredentialsByURI(conf.registry, { token: result.token }) - return npm.config.save('user') - }) + // if they're using legacy auth currently then we have to + // update them to a bearer token before continuing. + const auth = getAuth(conf) + + if (!auth.basic && !auth.token) { + throw new Error( + 'You need to be logged in to registry ' + + `${conf.registry} in order to enable 2fa` + ) + } + + if (auth.basic) { + log.info('profile', 'Updating authentication to bearer token') + const result = await npmProfile.createToken( + auth.basic.password, false, [], conf + ) + + if (!result.token) { + throw new Error( + `Your registry ${conf.registry} does not seem to ` + + 'support bearer tokens. Bearer tokens are required for ' + + 'two-factor authentication' + ) } - }).then(() => { - log.notice('profile', 'Enabling two factor authentication for ' + mode) - return readUserInfo.password() - }).then((password) => { - info.tfa.password = password - log.info('profile', 'Determine if tfa is pending') - return pulseTillDone.withPromise(profile.get(conf)).then((info) => { - if (!info.tfa) - return - if (info.tfa.pending) { - log.info('profile', 'Resetting two-factor authentication') - return pulseTillDone.withPromise(profile.set({ tfa: { password, mode: 'disable' } }, conf)) - } else { - if (conf.auth.otp) - return - return readUserInfo.otp('Enter one-time password from your authenticator app: ').then((otp) => { - conf.auth.otp = otp - }) - } - }) - }).then(() => { - log.info('profile', 'Setting two-factor authentication to ' + mode) - return pulseTillDone.withPromise(profile.set(info, conf)) - }).then((challenge) => { - if (challenge.tfa === null) { - output('Two factor authentication mode changed to: ' + mode) - return + + npm.config.setCredentialsByURI(conf.registry, { token: result.token }) + await npm.config.save('user') + } + + log.notice('profile', 'Enabling two factor authentication for ' + mode) + const password = await readUserInfo.password() + info.tfa.password = password + + log.info('profile', 'Determine if tfa is pending') + const userInfo = await pulseTillDone.withPromise(npmProfile.get(conf)) + + if (userInfo && userInfo.tfa && userInfo.tfa.pending) { + log.info('profile', 'Resetting two-factor authentication') + await pulseTillDone.withPromise( + npmProfile.set({ tfa: { password, mode: 'disable' } }, conf) + ) + } else if (userInfo && userInfo.tfa) { + if (conf.otp) + conf.otp = conf.otp + else { + const otp = await readUserInfo.otp( + 'Enter one-time password from your authenticator app: ' + ) + conf.otp = otp } - if (typeof challenge.tfa !== 'string' || !/^otpauth:[/][/]/.test(challenge.tfa)) - throw new Error('Unknown error enabling two-factor authentication. Expected otpauth URL, got: ' + inspect(challenge.tfa)) - - const otpauth = new URL(challenge.tfa) - const secret = otpauth.searchParams.get('secret') - return qrcode(challenge.tfa).then((code) => { - output('Scan into your authenticator app:\n' + code + '\n Or enter code:', secret) - }).then((code) => { - return readUserInfo.otp('And an OTP code from your authenticator: ') - }).then((otp1) => { - log.info('profile', 'Finalizing two-factor authentication') - return profile.set({ tfa: [otp1] }, conf) - }).then((result) => { - output('2FA successfully enabled. Below are your recovery codes, please print these out.') - output('You will need these to recover access to your account if you lose your authentication device.') - result.tfa.forEach((c) => output('\t' + c)) - }) - }) + } + + log.info('profile', 'Setting two-factor authentication to ' + mode) + const challenge = await pulseTillDone.withPromise(npmProfile.set(info, conf)) + + if (challenge.tfa === null) { + output('Two factor authentication mode changed to: ' + mode) + return + } + + const badResponse = typeof challenge.tfa !== 'string' + || !/^otpauth:[/][/]/.test(challenge.tfa) + if (badResponse) { + throw new Error( + 'Unknown error enabling two-factor authentication. Expected otpauth URL' + + ', got: ' + inspect(challenge.tfa) + ) + } + + const otpauth = new URL(challenge.tfa) + const secret = otpauth.searchParams.get('secret') + const code = await qrcode(challenge.tfa) + + output( + 'Scan into your authenticator app:\n' + code + '\n Or enter code:', secret + ) + + const interactiveOTP = + await readUserInfo.otp('And an OTP code from your authenticator: ') + + log.info('profile', 'Finalizing two-factor authentication') + + const result = await npmProfile.set({ tfa: [interactiveOTP] }, conf) + + output( + '2FA successfully enabled. Below are your recovery codes, ' + + 'please print these out.' + ) + output( + 'You will need these to recover access to your account ' + + 'if you lose your authentication device.' + ) + + for (const tfaCode of result.tfa) + output('\t' + tfaCode) } -function getAuth (conf) { +const getAuth = conf => { const creds = npm.config.getCredentialsByURI(conf.registry) - let auth + const auth = {} + if (creds.token) - auth = { token: creds.token } + auth.token = creds.token else if (creds.username) - auth = { basic: { username: creds.username, password: creds.password } } + auth.basic = { username: creds.username, password: creds.password } else if (creds.auth) { const basic = Buffer.from(creds.auth, 'base64').toString().split(':', 2) - auth = { basic: { username: basic[0], password: basic[1] } } - } else - auth = {} + auth.basic = { username: basic[0], password: basic[1] } + } if (conf.otp) auth.otp = conf.otp + return auth } -function disable2fa (args) { - let conf = npm.flatOptions - return pulseTillDone.withPromise(profile.get(conf)).then((info) => { - if (!info.tfa || info.tfa.pending) { - output('Two factor authentication not enabled.') - return - } - return readUserInfo.password().then((password) => { - return Promise.resolve().then(() => { - if (conf.otp) - return - return readUserInfo.otp('Enter one-time password from your authenticator: ').then((otp) => { - conf = { ...conf, otp } - }) - }).then(() => { - log.info('profile', 'disabling tfa') - return pulseTillDone.withPromise(profile.set({ tfa: { password: password, mode: 'disable' } }, conf)).then(() => { - if (conf.json) - output(JSON.stringify({ tfa: false }, null, 2)) - else if (conf.parseable) - output('tfa\tfalse') - else - output('Two factor authentication disabled.') - }) - }) - }) - }) -} +const disable2fa = async args => { + const conf = { ...npm.flatOptions } + const info = await pulseTillDone.withPromise(npmProfile.get(conf)) + + if (!info.tfa || info.tfa.pending) { + output('Two factor authentication not enabled.') + return + } + + const password = await readUserInfo.password() -function qrcode (url) { - return new Promise((resolve) => qrcodeTerminal.generate(url, resolve)) + if (!conf.otp) { + const msg = 'Enter one-time password from your authenticator app: ' + conf.otp = await readUserInfo.otp(msg) + } + + log.info('profile', 'disabling tfa') + + await pulseTillDone.withPromise(npmProfile.set({ + tfa: { password: password, mode: 'disable' }, + }, conf)) + + if (conf.json) + output(JSON.stringify({ tfa: false }, null, 2)) + else if (conf.parseable) + output('tfa\tfalse') + else + output('Two factor authentication disabled.') } + +const qrcode = url => + new Promise((resolve) => qrcodeTerminal.generate(url, resolve)) + +module.exports = Object.assign(cmd, { usage, completion }) diff --git a/tap-snapshots/test-lib-profile.js-TAP.test.js b/tap-snapshots/test-lib-profile.js-TAP.test.js new file mode 100644 index 0000000000000..bb838ad92c97d --- /dev/null +++ b/tap-snapshots/test-lib-profile.js-TAP.test.js @@ -0,0 +1,90 @@ +/* IMPORTANT + * This snapshot file is auto-generated, but designed for humans. + * It should be checked into source control and tracked carefully. + * Re-generate by setting TAP_SNAPSHOT=1 and running tests. + * Make sure to inspect the output below. Do not ignore changes! + */ +'use strict' +exports[`test/lib/profile.js TAP enable-2fa from token and set otp, retries on pending and verifies with qrcode > should output 2fa enablement success msgs 1`] = ` +Scan into your authenticator app: +qrcode + Or enter code: +12342FA successfully enabled. Below are your recovery codes, please print these out.You will need these to recover access to your account if you lose your authentication device. 123456 789101 +` + +exports[`test/lib/profile.js TAP profile get --parseable > should output parseable result value 1`] = ` +foo +` + +exports[`test/lib/profile.js TAP profile get multiple args --parseable > should output parseable profile value results 1`] = ` +foo foo@github.com (verified) https://github.com/npm +` + +exports[`test/lib/profile.js TAP profile get multiple args comma separated > should output all keys 1`] = ` +foo foo@github.com (verified) https://github.com/npm +` + +exports[`test/lib/profile.js TAP profile get multiple args default output > should output all keys 1`] = ` +foo foo@github.com (verified) https://github.com/npm +` + +exports[`test/lib/profile.js TAP profile get no args --parseable > should output all profile info as parseable result 1`] = ` +tfa auth-and-writesname fooemail foo@github.comemail_verified truecreated 2015-02-26T01:26:37.384Zupdated 2020-08-12T16:19:35.326Zfullname Foo Barhomepage https://github.comfreenode foobartwitter https://twitter.com/npmjsgithub https://github.com/npm +` + +exports[`test/lib/profile.js TAP profile get no args default output > should output table with contents 1`] = ` +name: foo +email: foo@github.com (verified) +two-factor auth: auth-and-writes +fullname: Foo Bar +homepage: https://github.com +freenode: foobar +twitter: https://twitter.com/npmjs +github: https://github.com/npm +created: 2015-02-26T01:26:37.384Z +updated: 2020-08-12T16:19:35.326Z +` + +exports[`test/lib/profile.js TAP profile get no args no tfa enabled > should output expected profile values 1`] = ` +name: foo +email: foo@github.com (verified) +two-factor auth: disabled +fullname: Foo Bar +homepage: https://github.com +freenode: foobar +twitter: https://twitter.com/npmjs +github: https://github.com/npm +created: 2015-02-26T01:26:37.384Z +updated: 2020-08-12T16:19:35.326Z +` + +exports[`test/lib/profile.js TAP profile get no args profile has cidr_whitelist item > should output table with contents 1`] = ` +name: foo +email: foo@github.com (verified) +two-factor auth: auth-and-writes +fullname: Foo Bar +homepage: https://github.com +freenode: foobar +twitter: https://twitter.com/npmjs +github: https://github.com/npm +created: 2015-02-26T01:26:37.384Z +updated: 2020-08-12T16:19:35.326Z +cidr_whitelist: 192.168.1.1 +` + +exports[`test/lib/profile.js TAP profile get no args unverified email > should output table with contents 1`] = ` +name: foo +email: foo@github.com(unverified) +two-factor auth: auth-and-writes +fullname: Foo Bar +homepage: https://github.com +freenode: foobar +twitter: https://twitter.com/npmjs +github: https://github.com/npm +created: 2015-02-26T01:26:37.384Z +updated: 2020-08-12T16:19:35.326Z +` + +exports[`test/lib/profile.js TAP profile set writable key --parseable > should output parseable set key success msg 1`] = ` +fullname Lorem Ipsum +` diff --git a/test/lib/profile.js b/test/lib/profile.js new file mode 100644 index 0000000000000..48a558cacec64 --- /dev/null +++ b/test/lib/profile.js @@ -0,0 +1,1465 @@ +const t = require('tap') +const requireInject = require('require-inject') + +let result = '' +const flatOptions = { + otp: '', + json: false, + parseable: false, + registry: 'https://registry.npmjs.org/', +} +const npm = { config: {}, flatOptions: { ...flatOptions }} +const mocks = { + ansistyles: { bright: a => a }, + npmlog: { + gauge: { show () {} }, + info () {}, + notice () {}, + warn () {}, + }, + 'npm-profile': { + async get () {}, + async set () {}, + async createToken () {}, + }, + 'qrcode-terminal': { generate: (url, cb) => cb() }, + 'cli-table3': class extends Array { + toString () { + return this + .filter(Boolean) + .map(i => [...Object.entries(i)] + .map(i => i.join(': '))) + .join('\n') + } + }, + '../../lib/npm.js': npm, + '../../lib/utils/output.js': (...msg) => { + result += msg.join('\n') + }, + '../../lib/utils/pulse-till-done.js': { + withPromise: async a => a, + }, + '../../lib/utils/otplease.js': async (opts, fn) => fn(opts), + '../../lib/utils/usage.js': () => 'usage instructions', + '../../lib/utils/read-user-info.js': { + async password () {}, + async otp () {}, + }, +} +const userProfile = { + tfa: { pending: false, mode: 'auth-and-writes' }, + name: 'foo', + email: 'foo@github.com', + email_verified: true, + created: '2015-02-26T01:26:37.384Z', + updated: '2020-08-12T16:19:35.326Z', + cidr_whitelist: null, + fullname: 'Foo Bar', + homepage: 'https://github.com', + freenode: 'foobar', + twitter: 'https://twitter.com/npmjs', + github: 'https://github.com/npm', +} + +t.afterEach(cb => { + result = '' + npm.config = {} + npm.flatOptions = { ...flatOptions } + cb() +}) + +const profile = requireInject('../../lib/profile.js', mocks) + +t.test('no args', t => { + profile([], err => { + t.match( + err, + /usage instructions/, + 'should throw usage instructions' + ) + t.end() + }) +}) + +t.test('profile get no args', t => { + const npmProfile = { + async get () { + return userProfile + }, + } + + const profile = requireInject('../../lib/profile.js', { + ...mocks, + 'npm-profile': npmProfile, + }) + + t.test('default output', t => { + profile(['get'], err => { + if (err) + throw err + + t.matchSnapshot( + result, + 'should output table with contents' + ) + t.end() + }) + }) + + t.test('--json', t => { + npm.flatOptions.json = true + + profile(['get'], err => { + if (err) + throw err + + t.deepEqual( + JSON.parse(result), + userProfile, + 'should output json profile result' + ) + t.end() + }) + }) + + t.test('--parseable', t => { + npm.flatOptions.parseable = true + + profile(['get'], err => { + if (err) + throw err + + t.matchSnapshot( + result, + 'should output all profile info as parseable result' + ) + t.end() + }) + }) + + t.test('no tfa enabled', t => { + const npmProfile = { + async get () { + return { + ...userProfile, + tfa: null, + } + }, + } + + const profile = requireInject('../../lib/profile.js', { + ...mocks, + 'npm-profile': npmProfile, + }) + + profile(['get'], err => { + if (err) + throw err + + t.matchSnapshot( + result, + 'should output expected profile values' + ) + t.end() + }) + }) + + t.test('unverified email', t => { + const npmProfile = { + async get () { + return { + ...userProfile, + email_verified: false, + } + }, + } + + const profile = requireInject('../../lib/profile.js', { + ...mocks, + 'npm-profile': npmProfile, + }) + + profile(['get'], err => { + if (err) + throw err + + t.matchSnapshot( + result, + 'should output table with contents' + ) + t.end() + }) + }) + + t.test('profile has cidr_whitelist item', t => { + const npmProfile = { + async get () { + return { + ...userProfile, + cidr_whitelist: ['192.168.1.1'], + } + }, + } + + const profile = requireInject('../../lib/profile.js', { + ...mocks, + 'npm-profile': npmProfile, + }) + + profile(['get'], err => { + if (err) + throw err + + t.matchSnapshot( + result, + 'should output table with contents' + ) + t.end() + }) + }) + + t.end() +}) + +t.test('profile get ', t => { + const npmProfile = { + async get () { + return userProfile + }, + } + + const profile = requireInject('../../lib/profile.js', { + ...mocks, + 'npm-profile': npmProfile, + }) + + t.test('default output', t => { + profile(['get', 'name'], err => { + if (err) + throw err + + t.equal( + result, + 'foo', + 'should output value result' + ) + t.end() + }) + }) + + t.test('--json', t => { + npm.flatOptions.json = true + + profile(['get', 'name'], err => { + if (err) + throw err + + t.deepEqual( + JSON.parse(result), + userProfile, + 'should output json profile result ignoring args filter' + ) + t.end() + }) + }) + + t.test('--parseable', t => { + npm.flatOptions.parseable = true + + profile(['get', 'name'], err => { + if (err) + throw err + + t.matchSnapshot( + result, + 'should output parseable result value' + ) + t.end() + }) + }) + + t.end() +}) + +t.test('profile get multiple args', t => { + const npmProfile = { + async get () { + return userProfile + }, + } + + const profile = requireInject('../../lib/profile.js', { + ...mocks, + 'npm-profile': npmProfile, + }) + + t.test('default output', t => { + profile(['get', 'name', 'email', 'github'], err => { + if (err) + throw err + + t.matchSnapshot( + result, + 'should output all keys' + ) + t.end() + }) + }) + + t.test('--json', t => { + npm.flatOptions.json = true + + profile(['get', 'name', 'email', 'github'], err => { + if (err) + throw err + + t.deepEqual( + JSON.parse(result), + userProfile, + 'should output json profile result and ignore args' + ) + t.end() + }) + }) + + t.test('--parseable', t => { + npm.flatOptions.parseable = true + + profile(['get', 'name', 'email', 'github'], err => { + if (err) + throw err + + t.matchSnapshot( + result, + 'should output parseable profile value results' + ) + t.end() + }) + }) + + t.test('comma separated', t => { + profile(['get', 'name,email,github'], err => { + if (err) + throw err + + t.matchSnapshot( + result, + 'should output all keys' + ) + t.end() + }) + }) + + t.end() +}) + +t.test('profile set ', t => { + const npmProfile = t => ({ + async get () { + return userProfile + }, + async set (newUser, conf) { + t.match( + newUser, + { + fullname: 'Lorem Ipsum', + }, + 'should set new value to key' + ) + return { + ...userProfile, + ...newUser, + } + }, + }) + + t.test('no key', t => { + profile(['set'], err => { + t.match( + err, + /npm profile set /, + 'should throw proper usage message' + ) + t.end() + }) + }) + + t.test('no value', t => { + profile(['set', 'email'], err => { + t.match( + err, + /npm profile set /, + 'should throw proper usage message' + ) + t.end() + }) + }) + + t.test('set password', t => { + profile(['set', 'password', '1234'], err => { + t.match( + err, + /Do not include your current or new passwords on the command line./, + 'should throw an error refusing to set password from args' + ) + t.end() + }) + }) + + t.test('unwritable key', t => { + profile(['set', 'name', 'foo'], err => { + t.match( + err, + /"name" is not a property we can set./, + 'should throw the unwritable key error' + ) + t.end() + }) + }) + + t.test('writable key', t => { + t.test('default output', t => { + t.plan(2) + + const profile = requireInject('../../lib/profile.js', { + ...mocks, + 'npm-profile': npmProfile(t), + }) + + profile(['set', 'fullname', 'Lorem Ipsum'], err => { + if (err) + throw err + + t.equal( + result, + 'Set\nfullname\nto\nLorem Ipsum', + 'should output set key success msg' + ) + }) + }) + + t.test('--json', t => { + t.plan(2) + + npm.flatOptions.json = true + + const profile = requireInject('../../lib/profile.js', { + ...mocks, + 'npm-profile': npmProfile(t), + }) + + profile(['set', 'fullname', 'Lorem Ipsum'], err => { + if (err) + throw err + + t.deepEqual( + JSON.parse(result), + { + fullname: 'Lorem Ipsum', + }, + 'should output json set key success msg' + ) + }) + }) + + t.test('--parseable', t => { + t.plan(2) + + npm.flatOptions.parseable = true + + const profile = requireInject('../../lib/profile.js', { + ...mocks, + 'npm-profile': npmProfile(t), + }) + + profile(['set', 'fullname', 'Lorem Ipsum'], err => { + if (err) + throw err + + t.matchSnapshot( + result, + 'should output parseable set key success msg' + ) + }) + }) + + t.end() + }) + + t.test('write new email', t => { + t.plan(3) + + const npmProfile = { + async get () { + return userProfile + }, + async set (newUser, conf) { + t.match( + newUser, + { + email: 'foo@npmjs.com', + }, + 'should set new value to email' + ) + t.match( + conf, + npm.flatOptions, + 'should forward flatOptions config' + ) + return { + ...userProfile, + ...newUser, + } + }, + } + + const profile = requireInject('../../lib/profile.js', { + ...mocks, + 'npm-profile': npmProfile, + }) + + profile(['set', 'email', 'foo@npmjs.com'], err => { + if (err) + throw err + + t.equal( + result, + 'Set\nemail\nto\nfoo@npmjs.com', + 'should output set key success msg' + ) + }) + }) + + t.test('change password', t => { + t.plan(6) + + const npmProfile = { + async get () { + return userProfile + }, + async set (newUser, conf) { + t.match( + newUser, + { + password: { + old: 'currentpassword1234', + new: 'newpassword1234', + }, + }, + 'should set new password' + ) + t.match( + conf, + npm.flatOptions, + 'should forward flatOptions config' + ) + return { + ...userProfile, + } + }, + } + + const readUserInfo = { + async password (label) { + if (label === 'Current password: ') + t.ok('should interactively ask for password confirmation') + else if (label === 'New password: ') + t.ok('should interactively ask for new password') + else if (label === ' Again: ') + t.ok('should interactively ask for new password confirmation') + else + throw new Error('Unexpected label: ' + label) + + return label === 'Current password: ' + ? 'currentpassword1234' + : 'newpassword1234' + }, + } + + const profile = requireInject('../../lib/profile.js', { + ...mocks, + 'npm-profile': npmProfile, + '../../lib/utils/read-user-info.js': readUserInfo, + }) + + profile(['set', 'password'], err => { + if (err) + throw err + + t.equal( + result, + 'Set\npassword', + 'should output set password success msg' + ) + t.end() + }) + }) + + t.test('password confirmation mismatch', t => { + t.plan(3) + let passwordPromptCount = 0 + + const npmProfile = { + async get () { + return userProfile + }, + async set (newUser, conf) { + return { + ...userProfile, + } + }, + } + + const readUserInfo = { + async password (label) { + passwordPromptCount++ + + switch (label) { + case 'Current password: ': + return 'currentpassword1234' + case 'New password: ': + return passwordPromptCount < 3 + ? 'password-that-will-not-be-confirmed' + : 'newpassword' + case ' Again: ': + return 'newpassword' + default: + return 'password1234' + } + }, + } + + const npmlog = { + gauge: { + show () {}, + }, + warn (title, msg) { + t.equal(title, 'profile', 'should use expected profile') + t.equal( + msg, + 'Passwords do not match, please try again.', + 'should log password mismatch message' + ) + }, + } + + const profile = requireInject('../../lib/profile.js', { + ...mocks, + npmlog, + 'npm-profile': npmProfile, + '../../lib/utils/read-user-info.js': readUserInfo, + }) + + profile(['set', 'password'], err => { + if (err) + throw err + + t.equal( + result, + 'Set\npassword', + 'should output set password success msg' + ) + t.end() + }) + }) + + t.end() +}) + +t.test('enable-2fa', t => { + t.test('invalid args', t => { + profile(['enable-2fa', 'foo', 'bar'], err => { + t.match( + err, + /npm profile enable-2fa \[auth-and-writes|auth-only\]/, + 'should throw usage error' + ) + t.end() + }) + }) + + t.test('invalid two factor auth mode', t => { + profile(['enable-2fa', 'foo'], err => { + t.match( + err, + /Invalid two-factor authentication mode "foo"/, + 'should throw invalid auth mode error' + ) + t.end() + }) + }) + + t.test('no support for --json output', t => { + npm.flatOptions.json = true + + profile(['enable-2fa', 'auth-only'], err => { + t.match( + err.message, + 'Enabling two-factor authentication is an interactive ' + + 'operation and JSON output mode is not available', + 'should throw no support msg' + ) + t.end() + }) + }) + + t.test('no support for --parseable output', t => { + npm.flatOptions.parseable = true + + profile(['enable-2fa', 'auth-only'], err => { + t.match( + err.message, + 'Enabling two-factor authentication is an interactive ' + + 'operation and parseable output mode is not available', + 'should throw no support msg' + ) + t.end() + }) + }) + + t.test('no bearer tokens returned by registry', t => { + t.plan(3) + + // mock legacy basic auth style + npm.config.getCredentialsByURI = reg => { + t.equal(reg, flatOptions.registry, 'should use expected registry') + return { auth: Buffer.from('foo:bar').toString('base64') } + } + + const npmProfile = { + async createToken (pass) { + t.match(pass, 'bar', 'should use password for basic auth') + return {} + }, + } + + const profile = requireInject('../../lib/profile.js', { + ...mocks, + 'npm-profile': npmProfile, + }) + + profile(['enable-2fa', 'auth-only'], err => { + t.match( + err.message, + 'Your registry https://registry.npmjs.org/ does ' + + 'not seem to support bearer tokens. Bearer tokens ' + + 'are required for two-factor authentication', + 'should throw no support msg' + ) + }) + }) + + t.test('from basic username/password auth', t => { + // mock legacy basic auth style with user/pass + npm.config.getCredentialsByURI = () => { + return { username: 'foo', password: 'bar' } + } + + const npmProfile = { + async createToken (pass) { + return {} + }, + } + + const profile = requireInject('../../lib/profile.js', { + ...mocks, + 'npm-profile': npmProfile, + }) + + profile(['enable-2fa', 'auth-only'], err => { + t.match( + err.message, + 'Your registry https://registry.npmjs.org/ does ' + + 'not seem to support bearer tokens. Bearer tokens ' + + 'are required for two-factor authentication', + 'should throw no support msg' + ) + t.end() + }) + }) + + t.test('no auth found', t => { + npm.config.getCredentialsByURI = () => ({}) + + const profile = requireInject('../../lib/profile.js', { + ...mocks, + }) + + profile(['enable-2fa', 'auth-only'], err => { + t.match( + err.message, + 'You need to be logged in to registry ' + + 'https://registry.npmjs.org/ in order to enable 2fa' + ) + t.end() + }) + }) + + t.test('from basic auth, asks for otp', t => { + t.plan(10) + + // mock legacy basic auth style + npm.config = { + getCredentialsByURI (reg) { + t.equal(reg, flatOptions.registry, 'should use expected registry') + return { auth: Buffer.from('foo:bar').toString('base64') } + }, + setCredentialsByURI (registry, { token }) { + t.equal(registry, flatOptions.registry, 'should set expected registry') + t.equal(token, 'token', 'should set expected token') + }, + save (type) { + t.equal(type, 'user', 'should save to user config') + }, + } + + const npmProfile = { + async createToken (pass) { + t.match(pass, 'bar', 'should use password for basic auth') + return { token: 'token' } + }, + async get () { + return userProfile + }, + async set (newProfile, conf) { + t.match( + newProfile, + { + tfa: { + mode: 'auth-only', + }, + }, + 'should set tfa mode' + ) + t.match( + conf, + { + ...npm.flatOptions, + otp: '123456', + }, + 'should forward flatOptions config' + ) + return { + ...userProfile, + tfa: null, + } + }, + } + + const readUserInfo = { + async password () { + t.ok('should interactively ask for password confirmation') + return 'password1234' + }, + async otp (label) { + t.equal( + label, + 'Enter one-time password from your authenticator app: ', + 'should ask for otp confirmation' + ) + return '123456' + }, + } + + const profile = requireInject('../../lib/profile.js', { + ...mocks, + 'npm-profile': npmProfile, + '../../lib/utils/read-user-info.js': readUserInfo, + }) + + profile(['enable-2fa', 'auth-only'], err => { + if (err) + throw err + + t.equal( + result, + 'Two factor authentication mode changed to: auth-only', + 'should output success msg' + ) + }) + }) + + t.test('from token and set otp, retries on pending and verifies with qrcode', t => { + t.plan(4) + + npm.flatOptions.otp = '1234' + + npm.config = { + getCredentialsByURI () { + return { token: 'token' } + }, + } + + let setCount = 0 + const npmProfile = { + async get () { + return { + ...userProfile, + tfa: { + pending: true, + }, + } + }, + async set (newProfile, conf) { + setCount++ + + // when profile response shows that 2fa is pending the + // first time calling npm-profile.set should reset 2fa + if (setCount === 1) { + t.match( + newProfile, + { + tfa: { + password: 'password1234', + mode: 'disable', + }, + }, + 'should reset 2fa' + ) + } else if (setCount === 2) { + t.match( + newProfile, + { + tfa: { + mode: 'auth-only', + }, + }, + 'should set tfa mode approprietly in follow-up call' + ) + } else if (setCount === 3) { + t.match( + newProfile, + { + tfa: ['123456'], + }, + 'should set tfa as otp code?' + ) + return { + ...userProfile, + tfa: [ + '123456', + '789101', + ], + } + } + + return { + ...userProfile, + tfa: 'otpauth://foo?secret=1234', + } + }, + } + + const readUserInfo = { + async password () { + return 'password1234' + }, + async otp (label) { + return '123456' + }, + } + + const qrcode = { + // eslint-disable-next-line standard/no-callback-literal + generate: (url, cb) => cb('qrcode'), + } + + const profile = requireInject('../../lib/profile.js', { + ...mocks, + 'npm-profile': npmProfile, + 'qrcode-terminal': qrcode, + '../../lib/utils/read-user-info.js': readUserInfo, + }) + + profile(['enable-2fa', 'auth-only'], err => { + if (err) + throw err + + t.matchSnapshot( + result, + 'should output 2fa enablement success msgs' + ) + }) + }) + + t.test('from token and set otp, retrieves invalid otp', t => { + npm.flatOptions.otp = '1234' + + npm.config = { + getCredentialsByURI () { + return { token: 'token' } + }, + } + + const npmProfile = { + async get () { + return { + ...userProfile, + tfa: { + pending: true, + }, + } + }, + async set (newProfile, conf) { + return { + ...userProfile, + tfa: 'http://foo?secret=1234', + } + }, + } + + const readUserInfo = { + async password () { + return 'password1234' + }, + async otp (label) { + return '123456' + }, + } + + const profile = requireInject('../../lib/profile.js', { + ...mocks, + 'npm-profile': npmProfile, + '../../lib/utils/read-user-info.js': readUserInfo, + }) + + profile(['enable-2fa', 'auth-only'], err => { + t.match( + err, + /Unknown error enabling two-factor authentication./, + 'should throw invalid 2fa auth url error' + ) + t.end() + }) + }) + + t.test('from token auth provides --otp config arg', t => { + npm.flatOptions.otp = '123456' + + npm.config = { + getCredentialsByURI (reg) { + return { token: 'token' } + }, + } + + const npmProfile = { + async get () { + return userProfile + }, + async set (newProfile, conf) { + return { + ...userProfile, + tfa: null, + } + }, + } + + const readUserInfo = { + async password () { + return 'password1234' + }, + async otp () { + throw new Error('should not ask for otp') + }, + } + + const profile = requireInject('../../lib/profile.js', { + ...mocks, + 'npm-profile': npmProfile, + '../../lib/utils/read-user-info.js': readUserInfo, + }) + + profile(['enable-2fa', 'auth-and-writes'], err => { + if (err) + throw err + + t.equal( + result, + 'Two factor authentication mode changed to: auth-and-writes', + 'should output success msg' + ) + t.end() + }) + }) + + t.test('missing tfa from user profile', t => { + npm.config = { + getCredentialsByURI (reg) { + return { token: 'token' } + }, + } + + const npmProfile = { + async get () { + return { + ...userProfile, + tfa: undefined, + } + }, + async set (newProfile, conf) { + return { + ...userProfile, + tfa: null, + } + }, + } + + const readUserInfo = { + async password () { + return 'password1234' + }, + async otp () { + return '123456' + }, + } + + const profile = requireInject('../../lib/profile.js', { + ...mocks, + 'npm-profile': npmProfile, + '../../lib/utils/read-user-info.js': readUserInfo, + }) + + profile(['enable-2fa', 'auth-only'], err => { + if (err) + throw err + + t.equal( + result, + 'Two factor authentication mode changed to: auth-only', + 'should output success msg' + ) + t.end() + }) + }) + + t.test('defaults to auth-and-writes permission if no mode specified', t => { + npm.config = { + getCredentialsByURI (reg) { + return { token: 'token' } + }, + } + + const npmProfile = { + async get () { + return { + ...userProfile, + tfa: undefined, + } + }, + async set (newProfile, conf) { + return { + ...userProfile, + tfa: null, + } + }, + } + + const readUserInfo = { + async password () { + return 'password1234' + }, + async otp () { + return '123456' + }, + } + + const profile = requireInject('../../lib/profile.js', { + ...mocks, + 'npm-profile': npmProfile, + '../../lib/utils/read-user-info.js': readUserInfo, + }) + + profile(['enable-2fa'], err => { + if (err) + throw err + + t.equal( + result, + 'Two factor authentication mode changed to: auth-and-writes', + 'should enable 2fa with auth-and-writes permission' + ) + t.end() + }) + }) + + t.end() +}) + +t.test('disable-2fa', t => { + t.test('no tfa enabled', t => { + const npmProfile = { + async get () { + return { + ...userProfile, + tfa: null, + } + }, + } + + const profile = requireInject('../../lib/profile.js', { + ...mocks, + 'npm-profile': npmProfile, + }) + + profile(['disable-2fa'], err => { + if (err) + throw err + + t.equal( + result, + 'Two factor authentication not enabled.', + 'should output already disalbed msg' + ) + t.end() + }) + }) + + t.test('requests otp', t => { + const npmProfile = t => ({ + async get () { + return userProfile + }, + async set (newProfile, conf) { + t.deepEqual( + newProfile, + { + tfa: { + password: 'password1234', + mode: 'disable', + }, + }, + 'should send the new info for setting in profile' + ) + t.match( + conf, + { + ...npm.flatOptions, + otp: '1234', + }, + 'should forward flatOptions config' + ) + }, + }) + + const readUserInfo = t => ({ + async password () { + t.ok('should interactively ask for password confirmation') + return 'password1234' + }, + async otp (label) { + t.equal( + label, + 'Enter one-time password from your authenticator app: ', + 'should ask for otp confirmation' + ) + return '1234' + }, + }) + + t.test('default output', t => { + const profile = requireInject('../../lib/profile.js', { + ...mocks, + 'npm-profile': npmProfile(t), + '../../lib/utils/read-user-info.js': readUserInfo(t), + }) + + profile(['disable-2fa'], err => { + if (err) + throw err + + t.equal( + result, + 'Two factor authentication disabled.', + 'should output already disabled msg' + ) + t.end() + }) + }) + + t.test('--json', t => { + npm.flatOptions.json = true + + const profile = requireInject('../../lib/profile.js', { + ...mocks, + 'npm-profile': npmProfile(t), + '../../lib/utils/read-user-info.js': readUserInfo(t), + }) + + profile(['disable-2fa'], err => { + if (err) + throw err + + t.deepEqual( + JSON.parse(result), + { tfa: false }, + 'should output json already disabled msg' + ) + t.end() + }) + }) + + t.test('--parseable', t => { + npm.flatOptions.parseable = true + + const profile = requireInject('../../lib/profile.js', { + ...mocks, + 'npm-profile': npmProfile(t), + '../../lib/utils/read-user-info.js': readUserInfo(t), + }) + + profile(['disable-2fa'], err => { + if (err) + throw err + + t.equal( + result, + 'tfa\tfalse', + 'should output parseable already disabled msg' + ) + t.end() + }) + }) + + t.end() + }) + + t.test('--otp config already set', t => { + t.plan(3) + + npm.flatOptions.otp = '123456' + + const npmProfile = { + async get () { + return userProfile + }, + async set (newProfile, conf) { + t.deepEqual( + newProfile, + { + tfa: { + password: 'password1234', + mode: 'disable', + }, + }, + 'should send the new info for setting in profile' + ) + t.match( + conf, + { + ...npm.flatOptions, + otp: '123456', + }, + 'should forward flatOptions config' + ) + }, + } + + const readUserInfo = { + async password () { + return 'password1234' + }, + async otp (label) { + throw new Error('should not ask for otp') + }, + } + + const profile = requireInject('../../lib/profile.js', { + ...mocks, + 'npm-profile': npmProfile, + '../../lib/utils/read-user-info.js': readUserInfo, + }) + + profile(['disable-2fa'], err => { + if (err) + throw err + + t.equal( + result, + 'Two factor authentication disabled.', + 'should output already disalbed msg' + ) + }) + }) + + t.end() +}) + +t.test('unknown subcommand', t => { + profile(['asfd'], err => { + t.match( + err, + /Unknown profile command: asfd/, + 'should throw unknown cmd error' + ) + t.end() + }) +}) + +t.test('completion', t => { + const { completion } = profile + + const testComp = ({ t, argv, expect, title }) => { + completion({ conf: { argv: { remain: argv } } }, (err, res) => { + if (err) + throw err + + t.strictSame(res, expect, title) + }) + } + + t.test('npm profile autocomplete', t => { + testComp({ + t, + argv: ['npm', 'profile'], + expect: ['enable-2fa', 'disable-2fa', 'get', 'set'], + title: 'should auto complete with subcommands', + }) + + t.end() + }) + + t.test('npm profile enable autocomplete', t => { + testComp({ + t, + argv: ['npm', 'profile', 'enable-2fa'], + expect: ['auth-and-writes', 'auth-only'], + title: 'should auto complete with auth types', + }) + + t.end() + }) + + t.test('npm profile no autocomplete', t => { + const noAutocompleteCmds = ['disable-2fa', 'disable-tfa', 'get', 'set'] + for (const subcmd of noAutocompleteCmds) { + testComp({ + t, + argv: ['npm', 'profile', subcmd], + expect: [], + title: `${subcmd} should have no autocomplete`, + }) + } + + t.end() + }) + + t.test('npm profile unknown subcommand autocomplete', t => { + completion({ + conf: { + argv: { + remain: ['npm', 'profile', 'asdf'], + }, + }, + }, (err, res) => { + t.match( + err, + /asdf not recognized/, + 'should throw unknown cmd error' + ) + + t.end() + }) + }) + + t.end() +})