added integration tests and added message with attachments

This commit is contained in:
2026-06-28 17:31:58 +02:00
parent e2f1e4a952
commit b45f5ff35f
14 changed files with 1913 additions and 513 deletions
+1
View File
@@ -1,3 +1,4 @@
/target /target
/result /result
/uploads /uploads
/test_data
Generated
+812 -478
View File
File diff suppressed because it is too large Load Diff
+6 -1
View File
@@ -19,7 +19,8 @@ serde = { version = "1.0.219", features = ["derive"] }
serde_json = "1.0.143" serde_json = "1.0.143"
sqlx = { version = "0.8.6", features = ["postgres", "runtime-tokio-native-tls", "macros", "uuid", "chrono"] } sqlx = { version = "0.8.6", features = ["postgres", "runtime-tokio-native-tls", "macros", "uuid", "chrono"] }
tokio = { version = "1.47.1", features = ["rt-multi-thread", "macros"] } tokio = { version = "1.47.1", features = ["rt-multi-thread", "macros"] }
tower-http = { version = "0.6.6", features = ["cors", "limit", "trace"] } tower = "0.5.3"
tower-http = { version = "0.6.6", features = ["cors", "limit", "trace", "fs"] }
tower_governor = "0.8.0" tower_governor = "0.8.0"
tracing = "0.1.41" tracing = "0.1.41"
tracing-subscriber = { version = "0.3.20", features = ["env-filter"] } tracing-subscriber = { version = "0.3.20", features = ["env-filter"] }
@@ -27,6 +28,10 @@ uuid = { version = "1.19.0", features = ["serde", "v7"] }
validator = "0.20.0" validator = "0.20.0"
[dev-dependencies] [dev-dependencies]
axum-test = "21.0.0"
http-body-util = "0.1.3" http-body-util = "0.1.3"
reqwest = { version = "0.13.1", features = ["json"] } reqwest = { version = "0.13.1", features = ["json"] }
serde_json = "1.0.143"
sha2 = "0.11.0"
tokio = "1.47.1"
tower = { version = "0.5.3", features = ["util"] } tower = { version = "0.5.3", features = ["util"] }
+43
View File
@@ -0,0 +1,43 @@
# Stage 1: Builder - Always uses the latest stable Rust version
FROM rust:latest AS builder
WORKDIR /app
# Install build dependencies
RUN apt-get update && apt-get install -y cmake pkg-config libssl-dev
# 1. Cache dependencies (standard Rust optimization)
COPY Cargo.toml Cargo.lock ./
RUN mkdir src && echo "fn main() {}" > src/main.rs
RUN cargo build --release
RUN rm -rf src
# 2. Build the actual application
COPY src ./src
# Ensure the compiler sees the new source files
RUN touch src/main.rs
RUN cargo build --release
# Stage 2: Runtime - Minimal image
FROM debian:bookworm-slim
# Install runtime libraries (SSL is required for Postgres/SQLx)
RUN apt-get update && apt-get install -y libssl3 ca-certificates && rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Copy the binary from builder
# Note: if your Cargo.toml 'name' is different, change 'frangipane' to your binary name
COPY --from=builder /app/target/release/frangipane /usr/local/bin/frangipane
# Create storage for avatars
RUN mkdir -p /var/lib/frangipane/avatars
# Default environment
ENV RUST_LOG="info"
# Expose Axum port
EXPOSE 8080
# Command to run. 'db' matches the service name in docker-compose.yml
CMD ["frangipane", "--database", "db:5432", "--port", "8080", "--data-dir", "/var/lib/frangipane"]
+10 -1
View File
@@ -48,7 +48,7 @@ CREATE TABLE IF NOT EXISTS room_invite_ (
CREATE TABLE IF NOT EXISTS message_ ( CREATE TABLE IF NOT EXISTS message_ (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
uuid UUID NOT NULL, uuid UUID UNIQUE NOT NULL,
sender INT REFERENCES user_(id) NOT NULL, sender INT REFERENCES user_(id) NOT NULL,
room INT REFERENCES room_(id) NOT NULL, room INT REFERENCES room_(id) NOT NULL,
message_type VARCHAR(32) NOT NULL, message_type VARCHAR(32) NOT NULL,
@@ -56,6 +56,14 @@ CREATE TABLE IF NOT EXISTS message_ (
sent_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP sent_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
); );
CREATE TABLE message_attachment_ (
id SERIAL PRIMARY KEY,
uuid UUID NOT NULL UNIQUE,
message_uuid UUID NOT NULL REFERENCES message_(uuid),
file_type VARCHAR(20) NOT NULL,
file_name TEXT NOT NULL
);
CREATE TABLE ws_token_ ( CREATE TABLE ws_token_ (
token TEXT PRIMARY KEY, token TEXT PRIMARY KEY,
expires_at TIMESTAMPTZ NOT NULL expires_at TIMESTAMPTZ NOT NULL
@@ -64,6 +72,7 @@ CREATE TABLE ws_token_ (
-- ==== INDICES ==== -- ==== INDICES ====
CREATE INDEX idx_message_room_sent_at ON message_ (room, sent_at); CREATE INDEX idx_message_room_sent_at ON message_ (room, sent_at);
CREATE UNIQUE INDEX idx_membership_user_room ON membership_ (user_id, room) INCLUDE (last_read_at); CREATE UNIQUE INDEX idx_membership_user_room ON membership_ (user_id, room) INCLUDE (last_read_at);
CREATE INDEX idx_attachment_message ON message_attachment_(message_uuid);
-- Timestamp creation -- Timestamp creation
-- CREATE OR REPLACE FUNCTION create_notification_timestamp() -- CREATE OR REPLACE FUNCTION create_notification_timestamp()
+38
View File
@@ -0,0 +1,38 @@
services:
db:
build:
context: ./db
dockerfile: Dockerfile
ports:
- "5432:5432"
environment:
- POSTGRES_USER=frangipane
- POSTGRES_PASSWORD=secret
- POSTGRES_DB=frangipane
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U frangipane -d frangipane"]
interval: 5s
timeout: 5s
retries: 5
app:
build:
context: .
dockerfile: Dockerfile
depends_on:
db:
condition: service_healthy
ports:
- "8080:8080"
environment:
- FRANGIPANE_JWT_SECRET=43aaf85b92f1ae6fbcef7732c50a0904
- RUST_LOG=frangipane=debug,tower_http=debug
volumes:
- app_data:/var/lib/frangipane
restart: unless-stopped
volumes:
postgres_data:
app_data:
+2
View File
@@ -32,6 +32,8 @@
lockFile = ./Cargo.lock; lockFile = ./Cargo.lock;
}; };
doCheck = false;
nativeBuildInputs = [ nativeBuildInputs = [
rust rust
pkgs.pkg-config pkgs.pkg-config
+13 -1
View File
@@ -7,7 +7,7 @@ use axum::{
}; };
use serde_json::json; use serde_json::json;
use crate::{MAX_ROOM_NAME_LENGTH, MAX_USERNAME_LENGTH}; use crate::{MAX_ATTACHMENTS_PER_MESSAGE, MAX_ROOM_NAME_LENGTH, MAX_USERNAME_LENGTH};
#[derive(Debug)] #[derive(Debug)]
pub enum APIError { pub enum APIError {
@@ -48,6 +48,8 @@ pub enum APIError {
// Uploads // Uploads
WrongFileFormat, WrongFileFormat,
TooManyAttachments,
FileTooLarge,
// Technical/Internal // Technical/Internal
DatabaseError(sqlx::Error), DatabaseError(sqlx::Error),
@@ -209,6 +211,16 @@ impl IntoResponse for APIError {
"WRONG_FILE_FORMAT", "WRONG_FILE_FORMAT",
"Wrong file format".into(), "Wrong file format".into(),
), ),
APIError::TooManyAttachments => (
StatusCode::BAD_REQUEST,
"MESSAGE_TOO_MANY_FILES",
format!("Maximum {} files allowed", MAX_ATTACHMENTS_PER_MESSAGE).into(),
),
APIError::FileTooLarge => (
StatusCode::PAYLOAD_TOO_LARGE,
"FILE_TOO_LARGE",
"One or more files exceed the size limit".into(),
),
// Internal // Internal
APIError::DatabaseError(e) => { APIError::DatabaseError(e) => {
+17 -4
View File
@@ -24,11 +24,14 @@ use tracing::Level;
const MAX_USERNAME_LENGTH: usize = 35; const MAX_USERNAME_LENGTH: usize = 35;
const MAX_ROOM_NAME_LENGTH: usize = 35; const MAX_ROOM_NAME_LENGTH: usize = 35;
const MAX_UPLOAD_SIZE: usize = 5 * 1024 * 1024; // Not actually used for now const MAX_UPLOAD_SIZE: usize = 5 * 1024 * 1024;
pub const MAX_ATTACHMENTS_PER_MESSAGE: usize = 8;
pub struct AppConfig { pub struct AppConfig {
pub avatar_dir: PathBuf, pub avatar_dir: PathBuf,
pub prohibit_registration: bool, pub prohibit_registration: bool,
pub max_file_size: usize,
pub uploads_dir: PathBuf,
} }
#[derive(clap::Parser, Debug)] #[derive(clap::Parser, Debug)]
@@ -50,6 +53,10 @@ pub struct Cli {
#[arg(short, long)] #[arg(short, long)]
pub no_registration: bool, pub no_registration: bool,
/// Max file upload size in bytes (default: 10MB)
#[arg(long, default_value = "10485760")]
pub max_file_size: usize,
/// Verbose mode /// Verbose mode
#[arg(short, long)] #[arg(short, long)]
pub verbose: bool, pub verbose: bool,
@@ -61,6 +68,7 @@ pub fn create_app(
config: Arc<AppConfig>, config: Arc<AppConfig>,
messages: realtime::RealtimeMessages, messages: realtime::RealtimeMessages,
voice: realtime::RealTimeVoices, voice: realtime::RealTimeVoices,
use_rate_limiter: bool,
) -> Router { ) -> Router {
let governor_conf = GovernorConfigBuilder::default() let governor_conf = GovernorConfigBuilder::default()
.burst_size(20) .burst_size(20)
@@ -90,7 +98,7 @@ pub fn create_app(
.allow_methods([Method::GET, Method::POST]) .allow_methods([Method::GET, Method::POST])
.allow_headers([header::AUTHORIZATION, header::CONTENT_TYPE]); .allow_headers([header::AUTHORIZATION, header::CONTENT_TYPE]);
Router::new() let mut router = Router::new()
.route("/version", get(get_version)) .route("/version", get(get_version))
.merge(routes::users::routes()) .merge(routes::users::routes())
.merge(routes::rooms::routes()) .merge(routes::rooms::routes())
@@ -102,7 +110,6 @@ pub fn create_app(
.layer(Extension(messages)) .layer(Extension(messages))
.layer(Extension(voice)) .layer(Extension(voice))
.layer(Extension(config)) .layer(Extension(config))
.layer(GovernorLayer::new(governor_conf))
.layer(cors) .layer(cors)
.layer(DefaultBodyLimit::max(1024 * 5 * 100)) .layer(DefaultBodyLimit::max(1024 * 5 * 100))
.layer( .layer(
@@ -110,7 +117,13 @@ pub fn create_app(
.make_span_with(DefaultMakeSpan::new().level(Level::DEBUG)) .make_span_with(DefaultMakeSpan::new().level(Level::DEBUG))
.on_request(()) .on_request(())
.on_response(DefaultOnResponse::new().level(Level::DEBUG)), .on_response(DefaultOnResponse::new().level(Level::DEBUG)),
) );
if use_rate_limiter {
router = router.layer(GovernorLayer::new(governor_conf));
}
router
} }
/// Public route to get current version /// Public route to get current version
+6 -2
View File
@@ -31,10 +31,14 @@ async fn main() -> anyhow::Result<()> {
let messages = realtime::RealtimeMessages::new(); let messages = realtime::RealtimeMessages::new();
let voice = realtime::RealTimeVoices::new(); let voice = realtime::RealTimeVoices::new();
let data_dir = PathBuf::from(cli.data_dir); let data_dir = PathBuf::from(&cli.data_dir);
let uploads_dir = PathBuf::from(format!("{}/{}", &cli.data_dir, "/uploads"));
let config = Arc::new(AppConfig { let config = Arc::new(AppConfig {
avatar_dir: data_dir.join("avatars"), avatar_dir: data_dir.join("avatars"),
prohibit_registration: cli.no_registration, prohibit_registration: cli.no_registration,
max_file_size: cli.max_file_size,
uploads_dir,
}); });
let port = cli.port; let port = cli.port;
@@ -43,7 +47,7 @@ async fn main() -> anyhow::Result<()> {
let listener = tokio::net::TcpListener::bind(&addr).await.unwrap(); let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
tracing::info!("Listening on {addr}"); tracing::info!("Listening on {addr}");
let app = create_app(db_pool, config, messages, voice); let app = create_app(db_pool, config, messages, voice, true);
axum::serve( axum::serve(
listener, listener,
+241 -19
View File
@@ -1,9 +1,9 @@
use std::{net::SocketAddr, time::Duration}; use std::{collections::HashMap, net::SocketAddr, sync::Arc, time::Duration};
use axum::{ use axum::{
Extension, Json, Router, Extension, Json, Router,
extract::{ extract::{
ConnectInfo, Path, Query, WebSocketUpgrade, ConnectInfo, DefaultBodyLimit, Path, Query, Request, WebSocketUpgrade,
ws::{Message as WsMessage, WebSocket}, ws::{Message as WsMessage, WebSocket},
}, },
http::{HeaderMap, StatusCode}, http::{HeaderMap, StatusCode},
@@ -13,9 +13,12 @@ use axum::{
use axum_extra::{TypedHeader, headers}; use axum_extra::{TypedHeader, headers};
use sqlx::PgPool; use sqlx::PgPool;
use tokio::select; use tokio::select;
use tower::ServiceExt;
use tower_http::services::ServeFile;
use uuid::Uuid; use uuid::Uuid;
use crate::{ use crate::{
AppConfig,
auth::{verify_jwt, verify_jwt_string}, auth::{verify_jwt, verify_jwt_string},
db::room_id_from_uuid, db::room_id_from_uuid,
errors::APIError, errors::APIError,
@@ -37,7 +40,7 @@ pub struct MessageRow {
pub sent_at: chrono::NaiveDateTime, pub sent_at: chrono::NaiveDateTime,
} }
#[derive(serde::Serialize, Debug, Clone)] #[derive(serde::Serialize, Debug, Clone, serde::Deserialize)]
pub struct Message { pub struct Message {
pub uuid: Uuid, pub uuid: Uuid,
pub room_uuid: Uuid, pub room_uuid: Uuid,
@@ -45,9 +48,17 @@ pub struct Message {
pub sender_uuid: Uuid, pub sender_uuid: Uuid,
pub message_type: String, pub message_type: String,
pub content: String, pub content: String,
pub attachments: Vec<Attachment>,
pub sent_at: String, pub sent_at: String,
} }
#[derive(serde::Serialize, serde::Deserialize, Clone, Debug)]
pub struct Attachment {
pub uuid: Uuid,
pub file_type: String, // image, video, textfile, binaryfile
pub file_name: String,
}
#[derive(serde::Deserialize)] #[derive(serde::Deserialize)]
pub struct NewMessagePayload { pub struct NewMessagePayload {
pub message_type: String, pub message_type: String,
@@ -64,6 +75,11 @@ pub fn routes() -> Router {
Router::new() Router::new()
.route("/messages/{room_uuid}", get(list_messages)) .route("/messages/{room_uuid}", get(list_messages))
.route("/messages/{room_uuid}", post(create_message)) .route("/messages/{room_uuid}", post(create_message))
.route(
"/messages/{room_uuid}/upload",
post(create_message_with_attachments).layer(DefaultBodyLimit::disable()),
)
.route("/uploads/{file_uuid}", get(get_upload))
.route("/ws/messages", get(message_ws_handler)) .route("/ws/messages", get(message_ws_handler))
} }
@@ -87,7 +103,8 @@ async fn list_messages(
let mut tx = db.begin().await?; let mut tx = db.begin().await?;
let messages = sqlx::query_as::<_, MessageRow>( // 1. Fetch the messages (Simple query)
let rows = sqlx::query_as::<_, MessageRow>(
r#" r#"
SELECT SELECT
m.uuid, m.uuid,
@@ -112,28 +129,62 @@ async fn list_messages(
.fetch_all(&mut *tx) .fetch_all(&mut *tx)
.await?; .await?;
let mut messages: Vec<Message> = messages // 2. Collect all the UUIDs of the messages we just found
let message_uuids: Vec<Uuid> = rows.iter().map(|m| m.uuid).collect();
// 3. Fetch all attachments for these specific messages in one batch
// We use a temporary struct to capture the message_uuid link from the DB
#[derive(sqlx::FromRow)]
struct AttachmentRow {
uuid: Uuid,
message_uuid: Uuid,
file_type: String,
file_name: String,
}
let attachment_rows = sqlx::query_as::<_, AttachmentRow>(
"SELECT uuid, message_uuid, file_type, file_name FROM message_attachment_ WHERE message_uuid = ANY($1)"
)
.bind(&message_uuids)
.fetch_all(&mut *tx)
.await?;
// 4. Group the attachments by their message_uuid in a HashMap
let mut attachment_map: HashMap<Uuid, Vec<Attachment>> = HashMap::new();
for att in attachment_rows {
attachment_map
.entry(att.message_uuid)
.or_default()
.push(Attachment {
uuid: att.uuid,
file_type: att.file_type,
file_name: att.file_name,
});
}
// 5. Map the MessageRow to the final Message struct, pulling from the HashMap
let mut messages: Vec<Message> = rows
.into_iter() .into_iter()
.map(|m| Message { .map(|m| {
uuid: m.uuid, let attachments = attachment_map.remove(&m.uuid).unwrap_or_default();
room_uuid: m.room_uuid, Message {
sender: m.sender, uuid: m.uuid,
sender_uuid: m.sender_uuid, room_uuid: m.room_uuid,
message_type: m.message_type, sender: m.sender,
content: m.content, sender_uuid: m.sender_uuid,
sent_at: m.sent_at.format("%Y-%m-%d %H:%M:%S").to_string(), message_type: m.message_type,
content: m.content,
attachments, // Populated from HashMap
sent_at: m.sent_at.format("%Y-%m-%d %H:%M:%S").to_string(),
}
}) })
.collect(); .collect();
messages.reverse(); messages.reverse();
// Reset last_read_at
sqlx::query( sqlx::query(
r#" "UPDATE membership_ SET last_read_at = CURRENT_TIMESTAMP WHERE user_id = $1 AND room = $2",
UPDATE membership_
SET last_read_at = CURRENT_TIMESTAMP
WHERE user_id = $1
AND room = $2
"#,
) )
.bind(user_id) .bind(user_id)
.bind(room_id) .bind(room_id)
@@ -184,6 +235,7 @@ async fn create_message(
sender_uuid: claims.sub, sender_uuid: claims.sub,
message_type: payload.message_type, message_type: payload.message_type,
content: payload.content, content: payload.content,
attachments: Vec::new(),
sent_at: sent_at.format("%Y-%m-%d %H:%M:%S").to_string(), sent_at: sent_at.format("%Y-%m-%d %H:%M:%S").to_string(),
}; };
@@ -309,3 +361,173 @@ async fn handle_message_socket(
} }
} }
} }
async fn create_message_with_attachments(
Path(room_uuid): Path<Uuid>,
Extension(db): Extension<PgPool>,
Extension(realtime): Extension<RealtimeMessages>,
Extension(config): Extension<Arc<AppConfig>>,
headers: HeaderMap,
mut multipart: axum::extract::Multipart,
) -> Result<(StatusCode, Json<Message>), APIError> {
let claims = verify_jwt(headers)?;
let user_id = user_id_from_uuid(&db, claims.sub).await?;
let room_id = room_id_from_uuid(&db, room_uuid).await?;
if !is_member(user_id, room_id, &db).await {
return Err(APIError::NotAMember);
}
let mut message_content = String::new();
let mut attachments = Vec::new();
// Process fields
while let Some(field) = multipart
.next_field()
.await
.map_err(|e| APIError::Internal(e.to_string()))?
{
let name = field.name().unwrap_or_default().to_string();
if name == "content" {
message_content = field.text().await.unwrap_or_default();
} else if name == "file" {
if attachments.len() >= crate::MAX_ATTACHMENTS_PER_MESSAGE {
return Err(APIError::TooManyAttachments);
}
let file_name = field.file_name().unwrap_or("unnamed").to_string();
let data = field
.bytes()
.await
.map_err(|_| APIError::Internal("Read error".into()))?;
if data.len() > config.max_file_size {
return Err(APIError::FileTooLarge);
}
// Determine file type
let kind = infer::get(&data);
let mime = kind
.map(|k| k.mime_type())
.unwrap_or("application/octet-stream");
let file_type = if mime.starts_with("image/") {
"image"
} else if mime.starts_with("video/") {
"video"
} else if mime.starts_with("text/") {
"textfile"
} else {
"binaryfile"
};
let file_uuid = Uuid::now_v7();
let storage_name = format!("{}_{}", file_uuid, file_name);
let path = config.uploads_dir.join(&storage_name);
tokio::fs::create_dir_all(&config.uploads_dir).await.ok();
tokio::fs::write(&path, data)
.await
.map_err(|e| APIError::Internal(e.to_string()))?;
attachments.push(Attachment {
uuid: file_uuid,
file_type: file_type.to_string(),
file_name: storage_name,
});
}
}
let message_uuid = Uuid::now_v7();
let mut tx = db.begin().await?;
let sent_at: chrono::NaiveDateTime = sqlx::query_scalar(
"INSERT INTO message_ (sender, room, message_type, content, uuid)
VALUES ($1, $2, $3, $4, $5) RETURNING sent_at",
)
.bind(user_id)
.bind(room_id)
.bind("attachment")
.bind(&message_content)
.bind(&message_uuid)
.fetch_one(&mut *tx)
.await?;
// Insert attachments
for att in &attachments {
sqlx::query(
"INSERT INTO message_attachment_ (uuid, message_uuid, file_type, file_name) VALUES ($1, $2, $3, $4)"
)
.bind(att.uuid)
.bind(message_uuid)
.bind(&att.file_type)
.bind(&att.file_name)
.execute(&mut *tx)
.await?;
}
tx.commit().await?;
let sender_name = username_from_uuid(&db, claims.sub).await?;
let message = Message {
uuid: message_uuid,
room_uuid,
sender: sender_name,
sender_uuid: claims.sub,
message_type: "attachment".to_string(),
content: message_content,
sent_at: sent_at.format("%Y-%m-%d %H:%M:%S").to_string(),
attachments,
};
let recipients: Vec<Uuid> = sqlx::query_scalar(
r#"
SELECT u.uuid
FROM membership_ m
JOIN user_ u ON u.id = m.user_id
WHERE m.room = $1
"#,
)
.bind(room_id)
.fetch_all(&db)
.await?;
let rt = realtime.clone();
let msg_clone = message.clone();
tokio::spawn(async move {
rt.broadcast(recipients, msg_clone);
});
Ok((StatusCode::CREATED, Json(message)))
}
async fn get_upload(
Path(file_uuid): Path<Uuid>,
Extension(config): Extension<Arc<AppConfig>>,
Extension(db): Extension<PgPool>,
req: Request,
) -> Result<impl IntoResponse, APIError> {
let file_name: String =
sqlx::query_scalar("SELECT file_name FROM message_attachment_ WHERE uuid = $1")
.bind(file_uuid)
.fetch_one(&db)
.await
.map_err(|_| APIError::Internal("File not found in DB".into()))?;
let path = config.uploads_dir.join(&file_name);
if !path.exists() {
return Err(APIError::Internal("File missing on disk".into()));
}
let service = ServeFile::new(path);
let result = service
.oneshot(req)
.await
.map_err(|e| APIError::Internal(format!("File service error: {}", e)))?;
Ok(result)
}
+2 -5
View File
@@ -13,7 +13,7 @@ use crate::{
routes::users::UserProfile, routes::users::UserProfile,
}; };
#[derive(sqlx::FromRow, serde::Serialize)] #[derive(sqlx::FromRow, serde::Serialize, serde::Deserialize)]
pub struct Room { pub struct Room {
pub uuid: Uuid, pub uuid: Uuid,
pub name: String, pub name: String,
@@ -60,7 +60,7 @@ pub fn routes() -> Router {
.route("/rooms", get(list_rooms)) .route("/rooms", get(list_rooms))
.route("/rooms", post(create_room)) .route("/rooms", post(create_room))
.route("/rooms/{room_uuid}", get(get_room)) .route("/rooms/{room_uuid}", get(get_room))
.route("/rooms/{room-uuid}/members", get(list_members)) .route("/rooms/{room_uuid}/members", get(list_members))
.route("/rooms/invites", get(list_invites)) .route("/rooms/invites", get(list_invites))
.route("/rooms/invite", post(send_invite)) .route("/rooms/invite", post(send_invite))
.route("/rooms/join", post(accept_request)) .route("/rooms/join", post(accept_request))
@@ -95,9 +95,6 @@ async fn list_rooms(
Extension(db): Extension<PgPool>, Extension(db): Extension<PgPool>,
) -> Result<Json<Vec<Room>>, APIError> { ) -> Result<Json<Vec<Room>>, APIError> {
let claims = verify_jwt(headers)?; let claims = verify_jwt(headers)?;
if claims.sub != claims.sub {
return Err(APIError::InvalidToken);
}
let user_id = user_id_from_uuid(&db, claims.sub).await?; let user_id = user_id_from_uuid(&db, claims.sub).await?;
+2 -2
View File
@@ -38,7 +38,7 @@ pub struct LoginPayload {
pub password: String, pub password: String,
} }
#[derive(serde::Serialize)] #[derive(serde::Serialize, serde::Deserialize)]
pub struct LoginResponse { pub struct LoginResponse {
pub uuid: Uuid, pub uuid: Uuid,
pub username: String, pub username: String,
@@ -60,7 +60,7 @@ pub struct UpdateUserPayoad {
pub password: String, pub password: String,
} }
#[derive(serde::Serialize)] #[derive(serde::Serialize, serde::Deserialize)]
pub struct UpdateUserResponse { pub struct UpdateUserResponse {
pub email: String, pub email: String,
pub username: String, pub username: String,
+720
View File
@@ -0,0 +1,720 @@
use axum::{
body::Body,
http::{Request, StatusCode, header},
};
use frangipane::{
AppConfig,
auth::{create_jwt, hash_password, verify_jwt_string, verify_password},
create_app, realtime,
routes::{messages::Message, rooms::Room},
users::LoginResponse,
};
use serde_json::json;
use sha2::{Digest, Sha256};
use sqlx::PgPool;
use std::sync::Arc;
use tower::ServiceExt;
use uuid::Uuid;
// --- HELPERS ---
async fn get_test_pool() -> sqlx::PgPool {
let database_url = format!("postgres://frangipane:secret@127.0.0.1/frangipane");
PgPool::connect(database_url.as_str()).await.unwrap()
}
fn get_test_config() -> Arc<AppConfig> {
let mut path = std::env::current_dir().unwrap();
path.push("target");
path.push("test_uploads");
std::fs::create_dir_all(&path).ok();
Arc::new(AppConfig {
avatar_dir: path.clone(),
prohibit_registration: false,
max_file_size: 1024 * 1024 * 10,
uploads_dir: path,
})
}
async fn setup_app() -> (axum::Router, sqlx::PgPool) {
let _ = tracing_subscriber::fmt()
.with_env_filter("debug")
.with_test_writer()
.try_init();
let pool = get_test_pool().await;
let app = create_app(
pool.clone(),
get_test_config(),
realtime::RealtimeMessages::new(),
realtime::RealTimeVoices::new(),
false,
);
(app, pool)
}
/// Helper to register a user and return credentials.
async fn register_test_user(app: &mut axum::Router, username: &str, email: &str) -> (String, Uuid) {
let payload = json!({
"username": username,
"email": email,
"password": "secure_password_123"
});
let response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/register")
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(serde_json::to_vec(&payload).unwrap()))
.unwrap(),
)
.await
.unwrap();
let status = response.status();
let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
if !status.is_success() {
panic!(
"Registration failed for {} with status {}. Body: {:?}",
username,
status,
String::from_utf8_lossy(&body_bytes)
);
}
let data: LoginResponse =
serde_json::from_slice(&body_bytes).expect("Failed to parse LoginResponse");
(data.token, data.uuid)
}
// --- AUTH TESTS ---
#[test]
fn test_password_hashing_logic() {
let pass = "hunter2_extra_safe";
let hash = hash_password(pass).unwrap();
assert!(verify_password(&hash, pass));
assert!(!verify_password(&hash, "wrong_pass"));
}
#[test]
fn test_jwt_logic() {
let id = Uuid::now_v7();
let token = create_jwt(id).unwrap();
let claims = verify_jwt_string(&token).unwrap();
assert_eq!(claims.sub, id);
}
// --- USER / REGISTRATION TESTS ---
#[tokio::test]
async fn test_api_register_success() {
let (mut app, _) = setup_app().await;
let username = format!("u{}", Uuid::now_v7().simple());
let email = format!("{}@example.com", username);
let (token, _) = register_test_user(&mut app, &username, &email).await;
assert!(!token.is_empty());
}
#[tokio::test]
async fn test_api_register_duplicate_username() {
let (mut app, _) = setup_app().await;
let name = format!("d{}", Uuid::now_v7().simple());
let email1 = format!("{}1@test.com", name);
let email2 = format!("{}2@test.com", name);
register_test_user(&mut app, &name, &email1).await;
let payload = json!({
"username": name,
"email": email2,
"password": "password123"
});
let res = app
.oneshot(
Request::builder()
.method("POST")
.uri("/register")
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(serde_json::to_vec(&payload).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(res.status(), StatusCode::CONFLICT);
}
#[tokio::test]
async fn test_api_register_invalid_email() {
let (app, _) = setup_app().await;
let payload = json!({
"username": "bademail",
"email": "not-an-email",
"password": "password123"
});
let res = app
.oneshot(
Request::builder()
.method("POST")
.uri("/register")
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(serde_json::to_vec(&payload).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn test_api_login_success() {
let (mut app, _) = setup_app().await;
let id = Uuid::now_v7().simple();
let name = format!("l{}", id);
let email = format!("{}@test.com", id);
register_test_user(&mut app, &name, &email).await;
let payload = json!({ "email": email, "password": "secure_password_123" });
let res = app
.oneshot(
Request::builder()
.method("POST")
.uri("/login")
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(serde_json::to_vec(&payload).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(res.status(), StatusCode::OK);
}
// --- ROOM TESTS ---
#[tokio::test]
async fn test_create_room_and_list() {
let (mut app, _) = setup_app().await;
let id = Uuid::now_v7().simple();
let name = format!("o{}", id);
let (token, _) = register_test_user(&mut app, &name, &format!("{}@t.com", id)).await;
let room_name = "Testing Room";
let _ = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/rooms")
.header(header::AUTHORIZATION, format!("Bearer {token}"))
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(
serde_json::to_vec(&json!({"name": room_name, "global": false})).unwrap(),
))
.unwrap(),
)
.await
.unwrap();
let res = app
.oneshot(
Request::builder()
.method("GET")
.uri("/rooms")
.header(header::AUTHORIZATION, format!("Bearer {token}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let body = axum::body::to_bytes(res.into_body(), usize::MAX)
.await
.unwrap();
let rooms: Vec<Room> = serde_json::from_slice(&body).unwrap();
assert!(rooms.iter().any(|r| r.name == room_name));
}
#[tokio::test]
async fn test_delete_room_permission() {
let (mut app, _) = setup_app().await;
let id1 = Uuid::now_v7().simple();
let id2 = Uuid::now_v7().simple();
let (t1, _) =
register_test_user(&mut app, &format!("u1{}", id1), &format!("{}@t.com", id1)).await;
let (t2, _) =
register_test_user(&mut app, &format!("u2{}", id2), &format!("{}@t.com", id2)).await;
// u1 creates room
let res = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/rooms")
.header(header::AUTHORIZATION, format!("Bearer {t1}"))
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(
serde_json::to_vec(&json!({"name": "KillMe", "global": false})).unwrap(),
))
.unwrap(),
)
.await
.unwrap();
let room: Room = serde_json::from_slice(
&axum::body::to_bytes(res.into_body(), usize::MAX)
.await
.unwrap(),
)
.unwrap();
// u2 tries to delete it
let res = app
.oneshot(
Request::builder()
.method("DELETE")
.uri(format!("/rooms/{}/delete", room.uuid))
.header(header::AUTHORIZATION, format!("Bearer {t2}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(res.status(), StatusCode::FORBIDDEN);
}
// --- FRIEND TESTS ---
#[tokio::test]
async fn test_friend_request_self_error() {
let (mut app, _) = setup_app().await;
let id = Uuid::now_v7().simple();
let name = format!("s{}", id);
let (token, _) = register_test_user(&mut app, &name, &format!("{}@t.com", id)).await;
let res = app
.oneshot(
Request::builder()
.method("POST")
.uri("/friends/request")
.header(header::AUTHORIZATION, format!("Bearer {token}"))
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(
serde_json::to_vec(&json!({"receiver_username": name})).unwrap(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn test_accept_friend_request() {
let (mut app, _) = setup_app().await;
let id1 = Uuid::now_v7().simple();
let id2 = Uuid::now_v7().simple();
let n1 = format!("f1{}", id1);
let n2 = format!("f2{}", id2);
let (t1, u1_uuid) = register_test_user(&mut app, &n1, &format!("{}@t.com", id1)).await;
let (t2, _) = register_test_user(&mut app, &n2, &format!("{}@t.com", id2)).await;
// t1 sends to t2
let _ = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/friends/request")
.header(header::AUTHORIZATION, format!("Bearer {t1}"))
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(
serde_json::to_vec(&json!({"receiver_username": n2})).unwrap(),
))
.unwrap(),
)
.await
.unwrap();
// t2 accepts
let res = app
.oneshot(
Request::builder()
.method("POST")
.uri("/friends/accept")
.header(header::AUTHORIZATION, format!("Bearer {t2}"))
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(
serde_json::to_vec(&json!({"sender_uuid": u1_uuid})).unwrap(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(res.status(), StatusCode::CREATED);
}
// --- MESSAGE TESTS ---
#[tokio::test]
async fn test_send_message_not_member() {
let (mut app, _) = setup_app().await;
let id1 = Uuid::now_v7().simple();
let id2 = Uuid::now_v7().simple();
let n1 = format!("ma{}", id1);
let n2 = format!("mb{}", id2);
let (t1, _) = register_test_user(&mut app, &n1, &format!("{}@t.com", id1)).await;
let (t2, _) = register_test_user(&mut app, &n2, &format!("{}@t.com", id2)).await;
// t1 creates room
let res = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/rooms")
.header(header::AUTHORIZATION, format!("Bearer {t1}"))
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(
serde_json::to_vec(&json!({"name": "Private", "global": false})).unwrap(),
))
.unwrap(),
)
.await
.unwrap();
let room: Room = serde_json::from_slice(
&axum::body::to_bytes(res.into_body(), usize::MAX)
.await
.unwrap(),
)
.unwrap();
// t2 (non-member) tries to send
let res = app
.oneshot(
Request::builder()
.method("POST")
.uri(format!("/messages/{}", room.uuid))
.header(header::AUTHORIZATION, format!("Bearer {t2}"))
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(
serde_json::to_vec(&json!({"message_type": "text", "content": "spy"})).unwrap(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(res.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn test_send_and_list_messages() {
let (mut app, _) = setup_app().await;
let id = Uuid::now_v7().simple();
let n = format!("m{}", id);
let (token, _) = register_test_user(&mut app, &n, &format!("{}@t.com", id)).await;
let res = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/rooms")
.header(header::AUTHORIZATION, format!("Bearer {token}"))
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(
serde_json::to_vec(&json!({"name": "Chat", "global": false})).unwrap(),
))
.unwrap(),
)
.await
.unwrap();
let room: Room = serde_json::from_slice(
&axum::body::to_bytes(res.into_body(), usize::MAX)
.await
.unwrap(),
)
.unwrap();
// Send
let _ = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri(format!("/messages/{}", room.uuid))
.header(header::AUTHORIZATION, format!("Bearer {token}"))
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(
serde_json::to_vec(&json!({"message_type": "text", "content": "hello"}))
.unwrap(),
))
.unwrap(),
)
.await
.unwrap();
// List
let res = app
.oneshot(
Request::builder()
.method("GET")
.uri(format!("/messages/{}", room.uuid))
.header(header::AUTHORIZATION, format!("Bearer {token}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let messages: Vec<Message> = serde_json::from_slice(
&axum::body::to_bytes(res.into_body(), usize::MAX)
.await
.unwrap(),
)
.unwrap();
assert_eq!(messages.len(), 1);
}
// --- MISC / SYSTEM TESTS ---
#[tokio::test]
async fn test_update_settings_and_validate() {
let (mut app, _) = setup_app().await;
let id = Uuid::now_v7().simple();
let n = format!("st{}", id);
let (token, _) = register_test_user(&mut app, &n, &format!("{}@t.com", id)).await;
let new_id = Uuid::now_v7().simple();
let new_name = format!("nw{}", new_id);
let new_email = format!("{}@new.com", new_id);
let res = app
.clone()
.oneshot(
Request::builder()
.method("PUT")
.uri("/account/settings")
.header(header::AUTHORIZATION, format!("Bearer {token}"))
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(
serde_json::to_vec(&json!({
"username": new_name,
"email": new_email,
"password": "new_secure_pass_123"
}))
.unwrap(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(res.status(), StatusCode::CREATED);
let res = app
.oneshot(
Request::builder()
.method("GET")
.uri("/validate-token")
.header(header::AUTHORIZATION, format!("Bearer {token}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(res.status(), StatusCode::OK);
}
#[tokio::test]
async fn test_version_route() {
let (app, _) = setup_app().await;
let res = app
.oneshot(
Request::builder()
.uri("/version")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(res.status(), StatusCode::OK);
}
#[tokio::test]
async fn test_unauthorized_access() {
let (app, _) = setup_app().await;
let paths = vec![
("GET", "/rooms", Body::empty()),
("GET", "/friends", Body::empty()),
("GET", "/friends/requests", Body::empty()),
(
"PUT",
"/account/settings",
Body::from(
json!({
"username": "a",
"email": "a@a.com",
"password": "p"
})
.to_string(),
),
),
];
for (method, path, body) in paths {
let res = app
.clone()
.oneshot(
Request::builder()
.method(method)
.uri(path)
.header("content-type", "application/json")
.body(body)
.unwrap(),
)
.await
.unwrap();
assert_eq!(
res.status(),
StatusCode::UNAUTHORIZED,
"Path {} should be protected",
path
);
}
}
#[tokio::test]
async fn test_upload_large_file_and_verify_hash() {
let (mut app, _) = setup_app().await;
let id = Uuid::now_v7().simple();
let username = format!("u{}", id);
let (token, _) = register_test_user(&mut app, &username, &format!("{}@t.com", id)).await;
// Create a room to upload into
let res = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/rooms")
.header(header::AUTHORIZATION, format!("Bearer {token}"))
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(
json!({"name": "UploadRoom", "global": false}).to_string(),
))
.unwrap(),
)
.await
.unwrap();
let room: Room = serde_json::from_slice(
&axum::body::to_bytes(res.into_body(), usize::MAX)
.await
.unwrap(),
)
.unwrap();
// Prepare file
let file_size = 1024 * 1024;
let file_bytes: Vec<u8> = (0..file_size).map(|i| (i % 255) as u8).collect();
let mut hasher = Sha256::new();
hasher.update(&file_bytes);
let original_hash = hasher.finalize();
let boundary = "boundary123";
let mut body_content = Vec::new();
// First boundary
body_content.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
body_content.extend_from_slice(b"Content-Disposition: form-data; name=\"content\"\r\n\r\n");
body_content.extend_from_slice(b"Check out this file");
body_content.extend_from_slice(b"\r\n"); // CRLF denoting end of field data
body_content.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
body_content.extend_from_slice(
b"Content-Disposition: form-data; name=\"file\"; filename=\"test_file.bin\"\r\n",
);
body_content.extend_from_slice(b"Content-Type: application/octet-stream\r\n\r\n");
body_content.extend_from_slice(&file_bytes);
body_content.extend_from_slice(b"\r\n"); // CRLF denoting end of field data
// Final boundary
body_content.extend_from_slice(format!("--{boundary}--\r\n").as_bytes());
let upload_res = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri(format!("/messages/{}/upload", room.uuid))
.header(header::AUTHORIZATION, format!("Bearer {token}"))
.header(
header::CONTENT_TYPE,
format!("multipart/form-data; boundary={boundary}"),
)
.body(Body::from(body_content))
.unwrap(),
)
.await
.unwrap();
assert_eq!(upload_res.status(), StatusCode::CREATED);
let message_resp: Message = serde_json::from_slice(
&axum::body::to_bytes(upload_res.into_body(), usize::MAX)
.await
.unwrap(),
)
.unwrap();
let file_uuid = message_resp.attachments[0].uuid;
// Download the file back
let download_res = app
.oneshot(
Request::builder()
.method("GET")
.uri(format!("/uploads/{}", file_uuid))
.header(header::AUTHORIZATION, format!("Bearer {token}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(download_res.status(), StatusCode::OK);
let downloaded_bytes = axum::body::to_bytes(download_res.into_body(), usize::MAX)
.await
.unwrap();
// Verify integrity
let mut hasher = Sha256::new();
hasher.update(&downloaded_bytes);
let downloaded_hash = hasher.finalize();
assert_eq!(
file_bytes.len(),
downloaded_bytes.len(),
"File sizes must match"
);
assert_eq!(original_hash, downloaded_hash, "File hashes must match");
}