diff --git a/.nycrc b/.nycrc index 147cb57c0..0f0a40d4b 100644 --- a/.nycrc +++ b/.nycrc @@ -113,6 +113,11 @@ "!**/gpii/node_modules/userListeners/src/listeners.js", "!**/gpii/node_modules/userListeners/src/pcsc.js", "!**/gpii/node_modules/userListeners/src/usb.js", + "!**/gpii/node_modules/gpii-iod/src/installOnDemand.js", + "!**/gpii/node_modules/gpii-iod/src/iodSettingsHandler.js", + "!**/gpii/node_modules/gpii-iod/src/packageDataSource.js", + "!**/gpii/node_modules/gpii-iod/src/packageInstaller.js", + "!**/gpii/node_modules/gpii-iod/src/packages.js", "testData", "tests", "reports", @@ -125,7 +130,7 @@ "gpii.js", "Gruntfile.js" ], - "reporter": "none", + "reporter": "lcov", "report-dir": "reports", "temp-directory": "coverage", "clean": false diff --git a/gpii/configs/shared/gpii.config.development.base.local.json5 b/gpii/configs/shared/gpii.config.development.base.local.json5 index b02f6036e..b59986dd7 100644 --- a/gpii/configs/shared/gpii.config.development.base.local.json5 +++ b/gpii/configs/shared/gpii.config.development.base.local.json5 @@ -29,6 +29,7 @@ "%flowManager/configs/gpii.flowManager.config.local.base.json5", "%preferencesServer/configs/gpii.preferencesServer.config.base.json5", "%flatMatchMaker/configs/gpii.flatMatchMaker.config.base.json5", - "%gpii-universal/gpii/configs/shared/gpii.config.couch.development.base.json5" + "%gpii-universal/gpii/configs/shared/gpii.config.couch.development.base.json5", + "%gpii-iod/configs/gpii.iod.config.development.json" ] } diff --git a/gpii/node_modules/eventLog/src/metrics.js b/gpii/node_modules/eventLog/src/metrics.js index e2dc8e1ab..86ca81011 100644 --- a/gpii/node_modules/eventLog/src/metrics.js +++ b/gpii/node_modules/eventLog/src/metrics.js @@ -110,7 +110,7 @@ fluid.defaults("gpii.metrics.lifecycle", { } }, listeners: { - "{lifecycleManager}.events.onCreate": { + "onCreate": { namespace: "trackPrefsSetChange", listener: "gpii.metrics.trackPrefsSetChange", args: ["{that}", "{lifecycleManager}"] @@ -222,7 +222,7 @@ gpii.metrics.preferenceChanged = function (that, current, previous) { fluid.each(changedPreferences, function (value, name) { that.logMetric("preference", { name: name, - newValue: value.toString() + setTo: fluid.isPrimitive(value) ? value.toString() : value }); }); } diff --git a/gpii/node_modules/eventLog/test/metricsTests.js b/gpii/node_modules/eventLog/test/metricsTests.js index b118d94dd..7cea8abf1 100644 --- a/gpii/node_modules/eventLog/test/metricsTests.js +++ b/gpii/node_modules/eventLog/test/metricsTests.js @@ -69,7 +69,7 @@ gpii.tests.metrics.preferenceChangedTestData = fluid.freezeRecursive({ }, expect: { name: "pref1", - newValue: "changed value1" + setTo: "changed value1" } }, "changed 2/2": { @@ -83,10 +83,10 @@ gpii.tests.metrics.preferenceChangedTestData = fluid.freezeRecursive({ }, expect: [{ name: "pref1", - newValue: "changed value1" + setTo: "changed value1" }, { name: "pref2", - newValue: "changed value2" + setTo: "changed value2" }] }, "changed 2/3": { @@ -102,10 +102,10 @@ gpii.tests.metrics.preferenceChangedTestData = fluid.freezeRecursive({ }, expect: [{ name: "pref1", - newValue: "changed value1" + setTo: "changed value1" }, { name: "pref2", - newValue: "changed value2" + setTo: "changed value2" }] }, "add 1+1": { @@ -118,7 +118,7 @@ gpii.tests.metrics.preferenceChangedTestData = fluid.freezeRecursive({ }, expect: [{ name: "pref2", - newValue: "new value2" + setTo: "new value2" }] }, "add+change": { @@ -131,10 +131,10 @@ gpii.tests.metrics.preferenceChangedTestData = fluid.freezeRecursive({ }, expect: [{ name: "pref1", - newValue: "changed value1" + setTo: "changed value1" }, { name: "pref2", - newValue: "new value2" + setTo: "new value2" }] }, "remove 1-1": { @@ -168,10 +168,10 @@ gpii.tests.metrics.preferenceChangedTestData = fluid.freezeRecursive({ }, expect: [{ name: "pref2", - newValue: "changed value2" + setTo: "changed value2" }, { name: "pref4", - newValue: "new value4" + setTo: "new value4" }] } }); diff --git a/gpii/node_modules/flowManager/src/FlowManager.js b/gpii/node_modules/flowManager/src/FlowManager.js index 61fc345d3..6a2a4ae62 100644 --- a/gpii/node_modules/flowManager/src/FlowManager.js +++ b/gpii/node_modules/flowManager/src/FlowManager.js @@ -200,6 +200,12 @@ fluid.defaults("gpii.flowManager.local", { }, userErrors: { type: "gpii.userErrors" + }, + installOnDemand: { + type: "gpii.iod", + options: { + gradeNames: ["gpii.iodLifeCycleManager"] + } } }, requestHandlers: { diff --git a/gpii/node_modules/gpii-iod/.gitignore b/gpii/node_modules/gpii-iod/.gitignore new file mode 100644 index 000000000..4e6302879 --- /dev/null +++ b/gpii/node_modules/gpii-iod/.gitignore @@ -0,0 +1,2 @@ +# Generated during testing: +test/localPackages diff --git a/gpii/node_modules/gpii-iod/README.md b/gpii/node_modules/gpii-iod/README.md new file mode 100644 index 000000000..37b8d86d9 --- /dev/null +++ b/gpii/node_modules/gpii-iod/README.md @@ -0,0 +1,116 @@ +# Install on Demand + +Provides the ability to install software based on the requirements of a user. + +[Technical Design](https://tinyurl.com/y7xhcghu) (google doc) + +## Operation + +### GPII Start + +* IoD server is detected using mDNS (or configured server, or local datasource) +* Package list is taken from the IoD server. +* Installation information about packages that *GPII* has installed but not removed, is loaded (if any). + * These packages are uninstalled. + +### Key-in + +Current implementation is a hack which uses a launch handler to invoke `gpii.iod.requirePackage`. + +The stages of installation: + +* Get the package info from the server. +* `requirePackage` finds a suitable `gpii.iod.packageInstaller` for the type package. +* Package file is downloaded from the URI in the package info. +* The installer component installs the package: + * chocolatey: Windows service is instructed to run `choco install`. +* Installation info is stored to disk to survive reboot. +* Package file is removed. + +### Key-out + +* If the key-out is due to another user logging in, then wait for the next key-out. +* The installer component uninstalls the package. + * chocolatey: Windows service is instructed to run `choco uninstall`. +* If successful, installation info file is removed. +* If failed, the package is uninstalled when GPII starts again. + +## Parts + +### `gpii.iod` + +The install on demand component. + +### `gpii.iod.packages` + +### `gpii.iod.packageInstaller` + +Base component of the package installers, which perform the work that's specific to the type of package being installed. +Implementations will probably be found in the OS-specific repository. + +This performs the initialise->cleanup pipeline. + +### `gpii.iod.packageDataSource` + +The package data source. + +## Packages + +Packages consist of a `packageInfo` json file, and the package file. Support can be provided for different types of +packages, however chocolatey already does a good job at wrapping other installer types. + +```javascript +/** + * @typedef {Object} packageInfo + * @property {string} name - The package name. + * @property {string} url - The package location (optional, to override the IoD server with an external location). + * @property {string} filename - The package filename. + * @property {string} packageType - Type of installer to use. + * @property {string} hash - The signed hash of the package. + */ +packageInfo = { + name: "some-package", + url: "https://iod-server.example.com/some-package", + filename: "some-package.1.0.0.nupkg", + packageType: "chocolatey" +}; +``` + +### Multi-lingual packages + +Multiple languages can be supported in a single package, like so: + +```json +{ + "name": "another-package", + "url": "https://example.com/default-language", + "languages": { + "en-GB": { + "url": "https://example.com/real-english" + }, + "es": { + "url": "https://example.com/general-spanish" + }, + "es-ES": { + "url": "https://example.com/spain-spanish" + }, + "es-MX": { + "url": "https://example.com/mexican-spanish" + } + } +} +``` + +When a package is requested, a language may be specified. The package can contain a `languages` field which contains +the language-specific fields for each supported language, which over-write the fields in the root. + +In the example above, all languages shall use the root values unless the requested language is British English, in that +case the `en-GB` block is used, or any type of Spanish. Spanish from Spain or Mexico would use the block that specific +to those countries (`es-ES` or `es-MX`), any other Spanish dialect will use the generic `es` block. + +## IoD Server + +Using the development config, GPII will provide package data from [testData/installOnDemand](../../../testData/installOnDemand). + +But, if it detects a running instance of the server [stegru/gpii-iod](https://github.com/stegru/gpii-iod) somewhere on +the network then that will be used instead. diff --git a/gpii/node_modules/gpii-iod/configs/gpii.iod.config.base.json b/gpii/node_modules/gpii-iod/configs/gpii.iod.config.base.json new file mode 100644 index 000000000..57530923c --- /dev/null +++ b/gpii/node_modules/gpii-iod/configs/gpii.iod.config.base.json @@ -0,0 +1,3 @@ +{ + "type": "gpii.iod.config.base" +} diff --git a/gpii/node_modules/gpii-iod/configs/gpii.iod.config.development.json b/gpii/node_modules/gpii-iod/configs/gpii.iod.config.development.json new file mode 100644 index 000000000..a793116ee --- /dev/null +++ b/gpii/node_modules/gpii-iod/configs/gpii.iod.config.development.json @@ -0,0 +1,17 @@ +{ + "type": "gpii.iod.config.development", + "options": { + "distributeOptions": { + "packageData.dev": { + "record": { + "endpoint": "http://vagrant.iod-test.net" + }, + "target": "{that gpii.iod}.options" + } + } + }, + "mergeConfigs": [ + "./gpii.iod.config.local.base.json", + "./gpii.iod.config.remote.base.json" + ] +} diff --git a/gpii/node_modules/gpii-iod/configs/gpii.iod.config.local.base.json b/gpii/node_modules/gpii-iod/configs/gpii.iod.config.local.base.json new file mode 100644 index 000000000..108799589 --- /dev/null +++ b/gpii/node_modules/gpii-iod/configs/gpii.iod.config.local.base.json @@ -0,0 +1,20 @@ +{ + "type": "gpii.iod.config.local.base", + "options": { + "distributeOptions": { + "packageData.local": { + "record": { + "gradeNames": [ "kettle.dataSource.file.moduleTerms" ], + "path": "%gpii-universal/testData/installOnDemand/%packageName.json5", + "termMap": { + "packageName": "%packageName" + } + }, + "target": "{that gpii.iod.packages packageData}.options" + } + } + }, + "mergeConfigs": [ + "./gpii.iod.config.base.json" + ] +} diff --git a/gpii/node_modules/gpii-iod/configs/gpii.iod.config.remote.base.json b/gpii/node_modules/gpii-iod/configs/gpii.iod.config.remote.base.json new file mode 100644 index 000000000..1ee1d867a --- /dev/null +++ b/gpii/node_modules/gpii-iod/configs/gpii.iod.config.remote.base.json @@ -0,0 +1,20 @@ +{ + "type": "gpii.iod.config.remote.base", + "options": { + "distributeOptions": { + "packageData.remote": { + "record": { + "url": "%endpoint/packages/%packageName", + "termMap": { + "packageName": "%packageName", + "endpoint": "noencode:%endpoint" + } + }, + "target": "{that gpii.iod.packages remotePackageData}.options" + } + } + }, + "mergeConfigs": [ + "./gpii.iod.config.base.json" + ] +} diff --git a/gpii/node_modules/gpii-iod/index.js b/gpii/node_modules/gpii-iod/index.js new file mode 100644 index 000000000..a074646e4 --- /dev/null +++ b/gpii/node_modules/gpii-iod/index.js @@ -0,0 +1,29 @@ +/* + * Install on Demand. + * + * Copyright 2018 Raising the Floor - International + * + * Licensed under the New BSD license. You may not use this file except in + * compliance with this License. + * + * The R&D leading to these results received funding from the + * Department of Education - Grant H421A150005 (GPII-APCP). However, + * these results do not necessarily represent the policy of the + * Department of Education, and you should not assume endorsement by the + * Federal Government. + * + * You may obtain a copy of the License at + * https://github.com/GPII/universal/blob/master/LICENSE.txt + */ + +"use strict"; + +var fluid = require("infusion"); + +fluid.module.register("gpii-iod", __dirname, require); + +require("./src/iodSettingsHandler.js"); +require("./src/installOnDemand.js"); +require("./src/packageInstaller.js"); +require("./src/packages.js"); +require("./src/packageDataSource.js"); diff --git a/gpii/node_modules/gpii-iod/package.json b/gpii/node_modules/gpii-iod/package.json new file mode 100644 index 000000000..1bc606603 --- /dev/null +++ b/gpii/node_modules/gpii-iod/package.json @@ -0,0 +1,13 @@ +{ + "name": "gpii-iod", + "description": "Install on Demand", + "version": "0.3.0", + "author": "GPII", + "bugs": "http://issues.gpii.net/browse/GPII", + "homepage": "http://gpii.net/", + "dependencies": {}, + "license" : "BSD-3-Clause", + "repository": "git://github.com/GPII/windows.git", + "main": "./index.js", + "engines": { "node" : ">=4.2.1" } +} diff --git a/gpii/node_modules/gpii-iod/src/installOnDemand.js b/gpii/node_modules/gpii-iod/src/installOnDemand.js new file mode 100644 index 000000000..ae68d553d --- /dev/null +++ b/gpii/node_modules/gpii-iod/src/installOnDemand.js @@ -0,0 +1,655 @@ +/* + * Install on Demand. + * + * Copyright 2018 Raising the Floor - International + * + * Licensed under the New BSD license. You may not use this file except in + * compliance with this License. + * + * The R&D leading to these results received funding from the + * Department of Education - Grant H421A150005 (GPII-APCP). However, + * these results do not necessarily represent the policy of the + * Department of Education, and you should not assume endorsement by the + * Federal Government. + * + * You may obtain a copy of the License at + * https://github.com/GPII/universal/blob/master/LICENSE.txt + */ + +"use strict"; + +var fluid = require("infusion"); + +var path = require("path"), + os = require("os"), + fs = require("fs"), + request = require("request"), + url = require("url"), + glob = require("glob"); + +var gpii = fluid.registerNamespace("gpii"); +fluid.registerNamespace("gpii.iod"); + +/** + * Installation state. + * @typedef {Object} Installation + * @property {id} - Installation ID + * @property {PackageData} packageData - Package data. + * @property {String} packageName - packageData.name + * @property {Component} installer - The gpii.iod.installer instance. + * @property {Boolean} failed - true if the installation had failed. + * @property {String} tmpDir - Temporary working directory. + * @property {String} installerFile - Path to the downloaded package file. + * @property {String} installerFileHash - The hash of the downloaded installer. + * @property {String[]} cleanupPaths - The directories to remove during cleanup. + * + */ + +fluid.defaults("gpii.iod", { + gradeNames: ["fluid.component", "fluid.modelComponent", "fluid.contextAware"], + + contextAwareness: { + platform: { + checks: { + windows: { + contextValue: "{gpii.contexts.windows}", + gradeNames: ["gpii.iod.windows"] + } + } + } + }, + components: { + packages: { + type: "gpii.iod.packages", + options: { + events: { + "onServerFound": "{gpii.iod}.events.onServerFound", + "onLocalPackagesFound": "{gpii.iod}.events.onLocalPackagesFound" + }, + // Map of recognised keys that sign the packages. + trustedKeys: "{gpii.iod}.options.config.trustedKeys" + } + } + }, + dynamicComponents: { + installers: { + createOnEvent: "onInstallerLoad", + type: "{arguments}.0", + options: { + installationID: "{arguments}.1" + } + } + }, + events: { + onServerFound: null, // [ endpoint address ] + onLocalPackagesFound: null, // [ directory ] + onInstallerLoad: null // [ packageInstaller grade name, installation ] + }, + listeners: { + "onCreate.configuredPackageSources": { + func: "{that}.discoverPackageSources", + args: [ "{that}.options.config.packageSources", false ] + }, + "onCreate.localPackageSources": { + priority: "after:configuredPackageSources", + func: "{that}.discoverPackageSources", + args: [ "@expand:{that}.getMountedVolumes()", true ] + }, + "onCreate.readInstallations": "{that}.readInstallations", + "onServerFound.autoInstall": { + funcName: "gpii.iod.autoInstall", + args: ["{that}", "{that}.options.config.autoInstall"] + } + }, + invokers: { + getMountedVolumes: "fluid.identity", + discoverPackageSources: { + funcName: "gpii.iod.discoverPackageSources", + args: [ + "{that}.events.onLocalPackagesFound", + "{that}.events.onServerFound", + "{arguments}.0", // address(es) + "{arguments}.1" // check before adding? + ] + }, + requirePackage: { + funcName: "gpii.iod.requirePackage", + args: ["{that}", "{arguments}.0"] + }, + initialiseInstallation: { + funcName: "gpii.iod.initialiseInstallation", + args: ["{that}", "{arguments}.0"] + }, + getWorkingPath: { + funcName: "gpii.iod.getWorkingPath", + args: ["{arguments}.0"] + }, + readInstallations: { + funcName: "gpii.iod.readInstallations", + args: ["{that}", "{gpii.journal}.settingsDir.gpiiSettingsDir" ] + }, + writeInstallation: { + funcName: "gpii.iod.writeInstallation", + args: ["{that}", "{gpii.journal}.settingsDir.gpiiSettingsDir", "{arguments}.0"] + }, + unrequirePackage: { + funcName: "gpii.iod.unrequirePackage", + args: ["{that}", "{arguments}.0"] + }, + autoRemove: { + funcName: "gpii.iod.autoRemove", + args: ["{that}", "{arguments}.0"] + }, + uninstallPackage: { + funcName: "gpii.iod.uninstallPackage", + args: ["{that}", "{arguments}.0"] + } + }, + + config: { + // The IoD server address + endpoint: undefined, + // Map of recognised keys that sign the packages. + trustedKeys: {}, + // Packages to install on startup + autoInstall: [] + }, + // Map of installer type -> grade name, for each type of installer. + installerGrades: {}, + + // Milliseconds to wait after key-out (or start up) before uninstalling any un-required packages + autoRemoveDelay: 20000, + + members: { + installations: {} + } +}); + +fluid.defaults("gpii.iodLifeCycleManager", { + gradeNames: ["fluid.component"], + listeners: { + "{lifecycleManager}.events.onSessionStop": { + func: "{that}.autoRemove", + args: [false] + }, + "{userListeners}.usb.events.onMount": { + func: "{that}.discoverPackageSources", + args: ["{arguments}.1", true] + } + }, + + model: { + logonChange: "{lifecycleManager}.model.logonChange" + } + +}); + +/** + * Reads the stored installations from a previous instance. Installations will be kept when an uninstall fails, or if + * GPII was closed without keying out. + * All installed packages from the previous instance will be set for removal. + * + * @param {Component} that The gpii.iod instance. + * @param {String} directory The directory containing the stored installation info. + * @return {Promise} Resolves when complete. + */ +gpii.iod.readInstallations = function (that, directory) { + var promise = fluid.promise(); + var needRemove = false; + + glob(path.join(directory, "iod-installation.*.json"), function (err, files) { + if (err) { + promise.reject({ + isError: true, + message: "Unable to read previous installations", + error: err + }); + } else { + fluid.each(files, function (file) { + try { + var content = fs.readFileSync(file); + var installation = JSON.parse(content); + if (installation && installation.id) { + fluid.log("IoD: Existing installation file '" + file + "': ", installation); + installation.required = false; + installation.uninstalling = false; + needRemove = needRemove || !installation.required; + that.installations[installation.id] = installation; + } + } catch (e) { + fluid.log("IoD: Error reading stored installation file '" + file + "': ", e); + } + }); + process.nextTick(promise.resolve); + } + }); + + promise.then(function () { + if (needRemove) { + // Loaded some uninstalled installation. + that.autoRemove(); + } + }); + + return promise; +}; + +/** + * Writes information about an installation so it can be uninstalled at a later time. + * If the installation has been removed, then the file will be deleted. + * + * @param {Component} that The gpii.iod instance. + * @param {String} directory The directory containing the stored installation info. + * @param {Installation} installation The installation state. + * @return {String} The file that the installation data was written to. + */ +gpii.iod.writeInstallation = function (that, directory, installation) { + var filename = path.join(directory, "iod-installation." + installation.id + ".json"); + + if (installation.removed) { + try { + fs.unlinkSync(filename); + } catch (e) { + // ignore + } + } else { + // Don't write the installer component. + var out = Object.assign({}, installation); + delete out.installer; + var content = JSON.stringify(out); + fs.writeFileSync(filename, content); + } + return filename; +}; + +/** + * Create a directory where packages are temporarily stored. + * + * @param {String} packageName Name of the package for which the directory is being created. + * @return {Object} Contains the full path (fullPath), and the first path that was created (createdPath), for cleanup + */ +gpii.iod.getWorkingPath = function (packageName) { + var createdPath = null; + + var parts = [ + os.tmpdir(), + "gpii-iod", + packageName && packageName.replace(/[^-a-z0-9]/, "_"), + Math.random().toString(36) + ]; + + // Create a new directory + var createDirectory = function (parent, child) { + var dir = path.join(parent, child); + try { + fs.mkdirSync(dir); + if (!createdPath) { + createdPath = dir; + } + } catch (e) { + if (e.code !== "EEXIST") { + throw e; + } + } + return dir; + }; + + // Create the parents of the path. (mkdirp isn't used because the first non-existing path is required to be known) + var fullPath = parts.reduce(createDirectory, ""); + + return { + fullPath: fullPath, + createdPath: createdPath + }; +}; + +/** + * Starts the process of installing a package. + * + * @param {Component} that The gpii.iod instance. + * @param {String|Object} packageRequest Package name, or object containing packageName, language, version. + * @param {String} packageRequest.packageName Name of the package. + * @param {String|String[]} packageRequest.language Language. + * @return {Promise} Resolves with true when the installation is complete, or with false if the package was already + * installed. + */ +gpii.iod.requirePackage = function (that, packageRequest) { + if (typeof(packageRequest) === "string") { + packageRequest = { + packageName: packageRequest + }; + } + + fluid.log("IoD: Requiring " + packageRequest.packageName); + + var promise = fluid.promise(); + + that.packages.getPackageData(packageRequest).then(function (packageData) { + var isInstalled = that.packages.checkInstalled(packageData); + if (isInstalled) { + promise.resolve(false); + } else { + that.initialiseInstallation(packageData).then(function (installation) { + installation.required = true; + if (installation.installed) { + promise.resolve(false); + } else { + installation.installer.startInstaller().then(function () { + installation.installed = true; + installation.gpiiInstalled = true; + // Store the installation info so it can still get removed if gpii restarts. + that.writeInstallation(installation); + promise.resolve(true); + }, promise.reject); + } + + // Destroy the installer + var destroy = function () { + if (installation.installer) { + installation.installer.destroy(); + } + delete installation.installer; + }; + promise.then(destroy, destroy); + + }, promise.reject); + } + }, promise.reject); + + return promise.then(function () { + fluid.log("IoD: Installation of " + packageRequest.packageName + " complete"); + }, function (err) { + fluid.log("IoD: Installation of " + packageRequest.packageName + " failed:", err.error || err); + }); +}; + +/** + * Creates the installer component for the given package. + * @param {Component} that The gpii.iod instance. + * @param {PackageData} packageData The package data. + * @return {Promise} Resolves with a gpii.iod.installer instance. + */ +gpii.iod.initialiseInstallation = function (that, packageData) { + fluid.log("IoD: Initialising installation for " + packageData.name); + + // See if it's already been loaded + var installation = fluid.find(that.installations, function (inst) { + return inst.packageName === packageData.name ? inst : undefined; + }); + + if (!installation) { + installation = { + id: fluid.allocateGuid(), + packageName: packageData.name, + cleanupPaths: [] + }; + } + + that.installations[installation.id] = installation; + + var promise = fluid.promise(); + + // Create the installer instance. + installation.packageData = packageData; + var installerGrade = that.options.installerGrades[packageData.packageType]; + if (installerGrade) { + // Load the installer. + that.events.onInstallerLoad.fire(installerGrade, installation.id); + promise.resolve(installation); + } else { + promise.reject({ + isError: true, + error: "Unable to find an installer for package type " + packageData.packageType + }); + } + + return promise; +}; + +/** + * No longer require a package. This will cause the package to be uninstalled in a short-time if there is no active + * session. + * + * @param {Component} that The gpii.iod instance. + * @param {String} packageName The name of the package to no longer require. + * @return {Promise} Resolves immediately with a boolean indicating if the package was installed, and will be removed. + */ +gpii.iod.unrequirePackage = function (that, packageName) { + + var installation = fluid.find(that.installations, function (inst) { + return inst.packageName === packageName ? inst : undefined; + }); + + if (installation) { + installation.required = false; + that.writeInstallation(installation); + } + + var promise = fluid.promise(); + promise.resolve(!!installation); + return promise; +}; + +/** + * Called by onSessionStop to uninstall the packages that are no longer required. The removal will be performed after + * a short time if there is no active session, to avoid giving the computer too much to do while it's in use. + * + * @param {Component} that The gpii.iod instance. + * @param {Boolean} immediate Uninstall immediately. + */ +gpii.iod.autoRemove = function (that, immediate) { + + var uninstall = function () { + var inSession = false;// that.model.logonChange.inProgress && that.model.logonChange.type !== "login"; + if (!inSession) { + // Get the first installation + var installation = fluid.find(that.installations, function (inst) { + return (!inst.required && !inst.removed && + (!inst.packageData || inst.packageData.uninstallTime !== "never")) + ? inst + : undefined; + }); + + if (installation && !installation.uninstalling && installation.gpiiInstalled) { + var autoInstalled = Array.isArray(that.options.config.autoInstall) + && that.options.config.autoInstall.indexOf(installation.packageData.name) > -1; + if (!autoInstalled) { + installation.uninstalling = true; + that.uninstallPackage(installation).then(uninstall, uninstall); + } + } + } + }; + + if (immediate) { + uninstall(); + } else { + setTimeout(uninstall, that.options.autoRemoveDelay); + } +}; + +/** + * Uninstall a package. + * + * @param {Component} that The gpii.iod instance. + * @param {Installation|String} installation The installation state, or package name. + * @return {Promise} Resolves when the package is removed. + */ +gpii.iod.uninstallPackage = function (that, installation) { + var packageName; + if (typeof(installation) === "string") { + packageName = installation; + installation = fluid.find(that.installations, function (inst) { + return (inst.packageName === packageName) ? inst : undefined; + }); + } else { + packageName = installation.packageName; + } + + if (!installation.gpiiInstalled) { + return fluid.promise().reject({ + isError: true, + message: "Not uninstalling package '" + packageName + "': Installed externally" + }); + } + + + var initPromise; + if (installation && installation.installer) { + initPromise = fluid.toPromise(installation); + } else { + initPromise = that.initialiseInstallation(installation.packageData); + } + + var promiseTogo = fluid.promise(); + initPromise.then(function (installation) { + var result = installation.installer.startUninstaller(); + fluid.promise.follow(result, promiseTogo); + }); + + + return promiseTogo.then(function () { + fluid.log("IoD: Uninstallation of " + packageName + " complete"); + installation.required = false; + installation.removed = true; + that.writeInstallation(installation); + delete that.installations[installation.id]; + + }, function (err) { + fluid.log("IoD: Uninstallation of " + packageName + " failed:", (err && err.error) || err); + // Remove it from the list so it's uninstalled again, but the file is kept so it tries again upon restart. + that.writeInstallation(installation); + delete that.installations[installation.id]; + }); +}; + +/** + * Adds the given packages sources, after optionally checking if it is valid. + * + * @param {Event} onLocalPackagesFound The event to fire when a local package source is to be added. + * @param {Event} onServerFound The event to fire when a remote package source is to be added. + * @param {Array|String} addresses The package source address(es) to add. + * @param {Boolean} check True to check if the source is usable before adding it. + * @return {Promise} Resolves when complete. + */ +gpii.iod.discoverPackageSources = function (onLocalPackagesFound, onServerFound, addresses, check) { + var promises = []; + + fluid.each(fluid.makeArray(addresses), function (address) { + var localPath; + if (address.includes("://")) { + try { + localPath = url.fileURLToPath(address); + } catch (e) { + // ignore + } + } else { + localPath = address; + } + + + var foundPromise; + if (localPath) { + if (check) { + var dataFile = path.join(localPath, ".morphic-packages"); + foundPromise = fluid.promise(); + fs.access(dataFile, function (err) { + if (err) { + fluid.log("IoD: Not using local package source '" + localPath + "': ", err.message); + foundPromise.reject(); + } else { + fluid.log("IoD: Using local package source '" + localPath + "':"); + foundPromise.resolve(localPath); + } + }); + } else { + foundPromise = fluid.toPromise(localPath); + } + + foundPromise.then(onLocalPackagesFound.fire); + } else { + foundPromise = check ? gpii.iod.checkService(address) : fluid.toPromise(address); + foundPromise.then(onServerFound.fire); + } + + // Ignore any rejections + var p = fluid.promise(); + foundPromise.then(p.resolve, function () { + p.resolve(); + }); + promises.push(p); + }); + + return fluid.promise.sequence(promises); +}; + +/** + * Check if an endpoint is listening for connections. + * + * @param {String} endpoint The service end point URI + * @return {Promise} Resolves with the endpoint address, rejects if it can't be connected to. + */ +gpii.iod.checkService = function (endpoint) { + var promise = fluid.promise(); + + request(endpoint, function (error, response) { + if (response) { + fluid.log("IoD: Endpoint found: " + endpoint); + promise.resolve(endpoint); + } else { + fluid.log("IoD: Unable to connect to endpoint " + endpoint + ": ", error); + promise.reject(error); + } + }); + return promise; +}; + +/** + * Installs the packages mentioned in the site config. + * + * @param {Component} that The gpii.iod instance. + * @param {Array} packages The array of package names to install. + */ +gpii.iod.autoInstall = function (that, packages) { + var next = function () { + gpii.iod.autoInstall(that, packages.splice(1)); + }; + if (packages && packages.length > 0) { + setTimeout(function () { + that.requirePackage(packages[0]).then(next, next); + }, 1000); + } +}; + +// For node < 10.12.0 +if (!url.pathToFileURL) { + url.pathToFileURL = function (localPath) { + var togo = new url.URL("file://"); + togo.pathname = path.resolve(localPath); + return togo; + }; +} +if (!url.fileURLToPath) { + url.fileURLToPath = function (fileUrl) { + var u = new url.URL(fileUrl); + var pathTogo; + if (u.protocol === "file:") { + pathTogo = decodeURIComponent(u.pathname); + if (process.platform === "win32") { + pathTogo = pathTogo.replace(/\//g, "\\"); + if (u.hostname) { + // UNC path + pathTogo = "\\\\" + u.hostname + u.pathname; + } else if (pathTogo[2] === ":") { + // X:\ path + pathTogo = pathTogo.substr(1); + } else { + throw new Error("File url has no drive or host. " + fileUrl); + } + } + } else { + throw new Error("File url must be a file: url. " + fileUrl); + } + return pathTogo; + }; +} diff --git a/gpii/node_modules/gpii-iod/src/iodSettingsHandler.js b/gpii/node_modules/gpii-iod/src/iodSettingsHandler.js new file mode 100644 index 000000000..2a6e29018 --- /dev/null +++ b/gpii/node_modules/gpii-iod/src/iodSettingsHandler.js @@ -0,0 +1,93 @@ +/* + * Install on Demand. + * + * Copyright 2018 Raising the Floor - International + * + * Licensed under the New BSD license. You may not use this file except in + * compliance with this License. + * + * The R&D leading to these results received funding from the + * Department of Education - Grant H421A150005 (GPII-APCP). However, + * these results do not necessarily represent the policy of the + * Department of Education, and you should not assume endorsement by the + * Federal Government. + * + * You may obtain a copy of the License at + * https://github.com/GPII/universal/blob/master/LICENSE.txt + */ + +"use strict"; + +var fluid = require("infusion"); + + +var gpii = fluid.registerNamespace("gpii"); +fluid.registerNamespace("gpii.iod.settingsHandler"); + +require("./packageInstaller.js"); + +gpii.iod.settingsHandler.getImpl = function () { +}; +gpii.iod.settingsHandler.setImpl = function (payload) { + + var packages = payload.settings.packages ? fluid.makeArray(payload.settings.packages) : payload.settings; + + var iod = fluid.queryIoCSelector(fluid.rootComponent, "gpii.iod")[0]; + + var results = {}; + + var packagePromises = []; + + fluid.each(Object.keys(packages), function (packageKey) { + var packageRequest = packages[packageKey] || packageKey; + if (!packageRequest.packageName) { + packageRequest.packageName = packageKey; + } + + if (packageRequest.install === undefined) { + packageRequest.install = true; + } + + + results[packageKey] = { + oldValue: {}, + newValue: {} + }; + + var installPromise = fluid.promise(); + + // For some reason the datasource.get call inside requirePackage doesn't resolve while it's inside this stack. + // A better developer would have discovered why, but you have to make do with what you've got. + process.nextTick(function () { + var p = packageRequest.install + ? iod.requirePackage(packageRequest) + : iod.unrequirePackage(packageRequest.packageName); + fluid.promise.follow(p, installPromise); + }); + + installPromise.then(function (installResult) { + results[packageKey].oldValue.install = packageRequest.install ? !installResult : installResult; + results[packageKey].newValue.install = packageRequest.install; + }, function (error) { + fluid.log(error); + results[packageKey].newValue.install = false; + }); + + packagePromises.push(installPromise); + }); + + var promise = fluid.promise(); + fluid.promise.sequence(packagePromises).then(function () { + promise.resolve(results); + }, promise.reject); + return promise; +}; + +gpii.iod.settingsHandler.get = function (payload) { + return gpii.settingsHandlers.invokeSettingsHandler(gpii.iod.settingsHandler.getImpl, payload); +}; + +gpii.iod.settingsHandler.set = function (payload) { + return gpii.settingsHandlers.invokeSettingsHandler(gpii.iod.settingsHandler.setImpl, payload); + +}; diff --git a/gpii/node_modules/gpii-iod/src/multiDataSource.js b/gpii/node_modules/gpii-iod/src/multiDataSource.js new file mode 100644 index 000000000..a990c18f7 --- /dev/null +++ b/gpii/node_modules/gpii-iod/src/multiDataSource.js @@ -0,0 +1,154 @@ +/* + * A readonly data source which encapsulates multiple data sources into one, where each source gets queried until one + * of them returns a result. + * + * Copyright 2019 Raising the Floor - International + * + * Licensed under the New BSD license. You may not use this file except in + * compliance with this License. + * + * The R&D leading to these results received funding from the + * Department of Education - Grant H421A150005 (GPII-APCP). However, + * these results do not necessarily represent the policy of the + * Department of Education, and you should not assume endorsement by the + * Federal Government. + * + * You may obtain a copy of the License at + * https://github.com/GPII/universal/blob/master/LICENSE.txt + */ + +"use strict"; + +var fluid = require("infusion"); + +require("kettle"); + +var gpii = fluid.registerNamespace("gpii"); +fluid.registerNamespace("gpii.iod.multiDataSource"); + +fluid.defaults("gpii.iod.multiDataSource", { + gradeNames: ["kettle.dataSource"], + readOnlyGrade: "gpii.iod.multiDataSource", + + components: { + encoding: { + // The root sources provide their own encoding. + type: "kettle.dataSource.encoding.none" + } + }, + dynamicComponents: { + rootDataSources: { + createOnEvent: "onNewDataSource", + type: "{arguments}.0", + options: { + priority: "{arguments}.2", + // The "path" or "url" value for the data source. + address: "{arguments}.1", + listeners: { + "onCreate.multiDataSource": { + func: "{gpii.iod.multiDataSource}.addDataSource", + args: ["{that}"] + }, + "onDestroy.removeSource": { + func: "{gpii.iod.multiDataSource}.removeDataSource", + args: ["{that}"] + } + } + } + } + }, + events: { + onNewDataSource: null // component name, path, priority + }, + members: { + sortedDataSources: [] + }, + invokers: { + getImpl: { + funcName: "gpii.iod.multiDataSource.getImpl", + args: ["{that}", "{arguments}.0", "{arguments}.1"] + }, + addDataSource: { + funcName: "gpii.iod.multiDataSource.addDataSource", + args: ["{that}", "{arguments}.0"] + }, + removeDataSource: { + funcName: "gpii.iod.multiDataSource.removeDataSource", + args: ["{that}", "{arguments}.0"] + } + } +}); + +/** + * Called when a new data source is to be added. + * @param {Component} that The gpii.iod.multiDataSource instance. + * @param {Component} dataSource The new data source. + */ +gpii.iod.multiDataSource.addDataSource = function (that, dataSource) { + // Insert the new one at the top of the array, so newer sources of the same priority are before the older ones. + that.sortedDataSources.unshift(dataSource); + dataSource.options.priority = parseInt(dataSource.options.priority); + fluid.stableSort(that.sortedDataSources, function (a, b) { + return a.options.priority - b.options.priority; + }); +}; + +/** + * Called when an existing data source has gone. + * @param {Component} that The gpii.iod.multiDataSource instance. + * @param {Component} dataSource The removed data source. + */ +gpii.iod.multiDataSource.removeDataSource = function (that, dataSource) { + var index = that.sortedDataSources.indexOf(dataSource); + if (index === -1) { + fluid.log("multiDataSource: removed an unknown data source"); + } else { + that.sortedDataSources.splice(index, 1); + } +}; + +/** + * Data source getter. Attempts a get() on each root data source until the first success. + * @param {Component} that The gpii.iod.multiDataSource instance. + * @param {Object} options The options. + * @param {Object} directModel The request. + * @return {Promise} Resolves with the result, rejects with the first rejection if all sources reject. + */ +gpii.iod.multiDataSource.getImpl = function (that, options, directModel) { + var promise = fluid.promise(); + var firstReject; + + // Copy the data source array, so it doesn't get modified while being enumerated. + var dataSources = that.sortedDataSources.slice(); + + var next = function (index) { + var source = dataSources[index]; + if (source) { + var result = source.get(directModel, options); + result.then(function (result) { + if (source.options.appendData) { + result = Object.assign({}, source.options.appendData, result); + } + promise.resolve(result); + }, function (reason) { + if (!firstReject) { + firstReject = reason; + } + next(index + 1); + }); + } else { + promise.reject(firstReject); + } + }; + + if (dataSources.length) { + next(0); + } else { + promise.reject({ + isError: true, + message: "no root data sources" + }); + } + + return promise; +}; diff --git a/gpii/node_modules/gpii-iod/src/packageDataSource.js b/gpii/node_modules/gpii-iod/src/packageDataSource.js new file mode 100644 index 000000000..21d830c79 --- /dev/null +++ b/gpii/node_modules/gpii-iod/src/packageDataSource.js @@ -0,0 +1,337 @@ +/* + * Install on Demand package data source. + * + * Copyright 2019 Raising the Floor - International + * + * Licensed under the New BSD license. You may not use this file except in + * compliance with this License. + * + * The R&D leading to these results received funding from the + * Department of Education - Grant H421A150005 (GPII-APCP). However, + * these results do not necessarily represent the policy of the + * Department of Education, and you should not assume endorsement by the + * Federal Government. + * + * You may obtain a copy of the License at + * https://github.com/GPII/universal/blob/master/LICENSE.txt + */ + +"use strict"; + +var fluid = require("infusion"), + crypto = require("crypto"), + fs = require("fs"), + json5 = require("json5"), + path = require("path"), + url = require("url"); + +require("kettle"); + +var gpii = fluid.registerNamespace("gpii"); +fluid.registerNamespace("gpii.iod.packageDataSource"); + +fluid.defaults("gpii.iod.packageDataSource", { + gradeNames: ["fluid.component"], + readOnlyGrade: "gpii.iodServer.packageDataSource", + events: { + onLostDataSource: null + } +}); + +fluid.defaults("gpii.iod.packageDataSource.remote", { + gradeNames: ["kettle.dataSource.URL", "gpii.iod.packageDataSource"], + url: "@expand:gpii.iod.joinUrl({that}.options.address, {that}.options.urlPath)", + urlPath: "/packages/%packageName", + termMap: { + packageName: "%packageName" + }, + appendData: { + baseUrl: "{that}.options.address" + } +}); + +fluid.defaults("gpii.iod.packageDataSource.local", { + gradeNames: ["kettle.dataSource", "gpii.iod.packageDataSource"], + + components: { + encoding: { + type: "kettle.dataSource.encoding.none" + } + }, + invokers: { + getImpl: { + funcName: "gpii.iod.packageDataSource.getLocal", + args: [ + "@expand:{that}.loadData()", + //"{arguments}.0", + "{arguments}.1", + "{that}.options.path" + ] + }, + loadData: { + funcName: "gpii.iod.packageDataSource.loadData", + args: ["{that}", "{that}.options.path", "{that}.options.dataFile"] + } + }, + path: "{that}.options.address", + dataFile: ".morphic-packages", + members: { + data: undefined + } +}); + +/** + * @typedef {PackageResponse} LocalPackage + */ + +/** + * Convenience function to concatenate two parts of a URL together, ensuring there's a single '/' between each part + * (like `path.join`, but performs no normalisation and always uses slash). + * + * @param {String} front The first part of the URL + * @param {String} end The final part of the URL + * @return {String} `font` and `end` combined. + */ +gpii.iod.joinUrl = function (front, end) { + return front.replace(/\/+$/, "") + "/" + end.replace(/^\/+/, ""); +}; + +/** + * Gets the package data from a local path. + * @param {Promise} loadPromise A promise that resolves when the local package data is available. + * @param {Object} packageRequest The package request. + * @param {Object} directory The directory where the local packages are located. + * @return {Promise} Resolves with the package response. + */ +gpii.iod.packageDataSource.getLocal = function (loadPromise, packageRequest, directory) { + var promise = fluid.promise(); + loadPromise.then(function (data) { + /** @type {LocalPackage} */ + var localPackage = fluid.copy(data[packageRequest.packageName]); + if (localPackage) { + var checkInstallerPromise; + + if (localPackage.installer) { + // Get the absolute path, and create the url for it. + var localPath = path.join(directory, localPackage.installer); + var installerURL = url.pathToFileURL(localPath); + if (localPackage.offset) { + installerURL.searchParams.set("offset", localPackage.offset); + } + localPackage.installer = installerURL.toString(); + + // Check if the installer file exists, as it would be pointless to return a response if the package + // can't be installed. + checkInstallerPromise = fluid.promise(); + fs.access(localPath, function (err) { + if (err) { + checkInstallerPromise.reject({ + isError: true, + message: "File for " + packageRequest.packageName + " not found", + localPath: localPath, + error: err + }); + } else { + checkInstallerPromise.resolve(); + } + }); + } + + fluid.toPromise(checkInstallerPromise).then(function () { + promise.resolve(localPackage); + }, promise.reject); + + } else { + promise.reject({ + message: "Package " + packageRequest.packageName + " not found" + }); + } + }, promise.reject); + + return promise; +}; + +/** + * Loads the package data from a local file. + * @param {Component} that The gpii.iod.packageDataSource.local instance. + * @param {String} directory The directory where the packages are kept. + * @param {String} file The local package data file (a child of `directory`) (".morphic-packages") + * @return {Promise>} Resolves with the packages from the local data file. + */ +gpii.iod.packageDataSource.loadData = function (that, directory, file) { + var promise = fluid.promise(); + var dataFile = path.join(directory, file); + + gpii.iod.packageDataSource.checkDataFile(that, dataFile).then(function (changed) { + if (changed || !that.data) { + fs.readFile(dataFile, "utf8", function (err, content) { + if (err) { + promise.reject({ + isError: true, + message: "IoD: unable to load the data file " + dataFile + ": " + err.message, + error: err + }); + } else { + var failed; + try { + that.data = json5.parse(content); + } catch (err) { + failed = true; + promise.reject({ + isError: true, + message: "IoD: unable to parse the data file " + dataFile + ": " + err.message, + error: err + }); + + } + if (!failed) { + if (!that.data.packages) { + that.data.packages = {}; + } + fluid.log("IoD: Loaded " + Object.keys(that.data.packages).length + " packages from " + dataFile); + promise.resolve(that.data.packages); + } + } + }); + } else { + promise.resolve(that.data.packages); + } + }, function (err) { + fluid.log("IoD: Unable to read from " + dataFile + ": ", err.message || err); + that.destroy(); + promise.reject(err); + }); + + return promise; +}; + +/** + * Checks if the data file exists, and has changed since the last time this was called. + * @param {Component} that The gpii.iod.packageDataSource.local instance. + * @param {String} dataFile Path to the .morphic-packages file. + * @return {Promise} Resolves with a boolean indicating if the file has changed (or the first call). Rejects + * if the file does not exist. + */ +gpii.iod.packageDataSource.checkDataFile = function (that, dataFile) { + var promise = fluid.promise(); + fs.stat(dataFile, function (err, stats) { + if (err) { + promise.reject(err); + } else { + var changed = stats.mtimeMs !== that.dataFileTime; + if (changed && that.dataFileTime) { + fluid.log("IoD: local package data file has changed - reloading"); + } + that.dataFileTime = stats.mtimeMs; + promise.resolve(changed); + } + }); + + return promise; +}; + +// fluid.defaults("gpii.iod.packageDataSource.local", { +// gradeNames: [ +// "kettle.dataSource.file.moduleTerms", "kettle.dataSource.encoding.JSON5", "gpii.iod.packageDataSource" +// ], +// path: "%gpii-universal/testData/installOnDemand/%packageName.json5", +// termMap: { +// "packageName": "%packageName" +// }, +// components: { +// encoding: { +// type: "kettle.dataSource.encoding.JSON5" +// } +// } +// }); + +/** + * Verifies the packageData JSON string against the packageDataSignature, returns the response with the packageData + * field de-serialised. + * @param {PackageResponse} packageResponse The response from the data source. + * @param {Object} trustedKeys Map of sha256 fingerprints (base64 encoded) of the public keys that are authorised. + * @return {Promise} Resolves with the response from the data source, with de-serialised packageData. + */ +gpii.iod.checkPackageSignature = function (packageResponse, trustedKeys) { + var promise = fluid.promise(); + gpii.iod.verifySignedJSON(packageResponse.packageData, packageResponse.packageDataSignature, trustedKeys) + .then(function (packageData) { + var result = fluid.copy(packageResponse); + result.packageData = packageData; + promise.resolve(result); + }, promise.reject); + + return promise; +}; + +/** + * Verifies a serialised object against a signature, and the public key is one of those specified. + * + * The base64 encoded public key is inside the object under the `publicKey` field. + * + * @param {String} data The JSON data to verify (expects a `publicKey` field) + * @param {String} signature The signature (base64). + * @param {Object} trustedKeys Map of sha256 fingerprints (base64 encoded) of the public keys that are authorised. + * + * @return {Promise} Resolves, with the de-serialised object, when complete. Rejects if the signature doesn't validate, + * or if one of the keys aren't in the given list. + */ +gpii.iod.verifySignedJSON = function (data, signature, trustedKeys) { + var promise = fluid.promise(); + var failureMessage; + var verified = false; + + try { + var obj = JSON.parse(data); + if (obj.publicKey) { + // Get the sha256 fingerprint of the public key, and check if it's one of the allowed keys. + var fingerprint = crypto.createHash("sha256").update(Buffer.from(obj.publicKey, "base64")).digest("base64"); + var keyName = fluid.keyForValue(trustedKeys, fingerprint); + var authorised = keyName !== undefined; + fluid.log("IoD: Package signing key: " + fingerprint + " - ", (keyName || "unknown")); + + if (authorised) { + fluid.log("IoD: Package signing key name: " + keyName); + // PEM encode the key - it's already base64 encoded, so just surround with the header and footer. + var publicKey = "-----BEGIN PUBLIC KEY-----\n" + + obj.publicKey.trim() + + "\n-----END PUBLIC KEY-----\n"; + + // Verify the package data with the signature. + var verify = crypto.createVerify("RSA-SHA512"); + verify.update(data); + + verified = verify.verify({key: publicKey}, signature, "base64"); + + if (!verified) { + failureMessage = "Signature could not be verified."; + } + + } else { + verified = false; + failureMessage = "Signed by an unknown key."; + } + } else { + verified = false; + failureMessage = "JSON object did not contain a publicKey field."; + } + + if (verified) { + promise.resolve(obj); + } else { + promise.reject({ + isError: true, + message: "Signed JSON data failed verification: " + failureMessage + }); + } + } catch (e) { + verified = false; + promise.reject({ + isError: true, + message: "Error while verifying signed JSON data: " + (e.message || ""), + error: e + }); + } + + return promise; +}; diff --git a/gpii/node_modules/gpii-iod/src/packageInstaller.js b/gpii/node_modules/gpii-iod/src/packageInstaller.js new file mode 100644 index 000000000..d6c3b2bd3 --- /dev/null +++ b/gpii/node_modules/gpii-iod/src/packageInstaller.js @@ -0,0 +1,726 @@ +/* + * Abstraction of something that installs packages. + * + * Copyright 2018 Raising the Floor - International + * + * Licensed under the New BSD license. You may not use this file except in + * compliance with this License. + * + * The R&D leading to these results received funding from the + * Department of Education - Grant H421A150005 (GPII-APCP). However, + * these results do not necessarily represent the policy of the + * Department of Education, and you should not assume endorsement by the + * Federal Government. + * + * You may obtain a copy of the License at + * https://github.com/GPII/universal/blob/master/LICENSE.txt + */ + +"use strict"; + +var path = require("path"), + fs = require("fs"), + request = require("request"), + crypto = require("crypto"), + url = require("url"), + child_process = require("child_process"); + +var fluid = require("infusion"); +var gpii = fluid.registerNamespace("gpii"); +fluid.registerNamespace("gpii.iod"); + +fluid.defaults("gpii.iod.packageInstaller", { + gradeNames: ["fluid.component"], + + invokers: { + created: { + funcName: "gpii.iod.installerCreated", + args: ["{that}", "{iod}"] + }, + startInstaller: { + funcName: "gpii.iod.startInstaller", + args: ["{that}", "{iod}"] + }, + startUninstaller: { + funcName: "gpii.iod.startUninstaller", + args: ["{that}", "{iod}"] + }, + executeCommand: { + funcName: "gpii.iod.executeCommand", + // PackageCommand, command, args + args: ["{that}", "{arguments}.0", "{arguments}.1", "{arguments}.2"] + }, + startProcess: { + funcName: "gpii.iod.startProcess", + // command, args + args: ["{arguments}.0", "{arguments}.1"] + }, + + // Remaining invokers are part of the installation pipe-line. Each one is passed the installation and returns + // an installation, either directly or via a promise. + initialise: { + funcName: "gpii.iod.initialise", + args: ["{that}", "{iod}", "{that}.installation", "{that}.installation.packageData"] + }, + downloadInstaller: { + funcName: "gpii.iod.downloadInstaller", + args: ["{that}", "{that}.installation", "{that}.installation.packageData"] + }, + checkPackage: { + funcName: "gpii.iod.checkPackage", + args: ["{that}", "{that}.installation", "{that}.installation.packageData"] + }, + prepareInstall: { + funcName: "gpii.iod.prepareInstall", + args: ["{that}", "{that}.installation", "{that}.installation.packageData"] + }, + installPackage: "fluid.notImplemented", + cleanup: { + funcName: "gpii.iod.cleanup", + args: ["{that}", "{that}.installation", "{that}.installation.packageData"] + }, + installComplete: { + funcName: "gpii.iod.installComplete", + args: ["{that}", "{iod}", "{that}.installation", "{that}.installation.packageData"] + }, + startApplication: { + funcName: "gpii.iod.startApplication", + args: ["{that}", "{that}.installation", "{that}.installation.packageData"] + }, + stopApplication: { + funcName: "gpii.iod.stopApplication", + args: ["{that}", "{that}.installation", "{that}.installation.packageData"] + }, + uninstallPackage: "fluid.notImplemented", + uninstallComplete: { + funcName: "gpii.iod.installComplete", + args: ["{that}", "{iod}", "{that}.installation", "{that}.installation.packageData"] + } + }, + events: { + // Dummy events for the installation pipe-lines + onInstallPackage: null, + onRemovePackage: null + }, + listeners: { + "onCreate": "{that}.created", + "onInstallPackage.initialise": { + func: "{that}.initialise", + priority: "first" + }, + "onInstallPackage.download": { + func: "{that}.downloadInstaller", + priority: "after:initialise" + }, + "onInstallPackage.check": { + func: "{that}.checkPackage", + priority: "after:download" + }, + "onInstallPackage.prepareInstall": { + func: "{that}.prepareInstall", + priority: "after:check" + }, + "onInstallPackage.install": { + func: "{that}.installPackage", + priority: "after:prepareInstall" + }, + "onInstallPackage.cleanup": { + func: "{that}.cleanup", + priority: "after:install" + }, + "onInstallPackage.complete": { + func: "{that}.installComplete", + priority: "after:cleanup" + }, + "onInstallPackage.startApplication": { + func: "{that}.startApplication", + priority: "last" + }, + + "onRemovePackage.stopApplication": { + func: "{that}.stopApplication", + priority: "first" + }, + "onRemovePackage.uninstall": { + func: "{that}.uninstallPackage", + priority: "after:stopApplication" + }, + "onRemovePackage.cleanup": { + func: "{that}.cleanup", + priority: "after:uninstall" + }, + "onRemovePackage.complete": { + func: "{that}.uninstallComplete", + priority: "after:cleanup" + } + }, + + // Types of package this installer supports + packageTypes: null, + + members: { + // Package information from the server. + packageData: null, + // "install" or "uninstall" + currentAction: null + } +}); + + +/** + * Installer component created. + * + * @param {Component} that The gpii.iod.installer instance. + * @param {Component} iod The gpii.iod instance. + */ +gpii.iod.installerCreated = function (that, iod) { + if (that.options.installationID) { + that.installation = iod.installations[that.options.installationID]; + } + if (that.installation) { + that.installation.installer = that; + that.packageData = that.installation.packageData; + if (!that.packageData.installCommands) { + that.packageData.installCommands = {}; + } + if (!that.packageData.uninstallCommands) { + that.packageData.uninstallCommands = {}; + } + } +}; + +/** + * Starts the installation pipeline. + * + * @param {Component} that The gpii.iod.installer instance. + * @param {Component} iod The gpii.iod instance. + * @return {Promise} Resolves when complete. + */ +gpii.iod.startInstaller = function (that) { + that.currentAction = "install"; + gpii.iod.addStageListeners(that, that.events.onInstallPackage); + return fluid.promise.fireTransformEvent(that.events.onInstallPackage); +}; + +/** + * Starts the un-installation pipeline. + * + * @param {Component} that The gpii.iod.installer instance. + * @param {Component} iod The gpii.iod instance. + * @return {Promise} Resolves when complete. + */ +gpii.iod.startUninstaller = function (that) { + that.currentAction = "uninstall"; + gpii.iod.addStageListeners(that, that.events.onRemovePackage); + return fluid.promise.fireTransformEvent(that.events.onRemovePackage); +}; + +/** + * Adds listeners before and after the existing listeners of an event (onInstallPackage or onRemovePackage), which + * update+log the current stage and possibly execute a command specified in the packageData.installCommands for that + * stage. + * + * @param {Component} that The gpii.iod.installer instance. + * @param {Event} event The event. + */ +gpii.iod.addStageListeners = function (that, event) { + // inject some listeners to record the current stage + fluid.each(Object.keys(event.listeners), function (namespace) { + event.addListener(function () { + that.installation.currentStage = namespace; + fluid.log("IoD: Entering stage: ", namespace); + return gpii.iod.customCommand(that, "before"); + }, "_before_" + namespace, "before:" + namespace); + + event.addListener(function () { + fluid.log("IoD: Leaving stage: ", namespace); + return gpii.iod.customCommand(that, "after"); + }, "_after_" + namespace, "after:" + namespace); + + }); +}; + +gpii.iod.customCommand = function (that, when) { + var commands = that.currentAction === "install" + ? that.packageData.installCommands + : that.packageData.uninstallCommands; + var command = commands && commands[that.installation.currentStage + ":" + when]; + var togo; + if (command) { + var promises = fluid.transform(fluid.makeArray(command), function (c) { + return function () { + return that.executeCommand(c); + }; + }); + togo = fluid.promise.sequence(promises); + } + return togo; +}; + +/** + * Initialises the installation. + * + * @param {Component} that The gpii.iod.installer instance. + * @param {Component} iod The gpii.iod instance. + * @param {Installation} installation The installation state. + * @param {PackageData} packageData The package data. + * @return {Promise} Resolves when complete. + */ +gpii.iod.initialise = function (that, iod, installation, packageData) { + var tempDir = iod.getWorkingPath(packageData.name); + installation.tempDir = tempDir.fullPath; + installation.cleanupPaths.push(tempDir.createdPath); + return packageData.installCommands.initialise + ? that.executeCommand(packageData.installCommands.initialise) + : fluid.promise().resolve(); +}; + +/** + * Downloads an installer from the server. + * + * @param {Component} that The gpii.iod.installer instance. + * @param {Installation} installation The installation state. + * @param {PackageData} packageData The package data. + * @return {Promise} Resolves when complete. + */ +gpii.iod.downloadInstaller = function (that, installation, packageData) { + + fluid.log("IoD: Downloading installer " + packageData.installerSource); + + installation.installerFile = path.join(installation.tempDir, packageData.installer); + + var promise = fluid.promise(); + if (packageData.installCommands.download) { + promise = that.executeCommand(packageData.installCommands.initialise); + } else { + promise = fluid.promise(); + + + if (packageData.installerSource) { + + if (packageData.installerSource.indexOf("://") >= 0) { + // Warning: Taking a url from an external source, downloading it, and then later executing it (as admin). + var downloadPromise = gpii.iod.fileDownload(packageData.installerSource, installation.installerFile); + downloadPromise.then(function (hash) { + installation.installerFileHash = hash; + promise.resolve(); + }, promise.reject); + } else { + fs.copyFile(packageData.installerSource, installation.installerFile, function (err) { + if (err) { + promise.reject({ + isError: true, + message: "Unable to copy package" + }); + } else { + promise.resolve(); + } + }); + } + } else { + promise.resolve(); + } + } + return promise.then(null, function (err) { + fluid.log("IoD: Failed download of " + packageData.installerSource + ": ", err); + }); +}; + +/** + * Downloads a file while generating its hash. + * + * @param {String} address The remote uri. + * @param {String} localPath Destination path. + * @param {Object} options Options + * @param {String} options.hash The hash algorithm (default: sha512) + * @param {Function} options.process Callback for the progress, called with current and total. + * @return {Promise} Resolves with the hash (hex string) when the download is complete. + */ +gpii.iod.fileDownload = function (address, localPath, options) { + options = Object.assign({ + hash: "sha512" + }, options); + + var promise = fluid.promise(); + + var output = fs.createWriteStream(localPath); + var hash = crypto.createHash(options.hash); + + output.on("finish", function () { + promise.resolve(hash.digest("hex")); + }); + + var downloadUrl = new url.URL(address); + + var stream; + if (downloadUrl.protocol === "file:") { + var offset = parseInt(downloadUrl.searchParams.get("offset")) || 0; + var file = url.fileURLToPath(address); + stream = fs.createReadStream(file, { + start: offset + }); + + stream.on("open", function () { + stream.pipe(output); + }); + } else { + stream = request.get({ + url: address + }); + + stream.on("response", function (response) { + if (response.statusCode === 200) { + stream.pipe(output); + } else { + promise.reject({ + isError: true, + message: "Unable to download package: " + response.statusCode + " " + response.statusMessage, + address: address + }); + } + }); + } + + stream.on("data", function (data) { + hash.update(data); + }); + + stream.on("error", function (err) { + promise.reject({ + isError: true, + message: "Unable to download package: " + err.message, + address: address, + error: err + }); + }); + + return promise; +}; + +/** + * Checks that a downloaded package is ok. + * + * @param {Component} that The gpii.iod.installer instance. + * @param {Installation} installation The installation state. + * @param {PackageData} packageData The package data. + * @return {Promise} Resolves when complete. + */ +gpii.iod.checkPackage = function (that, installation, packageData) { + var promise; + fluid.log("IoD: Checking downloaded package file " + packageData.filename); + if (packageData.installCommands.check) { + promise = that.executeCommand(packageData.installCommands.check); + } else { + // TODO: It shouldn't be checked here - another process may over-write it before the high privilege executes it. + // Instead, take ownership then check the integrity in the same context as it's being ran. + var matches = packageData.installerHash === installation.installerFileHash; + promise = fluid.promise(); + if (matches) { + promise.resolve(); + } else { + promise.reject({ + isError: true, + message: "The downloaded installation file is wrong" + }); + } + } + return promise; +}; + +/** + * Generate the installation instructions. + * + * @param {Component} that The gpii.iod.installer instance. + * @param {Installation} installation The installation state. + * @param {PackageData} packageData The package data. + * @return {Promise} Resolves when complete. + */ +gpii.iod.prepareInstall = function (that, installation, packageData) { + var promise; + fluid.log("IoD: Preparing installation for " + packageData.name); + if (packageData.installCommands.prepareInstall) { + promise = that.executeCommand(packageData.installCommands.prepareInstall); + } else { + // TODO: remove + if (installation.packageData.elevate) { + packageData.installerArgs = Object.assign({ + elevate: true + }, packageData.installerArgs); + packageData.uninstallerArgs = Object.assign({ + elevate: true + }, packageData.uninstallerArgs); + } + promise = fluid.promise().resolve(); + } + return promise; +}; + +/** + * Cleans up things that are no longer required. + * + * @param {Component} that The gpii.iod.installer instance. + * @param {Installation} installation The installation state. + * @param {PackageData} packageData The package data. + * @return {Promise} Resolves when complete. + */ +gpii.iod.cleanup = function (that, installation, packageData) { + var promise; + if (that.currentAction === "install") { + promise = packageData.installCommands.cleanup && that.executeCommand(packageData.installCommands.cleanup); + } else { + promise = packageData.uninstallCommands.cleanup && that.executeCommand(packageData.uninstallCommands.cleanup); + } + + if (!promise) { + if (!packageData.keepInstaller || that.currentAction === "uninstall") { + // TODO + fluid.log("IoD: Cleaning installation of " + packageData.name); + } + promise = fluid.promise().resolve(); + } + return promise; +}; + +/** + * Called when the installation has completed. + * @param {Component} that The gpii.iod.installer instance. + * @param {Component} iod The gpii.iod instance. + * @param {Installation} installation The installation state. + * @param {PackageData} packageData The package data. + * @return {Promise} Resolves when complete. + */ +gpii.iod.installComplete = function (that, iod, installation, packageData) { + var promise; + fluid.log("IoD: Completed installation of " + packageData.name); + if (packageData.installCommands.complete) { + promise = that.executeCommand(packageData.installCommands.complete); + } else { + // Check if the application is detected, to see if the (un)installation process really worked. + var installed = iod.packages.checkInstalled(packageData); + var installing = (that.currentAction === "install"); + promise = fluid.promise(); + + if (installed === installing) { + promise.resolve(); + } else { + promise.reject({ + isError: true, + message: packageData.name + + (installing ? " was not detected after installing" : " was still detected after uninstalling") + }); + } + } + return promise; +}; + +/** + * Starts the application. + * + * @param {Component} that The gpii.iod.installer instance. + * @param {Installation} installation The installation state. + * @param {PackageData} packageData The package data. + * @return {Promise} Resolves when the application has been started. + */ +gpii.iod.startApplication = function (that, installation, packageData) { + var promise = fluid.promise(); + fluid.log("IoD: Starting application " + packageData.name); + if (packageData.start) { + child_process.exec(packageData.start, function (err, stdout, stderr) { + if (err) { + fluid.log("IoD: startApplication error: ", err); + } + fluid.log("IoD: startApplication: ", { stdout: stdout, stderr: stderr }); + }); + } + promise.resolve(); + return promise; +}; + +/** + * Stops the application (for uninstallation). + * + * @param {Component} that The gpii.iod.installer instance. + * @param {Installation} installation The installation state. + * @param {PackageData} packageData The package data. + * @return {Promise} Resolves when the command has completed. + */ +gpii.iod.stopApplication = function (that, installation, packageData) { + var promise = fluid.promise(); + fluid.log("IoD: Stopping application " + packageData.name); + if (packageData.start) { + child_process.exec(packageData.start, function (err, stdout, stderr) { + if (err) { + fluid.log("IoD: stopApplication error: ", err); + } + fluid.log("IoD: stopApplication: ", { stdout: stdout, stderr: stderr }); + promise.resolve(); + }); + } else { + promise.resolve(); + } + return promise; +}; + +/** + * Expands "$(expanders)" in a string, whose content is a path to a field in the given object. + * + * Expanders are in the format of $(path) or $(path?default). + * Examples: + * "${a.b.c}", {a:{b:{c:"result"}}} returns "result". + * "${a.x?no}", {a:{b:{c:"result"}}} returns "no". + * + * @param {String|Object} unexpanded The input string, containing zero or more expanders. If an object, then string + * values within the object are worked on. + * @param {Object} sourceObject The object which the paths in the expanders refer to. + * @param {String} alwaysExpand `true` to make expanders that resolve to null/undefined resolve to an empty + * string, otherwise the function returns null. + * @return {String} The input string, with the expanders replaced by the value of the field they refer to. + */ +gpii.iod.expand = function (unexpanded, sourceObject, alwaysExpand) { + var unresolved = false; + var result; + + if (typeof(unexpanded) === "string") { + // Replace all occurences of "$(...)" + result = unexpanded.replace(/\$\(([^?}]*)(\?([^}]*))?\)/g, function (match, expression, defaultGroup, defaultValue) { + if (expression === "debug") { + fluid.log("expand object: ", sourceObject); + } + // Resolve the path to a field, deep in the object. + var value = expression.split(".").reduce(function (parent, property) { + return (parent && parent.hasOwnProperty(property)) ? parent[property] : undefined; + }, sourceObject); + + if (value === undefined || (typeof (value) === "object")) { + if (defaultGroup) { + value = defaultValue; + } + if (value === undefined || value === null) { + if (!alwaysExpand) { + unresolved = true; + } + value = ""; + } + } + return value; + }); + } else if (unexpanded === null || unexpanded === undefined) { + result = null; + } else if (fluid.isPlainObject(unexpanded)) { + result = fluid.transform(unexpanded, function (field) { + return gpii.iod.expand(field, sourceObject, alwaysExpand); + }); + } else { + result = unexpanded; + } + + return unresolved ? null : result; +}; + +/** + * Starts a process, and waits for it to exit. + * @param {String} command The command to run. + * @param {Array} args [optional] The arguments to pass. + * @return {Promise} Resolves when the process terminates. + */ +gpii.iod.startProcess = function (command, args) { + args = fluid.makeArray(args); + var promise = fluid.promise(); + fluid.log("spawning: " + command + " ", args); + var child = child_process.spawn(command, fluid.makeArray(args), { + stdio: "inherit" + }); + + child.on("error", function (err) { + if (!promise.disposition) { + promise.reject({ + isError: true, + error: err, + message: "Error running command", + command: command, + args: args + }); + } + }); + child.on("exit", function (code) { + if (code) { + if (!promise.disposition) { + promise.reject({ + isError: true, + exitCode: code, + message: "Error running command", + command: command, + args: args + }); + } + } else { + promise.resolve({ + exitCode: code + }); + } + }); + return promise; +}; + +/** + * Executes a command, which was specified in the package data. + * + * @param {Component} that The gpii.iod.installer instance. + * @param {PackageCommand} execOptions How the command is invoked. + * @param {String} command The command (overrides `execOptions.command`. + * @param {Array|String} args [optional] The arguments (overrides `execOptions.args`). + * @return {Promise} Resolves when complete. + */ +gpii.iod.executeCommand = function (that, execOptions, command, args) { + // Take a copy to modify. + execOptions = Object.assign({}, execOptions); + + if (command) { + execOptions.command = command; + } + + execOptions.args = fluid.makeArray(args || execOptions.args); + execOptions = gpii.iod.expand(execOptions, that.installation); + + var processPromise; + if (!execOptions.command) { + processPromise = fluid.promise().reject({ + isError: true, + message: "executeCommand called without a command" + }); + } else if (execOptions.elevate && that.startElevatedProcess) { + processPromise = that.startElevatedProcess(execOptions.command, execOptions.args, {desktop: execOptions.desktop}); + } else { + if (execOptions.elevate) { + fluid.log(fluid.logLevel.WARN, "Running elevated commands is not supported on this system."); + } + + processPromise = that.startProcess(execOptions.command, execOptions.args); + } + + var promiseTogo = fluid.promise(); + processPromise.then(function (result) { + var success; + // Resolve or reject based on the exit code. + if (fluid.isValue(execOptions.success)) { + success = fluid.makeArray(execOptions.success).includes(result.exitCode); + } else if (fluid.isValue(execOptions.failure)) { + success = !fluid.makeArray(execOptions.failure).includes(result.exitCode); + } else { + success = true; + } + + if (success) { + promiseTogo.resolve(result); + } else { + promiseTogo.reject({ + message: "Command returned " + (execOptions.success ? "non-success" : "failure") + + " exit code " + result.exitCode, + execOptions: execOptions, + result: result + }); + } + }, promiseTogo.reject); + + return promiseTogo; +}; diff --git a/gpii/node_modules/gpii-iod/src/packages.js b/gpii/node_modules/gpii-iod/src/packages.js new file mode 100644 index 000000000..68e9b61ea --- /dev/null +++ b/gpii/node_modules/gpii-iod/src/packages.js @@ -0,0 +1,339 @@ +/* + * Install on Demand packages. + * + * Copyright 2019 Raising the Floor - International + * + * Licensed under the New BSD license. You may not use this file except in + * compliance with this License. + * + * The R&D leading to these results received funding from the + * Department of Education - Grant H421A150005 (GPII-APCP). However, + * these results do not necessarily represent the policy of the + * Department of Education, and you should not assume endorsement by the + * Federal Government. + * + * You may obtain a copy of the License at + * https://github.com/GPII/universal/blob/master/LICENSE.txt + */ + +"use strict"; + +var fluid = require("infusion"), + fs = require("fs"); + +require("kettle"); + +var gpii = fluid.registerNamespace("gpii"); +fluid.registerNamespace("gpii.iod.packages"); +fluid.registerNamespace("gpii.iod.packages.resolvers"); + +fluid.require("%lifecycleManager"); +require("./packageInstaller.js"); +require("./iodSettingsHandler.js"); +require("./multiDataSource.js"); + +/** + * Information about a package. + * @typedef {Object} PackageData + * @property {String} name The package name. + * @property {String} packageType Type of installer to use. + * + * @property {String} publicKey Public key used to verify the package data. + * + * @property {String} installer Original filename of the installer file. + * @property {String} installerSize Size of installer. + * @property {String} installerHash Installer sha512 hash. + * @property {String} installerSource The installer location (where to download/copy it from). + * + * @property {Boolean} keepInstaller `true` to keep the installer file after installing (removes after uninstall). + * + * @property {PackageCommand|String} installerArgs Additional options used when executing the installer. + * @property {PackageCommand|String} uninstallerArgs Additional options used when executing the uninstaller. + * + * @property {String} uninstallTime When to uninstall this package, after it's no longer required: "immediate", "idle", + * "never". + * + * @property {String} uiLevel How much is displayed (if possible): "none" (default), "progress" (non-interactive), + * "progress-cancel" (progress, can be cancelled), "full" (fully interactive, asks questions). + * + * @property {Object} installCommands Commands to execute at certain points in the installation, rather than + * perform the default action (if any). + * @property {PackageCommand} installCommands.initialise The initialise command. + * @property {PackageCommand} installCommands.download The download command. + * @property {PackageCommand} installCommands.check The check command. + * @property {PackageCommand} installCommands.prepareInstall The prepareInstall command. + * @property {PackageCommand} installCommands.install The install command. + * @property {PackageCommand} installCommands.cleanup The cleanup command. + * @property {PackageCommand} installCommands.complete The installation is complete. + * + * @property {Object} uninstallCommands Commands to execute at certain points in the installation, rather than + * perform the default action (if any). + * @property {PackageCommand} uninstallCommands.uninstall The initialise command. + * @property {PackageCommand} uninstallCommands.cleanup The download command. + * @property {PackageCommand} uninstallCommands.complete The check command. + * + */ + +/** + * Describes how something is invoked. + * @typedef {Object} PackageCommand + * @property {String} command The command to invoke. + * @property {String|Array} args arguments passed to the command. + * @property {Number|Array} success Exit code(s) to assume success (mutually exclusive with failure). + * @property {Number|Array} failure Exit code(s) to assume failure (mutually exclusive with success). + * @property {Boolean} elevate true to run as administrator. + * @property {Boolean} desktop true to run in the context of the desktop, if elevate is true. + */ + +gpii.iod.resolvers = {}; + +fluid.defaults("gpii.iod.packages", { + gradeNames: ["fluid.component"], + components: { + dataSource: { + type: "gpii.iod.multiDataSource", + options: { + invokers: { + "checkPackageSignature": { + funcName: "gpii.iod.checkPackageSignature", + args: ["{arguments}.0", "{gpii.iod.packages}.options.trustedKeys"] + } + }, + listeners: { + "onRead.checkSignature": { + func: "{that}.checkPackageSignature", + args: "{arguments}.0" // packageResponse + } + } + } + }, + variableResolver: { + type: "gpii.lifecycleManager.variableResolver" + } + }, + invokers: { + getPackageData: { + funcName: "gpii.iod.getPackageData", + args: ["{that}", "{arguments}.0"] // packageRequest + }, + checkInstalled: { + funcName: "gpii.iod.checkInstalled", + args: ["{that}", "{arguments}.0"] // packageData + }, + resolvePackage: { + funcName: "gpii.iod.resolvePackage", + args: ["{that}", "{arguments}.0"] // packageData + } + }, + listeners: { + onCreate: "fluid.identity", + "onServerFound.dataSource": { + listener: "{that}.dataSource.events.onNewDataSource", + args: ["gpii.iod.packageDataSource.remote", "{arguments}.0", 20] // endpoint + }, + "onLocalPackagesFound.dataSource": { + listener: "{that}.dataSource.events.onNewDataSource", + args: ["gpii.iod.packageDataSource.local", "{arguments}.0", 10] // path + } + }, + members: { + resolvers: { + expander: { + func: "fluid.transform", + args: [ "{that}.options.resolvers", fluid.getGlobalValue ] + } + }, + fetcher: { + expander: { + func: "gpii.resolversToFetcher", + args: "{that}.resolvers" + } + }, + // The IoD server, set from onServerFound + endpoint: null + }, + resolvers: { + exists: "gpii.iod.existsResolver" + } +}); + +/** + * Resolver for ${{exists}.path}, determines if a filesystem path exists. The path can include environment variables, + * named between two '%' symbols (like %this%), to work around the resolvers not supporting nested expressions. + * + * @param {String} path The path to test. Environment variables within two '%' symbols are expanded. + * @return {Boolean} true if the path exists. + */ +gpii.iod.existsResolver = function (path) { + var expandedPath = path.replace(/%([^% ]+)%/g, function (match, name) { + return process.env[name]; + }); + return fs.existsSync(expandedPath); +}; + +/** + * Resolves ${} variables of fields in a packageData. + * + * @param {Component} that The gpii.iod.packages instance. + * @param {PackageData} packageData The package. + * @return {PackageData} A copy of the package data, with resolved fields. + */ +gpii.iod.resolvePackage = function (that, packageData) { + var result; + + if (packageData._original) { + // This package has already been resolved; work on the original copy. + result = gpii.iod.resolvePackage(that, packageData._original); + } else { + result = fluid.copy(packageData); + + // Allow references to the package itself via "${{this}.field}". + var fetchers = gpii.combineFetchers(that.fetcher, gpii.resolversToFetcher({"this": result})); + // Run the resolvers first, so the real values can be used in the transforms + result = that.variableResolver.resolve(result, fetchers); + + // Transform the package data. Because the same object is being used as the rules, just transform each field which + // have a transform rule, to avoid using malformed rules. + fluid.each(result, function (value, key) { + if (value && (value.transform || value.literalValue) && key !== "_original") { + var newValue = value; + var count = 0; + // Transform the field, until it's no longer a transform rule. Meaning, if the output is another field + // which has yet to be transformed, then transform it. + do { + if (++count > fluid.strategyRecursionBailout) { + fluid.log(fluid.logLevel.WARN, "ERROR: resolvePackage transform got too deep"); + newValue = undefined; + break; + } else { + newValue = fluid.model.transformWithRules(result, {out: newValue}).out; + } + } while (newValue && (newValue.transform || newValue.literalValue)); + result[key] = newValue; + } + }); + + // Stash the original, so it can be re-resolved. + result._original = fluid.freezeRecursive(packageData); + } + + return result; +}; + +/** + * Retrieve the package metadata. + * + * @param {Component} that The gpii.iod.packages instance. + * @param {Object} packageRequest Containing packageName, language, version. + * @param {String} packageRequest.packageName Name of the package. + * @param {String} packageRequest.version [optional] Version. + * @param {String} packageRequest.language [optional] Language code with optional country code (en, en-US, es-ES). + * @return {Promise} Resolves to an object containing package information. + */ +gpii.iod.getPackageData = function (that, packageRequest) { + fluid.log("IoD: Getting package info for " + packageRequest.packageName); + + var promise = fluid.promise(); + + that.dataSource.get({ + packageName: packageRequest.packageName, + language: packageRequest.language, + version: packageRequest.version + }).then(function (packageResponse) { + fluid.log("IoD: Package response: ", packageResponse); + /** @type {PackageData} */ + var packageData = packageResponse.packageData; + + if (packageData.name === packageRequest.packageName) { + if (packageResponse.installer) { + if (packageResponse.baseUrl && !packageResponse.installer.includes("://")) { + packageData.installerSource = gpii.iod.joinUrl(packageResponse.baseUrl, packageResponse.installer); + } else { + packageData.installerSource = packageResponse.installer; + } + } + + if (packageRequest.language && packageData.languages) { + // Merge the language-specific info. + var lang = gpii.iod.matchLanguage(Object.keys(packageData.languages), packageRequest.language); + if (lang) { + Object.assign(packageData, packageData.languages[lang]); + packageData.language = lang; + } + } + + var resolvedPackageData = that.resolvePackage(packageData); + promise.resolve(resolvedPackageData); + } else { + promise.reject({ + isError: true, + message: "Unable to get package " + packageRequest.packageName + + ": Incorrect package name '" + packageData.name + "'" + }); + } + }, function (err) { + promise.reject({ + isError: true, + message: "Unable to get package " + packageRequest.packageName + ": " + (err.message || "unknown error"), + error: err + }); + }); + + return promise; +}; + +/** + * Finds the best language from a list of available languages, using the following priority: + * - Exact match with country code + * - Exact match without country code + * - First language, ignoring country code. + * + * @param {Array} languages The list of available languages, with optional country code (en, en-US, es-ES) + * @param {String} language The preferred language. + * @return {String} The closest matching item from languages. + */ +gpii.iod.matchLanguage = function (languages, language) { + languages = fluid.makeArray(languages); + + // Exact match. + var index = languages.indexOf(language); + var match = index >= 0 && languages[index]; + + if (!match) { + var langCode = language.substr(0, 2); + // Language without country. + if (language.length > 2) { + index = languages.indexOf(language); + match = index >= 0 && languages[index]; + } + + if (!match) { + // Ignore the country. + match = languages.find(function (lang) { + return lang.substr(0, 2) === langCode; + }); + } + } + + return match; +}; + +/** + * Determines if a package is installed. + * + * @param {Component} that The gpii.iod.packages instance. + * @param {PackageData} packageData The package data. + * @return {Boolean} true if the package is installed. + */ +gpii.iod.checkInstalled = function (that, packageData) { + + // Update the isInstalled, to reflect the current situation. + packageData = that.resolvePackage(packageData); + + var isInstalled = packageData.isInstalled; + if (fluid.isPlainObject(isInstalled)) { + isInstalled = isInstalled.isInstalled || isInstalled.value; + } + + return isInstalled === undefined || !!fluid.coerceToPrimitive(isInstalled); +}; diff --git a/gpii/node_modules/gpii-iod/test/all-tests.js b/gpii/node_modules/gpii-iod/test/all-tests.js new file mode 100644 index 000000000..2c40e6c08 --- /dev/null +++ b/gpii/node_modules/gpii-iod/test/all-tests.js @@ -0,0 +1,6 @@ +"use strict"; + +require("./installOnDemandTests.js"); +require("./packageInstallerTests.js"); +require("./packagesTests.js"); +require("./packageDataSourceTests.js"); diff --git a/gpii/node_modules/gpii-iod/test/common.js b/gpii/node_modules/gpii-iod/test/common.js new file mode 100644 index 000000000..38134203f --- /dev/null +++ b/gpii/node_modules/gpii-iod/test/common.js @@ -0,0 +1,131 @@ +/* + * IoD Tests - package data source. + * + * Copyright 2020 Raising the Floor - International + * + * Licensed under the New BSD license. You may not use this file except in + * compliance with this License. + * + * The R&D leading to these results received funding from the + * Department of Education - Grant H421A150005 (GPII-APCP). However, + * these results do not necessarily represent the policy of the + * Department of Education, and you should not assume endorsement by the + * Federal Government. + * + * You may obtain a copy of the License at + * https://github.com/GPII/universal/blob/master/LICENSE.txt + */ + +"use strict"; + +var fluid = require("infusion"); +fluid.require("kettle"); + +var crypto = require("crypto"), + path = require("path"), + fs = require("fs"), + mkdirp = require("mkdirp"), + json5 = require("json5"); + +var gpii = fluid.registerNamespace("gpii"); +fluid.registerNamespace("gpii.tests.iod"); + +require("gpii-iodServer"); + +/** + * Signs `data` with the private key from `keyPair`. + * @param {Object} data The object to sign. + * @param {Object} keyPair The key to sign it with. + * @return {Object} The `string` and `signature` of the data. + */ +gpii.tests.iod.generateSignedData = function (data, keyPair) { + var signedData = gpii.iodServer.packageFile.signPackageData(data, keyPair); + + return { + string: signedData.buffer.toString("utf8"), + signature: signedData.signature + }; +}; + +gpii.tests.iod.keyPair = null; + +/** + * Generates a key pair. + * @param {String} passphrase [optional] The passphrase for the private key [default: "test"]. + * @return {Object} Object containing the private `key`, the corresponding `publicKey` and its `fingerprint`. + */ +gpii.tests.iod.generateKeyPair = function (passphrase) { + if (!gpii.tests.iod.keyPair) { + if (!passphrase) { + passphrase = "test"; + } + + var keyPair = crypto.generateKeyPairSync("rsa", { + modulusLength: 4096, + publicKeyEncoding: { + type: "spki", + format: "pem" + }, + privateKeyEncoding: { + type: "pkcs1", + format: "pem", + cipher: "aes-128-cbc", + passphrase: passphrase + } + }); + + // Include the passphrase + keyPair.passphrase = passphrase; + // signPackageData expects the private key to be `key`. + keyPair.key = keyPair.privateKey; + delete keyPair.privateKey; + + // Get the key (without the PEM header+trailer) + var keyBinary = gpii.iodServer.packageFile.readPEM(keyPair.publicKey); + // Generate the finger print + keyPair.fingerprint = crypto.createHash("sha256").update(keyBinary).digest("base64"); + keyPair.passphrase = passphrase; + gpii.tests.iod.keyPair = keyPair; + } + return gpii.tests.iod.keyPair; +}; + +/** + * Creates the .morphic-packages file, from local-packages.json5 and the contents of ./packageData/. + * + * @param {String} inputFile The local-packages.json5 file. + * @param {String} outputFile The output file, .morphic-packages. + * @param {Object} keyPair The private and public key object. + */ +gpii.tests.iod.generateLocalPackages = function (inputFile, outputFile, keyPair) { + var localPackages = json5.parse(fs.readFileSync(path.resolve(__dirname, inputFile))); + + // Add the files in the packageData directory + var dir = path.join(__dirname, "packageData"); + fluid.each(fs.readdirSync(dir), function (packageDataFile) { + var packageData = json5.parse(fs.readFileSync(path.join(dir, packageDataFile))); + localPackages.packages[packageData.name] = { + packageData: packageData + }; + }); + + // Replace the packageData objects with a serialised string with signature + fluid.each(localPackages.packages, function (localPackage) { + // If it's already a string, leave as-is + if (typeof(localPackage.packageData) !== "string") { + var signed = gpii.tests.iod.generateSignedData(localPackage.packageData, keyPair); + localPackage.packageData = signed.string; + if (!localPackage.packageDataSignature) { + localPackage.packageDataSignature = signed.signature.toString("base64"); + } + } + }); + + var outputPath = path.resolve(__dirname, outputFile); + fs.writeFileSync(outputPath, json5.stringify(localPackages, null, " ")); + + // Create a file that exists. + var packagesDir = path.join(path.dirname(outputPath), "packages"); + mkdirp.sync(packagesDir); + fs.writeFileSync(path.join(packagesDir, "existing-file"), "this file exists"); +}; diff --git a/gpii/node_modules/gpii-iod/test/installOnDemandTests.js b/gpii/node_modules/gpii-iod/test/installOnDemandTests.js new file mode 100644 index 000000000..b925b533c --- /dev/null +++ b/gpii/node_modules/gpii-iod/test/installOnDemandTests.js @@ -0,0 +1,579 @@ +/* + * IoD Tests. + * + * Copyright 2018 Raising the Floor - International + * + * Licensed under the New BSD license. You may not use this file except in + * compliance with this License. + * + * The R&D leading to these results received funding from the + * Department of Education - Grant H421A150005 (GPII-APCP). However, + * these results do not necessarily represent the policy of the + * Department of Education, and you should not assume endorsement by the + * Federal Government. + * + * You may obtain a copy of the License at + * https://github.com/GPII/universal/blob/master/LICENSE.txt + */ + +"use strict"; + +var os = require("os"), + fs = require("fs"), + path = require("path"), + rimraf = require("rimraf"); + +var fluid = require("infusion"); +var kettle = fluid.require("kettle"); +kettle.loadTestingSupport(); + +var jqUnit = fluid.require("node-jqunit"); +var gpii = fluid.registerNamespace("gpii"); + +fluid.registerNamespace("gpii.tests.iod"); + +require("./common.js"); +require("../index.js"); + +var teardowns = []; + +jqUnit.module("gpii.tests.iod", { + teardown: function () { + while (teardowns.length) { + teardowns.pop()(); + } + } +}); + +gpii.tests.iod.startInstallerTests = fluid.freezeRecursive([ + { + packageRequest: "package1", + expect: { + installer: "gpii.tests.iod.testInstaller1", + packageName: "package1" + }, + resolveValue: true + }, + { + packageRequest: "no-such-package", + expect: "reject" + }, + { + packageRequest: "unknownType", + expect: "reject" + }, + { + packageRequest: { + packageName: "package1" + }, + expect: null, + resolveValue: false + }, + { + packageRequest: { + packageName: "package2" + }, + expect: { + installer: "gpii.tests.iod.testInstaller2", + packageName: "package2" + }, + resolveValue: true + }, + { + packageRequest: { + packageName: "languages", + language: "es-ES" + }, + expect: { + installer: "gpii.tests.iod.testInstaller1", + packageName: "languages" + }, + resolveValue: true + }, + { + packageRequest: { + packageName: "languages", + language: "nl-NL" + }, + expect: null, + resolveValue: false + }, + { + packageRequest: "failInstall", + expect: "reject" + } +]); + +fluid.defaults("gpii.tests.iod", { + gradeNames: [ "gpii.iod", "gpii.lifecycleManager", "gpii.userListeners.usb" ], + + listeners: { + //"onCreate.discoverPackageSources": null, + "onCreate.readInstallations": null, + "onCreate.generateData": { + funcName: "gpii.tests.iod.generateLocalPackages", + args: [ + "local-packages.json5", + "localPackages/.morphic-packages", + "{that}.options.keyPair" + ] + } + }, + invokers: { + readInstallations: "fluid.identity", + writeInstallation: "fluid.identity" + }, + model: { + loginChange: null + }, + members: { + funcCalled: {} + }, + installerGrades: { + "testPackageType1": "gpii.tests.iod.testInstaller1", + "testPackageType2a": "gpii.tests.iod.testInstaller2", + "testPackageType2b": "gpii.tests.iod.testInstaller2", + "testFailPackageType": "gpii.tests.iod.testInstallerFail" + }, + keyPair: "@expand:gpii.tests.iod.generateKeyPair()", + config: { + trustedKeys: { + packagesTest: "{that}.options.keyPair.fingerprint" + }, + packageSources: [ + path.join(__dirname, "localPackages") + ] + } +}); + +fluid.defaults("gpii.tests.iod.testInstaller1", { + gradeNames: ["fluid.component", "gpii.iod.packageInstaller"], + + invokers: { + installPackage: "fluid.identity", + uninstallPackage: { + funcName: "gpii.tests.iod.testInstaller1.testFunctionCalled", + args: ["{that}", "{iod}", "uninstallPackage"] + }, + startInstaller: { + funcName: "gpii.tests.iod.testInstaller1.testFunctionCalled", + args: ["{that}", "{iod}", "startInstaller"] + } + } +}); + +fluid.defaults("gpii.tests.iod.testInstaller2", { + gradeNames: [ "gpii.tests.iod.testInstaller1"] +}); + +fluid.defaults("gpii.tests.iod.testInstallerFail", { + gradeNames: ["gpii.tests.iod.testInstaller1"], + testReject: "startInstaller" +}); + +/** + * Test function for packageInstaller, to check if a certain function has been called. + * @param {Component} that The gpii.tests.iod.testInstaller1 instance. + * @param {Component} iod The gpii.test.iod instance. + * @param {String} funcName Name of the function that id being tests. + * @return {Promise} A resolved promise. + */ +gpii.tests.iod.testInstaller1.testFunctionCalled = function (that, iod, funcName) { + if (iod.funcCalled[funcName]) { + jqUnit.fail(funcName + " called twice"); + } + iod.funcCalled[funcName] = { + installer: that.typeName, + packageName: that.installation && that.installation.packageName + }; + + var promise = fluid.promise(); + if (that.options.testReject === funcName || iod.options.testReject === funcName) { + promise.reject({ + isError: true, + error: "Test failure" + }); + } else { + promise.resolve(); + } + + return promise; +}; + +///* +jqUnit.test("test getWorkingPath", function () { + + var safeToRemove = false; + var packageName = "test" + Math.random().toString(36).substring(2); + var result = gpii.iod.getWorkingPath(packageName); + + jqUnit.assertTrue("getWorkingPath must return something", !!result); + jqUnit.assertEquals("fullPath must be a string", "string", typeof result.fullPath); + jqUnit.assertEquals("createdPath must be a string", "string", typeof result.createdPath); + + try { + jqUnit.assertNotEquals("fullPath must contain the package name", result.fullPath.indexOf(packageName)); + + var isParent = result.fullPath.startsWith(result.createdPath + path.sep); + jqUnit.assertTrue("The first created directory must be a parent of the full path", isParent); + + safeToRemove = isParent; + + var isTempDirParent = result.fullPath.startsWith(os.tmpdir()); + jqUnit.assertTrue("The path must be a subdirectory of the system's temporary directory", isTempDirParent); + + safeToRemove = safeToRemove && isTempDirParent; + + + // These two aren't supposed to be guaranteed, however using a random package name should have ensured this. + jqUnit.assertNotEquals("The first created directory must not be the full path", + result.createdPath, result.fullPath); + jqUnit.assertNotEquals("fullPath must contain the package name", result.fullPath.indexOf(packageName)); + + try { + var stats = fs.lstatSync(result.fullPath); + jqUnit.assertTrue("fullPath must be a directory", stats.isDirectory()); + } catch (e) { + fluid.log("Error checking the existence of result.fullPath"); + jqUnit.fail(e); + } + + var fullPathContents = fs.readdirSync(result.fullPath); + jqUnit.assertEquals("fullPath must be an empty directory", 0, fullPathContents.length); + + var createdPathContents = fs.readdirSync(result.createdPath); + jqUnit.assertEquals("createdPath must only contain a single file", 1, createdPathContents.length); + + } finally { + // Remove directories. If the test failed, the paths could point to anything. So, rimraf is not used which will + // ensure only the directories are removed, and any other content remain. + if (safeToRemove) { + var parts = result.fullPath.split(path.sep); + var tmpDir = os.tmpdir(); + while (parts.length > 0) { + var dir = parts.join(path.sep); + parts.pop(); + if ((dir === result.createdPath) || (dir === tmpDir)) { + break; + } else { + fs.rmdirSync(dir); + } + } + } + } +}); + +// Test requirePackage correctly starts the installer. +jqUnit.asyncTest("test requirePackage", function () { + var tests = gpii.tests.iod.startInstallerTests; + jqUnit.expect(tests.length * 3); + var iod = gpii.tests.iod(); + + var testIndex = -1; + var nextTest = function () { + if (++testIndex >= tests.length) { + iod.destroy(); + jqUnit.start(); + return; + } + + var test = tests[testIndex]; + var suffix = " - test:" + testIndex; + + iod.funcCalled.startInstaller = null; + var p = iod.requirePackage(test.packageRequest); + + jqUnit.assertTrue("requirePackage must return a promise" + suffix, fluid.isPromise(p)); + + p.then(function (value) { + jqUnit.assertDeepEq("requirePackage must resolve with the expected value" + suffix, + test.resolveValue, value); + jqUnit.assertDeepEq("startInstaller must have been called correctly" + suffix, + test.expect, iod.funcCalled.startInstaller); + process.nextTick(nextTest); + }, function (reason) { + if (test.expect !== "reject") { + fluid.log("reject reason: ", reason); + } + jqUnit.assertEquals("packageData must only reject if expected" + suffix, test.expect, "reject"); + jqUnit.assert("balance the assert count"); + process.nextTick(nextTest); + }); + }; + + nextTest(); +}); + +jqUnit.asyncTest("test installation storage", function () { + + var dir = path.join(os.tmpdir(), "gpii-test" + Math.random()); + + fs.mkdirSync(dir); + teardowns.push(function () { + rimraf.sync(dir); + }); + + var iod = gpii.tests.iod({ + invokers: { + uninstallPackage: { + funcName: "gpii.tests.iod.testInstaller1.testFunctionCalled", + args: ["{that}", "{iod}", "uninstallPackage"] + } + } + }); + + var testData = { + existingInst: { + input: { + id: "existing-installation" + }, + expect: { + id: "existing-installation" + } + }, + newInst: { + input: { + id: "new-installation" + }, + expectFile: { + id: "new-installation" + }, + expectRead: { + id: "new-installation", + required: false, + uninstalling: false + } + }, + updatedInst: { + input: { + id: "new-installation", + test1: "something" + }, + expectFile: { + id: "new-installation", + test1: "something" + }, + expectRead: { + id: "new-installation", + test1: "something", + required: false, + uninstalling: false + } + } + }; + + iod.installations = {}; + iod.installations[testData.existingInst.input.id] = testData.existingInst.input; + + var origInstallations = Object.assign({}, iod.installations); + + // No files exist - check the data is the same. + var p = gpii.iod.readInstallations(iod, dir); + + jqUnit.assertTrue("readInstallations should return a promise", fluid.isPromise(p)); + + p.then(function () { + jqUnit.assertDeepEq("readInstallation on an empty directory shouldn't change anything", + origInstallations, iod.installations); + + jqUnit.assertFalse("uninstallPackage should not have been called", iod.funcCalled.uninstallPackage); + + // Write the installation data. + var file = gpii.iod.writeInstallation(iod, dir, testData.newInst.input); + + jqUnit.assertEquals("writeInstallation should write to the correct file", + path.join(dir, "iod-installation." + testData.newInst.input.id + ".json"), file); + + // Check it was written correctly. + var writtenContent = fs.readFileSync(file); + var writtenObject = JSON.parse(writtenContent); + + jqUnit.assertDeepEq("writeInstallation should write the correct data", + testData.newInst.expectFile, writtenObject); + + // Check it gets loaded. + gpii.iod.readInstallations(iod, dir).then(function () { + jqUnit.assertDeepEq("readInstallations should read the correct data", + testData.newInst.expectRead, iod.installations[testData.newInst.input.id]); + + // jqUnit.assertTrue("uninstallPackage should have been called", !!iod.funcCalled.uninstallPackage); + // iod.funcCalled.uninstallPackage = null; + + // Overwrite the existing file. + var file = gpii.iod.writeInstallation(iod, dir, testData.updatedInst.input); + + // Check it was written correctly. + var writtenContent = fs.readFileSync(file); + var writtenObject = JSON.parse(writtenContent); + jqUnit.assertDeepEq("writeInstallation should overwrite with the correct data", + testData.updatedInst.expectFile, writtenObject); + + // Check the updated file gets loaded. + gpii.iod.readInstallations(iod, dir).then(function () { + jqUnit.assertDeepEq("readInstallations should update with the correct data", + testData.updatedInst.expectRead, iod.installations[testData.newInst.input.id]); + + // jqUnit.assertTrue("uninstallPackage should have been called again", + // !!iod.funcCalled.uninstallPackage); + + jqUnit.start(); + }); + }); + }); +}); + +// Tests package gets uninstalled after unrequirePackage is called. +jqUnit.asyncTest("test uninstallation", function () { + + jqUnit.expect(4); + + var iod = gpii.tests.iod(); + + var packageName = "package1"; + + iod.requirePackage(packageName).then(function () { + var installation = fluid.find(iod.installations, function (inst) { + return inst.packageName === packageName ? inst : undefined; + }); + + jqUnit.assertTrue("Package should have installed", installation && installation.installed); + jqUnit.assertTrue("Package should have been added to the list", !!iod.installations[installation.id]); + + iod.unrequirePackage(packageName); + + jqUnit.assertTrue("Package should have been set to be not required", !installation.require); + + var promise = iod.uninstallPackage(packageName); + + promise.then(function () { + // There's no promise or event, so just poll. + if (iod.installations[installation.id]) { + fluid.fail("Package was not removed"); + } else { + jqUnit.assertTrue("packageInstaller.uninstallPackage should have been called", + !!iod.funcCalled.uninstallPackage); + jqUnit.start(); + } + }, jqUnit.fail); + + }, jqUnit.fail); +}); + + +// Tests package whose uninstallation fails gets uninstalled after a restart. +jqUnit.asyncTest("test uninstallation after restart", function () { + + var dir = path.join(os.tmpdir(), "gpii-test" + Math.random()); + fs.mkdirSync(dir); + teardowns.push(function () { + rimraf.sync(dir); + }); + + var iodOptions = { + invokers: { + readInstallations: { + funcName: "gpii.iod.readInstallations", + args: ["{that}", dir ] + }, + writeInstallation: { + funcName: "gpii.iod.writeInstallation", + args: ["{that}", dir, "{arguments}.0"] + }, + uninstallPackage: { + funcName: "gpii.tests.iod.testInstaller1.testFunctionCalled", + args: ["{that}", "{iod}", "uninstallPackage"] + } + }, + testReject: "uninstallPackage", + autoRemoveDelay: 0 + }; + + var iod = gpii.tests.iod(iodOptions); + + var packageName = "package1"; + + // Wait for uninstallPackage to be called (should be called instantly) + var waitForUninstall = function () { + var promise = fluid.promise(); + var retries = 50; + var retry = function () { + if (iod.funcCalled.uninstallPackage) { + promise.resolve(); + } else { + if (--retries > 0) { + setTimeout(retry, 100); + } else { + promise.reject("Package was not removed"); + } + } + }; + retry(); + + return promise; + }; + + // Install the package, and fail uninstall. + iod.requirePackage(packageName).then(function () { + var installation = fluid.find(iod.installations, function (inst) { + return inst.packageName === packageName ? inst : undefined; + }); + + jqUnit.assertTrue("Package should have installed", installation && installation.installed); + jqUnit.assertTrue("Package should have been added to the list", !!iod.installations[installation.id]); + + iod.unrequirePackage(packageName); + + jqUnit.assertTrue("Package should have been set to be not required", !installation.require); + + process.nextTick(function () { + // Fake a restart by creating a new instance of iod. + iod.destroy(); + iodOptions.testReject = null; + iod = gpii.tests.iod(iodOptions); + iod.readInstallations(); + }); + + waitForUninstall().then(jqUnit.start, jqUnit.fail); + }, jqUnit.fail); +}); + +jqUnit.asyncTest("test service discovery", function () { + + jqUnit.expect(3); + + var server = require("http").createServer(); + server.listen(0, "127.0.0.1"); + + server.on("request", function (req, res) { + fluid.log("request: ", req.url); + jqUnit.assert("request made"); + res.end("hello"); + }); + + server.on("listening", function () { + var localUrl = "http://" + server.address().address + ":" + server.address().port + "/"; + fluid.log("listening: ", localUrl); + + // try checkService directly + var successPromise = gpii.iod.checkService(localUrl).then(function () { + jqUnit.assert("checkService should resolve"); + }, function (err) { + fluid.log(err); + jqUnit.fail("checkService should not reject"); + }); + + var failPromise = fluid.promise(); + // Check a local port (Gopher) which is probably closed. + gpii.iod.checkService("http://127.0.0.3:70").then(function () { + jqUnit.fail("checkService (fail test) should not resolve"); + }, function () { + jqUnit.assert("checkService (fail test) should reject"); + failPromise.resolve(); + }); + + fluid.promise.sequence([ + failPromise, + successPromise + ]).then(jqUnit.start); + }); +}); diff --git a/gpii/node_modules/gpii-iod/test/local-packages.json5 b/gpii/node_modules/gpii-iod/test/local-packages.json5 new file mode 100644 index 000000000..8225bee6b --- /dev/null +++ b/gpii/node_modules/gpii-iod/test/local-packages.json5 @@ -0,0 +1,35 @@ +/* eslint quotes: ["error", "double", { "avoidEscape": true }] */ +{ + packages: { + working: { + packageData: { + name: "working" + } + }, + renamed: { + packageData: { + name: "real-name" + } + }, + untrusted: { + // signature is valid, but the publicKey has not been trusted + packageData: '{"name":"untrusted","publicKey":"MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA9jz6Ls29OC1Nviy6r7BtHFXhNl3SBTF5kspJKHMylAZbZBGQEt/fhnSBIrsHYEG3nYP8y2Ghg2W6Yev3Q2191MIzDTSi1x838hFVxTwlunZJFXamktp8GCNh/MOW0/Db/eqsDlJ/l9AEZ8M3/4Nq0ON7k5cr6Ifh9qoK6HAhdOzQzp/GVGY3sf0mZCY2p0YJSa8zpgFwuNNTt3/ExhvXwEMIUwsBnZ4R/wERpAcG6C9GIxEzdFlZpbPASDDANBjW2kyFyqrPisKv6ZB9z6fDJ3SJBW3ZKlwdLWQf1G/DzxX9iRk2jTusuekDlhGTf+m7Vim+MmvJDQ7odA/CkwKi/ior+46lAk8H8kaXx01g1jjFr/x+LUIcmm72mQg3MLDKNQ4pIJ2Q1f9smR0Fac9l3/2cLbXaG/uASJkDJ6DoWpHwJehuEI57HCCug11i0WjrWMP4GdFpjiFA+mvC6dWEA7dDwGIRnm9X916o/L8217RzitH8VqNSu+vqVdYDT0E/vMDOPKD0jtMlDSfTUZqo9k7Bz2ugUTqBr2JATvmoFCevKjTXixs3g1sj42j20WAibzzMi//F492IsibnU4/0KR/6FuwKZ87sYsE+0sKEZNmdF/PlVEg0K6WiYkxIV+UaI4gvefCMYYQLix4RS17ZA4qZKDeAEvGZKeyFGc4ShNcCAwEAAQ=="}', + packageDataSignature: "nVJJfxKOZm1M9PyBMqGuLuG3aq9EqaMkvvpHJ26bctEO470L4Nm1JcCWzc2G9Er8W8g/CbYSRzOaCv1gQxtJTnSiHMS18irYF/gaz6p9xPJa298iUmFh84AmksCpQbdK3/mLrrdcnJQIFdu2aP8231N8KxeuZi+SNT7knGTYSHfmTqnzQJ1RuloM73YZjpgDi4MMRKT9m/f5LVfw+hXw747phIxBtjHF3Ut/FVktTNSopnlrn/r5gNK8eRo0pO1WH3PjSEzKoDbDdbm0D2y/eBvwDzyflRkZClnYEf4503kE9umKWmKk6nYPDGF6/QLv8hVso1gyK2igCIQdhcBFhVQYSfVJEUi9jC8/uia0auhFqtOPBUVYdq0+nSA6hkLZsMJ9YVquFVx89U59842G/dn/asBv2WfYXMaOJbHVtUG4dTf7mi1z2zZKgRAFzcLuCWNEHeblNsKRbXQ35o5IyltftFhjb+haamS+224ULjMtLaKIBufc8/Ep1xuANqNtsL1I4wfbROzIbWAgOCGGrm343cHDgxS7pnWH4bQrBm4Zl/rBR5Lz81pILA0wvYY1zaPikj7IIS0FXjRU7XQVoVhDGtrHkHKjwL+5sfYXLrE/pz/7T6YPPePiP/X8VVM1zwJgDMpw4jiYKsREW1g2eGlIw3VKH69g8bUp6rnpSzw=" + }, + unsigned: { + packageData: '{"name":"unsigned"}' + }, + "location-exists": { + packageData: { + name: "location-exists" + }, + installer: "packages/existing-file" + }, + "location-not-exists": { + packageData: { + name: "location-not-exists" + }, + installer: "packages/none-existing-file" + } + } +} diff --git a/gpii/node_modules/gpii-iod/test/multiDataSourceTests.js b/gpii/node_modules/gpii-iod/test/multiDataSourceTests.js new file mode 100644 index 000000000..bc94799e1 --- /dev/null +++ b/gpii/node_modules/gpii-iod/test/multiDataSourceTests.js @@ -0,0 +1,272 @@ +/* + * Tests for the multiDataSource component. + * + * Copyright 2019 Raising the Floor - International + * + * Licensed under the New BSD license. You may not use this file except in + * compliance with this License. + * + * The R&D leading to these results received funding from the + * Department of Education - Grant H421A150005 (GPII-APCP). However, + * these results do not necessarily represent the policy of the + * Department of Education, and you should not assume endorsement by the + * Federal Government. + * + * You may obtain a copy of the License at + * https://github.com/GPII/universal/blob/master/LICENSE.txt + */ + +"use strict"; + +var fluid = require("infusion"); +var kettle = fluid.require("kettle"); +kettle.loadTestingSupport(); + +var gpii = fluid.registerNamespace("gpii"); + +fluid.registerNamespace("gpii.tests.multiDataSource"); + +require("../src/multiDataSource.js"); + + +fluid.defaults("gpii.tests.multiDataSource.dataSource", { + gradeNames: ["kettle.dataSource"], + readOnlyGrade: "gpii.tests.multiDataSource", + + components: { + encoding: { + type: "kettle.dataSource.encoding.none" + } + }, + invokers: { + getImpl: { + funcName: "gpii.tests.multiDataSource.getImpl", + args: ["{that}", "{arguments}.0", "{arguments}.1"] + } + } +}); + +gpii.tests.multiDataSource.getImpl = function (that, options, directModel) { + var result; + var req = fluid.makeArray(directModel.request); + + if (req.indexOf(that.options.path) > -1 || directModel.request === "any") { + result = { + from: that.options.path + }; + } + + return result ? fluid.toPromise(result) : fluid.promise().reject({notFound: that.options.path}); +}; + + +fluid.defaults("gpii.tests.multiDataSource.tests", { + gradeNames: ["fluid.test.testEnvironment"], + + components: { + multiDataSource: { + type: "gpii.iod.multiDataSource" + }, + tester: { + type: "gpii.tests.multiDataSource.testCaseHolder" + } + } +}); + +fluid.defaults("gpii.tests.multiDataSource.testCaseHolder", { + gradeNames: ["fluid.test.testCaseHolder"], + modules: [{ + name: "multiDataSource", + tests: [{ + expect: 1, + name: "testing empty multiDataSource", + sequence: [{ + task: "{multiDataSource}.get", + args: [{request: "value"}], + reject: "jqUnit.assert", + rejectArgs: [ + "multiDataSource.get() with no root data sources should reject" + ] + }] + }, { + expect: 3, + name: "adding to multiDataSource", + sequence: [{ + func: "jqUnit.assertEquals", + args: [ + "sortedDataSources member should be empty before adding one", + 0, + "{multiDataSource}.sortedDataSources.length" + ] + }, { + func: "{multiDataSource}.events.onNewDataSource.fire", + args: ["gpii.tests.multiDataSource.dataSource", "first", 1] + }, { + func: "jqUnit.assertEquals", + args: [ + "sortedDataSources member should contain one item after adding it", + 1, + "{multiDataSource}.sortedDataSources.length" + ] + }, { + func: "{multiDataSource}.events.onNewDataSource.fire", + // 3rd is added before the 2nd, to prove the priorities work + args: ["gpii.tests.multiDataSource.dataSource", "third", 3] + }, { + func: "{multiDataSource}.events.onNewDataSource.fire", + args: ["gpii.tests.multiDataSource.dataSource", "second", 2] + }, { + func: "jqUnit.assertEquals", + args: [ + "sortedDataSources member should contain two more items after adding the second and third", + 3, + "{multiDataSource}.sortedDataSources.length" + ] + }] + }, { + expect: 12, + name: "getting from multiDataSource", + sequence: [{ + func: "jqUnit.assertEquals", + args: [ + "sortedDataSources member should be contain the items from the last test", + 3, + "{multiDataSource}.sortedDataSources.length" + ] + }, { + task: "{multiDataSource}.get", + args: [{request: "first"}], + resolve: "jqUnit.assertDeepEq", + resolveArgs: [ + "result from first source should be received for 'first'", {from: "first"}, "{arguments}.0" + ] + }, { + task: "{multiDataSource}.get", + args: [{request: "second"}], + resolve: "jqUnit.assertDeepEq", + resolveArgs: [ + "result from second source should be received for 'second'", {from: "second"}, "{arguments}.0" + ] + }, { + task: "{multiDataSource}.get", + args: [{request: "third"}], + resolve: "jqUnit.assertDeepEq", + resolveArgs: [ + "result from third source should be received for 'third'", {from: "third"}, "{arguments}.0" + ] + }, { + task: "{multiDataSource}.get", + args: [{request: "any"}], + resolve: "jqUnit.assertDeepEq", + resolveArgs: [ + "result from first source should be received for 'any'", {from: "first"}, "{arguments}.0" + ] + }, { + task: "{multiDataSource}.get", + args: [{request: "none"}], + reject: "jqUnit.assertDeepEq", + rejectArgs: [ + "reject from first source should be received for 'none'", {notFound: "first"}, "{arguments}.0" + ] + }, { + // tests ordering + task: "{multiDataSource}.get", + args: [{request: ["first", "third"]}], + resolve: "jqUnit.assertDeepEq", + resolveArgs: [ + "result from first source should be received for 'first or third'", {from: "first"}, "{arguments}.0" + ] + }, { + // tests ordering, even if added later. + task: "{multiDataSource}.get", + args: [{request: ["second", "third"]}], + resolve: "jqUnit.assertDeepEq", + resolveArgs: [ + "result from second source should be received for 'second or third'", + {from: "second"}, + "{arguments}.0" + ] + }, { + // Add a higher priority source + func: "{multiDataSource}.events.onNewDataSource.fire", + args: ["gpii.tests.multiDataSource.dataSource", "zeroth", 0] + }, { + // Check the newly added source is used first + task: "{multiDataSource}.get", + args: [{request: "any"}], + resolve: "jqUnit.assertDeepEq", + resolveArgs: [ + "result from zeroth should be received for 'any'", {from: "zeroth"}, "{arguments}.0" + ] + }, { + // Add a new source at the same priority as 'second' + func: "{multiDataSource}.events.onNewDataSource.fire", + args: ["gpii.tests.multiDataSource.dataSource", "new-second", 2] + }, { + // Check the newly added source is used - the same priority, but it's newer. + task: "{multiDataSource}.get", + args: [{request: ["new-second", "second", "third"]}], + resolve: "jqUnit.assertDeepEq", + resolveArgs: [ + "result from before-second should be received for 'before-second or second'", + {from: "new-second"}, + "{arguments}.0" + ] + }, { + // Check the new source didn't break the higher priority source + task: "{multiDataSource}.get", + args: [{request: "any"}], + resolve: "jqUnit.assertDeepEq", + resolveArgs: [ + "result from zeroth should be received for 'any' #2", {from: "zeroth"}, "{arguments}.0" + ] + }, { + // Check the sorted list is in the right order + func: "jqUnit.assertDeepEq", + args: [ + "Sorted data sources should be in the expected order", + [ + "zeroth", + "first", + "new-second", + "second", + "third" + ], + "@expand:fluid.getMembers({multiDataSource}.sortedDataSources, options.path)" + ] + }] + }, { + name: "Removing data sources", + expect: 2, + sequence: [{ + func: "{multiDataSource}.sortedDataSources.2.destroy" + }, { + // Check the destroyed source isn't in the list. + func: "jqUnit.assertDeepEq", + args: [ + "Destroyed data source should have been removed", + [ + "zeroth", + "first", + // removed: "new-second", + "second", + "third" + ], + "@expand:fluid.getMembers({multiDataSource}.sortedDataSources, options.path)" + ] + }, { + // Check things still work as expected, and no results from the removed source. + task: "{multiDataSource}.get", + args: [{request: ["new-second", "second", "third"]}], + resolve: "jqUnit.assertDeepEq", + resolveArgs: [ + "result from second should be received for 'before-second or second', after removing before-second", + {from: "second"}, + "{arguments}.0" + ] + }] + }] + }] +}); + +module.exports = kettle.test.bootstrap("gpii.tests.multiDataSource.tests"); diff --git a/gpii/node_modules/gpii-iod/test/packageData/env.json5 b/gpii/node_modules/gpii-iod/test/packageData/env.json5 new file mode 100644 index 000000000..5f4e56011 --- /dev/null +++ b/gpii/node_modules/gpii-iod/test/packageData/env.json5 @@ -0,0 +1,5 @@ +{ + "name": "env", + "test": "${{environment}.PATH}", + "packageType": "testPackageType1" +} diff --git a/gpii/node_modules/gpii-iod/test/packageData/failInstall.json5 b/gpii/node_modules/gpii-iod/test/packageData/failInstall.json5 new file mode 100644 index 000000000..2416326e9 --- /dev/null +++ b/gpii/node_modules/gpii-iod/test/packageData/failInstall.json5 @@ -0,0 +1,6 @@ +{ + "name": "failInstall", + "filename": "example.filename", + "url": "test://example", + "packageType": "testFailPackageType" +} diff --git a/gpii/node_modules/gpii-iod/test/packageData/languages.json5 b/gpii/node_modules/gpii-iod/test/packageData/languages.json5 new file mode 100644 index 000000000..cac67f445 --- /dev/null +++ b/gpii/node_modules/gpii-iod/test/packageData/languages.json5 @@ -0,0 +1,26 @@ +{ + "name": "languages", + "filename": "example.filename", + "packageType": "testPackageType1", + + "languages": { + "es": { + "filename": "file.es" + }, + "es-ES": { + "filename": "file.es-es" + }, + "es-MX": { + "filename": "file.es-mx" + }, + "zh-CN": { + "filename": "file.zh-cn" + }, + "zh-SG": { + "filename": "file.zh-sg" + }, + "nl-NL": { + "packageType": "testPackageType2a" + } + } +} diff --git a/gpii/node_modules/gpii-iod/test/packageData/package1.json5 b/gpii/node_modules/gpii-iod/test/packageData/package1.json5 new file mode 100644 index 000000000..42da7d502 --- /dev/null +++ b/gpii/node_modules/gpii-iod/test/packageData/package1.json5 @@ -0,0 +1,6 @@ +{ + "name": "package1", + "filename": "example.filename", + "url": "test://example", + "packageType": "testPackageType1" +} diff --git a/gpii/node_modules/gpii-iod/test/packageData/package2.json5 b/gpii/node_modules/gpii-iod/test/packageData/package2.json5 new file mode 100644 index 000000000..188c6f6e7 --- /dev/null +++ b/gpii/node_modules/gpii-iod/test/packageData/package2.json5 @@ -0,0 +1,6 @@ +{ + "name": "package2", + "filename": "example.filename", + "url": "test://example", + "packageType": "testPackageType2a" +} diff --git a/gpii/node_modules/gpii-iod/test/packageData/unknownType.json5 b/gpii/node_modules/gpii-iod/test/packageData/unknownType.json5 new file mode 100644 index 000000000..f032725ed --- /dev/null +++ b/gpii/node_modules/gpii-iod/test/packageData/unknownType.json5 @@ -0,0 +1,6 @@ +{ + "name": "unknownType", + "filename": "example.filename", + "url": "test://example", + "packageType": "unknown-package-type" +} diff --git a/gpii/node_modules/gpii-iod/test/packageDataSourceTests.js b/gpii/node_modules/gpii-iod/test/packageDataSourceTests.js new file mode 100644 index 000000000..0e56b22f8 --- /dev/null +++ b/gpii/node_modules/gpii-iod/test/packageDataSourceTests.js @@ -0,0 +1,698 @@ +/* + * IoD Tests - package data source. + * + * Copyright 2019 Raising the Floor - International + * + * Licensed under the New BSD license. You may not use this file except in + * compliance with this License. + * + * The R&D leading to these results received funding from the + * Department of Education - Grant H421A150005 (GPII-APCP). However, + * these results do not necessarily represent the policy of the + * Department of Education, and you should not assume endorsement by the + * Federal Government. + * + * You may obtain a copy of the License at + * https://github.com/GPII/universal/blob/master/LICENSE.txt + */ + +"use strict"; + +var fluid = require("infusion"); +var kettle = fluid.require("kettle"); +kettle.loadTestingSupport(); + +var path = require("path"), + fs = require("fs"), + url = require("url"), + json5 = require("json5"), + jqUnit = fluid.require("node-jqunit", require, "jqUnit"); + +var gpii = fluid.registerNamespace("gpii"); + +fluid.registerNamespace("gpii.tests.iodPackageData"); + +require("./common.js"); +require("../index.js"); + +require("gpii-iodServer"); + +fluid.defaults("gpii.tests.iodPackageData.tests", { + gradeNames: ["fluid.test.testEnvironment"], + + components: { + packages: { + type: "gpii.iod.packages", + options: { + events: { + onServerFound: null, + onLocalPackagesFound: null + } + } + }, + iodServer: { + type: "kettle.server", + options: { + port: 51286, + components: { + packageServer: { + type: "kettle.app", + options: { + requestHandlers: { + packages: { + route: "/iod/packages/:packageName", + method: "get", + type: "gpii.tests.iodPackageData.packagesRequest" + } + } + } + } + } + } + }, + tester: { + type: "gpii.tests.iodPackageData.testCaseHolder", + options: { + testData: "{tests}.options.testData" + } + }, + localTester: { + type: "gpii.tests.iodPackageData.testCaseHolder.packageSource", + options: { + isLocal: true, + testData: "{tests}.options.testData", + installerSource: url.pathToFileURL(path.join(__dirname, "localPackages/packages/existing-file")).toString(), + invokers: { + "createDataSource": { + func: "{packages}.events.onLocalPackagesFound.fire", + args: [path.join(__dirname, "localPackages")] + } + } + } + }, + localTester2: { + type: "gpii.tests.iodPackageData.testCaseHolder.packageSource", + options: { + isRemote: true, + testData: "{tests}.options.testData", + expect: { + installerSource: "http://127.0.0.1:51286/iod/installer/location-exists" + }, + invokers: { + "createDataSource": { + func: "{packages}.events.onServerFound.fire", + args: ["http://127.0.0.1:51286/iod"] + } + } + } + } + }, + testData: { + signing: { + keyPair: "@expand:gpii.tests.iod.generateKeyPair()", + testData: { + testField: "test value", + publicKey: "{that}.options.testData.signing.keyPair.publicKey" + }, + testDataNoKey: { + testField: "test value" + }, + signed: { + expander: { + funcName: "gpii.tests.iod.generateSignedData", + args: ["{that}.options.testData.signing.testData", "{that}.options.testData.signing.keyPair"] + } + }, + wrong: { + expander: { + funcName: "gpii.tests.iod.generateSignedData", + args: [{value: "wrong"}, "{that}.options.testData.signing.keyPair"] + } + } + }, + localPackages: { + packageA: { + packageData: { + name: "packageA" + } + }, + packageB: { + packageData: { + name: "packageB" + }, + installer: "packageDataSourceTests.js" + }, + packageC: { + packageData: { + name: "packageC" + }, + installer: "packageDataSourceTests.js", + offset: 42 + }, + packageD: { + packageData: { + name: "packageD" + }, + installer: "not/exist" + } + } + } +}); + +fluid.defaults("gpii.tests.iodPackageData.packagesRequest", { + gradeNames: ["kettle.request.http"], + invokers: { + handleRequest: { + funcName: "gpii.tests.iodPackageData.handleRequest", + args: [ + "{packageServer}", "{request}", "{request}.req.params.packageName", "{request}.req.params.lang" + ] + } + } +}); + + +/** + * Handles /packages requests. Responds with a {PackageResponse} for the given package. + * + * @param {Component} packages The gpii.iodServer.packageServer instance. + * @param {Component} request The gpii.iodServer.packageServer.packagesRequest for this request. + * @param {String} packageName Name of the requested package. + */ +gpii.tests.iodPackageData.handleRequest = function (packages, request, packageName) { + fluid.log("package requested: " + packageName); + var localPackages; + try { + localPackages = json5.parse(fs.readFileSync(path.join(__dirname, "localPackages/.morphic-packages"))); + } catch (e) { + fluid.log("Error loading local package data (probably expected): ", e.message); + localPackages = {}; + } + + var result = fluid.copy(localPackages.packages[packageName]); + if (result) { + if (result.installer) { + result.installer = "/installer/" + packageName; + delete result.location; + } + request.events.onSuccess.fire(result); + } else { + request.events.onError.fire({ + message: "No such package", + statusCode: 404 + }); + } +}; + + +gpii.tests.iodPackageData.assertContains = function (msg, expected, actual) { + var contains = (actual || "").toString().includes(expected); + if (contains) { + jqUnit.assert(msg); + } else { + jqUnit.assertEquals(msg, expected, actual); + } +}; + +fluid.defaults("gpii.tests.iodPackageData.testCaseHolder", { + gradeNames: ["fluid.test.testCaseHolder"], + modules: [{ + name: "verifySignedJSON tests", + tests: [{ + expect: 1, + name: "valid data", + sequence: [{ + task: "gpii.iod.verifySignedJSON", + args: [ + "{that}.options.testData.signing.signed.string", + "{that}.options.testData.signing.signed.signature", + {key1: "{that}.options.testData.signing.keyPair.fingerprint"} + ], + resolve: "jqUnit.assertDeepEq", + resolveArgs: [ + "verifySignedJSON with valid data should resolve with parsed data", + "@expand:JSON.parse({that}.options.testData.signing.signed.string)", + "{arguments}.0" + ] + }] + }, { + expect: 1, + name: "verifySignedJSON with invalid signed string should reject", + sequence: [{ + task: "gpii.iod.verifySignedJSON", + args: [ + "{that}.options.testData.signing.wrong.string", + "{that}.options.testData.signing.signed.signature", + {key1: "{that}.options.testData.signing.keyPair.fingerprint"} + ], + reject: "jqUnit.assertEquals", + rejectArgs: [ + "Reject reason should be the expected value", + "Signed JSON data failed verification: Signature could not be verified.", + "{arguments}.0.message" + ] + }] + }, { + expect: 1, + name: "verifySignedJSON with the wrong signature should reject", + sequence: [{ + task: "gpii.iod.verifySignedJSON", + args: [ + "{that}.options.testData.signing.signed.string", + "{that}.options.testData.signing.wrong.signature", + {key1: "{that}.options.testData.signing.keyPair.fingerprint"} + ], + reject: "jqUnit.assertEquals", + rejectArgs: [ + "Reject reason should be the expected value", + "Signed JSON data failed verification: Signature could not be verified.", + "{arguments}.0.message" + ] + }] + }, { + expect: 1, + name: "verifySignedJSON with a bad signature should reject", + sequence: [{ + task: "gpii.iod.verifySignedJSON", + args: [ + "{that}.options.testData.signing.signed.string", + Buffer.from("bad signature"), + {key1: "{that}.options.testData.signing.keyPair.fingerprint"} + ], + reject: "jqUnit.assertEquals", + rejectArgs: [ + "Reject reason should be the expected value", + "Signed JSON data failed verification: Signature could not be verified.", + "{arguments}.0.message" + ] + }] + }, { + expect: 1, + name: "verifySignedJSON with an empty signature should reject", + sequence: [{ + task: "gpii.iod.verifySignedJSON", + args: [ + "{that}.options.testData.signing.signed.string", + Buffer.from([]), + {key1: "{that}.options.testData.signing.keyPair.fingerprint"} + ], + reject: "jqUnit.assertEquals", + rejectArgs: [ + "Reject reason should be the expected value", + "Signed JSON data failed verification: Signature could not be verified.", + "{arguments}.0.message" + ] + }] + }, { + expect: 1, + name: "verifySignedJSON with unknown fingerprint should reject", + sequence: [{ + task: "gpii.iod.verifySignedJSON", + args: [ + "{that}.options.testData.signing.signed.string", + "{that}.options.testData.signing.signed.signature", + {key1: "another-fingerprint"} + ], + reject: "jqUnit.assertEquals", + rejectArgs: [ + "Reject reason should be the expected value", + "Signed JSON data failed verification: Signed by an unknown key.", + "{arguments}.0.message" + ] + }] + }, { + expect: 1, + name: "verifySignedJSON with no known fingerprints should reject", + sequence: [{ + task: "gpii.iod.verifySignedJSON", + args: [ + "{that}.options.testData.signing.signed.string", + "{that}.options.testData.signing.signed.signature", + {} + ], + reject: "jqUnit.assertEquals", + rejectArgs: [ + "Reject reason should be the expected value", + "Signed JSON data failed verification: Signed by an unknown key.", + "{arguments}.0.message" + ] + }] + }, { + expect: 1, + name: "verifySignedJSON with no public key in object should reject", + sequence: [{ + task: "gpii.iod.verifySignedJSON", + args: [ + "@expand:JSON.stringify({that}.options.testData.signing.testDataNoKey)", + "{that}.options.testData.signing.signed.signature", + {key1: "{that}.options.testData.signing.keyPair.fingerprint"} + ], + reject: "jqUnit.assertEquals", + rejectArgs: [ + "Reject reason should be the expected value", + "Signed JSON data failed verification: JSON object did not contain a publicKey field.", + "{arguments}.0.message" + ] + }] + }, { + expect: 1, + name: "verifySignedJSON with invalid JSON should reject", + sequence: [{ + task: "gpii.iod.verifySignedJSON", + args: [ + "} invalid json:", + "{that}.options.testData.signing.signed.signature", + {key1: "{that}.options.testData.signing.keyPair.fingerprint"} + ], + reject: "jqUnit.assertEquals", + rejectArgs: [ + "Reject reason should be the expected value", + "Error while verifying signed JSON data: Unexpected token } in JSON at position 0", + "{arguments}.0.message" + ] + }] + }] + }, { + name: "checkPackageSignature tests", + tests: [{ + expect: 1, + name: "valid data", + sequence: [{ + task: "gpii.iod.checkPackageSignature", + args: [ + { + packageData: "{that}.options.testData.signing.signed.string", + packageDataSignature: "{that}.options.testData.signing.signed.signature" + }, + {key1: "{that}.options.testData.signing.keyPair.fingerprint"} + ], + resolve: "jqUnit.assertDeepEq", + resolveArgs: [ + "checkPackageSignature with valid data should resolve with parsed packageData", + { + packageData: "@expand:JSON.parse({that}.options.testData.signing.signed.string)", + packageDataSignature: "{that}.options.testData.signing.signed.signature" + }, + "{arguments}.0" + ] + }] + }, { + expect: 1, + name: "invalid data", + sequence: [{ + task: "gpii.iod.checkPackageSignature", + args: [ + { + packageData: "{that}.options.testData.signing.signed.string", + packageDataSignature: "{that}.options.testData.signing.wrong.signature" + }, + {key1: "{that}.options.testData.signing.keyPair.fingerprint"} + ], + reject: "jqUnit.assertDeepEq", + rejectArgs: [ + "checkPackageSignature with invalid data should reject", + "Signed JSON data failed verification: Signature could not be verified.", + "{arguments}.0.message" + ] + }] + }] + }, { + name: "getLocal tests", + tests: [{ + expect: 5, + name: "local package source", + sequence: [{ + task: "gpii.iod.packageDataSource.getLocal", + args: [ + "@expand:fluid.toPromise({that}.options.testData.localPackages)", + { + packageName: "unknown-package" + }, + "" + ], + reject: "jqUnit.assertDeepEq", + rejectArgs: [ + "getLocal should reject with an unknown package", + "Package unknown-package not found", + "{arguments}.0.message" + ] + }, { + task: "gpii.iod.packageDataSource.getLocal", + args: [ + "@expand:fluid.toPromise({that}.options.testData.localPackages)", + { + packageName: "packageA" + }, + "" + ], + resolve: "jqUnit.assertDeepEq", + resolveArgs: [ + "getLocal should resolve with the requested package", + "{that}.options.testData.localPackages.packageA", + "{arguments}.0" + ] + }, { + task: "gpii.iod.packageDataSource.getLocal", + args: [ + "@expand:fluid.toPromise({that}.options.testData.localPackages)", + { + packageName: "packageB" + }, + __dirname + ], + resolve: "jqUnit.assertDeepEq", + resolveArgs: [ + "getLocal should resolve with the correct location", + { + packageData: { + name: "packageB" + }, + installer: "file://" + __filename + }, + "{arguments}.0" + ] + }, { + task: "gpii.iod.packageDataSource.getLocal", + args: [ + "@expand:fluid.toPromise({that}.options.testData.localPackages)", + { + packageName: "packageC" + }, + __dirname + ], + resolve: "jqUnit.assertDeepEq", + resolveArgs: [ + "getLocal should resolve with the correct location and offset", + { + packageData: { + name: "packageC" + }, + installer: "file://" + __filename + "?offset=42", + offset: 42 + }, + "{arguments}.0" + ] + }, { + task: "gpii.iod.packageDataSource.getLocal", + args: [ + "@expand:fluid.toPromise({that}.options.testData.localPackages)", + { + packageName: "packageD" + }, + __dirname + ], + reject: "jqUnit.assertDeepEq", + rejectArgs: [ + "getLocal should reject with a non-existing installer", + "File for packageD not found", + "{arguments}.0.message" + ] + }] + }] + }] +}); + +fluid.defaults("gpii.tests.iodPackageData.testCaseHolder.packageSource", { + gradeNames: ["fluid.test.testCaseHolder"], + modules: [{ + name: "packageSource tests", + tests: [{ + expect: 0, + name: "Generating .morphic-packages", + sequence: [{ + func: "gpii.tests.iod.generateLocalPackages", + args: [ + "local-packages.json5", + "localPackages/.morphic-packages", + "{that}.options.testData.signing.keyPair" + ] + }, { + func: "fluid.set", + args: [ + "{packages}.options", + "trustedKeys.testkey1", + "{that}.options.testData.signing.keyPair.fingerprint" + ] + }] + }, { + expect: 0, + name: "Add local package source", + sequence: [{ + func: "{that}.createDataSource", + args: [path.join(__dirname, "localPackages")] + }] + }] + }, { + name: "package tests", + tests: [{ + expect: 1, + name: "Testing successful package", + sequence: [{ + task: "{packages}.getPackageData", + args: [ + {packageName: "working"} + ], + resolve: "jqUnit.assertEquals", + resolveArgs: [ + "getPackageData should resolve with the requested package", + "working", + "{arguments}.0.name" + ] + }] + }, { + expect: 1, + name: "Testing unknown package", + sequence: [{ + task: "{packages}.getPackageData", + args: [ + {packageName: "unknown-package"} + ], + reject: "gpii.tests.iodPackageData.assertContains", + rejectArgs: [ + "getPackageData should reject for unknown package", + "Unable to get package unknown-package:", + "{arguments}.0.message" + ] + }] + }, { + expect: 1, + name: "Testing wrong named package", + sequence: [{ + task: "{packages}.getPackageData", + args: [ + {packageName: "renamed"} + ], + reject: "jqUnit.assertEquals", + rejectArgs: [ + "getPackageData should reject for renamed package", + "Unable to get package renamed: Incorrect package name 'real-name'", + "{arguments}.0.message" + ] + }] + }, { + expect: 1, + name: "Testing untrusted package", + sequence: [{ + task: "{packages}.getPackageData", + args: [ + {packageName: "untrusted"} + ], + reject: "jqUnit.assertEquals", + rejectArgs: [ + "getPackageData should reject for untrusted package", + "Unable to get package untrusted: Signed JSON data failed verification: Signed by an unknown key.", + "{arguments}.0.message" + ] + }] + }, { + expect: 1, + name: "Testing unsigned package", + sequence: [{ + task: "{packages}.getPackageData", + args: [ + {packageName: "unsigned"} + ], + reject: "jqUnit.assertEquals", + rejectArgs: [ + "getPackageData should reject for unsigned package", + "Unable to get package unsigned: Signed JSON data failed verification: JSON object did not contain a publicKey field.", + "{arguments}.0.message" + ] + }] + }, { + expect: 1, + name: "Testing package with installer location", + sequence: [{ + task: "{packages}.getPackageData", + args: [ + {packageName: "location-exists"} + ], + resolve: "jqUnit.assertEquals", + resolveArgs: [ + "getPackageData should resolve location-exists, with the correct installerSource", + "{that}.options.expect.installerSource", + "{arguments}.0.installerSource" + ] + }] + }, { + expect: 2, + name: "Testing package with installer location, which doesn't exist", + sequence: [{ + task: "gpii.tests.iodPackageData.testInstallerNotExist", + args: [ "{packages}", {packageName: "location-not-exists"}, "{that}.options.isRemote" ], + resolve: "jqUnit.assert" + }] + }, { + expect: 1, + name: "Removing the packages file should disable the source", + sequence: [{ + func: "gpii.tests.iodPackageData.renamePackageFile", + args: ["localPackages/.morphic-packages", "localPackages/.morphic-packages-removed"] + }, { + // Request a package that did exist. + task: "{packages}.getPackageData", + args: [ + {packageName: "working"} + ], + reject: "jqUnit.assertTrue", + rejectArgs: [ + "getPackageData should reject for package after removing the local packages file", + "{arguments}.0.message" + ] + }, { + // Put the file back again. + func: "gpii.tests.iodPackageData.renamePackageFile", + args: ["localPackages/.morphic-packages-removed", "localPackages/.morphic-packages"] + }] + }] + }] +}); + +/** + * Renames the packages file. + * @param {String} from The current name. + * @param {String} to The new name. + */ +gpii.tests.iodPackageData.renamePackageFile = function (from, to) { + fs.renameSync(path.resolve(__dirname, from), path.resolve(__dirname, to)); +}; + +gpii.tests.iodPackageData.testInstallerNotExist = function (packages, packageRequest, isRemote) { + var result = packages.getPackageData(packageRequest); + var promise; + + if (isRemote) { + promise = result.then(jqUnit.assert); + } else { + promise = fluid.promise(); + result.then(promise.reject, function (reason) { + jqUnit.assertEquals("getPackageData should reject for location-not-exists package", + "Unable to get package location-not-exists: File for location-not-exists not found", reason.message); + promise.resolve(); + }); + } + + return promise; +}; + +module.exports = kettle.test.bootstrap("gpii.tests.iodPackageData.tests"); diff --git a/gpii/node_modules/gpii-iod/test/packageInstallerTests.js b/gpii/node_modules/gpii-iod/test/packageInstallerTests.js new file mode 100644 index 000000000..b6b6cec86 --- /dev/null +++ b/gpii/node_modules/gpii-iod/test/packageInstallerTests.js @@ -0,0 +1,752 @@ +/* + * IoD Tests. + * + * Copyright 2018 Raising the Floor - International + * + * Licensed under the New BSD license. You may not use this file except in + * compliance with this License. + * + * The R&D leading to these results received funding from the + * Department of Education - Grant H421A150005 (GPII-APCP). However, + * these results do not necessarily represent the policy of the + * Department of Education, and you should not assume endorsement by the + * Federal Government. + * + * You may obtain a copy of the License at + * https://github.com/GPII/universal/blob/master/LICENSE.txt + */ + +"use strict"; + +var os = require("os"), + fs = require("fs"), + path = require("path"), + rimraf = require("rimraf"), + mkdirp = require("mkdirp"), + crypto = require("crypto"); + +var fluid = require("infusion"); +var kettle = fluid.require("kettle"); +kettle.loadTestingSupport(); +var jqUnit = fluid.require("node-jqunit"); +var gpii = fluid.registerNamespace("gpii"); + +fluid.registerNamespace("gpii.tests.iodInstaller"); + +require("../index.js"); + +gpii.tests.iodInstaller.teardowns = []; + +jqUnit.module("gpii.tests.iodInstaller", { + teardown: function () { + while (gpii.tests.iodInstaller.teardowns.length) { + gpii.tests.iodInstaller.teardowns.pop()(); + } + } +}); + +gpii.tests.iodInstaller.executeCommandTests = fluid.freezeRecursive([ + { + id: "command only", + command: "command", + expect: { + funcName: "startProcess", + command: "command", + args: [], + options: undefined + } + }, + { + id: "command + args", + command: "command", + args: ["arg1", "arg2", "arg3"], + expect: { + funcName: "startProcess", + command: "command", + args: ["arg1", "arg2", "arg3"], + options: undefined + } + }, + { + id: "args in options", + command: "command", + args: undefined, + execOptions: { + args: ["arg1", "arg2", "arg3"] + }, + expect: { + funcName: "startProcess", + command: "command", + args: ["arg1", "arg2", "arg3"], + options: undefined + } + }, + { + id: "command in options", + command: undefined, + args: undefined, + execOptions: { + command: "command", + args: ["arg1", "arg2", "arg3"] + }, + expect: { + funcName: "startProcess", + command: "command", + args: ["arg1", "arg2", "arg3"], + options: undefined + } + }, + { + id: "command + args in options, overridden", + command: "command", + args: ["arg1", "arg2", "arg3"], + execOptions: { + command: "unexpected", + args: ["unexpected1"] + }, + expect: { + funcName: "startProcess", + command: "command", + args: ["arg1", "arg2", "arg3"], + options: undefined + } + }, + { + id: "no command", + command: undefined, + args: ["arg1", "arg2", "arg3"], + execOptions: { + command: undefined, + args: ["unexpected1"] + }, + expect: { + reject: true + } + }, + { + id: "expand", + command: "$(value)", + args: ["a$(value)", "$(value2.value3)", "$(nothing?expected3)", "x $(nothing)", "y $(nothing?)"], + execOptions: { + }, + installation: { + value: "expected", + value2: { + value3: "expected2" + } + }, + expect: { + funcName: "startProcess", + command: "expected", + args: ["aexpected", "expected2", "expected3", null, "y "], + options: undefined + } + }, + { + id: "expand (options)", + command: undefined, + args: undefined, + execOptions: { + command: "$(value)", + args: ["a$(value)", "$(value2.value3)", "$(nothing?expected3)", "x $(nothing)", "y $(nothing?)"] + }, + installation: { + value: "expected", + value2: { + value3: "expected2" + } + }, + expect: { + funcName: "startProcess", + command: "expected", + args: ["aexpected", "expected2", "expected3", null, "y "], + options: undefined + } + }, + { + id: "elevated", + command: "command $(value)", + args: undefined, + execOptions: { + args: ["$(value)", "arg2"], + elevate: true + }, + installation: { + value: "expected" + }, + expect: { + funcName: "startElevatedProcess", + command: "command expected", + args: ["expected", "arg2"], + options: {desktop: undefined} + } + }, + { + id: "elevated (desktop)", + command: "command $(value)", + args: undefined, + execOptions: { + args: ["$(value)", "arg2"], + elevate: true, + desktop: true + }, + installation: { + value: "expected" + }, + expect: { + funcName: "startElevatedProcess", + command: "command expected", + args: ["expected", "arg2"], + options: {desktop: true} + } + } +]); + +fluid.defaults("gpii.tests.iodInstaller.dummyInstaller", { + gradeNames: ["fluid.component", "gpii.iod.packageInstaller"], + + invokers: { + initialise: "fluid.identity", + downloadInstaller: "fluid.identity", + checkPackage: "fluid.identity", + prepareInstall: "fluid.identity", + installPackage: "fluid.identity", + cleanup: "fluid.identity", + startApplication: "fluid.identity", + uninstallPackage: "fluid.identity", + stopApplication: "fluid.identity", + installComplete: "fluid.identity", + uninstallComplete: "fluid.identity", + executeCommand: "fluid.identity" + } +}); +fluid.defaults("gpii.tests.iodInstaller.loggingInstaller", { + gradeNames: ["fluid.component", "gpii.iod.packageInstaller"], + + invokers: { + initialise: "gpii.tests.iodInstaller.stage({that}, initialise)", + downloadInstaller: "gpii.tests.iodInstaller.stage({that}, downloadInstaller)", + checkPackage: "gpii.tests.iodInstaller.stage({that}, checkPackage)", + prepareInstall: "gpii.tests.iodInstaller.stage({that}, prepareInstall)", + installPackage: "gpii.tests.iodInstaller.stage({that}, installPackage)", + cleanup: "gpii.tests.iodInstaller.stage({that}, cleanup)", + startApplication: "gpii.tests.iodInstaller.stage({that}, startApplication)", + uninstallPackage: "gpii.tests.iodInstaller.stage({that}, uninstallPackage)", + stopApplication: "gpii.tests.iodInstaller.stage({that}, stopApplication)", + installComplete: "gpii.tests.iodInstaller.stage({that}, installComplete)", + uninstallComplete: "gpii.tests.iodInstaller.stage({that}, uninstallComplete)", + executeCommand: "gpii.tests.iodInstaller.stage({that}, execute, {arguments}.0.command, {arguments}.0.args.0, {arguments}.0.args.1)" + } +}); + +gpii.tests.iodInstaller.stage = function (that) { + that.stages.push(fluid.makeArray(arguments).splice(1).join(",")); +}; + +gpii.tests.iodInstaller.installStages = [ + "initialise", + "downloadInstaller", + "checkPackage", + "prepareInstall", + "installPackage", + "cleanup", + "installComplete", + "startApplication" +]; + +gpii.tests.iodInstaller.uninstallStages = [ + "stopApplication", + "uninstallPackage", + "cleanup", + "uninstallComplete" +]; + +// Test startInstaller starts the installation pipe-line. +jqUnit.asyncTest("test installation pipe-line", function () { + var testStages = function (packageData, expectInstall, expectUninstall) { + jqUnit.expect(2); + + var installer = gpii.tests.iodInstaller.loggingInstaller(); + installer.stages = []; + installer.packageData = packageData; + installer.installation = { + packageData: packageData + }; + + var promise = fluid.promise(); + + installer.startInstaller({}).then(function () { + var expect = expectInstall; + + jqUnit.assertDeepEq("All stages of the installation should be called in order.", expect, installer.stages); + + installer.stages = []; + installer.startUninstaller().then(function () { + var expect = expectUninstall; + + jqUnit.assertDeepEq("All stages of the uninstallation should be called in order.", expect, installer.stages); + promise.resolve(); + }); + }, promise.reject); + + promise.then(function () { + installer.destroy(); + }); + + return promise; + }; + + var work = [ + function () { + fluid.log("Testing stages"); + return testStages({}, gpii.tests.iodInstaller.installStages, gpii.tests.iodInstaller.uninstallStages); + }, + function () { + // Test that the "packageData.installCommands.XX:before" and "XX:after" commands (installation) get invoked. + fluid.log("Testing stages with :before and :after commands"); + var packageData = { + installCommands: {}, + uninstallCommands: {} + }; + // install commands + fluid.each(gpii.tests.iodInstaller.installStages, function (stage) { + packageData.installCommands[stage + ":before"] = { + command: "install", + args: ["before", stage] + }; + packageData.installCommands[stage + ":after"] = { + command: "install", + args: ["after", stage] + }; + }); + var expectInstall = [ + "execute,install,before,initialise", + "initialise", + "execute,install,after,initialise", + "downloadInstaller", + "checkPackage", + "execute,install,before,prepareInstall", + "prepareInstall", + "execute,install,after,prepareInstall", + "installPackage", + "execute,install,before,cleanup", + "cleanup", + "execute,install,after,cleanup", + "installComplete", + "execute,install,before,startApplication", + "startApplication", + "execute,install,after,startApplication" + ]; + return testStages(packageData, expectInstall, gpii.tests.iodInstaller.uninstallStages); + }, + function () { + // ":before" and ":after commands (uninstallation) + fluid.log("Testing stages with :before and :after commands - uninstallation"); + var packageData = { + installCommands: {}, + uninstallCommands: {} + }; + + // uninstall commands + fluid.each(gpii.tests.iodInstaller.uninstallStages, function (stage) { + packageData.uninstallCommands[stage + ":before"] = { + command: "uninstall", + args: ["before", stage] + }; + packageData.uninstallCommands[stage + ":after"] = { + command: "uninstall", + args: ["after", stage] + }; + }); + var expectUninstall = [ + "execute,uninstall,before,stopApplication", + "stopApplication", + "execute,uninstall,after,stopApplication", + "execute,uninstall,before,uninstallPackage", + "uninstallPackage", + "execute,uninstall,after,uninstallPackage", + "execute,uninstall,before,cleanup", + "cleanup", + "execute,uninstall,after,cleanup", + "execute,uninstall,before,uninstallComplete", + "uninstallComplete", + "execute,uninstall,after,uninstallComplete" + ]; + return testStages(packageData, gpii.tests.iodInstaller.installStages, expectUninstall); + } + ]; + + fluid.promise.sequence(work).then(function () { + jqUnit.start(); + }, jqUnit.fail); +}); + +gpii.tests.iodInstaller.sha512 = function (content) { + return crypto.createHash("sha512").update(content).digest("hex"); +}; + +// Test the file download +jqUnit.asyncTest("test file download", function () { + + var skipSSL = !!process.env.GPII_QUICKTEST; + + gpii.tests.iodInstaller.downloadTests = fluid.freezeRecursive([ + { + url: "https://raw.githubusercontent.com/GPII/universal/108be0f5f0377eaec9100c1926647e7550efc2ea/gpii.js", + expect: "969125ff55aac6237549f04d0f0307a54bbfbec1d9d9c742ff2129c16aef44f471a406c9ba8dcc28bf9d5855166384819c728d375ba0a03167c2eb45fbd9e3c0" + }, + { + url: "https://gpii-test.invalid", + expect: "reject" + }, + // Certificate problems + { + url: "https://badssl.com", + expect: "resolve" + }, + { + url: "https://expired.badssl.com/", + expect: "reject" + }, + { + url: "https://wrong.host.badssl.com/", + expect: "reject" + }, + { + url: "https://self-signed.badssl.com/", + expect: "reject" + }, + { + url: "https://untrusted-root.badssl.com/", + expect: "reject" + }, + // Prohibited ciphers + { + url: "https://rc4-md5.badssl.com/", + expect: "reject" + }, + { + url: "https://rc4.badssl.com/", + expect: "reject" + }, + { + url: "https://3des.badssl.com/", + expect: "reject" + }, + { + url: "https://null.badssl.com/", + expect: "reject" + }, + { + // Unopened port (hopefully) + url: "https://127.0.0.1:51749", + expect: "reject" + }, + // Local file tests + { + url: "file://" + __filename, + expect: gpii.tests.iodInstaller.sha512(fs.readFileSync(__filename)) + }, + { + url: "file://" + __filename + "?offset=20", + expect: gpii.tests.iodInstaller.sha512(fs.readFileSync(__filename, "utf8").substr(20)) + }, + { + url: "file://" + __dirname + "/no-such-file", + expect: "reject" + } + + ]); + + var filePrefix = path.join(os.tmpdir(), "gpii-test-download" + Math.random().toString(36) + "-"); + + var files = []; + // Remove all temporary files. + gpii.tests.iodInstaller.teardowns.push(function () { + fluid.each(files, function (file) { + try { + fs.unlinkSync(file); + } catch (e) { + // ignore. + } + }); + }); + + var tests = gpii.tests.iodInstaller.downloadTests; + + + jqUnit.expect(tests.length * 4); + + + var testIndex = -1; + var nextTest = function () { + if (++testIndex >= tests.length) { + jqUnit.start(); + return; + } + + var test = tests[testIndex]; + var suffix = " - test " + testIndex + "(" + test.url + ")"; + + if (skipSSL && test.url.indexOf("badssl.com") > -1) { + fluid.log("Skipping SSL test" + suffix); + jqUnit.expect(-4); + nextTest(); + } else { + + var outFile = filePrefix + testIndex; + files.push(outFile); + + var p = gpii.iod.fileDownload(test.url, outFile); + + jqUnit.assertTrue("fileDownload must return a promise" + suffix, fluid.isPromise(p)); + + p.then(function (result) { + jqUnit.assertNotEquals("fileDownload must only succeed if expected" + suffix, test.expect, "reject"); + + if (test.expect === "resolve") { + jqUnit.assert("resolved"); + jqUnit.assert("resolved"); + } else if (test.expect !== "reject") { + var digest = gpii.tests.iodInstaller.sha512(fs.readFileSync(outFile)); + jqUnit.assertEquals("Hash in result must be correct", test.expect, result); + jqUnit.assertEquals("Hash of download must be correct", test.expect, digest); + } + nextTest(); + }, function (err) { + jqUnit.assertEquals("fileDownload must only reject if expected" + suffix, test.expect, "reject"); + jqUnit.expect(-2); // the resolve block as 2 more asserts + if (test.expects !== "reject") { + fluid.log(err); + } + nextTest(); + }); + } + + }; + + nextTest(); +}); + +jqUnit.asyncTest("test downloadInstaller", function () { + + var tempDir = path.join(os.tmpdir(), "gpii-downloadInstaller-tests" + Math.random()); + mkdirp.sync(tempDir); + gpii.tests.iodInstaller.teardowns.push(function () { + rimraf.sync(tempDir); + }); + + var tests = [ + { + id: "successful local file URL", + installation: { + tempDir: "tempDir" + }, + packageData: { + installerSource: "file://installer-source", + installer: "installer.msi" + }, + fileDownload: "the hash", + expect: { + disposition: "resolve", + fileDownload: true, + installation: { + tempDir: "tempDir", + installerFile: "tempDir/installer.msi", + installerFileHash: "the hash" + } + } + }, { + id: "successful remote file", + installation: { + tempDir: "tempDir" + }, + packageData: { + installerSource: "https://installer-source", + installer: "installer.msi" + }, + fileDownload: "the hash", + expect: { + disposition: "resolve", + fileDownload: true, + installation: { + tempDir: "tempDir", + installerFile: "tempDir/installer.msi", + installerFileHash: "the hash" + } + } + }, { + id: "unsuccessful local file", + installation: { + tempDir: "tempDir" + }, + packageData: { + installerSource: "file://installer-source-reject", + installer: "installer.msi" + }, + expect: { + disposition: "reject", + fileDownload: true + } + }, { + id: "no file", + installation: { + tempDir: "tempDir" + }, + packageData: { + installer: "installer.msi" + }, + expect: { + disposition: "resolve", + fileDownload: false, + installation: { + tempDir: "tempDir", + installerFile: "tempDir/installer.msi" + } + } + } + ]; + + var installer; + var currentTest; + var suffix; + + // Mock fileDownload + var fileDownloadOrig = gpii.iod.fileDownload; + gpii.tests.iodInstaller.teardowns.push(function () { + gpii.iod.fileDownload = fileDownloadOrig; + }); + gpii.iod.fileDownload = function (address) { + var promise = fluid.promise(); + if (!currentTest.expect.fileDownload) { + jqUnit.fail("Call to fileDownload was unexpected" + suffix); + } + + jqUnit.assertEquals("fileDownload must be called with the correct address", + currentTest.packageData.installerSource, address); + + if (address.endsWith("reject")) { + promise.reject(); + } else { + promise.resolve(currentTest.fileDownload); + } + + return promise; + }; + + + var nextTest = function (testIndex) { + currentTest = tests[testIndex]; + if (installer) { + installer.destroy(); + } + if (!currentTest) { + jqUnit.start(); + return; + } + + suffix = " - test " + testIndex + "(" + currentTest.id + ")"; + + var installation = fluid.copy(currentTest.installation) || {}; + if (!installation.tempDir) { + installation.tempDir = tempDir; + } + var packageData = fluid.copy(currentTest.packageData); + if (!packageData.installCommands) { + packageData.installCommands = {}; + } + + + installer = gpii.tests.iodInstaller.loggingInstaller(); + + var p = gpii.iod.downloadInstaller(installer, installation, packageData); + jqUnit.assertTrue("fileDownload must return a promise" + suffix, fluid.isPromise(p)); + + p.then(function () { + jqUnit.assertEquals("downloadInstaller must only resolve if expected" + suffix, + currentTest.expect.disposition, "resolve"); + + jqUnit.assertDeepEq("downloadInstaller must only resolve if expected" + suffix, + currentTest.expect.installation, installation); + + nextTest(testIndex + 1); + }, function (err) { + if (currentTest.expect.disposition !== "reject") { + fluid.log(err); + } + jqUnit.assertEquals("downloadInstaller must only reject if expected" + suffix, + currentTest.expect.disposition, "reject"); + + nextTest(testIndex + 1); + }); + + }; + + nextTest(0); + +}); + +jqUnit.asyncTest("test executeCommand", function () { + + var tests = gpii.tests.iodInstaller.executeCommandTests; + + jqUnit.expect(tests.length * 2); + + var currentTest, messageSuffix; + + var installer = gpii.tests.iodInstaller.dummyInstaller({ + invokers: { + startProcess: { + func: function (args) { + jqUnit.assertDeepEq("startProcess should be called correctly" + messageSuffix, + currentTest.expect, args); + return fluid.promise().resolve(); + }, + args: [{ + funcName: "startProcess", + command: "{arguments}.0", + args: "{arguments}.1", + options: "{arguments}2" + }] + }, + startElevatedProcess: { + func: function (args) { + jqUnit.assertDeepEq("startElevatedProcess should be called correctly" + messageSuffix, + currentTest.expect, args); + return fluid.promise().resolve(); + }, + args: [{ + funcName: "startElevatedProcess", + command: "{arguments}.0", + args: "{arguments}.1", + options: "{arguments}2" + }] + } + } + }); + + + var doTest = function (testIndex) { + currentTest = tests[testIndex]; + if (currentTest) { + messageSuffix = " - test:" + currentTest.id; + installer.installation = currentTest.installation; + var p = gpii.iod.executeCommand(installer, currentTest.execOptions, currentTest.command, currentTest.args); + jqUnit.assertTrue("executeCommand must return a promise" + messageSuffix, fluid.isPromise(p)); + + p.then(function () { + doTest(testIndex + 1); + }, function () { + jqUnit.assertTrue("executeCommand should reject if expected" + messageSuffix, + currentTest.expect.reject); + doTest(testIndex + 1); + }); + + } else { + jqUnit.start(); + } + }; + + doTest(0); + +}); diff --git a/gpii/node_modules/gpii-iod/test/packagesTests.js b/gpii/node_modules/gpii-iod/test/packagesTests.js new file mode 100644 index 000000000..1acab4659 --- /dev/null +++ b/gpii/node_modules/gpii-iod/test/packagesTests.js @@ -0,0 +1,727 @@ +/* + * IoD Tests - packages. + * + * Copyright 2019 Raising the Floor - International + * + * Licensed under the New BSD license. You may not use this file except in + * compliance with this License. + * + * The R&D leading to these results received funding from the + * Department of Education - Grant H421A150005 (GPII-APCP). However, + * these results do not necessarily represent the policy of the + * Department of Education, and you should not assume endorsement by the + * Federal Government. + * + * You may obtain a copy of the License at + * https://github.com/GPII/universal/blob/master/LICENSE.txt + */ + +"use strict"; + +var fluid = require("infusion"); +var kettle = fluid.require("kettle"); +kettle.loadTestingSupport(); + +var json5 = require("json5"), + fs = require("fs"), + path = require("path"), + os = require("os"); + +var jqUnit = fluid.require("node-jqunit"); +var gpii = fluid.registerNamespace("gpii"); + +fluid.registerNamespace("gpii.tests.iodPackages"); + +require("./common.js"); +require("../index.js"); + +fluid.defaults("gpii.tests.iodPackages.tests", { + gradeNames: ["fluid.test.testEnvironment"], + + components: { + packages: { + type: "gpii.iod.packages", + options: { + events: { + onServerFound: null, + onLocalPackagesFound: null + }, + trustedKeys: { + packagesTest: "{tests}.options.keyPair.fingerprint" + }, + listeners: { + "onCreate.generateData": { + funcName: "gpii.tests.iod.generateLocalPackages", + args: [ + "local-packages.json5", + "localPackages/.morphic-packages", + "{tests}.options.keyPair" + ] + } + } + } + }, + tester: { + type: "gpii.tests.iodPackages.testCaseHolder" + } + }, + keyPair: "@expand:gpii.tests.iod.generateKeyPair()" +}); + +fluid.defaults("gpii.tests.iodPackages.testCaseHolder", { + gradeNames: ["fluid.test.testCaseHolder"], + modules: [{ + name: "exists resolver tests", + tests: [{ + expect: 5, + name: "testExistsResolver", + sequence: [{ + func: "gpii.tests.iodPackages.testExistsResolver" + }] + }] + }, { + name: "resolvePackage() tests", + tests: [{ + name: "testing resolvePackage()", + sequence: [{ + func: "gpii.tests.iodPackages.testResolvePackage", + args: ["{packages}", "@expand:fluid.getGlobalValue(gpii.tests.iodPackages.resolvePackageTests)"] + }] + }] + }, { + name: "checkInstalled() tests", + tests: [{ + name: "testing checkInstalled()", + sequence: [{ + func: "gpii.tests.iodPackages.testCheckInstalled", + args: ["{packages}", "{that}.options.testData.checkInstalledTests"] + }] + }] + }, { + name: "PackageData tests", + tests: [{ + name: "testing with no data source", + expect: 1, + sequence: [{ + task: "{packages}.getPackageData", + args: [{ + packageName: "some-package" + }], + reject: "jqUnit.assertEquals", + rejectArgs: [ + "Should reject with the expected error", + "no root data sources", + "{arguments}.0.error.message" + ] + }] + }, { + name: "adding a data source", + expect: 1, + sequence: [{ + func: "{packages}.events.onLocalPackagesFound.fire", + args: [path.join(__dirname, "localPackages")] + }, { + func: "jqUnit.assertEquals", + args: [ + "Packages should have a data source", + 1, + "{packages}.dataSource.sortedDataSources.length" + ] + }] + }, { + name: "testing getPackageData()", + expect: 1, + sequence: [{ + task: "gpii.tests.iodPackages.testGetPackageData", + args: ["{packages}", "{that}.options.testData.getPackageDataTests"], + resolve: "jqUnit.assert" + }] + }] + }], + testData: { + checkInstalledTests: [ + { + name: "literal true", + isInstalled: true, + expect: true + }, + { + name: "literal false", + isInstalled: false, + expect: false + }, + { + name: "string true", + isInstalled: "true", + expect: true + }, + { + name: "string false", + isInstalled: "false", + expect: false + }, + { + name: "literal 1", + isInstalled: 1, + expect: true + }, + { + name: "literal 0", + isInstalled: 0, + expect: false + }, + { + name: "string 1", + isInstalled: "1", + expect: true + }, + { + name: "string 0", + isInstalled: "0", + expect: false + }, + { + name: "word string", + isInstalled: "hello", + expect: true + }, + { + name: "empty string", + isInstalled: "", + expect: false + }, + { + name: "null", + isInstalled: null, + expect: false + }, + { + name: "undefined", + isInstalled: undefined, + expect: false + }, + { + name: "no value", + expect: false + }, + { + name: "empty object", + isInstalled: {}, + expect: false + }, + { + name: "object", + isInstalled: {something: "hello"}, + expect: false + }, + { + name: "object containing isInstalled:true", + isInstalled: {isInstalled: true}, + expect: true + }, + { + name: "object containing isInstalled:0", + isInstalled: {isInstalled: "0"}, + expect: false + } + ], + getPackageDataTests: [ + { + id: "No matching package", + request: { + packageName: "package-not-exists" + }, + expect: "reject" + }, + { + id: "variables resolved", + request: { + packageName: "env" + }, + expect: { + name: "env", + test: process.env.PATH, + packageType: "testPackageType1" + } + }, + { + id: "Single language package", + request: { + packageName: "package1" + }, + expect: json5.parse(fs.readFileSync(__dirname + "/packageData/package1.json5", "utf8")) + }, + { + id: "Single language package, with language specified", + request: { + packageName: "package1", + language: "fr-FR" + }, + expect: json5.parse(fs.readFileSync(__dirname + "/packageData/package1.json5", "utf8")) + }, + { + id: "Multi-language package, language not specified", + request: { + packageName: "languages" + }, + expect: { + "name": "languages", + "packageType": "testPackageType1", + "filename": "example.filename" + } + }, + { + id: "Multi-language package, unknown language specified", + request: { + packageName: "languages", + language: "xx-YY" + }, + expect: { + "name": "languages", + "packageType": "testPackageType1", + "filename": "example.filename" + } + }, + { + id: "Multi-language package, unknown language, no country specified", + request: { + packageName: "languages", + language: "xx" + }, + expect: { + "name": "languages", + "packageType": "testPackageType1", + "filename": "example.filename" + } + }, + { + id: "Multi-language package, full language specified", + request: { + packageName: "languages", + language: "es-ES" + }, + expect: { + "name": "languages", + "packageType": "testPackageType1", + "filename": "file.es-es", + "language": "es-ES" + } + }, + { + id: "Multi-language package, full language specified 2", + request: { + packageName: "languages", + language: "es-MX" + }, + expect: { + "name": "languages", + "packageType": "testPackageType1", + "filename": "file.es-mx", + "language": "es-MX" + } + }, + { + id: "Multi-language package, no country specified", + request: { + packageName: "languages", + language: "es" + }, + expect: { + "name": "languages", + "packageType": "testPackageType1", + "filename": "file.es", + "language": "es" + } + }, + { + id: "Multi-language package, unknown country specified", + request: { + packageName: "languages", + language: "es-YY" + }, + expect: { + "name": "languages", + "packageType": "testPackageType1", + "filename": "file.es", + "language": "es" + } + }, + { + id: "Multi-language package, no country specified, no non-country package", + request: { + packageName: "languages", + language: "zh" + }, + expect: { + "name": "languages", + "packageType": "testPackageType1", + "filename": "file.zh-cn", + "language": "zh-CN" + } + }, + { + id: "Multi-language package, unknown country specified, no non-country package", + request: { + packageName: "languages", + language: "zh-YY" + }, + expect: { + "name": "languages", + "packageType": "testPackageType1", + "filename": "file.zh-cn", + "language": "zh-CN" + } + } + ] + } +}); + +// This test data needs to be declared outside a component, to avoid resolving the `${{values}}` don't get resolved. +gpii.tests.iodPackages.resolvePackageTests = [ + // Resolver + { + name: "environment", + result: "${{environment}.PATH}", + expect: process.env.PATH + }, + { + name: "exists", + result: "${{exists}./}", + expect: true + }, + { + name: "exists (not)", + result: "${{exists}./gpii-test/not/exist}", + expect: false + }, + { + name: "this", + anotherValue: "it works", + result: "${{this}.anotherValue}", + expect: "it works" + }, + { + name: "this (object)", + anotherValue: { + deepValue: "it works" + }, + result: "${{this}.anotherValue}", + expect: { + deepValue: "it works" + } + }, + { + name: "this (deep field)", + anotherValue: { + deepValue: "it works" + }, + result: "${{this}.anotherValue.deepValue}", + expect: "it works" + }, + { + name: "this (multiple)", + first: "it works", + second: "${{this}.first}", + result: "${{this}.second}", + expect: "it works" + }, + { + name: "this (multiple, reverse order)", + result: "${{this}.second}", + second: "${{this}.first}", + first: "it works", + expect: "it works" + }, + { + name: "unknown resolver", + result: "${{stupid}}", + expect: undefined + }, + // Transforms + { + name: "basic transform", + result: { + transform: { + type: "fluid.transforms.literalValue", + input: "it works" + } + }, + expect: "it works" + }, + { + name: "basic transform, object result", + result: { + transform: { + type: "fluid.transforms.literalValue", + input: "it works", + outputPath: "nested" + } + }, + expect: { + nested: "it works" + } + }, + { + name: "basic transform, null result", + result: { + transform: { + type: "fluid.transforms.literalValue", + input: null + } + }, + expect: null + }, + { + name: "literal transform", + result: { + literalValue: "it works" + }, + expect: "it works" + }, + { + name: "transform, outer reference", + otherValue: "it works", + result: { + transform: { + type: "fluid.transforms.value", + inputPath: "otherValue" + } + }, + expect: "it works" + }, + { + name: "transform, outer deep reference", + otherValue: { + nested: "it works" + }, + result: { + transform: { + type: "fluid.transforms.value", + inputPath: "otherValue.nested" + } + }, + expect: "it works" + }, + { + name: "transform, reference to transformed", + otherValue: { + transform: { + type: "fluid.transforms.value", + input: "it works" + } + }, + result: { + transform: { + type: "fluid.transforms.value", + inputPath: "otherValue" + } + }, + expect: "it works" + }, + { + name: "transform, reference to transformed (looking ahead)", + result: { + transform: { + type: "fluid.transforms.value", + inputPath: "otherValue" + } + }, + otherValue: { + transform: { + type: "fluid.transforms.value", + input: "it works" + } + }, + expect: "it works" + }, + { + name: "transform, self reference", + result: { + transform: { + type: "fluid.transforms.value", + inputPath: "value" + } + }, + expect: undefined + }, + { + name: "transform, circular reference", + result: { + transform: { + type: "fluid.transforms.value", + inputPath: "value2" + } + }, + value2: { + transform: { + type: "fluid.transforms.value", + inputPath: "value3" + } + }, + value3: { + transform: { + type: "fluid.transforms.value", + inputPath: "value" + } + }, + expect: undefined + }, + { + name: "resolving transformed value", + result: "${{this}.value2}", + value2: { + transform: { + type: "fluid.transforms.value", + input: "it works" + } + }, + expect: "it works" + }, + { + name: "transforms operate on the resolved variables", + result: { + transform: { + type: "fluid.transforms.condition", + // will be true if it's not resolved + condition: "${{exists}./does/not/exist1}", + false: "it works", + true: "hide the evidence" + } + }, + expect: "it works" + } +]; + +/** + * Tests for existsResolver(). + */ +gpii.tests.iodPackages.testExistsResolver = function () { + + // Test it works on a non-existent file + var result = gpii.iod.existsResolver(path.join(os.tmpdir(), "not-exist" + Math.random())); + jqUnit.assertFalse("existsResolver should return false for a non-existing file", result); + + // Test it works on an existing file + var result2 = gpii.iod.existsResolver(__filename); + jqUnit.assertTrue("existsResolver should return true for an existing file", result2); + + // Test it works on an existing directory + var result3 = gpii.iod.existsResolver(__dirname); + jqUnit.assertTrue("existsResolver should return true for an existing directory", result3); + + // Test environment variables are expanded + var result4 = gpii.iod.existsResolver("%HOME%"); + jqUnit.assertTrue("existsResolver should return true, with an environment variable", result4); + process.env.GPII_TEST_RESOLVER1 = __dirname; + process.env.GPII_TEST_RESOLVER2 = path.basename(__filename, "js"); + var result5 = gpii.iod.existsResolver("%GPII_TEST_RESOLVER1%/%GPII_TEST_RESOLVER2%js"); + jqUnit.assertTrue("existsResolver should return true, with multiple environment variables", result5); +}; + +/** + * Tests that the resovlers and tranformations within a packageData object get applied. + * @param {Component} packages The gpii.iod.packages instance. + * @param {Array} resolvePackageTests The tests. + */ +gpii.tests.iodPackages.testResolvePackage = function (packages, resolvePackageTests) { + jqUnit.expect(resolvePackageTests.length * 2 * 3); + fluid.each(resolvePackageTests, function (test) { + + var current = test; + // Resolve the package more than once, to show it can be re-resolved. + for (var i = 1; i <= 3; i++) { + var resolved = packages.resolvePackage(current); + var suffix = " - " + test.name + " (pass " + i + ")"; + + jqUnit.assertDeepEq("return of resolvePackage should contain the original package" + suffix, + test, resolved._original); + + jqUnit.assertDeepEq("resolvePackage should return the expected value" + suffix, + test.expect, resolved.result); + + current = resolved; + } + }); +}; + +/** + * Tests the checkInstalled() function. + * @param {Component} packages The gpii.iod.packages instance. + * @param {Array} checkInstalledTests The tests. + */ +gpii.tests.iodPackages.testCheckInstalled = function (packages, checkInstalledTests) { + // Run the canned tests. + jqUnit.expect(checkInstalledTests.length); + fluid.each(checkInstalledTests, function (test) { + var result = packages.checkInstalled(test); + jqUnit.assertEquals("checkInstalled should return the expected result - " + test.name, test.expect, result); + }); + + // Create a package which uses an environment variable to determine if it's installed. + var testEnv = "_gpii_test_checkInstalled"; + var testPackage = packages.resolvePackage({ + name: "checkInstalledTest", + isInstalled: "${{environment}." + testEnv + "}" + }); + + // Ensure the same package can return a different result - ie, the result is live. + var testValues = [ false, true, false, false, true, true, false ]; + jqUnit.expect(testValues.length); + fluid.each(testValues, function (value, index) { + // Change what isInstalled should resolve to. + process.env[testEnv] = value.toString(); + + var result = packages.checkInstalled(testPackage); + + jqUnit.assertEquals("checkInstalled should return the expected result - index=" + index, value, result); + }); + + delete process.env[testEnv]; +}; + +gpii.tests.iodPackages.testGetPackageData = function (packages, tests) { + + var promise = fluid.promise(); + + jqUnit.expect(tests.length * 2); + + var testIndex = -1; + var nextTest = function () { + if (++testIndex >= tests.length) { + promise.resolve(); + return; + } + + var test = tests[testIndex]; + var suffix = " - test:" + test.id; + + fluid.log("getPackage: " + test.request.packageName + ", " + test.request.language); + + var p = packages.getPackageData(test.request); + + jqUnit.assertTrue("getPackageData must return a promise" + suffix, fluid.isPromise(p)); + + p.then(function (packageData) { + // Remove some fields that were added, so it can be compared directly with the file it came from. + delete packageData.languages; + delete packageData._original; + delete packageData.publicKey; + + jqUnit.assertDeepEq("packageData must match expected" + suffix, test.expect, packageData); + + nextTest(); + }, function (e) { + if (test.expect !== "reject") { + fluid.log(e); + } + jqUnit.assertEquals("packageData must only reject if expected" + suffix, test.expect, "reject"); + nextTest(); + }); + + }; + + nextTest(); + return promise; +}; + +module.exports = kettle.test.bootstrap("gpii.tests.iodPackages.tests"); diff --git a/index.js b/index.js index 02334caa0..18aa250f1 100644 --- a/index.js +++ b/index.js @@ -38,6 +38,7 @@ require("./gpii/node_modules/processReporter"); require("./gpii/node_modules/gpii-db-operation"); require("./gpii/node_modules/userListeners"); require("./gpii/node_modules/gpii-ini-file"); +require("./gpii/node_modules/gpii-iod"); gpii.loadTestingSupport = function () { fluid.contextAware.makeChecks({ diff --git a/package.json b/package.json index ee2f67c66..15e41b6e9 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "homepage": "http://gpii.net/", "dependencies": { "body-parser": "1.18.3", + "bonjour": "3.5", "connect-ensure-login": "0.1.1", "express": "4.16.4", "express-handlebars": "3.0.0", @@ -41,6 +42,7 @@ "browserify": "16.2.3", "gpii-express": "1.0.15", "gpii-grunt-lint-all": "1.0.5", + "gpii-iodServer": "stegru/gpii-iod#GPII-2972", "gpii-testem": "2.1.7", "grunt": "1.0.3", "grunt-markdownlint": "2.1.0", diff --git a/testData/defaultSettings/defaultSettings.win32.json5 b/testData/defaultSettings/defaultSettings.win32.json5 index d8ea20804..31f694139 100644 --- a/testData/defaultSettings/defaultSettings.win32.json5 +++ b/testData/defaultSettings/defaultSettings.win32.json5 @@ -2,7 +2,6 @@ "contexts": { "gpii-default": { "preferences": { - "http://registry.gpii.net/common/language": "en-US", "http://registry.gpii.net/common/DPIScale": 0, "http://registry.gpii.net/common/highContrast/enabled": false, "http://registry.gpii.net/common/highContrastTheme": "regular-contrast", @@ -18,7 +17,20 @@ "SwapMouseButtons": 0, "DoubleClickTime": 500 }, - "http://registry.gpii.net/common/cursorSize": 0 + "http://registry.gpii.net/common/cursorSize": 0, + "http://registry.gpii.net/applications/net.gpii.test.iod": { + "smartMouse": {}, + "envSerial": {}, + "focusBuddy": {} + }, + "http://registry.gpii.net/applications/net.gpii.morphic": { + "tooltipDisplayDelay": 500, + "scaleFactor": 1.2, + "alwaysUseChrome": false, + "appBarQss": false, + "closeQssOnClickOutside": false, + "disableRestartWarning": true + } } } } diff --git a/testData/deviceReporter/acceptanceTests/morphic.json b/testData/deviceReporter/acceptanceTests/morphic.json new file mode 100644 index 000000000..827b2a14e --- /dev/null +++ b/testData/deviceReporter/acceptanceTests/morphic.json @@ -0,0 +1,5 @@ +[ + { + "id": "net.gpii.morphic" + } +] diff --git a/testData/deviceReporter/installedSolutions.json b/testData/deviceReporter/installedSolutions.json index f915e9bc8..63461cab1 100644 --- a/testData/deviceReporter/installedSolutions.json +++ b/testData/deviceReporter/installedSolutions.json @@ -210,5 +210,9 @@ { "id": "com.microsoft.office" + }, + + { + "id": "net.gpii.test.iod" } ] diff --git a/testData/installOnDemand/demo-package.json5 b/testData/installOnDemand/demo-package.json5 new file mode 100644 index 000000000..e5589e375 --- /dev/null +++ b/testData/installOnDemand/demo-package.json5 @@ -0,0 +1,10 @@ +{ + "name": "demo-package", + "description": "Installs a small demo application", + "url": "https://github.com/stegru/gpii-iod/raw/GPII-2972/testData/packages/demo-package.1.0.0.nupkg", + "filename": "demo-package.1.0.0.nupkg", + "packageType": "chocolatey", + "isInstalled": { + "installed": true + } +} diff --git a/testData/installOnDemand/local.json5 b/testData/installOnDemand/local.json5 new file mode 100644 index 000000000..0156da2fe --- /dev/null +++ b/testData/installOnDemand/local.json5 @@ -0,0 +1,6 @@ +{ + "name": "dummy-local-package", + "url": "https://gpii.invalid", + "filename": "dummy-local-package", + "packageType": "dummy" +} diff --git a/testData/installOnDemand/nvda.json5 b/testData/installOnDemand/nvda.json5 new file mode 100644 index 000000000..88ff7d8de --- /dev/null +++ b/testData/installOnDemand/nvda.json5 @@ -0,0 +1,15 @@ +{ + "name": "nvda", + "description": "NVDA 2019.2", + "url": "https://chocolatey.org/api/v2/package/nvda/2019.2", + "filename": "nvda.2019.2.nupkg", + "packageType": "chocolatey", + "isInstalled": { + "installed": true + }, + "start": "\"c:\\Program Files (x86)\\NVDA\\nvda.exe\"", + "stop": { + cmd: "taskkill", + args: "/f /im nvda.exe" + } +} diff --git a/testData/installOnDemand/wget.json5 b/testData/installOnDemand/wget.json5 new file mode 100644 index 000000000..884355a5b --- /dev/null +++ b/testData/installOnDemand/wget.json5 @@ -0,0 +1,6 @@ +{ + "name": "wget", + "url": "https://chocolatey.org/api/v2/package/Wget/1.19.4", + "filename": "Wget.1.19.4.nupkg", + "packageType": "chocolatey" +} diff --git a/testData/preferences/iod_italian.json5 b/testData/preferences/iod_italian.json5 new file mode 100644 index 000000000..dfe35c835 --- /dev/null +++ b/testData/preferences/iod_italian.json5 @@ -0,0 +1,15 @@ +{ + "flat": { + "contexts": { + "gpii-default": { + "name": "Default preferences", + "preferences": { + "http://registry.gpii.net/applications/net.gpii.test.iod": { + "language.it-it": {} + }, + "http://registry.gpii.net/common/language": "it-it" + } + } + } + } +} diff --git a/testData/preferences/iod_jaws.json5 b/testData/preferences/iod_jaws.json5 new file mode 100644 index 000000000..b631d11bb --- /dev/null +++ b/testData/preferences/iod_jaws.json5 @@ -0,0 +1,14 @@ +{ + "flat": { + "contexts": { + "gpii-default": { + "name": "Default preferences", + "preferences": { + "http://registry.gpii.net/applications/net.gpii.test.iod": { + "jaws": {} + } + } + } + } + } +} diff --git a/testData/preferences/iod_nvda.json5 b/testData/preferences/iod_nvda.json5 new file mode 100644 index 000000000..7e839b6cf --- /dev/null +++ b/testData/preferences/iod_nvda.json5 @@ -0,0 +1,14 @@ +{ + "flat": { + "contexts": { + "gpii-default": { + "name": "Default preferences", + "preferences": { + "http://registry.gpii.net/applications/net.gpii.test.iod": { + "nvda": {} + } + } + } + } + } +} diff --git a/testData/preferences/iod_putty.json5 b/testData/preferences/iod_putty.json5 new file mode 100644 index 000000000..a899e7be6 --- /dev/null +++ b/testData/preferences/iod_putty.json5 @@ -0,0 +1,14 @@ +{ + "flat": { + "contexts": { + "gpii-default": { + "name": "Default preferences", + "preferences": { + "http://registry.gpii.net/applications/net.gpii.test.iod": { + "putty": {} + } + } + } + } + } +} diff --git a/testData/preferences/iod_zoomtext.json5 b/testData/preferences/iod_zoomtext.json5 new file mode 100644 index 000000000..83411198a --- /dev/null +++ b/testData/preferences/iod_zoomtext.json5 @@ -0,0 +1,14 @@ +{ + "flat": { + "contexts": { + "gpii-default": { + "name": "Default preferences", + "preferences": { + "http://registry.gpii.net/applications/net.gpii.test.iod": { + "zoomtext": {} + } + } + } + } + } +} diff --git a/testData/preferences/morphic_application.json5 b/testData/preferences/morphic_application.json5 new file mode 100644 index 000000000..c9c5c27c2 --- /dev/null +++ b/testData/preferences/morphic_application.json5 @@ -0,0 +1,53 @@ +/* # morphic_application.json5 + * + * ## Preference set used for manually testing Morphic QSS + * but also used by the Integration/Acceptance tests. + * + */ +{ + "flat": { + "name": "morphic_application", + "contexts": { + "gpii-default": { + "name": "Default preferences", + "preferences": { + "http://registry.gpii.net/applications/net.gpii.morphic": { + "tooltipDisplayDelay": 2000, + "scaleFactor": 1, + "alwaysUseChrome": true, + "appBarQss": true, + "buttonList": [ + "text-zoom", + "screen-zoom", + "color-vision", + "high-contrast", + "volume", + "mouse", + "read-aloud", + "snipping-tool", + "||", + "office-simplification", + "launch-documorph", + "usb-open", + "||", + "service-more", + "service-save", + "service-undo", + "service-reset-all", + "service-close" + ], + "morePanelList": + [ + [ "text-zoom", "screen-zoom", "color-vision", "high-contrast", "volume", "mouse", "read-aloud", "snipping-tool" ], + [ "text-zoom", "screen-zoom", "color-vision", "high-contrast", "volume", "mouse", "read-aloud", "snipping-tool" ], + [ "text-zoom", "screen-zoom", "color-vision", "high-contrast", "volume", "mouse", "read-aloud", "snipping-tool" ] + ], + "closeQssOnClickOutside": true, + "disableRestartWarning": false, + "openQssShortcut": "Shift+Ctrl+AltOrOption+SuperOrCmd+O" + } + } + } + } + } +} diff --git a/testData/solutions/solutionsDescription/net_gpii_morphic.md b/testData/solutions/solutionsDescription/net_gpii_morphic.md new file mode 100644 index 000000000..177ee9059 --- /dev/null +++ b/testData/solutions/solutionsDescription/net_gpii_morphic.md @@ -0,0 +1,30 @@ +# Morphic QuickStrip + +## Details + +* __Name__: Morphic QuickStrip +* __Id__: net.gpii.morphic +* __Platform__: MS Windows +* __Contact__: Javier Hernández + +## Description + +Morphic QuickStrip is built on top of the GPII and is the result of the APCP project. +It consists in a GUI running on electron that presents a bar that the user can use +to deal with different settings. At this moment, Morphic only works on MS Windows +and uses the WebSockets settingsHandler to communicate with the GPII. + +Useful links: + + * [Source code at github.com](https://github.com/GPII/gpii-app) + +## Installation + +Ask Javier Hernández for an installer. :P + +## Testing + +For manual testing, you need to login to the GPII with a preference set containing +preferences supported by the Morphic QuickStrip. + +_TODO:_ Provide a preference set for manual testing. diff --git a/testData/solutions/win32.json5 b/testData/solutions/win32.json5 index 7d14e5e50..346737dc3 100644 --- a/testData/solutions/win32.json5 +++ b/testData/solutions/win32.json5 @@ -1,4 +1,31 @@ { + "net.gpii.test.iod": { + "name": "Install on demand demo package", + "contexts": { + "OS": [ + { + "id": "win32", + "version": ">=5.0" + } + ] + }, + "settingsHandlers": { + "install": { + "type": "gpii.iod.settingsHandler", + "capabilities": [ + "http://registry\\.gpii\\.net/applications/net\\.gpii\\.test\\.iod" + ], + "options": {} + } + }, + "isInstalled": [ + { + "type": "gpii.deviceReporter.alwaysInstalled" + } + ] + }, + + "com.aisquared.zoomtext": { "name": "ZoomText", "contexts": { @@ -36201,5 +36228,133 @@ "type": "gpii.deviceReporter.alwaysInstalled" } ] + }, + "net.gpii.morphic": { + "name": "Morphic", + "contexts": { + "OS": [ + { + "id": "win32", + "version": ">=5.0" + } + ] + }, + "settingsHandlers": { + "configure": { + "type": "gpii.settingsHandlers.webSockets", + "liveness": "live", + "options": { + "path": "net.gpii.morphic" + }, + "supportedSettings": { + "tooltipDisplayDelay": { + "schema": { + "title": "QSS button tooltip delay", + "description": "Defines the delay in milliseconds before the tooltip is shown after a QSS button is selected", + "type": "integer", + "default": 500, + "minimum": 1, + "maximum": 10000 + } + }, + "scaleFactor": { + "schema": { + "title": "QSS scaling factor", + "description": "The scaling factor for the QSS", + "type": "number", + "default": 1.2, + "minimum": 1, + "maximum": 2 + } + }, + "alwaysUseChrome": { + "schema": { + "title": "Always Use Chrome", + "description": "Determines if Morphic should always use Chrome for launching external services", + "type": "boolean", + "default": false + } + }, + "appBarQss": { + "schema": { + "title": "Dock to bottom", + "description": "Make the QSS dock to the bottom of the screen, so application windows are positioned above it", + "type": "boolean", + "default": false + } + }, + "buttonList": { + "schema": { + "title": "QSS Button List", + "description": "List of the desired list of buttons shown in QSS", + "type": "array", + "default": [ + "screen-zoom", + "text-zoom", + "high-contrast", + "color-vision", + "mouse", + "volume", + "||", + "read-aloud", + "snipping-tool", + "office-simplification", + "launch-documorph", + "usb-open", + "||", + "service-more", + "service-save", + "service-undo", + "service-reset-all", + "service-close" + ] + } + }, + "morePanelList": { + "schema": { + "title": "QSS More Panel Button List", + "description": "List of the desired list of buttons shown in the More Panel", + "type": "array", + // The more panel is able to allocate 3 rows of buttons. + // Each row can contain 8 buttons. At this moment there are no default buttons. + "default": [ + [ /* first row of buttons */ ], + [ /* second row of buttons */ ], + [ /* third row of buttons */ ] + ] + } + }, + "closeQssOnClickOutside": { + "schema": { + "title": "Hide QSS on Outside Click", + "description": "Whether to hide the QSS when a user clicks outside of it", + "type": "boolean", + "default": false + } + }, + "disableRestartWarning": { + "schema": { + "title": "Disable restart warnings", + "description": "Whether to disable the displaying of notifications that suggest some applications may need to be restarted in order for a changed setting to be fully applied. An example for such setting is `Language`. If set to `true`, such notifications will NOT be displayed.", + "type": "boolean", + "default": true + } + }, + "openQssShortcut": { + "schema": { + "title": "Open QSS Shortcut", + "description": "The shortcut that open the QSS. For posible values refer to: https://electronjs.org/docs/api/accelerator", + "type": "string", + "default": "Shift+Ctrl+AltOrOption+SuperOrCmd+M" + } + } + } + } + }, + "isInstalled": [ + { + "type": "gpii.deviceReporter.alwaysInstalled" + } + ] } } diff --git a/tests/all-tests.js b/tests/all-tests.js index f2bf8c474..98e6294ca 100644 --- a/tests/all-tests.js +++ b/tests/all-tests.js @@ -82,6 +82,7 @@ var testIncludes = [ "../gpii/node_modules/solutionsRegistry/test/all-tests.js", "../gpii/node_modules/transformer/test/TransformerTests.js", "../gpii/node_modules/userListeners/test/all-tests.js" + //"../gpii/node_modules/gpii-iod/test/all-tests.js" ]; fluid.each(testIncludes, function (path) { diff --git a/tests/platform/index-windows.js b/tests/platform/index-windows.js index e2037c67c..50c630694 100644 --- a/tests/platform/index-windows.js +++ b/tests/platform/index-windows.js @@ -25,6 +25,7 @@ module.exports = [ "windows/windows-brightness-testSpec.js", "windows/windows-builtIn-testSpec.js", "windows/windows-jaws-testSpec.js", + "windows/windows-morphic-testSpec.js", "windows/windows-nvda-testSpec.js", // TODO: Make the MAGic tests something other than a copy of the JAWS tests. //"windows/windows-magic-testSpec.js", diff --git a/tests/platform/windows/configs/gpii.tests.acceptance.windows.morphic.config.json b/tests/platform/windows/configs/gpii.tests.acceptance.windows.morphic.config.json new file mode 100644 index 000000000..9935da202 --- /dev/null +++ b/tests/platform/windows/configs/gpii.tests.acceptance.windows.morphic.config.json @@ -0,0 +1,13 @@ +{ + "type": "gpii.tests.acceptance.windows.morphic", + "options": { + "distributeOptions": { + "acceptance.installedSolutionsPath": { + "record": "%gpii-universal/testData/deviceReporter/acceptanceTests/morphic.json", + "target": "{that deviceReporter installedSolutionsDataSource}.options.path", + "priority": "after:development.installedSolutionsPath" + } + } + }, + "mergeConfigs": "%gpii-universal/gpii/configs/shared/gpii.config.development.local.json5" +} diff --git a/tests/platform/windows/configs/gpii.tests.acceptance.windows.morphic.config.txt b/tests/platform/windows/configs/gpii.tests.acceptance.windows.morphic.config.txt new file mode 100644 index 000000000..94d5c786e --- /dev/null +++ b/tests/platform/windows/configs/gpii.tests.acceptance.windows.morphic.config.txt @@ -0,0 +1,7 @@ +This configuration file is used for testing the Morphic QSS in Windows + +It includes to the basic localInstall setup for acceptance tests which includes +the standard development config file (running GPII locally, using development setup). + +This config sets the device reporter file to be 'morphic.json', which will report +Morphic as being installed on the system. diff --git a/tests/platform/windows/windows-morphic-testSpec.js b/tests/platform/windows/windows-morphic-testSpec.js new file mode 100644 index 000000000..1c23ccf57 --- /dev/null +++ b/tests/platform/windows/windows-morphic-testSpec.js @@ -0,0 +1,87 @@ +/* +GPII Integration and Acceptance Testing + +Copyright 2014 Emergya +Copyright 2017 OCAD University + +Licensed under the New BSD license. You may not use this file except in +compliance with this License. + +You may obtain a copy of the License at +https://github.com/GPII/universal/blob/master/LICENSE.txt + +The research leading to these results has received funding from the European Union's +Seventh Framework Programme (FP7/2007-2013) under grant agreement no. 289016. +*/ + +"use strict"; + +var fluid = require("infusion"), + gpii = fluid.registerNamespace("gpii"); + +fluid.registerNamespace("gpii.tests.windows.morphic"); + +fluid.require("%gpii-universal"); + +gpii.tests.windows.morphic.testDefs = [ + { + name: "Acceptance tests for app-specific Morphic preferences", + gpiiKey: "morphic_application", + settingsHandlers: { + "gpii.settingsHandlers.webSockets": { + "data": [ + { + "settings": { + "tooltipDisplayDelay": 2000, + "scaleFactor": 1, + "alwaysUseChrome": true, + "appBarQss": true, + "buttonList": [ + "text-zoom", + "screen-zoom", + "color-vision", + "high-contrast", + "volume", + "mouse", + "read-aloud", + "snipping-tool", + "||", + "office-simplification", + "launch-documorph", + "usb-open", + "||", + "service-more", + "service-save", + "service-undo", + "service-reset-all", + "service-close" + ], + "morePanelList": + [ + [ "text-zoom", "screen-zoom", "color-vision", "high-contrast", "volume", "mouse", "read-aloud", "snipping-tool" ], + [ "text-zoom", "screen-zoom", "color-vision", "high-contrast", "volume", "mouse", "read-aloud", "snipping-tool" ], + [ "text-zoom", "screen-zoom", "color-vision", "high-contrast", "volume", "mouse", "read-aloud", "snipping-tool" ] + ], + "closeQssOnClickOutside": true, + "disableRestartWarning": false, + "openQssShortcut": "Shift+Ctrl+AltOrOption+SuperOrCmd+O" + }, + "options": { + "path": "net.gpii.morphic" + } + } + ] + } + } + } +]; + + +gpii.loadTestingSupport(); + +module.exports = gpii.test.bootstrap({ + testDefs: "gpii.tests.windows.morphic.testDefs", + configName: "gpii.tests.acceptance.windows.morphic.config", + configPath: "%gpii-universal/tests/platform/windows/configs" +}, ["gpii.test.integration.testCaseHolder.windows"], + module, require, __dirname); diff --git a/tests/platform/windows/windows-morphic-testSpec.txt b/tests/platform/windows/windows-morphic-testSpec.txt new file mode 100644 index 000000000..490b8c7c1 --- /dev/null +++ b/tests/platform/windows/windows-morphic-testSpec.txt @@ -0,0 +1,8 @@ +windows-morphic-testSpec.txt + +Descriptions: + +* Solution: Morphic QuickStrip +* Device reporter file: morphic.json +* preference sets: + * morphic_application