600 lines
24 KiB
Rust
600 lines
24 KiB
Rust
//! Integration tests — end-to-end server + client over Unix socket.
|
|
//!
|
|
//! These tests verify the role-name-as-password model:
|
|
//! caller sends a request with a role name → selector resolution → dispatch.
|
|
//! No policy table, no approval prompt. Knowing a valid role name is sufficient.
|
|
|
|
use signer::{
|
|
alg_cache::AlgorithmKeyCache,
|
|
dispatcher::DispatcherContext,
|
|
key_store::KeyStore,
|
|
mnemonic::MnemonicState,
|
|
role_table::{RoleCurve, RolePurpose, RoleTable},
|
|
server::{AuthMode, ListenMode, ServerContext},
|
|
};
|
|
use std::os::unix::net::UnixListener;
|
|
use std::time::Duration;
|
|
|
|
const TEST_MNEMONIC: &str = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
|
|
|
|
/// Set up a server context with a test mnemonic and role table.
|
|
fn setup_server(socket_name: &str) -> (
|
|
ServerContext,
|
|
RoleTable,
|
|
MnemonicState,
|
|
KeyStore,
|
|
AlgorithmKeyCache,
|
|
) {
|
|
let mut mnemonic = MnemonicState::new();
|
|
mnemonic.load(TEST_MNEMONIC).unwrap();
|
|
|
|
let mut role_table = RoleTable::new();
|
|
role_table
|
|
.register_role_path(
|
|
"main",
|
|
"m/44'/1237'/0'/0/0",
|
|
RolePurpose::Nostr,
|
|
RoleCurve::Secp256k1,
|
|
-1, -1, -1, &[],
|
|
)
|
|
.unwrap();
|
|
|
|
let mut key_store = KeyStore::new();
|
|
key_store.derive_all(&mut role_table, &mnemonic).unwrap();
|
|
|
|
let mut server = ServerContext::new(socket_name, ListenMode::Unix, AuthMode::Off);
|
|
server.start().unwrap();
|
|
|
|
(server, role_table, mnemonic, key_store, AlgorithmKeyCache::new())
|
|
}
|
|
|
|
/// Run the server poll loop in a background thread.
|
|
///
|
|
/// Returns a handle and a stop flag. Set the stop flag to `true` to
|
|
/// terminate the loop (the thread will exit on the next iteration).
|
|
fn spawn_server_loop(
|
|
mut server: ServerContext,
|
|
mut role_table: RoleTable,
|
|
mnemonic: MnemonicState,
|
|
mut key_store: KeyStore,
|
|
mut alg_cache: AlgorithmKeyCache,
|
|
) -> (std::thread::JoinHandle<()>, std::sync::Arc<std::sync::atomic::AtomicBool>) {
|
|
let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
|
let stop_clone = stop.clone();
|
|
|
|
let handle = std::thread::spawn(move || {
|
|
while server.running && !stop_clone.load(std::sync::atomic::Ordering::SeqCst) {
|
|
let mut dispatcher = DispatcherContext {
|
|
role_table: &mut role_table,
|
|
mnemonic: &mnemonic,
|
|
key_store: &mut key_store,
|
|
alg_key_cache: &mut alg_cache,
|
|
last_mine_result: None,
|
|
offload_mining: false,
|
|
pending_mine_job: None,
|
|
};
|
|
match server.handle_one(&mut dispatcher) {
|
|
Ok(Some(_activity)) => {}
|
|
Ok(None) => {
|
|
std::thread::sleep(Duration::from_millis(10));
|
|
}
|
|
Err(_) => break,
|
|
}
|
|
// Completed background mining jobs (activity lines) are drained
|
|
// and discarded here; the client-facing assertions live in the
|
|
// request threads.
|
|
let _ = server.drain_activities();
|
|
}
|
|
});
|
|
|
|
(handle, stop)
|
|
}
|
|
|
|
/// Send a framed request to a Unix socket and return the response.
|
|
fn send_request(socket_name: &str, request: &str) -> String {
|
|
let mut stream = signer::transport::connect_abstract_unix(socket_name).unwrap();
|
|
signer::transport::send_framed(&mut stream, request).unwrap();
|
|
signer::transport::recv_framed(&mut stream).unwrap()
|
|
}
|
|
|
|
/// Wait for the server socket to be ready.
|
|
fn wait_for_server(socket_name: &str, attempts: u32) {
|
|
for _ in 0..attempts {
|
|
if signer::transport::connect_abstract_unix(socket_name).is_ok() {
|
|
return;
|
|
}
|
|
std::thread::sleep(Duration::from_millis(20));
|
|
}
|
|
panic!("server socket {} not ready", socket_name);
|
|
}
|
|
|
|
#[test]
|
|
fn test_get_info_works() {
|
|
let socket_name = format!("signer_test_info_{}", std::process::id());
|
|
let (server, role_table, mnemonic, key_store, alg_cache) = setup_server(&socket_name);
|
|
|
|
let (handle, stop) = spawn_server_loop(server, role_table, mnemonic, key_store, alg_cache);
|
|
wait_for_server(&socket_name, 100);
|
|
|
|
// get_info is metadata — no role needed
|
|
let resp = send_request(&socket_name, r#"{"id":"1","method":"get_info","params":[]}"#);
|
|
assert!(resp.contains("\"result\""), "get_info failed: {}", resp);
|
|
|
|
let _ = signer::transport::connect_abstract_unix(&socket_name);
|
|
stop.store(true, std::sync::atomic::Ordering::SeqCst);
|
|
handle.join().ok();
|
|
}
|
|
|
|
#[test]
|
|
fn test_role_as_password_allows_with_valid_role() {
|
|
let socket_name = format!("signer_test_allow_{}", std::process::id());
|
|
let (server, role_table, mnemonic, key_store, alg_cache) = setup_server(&socket_name);
|
|
|
|
let (handle, stop) = spawn_server_loop(server, role_table, mnemonic, key_store, alg_cache);
|
|
wait_for_server(&socket_name, 100);
|
|
|
|
// Knowing the "main" role name is sufficient — no authorization.
|
|
let resp = send_request(
|
|
&socket_name,
|
|
r#"{"id":"2","method":"nostr_get_public_key","params":[{"role":"main","role_path":"m/44'/1237'/0'/0/0"}]}"#,
|
|
);
|
|
assert!(resp.contains("\"result\""), "role-as-password should allow, got: {}", resp);
|
|
// Result is a plain hex pubkey string (64 hex chars)
|
|
assert!(resp.contains("e8bcf3823669444d0b49ad45d65088635d9fd8500a75b5f20b59abefa56a144f"),
|
|
"expected pubkey in result, got: {}", resp);
|
|
|
|
let _ = signer::transport::connect_abstract_unix(&socket_name);
|
|
stop.store(true, std::sync::atomic::Ordering::SeqCst);
|
|
handle.join().ok();
|
|
}
|
|
|
|
#[test]
|
|
fn test_unknown_role_returns_selector_error() {
|
|
let socket_name = format!("signer_test_unknown_{}", std::process::id());
|
|
let (server, role_table, mnemonic, key_store, alg_cache) = setup_server(&socket_name);
|
|
|
|
let (handle, stop) = spawn_server_loop(server, role_table, mnemonic, key_store, alg_cache);
|
|
wait_for_server(&socket_name, 100);
|
|
|
|
// Unknown role name → selector error (the "password" is wrong)
|
|
let resp = send_request(
|
|
&socket_name,
|
|
r#"{"id":"3","method":"nostr_get_public_key","params":[{"role":"nonexistent","role_path":"m/44'/1237'/0'/0/0"}]}"#,
|
|
);
|
|
assert!(resp.contains("unknown_role"), "expected unknown_role, got: {}", resp);
|
|
|
|
let _ = signer::transport::connect_abstract_unix(&socket_name);
|
|
stop.store(true, std::sync::atomic::Ordering::SeqCst);
|
|
handle.join().ok();
|
|
}
|
|
|
|
#[test]
|
|
fn test_ed25519_sign_allowed_no_authorization() {
|
|
let socket_name = format!("signer_test_alg_{}", std::process::id());
|
|
let (server, role_table, mnemonic, key_store, alg_cache) = setup_server(&socket_name);
|
|
|
|
let (handle, stop) = spawn_server_loop(server, role_table, mnemonic, key_store, alg_cache);
|
|
wait_for_server(&socket_name, 100);
|
|
|
|
// Algorithm-based verbs have no authorization — dispatched directly.
|
|
let msg_hex = hex::encode(b"hello");
|
|
let req = format!(
|
|
r#"{{"id":"4","method":"sign","params":["{}",{{"algorithm":"ed25519","index":0}}]}}"#,
|
|
msg_hex
|
|
);
|
|
let resp = send_request(&socket_name, &req);
|
|
assert!(resp.contains("\"result\""), "expected success, got: {}", resp);
|
|
assert!(resp.contains("signature"), "expected signature in result: {}", resp);
|
|
|
|
let _ = signer::transport::connect_abstract_unix(&socket_name);
|
|
stop.store(true, std::sync::atomic::Ordering::SeqCst);
|
|
handle.join().ok();
|
|
}
|
|
|
|
#[test]
|
|
fn test_repeated_requests_all_allowed() {
|
|
let socket_name = format!("signer_test_repeat_{}", std::process::id());
|
|
let (server, role_table, mnemonic, key_store, alg_cache) = setup_server(&socket_name);
|
|
|
|
let (handle, stop) = spawn_server_loop(server, role_table, mnemonic, key_store, alg_cache);
|
|
wait_for_server(&socket_name, 100);
|
|
|
|
// Multiple requests with the same valid role — all allowed (no session state needed)
|
|
for i in 5..=7 {
|
|
let req = format!(
|
|
r#"{{"id":"{}","method":"nostr_get_public_key","params":[{{"role":"main","role_path":"m/44'/1237'/0'/0/0"}}]}}"#,
|
|
i
|
|
);
|
|
let resp = send_request(&socket_name, &req);
|
|
assert!(resp.contains("\"result\""), "request {} failed: {}", i, resp);
|
|
}
|
|
|
|
let _ = signer::transport::connect_abstract_unix(&socket_name);
|
|
stop.store(true, std::sync::atomic::Ordering::SeqCst);
|
|
handle.join().ok();
|
|
}
|
|
|
|
/// NIP-13 mining end-to-end: a `nostr_mine_event` request over the socket
|
|
/// returns a signed event with a nonce tag, sufficient leading zero bits,
|
|
/// and a valid signature from the role's pubkey.
|
|
#[test]
|
|
fn test_nostr_mine_event_end_to_end() {
|
|
let socket_name = format!("signer_test_mine_{}", std::process::id());
|
|
let (server, role_table, mnemonic, key_store, alg_cache) = setup_server(&socket_name);
|
|
let role_pubkey = key_store.get_pubkey_hex(0).unwrap().to_string();
|
|
|
|
let (handle, stop) = spawn_server_loop(server, role_table, mnemonic, key_store, alg_cache);
|
|
wait_for_server(&socket_name, 100);
|
|
|
|
// Unsigned event JSON (no id/sig). Built with serde_json so the
|
|
// embedded event string is properly escaped inside params[0].
|
|
let event_json = serde_json::json!({
|
|
"pubkey": role_pubkey,
|
|
"created_at": 1234567890,
|
|
"kind": 1,
|
|
"tags": [],
|
|
"content": "integration pow"
|
|
})
|
|
.to_string();
|
|
let req = serde_json::json!({
|
|
"id": "9",
|
|
"method": "nostr_mine_event",
|
|
"params": [
|
|
event_json,
|
|
{"role": "main", "role_path": "m/44'/1237'/0'/0/0",
|
|
"difficulty": 8, "threads": 2, "timeout_sec": 10}
|
|
]
|
|
})
|
|
.to_string();
|
|
let resp = send_request(&socket_name, &req);
|
|
assert!(resp.contains("\"result\""), "mine failed: {}", resp);
|
|
|
|
// Result is the signed mined event, wrapped as a JSON string.
|
|
let resp_json: serde_json::Value = serde_json::from_str(&resp).unwrap();
|
|
let result_str = resp_json["result"].as_str().expect("result is a JSON string");
|
|
let mined: nostr_core::types::Event = serde_json::from_str(result_str).unwrap();
|
|
|
|
// Nonce tag present (NIP-13).
|
|
let nonce_tag = mined
|
|
.tags
|
|
.iter()
|
|
.find(|t| t.kind() == "nonce")
|
|
.expect("mined event must carry a nonce tag");
|
|
assert_eq!(nonce_tag.0.len(), 3, "nonce tag should be [\"nonce\", value, difficulty]");
|
|
|
|
// Event id has at least the target leading zero bits.
|
|
let id = mined.id.as_ref().expect("mined event must have an id");
|
|
let zeros = nips::nip013::count_leading_zero_bits(id);
|
|
assert!(zeros >= 8, "expected >= 8 leading zero bits, got {}", zeros);
|
|
|
|
// Signature verifies and pubkey matches the role.
|
|
nips::nip001::verify_event_id(&mined).unwrap();
|
|
nips::nip001::verify_event_signature(&mined).unwrap();
|
|
assert_eq!(mined.pubkey.to_hex(), role_pubkey, "pubkey must match the role");
|
|
|
|
let _ = signer::transport::connect_abstract_unix(&socket_name);
|
|
stop.store(true, std::sync::atomic::Ordering::SeqCst);
|
|
handle.join().ok();
|
|
}
|
|
|
|
/// OTP encrypt/decrypt end-to-end: bind a test pad, then round-trip a
|
|
/// message through `encrypt` → `decrypt` over the socket (ASCII armor).
|
|
#[test]
|
|
fn test_otp_encrypt_decrypt_end_to_end() {
|
|
use base64::Engine;
|
|
use std::io::Write;
|
|
|
|
// Deterministic test pad: bytes 0,1,2,...,255,0,1,...
|
|
let pad_dir = std::env::temp_dir().join(format!("signer_otp_e2e_{}", std::process::id()));
|
|
let _ = std::fs::remove_dir_all(&pad_dir);
|
|
std::fs::create_dir_all(&pad_dir).unwrap();
|
|
let pad_data: Vec<u8> = (0..4096usize).map(|i| (i % 256) as u8).collect();
|
|
let chksum = hex::encode(&nostr_core::crypto::sha256::sha256(&pad_data));
|
|
let mut pad_file = std::fs::File::create(pad_dir.join(format!("{}.pad", chksum))).unwrap();
|
|
pad_file.write_all(&pad_data).unwrap();
|
|
|
|
signer::otp_pad::bind_global(pad_dir.to_str().unwrap(), &chksum, false).unwrap();
|
|
|
|
let socket_name = format!("signer_test_otp_{}", std::process::id());
|
|
let (server, role_table, mnemonic, key_store, alg_cache) = setup_server(&socket_name);
|
|
let (handle, stop) = spawn_server_loop(server, role_table, mnemonic, key_store, alg_cache);
|
|
wait_for_server(&socket_name, 100);
|
|
|
|
// encrypt (plaintext is base64)
|
|
let pt_b64 = base64::engine::general_purpose::STANDARD.encode(b"e2e otp secret");
|
|
let enc_req = serde_json::json!({
|
|
"id": "e1",
|
|
"method": "encrypt",
|
|
"params": [pt_b64, {"algorithm": "otp", "encoding": "ascii"}]
|
|
})
|
|
.to_string();
|
|
let resp = send_request(&socket_name, &enc_req);
|
|
assert!(resp.contains("\"result\""), "encrypt failed: {}", resp);
|
|
let resp_json: serde_json::Value = serde_json::from_str(&resp).unwrap();
|
|
let enc: serde_json::Value =
|
|
serde_json::from_str(resp_json["result"].as_str().unwrap()).unwrap();
|
|
assert_eq!(enc["encoding"], "ascii");
|
|
assert_eq!(enc["pad_chksum"], chksum.as_str());
|
|
let armor = enc["ciphertext"].as_str().unwrap();
|
|
assert!(armor.starts_with("-----BEGIN OTP MESSAGE-----"));
|
|
|
|
// decrypt (auto-detect)
|
|
let dec_req = serde_json::json!({
|
|
"id": "e2",
|
|
"method": "decrypt",
|
|
"params": [armor, {"algorithm": "otp"}]
|
|
})
|
|
.to_string();
|
|
let resp = send_request(&socket_name, &dec_req);
|
|
assert!(resp.contains("\"result\""), "decrypt failed: {}", resp);
|
|
let resp_json: serde_json::Value = serde_json::from_str(&resp).unwrap();
|
|
let dec: serde_json::Value =
|
|
serde_json::from_str(resp_json["result"].as_str().unwrap()).unwrap();
|
|
let pt = base64::engine::general_purpose::STANDARD
|
|
.decode(dec["plaintext"].as_str().unwrap())
|
|
.unwrap();
|
|
assert_eq!(pt, b"e2e otp secret");
|
|
|
|
signer::otp_pad::unbind_global();
|
|
let _ = signer::transport::connect_abstract_unix(&socket_name);
|
|
stop.store(true, std::sync::atomic::Ordering::SeqCst);
|
|
handle.join().ok();
|
|
let _ = std::fs::remove_dir_all(&pad_dir);
|
|
}
|
|
|
|
// Keep UnixListener import used (for potential future filesystem socket tests)
|
|
#[allow(dead_code)]
|
|
fn _unused(_l: UnixListener) {}
|
|
|
|
// ── Multi-threaded PoW offloading ────────────────────────────────────────────
|
|
//
|
|
// `nostr_mine_event` runs on a background thread: the server loop stays free
|
|
// to serve fast signing requests while mining is in flight, multiple mining
|
|
// jobs can run concurrently, and `server.stop()` cancels in-flight jobs
|
|
// immediately.
|
|
|
|
use signer::server::ServerContext as ServerCtx;
|
|
use std::sync::atomic::{AtomicBool, Ordering as AtomicOrdering};
|
|
use std::sync::Arc;
|
|
|
|
/// Server loop variant that keeps the dispatcher context alive across
|
|
/// iterations (same as `spawn_server_loop`) — shared by the concurrency
|
|
/// tests below.
|
|
fn spawn_server_loop_concurrent(
|
|
server: ServerCtx,
|
|
role_table: RoleTable,
|
|
mnemonic: MnemonicState,
|
|
key_store: KeyStore,
|
|
alg_cache: AlgorithmKeyCache,
|
|
) -> (std::thread::JoinHandle<()>, Arc<AtomicBool>) {
|
|
spawn_server_loop(server, role_table, mnemonic, key_store, alg_cache)
|
|
}
|
|
|
|
/// While a multi-second PoW mine is in flight, fast signing requests must
|
|
/// complete in milliseconds (they would block for the whole mine run in the
|
|
/// old synchronous design).
|
|
#[test]
|
|
fn test_concurrent_signing_during_pow_mining() {
|
|
let socket_name = format!("signer_test_conc_{}", std::process::id());
|
|
let (server, role_table, mnemonic, key_store, alg_cache) = setup_server(&socket_name);
|
|
let role_pubkey = key_store.get_pubkey_hex(0).unwrap().to_string();
|
|
|
|
let (handle, stop) = spawn_server_loop_concurrent(server, role_table, mnemonic, key_store, alg_cache);
|
|
wait_for_server(&socket_name, 100);
|
|
|
|
// Client A: high-difficulty mine that runs for seconds (difficulty 24
|
|
// is far beyond what a debug build reaches in <1s; timeout bounds it).
|
|
let mine_socket = socket_name.clone();
|
|
let mine_req = serde_json::json!({
|
|
"id": "mine1",
|
|
"method": "nostr_mine_event",
|
|
"params": [
|
|
{"pubkey": role_pubkey, "created_at": 1234567890, "kind": 1,
|
|
"tags": [], "content": "slow pow"},
|
|
{"role": "main", "role_path": "m/44'/1237'/0'/0/0",
|
|
"difficulty": 24, "threads": 2, "timeout_sec": 8}
|
|
]
|
|
})
|
|
.to_string();
|
|
let mine_thread = std::thread::spawn(move || {
|
|
let mut stream = signer::transport::connect_abstract_unix(&mine_socket).unwrap();
|
|
signer::transport::send_framed(&mut stream, &mine_req).unwrap();
|
|
signer::transport::recv_framed(&mut stream).unwrap()
|
|
});
|
|
|
|
// Give the mine request time to be accepted and offloaded.
|
|
std::thread::sleep(Duration::from_millis(500));
|
|
|
|
// Client B: fast requests must complete while A is still mining.
|
|
let fast_start = std::time::Instant::now();
|
|
// nostr_sign_event takes the unsigned event as a JSON *string* param.
|
|
let event_str = serde_json::json!({
|
|
"pubkey": role_pubkey, "created_at": 1234567890, "kind": 1,
|
|
"tags": [], "content": "fast sign during pow"
|
|
})
|
|
.to_string();
|
|
let sign_req = serde_json::json!({
|
|
"id": "sign1",
|
|
"method": "nostr_sign_event",
|
|
"params": [
|
|
event_str,
|
|
{"role": "main", "role_path": "m/44'/1237'/0'/0/0"}
|
|
]
|
|
})
|
|
.to_string();
|
|
let resp = send_request(&socket_name, &sign_req);
|
|
let fast_elapsed = fast_start.elapsed();
|
|
assert!(
|
|
resp.contains("\"result\""),
|
|
"fast sign during mining failed: {}",
|
|
resp
|
|
);
|
|
assert!(
|
|
fast_elapsed < Duration::from_millis(2000),
|
|
"fast sign took {:?} — mining is blocking the server loop",
|
|
fast_elapsed
|
|
);
|
|
|
|
let pk_resp = send_request(
|
|
&socket_name,
|
|
r#"{"id":"pk1","method":"nostr_get_public_key","params":[{"role":"main","role_path":"m/44'/1237'/0'/0/0"}]}"#,
|
|
);
|
|
assert!(pk_resp.contains("\"result\""), "get_public_key during mining failed: {}", pk_resp);
|
|
|
|
// The mine request eventually completes with a result (or a timeout
|
|
// error under heavy CI load — either way the response arrives).
|
|
let mine_resp = mine_thread.join().unwrap();
|
|
assert!(
|
|
mine_resp.contains("\"result\"") || mine_resp.contains("\"error\""),
|
|
"mine response malformed: {}",
|
|
mine_resp
|
|
);
|
|
|
|
let _ = signer::transport::connect_abstract_unix(&socket_name);
|
|
stop.store(true, AtomicOrdering::SeqCst);
|
|
handle.join().ok();
|
|
}
|
|
|
|
/// Two `nostr_mine_event` requests must be able to run concurrently (both
|
|
/// responses arrive while each other is still in flight).
|
|
#[test]
|
|
fn test_parallel_pow_jobs() {
|
|
let socket_name = format!("signer_test_par_{}", std::process::id());
|
|
let (server, role_table, mnemonic, key_store, alg_cache) = setup_server(&socket_name);
|
|
let role_pubkey = key_store.get_pubkey_hex(0).unwrap().to_string();
|
|
|
|
let (handle, stop) = spawn_server_loop_concurrent(server, role_table, mnemonic, key_store, alg_cache);
|
|
wait_for_server(&socket_name, 100);
|
|
|
|
let make_req = |id: &str, content: &str| {
|
|
serde_json::json!({
|
|
"id": id,
|
|
"method": "nostr_mine_event",
|
|
"params": [
|
|
{"pubkey": role_pubkey, "created_at": 1234567890, "kind": 1,
|
|
"tags": [], "content": content},
|
|
{"role": "main", "role_path": "m/44'/1237'/0'/0/0",
|
|
"difficulty": 22, "threads": 1, "timeout_sec": 8}
|
|
]
|
|
})
|
|
.to_string()
|
|
};
|
|
|
|
let s1 = socket_name.clone();
|
|
let r1 = make_req("p1", "parallel one");
|
|
let t1 = std::thread::spawn(move || {
|
|
let mut stream = signer::transport::connect_abstract_unix(&s1).unwrap();
|
|
signer::transport::send_framed(&mut stream, &r1).unwrap();
|
|
signer::transport::recv_framed(&mut stream).unwrap()
|
|
});
|
|
|
|
let s2 = socket_name.clone();
|
|
let r2 = make_req("p2", "parallel two");
|
|
let t2 = std::thread::spawn(move || {
|
|
let mut stream = signer::transport::connect_abstract_unix(&s2).unwrap();
|
|
signer::transport::send_framed(&mut stream, &r2).unwrap();
|
|
signer::transport::recv_framed(&mut stream).unwrap()
|
|
});
|
|
|
|
let resp1 = t1.join().unwrap();
|
|
let resp2 = t2.join().unwrap();
|
|
// Both jobs must produce a response (result or bounded-timeout error),
|
|
// proving neither was serialized behind the other's full run.
|
|
assert!(
|
|
resp1.contains("\"result\"") || resp1.contains("\"error\""),
|
|
"job1 malformed: {}",
|
|
resp1
|
|
);
|
|
assert!(
|
|
resp2.contains("\"result\"") || resp2.contains("\"error\""),
|
|
"job2 malformed: {}",
|
|
resp2
|
|
);
|
|
|
|
let _ = signer::transport::connect_abstract_unix(&socket_name);
|
|
stop.store(true, AtomicOrdering::SeqCst);
|
|
handle.join().ok();
|
|
}
|
|
|
|
/// `server.stop()` must cancel an in-flight mining job immediately (the
|
|
/// background worker aborts instead of running to its timeout).
|
|
#[test]
|
|
fn test_stop_cancels_inflight_mining() {
|
|
let socket_name = format!("signer_test_cancel_{}", std::process::id());
|
|
let (mut server, role_table, mnemonic, key_store, alg_cache) = setup_server(&socket_name);
|
|
let role_pubkey = key_store.get_pubkey_hex(0).unwrap().to_string();
|
|
|
|
// Drive the loop manually so we can stop while a job is in flight.
|
|
let stop_loop = Arc::new(AtomicBool::new(false));
|
|
let stop_clone = stop_loop.clone();
|
|
let mut role_table = role_table;
|
|
let mnemonic = mnemonic;
|
|
let mut key_store = key_store;
|
|
#[allow(unused_mut)]
|
|
let mut alg_cache = alg_cache;
|
|
let loop_handle = std::thread::spawn(move || {
|
|
while server.running && !stop_clone.load(AtomicOrdering::SeqCst) {
|
|
let mut dispatcher = DispatcherContext {
|
|
role_table: &mut role_table,
|
|
mnemonic: &mnemonic,
|
|
key_store: &mut key_store,
|
|
alg_key_cache: &mut alg_cache,
|
|
last_mine_result: None,
|
|
offload_mining: false,
|
|
pending_mine_job: None,
|
|
};
|
|
let _ = server.handle_one(&mut dispatcher);
|
|
let _ = server.drain_activities();
|
|
std::thread::sleep(Duration::from_millis(10));
|
|
}
|
|
// Loop exit: cancel in-flight jobs and wait for them.
|
|
server.stop();
|
|
let _ = server.wait_for_jobs(Duration::from_secs(2));
|
|
server
|
|
});
|
|
|
|
wait_for_server(&socket_name, 100);
|
|
|
|
// Start a long mine (8s timeout) in a client thread.
|
|
let mine_socket = socket_name.clone();
|
|
let mine_req = serde_json::json!({
|
|
"id": "c1",
|
|
"method": "nostr_mine_event",
|
|
"params": [
|
|
{"pubkey": role_pubkey, "created_at": 1234567890, "kind": 1,
|
|
"tags": [], "content": "to be cancelled"},
|
|
{"role": "main", "role_path": "m/44'/1237'/0'/0/0",
|
|
"difficulty": 24, "threads": 1, "timeout_sec": 8}
|
|
]
|
|
})
|
|
.to_string();
|
|
let mine_thread = std::thread::spawn(move || {
|
|
let mut stream = signer::transport::connect_abstract_unix(&mine_socket).unwrap();
|
|
signer::transport::send_framed(&mut stream, &mine_req).unwrap();
|
|
signer::transport::recv_framed(&mut stream).unwrap()
|
|
});
|
|
|
|
// Let the job get offloaded, then stop the loop.
|
|
std::thread::sleep(Duration::from_millis(500));
|
|
let stop_time = std::time::Instant::now();
|
|
stop_loop.store(true, AtomicOrdering::SeqCst);
|
|
let server = loop_handle.join().unwrap();
|
|
let stop_elapsed = stop_time.elapsed();
|
|
|
|
// Cancellation must be near-immediate, not the 8s mine timeout.
|
|
assert!(
|
|
stop_elapsed < Duration::from_secs(4),
|
|
"stop took {:?} — in-flight mining was not cancelled promptly",
|
|
stop_elapsed
|
|
);
|
|
assert_eq!(server.active_mining_count(), 0, "jobs still counted after stop");
|
|
|
|
// The client gets a response (result or mining_cancelled error).
|
|
let resp = mine_thread.join().unwrap();
|
|
assert!(
|
|
resp.contains("\"result\"") || resp.contains("\"error\""),
|
|
"cancelled mine response malformed: {}",
|
|
resp
|
|
);
|
|
}
|