From 83b7ab532056fafada6391a4326a87a2ce454370 Mon Sep 17 00:00:00 2001 From: Israel Barth Rubio Date: Fri, 17 Nov 2023 16:47:38 -0300 Subject: [PATCH] Add missing unit tests This commit adds an extensive set of unit tests to ensure all flask endpoints are working as expected. We are mainly interested in the HTTP status code and in the returned response bodies. In any case the unit tests are checking also that the underlying objects and methods are instantiated and called as expected. Besides that, we also do the following: * Add unit tests for all utilitary functions * Add unit tests for all functions used by the CLI * Add unit tests for the CLI References: BAR-132. Signed-off-by: Israel Barth Rubio --- .../pg_backup_api/tests/test_main.py | 156 +++++ pg_backup_api/pg_backup_api/tests/test_run.py | 123 ++++ .../tests/test_utility_controller.py | 551 ++++++++++++++++++ .../pg_backup_api/tests/test_utils.py | 195 +++++++ 4 files changed, 1025 insertions(+) create mode 100644 pg_backup_api/pg_backup_api/tests/test_main.py create mode 100644 pg_backup_api/pg_backup_api/tests/test_run.py create mode 100644 pg_backup_api/pg_backup_api/tests/test_utility_controller.py create mode 100644 pg_backup_api/pg_backup_api/tests/test_utils.py diff --git a/pg_backup_api/pg_backup_api/tests/test_main.py b/pg_backup_api/pg_backup_api/tests/test_main.py new file mode 100644 index 0000000..9f072f4 --- /dev/null +++ b/pg_backup_api/pg_backup_api/tests/test_main.py @@ -0,0 +1,156 @@ +# -*- coding: utf-8 -*- +# © Copyright EnterpriseDB UK Limited 2021-2023 +# +# This file is part of Postgres Backup API. +# +# Postgres Backup API is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Postgres Backup API is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Postgres Backup API. If not, see . + +"""Unit tests for the CLI.""" +from textwrap import dedent +from unittest.mock import MagicMock, patch + +import pytest + +from pg_backup_api.__main__ import main + + +_HELP_OUTPUT = { + "pg-backup-api --help": dedent("""\ + usage: pg-backup-api [-h] {serve,status,recovery} ... + + positional arguments: + {serve,status,recovery} + + options: + -h, --help show this help message and exit + + Postgres Backup API by EnterpriseDB (www.enterprisedb.com) +\ + """), + "pg-backup-api serve --help": dedent("""\ + usage: pg-backup-api serve [-h] [--port PORT] + + Start the REST API server. Listen for requests on '127.0.0.1', on the given port. + + options: + -h, --help show this help message and exit + --port PORT Port to bind to. +\ + """), # noqa: E501 + "pg-backup-api status --help": dedent("""\ + usage: pg-backup-api status [-h] [--port PORT] + + Check if the REST API server is up and running + + options: + -h, --help show this help message and exit + --port PORT Port to be checked. +\ + """), # noqa: E501 + "pg-backup-api recovery --help": dedent("""\ + usage: pg-backup-api recovery [-h] --server-name SERVER_NAME --operation-id OPERATION_ID + + Perform a 'barman recover' through the 'pg-backup-api'. Can only be run if a recover operation has been previously registered. + + options: + -h, --help show this help message and exit + --server-name SERVER_NAME + Name of the Barman server to be recovered. + --operation-id OPERATION_ID + ID of the operation in the 'pg-backup-api'. +\ + """), # noqa: E501 +} + +_COMMAND_FUNC = { + "pg-backup-api serve": "serve", + "pg-backup-api status": "status", + "pg-backup-api recovery --server-name SOME_SERVER --operation-id SOME_OP_ID": "recovery_operation", # noqa: E501 +} + + +@pytest.mark.parametrize("command", _HELP_OUTPUT.keys()) +def test_main_helper(command, capsys): + """Test :func:`main`. + + Ensure all the ``--help`` calls print the expected content to the console. + """ + with patch("sys.argv", command.split()), pytest.raises(SystemExit) as exc: + main() + + assert str(exc.value) == "0" + + assert capsys.readouterr().out == _HELP_OUTPUT[command] + + +@pytest.mark.parametrize("command", _COMMAND_FUNC.keys()) +@pytest.mark.parametrize("output", [None, "SOME_OUTPUT"]) +@pytest.mark.parametrize("success", [False, True]) +def test_main_funcs(command, output, success, capsys): + """Test :func:`main`. + + Ensure :func:`main` executes the expected functions, print the expected + messages, and exits with the expected codes. + """ + mock_controller = patch(f"pg_backup_api.__main__.{_COMMAND_FUNC[command]}") + mock_func = mock_controller.start() + + mock_func.return_value = (output, success) + + with patch("sys.argv", command.split()), pytest.raises(SystemExit) as exc: + main() + + mock_controller.stop() + + assert capsys.readouterr().out == (f"{output}\n" if output else "") + assert str(exc.value) == ("0" if success else "-1") + + +@patch("argparse.ArgumentParser.parse_args") +def test_main_with_func(mock_parse_args, capsys): + """Test :func:`main`. + + Ensure :func:`main` calls the function with the expected arguments, if a + command has a function associated with it. + """ + mock_parse_args.return_value.func = MagicMock() + mock_func = mock_parse_args.return_value.func + mock_func.return_value = ("SOME_OUTPUT", True) + + with pytest.raises(SystemExit) as exc: + main() + + capsys.readouterr() # so we don't write to stdout during unit tests + + mock_func.assert_called_once_with(mock_parse_args.return_value) + assert str(exc.value) == "0" + + +@patch("argparse.ArgumentParser.print_help") +@patch("argparse.ArgumentParser.parse_args") +def test_main_without_func(mock_parse_args, mock_print_help, capsys): + """Test :func:`main`. + + Ensure :func:`main` prints a helper if a command has no function associated + with it. + """ + delattr(mock_parse_args.return_value, "func") + + with pytest.raises(SystemExit) as exc: + main() + + capsys.readouterr() # so we don't write to stdout during unit tests + + mock_print_help.assert_called_once_with() + assert str(exc.value) == "0" diff --git a/pg_backup_api/pg_backup_api/tests/test_run.py b/pg_backup_api/pg_backup_api/tests/test_run.py new file mode 100644 index 0000000..255cc35 --- /dev/null +++ b/pg_backup_api/pg_backup_api/tests/test_run.py @@ -0,0 +1,123 @@ +# -*- coding: utf-8 -*- +# © Copyright EnterpriseDB UK Limited 2021-2023 +# +# This file is part of Postgres Backup API. +# +# Postgres Backup API is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Postgres Backup API is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Postgres Backup API. If not, see . + +"""Unit tests for functions used by the CLI.""" + +import argparse +from requests.exceptions import ConnectionError +from unittest.mock import MagicMock, patch, call + +import pytest + +from pg_backup_api.run import serve, status, recovery_operation + + +@pytest.mark.parametrize("port", [7480, 7481]) +@patch("pg_backup_api.run.output") +@patch("pg_backup_api.run.load_barman_config") +@patch("pg_backup_api.run.app") +def test_serve(mock_app, mock_load_config, mock_output, port): + """Test :func:`serve`. + + Ensure :func:`serve` performs the expected calls and return the expected + values. + """ + mock_output.AVAILABLE_WRITERS.__getitem__.return_value = MagicMock() + expected = mock_output.AVAILABLE_WRITERS.__getitem__.return_value + expected.return_value = MagicMock() + + args = argparse.Namespace(port=port) + + assert serve(args) == (mock_app.run.return_value, True) + + mock_load_config.assert_called_once_with() + mock_output.set_output_writer.assert_called_once_with( + expected.return_value, + ) + mock_app.run.assert_called_once_with(host="127.0.0.1", port=port) + + +@pytest.mark.parametrize("port", [7480, 7481]) +@patch("requests.get") +def test_status_ok(mock_request, port): + """Test :func:`status`. + + Ensure the expected ``GET`` request is performed, and that :func:`status` + returns `OK` when the API is available. + """ + args = argparse.Namespace(port=port) + + assert status(args) == ("OK", True) + + mock_request.assert_called_once_with(f"http://127.0.0.1:{port}/status") + + +@pytest.mark.parametrize("port", [7480, 7481]) +@patch("requests.get") +def test_status_failed(mock_request, port): + """Test :func:`status`. + + Ensure the expected ``GET`` request is performed, and that :func:`status` + returns an error message when the API is not available. + """ + args = argparse.Namespace(port=port) + + mock_request.side_effect = ConnectionError("Some Error") + + message = "The Postgres Backup API does not appear to be available." + assert status(args) == (message, False) + + mock_request.assert_called_once_with(f"http://127.0.0.1:{port}/status") + + +@pytest.mark.parametrize("server_name", ["SERVER_1", "SERVER_2"]) +@pytest.mark.parametrize("operation_id", ["OPERATION_1", "OPERATION_2"]) +@pytest.mark.parametrize("rc", [0, 1]) +@patch("pg_backup_api.run.RecoveryOperation") +def test_recovery_operation(mock_rec_op, server_name, operation_id, rc): + """Test :func:`recovery_operation`. + + Ensure the operation is created and executed, and that the expected values + are returned depending on the return code. + """ + args = argparse.Namespace(server_name=server_name, + operation_id=operation_id) + + mock_rec_op.return_value.run.return_value = ("SOME_OUTPUT", rc) + mock_write_output = mock_rec_op.return_value.write_output_file + mock_time_event = mock_rec_op.return_value.time_event_now + mock_read_job = mock_rec_op.return_value.read_job_file + + assert recovery_operation(args) == (mock_write_output.return_value, + rc == 0) + + mock_rec_op.assert_called_once_with(server_name, operation_id) + mock_rec_op.return_value.run.assert_called_once_with() + mock_time_event.assert_called_once_with() + mock_read_job.assert_called_once_with() + + # Make sure the expected content was added to `read_job_file` output before + # writing it to the output file. + assert len(mock_read_job.return_value.__setitem__.mock_calls) == 3 + mock_read_job.return_value.__setitem__.assert_has_calls([ + call('success', rc == 0), + call('end_time', mock_time_event.return_value), + call('output', "SOME_OUTPUT"), + ]) + + mock_write_output.assert_called_once_with(mock_read_job.return_value) diff --git a/pg_backup_api/pg_backup_api/tests/test_utility_controller.py b/pg_backup_api/pg_backup_api/tests/test_utility_controller.py new file mode 100644 index 0000000..1aeee3d --- /dev/null +++ b/pg_backup_api/pg_backup_api/tests/test_utility_controller.py @@ -0,0 +1,551 @@ +# -*- coding: utf-8 -*- +# © Copyright EnterpriseDB UK Limited 2021-2023 +# +# This file is part of Postgres Backup API. +# +# Postgres Backup API is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Postgres Backup API is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Postgres Backup API. If not, see . + +"""Unit tests for the REST API endpoints.""" +import json +from unittest.mock import Mock, MagicMock, patch + +import pytest + +from pg_backup_api.server_operation import (OperationServerConfigError, + OperationNotExists, + MalformedContent) + + +_HTTP_METHODS = {"DELETE", "GET", "PATCH", "POST", "PUT", "TRACE"} + + +@patch("pg_backup_api.server_operation.load_barman_config", MagicMock()) +@patch("pg_backup_api.logic.utility_controller.load_barman_config", + MagicMock()) +@patch("barman.__config__", MagicMock()) +class TestUtilityController: + """Run tests for the REST API endpoints.""" + + @pytest.fixture(scope="module") + def client(self): + """Mock :mod:`barman.output` and get a Flask testing client. + + :yield: a Flask testing client. + """ + with patch("pg_backup_api.run.load_barman_config", MagicMock()): + from pg_backup_api.run import app + app.config.update({ + "TESTING": True, + }) + + from barman import output + output.set_output_writer(output.AVAILABLE_WRITERS["json"]()) + + with app.test_client() as test_client: + with app.app_context(): + yield test_client + + def _ensure_http_methods_not_allowed(self, methods, path, client): + """Ensure none among *methods* are allowed when requesting *path*. + + :param methods: a set of methods to be tested. + :param path: the URL path to be requested. + :param client: a Flask testing client. + """ + for method in methods: + response = getattr(client, method.lower())(path) + assert response.status_code == 405 + expected = b"The method is not allowed for the requested URL." + assert expected in response.data + + @patch("pg_backup_api.logic.utility_controller.barman_diagnose", Mock()) + @patch.dict( + "pg_backup_api.logic.utility_controller.output._writer.json_output", + { + '_INFO': ['SOME', 'JSON', 'ENTRIES', '{"global":{"config":{}}}'], + }, + ) + def test_diagnose_ok(self, client): + """Test ``/diagnose`` endpoint. + + Ensure a ``GET`` request returns ``200`` and the expected JSON output. + """ + path = "/diagnose" + + response = client.get(path) + + assert response.status_code == 200 + assert response.data == b'{"global":{"config":{}}}\n' + + def test_diagnose_not_allowed(self, client): + """Test ``/diagnose`` endpoint. + + Ensure all other HTTP request methods return an error. + """ + path = "/diagnose" + self._ensure_http_methods_not_allowed(_HTTP_METHODS - {"GET"}, path, + client) + + def test_status_ok(self, client): + """Test ``/status`` endpoint. + + Ensure a ``GET`` request returns ``200`` and the expected output. + """ + path = "/status" + + response = client.get(path) + + assert response.status_code == 200 + assert response.data == b'"OK"' + + def test_status_not_allowed(self, client): + """Test ``/status`` endpoint. + + Ensure all other HTTP request methods return an error. + """ + path = "/status" + + self._ensure_http_methods_not_allowed(_HTTP_METHODS - {"GET"}, path, + client) + + @pytest.mark.parametrize("status", ["IN_PROGRESS", "DONE", "FAILED"]) + @patch("pg_backup_api.logic.utility_controller.OperationServer") + def test_servers_operation_id_get_ok(self, mock_op_server, status, client): + """Test ``/servers//operations/`` endpoint. + + Ensure a ``GET`` request returns ``200`` and the expected JSON output + according to the status of the operation. + """ + path = "/servers/SOME_SERVER_NAME/operations/SOME_OPERATION_ID" + + mock_op_server.return_value.config = object() + mock_get_status = mock_op_server.return_value.get_operation_status + + mock_get_status.return_value = status + + response = client.get(path) + + mock_op_server.assert_called_once_with("SOME_SERVER_NAME") + mock_get_status.assert_called_once_with("SOME_OPERATION_ID") + + assert response.status_code == 200 + expected = ( + '{"operation_id":"SOME_OPERATION_ID",' + f'"status":"{status}"}}\n' + ).encode() + assert response.data == expected + + @patch("pg_backup_api.logic.utility_controller.OperationServer") + def test_servers_operation_id_get_server_does_not_exist(self, + mock_op_server, + client): + """Test ``/servers//operations/`` endpoint. + + Ensure ``GET`` returns ``404`` if the Barman server doesn't exist. + """ + path = "/servers/SOME_SERVER_NAME/operations/SOME_OPERATION_ID" + + mock_op_server.side_effect = OperationServerConfigError("SOME_ISSUE") + + response = client.get(path) + assert response.status_code == 404 + assert response.data == b'{"error":"404 Not Found: SOME_ISSUE"}\n' + + @patch("pg_backup_api.logic.utility_controller.OperationServer") + def test_servers_operation_id_get_operation_does_not_exist(self, + mock_op_server, + client): + """Test ``/servers//operations/`` endpoint. + + Ensure ``GET`` returns ``404`` if the operation doesn't exist. + """ + path = "/servers/SOME_SERVER_NAME/operations/SOME_OPERATION_ID" + + mock_get_status = mock_op_server.return_value.get_operation_status + mock_op_server.return_value.config = object() + mock_get_status.side_effect = OperationNotExists("NOT_FOUND") + + response = client.get(path) + assert response.status_code == 404 + expected = b'{"error":"404 Not Found: Resource not found"}\n' + assert response.data == expected + + def test_servers_operation_id_get_not_allowed(self, client): + """Test ``/servers//operations/`` endpoint. + + Ensure all other HTTP request methods return an error. + """ + path = "/servers/SOME_SERVER_NAME/operations/SOME_OPERATION_ID" + self._ensure_http_methods_not_allowed(_HTTP_METHODS - {"GET"}, path, + client) + + @patch("pg_backup_api.logic.utility_controller.OperationServer") + def test_server_operation_get_ok(self, mock_op_server, client): + """Test ``/servers//operations`` endpoint. + + Ensure a ``GET`` request returns ``200`` and the expected JSON output. + """ + path = "/servers/SOME_SERVER_NAME/operations" + + mock_op_server.return_value.config = object() + mock_get_ops = mock_op_server.return_value.get_operations_list + mock_get_ops.return_value = [ + { + "id": "SOME_ID_1", + "type": "SOME_TYPE_1", + }, + { + "id": "SOME_ID_2", + "type": "SOME_TYPE_2", + }, + ] + + response = client.get(path) + + mock_op_server.assert_called_once_with("SOME_SERVER_NAME") + mock_get_ops.assert_called_once_with() + + assert response.status_code == 200 + data = json.dumps({"operations": mock_get_ops.return_value}) + data = data.replace(" ", "") + "\n" + expected = data.encode() + assert response.data == expected + + @patch("pg_backup_api.logic.utility_controller.OperationServer") + def test_server_operation_get_server_does_not_exist(self, mock_op_server, + client): + """Test ``/servers//operations`` endpoint. + + Ensure ``GET`` request returns ``404`` if Barman server doesn't exist. + """ + path = "/servers/SOME_SERVER_NAME/operations" + + mock_get_ops = mock_op_server.return_value.get_operations_list + mock_op_server.side_effect = OperationServerConfigError( + "SOME_ISSUE") + + response = client.get(path) + + mock_op_server.assert_called_once_with("SOME_SERVER_NAME") + mock_get_ops.assert_not_called() + + assert response.status_code == 404 + assert response.data == b'{"error":"404 Not Found: SOME_ISSUE"}\n' + + def test_server_operation_post_not_json(self, client): + """Test ``/servers//operations`` endpoint. + + Ensure ``POST`` request won't work without data in JSON format. + """ + path = "/servers/SOME_SERVER_NAME/operations" + + response = client.post(path, data={}) + + assert response.status_code == 415 + assert b"Unsupported Media Type" in response.data + + @patch("pg_backup_api.logic.utility_controller.OperationServer", Mock()) + @patch("pg_backup_api.logic.utility_controller.get_server_by_name") + @patch("pg_backup_api.logic.utility_controller.OperationType") + @patch("pg_backup_api.logic.utility_controller.parse_backup_id") + @patch("pg_backup_api.logic.utility_controller.Server") + @patch("pg_backup_api.logic.utility_controller.RecoveryOperation") + @patch("subprocess.Popen") + def test_server_operation_post_empty_json(self, mock_popen, mock_rec_op, + mock_server, mock_parse_id, + mock_op_type, mock_get_server, + client): + """Test ``/servers//operations`` endpoint. + + Ensure ``POST`` request returns ``400`` if JSON data is empty. + """ + path = "/servers/SOME_SERVER_NAME/operations" + + response = client.post(path, json={}) + + assert response.status_code == 400 + expected = ( + b"Minimum barman options not met for server " + b"'SOME_SERVER_NAME'" + ) + assert expected in response.data + + mock_get_server.assert_not_called() + mock_op_type.assert_not_called() + mock_parse_id.assert_not_called() + mock_server.assert_not_called() + mock_rec_op.assert_not_called() + mock_popen.assert_not_called() + + @patch("pg_backup_api.logic.utility_controller.OperationServer", Mock()) + @patch("pg_backup_api.logic.utility_controller.get_server_by_name") + @patch("pg_backup_api.logic.utility_controller.OperationType") + @patch("pg_backup_api.logic.utility_controller.parse_backup_id") + @patch("pg_backup_api.logic.utility_controller.Server") + @patch("pg_backup_api.logic.utility_controller.RecoveryOperation") + @patch("subprocess.Popen") + def test_server_operation_post_server_does_not_exist(self, mock_popen, + mock_rec_op, + mock_server, + mock_parse_id, + mock_op_type, + mock_get_server, + client): + """Test ``/servers//operations`` endpoint. + + Ensure ``POST`` request returns ``404`` if Barman server doesn't exist. + """ + path = "/servers/SOME_SERVER_NAME/operations" + + mock_get_server.return_value = None + + json_data = { + "type": "recovery", + } + response = client.post(path, json=json_data) + + mock_get_server.assert_called_once_with("SOME_SERVER_NAME") + mock_op_type.assert_not_called() + mock_parse_id.assert_not_called() + mock_server.assert_not_called() + mock_rec_op.assert_not_called() + mock_popen.assert_not_called() + + assert response.status_code == 404 + expected = ( + b'{"error":"404 Not Found: Server ' + b'\'SOME_SERVER_NAME\' does not exist"}\n' + ) + + assert response.data == expected + + @patch("pg_backup_api.logic.utility_controller.OperationServer", Mock()) + @patch("pg_backup_api.logic.utility_controller.get_server_by_name") + @patch("pg_backup_api.logic.utility_controller.OperationType") + @patch("pg_backup_api.logic.utility_controller.parse_backup_id") + @patch("pg_backup_api.logic.utility_controller.Server") + @patch("pg_backup_api.logic.utility_controller.RecoveryOperation") + @patch("subprocess.Popen") + def test_server_operation_post_backup_id_missing(self, mock_popen, + mock_rec_op, mock_server, + mock_parse_id, + mock_op_type, + mock_get_server, client): + """Test ``/servers//operations`` endpoint. + + Ensure ``POST`` request returns ``400`` if ``backup_id`` is missing. + """ + path = "/servers/SOME_SERVER_NAME/operations" + json_data = { + "type": "recovery", + } + + mock_op_type.return_value = mock_op_type.RECOVERY + + response = client.post(path, json=json_data) + + mock_get_server.assert_called_once_with("SOME_SERVER_NAME") + mock_op_type.assert_called_once_with("recovery") + mock_parse_id.assert_not_called() + mock_server.assert_not_called() + mock_rec_op.assert_not_called() + mock_popen.assert_not_called() + + assert response.status_code == 400 + assert b"Request body is missing ``backup_id``" in response.data + + @patch("pg_backup_api.logic.utility_controller.OperationServer", Mock()) + @patch("pg_backup_api.logic.utility_controller.get_server_by_name") + @patch("pg_backup_api.logic.utility_controller.OperationType") + @patch("pg_backup_api.logic.utility_controller.parse_backup_id") + @patch("pg_backup_api.logic.utility_controller.Server") + @patch("pg_backup_api.logic.utility_controller.RecoveryOperation") + @patch("subprocess.Popen") + def test_server_operation_post_backup_does_not_exist(self, mock_popen, + mock_rec_op, + mock_server, + mock_parse_id, + mock_op_type, + mock_get_server, + client): + """Test ``/servers//operations`` endpoint. + + Ensure ``POST`` request returns ``404`` if backup does not exist. + """ + path = "/servers/SOME_SERVER_NAME/operations" + json_data = { + "type": "recovery", + "backup_id": "SOME_BACKUP_ID", + } + + mock_parse_id.return_value = None + mock_op_type.return_value = mock_op_type.RECOVERY + + response = client.post(path, json=json_data) + + mock_get_server.assert_called_once_with("SOME_SERVER_NAME") + mock_op_type.assert_called_once_with("recovery") + mock_server.assert_called_once_with(mock_get_server.return_value) + mock_parse_id.assert_called_once_with(mock_server.return_value, + "SOME_BACKUP_ID") + mock_rec_op.assert_not_called() + mock_popen.assert_not_called() + + assert response.status_code == 404 + expected = ( + b'{"error":"404 Not Found: Backup ' + b'\'SOME_BACKUP_ID\' does not exist"}\n' + ) + assert response.data == expected + + @patch("pg_backup_api.logic.utility_controller.OperationServer", Mock()) + @patch("pg_backup_api.logic.utility_controller.get_server_by_name") + @patch("pg_backup_api.logic.utility_controller.OperationType") + @patch("pg_backup_api.logic.utility_controller.parse_backup_id") + @patch("pg_backup_api.logic.utility_controller.Server") + @patch("pg_backup_api.logic.utility_controller.RecoveryOperation") + @patch("subprocess.Popen") + def test_server_operation_post_missing_options(self, mock_popen, + mock_rec_op, mock_server, + mock_parse_id, mock_op_type, + mock_get_server, client): + """Test ``/servers//operations`` endpoint. + + Ensure ``POST`` request returns ``400`` if any option is missing. + """ + path = "/servers/SOME_SERVER_NAME/operations" + json_data = { + "type": "recovery", + "backup_id": "SOME_BACKUP_ID", + } + + mock_op_type.return_value = mock_op_type.RECOVERY + mock_parse_id.return_value = "SOME_BACKUP_ID" + mock_rec_op.return_value.id = "SOME_OP_ID" + mock_write_job = mock_rec_op.return_value.write_job_file + mock_write_job.side_effect = MalformedContent("SOME_ERROR") + + response = client.post(path, json=json_data) + + mock_get_server.assert_called_once_with("SOME_SERVER_NAME") + mock_op_type.assert_called_once_with("recovery") + mock_server.assert_called_once_with(mock_get_server.return_value) + mock_parse_id.assert_called_once_with(mock_server.return_value, + "SOME_BACKUP_ID") + mock_rec_op.assert_called_once_with("SOME_SERVER_NAME") + mock_write_job.assert_called_once_with(json_data) + mock_popen.assert_not_called() + + assert response.status_code == 400 + expected = b"Make sure all options/arguments are met and try again" + assert expected in response.data + + @patch("pg_backup_api.logic.utility_controller.OperationServer", Mock()) + @patch("pg_backup_api.logic.utility_controller.get_server_by_name") + @patch("pg_backup_api.logic.utility_controller.OperationType") + @patch("pg_backup_api.logic.utility_controller.parse_backup_id") + @patch("pg_backup_api.logic.utility_controller.Server") + @patch("pg_backup_api.logic.utility_controller.RecoveryOperation") + @patch("subprocess.Popen") + def test_server_operation_post_ok(self, mock_popen, mock_rec_op, + mock_server, mock_parse_id, mock_op_type, + mock_get_server, client): + """Test ``/servers//operations`` endpoint. + + Ensure ``POST`` request returns ``202`` if everything is ok, and ensure + the subprocess is started. + """ + path = "/servers/SOME_SERVER_NAME/operations" + json_data = { + "type": "recovery", + "backup_id": "SOME_BACKUP_ID", + } + + mock_op_type.return_value = mock_op_type.RECOVERY + mock_parse_id.return_value = "SOME_BACKUP_ID" + mock_rec_op.return_value.id = "SOME_OP_ID" + + response = client.post(path, json=json_data) + + mock_write_job = mock_rec_op.return_value.write_job_file + mock_get_server.assert_called_once_with("SOME_SERVER_NAME") + mock_op_type.assert_called_once_with("recovery") + mock_server.assert_called_once_with(mock_get_server.return_value) + mock_parse_id.assert_called_once_with(mock_server.return_value, + "SOME_BACKUP_ID") + mock_rec_op.assert_called_once_with("SOME_SERVER_NAME") + mock_write_job.assert_called_once_with(json_data) + mock_popen.assert_called_once_with(["pg-backup-api", "recovery", + "--server-name", + "SOME_SERVER_NAME", + "--operation-id", + "SOME_OP_ID"]) + + assert response.status_code == 202 + assert response.data == b'{"operation_id":"SOME_OP_ID"}\n' + + @patch("pg_backup_api.logic.utility_controller.OperationServer", Mock()) + @patch("pg_backup_api.logic.utility_controller.get_server_by_name") + @patch("pg_backup_api.logic.utility_controller.OperationType") + @patch("pg_backup_api.logic.utility_controller.parse_backup_id") + @patch("pg_backup_api.logic.utility_controller.Server") + @patch("pg_backup_api.logic.utility_controller.RecoveryOperation") + @patch("subprocess.Popen") + def test_server_operation_post_ok_type_missing(self, mock_popen, + mock_rec_op, mock_server, + mock_parse_id, mock_op_type, + mock_get_server, client): + """Test ``/servers//operations`` endpoint. + + Ensure ``POST`` request returns ``202`` if everything is ok, and ensure + the subprocess is started, even if ``type`` is absent, in which + case it defaults to ``recovery``. + """ + path = "/servers/SOME_SERVER_NAME/operations" + json_data = { + "backup_id": "SOME_BACKUP_ID", + } + + mock_op_type.return_value = mock_op_type.RECOVERY + mock_parse_id.return_value = "SOME_BACKUP_ID" + mock_rec_op.return_value.id = "SOME_OP_ID" + + response = client.post(path, json=json_data) + + mock_write_job = mock_rec_op.return_value.write_job_file + mock_get_server.assert_called_once_with("SOME_SERVER_NAME") + mock_op_type.assert_called_once_with("recovery") + mock_server.assert_called_once_with(mock_get_server.return_value) + mock_parse_id.assert_called_once_with(mock_server.return_value, + "SOME_BACKUP_ID") + mock_rec_op.assert_called_once_with("SOME_SERVER_NAME") + mock_write_job.assert_called_once_with(json_data) + mock_popen.assert_called_once_with(["pg-backup-api", "recovery", + "--server-name", + "SOME_SERVER_NAME", + "--operation-id", + "SOME_OP_ID"]) + + assert response.status_code == 202 + assert response.data == b'{"operation_id":"SOME_OP_ID"}\n' + + def test_server_operation_not_allowed(self, client): + """Test ``/servers//operations`` endpoint. + + Ensure all other HTTP request methods return an error. + """ + path = "/servers/SOME_SERVER_NAME/operations" + + self._ensure_http_methods_not_allowed(_HTTP_METHODS - {"GET", "POST"}, + path, client) diff --git a/pg_backup_api/pg_backup_api/tests/test_utils.py b/pg_backup_api/pg_backup_api/tests/test_utils.py new file mode 100644 index 0000000..9746edb --- /dev/null +++ b/pg_backup_api/pg_backup_api/tests/test_utils.py @@ -0,0 +1,195 @@ +# -*- coding: utf-8 -*- +# © Copyright EnterpriseDB UK Limited 2021-2023 +# +# This file is part of Postgres Backup API. +# +# Postgres Backup API is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Postgres Backup API is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Postgres Backup API. If not, see . + +"""Unit tests for utilitary functions.""" +from unittest.mock import MagicMock, patch, call + +from barman.infofile import BackupInfo +import pytest + +from pg_backup_api.utils import (create_app, load_barman_config, + setup_logging_for_wsgi_server, + get_server_by_name, parse_backup_id) + + +@patch("pg_backup_api.utils.Flask") +def test_create_app(mock_flask): + """Test :func:`create_app`. + + Ensure the :class:`Flask` object is created as expected. + """ + assert create_app() == mock_flask.return_value + + mock_flask.assert_called_once_with("Postgres Backup API") + + +@patch("pg_backup_api.utils.config.Config") +@patch("barman.__config__") +def test_load_barman_config(mock_global_config, mock_config): + """Test :func:`load_barman_config`. + + Ensure Barman configuration is loaded as expected. + """ + assert load_barman_config() is None + + mock_config.assert_called_once_with("/etc/barman.conf") + mock_global_config == mock_config.return_value + mock_load = mock_config.return_value.load_configuration_files_directory + mock_load.assert_called_once_with() + + +@patch("pg_backup_api.utils.dictConfig") +def test_setup_logging_for_wsgi_server(mock_dict_config): + """Test :func:`setup_logging_for_wsgi_server`. + + Ensure :meth:`logging.config.dictConfig` is called as expected. + """ + assert setup_logging_for_wsgi_server() is None + + log_format = "[%(asctime)s] %(levelname)s:%(module)s: %(message)s" + expected = { + "version": 1, + "formatters": { + "default": { + "format": log_format, + } + }, + "handlers": { + "wsgi": { + "class": "logging.FileHandler", + "filename": "/var/log/barman/barman-api.log", + "formatter": "default", + } + }, + "root": {"level": "INFO", "handlers": ["wsgi"]}, + "disable_existing_loggers": False, + } + mock_dict_config.assert_called_once_with(expected) + + +@patch("barman.__config__") +def test_get_server_by_name_not_found(mock_config): + """Test :func:`get_server_by_name`. + + Ensure ``None`` is returned if the server could not be found. + """ + mock_server_names = mock_config.server_names + mock_server_names.return_value = ["SERVER_1", "SERVER_2", "SERVER_3"] + mock_get_server = mock_config.get_server + + assert get_server_by_name("SERVER_4") is None + + mock_server_names.assert_called_once_with() + mock_get_server.assert_has_calls([ + call("SERVER_1"), + call("SERVER_2"), + call("SERVER_3"), + ]) + + +@patch("barman.__config__") +def test_get_server_by_name_ok(mock_config): + """Test :func:`get_server_by_name`. + + Ensure a server is returned if the server could be found. + """ + mock_server_names = mock_config.server_names + mock_server_names.return_value = ["SERVER_1", "SERVER_2", "SERVER_3"] + mock_get_server = mock_config.get_server + + assert get_server_by_name("SERVER_2") == mock_get_server.return_value + + mock_server_names.assert_called_once_with() + mock_get_server.assert_has_calls([ + call("SERVER_1"), + call("SERVER_2"), + ]) + + +@pytest.mark.parametrize("backup_id", ["latest", "last"]) +def test_parse_backup_id_latest(backup_id): + """Test :func:`parse_backup_id`. + + Ensure :meth:`barman.server.Server.get_last_backup_id()` is called when + backup ID is either ``latest`` or ``last``, then the corresponding backup + is returned. + """ + mock_server = MagicMock() + + expected = mock_server.get_backup.return_value + assert parse_backup_id(mock_server, backup_id) == expected + + mock_server.get_last_backup_id.assert_called_once_with() + mock_server.get_first_backup_id.assert_not_called() + expected = mock_server.get_last_backup_id.return_value + mock_server.get_backup.assert_called_once_with(expected) + + +@pytest.mark.parametrize("backup_id", ["oldest", "first"]) +def test_parse_backup_id_first(backup_id): + """Test :func:`parse_backup_id`. + + Ensure :meth:`barman.server.Server.get_first_backup_id()` is called when + backup ID is either ``oldest`` or ``first``, then the corresponding backup + is returned. + """ + mock_server = MagicMock() + + expected = mock_server.get_backup.return_value + assert parse_backup_id(mock_server, backup_id) == expected + + mock_server.get_last_backup_id.assert_not_called() + mock_server.get_first_backup_id.assert_called_once_with() + expected = mock_server.get_first_backup_id.return_value + mock_server.get_backup.assert_called_once_with(expected) + + +def test_parse_backup_id_last_failed(): + """Test :func:`parse_backup_id`. + + Ensure :meth:`barman.server.Server.get_last_backup_id()` is called when + backup ID is ``last-failed``, then the corresponding backup is returned. + """ + backup_id = "last-failed" + + mock_server = MagicMock() + + expected = mock_server.get_backup.return_value + assert parse_backup_id(mock_server, backup_id) == expected + + mock_server.get_last_backup_id.assert_called_once_with([BackupInfo.FAILED]) + mock_server.get_first_backup_id.assert_not_called() + expected = mock_server.get_last_backup_id.return_value + mock_server.get_backup.assert_called_once_with(expected) + + +def test_parse_backup_id_random(): + """Test :func:`parse_backup_id`. + + Ensure only :meth:`barman.server.Server.get_backup()` is called. + """ + backup_id = "random" + + mock_server = MagicMock() + + expected = mock_server.get_backup.return_value + assert parse_backup_id(mock_server, backup_id) == expected + + mock_server.get_last_backup_id.assert_not_called() + mock_server.get_first_backup_id.assert_not_called() + mock_server.get_backup.assert_called_once_with(backup_id)