diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml new file mode 100644 index 00000000..8b315ee0 --- /dev/null +++ b/.github/workflows/integration-tests.yml @@ -0,0 +1,41 @@ +name: integration-tests +on: + pull_request: + types: [opened, synchronize] + branches: + - main + push: + branches: + - main + +jobs: + integration-test: + runs-on: ubuntu-latest + name: Integration Tests + steps: + - name: Checkout + uses: actions/checkout@v2 + - name: Setup Node + uses: actions/setup-node@v2 + with: + node-version: '20' + - name: npm install + run: npm install + - name: Setup Integration Test Environment + env: + BOX_JWT_CONFIG: ${{ secrets.BOX_JWT_CONFIG }} + BOX_ADMIN_USER_ID: ${{ secrets.BOX_ADMIN_USER_ID }} + run: | + if [ -z "$BOX_JWT_CONFIG" ] || [ -z "$BOX_ADMIN_USER_ID" ]; then + echo "Missing required environment variables" + exit 1 + fi + mkdir -p ~/.box + chmod 700 ~/.box + echo '{"cacheTokens":true,"boxReportsFolderPath":"/tmp/box-reports","boxDownloadsFolderPath":"/tmp/box-downloads"}' > ~/.box/settings.json + chmod 600 ~/.box/settings.json + - name: Run Integration Tests + env: + BOX_JWT_CONFIG: ${{ secrets.BOX_JWT_CONFIG }} + BOX_ADMIN_USER_ID: ${{ secrets.BOX_ADMIN_USER_ID }} + run: npm run test:integration diff --git a/integration-tests/.eslintrc.yml b/integration-tests/.eslintrc.yml new file mode 100644 index 00000000..8fe0e069 --- /dev/null +++ b/integration-tests/.eslintrc.yml @@ -0,0 +1,9 @@ +--- +extends: '../.eslintrc.yml' + +env: + mocha: true + +rules: + require-jsdoc: off + no-unused-expressions: off # needed for chai assertions diff --git a/integration-tests/__tests__/users.test.js b/integration-tests/__tests__/users.test.js new file mode 100644 index 00000000..0024cb4e --- /dev/null +++ b/integration-tests/__tests__/users.test.js @@ -0,0 +1,172 @@ +'use strict'; + +const { expect } = require('chai'); +const { execCLI, getAdminUserId, setupEnvironment, cleanupEnvironment, getJWTConfig } = require('../helpers/test-helper'); + +describe('Users Integration Tests', () => { + let adminUserId; + let testUser; + let testUserEmail; + + beforeEach(async() => { + adminUserId = getAdminUserId(); + testUserEmail = `test-user-${Date.now()}@boxdemo.com`; + await setupEnvironment(); + }); + + after(async() => { + try { + if (testUser) { + await execCLI(`users:delete ${testUser.id} --force`); + } + } finally { + await cleanupEnvironment(); + } + }); + + describe('User Lifecycle', () => { + it('should create and delete a user', async() => { + const createOutput = await execCLI(`users:create "${testUserEmail}" ${testUserEmail} --json`); + testUser = JSON.parse(createOutput); + expect(testUser).to.be.an('object'); + expect(testUser.login).to.equal(testUserEmail); + expect(testUser.name).to.equal(testUserEmail); + + const deleteOutput = await execCLI(`users:delete ${testUser.id} --force --json`); + expect(deleteOutput).to.include('Successfully deleted user'); + testUser = null; + }); + + it('should update user info', async() => { + const createOutput = await execCLI(`users:create "${testUserEmail}" ${testUserEmail} --json`); + testUser = JSON.parse(createOutput); + + const newName = 'Updated Name'; + const updateOutput = await execCLI(`users:update ${testUser.id} --name="${newName}" --json`); + const updatedUser = JSON.parse(updateOutput); + expect(updatedUser.name).to.equal(newName); + }); + + it('should manage email aliases', async() => { + const createOutput = await execCLI(`users:create "${testUserEmail}" ${testUserEmail} --json`); + testUser = JSON.parse(createOutput); + + const aliasEmail = `alias-${Date.now()}@boxdemo.com`; + await execCLI(`users:email-aliases:add ${testUser.id} ${aliasEmail}`); + + const listOutput = await execCLI(`users:email-aliases ${testUser.id} --json`); + const aliases = JSON.parse(listOutput); + expect(aliases).to.be.an('array'); + expect(aliases.some(alias => alias.email === aliasEmail)).to.be.true; + + await execCLI(`users:email-aliases:remove ${testUser.id} ${aliasEmail}`); + const updatedListOutput = await execCLI(`users:email-aliases ${testUser.id} --json`); + const updatedAliases = JSON.parse(updatedListOutput); + expect(updatedAliases.some(alias => alias.email === aliasEmail)).to.be.false; + }); + }); + + describe('User Operations', () => { + it('should get user info', async() => { + const output = await execCLI(`users:get ${adminUserId} --json`); + const user = JSON.parse(output); + expect(user).to.be.an('object'); + expect(user.id).to.equal(adminUserId); + expect(user.type).to.equal('user'); + expect(user.login).to.be.a('string'); + expect(user.name).to.be.a('string'); + expect(user.created_at).to.be.a('string'); + expect(user.modified_at).to.be.a('string'); + }); + + it('should list group memberships', async() => { + const output = await execCLI(`users:groups ${adminUserId} --json`); + const memberships = JSON.parse(output); + expect(memberships).to.be.an('array'); + memberships.forEach(membership => { + expect(membership.type).to.equal('group_membership'); + expect(membership.user.id).to.equal(adminUserId); + expect(membership.group).to.be.an('object'); + }); + }); + + it('should search users', async() => { + const createOutput = await execCLI(`users:create "${testUserEmail}" ${testUserEmail} --json`); + testUser = JSON.parse(createOutput); + + const searchOutput = await execCLI(`users:search ${testUserEmail} --json`); + const searchResults = JSON.parse(searchOutput); + expect(searchResults).to.be.an('array'); + expect(searchResults.some(user => user.id === testUser.id)).to.be.true; + }); + + it('should terminate user sessions', async() => { + const createOutput = await execCLI(`users:create "${testUserEmail}" ${testUserEmail} --json`); + testUser = JSON.parse(createOutput); + + const output = await execCLI(`users:terminate-session ${testUser.id}`); + expect(output).to.include('Successfully terminated user sessions'); + }); + }); + + describe('Content Transfer', () => { + let sourceUser; + let destinationUser; + + beforeEach(async() => { + // Create source user + const sourceEmail = `test-source-${Date.now()}@boxdemo.com`; + const sourceOutput = await execCLI(`users:create "${sourceEmail}" ${sourceEmail} --json`); + sourceUser = JSON.parse(sourceOutput); + + // Create destination user + const destEmail = `test-dest-${Date.now()}@boxdemo.com`; + const destOutput = await execCLI(`users:create "${destEmail}" ${destEmail} --json`); + destinationUser = JSON.parse(destOutput); + }); + + afterEach(async() => { + // Clean up test users + if (sourceUser) { + await execCLI(`users:delete ${sourceUser.id} --force`); + } + if (destinationUser) { + await execCLI(`users:delete ${destinationUser.id} --force`); + } + }); + + it('should transfer content between users', async() => { + const output = await execCLI(`users:transfer-content ${sourceUser.id} ${destinationUser.id} --json`); + const result = JSON.parse(output); + expect(result.owned_by.id).to.equal(destinationUser.id); + expect(result.type).to.equal('folder'); + }); + + it('should transfer content with notify flag', async() => { + const output = await execCLI(`users:transfer-content ${sourceUser.id} ${destinationUser.id} --notify --json`); + const result = JSON.parse(output); + expect(result.owned_by.id).to.equal(destinationUser.id); + expect(result.type).to.equal('folder'); + }); + }); + + describe('User Invitations', () => { + let testEmail; + let enterpriseId; + + beforeEach(() => { + testEmail = `test-invite-${Date.now()}@boxdemo.com`; + // Get enterprise ID from JWT config + const jwtConfig = getJWTConfig(); + enterpriseId = jwtConfig.enterpriseID; + }); + + it('should invite a user to enterprise', async() => { + const output = await execCLI(`users:invite ${testEmail} ${enterpriseId} --json`); + const result = JSON.parse(output); + expect(result.enterprise.id).to.equal(enterpriseId); + expect(result.actionable_by.login).to.equal(testEmail); + expect(result.status).to.equal('pending'); + }); + }); +}); diff --git a/integration-tests/helpers/test-helper.js b/integration-tests/helpers/test-helper.js new file mode 100644 index 00000000..9e265c73 --- /dev/null +++ b/integration-tests/helpers/test-helper.js @@ -0,0 +1,300 @@ +'use strict'; + +const path = require('path'); +const fs = require('fs'); +const { promisify } = require('util'); +const util = require('util'); +const os = require('os'); +const exec = promisify(require('child_process').exec); +const { validateConfigObject } = require('../../src/util'); +const darwinKeychain = require('keychain'); +const darwinKeychainSetPassword = util.promisify(darwinKeychain.setPassword.bind(darwinKeychain)); +function loadKeytar() { + if (process.platform !== 'win32') { + return null; + } + // eslint-disable-next-line global-require + return require('keytar'); +} + +const keytar = loadKeytar(); + +const CONFIG_FOLDER_PATH = path.join(os.homedir(), '.box'); +const SETTINGS_FILE_PATH = path.join(CONFIG_FOLDER_PATH, 'settings.json'); +const ENVIRONMENTS_FILE_PATH = path.join(CONFIG_FOLDER_PATH, 'box_environments.json'); + +const CLI_PATH = process.env.CI ? '/home/runner/work/boxcli/boxcli/bin/run' : path.resolve(__dirname, '../../bin/run'); +const TIMEOUT = 60000; // 60 second timeout for operations + +const execCLI = async(command) => { + try { + const { stdout, stderr } = await exec(`${CLI_PATH} ${command}`, { timeout: TIMEOUT }); + if (stderr && !stderr.includes('DeprecationWarning')) { + throw new Error(`Command failed: ${stderr}`); + } + if (!stdout) { + throw new Error('Command produced no output'); + } + return stdout.trim(); + } catch (error) { + if (error.stderr && !error.stderr.includes('DeprecationWarning')) { + throw new Error(`Command failed: ${error.stderr}`); + } + throw error; + } +}; + +const getJWTConfig = () => { + const config = process.env.BOX_JWT_CONFIG; + if (!config) { + throw new Error('Missing BOX_JWT_CONFIG environment variable'); + } + const jwtConfig = JSON.parse(Buffer.from(config, 'base64').toString()); + + // Validate JWT config + const requiredFields = ['boxAppSettings', 'enterpriseID']; + const requiredAppFields = ['clientID', 'clientSecret', 'appAuth']; + const requiredAuthFields = ['publicKeyID', 'privateKey', 'passphrase']; + + if (!requiredFields.every(field => jwtConfig[field])) { + throw new Error(`JWT config missing required fields: ${requiredFields.filter(f => !jwtConfig[f]).join(', ')}`); + } + if (!requiredAppFields.every(field => jwtConfig.boxAppSettings[field])) { + throw new Error(`JWT config missing required app fields: ${requiredAppFields.filter(f => !jwtConfig.boxAppSettings[f]).join(', ')}`); + } + if (!requiredAuthFields.every(field => jwtConfig.boxAppSettings.appAuth[field])) { + throw new Error(`JWT config missing required auth fields: ${requiredAuthFields.filter(f => !jwtConfig.boxAppSettings.appAuth[f]).join(', ')}`); + } + + return jwtConfig; +}; + +const getAdminUserId = () => { + const userId = process.env.BOX_ADMIN_USER_ID; + if (!userId) { + throw new Error('Missing BOX_ADMIN_USER_ID environment variable'); + } + return userId; +}; + +async function execWithTimeout(command, timeoutMs = TIMEOUT) { + try { + const { stdout, stderr } = await exec(command, { timeout: timeoutMs }); + if (stderr && !stderr.includes('DeprecationWarning')) { + throw new Error(`Command failed with stderr: ${stderr}`); + } + return { stdout: stdout || '' }; + } catch (error) { + if (error.code === 'ETIMEDOUT') { + throw new Error(`Command timed out after ${timeoutMs}ms: ${command}`); + } + if (error.stderr && !error.stderr.includes('DeprecationWarning')) { + throw new Error(`Command failed: ${error.stderr}`); + } + throw error; + } +} + +const setupEnvironment = async() => { + const startTime = Date.now(); + const logWithTime = (msg) => { + const elapsed = Date.now() - startTime; + console.log(`[${elapsed}ms] ${msg}`); + }; + + logWithTime('Setting up test environment...'); + const jwtConfig = getJWTConfig(); + validateConfigObject(jwtConfig); + logWithTime('JWT config loaded and validated'); + + try { + // Clean up any existing environment first + logWithTime('Cleaning up existing environment...'); + try { + await exec(`${CLI_PATH} configure:environments:delete integration-test`, { timeout: 30000 }); + logWithTime('Existing environment deleted'); + } catch (error) { + // Ignore errors about non-existent environments + if (!error.stderr?.includes('environment does not exist')) { + throw error; + } + logWithTime('No existing environment to delete'); + } + + // Remove existing config directory and files + try { + await fs.promises.rm(CONFIG_FOLDER_PATH, { recursive: true, force: true }); + } catch { + // Directory might not exist + } + + // Create fresh config directory + await fs.promises.mkdir(CONFIG_FOLDER_PATH, { recursive: true, mode: 0o700 }); + + // Write settings and environment configuration + logWithTime('Writing configuration files...'); + const settings = { + cacheTokens: true, + boxReportsFolderPath: '/tmp/box-reports', + boxDownloadsFolderPath: '/tmp/box-downloads' + }; + + // Write JWT config file + const configFilePath = path.join(CONFIG_FOLDER_PATH, 'jwt-config.json'); + const configJson = { + boxAppSettings: { + clientID: jwtConfig.boxAppSettings.clientID, + clientSecret: jwtConfig.boxAppSettings.clientSecret, + appAuth: { + publicKeyID: jwtConfig.boxAppSettings.appAuth.publicKeyID, + privateKey: jwtConfig.boxAppSettings.appAuth.privateKey, + passphrase: jwtConfig.boxAppSettings.appAuth.passphrase + } + }, + enterpriseID: jwtConfig.enterpriseID + }; + await fs.promises.writeFile(configFilePath, JSON.stringify(configJson, null, 2)); + await fs.promises.chmod(configFilePath, 0o600); + logWithTime(`JWT config written to: ${configFilePath}`); + + const environmentConfig = { + name: 'integration-test', + defaultAsUserId: null, + useDefaultAsUser: false, + cacheTokens: true, + authMethod: 'jwt', + boxConfigFilePath: configFilePath, + hasInLinePrivateKey: true, + privateKeyPath: null, + enterpriseID: jwtConfig.enterpriseID + }; + + const environments = { + default: 'integration-test', + environments: { + 'integration-test': environmentConfig + } + }; + + // Write settings file + const settingsJson = JSON.stringify(settings, null, 2); + await fs.promises.writeFile(SETTINGS_FILE_PATH, settingsJson); + await fs.promises.chmod(SETTINGS_FILE_PATH, 0o600); + logWithTime(`Settings file written: ${settingsJson}`); + + // Write environment config + const environmentsJson = JSON.stringify(environments, null, 2); + + // Write to keychain on macOS/Windows + switch (process.platform) { + case 'darwin': { + try { + await darwinKeychainSetPassword({ + account: 'Box', + service: 'boxcli', + password: environmentsJson, + }); + logWithTime('Environment config written to keychain'); + } catch (error) { + logWithTime(`Failed to write to keychain: ${error.message}`); + } + break; + } + + case 'win32': { + try { + if (keytar) { + await keytar.setPassword( + 'boxcli', + 'Box', + environmentsJson + ); + logWithTime('Environment config written to keychain'); + } + } catch (error) { + logWithTime(`Failed to write to keychain: ${error.message}`); + } + break; + } + + default: + } + + // Always write to file system as fallback + await fs.promises.writeFile(ENVIRONMENTS_FILE_PATH, environmentsJson); + await fs.promises.chmod(ENVIRONMENTS_FILE_PATH, 0o600); + logWithTime(`Environment config written to file: ${environmentsJson}`); + + // Verify environment config was written correctly + const writtenConfig = JSON.parse(await fs.promises.readFile(ENVIRONMENTS_FILE_PATH, 'utf8')); + if (!writtenConfig.environments || !writtenConfig.environments['integration-test']) { + throw new Error('Failed to write environment configuration'); + } + + // Set environment variables that BoxCLI uses + process.env.BOX_ENVIRONMENT = 'integration-test'; + logWithTime(`Configuration files written to ${CONFIG_FOLDER_PATH}`); + + // Verify files exist and are readable + logWithTime('Verifying configuration files...'); + const [settingsContent, environmentsContent] = await Promise.all([ + fs.promises.readFile(SETTINGS_FILE_PATH, 'utf8'), + fs.promises.readFile(ENVIRONMENTS_FILE_PATH, 'utf8') + ]); + logWithTime(`Settings file content: ${settingsContent}`); + logWithTime(`Environments file content: ${environmentsContent}`); + + // Verify environment by trying to use it + logWithTime('Verifying environment by getting user info...'); + let retries = 3; + let lastError; + const verifyEnvironment = async() => { + const output = await execCLI(`users:get ${getAdminUserId()} --json`); + const user = JSON.parse(output); + if (!user.id || user.id !== getAdminUserId()) { + throw new Error('Failed to verify user info'); + } + return true; + }; + + const abortController = new AbortController(); + const timeoutId = setTimeout(() => abortController.abort('Timeout'), 15000); + + try { + await verifyEnvironment(); + clearTimeout(timeoutId); + logWithTime('Environment verified successfully'); + } catch (error) { + clearTimeout(timeoutId); + lastError = error; + logWithTime(`Environment verification failed: ${error.message}`); + throw error; + } + + if (retries === 0) { + throw new Error(`Failed to verify environment after multiple attempts: ${lastError.message}`); + } + logWithTime('Environment verification complete'); + } catch (error) { + logWithTime(`Error in environment setup: ${error.message}`); + throw error; + } +}; + +const cleanupEnvironment = async() => { + try { + await execWithTimeout(`${CLI_PATH} configure:environments:delete integration-test`); + console.log('Environment cleanup complete'); + } catch (error) { + console.error('Error cleaning up environment:', error); + } +}; + + +module.exports = { + getJWTConfig, + getAdminUserId, + execCLI, + setupEnvironment, + cleanupEnvironment +}; diff --git a/package.json b/package.json index 585f2dfc..df8a98b6 100644 --- a/package.json +++ b/package.json @@ -142,9 +142,10 @@ } }, "scripts": { - "test": "nyc mocha \"test/**/*.test.js\"", + "test": "nyc mocha \"test/**/*.test.js\" --timeout 60000", + "test:integration": "mocha \"integration-tests/__tests__/**/*.test.js\" --timeout 180000", "posttest": "npm run lint", - "lint": "eslint --fix ./src ./test", + "lint": "eslint --fix ./src ./test ./integration-tests", "clean": "find src/ -type d -name 'dist' -exec rm -rf {} +", "updatemd": "find docs -type f -name '*.md' -exec sed -i '' 's/\\.ts/\\.js/g' {} +", "prepack": "npm run license && oclif manifest && oclif readme --multi && npm run updatemd && npm shrinkwrap && git checkout origin/main -- package-lock.json",