diff --git a/FedoraSearchTool.py b/FedoraSearchTool.py new file mode 100644 index 0000000..4b45a7f --- /dev/null +++ b/FedoraSearchTool.py @@ -0,0 +1,126 @@ +#!/bin/env python + +import TestConstants as TC +import requests + + +class FedoraSearchTool(object): + + max_results = None + offset = None + order_by = None + order = None + baseUri = None + fields = list() + conditions = list() + authz = None + + def __init__(self, base, authz): + self.authz = authz + self.baseUri = base + + @staticmethod + def create(base_uri, calling_class): + """ + Create a FedoraSearchTool instance + :param base_uri: The base uri of the fedora instance. + :param calling_class: The test class this is used in to get the authorization + :return: FedoraSearchTool + """ + return FedoraSearchTool(base_uri, calling_class.get_auth(True)) + + def clear(self): + """ + Clear all search parameters. + :return: void + """ + self.fields = list() + self.conditions = list() + self.max_results = None + self.offset = None + self.order_by = None + self.order = None + + def add_field(self, field): + """ + Add a field to return in the search results. + :param field: The field + :return: void + """ + if field not in self.fields: + self.fields.append(field) + + def add_condition(self, field, operator, value): + """ + Add a condition to the search + :param field: The field to search against. + :param operator: The operator to use in the search. + :param value: The value to compare (operator) against the field + :return: void + """ + new_condition = field + operator + value + if new_condition not in self.conditions: + self.conditions.append(new_condition) + + def set_max_results(self, count): + """ + Set the max results. + :param count: Max results. + :return: void + """ + self.max_results = count + + def set_order_by(self, field): + """ + Set the field to order by. + :param field: The field. + :return: void + """ + self.order_by = field + + def set_order(self, order): + """ + Set the order to use with the order_by field. + :param order: Order + :return: void + TODO: Should use an enum of some sort. + """ + self.order = order + + def set_offset(self, offset): + """ + Set the offset. + :param offset: The offset. + :return: void + """ + self.offset = offset + + def do_query(self): + """ + Perform the search. + :return: Response object. + """ + parameters = {} + for condition in self.conditions: + if 'condition' in parameters: + try: + parameters['condition'].append(condition) + except AttributeError: + tmp = parameters['condition'] + parameters['condition'] = [tmp] + parameters['condition'].append(condition) + else: + parameters['condition'] = condition + + if len(self.fields) > 0: + parameters['fields'] = ",".join(self.fields) + if self.max_results is not None: + parameters['max_results'] = self.max_results + if self.offset is not None: + parameters['offset'] = self.offset + if self.order_by is not None: + parameters['order_by'] = self.order_by + if self.order is not None: + parameters['order'] = self.order + + return requests.get(self.baseUri + "/" + TC.FCR_SEARCH, auth=self.authz, params=parameters) diff --git a/README.md b/README.md index 27835f8..bb9d68a 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,12 @@ -# Fedora 4 Tests +# Fedora Tests -These python tests are meant to be run against a standalone Fedora 4 instance. +These python tests are meant to be run against a standalone Fedora instance. This will create, update and delete resources in the repository. So you may **not** want to use it on a production instance. Also, this is doing a cursory test. It does some verification of RDF, and patches are always welcome. -Note: in order to test authorization, please first verify that your Fedora repository in configured to use authorization. -Check the `repository.json` in use, and verify that the `security` block contains a `providers` list such as: - - "providers" : [ - { "classname" : "org.fcrepo.auth.common.ServletContainerAuthenticationProvider" } - ] +Note: in order to test authorization, please see [this note](#authz-tests) ## Installation @@ -68,6 +63,8 @@ second_site: password1: password1 user2: user2 password2: password2 + solrurl: http://someserver:8080/solr + triplestoreurl: http://someserver:8080/fuseki/test/sparql ``` To use the `second_site` configuration, simply start the testrunner with @@ -80,13 +77,16 @@ If a configuration cannot be found or the `-n|--site_name` argument is not prese ### Isolate tests You can also choose to run only a subset of all tests using the `-t|--tests` argument. It accepts a comma separated list of the following values which indicate which tests to run. -* `authz` - Authorization tests +* `archivalgroup` - Archival Group tests +* `authz` - Authorization tests (see [note](#authz-tests)) * `basic` - Basic interaction tests -* `sparql` - Sparql operation tests +* `camel` - Camel toolbox tests (see [note](#camel-tests)) +* `fixity` - Binary fixity tests +* `indirect` - Direct/Indirect container tests * `rdf` - RDF serialization tests -* `version` - Versioning tests +* `sparql` - Sparql operation tests * `transaction` - Transcation tests -* `fixity` - Binary fixity tests +* `version` - Versioning tests Without this parameter all the above tests will be run. @@ -95,8 +95,35 @@ To run only the `authz` and `sparql` tests you would execute: ./testrunner.py -c config.yml -t authz,sparql ``` +##### Camel Tests +`camel` tests are **NOT** executed by default, due to timing issues they should be run separately. + +They also require the configuration to have a `solrurl` parameter pointing to a Solr endpoint and a +`triplestoreurl` parameter pointing to the SPARQL endpoint of a triplestore. + +Both of these systems must be fed by the fcrepo-camel-toolbox for this testing. + +##### AuthZ tests +`authz` tests will require authz to be enabled on your Fedora, as well you will need to set the +following properties for your Fedora or the test `testGroupAuth` will fail. +```commandline +fcrepo.auth.webac.userAgent.baseUri=http://example.com/ +fcrepo.auth.webac.groupAgent.baseUri=http://example.com/ +``` + + ## Tests implemented +**Note**: this list is out of date, you are better to view the tests in the various test classes. + +### archivalgroup +1. Create an archivalgroup container +1. Create an archivalgroup member, delete it and it's tombstone +1. Create an archivalgroup member, delete it and PUT over the tombstone +1. Try to PUT over an archivalgroup member tombstone with a different interaction model +1. Create and delete an archivalgroup, delete it's tombstone. +1. Create and delete an archivalgroup, and PUT over the tombstone + ### authz 1. Create a container called **cover** 1. Patch it to a pcdm:Object @@ -108,6 +135,34 @@ To run only the `authz` and `sparql` tests you would execute: 1. Verify regular user 1 can access **cover** 1. Verify regular user 2 can't access **cover** + +1. Create a container that is readonly for regular user 1 +1. Create a container that regular user 1 has read/write access to. +1. Verify that regular user 1 can create/edit/append the container +1. Verify that regular user 1 cannot create a direct or indirect container +that targets the read-only container as the membership resource. + + +1. Create a container +1. Add an acl with multiple authorizations for user 1 +1. Verify that user 1 receives the most permissive set of permissions +from the authorizations + + +1. Verify that the `rel="acl"` link header is the same for: + * a binary + * its description + * the binary timemap + * the description timemap + * a binary memento + * a description memento + + +1. Verify that both a binary and its description share the permissions give +to the binary. + + + ### basic 1. Create a container 1. Create a container inside the container from step 1 @@ -121,11 +176,39 @@ To run only the `authz` and `sparql` tests you would execute: 1. Create a LDP Indirect container 1. Validate the correct Link header type + +1. Create a basic container +1. Create an indirect container +1. Create a direct container +1. Create a NonRDFSource +1. Try to create a ldp:Resource (not allowed) +1. Try to create a ldp:Container (not allowed) + + +1. Try to change each of the following (basic, direct, indirect container +and binary) to all other types. + +### camel - see [note](#camel-tests) +1. Create a container +1. Check the container is indexed to Solr +1. Check the container is indexed to the triplestore + ### fixity 1. Create a binary resource 1. Get a fixity result for that resource 1. Compare that the SHA-1 hash matches the expected value +### indirect +1. Create a pcdm:Object +2. Create a pcdm:Collection +3. Create an indirect container "members" inside the pcdm:Collection +4. Create a proxy object for the pcdm:Object inside the **members** indirectContainer +5. Verify that the pcdm:Collection has the memberRelation property added pointing to the pcdm:Object + +### rdf +1. Create a RDFSource object. +1. Retrieve that object in all possible RDF serializations. + ### sparql 1. Create a container 1. Set the dc:title of the container with a Patch request @@ -137,7 +220,7 @@ To run only the `authz` and `sparql` tests you would execute: 1. Verify the title 1. Create a container 1. Update the title to text with Unicode characters -1. Verify the title +1. Verify the title ### transaction 1. Create a transaction @@ -170,9 +253,3 @@ To run only the `authz` and `sparql` tests you would execute: 1. Create Memento at deleted memento's datetime 1. Verify Memento exists -### indirect -1. Create a pcdm:Object -2. Create a pcdm:Collection -3. Create an indirect container "members" inside the pcdm:Collection -4. Create a proxy object for the pcdm:Object inside the **members** indirectContainer -5. Verify that the pcdm:Collection has the memberRelation property added pointing to the pcdm:Object diff --git a/TestConstants.py b/TestConstants.py index 7cee1b8..e3bc964 100644 --- a/TestConstants.py +++ b/TestConstants.py @@ -10,15 +10,32 @@ USER2_PASS_PARAM = "password2" LOG_FILE_PARAM = "logfile" SELECTED_TESTS_PARAM = "selected_tests" +SOLR_URL_PARAM = "solrurl" +TRIPLESTORE_URL_PARAM = "triplestoreurl" +# API Test Suite things +USER_URL_PREFIX = "http://example.com/" # Via RFC 7231 3.3 PAYLOAD_HEADERS = ['Content-Length', 'Content-Range', 'Trailer', 'Transfer-Encoding'] # Fedora specific constants +FEDORA_NS = "http://fedora.info/definitions/v4/repository#" +FEDORA_API_NS = "http://fedora.info/definitions/fcrepo#" FCR_VERSIONS = "fcr:versions" FCR_FIXITY = "fcr:fixity" -SERVER_MANAGED = "http://fedora.info/definitions/v4/repository#ServerManaged" -INBOUND_REFERENCE = "http://fedora.info/definitions/v4/repository#InboundReferences" -EMBEDED_RESOURCE = "http://fedora.info/definitions/v4/repository#EmbedResources" +FCR_TX = "fcr:tx" +FCR_TOMBSTONE = "fcr:tombstone" +FCR_ACL = "fcr:acl" +FCR_SEARCH = "fcr:search" +FCR_METADATA = "fcr:metadata" +SERVER_MANAGED = FEDORA_NS + "ServerManaged" +INBOUND_REFERENCE = FEDORA_API_NS + "PreferInboundReferences" +EMBEDED_RESOURCE = FEDORA_NS + "EmbedResources" +ATOMIC_ID_HEADER = "Atomic-ID" +ARCHIVAL_GROUP = FEDORA_NS + "ArchivalGroup" +OVERWRITE_TOMBSTONE_HEADER = "Overwrite-Tombstone" + +FEDORA_TX_NS = "http://fedora.info/definitions/v4/transaction#" +FEDORA_TX_ENDPOINT_REL = FEDORA_TX_NS + "endpoint" GET_PREFER_MINIMAL = "return=minimal" PUT_PREFER_LENIENT = "handling=lenient; received=\"minimal\"" @@ -29,8 +46,14 @@ RFC_1123_FORMAT = "%a, %d %b %Y %H:%M:%S GMT" # General Mime and LDP constants +FEDORA_BINARY = FEDORA_NS + "Binary" + JSONLD_MIMETYPE = "application/ld+json" SPARQL_UPDATE_MIMETYPE = "application/sparql-update" +TURTLE_MIMETYPE = "text/turtle" +SPARQL_QUERY_MIMETYPE = "application/sparql-query" +SPARQL_RESULT_JSON_MIMETYPE = "application/sparql-results+json" +LINK_FORMAT_MIMETYPE = "application/link-format" LDP_NS = "http://www.w3.org/ns/ldp#" LDP_CONTAINER = LDP_NS + "Container" @@ -44,9 +67,45 @@ MEM_ORIGINAL_RESOURCE = MEMENTO_NS + "OriginalResource" MEM_TIMEGATE = MEMENTO_NS + "TimeGate" MEM_TIMEMAP = MEMENTO_NS + "TimeMap" +MEM_MEMENTO = MEMENTO_NS + "Memento" + +ACL_NS = "http://www.w3.org/ns/auth/acl#" + +DC_NS = "http://purl.org/dc/elements/1.1/" + +RDF_NS = "http://www.w3.org/1999/02/22-rdf-syntax-ns#" + +PCDM_NS = "http://pcdm.org/models#" + +# Some standard properties +DC_TITLE = "{}title".format(DC_NS) +RDF_TYPE = "{}type".format(RDF_NS) # Test constructs -OBJECT_TTL = "@prefix dc: ." \ - "@prefix pcdm: ." \ +OBJECT_TTL = "@prefix dc: <{0}> ." \ + "@prefix pcdm: <{1}> ." \ "<> a pcdm:Object ;" \ - "dc:title \"An Object\" ." + "dc:title \"An Object\" .".format(DC_NS, PCDM_NS) + +PCDM_CONTAINER_TITLE = "PCDM Container" + +PCDM_CONTAINER_TTL = "@prefix dc: <{1}> ." \ + "@prefix pcdm: <{2}> ." \ + "<> a pcdm:Object ;" \ + "dc:title \"{0}\" .".format(PCDM_CONTAINER_TITLE, DC_NS, PCDM_NS) +# Standard Response codes +CREATED = 201 +NO_CONTENT = 204 +OK = 200 +PARTIAL_CONTENT = 206 +NOT_AUTHORIZED = 401 +FORBIDDEN = 403 +NOT_FOUND = 404 +CONFLICT = 409 +GONE = 410 +BAD_REQUEST = 400 +METHOD_NOT_ALLOWED = 405 +UNSATISFIABLE_RANGE = 416 +SERVER_ERROR = 500 + + diff --git a/abstract_fedora_tests.py b/abstract_fedora_tests.py index 1e500c2..da155a6 100644 --- a/abstract_fedora_tests.py +++ b/abstract_fedora_tests.py @@ -8,6 +8,7 @@ import inspect import pyjq import json +import os from datetime import datetime, timezone from email.utils import format_datetime @@ -21,8 +22,16 @@ class FedoraTests(unittest.TestCase): # Holds the configuration config = {} + # Holds ultimate success or failure for tests. + results = {} + + """ Holds the container resource which test objects are placed in """ + CONTAINER = "" + def __init__(self, config): super().__init__() + if 'debug_level' not in config: + config['debug_level'] = 1 self.config = config def getBaseUri(self): @@ -33,14 +42,33 @@ def getFedoraBase(self): """ Return the Fedora Base URI """ return self.config[TestConstants.BASE_URL_PARAM] + @staticmethod + def getImagePath(): + return os.path.join(os.getcwd(), 'resources', 'basic_image.jpg'); + + @staticmethod + def getCurrentClass(): + return inspect.stack()[1][0].f_locals['self'].__class__.__name__ + def run_tests(self): + self.results = {} # Reset results variable + current = FedoraTests.getCurrentClass() self.check_for_retest(self.getBaseUri()) + self.log("\nStarting class {0}\n".format(current)) """ Check we can access the machine and then run the tests """ self.not_authorized() for test in self._testdict: method = getattr(self, test) - method() + self.log("Running {0}".format(test)) + try: + method() + self.results[test] = {'result': True} + self.log("Passed\n") + except AssertionError as e: + self.results[test] = {'result': False, 'message': str(e)} + self.log("Failed\n") self.cleanup(self.getBaseUri()) + self.log("\nExiting class {0}".format(current)) def not_authorized(self): """ Ensure we can even access the repository """ @@ -56,6 +84,17 @@ def not_authorized(self): print("Cannot connect the Fedora server, is your configuration correct? {0}".format(baseurl)) quit() + def get_transaction_provider(self): + headers = { + 'Accept': TestConstants.JSONLD_MIMETYPE + } + r = self.do_head(self.getFedoraBase(), headers=headers) + self.assertEqual(200, r.status_code, "Did not get expected response") + link_headers = self.get_link_headers(r) + if TestConstants.FEDORA_TX_ENDPOINT_REL in link_headers.keys(): + return link_headers.get(TestConstants.FEDORA_TX_ENDPOINT_REL)[0] + return None + @staticmethod def create_auth(username, password): """ Create a Basic Auth object using the provided username:password """ @@ -71,6 +110,11 @@ def create_user_auth(self): return FedoraTests.create_auth( self.config[TestConstants.USER_NAME_PARAM], self.config[TestConstants.USER_PASS_PARAM]) + def create_user2_auth(self): + """ Create a Basic Auth object using the test user 2 username:password """ + return FedoraTests.create_auth( + self.config[TestConstants.USER2_NAME_PARAM], self.config[TestConstants.USER2_PASS_PARAM]) + def get_auth(self, admin=True): """ The admin argument of this function is used through out the testing infrastructure, it is explained here. True - use admin username / password credentials @@ -138,6 +182,7 @@ def do_options(self, url, admin=True): return requests.options(url, auth=my_auth) def assert_regex_in(self, pattern, container, msg): + """ Do a regex match against all members of a list """ for i in container: if re.search(pattern, i): return @@ -146,15 +191,18 @@ def assert_regex_in(self, pattern, container, msg): self.fail(self._formatMessage(msg, standard_msg)) def assert_regex_matches(self, pattern, text, msg): + """ Do a regex match against a string """ if re.search(pattern, text): return + standard_msg = '%s pattern not matched in %s' % (unittest.util.safe_repr(pattern), unittest.util.safe_repr(text)) self.fail(self._formatMessage(msg, standard_msg)) - def make_type(self, type): + @staticmethod + def make_type(link_type: str) -> str: """ Turn a URI to Link type format """ - return "<{0}>; rel=\"type\"".format(type) + return "<{0}>; rel=\"type\"".format(link_type) def tear_down(self): """ Delete any resources created """ @@ -162,7 +210,7 @@ def tear_down(self): self.cleanup(node) self.nodes.clear() - def cleanup(self, uri): + def cleanup(self, uri: str) -> bool: """ Remove the CONTAINER """ self.log("Deleting {0}".format(uri)) r = self.do_delete(uri) @@ -179,32 +227,54 @@ def cleanup(self, uri): return True return False - def check_for_retest(self, uri): + def check_for_retest(self, uri: str): """Try to create CONTAINER """ - response = self.do_put(uri) - if response.status_code != 201: - caller = inspect.stack()[1][0].f_locals['self'].__class__.__name__ - print("The class ({}) has been run.\nYou need to remove the resource ({}) and all it's " - "children before re-running the test.".format(caller, uri)) - rerun = input("Remove the test objects and re-run? (y/N) ") - if rerun.lower().strip() == 'y': - if self.cleanup(uri): - self.do_put(uri) + try: + response = self.do_put(uri) + if response.status_code == 403: + print("Received a 403 Forbidden response, please check your credentials and try again.") + quit() + elif response.status_code != 201: + caller = FedoraTests.getCurrentClass() + print("The class ({0}) has been run.\nYou need to remove the resource ({1}) and all it's " + "children before re-running the test.".format(caller, uri)) + rerun = input("Remove the test objects and re-run? (y/N) ") + if rerun.lower().strip() == 'y': + if self.cleanup(uri): + self.do_put(uri) + else: + print("Error removing {0}, you may need to remove it manually.".format(uri)) + quit() else: - print("Error removing $URL, you may need to remove it manually.") + print("Exiting...") quit() - else: - print("Exiting...") - quit() + except requests.exceptions.ConnectionError: + self.log("Unable to connect to your repository, if you are sure its running. Please check your base uri " + "in the configuration.") + quit() + + def getHeader(self, uri: str, header_name: str, headers: dict = None): + """ Perform a HEAD request and return the requested header if exists. + :param uri (str) - The URI to request + :param header_name (str) - The header to look for + :param headers (dict) - headers to provide for the HEAD request or None for none. + :return the header(s) or None + """ + if headers is None: + headers = {} + r = self.do_head(uri, headers=headers) + self.assertTrue(TestConstants.OK, r) + if header_name in r.headers: + return r.headers[header_name] @staticmethod - def get_link_headers(response): + def get_link_headers(response: requests.Response) -> dict: """ Get the response's LINK headers, returned as a dict of key -> list() where the key is the rel=property and the list contains all uris """ headers = {} link_headers = [x.strip() for x in response.headers['Link'].split(",")] for x in link_headers: - matches = re.match(r'\s*<([^>]+)>;\s?rel=[\'"]?(\w+)[\'"]?', x) + matches = re.match(r'\s*<([^>]+)>;\s?rel=[\'"]?([^\'"]+)[\'"]?', x) if matches is not None: try: headers[matches.group(2)] @@ -262,14 +332,81 @@ def assertTitleExists(self, expected, location): return self.fail("Did not find expected title \"{0}\" in response".format(expected)) + def assertTypeExists(self, expected, location): + """ Check resource at {location} for the rdf:type {expected} """ + get_headers = { + 'Accept': TestConstants.JSONLD_MIMETYPE + } + response = self.do_get(location, headers=get_headers) + body = response.content.decode('UTF-8') + json_body = json.loads(body) + found_title = pyjq.all('.[] | ."@type" | .[]', json_body) + for title in found_title: + if title == expected: + return + self.fail("Did not find expected type \"{0}\" in response".format(expected)) + + def assertLinkHeaderExists(self, response, rel, expected=None): + """ Check for the existence of a link header and possibly match the URI """ + link_headers = self.get_link_headers(response) + if rel not in link_headers: + self.fail("Did not find expected link header with rel={0}".format(rel)) + else: + if expected is not None: + if link_headers.get(rel) != expected: + self.fail( + "Did not find expected link header value for rel={0}, found {1} expected {2}" + .format(rel, link_headers.get(rel), expected)) + + def assertHeaderExists(self, response, name, value=None): + """ Check for the existence of a header and possibly match the value """ + if name.lower() not in response.headers: + self.fail("Did not find expected header {0}".format(name)) + else: + if value is not None and response.headers[name.lower()] != value: + self.fail("Did not find expected header value for header {0}, found {1} expected {2}" + .format(name, response.headers[name], value)) + + def assertContrainedByHeaderExists(self, response): + """ Check for link headers with the constrainedby rel type """ + self.assertLinkHeaderExists(response, "http://www.w3.org/ns/ldp#constrainedBy") + def log(self, message): - print(message) + if 'debug_level' in self.config and self.config['debug_level'] > 0: + print(message) def find_binary_description(self, response): headers = FedoraTests.get_link_headers(response) self.assertIsNotNone(headers['describedby']) return headers['describedby'][0] + def checkResponse(self, expected, response: requests.Response): + try: + str_expected = [str(x) for x in expected] + all_expect = " or ".join(str_expected) + self.assertIn(response.status_code, expected, f"Expected ({all_expect}) got {response.status_code}") + except TypeError: + self.checkValue(expected, response.status_code) + + def checkValue(self, expected, received): + self.assertEqual(expected, received, f"Expected {expected} but received {received}") + self.log(" Passed {0} == {0}".format(received)) + + def createBasicContainer(self, parent_location): + """ Make a simple basic container with some RDF content. """ + headers = { + 'Content-type': TestConstants.TURTLE_MIMETYPE + } + return self.do_post(parent_location, headers, TestConstants.OBJECT_TTL) + + def verifyGet(self, uri, admin=True): + r = self.do_get(uri, admin=admin) + self.checkResponse(TestConstants.OK, r) + + def verifyGone(self, uri, admin=True): + r = self.do_get(uri, admin=admin) + self.checkResponse(TestConstants.GONE, r) + def Test(func): """ Decorator for isolating test functions """ diff --git a/archival_group_tests.py b/archival_group_tests.py new file mode 100644 index 0000000..ec8b130 --- /dev/null +++ b/archival_group_tests.py @@ -0,0 +1,217 @@ +#!/bin/env python + +import TestConstants as TC +from abstract_fedora_tests import FedoraTests, register_tests, Test + + +@register_tests +class FedoraArchivalGroupTests(FedoraTests): + # Create test objects all inside here for easy of review + CONTAINER = "/test_archival_groups" + + @Test + def testCreateArchivalGroup(self): + """ Test create an archival group """ + self.log("Create Archival Group container") + r = self.do_post(self.getBaseUri(), headers={ + "Link": self.make_type(TC.ARCHIVAL_GROUP) + }) + container_location = self.get_location(r) + self.checkResponse(TC.CREATED, r) + self.log("Create archivalgroup member") + r = self.do_post(container_location) + self.checkResponse(TC.CREATED, r) + + @Test + def testDeleteArchivalGroupMember(self): + """ Test creating a member of an archival group, deleting it and trying to purge it. """ + self.log("Create Archival Group container") + r = self.do_post(self.getBaseUri(), headers={ + "Link": self.make_type(TC.ARCHIVAL_GROUP) + }) + container_location = self.get_location(r) + self.checkResponse(TC.CREATED, r) + self.log("Create archivalgroup member") + r = self.do_post(container_location) + self.checkResponse(TC.CREATED, r) + member_location = self.get_location(r) + r = self.do_get(member_location) + self.checkResponse(TC.OK, r) + self.log("Delete archivalgroup member") + r = self.do_delete(member_location) + self.checkResponse(TC.NO_CONTENT, r) + self.log("Verify archivalgroup member is gone") + r = self.do_get(member_location) + self.checkResponse(TC.GONE, r) + + self.log("Try to delete archivalgroup member tombstone") + r = self.do_delete(member_location + "/" + TC.FCR_TOMBSTONE) + self.checkResponse(TC.METHOD_NOT_ALLOWED, r) + + @Test + def putOverArchivalGroupMember(self): + """ Test creating an archival group member, delete the member and then PUT overtop """ + self.log("Create Archival Group container") + r = self.do_post(self.getBaseUri(), headers={ + "Link": self.make_type(TC.ARCHIVAL_GROUP) + }) + container_location = self.get_location(r) + self.checkResponse(TC.CREATED, r) + self.log("Create archivalgroup member") + r = self.do_post(container_location) + self.checkResponse(TC.CREATED, r) + member_location = self.get_location(r) + r = self.do_get(member_location) + self.checkResponse(TC.OK, r) + self.log("Delete archivalgroup member") + r = self.do_delete(member_location) + self.checkResponse(TC.NO_CONTENT, r) + self.log("Verify archivalgroup member is gone") + r = self.do_get(member_location) + self.checkResponse(TC.GONE, r) + + self.log("Try to PUT over archivalgroup member tombstone, without header") + r = self.do_put(member_location) + self.checkResponse(TC.GONE, r) + + self.log("Try to PUT over archivalgroup member tombstone, with header") + r = self.do_put(member_location, headers={ + TC.OVERWRITE_TOMBSTONE_HEADER: "true" + }) + self.checkResponse(TC.CREATED, r) + + @Test + def putOverArchivalGroupMemberWithDifferentType(self): + """ Try to PUT a resource over an archival group member with a different interaction model """ + self.log("Create Archival Group container") + r = self.do_post(self.getBaseUri(), headers={ + "Link": self.make_type(TC.ARCHIVAL_GROUP) + }) + container_location = self.get_location(r) + self.checkResponse(TC.CREATED, r) + self.log("Create archivalgroup member") + r = self.do_post(container_location) + self.checkResponse(TC.CREATED, r) + member_location = self.get_location(r) + r = self.do_get(member_location) + self.checkResponse(TC.OK, r) + self.log("Delete archivalgroup member") + r = self.do_delete(member_location) + self.checkResponse(TC.NO_CONTENT, r) + self.log("Verify archivalgroup member is gone") + r = self.do_get(member_location) + self.checkResponse(TC.GONE, r) + + self.log("Try to PUT over archivalgroup member tombstone, without header") + r = self.do_put(member_location, headers={ + "Link": self.make_type(TC.LDP_NON_RDF_SOURCE), + "Content-type": "text/plain" + }, body="Hello World!") + self.checkResponse(TC.GONE, r) + + self.log("Try to PUT over archivalgroup member tombstone, with header") + r = self.do_put(member_location, headers={ + "Link": self.make_type(TC.LDP_NON_RDF_SOURCE), + "Content-type": "text/plain", + TC.OVERWRITE_TOMBSTONE_HEADER: "true" + }, body="Hello World!") + self.checkResponse(TC.CONFLICT, r) + + @Test + def testCreateAndDeleteArchivalGroup(self): + """ Test creating and deleting an archival group """ + self.log("Create Archival Group container") + r = self.do_post(self.getBaseUri(), headers={ + "Link": self.make_type(TC.ARCHIVAL_GROUP) + }) + container_location = self.get_location(r) + self.checkResponse(TC.CREATED, r) + self.log("Create archivalgroup member") + r = self.do_post(container_location) + self.checkResponse(TC.CREATED, r) + member_location = self.get_location(r) + r = self.do_get(member_location) + self.checkResponse(TC.OK, r) + self.log("Delete archivalgroup") + r = self.do_delete(container_location) + self.checkResponse(TC.NO_CONTENT, r) + self.log("Verify archivalgroup is gone") + r = self.do_get(container_location) + self.checkResponse(TC.GONE, r) + + self.log("Try to delete archivalgroup tombstone") + r = self.do_delete(container_location + "/" + TC.FCR_TOMBSTONE) + self.checkResponse(TC.NO_CONTENT, r) + + self.log("Check for archivalgroup") + r = self.do_get(container_location) + self.checkResponse(TC.NOT_FOUND, r) + self.log("Check for archivalgroup member") + r = self.do_get(member_location) + self.checkResponse(TC.NOT_FOUND, r) + + @Test + def testCreateAndPutOverArchivalGroup(self): + """ Test create and delete an archival group and put overtop the tombstone """ + self.log("Create Archival Group container") + r = self.do_post(self.getBaseUri(), headers={ + "Link": self.make_type(TC.ARCHIVAL_GROUP) + }) + container_location = self.get_location(r) + self.checkResponse(TC.CREATED, r) + self.log("Create archivalgroup member") + r = self.do_post(container_location) + self.checkResponse(TC.CREATED, r) + member_location = self.get_location(r) + r = self.do_get(member_location) + self.checkResponse(TC.OK, r) + + self.log("Try to delete archivalgroup") + r = self.do_delete(container_location) + self.checkResponse(TC.NO_CONTENT, r) + r = self.do_get(container_location) + self.checkResponse(TC.GONE, r) + + self.log("Try to PUT over archivalgroup, without header") + r = self.do_put(container_location) + self.checkResponse(TC.GONE, r) + + self.log("Try to PUT a new ArchivalGroupt over archivalgroup, with header") + r = self.do_put(container_location, headers={ + "Link": self.make_type(TC.ARCHIVAL_GROUP), + TC.OVERWRITE_TOMBSTONE_HEADER: "true" + }) + self.checkResponse(TC.CREATED, r) + + @Test + def testCreateAndPutOverArchivalGroupWithDifferentType(self): + """ Test create/delete an archival group and try to PUT overtop the tombstone with a different interaction + model """ + self.log("Create Archival Group container") + r = self.do_post(self.getBaseUri(), headers={ + "Link": self.make_type(TC.ARCHIVAL_GROUP) + }) + container_location = self.get_location(r) + self.checkResponse(TC.CREATED, r) + self.log("Create archivalgroup member") + r = self.do_post(container_location) + self.checkResponse(TC.CREATED, r) + member_location = self.get_location(r) + r = self.do_get(member_location) + self.checkResponse(TC.OK, r) + + self.log("Try to delete archivalgroup") + r = self.do_delete(container_location) + self.checkResponse(TC.NO_CONTENT, r) + r = self.do_get(container_location) + self.checkResponse(TC.GONE, r) + + self.log("Try to PUT over archivalgroup, without header") + r = self.do_put(container_location) + self.checkResponse(TC.GONE, r) + + self.log("Try to PUT a normal RDFResource over archivalgroup, with header") + r = self.do_put(container_location, headers={ + TC.OVERWRITE_TOMBSTONE_HEADER: "true" + }) + self.checkResponse(TC.CONFLICT, r) diff --git a/authz_tests.py b/authz_tests.py index acf40ca..569c78f 100644 --- a/authz_tests.py +++ b/authz_tests.py @@ -1,6 +1,6 @@ #!/bin/env python -import TestConstants +import TestConstants as TC from abstract_fedora_tests import FedoraTests, register_tests, Test import random import uuid @@ -8,7 +8,6 @@ @register_tests class FedoraAuthzTests(FedoraTests): - # Create test objects all inside here for easy of review CONTAINER = "/test_authz" @@ -34,208 +33,603 @@ def verifyAuthEnabled(self): temp_auth = FedoraTests.create_auth(random_string, random_string) r = self.do_get(self.getFedoraBase(), admin=temp_auth) - self.assertEqual(401, r.status_code, "Did not get expected response code") - - def getAclUri(self, response): - acls = self.get_link_headers(response) - self.assertIsNotNone(acls['acl']) - return acls['acl'][0] + if TC.NOT_AUTHORIZED != r.status_code: + self.log("It appears that authentication is not enabled on your repository.") + quit() + + @staticmethod + def getAclUri(response): + acls = FedoraAuthzTests.get_link_headers(response) + try: + return acls['acl'][0] + except KeyError: + Exception("No acl link header found") @Test def doAuthTests(self): - self.log("Running doAuthTests") - + """ Basic permissions test """ self.verifyAuthEnabled() self.log("Create \"cover\" container") - r = self.do_put(self.getBaseUri() + "/cover") - self.assertEqual(201, r.status_code, "Did not get expected response code") + r = self.do_post(headers={ + 'Slug': 'cover', + 'Content-type': 'text/turtle' + }, body="@prefix pcdm: .\n <> a pcdm:Object .") + self.checkResponse(TC.CREATED, r) cover_location = self.get_location(r) - cover_acl = self.getAclUri(r) + cover_acl = FedoraAuthzTests.getAclUri(r) self.log("Make \"cover\" a pcdm:Object") sparql = "PREFIX pcdm: " \ "PREFIX rdf: " \ "INSERT { <> rdf:type pcdm:Object } WHERE { }" headers = { - 'Content-type': TestConstants.SPARQL_UPDATE_MIMETYPE + 'Content-type': TC.SPARQL_UPDATE_MIMETYPE } r = self.do_patch(cover_location, headers=headers, body=sparql) - self.assertEqual(204, r.status_code, "Did not get expected response code") + self.checkResponse(204, r) self.log("Verify no current ACL") r = self.do_get(cover_acl) - self.assertEqual(404, r.status_code, "Did not get expected response code") + self.checkResponse(TC.NOT_FOUND, r) self.log("Add ACL to \"cover\"") headers = { 'Content-type': 'text/turtle' } - body = self.COVER_ACL.format(cover_location, self.config[TestConstants.USER_NAME_PARAM]) + body = self.COVER_ACL.format(cover_location, self.config[TC.USER_NAME_PARAM]) r = self.do_put(cover_acl, headers=headers, body=body) - self.assertEqual(201, r.status_code, "Did not get expected response code") + self.checkResponse(TC.CREATED, r) self.log("Create \"files\" inside \"cover\"") r = self.do_put(cover_location + "/files") - self.assertEqual(201, r.status_code, "Did not get expected response code") + self.checkResponse(TC.CREATED, r) files_location = self.get_location(r) - files_acl = self.getAclUri(r) + files_acl = FedoraAuthzTests.getAclUri(r) self.log("Anonymous can't access \"cover\"") r = self.do_get(cover_location, admin=None) - self.assertEqual(401, r.status_code, "Did not get expected response code") + self.checkResponse(TC.NOT_AUTHORIZED, r) self.log("Anonymous can't access \"cover/files\"") r = self.do_get(files_location, admin=None) - self.assertEqual(401, r.status_code, "Did not get expected response code") + self.checkResponse(TC.NOT_AUTHORIZED, r) - self.log("{0} can access \"cover\"".format(self.config[TestConstants.ADMIN_USER_PARAM])) + self.log("{0} can access \"cover\"".format(self.config[TC.ADMIN_USER_PARAM])) r = self.do_get(cover_location) - self.assertEqual(200, r.status_code, "Did not get expected response code") + self.checkResponse(TC.OK, r) - self.log("{0} can access \"cover/files\"".format(self.config[TestConstants.ADMIN_USER_PARAM])) + self.log("{0} can access \"cover/files\"".format(self.config[TC.ADMIN_USER_PARAM])) r = self.do_get(files_location) - self.assertEqual(200, r.status_code, "Did not get expected response code") + self.checkResponse(TC.OK, r) - self.log("{0} can access \"cover\"".format(self.config[TestConstants.USER_NAME_PARAM])) + self.log("{0} can access \"cover\"".format(self.config[TC.USER_NAME_PARAM])) r = self.do_get(cover_location, admin=False) - self.assertEqual(200, r.status_code, "Did not get expected response code") + self.checkResponse(TC.OK, r) - self.log("{0} can access \"cover/files\"".format(self.config[TestConstants.USER_NAME_PARAM])) + self.log("{0} can access \"cover/files\"".format(self.config[TC.USER_NAME_PARAM])) r = self.do_get(files_location, admin=False) - self.assertEqual(200, r.status_code, "Did not get expected response code") + self.checkResponse(TC.OK, r) - auth = self.create_auth(self.config[TestConstants.USER2_NAME_PARAM], - self.config[TestConstants.USER2_PASS_PARAM]) - self.log("{0} can't access \"cover\"".format(self.config[TestConstants.USER2_NAME_PARAM])) + auth = self.create_auth(self.config[TC.USER2_NAME_PARAM], + self.config[TC.USER2_PASS_PARAM]) + self.log("{0} can't access \"cover\"".format(self.config[TC.USER2_NAME_PARAM])) r = self.do_get(cover_location, admin=auth) - self.assertEqual(403, r.status_code, "Did not get expected response code") + self.checkResponse(TC.FORBIDDEN, r) - self.log("{0} can't access \"cover/files\"".format(self.config[TestConstants.USER2_NAME_PARAM])) + self.log("{0} can't access \"cover/files\"".format(self.config[TC.USER2_NAME_PARAM])) r = self.do_get(files_location, admin=auth) - self.assertEqual(403, r.status_code, "Did not get expected response code") + self.checkResponse(TC.FORBIDDEN, r) self.log("Verify \"cover/files\" has no ACL") r = self.do_get(files_acl) - self.assertEqual(404, r.status_code, "Did not get expected response code") + self.checkResponse(TC.NOT_FOUND, r) - self.log("PUT Acl to \"cover/files\" to allow access for {0}".format(self.config[TestConstants.USER2_NAME_PARAM])) + self.log("PUT Acl to \"cover/files\" to allow access for {0}".format(self.config[TC.USER2_NAME_PARAM])) headers = { 'Content-type': 'text/turtle' } - body = self.FILES_ACL.format(files_location, self.config[TestConstants.USER2_NAME_PARAM]) + body = self.FILES_ACL.format(files_location, self.config[TC.USER2_NAME_PARAM]) r = self.do_put(files_acl, headers=headers, body=body) - self.assertEqual(201, r.status_code, "Did not get expected response code") + self.checkResponse(TC.CREATED, r) - self.log("{0} can't access \"cover\"".format(self.config[TestConstants.USER2_NAME_PARAM])) + self.log("{0} can't access \"cover\"".format(self.config[TC.USER2_NAME_PARAM])) r = self.do_get(cover_location, admin=auth) - self.assertEqual(403, r.status_code, "Did not get expected response code") + self.checkResponse(TC.FORBIDDEN, r) - self.log("{0} can access \"cover/files\"".format(self.config[TestConstants.USER2_NAME_PARAM])) + self.log("{0} can access \"cover/files\"".format(self.config[TC.USER2_NAME_PARAM])) r = self.do_get(files_location, admin=auth) - self.assertEqual(200, r.status_code, "Did not get expected response code") - - self.log("Passed") + self.checkResponse(TC.OK, r) @Test def doDirectIndirectAuthTests(self): - self.log("Running doDirectIndirectAuthTests") - + """ Test that direct and indirect containers require permissions to the ldp:membershipResource too """ self.verifyAuthEnabled() self.log("Create a target container") r = self.do_post() - self.assertEqual(201, r.status_code, "Did not get expected response code") + self.checkResponse(TC.CREATED, r) target_location = self.get_location(r) - target_acl = self.getAclUri(r) + target_acl = FedoraAuthzTests.getAclUri(r) self.log("Create a write container") r = self.do_post() - self.assertEqual(201, r.status_code, "Did not get expected response code") + self.checkResponse(TC.CREATED, r) write_location = self.get_location(r) - write_acl = self.getAclUri(r) + write_acl = FedoraAuthzTests.getAclUri(r) self.log("Make sure the /target resource is readonly") - target_ttl = "@prefix acl: .\n"\ + target_ttl = "@prefix acl: <{2}> .\n" \ "<#readauthz> a acl:Authorization ;\n" \ " acl:agent \"{0}\" ;\n" \ " acl:mode acl:Read ;\n" \ - " acl:accessTo <{1}> .\n".format(self.config[TestConstants.USER_NAME_PARAM], target_location) + " acl:accessTo <{1}> .\n".format(self.config[TC.USER_NAME_PARAM], target_location, + TC.ACL_NS) headers = { 'Content-type': 'text/turtle' } r = self.do_put(target_acl, headers=headers, body=target_ttl) - self.assertEqual(201, r.status_code, "Did not get expected response code") + self.checkResponse(TC.CREATED, r) - self.log("Make sure the write resource is writable by \"{0}\"".format(self.config[TestConstants.USER_NAME_PARAM])) - write_ttl = "@prefix acl: .\n" \ + self.log("Make sure the write resource is writable by \"{0}\"".format(self.config[TC.USER_NAME_PARAM])) + write_ttl = "@prefix acl: <{2}> .\n" \ "<#writeauth> a acl:Authorization ;\n" \ " acl:agent \"{0}\" ;\n" \ " acl:mode acl:Read, acl:Write ;\n" \ " acl:accessTo <{1}> ;\n" \ - " acl:default <{1}> .\n".format(self.config[TestConstants.USER_NAME_PARAM], write_location) + " acl:default <{1}> .\n".format(self.config[TC.USER_NAME_PARAM], write_location, + TC.ACL_NS) r = self.do_put(write_acl, headers=headers, body=write_ttl) - self.assertEqual(201, r.status_code, "Did not get expected response code") + self.checkResponse(TC.CREATED, r) - self.log("Verify that \"{0}\" can create a simple resource under write resource (POST)".format(self.config[TestConstants.USER_NAME_PARAM])) + self.log("Verify that \"{0}\" can create a simple resource under write resource (POST)".format( + self.config[TC.USER_NAME_PARAM])) r = self.do_post(write_location, admin=False) - self.assertEqual(201, r.status_code, "Did not get expected response code") + self.checkResponse(TC.CREATED, r) uuid_value = str(uuid.uuid4()) self.log("Verify that \"{0}\" can create a simple resource under write resource (PUT)".format( - self.config[TestConstants.USER_NAME_PARAM])) + self.config[TC.USER_NAME_PARAM])) r = self.do_put(write_location + "/" + uuid_value, admin=False) - self.assertEqual(201, r.status_code, "Did not get expected response code") + self.checkResponse(TC.CREATED, r) - self.log("Verify that \"{0}\" CANNOT create a resource under target resource".format(self.config[TestConstants.USER_NAME_PARAM])) + self.log("Verify that \"{0}\" CANNOT create a resource under target resource".format( + self.config[TC.USER_NAME_PARAM])) r = self.do_post(target_location, admin=False) - self.assertEqual(403, r.status_code, "Did not get expected response code") + self.checkResponse(TC.FORBIDDEN, r) - self.log("Verify that \"{0}\" CANNOT create direct or indirect containers that reference target resources".format(self.config[TestConstants.USER_NAME_PARAM])) + self.log( + "Verify that \"{0}\" CANNOT create direct or indirect containers that reference target resources".format( + self.config[TC.USER_NAME_PARAM])) headers = { 'Content-type': 'text/turtle', - 'Link': self.make_type(TestConstants.LDP_DIRECT) + 'Link': self.make_type(TC.LDP_DIRECT) } - direct_ttl = "@prefix ldp: .\n" \ + direct_ttl = "@prefix ldp: <{0}> .\n" \ "@prefix test: .\n" \ - "<> ldp:membershipResource <{0}> ;\n" \ - "ldp:hasMemberRelation test:predicateToCreate .\n".format(target_location) + "<> ldp:membershipResource <{1}> ;\n" \ + "ldp:hasMemberRelation test:predicateToCreate .\n".format(TC.LDP_NS, target_location) r = self.do_post(write_location, headers=headers, body=direct_ttl, admin=False) - self.assertEqual(403, r.status_code, "Did not get expected response code") + self.checkResponse(TC.FORBIDDEN, r) headers = { 'Content-type': 'text/turtle', - 'Link': self.make_type(TestConstants.LDP_INDIRECT) + 'Link': self.make_type(TC.LDP_INDIRECT) } - indirect_ttl = "@prefix ldp: .\n" \ + indirect_ttl = "@prefix ldp: <{0}> .\n" \ "@prefix test: .\n" \ "<> ldp:insertedContentRelation test:something ;\n" \ - "ldp:membershipResource <{0}> ;\n" \ - "ldp:hasMemberRelation test:predicateToCreate .\n".format(target_location) + "ldp:membershipResource <{1}> ;\n" \ + "ldp:hasMemberRelation test:predicateToCreate .\n".format(TC.LDP_NS, target_location) r = self.do_post(write_location, headers=headers, body=indirect_ttl, admin=False) - self.assertEqual(403, r.status_code, "Did not get expected response code") + self.checkResponse(TC.FORBIDDEN, r) self.log("Go ahead and create the indirect and direct containers as admin") r = self.do_post(write_location, headers=headers, body=direct_ttl) - self.assertEqual(201, r.status_code, "Did not get expected response code") + self.checkResponse(TC.CREATED, r) direct_location = self.get_location(r) r = self.do_post(write_location, headers=headers, body=indirect_ttl) - self.assertEqual(201, r.status_code, "Did not get expected response code") + self.checkResponse(TC.CREATED, r) indirect_location = self.get_location(r) self.log("Attempt to verify that \"{0}\" can not actually create relationships on the readonly resource via " \ - "direct or indirect container".format(self.config[TestConstants.USER_NAME_PARAM])) + "direct or indirect container".format(self.config[TC.USER_NAME_PARAM])) r = self.do_post(direct_location, admin=False) - self.assertEqual(403, r.status_code, "Did not get expected status code") + self.checkResponse(TC.FORBIDDEN, r) r = self.do_post(indirect_location, admin=False) - self.assertEqual(403, r.status_code, "Did not get expected status code") + self.checkResponse(TC.FORBIDDEN, r) - self.log("Verify that \"{0}\" can still create a simple resource under write resource (POST)".format(self.config[TestConstants.USER_NAME_PARAM])) + self.log("Verify that \"{0}\" can still create a simple resource under write resource (POST)".format( + self.config[TC.USER_NAME_PARAM])) r = self.do_post(write_location, admin=False) - self.assertEqual(201, r.status_code, "Did not get expected response code") + self.checkResponse(TC.CREATED, r) uuid_value = str(uuid.uuid4()) self.log("Verify that \"{0}\" can still create a simple resource under write resource (PUT)".format( - self.config[TestConstants.USER_NAME_PARAM])) + self.config[TC.USER_NAME_PARAM])) r = self.do_put(write_location + "/" + uuid_value, admin=False) - self.assertEqual(201, r.status_code, "Did not get expected response code") + self.checkResponse(TC.CREATED, r) + + @Test + def multipleAuthzCreatePermissiveSet(self): + """ Ensure that multiple ACLs result in the most permissive union of all the ACLs """ + self.verifyAuthEnabled() + + self.log("Create a target container") + r = self.do_post() + self.checkResponse(201, r) + target_location = self.get_location(r) + target_acl = FedoraAuthzTests.getAclUri(r) + + double_ttl = "@prefix acl: <{2}> .\n" \ + "<#readonly> a acl:Authorization ;\n" \ + " acl:agent \"{0}\" ;\n" \ + " acl:mode acl:Read ;\n" \ + " acl:accessTo <{1}> .\n" \ + "<#readwrite> a acl:Authorization ;\n" \ + " acl:agent \"{0}\" ;\n" \ + " acl:mode acl:Write ;\n" \ + " acl:accessTo <{1}> .\n".format(self.config[TC.USER_NAME_PARAM], target_location, + TC.ACL_NS) + headers = { + 'Content-type': TC.TURTLE_MIMETYPE + } + + self.log("Add ACL with one read and one write authz for the same URI.") + r = self.do_put(target_acl, headers=headers, body=double_ttl) + self.checkResponse(201, r) + + self.log("Check we can read") + r = self.do_get(target_location, admin=False) + self.checkResponse(200, r) + + self.log("Check we can write") + headers = { + 'Content-type': TC.SPARQL_UPDATE_MIMETYPE + } + body = "prefix dc: <{0}> INSERT {{ <> dc:title \"A new title\" }} WHERE {{}}".format(TC.DC_NS) + r = self.do_patch(target_location, headers=headers, body=body) + self.checkResponse(204, r) + + @Test + def testAllThingsPointTogether(self): + self.verifyAuthEnabled() + """ Ensure we show the same ACL location for binaries and their metadata """ + self.log("Create a target binary") + headers = { + 'Content-type': 'text/plain', + 'Link': self.make_type(TC.LDP_NON_RDF_SOURCE) + } + r = self.do_post(headers=headers, body="this is a test payload") + self.checkResponse(201, r) + binary_location = self.get_location(r) + expected_acl = binary_location + "/fcr:acl" + acl_location = FedoraAuthzTests.getAclUri(r) + binary_description = self.find_binary_description(r) + + self.assertNotEqual(binary_location, binary_description) + + self.log("Check binary's acl link header is correct.") + self.checkValue(expected_acl, acl_location) + + self.log("Check binary description's acl link header is correct") + + r = self.do_get(binary_location) + self.checkResponse(200, r) + acl_location = FedoraAuthzTests.getAclUri(r) + self.checkValue(expected_acl, acl_location) + + self.log("Check binary timemap's acl link header is correct.") + binary_versions = binary_location + "/fcr:versions" + r = self.do_get(binary_versions) + self.checkResponse(200, r) + acl_location = FedoraAuthzTests.getAclUri(r) + self.checkValue(expected_acl, acl_location) + + self.log("Create a version of binary") + r = self.do_post(parent=binary_versions) + self.checkResponse(201, r) + + self.log("Check binary description timemap's acl link header is correct.") + binary_metadata_versions = binary_description + "/fcr:versions" + r = self.do_get(binary_metadata_versions) + self.checkResponse(200, r) + acl_location = FedoraAuthzTests.getAclUri(r) + self.checkValue(expected_acl, acl_location) + + @Test + def doBinaryAndMetadataShareACL(self): + """ Test that a binary and it's metadata share the same ACL permissions """ + self.verifyAuthEnabled() + + self.log("Create a target binary") + headers = { + 'Content-type': 'text/plain', + 'Link': self.make_type(TC.LDP_NON_RDF_SOURCE) + } + r = self.do_post(headers=headers, body="this is a test payload") + self.checkResponse(201, r) + binary_location = self.get_location(r) + acl_location = FedoraAuthzTests.getAclUri(r) + binary_description = self.find_binary_description(r) + + self.log("Add ACL allowing write to metadata but not binary for user") + binary_acl = "@prefix acl: <{0}> .\n" \ + "<#binary> a acl:Authorization ;\n" \ + " acl:mode acl:Write ;\n" \ + " acl:accessTo <{1}> ;\n" \ + " acl:agent \"{2}\" .\n".format(TC.ACL_NS, binary_location, + self.config[TC.USER_NAME_PARAM]) + headers = { + 'Content-type': TC.TURTLE_MIMETYPE + } + r = self.do_put(acl_location, headers=headers, body=binary_acl) + self.checkResponse(201, r) + + self.log("Try to read binary") + r = self.do_head(binary_location, admin=False) + self.checkResponse(403, r) + + self.log("Try to write binary") + headers = { + 'Content-type': 'text/plain' + } + r = self.do_put(binary_location, headers=headers, body="This is a new body", admin=False) + self.checkResponse(204, r) + + self.log("Try to read the metadata") + r = self.do_get(binary_description, admin=False) + self.checkResponse(403, r) + + self.log("Try to patch metadata") + patch_body = "prefix dc: <{0}> INSERT DATA {{ <> dc:title \"Updated title\"}}".format(TC.DC_NS) + headers = { + 'Content-type': TC.SPARQL_UPDATE_MIMETYPE + } + r = self.do_patch(binary_description, patch_body, headers=headers, admin=False) + self.checkResponse(204, r) + + @Test + def testContainerWithAccessToClass(self): + """ Test permissions using AccessToClass """ + self.verifyAuthEnabled() + + self.log("Create a container") + r = self.do_post() + location = self.get_location(r) + acl_location = self.getAclUri(r) + versions_location = location + "/" + TC.FCR_VERSIONS + + full_acl = "@prefix acl: <{0}> .\n" \ + "@prefix fedora: <{1}> .\n" \ + "@prefix memento: <{2}> .\n" \ + "<#container> a acl:Authorization ;\n" \ + " acl:mode acl:Read ;\n" \ + " acl:accessTo <{3}> ;\n" \ + " acl:agent \"{4}\" .\n" \ + "<#timemap> a acl:Authorization ;\n" \ + " acl:mode acl:Read ;\n" \ + " acl:accessToClass fedora:TimeMap ;\n" \ + " acl:default <{3}> ;\n" \ + " acl:agent \"{4}\" .\n" \ + "<#memento> a acl:Authorization ;\n" \ + " acl:mode acl:Read ;\n" \ + " acl:accessToClass memento:Memento ;\n" \ + " acl:default <{3}> ;\n" \ + " acl:agent \"{5}\" .\n".format(TC.ACL_NS, TC.FEDORA_NS, TC.MEMENTO_NS, + location, self.config[TC.USER_NAME_PARAM], + self.config[TC.ADMIN_USER_PARAM]) + turtle_headers = { + 'Content-type': TC.TURTLE_MIMETYPE + } + + user2 = self.create_user2_auth() + + self.log("Put ACL giving test user 1 read access to container and timemap but not mementos") + r = self.do_put(acl_location, headers=turtle_headers, body=full_acl) + self.checkResponse(201, r) + + self.log("Create a Memento as admin") + r = self.do_post(versions_location) + self.checkResponse(201, r) + memento_location = self.get_location(r) + + self.log("Try to get container as test user 1") + r = self.do_get(location, admin=False) + self.checkResponse(200, r) + + self.log("Try to get container as test user 2") + r = self.do_get(location, admin=user2) + self.checkResponse(403, r) + + self.log("Try to get timemap as test user 1") + r = self.do_get(versions_location, admin=False) + self.checkResponse(200, r) + + self.log("Try to get timemap as test user 2") + r = self.do_get(versions_location, admin=user2) + self.checkResponse(403, r) + + self.log("Check that memento exists as admin") + r = self.do_get(memento_location) + self.checkResponse(200, r) + + self.log("Try to get memento as test user 1") + r = self.do_get(memento_location, admin=False) + self.checkResponse(200, r) + + self.log("Try to get memento as test user 2") + r = self.do_get(memento_location, admin=user2) + self.checkResponse(403, r) + + @Test + def testPermissionsDoNotExtendInTx(self): + """ Ensure permissions work in transactions and do not extend """ + self.verifyAuthEnabled() + self.log("Create a container") + r = self.do_post() + location = self.get_location(r) + acl_location = self.getAclUri(r) + headers = { + 'Content-type': TC.TURTLE_MIMETYPE + } + readwriteString = "@prefix acl: .\n" \ + "<#readauthz> a acl:Authorization ;\n" \ + " acl:agent \"{0}\" ;\n" \ + " acl:mode acl:Read, acl:Write ;\n" \ + " acl:accessTo <{1}> .".format(self.config[TC.USER_NAME_PARAM], location) + + r = self.do_put(acl_location, headers=headers, body=readwriteString) + self.checkResponse(201, r) + # Test that user28 can read target resource. + r = self.do_get(location, admin=False) + self.checkResponse(200, r) + # Test that user28 can patch target resource. + patchString = "prefix dc: INSERT { <> dc:title " \ + "\"new title\" } WHERE {}" + patch_headers = { + 'Content-type': 'application/sparql-update' + } + r = self.do_patch(location, headers=patch_headers, body=patchString, admin=False) + self.checkResponse(204, r) + # Test that user28 can post to target resource. + r = self.do_post(location, admin=False) + self.checkResponse(201, r) + childResource = self.get_location(r) + # Test that user28 cannot patch the child resource(ACL is not acl: default). + r = self.do_patch(childResource, headers=patch_headers, body=patchString, admin=False) + self.checkResponse(403, r) + # Test that user28 cannot post to a child resource. + r = self.do_post(childResource, admin=False) + self.checkResponse(403, r) + # Test another user cannot access the target resource. + r = self.do_get(location, admin=self.create_user2_auth()) + self.checkResponse(403, r) + # Get the transaction endpoint. + transactionEndpoint = self.get_transaction_provider() + # Create a transaction. + r = self.do_post(transactionEndpoint) + self.checkResponse(201, r) + transactionId = self.get_location(r) + self.log("transaction ID {0}".format(transactionId)) + # Test user28 can post to target resource in a transaction. + txHeaders = { + 'Atomic-ID': transactionId + } + r = self.do_post(location, headers=txHeaders, admin=False) + self.checkResponse(201, r) + txChild = self.get_location(r) + # Test user28 cannot post to the child in a transaction. + r = self.do_post(txChild, headers=txHeaders, admin=False) + self.checkResponse(403, r) + + @Test + def testGroupAuth(self): + """ Test authentication using a group """ + self.log("THIS TEST REQUIRES FEDORA TO HAVE THE TESTSUITE WEBID PREFIX") + agentGroup = "@prefix acl: . " \ + "@prefix vcard: . " \ + "<> a vcard:Group;" \ + "vcard:hasMember <{}{}>.".format(TC.USER_URL_PREFIX, + self.config[TC.USER_NAME_PARAM]) + head = { + 'Content-type': TC.TURTLE_MIMETYPE + } + r = self.do_post(headers=head, body=agentGroup) + self.checkResponse(201, r) + group_location = self.get_location(r) + + r = self.do_post() + self.checkResponse(201, r) + target_location = self.get_location(r) + + acl = "@prefix acl: ." \ + "@prefix foaf: ." \ + "<#authorization> a acl:Authorization; " \ + "acl:accessTo <{}>;" \ + "acl:mode acl:Read, acl:Write;" \ + "acl:agentGroup <{}>.".format(target_location, group_location) + + r = self.do_put(target_location + "/" + TC.FCR_ACL, headers=head, body=acl) + self.checkResponse(201, r) + + r = self.do_get(target_location, admin=False) + self.checkResponse(200, r) + + @Test + def testControlOnlyPut(self): + """ Test that a user with Control permission can only PUT to the ACL and not read it. """ + r = self.do_post() + self.checkResponse(201, r) + resource_uri = self.get_location(r) + + control_acl = "@prefix acl: .\n" \ + "@prefix foaf: .\n" \ + "<#restricted> a acl:Authorization ;\n" \ + "acl:agent \"{0}\" ;\n" \ + "acl:mode acl:Control;\n" \ + "acl:default <{1}> ;\n" \ + "acl:accessTo <{1}> .".format(self.config[TC.USER_NAME_PARAM], resource_uri) + head = { + 'Content-type': TC.TURTLE_MIMETYPE + } + r = self.do_put(resource_uri + "/" + TC.FCR_ACL, headers=head, body=control_acl) + self.checkResponse(201, r) + + # Verify that testuser can not read the resource + r = self.do_get(resource_uri, admin=False) + self.checkResponse(403, r) + + new_control_acl = "@prefix acl: .\n" \ + "@prefix foaf: .\n" \ + " <#openaccess> a acl:Authorization ;" \ + " acl:mode acl:Read ;\n" \ + " acl:agentClass foaf:Agent ; \n" \ + " acl:accessTo <{}> .".format(resource_uri) + # Update ACL as testuser + r = self.do_put(resource_uri + "/" + TC.FCR_ACL, headers=head, body=new_control_acl, admin=False) + self.checkResponse(204, r) + + # Check that now testuser can read + r = self.do_get(resource_uri, admin=False) + self.checkResponse(200, r) + + @Test + def testCanAccessToAndAccessToClass(self): + """ Test you can't use both acl:accessTo and acl:accessToClass on the same ACL """ + r = self.do_post() + self.checkResponse(201, r) + resource_uri = self.get_location(r) + + acl = "@prefix acl: .\n" \ + "@prefix foaf: .\n" \ + "@prefix pcdm: .\n" \ + " <#openaccess> a acl:Authorization ;" \ + " acl:mode acl:Read ;\n" \ + " acl:agentClass foaf:Agent ; \n" \ + " acl:accessTo <{}> ; \n" \ + " acl:accessToClass pcdm:Object .".format(resource_uri) + + headers = { + 'Content-type': TC.TURTLE_MIMETYPE + } + + r = self.do_put(resource_uri + "/" + TC.FCR_ACL, headers=headers, body=acl) + self.checkResponse(400, r) + self.assertContrainedByHeaderExists(r) + + @Test + def testPutInvalidAcl(self): + """ Test you can't define the ACL location """ + self.log("Create a mock container to use as the ACL") + r = self.do_post() + self.checkResponse(201, r) + resource_uri = self.get_location(r) + + headers = { + 'Link': "<{}>; rel=\"acl\"".format(resource_uri) + } + # Make a new resource trying to define the location of the ACL. + r = self.do_post(headers=headers) + self.checkResponse(400, r) + self.assertContrainedByHeaderExists(r) diff --git a/basic_interaction_tests.py b/basic_interaction_tests.py index 4c270dc..f4a32d5 100644 --- a/basic_interaction_tests.py +++ b/basic_interaction_tests.py @@ -1,8 +1,16 @@ #!/bin/env python +import random +import shutil +import string +import tempfile +import time -import TestConstants +import TestConstants as TC from abstract_fedora_tests import FedoraTests, register_tests, Test import os +import pyjq +import json +import uuid @register_tests @@ -17,85 +25,136 @@ def createTestResource(self, type, files=None): headers = { 'Link': link_type } + self.log("Create the resource") r = self.do_post(self.getBaseUri(), headers=headers, files=files) - self.assertEqual(201, r.status_code, "Did not create container") + self.checkResponse(TC.CREATED, r) location = self.get_location(r) self.nodes.append(location) + self.log("GET the resource") r = self.do_get(location) - self.assertEqual(200, r.status_code, "Did not get container") - self.assertIsNotNone(r.headers['Link'], "Did not get any link headers returned") + self.checkResponse(TC.OK, r) + self.assertHeaderExists(r, 'Link') type_headers = FedoraTests.get_link_headers(r) self.assertIsNotNone(type_headers['type'], "Did not get any link headers with rel=type") self.assertIn(type, type_headers['type'], "Did not find link header for {}".format(type)) return location + def getEtag(self, uri): + """ Get the eTag header for a specified URI. """ + return self.getHeader(uri, "ETag").strip() + + def getStateToken(self, uri): + """ Get the X-State-Token header for a specified URI. """ + return self.getHeader(uri, "X-State-Token").strip() + + def duplicateImage(self): + """ Copy the test image to a location """ + new_file = os.path.join(tempfile.gettempdir(), 'temp_image.jpeg') + shutil.copyfile(self.getImagePath(), new_file) + return new_file + + @Test + def testMissingResource(self): + """ Test we get a 404 for a non-existant resource """ + fake_id = str(uuid.uuid4()) + r = self.do_get(self.getFedoraBase() + "/" + fake_id) + self.assertEqual(404, r.status_code, "Did not get expected response") + + @Test + def testDeleteAResource(self): + """ Test that we can reuse an atomic resource URL once it is purged """ + self.log("Create container") + r = self.do_post(self.getBaseUri()) + container_location = self.get_location(r) + self.checkResponse(TC.CREATED, r) + + self.log("Check container exists") + r = self.do_head(container_location) + self.checkResponse(TC.OK, r) + r = self.do_get(container_location) + self.checkResponse(TC.OK, r) + + self.log("Delete the container") + r = self.do_delete(container_location) + self.checkResponse(TC.NO_CONTENT, r) + + self.log("Check container doesn't exists") + r = self.do_head(container_location) + self.checkResponse(TC.GONE, r) + r = self.do_get(container_location) + self.checkResponse(TC.GONE, r) + + self.log("Try to put to location held by tombstone") + r = self.do_put(container_location) + self.checkResponse(TC.GONE, r) + + self.log("Delete the tombstone") + r = self.do_delete(container_location + "/" + TC.FCR_TOMBSTONE) + self.checkResponse(TC.NO_CONTENT, r) + + self.log("Check container doesn't exists") + r = self.do_head(container_location) + self.checkResponse(TC.NOT_FOUND, r) + r = self.do_get(container_location) + self.checkResponse(TC.NOT_FOUND, r) + + self.log("Try to put to location again") + r = self.do_put(container_location) + self.checkResponse(TC.CREATED, r) + @Test def testBasicContainer(self): - self.log("Running testBasicContainer") - self.createTestResource(TestConstants.LDP_BASIC) - self.log("Passed") + """ Test creating a basic container """ + self.createTestResource(TC.LDP_BASIC) @Test def testDirectContainer(self): - self.log("Running testDirectContainer") - self.createTestResource(TestConstants.LDP_DIRECT) - self.log("Passed") + """ Test creating a direct container """ + self.createTestResource(TC.LDP_DIRECT) @Test def testIndirectContainer(self): - self.log("Running testIndirectContainer") - self.createTestResource(TestConstants.LDP_INDIRECT) - self.log("Passed") + """ Test creating an indirect container """ + self.createTestResource(TC.LDP_INDIRECT) @Test def testNonRdfSource(self): - self.log("Running testNonRdfSource") + """ Test creating a binary """ testfiles = {'files': ('testdata.csv', 'this,is,some,data\n')} - self.createTestResource(TestConstants.LDP_NON_RDF_SOURCE, files=testfiles) - self.log("Passed") + self.createTestResource(TC.LDP_NON_RDF_SOURCE, files=testfiles) @Test def testLdpResource(self): """ We don't allow you to create a ldp:Resource so this returns 400 Bad Request """ - self.log("Running testLdpResource") - link_type = self.make_type(TestConstants.LDP_RESOURCE) + link_type = self.make_type(TC.LDP_RESOURCE) headers = { 'Link': link_type } r = self.do_post(self.getBaseUri(), headers=headers) - self.assertEqual(400, r.status_code, "Did not get expected response") - self.log("Passed") + self.checkResponse(TC.BAD_REQUEST, r) @Test def testLdpContainer(self): """ We don't allow you to create a ldp:Container so this returns 400 Bad Request """ - self.log("Running testLdpResource") - link_type = self.make_type(TestConstants.LDP_CONTAINER) + link_type = self.make_type(TC.LDP_CONTAINER) headers = { 'Link': link_type } r = self.do_post(self.getBaseUri(), headers=headers) - self.assertEqual(400, r.status_code, "Did create container") - self.log("Passed") - - + self.checkResponse(TC.BAD_REQUEST, r) @Test def doNestedTests(self): - self.log("Running doNestedTests") - + """ Test creating child objects and removing them """ self.log("Create a container") - headers = { - 'Content-type': 'text/turtle' - } - r = self.do_post(self.getBaseUri(), headers=headers, body=TestConstants.OBJECT_TTL) - self.assertEqual(201, r.status_code, "Did not get expected status code") + r = self.createBasicContainer(self.getBaseUri()) + self.checkResponse(TC.CREATED, r) location = self.get_location(r) self.log("Create a container in a container") - r = self.do_post(location, headers=headers, body=TestConstants.OBJECT_TTL) - self.assertEqual(201, r.status_code, "Did not get expected status code") - location2 = self.get_location(r) + r = self.createBasicContainer(location) + self.checkResponse(TC.CREATED, r) + main_child1 = self.get_location(r) self.log("Create binary inside a container inside a container") with open(os.path.join(os.getcwd(), 'resources', 'basic_image.jpg'), 'rb') as fp: @@ -103,29 +162,111 @@ def doNestedTests(self): 'Content-type': 'image/jpeg' } data = fp.read() - r = self.do_post(location2, headers=headers, body=data) - self.assertEqual(201, r.status_code, "Did not get expected status code") + r = self.do_post(main_child1, headers=headers, body=data) + self.checkResponse(TC.CREATED, r) binary_location = self.get_location(r) + self.log("Create a second child in the top container") + r = self.createBasicContainer(location) + self.checkResponse(TC.CREATED, r) + main_child2 = self.get_location(r) + + self.log("Verify containment") + headers = { + 'Accept': TC.JSONLD_MIMETYPE + } + r = self.do_get(location, headers=headers) + self.checkResponse(TC.OK, r) + body = r.content.decode('UTF-8').rstrip('\ny') + json_body = json.loads(body) + contained = pyjq.all('.[0]."http://www.w3.org/ns/ldp#contains"', json_body) + expected = [ + main_child1, + main_child2 + ] + found = list() + for c in contained[0]: + child_id = pyjq.first('."@id"', c) + found.append(child_id) + if len(found) != len(expected): + self.fail("Expected {0} contained resources, found {1}".format(len(expected), len(found))) + else: + for child_id in found: + if child_id not in expected: + self.fail("Found unexpected containment relationship {0}".format(child_id)) + self.log("Delete binary") r = self.do_delete(binary_location) - self.assertEqual(204, r.status_code, "Did not get expected status code") + self.checkResponse(TC.NO_CONTENT, r) self.log("Verify its gone") r = self.do_get(binary_location) - self.assertEqual(410, r.status_code, "Did not get expected status code") + self.checkResponse(TC.GONE, r) self.log("Delete container with a container inside it") r = self.do_delete(location) - self.assertEqual(204, r.status_code, "Did not get expected status code") + self.checkResponse(TC.NO_CONTENT, r) self.log("Verify both are gone") - r = self.do_get(location2) - self.assertEqual(410, r.status_code, "Did not get expected status code") + r = self.do_get(main_child1) + self.checkResponse(TC.GONE, r) r = self.do_get(location) - self.assertEqual(410, r.status_code, "Did not get expected status code") + self.checkResponse(TC.GONE, r) + + @Test + def testPurgeContainer(self): + """ Test create, delete and purge a container """ + r = self.do_post() + self.checkResponse(TC.CREATED, r) + uri = self.get_location(r) + + self.verifyGet(uri) + + r = self.do_post(uri) + self.checkResponse(TC.CREATED, r) + childUri = self.get_location(r) + + r = self.do_delete(childUri) + self.checkResponse(TC.NO_CONTENT, r) + + r = self.do_get(childUri) + self.checkResponse(TC.GONE, r) + + r = self.do_delete(childUri + "/" + TC.FCR_TOMBSTONE) + self.checkResponse(TC.NO_CONTENT, r) - self.log("Passed") + r = self.do_get(childUri) + self.checkResponse(TC.NOT_FOUND, r) + + r = self.do_put(childUri) + self.checkResponse(TC.CREATED, r) + + @Test + def testPurgeBinary(self): + """ Test create, delete and purge a binary """ + headers = { + 'Link': "<{}>; rel=\"type\"".format(TC.LDP_NON_RDF_SOURCE) + } + r = self.do_post(headers=headers, body="some text") + self.checkResponse(TC.CREATED, r) + childUri = self.get_location(r) + + self.verifyGet(childUri) + + r = self.do_delete(childUri) + self.checkResponse(TC.NO_CONTENT, r) + + r = self.do_get(childUri) + self.checkResponse(TC.GONE, r) + + r = self.do_delete(childUri + "/" + TC.FCR_TOMBSTONE) + self.checkResponse(TC.NO_CONTENT, r) + + r = self.do_get(childUri) + self.checkResponse(TC.NOT_FOUND, r) + + r = self.do_put(childUri) + self.checkResponse(TC.CREATED, r) def changeIxnModels(self, location, starting_model): """ This function uses a created object at {location} with starting type {starting_model}. @@ -135,39 +276,39 @@ def changeIxnModels(self, location, starting_model): (, ), """ expected_ixn_change = { - TestConstants.LDP_BASIC: [ - (TestConstants.LDP_INDIRECT, 409), - (TestConstants.LDP_DIRECT, 409), - (TestConstants.LDP_NON_RDF_SOURCE, 409), - (TestConstants.LDP_RESOURCE, 400), - (TestConstants.LDP_CONTAINER, 400) + TC.LDP_BASIC: [ + (TC.LDP_INDIRECT, 409), + (TC.LDP_DIRECT, 409), + (TC.LDP_NON_RDF_SOURCE, 409), + (TC.LDP_RESOURCE, 400), + (TC.LDP_CONTAINER, 400) ], - TestConstants.LDP_DIRECT: [ - (TestConstants.LDP_BASIC, 409), - (TestConstants.LDP_INDIRECT, 409), - (TestConstants.LDP_NON_RDF_SOURCE, 409), - (TestConstants.LDP_RESOURCE, 400), - (TestConstants.LDP_CONTAINER, 400) + TC.LDP_DIRECT: [ + (TC.LDP_BASIC, 409), + (TC.LDP_INDIRECT, 409), + (TC.LDP_NON_RDF_SOURCE, 409), + (TC.LDP_RESOURCE, 400), + (TC.LDP_CONTAINER, 400) ], - TestConstants.LDP_INDIRECT: [ - (TestConstants.LDP_BASIC, 409), - (TestConstants.LDP_DIRECT, 409), - (TestConstants.LDP_NON_RDF_SOURCE, 409), - (TestConstants.LDP_RESOURCE, 400), - (TestConstants.LDP_CONTAINER, 400) + TC.LDP_INDIRECT: [ + (TC.LDP_BASIC, 409), + (TC.LDP_DIRECT, 409), + (TC.LDP_NON_RDF_SOURCE, 409), + (TC.LDP_RESOURCE, 400), + (TC.LDP_CONTAINER, 400) ], - TestConstants.LDP_NON_RDF_SOURCE: [ - (TestConstants.LDP_BASIC, 409), - (TestConstants.LDP_DIRECT, 409), - (TestConstants.LDP_INDIRECT, 409), - (TestConstants.LDP_RESOURCE, 400), - (TestConstants.LDP_CONTAINER, 400) + TC.LDP_NON_RDF_SOURCE: [ + (TC.LDP_BASIC, 409), + (TC.LDP_DIRECT, 409), + (TC.LDP_INDIRECT, 409), + (TC.LDP_RESOURCE, 400), + (TC.LDP_CONTAINER, 400) ] } for model, result in expected_ixn_change[starting_model]: self.log("Changing from {0} to {1} expect status {2}".format(starting_model, model, result)) - if model == TestConstants.LDP_NON_RDF_SOURCE: + if model == TC.LDP_NON_RDF_SOURCE: files = {'file': ('testcsvdata.csv', 'this,is,changed,data\nnow,go,away,please\n')} else: files = None @@ -177,27 +318,313 @@ def changeIxnModels(self, location, starting_model): } r = self.do_put(location, headers=headers, files=files) - self.assertEqual(result, r.status_code, "Did not get expected response") - - self.log("Passed") + self.checkResponse(result, r) @Test def testChangeIxnModel(self): - self.log("Running changeIxnModel") - + """ Test responses when trying to change interaction models """ self.log("Create a basic container") - basic = self.createTestResource(TestConstants.LDP_BASIC) - self.changeIxnModels(basic, TestConstants.LDP_BASIC) + basic = self.createTestResource(TC.LDP_BASIC) + self.changeIxnModels(basic, TC.LDP_BASIC) self.log("Create a direct container") - direct = self.createTestResource(TestConstants.LDP_DIRECT) - self.changeIxnModels(direct, TestConstants.LDP_DIRECT) + direct = self.createTestResource(TC.LDP_DIRECT) + self.changeIxnModels(direct, TC.LDP_DIRECT) self.log("Create a indirect container") - indirect = self.createTestResource(TestConstants.LDP_INDIRECT) - self.changeIxnModels(indirect, TestConstants.LDP_INDIRECT) + indirect = self.createTestResource(TC.LDP_INDIRECT) + self.changeIxnModels(indirect, TC.LDP_INDIRECT) self.log("Create a Non Rdf Source") testfiles = {'files': ('testdata.csv', 'this,is,some,data\n')} - non_rdf = self.createTestResource(TestConstants.LDP_NON_RDF_SOURCE, files=testfiles) - self.changeIxnModels(non_rdf, TestConstants.LDP_NON_RDF_SOURCE) + non_rdf = self.createTestResource(TC.LDP_NON_RDF_SOURCE, files=testfiles) + self.changeIxnModels(non_rdf, TC.LDP_NON_RDF_SOURCE) + + @Test + def testBinaryTriples(self): + """ Test you can create a binary with some expected headers """ + self.log("Create binary with expected properties") + headers = { + 'Content-type': 'text/plain', + 'Content-Disposition': 'attachment; filename="mytestfile.txt"' + } + r = self.do_post(self.getBaseUri(), headers=headers, body="some sample text") + self.checkResponse(TC.CREATED, r) + + @Test + def testChecksum(self): + """ Test that interaction of state tokens and eTags + TODO: This test flaps a bit """ + self.log("Create parent resource") + r = self.do_post(self.getBaseUri()) + self.checkResponse(TC.CREATED, r) + parent_uri = self.get_location(r) + + first_etag = self.getEtag(parent_uri) + first_state_token = self.getStateToken(parent_uri) + self.log(f"First eTag of parent is {first_etag} and state token is {first_state_token}") + time.sleep(0.5) + self.log("Add child resource to parent") + r = self.do_post(parent_uri) + self.checkResponse(TC.CREATED, r) + + second_etag = self.getEtag(parent_uri) + second_state_token = self.getStateToken(parent_uri) + self.log(f"Second eTag of parent is {second_etag} and state token is {second_state_token}") + + self.assertNotEqual(first_etag, second_etag, "First and second state etags should not match") + self.assertEqual(first_state_token, second_state_token, "First and second state tokens should match") + + self.log("Add 30 child resources to parent") + for i in range(1, 30): + r = self.do_post(parent_uri, {'Slug': 'child_' + str(i)}) + self.checkResponse(TC.CREATED, r) + + third_etag = self.getEtag(parent_uri) + third_state_token = self.getStateToken(parent_uri) + if third_etag == second_etag: + start = time.perf_counter() + self.log("Waiting up to 2 seconds to account for H2 delay on eTags") + while second_etag == third_etag and time.perf_counter() - start < 2: + time.sleep(.3) + third_etag = self.getEtag(parent_uri) + third_state_token = self.getStateToken(parent_uri) + self.log(f"Third eTag of parent is {third_etag} and state token is {third_state_token}") + self.assertNotEqual(first_etag, third_etag, "First and third state etag should not match") + self.assertNotEqual(second_etag, third_etag, "Second and third state etag should not match") + + self.assertEqual(first_state_token, third_state_token, "First and third state tokens should match") + self.assertEqual(second_state_token, third_state_token, "Second and third state tokens should match") + + r = self.do_patch(parent_uri, headers={'Content-type': TC.SPARQL_UPDATE_MIMETYPE}, + body="INSERT {<> <" + TC.DC_TITLE + "> 'Some title'. } WHERE {}") + self.checkResponse(TC.NO_CONTENT, r) + + fourth_etag = self.getEtag(parent_uri) + fourth_state_token = self.getStateToken(parent_uri) + self.log(f"Fourth eTag of parent is {fourth_etag} and state token is {fourth_state_token}") + self.assertNotEqual(third_etag, fourth_etag, "Third and fourth state etag should match") + self.assertNotEqual(third_state_token, fourth_state_token, "Third and fourth state tokens should not match") + + # @Test # These tests require Fedora to allow the paths, might require more thought. + def testExternalContentProxyLocal(self): + self.log("Create external content to local resource") + image_path = self.duplicateImage() + external_headers = { + "Link": "; rel =\"http://fedora.info/definitions/fcrepo#ExternalContent\"; " + "handling=\"proxy\"; type=\"image/jpeg\"".format(image_path) + } + r = self.do_post(headers=external_headers) + self.checkResponse(TC.CREATED, r) + location = self.get_location(r) + r = self.do_get(location) + self.checkResponse(TC.OK, r) + self.assertHeaderExists(r, "Content-type", "image/jpeg") + self.log("Delete the local file") + os.unlink(image_path) + r = self.do_get(location) + self.checkResponse(TC.SERVER_ERROR, r) + + # @Test # These tests require Fedora to allow the paths, might require more thought. + def testExternalContentProxyCopy(self): + self.log("Create external content to local resource") + image_path = self.duplicateImage() + external_headers = { + "Link": "; rel =\"http://fedora.info/definitions/fcrepo#ExternalContent\"; " + "handling=\"copy\"; type=\"image/jpeg\"".format(image_path) + } + r = self.do_post(headers=external_headers) + self.checkResponse(TC.CREATED, r) + location = self.get_location(r) + r = self.do_get(location) + self.checkResponse(TC.OK, r) + self.assertHeaderExists(r, "Content-type", "image/jpeg") + self.log("Delete the local file") + os.unlink(image_path) + r = self.do_get(location) + self.checkResponse(TC.OK, r) + + # @Test # These tests require Fedora to allow the paths, might require more thought. + def testExternalContentHttpProxy(self): + self.log("Create a resource") + with open(self.getImagePath(), 'rb') as fp: + data = fp.read() + r = self.do_post(headers={ + "Content-type": "image/jpeg" + }, body=data) + self.checkResponse(TC.CREATED, r) + external_location = self.get_location(r) + self.log("Create external content to http resource") + external_headers = { + "Link": "<{}>; rel =\"http://fedora.info/definitions/fcrepo#ExternalContent\"; " + "handling=\"proxy\"; type=\"image/jpeg\"".format(external_location) + } + r = self.do_post(headers=external_headers) + self.checkResponse(TC.CREATED, r) + location = self.get_location(r) + r = self.do_get(location) + self.checkResponse(TC.OK, r) + self.log("Delete the local file") + + @Test + def testDeleteAndPutOverTombstoneRdf(self): + """ Test you need the header for PUT over a RDFSource tombstone """ + self.log("Create resource") + r = self.do_post() + self.checkResponse(TC.CREATED, r) + location = self.get_location(r) + + self.log("Delete resource") + r = self.do_delete(location) + self.checkResponse(TC.NO_CONTENT, r) + r = self.do_get(location) + self.checkResponse(TC.GONE, r) + + self.log("Try to PUT over tombstone") + r = self.do_put(location) + self.checkResponse(TC.GONE, r) + + self.log("Try to PUT over tombstone, with header") + r = self.do_put(location, headers={ + TC.OVERWRITE_TOMBSTONE_HEADER: "true" + }) + self.checkResponse(TC.CREATED, r) + + @Test + def testDeleteAndPutOverTombstoneNonRdf(self): + """ Test you need the header for PUT over a NonRDFSource tombstone """ + self.log("Create NonRdf resource") + r = self.do_post(headers={ + 'Link': self.make_type(TC.LDP_NON_RDF_SOURCE), + 'Content-type': 'text/plain' + }, body="Hello World!") + self.checkResponse(TC.CREATED, r) + location = self.get_location(r) + + self.log("Delete resource") + r = self.do_delete(location) + self.checkResponse(TC.NO_CONTENT, r) + r = self.do_get(location) + self.checkResponse(TC.GONE, r) + + self.log("Try to PUT over tombstone") + r = self.do_put(location, headers={ + 'Link': self.make_type(TC.LDP_NON_RDF_SOURCE), + 'Content-type': 'text/plain' + }, body="New body") + self.checkResponse(TC.GONE, r) + + self.log("Try to PUT over tombstone, with header") + r = self.do_put(location, headers={ + 'Link': self.make_type(TC.LDP_NON_RDF_SOURCE), + 'Content-type': 'text/plain', + TC.OVERWRITE_TOMBSTONE_HEADER: "true" + }, body="New body") + self.checkResponse(TC.CREATED, r) + + @Test + def testDeleteAndPutOverTombstoneWrongTypeRdf(self): + """ Test you can't PUT a NonRDFSource over a tombstone for a RDFSource """ + self.log("Create resource RDF") + r = self.do_post() + self.checkResponse(TC.CREATED, r) + location = self.get_location(r) + + self.log("Delete resource") + r = self.do_delete(location) + self.checkResponse(TC.NO_CONTENT, r) + r = self.do_get(location) + self.checkResponse(TC.GONE, r) + + self.log("Try to PUT NonRdfSource over tombstone") + r = self.do_put(location, headers={ + 'Link': self.make_type(TC.LDP_NON_RDF_SOURCE), + 'Content-type': 'text/plain' + }, body="Hello World!") + self.checkResponse(TC.GONE, r) + + self.log("Try to PUT over NonRdfSource tombstone, with header") + r = self.do_put(location, headers={ + 'Link': self.make_type(TC.LDP_NON_RDF_SOURCE), + 'Content-type': 'text/plain', + TC.OVERWRITE_TOMBSTONE_HEADER: "true" + }) + self.checkResponse(TC.CONFLICT, r) + + @Test + def testDeleteAndPutOverTombstoneWrongTypeNonRdf(self): + """ Test you can't PUT a RDFSource over a tombstone for a NonRDFSource """ + self.log("Create resource NonRDF") + r = self.do_post(headers={ + 'Link': self.make_type(TC.LDP_NON_RDF_SOURCE), + 'Content-type': 'text/plain' + }, body="Hello World!") + self.checkResponse(TC.CREATED, r) + location = self.get_location(r) + + self.log("Delete resource") + r = self.do_delete(location) + self.checkResponse(TC.NO_CONTENT, r) + r = self.do_get(location) + self.checkResponse(TC.GONE, r) + + self.log("Try to PUT RdfSource over tombstone") + r = self.do_put(location, headers={ + 'Link': self.make_type(TC.LDP_BASIC), + }) + self.checkResponse(TC.GONE, r) + + self.log("Try to PUT over RdfSource tombstone, with header") + r = self.do_put(location, headers={ + 'Link': self.make_type(TC.LDP_BASIC), + TC.OVERWRITE_TOMBSTONE_HEADER: "true" + }) + self.checkResponse(TC.CONFLICT, r) + + @Test + def testRangeRequests(self): + """ Test that we support RFC 7233 range requests """ + self.log("Create a NonRDFResource") + r = self.do_post(headers={ + 'Link': self.make_type(TC.LDP_NON_RDF_SOURCE), + 'Content-type': 'text/plain' + }, body=''.join(random.choices(string.ascii_lowercase, k=100))) + self.checkResponse(TC.CREATED, r) + location = self.get_location(r) + length = self.getHeader(location, 'Content-Length') + if length is not None: + half_length = int(int(length) / 2) + one_longer = int(length) + 1 + + self.log("Perform request for first half of the range") + r = self.do_get(location, headers={ + 'Range': 'bytes=0-{0}'.format(half_length) + }) + self.checkResponse(TC.PARTIAL_CONTENT, r) + range = r.headers['Content-Range'] + if range: + self.assertEqual('bytes 0-{0}/{1}'.format(half_length, length), range) + else: + self.fail("No Content-Range header found") + + self.log("Perform request for range slightly longer than the content") + r = self.do_get(location, headers={ + 'Range': 'bytes=0-{0}'.format(one_longer) + }) + self.checkResponse(TC.PARTIAL_CONTENT, r) + range = r.headers['Content-Range'] + if range: + self.assertEqual('bytes 0-{0}/{1}'.format(int(length) - 1, length), range) + else: + self.fail("No Content-Range header found") + + self.log("Perform request for range of the content length") + r = self.do_get(location, headers={ + 'Range': 'bytes=0-{0}'.format(length) + }) + self.checkResponse(TC.PARTIAL_CONTENT, r) + range = r.headers['Content-Range'] + if range: + self.assertEqual('bytes 0-{0}/{1}'.format(int(length) -1, length), range) + else: + self.fail("No Content-Range header found") diff --git a/camel_tests.py b/camel_tests.py new file mode 100644 index 0000000..fe27616 --- /dev/null +++ b/camel_tests.py @@ -0,0 +1,137 @@ +#!/bin/env python + +import TestConstants +from abstract_fedora_tests import FedoraTests, register_tests, Test +import uuid +import datetime +import requests +import json +import pyjq + + +@register_tests +class FedoraCamelTests(FedoraTests): + + # Create test objects all inside here for easy of review. + CONTAINER = "/test_camel" + + # How many seconds to wait for indexing to Solr and/or triplestore. + CONTAINER_WAIT = 30 + + def run_tests(self): + if not (self.hasSolrUrl() and self.hasTriplestoreUrl()): + print("**** Cannot run camel tests without a Solr and/or Triplestore base url ****") + else: + super().run_tests() + + @Test + def CamelCreateObject(self): + """ Test create an object in Fedora and track it into Solr/Triplestore """ + self.log("Create an object") + internal_id = str(uuid.uuid4()) + expected_url = self.getBaseUri() + "/" + internal_id + headers = { + 'Slug': internal_id, + 'Content-type': TestConstants.TURTLE_MIMETYPE + } + r = self.do_post(headers=headers, body=TestConstants.PCDM_CONTAINER_TTL) + self.assertEqual(201, r.status_code, "Did not get expected response code") + + if self.hasSolrUrl(): + self.log("Checking for item in Solr") + solr_response = self.timedQuery('querySolr', 'solrNumFound', expected_url) + if solr_response is not None: + self.assertEqual(expected_url, self.getIdFromSolr(solr_response), "Did not find ID in Solr.") + else: + self.fail("Timed out waiting to find record in Solr.") + + if self.hasTriplestoreUrl(): + self.log("Checking for item in Triplestore") + triplestore_response = self.timedQuery('queryTriplestore', 'triplestoreNumFound', expected_url) + if triplestore_response is not None: + self.assertEqual(TestConstants.PCDM_CONTAINER_TITLE, self.getIdFromTriplestore(triplestore_response), + "Did not find ID in Triplestore") + else: + self.fail("Timed out waiting to find record in Triplestore") + + # Utility functions. + def timedQuery(self, query_method, check_method, query_value): + query_func = getattr(self, query_method, None) + check_func = getattr(self, check_method, None) + if query_func is None or check_func is None: + Exception("Can't find expected methods {0}, {1}".format(query_method, check_method)) + current_time = datetime.datetime.now() + end_time = current_time + datetime.timedelta(seconds=self.CONTAINER_WAIT) + last_query = None + while current_time <= end_time: + # If a multiple of 5 seconds has passed since the last query, do the query + if last_query is None or (current_time - last_query).seconds >= 5: + last_query = datetime.datetime.now() + response = query_func(query_value) + if check_func(response) is not None: + return response + current_time = datetime.datetime.now() + return None + + def hasSolrUrl(self): + try: + return self.config[TestConstants.SOLR_URL_PARAM] is not None and \ + len(self.config[TestConstants.SOLR_URL_PARAM].strip()) > 0 + except KeyError: + return False + + def hasTriplestoreUrl(self): + try: + return self.config[TestConstants.TRIPLESTORE_URL_PARAM] is not None and \ + len(self.config[TestConstants.TRIPLESTORE_URL_PARAM].strip()) > 0 + except KeyError: + return False + + def solrNumFound(self, response): + body = response.content.decode('UTF-8') + json_body = json.loads(body) + num_found = pyjq.first('.response.numFound', json_body) + if num_found is not None and int(num_found) > 0: + return num_found + return None + + def querySolr(self, expected_id): + solr_select = self.config[TestConstants.SOLR_URL_PARAM].rstrip('/') + "/select" + params = { + 'q': 'id:"' + expected_id + '"', + 'wt': 'json' + } + r = requests.get(solr_select, params=params) + self.assertEqual(200, r.status_code, "Did not query Solr successfully.") + return r + + def getIdFromSolr(self, response): + body = response.content.decode('UTF-8') + json_body = json.loads(body) + found_title = pyjq.first('.response.docs[].id', json_body) + return found_title + + def queryTriplestore(self, expected_id): + query = "PREFIX dc: SELECT ?o WHERE { <" + expected_id + "> dc:title ?o}" + headers = { + 'Content-type': TestConstants.SPARQL_QUERY_MIMETYPE, + 'Accept': TestConstants.SPARQL_RESULT_JSON_MIMETYPE + } + r = requests.post(self.config[TestConstants.TRIPLESTORE_URL_PARAM], headers=headers, data=query) + self.assertEqual(200, r.status_code, 'Did not query Triplestore successfully.') + return r + + def triplestoreNumFound(self, response): + body = response.content.decode('UTF-8') + json_body = json.loads(body) + # This results a list of matching bindings, so it can be an empty list. + num_found = pyjq.all('.results.bindings[].o', json_body) + if num_found is not None and len(num_found) > 0: + return num_found + return None + + def getIdFromTriplestore(self, response): + body = response.content.decode('UTF-8') + json_body = json.loads(body) + found_id = pyjq.first('.results.bindings[].o.value', json_body) + return found_id diff --git a/config.yml.example b/config.yml.example index dc7b0fc..7be2a77 100644 --- a/config.yml.example +++ b/config.yml.example @@ -7,3 +7,13 @@ default: password1: testpass user2: testuser2 password2: testpass +tomcat: + baseurl: http://localhost:8080/fcrepo/rest + admin_user: fedoraAdmin + admin_password: secret3 + user1: adminuser + password1: password2 + user2: testuser + password2: password1 + solrurl: http://localhost:8080/solr + triplestoreurl: http://localhost:8080/fuseki/test/query diff --git a/fixity_tests.py b/fixity_tests.py index 30d4244..f54532b 100644 --- a/fixity_tests.py +++ b/fixity_tests.py @@ -1,10 +1,6 @@ #!/bin/env python -import TestConstants from abstract_fedora_tests import FedoraTests, register_tests, Test -import os.path -import pyjq -import json @register_tests @@ -14,34 +10,40 @@ class FedoraFixityTests(FedoraTests): CONTAINER = "/test_fixity" # Sha1 fixity result for basic_image.jpg in the resource sub-directory - FIXITY_RESULT = "urn:sha1:dec028a4400b4f7ed80ed1174e65179d6b57a0f2" + FIXITY_RESULT_SHA1 = "dec028a4400b4f7ed80ed1174e65179d6b57a0f2" + + def decode_digest_header(self, header): + digests = dict() + for digest in header.split(","): + (alg, value) = digest.split('=') + digests[alg] = value + return digests @Test def aFixityTest(self): - + """ Test doing a fixity test and that it matches the expected result. """ self.log("Create a binary") headers = { 'Content-type': 'image/jpeg', } - with open(os.path.join(os.getcwd(), 'resources', 'basic_image.jpg'), 'rb') as fp: + with open(self.getImagePath(), 'rb') as fp: data = fp.read() r = self.do_post(self.getBaseUri(), headers=headers, body=data) self.assertEqual(201, r.status_code, 'Did not create binary') location = self.get_location(r) - fixity_endpoint = location + "/" + TestConstants.FCR_FIXITY self.log("Get a fixity result") headers = { - 'Accept': TestConstants.JSONLD_MIMETYPE + 'Want-Digest': 'sha' } - r = self.do_get(fixity_endpoint, headers=headers) + r = self.do_head(location, headers=headers) self.assertEqual(200, r.status_code, "Can't get the fixity result") - body = r.content.decode('UTF-8').rstrip('\ny') - json_body = json.loads(body) - fixity_id = pyjq.first('.[0]."http://www.loc.gov/premis/rdf/v1#hasFixity"| .[]?."@id"', json_body) - fixity_result = pyjq.first('.[] | select(."@id" == "{0}") | ' - '."http://www.loc.gov/premis/rdf/v1#hasMessageDigest" | .[]?."@id"'.format(fixity_id), - json_body) - self.assertEqual(self.FIXITY_RESULT, fixity_result, "Fixity result was not a match for expected.") - - self.log("Passed") \ No newline at end of file + if 'Digest' in r.headers: + fixity_results = self.decode_digest_header(r.headers['Digest']) + if 'sha' in fixity_results.keys(): + self.assertEqual(self.FIXITY_RESULT_SHA1, fixity_results['sha'], + "Fixity result was not a match for expected.") + else: + self.fail("No sha digest returned") + else: + self.fail("No Digest header returned") diff --git a/indirect_tests.py b/indirect_tests.py index bd9fcea..f829b5c 100644 --- a/indirect_tests.py +++ b/indirect_tests.py @@ -4,18 +4,17 @@ from abstract_fedora_tests import FedoraTests, register_tests, Test import json import pyjq +import uuid @register_tests class FedoraIndirectTests(FedoraTests): - # Create test objects all inside here for ease of review/removal CONTAINER = "/test_indirect" @Test def doPcdmIndirect(self): - self.log("Running doPcdmIndirect") - + """ Test that we can create a working indirect container """ self.log("Create a PCDM container") basic_headers = { 'Link': self.make_type(TestConstants.LDP_BASIC), @@ -35,23 +34,28 @@ def doPcdmIndirect(self): 'Link': self.make_type(TestConstants.LDP_INDIRECT), 'Content-type': 'text/turtle' } - pcdm_indirect = "@prefix dc: ." \ - "@prefix pcdm: ." \ + pcdm_indirect = "@prefix dc: <{1}> ." \ + "@prefix pcdm: <{2}> ." \ "@prefix ore: ." \ - "@prefix ldp: ." \ + "@prefix ldp: <{3}> ." \ "<> dc:title \"Members Container\" ;" \ " ldp:membershipResource <{0}> ;" \ " ldp:hasMemberRelation pcdm:hasMember ;" \ - " ldp:insertedContentRelation ore:proxyFor .".format(pcdm_collection_location) + " ldp:insertedContentRelation ore:proxyFor .".format( + pcdm_collection_location, + TestConstants.DC_NS, + TestConstants.PCDM_NS, + TestConstants.LDP_NS + ) r = self.do_post(pcdm_collection_location, headers=indirect_headers, body=pcdm_indirect) self.assertEqual(201, r.status_code, "Did not get expected status code") members_location = self.get_location(r) self.log("Create a proxy object") - pcdm_proxy = "@prefix pcdm: " \ + pcdm_proxy = "@prefix pcdm: <{1}>" \ "@prefix ore: " \ "<> a pcdm:Object ;" \ - "ore:proxyFor <{0}> .".format(pcdm_container_location) + "ore:proxyFor <{0}> .".format(pcdm_container_location, TestConstants.PCDM_NS) r = self.do_post(members_location, headers=basic_headers, body=pcdm_proxy) self.assertEqual(201, r.status_code, "Did not get expected status code") @@ -71,4 +75,171 @@ def doPcdmIndirect(self): found_member = True self.assertTrue(found_member, "Did not find hasMember property") - self.log("Passed") \ No newline at end of file + @Test + def doAddIndirect(self): + """ Test indirect container interactions with a resource you don't have access to """ + read_only = str(uuid.uuid4()) + headers = { + 'Slug': read_only + } + r = self.do_post(self.getBaseUri(), headers=headers) + read_only_location = self.get_location(r) + self.log("making a read-only resource : {0}".format(read_only_location)) + self.assertEqual(201, r.status_code, "Did not get expected status code") + + turtle_headers = { + 'Content-type': TestConstants.TURTLE_MIMETYPE + } + read_only_acl = "@prefix acl: <{2}> . \n" \ + "<#readauthz> a acl:Authorization ; \n" \ + "acl:agent \"{0}\" ;\n" \ + "acl:mode acl:Read ;\n" \ + "acl:accessTo <{1}> .".format( + self.config[TestConstants.USER_NAME_PARAM], + read_only_location, + TestConstants.ACL_NS + ) + self.log("adding an acl to the read-only resource") + r = self.do_put(read_only_location + "/" + TestConstants.FCR_ACL, headers=turtle_headers, body=read_only_acl) + self.assertEqual(201, r.status_code, "Did not get expected status code") + + self.log("Do get as testuser") + r = self.do_get(read_only_location, admin=False) + self.assertEqual(200, r.status_code, "Did not get expected status code") + + patch_headers = { + "Content-type": TestConstants.SPARQL_UPDATE_MIMETYPE + } + patch_body = "INSERT DATA { <> <" + TestConstants.DC_NS + "title> \"Changed it\"}" + self.log("Try patch as testuser") + r = self.do_patch(read_only_location, headers=patch_headers, body=patch_body, admin=False) + self.assertEqual(403, r.status_code, "Did not get expected status code") + + writeable = str(uuid.uuid4()) + headers = { + 'Slug': writeable + } + r = self.do_post(self.getBaseUri(), headers=headers) + self.assertEqual(201, r.status_code, "Did not get expected status code") + writeable_location = self.get_location(r) + self.log("create a new writeable resource at {0}".format(writeable_location)) + + writeable_acl = "@prefix acl: <{2}> .\n" \ + "<#writeauth> a acl:Authorization ;\n" \ + " acl:agent \"{0}\" ;\n" \ + " acl:mode acl:Read, acl:Write ;\n" \ + " acl:accessTo <{1}> ;\n" \ + " acl:default <{1}> .".format( + self.config[TestConstants.USER_NAME_PARAM], + writeable_location, + TestConstants.ACL_NS + ) + self.log("create an ACl on the writeable resource") + r = self.do_put(writeable_location + "/" + TestConstants.FCR_ACL, headers=turtle_headers, body=writeable_acl) + self.assertEqual(201, r.status_code, "Did not get expected status code") + + self.log("Test writing inside the writeable resource as testuser") + r = self.do_post(writeable_location, admin=False) + self.assertEqual(201, r.status_code, "Did not get expected status code") + + indirect_template = "@prefix ldp: <{1}> .\n" \ + "@prefix example: .\n" \ + "@prefix dc: <{2}> .\n" \ + "<> ldp:insertedContentRelation ;\n" \ + "ldp:membershipResource <{0}> ;\n" \ + "ldp:hasMemberRelation ;\n" \ + "dc:title \"The indirect container\" ." + indirect_body = indirect_template.format(read_only_location, TestConstants.LDP_NS, TestConstants.DC_NS) + headers = { + "Slug": "indirect", + "Content-type": TestConstants.TURTLE_MIMETYPE, + 'Link': self.make_type(TestConstants.LDP_INDIRECT) + } + self.log("Try to create an indirect referencing a read-only resource") + r = self.do_post(writeable_location, headers=headers, body=indirect_body, admin=False) + self.assertEqual(403, r.status_code, "Did not get expected status code") + + headers = { + 'Slug': 'mockTarget', + } + self.log("Try to create a new mock target") + r = self.do_post(writeable_location, headers=headers, admin=False) + self.assertEqual(201, r.status_code, 'Did not get expected status code') + mockTarget_location = self.get_location(r) + + headers = { + "Slug": "indirect", + "Content-type": TestConstants.TURTLE_MIMETYPE, + 'Link': self.make_type(TestConstants.LDP_INDIRECT) + } + mock_body = indirect_template.format(mockTarget_location, TestConstants.LDP_NS, TestConstants.DC_NS) + self.log("Create an indirect container referencing an allowed target") + r = self.do_post(writeable_location, headers=headers, body=mock_body, admin=False) + self.assertEqual(201, r.status_code, 'Did not get expected status code') + indirect_location = self.get_location(r) + + insert_delete_patch = "prefix ldp: <{1}> \n" \ + "DELETE {{ <> ldp:membershipResource ?o }} \n" \ + "INSERT {{ <> ldp:membershipResource <{0}> }} \n" \ + "WHERE {{ <> ldp:membershipResource ?o }}".format(read_only_location, TestConstants.LDP_NS) + self.log("Try to patch the indirect from allowed to not-allowed") + r = self.do_patch(indirect_location, headers=patch_headers, body=insert_delete_patch, admin=False) + self.assertEqual(403, r.status_code, 'Did not get expected status code') + + delete_data_body = "prefix ldp: <{1}> \n" \ + "DELETE DATA {{ <> ldp:membershipResource <{0}> }}".format(mockTarget_location, TestConstants.LDP_NS) + self.log("Try to DELETE DATA the membershipResource") + r = self.do_patch(indirect_location, headers=patch_headers, body=delete_data_body, admin=False) + self.assertEqual(204, r.status_code, 'Did not get expected status code') + + insert_data_body = "prefix ldp: <{1}> \n" \ + "INSERT DATA {{ <> ldp:membershipResource <{0}> }}".format(read_only_location, TestConstants.LDP_NS) + self.log("Try to INSERT DATA the membershipResource") + r = self.do_patch(indirect_location, headers=patch_headers, body=insert_data_body, admin=False) + self.assertEqual(403, r.status_code, 'Did not get expected status code') + + self.log("Try to patch the indirect from allowed to not-allowed as Admin") + r = self.do_patch(indirect_location, headers=patch_headers, body=insert_delete_patch) + self.assertEqual(204, r.status_code, 'Did not get expected status code') + + self.log("Now try to post to the indirect") + post_body = "@prefix ldp: <{1}> .\n" \ + "@prefix test: .\n\n" \ + "<> test:something <{0}> .".format(mockTarget_location, TestConstants.LDP_NS) + r = self.do_post(indirect_location, headers=turtle_headers, body=post_body, admin=False) + self.assertEqual(403, r.status_code, "Did not get expected status code") + + self.log("Indirect is {0}\nread-only is {1}".format(indirect_location, read_only_location)) + + @Test + def testDirectWithServerManaged(self): + self.log("Try to create a direct container with server managed ldp:hasMemberRelation") + headers = { + 'Link': self.make_type(TestConstants.LDP_INDIRECT), + 'Content-type': 'text/turtle' + } + indirect_body = "@prefix ldp: <{0}> .\n" \ + "@prefix dc: <{1}> .\n" \ + "<> ldp:hasMemberRelation ldp:contains ;\n" \ + "dc:title \"Members Container\" .".format(TestConstants.LDP_NS, TestConstants.DC_NS) + r = self.do_post(self.getBaseUri(), headers=headers, body=indirect_body) + self.assertEqual(409, r.status_code, "Did not get expected status code") + + @Test + def testIndirectWithServerManaged(self): + self.log("Try to create an indirect container with server managed ldp:hasMemberRelation") + r = self.do_post() + # Create a container + self.checkResponse(TestConstants.CREATED, r) + location = self.get_location(r) + headers = { + 'Link': self.make_type(TestConstants.LDP_INDIRECT), + 'Content-type': 'text/turtle' + } + indirect_body = "@prefix ldp: <{1}> .\n" \ + "@prefix dc: <{2}> .\n" \ + "<> ldp:insertedContentRelation <{0}> ;\n" \ + "ldp:hasMemberRelation ldp:contains ;\n" \ + "dc:title \"Members Container\" .".format(location, TestConstants.LDP_NS, TestConstants.DC_NS) + r = self.do_post(self.getBaseUri(), headers=headers, body=indirect_body) + self.assertEqual(409, r.status_code, "Did not get expected status code") diff --git a/rdf_tests.py b/rdf_tests.py index dc65436..8dfa225 100644 --- a/rdf_tests.py +++ b/rdf_tests.py @@ -1,6 +1,9 @@ #!/bin/env python +import time -import TestConstants +import rdflib.parser + +import TestConstants as TC from abstract_fedora_tests import FedoraTests, register_tests, Test @@ -20,13 +23,9 @@ class FedoraRdfTests(FedoraTests): @Test def testRdfSerialization(self): - self.log("Starting testRdfSerialization") - + """ Test GETting RDF in supported formats """ self.log("Put new resource.") - headers = { - "Content-type": "text/turtle" - } - r = self.do_post(self.getBaseUri(), headers=headers, body=TestConstants.OBJECT_TTL) + r = self.createBasicContainer(self.getBaseUri()) self.assertEqual(201, r.status_code, "Did not create new object") location = self.get_location(r) @@ -34,10 +33,10 @@ def testRdfSerialization(self): self.assertTitleExists("An Object", location) self.log("PUT to update title.") - n_triples = "<{0}> \"Updated Title\" .".format(location) + n_triples = "<{0}> <{1}> \"Updated Title\" .".format(location, TC.DC_TITLE) headers = { "Content-type": "application/n-triples", - "Prefer": TestConstants.PUT_PREFER_LENIENT + "Prefer": TC.PUT_PREFER_LENIENT } r = self.do_put(location, headers=headers, body=n_triples) self.assertEqual(204, r.status_code, "Did not update the resource") @@ -64,4 +63,56 @@ def testRdfSerialization(self): r = self.do_get(location) self.assertEqual(410, r.status_code, "Object's tombstone not found.") - self.log("Passed") \ No newline at end of file + @Test + def TestRoundtrippingBinary(self): + """ Test roundtrip the metadata from a binary """ + self.log("Post new binary") + r = self.do_post(headers={'Content-type': 'text/plain'}, body="Content") + self.assertEqual(TC.CREATED, r.status_code, "Did not create binary") + location = self.get_location(r) + self.log("location is {}".format(location)) + + body = self.do_get(location + "/" + TC.FCR_METADATA, headers={'Accept': 'application/n-triples'}) + graph = rdflib.Graph() + graph.parse(data=body.content.decode(encoding='utf-8'), format='nt') + for (s, p, o) in graph: + if p in [rdflib.URIRef(TC.FEDORA_NS + "hasFixityService"), + rdflib.URIRef(TC.FEDORA_NS + "created"), + rdflib.URIRef(TC.FEDORA_NS + "createdBy"), + rdflib.URIRef(TC.FEDORA_NS + "lastModified"), + rdflib.URIRef(TC.FEDORA_NS + "lastModifiedBy"), + rdflib.URIRef(TC.RDF_TYPE), + rdflib.URIRef("http://www.loc.gov/premis/rdf/v1#hasMessageDigest")]: + graph.remove((s, p, o)) + + new_body = graph.serialize(format='nt') + self.log("PUTTING body {}".format(new_body)) + + self.log("Delete binary") + r = self.do_delete(location) + self.assertEqual(TC.NO_CONTENT, r.status_code, "Unable to delete binary") + self.log("Delete binary tombstone") + r = self.do_delete(location + "/" + TC.FCR_TOMBSTONE) + self.assertEqual(TC.NO_CONTENT, r.status_code, "Unable to delete binary tombstone") + + time.sleep(1) + self.log("Put binary back") + r = self.do_put(location, headers={'Content-type': 'text/plain'}, body="Content") + self.assertEqual(TC.CREATED, r.status_code, "Did not create binary") + self.log("Put the description back") + r = self.do_put(location + "/" + TC.FCR_METADATA, headers={ + 'Content-type': 'application/n-triples', + 'Prefer': 'handling=lenient' + }, body=new_body) + self.assertEqual(TC.NO_CONTENT, r.status_code, "Did not update binary description") + + self.log("Get the body again") + r = self.do_get(location + "/" + TC.FCR_METADATA, headers={'Accept': 'application/n-triples'}) + self.assertEqual(TC.OK, r.status_code, "Could not get the binary description") + get_body = r.content.decode(encoding='utf-8') + self.log("GET body {}".format(get_body)) + graph2 = rdflib.Graph() + graph2.parse(data=get_body, format='nt') + for triple in graph: + if triple not in graph2: + self.fail("Could not find triple {} in graph2".format(triple)) diff --git a/requirements.txt b/requirements.txt index 3a7e903..3275541 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ -PyYAML==5.4 -requests==2.20 -pyjq==2.3.0 +PyYAML>=5.4 +requests>=2.20.0 +pyjq>=2.3.0 +rdflib>=5.0.0 \ No newline at end of file diff --git a/sparql_tests.py b/sparql_tests.py index 4a0878c..e50564c 100644 --- a/sparql_tests.py +++ b/sparql_tests.py @@ -1,8 +1,10 @@ #!/bin/env python -import TestConstants +import TestConstants as TC from abstract_fedora_tests import FedoraTests, register_tests, Test import os +import json +import pyjq @register_tests @@ -19,35 +21,31 @@ class FedoraSparqlTests(FedoraTests): @Test def doSparqlContainerTest(self): - self.log("Running doSparqlContainerTest") - + """ Test Patch requests to a container """ self.log("Create container") headers = { 'Content-type': 'text/turtle' } - r = self.do_post(self.getBaseUri(), headers=headers, body=TestConstants.OBJECT_TTL) - self.assertEqual(201, r.status_code, "Did not create container") + r = self.do_post(self.getBaseUri(), headers=headers, body=TC.OBJECT_TTL) + self.checkResponse(201, r) location = self.get_location(r) self.log("Set dc:title with SPARQL") patch_headers = { - 'Content-type': TestConstants.SPARQL_UPDATE_MIMETYPE + 'Content-type': TC.SPARQL_UPDATE_MIMETYPE } r = self.do_patch(location, headers=patch_headers, body=self.TITLE_SPARQL) - self.assertEqual(204, r.status_code, "Did not get expected result") + self.checkResponse(204, r) self.assertTitleExists("First title", location) self.log("Update dc:title with SPARQL") r = self.do_patch(location, headers=patch_headers, body=self.UPDATE_SPARQL) - self.assertEqual(204, r.status_code, "Did not get expected results") + self.checkResponse(204, r) self.assertTitleExists("Updated title", location) - self.log("Passed") - @Test def doSparqlBinaryTest(self): - self.log("Running doSparqlBinaryTest") - + """ Test Patch requests to a binary """ self.log("Create a binary") headers = { 'Content-type': 'image/jpeg' @@ -55,31 +53,28 @@ def doSparqlBinaryTest(self): with open(os.path.join(os.getcwd(), 'resources', 'basic_image.jpg'), 'rb') as fp: data = fp.read() r = self.do_post(self.getBaseUri(), headers=headers, body=data) - self.assertEqual(201, r.status_code, "Did not get expected response") + self.checkResponse(201, r) description = self.find_binary_description(r) self.log("Set dc:title with SPARQL") patch_headers = { - 'Content-type': TestConstants.SPARQL_UPDATE_MIMETYPE + 'Content-type': TC.SPARQL_UPDATE_MIMETYPE } r = self.do_patch(description, headers=patch_headers, body=self.TITLE_SPARQL) - self.assertEqual(204, r.status_code, "Did not get expected result") + self.checkResponse(204, r) self.assertTitleExists("First title", description) self.log("Update dc:title with SPARQL") r = self.do_patch(description, headers=patch_headers, body=self.UPDATE_SPARQL) - self.assertEqual(204, r.status_code, "Did not get expected results") + self.checkResponse(204, r) self.assertTitleExists("Updated title", description) - self.log("Passed") - @Test def doUnicodeSparql(self): - self.log("Running doUnicodeSparql") - + """ Test unicode patch requests to a container """ self.log("Create a container") r = self.do_post(self.getBaseUri()) - self.assertEqual(201, r.status_code, "Did not get expected response code") + self.checkResponse(201, r) location = self.get_location(r) sparql = "PREFIX dc: " \ @@ -88,10 +83,155 @@ def doUnicodeSparql(self): self.log("Patching with unicode") headers = { - 'Content-type': TestConstants.SPARQL_UPDATE_MIMETYPE + 'Content-type': TC.SPARQL_UPDATE_MIMETYPE } r = self.do_patch(location, headers=headers, body=sparql) - self.assertEqual(204, r.status_code, "Did not get expected response code") + self.checkResponse(204, r) self.assertTitleExists("Die von Blumenbach gegründete anthropologische Sammlung der Universität", location) - self.log("Passed") \ No newline at end of file + @Test + def doAddType(self): + """ Test we can add a rdf:type to a resource """ + self.log("Create a container") + r = self.do_post(self.getBaseUri()) + self.checkResponse(201, r) + location = self.get_location(r) + + sparql = "PREFIX rdf: " \ + "PREFIX ldp: " \ + "PREFIX example: " \ + "INSERT DATA { <> rdf:type example:type }" + self.log("Patching with our own type") + headers = { + 'Content-type': TC.SPARQL_UPDATE_MIMETYPE + } + r = self.do_patch(location, headers=headers, body=sparql) + self.checkResponse(204, r) + self.assertTypeExists("http://www.example.org/ns#type", location) + + @Test + def doAddRestrictedType(self): + """ Test we can't add a rdf:type with a restricted prefix """ + self.log("Create a container") + r = self.do_post(self.getBaseUri()) + self.checkResponse(201, r) + location = self.get_location(r) + + sparql = "PREFIX rdf: " \ + "PREFIX ldp: " \ + "PREFIX example: " \ + "INSERT DATA {{ <> rdf:type <{0}> }}".format(TC.LDP_DIRECT) + self.log("Patching with our own type") + headers = { + 'Content-type': TC.SPARQL_UPDATE_MIMETYPE + } + r = self.do_patch(location, headers=headers, body=sparql) + self.checkResponse(409, r) + + @Test + def doInboundReferenceContainer(self): + """ Test that we can see inbound references in the RDF of a container """ + reference = "http://awoods.com/pointer" + self.log("Create a container") + r = self.do_post(self.getBaseUri()) + self.checkResponse(201, r) + location = self.get_location(r) + self.log("Create a RDF container with a reference to the first container.") + rdf = "<> <{}> <{}> .".format(reference, location) + headers = { + 'Content-type': TC.TURTLE_MIMETYPE, + } + r = self.do_post(self.getBaseUri(), headers=headers, body=rdf) + self.checkResponse(201, r) + second_location = self.get_location(r) + self.log("Get first container") + r = self.do_get(location) + self.checkResponse(200, r) + self.log("Get second container") + r = self.do_get(second_location) + self.checkResponse(200, r) + self.log("Get first container with Inbound References") + headers = { + 'Prefer': "return=representation; include=\"{}\"".format(TC.INBOUND_REFERENCE), + 'Accept': TC.JSONLD_MIMETYPE + } + r = self.do_get(location, headers=headers) + self.checkResponse(200, r) + body = r.content.decode('UTF-8') + json_body = json.loads(body) + result = pyjq.all('.[] | select(."@id" == "{}") | ."{}" | .[0]."@id"'.format(second_location, reference), json_body) + self.assertEqual(location, result[0]) + + @Test + def doInboundReferenceBinary(self): + """ Test that we can see inbound references in the RDF of a binary description """ + reference = "http://awoods.com/pointer" + self.log("Create a binary") + headers = { + 'Content-type': 'text/plain' + } + r = self.do_post(self.getBaseUri(), headers=headers, body="Some test text") + self.checkResponse(201, r) + location = self.get_location(r) + location_metadata = location + "/" + TC.FCR_METADATA + self.log("Create a RDF container with a reference to the binary.") + rdf = "<> <{}> <{}> .".format(reference, location) + headers = { + 'Content-type': TC.TURTLE_MIMETYPE, + } + r = self.do_post(self.getBaseUri(), headers=headers, body=rdf) + self.checkResponse(201, r) + second_location = self.get_location(r) + self.log("Get binary") + r = self.do_get(location) + self.checkResponse(200, r) + self.log("Get binary description") + r = self.do_get(location_metadata) + self.checkResponse(200, r) + self.log("Get first container") + r = self.do_get(second_location) + self.checkResponse(200, r) + self.log("Get binary description with Inbound References") + headers = { + 'Prefer': "return=representation; include=\"{}\"".format(TC.INBOUND_REFERENCE), + 'Accept': TC.JSONLD_MIMETYPE + } + r = self.do_get(location_metadata, headers=headers) + self.checkResponse(200, r) + body = r.content.decode('UTF-8') + json_body = json.loads(body) + result = pyjq.all('.[] | select(."@id" == "{}") | ."{}" | .[0]."@id"'.format(second_location, reference), + json_body) + self.assertEqual(location, result[0]) + + @Test + def testInboundReferenceToSelf(self): + """ Test we can generate a self-referencing inbound reference """ + reference = "http://awoods.com/pointsTo" + r = self.do_post(self.getBaseUri()) + self.checkResponse(TC.CREATED, r) + location = self.get_location(r) + + body = "INSERT {{ <> <{}> <{}> }} WHERE {{}}".format(reference, location) + headers = { + "Content-type": TC.SPARQL_UPDATE_MIMETYPE + } + r = self.do_patch(location, headers=headers, body=body) + self.checkResponse(TC.NO_CONTENT, r) + + headers = { + 'Prefer': "return=representation; include=\"{}\"".format(TC.INBOUND_REFERENCE), + 'Accept': TC.JSONLD_MIMETYPE + } + r = self.do_get(location, headers=headers) + self.checkResponse(200, r) + body = r.content.decode('UTF-8') + json_body = json.loads(body) + result = pyjq.all('.[] | select(."@id" == "{}") | ."{}" '.format(location, reference), json_body) + self.assertEqual(1, len(result)) + self_reference = pyjq.first( + '.[] | select(."@id" == "{}") | ."{}" | .[0]."@id"'.format(location, reference), + json_body + ) + self.assertEqual(location, self_reference) + diff --git a/ssearch_tests.py b/ssearch_tests.py new file mode 100644 index 0000000..dc0b735 --- /dev/null +++ b/ssearch_tests.py @@ -0,0 +1,73 @@ +#!/bin/env python +import TestConstants as TC +from abstract_fedora_tests import FedoraTests, register_tests, Test +from FedoraSearchTool import FedoraSearchTool +import pyjq +import json + + +@register_tests +class FedoraSimpleSearchTests(FedoraTests): + + # Create test objects all inside here for easy of review + CONTAINER = "/test_search" + + search = None + + def createSomeResources(self, base=None, count=10): + """ + Create some resources and track the URIs + :param base: The base uri to POST to. + :param count: The number of resources to create. + :return: List of created resource URIs. + """ + resource_ids = list() + if base is None: + base = self.getBaseUri() + for x in range(0, count): + r = self.do_post(base) + location = self.get_location(r) + resource_ids.append(location) + return resource_ids + + def getSearch(self): + """ + Get a FedoraSearchTool instance. + :return: The FedoraSearchTool. + """ + if self.search is None: + self.search = FedoraSearchTool.create(self.getFedoraBase(), self) + return self.search + + @Test + def testSearchAll(self): + """ Test that a search returns all resources created """ + max_results = 20 + offset = 0 + items = self.createSomeResources(count=5) + found = [] + search = self.getSearch() + search.add_condition("fedora_id", "=", "*") + while len(found) < len(items) and offset < 5: + search.set_max_results(max_results) + search.set_offset((offset * max_results)) + r = search.do_query() + self.checkResponse(200, r) + body = r.content.decode('UTF-8') + body_json = json.loads(body) + for item in items: + if pyjq.first('."items" | reduce .[] as $item (false; if $item."fedora_id" == "{}"' + ' then true else . end)'.format(item), body_json): + # Found the item so add it to the list. + found.append(item) + offset += 1 + if len(found) < len(items): + self.fail("Did not find all created ids.") + + @Test + def testWithBadParameter(self): + """ Test that a bad parameter returns a 400 status """ + search = self.getSearch() + search.add_condition("myField", "=", "*") + r = search.do_query() + self.checkResponse(TC.BAD_REQUEST, r) diff --git a/testrunner.py b/testrunner.py index f7baf14..82d088c 100755 --- a/testrunner.py +++ b/testrunner.py @@ -1,4 +1,4 @@ -#!/usr/bin/env python +#!/usr/bin/env python3 import argparse import TestConstants @@ -9,6 +9,7 @@ from yaml import CLoader as Loader except ImportError: from yaml import Loader +from yaml import SafeLoader from basic_interaction_tests import FedoraBasicIxnTests from version_tests import FedoraVersionTests from fixity_tests import FedoraFixityTests @@ -17,6 +18,9 @@ from transaction_tests import FedoraTransactionTests from authz_tests import FedoraAuthzTests from indirect_tests import FedoraIndirectTests +from camel_tests import FedoraCamelTests +from ssearch_tests import FedoraSimpleSearchTests +from archival_group_tests import FedoraArchivalGroupTests class FedoraTestRunner: @@ -31,23 +35,43 @@ class FedoraTestRunner: (TestConstants.USER2_NAME_PARAM, True), (TestConstants.USER2_PASS_PARAM, True), (TestConstants.LOG_FILE_PARAM, False), - (TestConstants.SELECTED_TESTS_PARAM, False) + (TestConstants.SELECTED_TESTS_PARAM, False), + (TestConstants.SOLR_URL_PARAM, False), + (TestConstants.TRIPLESTORE_URL_PARAM, False), + ('debug_level', False), + ('failed_only', False), ] config = {} logger = None - def set_up(self, args): - self.parse_cmdline_args(args) + tests = { + 'basic': FedoraBasicIxnTests, + 'version': FedoraVersionTests, + 'fixity': FedoraFixityTests, + 'rdf': FedoraRdfTests, + 'sparql': FedoraSparqlTests, + 'transaction': FedoraTransactionTests, + 'authz': FedoraAuthzTests, + 'indirect': FedoraIndirectTests, + 'search': FedoraSimpleSearchTests, + 'archivalgroup': FedoraArchivalGroupTests, + } + + def set_up(self, setup_args): + self.parse_cmdline_args(setup_args) self.check_config() def load_config(self, file, site): if os.path.exists(file): if os.access(file, os.R_OK): with open(file, 'r') as fp: - yml = load(fp.read()) + yml = load(fp.read(), Loader=SafeLoader) self.config = yml.get(site) - def parse_cmdline_args(self, args): + def get_tests(self) -> list: + return list(self.tests.keys()) + + def parse_cmdline_args(self, command_args): filename = eval('args.' + TestConstants.CONFIG_FILE_PARAM) if filename is not None: sitename = eval('args.' + TestConstants.SITE_NAME_PARAM) @@ -73,36 +97,40 @@ def check_config(self): if param_status: raise Exception("Missing config parameter (" + param_name + ")") - def run_tests(self): - for test in self.config[TestConstants.SELECTED_TESTS_PARAM]: - if test == 'all' or test == 'basic': - nested = FedoraBasicIxnTests(self.config) - nested.run_tests() - if test == 'all' or test == 'version': - versioning = FedoraVersionTests(self.config) - versioning.run_tests() - if test == 'all' or test == 'fixity': - fixity = FedoraFixityTests(self.config) - fixity.run_tests() - if test == 'all' or test == 'rdf': - rdf = FedoraRdfTests(self.config) - rdf.run_tests() - if test == 'all' or test == 'sparql': - sparql = FedoraSparqlTests(self.config) - sparql.run_tests() - if test == 'all' or test == 'transaction': - transaction = FedoraTransactionTests(self.config) - transaction.run_tests() - if test == 'all' or test == 'authz': - authz = FedoraAuthzTests(self.config) - authz.run_tests() - if test == 'all' or test == 'indirect': - indirect = FedoraIndirectTests(self.config) - indirect.run_tests() - - def main(self, args): - self.set_up(args) - self.run_tests() + def run_tests(self) -> dict: + results = {} + for chosen_test in self.config[TestConstants.SELECTED_TESTS_PARAM]: + if chosen_test == 'all': + for test_param, test_class in self.tests.items(): + instance = test_class(self.config) + instance.run_tests() + results[instance.__class__.__name__] = instance.results + if chosen_test == 'camel': + camel = FedoraCamelTests(self.config) + camel.run_tests() + else: + test_class = self.tests[chosen_test] + instance = test_class(self.config) + instance.run_tests() + results[instance.__class__.__name__] = instance.results + return results + + def report(self, test_results: dict): + """ Print out the test results collected from the various tests. """ + if len(test_results) > 0: + print("\n{:-^50}".format("Test Results")) + for k, results in test_results.items(): + print(f"\nTest Class: {k}") + for method, result in results.items(): + if result['result'] and not self.config['failed_only']: + print(f" Method: {method}, Result: Pass") + elif not result['result']: + print(f" Method: {method}, Result: Failed, {result['message']}") + + def main(self, application_args): + self.set_up(application_args) + results = self.run_tests() + self.report(results) def csv_list(string): @@ -116,7 +144,14 @@ def csv_list(string): class CSVAction(argparse.Action): - valid_options = ["authz", "basic", "sparql", "rdf", "version", "transaction", "fixity", "indirect"] + """ Holds the valid keys for the csv list """ + valid_options = [] + + def __init__(self, option_strings, dest, **kwargs): + if 'csv_options' in kwargs: + self.valid_options = kwargs['csv_options'] + del kwargs['csv_options'] + super(CSVAction, self).__init__(option_strings, dest, **kwargs) def __call__(self, parser, args, values, option_string=None): if isinstance(values, list): @@ -136,6 +171,7 @@ def __call__(self, parser, args, values, option_string=None): if __name__ == '__main__': + tests = FedoraTestRunner() parser = argparse.ArgumentParser(description="Fedora Tester runs a series of tests against an instance of the " "community implementation of the Fedora API specification.") @@ -158,9 +194,13 @@ def __call__(self, parser, args, values, option_string=None): parser.add_argument('-k', '--' + TestConstants.USER2_PASS_PARAM, dest=TestConstants.USER2_PASS_PARAM, help="Second regular user password") parser.add_argument('-t', '--tests', dest="selected_tests", help='Comma separated list of which tests to run from ' - '{0}. Defaults to running all tests'.format(",".join(CSVAction.valid_options)), - default=['all'], type=csv_list, action=CSVAction) + '{0}. Defaults to running all tests'.format(", ".join(tests.get_tests())), + default=['all'], type=csv_list, action=CSVAction, csv_options=tests.get_tests()) + parser.add_argument('-v', dest='debug_level', action='store_const', const=1, default=0, + help='Show all test steps') + parser.add_argument('--failed-only', dest='failed_only', action='store_true', default=False, + help='Only show failed tests') args = parser.parse_args() - tests = FedoraTestRunner() + tests.main(args) diff --git a/transaction_tests.py b/transaction_tests.py index d271c8e..8c017f2 100644 --- a/transaction_tests.py +++ b/transaction_tests.py @@ -1,109 +1,481 @@ #!/bin/env python -import TestConstants +import TestConstants as TC from abstract_fedora_tests import FedoraTests, register_tests, Test -import json -import pyjq @register_tests class FedoraTransactionTests(FedoraTests): - # Create test objects all inside here for easy of review CONTAINER = "/test_transaction" - def get_transaction_provider(self): - headers = { - 'Accept': TestConstants.JSONLD_MIMETYPE - } - r = self.do_get(self.getFedoraBase(), headers=headers) - self.assertEqual(200, r.status_code, "Did not get expected response") - body = r.content.decode('UTF-8') - json_body = json.loads(body) - tx_provider = pyjq.first('.[]."http://fedora.info/definitions/v4/repository#hasTransactionProvider" | .[]."@id"', - json_body) - return tx_provider + def createTransaction(self, admin=None): + if admin is None: + admin = True + tx_location = self.get_transaction_provider() + self.log("Create a transaction") + r = self.do_post(tx_location, admin=admin) + self.checkResponse(201, r) + return self.get_location(r) + + def checkResponse(self, expected, response, tx_id=None): + try: + super().checkResponse(expected, response) + except AssertionError as e: + if tx_id is not None: + self.do_delete(tx_id) + raise e @Test def doCommitTest(self): - self.log("Running doCommitTest") - + """ Test creating and committing a transaction """ tx_provider = self.get_transaction_provider() if tx_provider is None: self.log("Could not location transaction provider") self.log("Skipping test") else: - self.log("Create a transaction") - r = self.do_post(tx_provider) - self.assertEqual(201, r.status_code, "Did not get expected response code") - full_transaction_uri = self.get_location(r) - transaction = full_transaction_uri.replace(self.getFedoraBase(), "") - self.log("Transaction is {0}".format(transaction)) + transaction_id = self.createTransaction() + self.log("Transaction is {0}".format(transaction_id)) self.log("Get status of transaction") - r = self.do_get(full_transaction_uri) - self.assertEqual(200, r.status_code, "Did not get expected response code") + r = self.do_get(transaction_id) + self.checkResponse(TC.NO_CONTENT, r, transaction_id) + self.assertHeaderExists(r, "Atomic-Expires") self.log("Create an container in the transaction") - r = self.do_post(full_transaction_uri + self.CONTAINER) - self.assertEqual(201, r.status_code, "Did not get expected response code") + transaction_headers = { + 'Atomic-Id': transaction_id + } + r = self.do_post(headers=transaction_headers) + self.checkResponse(TC.CREATED, r, transaction_id) transaction_obj = self.get_location(r) self.log("Container is available inside the transaction") - r = self.do_get(transaction_obj) - self.assertEqual(200, r.status_code, "Did not get expected response code") + r = self.do_get(transaction_obj, headers=transaction_headers) + self.checkResponse(TC.OK, r, transaction_id) self.log("Container not available outside the transaction") - outside_location = transaction_obj.replace(transaction, "") - r = self.do_get(outside_location) - self.assertEqual(404, r.status_code, "Did not get expected response code") + r = self.do_get(transaction_obj) + self.checkResponse(TC.NOT_FOUND, r, transaction_id) + + self.log("Use an invalid transaction ID") + bad_headers = { + 'Atomic-ID': 'this-is-a-failure' + } + r = self.do_post(headers=bad_headers) + self.checkResponse(TC.CONFLICT, r, transaction_id) + + self.log("Use the bare UUID of a valid transaction ID") + diff_headers = { + 'Atomic-ID': transaction_id.replace(self.getFedoraBase() + "/" + TC.FCR_TX + "/", "") + } + r = self.do_post(headers=diff_headers) + self.checkResponse(TC.CREATED, r, transaction_id) + second_obj = self.get_location(r) + + self.log("Second container is available inside the transaction") + r = self.do_get(second_obj, headers=transaction_headers) + self.checkResponse(TC.OK, r, transaction_id) + + self.log("Second container not available outside the transaction") + r = self.do_get(second_obj) + self.checkResponse(TC.NOT_FOUND, r, transaction_id) + + self.log("Try to commit with old commit endpoint") + r = self.do_put(transaction_id + "/commit") + self.checkResponse(TC.NOT_FOUND, r, transaction_id) self.log("Commit transaction") - r = self.do_post(full_transaction_uri + "/fcr:tx/fcr:commit") - self.assertEqual(204, r.status_code, "Did not get expected response code") + r = self.do_put(transaction_id) + self.checkResponse(TC.NO_CONTENT, r, transaction_id) self.log("Container is now available outside the transaction") - r = self.do_get(outside_location) - self.assertEqual(200, r.status_code, "Did not get expected response code") + r = self.do_get(transaction_obj) + self.checkResponse(TC.OK, r) + + self.log("Transaction is no longer available") + r = self.do_get(transaction_id) + self.checkResponse(TC.GONE, r) - self.log("Passed") + self.log("Can't use the transaction anymore") + r = self.do_post(headers=transaction_headers) + self.checkResponse(TC.CONFLICT, r) @Test def doRollbackTest(self): - self.log("Running doRollbackTest") - + """ Test creating and rolling back a transaction """ tx_provider = self.get_transaction_provider() if tx_provider is None: self.log("Could not location transaction provider") self.log("Skipping test") else: - self.log("Create a transaction") - r = self.do_post(tx_provider) - self.assertEqual(201, r.status_code, "Did not get expected response code") - full_transaction_uri = self.get_location(r) - transaction = full_transaction_uri.replace(self.getFedoraBase(), "") - self.log("Transaction is {0}".format(transaction)) + transaction_id = self.createTransaction() + self.log("Transaction is {0}".format(transaction_id)) self.log("Create an container in the transaction") - r = self.do_post(full_transaction_uri + self.CONTAINER) - self.assertEqual(201, r.status_code, "Did not get expected response code") + transaction_headers = { + 'Atomic-Id': transaction_id + } + r = self.do_post(headers=transaction_headers) + self.checkResponse(TC.CREATED, r, transaction_id) transaction_obj = self.get_location(r) self.log("Container is available inside the transaction") - r = self.do_get(transaction_obj) - self.assertEqual(200, r.status_code, "Did not get expected response code") + r = self.do_get(transaction_obj, headers=transaction_headers) + self.checkResponse(TC.OK, r, transaction_id) self.log("Container not available outside the transaction") - outside_location = transaction_obj.replace(transaction, "") - r = self.do_get(outside_location) - self.assertEqual(404, r.status_code, "Did not get expected response code") + r = self.do_get(transaction_obj) + self.checkResponse(TC.NOT_FOUND, r, transaction_id) self.log("Rollback transaction") - r = self.do_post(full_transaction_uri + "/fcr:tx/fcr:rollback") - self.assertEqual(204, r.status_code, "Did not get expected response code") + r = self.do_delete(transaction_id) + self.checkResponse(TC.NO_CONTENT, r, transaction_id) self.log("Container is still not available outside the transaction") - r = self.do_get(outside_location) - self.assertEqual(404, r.status_code, "Did not get expected response code") + r = self.do_get(transaction_obj) + self.checkResponse(TC.NOT_FOUND, r) + + self.log("Transaction is no longer available") + r = self.do_get(transaction_id) + self.checkResponse(TC.GONE, r) + + self.log("Can't use the transaction anymore") + r = self.do_post(headers=transaction_headers) + self.checkResponse(TC.CONFLICT, r) + + @Test + def createAndDeleteInTwoTransaction(self): + """ Test creating a resource in one long running transaction and deleting it in a second """ + self.log("Create a transaction") + tx_id = self.createTransaction() + + self.log("Create a container") + headers = { + TC.ATOMIC_ID_HEADER: tx_id + } + r = self.do_post(headers=headers) + self.checkResponse(TC.CREATED, r, tx_id) + container_location = self.get_location(r) + + self.log("Get the container") + r = self.do_get(container_location, headers=headers) + self.checkResponse(TC.OK, r, tx_id) + + self.log("Commit the transaction") + r = self.do_put(tx_id) + self.checkResponse(TC.NO_CONTENT, r, tx_id) + + self.log("Get the container outside transaction") + r = self.do_get(container_location) + self.checkResponse(TC.OK, r) + + self.log("Create a new transaction") + tx_id = self.createTransaction() + headers = { + TC.ATOMIC_ID_HEADER: tx_id + } + + self.log("Delete the container") + r = self.do_delete(container_location, headers=headers) + self.checkResponse(TC.NO_CONTENT, r, tx_id) + + self.log("Container exists outside the transaction") + r = self.do_get(container_location) + self.checkResponse(TC.OK, r, tx_id) + + self.log("Container does not exist inside the transaction") + r = self.do_get(container_location, headers=headers) + self.checkResponse(TC.GONE, r, tx_id) + + self.log("Commit the transaction") + r = self.do_put(tx_id) + self.checkResponse(TC.NO_CONTENT, r, tx_id) + + self.log("Container does not exist outside the transaction") + r = self.do_get(container_location) + self.checkResponse(TC.GONE, r, tx_id) + + @Test + def createAndDeleteInOneTransaction(self): + """ Test creating and deleting a resource in a single transaction """ + tx_id = self.createTransaction() + + self.log("Create a container") + headers = { + TC.ATOMIC_ID_HEADER: tx_id + } + r = self.do_post(headers=headers) + self.checkResponse(TC.CREATED, r, tx_id) + container_location = self.get_location(r) + + self.log("Get the container: {}".format(container_location)) + r = self.do_get(container_location, headers=headers) + self.checkResponse(TC.OK, r, tx_id) + + self.log("Delete the container") + r = self.do_delete(container_location, headers=headers) + self.checkResponse(TC.NO_CONTENT, r, tx_id) + + self.log("Check its NOT FOUND") + r = self.do_get(container_location, headers=headers) + self.checkResponse(TC.NOT_FOUND, r, tx_id) + + self.log("Commit the transaction") + r = self.do_put(tx_id) + self.checkResponse(TC.NO_CONTENT, r) + + @Test + def testTransactionExclusion(self): + """ Test completely removing a resource and then re-adding it in a single transaction """ + self.log("Create a container.") + r = self.do_post() + self.checkResponse(TC.CREATED, r) + container_id = self.get_location(r) + + self.log("Get the container") + r = self.do_get(container_id) + self.checkResponse(TC.OK, r) + + tx_location = self.createTransaction() + + self.log("Delete the container in the transaction") + txheaders = { + TC.ATOMIC_ID_HEADER: tx_location + } + r = self.do_delete(container_id, headers=txheaders) + self.checkResponse(TC.NO_CONTENT, r, tx_location) + self.log("Ensure the container is removed in the transaction.") + r = self.do_get(container_id, headers=txheaders) + self.checkResponse(TC.GONE, r, tx_location) + self.log("Inside a transaction delete the tombstone.") + r = self.do_delete(container_id + "/" + TC.FCR_TOMBSTONE, headers=txheaders) + self.checkResponse(TC.NO_CONTENT, r, tx_location) + self.log("Ensure the container is totally removed in the transaction.") + r = self.do_get(container_id, headers=txheaders) + self.checkResponse(TC.NOT_FOUND, r, tx_location) + self.log("Extend the transaction") + r = self.do_post(tx_location) + self.checkResponse(TC.NO_CONTENT, r, tx_location) + r = self.do_post(tx_location) + self.checkResponse(TC.NO_CONTENT, r, tx_location) + + self.log("Inside the transaction put back the container.") + r = self.do_put(container_id, headers=txheaders) + self.checkResponse(TC.CREATED, r, tx_location) + self.log("Commit the transaction.") + r = self.do_put(tx_location) + self.checkResponse(TC.NO_CONTENT, r, tx_location) + self.log("Verify you can still get the container.") + r = self.do_get(container_id) + self.checkResponse(TC.OK, r) + + @Test + def aPlainUserTransactionRollbackInternal(self): + """ Test a normal user performing actions in a transaction, then rolling it back using info:fedora URIs """ + child, tx_id = self.setup_user_writeable_tx() + + self.log("Rollback transaction") + r = self.do_delete(tx_id, admin=False) + self.checkResponse(TC.NO_CONTENT, r, tx_id) + + self.log("Test getting the child (" + child + ") in transaction") + tx_headers = {TC.ATOMIC_ID_HEADER: tx_id} + r = self.do_get(child, admin=False, headers=tx_headers) + self.checkResponse(TC.CONFLICT, r, tx_id) + + self.log("Test getting the child (" + child + ") outside transaction") + r = self.do_get(child, admin=False) + self.checkResponse(TC.NOT_FOUND, r, tx_id) + + @Test + def aPlainUserTransactionRollbackExternal(self): + """ Test a normal user performing actions in a transaction, then rolling it back using http Fedora URIs """ + child, tx_id = self.setup_user_writeable_tx(fedora_base_uri=self.getFedoraBase()) + + self.log("Rollback transaction") + r = self.do_delete(tx_id, admin=False) + self.checkResponse(TC.NO_CONTENT, r, tx_id) + + self.log("Test getting the child (" + child + ") in transaction") + tx_headers = {TC.ATOMIC_ID_HEADER: tx_id} + r = self.do_get(child, admin=False, headers=tx_headers) + self.checkResponse(TC.CONFLICT, r, tx_id) + + self.log("Test getting the child (" + child + ") outside transaction") + r = self.do_get(child, admin=False) + self.checkResponse(TC.NOT_FOUND, r, tx_id) + + @Test + def aPlainUserTransactionCommitInternal(self): + """ Test a normal user performing actions in a transaction, then committing it using info:fedora URIs """ + child, tx_id = self.setup_user_writeable_tx() + + self.log(f"Test getting the child ({child}) outside the transaction") + r = self.do_get(child, admin=False) + self.checkResponse(TC.NOT_FOUND, r, tx_id) + + self.log("Commit transaction") + r = self.do_put(tx_id, admin=False) + self.checkResponse(TC.NO_CONTENT, r, tx_id) + + self.log(f"Test getting the child ({child}) outside the transaction") + r = self.do_get(child, admin=False) + self.checkResponse(TC.OK, r, tx_id) + + @Test + def aPlainUserTransactionCommitExternal(self): + """ Test a normal user performing actions in a transaction, then committing it using http Fedora URIs """ + child, tx_id = self.setup_user_writeable_tx(fedora_base_uri=self.getFedoraBase()) + + self.log(f"Test getting the child ({child}) outside the transaction") + r = self.do_get(child, admin=False) + self.checkResponse(TC.NOT_FOUND, r, tx_id) + + self.log("Commit transaction") + r = self.do_put(tx_id, admin=False) + self.checkResponse(TC.NO_CONTENT, r, tx_id) + + self.log(f"Test getting the child ({child}) outside the transaction") + r = self.do_get(child, admin=False) + self.checkResponse(TC.OK, r, tx_id) + + @Test + def aSinglePlainUserTransactionCommitInternal(self): + """ Test a normal user performing actions in a transaction, but a second user doesn't have permission + to the transaction endpoint using info:fedora URIs """ + child, tx_id = self.single_user_tx_setup() + + self.log("Commit transaction") + r = self.do_put(tx_id, admin=False) + self.checkResponse(TC.NO_CONTENT, r, tx_id) + + self.log(f"Test getting the child ({child}) outside the transaction") + r = self.do_get(child, admin=False) + self.checkResponse(TC.OK, r, tx_id) + + self.log("Try to start a transaction as the second normal user") + tx_endpoint = self.get_transaction_provider() + r = self.do_post(tx_endpoint, admin=self.create_user2_auth()) + self.checkResponse(TC.FORBIDDEN, r) + + @Test + def aSinglePlainUserTransactionCommitExternal(self): + """ Test a normal user performing actions in a transaction, but a second user doesn't have permission + to the transaction endpoint using http: Fedora URIs """ + child, tx_id = self.single_user_tx_setup(fedora_base_uri=self.getFedoraBase()) + + self.log("Commit transaction") + r = self.do_put(tx_id, admin=False) + self.checkResponse(TC.NO_CONTENT, r, tx_id) + + self.log(f"Test getting the child ({child}) outside the transaction") + r = self.do_get(child, admin=False) + self.checkResponse(TC.OK, r, tx_id) + + self.log("Try to start a transaction as the second normal user") + tx_endpoint = self.get_transaction_provider() + r = self.do_post(tx_endpoint, admin=self.create_user2_auth()) + self.checkResponse(TC.FORBIDDEN, r) + + def single_user_tx_setup(self, fedora_base_uri="info:fedora"): + """ Setup a root ACL with only one normal user allowed to access transactions. """ + root_acl = "@prefix rdfs: .\n" \ + "@prefix acl: .\n" \ + "@prefix foaf: .\n" \ + "@prefix fedora: .\n" \ + "@prefix webac: .\n" \ + "\n" \ + "<{1}/fcr:acl> a webac:Acl .\n" \ + "\n" \ + "<{1}/fcr:acl#authz> a acl:Authorization ;\n" \ + " rdfs:label \"Root Authorization\" ;\n" \ + " rdfs:comment \"By default, all non-Admin agents (foaf:Agent) only " \ + " have read access (acl:Read) to the repository\" ;\n" \ + " acl:agentClass foaf:Agent ;\n" \ + " acl:mode acl:Read ;\n" \ + " acl:accessTo ;\n" \ + " acl:default .\n" \ + "\n" \ + "<{1}/fcr:tx#authz_read_write> a acl:Authorization ;\n" \ + " rdfs:label \"Test Tx Authorization\" ;\n" \ + " rdfs:comment \"Provide read write access to the transaction endpoint\" ;\n" \ + " acl:agent \"{0}\" ;\n" \ + " acl:mode acl:Read, acl:Write ;\n" \ + " acl:accessTo ;\n" \ + " acl:default .\n".format(self.config[TC.USER_NAME_PARAM], + fedora_base_uri) + return self.setup_user_writeable_tx(root_acl=root_acl) + + def setup_user_writeable_tx(self, root_acl=None, fedora_base_uri: str = "info:fedora") -> tuple: + """ Setup the needed containers and ACLs for the tests. """ + self.log("Update the root ACL to allow {0} to access transactions".format(self.config[TC.USER_NAME_PARAM])) + + if root_acl is None: + root_acl = "@prefix rdfs: .\n" \ + "@prefix acl: .\n" \ + "@prefix foaf: .\n" \ + "@prefix fedora: .\n" \ + "@prefix webac: .\n" \ + "\n" \ + "<{0}/fcr:acl> a webac:Acl .\n" \ + "\n" \ + "<{0}/fcr:acl#authz> a acl:Authorization ;\n" \ + " rdfs:label \"Root Authorization\" ;\n" \ + " rdfs:comment \"By default, all non-Admin agents (foaf:Agent) only " \ + " have read access (acl:Read) to the repository\" ;\n" \ + " acl:agentClass foaf:Agent ;\n" \ + " acl:mode acl:Read ;\n" \ + " acl:accessTo ;\n" \ + " acl:default .\n" \ + "\n" \ + "<{0}/fcr:tx#authz_read_write> a acl:Authorization ;\n" \ + " rdfs:label \"Test Tx Authorization\" ;\n" \ + " rdfs:comment \"Provide read write access to the transaction endpoint\" ;\n" \ + " acl:agentClass acl:AuthenticatedAgent ;\n" \ + " acl:mode acl:Read, acl:Write ;\n" \ + " acl:accessTo ;\n" \ + " acl:default .\n".format(fedora_base_uri) + + r = self.do_put(self.getFedoraBase() + "/" + TC.FCR_ACL, {'Content-type': 'text/turtle'}, root_acl) + self.checkResponse([TC.CREATED, TC.NO_CONTENT], r) + + self.log("Create a resource") + r = self.do_post() + self.checkResponse(TC.CREATED, r) + container_id = self.get_location(r) + + auth = "@prefix acl: <{0}>.\n" \ + "@prefix fedora: <{1}>.\n" \ + "<#container> a acl:Authorization ;\n" \ + " acl:mode acl:Read, acl:Write, acl:Append, acl:Control ;\n" \ + " acl:accessTo <{2}> ;\n" \ + " acl:default <{2}> ;\n" \ + " acl:agent \"{3}\" .\n".format(TC.ACL_NS, TC.FEDORA_NS, container_id, + self.config[TC.USER_NAME_PARAM]) + + self.log("Set up ACL with user having full access.") + r = self.do_put(container_id + "/" + TC.FCR_ACL, headers={"Content-type": "text/turtle"}, body=auth, + admin=True) + self.checkResponse(TC.CREATED, r) - self.log("Passed") + self.log("Have user read the item") + r = self.do_get(container_id, admin=False) + self.checkResponse(TC.OK, r) + self.log("Have the user patch the item") + patch = "INSERT DATA { <> \"Some title\" }" + r = self.do_patch(container_id, headers={"Content-type": "application/sparql-update"}, body=patch, + admin=False) + self.checkResponse(TC.NO_CONTENT, r) + self.log("Start a transaction") + tx_id = self.createTransaction(False) + tx_headers = {TC.ATOMIC_ID_HEADER: tx_id} + self.log("Add a child object") + r = self.do_post(container_id, headers=tx_headers, admin=False) + self.checkResponse(TC.CREATED, r, tx_id) + child = self.get_location(r) + self.log(f"Test getting the child ({child}) in transaction") + r = self.do_get(child, admin=False, headers=tx_headers) + self.checkResponse(TC.OK, r, tx_id) + self.log(f"Test getting the child ({child}) outside the transaction") + return child, tx_id diff --git a/version_tests.py b/version_tests.py index f6e1e4e..5d5b772 100644 --- a/version_tests.py +++ b/version_tests.py @@ -1,8 +1,21 @@ #!/bin/env python +import collections +import json + +import pyjq import TestConstants from abstract_fedora_tests import FedoraTests, register_tests, Test import time +import rdflib +from rdflib.namespace import DC, RDF +import concurrent.futures as futures + + +def get_version_endpoint(uri): + if not uri.endswith("/" + TestConstants.FCR_VERSIONS): + uri += "/" + TestConstants.FCR_VERSIONS + return uri @register_tests @@ -12,63 +25,97 @@ class FedoraVersionTests(FedoraTests): CONTAINER = "/test_version" @staticmethod - def count_mementos(response): + def get_all_mementos(response): body = response.content.decode('UTF-8') mementos = [x for x in body.split('\n') if x.find("rel=\"memento\"") >= 0] - return len(mementos) + return mementos + @staticmethod + def count_mementos(response): + return len(FedoraVersionTests.get_all_mementos(response)) + + def checkMementoCount(self, expected, uri, admin=None, use_link_format=True): + if admin is None: + admin = True + uri = get_version_endpoint(uri) + if use_link_format: + rdf_type = TestConstants.LINK_FORMAT_MIMETYPE + else: + rdf_type = TestConstants.JSONLD_MIMETYPE + headers = { + 'Accept': rdf_type + } + r = self.do_get(uri, headers=headers, admin=admin) + self.checkResponse(200, r) + if rdf_type == TestConstants.LINK_FORMAT_MIMETYPE: + self.checkValue(expected, self.count_mementos(r)) + else: + body = r.content.decode('UTF-8') + json_body = json.loads(body) + found_title = pyjq.all('.[] | ."@type" | .[]', json_body) + matching = [x for x in found_title if x == expected] + self.checkValue(expected, len(matching)) + + def getNthMemento(self, uri, memento_number=1, admin=None): + if admin is None: + admin = True + uri = get_version_endpoint(uri) + headers = { + 'Accept': TestConstants.LINK_FORMAT_MIMETYPE + } + r = self.do_get(uri, headers=headers, admin=admin) + self.checkResponse(TestConstants.OK, r) + mementos = self.get_all_mementos(r) + # Remove one to match array numbering + memento_number -= 1 + if len(mementos) > memento_number: + return mementos[memento_number] + else: + self.fail("Count not get the {} memento, only found {}".format(memento_number+1, len(mementos))) + + # Waiting on https://fedora-repository.atlassian.net/browse/FCREPO-3655 @Test def doContainerVersioningTest(self): - self.log("Running doContainerVersioningTest") headers = { 'Link': self.make_type(TestConstants.LDP_BASIC) } r = self.do_post(self.getBaseUri(), headers=headers) self.log("Create a basic container") - self.assertEqual(201, r.status_code, "Did not create container") + self.checkResponse(TestConstants.CREATED, r) location = self.get_location(r) + self.log("at " + location) version_endpoint = location + "/" + TestConstants.FCR_VERSIONS r = self.do_get(version_endpoint) - self.assertEqual(200, r.status_code, "Did not find versions where we should") + self.checkResponse(TestConstants.OK, r) self.log("Create a version") r = self.do_post(version_endpoint) - self.assertEqual(201, r.status_code, 'Did not create a version') + self.checkResponse(TestConstants.CREATED, r) + memento_location = self.get_location(r) self.log("Get the resource content") headers = { 'Accept': TestConstants.JSONLD_MIMETYPE } r = self.do_get(location, headers=headers) - self.assertEqual(200, r.status_code, "Could not get the resource") + self.checkResponse(200, r) body = r.content.decode('UTF-8').rstrip('\n') new_date = FedoraTests.get_rfc_date("2000-06-01 08:21:00") - self.log("Create a version with provided datetime") headers = { 'Content-Type': TestConstants.JSONLD_MIMETYPE, 'Prefer': TestConstants.PUT_PREFER_LENIENT, 'Memento-Datetime': new_date } - r = self.do_post(version_endpoint, headers=headers, body="[]") - self.assertEqual(400, r.status_code, "Empty body should cause Bad request") - - r = self.do_post(version_endpoint, headers=headers, body=body) - self.assertEqual(201, r.status_code, "Did not create memento will body.") - memento_location = self.get_location(r) + self.log("Try to create a version with provided datetime (Fedora 6)") + r = self.do_post(version_endpoint, headers=headers) + self.checkResponse(TestConstants.BAD_REQUEST, r) - self.log("Try creating another version at the same location") + self.log("Try to create a version with provided datetime and body (Fedora 6)") r = self.do_post(version_endpoint, headers=headers, body=body) - self.assertEqual(409, r.status_code, "Did not get conflict trying to create a second memento") - - self.log("Check memento exists") - r = self.do_get(memento_location) - self.assertEqual(200, r.status_code, "Memento was not found") - found_datetime = r.headers['Memento-Datetime'] - self.assertIsNotNone(found_datetime, "Did not find Memento-Datetime header") - self.assertEqual(0, self.compare_rfc_dates(new_date, found_datetime), "Returned Memento-Datetime did not match sent") + self.checkResponse(TestConstants.BAD_REQUEST, r) self.log("Patch the original resource") sparql_body = "prefix dc: " \ @@ -79,59 +126,65 @@ def doContainerVersioningTest(self): "Content-Type": TestConstants.SPARQL_UPDATE_MIMETYPE } r = self.do_patch(location, headers=headers, body=sparql_body) - self.assertEqual(204, r.status_code, "Unable to patch") + self.checkResponse(TestConstants.NO_CONTENT, r) self.log("Try to patch the memento") r = self.do_patch(memento_location, headers=headers, body=sparql_body) - self.assertEqual(405, r.status_code, "Did not get denied PATCH request.") + self.checkResponse(TestConstants.METHOD_NOT_ALLOWED, r) - # Delay one second to ensure we don't POST at the same time + self.log("Wait a second to change the time.") time.sleep(1) self.log("Create another version") r = self.do_post(version_endpoint) - self.assertEqual(201, r.status_code, 'Did not create a version') + self.checkResponse(TestConstants.CREATED, r) self.log("Count mementos") - headers = { - 'Accept': 'application/link-format' - } - r = self.do_get(version_endpoint, headers=headers) - self.assertEqual(200, r.status_code, "Did not get the fcr:versions content") - self.assertEqual(3, FedoraVersionTests.count_mementos(r), "Incorrect number of Mementos") + self.checkMementoCount(2, version_endpoint) - self.log("Delete a Memento") + self.log("Try to delete a Memento") r = self.do_delete(memento_location) - self.assertEqual(204, r.status_code, "Did not delete the Memento") + self.checkResponse(TestConstants.METHOD_NOT_ALLOWED, r) - self.log("Validate delete") + self.log("Check memento still exists.") r = self.do_get(memento_location) - self.assertEqual(404, r.status_code, "Memento was not gone") + self.checkResponse(TestConstants.OK, r) - self.log("Validate delete with another count") - r = self.do_get(version_endpoint, headers=headers) - self.assertEqual(200, r.status_code, "Did not get the fcr:versions content") - self.assertEqual(2, FedoraVersionTests.count_mementos(r), "Incorrect number of Mementos") - - self.log("Create a memento at the deleted datetime") + @Test + def makeVersionsInsideASecond(self): headers = { - 'Content-Type': TestConstants.JSONLD_MIMETYPE, - 'Prefer': TestConstants.PUT_PREFER_LENIENT, - 'Memento-Datetime': new_date + 'Link': self.make_type(TestConstants.LDP_BASIC) } - r = self.do_post(version_endpoint, headers=headers, body=body) - self.assertEqual(201, r.status_code, "Did not create version with specific date and body") + r = self.do_post(self.getBaseUri(), headers=headers) + self.log("Create a basic container") + self.checkResponse(TestConstants.CREATED, r) + location = self.get_location(r) + self.log("at " + location) - self.log("Check the memento exists again") - r = self.do_get(memento_location) - self.assertEqual(200, r.status_code, "Memento was not found") + version_endpoint = location + "/" + TestConstants.FCR_VERSIONS + r = self.do_get(version_endpoint) + self.checkResponse(TestConstants.OK, r) + self.checkMementoCount(1, version_endpoint) - self.log("Passed") + time.sleep(1) + + self.log("Create multiple versions inside a second") + with futures.ThreadPoolExecutor(max_workers=3) as ec: + results = ec.map(self.do_post, [version_endpoint, version_endpoint, version_endpoint]) + status_codes = [r.status_code for r in results] + counter = collections.Counter(status_codes) + self.assertEqual(2, counter[TestConstants.CONFLICT]) + self.assertEqual(1, counter[TestConstants.CREATED]) + + self.log("Count mementos") + # Only one new memento as they are both in the same second. + self.checkMementoCount(2, version_endpoint) + # But multiple actual versions created + self.checkMementoCount(2, version_endpoint, use_link_format=True) @Test def doBinaryVersioningTest(self): - self.log("Running doBinaryVersioningTest") - + """ Test versioning with binaries and their metadata containers endpoint are synced """ headers = { 'Link': self.make_type(TestConstants.LDP_NON_RDF_SOURCE), 'Content-Type': 'text/csv' @@ -141,38 +194,55 @@ def doBinaryVersioningTest(self): r = self.do_post(self.getBaseUri(), headers=headers, files=files) self.log("Create a NonRdfSource") - self.assertEqual(201, r.status_code, "Did not create NonRdfSource") + self.checkResponse(TestConstants.CREATED, r) location = self.get_location(r) + self.log("URI is {}".format(location)) + description_location = self.find_binary_description(r) version_endpoint = location + "/" + TestConstants.FCR_VERSIONS + description_version_endpoint = description_location + "/" + TestConstants.FCR_VERSIONS + + self.checkMementoCount(1, location) + self.checkMementoCount(1, description_location) + + self.log("Get version endpoint") r = self.do_get(version_endpoint) - self.assertEqual(200, r.status_code, "Did not find versions where we should") + self.checkResponse(TestConstants.OK, r) + + self.log("Get description version endpoint") + r = self.do_get(description_version_endpoint) + self.checkResponse(TestConstants.OK, r) + self.log("Wait for one second") + time.sleep(1) self.log("Create a version") r = self.do_post(version_endpoint) - self.assertEqual(201, r.status_code, 'Did not create a version') + self.checkResponse(TestConstants.CREATED, r) + + self.log("Try to create another version within a second but not at the same time") + r = self.do_post(version_endpoint) + self.checkResponse(TestConstants.CREATED, r) + + self.assertNotEqual(version_endpoint, description_version_endpoint) + self.log("Try to create a version of the description quickly") + r = self.do_post(description_version_endpoint) + self.checkResponse(TestConstants.CREATED, r) + + self.checkMementoCount(2, location) + self.checkMementoCount(2, description_location) + + self.log("Wait one second") + time.sleep(1) new_date = FedoraTests.get_rfc_date("2000-06-01 08:21:00") - self.log("Create a version with provided datetime") + self.log("Try to create a version with provided datetime") headers = { 'Content-Type': 'text/csv', 'Memento-Datetime': new_date } r = self.do_post(version_endpoint, headers=headers, files=files) - self.assertEqual(201, r.status_code, "Did not create version with specific date and body") - memento_location = self.get_location(r) - - self.log("Try creating another version at the same location") - r = self.do_post(version_endpoint, headers=headers, files=files) - self.assertEqual(409, r.status_code, "Did not get conflict trying to create a second memento") - - self.log("Check memento exists") - r = self.do_head(memento_location) - self.assertEqual(200, r.status_code, "Memento was not found") - found_datetime = r.headers['Memento-Datetime'] - self.assertIsNotNone(found_datetime, "Did not find Memento-Datetime header") - self.assertEqual(0, self.compare_rfc_dates(new_date, found_datetime), "Returned Memento-Datetime did not match sent") + self.checkResponse(TestConstants.BAD_REQUEST, r) self.log("PUT to the original resource") files = {'file': ('report.csv', 'some,data,to,send\nanother,row,to,send\nevent,more,data,tosend\n')} @@ -180,52 +250,227 @@ def doBinaryVersioningTest(self): 'Content-Type': 'text/csv' } r = self.do_put(location, headers=headers, files=files) - self.assertEqual(204, r.status_code, "Unable to put") + self.checkResponse(TestConstants.NO_CONTENT, r) + + self.log("Create a memento with a simple POST") + r = self.do_post(version_endpoint) + self.checkResponse(TestConstants.CREATED, r) + memento_location = self.get_location(r) + + self.log("Try to GET the memento") + r = self.do_get(memento_location) + self.checkResponse(TestConstants.OK, r) self.log("Try to put to the memento") r = self.do_put(memento_location, headers=headers, files=files) - self.assertEqual(405, r.status_code, "Did not get denied PUT request.") + self.checkResponse(TestConstants.METHOD_NOT_ALLOWED, r) - # Delay one second to ensure we don't POST at the same time + self.log("Wait one second to change the time.") time.sleep(1) self.log("Create another version") r = self.do_post(version_endpoint) - self.assertEqual(201, r.status_code, 'Did not create a version') + self.checkResponse(TestConstants.CREATED, r) self.log("Count mementos") + self.checkMementoCount(4, version_endpoint) + + self.log("Try to DELETE the memento") + r = self.do_delete(memento_location) + self.checkResponse(TestConstants.METHOD_NOT_ALLOWED, r) + + self.log("Check the memento exists again") + r = self.do_head(memento_location) + self.checkResponse(TestConstants.OK, r) + + self.log("Validate count of mementos again") + self.checkMementoCount(4, version_endpoint) + + @Test + def checkBinaryVersioning(self): + """ Test that changes to a binary are displayed on the binary description version endpoint too """ headers = { - 'Accept': 'application/link-format' + 'Link': self.make_type(TestConstants.LDP_NON_RDF_SOURCE), + 'Content-Type': 'text/csv' } - r = self.do_get(version_endpoint, headers=headers) - self.assertEqual(200, r.status_code, "Did not get the fcr:versions content") - self.assertEqual(3, FedoraVersionTests.count_mementos(r), "Incorrect number of Mementos") + files = {'file': ('report.csv', 'some,data,to,send\nanother,row,to,send\n')} - self.log("Delete a Memento") - r = self.do_delete(memento_location) - self.assertEqual(204, r.status_code, "Did not delete the Memento") + self.log("Create a NonRdfSource") + r = self.do_post(self.getBaseUri(), headers=headers, files=files) + self.checkResponse(201, r) + location = self.get_location(r) + description_location = self.find_binary_description(r) - self.log("Validate delete") - r = self.do_get(memento_location) - self.assertEqual(404, r.status_code, "Memento was not gone") + binary_versions = location + "/" + TestConstants.FCR_VERSIONS + metadata_versions = description_location + "/" + TestConstants.FCR_VERSIONS + + link_headers = { + 'Accept': TestConstants.LINK_FORMAT_MIMETYPE + } + + self.log("Count Mementos of binary") + r = self.do_get(binary_versions, headers=link_headers) + self.checkResponse(200, r) + self.checkValue(1, self.count_mementos(r)) + + self.log("Count Mementos of binary description") + r = self.do_get(metadata_versions, headers=link_headers) + self.checkResponse(200, r) + self.checkValue(1, self.count_mementos(r)) + + self.log("Wait a second") + time.sleep(1) + + self.log("Create version of binary from existing") + r = self.do_post(binary_versions) + self.checkResponse(201, r) - self.log("Validate delete with another count") - r = self.do_get(version_endpoint, headers=headers) - self.assertEqual(200, r.status_code, "Did not get the fcr:versions content") - self.assertEqual(2, FedoraVersionTests.count_mementos(r), "Incorrect number of Mementos") + self.log("Count Mementos of binary") + self.checkMementoCount(2, binary_versions) - self.log("Create a memento at the deleted datetime") + self.log("Count Mementos of binary description") + self.checkMementoCount(2, metadata_versions) + + self.log("Wait a second") + time.sleep(1) + + self.log("Create version of binary metadata from existing") + r = self.do_post(metadata_versions) + self.checkResponse(201, r) + + self.log("Count Mementos of binary") + self.checkMementoCount(3, binary_versions) + + self.log("Count Mementos of binary description") + self.checkMementoCount(3, metadata_versions) + + @Test + def createBinaryVersionsWithTimeOrBody(self): + """ Test we can no longer provide a version datetime or a version body """ headers = { - 'Content-Type': TestConstants.JSONLD_MIMETYPE, - 'Prefer': TestConstants.PUT_PREFER_LENIENT, - 'Memento-Datetime': new_date + 'Link': self.make_type(TestConstants.LDP_NON_RDF_SOURCE), + 'Content-Type': 'text/csv' } + files = {'file': ('report.csv', 'some,data,to,send\nanother,row,to,send\n')} + + self.log("Create a NonRdfSource") + r = self.do_post(self.getBaseUri(), headers=headers, files=files) + self.checkResponse(201, r) + location = self.get_location(r) + description_location = self.find_binary_description(r) + + r = self.do_get(description_location) + new_body = "@prefix dc: <{0}> .\n".format(TestConstants.DC_NS) + \ + r.text[0:-2] + ";\n dc:title \"New title\" .\n".format(TestConstants.DC_NS) + + version_endpoint = location + "/" + TestConstants.FCR_VERSIONS + description_version_endpoint = description_location + "/" + TestConstants.FCR_VERSIONS + + self.log("Check we have a single mementos of binary and description (auto-versioning)") + self.checkMementoCount(1, version_endpoint) + self.checkMementoCount(1, description_version_endpoint) + + the_date = FedoraTests.get_rfc_date('2019-05-21 18:30:00') + files = {'file': ('report.csv', 'some,data,to,send\nanother,row,to,send\nevent,more,data,tosend\n')} + headers = { + 'Content-Type': 'text/csv', + 'Memento-Datetime': the_date + } + + self.log("Can't make version for {0} of binary with a body".format(the_date)) r = self.do_post(version_endpoint, headers=headers, files=files) - self.assertEqual(201, r.status_code, "Did not create version with specific date and body") + self.checkResponse(TestConstants.BAD_REQUEST, r) - self.log("Check the memento exists again") - r = self.do_head(memento_location) - self.assertEqual(200, r.status_code, "Memento was not found") + self.log("Check we haven't made a memento of the binary") + self.checkMementoCount(1, version_endpoint) + self.log("Check we haven't made a memento of the description") + self.checkMementoCount(1, description_version_endpoint) + + headers = { + 'Content-type': TestConstants.TURTLE_MIMETYPE, + 'Memento-Datetime': the_date, + 'Prefer': TestConstants.PUT_PREFER_LENIENT + } - self.log("Passed") + self.log("Can't make version for {0} of binary description with a body".format(the_date)) + r = self.do_post(description_version_endpoint, headers=headers, body=new_body) + self.checkResponse(TestConstants.BAD_REQUEST, r) + self.log("Count binary mementos") + self.checkMementoCount(1, version_endpoint) + + self.log("Count binary description mementos") + self.checkMementoCount(1, description_version_endpoint) + + @Test + def testMementoAreAccessibleAfterDelete(self): + """ Test mementos are still accessible when a resource is deleted but not purged. """ + r = self.do_post() + self.checkResponse(TestConstants.CREATED, r) + uri = self.get_location(r) + + self.verifyGet(uri) + + r = self.do_post(uri + "/" + TestConstants.FCR_VERSIONS) + self.checkResponse(TestConstants.CREATED, r) + memento = self.get_location(r) + self.log("Wait a second or the memento and the delete will occur in the same second") + time.sleep(1) + + self.verifyGet(memento) + + r = self.do_delete(uri) + self.checkResponse(TestConstants.NO_CONTENT, r) + + self.verifyGone(uri) + # TimeMaps and Mementos are accessible after the resource is deleted (but not purged) as of 6.5.0 + self.verifyGet(uri + "/" + TestConstants.FCR_VERSIONS) + self.verifyGet(memento) + + @Test + def testBinaryDescription(self): + """ Test checking past versions of binary descriptions """ + headers = { + 'Content-type': 'text/plain' + } + r = self.do_post(self.getBaseUri(), headers=headers, body="Some example text") + self.checkResponse(TestConstants.CREATED, r) + location = self.get_location(r) + description_uri = location + "/" + TestConstants.FCR_METADATA + test_type = "http://example.org/customType" + + headers = { + 'Content-type': "application/sparql-update" + } + update_string = "INSERT { <> <" + TestConstants.DC_TITLE + "> \"Original\" ." \ + " <> <" + TestConstants.RDF_TYPE + "> <" + test_type + "> } WHERE { }" + + r1 = self.do_patch(description_uri, update_string, headers=headers) + self.checkResponse(TestConstants.NO_CONTENT, r1) + + r2 = self.do_post(description_uri + "/" + TestConstants.FCR_VERSIONS) + self.checkResponse(TestConstants.CREATED, r2) + memento = self.get_location(r2) + + changed_string = "INSERT { <" + location + "> <" + TestConstants.DC_TITLE + "> \"Updated\". } WHERE { }" + r3 = self.do_patch(description_uri, changed_string, headers=headers) + self.checkResponse(TestConstants.NO_CONTENT, r3) + + r4 = self.do_get(memento, headers={'Accept': 'application/n-triples'}) + self.checkResponse(TestConstants.OK, r4) + graph = rdflib.Graph() + graph.parse(data=r4.content, format="nt") + + subject_uri = rdflib.URIRef(location) + + # Have to use assertTrue( triple NOT IN graph) or it throws an exception. + self.assertTrue("Property added to original before versioning must appear", + (subject_uri, DC.title, rdflib.Literal("Original")) in graph) + self.assertTrue("Property added after memento created must not appear", + (subject_uri, DC.title, rdflib.Literal("Updated")) not in graph) + self.assertTrue("Memento type should not be visible", + (subject_uri, RDF.type, rdflib.URIRef(TestConstants.MEM_MEMENTO)) not in graph) + self.assertTrue("Must have binary type", + (subject_uri, RDF.type, rdflib.URIRef(TestConstants.FEDORA_BINARY)) in graph) + self.assertTrue("Must have custom type", + (subject_uri, RDF.type, rdflib.URIRef(test_type)) in graph)