Repository: firebase/firebase-tools
Pull request #942 (2018-10-10 23:29:05)

Add precheck for base64 strings in auth:import
<!--

Thank you for contributing to the Firebase community! Please fill out the form below.

Run the linter and test suite
==============================
Run `npm test` to make sure your changes compile properly and the tests all pass on your local machine. We've hooked up this repo with continuous integration to double check those things for you. 

-->


### Description

<!-- Are you fixing a bug? Implementing a new feature? Make sure we have the context around your change. Link to other relevant issues or pull requests. -->

Add basic client-side type checks for base64 fields in auth:import. This ensures that auth:import won't throw an opaque

`Error: 400, Invalid value at 'users[66].password' (TYPE_BYTES), Base64 decoding failed for "false"
`

from the server but instead throws a

`Error: Line 67 (67, testuser67@email.com, , false, , , , , , , , , , , , , , , , , , , , , , , ) has invalid data format: Password hash should be base64 encoded.`

error after validation. Furthermore, the invalid request won't even be sent to the server.

Also exposed error messages for auth:import for this fix. Previously they are shown as "Error: An unexpected error has occurred." regardless of the error.

### Scenarios Tested

<!-- Write a list of all the user journeys and edge cases you've tested. Instructions for manual testing can be found at https://github.com/firebase/firebase-tools/blob/master/.github/CONTRIBUTING.md#development-setup -->
Tested for .json and .csv files, for multiple users, for blank fields, and different base64 hashes.



Patch:
diff --git a/.travis.yml b/.travis.yml
index f755a6e7a..96210ddd9 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -1,6 +1,5 @@
 language: node_js
 node_js:
-- '5.10'
 - '6'
 - '8'
 - stable
diff --git a/changelog.txt b/changelog.txt
index e69de29bb..e0f5bfbef 100644
--- a/changelog.txt
+++ b/changelog.txt
@@ -0,0 +1,5 @@
+feature - `firebase init` now respects a `--project <project_id>` flag for quick init on a specific project.
+feature - `firebase init` now creates a boilerplate `.gitignore` file.
+feature - `firebase deploy` now accepts a `--force` flag to automatically confirm deletion of missing functions.
+fixed - Removed dependency on coarse project permissions in more places.
+fixed - `firebase init` project selection prompt is now alphabetized.
diff --git a/commands/auth-import.js b/commands/auth-import.js
index 127dbaf9d..fc2de4e00 100644
--- a/commands/auth-import.js
+++ b/commands/auth-import.js
@@ -77,6 +77,11 @@ module.exports = new Command("auth:import [dataFile]")
                 return newStr === "" ? undefined : newStr;
               })
             );
+            if (user.error) {
+              return reject(
+                "Line " + counter + " (" + line + ") has invalid data format: " + user.error
+              );
+            }
             currentBatch.push(user);
             if (currentBatch.length === MAX_BATCH_SIZE) {
               batches.push(currentBatch);
@@ -113,16 +118,21 @@ module.exports = new Command("auth:import [dataFile]")
           });
         inStream.pipe(parser);
       }
-    }).then(function(userListArr) {
-      logger.debug(
-        "Preparing to import",
-        counter,
-        "user records in",
-        userListArr.length,
-        "batches."
-      );
-      if (userListArr.length) {
-        return serialImportUsers(projectId, hashOptions, userListArr, 0);
+    }).then(
+      function(userListArr) {
+        logger.debug(
+          "Preparing to import",
+          counter,
+          "user records in",
+          userListArr.length,
+          "batches."
+        );
+        if (userListArr.length) {
+          return serialImportUsers(projectId, hashOptions, userListArr, 0);
+        }
+      },
+      function(error) {
+        return utils.reject(error, { exit: 1 });
       }
-    });
+    );
   });
diff --git a/commands/deploy.js b/commands/deploy.js
index ac71c67d6..119896044 100644
--- a/commands/deploy.js
+++ b/commands/deploy.js
@@ -42,6 +42,10 @@ module.exports = new Command("deploy")
   .description("deploy code and assets to your Firebase project")
   .option("-p, --public <path>", "override the Hosting public directory specified in firebase.json")
   .option("-m, --message <message>", "an optional message describing this deploy")
+  .option(
+    "-f, --force",
+    "delete Cloud Functions missing from the current working directory without confirmation"
+  )
   .option(
     "--only <targets>",
     'only deploy to specified, comma-separated targets (e.g. "hosting,storage"). For functions, ' +
diff --git a/commands/init.js b/commands/init.js
index ec58f19bb..520179728 100644
--- a/commands/init.js
+++ b/commands/init.js
@@ -7,13 +7,16 @@ var path = require("path");
 
 var Command = require("../lib/command");
 var Config = require("../lib/config");
+var fsutils = require("../lib/fsutils");
 var init = require("../lib/init");
 var logger = require("../lib/logger");
 var prompt = require("../lib/prompt");
 var requireAuth = require("../lib/requireAuth");
 var utils = require("../lib/utils");
 
-var BANNER_TEXT = fs.readFileSync(__dirname + "/../templates/banner.txt", "utf8");
+var TEMPLATE_ROOT = path.resolve(__dirname, "../templates/");
+var BANNER_TEXT = fs.readFileSync(path.join(TEMPLATE_ROOT, "banner.txt"), "utf8");
+var GITIGNORE_TEMPLATE = fs.readFileSync(path.join(TEMPLATE_ROOT, "_gitignore"), "utf8");
 
 var _isOutside = function(from, to) {
   return path.relative(from, to).match(/^\.\./);
@@ -155,6 +158,10 @@ module.exports = new Command("init [feature]")
         config.writeProjectFile("firebase.json", setup.config);
         utils.logBullet("Writing project information to " + clc.bold(".firebaserc") + "...");
         config.writeProjectFile(".firebaserc", setup.rcfile);
+        if (!fsutils.fileExistsSync(config.path(".gitignore"))) {
+          utils.logBullet("Writing gitignore file to " + clc.bold(".gitignore") + "...");
+          config.writeProjectFile(".gitignore", GITIGNORE_TEMPLATE);
+        }
         logger.info();
         utils.logSuccess("Firebase initialization complete!");
 
diff --git a/lib/accountImporter.js b/lib/accountImporter.js
index 368897663..3c89f54d2 100644
--- a/lib/accountImporter.js
+++ b/lib/accountImporter.js
@@ -26,6 +26,16 @@ var ALLOWED_JSON_KEYS_RENAMING = {
 var ALLOWED_PROVIDER_USER_INFO_KEYS = ["providerId", "rawId", "email", "displayName", "photoUrl"];
 var ALLOWED_PROVIDER_IDS = ["google.com", "facebook.com", "twitter.com", "github.com"];
 
+var _isValidBase64 = function(str) {
+  var expected = Buffer.from(str, "base64").toString("base64");
+  // Buffer automatically pads with '=' character,
+  // but input string might not have padding.
+  if (str.length < expected.length && str.slice(-1) !== "=") {
+    str += "=".repeat(expected.length - str.length);
+  }
+  return expected === str;
+};
+
 var _toWebSafeBase64 = function(data) {
   return data
     .toString("base64")
@@ -115,6 +125,17 @@ var transArrayToUser = function(arr) {
   _addProviderUserInfo(user, "facebook.com", arr.slice(11, 15));
   _addProviderUserInfo(user, "twitter.com", arr.slice(15, 19));
   _addProviderUserInfo(user, "github.com", arr.slice(19, 23));
+
+  if (user.passwordHash && !_isValidBase64(user.passwordHash)) {
+    return {
+      error: "Password hash should be base64 encoded.",
+    };
+  }
+  if (user.salt && !_isValidBase64(user.salt)) {
+    return {
+      error: "Password salt should be base64 encoded.",
+    };
+  }
   return user;
 };
 
@@ -251,6 +272,17 @@ var validateUserJson = function(userJson) {
       }
     }
   }
+  var badFormat = JSON.stringify(userJson, null, 2) + " has invalid data format: ";
+  if (userJson.passwordHash && !_isValidBase64(userJson.passwordHash)) {
+    return {
+      error: badFormat + "Password hash should be base64 encoded.",
+    };
+  }
+  if (userJson.salt && !_isValidBase64(userJson.salt)) {
+    return {
+      error: badFormat + "Password salt should be base64 encoded.",
+    };
+  }
   return {};
 };
 
diff --git a/lib/deploy/functions/release.js b/lib/deploy/functions/release.js
index a6e352722..f56a4dd0a 100644
--- a/lib/deploy/functions/release.js
+++ b/lib/deploy/functions/release.js
@@ -11,10 +11,7 @@ var utils = require("../../utils");
 var helper = require("../../functionsDeployHelper");
 var prompt = require("../../prompt");
 
-var CLI_DEPLOYMENT_TOOL = "cli-firebase";
-var CLI_DEPLOYMENT_LABELS = {
-  "deployment-tool": CLI_DEPLOYMENT_TOOL,
-};
+var deploymentTool = require("../../deploymentTool");
 var timings = {};
 var deployments = [];
 var failedDeployments = 0;
@@ -171,7 +168,7 @@ module.exports = function(context, options, payload) {
                 functionName: functionName,
                 entryPoint: functionInfo.entryPoint,
                 trigger: functionTrigger,
-                labels: _.assign({}, CLI_DEPLOYMENT_LABELS, functionInfo.labels),
+                labels: _.assign({}, deploymentTool.labels, functionInfo.labels),
                 sourceUploadUrl: sourceUrl,
                 runtime: runtime,
                 availableMemoryMb: functionInfo.availableMemoryMb,
@@ -230,7 +227,7 @@ module.exports = function(context, options, payload) {
               functionName: functionName,
               trigger: functionTrigger,
               sourceUploadUrl: sourceUrl,
-              labels: _.assign({}, CLI_DEPLOYMENT_LABELS, functionInfo.labels),
+              labels: _.assign({}, deploymentTool.labels, functionInfo.labels),
               availableMemoryMb: functionInfo.availableMemoryMb,
               timeout: functionInfo.timeout,
             };
@@ -269,7 +266,7 @@ module.exports = function(context, options, payload) {
               functionInfo.sourceArchiveUrl === legacySourceUrlTwo
             );
           }
-          return functionInfo.labels["deployment-tool"] === CLI_DEPLOYMENT_TOOL;
+          return deploymentTool.check(functionInfo.labels);
         }) // only delete functions uploaded via firebase-tools
         .map(pluckName)
         .difference(uploadedNames)
@@ -283,7 +280,7 @@ module.exports = function(context, options, payload) {
         return "\t" + helper.getFunctionLabel(func);
       }).join("\n");
 
-      if (options.nonInteractive) {
+      if (options.nonInteractive && !options.force) {
         var deleteCommands = _.map(functionsToDelete, function(func) {
           return (
             "\tfirebase functions:delete " +
@@ -299,54 +296,58 @@ module.exports = function(context, options, payload) {
             "\n\nAborting because deletion cannot proceed in non-interactive mode. To fix, manually delete the functions by running:\n" +
             clc.bold(deleteCommands)
         );
+      } else if (!options.force) {
+        logger.info(
+          "\nThe following functions are found in your project but do not exist in your local source code:\n" +
+            deleteList +
+            "\n\nIf you are renaming a function or changing its region, it is recommended that you create the new " +
+            "function first before deleting the old one to prevent event loss. For more info, visit " +
+            clc.underline(
+              "https://firebase.google.com/docs/functions/manage-functions#modify" + "\n"
+            )
+        );
       }
 
-      logger.info(
-        "\nThe following functions are found in your project but do not exist in your local source code:\n" +
-          deleteList +
-          "\n\nIf you are renaming a function or changing its region, it is recommended that you create the new " +
-          "function first before deleting the old one to prevent event loss. For more info, visit " +
-          clc.underline("https://firebase.google.com/docs/functions/manage-functions#modify" + "\n")
-      );
+      const next = options.force
+        ? Promise.resolve(true)
+        : prompt.once({
+            type: "confirm",
+            name: "confirm",
+            default: false,
+            message:
+              "Would you like to proceed with deletion? Selecting no will continue the rest of the deployments.",
+          });
 
-      return prompt
-        .once({
-          type: "confirm",
-          name: "confirm",
-          default: false,
-          message:
-            "Would you like to proceed with deletion? Selecting no will continue the rest of the deployments.",
-        })
-        .then(function(proceed) {
-          if (!proceed) {
-            if (deployments.length !== 0) {
-              utils.logBullet(clc.bold.cyan("functions: ") + "continuing with other deployments.");
-            }
-            return;
+      next.then(function(proceed) {
+        if (!proceed) {
+          if (deployments.length !== 0) {
+            utils.logBullet(clc.bold.cyan("functions: ") + "continuing with other deployments.");
           }
-          functionsToDelete.forEach(function(name) {
-            var functionName = helper.getFunctionName(name);
-            var region = helper.getRegion(name);
+          return;
+        }
+        functionsToDelete.forEach(function(name) {
+          var functionName = helper.getFunctionName(name);
+          var region = helper.getRegion(name);
 
-            utils.logBullet(
-              clc.bold.cyan("functions: ") +
-                "deleting function " +
-                clc.bold(helper.getFunctionLabel(name)) +
-                "..."
-            );
-            _startTimer(name, "delete");
-            deployments.push({
-              name: name,
-              retryFunction: function() {
-                return gcp.cloudfunctions.delete({
-                  projectId: projectId,
-                  region: region,
-                  functionName: functionName,
-                });
-              },
-            });
+          utils.logBullet(
+            clc.bold.cyan("functions: ") +
+              "deleting function " +
+              clc.bold(helper.getFunctionLabel(name)) +
+              "..."
+          );
+          _startTimer(name, "delete");
+          deployments.push({
+            name: name,
+            retryFunction: function() {
+              return gcp.cloudfunctions.delete({
+                projectId: projectId,
+                region: region,
+                functionName: functionName,
+              });
+            },
           });
         });
+      });
     })
     .then(function() {
       return utils.promiseAllSettled(
diff --git a/lib/deploy/hosting/prepare.js b/lib/deploy/hosting/prepare.js
index 7cd551241..3b8c8a992 100644
--- a/lib/deploy/hosting/prepare.js
+++ b/lib/deploy/hosting/prepare.js
@@ -9,6 +9,7 @@ const fsutils = require("../../fsutils");
 const resolveProjectPath = require("../../resolveProjectPath");
 const utils = require("../../utils");
 const normalizedHostingConfigs = require("../../hosting/normalizedHostingConfigs");
+const deploymentTool = require("../../deploymentTool");
 
 module.exports = function(context, options) {
   // Allow the public directory to be overridden by the --public flag
@@ -74,6 +75,7 @@ module.exports = function(context, options) {
           auth: true,
           data: {
             config: convertConfig(cfg),
+            labels: deploymentTool.labels,
           },
         })
         .then(function(result) {
diff --git a/lib/deploymentTool.js b/lib/deploymentTool.js
new file mode 100644
index 000000000..ca090f8c3
--- /dev/null
+++ b/lib/deploymentTool.js
@@ -0,0 +1,24 @@
+const BASE = "cli-firebase";
+
+function _value() {
+  if (!process.env.FIREBASE_DEPLOY_AGENT) {
+    return BASE;
+  }
+
+  return [BASE, process.env.FIREBASE_DEPLOY_AGENT].join("--");
+}
+
+module.exports = {
+  base: BASE,
+  get value() {
+    return _value();
+  },
+  get labels() {
+    return {
+      "deployment-tool": _value(),
+    };
+  },
+  check: function(labels) {
+    return labels && labels["deployment-tool"] && labels["deployment-tool"].indexOf(BASE) === 0;
+  },
+};
diff --git a/lib/firebaseApi.js b/lib/firebaseApi.js
new file mode 100644
index 000000000..cff6d1d63
--- /dev/null
+++ b/lib/firebaseApi.js
@@ -0,0 +1,35 @@
+const api = require("./api");
+
+const API_VERSION = "v1beta1";
+
+function _list(nextPageToken, projects) {
+  projects = projects || [];
+
+  let path = `/${API_VERSION}/projects?page_size=100`;
+  if (nextPageToken) {
+    path += `&page_token=${nextPageToken}`;
+  }
+
+  return api
+    .request("GET", path, {
+      auth: true,
+      origin: api.firebaseApiOrigin,
+    })
+    .then(response => {
+      projects = projects.concat(response.body.results);
+      if (response.body.nextPageToken) {
+        return _list(response.body.nextPageToken, projects);
+      }
+      return projects;
+    });
+}
+
+exports.listProjects = () => _list();
+
+exports.getProject = projectId =>
+  api
+    .request("GET", `/${API_VERSION}/projects/${projectId}`, {
+      auth: true,
+      origin: api.firebaseApiOrigin,
+    })
+    .then(response => response.body);
diff --git a/lib/getInstanceId.js b/lib/getInstanceId.js
index 110834295..14dd31d02 100644
--- a/lib/getInstanceId.js
+++ b/lib/getInstanceId.js
@@ -1,7 +1,7 @@
 "use strict";
 
 var _ = require("lodash");
-var api = require("./api");
+var firebaseApi = require("./firebaseApi");
 var logger = require("./logger");
 
 /**
@@ -11,21 +11,16 @@ var logger = require("./logger");
  * @returns {Promise<String>} The instance ID
  */
 module.exports = function(options) {
-  return api
-    .request("GET", `/v1beta1/projects/${options.project}`, {
-      auth: true,
-      origin: api.firebaseApiOrigin,
-    })
-    .then(function(response) {
-      if (!_.has(response, "body.resources.realtimeDatabaseInstance")) {
-        logger.debug(
-          "[WARNING] Unable to fetch default resources. Falling back to project id (" +
-            options.project +
-            ")"
-        );
-        return options.project;
-      }
+  return firebaseApi.getProject(options.project).then(function(project) {
+    if (!_.has(project, "resources.realtimeDatabaseInstance")) {
+      logger.debug(
+        "[WARNING] Unable to fetch default resources. Falling back to project id (" +
+          options.project +
+          ")"
+      );
+      return options.project;
+    }
 
-      return _.get(response, "body.resources.realtimeDatabaseInstance");
-    });
+    return _.get(project, "resources.realtimeDatabaseInstance");
+  });
 };
diff --git a/lib/init/features/functions/typescript.js b/lib/init/features/functions/typescript.js
index 12e782bdd..0e4ea60fa 100644
--- a/lib/init/features/functions/typescript.js
+++ b/lib/init/features/functions/typescript.js
@@ -19,6 +19,7 @@ var PACKAGE_NO_LINTING_TEMPLATE = fs.readFileSync(
 var TSLINT_TEMPLATE = fs.readFileSync(path.join(TEMPLATE_ROOT, "tslint.json"), "utf8");
 var TSCONFIG_TEMPLATE = fs.readFileSync(path.join(TEMPLATE_ROOT, "tsconfig.json"), "utf8");
 var INDEX_TEMPLATE = fs.readFileSync(path.join(TEMPLATE_ROOT, "index.ts"), "utf8");
+var GITIGNORE_TEMPLATE = fs.readFileSync(path.join(TEMPLATE_ROOT, "_gitignore"), "utf8");
 
 module.exports = function(setup, config) {
   return prompt(setup.functions, [
@@ -50,6 +51,9 @@ module.exports = function(setup, config) {
     .then(function() {
       return config.askWriteProjectFile("functions/src/index.ts", INDEX_TEMPLATE);
     })
+    .then(function() {
+      return config.askWriteProjectFile("functions/.gitignore", GITIGNORE_TEMPLATE);
+    })
     .then(function() {
       return npmDependencies.askInstallDependencies(setup, config);
     });
diff --git a/lib/init/features/project.js b/lib/init/features/project.js
index aa0749823..9f588db14 100644
--- a/lib/init/features/project.js
+++ b/lib/init/features/project.js
@@ -3,7 +3,7 @@
 var clc = require("cli-color");
 
 var _ = require("lodash");
-var api = require("../../api");
+var firebaseApi = require("../../firebaseApi");
 var prompt = require("../../prompt");
 var logger = require("../../logger");
 var utils = require("../../utils");
@@ -25,18 +25,15 @@ function _getProject(options) {
   // The user passed in a --project flag directly, so no need to
   // load all projects.
   if (options.project) {
-    return api
-      .request("GET", "/v1beta1/projects/" + options.project, {
-        auth: true,
-        origin: api.firebaseApiOrigin,
-      })
-      .then(function(response) {
-        var id = response.body.projectId;
-        var name = response.body.displayName;
+    return firebaseApi
+      .getProject(options.project)
+      .then(function(project) {
+        var id = project.projectId;
+        var name = project.displayName;
         return {
           id: id,
           label: id + " (" + name + ")",
-          instance: response.body.resources.realtimeDatabaseInstance,
+          instance: _.get(project, "resources.realtimeDatabaseInstance"),
         };
       })
       .catch(function(e) {
@@ -45,11 +42,11 @@ function _getProject(options) {
   }
 
   // Load all projects and prompt the user to choose.
-  return api.getProjects().then(function(projects) {
-    var choices = _.map(projects, function(info, projectId) {
+  return firebaseApi.listProjects().then(function(projects) {
+    var choices = _.map(projects, function(project) {
       return {
-        name: projectId,
-        label: projectId + " (" + info.name + ")",
+        name: project.projectId,
+        label: project.projectId + " (" + project.displayName + ")",
       };
     });
     choices = _.orderBy(choices, ["name"], ["asc"]);
diff --git a/templates/_gitignore b/templates/_gitignore
new file mode 100644
index 000000000..f62685251
--- /dev/null
+++ b/templates/_gitignore
@@ -0,0 +1,65 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+firebase-debug.log*
+
+# Firebase cache
+.firebase/
+
+# Firebase config
+
+# Uncomment this if you'd like others to create their own Firebase project.
+# For a team working on the same Firebase project(s), it is recommended to leave
+# it commented so all members can deploy to the same project(s) in .firebaserc.
+# .firebaserc
+
+# Runtime data
+pids
+*.pid
+*.seed
+*.pid.lock
+
+# Directory for instrumented libs generated by jscoverage/JSCover
+lib-cov
+
+# Coverage directory used by tools like istanbul
+coverage
+
+# nyc test coverage
+.nyc_output
+
+# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
+.grunt
+
+# Bower dependency directory (https://bower.io/)
+bower_components
+
+# node-waf configuration
+.lock-wscript
+
+# Compiled binary addons (http://nodejs.org/api/addons.html)
+build/Release
+
+# Dependency directories
+node_modules/
+
+# Optional npm cache directory
+.npm
+
+# Optional eslint cache
+.eslintcache
+
+# Optional REPL history
+.node_repl_history
+
+# Output of 'npm pack'
+*.tgz
+
+# Yarn Integrity file
+.yarn-integrity
+
+# dotenv environment variables file
+.env
diff --git a/templates/init/functions/typescript/_gitignore b/templates/init/functions/typescript/_gitignore
new file mode 100644
index 000000000..a5985c411
--- /dev/null
+++ b/templates/init/functions/typescript/_gitignore
@@ -0,0 +1,6 @@
+## Compiled JavaScript files
+**/*.js
+**/*.js.map
+
+# Typescript v1 declaration files
+typings/


Test patch:
diff --git a/test/lib/accountImporter.spec.js b/test/lib/accountImporter.spec.js
index 3ae7f67ca..1e5e735dc 100644
--- a/test/lib/accountImporter.spec.js
+++ b/test/lib/accountImporter.spec.js
@@ -8,10 +8,25 @@ var helpers = require("../helpers");
 
 var expect = chai.expect;
 describe("accountImporter", function() {
+  var transArrayToUser = accountImporter.transArrayToUser;
   var validateOptions = accountImporter.validateOptions;
   var validateUserJson = accountImporter.validateUserJson;
   var serialImportUsers = accountImporter.serialImportUsers;
 
+  describe("transArrayToUser", function() {
+    it("should reject when passwordHash is invalid base64", function() {
+      return expect(transArrayToUser(["123", undefined, undefined, "false"])).to.have.property(
+        "error"
+      );
+    });
+
+    it("should not reject when passwordHash is valid base64", function() {
+      return expect(
+        transArrayToUser(["123", undefined, undefined, "Jlf7onfLbzqPNFP/1pqhx6fQF/w="])
+      ).to.not.have.property("error");
+    });
+  });
+
   describe("validateOptions", function() {
     it("should reject when unsupported hash algorithm provided", function() {
       return expect(validateOptions({ hashAlgo: "MD2" })).to.be.rejected;
@@ -63,6 +78,24 @@ describe("accountImporter", function() {
         })
       ).to.have.property("error");
     });
+
+    it("should reject when passwordHash is invalid base64", function() {
+      return expect(
+        validateUserJson({
+          localId: "123",
+          passwordHash: "false",
+        })
+      ).to.have.property("error");
+    });
+
+    it("should not reject when passwordHash is valid base64", function() {
+      return expect(
+        validateUserJson({
+          localId: "123",
+          passwordHash: "Jlf7onfLbzqPNFP/1pqhx6fQF/w=",
+        })
+      ).to.not.have.property("error");
+    });
   });
 
   describe("serialImportUsers", function() {