diff --git a/.env.sample b/.env.sample new file mode 100644 index 0000000..135f9aa --- /dev/null +++ b/.env.sample @@ -0,0 +1 @@ +DATABASE_URL=postgres://username:password@localhost/database_name diff --git a/.gitea/workflows/tag_release.yml b/.gitea/workflows/tag_release.yml index 0ef5ff7..ca7ddef 100644 --- a/.gitea/workflows/tag_release.yml +++ b/.gitea/workflows/tag_release.yml @@ -19,7 +19,7 @@ jobs: - name: Install Rust uses: actions-rs/toolchain@v1 with: - toolchain: 1.85.0 + toolchain: 1.86.0 components: cargo - name: Extract Version from Cargo.toml diff --git a/.gitea/workflows/workflow.yml b/.gitea/workflows/workflow.yml index d216362..557a245 100644 --- a/.gitea/workflows/workflow.yml +++ b/.gitea/workflows/workflow.yml @@ -18,33 +18,72 @@ jobs: - uses: actions/checkout@v4 - uses: actions-rust-lang/setup-rust-toolchain@v1 with: - toolchain: 1.85.0 + toolchain: 1.86.0 - run: | mkdir -p ~/.ssh - echo "${{ secrets.MYREPO_TOKEN }}" > ~/.ssh/gitlab_deploy_key - chmod 600 ~/.ssh/gitlab_deploy_key + echo "${{ secrets.MYREPO_TOKEN }}" > ~/.ssh/icarus_models_deploy_key + chmod 600 ~/.ssh/icarus_models_deploy_key ssh-keyscan ${{ vars.MYHOST }} >> ~/.ssh/known_hosts eval $(ssh-agent -s) - ssh-add -v ~/.ssh/gitlab_deploy_key + ssh-add -v ~/.ssh/icarus_models_deploy_key + cargo check test: name: Test Suite runs-on: ubuntu-24.04 + # --- Add database service definition --- + services: + postgres: + image: postgres:17.4 # Or pin to a more specific version like 14.9 + env: + # Use secrets for DB init, with fallbacks for flexibility + POSTGRES_USER: ${{ secrets.DB_TEST_USER || 'testuser' }} + POSTGRES_PASSWORD: ${{ secrets.DB_TEST_PASSWORD || 'testpassword' }} + POSTGRES_DB: ${{ secrets.DB_TEST_NAME || 'testdb' }} + # Options to wait until the database is ready + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + steps: - uses: actions/checkout@v4 - uses: actions-rust-lang/setup-rust-toolchain@v1 with: - toolchain: 1.85.0 - - run: | + toolchain: 1.86.0 + # --- Add this step for explicit verification --- + - name: Verify Docker Environment + run: | + echo "Runner User Info:" + id + echo "Checking Docker Version:" + docker --version + echo "Checking Docker Daemon Status (info):" + docker info + echo "Checking Docker Daemon Status (ps):" + docker ps -a + echo "Docker environment check complete." + # NOTE: Do NOT use continue-on-error here. + # If Docker isn't working as expected, the job SHOULD fail here. + - name: Run tests + env: + # Define DATABASE_URL for tests to use + DATABASE_URL: postgresql://${{ secrets.DB_TEST_USER || 'testuser' }}:${{ secrets.DB_TEST_PASSWORD || 'testpassword' }}@postgres:5432/${{ secrets.DB_TEST_NAME || 'testdb' }} + RUST_LOG: info # Optional: configure test log level + # Make SSH agent available if tests fetch private dependencies + SSH_AUTH_SOCK: ${{ env.SSH_AUTH_SOCK }} + run: | mkdir -p ~/.ssh - echo "${{ secrets.MYREPO_TOKEN }}" > ~/.ssh/gitlab_deploy_key - chmod 600 ~/.ssh/gitlab_deploy_key + echo "${{ secrets.MYREPO_TOKEN }}" > ~/.ssh/icarus_models_deploy_key + chmod 600 ~/.ssh/icarus_models_deploy_key ssh-keyscan ${{ vars.MYHOST }} >> ~/.ssh/known_hosts eval $(ssh-agent -s) - ssh-add -v ~/.ssh/gitlab_deploy_key + ssh-add -v ~/.ssh/icarus_models_deploy_key + cargo test fmt: @@ -54,16 +93,16 @@ jobs: - uses: actions/checkout@v4 - uses: actions-rust-lang/setup-rust-toolchain@v1 with: - toolchain: 1.85.0 + toolchain: 1.86.0 - run: rustup component add rustfmt - run: | mkdir -p ~/.ssh - echo "${{ secrets.MYREPO_TOKEN }}" > ~/.ssh/gitlab_deploy_key - chmod 600 ~/.ssh/gitlab_deploy_key + echo "${{ secrets.MYREPO_TOKEN }}" > ~/.ssh/icarus_models_deploy_key + chmod 600 ~/.ssh/icarus_models_deploy_key ssh-keyscan ${{ vars.MYHOST }} >> ~/.ssh/known_hosts eval $(ssh-agent -s) - ssh-add -v ~/.ssh/gitlab_deploy_key + ssh-add -v ~/.ssh/icarus_models_deploy_key cargo fmt --all -- --check clippy: @@ -73,16 +112,16 @@ jobs: - uses: actions/checkout@v4 - uses: actions-rust-lang/setup-rust-toolchain@v1 with: - toolchain: 1.85.0 + toolchain: 1.86.0 - run: rustup component add clippy - run: | mkdir -p ~/.ssh - echo "${{ secrets.MYREPO_TOKEN }}" > ~/.ssh/gitlab_deploy_key - chmod 600 ~/.ssh/gitlab_deploy_key + echo "${{ secrets.MYREPO_TOKEN }}" > ~/.ssh/icarus_models_deploy_key + chmod 600 ~/.ssh/icarus_models_deploy_key ssh-keyscan ${{ vars.MYHOST }} >> ~/.ssh/known_hosts eval $(ssh-agent -s) - ssh-add -v ~/.ssh/gitlab_deploy_key + ssh-add -v ~/.ssh/icarus_models_deploy_key cargo clippy -- -D warnings build: @@ -92,14 +131,13 @@ jobs: - uses: actions/checkout@v4 - uses: actions-rust-lang/setup-rust-toolchain@v1 with: - toolchain: 1.85.0 + toolchain: 1.86.0 - run: | mkdir -p ~/.ssh - echo "${{ secrets.MYREPO_TOKEN }}" > ~/.ssh/gitlab_deploy_key - chmod 600 ~/.ssh/gitlab_deploy_key + echo "${{ secrets.MYREPO_TOKEN }}" > ~/.ssh/icarus_models_deploy_key + chmod 600 ~/.ssh/icarus_models_deploy_key ssh-keyscan ${{ vars.MYHOST }} >> ~/.ssh/known_hosts eval $(ssh-agent -s) - ssh-add -v ~/.ssh/gitlab_deploy_key + ssh-add -v ~/.ssh/icarus_models_deploy_key cargo build --release - diff --git a/.gitignore b/.gitignore index 96ef6c0..e551aa3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ /target Cargo.lock +.env diff --git a/Cargo.toml b/Cargo.toml index d27538f..59fd2d9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,12 +1,27 @@ [package] name = "icarus_auth" -version = "0.1.0" +version = "0.2.0" edition = "2024" +rust-version = "1.86" [dependencies] axum = { version = "0.8.3" } serde = { version = "1.0.218", features = ["derive"] } serde_json = { version = "1.0.139" } tokio = { version = "1.44.1", features = ["rt-multi-thread"] } -tracing-subscriber = "0.3.19" -icarus_models = { git = "ssh://git@git.kundeng.us/phoenix/icarus_models.git", tag = "v0.2.0" } +tracing-subscriber = { version = "0.3.19" } +tower = { version = "0.5.2" } +hyper = { version = "1.6.0" } +sqlx = { version = "0.8.3", features = ["postgres", "runtime-tokio-native-tls", "time", "uuid"] } +dotenvy = { version = "0.15.7" } +uuid = { version = "1.16.0", features = ["v4", "serde"] } +argon2 = { version = "0.5.3", features = ["std"] } # Use the latest 0.5.x version +rand = { version = "0.9" } +time = { version = "0.3.41", features = ["macros", "serde"] } +icarus_models = { git = "ssh://git@git.kundeng.us/phoenix/icarus_models.git", tag = "v0.4.0" } + +[dev-dependencies] +http-body-util = { version = "0.1.3" } +url = { version = "2.5" } +reqwest = { version = "0.12.5", features = ["json"] } # For making HTTP requests in tests +once_cell = { version = "1.19" } # Useful for lazy initialization in tests/app setup diff --git a/migrations/20250402221858_init_migrate.sql b/migrations/20250402221858_init_migrate.sql new file mode 100644 index 0000000..8fe068c --- /dev/null +++ b/migrations/20250402221858_init_migrate.sql @@ -0,0 +1,22 @@ +-- Add migration script here +CREATE EXTENSION IF NOT EXISTS pgcrypto; + +CREATE TABLE IF NOT EXISTS "user" ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + username TEXT NOT NULL, + password TEXT NOT NULL, + email TEXT NOT NULL, + phone TEXT NOT NULL, + firstname TEXT NOT NULL, + lastname TEXT NOT NULL, + email_verified BOOL NOT NULL, + date_created TIMESTAMPTZ NOT NULL DEFAULT NOW(), + status TEXT NOT NULL, + last_login TIMESTAMPTZ NULL DEFAULT NOW(), + salt_id UUID NOT NULL +); + +CREATE TABLE IF NOT EXISTS "salt" ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + salt TEXT NOT NULL +); diff --git a/run_migrations.txt b/run_migrations.txt new file mode 100644 index 0000000..927b280 --- /dev/null +++ b/run_migrations.txt @@ -0,0 +1,25 @@ +# Make sure role has CREATEDB +ALTER ROLE username_that_needs_permission CREATEDB; + +# Install migrations +cargo install sqlx-cli + +# Make sure to populate DATABASE_URL with correct value. +# By default, the DATABASE_URL found in .env file will be used +export DATABASE_URL="postgres://icarus_op_test:password@localhost/icarus_auth_test" + +# init +sqlx migrate add init_migration +sqlx migrate run + +# Create +sqlx database create + +# Drop +sqlx database drop + +# setup +sqlx database setup + +# Reset +sqlx database reset diff --git a/src/callers/common.rs b/src/callers/common.rs new file mode 100644 index 0000000..afb5ffb --- /dev/null +++ b/src/callers/common.rs @@ -0,0 +1,30 @@ +use axum::{Extension, Json, http::StatusCode}; + +use serde::{Deserialize, Serialize}; + +#[derive(Deserialize, Serialize)] +pub struct TestResult { + message: String, +} + +// basic handler that responds with a static string +pub async fn root() -> &'static str { + "Hello, World!" +} + +pub async fn db_ping(Extension(pool): Extension) -> (StatusCode, Json) { + match sqlx::query("SELECT 1").execute(&pool).await { + Ok(_) => { + let tr = TestResult { + message: String::from("This works"), + }; + (StatusCode::OK, Json(tr)) + } + Err(e) => ( + StatusCode::BAD_REQUEST, + Json(TestResult { + message: e.to_string(), + }), + ), + } +} diff --git a/src/callers/mod.rs b/src/callers/mod.rs new file mode 100644 index 0000000..33ddec1 --- /dev/null +++ b/src/callers/mod.rs @@ -0,0 +1,8 @@ +pub mod common; +pub mod register; + +pub mod endpoints { + pub const ROOT: &str = "/"; + pub const REGISTER: &str = "/api/v2/register"; + pub const DBTEST: &str = "/api/v2/test/db"; +} diff --git a/src/callers/register.rs b/src/callers/register.rs new file mode 100644 index 0000000..b9d07e4 --- /dev/null +++ b/src/callers/register.rs @@ -0,0 +1,105 @@ +use axum::{Json, http::StatusCode}; + +use crate::hashing; +use crate::repo; + +pub mod request { + use serde::{Deserialize, Serialize}; + + #[derive(Default, Deserialize, Serialize)] + pub struct Request { + #[serde(skip_serializing_if = "String::is_empty")] + pub username: String, + #[serde(skip_serializing_if = "String::is_empty")] + pub password: String, + #[serde(skip_serializing_if = "String::is_empty")] + pub email: String, + #[serde(skip_serializing_if = "String::is_empty")] + pub phone: String, + #[serde(skip_serializing_if = "String::is_empty")] + pub firstname: String, + #[serde(skip_serializing_if = "String::is_empty")] + pub lastname: String, + } +} + +pub mod response { + use serde::{Deserialize, Serialize}; + + #[derive(Deserialize, Serialize)] + pub struct Response { + pub message: String, + pub data: Vec, + } +} + +pub async fn register_user( + axum::Extension(pool): axum::Extension, + Json(payload): Json, +) -> (StatusCode, Json) { + let mut user = icarus_models::user::User { + id: uuid::Uuid::nil(), + username: payload.username.clone(), + password: payload.password.clone(), + email: payload.email.clone(), + phone: payload.phone.clone(), + firstname: payload.firstname.clone(), + lastname: payload.lastname.clone(), + status: String::from("Active"), + email_verified: true, + date_created: Some(time::OffsetDateTime::now_utc()), + last_login: None, + salt_id: uuid::Uuid::nil(), + }; + + match repo::user::exists(&pool, &user.username).await { + Ok(res) => { + if res { + ( + StatusCode::NOT_FOUND, + Json(response::Response { + message: String::from("Error"), + data: vec![user], + }), + ) + } else { + let salt_string = hashing::generate_salt().unwrap(); + let mut salt = icarus_models::user::salt::Salt::default(); + let generated_salt = salt_string; + salt.salt = generated_salt.to_string(); + salt.id = repo::salt::insert(&pool, &salt).await.unwrap(); + user.salt_id = salt.id; + let hashed_password = + hashing::hash_password(&user.password, &generated_salt).unwrap(); + user.password = hashed_password; + + match repo::user::insert(&pool, &user).await { + Ok(id) => { + user.id = id; + ( + StatusCode::CREATED, + Json(response::Response { + message: String::from("User created"), + data: vec![user], + }), + ) + } + Err(err) => ( + StatusCode::BAD_REQUEST, + Json(response::Response { + message: err.to_string(), + data: vec![user], + }), + ), + } + } + } + Err(err) => ( + StatusCode::BAD_REQUEST, + Json(response::Response { + message: err.to_string(), + data: vec![user], + }), + ), + } +} diff --git a/src/config/mod.rs b/src/config/mod.rs new file mode 100644 index 0000000..f34b535 --- /dev/null +++ b/src/config/mod.rs @@ -0,0 +1,10 @@ +pub fn get_full() -> String { + get_address() + ":" + &get_port() +} +fn get_address() -> String { + String::from("0.0.0.0") +} + +fn get_port() -> String { + String::from("3000") +} diff --git a/src/hashing/mod.rs b/src/hashing/mod.rs new file mode 100644 index 0000000..1386d0c --- /dev/null +++ b/src/hashing/mod.rs @@ -0,0 +1,80 @@ +use argon2::{ + Argon2, // The Argon2 algorithm struct + PasswordVerifier, + password_hash::{ + PasswordHasher, + SaltString, + rand_core::OsRng, // Secure random number generator + }, +}; + +pub fn generate_salt() -> Result { + // Generate a random salt + // SaltString::generate uses OsRng internally for cryptographic security + let salt = SaltString::generate(&mut OsRng); + Ok(salt) +} + +pub fn hash_password( + password: &String, + salt: &SaltString, +) -> Result { + let password_bytes = password.as_bytes(); + + // Create an Argon2 instance with default parameters (recommended) + // You could customize parameters here if needed, but defaults are strong + let argon2 = Argon2::default(); + + // Hash the password with the salt + // The output is a PasswordHash string format that includes algorithm, version, + // parameters, salt, and the hash itself. + let password_hash = argon2.hash_password(password_bytes, salt)?.to_string(); + + Ok(password_hash) +} + +pub fn verify_password( + password_attempt: &String, + stored_hash: String, +) -> Result { + let password_bytes = password_attempt.as_bytes(); + + // Parse the stored hash string + // This extracts the salt, parameters, and hash digest + let parsed_hash = argon2::PasswordHash::new(stored_hash.as_str())?; + + // Create an Argon2 instance (it will use the parameters from the parsed hash) + let argon2 = Argon2::default(); + + // Verify the password against the parsed hash + // This automatically uses the correct salt and parameters embedded in `parsed_hash` + match argon2.verify_password(password_bytes, &parsed_hash) { + Ok(()) => Ok(true), // Passwords match + Err(argon2::password_hash::Error::Password) => Ok(false), // Passwords don't match + Err(e) => Err(e), // Some other error occurred (e.g., invalid hash format) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_hash_password() { + let some_password = String::from("somethingrandom"); + let salt = generate_salt().unwrap(); + match hash_password(&some_password, &salt) { + Ok(p) => match verify_password(&some_password, p.clone()) { + Ok(res) => { + assert_eq!(res, true); + } + Err(err) => { + assert!(false, "Error: {:?}", err.to_string()); + } + }, + Err(eerr) => { + assert!(false, "Error: {:?}", eerr.to_string()); + } + } + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..1e67995 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,41 @@ +pub mod callers; +pub mod config; +pub mod hashing; +pub mod repo; + +pub mod keys { + pub const DBURL: &str = "DATABASE_URL"; + + pub mod error { + pub const ERROR: &str = "DATABASE_URL must be set in .env"; + } +} + +mod connection_settings { + pub const MAXCONN: u32 = 5; +} + +pub mod db_pool { + + use sqlx::postgres::PgPoolOptions; + use std::env; + + use crate::{connection_settings, keys}; + + pub async fn create_pool() -> Result { + let database_url = get_db_url().await; + println!("Database url: {:?}", database_url); + + PgPoolOptions::new() + .max_connections(connection_settings::MAXCONN) + .connect(&database_url) + .await + } + + async fn get_db_url() -> String { + #[cfg(debug_assertions)] // Example: Only load .env in debug builds + dotenvy::dotenv().ok(); + + env::var(keys::DBURL).expect(keys::error::ERROR) + } +} diff --git a/src/main.rs b/src/main.rs index 6a44cd6..da52960 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,41 +1,247 @@ -use axum::{ - // Json, - Router, - // http::StatusCode, - routing::get, - // routing::{get, post}, -}; -// use serde::{Deserialize, Serialize}; +use icarus_auth::callers; +use icarus_auth::config; #[tokio::main] async fn main() { // initialize tracing tracing_subscriber::fmt::init(); - // build our application with a route - let app = Router::new() - // `GET /` goes to `root` - .route("/", get(root)); - // `POST /users` goes to `create_user` - // .route("/users", post(create_user)); + let app = init::app().await; // run our app with hyper, listening globally on port 3000 - let listener = tokio::net::TcpListener::bind(get_full()).await.unwrap(); + let url = config::get_full(); + let listener = tokio::net::TcpListener::bind(url).await.unwrap(); axum::serve(listener, app).await.unwrap(); } -fn get_full() -> String { - get_address() + ":" + &get_port() -} -fn get_address() -> String { - String::from("0.0.0.0") +mod db { + pub async fn migrations(pool: &sqlx::PgPool) { + // Run migrations using the sqlx::migrate! macro + // Assumes your migrations are in a ./migrations folder relative to Cargo.toml + sqlx::migrate!("./migrations") + .run(pool) + .await + .expect("Failed to run migrations on testcontainer DB"); + } } -fn get_port() -> String { - String::from("3000") +mod init { + use axum::{ + Router, + routing::{get, post}, + }; + + use crate::callers; + use crate::db; + + pub async fn routes() -> Router { + // build our application with a route + Router::new() + .route(callers::endpoints::DBTEST, get(callers::common::db_ping)) + .route(callers::endpoints::ROOT, get(callers::common::root)) + .route( + callers::endpoints::REGISTER, + post(callers::register::register_user), + ) + } + + pub async fn app() -> Router { + let pool = icarus_auth::db_pool::create_pool() + .await + .expect("Failed to create pool"); + + db::migrations(&pool).await; + + routes().await.layer(axum::Extension(pool)) + } } -// basic handler that responds with a static string -async fn root() -> &'static str { - "Hello, World!" +#[cfg(test)] +mod tests { + use super::*; + + use axum::{ + body::Body, + http::{Request, StatusCode}, + }; + use http_body_util::BodyExt; + use serde_json::json; + use tower::ServiceExt; // for `call`, `oneshot`, and `ready` + + mod db_mgr { + use std::str::FromStr; + + use icarus_auth::keys; + + pub const LIMIT: usize = 6; + + pub async fn get_pool() -> Result { + let tm_db_url = std::env::var(keys::DBURL).expect("DATABASE_URL must be present"); + let tm_options = sqlx::postgres::PgConnectOptions::from_str(&tm_db_url).unwrap(); + sqlx::PgPool::connect_with(tm_options).await + } + + pub async fn generate_db_name() -> String { + let db_name = + get_database_name().unwrap() + &"_" + &uuid::Uuid::new_v4().to_string()[..LIMIT]; + db_name + } + + pub async fn connect_to_db(db_name: &str) -> Result { + let db_url = std::env::var(keys::DBURL).expect("DATABASE_URL must be set for tests"); + let options = sqlx::postgres::PgConnectOptions::from_str(&db_url)?.database(db_name); + sqlx::PgPool::connect_with(options).await + } + + pub async fn create_database( + template_pool: &sqlx::PgPool, + db_name: &str, + ) -> Result<(), sqlx::Error> { + let create_query = format!("CREATE DATABASE {}", db_name); + match sqlx::query(&create_query).execute(template_pool).await { + Ok(_) => Ok(()), + Err(e) => Err(e), + } + } + + // Function to drop a database + pub async fn drop_database( + template_pool: &sqlx::PgPool, + db_name: &str, + ) -> Result<(), sqlx::Error> { + let drop_query = format!("DROP DATABASE IF EXISTS {} WITH (FORCE)", db_name); + sqlx::query(&drop_query).execute(template_pool).await?; + Ok(()) + } + + pub fn get_database_name() -> Result> { + dotenvy::dotenv().ok(); // Load .env file if it exists + + match std::env::var(keys::DBURL) { + Ok(database_url) => { + let parsed_url = url::Url::parse(&database_url)?; + if parsed_url.scheme() == "postgres" || parsed_url.scheme() == "postgresql" { + match parsed_url + .path_segments() + .and_then(|segments| segments.last().map(|s| s.to_string())) + { + Some(sss) => Ok(sss), + None => Err("Error parsing".into()), + } + } else { + // Handle other database types if needed + Err("Error parsing".into()) + } + } + Err(_) => { + // DATABASE_URL environment variable not found + Err("Error parsing".into()) + } + } + } + } + + #[tokio::test] + async fn test_hello_world() { + let app = init::app().await; + + // `Router` implements `tower::Service>` so we can + // call it like any tower service, no need to run an HTTP server. + let response = app + .oneshot( + Request::builder() + .uri(callers::endpoints::ROOT) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + + let body = response.into_body().collect().await.unwrap().to_bytes(); + assert_eq!(&body[..], b"Hello, World!"); + } + + #[tokio::test] + async fn test_register_user() { + let tm_pool = db_mgr::get_pool().await.unwrap(); + + let db_name = db_mgr::generate_db_name().await; + + match db_mgr::create_database(&tm_pool, &db_name).await { + Ok(_) => { + println!("Success"); + } + Err(e) => { + assert!(false, "Error: {:?}", e.to_string()); + } + } + + let pool = db_mgr::connect_to_db(&db_name).await.unwrap(); + + db::migrations(&pool).await; + + let app = init::routes().await.layer(axum::Extension(pool)); + + let usr = icarus_auth::callers::register::request::Request { + username: String::from("somethingsss"), + password: String::from("Raindown!"), + email: String::from("dev@null.com"), + phone: String::from("1234567890"), + firstname: String::from("Bob"), + lastname: String::from("Smith"), + }; + + let payload = json!({ + "username": &usr.username, + "password": &usr.password, + "email": &usr.email, + "phone": &usr.phone, + "firstname": &usr.firstname, + "lastname": &usr.lastname, + }); + + let response = app + .oneshot( + Request::builder() + .method(axum::http::Method::POST) + .uri(callers::endpoints::REGISTER) + .header(axum::http::header::CONTENT_TYPE, "application/json") + .body(Body::from(payload.to_string())) + .unwrap(), + ) + .await; + + match response { + Ok(resp) => { + assert_eq!( + resp.status(), + StatusCode::CREATED, + "Message: {:?} {:?}", + resp, + usr.username + ); + let body = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .unwrap(); + let parsed_body: callers::register::response::Response = + serde_json::from_slice(&body).unwrap(); + let returned_usr = &parsed_body.data[0]; + + assert_eq!(false, returned_usr.id.is_nil(), "Id is not populated"); + + assert_eq!( + usr.username, returned_usr.username, + "Usernames do not match" + ); + assert!(returned_usr.date_created.is_some(), "Date Created is empty"); + } + Err(err) => { + assert!(false, "Error: {:?}", err.to_string()); + } + }; + + let _ = db_mgr::drop_database(&tm_pool, &db_name).await; + } } diff --git a/src/repo/mod.rs b/src/repo/mod.rs new file mode 100644 index 0000000..049a840 --- /dev/null +++ b/src/repo/mod.rs @@ -0,0 +1,104 @@ +pub mod user { + use sqlx::Row; + + #[derive(Debug, serde::Serialize, sqlx::FromRow)] + pub struct InsertedData { + pub id: uuid::Uuid, + pub date_created: Option, + } + + pub async fn exists(pool: &sqlx::PgPool, username: &String) -> Result { + let result = sqlx::query( + r#" + SELECT 1 FROM "user" WHERE username = $1 + "#, + ) + .bind(username) + .fetch_optional(pool) + .await; + + match result { + Ok(r) => Ok(r.is_some()), + Err(e) => Err(e), + } + } + + pub async fn insert( + pool: &sqlx::PgPool, + user: &icarus_models::user::User, + ) -> Result { + let row = sqlx::query( + r#" + INSERT INTO "user" (username, password, email, phone, firstname, lastname, email_verified, status, salt_id) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + RETURNING id, date_created; + "#) + .bind(&user.username) + .bind(&user.password) + .bind(&user.email) + .bind(&user.phone) + .bind(&user.firstname) + .bind(&user.lastname) + .bind(user.email_verified) + .bind(&user.status) + .bind(user.salt_id) + .fetch_one(pool) + .await + .map_err(|e| { + eprintln!("Error inserting item: {}", e); + e + })?; + + let result = InsertedData { + id: row.try_get("id").map_err(|_e| sqlx::Error::RowNotFound)?, + date_created: row + .try_get("date_created") + .map_err(|_e| sqlx::Error::RowNotFound)?, + }; + + if !result.id.is_nil() { + Ok(result.id) + } else { + Err(sqlx::Error::RowNotFound) + } + } +} + +pub mod salt { + use sqlx::Row; + + #[derive(Debug, serde::Serialize, sqlx::FromRow)] + pub struct InsertedData { + pub id: uuid::Uuid, + } + + pub async fn insert( + pool: &sqlx::PgPool, + salt: &icarus_models::user::salt::Salt, + ) -> Result { + let row = sqlx::query( + r#" + INSERT INTO "salt" (salt) + VALUES ($1) + RETURNING id; + "#, + ) + .bind(&salt.salt) + .fetch_one(pool) + .await + .map_err(|e| { + eprintln!("Error inserting item: {}", e); + e + })?; + + let result = InsertedData { + id: row.try_get("id").map_err(|_e| sqlx::Error::RowNotFound)?, + }; + + if !result.id.is_nil() { + Ok(result.id) + } else { + Err(sqlx::Error::RowNotFound) + } + } +}