Firestore Test Production Security Rules With Android Unit Test

March 7, 2019

We shall test Firestore Security Rules in production environment using Android Unit Test (Instrumented tests).

NOTE: Other methods to test firestore security rules include Simulator and Firestore Emulator.

Android depedencies for testing.

dependencies {
    androidTestImplementation 'androidx.test:core:1.1.0'
    androidTestImplementation 'androidx.test:core-ktx:1.1.0'
    androidTestImplementation 'androidx.test.ext:junit:1.1.0'
    androidTestImplementation 'androidx.test.ext:junit-ktx:1.1.0'
    androidTestImplementation 'androidx.test:runner:1.1.1'
    androidTestImplementation 'androidx.test.espresso:espresso-core:3.1.1'
}

NOTE: Assuming you already setup Firestore on Android.

Create FirestoreSecurityRulesTest.kt at androidTest.

  • The following unit tests are to test against this User Security Rules.
  • Use @LargeTest because of network request (Firestore backend) is involved.
  • @FixMethodOrder(MethodSorters.NAME_ASCENDING) because some test involved creating document and query by another test.
  • expectFirestorePermissionDenied is used to detect rejected Firestore Security Rules.
  • Pre-create 3 users (2 normal users, 1 admin) using email & password authentication.
  • Assign admin role to one of the user.
  • Create methods to switch user such as signOut, signInAsUser1, signInAsUser2 and signInAsAdmin.
  • setup test by assigning db: FirebaseFirestore, auth: FirebaseAuth and user1: FirebaseUser.
  • We shall use user1 to create document, and use user1 (owner), user2 (non-owner) and admin (admin) to test access permission
  • tearUp is used to delete document created during this test

To run the test, right click on FirestoreSecurityRulesTest and select run 'FirestoreSecurityRulesTest'.

NOTE: When I run the test, occasionally network is lost/disabled on my device, thus I created test01_network to check for network issue. To solve this issue, launch the app (click the app icon on the device), then run the test again.

NOTE: This unit tests probably didn’t conver all cases, but good enough as a starter.

import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import com.google.android.gms.tasks.Tasks
import com.google.firebase.Timestamp
import com.google.firebase.auth.FirebaseAuth
import com.google.firebase.auth.FirebaseUser
import com.google.firebase.firestore.FirebaseFirestore
import com.google.firebase.firestore.FirebaseFirestoreException
import com.google.firebase.firestore.FirebaseFirestoreSettings
import org.hamcrest.CoreMatchers.*
import org.hamcrest.MatcherAssert.assertThat
import org.hamcrest.Matchers.greaterThan
import org.hamcrest.core.StringContains
import org.junit.*
import org.junit.rules.ExpectedException
import org.junit.runner.RunWith
import org.junit.runners.MethodSorters
import java.net.URL
import java.util.concurrent.ExecutionException
import java.util.concurrent.TimeUnit
import javax.net.ssl.HttpsURLConnection
import org.hamcrest.CoreMatchers.`is` as Is

@RunWith(AndroidJUnit4::class)
@LargeTest  // need network
@FixMethodOrder(MethodSorters.NAME_ASCENDING)
class FirestoreSecurityRulesTest {
    companion object {
        private const val COLLECTION_USER = "users"
    }

    private lateinit var db: FirebaseFirestore
    private lateinit var auth: FirebaseAuth
    private lateinit var user1: FirebaseUser

    @Rule
    @JvmField
    val expectedException = ExpectedException.none()

    fun expectFirestorePermissionDenied() {
        expectedException.expect(ExecutionException::class.java)
        expectedException.expectCause(isA(FirebaseFirestoreException::class.java))
        expectedException.expectMessage(StringContains("PERMISSION_DENIED"))
    }

    fun signInAsUser1(): FirebaseUser {
        auth.signOut()
        Tasks.await(auth.signInWithEmailAndPassword("user1@luasoftware.com", "***"))
        assertThat(auth.currentUser, Is(notNullValue()))
        assertThat(auth.currentUser?.email, Is("user1@luasoftware.com"))
        return auth.currentUser!!
    }

    fun signInAsUser2(): FirebaseUser {
        auth.signOut()
        Tasks.await(auth.signInWithEmailAndPassword("user2@luasoftware.com", "***"))
        assertThat(auth.currentUser, Is(notNullValue()))
        assertThat(auth.currentUser?.email, Is("user2@luasoftware.com"))
        return auth.currentUser!!
    }

    fun signInAsAdmin(): FirebaseUser {
        auth.signOut()
        Tasks.await(auth.signInWithEmailAndPassword("admin@luasoftware.com", "***"))
        assertThat(auth.currentUser, Is(notNullValue()))
        assertThat(auth.currentUser?.email, Is("admin@luasoftware.com"))
        return auth.currentUser!!
    }

    fun signOut() {
        auth.signOut()
        assertThat(auth.currentUser, Is(nullValue()))
    }

    @Before
    fun setup() {
        db = FirebaseFirestore.getInstance()
        val settings = FirebaseFirestoreSettings.Builder()
            .setPersistenceEnabled(false)
            .build()
        db.firestoreSettings = settings

        auth = FirebaseAuth.getInstance()

        user1 = signInAsUser1()
    }

    @After
    fun tearUp() {
        signInAsAdmin()

        val batch = db.batch()

        val collection = db.collection(COLLECTION_USER)

        batch.delete(collection.document(user1.uid))

        // these documents shouldn't be created
        batch.delete(collection.document("test_create_user_public"))
        batch.delete(collection.document("test_create_user_fake_id"))

        batch.commit()
    }

    /**
     * Sometimes network connection is disabled when running test
     */
    @Test
    fun test01_network() {
        val url = URL("https://www.android.com/")
        val conn = url.openConnection() as HttpsURLConnection
        assertThat(conn.responseCode, Is(HttpsURLConnection.HTTP_OK))
    }

    /**
     * Public cannot create User document
     */
    @Test
    fun test02_createUserByPublic() {
        signOut()

        val task = db.collection(COLLECTION_USER)
            .document("test_create_user_public")
            .set(mapOf("roles" to listOf("user")))

        expectFirestorePermissionDenied()
        Tasks.await(task, 10, TimeUnit.SECONDS)
    }


    /**
     * User can create User document
     */
    @Test
    fun test03_createUserAsUser1() {
        val user = signInAsUser1()

        val docRef = db.collection(COLLECTION_USER)
            .document(user.uid)

        val doc = Tasks.await(docRef.get())
        // make sure it doesn't exist
        assertThat(doc.exists(), Is(false))

        val task = docRef
            .set(mapOf(
                "name" to "User1",
                "roles" to listOf("user")
            ))

        Tasks.await(task, 10, TimeUnit.SECONDS)
    }

    /**
     * User cannot create User with FakeId
     */
    @Test
    fun test04_createUserWithFakeId() {
        val user = signInAsUser1()

        val docRef = db.collection(COLLECTION_USER)
            .document("test_create_user_fake_id")

        val doc = Tasks.await(docRef.get())
        // make sure it doesn't exist
        assertThat(doc.exists(), Is(false))

        val task = docRef
            .set(mapOf(
                "name" to "User1",
                "roles" to listOf("user")
            ))

        expectFirestorePermissionDenied()
        Tasks.await(task, 10, TimeUnit.SECONDS)
    }

    /**
     * User cannot create User document with admin role
     */
    @Test
    fun test05_createUserWithAdminRole() {
        val user = signInAsUser2()

        val docRef = db.collection(COLLECTION_USER)
            .document(user.uid)

        val doc = Tasks.await(docRef.get())
        // make sure it doesn't exist
        assertThat(doc.exists(), Is(false))

        val task = docRef
            .set(mapOf(
                "name" to "User2",
                "roles" to listOf("user", "admin")
            ))

        expectFirestorePermissionDenied()
        Tasks.await(task, 10, TimeUnit.SECONDS)
    }

    /**
     * Public can read/list any User document
     */
    @Test
    fun test06_readUserAsPublic() {
        signOut()

        val docRef = db.collection(COLLECTION_USER)
            .document(user1.uid)

        val doc = Tasks.await(docRef.get())
        assertThat(doc.exists(), Is(true))
        assertThat(doc.getString("name"), Is("User1"))

        val task = db.collection(COLLECTION_USER)
            .whereArrayContains("roles", "user")
            .get()

        val results = Tasks.await(task, 10, TimeUnit.SECONDS)
        assertThat(results.size(), greaterThan(0))
    }

    /**
     * Owner can update User document
     */
    @Test
    fun test07_updateUserAsOwner() {
        val user = signInAsUser1()

        val docRef = db.collection(COLLECTION_USER)
            .document(user.uid)

        val doc = Tasks.await(docRef.get())
        assertThat(doc.exists(), Is(true))

        val task = docRef
            .update(mapOf(
                "modified" to Timestamp.now()
            ))

        Tasks.await(task, 10, TimeUnit.SECONDS)
    }

    /**
     * Owner cannot change User document roles
     */
    @Test
    fun test08_updateUserRolesAsOwner() {
        val user = signInAsUser1()

        val docRef = db.collection(COLLECTION_USER)
            .document(user.uid)

        val doc = Tasks.await(docRef.get())
        assertThat(doc.exists(), Is(true))

        val task = docRef
            .update(mapOf(
                "roles" to listOf("user", "admin")
            ))

        expectFirestorePermissionDenied()
        Tasks.await(task, 10, TimeUnit.SECONDS)
    }

    /**
     * Owner/User cannot delete User document
     */
    fun test09_deleteUserAsOwner() {
        val user = signInAsUser1()

        val docRef = db.collection(COLLECTION_USER)
            .document(user.uid)

        val doc = Tasks.await(docRef.get())
        assertThat(doc.exists(), Is(true))

        val task = docRef.delete()

        expectFirestorePermissionDenied()
        Tasks.await(task, 10, TimeUnit.SECONDS)
    }

    /**
     * Non-owner cannot update User document
     */
    fun test10_updateUserAsNonOwner() {
        val user = signInAsUser2()

        val docRef = db.collection(COLLECTION_USER)
            .document(user1.uid)
        // make sure not owner
        assertThat(docRef.id, Is(not(user.uid)))

        val task = docRef.update(mapOf(
            "name" to "cannot change name by non-owner"
        ))

        expectFirestorePermissionDenied()
        Tasks.await(task, 10, TimeUnit.SECONDS)
    }

    /**
     * Admin can do anything
     */
    fun test11_updateUserAsAdmin() {
        val user = signInAsAdmin()

        val docRef = db.collection(COLLECTION_USER)
            .document(user1.uid)
        // make sure not owner
        assertThat(docRef.id, Is(not(user.uid)))

        val task = docRef.update(mapOf(
            "remark" to "changes by admin",
            "roles" to listOf("user", "moderator")
        ))
    }
}
This work is licensed under a
Creative Commons Attribution-NonCommercial 4.0 International License.