From ec2f6b9d52b1688047f40fd07cdd13fd0180b616 Mon Sep 17 00:00:00 2001 From: eraden Date: Tue, 13 Feb 2024 07:23:20 +0100 Subject: [PATCH] Test single api token --- .../src/http/api/authentication/api_tokens.rs | 261 +++++++++++++++++- .../api/authentication/forgot_password.rs | 4 +- crates/squadron-api/src/utils/mod.rs | 116 +++++++- plane_db.sql | 4 - web/architecture.svg | 6 + web/index.html | 24 ++ web/logo.svg | 18 ++ 7 files changed, 414 insertions(+), 19 deletions(-) create mode 100644 web/architecture.svg create mode 100644 web/index.html create mode 100644 web/logo.svg diff --git a/crates/squadron-api/src/http/api/authentication/api_tokens.rs b/crates/squadron-api/src/http/api/authentication/api_tokens.rs index 3873352..b9be80a 100644 --- a/crates/squadron-api/src/http/api/authentication/api_tokens.rs +++ b/crates/squadron-api/src/http/api/authentication/api_tokens.rs @@ -1,4 +1,3 @@ -use actix_jwt_session::Authenticated; use actix_web::web::{Data, Path, ServiceConfig}; use actix_web::{get, HttpResponse}; use entities::api_tokens::*; @@ -9,9 +8,8 @@ use sea_orm::*; use squadron_contract::{ApiTokenId, WorkspaceSlug}; use tracing::error; -use crate::extractors::RequireInstanceConfigured; +use crate::extractors::{RequireInstanceConfigured, RequireUser}; use crate::models::{Error, JsonError}; -use crate::session::AppClaims; use crate::DatabaseConnection; pub fn configure(_: reqwest::Client, config: &mut ServiceConfig) { @@ -21,19 +19,11 @@ pub fn configure(_: reqwest::Client, config: &mut ServiceConfig) { #[get("{workspace_slug}/api-tokens/{id}")] async fn single_api_token( _: RequireInstanceConfigured, - session: Authenticated, + user: RequireUser, path: Path<(WorkspaceSlug, ApiTokenId)>, db: Data, ) -> Result { let (slug, id) = path.into_inner(); - let user = entities::prelude::Users::find_by_id(session.account_id()) - .one(&**db) - .await - .map_err(|e| { - error!("Failed to load user: {e}"); - Error::DatabaseError - })? - .ok_or(Error::UserRequired)?; let slug = slug.as_str().to_string(); match ApiTokens::find() @@ -65,4 +55,249 @@ async fn single_api_token( } #[cfg(test)] -mod single_tests {} +mod single_tests { + use actix_jwt_session::{ + Hashing, JwtTtl, RefreshTtl, SessionMiddlewareFactory, JWT_COOKIE_NAME, JWT_HEADER_NAME, + REFRESH_COOKIE_NAME, REFRESH_HEADER_NAME, + }; + use actix_web::body::to_bytes; + use actix_web::web::Data; + use actix_web::{test, App}; + use reqwest::{Method, StatusCode}; + use sea_orm::Database; + use squadron_contract::deadpool_redis; + use tracing_test::traced_test; + use uuid::Uuid; + + use super::super::*; + use super::*; + use crate::session; + use crate::utils::{slugify, ApiTokenBuilder, UserExt}; + + macro_rules! create_app { + ($app: ident, $session_storage: ident, $db: ident) => { + std::env::set_var( + "DATABASE_URL", + "postgres://postgres@0.0.0.0:5432/squadron_test", + ); + let redis = deadpool_redis::Config::from_url("redis://0.0.0.0:6379") + .create_pool(Some(deadpool_redis::Runtime::Tokio1)) + .expect("Can't connect to redis"); + let $db: sea_orm::prelude::DatabaseConnection = + Database::connect("postgres://postgres@0.0.0.0:5432/squadron_test") + .await + .expect("Failed to connect to database"); + let ($session_storage, factory) = + SessionMiddlewareFactory::::build_ed_dsa() + .with_redis_pool(redis.clone()) + // Check if header "Authorization" exists and contains Bearer with encoded JWT + .with_jwt_header(JWT_HEADER_NAME) + // Check if cookie JWT exists and contains encoded JWT + .with_jwt_cookie(JWT_COOKIE_NAME) + .with_refresh_header(REFRESH_HEADER_NAME) + // Check if cookie JWT exists and contains encoded JWT + .with_refresh_cookie(REFRESH_COOKIE_NAME) + .with_jwt_json(&["access_token"]) + .finish(); + let $db = Data::new($db.clone()); + ensure_instance($db.clone()).await; + let $app = test::init_service( + App::new() + .app_data(Data::new($session_storage.clone())) + .app_data($db.clone()) + .app_data(Data::new(redis)) + .wrap(actix_web::middleware::NormalizePath::trim()) + .wrap(actix_web::middleware::Logger::default()) + .wrap(factory) + .service(single_api_token), + ) + .await; + }; + } + + async fn ensure_instance(db: Data) { + use entities::instances::*; + use entities::prelude::Instances; + use sea_orm::*; + + if Instances::find().count(&**db).await.unwrap() > 0 { + return; + } + ActiveModel { + instance_name: Set("Plan Free".into()), + is_telemetry_enabled: Set(true), + is_support_required: Set(true), + is_setup_done: Set(true), + is_signup_screen_visited: Set(true), + is_verified: Set(true), + user_count: Set(0), + instance_id: Set(random_hex(12)), + api_key: Set(random_hex(8)), + version: Set(env!("CARGO_PKG_VERSION").to_string()), + last_checked_at: Set(chrono::Utc::now().fixed_offset()), + ..Default::default() + } + .save(&**db) + .await + .unwrap(); + } + + async fn create_user( + db: Data, + user_name: &str, + pass: &str, + ) -> entities::users::Model { + use entities::users::*; + use sea_orm::*; + + if let Ok(Some(user)) = Users::find() + .filter(Column::Email.eq(format!("{user_name}@example.com"))) + .one(&**db) + .await + { + return user; + } + + let pass = Hashing::encrypt(pass).unwrap(); + + Users::insert(ActiveModel { + password: Set(pass), + email: Set(Some(format!("{user_name}@example.com"))), + display_name: Set(user_name.to_string()), + username: Set(Uuid::new_v4().to_string()), + first_name: Set("".to_string()), + last_name: Set("".to_string()), + last_location: Set("".to_string()), + created_location: Set("".to_string()), + is_password_autoset: Set(false), + token: Set(Uuid::new_v4().to_string()), + billing_address_country: Set("".to_string()), + user_timezone: Set("UTC".to_string()), + last_login_ip: Set("0.0.0.0".to_string()), + last_login_medium: Set("None".to_string()), + last_logout_ip: Set("0.0.0.0".to_string()), + last_login_uagent: Set("test".to_string()), + is_active: Set(true), + avatar: Set("".to_string()), + ..Default::default() + }) + .exec_with_returning(&**db) + .await + .unwrap() + } + + async fn ensure_workspace( + db: Data, + name: &str, + user: &entities::users::Model, + ) -> entities::workspaces::Model { + use entities::prelude::*; + use entities::workspaces::*; + + let slug = slugify(name); + if let Ok(Some(m)) = Workspaces::find() + .filter(Column::Slug.eq(slug)) + .one(&**db) + .await + { + return m; + } + + Workspaces::insert(user.new_workspace(name)) + .exec_with_returning(&**db) + .await + .unwrap() + } + + async fn ensure_api_token( + db: Data, + label: &str, + desc: &str, + user: &entities::users::Model, + workspace: &entities::workspaces::Model, + ) -> entities::api_tokens::Model { + use entities::api_tokens::*; + use entities::prelude::*; + + if let Ok(Some(m)) = ApiTokens::find() + .filter( + Column::Label.eq(label).and( + Column::Description.eq(desc).and( + Column::WorkspaceId + .eq(workspace.id) + .and(Column::UserId.eq(user.id)), + ), + ), + ) + .one(&**db) + .await + { + return m; + } + + ApiTokens::insert( + ApiTokenBuilder::new(label, desc, user, Some(workspace)).into_active_model(), + ) + .exec_with_returning(&**db) + .await + .unwrap() + } + + #[traced_test] + #[actix_web::test] + async fn valid() { + create_app!(app, session, db); + let user = create_user(db.clone(), "valid_single_api_token", "qweQWE123!@#").await; + let workspace = ensure_workspace(db.clone(), "Foo bar%&^", &user).await; + let api_token = ensure_api_token( + db.clone(), + "valid search single api token", + "", + &user, + &workspace, + ) + .await; + + let uri = format!( + "http://example.com/{}/api-tokens/{}", + workspace.slug, api_token.id + ); + eprintln!("URI: {uri:?}"); + + let pair = session + .store( + AppClaims { + expiration_time: (chrono::Utc::now() + chrono::Duration::days(100)) + .timestamp_millis(), + issued_at: 0, + subject: "999999999".into(), + audience: session::Audience::Web, + jwt_id: Uuid::new_v4(), + account_id: user.id, + not_before: 0, + }, + JwtTtl::new(actix_jwt_session::Duration::days(9999)), + RefreshTtl::new(actix_jwt_session::Duration::days(9999)), + ) + .await + .unwrap(); + let req = test::TestRequest::default() + .insert_header((JWT_HEADER_NAME, pair.jwt.encode().unwrap())) + .uri(&uri) + .method(Method::GET) + .to_request(); + + let resp = test::call_service(&app, req).await; + + assert_eq!(resp.status(), StatusCode::OK); + + let body = resp.into_body(); + let json: serde_json::Value = + serde_json::from_slice(&to_bytes(body).await.unwrap()[..]).unwrap(); + + let expected: serde_json::Value = + serde_json::from_str(&serde_json::to_string(&api_token).unwrap()).unwrap(); + + assert_eq!(json, expected,); + } +} diff --git a/crates/squadron-api/src/http/api/authentication/forgot_password.rs b/crates/squadron-api/src/http/api/authentication/forgot_password.rs index a88d0a4..8f84e21 100644 --- a/crates/squadron-api/src/http/api/authentication/forgot_password.rs +++ b/crates/squadron-api/src/http/api/authentication/forgot_password.rs @@ -156,7 +156,9 @@ mod tests { .app_data($db.clone()) .app_data(Data::new(redis)) .app_data(Data::new(EventBusClient::new_succ_mock())) - .app_data(Data::new(PasswordResetSecret::new("ahsdhy9asd".to_string()))) + .app_data(Data::new(PasswordResetSecret::new( + "ahsdhy9asd".to_string(), + ))) .wrap(actix_web::middleware::NormalizePath::trim()) .wrap(actix_web::middleware::Logger::default()) .wrap(factory) diff --git a/crates/squadron-api/src/utils/mod.rs b/crates/squadron-api/src/utils/mod.rs index 2f44be5..573eaa4 100644 --- a/crates/squadron-api/src/utils/mod.rs +++ b/crates/squadron-api/src/utils/mod.rs @@ -19,7 +19,7 @@ use sea_orm::*; use tracing::error; use uuid::Uuid; -use crate::http::{AuthError, OAuthError}; +use crate::http::{random_hex, AuthError, OAuthError}; use crate::models::Error; #[macro_export] @@ -392,3 +392,117 @@ pub mod pass_reset_token { ) } } + +pub trait UserExt { + fn new_workspace(&self, name: &str) -> entities::workspaces::ActiveModel; +} + +impl UserExt for entities::users::Model { + fn new_workspace(&self, name: &str) -> entities::workspaces::ActiveModel { + entities::workspaces::ActiveModel::new_workspace(self.id, name) + } +} + +pub trait WorkspaceExt { + fn new_workspace(owner_id: Uuid, name: &str) -> entities::workspaces::ActiveModel { + use sea_orm::*; + + entities::workspaces::ActiveModel { + name: Set(name.to_string()), + slug: Set(slugify(name)), + owner_id: Set(owner_id), + ..Default::default() + } + } +} + +impl WorkspaceExt for entities::workspaces::ActiveModel {} + +pub struct ApiTokenBuilder { + pub created_at: Option>, + pub updated_at: Option>, + + pub token: String, + pub label: String, + pub user_type: i16, + pub created_by_id: Option, + pub updated_by_id: Option, + pub user_id: Uuid, + pub workspace_id: Option, + pub description: String, + pub expired_at: Option>, + pub is_active: bool, + pub last_used: Option>, +} + +impl ApiTokenBuilder { + pub fn new( + label: &str, + desc: &str, + user: &entities::users::Model, + workspace: Option<&entities::workspaces::Model>, + ) -> Self { + let now = chrono::Utc::now().fixed_offset(); + Self { + token: random_hex(60), + created_at: Some(now), + updated_at: Some(now), + label: label.to_string(), + user_type: if user.is_bot { 1 } else { 0 }, + created_by_id: Some(user.id), + updated_by_id: Some(user.id), + user_id: user.id, + workspace_id: workspace.map(|w| w.id), + description: desc.to_string(), + expired_at: None, + is_active: true, + last_used: None, + } + } + + pub fn into_active_model(self) -> entities::api_tokens::ActiveModel { + use sea_orm::*; + + let ApiTokenBuilder { + label, + created_at, + updated_at, + token, + user_type, + created_by_id, + updated_by_id, + user_id, + workspace_id, + description, + expired_at, + is_active, + last_used, + } = self; + entities::api_tokens::ActiveModel { + label: Set(label), + created_at: created_at.map(Set).unwrap_or(NotSet), + updated_at: updated_at.map(Set).unwrap_or(NotSet), + token: Set(token), + user_type: Set(user_type), + created_by_id: Set(created_by_id), + updated_by_id: Set(updated_by_id), + user_id: Set(user_id), + workspace_id: Set(workspace_id), + description: Set(description), + expired_at: Set(expired_at), + is_active: Set(is_active), + last_used: Set(last_used), + ..Default::default() + } + } +} + +pub fn slugify(s: &str) -> String { + s.chars() + .filter_map(|c| match c { + _ if c.is_whitespace() => Some('-'), + _ if c.is_ascii_alphanumeric() => Some(c.to_ascii_lowercase()), + _ => None, + }) + .collect() +} diff --git a/plane_db.sql b/plane_db.sql index e1e092c..0bd1c47 100644 --- a/plane_db.sql +++ b/plane_db.sql @@ -91,10 +91,6 @@ CREATE TABLE users ( use_case text ); --- --- Name: workspaces; Type: TABLE; Schema: public; Owner: plane --- - CREATE TABLE workspaces ( created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), diff --git a/web/architecture.svg b/web/architecture.svg new file mode 100644 index 0000000..4cf4dd7 --- /dev/null +++ b/web/architecture.svg @@ -0,0 +1,6 @@ + + + + API Server + + diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..96fb19c --- /dev/null +++ b/web/index.html @@ -0,0 +1,24 @@ + + + + + + + +
+
+
+ + + + Squadron +
+
+ +
+ +
+
+
+ + diff --git a/web/logo.svg b/web/logo.svg new file mode 100644 index 0000000..d7d64e3 --- /dev/null +++ b/web/logo.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + +