Repository: freedomofpress/securedrop

Issue:
allow clients to set UUID for a reply
## Description

Currently we return the UUID of a reply after the reply has been stored. In order for clients with spotty connectivity to be able to store a reply locally and then confirm it has been stored by the server, they need to have some sort of ID that is sent to the server and then returned. We might as well make this the UUID.

## User Stories

As a developer working on the client, I want to be able to send a bunch of replies over a very slow / spotty connection and know which ones were stored and which ones failed.



Discussion:

Resolving patch:
diff --git a/securedrop/journalist_app/api.py b/securedrop/journalist_app/api.py
--- a/securedrop/journalist_app/api.py
+++ b/securedrop/journalist_app/api.py
@@ -3,7 +3,9 @@
 from datetime import datetime, timedelta
 from flask import abort, Blueprint, current_app, jsonify, request
 from functools import wraps
+from sqlalchemy.exc import IntegrityError
 from os import path
+from uuid import UUID
 from werkzeug.exceptions import default_exceptions  # type: ignore
 
 from db import db
@@ -243,9 +245,27 @@ def all_source_replies(source_uuid):
             filename = path.basename(filename)
 
             reply = Reply(user, source, filename)
-            db.session.add(reply)
-            db.session.add(source)
-            db.session.commit()
+
+            reply_uuid = data.get('uuid', None)
+            if reply_uuid is not None:
+                # check that is is parseable
+                try:
+                    UUID(reply_uuid)
+                except ValueError:
+                    abort(400, "'uuid' was not a valid UUID")
+                reply.uuid = reply_uuid
+
+            try:
+                db.session.add(reply)
+                db.session.add(source)
+                db.session.commit()
+            except IntegrityError as e:
+                db.session.rollback()
+                if 'UNIQUE constraint failed: replies.uuid' in str(e):
+                    abort(409, 'That UUID is already in use.')
+                else:
+                    raise e
+
             return jsonify({'message': 'Your reply has been stored',
                             'uuid': reply.uuid}), 201
 


Test patch:
diff --git a/securedrop/tests/test_journalist_api.py b/securedrop/tests/test_journalist_api.py
--- a/securedrop/tests/test_journalist_api.py
+++ b/securedrop/tests/test_journalist_api.py
@@ -2,9 +2,10 @@
 import hashlib
 import json
 import os
+import random
 
 from pyotp import TOTP
-from uuid import UUID
+from uuid import UUID, uuid4
 
 from flask import current_app, url_for
 from itsdangerous import TimedJSONWebSignatureSerializer
@@ -15,6 +16,8 @@
 os.environ['SECUREDROP_ENV'] = 'test'  # noqa
 from utils.api_helper import get_api_headers
 
+random.seed('◔ ⌣ ◔')
+
 
 def test_unauthenticated_user_gets_all_endpoints(journalist_app):
     with journalist_app.test_client() as app:
@@ -792,3 +795,58 @@ def test_empty_json_20X(journalist_app, journalist_api_token, test_journo,
                                 headers=get_api_headers(journalist_api_token))
 
             assert response.status_code in (200, 201)
+
+
+def test_set_reply_uuid(journalist_app, journalist_api_token, test_source):
+    msg = '-----BEGIN PGP MESSAGE-----\nwat\n-----END PGP MESSAGE-----'
+    reply_uuid = str(uuid4())
+    req_data = {'uuid': reply_uuid, 'reply': msg}
+
+    with journalist_app.test_client() as app:
+        # first check that we can set a valid UUID
+        source_uuid = test_source['uuid']
+        resp = app.post(url_for('api.all_source_replies',
+                                source_uuid=source_uuid),
+                        data=json.dumps(req_data),
+                        headers=get_api_headers(journalist_api_token))
+        assert resp.status_code == 201
+        assert resp.json['uuid'] == reply_uuid
+
+        reply = Reply.query.filter_by(uuid=reply_uuid).one_or_none()
+        assert reply is not None
+
+        len_of_replies = len(Source.query.get(test_source['id']).replies)
+
+        # next check that requesting with the same UUID does not succeed
+        source_uuid = test_source['uuid']
+        resp = app.post(url_for('api.all_source_replies',
+                                source_uuid=source_uuid),
+                        data=json.dumps(req_data),
+                        headers=get_api_headers(journalist_api_token))
+        assert resp.status_code == 409
+
+        new_len_of_replies = len(Source.query.get(test_source['id']).replies)
+
+        assert new_len_of_replies == len_of_replies
+
+        # check setting null for the uuid field doesn't break
+        req_data['uuid'] = None
+        source_uuid = test_source['uuid']
+        resp = app.post(url_for('api.all_source_replies',
+                                source_uuid=source_uuid),
+                        data=json.dumps(req_data),
+                        headers=get_api_headers(journalist_api_token))
+        assert resp.status_code == 201
+
+        new_uuid = resp.json['uuid']
+        reply = Reply.query.filter_by(uuid=new_uuid).one_or_none()
+        assert reply is not None
+
+        # check setting invalid values for the uuid field doesn't break
+        req_data['uuid'] = 'not a uuid'
+        source_uuid = test_source['uuid']
+        resp = app.post(url_for('api.all_source_replies',
+                                source_uuid=source_uuid),
+                        data=json.dumps(req_data),
+                        headers=get_api_headers(journalist_api_token))
+        assert resp.status_code == 400