diff --git a/src/api.js b/src/api.js index c5a8fc84..8d883b05 100644 --- a/src/api.js +++ b/src/api.js @@ -9,6 +9,7 @@ const path = require("path"); const routeUtils = require("./procedures/utils/router-utils"); const NetsBloxCloud = require("./cloud-client"); const { UserError } = require("./error"); +const RpcCaller = require("./rpc-caller"); class ServicesAPI { constructor() { @@ -85,7 +86,7 @@ class ServicesAPI { this.addServiceRoutes(router); - router.route("/").get((req, res) => { + router.route("/").get((_req, res) => { const metadata = Object.entries(this.services.metadata) .filter((nameAndMetadata) => this.isServiceLoaded(nameAndMetadata[0])) .map((pair) => { @@ -169,35 +170,24 @@ class ServicesAPI { return false; } - async invokeRPC(serviceName, rpcName, req, res) { + async invokeRPC(serviceName, rpcName, request, response) { const { clientId } = req.query; + const caller = new RpcCaller(NetsBloxCloud, clientId); this.logger.info( - `Received request to ${serviceName} for ${rpcName} (from ${clientId})`, + `Received request to ${serviceName} for ${rpcName} (from ${caller.clientId})`, ); - const ctx = {}; - ctx.response = res; - ctx.request = req; - const { username, state } = await NetsBloxCloud.getClientInfo(clientId); - // TODO: add support for external states, too? - const projectId = state?.browser?.projectId; - const roleId = state?.browser?.roleId; - - ctx.caller = { - username, - projectId, - roleId, - clientId, - }; + const ctx = { caller, request, response }; + const apiKey = this.services.getApiKey(serviceName); - const isLoggedIn = !!username; - if (apiKey && isLoggedIn) { + if (apiKey && await caller.isLoggedIn()) { // TODO: handle invalid settings (parse error) const apiKeyValue = await this.keys.get(username, apiKey); // TODO: double check this if (apiKeyValue) { ctx.apiKey = apiKeyValue; } } + // TODO: move the socket over? ctx.socket = new RemoteClient(projectId, roleId, clientId); const args = this.getArguments(serviceName, rpcName, req); diff --git a/src/cloud-client.js b/src/cloud-client.js index 63bd6144..a9aa9c7f 100644 --- a/src/cloud-client.js +++ b/src/cloud-client.js @@ -74,7 +74,7 @@ class NetsBloxCloud { } // Service Settings - async getServiceSettings(username) { + async getServiceSettings(username) { // TODO: memoize this const url = `/services/settings/user/${username}/${this.id}/all`; const settings = await this.get(url); // settings fields are strings which we happen to use to store JSON diff --git a/src/input-types.js b/src/input-types.js index 0bcbc8aa..336d4af6 100644 --- a/src/input-types.js +++ b/src/input-types.js @@ -433,20 +433,34 @@ defineType({ parser: async (blockXml, _params, ctx) => { let roleName = ""; let roleNames = [""]; + let username = null; + let projectId = null; + let roleId = null; if (ctx) { - const room = await Cloud.getRoomState(ctx.caller.projectId); + const room = await ctx.caller.getRoomState(); + roleId = await ctx.caller.getRoleId(); + projectId = await ctx.caller.getProjectId(); + username = await ctx.caller.getUsername(); if (room) { roleNames = Object.values(room.roles) .map((role) => role.name); - roleName = room.roles[ctx.caller.roleId].name; + roleName = room.roles[roleId].name; } } let factory = blocks2js.compile(blockXml); let env = blocks2js.newContext(); env.__start = function (project) { - project.ctx = ctx; + const defaultCtx = { caller: {} }; + project.ctx = ctx || defaultCtx; + + // ctx.caller is expected to provide sync access to things like projectId, roleId, username + // polyfill it for now + project.ctx.caller.roleId = roleId; + project.ctx.caller.projectId = projectId; + project.ctx.caller.username = username; + project.roleName = roleName; project.roleNames = roleNames; }; diff --git a/src/messages.js b/src/messages.js index 4663b6c0..cec74cb5 100644 --- a/src/messages.js +++ b/src/messages.js @@ -19,13 +19,7 @@ class SendMessage extends Message { } class SendMessageToClient extends Message { - constructor(projectId, roleId, clientId, type, contents) { - const state = { - browser: { - projectId, - roleId, - }, - }; + constructor(state, clientId, type, contents) { const target = { client: { state, clientId } }; super(target, type, contents); } diff --git a/src/procedures/alexa/alexa.js b/src/procedures/alexa/alexa.js index e78d045e..daa1faac 100644 --- a/src/procedures/alexa/alexa.js +++ b/src/procedures/alexa/alexa.js @@ -46,7 +46,8 @@ Alexa.createSkill = async function (configuration) { const vendorId = await h.getVendorID(smapiClient); - const manifest = schemas.manifest(this.caller.username, configuration); + const username = await this.caller.getUsername(); + const manifest = schemas.manifest(username, configuration); const interactionModel = schemas.interactionModel(configuration); const accountLinkingRequest = schemas.accountLinking(); @@ -77,8 +78,8 @@ Alexa.createSkill = async function (configuration) { await skills.updateOne({ _id: skillId }, { $set: { config: configuration, - context: this.caller, - author: this.caller.username, + context: await this.caller.toSnapshot(), + author: await this.caller.getUsername(), createdAt: new Date(), }, }, { upsert: true }); @@ -128,7 +129,7 @@ Alexa.invokeSkill = async function (id, utterance) { Alexa.deleteSkill = async function (id) { const { skills } = GetStorage(); const skillData = await h.getSkillData(id); - if (skillData.author !== this.caller.username) { + if (skillData.author !== await this.caller.getUsername()) { throw new Error("Unauthorized: Skills can only be deleted by the author."); } @@ -152,7 +153,9 @@ Alexa.deleteSkill = async function (id) { */ Alexa.listSkills = async function () { const { skills } = GetStorage(); - const skillConfigs = await skills.find({ author: this.caller.username }) + const skillConfigs = await skills.find({ + author: await this.caller.getUsername(), + }) .toArray(); return skillConfigs.map((skill) => skill._id); }; @@ -202,8 +205,8 @@ Alexa.updateSkill = async function (id, configuration) { await skills.updateOne({ _id: id }, { $set: { config: configuration, - context: this.caller, - author: this.caller.username, + context: await this.caller.toSnapshot(), + author: await this.caller.getUsername(), updatedAt: new Date(), }, }, { upsert: true }); diff --git a/src/procedures/alexa/helpers.js b/src/procedures/alexa/helpers.js index 306e705d..b97faeb9 100644 --- a/src/procedures/alexa/helpers.js +++ b/src/procedures/alexa/helpers.js @@ -31,16 +31,10 @@ function clarifyError(error) { return error; } -const ensureLoggedIn = function (caller) { - if (!caller.username) { - throw new Error("Login required."); - } -}; - const getAPIClient = async function (caller) { - ensureLoggedIn(caller); + const username = await caller.getUsername(); const collection = GetStorage().tokens; - const tokens = await collection.findOne({ username: caller.username }); + const tokens = await collection.findOne({ username }); if (!tokens) { throw new Error("Amazon Login required. Please login."); } diff --git a/src/procedures/alexa/skill.js b/src/procedures/alexa/skill.js index 53cd9cc1..98599e08 100644 --- a/src/procedures/alexa/skill.js +++ b/src/procedures/alexa/skill.js @@ -18,15 +18,13 @@ class AlexaSkill { .find((intentConfig) => intentConfig.name === name); const handlerXML = intentConfig.handler; - const context = this.skillData.context; + const context = CallerSnapshot.from(this.skillData.context); if (username) { - context.username = username; + context.setUsername(username); } + const socket = new RemoteClient( - context.projectId, - context.roleId, - null, - username, + context, ); const handler = await InputTypes.parse.Function(handlerXML, null, { diff --git a/src/procedures/autograders/autograders.js b/src/procedures/autograders/autograders.js index 94214af6..432d6da2 100644 --- a/src/procedures/autograders/autograders.js +++ b/src/procedures/autograders/autograders.js @@ -93,11 +93,6 @@ const validateTest = (testConfig) => { } }; -const ensureLoggedIn = function (caller) { - if (!caller.username) { - throw new Error("Login required."); - } -}; const Autograders = {}; /** @@ -106,10 +101,9 @@ const Autograders = {}; * @param {Object} configuration */ Autograders.createAutograder = async function (config) { - ensureLoggedIn(this.caller); + const author = await this.caller.getUsername(); config = preprocessConfig(config); const { autograders } = getDatabase(); - const author = this.caller.username; const extension = { type: "Autograder", name: config.name, @@ -143,9 +137,8 @@ Autograders.createAutograder = async function (config) { * @param {String} consumer - name of the consumer to add (eg, Coursera) */ Autograders.addLTIConsumer = async function (autograder, consumer) { - ensureLoggedIn(this.caller); const { autograders } = getDatabase(); - const author = this.caller.username; + const author = await this.caller.getUsername(); try { const secret = uuid.v4(); const consumerData = { @@ -209,9 +202,8 @@ Autograders.addLTIConsumer = async function (autograder, consumer) { * @param {String} autograder - name of the autograder to update */ Autograders.getLTIConsumers = async function (autograder) { - ensureLoggedIn(this.caller); const { autograders } = getDatabase(); - const author = this.caller.username; + const author = await this.caller.getUsername(); const grader = await autograders.findOne( { author, name: autograder }, ); @@ -232,9 +224,8 @@ Autograders.getLTIConsumers = async function (autograder) { * @param {String} consumer - name of the consumer to add (eg, Coursera) */ Autograders.removeLTIConsumer = async function (autograder, consumer) { - ensureLoggedIn(this.caller); const { autograders } = getDatabase(); - const author = this.caller.username; + const author = await this.caller.getUsername(); const grader = await autograders.findOne( { author, name: autograder }, ); @@ -259,8 +250,7 @@ Autograders.removeLTIConsumer = async function (autograder, consumer) { * List the autograders for the given user. */ Autograders.getAutograders = async function () { - ensureLoggedIn(this.caller); - const author = this.caller.username; + const author = await this.caller.getUsername(); const { autograders } = getDatabase(); const options = { projection: { name: 1 }, @@ -275,8 +265,7 @@ Autograders.getAutograders = async function () { * @param {String} name */ Autograders.getAutograderConfig = async function (name) { - ensureLoggedIn(this.caller); - const author = this.caller.username; + const author = await this.caller.getUsername(); const { autograders } = getDatabase(); const autograder = await autograders.findOne({ author, name }); if (!autograder) { diff --git a/src/procedures/battleship/battleship.js b/src/procedures/battleship/battleship.js index fd8d20d2..9b564263 100644 --- a/src/procedures/battleship/battleship.js +++ b/src/procedures/battleship/battleship.js @@ -106,8 +106,8 @@ Battleship.prototype.start = function () { * @param {String} facing Direction to face * @returns {Boolean} If piece was placed */ -Battleship.prototype.placeShip = function (ship, row, column, facing) { - var role = this.caller.roleId, +Battleship.prototype.placeShip = async function (ship, row, column, facing) { + var role = await this.caller.getRoleId(), len = SHIPS[ship]; row--; @@ -158,8 +158,8 @@ Battleship.prototype.placeShip = function (ship, row, column, facing) { * @param {BoundedNumber<1,100>} column Column to fire at * @returns {Boolean} If ship was hit */ -Battleship.prototype.fire = function (row, column) { - const role = this.caller.roleId; +Battleship.prototype.fire = async function (row, column) { + const role = await this.caller.getRoleId(); row = row - 1; column = column - 1; @@ -199,22 +199,23 @@ Battleship.prototype.fire = function (row, column) { const result = this._state._boards[target].fire(row, column); if (result) { - return Utils.getRoleName(this.caller.projectId, target) - .then((targetName) => { - const msgType = result.HIT - ? BattleshipConstants.HIT - : BattleshipConstants.MISS; - const data = { - role: targetName, - row: row + 1, - column: column + 1, - ship: result.SHIP, - sunk: result.SUNK, - }; - this.socket.sendMessageToRoom(msgType, data); - this.response.send(!!result); - return !!result; - }); + const targetName = await Utils.getRoleName( + await this.caller.getRoomState(), + target, + ); + + const msgType = result.HIT + ? BattleshipConstants.HIT + : BattleshipConstants.MISS; + + const data = { + role: targetName, + row: row + 1, + column: column + 1, + ship: result.SHIP, + sunk: result.SUNK, + }; + this.socket.sendMessageToRoom(msgType, data); } this.response.send(!!result); @@ -227,22 +228,17 @@ Battleship.prototype.fire = function (row, column) { * @returns {Integer} Number of remaining ships */ Battleship.prototype.remainingShips = async function (roleName) { + let role; + if (roleName) { // resolve the provided role name to a role ID - const metadata = await NetsBloxCloud.getRoomState(this.caller.projectId); - const role = Object.keys(metadata.roles).find((id) => { + const metadata = await this.caller.getRoomState(); + role = Object.keys(metadata.roles).find((id) => { return metadata.roles[id].name === roleName; }); - - if (!this._state._boards[role]) { - logger.error(`board doesn't exist for "${role}"`); - this._state._boards[role] = new Board(BOARD_SIZE); - } - - return this._state._boards[role].remaining(); + } else { + role = await this.caller.getRoleId(); } - const role = this.caller.roleId; - if (!this._state._boards[role]) { logger.error(`board doesn't exist for "${role}"`); this._state._boards[role] = new Board(BOARD_SIZE); diff --git a/src/procedures/cloud-variables/cloud-variables.js b/src/procedures/cloud-variables/cloud-variables.js index 8f2d56ac..6b523173 100644 --- a/src/procedures/cloud-variables/cloud-variables.js +++ b/src/procedures/cloud-variables/cloud-variables.js @@ -71,12 +71,6 @@ const ensureAuthorized = function (variable, password) { } }; -const ensureLoggedIn = function (caller) { - if (!caller.username) { - throw new Error("Login required."); - } -}; - const validateVariableName = function (name) { if (!/^[\w _()-]+$/.test(name)) { throw new Error("Invalid variable name."); @@ -105,7 +99,7 @@ CloudVariables._setMaxLockAge = function (age) { // for testing */ CloudVariables.getVariable = async function (name, password) { const { sharedVars } = getCollections(); - const username = this.caller.username; + const username = await this.caller.getUsernameOrClientId(); const variable = await sharedVars.findOne({ name: name }); ensureVariableExists(variable); @@ -146,7 +140,7 @@ CloudVariables.setVariable = async function (name, value, password) { validateContentSize(value); const { sharedVars } = getCollections(); - const username = this.caller.username; + const username = await this.caller.getUsernameOrClientId(); const variable = await sharedVars.findOne({ name: name }); ensureAuthorized(variable, password); @@ -200,7 +194,7 @@ CloudVariables.lockVariable = async function (name, password) { validateVariableName(name); const { sharedVars } = getCollections(); - const username = this.caller.username; + const username = await this.caller.getUsernameOrClientId(); const clientId = this.caller.clientId; const variable = await sharedVars.findOne({ name: name }); @@ -222,6 +216,7 @@ CloudVariables.lockVariable = async function (name, password) { CloudVariables._queueLockFor = async function (variable) { // Return a promise which will resolve when the lock is applied + const username = await this.caller.getUsernameOrClientId(); const deferred = utils.defer(); const id = variable._id; const { password } = variable; @@ -234,7 +229,7 @@ CloudVariables._queueLockFor = async function (variable) { id: id, password: password, clientId: this.caller.clientId, - username: this.caller.username, + username: username, promise: deferred, }; @@ -369,9 +364,7 @@ CloudVariables._onUnlockVariable = async function (id) { */ CloudVariables.getUserVariable = async function (name) { const { userVars } = getCollections(); - const username = this.caller.username; - - ensureLoggedIn(this.caller); + const username = await this.caller.getUsername(); const variable = await userVars.findOne({ name: name, owner: username }); if (!variable) { @@ -393,12 +386,11 @@ CloudVariables.getUserVariable = async function (name) { * @param {Any} value Value to store in variable */ CloudVariables.setUserVariable = async function (name, value) { - ensureLoggedIn(this.caller); + const username = await this.caller.getUsername(); validateVariableName(name); validateContentSize(value); const { userVars } = getCollections(); - const username = this.caller.username; const query = { $set: { value, @@ -415,9 +407,7 @@ CloudVariables.setUserVariable = async function (name, value) { */ CloudVariables.deleteUserVariable = async function (name) { const { userVars } = getCollections(); - const username = this.caller.username; - - ensureLoggedIn(this.caller); + const username = await this.caller.getUsername(); await userVars.deleteOne({ name: name, owner: username }); delete (userListeners[username] || {})[name]; }; @@ -427,8 +417,8 @@ CloudVariables._getListenBucket = function (name) { if (!bucket) bucket = globalListeners[name] = {}; return bucket; }; -CloudVariables._getUserListenBucket = function (name) { - const user = this.caller.username; +CloudVariables._getUserListenBucket = async function (name) { + const user = await this.caller.getUsername(); let userBucket = userListeners[user]; if (!userBucket) userBucket = userListeners[user] = {}; @@ -479,7 +469,7 @@ CloudVariables.listenToUserVariable = async function ( duration = 60 * 60 * 1000, ) { await this.getUserVariable(name); // ensure we can get the value - const bucket = this._getUserListenBucket(name); + const bucket = await this._getUserListenBucket(name); bucket[this.socket.clientId] = [this.socket, msgType, +new Date() + duration]; }; diff --git a/src/procedures/connect-n/connect-n.js b/src/procedures/connect-n/connect-n.js index 1f3edf77..7cfbfb41 100644 --- a/src/procedures/connect-n/connect-n.js +++ b/src/procedures/connect-n/connect-n.js @@ -43,7 +43,7 @@ ConnectN.prototype.newGame = function (row, column, numDotsToConnect) { ); logger.info( - this.caller.roleId + + this.caller.clientId + " is clearing board and creating a new one with size: ", this._state.numRow, ", ", @@ -70,11 +70,11 @@ ConnectN.prototype.newGame = function (row, column, numDotsToConnect) { * @param {Integer} column The given column at which to move */ ConnectN.prototype.play = async function (row, column) { - const { projectId, roleId } = this.caller; if (this._state._winner) { throw new Error("The game is over!"); } + const roleId = await this.caller.getRoleId(); if (this._state.lastMove === roleId) { throw new Error("Trying to play twice in a row!"); } @@ -106,7 +106,10 @@ ConnectN.prototype.play = async function (row, column) { ); this._state._winner = winnerId; - const roleNames = await Utils.getRoleNames(projectId, [roleId, winnerId]); + const roleNames = await Utils.getRoleNames( + await this.caller.getRoomState(), + [roleId, winnerId], + ); // Send the play message to everyone! const [roleName, winnerRoleName] = roleNames; const msgContents = { diff --git a/src/procedures/daily-word-guess/daily-word-guess.js b/src/procedures/daily-word-guess/daily-word-guess.js index 79178a89..fb8562a6 100644 --- a/src/procedures/daily-word-guess/daily-word-guess.js +++ b/src/procedures/daily-word-guess/daily-word-guess.js @@ -138,7 +138,8 @@ DailyWordGuess._getTodaysDate = function () { DailyWordGuess._getUserState = async function (caller) { const { games } = GetStorage(); const date = DailyWordGuess._getTodaysDate(); - const query = { date, caller: caller.username || caller.clientId }; + const callerId = (await caller.getUsername(true)) || caller.clientId; + const query = { date, caller: callerId }; const initialState = Object.assign({ tries: 6 }, query); const update = { "$setOnInsert": initialState }; const options = { upsert: true }; @@ -156,7 +157,8 @@ DailyWordGuess._setUserState = async function (caller, newState) { const { games } = GetStorage(); const date = DailyWordGuess._getTodaysDate(); - const query = { date, caller: caller.username || caller.clientId }; + const callerId = (await caller.getUsername(true)) || caller.clientId; + const query = { date, caller: callerId }; const update = { "$set": newState, "$setOnInsert": query }; const options = { upsert: true }; await games.updateOne(query, update, options); diff --git a/src/procedures/dev/dev.js b/src/procedures/dev/dev.js index e8c2553e..ec4f0352 100644 --- a/src/procedures/dev/dev.js +++ b/src/procedures/dev/dev.js @@ -47,7 +47,7 @@ dev.sendMessage = function (address, messageType, contents) { * @param{Object} contents */ dev.sendMessageToRole = async function (role, messageType, contents) { - const room = await NetsBloxCloud.getRoomState(this.caller.projectId); + const room = await this.caller.getRoomState(); const roleEntry = Object.entries(room.roles).find(([id, roleData]) => roleData.name === role ); @@ -127,8 +127,12 @@ dev.detectAbort = function () { /** * Return the caller info as detected by the server. */ -dev.callerInfo = function () { - return _.omit(this.caller, ["response", "request", "socket", "apiKey"]); +dev.callerInfo = async function () { + const callerInfo = { + state: await this.caller.getClientState(), + username: await this.caller.getUsername(), + }; + return callerInfo; }; /** diff --git a/src/procedures/n-player/n-player.js b/src/procedures/n-player/n-player.js index fe7ab741..b47391cc 100644 --- a/src/procedures/n-player/n-player.js +++ b/src/procedures/n-player/n-player.js @@ -33,7 +33,9 @@ const NPlayer = function () { * @returns {Boolean} ``true`` on successful start */ NPlayer.prototype.start = async function () { - this._state.players = await Utils.getRoleIds(this.caller.projectId); + this._state.players = Utils.getRoleIds( + await this.caller.getRoomState(), + ); this._state.active = this._state.players .findIndex((roleId) => roleId === this.socket.roleId); @@ -84,13 +86,13 @@ NPlayer.prototype.getPrevious = function () { * Get the player who will be active next. * @returns {String} role id of the next player, or empty string if there are no players */ -NPlayer.prototype.getNext = function () { +NPlayer.prototype.getNext = async function () { if (this._state.players.length == 0) { return ""; } else { const index = (this._state.active + 1) % this._state.players.length; const nextId = this._state.players[index]; - return Utils.getRoleName(this.caller.projectId, nextId); + return Utils.getRoleName(await this.caller.getRoomState(), nextId); } }; diff --git a/src/procedures/phone-iot/phone-iot.js b/src/procedures/phone-iot/phone-iot.js index 2b74ac60..760d46c5 100644 --- a/src/procedures/phone-iot/phone-iot.js +++ b/src/procedures/phone-iot/phone-iot.js @@ -159,7 +159,7 @@ types.defineType({ } if (!device) throw Error("Device not found."); - await acl.ensureAuthorized(ctx.caller.username, deviceId); + await acl.ensureAuthorized(await ctx.caller.getUsername(), deviceId); return device; }, }); @@ -180,12 +180,6 @@ PhoneIoT.serviceName = "PhoneIoT"; // keeps a dictionary of device objects keyed by mac_addr PhoneIoT.prototype._devices = {}; -PhoneIoT.prototype._ensureLoggedIn = function () { - if (this.caller.username !== undefined) { - throw new Error("Login required."); - } -}; - // fetch the device and updates its address. creates one if necessary PhoneIoT.prototype._getOrCreateDevice = function ( mac_addr, @@ -239,7 +233,7 @@ PhoneIoT.prototype._getRegistered = function () { PhoneIoT.prototype._getDevices = async function () { const availableDevices = Object.keys(this._devices); let devices = await acl.authorizedRobots( - this.caller.username, + await this.caller.getUsername(), availableDevices, ); return devices; diff --git a/src/procedures/public-roles/public-roles.js b/src/procedures/public-roles/public-roles.js index 59a742d1..e5b7d963 100644 --- a/src/procedures/public-roles/public-roles.js +++ b/src/procedures/public-roles/public-roles.js @@ -8,8 +8,6 @@ */ "use strict"; -const logger = require("../utils/logger")("public-roles"); -const NetsBloxCloud = require("../../cloud-client"); const PublicRoles = {}; /** @@ -18,16 +16,7 @@ const PublicRoles = {}; * @returns {String} the public role ID */ PublicRoles.getPublicRoleId = async function () { - const { projectId, roleId, clientId } = this.caller; - if (!projectId) { // TODO: extend the API to record if it is browser or external - throw new Error("Only supported from the NetsBlox browser."); - } - const state = await NetsBloxCloud.getRoomState(projectId); // TODO: update this API? - const roleName = state.roles[roleId]?.name; - if (!roleName) { - throw new Error("Could not find role"); - } - return `${roleName}@${state.name}@${state.owner}`; + return await this.caller.getAddress(); }; /** diff --git a/src/procedures/roboscape/roboscape.js b/src/procedures/roboscape/roboscape.js index dfa496af..75cb522b 100644 --- a/src/procedures/roboscape/roboscape.js +++ b/src/procedures/roboscape/roboscape.js @@ -60,14 +60,9 @@ RoboScape.serviceName = "RoboScape"; // keeps a dictionary of robot objects keyed by mac_addr RoboScape.prototype._robots = {}; -RoboScape.prototype._ensureLoggedIn = function () { - if (this.caller.username !== undefined) { - throw new Error("Login required."); - } -}; - RoboScape.prototype._ensureAuthorized = async function (robotId) { - await acl.ensureAuthorized(this.caller.username, robotId); + const username = await this.caller.getUsername(); + await acl.ensureAuthorized(username, robotId); }; // fetch the robot and updates its address. creates one if necessary @@ -188,7 +183,7 @@ RoboScape.prototype.listen = async function (robots) { RoboScape.prototype.getRobots = async function () { const availableRobots = Object.keys(this._robots); let robots = await acl.authorizedRobots( - this.caller.username, + await this.caller.getUsername(), availableRobots, ); return robots; diff --git a/src/procedures/service-creation/service-creation.js b/src/procedures/service-creation/service-creation.js index baf22ace..3c121317 100644 --- a/src/procedures/service-creation/service-creation.js +++ b/src/procedures/service-creation/service-creation.js @@ -35,14 +35,8 @@ const toUpperCamelCase = (name) => { .map((word) => word[0].toUpperCase() + word.slice(1)).join(""); }; -const ensureLoggedIn = function (caller) { - if (!caller.username) { - throw new Error("Login required."); - } -}; - -const isAuthorized = (caller, service) => { - return !service || caller.username === service.author; +const isAuthorized = async (username, service) => { + return !service || username === service.author; }; const fs = require("fs"); @@ -130,11 +124,11 @@ ServiceCreation._cleanDataset = (data) => { * * @param {Array} data 2D list of data */ -ServiceCreation.getCreateFromTableOptions = function (data) { - ensureLoggedIn(this.caller); +ServiceCreation.getCreateFromTableOptions = async function (data) { data = this._cleanDataset(data); validateDataset(data); + const username = await this.caller.getUsername(); const fields = data[0]; const indexField = fields[0]; const dataVariable = getVariableNameForData(fields); @@ -214,7 +208,7 @@ ServiceCreation.getCreateFromTableOptions = function (data) { } return { - help: `Dataset uploaded by ${this.caller.username}`, + help: `Dataset uploaded by ${username}`, RPCs: rpcOptions, }; }; @@ -273,12 +267,12 @@ const getBlockArgs = (blockXml) => { * @param {Object=} options Options (for details, check out :func:`ServiceCreation.getCreateFromTableOptions`) */ ServiceCreation.createServiceFromTable = async function (name, data, options) { - ensureLoggedIn(this.caller); - const defaultOptions = this.getCreateFromTableOptions(data); + const defaultOptions = await this.getCreateFromTableOptions(data); options = resolveOptions(options, defaultOptions); assertValidIdent(name); + const username = await this.caller.getUsername(); const methods = options.RPCs.map((rpc) => { const { name, help = "", code, query, transform, combine, initialValue } = rpc; @@ -320,7 +314,7 @@ ServiceCreation.createServiceFromTable = async function (name, data, options) { name, type: "DataService", help: options.help, - author: this.caller.username, + author: username, createdAt: new Date(), data, methods, @@ -329,7 +323,7 @@ ServiceCreation.createServiceFromTable = async function (name, data, options) { const storage = getDatabase(); const existingService = await storage.findOne({ name }); if ( - !isAuthorized(this.caller, existingService) || !isValidServiceName(name) + !isAuthorized(username, existingService) || !isValidServiceName(name) ) { throw new Error( `Service with name "${name}" already exists. Please choose a different name.`, @@ -356,15 +350,15 @@ ServiceCreation.createServiceFromTable = async function (name, data, options) { * @param {String} name Service name */ ServiceCreation.deleteService = async function (name) { - ensureLoggedIn(this.caller); + const username = await this.caller.getUsername(); const storage = getDatabase(); const existingService = await storage.findOne({ name }); - if (!isAuthorized(this.caller, existingService)) { + if (!isAuthorized(username, existingService)) { throw new Error( `Not allowed to delete ${name}. Only the author can do that!`, ); } - await storage.deleteOne({ name, author: this.caller.username }); + await storage.deleteOne({ name, author: username }); return ServiceEvents.emit(ServiceEvents.DELETE, name).shift(); }; diff --git a/src/procedures/shared-canvas/storage.js b/src/procedures/shared-canvas/storage.js index 6772b2e8..0a4b659a 100644 --- a/src/procedures/shared-canvas/storage.js +++ b/src/procedures/shared-canvas/storage.js @@ -36,9 +36,7 @@ class User { } async function getUser(caller) { - const username = caller?.username; - if (!username) throw Error("You must be logged in to use this feature"); - + const username = await caller.getUsername(); const info = await getUsersDB().findOne({ username }); const lastEdit = info?.lastEdit || 0; const numEdits = info?.numEdits || 0; diff --git a/src/procedures/twenty-questions/twenty-questions.js b/src/procedures/twenty-questions/twenty-questions.js index 550bc119..1c67d157 100644 --- a/src/procedures/twenty-questions/twenty-questions.js +++ b/src/procedures/twenty-questions/twenty-questions.js @@ -25,14 +25,14 @@ TwentyQuestions.prototype._ensureGameStarted = function (started = true) { }; TwentyQuestions.prototype._ensureCallerIsAnswerer = function () { - if (this.caller.roleId !== this._state.answerer) { + if (this.caller.clientId !== this._state.answerer) { const msg = "You're not the answerer!"; throw new Error(msg); } }; TwentyQuestions.prototype._ensureCallerIsGuesser = function () { - if (this.caller.roleId === this._state.answerer) { + if (this.caller.clientId === this._state.answerer) { const msg = "You're not the guesser!"; throw new Error(msg); } @@ -51,7 +51,7 @@ TwentyQuestions.prototype.start = function (answer) { this._state.started = true; this._state.guessCount = 0; this._state.correctAnswer = answer.toLowerCase(); - this._state.answerer = this.caller.roleId; + this._state.answerer = this.caller.clientId; this.socket.sendMessageToRoom("start"); return true; }; diff --git a/src/procedures/utils/index.js b/src/procedures/utils/index.js index fe6fa306..c1a5ec91 100644 --- a/src/procedures/utils/index.js +++ b/src/procedures/utils/index.js @@ -85,13 +85,8 @@ const encodeQueryData = (query, encode = true) => { return ret.join("&"); }; -const getRoleNames = async (projectId, roleIds) => { +const getRoleNames = (metadata, roleIds) => { roleIds = roleIds.filter((id) => !!id); - const metadata = await cloud.getRoomState(projectId); - if (!metadata) { - throw new Error("Project not found"); - } - try { return roleIds.map((id) => metadata.roles[id].name); } catch (err) { @@ -99,13 +94,12 @@ const getRoleNames = async (projectId, roleIds) => { } }; -const getRoleName = async (projectId, roleId) => { - const [name] = await getRoleNames(projectId, [roleId]); +const getRoleName = (metadata, roleId) => { + const [name] = getRoleNames(projectId, [roleId]); return name; }; -const getRoleIds = async (projectId) => { - const metadata = await cloud.getRoomState(projectId); +const getRoleIds = async (metadata) => { return Object.keys(metadata.roles); }; diff --git a/src/procedures/utils/router-utils.js b/src/procedures/utils/router-utils.js index e1519f2a..a2cf8b79 100644 --- a/src/procedures/utils/router-utils.js +++ b/src/procedures/utils/router-utils.js @@ -28,17 +28,17 @@ async function tryLogin(req, res, next) { // How should we choose which to login with? Maybe use 2 methods? // TODO: add client secret, too const { clientId } = req.query; - if (cookie) { - return setUsernameFromCookie(req, res, next); - } else if (clientId) { + if (clientId) { const { username, state } = await cloud.getClientInfo(clientId); req.username = username; req.clientState = state; + } else if (cookie) { + return setUsernameFromCookie(req, res, next); } next(); } -async function setUsernameFromCookie(req, res, next) { +async function setUsernameFromCookie(req, _res, next) { const cookie = req.cookies.netsblox; const username = await cloud.whoami(cookie); req.username = username; diff --git a/src/remote-client.js b/src/remote-client.js index 6e2ee1f5..4de47603 100644 --- a/src/remote-client.js +++ b/src/remote-client.js @@ -7,18 +7,18 @@ const { const NetsBloxCloud = require("./cloud-client"); class RemoteClient { - constructor(projectId, roleId, clientId, username) { - this.projectId = projectId; - this.clientId = clientId; - this.roleId = roleId; - this.username = username; + constructor(caller) { + this.context = caller; } + /** + * Send a message to the RPC caller. + */ async sendMessage(type, contents = {}) { + const state = await this.context.getClientState(); return NetsBloxCloud.sendMessage( new SendMessageToClient( - this.projectId, - this.roleId, + state, this.clientId, type, contents, @@ -26,19 +26,34 @@ class RemoteClient { ); } + /** + * Send a message to a given NetsBlox address. + */ async sendMessageTo(address, type, contents = {}) { return NetsBloxCloud.sendMessage( new SendMessage(address, type, contents), ); } + /** + * Send a message to a role (relative to the caller). + * + * Rejects if the caller is not a NetsBlox browser. + */ async sendMessageToRole(roleId, type, contents = {}) { + const projectId = await this.context.getProjectId(); return NetsBloxCloud.sendMessage( new SendMessageToRole(this.projectId, roleId, type, contents), ); } + /** + * Send a message to all roles in the RPC caller's room. + * + * Rejects if the caller is not a NetsBlox browser. + */ async sendMessageToRoom(type, contents = {}) { + const projectId = await this.context.getProjectId(); return NetsBloxCloud.sendMessage( new SendMessageToRoom(this.projectId, type, contents), ); diff --git a/src/rpc-caller.js b/src/rpc-caller.js new file mode 100644 index 00000000..71c3c9bf --- /dev/null +++ b/src/rpc-caller.js @@ -0,0 +1,199 @@ +/** + * An object representing the caller of an RPC. Provides access to information about the caller + * such as project ID, role ID, username, etc. + */ + +class ExternalCallerNotAllowed extends Error { + constructor(appId) { + super(`RPC must be called from NetsBlox (not ${appId})`); + } +} + +class LoginRequired extends Error { + constructor() { + super("Login Required."); + } +} + +class Unimplemented extends Error { + constructor() { + super("Unimplemented!"); + } +} + +class RpcCallerBase { + constructor(clientId) { + this.clientId = clientId; + } + + /** + * Get the username of the caller. + * + * Throws/rejects if unauthenticated and guest usage is not allowed. + * @param {boolean=} allowGuest + */ + async getUsername(allowGuest = false) { + const { username } = await this.getClientInfo(); + if (!username && !allowGuest) { + throw new LoginRequired(); + } + return username; + } + + /** + * Get the username of the caller. If not logged in, return the client ID. + */ + async getUsernameOrClientId() { + const { username } = await this.getClientInfo(); + return username || this.clientId; + } + + async isLoggedIn() { + const username = await this.getUsername(); + return !!username; + } + + async ensureLoggedIn() { + if (!await this.isLoggedIn()) { + throw new LoginRequired(); + } + } + + /** + * Get the role ID of the caller. + * + * Throws/rejects if caller is using an external client such as PyBlox. + */ + async getRoleId() { + return (await this._getBrowserState()).roleId; + } + + /** + * Get the project ID of the caller. + * + * Throws/rejects if caller is using an external client such as PyBlox. + */ + async getProjectId() { + return (await this._getBrowserState()).projectId; + } + + async getClientState() { + const { state } = await this.getClientInfo(); + return state; + } + + // private methods + async _getBrowserState() { + const state = await this.getClientState(); + const browserState = state?.browser; + + if (!browserState) { // TODO: test this error message + if (state?.external) { + throw new ExternalCallerNotAllowed(state.external.appId); + } else { + // FIXME: "state not found" type of error + throw new ExternalCallerNotAllowed(state.external.appId); + } + } + return browserState; + } + + async getClientInfo() { + if (!this.clientInfo) { + this.clientInfo = Object.freeze(await this._getClientInfo()); + } + + return this.clientInfo; + } + + async getRoomState() { + const { state } = await this.getClientInfo(); + const projectId = state?.browser?.projectId; + + if (!this.roomState && projectId) { + this.roomState = Object.freeze(await this._getRoomState(projectId)); + } + + return this.roomState; + } + + async getAddress() { + const { state } = await this.getClientInfo(); + if (!state) { + throw new Error("Unable to get NetsBlox address"); + } + + if (state.browser) { + const room = await this.getRoomState(); + const { roleId } = state.browser; + const roleName = room.roles[roleId]?.name; + if (!roleName) { + throw new Error("Could not find role"); + } + + return `${roleName}@${room.name}@${room.owner}`; + } else { + return `${state.address} #${state.appId}`; + } + } + + async toSnapshot() { + const clientInfo = await this.getClientInfo(); + const roomState = await this.getRoomState(); + return new SnapshotCaller(this.clientId, clientInfo, roomState); + } + + // the following are abstract methods + async _getClientInfo() { + throw new Unimplemented(); + } + + async _getRoomState(projectId) { + throw new Unimplemented(); + } +} + +class RpcCaller extends RpcCallerBase { + constructor(cloud, clientId) { + super(clientId); + this.cloud = cloud; + } + + async _getClientInfo() { + return await this.cloud.getClientInfo(this.clientId); + } + + async _getRoomState(projectId) { + return await this.cloud.getRoomState(projectId); + } +} + +/** + * A snapshot of an RPC caller with all the fields set (but exposes the same interface) + */ +class CallerSnapshot extends RpcCallerBase { + constructor(clientId, clientInfo, roomState) { + super(clientId); + this.clientInfo = clientInfo; + this.roomState = roomState; + } + + async _getClientInfo() { + return this.clientInfo; + } + + async _getRoomState(projectId) { + return this.roomState; + } + + setUsername(username) { + this.clientInfo.username = username; + } + + static load(data) { + return new CallerSnapshot(data.clientId, data.clientInfo, data.roomState); + } +} + +module.exports = RpcCaller; +module.exports.CallerSnapshot = CallerSnapshot; diff --git a/test/assets/mock-service.js b/test/assets/mock-service.js index 3934bb55..adf1346f 100644 --- a/test/assets/mock-service.js +++ b/test/assets/mock-service.js @@ -8,6 +8,7 @@ const utils = require("./utils"); const Logger = utils.reqSrc("logger"); const getArgsFor = utils.reqSrc("utils").getArgumentsFor; const Constants = utils.reqSrc("constants"); +const { CallerSnapshot } = utils.reqSrc("rpc-caller"); let logger; class MockService { @@ -71,12 +72,17 @@ class MockService { ctx.socket = this.socket; ctx.response = this.response; ctx.request = this.request; - ctx.caller = { - roleId: this.socket.roleId, - clientId: this.socket.uuid, + + const clientInfo = { username: this.socket.username, - projectId: this.socket.projectId || "testProject", + state: { + browser: { + roleId: this.socket.roleId, + projectId: this.socket.projectId || "testProject", + }, + }, }; + ctx.caller = new CallerSnapshot(this.socket.uuid, clientInfo, {}); ctx.apiKey = this.apiKey; const args = JSON.stringify(Array.prototype.slice.call(arguments)); const id = ctx.caller.clientId || "new client"; @@ -125,7 +131,8 @@ class MockRemoteClient { } MockRemoteClient.prototype.sendMessage = - MockRemoteClient.prototype.sendMessageToRoom = + MockRemoteClient.prototype + .sendMessageToRoom = function () { }; diff --git a/test/procedures/autograders/autograders.spec.js b/test/procedures/autograders/autograders.spec.js index ee4cd09f..ee55e54a 100644 --- a/test/procedures/autograders/autograders.spec.js +++ b/test/procedures/autograders/autograders.spec.js @@ -40,7 +40,7 @@ describe(utils.suiteName(__filename), function () { service.socket.username = null; await assert.rejects( () => service.createAutograder({}), - /Login required./, + /Login Required./, ); }); diff --git a/test/procedures/battleship.spec.js b/test/procedures/battleship.spec.js index 3a29fe1c..c96a62d9 100644 --- a/test/procedures/battleship.spec.js +++ b/test/procedures/battleship.spec.js @@ -44,29 +44,29 @@ describe(utils.suiteName(__filename), function () { }); describe("errors", function () { - it("should return false for invalid/missing dir", function () { - var result = battleship.placeShip(); + it("should return false for invalid/missing dir", async function () { + var result = await battleship.placeShip(); assert.notEqual(result.indexOf("Invalid direction"), -1, result); }); - it("should return false for invalid ship names", function () { + it("should return false for invalid ship names", async function () { var result; - result = battleship.placeShip("asdf", 2, 2, "north"); + result = await battleship.placeShip("asdf", 2, 2, "north"); assert.notEqual(result.indexOf("Invalid ship"), -1, result); }); - it("should error for bad row/col", function () { + it("should error for bad row/col", async function () { var result; - result = battleship.placeShip("ashd", 2, 2, "north"); + result = await battleship.placeShip("ashd", 2, 2, "north"); assert.notEqual(result.indexOf("Invalid ship"), -1, result); }); - it("should be 1 indexed", function () { + it("should be 1 indexed", async function () { var result; - result = battleship.placeShip("destroyer", 0, 2, "north"); + result = await battleship.placeShip("destroyer", 0, 2, "north"); assert.notEqual(result.indexOf("Invalid position"), -1, result); }); }); @@ -76,9 +76,9 @@ describe(utils.suiteName(__filename), function () { row = 1, col = 2; - beforeEach(function () { + beforeEach(async function () { battleship.socket.roleId = "test"; - battleship.placeShip("destroyer", row, col, "north"); + await battleship.placeShip("destroyer", row, col, "north"); // Check the spots! board = battleship.unwrap()._state._boards.test; }); @@ -95,17 +95,22 @@ describe(utils.suiteName(__filename), function () { assert.equal(occupied, 3); }); - it("should fail if overlapping boats", function () { + it("should fail if overlapping boats", async function () { var result; - result = battleship.placeShip("battleship", row + 1, col, "north"); + result = await battleship.placeShip( + "battleship", + row + 1, + col, + "north", + ); assert.notEqual(result.indexOf("colliding with another"), -1); }); - it("should move boat if placing same boat twice", function () { + it("should move boat if placing same boat twice", async function () { var oldPosition; - battleship.placeShip("destroyer", row, col + 1, "west"); + await battleship.placeShip("destroyer", row, col + 1, "west"); oldPosition = board._state._ships[row - 1][col - 1]; // correcting for 1 indexing assert.notEqual(oldPosition, "destroyer"); }); diff --git a/test/procedures/cloud-variables.spec.js b/test/procedures/cloud-variables.spec.js index ce81da8b..5819e058 100644 --- a/test/procedures/cloud-variables.spec.js +++ b/test/procedures/cloud-variables.spec.js @@ -78,11 +78,13 @@ describe(utils.suiteName(__filename), function () { .catch((err) => assert(err.message.includes("password"))); }); - it("should not set variables w/ bad password", function () { + it("should not set variables w/ bad password", async function () { const name = newVar(); - return cloudvariables.setVariable(name, "world", "password") - .then(() => cloudvariables.setVariable(name, "asdf")) - .catch((err) => assert(err.message.includes("password"))); + await cloudvariables.setVariable(name, "world", "password"); + await assert.rejects( + cloudvariables.setVariable(name, "asdf"), + /password/, + ); }); it("should not delete variables w/ bad password", function () { diff --git a/test/procedures/service-creation.spec.js b/test/procedures/service-creation.spec.js index afb4a103..c4ec36be 100644 --- a/test/procedures/service-creation.spec.js +++ b/test/procedures/service-creation.spec.js @@ -153,22 +153,22 @@ describe(utils.suiteName(__filename), function () { describe("getCreateFromTableOptions", function () { before(() => service.setRequester("client_1", "brian")); - it("should support # symbol", function () { + it("should support # symbol", async function () { const data = toStringEntries([ ["id", "# counted"], [1, 20], ]); - const options = service.getCreateFromTableOptions(data); + const options = await service.getCreateFromTableOptions(data); const rpcNames = options.RPCs.map((rpc) => rpc.name); assert(rpcNames.includes("get#CountedColumn")); }); - it("should support $ symbol", function () { + it("should support $ symbol", async function () { const data = toStringEntries([ ["id", "$ spent"], [1, 20], ]); - const options = service.getCreateFromTableOptions(data); + const options = await service.getCreateFromTableOptions(data); const rpcNames = options.RPCs.map((rpc) => rpc.name); assert( rpcNames.includes("get$SpentColumn"), @@ -176,12 +176,12 @@ describe(utils.suiteName(__filename), function () { ); }); - it("should support % symbol", function () { + it("should support % symbol", async function () { const data = toStringEntries([ ["id", "% of total"], [1, 20], ]); - const options = service.getCreateFromTableOptions(data); + const options = await service.getCreateFromTableOptions(data); const rpcNames = options.RPCs.map((rpc) => rpc.name); assert( rpcNames.includes("get%OfTotalColumn"), @@ -189,12 +189,12 @@ describe(utils.suiteName(__filename), function () { ); }); - it("should support accented characters", function () { + it("should support accented characters", async function () { const data = toStringEntries([ ["id", "érdös number"], [1, 20], ]); - const options = service.getCreateFromTableOptions(data); + const options = await service.getCreateFromTableOptions(data); const rpcNames = options.RPCs.map((rpc) => rpc.name); assert( rpcNames.includes("getÉrdösNumberColumn"), diff --git a/test/procedures/twentyquestions.spec.js b/test/procedures/twentyquestions.spec.js index ca107a50..276cb282 100644 --- a/test/procedures/twentyquestions.spec.js +++ b/test/procedures/twentyquestions.spec.js @@ -81,9 +81,9 @@ describe(utils.suiteName(__filename), function () { describe("for guesser", function () { beforeEach(function () { - twentyquestions.socket.roleId = "answerer"; + twentyquestions.socket.uuid = "answerer"; twentyquestions.start("books"); - twentyquestions.socket.roleId = "guesser"; + twentyquestions.socket.uuid = "guesser"; }); it("should return an error when invalid answer", function () { @@ -101,15 +101,15 @@ describe(utils.suiteName(__filename), function () { describe("game started", function () { function switchRole() { - if (twentyquestions.socket.roleId === "guesser") { - twentyquestions.socket.roleId = "answerer"; + if (twentyquestions.socket.uuid === "guesser") { + twentyquestions.socket.uuid = "answerer"; } else { - twentyquestions.socket.roleId = "guesser"; + twentyquestions.socket.uuid = "guesser"; } } beforeEach(function () { twentyquestions.restart(); - twentyquestions.socket.roleId = "answerer"; + twentyquestions.socket.uuid = "answerer"; twentyquestions.start("book shelf"); }); diff --git a/test/rpc-caller.spec.js b/test/rpc-caller.spec.js new file mode 100644 index 00000000..2ee64b64 --- /dev/null +++ b/test/rpc-caller.spec.js @@ -0,0 +1,147 @@ +const utils = require("./assets/utils"); + +describe(utils.suiteName(__filename), function () { + const assert = require("assert"); + const RpcCaller = utils.reqSrc("rpc-caller"); + + describe("RpcCaller", function () { + it("should cache username", async function () { + const clientId = "someClientId"; + let count = 0; + const username = "someUsername"; + const cloud = { + getClientInfo() { + count++; + assert.equal(count, 1); + return { + username, + }; + }, + }; + const caller = new RpcCaller(cloud, clientId); + assert.equal(await caller.getUsername(), username); + assert.equal(await caller.getUsername(), username); + assert.equal(count, 1); + }); + + it("should cache project ID", async function () { + let count = 0; + const clientId = "someClientId"; + const projectId = "someProjectID"; + const roleId = "someRoleID"; + const cloud = { + getClientInfo() { + count++; + assert.equal(count, 1); + return { + state: { browser: { projectId, roleId } }, + }; + }, + }; + const caller = new RpcCaller(cloud, clientId); + assert.equal(await caller.getProjectId(), projectId); + assert.equal(await caller.getProjectId(), projectId); + assert.equal(count, 1); + }); + + it("should cache role ID", async function () { + const clientId = "someClientId"; + let count = 0; + const projectId = "someProjectID"; + const roleId = "someRoleID"; + const cloud = { + getClientInfo() { + count++; + assert.equal(count, 1); + return { + state: { browser: { projectId, roleId } }, + }; + }, + }; + const caller = new RpcCaller(cloud, clientId); + assert.equal(await caller.getRoleId(), roleId); + assert.equal(await caller.getRoleId(), roleId); + assert.equal(count, 1); + }); + + it("should cache room state", async function () { + const clientId = "someClientId"; + let count = 0; + const projectId = "someProjectID"; + const roleId = "someRoleID"; + const name = "someProjectName"; + const roomState = { + name, + id: projectId, + }; + + const cloud = { + getRoomState(id) { + count++; + assert.equal(count, 1); + assert.equal(id, projectId); + return roomState; + }, + getClientInfo() { + return { + state: { browser: { projectId, roleId } }, + }; + }, + }; + const caller = new RpcCaller(cloud, clientId); + assert.deepEqual(await caller.getRoomState(), roomState); + assert.deepEqual(await caller.getRoomState(), roomState); + assert.equal(count, 1); + }); + }); + + describe("CallerSnapshot", function () { + const { CallerSnapshot } = utils.reqSrc("rpc-caller"); + + it("should change username w/ setUsername", async function () { + const username = "newUsername"; + const clientId = "_netsblox" + Date.now(); + const clientInfo = { username: "username" }; + const caller = new CallerSnapshot(clientId, clientInfo, {}); + caller.setUsername(username); + assert.equal(await caller.getUsername(), username); + }); + + it("should load from data", async function () { + const clientId = "_netsblox" + Date.now(); + const clientInfo = { + state: { + browser: { + projectId: "someProjectID", + roleId: "someRoleID", + }, + }, + username: "username", + }; + const roomState = { + name: "someProjectName", + }; + + const caller = new CallerSnapshot(clientId, clientInfo, roomState); + const data = JSON.parse(JSON.stringify(caller)); + + const loadedCaller = CallerSnapshot.load(data); + assert.equal(caller.clientId, clientId); + assert.equal(await caller.getUsername(), clientInfo.username); + assert.equal( + await caller.getProjectId(), + clientInfo.state.browser.projectId, + ); + assert.equal( + await caller.getRoleId(), + clientInfo.state.browser.roleId, + ); + + assert.deepEqual( + await caller.getRoomState(), + roomState, + ); + }); + }); + // TODO: test setUsername +});