added integration tests and added message with attachments
This commit is contained in:
@@ -1,3 +1,4 @@
|
|||||||
/target
|
/target
|
||||||
/result
|
/result
|
||||||
/uploads
|
/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"
|
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
@@ -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_ (
|
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()
|
||||||
|
|||||||
@@ -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;
|
lockFile = ./Cargo.lock;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
doCheck = false;
|
||||||
|
|
||||||
nativeBuildInputs = [
|
nativeBuildInputs = [
|
||||||
rust
|
rust
|
||||||
pkgs.pkg-config
|
pkgs.pkg-config
|
||||||
|
|||||||
+13
-1
@@ -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
@@ -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
@@ -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,
|
||||||
|
|||||||
+234
-12
@@ -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| {
|
||||||
|
let attachments = attachment_map.remove(&m.uuid).unwrap_or_default();
|
||||||
|
Message {
|
||||||
uuid: m.uuid,
|
uuid: m.uuid,
|
||||||
room_uuid: m.room_uuid,
|
room_uuid: m.room_uuid,
|
||||||
sender: m.sender,
|
sender: m.sender,
|
||||||
sender_uuid: m.sender_uuid,
|
sender_uuid: m.sender_uuid,
|
||||||
message_type: m.message_type,
|
message_type: m.message_type,
|
||||||
content: m.content,
|
content: m.content,
|
||||||
|
attachments, // Populated from HashMap
|
||||||
sent_at: m.sent_at.format("%Y-%m-%d %H:%M:%S").to_string(),
|
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
@@ -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
@@ -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
@@ -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