From 9b62e77d112fe68836ec029cf518fec173f1a533 Mon Sep 17 00:00:00 2001 From: One <43485962+c-git@users.noreply.github.com> Date: Sat, 14 Dec 2024 09:41:04 -0500 Subject: [PATCH] feat: move over code needed for demo server --- .gitignore | 5 +- ...afb6bafb1e6fb8d6d510d066128bfeb6bca65.json | 24 ++ ...2fce87e9749dd8cfc39557f60659821194e7c.json | 12 + ...7670a75d29f2df571b888c0bc12099e87e23a.json | 12 + ...2962724467cdde4a1f807fd9fe827abb8fb62.json | 12 + ...183c229f4017f738db79504670b3e1ee27aad.json | 12 + ...de2081ae59d9619557a6f487059686f8860ae.json | 12 + .vscode/settings.json | 5 + Cargo.lock | 273 +++++++++++++ Cargo.toml | 11 +- README.md | 4 +- crates/chat-app-server/.env | 1 + crates/chat-app-server/Cargo.toml | 32 ++ .../chat-app-server/configuration/base.toml | 20 + .../chat-app-server/configuration/local.toml | 5 + .../configuration/production.toml | 4 + .../migrations/20240101000000_legacy.sql | 138 +++++++ .../20240424012241_add_user_password_hash.sql | 4 + .../20240713130941_seed_for_first_login.sql | 40 ++ .../migrations/20240908184200_chat.sql | 27 ++ crates/chat-app-server/scripts/init_db.sh | 74 ++++ crates/chat-app-server/scripts/init_redis.sh | 20 + crates/chat-app-server/src/lib.rs | 77 ++++ crates/chat-app-server/src/main.rs | 65 +++- crates/chat-app-server/src/permissions.rs | 7 + crates/chat-app-server/src/startup.rs | 87 +++++ crates/chat-app-server/src/websocket.rs | 7 + crates/chat-app-server/tests/api/branch.rs | 39 ++ .../tests/api/change_password.rs | 134 +++++++ crates/chat-app-server/tests/api/chat.rs | 152 ++++++++ .../chat-app-server/tests/api/health_check.rs | 14 + crates/chat-app-server/tests/api/helpers.rs | 363 ++++++++++++++++++ .../chat-app-server/tests/api/host_branch.rs | 300 +++++++++++++++ crates/chat-app-server/tests/api/login.rs | 250 ++++++++++++ crates/chat-app-server/tests/api/main.rs | 12 + .../chat-app-server/tests/api/permissions.rs | 43 +++ crates/chat-app-server/tests/api/roles.rs | 81 ++++ .../api__users__list_users_and_roles.snap | 48 +++ .../tests/api/snapshots/api__users__user.snap | 14 + crates/chat-app-server/tests/api/users.rs | 266 +++++++++++++ .../chat-app-server/tests/api/web_sockets.rs | 69 ++++ crates/chat-app-shared/Cargo.toml | 8 + crates/chat-app-shared/src/lib.rs | 14 + crates/ws-auth/src/manager.rs | 2 +- crates/wykies-client-core/Cargo.toml | 32 ++ crates/wykies-client-core/src/client.rs | 345 +++++++++++++++++ crates/wykies-client-core/src/client/api.rs | 45 +++ .../src/client/api/admin.rs | 4 + .../src/client/api/admin/branch.rs | 17 + .../src/client/api/admin/host_branch.rs | 42 ++ .../src/client/api/admin/role.rs | 41 ++ .../src/client/api/admin/user.rs | 80 ++++ .../src/client/websocket.rs | 145 +++++++ crates/wykies-client-core/src/lib.rs | 20 + crates/wykies-client-core/tests/web_login.rs | 63 +++ crates/wykies-server/.env | 2 +- 56 files changed, 3627 insertions(+), 8 deletions(-) create mode 100644 .sqlx/query-4f80ede49b26a35b79eadc34216afb6bafb1e6fb8d6d510d066128bfeb6bca65.json create mode 100644 .sqlx/query-604cdcd44b2e9a88580993eaf362fce87e9749dd8cfc39557f60659821194e7c.json create mode 100644 .sqlx/query-6ecd79959cad547e6b3f51aec967670a75d29f2df571b888c0bc12099e87e23a.json create mode 100644 .sqlx/query-7ac5ef540c2800961b4f29c79b02962724467cdde4a1f807fd9fe827abb8fb62.json create mode 100644 .sqlx/query-a4a83056fd7220c402e68327dbf183c229f4017f738db79504670b3e1ee27aad.json create mode 100644 .sqlx/query-a736a9bb6fa77b60468de4034cede2081ae59d9619557a6f487059686f8860ae.json create mode 100644 .vscode/settings.json create mode 120000 crates/chat-app-server/.env create mode 100644 crates/chat-app-server/configuration/base.toml create mode 100644 crates/chat-app-server/configuration/local.toml create mode 100644 crates/chat-app-server/configuration/production.toml create mode 100644 crates/chat-app-server/migrations/20240101000000_legacy.sql create mode 100644 crates/chat-app-server/migrations/20240424012241_add_user_password_hash.sql create mode 100644 crates/chat-app-server/migrations/20240713130941_seed_for_first_login.sql create mode 100644 crates/chat-app-server/migrations/20240908184200_chat.sql create mode 100755 crates/chat-app-server/scripts/init_db.sh create mode 100755 crates/chat-app-server/scripts/init_redis.sh create mode 100644 crates/chat-app-server/src/permissions.rs create mode 100644 crates/chat-app-server/src/startup.rs create mode 100644 crates/chat-app-server/src/websocket.rs create mode 100644 crates/chat-app-server/tests/api/branch.rs create mode 100644 crates/chat-app-server/tests/api/change_password.rs create mode 100644 crates/chat-app-server/tests/api/chat.rs create mode 100644 crates/chat-app-server/tests/api/health_check.rs create mode 100644 crates/chat-app-server/tests/api/helpers.rs create mode 100644 crates/chat-app-server/tests/api/host_branch.rs create mode 100644 crates/chat-app-server/tests/api/login.rs create mode 100644 crates/chat-app-server/tests/api/main.rs create mode 100644 crates/chat-app-server/tests/api/permissions.rs create mode 100644 crates/chat-app-server/tests/api/roles.rs create mode 100644 crates/chat-app-server/tests/api/snapshots/api__users__list_users_and_roles.snap create mode 100644 crates/chat-app-server/tests/api/snapshots/api__users__user.snap create mode 100644 crates/chat-app-server/tests/api/users.rs create mode 100644 crates/chat-app-server/tests/api/web_sockets.rs create mode 100644 crates/chat-app-shared/Cargo.toml create mode 100644 crates/chat-app-shared/src/lib.rs create mode 100644 crates/wykies-client-core/Cargo.toml create mode 100644 crates/wykies-client-core/src/client.rs create mode 100644 crates/wykies-client-core/src/client/api.rs create mode 100644 crates/wykies-client-core/src/client/api/admin.rs create mode 100644 crates/wykies-client-core/src/client/api/admin/branch.rs create mode 100644 crates/wykies-client-core/src/client/api/admin/host_branch.rs create mode 100644 crates/wykies-client-core/src/client/api/admin/role.rs create mode 100644 crates/wykies-client-core/src/client/api/admin/user.rs create mode 100644 crates/wykies-client-core/src/client/websocket.rs create mode 100644 crates/wykies-client-core/src/lib.rs create mode 100644 crates/wykies-client-core/tests/web_login.rs diff --git a/.gitignore b/.gitignore index c41cc9e..90283ae 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,4 @@ -/target \ No newline at end of file +/target + +# Built version of front end deployment +crates/chat-app-server/app/ diff --git a/.sqlx/query-4f80ede49b26a35b79eadc34216afb6bafb1e6fb8d6d510d066128bfeb6bca65.json b/.sqlx/query-4f80ede49b26a35b79eadc34216afb6bafb1e6fb8d6d510d066128bfeb6bca65.json new file mode 100644 index 0000000..e7c481b --- /dev/null +++ b/.sqlx/query-4f80ede49b26a35b79eadc34216afb6bafb1e6fb8d6d510d066128bfeb6bca65.json @@ -0,0 +1,24 @@ +{ + "db_name": "MySQL", + "query": "SELECT `BranchID` FROM branch LIMIT 1;", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "BranchID", + "type_info": { + "type": "Long", + "flags": "NOT_NULL | PRIMARY_KEY | AUTO_INCREMENT", + "max_size": 11 + } + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + false + ] + }, + "hash": "4f80ede49b26a35b79eadc34216afb6bafb1e6fb8d6d510d066128bfeb6bca65" +} diff --git a/.sqlx/query-604cdcd44b2e9a88580993eaf362fce87e9749dd8cfc39557f60659821194e7c.json b/.sqlx/query-604cdcd44b2e9a88580993eaf362fce87e9749dd8cfc39557f60659821194e7c.json new file mode 100644 index 0000000..ad8b9e3 --- /dev/null +++ b/.sqlx/query-604cdcd44b2e9a88580993eaf362fce87e9749dd8cfc39557f60659821194e7c.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "INSERT INTO `roles` \n (`RoleID`, `Name`, `Description`, `Permissions`, `LockedEditing`) \n VALUES (NULL, 'Admin', 'Full Permissions', '11111111111111111111111111111111111', '0'); ", + "describe": { + "columns": [], + "parameters": { + "Right": 0 + }, + "nullable": [] + }, + "hash": "604cdcd44b2e9a88580993eaf362fce87e9749dd8cfc39557f60659821194e7c" +} diff --git a/.sqlx/query-6ecd79959cad547e6b3f51aec967670a75d29f2df571b888c0bc12099e87e23a.json b/.sqlx/query-6ecd79959cad547e6b3f51aec967670a75d29f2df571b888c0bc12099e87e23a.json new file mode 100644 index 0000000..2e672b8 --- /dev/null +++ b/.sqlx/query-6ecd79959cad547e6b3f51aec967670a75d29f2df571b888c0bc12099e87e23a.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "INSERT INTO `user`\n (`UserName`, `Password`, `password_hash`, `salt`, `DisplayName`, `PassChangeDate`, `Enabled`) \n VALUES (?, '', ?, '', 'Test User', CURRENT_DATE(), 1);", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "6ecd79959cad547e6b3f51aec967670a75d29f2df571b888c0bc12099e87e23a" +} diff --git a/.sqlx/query-7ac5ef540c2800961b4f29c79b02962724467cdde4a1f807fd9fe827abb8fb62.json b/.sqlx/query-7ac5ef540c2800961b4f29c79b02962724467cdde4a1f807fd9fe827abb8fb62.json new file mode 100644 index 0000000..eecd47e --- /dev/null +++ b/.sqlx/query-7ac5ef540c2800961b4f29c79b02962724467cdde4a1f807fd9fe827abb8fb62.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "INSERT INTO `hostbranch` \n (`hostname`, `AssignedBranch`)\n VALUES (?, ?);", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "7ac5ef540c2800961b4f29c79b02962724467cdde4a1f807fd9fe827abb8fb62" +} diff --git a/.sqlx/query-a4a83056fd7220c402e68327dbf183c229f4017f738db79504670b3e1ee27aad.json b/.sqlx/query-a4a83056fd7220c402e68327dbf183c229f4017f738db79504670b3e1ee27aad.json new file mode 100644 index 0000000..f3153e3 --- /dev/null +++ b/.sqlx/query-a4a83056fd7220c402e68327dbf183c229f4017f738db79504670b3e1ee27aad.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "UPDATE `user` SET `Enabled` = '0' WHERE `user`.`UserName` = ?;", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "a4a83056fd7220c402e68327dbf183c229f4017f738db79504670b3e1ee27aad" +} diff --git a/.sqlx/query-a736a9bb6fa77b60468de4034cede2081ae59d9619557a6f487059686f8860ae.json b/.sqlx/query-a736a9bb6fa77b60468de4034cede2081ae59d9619557a6f487059686f8860ae.json new file mode 100644 index 0000000..858e629 --- /dev/null +++ b/.sqlx/query-a736a9bb6fa77b60468de4034cede2081ae59d9619557a6f487059686f8860ae.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "INSERT INTO `user`\n (`UserName`, `Password`, `password_hash`, `salt`, `DisplayName`, `AssignedRole`, `PassChangeDate`, `Enabled`) \n VALUES (?, '', ?, '', 'Admin User', ?, CURRENT_DATE(), 1);", + "describe": { + "columns": [], + "parameters": { + "Right": 3 + }, + "nullable": [] + }, + "hash": "a736a9bb6fa77b60468de4034cede2081ae59d9619557a6f487059686f8860ae" +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..598eaa7 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "cSpell.words": [ + "HOSTBRANCH" + ] +} diff --git a/Cargo.lock b/Cargo.lock index aa9bce9..e2b3673 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -403,6 +403,28 @@ dependencies = [ "password-hash", ] +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "async-trait" version = "0.1.83" @@ -566,6 +588,37 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chat-app-server" +version = "0.1.0" +dependencies = [ + "actix-web", + "anyhow", + "argon2", + "chrono", + "ewebsock", + "insta", + "plugin-chat", + "rand", + "secrecy", + "serde", + "serde_json", + "sqlx", + "tokio", + "tracing", + "tracked-cancellations", + "uuid", + "ws-auth", + "wykies-client-core", + "wykies-server", + "wykies-shared", + "wykies-time", +] + +[[package]] +name = "chat-app-shared" +version = "0.1.0" + [[package]] name = "chrono" version = "0.4.39" @@ -624,6 +677,18 @@ dependencies = [ "toml", ] +[[package]] +name = "console" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e1f83fc076bd6dd27517eacdf25fef6c4dfe5f1d7448bafaaf3a26f13b5e4eb" +dependencies = [ + "encode_unicode", + "lazy_static", + "libc", + "windows-sys 0.52.0", +] + [[package]] name = "const-oid" version = "0.9.6" @@ -901,6 +966,12 @@ version = "0.29.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b1fe0049ce51d0fb414d029e668dd72eb30bc2b739bf34296ed97bd33df544f3" +[[package]] +name = "encode_unicode" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a357d28ed41a50f9c765dbfe56cbc04a64e53e5fc58ba79fbc34c10ef3df831f" + [[package]] name = "encoding_rs" version = "0.8.35" @@ -968,9 +1039,14 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "679247b4a005c82218a5f13b713239b0b6d484ec25347a719f5b7066152a748a" dependencies = [ + "async-stream", "document-features", + "futures", + "futures-util", "js-sys", "log", + "tokio", + "tokio-tungstenite", "tungstenite", "wasm-bindgen", "wasm-bindgen-futures", @@ -1584,6 +1660,21 @@ dependencies = [ "generic-array", ] +[[package]] +name = "insta" +version = "1.41.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e9ffc4d4892617c50a928c52b2961cb5174b6fc6ebf252b2fac9d21955c48b8" +dependencies = [ + "console", + "lazy_static", + "linked-hash-map", + "pest", + "pest_derive", + "serde", + "similar", +] + [[package]] name = "ipnet" version = "2.10.1" @@ -1653,6 +1744,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "linked-hash-map" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" + [[package]] name = "linux-raw-sys" version = "0.4.14" @@ -1745,6 +1842,16 @@ dependencies = [ "unicase", ] +[[package]] +name = "minicov" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f27fe9f1cc3c22e1687f9446c2083c4c5fc7f0bcf1c7a86bdbded14985895b4b" +dependencies = [ + "cc", + "walkdir", +] + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -2031,6 +2138,51 @@ version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" +[[package]] +name = "pest" +version = "2.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b7cafe60d6cf8e62e1b9b2ea516a089c008945bb5a275416789e7db0bc199dc" +dependencies = [ + "memchr", + "thiserror 2.0.6", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "816518421cfc6887a0d62bf441b6ffb4536fcc926395a69e1a85852d4363f57e" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d1396fd3a870fc7838768d171b4616d5c91f6cc25e377b673d714567d99377b" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pest_meta" +version = "2.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1e58089ea25d717bfd31fb534e4f3afcc2cc569c70de3e239778991ea3b7dea" +dependencies = [ + "once_cell", + "pest", + "sha2", +] + [[package]] name = "pin-project" version = "1.1.7" @@ -2398,6 +2550,20 @@ dependencies = [ "windows-registry", ] +[[package]] +name = "reqwest-cross" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "504978d1be19bfa25a1ea221e9002ece42ce9fa37d694c6909bc3f16b0a262bb" +dependencies = [ + "futures", + "js-sys", + "reqwest", + "tokio", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "ring" version = "0.17.8" @@ -2570,6 +2736,15 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "schannel" version = "0.1.27" @@ -2579,6 +2754,12 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + [[package]] name = "scopeguard" version = "1.2.0" @@ -2744,6 +2925,12 @@ dependencies = [ "rand_core", ] +[[package]] +name = "similar" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1de1d4f81173b03af4c0cbed3c898f6bff5b870e4a7f5d6f4057d62a7a4b686e" + [[package]] name = "slab" version = "0.4.9" @@ -2842,6 +3029,8 @@ dependencies = [ "once_cell", "paste", "percent-encoding", + "rustls", + "rustls-pemfile", "serde", "serde_json", "sha2", @@ -2852,6 +3041,7 @@ dependencies = [ "tokio-stream", "tracing", "url", + "webpki-roots", ] [[package]] @@ -3271,6 +3461,18 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite", +] + [[package]] name = "tokio-util" version = "0.7.13" @@ -3476,9 +3678,12 @@ dependencies = [ "httparse", "log", "rand", + "rustls", + "rustls-pki-types", "sha1", "thiserror 1.0.69", "utf-8", + "webpki-roots", ] [[package]] @@ -3487,6 +3692,12 @@ version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + [[package]] name = "unicase" version = "2.8.0" @@ -3584,6 +3795,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8c5f0a0af699448548ad1a2fbf920fb4bee257eae39953ba95cb84891a0446a" dependencies = [ "getrandom", + "serde", ] [[package]] @@ -3610,6 +3822,16 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "want" version = "0.3.1" @@ -3698,6 +3920,31 @@ version = "0.2.99" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "943aab3fdaaa029a6e0271b35ea10b72b943135afe9bffca82384098ad0e06a6" +[[package]] +name = "wasm-bindgen-test" +version = "0.3.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d44563646eb934577f2772656c7ad5e9c90fac78aa8013d776fcdaf24625d" +dependencies = [ + "js-sys", + "minicov", + "scoped-tls", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-bindgen-test-macro", +] + +[[package]] +name = "wasm-bindgen-test-macro" +version = "0.3.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54171416ce73aa0b9c377b51cc3cb542becee1cd678204812e8392e5b0e4a031" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "web-sys" version = "0.3.76" @@ -3753,6 +4000,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +[[package]] +name = "winapi-util" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" +dependencies = [ + "windows-sys 0.59.0", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" @@ -3984,6 +4240,23 @@ dependencies = [ "wykies-time", ] +[[package]] +name = "wykies-client-core" +version = "0.1.0" +dependencies = [ + "anyhow", + "ewebsock", + "futures", + "reqwest", + "reqwest-cross", + "secrecy", + "serde", + "serde_json", + "tracing", + "wasm-bindgen-test", + "wykies-shared", +] + [[package]] name = "wykies-server" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index eda16fa..89148cd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,9 +1,12 @@ [workspace] resolver = "2" -members = [ "crates/chat-app-server", +members = [ + "crates/chat-app-server", + "crates/chat-app-shared", "crates/plugin-chat", "crates/tracked-cancellations", "crates/ws-auth", + "crates/wykies-client-core", "crates/wykies-server", "crates/wykies-shared", "crates/wykies-time", @@ -22,7 +25,10 @@ chrono = { version = "0.4.34", default-features = false, features = ["clock", "s config = { version = "0.14", default-features = false, features = ["toml"] } egui = { version = "0.29.1", default-features = false } ewebsock = "0.8.0" +futures = "0.3.28" futures-util = "0.3.30" +insta = "1.40.0" +plugin-chat = { version = "*", path = "crates/plugin-chat" } rand = "0.8" reqwest = { version = "*", default-features = false, features = ["json", "rustls-tls", "cookies"] } # Only to set features, version set by reqwest-cross reqwest-cross = "0.4.1" @@ -45,8 +51,11 @@ tracing-bunyan-formatter = "0.3.10" tracing-log = "0.2.0" tracing-subscriber = { version = "0.3", features = ["fmt", "json"] } tracked-cancellations = { version = "*", path = "crates/tracked-cancellations" } +uuid = "1" +wasm-bindgen-test = "0.3.42" web-time = "1.1.0" ws-auth = { version = "*", path = "crates/ws-auth" } wykies-server = { version = "*", path = "crates/wykies-server" } wykies-shared = { version = "*", path = "crates/wykies-shared" } +wykies-client-core = { version = "*", path = "crates/wykies-client-core" } wykies-time = { version = "*", path = "crates/wykies-time" } diff --git a/README.md b/README.md index 5e253b2..b2174f1 100644 --- a/README.md +++ b/README.md @@ -8,8 +8,8 @@ They vary in levels of maturity and speed of development is you are interested i Brief points to be aware of when looking into any creates in this repo: - Feature flags on crates are documented with comments in their respective `Cargo.toml` files. -- Servers built using this framework need to enable the desired encryption options for the sqlx crate. -- The plugins are treated as first party code. There is not security separation. If that is needed do NOT give them access to the same database you use for the rest of your application. It was more designed for them to be able to be reused not to be sandboxed. +- Servers built using this framework need to enable the desired encryption options for the sqlx crate (See [sqlx readme](https://github.com/launchbadge/sqlx?tab=readme-ov-file#install) and [Demo chat server](crates/chat-app-server/Cargo.toml) for an example). +- The plugins are treated as first party code. There is not security separation. If that is needed do NOT give them access to the same database you use for the rest of your application. It was more designed for them to be able to be reused not to be sandboxed. Also pay attention to what routes they are adding to your application. diff --git a/crates/chat-app-server/.env b/crates/chat-app-server/.env new file mode 120000 index 0000000..2d09f53 --- /dev/null +++ b/crates/chat-app-server/.env @@ -0,0 +1 @@ +../wykies-server/.env \ No newline at end of file diff --git a/crates/chat-app-server/Cargo.toml b/crates/chat-app-server/Cargo.toml index adac310..b3251c8 100644 --- a/crates/chat-app-server/Cargo.toml +++ b/crates/chat-app-server/Cargo.toml @@ -2,5 +2,37 @@ name = "chat-app-server" version = "0.1.0" edition = "2021" +publish = false +description = "Server for Demo Chat App" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] +actix-web.workspace = true +anyhow.workspace = true +plugin-chat = { workspace = true, features = ["server_only"] } +serde.workspace = true +sqlx = { workspace = true, features = ["tls-rustls"] } +tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } +tracing.workspace = true +tracked-cancellations.workspace = true +ws-auth.workspace = true +wykies-server.workspace = true +wykies-shared = { workspace = true, features = ["server_only"] } + +[dev-dependencies] +argon2 = { workspace = true, features = ["std"] } +chrono.workspace = true +ewebsock.workspace = true +insta = { workspace = true, features = ["serde", "redactions", "json"] } +rand = { workspace = true, features = ["std_rng"] } +secrecy.workspace = true +serde_json.workspace = true +sqlx = { workspace = true, features = ["runtime-tokio", "macros", "mysql", "chrono", "migrate"] } +uuid = { workspace = true, features = ["v4", "serde"] } +wykies-client-core = { workspace = true, features = ["expose_test"] } +wykies-time = { workspace = true, features = ["mysql"] } + +[features] +default = [] +disable-cors = ["wykies-server/disable-cors"] diff --git a/crates/chat-app-server/configuration/base.toml b/crates/chat-app-server/configuration/base.toml new file mode 100644 index 0000000..8b984a1 --- /dev/null +++ b/crates/chat-app-server/configuration/base.toml @@ -0,0 +1,20 @@ +redis_uri = "redis://127.0.0.1:6379" +[application] +port = "8789" # W = 87, Y = 89 +host = "0.0.0.0" +hmac_secret = "super-long-and-secret-random-key-needed-to-verify-message-integrity" +[database] +host = "127.0.0.1" +port = 3306 +username = "db_user" +password = "password" +database_name = "chat_demo" +require_ssl = true +[user_auth] +login_attempt_limit = 5 +[websockets] +token_lifetime_secs = 20 +heartbeat_times_missed_allowance = 2 +heartbeat_additional_buffer_time_secs = 2 +[custom.chat] +heartbeat_interval_secs = 30 diff --git a/crates/chat-app-server/configuration/local.toml b/crates/chat-app-server/configuration/local.toml new file mode 100644 index 0000000..458b63a --- /dev/null +++ b/crates/chat-app-server/configuration/local.toml @@ -0,0 +1,5 @@ +[application] +host = "127.0.0.1" +base_url = "http://127.0.0.1" +[database] +require_ssl = true diff --git a/crates/chat-app-server/configuration/production.toml b/crates/chat-app-server/configuration/production.toml new file mode 100644 index 0000000..fc7f231 --- /dev/null +++ b/crates/chat-app-server/configuration/production.toml @@ -0,0 +1,4 @@ +[application] +host = "0.0.0.0" +[database] +require_ssl = true diff --git a/crates/chat-app-server/migrations/20240101000000_legacy.sql b/crates/chat-app-server/migrations/20240101000000_legacy.sql new file mode 100644 index 0000000..7b14b97 --- /dev/null +++ b/crates/chat-app-server/migrations/20240101000000_legacy.sql @@ -0,0 +1,138 @@ +SET SQL_MODE = "NO_AUTO_VALUE_ON_ZERO"; +START TRANSACTION; +SET time_zone = "+00:00"; +/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */ +; +/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */ +; +/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */ +; +/*!40101 SET NAMES utf8mb4 */ +; +-- +-- Table structure for table `branch` +-- + +CREATE TABLE `branch` ( + `BranchID` int(11) NOT NULL, + `BranchName` varchar(30) NOT NULL, + `BranchAddress` varchar(200) NOT NULL +) ENGINE = InnoDB DEFAULT CHARSET = latin1; +-- -------------------------------------------------------- +-- +-- Table structure for table `hostbranch` +-- + +CREATE TABLE `hostbranch` ( + `hostname` varchar(50) NOT NULL, + `AssignedBranch` int(11) NOT NULL +) ENGINE = InnoDB DEFAULT CHARSET = latin1; +-- -------------------------------------------------------- +-- +-- Table structure for table `roles` +-- + +CREATE TABLE `roles` ( + `RoleID` int(11) NOT NULL, + `Name` varchar(16) NOT NULL, + `Description` varchar(50) NOT NULL DEFAULT '', + `Permissions` varchar(256) NOT NULL, + `LockedEditing` tinyint(1) NOT NULL DEFAULT '0' +) ENGINE = InnoDB DEFAULT CHARSET = latin1; +-- -------------------------------------------------------- +-- +-- Table structure for table `user` +-- + +CREATE TABLE `user` ( + `UserName` varchar(16) NOT NULL, + `Password` binary(64) NOT NULL, + `salt` binary(64) NOT NULL, + `LockedEditing` tinyint(1) NOT NULL DEFAULT '0', + `ForcePassChange` tinyint(1) NOT NULL DEFAULT '1', + `DisplayName` varchar(30) NOT NULL, + `AssignedRole` int(11) DEFAULT NULL, + `PassChangeDate` date NOT NULL, + `Enabled` tinyint(1) NOT NULL DEFAULT '1', + `LockedOut` tinyint(1) NOT NULL DEFAULT '0', + `FailedAttempts` tinyint(4) NOT NULL DEFAULT '0' COMMENT 'Number of Failed Attempts Since Last Logon', + `AsycudaName` varchar(50) DEFAULT NULL +) ENGINE = InnoDB DEFAULT CHARSET = latin1; +-- -------------------------------------------------------- +-- +-- Table structure for table `version_info` +-- + +CREATE TABLE `version_info` ( + `dbversion` smallint(6) NOT NULL, + `MinExeVersion` varchar(15) NOT NULL +) ENGINE = InnoDB DEFAULT CHARSET = latin1 COMMENT = 'Stores the DB version for compatibility reason'; +-- +-- Indexes for dumped tables +-- + +-- +-- Indexes for table `branch` +-- +ALTER TABLE `branch` +ADD PRIMARY KEY (`BranchID`), + ADD UNIQUE KEY `BranchName` (`BranchName`); +-- +-- Indexes for table `hostbranch` +-- +ALTER TABLE `hostbranch` +ADD PRIMARY KEY (`hostname`), + ADD KEY `AssignedBranch` (`AssignedBranch`); +-- +-- Indexes for table `roles` +-- +ALTER TABLE `roles` +ADD PRIMARY KEY (`RoleID`), + ADD UNIQUE KEY `Name` (`Name`); +-- +-- Indexes for table `user` +-- +ALTER TABLE `user` +ADD PRIMARY KEY (`UserName`), + ADD UNIQUE KEY `DisplayName` (`DisplayName`), + ADD KEY `AssignedRole` (`AssignedRole`); +-- +-- Indexes for table `version_info` +-- +ALTER TABLE `version_info` +ADD PRIMARY KEY (`dbversion`); +-- +-- AUTO_INCREMENT for dumped tables +-- + +-- +-- AUTO_INCREMENT for table `branch` +-- +ALTER TABLE `branch` +MODIFY `BranchID` int(11) NOT NULL AUTO_INCREMENT; +-- +-- AUTO_INCREMENT for table `roles` +-- +ALTER TABLE `roles` +MODIFY `RoleID` int(11) NOT NULL AUTO_INCREMENT; +-- +-- Constraints for dumped tables +-- + +-- +-- Constraints for table `hostbranch` +-- +ALTER TABLE `hostbranch` +ADD CONSTRAINT `hostbranch_ibfk_1` FOREIGN KEY (`AssignedBranch`) REFERENCES `branch` (`BranchID`); +-- +-- Constraints for table `user` +-- +ALTER TABLE `user` +ADD CONSTRAINT `user_ibfk_1` FOREIGN KEY (`AssignedRole`) REFERENCES `roles` (`RoleID`); +COMMIT; +/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */ +; +/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */ +; +/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */ +; \ No newline at end of file diff --git a/crates/chat-app-server/migrations/20240424012241_add_user_password_hash.sql b/crates/chat-app-server/migrations/20240424012241_add_user_password_hash.sql new file mode 100644 index 0000000..1d94bd4 --- /dev/null +++ b/crates/chat-app-server/migrations/20240424012241_add_user_password_hash.sql @@ -0,0 +1,4 @@ +-- Add migration script here +ALTER TABLE `user` +ADD `password_hash` TEXT NOT NULL +AFTER `Password`; \ No newline at end of file diff --git a/crates/chat-app-server/migrations/20240713130941_seed_for_first_login.sql b/crates/chat-app-server/migrations/20240713130941_seed_for_first_login.sql new file mode 100644 index 0000000..6cd264d --- /dev/null +++ b/crates/chat-app-server/migrations/20240713130941_seed_for_first_login.sql @@ -0,0 +1,40 @@ +-- f +-- Create Seed Role +INSERT INTO `roles` ( + `RoleID`, + `Name`, + `Description`, + `Permissions`, + `LockedEditing` + ) +VALUES ( + NULL, + 'SeedAdmin', + 'Full Permissions', + '11111111111111111111111111111111111', + '0' + ); +-- Create Seed User +INSERT INTO `user` ( + `UserName`, + `Password`, + `password_hash`, + `salt`, + `DisplayName`, + `AssignedRole`, + `PassChangeDate`, + `Enabled` + ) +VALUES ( + 'seed_admin', + '', + '$argon2id$v=19$m=15000,t=2,p=1$MKnXfAG4x97WMzfWuOjs1g$MWvmzgFNfj8lneYHgghXuzXCpX+fs1NbVcWr2ieev8M', + '', + 'Seed Admin User', + LAST_INSERT_ID(), + CURRENT_DATE(), + 1 + ); +-- Create Seed Branch +INSERT INTO `branch` (`BranchID`, `BranchName`, `BranchAddress`) +VALUES (NULL, 'Seed Branch', ''); \ No newline at end of file diff --git a/crates/chat-app-server/migrations/20240908184200_chat.sql b/crates/chat-app-server/migrations/20240908184200_chat.sql new file mode 100644 index 0000000..2087e93 --- /dev/null +++ b/crates/chat-app-server/migrations/20240908184200_chat.sql @@ -0,0 +1,27 @@ +-- -------------------------------------------------------- +-- +-- Table structure for table `chat` +-- + +CREATE TABLE `chat` ( + `ChatID` int(11) NOT NULL, + `Author` varchar(16) NOT NULL, + `Timestamp` INT(11) UNSIGNED NOT NULL, + `Content` BINARY(255) NOT NULL +) ENGINE = InnoDB DEFAULT CHARSET = latin1; +-- +-- Indexes for table `chat` +-- +ALTER TABLE `chat` +ADD PRIMARY KEY (`ChatID`), + ADD KEY `Timestamp` (`Timestamp`); +-- +-- AUTO_INCREMENT for table `chat` +-- +ALTER TABLE `chat` +MODIFY `ChatID` int(11) NOT NULL AUTO_INCREMENT; +-- +-- Constraints for table `chat` +-- +ALTER TABLE `chat` +ADD CONSTRAINT `chat_ibfk_1` FOREIGN KEY (`Author`) REFERENCES `user` (`UserName`); \ No newline at end of file diff --git a/crates/chat-app-server/scripts/init_db.sh b/crates/chat-app-server/scripts/init_db.sh new file mode 100755 index 0000000..d600dc6 --- /dev/null +++ b/crates/chat-app-server/scripts/init_db.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +# set -x # Only turn on if debugging otherwise it just seems annoying +set -eo pipefail + +if ! [ -x "$(command -v mysql)" ]; then + echo >&2 "Error: mysql client is not installed." + echo >&2 "Use:" + echo >&2 " sudo apt install mysql-client" + echo >&2 "to install it." + exit 1 +fi + +if ! [ -x "$(command -v sqlx)" ]; then + echo >&2 "Error: sqlx is not installed." + echo >&2 "Use:" + echo >&2 " cargo install --version='~0.6' sqlx-cli --no-default-features --features rustls,mysql" + echo >&2 "to install it." + exit 1 +fi + +# Check if a custom user has been set, otherwise default to 'test_user' +DB_USER="${MYSQL_USER:=db_user}" +# Check if a custom password has been set, otherwise default to 'password' +DB_PASSWORD="${MYSQL_PASSWORD:=password}" +# Check if a custom database name has been set, otherwise default to 'chat_demo' +DB_NAME="${MYSQL_DB:=chat_demo}" +# Check if a custom port has been set, otherwise default to '3306' +DB_PORT="${MYSQL_PORT:=3306}" +# Check if a custom host has been set, otherwise default to 'localhost' +DB_HOST="${MYSQL_HOST:=localhost}" + +# Allow to skip Docker if a MySql database is already running +if [[ -z "${SKIP_DOCKER}" ]] +then + # if a mysql container is running, print instructions to kill it and exit + RUNNING_MYSQL_CONTAINER=$(docker ps --filter 'name=mysql' --format '{{.ID}}') + if [[ -n $RUNNING_MYSQL_CONTAINER ]]; then + echo >&2 "there is a mysql container already running, kill it with" + echo >&2 " docker kill ${RUNNING_MYSQL_CONTAINER}" + exit 1 + fi + # Launch mysql using Docker (Root needed for testing to create databases, database only used for testing) + docker run \ + -e MYSQL_USER=${DB_USER} \ + -e MYSQL_PASSWORD=${DB_PASSWORD} \ + -e MYSQL_DATABASE=${DB_NAME} \ + -e MYSQL_ROOT_PASSWORD=${DB_PASSWORD} \ + -e MYSQL_ROOT_HOST=% \ + -p "${DB_PORT}":3306 \ + -d \ + --name "mysql_$(date '+%s')" \ + mysql:latest + # TODO 4: Increase number of connections for testing +fi + +#Keep pinging MySql until it's ready to accept commands +max_retries=120 +until MYSQL_PWD="${DB_PASSWORD}" mysql --protocol=TCP -h "${DB_HOST}" -u "root" -P "${DB_PORT}" -D "mysql" -e '\q'; do + >&2 echo "MySql is still unavailable - sleeping ($max_retries attempts left)" + if [ $max_retries -lt 1 ]; then + >&2 echo "Exceeded attempts to connect to DB" + exit 1 + fi + sleep 1 + ((max_retries--)) +done + +>&2 echo "MySql is up and running on port ${DB_PORT} - running migrations now!" + +export DATABASE_URL=mysql://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME} +sqlx database create +sqlx migrate run + +>&2 echo "MySql has been migrated, ready to go!" diff --git a/crates/chat-app-server/scripts/init_redis.sh b/crates/chat-app-server/scripts/init_redis.sh new file mode 100755 index 0000000..44d97b4 --- /dev/null +++ b/crates/chat-app-server/scripts/init_redis.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -x +set -eo pipefail + +# if a redis container is running, print instructions to kill it and exit +RUNNING_CONTAINER=$(docker ps --filter 'name=redis' --format '{{.ID}}') +if [[ -n $RUNNING_CONTAINER ]]; then + echo >&2 "there is a redis container already running, kill it with" + echo >&2 " docker kill ${RUNNING_CONTAINER}" + exit 1 +fi + +# Launch Redis using Docker +docker run \ + -p "6379:6379" \ + -d \ + --name "redis_$(date '+%s')" \ + redis:7 + +>&2 echo "Redis is ready to go!" \ No newline at end of file diff --git a/crates/chat-app-server/src/lib.rs b/crates/chat-app-server/src/lib.rs index d56029e..22ea093 100644 --- a/crates/chat-app-server/src/lib.rs +++ b/crates/chat-app-server/src/lib.rs @@ -1 +1,78 @@ +//! # Notable design decisions (notes) +//! Using this area to document important design decisions made for ease of +//! reference and discoverability +//! - For variable length fields in the database we restrict in the code to +//! number of bytes not characters because our common case will be to use +//! ascii anyway and checking number of bytes is cheaper and will work all +//! version of MySQL +//! - Permissions restrictions on endpoints see +//! [`wykies_shared::uac::get_required_permissions`] +//! - Force password change is not enforced by the server yet +//! - Chained calls to endpoints is not preferred but may be suitable if the +//! chaining doesn't happen in the common case like in login and having to set +//! the branch. When this happens please ensure a constant in the same place +//! with the permissions is used so these cases can be tracked. +//! - WebSocket connections are not able to be authenticated using cookies so we +//! are using a two step process. A request is sent via an authenticated +//! connection for a token. The token and the requesting IP are saved then a +//! request to connect to the websocket is sent and the IP is validated and +//! the first message required to be sent by the client is the token that was +//! sent to them before. To simplify using this the path for the token and the +//! connection must have the same suffix so that one method in the client can +//! do both calls. +//! - Suggested sequence of steps to create an endpoint: +//! - Go to `server/src/routes.rs` and decide where it belongs, create a +//! stub in the appropriate module and add the use statement +//! - Go to `server/src/startup.rs` +//! - Add the use statement at the top +//! - Add the route in the server configuration +//! - Go to `shared/src/uac/permissions.rs` and add the permissions entry +//! (requires also creating the constant for the path) +//! - Add tests (Should fail as it's not implemented) +//! - Implement endpoint (Test should pass now) +//! - Add client interface + #![warn(unused_crate_dependencies)] + +use tracked_cancellations::CancellationTracker; +use wykies_shared::const_config::server::SERVER_SHUTDOWN_TIMEOUT; + +mod warning_suppress { + use sqlx as _; // Needed to enable TLS +} + +#[cfg(test)] // Included to prevent unused crate warning +mod warning_suppress_test { + use argon2 as _; + use chrono as _; + use ewebsock as _; + use insta as _; + use rand as _; + use secrecy as _; + use serde_json as _; + use sqlx as _; + use uuid as _; + use wykies_client_core as _; + use wykies_time as _; +} + +mod permissions; +pub mod startup; +mod websocket; + +// TODO 2: Move this into the shared server lib +pub async fn cancel_remaining_tasks(mut cancellation_tracker: CancellationTracker) { + cancellation_tracker.cancel(); + cancellation_tracker + .await_cancellations(SERVER_SHUTDOWN_TIMEOUT.into()) + .await; +} + +// TODO 3: Ensure we have a way to access the logs... Maybe we have to switch back to stdout but see what options the hosting provider supports +// TODO 3: Enable HTTPS https://actix.rs/docs/server/#tls--https https://github.com/actix/examples/tree/master/https-tls/rustls +// TODO 4: Some performance was left on the table by using `text` for the +// websockets instead of `binary` +// TODO 4: Purge history + +#[cfg(all(not(debug_assertions), feature = "disable-cors"))] +compile_error!("CORS is not allowed to be disabled for release builds"); diff --git a/crates/chat-app-server/src/main.rs b/crates/chat-app-server/src/main.rs index e7a11a9..0c80386 100644 --- a/crates/chat-app-server/src/main.rs +++ b/crates/chat-app-server/src/main.rs @@ -1,3 +1,64 @@ -fn main() { - println!("Hello, world!"); +use anyhow::Context; +use chat_app_server::{ + cancel_remaining_tasks, + startup::{start_servers, CustomConfiguration}, +}; +use tokio::task::JoinError; +use tracing::{error, info}; +use wykies_server::{ApiServerBuilder, ApiServerInit}; +use wykies_shared::telemetry; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + // Prep to start building server + let (file, path) = + telemetry::create_trace_file("chat-app-server").context("failed to create file for traces")?; + println!("Traces being written to: {path:?}"); + let ApiServerInit:: { + cancellation_token, + cancellation_tracker, + configuration, + } = ApiServerInit::new_with_tracing_init("chat_app_server", "info", file); + + let api_server_builder = ApiServerBuilder::new(&configuration) + .await + .expect("failed to initialize API Server"); + + let mut join_set = start_servers(api_server_builder, &configuration, cancellation_token).await; + let join_outcome = join_set.join_next().await.context("no tasks in join set")?; + report_exit(join_outcome); + + // Cancel any remaining tasks + cancel_remaining_tasks(cancellation_tracker).await; + + Ok(()) +} + +fn report_exit(join_set_outcome: Result<(&str, Result, JoinError>), JoinError>) { + match join_set_outcome { + Ok((task_name, spawn_join_outcome)) => match spawn_join_outcome { + Ok(Ok(())) => info!("{task_name} has exited from the join set with Ok(())"), + Ok(Err(e)) => { + error!( + error.cause_chain = ?e, + error.message = %e, + "{task_name} resulted in an error: {e}" + ); + } + Err(e) => { + error!( + error.cause_chain = ?e, + error.message = %e, + "{task_name} resulted in a join error so it must have panicked" + ); + } + }, + Err(e) => { + error!( // Not expected to happen as we have a very small anonymous async function that should not panic + error.cause_chain = ?e, + error.message = %e, + "anonymous async function panicked instead of returning the task name. NO TASK name available" + ); + } + } } diff --git a/crates/chat-app-server/src/permissions.rs b/crates/chat-app-server/src/permissions.rs new file mode 100644 index 0000000..78a785c --- /dev/null +++ b/crates/chat-app-server/src/permissions.rs @@ -0,0 +1,7 @@ +use wykies_shared::uac::{default_permissions, initialize_permissions}; + +/// Initializes the permissions may be run more than once without issue (will only have an effect the first time) +pub fn init_permissions() { + // Set permissions and ignore if they were already set + let _ = initialize_permissions(default_permissions()); +} diff --git a/crates/chat-app-server/src/startup.rs b/crates/chat-app-server/src/startup.rs new file mode 100644 index 0000000..6ba7b68 --- /dev/null +++ b/crates/chat-app-server/src/startup.rs @@ -0,0 +1,87 @@ +use crate::{permissions::init_permissions, websocket::WsIds}; +use actix_web::web::{self, ServiceConfig}; +use plugin_chat::server_only::{ + chat_ws_start_client_handler_loop, ChatPlugin, ChatPluginConfig, ChatSettings, +}; +use serde::de::DeserializeOwned; +use tokio::task::{JoinError, JoinSet}; +use tracing::info; +use tracked_cancellations::TrackedCancellationToken; +use ws_auth::ws_get_route_add_closures; +use wykies_server::{ + plugin::{ServerPlugin, ServerPluginArtifacts}, + ApiServerBuilder, Configuration, ServerTask as _, +}; + +#[derive(Clone, serde::Deserialize)] +pub struct CustomConfiguration { + pub chat: ChatSettings, +} + +pub async fn start_servers( + api_server_builder: ApiServerBuilder<'_, T>, + configuration: &Configuration, + cancellation_token: TrackedCancellationToken, +) -> JoinSet<(&'static str, Result, JoinError>)> +where + T: Clone + DeserializeOwned, +{ + init_permissions(); + + // Chat Server + let ServerPluginArtifacts { + task: chat_server, + handle: chat_server_handle, + } = ChatPlugin::setup( + &ChatPluginConfig { + ws_id: WsIds::CHAT, + settings: configuration.custom.chat.clone(), + }, + api_server_builder.db_pool.clone(), + cancellation_token.clone(), + &configuration.websockets, + ) + .expect("failed to start Chat Server"); + + // Setup Routes / Server Resources + let (chat_open_add, chat_protected_add) = + ws_get_route_add_closures("chat", WsIds::CHAT, chat_ws_start_client_handler_loop); + let open_resources = move |cfg: &mut ServiceConfig| { + cfg.service(web::scope("/ws").configure(chat_open_add.clone())) + .app_data(web::Data::from(chat_server_handle.clone())); + }; + let protected_resources = move |cfg: &mut ServiceConfig| { + cfg.service(web::scope("/ws_token").configure(chat_protected_add.clone())); + }; + + // Finalize Server + let api_server = api_server_builder + .finish(open_resources, protected_resources) + .await + .expect("failed to finalize API Server"); + + // Start up the tasks + let mut result = JoinSet::new(); + let cancellation_token1 = cancellation_token.clone(); + result.spawn(async move { + let name = api_server.name(); + ( + name, + tokio::spawn(api_server.run(cancellation_token1)).await, + ) + }); + result.spawn(async move { + let name = chat_server.name(); + ( + name, + tokio::spawn(chat_server.run(cancellation_token)).await, + ) + }); + + // Print a message to stdout that server is started + println!("-- Server Started --"); + info!("-- Server Started --"); + println!("{}", "-".repeat(80)); // Add separator + + result +} diff --git a/crates/chat-app-server/src/websocket.rs b/crates/chat-app-server/src/websocket.rs new file mode 100644 index 0000000..3228580 --- /dev/null +++ b/crates/chat-app-server/src/websocket.rs @@ -0,0 +1,7 @@ +use ws_auth::WsId; + +pub struct WsIds; + +impl WsIds { + pub const CHAT: WsId = WsId::new(1); +} diff --git a/crates/chat-app-server/tests/api/branch.rs b/crates/chat-app-server/tests/api/branch.rs new file mode 100644 index 0000000..cab4ab8 --- /dev/null +++ b/crates/chat-app-server/tests/api/branch.rs @@ -0,0 +1,39 @@ +use wykies_shared::branch::{Branch, BranchDraft}; + +use crate::helpers::{no_cb, spawn_app}; + +#[tokio::test] +async fn create_branch() { + // Arrange + let app_admin = spawn_app().await.create_admin_user().await; + let branch_draft = BranchDraft { + name: "test name".to_string().try_into().unwrap(), + address: "test address".to_string().try_into().unwrap(), + }; + + // Act - Login the admin + app_admin.login_assert().await; + + // Act - Create Branch + let branch_id = app_admin + .core_client + .create_branch(&branch_draft, no_cb) + .await + .expect("failed to get msg from rx") + .expect("failed to extract branch id from result"); + + // Assert - Verify branch was created + let branches = app_admin + .core_client + .get_branches(no_cb) + .await + .expect("failed to get msg from rx") + .expect("failed to extract branches from result"); + let actual = branches.into_iter().find(|x| x.id == branch_id).unwrap(); + let expected = Branch { + id: branch_id, + name: branch_draft.name, + address: branch_draft.address, + }; + assert_eq!(actual, expected); +} diff --git a/crates/chat-app-server/tests/api/change_password.rs b/crates/chat-app-server/tests/api/change_password.rs new file mode 100644 index 0000000..5afae26 --- /dev/null +++ b/crates/chat-app-server/tests/api/change_password.rs @@ -0,0 +1,134 @@ +use crate::helpers::{no_cb, spawn_app}; +use uuid::Uuid; +use wykies_shared::{ + errors::NotLoggedInError, req_args::api::ChangePasswordReqArgs, uac::ChangePasswordError, +}; + +#[tokio::test] +async fn you_must_be_logged_in_to_change_your_password() { + // Arrange + let app = spawn_app().await; + let new_password = Uuid::new_v4().to_string(); + let args = ChangePasswordReqArgs { + current_password: Uuid::new_v4().to_string().into(), + new_password: new_password.clone().to_string().into(), + new_password_check: new_password.to_string().into(), + }; + + // Act + let actual = app.core_client.change_password(&args, no_cb).await.unwrap(); + + // Assert + assert_eq!( + actual.unwrap_err().to_string(), + NotLoggedInError.to_string() + ); +} + +#[tokio::test] +async fn new_password_fields_must_match() { + // Arrange + let app = spawn_app().await; + let new_password = Uuid::new_v4().to_string(); + let another_new_password = Uuid::new_v4().to_string(); + + // Act - Login + app.login_assert().await; + + // Act - Try to change password + let actual = app + .core_client + .change_password( + &ChangePasswordReqArgs { + current_password: app.test_user.password.into(), + new_password: new_password.clone().into(), + new_password_check: another_new_password.into(), + }, + no_cb, + ) + .await + .unwrap(); + + // Assert - Password mismatch rejected + assert_eq!( + actual.unwrap_err().to_string(), + ChangePasswordError::PasswordsDoNotMatch.to_string() + ); +} + +#[tokio::test] +async fn current_password_must_be_valid() { + // Arrange + let app = spawn_app().await; + let new_password = Uuid::new_v4().to_string(); + let wrong_password = Uuid::new_v4().to_string(); + + // Act - Login + app.login_assert().await; + + // Act - Try to change password + let actual = app + .core_client + .change_password( + &ChangePasswordReqArgs { + current_password: wrong_password.into(), + new_password: new_password.clone().into(), + new_password_check: new_password.into(), + }, + no_cb, + ) + .await + .unwrap(); + + // Assert - Rejected wrong current password + assert_eq!( + actual.unwrap_err().to_string(), + ChangePasswordError::CurrentPasswordWrong( + wykies_shared::uac::AuthError::InvalidUserOrPassword, + ) + .to_string() + ); +} + +#[tokio::test] +async fn changing_password_works() { + // Arrange + let app = spawn_app().await; + let new_password = Uuid::new_v4().to_string(); + + // Act - Login + app.login_assert().await; + + // Act - Change password + let actual = app + .core_client + .change_password( + &ChangePasswordReqArgs { + current_password: app.test_user.password.clone().into(), + new_password: new_password.clone().into(), + new_password_check: new_password.clone().into(), + }, + no_cb, + ) + .await + .unwrap(); + + // Assert + actual.unwrap(); + + // Act - Logout + app.logout_assert().await; + + // Act - Login using the new password + let login_outcome = app + .core_client + .login( + app.test_user.login_args().password(new_password.into()), + no_cb, + ) + .await + .unwrap(); + + // Assert - Login succeeded + assert!(login_outcome.unwrap().is_any_success()); +} diff --git a/crates/chat-app-server/tests/api/chat.rs b/crates/chat-app-server/tests/api/chat.rs new file mode 100644 index 0000000..746791c --- /dev/null +++ b/crates/chat-app-server/tests/api/chat.rs @@ -0,0 +1,152 @@ +use crate::helpers::{no_cb, spawn_app, wait_for_message}; +use ewebsock::{WsEvent, WsMessage}; +use plugin_chat::{ChatIM, ChatImText, ChatMsg, ChatUser, InitialStateBody, RespHistoryBody}; +use wykies_shared::{const_config::path::PATH_WS_TOKEN_CHAT, uac::Username}; +use wykies_time::Timestamp; + +#[tokio::test] +async fn sent_messages_received() { + // Arrange + let app = spawn_app().await; + app.login_assert().await; + let mut conn1 = app + .core_client + .ws_connect(PATH_WS_TOKEN_CHAT, no_cb) + .await + .expect("failed to receive on rx") + .expect("connection result was not ok"); + let conn2 = app + .core_client + .ws_connect(PATH_WS_TOKEN_CHAT, no_cb) + .await + .expect("failed to receive on rx") + .expect("connection result was not ok"); + let author: Username = app.test_user.username.try_into().unwrap(); + let expected_im = ChatMsg::IM(ChatIM { + author: author.clone(), + timestamp: Timestamp::now(), + content: "test message".try_into().unwrap(), + }); + let msg = WsMessage::Text(serde_json::to_string(&expected_im).unwrap()); + let chat_user = ChatUser::new(author); + let expected_initial_state = WsEvent::Message(WsMessage::Text( + serde_json::to_string(&ChatMsg::InitialState(InitialStateBody::new( + vec![(chat_user, 2)], + RespHistoryBody::new(Vec::new()), + ))) + .unwrap(), + )); + + // Act - Wait for initial message + let actual_initial_state = wait_for_message(&conn2.rx, true) + .await + .expect("failed to receive initial state"); + + // Assert + assert_eq!( + format!("{actual_initial_state:?}"), + format!("{expected_initial_state:?}") + ); + + // Act + conn1.tx.send(msg.clone()); + + let incoming = wait_for_message(&conn2.rx, true) + .await + .expect("failed to receive message"); + + let mut actual: ChatMsg = match incoming { + WsEvent::Message(WsMessage::Text(text)) => serde_json::from_str(&text).unwrap(), + other => panic!("Actual: {other:?}\nExpected: {:?}", WsEvent::Message(msg)), + }; + + // Prevent test from being flakey as server might change the time stamp + if let (ChatMsg::IM(actual), ChatMsg::IM(expected)) = (&mut actual, &expected_im) { + actual.timestamp = expected.timestamp + } + + // Assert + assert_eq!(actual, expected_im); +} + +#[tokio::test] +async fn connect_to_chat() { + // Arrange + let app = spawn_app().await; + app.login_assert().await; + + // Connect Websocket + app.core_client + .ws_connect(PATH_WS_TOKEN_CHAT, no_cb) + .await + .expect("failed to receive on rx") + .expect("connection result was not ok"); +} + +#[tokio::test] +async fn load_history() { + // Arrange + let app = spawn_app().await; + app.login_assert().await; + let author: Username = app.test_user.username.clone().try_into().unwrap(); + let expected_ims_texts: Vec = (1..9) + .map(|i| format!("Message #{i}").try_into().unwrap()) + .collect(); + + // Act - Connect Websocket + let mut conn = app + .core_client + .ws_connect(PATH_WS_TOKEN_CHAT, no_cb) + .await + .expect("failed to receive on rx") + .expect("connection result was not ok"); + + // Act - Send messages + for im in expected_ims_texts.iter() { + let msg = ChatMsg::IM(ChatIM { + author: author.clone(), + timestamp: Timestamp::now(), + content: im.clone(), + }); + let msg = WsMessage::Text(serde_json::to_string(&msg).unwrap()); + conn.tx.send(msg); + } + tokio::time::sleep(std::time::Duration::from_millis(100)).await; //Wait for message to be sent before dropping sender + + // Act - Reconnect to see if messages are sent in the history + conn = app + .core_client + .ws_connect(PATH_WS_TOKEN_CHAT, no_cb) + .await + .expect("failed to receive on rx") + .expect("connection result was not ok"); + + // Act - Wait for initial state message + let incoming = wait_for_message(&conn.rx, true) + .await + .expect("failed to receive message"); + + // Assert + match incoming { + WsEvent::Message(WsMessage::Text(text)) => { + let msg: ChatMsg = serde_json::from_str(&text).unwrap(); + let ims = match msg { + ChatMsg::InitialState(InitialStateBody { + history: RespHistoryBody { ims, .. }, + .. + }) => ims, + other => panic!("expected initial state but got: {other:?}"), + }; + assert!( + ims.iter().all(|im| im.author == author), + "unexpected author in: {ims:?}" + ); + let actual_im_texts: Vec = ims.into_iter().map(|im| im.content).collect(); + assert_eq!(actual_im_texts, expected_ims_texts) + } + other => panic!("unexpected event: {other:?}"), + } +} + +// TODO 4: Add a test for load_more chat messages +// TODO 4: Add test for saving to DB diff --git a/crates/chat-app-server/tests/api/health_check.rs b/crates/chat-app-server/tests/api/health_check.rs new file mode 100644 index 0000000..1d300d8 --- /dev/null +++ b/crates/chat-app-server/tests/api/health_check.rs @@ -0,0 +1,14 @@ +use crate::helpers::{no_cb, spawn_app}; + +#[tokio::test] +async fn health_check_works() { + // Arrange + let app = spawn_app().await; + + // Act + let actual = app.core_client.health_check(no_cb).await.unwrap(); + + // Assert + // Using unwrap so error shows instead of asserting `is_ok`` + actual.unwrap(); +} diff --git a/crates/chat-app-server/tests/api/helpers.rs b/crates/chat-app-server/tests/api/helpers.rs new file mode 100644 index 0000000..8b0421f --- /dev/null +++ b/crates/chat-app-server/tests/api/helpers.rs @@ -0,0 +1,363 @@ +use anyhow::{bail, Context}; +use argon2::password_hash::SaltString; +use argon2::PasswordHasher; +use chat_app_server::startup::{start_servers, CustomConfiguration}; +use sqlx::{Connection, Executor}; +use std::fmt::Debug; +use std::mem::forget; +use std::ops::Deref; +use std::sync::LazyLock; +use std::time::{Duration, Instant}; +use tracked_cancellations::TrackedCancellationToken; +use uuid::Uuid; +use wykies_client_core::LoginOutcome; +use wykies_server::ApiServerBuilder; +use wykies_server::{ + db_types::{DbConnection, DbPool}, + db_utils::validate_one_row_affected, + get_configuration, get_db_connection_pool, DatabaseSettings, +}; +use wykies_shared::const_config::path::PATH_WS_TOKEN_CHAT; +use wykies_shared::{ + host_branch::HostBranchPair, + id::DbId, + req_args::LoginReqArgs, + telemetry::{self, get_subscriber, init_subscriber}, + uac::Username, +}; +use wykies_time::Seconds; + +const MSG_WAIT_TIMEOUT: Seconds = Seconds::new(2); + +// Ensure that the `tracing` stack is only initialised once +static TRACING: LazyLock<()> = LazyLock::new(|| { + let default_filter_level = "info".to_string(); + let subscriber_name = "test".to_string(); + if std::env::var("TEST_LOG").is_ok() { + let log_file_name = format!("server_tests{}", Uuid::new_v4()); + let (file, path) = telemetry::create_trace_file(&log_file_name).unwrap(); + println!("Traces for tests being written to: {path:?}"); + let subscriber = get_subscriber(subscriber_name, default_filter_level, file); + init_subscriber(subscriber).unwrap(); + } else { + let subscriber = get_subscriber(subscriber_name, default_filter_level, std::io::sink); + init_subscriber(subscriber).unwrap(); + println!("Traces set to std::io::sink"); + }; +}); + +pub struct TestApp { + pub address: String, + pub port: u16, + pub db_pool: DbPool, + pub test_user: TestUser, + pub core_client: wykies_client_core::Client, + pub login_attempt_limit: u8, + pub host_branch_pair: HostBranchPair, +} + +impl Debug for TestApp { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("TestApp") + .field("address", &self.address) + .field("port", &self.port) + .field("test_user", &self.test_user) + .finish() + } +} + +impl TestApp { + /// Creates a clone of [`Self`] with an admin user and separate api_client + #[tracing::instrument] + pub async fn create_admin_user(&self) -> Self { + let admin_user = TestUser::generate("admin"); + admin_user.store(&self.db_pool, true).await; + Self { + address: self.address.clone(), + port: self.port, + db_pool: self.db_pool.clone(), + test_user: admin_user, + core_client: build_core_client(self.address.clone()), + login_attempt_limit: self.login_attempt_limit, + host_branch_pair: self.host_branch_pair.clone(), + } + } + + #[tracing::instrument] + pub async fn is_logged_in(&self) -> bool { + // Also tests if able to establish a websocket connection but this was the simplest alternative that didn't need any permissions + self.core_client + .ws_connect(PATH_WS_TOKEN_CHAT, no_cb) + .await + .expect("failed to receive on rx") + .is_ok() + } + + async fn store_host_branch(&self) { + let sql_result = sqlx::query!( + "INSERT INTO `hostbranch` + (`hostname`, `AssignedBranch`) + VALUES (?, ?);", + self.host_branch_pair.host_id, + self.host_branch_pair.branch_id + ) + .execute(&self.db_pool) + .await + .unwrap(); + validate_one_row_affected(&sql_result).unwrap(); + } + + pub async fn login(&self) -> anyhow::Result { + self.core_client + .login(self.test_user.login_args(), no_cb) + .await + .unwrap() + } + + /// Logs in the user and panics if the login is not successful + pub async fn login_assert(&self) { + assert!(self + .core_client + .login(self.test_user.login_args(), no_cb) + .await + .expect("failed to receive on rx") + .expect("failed to extract login outcome") + .is_any_success()); + } + + /// Logs out the user and panics on errors + pub async fn logout_assert(&self) { + self.core_client + .logout(no_cb) + .await + .expect("failed to receive on rx") + .expect("login result was not ok"); + } +} + +/// Empty function for use when a call back isn't needed +pub fn no_cb() {} + +fn build_core_client(server_address: String) -> wykies_client_core::Client { + wykies_client_core::Client::new(server_address) +} + +pub async fn spawn_app() -> TestApp { + let result = spawn_app_without_host_branch_stored().await; + result.store_host_branch().await; + result +} + +pub async fn spawn_app_without_host_branch_stored() -> TestApp { + // Deref TRACING to force the LazyLock to initialize + let _ = TRACING.deref(); + + // Randomise configuration to ensure test isolation + let configuration = { + let mut c = + get_configuration::().expect("failed to read configuration"); + // Use a different database for each test case + c.database.database_name = Uuid::new_v4().to_string(); + // Use a random OS port + c.application.port = 0; + // Use root user to be able to create a new database + c.database.username = "root".to_string(); + + c + }; + + // Create and migrate the database + configure_database(&configuration.database).await; + + // Create tokens to be able to start server + let (cancellation_token, _) = TrackedCancellationToken::new(); + + // Launch the application as a background task + let server_builder = ApiServerBuilder::new(&configuration) + .await + .expect("Failed to build application."); + let application_port = server_builder + .port() + .expect("failed to get application port"); + let join_set = start_servers(server_builder, &configuration, cancellation_token).await; + forget(join_set); // Leak the JoinSet so the server doesn't get shutdown + + let login_attempt_limit = configuration.user_auth.login_attempt_limit; + + let db_pool = get_db_connection_pool(&configuration.database); + + let host_branch_pair = HostBranchPair { + host_id: "127.0.0.1".to_string().try_into().unwrap(), + branch_id: get_seed_branch_from_db(&db_pool).await, + }; + + let address = format!("http://localhost:{}", application_port); + let core_client = build_core_client(address.clone()); + + let test_app = TestApp { + address, + port: application_port, + db_pool, + test_user: TestUser::generate("normal"), + core_client, + login_attempt_limit, + host_branch_pair, + }; + + test_app.test_user.store(&test_app.db_pool, false).await; + + test_app +} + +#[allow(non_snake_case)] +async fn get_seed_branch_from_db(pool: &DbPool) -> DbId { + let branch_id = sqlx::query!("SELECT `BranchID` FROM branch LIMIT 1;") + .fetch_one(pool) + .await + .context("failed to get seed branch id") + .unwrap() + .BranchID; + branch_id.try_into().unwrap() +} + +async fn configure_database(config: &DatabaseSettings) -> DbPool { + // Create database + let mut connection = DbConnection::connect_with(&config.without_db()) + .await + .expect("Failed to connect to Database"); + connection + .execute(&*format!(r#"CREATE DATABASE `{}`;"#, config.database_name)) + .await + .expect("Failed to create database"); + + // Migrate database + let connection_pool = DbPool::connect_with(config.with_db()) + .await + .expect("Failed to connect to Database."); + sqlx::migrate!("./migrations") + .run(&connection_pool) + .await + .expect("Failed to migrate the database"); + + connection_pool +} + +#[derive(Debug)] +pub struct TestUser { + pub username: String, + pub password: String, +} + +impl TestUser { + pub fn generate(username_prefix: &str) -> Self { + let remaining_length = Username::MAX_LENGTH - username_prefix.len() - 1; + let username = format!( + "{username_prefix}-{}", + &Uuid::new_v4().to_string()[..remaining_length] + ); + Self { + username, + password: Uuid::new_v4().to_string(), + } + } + + pub fn login_args(&self) -> LoginReqArgs { + LoginReqArgs::new(self.username.clone(), self.password.clone().into()) + } + + pub async fn disable_in_db(&self, app: &TestApp) { + let sql_result = sqlx::query!( + "UPDATE `user` SET `Enabled` = '0' WHERE `user`.`UserName` = ?;", + self.username, + ) + .execute(&app.db_pool) + .await + .expect("failed to set user to disabled"); + validate_one_row_affected(&sql_result).expect("failed to set user to disabled"); + } + + pub async fn set_locked_out_in_db(&self, app: &TestApp, value: bool) { + let value = if value { 1 } else { 0 }; + let sql_result = sqlx::query!( + "UPDATE `user` SET `LockedOut` = ? WHERE `user`.`UserName` = ?;", + value, + self.username, + ) + .execute(&app.db_pool) + .await + .expect("failed to set user to disabled"); + validate_one_row_affected(&sql_result).expect("failed to set user to disabled"); + } + + async fn store(&self, pool: &DbPool, is_admin: bool) { + let salt = SaltString::generate(&mut rand::thread_rng()); + // Match production parameters + let password_hash = wykies_server::authentication::argon2_settings() + .hash_password(self.password.as_bytes(), &salt) + .unwrap() + .to_string(); + + if is_admin { + let sql_result = sqlx::query!( + "INSERT INTO `roles` + (`RoleID`, `Name`, `Description`, `Permissions`, `LockedEditing`) + VALUES (NULL, 'Admin', 'Full Permissions', '11111111111111111111111111111111111', '0'); ", + ) + .execute(pool) + .await + .expect("failed to store test user"); + validate_one_row_affected(&sql_result).expect("failed to store admin role"); + let role_id = sql_result.last_insert_id(); + + let sql_result = sqlx::query!( + "INSERT INTO `user` + (`UserName`, `Password`, `password_hash`, `salt`, `DisplayName`, `AssignedRole`, `PassChangeDate`, `Enabled`) + VALUES (?, '', ?, '', 'Admin User', ?, CURRENT_DATE(), 1);", + self.username, + password_hash, + role_id + ) + .execute(pool) + .await + .expect("failed to store test user"); + validate_one_row_affected(&sql_result).expect("failed to store admin user"); + } else { + let sql_result = sqlx::query!( + "INSERT INTO `user` + (`UserName`, `Password`, `password_hash`, `salt`, `DisplayName`, `PassChangeDate`, `Enabled`) + VALUES (?, '', ?, '', 'Test User', CURRENT_DATE(), 1);", + self.username, + password_hash, + ) + .execute(pool) + .await + .expect("failed to store test user"); + validate_one_row_affected(&sql_result).expect("failed to store test user"); + } + } +} + +pub async fn wait_for_message( + rx: &ewebsock::WsReceiver, + should_ignore_ping: bool, +) -> anyhow::Result { + let start = Instant::now(); + let timeout: Duration = MSG_WAIT_TIMEOUT.into(); + while start.elapsed() < timeout { + if let Some(msg) = rx.try_recv() { + let _empty_vec = Vec::::new(); + if should_ignore_ping + && matches!( + &msg, + ewebsock::WsEvent::Message(ewebsock::WsMessage::Ping(_empty_vec)) + ) + { + continue; // Skip ping messages + } + return Ok(msg); + } else { + tokio::time::sleep(Duration::from_millis(1)).await; + } + } + bail!("Timed out after {MSG_WAIT_TIMEOUT:?}") +} diff --git a/crates/chat-app-server/tests/api/host_branch.rs b/crates/chat-app-server/tests/api/host_branch.rs new file mode 100644 index 0000000..0717fff --- /dev/null +++ b/crates/chat-app-server/tests/api/host_branch.rs @@ -0,0 +1,300 @@ +use wykies_client_core::LoginOutcome; +use wykies_shared::{ + branch::BranchDraft, host_branch::HostBranchPair, + req_args::api::admin::host_branch::LookupReqArgs, uac::AuthError, +}; + +use crate::helpers::{no_cb, spawn_app, spawn_app_without_host_branch_stored, TestApp}; + +#[tokio::test] +async fn set_host_branch_pair() { + // Arrange + let app_admin = spawn_app().await.create_admin_user().await; + let branch_draft = BranchDraft { + name: "test name".to_string().try_into().unwrap(), + address: "test address".to_string().try_into().unwrap(), + }; + + // Act - Login the admin + app_admin.login_assert().await; + + // Arrange - Create Branch + let branch_id = app_admin + .core_client + .create_branch(&branch_draft, no_cb) + .await + .expect("failed to receive from rx") + .expect("failed to extract branch id"); + let mut host_branch_pair = HostBranchPair { + host_id: "Host name or IP".to_string().try_into().unwrap(), + branch_id, + }; + + // Create New Pair + send_request_and_verify_response(&app_admin, &host_branch_pair).await; + + // Do Same Pair Again + send_request_and_verify_response(&app_admin, &host_branch_pair).await; + + // Act - Create new branch + let branch_draft = BranchDraft { + name: "test name2".to_string().try_into().unwrap(), + address: "test address2".to_string().try_into().unwrap(), + }; + let branch_id = app_admin + .core_client + .create_branch(&branch_draft, no_cb) + .await + .expect("failed to receive from rx") + .expect("failed to get branch new branch id"); + host_branch_pair.branch_id = branch_id; + + // Update Host to New Branch + send_request_and_verify_response(&app_admin, &host_branch_pair).await; +} + +async fn send_request_and_verify_response(app: &TestApp, pair: &HostBranchPair) { + // Act - Set Pair (Create / Update) + app.core_client + .create_host_branch_pair(pair, no_cb) + .await + .expect("failed to receive from rx") + .expect("failed to create host branch pair"); + + // Act - Retrieve current list of pairs + let pairs = app + .core_client + .get_list_host_branch_pairs(no_cb) + .await + .expect("failed to receive from rx") + .expect("failed to get host branch pairs"); + + // Assert - Verify Pair was created + assert!( + pairs.contains(pair), + "actual: {pairs:?}, expected it to include: {pair:?}" + ); +} + +#[tokio::test] +async fn host_branch_pair_lookup() { + // Arrange + let app_admin = spawn_app().await.create_admin_user().await; + let branch_draft = BranchDraft { + name: "test name".to_string().try_into().unwrap(), + address: "test address".to_string().try_into().unwrap(), + }; + + // Act - Login the admin + app_admin.login_assert().await; + + // Arrange - Create Branch + let branch_id = app_admin + .core_client + .create_branch(&branch_draft, no_cb) + .await + .expect("failed to receive from rx") + .expect("failed to extract new branch id"); + let host_branch_pair = HostBranchPair { + host_id: "Host name or IP".to_string().try_into().unwrap(), + branch_id, + }; + + // Act - Do lookup + let actual = app_admin + .core_client + .get_host_branch_pair( + &LookupReqArgs { + host_id: host_branch_pair.host_id.clone(), + }, + no_cb, + ) + .await + .expect("failed to receive from rx") + .expect("failed to extract value"); + + // Assert - Ensure not found + assert_eq!(actual, None); + + // Create New Pair + send_request_and_verify_response(&app_admin, &host_branch_pair).await; + + // Act - Do lookup + let actual = app_admin + .core_client + .get_host_branch_pair( + &LookupReqArgs { + host_id: host_branch_pair.host_id.clone(), + }, + no_cb, + ) + .await + .expect("failed to receive from rx") + .expect("failed to extract value"); + + // Assert - Found + assert_eq!(actual, Some(host_branch_pair.branch_id)); +} + +#[tokio::test] +async fn ensure_branch_only_changes_if_not_set() { + // Arrange + let app_admin = spawn_app().await.create_admin_user().await; + + // Arrange - Create 2nd branch and logout to test setting it + app_admin.login_assert().await; + let body = BranchDraft { + name: "second branch".to_string().try_into().unwrap(), + address: "other address".to_string().try_into().unwrap(), + }; + let new_branch_id = app_admin + .core_client + .create_branch(&body, no_cb) + .await + .expect("failed to receive from rx") + .expect("failed to extract value"); + app_admin.logout_assert().await; + + // Act - Login and request branch is changed + assert!(app_admin + .core_client + .login( + app_admin + .test_user + .login_args() + .branch_to_set(Some(new_branch_id)), + no_cb, + ) + .await + .expect("failed to receive from rx") + .expect("failed to extract return value") + .is_any_success()); + + // Act - Get current branch set + let curr_branch_id = app_admin + .core_client + .get_host_branch_pair( + &LookupReqArgs { + host_id: app_admin.host_branch_pair.host_id.clone(), + }, + no_cb, + ) + .await + .expect("failed to receive from rx") + .expect("failed to extract value") + .expect("expected some not none"); + + // Assert - Confirm branch has not changed + assert_ne!(curr_branch_id, new_branch_id); + assert_eq!(curr_branch_id, app_admin.host_branch_pair.branch_id); +} + +#[tokio::test] +async fn ensure_branch_not_set_without_permissions() { + // Arrange - Setup without branch assigned + let app = spawn_app_without_host_branch_stored().await; + + // Act - Login without requesting branch be set + let actual = app.login().await; + + // Assert - Correct error returned + assert_eq!( + actual.unwrap_err().to_string(), + AuthError::BranchNotSetAndUnableToSet { + client_identifier: app.host_branch_pair.host_id.clone(), + } + .to_string() + ); + + // Act - Login again and attempt to set branch + let actual = app + .core_client + .login( + app.test_user + .login_args() + .branch_to_set(Some(app.host_branch_pair.branch_id)), + no_cb, + ) + .await + .unwrap(); + + // Assert - Correct error returned + assert_eq!( + actual.unwrap_err().to_string(), + AuthError::BranchNotSetAndUnableToSet { + client_identifier: app.host_branch_pair.host_id.clone(), + } + .to_string() + ); + + // Assert - Ensure user is not logged in + assert!(!app.is_logged_in().await); +} + +#[tokio::test] +async fn ensure_branch_can_be_set_with_permissions() { + // Arrange - Setup without branch assigned + let app_admin = spawn_app_without_host_branch_stored() + .await + .create_admin_user() + .await; + + // Act - Login without requesting branch be set + let actual = app_admin.login().await; + + // Assert - Correct error returned + assert_eq!(actual.unwrap(), LoginOutcome::RetryWithBranchSet); + + // Act - Login again and set branch + assert!(app_admin + .core_client + .login( + app_admin + .test_user + .login_args() + .branch_to_set(Some(app_admin.host_branch_pair.branch_id)), + no_cb, + ) + .await + .expect("failed to receive on rx") + .expect("failed to extract returned value") + .is_any_success()); + + // Act - Get current branch set + let actual = app_admin + .core_client + .get_host_branch_pair( + &LookupReqArgs { + host_id: app_admin.host_branch_pair.host_id.clone(), + }, + no_cb, + ) + .await + .expect("failed to receive from rx") + .expect("failed to extract value"); + + // Assert - Check set to expected branch + assert_eq!(actual, Some(app_admin.host_branch_pair.branch_id)); + + // Act - Log out + app_admin.logout_assert().await; + + // Act - Log back in + app_admin.login_assert().await; + + // Act - Get current branch set + let actual = app_admin + .core_client + .get_host_branch_pair( + &LookupReqArgs { + host_id: app_admin.host_branch_pair.host_id.clone(), + }, + no_cb, + ) + .await + .expect("failed to receive from rx") + .expect("failed to extract value"); + + // Assert - Branch is still set + assert_eq!(actual, Some(app_admin.host_branch_pair.branch_id)); +} diff --git a/crates/chat-app-server/tests/api/login.rs b/crates/chat-app-server/tests/api/login.rs new file mode 100644 index 0000000..399c66c --- /dev/null +++ b/crates/chat-app-server/tests/api/login.rs @@ -0,0 +1,250 @@ +use crate::helpers::{no_cb, spawn_app}; +use std::sync::{Arc, Mutex}; +use wykies_client_core::LoginOutcome; +use wykies_shared::{req_args::LoginReqArgs, uac::AuthError}; + +#[tokio::test] +async fn login_failure_invalid_user() { + // Arrange + let app = spawn_app().await; + let login_args = LoginReqArgs::new( + "random-username".to_string(), + "random-password".to_string().into(), + ); + + // Act + let outcome = app.core_client.login(login_args, no_cb).await.unwrap(); + + // Assert + assert_eq!( + outcome.unwrap_err().to_string(), + AuthError::InvalidUserOrPassword.to_string() + ); +} + +#[tokio::test] +async fn login_failure_invalid_password() { + // Arrange + let app = spawn_app().await; + let login_args = app + .test_user + .login_args() + .password("random-password".to_string().into()); + + // Act + let outcome = app.core_client.login(login_args, no_cb).await.unwrap(); + + // Assert + assert_eq!( + outcome.unwrap_err().to_string(), + AuthError::InvalidUserOrPassword.to_string() + ); +} + +#[tokio::test] +async fn login_failure_not_enabled() { + // Arrange + let app = spawn_app().await; + app.test_user.disable_in_db(&app).await; + + // Act + let outcome = app.login().await; + + // Assert + assert_eq!( + outcome.unwrap_err().to_string(), + AuthError::NotEnabled.to_string() + ); +} + +#[tokio::test] +async fn login_logout_round_trip() { + // Arrange + let app = spawn_app().await; + + // Assert - Ensure not logged in + assert!( + !app.is_logged_in().await, + "should not be logged in before logging in" + ); + + // Act - Login + let login_outcome = app.login().await.unwrap(); + + // Assert - Login successful and user info stored + assert_eq!(login_outcome, LoginOutcome::ForcePasswordChange); + assert_eq!( + app.core_client.user_info().unwrap().username.as_ref(), + app.test_user.username + ); + + // Assert - Ensure we are logged in + assert!( + app.is_logged_in().await, + "should be logged in after logging in" + ); + + // Act - Logout + app.logout_assert().await; + + // Assert - Ensure we are not logged in + assert!( + !app.is_logged_in().await, + "should not be logged in after logging out" + ); +} + +#[tokio::test] +async fn ensure_call_back_is_run() { + // Arrange + let app = spawn_app().await; + let test_flag = Arc::new(Mutex::new(false)); + let test_flag_clone = Arc::clone(&test_flag); + + // Act + assert!(app + .core_client + .login(app.test_user.login_args(), move || { + *test_flag_clone.lock().unwrap() = true; + }) + .await + .expect("failed to receive from rx") + .expect("failed to get result of login") + .is_any_success()); + + // Assert + assert!(*test_flag.lock().unwrap(), "flag was not flipped"); +} + +#[tokio::test] +/// Check that flag is respected even without any attempts +async fn login_user_locked_out_no_attempts() { + // Arrange + let app = spawn_app().await; + app.test_user.set_locked_out_in_db(&app, true).await; + + // Act + let outcome = app.login().await; + + // Assert + assert_eq!( + outcome.unwrap_err().to_string(), + AuthError::LockedOut.to_string() + ); +} + +#[tokio::test] +async fn ensure_user_gets_locked_out() { + // Arrange + let app = spawn_app().await; + let login_args = app + .test_user + .login_args() + .password("random-password".to_string().into()); + + // Assert - Ensure not logged in + assert!( + !app.is_logged_in().await, + "should not be logged in before logging in" + ); + + // Act - Repeat attempting login one less than the limit so they should still + // not be locked out + let login_attempt_limit = app.login_attempt_limit; + for _ in 1..login_attempt_limit { + // Attempt login + let outcome = app + .core_client + .login(login_args.clone(), no_cb) + .await + .unwrap(); + + // Ensure not locked out + assert_eq!( + outcome.unwrap_err().to_string(), + AuthError::InvalidUserOrPassword.to_string() + ); + } + + // Attempt login again which should trigger the lockout + let outcome = app.core_client.login(login_args, no_cb).await.unwrap(); + + // Assert - User is locked out + assert_eq!( + outcome.unwrap_err().to_string(), + AuthError::LockedOut.to_string() + ); + + // Act - Attempt login again with correct password + let outcome = app.login().await; + + // Assert - User is still locked out + assert_eq!( + outcome.unwrap_err().to_string(), + AuthError::LockedOut.to_string() + ); +} + +#[tokio::test] +async fn ensure_locked_out_is_reset() { + // Arrange + let app = spawn_app().await; + let login_args = app + .test_user + .login_args() + .password("random-password".to_string().into()); + + // Assert - Ensure not logged in + assert!( + !app.is_logged_in().await, + "should not be logged in before logging in" + ); + + // Act - Repeat attempting login one less than the limit so they should still + // not be locked out + let login_attempt_limit = app.login_attempt_limit; + for _ in 1..login_attempt_limit { + // Attempt login + let outcome = app + .core_client + .login(login_args.clone(), no_cb) + .await + .unwrap(); + + // Ensure not locked out + assert_eq!( + outcome.unwrap_err().to_string(), + AuthError::InvalidUserOrPassword.to_string() + ); + } + + // Login Successfully which should reset the count + app.login_assert().await; + + // Assert - User is logged in + assert!(app.is_logged_in().await); + + // Act - Log out + app.logout_assert().await; + + // Assert - user is logged out + assert!(!app.is_logged_in().await); + + // Act - Repeat attempting login one less than the limit so they should still + // not be locked out + let login_attempt_limit = app.login_attempt_limit; + for _ in 1..login_attempt_limit { + // Attempt login + let outcome = app + .core_client + .login(login_args.clone(), no_cb) + .await + .unwrap(); + + // Ensure not locked out + assert_eq!( + outcome.unwrap_err().to_string(), + AuthError::InvalidUserOrPassword.to_string() + ); + } +} diff --git a/crates/chat-app-server/tests/api/main.rs b/crates/chat-app-server/tests/api/main.rs new file mode 100644 index 0000000..e26bef8 --- /dev/null +++ b/crates/chat-app-server/tests/api/main.rs @@ -0,0 +1,12 @@ +mod branch; +mod change_password; +mod chat; +mod health_check; +mod host_branch; +mod login; +mod permissions; +mod roles; +mod users; +mod web_sockets; + +mod helpers; diff --git a/crates/chat-app-server/tests/api/permissions.rs b/crates/chat-app-server/tests/api/permissions.rs new file mode 100644 index 0000000..a2b477e --- /dev/null +++ b/crates/chat-app-server/tests/api/permissions.rs @@ -0,0 +1,43 @@ +use wykies_shared::uac::{Permission, PermissionsError}; + +use crate::helpers::{no_cb, spawn_app}; + +#[tokio::test] +async fn unprivileged_user_is_denied() { + // Arrange + let app = spawn_app().await; + + // Act - Login + app.login_assert().await; + + // Act - Attempt to access restricted endpoint + let actual = app + .core_client + .get_list_host_branch_pairs(no_cb) + .await + .unwrap(); + + // Assert - Ensure request was denied + let expected_error = + PermissionsError::MissingPermissions(vec![Permission::ManHostBranchAssignment]); + assert_eq!(actual.unwrap_err().to_string(), expected_error.to_string()); +} + +#[tokio::test] +async fn test_admin_user_works() { + // Arrange + let app_admin = spawn_app().await.create_admin_user().await; + + // Act - Login + app_admin.login_assert().await; + + // Act - Attempt to access restricted endpoint + let actual = app_admin + .core_client + .get_list_host_branch_pairs(no_cb) + .await + .unwrap(); + + // Assert - Ensure request succeeded + actual.unwrap(); +} diff --git a/crates/chat-app-server/tests/api/roles.rs b/crates/chat-app-server/tests/api/roles.rs new file mode 100644 index 0000000..43658ce --- /dev/null +++ b/crates/chat-app-server/tests/api/roles.rs @@ -0,0 +1,81 @@ +use crate::helpers::{no_cb, spawn_app}; +use wykies_shared::{ + req_args::api::admin::role::AssignReqArgs, + uac::{Permission, Role, RoleDraft}, +}; + +#[tokio::test] +async fn create_and_assign_role_to_user() { + // Arrange + let app_normal = spawn_app().await; + let app_admin = app_normal.create_admin_user().await; + let role_draft = RoleDraft { + name: "Test Role".to_string().try_into().unwrap(), + description: "Test Description".to_string().try_into().unwrap(), + permissions: vec![ + Permission::ManHostBranchAssignment, + Permission::RecordDiscrepancy, + ] + .into(), + }; + + // Act - Login the admin + app_admin.login_assert().await; + + // Act - Create Role + let role_id = app_admin + .core_client + .create_role(&role_draft, no_cb) + .await + .expect("failed to receive on rx") + .expect("failed to extract role_id"); + + // Assert - Verify Role was created + let role = app_admin + .core_client + .get_role(role_id, no_cb) + .await + .expect("failed to receive on rx") + .expect("failed to extract role_id"); + let expected = Role { + id: role_id, + name: role_draft.name, + description: role_draft.description, + permissions: role_draft.permissions, + }; + assert_eq!(role, expected); + + // Act - Login the Normal user + app_normal.login_assert().await; + + // Assert - Ensure they have no permissions + let user = app_normal.core_client.user_info().unwrap(); + assert!(user.permissions.0.is_empty()); + + // Act - Log out the normal user + app_normal + .core_client + .logout(no_cb) + .await + .expect("failed to receive on rx") + .expect("failed to log out"); + + // Act - Set the role for the normal user + let req_args = AssignReqArgs { + username: app_normal.test_user.username.clone().try_into().unwrap(), + role_id: role.id, + }; + app_admin + .core_client + .assign_role(&req_args, no_cb) + .await + .expect("failed to receive on rx") + .expect("failed to assign role"); + + // Act - Login the Normal user + app_normal.login_assert().await; + + // Assert - Normal user now has the permissions defined + let user = app_normal.core_client.user_info().unwrap(); + assert_eq!(user.permissions, role.permissions); +} diff --git a/crates/chat-app-server/tests/api/snapshots/api__users__list_users_and_roles.snap b/crates/chat-app-server/tests/api/snapshots/api__users__list_users_and_roles.snap new file mode 100644 index 0000000..b371970 --- /dev/null +++ b/crates/chat-app-server/tests/api/snapshots/api__users__list_users_and_roles.snap @@ -0,0 +1,48 @@ +--- +source: crates/chat-app-server/tests/api/users.rs +expression: actual +--- +{ + "users": [ + { + "username": "[value varies]", + "display_name": "Admin User", + "force_pass_change": true, + "assigned_role": 2, + "enabled": true, + "locked_out": false, + "failed_attempts": 0, + "pass_change_date": "[date]" + }, + { + "username": "[value varies]", + "display_name": "Test User", + "force_pass_change": true, + "assigned_role": null, + "enabled": true, + "locked_out": false, + "failed_attempts": 0, + "pass_change_date": "[date]" + }, + { + "username": "[value varies]", + "display_name": "Seed Admin User", + "force_pass_change": true, + "assigned_role": 1, + "enabled": true, + "locked_out": false, + "failed_attempts": 0, + "pass_change_date": "[date]" + } + ], + "roles": [ + { + "id": 2, + "name": "Admin" + }, + { + "id": 1, + "name": "SeedAdmin" + } + ] +} diff --git a/crates/chat-app-server/tests/api/snapshots/api__users__user.snap b/crates/chat-app-server/tests/api/snapshots/api__users__user.snap new file mode 100644 index 0000000..a63e531 --- /dev/null +++ b/crates/chat-app-server/tests/api/snapshots/api__users__user.snap @@ -0,0 +1,14 @@ +--- +source: crates/chat-app-server/tests/api/users.rs +expression: actual +--- +{ + "username": "[value varies]", + "display_name": "Admin User", + "force_pass_change": true, + "assigned_role": 2, + "enabled": true, + "locked_out": false, + "failed_attempts": 0, + "pass_change_date": "[date]" +} diff --git a/crates/chat-app-server/tests/api/users.rs b/crates/chat-app-server/tests/api/users.rs new file mode 100644 index 0000000..2d51476 --- /dev/null +++ b/crates/chat-app-server/tests/api/users.rs @@ -0,0 +1,266 @@ +use secrecy::SecretString; +use uuid::Uuid; +use wykies_client_core::LoginOutcome; +use wykies_shared::{ + req_args::{ + api::admin::user::{NewUserReqArgs, PasswordResetReqArgs}, + LoginReqArgs, + }, + uac::{ResetPasswordError, UserMetadata, UserMetadataDiff, Username}, +}; + +use crate::helpers::{no_cb, spawn_app}; + +#[tokio::test] +async fn list_users_and_roles() { + // Arrange + let app = spawn_app().await.create_admin_user().await; + app.login_assert().await; + + // Act + let actual = app + .core_client + .list_users_and_roles(no_cb) + .await + .expect("failed to receive on rx") + .expect("failed to extract result"); + + // Assert + insta::assert_json_snapshot!(actual, { + ".users[].username" => "[value varies]", + ".users[].pass_change_date" => "[date]" + }); +} + +#[tokio::test] +async fn user() { + // Arrange + let app = spawn_app().await.create_admin_user().await; + app.login_assert().await; + + // Act + let actual = app + .core_client + .get_user(app.test_user.username.clone().try_into().unwrap(), no_cb) + .await + .expect("failed to receive on rx") + .expect("failed to extract result"); + + // Assert + insta::assert_json_snapshot!(actual, { + ".username" => "[value varies]", + ".pass_change_date" => "[date]" + }); +} + +#[tokio::test] +async fn user_update_display_name() { + common_update_user_test(|mut user| { + user.display_name = "Edited Name".to_string().try_into().unwrap(); + user + }) + .await +} + +#[tokio::test] +async fn user_update_force_pass_change() { + common_update_user_test(|mut user| { + user.force_pass_change = false; + user + }) + .await +} + +#[tokio::test] +async fn user_update_assigned_role() { + common_update_user_test(|mut user| { + user.assigned_role = None; + user + }) + .await +} + +#[tokio::test] +async fn user_update_enabled() { + common_update_user_test(|mut user| { + user.enabled = false; + user + }) + .await +} + +#[tokio::test] +async fn user_update_locked_out() { + common_update_user_test(|mut user| { + user.locked_out = true; + user.failed_attempts = 10; + user + }) + .await +} + +#[tokio::test] +async fn user_update_all() { + common_update_user_test(|mut user| { + user.display_name = "All Changed".to_string().try_into().unwrap(); + user.assigned_role = Some(1.into()); + user.enabled = false; + user.force_pass_change = false; + user.locked_out = true; + user.failed_attempts = 10; + user + }) + .await +} + +async fn common_update_user_test(f: impl FnOnce(UserMetadata) -> UserMetadata) { + // Arrange + let app = spawn_app().await.create_admin_user().await; + app.login_assert().await; + + // Arrange -- Get User from DB + let original_user = app + .core_client + .get_user(app.test_user.username.clone().try_into().unwrap(), no_cb) + .await + .expect("failed to receive on rx") + .expect("failed to extract result"); + + // Arrange -- Create modified user + let edited_user = f(original_user.clone()); + + // Arrange -- Create Diff + let diff = UserMetadataDiff::from_diff(&original_user, &edited_user) + .expect("username must match") + .expect("no difference found"); + + // Act -- Push change + app.core_client + .update_user(diff, no_cb) + .await + .expect("failed to receive on rx") + .expect("failed to extract result"); + + // Act -- Get updated user + let actual = app + .core_client + .get_user(app.test_user.username.clone().try_into().unwrap(), no_cb) + .await + .expect("failed to receive on rx") + .expect("failed to extract result"); + + // Assert + assert_eq!(actual, edited_user); +} + +#[tokio::test] +async fn new_user() { + // Arrange + let app = spawn_app().await.create_admin_user().await; + app.login_assert().await; + let username: Username = "New User".to_string().try_into().unwrap(); + let password: SecretString = "a test password".to_string().into(); + let req_args = NewUserReqArgs { + username: username.clone(), + display_name: "Display New".to_string().try_into().unwrap(), + password: password.clone(), + assigned_role: None, + }; + + // Act + app.core_client + .new_user(req_args.clone(), no_cb) + .await + .expect("failed to receive on rx") + .expect("failed to extract result"); + // TODO 4: Add macro to add the expects as there isn't much value in copying + // this every time. Needs to be macro and not a function to capture the location + // of the panic in the code. + let actual = app + .core_client + .get_user(username.clone(), no_cb) + .await + .expect("failed to receive on rx") + .expect("failed to extract result"); + + // Assert + let expected = UserMetadata { + username: req_args.username, + display_name: req_args.display_name, + force_pass_change: true, + assigned_role: req_args.assigned_role, + enabled: true, + locked_out: false, + failed_attempts: 0, + pass_change_date: chrono::Utc::now().date_naive(), + }; + assert_eq!(actual, expected); + + // Arrange -- Logout to test logging in as new user + app.logout_assert().await; + let login_args = LoginReqArgs::new(username, password); + + // Act + let outcome = app + .core_client + .login(login_args, no_cb) + .await + .expect("failed to receive on rx") + .expect("failed to extract result"); + + // Assert + assert_eq!(outcome, LoginOutcome::ForcePasswordChange); +} + +#[tokio::test] +async fn password_reset_normal() { + // Arrange + let mut app_normal = spawn_app().await; + let app_admin = app_normal.create_admin_user().await; + let new_password = Uuid::new_v4().to_string(); + let password_reset_req_args = PasswordResetReqArgs { + username: app_normal.test_user.username.clone().try_into().unwrap(), + new_password: new_password.clone().into(), + }; + app_admin.login_assert().await; + + // Act - Change password + app_admin + .core_client + .reset_password(password_reset_req_args, no_cb) + .await + .expect("failed to receive on rx") + .expect("failed to extract result"); + + // Act - Login using the new password + app_normal.test_user.password = new_password; + let login_outcome = app_normal.login().await.unwrap(); + + // Assert - Login succeeded + assert_eq!(login_outcome, LoginOutcome::ForcePasswordChange); +} + +#[tokio::test] +async fn password_reset_blocked_same_user() { + // Arrange + let app = spawn_app().await.create_admin_user().await; + let args = PasswordResetReqArgs { + username: app.test_user.username.clone().try_into().unwrap(), + new_password: Uuid::new_v4().to_string().into(), + }; + app.login_assert().await; + + // Act + let actual = app + .core_client + .reset_password(args, no_cb) + .await + .expect("failed to receive on rx") + .expect_err("failed to extract error"); + + // Assert + assert_eq!( + actual.to_string(), + ResetPasswordError::NoResetOwnPassword.to_string() + ); +} diff --git a/crates/chat-app-server/tests/api/web_sockets.rs b/crates/chat-app-server/tests/api/web_sockets.rs new file mode 100644 index 0000000..ea18952 --- /dev/null +++ b/crates/chat-app-server/tests/api/web_sockets.rs @@ -0,0 +1,69 @@ +//! Happy path tested in other modules just testing authentication here + +use ewebsock::WsEvent; +use wykies_client_core::ws_expose_test::{self, EXPOSE_TEST_DUMMY_ARGUMENT}; +use wykies_shared::{const_config::path::PATH_WS_TOKEN_CHAT, token::AuthToken}; + +use crate::helpers::{no_cb, spawn_app, wait_for_message}; + +#[tokio::test] +async fn rejected_without_requesting_token() { + // Arrange + let app = spawn_app().await; + app.login_assert().await; + let ws_url = app.core_client.expose_test_ws_url_from(&PATH_WS_TOKEN_CHAT); + + // Try to connect + let conn = ws_expose_test::initiate_ws_connection(ws_url, no_cb).unwrap(); + + // Get response + let response = wait_for_message(&conn.rx, false).await.unwrap(); + + // Assert + assert_eq!( + format!("{:?}", response), + format!( + "{:?}", + WsEvent::Error("HTTP error: 400 Bad Request".to_string()) + ) + ); +} + +#[tokio::test] +async fn fails_to_connect_without_correct_token() { + // Arrange + let app = spawn_app().await; + app.login_assert().await; + let ws_url = app.core_client.expose_test_ws_url_from(&PATH_WS_TOKEN_CHAT); + + // Request token + let _token: AuthToken = app + .core_client + .expose_test_send_request_expect_json( + PATH_WS_TOKEN_CHAT, + &EXPOSE_TEST_DUMMY_ARGUMENT, + no_cb, + ) + .await + .expect("failed to get msg from rx") + .expect("failed to extract token"); + + // Initiate connection + let mut conn = ws_expose_test::initiate_ws_connection(ws_url, no_cb).unwrap(); + + // Wait for connection to be opened + ws_expose_test::wait_for_connection_to_open(&mut conn) + .await + .unwrap(); + + // Send wrong token + let token = AuthToken::new_rand(); + conn.tx.send(token.into()); + + // Get response + let response = wait_for_message(&conn.rx, false).await.unwrap(); + + // Assert - Assert that `Closed` is received. Note anything but `Closed` is an + // error including `Ping` + assert_eq!(format!("{:?}", response), format!("{:?}", WsEvent::Closed)); +} diff --git a/crates/chat-app-shared/Cargo.toml b/crates/chat-app-shared/Cargo.toml new file mode 100644 index 0000000..0e42734 --- /dev/null +++ b/crates/chat-app-shared/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "chat-app-shared" +version = "0.1.0" +edition = "2021" +publish = false +description = "Shared code between the chat frontend and backend" + +[dependencies] diff --git a/crates/chat-app-shared/src/lib.rs b/crates/chat-app-shared/src/lib.rs new file mode 100644 index 0000000..b93cf3f --- /dev/null +++ b/crates/chat-app-shared/src/lib.rs @@ -0,0 +1,14 @@ +pub fn add(left: u64, right: u64) -> u64 { + left + right +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn it_works() { + let result = add(2, 2); + assert_eq!(result, 4); + } +} diff --git a/crates/ws-auth/src/manager.rs b/crates/ws-auth/src/manager.rs index 591b0e8..cc88afd 100644 --- a/crates/ws-auth/src/manager.rs +++ b/crates/ws-auth/src/manager.rs @@ -343,6 +343,6 @@ mod tests { #[test] fn ws_type_must_match() { - // TODO 3: Implement test + // TODO 4: Implement test } } diff --git a/crates/wykies-client-core/Cargo.toml b/crates/wykies-client-core/Cargo.toml new file mode 100644 index 0000000..fe24afd --- /dev/null +++ b/crates/wykies-client-core/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "wykies-client-core" +version = "0.1.0" +edition = "2021" +publish = false +description = "Stores the functionality common between Clients" + +[dependencies] +anyhow.workspace = true +futures.workspace = true +reqwest.workspace = true +reqwest-cross = { workspace = true, features = ["yield_now"] } +secrecy.workspace = true +serde.workspace = true +serde_json.workspace = true +tracing.workspace = true +wykies-shared = { workspace = true, features = ["client_only"] } + +# native: +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +ewebsock = { workspace = true, features = ["tls", "tokio"] } + +# web: +[target.'cfg(target_arch = "wasm32")'.dependencies] +ewebsock = { workspace = true, features = ["tls"] } + +[dev-dependencies] +wasm-bindgen-test.workspace = true + +[features] +default = [] +expose_test = [] # Makes some private functions public for testing (Using modules and "expose_test_" as a prefix for associated functions) diff --git a/crates/wykies-client-core/src/client.rs b/crates/wykies-client-core/src/client.rs new file mode 100644 index 0000000..e9d556f --- /dev/null +++ b/crates/wykies-client-core/src/client.rs @@ -0,0 +1,345 @@ +use anyhow::{anyhow, Context}; +use closure_traits::{ChannelCallBack, ChannelCallBackOutput}; +use futures::channel::oneshot; +use reqwest::{Method, StatusCode}; +use secrecy::ExposeSecret as _; +use std::fmt::Debug; +use std::sync::{Arc, Mutex}; +use tracing::info; +use wykies_shared::uac::LoginResponse; +use wykies_shared::{ + branch::Branch, + const_config::path::{PathSpec, PATH_BRANCHES, PATH_HEALTH_CHECK, PATH_LOGIN}, + req_args::LoginReqArgs, + uac::UserInfo, +}; + +pub mod api; +pub mod websocket; + +const DUMMY_ARGUMENT: &[(&str, &str)] = &[("", "")]; + +#[derive(Debug, Clone)] +pub struct Client { + api_client: reqwest::Client, + inner: Arc>, +} + +#[derive(Debug)] +struct ClientInner { + server_address: String, + user_info: Option>, +} + +impl Default for Client { + fn default() -> Self { + // TODO 3: Load url from server config into binary at compile time + // TODO 3: Add test to ensure URL starts with "http" so when we replace "http" + // with "ws" it will not be a problem. Ignore the following s as both + // would need it. Both https and wss. + Self::new("http://localhost:8789".to_string()) + } +} + +#[must_use] +#[derive(Debug, PartialEq, Eq)] +pub enum LoginOutcome { + Success, + ForcePasswordChange, + RetryWithBranchSet, +} + +impl LoginOutcome { + /// Returns `true` if the login outcome is + /// [`Success`] or [`ForcePasswordChange`] + /// + /// [`Success`]: LoginOutcome::Success + /// [`ForcePasswordChange`]: LoginOutcome::ForcePasswordChange + #[must_use] + pub fn is_any_success(&self) -> bool { + matches!(self, Self::Success) || matches!(self, Self::ForcePasswordChange) + } +} + +impl ClientInner { + #[tracing::instrument] + fn new(server_address: String) -> Self { + Self { + server_address, + user_info: None, + } + } +} + +impl Client { + #[tracing::instrument(name = "NEW CLIENT-CORE")] + pub fn new(server_address: String) -> Self { + let api_client = reqwest::Client::builder() + .cookie_store(true) + .build() + .expect("Unable to create reqwest client"); + Self { + api_client, + inner: Arc::new(Mutex::new(ClientInner::new(server_address))), + } + } + + #[tracing::instrument(skip(ui_notify))] + pub fn get_branches(&self, ui_notify: F) -> oneshot::Receiver>> + where + F: UiCallBack, + { + self.send_request_expect_json(PATH_BRANCHES, &DUMMY_ARGUMENT, ui_notify) + } + + #[tracing::instrument(skip(ui_notify))] + pub fn login( + &self, + args: LoginReqArgs, + ui_notify: F, + ) -> oneshot::Receiver> { + let (tx, rx) = oneshot::channel(); + let args = serde_json::json!({ + "username": args.username, + "password": args.password.expose_secret(), + "branch_to_set": args.branch_to_set, + }); + let client = self.clone(); + let on_done = move |resp: reqwest::Result| async { + let msg = process_login(resp, client).await; + tx.send(msg).expect("failed to send oneshot msg"); + ui_notify(); + }; + + self.initiate_request(PATH_LOGIN, &args, on_done); + rx + } + + #[tracing::instrument(skip(ui_notify))] + pub fn health_check(&self, ui_notify: F) -> oneshot::Receiver> + where + F: UiCallBack, + { + self.send_request_expect_empty(PATH_HEALTH_CHECK, &DUMMY_ARGUMENT, ui_notify) + } + + #[tracing::instrument(skip(args, on_done))] + // WARNING: Must skip args as it my contain sensitive info and "safe" versions + // would usually already be logged by the caller + fn initiate_request(&self, path_spec: PathSpec, args: &T, on_done: F) + where + T: serde::Serialize + Debug, + F: ChannelCallBack, + O: ChannelCallBackOutput, + { + let is_get_method = path_spec.method == Method::GET; + let mut request = self + .api_client + .request(path_spec.method, self.path_to_url(path_spec.path)); + request = if is_get_method { + request.query(&args) + } else { + request.json(&args) + }; + reqwest_cross::fetch(request, on_done) + } + + fn send_request_expect_json( + &self, + path_spec: PathSpec, + args: &T, + ui_notify: F, + ) -> oneshot::Receiver> + where + T: serde::Serialize + std::fmt::Debug, + F: UiCallBack, + U: Send + std::fmt::Debug + serde::de::DeserializeOwned + 'static, + { + let (tx, rx) = oneshot::channel(); + let on_done = move |resp: reqwest::Result| async { + let msg = process_json_body(resp).await; + tx.send(msg).expect("failed to send oneshot msg"); + ui_notify(); + }; + self.initiate_request(path_spec, args, on_done); + rx + } + + #[cfg(feature = "expose_test")] + pub fn expose_test_send_request_expect_json( + &self, + path_spec: PathSpec, + args: &T, + ui_notify: F, + ) -> oneshot::Receiver> + where + T: serde::Serialize + std::fmt::Debug, + F: UiCallBack, + U: Send + std::fmt::Debug + serde::de::DeserializeOwned + 'static, + { + self.send_request_expect_json(path_spec, args, ui_notify) + } + + fn send_request_expect_empty( + &self, + path_spec: PathSpec, + args: &T, + ui_notify: F, + ) -> oneshot::Receiver> + where + T: serde::Serialize + std::fmt::Debug, + F: UiCallBack, + { + let (tx, rx) = oneshot::channel(); + let on_done = move |resp: reqwest::Result| async { + let msg = process_empty(resp).await; + tx.send(msg).expect("failed to send oneshot msg"); + ui_notify(); + }; + self.initiate_request(path_spec, args, on_done); + rx + } + + fn send_request_no_wait(&self, path_spec: PathSpec, args: &T) + where + T: serde::Serialize + std::fmt::Debug, + { + self.initiate_request(path_spec, args, |_| async {}); + } + + #[tracing::instrument(ret)] + fn path_to_url(&self, path: &str) -> String { + format!( + "{}{path}", + &self + .inner + .lock() + .expect("failed to unlock client mutex") + .server_address + ) + } + + pub fn user_info(&self) -> Option> { + self.inner.lock().expect("mutex poisoned").user_info.clone() + } + + pub fn is_logged_in(&self) -> bool { + self.inner + .lock() + .expect("mutex poisoned") + .user_info + .is_some() + } +} + +#[tracing::instrument(ret, err(Debug))] +async fn process_empty(response: reqwest::Result) -> anyhow::Result<()> { + let (response, status) = extract_response(response)?; + if status == StatusCode::OK { + Ok(()) + } else { + Err(handle_error(response).await) + } +} + +#[tracing::instrument(ret, err(Debug))] +async fn process_json_body(response: reqwest::Result) -> anyhow::Result +where + T: Debug + serde::de::DeserializeOwned, +{ + let (response, status) = extract_response(response)?; + match status { + StatusCode::OK => Ok(response + .json() + .await + .context("failed to parse result as json")?), + _ => Err(handle_error(response).await), + } +} + +#[tracing::instrument(ret, err(Debug))] +async fn process_login( + response: reqwest::Result, + client: Client, +) -> anyhow::Result { + let (response, status) = extract_response(response)?; + match status { + StatusCode::OK => { + let login_response: LoginResponse = response + .json() + .await + .context("failed to parse result as json")?; + let (result, user_info) = match login_response { + LoginResponse::Success(user_info) => (LoginOutcome::Success, user_info), + LoginResponse::SuccessForcePassChange(user_info) => { + (LoginOutcome::ForcePasswordChange, user_info) + } + }; + client.inner.lock().expect("mutex poisoned").user_info = Some(Arc::new(user_info)); + Ok(result) + } + StatusCode::FAILED_DEPENDENCY => Ok(LoginOutcome::RetryWithBranchSet), + _ => Err(handle_error(response).await), + } +} + +#[tracing::instrument(ret)] +async fn handle_error(response: reqwest::Response) -> anyhow::Error { + let status = response.status(); + debug_assert!( + !status.is_success(), + "this is supposed to be an error, right? Status code is: {status}" + ); + let Ok(body) = response.text().await else { + return anyhow!("failed to get response body"); + }; + if body.is_empty() { + anyhow!("request failed with status code: {status} and no body") + } else { + anyhow!("{body}") + } +} + +/// Provides a way to standardize the error message +#[tracing::instrument(ret, err(Debug))] +fn extract_response( + response: reqwest::Result, +) -> anyhow::Result<(reqwest::Response, StatusCode)> { + if response.is_err() { + info!("Response is err: {:#?}", response); + } + let response = response.context("failed to send request")?; + let status = response.status(); + Ok((response, status)) +} + +pub trait UiCallBack: 'static + Send + FnOnce() {} +impl UiCallBack for T where T: 'static + Send + FnOnce() {} + +#[cfg(not(target_arch = "wasm32"))] +pub mod closure_traits { + pub trait ChannelCallBack: + 'static + Send + FnOnce(reqwest::Result) -> O + { + } + impl ChannelCallBack for T where + T: 'static + Send + FnOnce(reqwest::Result) -> O + { + } + pub trait ChannelCallBackOutput: futures::Future + Send {} + impl ChannelCallBackOutput for T where T: futures::Future + Send {} +} + +#[cfg(target_arch = "wasm32")] +pub mod closure_traits { + pub trait ChannelCallBack: + 'static + FnOnce(reqwest::Result) -> O + { + } + impl ChannelCallBack for T where + T: 'static + FnOnce(reqwest::Result) -> O + { + } + pub trait ChannelCallBackOutput: futures::Future {} + impl ChannelCallBackOutput for T where T: futures::Future {} +} diff --git a/crates/wykies-client-core/src/client/api.rs b/crates/wykies-client-core/src/client/api.rs new file mode 100644 index 0000000..3805d13 --- /dev/null +++ b/crates/wykies-client-core/src/client/api.rs @@ -0,0 +1,45 @@ +use futures::channel::oneshot; +use secrecy::ExposeSecret as _; +use wykies_shared::{ + const_config::path::{PATH_API_CHANGE_PASSWORD, PATH_API_LOGOUT}, + req_args::api::ChangePasswordReqArgs, +}; + +use crate::{client::UiCallBack, Client}; + +pub mod admin; + +impl Client { + #[tracing::instrument(skip(args, ui_notify))] + pub fn change_password( + &self, + args: &ChangePasswordReqArgs, + ui_notify: F, + ) -> oneshot::Receiver> + where + F: UiCallBack, + { + let args = serde_json::json!({ + "current_password": args.current_password.expose_secret(), + "new_password": args.new_password.expose_secret(), + "new_password_check": args.new_password_check.expose_secret() + }); + self.send_request_expect_empty(PATH_API_CHANGE_PASSWORD, &args, ui_notify) + } + + #[tracing::instrument(skip(ui_notify))] + pub fn logout(&self, ui_notify: F) -> oneshot::Receiver> { + self.clear_user_info(); // Clear user info even if logout fails + self.send_request_expect_empty(PATH_API_LOGOUT, &"", ui_notify) + } + + #[tracing::instrument] + pub fn logout_no_wait(&self) { + self.clear_user_info(); // Clear user info even if logout fails + self.send_request_no_wait(PATH_API_LOGOUT, &""); + } + + fn clear_user_info(&self) { + self.inner.lock().expect("mutex poisoned").user_info = None; + } +} diff --git a/crates/wykies-client-core/src/client/api/admin.rs b/crates/wykies-client-core/src/client/api/admin.rs new file mode 100644 index 0000000..f481418 --- /dev/null +++ b/crates/wykies-client-core/src/client/api/admin.rs @@ -0,0 +1,4 @@ +pub mod branch; +pub mod host_branch; +pub mod role; +pub mod user; diff --git a/crates/wykies-client-core/src/client/api/admin/branch.rs b/crates/wykies-client-core/src/client/api/admin/branch.rs new file mode 100644 index 0000000..2c82e38 --- /dev/null +++ b/crates/wykies-client-core/src/client/api/admin/branch.rs @@ -0,0 +1,17 @@ +use futures::channel::oneshot; +use wykies_shared::{ + branch::BranchDraft, const_config::path::PATH_API_ADMIN_BRANCH_CREATE, id::DbId, +}; + +use crate::{client::UiCallBack, Client}; + +impl Client { + #[tracing::instrument(skip(ui_notify))] + pub fn create_branch( + &self, + args: &BranchDraft, + ui_notify: F, + ) -> oneshot::Receiver> { + self.send_request_expect_json(PATH_API_ADMIN_BRANCH_CREATE, args, ui_notify) + } +} diff --git a/crates/wykies-client-core/src/client/api/admin/host_branch.rs b/crates/wykies-client-core/src/client/api/admin/host_branch.rs new file mode 100644 index 0000000..0b9247a --- /dev/null +++ b/crates/wykies-client-core/src/client/api/admin/host_branch.rs @@ -0,0 +1,42 @@ +use futures::channel::oneshot; +use wykies_shared::{ + const_config::path::{ + PATH_API_ADMIN_HOSTBRANCH_LIST, PATH_API_ADMIN_HOSTBRANCH_SET, PATH_API_HOSTBRANCH_LOOKUP, + }, + host_branch::HostBranchPair, + id::DbId, + req_args::api::admin::host_branch, +}; + +use crate::{ + client::{UiCallBack, DUMMY_ARGUMENT}, + Client, +}; + +impl Client { + #[tracing::instrument(skip(ui_notify))] + pub fn get_list_host_branch_pairs( + &self, + ui_notify: F, + ) -> oneshot::Receiver>> { + self.send_request_expect_json(PATH_API_ADMIN_HOSTBRANCH_LIST, &DUMMY_ARGUMENT, ui_notify) + } + + #[tracing::instrument(skip(ui_notify))] + pub fn create_host_branch_pair( + &self, + args: &HostBranchPair, + ui_notify: F, + ) -> oneshot::Receiver> { + self.send_request_expect_empty(PATH_API_ADMIN_HOSTBRANCH_SET, args, ui_notify) + } + + #[tracing::instrument(skip(ui_notify))] + pub fn get_host_branch_pair( + &self, + args: &host_branch::LookupReqArgs, + ui_notify: F, + ) -> oneshot::Receiver>> { + self.send_request_expect_json(PATH_API_HOSTBRANCH_LOOKUP, args, ui_notify) + } +} diff --git a/crates/wykies-client-core/src/client/api/admin/role.rs b/crates/wykies-client-core/src/client/api/admin/role.rs new file mode 100644 index 0000000..d478adf --- /dev/null +++ b/crates/wykies-client-core/src/client/api/admin/role.rs @@ -0,0 +1,41 @@ +use futures::channel::oneshot; +use wykies_shared::{ + const_config::path::{ + PATH_API_ADMIN_ROLE, PATH_API_ADMIN_ROLE_ASSIGN, PATH_API_ADMIN_ROLE_CREATE, + }, + id::DbId, + req_args::api::admin::role::{self, AssignReqArgs}, + uac::{Role, RoleDraft}, +}; + +use crate::{client::UiCallBack, Client}; + +impl Client { + #[tracing::instrument(skip(ui_notify))] + pub fn create_role( + &self, + args: &RoleDraft, + ui_notify: F, + ) -> oneshot::Receiver> { + self.send_request_expect_json(PATH_API_ADMIN_ROLE_CREATE, args, ui_notify) + } + + #[tracing::instrument(skip(ui_notify))] + pub fn get_role( + &self, + role_id: DbId, + ui_notify: F, + ) -> oneshot::Receiver> { + let args = role::LookupReqArgs { role_id }; + self.send_request_expect_json(PATH_API_ADMIN_ROLE, &args, ui_notify) + } + + #[tracing::instrument(skip(ui_notify))] + pub fn assign_role( + &self, + args: &AssignReqArgs, + ui_notify: F, + ) -> oneshot::Receiver> { + self.send_request_expect_empty(PATH_API_ADMIN_ROLE_ASSIGN, args, ui_notify) + } +} diff --git a/crates/wykies-client-core/src/client/api/admin/user.rs b/crates/wykies-client-core/src/client/api/admin/user.rs new file mode 100644 index 0000000..0d8e701 --- /dev/null +++ b/crates/wykies-client-core/src/client/api/admin/user.rs @@ -0,0 +1,80 @@ +use futures::channel::oneshot; +use secrecy::ExposeSecret; +use wykies_shared::{ + const_config::path::{ + PATH_API_ADMIN_USER, PATH_API_ADMIN_USERS_LIST_AND_ROLES, PATH_API_ADMIN_USER_NEW, + PATH_API_ADMIN_USER_PASSWORD_RESET, PATH_API_ADMIN_USER_UPDATE, + }, + req_args::{ + api::admin::user::{self, NewUserReqArgs, PasswordResetReqArgs}, + RonWrapper, + }, + uac::{ListUsersRoles, UserMetadata, UserMetadataDiff, Username}, +}; + +use crate::{ + client::{UiCallBack, DUMMY_ARGUMENT}, + Client, +}; + +impl Client { + #[tracing::instrument(skip(ui_notify))] + pub fn get_user( + &self, + username: Username, + ui_notify: F, + ) -> oneshot::Receiver> { + let args = user::LookupReqArgs { username }; + self.send_request_expect_json(PATH_API_ADMIN_USER, &args, ui_notify) + } + + #[tracing::instrument(skip(ui_notify))] + pub fn new_user( + &self, + user: NewUserReqArgs, + ui_notify: F, + ) -> oneshot::Receiver> { + let args = serde_json::json!({ + "username": user.username, + "display_name": user.display_name, + "password": user.password.expose_secret(), + "assigned_role": user.assigned_role + }); + self.send_request_expect_empty(PATH_API_ADMIN_USER_NEW, &args, ui_notify) + } + + #[tracing::instrument(skip(ui_notify))] + pub fn reset_password( + &self, + args: PasswordResetReqArgs, + ui_notify: F, + ) -> oneshot::Receiver> { + let args = serde_json::json!({ + "username": args.username, + "new_password": args.new_password.expose_secret() + }); + self.send_request_expect_empty(PATH_API_ADMIN_USER_PASSWORD_RESET, &args, ui_notify) + } + + #[tracing::instrument(skip(ui_notify))] + pub fn update_user( + &self, + diff: UserMetadataDiff, + ui_notify: F, + ) -> oneshot::Receiver> { + let wrapped = RonWrapper::new(&diff).expect("failed to create ron wrapper"); + self.send_request_expect_empty(PATH_API_ADMIN_USER_UPDATE, &wrapped, ui_notify) + } + + #[tracing::instrument(skip(ui_notify))] + pub fn list_users_and_roles( + &self, + ui_notify: F, + ) -> oneshot::Receiver> { + self.send_request_expect_json( + PATH_API_ADMIN_USERS_LIST_AND_ROLES, + &DUMMY_ARGUMENT, + ui_notify, + ) + } +} diff --git a/crates/wykies-client-core/src/client/websocket.rs b/crates/wykies-client-core/src/client/websocket.rs new file mode 100644 index 0000000..56c2a1c --- /dev/null +++ b/crates/wykies-client-core/src/client/websocket.rs @@ -0,0 +1,145 @@ +use std::fmt::Debug; + +use anyhow::{bail, Context as _}; +use ewebsock::WsEvent; +use futures::channel::oneshot; +use wykies_shared::{ + const_config::path::{PathSpec, PATH_WS_PREFIX}, + token::AuthToken, +}; + +use crate::Client; + +use super::{process_json_body, DUMMY_ARGUMENT}; + +const WS_CONNECTION_PREFIX: &str = "/ws"; + +pub struct WebSocketConnection { + pub tx: ewebsock::WsSender, + pub rx: ewebsock::WsReceiver, +} + +pub trait WakeFn: Fn() + Send + Sync + 'static {} +impl WakeFn for T where T: Fn() + Send + Sync + 'static {} + +impl Client { + #[tracing::instrument(skip(wake_up))] + pub fn ws_connect( + &self, + path_spec: PathSpec, + wake_up: F, + ) -> oneshot::Receiver> { + let (tx, rx) = oneshot::channel(); + let ws_url = self.ws_url_from(&path_spec); + let on_done = move |resp: reqwest::Result| async { + let result = do_connect_ws(resp, ws_url, wake_up).await; + tx.send(result).expect("failed to send oneshot msg"); + }; + self.initiate_request(path_spec, &DUMMY_ARGUMENT, on_done); + rx + } + + /// Appends `path` onto the base websocket url + /// + /// # Panic + /// + /// Panics if the server_address does not start with "http" + #[tracing::instrument(ret)] + fn ws_url_from(&self, path_spec: &PathSpec) -> String { + assert_eq!(&path_spec.path[..PATH_WS_PREFIX.len()], PATH_WS_PREFIX); + let suffix = &path_spec.path[PATH_WS_PREFIX.len()..]; + let mut result = "ws".to_string(); + { + let guard = self.inner.lock().expect("client-core mutex poisoned"); + let server_address = &guard.server_address; + assert!(server_address.starts_with("http")); + result.push_str(&server_address[4..]); + } + result.push_str(WS_CONNECTION_PREFIX); + result.push_str(suffix); + result + } + + #[cfg(feature = "expose_test")] + pub fn expose_test_ws_url_from(&self, path_spec: &PathSpec) -> String { + self.ws_url_from(path_spec) + } +} + +#[tracing::instrument(skip(wake_up))] +async fn do_connect_ws( + response: reqwest::Result, + ws_url: String, + wake_up: F, +) -> anyhow::Result { + // Get token from response passed in + let token = extract_token(response).await?; + + // Initiate connection + let mut result = initiate_ws_connection(ws_url, wake_up)?; + + // Wait for connection to complete before sending token + wait_for_connection_to_open(&mut result).await?; + + // Send token + result.tx.send(token.into()); + + Ok(result) +} + +async fn wait_for_connection_to_open(conn: &mut WebSocketConnection) -> anyhow::Result<()> { + let event = loop { + if let Some(m) = conn.rx.try_recv() { + break m; + } else { + reqwest_cross::yield_now().await; + } + }; + if matches!(event, WsEvent::Opened) { + Ok(()) + } else { + bail!("expected first event to be opened but got {event:?}") + } +} + +async fn extract_token(response: reqwest::Result) -> anyhow::Result { + process_json_body(response).await +} + +fn initiate_ws_connection(ws_url: String, wake_up: F) -> anyhow::Result +where + F: WakeFn, +{ + let (tx, rx) = ewebsock::connect_with_wakeup(ws_url, Default::default(), wake_up) + .map_err(|e| anyhow::anyhow!("{e}")) + .context("failed to connect web socket")?; + Ok(WebSocketConnection { tx, rx }) +} + +impl Debug for WebSocketConnection { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "WebSocketConnection {{ .. }} ") + } +} + +#[cfg(feature = "expose_test")] +pub mod expose_test { + + use super::{WakeFn, WebSocketConnection}; + + pub const EXPOSE_TEST_DUMMY_ARGUMENT: &[(&str, &str)] = crate::client::DUMMY_ARGUMENT; + + pub fn initiate_ws_connection( + ws_url: String, + wake_up: F, + ) -> anyhow::Result + where + F: WakeFn, + { + super::initiate_ws_connection(ws_url, wake_up) + } + + pub async fn wait_for_connection_to_open(conn: &mut WebSocketConnection) -> anyhow::Result<()> { + super::wait_for_connection_to_open(conn).await + } +} diff --git a/crates/wykies-client-core/src/lib.rs b/crates/wykies-client-core/src/lib.rs new file mode 100644 index 0000000..b0e01cb --- /dev/null +++ b/crates/wykies-client-core/src/lib.rs @@ -0,0 +1,20 @@ +//! Stores functionality that should be shared between different clients +//! NB: The assumption is made that the async runtime has already been started +//! before any functions from this library are called + +#![warn(unused_crate_dependencies)] + +#[cfg(test)] // Included to prevent unused crate warning +mod warning_suppress { + use wasm_bindgen_test as _; +} + +mod client; + +pub use client::{ + websocket::{WakeFn, WebSocketConnection}, + Client, LoginOutcome, UiCallBack, +}; + +#[cfg(feature = "expose_test")] +pub use client::websocket::expose_test as ws_expose_test; diff --git a/crates/wykies-client-core/tests/web_login.rs b/crates/wykies-client-core/tests/web_login.rs new file mode 100644 index 0000000..4e66ac7 --- /dev/null +++ b/crates/wykies-client-core/tests/web_login.rs @@ -0,0 +1,63 @@ +//! IMPORTANT!!! +//! A server must be started up on localhost separately (Will not work in CI due +//! to IPv6). Only intended for local testing. +//! From the folder "crates/wykies-server" run `cargo run --features disable-cors` +//! to start the server. Then from the folder "crates/wykies-client-core" run one +//! of the following to execute the tests +//! - `wasm-pack test --headless --firefox` +//! - `wasm-pack test --headless --chrome` +use wasm_bindgen_test::wasm_bindgen_test; +use wasm_bindgen_test::wasm_bindgen_test_configure; +use wykies_client_core::{Client, LoginOutcome}; +use wykies_shared::const_config::path::PATH_WS_TOKEN_CHAT; +use wykies_shared::req_args::LoginReqArgs; + +wasm_bindgen_test_configure!(run_in_browser); +fn main() { + #[wasm_bindgen_test] + async fn login_logout_round_trip() { + // Arrange + // ASSUMING SERVER HAS BEEN STARTED (See module docs comment) + let client = Client::default(); + let login_args = LoginReqArgs::new_with_branch( + "seed_admin".to_string(), + "f".to_string().into(), + 1.into(), + ); + + // Assert - Ensure not logged in + assert!( + !is_logged_in(&client).await, + "should not be logged in before logging in" + ); + + // Act - Login + let login_outcome = client.login(login_args.clone(), no_cb).await.unwrap(); + + // Assert - Login successful and user info stored + assert_eq!( + login_outcome + .expect("IMPORTANT!!! ensure server is started properly see module doc comment"), + LoginOutcome::ForcePasswordChange + ); + assert_eq!( + client.user_info().unwrap().username.as_ref(), + &login_args.username + ); + // Unable to go further likely because the sensitive headers are being + // sanitized at some point. This includes the cookies. + // Still keeping this test to check for basic functionality and testing + // for deadlocks + } +} + +async fn is_logged_in(client: &Client) -> bool { + // Also tests if able to establish a websocket connection but this was the simplest alternative that didn't need any permissions + client + .ws_connect(PATH_WS_TOKEN_CHAT, no_cb) + .await + .expect("failed to receive on rx") + .is_ok() +} + +fn no_cb() {} diff --git a/crates/wykies-server/.env b/crates/wykies-server/.env index 2cf730f..1366170 100644 --- a/crates/wykies-server/.env +++ b/crates/wykies-server/.env @@ -1,2 +1,2 @@ -DATABASE_URL="mysql://db_user:password@localhost:3306/parts" +DATABASE_URL="mysql://db_user:password@localhost:3306/chat_demo" SQLX_OFFLINE=true \ No newline at end of file