Repository: mozmeao/basket

Issue:
Add new endpoint for FxA to lookup users and redirect to preference center
We're still working out whether this will need to accept only an FxA ID, or also another random token, but it will need to do the following:

1. Look up the user using the token provided in SFMC FxA table
2. Using the email returned, look up the user in SFDC
    * If no SFDC record found for user, create one, and redirect to the email pref center on bedrock using the new token
    * If SFDC record is found, use the token in that record to redirect to the email pref center on bedrock
3. Include a query param (e.g. `?fxa=1`) on the links to the email pref center so that a new lookup of the user in SFDC is not required. The request for user data in that case should not ask for FxA status.


Discussion:
@shane-tomlinson yo. Is there something I can do to get started on this bit or is there more for you to do on your side? I know the desire is simply to have a good URL we can put in emails or on websites that will result in a user seeing their email preferences page. Whether that URL starts at basket or FxA doesn't matter to me.
> yo. Is there something I can do to get started on this bit or is there more for you to do on your side? 

I'll comment more tomorrow. The TL;DR is I think the basic plan we came up with the other day is still sound, with some slight modifications. To avoid being the blocker, I'm trying to rope either @dannycoates or @vbudhram into taking over (and of course need to talk to them about it!)
@vbudhram is going to take over this work. Copying some context from the email:

> I think there is a path forward that provides the security we want with a reasonable UI. If we add support for [`prompt=none`](https://github.com/mozilla/fxa/issues/640), we could have users bounce off of an endpoint on Basket that sets up the OAuth state token and binds it to the session, then redirects to FxA with ?prompt=none. If the user is signed into FxA, we redirect back to Basket with OAuth credentials without requiring user interaction. I have put added a new diagram below with this flow. There are a lot of redirects, but this approach could work as a generic solution to other problems we have too (securing the payments page).
>
> FxA->Basket (setup OAuth CSRF protection)->FxA w/ prompt=none->Basket w/ OAuth credentials.
>
> There are some details that are being glossed over, but I really think it could work. 


![basket-from-communication-prefs-real-state-token](https://user-images.githubusercontent.com/848085/57950846-4fe62100-78e0-11e9-9549-44fb7ed971b5.png)


Note: This flow should add a default subscription to `firefox-accounts-journey` for any new CRM records created.
> Note: This flow should add a default subscription to firefox-accounts-journey for any new CRM records created.

Would we add `firefox-accounts-journey` on our side, or can you handle that automatically on your side when the `verified` event arrives?
> Would we add `firefox-accounts-journey` on our side, or can you handle that automatically on your side when the `verified` event arrives?

I can handle it. It's going to be default `True` for everyone created during an FxA flow, so basket can do that both from the verified events and when a new CRM record is created from a link to the pref center.
#185 should not have closed this.

Resolving patch:
diff --git a/basket/news/views.py b/basket/news/views.py
--- a/basket/news/views.py
+++ b/basket/news/views.py
@@ -140,22 +140,22 @@ def fxa_start(request):
 
 
 @require_safe
-def fxa_success(request):
+def fxa_callback(request):
     # remove state from session to prevent multiple attempts
     error_url = 'https://{}/newsletter/fxa-error/'.format(settings.FXA_EMAIL_PREFS_DOMAIN)
     sess_state = request.session.pop('fxa_state', None)
     if sess_state is None:
-        statsd.incr('news.views.fxa_success.error.no_state')
+        statsd.incr('news.views.fxa_callback.error.no_state')
         return HttpResponseRedirect(error_url)
 
     code = request.GET.get('code')
     state = request.GET.get('state')
     if not (code and state):
-        statsd.incr('news.views.fxa_success.error.no_code_state')
+        statsd.incr('news.views.fxa_callback.error.no_code_state')
         return HttpResponseRedirect(error_url)
 
     if sess_state != state:
-        statsd.incr('news.views.fxa_success.error.no_state_match')
+        statsd.incr('news.views.fxa_callback.error.no_state_match')
         return HttpResponseRedirect(error_url)
 
     fxa_oauth, fxa_profile = get_fxa_clients()
@@ -163,7 +163,7 @@ def fxa_success(request):
         access_token = fxa_oauth.trade_code(code)['access_token']
         user_profile = fxa_profile.get_profile(access_token)
     except Exception:
-        statsd.incr('news.views.fxa_success.error.fxa_comm')
+        statsd.incr('news.views.fxa_callback.error.fxa_comm')
         sentry_client.captureException()
         return HttpResponseRedirect(error_url)
 
diff --git a/basket/urls.py b/basket/urls.py
--- a/basket/urls.py
+++ b/basket/urls.py
@@ -5,7 +5,7 @@
 
 from watchman import views as watchman_views
 
-from basket.news.views import subscribe_main, subscribe_json, fxa_start, fxa_success
+from basket.news.views import subscribe_main, subscribe_json, fxa_start, fxa_callback
 
 
 urlpatterns = [
@@ -20,7 +20,7 @@
     urlpatterns.append(url(r'^subscribe/?$', subscribe_main))
     urlpatterns.append(url(r'^subscribe\.json$', subscribe_json))
     urlpatterns.append(url(r'^fxa/?$', fxa_start))
-    urlpatterns.append(url(r'^fxa/callback/?$', fxa_success))
+    urlpatterns.append(url(r'^fxa/callback/?$', fxa_callback))
 
 if settings.DISABLE_ADMIN:
     urlpatterns.append(


Test patch:
diff --git a/basket/news/tests/test_views.py b/basket/news/tests/test_views.py
--- a/basket/news/tests/test_views.py
+++ b/basket/news/tests/test_views.py
@@ -2,11 +2,12 @@
 
 import json
 
+from django.conf import settings
 from django.core.cache import cache
 from django.core.urlresolvers import reverse
 from django.test import TestCase
 from django.test import override_settings
-from django.test.client import RequestFactory
+from django.test.client import RequestFactory, Client
 
 from basket import errors
 from email_validator import EmailSyntaxError
@@ -1200,3 +1201,179 @@ def test_requires_all_fields(self):
         self.assertEqual(resp.status_code, 401, resp.content)
         data = json.loads(resp.content)
         self.assertEqual(errors.BASKET_USAGE_ERROR, data['code'])
+
+
+@override_settings(FXA_EMAIL_PREFS_DOMAIN='www.mozilla.org')
+class FxAPrefCenterOauthCallbackTests(ViewsPatcherMixin, TestCase):
+    def setUp(self):
+        self.client = Client()
+        self._patch_views('get_user_data')
+        self._patch_views('get_fxa_clients')
+        self._patch_views('statsd')
+        self._patch_views('sentry_client')
+        self._patch_views('upsert_contact')
+
+    def test_no_session_state(self):
+        """Should return a redirect to error page"""
+        resp = self.client.get('/fxa/callback/')
+        assert resp.status_code == 302
+        assert resp['location'] == 'https://www.mozilla.org/newsletter/fxa-error/'
+        self.statsd.incr.assert_called_with('news.views.fxa_callback.error.no_state')
+
+    def test_no_returned_state(self):
+        """Should return a redirect to error page"""
+        session = self.client.session
+        session['fxa_state'] = 'thedude'
+        session.save()
+        resp = self.client.get('/fxa/callback/', {'code': 'thecode'})
+        assert resp.status_code == 302
+        assert resp['location'] == 'https://www.mozilla.org/newsletter/fxa-error/'
+        self.statsd.incr.assert_called_with('news.views.fxa_callback.error.no_code_state')
+
+    def test_no_returned_code(self):
+        """Should return a redirect to error page"""
+        session = self.client.session
+        session['fxa_state'] = 'thedude'
+        session.save()
+        resp = self.client.get('/fxa/callback/', {'state': 'thedude'})
+        assert resp.status_code == 302
+        assert resp['location'] == 'https://www.mozilla.org/newsletter/fxa-error/'
+        self.statsd.incr.assert_called_with('news.views.fxa_callback.error.no_code_state')
+
+    def test_session_and_request_state_no_match(self):
+        """Should return a redirect to error page"""
+        session = self.client.session
+        session['fxa_state'] = 'thedude'
+        session.save()
+        # no state
+        resp = self.client.get('/fxa/callback/', {'code': 'thecode', 'state': 'walter'})
+        assert resp.status_code == 302
+        assert resp['location'] == 'https://www.mozilla.org/newsletter/fxa-error/'
+        self.statsd.incr.assert_called_with('news.views.fxa_callback.error.no_state_match')
+
+    def test_fxa_communication_issue(self):
+        """Should return a redirect to error page"""
+        fxa_oauth_mock = Mock()
+        self.get_fxa_clients.return_value = fxa_oauth_mock, Mock()
+        fxa_oauth_mock.trade_code.side_effect = RuntimeError
+        session = self.client.session
+        session['fxa_state'] = 'thedude'
+        session.save()
+        # no state
+        resp = self.client.get('/fxa/callback/', {'code': 'thecode', 'state': 'thedude'})
+        assert resp.status_code == 302
+        assert resp['location'] == 'https://www.mozilla.org/newsletter/fxa-error/'
+        self.statsd.incr.assert_called_with('news.views.fxa_callback.error.fxa_comm')
+        self.sentry_client.captureException.assert_called()
+
+    def test_existing_user(self):
+        """Should return a redirect to email pref center"""
+        fxa_oauth_mock, fxa_profile_mock = Mock(), Mock()
+        fxa_oauth_mock.trade_code.return_value = {'access_token': 'access-token'}
+        fxa_profile_mock.get_profile.return_value = {'email': 'dude@example.com'}
+        self.get_fxa_clients.return_value = fxa_oauth_mock, fxa_profile_mock
+        self.get_user_data.return_value = {'token': 'the-token'}
+        session = self.client.session
+        session['fxa_state'] = 'thedude'
+        session.save()
+        # no state
+        resp = self.client.get('/fxa/callback/', {'code': 'thecode', 'state': 'thedude'})
+        assert resp.status_code == 302
+        fxa_oauth_mock.trade_code.assert_called_with('thecode')
+        fxa_profile_mock.get_profile.assert_called_with('access-token')
+        self.statsd.incr.assert_not_called()
+        assert resp['location'] == 'https://www.mozilla.org/newsletter/existing/the-token/?fxa=1'
+        self.get_user_data.assert_called_with(email='dude@example.com')
+
+    def test_new_user_with_locale(self):
+        """Should return a redirect to email pref center"""
+        fxa_oauth_mock, fxa_profile_mock = Mock(), Mock()
+        fxa_oauth_mock.trade_code.return_value = {'access_token': 'access-token'}
+        fxa_profile_mock.get_profile.return_value = {
+            'email': 'dude@example.com',
+            'locale': 'en,en-US',
+        }
+        self.get_fxa_clients.return_value = fxa_oauth_mock, fxa_profile_mock
+        self.get_user_data.return_value = None
+        self.upsert_contact.return_value = 'the-new-token', True
+        session = self.client.session
+        session['fxa_state'] = 'thedude'
+        session.save()
+        # no state
+        resp = self.client.get('/fxa/callback/', {'code': 'thecode', 'state': 'thedude'})
+        assert resp.status_code == 302
+        fxa_oauth_mock.trade_code.assert_called_with('thecode')
+        fxa_profile_mock.get_profile.assert_called_with('access-token')
+        self.statsd.incr.assert_not_called()
+        assert resp['location'] == 'https://www.mozilla.org/newsletter/existing/the-new-token/?fxa=1'
+        self.get_user_data.assert_called_with(email='dude@example.com')
+        self.upsert_contact.assert_called_with(SUBSCRIBE, {
+            'email': 'dude@example.com',
+            'optin': True,
+            'format': 'H',
+            'newsletters': [settings.FXA_REGISTER_NEWSLETTER],
+            'source_url': settings.FXA_REGISTER_SOURCE_URL + '?utm_source=basket-fxa-oauth',
+            'lang': 'en',
+        }, None)
+
+    def test_new_user_without_locale(self):
+        """Should return a redirect to email pref center"""
+        fxa_oauth_mock, fxa_profile_mock = Mock(), Mock()
+        fxa_oauth_mock.trade_code.return_value = {'access_token': 'access-token'}
+        fxa_profile_mock.get_profile.return_value = {
+            'email': 'dude@example.com',
+        }
+        self.get_fxa_clients.return_value = fxa_oauth_mock, fxa_profile_mock
+        self.get_user_data.return_value = None
+        self.upsert_contact.return_value = 'the-new-token', True
+        session = self.client.session
+        session['fxa_state'] = 'thedude'
+        session.save()
+        # no state
+        resp = self.client.get('/fxa/callback/', {'code': 'thecode', 'state': 'thedude'})
+        assert resp.status_code == 302
+        fxa_oauth_mock.trade_code.assert_called_with('thecode')
+        fxa_profile_mock.get_profile.assert_called_with('access-token')
+        self.statsd.incr.assert_not_called()
+        assert resp['location'] == 'https://www.mozilla.org/newsletter/existing/the-new-token/?fxa=1'
+        self.get_user_data.assert_called_with(email='dude@example.com')
+        self.upsert_contact.assert_called_with(SUBSCRIBE, {
+            'email': 'dude@example.com',
+            'optin': True,
+            'format': 'H',
+            'newsletters': [settings.FXA_REGISTER_NEWSLETTER],
+            'source_url': settings.FXA_REGISTER_SOURCE_URL + '?utm_source=basket-fxa-oauth',
+        }, None)
+
+
+class FxAPrefCenterOauthStartTests(ViewsPatcherMixin, TestCase):
+    def setUp(self):
+        self.client = Client()
+        self._patch_views('get_fxa_clients')
+        self._patch_views('generate_fxa_state')
+
+    def test_get_redirect_url(self):
+        fxa_oauth_mock = Mock()
+        fxa_oauth_mock.get_redirect_url.return_value = good_redirect = 'https://example.com/oauth'
+        self.get_fxa_clients.return_value = fxa_oauth_mock, None
+        self.generate_fxa_state.return_value = 'the-dude-abides'
+        resp = self.client.get('/fxa/')
+        fxa_oauth_mock.get_redirect_url.assert_called_with(state='the-dude-abides',
+                                                           redirect_uri='http://testserver/fxa/callback/',
+                                                           scope='profile',
+                                                           email=None)
+        assert resp.status_code == 302
+        assert resp['location'] == good_redirect
+
+    def test_get_redirect_url_with_email(self):
+        fxa_oauth_mock = Mock()
+        fxa_oauth_mock.get_redirect_url.return_value = good_redirect = 'https://example.com/oauth'
+        self.get_fxa_clients.return_value = fxa_oauth_mock, None
+        self.generate_fxa_state.return_value = 'the-dude-abides'
+        resp = self.client.get('/fxa/?email=dude%40example.com')
+        fxa_oauth_mock.get_redirect_url.assert_called_with(state='the-dude-abides',
+                                                           redirect_uri='http://testserver/fxa/callback/',
+                                                           scope='profile',
+                                                           email='dude@example.com')
+        assert resp.status_code == 302
+        assert resp['location'] == good_redirect