diff --git a/.sqlx/query-065775eed418be50a65681072d9ecf6cbe356950d8ee7d042b6f32a3f8d19ed3.json b/.sqlx/query-065775eed418be50a65681072d9ecf6cbe356950d8ee7d042b6f32a3f8d19ed3.json new file mode 100644 index 00000000..5ac588c8 --- /dev/null +++ b/.sqlx/query-065775eed418be50a65681072d9ecf6cbe356950d8ee7d042b6f32a3f8d19ed3.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO escrow_users (wallet_address, balance) VALUES ($1, $2)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Numeric" + ] + }, + "nullable": [] + }, + "hash": "065775eed418be50a65681072d9ecf6cbe356950d8ee7d042b6f32a3f8d19ed3" +} diff --git a/.sqlx/query-11aa9c9cfe5c77fcb19baf0c6d41d656796e4d72978756c3794a80a193daaf42.json b/.sqlx/query-11aa9c9cfe5c77fcb19baf0c6d41d656796e4d72978756c3794a80a193daaf42.json new file mode 100644 index 00000000..0e60e861 --- /dev/null +++ b/.sqlx/query-11aa9c9cfe5c77fcb19baf0c6d41d656796e4d72978756c3794a80a193daaf42.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO escrow_users (wallet_address, balance) VALUES ($1, 0)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "11aa9c9cfe5c77fcb19baf0c6d41d656796e4d72978756c3794a80a193daaf42" +} diff --git a/.sqlx/query-1f870e16873ababc69e3b2e5e4bbd6bccd4edb58f94e2869830a62b0a17ef95d.json b/.sqlx/query-1f870e16873ababc69e3b2e5e4bbd6bccd4edb58f94e2869830a62b0a17ef95d.json new file mode 100644 index 00000000..e6acf3cb --- /dev/null +++ b/.sqlx/query-1f870e16873ababc69e3b2e5e4bbd6bccd4edb58f94e2869830a62b0a17ef95d.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE projects\n SET bounty_amount = bounty_amount - $1\n WHERE id = $2\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Numeric", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "1f870e16873ababc69e3b2e5e4bbd6bccd4edb58f94e2869830a62b0a17ef95d" +} diff --git a/.sqlx/query-48f35d90b9ecd0e1de90d52902684739bdb30b175048dd6791bc0c225abbf809.json b/.sqlx/query-48f35d90b9ecd0e1de90d52902684739bdb30b175048dd6791bc0c225abbf809.json new file mode 100644 index 00000000..02bacc2e --- /dev/null +++ b/.sqlx/query-48f35d90b9ecd0e1de90d52902684739bdb30b175048dd6791bc0c225abbf809.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO escrow_users (wallet_address, balance)\n VALUES ($1, $2)\n ON CONFLICT (wallet_address) DO UPDATE\n SET balance = EXCLUDED.balance\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Numeric" + ] + }, + "nullable": [] + }, + "hash": "48f35d90b9ecd0e1de90d52902684739bdb30b175048dd6791bc0c225abbf809" +} diff --git a/.sqlx/query-4d9c26ba7a741292b958a39414857c635403eaf7209be35f62f04ef486f1a26a.json b/.sqlx/query-4d9c26ba7a741292b958a39414857c635403eaf7209be35f62f04ef486f1a26a.json new file mode 100644 index 00000000..5d406952 --- /dev/null +++ b/.sqlx/query-4d9c26ba7a741292b958a39414857c635403eaf7209be35f62f04ef486f1a26a.json @@ -0,0 +1,69 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id, email, name, status as \"status!: SubscriberStatus\", subscribed_at, created_at, updated_at FROM newsletter_subscribers", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "email", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "name", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "status!: SubscriberStatus", + "type_info": { + "Custom": { + "name": "subscriber_status", + "kind": { + "Enum": [ + "pending", + "active", + "unsubscribed", + "bounced", + "spam_complaint" + ] + } + } + } + }, + { + "ordinal": 4, + "name": "subscribed_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 6, + "name": "updated_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false, + false, + false, + false, + true, + false, + true + ] + }, + "hash": "4d9c26ba7a741292b958a39414857c635403eaf7209be35f62f04ef486f1a26a" +} diff --git a/.sqlx/query-5f2ff0456eb826c144c6ac177f58dbff54eb70f7eae9fa898b28b99253956610.json b/.sqlx/query-5f2ff0456eb826c144c6ac177f58dbff54eb70f7eae9fa898b28b99253956610.json new file mode 100644 index 00000000..428ab6d0 --- /dev/null +++ b/.sqlx/query-5f2ff0456eb826c144c6ac177f58dbff54eb70f7eae9fa898b28b99253956610.json @@ -0,0 +1,33 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO projects (\n owner_address, contract_address, name, description, contact_info,\n supporting_document_path, project_logo_path, repository_url,\n bounty_amount, bounty_currency, bounty_expiry_date, closed_at\n ) VALUES (\n $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12\n ) RETURNING id\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Varchar", + "Text", + "Varchar", + "Text", + "Text", + "Text", + "Numeric", + "Varchar", + "Timestamptz", + "Timestamptz" + ] + }, + "nullable": [ + false + ] + }, + "hash": "5f2ff0456eb826c144c6ac177f58dbff54eb70f7eae9fa898b28b99253956610" +} diff --git a/.sqlx/query-623317aa926d53805d2fa57a917b821a6f15140381e35f94734f28d89dd94329.json b/.sqlx/query-623317aa926d53805d2fa57a917b821a6f15140381e35f94734f28d89dd94329.json new file mode 100644 index 00000000..a40543aa --- /dev/null +++ b/.sqlx/query-623317aa926d53805d2fa57a917b821a6f15140381e35f94734f28d89dd94329.json @@ -0,0 +1,19 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO escrow_transactions (\n wallet_address, project_id, type, amount, currency, status, notes, transaction_hash\n ) VALUES ($1, $2, 'bounty_disbursement', $3, $4, 'completed', $5, $6)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Uuid", + "Numeric", + "Varchar", + "Text", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "623317aa926d53805d2fa57a917b821a6f15140381e35f94734f28d89dd94329" +} diff --git a/.sqlx/query-b6c9a65110b90a4b0caf2a29e4adbc45a014a58e205e7e63b424bb4da4adb470.json b/.sqlx/query-b6c9a65110b90a4b0caf2a29e4adbc45a014a58e205e7e63b424bb4da4adb470.json new file mode 100644 index 00000000..af241db6 --- /dev/null +++ b/.sqlx/query-b6c9a65110b90a4b0caf2a29e4adbc45a014a58e205e7e63b424bb4da4adb470.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO escrow_users (wallet_address) VALUES ($1) ON CONFLICT (wallet_address) DO NOTHING", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "b6c9a65110b90a4b0caf2a29e4adbc45a014a58e205e7e63b424bb4da4adb470" +} diff --git a/.sqlx/query-cfbcf2a97493a43be69a43da254ebf928f26b7a6763c7a09d379f3f125bec29f.json b/.sqlx/query-cfbcf2a97493a43be69a43da254ebf928f26b7a6763c7a09d379f3f125bec29f.json new file mode 100644 index 00000000..23edd7fe --- /dev/null +++ b/.sqlx/query-cfbcf2a97493a43be69a43da254ebf928f26b7a6763c7a09d379f3f125bec29f.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT closed_at FROM projects WHERE id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "closed_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + true + ] + }, + "hash": "cfbcf2a97493a43be69a43da254ebf928f26b7a6763c7a09d379f3f125bec29f" +} diff --git a/.sqlx/query-d2a025435b3487d2891896c80d129280502796be032f3b795931d4499c7b0423.json b/.sqlx/query-d2a025435b3487d2891896c80d129280502796be032f3b795931d4499c7b0423.json new file mode 100644 index 00000000..e7d83179 --- /dev/null +++ b/.sqlx/query-d2a025435b3487d2891896c80d129280502796be032f3b795931d4499c7b0423.json @@ -0,0 +1,41 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT amount, type as \"type!: String\"\n FROM escrow_transactions\n WHERE wallet_address = $1 AND transaction_hash = $2\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "amount", + "type_info": "Numeric" + }, + { + "ordinal": 1, + "name": "type!: String", + "type_info": { + "Custom": { + "name": "transaction_type", + "kind": { + "Enum": [ + "deposit", + "bounty_allocation", + "bounty_disbursement", + "withdrawal" + ] + } + } + } + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "d2a025435b3487d2891896c80d129280502796be032f3b795931d4499c7b0423" +} diff --git a/.sqlx/query-d928006be2e4b33668b606cfb4cf4f509ef347b5de3fd1540594439de2a22807.json b/.sqlx/query-d928006be2e4b33668b606cfb4cf4f509ef347b5de3fd1540594439de2a22807.json new file mode 100644 index 00000000..ddc3e7bc --- /dev/null +++ b/.sqlx/query-d928006be2e4b33668b606cfb4cf4f509ef347b5de3fd1540594439de2a22807.json @@ -0,0 +1,40 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT status::text as status, reason::text as reason, validator_notes, validated_by \n FROM research_report \n WHERE id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "status", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "reason", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "validator_notes", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "validated_by", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + null, + null, + true, + true + ] + }, + "hash": "d928006be2e4b33668b606cfb4cf4f509ef347b5de3fd1540594439de2a22807" +} diff --git a/.sqlx/query-dd220f05c4526998c187238cd905b4e60efb7f99fe9471fe650ca06b0d03a618.json b/.sqlx/query-dd220f05c4526998c187238cd905b4e60efb7f99fe9471fe650ca06b0d03a618.json new file mode 100644 index 00000000..b44d5059 --- /dev/null +++ b/.sqlx/query-dd220f05c4526998c187238cd905b4e60efb7f99fe9471fe650ca06b0d03a618.json @@ -0,0 +1,34 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT reason::text as reason, validator_notes, validated_by \n FROM research_report \n WHERE id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "reason", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "validator_notes", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "validated_by", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + null, + true, + true + ] + }, + "hash": "dd220f05c4526998c187238cd905b4e60efb7f99fe9471fe650ca06b0d03a618" +} diff --git a/.sqlx/query-e5beae84945488d05739bbde3b81dbcc34b4a8f88c11c5342b96ac24bf36dbd5.json b/.sqlx/query-e5beae84945488d05739bbde3b81dbcc34b4a8f88c11c5342b96ac24bf36dbd5.json new file mode 100644 index 00000000..1cdbf166 --- /dev/null +++ b/.sqlx/query-e5beae84945488d05739bbde3b81dbcc34b4a8f88c11c5342b96ac24bf36dbd5.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT balance FROM escrow_users WHERE wallet_address = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "balance", + "type_info": "Numeric" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "e5beae84945488d05739bbde3b81dbcc34b4a8f88c11c5342b96ac24bf36dbd5" +} diff --git a/.sqlx/query-f90de51df9f3e40be8dc965bc68944a55d7ab955f3e280d2be09a5261da91d7d.json b/.sqlx/query-f90de51df9f3e40be8dc965bc68944a55d7ab955f3e280d2be09a5261da91d7d.json new file mode 100644 index 00000000..b570b5e7 --- /dev/null +++ b/.sqlx/query-f90de51df9f3e40be8dc965bc68944a55d7ab955f3e280d2be09a5261da91d7d.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE escrow_users\n SET balance = balance + $1\n WHERE wallet_address = $2\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Numeric", + "Text" + ] + }, + "nullable": [] + }, + "hash": "f90de51df9f3e40be8dc965bc68944a55d7ab955f3e280d2be09a5261da91d7d" +} diff --git a/src/http/mod.rs b/src/http/mod.rs index b577f8b7..d54fcb6b 100644 --- a/src/http/mod.rs +++ b/src/http/mod.rs @@ -16,6 +16,7 @@ mod escrow; mod health_check; pub mod newsletter; mod project; +mod report; mod support_ticket; mod transaction; mod types; @@ -55,6 +56,7 @@ pub fn api_router(app_state: AppState) -> Router { .merge(escrow::router()) .merge(newsletter::router()) .merge(validator::router()) + .merge(report::router()) .layer(trace_layer) .layer(request_id_layer) .layer(propagate_request_id_layer) diff --git a/src/http/report/domain.rs b/src/http/report/domain.rs new file mode 100644 index 00000000..7db4226d --- /dev/null +++ b/src/http/report/domain.rs @@ -0,0 +1,71 @@ +use garde::Validate; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Serialize, Deserialize, Validate)] +pub struct RejectReportRequest { + #[garde(skip)] + pub report_id: Uuid, + #[garde(custom(validate_rejection_reason))] + pub reason: String, + #[garde(ascii, length(max = 1000))] + pub validator_notes: Option, + #[garde(custom(validate_starknet_address))] + pub validated_by: String, +} + +#[derive(Debug, Serialize)] +pub struct RejectReportResponse { + pub message: String, + pub report_id: Uuid, + pub status: String, + pub reason: String, + pub validator_notes: Option, + pub validated_by: String, + pub rejected_at: chrono::DateTime, +} + +#[derive(Debug, sqlx::FromRow)] +#[allow(dead_code)] // Fields are part of database schema and will be used in future functionality +pub struct Report { + pub id: Uuid, + pub title: String, + pub project_id: Uuid, + pub body: String, + pub reported_by: String, + pub validated_by: Option, + pub status: String, // Cast from enum to string in query + pub severity: Option, // Cast from enum to string in query + pub allocated_reward: Option, // Use BigDecimal for numeric fields + pub reason: Option, // Cast from enum to string in query + pub validator_notes: Option, + pub researcher_response: Option, + pub created_at: chrono::DateTime, + pub updated_at: Option>, +} + +pub fn validate_rejection_reason(reason: &str, _context: &()) -> garde::Result { + let valid_reasons = [ + "duplicate_report", + "incomplete_information", + "already_known", + "out_of_scope", + ]; + + if valid_reasons.contains(&reason) { + Ok(()) + } else { + Err(garde::Error::new("Invalid rejection reason")) + } +} + +pub fn validate_starknet_address(address: &str, _context: &()) -> garde::Result { + if address.starts_with("0x") + && address.len() == 66 + && address.chars().skip(2).all(|c| c.is_ascii_hexdigit()) + { + Ok(()) + } else { + Err(garde::Error::new("Invalid Starknet address")) + } +} diff --git a/src/http/report/mod.rs b/src/http/report/mod.rs new file mode 100644 index 00000000..321728c3 --- /dev/null +++ b/src/http/report/mod.rs @@ -0,0 +1,11 @@ +mod domain; +mod reject_report; + +use axum::{Router, routing::post}; +pub use domain::*; + +use crate::AppState; + +pub(crate) fn router() -> Router { + Router::new().route("/report/reject", post(reject_report::reject_report)) +} diff --git a/src/http/report/reject_report.rs b/src/http/report/reject_report.rs new file mode 100644 index 00000000..2019a7d9 --- /dev/null +++ b/src/http/report/reject_report.rs @@ -0,0 +1,150 @@ +use crate::{ + AppState, Error, Result, + http::report::{RejectReportRequest, RejectReportResponse, Report}, +}; +use axum::{Json, extract::State, http::StatusCode}; +use garde::Validate; +use sqlx::PgPool; +use uuid::Uuid; + +#[tracing::instrument(name = "Reject Report", skip(state, request))] +pub async fn reject_report( + State(state): State, + Json(request): Json, +) -> Result<(StatusCode, Json)> { + request.validate()?; + + tracing::info!( + report_id = %request.report_id, + validated_by = %request.validated_by, + "Attempting to reject report" + ); + + // First, verify the report exists and is in a valid state for rejection + let report = get_report_by_id(&state.db.pool, &request.report_id).await?; + + if report.is_none() { + tracing::warn!( + report_id = %request.report_id, + "Report not found" + ); + return Err(Error::NotFound); + } + + let report = report.unwrap(); + + // Check if the report is already rejected or in a final state + if report.status == "rejected" || report.status == "closed" { + tracing::warn!( + report_id = %request.report_id, + status = %report.status, + "Cannot reject report that is already in final state" + ); + return Err(Error::Conflict); + } + + // Verify the validator is authorized to reject this report + if let Some(assigned_validator) = &report.validated_by { + if assigned_validator != &request.validated_by { + tracing::warn!( + report_id = %request.report_id, + assigned_validator = %assigned_validator, + request_validator = %request.validated_by, + "Validator not authorized to reject this report" + ); + return Err(Error::Forbidden); + } + } + // If no validator is assigned, any validator can reject (based on business logic) + + // Perform the rejection + let rejection_time = chrono::Utc::now(); + reject_report_in_db( + &state.db.pool, + &request.report_id, + &request.reason, + &request.validator_notes, + &request.validated_by, + &rejection_time, + ) + .await?; + + tracing::info!( + report_id = %request.report_id, + validated_by = %request.validated_by, + "Successfully rejected report" + ); + + Ok(( + StatusCode::OK, + Json(RejectReportResponse { + message: "Report successfully rejected".to_string(), + report_id: request.report_id, + status: "rejected".to_string(), + reason: request.reason, + validator_notes: request.validator_notes, + validated_by: request.validated_by, + rejected_at: rejection_time, + }), + )) +} + +async fn get_report_by_id(pool: &PgPool, report_id: &Uuid) -> Result> { + let report = sqlx::query_as::<_, Report>( + r#" + SELECT + id, title, project_id, body, reported_by, validated_by, + status::text as status, + severity::text as severity, + allocated_reward, + reason::text as reason, + validator_notes, + researcher_response, + created_at, + updated_at + FROM research_report + WHERE id = $1 + "#, + ) + .bind(report_id) + .fetch_optional(pool) + .await?; + + Ok(report) +} + +async fn reject_report_in_db( + pool: &PgPool, + report_id: &Uuid, + reason: &str, + validator_notes: &Option, + validated_by: &str, + rejection_time: &chrono::DateTime, +) -> Result<()> { + let result = sqlx::query( + r#" + UPDATE research_report + SET + status = 'rejected'::report_status_type, + reason = $2::rejection_reason, + validator_notes = $3, + validated_by = $4, + updated_at = $5 + WHERE id = $1 + "#, + ) + .bind(report_id) + .bind(reason) + .bind(validator_notes) + .bind(validated_by) + .bind(rejection_time) + .execute(pool) + .await?; + + // Check if any rows were affected (report exists and was updated) + if result.rows_affected() == 0 { + return Err(Error::NotFound); + } + + Ok(()) +} diff --git a/tests/api/main.rs b/tests/api/main.rs index 86da4f78..75777c7a 100644 --- a/tests/api/main.rs +++ b/tests/api/main.rs @@ -5,6 +5,7 @@ mod health_check; mod helpers; mod newsletter; mod projects; +mod report; mod support_tickets; mod transaction; mod validator; diff --git a/tests/api/report.rs b/tests/api/report.rs new file mode 100644 index 00000000..487847c7 --- /dev/null +++ b/tests/api/report.rs @@ -0,0 +1,461 @@ +use crate::helpers::{TestApp, generate_address}; +use axum::{body::Body, extract::Request, http::StatusCode}; +use serde_json::json; +use uuid::Uuid; + +#[tokio::test] +async fn test_reject_report_success() { + let app = TestApp::new().await; + let db = &app.db; + + // Create a test project first (using correct schema fields) + let project_id = Uuid::now_v7(); + let project_wallet = generate_address(); + + sqlx::query( + r#" + INSERT INTO projects ( + id, name, description, contract_address, owner_address, contact_info, created_at + ) VALUES ( + $1, 'Test Project', 'A test project for reports', $2, $2, 'test@example.com', now() + ) + "#, + ) + .bind(project_id) + .bind(&project_wallet) + .execute(&db.pool) + .await + .expect("Failed to insert test project"); + + // Create a test report + let report_id = Uuid::now_v7(); + let researcher_wallet = generate_address(); + let validator_wallet = generate_address(); + + sqlx::query( + r#" + INSERT INTO research_report ( + id, title, project_id, body, reported_by, status, created_at + ) VALUES ( + $1, 'Test Report', $2, 'This is a test report body with sufficient content to meet the minimum requirement of 50 characters.', $3, 'submitted', now() + ) + "#, + ) + .bind(report_id) + .bind(project_id) + .bind(&researcher_wallet) + .execute(&db.pool) + .await + .expect("Failed to insert test report"); + + // Prepare rejection request + let payload = json!({ + "report_id": report_id, + "reason": "incomplete_information", + "validator_notes": "The report lacks sufficient technical details to assess the vulnerability.", + "validated_by": validator_wallet + }); + + let req = Request::builder() + .method("POST") + .uri("/report/reject") + .header("content-type", "application/json") + .body(Body::from(payload.to_string())) + .unwrap(); + + let res = app.request(req).await; + let status = res.status(); + + assert_eq!(status, StatusCode::OK); + + // Verify the report has been rejected + let report_status = + sqlx::query_scalar::<_, String>("SELECT status::text FROM research_report WHERE id = $1") + .bind(report_id) + .fetch_one(&db.pool) + .await + .expect("Failed to check report status"); + + assert_eq!(report_status, "rejected"); + + // Verify the rejection details + let report = sqlx::query!( + r#" + SELECT reason::text as reason, validator_notes, validated_by + FROM research_report + WHERE id = $1 + "#, + report_id + ) + .fetch_one(&db.pool) + .await + .expect("Failed to fetch rejected report"); + + assert_eq!(report.reason, Some("incomplete_information".to_string())); + assert_eq!( + report.validator_notes, + Some( + "The report lacks sufficient technical details to assess the vulnerability." + .to_string() + ) + ); + assert_eq!(report.validated_by, Some(validator_wallet)); +} + +#[tokio::test] +async fn test_reject_report_not_found() { + let app = TestApp::new().await; + let non_existent_report_id = Uuid::now_v7(); + let validator_wallet = generate_address(); + + let payload = json!({ + "report_id": non_existent_report_id, + "reason": "duplicate_report", + "validator_notes": "This report duplicates an existing finding.", + "validated_by": validator_wallet + }); + + let req = Request::builder() + .method("POST") + .uri("/report/reject") + .header("content-type", "application/json") + .body(Body::from(payload.to_string())) + .unwrap(); + + let res = app.request(req).await; + let status = res.status(); + + assert_eq!(status, StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn test_reject_report_already_rejected() { + let app = TestApp::new().await; + let db = &app.db; + + // Create a test project (using correct schema fields) + let project_id = Uuid::now_v7(); + let project_wallet = generate_address(); + + sqlx::query( + r#" + INSERT INTO projects ( + id, name, description, contract_address, owner_address, contact_info, created_at + ) VALUES ( + $1, 'Test Project', 'A test project for reports', $2, $2, 'test@example.com', now() + ) + "#, + ) + .bind(project_id) + .bind(&project_wallet) + .execute(&db.pool) + .await + .expect("Failed to insert test project"); + + // Create an already rejected report + let report_id = Uuid::now_v7(); + let researcher_wallet = generate_address(); + let validator_wallet = generate_address(); + + sqlx::query( + r#" + INSERT INTO research_report ( + id, title, project_id, body, reported_by, status, reason, + validator_notes, validated_by, created_at + ) VALUES ( + $1, 'Already Rejected Report', $2, 'This report was already rejected. It contains sufficient content to meet the minimum requirement of 50 characters for the body field.', $3, 'rejected', 'duplicate_report', 'Already rejected', $4, now() + ) + "#, + ) + .bind(report_id) + .bind(project_id) + .bind(&researcher_wallet) + .bind(&validator_wallet) + .execute(&db.pool) + .await + .expect("Failed to insert already rejected report"); + + // Try to reject it again + let payload = json!({ + "report_id": report_id, + "reason": "out_of_scope", + "validator_notes": "Trying to reject again.", + "validated_by": validator_wallet + }); + + let req = Request::builder() + .method("POST") + .uri("/report/reject") + .header("content-type", "application/json") + .body(Body::from(payload.to_string())) + .unwrap(); + + let res = app.request(req).await; + let status = res.status(); + + assert_eq!(status, StatusCode::CONFLICT); +} + +#[tokio::test] +async fn test_reject_report_unauthorized_validator() { + let app = TestApp::new().await; + let db = &app.db; + + // Create a test project (using correct schema fields) + let project_id = Uuid::now_v7(); + let project_wallet = generate_address(); + + sqlx::query( + r#" + INSERT INTO projects ( + id, name, description, contract_address, owner_address, contact_info, created_at + ) VALUES ( + $1, 'Test Project', 'A test project for reports', $2, $2, 'test@example.com', now() + ) + "#, + ) + .bind(project_id) + .bind(&project_wallet) + .execute(&db.pool) + .await + .expect("Failed to insert test project"); + + // Create a report assigned to a specific validator + let report_id = Uuid::now_v7(); + let researcher_wallet = generate_address(); + let assigned_validator = generate_address(); + let unauthorized_validator = generate_address(); + + sqlx::query( + r#" + INSERT INTO research_report ( + id, title, project_id, body, reported_by, status, validated_by, created_at + ) VALUES ( + $1, 'Assigned Report', $2, 'This report is assigned to a specific validator. It contains sufficient content to meet the minimum requirement of 50 characters for the body field.', $3, 'assigned', $4, now() + ) + "#, + ) + .bind(report_id) + .bind(project_id) + .bind(&researcher_wallet) + .bind(&assigned_validator) + .execute(&db.pool) + .await + .expect("Failed to insert assigned report"); + + // Try to reject with unauthorized validator + let payload = json!({ + "report_id": report_id, + "reason": "already_known", + "validator_notes": "Unauthorized rejection attempt.", + "validated_by": unauthorized_validator + }); + + let req = Request::builder() + .method("POST") + .uri("/report/reject") + .header("content-type", "application/json") + .body(Body::from(payload.to_string())) + .unwrap(); + + let res = app.request(req).await; + let status = res.status(); + + assert_eq!(status, StatusCode::FORBIDDEN); +} + +#[tokio::test] +async fn test_reject_report_invalid_reason() { + let app = TestApp::new().await; + let db = &app.db; + + // Create a test project (using correct schema fields) + let project_id = Uuid::now_v7(); + let project_wallet = generate_address(); + + sqlx::query( + r#" + INSERT INTO projects ( + id, name, description, contract_address, owner_address, contact_info, created_at + ) VALUES ( + $1, 'Test Project', 'A test project for reports', $2, $2, 'test@example.com', now() + ) + "#, + ) + .bind(project_id) + .bind(&project_wallet) + .execute(&db.pool) + .await + .expect("Failed to insert test project"); + + // Create a test report + let report_id = Uuid::now_v7(); + let researcher_wallet = generate_address(); + let validator_wallet = generate_address(); + + sqlx::query( + r#" + INSERT INTO research_report ( + id, title, project_id, body, reported_by, status, created_at + ) VALUES ( + $1, 'Test Report', $2, 'This is a test report body with sufficient content.', $3, 'submitted', now() + ) + "#, + ) + .bind(report_id) + .bind(project_id) + .bind(&researcher_wallet) + .execute(&db.pool) + .await + .expect("Failed to insert test report"); + + // Try to reject with invalid reason + let payload = json!({ + "report_id": report_id, + "reason": "invalid_reason", + "validator_notes": "Invalid reason test.", + "validated_by": validator_wallet + }); + + let req = Request::builder() + .method("POST") + .uri("/report/reject") + .header("content-type", "application/json") + .body(Body::from(payload.to_string())) + .unwrap(); + + let res = app.request(req).await; + let status = res.status(); + + assert_eq!(status, StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn test_reject_report_invalid_validator_address() { + let app = TestApp::new().await; + let report_id = Uuid::now_v7(); + + let payload = json!({ + "report_id": report_id, + "reason": "duplicate_report", + "validator_notes": "Test with invalid address.", + "validated_by": "invalid_address" + }); + + let req = Request::builder() + .method("POST") + .uri("/report/reject") + .header("content-type", "application/json") + .body(Body::from(payload.to_string())) + .unwrap(); + + let res = app.request(req).await; + let status = res.status(); + + assert_eq!(status, StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn test_reject_report_missing_fields() { + let app = TestApp::new().await; + + let payload = json!({ + // Missing required fields + "validator_notes": "Test missing fields." + }); + + let req = Request::builder() + .method("POST") + .uri("/report/reject") + .header("content-type", "application/json") + .body(Body::from(payload.to_string())) + .unwrap(); + + let res = app.request(req).await; + let status = res.status(); + + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY); +} + +#[tokio::test] +async fn test_reject_report_without_notes() { + let app = TestApp::new().await; + let db = &app.db; + + // Create a test project (using correct schema fields) + let project_id = Uuid::now_v7(); + let project_wallet = generate_address(); + + sqlx::query( + r#" + INSERT INTO projects ( + id, name, description, contract_address, owner_address, contact_info, created_at + ) VALUES ( + $1, 'Test Project', 'A test project for reports', $2, $2, 'test@example.com', now() + ) + "#, + ) + .bind(project_id) + .bind(&project_wallet) + .execute(&db.pool) + .await + .expect("Failed to insert test project"); + + // Create a test report + let report_id = Uuid::now_v7(); + let researcher_wallet = generate_address(); + let validator_wallet = generate_address(); + + sqlx::query( + r#" + INSERT INTO research_report ( + id, title, project_id, body, reported_by, status, created_at + ) VALUES ( + $1, 'Test Report', $2, 'This is a test report body with sufficient content.', $3, 'submitted', now() + ) + "#, + ) + .bind(report_id) + .bind(project_id) + .bind(&researcher_wallet) + .execute(&db.pool) + .await + .expect("Failed to insert test report"); + + // Reject without validator notes + let payload = json!({ + "report_id": report_id, + "reason": "out_of_scope", + "validated_by": validator_wallet + }); + + let req = Request::builder() + .method("POST") + .uri("/report/reject") + .header("content-type", "application/json") + .body(Body::from(payload.to_string())) + .unwrap(); + + let res = app.request(req).await; + let status = res.status(); + + assert_eq!(status, StatusCode::OK); + + // Verify the report was rejected without notes + let report = sqlx::query!( + r#" + SELECT status::text as status, reason::text as reason, validator_notes, validated_by + FROM research_report + WHERE id = $1 + "#, + report_id + ) + .fetch_one(&db.pool) + .await + .expect("Failed to fetch rejected report"); + + assert_eq!(report.status, Some("rejected".to_string())); + assert_eq!(report.reason, Some("out_of_scope".to_string())); + assert_eq!(report.validator_notes, None); + assert_eq!(report.validated_by, Some(validator_wallet)); +}