diff --git a/microsetta_private_api/admin/admin_impl.py b/microsetta_private_api/admin/admin_impl.py index d4c889cbc..e46ae95f3 100644 --- a/microsetta_private_api/admin/admin_impl.py +++ b/microsetta_private_api/admin/admin_impl.py @@ -35,6 +35,7 @@ from werkzeug.exceptions import Unauthorized from microsetta_private_api.qiita import qclient from microsetta_private_api.repo.interested_user_repo import InterestedUserRepo +from microsetta_private_api.repo.removal_queue_repo import RemovalQueueRepo def search_barcode(token_info, sample_barcode): @@ -614,6 +615,15 @@ def list_campaigns(token_info): return jsonify(campaigns), 200 +def list_removal_queue(token_info): + validate_admin_access(token_info) + + with Transaction() as t: + repo = RemovalQueueRepo(t) + requests = repo.get_all_account_removal_requests() + return jsonify(requests), 200 + + def post_campaign_information(body, token_info): validate_admin_access(token_info) @@ -853,6 +863,40 @@ def delete_account(account_id, token_info): return None, 204 +def ignore_removal_request(account_id, token_info): + validate_admin_access(token_info) + + with Transaction() as t: + rq_repo = RemovalQueueRepo(t) + try: + # remove the user from the queue, noting the admin who allowed it + # and the time the action was performed. + rq_repo.update_queue(account_id, token_info['email'], 'ignored') + t.commit() + except RepoException as e: + raise e + + return None, 204 + + +def allow_removal_request(account_id, token_info): + validate_admin_access(token_info) + + with Transaction() as t: + rq_repo = RemovalQueueRepo(t) + + try: + # remove the user from the queue, noting the admin who allowed it + # and the time the action was performed. + rq_repo.update_queue(account_id, token_info['email'], 'deleted') + t.commit() + except RepoException as e: + raise e + + # delete the user + return delete_account(account_id, token_info) + + def get_vioscreen_sample_to_user(token_info): validate_admin_access(token_info) with Transaction() as t: diff --git a/microsetta_private_api/api/__init__.py b/microsetta_private_api/api/__init__.py index 0500f5dee..a3b1d5560 100644 --- a/microsetta_private_api/api/__init__.py +++ b/microsetta_private_api/api/__init__.py @@ -3,6 +3,12 @@ read_account, update_account, check_email_match, _verify_jwt, _verify_jwt_mock ) + +from ._removal_queue import ( + check_request_remove_account, request_remove_account, + cancel_request_remove_account +) + from ._consent import ( render_consent_doc, check_consent_signature, @@ -69,6 +75,9 @@ 'read_account', 'update_account', 'check_email_match', + 'request_remove_account', + 'cancel_request_remove_account', + 'check_request_remove_account', 'render_consent_doc', 'create_source', 'read_source', diff --git a/microsetta_private_api/api/_account.py b/microsetta_private_api/api/_account.py index 2e8ab7a41..673fb1db8 100644 --- a/microsetta_private_api/api/_account.py +++ b/microsetta_private_api/api/_account.py @@ -1,11 +1,8 @@ import uuid - import jwt from flask import jsonify from jwt import InvalidTokenError - from werkzeug.exceptions import Unauthorized, Forbidden, NotFound - from microsetta_private_api.api.literals import AUTHROCKET_PUB_KEY, \ INVALID_TOKEN_MSG, JWT_ISS_CLAIM_KEY, JWT_SUB_CLAIM_KEY, \ JWT_EMAIL_CLAIM_KEY, ACCT_NOT_FOUND_MSG, CRONJOB_PUB_KEY diff --git a/microsetta_private_api/api/_removal_queue.py b/microsetta_private_api/api/_removal_queue.py new file mode 100644 index 000000000..469e8fb12 --- /dev/null +++ b/microsetta_private_api/api/_removal_queue.py @@ -0,0 +1,39 @@ +from flask import jsonify +from microsetta_private_api.repo.transaction import Transaction +from microsetta_private_api.repo.removal_queue_repo import RemovalQueueRepo +from microsetta_private_api.api._account import _validate_account_access + + +def check_request_remove_account(account_id, token_info): + # raises 401 if method fails + _validate_account_access(token_info, account_id) + + with Transaction() as t: + rq_repo = RemovalQueueRepo(t) + status = rq_repo.check_request_remove_account(account_id) + result = {'account_id': account_id, 'status': status} + return jsonify(result), 200 + + +def request_remove_account(account_id, token_info): + # raises 401 if method fails + _validate_account_access(token_info, account_id) + + with Transaction() as t: + rq_repo = RemovalQueueRepo(t) + rq_repo.request_remove_account(account_id) + t.commit() + + return jsonify(code=200, message="Request Accepted"), 200 + + +def cancel_request_remove_account(account_id, token_info): + # raises 401 if method fails + _validate_account_access(token_info, account_id) + + with Transaction() as t: + rq_repo = RemovalQueueRepo(t) + rq_repo.cancel_request_remove_account(account_id) + t.commit() + + return jsonify(code=200, message="Request Accepted"), 200 diff --git a/microsetta_private_api/api/microsetta_private_api.yaml b/microsetta_private_api/api/microsetta_private_api.yaml index 3dc1bd4bb..3b52125c5 100644 --- a/microsetta_private_api/api/microsetta_private_api.yaml +++ b/microsetta_private_api/api/microsetta_private_api.yaml @@ -168,6 +168,51 @@ paths: '422': $ref: '#/components/responses/422UnprocessableEntity' + '/accounts/{account_id}/removal_queue': + get: + operationId: microsetta_private_api.api.check_request_remove_account + tags: + - Account + summary: Verify user requested account to be removed + description: Verify user requested account to be removed + parameters: + - $ref: '#/components/parameters/account_id' + responses: + '200': + description: Successfully requested for removal request status + '401': + $ref: '#/components/responses/401Unauthorized' + put: + operationId: microsetta_private_api.api.request_remove_account + tags: + - Account + summary: Request account to be removed + description: Request account to be removed + parameters: + - $ref: '#/components/parameters/account_id' + responses: + '200': + description: Successfully requested for account to be removed + '401': + $ref: '#/components/responses/401Unauthorized' + '422': + $ref: '#/components/responses/422UnprocessableEntity' + delete: + operationId: microsetta_private_api.api.cancel_request_remove_account + tags: + - Account + summary: Cancel request for account to be removed + description: Cancel request for account to be removed + parameters: + - $ref: '#/components/parameters/account_id' + responses: + '200': + description: Successfully canceled request + '401': + $ref: '#/components/responses/401Unauthorized' + '422': + $ref: '#/components/responses/422UnprocessableEntity' + '/accounts/{account_id}/check_duplicate_source': post: operationId: microsetta_private_api.api.check_duplicate_source_name @@ -2128,6 +2173,51 @@ paths: '401': $ref: '#/components/responses/401Unauthorized' + '/admin/account_removal/{account_id}': + put: + operationId: microsetta_private_api.admin.admin_impl.ignore_removal_request + tags: + - Admin + parameters: + - $ref: '#/components/parameters/account_id' + responses: + '200': + description: Removes account from queue w/out deleting it. + content: + application/json: + schema: + type: array + delete: + operationId: microsetta_private_api.admin.admin_impl.allow_removal_request + tags: + - Admin + parameters: + - $ref: '#/components/parameters/account_id' + responses: + '200': + description: Updates queue, log before calling delete_account() + content: + application/json: + schema: + type: array + + '/admin/account_removal/list': + get: + operationId: microsetta_private_api.admin.admin_impl.list_removal_queue + tags: + - Admin + summary: Return a list of all account removal requests + description: Return a list of all account removal requests + responses: + '200': + description: Array of account removal requests + content: + application/json: + schema: + type: array + '401': + $ref: '#/components/responses/401Unauthorized' + '/admin/scan/{sample_barcode}': post: # Note: We might want to be able to differentiate system administrator operations diff --git a/microsetta_private_api/api/tests/test_api.py b/microsetta_private_api/api/tests/test_api.py index ed6dfeb2a..400ba4230 100644 --- a/microsetta_private_api/api/tests/test_api.py +++ b/microsetta_private_api/api/tests/test_api.py @@ -10,6 +10,7 @@ from urllib.parse import urlencode from unittest import TestCase from math import isclose +from uuid import uuid4 import microsetta_private_api.server from microsetta_private_api import localization from microsetta_private_api.model.preparation import Preparation @@ -613,6 +614,19 @@ def tearDown(self): # is there some better pattern I can use to split up what should be # a 'with' call? self.client.__exit__(None, None, None) + + # references to dummy admins need to be removed from + # ag.account_removal_log before delete_dummy_accts() will be + # successful. + ids = (ACCT_ID_1, ACCT_ID_2, ACCT_ID_3) + with Transaction() as t: + cur = t.cursor() + cur.execute("DELETE FROM ag.account_removal_log WHERE account_id" + " IN %s", (ids,)) + cur.execute("DELETE FROM ag.account_removal_log WHERE admin_id IN" + " %s", (ids,)) + t.commit() + delete_dummy_accts() def run_query_and_content_required_field_test(self, url, action, @@ -1288,6 +1302,178 @@ def test_email_match_fail_404(self): self.assertEqual(response.status_code, 404) # endregion account/email_match tests + def test_request_account_removal(self): + # create a dummy account and then confirm it is not present in the + # delete-queue. + dummy_acct_id = create_dummy_acct(create_dummy_1=True) + + # this admin account needs to be created, but the metadata for it + # is already associated w/make_headers(FAKE_TOKEN_ADMIN). + create_dummy_acct(create_dummy_1=False, iss=ACCT_MOCK_ISS_3, + sub=ACCT_MOCK_SUB_3, dummy_is_admin=True) + + # check to see if account_id is already in the removal queue. It + # shouldn't be. + response = self.client.get( + f'/api/accounts/{dummy_acct_id}/removal_queue', + headers=self.dummy_auth) + + # Like similar functions, GET will return status-code 200 as long as + # the query was successful. Whether it is in the queue (True) or not + # (False) is stored in the response data under the 'status' key. + self.assertEqual(200, response.status_code) + self.assertFalse(json.loads(response.data)['status']) + + # submit a request for this account to be removed. + response = self.client.put( + f'/api/accounts/{dummy_acct_id}/removal_queue', + headers=self.dummy_auth) + + self.assertEqual(200, response.status_code) + + self.assertEqual(json.loads(response.data)['message'], + "Request Accepted") + + # Verify it is now present in the queue. + response = self.client.get( + f'/api/accounts/{dummy_acct_id}/removal_queue', + headers=self.dummy_auth) + + self.assertEqual(200, response.status_code) + + self.assertTrue(json.loads(response.data)['status']) + + # try to request a second time. Verify that an error is returned + # instead. + response = self.client.put( + f'/api/accounts/{dummy_acct_id}/removal_queue', + headers=self.dummy_auth) + + self.assertEqual(422, response.status_code) + + self.assertEqual(json.loads(response.data)['message'], + "Account is already in removal queue") + + # attempt to remove the account id from the account removal queue + # and cancel the deletion request. + response = self.client.delete( + f'/api/accounts/{dummy_acct_id}/removal_queue', + headers=self.dummy_auth) + + self.assertEqual(200, response.status_code) + + # Verify it is not present in the queue. + response = self.client.get( + f'/api/accounts/{dummy_acct_id}/removal_queue', + headers=self.dummy_auth) + + self.assertEqual(200, response.status_code) + + self.assertFalse(json.loads(response.data)['status']) + + # attempt to remove the request again and confirm that an error + # is returned. + response = self.client.delete( + f'/api/accounts/{dummy_acct_id}/removal_queue', + headers=self.dummy_auth) + + self.assertEqual(422, response.status_code) + self.assertEqual(json.loads(response.data)['message'], + "Account is not in removal queue") + + # now let's test having an admin ignore the request. + # first, put the user back in the queue. + response = self.client.put( + f'/api/accounts/{dummy_acct_id}/removal_queue', + headers=self.dummy_auth) + self.assertEqual(200, response.status_code) + self.assertEqual(json.loads(response.data)['message'], + "Request Accepted") + + response = self.client.put( + f'/api/admin/account_removal/{dummy_acct_id}', + headers=make_headers(FAKE_TOKEN_ADMIN)) + + self.assertEqual(204, response.status_code) + + # Note that the behavior for an admin ignoring the request is + # identical to a user cancelling the request except that there will be + # a log entry in ag.account_removal_log if an admin ignored the + # request. Either way the request is removed from the queue. + with Transaction() as t: + cur = t.cursor() + cur.execute("""SELECT account_id, reviewed_on, CURRENT_TIMESTAMP + FROM ag.account_removal_log ORDER BY id DESC""") + (a_id, r_date, n_date) = cur.fetchone() + + # verify that the account in the log belongs to our dummy user. + self.assertEqual(dummy_acct_id, a_id) + + # check that the admin just completed this review. Verify that + # the review occured in the last minute. + self.assertTrue((n_date - r_date).total_seconds() < 60) + + # attempt to ignore the request again and confirm that an error + # is returned. + response = self.client.put( + f'/api/admin/account_removal/{dummy_acct_id}', + headers=make_headers(FAKE_TOKEN_ADMIN)) + + self.assertEqual(422, response.status_code) + + self.assertEqual(json.loads(response.data)['message'], + "Account is not in removal queue") + + # generate a random UUID4 value and assume that it is not a valid + # account number because of its statistical uniqueness. Attempt to + # query for an account w/this number and confirm that it will not be + # found. + + uri = '/api/accounts/%s/removal_queue' % uuid4() + + response = self.client.get(uri, headers=self.dummy_auth) + + self.assertEqual(404, response.status_code) + + results = json.loads(response.data) + self.assertIn('detail', results) + self.assertEqual(results['detail'], 'Account not found') + + # push the dummy_acct_id account back onto the queue and attempt to + # delete the account. Then confirm that the account no longer exists: + + # mimic a user pressing the 'Delete Account' button in their + # accounts page. This will put the account_id for dummy_acct_id back + # in the queue. + response = self.client.put( + f'/api/accounts/{dummy_acct_id}/removal_queue', + headers=self.dummy_auth) + + # confirm that the operation was a success. + self.assertEqual(200, response.status_code) + + # Attempt to delete user dummy_acct_id using the account_removal + # endpoint. Note this endpoint wraps our standard account_delete() + # functionality; it deletes the id from the delete-queue and logs the + # deletion in a separate 'log' table, before calling account_delete(). + response = self.client.delete( + f'/api/admin/account_removal/{dummy_acct_id}', + headers=make_headers(FAKE_TOKEN_ADMIN)) + + # confirm that the operation was a success. + self.assertEqual(204, response.status_code) + + # if the operation was a success, use the same functionality we + # previously tested to confirm that the user we just deleted + # (dummy_acct_id) is no longer in the system. + response = self.client.get( + '/api/accounts/%s?%s' % + (dummy_acct_id, self.default_lang_querystring), + headers=make_headers(FAKE_TOKEN_ADMIN)) + + # confirm the user was not found. + self.assertEqual(404, response.status_code) + @pytest.mark.usefixtures("client") class SourceTests(ApiTests): diff --git a/microsetta_private_api/db/patches/0111.sql b/microsetta_private_api/db/patches/0111.sql new file mode 100644 index 000000000..ee2dde3e3 --- /dev/null +++ b/microsetta_private_api/db/patches/0111.sql @@ -0,0 +1,21 @@ +CREATE TABLE ag.delete_account_queue ( + id SERIAL PRIMARY KEY, + account_id uuid UNIQUE NOT NULL REFERENCES ag.account(id), + requested_on timestamptz default current_timestamp +); + +CREATE TYPE DISPOSITION_TYPE AS ENUM ('ignored', 'deleted'); +CREATE TABLE ag.account_removal_log ( + id SERIAL PRIMARY KEY, + -- account_id is not referenced to ag.account(id) so that the account may + -- be deleted and the record of it kept afterward. + account_id uuid NOT NULL, + admin_id uuid NOT NULL REFERENCES ag.account(id), + -- although a method exists to remove entries from this table, the + -- intention is to record the admin who accepted or denied the request + -- here. This means that an account_id may appear more than once if the + -- user makes multiple requests. + disposition DISPOSITION_TYPE, + requested_on timestamptz, + reviewed_on timestamptz default current_timestamp +); diff --git a/microsetta_private_api/model/removal_queue_requests.py b/microsetta_private_api/model/removal_queue_requests.py new file mode 100644 index 000000000..5d630ad10 --- /dev/null +++ b/microsetta_private_api/model/removal_queue_requests.py @@ -0,0 +1,17 @@ +from microsetta_private_api.model.model_base import ModelBase + + +class RemovalQueueRequest(ModelBase): + def __init__(self, id, account_id, email, first_name, last_name, + requested_on): + self.id = id + self.account_id = account_id + self.email = email + self.first_name = first_name + self.last_name = last_name + + # 2022-07-27 17:15:33.937458-07:00 -> 2022-07-27 17:15:33 + self.requested_on = str(requested_on).split('.')[0] + + def to_api(self): + return self.__dict__.copy() diff --git a/microsetta_private_api/repo/removal_queue_repo.py b/microsetta_private_api/repo/removal_queue_repo.py new file mode 100644 index 000000000..4b83a45d2 --- /dev/null +++ b/microsetta_private_api/repo/removal_queue_repo.py @@ -0,0 +1,86 @@ +from microsetta_private_api.repo.base_repo import BaseRepo +from microsetta_private_api.exceptions import RepoException + + +class RemovalQueueRepo(BaseRepo): + def __init__(self, transaction): + super().__init__(transaction) + + def _check_account_is_admin(self, admin_email): + with self._transaction.cursor() as cur: + cur.execute("SELECT count(id) FROM account WHERE account_type = " + "'admin' and email = %s", (admin_email,)) + count = cur.fetchone()[0] + + return False if count == 0 else True + + def check_request_remove_account(self, account_id): + with self._transaction.cursor() as cur: + cur.execute("SELECT count(id) FROM delete_account_queue WHERE " + "account_id = %s", (account_id,)) + count = cur.fetchone()[0] + + return False if count == 0 else True + + def request_remove_account(self, account_id): + with self._transaction.cursor() as cur: + cur.execute("SELECT account_id from delete_account_queue where " + "account_id = %s", (account_id,)) + result = cur.fetchone() + + if result is not None: + raise RepoException("Account is already in removal queue") + + cur.execute( + "INSERT INTO delete_account_queue (account_id) VALUES (%s)", + (account_id,)) + + def cancel_request_remove_account(self, account_id): + if not self.check_request_remove_account(account_id): + raise RepoException("Account is not in removal queue") + + with self._transaction.cursor() as cur: + cur.execute("DELETE FROM delete_account_queue WHERE account_id =" + " %s", (account_id,)) + + def update_queue(self, account_id, admin_email, disposition): + if not self.check_request_remove_account(account_id): + raise RepoException("Account is not in removal queue") + + if not self._check_account_is_admin(admin_email): + raise RepoException("That is not an admin email address") + + if disposition not in ['ignored', 'deleted']: + raise RepoException("Disposition must be either 'ignored' or " + "'deleted'") + + with self._transaction.cursor() as cur: + # preserve the time account removal was requested by the user. + cur.execute("SELECT requested_on FROM delete_account_queue " + "WHERE account_id = %s", (account_id,)) + requested_on = cur.fetchone()[0] + + # get the account id of the admin that authorized this account + # to be deleted or ignored. + cur.execute("SELECT id FROM account WHERE email = %s", + (admin_email,)) + admin_id = cur.fetchone()[0] + + # add an entry to the log detailing who reviewed the account + # and when. + cur.execute("INSERT INTO account_removal_log (account_id, " + "admin_id, disposition, requested_on) VALUES (%s," + " %s, %s, %s)", (account_id, admin_id, disposition, + requested_on)) + + # delete the entry from queue. Note that reviewed entries are + # deleted from the queue whether or not they were approved + # (deleted) or not (ignored). + + # For clarity: + # allow_removal_request() will call this method and then call + # delete_account() immediately after. + # ignore_removal_request() will call this method and do nothing + # after. + cur.execute("DELETE FROM delete_account_queue WHERE account_id" + " = %s", (account_id,)) diff --git a/microsetta_private_api/repo/tests/test_removal_queue_repo.py b/microsetta_private_api/repo/tests/test_removal_queue_repo.py new file mode 100644 index 000000000..c366fbb2e --- /dev/null +++ b/microsetta_private_api/repo/tests/test_removal_queue_repo.py @@ -0,0 +1,241 @@ +import datetime +import unittest +from microsetta_private_api.repo.removal_queue_repo import RemovalQueueRepo +from microsetta_private_api.repo.transaction import Transaction +from microsetta_private_api.repo.account_repo import AccountRepo +from microsetta_private_api.model.account import Account +from microsetta_private_api.model.address import Address +from psycopg2.errors import InvalidTextRepresentation, ForeignKeyViolation +from microsetta_private_api.exceptions import RepoException + + +class RemovalQueueTests(unittest.TestCase): + ACC_ID = '500d79fc-99e8-4c48-b911-a72005c9e0c9' + ADM_ID = '500d79fc-99e8-4c48-b911-a72005c9e0ca' + + # a UUID generated from parts of existing UUIDs. Statistically + # unlikely to be an existing account, but is of the correct + # form. + bad_id = '54a236d8-8439-48f2-b273-0c42fb82278c' + + def setUp(self): + with Transaction() as t: + acct_repo = AccountRepo(t) + + # Set up test account with sources + self.acc = Account(RemovalQueueTests.ACC_ID, + "uniqueid@somedomain.org", + "standard", + "https://www.somedomain.org", + "1234ThisIsNotARealSub", + "Charles", + "C", + Address( + "9500 Gilman Drive", + "La Jolla", + "CA", + 92093, + "US" + ), + "en_US") + + acct_repo.create_account(self.acc) + + self.adm = Account(RemovalQueueTests.ADM_ID, + "somebodyelse@somedomain.org", + "admin", + "https://www.somedomain2.org", + "1234ThisIsNotARealSub", + "Charles2", + "C2", + Address( + "95002 Gilman Drive", + "La Jolla", + "CA", + 92093, + "US" + ), + "en_US") + + acct_repo.create_account(self.adm) + + t.commit() + + def tearDown(self): + ids = (RemovalQueueTests.ACC_ID, RemovalQueueTests.ADM_ID) + with Transaction() as t: + cur = t.cursor() + cur.execute("DELETE FROM ag.delete_account_queue WHERE account_id" + " in %s", (ids,)) + cur.execute("DELETE FROM ag.account_removal_log WHERE account_id" + " in %s", (ids,)) + t.commit() + + with Transaction() as t: + acct_repo = AccountRepo(t) + acct_repo.delete_account(self.acc.id) + acct_repo.delete_account(self.adm.id) + t.commit() + + def test_check_request_remove_account(self): + with Transaction() as t: + rqr = RemovalQueueRepo(t) + + # this newly-generated account should not already be in the + # queue. Confirm this is true. + self.assertFalse(rqr.check_request_remove_account(self.acc.id)) + + # use request_remove_account() to push the valid account onto + # the queue. + rqr.request_remove_account(self.acc.id) + + # assume request_remove_account() succeeded. + # check_request_remove_account() should return True. + self.assertTrue(rqr.check_request_remove_account(self.acc.id)) + + def test_check_request_remove_account_invalid_ids(self): + with Transaction() as t: + rqr = RemovalQueueRepo(t) + + # a UUID generated from parts of existing UUIDs. Statistically + # unlikely to be an existing account, but is of the correct + # form. + bad_id = '54a236d8-8439-48f2-b273-0c42fb82278c' + self.assertFalse(rqr.check_request_remove_account(bad_id)) + + # request removal for an obviously invalid account_id. + with self.assertRaises(InvalidTextRepresentation): + rqr.check_request_remove_account('XXXX') + + def test_request_remove_account(self): + with Transaction() as t: + rqr = RemovalQueueRepo(t) + # use request_remove_account() to push the valid account onto + # the queue. + rqr.request_remove_account(self.acc.id) + + # assume check_request_remove_account() works correctly. + # verify account is now in the queue. + self.assertTrue(rqr.check_request_remove_account(self.acc.id)) + + def test_request_remove_account_failure(self): + with Transaction() as t: + rqr = RemovalQueueRepo(t) + + # remove a valid account twice + rqr.request_remove_account(self.acc.id) + with self.assertRaises(RepoException): + rqr.request_remove_account(self.acc.id) + + # remove a non-existant id. + with self.assertRaises(ForeignKeyViolation): + rqr.request_remove_account(RemovalQueueTests.bad_id) + + def test_cancel_request_remove_account(self): + with Transaction() as t: + rqr = RemovalQueueRepo(t) + # use request_remove_account() to push the valid account onto + # the queue. + rqr.request_remove_account(self.acc.id) + + # assume check_request_remove_account() works correctly. + # verify account is now in the queue. + self.assertTrue(rqr.check_request_remove_account(self.acc.id)) + + # cancel the request to delete the account. + rqr.cancel_request_remove_account(self.acc.id) + + # verify account is not in the queue. + self.assertFalse(rqr.check_request_remove_account(self.acc.id)) + + def test_cancel_request_remove_account_failure(self): + with Transaction() as t: + rqr = RemovalQueueRepo(t) + + # use request_remove_account() to push the valid account onto + # the queue. + with self.assertRaises(InvalidTextRepresentation): + rqr.cancel_request_remove_account('XXXX') + + with Transaction() as t: + rqr = RemovalQueueRepo(t) + + # remove a non-existant id. + with self.assertRaises(RepoException): + rqr.cancel_request_remove_account(RemovalQueueTests.bad_id) + + with Transaction() as t: + rqr = RemovalQueueRepo(t) + + # use request_remove_account() to push the valid account onto + # the queue. + rqr.request_remove_account(self.acc.id) + + # cancel the request to delete the account twice. + rqr.cancel_request_remove_account(self.acc.id) + with self.assertRaises(RepoException): + rqr.cancel_request_remove_account(self.acc.id) + + def test_update_queue_success(self): + with Transaction() as t: + rqr = RemovalQueueRepo(t) + + # push the standard account onto the queue. + rqr.request_remove_account(self.acc.id) + + # update_queue should migrate the relevant information to the + # ag.account_removal_log table and delete the entry from the + # queue table. + rqr.update_queue(self.acc.id, self.adm.email, 'deleted') + + # confirm that the account id is no longer in the queue table. + self.assertFalse(rqr.check_request_remove_account(self.acc.id)) + + t.commit() + + # confirm using SQL that the info exists in ag.account_removal_log. + with Transaction() as t: + with t.cursor() as cur: + cur.execute("SELECT account_id, admin_id, disposition, " + "requested_on, reviewed_on FROM " + "ag.account_removal_log") + rows = cur.fetchall() + self.assertEqual(len(rows), 1) + for account_id, admin_id, disposition, requested_on,\ + reviewed_on in rows: + # note this loop should only execute once. + self.assertEqual(account_id, self.acc.id) + self.assertEqual(admin_id, self.adm.id) + self.assertEqual(disposition, 'deleted') + now = datetime.datetime.now().timestamp() + # the requested_on time should be not far in the past. + # assume it is not NULL and is less than a minute ago. + self.assertLess(now - requested_on.timestamp(), 60) + # ditto for reviewed_on. + self.assertLess(now - reviewed_on.timestamp(), 60) + + def test_update_queue_failure(self): + with Transaction() as t: + rqr = RemovalQueueRepo(t) + + with self.assertRaises(InvalidTextRepresentation): + rqr.update_queue('XXXX', self.adm.email, 'ignored') + + with Transaction() as t: + rqr = RemovalQueueRepo(t) + + # push the standard account onto the queue. + rqr.request_remove_account(self.acc.id) + + # ensure that an Error is raised when an invalid admin + # email address is passed. + with self.assertRaises(RepoException): + rqr.update_queue(self.acc.id, 'XXXX', 'ignored') + + # ensure that an Error is raised when disposition is None or + # emptry string. + with self.assertRaises(RepoException): + rqr.update_queue(self.acc.id, self.adm.email, None) + + with self.assertRaises(RepoException): + rqr.update_queue(self.acc.id, self.adm.email, '')