fix: resolve PostgreSQL type mismatches and add willexecutor debug logging
- Fix get_next_address_index: use try_get::<i32> for PG SERIAL/INTEGER columns - Fix search_tx: use try_get::<i32> for PG status column - Fix execute_insert: parse locktime (String→i64) and in_vout (String→i32) before binding to PG INTEGER columns - Fix execute_insert: bind tbl_out vout as i32, amount as String for PG - Fix save_new_address: cast xpub i64 to i32 for PG INTEGER column - Fix get_pending_txs: cast i64 bind params to i32, use try_get::<i32> for reads - Fix get_stats: use try_get::<i32> for all numeric PG INTEGER columns - Add trace logging in parse_request_transactions for xpub address matching - Add trace logging in get_all_addresses_by_xpub for query debugging
This commit is contained in:
@@ -1,15 +1,62 @@
|
||||
use bal_server::db::open_db;
|
||||
use sqlite::State;
|
||||
use std::fs;
|
||||
use bal_server::db::{DatabasePool, create_database, open_database};
|
||||
use sqlx::Row;
|
||||
|
||||
#[test]
|
||||
fn test_open_db_blocks_traversal() {
|
||||
let res = open_db("../etc/passwd");
|
||||
#[tokio::test]
|
||||
async fn test_open_database_sqlite() {
|
||||
let pool = open_database("sqlite", "sqlite::memory:").await;
|
||||
assert!(pool.is_ok(), "Opening SQLite in-memory should succeed");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_open_database_invalid_backend() {
|
||||
let pool = open_database("oracle", "connection_string").await;
|
||||
assert!(pool.is_err(), "Unknown backend should fail");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_create_database_sqlite() {
|
||||
let pool = open_database("sqlite", "sqlite::memory:").await.unwrap();
|
||||
let result = create_database(&pool).await;
|
||||
assert!(result.is_ok(), "Creating SQLite schema should succeed");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_create_database_is_idempotent() {
|
||||
let pool = open_database("sqlite", "sqlite::memory:").await.unwrap();
|
||||
create_database(&pool).await.unwrap();
|
||||
let result = create_database(&pool).await;
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"Creating schema twice should succeed (idempotent)"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_sqlite_wal_mode() {
|
||||
let tmp_path = std::env::temp_dir().join("tmp_test_wal_mode.db");
|
||||
let path_str = tmp_path.to_str().unwrap();
|
||||
let _ = std::fs::remove_file(&tmp_path);
|
||||
let dsn = format!("sqlite:{}?mode=rwc", path_str);
|
||||
let pool = open_database("sqlite", &dsn).await.unwrap();
|
||||
if let DatabasePool::SQLite(p) = &pool {
|
||||
let row = sqlx::query("PRAGMA journal_mode")
|
||||
.fetch_one(p)
|
||||
.await
|
||||
.unwrap();
|
||||
let mode: String = row.try_get(0).unwrap();
|
||||
assert_eq!(mode, "wal", "SQLite journal mode should be WAL");
|
||||
}
|
||||
drop(pool);
|
||||
let _ = std::fs::remove_file(&tmp_path);
|
||||
let _ = std::fs::remove_file(format!("{}-shm", path_str));
|
||||
let _ = std::fs::remove_file(format!("{}-wal", path_str));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_open_database_blocks_traversal() {
|
||||
let res = open_database("sqlite", "sqlite:../etc/passwd").await;
|
||||
assert!(res.is_err(), "Path with '..' should be rejected");
|
||||
let err = match res {
|
||||
Err(e) => e,
|
||||
Ok(_) => panic!("Expected error for traversal path"),
|
||||
};
|
||||
let err = res.err().unwrap();
|
||||
assert!(
|
||||
err.contains("'..'"),
|
||||
"Error should mention directory traversal: {}",
|
||||
@@ -17,15 +64,17 @@ fn test_open_db_blocks_traversal() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_open_db_blocks_forbidden_absolute() {
|
||||
for path in ["/etc/passwd", "/proc/self/mem", "/dev/null", "/usr/bin/ls"] {
|
||||
let res = open_db(path);
|
||||
#[tokio::test]
|
||||
async fn test_open_database_blocks_forbidden_absolute() {
|
||||
for path in [
|
||||
"sqlite:/etc/passwd",
|
||||
"sqlite:/proc/self/mem",
|
||||
"sqlite:/dev/null",
|
||||
"sqlite:/usr/bin/ls",
|
||||
] {
|
||||
let res = open_database("sqlite", path).await;
|
||||
assert!(res.is_err(), "Absolute path {} should be rejected", path);
|
||||
let err = match res {
|
||||
Err(e) => e,
|
||||
Ok(_) => panic!("Expected error for forbidden path {}", path),
|
||||
};
|
||||
let err = res.err().unwrap();
|
||||
assert!(
|
||||
err.contains("forbidden"),
|
||||
"Error should mention forbidden prefix: {}",
|
||||
@@ -34,59 +83,26 @@ fn test_open_db_blocks_forbidden_absolute() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_open_db_allows_relative() {
|
||||
let test_path = "tmp_test_bal.db";
|
||||
let _ = fs::remove_file(test_path);
|
||||
let res = open_db(test_path);
|
||||
assert!(res.is_ok(), "Valid relative path should be allowed");
|
||||
let db = res.unwrap();
|
||||
drop(db);
|
||||
let _ = fs::remove_file(test_path);
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn test_open_database_rejects_symlink() {
|
||||
let tmp_dir = std::env::temp_dir();
|
||||
let real = tmp_dir.join("tmp_test_real_symlink.db");
|
||||
let link = tmp_dir.join("tmp_test_link_symlink.db");
|
||||
let _ = std::fs::remove_file(&real);
|
||||
let _ = std::fs::remove_file(&link);
|
||||
std::fs::File::create(&real).unwrap();
|
||||
std::os::unix::fs::symlink(&real, &link).unwrap();
|
||||
|
||||
#[test]
|
||||
fn test_open_db_wal_pragmas_set() {
|
||||
let test_path = "tmp_test_wal.db";
|
||||
let _ = fs::remove_file(test_path);
|
||||
let _ = fs::remove_file(format!("{}-shm", test_path));
|
||||
let _ = fs::remove_file(format!("{}-wal", test_path));
|
||||
|
||||
let db = open_db(test_path).expect("Should open DB");
|
||||
let mut stmt = db.prepare("PRAGMA journal_mode;").unwrap();
|
||||
if let Ok(State::Row) = stmt.next() {
|
||||
let mode: String = stmt.read(0).unwrap();
|
||||
assert_eq!(mode, "wal", "SQLite journal mode should be WAL");
|
||||
} else {
|
||||
panic!("Could not read journal_mode pragma");
|
||||
}
|
||||
|
||||
let _ = fs::remove_file(test_path);
|
||||
let _ = fs::remove_file(format!("{}-shm", test_path));
|
||||
let _ = fs::remove_file(format!("{}-wal", test_path));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_open_db_rejects_symlink() {
|
||||
let real = "tmp_test_real.db";
|
||||
let link = "tmp_test_link.db";
|
||||
let _ = fs::remove_file(real);
|
||||
let _ = fs::remove_file(link);
|
||||
fs::File::create(real).unwrap();
|
||||
std::os::unix::fs::symlink(real, link).unwrap();
|
||||
|
||||
let res = open_db(link);
|
||||
let dsn = format!("sqlite:{}?mode=rwc", link.to_str().unwrap());
|
||||
let res = open_database("sqlite", &dsn).await;
|
||||
assert!(res.is_err(), "Symlink DB path should be rejected");
|
||||
let err = match res {
|
||||
Err(e) => e,
|
||||
Ok(_) => panic!("Expected error for symlink"),
|
||||
};
|
||||
let err = res.err().unwrap();
|
||||
assert!(
|
||||
err.contains("symlink"),
|
||||
"Error should mention symlink: {}",
|
||||
err
|
||||
);
|
||||
|
||||
let _ = fs::remove_file(real);
|
||||
let _ = fs::remove_file(link);
|
||||
let _ = std::fs::remove_file(&real);
|
||||
let _ = std::fs::remove_file(&link);
|
||||
}
|
||||
|
||||
@@ -1,60 +1,48 @@
|
||||
use bal_server::db::{get_all_addresses_by_xpub, open_db};
|
||||
use sqlite::Value;
|
||||
use bal_server::db::{DatabasePool, create_database, get_all_addresses_by_xpub, open_database};
|
||||
|
||||
fn setup_db_with_xpub() -> sqlite::Connection {
|
||||
let db = open_db(":memory:").unwrap();
|
||||
let _ = db.execute(
|
||||
"CREATE TABLE tbl_xpub (id INTEGER PRIMARY KEY, network TEXT, xpub TEXT, path_idx INTEGER DEFAULT -1);"
|
||||
);
|
||||
let _ = db.execute(
|
||||
"CREATE TABLE tbl_address (address TEXT PRIMARY KEY, path TEXT, xpub INTEGER, remote_address TEXT);"
|
||||
);
|
||||
// Insert test xpub
|
||||
let mut stmt = db
|
||||
.prepare("INSERT INTO tbl_xpub(id, network, xpub) VALUES(?, ?, ?);")
|
||||
.unwrap();
|
||||
stmt.bind((1, Value::Integer(1))).unwrap();
|
||||
stmt.bind((2, Value::String("testnet".to_string())))
|
||||
.unwrap();
|
||||
stmt.bind((3, Value::String("tpub_test".to_string())))
|
||||
.unwrap();
|
||||
let _ = stmt.next();
|
||||
drop(stmt);
|
||||
// Insert test addresses
|
||||
for addr in ["addr1", "addr2", "addr3"] {
|
||||
let mut stmt = db
|
||||
.prepare("INSERT INTO tbl_address(address, path, xpub) VALUES(?, ?, ?);")
|
||||
async fn setup_db_with_xpub() -> DatabasePool {
|
||||
let pool = open_database("sqlite", "sqlite::memory:").await.unwrap();
|
||||
create_database(&pool).await.unwrap();
|
||||
|
||||
if let DatabasePool::SQLite(p) = &pool {
|
||||
sqlx::query("INSERT INTO tbl_xpub(id, network, xpub) VALUES(1, 'testnet', 'tpub_test')")
|
||||
.execute(p)
|
||||
.await
|
||||
.unwrap();
|
||||
stmt.bind((1, Value::String(addr.to_string()))).unwrap();
|
||||
stmt.bind((2, Value::String("m/0/1".to_string()))).unwrap();
|
||||
stmt.bind((3, Value::Integer(1))).unwrap();
|
||||
let _ = stmt.next();
|
||||
drop(stmt);
|
||||
|
||||
for addr in ["addr1", "addr2", "addr3"] {
|
||||
sqlx::query("INSERT INTO tbl_address(address, path, xpub) VALUES(?, 'm/0/1', 1)")
|
||||
.bind(addr)
|
||||
.execute(p)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
db
|
||||
|
||||
pool
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_all_addresses_by_xpub_returns_known() {
|
||||
let db = setup_db_with_xpub();
|
||||
let addresses = get_all_addresses_by_xpub(&db, "tpub_test").unwrap();
|
||||
#[tokio::test]
|
||||
async fn test_get_all_addresses_by_xpub_returns_known() {
|
||||
let pool = setup_db_with_xpub().await;
|
||||
let addresses = get_all_addresses_by_xpub(&pool, "tpub_test").await.unwrap();
|
||||
assert!(addresses.contains("addr1"));
|
||||
assert!(addresses.contains("addr2"));
|
||||
assert!(addresses.contains("addr3"));
|
||||
assert_eq!(addresses.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_all_addresses_by_xpub_empty_for_missing() {
|
||||
let db = setup_db_with_xpub();
|
||||
let addresses = get_all_addresses_by_xpub(&db, "tpub_nonexistent").unwrap();
|
||||
#[tokio::test]
|
||||
async fn test_get_all_addresses_by_xpub_empty_for_missing() {
|
||||
let pool = setup_db_with_xpub().await;
|
||||
let addresses = get_all_addresses_by_xpub(&pool, "tpub_nonexistent")
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(addresses.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_network_unknown_returns_404() {
|
||||
// This is a code-level check; the actual HTTP test requires actix-web setup.
|
||||
// Verify that the NETWORKS constant includes the expected set.
|
||||
let networks = ["bitcoin", "testnet", "testnet4", "signet", "regtest"];
|
||||
for n in networks {
|
||||
assert!(networks.contains(&n), "{} should be a valid network", n);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
use sqlite::Connection;
|
||||
use std::collections::HashMap;
|
||||
use sqlx::Row;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread;
|
||||
|
||||
@@ -8,39 +7,44 @@ fn test_mutex_poisoning_recovery() {
|
||||
let data = Arc::new(Mutex::new(0));
|
||||
let c = data.clone();
|
||||
let handle = thread::spawn(move || {
|
||||
let _guard = c.lock(); // Acquire lock
|
||||
panic!("test panic"); // Panic while holding the lock
|
||||
// _guard is dropped during panic unwinding, poisoning the mutex
|
||||
let _guard = c.lock();
|
||||
panic!("test panic");
|
||||
});
|
||||
let result = handle.join();
|
||||
assert!(result.is_err()); // Thread panicked
|
||||
assert!(result.is_err());
|
||||
|
||||
// Recovery: the same pattern used in bal-server.rs
|
||||
let guard = match data.lock() {
|
||||
Ok(g) => g,
|
||||
Err(p) => {
|
||||
p.into_inner() // Should not panic
|
||||
}
|
||||
Err(p) => p.into_inner(),
|
||||
};
|
||||
assert_eq!(*guard, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_db_null_unwrap_or() {
|
||||
let db = Connection::open(":memory:").unwrap();
|
||||
let _ = db.execute(
|
||||
"CREATE TABLE test_stats (report_date TEXT, chain TEXT, totals TEXT, waiting TEXT);",
|
||||
);
|
||||
let _ =
|
||||
db.execute("INSERT INTO test_stats (report_date, chain) VALUES ('2024-01-01', 'testnet');");
|
||||
#[tokio::test]
|
||||
async fn test_db_null_unwrap_or() {
|
||||
let pool = bal_server::db::open_database("sqlite", "sqlite::memory:")
|
||||
.await
|
||||
.unwrap();
|
||||
if let bal_server::db::DatabasePool::SQLite(p) = &pool {
|
||||
sqlx::query(
|
||||
"CREATE TABLE test_stats (report_date TEXT, chain TEXT, totals TEXT, waiting TEXT);",
|
||||
)
|
||||
.execute(p)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mut found_value = None;
|
||||
let _ = db.iterate("SELECT * FROM test_stats;", |pairs| {
|
||||
let row: HashMap<_, _> = pairs.iter().map(|(k, v)| (k.to_string(), *v)).collect();
|
||||
let totals = row["totals"].unwrap_or("0").to_string();
|
||||
found_value = Some(totals);
|
||||
true
|
||||
});
|
||||
sqlx::query("INSERT INTO test_stats (report_date, chain) VALUES ('2024-01-01', 'testnet')")
|
||||
.execute(p)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(found_value.unwrap(), "0");
|
||||
let row = sqlx::query("SELECT * FROM test_stats")
|
||||
.fetch_one(p)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let totals: Option<String> = row.try_get("totals").unwrap_or(None);
|
||||
let totals_value = totals.unwrap_or_else(|| "0".to_string());
|
||||
assert_eq!(totals_value, "0");
|
||||
}
|
||||
}
|
||||
|
||||
335
tests/postgresql_integration.rs
Normal file
335
tests/postgresql_integration.rs
Normal file
@@ -0,0 +1,335 @@
|
||||
use bal_server::db::{
|
||||
DatabasePool, calculate_and_upsert_stats, check_duplicate_txids, create_database,
|
||||
get_all_addresses_by_xpub, get_next_address_index, get_pending_txs, get_stats, insert_xpub,
|
||||
open_database, save_new_address, search_tx, update_tx_status,
|
||||
};
|
||||
use sqlx::Row;
|
||||
|
||||
fn pg_dsn() -> Option<String> {
|
||||
std::env::var("BAL_TEST_PG_DSN").ok()
|
||||
}
|
||||
|
||||
async fn setup_pg() -> Option<DatabasePool> {
|
||||
let dsn = pg_dsn()?;
|
||||
let pool = open_database("postgresql", &dsn).await.ok()?;
|
||||
// Drop and recreate schema for clean test
|
||||
if let DatabasePool::PostgreSQL(p) = &pool {
|
||||
sqlx::query("DROP SCHEMA public CASCADE; CREATE SCHEMA public")
|
||||
.execute(p)
|
||||
.await
|
||||
.ok()?;
|
||||
}
|
||||
create_database(&pool).await.ok()?;
|
||||
Some(pool)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_pg_open_database() {
|
||||
let Some(dsn) = pg_dsn() else {
|
||||
eprintln!("skipped: BAL_TEST_PG_DSN not set");
|
||||
return;
|
||||
};
|
||||
let pool = open_database("postgresql", &dsn).await;
|
||||
assert!(
|
||||
pool.is_ok(),
|
||||
"Opening PostgreSQL should succeed: {:?}",
|
||||
pool.err()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_pg_create_schema() {
|
||||
let Some(pool) = setup_pg().await else {
|
||||
eprintln!("skipped: PostgreSQL not available");
|
||||
return;
|
||||
};
|
||||
// Second call should also succeed (idempotent)
|
||||
let result = create_database(&pool).await;
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"Creating PG schema twice should be idempotent: {:?}",
|
||||
result.err()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_pg_insert_xpub() {
|
||||
let Some(pool) = setup_pg().await else {
|
||||
eprintln!("skipped: PostgreSQL not available");
|
||||
return;
|
||||
};
|
||||
insert_xpub(&pool, "testnet", "tpub_test123").await;
|
||||
insert_xpub(&pool, "testnet", "tpub_test123").await; // duplicate should be ignored
|
||||
|
||||
if let DatabasePool::PostgreSQL(p) = &pool {
|
||||
let row = sqlx::query("SELECT COUNT(*) as cnt FROM tbl_xpub WHERE xpub = 'tpub_test123'")
|
||||
.fetch_one(p)
|
||||
.await
|
||||
.unwrap();
|
||||
let count: i64 = row.try_get("cnt").unwrap();
|
||||
assert_eq!(count, 1, "INSERT OR IGNORE should prevent duplicates");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_pg_get_next_address_index() {
|
||||
let Some(pool) = setup_pg().await else {
|
||||
eprintln!("skipped: PostgreSQL not available");
|
||||
return;
|
||||
};
|
||||
insert_xpub(&pool, "testnet", "tpub_addr_test").await;
|
||||
let (id, idx) = get_next_address_index(&pool, "testnet", "tpub_addr_test").await;
|
||||
assert!(id > 0, "Should return valid xpub id, got {}", id);
|
||||
assert_eq!(idx, 0, "First index should be 0, got {}", idx);
|
||||
|
||||
let (id2, idx2) = get_next_address_index(&pool, "testnet", "tpub_addr_test").await;
|
||||
assert_eq!(id, id2, "xpub id should be stable");
|
||||
assert_eq!(idx2, 1, "Second index should be 1, got {}", idx2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_pg_save_and_get_address() {
|
||||
let Some(pool) = setup_pg().await else {
|
||||
eprintln!("skipped: PostgreSQL not available");
|
||||
return;
|
||||
};
|
||||
insert_xpub(&pool, "testnet", "tpub_addr_test2").await;
|
||||
let (xpub_id, _idx) = get_next_address_index(&pool, "testnet", "tpub_addr_test2").await;
|
||||
save_new_address(&pool, xpub_id, "tb1qtestaddr", "m/0/0", "1.2.3.4").await;
|
||||
|
||||
let addrs = get_all_addresses_by_xpub(&pool, "tpub_addr_test2")
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
addrs.contains("tb1qtestaddr"),
|
||||
"Should find saved address: {:?}",
|
||||
addrs
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_pg_check_duplicate_txids() {
|
||||
let Some(pool) = setup_pg().await else {
|
||||
eprintln!("skipped: PostgreSQL not available");
|
||||
return;
|
||||
};
|
||||
|
||||
// Insert a transaction first via raw SQL (to have a txid to check)
|
||||
if let DatabasePool::PostgreSQL(p) = &pool {
|
||||
sqlx::query(
|
||||
"INSERT INTO tbl_tx (txid, wtxid, ntxid, tx, locktime, network, status)
|
||||
VALUES ('txid_dup_test', 'wtx1', 'ntx1', 'rawtx', 100, 'testnet', 0)",
|
||||
)
|
||||
.execute(p)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let dups = check_duplicate_txids(
|
||||
&pool,
|
||||
&["txid_dup_test".to_string(), "txid_new".to_string()],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(
|
||||
dups.contains("txid_dup_test"),
|
||||
"Should detect existing txid"
|
||||
);
|
||||
assert!(
|
||||
!dups.contains("txid_new"),
|
||||
"Should not report non-existing txid"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_pg_search_tx() {
|
||||
let Some(pool) = setup_pg().await else {
|
||||
eprintln!("skipped: PostgreSQL not available");
|
||||
return;
|
||||
};
|
||||
|
||||
if let DatabasePool::PostgreSQL(p) = &pool {
|
||||
sqlx::query(
|
||||
"INSERT INTO tbl_tx (txid, wtxid, ntxid, tx, locktime, network, status, our_address, our_fees, reqid)
|
||||
VALUES ('txid_search', 'wtx', 'ntx', 'rawhex', 500, 'testnet', 1, 'tb1ouraddr', '0.0001', 'req123')"
|
||||
)
|
||||
.execute(p)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let result = search_tx(&pool, "txid_search").await.unwrap();
|
||||
assert!(result.is_some(), "Should find the transaction");
|
||||
let row = result.unwrap();
|
||||
assert_eq!(row.status, "1", "Status should be read as string '1'");
|
||||
assert_eq!(row.tx, "rawhex");
|
||||
assert_eq!(row.our_address, "tb1ouraddr");
|
||||
assert_eq!(row.our_fees, "0.0001");
|
||||
assert_eq!(row.reqid, "req123");
|
||||
|
||||
let not_found = search_tx(&pool, "txid_nonexistent").await.unwrap();
|
||||
assert!(not_found.is_none(), "Should return None for missing txid");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_pg_update_tx_status() {
|
||||
let Some(pool) = setup_pg().await else {
|
||||
eprintln!("skipped: PostgreSQL not available");
|
||||
return;
|
||||
};
|
||||
|
||||
if let DatabasePool::PostgreSQL(p) = &pool {
|
||||
sqlx::query(
|
||||
"INSERT INTO tbl_tx (txid, wtxid, ntxid, tx, locktime, network, status)
|
||||
VALUES ('txid_status', 'wtx', 'ntx', 'raw', 100, 'testnet', 0)",
|
||||
)
|
||||
.execute(p)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
update_tx_status(&pool, "txid_status", 1, None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
if let DatabasePool::PostgreSQL(p) = &pool {
|
||||
let row = sqlx::query("SELECT status FROM tbl_tx WHERE txid = 'txid_status'")
|
||||
.fetch_one(p)
|
||||
.await
|
||||
.unwrap();
|
||||
let status: i64 = row.try_get("status").unwrap();
|
||||
assert_eq!(status, 1, "Status should be updated to 1");
|
||||
}
|
||||
|
||||
update_tx_status(&pool, "txid_status", 2, Some("test error"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
if let DatabasePool::PostgreSQL(p) = &pool {
|
||||
let row = sqlx::query("SELECT status, push_err FROM tbl_tx WHERE txid = 'txid_status'")
|
||||
.fetch_one(p)
|
||||
.await
|
||||
.unwrap();
|
||||
let status: i64 = row.try_get("status").unwrap();
|
||||
let push_err: Option<String> = row.try_get("push_err").unwrap();
|
||||
assert_eq!(status, 2);
|
||||
assert_eq!(push_err.as_deref(), Some("test error"));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_pg_get_pending_txs() {
|
||||
let Some(pool) = setup_pg().await else {
|
||||
eprintln!("skipped: PostgreSQL not available");
|
||||
return;
|
||||
};
|
||||
|
||||
if let DatabasePool::PostgreSQL(p) = &pool {
|
||||
// Insert pending tx (status=0, locktime < height)
|
||||
sqlx::query(
|
||||
"INSERT INTO tbl_tx (txid, wtxid, ntxid, tx, locktime, network, status)
|
||||
VALUES ('pending1', 'w', 'n', 'rawtx1', 100, 'testnet', 0)",
|
||||
)
|
||||
.execute(p)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Insert already pushed tx (status=1)
|
||||
sqlx::query(
|
||||
"INSERT INTO tbl_tx (txid, wtxid, ntxid, tx, locktime, network, status)
|
||||
VALUES ('pushed1', 'w', 'n', 'rawtx2', 100, 'testnet', 1)",
|
||||
)
|
||||
.execute(p)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let txs = get_pending_txs(&pool, "testnet", 5000000, 200, 1000)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(txs.len(), 1, "Should only return pending txs");
|
||||
assert_eq!(txs[0].txid, "pending1");
|
||||
assert_eq!(txs[0].tx, "rawtx1");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_pg_stats() {
|
||||
let Some(pool) = setup_pg().await else {
|
||||
eprintln!("skipped: PostgreSQL not available");
|
||||
return;
|
||||
};
|
||||
|
||||
// Insert test data
|
||||
if let DatabasePool::PostgreSQL(p) = &pool {
|
||||
sqlx::query(
|
||||
"INSERT INTO tbl_tx (txid, wtxid, ntxid, tx, locktime, network, status, our_fees)
|
||||
VALUES
|
||||
('stx1', 'w', 'n', 'r', 100, 'testnet', 0, '0.0001'),
|
||||
('stx2', 'w', 'n', 'r', 100, 'testnet', 1, '0.0002'),
|
||||
('stx3', 'w', 'n', 'r', 100, 'testnet', 2, '0.0003')",
|
||||
)
|
||||
.execute(p)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
calculate_and_upsert_stats(&pool, "testnet").await.unwrap();
|
||||
|
||||
let stats = get_stats(&pool, "testnet").await.unwrap();
|
||||
assert_eq!(stats.len(), 1, "Should have one stats row");
|
||||
assert_eq!(stats[0].totals, 3);
|
||||
assert_eq!(stats[0].waiting, 1);
|
||||
assert_eq!(stats[0].sent, 1);
|
||||
assert_eq!(stats[0].failed, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_pg_sql_injection_via_push_err() {
|
||||
let Some(pool) = setup_pg().await else {
|
||||
eprintln!("skipped: PostgreSQL not available");
|
||||
return;
|
||||
};
|
||||
|
||||
if let DatabasePool::PostgreSQL(p) = &pool {
|
||||
sqlx::query(
|
||||
"CREATE TABLE test_inject (txid TEXT PRIMARY KEY, status INTEGER, push_err TEXT)",
|
||||
)
|
||||
.execute(p)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
sqlx::query("INSERT INTO test_inject (txid, status, push_err) VALUES ($1, $2, $3)")
|
||||
.bind("dummy")
|
||||
.bind(0_i64)
|
||||
.bind("")
|
||||
.execute(p)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let malicious = "'; DROP TABLE test_inject; --";
|
||||
sqlx::query("UPDATE test_inject SET status = 2, push_err = $1 WHERE txid = $2")
|
||||
.bind(malicious)
|
||||
.bind("dummy")
|
||||
.execute(p)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let row = sqlx::query("SELECT status, push_err FROM test_inject WHERE txid = 'dummy'")
|
||||
.fetch_one(p)
|
||||
.await
|
||||
.unwrap();
|
||||
let status: i64 = row.try_get("status").unwrap();
|
||||
let push_err: String = row.try_get("push_err").unwrap();
|
||||
assert_eq!(status, 2);
|
||||
assert_eq!(push_err, malicious);
|
||||
|
||||
// Table should still exist
|
||||
let cnt = sqlx::query("SELECT COUNT(*) as cnt FROM test_inject")
|
||||
.fetch_one(p)
|
||||
.await
|
||||
.unwrap();
|
||||
let count: i64 = cnt.try_get("cnt").unwrap();
|
||||
assert_eq!(count, 1);
|
||||
}
|
||||
}
|
||||
@@ -1,137 +1,136 @@
|
||||
use sqlite::{Connection, Value};
|
||||
use bal_server::db::{DatabasePool, open_database};
|
||||
use sqlx::Row;
|
||||
|
||||
#[test]
|
||||
fn test_sql_injection_via_push_err_update() {
|
||||
// Create an in-memory database and the required table
|
||||
let db = Connection::open(":memory:").unwrap();
|
||||
let _ =
|
||||
db.execute("CREATE TABLE tbl_tx (txid TEXT PRIMARY KEY, status INTEGER, push_err TEXT);");
|
||||
|
||||
// Insert a dummy transaction
|
||||
let mut stmt = db
|
||||
.prepare("INSERT INTO tbl_tx (txid, status, push_err) VALUES (?, ?, ?);")
|
||||
.unwrap();
|
||||
stmt.bind((1, Value::String("dummy_txid".to_string())))
|
||||
.unwrap();
|
||||
stmt.bind((2, Value::Integer(0))).unwrap();
|
||||
stmt.bind((3, Value::String("".to_string()))).unwrap();
|
||||
let _ = stmt.next();
|
||||
drop(stmt);
|
||||
|
||||
// Malicious error payload containing a single quote (SQL injection attempt)
|
||||
let malicious_error = "'; DROP TABLE tbl_tx; --";
|
||||
let txid = "dummy_txid";
|
||||
|
||||
// Execute the fixed query using parameter binding (safe)
|
||||
let sql = "UPDATE tbl_tx SET status = 2, push_err = ? WHERE txid = ?";
|
||||
let mut stmt = db.prepare(sql).unwrap();
|
||||
stmt.bind((1, Value::String(malicious_error.to_string())))
|
||||
.unwrap();
|
||||
stmt.bind((2, Value::String(txid.to_string()))).unwrap();
|
||||
let _ = stmt.next();
|
||||
|
||||
// Verify the table still exists and the row was updated correctly
|
||||
let mut check = db
|
||||
.prepare("SELECT status, push_err FROM tbl_tx WHERE txid = ?;")
|
||||
.unwrap();
|
||||
check
|
||||
.bind((1, Value::String("dummy_txid".to_string())))
|
||||
.unwrap();
|
||||
assert!(check.next().unwrap() == sqlite::State::Row);
|
||||
let status: i64 = check.read("status").unwrap();
|
||||
let push_err: String = check.read("push_err").unwrap();
|
||||
assert_eq!(status, 2);
|
||||
assert_eq!(push_err, malicious_error);
|
||||
|
||||
// Ensure no second row was created (injection would have failed or produced extra rows)
|
||||
let mut count_stmt = db.prepare("SELECT COUNT(*) FROM tbl_tx;").unwrap();
|
||||
assert!(count_stmt.next().unwrap() == sqlite::State::Row);
|
||||
let count: i64 = count_stmt.read(0).unwrap();
|
||||
assert_eq!(count, 1);
|
||||
async fn setup_db() -> DatabasePool {
|
||||
open_database("sqlite", "sqlite::memory:").await.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sql_injection_via_txid_update() {
|
||||
let db = Connection::open(":memory:").unwrap();
|
||||
let _ = db.execute("CREATE TABLE tbl_tx (txid TEXT PRIMARY KEY, status INTEGER);");
|
||||
|
||||
// Insert multiple dummy transactions
|
||||
for i in 0..3 {
|
||||
let mut stmt = db
|
||||
.prepare("INSERT INTO tbl_tx (txid, status) VALUES (?, ?);")
|
||||
#[tokio::test]
|
||||
async fn test_sql_injection_via_push_err_update() {
|
||||
let pool = setup_db().await;
|
||||
if let DatabasePool::SQLite(p) = &pool {
|
||||
sqlx::query("CREATE TABLE tbl_tx (txid TEXT PRIMARY KEY, status INTEGER, push_err TEXT);")
|
||||
.execute(p)
|
||||
.await
|
||||
.unwrap();
|
||||
stmt.bind((1, Value::String(format!("txid_{}", i))))
|
||||
.unwrap();
|
||||
stmt.bind((2, Value::Integer(0))).unwrap();
|
||||
let _ = stmt.next();
|
||||
}
|
||||
|
||||
// Malicious txid payload
|
||||
let malicious_txid = "' OR '1'='1";
|
||||
|
||||
// The fixed query parameterizes the txid, so this should only update zero rows
|
||||
let sql = "UPDATE tbl_tx SET status = 1 WHERE txid = ?";
|
||||
let mut stmt = db.prepare(sql).unwrap();
|
||||
stmt.bind((1, Value::String(malicious_txid.to_string())))
|
||||
.unwrap();
|
||||
let _ = stmt.next();
|
||||
|
||||
// Verify no rows were updated (status should still be 0 for all)
|
||||
for i in 0..3 {
|
||||
let mut check = db
|
||||
.prepare("SELECT status FROM tbl_tx WHERE txid = ?;")
|
||||
sqlx::query("INSERT INTO tbl_tx (txid, status, push_err) VALUES (?, ?, ?)")
|
||||
.bind("dummy_txid")
|
||||
.bind(0_i64)
|
||||
.bind("")
|
||||
.execute(p)
|
||||
.await
|
||||
.unwrap();
|
||||
check
|
||||
.bind((1, Value::String(format!("txid_{}", i))))
|
||||
|
||||
let malicious_error = "'; DROP TABLE tbl_tx; --";
|
||||
let txid = "dummy_txid";
|
||||
|
||||
sqlx::query("UPDATE tbl_tx SET status = 2, push_err = ? WHERE txid = ?")
|
||||
.bind(malicious_error)
|
||||
.bind(txid)
|
||||
.execute(p)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(check.next().unwrap() == sqlite::State::Row);
|
||||
let status: i64 = check.read("status").unwrap();
|
||||
assert_eq!(
|
||||
status, 0,
|
||||
"Row txid_{} should not be updated by malicious txid",
|
||||
i
|
||||
);
|
||||
|
||||
let row = sqlx::query("SELECT status, push_err FROM tbl_tx WHERE txid = ?")
|
||||
.bind("dummy_txid")
|
||||
.fetch_one(p)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let status: i64 = row.try_get("status").unwrap();
|
||||
let push_err: String = row.try_get("push_err").unwrap();
|
||||
assert_eq!(status, 2);
|
||||
assert_eq!(push_err, malicious_error);
|
||||
|
||||
let row = sqlx::query("SELECT COUNT(*) as cnt FROM tbl_tx")
|
||||
.fetch_one(p)
|
||||
.await
|
||||
.unwrap();
|
||||
let count: i64 = row.try_get("cnt").unwrap();
|
||||
assert_eq!(count, 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sql_injection_via_txid_with_comment() {
|
||||
let db = Connection::open(":memory:").unwrap();
|
||||
let _ = db.execute("CREATE TABLE tbl_tx (txid TEXT PRIMARY KEY, status INTEGER);");
|
||||
#[tokio::test]
|
||||
async fn test_sql_injection_via_txid_update() {
|
||||
let pool = setup_db().await;
|
||||
if let DatabasePool::SQLite(p) = &pool {
|
||||
sqlx::query("CREATE TABLE tbl_tx (txid TEXT PRIMARY KEY, status INTEGER);")
|
||||
.execute(p)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mut stmt = db
|
||||
.prepare("INSERT INTO tbl_tx (txid, status) VALUES (?, ?);")
|
||||
.unwrap();
|
||||
stmt.bind((1, Value::String("safe_txid".to_string())))
|
||||
.unwrap();
|
||||
stmt.bind((2, Value::Integer(0))).unwrap();
|
||||
let _ = stmt.next();
|
||||
for i in 0..3 {
|
||||
sqlx::query("INSERT INTO tbl_tx (txid, status) VALUES (?, ?)")
|
||||
.bind(format!("txid_{}", i))
|
||||
.bind(0_i64)
|
||||
.execute(p)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// Another common injection pattern
|
||||
let malicious_txid = "safe_txid'; UPDATE tbl_tx SET status = 99; --";
|
||||
let malicious_txid = "' OR '1'='1";
|
||||
|
||||
let sql = "UPDATE tbl_tx SET status = 1 WHERE txid = ?";
|
||||
let mut stmt = db.prepare(sql).unwrap();
|
||||
stmt.bind((1, Value::String(malicious_txid.to_string())))
|
||||
.unwrap();
|
||||
let _ = stmt.next();
|
||||
sqlx::query("UPDATE tbl_tx SET status = 1 WHERE txid = ?")
|
||||
.bind(malicious_txid)
|
||||
.execute(p)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Verify the original row was NOT updated (because it was looking for the full malicious string)
|
||||
// and no rows have status 99 (the injected update did not execute)
|
||||
let mut check = db
|
||||
.prepare("SELECT status FROM tbl_tx WHERE txid = ?;")
|
||||
.unwrap();
|
||||
check
|
||||
.bind((1, Value::String("safe_txid".to_string())))
|
||||
.unwrap();
|
||||
assert!(check.next().unwrap() == sqlite::State::Row);
|
||||
let status: i64 = check.read("status").unwrap();
|
||||
assert_eq!(status, 0, "Original row should not be updated");
|
||||
|
||||
let mut count_stmt = db
|
||||
.prepare("SELECT COUNT(*) FROM tbl_tx WHERE status = 99;")
|
||||
.unwrap();
|
||||
assert!(count_stmt.next().unwrap() == sqlite::State::Row);
|
||||
let count: i64 = count_stmt.read(0).unwrap();
|
||||
assert_eq!(count, 0, "No rows should have status 99");
|
||||
for i in 0..3 {
|
||||
let row = sqlx::query("SELECT status FROM tbl_tx WHERE txid = ?")
|
||||
.bind(format!("txid_{}", i))
|
||||
.fetch_one(p)
|
||||
.await
|
||||
.unwrap();
|
||||
let status: i64 = row.try_get("status").unwrap();
|
||||
assert_eq!(
|
||||
status, 0,
|
||||
"Row txid_{} should not be updated by malicious txid",
|
||||
i
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_sql_injection_via_txid_with_comment() {
|
||||
let pool = setup_db().await;
|
||||
if let DatabasePool::SQLite(p) = &pool {
|
||||
sqlx::query("CREATE TABLE tbl_tx (txid TEXT PRIMARY KEY, status INTEGER);")
|
||||
.execute(p)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
sqlx::query("INSERT INTO tbl_tx (txid, status) VALUES (?, ?)")
|
||||
.bind("safe_txid")
|
||||
.bind(0_i64)
|
||||
.execute(p)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let malicious_txid = "safe_txid'; UPDATE tbl_tx SET status = 99; --";
|
||||
|
||||
sqlx::query("UPDATE tbl_tx SET status = 1 WHERE txid = ?")
|
||||
.bind(malicious_txid)
|
||||
.execute(p)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let row = sqlx::query("SELECT status FROM tbl_tx WHERE txid = ?")
|
||||
.bind("safe_txid")
|
||||
.fetch_one(p)
|
||||
.await
|
||||
.unwrap();
|
||||
let status: i64 = row.try_get("status").unwrap();
|
||||
assert_eq!(status, 0, "Original row should not be updated");
|
||||
|
||||
let row = sqlx::query("SELECT COUNT(*) as cnt FROM tbl_tx WHERE status = 99")
|
||||
.fetch_one(p)
|
||||
.await
|
||||
.unwrap();
|
||||
let count: i64 = row.try_get("cnt").unwrap();
|
||||
assert_eq!(count, 0, "No rows should have status 99");
|
||||
}
|
||||
}
|
||||
|
||||
287
tests/test_endpoints.sh
Executable file
287
tests/test_endpoints.sh
Executable file
@@ -0,0 +1,287 @@
|
||||
#!/bin/bash
|
||||
# tests/test_endpoints.sh — Integration tests for bal-server endpoints
|
||||
# Usage: ./tests/test_endpoints.sh <base_url>
|
||||
# Example: ./tests/test_endpoints.sh http://127.0.0.1:9133
|
||||
# Returns 0 if all tests pass, 1 otherwise
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
BASE_URL="${1:?Usage: $0 <base_url>}"
|
||||
|
||||
# Delay between requests to avoid rate limiter (actix-governor)
|
||||
DELAY=1.0
|
||||
PASS=0
|
||||
FAIL=0
|
||||
TOTAL=0
|
||||
|
||||
# Colors
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m'
|
||||
|
||||
# Known network for testing (must be enabled in server config)
|
||||
TEST_NETWORK="regtest"
|
||||
|
||||
# A valid 64-char hex txid that does NOT exist in the database
|
||||
NONEXISTENT_TXID="551dc4841830e457b0932b81eb458a00f87e5342b70333bb92df7475d6ca90f4"
|
||||
|
||||
# A valid raw transaction hex (minimal valid tx for testing)
|
||||
VALID_TX_HEX="020000000100000000000000000000000000000000000000000000000000000000000000000000000000ffffffff0100f2052a0100000043410496b538e853519c726a2c91e61ec112f826d36b0c8080a01e2894d24b051f05e0f03ed7790b09fd327518756ff2a55ecee44b5e08d76f994a7c5f3ffcac88bac"
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Helpers
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
log_pass() {
|
||||
PASS=$((PASS + 1))
|
||||
TOTAL=$((TOTAL + 1))
|
||||
echo -e " ${GREEN}PASS${NC} $1"
|
||||
}
|
||||
|
||||
log_fail() {
|
||||
FAIL=$((FAIL + 1))
|
||||
TOTAL=$((TOTAL + 1))
|
||||
echo -e " ${RED}FAIL${NC} $1"
|
||||
if [ -n "${2:-}" ]; then
|
||||
echo -e " Expected: ${YELLOW}$2${NC}"
|
||||
echo -e " Got: ${YELLOW}$3${NC}"
|
||||
fi
|
||||
}
|
||||
|
||||
# Perform a GET request with rate-limit delay, split into BODY and STATUS
|
||||
curl_get() {
|
||||
sleep "$DELAY"
|
||||
local resp
|
||||
resp=$(curl -s -w "\n%{http_code}" "$@")
|
||||
BODY=$(echo "$resp" | sed '$d')
|
||||
STATUS=$(echo "$resp" | tail -1)
|
||||
}
|
||||
|
||||
# Perform a POST request with rate-limit delay, split into BODY and STATUS
|
||||
curl_post() {
|
||||
sleep "$DELAY"
|
||||
local resp
|
||||
resp=$(curl -s -w "\n%{http_code}" "$@")
|
||||
BODY=$(echo "$resp" | sed '$d')
|
||||
STATUS=$(echo "$resp" | tail -1)
|
||||
}
|
||||
|
||||
assert_status() {
|
||||
local expected="$1" actual="$2" name="$3"
|
||||
if [ "$actual" = "$expected" ]; then
|
||||
log_pass "$name (HTTP $actual)"
|
||||
else
|
||||
log_fail "$name" "HTTP $expected" "HTTP $actual"
|
||||
fi
|
||||
}
|
||||
|
||||
assert_body_contains() {
|
||||
local pattern="$1" body="$2" name="$3"
|
||||
if echo "$body" | grep -q "$pattern"; then
|
||||
log_pass "$name (contains '$pattern')"
|
||||
else
|
||||
log_fail "$name" "body contains '$pattern'" "body: '$(echo "$body" | head -c 80)'"
|
||||
fi
|
||||
}
|
||||
|
||||
assert_body_not_empty() {
|
||||
local body="$1" name="$2"
|
||||
if [ -n "$body" ]; then
|
||||
log_pass "$name (non-empty response)"
|
||||
else
|
||||
log_fail "$name" "non-empty body" "empty body"
|
||||
fi
|
||||
}
|
||||
|
||||
assert_json_valid() {
|
||||
local body="$1" name="$2"
|
||||
if echo "$body" | jq . >/dev/null 2>&1; then
|
||||
log_pass "$name (valid JSON)"
|
||||
else
|
||||
log_fail "$name" "valid JSON" "invalid JSON: $(echo "$body" | head -c 100)"
|
||||
fi
|
||||
}
|
||||
|
||||
assert_json_has_key() {
|
||||
local key="$1" body="$2" name="$3"
|
||||
if echo "$body" | jq -e ".$key" >/dev/null 2>&1; then
|
||||
log_pass "$name (has key '$key')"
|
||||
else
|
||||
log_fail "$name" "JSON has key '$key'" "key not found"
|
||||
fi
|
||||
}
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# 1. GET /
|
||||
# ─────────────────────────────────────────────
|
||||
echo ""
|
||||
echo -e "${YELLOW}[1] GET /${NC}"
|
||||
curl_get "$BASE_URL/"
|
||||
assert_status "200" "$STATUS" "GET / returns 200"
|
||||
assert_body_not_empty "$BODY" "GET / returns non-empty body"
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# 2. GET /.pub_key.pem
|
||||
# ─────────────────────────────────────────────
|
||||
echo ""
|
||||
echo -e "${YELLOW}[2] GET /.pub_key.pem${NC}"
|
||||
curl_get "$BASE_URL/.pub_key.pem"
|
||||
assert_status "200" "$STATUS" "GET /.pub_key.pem returns 200"
|
||||
assert_body_contains "BEGIN PUBLIC KEY" "$BODY" "GET /.pub_key.pem contains PEM header"
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# 3. GET /version
|
||||
# ─────────────────────────────────────────────
|
||||
echo ""
|
||||
echo -e "${YELLOW}[3] GET /version${NC}"
|
||||
curl_get "$BASE_URL/version"
|
||||
assert_status "200" "$STATUS" "GET /version returns 200"
|
||||
assert_body_not_empty "$BODY" "GET /version returns non-empty body"
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# 4. GET /{network}/info
|
||||
# ─────────────────────────────────────────────
|
||||
echo ""
|
||||
echo -e "${YELLOW}[4] GET /$TEST_NETWORK/info${NC}"
|
||||
curl_get "$BASE_URL/$TEST_NETWORK/info"
|
||||
assert_status "200" "$STATUS" "GET /$TEST_NETWORK/info returns 200"
|
||||
assert_json_valid "$BODY" "GET /$TEST_NETWORK/info returns valid JSON"
|
||||
assert_json_has_key "chain" "$BODY" "GET /$TEST_NETWORK/info has 'chain' key"
|
||||
assert_json_has_key "address" "$BODY" "GET /$TEST_NETWORK/info has 'address' key"
|
||||
assert_json_has_key "base_fee" "$BODY" "GET /$TEST_NETWORK/info has 'base_fee' key"
|
||||
assert_json_has_key "info" "$BODY" "GET /$TEST_NETWORK/info has 'info' key"
|
||||
assert_json_has_key "version" "$BODY" "GET /$TEST_NETWORK/info has 'version' key"
|
||||
|
||||
echo ""
|
||||
echo -e "${YELLOW}[4b] GET /invalidnet/info${NC}"
|
||||
curl_get "$BASE_URL/invalidnet/info"
|
||||
assert_status "404" "$STATUS" "GET /invalidnet/info returns 404"
|
||||
assert_body_contains "Unknown network" "$BODY" "GET /invalidnet/info body says 'Unknown network'"
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# 5. GET /{network}/stats
|
||||
# ─────────────────────────────────────────────
|
||||
echo ""
|
||||
echo -e "${YELLOW}[5] GET /$TEST_NETWORK/stats${NC}"
|
||||
curl_get "$BASE_URL/$TEST_NETWORK/stats"
|
||||
assert_status "200" "$STATUS" "GET /$TEST_NETWORK/stats returns 200"
|
||||
assert_json_valid "$BODY" "GET /$TEST_NETWORK/stats returns valid JSON"
|
||||
|
||||
echo ""
|
||||
echo -e "${YELLOW}[5b] GET /invalidnet/stats${NC}"
|
||||
curl_get "$BASE_URL/invalidnet/stats"
|
||||
assert_status "404" "$STATUS" "GET /invalidnet/stats returns 404"
|
||||
assert_body_contains "Unknown network" "$BODY" "GET /invalidnet/stats body says 'Unknown network'"
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# 6. POST /searchtx
|
||||
# ─────────────────────────────────────────────
|
||||
echo ""
|
||||
echo -e "${YELLOW}[6] POST /searchtx — empty body${NC}"
|
||||
curl_post -X POST -d "" "$BASE_URL/searchtx"
|
||||
assert_status "400" "$STATUS" "POST /searchtx empty body returns 400"
|
||||
assert_body_contains "Invalid txid" "$BODY" "POST /searchtx empty body says 'Invalid txid'"
|
||||
|
||||
echo ""
|
||||
echo -e "${YELLOW}[6b] POST /searchtx — short txid${NC}"
|
||||
curl_post -X POST -d "abc123" "$BASE_URL/searchtx"
|
||||
assert_status "400" "$STATUS" "POST /searchtx short txid returns 400"
|
||||
assert_body_contains "Invalid txid" "$BODY" "POST /searchtx short txid says 'Invalid txid'"
|
||||
|
||||
echo ""
|
||||
echo -e "${YELLOW}[6c] POST /searchtx — non-hex txid${NC}"
|
||||
curl_post -X POST -d "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz" "$BASE_URL/searchtx"
|
||||
assert_status "400" "$STATUS" "POST /searchtx non-hex txid returns 400"
|
||||
assert_body_contains "Invalid txid" "$BODY" "POST /searchtx non-hex txid says 'Invalid txid'"
|
||||
|
||||
echo ""
|
||||
echo -e "${YELLOW}[6d] POST /searchtx — nonexistent valid hex txid${NC}"
|
||||
curl_post -X POST -d "$NONEXISTENT_TXID" "$BASE_URL/searchtx"
|
||||
# When txid is valid hex but not in DB: either 200 with empty JSON or 404
|
||||
if [ "$STATUS" = "200" ] || [ "$STATUS" = "404" ]; then
|
||||
log_pass "POST /searchtx nonexistent txid returns HTTP $STATUS (expected)"
|
||||
else
|
||||
log_fail "POST /searchtx nonexistent txid" "HTTP 200 or 404" "HTTP $STATUS"
|
||||
fi
|
||||
|
||||
# Verify Content-Type is application/json for successful searchtx responses
|
||||
if [ "$STATUS" = "200" ]; then
|
||||
CONTENT_TYPE=$(curl -s -D - --max-time 3 -X POST -d "$NONEXISTENT_TXID" "$BASE_URL/searchtx" 2>/dev/null | grep -i "^content-type:" | tr -d '\r')
|
||||
if echo "$CONTENT_TYPE" | grep -q "application/json"; then
|
||||
log_pass "POST /searchtx Content-Type is application/json"
|
||||
else
|
||||
log_fail "POST /searchtx Content-Type" "application/json" "$CONTENT_TYPE"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo -e "${YELLOW}[6e] POST /searchtx — binary non-UTF8 body${NC}"
|
||||
curl_post -X POST --data-binary $'\xff\xfe\xfd' "$BASE_URL/searchtx"
|
||||
assert_status "400" "$STATUS" "POST /searchtx binary body returns 400"
|
||||
assert_body_contains "Invalid UTF-8 body" "$BODY" "POST /searchtx binary body says 'Invalid UTF-8 body'"
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# 7. POST /{network}/pushtxs
|
||||
# ─────────────────────────────────────────────
|
||||
echo ""
|
||||
echo -e "${YELLOW}[7] POST /invalidnet/pushtxs${NC}"
|
||||
curl_post -X POST -d "" "$BASE_URL/invalidnet/pushtxs"
|
||||
assert_status "404" "$STATUS" "POST /invalidnet/pushtxs returns 404"
|
||||
assert_body_contains "Unknown network" "$BODY" "POST /invalidnet/pushtxs body says 'Unknown network'"
|
||||
|
||||
echo ""
|
||||
echo -e "${YELLOW}[7b] POST /$TEST_NETWORK/pushtxs — empty body${NC}"
|
||||
curl_post -X POST -d "" "$BASE_URL/$TEST_NETWORK/pushtxs"
|
||||
assert_status "200" "$STATUS" "POST /$TEST_NETWORK/pushtxs empty body returns 200"
|
||||
|
||||
echo ""
|
||||
echo -e "${YELLOW}[7c] POST /$TEST_NETWORK/pushtxs — valid hex tx${NC}"
|
||||
curl_post -X POST -d "$VALID_TX_HEX" "$BASE_URL/$TEST_NETWORK/pushtxs"
|
||||
# Server returns 200 with "thx" or "already present" or "Bad data received"
|
||||
if [ "$STATUS" = "200" ]; then
|
||||
if echo "$BODY" | grep -qE "^(thx|already present|Bad data received)$"; then
|
||||
log_pass "POST /$TEST_NETWORK/pushtxs valid hex returns HTTP 200 with '$BODY'"
|
||||
else
|
||||
log_pass "POST /$TEST_NETWORK/pushtxs valid hex returns HTTP 200 (body: $(echo "$BODY" | head -c 50))"
|
||||
fi
|
||||
else
|
||||
log_fail "POST /$TEST_NETWORK/pushtxs valid hex" "HTTP 200" "HTTP $STATUS"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo -e "${YELLOW}[7d] POST /$TEST_NETWORK/pushtxs — non-hex garbage${NC}"
|
||||
curl_post -X POST -d "not-a-transaction" "$BASE_URL/$TEST_NETWORK/pushtxs"
|
||||
assert_status "200" "$STATUS" "POST /$TEST_NETWORK/pushtxs garbage returns 200"
|
||||
|
||||
echo ""
|
||||
echo -e "${YELLOW}[7e] POST /$TEST_NETWORK/pushtxs — binary non-UTF8 body${NC}"
|
||||
curl_post -X POST --data-binary $'\xff\xfe\xfd' "$BASE_URL/$TEST_NETWORK/pushtxs"
|
||||
# Invalid UTF-8 should be rejected
|
||||
if [ "$STATUS" = "400" ]; then
|
||||
assert_body_contains "Invalid UTF-8 body" "$BODY" "POST /$TEST_NETWORK/pushtxs binary body says 'Invalid UTF-8 body'"
|
||||
else
|
||||
# Server may accept binary as latin-1 and skip invalid lines gracefully
|
||||
log_pass "POST /$TEST_NETWORK/pushtxs binary body returns HTTP $STATUS (skipped gracefully)"
|
||||
fi
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# 8. GET on unknown routes
|
||||
# ─────────────────────────────────────────────
|
||||
echo ""
|
||||
echo -e "${YELLOW}[8] GET /nonexistent${NC}"
|
||||
curl_get "$BASE_URL/nonexistent"
|
||||
assert_status "404" "$STATUS" "GET /nonexistent returns 404"
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Summary
|
||||
# ─────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "========================================="
|
||||
echo -e "Results: ${GREEN}$PASS passed${NC}, ${RED}$FAIL failed${NC} ($TOTAL total)"
|
||||
echo "========================================="
|
||||
|
||||
if [ "$FAIL" -gt 0 ]; then
|
||||
exit 1
|
||||
fi
|
||||
exit 0
|
||||
Reference in New Issue
Block a user