From 4efc7f6324c84c74e6745d7e26b6d0131ee3939d Mon Sep 17 00:00:00 2001 From: jrconlin Date: Thu, 13 Dec 2018 14:16:45 -0800 Subject: [PATCH] feat: Add FCM HTTPv1 API support With the new FCM migration, it makes some sense to switch to the new FCM HTTPv1 API. In addition, this uses twisted async. Closes #1291 --- autopush/router/__init__.py | 6 +- autopush/router/fcm_v1.py | 34 ++-- autopush/router/fcmv1client.py | 71 +++++-- autopush/tests/test_fcmclient.py | 103 ++++++++++ autopush/tests/test_router.py | 338 ++++++++++++++++++++++++++++++- autopush/tests/test_z_main.py | 14 ++ 6 files changed, 526 insertions(+), 40 deletions(-) create mode 100644 autopush/tests/test_fcmclient.py diff --git a/autopush/router/__init__.py b/autopush/router/__init__.py index 0adfae62..6495bd0b 100644 --- a/autopush/router/__init__.py +++ b/autopush/router/__init__.py @@ -15,10 +15,10 @@ from autopush.router.interface import IRouter # noqa from autopush.router.webpush import WebPushRouter from autopush.router.fcm import FCMRouter -from autopush.router.fcm_v1 import FCMV1Router +from autopush.router.fcm_v1 import FCMv1Router from autopush.router.adm import ADMRouter -__all__ = ["APNSRouter", "FCMRouter", "FCMV1Router", "GCMRouter", +__all__ = ["APNSRouter", "FCMRouter", "FCMv1Router", "GCMRouter", "WebPushRouter", "ADMRouter"] @@ -42,5 +42,5 @@ def routers_from_config(conf, db, agent): if router_conf['fcm']['version'] == 0: routers["fcm"] = FCMRouter(conf, router_conf["fcm"], db.metrics) if router_conf['fcm']['version'] == 1: - routers["fcm"] = FCMV1Router(conf, router_conf["fcm"], db.metrics) + routers["fcm"] = FCMv1Router(conf, router_conf["fcm"], db.metrics) return routers diff --git a/autopush/router/fcm_v1.py b/autopush/router/fcm_v1.py index 031b06ef..876feffa 100644 --- a/autopush/router/fcm_v1.py +++ b/autopush/router/fcm_v1.py @@ -12,7 +12,7 @@ from autopush.types import JSONDict # noqa -class FCMV1Router(FCMRouter): +class FCMv1Router(FCMRouter): """FCM v1 HTTP Router Implementation Note: FCM v1 is a newer version of the FCM HTTP API. @@ -29,11 +29,11 @@ def __init__(self, conf, router_conf, metrics): self.senderID = router_conf.get("senderID") self.version = router_conf["version"] self.log = Logger() - self.fcm = FCMv1(router_conf["service_cred_path"], - router_conf["senderID"], - self.log, - self.metrics) - self._base_tags = ["platform:fcm"] + self.fcm = FCMv1(project_id=self.senderID, + service_cred_path=router_conf['service_cred_path'], + logger=self.log, + metrics=self.metrics) + self._base_tags = ["platform:fcmv1"] self.log.debug("Starting FCMv1 router...") def amend_endpoint_response(self, response, router_data): @@ -102,7 +102,7 @@ def _route(self, notification, router_data): "collapse_key": self.collapseKey, "data_message": data, "dry_run": self.dryRun or ('dryrun' in router_data), - "time_to_live": router_ttl + "ttl": router_ttl }) d.addCallback( self._process_reply, notification, router_data, router_ttl @@ -118,7 +118,7 @@ def _process_error(self, failure): self.log.error("FCM Authentication Error: {}".format(err)) raise RouterException("Server error", status_code=500, errno=901) if isinstance(err, TimeoutError): - self.log.warn("GCM Timeout: %s" % err) + self.log.warn("FCM Timeout: %s" % err) self.metrics.increment("notification.bridge.error", tags=make_tags( self._base_tags, @@ -127,7 +127,7 @@ def _process_error(self, failure): errno=903, log_exception=False) if isinstance(err, ConnectError): - self.log.warn("GCM Unavailable: %s" % err) + self.log.warn("FCM Unavailable: %s" % err) self.metrics.increment("notification.bridge.error", tags=make_tags( self._base_tags, @@ -135,6 +135,12 @@ def _process_error(self, failure): raise RouterException("Server error", status_code=502, errno=902, log_exception=False) + if isinstance(err, RouterException): + self.log.warn("FCM Error: {}".format(err)) + self.metrics.increment("notification.bridge.error", + tags=make_tags( + self._base_tags, + reason="server_error")) return failure def _error(self, err, status, **kwargs): @@ -148,15 +154,7 @@ def _process_reply(self, reply, notification, router_data, ttl): # acks: # for reg_id, msg_id in reply.success.items(): # updates - if reply.failure: - self.metrics.increment("notification.bridge.error", - tags=make_tags(self._base_tags, - reason="failure")) - raise RouterException("FCM failure to deliver", - status_code=reply.code, - response_body="Please try request " - "later.", - log_exception=False) + # Failures are returned as non-200 messages (404, 410, etc.) self.metrics.increment("notification.bridge.sent", tags=self._base_tags) self.metrics.increment("notification.message_data", diff --git a/autopush/router/fcmv1client.py b/autopush/router/fcmv1client.py index acffa6cf..2088227d 100644 --- a/autopush/router/fcmv1client.py +++ b/autopush/router/fcmv1client.py @@ -3,7 +3,7 @@ import treq from oauth2client.service_account import ServiceAccountCredentials from twisted.logger import Logger -from twisted.internet.error import ConnectError +from twisted.internet.error import (ConnectError, TimeoutError) from autopush.exceptions import RouterException @@ -14,30 +14,62 @@ class FCMAuthenticationError(Exception): class Result(object): - def __init__(self): - self.code = 0 + def __init__(self, response): + self.code = response.code self.success = 0 - self.failure = [] + self.retry_message = None + self.retry_after = ( + response.headers.getRawHeaders('Retry-After') or [None])[0] - def parse_response(self, content, code): + def parse_response(self, content): # 400 will return an error message indicating what's wrong with the # javascript message you sent. # 403 is an error indicating that the client app is missing the # FCM Cloud Messaging permission (and a URL to set it) - if code in (400, 403, 404): - raise RouterException(content) - data = json.loads(content) - self.code = code - self.success = data.get('success', 0) - if data.get('failure'): - self.failure = data.get('failed_registration_ids') + # Successful content body + # { "name": "projects/.../messages/0:..."} + # Failures: + # { "error": + # { "status": str + # "message": str + # "code": u64 + # "details: [ + # {"errorCode": str, + # "@type": str}, + # {"fieldViolations": [ + # {"field": str, + # "description": str} + # ], + # "type", str + # } + # ] + # } + # } + # (Failures are a tad more verbose) + if 500 <= self.code <= 599: + self.retry_message = content + return self + try: + data = json.loads(content) + if self.code in (400, 403, 404) or data.get('error'): + # Having a hard time finding information about how some + # things are handled in FCM, e.g. retransmit requests. + # For now, catalog them as errors and provide back-pressure. + err = data.get("error") + raise RouterException("{}: {}".format(err.get("status"), + err.get("message"))) + if "name" in data: + self.success = 1 + except (TypeError, ValueError, KeyError, AttributeError): + raise RouterException( + "Unknown error response: {}".format(content)) return self class FCMv1(object): def __init__(self, - service_cred_path, project_id, + service_cred_path=None, logger=None, metrics=None, **options): @@ -49,9 +81,10 @@ def __init__(self, self.metrics = metrics self.logger = logger or Logger() self._options = options - self.svc_cred = ServiceAccountCredentials.from_json_keyfile_name( - service_cred_path, - ["https://www.googleapis.com/auth/firebase.messaging"]) + if service_cred_path: + self.svc_cred = ServiceAccountCredentials.from_json_keyfile_name( + service_cred_path, + ["https://www.googleapis.com/auth/firebase.messaging"]) self._sender = treq.post def _get_access_token(self): @@ -74,13 +107,15 @@ def process(self, response, payload=None): if response.code == 401: raise FCMAuthenticationError("Authentication Error") - result = Result() + result = Result(response) + d = response.text() - d.addCallback(result.parse_response, response.code) + d.addCallback(result.parse_response) return d def error(self, failure): if isinstance(failure.value, FCMAuthenticationError) or \ + isinstance(failure.value, TimeoutError) or \ isinstance(failure.value, ConnectError): raise failure.value self.logger.error("FCMv1Client failure: {}".format(failure.value)) diff --git a/autopush/tests/test_fcmclient.py b/autopush/tests/test_fcmclient.py new file mode 100644 index 00000000..bd4dd46c --- /dev/null +++ b/autopush/tests/test_fcmclient.py @@ -0,0 +1,103 @@ +import json + +import pytest +import treq +from mock import Mock + +from oauth2client.service_account import ServiceAccountCredentials +from twisted.internet.defer import Deferred, inlineCallbacks +from twisted.trial import unittest +from twisted.web.http_headers import Headers + +from autopush.exceptions import RouterException +from autopush.router.fcmv1client import FCMv1, FCMAuthenticationError + + +class FCMv1TestCase(unittest.TestCase): + + def setUp(self): + self._m_request = Deferred() + self._m_response = Mock(spec=treq.response._Response) + self._m_response.code = 200 + self._m_response.headers = Headers() + self._m_resp_text = Deferred() + self._m_response.text.return_value = self._m_resp_text + self.fcm = FCMv1(project_id="fcm_test") + self.fcm._sender = Mock(spec=treq.request) + self.fcm.svc_cred = Mock(spec=ServiceAccountCredentials) + atoken = Mock() + atoken.access_token = "access_token" + self.fcm.svc_cred.get_access_token.return_value = atoken + self.fcm._sender.return_value = self._m_request + self.m_payload = {"ttl": 60, "data_message": "some content"} + self._success = { + u"name": (u'projects/fir-bridgetest/messages/' + u'0:1544652984769917%0aa51ebcf9fd7ecd') + } + self._failure = { + u'error': { + u'status': u'INVALID_ARGUMENT', + u'message': (u'The registration token is not a valid ' + u'FCM registration token'), + u'code': 400, + u'details': [ + { + u'errorCode': u'INVALID_ARGUMENT', + u'@type': (u'type.googleapis.com/google.firebase' + u'.fcm.v1.FcmError')}, + {u'fieldViolations': [ + {u'field': u'message.token', + u'description': (u'The registration token is not ' + u'a valid FCM registration token')}], + u'@type': u'type.googleapis.com/google.rpc.BadRequest'} + ] + } + } + + @inlineCallbacks + def test_send(self): + content = json.dumps(self._success) + self._m_resp_text.callback(content) + self._m_request.callback(self._m_response) + result = yield self.fcm.send("token", self.m_payload) + assert result.success == 1 + + @inlineCallbacks + def test_bad_reply(self): + self._m_response.code = 400 + content = json.dumps("Invalid JSON") + self._m_resp_text.callback(content) + self._m_request.callback(self._m_response) + with pytest.raises(RouterException) as ex: + yield self.fcm.send("token", self.m_payload) + assert ex.value.status_code == 500 + + @inlineCallbacks + def test_fail_400(self): + self._m_response.code = 400 + content = json.dumps(self._failure) + self._m_resp_text.callback(content) + self._m_request.callback(self._m_response) + with pytest.raises(RouterException) as ex: + yield self.fcm.send("token", self.m_payload) + assert ex.value.status_code == 500 + assert "Server error: INVALID_ARGUMENT:" in ex.value.message + + @inlineCallbacks + def test_fail_401(self): + self._m_response.code = 401 + content = "Unauthorized" + self._m_resp_text.callback(content) + self._m_request.callback(self._m_response) + with pytest.raises(FCMAuthenticationError): + yield self.fcm.send("token", self.m_payload) + + @inlineCallbacks + def test_fail_500(self): + self._m_response.code = 500 + content = "OMG" + self._m_response.headers.addRawHeader('Retry-After', 123) + self._m_resp_text.callback(content) + self._m_request.callback(self._m_response) + result = yield self.fcm.send("token", self.m_payload) + assert result.retry_after == 123 diff --git a/autopush/tests/test_router.py b/autopush/tests/test_router.py index 377808f4..af7f8acf 100644 --- a/autopush/tests/test_router.py +++ b/autopush/tests/test_router.py @@ -11,6 +11,7 @@ import treq from botocore.exceptions import ClientError from mock import Mock, PropertyMock, patch +from oauth2client.service_account import ServiceAccountCredentials from twisted.trial import unittest from twisted.internet.error import ( ConnectionRefusedError, @@ -37,7 +38,8 @@ GCMRouter, WebPushRouter, FCMRouter, - gcmclient) + gcmclient, + FCMv1Router, fcmv1client) from autopush.router.interface import RouterResponse, IRouter from autopush.tests import MockAssist from autopush.tests.support import test_db @@ -757,6 +759,340 @@ def test_register_invalid_token(self): app_id="invalid") +class FCMv1RouterTestCase(unittest.TestCase): + def setUp(self): + conf = AutopushConfig( + hostname="localhost", + statsd_host=None, + ) + self.fcm_config = {'max_data': 32, + 'ttl': 60, + 'version': 1, + # We specify 'None' here because we're going to + # mock out the actual service credential service. + # This should be a path to a valid service + # credential JSON file. + 'service_cred_path': None, + 'senderID': 'fir-bridgetest'} + self._m_request = Deferred() + self.response = Mock(spec=treq.response._Response) + self.response.code = 200 + self.response.headers = Headers() + self._m_resp_text = Deferred() + self.response.text.return_value = self._m_resp_text + self.response.content = json.dumps( + {u'name': (u'projects/fir-bridgetest/messages/' + u'0:1510011451922224%7a0e7efbaab8b7cc')}) + self.client = fcmv1client.FCMv1(project_id="SomeKey") + self.client._sender = Mock() + self.client.svc_cred = Mock(spec=ServiceAccountCredentials) + self.client._sender.return_value = self._m_request + self.router = FCMv1Router(conf, self.fcm_config, SinkMetrics()) + self.router.fcm = self.client + self.headers = {"content-encoding": "aesgcm", + "encryption": "test", + "encryption-key": "test"} + # Payloads are Base64-encoded. + self.notif = WebPushNotification( + uaid=uuid.UUID(dummy_uaid), + channel_id=uuid.UUID(dummy_chid), + data="q60d6g", + headers=self.headers, + ttl=200, + message_id=10, + ) + self.notif.cleanup_headers() + self.router_data = dict( + router_data=dict( + token="connect_data", + creds=dict(senderID="fir-bridgetest"))) + + def _set_content(self, content=None): + if content is None: + content = self.response.content + self._m_resp_text.callback(content) + self._m_request.callback(self.response) + + def _check_error_call(self, exc, code, response=None, errno=None): + assert isinstance(exc, RouterException) + assert exc.status_code == code + if errno is not None: + assert exc.errno == errno + assert self.client._sender.called + if response: + assert response in exc.response_body + self.flushLoggedErrors() + + @patch("autopush.router.fcmv1client.ServiceAccountCredentials") + def test_init(self, m_sac): + conf = AutopushConfig( + hostname="localhost", + statsd_host=None, + ) + m_sac.from_json_keyfile_name.side_effect = IOError + with pytest.raises(IOError): + FCMv1Router(conf, + {"service_cred_path": "invalid_path", + "senderID": "fir-bridgetest", + "version": 1}, + SinkMetrics()) + + def test_register(self): + router_data = {"token": "fir-bridgetest"} + self.router.register( + "uaid", router_data=router_data, app_id="fir-bridgetest") + # Check the information that will be recorded for this user + assert router_data == { + "token": "fir-bridgetest", + "creds": {"senderID": "fir-bridgetest"}} + + def test_register_bad(self): + with pytest.raises(RouterException): + self.router.register("uaid", router_data={}, app_id="") + with pytest.raises(RouterException): + self.router.register("uaid", router_data={}, app_id=None) + with pytest.raises(RouterException): + self.router.register( + "uaid", + router_data={"token": "abcd1234"}, + app_id="invalid123") + + def test_route_notification(self): + d = self.router.route_notification(self.notif, self.router_data) + self._set_content() + + def check_results(result): + assert isinstance(result, RouterResponse) + assert self.client._sender.called + # Make sure the data was encoded as base64 + payload = json.loads(self.client._sender.call_args[1]['data']) + data = payload['message']['android']['data'] + assert data['body'] == 'q60d6g' + assert data['enc'] == 'test' + assert data['chid'] == dummy_chid + assert data['enckey'] == 'test' + assert data['con'] == 'aesgcm' + d.addCallback(check_results) + return d + + def test_ttl_none(self): + self.notif = WebPushNotification( + uaid=uuid.UUID(dummy_uaid), + channel_id=uuid.UUID(dummy_chid), + data="q60d6g", + headers=self.headers, + ttl=None + ) + self.notif.cleanup_headers() + self._set_content() + d = self.router.route_notification(self.notif, self.router_data) + + def check_results(result): + assert isinstance(result, RouterResponse) + assert result.status_code == 201 + assert result.logged_status == 200 + assert "TTL" in result.headers + assert self.client._sender.called + # Make sure the data was encoded as base64 + payload = json.loads(self.client._sender.call_args[1]['data']) + data = payload['message']['android']['data'] + assert data['body'] == 'q60d6g' + assert data['enc'] == 'test' + assert data['chid'] == dummy_chid + assert data['enckey'] == 'test' + assert data['con'] == 'aesgcm' + # use the defined min TTL + assert payload['message']['android']['ttl'] == "60s" + d.addCallback(check_results) + return d + + def test_ttl_high(self): + self.notif = WebPushNotification( + uaid=uuid.UUID(dummy_uaid), + channel_id=uuid.UUID(dummy_chid), + data="q60d6g", + headers=self.headers, + ttl=5184000 + ) + self.notif.cleanup_headers() + self._set_content() + d = self.router.route_notification(self.notif, self.router_data) + + def check_results(result): + assert isinstance(result, RouterResponse) + assert self.client._sender.called + # Make sure the data was encoded as base64 + payload = json.loads(self.client._sender.call_args[1]['data']) + data = payload['message']['android']['data'] + assert data['body'] == 'q60d6g' + assert data['enc'] == 'test' + assert data['chid'] == dummy_chid + assert data['enckey'] == 'test' + assert data['con'] == 'aesgcm' + # use the defined min TTL + assert payload['message']['android']['ttl'] == "2419200s" + d.addCallback(check_results) + return d + + def test_long_data(self): + bad_notif = WebPushNotification( + uaid=uuid.UUID(dummy_uaid), + channel_id=uuid.UUID(dummy_chid), + data="\x01abcdefghijklmnopqrstuvwxyz0123456789", + headers=self.headers, + ttl=200 + ) + self._set_content() + + with pytest.raises(RouterException) as ex: + self.router.route_notification(bad_notif, self.router_data) + + assert isinstance(ex.value, RouterException) + assert ex.value.status_code == 413 + assert ex.value.errno == 104 + + def test_route_crypto_notification(self): + del(self.notif.headers['encryption_key']) + self.notif.headers['crypto_key'] = 'crypto' + self._set_content() + + d = self.router.route_notification(self.notif, self.router_data) + + def check_results(result): + assert isinstance(result, RouterResponse) + assert self.client._sender.called + d.addCallback(check_results) + return d + + def test_router_notification_gcm_auth_error(self): + self.response.code = 401 + self._set_content() + d = self.router.route_notification(self.notif, self.router_data) + + def check_results(fail): + self._check_error_call(fail.value, 500, "Server error", 901) + d.addBoth(check_results) + return d + + def test_router_notification_gcm_other_error(self): + self._m_request.errback(Failure(Exception)) + d = self.router.route_notification(self.notif, self.router_data) + + def check_results(fail): + self._check_error_call(fail.value, 500, "Server error") + d.addBoth(check_results) + return d + + def test_router_notification_connection_error(self): + + self._m_request.errback(Failure(ConnectError("oh my!"))) + d = self.router.route_notification(self.notif, self.router_data) + + def check_results(fail): + self._check_error_call(fail.value, 502, "Server error", 902) + d.addBoth(check_results) + return d + + def test_router_notification_fcm_error(self): + self.response.code = 400 + self.response.content = json.dumps({ + u'error': { + u'status': u'INVALID_ARGUMENT', + u'message': (u'The registration token is not a valid ' + u'FCM registration token'), + u'code': 400, + u'details': [ + { + u'errorCode': u'INVALID_ARGUMENT', + u'@type': (u'type.googleapis.com/google.firebase' + u'.fcm.v1.FcmError')}, + {u'fieldViolations': [ + {u'field': u'message.token', + u'description': (u'The registration token is not ' + u'a valid FCM registration token')}], + u'@type': u'type.googleapis.com/google.rpc.BadRequest'} + ] + } + }) + self.router.metrics = Mock() + self._set_content() + d = self.router.route_notification(self.notif, self.router_data) + + def check_results(fail): + assert self.router.metrics.increment.called + assert self.router.metrics.increment.call_args[0][0] == ( + 'notification.bridge.error') + self.router.metrics.increment.call_args[1]['tags'].sort() + assert self.router.metrics.increment.call_args[1]['tags'] == [ + 'platform:fcmv1', 'reason:server_error'] + assert "INVALID_ARGUMENT" in fail.value.message + self._check_error_call(fail.value, 500) + d.addBoth(check_results) + return d + + def test_router_no_token(self): + uaid_data = dict( + router_data=dict( + token=None, + creds=dict( + senderID="fir-bridgetest"))) + with pytest.raises(RouterException): + self.router.route_notification(self.notif, uaid_data) + + def test_router_timeout(self): + self.router.metrics = Mock() + + def timeout(*args, **kwargs): + self._m_request.errback(Failure(TimeoutError())) + return self._m_request + + self.client._sender.side_effect = timeout + d = self.router.route_notification(self.notif, self.router_data) + + def check_results(fail): + assert self.router.metrics.increment.called + assert self.router.metrics.increment.call_args[0][0] == ( + 'notification.bridge.error') + self.router.metrics.increment.call_args[1]['tags'].sort() + assert self.router.metrics.increment.call_args[1]['tags'] == [ + 'platform:fcmv1', 'reason:timeout'] + + d.addBoth(check_results) + return d + + def test_router_unknown_err(self): + self.router.metrics = Mock() + + def timeout(*args, **kwargs): + self._m_request.errback(Failure(Exception())) + return self._m_request + + self.client._sender.side_effect = timeout + d = self.router.route_notification(self.notif, self.router_data) + + def check_results(fail): + assert isinstance(fail.value, RouterException) + + d.addBoth(check_results) + return d + + def test_amend(self): + router_data = {"token": "fir-bridgetest"} + self.router.register("uaid", router_data=router_data, + app_id="fir-bridgetest") + resp = {"key": "value"} + self.router.amend_endpoint_response( + resp, self.router_data.get('router_data')) + assert {"key": "value", "senderid": "fir-bridgetest"} == resp + + def test_register_invalid_token(self): + with pytest.raises(RouterException): + self.router.register( + uaid="uaid", + router_data={"token": "invalid"}, + app_id="invalid") + + class FCMRouterTestCase(unittest.TestCase): @patch("pyfcm.FCMNotification", spec=pyfcm.FCMNotification) diff --git a/autopush/tests/test_z_main.py b/autopush/tests/test_z_main.py index e04de4a1..ca42588b 100644 --- a/autopush/tests/test_z_main.py +++ b/autopush/tests/test_z_main.py @@ -13,6 +13,7 @@ from twisted.internet.defer import Deferred from twisted.trial import unittest as trialtest import hyper +import oauth2client import hyper.tls import autopush.db @@ -290,6 +291,7 @@ class TestArg(AutopushConfig): fcm_collapsekey = "collapse" fcm_senderid = '12345' fcm_auth = 'abcde' + fcm_service_cred_path = '' ssl_key = "keys/server.crt" ssl_cert = "keys/server.key" ssl_dh_param = None @@ -403,7 +405,11 @@ def test_bad_client_certs(self): @patch('autopush.router.apns2.HTTP20Connection', spec=hyper.HTTP20Connection) @patch('hyper.tls', spec=hyper.tls) + @patch('autopush.router.fcmv1client.ServiceAccountCredentials', + spec=oauth2client.service_account.ServiceAccountCredentials) def test_conf(self, *args): + self.TestArg.fcm_service_cred_path = "some/file.json" + self.TestArg.fcm_project_id = "fir_testbridge" conf = AutopushConfig.from_argparse(self.TestArg) app = EndpointApplication(conf, resource=autopush.tests.boto_resource) @@ -420,6 +426,14 @@ def test_conf(self, *args): assert app.routers["adm"].router_conf['dev']['client_secret'] == \ "deadbeef0000decafbad1111" + self.TestArg.fcm_service_cred_path = "" + self.TestArg.fcm_project_id = "" + conf = AutopushConfig.from_argparse(self.TestArg) + assert conf.router_conf['fcm']['version'] == 0 + app = EndpointApplication(conf, + resource=autopush.tests.boto_resource) + assert app.routers["fcm"].router_conf["version"] == 0 + def test_bad_senders(self): old_list = self.TestArg.senderid_list self.TestArg.senderid_list = "{}"