Test single api token

This commit is contained in:
eraden 2024-02-13 07:23:20 +01:00
parent 495746d6a1
commit ec2f6b9d52
7 changed files with 414 additions and 19 deletions

View File

@ -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<AppClaims>,
user: RequireUser,
path: Path<(WorkspaceSlug, ApiTokenId)>,
db: Data<DatabaseConnection>,
) -> Result<HttpResponse, JsonError> {
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::<session::AppClaims>::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<DatabaseConnection>) {
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<DatabaseConnection>,
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<DatabaseConnection>,
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<DatabaseConnection>,
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,);
}
}

View File

@ -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)

View File

@ -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<chrono::DateTime<chrono::FixedOffset>>,
pub updated_at: Option<chrono::DateTime<chrono::FixedOffset>>,
pub token: String,
pub label: String,
pub user_type: i16,
pub created_by_id: Option<Uuid>,
pub updated_by_id: Option<Uuid>,
pub user_id: Uuid,
pub workspace_id: Option<Uuid>,
pub description: String,
pub expired_at: Option<chrono::DateTime<chrono::FixedOffset>>,
pub is_active: bool,
pub last_used: Option<chrono::DateTime<chrono::FixedOffset>>,
}
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()
}

View File

@ -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(),

6
web/architecture.svg Normal file
View File

@ -0,0 +1,6 @@
<svg height="600" width="600" xmlns="http://www.w3.org/2000/svg">
<g>
<rect x="20" y="20" width="100" height="60" fill="rgb(94, 235, 244)" />
<text x="20" y="100">API Server</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 217 B

24
web/index.html Normal file
View File

@ -0,0 +1,24 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
</head>
<body>
<base href="/" />
<main>
<header style="display:flex;position:sticky;top:0;left:0;height:2rem;width:100%;">
<div style="display:flex;">
<picture>
<img src="/logo.svg" style="height:36px;margin-right:1rem;" />
</picture>
<b style="height:2rem;line-height:2rem;text-align:center;display:block;">Squadron</b>
</div>
</header>
<app style="display:block;position:relative;margin-top:2rem;">
<section>
<img src="/architecture.svg" />
</section>
</app>
</main>
</body>
</html>

18
web/logo.svg Normal file
View File

@ -0,0 +1,18 @@
<svg height="560" width="560" xmlns="http://www.w3.org/2000/svg">
<defs>
<g id="jet">
<polygon points="200,60 260,150 140,150" />
<polygon points="200,10 225,190 175,190" />
</g>
</defs>
<circle r="260" cx="280" cy="280" fill="rgb(94, 235, 244)" />
<g transform="translate(0 90) rotate(-45 0 0)" transform-box="fill-box" transform-origin="center" style="fill:#00569d;stroke:none;stroke-width:0">
<g transform="translate(30, 30)"><use href="#jet" /></g>
<g transform="translate(130, -90)"><use href="#jet" /></g>
<g transform="translate(230, 30)"><use href="#jet" /></g>
<g transform="translate(130, 190)"><use href="#jet" /></g>
<g transform="translate(300, 190)"><use href="#jet" /></g>
<g transform="translate(-50, 190)"><use href="#jet" /></g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 785 B