Merge branch 'master' into ui/input-field-frontend-validation

This commit is contained in:
andrewdds 2018-08-09 13:56:09 -04:00 committed by GitHub
commit 3f5d961513
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
59 changed files with 948 additions and 226 deletions

View File

@ -16,12 +16,14 @@ log/*
LICENSE
*.md
# Skip pipenv/virtualenv related things
# Skip envrc
.envrc
.venv
# Skip ansible-container stuff
ansible*
container.yml
meta.yml
requirements.yml
# Skip kubernetes and Docker config stuff
deploy

12
Pipfile.lock generated
View File

@ -1,7 +1,7 @@
{
"_meta": {
"hash": {
"sha256": "2b149e0d8c23814a2c701b53f5c75b36714a2ccd4e2a2769924ef6e2a3f09e97"
"sha256": "5fc8273838354406366b401529a6f512a73ac6a8ecea6699afa4ab7b4996bf13"
},
"pipfile-spec": 6,
"requires": {
@ -271,6 +271,7 @@
"sha256:1d936da41ee06216d89fdc7ead1ee9a5da2811a8787515a976b646e110c3f622",
"sha256:e4ef42e82b0b493c5849eed98b5ab49d6767caf982127e9a33167f1153b36cc5"
],
"markers": "python_version != '3.0.*' and python_version != '3.1.*' and python_version != '3.2.*' and python_version >= '2.7' and python_version != '3.3.*'",
"version": "==2018.5"
},
"redis": {
@ -501,6 +502,7 @@
"sha256:b9c40e9750f3d77e6e4d441d8b0266cf555e7cdabdcff33c4fd06366ca761ef8",
"sha256:ec9ef8f4a9bc6f71eec99e1806bfa2de401650d996c59330782b89a5555c1497"
],
"markers": "python_version != '3.3.*' and python_version >= '2.7' and python_version != '3.1.*' and python_version != '3.0.*' and python_version != '3.2.*'",
"version": "==4.3.4"
},
"itsdangerous": {
@ -618,6 +620,7 @@
"sha256:6e3836e39f4d36ae72840833db137f7b7d35105079aee6ec4a62d9f80d594dd1",
"sha256:95eb8364a4708392bae89035f45341871286a333f749c3141c20573d2b3876e1"
],
"markers": "python_version != '3.3.*' and python_version >= '2.7' and python_version != '3.1.*' and python_version != '3.0.*' and python_version != '3.2.*'",
"version": "==0.7.1"
},
"prompt-toolkit": {
@ -640,6 +643,7 @@
"sha256:3fd59af7435864e1a243790d322d763925431213b6b8529c6ca71081ace3bbf7",
"sha256:e31fb2767eb657cbde86c454f02e99cb846d3cd9d61b318525140214fdc0e98e"
],
"markers": "python_version != '3.3.*' and python_version >= '2.7' and python_version != '3.1.*' and python_version != '3.0.*' and python_version != '3.2.*'",
"version": "==1.5.4"
},
"pygments": {
@ -689,15 +693,11 @@
},
"pyyaml": {
"hashes": [
"sha256:1cbc199009e78f92d9edf554be4fe40fb7b0bef71ba688602a00e97a51909110",
"sha256:254bf6fda2b7c651837acb2c718e213df29d531eebf00edb54743d10bcb694eb",
"sha256:3108529b78577327d15eec243f0ff348a0640b0c3478d67ad7f5648f93bac3e2",
"sha256:3c17fb92c8ba2f525e4b5f7941d850e7a48c3a59b32d331e2502a3cdc6648e76",
"sha256:6f89b5c95e93945b597776163403d47af72d243f366bf4622ff08bdfd1c950b7",
"sha256:8d6d96001aa7f0a6a4a95e8143225b5d06e41b1131044913fecb8f85a125714b",
"sha256:be622cc81696e24d0836ba71f6272a2b5767669b0d79fdcf0295d51ac2e156c8",
"sha256:c8a88edd93ee29ede719080b2be6cb2333dfee1dccba213b422a9c8e97f2967b",
"sha256:f39411e380e2182ad33be039e8ee5770a5d9efe01a2bfb7ae58d9ba31c4a2a9d"
"sha256:c8a88edd93ee29ede719080b2be6cb2333dfee1dccba213b422a9c8e97f2967b"
],
"version": "==4.2b4"
},

View File

@ -0,0 +1,43 @@
"""rename request creator
Revision ID: 05d6272bdb43
Revises: 77b065750596
Create Date: 2018-08-07 20:21:22.559283
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = '05d6272bdb43'
down_revision = '77b065750596'
branch_labels = None
depends_on = None
def upgrade():
db = op.get_bind()
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('requests', sa.Column('user_id', postgresql.UUID(as_uuid=True), nullable=True))
op.create_foreign_key('requests_user_id_fk', 'requests', 'users', ['user_id'], ['id'])
# ### end Alembic commands ###
db.execute("UPDATE requests SET user_id = creator")
op.alter_column('requests', 'user_id', nullable=False)
op.drop_column('requests', 'creator')
def downgrade():
db = op.get_bind()
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('requests', sa.Column('creator', postgresql.UUID(), autoincrement=False, nullable=True))
op.drop_constraint('requests_user_id_fk', 'requests', type_='foreignkey')
# ### end Alembic commands ###
db.execute("UPDATE requests SET creator = user_id")
op.drop_column('requests', 'user_id')

View File

@ -34,6 +34,4 @@ def upgrade():
def downgrade():
db = op.get_bind()
db.execute("DELETE FROM roles WHERE name = 'default'")
pass

View File

@ -0,0 +1,49 @@
"""new request statuses
Revision ID: 77b065750596
Revises: 1f57f784ed5b
Create Date: 2018-08-07 16:42:11.502361
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.orm.session import sessionmaker
from atst.models.request_status_event import RequestStatus
# revision identifiers, used by Alembic.
revision = '77b065750596'
down_revision = '1f57f784ed5b'
branch_labels = None
depends_on = None
def upgrade():
"""
Update all existing request statuses so that the state of the
table reflects the statuses listed in RequestStatus.
This involves fixing the casing on existing statuses, and
deleting statuses that have no match.
"""
db = op.get_bind()
status_events = db.execute("SELECT * FROM request_status_events").fetchall()
for status_event in status_events:
try:
status = RequestStatus[status_event["new_status"].upper()]
query = sa.text("""
UPDATE request_status_events
SET new_status = :status
WHERE id = :id"""
)
db.execute(query, id=status_event["id"], status=status.name)
except KeyError:
query = sa.text("DELETE FROM request_status_events WHERE id = :id")
db.execute(query, id=status_event["id"])
def downgrade():
pass

View File

@ -169,15 +169,4 @@ def upgrade():
def downgrade():
db = op.get_bind()
db.execute("""
DELETE FROM roles
WHERE name IN (
'ccpo',
'owner',
'admin',
'developer',
'billing_auditor',
'security_auditor'
);
""")
pass

View File

@ -15,6 +15,7 @@ from atst.routes import bp
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.auth import apply_authentication
@ -45,10 +46,11 @@ def make_app(config):
Session(app)
assets_environment.init_app(app)
make_error_pages(app)
app.register_blueprint(bp)
app.register_blueprint(workspace_routes)
app.register_blueprint(requests_bp)
if ENV != "production":
if ENV != "prod":
app.register_blueprint(dev_routes)
apply_authentication(app)

View File

@ -14,3 +14,19 @@ class AlreadyExistsError(Exception):
@property
def message(self):
return "{} already exists".format(self.resource_name)
class UnauthorizedError(Exception):
def __init__(self, user, action):
self.user = user
self.action = action
@property
def message(self):
return "User {} not authorized to {}".format(self.user.id, self.action)
class UnauthenticatedError(Exception):
@property
def message(self):
return str(self)

View File

@ -1,8 +1,9 @@
from sqlalchemy import exists, and_
from sqlalchemy import exists, and_, exc
from sqlalchemy.orm.exc import NoResultFound
from sqlalchemy.orm.attributes import flag_modified
from atst.models import Request, RequestStatusEvent
from atst.models.request import Request
from atst.models.request_status_event import RequestStatusEvent, RequestStatus
from atst.database import db
from .exceptions import NotFoundError
@ -30,11 +31,9 @@ class Requests(object):
AUTO_APPROVE_THRESHOLD = 1000000
@classmethod
def create(cls, creator_id, body):
request = Request(creator=creator_id, body=body)
status_event = RequestStatusEvent(new_status="incomplete")
request.status_events.append(status_event)
def create(cls, creator, body):
request = Request(creator=creator, body=body)
request = Requests.set_status(request, RequestStatus.STARTED)
db.session.add(request)
db.session.commit()
@ -42,12 +41,15 @@ class Requests(object):
return request
@classmethod
def exists(cls, request_id, creator_id):
return db.session.query(
exists().where(
and_(Request.id == request_id, Request.creator == creator_id)
)
).scalar()
def exists(cls, request_id, creator):
try:
return db.session.query(
exists().where(
and_(Request.id == request_id, Request.creator == creator)
)
).scalar()
except exc.DataError:
return False
@classmethod
def get(cls, request_id):
@ -59,10 +61,10 @@ class Requests(object):
return request
@classmethod
def get_many(cls, creator_id=None):
def get_many(cls, creator=None):
filters = []
if creator_id:
filters.append(Request.creator == creator_id)
if creator:
filters.append(Request.creator == creator)
requests = (
db.session.query(Request)
@ -74,10 +76,13 @@ class Requests(object):
@classmethod
def submit(cls, request):
request.status_events.append(RequestStatusEvent(new_status="submitted"))
new_status = None
if Requests.should_auto_approve(request):
request.status_events.append(RequestStatusEvent(new_status="approved"))
new_status = RequestStatus.PENDING_FINANCIAL_VERIFICATION
else:
new_status = RequestStatus.PENDING_CCPO_APPROVAL
request = Requests.set_status(request, new_status)
db.session.add(request)
db.session.commit()
@ -100,11 +105,6 @@ class Requests(object):
request.body = deep_merge(request_delta, request.body)
if Requests.should_allow_submission(request):
request.status_events.append(
RequestStatusEvent(new_status="pending_submission")
)
# Without this, sqlalchemy won't notice the change to request.body,
# since it doesn't track dictionary mutations by default.
flag_modified(request, "body")
@ -112,6 +112,20 @@ class Requests(object):
db.session.add(request)
db.session.commit()
@classmethod
def set_status(cls, request: Request, status: RequestStatus):
status_event = RequestStatusEvent(new_status=status)
request.status_events.append(status_event)
return request
@classmethod
def action_required_by(cls, request):
return {
RequestStatus.STARTED: "mission_owner",
RequestStatus.PENDING_FINANCIAL_VERIFICATION: "mission_owner",
RequestStatus.PENDING_CCPO_APPROVAL: "ccpo"
}.get(request.status)
@classmethod
def should_auto_approve(cls, request):
try:
@ -129,6 +143,10 @@ class Requests(object):
"primary_poc",
]
existing_request_sections = request.body.keys()
return request.status == "incomplete" and all(
return request.status == RequestStatus.STARTED and all(
section in existing_request_sections for section in all_request_sections
)
@classmethod
def is_pending_financial_verification(cls, request):
return request.status == RequestStatus.PENDING_FINANCIAL_VERIFICATION

View File

@ -37,6 +37,7 @@ class Users(object):
db.session.add(user)
db.session.commit()
except IntegrityError:
db.session.rollback()
raise AlreadyExistsError("user")
return user

View File

@ -29,8 +29,10 @@ class NewlineListField(Field):
widget = TextArea()
def _value(self):
if self.data:
return "\n".join(self.data)
if isinstance(self.data, list):
return '\n'.join(self.data)
elif self.data:
return self.data
else:
return ""

View File

@ -70,7 +70,7 @@ class FinancialForm(ValidatedForm):
"Unique Item Identifier (UII)s related to your application(s) if you already have them."
)
pe_id = StringField("Program Element (PE) Number related to your request")
pe_id = StringField("Program Element (PE) Number related to your request", validators=[Required()])
treasury_code = StringField("Program Treasury Code")

View File

@ -62,7 +62,7 @@ class RequestForm(ValidatedForm):
)
engineering_assessment = RadioField(
description="Have you completed an engineering assessment of your software systems for cloud readiness?",
"Have you completed an engineering assessment of your software systems for cloud readiness?",
choices=[("yes", "Yes"), ("no", "No"), ("in_progress", "In Progress")],
)

View File

@ -1,6 +1,6 @@
from sqlalchemy import Column, func
from sqlalchemy import Column, func, ForeignKey
from sqlalchemy.types import DateTime
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import relationship
from atst.models import Base
@ -11,22 +11,19 @@ class Request(Base):
__tablename__ = "requests"
id = Id()
creator = Column(UUID(as_uuid=True))
time_created = Column(DateTime(timezone=True), server_default=func.now())
body = Column(JSONB)
status_events = relationship(
"RequestStatusEvent", backref="request", order_by="RequestStatusEvent.sequence"
)
user_id = Column(ForeignKey("users.id"), nullable=False)
creator = relationship("User")
@property
def status(self):
return self.status_events[-1].new_status
@property
def action_required_by(self):
return {
"incomplete": "mission_owner",
"pending_submission": "mission_owner",
"submitted": "ccpo",
"approved": "mission_owner",
}.get(self.status)
def status_displayname(self):
return self.status_events[-1].displayname

View File

@ -1,5 +1,6 @@
from sqlalchemy import Column, func, ForeignKey
from sqlalchemy.types import DateTime, String, BigInteger
from enum import Enum
from sqlalchemy import Column, func, ForeignKey, Enum as SQLAEnum
from sqlalchemy.types import DateTime, BigInteger
from sqlalchemy.schema import Sequence
from sqlalchemy.dialects.postgresql import UUID
@ -7,11 +8,20 @@ from atst.models import Base
from atst.models.types import Id
class RequestStatus(Enum):
STARTED = "Started"
PENDING_FINANCIAL_VERIFICATION = "Pending Financial Verification"
PENDING_CCPO_APPROVAL = "Pending CCPO Approval"
APPROVED = "Approved"
EXPIRED = "Expired"
DELETED = "Deleted"
class RequestStatusEvent(Base):
__tablename__ = "request_status_events"
id = Id()
new_status = Column(String())
new_status = Column(SQLAEnum(RequestStatus))
time_created = Column(DateTime(timezone=True), server_default=func.now())
request_id = Column(
UUID(as_uuid=True), ForeignKey("requests.id", ondelete="CASCADE")
@ -19,3 +29,7 @@ class RequestStatusEvent(Base):
sequence = Column(
BigInteger, Sequence("request_status_events_sequence_seq"), nullable=False
)
@property
def displayname(self):
return self.new_status.value

View File

@ -5,6 +5,7 @@ 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
bp = Blueprint("atst", __name__)
@ -29,6 +30,9 @@ 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
@bp.route('/login-redirect')
def login_redirect():
if request.environ.get('HTTP_X_SSL_CLIENT_VERIFY') == 'SUCCESS' and _is_valid_certificate(request):
@ -39,15 +43,7 @@ def login_redirect():
return redirect(url_for("atst.home"))
else:
return redirect(url_for("atst.unauthorized"))
@bp.route("/unauthorized")
def unauthorized():
template = render_template('unauthorized.html')
response = app.make_response(template)
response.status_code = 401
return response
raise UnauthenticatedError()
def _is_valid_certificate(request):

View File

@ -9,50 +9,56 @@ _DEV_USERS = {
"dod_id": "1234567890",
"first_name": "Sam",
"last_name": "Seeceepio",
"atat_role": "ccpo",
"atat_role_name": "ccpo",
"email": "sam@test.com"
},
"amanda": {
"dod_id": "2345678901",
"first_name": "Amanda",
"last_name": "Adamson",
"atat_role": "default",
"atat_role_name": "default",
"email": "amanda@test.com"
},
"brandon": {
"dod_id": "3456789012",
"first_name": "Brandon",
"last_name": "Buchannan",
"atat_role": "default",
"atat_role_name": "default",
"email": "brandon@test.com"
},
"christina": {
"dod_id": "4567890123",
"first_name": "Christina",
"last_name": "Collins",
"atat_role": "default",
"atat_role_name": "default",
"email": "christina@test.com"
},
"dominick": {
"dod_id": "5678901234",
"first_name": "Dominick",
"last_name": "Domingo",
"atat_role": "default",
"atat_role_name": "default",
"email": "dominick@test.com"
},
"erica": {
"dod_id": "6789012345",
"first_name": "Erica",
"last_name": "Eichner",
"atat_role": "default",
"atat_role_name": "default",
"email": "erica@test.com"
},
}
@bp.route("/login-dev")
def login_dev():
role = request.args.get("username", "amanda")
user_data = _DEV_USERS[role]
basic_data = {k:v for k,v in user_data.items() if k not in ["dod_id", "atat_role"]}
user = _set_user_permissions(user_data["dod_id"], user_data["atat_role"], basic_data)
user = Users.get_or_create_by_dod_id(
user_data["dod_id"],
atat_role_name=user_data["atat_role_name"],
first_name=user_data["first_name"],
last_name=user_data["last_name"],
email=user_data["email"]
)
session["user_id"] = user.id
return redirect(url_for("atst.home"))
def _set_user_permissions(dod_id, role, user_data):
return Users.get_or_create_by_dod_id(dod_id, atat_role_name=role, **user_data)

21
atst/routes/errors.py Normal file
View File

@ -0,0 +1,21 @@
from flask import render_template
import atst.domain.exceptions as exceptions
def make_error_pages(app):
@app.errorhandler(exceptions.NotFoundError)
@app.errorhandler(exceptions.UnauthorizedError)
# pylint: disable=unused-variable
def not_found(e):
app.logger.error(e.message)
return render_template("not_found.html"), 404
@app.errorhandler(exceptions.UnauthenticatedError)
# pylint: disable=unused-variable
def unauthorized(e):
app.logger.error(e.message)
return render_template('unauthenticated.html'), 401
return app

View File

@ -25,10 +25,10 @@ def update_financial_verification(request_id):
if form.validate():
request_data = {"financial_verification": post_data}
Requests.update(request_id, request_data)
valid = form.perform_extra_validation(
existing_request.body.get("financial_verification")
)
Requests.update(request_id, request_data)
if valid:
return redirect(url_for("requests.financial_verification_submitted"))
else:
@ -41,4 +41,4 @@ def update_financial_verification(request_id):
@requests_bp.route("/requests/financial_verification_submitted")
def financial_verification_submitted():
pass
return render_template("requests/financial_verification_submitted.html")

View File

@ -1,35 +1,36 @@
import pendulum
from flask import render_template, g
from flask import render_template, g, url_for
from . import requests_bp
from atst.domain.requests import Requests
def map_request(user, request):
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)
return {
"order_id": request.id,
"is_new": is_new,
"status": request.status,
"app_count": 1,
"status": request.status_displayname,
"app_count": app_count,
"date": time_created.format("M/DD/YYYY"),
"full_name": user.full_name
"full_name": request.creator.full_name,
"edit_link": verify_url if Requests.is_pending_financial_verification(request) else update_url
}
@requests_bp.route("/requests", methods=["GET"])
def requests_index():
requests = []
if (
"review_and_approve_jedi_workspace_request"
in g.current_user.atat_permissions
):
if "review_and_approve_jedi_workspace_request" in g.current_user.atat_permissions:
requests = Requests.get_many()
else:
requests = Requests.get_many(creator_id=g.current_user.id)
requests = Requests.get_many(creator=g.current_user)
mapped_requests = [map_request(g.current_user, r) for r in requests]
mapped_requests = [map_request(r) for r in requests]
return render_template("requests.html", requests=mapped_requests)

View File

@ -76,7 +76,7 @@ class JEDIRequestFlow(object):
@property
def can_submit(self):
return self.request and self.request.status != "incomplete"
return self.request and Requests.should_allow_submission(self.request)
@property
def next_screen(self):
@ -124,5 +124,5 @@ class JEDIRequestFlow(object):
if self.request_id:
Requests.update(self.request_id, request_data)
else:
request = Requests.create(self.current_user.id, request_data)
request = Requests.create(self.current_user, request_data)
self.request_id = request.id

View File

@ -3,6 +3,8 @@ from flask import g, redirect, render_template, url_for, request as http_request
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.domain.exceptions import UnauthorizedError
@requests_bp.route("/requests/new/<int:screen>", methods=["GET"])
@ -25,6 +27,9 @@ def requests_form_new(screen):
)
@requests_bp.route("/requests/new/<int:screen>/<string:request_id>", methods=["GET"])
def requests_form_update(screen=1, request_id=None):
if request_id:
_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)
@ -79,10 +84,12 @@ def requests_update(screen=1, request_id=None):
request_id=jedi_flow.request_id,
)
return redirect(where)
else:
return render_template(
"requests/screen-%d.html" % int(screen), **rerender_args
)
else:
return render_template("requests/screen-%d.html" % int(screen), **rerender_args)
@ -94,5 +101,18 @@ def requests_submit(request_id=None):
if request.status == "approved":
return redirect("/requests?modal=True")
else:
return redirect("/requests")
# TODO: generalize this, along with other authorizations, into a policy-pattern
# for authorization in the application
def _check_can_view_request(request_id):
if Permissions.REVIEW_AND_APPROVE_JEDI_WORKSPACE_REQUEST in g.current_user.atat_permissions:
pass
elif Requests.exists(request_id, g.current_user):
pass
else:
raise UnauthorizedError(g.current_user, "view request {}".format(request_id))

View File

@ -1,23 +1,23 @@
[default]
PORT=8000
ENVIRONMENT = dev
DEBUG = true
COOKIE_SECRET = some-secret-please-replace
SECRET = change_me_into_something_secret
SECRET_KEY = change_me_into_something_secret
CAC_URL = http://localhost:8000/login-redirect
CA_CHAIN = ssl/server-certs/ca-chain.pem
COOKIE_SECRET = some-secret-please-replace
CRL_DIRECTORY = crl
DEBUG = true
ENVIRONMENT = dev
PERMANENT_SESSION_LIFETIME = 600
PE_NUMBER_CSV_URL = http://c95e1ebb198426ee57b8-174bb05a294821bedbf46b6384fe9b1f.r31.cf5.rackcdn.com/penumbers.csv
REDIS_URI = redis://localhost:6379
PGAPPNAME = atst
PGDATABASE = atat
PGHOST = localhost
PGPASSWORD = postgres
PGPORT = 5432
PGUSER = postgres
PGPASSWORD = postgres
PGDATABASE = atat
SESSION_TYPE = redis
PORT=8000
REDIS_URI = redis://localhost:6379
SECRET = change_me_into_something_secret
SECRET_KEY = change_me_into_something_secret
SESSION_COOKIE_NAME=atat
SESSION_TYPE = redis
SESSION_USE_SIGNER = True
PERMANENT_SESSION_LIFETIME = 600
CRL_DIRECTORY = crl
CA_CHAIN = ssl/server-certs/ca-chain.pem
WTF_CSRF_ENABLED = true

View File

@ -1,2 +1,3 @@
[default]
SESSION_COOKIE_SECURE=True
SESSION_COOKIE_DOMAIN=atat.codes

View File

@ -1,4 +1,4 @@
FROM python:3.6.5-alpine
FROM alpine:3.8
### Very low chance of changing
###############################
@ -7,12 +7,12 @@ ARG APP_USER=atst
ARG APP_GROUP=atat
ARG APP_DIR=/opt/atat/atst
ARG APP_PORT=8000
ARG SITE_PACKAGES_DIR=/usr/local/lib/python3.6/site-packages
ARG LOCAL_BIN_DIR=/usr/bin
ARG SITE_PACKAGES_DIR=/usr/lib/python3.6/site-packages
ENV APP_USER "${APP_USER}"
ENV APP_GROUP "${APP_GROUP}"
ENV APP_DIR "${APP_DIR}"
ENV SKIP_PIPENV true
# Set port to open
EXPOSE "${APP_PORT}"
@ -21,13 +21,16 @@ EXPOSE "${APP_PORT}"
ENTRYPOINT ["/usr/bin/dumb-init", "--"]
# Default command is to launch the server
CMD ["bash", "-c", "${APP_DIR}/script/server"]
CMD ["bash", "-c", "${APP_DIR}/script/uwsgi_server"]
### Items that will change almost every build
#############################################
# Copy installed python packages from the tester image
COPY --from=atst-tester:latest "${SITE_PACKAGES_DIR}" "${SITE_PACKAGES_DIR}"
# Copy local bin directory (contains python system package wrappers)
COPY --from=atst-tester:latest "${LOCAL_BIN_DIR}" "${LOCAL_BIN_DIR}"
# Copy the app directory contents from the tester image (includes node modules)
COPY --from=atst-tester:latest "${APP_DIR}" "${APP_DIR}"

View File

@ -0,0 +1,15 @@
---
apiVersion: v1
kind: ConfigMap
metadata:
name: atst-config
namespace: atat
data:
uwsgi-config: |-
[uwsgi]
callable = app
module = app
socket = /var/run/uwsgi/uwsgi.socket
plugins = python3
virtualenv = /opt/atat/atst/.venv
chmod-socket = 666

View File

@ -0,0 +1,10 @@
---
apiVersion: v1
kind: ConfigMap
metadata:
name: atst-envvars
namespace: atat
data:
FLASK_ENV: dev
OVERRIDE_CONFIG_FULLPATH: /opt/atat/atst/atst-overrides.ini
UWSGI_CONFIG_FULLPATH: /opt/atat/atst/uwsgi-config.ini

View File

@ -0,0 +1,79 @@
---
apiVersion: v1
kind: ConfigMap
metadata:
name: atst-nginx
namespace: atat
data:
nginx-config: |-
server {
server_name www.atat.codes atat.codes;
listen 8442;
listen [::]:8442 ipv6only=on;
if ($http_x_forwarded_proto != 'https') {
return 301 https://$host$request_uri;
}
location /login-redirect {
return 301 https://auth.atat.codes$request_uri;
}
location /login-dev {
try_files $uri @appbasicauth;
}
location / {
try_files $uri @app;
}
location @app {
include uwsgi_params;
uwsgi_pass unix:///var/run/uwsgi/uwsgi.socket;
}
location @appbasicauth {
include uwsgi_params;
uwsgi_pass unix:///var/run/uwsgi/uwsgi.socket;
auth_basic "Developer Access";
auth_basic_user_file /etc/nginx/.htpasswd;
}
}
server {
server_name auth.atat.codes;
listen 8443 ssl;
listen [::]:8443 ssl ipv6only=on;
# SSL server certificate and private key
ssl_certificate /etc/ssl/private/auth.atat.crt;
ssl_certificate_key /etc/ssl/private/auth.atat.key;
# Set SSL protocols, ciphers, and related options
ssl_protocols TLSv1.3 TLSv1.2;
ssl_ciphers 'ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-SHA384:ECDHE-RSA-AES256-SHA384:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA256';
ssl_prefer_server_ciphers on;
ssl_ecdh_curve secp384r1;
ssl_dhparam /etc/ssl/dhparam.pem;
# SSL session options
ssl_session_timeout 4h;
ssl_session_cache shared:SSL:10m; # 1mb = ~4000 sessions
ssl_session_tickets off;
# OCSP Stapling
ssl_stapling on;
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;
# Guard against HTTPS -> HTTP downgrade
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; always";
location / {
return 301 https://www.atat.codes$request_uri;
}
location /login-redirect {
try_files $uri @app;
}
location @app {
include uwsgi_params;
uwsgi_pass unix:///var/run/uwsgi/uwsgi.socket;
uwsgi_param HTTP_X_SSL_CLIENT_VERIFY $ssl_client_verify;
uwsgi_param HTTP_X_SSL_CLIENT_CERT $ssl_client_raw_cert;
uwsgi_param HTTP_X_SSL_CLIENT_S_DN $ssl_client_s_dn;
uwsgi_param HTTP_X_SSL_CLIENT_S_DN_LEGACY $ssl_client_s_dn_legacy;
uwsgi_param HTTP_X_SSL_CLIENT_I_DN $ssl_client_i_dn;
uwsgi_param HTTP_X_SSL_CLIENT_I_DN_LEGACY $ssl_client_i_dn_legacy;
}
}

165
deploy/kubernetes/atst.yml Normal file
View File

@ -0,0 +1,165 @@
---
apiVersion: v1
kind: Namespace
metadata:
name: atat
---
apiVersion: extensions/v1beta1
kind: Deployment
metadata:
labels:
app: atst
name: atst
namespace: atat
spec:
replicas: 1
strategy:
type: RollingUpdate
template:
metadata:
labels:
app: atst
spec:
securityContext:
fsGroup: 101
containers:
- name: atst
image: registry.atat.codes:443/atst-prod:e9b6f76
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"
- name: atst-nginx
image: nginx:alpine
ports:
- containerPort: 8442
name: http
- containerPort: 8443
name: https
volumeMounts:
- name: nginx-auth-tls
mountPath: "/etc/ssl/private"
- name: nginx-config
mountPath: "/etc/nginx/conf.d/atst.conf"
subPath: atst.conf
- name: nginx-dhparam
mountPath: "/etc/ssl/dhparam.pem"
subPath: dhparam.pem
- name: nginx-htpasswd
mountPath: "/etc/nginx/.htpasswd"
subPath: .htpasswd
- name: uwsgi-socket-dir
mountPath: "/var/run/uwsgi"
imagePullSecrets:
- name: regcred
volumes:
- name: atst-config
secret:
secretName: atst-config-ini
items:
- key: atst-overrides.ini
path: atst-overrides.ini
mode: 0644
- name: nginx-auth-tls
secret:
secretName: auth-atst-ingress-tls
items:
- key: tls.crt
path: auth.atat.crt
mode: 0644
- key: tls.key
path: auth.atat.key
mode: 0640
- name: nginx-config
configMap:
name: atst-nginx
items:
- key: nginx-config
path: atst.conf
- name: nginx-dhparam
secret:
secretName: dhparam-4096
items:
- key: dhparam.pem
path: dhparam.pem
mode: 0640
- name: nginx-htpasswd
secret:
secretName: atst-nginx-htpasswd
items:
- key: htpasswd
path: .htpasswd
mode: 0640
- name: uwsgi-config
configMap:
name: atst-config
items:
- key: uwsgi-config
path: uwsgi-config.ini
mode: 0644
- name: uwsgi-socket-dir
emptyDir:
medium: Memory
---
apiVersion: v1
kind: Service
metadata:
labels:
app: atst
name: atst
namespace: atat
spec:
ports:
- name: http
port: 80
targetPort: 8442
selector:
app: atst
---
apiVersion: v1
kind: Service
metadata:
labels:
app: atst
name: atst-auth
namespace: atat
spec:
type: NodePort
ports:
- name: https
protocol: TCP
nodePort: 32751
port: 8443
selector:
app: atst
---
apiVersion: extensions/v1beta1
kind: Ingress
metadata:
name: atst
namespace: atat
annotations:
kubernetes.io/tls-acme: "true"
kubernetes.io/ingress.class: "nginx"
nginx.ingress.kubernetes.io/proxy-body-size: 10m
spec:
tls:
- hosts:
- www.atat.codes
secretName: atst-ingress-tls
rules:
- host: www.atat.codes
http:
paths:
- path: /
backend:
serviceName: atst
servicePort: 80

View File

@ -0,0 +1,4 @@
#!/bin/bash
kubectl -n atat delete secret atst-config-ini
kubectl -n atat create secret generic atst-config-ini --from-file="${1}"

View File

@ -0,0 +1,4 @@
#!/bin/bash
kubectl -n atat delete secret dhparam-4096
kubectl -n atat create secret generic dhparam-4096 --from-file="${1}"

View File

@ -0,0 +1,4 @@
#!/bin/bash
kubectl -n atat delete secret atst-nginx-htpasswd
kubectl -n atat create secret generic atst-nginx-htpasswd --from-file="${1}"

View File

@ -9,5 +9,8 @@ source "$(dirname "${0}")"/../script/include/global_header.inc.sh
APP_USER="atst"
APP_UID="8010"
# Add additional packages required by app dependencies
ADDITIONAL_PACKAGES="postgresql-libs python3 uwsgi uwsgi-python3"
# Run the shared alpine setup script
source ./script/include/run_alpine_setup

@ -1 +1 @@
Subproject commit 8cf96c9776e7fd73c11d57160d26fc1715bf00da
Subproject commit c44ca5070da78fd522a2e485aaa225cc638e11d3

37
script/seed.py Normal file
View File

@ -0,0 +1,37 @@
# Add root project dir to the python path
import os
import sys
parent_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
sys.path.append(parent_dir)
from atst.app import make_config, make_app
from atst.domain.users import Users
from atst.domain.requests import Requests
from atst.domain.exceptions import AlreadyExistsError
from tests.factories import RequestFactory
from atst.routes.dev import _DEV_USERS as DEV_USERS
def seed_db():
users = []
for dev_user in DEV_USERS.values():
try:
user = Users.create(**dev_user)
users.append(user)
except AlreadyExistsError:
pass
for user in users:
for dollar_value in [1, 200, 3000, 40000, 500000, 1000000]:
request = Requests.create(
user, RequestFactory.build_request_body(user, dollar_value)
)
Requests.submit(request)
if __name__ == "__main__":
config = make_config()
app = make_app(config)
with app.app_context():
seed_db()

View File

@ -5,9 +5,6 @@
source "$(dirname "${0}")"/../script/include/global_header.inc.sh
# Turn on sass compiler installation
INSTALL_SASS="true"
# Enable database resetting
RESET_DB="true"
@ -16,3 +13,6 @@ source ./script/include/run_setup
# Fetch and import the PE numbers
run_command "python script/ingest_pe_numbers.py"
# Compile assets and generate hash-named static files
yarn build

View File

@ -10,7 +10,7 @@ mkdir -p crl
rsync -rq crl-tmp/. crl/.
rm -rf crl-tmp
if [[ $FLASK_ENV != "production" ]]; then
if [[ $FLASK_ENV != "prod" ]]; then
# place our test CRL there
cp ssl/client-certs/client-ca.der.crl crl/
fi

View File

@ -9,7 +9,7 @@ echo "Resetting CA bundle..."
rm ssl/server-certs/ca-chain.pem &> /dev/null || true
touch $CA_CHAIN
if [[ $FLASK_ENV != "production" ]]; then
if [[ $FLASK_ENV != "prod" ]]; then
# only for testing and development
echo "Copy in testing client CA..."
cat ssl/client-certs/client-ca.crt >> $CA_CHAIN

11
script/uwsgi_server Executable file
View File

@ -0,0 +1,11 @@
#!/bin/bash
# script/uwsgi_server: Launch the UWSGI server
source "$(dirname "${0}")"/../script/include/global_header.inc.sh
# Before starting the server, apply any pending migrations to the DB
migrate_db
# Launch UWSGI
run_command "uwsgi --ini ${UWSGI_CONFIG_FULLPATH}"

View File

@ -65,20 +65,12 @@
}
label {
padding: 0 0 $gap 0;
padding: 0 0 $gap/2 0;
margin: 0;
@include h4;
@include line-max;
position: relative;
.usa-input__help {
display: block;
@include h5;
font-weight: normal;
padding-top: $gap / 2;
@include line-max;
}
.icon {
position: absolute;
left: 100%;
@ -88,6 +80,14 @@
}
}
.usa-input__help {
display: block;
@include h4;
font-weight: normal;
padding: $gap/2 0;
@include line-max;
}
input,
textarea,
select {
@ -100,9 +100,14 @@
padding: 0 0 $gap 0;
@include h4;
label {
font-weight: $font-bold;
}
.icon {
vertical-align: middle;
}
}
ul {

View File

@ -20,6 +20,7 @@ h1, h2, h3, h4, h5, h6 {
+ .subtitle * {
margin-top: 0;
color: $color-gray;
}
}

View File

@ -1,11 +1,11 @@
{% from "components/icon.html" import Icon %}
{% macro OptionsInput(field, inline=False) -%}
<div class='usa-input {% if errors %}usa-input--error{% endif %}'>
<div class='usa-input {% if field.errors %}usa-input--error{% endif %}'>
<fieldset class="usa-input__choices {% if inline %}usa-input__choices--inline{% endif %}">
<legend>
{{ field.label }}
{{ field.label | striptags}}
{% if field.description %}
<span class='usa-input__help'>{{ field.description | safe }}</span>
@ -26,4 +26,7 @@
</fieldset>
</div>
{%- endmacro %}

View File

@ -17,7 +17,7 @@
<span class='usa-input__help'>{{ field.description | safe }}</span>
{% endif %}
{% if errors %}
{% if field.errors %}
{{ Icon('alert') }}
{% endif %}
@ -54,6 +54,7 @@
placeholder='{{ placeholder }}'
{% if field.errors %}aria-invalid='true'{% endif %}>
</masked-input>
{% endif %}
{% if field.errors %}

View File

@ -4,7 +4,7 @@
<main class="usa-section usa-content">
<h1>Unauthorized</h1>
<h1>Not Found</h1>
</main>

View File

@ -88,7 +88,7 @@
{% for r in requests %}
<tr>
<th scope="row">
<a class='icon-link icon-link--large' href="{{ url_for('requests.requests_form_update', screen=1, request_id=r['order_id']) if r["status"] != "approved" else url_for('requests.financial_verification', request_id=r['order_id']) }}">{{ r['order_id'] }}</a>
<a class='icon-link icon-link--large' href="{{ r['edit_link'] }}">{{ r['order_id'] }}</a>
{% if r['is_new'] %}<span class="usa-label">New</span>
</th>
{% endif %}

View File

@ -1,4 +1,4 @@
{% extends "../base.html.to" %}
{% extends "base.html" %}
{% block content %}
@ -15,4 +15,4 @@
</div>
</div>
{% end %}
{% endblock %}

View File

@ -1,13 +1,21 @@
<div class="progress-menu progress-menu--four">
<ul>
{% for s in screens %}
<li class="progress-menu__item">
<a href="{{ url_for('requests.requests_form_update', screen=loop.index, request_id=request_id) if request_id else url_for('requests.requests_form_new', screen=loop.index) }}"
{% if g.matchesPath('/requests/new/{{ loop.index + 1 }}') %}class="active"{% endif %}
>
{{ s['title'] }}
</a>
</li>
{% if loop.index < current %}
{% set step_indicator = 'complete' %}
{% elif loop.index == current %}
{% set step_indicator = 'active' %}
{% else %}
{% set step_indicator = 'incomplete' %}
{% endif %}
<li class="progress-menu__item progress-menu__item--{{ step_indicator }}">
<a href="{{ url_for('requests.requests_form_update', screen=loop.index, request_id=request_id) if request_id else url_for('requests.requests_form_new', screen=loop.index) }}"
{% if g.matchesPath('/requests/new/{{ loop.index + 1 }}') %}class="active"{% endif %}
>
{{ s['title'] }}
</a>
</li>
{% endfor %}
</ul>
</div>

View File

@ -0,0 +1,12 @@
{% extends "error_base.html" %}
{% block content %}
<main class="usa-section usa-content">
<h1>Log in Failed</h1>
</main>
{% endblock %}

View File

@ -5,10 +5,10 @@ import alembic.command
from atst.app import make_app, make_config
from atst.database import db as _db
from .mocks import MOCK_USER
import tests.factories as factories
@pytest.fixture(scope='session')
@pytest.fixture(scope="session")
def app(request):
config = make_config()
@ -27,11 +27,11 @@ def apply_migrations():
alembic_config = os.path.join(os.path.dirname(__file__), "../", "alembic.ini")
config = alembic.config.Config(alembic_config)
app_config = make_config()
config.set_main_option('sqlalchemy.url', app_config["DATABASE_URI"])
alembic.command.upgrade(config, 'head')
config.set_main_option("sqlalchemy.url", app_config["DATABASE_URI"])
alembic.command.upgrade(config, "head")
@pytest.fixture(scope='session')
@pytest.fixture(scope="session")
def db(app, request):
_db.app = app
@ -43,7 +43,7 @@ def db(app, request):
_db.drop_all()
@pytest.fixture(scope='function', autouse=True)
@pytest.fixture(scope="function", autouse=True)
def session(db, request):
"""Creates a new database session for a test."""
connection = db.engine.connect()
@ -54,6 +54,14 @@ def session(db, request):
db.session = session
factory_list = [
cls
for _name, cls in factories.__dict__.items()
if isinstance(cls, type) and cls.__module__ == "tests.factories"
]
for factory in factory_list:
factory._meta.sqlalchemy_session = session
yield session
transaction.rollback()
@ -81,10 +89,13 @@ def dummy_form():
def dummy_field():
return DummyField()
@pytest.fixture
def user_session(monkeypatch):
def set_user_session(user = MOCK_USER):
monkeypatch.setattr("atst.domain.auth.get_current_user", lambda *args: user)
@pytest.fixture
def user_session(monkeypatch, session):
def set_user_session(user=None):
monkeypatch.setattr(
"atst.domain.auth.get_current_user",
lambda *args: user or factories.UserFactory.build(),
)
return set_user_session

View File

@ -6,20 +6,8 @@ from atst.domain.pe_numbers import PENumbers
from tests.factories import PENumberFactory
@pytest.fixture(scope="function")
def new_pe_number(session):
def make_pe_number(**kwargs):
pen = PENumberFactory.create(**kwargs)
session.add(pen)
session.commit()
return pen
return make_pe_number
def test_can_get_pe_number(new_pe_number):
new_pen = new_pe_number(number="0701367F", description="Combat Support - Offensive")
def test_can_get_pe_number():
new_pen = PENumberFactory.create(number="0701367F", description="Combat Support - Offensive")
pen = PENumbers.get(new_pen.number)
assert pen.number == new_pen.number

View File

@ -3,17 +3,14 @@ from uuid import uuid4
from atst.domain.exceptions import NotFoundError
from atst.domain.requests import Requests
from atst.models.request_status_event import RequestStatus
from tests.factories import RequestFactory
from tests.factories import RequestFactory, UserFactory
@pytest.fixture(scope="function")
def new_request(session):
created_request = RequestFactory.create()
session.add(created_request)
session.commit()
return created_request
return RequestFactory.create()
def test_can_get_request(new_request):
@ -27,22 +24,42 @@ def test_nonexistent_request_raises():
Requests.get(uuid4())
def test_new_request_has_started_status():
request = Requests.create(UserFactory.build(), {})
assert request.status == RequestStatus.STARTED
def test_auto_approve_less_than_1m(new_request):
new_request.body = {"details_of_use": {"dollar_value": 999999}}
request = Requests.submit(new_request)
assert request.status == 'approved'
assert request.status == RequestStatus.PENDING_FINANCIAL_VERIFICATION
def test_dont_auto_approve_if_dollar_value_is_1m_or_above(new_request):
new_request.body = {"details_of_use": {"dollar_value": 1000000}}
request = Requests.submit(new_request)
assert request.status == 'submitted'
assert request.status == RequestStatus.PENDING_CCPO_APPROVAL
def test_dont_auto_approve_if_no_dollar_value_specified(new_request):
new_request.body = {"details_of_use": {}}
request = Requests.submit(new_request)
assert request.status == 'submitted'
assert request.status == RequestStatus.PENDING_CCPO_APPROVAL
def test_should_allow_submission(new_request):
assert Requests.should_allow_submission(new_request)
del new_request.body['details_of_use']
assert not Requests.should_allow_submission(new_request)
def test_exists(session):
user_allowed = UserFactory.create()
user_denied = UserFactory.create()
request = RequestFactory.create(creator=user_allowed)
assert Requests.exists(request.id, user_allowed)
assert not Requests.exists(request.id, user_denied)

View File

@ -6,20 +6,8 @@ from atst.domain.task_orders import TaskOrders
from tests.factories import TaskOrderFactory
@pytest.fixture(scope="function")
def new_task_order(session):
def make_task_order(**kwargs):
to = TaskOrderFactory.create(**kwargs)
session.add(to)
session.commit()
return to
return make_task_order
def test_can_get_task_order(new_task_order):
new_to = new_task_order(number="0101969F")
def test_can_get_task_order():
new_to = TaskOrderFactory.create(number="0101969F")
to = TaskOrders.get(new_to.number)
assert to.id == to.id

View File

@ -1,40 +1,102 @@
import random
import string
import factory
from uuid import uuid4
from atst.models import Request
from atst.models.request import Request
from atst.models.request_status_event import RequestStatusEvent, RequestStatus
from atst.models.pe_number import PENumber
from atst.models.task_order import TaskOrder
from atst.models.user import User
from atst.models.role import Role
from atst.models.request_status_event import RequestStatusEvent
from atst.domain.roles import Roles
class RequestFactory(factory.Factory):
class Meta:
model = Request
id = factory.Sequence(lambda x: uuid4())
class PENumberFactory(factory.Factory):
class Meta:
model = PENumber
class TaskOrderFactory(factory.Factory):
class Meta:
model = TaskOrder
class RoleFactory(factory.Factory):
class RoleFactory(factory.alchemy.SQLAlchemyModelFactory):
class Meta:
model = Role
permissions = []
class UserFactory(factory.Factory):
class UserFactory(factory.alchemy.SQLAlchemyModelFactory):
class Meta:
model = User
id = factory.Sequence(lambda x: uuid4())
email = "fake.user@mail.com"
first_name = "Fake"
last_name = "User"
email = factory.Faker("email")
first_name = factory.Faker("first_name")
last_name = factory.Faker("last_name")
atat_role = factory.SubFactory(RoleFactory)
dod_id = factory.LazyFunction(lambda: "".join(random.choices(string.digits, k=10)))
class RequestStatusEventFactory(factory.alchemy.SQLAlchemyModelFactory):
class Meta:
model = RequestStatusEvent
id = factory.Sequence(lambda x: uuid4())
class RequestFactory(factory.alchemy.SQLAlchemyModelFactory):
class Meta:
model = Request
id = factory.Sequence(lambda x: uuid4())
status_events = factory.RelatedFactory(
RequestStatusEventFactory, "request", new_status=RequestStatus.STARTED
)
creator = factory.SubFactory(UserFactory)
body = factory.LazyAttribute(lambda r: RequestFactory.build_request_body(r.creator))
@classmethod
def build_request_body(cls, user, dollar_value=1000000):
return {
"primary_poc": {
"dodid_poc": user.dod_id,
"email_poc": user.email,
"fname_poc": user.first_name,
"lname_poc": user.last_name
},
"details_of_use": {
"jedi_usage": "adf",
"start_date": "2018-08-08",
"cloud_native": "yes",
"dollar_value": dollar_value,
"dod_component": "us_navy",
"data_transfers": "less_than_100gb",
"jedi_migration": "yes",
"num_software_systems": 1,
"number_user_sessions": 2,
"average_daily_traffic": 1,
"engineering_assessment": "yes",
"technical_support_team": "yes",
"estimated_monthly_spend": 100,
"expected_completion_date": "less_than_1_month",
"rationalization_software_systems": "yes",
"organization_providing_assistance": "in_house_staff"
},
"information_about_you": {
"citizenship": "United States",
"designation": "military",
"phone_number": "1234567890",
"email_request": user.email,
"fname_request": user.first_name,
"lname_request": user.last_name,
"service_branch": "ads",
"date_latest_training": "2018-08-06"
}
}
class PENumberFactory(factory.alchemy.SQLAlchemyModelFactory):
class Meta:
model = PENumber
class TaskOrderFactory(factory.alchemy.SQLAlchemyModelFactory):
class Meta:
model = TaskOrder

View File

@ -1,8 +1,8 @@
from tests.factories import RequestFactory, UserFactory
MOCK_USER = UserFactory.create()
MOCK_REQUEST = RequestFactory.create(
MOCK_USER = UserFactory.build()
MOCK_REQUEST = RequestFactory.build(
creator=MOCK_USER.id,
body={
"financial_verification": {

0
tests/models/__init__.py Normal file
View File

View File

@ -0,0 +1,70 @@
from tests.factories import RequestFactory, UserFactory
from atst.domain.requests import Requests, RequestStatus
def test_started_request_requires_mo_action():
request = RequestFactory.create()
assert Requests.action_required_by(request) == "mission_owner"
def test_pending_financial_requires_mo_action():
request = RequestFactory.create()
request = Requests.set_status(request, RequestStatus.PENDING_FINANCIAL_VERIFICATION)
assert Requests.action_required_by(request) == "mission_owner"
def test_pending_ccpo_approval_requires_ccpo():
request = RequestFactory.create()
request = Requests.set_status(request, RequestStatus.PENDING_CCPO_APPROVAL)
assert Requests.action_required_by(request) == "ccpo"
def test_request_has_creator():
user = UserFactory.create()
request = RequestFactory.create(creator=user)
assert request.creator == user
def test_request_status_started_displayname():
request = RequestFactory.create()
request = Requests.set_status(request, RequestStatus.STARTED)
assert request.status_displayname == "Started"
def test_request_status_pending_financial_displayname():
request = RequestFactory.create()
request = Requests.set_status(request, RequestStatus.PENDING_FINANCIAL_VERIFICATION)
assert request.status_displayname == "Pending Financial Verification"
def test_request_status_pending_ccpo_displayname():
request = RequestFactory.create()
request = Requests.set_status(request, RequestStatus.PENDING_CCPO_APPROVAL)
assert request.status_displayname == "Pending CCPO Approval"
def test_request_status_pending_approved_displayname():
request = RequestFactory.create()
request = Requests.set_status(request, RequestStatus.APPROVED)
assert request.status_displayname == "Approved"
def test_request_status_pending_expired_displayname():
request = RequestFactory.create()
request = Requests.set_status(request, RequestStatus.EXPIRED)
assert request.status_displayname == "Expired"
def test_request_status_pending_deleted_displayname():
request = RequestFactory.create()
request = Requests.set_status(request, RequestStatus.DELETED)
assert request.status_displayname == "Deleted"

View File

@ -61,11 +61,9 @@ class TestPENumberInForm:
assert response.status_code == 302
assert "/requests/financial_verification_submitted" in response.headers.get("Location")
def test_submit_request_form_with_new_valid_pe_id(self, session, monkeypatch, client):
def test_submit_request_form_with_new_valid_pe_id(self, monkeypatch, client):
self._set_monkeypatches(monkeypatch)
pe = PENumberFactory.create(number="8675309U", description="sample PE number")
session.add(pe)
session.commit()
data = dict(self.required_data)
data['pe_id'] = pe.number
@ -74,3 +72,14 @@ class TestPENumberInForm:
assert response.status_code == 302
assert "/requests/financial_verification_submitted" in response.headers.get("Location")
def test_submit_request_form_with_missing_pe_id(self, monkeypatch, client):
self._set_monkeypatches(monkeypatch)
data = dict(self.required_data)
data['pe_id'] = ''
response = self.submit_data(client, data)
assert "There were some errors, see below" in response.data.decode()
assert response.status_code == 200

View File

@ -2,7 +2,8 @@ import re
import pytest
import urllib
from tests.mocks import MOCK_USER, MOCK_REQUEST
from tests.factories import RequestFactory
from tests.factories import RequestFactory, UserFactory
from atst.domain.roles import Roles
ERROR_CLASS = "alert--error"
@ -27,3 +28,41 @@ def test_submit_valid_request_form(monkeypatch, client, user_session):
data="meaning=42",
)
assert "/requests/new/2" in response.headers.get("Location")
def test_owner_can_view_request(client, user_session):
user = UserFactory.create()
user_session(user)
request = RequestFactory.create(creator=user)
response = client.get("/requests/new/1/{}".format(request.id), follow_redirects=True)
assert response.status_code == 200
def test_non_owner_cannot_view_request(client, user_session):
user = UserFactory.create()
user_session(user)
request = RequestFactory.create()
response = client.get("/requests/new/1/{}".format(request.id), follow_redirects=True)
assert response.status_code == 404
def test_ccpo_can_view_request(client, user_session):
ccpo = Roles.get("ccpo")
user = UserFactory.create(atat_role=ccpo)
user_session(user)
request = RequestFactory.create()
response = client.get("/requests/new/1/{}".format(request.id), follow_redirects=True)
assert response.status_code == 200
def test_nonexistent_request(client, user_session):
user_session()
response = client.get("/requests/new/1/foo", follow_redirects=True)
assert response.status_code == 404

View File

@ -27,8 +27,7 @@ def test_successful_login_redirect(client, monkeypatch):
def test_unsuccessful_login_redirect(client, monkeypatch):
resp = client.get("/login-redirect")
assert resp.status_code == 302
assert "unauthorized" in resp.headers["Location"]
assert resp.status_code == 401
assert "user_id" not in session
@ -55,7 +54,6 @@ def test_routes_are_protected(client, app):
UNPROTECTED_ROUTES = ["/", "/login-dev", "/login-redirect", "/unauthorized"]
# this implicitly relies on the test config and test CRL in tests/fixtures/crl
@ -72,8 +70,7 @@ def test_crl_validation_on_login(client):
"HTTP_X_SSL_CLIENT_CERT": bad_cert.decode(),
},
)
assert resp.status_code == 302
assert "unauthorized" in resp.headers["Location"]
assert resp.status_code == 401
assert "user_id" not in session
# good cert is not on the test CRL, passes