learn and share notes on atproto (wip) 🦉 malfestio.stormlightlabs.org/
readability solid axum atproto srs
5
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: `me` endpoint now returns authenticated user details

+131 -12
+13 -3
crates/server/src/api/auth.rs
··· 62 62 } 63 63 } 64 64 65 - /// TODO: replace with middleware 66 - pub async fn me() -> impl IntoResponse { 67 - Json(json!({ "status": "authenticated" })) 65 + pub async fn me(ctx: Option<axum::Extension<crate::middleware::auth::UserContext>>) -> impl IntoResponse { 66 + match ctx { 67 + Some(axum::Extension(user)) => ( 68 + StatusCode::OK, 69 + Json(json!({ 70 + "status": "authenticated", 71 + "did": user.did, 72 + "handle": user.handle 73 + })), 74 + ) 75 + .into_response(), 76 + None => (StatusCode::UNAUTHORIZED, Json(json!({ "error": "Unauthorized" }))).into_response(), 77 + } 68 78 }
+68 -2
crates/server/src/api/deck.rs
··· 25 25 pub published: bool, 26 26 } 27 27 28 - // TODO: add tests 29 28 pub async fn create_deck( 30 29 State(state): State<SharedState>, ctx: Option<axum::Extension<UserContext>>, Json(payload): Json<CreateDeckRequest>, 31 30 ) -> impl IntoResponse { ··· 80 79 81 80 match state.deck_repo.get(&id).await { 82 81 Ok(deck) => { 83 - // Access control check 84 82 let is_owner = user_did.as_ref() == Some(&deck.owner_did); 85 83 let has_access = match &deck.visibility { 86 84 Visibility::Public | Visibility::Unlisted => true, ··· 247 245 } 248 246 } 249 247 } 248 + 249 + #[cfg(test)] 250 + mod tests { 251 + use super::*; 252 + use crate::middleware::auth::UserContext; 253 + use crate::state::AppState; 254 + use axum::extract::{Extension, State}; 255 + use axum::http::StatusCode; 256 + use axum::response::IntoResponse; 257 + use malfestio_core::model::Visibility; 258 + use std::sync::Arc; 259 + 260 + #[tokio::test] 261 + async fn test_create_deck_success() { 262 + let pool = crate::db::create_pool("postgres://postgres:postgres@localhost:5432/malfestio").unwrap(); 263 + 264 + let state = AppState::new_with_repos( 265 + pool, 266 + Arc::new(crate::repository::card::mock::MockCardRepository::new()), 267 + Arc::new(crate::repository::note::mock::MockNoteRepository::new()), 268 + Arc::new(crate::repository::oauth::mock::MockOAuthRepository::new()), 269 + ); 270 + 271 + let user = UserContext { did: "did:plc:alice".to_string(), handle: "alice.bsky.social".to_string() }; 272 + 273 + let payload = CreateDeckRequest { 274 + title: "My New Deck".to_string(), 275 + description: "A test deck".to_string(), 276 + tags: vec!["rust".to_string()], 277 + visibility: Visibility::Public, 278 + }; 279 + 280 + let response = create_deck(State(state), Some(Extension(user)), Json(payload)) 281 + .await 282 + .into_response(); 283 + 284 + assert_eq!(response.status(), StatusCode::CREATED); 285 + 286 + let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap(); 287 + let body_json: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap(); 288 + 289 + assert_eq!(body_json["title"], "My New Deck"); 290 + assert_eq!(body_json["owner_did"], "did:plc:alice"); 291 + assert_eq!(body_json["visibility"]["type"], "Public"); 292 + } 293 + 294 + #[tokio::test] 295 + async fn test_create_deck_unauthorized() { 296 + let pool = crate::db::create_pool("postgres://postgres:postgres@localhost:5432/malfestio").unwrap(); 297 + let state = AppState::new_with_repos( 298 + pool, 299 + Arc::new(crate::repository::card::mock::MockCardRepository::new()), 300 + Arc::new(crate::repository::note::mock::MockNoteRepository::new()), 301 + Arc::new(crate::repository::oauth::mock::MockOAuthRepository::new()), 302 + ); 303 + 304 + let payload = CreateDeckRequest { 305 + title: "My New Deck".to_string(), 306 + description: "A test deck".to_string(), 307 + tags: vec![], 308 + visibility: Visibility::Public, 309 + }; 310 + 311 + let response = create_deck(State(state), None, Json(payload)).await.into_response(); 312 + 313 + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); 314 + } 315 + }
+32 -1
crates/server/src/api/importer.rs
··· 8 8 url: String, 9 9 } 10 10 11 - // TODO: add tests 12 11 pub async fn import_article(Json(payload): Json<ImportRequest>) -> impl IntoResponse { 13 12 if payload.url.trim().is_empty() { 14 13 return (StatusCode::BAD_REQUEST, Json(json!({"error": "URL is required"}))).into_response(); ··· 39 38 .into_response(), 40 39 } 41 40 } 41 + 42 + #[cfg(test)] 43 + mod tests { 44 + use super::*; 45 + use axum::http::StatusCode; 46 + use axum::response::IntoResponse; 47 + 48 + #[tokio::test] 49 + async fn test_import_article_wikipedia() { 50 + let payload = ImportRequest { url: "https://www.rust-lang.org".to_string() }; 51 + let response = import_article(Json(payload)).await.into_response(); 52 + let status = response.status(); 53 + if status != StatusCode::OK { 54 + let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap(); 55 + let body_str = String::from_utf8(body_bytes.to_vec()).unwrap(); 56 + panic!("Test failed with status {}. Body: {}", status, body_str); 57 + } 58 + 59 + let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap(); 60 + let body_json: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap(); 61 + let title = body_json["title"].as_str().unwrap(); 62 + assert!(title.contains("Rust")); 63 + assert!(body_json["text"].as_str().unwrap().len() > 100); 64 + } 65 + 66 + #[tokio::test] 67 + async fn test_import_article_empty_url() { 68 + let payload = ImportRequest { url: " ".to_string() }; 69 + let response = import_article(Json(payload)).await.into_response(); 70 + assert_eq!(response.status(), StatusCode::BAD_REQUEST); 71 + } 72 + }
+7 -1
crates/server/src/api/social.rs
··· 252 252 .into_response(); 253 253 254 254 assert_eq!(response.status(), StatusCode::OK); 255 - // TODO: parse body to verify content 255 + 256 + let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap(); 257 + let followers: Vec<String> = serde_json::from_slice(&body_bytes).unwrap(); 258 + 259 + assert_eq!(followers.len(), 2); 260 + assert!(followers.contains(&"did:plc:1".to_string())); 261 + assert!(followers.contains(&"did:plc:2".to_string())); 256 262 } 257 263 258 264 #[tokio::test]
+11 -5
crates/server/src/middleware/auth.rs
··· 1 1 use crate::state::SharedState; 2 + 2 3 use axum::{ 3 4 extract::{Request, State}, 4 5 http::{self}, ··· 14 15 pub handle: String, 15 16 } 16 17 17 - /// Cache expiry time 18 - const CACHE_TTL: Duration = Duration::from_secs(300); // 5 minutes 18 + /// Cache expiry time (5 minutes) 19 + const CACHE_TTL: Duration = Duration::from_secs(300); 19 20 20 - /// TODO: Cache or use signature verification for performance 21 + /// Delegated Authentication Strategy: 22 + /// 23 + /// We verify the token by calling the PDS `getSession` endpoint. 24 + /// To improve performance, we cache the result for a short duration (TTL). 25 + /// This avoids validating the JWT signature locally, which simplifies key management 26 + /// (no need to fetch/rotate PDS public keys) while maintaining security via the PDS. 27 + /// 28 + /// NOTE: This assumes the PDS is trusted. 21 29 pub async fn auth_middleware(State(state): State<SharedState>, mut req: Request, next: Next) -> Response { 22 30 let auth_header = req.headers().get(http::header::AUTHORIZATION); 23 31 ··· 56 64 let body: serde_json::Value = response.json().await.unwrap_or_default(); 57 65 let did = body["did"].as_str().unwrap_or("").to_string(); 58 66 let handle = body["handle"].as_str().unwrap_or("").to_string(); 59 - 60 67 let user_ctx = UserContext { did, handle }; 61 68 62 - // Update cache 63 69 { 64 70 let mut cache = state.auth_cache.write().await; 65 71 cache.insert(token.to_string(), (user_ctx.clone(), Instant::now()));