added integration tests and added message with attachments
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
/target
|
||||
/result
|
||||
/uploads
|
||||
/test_data
|
||||
|
||||
Generated
+812
-478
File diff suppressed because it is too large
Load Diff
+6
-1
@@ -19,7 +19,8 @@ serde = { version = "1.0.219", features = ["derive"] }
|
||||
serde_json = "1.0.143"
|
||||
sqlx = { version = "0.8.6", features = ["postgres", "runtime-tokio-native-tls", "macros", "uuid", "chrono"] }
|
||||
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"
|
||||
tracing = "0.1.41"
|
||||
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"
|
||||
|
||||
[dev-dependencies]
|
||||
axum-test = "21.0.0"
|
||||
http-body-util = "0.1.3"
|
||||
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"] }
|
||||
|
||||
+43
@@ -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
@@ -48,7 +48,7 @@ CREATE TABLE IF NOT EXISTS room_invite_ (
|
||||
|
||||
CREATE TABLE IF NOT EXISTS message_ (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
uuid UUID NOT NULL,
|
||||
uuid UUID UNIQUE NOT NULL,
|
||||
sender INT REFERENCES user_(id) NOT NULL,
|
||||
room INT REFERENCES room_(id) 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
|
||||
);
|
||||
|
||||
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_ (
|
||||
token TEXT PRIMARY KEY,
|
||||
expires_at TIMESTAMPTZ NOT NULL
|
||||
@@ -64,6 +72,7 @@ CREATE TABLE ws_token_ (
|
||||
-- ==== INDICES ====
|
||||
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 INDEX idx_attachment_message ON message_attachment_(message_uuid);
|
||||
|
||||
-- Timestamp creation
|
||||
-- CREATE OR REPLACE FUNCTION create_notification_timestamp()
|
||||
|
||||
@@ -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:
|
||||
@@ -32,6 +32,8 @@
|
||||
lockFile = ./Cargo.lock;
|
||||
};
|
||||
|
||||
doCheck = false;
|
||||
|
||||
nativeBuildInputs = [
|
||||
rust
|
||||
pkgs.pkg-config
|
||||
|
||||
+13
-1
@@ -7,7 +7,7 @@ use axum::{
|
||||
};
|
||||
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)]
|
||||
pub enum APIError {
|
||||
@@ -48,6 +48,8 @@ pub enum APIError {
|
||||
|
||||
// Uploads
|
||||
WrongFileFormat,
|
||||
TooManyAttachments,
|
||||
FileTooLarge,
|
||||
|
||||
// Technical/Internal
|
||||
DatabaseError(sqlx::Error),
|
||||
@@ -209,6 +211,16 @@ impl IntoResponse for APIError {
|
||||
"WRONG_FILE_FORMAT",
|
||||
"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
|
||||
APIError::DatabaseError(e) => {
|
||||
|
||||
+17
-4
@@ -24,11 +24,14 @@ use tracing::Level;
|
||||
|
||||
const MAX_USERNAME_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 avatar_dir: PathBuf,
|
||||
pub prohibit_registration: bool,
|
||||
pub max_file_size: usize,
|
||||
pub uploads_dir: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(clap::Parser, Debug)]
|
||||
@@ -50,6 +53,10 @@ pub struct Cli {
|
||||
#[arg(short, long)]
|
||||
pub no_registration: bool,
|
||||
|
||||
/// Max file upload size in bytes (default: 10MB)
|
||||
#[arg(long, default_value = "10485760")]
|
||||
pub max_file_size: usize,
|
||||
|
||||
/// Verbose mode
|
||||
#[arg(short, long)]
|
||||
pub verbose: bool,
|
||||
@@ -61,6 +68,7 @@ pub fn create_app(
|
||||
config: Arc<AppConfig>,
|
||||
messages: realtime::RealtimeMessages,
|
||||
voice: realtime::RealTimeVoices,
|
||||
use_rate_limiter: bool,
|
||||
) -> Router {
|
||||
let governor_conf = GovernorConfigBuilder::default()
|
||||
.burst_size(20)
|
||||
@@ -90,7 +98,7 @@ pub fn create_app(
|
||||
.allow_methods([Method::GET, Method::POST])
|
||||
.allow_headers([header::AUTHORIZATION, header::CONTENT_TYPE]);
|
||||
|
||||
Router::new()
|
||||
let mut router = Router::new()
|
||||
.route("/version", get(get_version))
|
||||
.merge(routes::users::routes())
|
||||
.merge(routes::rooms::routes())
|
||||
@@ -102,7 +110,6 @@ pub fn create_app(
|
||||
.layer(Extension(messages))
|
||||
.layer(Extension(voice))
|
||||
.layer(Extension(config))
|
||||
.layer(GovernorLayer::new(governor_conf))
|
||||
.layer(cors)
|
||||
.layer(DefaultBodyLimit::max(1024 * 5 * 100))
|
||||
.layer(
|
||||
@@ -110,7 +117,13 @@ pub fn create_app(
|
||||
.make_span_with(DefaultMakeSpan::new().level(Level::DEBUG))
|
||||
.on_request(())
|
||||
.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
|
||||
|
||||
+6
-2
@@ -31,10 +31,14 @@ async fn main() -> anyhow::Result<()> {
|
||||
let messages = realtime::RealtimeMessages::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 {
|
||||
avatar_dir: data_dir.join("avatars"),
|
||||
prohibit_registration: cli.no_registration,
|
||||
max_file_size: cli.max_file_size,
|
||||
uploads_dir,
|
||||
});
|
||||
|
||||
let port = cli.port;
|
||||
@@ -43,7 +47,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
|
||||
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(
|
||||
listener,
|
||||
|
||||
+234
-12
@@ -1,9 +1,9 @@
|
||||
use std::{net::SocketAddr, time::Duration};
|
||||
use std::{collections::HashMap, net::SocketAddr, sync::Arc, time::Duration};
|
||||
|
||||
use axum::{
|
||||
Extension, Json, Router,
|
||||
extract::{
|
||||
ConnectInfo, Path, Query, WebSocketUpgrade,
|
||||
ConnectInfo, DefaultBodyLimit, Path, Query, Request, WebSocketUpgrade,
|
||||
ws::{Message as WsMessage, WebSocket},
|
||||
},
|
||||
http::{HeaderMap, StatusCode},
|
||||
@@ -13,9 +13,12 @@ use axum::{
|
||||
use axum_extra::{TypedHeader, headers};
|
||||
use sqlx::PgPool;
|
||||
use tokio::select;
|
||||
use tower::ServiceExt;
|
||||
use tower_http::services::ServeFile;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
AppConfig,
|
||||
auth::{verify_jwt, verify_jwt_string},
|
||||
db::room_id_from_uuid,
|
||||
errors::APIError,
|
||||
@@ -37,7 +40,7 @@ pub struct MessageRow {
|
||||
pub sent_at: chrono::NaiveDateTime,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize, Debug, Clone)]
|
||||
#[derive(serde::Serialize, Debug, Clone, serde::Deserialize)]
|
||||
pub struct Message {
|
||||
pub uuid: Uuid,
|
||||
pub room_uuid: Uuid,
|
||||
@@ -45,9 +48,17 @@ pub struct Message {
|
||||
pub sender_uuid: Uuid,
|
||||
pub message_type: String,
|
||||
pub content: String,
|
||||
pub attachments: Vec<Attachment>,
|
||||
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)]
|
||||
pub struct NewMessagePayload {
|
||||
pub message_type: String,
|
||||
@@ -64,6 +75,11 @@ pub fn routes() -> Router {
|
||||
Router::new()
|
||||
.route("/messages/{room_uuid}", get(list_messages))
|
||||
.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))
|
||||
}
|
||||
|
||||
@@ -87,7 +103,8 @@ async fn list_messages(
|
||||
|
||||
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#"
|
||||
SELECT
|
||||
m.uuid,
|
||||
@@ -112,28 +129,62 @@ async fn list_messages(
|
||||
.fetch_all(&mut *tx)
|
||||
.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()
|
||||
.map(|m| Message {
|
||||
.map(|m| {
|
||||
let attachments = attachment_map.remove(&m.uuid).unwrap_or_default();
|
||||
Message {
|
||||
uuid: m.uuid,
|
||||
room_uuid: m.room_uuid,
|
||||
sender: m.sender,
|
||||
sender_uuid: m.sender_uuid,
|
||||
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();
|
||||
|
||||
messages.reverse();
|
||||
|
||||
// Reset last_read_at
|
||||
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(room_id)
|
||||
@@ -184,6 +235,7 @@ async fn create_message(
|
||||
sender_uuid: claims.sub,
|
||||
message_type: payload.message_type,
|
||||
content: payload.content,
|
||||
attachments: Vec::new(),
|
||||
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
@@ -13,7 +13,7 @@ use crate::{
|
||||
routes::users::UserProfile,
|
||||
};
|
||||
|
||||
#[derive(sqlx::FromRow, serde::Serialize)]
|
||||
#[derive(sqlx::FromRow, serde::Serialize, serde::Deserialize)]
|
||||
pub struct Room {
|
||||
pub uuid: Uuid,
|
||||
pub name: String,
|
||||
@@ -60,7 +60,7 @@ pub fn routes() -> Router {
|
||||
.route("/rooms", get(list_rooms))
|
||||
.route("/rooms", post(create_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/invite", post(send_invite))
|
||||
.route("/rooms/join", post(accept_request))
|
||||
@@ -95,9 +95,6 @@ async fn list_rooms(
|
||||
Extension(db): Extension<PgPool>,
|
||||
) -> Result<Json<Vec<Room>>, APIError> {
|
||||
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?;
|
||||
|
||||
|
||||
+2
-2
@@ -38,7 +38,7 @@ pub struct LoginPayload {
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
pub struct LoginResponse {
|
||||
pub uuid: Uuid,
|
||||
pub username: String,
|
||||
@@ -60,7 +60,7 @@ pub struct UpdateUserPayoad {
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
pub struct UpdateUserResponse {
|
||||
pub email: String,
|
||||
pub username: String,
|
||||
|
||||
+720
@@ -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");
|
||||
}
|
||||
Reference in New Issue
Block a user