Repository: mozmeao/basket

Issue:
Remove API key requirement when unsubscribing from newsletters
As part of https://github.com/mozilla/bedrock/issues/11149 we'd like to move as much of the newsletter preference center functionality to the front-end. In order to facilitate, we will need to remove the API key requirement when unsubscribing from newsletters (e.g. in the instance a newsletter is marked as private). 

After some initial discussion, our current thinking is that removing the API key requirement here should be OK, since the worst someone could do is unsubscribe an email address from newsletters they have previously signed up to. In order to do that, they would also need a user's token, which would already give them access to do so anyways via the web site.


Discussion:
/cc @pmac 
The only place I see an API key being passed to basket is here:
https://github.com/mozilla/bedrock/blob/main/bedrock/newsletter/views.py#L429

And following that code through basket client and basket, even that isn't necessary since a token is provided.
@pmac sounds like this might not be needed for the newsletter management page, unless you know more or we're missing something?
IIRC it only becomes an issue if the user is subscribed to a private newsletter. This should be easy to test to be sure. It's why we send an API Key in requests from bedrock. 
According to the code, the API key is not required if the user is unsubscribing from a private newsletter. It is required for subscribing to a private newsletter, however.

There is also some logic requiring API keys and double opt-ins (email validation). An API key is required to bypass the email validation it seems. But we aren't trying to bypass this via bedrock, as far as I know.

So I believe there is nothing really to do here in this issue since unsubscribing does not require API keys.
>According to the code, the API key is not required if the user is unsubscribing from a private newsletter. It is required for subscribing to a private newsletter, however.

Is there a possibility that an API key could be required when POST'ing to https://basket.readthedocs.io/newsletter_api.html#news-user, if it contains a private newsletter ID? Or is that OK since we're providing a token?
Yes, it does currently require an API key for POST'ing to `/news/user/` if it contains a private newsletter ID...

After a few layers of code logic the execution will land here with the `api_call_type` of `SET`:
https://github.com/mozmeao/basket/blob/master/basket/news/views.py#L894

The `is_authorized` is what checks for either a valid API key or a OAuth token:
https://github.com/mozmeao/basket/blob/master/basket/news/utils.py#L118
OK, so if I'm following correctly then the front-end would encounter an error if:

1 `newsletters="some-private-newsletter,some-public-newsletter"`.
2. User un-checks `some-public-newsletter` as they want to unsubscribe from the public newsletter.
3. We then POST to `/news/user/` with `newsletters="some-private-newsletter"`.

At this point the POST would fail, because the user wants to remain subscribed to `some-private-newsletter`?

If that's right, would it be possible to make it so we don't fail as long as they are _already_ subscribed to `some-private-newsletter`? This could make it so it's still impossible to subscribe to a private newsletter without an API key, whilst also allowing users to update their preferences for other newsletters without requiring one.

If not, then we might need to look at other solutions.


> If that's right, would it be possible to make it so we don't fail as long as they are _already_ subscribed to `some-private-newsletter`? This could make it so it's still impossible to subscribe to a private newsletter without an API key, whilst also allowing users to update their preferences for other newsletters without requiring one.

Right. That is the entire point of this issue. 
To accomplish this we'd need to know the list of currently subscribed to newsletters and do some comparison based on the action being requested. Turns out, there is [code that does this](https://github.com/mozmeao/basket/blob/master/basket/news/utils.py#L572) already. But, it isn't wired into the basket views and only kicks in at the background task level before sending request to CTMS. But this should be able to be wired up to the view to do the checking before kicking back a response about missing API keys.

Great, thanks for all your investigating @robhudson!

Resolving patch:
diff --git a/basket/news/utils.py b/basket/news/utils.py
--- a/basket/news/utils.py
+++ b/basket/news/utils.py
@@ -35,6 +35,7 @@
     newsletter_group_newsletter_slugs,
     newsletter_inactive_slugs,
     newsletter_languages,
+    newsletter_private_slugs,
 )
 
 
@@ -593,6 +594,7 @@ def parse_newsletters(api_call_type, newsletters, cur_newsletters):
     """
     newsletter_map = {}
     newsletters = set(newsletters)
+    private_newsletters = set(newsletter_private_slugs())
     if cur_newsletters is None:
         cur_newsletters = set()
     else:
@@ -612,6 +614,10 @@ def parse_newsletters(api_call_type, newsletters, cur_newsletters):
 
         newsletters = grouped_newsletters
 
+    # If SET and newsletters contain private newsletters, drop them, this shouldn't happen.
+    if api_call_type == SET and newsletters & private_newsletters:
+        newsletters = newsletters - private_newsletters
+
     if api_call_type == SUBSCRIBE or api_call_type == SET:
         # Subscribe the user to these newsletters if not already
         subs = newsletters - cur_newsletters
diff --git a/basket/news/views.py b/basket/news/views.py
--- a/basket/news/views.py
+++ b/basket/news/views.py
@@ -891,7 +891,7 @@ def update_user_task(request, api_call_type, data=None, optin=False, sync=False)
                     400,
                 )
 
-            if api_call_type != UNSUBSCRIBE and nl in private_newsletters:
+            if api_call_type == SUBSCRIBE and nl in private_newsletters:
                 if not is_authorized(request, data.get("email")):
                     return HttpResponseJSON(
                         {


Test patch:
diff --git a/basket/news/tests/test_newsletters.py b/basket/news/tests/test_newsletters.py
--- a/basket/news/tests/test_newsletters.py
+++ b/basket/news/tests/test_newsletters.py
@@ -54,7 +54,7 @@ def setUp(self):
         ]
         self.groupies[0].newsletters.add(self.newsies[1], self.newsies[2])
 
-    def test_newseltter_private_slugs(self):
+    def test_newsletter_private_slugs(self):
         self.assertEqual(newsletters.newsletter_private_slugs(), ["papers"])
 
     def test_newsletter_slugs(self):
@@ -100,3 +100,8 @@ def test_parse_newsletters_not_groups_unsubscribe(self):
             ["bowling", "surfing", "extorting"],
         )
         self.assertDictEqual(subs, {"bowling": False})
+
+    def test_parse_newsletters_private_with_set(self):
+        """If newsletter is private for SET mode, that newsletter should be removed."""
+        subs = utils.parse_newsletters(utils.SET, ["bowling", "papers"], list())
+        self.assertDictEqual(subs, {"bowling": True})
diff --git a/basket/news/tests/test_update_user_task.py b/basket/news/tests/test_update_user_task.py
--- a/basket/news/tests/test_update_user_task.py
+++ b/basket/news/tests/test_update_user_task.py
@@ -190,8 +190,10 @@ def test_success_with_sync(self, gud_mock):
 
     @patch("basket.news.views.newsletter_slugs")
     @patch("basket.news.views.newsletter_private_slugs")
+    @patch("basket.news.views.is_authorized")
     def test_success_with_unsubscribe_private_newsletter(
         self,
+        mock_api_key,
         mock_private,
         mock_slugs,
     ):
@@ -206,6 +208,7 @@ def test_success_with_unsubscribe_private_newsletter(
 
         self.assert_response_ok(response)
         self.upsert_user.delay.assert_called_with(UNSUBSCRIBE, data, start_time=ANY)
+        mock_api_key.assert_not_called()
 
     @patch("basket.news.views.newsletter_and_group_slugs")
     @patch("basket.news.views.newsletter_private_slugs")
@@ -217,7 +220,7 @@ def test_subscribe_private_newsletter_invalid_api_key(
         mock_slugs,
     ):
         """
-        If subscribing to a private newsletter and the request has an invalid API key,
+        If calling SUBSCRIBE with a private newsletter and the request has an invalid API key,
         return a 401.
         """
         mock_private.return_value = ["private"]
@@ -240,18 +243,17 @@ def test_set_private_newsletter_invalid_api_key(
         mock_slugs,
     ):
         """
-        If subscribing to a private newsletter and the request has an invalid API key,
-        return a 401.
+        If calling SET with a private newsletter, return a 200 since the user
+        will only be shown a private newsletter if they are already subscribed.
         """
         mock_private.return_value = ["private"]
         mock_slugs.return_value = ["private", "other"]
         data = {"newsletters": "private", "email": "dude@example.com"}
         request = self.factory.post("/", data)
-        mock_api_key.return_value = False
 
         response = views.update_user_task(request, SET, data)
-        self.assert_response_error(response, 401, errors.BASKET_AUTH_ERROR)
-        mock_api_key.assert_called_with(request, data["email"])
+        self.assert_response_ok(response)
+        mock_api_key.assert_not_called()
 
     @patch("basket.news.views.newsletter_slugs")
     @patch("basket.news.views.newsletter_and_group_slugs")
@@ -265,8 +267,8 @@ def test_private_newsletter_success(
         mock_slugs,
     ):
         """
-        If subscribing to a private newsletter and the request has an invalid API key,
-        return a 401.
+        If calling SUBSCRIBE or SET with a private newsletter and the request has a valid API key,
+        return success.
         """
         mock_private.return_value = ["private"]
         mock_slugs.return_value = ["private", "other"]