Repository: firebase/firebase-tools (ts)

Issue:
oobCode not appearing after updateEmail is called while using emulators
<!-- DO NOT DELETE
validate_template=true
template_path=.github/ISSUE_TEMPLATE/bug_report.md
-->

<!--
Thank you for contributing to the Firebase community!

Think you found a bug?
=======================
Yeah, we're definitely not perfect! Please use this template and include a minimal repro when opening the issue. If you know how to solve the issue, please create a Pull Request, and we'd be happy to review it!

Have a feature request?
========================
Great, we love hearing how we can improve our products! However, do not use this template to submit a feature request. Please submit your feature requests to: https://firebase.google.com/support/contact/bugs-features/

Have a usage question?
=======================
We get lots of those and we love helping you, but GitHub is not the best place for them and they will be closed. Please take a look at the guide first: https://firebase.google.com/docs/cli/

If the official documentation doesn't help, try asking through our official support channel: https://firebase.google.com/support/

Additional locations to check for solutions or assistance from the community:
- Stack Overflow: https://stackoverflow.com/
- Firebase Slack Community: https://firebase.community/

*Please avoid duplicate posting across multiple channels!*
-->

### [REQUIRED] Environment info

<!-- What version of the Firebase CLI (`firebase-tools`) are you using? Note that your issue may already be fixed in the latest versions. The latest version can be found at https://github.com/firebase/firebase-tools/releases -->

<!-- Output of `firebase --version` -->

**firebase-tools: 9.2.1**

<!-- e.g. macOS, Windows, Ubuntu -->

**Platform: macOS**

### [REQUIRED] Test case

<!-- Provide a minimal, complete, and verifiable example (http://stackoverflow.com/help/mcve) -->

```js
await testFirebaseApp.auth().createUserWithEmailAndPassword('test@email.com', 'testing');
await testFirebaseApp.auth().currentUser?.updateEmail('test1@email.com');

const oobCodes = await axios.get(`http://localhost:9099/emulator/v1/projects/project-test/oobCodes`);
```

### [REQUIRED] Steps to reproduce

<!-- Provide the steps needed to reproduce the issue with the above test case. -->

Running the code above

### [REQUIRED] Expected behavior

<!-- What is the expected behavior? -->

oobCodes contains an oobCode for test@email.com to revert the email

### [REQUIRED] Actual behavior

<!-- Run the command with --debug flag, and include the logs below. -->

The response does not contain an oobCode to revert the email. But does contain oobCodes for other actions such as resetPassword and verifyEmail


Pull request description:
Add OOB flow to reset email when updateEmail is used.
### Description

Fixes #3070. Tests are pending. Drafting a PR to clarify a few things.

### Scenarios Tested

Manually tested creating an account, updating email and using the oobLink to reset email.

### Sample Commands

await firebase.auth().createUserWithEmailAndPassword('test@email.com', 'testing');
await firebase.auth().currentUser?.updateEmail('test1@email.com');

HTTP GET http://localhost:9099/emulator/v1/projects/project-test/oobCodes

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e69de29bb..efc319d09 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -0,0 +1,1 @@
+- Fixes issue where OOB flow is not initiated after updating a user's email (#3096)
diff --git a/src/emulator/auth/handlers.ts b/src/emulator/auth/handlers.ts
index 1ef1dc4ae..21127ef27 100644
--- a/src/emulator/auth/handlers.ts
+++ b/src/emulator/auth/handlers.ts
@@ -36,6 +36,42 @@ export function registerHandlers(
     const state = getProjectStateByApiKey(apiKey);
 
     switch (mode) {
+      case "recoverEmail": {
+        const oob = state.validateOobCode(oobCode);
+        const RETRY_INSTRUCTIONS =
+          "If you're trying to test the reverting email flow, try changing the email again to generate a new link.";
+        if (oob?.requestType !== "RECOVER_EMAIL") {
+          return res.status(400).json({
+            authEmulator: {
+              error: `Requested mode does not match the OOB code provided.`,
+              instructions: RETRY_INSTRUCTIONS,
+            },
+          });
+        }
+        try {
+          const resp = setAccountInfoImpl(state, {
+            oobCode,
+          });
+          const email = resp.email;
+          return res.status(200).json({
+            authEmulator: { success: `The email has been successfully reset.`, email },
+          });
+        } catch (e) {
+          if (
+            e instanceof NotImplementedError ||
+            (e instanceof BadRequestError && e.message === "INVALID_OOB_CODE")
+          ) {
+            return res.status(400).json({
+              authEmulator: {
+                error: `Your request to revert your email has expired or the link has already been used.`,
+                instructions: RETRY_INSTRUCTIONS,
+              },
+            });
+          } else {
+            throw e;
+          }
+        }
+      }
       case "resetPassword": {
         const oob = state.validateOobCode(oobCode);
         if (oob?.requestType !== "PASSWORD_RESET") {
diff --git a/src/emulator/auth/operations.ts b/src/emulator/auth/operations.ts
index 8ced3416d..ca71dd66e 100644
--- a/src/emulator/auth/operations.ts
+++ b/src/emulator/auth/operations.ts
@@ -18,6 +18,7 @@ import { Emulators } from "../types";
 import { EmulatorLogger } from "../emulatorLogger";
 import {
   ProjectState,
+  OobRequestType,
   UserInfo,
   ProviderUserInfo,
   PROVIDER_PASSWORD,
@@ -25,6 +26,7 @@ import {
   PROVIDER_PHONE,
   SIGNIN_METHOD_EMAIL_LINK,
   PROVIDER_CUSTOM,
+  OobRecord,
 } from "./state";
 
 import * as schema from "./schema";
@@ -206,6 +208,7 @@ function lookup(
 
   if (ctx.security?.Oauth2) {
     if (reqBody.initialEmail) {
+      // TODO: This is now possible. See ProjectState.getUserByInitialEmail.
       throw new NotImplementedError("Lookup by initialEmail is not implemented.");
     }
     for (const localId of reqBody.localId ?? []) {
@@ -776,50 +779,22 @@ function sendOobCode(
     );
   }
 
-  const { oobCode, oobLink } = state.createOob(email, reqBody.requestType, (oobCode) => {
-    // TODO: Support custom handler links.
-    const url = authEmulatorUrl(ctx.req as express.Request);
-    url.pathname = "/emulator/action";
-    url.searchParams.set("mode", mode);
-    url.searchParams.set("lang", "en");
-    url.searchParams.set("oobCode", oobCode);
-
-    // This doesn't matter for now, since any API key works for defaultProject.
-    // TODO: What if reqBody.targetProjectId is set?
-    url.searchParams.set("apiKey", "fake-api-key");
-
-    if (reqBody.continueUrl) {
-      url.searchParams.set("continueUrl", reqBody.continueUrl);
-    }
-
-    return url.toString();
+  const url = authEmulatorUrl(ctx.req as express.Request);
+  const oobRecord = createOobRecord(state, email, url, {
+    requestType: reqBody.requestType,
+    mode,
+    continueUrl: reqBody.continueUrl,
   });
 
   if (reqBody.returnOobLink) {
     return {
       kind: "identitytoolkit#GetOobConfirmationCodeResponse",
       email,
-      oobCode,
-      oobLink,
+      oobCode: oobRecord.oobCode,
+      oobLink: oobRecord.oobLink,
     };
   } else {
-    // Print out a developer-friendly log containing the link, in lieu of
-    // sending a real email out to the email address.
-    let message: string | undefined;
-    switch (reqBody.requestType) {
-      case "EMAIL_SIGNIN":
-        message = `To sign in as ${email}, follow this link: ${oobLink}`;
-        break;
-      case "PASSWORD_RESET":
-        message = `To reset the password for ${email}, follow this link: ${oobLink}&newPassword=NEW_PASSWORD_HERE`;
-        break;
-      case "VERIFY_EMAIL":
-        message = `To verify the email address ${email}, follow this link: ${oobLink}`;
-        break;
-    }
-    if (message) {
-      EmulatorLogger.forEmulator(Emulators.AUTH).log("BULLET", message);
-    }
+    logOobMessage(oobRecord);
 
     return {
       kind: "identitytoolkit#GetOobConfirmationCodeResponse",
@@ -860,7 +835,11 @@ function setAccountInfo(
   reqBody: Schemas["GoogleCloudIdentitytoolkitV1SetAccountInfoRequest"],
   ctx: ExegesisContext
 ): Schemas["GoogleCloudIdentitytoolkitV1SetAccountInfoResponse"] {
-  return setAccountInfoImpl(state, reqBody, { privileged: !!ctx.security?.Oauth2 });
+  const url = authEmulatorUrl(ctx.req as express.Request);
+  return setAccountInfoImpl(state, reqBody, {
+    privileged: !!ctx.security?.Oauth2,
+    emulatorUrl: url,
+  });
 }
 
 /**
@@ -869,12 +848,13 @@ function setAccountInfo(
  * @param state the current project state
  * @param reqBody request with fields to update
  * @param privileged whether request is OAuth2 authenticated. Affects validation
+ * @param emulatorUrl url to the auth emulator instance. Needed for sending OOB link for email reset
  * @return the HTTP response body
  */
 export function setAccountInfoImpl(
   state: ProjectState,
   reqBody: Schemas["GoogleCloudIdentitytoolkitV1SetAccountInfoRequest"],
-  { privileged = false }: { privileged?: boolean } = {}
+  { privileged = false, emulatorUrl = undefined }: { privileged?: boolean; emulatorUrl?: URL } = {}
 ): Schemas["GoogleCloudIdentitytoolkitV1SetAccountInfoResponse"] {
   // TODO: Implement these.
   const unimplementedFields: (keyof typeof reqBody)[] = [
@@ -914,22 +894,40 @@ export function setAccountInfoImpl(
   const updates: Omit<Partial<UserInfo>, "localId" | "providerUserInfo"> = {};
   let user: UserInfo;
   let signInProvider: string | undefined;
+  let isEmailUpdate: boolean = false;
 
   if (reqBody.oobCode) {
     const oob = state.validateOobCode(reqBody.oobCode);
     assert(oob, "INVALID_OOB_CODE");
-    if (oob.requestType !== "VERIFY_EMAIL") {
-      throw new NotImplementedError(oob.requestType);
-    }
-    state.deleteOobCode(reqBody.oobCode);
-
-    signInProvider = PROVIDER_PASSWORD;
-    const maybeUser = state.getUserByEmail(oob.email);
-    assert(maybeUser, "INVALID_OOB_CODE");
-    user = maybeUser;
-    updates.emailVerified = true;
-    if (oob.email !== user.email) {
-      updates.email = oob.email;
+    switch (oob.requestType) {
+      case "VERIFY_EMAIL": {
+        state.deleteOobCode(reqBody.oobCode);
+        signInProvider = PROVIDER_PASSWORD;
+        const maybeUser = state.getUserByEmail(oob.email);
+        assert(maybeUser, "INVALID_OOB_CODE");
+        user = maybeUser;
+        updates.emailVerified = true;
+        if (oob.email !== user.email) {
+          updates.email = oob.email;
+        }
+        break;
+      }
+      case "RECOVER_EMAIL": {
+        state.deleteOobCode(reqBody.oobCode);
+        const maybeUser = state.getUserByInitialEmail(oob.email);
+        assert(maybeUser, "INVALID_OOB_CODE");
+        // Assert that we don't have any user with this initialEmail
+        assert(!state.getUserByEmail(oob.email), "EMAIL_EXISTS");
+        user = maybeUser;
+        if (oob.email !== user.email) {
+          updates.email = oob.email;
+          // Consider email verified, since this flow is initiated from the user's email
+          updates.emailVerified = true;
+        }
+        break;
+      }
+      default:
+        throw new NotImplementedError(oob.requestType);
     }
   } else {
     if (reqBody.idToken) {
@@ -944,12 +942,21 @@ export function setAccountInfoImpl(
 
     if (reqBody.email) {
       assert(isValidEmailAddress(reqBody.email), "INVALID_EMAIL");
+
       const newEmail = canonicalizeEmailAddress(reqBody.email);
       if (newEmail !== user.email) {
         assert(!state.getUserByEmail(newEmail), "EMAIL_EXISTS");
         updates.email = newEmail;
         // TODO: Set verified if email is verified by IDP linked to account.
         updates.emailVerified = false;
+        isEmailUpdate = true;
+        // Only update initial email if the user is not anonymous and does not have an initial email.
+        // We need to check for an anonymous user through the signIn provider, rather than relying
+        // on an empty user.email field, because it is possible for an anonymous user to update their
+        // email address through the SetAccountInfo endpoint.
+        if (signInProvider !== PROVIDER_ANONYMOUS && user.email && !user.initialEmail) {
+          updates.initialEmail = user.email;
+        }
       }
     }
     if (reqBody.password) {
@@ -1033,6 +1040,14 @@ export function setAccountInfoImpl(
     deleteProviders: reqBody.deleteProvider,
   });
 
+  // Only initiate the recover email OOB flow for non-anonymous users
+  if (signInProvider !== PROVIDER_ANONYMOUS && user.initialEmail && isEmailUpdate) {
+    if (!emulatorUrl) {
+      throw new Error("Internal assertion error: missing emulatorUrl param");
+    }
+    sendOobForEmailReset(state, user.initialEmail, emulatorUrl);
+  }
+
   return redactPasswordHash({
     kind: "identitytoolkit#SetAccountInfoResponse",
     localId: user.localId,
@@ -1048,6 +1063,74 @@ export function setAccountInfoImpl(
   });
 }
 
+function sendOobForEmailReset(state: ProjectState, initialEmail: string, url: URL) {
+  const oobRecord = createOobRecord(state, initialEmail, url, {
+    requestType: "RECOVER_EMAIL",
+    mode: "recoverEmail",
+  });
+
+  // Print out a developer-friendly log
+  logOobMessage(oobRecord);
+}
+
+function createOobRecord(
+  state: ProjectState,
+  email: string,
+  url: URL,
+  params: {
+    requestType: OobRequestType;
+    mode: string;
+    continueUrl?: string;
+  }
+): OobRecord {
+  const oobRecord = state.createOob(email, params.requestType, (oobCode) => {
+    url.pathname = "/emulator/action";
+    url.searchParams.set("mode", params.mode);
+    url.searchParams.set("lang", "en");
+    url.searchParams.set("oobCode", oobCode);
+    // TODO: Support custom handler links.
+
+    // This doesn't matter for now, since any API key works for defaultProject.
+    // TODO: What if reqBody.targetProjectId is set?
+    url.searchParams.set("apiKey", "fake-api-key");
+
+    if (params.continueUrl) {
+      url.searchParams.set("continueUrl", params.continueUrl);
+    }
+
+    return url.toString();
+  });
+
+  return oobRecord;
+}
+
+function logOobMessage(oobRecord: OobRecord) {
+  const oobLink = oobRecord.oobLink;
+  const email = oobRecord.email;
+
+  // Generate a developer-friendly log containing the link, in lieu of
+  // sending a real email out to the email address.
+  let maybeMessage: string | undefined;
+  switch (oobRecord.requestType) {
+    case "EMAIL_SIGNIN":
+      maybeMessage = `To sign in as ${email}, follow this link: ${oobLink}`;
+      break;
+    case "PASSWORD_RESET":
+      maybeMessage = `To reset the password for ${email}, follow this link: ${oobLink}&newPassword=NEW_PASSWORD_HERE`;
+      break;
+    case "VERIFY_EMAIL":
+      maybeMessage = `To verify the email address ${email}, follow this link: ${oobLink}`;
+      break;
+    case "RECOVER_EMAIL":
+      maybeMessage = `To reset your email address to ${email}, follow this link: ${oobLink}`;
+      break;
+  }
+
+  if (maybeMessage) {
+    EmulatorLogger.forEmulator(Emulators.AUTH).log("BULLET", maybeMessage);
+  }
+}
+
 function signInWithCustomToken(
   state: ProjectState,
   reqBody: Schemas["GoogleCloudIdentitytoolkitV1SignInWithCustomTokenRequest"]
diff --git a/src/emulator/auth/state.ts b/src/emulator/auth/state.ts
index 3fdee3945..4c6a7b9cb 100644
--- a/src/emulator/auth/state.ts
+++ b/src/emulator/auth/state.ts
@@ -15,6 +15,7 @@ export const SIGNIN_METHOD_EMAIL_LINK = "emailLink";
 export class ProjectState {
   private users: Map<string, UserInfo> = new Map();
   private localIdForEmail: Map<string, string> = new Map();
+  private localIdForInitialEmail: Map<string, string> = new Map();
   private localIdForPhoneNumber: Map<string, string> = new Map();
   private localIdsForProviderEmail: Map<string, Set<string>> = new Map();
   private userIdForProviderRawId: Map<string, Map<string, string>> = new Map();
@@ -153,6 +154,10 @@ export class ProjectState {
       deleteProviders.push(PROVIDER_PASSWORD);
     }
 
+    if (user.initialEmail) {
+      this.localIdForInitialEmail.set(user.initialEmail, user.localId);
+    }
+
     if (oldPhoneNumber && oldPhoneNumber !== user.phoneNumber) {
       this.localIdForPhoneNumber.delete(oldPhoneNumber);
     }
@@ -234,6 +239,14 @@ export class ProjectState {
     return this.getUserByLocalIdAssertingExists(localId);
   }
 
+  getUserByInitialEmail(initialEmail: string): UserInfo | undefined {
+    const localId = this.localIdForInitialEmail.get(initialEmail);
+    if (!localId) {
+      return undefined;
+    }
+    return this.getUserByLocalIdAssertingExists(localId);
+  }
+
   private getUserByLocalIdAssertingExists(localId: string): UserInfo {
     const userInfo = this.getUserByLocalId(localId);
     if (!userInfo) {
@@ -471,6 +484,10 @@ export class ProjectState {
       this.localIdForEmail.delete(user.email);
     }
 
+    if (user.initialEmail) {
+      this.localIdForInitialEmail.delete(user.initialEmail);
+    }
+
     if (user.phoneNumber) {
       this.localIdForPhoneNumber.delete(user.phoneNumber);
     }
@@ -501,7 +518,7 @@ interface RefreshTokenRecord {
   extraClaims: Record<string, unknown>;
 }
 
-type OobRequestType = NonNullable<
+export type OobRequestType = NonNullable<
   Schemas["GoogleCloudIdentitytoolkitV1GetOobCodeRequest"]["requestType"]
 >;
 


Test patch:
diff --git a/src/test/emulators/auth/setAccountInfo.spec.ts b/src/test/emulators/auth/setAccountInfo.spec.ts
index 96f427a54..6daa1eef0 100644
--- a/src/test/emulators/auth/setAccountInfo.spec.ts
+++ b/src/test/emulators/auth/setAccountInfo.spec.ts
@@ -14,6 +14,7 @@ import {
   updateAccountByLocalId,
   getSigninMethods,
   signInWithEmailLink,
+  inspectOobs,
   expectIdTokenExpired,
 } from "./helpers";
 
@@ -146,7 +147,7 @@ describeAuthEmulator("accounts:update", ({ authApi, getClock }) => {
     expect(await getSigninMethods(authApi(), email)).not.to.contain(["password"]);
   });
 
-  it("should allow changing email of an existing user", async () => {
+  it("should allow changing email of an existing user, and send out an oob to reset the email", async () => {
     const oldEmail = "alice@example.com";
     const password = "notasecret";
     const newEmail = "bob@example.com";
@@ -191,6 +192,12 @@ describeAuthEmulator("accounts:update", ({ authApi, getClock }) => {
         expectStatusCode(400, res);
         expect(res.body.error.message).equals("EMAIL_NOT_FOUND");
       });
+
+    // An oob is sent to oldEmail
+    const oobs = await inspectOobs(authApi());
+    expect(oobs).to.have.length(1);
+    expect(oobs[0].email).to.equal(oldEmail);
+    expect(oobs[0].requestType).to.equal("RECOVER_EMAIL");
   });
 
   it("should disallow setting email to same as an existing user", async () => {
@@ -218,6 +225,137 @@ describeAuthEmulator("accounts:update", ({ authApi, getClock }) => {
       });
   });
 
+  it("should set initialEmail for the user, after updating email", async () => {
+    const oldEmail = "alice@example.com";
+    const password = "notasecret";
+    const newEmail = "bob@example.com";
+    const { idToken } = await registerUser(authApi(), { email: oldEmail, password });
+
+    await authApi()
+      .post("/identitytoolkit.googleapis.com/v1/accounts:update")
+      .query({ key: "fake-api-key" })
+      .send({ idToken, email: newEmail })
+      .then((res) => {
+        expectStatusCode(200, res);
+        expect(res.body.email).to.equal(newEmail);
+      });
+
+    // Verify that the initial email has been set.
+    const info = await getAccountInfoByIdToken(authApi(), idToken);
+    expect(info.initialEmail).to.equal(oldEmail);
+  });
+
+  it("should reset email when OOB flow is initiated, after updating user email", async () => {
+    const oldEmail = "alice@example.com";
+    const password = "notasecret";
+    const newEmail = "bob@example.com";
+    const { idToken } = await registerUser(authApi(), { email: oldEmail, password });
+
+    await authApi()
+      .post("/identitytoolkit.googleapis.com/v1/accounts:update")
+      .query({ key: "fake-api-key" })
+      .send({ idToken, email: newEmail })
+      .then((res) => {
+        expectStatusCode(200, res);
+      });
+
+    // An oob is sent to the oldEmail
+    const oobs = await inspectOobs(authApi());
+    expect(oobs).to.have.length(1);
+    expect(oobs[0].email).to.equal(oldEmail);
+    expect(oobs[0].requestType).to.equal("RECOVER_EMAIL");
+
+    // The returned oobCode can be redeemed to verify the email.
+    await authApi()
+      .post("/identitytoolkit.googleapis.com/v1/accounts:update")
+      .query({ key: "fake-api-key" })
+      // OOB code is enough, no idToken needed.
+      .send({ oobCode: oobs[0].oobCode })
+      .then((res) => {
+        expectStatusCode(200, res);
+        expect(res.body.email).to.equal(oldEmail);
+        // Email is verified since this flow can only be initiated through a link sent to the user's email.
+        expect(res.body.emailVerified).to.equal(true);
+      });
+
+    // oobCode is removed after redeemed.
+    const oobs2 = await inspectOobs(authApi());
+    expect(oobs2).to.have.length(0);
+  });
+
+  it("should disallow resetting an email if another user exists with the same email", async () => {
+    const userBob = { email: "bob@example.com", password: "notasecret" };
+    const userOtherBob = { email: "bob@example.com", password: "notasecreteither" };
+    const bobNewEmail = "bob_new@example.com";
+
+    // Register first user
+    const { idToken } = await registerUser(authApi(), userBob);
+
+    // Update first user's email
+    await authApi()
+      .post("/identitytoolkit.googleapis.com/v1/accounts:update")
+      .send({ idToken, email: bobNewEmail })
+      .query({ key: "fake-api-key" })
+      .then((res) => {
+        expectStatusCode(200, res);
+        expect(res.body.email).to.equal(bobNewEmail);
+      });
+
+    // Register second user with the same email as the first user's initialEmail
+    await registerUser(authApi(), userOtherBob);
+
+    // Try to reset the first user's email.
+    const oobs = await inspectOobs(authApi());
+    expect(oobs).to.have.length(1);
+    expect(oobs[0].requestType).to.equal("RECOVER_EMAIL");
+
+    await authApi()
+      .post("/identitytoolkit.googleapis.com/v1/accounts:update")
+      .send({ oobCode: oobs[0].oobCode })
+      .query({ key: "fake-api-key" })
+      .then((res) => {
+        expectStatusCode(400, res);
+        expect(res.body.error).to.have.property("message").equals("EMAIL_EXISTS");
+      });
+  });
+
+  it("should not set initial email or send OOB when anon user updates email", async () => {
+    const { idToken } = await registerAnonUser(authApi());
+    const email = "alice@example.com";
+
+    // Update the email once.
+    await authApi()
+      .post("/identitytoolkit.googleapis.com/v1/accounts:update")
+      .query({ key: "fake-api-key" })
+      .send({ idToken, email })
+      .then((res) => {
+        expectStatusCode(200, res);
+        expect(res.body.email).to.equal(email);
+      });
+
+    // No OOB code should be sent.
+    expect(await inspectOobs(authApi())).to.have.length(0);
+    const info = await getAccountInfoByIdToken(authApi(), idToken);
+    expect(info.initialEmail).to.be.undefined;
+  });
+
+  it("should not update email if user is disabled", async () => {
+    const user = { email: "bob@example.com", password: "notasecret" };
+    const newEmail = "alice@example.com";
+    const { localId, idToken } = await registerUser(authApi(), user);
+    await updateAccountByLocalId(authApi(), localId, { disableUser: true });
+
+    // Try to update the email.
+    await authApi()
+      .post("/identitytoolkit.googleapis.com/v1/accounts:update")
+      .query({ key: "fake-api-key" })
+      .send({ idToken, email: newEmail })
+      .then((res) => {
+        expectStatusCode(400, res);
+        expect(res.body.error).to.have.property("message").equals("USER_DISABLED");
+      });
+  });
+
   it("should update phoneNumber if specified", async () => {
     const phoneNumber = TEST_PHONE_NUMBER;
     const { localId, idToken } = await signInWithPhoneNumber(authApi(), phoneNumber);