diff --git a/gpii/node_modules/userListeners/index.js b/gpii/node_modules/userListeners/index.js index b43dee974..af72537e9 100644 --- a/gpii/node_modules/userListeners/index.js +++ b/gpii/node_modules/userListeners/index.js @@ -25,3 +25,4 @@ fluid.module.register("userListeners", __dirname, require); require("./src/listeners.js"); require("./src/pcsc.js"); require("./src/usb.js"); +require("./src/userFolder.js"); diff --git a/gpii/node_modules/userListeners/src/listeners.js b/gpii/node_modules/userListeners/src/listeners.js index 304a96b84..e3074760b 100644 --- a/gpii/node_modules/userListeners/src/listeners.js +++ b/gpii/node_modules/userListeners/src/listeners.js @@ -18,10 +18,14 @@ "use strict"; -var fluid = require("infusion"); +var fluid = require("infusion"), + fs = require("fs"), + path = require("path"); var gpii = fluid.registerNamespace("gpii"); +require("../../lifecycleManager/"); + fluid.registerNamespace("gpii.userListeners"); // The user listeners. @@ -55,6 +59,15 @@ fluid.defaults("gpii.userListeners", { onListenersStop: "{userListeners}.events.onListenersStop" } } + }, + userFolder: { + type: "gpii.userListeners.userFolder", + options: { + events: { + onListenersStart: "{userListeners}.events.onListenersStart", + onListenersStop: "{userListeners}.events.onListenersStop" + } + } } }, events: { @@ -106,6 +119,14 @@ fluid.defaults("gpii.userListener", { "{that}", "{arguments}.0" // The error. ] + }, + readTokenFile: { + funcName: "gpii.userListeners.readTokenFile", + args: [ "{arguments}.0", "{arguments}.1" ] // directory, file + }, + writeTokenFile: { + funcName: "gpii.userListeners.writeTokenFile", + args: [ "{arguments}.0", "{arguments}.1", "{arguments}.2" ] // directory, file, token } }, members: { @@ -208,3 +229,78 @@ gpii.userListeners.failed = function (that, err) { }); }); }; + +/** + * Reads a token from the file. + * + * @param {String} tokenDirectory - The directory containing the token file. + * @param {String} tokenFile [optional] The file, in tokenDirectory, containing the token. Can be omitted if the first + * argument is the full path to the file. + * @return {Promise} resolves when the token is read, rejects if there's no file (or there was an error reading it). + */ +gpii.userListeners.readTokenFile = function (tokenDirectory, tokenFile) { + var promise = fluid.promise(); + + // Try to read the token file. + var tokenPath = path.join(tokenDirectory, tokenFile || ""); + fluid.log("Reading token file: ", tokenPath); + fs.readFile(tokenPath, "utf8", function (err, data) { + if (err) { + if (err.code === "ENOENT") { + fluid.log("Token file not found"); + promise.reject(); + } else { + var message = "Error reading token file " + tokenPath + ": " + err.message; + fluid.log(message); + promise.reject({ + isError: true, + message: message, + error: err + }); + } + } else { + var token = data.trim(); + if (token.length > 0) { + fluid.log("Got token: ", token); + promise.resolve(token); + } else { + fluid.log("Empty token file"); + promise.reject(); + } + } + }); + + return promise; +}; + +/** + * Writes a token to a file. + * + * @param {String} tokenDirectory - The directory containing the token file. + * @param {String} tokenFile [optional] The file, in tokenDirectory, containing the token. Can be omitted if the first + * argument is the full path to the file. + * @param {String} tokenValue - The token's value. + * @return {Promise} resolves when complete, with a value of true if it was written. + */ +gpii.userListeners.writeTokenFile = function (tokenDirectory, tokenFile, tokenValue) { + var promise = fluid.promise(); + + var tokenPath = path.join(tokenDirectory, tokenFile || ""); + fluid.log("Writing token '" + tokenValue + "' to ", tokenPath); + + fs.writeFile(tokenPath, tokenValue, "utf8", function (err) { + if (err) { + var message = "Error writing token file " + tokenPath + ": " + err.message; + fluid.log(message); + promise.reject({ + isError: true, + message: message, + error: err + }); + } else { + promise.resolve(true); + } + }); + + return promise; +}; diff --git a/gpii/node_modules/userListeners/src/usb.js b/gpii/node_modules/userListeners/src/usb.js index 8d0698d78..86686e098 100644 --- a/gpii/node_modules/userListeners/src/usb.js +++ b/gpii/node_modules/userListeners/src/usb.js @@ -17,9 +17,7 @@ "use strict"; -var fluid = require("infusion"), - fs = require("fs"), - path = require("path"); +var fluid = require("infusion"); var gpii = fluid.registerNamespace("gpii"); @@ -84,27 +82,10 @@ gpii.userListeners.usb.readUSBToken = function (that, usbPath) { var promise = fluid.promise(); // Try to read the token file. - var tokenFile = path.join(usbPath, that.tokenFile); - fs.readFile(tokenFile, "utf8", function (err, data) { - if (err) { - fluid.log("Error reading token from device (" + usbPath + "):"); - promise.reject({ - isError: true, - message: "Error reading token from device (" + usbPath + ")", - error: err - }); - } else { - that.currentDevice.path = usbPath; - fluid.log("Got token from USB device (" + that.currentDevice.path + "):", that.currentDevice.token); - if (that.currentDevice.token !== data.trim()) { - that.currentDevice.token = data.trim(); - that.events.onTokenArrive.fire(that, that.currentDevice.token); - } else { - fluid.log("Ignoring key-in request. Got the same token from a different USB device (" + that.currentDevice.path + "):", - that.currentDevice.token); - } - promise.resolve(that.currentDevice.token); - } + promise = that.readTokenFile(usbPath, that.tokenFile).then(function (token) { + that.currentDevice.token = token; + that.currentDevice.path = usbPath; + that.events.onTokenArrive.fire(that, that.currentDevice.token); }); return promise; diff --git a/gpii/node_modules/userListeners/src/userFolder.js b/gpii/node_modules/userListeners/src/userFolder.js new file mode 100644 index 000000000..2832e3365 --- /dev/null +++ b/gpii/node_modules/userListeners/src/userFolder.js @@ -0,0 +1,115 @@ +/* userFolder user listener. + * + * Copyright 2017 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"); + +// The user folder user listener. +fluid.defaults("gpii.userListeners.userFolder", { + gradeNames: ["fluid.component", "fluid.contextAware", "gpii.userListener"], + contextAwareness: { + platform: { + checks: { + windows: { + contextValue: "{gpii.contexts.windows}", + gradeNames: "gpii.userListeners.userFolder.windows" + } + } + } + }, + members: { + tokenDirectory: "@expand:{settingsDir}.getGpiiSettingsDir()", + tokenFile: ".gpii-user-token.txt", + done: false, + listenerName: "userFolder" + }, + components: { + settingsDir: { + type: "gpii.settingsDir" + } + }, + invokers: { + startListener: "{that}.attemptLogin", + stopListener: "fluid.identity", + attemptLogin: { + funcName: "gpii.userListeners.userFolder.attemptLogin", + args: ["{that}"] + }, + getToken: { + func: "{that}.readTokenFile", + args: ["{that}.tokenDirectory", "{that}.tokenFile"] + }, + storeToken: { + func: "{that}.writeTokenFile", + args: [ "{that}.tokenDirectory", "{that}.tokenFile", "{arguments}.0" ] + } + }, + listeners: { + "onCreate.loginListener": { + funcName: "gpii.userListeners.userFolder.addLoginListener", + args: [ "{that}", "{lifecycleManager}.events.onSessionStart"] + } + }, + // Disabling by default, as it is the safest and will prevent people's keys from being accidentally stored when it + // is not desired (eg, on shared or public access computers). + saveLastLogin: false +}); + +/** + * Adds a listener to the onSessionStart event of the lifecycle manager, which stores the token file. + * + * @param {Component} that The gpii.userListeners.userFolder instance. + * @param {EventFirer} firer The event firer for the onSessionStart event. + */ +gpii.userListeners.userFolder.addLoginListener = function (that, firer) { + if (that.options.saveLastLogin) { + firer.addListener(function (gradeName, gpiiKey) { + if (gpiiKey !== "noUser") { + that.storeToken(gpiiKey); + } + }); + } +}; + +/** + * Performs the key-in, by raising the onTokenArrive event with the result of getToken(). + * + * This should only be called once, during startup. Subsequent calls will have no effect. + * + * @param {Component} that - The gpii.userListeners.userFolder instance. + * @return {Promise} A promise resolving when the token has been read. + */ +gpii.userListeners.userFolder.attemptLogin = function (that) { + var promise; + + if (that.done) { + promise = fluid.promise().reject({ + isError: true, + message: "userFolder.attemptLogin had already been called." + }); + } else { + that.done = true; + promise = that.getToken().then(function (token) { + that.events.onTokenArrive.fire(that, token); + }); + } + + return promise; +}; diff --git a/gpii/node_modules/userListeners/test/all-tests.js b/gpii/node_modules/userListeners/test/all-tests.js index 1b2f7faba..7ea8145c3 100644 --- a/gpii/node_modules/userListeners/test/all-tests.js +++ b/gpii/node_modules/userListeners/test/all-tests.js @@ -20,3 +20,4 @@ require("./pcscTests.js"); require("./usbTests.js"); +require("./userFolderTests.js"); diff --git a/gpii/node_modules/userListeners/test/pcscTests.js b/gpii/node_modules/userListeners/test/pcscTests.js index 49c7fa5fa..3b7b8ad2d 100644 --- a/gpii/node_modules/userListeners/test/pcscTests.js +++ b/gpii/node_modules/userListeners/test/pcscTests.js @@ -134,6 +134,28 @@ fluid.defaults("gpii.test.userListeners.pcsc", { } }); +/* + * Get an instance of the gpii.userListeners.pcsc component. + */ +gpii.tests.userListener.getPcscListener = function () { + var userListeners = gpii.userListeners({ + listeners: { + "onCreate.startListeners": "fluid.identity" + }, + events: { + "onListenersStart": null + }, + distributeOptions: { + pcsc: { + record: "gpii.test.userListeners.pcsc", + target: "{that pcsc}.options.gradeNames" + } + } + }); + + return userListeners.pcsc; +}; + /** * Creates a card reader that emulates a real reader, by returning a canned response to commands. * diff --git a/gpii/node_modules/userListeners/test/usbTests.js b/gpii/node_modules/userListeners/test/usbTests.js index a3d2b15a4..f762e66f9 100644 --- a/gpii/node_modules/userListeners/test/usbTests.js +++ b/gpii/node_modules/userListeners/test/usbTests.js @@ -52,6 +52,28 @@ jqUnit.module("gpii.tests.userListener.usb", { } }); +/* + * Get an instance of the gpii.userListeners.usb component. + */ +gpii.tests.userListener.getUSBListener = function () { + var userListeners = gpii.userListeners({ + listeners: { + "onCreate.startListeners": "fluid.identity" + }, + events: { + "onListenersStart": null + }, + distributeOptions: { + usb: { + record: "gpii.tests.userListener.usbListener", + target: "{that usb}.options.gradeNames" + } + } + }); + + return userListeners.usb; +}; + gpii.tests.userListener.insertTests = [ // Device added { @@ -189,7 +211,7 @@ gpii.tests.userListener.deviceMountTests = function (tests, func, createFiles, e // Tests USB device insertion. jqUnit.asyncTest("USB listener - device inserted", function () { gpii.tests.userListener.deviceMountTests( - gpii.tests.userListener.insertTests, gpii.userListeners.usb.readUSBToken, true, 9); + gpii.tests.userListener.insertTests, gpii.userListeners.usb.readUSBToken, true, 10); }); // Tests USB device removal. diff --git a/gpii/node_modules/userListeners/test/userFolderTests.js b/gpii/node_modules/userListeners/test/userFolderTests.js new file mode 100644 index 000000000..fa9a423c9 --- /dev/null +++ b/gpii/node_modules/userListeners/test/userFolderTests.js @@ -0,0 +1,290 @@ +/* + * userFolder User listener tests + * + * Copyright 2017 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"), + os = require("os"), + path = require("path"), + fs = require("fs"); + +var jqUnit = fluid.require("node-jqunit"); +var gpii = fluid.registerNamespace("gpii"); +fluid.registerNamespace("gpii.tests.userListener"); + +require("../index.js"); +require("./shared/lifecycleManagerTestGrade.js"); + +fluid.defaults("gpii.tests.userListener.userFolderListener", { + gradeNames: ["gpii.userListeners.userFolder"], + components: { + lifecycleManager: { + type: "gpii.tests.userListener.lifecycleManager" + } + }, + invokers: { + startListener: "fluid.identity", + stopListener: "fluid.identity" + } +}); + +var teardowns = []; +jqUnit.module("gpii.tests.userListener.userFolder", { + teardown: function () { + while (teardowns.length) { + teardowns.pop()(); + } + } +}); + +gpii.tests.userListener.tokenFileTests = fluid.freezeRecursive([ + { + filename: "test1", + content: "test-key1", + expect: "test-key1" + }, + { + filename: "test2", + content: " \n \t test-key2 \n \t \r", + expect: "test-key2" + }, + { + filename: "test3", + content: "", + expect: "reject" + }, + { + filename: "test4", + content: " \r\n\t", + expect: "reject" + }, + { + directory: "/does/not/exist", + filename: "a", + expect: "reject" + }, + { + directory: "/", + filename: "", + expect: "reject" + }, + { + directory: "", + filename: "", + expect: "reject" + } +]); + +/** + * Get an instance of the gpii.userListeners.userFolder component. + * @return {Component} The gpii.userListeners.userFolder component. + */ +gpii.tests.userListener.getUserFolderListener = function () { + var userListeners = gpii.userListeners({ + listeners: { + "onCreate.startListeners": "fluid.identity" + }, + events: { + "onListenersStart": null + }, + distributeOptions: { + userFolder: { + record: "gpii.tests.userListener.userFolderListener", + target: "{that userFolder}.options.gradeNames" + } + } + }); + + return userListeners.userFolder; +}; + +jqUnit.asyncTest("userFolder readTokenFile", function () { + + var tests = gpii.tests.userListener.tokenFileTests; + + jqUnit.expect(tests.length * 2); + var tempDir = path.join(os.tmpdir(), "gpii-listener-tests" + Math.random()); + fs.mkdirSync(tempDir); + var tempFiles = []; + + teardowns.push(function () { + while (tempFiles.length) { + fs.unlinkSync(tempFiles.pop()); + } + fs.rmdirSync(tempDir); + }); + + var doTest = function (index) { + if (index >= tests.length) { + jqUnit.start(); + } else { + var currentTest = tests[index]; + var messagePrefix = " - test index " + index; + + if (currentTest.hasOwnProperty("content")) { + var tempFile = path.join(tempDir, currentTest.filename); + fs.writeFileSync(tempFile, currentTest.content, "utf8"); + tempFiles.push(tempFile); + } + + var directory = currentTest.hasOwnProperty("directory") ? currentTest.directory : tempDir; + var file = currentTest.filename; + + var p = gpii.userListeners.readTokenFile(directory, file); + + jqUnit.assertTrue("readTokenFile must return a promise" + messagePrefix, fluid.isPromise(p)); + + p.then(function (token) { + jqUnit.assertEquals("readTokenFile should resolve with the expected value" + messagePrefix, + currentTest.expect, token); + + doTest(index + 1); + }, function () { + jqUnit.assertEquals("readTokenFile should reject if expected" + messagePrefix, + currentTest.expect, "reject"); + + doTest(index + 1); + }); + } + }; + + doTest(0); +}); + +jqUnit.asyncTest("userFolder writeTokenFile", function () { + + var tempDir = os.tmpdir(); + var tokenFile = "test-token-file" + Math.random(); + var tokenPath = path.join(tempDir, tokenFile); + + teardowns.push(function () { + try { + fs.unlinkSync(tokenPath); + } catch (e) { + // ignore + } + }); + + fluid.promise.sequence([ + function () { + var p = gpii.userListeners.writeTokenFile(tempDir, tokenFile, "token1", true); + jqUnit.assertTrue("1st writeTokenFile should return a promise", fluid.isPromise(p)); + + return p.then(function () { + var content = fs.readFileSync(tokenPath, "utf8"); + jqUnit.assertEquals("Token in file should match what was written", "token1", content); + }); + }, + function () { + // Using the same path as before also tests over-writing. + var p = gpii.userListeners.writeTokenFile(tokenPath, null, "token2", true); + jqUnit.assertTrue("2nd writeTokenFile should return a promise", fluid.isPromise(p)); + + return p.then(function () { + var content = fs.readFileSync(tokenPath, "utf8"); + jqUnit.assertEquals("Token in file should match what was written", "token2", content); + }); + }, + function () { + var p = gpii.userListeners.writeTokenFile("does/not/exist", tokenFile, "token3", true); + jqUnit.assertTrue("3rd writeTokenFile should return a promise", fluid.isPromise(p)); + + var promiseTogo = fluid.promise(); + p.then(function () { + jqUnit.fail("writeTokenFile with a bad path should reject"); + promiseTogo.reject(); + }, promiseTogo.resolve); + } + ]).then(jqUnit.start, jqUnit.fail); +}); + +// Test if the login on startup gets called. +jqUnit.asyncTest("Test attemptLogin", function () { + + jqUnit.expect(3); + + var testToken = "test-token" + Math.random(); + + // Write the test token file. + var tempDir = os.tmpdir(); + var tokenFile = "test-token-file" + Math.random(); + var tokenPath = path.join(tempDir, tokenFile); + fs.writeFileSync(tokenPath, testToken, "utf8"); + + teardowns.push(function () { + try { + fs.unlinkSync(tokenPath); + } catch (e) { + // ignore + } + }); + + var userFolder = gpii.tests.userListener.getUserFolderListener(); + + userFolder.tokenDirectory = tempDir; + userFolder.tokenFile = tokenFile; + + var eventRaised = false; + + userFolder.events.onTokenArrive.addListener(function (that, token) { + jqUnit.assertFalse("Token event should only fire once", eventRaised); + eventRaised = true; + jqUnit.assertEquals("Token in event should match the test token", testToken, token); + }); + + gpii.userListeners.userFolder.attemptLogin(userFolder).then(function () { + // try it again - it should reject. + gpii.userListeners.userFolder.attemptLogin(userFolder).then(function () { + jqUnit.fail("Second call to attemptLogin should reject"); + }, jqUnit.start); + }); +}); + +// Tests that the onSessionStart event would be listened upon if configured to. +jqUnit.test("test addLoginListener", function () { + + jqUnit.expect(3); + + var tokenValue = "test-token"; + var tokenWrote = false; + + var userFolder = gpii.tests.userListener.getUserFolderListener(); + + // Fake the storeToken routine. + userFolder.storeToken = function (token) { + tokenWrote = true; + jqUnit.assertEquals("Correct token should be written", tokenValue, token); + }; + + var firer = fluid.makeEventFirer(); + + // Test when saving is disabled + userFolder.options.saveLastLogin = false; + gpii.userListeners.userFolder.addLoginListener(userFolder, firer); + firer.fire("dummy", tokenValue); + + jqUnit.assertFalse("Token should not have been written", tokenWrote); + + // Test when saving is enabled + userFolder.options.saveLastLogin = true; + gpii.userListeners.userFolder.addLoginListener(userFolder, firer); + firer.fire("dummy", tokenValue); + + jqUnit.assertTrue("Token should have been written", tokenWrote); + + userFolder.destroy(); +});