Skip to content

Commit d58d922

Browse files
authored
Merge branch 'main' into issue-1362
2 parents e2eaebd + a6bb424 commit d58d922

File tree

4 files changed

+111
-5
lines changed

4 files changed

+111
-5
lines changed

modules/mini-chat/mini-chat/src/domain/ports/metric_labels.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ pub mod upload_result {
7979
#[allow(dead_code)] // declared ahead of call site (deferred metrics)
8080
pub const UNSUPPORTED_TYPE: &str = "unsupported_type";
8181
pub const PROVIDER_ERROR: &str = "provider_error";
82+
pub const STORAGE_LIMIT_EXCEEDED: &str = "storage_limit_exceeded";
8283
}
8384

8485
/// Cleanup resource type labels (`resource_type` label).

modules/mini-chat/mini-chat/src/domain/service/attachment_service.rs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -965,6 +965,29 @@ impl<
965965
self.spawn_delete_file(ctx.clone(), &provider_id, &provider_file_id);
966966
return Err(DomainError::not_found("Attachment", attachment_id));
967967
}
968+
969+
// 4b. Post-upload aggregate storage check.
970+
// Reuses `conn` from step 4 so `sum_size_bytes` sees the just-written
971+
// row without an extra connection checkout.
972+
// Runs for all attachment types (not just documents) to match the
973+
// preflight check which is also type-agnostic.
974+
let total_bytes = self
975+
.attachment_repo
976+
.sum_size_bytes(&conn, &scope, chat_id)
977+
.await?;
978+
let max_bytes = i64::from(self.rag_config.max_total_upload_mb_per_chat) * 1_048_576;
979+
if total_bytes > max_bytes {
980+
self.try_set_failed(&scope, attachment_id, "uploaded", "storage_limit_exceeded")
981+
.await;
982+
self.spawn_delete_file(ctx.clone(), &provider_id, &provider_file_id);
983+
self.metrics
984+
.record_attachment_upload(kind_metric, upload_result::STORAGE_LIMIT_EXCEEDED);
985+
return Err(DomainError::StorageLimitExceeded {
986+
message: format!(
987+
"Upload causes total to exceed {max_bytes} byte limit (current total: {total_bytes})"
988+
),
989+
});
990+
}
968991
}
969992

970993
// 5. Execute purpose-specific paths (each fires independently).

modules/mini-chat/mini-chat/src/domain/service/attachment_service_test.rs

Lines changed: 85 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -168,14 +168,15 @@ fn vector_store_add_file_response() -> serde_json::Value {
168168
/// Test helper: wraps the new streaming `upload_file` with the old simple interface.
169169
///
170170
/// Calls `get_upload_context`, validates MIME, converts bytes to stream, and
171-
/// calls `upload_file` with the full parameter set.
172-
async fn test_upload_file(
171+
/// calls `upload_file` with the given `size_hint`.
172+
async fn test_upload_file_inner(
173173
svc: &TestAttachmentService,
174174
ctx: &modkit_security::SecurityContext,
175175
chat_id: Uuid,
176176
filename: &str,
177177
content_type: &str,
178178
data: Bytes,
179+
size_hint: Option<u64>,
179180
) -> Result<crate::infra::db::entity::attachment::Model, crate::domain::error::DomainError> {
180181
use crate::domain::mime_validation::{
181182
infer_mime_from_extension, normalize_mime, remap_csv_to_plain, validate_mime,
@@ -196,7 +197,6 @@ async fn test_upload_file(
196197
};
197198
let validated = validate_mime(effective_ct)?;
198199

199-
let size = data.len() as u64;
200200
let stream = bytes_to_stream(data);
201201

202202
svc.upload_file(
@@ -207,11 +207,36 @@ async fn test_upload_file(
207207
validated.mime,
208208
validated.kind,
209209
stream,
210-
Some(size),
210+
size_hint,
211211
)
212212
.await
213213
}
214214

215+
/// Upload with known `Content-Length`.
216+
async fn test_upload_file(
217+
svc: &TestAttachmentService,
218+
ctx: &modkit_security::SecurityContext,
219+
chat_id: Uuid,
220+
filename: &str,
221+
content_type: &str,
222+
data: Bytes,
223+
) -> Result<crate::infra::db::entity::attachment::Model, crate::domain::error::DomainError> {
224+
let size = data.len() as u64;
225+
test_upload_file_inner(svc, ctx, chat_id, filename, content_type, data, Some(size)).await
226+
}
227+
228+
/// Upload without `Content-Length` (simulates chunked transfer encoding).
229+
async fn test_upload_file_chunked(
230+
svc: &TestAttachmentService,
231+
ctx: &modkit_security::SecurityContext,
232+
chat_id: Uuid,
233+
filename: &str,
234+
content_type: &str,
235+
data: Bytes,
236+
) -> Result<crate::infra::db::entity::attachment::Model, crate::domain::error::DomainError> {
237+
test_upload_file_inner(svc, ctx, chat_id, filename, content_type, data, None).await
238+
}
239+
215240
// ── P5-B1: Upload document full lifecycle ──
216241

217242
#[tokio::test]
@@ -593,6 +618,62 @@ async fn test_upload_storage_limit_exceeded() {
593618
);
594619
}
595620

621+
// ── P5-C5: Post-upload storage limit for chunked uploads (no Content-Length) ──
622+
623+
#[tokio::test]
624+
async fn test_upload_storage_limit_exceeded_chunked() {
625+
let db = inmem_db().await;
626+
let tenant_id = Uuid::new_v4();
627+
let chat_id = Uuid::new_v4();
628+
let user_id = Uuid::new_v4();
629+
let db_prov = mock_db_provider(db.clone());
630+
insert_chat_for_user(&db_prov, tenant_id, chat_id, user_id).await;
631+
632+
let config = RagConfig {
633+
max_documents_per_chat: 50,
634+
max_total_upload_mb_per_chat: 1, // 1 MB limit
635+
..RagConfig::default()
636+
};
637+
638+
// Insert a large existing attachment (close to limit)
639+
let mut params =
640+
crate::domain::service::test_helpers::InsertTestAttachmentParams::ready_document(
641+
tenant_id, chat_id,
642+
);
643+
params.uploaded_by_user_id = user_id;
644+
params.size_bytes = 900_000; // ~0.86 MB
645+
crate::domain::service::test_helpers::insert_test_attachment(&db_prov, params).await;
646+
647+
let ctx = crate::domain::service::test_helpers::test_security_ctx_with_id(tenant_id, user_id);
648+
649+
// OAGW returns file upload success (the provider accepts the file)
650+
let oagw = MockOagwGateway::with_responses(vec![Ok(file_upload_response("file-chunked-001"))]);
651+
let outbox = Arc::new(NoopOutboxEnqueuer);
652+
let svc = build_service(db, Arc::clone(&oagw) as _, outbox, config);
653+
654+
// Upload 200KB via chunked encoding (no Content-Length → size_hint = None).
655+
// The preflight check is skipped, but the post-upload check should reject.
656+
let result = test_upload_file_chunked(
657+
&svc,
658+
&ctx,
659+
chat_id,
660+
"big.pdf",
661+
"application/pdf",
662+
Bytes::from(vec![0u8; 200_000]),
663+
)
664+
.await;
665+
666+
assert!(result.is_err());
667+
let err = result.unwrap_err();
668+
assert!(
669+
matches!(
670+
err,
671+
crate::domain::error::DomainError::StorageLimitExceeded { .. }
672+
),
673+
"expected StorageLimitExceeded for chunked upload, got: {err:?}"
674+
);
675+
}
676+
596677
// ── P5-D1: Provider upload failure sets attachment to failed ──
597678

598679
#[tokio::test]

modules/mini-chat/mini-chat/src/infra/db/repo/attachment_repo.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -311,7 +311,8 @@ impl crate::domain::repos::AttachmentRepository for AttachmentRepository {
311311
.filter(
312312
Condition::all()
313313
.add(Column::ChatId.eq(chat_id))
314-
.add(Column::DeletedAt.is_null()),
314+
.add(Column::DeletedAt.is_null())
315+
.add(Column::Status.ne(AttachmentStatus::Failed)),
315316
)
316317
.secure()
317318
.scope_with(scope)

0 commit comments

Comments
 (0)