diff --git a/.gitignore b/.gitignore index ea8b9bbd958d85ed5a4e2c2682831e6ef432e85b..6326e2c04a9cfa4dadc721b2c5a1d4c927463752 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ __pycache__ *.pyc settings*.cfg /data +.cache diff --git a/Makefile b/Makefile new file mode 100644 index 0000000000000000000000000000000000000000..26ba66bfa18bbe7e37784f0bf1d118a99c3110c3 --- /dev/null +++ b/Makefile @@ -0,0 +1,13 @@ +.PHONY: help db_test test + + +help: + @echo "Please use \`make <target>' where <target> is one of" + @echo " db_test to start the postgres database for test" + @echo " test to run the tests" + +db_test: + docker-compose -f docker-compose-test.yml up -d postgres_test + +test: + docker-compose -f docker-compose-test.yml run --rm web_test diff --git a/README.rst b/README.rst index f6f4d2b97c4442a0e282f56635181153cfec8bad..c0284028f7e567f3e2cc0b4805dd3b07469f99be 100644 --- a/README.rst +++ b/README.rst @@ -11,7 +11,7 @@ You can use docker for development: 1. Clone the repository -2. Create the database. Data will be stored under "./data" by default. +2. Create the database. Data will be stored under "./data/dev" by default. You can export the PGDATA_VOLUME variable to use another directory:: # Start only postgres so it has time to initialize @@ -30,6 +30,23 @@ You can use docker for development: Once the database has been created, you only need to run `docker-compose up` to start the app. +Testing +------- + +1. Create the database. Data will be stored under "./data/test":: + + # Start only postgres so it has time to initialize + $ docker-compose -f docker-compose-test.yml up -d postgres_test + or + $ make db_test + +2. Run the tests:: + + $ docker-compose -f docker-compose-test.yml run --rm web_test + or + $ make test + + Backup & restore ---------------- diff --git a/conftest.py b/conftest.py new file mode 100644 index 0000000000000000000000000000000000000000..b37c41aca8e15e92668f784a51ca1cd0dfc3fa8a --- /dev/null +++ b/conftest.py @@ -0,0 +1,90 @@ +# -*- coding: utf-8 -*- +""" +conftest +~~~~~~~~ + +This module defines the main configuration for the tests. + +:copyright: (c) 2017 European Spallation Source ERIC +:license: BSD 2-Clause, see LICENSE for more details. + +""" +import pytest +from flask_ldap3_login import AuthenticationResponse, AuthenticationResponseStatus +from app.factory import create_app +from app.extensions import db +from app.defaults import defaults + + +@pytest.fixture(scope='session') +def app(request): + config = { + 'TESTING': True, + 'WTF_CSRF_ENABLED': False, + 'SQLALCHEMY_DATABASE_URI': 'postgresql://ics:icstest@postgres_test/inventory_db_test', + 'INVENTORY_LDAP_GROUPS': { + 'admin': 'Inventory Admin', + 'create': 'Inventory User', + } + } + app = create_app(config=config) + with app.app_context(): + db.drop_all() + db.engine.execute('CREATE EXTENSION IF NOT EXISTS citext') + db.create_all() + for instance in defaults: + db.session.add(instance) + db.session.flush() + db.session.expunge_all() + db.session.commit() + yield app + db.session.remove() + db.drop_all() + + +@pytest.fixture +def client(request, app): + return app.test_client() + + +# TODO: make this work to clean the database between tests +# @pytest.fixture(autouse=True) +# def dbsession(request, monkeypatch): +# # Roll back at the end of every test +# request.addfinalizer(db.session.remove) +# # Prevent the session from closing (make it a no-op) and +# # committing (redirect to flush() instead) +# monkeypatch.setattr(db.session, 'commit', db.session.flush) +# monkeypatch.setattr(db.session, 'remove', lambda: None) + + +@pytest.fixture(autouse=True) +def no_ldap_connection(monkeypatch): + """Make sure we don't make any connection to the LDAP server""" + monkeypatch.delattr('flask_ldap3_login.LDAP3LoginManager._make_connection') + + +@pytest.fixture(autouse=True) +def patch_ldap_authenticate(monkeypatch): + + def authenticate(self, username, password): + response = AuthenticationResponse() + response.user_id = username + response.user_dn = f'cn={username},dc=esss,dc=lu,dc=se' + if username == 'admin' and password == 'adminpasswd': + response.status = AuthenticationResponseStatus.success + response.user_info = {'cn': 'Admin User', 'mail': 'admin@example.com'} + response.user_groups = [{'cn': 'Inventory Admin'}] + elif username == 'user_rw' and password == 'userrw': + response.status = AuthenticationResponseStatus.success + response.user_info = {'cn': 'User RW', 'mail': 'user_rw@example.com'} + response.user_groups = [{'cn': 'Inventory User'}] + elif username == 'user_ro' and password == 'userro': + response.status = AuthenticationResponseStatus.success + response.user_info = {'cn': 'User RO', 'mail': 'user_ro@example.com'} + response.user_groups = [{'cn': 'ESS Employees'}] + else: + response.status = AuthenticationResponseStatus.fail + return response + + monkeypatch.setattr('flask_ldap3_login.LDAP3LoginManager.authenticate', authenticate) diff --git a/docker-compose-test.yml b/docker-compose-test.yml new file mode 100644 index 0000000000000000000000000000000000000000..49c2c818a8f13e0ec7aaa03edb1b2be29d6e3057 --- /dev/null +++ b/docker-compose-test.yml @@ -0,0 +1,23 @@ +version: '2' +services: + web_test: + image: inventory + container_name: inventory_web_test + build: . + command: pytest -v + volumes: + - .:/app + depends_on: + - postgres_test + postgres_test: + image: postgres:9.6 + container_name: inventory_postgres_test + expose: + - "5432" + volumes: + - ./data/test:/var/lib/postgresql/data/pgdata + environment: + POSTGRES_USER: ics + POSTGRES_PASSWORD: icstest + POSTGRES_DB: inventory_db_test + PGDATA: /var/lib/postgresql/data/pgdata diff --git a/environment.yml b/environment.yml index c2a7f72c55836346414354d55eea37ec7e5d3a0e..c81b9419e00859378aecccf7f5409a966f4e557e 100644 --- a/environment.yml +++ b/environment.yml @@ -8,7 +8,7 @@ dependencies: - conda-forge::certifi=2017.4.17=py36_0 - conda-forge::click=6.7=py36_0 - conda-forge::colorama=0.3.9=py36_0 -- conda-forge::flask=0.12=py36_0 +- conda-forge::flask=0.12.2=py36_0 - conda-forge::flask-login=0.4.0=py36_0 - conda-forge::flask-sqlalchemy=2.2=py36_1 - conda-forge::flask-wtf=0.14.2=py36_0 @@ -28,6 +28,8 @@ dependencies: - conda-forge::pillow=4.2.1=py36_0 - conda-forge::pip=9.0.1=py36_0 - conda-forge::psycopg2=2.7.1=py36_0 +- conda-forge::py=1.4.34=py36_0 +- conda-forge::pytest=3.2.1=py36_0 - conda-forge::python=3.6.1=3 - conda-forge::python-dateutil=2.6.0=py36_0 - conda-forge::python-editor=1.0.3=py36_0 diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000000000000000000000000000000000000..530653886eb8ac84983471333471aecd1485efdf --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,129 @@ +# -*- coding: utf-8 -*- +""" +tests.test_api +~~~~~~~~~~~~~~ + +This module defines API tests. + +:copyright: (c) 2017 European Spallation Source ERIC +:license: BSD 2-Clause, see LICENSE for more details. + +""" +import json +import pytest + + +def get(client, url, token=None): + response = client.get( + url, + headers={'Content-Type': 'application/json', + 'Authorization': f'Bearer {token}'}, + ) + if response.headers['Content-Type'] == 'application/json': + response.json = json.loads(response.data) + return response + + +def post(client, url, data, token=None): + headers = {'Content-Type': 'application/json'} + if token is not None: + headers['Authorization'] = f'Bearer {token}' + response = client.post(url, data=json.dumps(data), headers=headers) + if response.headers['Content-Type'] == 'application/json': + response.json = json.loads(response.data) + return response + + +def login(client, username, password): + data = { + 'username': username, + 'password': password + } + return post(client, '/api/login', data) + + +def get_token(client, username, password): + response = login(client, username, password) + return response.json['access_token'] + + +@pytest.fixture() +def readonly_token(client): + return get_token(client, 'user_ro', 'userro') + + +@pytest.fixture() +def user_token(client): + return get_token(client, 'user_rw', 'userrw') + + +@pytest.fixture() +def admin_token(client): + return get_token(client, 'admin', 'adminpasswd') + + +def check_response_message(response, msg, status_code=400): + assert response.status_code == status_code + try: + data = response.json + except AttributeError: + data = json.loads(response.data) + try: + message = data['message'] + except KeyError: + # flask-jwt-extended is using "msg" instead of "message" + # in its default callbacks + message = data['msg'] + assert message == msg + + +def check_names(response, names): + response_names = set(item['name'] for item in response.json) + assert set(names) == response_names + + +def test_login(client): + response = client.post('/api/login') + check_response_message(response, 'Body should be a JSON object') + response = post(client, '/api/login', data={'username': 'foo', 'passwd': ''}) + check_response_message(response, 'Missing mandatory field (username or password)', 422) + response = login(client, 'foo', 'invalid') + check_response_message(response, 'Invalid credentials', 401) + response = login(client, 'user_ro', 'userro') + assert response.status_code == 200 + assert 'access_token' in response.json + + +def test_get_locations(client, readonly_token): + response = client.get('/api/locations') + check_response_message(response, 'Missing Authorization Header', 401) + response = get(client, '/api/locations', 'xxxxxxxxx') + check_response_message(response, 'Not enough segments', 422) + response = get(client, '/api/locations', readonly_token) + check_names(response, ('ICS lab', 'Utgård', 'Site', 'ESS')) + + +def test_create_locations_fail(client, readonly_token): + response = client.post('/api/locations') + check_response_message(response, 'Missing Authorization Header', 401) + response = post(client, '/api/locations', data={}, token='xxxxxxxxx') + check_response_message(response, 'Not enough segments', 422) + response = post(client, '/api/locations', data={}, token=readonly_token) + check_response_message(response, "User doesn't have the required group", 403) + + +def test_create_locations(client, user_token): + response = post(client, '/api/locations', data={}, token=user_token) + check_response_message(response, "Missing mandatory field 'name'", 422) + data = {'name': 'Foo'} + response = post(client, '/api/locations', data=data, token=user_token) + assert response.status_code == 201 + assert response.json == {'id': 5, 'name': 'Foo'} + response = post(client, '/api/locations', data=data, token=user_token) + check_response_message(response, 'IntegrityError', 409) + response = post(client, '/api/locations', data={'name': 'foo'}, token=user_token) + check_response_message(response, 'IntegrityError', 409) + response = post(client, '/api/locations', data={'name': 'FOO'}, token=user_token) + check_response_message(response, 'IntegrityError', 409) + response = get(client, '/api/locations', user_token) + check_names(response, ('ICS lab', 'Utgård', 'Site', 'ESS', 'Foo')) diff --git a/tests/test_basic.py b/tests/test_basic.py new file mode 100644 index 0000000000000000000000000000000000000000..ee594471b7af260bab170f6831d96e9a34d0992b --- /dev/null +++ b/tests/test_basic.py @@ -0,0 +1,45 @@ +# -*- coding: utf-8 -*- +""" +tests.test_basic +~~~~~~~~~~~~~~~~ + +This module defines basic web tests. + +:copyright: (c) 2017 European Spallation Source ERIC +:license: BSD 2-Clause, see LICENSE for more details. + +""" + + +def login(client, username, password): + data = { + 'username': username, + 'password': password + } + return client.post('/login', data=data, follow_redirects=True) + + +def logout(client): + return client.get('/logout', follow_redirects=True) + + +def test_login_logout(client): + response = login(client, 'unknown', 'invalid') + assert b'<title>Login</title>' in response.data + response = login(client, 'user_rw', 'invalid') + assert b'<title>Login</title>' in response.data + response = login(client, 'user_rw', 'userrw') + assert b'Welcome to the ICS Inventory!' in response.data + assert b'User RW' in response.data + response = logout(client) + assert b'<title>Login</title>' in response.data + + +def test_index(client): + response = client.get('/') + assert response.status_code == 302 + assert '/login' in response.headers['Location'] + login(client, 'user_ro', 'userro') + response = client.get('/') + assert b'Welcome to the ICS Inventory!' in response.data + assert b'User RO' in response.data