diff --git a/.gitignore b/.gitignore index 37bb5a94..58193f90 100644 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,6 @@ config/dev.ini # coverage output .coverage + +# selenium testing +browserstacklocal diff --git a/Pipfile b/Pipfile index 3df91220..bc5ed901 100644 --- a/Pipfile +++ b/Pipfile @@ -34,6 +34,7 @@ factory-boy = "*" pytest-flask = "*" pytest-env = "*" pytest-cov = "*" +selenium = "*" [requires] python_version = "3.6.6" diff --git a/Pipfile.lock b/Pipfile.lock index 474b822c..bf863dc6 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "c95e31f2315762631fcae7253275e46cbca22bbfd4467cf454e74163743c6ae7" + "sha256": "1e5e6a695229166aaa5e6c427fed07a903766e9b3d24981a19cc8e5ada8db978" }, "pipfile-spec": 6, "requires": { @@ -440,10 +440,10 @@ }, "colorama": { "hashes": [ - "sha256:463f8483208e921368c9f306094eb6f725c6ca42b0f97e313cb5d5512459feda", - "sha256:48eb22f4f8461b1df5734a074b57042430fb06e1d61bd1e11b078c0fe6d7a1f1" + "sha256:a3d89af5db9e9806a779a50296b5fdb466e281147c2c235e8225ecc6dbf7bbf3", + "sha256:c9b54bebe91a6a803e0772c8561d53f2926bfeb17cd141fbabcb08424086595c" ], - "version": "==0.3.9" + "version": "==0.4.0" }, "coverage": { "hashes": [ @@ -508,10 +508,10 @@ }, "faker": { "hashes": [ - "sha256:74b32991f8e08e4f2f84858b919eca253becfaec4b3fa5fcff7fdbd70d5d78b1", - "sha256:c2ce42dd8361e6d392276006d757532562463c8642b1086709584200b7fd7758" + "sha256:2621643b80a10b91999925cfd20f64d2b36f20bf22136bbdc749bb57d6ffe124", + "sha256:5ed822d31bd2d6edf10944d176d30dc9c886afdd381eefb7ba8b7aad86171646" ], - "version": "==0.9.1" + "version": "==0.9.2" }, "flask": { "hashes": [ @@ -523,10 +523,10 @@ }, "gitdb2": { "hashes": [ - "sha256:87783b7f4a8f6b71c7fe81d32179b3c8781c1a7d6fa0c69bff2f315b00aff4f8", - "sha256:bb4c85b8a58531c51373c89f92163b92f30f81369605a67cd52d1fc21246c044" + "sha256:83361131a1836661a155172932a13c08bda2db3674e4caa32368aa6eb02f38c2", + "sha256:e3a0141c5f2a3f635c7209d56c496ebe1ad35da82fe4d3ec4aaa36278d70648a" ], - "version": "==2.0.4" + "version": "==2.0.5" }, "gitpython": { "hashes": [ @@ -684,11 +684,11 @@ }, "prompt-toolkit": { "hashes": [ - "sha256:5eff0c9fd652384ecfe730bbcdf3658868725c6928fbf608d9338834d7a974b6", - "sha256:81da9ecf6ca6806a549697529af8ec3ac5b739c13ac14607218e650db1b53131", - "sha256:c67c1c264d8a0d9e1070e9272bacee00f76c81daab7bc4bf09ff991bd1e224a7" + "sha256:646b3401b3b0bb7752100bc9b7aeecb36cb09cdfc63652b5856708b5ba8db7da", + "sha256:82766ffd7397e6661465e20bd1390db0781ca4fbbab4cf6c2578cacdd8b09754", + "sha256:ccad8461b5d912782726af17122113e196085e7e11d57cf0c9b982bf1ab2c7be" ], - "version": "==2.0.5" + "version": "==2.0.6" }, "ptyprocess": { "hashes": [ @@ -699,10 +699,10 @@ }, "py": { "hashes": [ - "sha256:06a30435d058473046be836d3fc4f27167fd84c45b99704f2fb5509ef61f9af1", - "sha256:50402e9d1c9005d759426988a492e0edaadb7f4e68bcddfea586bc7432d009c6" + "sha256:bf92637198836372b520efcba9e020c330123be8ce527e535d185ed4b6f45694", + "sha256:e76826342cefe3c3d5f7e8ee4316b80d1dd8a300781612ddbc765c17ba25a6c6" ], - "version": "==1.6.0" + "version": "==1.7.0" }, "pygments": { "hashes": [ @@ -778,6 +778,14 @@ ], "version": "==4.2b4" }, + "selenium": { + "hashes": [ + "sha256:ab192cd046164c40fabcf44b47c66c8b12495142f4a69dcc55ea6eeef096e614", + "sha256:fdb6b1143d8899e8a32e358ad05bf5d89a480dbac359dbbd341592aa8696dcd1" + ], + "index": "pypi", + "version": "==3.14.1" + }, "simplegeneric": { "hashes": [ "sha256:dc972e06094b9af5b855b3df4a646395e43d1c9d0d39ed345b7393560d0b9173" @@ -793,10 +801,10 @@ }, "smmap2": { "hashes": [ - "sha256:0dd53d991af487f9b22774fa89451358da3607c02b9b886a54736c6a313ece0b", - "sha256:dc216005e529d57007ace27048eb336dcecb7fc413cfb3b2f402bb25972b69c6" + "sha256:0555a7bf4df71d1ef4218e4807bbf9b201f910174e6e08af2e138d4e517b4dde", + "sha256:29a9ffa0497e7f2be94ca0ed1ca1aa3cd4cf25a1f6b4f5f87f74b46ed91d609a" ], - "version": "==2.0.4" + "version": "==2.0.5" }, "stevedore": { "hashes": [ @@ -856,6 +864,13 @@ "markers": "python_version < '3.7' and implementation_name == 'cpython'", "version": "==1.1.0" }, + "urllib3": { + "hashes": [ + "sha256:a68ac5e15e76e7e5dd2b8f94007233e01effe3e50e8daddf69acfd81cb686baf", + "sha256:b5725a0bd4ba422ab0e66e89e030c806576753ea3ee08554382c14e685d117b5" + ], + "version": "==1.23" + }, "watchdog": { "hashes": [ "sha256:965f658d0732de3188211932aeb0bb457587f04f63ab4c1e33eab878e9de961d" diff --git a/README.md b/README.md index bd646be0..a0c82d63 100644 --- a/README.md +++ b/README.md @@ -131,6 +131,31 @@ To re-run tests each time a file is changed: pipenv run ptw +### Selenium Tests + +Selenium tests rely on BrowserStack. In order to run the Selenium tests +locally, you need BrowserStack credentials. The user email and key can +be found on the account settings page. To run the selenium tests: + +``` +BROWSERSTACK_TOKEN= BROWSERSTACK_EMAIL= ./script/selenium_test +``` + +The selenium tests are in `tests/acceptance`. This directory is ignored by +pytest for normal test runs. + +The `selenium_test` script manages the setup of a separate database and +launching the BrowserStackLocal client. If you already have the client running +locally, you can run the selenium tests with: + +``` +BROWSERSTACK_TOKEN= BROWSERSTACK_EMAIL= pipenv run pytest tests/acceptance +``` + +The BrowserStack email is the one associated with the account. The token is +available in the BrowserStack profile information page. Go to the dashboard, +then "Account" > "Settings", then the token is under "Local Testing". + ## Notes Jinja templates are like mustache templates -- add the diff --git a/atst/routes/errors.py b/atst/routes/errors.py index 40a00c26..9d70578c 100644 --- a/atst/routes/errors.py +++ b/atst/routes/errors.py @@ -17,4 +17,13 @@ def make_error_pages(app): app.logger.error(e.message) return render_template("error.html", message="Log in Failed"), 401 + @app.errorhandler(Exception) + # pylint: disable=unused-variable + def exception(e): + app.logger.error(e.message) + return ( + render_template("error.html", message="An Unexpected Error Occurred"), + 500, + ) + return app diff --git a/config/selenium.ini b/config/selenium.ini new file mode 100644 index 00000000..054b8ed3 --- /dev/null +++ b/config/selenium.ini @@ -0,0 +1,3 @@ +[default] +PGDATABASE = atat_selenium +CRL_DIRECTORY = tests/fixtures/crl diff --git a/pytest.ini b/pytest.ini index 91d52803..30767796 100644 --- a/pytest.ini +++ b/pytest.ini @@ -2,4 +2,4 @@ norecursedirs = .venv .git node_modules env = D:FLASK_ENV=test -addopts = --cov=atst --cov-report term --cov-fail-under 90 +addopts = --ignore=tests/acceptance/ --cov=atst --cov-report term --cov-fail-under 90 diff --git a/script/selenium_test b/script/selenium_test new file mode 100755 index 00000000..b233c069 --- /dev/null +++ b/script/selenium_test @@ -0,0 +1,53 @@ +#!/bin/bash + +# script/selenium_test: Run selenium tests via BrowserStack + +source "$(dirname "${0}")"/../script/include/global_header.inc.sh + +export FLASK_ENV=selenium + +# create upload directory for app +mkdir uploads | true + +# Fetch postgres settings and set them as ENV vars +source ./script/get_db_settings + +if [ -n "${PGDATABASE}" ]; then + echo "Resetting database ${PGDATABASE}..." + # Reset the db + reset_db "${PGDATABASE}" +else + echo "ERROR: RESET_DB is set, but PGDATABASE is not!" + echo "Skipping database reset..." +fi + +BSL_FILE=BrowserStackLocal +if [[ `uname` == "Darwin" ]]; then + BSL_DOWNLOAD="https://www.browserstack.com/browserstack-local/BrowserStackLocal-darwin-x64.zip" +else + BSL_DOWNLOAD="https://www.browserstack.com/browserstack-local/BrowserStackLocal-linux-x64.zip" +fi + +# Fetch BrowserStackLocal script +if [ -e "${BSL_FILE}" ]; then + echo "BrowserStack file already exists" +else + echo "downloading BrowserStack file" + curl $BSL_DOWNLOAD --output $BSL_FILE.zip + unzip $BSL_FILE.zip -d . + rm $BSL_FILE.zip + chmod u+x $BSL_FILE +fi + +# run BrowserStackLocal in the background +echo "starting BrowserStack local client..." +./$BSL_FILE --key $BROWSERSTACK_TOKEN & +BSL_ID=$! +trap "kill $BSL_ID" SIGTERM SIGINT EXIT + +# run example selenium script that fetches the home page +echo "running selenium tests" +pipenv run pytest tests/acceptance -s --no-cov + +# kill BrowserStackLocal +kill $BSL_ID diff --git a/tests/acceptance/__init__.py b/tests/acceptance/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/acceptance/browsers.py b/tests/acceptance/browsers.py new file mode 100644 index 00000000..f9c30fcd --- /dev/null +++ b/tests/acceptance/browsers.py @@ -0,0 +1,18 @@ +BROWSERSTACK_CONFIG = { + "win7_ie10": { + "browser": "IE", + "browser_version": "10.0", + "os": "Windows", + "os_version": "7", + "resolution": "1024x768", + "browserstack.local": True, + }, + "win10_chrome62": { + "browser": "Chrome", + "browser_version": "62.0", + "os": "Windows", + "os_version": "10", + "resolution": "1024x768", + "browserstack.local": True, + }, +} diff --git a/tests/acceptance/conftest.py b/tests/acceptance/conftest.py new file mode 100644 index 00000000..29aa2953 --- /dev/null +++ b/tests/acceptance/conftest.py @@ -0,0 +1,65 @@ +import os +import pytest +import logging +from collections import Mapping +from selenium import webdriver +from selenium.webdriver.common.keys import Keys +from selenium.webdriver.common.desired_capabilities import DesiredCapabilities + +from .browsers import BROWSERSTACK_CONFIG + + +@pytest.fixture(scope="function", autouse=True) +def session(db, request): + """ + Override base test session + """ + pass + + +class DriverCollection(Mapping): + """ + Allows access to drivers with dictionary syntax. Keeps track of which ones + have already been initialized. Allows teardown of all existing drivers. + """ + + def __init__(self): + self._drivers = {} + + def __iter__(self): + return iter(self._drivers) + + def __len__(self): + return len(self._drivers) + + def __getitem__(self, name): + if name in self._drivers: + return self._drivers[name] + + elif name in BROWSERSTACK_CONFIG: + self._drivers[name] = self._build_driver(name) + return self._drivers[name] + + else: + raise AttributeError("Driver {} not found".format(name)) + + def _build_driver(self, config_key): + return webdriver.Remote( + command_executor="http://{}:{}@hub.browserstack.com:80/wd/hub".format( + os.getenv("BROWSERSTACK_EMAIL"), os.getenv("BROWSERSTACK_TOKEN") + ), + desired_capabilities=BROWSERSTACK_CONFIG.get(config_key), + ) + + def teardown(self): + for driver in self._drivers.values(): + driver.quit() + + +@pytest.fixture(scope="session") +def drivers(): + driver_collection = DriverCollection() + + yield driver_collection + + driver_collection.teardown() diff --git a/tests/acceptance/test_basic.py b/tests/acceptance/test_basic.py new file mode 100644 index 00000000..a130d1fc --- /dev/null +++ b/tests/acceptance/test_basic.py @@ -0,0 +1,50 @@ +import pytest +import requests +from flask import url_for +from urllib.parse import urljoin +from .browsers import BROWSERSTACK_CONFIG +from atst.domain.users import Users +import atst.domain.exceptions as exceptions +from atst.routes.dev import _DEV_USERS as DEV_USERS +from tests.test_auth import _login + +import cryptography.x509 as x509 +from cryptography.hazmat.backends import default_backend + + +USER_CERT = "ssl/client-certs/atat.mil.crt" + + +@pytest.mark.parametrize("browser_type", BROWSERSTACK_CONFIG.keys()) +@pytest.mark.usefixtures("live_server") +def test_can_get_title(browser_type, app, drivers): + driver = drivers[browser_type] + driver.get(url_for("atst.root", _external=True)) + assert "JEDI" in driver.title + + +def _get_common_name(cert_path): + with open(USER_CERT, "rb") as cert_file: + cert = x509.load_pem_x509_certificate(cert_file.read(), default_backend()) + common_names = cert.subject.get_attributes_for_oid(x509.NameOID.COMMON_NAME) + return common_names[0].value + + +@pytest.fixture(scope="module") +def valid_user_from_cert(): + cn = _get_common_name(USER_CERT) + cn_parts = cn.split(".") + user_info = { + "last_name": cn_parts[0], + "first_name": cn_parts[1], + "dod_id": cn_parts[-1], + "atat_role_name": "developer", + } + return Users.get_or_create_by_dod_id(**user_info) + + +@pytest.mark.usefixtures("live_server") +def test_login(drivers, client, app, valid_user_from_cert): + driver = drivers["win7_ie10"] + driver.get(url_for("dev.login_dev", _external=True)) + assert "Sign in" not in driver.title