From be57512a653d3051cd29c8c63df311c172eca8f7 Mon Sep 17 00:00:00 2001
From: Dan Jones <dan.jones@noc.ac.uk>
Date: Thu, 9 Feb 2023 09:55:03 +0000
Subject: [PATCH] tests: unit tests for models and api endpoints

- done
  - client and token model
  - client and token api endpoints
  - send api endpoint
- todo
  - notify api endpoint
  - receive api endpoint
---
 Pipfile                             |  24 ++
 Pipfile.lock                        | 569 ++++++++++++++++++++++++++++
 api.py                              |  26 +-
 api_client_test.py                  |  95 +++++
 api_send_test.py                    |  59 +++
 api_token_test.py                   |  39 ++
 conftest.py                         |  92 +++++
 docker/Dockerfile                   |   3 +-
 endpoints/auth_resource.py          |  20 +-
 endpoints/client.py                 |  77 ++++
 endpoints/notify.py                 |  13 +-
 endpoints/receive.py                |   4 +-
 endpoints/send.py                   |  13 +-
 endpoints/token.py                  |   8 +-
 models/client_model.py              |  83 ++++
 models/client_model_test.py         | 135 +++++++
 models/{token.py => token_model.py} |  71 ++--
 models/token_model_test.py          | 130 +++++++
 requirements-dev.txt                |   5 +
 requirements.txt                    |   2 +-
 rmq.py                              |  49 ++-
 soar_bus.py                         |  14 +-
 22 files changed, 1422 insertions(+), 109 deletions(-)
 create mode 100644 Pipfile
 create mode 100644 Pipfile.lock
 create mode 100644 api_client_test.py
 create mode 100644 api_send_test.py
 create mode 100644 api_token_test.py
 create mode 100644 conftest.py
 create mode 100644 endpoints/client.py
 create mode 100644 models/client_model.py
 create mode 100644 models/client_model_test.py
 rename models/{token.py => token_model.py} (55%)
 create mode 100644 models/token_model_test.py
 create mode 100644 requirements-dev.txt

diff --git a/Pipfile b/Pipfile
new file mode 100644
index 0000000..cd8d354
--- /dev/null
+++ b/Pipfile
@@ -0,0 +1,24 @@
+[[source]]
+url = "https://pypi.org/simple"
+verify_ssl = true
+name = "pypi"
+
+[packages]
+pubsubpy = "*"
+pika = "*"
+pyrabbit = "*"
+flask = "*"
+flask-restful = "*"
+marshmallow = "*"
+bson = "*"
+flask-cors = "*"
+cryptography = "*"
+
+[dev-packages]
+pytest = "*"
+pytest-rabbitmq = "*"
+pytest-mock = "*"
+black = "*"
+
+[requires]
+python_version = "3.8"
diff --git a/Pipfile.lock b/Pipfile.lock
new file mode 100644
index 0000000..d72d816
--- /dev/null
+++ b/Pipfile.lock
@@ -0,0 +1,569 @@
+{
+    "_meta": {
+        "hash": {
+            "sha256": "21c6756e40ab45e59ba28a311bc17728bc285e1ad8de901d4a4213355880027a"
+        },
+        "pipfile-spec": 6,
+        "requires": {
+            "python_version": "3.8"
+        },
+        "sources": [
+            {
+                "name": "pypi",
+                "url": "https://pypi.org/simple",
+                "verify_ssl": true
+            }
+        ]
+    },
+    "default": {
+        "amqp": {
+            "hashes": [
+                "sha256:2c1b13fecc0893e946c65cbd5f36427861cffa4ea2201d8f6fca22e2a373b5e2",
+                "sha256:6f0956d2c23d8fa6e7691934d8c3930eadb44972cbbd1a7ae3a520f735d43359"
+            ],
+            "markers": "python_version >= '3.6'",
+            "version": "==5.1.1"
+        },
+        "aniso8601": {
+            "hashes": [
+                "sha256:1d2b7ef82963909e93c4f24ce48d4de9e66009a21bf1c1e1c85bdd0812fe412f",
+                "sha256:72e3117667eedf66951bb2d93f4296a56b94b078a8a95905a052611fb3f1b973"
+            ],
+            "version": "==9.0.1"
+        },
+        "bson": {
+            "hashes": [
+                "sha256:d6511b2ab051139a9123c184de1a04227262173ad593429d21e443d6462d6590"
+            ],
+            "index": "pypi",
+            "version": "==0.5.10"
+        },
+        "cffi": {
+            "hashes": [
+                "sha256:00a9ed42e88df81ffae7a8ab6d9356b371399b91dbdf0c3cb1e84c03a13aceb5",
+                "sha256:03425bdae262c76aad70202debd780501fabeaca237cdfddc008987c0e0f59ef",
+                "sha256:04ed324bda3cda42b9b695d51bb7d54b680b9719cfab04227cdd1e04e5de3104",
+                "sha256:0e2642fe3142e4cc4af0799748233ad6da94c62a8bec3a6648bf8ee68b1c7426",
+                "sha256:173379135477dc8cac4bc58f45db08ab45d228b3363adb7af79436135d028405",
+                "sha256:198caafb44239b60e252492445da556afafc7d1e3ab7a1fb3f0584ef6d742375",
+                "sha256:1e74c6b51a9ed6589199c787bf5f9875612ca4a8a0785fb2d4a84429badaf22a",
+                "sha256:2012c72d854c2d03e45d06ae57f40d78e5770d252f195b93f581acf3ba44496e",
+                "sha256:21157295583fe8943475029ed5abdcf71eb3911894724e360acff1d61c1d54bc",
+                "sha256:2470043b93ff09bf8fb1d46d1cb756ce6132c54826661a32d4e4d132e1977adf",
+                "sha256:285d29981935eb726a4399badae8f0ffdff4f5050eaa6d0cfc3f64b857b77185",
+                "sha256:30d78fbc8ebf9c92c9b7823ee18eb92f2e6ef79b45ac84db507f52fbe3ec4497",
+                "sha256:320dab6e7cb2eacdf0e658569d2575c4dad258c0fcc794f46215e1e39f90f2c3",
+                "sha256:33ab79603146aace82c2427da5ca6e58f2b3f2fb5da893ceac0c42218a40be35",
+                "sha256:3548db281cd7d2561c9ad9984681c95f7b0e38881201e157833a2342c30d5e8c",
+                "sha256:3799aecf2e17cf585d977b780ce79ff0dc9b78d799fc694221ce814c2c19db83",
+                "sha256:39d39875251ca8f612b6f33e6b1195af86d1b3e60086068be9cc053aa4376e21",
+                "sha256:3b926aa83d1edb5aa5b427b4053dc420ec295a08e40911296b9eb1b6170f6cca",
+                "sha256:3bcde07039e586f91b45c88f8583ea7cf7a0770df3a1649627bf598332cb6984",
+                "sha256:3d08afd128ddaa624a48cf2b859afef385b720bb4b43df214f85616922e6a5ac",
+                "sha256:3eb6971dcff08619f8d91607cfc726518b6fa2a9eba42856be181c6d0d9515fd",
+                "sha256:40f4774f5a9d4f5e344f31a32b5096977b5d48560c5592e2f3d2c4374bd543ee",
+                "sha256:4289fc34b2f5316fbb762d75362931e351941fa95fa18789191b33fc4cf9504a",
+                "sha256:470c103ae716238bbe698d67ad020e1db9d9dba34fa5a899b5e21577e6d52ed2",
+                "sha256:4f2c9f67e9821cad2e5f480bc8d83b8742896f1242dba247911072d4fa94c192",
+                "sha256:50a74364d85fd319352182ef59c5c790484a336f6db772c1a9231f1c3ed0cbd7",
+                "sha256:54a2db7b78338edd780e7ef7f9f6c442500fb0d41a5a4ea24fff1c929d5af585",
+                "sha256:5635bd9cb9731e6d4a1132a498dd34f764034a8ce60cef4f5319c0541159392f",
+                "sha256:59c0b02d0a6c384d453fece7566d1c7e6b7bae4fc5874ef2ef46d56776d61c9e",
+                "sha256:5d598b938678ebf3c67377cdd45e09d431369c3b1a5b331058c338e201f12b27",
+                "sha256:5df2768244d19ab7f60546d0c7c63ce1581f7af8b5de3eb3004b9b6fc8a9f84b",
+                "sha256:5ef34d190326c3b1f822a5b7a45f6c4535e2f47ed06fec77d3d799c450b2651e",
+                "sha256:6975a3fac6bc83c4a65c9f9fcab9e47019a11d3d2cf7f3c0d03431bf145a941e",
+                "sha256:6c9a799e985904922a4d207a94eae35c78ebae90e128f0c4e521ce339396be9d",
+                "sha256:70df4e3b545a17496c9b3f41f5115e69a4f2e77e94e1d2a8e1070bc0c38c8a3c",
+                "sha256:7473e861101c9e72452f9bf8acb984947aa1661a7704553a9f6e4baa5ba64415",
+                "sha256:8102eaf27e1e448db915d08afa8b41d6c7ca7a04b7d73af6514df10a3e74bd82",
+                "sha256:87c450779d0914f2861b8526e035c5e6da0a3199d8f1add1a665e1cbc6fc6d02",
+                "sha256:8b7ee99e510d7b66cdb6c593f21c043c248537a32e0bedf02e01e9553a172314",
+                "sha256:91fc98adde3d7881af9b59ed0294046f3806221863722ba7d8d120c575314325",
+                "sha256:94411f22c3985acaec6f83c6df553f2dbe17b698cc7f8ae751ff2237d96b9e3c",
+                "sha256:98d85c6a2bef81588d9227dde12db8a7f47f639f4a17c9ae08e773aa9c697bf3",
+                "sha256:9ad5db27f9cabae298d151c85cf2bad1d359a1b9c686a275df03385758e2f914",
+                "sha256:a0b71b1b8fbf2b96e41c4d990244165e2c9be83d54962a9a1d118fd8657d2045",
+                "sha256:a0f100c8912c114ff53e1202d0078b425bee3649ae34d7b070e9697f93c5d52d",
+                "sha256:a591fe9e525846e4d154205572a029f653ada1a78b93697f3b5a8f1f2bc055b9",
+                "sha256:a5c84c68147988265e60416b57fc83425a78058853509c1b0629c180094904a5",
+                "sha256:a66d3508133af6e8548451b25058d5812812ec3798c886bf38ed24a98216fab2",
+                "sha256:a8c4917bd7ad33e8eb21e9a5bbba979b49d9a97acb3a803092cbc1133e20343c",
+                "sha256:b3bbeb01c2b273cca1e1e0c5df57f12dce9a4dd331b4fa1635b8bec26350bde3",
+                "sha256:cba9d6b9a7d64d4bd46167096fc9d2f835e25d7e4c121fb2ddfc6528fb0413b2",
+                "sha256:cc4d65aeeaa04136a12677d3dd0b1c0c94dc43abac5860ab33cceb42b801c1e8",
+                "sha256:ce4bcc037df4fc5e3d184794f27bdaab018943698f4ca31630bc7f84a7b69c6d",
+                "sha256:cec7d9412a9102bdc577382c3929b337320c4c4c4849f2c5cdd14d7368c5562d",
+                "sha256:d400bfb9a37b1351253cb402671cea7e89bdecc294e8016a707f6d1d8ac934f9",
+                "sha256:d61f4695e6c866a23a21acab0509af1cdfd2c013cf256bbf5b6b5e2695827162",
+                "sha256:db0fbb9c62743ce59a9ff687eb5f4afbe77e5e8403d6697f7446e5f609976f76",
+                "sha256:dd86c085fae2efd48ac91dd7ccffcfc0571387fe1193d33b6394db7ef31fe2a4",
+                "sha256:e00b098126fd45523dd056d2efba6c5a63b71ffe9f2bbe1a4fe1716e1d0c331e",
+                "sha256:e229a521186c75c8ad9490854fd8bbdd9a0c9aa3a524326b55be83b54d4e0ad9",
+                "sha256:e263d77ee3dd201c3a142934a086a4450861778baaeeb45db4591ef65550b0a6",
+                "sha256:ed9cb427ba5504c1dc15ede7d516b84757c3e3d7868ccc85121d9310d27eed0b",
+                "sha256:fa6693661a4c91757f4412306191b6dc88c1703f780c8234035eac011922bc01",
+                "sha256:fcd131dd944808b5bdb38e6f5b53013c5aa4f334c5cad0c72742f6eba4b73db0"
+            ],
+            "version": "==1.15.1"
+        },
+        "click": {
+            "hashes": [
+                "sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e",
+                "sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48"
+            ],
+            "markers": "python_version >= '3.7'",
+            "version": "==8.1.3"
+        },
+        "cryptography": {
+            "hashes": [
+                "sha256:0f8da300b5c8af9f98111ffd512910bc792b4c77392a9523624680f7956a99d4",
+                "sha256:35f7c7d015d474f4011e859e93e789c87d21f6f4880ebdc29896a60403328f1f",
+                "sha256:5aa67414fcdfa22cf052e640cb5ddc461924a045cacf325cd164e65312d99502",
+                "sha256:5d2d8b87a490bfcd407ed9d49093793d0f75198a35e6eb1a923ce1ee86c62b41",
+                "sha256:6687ef6d0a6497e2b58e7c5b852b53f62142cfa7cd1555795758934da363a965",
+                "sha256:6f8ba7f0328b79f08bdacc3e4e66fb4d7aab0c3584e0bd41328dce5262e26b2e",
+                "sha256:706843b48f9a3f9b9911979761c91541e3d90db1ca905fd63fee540a217698bc",
+                "sha256:807ce09d4434881ca3a7594733669bd834f5b2c6d5c7e36f8c00f691887042ad",
+                "sha256:83e17b26de248c33f3acffb922748151d71827d6021d98c70e6c1a25ddd78505",
+                "sha256:96f1157a7c08b5b189b16b47bc9db2332269d6680a196341bf30046330d15388",
+                "sha256:aec5a6c9864be7df2240c382740fcf3b96928c46604eaa7f3091f58b878c0bb6",
+                "sha256:b0afd054cd42f3d213bf82c629efb1ee5f22eba35bf0eec88ea9ea7304f511a2",
+                "sha256:ced4e447ae29ca194449a3f1ce132ded8fcab06971ef5f618605aacaa612beac",
+                "sha256:d1f6198ee6d9148405e49887803907fe8962a23e6c6f83ea7d98f1c0de375695",
+                "sha256:e124352fd3db36a9d4a21c1aa27fd5d051e621845cb87fb851c08f4f75ce8be6",
+                "sha256:e422abdec8b5fa8462aa016786680720d78bdce7a30c652b7fadf83a4ba35336",
+                "sha256:ef8b72fa70b348724ff1218267e7f7375b8de4e8194d1636ee60510aae104cd0",
+                "sha256:f0c64d1bd842ca2633e74a1a28033d139368ad959872533b1bab8c80e8240a0c",
+                "sha256:f24077a3b5298a5a06a8e0536e3ea9ec60e4c7ac486755e5fb6e6ea9b3500106",
+                "sha256:fdd188c8a6ef8769f148f88f859884507b954cc64db6b52f66ef199bb9ad660a",
+                "sha256:fe913f20024eb2cb2f323e42a64bdf2911bb9738a15dba7d3cce48151034e3a8"
+            ],
+            "index": "pypi",
+            "version": "==39.0.1"
+        },
+        "flask": {
+            "hashes": [
+                "sha256:642c450d19c4ad482f96729bd2a8f6d32554aa1e231f4f6b4e7e5264b16cca2b",
+                "sha256:b9c46cc36662a7949f34b52d8ec7bb59c0d74ba08ba6cb9ce9adc1d8676d9526"
+            ],
+            "index": "pypi",
+            "version": "==2.2.2"
+        },
+        "flask-cors": {
+            "hashes": [
+                "sha256:74efc975af1194fc7891ff5cd85b0f7478be4f7f59fe158102e91abb72bb4438",
+                "sha256:b60839393f3b84a0f3746f6cdca56c1ad7426aa738b70d6c61375857823181de"
+            ],
+            "index": "pypi",
+            "version": "==3.0.10"
+        },
+        "flask-restful": {
+            "hashes": [
+                "sha256:4970c49b6488e46c520b325f54833374dc2b98e211f1b272bd4b0c516232afe2",
+                "sha256:ccec650b835d48192138c85329ae03735e6ced58e9b2d9c2146d6c84c06fa53e"
+            ],
+            "index": "pypi",
+            "version": "==0.3.9"
+        },
+        "future": {
+            "hashes": [
+                "sha256:34a17436ed1e96697a86f9de3d15a3b0be01d8bc8de9c1dffd59fb8234ed5307"
+            ],
+            "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'",
+            "version": "==0.18.3"
+        },
+        "httplib2": {
+            "hashes": [
+                "sha256:987c8bb3eb82d3fa60c68699510a692aa2ad9c4bd4f123e51dfb1488c14cdd01",
+                "sha256:fc144f091c7286b82bec71bdbd9b27323ba709cc612568d3000893bfd9cb4b34"
+            ],
+            "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
+            "version": "==0.21.0"
+        },
+        "importlib-metadata": {
+            "hashes": [
+                "sha256:7efb448ec9a5e313a57655d35aa54cd3e01b7e1fbcf72dce1bf06119420f5bad",
+                "sha256:e354bedeb60efa6affdcc8ae121b73544a7aa74156d047311948f6d711cd378d"
+            ],
+            "markers": "python_version < '3.10'",
+            "version": "==6.0.0"
+        },
+        "itsdangerous": {
+            "hashes": [
+                "sha256:2c2349112351b88699d8d4b6b075022c0808887cb7ad10069318a8b0bc88db44",
+                "sha256:5dbbc68b317e5e42f327f9021763545dc3fc3bfe22e6deb96aaf1fc38874156a"
+            ],
+            "markers": "python_version >= '3.7'",
+            "version": "==2.1.2"
+        },
+        "jinja2": {
+            "hashes": [
+                "sha256:31351a702a408a9e7595a8fc6150fc3f43bb6bf7e319770cbc0db9df9437e852",
+                "sha256:6088930bfe239f0e6710546ab9c19c9ef35e29792895fed6e6e31a023a182a61"
+            ],
+            "markers": "python_version >= '3.7'",
+            "version": "==3.1.2"
+        },
+        "kombu": {
+            "hashes": [
+                "sha256:37cee3ee725f94ea8bb173eaab7c1760203ea53bbebae226328600f9d2799610",
+                "sha256:8b213b24293d3417bcf0d2f5537b7f756079e3ea232a8386dcc89a59fd2361a4"
+            ],
+            "markers": "python_version >= '3.7'",
+            "version": "==5.2.4"
+        },
+        "markupsafe": {
+            "hashes": [
+                "sha256:0576fe974b40a400449768941d5d0858cc624e3249dfd1e0c33674e5c7ca7aed",
+                "sha256:085fd3201e7b12809f9e6e9bc1e5c96a368c8523fad5afb02afe3c051ae4afcc",
+                "sha256:090376d812fb6ac5f171e5938e82e7f2d7adc2b629101cec0db8b267815c85e2",
+                "sha256:0b462104ba25f1ac006fdab8b6a01ebbfbce9ed37fd37fd4acd70c67c973e460",
+                "sha256:137678c63c977754abe9086a3ec011e8fd985ab90631145dfb9294ad09c102a7",
+                "sha256:1bea30e9bf331f3fef67e0a3877b2288593c98a21ccb2cf29b74c581a4eb3af0",
+                "sha256:22152d00bf4a9c7c83960521fc558f55a1adbc0631fbb00a9471e097b19d72e1",
+                "sha256:22731d79ed2eb25059ae3df1dfc9cb1546691cc41f4e3130fe6bfbc3ecbbecfa",
+                "sha256:2298c859cfc5463f1b64bd55cb3e602528db6fa0f3cfd568d3605c50678f8f03",
+                "sha256:28057e985dace2f478e042eaa15606c7efccb700797660629da387eb289b9323",
+                "sha256:2e7821bffe00aa6bd07a23913b7f4e01328c3d5cc0b40b36c0bd81d362faeb65",
+                "sha256:2ec4f2d48ae59bbb9d1f9d7efb9236ab81429a764dedca114f5fdabbc3788013",
+                "sha256:340bea174e9761308703ae988e982005aedf427de816d1afe98147668cc03036",
+                "sha256:40627dcf047dadb22cd25ea7ecfe9cbf3bbbad0482ee5920b582f3809c97654f",
+                "sha256:40dfd3fefbef579ee058f139733ac336312663c6706d1163b82b3003fb1925c4",
+                "sha256:4cf06cdc1dda95223e9d2d3c58d3b178aa5dacb35ee7e3bbac10e4e1faacb419",
+                "sha256:50c42830a633fa0cf9e7d27664637532791bfc31c731a87b202d2d8ac40c3ea2",
+                "sha256:55f44b440d491028addb3b88f72207d71eeebfb7b5dbf0643f7c023ae1fba619",
+                "sha256:608e7073dfa9e38a85d38474c082d4281f4ce276ac0010224eaba11e929dd53a",
+                "sha256:63ba06c9941e46fa389d389644e2d8225e0e3e5ebcc4ff1ea8506dce646f8c8a",
+                "sha256:65608c35bfb8a76763f37036547f7adfd09270fbdbf96608be2bead319728fcd",
+                "sha256:665a36ae6f8f20a4676b53224e33d456a6f5a72657d9c83c2aa00765072f31f7",
+                "sha256:6d6607f98fcf17e534162f0709aaad3ab7a96032723d8ac8750ffe17ae5a0666",
+                "sha256:7313ce6a199651c4ed9d7e4cfb4aa56fe923b1adf9af3b420ee14e6d9a73df65",
+                "sha256:7668b52e102d0ed87cb082380a7e2e1e78737ddecdde129acadb0eccc5423859",
+                "sha256:7df70907e00c970c60b9ef2938d894a9381f38e6b9db73c5be35e59d92e06625",
+                "sha256:7e007132af78ea9df29495dbf7b5824cb71648d7133cf7848a2a5dd00d36f9ff",
+                "sha256:835fb5e38fd89328e9c81067fd642b3593c33e1e17e2fdbf77f5676abb14a156",
+                "sha256:8bca7e26c1dd751236cfb0c6c72d4ad61d986e9a41bbf76cb445f69488b2a2bd",
+                "sha256:8db032bf0ce9022a8e41a22598eefc802314e81b879ae093f36ce9ddf39ab1ba",
+                "sha256:99625a92da8229df6d44335e6fcc558a5037dd0a760e11d84be2260e6f37002f",
+                "sha256:9cad97ab29dfc3f0249b483412c85c8ef4766d96cdf9dcf5a1e3caa3f3661cf1",
+                "sha256:a4abaec6ca3ad8660690236d11bfe28dfd707778e2442b45addd2f086d6ef094",
+                "sha256:a6e40afa7f45939ca356f348c8e23048e02cb109ced1eb8420961b2f40fb373a",
+                "sha256:a6f2fcca746e8d5910e18782f976489939d54a91f9411c32051b4aab2bd7c513",
+                "sha256:a806db027852538d2ad7555b203300173dd1b77ba116de92da9afbc3a3be3eed",
+                "sha256:abcabc8c2b26036d62d4c746381a6f7cf60aafcc653198ad678306986b09450d",
+                "sha256:b8526c6d437855442cdd3d87eede9c425c4445ea011ca38d937db299382e6fa3",
+                "sha256:bb06feb762bade6bf3c8b844462274db0c76acc95c52abe8dbed28ae3d44a147",
+                "sha256:c0a33bc9f02c2b17c3ea382f91b4db0e6cde90b63b296422a939886a7a80de1c",
+                "sha256:c4a549890a45f57f1ebf99c067a4ad0cb423a05544accaf2b065246827ed9603",
+                "sha256:ca244fa73f50a800cf8c3ebf7fd93149ec37f5cb9596aa8873ae2c1d23498601",
+                "sha256:cf877ab4ed6e302ec1d04952ca358b381a882fbd9d1b07cccbfd61783561f98a",
+                "sha256:d9d971ec1e79906046aa3ca266de79eac42f1dbf3612a05dc9368125952bd1a1",
+                "sha256:da25303d91526aac3672ee6d49a2f3db2d9502a4a60b55519feb1a4c7714e07d",
+                "sha256:e55e40ff0cc8cc5c07996915ad367fa47da6b3fc091fdadca7f5403239c5fec3",
+                "sha256:f03a532d7dee1bed20bc4884194a16160a2de9ffc6354b3878ec9682bb623c54",
+                "sha256:f1cd098434e83e656abf198f103a8207a8187c0fc110306691a2e94a78d0abb2",
+                "sha256:f2bfb563d0211ce16b63c7cb9395d2c682a23187f54c3d79bfec33e6705473c6",
+                "sha256:f8ffb705ffcf5ddd0e80b65ddf7bed7ee4f5a441ea7d3419e861a12eaf41af58"
+            ],
+            "markers": "python_version >= '3.7'",
+            "version": "==2.1.2"
+        },
+        "marshmallow": {
+            "hashes": [
+                "sha256:90032c0fd650ce94b6ec6dc8dfeb0e3ff50c144586462c389b81a07205bedb78",
+                "sha256:93f0958568da045b0021ec6aeb7ac37c81bfcccbb9a0e7ed8559885070b3a19b"
+            ],
+            "index": "pypi",
+            "version": "==3.19.0"
+        },
+        "packaging": {
+            "hashes": [
+                "sha256:714ac14496c3e68c99c29b00845f7a2b85f3bb6f1078fd9f72fd20f0570002b2",
+                "sha256:b6ad297f8907de0fa2fe1ccbd26fdaf387f5f47c7275fedf8cce89f99446cf97"
+            ],
+            "markers": "python_version >= '3.7'",
+            "version": "==23.0"
+        },
+        "pika": {
+            "hashes": [
+                "sha256:89f5e606646caebe3c00cbdbc4c2c609834adde45d7507311807b5775edac8e0",
+                "sha256:beb19ff6dd1547f99a29acc2c6987ebb2ba7c44bf44a3f8e305877c5ef7d2fdc"
+            ],
+            "index": "pypi",
+            "version": "==1.3.1"
+        },
+        "pubsubpy": {
+            "hashes": [
+                "sha256:58e394d14dd172fc03caff172adf3817d980bb6b8cb46cd18a362f8aa6e530c6",
+                "sha256:b29fa140615935ac03801ccd1de137ce4d33b741465b9002f290538ce966f2e9"
+            ],
+            "index": "pypi",
+            "version": "==2.3.0"
+        },
+        "pycparser": {
+            "hashes": [
+                "sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9",
+                "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206"
+            ],
+            "version": "==2.21"
+        },
+        "pyparsing": {
+            "hashes": [
+                "sha256:2b020ecf7d21b687f219b71ecad3631f644a47f01403fa1d1036b0c6416d70fb",
+                "sha256:5026bae9a10eeaefb61dab2f09052b9f4307d44aee4eda64b309723d8d206bbc"
+            ],
+            "markers": "python_version >= '3.1'",
+            "version": "==3.0.9"
+        },
+        "pyrabbit": {
+            "hashes": [
+                "sha256:50b8995fbfde14820ddc97292312c8f0c77054748c2b018138d03d94e400c39c"
+            ],
+            "index": "pypi",
+            "version": "==1.1.0"
+        },
+        "python-dateutil": {
+            "hashes": [
+                "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86",
+                "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"
+            ],
+            "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
+            "version": "==2.8.2"
+        },
+        "pytz": {
+            "hashes": [
+                "sha256:01a0681c4b9684a28304615eba55d1ab31ae00bf68ec157ec3708a8182dbbcd0",
+                "sha256:78f4f37d8198e0627c5f1143240bb0206b8691d8d7ac6d78fee88b78733f8c4a"
+            ],
+            "version": "==2022.7.1"
+        },
+        "six": {
+            "hashes": [
+                "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926",
+                "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"
+            ],
+            "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
+            "version": "==1.16.0"
+        },
+        "vine": {
+            "hashes": [
+                "sha256:4c9dceab6f76ed92105027c49c823800dd33cacce13bdedc5b914e3514b7fb30",
+                "sha256:7d3b1624a953da82ef63462013bbd271d3eb75751489f9807598e8f340bd637e"
+            ],
+            "markers": "python_version >= '3.6'",
+            "version": "==5.0.0"
+        },
+        "werkzeug": {
+            "hashes": [
+                "sha256:7ea2d48322cc7c0f8b3a215ed73eabd7b5d75d0b50e31ab006286ccff9e00b8f",
+                "sha256:f979ab81f58d7318e064e99c4506445d60135ac5cd2e177a2de0089bfd4c9bd5"
+            ],
+            "markers": "python_version >= '3.7'",
+            "version": "==2.2.2"
+        },
+        "zipp": {
+            "hashes": [
+                "sha256:6c4fe274b8f85ec73c37a8e4e3fa00df9fb9335da96fb789e3b96b318e5097b3",
+                "sha256:a3cac813d40993596b39ea9e93a18e8a2076d5c378b8bc88ec32ab264e04ad02"
+            ],
+            "markers": "python_version >= '3.7'",
+            "version": "==3.12.1"
+        }
+    },
+    "develop": {
+        "attrs": {
+            "hashes": [
+                "sha256:29e95c7f6778868dbd49170f98f8818f78f3dc5e0e37c0b1f474e3561b240836",
+                "sha256:c9227bfc2f01993c03f68db37d1d15c9690188323c067c641f1a35ca58185f99"
+            ],
+            "markers": "python_version >= '3.6'",
+            "version": "==22.2.0"
+        },
+        "black": {
+            "hashes": [
+                "sha256:0052dba51dec07ed029ed61b18183942043e00008ec65d5028814afaab9a22fd",
+                "sha256:0680d4380db3719ebcfb2613f34e86c8e6d15ffeabcf8ec59355c5e7b85bb555",
+                "sha256:121ca7f10b4a01fd99951234abdbd97728e1240be89fde18480ffac16503d481",
+                "sha256:162e37d49e93bd6eb6f1afc3e17a3d23a823042530c37c3c42eeeaf026f38468",
+                "sha256:2a951cc83ab535d248c89f300eccbd625e80ab880fbcfb5ac8afb5f01a258ac9",
+                "sha256:2bf649fda611c8550ca9d7592b69f0637218c2369b7744694c5e4902873b2f3a",
+                "sha256:382998821f58e5c8238d3166c492139573325287820963d2f7de4d518bd76958",
+                "sha256:49f7b39e30f326a34b5c9a4213213a6b221d7ae9d58ec70df1c4a307cf2a1580",
+                "sha256:57c18c5165c1dbe291d5306e53fb3988122890e57bd9b3dcb75f967f13411a26",
+                "sha256:7a0f701d314cfa0896b9001df70a530eb2472babb76086344e688829efd97d32",
+                "sha256:8178318cb74f98bc571eef19068f6ab5613b3e59d4f47771582f04e175570ed8",
+                "sha256:8b70eb40a78dfac24842458476135f9b99ab952dd3f2dab738c1881a9b38b753",
+                "sha256:9880d7d419bb7e709b37e28deb5e68a49227713b623c72b2b931028ea65f619b",
+                "sha256:9afd3f493666a0cd8f8df9a0200c6359ac53940cbde049dcb1a7eb6ee2dd7074",
+                "sha256:a29650759a6a0944e7cca036674655c2f0f63806ddecc45ed40b7b8aa314b651",
+                "sha256:a436e7881d33acaf2536c46a454bb964a50eff59b21b51c6ccf5a40601fbef24",
+                "sha256:a59db0a2094d2259c554676403fa2fac3473ccf1354c1c63eccf7ae65aac8ab6",
+                "sha256:a8471939da5e824b891b25751955be52ee7f8a30a916d570a5ba8e0f2eb2ecad",
+                "sha256:b0bd97bea8903f5a2ba7219257a44e3f1f9d00073d6cc1add68f0beec69692ac",
+                "sha256:b6a92a41ee34b883b359998f0c8e6eb8e99803aa8bf3123bf2b2e6fec505a221",
+                "sha256:bb460c8561c8c1bec7824ecbc3ce085eb50005883a6203dcfb0122e95797ee06",
+                "sha256:bfffba28dc52a58f04492181392ee380e95262af14ee01d4bc7bb1b1c6ca8d27",
+                "sha256:c1c476bc7b7d021321e7d93dc2cbd78ce103b84d5a4cf97ed535fbc0d6660648",
+                "sha256:c91dfc2c2a4e50df0026f88d2215e166616e0c80e86004d0003ece0488db2739",
+                "sha256:e6663f91b6feca5d06f2ccd49a10f254f9298cc1f7f49c46e498a0771b507104"
+            ],
+            "index": "pypi",
+            "version": "==23.1.0"
+        },
+        "click": {
+            "hashes": [
+                "sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e",
+                "sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48"
+            ],
+            "markers": "python_version >= '3.7'",
+            "version": "==8.1.3"
+        },
+        "exceptiongroup": {
+            "hashes": [
+                "sha256:327cbda3da756e2de031a3107b81ab7b3770a602c4d16ca618298c526f4bec1e",
+                "sha256:bcb67d800a4497e1b404c2dd44fca47d3b7a5e5433dbab67f96c1a685cdfdf23"
+            ],
+            "markers": "python_version < '3.11'",
+            "version": "==1.1.0"
+        },
+        "iniconfig": {
+            "hashes": [
+                "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3",
+                "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"
+            ],
+            "markers": "python_version >= '3.7'",
+            "version": "==2.0.0"
+        },
+        "mirakuru": {
+            "hashes": [
+                "sha256:ec84d4d81b4bca96cb0e598c6b3d198a92f036a0c1223c881482c02a98508226",
+                "sha256:fdb67d141cc9f7abd485a515d618daf3272c3e6ff48380749997ff8e8c5f2cb2"
+            ],
+            "markers": "python_version >= '3.7'",
+            "version": "==2.4.2"
+        },
+        "mypy-extensions": {
+            "hashes": [
+                "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d",
+                "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"
+            ],
+            "markers": "python_version >= '3.5'",
+            "version": "==1.0.0"
+        },
+        "packaging": {
+            "hashes": [
+                "sha256:714ac14496c3e68c99c29b00845f7a2b85f3bb6f1078fd9f72fd20f0570002b2",
+                "sha256:b6ad297f8907de0fa2fe1ccbd26fdaf387f5f47c7275fedf8cce89f99446cf97"
+            ],
+            "markers": "python_version >= '3.7'",
+            "version": "==23.0"
+        },
+        "pamqp": {
+            "hashes": [
+                "sha256:2f81b5c186f668a67f165193925b6bfd83db4363a6222f599517f29ecee60b02",
+                "sha256:5cd0f5a85e89f20d5f8e19285a1507788031cfca4a9ea6f067e3cf18f5e294e8"
+            ],
+            "version": "==2.3.0"
+        },
+        "pathspec": {
+            "hashes": [
+                "sha256:3a66eb970cbac598f9e5ccb5b2cf58930cd8e3ed86d393d541eaf2d8b1705229",
+                "sha256:64d338d4e0914e91c1792321e6907b5a593f1ab1851de7fc269557a21b30ebbc"
+            ],
+            "markers": "python_version >= '3.7'",
+            "version": "==0.11.0"
+        },
+        "platformdirs": {
+            "hashes": [
+                "sha256:8a1228abb1ef82d788f74139988b137e78692984ec7b08eaa6c65f1723af28f9",
+                "sha256:b1d5eb14f221506f50d6604a561f4c5786d9e80355219694a1b244bcd96f4567"
+            ],
+            "markers": "python_version >= '3.7'",
+            "version": "==3.0.0"
+        },
+        "pluggy": {
+            "hashes": [
+                "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159",
+                "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"
+            ],
+            "markers": "python_version >= '3.6'",
+            "version": "==1.0.0"
+        },
+        "port-for": {
+            "hashes": [
+                "sha256:232bd999015b7fbdf19f90f3a9298cc742252d67650108123940bfc75c6f4d4e",
+                "sha256:31860afde6cb552e1830c927def3288350c8fbbe9aea8aed8150ed9d1aa0de81"
+            ],
+            "markers": "python_version >= '3.7'",
+            "version": "==0.6.3"
+        },
+        "psutil": {
+            "hashes": [
+                "sha256:149555f59a69b33f056ba1c4eb22bb7bf24332ce631c44a319cec09f876aaeff",
+                "sha256:16653106f3b59386ffe10e0bad3bb6299e169d5327d3f187614b1cb8f24cf2e1",
+                "sha256:3d7f9739eb435d4b1338944abe23f49584bde5395f27487d2ee25ad9a8774a62",
+                "sha256:3ff89f9b835100a825b14c2808a106b6fdcc4b15483141482a12c725e7f78549",
+                "sha256:54c0d3d8e0078b7666984e11b12b88af2db11d11249a8ac8920dd5ef68a66e08",
+                "sha256:54d5b184728298f2ca8567bf83c422b706200bcbbfafdc06718264f9393cfeb7",
+                "sha256:6001c809253a29599bc0dfd5179d9f8a5779f9dffea1da0f13c53ee568115e1e",
+                "sha256:68908971daf802203f3d37e78d3f8831b6d1014864d7a85937941bb35f09aefe",
+                "sha256:6b92c532979bafc2df23ddc785ed116fced1f492ad90a6830cf24f4d1ea27d24",
+                "sha256:852dd5d9f8a47169fe62fd4a971aa07859476c2ba22c2254d4a1baa4e10b95ad",
+                "sha256:9120cd39dca5c5e1c54b59a41d205023d436799b1c8c4d3ff71af18535728e94",
+                "sha256:c1ca331af862803a42677c120aff8a814a804e09832f166f226bfd22b56feee8",
+                "sha256:efeae04f9516907be44904cc7ce08defb6b665128992a56957abc9b61dca94b7",
+                "sha256:fd8522436a6ada7b4aad6638662966de0d61d241cb821239b2ae7013d41a43d4"
+            ],
+            "markers": "sys_platform != 'cygwin'",
+            "version": "==5.9.4"
+        },
+        "pytest": {
+            "hashes": [
+                "sha256:c7c6ca206e93355074ae32f7403e8ea12163b1163c976fee7d4d84027c162be5",
+                "sha256:d45e0952f3727241918b8fd0f376f5ff6b301cc0777c6f9a556935c92d8a7d42"
+            ],
+            "index": "pypi",
+            "version": "==7.2.1"
+        },
+        "pytest-mock": {
+            "hashes": [
+                "sha256:f4c973eeae0282963eb293eb173ce91b091a79c1334455acfac9ddee8a1c784b",
+                "sha256:fbbdb085ef7c252a326fd8cdcac0aa3b1333d8811f131bdcc701002e1be7ed4f"
+            ],
+            "index": "pypi",
+            "version": "==3.10.0"
+        },
+        "pytest-rabbitmq": {
+            "hashes": [
+                "sha256:694ef26a6b85ec3b67428cd91a3abc35231c0a90adc70dbea59e94a6950dd07e",
+                "sha256:a9306b3e8c53440663fbc1baa7d77f574b9612403192fafa09224cd8824e19c1"
+            ],
+            "index": "pypi",
+            "version": "==2.2.1"
+        },
+        "rabbitpy": {
+            "hashes": [
+                "sha256:58be8ccef6d64010d98c77b0966be148aafb48b867359e885d9cd671361fe5ba",
+                "sha256:c6a6d0e8e51d0859fbb9bb78c8eeddd7b1ec79957db633673a8741d0cedf4830"
+            ],
+            "version": "==2.0.1"
+        },
+        "tomli": {
+            "hashes": [
+                "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc",
+                "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"
+            ],
+            "markers": "python_version < '3.11'",
+            "version": "==2.0.1"
+        },
+        "typing-extensions": {
+            "hashes": [
+                "sha256:1511434bb92bf8dd198c12b1cc812e800d4181cfcb867674e0f8279cc93087aa",
+                "sha256:16fa4864408f655d35ec496218b85f79b3437c829e93320c7c9215ccfd92489e"
+            ],
+            "markers": "python_version < '3.10'",
+            "version": "==4.4.0"
+        }
+    }
+}
diff --git a/api.py b/api.py
index 72d91fe..ee83b44 100644
--- a/api.py
+++ b/api.py
@@ -7,25 +7,31 @@ from endpoints.notify import Notify
 from endpoints.receive import Receive
 from endpoints.send import Send
 from endpoints.token import Token
-from models.token import TokenModel
+from models.token_model import TokenModel
+
 
 import os
 
 token = TokenModel()
 token.setSecret()
 
-app = Flask(__name__)
-api = Api(app)
-CORS(app, resources={r"*": {"origins": "http://localhost:8086"}})
 
-api.add_resource(ClientList, "/client")
-api.add_resource(Client, "/client/<client_id>")
-api.add_resource(Receive, "/receive")
-api.add_resource(Send, "/send")
-api.add_resource(Notify, "/notify")
-api.add_resource(Token, "/token")
+def create_app():
+    app = Flask(__name__)
+    api = Api(app)
+    CORS(app, resources={r"*": {"origins": "http://localhost:8086"}})
+
+    api.add_resource(ClientList, "/client")
+    api.add_resource(Client, "/client/<client_id>")
+    api.add_resource(Receive, "/receive")
+    api.add_resource(Send, "/send")
+    api.add_resource(Notify, "/notify")
+    api.add_resource(Token, "/token")
+    return app
+
 
 flask_host = os.getenv("FLASK_HOST", "localhost") # Sets to whatever MQ_HOST is, or defaults to localhost
 
 if __name__ == "__main__":
+    app = create_app()
     app.run(debug=False, port=8087, host=flask_host)
diff --git a/api_client_test.py b/api_client_test.py
new file mode 100644
index 0000000..f7f4b78
--- /dev/null
+++ b/api_client_test.py
@@ -0,0 +1,95 @@
+import flask
+import json
+import pytest
+from unittest.mock import patch, mock_open, call
+from werkzeug.exceptions import HTTPException
+from api import create_app
+
+
+@pytest.mark.usefixtures("mock_clients")
+def test_get_client(mock_clients):
+    app = create_app()
+    with patch(
+        "builtins.open", mock_open(read_data=json.dumps(mock_clients))
+    ) as mock_file_open, app.test_client() as app_test_client:
+        response = app_test_client.get("/client")
+        assert response.status_code == 200
+        assert len(response.json.keys()) == 2
+        assert "client-1" in response.json
+
+
+@pytest.mark.usefixtures("mock_clients")
+def test_get_client_1(mock_clients):
+    app = create_app()
+    with patch(
+        "builtins.open", mock_open(read_data=json.dumps(mock_clients))
+    ) as mock_file_open, app.test_client() as app_test_client:
+        response = app_test_client.get("/client/client-1")
+        assert response.status_code == 200
+        assert "client_id" in response.json
+        assert response.json["client_id"] == "client-1"
+        assert "client_name" in response.json
+        assert "secret" not in response.json
+
+
+@pytest.mark.usefixtures("mock_clients", "mock_new_client")
+def test_post_client(mock_clients, mock_new_client):
+    app = create_app()
+    with patch(
+        "builtins.open", mock_open(read_data=json.dumps(mock_clients))
+    ) as mock_file_open, app.test_client() as app_test_client:
+        response = app_test_client.post("/client", json=mock_new_client)
+        assert response.status_code == 201
+        assert "client_id" in response.json
+        assert response.json["client_id"] == "client-3"
+        assert "client_name" in response.json
+        assert "secret" in response.json
+
+
+@pytest.mark.usefixtures("mock_clients")
+def test_put_client_1(mock_clients):
+    app = create_app()
+    with patch(
+        "builtins.open", mock_open(read_data=json.dumps(mock_clients))
+    ) as mock_file_open, app.test_client() as app_test_client:
+        response = app_test_client.get("/client/client-1")
+        client_1 = response.json
+        print(client_1)
+        client_1["subscription"] = "soar.client-1.#"
+        response = app_test_client.put("/client/client-1", json=client_1)
+        print(response.data)
+        assert response.status_code == 201
+        assert "client_id" in response.json
+        assert response.json["client_id"] == "client-1"
+        assert response.json["subscription"] == "soar.client-1.#"
+        assert "client_name" in response.json
+        assert "secret" in response.json
+
+
+@pytest.mark.usefixtures("mock_clients")
+def test_put_client_1(mock_clients):
+    app = create_app()
+    with patch(
+        "builtins.open", mock_open(read_data=json.dumps(mock_clients))
+    ) as mock_file_open, app.test_client() as app_test_client:
+        response = app_test_client.get("/client/client-1")
+        client_1 = response.json
+        print(client_1)
+        client_1["subscription"] = "soar.client-1.#"
+        response = app_test_client.put("/client/client-1", json=client_1)
+        assert response.status_code == 201
+        assert "client_id" in response.json
+        assert response.json["client_id"] == "client-1"
+        assert response.json["subscription"] == "soar.client-1.#"
+        assert "client_name" in response.json
+        assert "secret" in response.json
+
+
+@pytest.mark.usefixtures("mock_clients")
+def test_delete_client_1(mock_clients):
+    app = create_app()
+    with patch(
+        "builtins.open", mock_open(read_data=json.dumps(mock_clients))
+    ) as mock_file_open, app.test_client() as app_test_client:
+        response = app_test_client.delete("/client/client-1")
+        assert response.status_code == 204
diff --git a/api_send_test.py b/api_send_test.py
new file mode 100644
index 0000000..80db180
--- /dev/null
+++ b/api_send_test.py
@@ -0,0 +1,59 @@
+import flask
+import json
+import pytest
+from unittest.mock import patch, mock_open, call
+from werkzeug.exceptions import HTTPException
+from api import create_app
+from endpoints import send
+from conftest import get_auth_header
+
+
+@pytest.mark.usefixtures(
+    "mock_clients", "mock_client_credentials", "mock_post_send", "mock_message_send"
+)
+def test_post_send(
+    mock_clients, mock_client_credentials, mock_post_send, mock_message_send
+):
+    app = create_app()
+    with patch(
+        "builtins.open", mock_open(read_data=json.dumps(mock_clients))
+    ) as mock_file_open, patch.object(
+        send, "write_to_queue"
+    ) as mock_write_to_queue, app.test_client() as app_test_client:
+        auth_header = get_auth_header(app_test_client, mock_client_credentials)
+        response = app_test_client.post(
+            "/send", json=mock_post_send, headers=auth_header
+        )
+        assert response.status_code == 200
+        mock_write_to_queue.assert_called_once_with(
+            queue_name="client-1-outbox", msg=json.dumps(mock_message_send)
+        )
+
+
+@pytest.mark.usefixtures("mock_clients", "mock_post_send")
+def test_post_send_no_token(mock_clients, mock_post_send):
+    app = create_app()
+    with patch(
+        "builtins.open", mock_open(read_data=json.dumps(mock_clients))
+    ) as mock_file_open, patch.object(
+        send, "write_to_queue"
+    ) as mock_write_to_queue, app.test_client() as app_test_client:
+        response = app_test_client.post("/send", json=mock_post_send)
+        assert response.status_code == 403
+        mock_write_to_queue.assert_not_called()
+
+
+@pytest.mark.usefixtures("mock_clients", "mock_post_send")
+def test_post_send_invalid_token(mock_clients, mock_post_send):
+    app = create_app()
+    with patch(
+        "builtins.open", mock_open(read_data=json.dumps(mock_clients))
+    ) as mock_file_open, patch.object(
+        send, "write_to_queue"
+    ) as mock_write_to_queue, app.test_client() as app_test_client:
+        auth_header = {"Authorization": "made-up-token"}
+        response = app_test_client.post(
+            "/send", json=mock_post_send, headers=auth_header
+        )
+        assert response.status_code == 403
+        mock_write_to_queue.assert_not_called()
diff --git a/api_token_test.py b/api_token_test.py
new file mode 100644
index 0000000..7245cec
--- /dev/null
+++ b/api_token_test.py
@@ -0,0 +1,39 @@
+import flask
+import json
+import pytest
+from unittest.mock import patch, mock_open, call
+from werkzeug.exceptions import HTTPException
+from api import create_app
+
+
+@pytest.mark.usefixtures("mock_clients", "mock_client_credentials")
+def test_get_token(mock_clients, mock_client_credentials):
+    app = create_app()
+    with patch(
+        "builtins.open", mock_open(read_data=json.dumps(mock_clients))
+    ) as mock_file_open, app.test_client() as app_test_client:
+        response = app_test_client.get("/token", query_string=mock_client_credentials)
+        assert response.status_code == 200
+        assert "token" in response.json
+
+
+@pytest.mark.usefixtures("mock_clients", "mock_invalid_credentials")
+def test_get_token_invalid_credentials(mock_clients, mock_invalid_credentials):
+    app = create_app()
+    with patch(
+        "builtins.open", mock_open(read_data=json.dumps(mock_clients))
+    ) as mock_file_open, app.test_client() as app_test_client:
+        response = app_test_client.get("/token", query_string=mock_invalid_credentials)
+        assert response.status_code == 403
+        assert "token" not in response.json
+
+
+@pytest.mark.usefixtures("mock_clients", "mock_client_credentials")
+def test_get_token_no_credentials(mock_clients, mock_client_credentials):
+    app = create_app()
+    with patch(
+        "builtins.open", mock_open(read_data=json.dumps(mock_clients))
+    ) as mock_file_open, app.test_client() as app_test_client:
+        response = app_test_client.get("/token")
+        assert response.status_code == 400
+        assert "token" not in response.json
diff --git a/conftest.py b/conftest.py
new file mode 100644
index 0000000..c6fcacd
--- /dev/null
+++ b/conftest.py
@@ -0,0 +1,92 @@
+import pytest
+
+
+def clients():
+    return {
+        "client-1": {
+            "client_id": "client-1",
+            "client_name": "Client 1",
+            "subscription": "soar.#",
+            "secret": "abc123",
+        },
+        "client-2": {
+            "client_id": "client-2",
+            "client_name": "Client 2",
+            "subscription": "soar.client-2.#",
+            "secret": "xyz789",
+        },
+    }
+
+
+def get_auth_header(client, credentials):
+    token_response = client.get("/token", query_string=credentials)
+    if token_response.status_code == 200:
+        token = token_response.json["token"]
+        return {"Authorization": f"Bearer {token}"}
+    else:
+        return None
+
+
+@pytest.fixture
+def mock_clients():
+    return clients()
+
+
+@pytest.fixture
+def mock_new_client():
+    return {
+        "client_id": "client-3",
+        "client_name": "Client 3",
+        "subscription": "soar.client-3.#",
+    }
+
+
+@pytest.fixture
+def mock_client_credentials():
+    mock_clients = clients()
+    return {
+        "client_id": mock_clients["client-1"]["client_id"],
+        "secret": mock_clients["client-1"]["secret"],
+    }
+
+
+@pytest.fixture
+def mock_invalid_credentials():
+    return {"client_id": "client-invalid", "secret": "fake-secret"}
+
+
+@pytest.fixture
+def mock_token_secret():
+    return "2UrRyeb9c6hq8Gj9nmI5safPz9LpPeUFtifeMNx4GQo="
+
+
+def posts():
+    return {
+        "send": {
+            "topic": "soar.client-1.message",
+            "body": "this is a pub/sub message from client-1",
+        },
+        "notify": {"body": "this is a broadcast message from client-1"},
+    }
+
+
+@pytest.fixture
+def mock_post_send():
+    return posts()["send"]
+
+
+@pytest.fixture
+def mock_message_send():
+    post = posts()["send"]
+    return {"topic": post["topic"], "message": post["body"]}
+
+
+@pytest.fixture
+def mock_post_notify():
+    return posts()["notify"]
+
+
+@pytest.fixture
+def mock_message_notify():
+    post = posts()["send"]
+    return {"message": post["body"]}
diff --git a/docker/Dockerfile b/docker/Dockerfile
index 606b939..f4b5099 100644
--- a/docker/Dockerfile
+++ b/docker/Dockerfile
@@ -3,6 +3,7 @@ FROM python:alpine3.17
 WORKDIR /app
 
 COPY requirements.txt requirements.txt
-RUN pip install -r requirements.txt
+COPY requirements-dev.txt requirements-dev.txt
+RUN pip install -r requirements-dev.txt
 
 ENTRYPOINT [ "python" ]
\ No newline at end of file
diff --git a/endpoints/auth_resource.py b/endpoints/auth_resource.py
index 6429a20..b970928 100644
--- a/endpoints/auth_resource.py
+++ b/endpoints/auth_resource.py
@@ -2,28 +2,26 @@ import json
 import os
 
 from flask_restful import Resource, abort
-
-from models.token import TokenModel
+from models.token_model import TokenModel
 
 
 class AuthResource(Resource):
-
-    def __init__(self): 
+    def __init__(self):
         self.token = TokenModel()
         with open("./data/clients.json", "r") as clients_file:
             self.clients = json.load(clients_file)
 
-    def auth(self, request): 
+    def auth(self, request):
         allow = False
-        auth = request.headers.get('Authorization', False)
+        auth = request.headers.get("Authorization", False)
         if auth:
-            token = auth.split(' ').pop()
+            token = auth.split(" ").pop()
             parsed = self.token.validate(token)
-            if parsed['valid']:
-                client = self.clients.get(parsed['client_id'])
-                if client: 
+            if parsed["valid"]:
+                client = self.clients.get(parsed["client_id"])
+                if client:
                     self.client = client
                     allow = True
         if not allow:
             abort(403, message="Invalid token")
-        return allow
\ No newline at end of file
+        return allow
diff --git a/endpoints/client.py b/endpoints/client.py
new file mode 100644
index 0000000..60fca56
--- /dev/null
+++ b/endpoints/client.py
@@ -0,0 +1,77 @@
+from flask_restful import Resource, request, abort
+from marshmallow import Schema, fields
+import json
+import os
+import random
+import string
+from models.client_model import ClientModel
+
+
+class ClientSchema(Schema):
+    client_id = fields.Str(required=True)
+    client_name = fields.Str(required=True)
+    subscription = fields.Str(required=True)
+
+
+# Client
+class Client(Resource):
+    clients_file = None
+
+    def __init__(self):
+        self.schema = ClientSchema()
+        self.clients_file = ClientModel()
+
+    def get(self, client_id):
+        client = self.clients_file.find(client_id)
+        del client["secret"]
+        if not client:
+            abort(404, message="No client with id: {}".format(client_id))
+        return client
+
+    def delete(self, client_id):
+        client = self.clients_file.find(client_id)
+        if not client:
+            abort(404, message="No client with id: {}".format(client_id))
+        else:
+            self.clients_file.remove(client)
+        return client, 204
+
+    def put(self, client_id):
+        args = request.get_json()
+        errors = self.schema.validate(args)
+        if errors:
+            abort(400, message=str(errors))
+
+        client = self.clients_file.find(client_id)
+        if not client:
+            abort(404, message="No client with id: {}".format(client_id))
+        else:
+            client = self.clients_file.update(args)
+        return client, 201
+
+
+# ClientList
+class ClientList(Resource):
+    def __init__(self):
+        self.schema = ClientSchema()
+        self.clients_file = ClientModel()
+
+    def get(self):
+        return {
+            client_id: (client, client.pop("secret", None))[0]
+            for client_id, client in self.clients_file.get().items()
+        }
+
+    def post(self):
+        args = request.get_json()
+
+        errors = self.schema.validate(args)
+        if errors:
+            abort(400, message=str(errors))
+
+        client = self.clients_file.find(args["client_id"])
+        if client:
+            abort(403, message="Duplicate client id: {}".format(client_id))
+        else:
+            client = self.clients_file.add(args)
+        return client, 201
diff --git a/endpoints/notify.py b/endpoints/notify.py
index 22bcb8d..dff460e 100644
--- a/endpoints/notify.py
+++ b/endpoints/notify.py
@@ -10,6 +10,7 @@ from rmq import write_to_queue
 class NotifySchema(Schema):
     body = fields.Str(required=True)
 
+
 class Notify(AuthResource):
     clients = None
     schema = None
@@ -17,7 +18,7 @@ class Notify(AuthResource):
     def __init__(self):
         super().__init__()
         self.schema = NotifySchema()
-   
+
     def post(self):
         args = request.get_json()
         errors = self.schema.validate(args)
@@ -27,12 +28,12 @@ class Notify(AuthResource):
         allow = False
         body = args.get("body")
         message = {
-            'topic': 'broadcast',
-            'message': body,
+            "topic": "broadcast",
+            "message": body,
         }
 
         allow = self.auth(request)
-        
+
         if allow:
-            notify_queue = self.client['client_id'] + "-broadcast"
-            write_to_queue(queue_name=notify_queue, msg=json.dumps(message))
\ No newline at end of file
+            notify_queue = self.client["client_id"] + "-broadcast"
+            write_to_queue(queue_name=notify_queue, msg=json.dumps(message))
diff --git a/endpoints/receive.py b/endpoints/receive.py
index 5c1f99c..bc299fa 100644
--- a/endpoints/receive.py
+++ b/endpoints/receive.py
@@ -26,5 +26,5 @@ class Receive(AuthResource):
 
         allow = self.auth(request)
         if allow:
-            inbox_queue = self.client['client_id'] + "-inbox"
-            return read_from_queue(queue_name=inbox_queue, max_msgs=max_messages)
\ No newline at end of file
+            inbox_queue = self.client["client_id"] + "-inbox"
+            return read_from_queue(queue_name=inbox_queue, max_msgs=max_messages)
diff --git a/endpoints/send.py b/endpoints/send.py
index 9fe91ca..cb64c4a 100644
--- a/endpoints/send.py
+++ b/endpoints/send.py
@@ -11,6 +11,7 @@ class SendSchema(Schema):
     body = fields.Str(required=True)
     topic = fields.Str(required=True)
 
+
 class Send(AuthResource):
     clients = None
     schema = None
@@ -18,7 +19,7 @@ class Send(AuthResource):
     def __init__(self):
         super().__init__()
         self.schema = SendSchema()
-    
+
     def post(self):
         args = request.get_json()
         errors = self.schema.validate(args)
@@ -26,14 +27,14 @@ class Send(AuthResource):
             abort(400, message=str(errors))
 
         allow = self.auth(request)
-        
+
         if allow:
             body = args.get("body")
             topic = args.get("topic")
-            outbox_queue = self.client['client_id'] + "-outbox"
+            outbox_queue = self.client["client_id"] + "-outbox"
             message = {
-                'topic': topic,
-                'message': body,
+                "topic": topic,
+                "message": body,
             }
 
-            write_to_queue(queue_name=outbox_queue, msg=json.dumps(message))
\ No newline at end of file
+            write_to_queue(queue_name=outbox_queue, msg=json.dumps(message))
diff --git a/endpoints/token.py b/endpoints/token.py
index cbc1ec5..7d1f28e 100644
--- a/endpoints/token.py
+++ b/endpoints/token.py
@@ -1,7 +1,7 @@
-import json 
+import json
 from flask_restful import Resource, request, abort
 from marshmallow import Schema, fields
-from models.token import TokenModel
+from models.token_model import TokenModel
 
 class TokenQuerySchema(Schema):
     client_id = fields.Str(required=True)
@@ -18,7 +18,7 @@ class Token(Resource):
         self.model = TokenModel()
         with open("./data/clients.json", "r") as clients_file:
             self.clients = json.load(clients_file)
-    
+
     def get(self):
         errors = self.schema.validate(request.args)
         if errors:
@@ -38,4 +38,4 @@ class Token(Resource):
 
         else:
             abort(403, message="Invalid client credentials")
-        return token
\ No newline at end of file
+        return token
diff --git a/models/client_model.py b/models/client_model.py
new file mode 100644
index 0000000..ec52219
--- /dev/null
+++ b/models/client_model.py
@@ -0,0 +1,83 @@
+"""
+The backbone doesn't have a relational database or other backend state provider 
+It just saves its configuration to the local filesystem
+In docker data is saved to a mounted volume for persistence
+
+POSTS to /client create entries in clients.json. The return from the post 
+contains a generated secret but subsequent calls to GET /client or 
+GET /client/{id} do not return the secret.
+
+Each time .find is called the .get method is called which checks the 
+mtime of the file and reloads it if newer. This means that new clients 
+should be returned
+"""
+import json
+import os
+import random
+import string
+
+
+class ClientModel:
+    file = "clients.json"
+    mtime = 0
+    clients = {}
+    parser = None
+
+    def __init__(self):
+        self.get()
+
+    def get(self):
+        try:
+            mtime = os.path.getmtime(self.file)
+            if mtime > self.mtime:
+                with open(self.file, "r") as client_file:
+                    self.clients = json.load(client_file)
+                    self.mtime = mtime
+        except FileNotFoundError as error:
+            self.clients = {}
+            self.save()
+            self.mtime = os.path.getmtime(self.file)
+
+        return self.clients
+
+    def find(self, client_id):
+        self.get()
+        if client_id in self.clients:
+            client = self.clients[client_id]
+        else:
+            client = None
+        return client
+
+    def add(self, client):
+        client["secret"] = self.secret()
+        self.clients[client["client_id"]] = client
+        self.save()
+        return client
+
+    def remove(self, client):
+        del self.clients[client["client_id"]]
+        self.save()
+
+    def update(self, client_updates):
+        client = self.find(client_updates["client_id"])
+        client.update(client_updates)
+        self.clients[client["client_id"]] = client
+        self.save()
+        return client
+
+    def save(self):
+        try:
+            with open(self.file, "w") as client_file:
+                client_file.write(json.dumps(self.clients, indent=2))
+                return True
+        except OSError as error:
+            print(str(error))
+            return False
+
+    def secret(self, chars=36):
+        res = "".join(
+            random.choices(
+                string.ascii_lowercase + string.ascii_uppercase + string.digits, k=chars
+            )
+        )
+        return str(res)
diff --git a/models/client_model_test.py b/models/client_model_test.py
new file mode 100644
index 0000000..2791092
--- /dev/null
+++ b/models/client_model_test.py
@@ -0,0 +1,135 @@
+import json
+import os
+import pytest
+import re
+from unittest.mock import patch, mock_open, call
+from client_model import ClientModel
+
+
+@pytest.mark.usefixtures("mock_clients")
+def test_init_with_clients(mock_clients):
+    # File is read on __init__
+    with patch(
+        "builtins.open", mock_open(read_data=json.dumps(mock_clients))
+    ) as mock_file_open:
+        clients_file = ClientModel()
+        assert len(clients_file.clients.keys()) == 2
+        assert "client-1" in clients_file.clients.keys()
+
+
+def test_init_no_clients_file():
+    # Handles FileNotFound on __init__
+    with patch("builtins.open", side_effect=FileNotFoundError()) as mock_file_open:
+        clients_file = ClientModel()
+        assert len(clients_file.clients.keys()) == 0
+        assert "client-1" not in clients_file.clients.keys()
+
+
+@pytest.mark.usefixtures("mock_clients")
+def test_get_unmodified(mock_clients):
+    # If modtime is unchanged file is not read
+    with patch(
+        "builtins.open", mock_open(read_data=json.dumps(mock_clients))
+    ) as mock_file_open, patch("os.path.getmtime", return_value=3) as mock_getmtime:
+        clients_file = ClientModel()
+        clients = clients_file.get()
+        assert mock_file_open.call_count == 1
+
+
+@pytest.mark.usefixtures("mock_clients")
+def test_get_modified(mock_clients):
+    # If modtime has changed file is re-read
+    with patch(
+        "builtins.open", mock_open(read_data=json.dumps(mock_clients))
+    ) as mock_file_open, patch("os.path.getmtime", return_value=3) as mock_getmtime:
+        print(os.path.getmtime("clients.json"))
+        clients_file = ClientModel()
+        mock_getmtime.return_value = 4
+        print(os.path.getmtime("clients.json"))
+        clients = clients_file.get()
+        assert mock_file_open.call_count == 2
+
+
+@pytest.mark.usefixtures("mock_clients")
+def test_find_existing(mock_clients):
+    with patch(
+        "builtins.open", mock_open(read_data=json.dumps(mock_clients))
+    ) as mock_file_open:
+        clients_file = ClientModel()
+        client = clients_file.find("client-1")
+        assert client is not None
+        assert client.get("subscription") == "soar.#"
+
+
+@pytest.mark.usefixtures("mock_clients")
+def test_find_nonexisting(mock_clients):
+    with patch(
+        "builtins.open", mock_open(read_data=json.dumps(mock_clients))
+    ) as mock_file_open:
+        clients_file = ClientModel()
+        client = clients_file.find("client-3")
+        assert client is None
+
+
+@pytest.mark.usefixtures("mock_clients", "mock_new_client")
+def test_add(mock_clients, mock_new_client):
+    with patch(
+        "builtins.open", mock_open(read_data=json.dumps(mock_clients))
+    ) as mock_file_open:
+        clients_file = ClientModel()
+        client = clients_file.add(mock_new_client)
+        assert client["client_id"] in clients_file.clients
+        assert "secret" in client
+
+
+@pytest.mark.usefixtures("mock_clients")
+def test_remove(mock_clients):
+    with patch(
+        "builtins.open", mock_open(read_data=json.dumps(mock_clients))
+    ) as mock_file_open:
+        clients_file = ClientModel()
+        client = clients_file.find("client-2")
+        clients_file.remove(client)
+        assert len(clients_file.clients) == 1
+        assert "client-2" not in clients_file.clients
+
+
+@pytest.mark.usefixtures("mock_clients")
+def test_update(mock_clients):
+    with patch(
+        "builtins.open", mock_open(read_data=json.dumps(mock_clients))
+    ) as mock_file_open:
+        clients_file = ClientModel()
+        client = clients_file.find("client-1")
+        assert client["subscription"] == "soar.#"
+        client["subscription"] = "soar.client-1.#"
+        clients_file.update(client)
+        client = clients_file.find("client-1")
+        assert len(clients_file.clients) == 2
+        assert client["subscription"] == "soar.client-1.#"
+
+
+@pytest.mark.usefixtures("mock_clients")
+def test_save(mock_clients):
+    with patch(
+        "builtins.open", mock_open(read_data=json.dumps(mock_clients))
+    ) as mock_file_open:
+        clients_file = ClientModel()
+        mock_file_open.assert_any_call("clients.json", "r")
+        clients_file.save()
+        mock_file_open.assert_any_call("clients.json", "w")
+        mock_file_open().write.has_any_call(json.dumps(mock_clients))
+
+
+@pytest.mark.usefixtures("mock_clients")
+def test_secret(mock_clients):
+    with patch(
+        "builtins.open", mock_open(read_data=json.dumps(mock_clients))
+    ) as mock_file_open:
+        secret_pattern = "^[A-Za-z0-9]+$"
+        clients_file = ClientModel()
+        secret = clients_file.secret()
+        assert len(secret) == 36
+        assert re.match(secret_pattern, secret)
+        secret = clients_file.secret(48)
+        assert len(secret) == 48
diff --git a/models/token.py b/models/token_model.py
similarity index 55%
rename from models/token.py
rename to models/token_model.py
index 6721a99..9782085 100644
--- a/models/token.py
+++ b/models/token_model.py
@@ -1,4 +1,4 @@
-from cryptography.fernet import Fernet,InvalidToken
+from cryptography.fernet import Fernet, InvalidToken
 import datetime
 import os
 import json
@@ -7,84 +7,73 @@ import json
 TOKENS = {}
 
 
-class TokenModel():
+class TokenModel:
     clients = None
     schema = None
     key = None
     fernet = None
     token_lifetime_hours = None
-    env_lifetime = 'SOAR_TOKEN_LIFETIME'
-    env_secret = 'SOAR_TOKEN_SECRET'
+    env_lifetime = "SOAR_TOKEN_LIFETIME"
+    env_secret = "SOAR_TOKEN_SECRET"
 
     def __init__(self):
         self.getFernet()
-        self.token_lifetime_hours = os.getenv(self.env_lifetime, 24)
-    
-    def getFernet(self): 
-        self.fernet = Fernet(self.getKey().encode()) 
+        self.token_lifetime_hours = int(os.getenv(self.env_lifetime, 24))
 
-    def getKey(self): 
+    def getFernet(self):
+        self.fernet = Fernet(self.getKey().encode())
+
+    def getKey(self):
         key = os.getenv(self.env_secret)
         print(key)
-        if not key: 
+        if not key:
             key = Fernet.generate_key().decode()
             os.environ[self.env_secret] = key
-        self.key = key    
+        self.key = key
         return self.key
 
-    def setSecret(self): 
+    def setSecret(self):
         if not os.getenv(self.env_secret):
-            os.environ[self.env_secret] = self.getKey() 
+            os.environ[self.env_secret] = self.getKey()
 
-    def getExpiry(self): 
+    def getExpiry(self):
         now = datetime.datetime.utcnow()
         expires = now + datetime.timedelta(hours=self.token_lifetime_hours)
         return expires.isoformat()
-    
+
     def encrypt(self, client_id):
         try:
             expiry = self.getExpiry()
-            token_content = {
-                'client_id': client_id,
-                'expiry': expiry 
-            }
+            token_content = {"client_id": client_id, "expiry": expiry}
             token = self.fernet.encrypt(json.dumps(token_content).encode()).decode()
-            return {
-                'token': token,
-                'expiry': expiry
-            }
-        except KeyError as e: 
+            return {"token": token, "expiry": expiry}
+        except KeyError as e:
             return None
-    
+
     def decrypt(self, token):
-        try:  
+        try:
             content = json.loads(self.fernet.decrypt(token.encode()).decode())
-            return content 
-        except (InvalidToken,KeyError) as e:
+            return content
+        except (InvalidToken, KeyError) as e:
             return None
 
     def get(self, client_id):
         response = self.encrypt(client_id)
-        TOKENS[response['token']] = client_id
-        return response 
+        TOKENS[response["token"]] = client_id
+        return response
 
     def validate(self, token):
-        response = {
-            'valid': False
-        }
+        response = {"valid": False}
         if token in TOKENS:
             content = self.decrypt(token)
             if content:
                 now = datetime.datetime.utcnow()
-                expires = datetime.datetime.fromisoformat(content['expiry'])
-                response['valid'] = expires > now
-                if response['valid']:
+                expires = datetime.datetime.fromisoformat(content["expiry"])
+                response["valid"] = expires > now
+                if response["valid"]:
                     response.update(content)
-                else: 
+                else:
                     del TOKENS[token]
             else:
                 del TOKENS[token]
-        return response 
-
-
-
+        return response
diff --git a/models/token_model_test.py b/models/token_model_test.py
new file mode 100644
index 0000000..e8748c9
--- /dev/null
+++ b/models/token_model_test.py
@@ -0,0 +1,130 @@
+import cryptography
+from datetime import datetime, timedelta
+import json
+import math
+import os
+import pytest
+import re
+from unittest.mock import patch, mock_open, call
+from token_model import TokenModel
+
+
+def test_init():
+    token = TokenModel()
+    assert type(token.fernet) == cryptography.fernet.Fernet
+    assert token.token_lifetime_hours == 24
+
+
+def test_get_fernet_no_env():
+    token = TokenModel()
+    token.getFernet()
+    assert type(token.fernet) == cryptography.fernet.Fernet
+
+
+@pytest.mark.usefixtures("mock_token_secret")
+def test_get_fernet_env(mock_token_secret):
+    with patch.dict(os.environ, {"SOAR_TOKEN_SECRET": mock_token_secret}):
+        token = TokenModel()
+        token.getFernet()
+        assert type(token.fernet) == cryptography.fernet.Fernet
+        assert token.key == mock_token_secret
+
+
+def test_get_key_no_env():
+    token = TokenModel()
+    secret = token.getKey()
+    assert secret is not None
+    assert token.key is not None
+    assert secret == token.key
+
+
+@pytest.mark.usefixtures("mock_token_secret")
+def test_get_key_env(mock_token_secret):
+    with patch.dict(os.environ, {"SOAR_TOKEN_SECRET": mock_token_secret}):
+        token = TokenModel()
+        secret = token.getKey()
+        assert secret == mock_token_secret
+        assert token.key == mock_token_secret
+
+
+def test_set_secret():
+    with patch.dict(os.environ):
+        token = TokenModel()
+        token.setSecret()
+        assert os.getenv(token.env_secret) == token.key
+
+
+def test_get_expiry_no_env():
+    token = TokenModel()
+    now = datetime.utcnow()
+    expected = now + timedelta(hours=token.token_lifetime_hours)
+    returned = datetime.strptime(token.getExpiry(), "%Y-%m-%dT%H:%M:%S.%f")
+    diff_seconds = abs(int((returned - expected).total_seconds()))
+    # Although the expected and returned are calculated separately
+    # the difference is generally a few microseconds.
+    assert diff_seconds == 0
+    lifetime = math.ceil((returned - now).total_seconds())
+    assert lifetime >= 60 * 60 * 24
+
+
+def test_get_expiry_env():
+    with patch.dict(os.environ, {"SOAR_TOKEN_LIFETIME": "1"}):
+        token = TokenModel()
+        now = datetime.utcnow()
+        expected = now + timedelta(hours=token.token_lifetime_hours)
+        returned = datetime.fromisoformat(token.getExpiry())
+        diff_seconds = abs(int((returned - expected).total_seconds()))
+
+        assert diff_seconds == 0
+        lifetime = math.ceil((returned - now).total_seconds())
+        assert lifetime >= 60 * 60
+
+
+def test_encrypt_decrypt_no_env():
+    token = TokenModel()
+    clear = "test"
+    cipher = token.encrypt(clear)
+    decoded = token.decrypt(cipher["token"])
+    assert decoded["client_id"] == clear
+    assert decoded["expiry"] == cipher["expiry"]
+
+
+@pytest.mark.usefixtures("mock_token_secret")
+def test_encrypt_decrypt_env(mock_token_secret):
+    with patch.dict(os.environ, {"SOAR_TOKEN_SECRET": mock_token_secret}):
+        token = TokenModel()
+        clear = "test"
+        cipher = token.encrypt(clear)
+        decoded = token.decrypt(cipher["token"])
+        assert decoded["client_id"] == clear
+        assert decoded["expiry"] == cipher["expiry"]
+        # A separate instance with the same secret
+        # is able to decode existing encrypted data
+        token = TokenModel()
+        decoded = token.decrypt(cipher["token"])
+        assert decoded["client_id"] == clear
+        assert decoded["expiry"] == cipher["expiry"]
+
+
+def test_get():
+    # get returns an encrypted token
+    token = TokenModel()
+    response = token.get("test")
+    assert "token" in response
+    assert "expiry" in response
+    decoded = token.decrypt(response["token"])
+    assert "client_id" in decoded
+    assert decoded["client_id"] == "test"
+
+
+def test_validate():
+    token = TokenModel()
+    valid_response = token.get("test")
+    valid_token = valid_response["token"]
+    validation = token.validate(valid_token)
+    assert validation["valid"]
+    assert "client_id" in validation
+    invalid_token = "abc" + valid_token[3:]
+    validation = token.validate(invalid_token)
+    assert not validation["valid"]
+    assert "client_id" not in validation
diff --git a/requirements-dev.txt b/requirements-dev.txt
new file mode 100644
index 0000000..018e083
--- /dev/null
+++ b/requirements-dev.txt
@@ -0,0 +1,5 @@
+-r requirements.txt
+black==23.1.0
+pytest==7.2.1
+pytest-mock==3.10.0
+pytest-rabbitmq==2.2.1
\ No newline at end of file
diff --git a/requirements.txt b/requirements.txt
index 6cd0750..7715a39 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -16,7 +16,7 @@ jinja2==3.1.2 ; python_version >= '3.7'
 kombu==5.2.4 ; python_version >= '3.7'
 markupsafe==2.1.1 ; python_version >= '3.7'
 marshmallow==3.19.0
-packaging==21.3 ; python_version >= '3.6'
+packaging>=22.0 ; python_version >= '3.6'
 pika==1.3.1
 pubsubpy==2.3.0
 pycparser==2.21
diff --git a/rmq.py b/rmq.py
index ae9656b..719835e 100644
--- a/rmq.py
+++ b/rmq.py
@@ -7,6 +7,7 @@ host = os.getenv("MQ_HOST", "localhost") # Sets to whatever MQ_HOST is, or defau
 
 # -------------------------------------------------------------------------------------------------------------------------------------------------------------
 
+
 def pika_connect(host):
     try:
         connection = pika.BlockingConnection(pika.ConnectionParameters(host))
@@ -22,16 +23,22 @@ def pika_connect(host):
     return connection, channel
 
 
-def setup_queue(channel, queue_name=''):
-    channel.queue_declare(queue=queue_name, exclusive=False, durable=True) # exclusive means the queue can only be used by the connection that created it
+def setup_queue(channel, queue_name=""):
+    channel.queue_declare(
+        queue=queue_name, exclusive=False, durable=True
+    )  # exclusive means the queue can only be used by the connection that created it
 
 
 def fanout_exchange(channel, exchange_name):
-    channel.exchange_declare(exchange=exchange_name, exchange_type='fanout', durable=True)
+    channel.exchange_declare(
+        exchange=exchange_name, exchange_type="fanout", durable=True
+    )
 
 
 def topic_exchange(channel, exchange_name):
-    channel.exchange_declare(exchange=exchange_name, exchange_type='topic', durable=True)
+    channel.exchange_declare(
+        exchange=exchange_name, exchange_type="topic", durable=True
+    )
 
 
 def deliver_to_exchange(channel, body, exchange_name, topic=None):
@@ -39,37 +46,39 @@ def deliver_to_exchange(channel, body, exchange_name, topic=None):
         fanout_exchange(channel=channel, exchange_name=exchange_name)
         channel.basic_publish(
             exchange=exchange_name,
-            routing_key='', 
-            body=body, 
+            routing_key="",
+            body=body,
             properties=pika.BasicProperties(
                 delivery_mode=pika.spec.PERSISTENT_DELIVERY_MODE
-            )
+            ),
         )
     else:
         topic_exchange(channel=channel, exchange_name=exchange_name)
         channel.basic_publish(
             exchange=exchange_name,
-            routing_key=topic, 
-            body=body, 
+            routing_key=topic,
+            body=body,
             properties=pika.BasicProperties(
                 delivery_mode=pika.spec.PERSISTENT_DELIVERY_MODE
-            )
+            ),
         )
 
+
 # -------------------------------------------------------------------------------------------------------------------------------------------------------------
 
+
 def write_to_queue(queue_name, msg):
     # write a single message to a queue
     connection, channel = pika_connect(host=host)
     setup_queue(channel=channel, queue_name=queue_name)
 
     channel.basic_publish(
-        exchange='', 
-        routing_key=queue_name, 
+        exchange="",
+        routing_key=queue_name,
         body=msg,
         properties=pika.BasicProperties(
             delivery_mode=pika.spec.PERSISTENT_DELIVERY_MODE
-        )
+        ),
     )
 
     connection.close()
@@ -125,12 +134,12 @@ def forward(from_queue, to_queue):
 
     def forward_callback(ch, method, properties, body):
         channel.basic_publish(
-            exchange='',
-            routing_key=to_queue, 
+            exchange="",
+            routing_key=to_queue,
             body=body,
             properties=pika.BasicProperties(
                 delivery_mode=pika.spec.PERSISTENT_DELIVERY_MODE
-            )
+            ),
         )
         ch.basic_ack(delivery_tag=method.delivery_tag)
 
@@ -150,7 +159,9 @@ def publish(queue_name, exchange_name):
     def publish_callback(ch, method, properties, body):
         message = json.loads(body.decode())
         topic = message["topic"]
-        deliver_to_exchange(channel=ch, body=body, exchange_name=exchange_name, topic=topic)
+        deliver_to_exchange(
+            channel=ch, body=body, exchange_name=exchange_name, topic=topic
+        )
         ch.basic_ack(delivery_tag=method.delivery_tag)
 
     try:
@@ -161,7 +172,7 @@ def publish(queue_name, exchange_name):
 
 
 def subscribe(queue_name, exchange_name, topic=None):
-    # setup bindings between queue and exchange, 
+    # setup bindings between queue and exchange,
     # exchange_type is either 'fanout' or 'topic' based on if the topic arg is passed
     connection, channel = pika_connect(host=host)
     setup_queue(channel=channel, queue_name=queue_name)
@@ -183,4 +194,4 @@ def listen(queue_name, callback):
     setup_queue(channel=channel, queue_name=queue_name)
 
     channel.basic_consume(queue=queue_name, on_message_callback=callback)
-    channel.start_consuming()
\ No newline at end of file
+    channel.start_consuming()
diff --git a/soar_bus.py b/soar_bus.py
index f236322..741f6af 100644
--- a/soar_bus.py
+++ b/soar_bus.py
@@ -11,8 +11,8 @@
 
 import concurrent.futures
 
-from endpoints.clients import ClientsFile
 from rmq import broadcast, forward, publish, subscribe
+from models.client_model import ClientModel
 
 THREADS = []
 EXCHANGES = {
@@ -23,7 +23,7 @@ EXCHANGES = {
 
 def main():
     print("Starting SOAR bus...")
-    clients_file = ClientsFile()
+    clients_file = ClientModel()
     clients = clients_file.get()
 
     with concurrent.futures.ProcessPoolExecutor() as executor:
@@ -31,7 +31,7 @@ def main():
         thread = executor.submit(publish, "soar-publish", EXCHANGES.get("publish"))
         THREADS.append(thread)
 
-        for (id, client) in clients.items():
+        for id, client in clients.items():
             # forward
             thread = executor.submit(forward, f"{id}-outbox", "soar-publish")
             THREADS.append(thread)
@@ -42,16 +42,14 @@ def main():
             THREADS.append(thread)
             # subscribe
             thread = executor.submit(
-                subscribe, 
+                subscribe,
                 f"{id}-inbox",
                 EXCHANGES.get("publish"),
-                client["subscription"] # topic
+                client["subscription"],  # topic
             )
             THREADS.append(thread)
             thread = executor.submit(
-                subscribe, 
-                f"{id}-inbox",
-                EXCHANGES.get("broadcast")
+                subscribe, f"{id}-inbox", EXCHANGES.get("broadcast")
             )
             THREADS.append(thread)
             # push
-- 
GitLab