diff --git a/.travis.yml b/.travis.yml index 94b83396..5a0734c2 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,14 +1,17 @@ sudo: required -language: python -python: "3.6" +language: minimal services: - docker git: submodules: false env: global: - - TESTER_IMAGE_NAME=atst-tester - PROD_IMAGE_NAME=atst-prod + - TESTER_IMAGE1_NAME=atst-tester-nocrls + - TESTER_IMAGE2_NAME=atst-tester +cache: + directories: + - crl before_install: # Use sed to replace the SSH URL with the public URL @@ -17,16 +20,22 @@ before_install: - git submodule update --init --recursive before_script: + - rm -rf ./crl/* - docker run -d --name postgres96 postgres:9.6-alpine - docker run -d --name redis redis:4.0.10-alpine - docker run --link postgres96:postgres96 --link redis:redis waisbrot/wait - export postgres_ip="$(docker inspect -f "{{ .NetworkSettings.IPAddress }}" postgres96)" - export redis_ip="$(docker inspect -f "{{ .NetworkSettings.IPAddress }}" redis)" - docker login -u $ATAT_DOCKER_REGISTRY_USERNAME -p $ATAT_DOCKER_REGISTRY_PASSWORD $ATAT_DOCKER_REGISTRY_URL - - docker build --tag "${TESTER_IMAGE_NAME}" --add-host "postgreshost:${postgres_ip}" --add-host "redishost:${redis_ip}" . -f deploy/docker/tester/Dockerfile + - docker build --tag "${TESTER_IMAGE1_NAME}" --add-host "postgreshost:${postgres_ip}" --add-host "redishost:${redis_ip}" . -f deploy/docker/tester/Dockerfile script: - - docker run --add-host "postgreshost:${postgres_ip}" --add-host "redishost:${redis_ip}" "${TESTER_IMAGE_NAME}" + - docker run -d --entrypoint='/bin/sh' -t --name current-atst-tester "${TESTER_IMAGE1_NAME}" + - docker container exec -t current-atst-tester script/sync-crls + - docker commit current-atst-tester "${TESTER_IMAGE2_NAME}" + - docker cp current-atst-tester:/opt/atat/atst/crl/. ./crl/ + - docker container stop current-atst-tester + - docker run --add-host "postgreshost:${postgres_ip}" --add-host "redishost:${redis_ip}" "${TESTER_IMAGE2_NAME}" before_deploy: - docker build --tag "${PROD_IMAGE_NAME}" . -f deploy/docker/prod/Dockerfile diff --git a/atst/app.py b/atst/app.py index d095a8bf..06f3c475 100644 --- a/atst/app.py +++ b/atst/app.py @@ -16,7 +16,7 @@ from atst.routes.workspaces import bp as workspace_routes from atst.routes.requests import requests_bp from atst.routes.dev import bp as dev_routes from atst.routes.errors import make_error_pages -from atst.domain.authnid.crl.validator import Validator +from atst.domain.authnid.crl import Validator from atst.domain.auth import apply_authentication @@ -68,7 +68,7 @@ def make_flask_callbacks(app): ) g.dev = os.getenv("FLASK_ENV", "dev") == "dev" g.matchesPath = lambda href: re.match("^" + href, request.path) - g.modalOpen = request.args.get("modal", False) + g.modal = request.args.get("modal", None) g.current_user = { "id": "cce17030-4109-4719-b958-ed109dbb87c8", "first_name": "Amanda", @@ -142,8 +142,6 @@ def make_crl_validator(app): for filename in pathlib.Path(app.config["CRL_DIRECTORY"]).glob("*"): crl_locations.append(filename.absolute()) app.crl_validator = Validator( - roots=[app.config["CA_CHAIN"]], crl_locations=crl_locations + roots=[app.config["CA_CHAIN"]], crl_locations=crl_locations, logger=app.logger ) - for e in app.crl_validator.errors: - app.logger.error(e) diff --git a/atst/domain/authnid/__init__.py b/atst/domain/authnid/__init__.py index e69de29b..80d645b8 100644 --- a/atst/domain/authnid/__init__.py +++ b/atst/domain/authnid/__init__.py @@ -0,0 +1,62 @@ +from atst.domain.exceptions import UnauthenticatedError, NotFoundError +from atst.domain.users import Users +from .utils import parse_sdn, email_from_certificate + + +class AuthenticationContext(): + + def __init__(self, crl_validator, auth_status, sdn, cert): + if None in locals().values(): + raise UnauthenticatedError( + "Missing required authentication context components" + ) + + self.crl_validator = crl_validator + self.auth_status = auth_status + self.sdn = sdn + self.cert = cert.encode() + self._parsed_sdn = None + + def authenticate(self): + if not self.auth_status == "SUCCESS": + raise UnauthenticatedError("SSL/TLS client authentication failed") + + elif not self._crl_check(): + raise UnauthenticatedError("Client certificate failed CRL check") + + return True + + def get_user(self): + try: + return Users.get_by_dod_id(self.parsed_sdn["dod_id"]) + + except NotFoundError: + email = self._get_user_email() + return Users.create(**{"email": email, **self.parsed_sdn}) + + def _get_user_email(self): + try: + return email_from_certificate(self.cert) + + # this just means it is not an email certificate; we might choose to + # log in that case + except ValueError: + return None + + def _crl_check(self): + if self.cert: + result = self.crl_validator.validate(self.cert) + return result + + else: + return False + + @property + def parsed_sdn(self): + if not self._parsed_sdn: + try: + self._parsed_sdn = parse_sdn(self.sdn) + except ValueError as exc: + raise UnauthenticatedError(str(exc)) + + return self._parsed_sdn diff --git a/atst/domain/authnid/crl/validator.py b/atst/domain/authnid/crl/__init__.py similarity index 94% rename from atst/domain/authnid/crl/validator.py rename to atst/domain/authnid/crl/__init__.py index 409a8bf7..68358e37 100644 --- a/atst/domain/authnid/crl/validator.py +++ b/atst/domain/authnid/crl/__init__.py @@ -20,11 +20,11 @@ class Validator: re.DOTALL, ) - def __init__(self, crl_locations=[], roots=[], base_store=crypto.X509Store): - self.errors = [] + def __init__(self, crl_locations=[], roots=[], base_store=crypto.X509Store, logger=None): self.crl_locations = crl_locations self.roots = roots self.base_store = base_store + self.logger = logger self._reset() def _reset(self): @@ -34,12 +34,16 @@ class Validator: self._add_roots(self.roots) self.store.set_flags(crypto.X509StoreFlags.CRL_CHECK) + def log_error(self, message): + if self.logger: + self.logger.error(message) + def _add_crls(self, locations): for filename in locations: try: self._add_crl(filename) except crypto.Error as err: - self.errors.append( + self.log_error( "CRL could not be parsed. Filename: {}, Error: {}, args: {}".format( filename, type(err), err.args ) @@ -116,7 +120,7 @@ class Validator: return True except crypto.X509StoreContextError as err: - self.errors.append( + self.log_error( "Certificate revoked or errored. Error: {}. Args: {}".format( type(err), err.args ) diff --git a/atst/domain/authnid/crl/util.py b/atst/domain/authnid/crl/util.py index 13e8106d..7e9948e1 100644 --- a/atst/domain/authnid/crl/util.py +++ b/atst/domain/authnid/crl/util.py @@ -56,7 +56,6 @@ def refresh_crls(out_dir, logger=None): if __name__ == "__main__": import sys - import datetime import logging logging.basicConfig( diff --git a/atst/domain/authnid/utils.py b/atst/domain/authnid/utils.py index 763820ce..58f04ce4 100644 --- a/atst/domain/authnid/utils.py +++ b/atst/domain/authnid/utils.py @@ -1,7 +1,9 @@ import re +import cryptography.x509 as x509 +from cryptography.hazmat.backends import default_backend + -# TODO: our sample SDN does not have an email address def parse_sdn(sdn): try: parts = sdn.split(",") @@ -9,5 +11,21 @@ def parse_sdn(sdn): cn = cn_string.split("=")[-1] info = cn.split(".") return {"last_name": info[0], "first_name": info[1], "dod_id": info[-1]} + except (IndexError, AttributeError): raise ValueError("'{}' is not a valid SDN".format(sdn)) + + +def email_from_certificate(cert_file): + cert = x509.load_pem_x509_certificate(cert_file, default_backend()) + try: + ext = cert.extensions.get_extension_for_class(x509.SubjectAlternativeName) + email = ext.value.get_values_for_type(x509.RFC822Name) + if email: + return email[0] + + else: + raise ValueError("No email available for certificate with serial {}".format(cert.serial_number)) + + except x509.extensions.ExtensionNotFound: + raise ValueError("No subjectAltName available for certificate with serial {}".format(cert.serial_number)) diff --git a/atst/domain/date.py b/atst/domain/date.py new file mode 100644 index 00000000..4a131671 --- /dev/null +++ b/atst/domain/date.py @@ -0,0 +1,12 @@ +import pendulum + + +def parse_date(data): + date_formats = ["YYYY-MM-DD", "MM/DD/YYYY"] + for _format in date_formats: + try: + return pendulum.from_format(data, _format).date() + except (ValueError, pendulum.parsing.exceptions.ParserError): + pass + + raise ValueError("Unable to parse string {}".format(data)) diff --git a/atst/domain/requests.py b/atst/domain/requests.py index 3986a849..23290085 100644 --- a/atst/domain/requests.py +++ b/atst/domain/requests.py @@ -150,3 +150,7 @@ class Requests(object): @classmethod def is_pending_financial_verification(cls, request): return request.status == RequestStatus.PENDING_FINANCIAL_VERIFICATION + + @classmethod + def is_pending_ccpo_approval(cls, request): + return request.status == RequestStatus.PENDING_CCPO_APPROVAL diff --git a/atst/forms/fields.py b/atst/forms/fields.py index 00e53529..2c06154a 100644 --- a/atst/forms/fields.py +++ b/atst/forms/fields.py @@ -1,20 +1,14 @@ from wtforms.fields.html5 import DateField from wtforms.fields import Field from wtforms.widgets import TextArea -import pendulum + +from atst.domain.date import parse_date class DateField(DateField): def _value(self): if self.data: - date_formats = ["YYYY-MM-DD", "MM/DD/YYYY"] - for _format in date_formats: - try: - return pendulum.from_format(self.data, _format).date() - except (ValueError, pendulum.parsing.exceptions.ParserError): - pass - - raise ValueError("Unable to parse string {}".format(self.data)) + return parse_date(self.data) else: return None diff --git a/atst/forms/financial.py b/atst/forms/financial.py index 674eace4..994e84bf 100644 --- a/atst/forms/financial.py +++ b/atst/forms/financial.py @@ -1,7 +1,6 @@ import re from wtforms.fields.html5 import EmailField from wtforms.fields import StringField, SelectField -from wtforms.form import Form from wtforms.validators import Required, Email from atst.domain.exceptions import NotFoundError @@ -41,7 +40,7 @@ def suggest_pe_id(pe_id): def validate_pe_id(field, existing_request): try: - pe_number = PENumbers.get(field.data) + PENumbers.get(field.data) except NotFoundError: suggestion = suggest_pe_id(field.data) error_str = ( diff --git a/atst/forms/org.py b/atst/forms/org.py index b8220228..f45c8a15 100644 --- a/atst/forms/org.py +++ b/atst/forms/org.py @@ -12,9 +12,11 @@ class OrgForm(ValidatedForm): lname_request = StringField("Last Name", validators=[Required(), Alphabet()]) - email_request = EmailField("Email Address", validators=[Required(), Email()]) + email_request = EmailField("E-mail Address", validators=[Required(), Email()]) - phone_number = TelField("Phone Number", validators=[Required(), PhoneNumber()]) + phone_number = TelField("Phone Number", + description='Enter a 10-digit phone number', + validators=[Required(), PhoneNumber()]) service_branch = StringField( "Service Branch or Agency", diff --git a/atst/forms/poc.py b/atst/forms/poc.py index 66b77064..c595097b 100644 --- a/atst/forms/poc.py +++ b/atst/forms/poc.py @@ -2,7 +2,7 @@ from wtforms.fields import StringField from wtforms.fields.html5 import EmailField from wtforms.validators import Required, Email, Length from .forms import ValidatedForm -from .validators import IsNumber, Alphabet +from .validators import IsNumber class POCForm(ValidatedForm): diff --git a/atst/forms/request.py b/atst/forms/request.py index c6b7c8c5..1c4c7420 100644 --- a/atst/forms/request.py +++ b/atst/forms/request.py @@ -1,10 +1,7 @@ from wtforms.fields.html5 import IntegerField -from wtforms.fields import RadioField, StringField, TextAreaField, SelectField -from wtforms.validators import NumberRange, InputRequired +from wtforms.fields import RadioField, TextAreaField, SelectField from .fields import DateField from .forms import ValidatedForm -from .validators import DateRange -import pendulum class RequestForm(ValidatedForm): diff --git a/atst/forms/validators.py b/atst/forms/validators.py index 3937dabb..241a5401 100644 --- a/atst/forms/validators.py +++ b/atst/forms/validators.py @@ -2,18 +2,19 @@ import re from wtforms.validators import ValidationError import pendulum +from atst.domain.date import parse_date + def DateRange(lower_bound=None, upper_bound=None, message=None): def _date_range(form, field): now = pendulum.now().date() + date = parse_date(field.data) if lower_bound is not None: - date = pendulum.parse(field.data).date() if (now - lower_bound) > date: raise ValidationError(message) if upper_bound is not None: - date = pendulum.parse(field.data).date() if (now + upper_bound) < date: raise ValidationError(message) diff --git a/atst/routes/__init__.py b/atst/routes/__init__.py index 965b4b37..f8c4199c 100644 --- a/atst/routes/__init__.py +++ b/atst/routes/__init__.py @@ -4,8 +4,8 @@ import pendulum from atst.domain.requests import Requests from atst.domain.users import Users -from atst.domain.authnid.utils import parse_sdn -from atst.domain.exceptions import UnauthenticatedError +from atst.domain.authnid import AuthenticationContext + bp = Blueprint("atst", __name__) @@ -30,28 +30,29 @@ def catch_all(path): return render_template("{}.html".format(path)) -# TODO: this should be partly consolidated into a domain function that takes -# all the necessary UWSGI environment values as args and either returns a user -# or raises the UnauthenticatedError +def _make_authentication_context(): + return AuthenticationContext( + crl_validator=app.crl_validator, + auth_status=request.environ.get("HTTP_X_SSL_CLIENT_VERIFY"), + sdn=request.environ.get("HTTP_X_SSL_CLIENT_S_DN"), + cert=request.environ.get("HTTP_X_SSL_CLIENT_CERT") + ) + + @bp.route('/login-redirect') def login_redirect(): - if request.environ.get('HTTP_X_SSL_CLIENT_VERIFY') == 'SUCCESS' and _is_valid_certificate(request): - sdn = request.environ.get('HTTP_X_SSL_CLIENT_S_DN') - sdn_parts = parse_sdn(sdn) - user = Users.get_or_create_by_dod_id(**sdn_parts) - session["user_id"] = user.id + auth_context = _make_authentication_context() + auth_context.authenticate() + user = auth_context.get_user() + session["user_id"] = user.id - return redirect(url_for("atst.home")) - else: - raise UnauthenticatedError() + return redirect(url_for("atst.home")) def _is_valid_certificate(request): cert = request.environ.get('HTTP_X_SSL_CLIENT_CERT') if cert: result = app.crl_validator.validate(cert.encode()) - if not result: - app.logger.info(app.crl_validator.errors[-1]) return result else: return False diff --git a/atst/routes/requests/index.py b/atst/routes/requests/index.py index 5e9d26ca..5b7984ee 100644 --- a/atst/routes/requests/index.py +++ b/atst/routes/requests/index.py @@ -9,8 +9,10 @@ def map_request(request): time_created = pendulum.instance(request.time_created) is_new = time_created.add(days=1) > pendulum.now() app_count = request.body.get("details_of_use", {}).get("num_software_systems", 0) - update_url = url_for('requests.requests_form_update', screen=1, request_id=request.id) - verify_url = url_for('requests.financial_verification', request_id=request.id) + update_url = url_for( + "requests.requests_form_update", screen=1, request_id=request.id + ) + verify_url = url_for("requests.financial_verification", request_id=request.id) return { "order_id": request.id, @@ -19,7 +21,9 @@ def map_request(request): "app_count": app_count, "date": time_created.format("M/DD/YYYY"), "full_name": request.creator.full_name, - "edit_link": verify_url if Requests.is_pending_financial_verification(request) else update_url + "edit_link": verify_url if Requests.is_pending_financial_verification( + request + ) else update_url, } @@ -33,4 +37,12 @@ def requests_index(): mapped_requests = [map_request(r) for r in requests] - return render_template("requests.html", requests=mapped_requests) + pending_fv = any(Requests.is_pending_financial_verification(r) for r in requests) + pending_ccpo = any(Requests.is_pending_ccpo_approval(r) for r in requests) + + return render_template( + "requests.html", + requests=mapped_requests, + pending_financial_verification=pending_fv, + pending_ccpo_approval=pending_ccpo, + ) diff --git a/atst/routes/requests/jedi_request_flow.py b/atst/routes/requests/jedi_request_flow.py index 1a3a7163..6623a909 100644 --- a/atst/routes/requests/jedi_request_flow.py +++ b/atst/routes/requests/jedi_request_flow.py @@ -11,13 +11,15 @@ class JEDIRequestFlow(object): def __init__( self, current_step, + current_user=None, request=None, post_data=None, request_id=None, - current_user=None, existing_request=None, ): self.current_step = current_step + + self.current_user = current_user self.request = request self.post_data = post_data @@ -26,16 +28,13 @@ class JEDIRequestFlow(object): self.request_id = request_id self.form = self._form() - self.current_user = current_user self.existing_request = existing_request def _form(self): if self.is_post: return self.form_class()(self.post_data) - elif self.request: - return self.form_class()(data=self.current_step_data) else: - return self.form_class()() + return self.form_class()(data=self.current_step_data) def validate(self): return self.form.validate() @@ -59,6 +58,16 @@ class JEDIRequestFlow(object): def form_class(self): return self.current_screen["form"] + # maps user data to fields in OrgForm; this should be moved into the + # request initialization process when we have a request schema, or we just + # shouldn't record this data on the request + def map_user_data(self, user): + return { + "fname_request": user.first_name, + "lname_request": user.last_name, + "email_request": user.email + } + @property def current_step_data(self): data = {} @@ -69,8 +78,13 @@ class JEDIRequestFlow(object): if self.request: if self.form_section == "review_submit": data = self.request.body + elif self.form_section == "information_about_you": + form_data = self.request.body.get(self.form_section, {}) + data = { **self.map_user_data(self.request.creator), **form_data } else: data = self.request.body.get(self.form_section, {}) + elif self.form_section == "information_about_you": + data = self.map_user_data(self.current_user) return defaultdict(lambda: defaultdict(lambda: "Input required"), data) diff --git a/atst/routes/requests/requests_form.py b/atst/routes/requests/requests_form.py index d384abdf..c027890c 100644 --- a/atst/routes/requests/requests_form.py +++ b/atst/routes/requests/requests_form.py @@ -4,12 +4,13 @@ from . import requests_bp from atst.domain.requests import Requests from atst.routes.requests.jedi_request_flow import JEDIRequestFlow from atst.models.permissions import Permissions +from atst.models.request_status_event import RequestStatus from atst.domain.exceptions import UnauthorizedError @requests_bp.route("/requests/new/", methods=["GET"]) def requests_form_new(screen): - jedi_flow = JEDIRequestFlow(screen, request=None) + jedi_flow = JEDIRequestFlow(screen, request=None, current_user=g.current_user) return render_template( "requests/screen-%d.html" % int(screen), @@ -31,7 +32,7 @@ def requests_form_update(screen=1, request_id=None): _check_can_view_request(request_id) request = Requests.get(request_id) if request_id is not None else None - jedi_flow = JEDIRequestFlow(screen, request, request_id=request_id) + jedi_flow = JEDIRequestFlow(screen, request=request, request_id=request_id, current_user=g.current_user) return render_template( "requests/screen-%d.html" % int(screen), @@ -41,6 +42,7 @@ def requests_form_update(screen=1, request_id=None): current=screen, next_screen=screen + 1, request_id=request_id, + jedi_request=jedi_flow.request, can_submit=jedi_flow.can_submit, ) @@ -99,11 +101,11 @@ def requests_submit(request_id=None): request = Requests.get(request_id) Requests.submit(request) - if request.status == "approved": - return redirect("/requests?modal=True") + if request.status == RequestStatus.PENDING_FINANCIAL_VERIFICATION: + return redirect("/requests?modal=pendingFinancialVerification") else: - return redirect("/requests") + return redirect("/requests?modal=pendingCCPOApproval") # TODO: generalize this, along with other authorizations, into a policy-pattern diff --git a/deploy/kubernetes/atst-debugger.yml b/deploy/kubernetes/atst-debugger.yml new file mode 100644 index 00000000..d86abd0f --- /dev/null +++ b/deploy/kubernetes/atst-debugger.yml @@ -0,0 +1,43 @@ +apiVersion: v1 +kind: Pod +metadata: + name: atst-debugger + namespace: atat +spec: + securityContext: + fsGroup: 101 + containers: + - name: atst-debugger + image: registry.atat.codes:443/atst-prod:a1916b1 + args: ["/bin/bash", "-c", "while true; do date; sleep 45; done"] + envFrom: + - configMapRef: + name: atst-envvars + volumeMounts: + - name: atst-config + mountPath: "/opt/atat/atst/atst-overrides.ini" + subPath: atst-overrides.ini + - name: uwsgi-config + mountPath: "/opt/atat/atst/uwsgi-config.ini" + subPath: uwsgi-config.ini + - name: uwsgi-socket-dir + mountPath: "/var/run/uwsgi" + volumes: + - name: atst-config + secret: + secretName: atst-config-ini + items: + - key: atst-overrides.ini + path: atst-overrides.ini + mode: 0644 + - name: uwsgi-config + configMap: + name: atst-config + items: + - key: uwsgi-config + path: uwsgi-config.ini + mode: 0644 + - name: uwsgi-socket-dir + emptyDir: + medium: Memory + restartPolicy: Never diff --git a/deploy/kubernetes/atst-nginx-configmap.yml b/deploy/kubernetes/atst-nginx-configmap.yml index 6e2b1d69..553bb30b 100644 --- a/deploy/kubernetes/atst-nginx-configmap.yml +++ b/deploy/kubernetes/atst-nginx-configmap.yml @@ -55,9 +55,9 @@ data: ssl_stapling_verify on; resolver 8.8.8.8 8.8.4.4; # Request and validate client certificate - #ssl_verify_client on; - #ssl_verify_depth 10; - #ssl_client_certificate /etc/nginx/ssl/ca/client-ca.pem; + ssl_verify_client on; + ssl_verify_depth 10; + ssl_client_certificate /etc/ssl/client-ca-bundle.pem; # Guard against HTTPS -> HTTP downgrade add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; always"; location / { diff --git a/deploy/kubernetes/atst.yml b/deploy/kubernetes/atst.yml index c302f8af..c62d7a6a 100644 --- a/deploy/kubernetes/atst.yml +++ b/deploy/kubernetes/atst.yml @@ -24,7 +24,10 @@ spec: fsGroup: 101 containers: - name: atst - image: registry.atat.codes:443/atst-prod:e9b6f76 + image: registry.atat.codes:443/atst-prod:a1916b1 + resources: + requests: + memory: "2500Mi" envFrom: - configMapRef: name: atst-envvars @@ -32,6 +35,9 @@ spec: - name: atst-config mountPath: "/opt/atat/atst/atst-overrides.ini" subPath: atst-overrides.ini + - name: nginx-client-ca-bundle + mountPath: "/opt/atat/atst/ssl/server-certs/ca-chain.pem" + subPath: client-ca-bundle.pem - name: uwsgi-config mountPath: "/opt/atat/atst/uwsgi-config.ini" subPath: uwsgi-config.ini @@ -47,6 +53,9 @@ spec: volumeMounts: - name: nginx-auth-tls mountPath: "/etc/ssl/private" + - name: nginx-client-ca-bundle + mountPath: "/etc/ssl/client-ca-bundle.pem" + subPath: client-ca-bundle.pem - name: nginx-config mountPath: "/etc/nginx/conf.d/atst.conf" subPath: atst.conf @@ -78,6 +87,13 @@ spec: - key: tls.key path: auth.atat.key mode: 0640 + - name: nginx-client-ca-bundle + secret: + secretName: nginx-client-ca-bundle + items: + - key: client-ca-bundle.pem + path: client-ca-bundle.pem + mode: 0666 - name: nginx-config configMap: name: atst-nginx diff --git a/deploy/kubernetes/set_clientca_secret.sh b/deploy/kubernetes/set_clientca_secret.sh new file mode 100755 index 00000000..b27fbb2d --- /dev/null +++ b/deploy/kubernetes/set_clientca_secret.sh @@ -0,0 +1,4 @@ +#!/bin/bash + +kubectl -n atat delete secret atst-config-ini +kubectl -n atat create secret generic nginx-client-ca-bundle --from-file="${1}" diff --git a/js/components/text_input.js b/js/components/text_input.js index e027a800..60e9021f 100644 --- a/js/components/text_input.js +++ b/js/components/text_input.js @@ -14,32 +14,54 @@ export default { type: String, default: () => 'anything' }, - value: { + initialValue: { type: String, default: () => '' - } + }, + initialErrors: Array }, data: function () { return { - showError: false, + showError: (this.initialErrors && this.initialErrors.length) || false, showValid: false, mask: inputValidations[this.validation].mask, - renderedValue: this.value + pipe: inputValidations[this.validation].pipe || undefined, + keepCharPositions: inputValidations[this.validation].keepCharPositions || false, + validationError: inputValidations[this.validation].validationError || '', + value: this.initialValue, + modified: false + } + }, + + computed:{ + rawValue: function () { + return this._rawValue(this.value) } }, mounted: function () { - const value = this.$refs.input.value - if (value) { - this._checkIfValid({ value, invalidate: true }) - this.renderedValue = conformToMask(value, this.mask).conformedValue + if (this.value) { + this._checkIfValid({ value: this.value, invalidate: true }) + + if (this.mask && this.validation !== 'email') { + const mask = typeof this.mask.mask !== 'function' + ? this.mask + : mask.mask(this.value).filter((val) => val !== '[]') + + this.value = conformToMask(this.value, mask).conformedValue + } } }, methods: { // When user types a character - onInput: function (value) { + onInput: function (e) { + // When we use the native textarea element, we receive an event object + // When we use the masked-input component, we receive the value directly + const value = typeof e === 'object' ? e.target.value : e + this.value = value + this.modified = true this._checkIfValid({ value }) }, @@ -52,7 +74,11 @@ export default { // _checkIfValid: function ({ value, invalidate = false}) { // Validate the value - const valid = this._validate(value) + let valid = this._validate(value) + + if (!this.modified && this.initialErrors && this.initialErrors.length) { + valid = false + } // Show error messages or not if (valid) { @@ -70,13 +96,14 @@ export default { }) }, - _validate: function (value) { - // Strip out all the mask characters - let rawValue = inputValidations[this.validation].unmask.reduce((currentValue, character) => { + _rawValue: function (value) { + return inputValidations[this.validation].unmask.reduce((currentValue, character) => { return currentValue.split(character).join('') }, value) + }, - return inputValidations[this.validation].match.test(rawValue) + _validate: function (value) { + return inputValidations[this.validation].match.test(this._rawValue(value)) } } } diff --git a/js/index.js b/js/index.js index 76c3d81e..11f96763 100644 --- a/js/index.js +++ b/js/index.js @@ -24,7 +24,16 @@ const app = new Vue({ return { modals: { styleguideModal: false, + pendingFinancialVerification: false, + pendingCCPOApproval: false, } } + }, + mounted: function() { + const modalOpen = document.querySelector("#modalOpen"); + if (modalOpen) { + const modal = modalOpen.getAttribute("data-modal"); + this.modals[modal] = true; + } } }) diff --git a/js/lib/input_validations.js b/js/lib/input_validations.js index 6e7a066d..db5396c4 100644 --- a/js/lib/input_validations.js +++ b/js/lib/input_validations.js @@ -1,20 +1,56 @@ import createNumberMask from 'text-mask-addons/dist/createNumberMask' import emailMask from 'text-mask-addons/dist/emailMask' +import createAutoCorrectedDatePipe from 'text-mask-addons/dist/createAutoCorrectedDatePipe' export default { anything: { mask: false, match: /^(?!\s*$).+/, unmask: [], + validationError: 'Please enter a response.' + }, + integer: { + mask: createNumberMask({ prefix: '', allowDecimal: false }), + match: /^[1-9]\d*$/, + unmask: [','], + validationError: 'Please enter a number.' }, dollars: { mask: createNumberMask({ prefix: '$', allowDecimal: true }), match: /^-?\d+\.?\d*$/, - unmask: ['$',','] + unmask: ['$',','], + validationError: 'Please enter a dollar amount.' + }, + gigabytes: { + mask: createNumberMask({ prefix: '', suffix:'GB', allowDecimal: false }), + match: /^[1-9]\d*$/, + unmask: [',','GB'], + validationError: 'Please enter an amount of data in gigabytes.' }, email: { mask: emailMask, match: /(?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\[(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?|[a-z0-9-]*[a-z0-9]:(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])+)\])/, unmask: [], + validationError: 'Please enter a valid e-mail address.' + }, + date: { + mask: [/\d/,/\d/,'/',/\d/,/\d/,'/',/\d/,/\d/,/\d/,/\d/], + match: /(0[1-9]|1[012])[- \/.](0[1-9]|[12][0-9]|3[01])[- \/.](19|20)\d\d/, + unmask: [], + pipe: createAutoCorrectedDatePipe('mm/dd/yyyy HH:MM'), + keepCharPositions: true, + validationError: 'Please enter a valid date in the form MM/DD/YYYY.' + }, + usPhone: { + mask: ['(', /[1-9]/, /\d/, /\d/, ')', ' ', /\d/, /\d/, /\d/, '-', /\d/, /\d/, /\d/, /\d/], + match: /^\d{10}$/, + unmask: ['(',')','-',' '], + validationError: 'Please enter a 10-digit phone number.' + }, + dodId: { + mask: createNumberMask({ prefix: '', allowDecimal: false, includeThousandsSeparator: false }), + match: /^\d{10}$/, + unmask: [], + validationError: 'Please enter a 10-digit D.O.D. ID number.' } } diff --git a/script/alpine_setup b/script/alpine_setup index b9eeb9a7..41096326 100755 --- a/script/alpine_setup +++ b/script/alpine_setup @@ -10,7 +10,7 @@ APP_USER="atst" APP_UID="8010" # Add additional packages required by app dependencies -ADDITIONAL_PACKAGES="postgresql-libs python3 uwsgi uwsgi-python3" +ADDITIONAL_PACKAGES="postgresql-libs python3 rsync uwsgi uwsgi-python3" # Run the shared alpine setup script source ./script/include/run_alpine_setup diff --git a/script/test b/script/test index bd231db8..d1e22bbf 100755 --- a/script/test +++ b/script/test @@ -10,7 +10,7 @@ export FLASK_ENV=test RESET_DB="true" # Define all relevant python files and directories for this app -PYTHON_FILES="./app.py ./atst ./config" +PYTHON_FILES="./app.py ./atst/** ./config" # Enable Python testing RUN_PYTHON_TESTS="true" diff --git a/styles/atat.scss b/styles/atat.scss index 70d9d4b9..2a65b8d9 100644 --- a/styles/atat.scss +++ b/styles/atat.scss @@ -17,6 +17,7 @@ @import 'elements/labels'; @import 'elements/diff'; @import 'elements/tooltip'; +@import 'elements/kpi'; @import 'components/topbar'; @import 'components/global_layout'; diff --git a/styles/elements/_inputs.scss b/styles/elements/_inputs.scss index f8623f99..97a6642f 100644 --- a/styles/elements/_inputs.scss +++ b/styles/elements/_inputs.scss @@ -79,7 +79,7 @@ margin-left: $gap; } } - + .usa-input__title { display: flex; align-items: center; @@ -90,7 +90,7 @@ margin-left: $gap/2; } } - + .usa-input__help { @include h4; font-weight: normal; diff --git a/styles/elements/_kpi.scss b/styles/elements/_kpi.scss new file mode 100644 index 00000000..f68fe30c --- /dev/null +++ b/styles/elements/_kpi.scss @@ -0,0 +1,25 @@ +.kpi { + + margin-bottom: $gap; + + .kpi__item { + @include panel-base; + text-align: center; + margin: $gap; + padding: $gap * 2; + + &:first-child { + margin-left: -$gap; + } + + &:last-child { + margin-right: -$gap; + } + } + + .kpi__item__value { + @include h1; + padding-bottom: $gap / 2; + } + +} \ No newline at end of file diff --git a/styles/elements/_panels.scss b/styles/elements/_panels.scss index af5decf4..27fdc144 100644 --- a/styles/elements/_panels.scss +++ b/styles/elements/_panels.scss @@ -29,6 +29,19 @@ } } +@mixin panel-row { + @include grid-row; + + .col { + margin: 0 $site-margins-mobile * 2; + + @include media($medium-screen) { + margin: 0 $site-margins * 2; + } + } + +} + @mixin panel-actions { padding: $gap; } diff --git a/templates/base.html b/templates/base.html index 515caec0..3f5125ce 100644 --- a/templates/base.html +++ b/templates/base.html @@ -36,6 +36,11 @@ {% include 'footer.html' %} {% block modal %}{% endblock %} + + {% if g.modal %} +
+
+ {% endif %} {% assets "js_all" %} diff --git a/templates/components/alert.html b/templates/components/alert.html index d3385d1d..1fd30167 100644 --- a/templates/components/alert.html +++ b/templates/components/alert.html @@ -1,6 +1,6 @@ {% from "components/icon.html" import Icon %} -{% macro Alert(title, message=None, actions=None, level='info') -%} +{% macro Alert(title, message=None, actions=None, level='info', fragment=None) -%} {% set role = 'alertdialog' if actions else 'alert' %} {% set levels = { 'warning': { @@ -31,6 +31,12 @@
{{ message | safe }}
{% endif %} + {% if fragment %} +
+ {% include fragment %} +
+ {% endif %} + {% if actions %}
{{ actions | safe }}
{% endif %} diff --git a/templates/components/options_input.html b/templates/components/options_input.html index af2dfd81..817b7df3 100644 --- a/templates/components/options_input.html +++ b/templates/components/options_input.html @@ -10,7 +10,6 @@ {{ field.label | striptags}} {% if tooltip %}{{ Tooltip(tooltip) }}{% endif %} - {% if field.description %} {{ field.description | safe }} {% endif %} diff --git a/templates/components/text_input.html b/templates/components/text_input.html index c8fcbfa6..7c17e8f9 100644 --- a/templates/components/text_input.html +++ b/templates/components/text_input.html @@ -1,24 +1,65 @@ {% from "components/icon.html" import Icon %} {% from "components/tooltip.html" import Tooltip %} -{% macro TextInput(field, tooltip='', placeholder='') -%} -
-
+ +{%- endmacro %} diff --git a/templates/fragments/pending_ccpo_approval_alert.html b/templates/fragments/pending_ccpo_approval_alert.html new file mode 100644 index 00000000..1d2e9255 --- /dev/null +++ b/templates/fragments/pending_ccpo_approval_alert.html @@ -0,0 +1,11 @@ +

+ We will review and respond to your request in 72 hours. You’ll be notified via email or phone. +

+ +

+ While your request is being reviewed, your next step is to create a Task Order associated with JEDI Cloud. Please contact a Contracting Officer (KO), Contracting Officer Representative (COR), or a Financial Manager to help with this step. +

+ +

+ Learn more about the JEDI Task Order and the Financial Verification process. +

diff --git a/templates/fragments/pending_ccpo_approval_modal.html b/templates/fragments/pending_ccpo_approval_modal.html new file mode 100644 index 00000000..68a48c2d --- /dev/null +++ b/templates/fragments/pending_ccpo_approval_modal.html @@ -0,0 +1,35 @@ +

+ Request submitted. Approval pending. +

+ +

+ We will review and respond to your request in 72 hours. You’ll be notified via email or phone. +

+ +

+ Your request is being reviewed because: +

+

+ +

+ Next Steps +

+ +

+ While your request is being reviewed, your next step is to create a Task Order associated with JEDI Cloud. Please contact a Contracting Officer (KO), Contracting Officer Representative (COR), or a Financial Manager to help with this step. +

+ +

+ Once the Task Order has been created, you will be asked to provide details about the task order in the Financial Verification step. +

+ +

+ Learn more about the JEDI Task Order and the Financial Verification process. +

diff --git a/templates/fragments/pending_financial_verification.html b/templates/fragments/pending_financial_verification.html new file mode 100644 index 00000000..858b3e26 --- /dev/null +++ b/templates/fragments/pending_financial_verification.html @@ -0,0 +1,12 @@ +

+ The next step is to create a Task Order associated with JEDI Cloud. + Please contact a Contracting Officer (KO), Contracting Officer + Representative (COR), or a Financial Manager to help with this step. +

+

+ Once the Task Order has been created, you will be asked to provide + details about the task order in the Financial Verification step. +

+

+ Learn more about the JEDI Task Order and the Financial Verification process. +

diff --git a/templates/requests.html b/templates/requests.html index 25520ac2..566da288 100644 --- a/templates/requests.html +++ b/templates/requests.html @@ -4,37 +4,27 @@ {% from "components/modal.html" import Modal %} {% from "components/empty_state.html" import EmptyState %} -{% block modal %} - {% if g.modalOpen %} - {% call Modal() %} -

Your request is now approved!

- -

- Your next step is to create a Task Order (T.O.) associated with - JEDI Cloud. Please consult a Contracting Officer (KO) or - Contracting Officer Representative (COR) to help with this step. -

- -

- Once the Task Order (T.O.) has been created, we will need the following - details to create your account. These details will help keep your cloud - usage in sync with your budget. -

- - {{ Alert("You'll need these details: ", - message="

Task Order Number

Contracting Officer: Name, E-mail and Office

" - ) }} - - -
- Close -
- {% endcall %} - {% endif %} -{% endblock %} - {% block content %} + {% call Modal(name='pendingFinancialVerification', dismissable=True) %} +

Request submitted!

+ + {% include 'fragments/pending_financial_verification.html' %} + +
+ Close +
+ {% endcall %} + + {% call Modal(name='pendingCCPOApproval', dismissable=True) %} + + {% include 'fragments/pending_ccpo_approval_modal.html' %} + +
+ Close +
+ {% endcall %} + {% if not requests %} {{ EmptyState( @@ -46,9 +36,32 @@ {% else %} - {{ Alert('Pending Financial Verification', - message="

Your next step is to create a Task Order (T.O.) associated with JEDI Cloud. Please consult a Contracting Officer (KO) or Contracting Officer Representative (COR) to help with this step.

" - ) }} + {% if pending_financial_verification %} + + {{ Alert('Pending Financial Verification', fragment="fragments/pending_financial_verification.html") }} + + {% endif %} + + {% if pending_ccpo_approval %} + + {{ Alert('Request submitted. Approval pending.', fragment="fragments/pending_ccpo_approval_alert.html") }} + + {% endif %} + +
+
+
3
+
Pending Requests
+
+
+
2,456
+
Completed Requests This Year
+
+
+
234
+
Denied Requests
+
+
diff --git a/templates/requests/menu.html b/templates/requests/menu.html index d62de246..40de55d3 100644 --- a/templates/requests/menu.html +++ b/templates/requests/menu.html @@ -1,7 +1,7 @@
    {% for s in screens %} - {% if loop.index < current %} + {% if jedi_request and s.section in jedi_request.body %} {% set step_indicator = 'complete' %} {% elif loop.index == current %} {% set step_indicator = 'active' %} diff --git a/templates/requests/screen-1.html b/templates/requests/screen-1.html index 835c6168..ea17827f 100644 --- a/templates/requests/screen-1.html +++ b/templates/requests/screen-1.html @@ -17,7 +17,6 @@ ) }} {% endif %} -

    We’d like to know a little about how you plan to use JEDI Cloud services to process your request. Please answer the following questions to the best of your ability. Note that the CCPO does not directly help with migrating systems to JEDI Cloud. These questions are for learning about your cloud readiness and financial usage of the JEDI Cloud; your estimates will not be used for any department level reporting.

    All fields are required, unless specified optional.

    @@ -26,7 +25,7 @@ {{ TextInput(f.jedi_usage,placeholder="Briefly describe how you are expecting to use the JEDI Cloud. \n e.g. We are migrating XYZ application to the cloud so that...",tooltip="Your answer will help us provide tangible examples to DoD leadership how and why commercial cloud resources are accelerating the Department\\'s missions.") }}

    Cloud Readiness

    -{{ TextInput(f.num_software_systems,placeholder="Number of systems",tooltip="A software system can be any code that you plan to host on cloud infrastructure. For example, it could be a custom-developed web application, or a large ERP system.") }} +{{ TextInput(f.num_software_systems,validation="integer",tooltip="A software system can be any code that you plan to host on cloud infrastructure. For example, it could be a custom-developed web application, or a large ERP system.") }} {{ OptionsInput(f.jedi_migration, tooltip="Cloud migration is the process of moving data, applications or other business elements from an organization\\'s onsite computers/data centers to the cloud, or moving them from one cloud environment to another.") }} {{ OptionsInput(f.rationalization_software_systems, tooltip="Rationalization is the DoD process to determine whether the application should move to the cloud.") }} {{ OptionsInput(f.technical_support_team) }} @@ -37,13 +36,12 @@ {{ OptionsInput(f.cloud_native, tooltip="Cloud native is architecting and designing your application to use all the benefits of the commercial cloud. Specifically, designing applications so that they are decoupled from a physical resource.") }}

    Financial Usage

    -{{ TextInput(f.estimated_monthly_spend, tooltip="Refer to financial verification step help docs") }} +{{ TextInput(f.estimated_monthly_spend, tooltip="Refer to financial verification step help docs", validation="dollars") }} So this means you are spending approximately $1,000,023 annually. -{{ TextInput(f.dollar_value) }} -{{ TextInput(f.number_user_sessions) }} -{{ TextInput(f.average_daily_traffic, tooltip="Requests are the client-to-server network traffic that is being transferred to your systems") }} -{{ TextInput(f.average_daily_traffic_gb, tooltip="GB uploaded is the gigabyte amount of data traffic that is being transferred to your systems") }} -{{ TextInput(f.start_date) }} - +{{ TextInput(f.dollar_value,validation="dollars") }} +{{ TextInput(f.number_user_sessions,validation="integer") }} +{{ TextInput(f.average_daily_traffic, tooltip="Requests are the client-to-server network traffic that is being transferred to your systems",validation="integer") }} +{{ TextInput(f.average_daily_traffic_gb, tooltip="GB uploaded is the gigabyte amount of data traffic that is being transferred to your systems",validation="gigabytes") }} +{{ TextInput(f.start_date, validation="date", placeholder="MM / DD / YYYY") }} {% endblock %} diff --git a/templates/requests/screen-2.html b/templates/requests/screen-2.html index a0439b5d..6d911d32 100644 --- a/templates/requests/screen-2.html +++ b/templates/requests/screen-2.html @@ -19,16 +19,15 @@

    Please tell us more about you.

    -{{ TextInput(f.fname_request,placeholder='First Name') }} -{{ TextInput(f.lname_request,placeholder='Last Name') }} -{{ TextInput(f.email_request,placeholder='jane@mail.mil') }} -{{ TextInput(f.phone_number,placeholder='(123) 456-7890') }} +{{ TextInput(f.fname_request, placeholder='First Name') }} +{{ TextInput(f.lname_request, placeholder='Last Name') }} +{{ TextInput(f.email_request, placeholder='jane@mail.mil', validation='email') }} +{{ TextInput(f.phone_number, placeholder='e.g. (123) 456-7890', validation='usPhone') }}

    We want to collect the following information from you for security auditing and determining priviledged user access.

    {{ TextInput(f.service_branch,placeholder='e.g. US Air Force, US Army, US Navy, Marine Corps, Defense Media Agency') }} {{ OptionsInput(f.citizenship) }} {{ OptionsInput(f.designation) }} -{{ TextInput(f.date_latest_training,tooltip="When was the last time you completed the IA training?
    Information Assurance (IA) training is an important step in cyber awareness.") }} - +{{ TextInput(f.date_latest_training,tooltip="When was the last time you completed the IA training?
    Information Assurance (IA) training is an important step in cyber awareness.",placeholder="MM / DD / YYYY", validation="date") }} {% endblock %} diff --git a/templates/requests/screen-3.html b/templates/requests/screen-3.html index 7de95813..8971215c 100644 --- a/templates/requests/screen-3.html +++ b/templates/requests/screen-3.html @@ -30,7 +30,7 @@ {{ TextInput(f.fname_poc,placeholder='First Name') }} {{ TextInput(f.lname_poc,placeholder='Last Name') }} -{{ TextInput(f.email_poc,placeholder='jane@mail.mil') }} -{{ TextInput(f.dodid_poc,placeholder='10-digit number on the back of the CAC') }} +{{ TextInput(f.email_poc,placeholder='jane@mail.mil', validation='email') }} +{{ TextInput(f.dodid_poc,placeholder='10-digit number on the back of the CAC', validation='dodId') }} {% endblock %} diff --git a/tests/conftest.py b/tests/conftest.py index ab912679..12e7c320 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,11 +2,19 @@ import os import pytest import alembic.config import alembic.command +from logging.config import dictConfig from atst.app import make_app, make_config from atst.database import db as _db import tests.factories as factories +dictConfig({ + 'version': 1, + 'handlers': {'wsgi': { + 'class': 'logging.NullHandler', + }} +}) + @pytest.fixture(scope="session") def app(request): diff --git a/tests/domain/authnid/test_authentication_context.py b/tests/domain/authnid/test_authentication_context.py new file mode 100644 index 00000000..f2a359af --- /dev/null +++ b/tests/domain/authnid/test_authentication_context.py @@ -0,0 +1,92 @@ +import pytest + +from atst.domain.authnid import AuthenticationContext +from atst.domain.exceptions import UnauthenticatedError, NotFoundError +from atst.domain.users import Users + +from tests.mocks import DOD_SDN_INFO, DOD_SDN, FIXTURE_EMAIL_ADDRESS +from tests.factories import UserFactory + +CERT = open("tests/fixtures/{}.crt".format(FIXTURE_EMAIL_ADDRESS)).read() + + +class MockCRLValidator(): + + def __init__(self, value): + self.value = value + + def validate(self, cert): + return self.value + + +def test_can_authenticate(): + auth_context = AuthenticationContext( + MockCRLValidator(True), "SUCCESS", DOD_SDN, CERT + ) + assert auth_context.authenticate() + + +def test_unsuccessful_status(): + auth_context = AuthenticationContext( + MockCRLValidator(True), "FAILURE", DOD_SDN, CERT + ) + with pytest.raises(UnauthenticatedError) as excinfo: + assert auth_context.authenticate() + + (message,) = excinfo.value.args + assert "client authentication" in message + + +def test_crl_check_fails(): + auth_context = AuthenticationContext( + MockCRLValidator(False), "SUCCESS", DOD_SDN, CERT + ) + with pytest.raises(UnauthenticatedError) as excinfo: + assert auth_context.authenticate() + + (message,) = excinfo.value.args + assert "CRL check" in message + + +def test_bad_sdn(): + auth_context = AuthenticationContext( + MockCRLValidator(True), "SUCCESS", "abc123", CERT + ) + with pytest.raises(UnauthenticatedError) as excinfo: + auth_context.get_user() + + (message,) = excinfo.value.args + assert "SDN" in message + + +def test_user_exists(): + user = UserFactory.create(**DOD_SDN_INFO) + auth_context = AuthenticationContext( + MockCRLValidator(True), "SUCCESS", DOD_SDN, CERT + ) + auth_user = auth_context.get_user() + + assert auth_user == user + + +def test_creates_user(): + # check user does not exist + with pytest.raises(NotFoundError): + Users.get_by_dod_id(DOD_SDN_INFO["dod_id"]) + + auth_context = AuthenticationContext( + MockCRLValidator(True), "SUCCESS", DOD_SDN, CERT + ) + user = auth_context.get_user() + assert user.dod_id == DOD_SDN_INFO["dod_id"] + assert user.email == FIXTURE_EMAIL_ADDRESS + + +def test_user_cert_has_no_email(): + cert = open("ssl/client-certs/atat.mil.crt").read() + auth_context = AuthenticationContext( + MockCRLValidator(True), "SUCCESS", DOD_SDN, cert + ) + user = auth_context.get_user() + + assert user.email == None diff --git a/tests/domain/authnid/test_crl.py b/tests/domain/authnid/test_crl.py index 6bfdd99e..5593a865 100644 --- a/tests/domain/authnid/test_crl.py +++ b/tests/domain/authnid/test_crl.py @@ -4,7 +4,7 @@ import re import os import shutil from OpenSSL import crypto, SSL -from atst.domain.authnid.crl.validator import Validator +from atst.domain.authnid.crl import Validator import atst.domain.authnid.crl.util as util diff --git a/tests/domain/authnid/test_utils.py b/tests/domain/authnid/test_utils.py index caaa29f9..54e8f13d 100644 --- a/tests/domain/authnid/test_utils.py +++ b/tests/domain/authnid/test_utils.py @@ -1,16 +1,39 @@ import pytest import atst.domain.authnid.utils as utils -from tests.mocks import DOD_SDN +from tests.mocks import DOD_SDN, FIXTURE_EMAIL_ADDRESS def test_parse_sdn(): parsed = utils.parse_sdn(DOD_SDN) - assert parsed.get('first_name') == 'ART' - assert parsed.get('last_name') == 'GARFUNKEL' - assert parsed.get('dod_id') == '5892460358' + assert parsed.get("first_name") == "ART" + assert parsed.get("last_name") == "GARFUNKEL" + assert parsed.get("dod_id") == "5892460358" + def test_parse_bad_sdn(): with pytest.raises(ValueError): - utils.parse_sdn('this has nothing to do with anything') + utils.parse_sdn("this has nothing to do with anything") with pytest.raises(ValueError): utils.parse_sdn(None) + + +def test_parse_email_cert(): + cert_file = open("tests/fixtures/{}.crt".format(FIXTURE_EMAIL_ADDRESS), "rb").read() + email = utils.email_from_certificate(cert_file) + assert email == FIXTURE_EMAIL_ADDRESS + + +def test_parse_cert_with_no_email(): + cert_file = open("tests/fixtures/no-email.crt", "rb").read() + with pytest.raises(ValueError) as excinfo: + email = utils.email_from_certificate(cert_file) + (message,) = excinfo.value.args + assert "email" in message + + +def test_parse_cert_with_no_san(): + cert_file = open("tests/fixtures/no-san.crt", "rb").read() + with pytest.raises(ValueError) as excinfo: + email = utils.email_from_certificate(cert_file) + (message,) = excinfo.value.args + assert "subjectAltName" in message diff --git a/tests/domain/test_date.py b/tests/domain/test_date.py new file mode 100644 index 00000000..fe80530d --- /dev/null +++ b/tests/domain/test_date.py @@ -0,0 +1,21 @@ +import pytest +import pendulum + +from atst.domain.date import parse_date + + +def test_date_with_slashes(): + date_str = "1/2/2020" + assert parse_date(date_str) == pendulum.date(2020, 1, 2) + + +def test_date_with_dashes(): + date_str = "2020-1-2" + assert parse_date(date_str) == pendulum.date(2020, 1, 2) + + +def test_invalid_date(): + date_str = "This is not a valid data" + with pytest.raises(ValueError): + parse_date(date_str) + diff --git a/tests/fixtures/README.md b/tests/fixtures/README.md new file mode 100644 index 00000000..564e4553 --- /dev/null +++ b/tests/fixtures/README.md @@ -0,0 +1,46 @@ +# Regenerating Fixture Certificates + +You don't need to keep the key file generated by this process. + +1. Certificate with an email as subjectAltName: + +``` +openssl req -x509 \ + -newkey rsa:4096 \ + -sha256 \ + -nodes \ + -days 3650 \ + -keyout _foo.key \ + -out artgarfunkel@uso.mil.crt \ + -subj "/CN=GARFUNKEL.ART.G.5892460358" \ + -extensions SAN \ + -config <(cat /etc/ssl/openssl.cnf; echo '[SAN]'; echo 'subjectAltName=email:artgarfunkel@uso.mil') +``` + +2. Certificate with a DNS name as subjectAltName: + +``` +openssl req -x509 \ + -newkey rsa:4096 \ + -sha256 \ + -nodes \ + -days 3650 \ + -keyout _foo.key \ + -out no-email.crt \ + -subj "/CN=GARFUNKEL.ART.G.5892460358" \ + -extensions SAN \ + -config <(cat /etc/ssl/openssl.cnf; echo '[SAN]'; echo 'subjectAltName=DNS:artgarfunkel.com') +``` + +3. Certificate with no subjectAltName: + +``` +openssl req -x509 \ + -newkey rsa:4096 \ + -sha256 \ + -nodes \ + -days 3650 \ + -keyout _foo.key \ + -out no-san.crt \ + -subj "/CN=GARFUNKEL.ART.G.5892460358" +``` diff --git a/tests/fixtures/artgarfunkel@uso.mil.crt b/tests/fixtures/artgarfunkel@uso.mil.crt new file mode 100644 index 00000000..ab9d0c92 --- /dev/null +++ b/tests/fixtures/artgarfunkel@uso.mil.crt @@ -0,0 +1,29 @@ +-----BEGIN CERTIFICATE----- +MIIE8DCCAtigAwIBAgIJALTstfJQuulmMA0GCSqGSIb3DQEBCwUAMCUxIzAhBgNV +BAMMGkdBUkZVTktFTC5BUlQuRy41ODkyNDYwMzU4MB4XDTE4MDgwODE0MDI0N1oX +DTI4MDgwNTE0MDI0N1owJTEjMCEGA1UEAwwaR0FSRlVOS0VMLkFSVC5HLjU4OTI0 +NjAzNTgwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQD1OuSSniuiUP3Q +JqVJOS2LE+kmK4Y5TexTCCDhBebarg+dEYipdA4AwZMKSDL/6D+lZJCM1MTsUgaN +X/8lRv2obVGnWuEL5Rbcwhlf3yTaohhlPk/qFyQoQaxcLZgwlwUn47i5jKG1cFqA +l4TignN3n6cwbpjFfkP9Oepiffu4ThrOsrOWTN56IB7TrHElFIdjVuUWbIuK9CET +8UWixUecrLr64AKyDndaVyzGJBwhtyn7AanVYld9la0FSxu8ZcYMikSOvOEqqOMA +Nu2NapInrb+g1JEPycXTpGxMiLbFscmAkgmAqkxzeFBW0UHCQsbxG6Ep1Km3QfYw +QqvEfNRPuGq2bGtpbMUF9K4DSsI2yErtc8QvKVQ86xEuwoiFxiRtO+WQKJrq8CqU +sZxcz6ZAw2pERIYtGCi573rxb8g7skEvlIPIYWqljEwFOIrgoRav0x3dHdfA5Ubh +M0fx38icinVmL0Xd7G0JFY2RFQ13/r/zaxmmm546tH9tSjn1bwaO/6OcX9g5kCUH +p2cWklug3/bDQyKre9UZBjI7bUMWtL1w6uhdRm5yq4lX+o8G/tbUYVPER75z+AKO +p/eizAKCKSHRXDKIJr3zZG54jyd+VzTcjBSNQN/liclEBzlnZqZUgPPUR8kQ0S3E +n8jQ/Jk9MS/DUuNvEzBgZS5e3KtpZwIDAQABoyMwITAfBgNVHREEGDAWgRRhcnRn +YXJmdW5rZWxAdXNvLm1pbDANBgkqhkiG9w0BAQsFAAOCAgEAQzAA7aweU7ZHDK3l +pjcpfXruVOqceGst/avMHZp3ZS9YOkd+K3jnLVBObfBGwZkJjsyqvs0AMVi3mTYY +WeEkhTk50G2xA2UydsOQcuH/qOT6duj54a0TCB4/2kMBq6IhCT3xR4rbfxA+5ArD +yCConiy1FUX5nofYGNC7VPUgjQb64LtTr1+wO6nTwdpALeOX2GZXoBWVQO2W+2Ul +buIGV5TnpjoJGJmuO/A76qwMi5+e6EYAKmomjGCaTKyvbb2WAlCoHzdDd+nQMFYm +gBBMVOkiTZ2udIbQMFGdqAZjDEP484rsCVrth4PKAZ9/3LAe6XddLZZbqq5cap2l +u6jDinFIeV2aldRh285qwvX7+R3KQK7k5wNDbf8DlaPUhnF+CliYDBKFCoKE60AY +mp40YME0NE3XSGuIemJUazxFAJ8zUu8yEP3K/mzAwtRHiy+yQwKyPK4Wl+skXYHs +XbouRkWK7jleVKXLiE0Uw0EbWkfAVBM8IgGWp70UivCTlAdokwdKBxsLhsn57mJ5 +GP+9YTpwVQKWTBp06z0ZHaRI91d9Ke7YUSfDmLZ6VE9txqd9P2X2B2HbXFaYzGJh +gWtvqRh94ttaVsGr9iK7ANS9gXvn7Vb1ElyyF2wzP64WJtew7tywFq+Xhbm4/WPr +wM+BoGmfKP7uq0GBfu/HengJEGk= +-----END CERTIFICATE----- diff --git a/tests/fixtures/no-email.crt b/tests/fixtures/no-email.crt new file mode 100644 index 00000000..645ea845 --- /dev/null +++ b/tests/fixtures/no-email.crt @@ -0,0 +1,29 @@ +-----BEGIN CERTIFICATE----- +MIIE6TCCAtGgAwIBAgIJAKlkkD2Xt+vWMA0GCSqGSIb3DQEBCwUAMCUxIzAhBgNV +BAMMGkdBUkZVTktFTC5BUlQuRy41ODkyNDYwMzU4MB4XDTE4MDgwODE0MjI0MFoX +DTI4MDgwNTE0MjI0MFowJTEjMCEGA1UEAwwaR0FSRlVOS0VMLkFSVC5HLjU4OTI0 +NjAzNTgwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDi81dB+2WcfgoD +ls0A7q/lefu+rOEDp90o22MO/D4uAkqztI/O/JUzGs3MG6YWEREwanlgS67Cnhki +NFHWKh0QUXyqGqYgxmyNMXemawFI5ilpCXhToSoi3aKP8Da6YO1FbhF+X9NpEgpC +cNHwKnfzOreQ4s01q8TdKL6X9wQvtX1ILNjPpCRMrfaBkiD7VbAC+Ds8SW9V10MD +1jQkZyaPtZgNU9nou9OCwHpiva1HIckNy0E8UAuSGHWmwkK62rTUvZfKHrtWWaWY +G/njwSdotAZvH4xdFW+/wJdcpj1IHACtzkctLjub78RmuvPsNHcEy6x77efSJKvb +oBGvEzOFYqoXDhvLOpxQfsZNFO1suJlcXynzVx9hmVrUfw7l8Z/yUhuNKhuRQ7fw ++9YMuXrYrcTCsZx73eTsQCX7A6QSq5//N9GNSHl5/adZXcmSwFed6OOUrMRs73HY +IH35yiyGS4BbulyKUeGHdeiD00Crb2/DSxrH5M2BqFQw993clkhdbr8AT/B/lhh8 +Bysc3fHxwXGN65k1vfgrMm3aULUHLDH9RWjMra8OF2dZndQfmFSIxVOmDmmVjfME +lBg1TXY+JyKdkZrMb8IOpd08F+g10s+OnImldjsoSW0qkxDzUIbDRSvPK5dxukDc +ygecXqeKB7Bm2lceAurcARZiDdGvRwIDAQABoxwwGjAYBgNVHREEETAPgg1nYXJm +dW5rZWwuY29tMA0GCSqGSIb3DQEBCwUAA4ICAQCdaxkg4ZmmFqGqQ5bkjOucEowI +UpFIlgn3ORX/NjeAFpRlXr+kAyrezOfe3DzffFM63GVyqCR3swfwu0DdgpaGI++z +wMjXdDKDWfCSdFeFQczt/UyOQg7lkgKAgP6AgWrS9iOUwWY2Ecd+IhLjEAJ8ESgO +udi60tx9fDSlmpc3BlXBNkZUPGQW8abv+E2hV9dhNwCLVOxgK655E+9Lv3qRFFG5 +HczGP8UcKL/0e1CIV8JfiPNG3lI9LJKE0fik7jN1nvPuM9ubKwKuxWgxDH4iP4aw +qa76rGYRT4VDcU89bRRX6fVCOK7iFd4db32zsAaFcOnztpMWAyIaTSZ4RuJivpqn +rTl0+ZOVHLierhFAH96prWcUBtyaprRCx5y/bIme+KBdEuge+s6+H4fYjMeryenQ +6kK8yqqAngDxxD400U1uP5TERu+E/JiP1AaiyPyh5j1bOjzM8/aohwTLK4pSeUHC +2AITpHPjXumTYMVLJliJ1/B+ZW8wS7kg1ICL6x9hrt/SbdDqQPZa/pE8NHuzMNSr +TaTDjaBEz50awlMYv4b3u+YQbVhGabw+2sYDG6VhiMakyuY2FCIi5Tc/ybBvXta8 +lh8Xo8hSVlwvPumqLLITl17+KXHNL1KnTgWfXntFL6t/2OQrSbDfVXmThtW+FEmm +7ZFG54OsGWYdg8uNNg== +-----END CERTIFICATE----- diff --git a/tests/fixtures/no-san.crt b/tests/fixtures/no-san.crt new file mode 100644 index 00000000..584e1135 --- /dev/null +++ b/tests/fixtures/no-san.crt @@ -0,0 +1,31 @@ +-----BEGIN CERTIFICATE----- +MIIFVTCCAz2gAwIBAgIJAJRtzRX0VhJJMA0GCSqGSIb3DQEBCwUAMCUxIzAhBgNV +BAMTGkdBUkZVTktFTC5BUlQuRy41ODkyNDYwMzU4MB4XDTE4MDgwODE0MjQ1NFoX +DTI4MDgwNTE0MjQ1NFowJTEjMCEGA1UEAxMaR0FSRlVOS0VMLkFSVC5HLjU4OTI0 +NjAzNTgwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCak8upRyMLNUKK +vP6Ly50KGXDAktTBOFHDDsTRIIBeEiRImcuQ3nrqgHPKxlYdPG1k88VSDnrCDZry +DTm58NGCrtB6tJPqlZag8vpNffk9pEPOBKvUN9v5xqGgSN3sIdv0aMtMRJUXS61R +gzKJ76D+QM/7sKFhtPmETcfkBN99On7Zxw33TcwIlpv8t8tPB6F/r8jw07oWFBza +Z1Ui2+mFs6rZlxFOP8qRo82iencrMuW3/Tvqjl0N/AHPkdT7PbqAyg1aDkHYIBvc +euk/23Rgp1BQCX/Dia412/mMW0l6wYrw3pMBQ0j9LPSKTWx6rf7xa5TTweqcoKhB +zaeOV90wQk7gd+13u12ZqtPDI2Lgzi9PiIIDyDOGe4yX+O4YGTOV1pX2RyYCx9Hi +D6Pz9LoABz7TYq7A+LjKx5T5Q4XXiyUiQHTHQ5dC8v1rcUdZBB47eyAE0ZtVcCVI +tcG6eJgbM907AAabwca5sy0ogfYABMSUz6YWA1SMeDclwtRBlSWMFa2OCDJl7wBU +5Iyj/5a4MJ834IJh++gxpeijTktU1RyCDRUgXlAQNdqFxPmgwPbTo4KPDOw/YUnt +PSZfO2jiqhXgSRxlG5+2CAMiUVo2kelJxemDkJ30Yk3ebjx6qyEYizE0Mmh3xFYf +cOr7h1dxhjvAUtu3/ekNZWdz4WUcMQIDAQABo4GHMIGEMB0GA1UdDgQWBBRGIuCr +zBlH956853iOtEt/RF1wkzBVBgNVHSMETjBMgBRGIuCrzBlH956853iOtEt/RF1w +k6EppCcwJTEjMCEGA1UEAxMaR0FSRlVOS0VMLkFSVC5HLjU4OTI0NjAzNTiCCQCU +bc0V9FYSSTAMBgNVHRMEBTADAQH/MA0GCSqGSIb3DQEBCwUAA4ICAQAHVurBIQJS +makSWkuIFYuhKI5GDU9R1xeFe56zDVKE6Xoqki6CxUlHcIY/QN2nJ02GVN12GMAi +p4jewaiomr0LIlmzk63jn380okRjOFNoieIyQiXL0rH2oV4DESbWLuLoFnWFHGI7 +8VsyURDe00H58t3MsEEOzrbjSV7KeyifjIND6yrDuzoLY2FquTOq3Q41XRJxIOuk +0p0Cd9E07YzAb9kzODO5ZPvXfkAIqZIrAYb9bjcMs6gb8CbzA/STdSEPp2NjgAsc +fjI0VtUPyTX2fKE9nrHeSNsT7WFPslbzvXVtlmUvlyDgnHglKjsgSLTgFaAERUSz +WkJG0+lysAPga/qpD22C3OB/igT/S+KJjw8oubX6iAAxIDM1Oa+YStft5IXX2KSm +5FT2HIMtXBG9pkgmJ9O+xrDrJwSz+sezXYuV88T4fDYdXAUqgBudmml/h+OGEB4C +k3Mc0ibe5Np4SyDg9qWDa+u6GojQCkTA0ygxcXR0M/t204MXqV7g4zCt624BB+nH +TYLeq49SQsl2XmPLsjwWIToly1F6tizP0gWYFamGD2bqZNDIEl/5a/CLwpOlSWc8 +K6tfqAlNnM56/vMXDeo/na7XLRHPkLisUZCxBYVuSFu77gZsawVxcZlO3Hwn1L7a +Pdu9qr067Y/6AAogCQANMXWfywkc+TZMlQ== +-----END CERTIFICATE----- diff --git a/tests/mocks.py b/tests/mocks.py index 0307997e..2a2f0fab 100644 --- a/tests/mocks.py +++ b/tests/mocks.py @@ -3,18 +3,11 @@ from tests.factories import RequestFactory, UserFactory MOCK_USER = UserFactory.build() MOCK_REQUEST = RequestFactory.build( - creator=MOCK_USER.id, - body={ - "financial_verification": { - "pe_id": "0203752A", - }, - } + creator=MOCK_USER.id, body={"financial_verification": {"pe_id": "0203752A"}} ) -DOD_SDN_INFO = { - 'first_name': 'ART', - 'last_name': 'GARFUNKEL', - 'dod_id': '5892460358' - } +DOD_SDN_INFO = {"first_name": "ART", "last_name": "GARFUNKEL", "dod_id": "5892460358"} DOD_SDN = f"CN={DOD_SDN_INFO['last_name']}.{DOD_SDN_INFO['first_name']}.G.{DOD_SDN_INFO['dod_id']},OU=OTHER,OU=PKI,OU=DoD,O=U.S. Government,C=US" MOCK_VALID_PE_ID = "8675309U" + +FIXTURE_EMAIL_ADDRESS = "artgarfunkel@uso.mil" diff --git a/tests/routes/test_request_new.py b/tests/routes/test_request_new.py index e31aae79..fadf0ce9 100644 --- a/tests/routes/test_request_new.py +++ b/tests/routes/test_request_new.py @@ -66,3 +66,49 @@ def test_nonexistent_request(client, user_session): response = client.get("/requests/new/1/foo", follow_redirects=True) assert response.status_code == 404 + + +def test_creator_info_is_autopopulated(monkeypatch, client, user_session): + user = UserFactory.create() + user_session(user) + request = RequestFactory.create(creator=user, body={"information_about_you": {}}) + + response = client.get("/requests/new/2/{}".format(request.id)) + body = response.data.decode() + assert "initial-value='{}'".format(user.first_name) in body + assert "initial-value='{}'".format(user.last_name) in body + assert "initial-value='{}'".format(user.email) in body + + +def test_creator_info_is_autopopulated_for_new_request(monkeypatch, client, user_session): + user = UserFactory.create() + user_session(user) + + response = client.get("/requests/new/2") + body = response.data.decode() + assert "initial-value='{}'".format(user.first_name) in body + assert "initial-value='{}'".format(user.last_name) in body + assert "initial-value='{}'".format(user.email) in body + + +def test_non_creator_info_is_not_autopopulated(monkeypatch, client, user_session): + user = UserFactory.create() + creator = UserFactory.create() + user_session(user) + request = RequestFactory.create(creator=creator, body={"information_about_you": {}}) + + response = client.get("/requests/new/2/{}".format(request.id)) + body = response.data.decode() + assert not user.first_name in body + assert not user.last_name in body + assert not user.email in body + +def test_can_review_data(user_session, client): + creator = UserFactory.create() + user_session(creator) + request = RequestFactory.create(creator=creator) + response = client.get("/requests/new/4/{}".format(request.id)) + body = response.data.decode() + # assert a sampling of the request data is on the review page + assert request.body["primary_poc"]["fname_poc"] in body + assert request.body["information_about_you"]["email_request"] in body diff --git a/tests/routes/test_request_submit.py b/tests/routes/test_request_submit.py index 428e056e..480d6dc2 100644 --- a/tests/routes/test_request_submit.py +++ b/tests/routes/test_request_submit.py @@ -1,6 +1,7 @@ import pytest from tests.mocks import MOCK_USER from tests.factories import RequestFactory +from atst.models.request_status_event import RequestStatus def _mock_func(*args, **kwargs): @@ -20,19 +21,18 @@ def test_submit_reviewed_request(monkeypatch, client, user_session): follow_redirects=False, ) assert "/requests" in response.headers["Location"] - assert "modal" not in response.headers["Location"] + assert "modal=pendingCCPOApproval" in response.headers["Location"] def test_submit_autoapproved_reviewed_request(monkeypatch, client, user_session): user_session() monkeypatch.setattr("atst.domain.requests.Requests.get", _mock_func) monkeypatch.setattr("atst.domain.requests.Requests.submit", _mock_func) - monkeypatch.setattr("atst.models.request.Request.status", "approved") - # this just needs to send a known invalid form value + monkeypatch.setattr("atst.models.request.Request.status", RequestStatus.PENDING_FINANCIAL_VERIFICATION) response = client.post( "/requests/submit/1", headers={"Content-Type": "application/x-www-form-urlencoded"}, data="", follow_redirects=False, ) - assert "/requests?modal=True" in response.headers["Location"] + assert "/requests?modal=pendingFinancialVerification" in response.headers["Location"] diff --git a/tests/test_auth.py b/tests/test_auth.py index 7e2b483d..4aa5bc1d 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -1,5 +1,9 @@ +import pytest from flask import session, url_for -from .mocks import DOD_SDN +from .mocks import DOD_SDN_INFO, DOD_SDN, FIXTURE_EMAIL_ADDRESS +from atst.domain.users import Users +from atst.domain.exceptions import NotFoundError +from .factories import UserFactory MOCK_USER = {"id": "438567dd-25fa-4d83-a8cc-8aa8366cb24a"} @@ -10,12 +14,15 @@ def _fetch_user_info(c, t): def test_successful_login_redirect(client, monkeypatch): - monkeypatch.setattr("atst.routes._is_valid_certificate", lambda *args: True) + monkeypatch.setattr("atst.domain.authnid.AuthenticationContext.authenticate", lambda *args: True) + monkeypatch.setattr("atst.domain.authnid.AuthenticationContext.get_user", lambda *args: UserFactory.create()) resp = client.get( "/login-redirect", environ_base={ - "HTTP_X_SSL_CLIENT_VERIFY": "SUCCESS", "HTTP_X_SSL_CLIENT_S_DN": DOD_SDN + "HTTP_X_SSL_CLIENT_VERIFY": "SUCCESS", + "HTTP_X_SSL_CLIENT_S_DN": "", + "HTTP_X_SSL_CLIENT_CERT": "", }, ) @@ -58,8 +65,8 @@ UNPROTECTED_ROUTES = ["/", "/login-dev", "/login-redirect", "/unauthorized"] def test_crl_validation_on_login(client): - good_cert = open("ssl/client-certs/atat.mil.crt", "rb").read() - bad_cert = open("ssl/client-certs/bad-atat.mil.crt", "rb").read() + good_cert = open("ssl/client-certs/atat.mil.crt").read() + bad_cert = open("ssl/client-certs/bad-atat.mil.crt").read() # bad cert is on the test CRL resp = client.get( @@ -67,7 +74,7 @@ def test_crl_validation_on_login(client): environ_base={ "HTTP_X_SSL_CLIENT_VERIFY": "SUCCESS", "HTTP_X_SSL_CLIENT_S_DN": DOD_SDN, - "HTTP_X_SSL_CLIENT_CERT": bad_cert.decode(), + "HTTP_X_SSL_CLIENT_CERT": bad_cert, }, ) assert resp.status_code == 401 @@ -79,9 +86,55 @@ def test_crl_validation_on_login(client): environ_base={ "HTTP_X_SSL_CLIENT_VERIFY": "SUCCESS", "HTTP_X_SSL_CLIENT_S_DN": DOD_SDN, - "HTTP_X_SSL_CLIENT_CERT": good_cert.decode(), + "HTTP_X_SSL_CLIENT_CERT": good_cert, }, ) assert resp.status_code == 302 assert "home" in resp.headers["Location"] assert session["user_id"] + + +def test_creates_new_user_on_login(monkeypatch, client): + monkeypatch.setattr("atst.domain.authnid.AuthenticationContext.authenticate", lambda *args: True) + cert_file = open("tests/fixtures/{}.crt".format(FIXTURE_EMAIL_ADDRESS)).read() + + # ensure user does not exist + with pytest.raises(NotFoundError): + Users.get_by_dod_id(DOD_SDN_INFO["dod_id"]) + + resp = client.get( + "/login-redirect", + environ_base={ + "HTTP_X_SSL_CLIENT_VERIFY": "SUCCESS", + "HTTP_X_SSL_CLIENT_S_DN": DOD_SDN, + "HTTP_X_SSL_CLIENT_CERT": cert_file, + }, + ) + + user = Users.get_by_dod_id(DOD_SDN_INFO["dod_id"]) + assert user.first_name == DOD_SDN_INFO["first_name"] + assert user.last_name == DOD_SDN_INFO["last_name"] + assert user.email == FIXTURE_EMAIL_ADDRESS + + +def test_creates_new_user_without_email_on_login(monkeypatch, client): + monkeypatch.setattr("atst.routes._is_valid_certificate", lambda *args: True) + cert_file = open("ssl/client-certs/atat.mil.crt").read() + + # ensure user does not exist + with pytest.raises(NotFoundError): + Users.get_by_dod_id(DOD_SDN_INFO["dod_id"]) + + resp = client.get( + "/login-redirect", + environ_base={ + "HTTP_X_SSL_CLIENT_VERIFY": "SUCCESS", + "HTTP_X_SSL_CLIENT_S_DN": DOD_SDN, + "HTTP_X_SSL_CLIENT_CERT": cert_file, + }, + ) + + user = Users.get_by_dod_id(DOD_SDN_INFO["dod_id"]) + assert user.first_name == DOD_SDN_INFO["first_name"] + assert user.last_name == DOD_SDN_INFO["last_name"] + assert user.email == None diff --git a/tests/test_integration.py b/tests/test_integration.py index e8fa6348..633f14b1 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -1,46 +1,67 @@ import pytest -from tests.mocks import MOCK_USER +from urllib.parse import urlencode +from .factories import UserFactory, RequestFactory + from atst.routes.requests.jedi_request_flow import JEDIRequestFlow +from atst.models.request_status_event import RequestStatus +from atst.domain.requests import Requests + @pytest.fixture def screens(app): return JEDIRequestFlow(3).screens -@pytest.mark.skip() -def test_stepthrough_request_form(monkeypatch, screens, client): - monkeypatch.setattr( - "atst.handlers.request_new.RequestNew.get_current_user", lambda s: MOCK_USER - ) - monkeypatch.setattr( - "atst.handlers.request_new.RequestNew.check_xsrf_cookie", lambda s: True - ) - monkeypatch.setattr( - "atst.handlers.request_new.JEDIRequestFlow.validate", lambda s: True - ) +def test_stepthrough_request_form(user_session, screens, client): + user = UserFactory.create() + user_session(user) + mock_request = RequestFactory.stub() - def take_a_step(inc, req=None): + def post_form(url, redirects=False, data=""): + return client.post( + url, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + data=data, + follow_redirects=redirects, + ) + + def take_a_step(inc, req=None, data=None): req_url = "/requests/new/{}".format(inc) if req: req_url += "/" + req - response = client.post( - req_url, - headers={"Content-Type": "application/x-www-form-urlencoded"}, - data="meaning=42", - ) - return response + # we do it twice, with and without redirect, in order to get the + # destination url + prelim_resp = post_form(req_url, data=data) + response = post_form(req_url, True, data=data) + return (prelim_resp.headers.get("Location"), response) # GET the initial form - response = client.get("/requests/new") + response = client.get("/requests/new/1") assert screens[0]["title"] in response.data.decode() # POST to each of the form pages up until review and submit req_id = None for i in range(1, len(screens)): - resp = take_a_step(i, req=req_id) - __import__('ipdb').set_trace() - req_id = resp.effective_url.split("/")[-1] + # get appropriate form data to POST for this section + section = screens[i - 1]["section"] + post_data = urlencode(mock_request.body[section]) + + effective_url, resp = take_a_step(i, req=req_id, data=post_data) + req_id = effective_url.split("/")[-1] screen_title = screens[i]["title"].replace("&", "&") - assert "/requests/new/{}/{}".format(i + 1, req_id) in resp.effective_url + assert "/requests/new/{}/{}".format(i + 1, req_id) in effective_url assert screen_title in resp.data.decode() + + # at this point, the real request we made and the mock_request bodies + # should be equivalent + assert Requests.get(req_id).body == mock_request.body + + # finish the review and submit step + client.post( + "/requests/submit/{}".format(req_id), + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + + finished_request = Requests.get(req_id) + assert finished_request.status == RequestStatus.PENDING_CCPO_APPROVAL