You've already forked AstralRinth
forked from didirus/AstralRinth
OAuth 2.0 Authorization Server [MOD-559] (#733)
* WIP end-of-day push * Authorize endpoint, accept endpoints, DB stuff for oauth clients, their redirects, and client authorizations * OAuth Client create route * Get user clients * Client delete * Edit oauth client * Include redirects in edit client route * Database stuff for tokens * Reorg oauth stuff out of auth/flows and into its own module * Impl OAuth get access token endpoint * Accept oauth access tokens as auth and update through AuthQueue * User OAuth authorization management routes * Forgot to actually add the routes lol * Bit o cleanup * Happy path test for OAuth and minor fixes for things it found * Add dummy data oauth client (and detect/handle dummy data version changes) * More tests * Another test * More tests and reject endpoint * Test oauth client and authorization management routes * cargo sqlx prepare * dead code warning * Auto clippy fixes * Uri refactoring * minor name improvement * Don't compile-time check the test sqlx queries * Trying to fix db concurrency problem to get tests to pass * Try fix from test PR * Fixes for updated sqlx * Prevent restricted scopes from being requested or issued * Get OAuth client(s) * Remove joined oauth client info from authorization returns * Add default conversion to OAuthError::error so we can use ? * Rework routes * Consolidate scopes into SESSION_ACCESS * Cargo sqlx prepare * Parse to OAuthClientId automatically through serde and actix * Cargo clippy * Remove validation requiring 1 redirect URI on oauth client creation * Use serde(flatten) on OAuthClientCreationResult
This commit is contained in:
@@ -29,7 +29,7 @@ impl ApiV2 {
|
||||
.set_multipart(creation_data.segment_data)
|
||||
.to_request();
|
||||
let resp = self.call(req).await;
|
||||
assert_status(resp, StatusCode::OK);
|
||||
assert_status(&resp, StatusCode::OK);
|
||||
|
||||
// Approve as a moderator.
|
||||
let req = TestRequest::patch()
|
||||
@@ -42,7 +42,7 @@ impl ApiV2 {
|
||||
))
|
||||
.to_request();
|
||||
let resp = self.call(req).await;
|
||||
assert_status(resp, StatusCode::NO_CONTENT);
|
||||
assert_status(&resp, StatusCode::NO_CONTENT);
|
||||
|
||||
let project = self
|
||||
.get_project_deserialized(&creation_data.slug, pat)
|
||||
@@ -82,16 +82,20 @@ impl ApiV2 {
|
||||
test::read_body_json(resp).await
|
||||
}
|
||||
|
||||
pub async fn get_user_projects(&self, user_id_or_username: &str, pat: &str) -> ServiceResponse {
|
||||
let req = test::TestRequest::get()
|
||||
.uri(&format!("/v2/user/{}/projects", user_id_or_username))
|
||||
.append_header(("Authorization", pat))
|
||||
.to_request();
|
||||
self.call(req).await
|
||||
}
|
||||
|
||||
pub async fn get_user_projects_deserialized(
|
||||
&self,
|
||||
user_id_or_username: &str,
|
||||
pat: &str,
|
||||
) -> Vec<Project> {
|
||||
let req = test::TestRequest::get()
|
||||
.uri(&format!("/v2/user/{}/projects", user_id_or_username))
|
||||
.append_header(("Authorization", pat))
|
||||
.to_request();
|
||||
let resp = self.call(req).await;
|
||||
let resp = self.get_user_projects(user_id_or_username, pat).await;
|
||||
assert_eq!(resp.status(), 200);
|
||||
test::read_body_json(resp).await
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use actix_http::StatusCode;
|
||||
use actix_web::{dev::ServiceResponse, test};
|
||||
use labrinth::models::{
|
||||
notifications::Notification,
|
||||
@@ -5,6 +6,8 @@ use labrinth::models::{
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::common::asserts::assert_status;
|
||||
|
||||
use super::ApiV2;
|
||||
|
||||
impl ApiV2 {
|
||||
@@ -114,16 +117,21 @@ impl ApiV2 {
|
||||
self.call(req).await
|
||||
}
|
||||
|
||||
pub async fn get_user_notifications(&self, user_id: &str, pat: &str) -> ServiceResponse {
|
||||
let req = test::TestRequest::get()
|
||||
.uri(&format!("/v2/user/{user_id}/notifications"))
|
||||
.append_header(("Authorization", pat))
|
||||
.to_request();
|
||||
self.call(req).await
|
||||
}
|
||||
|
||||
pub async fn get_user_notifications_deserialized(
|
||||
&self,
|
||||
user_id: &str,
|
||||
pat: &str,
|
||||
) -> Vec<Notification> {
|
||||
let req = test::TestRequest::get()
|
||||
.uri(&format!("/v2/user/{user_id}/notifications"))
|
||||
.append_header(("Authorization", pat))
|
||||
.to_request();
|
||||
let resp = self.call(req).await;
|
||||
let resp = self.get_user_notifications(user_id, pat).await;
|
||||
assert_status(&resp, StatusCode::OK);
|
||||
test::read_body_json(resp).await
|
||||
}
|
||||
|
||||
|
||||
19
tests/common/api_v3/mod.rs
Normal file
19
tests/common/api_v3/mod.rs
Normal file
@@ -0,0 +1,19 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
use super::environment::LocalService;
|
||||
use actix_web::dev::ServiceResponse;
|
||||
use std::rc::Rc;
|
||||
|
||||
pub mod oauth;
|
||||
pub mod oauth_clients;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ApiV3 {
|
||||
pub test_app: Rc<dyn LocalService>,
|
||||
}
|
||||
|
||||
impl ApiV3 {
|
||||
pub async fn call(&self, req: actix_http::Request) -> ServiceResponse {
|
||||
self.test_app.call(req).await.unwrap()
|
||||
}
|
||||
}
|
||||
156
tests/common/api_v3/oauth.rs
Normal file
156
tests/common/api_v3/oauth.rs
Normal file
@@ -0,0 +1,156 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use actix_http::StatusCode;
|
||||
use actix_web::{
|
||||
dev::ServiceResponse,
|
||||
test::{self, TestRequest},
|
||||
};
|
||||
use labrinth::auth::oauth::{
|
||||
OAuthClientAccessRequest, RespondToOAuthClientScopes, TokenRequest, TokenResponse,
|
||||
};
|
||||
use reqwest::header::{AUTHORIZATION, LOCATION};
|
||||
|
||||
use crate::common::asserts::assert_status;
|
||||
|
||||
use super::ApiV3;
|
||||
|
||||
impl ApiV3 {
|
||||
pub async fn complete_full_authorize_flow(
|
||||
&self,
|
||||
client_id: &str,
|
||||
client_secret: &str,
|
||||
scope: Option<&str>,
|
||||
redirect_uri: Option<&str>,
|
||||
state: Option<&str>,
|
||||
user_pat: &str,
|
||||
) -> String {
|
||||
let auth_resp = self
|
||||
.oauth_authorize(client_id, scope, redirect_uri, state, user_pat)
|
||||
.await;
|
||||
let flow_id = get_authorize_accept_flow_id(auth_resp).await;
|
||||
let redirect_resp = self.oauth_accept(&flow_id, user_pat).await;
|
||||
let auth_code = get_auth_code_from_redirect_params(&redirect_resp).await;
|
||||
let token_resp = self
|
||||
.oauth_token(auth_code, None, client_id.to_string(), client_secret)
|
||||
.await;
|
||||
get_access_token(token_resp).await
|
||||
}
|
||||
|
||||
pub async fn oauth_authorize(
|
||||
&self,
|
||||
client_id: &str,
|
||||
scope: Option<&str>,
|
||||
redirect_uri: Option<&str>,
|
||||
state: Option<&str>,
|
||||
pat: &str,
|
||||
) -> ServiceResponse {
|
||||
let uri = generate_authorize_uri(client_id, scope, redirect_uri, state);
|
||||
let req = TestRequest::get()
|
||||
.uri(&uri)
|
||||
.append_header((AUTHORIZATION, pat))
|
||||
.to_request();
|
||||
self.call(req).await
|
||||
}
|
||||
|
||||
pub async fn oauth_accept(&self, flow: &str, pat: &str) -> ServiceResponse {
|
||||
self.call(
|
||||
TestRequest::post()
|
||||
.uri("/v3/auth/oauth/accept")
|
||||
.append_header((AUTHORIZATION, pat))
|
||||
.set_json(RespondToOAuthClientScopes {
|
||||
flow: flow.to_string(),
|
||||
})
|
||||
.to_request(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn oauth_reject(&self, flow: &str, pat: &str) -> ServiceResponse {
|
||||
self.call(
|
||||
TestRequest::post()
|
||||
.uri("/v3/auth/oauth/reject")
|
||||
.append_header((AUTHORIZATION, pat))
|
||||
.set_json(RespondToOAuthClientScopes {
|
||||
flow: flow.to_string(),
|
||||
})
|
||||
.to_request(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn oauth_token(
|
||||
&self,
|
||||
auth_code: String,
|
||||
original_redirect_uri: Option<String>,
|
||||
client_id: String,
|
||||
client_secret: &str,
|
||||
) -> ServiceResponse {
|
||||
self.call(
|
||||
TestRequest::post()
|
||||
.uri("/v3/auth/oauth/token")
|
||||
.append_header((AUTHORIZATION, client_secret))
|
||||
.set_form(TokenRequest {
|
||||
grant_type: "authorization_code".to_string(),
|
||||
code: auth_code,
|
||||
redirect_uri: original_redirect_uri,
|
||||
client_id: serde_json::from_str(&format!("\"{}\"", client_id)).unwrap(),
|
||||
})
|
||||
.to_request(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
pub fn generate_authorize_uri(
|
||||
client_id: &str,
|
||||
scope: Option<&str>,
|
||||
redirect_uri: Option<&str>,
|
||||
state: Option<&str>,
|
||||
) -> String {
|
||||
format!(
|
||||
"/v3/auth/oauth/authorize?client_id={}{}{}{}",
|
||||
urlencoding::encode(client_id),
|
||||
optional_query_param("redirect_uri", redirect_uri),
|
||||
optional_query_param("scope", scope),
|
||||
optional_query_param("state", state),
|
||||
)
|
||||
.to_string()
|
||||
}
|
||||
|
||||
pub async fn get_authorize_accept_flow_id(response: ServiceResponse) -> String {
|
||||
assert_status(&response, StatusCode::OK);
|
||||
test::read_body_json::<OAuthClientAccessRequest, _>(response)
|
||||
.await
|
||||
.flow_id
|
||||
}
|
||||
|
||||
pub async fn get_auth_code_from_redirect_params(response: &ServiceResponse) -> String {
|
||||
assert_status(response, StatusCode::FOUND);
|
||||
let query_params = get_redirect_location_query_params(response);
|
||||
query_params.get("code").unwrap().to_string()
|
||||
}
|
||||
|
||||
pub async fn get_access_token(response: ServiceResponse) -> String {
|
||||
assert_status(&response, StatusCode::OK);
|
||||
test::read_body_json::<TokenResponse, _>(response)
|
||||
.await
|
||||
.access_token
|
||||
}
|
||||
|
||||
pub fn get_redirect_location_query_params(
|
||||
response: &ServiceResponse,
|
||||
) -> actix_web::web::Query<HashMap<String, String>> {
|
||||
let redirect_location = response.headers().get(LOCATION).unwrap().to_str().unwrap();
|
||||
actix_web::web::Query::<HashMap<String, String>>::from_query(
|
||||
redirect_location.split_once('?').unwrap().1,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn optional_query_param(key: &str, value: Option<&str>) -> String {
|
||||
if let Some(val) = value {
|
||||
format!("&{key}={}", urlencoding::encode(val))
|
||||
} else {
|
||||
"".to_string()
|
||||
}
|
||||
}
|
||||
107
tests/common/api_v3/oauth_clients.rs
Normal file
107
tests/common/api_v3/oauth_clients.rs
Normal file
@@ -0,0 +1,107 @@
|
||||
use actix_http::StatusCode;
|
||||
use actix_web::{
|
||||
dev::ServiceResponse,
|
||||
test::{self, TestRequest},
|
||||
};
|
||||
use labrinth::{
|
||||
models::{
|
||||
oauth_clients::{OAuthClient, OAuthClientAuthorization},
|
||||
pats::Scopes,
|
||||
},
|
||||
routes::v3::oauth_clients::OAuthClientEdit,
|
||||
};
|
||||
use reqwest::header::AUTHORIZATION;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::common::asserts::assert_status;
|
||||
|
||||
use super::ApiV3;
|
||||
|
||||
impl ApiV3 {
|
||||
pub async fn add_oauth_client(
|
||||
&self,
|
||||
name: String,
|
||||
max_scopes: Scopes,
|
||||
redirect_uris: Vec<String>,
|
||||
pat: &str,
|
||||
) -> ServiceResponse {
|
||||
let max_scopes = max_scopes.bits();
|
||||
let req = TestRequest::post()
|
||||
.uri("/v3/oauth/app")
|
||||
.append_header((AUTHORIZATION, pat))
|
||||
.set_json(json!({
|
||||
"name": name,
|
||||
"max_scopes": max_scopes,
|
||||
"redirect_uris": redirect_uris
|
||||
}))
|
||||
.to_request();
|
||||
|
||||
self.call(req).await
|
||||
}
|
||||
|
||||
pub async fn get_user_oauth_clients(&self, user_id: &str, pat: &str) -> Vec<OAuthClient> {
|
||||
let req = TestRequest::get()
|
||||
.uri(&format!("/v3/user/{}/oauth_apps", user_id))
|
||||
.append_header((AUTHORIZATION, pat))
|
||||
.to_request();
|
||||
let resp = self.call(req).await;
|
||||
assert_status(&resp, StatusCode::OK);
|
||||
|
||||
test::read_body_json(resp).await
|
||||
}
|
||||
|
||||
pub async fn get_oauth_client(&self, client_id: String, pat: &str) -> ServiceResponse {
|
||||
let req = TestRequest::get()
|
||||
.uri(&format!("/v3/oauth/app/{}", client_id))
|
||||
.append_header((AUTHORIZATION, pat))
|
||||
.to_request();
|
||||
|
||||
self.call(req).await
|
||||
}
|
||||
|
||||
pub async fn edit_oauth_client(
|
||||
&self,
|
||||
client_id: &str,
|
||||
edit: OAuthClientEdit,
|
||||
pat: &str,
|
||||
) -> ServiceResponse {
|
||||
let req = TestRequest::patch()
|
||||
.uri(&format!("/v3/oauth/app/{}", urlencoding::encode(client_id)))
|
||||
.set_json(edit)
|
||||
.append_header((AUTHORIZATION, pat))
|
||||
.to_request();
|
||||
|
||||
self.call(req).await
|
||||
}
|
||||
|
||||
pub async fn delete_oauth_client(&self, client_id: &str, pat: &str) -> ServiceResponse {
|
||||
let req = TestRequest::delete()
|
||||
.uri(&format!("/v3/oauth/app/{}", client_id))
|
||||
.append_header((AUTHORIZATION, pat))
|
||||
.to_request();
|
||||
|
||||
self.call(req).await
|
||||
}
|
||||
|
||||
pub async fn revoke_oauth_authorization(&self, client_id: &str, pat: &str) -> ServiceResponse {
|
||||
let req = TestRequest::delete()
|
||||
.uri(&format!(
|
||||
"/v3/oauth/authorizations?client_id={}",
|
||||
urlencoding::encode(client_id)
|
||||
))
|
||||
.append_header((AUTHORIZATION, pat))
|
||||
.to_request();
|
||||
self.call(req).await
|
||||
}
|
||||
|
||||
pub async fn get_user_oauth_authorizations(&self, pat: &str) -> Vec<OAuthClientAuthorization> {
|
||||
let req = TestRequest::get()
|
||||
.uri("/v3/oauth/authorizations")
|
||||
.append_header((AUTHORIZATION, pat))
|
||||
.to_request();
|
||||
let resp = self.call(req).await;
|
||||
assert_status(&resp, StatusCode::OK);
|
||||
|
||||
test::read_body_json(resp).await
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,12 @@
|
||||
pub fn assert_status(response: actix_web::dev::ServiceResponse, status: actix_http::StatusCode) {
|
||||
#![allow(dead_code)]
|
||||
|
||||
pub fn assert_status(response: &actix_web::dev::ServiceResponse, status: actix_http::StatusCode) {
|
||||
assert_eq!(response.status(), status, "{:#?}", response.response());
|
||||
}
|
||||
|
||||
pub fn assert_any_status_except(
|
||||
response: &actix_web::dev::ServiceResponse,
|
||||
status: actix_http::StatusCode,
|
||||
) {
|
||||
assert_ne!(response.status(), status, "{:#?}", response.response());
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ use url::Url;
|
||||
|
||||
use crate::common::{dummy_data, environment::TestEnvironment};
|
||||
|
||||
use super::dummy_data::DUMMY_DATA_UPDATE;
|
||||
|
||||
// The dummy test database adds a fair bit of 'dummy' data to test with.
|
||||
// Some constants are used to refer to that data, and are described here.
|
||||
// The rest can be accessed in the TestEnvironment 'dummy' field.
|
||||
@@ -119,11 +121,7 @@ impl TemporaryDatabase {
|
||||
.await
|
||||
.unwrap();
|
||||
if db_exists.is_none() {
|
||||
let create_db_query = format!("CREATE DATABASE {TEMPLATE_DATABASE_NAME}");
|
||||
sqlx::query(&create_db_query)
|
||||
.execute(&main_pool)
|
||||
.await
|
||||
.expect("Database creation failed");
|
||||
create_template_database(&main_pool).await;
|
||||
}
|
||||
|
||||
// Switch to template
|
||||
@@ -135,30 +133,52 @@ impl TemporaryDatabase {
|
||||
.await
|
||||
.expect("Connection to database failed");
|
||||
|
||||
// Run migrations on the template
|
||||
let migrations = sqlx::migrate!("./migrations");
|
||||
migrations.run(&pool).await.expect("Migrations failed");
|
||||
|
||||
// Check if dummy data exists- a fake 'dummy_data' table is created if it does
|
||||
let dummy_data_exists: bool =
|
||||
let mut dummy_data_exists: bool =
|
||||
sqlx::query_scalar("SELECT to_regclass('dummy_data') IS NOT NULL")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
if dummy_data_exists {
|
||||
// Check if the dummy data needs to be updated
|
||||
let dummy_data_update =
|
||||
sqlx::query_scalar::<_, i64>("SELECT update_id FROM dummy_data")
|
||||
.fetch_optional(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
let needs_update = !dummy_data_update.is_some_and(|d| d == DUMMY_DATA_UPDATE);
|
||||
if needs_update {
|
||||
println!("Dummy data updated, so template DB tables will be dropped and re-created");
|
||||
// Drop all tables in the database so they can be re-created and later filled with updated dummy data
|
||||
sqlx::query("DROP SCHEMA public CASCADE;")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("CREATE SCHEMA public;")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
dummy_data_exists = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Run migrations on the template
|
||||
let migrations = sqlx::migrate!("./migrations");
|
||||
migrations.run(&pool).await.expect("Migrations failed");
|
||||
|
||||
if !dummy_data_exists {
|
||||
// Add dummy data
|
||||
let temporary_test_env = TestEnvironment::build_with_db(TemporaryDatabase {
|
||||
pool: pool.clone(),
|
||||
database_name: TEMPLATE_DATABASE_NAME.to_string(),
|
||||
redis_pool: RedisPool::new(None),
|
||||
redis_pool: RedisPool::new(Some(generate_random_name("test_template_"))),
|
||||
})
|
||||
.await;
|
||||
dummy_data::add_dummy_data(&temporary_test_env).await;
|
||||
temporary_test_env.db.pool.close().await;
|
||||
}
|
||||
pool.close().await;
|
||||
|
||||
// Switch back to main database (as we cant create from template while connected to it)
|
||||
let pool = PgPool::connect(url.as_str()).await.unwrap();
|
||||
drop(pool);
|
||||
|
||||
// Create the temporary database from the template
|
||||
let create_db_query = format!(
|
||||
@@ -167,7 +187,7 @@ impl TemporaryDatabase {
|
||||
);
|
||||
|
||||
sqlx::query(&create_db_query)
|
||||
.execute(&pool)
|
||||
.execute(&main_pool)
|
||||
.await
|
||||
.expect("Database creation failed");
|
||||
|
||||
@@ -216,6 +236,14 @@ impl TemporaryDatabase {
|
||||
}
|
||||
}
|
||||
|
||||
async fn create_template_database(pool: &sqlx::Pool<sqlx::Postgres>) {
|
||||
let create_db_query = format!("CREATE DATABASE {TEMPLATE_DATABASE_NAME}");
|
||||
sqlx::query(&create_db_query)
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("Database creation failed");
|
||||
}
|
||||
|
||||
// Appends a random 8-digit number to the end of the str
|
||||
pub fn generate_random_name(str: &str) -> String {
|
||||
let mut str = String::from(str);
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
#![allow(dead_code)]
|
||||
use actix_http::StatusCode;
|
||||
use actix_web::test::{self, TestRequest};
|
||||
use labrinth::{
|
||||
models::projects::Project,
|
||||
models::{organizations::Organization, pats::Scopes, projects::Version},
|
||||
models::{
|
||||
oauth_clients::OAuthClient, organizations::Organization, pats::Scopes, projects::Version,
|
||||
},
|
||||
};
|
||||
use serde_json::json;
|
||||
use sqlx::Executor;
|
||||
@@ -11,11 +14,14 @@ use crate::common::{actix::AppendsMultipart, database::USER_USER_PAT};
|
||||
|
||||
use super::{
|
||||
actix::{MultipartSegment, MultipartSegmentData},
|
||||
asserts::assert_status,
|
||||
database::USER_USER_ID,
|
||||
environment::TestEnvironment,
|
||||
get_json_val_str,
|
||||
request_data::get_public_project_creation_data,
|
||||
};
|
||||
|
||||
pub const DUMMY_DATA_UPDATE: i64 = 1;
|
||||
pub const DUMMY_DATA_UPDATE: i64 = 3;
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub const DUMMY_CATEGORIES: &[&str] = &[
|
||||
@@ -28,6 +34,8 @@ pub const DUMMY_CATEGORIES: &[&str] = &[
|
||||
"optimization",
|
||||
];
|
||||
|
||||
pub const DUMMY_OAUTH_CLIENT_ALPHA_SECRET: &str = "abcdefghijklmnopqrstuvwxyz";
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub enum DummyJarFile {
|
||||
DummyProjectAlpha,
|
||||
@@ -43,16 +51,80 @@ pub enum DummyImage {
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct DummyData {
|
||||
/// Alpha project:
|
||||
/// This is a dummy project created by USER user.
|
||||
/// It's approved, listed, and visible to the public.
|
||||
pub project_alpha: DummyProjectAlpha,
|
||||
|
||||
/// Beta project:
|
||||
/// This is a dummy project created by USER user.
|
||||
/// It's not approved, unlisted, and not visible to the public.
|
||||
pub project_beta: DummyProjectBeta,
|
||||
|
||||
/// Zeta organization:
|
||||
/// This is a dummy organization created by USER user.
|
||||
/// There are no projects in it.
|
||||
pub organization_zeta: DummyOrganizationZeta,
|
||||
|
||||
/// Alpha OAuth Client:
|
||||
/// This is a dummy OAuth client created by USER user.
|
||||
///
|
||||
/// All scopes are included in its max scopes
|
||||
///
|
||||
/// It has one valid redirect URI
|
||||
pub oauth_client_alpha: DummyOAuthClientAlpha,
|
||||
}
|
||||
|
||||
impl DummyData {
|
||||
pub fn new(
|
||||
project_alpha: Project,
|
||||
project_alpha_version: Version,
|
||||
project_beta: Project,
|
||||
project_beta_version: Version,
|
||||
organization_zeta: Organization,
|
||||
oauth_client_alpha: OAuthClient,
|
||||
) -> Self {
|
||||
DummyData {
|
||||
project_alpha: DummyProjectAlpha {
|
||||
team_id: project_alpha.team.to_string(),
|
||||
project_id: project_alpha.id.to_string(),
|
||||
project_slug: project_alpha.slug.unwrap(),
|
||||
version_id: project_alpha_version.id.to_string(),
|
||||
thread_id: project_alpha.thread_id.to_string(),
|
||||
file_hash: project_alpha_version.files[0].hashes["sha1"].clone(),
|
||||
},
|
||||
|
||||
project_beta: DummyProjectBeta {
|
||||
team_id: project_beta.team.to_string(),
|
||||
project_id: project_beta.id.to_string(),
|
||||
project_slug: project_beta.slug.unwrap(),
|
||||
version_id: project_beta_version.id.to_string(),
|
||||
thread_id: project_beta.thread_id.to_string(),
|
||||
file_hash: project_beta_version.files[0].hashes["sha1"].clone(),
|
||||
},
|
||||
|
||||
organization_zeta: DummyOrganizationZeta {
|
||||
organization_id: organization_zeta.id.to_string(),
|
||||
team_id: organization_zeta.team_id.to_string(),
|
||||
organization_title: organization_zeta.title,
|
||||
},
|
||||
|
||||
oauth_client_alpha: DummyOAuthClientAlpha {
|
||||
client_id: get_json_val_str(oauth_client_alpha.id),
|
||||
client_secret: DUMMY_OAUTH_CLIENT_ALPHA_SECRET.to_string(),
|
||||
valid_redirect_uri: oauth_client_alpha
|
||||
.redirect_uris
|
||||
.first()
|
||||
.unwrap()
|
||||
.uri
|
||||
.clone(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct DummyProjectAlpha {
|
||||
// Alpha project:
|
||||
// This is a dummy project created by USER user.
|
||||
// It's approved, listed, and visible to the public.
|
||||
pub project_id: String,
|
||||
pub project_slug: String,
|
||||
pub version_id: String,
|
||||
@@ -63,9 +135,6 @@ pub struct DummyProjectAlpha {
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct DummyProjectBeta {
|
||||
// Beta project:
|
||||
// This is a dummy project created by USER user.
|
||||
// It's not approved, unlisted, and not visible to the public.
|
||||
pub project_id: String,
|
||||
pub project_slug: String,
|
||||
pub version_id: String,
|
||||
@@ -76,14 +145,18 @@ pub struct DummyProjectBeta {
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct DummyOrganizationZeta {
|
||||
// Zeta organization:
|
||||
// This is a dummy organization created by USER user.
|
||||
// There are no projects in it.
|
||||
pub organization_id: String,
|
||||
pub organization_title: String,
|
||||
pub team_id: String,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct DummyOAuthClientAlpha {
|
||||
pub client_id: String,
|
||||
pub client_secret: String,
|
||||
pub valid_redirect_uri: String,
|
||||
}
|
||||
|
||||
pub async fn add_dummy_data(test_env: &TestEnvironment) -> DummyData {
|
||||
// Adds basic dummy data to the database directly with sql (user, pats)
|
||||
let pool = &test_env.db.pool.clone();
|
||||
@@ -101,37 +174,22 @@ pub async fn add_dummy_data(test_env: &TestEnvironment) -> DummyData {
|
||||
|
||||
let zeta_organization = add_organization_zeta(test_env).await;
|
||||
|
||||
let oauth_client_alpha = get_oauth_client_alpha(test_env).await;
|
||||
|
||||
sqlx::query("INSERT INTO dummy_data (update_id) VALUES ($1)")
|
||||
.bind(DUMMY_DATA_UPDATE)
|
||||
.execute(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
DummyData {
|
||||
project_alpha: DummyProjectAlpha {
|
||||
team_id: alpha_project.team.to_string(),
|
||||
project_id: alpha_project.id.to_string(),
|
||||
project_slug: alpha_project.slug.unwrap(),
|
||||
version_id: alpha_version.id.to_string(),
|
||||
thread_id: alpha_project.thread_id.to_string(),
|
||||
file_hash: alpha_version.files[0].hashes["sha1"].clone(),
|
||||
},
|
||||
|
||||
project_beta: DummyProjectBeta {
|
||||
team_id: beta_project.team.to_string(),
|
||||
project_id: beta_project.id.to_string(),
|
||||
project_slug: beta_project.slug.unwrap(),
|
||||
version_id: beta_version.id.to_string(),
|
||||
thread_id: beta_project.thread_id.to_string(),
|
||||
file_hash: beta_version.files[0].hashes["sha1"].clone(),
|
||||
},
|
||||
|
||||
organization_zeta: DummyOrganizationZeta {
|
||||
organization_id: zeta_organization.id.to_string(),
|
||||
team_id: zeta_organization.team_id.to_string(),
|
||||
organization_title: zeta_organization.title,
|
||||
},
|
||||
}
|
||||
DummyData::new(
|
||||
alpha_project,
|
||||
alpha_version,
|
||||
beta_project,
|
||||
beta_version,
|
||||
zeta_organization,
|
||||
oauth_client_alpha,
|
||||
)
|
||||
}
|
||||
|
||||
pub async fn get_dummy_data(test_env: &TestEnvironment) -> DummyData {
|
||||
@@ -139,31 +197,17 @@ pub async fn get_dummy_data(test_env: &TestEnvironment) -> DummyData {
|
||||
let (beta_project, beta_version) = get_project_beta(test_env).await;
|
||||
|
||||
let zeta_organization = get_organization_zeta(test_env).await;
|
||||
DummyData {
|
||||
project_alpha: DummyProjectAlpha {
|
||||
team_id: alpha_project.team.to_string(),
|
||||
project_id: alpha_project.id.to_string(),
|
||||
project_slug: alpha_project.slug.unwrap(),
|
||||
version_id: alpha_version.id.to_string(),
|
||||
thread_id: alpha_project.thread_id.to_string(),
|
||||
file_hash: alpha_version.files[0].hashes["sha1"].clone(),
|
||||
},
|
||||
|
||||
project_beta: DummyProjectBeta {
|
||||
team_id: beta_project.team.to_string(),
|
||||
project_id: beta_project.id.to_string(),
|
||||
project_slug: beta_project.slug.unwrap(),
|
||||
version_id: beta_version.id.to_string(),
|
||||
thread_id: beta_project.thread_id.to_string(),
|
||||
file_hash: beta_version.files[0].hashes["sha1"].clone(),
|
||||
},
|
||||
let oauth_client_alpha = get_oauth_client_alpha(test_env).await;
|
||||
|
||||
organization_zeta: DummyOrganizationZeta {
|
||||
organization_id: zeta_organization.id.to_string(),
|
||||
team_id: zeta_organization.team_id.to_string(),
|
||||
organization_title: zeta_organization.title,
|
||||
},
|
||||
}
|
||||
DummyData::new(
|
||||
alpha_project,
|
||||
alpha_version,
|
||||
beta_project,
|
||||
beta_version,
|
||||
zeta_organization,
|
||||
oauth_client_alpha,
|
||||
)
|
||||
}
|
||||
|
||||
pub async fn add_project_alpha(test_env: &TestEnvironment) -> (Project, Version) {
|
||||
@@ -282,6 +326,7 @@ pub async fn get_project_beta(test_env: &TestEnvironment) -> (Project, Version)
|
||||
.append_header(("Authorization", USER_USER_PAT))
|
||||
.to_request();
|
||||
let resp = test_env.call(req).await;
|
||||
assert_status(&resp, StatusCode::OK);
|
||||
let project: Project = test::read_body_json(resp).await;
|
||||
|
||||
// Get project's versions
|
||||
@@ -290,6 +335,7 @@ pub async fn get_project_beta(test_env: &TestEnvironment) -> (Project, Version)
|
||||
.append_header(("Authorization", USER_USER_PAT))
|
||||
.to_request();
|
||||
let resp = test_env.call(req).await;
|
||||
assert_status(&resp, StatusCode::OK);
|
||||
let versions: Vec<Version> = test::read_body_json(resp).await;
|
||||
let version = versions.into_iter().next().unwrap();
|
||||
|
||||
@@ -308,6 +354,14 @@ pub async fn get_organization_zeta(test_env: &TestEnvironment) -> Organization {
|
||||
organization
|
||||
}
|
||||
|
||||
pub async fn get_oauth_client_alpha(test_env: &TestEnvironment) -> OAuthClient {
|
||||
let oauth_clients = test_env
|
||||
.v3
|
||||
.get_user_oauth_clients(USER_USER_ID, USER_USER_PAT)
|
||||
.await;
|
||||
oauth_clients.into_iter().next().unwrap()
|
||||
}
|
||||
|
||||
impl DummyJarFile {
|
||||
pub fn filename(&self) -> String {
|
||||
match self {
|
||||
|
||||
@@ -4,6 +4,7 @@ use std::{rc::Rc, sync::Arc};
|
||||
|
||||
use super::{
|
||||
api_v2::ApiV2,
|
||||
api_v3::ApiV3,
|
||||
asserts::assert_status,
|
||||
database::{TemporaryDatabase, FRIEND_USER_ID, USER_USER_PAT},
|
||||
dummy_data,
|
||||
@@ -34,6 +35,7 @@ pub struct TestEnvironment {
|
||||
test_app: Rc<dyn LocalService>, // Rc as it's not Send
|
||||
pub db: TemporaryDatabase,
|
||||
pub v2: ApiV2,
|
||||
pub v3: ApiV3,
|
||||
|
||||
pub dummy: Option<Arc<dummy_data::DummyData>>,
|
||||
}
|
||||
@@ -56,6 +58,9 @@ impl TestEnvironment {
|
||||
v2: ApiV2 {
|
||||
test_app: test_app.clone(),
|
||||
},
|
||||
v3: ApiV3 {
|
||||
test_app: test_app.clone(),
|
||||
},
|
||||
test_app,
|
||||
db,
|
||||
dummy: None,
|
||||
@@ -81,7 +86,27 @@ impl TestEnvironment {
|
||||
USER_USER_PAT,
|
||||
)
|
||||
.await;
|
||||
assert_status(resp, StatusCode::NO_CONTENT);
|
||||
assert_status(&resp, StatusCode::NO_CONTENT);
|
||||
}
|
||||
|
||||
pub async fn assert_read_notifications_status(
|
||||
&self,
|
||||
user_id: &str,
|
||||
pat: &str,
|
||||
status_code: StatusCode,
|
||||
) {
|
||||
let resp = self.v2.get_user_notifications(user_id, pat).await;
|
||||
assert_status(&resp, status_code);
|
||||
}
|
||||
|
||||
pub async fn assert_read_user_projects_status(
|
||||
&self,
|
||||
user_id: &str,
|
||||
pat: &str,
|
||||
status_code: StatusCode,
|
||||
) {
|
||||
let resp = self.v2.get_user_projects(user_id, pat).await;
|
||||
assert_status(&resp, status_code);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ use self::database::TemporaryDatabase;
|
||||
|
||||
pub mod actix;
|
||||
pub mod api_v2;
|
||||
pub mod api_v3;
|
||||
pub mod asserts;
|
||||
pub mod database;
|
||||
pub mod dummy_data;
|
||||
@@ -42,3 +43,11 @@ pub async fn setup(db: &TemporaryDatabase) -> LabrinthConfig {
|
||||
maxmind_reader.clone(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn get_json_val_str(val: impl serde::Serialize) -> String {
|
||||
serde_json::to_value(val)
|
||||
.unwrap()
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
@@ -45,6 +45,25 @@ INSERT INTO categories (id, category, project_type) VALUES
|
||||
(106, 'mobs', 2),
|
||||
(107, 'optimization', 2);
|
||||
|
||||
-- Create dummy oauth client, secret_hash is SHA512 hash of full lowercase alphabet
|
||||
INSERT INTO oauth_clients (
|
||||
id,
|
||||
name,
|
||||
icon_url,
|
||||
max_scopes,
|
||||
secret_hash,
|
||||
created_by
|
||||
)
|
||||
VALUES (
|
||||
1,
|
||||
'oauth_client_alpha',
|
||||
NULL,
|
||||
$1,
|
||||
'4dbff86cc2ca1bae1e16468a05cb9881c97f1753bce3619034898faa1aabe429955a1bf8ec483d7421fe3c1646613a59ed5441fb0f321389f77f48a879c7b1f1',
|
||||
3
|
||||
);
|
||||
INSERT INTO oauth_client_redirect_uris (id, client_id, uri) VALUES (1, 1, 'https://modrinth.com/oauth_callback');
|
||||
|
||||
-- Create dummy data table to mark that this file has been run
|
||||
CREATE TABLE dummy_data (
|
||||
update_id bigint PRIMARY KEY
|
||||
|
||||
292
tests/oauth.rs
Normal file
292
tests/oauth.rs
Normal file
@@ -0,0 +1,292 @@
|
||||
use crate::common::{
|
||||
api_v3::oauth::get_redirect_location_query_params, database::FRIEND_USER_ID,
|
||||
dummy_data::DummyOAuthClientAlpha,
|
||||
};
|
||||
use actix_http::StatusCode;
|
||||
use actix_web::test::{self};
|
||||
use common::{
|
||||
api_v3::oauth::{get_auth_code_from_redirect_params, get_authorize_accept_flow_id},
|
||||
asserts::{assert_any_status_except, assert_status},
|
||||
database::{FRIEND_USER_PAT, USER_USER_ID, USER_USER_PAT},
|
||||
environment::with_test_environment,
|
||||
};
|
||||
use labrinth::auth::oauth::TokenResponse;
|
||||
use reqwest::header::{CACHE_CONTROL, PRAGMA};
|
||||
|
||||
mod common;
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn oauth_flow_happy_path() {
|
||||
with_test_environment(|env| async move {
|
||||
let DummyOAuthClientAlpha {
|
||||
valid_redirect_uri: base_redirect_uri,
|
||||
client_id,
|
||||
client_secret,
|
||||
} = env.dummy.as_ref().unwrap().oauth_client_alpha.clone();
|
||||
|
||||
// Initiate authorization
|
||||
let redirect_uri = format!("{}?foo=bar", base_redirect_uri);
|
||||
let original_state = "1234";
|
||||
let resp = env
|
||||
.v3
|
||||
.oauth_authorize(
|
||||
&client_id,
|
||||
Some("USER_READ NOTIFICATION_READ"),
|
||||
Some(&redirect_uri),
|
||||
Some(original_state),
|
||||
FRIEND_USER_PAT,
|
||||
)
|
||||
.await;
|
||||
assert_status(&resp, StatusCode::OK);
|
||||
let flow_id = get_authorize_accept_flow_id(resp).await;
|
||||
|
||||
// Accept the authorization request
|
||||
let resp = env.v3.oauth_accept(&flow_id, FRIEND_USER_PAT).await;
|
||||
assert_status(&resp, StatusCode::FOUND);
|
||||
let query = get_redirect_location_query_params(&resp);
|
||||
|
||||
let auth_code = query.get("code").unwrap();
|
||||
let state = query.get("state").unwrap();
|
||||
let foo_val = query.get("foo").unwrap();
|
||||
assert_eq!(state, original_state);
|
||||
assert_eq!(foo_val, "bar");
|
||||
|
||||
// Get the token
|
||||
let resp = env
|
||||
.v3
|
||||
.oauth_token(
|
||||
auth_code.to_string(),
|
||||
Some(redirect_uri.clone()),
|
||||
client_id.to_string(),
|
||||
&client_secret,
|
||||
)
|
||||
.await;
|
||||
assert_status(&resp, StatusCode::OK);
|
||||
assert_eq!(resp.headers().get(CACHE_CONTROL).unwrap(), "no-store");
|
||||
assert_eq!(resp.headers().get(PRAGMA).unwrap(), "no-cache");
|
||||
let token_resp: TokenResponse = test::read_body_json(resp).await;
|
||||
|
||||
// Validate the token works
|
||||
env.assert_read_notifications_status(
|
||||
FRIEND_USER_ID,
|
||||
&token_resp.access_token,
|
||||
StatusCode::OK,
|
||||
)
|
||||
.await;
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn oauth_authorize_for_already_authorized_scopes_returns_auth_code() {
|
||||
with_test_environment(|env| async {
|
||||
let DummyOAuthClientAlpha { client_id, .. } = env.dummy.unwrap().oauth_client_alpha.clone();
|
||||
|
||||
let resp = env
|
||||
.v3
|
||||
.oauth_authorize(
|
||||
&client_id,
|
||||
Some("USER_READ NOTIFICATION_READ"),
|
||||
None,
|
||||
Some("1234"),
|
||||
USER_USER_PAT,
|
||||
)
|
||||
.await;
|
||||
let flow_id = get_authorize_accept_flow_id(resp).await;
|
||||
env.v3.oauth_accept(&flow_id, USER_USER_PAT).await;
|
||||
|
||||
let resp = env
|
||||
.v3
|
||||
.oauth_authorize(
|
||||
&client_id,
|
||||
Some("USER_READ"),
|
||||
None,
|
||||
Some("5678"),
|
||||
USER_USER_PAT,
|
||||
)
|
||||
.await;
|
||||
assert_status(&resp, StatusCode::FOUND);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn get_oauth_token_with_already_used_auth_code_fails() {
|
||||
with_test_environment(|env| async {
|
||||
let DummyOAuthClientAlpha {
|
||||
client_id,
|
||||
client_secret,
|
||||
..
|
||||
} = env.dummy.unwrap().oauth_client_alpha.clone();
|
||||
|
||||
let resp = env
|
||||
.v3
|
||||
.oauth_authorize(&client_id, None, None, None, USER_USER_PAT)
|
||||
.await;
|
||||
let flow_id = get_authorize_accept_flow_id(resp).await;
|
||||
|
||||
let resp = env.v3.oauth_accept(&flow_id, USER_USER_PAT).await;
|
||||
let auth_code = get_auth_code_from_redirect_params(&resp).await;
|
||||
|
||||
let resp = env
|
||||
.v3
|
||||
.oauth_token(auth_code.clone(), None, client_id.clone(), &client_secret)
|
||||
.await;
|
||||
assert_status(&resp, StatusCode::OK);
|
||||
|
||||
let resp = env
|
||||
.v3
|
||||
.oauth_token(auth_code, None, client_id, &client_secret)
|
||||
.await;
|
||||
assert_status(&resp, StatusCode::BAD_REQUEST);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn authorize_with_broader_scopes_can_complete_flow() {
|
||||
with_test_environment(|env| async move {
|
||||
let DummyOAuthClientAlpha {
|
||||
client_id,
|
||||
client_secret,
|
||||
..
|
||||
} = env.dummy.as_ref().unwrap().oauth_client_alpha.clone();
|
||||
|
||||
let first_access_token = env
|
||||
.v3
|
||||
.complete_full_authorize_flow(
|
||||
&client_id,
|
||||
&client_secret,
|
||||
Some("PROJECT_READ"),
|
||||
None,
|
||||
None,
|
||||
USER_USER_PAT,
|
||||
)
|
||||
.await;
|
||||
let second_access_token = env
|
||||
.v3
|
||||
.complete_full_authorize_flow(
|
||||
&client_id,
|
||||
&client_secret,
|
||||
Some("PROJECT_READ NOTIFICATION_READ"),
|
||||
None,
|
||||
None,
|
||||
USER_USER_PAT,
|
||||
)
|
||||
.await;
|
||||
|
||||
env.assert_read_notifications_status(
|
||||
USER_USER_ID,
|
||||
&first_access_token,
|
||||
StatusCode::UNAUTHORIZED,
|
||||
)
|
||||
.await;
|
||||
env.assert_read_user_projects_status(USER_USER_ID, &first_access_token, StatusCode::OK)
|
||||
.await;
|
||||
|
||||
env.assert_read_notifications_status(USER_USER_ID, &second_access_token, StatusCode::OK)
|
||||
.await;
|
||||
env.assert_read_user_projects_status(USER_USER_ID, &second_access_token, StatusCode::OK)
|
||||
.await;
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn oauth_authorize_with_broader_scopes_requires_user_accept() {
|
||||
with_test_environment(|env| async {
|
||||
let client_id = env.dummy.unwrap().oauth_client_alpha.client_id.clone();
|
||||
let resp = env
|
||||
.v3
|
||||
.oauth_authorize(&client_id, Some("USER_READ"), None, None, USER_USER_PAT)
|
||||
.await;
|
||||
let flow_id = get_authorize_accept_flow_id(resp).await;
|
||||
env.v3.oauth_accept(&flow_id, USER_USER_PAT).await;
|
||||
|
||||
let resp = env
|
||||
.v3
|
||||
.oauth_authorize(
|
||||
&client_id,
|
||||
Some("USER_READ NOTIFICATION_READ"),
|
||||
None,
|
||||
None,
|
||||
USER_USER_PAT,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_status(&resp, StatusCode::OK);
|
||||
get_authorize_accept_flow_id(resp).await; // ensure we can deser this without error to really confirm
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn reject_authorize_ends_authorize_flow() {
|
||||
with_test_environment(|env| async move {
|
||||
let client_id = env.dummy.unwrap().oauth_client_alpha.client_id.clone();
|
||||
let resp = env
|
||||
.v3
|
||||
.oauth_authorize(&client_id, None, None, None, USER_USER_PAT)
|
||||
.await;
|
||||
let flow_id = get_authorize_accept_flow_id(resp).await;
|
||||
|
||||
let resp = env.v3.oauth_reject(&flow_id, USER_USER_PAT).await;
|
||||
assert_status(&resp, StatusCode::FOUND);
|
||||
|
||||
let resp = env.v3.oauth_accept(&flow_id, USER_USER_PAT).await;
|
||||
assert_any_status_except(&resp, StatusCode::FOUND);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn accept_authorize_after_already_accepting_fails() {
|
||||
with_test_environment(|env| async move {
|
||||
let client_id = env.dummy.unwrap().oauth_client_alpha.client_id.clone();
|
||||
let resp = env
|
||||
.v3
|
||||
.oauth_authorize(&client_id, None, None, None, USER_USER_PAT)
|
||||
.await;
|
||||
let flow_id = get_authorize_accept_flow_id(resp).await;
|
||||
let resp = env.v3.oauth_accept(&flow_id, USER_USER_PAT).await;
|
||||
assert_status(&resp, StatusCode::FOUND);
|
||||
|
||||
let resp = env.v3.oauth_accept(&flow_id, USER_USER_PAT).await;
|
||||
assert_status(&resp, StatusCode::BAD_REQUEST);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn revoke_authorization_after_issuing_token_revokes_token() {
|
||||
with_test_environment(|env| async move {
|
||||
let DummyOAuthClientAlpha {
|
||||
client_id,
|
||||
client_secret,
|
||||
..
|
||||
} = env.dummy.as_ref().unwrap().oauth_client_alpha.clone();
|
||||
let access_token = env
|
||||
.v3
|
||||
.complete_full_authorize_flow(
|
||||
&client_id,
|
||||
&client_secret,
|
||||
Some("NOTIFICATION_READ"),
|
||||
None,
|
||||
None,
|
||||
USER_USER_PAT,
|
||||
)
|
||||
.await;
|
||||
env.assert_read_notifications_status(USER_USER_ID, &access_token, StatusCode::OK)
|
||||
.await;
|
||||
|
||||
let resp = env
|
||||
.v3
|
||||
.revoke_oauth_authorization(&client_id, USER_USER_PAT)
|
||||
.await;
|
||||
assert_status(&resp, StatusCode::OK);
|
||||
|
||||
env.assert_read_notifications_status(USER_USER_ID, &access_token, StatusCode::UNAUTHORIZED)
|
||||
.await;
|
||||
})
|
||||
.await;
|
||||
}
|
||||
193
tests/oauth_clients.rs
Normal file
193
tests/oauth_clients.rs
Normal file
@@ -0,0 +1,193 @@
|
||||
use actix_http::StatusCode;
|
||||
use actix_web::test;
|
||||
use common::{
|
||||
database::{FRIEND_USER_ID, FRIEND_USER_PAT, USER_USER_ID, USER_USER_PAT},
|
||||
dummy_data::DummyOAuthClientAlpha,
|
||||
environment::with_test_environment,
|
||||
get_json_val_str,
|
||||
};
|
||||
use labrinth::{
|
||||
models::{
|
||||
oauth_clients::{OAuthClient, OAuthClientCreationResult},
|
||||
pats::Scopes,
|
||||
},
|
||||
routes::v3::oauth_clients::OAuthClientEdit,
|
||||
};
|
||||
|
||||
use crate::common::{asserts::assert_status, database::USER_USER_ID_PARSED};
|
||||
|
||||
mod common;
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn can_create_edit_get_oauth_client() {
|
||||
with_test_environment(|env| async move {
|
||||
let client_name = "test_client".to_string();
|
||||
let redirect_uris = vec![
|
||||
"https://modrinth.com".to_string(),
|
||||
"https://modrinth.com/a".to_string(),
|
||||
];
|
||||
let resp = env
|
||||
.v3
|
||||
.add_oauth_client(
|
||||
client_name.clone(),
|
||||
Scopes::all() - Scopes::restricted(),
|
||||
redirect_uris.clone(),
|
||||
FRIEND_USER_PAT,
|
||||
)
|
||||
.await;
|
||||
assert_status(&resp, StatusCode::OK);
|
||||
let creation_result: OAuthClientCreationResult = test::read_body_json(resp).await;
|
||||
let client_id = get_json_val_str(creation_result.client.id);
|
||||
|
||||
let icon_url = Some("https://modrinth.com/icon".to_string());
|
||||
let edited_redirect_uris = vec![
|
||||
redirect_uris[0].clone(),
|
||||
"https://modrinth.com/b".to_string(),
|
||||
];
|
||||
let edit = OAuthClientEdit {
|
||||
name: None,
|
||||
icon_url: Some(icon_url.clone()),
|
||||
max_scopes: None,
|
||||
redirect_uris: Some(edited_redirect_uris.clone()),
|
||||
};
|
||||
let resp = env
|
||||
.v3
|
||||
.edit_oauth_client(&client_id, edit, FRIEND_USER_PAT)
|
||||
.await;
|
||||
assert_status(&resp, StatusCode::OK);
|
||||
|
||||
let clients = env
|
||||
.v3
|
||||
.get_user_oauth_clients(FRIEND_USER_ID, FRIEND_USER_PAT)
|
||||
.await;
|
||||
assert_eq!(1, clients.len());
|
||||
assert_eq!(icon_url, clients[0].icon_url);
|
||||
assert_eq!(client_name, clients[0].name);
|
||||
assert_eq!(2, clients[0].redirect_uris.len());
|
||||
assert_eq!(edited_redirect_uris[0], clients[0].redirect_uris[0].uri);
|
||||
assert_eq!(edited_redirect_uris[1], clients[0].redirect_uris[1].uri);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn create_oauth_client_with_restricted_scopes_fails() {
|
||||
with_test_environment(|env| async move {
|
||||
let resp = env
|
||||
.v3
|
||||
.add_oauth_client(
|
||||
"test_client".to_string(),
|
||||
Scopes::restricted(),
|
||||
vec!["https://modrinth.com".to_string()],
|
||||
FRIEND_USER_PAT,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_status(&resp, StatusCode::BAD_REQUEST);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn get_oauth_client_for_client_creator_succeeds() {
|
||||
with_test_environment(|env| async move {
|
||||
let DummyOAuthClientAlpha { client_id, .. } =
|
||||
env.dummy.as_ref().unwrap().oauth_client_alpha.clone();
|
||||
|
||||
let resp = env
|
||||
.v3
|
||||
.get_oauth_client(client_id.clone(), USER_USER_PAT)
|
||||
.await;
|
||||
|
||||
assert_status(&resp, StatusCode::OK);
|
||||
let client: OAuthClient = test::read_body_json(resp).await;
|
||||
assert_eq!(get_json_val_str(client.id), client_id);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn get_oauth_client_for_unrelated_user_fails() {
|
||||
with_test_environment(|env| async move {
|
||||
let DummyOAuthClientAlpha { client_id, .. } =
|
||||
env.dummy.as_ref().unwrap().oauth_client_alpha.clone();
|
||||
|
||||
let resp = env
|
||||
.v3
|
||||
.get_oauth_client(client_id.clone(), FRIEND_USER_PAT)
|
||||
.await;
|
||||
|
||||
assert_status(&resp, StatusCode::UNAUTHORIZED);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn can_delete_oauth_client() {
|
||||
with_test_environment(|env| async move {
|
||||
let client_id = env.dummy.unwrap().oauth_client_alpha.client_id.clone();
|
||||
let resp = env.v3.delete_oauth_client(&client_id, USER_USER_PAT).await;
|
||||
assert_status(&resp, StatusCode::NO_CONTENT);
|
||||
|
||||
let clients = env
|
||||
.v3
|
||||
.get_user_oauth_clients(USER_USER_ID, USER_USER_PAT)
|
||||
.await;
|
||||
assert_eq!(0, clients.len());
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn delete_oauth_client_after_issuing_access_tokens_revokes_tokens() {
|
||||
with_test_environment(|env| async move {
|
||||
let DummyOAuthClientAlpha {
|
||||
client_id,
|
||||
client_secret,
|
||||
..
|
||||
} = env.dummy.as_ref().unwrap().oauth_client_alpha.clone();
|
||||
let access_token = env
|
||||
.v3
|
||||
.complete_full_authorize_flow(
|
||||
&client_id,
|
||||
&client_secret,
|
||||
Some("NOTIFICATION_READ"),
|
||||
None,
|
||||
None,
|
||||
USER_USER_PAT,
|
||||
)
|
||||
.await;
|
||||
|
||||
env.v3.delete_oauth_client(&client_id, USER_USER_PAT).await;
|
||||
|
||||
env.assert_read_notifications_status(USER_USER_ID, &access_token, StatusCode::UNAUTHORIZED)
|
||||
.await;
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn can_list_user_oauth_authorizations() {
|
||||
with_test_environment(|env| async move {
|
||||
let DummyOAuthClientAlpha {
|
||||
client_id,
|
||||
client_secret,
|
||||
..
|
||||
} = env.dummy.as_ref().unwrap().oauth_client_alpha.clone();
|
||||
env.v3
|
||||
.complete_full_authorize_flow(
|
||||
&client_id,
|
||||
&client_secret,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
USER_USER_PAT,
|
||||
)
|
||||
.await;
|
||||
|
||||
let authorizations = env.v3.get_user_oauth_authorizations(USER_USER_PAT).await;
|
||||
assert_eq!(1, authorizations.len());
|
||||
assert_eq!(USER_USER_ID_PARSED, authorizations[0].user_id.0 as i64);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
Reference in New Issue
Block a user