From 8a88d9f4abb600fc4a03ffedf7cd89b8616e690b Mon Sep 17 00:00:00 2001 From: Piero Toffanin Date: Mon, 25 Jun 2018 13:31:42 -0400 Subject: [PATCH] Tokens support --- nodeodm/admin.py | 2 +- nodeodm/api_client.py | 14 ++- nodeodm/external/node-OpenDroneMap | 2 +- nodeodm/fixtures/test_processingnodes.json | 13 +++ .../migrations/0002_processingnode_token.py | 18 +++ nodeodm/migrations/0003_auto_20180625_1230.py | 18 +++ nodeodm/models.py | 5 +- nodeodm/tests.py | 104 +++++++++++++++++- 8 files changed, 166 insertions(+), 10 deletions(-) create mode 100644 nodeodm/migrations/0002_processingnode_token.py create mode 100644 nodeodm/migrations/0003_auto_20180625_1230.py diff --git a/nodeodm/admin.py b/nodeodm/admin.py index 23b4897c..4be2e9ba 100644 --- a/nodeodm/admin.py +++ b/nodeodm/admin.py @@ -4,6 +4,6 @@ from guardian.admin import GuardedModelAdmin from .models import ProcessingNode class ProcessingNodeAdmin(GuardedModelAdmin): - fields = ('hostname', 'port') + fields = ('hostname', 'port', 'token') admin.site.register(ProcessingNode, ProcessingNodeAdmin) diff --git a/nodeodm/api_client.py b/nodeodm/api_client.py index 82d07ead..5c3ecefc 100644 --- a/nodeodm/api_client.py +++ b/nodeodm/api_client.py @@ -6,20 +6,24 @@ import requests import mimetypes import json import os -from urllib.parse import urlunparse +from urllib.parse import urlunparse, urlencode from app.testwatch import TestWatch class ApiClient: - def __init__(self, host, port, timeout=30): + def __init__(self, host, port, token = "", timeout=30): self.host = host self.port = port + self.token = token self.timeout = timeout - def url(self, url): + def url(self, url, query = {}): netloc = self.host if self.port == 80 else "{}:{}".format(self.host, self.port) + if len(self.token) > 0: + query['token'] = self.token + # TODO: https support - return urlunparse(('http', netloc, url, '', '', '')) + return urlunparse(('http', netloc, url, '', urlencode(query), '')) def info(self): return requests.get(self.url('/info'), timeout=self.timeout).json() @@ -32,7 +36,7 @@ class ApiClient: @TestWatch.watch() def task_output(self, uuid, line = 0): - return requests.get(self.url('/task/{}/output?line={}').format(uuid, line), timeout=self.timeout).json() + return requests.get(self.url('/task/{}/output', {'line': line}).format(uuid), timeout=self.timeout).json() def task_cancel(self, uuid): return requests.post(self.url('/task/cancel'), data={'uuid': uuid}, timeout=self.timeout).json() diff --git a/nodeodm/external/node-OpenDroneMap b/nodeodm/external/node-OpenDroneMap index 1b3b33df..bcdbc51f 160000 --- a/nodeodm/external/node-OpenDroneMap +++ b/nodeodm/external/node-OpenDroneMap @@ -1 +1 @@ -Subproject commit 1b3b33df63ce1423988dfb68969e827d27de9035 +Subproject commit bcdbc51f48a3da87d63b77d07434f9c51960b39f diff --git a/nodeodm/fixtures/test_processingnodes.json b/nodeodm/fixtures/test_processingnodes.json index 24004765..02cedfee 100644 --- a/nodeodm/fixtures/test_processingnodes.json +++ b/nodeodm/fixtures/test_processingnodes.json @@ -22,5 +22,18 @@ }, "model": "nodeodm.processingnode", "pk": 2 + }, + { + "fields": { + "api_version": "", + "available_options": {}, + "hostname": "localhost", + "last_refreshed": null, + "port": 11224, + "queue_count": 0, + "token": "test_token" + }, + "model": "nodeodm.processingnode", + "pk": 3 } ] \ No newline at end of file diff --git a/nodeodm/migrations/0002_processingnode_token.py b/nodeodm/migrations/0002_processingnode_token.py new file mode 100644 index 00000000..37098eb7 --- /dev/null +++ b/nodeodm/migrations/0002_processingnode_token.py @@ -0,0 +1,18 @@ +# Generated by Django 2.0.3 on 2018-06-25 16:27 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('nodeodm', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='processingnode', + name='token', + field=models.CharField(default='', help_text="Token to use for authentication. If the node doesn't have authentication, you can leave this field blank.", max_length=1024, null=True), + ), + ] diff --git a/nodeodm/migrations/0003_auto_20180625_1230.py b/nodeodm/migrations/0003_auto_20180625_1230.py new file mode 100644 index 00000000..09ab181c --- /dev/null +++ b/nodeodm/migrations/0003_auto_20180625_1230.py @@ -0,0 +1,18 @@ +# Generated by Django 2.0.3 on 2018-06-25 16:30 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('nodeodm', '0002_processingnode_token'), + ] + + operations = [ + migrations.AlterField( + model_name='processingnode', + name='token', + field=models.CharField(blank=True, default='', help_text="Token to use for authentication. If the node doesn't have authentication, you can leave this field blank.", max_length=1024), + ), + ] diff --git a/nodeodm/models.py b/nodeodm/models.py index 514054f7..ff1b4709 100644 --- a/nodeodm/models.py +++ b/nodeodm/models.py @@ -41,7 +41,8 @@ class ProcessingNode(models.Model): last_refreshed = models.DateTimeField(null=True, help_text="When was the information about this node last retrieved?") queue_count = models.PositiveIntegerField(default=0, help_text="Number of tasks currently being processed by this node (as reported by the node itself)") available_options = fields.JSONField(default=dict(), help_text="Description of the options that can be used for processing") - + token = models.CharField(max_length=1024, blank=True, default="", help_text="Token to use for authentication. If the node doesn't have authentication, you can leave this field blank.") + def __str__(self): return '{}:{}'.format(self.hostname, self.port) @@ -81,7 +82,7 @@ class ProcessingNode(models.Model): return False def api_client(self, timeout=30): - return ApiClient(self.hostname, self.port, timeout) + return ApiClient(self.hostname, self.port, self.token, timeout) def get_available_options_json(self, pretty=False): """ diff --git a/nodeodm/tests.py b/nodeodm/tests.py index 929040a5..6f0451c5 100644 --- a/nodeodm/tests.py +++ b/nodeodm/tests.py @@ -22,7 +22,7 @@ class TestClientApi(TestCase): def setUpClass(cls): super(TestClientApi, cls).setUpClass() cls.node_odm = subprocess.Popen(['node', 'index.js', '--port', '11223', '--test'], shell=False, cwd=path.join(current_dir, "external", "node-OpenDroneMap")) - time.sleep(5) # Wait for the server to launch + time.sleep(2) # Wait for the server to launch @classmethod @@ -187,3 +187,105 @@ class TestClientApi(TestCase): # Best choice now is original processing node self.assertTrue(ProcessingNode.find_best_available_node().id == pnode.id) + + def test_token_auth(self): + node_odm = subprocess.Popen( + ['node', 'index.js', '--port', '11224', '--token', 'test_token', '--test'], shell=False, + cwd=path.join(current_dir, "external", "node-OpenDroneMap")) + time.sleep(2) + + def wait_for_status(api, uuid, status, num_retries=10, error_description="Failed to wait for status"): + retries = 0 + while True: + try: + task_info = api.task_info(uuid) + if task_info['status']['code'] == status: + return True + except ProcessingError: + pass + + time.sleep(0.5) + retries += 1 + if retries >= num_retries: + self.assertTrue(False, error_description) + return False + + api = ApiClient("localhost", 11224, "test_token") + online_node = ProcessingNode.objects.get(pk=3) + + self.assertTrue(online_node.update_node_info(), "Could update info") + + # Can always call info(), options() (even without valid tokens) + api.token = "invalid" + self.assertTrue(type(api.info()['version']) == str) + self.assertTrue(len(api.options()) > 0) + + # Cannot call new_task() without token + import glob + res = api.new_task( + glob.glob("nodeodm/fixtures/test_images/*.JPG")) + self.assertTrue('error' in res) + + # Can call new_task() with token + api.token = "test_token" + res = api.new_task( + glob.glob("nodeodm/fixtures/test_images/*.JPG")) + self.assertTrue('uuid' in res) + self.assertFalse('error' in res) + uuid = res['uuid'] + + # Can call task_info() with token + task_info = api.task_info(uuid) + self.assertTrue(isinstance(task_info['dateCreated'], int)) + + # Cannot call task_info() without token + api.token = "invalid" + res = api.task_info(uuid) + self.assertTrue('error' in res) + self.assertTrue('token does not match' in res['error']) + + # Here we are waiting for the task to be completed + api.token = "test_token" + wait_for_status(api, uuid, status_codes.COMPLETED, 10, "Could not download assets") + + # Cannot download assets without token + api.token = "invalid" + res = api.task_download(uuid, "all.zip") + self.assertTrue('error' in res) + + api.token = "test_token" + asset = api.task_download(uuid, "all.zip") + self.assertTrue(isinstance(asset, requests.Response)) + + # Cannot get task output without token + api.token = "invalid" + res = api.task_output(uuid, 0) + self.assertTrue('error' in res) + + api.token = "test_token" + res = api.task_output(uuid, 0) + self.assertTrue(isinstance(res, list)) + + # Cannot restart task without token + online_node.token = "invalid" + self.assertRaises(ProcessingError, online_node.restart_task, uuid) + + online_node.token = "test_token" + self.assertTrue(online_node.restart_task(uuid)) + + # Cannot cancel task without token + online_node.token = "invalid" + self.assertRaises(ProcessingError, online_node.cancel_task, uuid) + online_node.token = "test_token" + self.assertTrue(online_node.cancel_task(uuid)) + + # Wait for task to be canceled + wait_for_status(api, uuid, status_codes.CANCELED, 5, "Could not cancel task") + + # Cannot delete task without token + online_node.token = "invalid" + self.assertRaises(ProcessingError, online_node.remove_task, "wrong-uuid") + online_node.token = "test_token" + self.assertTrue(online_node.remove_task(uuid)) + + node_odm.terminate();