diff --git a/docs/development/journalist_api.rst b/docs/development/journalist_api.rst index 0eab1ed9f7..8bfcc0cd08 100644 --- a/docs/development/journalist_api.rst +++ b/docs/development/journalist_api.rst @@ -357,6 +357,9 @@ Requires authentication. Clients are expected to encrypt replies prior to submission to the server. Replies should be encrypted to the public key of the source. +Including the ``uuid`` field in the request is optional. Clients may want to +pre-set the ``uuid`` so they can track in-flight messages. + .. code:: sh POST /api/v1/sources//replies @@ -366,6 +369,7 @@ with the reply in the request body: .. code:: json { + "uuid": "0bc588dd-f613-4999-b21e-1cebbd9adc2c", "reply": "-----BEGIN PGP MESSAGE-----[...]-----END PGP MESSAGE-----" } @@ -379,7 +383,8 @@ Response 201 created (application/json): } The returned ``uuid`` field is the UUID of the reply and can be used to -reference this reply later. +reference this reply later. If the client set the ``uuid`` in the request, +this will have the same value. Replies that do not contain a GPG encrypted message will be rejected: diff --git a/securedrop/journalist_app/api.py b/securedrop/journalist_app/api.py index 79de784473..fe3b4e3686 100644 --- 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 diff --git a/securedrop/tests/test_journalist_api.py b/securedrop/tests/test_journalist_api.py index adadb6a181..dfccc61daa 100644 --- 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