Merge branch 'master' into ui/tooltips

This commit is contained in:
luisgov 2018-08-13 15:05:08 -04:00 committed by GitHub
commit 143c58addc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
57 changed files with 1021 additions and 208 deletions

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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
)

View File

@ -56,7 +56,6 @@ def refresh_crls(out_dir, logger=None):
if __name__ == "__main__":
import sys
import datetime
import logging
logging.basicConfig(

View File

@ -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))

12
atst/domain/date.py Normal file
View File

@ -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))

View File

@ -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

View File

@ -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

View File

@ -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 = (

View File

@ -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",

View File

@ -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):

View File

@ -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):

View File

@ -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)

View File

@ -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)
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()
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

View File

@ -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,
)

View File

@ -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)

View File

@ -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/<int:screen>", 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

View File

@ -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

View File

@ -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 / {

View File

@ -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

View File

@ -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}"

View File

@ -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))
}
}
}

View File

@ -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;
}
}
})

View File

@ -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.'
}
}

View File

@ -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

View File

@ -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"

View File

@ -17,6 +17,7 @@
@import 'elements/labels';
@import 'elements/diff';
@import 'elements/tooltip';
@import 'elements/kpi';
@import 'components/topbar';
@import 'components/global_layout';

25
styles/elements/_kpi.scss Normal file
View File

@ -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;
}
}

View File

@ -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;
}

View File

@ -36,6 +36,11 @@
{% include 'footer.html' %}
{% block modal %}{% endblock %}
{% if g.modal %}
<div data-modal="{{ g.modal }}" id="modalOpen">
</div>
{% endif %}
</div>
{% assets "js_all" %}

View File

@ -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 @@
<div class='alert__message'>{{ message | safe }}</div>
{% endif %}
{% if fragment %}
<div class='alert__message'>
{% include fragment %}
</div>
{% endif %}
{% if actions %}
<div class='alert__actions'>{{ actions | safe }}</div>
{% endif %}

View File

@ -10,7 +10,6 @@
{{ field.label | striptags}}
{% if tooltip %}{{ Tooltip(tooltip) }}{% endif %}
</div>
{% if field.description %}
<span class='usa-input__help'>{{ field.description | safe }}</span>
{% endif %}

View File

@ -1,8 +1,17 @@
{% from "components/icon.html" import Icon %}
{% from "components/tooltip.html" import Tooltip %}
{% macro TextInput(field, tooltip='', placeholder='') -%}
<div class='usa-input {% if field.errors %}usa-input--error{% endif %}'>
{% macro TextInput(field, tooltip='', placeholder='', validation='anything', paragraph=False) -%}
<textinput
name='{{ field.name }}'
validation='{{ validation }}'
{% if field.data %}initial-value='{{ field.data }}'{% endif %}
{% if field.errors %}v-bind:initial-errors='{{ field.errors }}'{% endif %}
inline-template>
<div
v-bind:class="['usa-input usa-input--validation--' + validation, { 'usa-input--error': showError, 'usa-input--success': showValid }]">
<label for={{field.name}}>
<div class="usa-input__title">{{ field.label | striptags }} {% if tooltip %}{{ Tooltip(tooltip) }}{% endif %}</div>
@ -10,15 +19,47 @@
<span class='usa-input__help'>{{ field.description | safe }}</span>
{% endif %}
{% if field.errors %} {{ Icon('alert',classes="icon-validation") }} {% endif %}
<span v-show='showError'>{{ Icon('alert',classes="icon-validation")) }}</span>
<span v-show='showValid'>{{ Icon('ok',classes="icon-validation")) }}</span>
</label>
{{ field(placeholder=placeholder) | safe }}
{% if paragraph %}
<textarea
v-on:input='onInput'
v-on:change='onChange'
v-bind:value='value'
id='{{ field.name }}'
ref='input'
placeholder='{{ placeholder }}'>
</textarea>
{% else %}
<masked-input
v-on:input='onInput'
v-on:change='onChange'
v-bind:value='value'
v-bind:mask='mask'
v-bind:pipe='pipe'
v-bind:keep-char-positions='keepCharPositions'
v-bind:aria-invalid='showError'
id='{{ field.name }}'
type='text'
ref='input'
placeholder='{{ placeholder }}'>
</masked-input>
{% if field.errors %}
{% for error in field.errors %}
<span class='usa-input__message'>{{ error }}</span>
{% endfor %}
{% endif %}
<input type='hidden' v-bind:value='rawValue' name='{{ field.name }}' />
<template v-if='showError'>
<span v-if='initialErrors' v-for='error in initialErrors' class='usa-input__message' v-html='error'></span>
<span v-if='!initialErrors' class='usa-input__message' v-html='validationError'></span>
</template>
</div>
</textinput>
{%- endmacro %}

View File

@ -0,0 +1,11 @@
<p>
We will review and respond to your request in 72 hours. Youll be notified via email or phone.
</p>
<p>
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.
</p>
<p>
Learn more about the JEDI Task Order and the Financial Verification process.
</p>

View File

@ -0,0 +1,35 @@
<h1>
Request submitted. Approval pending.
</h1>
<p>
We will review and respond to your request in 72 hours. Youll be notified via email or phone.
</p>
<p>
Your request is being reviewed because:
<ul>
<li>
Your request includes over $1 million for cloud resources
</li>
<li>
We may need more information about your request
</li>
</ul>
</p>
<h2>
Next Steps
</h2>
<p>
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.
</p>
<p>
Once the Task Order has been created, you will be asked to provide details about the task order in the Financial Verification step.
</p>
<p>
Learn more about the JEDI Task Order and the Financial Verification process.
</p>

View File

@ -0,0 +1,12 @@
<p>
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.
</p>
<p>
Once the Task Order has been created, you will be asked to provide
details about the task order in the Financial Verification step.
</p>
<p>
<i>Learn more</i> about the JEDI Task Order and the Financial Verification process.
</p>

View File

@ -4,36 +4,26 @@
{% from "components/modal.html" import Modal %}
{% from "components/empty_state.html" import EmptyState %}
{% block modal %}
{% if g.modalOpen %}
{% call Modal() %}
<h1>Your request is now approved!</h1>
{% block content %}
<p>
Your next step is to create a <b>Task Order (T.O.)</b> associated with
JEDI Cloud. Please consult a <b>Contracting Officer (KO)</b> or
<b>Contracting Officer Representative (COR)</b> to help with this step.
</p>
<p>
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.
</p>
{{ Alert("You'll need these details: ",
message="<p>Task Order Number</p><p>Contracting Officer: Name, E-mail and Office</p>"
) }}
{% call Modal(name='pendingFinancialVerification', dismissable=True) %}
<h1>Request submitted!</h1>
{% include 'fragments/pending_financial_verification.html' %}
<div class='action-group'>
<a href='/requests' class='action-group__action usa-button'>Close</a>
<a v-on:click="closeModal('pendingFinancialVerification')" class='action-group__action usa-button'>Close</a>
</div>
{% endcall %}
{% endif %}
{% endblock %}
{% block content %}
{% call Modal(name='pendingCCPOApproval', dismissable=True) %}
{% include 'fragments/pending_ccpo_approval_modal.html' %}
<div class='action-group'>
<a v-on:click="closeModal('pendingCCPOApproval')" class='action-group__action usa-button'>Close</a>
</div>
{% endcall %}
{% if not requests %}
@ -46,9 +36,32 @@
{% else %}
{{ Alert('Pending Financial Verification',
message="<p>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.</p>"
) }}
{% 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 %}
<div class="row kpi">
<div class="kpi__item col col--grow">
<div class="kpi__item__value">3</div>
<div class="kpi__item__description">Pending Requests</div>
</div>
<div class="kpi__item col col--grow">
<div class="kpi__item__value">2,456</div>
<div class="kpi__item__description">Completed Requests This Year</div>
</div>
<div class="kpi__item col col--grow">
<div class="kpi__item__value">234</div>
<div class="kpi__item__description">Denied Requests</div>
</div>
</div>
<div class="col col--grow">

View File

@ -1,7 +1,7 @@
<div class="progress-menu progress-menu--four">
<ul>
{% 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' %}

View File

@ -17,7 +17,6 @@
) }}
{% endif %}
<p>Wed 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.</p>
<p><em>All fields are required, unless specified optional.</em></p>
@ -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.") }}
<h2>Cloud Readiness</h2>
{{ 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.") }}
<h2>Financial Usage</h2>
{{ 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") }}
<span>So this means you are spending approximately <b class="label">$1,000,023</b> annually.</span>
{{ 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 %}

View File

@ -21,14 +21,13 @@
{{ 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.email_request, placeholder='jane@mail.mil', validation='email') }}
{{ TextInput(f.phone_number, placeholder='e.g. (123) 456-7890', validation='usPhone') }}
<p>We want to collect the following information from you for security auditing and determining priviledged user access.</p>
{{ 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? <br> 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? <br> Information Assurance (IA) training is an important step in cyber awareness.",placeholder="MM / DD / YYYY", validation="date") }}
{% endblock %}

View File

@ -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 %}

View File

@ -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):

View File

@ -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

View File

@ -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

View File

@ -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

21
tests/domain/test_date.py Normal file
View File

@ -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)

46
tests/fixtures/README.md vendored Normal file
View File

@ -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"
```

29
tests/fixtures/artgarfunkel@uso.mil.crt vendored Normal file
View File

@ -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-----

29
tests/fixtures/no-email.crt vendored Normal file
View File

@ -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-----

31
tests/fixtures/no-san.crt vendored Normal file
View File

@ -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-----

View File

@ -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"

View File

@ -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

View File

@ -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"]

View File

@ -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

View File

@ -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 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):
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("&", "&amp;")
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