- 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)
236 lines
8.9 KiB
Rust
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");
|
|
}
|