diff --git a/package-lock.json b/package-lock.json index 6277e007..f5743d70 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,7 +18,7 @@ "@oclif/plugin-not-found": "^3.2.73", "@oclif/plugin-update": "3.2.4", "@oclif/plugin-version": "^2.2.36", - "@oclif/table": "^0.5.8", + "@oclif/table": "0.5.4", "@octokit/rest": "^22.0.0", "archiver": "^8.0.0", "box-node-sdk": "^4.11.1", @@ -2630,9 +2630,9 @@ } }, "node_modules/@oclif/table": { - "version": "0.5.9", - "resolved": "https://registry.npmjs.org/@oclif/table/-/table-0.5.9.tgz", - "integrity": "sha512-/bvSRF38rrZ8O3hsChEKb98bEpmY7GAMeMU5hRkt73RCEoAo1UuRKX4IO0zlkD6UooSs30LfEMJeSU2NZOkndA==", + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/@oclif/table/-/table-0.5.4.tgz", + "integrity": "sha512-tIW7JTfO/t19cfOZofxEi16GAV12osvuSwdDHQ6ltIYtNeDyT8vJqdqo5NmyKNNwUy6V1DoGsVhJtPTFabR4hg==", "license": "MIT", "dependencies": { "@types/react": "^18.3.12", @@ -2642,8 +2642,7 @@ "natural-orderby": "^3.0.2", "object-hash": "^3.0.0", "react": "^18.3.1", - "string-width": "^8.2.1", - "strip-ansi": "^7.2.0", + "strip-ansi": "^7.1.2", "wrap-ansi": "^9.0.2" }, "engines": { @@ -2668,22 +2667,6 @@ "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", "license": "MIT" }, - "node_modules/@oclif/table/node_modules/string-width": { - "version": "8.2.1", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.1.tgz", - "integrity": "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==", - "license": "MIT", - "dependencies": { - "get-east-asian-width": "^1.5.0", - "strip-ansi": "^7.1.2" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/@oclif/table/node_modules/wrap-ansi": { "version": "9.0.2", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", diff --git a/package.json b/package.json index d29a8a82..fafe6b9d 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,7 @@ "@oclif/plugin-not-found": "^3.2.73", "@oclif/plugin-update": "3.2.4", "@oclif/plugin-version": "^2.2.36", - "@oclif/table": "^0.5.8", + "@oclif/table": "0.5.4", "@octokit/rest": "^22.0.0", "archiver": "^8.0.0", "box-node-sdk": "^4.11.1", diff --git a/src/commands/login.js b/src/commands/login.js index 054f7492..afee7066 100644 --- a/src/commands/login.js +++ b/src/commands/login.js @@ -531,6 +531,7 @@ OAuthLoginCommand.flags = { module.exports = OAuthLoginCommand; module.exports._test = { + SUPPORTED_DEFAULT_APP_PORTS, promptForAuthMethod, promptForPlatformAppCredentials, setOAuthCallbackTimeoutMs(timeoutMs) { diff --git a/src/commands/update.js b/src/commands/update.js index 0200d119..7ed0f83d 100644 --- a/src/commands/update.js +++ b/src/commands/update.js @@ -7,10 +7,12 @@ const UpdateCommand = class GithubUpdatecommand extends UpdateCommand { async run() { - const { printTable } = await import('@oclif/table'); - const { flags } = await this.parse(UpdateCommand); + const { flags } = await this.parse(GithubUpdatecommand); const updater = new GitHubUpdater(this.config); if (flags.available) { + // @oclif/table is pinned to 0.5.4: later versions depend on string-width@8, + // which requires Node 20 and fails to load on Node 18. + const { printTable } = await import('@oclif/table'); const index = await updater.fetchVersionIndex(); const allVersions = sort(Object.keys(index)).reverse(); const localVersions = await updater.findLocalVersions(); @@ -30,7 +32,7 @@ class GithubUpdatecommand extends UpdateCommand { return; } - updater.runUpdate({ + await updater.runUpdate({ autoUpdate: flags.autoupdate, force: flags.force, version: flags.interactive diff --git a/test/commands/configure-environments.test.js b/test/commands/configure-environments.test.js index 0e74191a..a33c1acd 100644 --- a/test/commands/configure-environments.test.js +++ b/test/commands/configure-environments.test.js @@ -30,7 +30,7 @@ describe('Configure environments masking', function () { sandbox.restore(); }); - it('configure:environments:get should not print raw client secrets', function () { + test.do(() => { getEnvironmentsStub.resolves({ default: 'dev', environments: { @@ -40,18 +40,18 @@ describe('Configure environments masking', function () { }, }, }); - - return test - .stub(BoxCommand.prototype, 'getEnvironments', getEnvironmentsStub) - .stdout() - .command(['configure:environments:get']) - .it('masks clientSecret in output', (ctx) => { + }) + .stdout() + .command(['configure:environments:get']) + .it( + 'configure:environments:get should not print raw client secrets', + (ctx) => { assert.notInclude(ctx.stdout, RAW_SECRET); assert.include(ctx.stdout, MASKED_SECRET); - }); - }); + } + ); - it('configure:environments:update should not print raw client secrets', function () { + test.do(() => { getEnvironmentsStub.resolves({ default: 'dev', environments: { @@ -61,19 +61,14 @@ describe('Configure environments masking', function () { }, }, }); - - return test - .stub(BoxCommand.prototype, 'getEnvironments', getEnvironmentsStub) - .stub( - BoxCommand.prototype, - 'updateEnvironments', - updateEnvironmentsStub - ) - .stdout() - .command(['configure:environments:update']) - .it('masks clientSecret in output', (ctx) => { + }) + .stdout() + .command(['configure:environments:update']) + .it( + 'configure:environments:update should not print raw client secrets', + (ctx) => { assert.notInclude(ctx.stdout, RAW_SECRET); assert.include(ctx.stdout, MASKED_SECRET); - }); - }); + } + ); }); diff --git a/test/commands/hubs.test.js b/test/commands/hubs.test.js index f0ad7c6c..6c200881 100644 --- a/test/commands/hubs.test.js +++ b/test/commands/hubs.test.js @@ -421,22 +421,23 @@ describe('Hubs', function () { assert.deepEqual(JSON.parse(context.stdout), response); }); - it('rejects unsupported hub collaboration roles on create', async function () { - return test - .command([ - 'hubs:collaborations:create', - '12345', - '--role=owner', - '--user-id=22222', - '--token=test', - ]) - .catch((error) => { + test.stderr() + .command([ + 'hubs:collaborations:create', + '12345', + '--role=owner', + '--user-id=22222', + '--token=test', + ]) + .it( + 'rejects unsupported hub collaboration roles on create', + (context) => { assert.include( - error.message, + context.stderr, 'Expected --role=owner to be one of: editor, viewer, co-owner' ); - }); - }); + } + ); }); describe('hubs:collaborations:get', function () { @@ -483,21 +484,22 @@ describe('Hubs', function () { assert.deepEqual(JSON.parse(context.stdout), response); }); - it('rejects unsupported hub collaboration roles on update', async function () { - return test - .command([ - 'hubs:collaborations:update', - '99999', - '--role=owner', - '--token=test', - ]) - .catch((error) => { + test.stderr() + .command([ + 'hubs:collaborations:update', + '99999', + '--role=owner', + '--token=test', + ]) + .it( + 'rejects unsupported hub collaboration roles on update', + (context) => { assert.include( - error.message, + context.stderr, 'Expected --role=owner to be one of: editor, viewer, co-owner' ); - }); - }); + } + ); }); describe('hubs:collaborations:delete', function () { diff --git a/test/commands/login.test.js b/test/commands/login.test.js index 1d10ff8c..c90eb7c6 100644 --- a/test/commands/login.test.js +++ b/test/commands/login.test.js @@ -50,6 +50,23 @@ async function waitForLoopbackPort(port, maxAttempts = 20, delayMs = 5) { ); } +async function findFreePort(ports) { + for (const port of ports) { + const isFree = await new Promise((resolve) => { + const server = nodeHttp.createServer(); + server.once('error', () => resolve(false)); + server.listen(port, 'localhost', () => + server.close(() => resolve(true)) + ); + }); + if (isFree) { + return port; + } + } + + throw new Error(`None of the ports ${ports.join(', ')} is free`); +} + describe('Login', function () { let sandbox; const DEFAULT_CLIENT_ID = 'udz8zp4yue87uk9dzq4xk425kkwvqvh1'; @@ -436,56 +453,42 @@ describe('Login', function () { ); }); - it('should show error when specified env does not exist and no current env', async function () { + test.do(() => { getEnvironmentsStub.resolves({ default: '', environments: {} }); + }) + .stderr() + .command(['login', '--reauthorize', '-n', 'nonexistent']) + .it( + 'should show error when specified env does not exist and no current env', + (ctx) => { + assert.include( + ctx.stderr, + 'The "nonexistent" environment does not exist' + ); + } + ); - await test - .stub( - BoxCommand.prototype, - 'getEnvironments', - getEnvironmentsStub - ) - .stdout() - .command(['login', '--reauthorize', '-n', 'nonexistent']) - .it( - 'shows error when env does not exist and no current env', - (ctx) => { - assert.include( - ctx.stdout, - 'The "nonexistent" environment does not exist' - ); - } - ); - }); - - it('should show error when specified env does not exist and current env is not OAuth', async function () { + test.do(() => { getEnvironmentsStub.resolves({ default: 'jwt-env', environments: { 'jwt-env': { authMethod: 'jwt', clientId: 'cid' }, }, }); + }) + .stderr() + .command(['login', '--reauthorize', '-n', 'nonexistent']) + .it( + 'should show error when specified env does not exist and current env is not OAuth', + (ctx) => { + assert.include( + ctx.stderr, + 'The "nonexistent" environment does not exist' + ); + } + ); - await test - .stub( - BoxCommand.prototype, - 'getEnvironments', - getEnvironmentsStub - ) - .stdout() - .command(['login', '--reauthorize', '-n', 'nonexistent']) - .it( - 'shows error when env does not exist and current is not oauth', - (ctx) => { - assert.include( - ctx.stdout, - 'The "nonexistent" environment does not exist' - ); - } - ); - }); - - it('should show error when specified env does not exist and name is not oauth (even if current is OAuth)', async function () { + test.do(() => { getEnvironmentsStub.resolves({ default: 'my-oauth', environments: { @@ -496,25 +499,37 @@ describe('Login', function () { }, }, }); - - await test - .stub( - BoxCommand.prototype, - 'getEnvironments', - getEnvironmentsStub - ) - .stdout() - .command(['login', '--reauthorize', '-n', 'nonexistent']) - .it('does not fallback when name is not oauth', (ctx) => { + }) + .stderr() + .command(['login', '--reauthorize', '-n', 'nonexistent']) + .it( + 'should show error when specified env does not exist and name is not oauth (even if current is OAuth)', + (ctx) => { assert.include( - ctx.stdout, + ctx.stderr, 'The "nonexistent" environment does not exist' ); - }); - }); + } + ); - it('should use current OAuth env when name is oauth and that env does not exist', async function () { - const promptStub = sandbox.stub(inquirer, 'prompt').resolves({ + let promptStub; + const reauthorizeWithCodeArgs = [ + 'login', + '--reauthorize', + '-n', + 'oauth', + '--default-box-app', + '--code', + '--port', + ]; + test.do(async () => { + // .command() reads its args when the test runs, so the port can be appended here. + reauthorizeWithCodeArgs[7] = String( + await findFreePort( + OAuthLoginCommand._test.SUPPORTED_DEFAULT_APP_PORTS + ) + ); + promptStub = sandbox.stub(inquirer, 'prompt').resolves({ code: 'test-code', state: 'test-state', }); @@ -528,36 +543,20 @@ describe('Login', function () { }, }, }); - - await test - .stub( - BoxCommand.prototype, - 'getEnvironments', - getEnvironmentsStub - ) - .stdout() - .command([ - 'login', - '--reauthorize', - '-n', - 'oauth', - '--default-box-app', - '--code', - '--port', - '16987', - ]) - .it( - 'falls back to current oauth env when name is oauth and env does not exist', - (ctx) => { - assert.notInclude( - ctx.stdout, - 'The "oauth" environment does not exist' - ); - assert.include(ctx.stdout, 'Please open'); - assert.isTrue(promptStub.calledOnce); - } - ); - }); + }) + .stderr() + .command(reauthorizeWithCodeArgs) + .it( + 'should use current OAuth env when name is oauth and that env does not exist', + (ctx) => { + assert.notInclude( + ctx.stderr, + 'The "oauth" environment does not exist' + ); + assert.include(ctx.stderr, 'Please open'); + assert.isTrue(promptStub.calledOnce); + } + ); }); describe('oauth callback command behavior', function () { diff --git a/test/commands/logout.test.js b/test/commands/logout.test.js index 2b2c786f..a6e4a05f 100644 --- a/test/commands/logout.test.js +++ b/test/commands/logout.test.js @@ -37,6 +37,8 @@ describe('Logout', function () { let tokenCacheGetStub; let tokenCacheClearStub; let revokeTokensStub; + let confirmStub; + let inquirerPromptStub; beforeEach(function () { sandbox = sinon.createSandbox(); @@ -83,97 +85,69 @@ describe('Logout', function () { ); }); - it('should show error when no current environment', async function () { + test.do(() => { getEnvironmentsStub.resolves({ default: null, environments: {} }); + }) + .stderr() + .command(['logout', '--force']) + .it('should show error when no current environment', (ctx) => { + assert.include(ctx.stderr, 'No current environment found'); + }); - await test - .stub( - BoxCommand.prototype, - 'getEnvironments', - getEnvironmentsStub - ) - .command(['logout', '--force']) - .catch((error) => { - assert.include( - error.message, - 'No current environment found' - ); - }) - .it('errors when no current environment'); - }); - - it('should show already logged out when no access token', async function () { + test.do(() => { tokenCacheGetStub.resolves(); - - await test - .stub( - BoxCommand.prototype, - 'getEnvironments', - getEnvironmentsStub - ) - .stub(CLITokenCache.prototype, 'get', tokenCacheGetStub) - .stdout() - .command(['logout', '--force']) - .it('shows already logged out', (ctx) => { + }) + .stderr() + .command(['logout', '--force']) + .it( + 'should show already logged out when no access token', + (ctx) => { assert.include( - ctx.stdout, + ctx.stderr, 'You are already logged out from "oauth" environment' ); - }); - }); + } + ); - it('should cancel when user declines confirmation (no --force)', async function () { + test.do(() => { tokenCacheGetStub.resolves({ accessToken: 'test-token', refreshToken: 'refresh', }); - const confirmStub = sandbox + confirmStub = sandbox .stub(BoxCommand.prototype, 'confirm') .resolves(false); - - await test - .stub( - BoxCommand.prototype, - 'getEnvironments', - getEnvironmentsStub - ) - .stub(BoxCommand.prototype, 'confirm', confirmStub) - .stub(CLITokenCache.prototype, 'get', tokenCacheGetStub) - .stdout() - .command(['logout']) - .it('cancels when user declines', (ctx) => { - assert.include(ctx.stdout, 'Logout cancelled'); + }) + .stderr() + .command(['logout']) + .it( + 'should cancel when user declines confirmation (no --force)', + (ctx) => { + assert.include(ctx.stderr, 'Logout cancelled'); assert.isTrue( confirmStub.calledWith( sinon.match(/logout from "oauth"/) ) ); - }); - }); + } + ); - it('should successfully logout when user confirms (no --force)', async function () { + test.do(() => { tokenCacheGetStub.resolves({ accessToken: 'test-token', refreshToken: 'refresh', }); tokenCacheClearStub.callsFake((cb) => cb(null)); - const confirmStub = sandbox + confirmStub = sandbox .stub(BoxCommand.prototype, 'confirm') .resolves(true); revokeTokensStub.resolves({ statusCode: 200 }); - - await test - .stub( - BoxCommand.prototype, - 'getEnvironments', - getEnvironmentsStub - ) - .stub(BoxCommand.prototype, 'confirm', confirmStub) - .stub(CLITokenCache.prototype, 'get', tokenCacheGetStub) - .stub(CLITokenCache.prototype, 'clear', tokenCacheClearStub) - .stdout() - .command(['logout']) - .it('logs out when user confirms', (ctx) => { + }) + .stderr() + .command(['logout']) + .it( + 'should successfully logout when user confirms (no --force)', + (ctx) => { assert.isTrue( confirmStub.calledWith( sinon.match(/logout from "oauth"/) @@ -182,317 +156,222 @@ describe('Logout', function () { assert.isTrue(revokeTokensStub.calledWith('test-token')); assert.isTrue(tokenCacheClearStub.called); assert.include( - ctx.stdout, + ctx.stderr, 'Successfully logged out from "oauth"' ); - }); - }); + } + ); - it('should skip revoke and clear cache when revoke returns 400 invalid_token', async function () { + test.do(() => { tokenCacheGetStub.resolves({ accessToken: 'test-token', refreshToken: 'refresh', }); tokenCacheClearStub.callsFake((cb) => cb(null)); - revokeTokensStub.resolves({ statusCode: 400, body: { error: 'invalid_token' }, }); + }) + .stderr() + .command(['logout', '--force']) + .it( + 'should skip revoke and clear cache when revoke returns 400 invalid_token', + (ctx) => { + assert.isTrue(revokeTokensStub.calledWith('test-token')); + assert.isTrue(tokenCacheClearStub.called); + assert.include( + ctx.stderr, + 'Access token is already invalid' + ); + assert.include(ctx.stderr, 'Successfully logged out'); + } + ); - await test - .stub( - BoxCommand.prototype, - 'getEnvironments', - getEnvironmentsStub - ) - .stub(CLITokenCache.prototype, 'get', tokenCacheGetStub) - .stub(CLITokenCache.prototype, 'clear', tokenCacheClearStub) - .stdout() - .command(['logout', '--force']) - .it( - 'skips revoke and clears cache for invalid_token', - (ctx) => { - assert.isTrue( - revokeTokensStub.calledWith('test-token') - ); - assert.isTrue(tokenCacheClearStub.called); - assert.include( - ctx.stdout, - 'Access token is already invalid' - ); - assert.include(ctx.stdout, 'Successfully logged out'); - } - ); - }); - - it('should clear cache when revoke returns other 400 and user selects clear', async function () { + test.do(() => { tokenCacheGetStub.resolves({ accessToken: 'test-token', refreshToken: 'refresh', }); tokenCacheClearStub.callsFake((cb) => cb(null)); - revokeTokensStub.resolves({ statusCode: 400, body: { error: 'invalid_request' }, }); - - const inquirerPromptStub = sandbox + inquirerPromptStub = sandbox .stub(inquirer, 'prompt') - .resolves({ - action: 'clear', - }); - - await test - .stub( - BoxCommand.prototype, - 'getEnvironments', - getEnvironmentsStub - ) - .stub(CLITokenCache.prototype, 'get', tokenCacheGetStub) - .stub(CLITokenCache.prototype, 'clear', tokenCacheClearStub) - .stub(inquirer, 'prompt', inquirerPromptStub) - .stdout() - .command(['logout', '--force']) - .it('clears cache when user selects clear', (ctx) => { + .resolves({ action: 'clear' }); + }) + .stderr() + .command(['logout', '--force']) + .it( + 'should clear cache when revoke returns other 400 and user selects clear', + (ctx) => { assert.isTrue(revokeTokensStub.calledWith('test-token')); + assert.isTrue(inquirerPromptStub.calledOnce); assert.isTrue(tokenCacheClearStub.called); - assert.include(ctx.stdout, 'Successfully logged out'); - }); - }); + assert.include(ctx.stderr, 'Successfully logged out'); + } + ); - it('should successfully logout and clear cache when revoke succeeds', async function () { + test.do(() => { tokenCacheGetStub.resolves({ accessToken: 'test-token', refreshToken: 'refresh', }); tokenCacheClearStub.callsFake((cb) => cb(null)); - revokeTokensStub.resolves({ statusCode: 200 }); - - await test - .stub( - BoxCommand.prototype, - 'getEnvironments', - getEnvironmentsStub - ) - .stub(CLITokenCache.prototype, 'get', tokenCacheGetStub) - .stub(CLITokenCache.prototype, 'clear', tokenCacheClearStub) - .stdout() - .command(['logout', '--force']) - .it('successfully logs out when revoke succeeds', (ctx) => { + }) + .stderr() + .command(['logout', '--force']) + .it( + 'should successfully logout and clear cache when revoke succeeds', + (ctx) => { assert.isTrue(revokeTokensStub.calledWith('test-token')); assert.isTrue(tokenCacheClearStub.called); assert.include( - ctx.stdout, + ctx.stderr, 'Successfully logged out from "oauth"' ); - }); - }); + } + ); - it('should clear cache when revoke fails and user selects clear', async function () { + test.do(() => { tokenCacheGetStub.resolves({ accessToken: 'test-token', refreshToken: 'refresh', }); tokenCacheClearStub.callsFake((cb) => cb(null)); - revokeTokensStub.rejects(new Error('ECONNREFUSED')); - - const inquirerPromptStub = sandbox + inquirerPromptStub = sandbox .stub(inquirer, 'prompt') - .resolves({ - action: 'clear', - }); - - await test - .stub( - BoxCommand.prototype, - 'getEnvironments', - getEnvironmentsStub - ) - .stub(CLITokenCache.prototype, 'get', tokenCacheGetStub) - .stub(CLITokenCache.prototype, 'clear', tokenCacheClearStub) - .stub(inquirer, 'prompt', inquirerPromptStub) - .stdout() - .command(['logout', '--force']) - .it( - 'clears cache when revoke fails and user selects clear', - (ctx) => { - assert.isTrue(tokenCacheClearStub.called); - assert.include(ctx.stdout, 'Successfully logged out'); - } - ); - }); + .resolves({ action: 'clear' }); + }) + .stderr() + .command(['logout', '--force']) + .it( + 'should clear cache when revoke fails and user selects clear', + (ctx) => { + assert.isTrue(inquirerPromptStub.calledOnce); + assert.isTrue(tokenCacheClearStub.called); + assert.include(ctx.stderr, 'Successfully logged out'); + } + ); - it('should show abort message when revoke fails and user selects abort', async function () { + test.do(() => { tokenCacheGetStub.resolves({ accessToken: 'test-token', refreshToken: 'refresh', }); - revokeTokensStub.rejects(new Error('ECONNREFUSED')); - - const inquirerPromptStub = sandbox + inquirerPromptStub = sandbox .stub(inquirer, 'prompt') - .resolves({ - action: 'abort', - }); - - await test - .stub( - BoxCommand.prototype, - 'getEnvironments', - getEnvironmentsStub - ) - .stub(CLITokenCache.prototype, 'get', tokenCacheGetStub) - .stub(CLITokenCache.prototype, 'clear', tokenCacheClearStub) - .stub(inquirer, 'prompt', inquirerPromptStub) - .stdout() - .command(['logout', '--force']) - .it('shows abort message when user selects abort', (ctx) => { + .resolves({ action: 'abort' }); + }) + .stderr() + .command(['logout', '--force']) + .it( + 'should show abort message when revoke fails and user selects abort', + (ctx) => { assert.isFalse(tokenCacheClearStub.called); - assert.include(ctx.stdout, 'Logout aborted'); - assert.include(ctx.stdout, 'not revoked'); - assert.include(ctx.stdout, 'remains cached'); - }); - }); + assert.include(ctx.stderr, 'Logout aborted'); + assert.include(ctx.stderr, 'not revoked'); + assert.include(ctx.stderr, 'remains cached'); + } + ); - it('should retry revoke when revoke fails and user selects retry, then succeed', async function () { + test.do(() => { tokenCacheGetStub.resolves({ accessToken: 'test-token', refreshToken: 'refresh', }); tokenCacheClearStub.callsFake((cb) => cb(null)); - revokeTokensStub .onFirstCall() .rejects(new Error('ECONNREFUSED')) .onSecondCall() .resolves({ statusCode: 200 }); - - const inquirerPromptStub = sandbox + inquirerPromptStub = sandbox .stub(inquirer, 'prompt') - .resolves({ - action: 'retry', - }); - - await test - .stub( - BoxCommand.prototype, - 'getEnvironments', - getEnvironmentsStub - ) - .stub(CLITokenCache.prototype, 'get', tokenCacheGetStub) - .stub(CLITokenCache.prototype, 'clear', tokenCacheClearStub) - .stub(inquirer, 'prompt', inquirerPromptStub) - .stdout() - .command(['logout', '--force']) - .it('retries revoke and succeeds', (ctx) => { + .resolves({ action: 'retry' }); + }) + .stderr() + .command(['logout', '--force']) + .it( + 'should retry revoke when revoke fails and user selects retry, then succeed', + (ctx) => { assert.strictEqual(revokeTokensStub.callCount, 2); assert.isTrue(tokenCacheClearStub.called); assert.include( - ctx.stdout, + ctx.stderr, 'Successfully logged out from "oauth"' ); - }); - }); + } + ); - it('should use --on-revoke-failure=clear when revoke fails (skips prompt)', async function () { + test.do(() => { tokenCacheGetStub.resolves({ accessToken: 'test-token', refreshToken: 'refresh', }); tokenCacheClearStub.callsFake((cb) => cb(null)); - revokeTokensStub.rejects(new Error('ECONNREFUSED')); - - const inquirerPromptStub = sandbox.stub(inquirer, 'prompt'); - - await test - .stub( - BoxCommand.prototype, - 'getEnvironments', - getEnvironmentsStub - ) - .stub(CLITokenCache.prototype, 'get', tokenCacheGetStub) - .stub(CLITokenCache.prototype, 'clear', tokenCacheClearStub) - .stub(inquirer, 'prompt', inquirerPromptStub) - .stdout() - .command(['logout', '--force', '--on-revoke-failure=clear']) - .it('clears cache using flag (no prompt)', (ctx) => { + inquirerPromptStub = sandbox.stub(inquirer, 'prompt'); + }) + .stderr() + .command(['logout', '--force', '--on-revoke-failure=clear']) + .it( + 'should use --on-revoke-failure=clear when revoke fails (skips prompt)', + (ctx) => { assert.isFalse(inquirerPromptStub.called); assert.isTrue(tokenCacheClearStub.called); - assert.include(ctx.stdout, 'Successfully logged out'); - }); - }); + assert.include(ctx.stderr, 'Successfully logged out'); + } + ); - it('should use --on-revoke-failure=abort when revoke fails (skips prompt)', async function () { + test.do(() => { tokenCacheGetStub.resolves({ accessToken: 'test-token', refreshToken: 'refresh', }); - revokeTokensStub.rejects(new Error('ECONNREFUSED')); - - const inquirerPromptStub = sandbox.stub(inquirer, 'prompt'); - - await test - .stub( - BoxCommand.prototype, - 'getEnvironments', - getEnvironmentsStub - ) - .stub(CLITokenCache.prototype, 'get', tokenCacheGetStub) - .stub(CLITokenCache.prototype, 'clear', tokenCacheClearStub) - .stub(inquirer, 'prompt', inquirerPromptStub) - .stdout() - .command(['logout', '--force', '--on-revoke-failure=abort']) - .it('aborts using flag (no prompt)', (ctx) => { + inquirerPromptStub = sandbox.stub(inquirer, 'prompt'); + }) + .stderr() + .command(['logout', '--force', '--on-revoke-failure=abort']) + .it( + 'should use --on-revoke-failure=abort when revoke fails (skips prompt)', + (ctx) => { assert.isFalse(inquirerPromptStub.called); assert.isFalse(tokenCacheClearStub.called); - assert.include(ctx.stdout, 'Logout aborted'); - assert.include(ctx.stdout, 'remains cached'); - }); - }); + assert.include(ctx.stderr, 'Logout aborted'); + assert.include(ctx.stderr, 'remains cached'); + } + ); - it('should clear cache when credentials invalid and user selects clear', async function () { + test.do(() => { getEnvironmentsStub.resolves(OAUTH_ENV_NO_CREDS); tokenCacheGetStub.resolves({ accessToken: 'test-token', refreshToken: 'refresh', }); tokenCacheClearStub.callsFake((cb) => cb(null)); - - const inquirerPromptStub = sandbox + inquirerPromptStub = sandbox .stub(inquirer, 'prompt') - .resolves({ - action: 'clear', - }); - - await test - .stub( - BoxCommand.prototype, - 'getEnvironments', - getEnvironmentsStub - ) - .stub(CLITokenCache.prototype, 'get', tokenCacheGetStub) - .stub(CLITokenCache.prototype, 'clear', tokenCacheClearStub) - .stub(inquirer, 'prompt', inquirerPromptStub) - .stdout() - .command(['logout', '--force']) - .it( - 'clears cache when credentials invalid and user selects clear', - (ctx) => { - assert.isFalse(revokeTokensStub.called); - assert.isTrue(tokenCacheClearStub.called); - assert.include(ctx.stdout, 'Successfully logged out'); - } - ); - }); + .resolves({ action: 'clear' }); + }) + .stderr() + .command(['logout', '--force']) + .it( + 'should clear cache when credentials invalid and user selects clear', + (ctx) => { + assert.isFalse(revokeTokensStub.called); + assert.isTrue(tokenCacheClearStub.called); + assert.include(ctx.stderr, 'Successfully logged out'); + } + ); - it('should reject when tokenCache.clear fails', async function () { + test.do(() => { tokenCacheGetStub.resolves({ accessToken: 'test-token', refreshToken: 'refresh', @@ -500,22 +379,13 @@ describe('Logout', function () { tokenCacheClearStub.callsFake((cb) => cb(new Error('Clear failed')) ); - revokeTokensStub.resolves({ statusCode: 200 }); - - await test - .stub( - BoxCommand.prototype, - 'getEnvironments', - getEnvironmentsStub - ) - .stub(CLITokenCache.prototype, 'get', tokenCacheGetStub) - .stub(CLITokenCache.prototype, 'clear', tokenCacheClearStub) - .command(['logout', '--force']) - .catch((error) => { - assert.include(error.message, 'Clear failed'); - }) - .it('rejects when tokenCache.clear fails'); - }); + }) + .stderr() + .command(['logout', '--force']) + .it('should reject when tokenCache.clear fails', (ctx) => { + assert.include(ctx.stderr, 'Clear failed'); + assert.notInclude(ctx.stderr, 'Successfully logged out'); + }); }); }); diff --git a/test/commands/update.test.js b/test/commands/update.test.js index f01b94ca..c3a0c028 100644 --- a/test/commands/update.test.js +++ b/test/commands/update.test.js @@ -1,5 +1,7 @@ 'use strict'; +// fancy-test's .catch() asserts on a thrown error; it is not Promise#catch +/* eslint-disable promise/catch-or-return */ const { test } = require('@oclif/test'); const assert = require('chai').assert; const sinon = require('sinon'); @@ -12,6 +14,7 @@ describe('Update', function () { let fetchVersionIndexStub; let findLocalVersionsStub; let runUpdateStub; + let promptStub; beforeEach(function () { sandbox = sinon.createSandbox(); @@ -32,128 +35,77 @@ describe('Update', function () { }); describe('update', function () { - it('should list available versions when --available flag is passed', async function () { - const mockVersionIndex = { + test.do(() => { + fetchVersionIndexStub.resolves({ '3.0.0': 'https://github.com/box/boxcli/releases/download/v3.0.0', '2.5.0': 'https://github.com/box/boxcli/releases/download/v2.5.0', '2.0.0': 'https://github.com/box/boxcli/releases/download/v2.0.0', - }; - const mockLocalVersions = ['/path/to/3.0.0']; - - fetchVersionIndexStub.resolves(mockVersionIndex); - findLocalVersionsStub.resolves(mockLocalVersions); - - await test - .stdout() - .stub( - GitHubUpdater.prototype, - 'fetchVersionIndex', - fetchVersionIndexStub - ) - .stub( - GitHubUpdater.prototype, - 'findLocalVersions', - findLocalVersionsStub - ) - .command(['update', '--available']) - .it('displays available versions in table format', (ctx) => { - // Verify the stubs were called + }); + findLocalVersionsStub.resolves(['/path/to/3.0.0']); + }) + .stdout() + .command(['update', '--available']) + .it( + 'should list available versions when --available flag is passed', + (ctx) => { assert.isTrue(fetchVersionIndexStub.called); assert.isTrue(findLocalVersionsStub.called); - // Verify output contains version numbers assert.include(ctx.stdout, '3.0.0'); assert.include(ctx.stdout, '2.5.0'); - }); - }); + } + ); - it('should call runUpdate with correct parameters when updating to specific version', async function () { + test.do(() => { runUpdateStub.resolves(); - - await test - .stub(GitHubUpdater.prototype, 'runUpdate', runUpdateStub) - .command(['update', '--version=3.0.0']) - .it('updates to specified version', () => { + }) + .command(['update', '--version=3.0.0']) + .it( + 'should call runUpdate with correct parameters when updating to specific version', + () => { assert.isTrue(runUpdateStub.called); const callArgs = runUpdateStub.getCall(0).args[0]; assert.equal(callArgs.version, '3.0.0'); - assert.isFalse(callArgs.autoUpdate); - assert.isFalse(callArgs.force); - }); - }); + assert.notOk(callArgs.autoUpdate); + assert.notOk(callArgs.force); + } + ); - it('should call runUpdate with force flag', async function () { + test.do(() => { runUpdateStub.resolves(); - - await test - .stub(GitHubUpdater.prototype, 'runUpdate', runUpdateStub) - .command(['update', '--force']) - .it('updates with force flag', () => { - assert.isTrue(runUpdateStub.called); - const callArgs = runUpdateStub.getCall(0).args[0]; - assert.isTrue(callArgs.force); - }); - }); - - it('should call runUpdate with autoupdate flag', async function () { + }) + .command(['update', '--force']) + .it('should call runUpdate with force flag', () => { + assert.isTrue(runUpdateStub.called); + const callArgs = runUpdateStub.getCall(0).args[0]; + assert.isTrue(callArgs.force); + }); + + test.do(() => { runUpdateStub.resolves(); - - await test - .stub(GitHubUpdater.prototype, 'runUpdate', runUpdateStub) - .command(['update', '--autoupdate']) - .it('updates with autoupdate flag', () => { - assert.isTrue(runUpdateStub.called); - const callArgs = runUpdateStub.getCall(0).args[0]; - assert.isTrue(callArgs.autoUpdate); - }); - }); - - it('should update to specific channel', async function () { - runUpdateStub.resolves(); - - await test - .stub(GitHubUpdater.prototype, 'runUpdate', runUpdateStub) - .command(['update', 'stable']) - .it('updates to specified channel', () => { - assert.isTrue(runUpdateStub.called); - const callArgs = runUpdateStub.getCall(0).args[0]; - assert.equal(callArgs.channel, 'stable'); - }); - }); - - it('should error when both version and channel are specified', async function () { - await test - .command(['update', 'stable', '--version=3.0.0']) - .catch((error) => { - assert.include( - error.message, - 'You cannot specify both a version and a channel' - ); - }) - .it('throws error when both channel and version are provided'); - }); - - it('should handle interactive version selection', async function () { - const promptStub = sandbox.stub( - UpdateCommand.prototype, - 'promptForVersion' - ); - promptStub.resolves('3.0.0'); + }) + .command(['update', '--autoupdate']) + .it('should call runUpdate with autoupdate flag', () => { + assert.isTrue(runUpdateStub.called); + const callArgs = runUpdateStub.getCall(0).args[0]; + assert.isTrue(callArgs.autoUpdate); + }); + + test.do(() => { + promptStub = sandbox + .stub(UpdateCommand.prototype, 'promptForVersion') + .resolves('3.0.0'); runUpdateStub.resolves(); - - await test - .stub(UpdateCommand.prototype, 'promptForVersion', promptStub) - .stub(GitHubUpdater.prototype, 'runUpdate', runUpdateStub) - .command(['update', '--interactive']) - .it('prompts for version selection in interactive mode', () => { - assert.isTrue(promptStub.called); - assert.isTrue(runUpdateStub.called); - const callArgs = runUpdateStub.getCall(0).args[0]; - assert.equal(callArgs.version, '3.0.0'); - }); - }); + }) + .command(['update', '--interactive']) + .it('should handle interactive version selection', () => { + assert.isTrue(promptStub.called); + assert.isTrue(runUpdateStub.called); + const callArgs = runUpdateStub.getCall(0).args[0]; + assert.equal(callArgs.version, '3.0.0'); + }); }); describe('update command description', function () { @@ -166,285 +118,188 @@ describe('Update', function () { }); describe('GitHubUpdater integration', function () { - it('should instantiate GitHubUpdater with config', async function () { + test.do(() => { runUpdateStub.resolves(); - - await test - .stub(GitHubUpdater.prototype, 'runUpdate', runUpdateStub) - .command(['update']) - .it('creates GitHubUpdater instance', () => { - // Verify updater was instantiated - assert.isTrue(runUpdateStub.called); - }); - }); - - it('should handle update errors gracefully', async function () { - const updateError = new Error('Update failed'); - runUpdateStub.rejects(updateError); - - await test - .stub(GitHubUpdater.prototype, 'runUpdate', runUpdateStub) - .command(['update']) - .catch((error) => { - assert.include(error.message, 'Update failed'); - }) - .it('handles update errors'); - }); - - it('should handle version index fetch errors', async function () { - const fetchError = new Error('Failed to fetch version index'); - fetchVersionIndexStub.rejects(fetchError); - - await test - .stub( - GitHubUpdater.prototype, - 'fetchVersionIndex', - fetchVersionIndexStub - ) - .command(['update', '--available']) - .catch((error) => { - assert.include( - error.message, - 'Failed to fetch version index' - ); - }) - .it('handles version index fetch errors'); - }); + }) + .command(['update']) + .it('should instantiate GitHubUpdater with config', () => { + assert.isTrue(runUpdateStub.called); + }); + + test.do(() => { + runUpdateStub.rejects(new Error('Update failed')); + }) + .command(['update']) + .catch((error) => { + assert.include(error.message, 'Update failed'); + }) + .it('should handle update errors gracefully'); + + test.do(() => { + fetchVersionIndexStub.rejects( + new Error('Failed to fetch version index') + ); + }) + .command(['update', '--available']) + .catch((error) => { + assert.include(error.message, 'Failed to fetch version index'); + }) + .it('should handle version index fetch errors'); + + test.do(() => { + runUpdateStub.resolves(); + }) + .command(['update', 'stable']) + .catch((error) => { + assert.include(error.message, 'Unexpected argument: stable'); + }) + .it('should reject a channel argument'); }); describe('update flags combinations', function () { - it('should work with version and force flags together', async function () { + test.do(() => { runUpdateStub.resolves(); - - await test - .stub(GitHubUpdater.prototype, 'runUpdate', runUpdateStub) - .command(['update', '--version=3.0.0', '--force']) - .it('updates to version with force', () => { - assert.isTrue(runUpdateStub.called); - const callArgs = runUpdateStub.getCall(0).args[0]; - assert.equal(callArgs.version, '3.0.0'); - assert.isTrue(callArgs.force); - }); - }); - - it('should work with channel and autoupdate flags together', async function () { + }) + .command(['update', '--version=3.0.0', '--force']) + .it('should work with version and force flags together', () => { + assert.isTrue(runUpdateStub.called); + const callArgs = runUpdateStub.getCall(0).args[0]; + assert.equal(callArgs.version, '3.0.0'); + assert.isTrue(callArgs.force); + }); + + test.do(() => { + promptStub = sandbox + .stub(UpdateCommand.prototype, 'promptForVersion') + .resolves('3.5.0'); runUpdateStub.resolves(); - - await test - .stub(GitHubUpdater.prototype, 'runUpdate', runUpdateStub) - .command(['update', 'stable', '--autoupdate']) - .it('updates to channel with autoupdate', () => { - assert.isTrue(runUpdateStub.called); - const callArgs = runUpdateStub.getCall(0).args[0]; - assert.equal(callArgs.channel, 'stable'); - assert.isTrue(callArgs.autoUpdate); - }); - }); - - it('should work with interactive and force flags together', async function () { - const promptStub = sandbox.stub( - UpdateCommand.prototype, - 'promptForVersion' - ); - promptStub.resolves('3.5.0'); + }) + .command(['update', '--interactive', '--force']) + .it('should work with interactive and force flags together', () => { + assert.isTrue(promptStub.called); + assert.isTrue(runUpdateStub.called); + const callArgs = runUpdateStub.getCall(0).args[0]; + assert.equal(callArgs.version, '3.5.0'); + assert.isTrue(callArgs.force); + }); + + test.do(() => { runUpdateStub.resolves(); - - await test - .stub(UpdateCommand.prototype, 'promptForVersion', promptStub) - .stub(GitHubUpdater.prototype, 'runUpdate', runUpdateStub) - .command(['update', '--interactive', '--force']) - .it('prompts for version with force flag', () => { - assert.isTrue(promptStub.called); - assert.isTrue(runUpdateStub.called); - const callArgs = runUpdateStub.getCall(0).args[0]; - assert.equal(callArgs.version, '3.5.0'); - assert.isTrue(callArgs.force); - }); - }); - - it('should handle autoupdate and force flags together', async function () { - runUpdateStub.resolves(); - - await test - .stub(GitHubUpdater.prototype, 'runUpdate', runUpdateStub) - .command(['update', '--autoupdate', '--force']) - .it('updates with autoupdate and force', () => { - assert.isTrue(runUpdateStub.called); - const callArgs = runUpdateStub.getCall(0).args[0]; - assert.isTrue(callArgs.autoUpdate); - assert.isTrue(callArgs.force); - }); - }); + }) + .command(['update', '--autoupdate', '--force']) + .it('should handle autoupdate and force flags together', () => { + assert.isTrue(runUpdateStub.called); + const callArgs = runUpdateStub.getCall(0).args[0]; + assert.isTrue(callArgs.autoUpdate); + assert.isTrue(callArgs.force); + }); }); describe('update command without flags', function () { - it('should run update without any flags', async function () { + test.do(() => { runUpdateStub.resolves(); - - await test - .stub(GitHubUpdater.prototype, 'runUpdate', runUpdateStub) - .command(['update']) - .it('runs update with defaults', () => { - assert.isTrue(runUpdateStub.called); - const callArgs = runUpdateStub.getCall(0).args[0]; - assert.isFalse(callArgs.autoUpdate); - assert.isFalse(callArgs.force); - assert.isUndefined(callArgs.version); - }); - }); + }) + .command(['update']) + .it('should run update without any flags', () => { + assert.isTrue(runUpdateStub.called); + const callArgs = runUpdateStub.getCall(0).args[0]; + assert.notOk(callArgs.autoUpdate); + assert.notOk(callArgs.force); + assert.isUndefined(callArgs.version); + }); }); describe('available versions display', function () { - it('should display both local and remote versions', async function () { - const mockVersionIndex = { + test.do(() => { + fetchVersionIndexStub.resolves({ '4.0.0': 'https://github.com/box/boxcli/releases/download/v4.0.0', '3.5.0': 'https://github.com/box/boxcli/releases/download/v3.5.0', '3.0.0': 'https://github.com/box/boxcli/releases/download/v3.0.0', - }; - const mockLocalVersions = [ + }); + findLocalVersionsStub.resolves([ '/path/to/versions/4.0.0-darwin-x64', '/path/to/versions/3.5.0-darwin-x64', - ]; - - fetchVersionIndexStub.resolves(mockVersionIndex); - findLocalVersionsStub.resolves(mockLocalVersions); - - await test - .stdout() - .stub( - GitHubUpdater.prototype, - 'fetchVersionIndex', - fetchVersionIndexStub - ) - .stub( - GitHubUpdater.prototype, - 'findLocalVersions', - findLocalVersionsStub - ) - .command(['update', '--available']) - .it('displays mixed local and remote versions', (ctx) => { - assert.isTrue(fetchVersionIndexStub.called); - assert.isTrue(findLocalVersionsStub.called); - assert.include(ctx.stdout, '4.0.0'); - assert.include(ctx.stdout, '3.5.0'); - assert.include(ctx.stdout, '3.0.0'); - }); - }); - - it('should display only remote versions when no local versions', async function () { - const mockVersionIndex = { + ]); + }) + .stdout() + .command(['update', '--available']) + .it('should display both local and remote versions', (ctx) => { + assert.isTrue(fetchVersionIndexStub.called); + assert.isTrue(findLocalVersionsStub.called); + assert.include(ctx.stdout, '4.0.0'); + assert.include(ctx.stdout, '3.5.0'); + assert.include(ctx.stdout, '3.0.0'); + }); + + test.do(() => { + fetchVersionIndexStub.resolves({ '2.0.0': 'https://github.com/box/boxcli/releases/download/v2.0.0', - }; - const mockLocalVersions = []; - - fetchVersionIndexStub.resolves(mockVersionIndex); - findLocalVersionsStub.resolves(mockLocalVersions); - - await test - .stdout() - .stub( - GitHubUpdater.prototype, - 'fetchVersionIndex', - fetchVersionIndexStub - ) - .stub( - GitHubUpdater.prototype, - 'findLocalVersions', - findLocalVersionsStub - ) - .command(['update', '--available']) - .it('displays only remote versions', (ctx) => { + }); + findLocalVersionsStub.resolves([]); + }) + .stdout() + .command(['update', '--available']) + .it( + 'should display only remote versions when no local versions', + (ctx) => { assert.isTrue(fetchVersionIndexStub.called); assert.isTrue(findLocalVersionsStub.called); assert.include(ctx.stdout, '2.0.0'); - }); - }); + } + ); }); describe('edge cases', function () { - it('should handle empty version index', async function () { - const mockVersionIndex = {}; - const mockLocalVersions = []; - - fetchVersionIndexStub.resolves(mockVersionIndex); - findLocalVersionsStub.resolves(mockLocalVersions); - - await test - .stdout() - .stub( - GitHubUpdater.prototype, - 'fetchVersionIndex', - fetchVersionIndexStub - ) - .stub( - GitHubUpdater.prototype, - 'findLocalVersions', - findLocalVersionsStub - ) - .command(['update', '--available']) - .it('handles empty version index gracefully', () => { - assert.isTrue(fetchVersionIndexStub.called); - assert.isTrue(findLocalVersionsStub.called); - }); - }); - - it('should handle promptForVersion returning null', async function () { - const promptStub = sandbox.stub( - UpdateCommand.prototype, - 'promptForVersion' - ); - promptStub.resolves(null); + test.do(() => { + fetchVersionIndexStub.resolves({}); + findLocalVersionsStub.resolves([]); + }) + .stdout() + .command(['update', '--available']) + .it('should handle empty version index', () => { + assert.isTrue(fetchVersionIndexStub.called); + assert.isTrue(findLocalVersionsStub.called); + }); + + test.do(() => { + promptStub = sandbox + .stub(UpdateCommand.prototype, 'promptForVersion') + .resolves(null); runUpdateStub.resolves(); - - await test - .stub(UpdateCommand.prototype, 'promptForVersion', promptStub) - .stub(GitHubUpdater.prototype, 'runUpdate', runUpdateStub) - .command(['update', '--interactive']) - .it('handles null version from prompt', () => { - assert.isTrue(promptStub.called); - assert.isTrue(runUpdateStub.called); - const callArgs = runUpdateStub.getCall(0).args[0]; - assert.isNull(callArgs.version); - }); - }); - - it('should handle version with special characters in basename matching', async function () { - const mockVersionIndex = { + }) + .command(['update', '--interactive']) + .it('should handle promptForVersion returning null', () => { + assert.isTrue(promptStub.called); + assert.isTrue(runUpdateStub.called); + const callArgs = runUpdateStub.getCall(0).args[0]; + assert.isNull(callArgs.version); + }); + + test.do(() => { + fetchVersionIndexStub.resolves({ '1.0.0-beta': 'https://github.com/box/boxcli/releases/download/v1.0.0-beta', - }; - const mockLocalVersions = [ + }); + findLocalVersionsStub.resolves([ '/path/to/versions/1.0.0-beta-darwin-x64', - ]; - - fetchVersionIndexStub.resolves(mockVersionIndex); - findLocalVersionsStub.resolves(mockLocalVersions); - - await test - .stdout() - .stub( - GitHubUpdater.prototype, - 'fetchVersionIndex', - fetchVersionIndexStub - ) - .stub( - GitHubUpdater.prototype, - 'findLocalVersions', - findLocalVersionsStub - ) - .command(['update', '--available']) - .it('matches version with special characters', (ctx) => { + ]); + }) + .stdout() + .command(['update', '--available']) + .it( + 'should handle version with special characters in basename matching', + (ctx) => { assert.include(ctx.stdout, '1.0.0-beta'); assert.include( ctx.stdout, '/path/to/versions/1.0.0-beta-darwin-x64' ); - }); - }); + } + ); }); describe('command class properties', function () {