firebase / firebase-functions-test

MIT License
235 stars 52 forks source link

let Syntax from "Intermediate Topics in Firebase" not valid #83

Closed b0ot closed 3 years ago

b0ot commented 3 years ago

@samtstern

Version info

firebase-functions-test:

firebase-functions:

firebase-admin:

firebase -V: 8.17.0

Test case

Based on Firebase YouTube Tutorial: Intermediate topics in Firebase Security Rules - Firecasts

Steps to reproduce

Todd has just refactored his code from:

      function postHasAllRequiredFields() {
        return (request.resource.data.keys().hasAll(["authorId", "visibility", "content", "headline"]));
      }
      function postHasAllRequiredFields() {
          let requiredFields = ["authorId", "visibility", "content", "headline"];
        return (request.resource.data.keys().hasAll(requiredFields));
      }

Expected behavior

Refactored code should still pass test cases

Actual behavior

npm test 

results in multiple errors:

Error: Expected PERMISSION_DENIED but got unexpected error: FirebaseError: [code=invalid-argument]: 3 INVALID_ARGUMENT: Error compiling rules:
L33:9 missing ';' at 'return'

The same result happens in the next section as well when Todd does the posthasonlyAllowedFields.

It appears that the "let" syntax is not valid.

samtstern commented 3 years ago

@b0ot I was not able to reproduce this. Here's what I have:

firestore.rules

rules_version = '2'
service cloud.firestore {
  match /databases/{database}/documents {
    function postHasAllRequiredFields() {
      let requiredFields = ["authorId", "visibility", "content", "headline"];
      return (request.resource.data.keys().hasAll(requiredFields));
    }

    match /posts/{post} {
      allow read, write: if postHasAllRequiredFields();
    }
  }
}

index.test.js

const firebase = require("@firebase/rules-unit-testing");

const app =firebase.initializeTestApp({
  projectId: "fir-dumpster"
});

const db = app.firestore();

async function main() {
  await firebase.assertSucceeds(db.collection('posts').add({
    "authorId": "foo",
    "visibility": "bar",
    "content": "baz",
    "headline": "qux",
  }));
  console.log("Request succeeded.");

  await firebase.assertFails(db.collection('posts').add({
    "authorId": "foo",
    "visibility": "bar",
    "content": "baz",
  }));
  console.log("Request failed.");
}

main();

Then here's what I get when I run it:

$ firebase emulators:exec "node index.test.js"
i  emulators: Starting emulators: firestore
i  firestore: Firestore Emulator logging to firestore-debug.log
i  Running script: node index.test.js
Request succeeded.
Request failed.
✔  Script exited successfully (code 0)
i  emulators: Shutting down emulators.
i  firestore: Stopping Firestore Emulator
i  hub: Stopping emulator hub

I think you must have a syntax error in your rules somewhere?

b0ot commented 3 years ago

Sam, If there is a syntax error, I haven't been able to figure it out. I will confirm that I am able to use let within functions at the match /databases/{database}/documents { level.

I tried to simplify the testing further and created the following.

This works for 100% of test cases

      function postHasOnlyAllowedFields() {
        return request.resource.data.keys().hasOnly(["authorId", "visibility", "content", "headline", "photo", "tags", "location"])
      }

      // function postHasOnlyAllowedFields() {
      //   let requiredAndOptionalFields = ["authorId", "visibility", "content", "headline", "photo", "tags", "location"]
      //   return request.resource.data.keys().hasOnly(requiredAndOptionalFields);
      // }

This does NOT work

      // function postHasOnlyAllowedFields() {
      //   return request.resource.data.keys().hasOnly(["authorId", "visibility", "content", "headline", "photo", "tags", "location"])
      // }

      function postHasOnlyAllowedFields() {
        let requiredAndOptionalFields = ["authorId", "visibility", "content", "headline", "photo", "tags", "location"]
        return request.resource.data.keys().hasOnly(requiredAndOptionalFields);
      }

The error is:

     Error: 3 INVALID_ARGUMENT: Error compiling rules:
L48:9 missing ';' at 'return'

L48 refers to:
return request.resource.data.keys().hasOnly(requiredAndOptionalFields);

Full firestore.rules

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    // match /{document=**} {
    //   allow read, write: if request.time < timestamp.date(2020, 6, 4);
    // }
    // match /{document=**} {
    //  allow read, write: if request.auth != null
    // }

    function userIsModerator() {
      return request.auth.token.isModerator == true;
    }

    function documentFieldsCheckOut(requiredFields, optionalFields) {
      let allFields = requiredFields.concat(optionalFields);
      // let unused = debug("**hack to allow debugs**")
      return request.resource.data.keys().hasAll(requiredFields) && request.resource.data.keys().hasOnly(allFields)
    }

    function editOnlyChangesFields(allowedFields){
      let affectedKeys = request.resource.data.diff(resource.data).affectedKeys();
      return affectedKeys.hasOnly(allowedFields);
    }    

    match /offers/{offerID} {
        allow read: if request.auth != null
    }
    match /readonly/{docId} {
      allow read: if true;
      allow write: if false;
    }
    match /users/{userId} {
      allow write: if (request.auth.uid == userId);
    }
    match /posts/{postId} {

      function postHasAllRequiredFields() {
        return (request.resource.data.keys().hasAll(["authorId", "visibility", "content", "headline"]));
      }

      // function postHasOnlyAllowedFields() {
      //   return request.resource.data.keys().hasOnly(["authorId", "visibility", "content", "headline", "photo", "tags", "location"])
      // }

      function postHasOnlyAllowedFields() {
        let requiredAndOptionalFields = ["authorId", "visibility", "content", "headline", "photo", "tags", "location"]
        return request.resource.data.keys().hasOnly(requiredAndOptionalFields);
      }

      allow read: if (resource.data.visibility == "public") || (resource.data.authorId == request.auth.uid);
      allow update: if ((resource.data.authorId == request.auth.uid) || userIsModerator()) && editOnlyChangesFields(["visibility", "content"])
      allow create: if (request.resource.data.authorId == request.auth.uid) && postHasAllRequiredFields() && postHasOnlyAllowedFields()

      // allow create: if (request.resource.data.authorId == request.auth.uid) && documentFieldsCheckOut(["authorId", "visibility", "content", "headline"], ["photo", "tags", "location"]);
    }

    match /rooms/{roomId} {
      // Security rules for the rooms go here
      function userIsRoomMod() {
        return (request.auth.uid in (get(/databases/$(database)/documents/rooms/$(roomId)).data.roomMods));
      }
      match /posts/{postId} {
        allow update: if (resource.data.authorId == request.auth.uid) || 
          userIsRoomMod()
          // get retrieves a document from the database. Should start /databases/$(database)/documents
      }

    }
  }
}

Full test.js

const assert = require('assert');
const firebase = require('@firebase/rules-unit-testing');
const { request } = require('http');
// const { get } = require('https');

const MY_PROJECT_ID = "fir-securityrules";
const myId = "user_abc"
const myAuth = {uid: "user_abc", email: "abc@gmail.com"};

const modId = "user_mod"
const modAuth = {uid: modId, email: "mod@gmail.com", isModerator: true}

const theirId = "user_xyz"

const EMULATOR_PORT = 8092

function getFirestore(auth) {
  const db = firebase.initializeTestApp({projectId: MY_PROJECT_ID, auth: auth }).firestore();
  db.useEmulator("localhost", EMULATOR_PORT)
  return db
}

function getAdminFirestore() {
  const db = firebase.initializeAdminApp({projectId: MY_PROJECT_ID}).firestore();
  // db.useEmulator("localhost", EMULATOR_PORT)
  return db
}

beforeEach(async() => {
  await firebase.clearFirestoreData({projectId: MY_PROJECT_ID});
});

describe("Majafy App", () => {

  it("understands math", () => {
    assert.strictEqual(2+2, 4);
  });

  it("Can read items in the read-only collection", async() => {
    const db = getFirestore(null)
    const testDoc = db.collection("readonly").doc("testDoc");
    await firebase.assertSucceeds(testDoc.get());
  })

  it("Can't write items in the read-only collection", async() => {
    const db = getFirestore(null)
    const testDoc2 = db.collection("readonly").doc("testDoc2");
    await firebase.assertFails(testDoc2.set({foo: "bar"}));
  })

  it("Can write to a user document with the same ID as our user", async() => {
    const db = getFirestore(myAuth)
    const testDoc = db.collection("users").doc(myId);
    await firebase.assertSucceeds(testDoc.set({foo: "bar"}));
  })

  it("Can't write to a user document with the same ID as our user", async() => {
    const db = getFirestore(myAuth)
    const testDoc = db.collection("users").doc(theirId);
    await firebase.assertFails(testDoc.set({foo: "bar"}));
  })

  it("Can read posts marked public", async() => {
    const db = getFirestore(null)
    const testQuery = db.collection("posts").where("visibility", "==", "public")
    await firebase.assertSucceeds(testQuery.get());
  })  

  it("Can read my own posts", async() => {
    const db = getFirestore(myAuth)
    const testQuery = db.collection("posts").where("authorId", "==", myId);
    await firebase.assertSucceeds(testQuery.get());
  })  

  it("Can't read all posts", async() => {
    const db = getFirestore(myAuth)
    const testQuery = db.collection("posts");
    await firebase.assertFails(testQuery.get());
  })  

  it("Can read a single public post", async() => {
    const admin = getAdminFirestore();
    const postId = "public_post";
    const setupDoc = admin.collection("posts").doc(postId)
    await setupDoc.set({authorId: theirId, visibility: "public"})

    const db = getFirestore(null)
    const testRead = db.collection("posts").doc(postId);
    await firebase.assertSucceeds(testRead.get());
  })
  it("Can't read a private post belonging to another user", async() => {
    const admin = getAdminFirestore();
    const postId = "private_post";
    const setupDoc = admin.collection("posts").doc(postId)
    await setupDoc.set({authorId: theirId, visibility: "private"})

    const db = getFirestore(myAuth)
    const testRead = db.collection("posts").doc(postId);
    await firebase.assertFails(testRead.get());
  })

  it("Allows a user to edit their own post", async() => {
    const postId = "post_123"
    const admin = getAdminFirestore()
    await admin.collection('posts').doc(postId).set({content: "before", authorId: myId})

    const db = getFirestore(myAuth);
    const testDoc = db.collection("posts").doc(postId)
    await firebase.assertSucceeds(testDoc.update({content: "after"}))
  })

  it("Doesn't allow a user to edit somebody elses post", async() => {
    const postId = "post_123"
    const admin = getAdminFirestore()
    await admin.collection('posts').doc(postId).set({content: "before", authorId: theirId})

    const db = getFirestore(myAuth);
    const testDoc = db.collection("posts").doc(postId)
    await firebase.assertFails(testDoc.update({content: "after"}))
  })

  it("Allows a moderator to edit somebody elses post", async() => {
    const postId = "post_123"
    const admin = getAdminFirestore()
    await admin.collection('posts').doc(postId).set({content: "before", authorId: theirId})

    const db = getFirestore(modAuth);
    const testDoc = db.collection("posts").doc(postId)
    await firebase.assertSucceeds(testDoc.update({content: "after"}))
  })

  it("Allows a user to edit their own room post", async() => {
    const postPath = "/rooms/room_abc/posts/post_123"
    const admin = getAdminFirestore()
    await admin.doc(postPath).set({content: "before", authorId: myId})

    const db = getFirestore(myAuth);
    const testDoc = db.doc(postPath)
    await firebase.assertSucceeds(testDoc.update({content: "after"}))
  })  

  it("Won't allow a user to edit someone else's room post", async() => {
    const postPath = "/rooms/room_abc/posts/post_123"
    const admin = getAdminFirestore()
    await admin.doc(postPath).set({content: "before", authorId: theirId})

    const db = getFirestore(myAuth);
    const testDoc = db.doc(postPath)
    await firebase.assertFails(testDoc.update({content: "after"}))
  })  

  it("Allows room mod to edit another person's room post", async() => {
    const roomPath = "/rooms/room_abc";
    const postPath = `${roomPath}/posts/post_123`;
    //const postPath = "/rooms/room_abc/posts/post_123"
    const admin = getAdminFirestore()
    await admin.doc(roomPath).set({topic: "Unit testers", roomMods: [myId, "fooUser"]})
    await admin.doc(postPath).set({content: "before", authorId: theirId, headline: "headline", visibility: "public"})

    const db = getFirestore(myAuth)
    const testDoc = db.doc(postPath)
    await firebase.assertSucceeds(testDoc.update({content: "after"}))
  })

  it("allows a user to create a post when they list themselves as teh author", async() => {
    const postPath = "/posts/post_123"
    const db = getFirestore(myAuth)
    const testDoc = db.doc(postPath)
    await firebase.assertSucceeds(testDoc.set({content: "after", authorId: myId, headline: "headline", visibility: "public"}))
  })

  it("doesn't allow a post when they list a different author", async() => {
    const postPath = "/posts/post_123"
    const db = getFirestore(myAuth)
    const testDoc = db.doc(postPath)
    await firebase.assertFails(testDoc.set({content: "after", authorId: theirId}))
  })
  it("allows a user to create a post when they have all required fields", async() => {
    const postPath = "/posts/post_123"
    const db = getFirestore(myAuth)
    const testDoc = db.doc(postPath)
    await firebase.assertSucceeds(testDoc.set({content: "after", authorId: myId, headline: "headline", visibility: "public"}))
  })

  it("doesn't allow a user to create a post when they don't have all required fields", async() => {
    const postPath = "/posts/post_123"
    const db = getFirestore(myAuth)
    const testDoc = db.doc(postPath)
    await firebase.assertFails(testDoc.set({content: "after", authorId: myId}))
  })

  it("Can create a post with all required and optional fields", async() => {
    const postPath = "/posts/post_123"
    const db = getFirestore(myAuth)
    const testDoc = db.doc(postPath)
    await firebase.assertSucceeds(testDoc.set({content: "content", authorId: myId, headline: "headline", visibility: "public", location: "San Fran", tags: ["screencast", "tutorial"], photo: "url_goes_here"}))
  })

  it("doesn't allow a user to create a post when they don't have all required fields", async() => {
    const postPath = "/posts/post_123"
    const db = getFirestore(myAuth)
    const testDoc = db.doc(postPath)
    await firebase.assertFails(testDoc.set({content: "after", authorId: myId, headline: "headline", visibility: "public", not_allowed: true}))
  })

  it("Can edit a post with allowed fields", async() => {
    const postPath = "/posts/post_123"
    const admin = getAdminFirestore();
    await admin.doc(postPath).set({content: "before_content", authorId: myId, headline: "before_headline", visibility: "public"})

    const db = getFirestore(myAuth)
    const testDoc = db.doc(postPath)
    await firebase.assertSucceeds(testDoc.update({content:"after_content"}));

  })

  it("Can't edit a post's forbidden fields", async() => {
    const postPath = "/posts/post_123"
    const admin = getAdminFirestore();
    await admin.doc(postPath).set({content: "before_content", authorId: myId, headline: "before_headline", visibility: "public"})

    const db = getFirestore(myAuth)
    const testDoc = db.doc(postPath)
    await firebase.assertFails(testDoc.update({headline:"sneaky change?"}));

  })

})

after(async() => {
  await firebase.clearFirestoreData({projectId: MY_PROJECT_ID});
});
b0ot commented 3 years ago

Ok, I'm an idiot... the error and Line Number had me so focused on the return statement, however it was actually the line above that had the issue.

Error was: Error: 3 INVALID_ARGUMENT: Error compiling rules: L48:9 missing ';' at 'return'

L47: let requiredAndOptionalFields = ["authorId", "visibility", "content", "headline", "photo", "tags", "location"] L48: return request.resource.data.keys().hasOnly(requiredAndOptionalFields);

I simply needed to add a ';' to the end of L47 let requiredAndOptionalFields = ["authorId", "visibility", "content", "headline", "photo", "tags", "location"];

I think my earlier frustrations have caused me to be a bit too quick to jump to "bug" as opposed to doing a proper amount of normal debugging. I'll try to vet my problems more before future bug reports. I also could have sworn I had tried this but obviously not.

Apologies to @samtstern and team.

samtstern commented 3 years ago

@b0ot thank you for following up and glad you fixed it! I think your earlier frustrations make it perfectly reasonable that you suspect a bug, you have definitely encountered some real ones before.

vdiaz1130 commented 3 years ago

Importing clearFirestoreData from '@firebase/rules-unit-testing'; no longer works with version 2.0.0. How do we import it now?

image