diff --git a/.dockerignore b/.dockerignore index e9ff2bac..8bad9296 100644 --- a/.dockerignore +++ b/.dockerignore @@ -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 diff --git a/Pipfile.lock b/Pipfile.lock index b9b8cab6..7f8a00cb 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -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" }, diff --git a/alembic/versions/05d6272bdb43_rename_request_creator_.py b/alembic/versions/05d6272bdb43_rename_request_creator_.py new file mode 100644 index 00000000..c8a3966e --- /dev/null +++ b/alembic/versions/05d6272bdb43_rename_request_creator_.py @@ -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') diff --git a/alembic/versions/4ea5917e7781_add_default_atat_role.py b/alembic/versions/4ea5917e7781_add_default_atat_role.py index 78b6ef55..21b03166 100644 --- a/alembic/versions/4ea5917e7781_add_default_atat_role.py +++ b/alembic/versions/4ea5917e7781_add_default_atat_role.py @@ -34,6 +34,4 @@ def upgrade(): def downgrade(): - db = op.get_bind() - db.execute("DELETE FROM roles WHERE name = 'default'") - + pass diff --git a/alembic/versions/77b065750596_new_request_statuses.py b/alembic/versions/77b065750596_new_request_statuses.py new file mode 100644 index 00000000..23b22c29 --- /dev/null +++ b/alembic/versions/77b065750596_new_request_statuses.py @@ -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 diff --git a/alembic/versions/96a9f3537996_add_roles_and_permissions.py b/alembic/versions/96a9f3537996_add_roles_and_permissions.py index 4380208a..0729127e 100644 --- a/alembic/versions/96a9f3537996_add_roles_and_permissions.py +++ b/alembic/versions/96a9f3537996_add_roles_and_permissions.py @@ -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 diff --git a/atst/app.py b/atst/app.py index 4b7a0806..d095a8bf 100644 --- a/atst/app.py +++ b/atst/app.py @@ -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) diff --git a/atst/domain/exceptions.py b/atst/domain/exceptions.py index 802997d4..ec574232 100644 --- a/atst/domain/exceptions.py +++ b/atst/domain/exceptions.py @@ -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) diff --git a/atst/domain/requests.py b/atst/domain/requests.py index 5c04d3c7..3986a849 100644 --- a/atst/domain/requests.py +++ b/atst/domain/requests.py @@ -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 diff --git a/atst/domain/users.py b/atst/domain/users.py index 54cf4ae0..bc3d972f 100644 --- a/atst/domain/users.py +++ b/atst/domain/users.py @@ -37,6 +37,7 @@ class Users(object): db.session.add(user) db.session.commit() except IntegrityError: + db.session.rollback() raise AlreadyExistsError("user") return user diff --git a/atst/forms/fields.py b/atst/forms/fields.py index bc542bf5..00e53529 100644 --- a/atst/forms/fields.py +++ b/atst/forms/fields.py @@ -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 "" diff --git a/atst/forms/financial.py b/atst/forms/financial.py index 68196837..674eace4 100644 --- a/atst/forms/financial.py +++ b/atst/forms/financial.py @@ -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") diff --git a/atst/forms/request.py b/atst/forms/request.py index f15e9140..fb179587 100644 --- a/atst/forms/request.py +++ b/atst/forms/request.py @@ -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")], ) diff --git a/atst/models/request.py b/atst/models/request.py index e8690332..bd14b5db 100644 --- a/atst/models/request.py +++ b/atst/models/request.py @@ -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 diff --git a/atst/models/request_status_event.py b/atst/models/request_status_event.py index 4f65a2ec..81505f2a 100644 --- a/atst/models/request_status_event.py +++ b/atst/models/request_status_event.py @@ -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 diff --git a/atst/routes/__init__.py b/atst/routes/__init__.py index 1884d28f..965b4b37 100644 --- a/atst/routes/__init__.py +++ b/atst/routes/__init__.py @@ -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): diff --git a/atst/routes/dev.py b/atst/routes/dev.py index cbd02cea..f66e3f08 100644 --- a/atst/routes/dev.py +++ b/atst/routes/dev.py @@ -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) diff --git a/atst/routes/errors.py b/atst/routes/errors.py new file mode 100644 index 00000000..5fdac3d1 --- /dev/null +++ b/atst/routes/errors.py @@ -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 diff --git a/atst/routes/requests/financial_verification.py b/atst/routes/requests/financial_verification.py index 38420287..f6b8cfda 100644 --- a/atst/routes/requests/financial_verification.py +++ b/atst/routes/requests/financial_verification.py @@ -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") diff --git a/atst/routes/requests/index.py b/atst/routes/requests/index.py index 7e098b47..5e9d26ca 100644 --- a/atst/routes/requests/index.py +++ b/atst/routes/requests/index.py @@ -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) diff --git a/atst/routes/requests/jedi_request_flow.py b/atst/routes/requests/jedi_request_flow.py index e750a9e0..1a3a7163 100644 --- a/atst/routes/requests/jedi_request_flow.py +++ b/atst/routes/requests/jedi_request_flow.py @@ -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 diff --git a/atst/routes/requests/requests_form.py b/atst/routes/requests/requests_form.py index 63a14224..d384abdf 100644 --- a/atst/routes/requests/requests_form.py +++ b/atst/routes/requests/requests_form.py @@ -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/", methods=["GET"]) @@ -25,6 +27,9 @@ def requests_form_new(screen): ) @requests_bp.route("/requests/new//", 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)) + diff --git a/config/base.ini b/config/base.ini index f256fa3a..749b9b2b 100644 --- a/config/base.ini +++ b/config/base.ini @@ -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 diff --git a/config/prod.ini b/config/prod.ini index fbaaa394..bbbf8f8b 100644 --- a/config/prod.ini +++ b/config/prod.ini @@ -1,2 +1,3 @@ [default] SESSION_COOKIE_SECURE=True +SESSION_COOKIE_DOMAIN=atat.codes diff --git a/deploy/docker/prod/Dockerfile b/deploy/docker/prod/Dockerfile index ea4bdbcc..37a36859 100644 --- a/deploy/docker/prod/Dockerfile +++ b/deploy/docker/prod/Dockerfile @@ -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}" diff --git a/deploy/kubernetes/atst-configmap.yml b/deploy/kubernetes/atst-configmap.yml new file mode 100644 index 00000000..a9584fc5 --- /dev/null +++ b/deploy/kubernetes/atst-configmap.yml @@ -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 diff --git a/deploy/kubernetes/atst-envvars-configmap.yml b/deploy/kubernetes/atst-envvars-configmap.yml new file mode 100644 index 00000000..e7bfec14 --- /dev/null +++ b/deploy/kubernetes/atst-envvars-configmap.yml @@ -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 diff --git a/deploy/kubernetes/atst-nginx-configmap.yml b/deploy/kubernetes/atst-nginx-configmap.yml new file mode 100644 index 00000000..6e2b1d69 --- /dev/null +++ b/deploy/kubernetes/atst-nginx-configmap.yml @@ -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; + } + } diff --git a/deploy/kubernetes/atst.yml b/deploy/kubernetes/atst.yml new file mode 100644 index 00000000..c302f8af --- /dev/null +++ b/deploy/kubernetes/atst.yml @@ -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 diff --git a/deploy/kubernetes/set_atstconfig_secret.sh b/deploy/kubernetes/set_atstconfig_secret.sh new file mode 100755 index 00000000..0dcb90b0 --- /dev/null +++ b/deploy/kubernetes/set_atstconfig_secret.sh @@ -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}" diff --git a/deploy/kubernetes/set_dhparam_secret.sh b/deploy/kubernetes/set_dhparam_secret.sh new file mode 100755 index 00000000..dfc9401a --- /dev/null +++ b/deploy/kubernetes/set_dhparam_secret.sh @@ -0,0 +1,4 @@ +#!/bin/bash + +kubectl -n atat delete secret dhparam-4096 +kubectl -n atat create secret generic dhparam-4096 --from-file="${1}" diff --git a/deploy/kubernetes/set_htpasswd_secret.sh b/deploy/kubernetes/set_htpasswd_secret.sh new file mode 100755 index 00000000..540048ca --- /dev/null +++ b/deploy/kubernetes/set_htpasswd_secret.sh @@ -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}" diff --git a/script/alpine_setup b/script/alpine_setup index 28f836c2..b9eeb9a7 100755 --- a/script/alpine_setup +++ b/script/alpine_setup @@ -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 diff --git a/script/include b/script/include index 8cf96c97..c44ca507 160000 --- a/script/include +++ b/script/include @@ -1 +1 @@ -Subproject commit 8cf96c9776e7fd73c11d57160d26fc1715bf00da +Subproject commit c44ca5070da78fd522a2e485aaa225cc638e11d3 diff --git a/script/seed.py b/script/seed.py new file mode 100644 index 00000000..d865b9d7 --- /dev/null +++ b/script/seed.py @@ -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() diff --git a/script/setup b/script/setup index 23a2fce3..1e9c872e 100755 --- a/script/setup +++ b/script/setup @@ -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 diff --git a/script/sync-crls b/script/sync-crls index 93ec6772..3c02ac93 100755 --- a/script/sync-crls +++ b/script/sync-crls @@ -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 diff --git a/script/sync-dod-certs b/script/sync-dod-certs index 043629c1..9d7263d8 100755 --- a/script/sync-dod-certs +++ b/script/sync-dod-certs @@ -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 diff --git a/script/uwsgi_server b/script/uwsgi_server new file mode 100755 index 00000000..275b3b93 --- /dev/null +++ b/script/uwsgi_server @@ -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}" diff --git a/styles/elements/_inputs.scss b/styles/elements/_inputs.scss index 459e907d..eff35e34 100644 --- a/styles/elements/_inputs.scss +++ b/styles/elements/_inputs.scss @@ -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 { diff --git a/styles/elements/_typography.scss b/styles/elements/_typography.scss index fcdc97d9..b1af1be1 100644 --- a/styles/elements/_typography.scss +++ b/styles/elements/_typography.scss @@ -20,6 +20,7 @@ h1, h2, h3, h4, h5, h6 { + .subtitle * { margin-top: 0; + color: $color-gray; } } diff --git a/templates/components/options_input.html b/templates/components/options_input.html index ff0d8814..dbd01947 100644 --- a/templates/components/options_input.html +++ b/templates/components/options_input.html @@ -1,11 +1,11 @@ {% from "components/icon.html" import Icon %} {% macro OptionsInput(field, inline=False) -%} -
+
- {{ field.label }} + {{ field.label | striptags}} {% if field.description %} {{ field.description | safe }} @@ -26,4 +26,7 @@
+ + + {%- endmacro %} diff --git a/templates/components/text_input.html b/templates/components/text_input.html index b4c990a9..189504ed 100644 --- a/templates/components/text_input.html +++ b/templates/components/text_input.html @@ -17,7 +17,7 @@ {{ field.description | safe }} {% endif %} - {% if errors %} + {% if field.errors %} {{ Icon('alert') }} {% endif %} @@ -54,6 +54,7 @@ placeholder='{{ placeholder }}' {% if field.errors %}aria-invalid='true'{% endif %}> + {% endif %} {% if field.errors %} diff --git a/templates/unauthorized.html b/templates/not_found.html similarity index 84% rename from templates/unauthorized.html rename to templates/not_found.html index efaa3b95..59cc223f 100644 --- a/templates/unauthorized.html +++ b/templates/not_found.html @@ -4,7 +4,7 @@
-

Unauthorized

+

Not Found

diff --git a/templates/requests.html b/templates/requests.html index 17993dfa..25520ac2 100644 --- a/templates/requests.html +++ b/templates/requests.html @@ -88,7 +88,7 @@ {% for r in requests %} - {{ r['order_id'] }} + {{ r['order_id'] }} {% if r['is_new'] %}New {% endif %} diff --git a/templates/requests/financial_verification_submitted.html.to b/templates/requests/financial_verification_submitted.html similarity index 84% rename from templates/requests/financial_verification_submitted.html.to rename to templates/requests/financial_verification_submitted.html index 21088724..01d6ef89 100644 --- a/templates/requests/financial_verification_submitted.html.to +++ b/templates/requests/financial_verification_submitted.html @@ -1,4 +1,4 @@ -{% extends "../base.html.to" %} +{% extends "base.html" %} {% block content %} @@ -15,4 +15,4 @@
-{% end %} +{% endblock %} diff --git a/templates/requests/menu.html b/templates/requests/menu.html index 24b28d4f..d62de246 100644 --- a/templates/requests/menu.html +++ b/templates/requests/menu.html @@ -1,13 +1,21 @@
    {% for s in screens %} -
  • - - {{ s['title'] }} - -
  • + {% if loop.index < current %} + {% set step_indicator = 'complete' %} + {% elif loop.index == current %} + {% set step_indicator = 'active' %} + {% else %} + {% set step_indicator = 'incomplete' %} + {% endif %} + +
  • + + {{ s['title'] }} + +
  • {% endfor %}
diff --git a/templates/unauthenticated.html b/templates/unauthenticated.html new file mode 100644 index 00000000..8fabbdf9 --- /dev/null +++ b/templates/unauthenticated.html @@ -0,0 +1,12 @@ +{% extends "error_base.html" %} + +{% block content %} + +
+ +

Log in Failed

+ +
+ +{% endblock %} + diff --git a/tests/conftest.py b/tests/conftest.py index bdc59286..ab912679 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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 diff --git a/tests/domain/test_pe_numbers.py b/tests/domain/test_pe_numbers.py index 60c410b5..98b90470 100644 --- a/tests/domain/test_pe_numbers.py +++ b/tests/domain/test_pe_numbers.py @@ -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 diff --git a/tests/domain/test_requests.py b/tests/domain/test_requests.py index 901caf73..2044d52b 100644 --- a/tests/domain/test_requests.py +++ b/tests/domain/test_requests.py @@ -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) diff --git a/tests/domain/test_task_orders.py b/tests/domain/test_task_orders.py index 2f03a6d0..ba422bc5 100644 --- a/tests/domain/test_task_orders.py +++ b/tests/domain/test_task_orders.py @@ -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 diff --git a/tests/factories.py b/tests/factories.py index 11b11a1b..cf81fa71 100644 --- a/tests/factories.py +++ b/tests/factories.py @@ -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 diff --git a/tests/mocks.py b/tests/mocks.py index 1e44f96e..0307997e 100644 --- a/tests/mocks.py +++ b/tests/mocks.py @@ -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": { diff --git a/tests/models/__init__.py b/tests/models/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/models/test_requests.py b/tests/models/test_requests.py new file mode 100644 index 00000000..d6592a25 --- /dev/null +++ b/tests/models/test_requests.py @@ -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" diff --git a/tests/routes/test_financial_verification.py b/tests/routes/test_financial_verification.py index 60f0f9b0..8e62cc48 100644 --- a/tests/routes/test_financial_verification.py +++ b/tests/routes/test_financial_verification.py @@ -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 diff --git a/tests/routes/test_request_new.py b/tests/routes/test_request_new.py index 0927f1be..e31aae79 100644 --- a/tests/routes/test_request_new.py +++ b/tests/routes/test_request_new.py @@ -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 diff --git a/tests/test_auth.py b/tests/test_auth.py index 69cb3166..7e2b483d 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -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