Skip to content

Commit 449fa15

Browse files
authored
Merge pull request #49 from Quantus-Network/feat/update-linking-x-flow
Update linking X flow
2 parents 791c053 + 9a6ce55 commit 449fa15

13 files changed

Lines changed: 305 additions & 13 deletions

File tree

Cargo.lock

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ path = "src/bin/create_raid.rs"
2424
qp-human-checkphrase = "0.1.2"
2525
qp-rusty-crystals-dilithium = "2.0.0"
2626
quantus-cli = "0.3.0"
27-
rusx = {git = "https://github.com/Quantus-Network/rusx", tag = "v0.6.0"}
27+
rusx = {git = "https://github.com/Quantus-Network/rusx", tag = "v0.6.1"}
2828

2929
# Async runtime
3030
tokio = {version = "1.46", features = ["full", "test-util"]}
@@ -90,4 +90,4 @@ tiny-keccak = {version = "2.0.2", features = ["keccak"]}
9090
mockall = "0.13"
9191
wiremock = "0.5"
9292
# Enable the testing feature ONLY for tests
93-
rusx = {git = "https://github.com/Quantus-Network/rusx", tag = "v0.6.0", features = ["testing"]}
93+
rusx = {git = "https://github.com/Quantus-Network/rusx", tag = "v0.6.1", features = ["testing"]}

config/default.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,9 @@ callback_url = "http://localhost:3000/api/auth/x/callback"
5454
client_id = "WlVrcm4xSEpXQ2l3TURFM3lLZnE6MTpjaQ"
5555
client_secret = "lfXc45dZLqYTzP62Ms32EhXinGQzxcIP9TvjJml2B-h0T1nIJK"
5656

57+
[x_association]
58+
bio_mention = "@QuantusNetwork"
59+
5760
[tweet_sync]
5861
api_key = "some-key"
5962
interval_in_hours = 24

config/example.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,9 @@ callback_url = "http://localhost:12345/example/callback"
6464
client_id = "example-id"
6565
client_secret = "example-secret"
6666

67+
[x_association]
68+
bio_mention = "@QuantusNetwork"
69+
6770
[tweet_sync]
6871
api_key = "some-key"
6972
interval_in_hours = 24

config/test.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,9 @@ callback_url = "http://localhost:12345/api/auth/x/callback"
5454
client_id = "test-id"
5555
client_secret = "test-secret"
5656

57+
[x_association]
58+
bio_mention = "@QuantusNetwork"
59+
5760
[tweet_sync]
5861
api_key = "some-key"
5962
interval_in_hours = 24

src/config.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ pub struct Config {
1717
pub tg_bot: TelegramBotConfig,
1818
pub raid_leaderboard: RaidLeaderboardConfig,
1919
pub alert: AlertConfig,
20+
pub x_association: XAssociationConfig,
2021
}
2122

2223
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -99,6 +100,11 @@ pub struct AlertConfig {
99100
pub webhook_url: String,
100101
}
101102

103+
#[derive(Debug, Clone, Serialize, Deserialize)]
104+
pub struct XAssociationConfig {
105+
pub bio_mention: String,
106+
}
107+
102108
impl Config {
103109
pub fn load(config_path: &str) -> Result<Self, config::ConfigError> {
104110
let settings = config::Config::builder()
@@ -169,6 +175,10 @@ impl Config {
169175
pub fn get_raid_leaderboard_tweets_req_interval(&self) -> time::Duration {
170176
time::Duration::from_secs(self.raid_leaderboard.tweets_req_interval_in_secs)
171177
}
178+
179+
pub fn get_x_bio_mention(&self) -> &str {
180+
&self.x_association.bio_mention
181+
}
172182
}
173183

174184
impl Default for Config {
@@ -235,6 +245,9 @@ impl Default for Config {
235245
alert: AlertConfig {
236246
webhook_url: "https://your-webhook-url.com".to_string(),
237247
},
248+
x_association: XAssociationConfig {
249+
bio_mention: "@QuantusNetwork".to_string(),
250+
},
238251
}
239252
}
240253
}

src/handlers/address.rs

Lines changed: 263 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,13 @@ use crate::{
2121
eth_association::{
2222
AssociateEthAddressRequest, AssociateEthAddressResponse, EthAssociation, EthAssociationInput,
2323
},
24+
x_association::{AssociateXHandleRequest, XAssociation, XAssociationInput},
2425
},
2526
AppError,
2627
};
2728

29+
use rusx::resources::{user::UserParams, UserField};
30+
2831
use super::SuccessResponse;
2932

3033
#[derive(Debug, thiserror::Error)]
@@ -222,6 +225,65 @@ pub async fn associate_eth_address(
222225
Ok(Json(response))
223226
}
224227

228+
pub async fn associate_x_handle(
229+
State(state): State<AppState>,
230+
Extension(user): Extension<Address>,
231+
Json(payload): Json<AssociateXHandleRequest>,
232+
) -> Result<NoContent, AppError> {
233+
tracing::info!(
234+
"Received X handle association request for quan_address: {} -> username: {}",
235+
user.quan_address.0,
236+
payload.username,
237+
);
238+
239+
let mut params = UserParams::new();
240+
params.user_fields = Some(vec![UserField::Description, UserField::Username]);
241+
242+
let user_resp = state
243+
.twitter_gateway
244+
.users()
245+
.get_by_username(&payload.username, Some(params))
246+
.await
247+
.map_err(|e| {
248+
tracing::error!("Failed to fetch user by username {}: {:?}", payload.username, e);
249+
AppError::Handler(HandlerError::Address(AddressHandlerError::InvalidQueryParams(format!(
250+
"Failed to verify Twitter username: {}",
251+
e
252+
))))
253+
})?;
254+
255+
let twitter_user = user_resp.data.ok_or_else(|| {
256+
AppError::Handler(HandlerError::Address(AddressHandlerError::InvalidQueryParams(
257+
"Twitter user not found".to_string(),
258+
)))
259+
})?;
260+
261+
let bio = twitter_user.description.unwrap_or_default();
262+
let x_bio_mention = state.config.get_x_bio_mention();
263+
if !bio.to_lowercase().contains(&x_bio_mention.to_lowercase()) {
264+
return Err(AppError::Handler(HandlerError::Address(
265+
AddressHandlerError::Unauthorized(format!(
266+
"Twitter bio must contain '{}' to verify ownership",
267+
x_bio_mention
268+
)),
269+
)));
270+
}
271+
272+
let new_association = XAssociation::new(XAssociationInput {
273+
quan_address: user.quan_address.0,
274+
username: twitter_user.username,
275+
})?;
276+
277+
state.db.x_associations.create(&new_association).await?;
278+
tracing::info!(
279+
"Created association for quan_address {} with X username {}",
280+
new_association.quan_address.0,
281+
new_association.username
282+
);
283+
284+
Ok(NoContent)
285+
}
286+
225287
pub async fn update_eth_address(
226288
State(state): State<AppState>,
227289
Extension(user): Extension<Address>,
@@ -377,6 +439,207 @@ mod tests {
377439
use tower::ServiceExt;
378440
use uuid::Uuid; // Required for .oneshot()
379441

442+
use rusx::{
443+
resources::{
444+
user::{User, UserApi},
445+
TwitterApiResponse,
446+
},
447+
MockTwitterGateway, MockUserApi,
448+
};
449+
use std::sync::Arc;
450+
451+
#[tokio::test]
452+
async fn test_associate_x_handle_success() {
453+
let mut state = create_test_app_state().await;
454+
reset_database(&state.db.pool).await;
455+
456+
// 1. Setup User & Token
457+
let user = create_persisted_address(&state.db.addresses, "108").await;
458+
let token = generate_test_token(&state.config.jwt.secret, &user.quan_address.0);
459+
460+
// 2. Mock Twitter Gateway
461+
let mut mock_gateway = MockTwitterGateway::new();
462+
let mut mock_user_api = MockUserApi::new();
463+
464+
// Expect get_by_username
465+
let bio_mention = state.config.get_x_bio_mention().to_string();
466+
mock_user_api.expect_get_by_username().returning(move |_, _| {
467+
Ok(TwitterApiResponse {
468+
data: Some(User {
469+
id: "u1".to_string(),
470+
name: "Test User".to_string(),
471+
username: "test_user".to_string(),
472+
description: Some(format!("I love {}", bio_mention)), // Contains keyword from config
473+
public_metrics: None,
474+
}),
475+
includes: None,
476+
meta: None,
477+
})
478+
});
479+
480+
let user_api_arc: Arc<dyn UserApi> = Arc::new(mock_user_api);
481+
mock_gateway.expect_users().return_const(user_api_arc);
482+
483+
state.twitter_gateway = Arc::new(mock_gateway);
484+
485+
// 3. Setup Router
486+
let router = Router::new()
487+
.route("/associate-x", post(associate_x_handle))
488+
.layer(middleware::from_fn_with_state(state.clone(), jwt_auth))
489+
.with_state(state.clone());
490+
491+
// 4. Request
492+
let payload = json!({ "username": "test_user" });
493+
let response = router
494+
.oneshot(
495+
Request::builder()
496+
.method("POST")
497+
.uri("/associate-x")
498+
.header(http::header::CONTENT_TYPE, "application/json")
499+
.header(http::header::AUTHORIZATION, format!("Bearer {}", token))
500+
.body(Body::from(serde_json::to_string(&payload).unwrap()))
501+
.unwrap(),
502+
)
503+
.await
504+
.unwrap();
505+
506+
// 5. Assert
507+
assert_eq!(response.status(), StatusCode::NO_CONTENT);
508+
509+
// Check DB
510+
let assoc = state
511+
.db
512+
.x_associations
513+
.find_by_address(&user.quan_address)
514+
.await
515+
.unwrap();
516+
assert!(assoc.is_some());
517+
assert_eq!(assoc.unwrap().username, "test_user");
518+
}
519+
520+
#[tokio::test]
521+
async fn test_associate_x_handle_fails_bad_bio() {
522+
let mut state = create_test_app_state().await;
523+
reset_database(&state.db.pool).await;
524+
525+
let user = create_persisted_address(&state.db.addresses, "109").await;
526+
let token = generate_test_token(&state.config.jwt.secret, &user.quan_address.0);
527+
528+
let mut mock_gateway = MockTwitterGateway::new();
529+
let mut mock_user_api = MockUserApi::new();
530+
531+
mock_user_api.expect_get_by_username().returning(|_, _| {
532+
Ok(TwitterApiResponse {
533+
data: Some(User {
534+
id: "u1".to_string(),
535+
name: "Test User".to_string(),
536+
username: "test_user".to_string(),
537+
description: Some("No keyword here".to_string()), // Missing keyword
538+
public_metrics: None,
539+
}),
540+
includes: None,
541+
meta: None,
542+
})
543+
});
544+
545+
let user_api_arc: Arc<dyn UserApi> = Arc::new(mock_user_api);
546+
mock_gateway.expect_users().return_const(user_api_arc);
547+
state.twitter_gateway = Arc::new(mock_gateway);
548+
549+
let router = Router::new()
550+
.route("/associate-x", post(associate_x_handle))
551+
.layer(middleware::from_fn_with_state(state.clone(), jwt_auth))
552+
.with_state(state);
553+
554+
let payload = json!({ "username": "test_user" });
555+
let response = router
556+
.oneshot(
557+
Request::builder()
558+
.method("POST")
559+
.uri("/associate-x")
560+
.header(http::header::CONTENT_TYPE, "application/json")
561+
.header(http::header::AUTHORIZATION, format!("Bearer {}", token))
562+
.body(Body::from(serde_json::to_string(&payload).unwrap()))
563+
.unwrap(),
564+
)
565+
.await
566+
.unwrap();
567+
568+
// Should return 401 Unauthorized
569+
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
570+
}
571+
572+
#[tokio::test]
573+
async fn test_associate_x_handle_case_insensitive_success() {
574+
let mut state = create_test_app_state().await;
575+
reset_database(&state.db.pool).await;
576+
577+
// 1. Setup User & Token
578+
let user = create_persisted_address(&state.db.addresses, "110").await;
579+
let token = generate_test_token(&state.config.jwt.secret, &user.quan_address.0);
580+
581+
// 2. Mock Twitter Gateway
582+
let mut mock_gateway = MockTwitterGateway::new();
583+
let mut mock_user_api = MockUserApi::new();
584+
585+
// Expect get_by_username
586+
let bio_mention = state.config.get_x_bio_mention().to_string();
587+
// Create a lowercase version of the mention for the bio
588+
let lowercase_bio_mention = bio_mention.to_lowercase();
589+
590+
mock_user_api.expect_get_by_username().returning(move |_, _| {
591+
Ok(TwitterApiResponse {
592+
data: Some(User {
593+
id: "u1".to_string(),
594+
name: "Test User".to_string(),
595+
username: "test_user".to_string(),
596+
description: Some(format!("I love {}", lowercase_bio_mention)), // Contains lowercase keyword
597+
public_metrics: None,
598+
}),
599+
includes: None,
600+
meta: None,
601+
})
602+
});
603+
604+
let user_api_arc: Arc<dyn UserApi> = Arc::new(mock_user_api);
605+
mock_gateway.expect_users().return_const(user_api_arc);
606+
607+
state.twitter_gateway = Arc::new(mock_gateway);
608+
609+
// 3. Setup Router
610+
let router = Router::new()
611+
.route("/associate-x", post(associate_x_handle))
612+
.layer(middleware::from_fn_with_state(state.clone(), jwt_auth))
613+
.with_state(state.clone());
614+
615+
// 4. Request
616+
let payload = json!({ "username": "test_user" });
617+
let response = router
618+
.oneshot(
619+
Request::builder()
620+
.method("POST")
621+
.uri("/associate-x")
622+
.header(http::header::CONTENT_TYPE, "application/json")
623+
.header(http::header::AUTHORIZATION, format!("Bearer {}", token))
624+
.body(Body::from(serde_json::to_string(&payload).unwrap()))
625+
.unwrap(),
626+
)
627+
.await
628+
.unwrap();
629+
630+
// 5. Assert - Should be successful even with different case
631+
assert_eq!(response.status(), StatusCode::NO_CONTENT);
632+
633+
// Check DB
634+
let assoc = state
635+
.db
636+
.x_associations
637+
.find_by_address(&user.quan_address)
638+
.await
639+
.unwrap();
640+
assert!(assoc.is_some());
641+
}
642+
380643
#[tokio::test]
381644
async fn test_update_eth_address_success() {
382645
let state = create_test_app_state().await;

src/handlers/auth.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -443,6 +443,7 @@ mod tests {
443443
id: "101".to_string(),
444444
name: "Quantus Network".to_string(),
445445
username: expected_username.to_string(),
446+
description: Some("Quantus Network".to_string()),
446447
public_metrics: Default::default(),
447448
}),
448449
includes: Default::default(),

0 commit comments

Comments
 (0)