diff --git a/tests/test_search/test_permissions/__init__.py b/tests/test_search/test_permissions/__init__.py new file mode 100644 index 00000000000..829197bafa5 --- /dev/null +++ b/tests/test_search/test_permissions/__init__.py @@ -0,0 +1,117 @@ +# -*- coding: utf-8 -*- +"""This is a test suite for permissions on the search_search endpoint. It has four parts: + + - nodefuncs - functions that return Nodes + - permfuncs - functions that set permissions on a Node + - varyfuncs - functions that vary the (non-permission) state of a Node + - TestSearchSearchAPI - the actual tests against the search_search API, + which are generated from the combinations of the above three function types + +""" +from __future__ import absolute_import, division, print_function, unicode_literals + +import sys +import unittest +import unittest.case + +from nose.tools import assert_equal + +from nose_parameterized import parameterized +from tests.test_search import SearchTestCase +from tests.test_search.test_permissions.test_varyfuncs import VARYFUNCS, REGFUNCS, REGFUNCS_PRIVATE +from tests.test_search.test_permissions.test_varyfuncs import base +from tests.test_search.test_permissions.test_permfuncs import anon, auth, read +from tests.test_search.test_permissions.test_nodefuncs import NODEFUNCS, NODEFUNCS_PRIVATE +from website.project.model import Node +from website.util import api_url_for + + +def determine_case_name(nodefunc, permfunc, varyfunc, should_see, **_): + return "{}{}_{}_{}".format( + '' if varyfunc is base else varyfunc.__name__ + '_', + nodefunc.__name__, + 'is_shown_to' if should_see else 'is_hidden_from', + permfunc.__name__ + ) + + +def is_private(nodefunc, varyfunc): + return nodefunc in NODEFUNCS_PRIVATE or varyfunc in REGFUNCS_PRIVATE + + +def want(name): + # filter cases here since we can't use nose's usual mechanisms with parameterization + return True + + +def generate_cases(): + for nodefunc in NODEFUNCS: + for permfunc in (anon, auth, read): + for varyfunc in VARYFUNCS: + if nodefunc in NODEFUNCS_PRIVATE and varyfunc in REGFUNCS: + # Registration makes a node public, so skip it. + continue + should_see = permfunc is read if is_private(nodefunc, varyfunc) else True + name = determine_case_name(**locals()) + if want(name): + yield name, nodefunc, permfunc, varyfunc, should_see + + +class TestGenerateCases(unittest.TestCase): + + # gc - generate_cases + + def test_gc_generates_cases(self): + assert_equal(len(list(generate_cases())), 342) + + def test_gc_doesnt_create_any_nodes(self): + list(generate_cases()) + assert_equal(len(Node.find()), 0) + + +def possiblyExpectFailure(case): + + # This is a hack to conditionally wrap a failure expectation around *some* + # of the cases we're feeding to node-parameterized. TODO It can be removed + # when we write the code to unfail the tests. + + def test(*a, **kw): # name must start with test or it's ignored + _, _, nodefunc, permfunc, varyfunc, _ = a + + # The tests we expect to fail are those where a node is in a private + # state, and a user does have read permission: a private search, in + # other words. + + if is_private(nodefunc, varyfunc) and permfunc is read: + + # This bit is copied from the unittest/case.py:expectedFailure + # decorator. + + try: + case(*a, **kw) + except Exception: + raise unittest.case._ExpectedFailure(sys.exc_info()) + raise unittest.case._UnexpectedSuccess + else: + case(*a, **kw) + return test + + +class TestSearchSearchAPI(SearchTestCase): + """Exercises the website.search.views.search_search view. + """ + + def search(self, query, category, auth): + url = api_url_for('search_search') + data = {'q': 'category:{} AND {}'.format(category, query)} + return self.app.get(url, data, auth=auth).json['results'] + + @parameterized.expand(generate_cases) + @possiblyExpectFailure + def test(self, ignored, nodefunc, permfunc, varyfunc, should_see): + node = nodefunc() + auth = permfunc(node) + query, type_, key, expected_name = varyfunc(node) + expected = [(expected_name, type_)] if should_see else [] + results = self.search(query, type_, auth) + assert_equal([(x[key], x['category']) for x in results], expected) diff --git a/tests/test_search/test_permissions/test_nodefuncs.py b/tests/test_search/test_permissions/test_nodefuncs.py new file mode 100644 index 00000000000..97b0218fd91 --- /dev/null +++ b/tests/test_search/test_permissions/test_nodefuncs.py @@ -0,0 +1,93 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, division, print_function, unicode_literals + +from nose.tools import assert_equal, ok_ + +from modularodm import Q + +from tests import factories +from tests.base import DbIsolationMixin +from tests.test_search import OsfTestCase +from website.project.model import Node + + +def _project(is_public): + project = factories.ProjectFactory(title='Flim Flammity', is_public=is_public) + project.update_search() + return project + +def public_project(): return _project(True) +def private_project(): return _project(False) + + +def _component(is_public, parent_is_public): + project = factories.ProjectFactory(title='Slim Slammity', is_public=parent_is_public) + project.update_search() + component = factories.NodeFactory( + title='Flim Flammity', + parent=project, + is_public=is_public, + ) + component.update_search() + return component + +def public_component_of_a_public_project(): return _component(True, True) +def public_component_of_a_private_project(): return _component(True, False) +def private_component_of_a_public_project(): return _component(False, True) +def private_component_of_a_private_project(): return _component(False, False) + + +NODEFUNCS_PRIVATE = [ + private_project, + private_component_of_a_public_project, + private_component_of_a_private_project +] +NODEFUNCS = [ + public_project, + public_component_of_a_public_project, + public_component_of_a_private_project +] + NODEFUNCS_PRIVATE + + +class TestNodeFuncs(DbIsolationMixin, OsfTestCase): + + def test_there_are_no_nodes_to_start_with(self): + assert_equal(Node.find().count(), 0) + + + # pp - {public,private}_project + + def test_pp_makes_public_project_public(self): + public_project() + ok_(Node.find_one().is_public) + + def test_pp_makes_private_project_private(self): + private_project() + ok_(not Node.find_one().is_public) + + + # pcoapp - {public,private}_component_of_a_{public,private}_project + + def test_pcoapp_makes_public_component_of_a_public_project(self): + public_component_of_a_public_project() + component = Node.find_one(Q('parent_node', 'ne', None)) + ok_(component.is_public) + ok_(component.parent_node.is_public) + + def test_pcoapp_makes_public_component_of_a_private_project(self): + public_component_of_a_private_project() + component = Node.find_one(Q('parent_node', 'ne', None)) + ok_(component.is_public) + ok_(not component.parent_node.is_public) + + def test_pcoapp_makes_private_component_of_a_public_project(self): + private_component_of_a_public_project() + component = Node.find_one(Q('parent_node', 'ne', None)) + ok_(not component.is_public) + ok_(component.parent_node.is_public) + + def test_pcoapp_makes_private_component_of_a_private_project(self): + private_component_of_a_private_project() + component = Node.find_one(Q('parent_node', 'ne', None)) + ok_(not component.is_public) + ok_(not component.parent_node.is_public) diff --git a/tests/test_search/test_permissions/test_permfuncs.py b/tests/test_search/test_permissions/test_permfuncs.py new file mode 100644 index 00000000000..444cc702fdf --- /dev/null +++ b/tests/test_search/test_permissions/test_permfuncs.py @@ -0,0 +1,73 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, division, print_function, unicode_literals + +from nose.tools import assert_equal, assert_in, assert_not_in + +from modularodm import Q + +from framework.auth.core import User +from tests import factories +from tests.base import DbIsolationMixin +from tests.test_search import OsfTestCase +from tests.test_search.test_permissions.test_nodefuncs import public_project +from website.util import permissions +from website.project.model import Node + + +def anon(node): + return None + + +def auth(node): + return factories.AuthUserFactory().auth + + +def read(node): + user = factories.AuthUserFactory() + node.add_contributor(user, permissions.READ) + return user.auth + + +class TestPermFuncs(DbIsolationMixin, OsfTestCase): + + @staticmethod + def get_user_id_from_authtuple(authtuple): + return User.find_one(Q('emails', 'eq', authtuple[0]))._id + + + # anon + + def test_anon_returns_none(self): + assert_equal(anon(public_project()), None) + + def test_anon_makes_no_user(self): + anon(public_project()) + assert_equal(len(User.find()), 1) # only the project creator + + + # auth + + def test_auth_returns_authtuple(self): + assert_equal(auth(public_project())[1], 'password') + + def test_auth_creates_a_user(self): + auth(public_project()) + assert_equal(len(User.find()), 2) # project creator + 1 + + def test_auth_user_is_not_a_contributor_on_the_node(self): + user_id = self.get_user_id_from_authtuple(auth(public_project())) + assert_not_in(user_id, Node.find_one().permissions.keys()) + + + # read + + def test_read_returns_authtuple(self): + assert_equal(read(public_project())[1], 'password') + + def test_read_creates_a_user(self): + read(public_project()) + assert_equal(len(User.find()), 2) # project creator + 1 + + def test_read_user_is_a_contributor_on_the_node(self): + user_id = self.get_user_id_from_authtuple(read(public_project())) + assert_in(user_id, Node.find_one().permissions.keys()) diff --git a/tests/test_search/test_permissions/test_varyfuncs.py b/tests/test_search/test_permissions/test_varyfuncs.py new file mode 100644 index 00000000000..00ffc3c8d08 --- /dev/null +++ b/tests/test_search/test_permissions/test_varyfuncs.py @@ -0,0 +1,320 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, division, print_function, unicode_literals + +from nose.tools import assert_equal, ok_ + +from modularodm import Q +from framework.auth import Auth +from tests import factories +from tests.base import DbIsolationMixin +from tests.test_search import OsfTestCase +from tests.test_search.test_permissions.test_nodefuncs import ( + public_project, public_component_of_a_public_project +) +from tests.utils import mock_archive, run_celery_tasks +from website.addons.wiki.model import NodeWikiPage +from website.files.models.base import File +from website.project.model import Node + + +# The return value is: (search query, category, key to test, expected value). +# Check TestSearchSearchAPI for usage. + +def base(node): + category = 'project' if node.parent_node is None else 'component' + return 'flim', category, 'title', 'Flim Flammity' + + +def file_on(node): + node.get_addon('osfstorage').get_root().append_file('Blim Blammity') + return 'blim', 'file', 'name', 'Blim Blammity' + + +def wiki_on(node): + category = 'project' if node.parent_node is None else 'component' + with run_celery_tasks(): + node.update_node_wiki('Blim Blammity', 'Blim, blammity.', Auth(node.creator)) + return 'blim', category, 'title', 'Flim Flammity' + + +# Registrations are more complicated, because they have multiple possible +# states. We therefore have a second-level generator to programmatically create +# the functions to vary a node according to the different registration states. + +def _register(*a, **kw): + embargo = kw.get('embargo') # (None, True, False) + kw['embargo'] = False if embargo is None else True # (True, False) + for unwanted in ('regfunc', 'should_be_public', 'private', 'public'): + kw.pop(unwanted, '') + registration = mock_archive(*a, **kw).__enter__() # gooooooofffyyyyyy + if embargo is False and registration.is_embargoed: + registration.terminate_embargo(Auth(registration.creator)) + registration.update_search() + return 'flim', 'registration', 'title', 'Flim Flammity' + + +def name_regfunc(embargo, autoapprove, autocomplete, retraction, autoapprove_retraction, **_): + retraction_part = '' if not retraction else \ + '{}_retraction_of_'.format('approved' if autoapprove_retraction else + 'unapproved') + return '{}{}{}{}_{}_registration_of_a'.format( + retraction_part, + '' if not retraction else 'an_' if embargo in (None, True) else 'a_', + 'embargoed_' if embargo else '' if embargo is None else 'formerly_embargoed_', + 'approved' if autoapprove else 'unapproved', + 'complete' if autocomplete else 'incomplete', + ).encode('ascii') + + +def want_regfunc(name): # helpful to filter regfuncs during development + return True + + +def determine_whether_it_should_be_public(retraction, embargo, autoapprove_retraction, \ + autocomplete, autoapprove, **kw): + if retraction and embargo: + # Approving a retraction removes embargoes and makes the reg public, + # but only for *completed* registrations. + should_be_public = autoapprove_retraction and autocomplete + elif embargo: + should_be_public = False + else: + should_be_public = (autoapprove or autoapprove_retraction) and autocomplete + return should_be_public + + +def create_regfunc(**kw): + def regfunc(node): + return _register(node, **kw) + regfunc.__name__ = name_regfunc(**kw) + return regfunc + + +def create_regfuncs(): + public = set() + private = set() + # Default values are listed first for all of these ... + for embargo in (None, True, False): # never, currently, formerly + for autoapprove in (False, True): + for autocomplete in (True, False): + for autoapprove_retraction in (None, False, True): + retraction = autoapprove_retraction is not None + if retraction and not (autoapprove or embargo is not None): + continue # 'Only public or embargoed registrations may be withdrawn.' + regfunc = create_regfunc(**locals()) + if not want_regfunc(regfunc.__name__): + continue + should_be_public = determine_whether_it_should_be_public(**locals()) + (public if should_be_public else private).add(regfunc) + return public, private + +REGFUNCS_PUBLIC, REGFUNCS_PRIVATE = create_regfuncs() +REGFUNCS = REGFUNCS_PUBLIC | REGFUNCS_PRIVATE +_REGFUNCS_BY_NAME = {regfunc.__name__: regfunc for regfunc in REGFUNCS} + +VARYFUNCS = ( + base, + file_on, + wiki_on, +) + tuple(REGFUNCS) + + +class TestVaryFuncs(DbIsolationMixin, OsfTestCase): + + # base + + def test_base_specifies_project_for_project(self): + assert_equal(base(public_project())[1], 'project') + + def test_base_specifies_component_for_component(self): + assert_equal(base(public_component_of_a_public_project())[1], 'component') + + + # fo - file_on + + def test_fo_makes_a_file_on_a_node(self): + file_on(factories.ProjectFactory()) + assert_equal(File.find_one(Q('is_file', 'eq', True)).name, 'Blim Blammity') + + + # wo - wiki_on + + def test_wo_makes_a_wiki_on_a_node(self): + project = factories.ProjectFactory() + wiki_on(project) + page = NodeWikiPage.load(project.wiki_pages_current['blim blammity']) + assert_equal(page.page_name, 'Blim Blammity') + assert_equal(page.content, 'Blim, blammity.') + + + # regfuncs + + def Check(self, name): + regfunc = _REGFUNCS_BY_NAME[name + '_registration_of_a'] + regfunc(factories.ProjectFactory(title='Flim Flammity')) + reg = Node.find_one(Q('is_registration', 'eq', True)) + + def check(retraction_state, embargo_state, approval_state, job_done): + if retraction_state is None: + ok_(reg.retraction is None) + else: + ok_(reg.retraction is not None) + assert_equal(reg.retraction.state, retraction_state) + + if embargo_state is None: + ok_(reg.embargo is None) + else: + ok_(reg.embargo is not None) + assert_equal(reg.embargo.state, embargo_state) + + if approval_state is None: + ok_(reg.registration_approval is None) + else: + ok_(reg.registration_approval is not None) + assert_equal(reg.registration_approval.state, approval_state) + + if job_done: + ok_(reg.archive_job.done) + else: + ok_(not reg.archive_job.done) + + return check + + def test_number_of_regfuncs(self): + assert_equal(len(REGFUNCS), 32) + + def test_number_of_regfunc_tests(self): + is_regfunc_test = lambda n: n.startswith('test_regfunc_') + regfunc_tests = filter(is_regfunc_test, self.__class__.__dict__.keys()) + assert_equal(len(regfunc_tests), len(REGFUNCS)) + + # no retraction + def test_regfunc_ac(self): + check = self.Check('approved_complete') + check(None, None, 'approved', True) + + def test_regfunc_ai(self): + check = self.Check('approved_incomplete') + check(None, None, 'approved', False) + + def test_regfunc_uc(self): + check = self.Check('unapproved_complete') + check(None, None, 'unapproved', True) + + def test_regfunc_ui(self): + check = self.Check('unapproved_incomplete') + check(None, None, 'unapproved', False) + + def test_regfunc_eac(self): + check = self.Check('embargoed_approved_complete') + check(None, 'approved', None, True) + + def test_regfunc_eai(self): + check = self.Check('embargoed_approved_incomplete') + check(None, 'approved', None, False) + + def test_regfunc_euc(self): + check = self.Check('embargoed_unapproved_complete') + check(None, 'unapproved', None, True) + + def test_regfunc_eui(self): + check = self.Check('embargoed_unapproved_incomplete') + check(None, 'unapproved', None, False) + + def test_regfunc_feac(self): + check = self.Check('formerly_embargoed_approved_complete') + check(None, 'completed', None, True) + + def test_regfunc_feai(self): + check = self.Check('formerly_embargoed_approved_incomplete') + check(None, 'completed', None, False) + + def test_regfunc_feuc(self): + check = self.Check('formerly_embargoed_unapproved_complete') + check(None, 'unapproved', None, True) + + def test_regfunc_feui(self): + check = self.Check('formerly_embargoed_unapproved_incomplete') + check(None, 'unapproved', None, False) + + # unapproved retraction + def test_regfunc_uroaac(self): + check = self.Check('unapproved_retraction_of_an_approved_complete') + check('unapproved', None, 'approved', True) + + def test_regfunc_uroaai(self): + check = self.Check('unapproved_retraction_of_an_approved_incomplete') + check('unapproved', None, 'approved', False) + + def test_regfunc_uroaeac(self): + check = self.Check('unapproved_retraction_of_an_embargoed_approved_complete') + check('unapproved', 'approved', None, True) + + def test_regfunc_uroaeai(self): + check = self.Check('unapproved_retraction_of_an_embargoed_approved_incomplete') + check('unapproved', 'approved', None, False) + + def test_regfunc_uroaeuc(self): + check = self.Check('unapproved_retraction_of_an_embargoed_unapproved_complete') + check('unapproved', 'unapproved', None, True) + + def test_regfunc_uroaeui(self): + check = self.Check('unapproved_retraction_of_an_embargoed_unapproved_incomplete') + check('unapproved', 'unapproved', None, False) + + def test_regfunc_uroafeac(self): + check = self.Check('unapproved_retraction_of_a_formerly_embargoed_approved_complete') + check('unapproved', 'completed', None, True) + + def test_regfunc_uroafeai(self): + check = self.Check('unapproved_retraction_of_a_formerly_embargoed_approved_incomplete') + check('unapproved', 'completed', None, False) + + def test_regfunc_uroafeuc(self): + check = self.Check('unapproved_retraction_of_a_formerly_embargoed_unapproved_complete') + check('unapproved', 'unapproved', None, True) + + def test_regfunc_uroafeui(self): + check = self.Check('unapproved_retraction_of_a_formerly_embargoed_unapproved_incomplete') + check('unapproved', 'unapproved', None, False) + + # approved retraction + def test_regfunc_aroaac(self): + check = self.Check('approved_retraction_of_an_approved_complete') + check('approved', None, 'approved', True) + + def test_regfunc_aroaai(self): + check = self.Check('approved_retraction_of_an_approved_incomplete') + check('approved', None, 'approved', False) + + def test_regfunc_aroaeac(self): + check = self.Check('approved_retraction_of_an_embargoed_approved_complete') + check('approved', 'rejected', None, True) + + def test_regfunc_aroaeai(self): + check = self.Check('approved_retraction_of_an_embargoed_approved_incomplete') + check('approved', 'rejected', None, False) + + def test_regfunc_aroaeuc(self): + check = self.Check('approved_retraction_of_an_embargoed_unapproved_complete') + check('approved', 'rejected', None, True) + + def test_regfunc_aroaeui(self): + check = self.Check('approved_retraction_of_an_embargoed_unapproved_incomplete') + check('approved', 'rejected', None, False) + + def test_regfunc_aroafeac(self): + check = self.Check('approved_retraction_of_a_formerly_embargoed_approved_complete') + check('approved', 'rejected', None, True) + + def test_regfunc_aroafeai(self): + check = self.Check('approved_retraction_of_a_formerly_embargoed_approved_incomplete') + check('approved', 'rejected', None, False) + + def test_regfunc_aroafeuc(self): + check = self.Check('approved_retraction_of_a_formerly_embargoed_unapproved_complete') + check('approved', 'rejected', None, True) + + def test_regfunc_aroafeui(self): + check = self.Check('approved_retraction_of_a_formerly_embargoed_unapproved_incomplete') + check('approved', 'rejected', None, False) diff --git a/tests/test_search/test_views.py b/tests/test_search/test_views.py index 9baad3e7710..a9b9ea0b66f 100644 --- a/tests/test_search/test_views.py +++ b/tests/test_search/test_views.py @@ -8,10 +8,18 @@ from website.util import api_url_for -class TestSearchViews(SearchTestCase): +class TestSearchPage(SearchTestCase): + + def test_search_projects(self): + factories.ProjectFactory(title='Foo Bar') + res = self.app.get('/search/', {'q': 'foo'}) + assert_equal(res.status_code, 200) + + +class TestUserSearchAPI(SearchTestCase): def setUp(self): - super(TestSearchViews, self).setUp() + super(TestUserSearchAPI, self).setUp() import website.search.search as search search.delete_all() @@ -22,7 +30,7 @@ def setUp(self): factories.UserFactory(fullname='Freddie Mercury{}'.format(i)) def tearDown(self): - super(TestSearchViews, self).tearDown() + super(TestUserSearchAPI, self).tearDown() import website.search.search as search search.delete_all() @@ -89,13 +97,8 @@ def test_search_pagination_smaller_pages_page_2(self): assert_equal(page, 2) assert_equal(pages, 3) - def test_search_projects(self): - url = '/search/' - res = self.app.get(url, {'q': self.project.title}) - assert_equal(res.status_code, 200) - -class TestODMTitleSearch(SearchTestCase): +class TestODMTitleSearchAPI(SearchTestCase): """ Docs from original method: :arg term: The substring of the title. :arg category: Category of the node. @@ -109,7 +112,7 @@ class TestODMTitleSearch(SearchTestCase): :return: a list of dictionaries of projects """ def setUp(self): - super(TestODMTitleSearch, self).setUp() + super(TestODMTitleSearchAPI, self).setUp() self.user = factories.AuthUserFactory() self.user_two = factories.AuthUserFactory()