Yesterday, I explained how I chose to start testing Firebase for taking over the Kids In Touch API. I mentioned that all was not roses. I'll go over one of my biggest issues with the service here.
Rules Based API
With Firebase, you don't write any server side code. Unlike Kinvey, Parse, etc, you don't have tools that allow you to write any backend logic. This is a simple datastore, folks. However, you also don't want people writing into your data store willy nilly. So Firebase has developed a JSON based rules language that allows you to control who can write and where they can write. It's pretty neat and generally easy. It's amazing that so much data control can sit in one simple JSON entry.
For example, to allow ANYONE to write anything to your Firebase:
{
"rules": {
".read": true,
".write": true
}
}
Of course, you really don't want to do that. So, you can then use information from the user's authentication to decide what they can write.
{
"rules": {
"users": {
"$user_id": {
// grants write access to the owner of this user account
// whose uid must exactly match the key ($user_id)
".write": "$user_id === auth.uid",
".read" : "$user_id === auth.uid"
}
}
}
}
In the example above, someone logged in could read and write to their user profile (/users/simpleLogin:383). No one else could read or write there.
As developers, we all know "never trust user input". Because of that, we do validation checking. Firebase makes this pretty easy too. Again, it all goes inside the single Rules document.
{
"rules": {
"users": {
"$user_id": {
// grants write access to the owner of this user account
// whose uid must exactly match the key ($user_id)
".write": "$user_id === auth.uid",
".read" : "$user_id === auth.uid",
"familyName" : ".validate": "newData.isString() && newData.val().length > 1 && newData.val().length < 100",
"email": {
// an email is only allowed in the profile if it matches
// the auth token's email account (for Google or password auth)
".validate": "newData.val() === auth.email"
}
}
}
}
}
The example above again allows reading and writing by the logged in user. However, it included validation on the familyName property. It must be a string longer than 2 characters and shorter than 100 characters. Email validation is simply restricted to ensuring the user's provided email address matches the information in their authentication details.
If your client tries to write, read where they are not allowed, you get a permissions error that looks like this:
{
code : "PERMISSION_DENIED",
message : "permission_denied: Client doesn't have permission to access the desired data."
}
or an even more terse
{"code":"PERMISSION_DENIED"}
Validation Errors are Not Permissions Problems
Here's my problem with all of this. "PERMISSION_DENIED" is not the same thing as "Hey dummy! 'X' is not a valid familyName"
Permission denied is generally an authentication type of issue. It does not mean the same thing as a validation error.
Look at any API provider and you'll find they provide validation errors that explain what is wrong with the provided data. Something like :
{
status : "fail",
validationErrors : [
{
familyName : 'required'
},
{
givenName : 'less than 50'
}
]
}
With Firebase, you don't get that. You get {"code":"PERMISSION_DENIED"}
. This is really painful from a development and testing perspective.
On your client, if you've missed some validation check and the API rejects your POST, you need to know why. Firebase will not tell you. So, what do you tell your user?
"Umm... sorry. Something is wrong and I have no idea why. Why don't you go change all your data and try again."
When doing testing, you can't check a bunch on invalid strings at once. Why? Because if ANY of them is wrong, you get the dreaded {"code":"PERMISSION_DENIED"}
. So, you don't know WHICH validation was preventing the write. So, you have to check each and every field with a different test.
Example :
Say, I have these rules:
{
"rules": {
"users": {
"$user_id": {
// grants write access to the owner of this user account
// whose uid must exactly match the key ($user_id)
".write": "$user_id === auth.uid",
".read" : "$user_id === auth.uid",
"familyName" : ".validate": "newData.isString() && newData.val().length > 1 && newData.val().length < 100",
"givenName" : ".validate": "newData.isString() && newData.val().length > 1 && newData.val().length < 100",
"age" : ".validate": "newData.isNumber() && newData.val() > 13 && newData.val() < 110",
"email": {
// an email is only allowed in the profile if it matches
// the auth token's email account (for Google or password auth)
".validate": "newData.val() === auth.email"
}
}
}
}
}
I'd love to write a test like this:
test('Valid Account Details Will Be Saved', function(done) {
var userDetails = {
familyName : "J",
givenName : "D",
age : 11
email : "test"
};
appBase.child('user').child(userAuthDetails.auth.uid).set(userDetails, function(err) {
expect(err.status).to.be('fail');
expect(err.validationErrors.familyName).to.be('greater than 1 character');
expect(err.validationErrors.givenName).to.be('greater than 1 character');
expect(err.validationErrors.age).to.be('greater than 13');
expect(err.validationErrors.email).to.be('match auth');
done();
})
});
Unfortunately, I can't. Because of REAL errors, I get {"code":"PERMISSION_DENIED"}
;
This means, I have to test like this:
// Test familyName Validation
test('Valid User Details Will Be Saved', function(done) {
var userDetails = {
familyName : "A",
givenName : "A good givenName",
age : 14
email : "[email protected]"
};
appBase.child('user').child(userAuthDetails.auth.uid).set(userDetails, function(err) {
expect(err.code).to.be('PERMISSION_DENIED');
done();
})
});
// Test givenName Validation
test('Valid User Details Will Be Saved', function(done) {
var userDetails = {
familyName : "A good familyName",
givenName : "A",
age : 14
email : "[email protected]"
};
appBase.child('user').child(userAuthDetails.auth.uid).set(userDetails, function(err) {
expect(err.code).to.be('PERMISSION_DENIED');
done();
})
});
// Test age Validation
test('Valid User Details Will Be Saved', function(done) {
var userDetails = {
familyName : "A good familyName",
givenName : "A good givenName",
age : 11
email : "[email protected]"
};
appBase.child('user').child(userAuthDetails.auth.uid).set(userDetails, function(err) {
expect(err.code).to.be('PERMISSION_DENIED');
done();
})
});
// Test email Validation
test('Valid User Details Will Be Saved', function(done) {
var userDetails = {
familyName : "A good familyName",
givenName : "A good givenName",
age : 14
email : "[email protected]"
};
appBase.child('user').child(userAuthDetails.auth.uid).set(userDetails, function(err) {
expect(err.code).to.be('PERMISSION_DENIED');
done();
})
});
Wow! That's a lot of testing just to prove each field is invalid. In fact, it doesn't REALLY prove each field is invalid. Maybe I'm missing something else on all those tests and just THINK I'm proving the validation works.
Fortunately, there is a way to provde the validation is working as you expect. If you're testing from something like Mocha, you need to be using custom tokens as Firebase's Simple Login utility only works in the browser. When you create the token add the debug: true
option Then you'll get an output that includes the validation rules that have failed like this:
FIREBASE: /:.write: "false"
FIREBASE: => false
FIREBASE: /accounts
FIREBASE: /accounts/token:924f9809:.write: "$accountsId === auth.uid && !data.exists() && newData.exists()"
FIREBASE: => true
FIREBASE: /accounts/token:924f9809/familyName:.validate: "newData.isString() && newData.val().length > 1 && newData.val().length < 100"
FIREBASE: => false
FIREBASE:
FIREBASE: Validation failed.
FIREBASE: Write was denied.
FIREBASE WARNING: set at /accounts/token:924f9809 failed: permission_denied
That's great. You can see what actually failed and why. However, that doesn't help you with the overall problem of no real validation errors. Your tests can't read that debug output.
I really hope that Firebase will see the value in providing real validation errors in future releases.