Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Throws "FirebaseError: Data must be an object, but it was: a custom Object object" when unit testing firestore rules. #7781

Closed
ASE55471 opened this issue Nov 14, 2023 · 4 comments
Assignees

Comments

@ASE55471
Copy link

ASE55471 commented Nov 14, 2023

Operating System

MacOS 14.1 Sonoma

Browser Version

Firebase SDK Version

Firebase SDK Product:

Firestore

Describe your project's tooling

"devDependencies": {
  "@firebase/rules-unit-testing": "^3.0.1",
  "jest-environment-node": "^29.7.0",
  "@jest/globals": "^29.7.0",
  "jest": "^29.7.0",
  "ts-jest": "^29.1.1",
  "typescript": "^4.7.4"
}

firebase-tools: 12.8.1

Describe the problem

I am testing firestore rules with custom node environment, But when I write data emulator always throws FirebaseError: Function DocumentReference.set() called with invalid data. Data must be an object, but it was: a custom Object object,

Steps and code to reproduce issue

  1. Create custom node environment
/**
 * @jest-environment
 */

import { RulesTestEnvironment, initializeTestEnvironment } from "@firebase/rules-unit-testing";

import * as fs from 'fs';

const NodeEnvironment = require('jest-environment-node').TestEnvironment;
const MY_PROJECT_ID = "projectId";

declare global {
  var testEnv: RulesTestEnvironment;
}

class CustomEnvironment extends NodeEnvironment {

  constructor(config: any, context: any) {
    super(config, context);
    console.log(config.globalConfig);
    console.log(config.projectConfig);
    this.testPath = context.testPath;
    this.docblockPragmas = context.docblockPragmas;
  }

  async setup() {
    await super.setup();
    let testEnv = await initializeTestEnvironment({
      projectId: MY_PROJECT_ID,
      firestore: {
        rules: fs.readFileSync("firestore.rules", "utf8"),
        host: '127.0.0.1',
        port: 8080
      },
    });
    this.global.testEnv = testEnv;
  }

  async teardown() {
    this.global.testEnv.cleanup();
    await super.teardown();
  }

  getVmContext() {
    return super.getVmContext();
  }

}

module.exports = CustomEnvironment;
  1. Create a test file
/**
 * @jest-environment
 */

import {
  RulesTestEnvironment,
} from "@firebase/rules-unit-testing"
import { afterAll, beforeAll, describe, test } from "@jest/globals";

let testEnv: RulesTestEnvironment;

beforeAll(async () => {
  testEnv = globalThis.testEnv;
})

afterAll(async () => {
  await testEnv?.clearFirestore();
})

describe("app", () => {

  test("Example", async () => {

    // Create base data for test.
    const profile = {
      name: "Jason"
    };

    // Write prepared data to database for test.
    await testEnv.withSecurityRulesDisabled(async (context) => {
      const firestore = await context.firestore();
      const profileRef = firestore.collection("profile").doc("user");
      await profileRef.set(profile);
    });

  });
})
  1. run npm run jest
FAIL  test/profile.test.ts
  app
    ✕ Example (3 ms)

  ● app › Example

    FirebaseError: Function DocumentReference.set() called with invalid data. Data must be an object, but it was: a custom Object object (found in document profile/user)



Test Suites: 1 failed, 1 total
Tests:       1 failed, 1 total
Snapshots:   0 total
Time:        1.434 s
Ran all test suites.
@ASE55471 ASE55471 added new A new issue that hasn't be categoirzed as question, bug or feature request question labels Nov 14, 2023
@jbalidiong jbalidiong added needs-attention and removed new A new issue that hasn't be categoirzed as question, bug or feature request labels Nov 14, 2023
@jbalidiong
Copy link
Contributor

Hi @ASE55471 thanks for the detailed report of the issue. I'll try and replicate and validate the issue.

@jbalidiong
Copy link
Contributor

Hi @ASE55471, I was able to reproduce the behavior now. Let me check what we can do for this issue or bring someone here that can provide more context about it. I’ll update this thread if I have any information to share.

@jbalidiong jbalidiong added bug testing-sdk testing with emulator and removed question labels Nov 17, 2023
@dconeybe dconeybe self-assigned this Nov 21, 2023
@dconeybe
Copy link
Contributor

Update: I've figured out where things are going wrong, but don't have a solution yet.

The error is happening because a check for Object.getPrototypeOf(input) === Object.prototype is evaluating to false here:

export function isPlainObject(input: unknown): boolean {
return (
typeof input === 'object' &&
input !== null &&
(Object.getPrototypeOf(input) === Object.prototype ||
Object.getPrototypeOf(input) === null)
);
}

which is called from DocumentReference.set() on the object it is given.

The strange thing is that if I add

console.log(Object.getPrototypeOf(profile)===Object.prototype);

to the Jest test code in profile.test.ts, and then add the same line in DocumentReference.set() then it evaluates to true in the Jest test code but false in DocumentReference.set(). So it looks like either (a) the call to Object.getPrototypeOf(profile) is returning a different object in the Jest test code than when called in DocumentReference.set(), or (b) the value of Object.prototype is changing between these two contexts. I'll continue investigating.

@dconeybe
Copy link
Contributor

I've found the root cause and a solution. Basically, the code that runs in test-environment.ts has a different view of "global" variables than the code that runs in the tests themselves. That is why the Object.prototype object from these two places of code are distinct objects, and compare unequal (as mentioned in the previous comment). One workaround, then, is to move the Firebase initialization code into the tests instead of the test environment, something like this:

import {
  RulesTestEnvironment,
  initializeTestEnvironment,
} from "@firebase/rules-unit-testing"

import { afterAll, beforeAll, describe, test } from "@jest/globals";
import * as fs from "fs";

let testEnv: RulesTestEnvironment;

beforeAll(async () => {
  testEnv = await initializeTestEnvironment({
    projectId: "my-project-id",
    firestore: {
      rules: fs.readFileSync("firestore.rules", "utf8"),
      host: "127.0.0.1",
      port: 8080,
    },
  });
});

afterAll(async () => {
  if (testEnv) {
    await testEnv.cleanup();
    testEnv = null;
  }
});

With this change, both the initializeTestEnvironment() and test code will have the same view of global variables, and the Object.prototype equality checks deep inside Firestore's code will behave as expected.

Here is a more detailed explanation of what's going on. Jest attempts to isolate tests from one another, and one of the mechanisms it uses for this is Node's "vm" feature (https://nodejs.org/docs/latest-v21.x/api/vm.html). By using the "vm" module, Node can run two pieces of JavaScript code with distinct global variables. This reduces the degree to which tests can interfere with each other. For example, if one test adds something to Object's prototype then that change would normally bleed into other tests; however, since the tests are run in a different "vm", they both have their own local copy of the Object class, so one test's changes to it are not reflected in the other test. I found this article that explains it better than I can: https://backend.cafe/should-you-use-jest-as-a-testing-library and it also provides some additional workarounds.

The reason that this causes a problem is that Firestore attempts to do some checks of the objects specified to DocumentReference.set() (or setDoc() in the newer v9 SDK) to make sure that they are suitable for storing into Firestore. One of the checks that is performed is making sure that the object is a "plain" object, and not part of a class hierarchy. Part of this check is to verify that the prototype of the object is either null or Object.prototype, and if it is something else then an exception is thrown under the assumption that the given object is "complex", and not suitable for storing in Firestore. But since Firestore was initialized in a different Node "vm" it has a distinct Object.prototype object than the object created in the test code, and this check erroneously fails.

Another workaround that seems to work, but is likely more brittle, is to export a function from the test environment to "reparent" an object into the "vm" of Firestore. The tests would, then, need to call this method on all objects specified to DocumentReference.set(), which is, admittedly, cumbersome. But, for completeness, here is what it could look like:

// test-environment.ts
import { RulesTestEnvironment, initializeTestEnvironment } from "@firebase/rules-unit-testing";
import type { DocumentData } from "firebase/firestore";

import * as fs from 'fs';

const NodeEnvironment = require('jest-environment-node').TestEnvironment;
const MY_PROJECT_ID = "my-test-project";

declare global {
  var testEnv: RulesTestEnvironment;
  var reparent: (obj: DocumentData) => DocumentData;
}

class CustomEnvironment extends NodeEnvironment {

  constructor(config: any, context: any) {
    super(config, context);
    console.log(config.globalConfig);
    console.log(config.projectConfig);
    this.testPath = context.testPath;
    this.docblockPragmas = context.docblockPragmas;
  }

  async setup() {
    await super.setup();
    let testEnv = await initializeTestEnvironment({
      projectId: MY_PROJECT_ID,
      firestore: {
        rules: fs.readFileSync("firestore.rules", "utf8"),
        host: '127.0.0.1',
        port: 8080
      },
    });
    this.global.testEnv = testEnv;

    this.global.reparent = function reparent(obj: unknown): unknown {
      if (obj === null) {
        return null;
      }
      if (typeof obj === "undefined") {
        return undefined;
      }
      if (typeof obj !== "object") {
        return obj;
      }

      if (Array.isArray(obj)) {
        const reparentedObj = [];
        for (const element of obj) {
          reparentedObj.push(reparent(element));
        }
        return reparentedObj;
      }

      const reparentedObj: Record<string|number|symbol, any> = {};
      for (const propertyName in obj) {
        reparentedObj[propertyName] = reparent(obj[propertyName]);
      }
      return reparentedObj;
    }
  }

  async teardown() {
    this.global.testEnv?.cleanup();
    await super.teardown();
  }

  getVmContext() {
    return super.getVmContext();
  }

}

module.exports = CustomEnvironment;
// tests/index.test.ts
// This is the same as the code in the OP of this issue EXCEPT that the call to
// profileRef.set(profile) is changed to profileRef.set(reparent(profile))
import {
  RulesTestEnvironment,
} from "@firebase/rules-unit-testing"
import { afterAll, beforeAll, describe, test } from "@jest/globals";
import type { DocumentData } from "firebase/firestore";

let testEnv: RulesTestEnvironment;
let reparent: (obj: DocumentData) => DocumentData;

beforeAll(async () => {
  testEnv = globalThis.testEnv;
  reparent = globalThis.reparent;
})

afterAll(async () => {
  await testEnv?.clearFirestore();
})

describe("app", () => {

  test("Example", async () => {

    // Create base data for test.
    const profile = {
      name: "Jason"
    };

    // Write prepared data to database for test.
    await testEnv.withSecurityRulesDisabled(async (context) => {
      const firestore = await context.firestore();
      const profileRef = firestore.collection("profile").doc("user");
      await profileRef.set(reparent(profile));
    });

  });
})

Since there is no action for the Firestore SDK to take to fix this issue, I'm going to close it. Please feel free to comment if you think there is something that the Firestore SDK could/should do to mitigate this issue. Thank you for reporting it! All the best.

@firebase firebase locked and limited conversation to collaborators Dec 23, 2023
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Projects
None yet
Development

No branches or pull requests

4 participants