Merge branch 'master' into request_form_styles

This commit is contained in:
luisgov 2018-07-17 14:46:42 -04:00 committed by GitHub
commit cbe20298af
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
37 changed files with 716 additions and 286 deletions

156
.bandit_config Normal file
View File

@ -0,0 +1,156 @@
### This config may optionally select a subset of tests to run or skip by
### filling out the 'tests' and 'skips' lists given below. If no tests are
### specified for inclusion then it is assumed all tests are desired. The skips
### set will remove specific tests from the include set.
### Note that the same test ID should not appear in both 'tests' and 'skips',
### this would be nonsensical and is detected by Bandit at runtime.
# (optional) list included test IDs here, eg '[B101, B406]':
tests:
# (optional) list skipped test IDs here, eg '[B101, B406]':
skips:
### (optional) plugin settings - some test plugins require configuration data
### that may be given here, per-plugin. All bandit test plugins have a built in
### set of sensible defaults and these will be used if no configuration is
### provided. It is not necessary to provide settings for every (or any) plugin
### if the defaults are acceptable.
any_other_function_with_shell_equals_true:
no_shell: [os.execl, os.execle, os.execlp, os.execlpe, os.execv, os.execve, os.execvp,
os.execvpe, os.spawnl, os.spawnle, os.spawnlp, os.spawnlpe, os.spawnv, os.spawnve,
os.spawnvp, os.spawnvpe, os.startfile]
shell: [os.system, os.popen, os.popen2, os.popen3, os.popen4, popen2.popen2, popen2.popen3,
popen2.popen4, popen2.Popen3, popen2.Popen4, commands.getoutput, commands.getstatusoutput]
subprocess: [subprocess.Popen, subprocess.call, subprocess.check_call, subprocess.check_output,
utils.execute, utils.execute_with_timeout]
execute_with_run_as_root_equals_true:
function_names: [ceilometer.utils.execute, cinder.utils.execute, neutron.agent.linux.utils.execute,
nova.utils.execute, nova.utils.trycmd]
hardcoded_tmp_directory:
tmp_dirs: [/tmp, /var/tmp, /dev/shm]
linux_commands_wildcard_injection:
no_shell: [os.execl, os.execle, os.execlp, os.execlpe, os.execv, os.execve, os.execvp,
os.execvpe, os.spawnl, os.spawnle, os.spawnlp, os.spawnlpe, os.spawnv, os.spawnve,
os.spawnvp, os.spawnvpe, os.startfile]
shell: [os.system, os.popen, os.popen2, os.popen3, os.popen4, popen2.popen2, popen2.popen3,
popen2.popen4, popen2.Popen3, popen2.Popen4, commands.getoutput, commands.getstatusoutput]
subprocess: [subprocess.Popen, subprocess.call, subprocess.check_call, subprocess.check_output,
utils.execute, utils.execute_with_timeout]
password_config_option_not_marked_secret:
function_names: [oslo.config.cfg.StrOpt, oslo_config.cfg.StrOpt]
ssl_with_bad_defaults:
bad_protocol_versions: [PROTOCOL_SSLv2, SSLv2_METHOD, SSLv23_METHOD, PROTOCOL_SSLv3,
PROTOCOL_TLSv1, SSLv3_METHOD, TLSv1_METHOD]
ssl_with_bad_version:
bad_protocol_versions: [PROTOCOL_SSLv2, SSLv2_METHOD, SSLv23_METHOD, PROTOCOL_SSLv3,
PROTOCOL_TLSv1, SSLv3_METHOD, TLSv1_METHOD]
start_process_with_a_shell:
no_shell: [os.execl, os.execle, os.execlp, os.execlpe, os.execv, os.execve, os.execvp,
os.execvpe, os.spawnl, os.spawnle, os.spawnlp, os.spawnlpe, os.spawnv, os.spawnve,
os.spawnvp, os.spawnvpe, os.startfile]
shell: [os.system, os.popen, os.popen2, os.popen3, os.popen4, popen2.popen2, popen2.popen3,
popen2.popen4, popen2.Popen3, popen2.Popen4, commands.getoutput, commands.getstatusoutput]
subprocess: [subprocess.Popen, subprocess.call, subprocess.check_call, subprocess.check_output,
utils.execute, utils.execute_with_timeout]
start_process_with_no_shell:
no_shell: [os.execl, os.execle, os.execlp, os.execlpe, os.execv, os.execve, os.execvp,
os.execvpe, os.spawnl, os.spawnle, os.spawnlp, os.spawnlpe, os.spawnv, os.spawnve,
os.spawnvp, os.spawnvpe, os.startfile]
shell: [os.system, os.popen, os.popen2, os.popen3, os.popen4, popen2.popen2, popen2.popen3,
popen2.popen4, popen2.Popen3, popen2.Popen4, commands.getoutput, commands.getstatusoutput]
subprocess: [subprocess.Popen, subprocess.call, subprocess.check_call, subprocess.check_output,
utils.execute, utils.execute_with_timeout]
start_process_with_partial_path:
no_shell: [os.execl, os.execle, os.execlp, os.execlpe, os.execv, os.execve, os.execvp,
os.execvpe, os.spawnl, os.spawnle, os.spawnlp, os.spawnlpe, os.spawnv, os.spawnve,
os.spawnvp, os.spawnvpe, os.startfile]
shell: [os.system, os.popen, os.popen2, os.popen3, os.popen4, popen2.popen2, popen2.popen3,
popen2.popen4, popen2.Popen3, popen2.Popen4, commands.getoutput, commands.getstatusoutput]
subprocess: [subprocess.Popen, subprocess.call, subprocess.check_call, subprocess.check_output,
utils.execute, utils.execute_with_timeout]
subprocess_popen_with_shell_equals_true:
no_shell: [os.execl, os.execle, os.execlp, os.execlpe, os.execv, os.execve, os.execvp,
os.execvpe, os.spawnl, os.spawnle, os.spawnlp, os.spawnlpe, os.spawnv, os.spawnve,
os.spawnvp, os.spawnvpe, os.startfile]
shell: [os.system, os.popen, os.popen2, os.popen3, os.popen4, popen2.popen2, popen2.popen3,
popen2.popen4, popen2.Popen3, popen2.Popen4, commands.getoutput, commands.getstatusoutput]
subprocess: [subprocess.Popen, subprocess.call, subprocess.check_call, subprocess.check_output,
utils.execute, utils.execute_with_timeout]
subprocess_without_shell_equals_true:
no_shell: [os.execl, os.execle, os.execlp, os.execlpe, os.execv, os.execve, os.execvp,
os.execvpe, os.spawnl, os.spawnle, os.spawnlp, os.spawnlpe, os.spawnv, os.spawnve,
os.spawnvp, os.spawnvpe, os.startfile]
shell: [os.system, os.popen, os.popen2, os.popen3, os.popen4, popen2.popen2, popen2.popen3,
popen2.popen4, popen2.Popen3, popen2.Popen4, commands.getoutput, commands.getstatusoutput]
subprocess: [subprocess.Popen, subprocess.call, subprocess.check_call, subprocess.check_output,
utils.execute, utils.execute_with_timeout]
try_except_continue: {check_typed_exception: false}
try_except_pass: {check_typed_exception: false}
### Reference of Available tests:
# B101 : assert_used
# B102 : exec_used
# B103 : set_bad_file_permissions
# B104 : hardcoded_bind_all_interfaces
# B105 : hardcoded_password_string
# B106 : hardcoded_password_funcarg
# B107 : hardcoded_password_default
# B108 : hardcoded_tmp_directory
# B109 : password_config_option_not_marked_secret
# B110 : try_except_pass
# B111 : execute_with_run_as_root_equals_true
# B112 : try_except_continue
# B201 : flask_debug_true
# B301 : pickle
# B302 : marshal
# B303 : md5
# B304 : ciphers
# B305 : cipher_modes
# B306 : mktemp_q
# B307 : eval
# B308 : mark_safe
# B309 : httpsconnection
# B310 : urllib_urlopen
# B311 : random
# B312 : telnetlib
# B313 : xml_bad_cElementTree
# B314 : xml_bad_ElementTree
# B315 : xml_bad_expatreader
# B316 : xml_bad_expatbuilder
# B317 : xml_bad_sax
# B318 : xml_bad_minidom
# B319 : xml_bad_pulldom
# B320 : xml_bad_etree
# B321 : ftplib
# B322 : input
# B401 : import_telnetlib
# B402 : import_ftplib
# B403 : import_pickle
# B404 : import_subprocess
# B405 : import_xml_etree
# B406 : import_xml_sax
# B407 : import_xml_expat
# B408 : import_xml_minidom
# B409 : import_xml_pulldom
# B410 : import_lxml
# B411 : import_xmlrpclib
# B412 : import_httpoxy
# B501 : request_with_no_cert_validation
# B502 : ssl_with_bad_version
# B503 : ssl_with_bad_defaults
# B504 : ssl_with_no_version
# B505 : weak_cryptographic_key
# B506 : yaml_load
# B601 : paramiko_calls
# B602 : subprocess_popen_with_shell_equals_true
# B603 : subprocess_without_shell_equals_true
# B604 : any_other_function_with_shell_equals_true
# B605 : start_process_with_a_shell
# B606 : start_process_with_no_shell
# B607 : start_process_with_partial_path
# B608 : hardcoded_sql_expressions
# B609 : linux_commands_wildcard_injection
# B701 : jinja2_autoescape_false
# B702 : use_of_mako_templates

4
.gitmodules vendored Normal file
View File

@ -0,0 +1,4 @@
[submodule "script/include"]
path = script/include
url = git@github.com:dod-ccpo/scriptz.git
branch = master

View File

@ -2,20 +2,28 @@ sudo: required
language: python
python: "3.6"
services: docker
git:
submodules: false
env:
global:
- TESTER_IMAGE_NAME=atst-tester
- PROD_IMAGE_NAME=atst-prod
before_install:
# Use sed to replace the SSH URL with the public URL
- sed -i 's/git@github.com:/https:\/\/github.com\//' .gitmodules
# Manually initialize submodules
- git submodule update --init --recursive
before_script:
- docker login -u $ATAT_DOCKER_REGISTRY_USERNAME -p $ATAT_DOCKER_REGISTRY_PASSWORD $ATAT_DOCKER_REGISTRY_URL
- docker build --tag "${TESTER_IMAGE_NAME}" . -f docker/tester/Dockerfile
- docker build --tag "${TESTER_IMAGE_NAME}" . -f deploy/docker/tester/Dockerfile
script:
- docker run "${TESTER_IMAGE_NAME}"
before_deploy:
- docker build --tag "${PROD_IMAGE_NAME}" . -f docker/prod/Dockerfile
- docker build --tag "${PROD_IMAGE_NAME}" . -f deploy/docker/prod/Dockerfile
- git_sha="$(git rev-parse --short HEAD)"
- remote_image_name="${ATAT_DOCKER_REGISTRY_URL}/${PROD_IMAGE_NAME}:${git_sha}"
- docker tag "${PROD_IMAGE_NAME}" "${remote_image_name}"

View File

@ -19,6 +19,7 @@ ipython = "*"
ipdb = "*"
pylint = "*"
black = "*"
pytest-watch = "*"
[requires]
python_version = "3.6"

47
Pipfile.lock generated
View File

@ -1,7 +1,7 @@
{
"_meta": {
"hash": {
"sha256": "53a2048f5dc853a0ac2d565164c23dcdb2df802e055c1530864bfc8390af3c3e"
"sha256": "3bea02ccdb0e3877f2595d7fb405408114ec8947e0484d5b4aaf14a4c8ff78b2"
},
"pipfile-spec": 6,
"requires": {
@ -119,6 +119,13 @@
"markers": "sys_platform == 'darwin'",
"version": "==0.1.0"
},
"argh": {
"hashes": [
"sha256:a9b3aaa1904eeb78e32394cd46c6f37ac0fb4af6dc488daa58971bdc7d7fcaf3",
"sha256:e9535b8c84dc9571a48999094fda7f33e63c3f1b74f3e5f3ac0105a58405bb65"
],
"version": "==0.26.2"
},
"astroid": {
"hashes": [
"sha256:a8d8c7fe34e34e868426b9bafce852c355a3951eef60bc831b2ed541558f8d37",
@ -170,6 +177,13 @@
],
"version": "==6.7"
},
"colorama": {
"hashes": [
"sha256:463f8483208e921368c9f306094eb6f725c6ca42b0f97e313cb5d5512459feda",
"sha256:48eb22f4f8461b1df5734a074b57042430fb06e1d61bd1e11b078c0fe6d7a1f1"
],
"version": "==0.3.9"
},
"decorator": {
"hashes": [
"sha256:2c51dff8ef3c447388fe5e4453d24a2bf128d3a4c32af3fabef1f01c6851ab82",
@ -177,6 +191,12 @@
],
"version": "==4.3.0"
},
"docopt": {
"hashes": [
"sha256:49b3a825280bd66b3aa83585ef59c4a8c82f2c8a522dbe754a8bc8d08c85c491"
],
"version": "==0.6.2"
},
"gitdb2": {
"hashes": [
"sha256:b60e29d4533e5e25bb50b7678bbc187c8f6bcff1344b4f293b2ba55c85795f09",
@ -280,10 +300,16 @@
},
"parso": {
"hashes": [
"sha256:8105449d86d858e53ce3e0044ede9dd3a395b1c9716c696af8aa3787158ab806",
"sha256:d250235e52e8f9fc5a80cc2a5f804c9fefd886b2e67a2b1099cf085f403f8e33"
"sha256:35704a43a3c113cce4de228ddb39aab374b8004f4f2407d070b6a2ca784ce8a2",
"sha256:895c63e93b94ac1e1690f5fdd40b65f07c8171e3e53cbd7793b5b96c0e0a7f24"
],
"version": "==0.3.0"
"version": "==0.3.1"
},
"pathtools": {
"hashes": [
"sha256:7c35c5421a39bb82e58018febd90e3b6e5db34c5443aaaf742b3f33d4655f1c0"
],
"version": "==0.1.2"
},
"pbr": {
"hashes": [
@ -370,6 +396,13 @@
"index": "pypi",
"version": "==0.5.0"
},
"pytest-watch": {
"hashes": [
"sha256:06136f03d5b361718b8d0d234042f7b2f203910d8568f63df2f866b547b3d4b9"
],
"index": "pypi",
"version": "==4.2.0"
},
"pyyaml": {
"hashes": [
"sha256:254bf6fda2b7c651837acb2c718e213df29d531eebf00edb54743d10bcb694eb",
@ -460,6 +493,12 @@
"markers": "python_version < '3.7' and implementation_name == 'cpython'",
"version": "==1.1.0"
},
"watchdog": {
"hashes": [
"sha256:7e65882adb7746039b6f3876ee174952f8eaaa34491ba34333ddf1fe35de4162"
],
"version": "==0.8.3"
},
"wcwidth": {
"hashes": [
"sha256:3df37372226d6e63e1b1e1eda15c594bca98a22d33a23832a90998faa96bc65e",

View File

@ -3,35 +3,74 @@
[![Build Status](https://travis-ci.org/dod-ccpo/atst.svg?branch=master)](https://travis-ci.org/dod-ccpo/atst)
## Description
This is the main user-facing web application for the ATAT stack. All end-user
requests are handled by ATST, with it making backend calls to various
microservices when appropriate.
## Installation
### Requirements
See the [scriptz](https://github.com/dod-ccpo/scriptz) repository for the shared
requirements and guidelines for all ATAT applications.
Additionally, ATST requires a redis instance for session management. Have redis
installed and running. By default, ATST will try to connect to a redis instance
running on localhost on its default port, 6379.
### Cloning
This project contains git submodules. Here is an example clone command that will
automatically initialize and update those modules:
git clone --recurse-submodules git@github.com:dod-ccpo/atst.git
If you have an existing clone that does not yet contain the submodules, you can
set them up with the following command:
git submodule update --init --recursive
### Setup
This application uses Pipenv to manage Python dependencies and a virtual
environment. Instead of the classic `requirements.txt` file, pipenv uses a
Pipfile and Pipfile.lock, making it more similar to other modern package managers
like yarn or mix.
To perform the installation, run the setup script:
script/setup
The setup script installs pipenv, which is what this application uses to manage its dependences and virtualenv. Instead of the classic `requirements.txt` file, pipenv uses a Pipfile and Pipfile.lock, making it more similar to other modern package managers like yarn or mix.
The setup script creates the virtual environment, and then calls script/bootstrap
to install all of the Python and Node dependencies.
To enter the virtualenv manually (a la `source .venv/bin/activate`):
pipenv shell
If you want to automatically load the virtual environment whenever you enter the project directory, take a look at [direnv](https://direnv.net/). An `.envrc` file is included in this repository. direnv will activate and deactivate virtualenvs for you when you enter and leave the directory.
If you want to automatically load the virtual environment whenever you enter the
project directory, take a look at [direnv](https://direnv.net/). An `.envrc`
file is included in this repository. direnv will activate and deactivate
virtualenvs for you when you enter and leave the directory.
Additionally, ATST requires a redis instance for session management. Have redis installed and running. By default, ATST will try to connect to a redis instance running on localhost on its default port, 6379.
## Running (development)
To start the app and watch for changes:
To start the app locally in the foreground and watch for changes:
DEBUG=1 script/server
script/dev_server
## Testing
To run unit tests:
To run lint, static analysis, and unit tests:
script/test
or
To run only the unit tests:
python -m pytest
pipenv run python -m pytest
To re-run tests each time a file is changed:
pipenv run ptw
## Notes

View File

@ -4,12 +4,13 @@ import tornado.web
from tornado.web import url
from redis import StrictRedis
from atst.handlers.main import MainHandler
from atst.handlers.home import Home
from atst.handlers.login import Login
from atst.handlers.main import Main
from atst.handlers.root import Root
from atst.handlers.login_redirect import LoginRedirect
from atst.handlers.workspace import Workspace
from atst.handlers.request import Request
from atst.handlers.request_new import RequestNew
from atst.handlers.request_submit import RequestsSubmit
from atst.handlers.dev import Dev
from atst.home import home
from atst.api_client import ApiClient
@ -21,23 +22,23 @@ ENV = os.getenv("TORNADO_ENV", "dev")
def make_app(config, deps, **kwargs):
routes = [
url(r"/", Home, {"page": "login"}, name="main"),
url(r"/", Root, {"page": "root"}, name="root"),
url(
r"/login",
Login,
r"/login-redirect",
LoginRedirect,
{"sessions": deps["sessions"], "authnid_client": deps["authnid_client"]},
name="login",
name="login_redirect",
),
url(r"/home", MainHandler, {"page": "home"}, name="home"),
url(r"/home", Main, {"page": "home"}, name="home"),
url(
r"/styleguide",
MainHandler,
Main,
{"page": "styleguide"},
name="styleguide",
),
url(
r"/workspaces/blank",
MainHandler,
Main,
{"page": "workspaces_blank"},
name="workspaces_blank",
),
@ -71,9 +72,15 @@ def make_app(config, deps, **kwargs):
{"page": "requests_new", "requests_client": deps["requests_client"]},
name="request_form_update",
),
url(r"/users", MainHandler, {"page": "users"}, name="users"),
url(r"/reports", MainHandler, {"page": "reports"}, name="reports"),
url(r"/calculator", MainHandler, {"page": "calculator"}, name="calculator"),
url(
r"/requests/submit/(\S+)",
RequestsSubmit,
{"requests_client": deps["requests_client"]},
name="requests_submit",
),
url(r"/users", Main, {"page": "users"}, name="users"),
url(r"/reports", Main, {"page": "reports"}, name="reports"),
url(r"/calculator", Main, {"page": "calculator"}, name="calculator"),
]
if not ENV == "production":

View File

@ -15,10 +15,14 @@ class FinancialForm(Form):
"Unique Item Identifier (UII)s related to your application(s) if you already have them."
)
pe_id = NewlineListField(
"Program Element (PE) Numbers related to your request"
pe_id = StringField(
"Program Element (PE) Number related to your request"
)
treasury_code = StringField("Please provide your Program Treasury Code")
ba_code = StringField("Please provide your Program BA Code")
fname_co = StringField("Contracting Officer First Name", validators=[Required()])
lname_co = StringField("Contracting Officer Last Name", validators=[Required()])

View File

@ -2,7 +2,7 @@ from wtforms.fields.html5 import IntegerField
from wtforms.fields import RadioField, StringField, TextAreaField
from wtforms.validators import NumberRange, InputRequired
from wtforms_tornado import Form
from .fields import DateField, NewlineListField
from .fields import DateField
from .validators import DateRange
import pendulum

View File

@ -23,6 +23,7 @@ class BaseHandler(tornado.web.RequestHandler):
try:
session = self.application.sessions.get_session(cookie)
except SessionNotFoundError:
self.clear_cookie("atat")
return None
else:
return None

View File

@ -2,7 +2,7 @@ import tornado
from atst.handler import BaseHandler
class Login(BaseHandler):
class LoginRedirect(BaseHandler):
def initialize(self, authnid_client, sessions):
self.authnid_client = authnid_client
self.sessions = sessions

View File

@ -2,7 +2,7 @@ import tornado
from atst.handler import BaseHandler
class MainHandler(BaseHandler):
class Main(BaseHandler):
def initialize(self, page):
self.page = page

View File

@ -1,43 +1,15 @@
import tornado
from collections import defaultdict
from atst.handler import BaseHandler
from atst.forms.request import RequestForm
from atst.forms.org import OrgForm
from atst.forms.poc import POCForm
from atst.forms.review import ReviewForm
from atst.forms.financial import FinancialForm
import tornado.httputil
class RequestNew(BaseHandler):
screens = [
{
"title": "Details of Use",
"section": "details_of_use",
"form": RequestForm,
"subitems": [
{"title": "Overall request details", "id": "overall-request-details"},
{"title": "Cloud Resources", "id": "cloud-resources"},
{"title": "Support Staff", "id": "support-staff"},
],
},
{
"title": "Information About You",
"section": "information_about_you",
"form": OrgForm,
},
{
"title": "Primary Point of Contact",
"section": "primary_poc",
"form": POCForm,
},
{"title": "Review & Submit", "section": "review_submit", "form": ReviewForm},
{
"title": "Financial Verification",
"section": "financial_verification",
"form": FinancialForm,
},
]
def initialize(self, page, requests_client):
self.page = page
self.requests_client = requests_client
@ -47,58 +19,62 @@ class RequestNew(BaseHandler):
def post(self, screen=1, request_id=None):
self.check_xsrf_cookie()
screen = int(screen)
form_metadata = self.screens[screen - 1]
form_section = form_metadata["section"]
form = form_metadata["form"](self.request.arguments)
post_data = self.request.arguments
jedi_flow = JEDIRequestFlow(
self.requests_client, screen, post_data=post_data, request_id=request_id
)
if form.validate():
response = yield self.create_or_update_request(
form_section, form.data, request_id
)
if jedi_flow.validate():
response = yield jedi_flow.create_or_update_request(self.get_current_user())
if response.ok:
where = self.application.default_router.reverse_url(
"request_form_update",
str(screen + 1),
request_id or response.json["id"],
"request_form_update", str(screen + 1), jedi_flow.request_id
)
self.redirect(where)
else:
self.set_status(response.code)
else:
self.show_form(screen, form)
self.render(
"requests/screen-%d.html.to" % int(screen),
f=jedi_flow.form,
data=post_data,
page=self.page,
screens=jedi_flow.screens,
current=screen,
next_screen=jedi_flow.next_screen,
request_id=jedi_flow.request_id,
)
@tornado.web.authenticated
@tornado.gen.coroutine
def get(self, screen=1, request_id=None):
form = None
form_data = None
is_review_section = screen == 4
screen = int(screen)
request = None
if request_id:
request = yield self.get_request(request_id)
if request.ok:
if is_review_section:
form_data = request.json["body"]
else:
form_metadata = self.screens[int(screen) - 1]
section = form_metadata["section"]
form_data = request.json["body"].get(section, request.json["body"])
form = form_metadata["form"](data=form_data)
response = yield self.requests_client.get(
"/users/{}/requests/{}".format(
self.get_current_user()["id"], request_id
),
raise_error=False,
)
if response.ok:
request = response.json
self.show_form(screen=screen, form=form, request_id=request_id, data=form_data)
jedi_flow = JEDIRequestFlow(
self.requests_client, screen, request, request_id=request_id
)
def show_form(self, screen=1, form=None, request_id=None, data=None):
if not form:
form = self.screens[int(screen) - 1]["form"](self.request.arguments)
self.render(
"requests/screen-%d.html.to" % int(screen),
f=form,
data=data,
f=jedi_flow.form,
data=jedi_flow.current_step_data,
page=self.page,
screens=self.screens,
current=int(screen),
next_screen=int(screen) + 1,
screens=jedi_flow.screens,
current=screen,
next_screen=screen + 1,
request_id=request_id,
can_submit=jedi_flow.can_submit
)
@tornado.gen.coroutine
@ -109,16 +85,128 @@ class RequestNew(BaseHandler):
)
return request
class JEDIRequestFlow(object):
def __init__(
self,
requests_client,
current_step,
request=None,
post_data=None,
request_id=None,
):
self.requests_client = requests_client
self.current_step = current_step
self.request = request
self.post_data = post_data
self.is_post = self.post_data is not None
self.request_id = request_id
self.form = self._form()
def _form(self):
if self.is_post:
return self.form_class()(self.post_data)
elif self.request:
return self.form_class()(data=self.current_step_data)
else:
return self.form_class()()
def validate(self):
return self.form.validate()
@property
def current_screen(self):
return self.screens[self.current_step - 1]
@property
def form_section(self):
return self.current_screen["section"]
def form_class(self):
return self.current_screen["form"]
@property
def current_step_data(self):
data = {}
if self.is_post:
data = self.post_data
if self.request:
if self.form_section == "review_submit":
data = self.request["body"]
else:
data = self.request["body"].get(self.form_section, {})
return defaultdict(lambda: defaultdict(lambda: 'Input required'), data)
@property
def can_submit(self):
return self.request and self.request["status"] != "incomplete"
@property
def next_screen(self):
return self.current_step + 1
@property
def screens(self):
return [
{
"title": "Details of Use",
"section": "details_of_use",
"form": RequestForm,
"subitems": [
{
"title": "Overall request details",
"id": "overall-request-details",
},
{"title": "Cloud Resources", "id": "cloud-resources"},
{"title": "Support Staff", "id": "support-staff"},
],
"show": True,
},
{
"title": "Information About You",
"section": "information_about_you",
"form": OrgForm,
"show": True,
},
{
"title": "Primary Point of Contact",
"section": "primary_poc",
"form": POCForm,
"show": True,
},
{
"title": "Review & Submit",
"section": "review_submit",
"form": ReviewForm,
"show":True,
},
{
"title": "Financial Verification",
"section": "financial_verification",
"form": FinancialForm,
"show": self.request and self.request["status"] == "approved",
},
]
@tornado.gen.coroutine
def create_or_update_request(self, form_section, form_data, request_id=None):
def create_or_update_request(self, user):
request_data = {
"creator_id": self.get_current_user()["id"],
"request": {form_section: form_data},
"creator_id": user["id"],
"request": {self.form_section: self.form.data},
}
if request_id:
if self.request_id:
response = yield self.requests_client.patch(
"/requests/{}".format(request_id), json=request_data
"/requests/{}".format(self.request_id), json=request_data
)
else:
response = yield self.requests_client.post("/requests", json=request_data)
self.request = response.json
self.request_id = self.request["id"]
return response

View File

@ -0,0 +1,17 @@
import tornado
from atst.handler import BaseHandler
class RequestsSubmit(BaseHandler):
def initialize(self, requests_client):
self.requests_client = requests_client
@tornado.web.authenticated
@tornado.gen.coroutine
def post(self, request_id):
yield self.requests_client.post(
"/requests/{}/submit".format(request_id),
allow_nonstandard_methods=True
)
self.redirect("/requests")

View File

@ -1,7 +1,7 @@
from atst.handler import BaseHandler
class Home(BaseHandler):
class Root(BaseHandler):
def initialize(self, page):
self.page = page

View File

@ -23,12 +23,12 @@ RUN set -x ; \
# Set working dir
WORKDIR ${APP_DIR}
# Copy over alpine setup script
COPY script/alpine_setup ./script/
# Copy over setup scripts
COPY script/ ./script/
# Add required system packages and app user
RUN set -x ; \
script/alpine_setup "${APP_USER}" "${APP_GROUP}"
script/alpine_setup
### Items that will change almost every build
#############################################

View File

@ -3,20 +3,11 @@
# script/alpine_setup: Adds all the system packages, directors, users, etc.
# required to run the application on Alpine
# If a command fails, exit the script
set -e
source "$(dirname "${0}")"/../script/include/global_header.inc.sh
# Ensure we are in the app root directory (not the /script directory)
cd "$(dirname "${0}")/.."
# Set app specific items
APP_USER="atst"
APP_UID="8010"
APP_USER=${1}
APP_GROUP=${2}
apk update
apk upgrade
apk add bash
apk add dumb-init
addgroup -g 8000 -S "${APP_GROUP}"
adduser -u 8010 -D -S -G "${APP_GROUP}" "${APP_USER}"
# Run the shared alpine setup script
source ./script/include/run_alpine_setup

View File

@ -3,33 +3,18 @@
# script/bootstrap: Resolve all dependencies that the application requires to
# run.
# If a command fails, exit the script
set -e
source "$(dirname "${0}")"/../script/include/global_header.inc.sh
# Ensure we are in the app root directory (not the /script directory)
cd "$(dirname "${0}")/.."
# Set sass compiling command for this app
COMPILE_SASS_CMD="webassets -m atst.assets build"
if [ -z "${CIBUILD+xxxx}" ]; then
CMD_PREFIX='pipenv run '
fi
PIP_CMD="${CMD_PREFIX}pip"
WEBASSETS_CMD="${CMD_PREFIX}webassets"
# Enable python and node package installation
INSTALL_PYTHON_PACKAGES="true"
INSTALL_NODE_PACKAGES="true"
PIPENV_INSTALL_FLAGS='--dev'
if [ -n "${CIBUILD}" ]; then
PIPENV_INSTALL_FLAGS+=' --system --ignore-pipfile'
fi
# Run the shared bootstrap script
source ./script/include/run_bootstrap
# Install Python dependencies
${PIP_CMD} install --upgrade pip
pipenv install ${PIPENV_INSTALL_FLAGS}
# Install uswds node module and dependencies
npm install
# Relink uswds fonts into the /static directory
# Link USWDS fonts into the /static directory
rm -f ./static/fonts
ln -s ../node_modules/uswds/src/fonts ./static/fonts
# Precompile assets for deployment
${WEBASSETS_CMD} -m atst.assets build
ln -s ../node/modules/uswds/src/fonts ./static/fonts

View File

@ -2,15 +2,7 @@
# script/cibuild: Run CI related checks and tests
# If a command fails, exit the script
set -e
# Ensure we are in the app root directory (not the /script directory)
cd "$(dirname "${0}")/.."
source "$(dirname "${0}")"/../script/include/global_header.inc.sh
# Run lint/style checks and unit tests
script/test
# Run static code analysis security checks
# (excluding the tests and node_modules subdirs)
bandit -r . -x node_modules,tests
source ./script/test

32
script/dev_server Executable file
View File

@ -0,0 +1,32 @@
#!/bin/bash
# script/dev_server: Launch a local dev version of the server in the background
#
# WIP
#
source "$(dirname "${0}")"/../script/include/global_header.inc.sh
# Create a function to run after a trap is triggered
reap() {
kill -s SIGTERM -- "-$$"
sleep 0.1
exit
}
# Register trapping of SIGTERM and SIGINT
trap reap SIGTERM SIGINT
# Display the script PID, which will also be the process group ID for all
# child processes
echo "Process Group: $$"
# Set server launch related environment variables
DEBUG=1
LAUNCH_ARGS="$*"
# Launch the app
source ./script/server &
wait

1
script/include Submodule

@ -0,0 +1 @@
Subproject commit 7417942f1614d6a7ad94e94d1621dca9b422dec2

View File

@ -1,26 +1,8 @@
#!/bin/bash
reap() {
kill -TERM $child
sleep 0.1
exit
}
# script/server: Launch the server
trap reap TERM INT
# If a command fails, exit the script
set -e
# Ensure we are in the app root directory (not the /script directory)
cd "$(dirname "${0}")/.."
if [ -z "${SKIP_PIPENV+xxxx}" ]; then
CMD_PREFIX='pipenv run '
fi
PYTHON_CMD="${CMD_PREFIX}python"
source "$(dirname "${0}")"/../script/include/global_header.inc.sh
# Launch the app
${PYTHON_CMD} app.py ${@} &
child=$!
wait $child
run_command "./app.py ${LAUNCH_ARGS}"

View File

@ -3,24 +3,10 @@
# script/setup: Set up application for the first time after cloning, or set it
# back to the initial first unused state.
# If a command fails, exit the script
set -e
source "$(dirname "${0}")"/../script/include/global_header.inc.sh
# Ensure we are in the app root directory (not the /script directory)
cd "$(dirname "${0}")/.."
# Turn on sass compiler installation
INSTALL_SASS="true"
# Install virtualenv
pip install pipenv
pipenv --python 3.6
if ! type sass > /dev/null; then
if type gem > /dev/null; then
echo 'installing a sass compiler...'
gem install sass
else
echo 'Could not install a sass compiler. Please install a version of sass.'
fi
fi
# Install application dependencies
script/bootstrap
# Run the shared setup script
source ./script/include/run_setup

View File

@ -2,22 +2,13 @@
# script/test: Run static code checks and unit tests
# If a command fails, exit the script
set -e
source "$(dirname "${0}")"/../script/include/global_header.inc.sh
# Ensure we are in the app root directory (not the /script directory)
cd "$(dirname "${0}")/.."
# Define all relevant python files and directories for this app
PYTHON_FILES="./app.py ./atst ./config"
if [ -z "${SKIP_PIPENV+xxxx}" ]; then
CMD_PREFIX='pipenv run '
fi
PYLINT_CMD="${CMD_PREFIX}pylint"
PYTHON_CMD="${CMD_PREFIX}python"
# Enable Python testing
RUN_PYTHON_TESTS="true"
# Run lint check
echo "Running lint..."
${PYLINT_CMD} app.py atst/ tests/
# Run unit tests
echo "Running unit tests..."
${PYTHON_CMD} -m pytest -s $*
# Run the shared test script
source ./script/include/run_test

View File

@ -1,10 +1,8 @@
#!/bin/bash
# If a command fails, exit the script
set -e
# script/update: Update dependencies
# Ensure we are in the app root directory (not the /script directory)
cd "$(dirname "${0}")/.."
source "$(dirname "${0}")"/../script/include/global_header.inc.sh
# Update dependencies
script/bootstrap
# Run the bootstrap script
source ./script/bootstrap

View File

@ -47,6 +47,9 @@
@include media($medium-screen) {
@include margin(($site-margins * 2) null);
}
@include media($large-screen) {
flex-wrap: nowrap;
}

View File

@ -79,6 +79,10 @@
> ul {
@include panel-margin;
&:last-child {
margin: 0;
}
> li {
&:last-child {
> .sidenav__link {

View File

@ -1,5 +1,9 @@
{% extends '../requests_new.html.to' %}
{% block form_action %}
<form method='POST' action="{{ reverse_url('requests_submit', request_id) }}" autocomplete="off">
{% end %}
{% block form %}
{% autoescape None %}
@ -8,110 +12,105 @@
{% end %}
<h2 id="review-submit">Review &amp; Submit</h2>
<p class="usa-font-lead">Before you can submit your request, please take a moment to review the information entered in the form. You may make changes by clicking the edit link on each section. When all information looks right, go ahead and submit.</p>
<h3>Details of Use <a href="">Edit</a></h3>
<h3>Details of Use <a href="{{ reverse_url('request_form_update', 1, request_id) }}">Edit</a></h3>
<h4>Overall Request Details</h4>
<label>What is the total estimated dollar value of the cloud resources you are requesting using the JEDI CSP Calculator? </label>
<b>{{ data.get('details_of_use', {}).get('dollar_value') }}</b>
<b>{{ data['details_of_use']['dollar_value'] }}</b>
<label>Please estimate the number of applications that might be supported by this request</label>
<b>{{ data.get('details_of_use', {}).get('num_applications') }}</b>
<b>{{ data['details_of_use']['num_applications'] }}</b>
<label>Start Date</label>
<b>{{ data.get('details_of_use', {}).get('date_start') }}</b>
<b>{{ data['details_of_use']['date_start'] }}</b>
<label>Please briefly describe how your team is expecting to use the JEDI Cloud</label>
<b>{{ data.get('details_of_use', {}).get('app_description') }}</b>
<b>{{ data['details_of_use']['app_description'] }}</b>
<label>What organizations are supported by these applications?</label>
<b>{{ data.get('details_of_use', {}).get('supported_organizations') }}</b>
<label>Please enter the Unique Item Identifier (UII)s related to your application(s) if you already have them.</label>
<b>{{ data.get('details_of_use', {}).get('uii_ids') }}</b>
<label>Please provide the Program Element (PE) Numbers related to your request</label>
<b>{{ data.get('details_of_use', {}).get('pe_id') }}</b>
<b>{{ data['details_of_use']['supported_organizations'] }}</b>
<h4>Cloud Resources</h4>
<label>Total Number of vCPU cores</label>
<b>{{ data.get('details_of_use', {}).get('total_cores') }}</b>
<b>{{ data['details_of_use']['total_cores'] }}</b>
<label>Total RAM</label>
<b>{{ data.get('details_of_use', {}).get('total_ram') }}</b>
<b>{{ data['details_of_use']['total_ram'] }}</b>
<label>Total object storage</label>
<b>{{ data.get('details_of_use', {}).get('total_object_storage') }}</b>
<b>{{ data['details_of_use']['total_object_storage'] }}</b>
<label>Total server storage</label>
<b>{{ data.get('details_of_use', {}).get('total_server_storage') }}</b>
<b>{{ data['details_of_use']['total_server_storage'] }}</b>
<h4>Support Staff</h4>
<label>Do you have a contractor to advise and assist you with using cloud services?</label>
<b>{{ data.get('details_of_use', {}).get('has_contractor_advisor') }}</b>
<b>{{ data['details_of_use']['has_contractor_advisor'] }}</b>
<label>Are you using the JEDI Cloud to migrate existing applications?</label>
<b>{{ data.get('details_of_use', {}).get('is_migrating_application') }}</b>
<b>{{ data['details_of_use']['is_migrating_application'] }}</b>
<label>Please describe the organizations that are supporting you, include both government and contractor resources</label>
<b>{{ data.get('details_of_use', {}).get('supporting_organization') }}</b>
<b>{{ data['details_of_use']['supporting_organization'] }}</b>
<label>Do you have a migration office that you're working with to migrate to the cloud?</label>
<b>{{ data.get('details_of_use', {}).get('has_migration_office') }}</b>
<b>{{ data['details_of_use']['has_migration_office'] }}</b>
<label>Please describe the organizations that are supporting you, include both government and contractor resources.</label>
<b>{{ data.get('details_of_use', {}).get('supporting_organization') }}</b>
<b>{{ data['details_of_use']['supporting_organization'] }}</b>
<br><br><hr>
<h3>Information About You <a href="">Edit</a></h3>
<h3>Information About You <a href="{{ reverse_url('request_form_update', 2, request_id) }}">Edit</a></h3>
<label>First Name</label>
<b>{{ data.get('information_about_you', {}).get('fname_request') }}</b>
<b>{{ data['information_about_you']['fname_request'] }}</b>
<label>Last Name</label>
<b>{{ data.get('information_about_you', {}).get('lname_request') }}</b>
<b>{{ data['information_about_you']['lname_request'] }}</b>
<label>Email (associated with your CAC)</label>
<b>{{ data.get('information_about_you', {}).get('email_request') }}</b>
<b>{{ data['information_about_you']['email_request'] }}</b>
<label>Phone Number</label>
<b>{{ data.get('information_about_you', {}).get('phone_number') }}</b>
<b>{{ data['information_about_you']['phone_number'] }}</b>
<label>Service Branch or Agency</label>
<b>{{ data.get('information_about_you', {}).get('service_branch') }}</b>
<b>{{ data['information_about_you']['service_branch'] }}</b>
<label>Citizenship</label>
<b>{{ data.get('information_about_you', {}).get('citizenship') }}</b>
<b>{{ data['information_about_you']['citizenship'] }}</b>
<label>Designation of Person</label>
<b>{{ data.get('information_about_you', {}).get('designation') }}</b>
<b>{{ data['information_about_you']['designation'] }}</b>
<label>Latest Information Assurance (IA) Training completion date</label>
<b>{{ data.get('information_about_you', {}).get('date_latest_training') }}</b>
<b>{{ data['information_about_you']['date_latest_training'] }}</b>
<br><br><hr>
<h3>Primary Government/Military Point of Contact (POC) <a href="">Edit</a></h3>
<h3>Primary Government/Military Point of Contact (POC) <a href="{{ reverse_url('request_form_update', 3, request_id) }}">Edit</a></h3>
<label>POC First Name</label>
<b>{{ data.get('primary_poc', {}).get('fname_poc')}}</b>
<b>{{ data['primary_poc']['fname_poc']}}</b>
<label>POC Last Name</label>
<b>{{ data.get('primary_poc', {}).get('lname_poc')}}</b>
<b>{{ data['primary_poc']['lname_poc']}}</b>
<label>POC Email (associated with CAC)</label>
<b>{{ data.get('primary_poc', {}).get('email_poc')}} </b>
<b>{{ data['primary_poc']['email_poc']}} </b>
<label>DOD ID</label>
<b>{{ data.get('primary_poc', {}).get('dodid_poc')}}</b>
<b>{{ data['primary_poc']['dodid_poc']}}</b>
<br><br>
@ -119,5 +118,8 @@
{% end %}
{% block next %}
<input type='submit' class='usa-button usa-button-primary' value='Submit' />
{% if not can_submit %}
<b class="usa-input-error-message">Please complete all required fields before submitting.</b>
{% end %}
<input type='submit' class='usa-button usa-button-primary' value='Submit' {{ "disabled" if not can_submit else "" }} />
{% end %}

View File

@ -35,6 +35,21 @@
</div>
{% end %}
{{ f.treasury_code.label }}
{{ f.treasury_code(placeholder="Example: 1200") }}
{% for e in f.treasury_code.errors %}
<div class="usa-input-error-message">
{{ e }}
</div>
{% end %}
{{ f.ba_code.label }}
{{ f.ba_code(placeholder="Example: 02") }}
{% for e in f.ba_code.errors %}
<div class="usa-input-error-message">
{{ e }}
</div>
{% end %}
<!-- KO Information -->

View File

@ -7,25 +7,27 @@
<ul>
{% for i,s in enumerate(screens) %}
<li>
{% if i+1==current %}
<a class="sidenav__link sidenav__link--active" href="{{ reverse_url('request_form_update', i+1, request_id) if request_id else reverse_url('request_form_new',i+1) }}">
{{ i+1 }}. {{ s['title'] }}
</a>
{% if s.get('subitems') %}
<ul>
{% for j,t in enumerate(s['subitems']) %}
<li><a class="sidenav__link" href="#{{ t['id'] }}">{{ t['title'] }}</a></li>
{% end %}
</ul>
{% end %}
{% if s["show"] %}
<li>
{% if i+1==current %}
<a class="sidenav__link sidenav__link--active" href="{{ reverse_url('request_form_update', i+1, request_id) if request_id else reverse_url('request_form_new',i+1) }}">
{{ i+1 }}. {{ s['title'] }}
</a>
{% if s.get('subitems') %}
<ul>
{% for j,t in enumerate(s['subitems']) %}
<li><a class="sidenav__link" href="#{{ t['id'] }}">{{ t['title'] }}</a></li>
{% end %}
</ul>
{% end %}
{% else %}
<a class="sidenav__link" href="{{ reverse_url('request_form_update', i+1, request_id) if request_id else reverse_url('request_form_new',i+1) }}">
{{ i+1 }}. {{ s['title'] }}
</a>
{% else %}
<a class="sidenav__link" href="{{ reverse_url('request_form_update', i+1, request_id) if request_id else reverse_url('request_form_new',i+1) }}">
{{ i+1 }}. {{ s['title'] }}
</a>
{% end %}
</li>
{% end %}
</li>
{% end %}
</ul>
</div>
</div>

View File

@ -9,18 +9,20 @@
{% block content %}
<div class="col col--grow">
<div class="panel">
<main class="panel__content">
<div class="panel__heading">
<h1>New Request</h1>
</div>
{% if request_id %}
<form method='POST' action="{{ reverse_url('request_form_update', current, request_id) }}" autocomplete="off">
{% else %}
<form method='POST' action="{{ reverse_url('request_form_new', current) }}" autocomplete="off">
{% block form_action %}
{% if request_id %}
<form method='POST' action="{{ reverse_url('request_form_update', current, request_id) }}" autocomplete="off">
{% else %}
<form method='POST' action="{{ reverse_url('request_form_new', current) }}" autocomplete="off">
{% end %}
{% end %}
{% module xsrf_form_html() %}
@ -31,16 +33,11 @@
<input type='submit' class='usa-button usa-button-primary' value='Save & Continue' />
{% end %}
</form>
</main>
</main>
</div>
</div>
{% end %}

View File

@ -19,3 +19,23 @@ def app():
deps.update(TEST_DEPS)
return make_app(config, deps)
class DummyForm(dict):
pass
class DummyField(object):
def __init__(self, data=None, errors=(), raw_data=None):
self.data = data
self.errors = list(errors)
self.raw_data = raw_data
@pytest.fixture
def dummy_form():
return DummyForm()
@pytest.fixture
def dummy_field():
return DummyField()

View File

@ -0,0 +1,61 @@
from wtforms.validators import ValidationError
import pytest
from atst.forms.validators import Alphabet, IsNumber, PhoneNumber
class TestIsNumber:
@pytest.mark.parametrize("valid", ["0", "12", "-12"])
def test_IsNumber_accepts_integers(self, valid, dummy_form, dummy_field):
validator = IsNumber()
dummy_field.data = valid
validator(dummy_form, dummy_field)
@pytest.mark.parametrize("invalid", ["12.1", "two", ""])
def test_IsNumber_rejects_anything_else(self, invalid, dummy_form, dummy_field):
validator = IsNumber()
dummy_field.data = invalid
with pytest.raises(ValidationError):
validator(dummy_form, dummy_field)
class TestPhoneNumber:
@pytest.mark.parametrize("valid", [
"12345",
"1234567890",
"(123) 456-7890",
])
def test_PhoneNumber_accepts_valid_numbers(self, valid, dummy_form, dummy_field):
validator = PhoneNumber()
dummy_field.data = valid
validator(dummy_form, dummy_field)
@pytest.mark.parametrize("invalid", [
"1234",
"123456",
"1234567abc",
"(123) 456-789012",
])
def test_PhoneNumber_rejects_invalid_numbers(self, invalid, dummy_form, dummy_field):
validator = PhoneNumber()
dummy_field.data = invalid
with pytest.raises(ValidationError):
validator(dummy_form, dummy_field)
class TestAlphabet:
@pytest.mark.parametrize("valid", ["a", "abcde"])
def test_Alphabet_accepts_letters(self, valid, dummy_form, dummy_field):
validator = Alphabet()
dummy_field.data = valid
validator(dummy_form, dummy_field)
@pytest.mark.parametrize("invalid", ["", "hi mark", "cloud9"])
def test_Alphabet_rejects_non_letters(self, invalid, dummy_form, dummy_field):
validator = Alphabet()
dummy_field.data = invalid
with pytest.raises(ValidationError):
validator(dummy_form, dummy_field)

View File

@ -49,6 +49,7 @@ class MockRequestsClient(MockApiClient):
"id": "66b8ef71-86d3-48ef-abc2-51bfa1732b6b",
"creator": "49903ae7-da4a-49bf-a6dc-9dff5d004238",
"body": {},
"status": "incomplete"
}
return self._get_response("GET", path, 200, json=json)

View File

@ -21,11 +21,14 @@ def test_redirects_when_not_logged_in(http_client, base_url):
@pytest.mark.gen_test
def test_redirects_when_session_does_not_exist(monkeypatch, http_client, base_url):
monkeypatch.setattr("atst.handlers.main.MainHandler.get_secure_cookie", lambda s,c: 'stale cookie!')
monkeypatch.setattr("atst.handlers.main.Main.get_secure_cookie", lambda s,c: 'stale cookie!')
response = yield http_client.fetch(
base_url + "/home", raise_error=False, follow_redirects=False
)
location = response.headers["Location"]
cookie = response.headers._dict.get('Set-Cookie')
# should clear session cookie
assert 'atat=""' in cookie
assert response.code == 302
assert response.error
assert re.match("/\??", location)
@ -33,9 +36,9 @@ def test_redirects_when_session_does_not_exist(monkeypatch, http_client, base_ur
@pytest.mark.gen_test
def test_login_with_valid_bearer_token(app, monkeypatch, http_client, base_url):
monkeypatch.setattr("atst.handlers.login.Login._fetch_user_info", _fetch_user_info)
monkeypatch.setattr("atst.handlers.login_redirect.LoginRedirect._fetch_user_info", _fetch_user_info)
response = yield http_client.fetch(
base_url + "/login?bearer-token=abc-123",
base_url + "/login-redirect?bearer-token=abc-123",
follow_redirects=False,
raise_error=False,
)
@ -65,10 +68,10 @@ def test_login_with_invalid_bearer_token(http_client, base_url):
@pytest.mark.gen_test
def test_valid_login_creates_session(app, monkeypatch, http_client, base_url):
monkeypatch.setattr("atst.handlers.login.Login._fetch_user_info", _fetch_user_info)
monkeypatch.setattr("atst.handlers.login_redirect.LoginRedirect._fetch_user_info", _fetch_user_info)
assert len(app.sessions.sessions) == 0
yield http_client.fetch(
base_url + "/login?bearer-token=abc-123",
base_url + "/login-redirect?bearer-token=abc-123",
follow_redirects=False,
raise_error=False,
)