use super::*; async fn setup() -> (axum::Router, String, raisfast::db::Pool) { let (app, state) = test_app().await; let (int_id, doc_id) = create_admin(&state.pool).await; let tok = make_token(&doc_id, int_id, raisfast::models::user::UserRole::Admin); (app, tok, state.pool) } #[tokio::test] async fn create_token_success() { let (mut app, tok, _) = setup().await; let (status, body) = send( &mut app, post_json_auth( "/api/v1/tokens", json!({"name": "CI/CD", "scopes": ["read", "write"]}), &tok, ), ) .await; assert_eq!(status, StatusCode::CREATED); let data = &body["data"]; assert!(data["token"].as_str().unwrap().starts_with("rblog_")); assert_eq!(data["name"].as_str().unwrap(), "CI/CD"); } #[tokio::test] async fn list_tokens() { let (mut app, tok, _) = setup().await; let (_, create_body) = send( &mut app, post_json_auth( "/api/v1/tokens", json!({"name": "My Token", "scopes": ["read"]}), &tok, ), ) .await; let (status, body) = send(&mut app, get_auth("/api/v1/tokens", &tok)).await; assert!(status.is_success()); let tokens = body["data"].as_array().unwrap(); assert_eq!(tokens.len(), 1); assert_eq!(tokens[0]["name"].as_str().unwrap(), "My Token"); assert!(tokens[0]["token"].is_null()); assert_eq!( tokens[0]["token_prefix"].as_str().unwrap(), create_body["data"]["token_prefix"].as_str().unwrap() ); } #[tokio::test] async fn delete_token() { let (mut app, tok, _) = setup().await; let (_, create_body) = send( &mut app, post_json_auth( "/api/v1/tokens", json!({"name": "To Delete", "scopes": ["read"]}), &tok, ), ) .await; let id = create_body["data"]["id"].as_str().unwrap(); let (status, _) = send(&mut app, delete_auth(&format!("/api/v1/tokens/{id}"), &tok)).await; assert!(status.is_success()); let (_, body) = send(&mut app, get_auth("/api/v1/tokens", &tok)).await; assert!(body["data"].as_array().unwrap().is_empty()); } #[tokio::test] async fn delete_token_not_found() { let (mut app, tok, _) = setup().await; let (status, _) = send(&mut app, delete_auth("/api/v1/tokens/nonexistent", &tok)).await; assert_eq!(status, StatusCode::NOT_FOUND); } #[tokio::test] async fn create_token_requires_auth() { let (mut app, _, _) = setup().await; let (status, _) = send( &mut app, post_json("/api/v1/tokens", json!({"name": "x", "scopes": ["read"]})), ) .await; assert_eq!(status, StatusCode::UNAUTHORIZED); } #[tokio::test] async fn api_token_authenticates_as_user() { let (mut app, tok, _) = setup().await; let (_, create_body) = send( &mut app, post_json_auth( "/api/v1/tokens", json!({"name": "Test Auth", "scopes": ["read", "write"]}), &tok, ), ) .await; let api_token = create_body["data"]["token"].as_str().unwrap(); let (status, body) = send(&mut app, get_auth("/api/v1/users/me", api_token)).await; assert!( status.is_success(), "api token auth failed: {status} {body:?}" ); assert_eq!(body["data"]["username"].as_str().unwrap(), "testadmin"); } #[tokio::test] async fn api_token_admin_scope() { let (mut app, tok, _) = setup().await; let (_, create_body) = send( &mut app, post_json_auth( "/api/v1/tokens", json!({"name": "Admin Token", "scopes": ["admin"]}), &tok, ), ) .await; let api_token = create_body["data"]["token"].as_str().unwrap(); let (status, body) = send(&mut app, get_auth("/api/v1/admin/stats", api_token)).await; assert!(status.is_success(), "admin scope failed: {status} {body:?}"); } #[tokio::test] async fn api_token_read_scope_cannot_create_post() { let (mut app, tok, _) = setup().await; let (_, create_body) = send( &mut app, post_json_auth( "/api/v1/tokens", json!({"name": "Read Only", "scopes": ["read"]}), &tok, ), ) .await; let api_token = create_body["data"]["token"].as_str().unwrap(); let (status, _) = send( &mut app, post_json_auth( "/api/v1/posts", json!({"title": "t", "content": "c", "slug": "s"}), api_token, ), ) .await; assert_eq!(status, StatusCode::FORBIDDEN); } #[tokio::test] async fn invalid_api_token_rejected() { let (mut app, _, _) = setup().await; let (status, _) = send( &mut app, get_auth("/api/v1/users/me", "rblog_invalid_token_here"), ) .await; assert_eq!(status, StatusCode::UNAUTHORIZED); } #[tokio::test] async fn delete_token_non_owner_forbidden() { let (mut app, tok, pool) = setup().await; let (_, create_body) = send( &mut app, post_json_auth( "/api/v1/tokens", json!({"name": "Owned", "scopes": ["read"]}), &tok, ), ) .await; let id = create_body["data"]["id"].as_str().unwrap(); let reader_hash = raisfast::services::auth::hash_password("ReaderPass123!").unwrap(); let reader_doc_id = uuid::Uuid::now_v7().to_string(); let sql = format!( "INSERT INTO users (document_id, username, role, status, registered_via) VALUES ({}, 'tokenreader', 'reader', 'active', 'email') RETURNING id", raisfast::db::dialect::ph(1) ); let reader_int_id: i64 = sqlx::query_scalar(&sql) .bind(&reader_doc_id) .fetch_one(&pool) .await .unwrap(); let cred_data = serde_json::json!({"password_hash": reader_hash}).to_string(); let (cred_doc_id, cred_now) = raisfast::utils::id::new_document_id_and_timestamp(); let cred_sql = format!( "INSERT INTO user_credentials (document_id, user_id, auth_type, identifier, credential_data, verified, created_at, updated_at) VALUES ({}, {}, 'email', {}, {}, 1, {}, {})", raisfast::db::dialect::ph(1), raisfast::db::dialect::ph(2), raisfast::db::dialect::ph(3), raisfast::db::dialect::ph(4), raisfast::db::dialect::ph(5), raisfast::db::dialect::ph(6) ); sqlx::query(&cred_sql) .bind(&cred_doc_id) .bind(reader_int_id) .bind("reader-token@test.com") .bind(&cred_data) .bind(cred_now) .bind(cred_now) .execute(&pool) .await .unwrap(); let reader_tok = make_token( &reader_doc_id, reader_int_id, raisfast::models::user::UserRole::Reader, ); let (status, _) = send( &mut app, delete_auth(&format!("/api/v1/tokens/{id}"), &reader_tok), ) .await; assert_eq!(status, StatusCode::FORBIDDEN); } #[tokio::test] async fn admin_can_delete_other_users_token() { let (mut app, tok, pool) = setup().await; let reader_hash = raisfast::services::auth::hash_password("ReaderPass123!").unwrap(); let reader_doc_id = uuid::Uuid::now_v7().to_string(); let sql = format!( "INSERT INTO users (document_id, username, role, status, registered_via) VALUES ({}, 'readeradmindel', 'reader', 'active', 'email') RETURNING id", raisfast::db::dialect::ph(1) ); let reader_int_id: i64 = sqlx::query_scalar(&sql) .bind(&reader_doc_id) .fetch_one(&pool) .await .unwrap(); let cred_data = serde_json::json!({"password_hash": reader_hash}).to_string(); let (cred_doc_id, cred_now) = raisfast::utils::id::new_document_id_and_timestamp(); let cred_sql = format!( "INSERT INTO user_credentials (document_id, user_id, auth_type, identifier, credential_data, verified, created_at, updated_at) VALUES ({}, {}, 'email', {}, {}, 1, {}, {})", raisfast::db::dialect::ph(1), raisfast::db::dialect::ph(2), raisfast::db::dialect::ph(3), raisfast::db::dialect::ph(4), raisfast::db::dialect::ph(5), raisfast::db::dialect::ph(6) ); sqlx::query(&cred_sql) .bind(&cred_doc_id) .bind(reader_int_id) .bind("reader-admin-del@test.com") .bind(&cred_data) .bind(cred_now) .bind(cred_now) .execute(&pool) .await .unwrap(); let reader_tok = make_token( &reader_doc_id, reader_int_id, raisfast::models::user::UserRole::Reader, ); let (_, create_body) = send( &mut app, post_json_auth( "/api/v1/tokens", json!({"name": "Reader Token", "scopes": ["read"]}), &reader_tok, ), ) .await; let id = create_body["data"]["id"].as_str().unwrap(); let (status, _) = send(&mut app, delete_auth(&format!("/api/v1/tokens/{id}"), &tok)).await; assert!(status.is_success()); } #[tokio::test] async fn create_token_validation_empty_name() { let (mut app, tok, _) = setup().await; let (status, body) = send( &mut app, post_json_auth( "/api/v1/tokens", json!({"name": "", "scopes": ["read"]}), &tok, ), ) .await; assert!( status == StatusCode::BAD_REQUEST || status == StatusCode::UNPROCESSABLE_ENTITY, "expected 400 or 422, got {status}: {body:?}" ); } #[tokio::test] async fn create_token_validation_empty_scopes() { let (mut app, tok, _) = setup().await; let (status, _body) = send( &mut app, post_json_auth( "/api/v1/tokens", json!({"name": "Test", "scopes": []}), &tok, ), ) .await; assert_eq!(status, StatusCode::BAD_REQUEST); } #[tokio::test] async fn create_token_validation_invalid_scope() { let (mut app, tok, _) = setup().await; let (status, _) = send( &mut app, post_json_auth( "/api/v1/tokens", json!({"name": "Test", "scopes": ["superuser"]}), &tok, ), ) .await; assert_eq!(status, StatusCode::BAD_REQUEST); } #[tokio::test] async fn create_token_validation_missing_body() { let (mut app, tok, _) = setup().await; let (status, _) = send( &mut app, Request::builder() .method("POST") .uri("/api/v1/tokens") .header(header::AUTHORIZATION, format!("Bearer {tok}")) .header(header::CONTENT_TYPE, "application/json") .body(Body::from("{}")) .unwrap(), ) .await; assert!( status == StatusCode::BAD_REQUEST || status == StatusCode::UNPROCESSABLE_ENTITY, "expected 400 or 422, got {status}" ); } #[tokio::test] async fn token_deleted_cannot_authenticate() { let (mut app, tok, _) = setup().await; let (_, create_body) = send( &mut app, post_json_auth( "/api/v1/tokens", json!({"name": "Then Delete", "scopes": ["read", "write"]}), &tok, ), ) .await; let api_token = create_body["data"]["token"].as_str().unwrap(); let id = create_body["data"]["id"].as_str().unwrap(); send(&mut app, delete_auth(&format!("/api/v1/tokens/{id}"), &tok)).await; let (status, _) = send(&mut app, get_auth("/api/v1/users/me", api_token)).await; assert_eq!(status, StatusCode::UNAUTHORIZED); } #[tokio::test] async fn multiple_tokens_per_user() { let (mut app, tok, _) = setup().await; send( &mut app, post_json_auth( "/api/v1/tokens", json!({"name": "First", "scopes": ["read"]}), &tok, ), ) .await; tokio::time::sleep(std::time::Duration::from_secs(1)).await; send( &mut app, post_json_auth( "/api/v1/tokens", json!({"name": "Second", "scopes": ["write"]}), &tok, ), ) .await; tokio::time::sleep(std::time::Duration::from_secs(1)).await; send( &mut app, post_json_auth( "/api/v1/tokens", json!({"name": "Third", "scopes": ["admin"]}), &tok, ), ) .await; let (_, body) = send(&mut app, get_auth("/api/v1/tokens", &tok)).await; let tokens = body["data"].as_array().unwrap(); assert_eq!(tokens.len(), 3); assert_eq!(tokens[0]["name"].as_str().unwrap(), "Third"); assert_eq!(tokens[1]["name"].as_str().unwrap(), "Second"); assert_eq!(tokens[2]["name"].as_str().unwrap(), "First"); } #[tokio::test] async fn list_tokens_sanitized_fields() { let (mut app, tok, _) = setup().await; send( &mut app, post_json_auth( "/api/v1/tokens", json!({"name": "Safe", "scopes": ["read"]}), &tok, ), ) .await; let (_, body) = send(&mut app, get_auth("/api/v1/tokens", &tok)).await; let token = &body["data"][0]; let obj = token.as_object().unwrap(); assert!(obj.contains_key("id")); assert!(obj.contains_key("name")); assert!(obj.contains_key("token_prefix")); assert!(obj.contains_key("scopes")); assert!(obj.contains_key("created_at")); assert!(!obj.contains_key("token"), "full token must not appear"); assert!(!obj.contains_key("token_hash"), "hash must not appear"); assert!(!obj.contains_key("user_id"), "user_id must not appear"); } #[tokio::test] async fn token_with_expires_at() { let (mut app, tok, _) = setup().await; let (status, body) = send( &mut app, post_json_auth( "/api/v1/tokens", json!({"name": "Expiring", "scopes": ["read"], "expires_at": "2099-12-31T00:00:00+00:00"}), &tok, ), ) .await; assert_eq!(status, StatusCode::CREATED); assert_eq!( body["data"]["expires_at"].as_str().unwrap(), "2099-12-31T00:00:00Z" ); } #[tokio::test] async fn token_prefix_is_first_8_chars() { let (mut app, tok, _) = setup().await; let (_, body) = send( &mut app, post_json_auth( "/api/v1/tokens", json!({"name": "Prefix", "scopes": ["read"]}), &tok, ), ) .await; let full_token = body["data"]["token"].as_str().unwrap(); let prefix = body["data"]["token_prefix"].as_str().unwrap(); assert_eq!(prefix, &full_token[..8]); assert!(prefix.starts_with("rblog_")); } #[tokio::test] async fn list_requires_auth() { let (mut app, _, _) = setup().await; let (status, _) = send(&mut app, get_req("/api/v1/tokens")).await; assert_eq!(status, StatusCode::UNAUTHORIZED); } #[tokio::test] async fn delete_requires_auth() { let (mut app, _, _) = setup().await; let (status, _) = send( &mut app, Request::builder() .method("DELETE") .uri("/api/v1/tokens/some-id") .body(Body::empty()) .unwrap(), ) .await; assert_eq!(status, StatusCode::UNAUTHORIZED); } #[tokio::test] async fn each_user_sees_only_own_tokens() { let (mut app, tok, pool) = setup().await; let reader_hash = raisfast::services::auth::hash_password("ReaderPass123!").unwrap(); let reader_doc_id = uuid::Uuid::now_v7().to_string(); let sql = format!( "INSERT INTO users (document_id, username, role, status, registered_via) VALUES ({}, 'isolationreader', 'reader', 'active', 'email') RETURNING id", raisfast::db::dialect::ph(1) ); let reader_int_id: i64 = sqlx::query_scalar(&sql) .bind(&reader_doc_id) .fetch_one(&pool) .await .unwrap(); let cred_data = serde_json::json!({"password_hash": reader_hash}).to_string(); let (cred_doc_id, cred_now) = raisfast::utils::id::new_document_id_and_timestamp(); let cred_sql = format!( "INSERT INTO user_credentials (document_id, user_id, auth_type, identifier, credential_data, verified, created_at, updated_at) VALUES ({}, {}, 'email', {}, {}, 1, {}, {})", raisfast::db::dialect::ph(1), raisfast::db::dialect::ph(2), raisfast::db::dialect::ph(3), raisfast::db::dialect::ph(4), raisfast::db::dialect::ph(5), raisfast::db::dialect::ph(6) ); sqlx::query(&cred_sql) .bind(&cred_doc_id) .bind(reader_int_id) .bind("isolation@test.com") .bind(&cred_data) .bind(cred_now) .bind(cred_now) .execute(&pool) .await .unwrap(); let reader_tok = make_token( &reader_doc_id, reader_int_id, raisfast::models::user::UserRole::Reader, ); send( &mut app, post_json_auth( "/api/v1/tokens", json!({"name": "Admin Token", "scopes": ["admin"]}), &tok, ), ) .await; send( &mut app, post_json_auth( "/api/v1/tokens", json!({"name": "Reader Token", "scopes": ["read"]}), &reader_tok, ), ) .await; let (_, admin_body) = send(&mut app, get_auth("/api/v1/tokens", &tok)).await; assert_eq!(admin_body["data"].as_array().unwrap().len(), 1); assert_eq!( admin_body["data"][0]["name"].as_str().unwrap(), "Admin Token" ); let (_, reader_body) = send(&mut app, get_auth("/api/v1/tokens", &reader_tok)).await; assert_eq!(reader_body["data"].as_array().unwrap().len(), 1); assert_eq!( reader_body["data"][0]["name"].as_str().unwrap(), "Reader Token" ); }