diff --git a/CHANGES b/CHANGES index 68d6c677b..7e6d23d84 100644 --- a/CHANGES +++ b/CHANGES @@ -1665,3 +1665,7 @@ * Rework app task system so that we can now pass dynamic arguments * Add external LDAP server integration +[4.2.1] +* Rework the app configuration routes & UI + + diff --git a/src/apps.js b/src/apps.js index f6df8ee94..e41f6c97d 100644 --- a/src/apps.js +++ b/src/apps.js @@ -17,6 +17,21 @@ exports = module.exports = { configure: configure, uninstall: uninstall, + setAccessRestriction: setAccessRestriction, + setLabel: setLabel, + setIcon: setIcon, + setTags: setTags, + setMemoryLimit: setMemoryLimit, + setAutomaticBackup: setAutomaticBackup, + setAutomaticUpdate: setAutomaticUpdate, + setRobotsTxt: setRobotsTxt, + setCertificate: setCertificate, + setDebugMode: setDebugMode, + setEnvironment: setEnvironment, + setMailbox: setMailbox, + setLocation: setLocation, + setDataDir: setDataDir, + restore: restore, clone: clone, @@ -58,6 +73,9 @@ exports = module.exports = { ISTATE_PENDING_INSTALL: 'pending_install', // installs and fresh reinstalls ISTATE_PENDING_CLONE: 'pending_clone', // clone ISTATE_PENDING_CONFIGURE: 'pending_configure', // config (location, port) changes and on infra update + ISTATE_PENDING_CREATE_CONTAINER: 'pending_create_container', + ISTATE_PENDING_LOCATION_CHANGE: 'pending_location_change', + ISTATE_PENDING_DATA_DIR_MIGRATION: 'pending_data_dir_migration', ISTATE_PENDING_UNINSTALL: 'pending_uninstall', // uninstallation ISTATE_PENDING_RESTORE: 'pending_restore', // restore to previous backup or on upgrade ISTATE_PENDING_UPDATE: 'pending_update', // update from installed state preserving data @@ -803,6 +821,359 @@ function install(data, user, auditSource, callback) { }); } +function setAccessRestriction(appId, accessRestriction, auditSource, callback) { + assert.strictEqual(typeof appId, 'string'); + assert.strictEqual(typeof accessRestriction, 'object'); + assert.strictEqual(typeof auditSource, 'object'); + assert.strictEqual(typeof callback, 'function'); + + get(appId, function (error, app) { + if (error) return callback(error); + + error = validateAccessRestriction(accessRestriction); + if (error) return callback(error); + + appdb.update(appId, { accessRestriction: accessRestriction }, function (error) { + if (error && error.reason === DatabaseError.NOT_FOUND) return callback(new AppsError(AppsError.NOT_FOUND, 'No such app')); + if (error) return callback(new AppsError(AppsError.INTERNAL_ERROR, error)); + + eventlog.add(eventlog.ACTION_APP_CONFIGURE, auditSource, { appId: appId, app: app, accessRestriction: accessRestriction }); + + callback(); + }); + }); +} + +function setLabel(appId, label, auditSource, callback) { + assert.strictEqual(typeof appId, 'string'); + assert.strictEqual(typeof label, 'string'); + assert.strictEqual(typeof auditSource, 'object'); + assert.strictEqual(typeof callback, 'function'); + + get(appId, function (error, app) { + if (error) return callback(error); + + error = validateLabel(label); + if (error) return callback(error); + + appdb.update(appId, { label: label }, function (error) { + if (error && error.reason === DatabaseError.NOT_FOUND) return callback(new AppsError(AppsError.NOT_FOUND, 'No such app')); + if (error) return callback(new AppsError(AppsError.INTERNAL_ERROR, error)); + + eventlog.add(eventlog.ACTION_APP_CONFIGURE, auditSource, { appId: appId, app: app, label: label }); + + callback(); + }); + }); +} + +function setTags(appId, tags, auditSource, callback) { + assert.strictEqual(typeof appId, 'string'); + assert(Array.isArray(tags)); + assert.strictEqual(typeof auditSource, 'object'); + assert.strictEqual(typeof callback, 'function'); + + get(appId, function (error, app) { + if (error) return callback(error); + + error = validateTags(tags); + if (error) return callback(error); + + appdb.update(appId, { tags: tags }, function (error) { + if (error && error.reason === DatabaseError.NOT_FOUND) return callback(new AppsError(AppsError.NOT_FOUND, 'No such app')); + if (error) return callback(new AppsError(AppsError.INTERNAL_ERROR, error)); + + eventlog.add(eventlog.ACTION_APP_CONFIGURE, auditSource, { appId: appId, app: app, tags: tags }); + + callback(); + }); + }); +} + +function setIcon(appId, icon, auditSource, callback) { + assert.strictEqual(typeof appId, 'string'); + assert(icon === null || typeof icon === 'string'); + assert.strictEqual(typeof auditSource, 'object'); + assert.strictEqual(typeof callback, 'function'); + + get(appId, function (error, app) { + if (error) return callback(error); + + if (icon) { + if (!validator.isBase64(icon)) return callback(new AppsError(AppsError.BAD_FIELD, 'icon is not base64', { field: 'icon' })); + + if (!safe.fs.writeFileSync(path.join(paths.APP_ICONS_DIR, appId + '.user.png'), Buffer.from(icon, 'base64'))) { + return callback(new AppsError(AppsError.INTERNAL_ERROR, 'Error saving icon:' + safe.error.message)); + } + } else { + safe.fs.unlinkSync(path.join(paths.APP_ICONS_DIR, appId + '.user.png')); + } + + eventlog.add(eventlog.ACTION_APP_CONFIGURE, auditSource, { appId: appId, app: app, icon: icon }); + + callback(); + }); +} + +function setMemoryLimit(appId, memoryLimit, auditSource, callback) { + assert.strictEqual(typeof appId, 'string'); + assert.strictEqual(typeof memoryLimit, 'number'); + assert.strictEqual(typeof auditSource, 'object'); + assert.strictEqual(typeof callback, 'function'); + + get(appId, function (error, app) { + if (error) return callback(error); + + error = validateMemoryLimit(app.manifest, memoryLimit); + if (error) return callback(error); + + scheduleTask(appId, {}, { installationState: exports.ISTATE_PENDING_CREATE_CONTAINER, memoryLimit: memoryLimit }, function (error, result) { + if (error) return callback(error); + + eventlog.add(eventlog.ACTION_APP_CONFIGURE, auditSource, { appId: appId, app: app, memoryLimit: memoryLimit, taskId: result.taskId }); + + callback(null, { taskId: result.taskId }); + }); + }); +} + +function setEnvironment(appId, env, auditSource, callback) { + assert.strictEqual(typeof appId, 'string'); + assert.strictEqual(typeof env, 'object'); + assert.strictEqual(typeof memoryLimit, 'number'); + assert.strictEqual(typeof auditSource, 'object'); + assert.strictEqual(typeof callback, 'function'); + + get(appId, function (error, app) { + if (error) return callback(error); + + error = validateEnv(env); + if (error) return callback(error); + + scheduleTask(appId, {}, { installationState: exports.ISTATE_PENDING_CREATE, env: env }, function (error, result) { + if (error) return callback(error); + + eventlog.add(eventlog.ACTION_APP_CONFIGURE, auditSource, { appId: appId, app: app, env: env, taskId: result.taskId }); + + callback(null, { taskId: result.taskId }); + }); + }); +} + +function setDebugMode(appId, debugMode, auditSource, callback) { + assert.strictEqual(typeof appId, 'string'); + assert.strictEqual(typeof debugMode, 'object'); + assert.strictEqual(typeof auditSource, 'object'); + assert.strictEqual(typeof callback, 'function'); + + get(appId, function (error, app) { + if (error) return callback(error); + + error = validateDebugMode(debugMode); + if (error) return callback(error); + + scheduleTask(appId, {}, { installationState: exports.ISTATE_PENDING_CREATE, debugMode: debugMode }, function (error, result) { + if (error) return callback(error); + + eventlog.add(eventlog.ACTION_APP_CONFIGURE, auditSource, { appId: appId, app: app, debugMode: debugMode, taskId: result.taskId }); + + callback(null, { taskId: result.taskId }); + }); + }); +} + +function setMailbox(appId, mailboxName, auditSource, callback) { + assert.strictEqual(typeof appId, 'string'); + assert(!mailboxName || typeof mailboxName === 'string'); + assert.strictEqual(typeof memoryLimit, 'number'); + assert.strictEqual(typeof auditSource, 'object'); + assert.strictEqual(typeof callback, 'function'); + + get(appId, function (error, app) { + if (error) return callback(error); + + if (mailboxName) { + error = mail.validateName(mailboxName); + if (error) return callback(new AppsError(AppsError.BAD_FIELD, error.message, { field: 'mailboxName' })); + } else { + mailboxName = mailboxNameForLocation(app.location, app.manifest); + } + + scheduleTask(appId, {}, { installationState: exports.ISTATE_PENDING_CREATE, mailboxName: mailboxName }, function (error, result) { + if (error) return callback(error); + + eventlog.add(eventlog.ACTION_APP_CONFIGURE, auditSource, { appId: appId, app: app, mailboxName: mailboxName, taskId: result.taskId }); + + callback(null, { taskId: result.taskId }); + }); + }); +} + +function setAutomaticBackup(appId, enable, auditSource, callback) { + assert.strictEqual(typeof appId, 'string'); + assert.strictEqual(typeof enable, 'boolean'); + assert.strictEqual(typeof auditSource, 'object'); + assert.strictEqual(typeof callback, 'function'); + + get(appId, function (error, app) { + if (error) return callback(error); + + appdb.update(appId, { enableBackup: enable }, function (error) { + if (error && error.reason === DatabaseError.NOT_FOUND) return callback(new AppsError(AppsError.NOT_FOUND, 'No such app')); + if (error) return callback(new AppsError(AppsError.INTERNAL_ERROR, error)); + + eventlog.add(eventlog.ACTION_APP_CONFIGURE, auditSource, { appId: appId, app: app, enableBackup: enable }); + + callback(); + }); + }); +} + +function setAutomaticUpdate(appId, enable, auditSource, callback) { + assert.strictEqual(typeof appId, 'string'); + assert.strictEqual(typeof enable, 'boolean'); + assert.strictEqual(typeof auditSource, 'object'); + assert.strictEqual(typeof callback, 'function'); + + get(appId, function (error, app) { + if (error) return callback(error); + + appdb.update(appId, { enableAutomaticUpdate: enable }, function (error) { + if (error && error.reason === DatabaseError.NOT_FOUND) return callback(new AppsError(AppsError.NOT_FOUND, 'No such app')); + if (error) return callback(new AppsError(AppsError.INTERNAL_ERROR, error)); + + eventlog.add(eventlog.ACTION_APP_CONFIGURE, auditSource, { appId: appId, app: app, enableAutomaticUpdate: enable }); + + callback(); + }); + }); +} + +function setRobotsTxt(appId, robotsTxt, auditSource, callback) { + assert.strictEqual(typeof appId, 'string'); + assert(!robotsTxt || typeof robotsTxt === 'string'); + assert.strictEqual(typeof auditSource, 'object'); + assert.strictEqual(typeof callback, 'function'); + + get(appId, function (error, app) { + if (error) return callback(error); + + error = validateRobotsTxt(robotsTxt); + if (error) return callback(error); + + appdb.update(appId, { robotsTxt: robotsTxt }, function (error) { + if (error && error.reason === DatabaseError.NOT_FOUND) return callback(new AppsError(AppsError.NOT_FOUND, 'No such app')); + if (error) return callback(new AppsError(AppsError.INTERNAL_ERROR, error)); + + // TODO: call reverseProxy config re-write here + eventlog.add(eventlog.ACTION_APP_CONFIGURE, auditSource, { appId: appId, app: app, robotsTxt: robotsTxt }); + + callback(); + }); + }); +} + +function setCertificate(appId, bundle, auditSource, callback) { + assert.strictEqual(typeof appId, 'string'); + assert(bundle && typeof bundle === 'object'); + assert.strictEqual(typeof auditSource, 'object'); + assert.strictEqual(typeof callback, 'function'); + + get(appId, function (error, app) { + if (error) return callback(error); + + domains.get(app.domain, function (error, domainObject) { + if (error && error.reason === DomainsError.NOT_FOUND) return callback(new AppsError(AppsError.NOT_FOUND, 'No such domain')); + if (error) return callback(new AppsError(AppsError.INTERNAL_ERROR, 'Could not get domain info:' + error.message)); + + + if (bundle.cert && bundle.key) { + error = reverseProxy.validateCertificate(app.location, domainObject, { cert: bundle.cert, key: bundle.key }); + if (error) return callback(new AppsError(AppsError.BAD_FIELD, error.message, { field: 'cert' })); + } + + error = reverseProxy.setAppCertificateSync(app.location, domainObject, { cert: bundle.cert, key: bundle.key }); + if (error) return callback(new AppsError(AppsError.INTERNAL_ERROR, 'Error setting cert: ' + error.message)); + + eventlog.add(eventlog.ACTION_APP_CONFIGURE, auditSource, { appId: appId, app: app, cert: bundle.cert, key: bundle.key }); + + callback(); + }); + }); +} + +function setLocation(appId, data, auditSource, callback) { + assert.strictEqual(typeof appId, 'string'); + assert.strictEqual(typeof data, 'object'); + assert.strictEqual(typeof auditSource, 'object'); + assert.strictEqual(typeof callback, 'function'); + + get(appId, function (error, app) { + if (error) return callback(error); + + let values = { + installationState: exports.ISTATE_PENDING_LOCATION_CHANGE, + // these are intentionally reset, if not set + portBindings: null, + alternateDomains: [] + }; + + values.location = data.location.toLowerCase(); + values.domain = data.domain.toLowerCase(); + + if ('portBindings' in data) { + error = validatePortBindings(data.portBindings, app.manifest); + if (error) return callback(error); + + values.portBindings = translatePortBindings(data.portBindings || null, app.manifest); + } + + if ('alternateDomains' in data) { + // TODO validate all subdomains [{ domain: '', subdomain: ''}] + values.alternateDomains = data.alternateDomains; + } + + domains.get(values.domain, function (error, domainObject) { + if (error && error.reason === DomainsError.NOT_FOUND) return callback(new AppsError(AppsError.NOT_FOUND, 'No such domain')); + if (error) return callback(new AppsError(AppsError.INTERNAL_ERROR, 'Could not get domain info:' + error.message)); + + error = domains.validateHostname(values.location, domainObject); + if (error) return callback(new AppsError(AppsError.BAD_FIELD, 'Bad location: ' + error.message, { field: 'location' })); + + scheduleTask(appId, { oldConfig: getAppConfig(app) }, values, function (error, result) { + if (error && error.reason === AppsError.ALREADY_EXISTS) error = getDuplicateErrorDetails(error.message, values.location, domainObject, data.portBindings, app.alternateDomains); + if (error) return callback(error); + + eventlog.add(eventlog.ACTION_APP_CONFIGURE, auditSource, { appId: appId, app: app, config: values, taskId: result.taskId }); + + callback(null, { taskId: result.taskId }); + }); + }); + }); +} + +function setDataDir(appId, dataDir, auditSource, callback) { + assert.strictEqual(typeof appId, 'string'); + assert.strictEqual(typeof dataDir, 'string'); + assert.strictEqual(typeof auditSource, 'object'); + assert.strictEqual(typeof callback, 'function'); + + get(appId, function (error, app) { + if (error) return callback(error); + + error = validateDataDir(dataDir); + if (error) return callback(error); + + scheduleTask(appId, { oldConfig: getAppConfig(app) }, { installationState: exports.ISTATE_PENDING_DATA_DIR_MIGRATION, dataDir: dataDir }, function (error, result) { + if (error) return callback(error); + + eventlog.add(eventlog.ACTION_APP_CONFIGURE, auditSource, { appId: appId, app: app, dataDir: dataDir, taskId: result.taskId }); + + callback(null, { taskId: result.taskId }); + }); + }); +} + function configure(appId, data, user, auditSource, callback) { assert.strictEqual(typeof appId, 'string'); assert(data && typeof data === 'object'); diff --git a/src/apptask.js b/src/apptask.js index e15f1ac08..caca15b94 100644 --- a/src/apptask.js +++ b/src/apptask.js @@ -438,7 +438,7 @@ function waitForDnsPropagation(app, callback) { }); } -function migrateDataDir(app, sourceDir, callback) { +function moveDataDir(app, sourceDir, callback) { assert.strictEqual(typeof app, 'object'); assert.strictEqual(typeof sourceDir, 'string'); assert.strictEqual(typeof callback, 'function'); @@ -446,9 +446,9 @@ function migrateDataDir(app, sourceDir, callback) { let resolvedSourceDir = apps.getDataDir(app, sourceDir); let resolvedTargetDir = apps.getDataDir(app, app.dataDir); - debug(`migrateDataDir: migrating data from ${resolvedSourceDir} to ${resolvedTargetDir}`); + debug(`moveDataDir: migrating data from ${resolvedSourceDir} to ${resolvedTargetDir}`); - shell.sudo('migrateDataDir', [ MV_VOLUME_CMD, resolvedSourceDir, resolvedTargetDir ], {}, function (error) { + shell.sudo('moveDataDir', [ MV_VOLUME_CMD, resolvedSourceDir, resolvedTargetDir ], {}, function (error) { if (error) return callback(new BoxError(BoxError.EXTERNAL_ERROR, `Error migrating data directory: ${error.message}`)); callback(null); @@ -607,6 +607,127 @@ function backup(app, progressCallback, callback) { }); } +function create(app, progressCallback, callback) { + assert.strictEqual(typeof app, 'object'); + assert.strictEqual(typeof progressCallback, 'function'); + assert.strictEqual(typeof callback, 'function'); + + async.series([ + progressCallback.bind(null, { percent: 10, message: 'Cleaning up old install' }), + stopApp.bind(null, app, progressCallback), + deleteContainers.bind(null, app, { managedOnly: true }), + + progressCallback.bind(null, { percent: 60, message: 'Creating container' }), + createContainer.bind(null, app), + + progressCallback.bind(null, { percent: 80, message: 'Starting app' }), + runApp.bind(null, app, progressCallback), + + progressCallback.bind(null, { percent: 100, message: 'Done' }), + updateApp.bind(null, app, { installationState: apps.ISTATE_INSTALLED, error: null, health: null }) + ], function seriesDone(error) { + if (error) { + debugApp(app, 'error creating : %s', error); + return updateApp(app, { installationState: apps.ISTATE_ERROR, error: error.toPlainObject ? error.toPlainObject() : error.message }, callback.bind(null, error)); + } + callback(null); + }); +} + +function changeLocation(app, oldConfig, progressCallback, callback) { + assert.strictEqual(typeof app, 'object'); + assert.strictEqual(typeof oldConfig, 'object'); + assert.strictEqual(typeof progressCallback, 'function'); + assert.strictEqual(typeof callback, 'function'); + + const locationChanged = oldConfig.fqdn !== app.fqdn; + + async.series([ + progressCallback.bind(null, { percent: 10, message: 'Cleaning up old install' }), + unconfigureReverseProxy.bind(null, app), + stopApp.bind(null, app, progressCallback), + deleteContainers.bind(null, app, { managedOnly: true }), + function (next) { + let obsoleteDomains = oldConfig.alternateDomains.filter(function (o) { + return !app.alternateDomains.some(function (n) { return n.subdomain === o.subdomain && n.domain === o.domain; }); + }); + + if (locationChanged) obsoleteDomains.push({ subdomain: oldConfig.location, domain: oldConfig.domain }); + + if (obsoleteDomains.length === 0) return next(); + + unregisterSubdomains(app, obsoleteDomains, next); + }, + + progressCallback.bind(null, { percent: 30, message: 'Registering subdomains' }), + registerSubdomains.bind(null, app, !locationChanged /* overwrite */), // if location changed, do not overwrite to detect conflicts + + // re-setup addons since they rely on the app's fqdn (e.g oauth) + progressCallback.bind(null, { percent: 50, message: 'Setting up addons' }), + addons.setupAddons.bind(null, app, app.manifest.addons), + + progressCallback.bind(null, { percent: 60, message: 'Creating container' }), + createContainer.bind(null, app), + + runApp.bind(null, app, progressCallback), + + progressCallback.bind(null, { percent: 80, message: 'Waiting for DNS propagation' }), + exports._waitForDnsPropagation.bind(null, app), + + progressCallback.bind(null, { percent: 90, message: 'Configuring reverse proxy' }), + configureReverseProxy.bind(null, app), + + progressCallback.bind(null, { percent: 100, message: 'Done' }), + updateApp.bind(null, app, { installationState: apps.ISTATE_INSTALLED, error: null, health: null }) + ], function seriesDone(error) { + if (error) { + debugApp(app, 'error reconfiguring : %s', error); + return updateApp(app, { installationState: apps.ISTATE_ERROR, error: error.toPlainObject ? error.toPlainObject() : error.message }, callback.bind(null, error)); + } + callback(null); + }); +} + +function migrateDataDir(app, oldConfig, progressCallback, callback) { + assert.strictEqual(typeof app, 'object'); + assert.strictEqual(typeof oldConfig, 'object'); + assert.strictEqual(typeof progressCallback, 'function'); + assert.strictEqual(typeof callback, 'function'); + + const dataDirChanged = oldConfig.dataDir !== app.dataDir; + + async.series([ + progressCallback.bind(null, { percent: 10, message: 'Cleaning up old install' }), + stopApp.bind(null, app, progressCallback), + deleteContainers.bind(null, app, { managedOnly: true }), + + progressCallback.bind(null, { percent: 45, message: 'Ensuring app data directory' }), + createAppDir.bind(null, app), + + // migrate dataDir + function (next) { + if (!dataDirChanged) return next(); + + moveDataDir(app, oldConfig.dataDir, next); + }, + + progressCallback.bind(null, { percent: 60, message: 'Creating container' }), + createContainer.bind(null, app), + + progressCallback.bind(null, { percent: 60, message: 'Starting app' }), + runApp.bind(null, app, progressCallback), + + progressCallback.bind(null, { percent: 100, message: 'Done' }), + updateApp.bind(null, app, { installationState: apps.ISTATE_INSTALLED, error: null, health: null }) + ], function seriesDone(error) { + if (error) { + debugApp(app, 'error reconfiguring : %s', error); + return updateApp(app, { installationState: apps.ISTATE_ERROR, error: error.toPlainObject ? error.toPlainObject() : error.message }, callback.bind(null, error)); + } + callback(null); + }); +} + // note that configure is called after an infra update as well function configure(app, oldConfig, progressCallback, callback) { assert.strictEqual(typeof app, 'object'); @@ -658,7 +779,7 @@ function configure(app, oldConfig, progressCallback, callback) { function (next) { if (!dataDirChanged) return next(); - migrateDataDir(app, oldConfig.dataDir, next); + moveDataDir(app, oldConfig.dataDir, next); }, progressCallback.bind(null, { percent: 60, message: 'Creating container' }), @@ -894,6 +1015,9 @@ function run(appId, args, progressCallback, callback) { switch (app.installationState) { case apps.ISTATE_PENDING_INSTALL: return install(app, args.restoreConfig || {}, progressCallback, callback); case apps.ISTATE_PENDING_CONFIGURE: return configure(app, args.oldConfig, progressCallback, callback); + case apps.ISTATE_PENDING_CREATE_CONTAINER: return create(app, progressCallback, callback); + case apps.ISTATE_PENDING_LOCATION_CHANGE: return changeLocation(app, args.oldConfig, progressCallback, callback); + case apps.ISTATE_PENDING_DATA_DIR_MIGRATION: return migrateDataDir(app, args.oldConfig, progressCallback, callback); case apps.ISTATE_PENDING_UNINSTALL: return uninstall(app, progressCallback, callback); case apps.ISTATE_PENDING_CLONE: return install(app, args.restoreConfig || {}, progressCallback, callback); case apps.ISTATE_PENDING_RESTORE: return install(app, args.restoreConfig || {}, progressCallback, callback); diff --git a/src/routes/apps.js b/src/routes/apps.js index 88e7015a7..8b86ebca6 100644 --- a/src/routes/apps.js +++ b/src/routes/apps.js @@ -14,6 +14,21 @@ exports = module.exports = { getLogStream: getLogStream, listBackups: listBackups, + setAccessRestriction: setAccessRestriction, + setLabel: setLabel, + setTags: setTags, + setIcon: setIcon, + setMemoryLimit: setMemoryLimit, + setAutomaticBackup: setAutomaticBackup, + setAutomaticUpdate: setAutomaticUpdate, + setRobotsTxt: setRobotsTxt, + setCertificate: setCertificate, + setDebugMode: setDebugMode, + setEnvironment: setEnvironment, + setMailbox: setMailbox, + setLocation: setLocation, + setDataDir: setDataDir, + stopApp: stopApp, startApp: startApp, exec: exec, @@ -146,6 +161,203 @@ function installApp(req, res, next) { }); } +function setAccessRestriction(req, res, next) { + assert.strictEqual(typeof req.body, 'object'); + assert.strictEqual(typeof req.params.id, 'string'); + + if (typeof req.body.accessRestriction !== 'object') return next(new HttpError(400, 'accessRestriction must be an object')); + + apps.setAccessRestriction(req.params.id, req.body.accessRestriction, auditSource.fromRequest(req), function (error) { + if (error) return next(toHttpError(error)); + + next(new HttpSuccess(200, {})); + }); +} + +function setLabel(req, res, next) { + assert.strictEqual(typeof req.body, 'object'); + assert.strictEqual(typeof req.params.id, 'string'); + + if (typeof req.body.label !== 'string') return next(new HttpError(400, 'label must be a string')); + + apps.setLabel(req.params.id, req.body.label, auditSource.fromRequest(req), function (error) { + if (error) return next(toHttpError(error)); + + next(new HttpSuccess(200, {})); + }); +} + +function setTags(req, res, next) { + assert.strictEqual(typeof req.body, 'object'); + assert.strictEqual(typeof req.params.id, 'string'); + + if (!Array.isArray(req.body.tags)) return next(new HttpError(400, 'tags must be an array')); + if (req.body.tags.some((t) => typeof t !== 'string')) return next(new HttpError(400, 'tags array must contain strings')); + + apps.setTags(req.params.id, req.body.tags, auditSource.fromRequest(req), function (error) { + if (error) return next(toHttpError(error)); + + next(new HttpSuccess(200, {})); + }); +} + +function setIcon(req, res, next) { + assert.strictEqual(typeof req.body, 'object'); + assert.strictEqual(typeof req.params.id, 'string'); + + if (req.body.icon !== null && typeof req.body.icon !== 'string') return next(new HttpError(400, 'icon is null or a base-64 image string')); + + apps.setIcon(req.params.id, req.body.icon, auditSource.fromRequest(req), function (error) { + if (error) return next(toHttpError(error)); + + next(new HttpSuccess(200, {})); + }); +} + +function setMemoryLimit(req, res, next) { + assert.strictEqual(typeof req.body, 'object'); + assert.strictEqual(typeof req.params.id, 'string'); + + if (typeof req.body.memoryLimit !== 'number') return next(new HttpError(400, 'memoryLimit is not a number')); + + apps.setMemoryLimit(req.params.id, req.body.memoryLimit, auditSource.fromRequest(req), function (error, result) { + if (error) return next(toHttpError(error)); + + next(new HttpSuccess(202, { taskId: result.taskId })); + }); +} + +function setAutomaticBackup(req, res, next) { + assert.strictEqual(typeof req.body, 'object'); + assert.strictEqual(typeof req.params.id, 'string'); + + if (typeof req.body.enable !== 'boolean') return next(new HttpError(400, 'enable must be a boolean')); + + apps.setAutomaticBackup(req.params.id, req.body.enable, auditSource.fromRequest(req), function (error) { + if (error) return next(toHttpError(error)); + + next(new HttpSuccess(200, {})); + }); +} + +function setAutomaticUpdate(req, res, next) { + assert.strictEqual(typeof req.body, 'object'); + assert.strictEqual(typeof req.params.id, 'string'); + + if (typeof req.body.enable !== 'boolean') return next(new HttpError(400, 'enable must be a boolean')); + + apps.setAutomaticUpdate(req.params.id, req.body.enable, auditSource.fromRequest(req), function (error) { + if (error) return next(toHttpError(error)); + + next(new HttpSuccess(200, {})); + }); +} + +function setRobotsTxt(req, res, next) { + assert.strictEqual(typeof req.body, 'object'); + assert.strictEqual(typeof req.params.id, 'string'); + + if (req.body.robotsTxt !== null && typeof req.body.robotsTxt !== 'string') return next(new HttpError(400, 'robotsTxt is not a string')); + + apps.setRobotsTxt(req.params.id, req.body.robotsTxt, auditSource.fromRequest(req), function (error) { + if (error) return next(toHttpError(error)); + + next(new HttpSuccess(200, {})); + }); +} + +function setCertificate(req, res, next) { + assert.strictEqual(typeof req.body, 'object'); + assert.strictEqual(typeof req.params.id, 'string'); + + if (req.body.key !== null && typeof req.body.cert !== 'string') return next(new HttpError(400, 'cert must be a string')); + if (req.body.cert !== null && typeof req.body.key !== 'string') return next(new HttpError(400, 'key must be a string')); + if (req.body.cert && !req.body.key) return next(new HttpError(400, 'key must be provided')); + if (!req.body.cert && req.body.key) return next(new HttpError(400, 'cert must be provided')); + + apps.setCertificate(req.params.id, req.body, auditSource.fromRequest(req), function (error) { + if (error) return next(toHttpError(error)); + + next(new HttpSuccess(200, {})); + }); +} + +function setEnvironment(req, res, next) { + assert.strictEqual(typeof req.body, 'object'); + assert.strictEqual(typeof req.params.id, 'string'); + + if (!req.body.env || typeof req.body.env !== 'object') return next(new HttpError(400, 'env must be an object')); + if (Object.keys(req.body.env).some(function (key) { return typeof req.body.env[key] !== 'string'; })) return next(new HttpError(400, 'env must contain values as strings')); + + apps.setEnvironment(req.params.id, req.body.env, auditSource.fromRequest(req), function (error, result) { + if (error) return next(toHttpError(error)); + + next(new HttpSuccess(202, { taskId: result.taskId })); + }); +} + +function setDebugMode(req, res, next) { + assert.strictEqual(typeof req.body, 'object'); + assert.strictEqual(typeof req.params.id, 'string'); + + if (req.body.debugMode !== null && typeof req.body.debugMode !== 'object') return next(new HttpError(400, 'debugMode must be an object')); + + apps.setDebugMode(req.params.id, req.body.debugMode, auditSource.fromRequest(req), function (error, result) { + if (error) return next(toHttpError(error)); + + next(new HttpSuccess(202, { taskId: result.taskId })); + }); +} + +function setMailbox(req, res, next) { + assert.strictEqual(typeof req.body, 'object'); + assert.strictEqual(typeof req.params.id, 'string'); + + if (req.body.mailboxName !== null && typeof req.body.mailboxName !== 'string') return next(new HttpError(400, 'mailboxName must be a string')); + + apps.setMailbox(req.params.id, req.body.mailboxName, auditSource.fromRequest(req), function (error, result) { + if (error) return next(toHttpError(error)); + + next(new HttpSuccess(202, { taskId: result.taskId })); + }); +} + +function setLocation(req, res, next) { + assert.strictEqual(typeof req.body, 'object'); + assert.strictEqual(typeof req.params.id, 'string'); + + if (!req.body.location) return next(new HttpError(400, 'location is required')); + if (typeof req.body.location !== 'string') return next(new HttpError(400, 'location must be string')); + if (!req.body.domain) return next(new HttpError(400, 'domain is required')); + if (typeof req.body.domain !== 'string') return next(new HttpError(400, 'domain must be string')); + + if ('portBindings' in req.body && typeof req.body.portBindings !== 'object') return next(new HttpError(400, 'portBindings must be an object')); + + if ('alternateDomains' in req.body) { + if (!Array.isArray(req.body.alternateDomains)) return next(new HttpError(400, 'alternateDomains must be an array')); + if (req.body.alternateDomains.some(function (d) { return (typeof d.domain !== 'string' || typeof d.subdomain !== 'string'); })) return next(new HttpError(400, 'alternateDomains array must contain objects with domain and subdomain strings')); + } + + apps.setLocation(req.params.id, req.body, auditSource.fromRequest(req), function (error, result) { + if (error) return next(toHttpError(error)); + + next(new HttpSuccess(202, { taskId: result.taskId })); + }); +} + +function setDataDir(req, res, next) { + assert.strictEqual(typeof req.body, 'object'); + assert.strictEqual(typeof req.params.id, 'string'); + + if (typeof req.body.dataDir !== 'string') return next(new HttpError(400, 'dataDir must be a string')); + + apps.setDataDir(req.params.id, req.body.dataDir, auditSource.fromRequest(req), function (error, result) { + if (error) return next(toHttpError(error)); + + next(new HttpSuccess(202, { taskId: result.taskId })); + }); +} + function configureApp(req, res, next) { assert.strictEqual(typeof req.body, 'object'); assert.strictEqual(typeof req.params.id, 'string'); @@ -268,10 +480,10 @@ function startApp(req, res, next) { debug('Start app id:%s', req.params.id); - apps.start(req.params.id, function (error) { + apps.start(req.params.id, function (error, result) { if (error) return next(toHttpError(error)); - next(new HttpSuccess(202, { })); + next(new HttpSuccess(202, { taskId: result.taskId })); }); } @@ -280,10 +492,10 @@ function stopApp(req, res, next) { debug('Stop app id:%s', req.params.id); - apps.stop(req.params.id, function (error) { + apps.stop(req.params.id, function (error, result) { if (error) return next(toHttpError(error)); - next(new HttpSuccess(202, { })); + next(new HttpSuccess(202, { taskId: result.taskId })); }); } diff --git a/src/routes/test/apps-test.js b/src/routes/test/apps-test.js index 2ede21894..55f3e7ada 100644 --- a/src/routes/test/apps-test.js +++ b/src/routes/test/apps-test.js @@ -3,11 +3,8 @@ /* global it:false */ /* global describe:false */ /* global before:false */ -/* global after:false */ -/* global xit:false */ let apps = require('../../apps.js'), - assert = require('assert'), async = require('async'), child_process = require('child_process'), clients = require('../../clients.js'), @@ -31,8 +28,7 @@ let apps = require('../../apps.js'), settingsdb = require('../../settingsdb.js'), superagent = require('superagent'), tokendb = require('../../tokendb.js'), - url = require('url'), - uuid = require('uuid'); + url = require('url'); var SERVER_URL = 'http://localhost:' + constants.PORT; @@ -72,6 +68,9 @@ var user_1_id = null; var token = null; var token_1 = null; +let KEY, CERT; +let appstoreIconServer = hock.createHock({ throwOnUnmatched: false }); + function checkAddons(appEntry, done) { async.retry({ times: 15, interval: 3000 }, function (callback) { // this was previously written with superagent but it was getting sporadic EPIPE @@ -122,8 +121,26 @@ function checkRedis(containerId, done) { }); } -var imageDeleted; -var imageCreated; +function waitForTask(taskId, callback) { + process.stdout.write('Waiting for task ' + taskId + ' .'); + + async.retry({ times: 50, interval: 4000 }, function (retryCallback) { + superagent.get(SERVER_URL + '/api/v1/tasks/' + taskId) + .query({ access_token: token }) + .end(function (error, result) { + process.stdout.write('.'); + + if (!result || result.statusCode !== 200) return retryCallback(null, new Error('Bad result')); + + if (result.body.active) return retryCallback(new Error('Still active')); + + retryCallback(); + }); + }, function (error, result) { + console.log(); + callback(error || result); + }); +} function waitForSetup(done) { async.retry({ times: 5, interval: 4000 }, function (retryCallback) { @@ -141,8 +158,9 @@ function waitForSetup(done) { function startBox(done) { console.log('Starting box code...'); - imageDeleted = false; - imageCreated = false; + child_process.execSync('openssl req -subj "/CN=*.' + DOMAIN_0.domain + '/O=My Company Name LTD./C=US" -new -newkey rsa:2048 -days 365 -nodes -x509 -keyout /tmp/server.key -out /tmp/server.crt'); + KEY = fs.readFileSync('/tmp/server.key', 'utf8'); + CERT = fs.readFileSync('/tmp/server.crt', 'utf8'); process.env.TEST_CREATE_INFRA = 1; @@ -196,6 +214,15 @@ function startBox(done) { }); }, + function (callback) { + appstoreIconServer + .get('/api/v1/apps/' + APP_STORE_ID + '/versions/' + APP_MANIFEST.version + '/icon') + .replyWithFile(200, path.resolve(__dirname, '../../../assets/avatar.png')); + + var port = parseInt(url.parse(settings.apiServerOrigin()).port, 10); + http.createServer(appstoreIconServer.handler).listen(port, callback); + }, + function (callback) { process.stdout.write('Waiting for platform to be ready...'); async.retry({ times: 500, interval: 1000 }, function (retryCallback) { @@ -218,11 +245,12 @@ function stopBox(done) { delete process.env.TEST_CREATE_INFRA; child_process.execSync('docker ps -qa --filter \'network=cloudron\' | xargs --no-run-if-empty docker rm -f'); + appstoreIconServer.done(); async.series([ database._clear, server.stop, - ldap.stop + ldap.stop, ], done); } @@ -230,905 +258,1076 @@ describe('App API', function () { let taskId = ''; before(startBox); - after(stopBox); - it('app install fails - missing manifest', function (done) { - superagent.post(SERVER_URL + '/api/v1/apps/install') - .query({ access_token: token }) - .end(function (err, res) { - expect(res.statusCode).to.equal(400); - expect(res.body.message).to.eql('appStoreId or manifest is required'); - done(); - }); - }); + describe('Install', function () { + it('app install fails - missing manifest', function (done) { + superagent.post(SERVER_URL + '/api/v1/apps/install') + .query({ access_token: token }) + .end(function (err, res) { + expect(res.statusCode).to.equal(400); + expect(res.body.message).to.eql('appStoreId or manifest is required'); + done(); + }); + }); - it('app install fails - null manifest', function (done) { - superagent.post(SERVER_URL + '/api/v1/apps/install') - .query({ access_token: token }) - .send({ manifest: null }) - .end(function (err, res) { - expect(res.statusCode).to.equal(400); - expect(res.body.message).to.eql('appStoreId or manifest is required'); - done(); - }); - }); + it('app install fails - null manifest', function (done) { + superagent.post(SERVER_URL + '/api/v1/apps/install') + .query({ access_token: token }) + .send({ manifest: null }) + .end(function (err, res) { + expect(res.statusCode).to.equal(400); + expect(res.body.message).to.eql('appStoreId or manifest is required'); + done(); + }); + }); - it('app install fails - bad manifest format', function (done) { - superagent.post(SERVER_URL + '/api/v1/apps/install') - .query({ access_token: token }) - .send({ manifest: 'epic' }) - .end(function (err, res) { - expect(res.statusCode).to.equal(400); - expect(res.body.message).to.eql('manifest must be an object'); - done(); - }); - }); + it('app install fails - bad manifest format', function (done) { + superagent.post(SERVER_URL + '/api/v1/apps/install') + .query({ access_token: token }) + .send({ manifest: 'epic' }) + .end(function (err, res) { + expect(res.statusCode).to.equal(400); + expect(res.body.message).to.eql('manifest must be an object'); + done(); + }); + }); - it('app install fails - empty appStoreId format', function (done) { - superagent.post(SERVER_URL + '/api/v1/apps/install') - .query({ access_token: token }) - .send({ manifest: null, appStoreId: '' }) - .end(function (err, res) { - expect(res.statusCode).to.equal(400); - expect(res.body.message).to.eql('appStoreId or manifest is required'); - done(); - }); - }); + it('app install fails - empty appStoreId format', function (done) { + superagent.post(SERVER_URL + '/api/v1/apps/install') + .query({ access_token: token }) + .send({ manifest: null, appStoreId: '' }) + .end(function (err, res) { + expect(res.statusCode).to.equal(400); + expect(res.body.message).to.eql('appStoreId or manifest is required'); + done(); + }); + }); - it('app install fails - invalid json', function (done) { - superagent.post(SERVER_URL + '/api/v1/apps/install') - .query({ access_token: token }) - .send('garbage') - .end(function (err, res) { - expect(res.statusCode).to.equal(400); - done(); - }); - }); + it('app install fails - invalid json', function (done) { + superagent.post(SERVER_URL + '/api/v1/apps/install') + .query({ access_token: token }) + .send('garbage') + .end(function (err, res) { + expect(res.statusCode).to.equal(400); + done(); + }); + }); - it('app install fails - missing domain', function (done) { - superagent.post(SERVER_URL + '/api/v1/apps/install') - .query({ access_token: token }) - .send({ manifest: APP_MANIFEST, location: 'some', accessRestriction: null }) - .end(function (err, res) { - expect(res.statusCode).to.equal(400); - expect(res.body.message).to.eql('domain is required'); - done(); - }); - }); + it('app install fails - missing domain', function (done) { + superagent.post(SERVER_URL + '/api/v1/apps/install') + .query({ access_token: token }) + .send({ manifest: APP_MANIFEST, location: 'some', accessRestriction: null }) + .end(function (err, res) { + expect(res.statusCode).to.equal(400); + expect(res.body.message).to.eql('domain is required'); + done(); + }); + }); - it('app install fails - non-existing domain', function (done) { - superagent.post(SERVER_URL + '/api/v1/apps/install') - .query({ access_token: token }) - .send({ manifest: APP_MANIFEST, location: 'some', accessRestriction: null, domain: 'doesnotexist.com' }) - .end(function (err, res) { - expect(res.statusCode).to.equal(404); - expect(res.body.message).to.eql('No such domain'); - done(); - }); - }); + it('app install fails - non-existing domain', function (done) { + superagent.post(SERVER_URL + '/api/v1/apps/install') + .query({ access_token: token }) + .send({ manifest: APP_MANIFEST, location: 'some', accessRestriction: null, domain: 'doesnotexist.com' }) + .end(function (err, res) { + expect(res.statusCode).to.equal(404); + expect(res.body.message).to.eql('No such domain'); + done(); + }); + }); - it('app install fails - invalid location type', function (done) { - superagent.post(SERVER_URL + '/api/v1/apps/install') - .query({ access_token: token }) - .send({ manifest: APP_MANIFEST, location: 42, accessRestriction: null, domain: DOMAIN_0.domain }) - .end(function (err, res) { - expect(res.statusCode).to.equal(400); - expect(res.body.message).to.eql('location is required'); - done(); - }); - }); + it('app install fails - invalid location type', function (done) { + superagent.post(SERVER_URL + '/api/v1/apps/install') + .query({ access_token: token }) + .send({ manifest: APP_MANIFEST, location: 42, accessRestriction: null, domain: DOMAIN_0.domain }) + .end(function (err, res) { + expect(res.statusCode).to.equal(400); + expect(res.body.message).to.eql('location is required'); + done(); + }); + }); - it('app install fails - reserved admin location', function (done) { - superagent.post(SERVER_URL + '/api/v1/apps/install') - .query({ access_token: token }) - .send({ manifest: APP_MANIFEST, location: 'my', accessRestriction: null, domain: DOMAIN_0.domain }) - .end(function (err, res) { - expect(res.statusCode).to.equal(400); - expect(res.body.message).to.contain('my is reserved'); - done(); - }); - }); + it('app install fails - reserved admin location', function (done) { + superagent.post(SERVER_URL + '/api/v1/apps/install') + .query({ access_token: token }) + .send({ manifest: APP_MANIFEST, location: 'my', accessRestriction: null, domain: DOMAIN_0.domain }) + .end(function (err, res) { + expect(res.statusCode).to.equal(400); + expect(res.body.message).to.contain('my is reserved'); + done(); + }); + }); - it('app install fails - reserved smtp location', function (done) { - superagent.post(SERVER_URL + '/api/v1/apps/install') - .query({ access_token: token }) - .send({ manifest: APP_MANIFEST, location: constants.SMTP_LOCATION, accessRestriction: null, domain: DOMAIN_0.domain }) - .end(function (err, res) { - expect(res.statusCode).to.equal(400); - expect(res.body.message).to.contain(constants.SMTP_LOCATION + ' is reserved'); - done(); - }); - }); + it('app install fails - reserved smtp location', function (done) { + superagent.post(SERVER_URL + '/api/v1/apps/install') + .query({ access_token: token }) + .send({ manifest: APP_MANIFEST, location: constants.SMTP_LOCATION, accessRestriction: null, domain: DOMAIN_0.domain }) + .end(function (err, res) { + expect(res.statusCode).to.equal(400); + expect(res.body.message).to.contain(constants.SMTP_LOCATION + ' is reserved'); + done(); + }); + }); - it('app install fails - portBindings must be object', function (done) { - superagent.post(SERVER_URL + '/api/v1/apps/install') - .query({ access_token: token }) - .send({ manifest: APP_MANIFEST, location: APP_LOCATION, portBindings: 23, accessRestriction: null, domain: DOMAIN_0.domain }) - .end(function (err, res) { - expect(res.statusCode).to.equal(400); - expect(res.body.message).to.contain('portBindings must be an object'); - done(); - }); - }); + it('app install fails - portBindings must be object', function (done) { + superagent.post(SERVER_URL + '/api/v1/apps/install') + .query({ access_token: token }) + .send({ manifest: APP_MANIFEST, location: APP_LOCATION, portBindings: 23, accessRestriction: null, domain: DOMAIN_0.domain }) + .end(function (err, res) { + expect(res.statusCode).to.equal(400); + expect(res.body.message).to.contain('portBindings must be an object'); + done(); + }); + }); - it('app install fails - accessRestriction is required', function (done) { - superagent.post(SERVER_URL + '/api/v1/apps/install') - .query({ access_token: token }) - .send({ manifest: APP_MANIFEST, location: APP_LOCATION, portBindings: {}, domain: DOMAIN_0.domain }) - .end(function (err, res) { - expect(res.statusCode).to.equal(400); - expect(res.body.message).to.contain('accessRestriction is required'); - done(); - }); - }); + it('app install fails - accessRestriction is required', function (done) { + superagent.post(SERVER_URL + '/api/v1/apps/install') + .query({ access_token: token }) + .send({ manifest: APP_MANIFEST, location: APP_LOCATION, portBindings: {}, domain: DOMAIN_0.domain }) + .end(function (err, res) { + expect(res.statusCode).to.equal(400); + expect(res.body.message).to.contain('accessRestriction is required'); + done(); + }); + }); - it('app install fails - accessRestriction type is wrong', function (done) { - superagent.post(SERVER_URL + '/api/v1/apps/install') - .query({ access_token: token }) - .send({ manifest: APP_MANIFEST, location: APP_LOCATION, portBindings: {}, accessRestriction: '', domain: DOMAIN_0.domain }) - .end(function (err, res) { - expect(res.statusCode).to.equal(400); - expect(res.body.message).to.contain('accessRestriction is required'); - done(); - }); - }); + it('app install fails - accessRestriction type is wrong', function (done) { + superagent.post(SERVER_URL + '/api/v1/apps/install') + .query({ access_token: token }) + .send({ manifest: APP_MANIFEST, location: APP_LOCATION, portBindings: {}, accessRestriction: '', domain: DOMAIN_0.domain }) + .end(function (err, res) { + expect(res.statusCode).to.equal(400); + expect(res.body.message).to.contain('accessRestriction is required'); + done(); + }); + }); - it('app install fails for non admin', function (done) { - superagent.post(SERVER_URL + '/api/v1/apps/install') - .query({ access_token: token_1 }) - .send({ manifest: APP_MANIFEST, location: APP_LOCATION, portBindings: null, accessRestriction: null, domain: DOMAIN_0.domain }) - .end(function (err, res) { - expect(res.statusCode).to.equal(403); - done(); - }); - }); + it('app install fails for non admin', function (done) { + superagent.post(SERVER_URL + '/api/v1/apps/install') + .query({ access_token: token_1 }) + .send({ manifest: APP_MANIFEST, location: APP_LOCATION, portBindings: null, accessRestriction: null, domain: DOMAIN_0.domain }) + .end(function (err, res) { + expect(res.statusCode).to.equal(403); + done(); + }); + }); - it('app install fails because manifest download fails', function (done) { - var fake = nock(settings.apiServerOrigin()).get('/api/v1/apps/test').reply(404, {}); - - superagent.post(SERVER_URL + '/api/v1/apps/install') - .query({ access_token: token }) - .send({ appStoreId: APP_STORE_ID, location: APP_LOCATION, portBindings: null, domain: DOMAIN_0.domain, accessRestriction: { users: [ 'someuser' ], groups: [] } }) - .end(function (err, res) { - expect(res.statusCode).to.equal(404); - expect(fake.isDone()).to.be.ok(); - done(); - }); - }); - - it('app install fails due to purchase failure', function (done) { - var fake1 = nock(settings.apiServerOrigin()).get('/api/v1/apps/test').reply(200, { manifest: APP_MANIFEST }); - - superagent.post(SERVER_URL + '/api/v1/apps/install') - .query({ access_token: token }) - .send({ appStoreId: APP_STORE_ID, location: APP_LOCATION, domain: DOMAIN_0.domain, portBindings: null, accessRestriction: null }) - .end(function (err, res) { - expect(res.statusCode).to.equal(424); - expect(fake1.isDone()).to.be.ok(); - done(); - }); - }); - - it('app install succeeds with purchase', function (done) { - var fake2 = nock(settings.apiServerOrigin()).get('/api/v1/apps/' + APP_STORE_ID).reply(200, { manifest: APP_MANIFEST }); - var fake3 = nock(settings.apiServerOrigin()).post(function (uri) { return uri.indexOf('/api/v1/cloudronapps') >= 0; }, (body) => body.appstoreId === APP_STORE_ID && body.manifestId === APP_MANIFEST.id && body.appId).reply(201, { }); - - settingsdb.set(settings.CLOUDRON_TOKEN_KEY, USER_1_APPSTORE_TOKEN, function (error) { - if (error) return done(error); + it('app install fails because manifest download fails', function (done) { + var fake = nock(settings.apiServerOrigin()).get('/api/v1/apps/test').reply(404, {}); superagent.post(SERVER_URL + '/api/v1/apps/install') .query({ access_token: token }) - .send({ appStoreId: APP_STORE_ID, location: APP_LOCATION, domain: DOMAIN_0.domain, portBindings: null, accessRestriction: { users: [ 'someuser' ], groups: [] } }) + .send({ appStoreId: APP_STORE_ID, location: APP_LOCATION, portBindings: null, domain: DOMAIN_0.domain, accessRestriction: { users: [ 'someuser' ], groups: [] } }) + .end(function (err, res) { + expect(res.statusCode).to.equal(404); + expect(fake.isDone()).to.be.ok(); + done(); + }); + }); + + it('app install fails due to purchase failure', function (done) { + var fake1 = nock(settings.apiServerOrigin()).get('/api/v1/apps/test').reply(200, { manifest: APP_MANIFEST }); + + superagent.post(SERVER_URL + '/api/v1/apps/install') + .query({ access_token: token }) + .send({ appStoreId: APP_STORE_ID, location: APP_LOCATION, domain: DOMAIN_0.domain, portBindings: null, accessRestriction: null }) + .end(function (err, res) { + expect(res.statusCode).to.equal(424); + expect(fake1.isDone()).to.be.ok(); + done(); + }); + }); + + it('app install succeeds with purchase', function (done) { + var fake1 = nock(settings.apiServerOrigin()).get('/api/v1/apps/' + APP_STORE_ID).reply(200, { manifest: APP_MANIFEST }); + var fake2 = nock(settings.apiServerOrigin()).post(function (uri) { return uri.indexOf('/api/v1/cloudronapps') >= 0; }, (body) => body.appstoreId === APP_STORE_ID && body.manifestId === APP_MANIFEST.id && body.appId).reply(201, { }); + + settingsdb.set(settings.CLOUDRON_TOKEN_KEY, USER_1_APPSTORE_TOKEN, function (error) { + if (error) return done(error); + + superagent.post(SERVER_URL + '/api/v1/apps/install') + .query({ access_token: token }) + .send({ appStoreId: APP_STORE_ID, location: APP_LOCATION, domain: DOMAIN_0.domain, portBindings: { ECHO_SERVER_PORT: 7171 }, accessRestriction: { users: [ 'someuser' ], groups: [] } }) + .end(function (err, res) { + expect(res.statusCode).to.equal(202); + expect(res.body.id).to.be.a('string'); + APP_ID = res.body.id; + expect(fake1.isDone()).to.be.ok(); + expect(fake2.isDone()).to.be.ok(); + taskId = res.body.taskId; + done(); + }); + }); + }); + + it('app install fails because of conflicting location', function (done) { + superagent.post(SERVER_URL + '/api/v1/apps/install') + .query({ access_token: token }) + .send({ manifest: APP_MANIFEST, location: APP_LOCATION, domain: DOMAIN_0.domain, portBindings: null, accessRestriction: null }) + .end(function (err, res) { + expect(res.statusCode).to.equal(409); + done(); + }); + }); + }); + + describe('get', function () { + it('can get app status', function (done) { + superagent.get(SERVER_URL + '/api/v1/apps/' + APP_ID) + .query({ access_token: token }) + .end(function (err, res) { + expect(res.statusCode).to.equal(200); + expect(res.body.id).to.eql(APP_ID); + expect(res.body.installationState).to.be.ok(); + done(); + }); + }); + + it('cannot get invalid app status', function (done) { + superagent.get(SERVER_URL + '/api/v1/apps/kubachi') + .query({ access_token: token }) + .end(function (err, res) { + expect(res.statusCode).to.equal(404); + done(); + }); + }); + + it('can get all apps', function (done) { + superagent.get(SERVER_URL + '/api/v1/apps') + .query({ access_token: token }) + .end(function (err, res) { + expect(res.statusCode).to.equal(200); + expect(res.body.apps).to.be.an('array'); + expect(res.body.apps[0].id).to.eql(APP_ID); + expect(res.body.apps[0].installationState).to.be.ok(); + done(); + }); + }); + + it('non admin cannot see the app due to accessRestriction', function (done) { + superagent.get(SERVER_URL + '/api/v1/apps') + .query({ access_token: token_1 }) + .end(function (err, res) { + expect(res.statusCode).to.equal(200); + expect(res.body.apps).to.be.an('array'); + expect(res.body.apps.length).to.equal(0); + done(); + }); + }); + }); + + describe('post installation', function () { + let appResult, appEntry; + + it('task completed', function (done) { + waitForTask(taskId, done); + }); + + it('app is running', function (callback) { + superagent.get(SERVER_URL + '/api/v1/apps/' + APP_ID) + .query({ access_token: token }) + .end(function (err, res) { + if (res.statusCode !== 200) return callback(new Error('Response error')); + if (res.body.installationState === apps.ISTATE_INSTALLED) { appResult = res.body; return callback(); } + if (res.body.installationState === apps.ISTATE_ERROR) return callback(new Error('Install error')); + + callback(new Error('Unknown app state:' + res.body.installationState)); + }); + }); + + it('can get app', function (done) { + apps.get(appResult.id, function (error, app) { + expect(!error).to.be.ok(); + expect(app).to.be.an('object'); + appEntry = app; + done(); + }); + }); + + it('container created', function (done) { + expect(appResult.containerId).to.be(undefined); + expect(appEntry.containerId).to.be.ok(); + docker.getContainer(appEntry.containerId).inspect(function (error, data) { + expect(error).to.not.be.ok(); + expect(data.Config.ExposedPorts['7777/tcp']).to.eql({ }); + expect(data.Config.Env).to.contain('CLOUDRON_WEBADMIN_ORIGIN=' + settings.adminOrigin()); + expect(data.Config.Env).to.contain('CLOUDRON_API_ORIGIN=' + settings.adminOrigin()); + expect(data.Config.Env).to.contain('CLOUDRON=1'); + expect(data.Config.Env).to.contain('CLOUDRON_APP_ORIGIN=https://' + APP_LOCATION + '.' + DOMAIN_0.domain); + expect(data.Config.Env).to.contain('CLOUDRON_APP_DOMAIN=' + APP_LOCATION + '.' + DOMAIN_0.domain); + // Hostname must not be set of app fqdn or app location! + expect(data.Config.Hostname).to.not.contain(APP_LOCATION); + expect(data.Config.Env).to.contain('ECHO_SERVER_PORT=7171'); + expect(data.HostConfig.PortBindings['7778/tcp'][0].HostPort).to.eql('7171'); + done(); + }); + }); + + it('nginx config', function (done) { + expect(fs.existsSync(paths.NGINX_APPCONFIG_DIR + '/' + APP_LOCATION + '.conf')); + done(); + }); + + it('volume created', function (done) { + expect(fs.existsSync(paths.APPS_DATA_DIR + '/' + APP_ID)); + let volume = docker.getVolume(APP_ID + '-localstorage'); + volume.inspect(function (error, volume) { + expect(error).to.be(null); + expect(volume.Labels.appId).to.eql(APP_ID); + expect(volume.Options.device).to.eql(paths.APPS_DATA_DIR + '/' + APP_ID + '/data'); + done(); + }); + }); + + it('http is up and running', function (done) { + var tryCount = 20; + + // TODO what does that check for? + expect(appResult.httpPort).to.be(undefined); + + (function healthCheck() { + superagent.get('http://localhost:' + appEntry.httpPort + appResult.manifest.healthCheckPath) + .end(function (err, res) { + if (err || res.statusCode !== 200) { + if (--tryCount === 0) { + console.log('Unable to curl http://localhost:' + appEntry.httpPort + appResult.manifest.healthCheckPath); + return done(new Error('Timedout')); + } + return setTimeout(healthCheck, 2000); + } + + expect(!err).to.be.ok(); + expect(res.statusCode).to.equal(200); + done(); + }); + })(); + }); + + it('tcp port mapping works', function (done) { + var client = net.connect(7171); + client.on('data', function (data) { + expect(data.toString()).to.eql('ECHO_SERVER_PORT=7171'); + done(); + }); + client.on('error', done); + }); + + it('running container has volume mounted', function (done) { + docker.getContainer(appEntry.containerId).inspect(function (error, data) { + expect(error).to.not.be.ok(); + expect(data.Mounts.filter(function (mount) { return mount.Destination === '/app/data'; })[0].Type).to.eql('volume'); + + done(); + }); + }); + + it('app responds to http request', function (done) { + superagent.get('http://localhost:' + appEntry.httpPort).end(function (err, res) { + expect(!err).to.be.ok(); + expect(res.statusCode).to.equal(200); + done(); + }); + }); + + it('oauth addon config', function (done) { + var appContainer = docker.getContainer(appEntry.containerId); + appContainer.inspect(function (error, data) { + expect(error).to.not.be.ok(); + + clients.getByAppIdAndType(APP_ID, clients.TYPE_OAUTH, function (error, client) { + expect(error).to.not.be.ok(); + expect(client.id.length).to.be(40); // cid- + 32 hex chars (128 bits) + 4 hyphens + expect(client.clientSecret.length).to.be(256); // 32 hex chars (8 * 256 bits) + expect(data.Config.Env).to.contain('CLOUDRON_OAUTH_CLIENT_ID=' + client.id); + expect(data.Config.Env).to.contain('CLOUDRON_OAUTH_CLIENT_SECRET=' + client.clientSecret); + done(); + }); + }); + }); + + it('installation - app can populate addons', function (done) { + superagent.get(`http://localhost:${appEntry.httpPort}/populate_addons`).end(function (error, res) { + expect(!error).to.be.ok(); + expect(res.statusCode).to.equal(200); + for (var key in res.body) { + expect(res.body[key]).to.be('OK'); + } + done(); + }); + }); + + it('installation - app can check addons', function (done) { + console.log('This test can take a while as it waits for scheduler addon to tick 3'); + checkAddons(appEntry, done); + }); + + it('installation - redis addon created', function (done) { + checkRedis('redis-' + APP_ID, done); + }); + }); + + describe('logs', function () { + it('logs - stdout and stderr', function (done) { + superagent.get(SERVER_URL + '/api/v1/apps/' + APP_ID + '/logs') + .query({ access_token: token }) + .buffer(false) + .end(function (err, res) { + var data = ''; + res.on('data', function (d) { data += d.toString('utf8'); }); + res.on('end', function () { + expect(data.length).to.not.be(0); + done(); + }); + res.on('error', done); + }); + }); + + it('logStream - requires event-stream accept header', function (done) { + superagent.get(SERVER_URL + '/api/v1/apps/' + APP_ID + '/logstream') + .query({ access_token: token, fromLine: 0 }) + .end(function (err, res) { + expect(res.statusCode).to.be(400); + done(); + }); + }); + + it('logStream - stream logs', function (done) { + var options = { + port: constants.PORT, host: 'localhost', path: '/api/v1/apps/' + APP_ID + '/logstream?access_token=' + token, + headers: { 'Accept': 'text/event-stream', 'Connection': 'keep-alive' } + }; + + // superagent doesn't work. maybe https://github.com/visionmedia/superagent/issues/420 + var req = http.get(options, function (res) { + var data = ''; + res.on('data', function (d) { data += d.toString('utf8'); }); + setTimeout(function checkData() { + expect(data.length).to.not.be(0); + data.split('\n').forEach(function (line) { + if (line.indexOf('id: ') !== 0) return; + expect(parseInt(line.substr(4), 10)).to.be.a('number'); // timestamp + }); + + req.abort(); + done(); + }, 1000); + res.on('error', done); + }); + + req.on('error', done); + }); + }); + + describe('configure (db fields)', function () { + it('fails for no label', function (done) { + superagent.post(SERVER_URL + '/api/v1/apps/' + APP_ID + '/configure/label') + .query({ access_token: token }) + .end(function (err, res) { + expect(res.statusCode).to.equal(400); + done(); + }); + }); + + it('fails for invalid label', function (done) { + superagent.post(SERVER_URL + '/api/v1/apps/' + APP_ID + '/configure/label') + .query({ access_token: token }) + .send({ label: null }) + .end(function (err, res) { + expect(res.statusCode).to.equal(400); + done(); + }); + }); + + it('can set the label', function (done) { + superagent.post(SERVER_URL + '/api/v1/apps/' + APP_ID + '/configure/label') + .query({ access_token: token }) + .send({ label: 'LABEL'}) + .end(function (err, res) { + expect(res.statusCode).to.equal(200); + done(); + }); + }); + + it('did set the label', function (done) { + superagent.get(SERVER_URL + '/api/v1/apps/' + APP_ID) + .query({ access_token: token }) + .end(function (err, res) { + expect(res.statusCode).to.equal(200); + expect(res.body.label).to.be('LABEL'); + done(); + }); + }); + + ///////////// tags + it('fails for no tags', function (done) { + superagent.post(SERVER_URL + '/api/v1/apps/' + APP_ID + '/configure/tags') + .query({ access_token: token }) + .end(function (err, res) { + expect(res.statusCode).to.equal(400); + done(); + }); + }); + + it('fails for null tags', function (done) { + superagent.post(SERVER_URL + '/api/v1/apps/' + APP_ID + '/configure/tags') + .query({ access_token: token }) + .send({ tags: null }) + .end(function (err, res) { + expect(res.statusCode).to.equal(400); + done(); + }); + }); + + it('fails for empty tag', function (done) { + superagent.post(SERVER_URL + '/api/v1/apps/' + APP_ID + '/configure/tags') + .query({ access_token: token }) + .send({ tags: ['tag1', '', 'tag2'] }) + .end(function (err, res) { + expect(res.statusCode).to.equal(400); + done(); + }); + }); + + it('fails for non-string tag', function (done) { + superagent.post(SERVER_URL + '/api/v1/apps/' + APP_ID + '/configure/tags') + .query({ access_token: token }) + .send({ tags: ['tag1', 123, 'tag2'] }) + .end(function (err, res) { + expect(res.statusCode).to.equal(400); + done(); + }); + }); + + it('can set the tags', function (done) { + superagent.post(SERVER_URL + '/api/v1/apps/' + APP_ID + '/configure/tags') + .query({ access_token: token }) + .send({ tags: [ 'tag1', 'tag2' ] }) + .end(function (err, res) { + expect(res.statusCode).to.equal(200); + done(); + }); + }); + + it('did set the tags', function (done) { + superagent.get(SERVER_URL + '/api/v1/apps/' + APP_ID) + .query({ access_token: token }) + .end(function (err, res) { + expect(res.statusCode).to.equal(200); + expect(res.body.tags).to.eql([ 'tag1', 'tag2' ]); + done(); + }); + }); + + ///////////// icon + it('fails for no icon', function (done) { + superagent.post(SERVER_URL + '/api/v1/apps/' + APP_ID + '/configure/icon') + .query({ access_token: token }) + .end(function (err, res) { + expect(res.statusCode).to.equal(400); + done(); + }); + }); + + it('fails for invalid icon', function (done) { + superagent.post(SERVER_URL + '/api/v1/apps/' + APP_ID + '/configure/icon') + .query({ access_token: token }) + .send({ icon: 'something non base64' }) + .end(function (err, res) { + expect(res.statusCode).to.equal(400); + done(); + }); + }); + + it('can set the icon', function (done) { + superagent.post(SERVER_URL + '/api/v1/apps/' + APP_ID + '/configure/icon') + .query({ access_token: token }) + .send({ icon: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQMAAAAl21bKAAAAA1BMVEX/TQBcNTh/AAAAAXRSTlPM0jRW/QAAAApJREFUeJxjYgAAAAYAAzY3fKgAAAAASUVORK5CYII=' }) + .end(function (err, res) { + expect(res.statusCode).to.equal(200); + done(); + }); + }); + + it('did set the icon', function (done) { + superagent.get(SERVER_URL + '/api/v1/apps/' + APP_ID + '/icon') + .query({ access_token: token }) + .end(function (err, res) { + expect(res.statusCode).to.equal(200); // response is some PNG + done(); + }); + }); + + it('can reset the icon', function (done) { + superagent.post(SERVER_URL + '/api/v1/apps/' + APP_ID + '/configure/icon') + .query({ access_token: token }) + .send({ icon: null }) + .end(function (err, res) { + expect(res.statusCode).to.equal(200); + done(); + }); + }); + + ////////////// automatic updates + it('can disable automatic updates', function (done) { + superagent.post(SERVER_URL + '/api/v1/apps/' + APP_ID + '/configure/automatic_update') + .query({ access_token: token }) + .send({ enable: false }) + .end(function (err, res) { + expect(res.statusCode).to.equal(200); + done(); + }); + }); + + it('did disable automatic updates', function (done) { + superagent.get(SERVER_URL + '/api/v1/apps/' + APP_ID) + .query({ access_token: token }) + .end(function (err, res) { + expect(res.statusCode).to.equal(200); + expect(res.body.enableAutomaticUpdate).to.be(false); + done(); + }); + }); + + it('can disable automatic backups', function (done) { + superagent.post(SERVER_URL + '/api/v1/apps/' + APP_ID + '/configure/automatic_backup') + .query({ access_token: token }) + .send({ enable: false }) + .end(function (err, res) { + expect(res.statusCode).to.equal(200); + done(); + }); + }); + + it('did disable automatic backups', function (done) { + superagent.get(SERVER_URL + '/api/v1/apps/' + APP_ID) + .query({ access_token: token }) + .end(function (err, res) { + expect(res.statusCode).to.equal(200); + expect(res.body.enableBackup).to.be(false); + done(); + }); + }); + + ////////////// access restriction + it('cannot set bad accessRestriction', function (done) { + superagent.post(SERVER_URL + '/api/v1/apps/' + APP_ID + '/configure/access_restriction') + .query({ access_token: token }) + .send({ accessRestriction: false }) + .end(function (err, res) { + expect(res.statusCode).to.equal(400); + done(); + }); + }); + + it('can clear accessRestriction', function (done) { + superagent.post(SERVER_URL + '/api/v1/apps/' + APP_ID + '/configure/access_restriction') + .query({ access_token: token }) + .send({ accessRestriction: null }) + .end(function (err, res) { + expect(res.statusCode).to.equal(200); + done(); + }); + }); + + it('can set accessRestriction', function (done) { + superagent.post(SERVER_URL + '/api/v1/apps/' + APP_ID + '/configure/access_restriction') + .query({ access_token: token }) + .send({ accessRestriction: { users: [ 'someuserid' ], groups: [ 'somegroupid' ] } }) + .end(function (err, res) { + expect(res.statusCode).to.equal(200); + done(); + }); + }); + + /////////////// cert + it('cannot set only the cert, no key', function (done) { + superagent.post(SERVER_URL + '/api/v1/apps/' + APP_ID + '/configure/cert') + .query({ access_token: token }) + .send({ cert: CERT }) + .end(function (err, res) { + expect(res.statusCode).to.equal(400); + done(); + }); + }); + + it('cannot reconfigure app with only the key, no cert', function (done) { + superagent.post(SERVER_URL + '/api/v1/apps/' + APP_ID + '/configure/cert') + .query({ access_token: token }) + .send({ key: KEY }) + .end(function (err, res) { + expect(res.statusCode).to.equal(400); + done(); + }); + }); + + it('cannot set invalid cert', function (done) { + superagent.post(SERVER_URL + '/api/v1/apps/' + APP_ID + '/configure/cert') + .query({ access_token: token }) + .send({ cert: 'x' + CERT, key: KEY }) + .end(function (err, res) { + expect(res.statusCode).to.equal(400); + done(); + }); + }); + + it('can set cert', function (done) { + superagent.post(SERVER_URL + '/api/v1/apps/' + APP_ID + '/configure/cert') + .query({ access_token: token }) + .send({ cert: CERT, key: KEY }) + .end(function (err, res) { + expect(res.statusCode).to.equal(200); + done(); + }); + }); + + it('can reset cert', function (done) { + superagent.post(SERVER_URL + '/api/v1/apps/' + APP_ID + '/configure/cert') + .query({ access_token: token }) + .send({ cert: null, key: null }) + .end(function (err, res) { + expect(res.statusCode).to.equal(200); + done(); + }); + }); + }); + + describe('memory limit', function () { + it('fails for no memory limit', function (done) { + superagent.post(SERVER_URL + '/api/v1/apps/' + APP_ID + '/configure/memory_limit') + .query({ access_token: token }) + .end(function (err, res) { + expect(res.statusCode).to.equal(400); + done(); + }); + }); + + it('fails for invalid memory limit', function (done) { + superagent.post(SERVER_URL + '/api/v1/apps/' + APP_ID + '/configure/memory_limit') + .query({ access_token: token }) + .send({ memoryLimit: -34 }) + .end(function (err, res) { + expect(res.statusCode).to.equal(400); + done(); + }); + }); + + it('can set the memory limit', function (done) { + superagent.post(SERVER_URL + '/api/v1/apps/' + APP_ID + '/configure/memory_limit') + .query({ access_token: token }) + .send({ memoryLimit: 512 * 1024 * 1024 }) .end(function (err, res) { expect(res.statusCode).to.equal(202); - expect(res.body.id).to.be.a('string'); - APP_ID = res.body.id; - expect(fake2.isDone()).to.be.ok(); - expect(fake3.isDone()).to.be.ok(); taskId = res.body.taskId; done(); }); }); - }); - it('app install fails because of conflicting location', function (done) { - superagent.post(SERVER_URL + '/api/v1/apps/install') - .query({ access_token: token }) - .send({ manifest: APP_MANIFEST, location: APP_LOCATION, domain: DOMAIN_0.domain, portBindings: null, accessRestriction: null }) - .end(function (err, res) { - expect(res.statusCode).to.equal(409); - done(); + it('wait for memory limit', function (done) { + waitForTask(taskId, done); + }); + + it('did set memory limit', function (done) { + apps.get(APP_ID, function (error, app) { + if (error) return done(error); + + docker.getContainer(app.containerId).inspect(function (error, data) { + expect(error).to.not.be.ok(); + expect(data.HostConfig.Memory).to.be(512 * 1024 * 1024/2); + done(); + }); }); + }); }); - it('can get app status', function (done) { - superagent.get(SERVER_URL + '/api/v1/apps/' + APP_ID) - .query({ access_token: token }) - .end(function (err, res) { - expect(res.statusCode).to.equal(200); - expect(res.body.id).to.eql(APP_ID); - expect(res.body.installationState).to.be.ok(); - done(); + describe('configure location', function () { + it('cannot reconfigure app with missing domain', function (done) { + superagent.post(SERVER_URL + '/api/v1/apps/' + APP_ID + '/configure/location') + .query({ access_token: token }) + .send({ location: 'hellothre' }) + .end(function (err, res) { + expect(res.statusCode).to.equal(400); + done(); + }); + }); + + it('cannot reconfigure app with bad location', function (done) { + superagent.post(SERVER_URL + '/api/v1/apps/' + APP_ID + '/configure/location') + .query({ access_token: token }) + .send({ location: 1234, domain: DOMAIN_0.domain }) + .end(function (err, res) { + expect(res.statusCode).to.equal(400); + done(); + }); + }); + + it('non admin cannot reconfigure app', function (done) { + superagent.post(SERVER_URL + '/api/v1/apps/' + APP_ID + '/configure/location') + .query({ access_token: token_1 }) + .send({ location: APP_LOCATION_NEW, domain: DOMAIN_0.domain, portBindings: { ECHO_SERVER_PORT: 7172 }, accessRestriction: null }) + .end(function (err, res) { + expect(res.statusCode).to.equal(403); + done(); + }); + }); + + it('can change location', function (done) { + superagent.post(SERVER_URL + '/api/v1/apps/' + APP_ID + '/configure/location') + .query({ access_token: token }) + .send({ location: APP_LOCATION_NEW, domain: DOMAIN_0.domain, portBindings: { ECHO_SERVER_PORT: 7172 } }) + .end(function (err, res) { + expect(res.statusCode).to.equal(202); + taskId = res.body.taskId; + done(); + }); + }); + + it('wait for task', function (done) { + waitForTask(taskId, done); + }); + + it('did change env vars', function (done) { + apps.get(APP_ID, function (error, app) { + if (error) return done(error); + + docker.getContainer(app.containerId).inspect(function (error, data) { + expect(error).to.not.be.ok(); + expect(data.Config.Env).to.contain('CLOUDRON_APP_ORIGIN=https://' + APP_LOCATION_NEW + '.' + DOMAIN_0.domain); + expect(data.Config.Env).to.contain('CLOUDRON_APP_DOMAIN=' + APP_LOCATION_NEW + '.' + DOMAIN_0.domain); + expect(data.Config.Hostname).to.not.contain(APP_LOCATION_NEW); + expect(data.Config.Env).to.contain('ECHO_SERVER_PORT=7172'); + expect(data.HostConfig.PortBindings['7778/tcp'][0].HostPort).to.eql('7172'); + done(); + }); }); - }); + }); - it('cannot get invalid app status', function (done) { - superagent.get(SERVER_URL + '/api/v1/apps/kubachi') - .query({ access_token: token }) - .end(function (err, res) { - expect(res.statusCode).to.equal(404); - done(); + it('port mapping works after reconfiguration', function (done) { + setTimeout(function () { + var client = net.connect(7172); + client.on('data', function (data) { + expect(data.toString()).to.eql('ECHO_SERVER_PORT=7172'); + done(); + }); + client.on('error', done); + }, 4000); + }); + + it('app can check addons', function (done) { + console.log('This test can take a while as it waits for scheduler addon to tick 4'); + + apps.get(APP_ID, function (error, app) { + if (error) return done(error); + + checkAddons(app, done); }); + }); }); - it('can get all apps', function (done) { - superagent.get(SERVER_URL + '/api/v1/apps') - .query({ access_token: token }) - .end(function (err, res) { - expect(res.statusCode).to.equal(200); - expect(res.body.apps).to.be.an('array'); - expect(res.body.apps[0].id).to.eql(APP_ID); - expect(res.body.apps[0].installationState).to.be.ok(); - done(); - }); - }); + describe('start/stop', function () { + it('non admin cannot stop app', function (done) { + superagent.post(SERVER_URL + '/api/v1/apps/' + APP_ID + '/stop') + .query({ access_token: token_1 }) + .end(function (err, res) { + expect(res.statusCode).to.equal(403); + done(); + }); + }); - it('non admin cannot see the app due to accessRestriction', function (done) { - superagent.get(SERVER_URL + '/api/v1/apps') - .query({ access_token: token_1 }) - .end(function (err, res) { - expect(res.statusCode).to.equal(200); - expect(res.body.apps).to.be.an('array'); - expect(res.body.apps.length).to.equal(0); - done(); - }); - }); + it('can stop app', function (done) { + superagent.post(SERVER_URL + '/api/v1/apps/' + APP_ID + '/stop') + .query({ access_token: token }) + .end(function (err, res) { + expect(res.statusCode).to.equal(202); + taskId = res.body.taskId; + done(); + }); + }); - it('cannot uninstall invalid app', function (done) { - superagent.post(SERVER_URL + '/api/v1/apps/whatever/uninstall') - .query({ access_token: token }) - .end(function (err, res) { - expect(res.statusCode).to.equal(404); - done(); - }); - }); + it('wait for task', function (done) { + waitForTask(taskId, done); + }); - it('non admin cannot uninstall app', function (done) { - superagent.post(SERVER_URL + '/api/v1/apps/' + APP_ID + '/uninstall') - .query({ access_token: token_1 }) - .end(function (err, res) { - expect(res.statusCode).to.equal(403); - done(); - }); - }); + it('did stop the app', function (done) { + apps.get(APP_ID, function (error, app) { + if (error) return done(error); - it('can stop the task', function (done) { - superagent.post(SERVER_URL + '/api/v1/tasks/' + taskId + '/stop') - .query({ access_token: token }) - .end(function (err, res) { - expect(res.statusCode).to.equal(204); - setTimeout(done, 4000); // wait for it to really die - }); - }); + superagent.get('http://localhost:' + app.httpPort + APP_MANIFEST.healthCheckPath).end(function (err) { + if (!err || err.code !== 'ECONNREFUSED') return done(new Error('App has not died')); - it('can uninstall app', function (done) { - var fake1 = nock(settings.apiServerOrigin()).get(function (uri) { return uri.indexOf('/api/v1/cloudronapps/') >= 0; }).reply(200, { }); - var fake2 = nock(settings.apiServerOrigin()).delete(function (uri) { return uri.indexOf('/api/v1/cloudronapps/') >= 0; }).reply(204, { }); + // wait for app status to be updated + superagent.get(SERVER_URL + '/api/v1/apps/' + APP_ID).query({ access_token: token }).end(function (error, result) { + if (error || result.statusCode !== 200 || result.body.runState !== 'stopped') return done(new Error('App is not in stopped state')); - superagent.post(SERVER_URL + '/api/v1/apps/' + APP_ID + '/uninstall') - .query({ access_token: token }) - .end(function (err, res) { - expect(res.statusCode).to.equal(202); - expect(fake1.isDone()).to.be.ok(); - expect(fake2.isDone()).to.be.ok(); - done(); - }); - }); - - it('app install succeeds again', function (done) { - var fake1 = nock(settings.apiServerOrigin()).get('/api/v1/apps/' + APP_STORE_ID).reply(200, { manifest: APP_MANIFEST }); - var fake2 = nock(settings.apiServerOrigin()).post(function (uri) { return uri.indexOf('/api/v1/cloudronapps') >= 0; }, (body) => body.appstoreId === APP_STORE_ID && body.manifestId === APP_MANIFEST.id && body.appId).reply(201, { }); - - superagent.post(SERVER_URL + '/api/v1/apps/install') - .query({ access_token: token }) - .send({ appStoreId: APP_STORE_ID, location: APP_LOCATION_2, domain: DOMAIN_0.domain, portBindings: null, accessRestriction: null }) - .end(function (err, res) { - expect(res.statusCode).to.equal(202); - expect(res.body.id).to.be.a('string'); - APP_ID = res.body.id; - expect(fake1.isDone()).to.be.ok(); - expect(fake2.isDone()).to.be.ok(); - done(); - }); - }); - - it('app install fails with developer token', function (done) { - superagent.post(SERVER_URL + '/api/v1/developer/login') - .send({ username: USERNAME, password: PASSWORD }) - .end(function (error, result) { - expect(error).to.not.be.ok(); - expect(result.statusCode).to.equal(200); - expect(new Date(result.body.expires).toString()).to.not.be('Invalid Date'); - expect(result.body.accessToken).to.be.a('string'); - - // overwrite non dev token - token = result.body.accessToken; - - superagent.post(SERVER_URL + '/api/v1/apps/install') - .query({ access_token: token }) - .send({ manifest: APP_MANIFEST, location: APP_LOCATION+APP_LOCATION, domain: DOMAIN_0.domain, portBindings: null, accessRestriction: null }) - .end(function (err, res) { - expect(res.statusCode).to.equal(424); // appstore purchase external error done(); }); - }); - }); -}); - -describe('App installation', function () { - var apiHockInstance = hock.createHock({ throwOnUnmatched: false }); - var validCert1, validKey1; - - before(function (done) { - child_process.execSync('openssl req -subj "/CN=*.' + DOMAIN_0.domain + '/O=My Company Name LTD./C=US" -new -newkey rsa:2048 -days 365 -nodes -x509 -keyout /tmp/server.key -out /tmp/server.crt'); - validKey1 = fs.readFileSync('/tmp/server.key', 'utf8'); - validCert1 = fs.readFileSync('/tmp/server.crt', 'utf8'); - - APP_ID = uuid.v4(); - - async.series([ - startBox, - - function (callback) { - apiHockInstance - .get('/api/v1/apps/' + APP_STORE_ID + '/versions/' + APP_MANIFEST.version + '/icon') - .replyWithFile(200, path.resolve(__dirname, '../../../assets/avatar.png')); - - var port = parseInt(url.parse(settings.apiServerOrigin()).port, 10); - http.createServer(apiHockInstance.handler).listen(port, callback); - }, - - function (callback) { - settingsdb.set(settings.CLOUDRON_TOKEN_KEY, USER_1_APPSTORE_TOKEN, function (error) { - if (error) return callback(error); - - callback(); }); - } - ], done); - }); + }); + }); - var appResult = null, appEntry = null; + it('nonadmin cannot start app', function (done) { + superagent.post(SERVER_URL + '/api/v1/apps/' + APP_ID + '/start') + .query({ access_token: token_1 }) + .end(function (err, res) { + expect(res.statusCode).to.equal(403); + done(); + }); + }); - it('can install test app', function (done) { - var fake1 = nock(settings.apiServerOrigin()).get('/api/v1/apps/' + APP_STORE_ID).reply(200, { manifest: APP_MANIFEST }); - var fake2 = nock(settings.apiServerOrigin()).post(function (uri) { return uri.indexOf('/api/v1/cloudronapps') >= 0; }, (body) => body.appstoreId === APP_STORE_ID && body.manifestId === APP_MANIFEST.id && body.appId).reply(201, { }); - - var count = 0; - function checkInstallStatus() { - superagent.get(SERVER_URL + '/api/v1/apps/' + APP_ID) + it('can start app', function (done) { + superagent.post(SERVER_URL + '/api/v1/apps/' + APP_ID + '/start') .query({ access_token: token }) .end(function (err, res) { - expect(res.statusCode).to.equal(200); - - if (res.body.installationState === apps.ISTATE_INSTALLED) { appResult = res.body; return done(null); } - if (res.body.installationState === apps.ISTATE_ERROR) return done(new Error('Install error')); - if (++count > 500) return done(new Error('Timedout')); - - setTimeout(checkInstallStatus, 1000); - }); - } - - superagent.post(SERVER_URL + '/api/v1/apps/install') - .query({ access_token: token }) - .send({ appStoreId: APP_STORE_ID, location: APP_LOCATION, domain: DOMAIN_0.domain, portBindings: { ECHO_SERVER_PORT: 7171 }, accessRestriction: null }) - .end(function (err, res) { - expect(res.statusCode).to.equal(202); - expect(fake1.isDone()).to.be.ok(); - expect(fake2.isDone()).to.be.ok(); - APP_ID = res.body.id; - - checkInstallStatus(); - }); - }); - - xit('installation - image created', function (done) { - expect(imageCreated).to.be.ok(); - done(); - }); - - it('installation - can get app', function (done) { - apps.get(appResult.id, function (error, app) { - expect(!error).to.be.ok(); - expect(app).to.be.an('object'); - appEntry = app; - done(); - }); - }); - - it('installation - container created', function (done) { - expect(appResult.containerId).to.be(undefined); - expect(appEntry.containerId).to.be.ok(); - docker.getContainer(appEntry.containerId).inspect(function (error, data) { - expect(error).to.not.be.ok(); - expect(data.Config.ExposedPorts['7777/tcp']).to.eql({ }); - expect(data.Config.Env).to.contain('CLOUDRON_WEBADMIN_ORIGIN=' + settings.adminOrigin()); - expect(data.Config.Env).to.contain('CLOUDRON_API_ORIGIN=' + settings.adminOrigin()); - expect(data.Config.Env).to.contain('CLOUDRON=1'); - expect(data.Config.Env).to.contain('CLOUDRON_APP_ORIGIN=https://' + APP_LOCATION + '.' + DOMAIN_0.domain); - expect(data.Config.Env).to.contain('CLOUDRON_APP_DOMAIN=' + APP_LOCATION + '.' + DOMAIN_0.domain); - // Hostname must not be set of app fqdn or app location! - expect(data.Config.Hostname).to.not.contain(APP_LOCATION); - expect(data.Config.Env).to.contain('ECHO_SERVER_PORT=7171'); - expect(data.HostConfig.PortBindings['7778/tcp'][0].HostPort).to.eql('7171'); - done(); - }); - }); - - it('installation - nginx config', function (done) { - expect(fs.existsSync(paths.NGINX_APPCONFIG_DIR + '/' + APP_LOCATION + '.conf')); - done(); - }); - - it('installation - registered subdomain', function (done) { - // this is checked in unregister subdomain testcase - done(); - }); - - it('installation - volume created', function (done) { - expect(fs.existsSync(paths.APPS_DATA_DIR + '/' + APP_ID)); - let volume = docker.getVolume(APP_ID + '-localstorage'); - volume.inspect(function (error, volume) { - expect(error).to.be(null); - expect(volume.Labels.appId).to.eql(APP_ID); - expect(volume.Options.device).to.eql(paths.APPS_DATA_DIR + '/' + APP_ID + '/data'); - done(); - }); - }); - - it('installation - http is up and running', function (done) { - var tryCount = 20; - - // TODO what does that check for? - expect(appResult.httpPort).to.be(undefined); - - (function healthCheck() { - superagent.get('http://localhost:' + appEntry.httpPort + appResult.manifest.healthCheckPath) - .end(function (err, res) { - if (err || res.statusCode !== 200) { - if (--tryCount === 0) { - console.log('Unable to curl http://localhost:' + appEntry.httpPort + appResult.manifest.healthCheckPath); - return done(new Error('Timedout')); - } - return setTimeout(healthCheck, 2000); - } - - expect(!err).to.be.ok(); - expect(res.statusCode).to.equal(200); + expect(res.statusCode).to.equal(202); + taskId = res.body.taskId; done(); }); - })(); - }); - - it('installation - tcp port mapping works', function (done) { - var client = net.connect(7171); - client.on('data', function (data) { - expect(data.toString()).to.eql('ECHO_SERVER_PORT=7171'); - done(); - }); - client.on('error', done); - }); - - it('installation - running container has volume mounted', function (done) { - docker.getContainer(appEntry.containerId).inspect(function (error, data) { - expect(error).to.not.be.ok(); - expect(data.Mounts.filter(function (mount) { return mount.Destination === '/app/data'; })[0].Type).to.eql('volume'); - - done(); - }); - }); - - it('installation - app responds to http request', function (done) { - superagent.get('http://localhost:' + appEntry.httpPort).end(function (err, res) { - expect(!err).to.be.ok(); - expect(res.statusCode).to.equal(200); - done(); - }); - }); - - it('installation - oauth addon config', function (done) { - var appContainer = docker.getContainer(appEntry.containerId); - appContainer.inspect(function (error, data) { - expect(error).to.not.be.ok(); - - clients.getByAppIdAndType(APP_ID, clients.TYPE_OAUTH, function (error, client) { - expect(error).to.not.be.ok(); - expect(client.id.length).to.be(40); // cid- + 32 hex chars (128 bits) + 4 hyphens - expect(client.clientSecret.length).to.be(256); // 32 hex chars (8 * 256 bits) - expect(data.Config.Env).to.contain('CLOUDRON_OAUTH_CLIENT_ID=' + client.id); - expect(data.Config.Env).to.contain('CLOUDRON_OAUTH_CLIENT_SECRET=' + client.clientSecret); - done(); - }); - }); - }); - - it('installation - app can populate addons', function (done) { - superagent.get(`http://localhost:${appEntry.httpPort}/populate_addons`).end(function (error, res) { - expect(!error).to.be.ok(); - expect(res.statusCode).to.equal(200); - for (var key in res.body) { - expect(res.body[key]).to.be('OK'); - } - done(); - }); - }); - - it('installation - app can check addons', function (done) { - console.log('This test can take a while as it waits for scheduler addon to tick 3'); - checkAddons(appEntry, done); - }); - - it('installation - redis addon created', function (done) { - checkRedis('redis-' + APP_ID, done); - }); - - it('logs - stdout and stderr', function (done) { - superagent.get(SERVER_URL + '/api/v1/apps/' + APP_ID + '/logs') - .query({ access_token: token }) - .buffer(false) - .end(function (err, res) { - var data = ''; - res.on('data', function (d) { data += d.toString('utf8'); }); - res.on('end', function () { - expect(data.length).to.not.be(0); - done(); - }); - res.on('error', done); - }); - }); - - it('logStream - requires event-stream accept header', function (done) { - superagent.get(SERVER_URL + '/api/v1/apps/' + APP_ID + '/logstream') - .query({ access_token: token, fromLine: 0 }) - .end(function (err, res) { - expect(res.statusCode).to.be(400); - done(); - }); - }); - - it('logStream - stream logs', function (done) { - var options = { - port: constants.PORT, host: 'localhost', path: '/api/v1/apps/' + APP_ID + '/logstream?access_token=' + token, - headers: { 'Accept': 'text/event-stream', 'Connection': 'keep-alive' } - }; - - // superagent doesn't work. maybe https://github.com/visionmedia/superagent/issues/420 - var req = http.get(options, function (res) { - var data = ''; - res.on('data', function (d) { data += d.toString('utf8'); }); - setTimeout(function checkData() { - expect(data.length).to.not.be(0); - data.split('\n').forEach(function (line) { - if (line.indexOf('id: ') !== 0) return; - expect(parseInt(line.substr(4), 10)).to.be.a('number'); // timestamp - }); - - req.abort(); - done(); - }, 1000); - res.on('error', done); }); - req.on('error', done); - }); + it('wait for app to start', function (done) { + waitForTask(taskId, function () { setTimeout(done, 5000); }); // give app 5 seconds to start + }); - it('non admin cannot stop app', function (done) { - superagent.post(SERVER_URL + '/api/v1/apps/' + APP_ID + '/stop') - .query({ access_token: token_1 }) - .end(function (err, res) { - expect(res.statusCode).to.equal(403); - done(); - }); - }); + it('did start the app', function (done) { + apps.get(APP_ID, function (error, app) { + if (error) return done(error); - it('can stop app', function (done) { - superagent.post(SERVER_URL + '/api/v1/apps/' + APP_ID + '/stop') - .query({ access_token: token }) - .end(function (err, res) { - expect(res.statusCode).to.equal(202); - done(); - }); - }); - - it('did stop the app', function (done) { - function waitForAppToDie() { - superagent.get('http://localhost:' + appEntry.httpPort + appResult.manifest.healthCheckPath).end(function (err) { - if (!err || err.code !== 'ECONNREFUSED') return setTimeout(waitForAppToDie, 500); - - // wait for app status to be updated - superagent.get(SERVER_URL + '/api/v1/apps/' + APP_ID).query({ access_token: token }).end(function (error, result) { - if (error || result.statusCode !== 200 || result.body.runState !== 'stopped') return setTimeout(waitForAppToDie, 500); - done(); - }); - }); - } - - waitForAppToDie(); - }); - - it('nonadmin cannot start app', function (done) { - superagent.post(SERVER_URL + '/api/v1/apps/' + APP_ID + '/start') - .query({ access_token: token_1 }) - .end(function (err, res) { - expect(res.statusCode).to.equal(403); - done(); - }); - }); - - it('can start app', function (done) { - superagent.post(SERVER_URL + '/api/v1/apps/' + APP_ID + '/start') - .query({ access_token: token }) - .end(function (err, res) { - expect(res.statusCode).to.equal(202); - done(); - }); - }); - - it('did start the app', function (done) { - var count = 0; - function checkStartState() { - superagent.get('http://localhost:' + appEntry.httpPort + appResult.manifest.healthCheckPath) - .end(function (err, res) { - if (res && res.statusCode === 200) return done(); - if (++count > 50) return done(new Error('Timedout')); - setTimeout(checkStartState, 500); - }); - } - - checkStartState(); - }); - - it('installation - app can check addons', function (done) { - console.log('This test can take a while as it waits for scheduler addon to tick 2'); - checkAddons(appEntry, done); - }); - - function checkConfigureStatus(count, done) { - assert.strictEqual(typeof count, 'number'); - assert.strictEqual(typeof done, 'function'); - - superagent.get(SERVER_URL + '/api/v1/apps/' + APP_ID) - .query({ access_token: token }) - .end(function (err, res) { - expect(res.statusCode).to.equal(200); - if (res.body.installationState === apps.ISTATE_INSTALLED) { appResult = res.body; expect(appResult).to.be.ok(); return done(null); } - if (res.body.installationState === apps.ISTATE_ERROR) return done(new Error('Install error')); - if (++count > 50) return done(new Error('Timedout')); - setTimeout(checkConfigureStatus.bind(null, count, done), 1000); - }); - } - - it('cannot reconfigure app with missing domain', function (done) { - superagent.post(SERVER_URL + '/api/v1/apps/' + APP_ID + '/configure') - .query({ access_token: token }) - .send({ location: 'hellothre' }) - .end(function (err, res) { - expect(res.statusCode).to.equal(400); - done(); - }); - }); - - it('cannot reconfigure app with bad location', function (done) { - superagent.post(SERVER_URL + '/api/v1/apps/' + APP_ID + '/configure') - .query({ access_token: token }) - .send({ location: 1234, domain: DOMAIN_0.domain }) - .end(function (err, res) { - expect(res.statusCode).to.equal(400); - done(); - }); - }); - - it('cannot reconfigure app with bad accessRestriction', function (done) { - superagent.post(SERVER_URL + '/api/v1/apps/' + APP_ID + '/configure') - .query({ access_token: token }) - .send({ accessRestriction: false }) - .end(function (err, res) { - expect(res.statusCode).to.equal(400); - done(); - }); - }); - - it('cannot reconfigure app with only the cert, no key', function (done) { - superagent.post(SERVER_URL + '/api/v1/apps/' + APP_ID + '/configure') - .query({ access_token: token }) - .send({ cert: validCert1 }) - .end(function (err, res) { - expect(res.statusCode).to.equal(400); - done(); - }); - }); - - it('cannot reconfigure app with only the key, no cert', function (done) { - superagent.post(SERVER_URL + '/api/v1/apps/' + APP_ID + '/configure') - .query({ access_token: token }) - .send({ key: validKey1 }) - .end(function (err, res) { - expect(res.statusCode).to.equal(400); - done(); - }); - }); - - it('cannot reconfigure app with cert not being a string', function (done) { - superagent.post(SERVER_URL + '/api/v1/apps/' + APP_ID + '/configure') - .query({ access_token: token }) - .send({ cert: 1234, key: validKey1 }) - .end(function (err, res) { - expect(res.statusCode).to.equal(400); - done(); - }); - }); - - it('cannot reconfigure app with key not being a string', function (done) { - superagent.post(SERVER_URL + '/api/v1/apps/' + APP_ID + '/configure') - .query({ access_token: token }) - .send({ cert: validCert1, key: 1234 }) - .end(function (err, res) { - expect(res.statusCode).to.equal(400); - done(); - }); - }); - - it('cannot reconfigure app with invalid tags', function (done) { - superagent.post(SERVER_URL + '/api/v1/apps/' + APP_ID + '/configure') - .query({ access_token: token }) - .send({ tags: 'foobar' }) - .end(function (err, res) { - expect(res.statusCode).to.equal(400); - - superagent.post(SERVER_URL + '/api/v1/apps/' + APP_ID + '/configure') - .query({ access_token: token }) - .send({ tags: ['hello', '', 'there' ] }) + superagent.get('http://localhost:' + app.httpPort + APP_MANIFEST.healthCheckPath) .end(function (err, res) { - expect(res.statusCode).to.equal(400); - - superagent.post(SERVER_URL + '/api/v1/apps/' + APP_ID + '/configure') - .query({ access_token: token }) - .send({ tags: ['hello', 1234, 'there' ] }) - .end(function (err, res) { - expect(res.statusCode).to.equal(400); - done(); - }); + if (res && res.statusCode === 200) return done(); + done(new Error('app is not running')); }); }); - }); - - it('non admin cannot reconfigure app', function (done) { - superagent.post(SERVER_URL + '/api/v1/apps/' + APP_ID + '/configure') - .query({ access_token: token_1 }) - .send({ location: APP_LOCATION_NEW, domain: DOMAIN_0.domain, portBindings: { ECHO_SERVER_PORT: 7172 }, accessRestriction: null }) - .end(function (err, res) { - expect(res.statusCode).to.equal(403); - done(); - }); - }); - - it('can reconfigure app', function (done) { - superagent.post(SERVER_URL + '/api/v1/apps/' + APP_ID + '/configure') - .query({ access_token: token }) - .send({ location: APP_LOCATION_NEW, domain: DOMAIN_0.domain, portBindings: { ECHO_SERVER_PORT: 7172 } }) - .end(function (err, res) { - expect(res.statusCode).to.equal(202); - checkConfigureStatus(0, done); - }); - }); - - it('changed container id after reconfigure', function (done) { - var oldContainerId = appEntry.containerId; - apps.get(appResult.id, function (error, app) { - expect(!error).to.be.ok(); - expect(app).to.be.an('object'); - appEntry = app; - expect(appEntry.containerId).to.not.be(oldContainerId); - done(); }); }); - it('port mapping works after reconfiguration', function (done) { - setTimeout(function () { - var client = net.connect(7172); - client.on('data', function (data) { - expect(data.toString()).to.eql('ECHO_SERVER_PORT=7172'); + describe('uninstall', function () { + it('cannot uninstall invalid app', function (done) { + superagent.post(SERVER_URL + '/api/v1/apps/whatever/uninstall') + .query({ access_token: token }) + .end(function (err, res) { + expect(res.statusCode).to.equal(404); + done(); + }); + }); + + it('non admin cannot uninstall app', function (done) { + superagent.post(SERVER_URL + '/api/v1/apps/' + APP_ID + '/uninstall') + .query({ access_token: token_1 }) + .end(function (err, res) { + expect(res.statusCode).to.equal(403); + done(); + }); + }); + + it('can uninstall app', function (done) { + var fake1 = nock(settings.apiServerOrigin()).get(function (uri) { return uri.indexOf('/api/v1/cloudronapps/') >= 0; }).reply(200, { }); + var fake2 = nock(settings.apiServerOrigin()).delete(function (uri) { return uri.indexOf('/api/v1/cloudronapps/') >= 0; }).reply(204, { }); + + superagent.post(SERVER_URL + '/api/v1/apps/' + APP_ID + '/uninstall') + .query({ access_token: token }) + .end(function (err, res) { + expect(res.statusCode).to.equal(202); + taskId = res.body.taskId; + expect(fake1.isDone()).to.be.ok(); + expect(fake2.isDone()).to.be.ok(); + done(); + }); + }); + }); + + describe('post uninstall', function () { + let appEntry; + + it('can get app', function (done) { + apps.get(APP_ID, function (error, app) { + expect(!error).to.be.ok(); + expect(app).to.be.an('object'); + appEntry = app; done(); }); - client.on('error', done); - }, 2000); - }); + }); - it('reconfiguration - redis addon recreated', function (done) { - checkRedis('redis-' + APP_ID, done); - }); + it('did uninstall the app', function (done) { + waitForTask(taskId, done); + }); - it('installation - app can check addons', function (done) { - console.log('This test can take a while as it waits for scheduler addon to tick 4'); - checkAddons(appEntry, done); - }); - - it('can reconfigure app with custom certificate', function (done) { - superagent.post(SERVER_URL + '/api/v1/apps/' + APP_ID + '/configure') - .query({ access_token: token }) - .send({ location: APP_LOCATION_NEW, domain: DOMAIN_0.domain, portBindings: { ECHO_SERVER_PORT: 7172 }, accessRestriction: null, cert: validCert1, key: validKey1 }) - .end(function (err, res) { - expect(res.statusCode).to.equal(202); - checkConfigureStatus(0, done); - }); - }); - - it('can uninstall app', function (done) { - var fake1 = nock(settings.apiServerOrigin()).get(function (uri) { return uri.indexOf('/api/v1/cloudronapps/') >= 0; }).reply(200, { }); - var fake2 = nock(settings.apiServerOrigin()).delete(function (uri) { return uri.indexOf('/api/v1/cloudronapps/') >= 0; }).reply(204, { }); - - var count = 0; - function checkUninstallStatus() { + it('app is gone', function (done) { superagent.get(SERVER_URL + '/api/v1/apps/' + APP_ID) .query({ access_token: token }) .end(function (err, res) { - if (res) console.log('Uninstall progress', res.body.installationState, res.body.errorMessage); - if (res.statusCode === 404) return done(null); - if (++count > 50) return done(new Error('Timedout')); - setTimeout(checkUninstallStatus, 1000); + done(new Error('App is still there')); }); - } + }); - superagent.post(SERVER_URL + '/api/v1/apps/' + APP_ID + '/uninstall') - .query({ access_token: token }) - .end(function (err, res) { - expect(res.statusCode).to.equal(202); - - expect(fake1.isDone()).to.be.ok(); - expect(fake2.isDone()).to.be.ok(); - - checkUninstallStatus(); + it('container destroyed', function (done) { + docker.getContainer(appEntry.containerId).inspect(function (error, data) { + expect(error).to.be.ok(); + expect(data).to.not.be.ok(); + done(); }); - }); + }); - it('uninstalled - container destroyed', function (done) { - docker.getContainer(appEntry.containerId).inspect(function (error, data) { - expect(error).to.be.ok(); - expect(data).to.not.be.ok(); + it('volume destroyed', function (done) { + expect(!fs.existsSync(paths.APPS_DATA_DIR + '/' + APP_ID)); done(); }); + + it('removed nginx', function (done) { + expect(!fs.existsSync(paths.NGINX_APPCONFIG_DIR + '/' + APP_LOCATION + '.conf')); + done(); + }); + + it('removed redis addon', function (done) { + docker.getContainer('redis-' + APP_ID).inspect(function (error) { + expect(error).to.be.ok(); + done(); + }); + }); }); - xit('uninstalled - image destroyed', function (done) { - expect(imageDeleted).to.be.ok(); - done(); - }); + describe('not sure what this is', function () { + it('app install succeeds again', function (done) { + var fake1 = nock(settings.apiServerOrigin()).get('/api/v1/apps/' + APP_STORE_ID).reply(200, { manifest: APP_MANIFEST }); + var fake2 = nock(settings.apiServerOrigin()).post(function (uri) { return uri.indexOf('/api/v1/cloudronapps') >= 0; }, (body) => body.appstoreId === APP_STORE_ID && body.manifestId === APP_MANIFEST.id && body.appId).reply(201, { }); - it('uninstalled - volume destroyed', function (done) { - expect(!fs.existsSync(paths.APPS_DATA_DIR + '/' + APP_ID)); - done(); - }); + superagent.post(SERVER_URL + '/api/v1/apps/install') + .query({ access_token: token }) + .send({ appStoreId: APP_STORE_ID, location: APP_LOCATION_2, domain: DOMAIN_0.domain, portBindings: null, accessRestriction: null }) + .end(function (err, res) { + expect(res.statusCode).to.equal(202); + expect(res.body.id).to.be.a('string'); + APP_ID = res.body.id; + expect(fake1.isDone()).to.be.ok(); + expect(fake2.isDone()).to.be.ok(); + done(); + }); + }); - it('uninstalled - unregistered subdomain', function (done) { - apiHockInstance.done(function (error) { // checks if all the apiHockServer APIs were called - expect(!error).to.be.ok(); - done(); + it('app install fails with developer token', function (done) { + superagent.post(SERVER_URL + '/api/v1/developer/login') + .send({ username: USERNAME, password: PASSWORD }) + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(result.statusCode).to.equal(200); + expect(new Date(result.body.expires).toString()).to.not.be('Invalid Date'); + expect(result.body.accessToken).to.be.a('string'); + + // overwrite non dev token + token = result.body.accessToken; + + superagent.post(SERVER_URL + '/api/v1/apps/install') + .query({ access_token: token }) + .send({ manifest: APP_MANIFEST, location: APP_LOCATION+APP_LOCATION, domain: DOMAIN_0.domain, portBindings: null, accessRestriction: null }) + .end(function (err, res) { + expect(res.statusCode).to.equal(424); // appstore purchase external error + done(); + }); + }); }); }); - it('uninstalled - removed nginx', function (done) { - expect(!fs.existsSync(paths.NGINX_APPCONFIG_DIR + '/' + APP_LOCATION + '.conf')); - done(); + describe('the end', function () { + // this is here so we can debug things if tests fail + it('can stop box', stopBox); }); - - it('uninstalled - removed redis addon', function (done) { - docker.getContainer('redis-' + APP_ID).inspect(function (error) { - expect(error).to.be.ok(); - done(); - }); - }); - - // this is here so that --bail does not stop the box code - it('stop box', stopBox); }); diff --git a/src/server.js b/src/server.js index 219db1566..825da57e5 100644 --- a/src/server.js +++ b/src/server.js @@ -226,7 +226,23 @@ function initializeExpressSync() { router.post('/api/v1/apps/install', appsManageScope, routes.apps.installApp); router.post('/api/v1/apps/:id/uninstall', appsManageScope, routes.apps.uninstallApp); + router.post('/api/v1/apps/:id/configure', appsManageScope, routes.apps.configureApp); + router.post('/api/v1/apps/:id/configure/access_restriction', appsManageScope, routes.apps.setAccessRestriction); + router.post('/api/v1/apps/:id/configure/label', appsManageScope, routes.apps.setLabel); + router.post('/api/v1/apps/:id/configure/tags', appsManageScope, routes.apps.setTags); + router.post('/api/v1/apps/:id/configure/icon', appsManageScope, routes.apps.setIcon); + router.post('/api/v1/apps/:id/configure/memory_limit', appsManageScope, routes.apps.setMemoryLimit); + router.post('/api/v1/apps/:id/configure/automatic_backup', appsManageScope, routes.apps.setAutomaticBackup); + router.post('/api/v1/apps/:id/configure/automatic_update', appsManageScope, routes.apps.setAutomaticUpdate); + router.post('/api/v1/apps/:id/configure/robots_txt', appsManageScope, routes.apps.setRobotsTxt); + router.post('/api/v1/apps/:id/configure/cert', appsManageScope, routes.apps.setCertificate); + router.post('/api/v1/apps/:id/configure/debug_mode', appsManageScope, routes.apps.setDebugMode); + router.post('/api/v1/apps/:id/configure/mailbox', appsManageScope, routes.apps.setMailbox); + router.post('/api/v1/apps/:id/configure/env', appsManageScope, routes.apps.setEnvironment); + router.post('/api/v1/apps/:id/configure/data_dir', appsManageScope, routes.apps.setDataDir); + router.post('/api/v1/apps/:id/configure/location', appsManageScope, routes.apps.setLocation); + router.post('/api/v1/apps/:id/update', appsManageScope, routes.apps.updateApp); router.post('/api/v1/apps/:id/restore', appsManageScope, routes.apps.restoreApp); router.post('/api/v1/apps/:id/backup', appsManageScope, routes.apps.backupApp);