niko_trust_gui/tests/live_relay.rs
Niko Marmeladkov 6278321873
niko_trust_gui: TCE protocol client with iced GUI
- Byte-exact TCE codec (encoder/decoder, canonical numbers, vectors-tested)
- Ed25519 identities with bech32m trust1… addresses
- Relay client: challenge/auth handshake, request feed, responses,
  transparent re-auth on 401
- iced GUI (Material-dark, Solana-style palette): tab shell, approval
  prompts with native toasts on Linux, address book, history, settings,
  password change
- Portable single-file vault (identity + book + history + settings)
- Live 2FA roundtrip examples (send_2fa, await_2fa)
2026-08-22 23:20:41 +03:00

236 lines
8.9 KiB
Rust

//! Live integration tests against a real relay instance.
//!
//! Skips (with a clear message) when `NIKO_RELAY` is not set. The store is
//! in-memory per server run, so these tests are self-contained: they create
//! their own sender and recipient identities, post a request, verify and
//! answer it, and check the response is queryable. Nothing is left behind.
use niko_trust_gui::inbox::{Inbox, Outcome};
use niko_trust_gui::protocol::{encode_approval_request, ApprovalRequest, Decision};
use niko_trust_gui::relay::Relay;
use niko_trust_gui::signer::Signer;
use niko_trust_gui::tce::NONCE_SIZE;
fn base() -> Option<String> {
std::env::var("NIKO_RELAY")
.ok()
.map(|s| s.trim_end_matches('/').to_string())
}
fn now() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs()
}
fn mk_request(
sender: &Signer,
recipient: &Signer,
action: &str,
message: &str,
) -> (Vec<u8>, [u8; 64]) {
let create = now();
let req = ApprovalRequest {
sender: sender.pubkey(),
recipient: recipient.pubkey(),
action: action.to_string(),
payload: Default::default(),
message: message.to_string(),
created_at: create,
expires_at: create + 40,
nonce: [
0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
0x0e, 0x0f,
],
};
let tce = encode_approval_request(&req).expect("encode request");
let sig = sender.sign(&tce);
(tce, sig)
}
#[test]
fn live_auth_fetch_respond_roundtrip() {
let Some(base) = base() else {
eprintln!("skipping live_auth_fetch_respond_roundtrip: NIKO_RELAY not set");
return;
};
let recipient = Signer::generate().expect("recipient signer");
let sender = Signer::generate().expect("sender signer");
// Authenticate as the recipient with the generic "read" scope.
let relay = Relay::auth(&base, &recipient, "*").expect("auth handshake");
assert!(!relay.audience().is_empty());
// Sender posts an ApprovalRequest addressed to the recipient.
let (req_tce, req_sig) = mk_request(&sender, &recipient, "login", "Approve 2FA login");
let sender_agent = Relay::auth(&base, &sender, "*").expect("sender auth");
let req_id = sender_agent.store(&req_tce, &req_sig).expect("store request");
assert!(!req_id.is_empty());
// Recipient fetches, decodes and verifies the incoming request.
let inbox = relay.fetch_requests(&recipient).expect("fetch requests");
assert_eq!(inbox.len(), 1, "expected exactly one new request");
let inc = &inbox[0];
assert_eq!(inc.request.action, "login");
assert_eq!(inc.request.message, "Approve 2FA login");
// Respond Allow; the relay must accept it.
let resp_id = relay
.respond(&recipient, &inc.request, &req_tce, Decision::Allow)
.expect("respond allow");
assert!(!resp_id.is_empty());
// Responding again to the same request must be refused by the server.
let again = relay.respond(&recipient, &inc.request, &req_tce, Decision::Deny);
assert!(again.is_err(), "second response to a request must be rejected");
}
#[test]
fn live_rejects_wrong_recipient() {
let Some(base) = base() else {
eprintln!("skipping live_rejects_wrong_recipient: NIKO_RELAY not set");
return;
};
let me = Signer::generate().expect("me signer");
let someone_else = Signer::generate().expect("victim signer");
let sender = Signer::generate().expect("sender signer");
let relay = Relay::auth(&base, &me, "*").expect("auth");
// Request addressed to someone_else: polling as `me` must yield nothing.
let (req_tce, req_sig) = mk_request(&sender, &someone_else, "x", "not for me");
let sender_agent = Relay::auth(&base, &sender, "*").expect("sender auth");
sender_agent.store(&req_tce, &req_sig).expect("store");
let inbox = relay.fetch_requests(&me).expect("fetch");
assert!(
inbox.iter().all(|r| r.request.recipient != me.pubkey()),
"no request may target my key that was addressed elsewhere"
);
}
#[test]
fn live_ignores_poisoned_envelope() {
let Some(base) = base() else {
eprintln!("skipping live_ignores_poisoned_envelope: NIKO_RELAY not set");
return;
};
let me = Signer::generate().expect("me signer");
let sender = Signer::generate().expect("sender signer");
let relay = Relay::auth(&base, &me, "*").expect("auth");
// A request addressed to me but signed by a *different* key. The relay
// stores any well-formed envelope without checking signatures (INV-5), so
// this lands in the feed; the client must skip it because the signature
// does not verify against the request's sender field.
let timestamp = now() - 1000;
let req = ApprovalRequest {
sender: sender.pubkey(),
recipient: me.pubkey(),
action: "phish".to_string(),
payload: Default::default(),
message: "".to_string(),
created_at: timestamp,
expires_at: timestamp + 30,
nonce: [7u8; NONCE_SIZE],
};
let tce = encode_approval_request(&req).expect("encode");
// Wrong signer: forge a signature with an unrelated identity.
let forger = Signer::generate().expect("forger signer");
let bad_sig = forger.sign(&tce);
let sender_agent = Relay::auth(&base, &sender, "*").expect("sender auth");
let _ = sender_agent.store(&tce, &bad_sig).expect("store poisoned");
let inbox = relay.fetch_requests(&me).expect("fetch");
assert!(
inbox.iter().all(|r| r.request.sender != sender.pubkey() || r.request.action != "phish"),
"envelope with a forged signature must be dropped from the inbox"
);
}
/// Regression: after a restart the in-memory inbox is empty, so an
/// already-answered request used to reappear as "pending" and re-answering
/// failed with 422. The client must reconcile each new request against the
/// relay's responses feed and restore the recorded outcome.
#[test]
fn live_restart_reconciles_answered_requests() {
let Some(base) = base() else {
eprintln!("skipping live_restart_reconciles_answered_requests: NIKO_RELAY not set");
return;
};
let recipient = Signer::generate().expect("recipient signer");
let sender = Signer::generate().expect("sender signer");
fn mk(
sender: &Signer,
recipient: &Signer,
message: &str,
nonce_byte: u8,
) -> (Vec<u8>, [u8; 64]) {
let create = now();
let req = ApprovalRequest {
sender: sender.pubkey(),
recipient: recipient.pubkey(),
action: "login".to_string(),
payload: Default::default(),
message: message.to_string(),
created_at: create,
expires_at: create + 40,
nonce: [nonce_byte; NONCE_SIZE],
};
let tce = encode_approval_request(&req).expect("encode");
let sig = sender.sign(&tce);
(tce, sig)
}
let relay = Relay::auth(&base, &recipient, "*").expect("recipient auth");
let sender_agent = Relay::auth(&base, &sender, "*").expect("sender auth");
// Two distinct requests; answer one Allow, the other Deny.
let (tce1, sig1) = mk(&sender, &recipient, "restart-allow", 0x01);
sender_agent.store(&tce1, &sig1).expect("store 1");
let (tce2, sig2) = mk(&sender, &recipient, "restart-deny", 0x02);
sender_agent.store(&tce2, &sig2).expect("store 2");
let list = relay.fetch_requests(&recipient).expect("fetch");
for r in &list {
let d = if r.request.message == "restart-allow" {
Decision::Allow
} else {
Decision::Deny
};
relay
.respond(&recipient, &r.request, &r.tce, d)
.expect("respond as recipient");
}
// Simulate a restart: a brand-new inbox that has never seen anything.
let mut inbox = Inbox::new();
let list2 = relay.fetch_requests(&recipient).expect("refetch after restart");
let new_items = inbox.observe(now(), &list2);
assert_eq!(new_items.len(), 2, "both requests are new to the fresh inbox");
// Reconcile exactly like the GUI does (AnswerChecked).
for r in new_items {
let rid = hex::encode(r.request_id);
let resps = relay.fetch_responses(&rid).expect("responses feed");
let mine = resps
.into_iter()
.find(|x| x.response.responder == recipient.pubkey())
.expect("request was answered before the restart");
inbox.set_outcome(&r.request_id, Outcome::from(mine.response.decision));
}
let h = inbox.history();
let allow = h.iter().find(|x| x.message == "restart-allow").unwrap();
let deny = h.iter().find(|x| x.message == "restart-deny").unwrap();
assert_eq!(allow.outcome, Outcome::Approved, "answered-allow must not be pending");
assert_eq!(deny.outcome, Outcome::Denied, "answered-deny must not be pending");
assert_eq!(inbox.pending_count(), 0, "nothing may be left pending after reconciliation");
}