| """Test the API for crashreports, devices, heartbeats and logfiles.""" |
| import os |
| import tempfile |
| |
| from django.contrib.auth.models import User |
| from django.urls import reverse |
| |
| from rest_framework import status |
| from rest_framework.test import APIClient, APITestCase |
| |
| from crashreports.models import Crashreport |
| |
| |
| class InvalidCrashTypeError(BaseException): |
| """Invalid crash type encountered. |
| |
| The valid crash type values (strings) are: |
| - 'crash'; |
| - 'smpl'; |
| - 'other'. |
| |
| Args: |
| - crash_type: The invalid crash type. |
| """ |
| |
| def __init__(self, crash_type): |
| """Initialise the exception using the crash type to build a message. |
| |
| Args: |
| crash_type: The invalid crash type. |
| """ |
| super(InvalidCrashTypeError, self).__init__( |
| '{} is not a valid crash type'.format(crash_type)) |
| |
| |
| class Dummy(): |
| """Dummy values for devices, heartbeats and crashreports.""" |
| |
| DEFAULT_DUMMY_DEVICE_REGISTER_VALUES = { |
| 'board_date': '2015-12-15T01:23:45Z', |
| 'chipset': 'Qualcomm MSM8974PRO-AA', |
| } |
| |
| DEFAULT_DUMMY_HEARTBEAT_VALUES = { |
| 'uuid': None, |
| 'app_version': 10100, |
| 'uptime': ( |
| 'up time: 16 days, 21:49:56, idle time: 5 days, 20:55:04, ' |
| 'sleep time: 10 days, 20:46:27'), |
| 'build_fingerprint': ( |
| 'Fairphone/FP2/FP2:6.0.1/FP2-gms-18.03.1/FP2-gms-18.03.1:user/' |
| 'release-keys'), |
| 'radio_version': '4437.1-FP2-0-08', |
| 'date': '2018-03-19T09:58:30.386Z', |
| } |
| |
| DEFAULT_DUMMY_CRASHREPORTS_VALUES = DEFAULT_DUMMY_HEARTBEAT_VALUES.copy() |
| DEFAULT_DUMMY_CRASHREPORTS_VALUES.update({ |
| 'is_fake_report': 0, |
| 'boot_reason': 'why?', |
| 'power_on_reason': 'it was powered on', |
| 'power_off_reason': 'something happened and it went off', |
| }) |
| |
| CRASH_TYPE_TO_BOOT_REASON_MAP = { |
| 'crash': Crashreport.BOOT_REASON_KEYBOARD_POWER_ON, |
| 'smpl': Crashreport.BOOT_REASON_RTC_ALARM, |
| 'other': 'whatever', |
| } |
| |
| @staticmethod |
| def _update_copy(original, update): |
| """Merge fields of update into a copy of original.""" |
| data = original.copy() |
| data.update(update) |
| return data |
| |
| @staticmethod |
| def device_register_data(**kwargs): |
| """Return the data required to register a device. |
| |
| Use the values passed as keyword arguments or default to the ones |
| from `Dummy.DEFAULT_DUMMY_DEVICE_REGISTER_VALUES`. |
| """ |
| return Dummy._update_copy( |
| Dummy.DEFAULT_DUMMY_DEVICE_REGISTER_VALUES, kwargs) |
| |
| @staticmethod |
| def heartbeat_data(**kwargs): |
| """Return the data required to create a heartbeat. |
| |
| Use the values passed as keyword arguments or default to the ones |
| from `Dummy.DEFAULT_DUMMY_HEARTBEAT_VALUES`. |
| """ |
| return Dummy._update_copy(Dummy.DEFAULT_DUMMY_HEARTBEAT_VALUES, kwargs) |
| |
| @staticmethod |
| def crashreport_data(report_type=None, **kwargs): |
| """Return the data required to create a crashreport. |
| |
| Use the values passed as keyword arguments or default to the ones |
| from `Dummy.DEFAULT_DUMMY_CRASHREPORTS_VALUES`. |
| |
| Args: |
| report_type (str, optional): A valid value from |
| `Dummy.CRASH_TYPE_TO_BOOT_REASON_MAP.keys()` that will |
| define the boot reason if not explicitly defined in the |
| keyword arguments already. |
| """ |
| data = Dummy._update_copy( |
| Dummy.DEFAULT_DUMMY_CRASHREPORTS_VALUES, kwargs) |
| if report_type and 'boot_reason' not in kwargs: |
| if report_type not in Dummy.CRASH_TYPE_TO_BOOT_REASON_MAP: |
| raise InvalidCrashTypeError(report_type) |
| data['boot_reason'] = Dummy.CRASH_TYPE_TO_BOOT_REASON_MAP.get( |
| report_type) |
| return data |
| |
| |
| class DeviceRegisterAPITestCase(APITestCase): |
| """Base class that offers a device registration method.""" |
| |
| REGISTER_DEVICE_URL = "api_v1_register_device" |
| |
| def setUp(self): |
| """Create an admin user for accessing the API. |
| |
| The APIClient that can be used to make authenticated requests to the |
| server is stored in self.admin. |
| """ |
| admin_user = User.objects.create_superuser( |
| 'somebody', 'somebody@example.com', 'thepassword') |
| self.admin = APIClient() |
| self.admin.force_authenticate(admin_user) |
| |
| def _register_device(self, **kwargs): |
| """Register a new device. |
| |
| Arguments: |
| **kwargs: The data to pass the dummy data creation |
| method `Dummy.device_register_data`. |
| Returns: |
| (UUID, APIClient, str): The uuid of the new device as well as an |
| authentication token and the associated user with credentials. |
| |
| """ |
| data = Dummy.device_register_data(**kwargs) |
| response = self.client.post(reverse(self.REGISTER_DEVICE_URL), data) |
| self.assertEqual(response.status_code, status.HTTP_200_OK) |
| |
| uuid = response.data['uuid'] |
| token = response.data['token'] |
| user = APIClient() |
| user.credentials(HTTP_AUTHORIZATION='Token ' + token) |
| |
| return uuid, user, token |
| |
| |
| class DeviceTestCase(DeviceRegisterAPITestCase): |
| """Test cases for registering devices.""" |
| |
| def test_register(self): |
| """Test registration of devices.""" |
| response = self.client.post(reverse(self.REGISTER_DEVICE_URL), |
| Dummy.device_register_data()) |
| self.assertTrue("token" in response.data) |
| self.assertTrue("uuid" in response.data) |
| self.assertEqual(response.status_code, status.HTTP_200_OK) |
| |
| def test_create_missing_fields(self): |
| """Test registration with missing fields.""" |
| response = self.client.post(reverse(self.REGISTER_DEVICE_URL)) |
| self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) |
| |
| def test_create_missing_board_date(self): |
| """Test registration with missing board date.""" |
| data = Dummy.device_register_data() |
| data.pop('board_date') |
| response = self.client.post(reverse(self.REGISTER_DEVICE_URL), data) |
| self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) |
| |
| def test_create_missing_chipset(self): |
| """Test registration with missing chipset.""" |
| data = Dummy.device_register_data() |
| data.pop('chipset') |
| response = self.client.post(reverse(self.REGISTER_DEVICE_URL), data) |
| self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) |
| |
| def test_create_invalid_board_date(self): |
| """Test registration with invalid board date.""" |
| data = Dummy.device_register_data() |
| data['board_date'] = 'not_a_valid_date' |
| response = self.client.post(reverse(self.REGISTER_DEVICE_URL), data) |
| self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) |
| |
| def test_create_non_existent_time_board_date(self): |
| """Test registration with non existing time. |
| |
| Test the resolution of a naive date-time in which the |
| Europe/Amsterdam daylight saving time transition moved the time |
| "forward". The server should not crash when receiving a naive |
| date-time which does not exist in the server timezone or locale. |
| """ |
| data = Dummy.device_register_data() |
| # In 2017, the Netherlands changed from CET to CEST on March, |
| # 26 at 02:00 |
| data['board_date'] = '2017-03-26 02:34:56' |
| response = self.client.post(reverse(self.REGISTER_DEVICE_URL), data) |
| self.assertEqual(response.status_code, status.HTTP_200_OK) |
| |
| def test_create_ambiguous_time_board_date(self): |
| """Test registration with ambiguous time. |
| |
| Test the resolution of a naive date-time in which the |
| Europe/Amsterdam daylight saving time transition moved the time |
| "backward". The server should not crash when receiving a naive |
| date-time that can belong to multiple timezones. |
| """ |
| data = Dummy.device_register_data() |
| # In 2017, the Netherlands changed from CEST to CET on October, |
| # 29 at 03:00 |
| data['board_date'] = '2017-10-29 02:34:56' |
| response = self.client.post(reverse(self.REGISTER_DEVICE_URL), data) |
| self.assertEqual(response.status_code, status.HTTP_200_OK) |
| |
| |
| class ListDevicesTestCase(DeviceRegisterAPITestCase): |
| """Test cases for listing and deleting devices.""" |
| |
| LIST_CREATE_URL = "api_v1_list_devices" |
| RETRIEVE_URL = "api_v1_retrieve_device" |
| |
| def test_device_list(self): |
| """Test registration of 2 devices.""" |
| number_of_devices = 2 |
| uuids = [ |
| str(self._register_device()[0]) |
| for _ in range(number_of_devices) |
| ] |
| |
| response = self.admin.get(reverse(self.LIST_CREATE_URL), {}) |
| self.assertEqual(response.status_code, status.HTTP_200_OK) |
| self.assertEqual(len(response.data['results']), number_of_devices) |
| for result in response.data['results']: |
| self.assertIn(result['uuid'], uuids) |
| |
| def test_device_list_unauth(self): |
| """Test listing devices without authentication.""" |
| response = self.client.get(reverse(self.LIST_CREATE_URL), {}) |
| self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) |
| |
| def test_retrieve_device_auth(self): |
| """Test retrieval of devices as admin user.""" |
| uuid, _, token = self._register_device() |
| response = self.admin.get(reverse(self.RETRIEVE_URL, args=[uuid]), {}) |
| self.assertEqual(response.status_code, status.HTTP_200_OK) |
| self.assertEqual(response.data['uuid'], str(uuid)) |
| self.assertEqual(response.data['token'], token) |
| |
| def test_retrieve_device_unauth(self): |
| """Test retrieval of devices without authentication.""" |
| uuid, _, _ = self._register_device() |
| response = self.client.get(reverse(self.RETRIEVE_URL, args=[uuid]), {}) |
| self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) |
| |
| def test_delete_device_auth(self): |
| """Test deletion of devices as admin user.""" |
| uuid, _, _ = self._register_device() |
| url = reverse(self.RETRIEVE_URL, args=[uuid]) |
| response = self.admin.delete(url, {}) |
| self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) |
| response = self.admin.delete(url, {}) |
| self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) |
| |
| |
| class HeartbeatListTestCase(DeviceRegisterAPITestCase): |
| """Test cases for heartbeats.""" |
| |
| LIST_CREATE_URL = "api_v1_heartbeats" |
| RETRIEVE_URL = "api_v1_heartbeat" |
| LIST_CREATE_BY_UUID_URL = "api_v1_heartbeats_by_uuid" |
| RETRIEVE_BY_UUID_URL = "api_v1_heartbeat_by_uuid" |
| |
| @staticmethod |
| def _create_dummy_data(**kwargs): |
| return Dummy.heartbeat_data(**kwargs) |
| |
| def _post_multiple(self, client, data, count): |
| return [ |
| client.post(reverse(self.LIST_CREATE_URL), data) |
| for _ in range(count)] |
| |
| def _retrieve_single(self, user): |
| count = 5 |
| response = self._post_multiple(self.admin, self.data, count) |
| self.assertEqual(len(response), count) |
| self.assertEqual(response[0].status_code, status.HTTP_201_CREATED) |
| url = reverse(self.RETRIEVE_URL, args=[response[0].data['id']]) |
| request = user.get(url) |
| return request.status_code |
| |
| def _retrieve_single_by_device(self, user): |
| count = 5 |
| response = self._post_multiple(self.user, self.data, count) |
| self.assertEqual(len(response), count) |
| self.assertEqual(response[0].status_code, status.HTTP_201_CREATED) |
| url = reverse(self.RETRIEVE_BY_UUID_URL, args=[ |
| self.uuid, response[0].data['device_local_id']]) |
| request = user.get(url) |
| return request.status_code |
| |
| def setUp(self): |
| """Set up a device and some data.""" |
| super().setUp() |
| self.uuid, self.user, self.token = self._register_device() |
| self.data = self._create_dummy_data(uuid=self.uuid) |
| |
| def test_create_no_auth(self): |
| """Test creation without authentication.""" |
| noauth_client = APIClient() |
| response = noauth_client.post( |
| reverse(self.LIST_CREATE_URL), self.data) |
| self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) |
| |
| def test_create_as_admin(self): |
| """Test creation as admin.""" |
| response = self.admin.post(reverse(self.LIST_CREATE_URL), self.data) |
| self.assertEqual(response.status_code, status.HTTP_201_CREATED) |
| self.assertTrue(response.data['id'] > 0) |
| |
| def test_create_as_admin_not_existing_device(self): |
| """Test creation of heartbeat on non-existing device.""" |
| response = self.admin.post( |
| reverse(self.LIST_CREATE_URL), self._create_dummy_data()) |
| self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) |
| |
| def test_create_as_uuid_owner(self): |
| """Test creation as owner.""" |
| response = self.user.post( |
| reverse(self.LIST_CREATE_URL), |
| self._create_dummy_data(uuid=self.uuid)) |
| self.assertEqual(response.status_code, status.HTTP_201_CREATED) |
| self.assertEqual(response.data['id'], -1) |
| |
| def test_create_as_uuid_not_owner(self): |
| """Test creation as non-owner.""" |
| uuid, _, _ = self._register_device() |
| response = self.user.post( |
| reverse(self.LIST_CREATE_URL), |
| self._create_dummy_data(uuid=uuid)) |
| self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) |
| |
| def test_list(self): |
| """Test listing of heartbeats.""" |
| count = 5 |
| self._post_multiple(self.user, self.data, count) |
| response = self.admin.get(reverse(self.LIST_CREATE_URL)) |
| self.assertEqual(response.status_code, status.HTTP_200_OK) |
| self.assertEqual(len(response.data['results']), count) |
| |
| def test_retrieve_single_admin(self): |
| """Test retrieval as admin.""" |
| self.assertEqual( |
| self._retrieve_single(self.admin), status.HTTP_200_OK) |
| |
| def test_retrieve_single_device_owner(self): |
| """Test retrieval as device owner.""" |
| self.assertEqual( |
| self._retrieve_single(self.user), status.HTTP_403_FORBIDDEN) |
| |
| def test_retrieve_single_noauth(self): |
| """Test retrieval without authentication.""" |
| noauth_client = APIClient() |
| self.assertEqual( |
| self._retrieve_single(noauth_client), |
| status.HTTP_401_UNAUTHORIZED) |
| |
| def test_retrieve_single_by_device_admin(self): |
| """Test retrieval by device as admin.""" |
| self.assertEqual( |
| self._retrieve_single_by_device(self.admin), status.HTTP_200_OK) |
| |
| def test_retrieve_single_by_device_device_owner(self): |
| """Test retrieval by device as owner.""" |
| self.assertEqual( |
| self._retrieve_single_by_device(self.user), |
| status.HTTP_403_FORBIDDEN) |
| |
| def test_retrieve_single_by_device_noauth(self): |
| """Test retrieval by device without authentication.""" |
| noauth_client = APIClient() |
| self.assertEqual( |
| self._retrieve_single_by_device(noauth_client), |
| status.HTTP_401_UNAUTHORIZED) |
| |
| def test_list_by_uuid(self): |
| """Test listing of devices by UUID.""" |
| count = 5 |
| uuid, _, _ = self._register_device() |
| self._post_multiple(self.user, self.data, count) |
| self._post_multiple( |
| self.admin, self._create_dummy_data(uuid=uuid), count) |
| url = reverse(self.LIST_CREATE_BY_UUID_URL, args=[self.uuid]) |
| response = self.admin.get(url) |
| self.assertEqual(response.status_code, status.HTTP_200_OK) |
| self.assertEqual(len(response.data['results']), count) |
| |
| def test_list_noauth(self): |
| """Test listing of devices without authentication.""" |
| count = 5 |
| noauth_client = APIClient() |
| self._post_multiple(self.user, self.data, count) |
| response = noauth_client.get(reverse(self.LIST_CREATE_URL)) |
| self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) |
| |
| def test_list_device_owner(self): |
| """Test listing as device owner.""" |
| count = 5 |
| self._post_multiple(self.user, self.data, count) |
| response = self.user.get(reverse(self.LIST_CREATE_URL)) |
| self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) |
| |
| def test_no_radio_version(self): |
| """Test creation and retrieval without radio version.""" |
| data = self._create_dummy_data(uuid=self.uuid) |
| data.pop('radio_version') |
| response = self.user.post(reverse(self.LIST_CREATE_URL), data) |
| self.assertEqual(response.status_code, status.HTTP_201_CREATED) |
| url = reverse(self.LIST_CREATE_BY_UUID_URL, args=[self.uuid]) |
| response = self.admin.get(url) |
| self.assertEqual(response.status_code, status.HTTP_200_OK) |
| self.assertEqual(len(response.data['results']), 1) |
| self.assertIsNone(response.data['results'][0]['radio_version']) |
| |
| def test_radio_version_field(self): |
| """Test retrieval of radio version field.""" |
| response = self.user.post(reverse(self.LIST_CREATE_URL), self.data) |
| self.assertEqual(response.status_code, status.HTTP_201_CREATED) |
| url = reverse(self.LIST_CREATE_BY_UUID_URL, args=[self.uuid]) |
| response = self.admin.get(url) |
| self.assertEqual(response.status_code, status.HTTP_200_OK) |
| self.assertEqual(len(response.data['results']), 1) |
| self.assertEqual(response.data['results'][0]['radio_version'], |
| self.data['radio_version']) |
| |
| def test_send_non_existent_time(self): |
| """Test sending of heartbeat with non existent time. |
| |
| Test the resolution of a naive date-time in which the |
| Europe/Amsterdam daylight saving time transition moved the time |
| "forward". |
| """ |
| data = self._create_dummy_data(uuid=self.uuid) |
| # In 2017, the Netherlands changed from CET to CEST on March, |
| # 26 at 02:00 |
| data['date'] = '2017-03-26 02:34:56' |
| response = self.user.post(reverse(self.LIST_CREATE_URL), data) |
| self.assertEqual(response.status_code, status.HTTP_201_CREATED) |
| |
| def test_send_ambiguous_time(self): |
| """Test sending of heartbeat with ambiguous time. |
| |
| Test the resolution of a naive date-time in which the |
| Europe/Amsterdam daylight saving time transition moved the time |
| "backward". |
| """ |
| data = self._create_dummy_data(uuid=self.uuid) |
| # In 2017, the Netherlands changed from CEST to CET on October, |
| # 29 at 03:00 |
| data['date'] = '2017-10-29 02:34:56' |
| response = self.user.post(reverse(self.LIST_CREATE_URL), data) |
| self.assertEqual(response.status_code, status.HTTP_201_CREATED) |
| |
| |
| # pylint: disable=too-many-ancestors |
| class CrashreportListTestCase(HeartbeatListTestCase): |
| """Test cases for crash reports.""" |
| |
| LIST_CREATE_URL = "api_v1_crashreports" |
| RETRIEVE_URL = "api_v1_crashreport" |
| LIST_CREATE_BY_UUID_URL = "api_v1_crashreports_by_uuid" |
| RETRIEVE_BY_UUID_URL = "api_v1_crashreport_by_uuid" |
| |
| @staticmethod |
| def _create_dummy_data(**kwargs): |
| return Dummy.crashreport_data(**kwargs) |
| |
| |
| class LogfileUploadTest(DeviceRegisterAPITestCase): |
| """Test cases for upload of log files.""" |
| |
| LIST_CREATE_URL = "api_v1_crashreports" |
| PUT_LOGFILE_URL = "api_v1_putlogfile_for_device_id" |
| |
| def _upload_crashreport(self, user, uuid): |
| """ |
| Upload dummy crashreport data. |
| |
| Args: |
| user: The user which should be used for uploading the report |
| uuid: The uuid of the device to which the report should be uploaded |
| |
| Returns: The local id of the device for which the report was uploaded. |
| |
| """ |
| data = Dummy.crashreport_data(uuid=uuid) |
| response = user.post(reverse(self.LIST_CREATE_URL), data) |
| self.assertEqual(status.HTTP_201_CREATED, response.status_code) |
| self.assertTrue('device_local_id' in response.data) |
| device_local_id = response.data['device_local_id'] |
| |
| return device_local_id |
| |
| def _test_logfile_upload(self, user, uuid): |
| # Upload crashreport |
| device_local_id = self._upload_crashreport(user, uuid) |
| |
| # Upload a logfile for the crashreport |
| logfile = tempfile.NamedTemporaryFile('w+', suffix=".log", delete=True) |
| logfile.write(u"blihblahblub") |
| response = user.post( |
| reverse(self.PUT_LOGFILE_URL, args=[ |
| uuid, device_local_id, os.path.basename(logfile.name) |
| ]), |
| {'file': logfile}, format="multipart") |
| self.assertEqual(status.HTTP_201_CREATED, response.status_code) |
| |
| def test_logfile_upload_as_user(self): |
| """Test upload of logfiles as device owner.""" |
| uuid, user, _ = self._register_device() |
| self._test_logfile_upload(user, uuid) |
| |
| def test_logfile_upload_as_admin(self): |
| """Test upload of logfiles as admin user.""" |
| uuid, _, _ = self._register_device() |
| self._test_logfile_upload(self.admin, uuid) |