From 508f218fc061095839a3edb931de5c813d1cc9fe Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sat, 6 Sep 2025 16:18:32 +0200 Subject: [PATCH] feat(api_key): implement API key management with creation, retrieval, and deletion endpoints --- thoughts-backend/Cargo.lock | 1 + thoughts-backend/api/src/extractor/auth.rs | 22 ++++- thoughts-backend/api/src/routers/api_key.rs | 93 +++++++++++++++++++ thoughts-backend/api/src/routers/mod.rs | 1 + thoughts-backend/api/src/routers/tag.rs | 6 -- thoughts-backend/api/src/routers/user.rs | 6 +- thoughts-backend/app/Cargo.toml | 1 + .../app/src/persistence/api_key.rs | 93 +++++++++++++++++++ thoughts-backend/app/src/persistence/mod.rs | 1 + thoughts-backend/doc/src/user.rs | 2 +- thoughts-backend/migration/src/lib.rs | 2 + .../src/m20250906_134056_add_api_keys.rs | 69 ++++++++++++++ .../models/src/domains/api_key.rs | 32 +++++++ thoughts-backend/models/src/domains/mod.rs | 1 + .../models/src/domains/prelude.rs | 1 + thoughts-backend/models/src/domains/user.rs | 3 + .../models/src/schemas/api_key.rs | 62 +++++++++++++ thoughts-backend/models/src/schemas/mod.rs | 1 + thoughts-backend/tests/api/api_key.rs | 79 ++++++++++++++++ thoughts-backend/tests/api/mod.rs | 1 + thoughts-backend/tests/api/tag.rs | 1 - thoughts-backend/tests/api/user.rs | 53 +++++++++++ 22 files changed, 520 insertions(+), 11 deletions(-) create mode 100644 thoughts-backend/api/src/routers/api_key.rs create mode 100644 thoughts-backend/app/src/persistence/api_key.rs create mode 100644 thoughts-backend/migration/src/m20250906_134056_add_api_keys.rs create mode 100644 thoughts-backend/models/src/domains/api_key.rs create mode 100644 thoughts-backend/models/src/schemas/api_key.rs create mode 100644 thoughts-backend/tests/api/api_key.rs diff --git a/thoughts-backend/Cargo.lock b/thoughts-backend/Cargo.lock index 8d8ad5c..0a7889f 100644 --- a/thoughts-backend/Cargo.lock +++ b/thoughts-backend/Cargo.lock @@ -358,6 +358,7 @@ version = "0.1.0" dependencies = [ "bcrypt", "models", + "rand 0.8.5", "sea-orm", "validator", ] diff --git a/thoughts-backend/api/src/extractor/auth.rs b/thoughts-backend/api/src/extractor/auth.rs index 2b5bc54..d324146 100644 --- a/thoughts-backend/api/src/extractor/auth.rs +++ b/thoughts-backend/api/src/extractor/auth.rs @@ -8,7 +8,7 @@ use once_cell::sync::Lazy; use sea_orm::prelude::Uuid; use serde::{Deserialize, Serialize}; -use app::state::AppState; +use app::{persistence::api_key, state::AppState}; #[derive(Debug, Serialize, Deserialize)] pub struct Claims { @@ -28,14 +28,24 @@ impl FromRequestParts for AuthUser { async fn from_request_parts( parts: &mut Parts, - _state: &AppState, + state: &AppState, ) -> Result { + // --- Test User ID (Keep for testing) --- if let Some(user_id_header) = parts.headers.get("x-test-user-id") { let user_id_str = user_id_header.to_str().unwrap_or("0"); let user_id = user_id_str.parse::().unwrap_or(Uuid::nil()); return Ok(AuthUser { id: user_id }); } + // --- API Key Authentication --- + if let Some(api_key) = get_api_key_from_header(&parts.headers) { + return match api_key::validate_api_key(&state.conn, &api_key).await { + Ok(user) => Ok(AuthUser { id: user.id }), + Err(_) => Err((StatusCode::UNAUTHORIZED, "Invalid API Key")), + }; + } + + // --- JWT Authentication (Fallback) --- let token = get_token_from_header(&parts.headers) .ok_or((StatusCode::UNAUTHORIZED, "Missing or invalid token"))?; @@ -56,3 +66,11 @@ fn get_token_from_header(headers: &HeaderMap) -> Option { .and_then(|header| header.strip_prefix("Bearer ")) .map(|token| token.to_owned()) } + +fn get_api_key_from_header(headers: &HeaderMap) -> Option { + headers + .get("Authorization") + .and_then(|header| header.to_str().ok()) + .and_then(|header| header.strip_prefix("ApiKey ")) + .map(|key| key.to_owned()) +} diff --git a/thoughts-backend/api/src/routers/api_key.rs b/thoughts-backend/api/src/routers/api_key.rs new file mode 100644 index 0000000..f4f4ec1 --- /dev/null +++ b/thoughts-backend/api/src/routers/api_key.rs @@ -0,0 +1,93 @@ +use crate::{ + error::ApiError, + extractor::{AuthUser, Json}, + models::ApiErrorResponse, +}; +use app::{persistence::api_key, state::AppState}; +use axum::{ + extract::{Path, State}, + http::StatusCode, + response::IntoResponse, + routing::{delete, get}, + Router, +}; +use models::schemas::api_key::{ApiKeyListSchema, ApiKeyRequest, ApiKeyResponse}; +use sea_orm::prelude::Uuid; + +#[utoipa::path( + get, + path = "/me/api-keys", + responses( + (status = 200, description = "List of API keys", body = ApiKeyListSchema), + (status = 401, description = "Unauthorized", body = ApiErrorResponse), + (status = 500, description = "Internal server error", body = ApiErrorResponse), + ), + security( + ("bearerAuth" = []) + ) +)] +async fn get_keys( + State(state): State, + auth_user: AuthUser, +) -> Result { + let keys = api_key::get_api_keys_for_user(&state.conn, auth_user.id).await?; + Ok(Json(ApiKeyListSchema::from(keys))) +} + +#[utoipa::path( + post, + path = "/me/api-keys", + request_body = ApiKeyRequest, + responses( + (status = 201, description = "API key created", body = ApiKeyResponse), + (status = 400, description = "Bad request", body = ApiErrorResponse), + (status = 401, description = "Unauthorized", body = ApiErrorResponse), + (status = 422, description = "Validation error", body = ApiErrorResponse), + (status = 500, description = "Internal server error", body = ApiErrorResponse), + ), + security( + ("bearerAuth" = []) + ) +)] +async fn create_key( + State(state): State, + auth_user: AuthUser, + Json(params): Json, +) -> Result { + let (key_model, plaintext_key) = + api_key::create_api_key(&state.conn, auth_user.id, params.name).await?; + + let response = ApiKeyResponse::from_parts(key_model, Some(plaintext_key)); + Ok((StatusCode::CREATED, Json(response))) +} + +#[utoipa::path( + delete, + path = "/me/api-keys/{key_id}", + responses( + (status = 204, description = "API key deleted"), + (status = 401, description = "Unauthorized", body = ApiErrorResponse), + (status = 404, description = "API key not found", body = ApiErrorResponse), + (status = 500, description = "Internal server error", body = ApiErrorResponse), + ), + params( + ("key_id" = Uuid, Path, description = "The ID of the API key to delete") + ), + security( + ("bearerAuth" = []) + ) +)] +async fn delete_key( + State(state): State, + auth_user: AuthUser, + Path(key_id): Path, +) -> Result { + api_key::delete_api_key(&state.conn, key_id, auth_user.id).await?; + Ok(StatusCode::NO_CONTENT) +} + +pub fn create_api_key_router() -> Router { + Router::new() + .route("/", get(get_keys).post(create_key)) + .route("/{key_id}", delete(delete_key)) +} diff --git a/thoughts-backend/api/src/routers/mod.rs b/thoughts-backend/api/src/routers/mod.rs index 9a59d9f..caa5178 100644 --- a/thoughts-backend/api/src/routers/mod.rs +++ b/thoughts-backend/api/src/routers/mod.rs @@ -1,5 +1,6 @@ use axum::Router; +pub mod api_key; pub mod auth; pub mod feed; pub mod root; diff --git a/thoughts-backend/api/src/routers/tag.rs b/thoughts-backend/api/src/routers/tag.rs index b718d28..c3d7db6 100644 --- a/thoughts-backend/api/src/routers/tag.rs +++ b/thoughts-backend/api/src/routers/tag.rs @@ -19,17 +19,11 @@ async fn get_thoughts_by_tag( Path(tag_name): Path, ) -> Result { let thoughts_with_authors = get_thoughts_by_tag_name(&state.conn, &tag_name).await; - println!( - "Result from get_thoughts_by_tag_name: {:?}", - thoughts_with_authors - ); let thoughts_with_authors = thoughts_with_authors?; - println!("Thoughts with authors: {:?}", thoughts_with_authors); let thoughts_schema: Vec = thoughts_with_authors .into_iter() .map(ThoughtSchema::from) .collect(); - println!("Thoughts schema: {:?}", thoughts_schema); Ok(Json(ThoughtListSchema::from(thoughts_schema))) } diff --git a/thoughts-backend/api/src/routers/user.rs b/thoughts-backend/api/src/routers/user.rs index 5e49dd5..a471a0e 100644 --- a/thoughts-backend/api/src/routers/user.rs +++ b/thoughts-backend/api/src/routers/user.rs @@ -19,9 +19,12 @@ use models::schemas::user::{UserListSchema, UserSchema}; use models::{params::user::UpdateUserParams, schemas::thought::ThoughtListSchema}; use models::{queries::user::UserQuery, schemas::thought::ThoughtSchema}; -use crate::extractor::{Json, Valid}; use crate::models::ApiErrorResponse; use crate::{error::ApiError, extractor::AuthUser}; +use crate::{ + extractor::{Json, Valid}, + routers::api_key::create_api_key_router, +}; #[utoipa::path( get, @@ -358,6 +361,7 @@ pub fn create_user_router() -> Router { Router::new() .route("/", get(users_get)) .route("/me", get(get_me).put(update_me)) + .nest("/me/api-keys", create_api_key_router()) .route("/{param}", get(get_user_by_param)) .route("/{username}/thoughts", get(user_thoughts_get)) .route( diff --git a/thoughts-backend/app/Cargo.toml b/thoughts-backend/app/Cargo.toml index 28f192c..329c6f6 100644 --- a/thoughts-backend/app/Cargo.toml +++ b/thoughts-backend/app/Cargo.toml @@ -12,4 +12,5 @@ path = "src/lib.rs" bcrypt = "0.17.1" models = { path = "../models" } validator = "0.20" +rand = "0.8.5" sea-orm = { version = "1.1.12" } diff --git a/thoughts-backend/app/src/persistence/api_key.rs b/thoughts-backend/app/src/persistence/api_key.rs new file mode 100644 index 0000000..c9dcb25 --- /dev/null +++ b/thoughts-backend/app/src/persistence/api_key.rs @@ -0,0 +1,93 @@ +use bcrypt::{hash, verify, DEFAULT_COST}; +use models::domains::{api_key, user}; +use rand::distributions::{Alphanumeric, DistString}; +use sea_orm::{ + prelude::Uuid, ActiveModelTrait, ColumnTrait, DbConn, DbErr, EntityTrait, QueryFilter, Set, +}; + +use crate::error::UserError; + +const KEY_PREFIX: &str = "th_"; +const KEY_RANDOM_LENGTH: usize = 32; +const KEY_LOOKUP_PREFIX_LENGTH: usize = 8; + +fn generate_key() -> String { + let random_part = Alphanumeric.sample_string(&mut rand::thread_rng(), KEY_RANDOM_LENGTH); + format!("{}{}", KEY_PREFIX, random_part) +} + +pub async fn create_api_key( + db: &DbConn, + user_id: Uuid, + name: String, +) -> Result<(api_key::Model, String), UserError> { + let plaintext_key = generate_key(); + let key_hash = + hash(&plaintext_key, DEFAULT_COST).map_err(|e| UserError::Internal(e.to_string()))?; + let key_prefix = plaintext_key[..KEY_LOOKUP_PREFIX_LENGTH].to_string(); + + let new_key = api_key::ActiveModel { + user_id: Set(user_id), + name: Set(name), + key_hash: Set(key_hash), + key_prefix: Set(key_prefix), + ..Default::default() + } + .insert(db) + .await + .map_err(|e| UserError::Internal(e.to_string()))?; + + Ok((new_key, plaintext_key)) +} + +pub async fn validate_api_key(db: &DbConn, plaintext_key: &str) -> Result { + if !plaintext_key.starts_with(KEY_PREFIX) + || plaintext_key.len() != KEY_PREFIX.len() + KEY_RANDOM_LENGTH + { + return Err(UserError::Validation("Invalid API key format".to_string())); + } + + let key_prefix = &plaintext_key[..KEY_LOOKUP_PREFIX_LENGTH]; + + let candidate_keys = api_key::Entity::find() + .filter(api_key::Column::KeyPrefix.eq(key_prefix)) + .all(db) + .await + .map_err(|e| UserError::Internal(e.to_string()))?; + + for key in candidate_keys { + if verify(plaintext_key, &key.key_hash).unwrap_or(false) { + return super::user::get_user(db, key.user_id) + .await + .map_err(|e| UserError::Internal(e.to_string()))? + .ok_or(UserError::NotFound); + } + } + + Err(UserError::Validation("Invalid API key".to_string())) +} + +pub async fn get_api_keys_for_user( + db: &DbConn, + user_id: Uuid, +) -> Result, DbErr> { + api_key::Entity::find() + .filter(api_key::Column::UserId.eq(user_id)) + .all(db) + .await +} + +pub async fn delete_api_key(db: &DbConn, key_id: Uuid, user_id: Uuid) -> Result<(), UserError> { + let result = api_key::Entity::delete_many() + .filter(api_key::Column::Id.eq(key_id)) + .filter(api_key::Column::UserId.eq(user_id)) // Ensure user owns the key + .exec(db) + .await + .map_err(|e| UserError::Internal(e.to_string()))?; + + if result.rows_affected == 0 { + Err(UserError::NotFound) + } else { + Ok(()) + } +} diff --git a/thoughts-backend/app/src/persistence/mod.rs b/thoughts-backend/app/src/persistence/mod.rs index cb16996..d53e800 100644 --- a/thoughts-backend/app/src/persistence/mod.rs +++ b/thoughts-backend/app/src/persistence/mod.rs @@ -1,3 +1,4 @@ +pub mod api_key; pub mod auth; pub mod follow; pub mod tag; diff --git a/thoughts-backend/doc/src/user.rs b/thoughts-backend/doc/src/user.rs index 86700a4..74af981 100644 --- a/thoughts-backend/doc/src/user.rs +++ b/thoughts-backend/doc/src/user.rs @@ -19,7 +19,7 @@ use models::schemas::{ user_inbox_post, user_outbox_get, get_me, - update_me + update_me, ), components(schemas( CreateUserParams, diff --git a/thoughts-backend/migration/src/lib.rs b/thoughts-backend/migration/src/lib.rs index f1f2188..05af7a4 100644 --- a/thoughts-backend/migration/src/lib.rs +++ b/thoughts-backend/migration/src/lib.rs @@ -4,6 +4,7 @@ mod m20240101_000001_init; mod m20250905_000001_init; mod m20250906_100000_add_profile_fields; mod m20250906_130237_add_tags; +mod m20250906_134056_add_api_keys; pub struct Migrator; @@ -15,6 +16,7 @@ impl MigratorTrait for Migrator { Box::new(m20250905_000001_init::Migration), Box::new(m20250906_100000_add_profile_fields::Migration), Box::new(m20250906_130237_add_tags::Migration), + Box::new(m20250906_134056_add_api_keys::Migration), ] } } diff --git a/thoughts-backend/migration/src/m20250906_134056_add_api_keys.rs b/thoughts-backend/migration/src/m20250906_134056_add_api_keys.rs new file mode 100644 index 0000000..cd01004 --- /dev/null +++ b/thoughts-backend/migration/src/m20250906_134056_add_api_keys.rs @@ -0,0 +1,69 @@ +use super::m20240101_000001_init::User; +use sea_orm_migration::{prelude::*, schema::*}; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(ApiKey::Table) + .if_not_exists() + .col( + ColumnDef::new(ApiKey::Id) + .uuid() + .not_null() + .primary_key() + .default(Expr::cust("gen_random_uuid()")), + ) + .col(uuid(ApiKey::UserId).not_null()) + .foreign_key( + ForeignKey::create() + .name("fk_api_key_user_id") + .from(ApiKey::Table, ApiKey::UserId) + .to(User::Table, User::Id) + .on_delete(ForeignKeyAction::Cascade), + ) + .col(text(ApiKey::KeyHash).not_null().unique_key()) + .col(string(ApiKey::Name).not_null()) + .col( + timestamp_with_time_zone(ApiKey::CreatedAt) + .not_null() + .default(Expr::current_timestamp()), + ) + .col(ColumnDef::new(ApiKey::KeyPrefix).string_len(8).not_null()) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx-api_keys-key_prefix") + .table(ApiKey::Table) + .col(ApiKey::KeyPrefix) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(ApiKey::Table).to_owned()) + .await + } +} + +#[derive(DeriveIden)] +enum ApiKey { + Table, + Id, + UserId, + KeyHash, + Name, + CreatedAt, + KeyPrefix, +} diff --git a/thoughts-backend/models/src/domains/api_key.rs b/thoughts-backend/models/src/domains/api_key.rs new file mode 100644 index 0000000..845919f --- /dev/null +++ b/thoughts-backend/models/src/domains/api_key.rs @@ -0,0 +1,32 @@ +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] +#[sea_orm(table_name = "api_key")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub user_id: Uuid, + pub key_prefix: String, + #[sea_orm(unique)] + pub key_hash: String, + pub name: String, + pub created_at: DateTimeWithTimeZone, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::user::Entity", + from = "Column::UserId", + to = "super::user::Column::Id" + )] + User, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::User.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/thoughts-backend/models/src/domains/mod.rs b/thoughts-backend/models/src/domains/mod.rs index 5f3d41f..f1cd63e 100644 --- a/thoughts-backend/models/src/domains/mod.rs +++ b/thoughts-backend/models/src/domains/mod.rs @@ -2,6 +2,7 @@ pub mod prelude; +pub mod api_key; pub mod follow; pub mod tag; pub mod thought; diff --git a/thoughts-backend/models/src/domains/prelude.rs b/thoughts-backend/models/src/domains/prelude.rs index 21455f6..3028691 100644 --- a/thoughts-backend/models/src/domains/prelude.rs +++ b/thoughts-backend/models/src/domains/prelude.rs @@ -1,5 +1,6 @@ //! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0 +pub use super::api_key::Entity as ApiKey; pub use super::follow::Entity as Follow; pub use super::tag::Entity as Tag; pub use super::thought::Entity as Thought; diff --git a/thoughts-backend/models/src/domains/user.rs b/thoughts-backend/models/src/domains/user.rs index 799bfbe..4e0e828 100644 --- a/thoughts-backend/models/src/domains/user.rs +++ b/thoughts-backend/models/src/domains/user.rs @@ -28,6 +28,9 @@ pub enum Relation { #[sea_orm(has_many = "super::top_friends::Entity")] TopFriends, + + #[sea_orm(has_many = "super::api_key::Entity")] + ApiKey, } impl ActiveModelBehavior for ActiveModel {} diff --git a/thoughts-backend/models/src/schemas/api_key.rs b/thoughts-backend/models/src/schemas/api_key.rs new file mode 100644 index 0000000..7dab33d --- /dev/null +++ b/thoughts-backend/models/src/schemas/api_key.rs @@ -0,0 +1,62 @@ +use crate::domains::api_key; +use common::DateTimeWithTimeZoneWrapper; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; +use uuid::Uuid; + +#[derive(Serialize, ToSchema)] +pub struct ApiKeySchema { + pub id: Uuid, + pub name: String, + pub key_prefix: String, + pub created_at: DateTimeWithTimeZoneWrapper, +} + +#[derive(Serialize, ToSchema)] +pub struct ApiKeyResponse { + #[serde(flatten)] + pub key: ApiKeySchema, + /// The full plaintext API key. This is only returned on creation. + #[serde(skip_serializing_if = "Option::is_none")] + pub plaintext_key: Option, +} + +impl ApiKeyResponse { + pub fn from_parts(model: api_key::Model, plaintext_key: Option) -> Self { + Self { + key: ApiKeySchema { + id: model.id, + name: model.name, + key_prefix: model.key_prefix, + created_at: model.created_at.into(), + }, + plaintext_key, + } + } +} + +#[derive(Serialize, ToSchema)] +pub struct ApiKeyListSchema { + pub api_keys: Vec, +} + +impl From> for ApiKeyListSchema { + fn from(keys: Vec) -> Self { + Self { + api_keys: keys + .into_iter() + .map(|k| ApiKeySchema { + id: k.id, + name: k.name, + key_prefix: k.key_prefix, + created_at: k.created_at.into(), + }) + .collect(), + } + } +} + +#[derive(Deserialize, ToSchema)] +pub struct ApiKeyRequest { + pub name: String, +} diff --git a/thoughts-backend/models/src/schemas/mod.rs b/thoughts-backend/models/src/schemas/mod.rs index ca7dd23..77fb125 100644 --- a/thoughts-backend/models/src/schemas/mod.rs +++ b/thoughts-backend/models/src/schemas/mod.rs @@ -1,2 +1,3 @@ +pub mod api_key; pub mod thought; pub mod user; diff --git a/thoughts-backend/tests/api/api_key.rs b/thoughts-backend/tests/api/api_key.rs new file mode 100644 index 0000000..60e0f34 --- /dev/null +++ b/thoughts-backend/tests/api/api_key.rs @@ -0,0 +1,79 @@ +use crate::api::main::{create_user_with_password, login_user, setup}; +use axum::http::{header, HeaderName, StatusCode}; +use http_body_util::BodyExt; +use serde_json::{json, Value}; +use utils::testing::{make_jwt_request, make_request_with_headers}; + +#[tokio::test] +async fn test_api_key_flow() { + let app = setup().await; + let _ = create_user_with_password(&app.db, "apikey_user", "password123").await; + let jwt = login_user(app.router.clone(), "apikey_user", "password123").await; + + // 1. Create a new API key using JWT auth + let create_body = json!({ "name": "My Test Key" }).to_string(); + let response = make_jwt_request( + app.router.clone(), + "/users/me/api-keys", + "POST", + Some(create_body), + &jwt, + ) + .await; + assert_eq!(response.status(), StatusCode::CREATED); + + let body = response.into_body().collect().await.unwrap().to_bytes(); + let v: Value = serde_json::from_slice(&body).unwrap(); + + let plaintext_key = v["plaintext_key"] + .as_str() + .expect("Plaintext key not found") + .to_string(); + let key_id = v["id"].as_str().expect("Key ID not found").to_string(); + assert!(plaintext_key.starts_with("th_")); + + // 2. Use the new API key to post a thought + + let thought_body = json!({ "content": "Posting with an API key!" }).to_string(); + let key = plaintext_key.clone(); + let api_key_header = format!("ApiKey {}", key); + let content_type = "application/json"; + let headers: Vec<(HeaderName, &str)> = vec![ + (header::AUTHORIZATION, &api_key_header), + (header::CONTENT_TYPE, content_type), + ]; + + let response = make_request_with_headers( + app.router.clone(), + "/thoughts", + "POST", + Some(thought_body), + headers, + ) + .await; + + assert_eq!(response.status(), StatusCode::CREATED); + + // 3. Delete the API key using JWT auth + let response = make_jwt_request( + app.router.clone(), + &format!("/users/me/api-keys/{}", key_id), + "DELETE", + None, + &jwt, + ) + .await; + assert_eq!(response.status(), StatusCode::NO_CONTENT); + + // 4. Try to use the deleted key again, expecting failure + let body = json!({ "content": "This should fail" }).to_string(); + let headers: Vec<(HeaderName, &str)> = vec![ + (header::AUTHORIZATION, &api_key_header), + (header::CONTENT_TYPE, content_type), + ]; + + let response = + make_request_with_headers(app.router.clone(), "/thoughts", "POST", Some(body), headers) + .await; + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); +} diff --git a/thoughts-backend/tests/api/mod.rs b/thoughts-backend/tests/api/mod.rs index 88b64fc..a97e54a 100644 --- a/thoughts-backend/tests/api/mod.rs +++ b/thoughts-backend/tests/api/mod.rs @@ -1,4 +1,5 @@ mod activitypub; +mod api_key; mod auth; mod feed; mod follow; diff --git a/thoughts-backend/tests/api/tag.rs b/thoughts-backend/tests/api/tag.rs index 1b57c4b..68ee392 100644 --- a/thoughts-backend/tests/api/tag.rs +++ b/thoughts-backend/tests/api/tag.rs @@ -25,7 +25,6 @@ async fn test_hashtag_flow() { // 3. Fetch thoughts by tag "rustlang" let response = make_get_request(app.router.clone(), "/tags/rustlang", Some(user.id)).await; - println!("Response: {:?}", response); assert_eq!(response.status(), StatusCode::OK); let body_bytes = response.into_body().collect().await.unwrap().to_bytes(); let v: Value = serde_json::from_slice(&body_bytes).unwrap(); diff --git a/thoughts-backend/tests/api/user.rs b/thoughts-backend/tests/api/user.rs index 192af8b..321015c 100644 --- a/thoughts-backend/tests/api/user.rs +++ b/thoughts-backend/tests/api/user.rs @@ -183,3 +183,56 @@ async fn test_update_me_top_friends() { assert_eq!(top_friends_list_2[0].friend_id, friend2.id); assert_eq!(top_friends_list_2[0].position, 1); } + +#[tokio::test] +async fn test_update_me_css_and_images() { + let app = setup().await; + + // 1. Create and log in as a user + let _ = create_user_with_password(&app.db, "css_user", "password123").await; + let token = login_user(app.router.clone(), "css_user", "password123").await; + + // 2. Attempt to update with an invalid avatar URL + let invalid_body = json!({ + "avatar_url": "not-a-valid-url" + }) + .to_string(); + + let response = make_jwt_request( + app.router.clone(), + "/users/me", + "PUT", + Some(invalid_body), + &token, + ) + .await; + assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); + + // 3. Update profile with valid URLs and custom CSS + let valid_body = json!({ + "avatar_url": "https://example.com/new-avatar.png", + "header_url": "https://example.com/new-header.jpg", + "custom_css": "body { color: blue; }" + }) + .to_string(); + + let response = make_jwt_request( + app.router.clone(), + "/users/me", + "PUT", + Some(valid_body), + &token, + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + + // 4. Verify the changes were persisted by fetching the profile again + let response = make_jwt_request(app.router.clone(), "/users/me", "GET", None, &token).await; + assert_eq!(response.status(), StatusCode::OK); + let body = response.into_body().collect().await.unwrap().to_bytes(); + let v: Value = serde_json::from_slice(&body).unwrap(); + + assert_eq!(v["avatar_url"], "https://example.com/new-avatar.png"); + assert_eq!(v["header_url"], "https://example.com/new-header.jpg"); + assert_eq!(v["custom_css"], "body { color: blue; }"); +}