Browse Source

Migrating latest Atheneum changes to Corvus

merge-requests/1/merge
Drew Short 6 years ago
parent
commit
0c03d0c2e4
  1. 4
      .dockerignore
  2. 15
      .gitignore
  3. 31
      .gitlab-ci.yml
  4. 6
      CONTRIBUTING.md
  5. 23
      Dockerfile
  6. 33
      README.md
  7. 12
      server/.dockerignore
  8. 0
      server/CHANGELOG.md
  9. 4
      server/Dockerfile
  10. 25
      server/Pipfile
  11. 223
      server/Pipfile.lock
  12. 57
      server/README.md
  13. 4
      server/corvus/__init__.py
  14. 6
      server/corvus/api/authentication_api.py
  15. 74
      server/corvus/api/model.py
  16. 80
      server/corvus/api/user_api.py
  17. 11
      server/corvus/errors.py
  18. 2
      server/corvus/middleware/authentication_middleware.py
  19. 26
      server/corvus/service/authentication_service.py
  20. 117
      server/corvus/service/patch_service.py
  21. 22
      server/corvus/service/role_service.py
  22. 28
      server/corvus/service/transformation_service.py
  23. 89
      server/corvus/service/user_service.py
  24. 12
      server/corvus/service/user_token_service.py
  25. 170
      server/corvus/service/validation_service.py
  26. 4
      server/corvus/utility/json_utility.py
  27. 20
      server/corvus/utility/pagination_utility.py
  28. 1
      server/corvus/utility/session_utility.py
  29. 20
      server/documentation/Makefile
  30. 128
      server/documentation/api/authentication.rst
  31. 9
      server/documentation/api/index.rst
  32. 273
      server/documentation/api/user.rst
  33. 157
      server/documentation/conf.py
  34. 20
      server/documentation/index.rst
  35. 4
      server/documentation/introduction.rst
  36. 36
      server/documentation/make.bat
  37. 2
      server/manage.py
  38. 11
      server/run_tests.sh
  39. 2
      server/tests/api/test_authentication_api.py
  40. 109
      server/tests/api/test_user_api.py
  41. 2
      server/tests/conftest.py
  42. 87
      server/tests/service/test_patch_service.py
  43. 7
      server/tests/service/test_role_service.py
  44. 38
      server/tests/service/test_transformation_service.py
  45. 25
      server/tests/service/test_validation_service.py

4
.dockerignore

@ -1,4 +0,0 @@
server/instance/
server/setup.py
server/test/
.admin_credentials

15
.gitignore

@ -1,8 +1,11 @@
instance/
.idea
*.iml
.admin_credentials
.idea/
*__pycache__/
.pytest_cache/
.coverage
.mypy_cache/
# Atheneum Specific Ignores
/server/.admin_credentials
/server/.coverage
/server/.mypy_cache/
/server/.pytest_cache/
/server/documentation/_build/
/server/instance/

31
.gitlab-ci.yml

@ -1,21 +1,32 @@
stages:
- test
- deploy
Corvus:Tests:
tests:
image: python:3.6-slim-stretch
stage: test
script:
- python3 --version
- python3 -m pip --version
- python3 -m pip install pipenv
- python3 -m pipenv --version
- cd server
- pipenv install --dev --system
- pylint corvus
- mypy corvus tests
- PYTHONPATH=$(pwd) coverage run --source corvus -m pytest
- coverage report --fail-under=85 -m --skip-covered
- pycodestyle corvus tests
- pydocstyle corvus
- bash ./run_tests.sh
tags:
- docker
pages:
image: python:3.6-slim-stretch
stage: deploy
script:
- python3 -m pip install pipenv
- cd server
- pipenv install --dev --system
- cd documentation
- sphinx-build -M html "." "_build"
- mv _build/html/ ../../public/
artifacts:
paths:
- public
tags:
- docker
only:
- master

6
CONTRIBUTING.md

@ -0,0 +1,6 @@
# Contributing
* Fork the repository
* Make changes
* Test everything
* Open issue and attach patchfile for changes

23
Dockerfile

@ -1,23 +0,0 @@
FROM python:3.6-slim-stretch
MAINTAINER Drew Short <warrick@sothr.com>
ENV CORVUS_APP_DIRECTORY /opt/corvus
ENV CORVUS_CONFIG_DIRECTORY /srv/corvus/config
ENV CORVUS_DATA_DIRECTORY /srv/corvus/data
RUN mkdir -p ${CORVUS_APP_DIRECTORY} \
&& mkdir -p ${CORVUS_CONFIG_DIRECTORY} \
&& mkdir -p ${CORVUS_DATA_DIRECTORY} \
&& pip install pipenv gunicorn
VOLUME ${CORVUS_CONFIG_DIRECTORY}
VOLUME ${CORVUS_DATA_DIRECTORY}
COPY ./server/ ${CORVUS_APP_DIRECTORY}/
RUN cd ${CORVUS_APP_DIRECTORY} \
&& pipenv install --system --deploy --ignore-pipfile
WORKDIR ${CORVUS_APP_DIRECTORY}
CMD ./entrypoint.sh

33
README.md

@ -0,0 +1,33 @@
# Corvus [![pipeline status](https://gitlab.com/WarrickSothr/Corvus/badges/master/pipeline.svg)](https://gitlab.com/WarrickSothr/Corvus/commits/master)
A python flask framework and web client.
## Parts
### Server
The core API server
More information available at server/README.md
### Administration
The administration SPA.
More information available at administration/README.md
## Release History
## Changelog
See:
* server/CHANGELOG.md
* administration/CHANGELOG.md
## FAQ
* TODO
## Maintainers
* Drew Short <warrick(AT)sothr(DOT)com>

12
server/.dockerignore

@ -0,0 +1,12 @@
.admin_credentials
.coverage
.pylintrc
mypy.ini
run_tests.sh
setup.py
test_settings.py
.mypy_cache/
.pytest_cache/
documentation/
instance/
tests/

0
server/CHANGELOG.md

4
server/Dockerfile

@ -13,11 +13,13 @@ RUN mkdir -p ${CORVUS_APP_DIRECTORY} \
VOLUME ${CORVUS_CONFIG_DIRECTORY}
VOLUME ${CORVUS_DATA_DIRECTORY}
COPY ./server/ ${CORVUS_APP_DIRECTORY}/
COPY ./ ${CORVUS_APP_DIRECTORY}/
RUN cd ${CORVUS_APP_DIRECTORY} \
&& pipenv install --system --deploy --ignore-pipfile
EXPOSE 8080
WORKDIR ${CORVUS_APP_DIRECTORY}
CMD ./entrypoint.sh

25
server/Pipfile

@ -8,18 +8,23 @@ flask = ">=1.0,<1.1"
flask-sqlalchemy = ">=2.3,<2.4"
flask-migrate = ">=2.1,<2.2"
pynacl = ">=1.2,<1.3"
click = "*"
"rfc3339" = "*"
click = ">=6.7,<6.8"
rfc3339 = ">=6.0,<6.1"
iso8601 = ">=0.1,<0.2"
[dev-packages]
python-dotenv = "*"
pytest = "*"
coverage = "*"
pycodestyle = "*"
mypy = "*"
mock = "*"
pylint = "*"
pydocstyle = "*"
python-dotenv = ">=0.8,<0.9"
pytest = ">=3.6<3.7"
coverage = ">=4.5,<4.6"
pycodestyle = ">=2.4,<2.5"
mypy = ">=0.620,<1.0"
mock = ">=2.0,<2.1"
pylint = ">=2.0,<2.1"
pydocstyle = ">=2.1,<2.2"
sphinx = ">=1.7,<1.8"
sphinx-rtd-theme = ">=0.4,<0.5"
sphinxcontrib-httpdomain = ">=1.7,<1.8"
sphinx-jsondomain = "*"
[requires]
python_version = "3.6"

223
server/Pipfile.lock

@ -1,7 +1,7 @@
{
"_meta": {
"hash": {
"sha256": "5f2e59b2a44c07b72414512802099dc64544fc8e724f5e5b033d627ac0a20626"
"sha256": "9ba0840844223ce8320a900a02e33f5ea9314301a827f49f0b92a4eff7227812"
},
"pipfile-spec": 6,
"requires": {
@ -18,9 +18,10 @@
"default": {
"alembic": {
"hashes": [
"sha256:1cd32df9a3b8c1749082ef60ffbe05ff16617b6afadfdabc680dcb9344af33d7"
"sha256:52d73b1d750f1414fa90c25a08da47b87de1e4ad883935718a8f36396e19e78e",
"sha256:eb7db9b4510562ec37c91d00b00d95fde076c1030d3f661aea882eec532b3565"
],
"version": "==0.9.10"
"version": "==1.0.0"
},
"cffi": {
"hashes": [
@ -91,6 +92,15 @@
"index": "pypi",
"version": "==2.3.2"
},
"iso8601": {
"hashes": [
"sha256:210e0134677cc0d02f6028087fee1df1e1d76d372ee1db0bf30bf66c5c1c89a3",
"sha256:49c4b20e1f38aa5cf109ddcd39647ac419f928512c869dc01d5c7098eddede82",
"sha256:bbbae5fb4a7abfe71d4688fd64bff70b91bbd74ef6a99d964bab18f7fdf286dd"
],
"index": "pypi",
"version": "==0.1.12"
},
"itsdangerous": {
"hashes": [
"sha256:cbb3fcf8d3e33df861709ecaf89d9e6629cff0a217bc2848f1b41cd30d360519"
@ -189,9 +199,9 @@
},
"sqlalchemy": {
"hashes": [
"sha256:e21e5561a85dcdf16b8520ae4daec7401c5c24558e0ce004f9b60be75c4b6957"
"sha256:72325e67fb85f6e9ad304c603d83626d1df684fdf0c7ab1f0352e71feeab69d8"
],
"version": "==1.2.9"
"version": "==1.2.10"
},
"werkzeug": {
"hashes": [
@ -202,12 +212,19 @@
}
},
"develop": {
"alabaster": {
"hashes": [
"sha256:674bb3bab080f598371f4443c5008cbfeb1a5e622dd312395d2d82af2c54c456",
"sha256:b63b1f4dc77c074d386752ec4a8a7517600f6c0db8cd42980cae17ab7b3275d7"
],
"version": "==0.7.11"
},
"astroid": {
"hashes": [
"sha256:0ef2bf9f07c3150929b25e8e61b5198c27b0dca195e156f0e4d5bdd89185ca1a",
"sha256:fc9b582dba0366e63540982c3944a9230cbc6f303641c51483fa547dcc22393a"
"sha256:0a0c484279a5f08c9bcedd6fa9b42e378866a7dcc695206b92d59dc9f2d9760d",
"sha256:218e36cf8d98a42f16214e8670819ce307fa707d1dcf7f9af84c7aede1febc7f"
],
"version": "==1.6.5"
"version": "==2.0.1"
},
"atomicwrites": {
"hashes": [
@ -223,15 +240,39 @@
],
"version": "==18.1.0"
},
"babel": {
"hashes": [
"sha256:6778d85147d5d85345c14a26aada5e478ab04e39b078b0745ee6870c2b5cf669",
"sha256:8cba50f48c529ca3fa18cf81fa9403be176d374ac4d60738b839122dfaaa3d23"
],
"version": "==2.6.0"
},
"certifi": {
"hashes": [
"sha256:13e698f54293db9f89122b0581843a782ad0934a4fe0172d2a980ba77fc61bb7",
"sha256:9fa520c1bacfb634fa7af20a76bcbd3d5fb390481724c597da32c719a7dca4b0"
],
"version": "==2018.4.16"
},
"chardet": {
"hashes": [
"sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae",
"sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"
],
"version": "==3.0.4"
},
"coverage": {
"hashes": [
"sha256:03481e81d558d30d230bc12999e3edffe392d244349a90f4ef9b88425fac74ba",
"sha256:0b136648de27201056c1869a6c0d4e23f464750fd9a9ba9750b8336a244429ed",
"sha256:104ab3934abaf5be871a583541e8829d6c19ce7bde2923b2751e0d3ca44db60a",
"sha256:10a46017fef60e16694a30627319f38a2b9b52e90182dddb6e37dcdab0f4bf95",
"sha256:15b111b6a0f46ee1a485414a52a7ad1d703bdf984e9ed3c288a4414d3871dcbd",
"sha256:198626739a79b09fa0a2f06e083ffd12eb55449b5f8bfdbeed1df4910b2ca640",
"sha256:1c383d2ef13ade2acc636556fd544dba6e14fa30755f26812f54300e401f98f2",
"sha256:23d341cdd4a0371820eb2b0bd6b88f5003a7438bbedb33688cd33b8eae59affd",
"sha256:28b2191e7283f4f3568962e373b47ef7f0392993bb6660d079c62bd50fe9d162",
"sha256:2a5b73210bad5279ddb558d9a2bfedc7f4bf6ad7f3c988641d83c40293deaec1",
"sha256:2eb564bbf7816a9d68dd3369a510be3327f1c618d2357fa6b1216994c2e3d508",
"sha256:337ded681dd2ef9ca04ef5d93cfc87e52e09db2594c296b4a0a3662cb1b41249",
"sha256:3a2184c6d797a125dca8367878d3b9a178b6fdd05fdc2d35d758c3006a1cd694",
@ -265,6 +306,35 @@
"index": "pypi",
"version": "==4.5.1"
},
"docutils": {
"hashes": [
"sha256:02aec4bd92ab067f6ff27a38a38a41173bf01bed8f89157768c1573f53e474a6",
"sha256:51e64ef2ebfb29cae1faa133b3710143496eca21c530f3f71424d77687764274",
"sha256:7a4bd47eaf6596e1295ecb11361139febe29b084a87bf005bf899f9a42edc3c6"
],
"version": "==0.14"
},
"faker": {
"hashes": [
"sha256:228970fbdd8322e92406cff256338c1cf0a330332d0172f4403863689a3c5576",
"sha256:310b20f3c497a777622920dca314d90f774028d49c7ee7ccfa96ca4b9d9bf429"
],
"version": "==0.7.18"
},
"idna": {
"hashes": [
"sha256:156a6814fb5ac1fc6850fb002e0852d56c0c8d2531923a51032d1b70760e186e",
"sha256:684a38a6f903c1d71d6d5fac066b58d7768af4de2b832e426ec79c30daa94a16"
],
"version": "==2.7"
},
"imagesize": {
"hashes": [
"sha256:3620cc0cadba3f7475f9940d22431fc4d407269f1be59ec9b8edcca26440cf18",
"sha256:5b326e4678b6925158ccc66a9fa3122b6106d7c876ee32d7de6ce59385b96315"
],
"version": "==1.0.0"
},
"isort": {
"hashes": [
"sha256:1153601da39a25b14ddc54955dbbacbb6b2d19135386699e2ad58517953b34af",
@ -273,6 +343,13 @@
],
"version": "==4.3.4"
},
"jinja2": {
"hashes": [
"sha256:74c935a1b8bb9a3947c50a54766a969d4846290e1e788ea44c1392163723c3bd",
"sha256:f84be1bb0040caca4cea721fcbbbbd61f9be9464ca236387158b0feea01914a4"
],
"version": "==2.10"
},
"lazy-object-proxy": {
"hashes": [
"sha256:0ce34342b419bd8f018e6666bfef729aec3edf62345a53b537a4dcc115746a33",
@ -307,6 +384,12 @@
],
"version": "==1.3.1"
},
"markupsafe": {
"hashes": [
"sha256:a6be69091dac236ea9c6bc7d012beab42010fa914c459791d627dad4910eb665"
],
"version": "==1.0"
},
"mccabe": {
"hashes": [
"sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42",
@ -332,26 +415,32 @@
},
"mypy": {
"hashes": [
"sha256:1b899802a89b67bb68f30d788bba49b61b1f28779436f06b75c03495f9d6ea5c",
"sha256:f472645347430282d62d1f97d12ccb8741f19f1572b7cf30b58280e4e0818739"
"sha256:673ea75fb750289b7d1da1331c125dc62fc1c3a8db9129bb372ae7b7d5bf300a",
"sha256:c770605a579fdd4a014e9f0a34b6c7a36ce69b08100ff728e96e27445cef3b3c"
],
"index": "pypi",
"version": "==0.610"
"version": "==0.620"
},
"packaging": {
"hashes": [
"sha256:e9215d2d2535d3ae866c3d6efc77d5b24a0192cce0ff20e42896cc0664f889c0",
"sha256:f019b770dd64e585a99714f1fd5e01c7a8f11b45635aa953fd41c689a657375b"
],
"version": "==17.1"
},
"pbr": {
"hashes": [
"sha256:4f2b11d95917af76e936811be8361b2b19616e5ef3b55956a429ec7864378e0c",
"sha256:e0f23b61ec42473723b2fec2f33fb12558ff221ee551962f01dd4de9053c2055"
"sha256:1b8be50d938c9bb75d0eaf7eda111eec1bf6dc88a62a6412e33bf077457e0f45",
"sha256:b486975c0cafb6beeb50ca0e17ba047647f229087bd74e37f4a7e2cac17d2caa"
],
"version": "==4.1.0"
"version": "==4.2.0"
},
"pluggy": {
"hashes": [
"sha256:7f8ae7f5bdf75671a718d2daf0a64b7885f74510bcd98b1a0bb420eb9a9d0cff",
"sha256:d345c8fe681115900d6da8d048ba67c25df42973bda370783cd58826442dcd7c",
"sha256:e160a7fcf25762bb60efc7e171d4497ff1d8d2d75a3d0df7a21b76821ecbf5c5"
"sha256:6e3836e39f4d36ae72840833db137f7b7d35105079aee6ec4a62d9f80d594dd1",
"sha256:95eb8364a4708392bae89035f45341871286a333f749c3141c20573d2b3876e1"
],
"version": "==0.6.0"
"version": "==0.7.1"
},
"py": {
"hashes": [
@ -378,21 +467,47 @@
"index": "pypi",
"version": "==2.1.1"
},
"pygments": {
"hashes": [
"sha256:78f3f434bcc5d6ee09020f92ba487f95ba50f1e3ef83ae96b9d5ffa1bab25c5d",
"sha256:dbae1046def0efb574852fab9e90209b23f556367b5a320c0bcb871c77c3e8cc"
],
"version": "==2.2.0"
},
"pylint": {
"hashes": [
"sha256:a48070545c12430cfc4e865bf62f5ad367784765681b3db442d8230f0960aa3c",
"sha256:fff220bcb996b4f7e2b0f6812fd81507b72ca4d8c4d05daf2655c333800cb9b3"
"sha256:2c90a24bee8fae22ac98061c896e61f45c5b73c2e0511a4bf53f99ba56e90434",
"sha256:454532779425098969b8f54ab0f056000b883909f69d05905ea114df886e3251"
],
"index": "pypi",
"version": "==1.9.2"
"version": "==2.0.1"
},
"pyparsing": {
"hashes": [
"sha256:0832bcf47acd283788593e7a0f542407bd9550a55a8a8435214a1960e04bcb04",
"sha256:281683241b25fe9b80ec9d66017485f6deff1af5cde372469134b56ca8447a07",
"sha256:8f1e18d3fd36c6795bb7e02a39fd05c611ffc2596c1e0d995d34d67630426c18",
"sha256:9e8143a3e15c13713506886badd96ca4b579a87fbdf49e550dbfc057d6cb218e",
"sha256:b8b3117ed9bdf45e14dcc89345ce638ec7e0e29b2b579fa1ecf32ce45ebac8a5",
"sha256:e4d45427c6e20a59bf4f88c639dcc03ce30d193112047f94012102f235853a58",
"sha256:fee43f17a9c4087e7ed1605bd6df994c6173c1e977d7ade7b651292fab2bd010"
],
"version": "==2.2.0"
},
"pytest": {
"hashes": [
"sha256:0453c8676c2bee6feb0434748b068d5510273a916295fd61d306c4f22fbfd752",
"sha256:4b208614ae6d98195430ad6bde03641c78553acee7c83cec2e85d613c0cd383d"
"sha256:341ec10361b64a24accaec3c7ba5f7d5ee1ca4cebea30f76fad3dd12db9f0541",
"sha256:952c0389db115437f966c4c2079ae9d54714b9455190e56acebe14e8c38a7efa"
],
"index": "pypi",
"version": "==3.6.3"
"version": "==3.6.4"
},
"python-dateutil": {
"hashes": [
"sha256:1adb80e7a782c12e52ef9a8182bebeb73f1d7e24e374397af06fb4956c8dc5c0",
"sha256:e27001de32f627c22380a688bcc43ce83504a7bc5da472209b4c70f02829f0b8"
],
"version": "==2.7.3"
},
"python-dotenv": {
"hashes": [
@ -402,6 +517,20 @@
"index": "pypi",
"version": "==0.8.2"
},
"pytz": {
"hashes": [
"sha256:a061aa0a9e06881eb8b3b2b43f05b9439d6583c206d0a6c340ff72a7b6669053",
"sha256:ffb9ef1de172603304d9d2819af6f5ece76f2e85ec10692a524dd876e72bf277"
],
"version": "==2018.5"
},
"requests": {
"hashes": [
"sha256:63b52e3c866428a224f97cab011de738c36aec0185aa91cfacd418b5d58911d1",
"sha256:ec22d826a36ed72a7358ff3fe56cbd4ba69dd7a6718ffd450ff0e9df7a47ce6a"
],
"version": "==2.19.1"
},
"six": {
"hashes": [
"sha256:70e8a77beed4562e7f14fe23a786b54f6296e34344c23bc42f07b15018ff98e9",
@ -416,6 +545,45 @@
],
"version": "==1.2.1"
},
"sphinx": {
"hashes": [
"sha256:217ad9ece2156ed9f8af12b5d2c82a499ddf2c70a33c5f81864a08d8c67b9efc",
"sha256:a765c6db1e5b62aae857697cd4402a5c1a315a7b0854bbcd0fc8cdc524da5896"
],
"index": "pypi",
"version": "==1.7.6"
},
"sphinx-jsondomain": {
"hashes": [
"sha256:cdc03fa28dafc383d30451d724ea293dded4619d777acfa003b2a538bbf52f85",
"sha256:fd4688d3026bd40c8d3aea94c0ea423d32dd9a046e6dc80fba83aaf60c87abc4"
],
"index": "pypi",
"version": "==0.0.3"
},
"sphinx-rtd-theme": {
"hashes": [
"sha256:3b49758a64f8a1ebd8a33cb6cc9093c3935a908b716edfaa5772fd86aac27ef6",
"sha256:80e01ec0eb711abacb1fa507f3eae8b805ae8fa3e8b057abfdf497e3f644c82c"
],
"index": "pypi",
"version": "==0.4.1"
},
"sphinxcontrib-httpdomain": {
"hashes": [
"sha256:1fb5375007d70bf180cdd1c79e741082be7aa2d37ba99efe561e1c2e3f38191e",
"sha256:ac40b4fba58c76b073b03931c7b8ead611066a6aebccafb34dc19694f4eb6335"
],
"index": "pypi",
"version": "==1.7.0"
},
"sphinxcontrib-websupport": {
"hashes": [
"sha256:68ca7ff70785cbe1e7bccc71a48b5b6d965d79ca50629606c7861a21b206d9dd",
"sha256:9de47f375baf1ea07cdb3436ff39d7a9c76042c10a769c52353ec46e4e8fc3b9"
],
"version": "==1.1.0"
},
"typed-ast": {
"hashes": [
"sha256:0948004fa228ae071054f5208840a1e88747a357ec1101c17217bfe99b299d58",
@ -444,6 +612,13 @@
],
"version": "==1.1.0"
},
"urllib3": {
"hashes": [
"sha256:a68ac5e15e76e7e5dd2b8f94007233e01effe3e50e8daddf69acfd81cb686baf",
"sha256:b5725a0bd4ba422ab0e66e89e030c806576753ea3ee08554382c14e685d117b5"
],
"version": "==1.23"
},
"wrapt": {
"hashes": [
"sha256:d4d560d479f2c21e1b5443bbd15fe7ec4b37fe7e53d335d3b9b0a7b1226fe3c6"

57
server/README.md

@ -0,0 +1,57 @@
# Corvus Server
## [API Documentation](https://warricksothr.gitlab.io/Corvus)
## Requirements
* Python 3.6
* Pipenv
## Installation
```bash
git clone https://gitlab.com/WarrickSothr/Corvus.git
cd Corvus/server
pipenv install
pipenv shell
```
## Configuration
## Running
### Docker
```bash
docker build -t corvus:local-test .
docker run -d corvus:local-test
```
### Local Development Version
```bash
FLASK_APP=corvus:corvus flask db upgrade
python manage.py user register-admin
FLASK_APP=corvus:corvus flask run
```
## FAQ
## Development
```bash
pipenv install --dev
```
* Make changes
* Add/Update tests
```bash
./run_tests
```
* If everything passes follow contributing guide.
## Contributing
See ../CONTRIBUTING.md

4
server/corvus/__init__.py

@ -6,7 +6,8 @@ from flask import Flask
from flask_migrate import Migrate
from corvus.db import db
from corvus.errors import BaseError, handle_corvus_base_error
from corvus.errors import BaseError, handle_corvus_base_error, \
handle_corvus_404_error
from corvus.utility import json_utility, session_utility
dictConfig({
@ -92,6 +93,7 @@ def register_error_handlers(app: Flask) -> None:
:param app:
:return:
"""
app.register_error_handler(404, handle_corvus_404_error)
app.register_error_handler(BaseError, handle_corvus_base_error)

6
server/corvus/api/authentication_api.py

@ -2,7 +2,7 @@
from flask import Blueprint, g
from corvus.api.decorators import return_json
from corvus.api.model import APIResponse
from corvus.api.model import APIMessage, APIResponse
from corvus.middleware import authentication_middleware
from corvus.service import (
user_token_service,
@ -37,7 +37,7 @@ def login_bump() -> APIResponse:
:return: A time stamp for the bumped login
"""
user_service.update_last_login_time(g.user)
return APIResponse({'lastLoginTime': g.user.last_login_time}, 200)
return APIResponse(g.user, 200, ['lastLoginTime'])
@AUTH_BLUEPRINT.route('/logout', methods=['POST'])
@ -50,4 +50,4 @@ def logout() -> APIResponse:
:return:
"""
authentication_service.logout(g.user_token)
return APIResponse(None, 200)
return APIResponse(APIMessage(True, None), 200)

74
server/corvus/api/model.py

@ -1,8 +1,13 @@
"""Model definitions for the api module."""
from typing import Any, List, Optional
from typing import Any, List, Optional, Dict, Type
from flask_sqlalchemy import Pagination
class APIResponse: # pylint: disable=too-few-public-methods
from corvus import db
# pylint: disable=too-few-public-methods
class APIResponse:
"""Custom class to wrap api responses."""
def __init__(self,
@ -13,3 +18,68 @@ class APIResponse: # pylint: disable=too-few-public-methods
self.payload = payload
self.status = status
self.options = options
# pylint: disable=too-few-public-methods
class BaseAPIMessage:
"""Base class for API responses."""
def to_dict(self) -> Dict[str, Any]:
"""Abstract to_dict."""
raise NotImplementedError('Not Implemented')
# pylint: disable=too-few-public-methods
class APIMessage(BaseAPIMessage):
"""Simple class to encapsulate response messages."""
success: bool
message: Optional[str]
def __init__(self,
success: bool,
message: Optional[str]) -> None:
"""Construct an APIMessage."""
self.success = success
self.message = message
def to_dict(self) -> Dict[str, Any]:
"""Serialize an APIMessage to a dict."""
obj: Dict[str, Any] = {
'success': self.success
}
if self.message is not None:
obj['message'] = self.message
return obj
# pylint: disable=too-few-public-methods
class APIPage(BaseAPIMessage):
"""Simple page response."""
def __init__(self,
page: int,
total_count: int,
last_page: int,
items: List[Type[db.Model]]) -> None:
"""Construct and APIPage."""
self.page = page
self.count = len(items)
self.total_count = total_count
self.last_page = last_page
self.items = items
def to_dict(self) -> Dict[str, Any]:
"""Serialize an APIPage."""
return {
'page': self.page,
'count': self.count,
'totalCount': self.total_count,
'lastPage': self.last_page,
'items': self.items
}
@staticmethod
def from_page(page: Pagination) -> 'APIPage':
"""Create an APIPage from a Pagination object."""
return APIPage(page.page, page.total, page.pages, page.items)

80
server/corvus/api/user_api.py

@ -1,20 +1,45 @@
"""User API blueprint and endpoint definitions."""
from flask import Blueprint, abort, request
from flask import Blueprint, abort, request, g
from corvus.api.decorators import return_json
from corvus.api.model import APIResponse
from corvus.api.model import APIResponse, APIMessage, APIPage
from corvus.middleware import authentication_middleware
from corvus.model import User
from corvus.service import user_service, transformation_service
from corvus.service import (
patch_service,
transformation_service,
user_service
)
from corvus.service.patch_service import get_patch_fields
from corvus.service.role_service import Role
from corvus.utility.pagination_utility import get_pagination_params
USER_BLUEPRINT = Blueprint(
name='user', import_name=__name__, url_prefix='/user')
@USER_BLUEPRINT.route('', methods=['GET'])
@return_json
@authentication_middleware.require_token_auth
@authentication_middleware.require_role(required_role=Role.USER)
def get_users() -> APIResponse:
"""
Get a list of users.
:return: a paginated list of users
"""
page, per_page = get_pagination_params(request.args)
user_page = user_service.get_users(page, per_page)
if user_page is not None:
return APIResponse(APIPage.from_page(user_page), 200)
return abort(404)
@USER_BLUEPRINT.route('/<name>', methods=['GET'])
@return_json
@authentication_middleware.require_token_auth
@authentication_middleware.require_role(required_role=Role.USER)
def get_user(name: str) -> APIResponse:
"""
Get a user.
@ -27,7 +52,27 @@ def get_user(name: str) -> APIResponse:
return abort(404)
@USER_BLUEPRINT.route('/', methods=['POST'])
@USER_BLUEPRINT.route('/<name>', methods=['PATCH'])
@return_json
@authentication_middleware.require_token_auth
@authentication_middleware.require_role(required_role=Role.USER)
def patch_user(name: str) -> APIResponse:
"""
Patch a user.
:return: user if patched, 4xx error on patching issue, 404 on nonexistent
"""
user = user_service.find_by_name(name)
if user is not None:
user_patch: User = transformation_service.deserialize_model(
User, request.json)
patched_user = patch_service.patch(
g.user, user, user_patch, get_patch_fields(request.json))
return APIResponse(patched_user, 200)
return abort(404)
@USER_BLUEPRINT.route('', methods=['POST'])
@return_json
@authentication_middleware.require_token_auth
@authentication_middleware.require_role(required_role=Role.ADMIN)
@ -38,10 +83,33 @@ def register_user() -> APIResponse:
:return: The newly registered User
"""
new_user: User = transformation_service.deserialize_model(
User.__name__, request.json)
User, request.json)
requested_password = None
if 'password' in request.json:
requested_password = request.json['password'].strip()
registered_user = user_service.register(
name=new_user.name,
password=None,
password=requested_password,
role=new_user.role
)
return APIResponse(payload=registered_user, status=200)
@USER_BLUEPRINT.route('/<name>', methods=['DELETE'])
@return_json
@authentication_middleware.require_token_auth
@authentication_middleware.require_role(required_role=Role.ADMIN)
def delete_user(name: str) -> APIResponse:
"""
Delete a user with the service.
:return: The newly registered User
"""
user = user_service.find_by_name(name)
if user is not None:
user_service.delete(user)
return APIResponse(
APIMessage(True, 'Successfully Deleted'), status=200)
return abort(404)

11
server/corvus/errors.py

@ -1,8 +1,10 @@
"""Error definitions for Corvus."""
from typing import Dict
from werkzeug.exceptions import HTTPException
from corvus.api.decorators import return_json
from corvus.api.model import APIResponse
from corvus.api.model import APIResponse, APIMessage
class BaseError(Exception):
@ -44,6 +46,13 @@ class ValidationError(ClientError):
pass
@return_json
def handle_corvus_404_error(exception: HTTPException) -> APIResponse:
"""Error handler for 404 Corvus errors."""
return APIResponse(
payload=APIMessage(False, 'Not Found'), status=exception.code)
@return_json
def handle_corvus_base_error(error: BaseError) -> APIResponse:
"""Error handler for basic Corvus raised errors."""

2
server/corvus/middleware/authentication_middleware.py

@ -146,7 +146,7 @@ def require_token_auth(func: Callable) -> Callable:
request.headers.get('Authorization', None))
if token and authenticate_with_token(token.username, token.password):
return func(*args, **kwargs)
return authentication_failed('Bearer')
return authentication_failed('Token')
return decorate

26
server/corvus/service/authentication_service.py

@ -1,14 +1,40 @@
"""Service to handle authentication."""
import re
from datetime import datetime
from typing import Optional
from nacl import pwhash
from nacl.exceptions import InvalidkeyError
from corvus import errors
from corvus.model import User, UserToken
from corvus.service import user_token_service
def validate_password_strength(proposed_password: str) -> str:
"""Validate that a password meets minimum strength requirements."""
# calculating the length
length_error = len(proposed_password) < 8
# searching for digits
digit_error = re.search(r"\d", proposed_password) is None
# searching for uppercase
uppercase_error = re.search(r"[A-Z]", proposed_password) is None
# searching for lowercase
lowercase_error = re.search(r"[a-z]", proposed_password) is None
if length_error or digit_error or uppercase_error or lowercase_error:
raise errors.ValidationError(
' '.join(['The password must be at least 8 characters long.',
'Contain one or more digits,',
'one or more uppercase characters,',
'and one or more lowercase characters']))
return proposed_password
def is_valid_password(user: User, password: str) -> bool:
"""
User password must pass pwhash verify.

117
server/corvus/service/patch_service.py

@ -0,0 +1,117 @@
"""Patching support for db.Model objects."""
from typing import Type, Set, Optional, Any, Dict
from corvus import db
from corvus import errors
from corvus.model import User
from corvus.service import transformation_service
from corvus.service import validation_service
def get_patch_fields(patch_json: Dict[str, Any]) -> Set[str]:
"""Convert json fields to python fields."""
return {
transformation_service.convert_key_from_json(key) for key in
patch_json.keys()}
def perform_patch(request_user: User,
original_model: Type[db.Model],
patch_model: Type[db.Model],
model_attributes: Set[str],
patched_fields: Optional[Set[str]]) \
-> Type[db.Model]:
"""
Patch changed attributes onto original model.
:param request_user:
:param original_model: The model to apply the patches to
:param patch_model: The model to pull the patch information from
:param model_attributes: The attributes that are valid for patching
:param patched_fields: The explicitly passed fields for patching
:return: Thd patched original_model
"""
change_set = validation_service.determine_change_set(
original_model, patch_model, model_attributes, patched_fields)
model_validation = validation_service.validate_model(
request_user, original_model, change_set)
if model_validation.success:
for attribute, value in change_set.items():
setattr(original_model, attribute, value)
db.session.commit()
else:
raise errors.ValidationError(
'Restricted attributes modified. Invalid Patch Set.')
return original_model
def versioning_aware_patch(request_user: User,
original_model: Type[db.Model],
patch_model: Type[db.Model],
model_attributes: Set[str],
patched_fields: Optional[Set[str]]) \
-> Type[db.Model]:
"""
Account for version numbers in the model.
Versions must match to perform the patching. Otherwise a simultaneous edit
error has occurred. If the versions match and the patch moves forward, bump
the version on the model by 1 to prevent other reads from performing a
simultaneous edit.
:param patched_fields:
:param request_user:
:param original_model: The model to apply the patches to
:param patch_model: The model to pull the patch information from
:param model_attributes: The attributes that are valid for patching
:return: Thd patched original_model
"""
if original_model.version == patch_model.version:
patch_model.version = patch_model.version + 1
return perform_patch(
request_user,
original_model,
patch_model,
model_attributes,
patched_fields)
raise errors.ValidationError(
'Versions do not match. Concurrent edit in progress.')
def patch(
request_user: User,
original_model: Type[db.Model],
patch_model: Type[db.Model],
patched_fields: Optional[Set[str]] = None) -> Type[db.Model]:
"""
Patch the original model with the patch model data.
:param request_user:
:param original_model: The model to apply the patches to
:param patch_model: The model to pull the patch information from
:param patched_fields:
:return: The patched original_model
"""
if type(original_model) is type(patch_model):
model_attributes = validation_service.get_changable_attribute_names(
original_model)
if patch_model.id is not None and original_model.id != patch_model.id:
raise errors.ValidationError('Cannot change ids through patching')
if 'version' in model_attributes:
return versioning_aware_patch(
request_user,
original_model,
patch_model,
model_attributes,
patched_fields)
return perform_patch(
request_user,
original_model,
patch_model,
model_attributes,
patched_fields)
raise errors.ValidationError(
'Model types "{}" and "{}" do not match'.format(
original_model.__class__.__name__,
patch_model.__class__.__name__
))

22
server/corvus/service/role_service.py

@ -66,13 +66,33 @@ class RoleTree(defaultdict):
return [self.data] + self.parent.get_parent_roles()
return [self.data]
def get_children_roles(self) -> List[Role]:
"""Return all the roles from self to the lowest child."""
if self.roles and (
len(self.roles.keys()) > 1 or len(self.roles[self.data]) > 1):
child_roles = [self.data]
for role in self.roles.keys():
for role_tree in self.roles[role]:
if role_tree.data != self.data:
child_roles.extend(role_tree.get_children_roles())
return child_roles
return [self.data]
def find_roles_in_hierarchy(self, request_role: Role) -> Set[Role]:
"""Find a set of all roles that fall within the hierarchy."""
roles: List[Role] = []
role_trees = self.find_role(request_role)
for role_tree in role_trees:
roles.extend(role_tree.get_parent_roles())
return set(role for role in roles)
return set(roles)
def find_children_roles(self, request_role: Role) -> Set[Role]:
"""Find all children roles, including this role."""
roles: List[Role] = []
role_trees = self.find_role(request_role)
for role_tree in role_trees:
roles.extend(role_tree.get_children_roles())
return set(roles)
ROLES = RoleTree(None, Role.ADMIN)

28
server/corvus/service/transformation_service.py

@ -1,5 +1,6 @@
"""Handle Model Serialization."""
import logging
import re
from typing import Dict, Callable, Any, List, Optional, Type
from corvus import errors
@ -15,7 +16,6 @@ class BaseTransformer:
def __init__(self, model: Type[db.Model]) -> None:
"""Initialize the base serializer."""
self._fields: Dict[str, Callable[[db.Model], Any]] = {}
self.model = model
def serialize(self, options: Optional[List[str]]) -> Any:
@ -71,9 +71,21 @@ _model_transformers: Dict[str, Type[BaseTransformer]] = {}
def register_transformer(
model_name: str, model_serializer: Type[BaseTransformer]) -> None:
model_serializer: Type[BaseTransformer]) -> Type[BaseTransformer]:
"""Add a model to the serializer mapping."""
_model_transformers[model_name] = model_serializer
model_name = model_serializer.type.__name__
if model_name not in _model_transformers:
_model_transformers[model_name] = model_serializer
else:
raise KeyError(
' '.join([
'A transformer for type "{}" already exists with class "{}".',
'Cannot register a new transformer with class "{}"'
]).format(
model_name,
_model_transformers[model_name].__name__,
model_serializer.__name__))
return model_serializer
def serialize_model(model_obj: db.Model,
@ -88,14 +100,20 @@ def serialize_model(model_obj: db.Model,
def deserialize_model(
model_type: str,
model_type: Type[db.Model],
json_model_object: dict,
options: Optional[List[str]] = None) -> db.Model:
"""Lookup a Model and hand it off to the deserializer."""
try:
transformer = _model_transformers[model_type]
transformer = _model_transformers[model_type.__name__]
return transformer(
transformer.type()).deserialize(json_model_object, options)
except KeyError:
raise NotImplementedError(
'{} has no registered serializers'.format(model_type))
def convert_key_from_json(key: str) -> str:
"""Convert a key from camelCase."""
substitute = re.sub('(.)([A-Z][a-z]+)', r'\1_\2', key)
return re.sub('([a-z0-9])([A-Z])', r'\1_\2', substitute).lower()

89
server/corvus/service/user_service.py

@ -3,21 +3,30 @@ import logging
import random
import string
from datetime import datetime
from typing import Optional, Dict, Callable, Any
from typing import Optional, Dict, Callable, Any, Tuple
from flask_sqlalchemy import Pagination
from iso8601 import iso8601
from corvus import errors
from corvus.db import db
from corvus.model import User
from corvus.service.role_service import Role
from corvus.service import role_service
from corvus.service.authentication_service import validate_password_strength
from corvus.service.transformation_service import (
BaseTransformer,
register_transformer
)
from corvus.service.validation_service import (
BaseValidator,
register_validator
)
from corvus.utility import authentication_utility
LOGGER = logging.getLogger(__name__)
@register_transformer
class UserTransformer(BaseTransformer):
"""Serialize User model."""
@ -61,7 +70,7 @@ class UserTransformer(BaseTransformer):
def deserialize_creation_time(
model: User, creation_time: datetime) -> None:
"""User creation time."""
model.creation_time = creation_time
model.creation_time = iso8601.parse_date(creation_time)
def serialize_last_login_time(self) -> datetime:
"""User last login time."""
@ -71,7 +80,7 @@ class UserTransformer(BaseTransformer):
def deserialize_last_login_time(
model: User, last_login_time: datetime) -> None:
"""User last login time."""
model.last_login_time = last_login_time
model.last_login_time = iso8601.parse_date(last_login_time)
def serialize_version(self) -> int:
"""User version."""
@ -89,10 +98,68 @@ class UserTransformer(BaseTransformer):
@staticmethod
def deserialize_role(model: User, role_value: str) -> None:
"""User role."""
model.role = Role(role_value)
model.role = role_service.Role(role_value)
register_transformer(User.__name__, UserTransformer)
@register_validator
class UserValidator(BaseValidator):
"""Validate User model."""
type = User
def _validators(
self) -> Dict[str, Callable[[Any], Tuple[bool, str]]]:
return {
'id': self.no_validation,
'name': self.validate_name,
'role': self.validate_role,
'password_hash': self.no_validation,
'password_revision': self.no_validation,
'creation_time': self.no_validation,
'last_login_time': self.no_validation,
'version': self.validate_version
}
def validate_name(self, new_name: Any) -> Tuple[bool, str]:
"""
Name changes are only allowed to be performed by an Admin.
:param new_name:
:return:
"""
validation_result = (self.request_user.role == role_service.Role.ADMIN
or new_name is None)
if validation_result:
return validation_result, ''
return (validation_result,
'Names can only be changed by an administrator')
def validate_role(self, new_role: Any) -> Tuple[bool, str]:
"""
Roles can only be increased to the level of the request_user.
:param new_role:
:return:
"""
acceptable_roles = role_service.ROLES.find_children_roles(
self.request_user.role)
role = new_role if new_role is not None else self.model.role
if role in acceptable_roles:
return True, ''
return False, 'Role escalation is not permitted'
def get_users(
page: int, per_page: int = 20, max_per_page: int = 100) -> Pagination:
"""
Page through users in the system.
:param page: The page to request
:param per_page: The number per page
:param max_per_page:
:return:
"""
return User.query.paginate(page, per_page, True, max_per_page)
def find_by_name(name: str) -> Optional[User]:
@ -105,17 +172,25 @@ def find_by_name(name: str) -> Optional[User]:
return User.query.filter_by(name=name).first()
def register(name: str, password: Optional[str], role: Optional[str]) -> User:
def register(
name: str,
password: Optional[str],
role: Optional[str],
validate_password: bool = True) -> User:
"""
Register a new user.
:param name: Desired user name. Must be unique and not already registered
:param password: Password to be hashed and stored for the user
:param role: Role to assign the user [ROLE_USER, ROLE_ADMIN]
:param validate_password: Perform password validation
:return:
"""
if validate_password and password is not None:
validate_password_strength(password)
password = password if password is not None else ''.join(
random.choices(string.ascii_letters + string.digits, k=32))
role = role if role is not None else User.ROLE_USER
if find_by_name(name=name) is not None:

12
server/corvus/service/user_token_service.py

@ -3,6 +3,8 @@ import uuid
from datetime import datetime
from typing import Optional, Dict, Callable, Any
from iso8601 import iso8601
from corvus.db import db
from corvus.model import User, UserToken
from corvus.service.transformation_service import (
@ -11,6 +13,7 @@ from corvus.service.transformation_service import (
)
@register_transformer
class UserTokenTransformer(BaseTransformer):
"""Serialize User model."""
@ -76,7 +79,7 @@ class UserTokenTransformer(BaseTransformer):
def deserialize_expiration_time(
model: UserToken, expiration_time: datetime) -> None:
"""User token expiration time."""
model.expiration_time = expiration_time
model.expiration_time = iso8601.parse_date(expiration_time)
def serialize_creation_time(self) -> datetime:
"""User token creation time."""
@ -86,7 +89,7 @@ class UserTokenTransformer(BaseTransformer):
def deserialize_creation_time(
model: UserToken, creation_time: datetime) -> None:
"""User token creation time."""
model.creation_time = creation_time
model.creation_time = iso8601.parse_date(creation_time)
def serialize_last_usage_time(self) -> datetime:
"""User token last usage time."""
@ -96,7 +99,7 @@ class UserTokenTransformer(BaseTransformer):
def deserialize_last_usage_time(
model: UserToken, last_usage_time: datetime) -> None:
"""User token last usage time."""
model.last_usage_time = last_usage_time
model.last_usage_time = iso8601.parse_date(last_usage_time)
def serialize_version(self) -> int:
"""User token version."""
@ -108,9 +111,6 @@ class UserTokenTransformer(BaseTransformer):
model.version = version
register_transformer(UserToken.__name__, UserTokenTransformer)
def generate_token() -> uuid.UUID:
"""
Generate a unique token.

170
server/corvus/service/validation_service.py

@ -0,0 +1,170 @@
"""Validation service for Corvus models."""
from typing import Type, Dict, Callable, Any, Set, Optional, Tuple
from sqlalchemy import orm
from corvus import db, errors
from corvus.model import User
_changable_attribute_names: Dict[str, Set[str]] = {}
def get_changable_attribute_names(model: Type[db.Model]) -> Set[str]:
"""
Retrieve columns from a SQLAlchemy model.
Caches already seen models to improve performance.
:param model:
:return: A list of changeable model attribute names
"""
class_name = model.__class__.__name__
if class_name in _changable_attribute_names:
return _changable_attribute_names[class_name]
model_attributes = {prop.key for prop in
orm.class_mapper(model.__class__).iterate_properties
if isinstance(prop, orm.ColumnProperty)}
_changable_attribute_names[class_name] = model_attributes
return model_attributes
def determine_change_set(original_model: Type[db.Model],
update_model: Type[db.Model],
model_attributes: Set[str],
options: Optional[Set[str]]) -> Dict[str, Any]:
"""
Determine the change set for two models.
:param options:
:param original_model:
:param update_model:
:param model_attributes:
:return:
"""
if options is None:
options = model_attributes
else:
options = model_attributes.intersection(options)
change_set = {}
for attribute in options:
original_attribute = getattr(original_model, attribute)
changed_attribute = getattr(update_model, attribute)
if original_attribute != changed_attribute:
change_set[attribute] = changed_attribute
return change_set
class ModelValidationResult: # pylint: disable=too-few-public-methods
"""Result from model validation."""
field_results: Dict[str, Tuple[bool, str]]
success: bool
failed: Dict[str, str] = {}
def __init__(self, field_results: Dict[str, Tuple[bool, str]]) -> None:
"""Initialize the validation results."""
self.field_results = field_results
self.success = len(
[result for (result, _) in self.field_results.values() if
result is False]) == 0
if not self.success:
failed = [(field, rslt[1]) for (field, rslt) in
self.field_results.items() if rslt[0] is False]
self.failed = {}
for field, reason in failed:
self.failed[field] = reason
def get_change_set_value(
change_set: Optional[Dict[str, Any]], field: str) -> Any:
"""Read a value or default from changeset."""
if change_set is not None and field in change_set.keys():
return change_set[field]
return None
class BaseValidator:
"""Base Model validator."""
type: Type[db.Model]
def __init__(self, request_user: User, model: Type[db.Model]) -> None:
"""Initialize the base validator."""
self.request_user = request_user
self._fields: Set[str] = get_changable_attribute_names(model)
self.model = model
def validate(self,
change_set: Optional[Dict[str, Any]] = None) \
-> ModelValidationResult:
"""Validate Model fields."""
field_validators = self._validators()
fields_to_validate = self._fields
if change_set:
fields_to_validate = set(change_set.keys())
validation_results: Dict[str, Tuple[bool, str]] = {}
for field in fields_to_validate:
if field not in field_validators:
raise errors.ValidationError(
'Invalid key: %r. Valid keys: %r.' % (
field, list(sorted(field_validators.keys()))))
field_validator = field_validators[field]
field_result = field_validator(
get_change_set_value(change_set, field))
validation_results[field] = field_result
return ModelValidationResult(validation_results)
def _validators(
self) -> Dict[str, Callable[[Any], Tuple[bool, str]]]:
"""Field definitions."""
raise NotImplementedError()
@staticmethod
def no_validation(_new_value: Any) -> Tuple[bool, str]:
"""Perform no validation."""
return True, ''
def validate_version(self, new_version: Any) -> Tuple[bool, str]:
"""Perform a standard version validation."""
if new_version is not None:
version_increasing = self.model.version <= new_version
if version_increasing:
return version_increasing, ''
return version_increasing, 'Unacceptable version change'
return True, ''
_model_validators: Dict[str, Type[BaseValidator]] = {}
def register_validator(
model_validator: Type[BaseValidator]) -> Type[BaseValidator]:
"""Add a model to the serializer mapping."""
model_name = model_validator.type.__name__
if model_name not in _model_validators:
_model_validators[model_name] = model_validator
else:
raise KeyError(
' '.join([
'A validator for type "{}" already exists with class "{}".',
'Cannot register a new validator with class "{}"'
]).format(
model_name,
_model_validators[model_name].__name__,
model_validator.__name__))
return model_validator
def validate_model(request_user: User,
model_obj: db.Model,
change_set: Optional[Dict[str, Any]] = None) \
-> ModelValidationResult:
"""Lookup a Model and hand off to the validator."""
try:
return _model_validators[type(model_obj).__name__](
request_user, model_obj).validate(change_set)
except KeyError:
raise NotImplementedError(
'{} has no registered validator'.format(model_obj.__name__))

4
server/corvus/utility/json_utility.py

@ -5,7 +5,7 @@ from typing import Any
import rfc3339
from flask.json import JSONEncoder
from corvus.api.model import APIResponse
from corvus.api.model import APIResponse, BaseAPIMessage
from corvus.db import db
from corvus.errors import BaseError
from corvus.service.transformation_service import serialize_model
@ -25,6 +25,8 @@ class CustomJSONEncoder(JSONEncoder):
return serialize_model(o.payload, o.options)
if isinstance(payload, BaseError):
return payload.to_dict()
if isinstance(payload, BaseAPIMessage):
return payload.to_dict()
return payload
if isinstance(o, db.Model):
return serialize_model(o)

20
server/corvus/utility/pagination_utility.py

@ -0,0 +1,20 @@
"""Pagination utility functions."""
from typing import Tuple
from werkzeug.datastructures import MultiDict
from corvus import errors
def get_pagination_params(request_args: MultiDict) -> Tuple[int, int]:
"""Get page and perPage request parameters."""
page = request_args.get('page', 1)
per_page = request_args.get('perPage', 20)
try:
return int(page), int(per_page)
except ValueError:
raise errors.ClientError(
' '.join([
'Invalid pagination parameters:',
'page={}',
'perPage={}']).format(page, per_page))

1
server/corvus/utility/session_utility.py

@ -11,6 +11,7 @@ class DisableSessionInterface(SecureCookieSessionInterface):
"""Disable default cookie generation."""
return False
# pylint: disable=useless-return
def save_session(self, app: Any, session: Any, response: Any) -> Any:
"""Prevent creating session from requests."""
return None

20
server/documentation/Makefile

@ -0,0 +1,20 @@
# Minimal makefile for Sphinx documentation
#
# You can set these variables from the command line.
SPHINXOPTS =
SPHINXBUILD = sphinx-build
SPHINXPROJ = Corvus
SOURCEDIR = .
BUILDDIR = _build
# Put it first so that "make" without argument is like "make help".
help:
@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
.PHONY: help Makefile
# Catch-all target: route all unknown targets to Sphinx using the new
# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
%: Makefile
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)

128
server/documentation/api/authentication.rst

@ -0,0 +1,128 @@
Authentication API
==================
.. http:post:: /auth/login
Authenticate with the server and receive a userToken for requests.
**Example request**:
.. sourcecode:: http
POST /auth/login HTTP/1.1
Host: example.tld
Accept: application/json
Authorization: Basic <Base64 Encoded Basic Auth>
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"creationTime": "2018-07-29T11:59:29-05:00",
"enabled": true,
"token": "b94cf5c7-cddc-4610-9d4c-6b8e04088ae8",
"version": 0
}
:<header Accept: Response content type depends on :mailheader:`Accept` header
:<header Authorization: Encoded basic authorization
:>header Content-Type: Depends on :mailheader:`Accept` header of request
:>json datetime creationTime: Creation time for the userToken
:>json datetime expirationTime: Expiration time for the userToken
:>json boolean enabled: Whether the userToken is enabled
:>json string token: UserToken to use for further authentication
:>json int version: Version for the object
:statuscode 200: User successfully logged in
:statuscode 401: Authorization failed
.. http:post:: /auth/bump
Bump user login information.
**Example request**:
.. sourcecode:: http
POST /auth/bump HTTP/1.1
Host: example.tld
Accept: application/json
Authorization: Token <Base64(user:userToken)>
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"lastLoginTime": "2018-07-29T12:15:51-05:00"
}
:<header Accept: Response content type depends on :mailheader:`Accept` header
:<header Authorization: Encoded token authorization
:>header Content-Type: Depends on :mailheader:`Accept` header of request
:>json datetime lastLoginTime: Updated lastLoginTime for the user
:statuscode 200: User last_login_time successfully bumped
:statuscode 401: Authorization failed
.. http:post:: /auth/logout
Logout a user and remove the provided userToken from valid tokens.
**Example request**:
.. sourcecode:: http
POST /auth/logout HTTP/1.1
Host: example.tld
Accept: application/json
Authorization: Token <Base64(user:userToken)>
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"success": true
}
:<header Accept: Response content type depends on :mailheader:`Accept` header
:<header Authorization: Rncoded token authorization
:>header Content-Type: Depends on :mailheader:`Accept` header of request
:>json boolean success: Whether the logout was successful
:statuscode 200: User successfully logged out
:statuscode 401: Authorization failed
Authentication Object Models
============================
.. json:object:: UserToken
:showexample:
UserToken definition
:property token: The token value, used for authentication
:proptype token: string
:property note: Additional information about the token
:proptype note: string
:property enabled: Determine if a token will be accepted
:proptype enabled: boolean
:property expirationTime: The time that the token becomes invalid, regardless of enabled state
:proptype expirationTime: iso8601
:property creationTime: The time that the token was created
:proptype creationTime: iso8601
:property lastUsageTime: The time that the token was last used
:proptype lastUsageTime: iso8601
:property version: An identifier for the token version
:proptype version: integer

9
server/documentation/api/index.rst

@ -0,0 +1,9 @@
Corvus API documentation
==========================
.. toctree::
:maxdepth: 2
:caption: Contents:
authentication
user

273
server/documentation/api/user.rst

@ -0,0 +1,273 @@
User API
========
.. http:get:: /user
Get a page of users.
**Example request**
.. sourcecode:: http
GET /user HTTP/1.1
Host: example.tld
Accept: application/json
Authorization: Token <Base64(user:userToken)>
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"page": 1,
"count": 1,
"totalCount": 1,
"lastPage": 1,
"items": [
{
"creationTime": "2018-07-29T11:58:17-05:00",
"lastLoginTime": "2018-07-29T12:43:27-05:00",
"name": "corvus_administrator",
"role": "ADMIN",
"version": 0
}
]
}
:query int page: User page to retrieve
:query int perPage: Number of records to retrieve per page (max 100)
:<header Accept: Response content type depends on :mailheader:`Accept` header
:<header Authorization: The encoded basic authorization
:>header Content-Type: Depends on :mailheader:`Accept` header of request
:>json int page: Page retrieved
:>json int count: Number of items returned
:>json int totalCount: Total number of items available
:>json int lastPage: Last page that can be requested before 404
:>json int items: List of Users
:statuscode 200: Successfully retrieved the user
:statuscode 400: Invalid page or perPage values
:statuscode 401: Authorization failed
:statuscode 404: User page doesn't exist
.. http:get:: /user/(str:user_name)
Find a user by name.
**Example request**:
.. sourcecode:: http
GET /user/corvus_administrator HTTP/1.1
Host: example.tld
Accept: application/json
Authorization: Token <Base64(user:userToken)>
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"creationTime": "2018-07-29T11:58:17-05:00",
"lastLoginTime": "2018-07-29T12:43:27-05:00",
"name": "corvus_administrator",
"role": "ADMIN",
"version": 0
}
:param string user_name: Name of the user to retrieve information about
:<header Accept: Response content type depends on :mailheader:`Accept` header
:<header Authorization: The encoded basic authorization
:>header Content-Type: Depends on :mailheader:`Accept` header of request
:>json datetime creationTime: Creation time for the user
:>json datetime lastLoginTime: When the user last logged in, or was last bumped
:>json string name: The user name
:>json string role: The role assigned to the user
:>json int version: Version information
:statuscode 200: Successfully retrieved the user
:statuscode 401: Authorization failed
:statuscode 404: User doesn't exist
.. http:patch:: /user/(str:user_name)
Patch a user.
**Example request**:
.. sourcecode:: http
PATCH /user/corvus_administrator HTTP/1.1
Host: example.tld
Accept: application/json
Authorization: Token <Base64(user:userToken)>
Content-Type: application/json
{
"lastLoginTime": "2019-07-29T12:43:27-05:00",
"version": 0
}
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"creationTime": "2018-07-29T11:58:17-05:00",
"lastLoginTime": "2019-07-29T12:43:27-05:00",
"name": "corvus_administrator",
"role": "ADMIN",
"version": 1
}
:param string user_name: Name of the user to update
:<header Accept: Response content type depends on :mailheader:`Accept` header
:<header Authorization: Encoded token authorization
:<header Content-Type: application/json
:<json datetime createDateTime: Update createDateTime (Administrator Only)
:<json datetime lastLoginTime: Update lastLoginTime
:<json string name: Update user name (Administrator Only)
:<json string role: Update user role (Must be less than or equal to the role authenticating the action)
:<json int version: Must match the latest version of the user
:>header Content-Type: Depends on :mailheader:`Accept` header of request
:>json datetime creationTime: Creation time for the user
:>json datetime lastLoginTime: When the user last logged in, or was last bumped
:>json string name: The user name
:>json string role: The role assigned to the user
:>json int version: Version information
:statuscode 200: Successfully patched the user
:statuscode 400: An issue in the payload was discovered
:statuscode 401: Authorization failed
:statuscode 404: User doesn't exist
.. http:post:: /user
Register a new user with the service.
**Example request**:
.. sourcecode:: http
POST /user HTTP/1.1
Host: example.tld
Accept: application/json
Authorization: Token <Base64(user:userToken)>
Content-Type: application/json
{
"name": "test_user",
"password": "JvZ9bm79",
"role": "USER"
}
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"creationTime": "2018-07-29T14:16:48-05:00",
"name": "test_user",
"role": "USER",
"version": 0
}
:<header Accept: Response content type depends on :mailheader:`Accept` header
:<header Authorization: Encoded token authorization
:<header Content-Type: application/json
:<json string name: Name of the user
:<json string password: Password to use
:<json string role: Role to assign to the user (Must be less than or equal to the role of the authenticating user)
:>header Content-Type: Depends on :mailheader:`Accept` header of request
:>json datetime creationTime: Datetime the user was created
:>json string name: Name of the created user
:>json string role: Role of the created user
:>json int version: Version number of the created user
:statuscode 200: Successfully registered the user
:statuscode 400: An issue in the payload was discovered
:statuscode 401: Authorization failed
.. http:delete:: /user/(str:user_name)
Register a new user with the service.
**Example request**:
.. sourcecode:: http
DELETE /user/test_user HTTP/1.1
Host: example.tld
Accept: application/json
Authorization: Token <Base64(user:userToken)>
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"message": "Successfully Deleted",
"success": true
}
:param string user_name: Name of the user to delete
:<header Accept: Response content type depends on :mailheader:`Accept` header
:<header Authorization: Encoded token authorization
:>header Content-Type: Depends on :mailheader:`Accept` header of request
:>json string message: Success or failure message
:>json boolean success: Action status indicator
:statuscode 200: Successfully deleted the user
:statuscode 401: Authorization failed
:statuscode 404: User doesn't exist
User Object Models
==================
.. json:object:: Page<User>
:showexample:
Page<User> definition
:property page: The page returned
:proptype page: integer
:property count: The number of items on this page
:proptype count: integer
:property totalCount: The total number of items available
:proptype totalCount: integer
:property lastPage: The last page that is accessible before 404
:proptype lastPage: integer
:property items: The list of items on the page
:proptype items: :json:list: 'User'
.. json:object:: User
:showexample:
User definition
:property name: The unique name of the user
:proptype name: string
:property creationTime: The time that the user was created
:proptype creationTime: iso8601
:property lastLoginTime: The time that the user last logged in
:proptype lastLoginTime: iso8601
:property version: An identifier for the user version
:proptype version: integer
:property role: The assigned role for the user
:proptype role: string

157
server/documentation/conf.py

@ -0,0 +1,157 @@
# -*- coding: utf-8 -*-
#
# Configuration file for the Sphinx documentation builder.
#
# This file does only contain a selection of the most common options. For a
# full list see the documentation:
# http://www.sphinx-doc.org/en/master/config
# -- Path setup --------------------------------------------------------------
# If extensions (or modules to document with autodoc) are in another directory,
# add these directories to sys.path here. If the directory is relative to the
# documentation root, use os.path.abspath to make it absolute, like shown here.
#
# import os
# import sys
# sys.path.insert(0, os.path.abspath('.'))
# -- Project information -----------------------------------------------------
project = 'Corvus'
copyright = '2018, Drew Short'
author = 'Drew Short'
# The short X.Y version
version = '2018.8'
# The full version, including alpha/beta/rc tags
release = '2018.8.1'
# -- General configuration ---------------------------------------------------
# If your documentation needs a minimal Sphinx version, state it here.
#
# needs_sphinx = '1.0'
# Add any Sphinx extension module names here, as strings. They can be
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
# ones.
extensions = [
'sphinxcontrib.httpdomain',
'sphinxjsondomain'
]
# Add any paths that contain templates here, relative to this directory.
templates_path = ['_templates']
# The suffix(es) of source filenames.
# You can specify multiple suffix as a list of string:
#
# source_suffix = ['.rst', '.md']
source_suffix = '.rst'
# The master toctree document.
master_doc = 'index'
# The language for content autogenerated by Sphinx. Refer to documentation
# for a list of supported languages.
#
# This is also used if you do content translation via gettext catalogs.
# Usually you set "language" from the command line for these cases.
language = None
# List of patterns, relative to source directory, that match files and
# directories to ignore when looking for source files.
# This pattern also affects html_static_path and html_extra_path .
exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store']
# The name of the Pygments (syntax highlighting) style to use.
pygments_style = 'sphinx'
# -- Options for HTML output -------------------------------------------------
# The theme to use for HTML and HTML Help pages. See the documentation for
# a list of builtin themes.
#
html_theme = 'sphinx_rtd_theme'
# Theme options are theme-specific and customize the look and feel of a theme
# further. For a list of options available for each theme, see the
# documentation.
#
# html_theme_options = {}
# Add any paths that contain custom static files (such as style sheets) here,
# relative to this directory. They are copied after the builtin static files,
# so a file named "default.css" will overwrite the builtin "default.css".
html_static_path = ['_static']
# Custom sidebar templates, must be a dictionary that maps document names
# to template names.
#
# The default sidebars (for documents that don't match any pattern) are
# defined by theme itself. Builtin themes are using these templates by
# default: ``['localtoc.html', 'relations.html', 'sourcelink.html',
# 'searchbox.html']``.
#
# html_sidebars = {}
# -- Options for HTMLHelp output ---------------------------------------------
# Output file base name for HTML help builder.
htmlhelp_basename = 'Corvusdoc'
# -- Options for LaTeX output ------------------------------------------------
latex_elements = {
# The paper size ('letterpaper' or 'a4paper').
#
# 'papersize': 'letterpaper',
# The font size ('10pt', '11pt' or '12pt').
#
# 'pointsize': '10pt',
# Additional stuff for the LaTeX preamble.
#
# 'preamble': '',
# Latex figure (float) alignment
#
# 'figure_align': 'htbp',
}
# Grouping the document tree into LaTeX files. List of tuples
# (source start file, target name, title,
# author, documentclass [howto, manual, or own class]).
latex_documents = [
(master_doc, 'Corvus.tex', 'Corvus Documentation',
'Drew Short', 'manual'),
]
# -- Options for manual page output ------------------------------------------
# One entry per manual page. List of tuples
# (source start file, name, description, authors, manual section).
man_pages = [
(master_doc, 'corvus', 'Corvus Documentation',
[author], 1)
]
# -- Options for Texinfo output ----------------------------------------------
# Grouping the document tree into Texinfo files. List of tuples
# (source start file, target name, title, author,
# dir menu entry, description, category)
texinfo_documents = [
(master_doc, 'Corvus', 'Corvus Documentation',
author, 'Corvus', 'One line description of project.',
'Miscellaneous'),
]

20
server/documentation/index.rst

@ -0,0 +1,20 @@
.. Corvus documentation master file, created by
sphinx-quickstart on Sun Jul 29 11:09:44 2018.
You can adapt this file completely to your liking, but it should at least
contain the root `toctree` directive.
Welcome to Corvus's documentation!
====================================
.. toctree::
:maxdepth: 2
:caption: Contents:
introduction
api/index
Indices and tables
==================
* :ref:`genindex`
* :ref:`search`

4
server/documentation/introduction.rst

@ -0,0 +1,4 @@
Introduction To Corvus
========================
TODO

36
server/documentation/make.bat

@ -0,0 +1,36 @@
@ECHO OFF
pushd %~dp0
REM Command file for Sphinx documentation
if "%SPHINXBUILD%" == "" (
set SPHINXBUILD=sphinx-build
)
set SOURCEDIR=.
set BUILDDIR=_build
set SPHINXPROJ=Corvus
if "%1" == "" goto help
%SPHINXBUILD% >NUL 2>NUL
if errorlevel 9009 (
echo.
echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
echo.installed, then set the SPHINXBUILD environment variable to point
echo.to the full path of the 'sphinx-build' executable. Alternatively you
echo.may add the Sphinx directory to PATH.
echo.
echo.If you don't have Sphinx installed, grab it from
echo.http://sphinx-doc.org/
exit /b 1
)
%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS%
goto end
:help
%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS%
:end
popd

2
server/manage.py

@ -56,7 +56,7 @@ def register_user(
if existing_user is None:
user_password = password if password else ''.join(
random.choices(string.ascii_letters + string.digits, k=24))
new_user = user_service.register(name, user_password, role)
new_user = user_service.register(name, user_password, role, False)
logging.warning(
'Created new user: \'%s\' with password \'%s\' and role %s',
new_user.name,

11
server/run_tests.sh

@ -3,6 +3,17 @@
set -e
set -x
python3 --version
python3 -m pip --version
pylint --version
mypy --version
coverage --version
pytest --version
pycodestyle --version
pydocstyle --version
pylint corvus
mypy corvus tests
PYTHONPATH=$(pwd) coverage run --source corvus -m pytest

2
server/tests/api/test_authentication_api.py

@ -19,4 +19,4 @@ def test_logout_happy_path(auth: AuthActions):
auth.login()
result = auth.logout()
assert result.status_code == 200
assert result.json is None
assert result.json['success']

109
server/tests/api/test_user_api.py

@ -1,9 +1,41 @@
from datetime import datetime
import rfc3339
from flask import json
from flask.testing import FlaskClient
from tests.conftest import AuthActions
def test_get_users_happy_path(auth: AuthActions, client: FlaskClient):
auth.login()
auth_header = auth.get_authorization_header_token()
result = client.get(
'/user',
headers={
auth_header[0]: auth_header[1]
})
assert 200 == result.status_code
assert result.json is not None
assert result.json['page'] == 1
assert result.json['lastPage'] == 1
assert result.json['count'] == 1
assert result.json['totalCount'] == 1
assert result.json['items'][0]['name'] == auth.username
def test_get_users_nonexistent_page(auth: AuthActions, client: FlaskClient):
auth.login()
auth_header = auth.get_authorization_header_token()
result = client.get(
'/user?page=2',
headers={
auth_header[0]: auth_header[1]
})
assert 404 == result.status_code
assert result.json is not None
def test_get_user_happy_path(auth: AuthActions, client: FlaskClient):
auth.login()
auth_header = auth.get_authorization_header_token()
@ -17,11 +49,38 @@ def test_get_user_happy_path(auth: AuthActions, client: FlaskClient):
assert result.json['name'] == client.application.config['test_username']
def test_patch_user_happy_path(auth: AuthActions, client: FlaskClient):
auth.login()
auth_header = auth.get_authorization_header_token()
last_login_time = rfc3339.format(datetime.now())
user = client.get(
'/user/{}'.format(client.application.config['test_username']),
headers={
auth_header[0]: auth_header[1]
})
patched_user = client.patch(
'/user/{}'.format(client.application.config['test_username']),
data=json.dumps({
'version': user.json['version'],
'lastLoginTime': last_login_time
}),
headers={
auth_header[0]: auth_header[1],
'Content-Type': 'application/json'
})
assert 200 == patched_user.status_code
assert patched_user.json['version'] == user.json['version'] + 1
assert patched_user.json['lastLoginTime'] == last_login_time
def test_register_user_happy_path(auth: AuthActions, client: FlaskClient):
auth.login()
auth_header = auth.get_authorization_header_token()
result = client.post(
'/user/',
'/user',
data=json.dumps({
'name': 'test_registered_user'
}),
@ -34,11 +93,30 @@ def test_register_user_happy_path(auth: AuthActions, client: FlaskClient):
assert result.json['name'] == 'test_registered_user'
def test_register_user_invalid_password(
auth: AuthActions, client: FlaskClient):
auth.login()
auth_header = auth.get_authorization_header_token()
result = client.post(
'/user',
data=json.dumps({
'name': 'test_registered_user',
'password': ''
}),
headers={
auth_header[0]: auth_header[1],
'Content-Type': 'application/json'
})
assert 400 == result.status_code
assert result.json is not None
assert 'message' in result.json
def test_register_user_twice_failure(auth: AuthActions, client: FlaskClient):
auth.login()
auth_header = auth.get_authorization_header_token()
result1 = client.post(
'/user/',
'/user',
data=json.dumps({
'name': 'test_registered_user'
}),
@ -47,7 +125,7 @@ def test_register_user_twice_failure(auth: AuthActions, client: FlaskClient):
'Content-Type': 'application/json'
})
result2 = client.post(
'/user/',
'/user',
data=json.dumps({
'name': 'test_registered_user'
}),
@ -61,3 +139,28 @@ def test_register_user_twice_failure(auth: AuthActions, client: FlaskClient):
assert 400 == result2.status_code
assert result2.json is not None
assert result2.json['message'] == 'User name is already taken.'
def test_delete_user_happy_path(auth: AuthActions, client: FlaskClient):
auth.login()
auth_header = auth.get_authorization_header_token()
result1 = client.post(
'/user',
data=json.dumps({
'name': 'test_registered_user'
}),
headers={
auth_header[0]: auth_header[1],
'Content-Type': 'application/json'
})
result2 = client.delete(
'/user/'+result1.json['name'],
headers={
auth_header[0]: auth_header[1]
})
assert 200 == result1.status_code
assert result1.json is not None
assert result1.json['name'] == 'test_registered_user'
assert 200 == result2.status_code
assert result2.json is not None
assert 'message' in result2.json

2
server/tests/conftest.py

@ -21,7 +21,7 @@ def add_test_user() -> Tuple[str, str]:
random.choices(string.ascii_letters + string.digits, k=17)).strip()
test_password = ''.join(
random.choices(string.ascii_letters + string.digits, k=32)).strip()
user_service.register(test_username, test_password, User.ROLE_ADMIN)
user_service.register(test_username, test_password, User.ROLE_ADMIN, False)
return test_username, test_password

87
server/tests/service/test_patch_service.py

@ -0,0 +1,87 @@
from datetime import datetime, timedelta
import pytest
from mock import MagicMock, patch
from corvus import errors
from corvus.model import UserToken, User
from corvus.service import patch_service, role_service
service_module = 'corvus.service.patch_service'
@patch(service_module + '.db.session.commit')
def test_patch_models(
mock_db_session_commit: MagicMock):
request_user = User()
request_user.role = role_service.Role.ADMIN
user = User()
user.name = 'TestUser'
user.version = 1
user.last_login_time = datetime.now() - timedelta(days=1)
user_patch = User()
user_patch.name = 'TestUser'
user_patch.version = 1
user_patch.last_login_time = datetime.now()
patched_user = patch_service.patch(request_user, user, user_patch)
assert patched_user.version > 1
assert patched_user.last_login_time == user_patch.last_login_time
mock_db_session_commit.assert_called_once()
def test_patch_of_different_types():
request_user = User()
request_user.role = role_service.Role.ADMIN
user = User()
user_token = UserToken()
with pytest.raises(errors.ValidationError) as error_info:
patch_service.patch(request_user, user, user_token)
def test_patch_different_ids():
request_user = User()
request_user.role = role_service.Role.ADMIN
user1 = User()
user1.id = 1
user2 = User()
user2.id = 2
with pytest.raises(errors.ValidationError) as error_info:
patch_service.patch(request_user, user1, user2)
def test_patch_different_versions():
request_user = User()
request_user.role = role_service.Role.ADMIN
user1 = User()
user1.version = 1
user2 = User()
user2.version = 2
with pytest.raises(errors.ValidationError) as error_info:
patch_service.patch(request_user, user1, user2)
def test_patch_restricted_attributes():
request_user = User()
request_user.role = role_service.Role.USER
user1 = User()
user1.version = 1
user1.name = 'Bob'
user2 = User()
user2.version = 1
user2.name = 'Chris'
with pytest.raises(errors.ValidationError) as error_info:
patch_service.patch(request_user, user1, user2)

7
server/tests/service/test_role_service.py

@ -15,5 +15,12 @@ def test_role_tree_find_roles_in_hierarchy():
assert Role.ADMIN in roles
def test_role_tree_find_children_roles():
roles = ROLES.find_children_roles(Role.USER)
assert len(roles) == 2
assert Role.USER in roles
assert Role.ANONYMOUS in roles
def test_role_tree_find_role_key_error():
assert len(ROLES.find_role(Role.NONE)) == 0

38
server/tests/service/test_transformation_service.py

@ -1,8 +1,15 @@
from typing import Dict, Callable, Any
import pytest
from corvus import db
from corvus.model import UserToken
from corvus.service.transformation_service import (
serialize_model,
deserialize_model
)
BaseTransformer,
deserialize_model,
register_transformer,
serialize_model)
from corvus.service.user_token_service import UserTokenTransformer
def test_serialize_model():
@ -17,6 +24,29 @@ def test_deserialize_model():
user_token_json = {
'token': 'test'
}
result = deserialize_model('UserToken', user_token_json)
result = deserialize_model(UserToken, user_token_json)
assert result is not None
assert result.token == 'test'
def test_registering_two_transformers_of_the_same_type():
class BadTransformer(BaseTransformer):
type = UserToken
def _serializers(self) -> Dict[str, Callable[[], Any]]:
pass
def _deserializers(self) -> Dict[str, Callable[[db.Model, Any], None]]:
pass
error = None
with pytest.raises(KeyError) as e_info:
error = e_info
register_transformer(BadTransformer)
assert error is not None
error_msg = error.value.args[0]
assert error_msg is not None
assert UserToken.__name__ in error_msg
assert UserTokenTransformer.__name__ in error_msg
assert BadTransformer.__name__ in error_msg

25
server/tests/service/test_validation_service.py

@ -0,0 +1,25 @@
from corvus.model import User
from corvus.service import role_service, validation_service
def test_successful_validation():
request_user = User()
request_user.role = role_service.Role.USER
user = User()
user.role = role_service.Role.USER
validation_result = validation_service.validate_model(request_user, user)
assert validation_result.success
def test_failed_validation():
request_user = User()
request_user.role = role_service.Role.ANONYMOUS
user = User()
user.role = role_service.Role.USER
validation_result = validation_service.validate_model(request_user, user)
assert validation_result.success is False
assert 'role' in validation_result.failed
Loading…
Cancel
Save