feat: rework signup into a safer multi-step flow, add keep-mail setting
This commit is contained in:
parent
53c9c4b8ce
commit
2892ea3fa7
3 changed files with 649 additions and 132 deletions
382
src/app.rs
382
src/app.rs
|
|
@ -75,14 +75,28 @@ pub enum OnboardMode {
|
|||
|
||||
/// Signup: bind the identity to a username, or recall an existing address.
|
||||
pub struct Signup {
|
||||
pub step: SignupStep,
|
||||
pub mode: SignupMode,
|
||||
pub user: String,
|
||||
pub host: String,
|
||||
pub invite: String,
|
||||
/// The server's base32 static key, pasted from what the operator
|
||||
/// published; pinned before the registration connects.
|
||||
pub key: String,
|
||||
/// True when onboarding just wrote the key file, so Back is offered.
|
||||
pub fresh: bool,
|
||||
/// True once this host's key is known to be pinned — either a fetch
|
||||
/// was just confirmed (see `Carrier::pin_offer`) or it already was
|
||||
/// from an earlier session — so the server step's button reads
|
||||
/// "Continue" instead of "Fetch the server's key".
|
||||
pub key_trusted: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SignupStep {
|
||||
Server,
|
||||
Account,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SignupMode {
|
||||
New,
|
||||
Existing,
|
||||
}
|
||||
|
||||
/// The compose view: `to` takes a short address or a smol:// URI.
|
||||
|
|
@ -107,6 +121,8 @@ pub enum Dialog {
|
|||
},
|
||||
/// Rotation only moves the index forward, so the action asks first.
|
||||
Rotate,
|
||||
/// Factory reset erases every local file, so it asks first too.
|
||||
Reset,
|
||||
/// The message being answered carries the sender's own frontmatter
|
||||
/// fields; the reply asks whether to carry copies of them.
|
||||
Reply {
|
||||
|
|
@ -133,9 +149,13 @@ pub enum Message {
|
|||
SignupUserChanged(String),
|
||||
SignupHostChanged(String),
|
||||
SignupInviteChanged(String),
|
||||
SignupKeyChanged(String),
|
||||
SignupBack,
|
||||
SignupLearnKey,
|
||||
/// The server step's one button: fetches the host's key, or — once
|
||||
/// one is on screen (fetched this session or already pinned) — pins
|
||||
/// it and advances.
|
||||
SignupKeyAction,
|
||||
SignupStepBack,
|
||||
SignupStartOver,
|
||||
SignupModeChanged(SignupMode),
|
||||
SignupRegister,
|
||||
SignupRecall,
|
||||
|
||||
|
|
@ -186,6 +206,7 @@ pub enum Message {
|
|||
|
||||
// Settings
|
||||
SettingsLearnKey,
|
||||
KeepMailChanged(bool),
|
||||
TimeoutChanged(String),
|
||||
RotatePressed,
|
||||
RotateConfirmed,
|
||||
|
|
@ -194,6 +215,9 @@ pub enum Message {
|
|||
ExportPressed,
|
||||
ImportBodyChanged(String),
|
||||
ImportPressed,
|
||||
ResetPressed,
|
||||
ResetConfirmed,
|
||||
ResetCancelled,
|
||||
}
|
||||
|
||||
/// What a background operation reports back.
|
||||
|
|
@ -219,8 +243,6 @@ pub enum PendingOp {
|
|||
Register {
|
||||
addr: Address,
|
||||
invite: Option<String>,
|
||||
/// A pasted operator key, pinned before the registration connects.
|
||||
key: String,
|
||||
},
|
||||
Restore {
|
||||
addr: Address,
|
||||
|
|
@ -279,6 +301,9 @@ pub struct Carrier {
|
|||
pub status_error: bool,
|
||||
pub busy: Option<String>,
|
||||
pub cancel: Arc<AtomicBool>,
|
||||
/// Off by default: a fetch acknowledges what it takes, so mail is
|
||||
/// removed from the server once it's stored here.
|
||||
pub keep_mail: bool,
|
||||
pub timeout: u64,
|
||||
pub timeout_text: String,
|
||||
pub onboard: Onboard,
|
||||
|
|
@ -304,6 +329,11 @@ pub struct Carrier {
|
|||
/// The view syncs entry text from the model once after a prefill
|
||||
/// (reply, write-to-contact), then leaves typing to the widgets.
|
||||
pub compose_fresh: Cell<bool>,
|
||||
/// Same pattern for the onboarding/signup entries: a programmatic
|
||||
/// reset (factory reset, discarding a cancelled identity) clears the
|
||||
/// model, and this flag makes the view push that blank state into
|
||||
/// the entries once, instead of leaving stale typed text on screen.
|
||||
pub form_fresh: Cell<bool>,
|
||||
}
|
||||
|
||||
impl Carrier {
|
||||
|
|
@ -334,6 +364,7 @@ impl Carrier {
|
|||
status_error: false,
|
||||
busy: None,
|
||||
cancel: Arc::new(AtomicBool::new(false)),
|
||||
keep_mail: false,
|
||||
timeout: 30,
|
||||
timeout_text: "30".to_string(),
|
||||
onboard: Onboard {
|
||||
|
|
@ -342,11 +373,12 @@ impl Carrier {
|
|||
seed_shown: String::new(),
|
||||
},
|
||||
signup: Signup {
|
||||
step: SignupStep::Server,
|
||||
mode: SignupMode::New,
|
||||
user: String::new(),
|
||||
host: String::new(),
|
||||
invite: String::new(),
|
||||
key: String::new(),
|
||||
fresh: false,
|
||||
key_trusted: false,
|
||||
},
|
||||
folder: Folder::Inbox,
|
||||
lists: vec![Vec::new(), Vec::new(), Vec::new()],
|
||||
|
|
@ -362,6 +394,7 @@ impl Carrier {
|
|||
contact_import: String::new(),
|
||||
dialog: None,
|
||||
compose_fresh: Cell::new(false),
|
||||
form_fresh: Cell::new(true),
|
||||
};
|
||||
let op = registered.then(|| PendingOp::Snapshot {
|
||||
read: app.marks.snapshot(),
|
||||
|
|
@ -427,6 +460,61 @@ impl Carrier {
|
|||
self.compose_fresh.set(true);
|
||||
}
|
||||
|
||||
/// Factory reset: close the store, erase the config directory, then
|
||||
/// boot a fresh model in place. The connection is swapped out before
|
||||
/// the files go because Windows refuses to remove a file a live
|
||||
/// connection holds, and the busy guard has already seen to it that
|
||||
/// no operation holds a clone of the store.
|
||||
fn factory_reset(&mut self) {
|
||||
let store = match Store::open_in_memory() {
|
||||
Ok(store) => Arc::new(store),
|
||||
Err(e) => {
|
||||
self.fail(e.to_string());
|
||||
return;
|
||||
}
|
||||
};
|
||||
self.store = store;
|
||||
if let Err(e) = ops::factory_reset(&self.dir) {
|
||||
self.fail(e);
|
||||
return;
|
||||
}
|
||||
let (app, _) = Carrier::boot(self.dir.clone());
|
||||
*self = app;
|
||||
self.note("factory reset done. Create a new identity or restore a seed.".into());
|
||||
}
|
||||
|
||||
/// Cancelling out of a not-yet-registered identity removes the key
|
||||
/// file too, so Back always leaves onboarding resumable from a clean
|
||||
/// start — never behind a stale key a later Create could silently
|
||||
/// overwrite and orphan the server registration against. The seed
|
||||
/// already shown (or pasted) still regenerates the same master, so
|
||||
/// nothing is actually lost.
|
||||
fn discard_identity(&mut self) {
|
||||
if self.store.account().ok().flatten().is_none() {
|
||||
if let Err(e) = ops::delete_key(&self.key_path()) {
|
||||
self.fail(e);
|
||||
return;
|
||||
}
|
||||
self.master = None;
|
||||
}
|
||||
self.onboard = Onboard {
|
||||
mode: OnboardMode::Start,
|
||||
seed: String::new(),
|
||||
seed_shown: String::new(),
|
||||
};
|
||||
self.signup = Signup {
|
||||
step: SignupStep::Server,
|
||||
mode: SignupMode::New,
|
||||
user: String::new(),
|
||||
host: String::new(),
|
||||
invite: String::new(),
|
||||
key_trusted: false,
|
||||
};
|
||||
self.pin_offer = None;
|
||||
self.page = Page::Onboard;
|
||||
self.form_fresh.set(true);
|
||||
}
|
||||
|
||||
/// The current folder's rows, drafts included: drafts carry the
|
||||
/// recipient where mail carries the sender, newest first.
|
||||
pub fn rows(&self) -> Vec<Row> {
|
||||
|
|
@ -504,6 +592,17 @@ impl Carrier {
|
|||
Address::parse(&format!("{user}@{host}")).ok()
|
||||
}
|
||||
|
||||
/// The host as typed on the signup page's server step, before a
|
||||
/// username exists — `learn_key`/`connect` only read host/port/scheme,
|
||||
/// so a throwaway username satisfies `Address::parse`'s grammar.
|
||||
fn signup_host_address(&self) -> Option<Address> {
|
||||
let host = self.signup.host.trim();
|
||||
if host.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Address::parse(&format!("x@{host}")).ok()
|
||||
}
|
||||
|
||||
/// Opens one message and marks it read inline: opening an envelope and
|
||||
/// re-listing is local and fast, so it needs no background pass.
|
||||
fn select_message(&mut self, folder: Folder, id: String) {
|
||||
|
|
@ -596,16 +695,20 @@ impl Carrier {
|
|||
}
|
||||
Message::SignupHostChanged(host) => {
|
||||
self.signup.host = host;
|
||||
// A different host invalidates whatever key was on
|
||||
// screen: there is only ever one key pending trust.
|
||||
self.signup.key_trusted = false;
|
||||
self.pin_offer = None;
|
||||
}
|
||||
Message::SignupInviteChanged(invite) => {
|
||||
self.signup.invite = invite;
|
||||
}
|
||||
Message::SignupKeyChanged(key) => {
|
||||
self.signup.key = key;
|
||||
}
|
||||
Message::ContactImportChanged(text) => {
|
||||
self.contact_import = text;
|
||||
}
|
||||
Message::KeepMailChanged(keep) => {
|
||||
self.keep_mail = keep;
|
||||
}
|
||||
Message::TimeoutChanged(text) => {
|
||||
if let Ok(timeout) = text.trim().parse::<u64>() {
|
||||
self.timeout = timeout;
|
||||
|
|
@ -640,7 +743,13 @@ impl Carrier {
|
|||
self.onboard.mode = OnboardMode::Restore;
|
||||
}
|
||||
Message::OnboardBack => {
|
||||
self.onboard.mode = OnboardMode::Start;
|
||||
if self.onboard.mode == OnboardMode::Created {
|
||||
self.discard_identity();
|
||||
} else {
|
||||
self.onboard.mode = OnboardMode::Start;
|
||||
self.onboard.seed.clear();
|
||||
self.form_fresh.set(true);
|
||||
}
|
||||
}
|
||||
Message::OnboardRestoreFromSeed => match ops::parse_seed(&self.onboard.seed) {
|
||||
Ok(master) => {
|
||||
|
|
@ -648,7 +757,6 @@ impl Carrier {
|
|||
self.fail(e);
|
||||
} else {
|
||||
self.master = Some(master);
|
||||
self.signup.fresh = true;
|
||||
self.page = Page::Signup;
|
||||
self.note("Seed restored. Bind a username or recall an address.".into());
|
||||
}
|
||||
|
|
@ -656,15 +764,28 @@ impl Carrier {
|
|||
Err(e) => self.fail(e),
|
||||
},
|
||||
Message::OnboardSeedSaved => {
|
||||
self.signup.fresh = true;
|
||||
self.page = Page::Signup;
|
||||
}
|
||||
Message::SignupBack => {
|
||||
self.page = Page::Onboard;
|
||||
Message::SignupStepBack => {
|
||||
self.signup.step = SignupStep::Server;
|
||||
}
|
||||
Message::SignupLearnKey => {
|
||||
let Some(addr) = self.signup_address() else {
|
||||
self.fail("enter user and host first".into());
|
||||
Message::SignupStartOver => {
|
||||
self.discard_identity();
|
||||
}
|
||||
Message::SignupModeChanged(mode) => {
|
||||
self.signup.mode = mode;
|
||||
}
|
||||
Message::SignupKeyAction => {
|
||||
if let Some((addr, key)) = self.pin_offer.take() {
|
||||
self.signup.step = SignupStep::Account;
|
||||
return vec![PendingOp::Pin { addr, key }];
|
||||
}
|
||||
if self.signup.key_trusted {
|
||||
self.signup.step = SignupStep::Account;
|
||||
return Vec::new();
|
||||
}
|
||||
let Some(addr) = self.signup_host_address() else {
|
||||
self.fail("enter the server host first".into());
|
||||
return Vec::new();
|
||||
};
|
||||
return vec![PendingOp::LearnKey { addr }];
|
||||
|
|
@ -694,14 +815,19 @@ impl Carrier {
|
|||
} else {
|
||||
Some(self.signup.invite.clone())
|
||||
};
|
||||
let key = self.signup.key.trim().to_string();
|
||||
return vec![PendingOp::Register { addr, invite, key }];
|
||||
if !self.start("registering") {
|
||||
return Vec::new();
|
||||
}
|
||||
return vec![PendingOp::Register { addr, invite }];
|
||||
}
|
||||
Message::SignupRecall => {
|
||||
let Some(addr) = self.signup_address() else {
|
||||
self.fail("enter user and host".into());
|
||||
return Vec::new();
|
||||
};
|
||||
if !self.start("restoring") {
|
||||
return Vec::new();
|
||||
}
|
||||
return vec![PendingOp::Restore { addr }];
|
||||
}
|
||||
Message::GotoMail(folder) => {
|
||||
|
|
@ -989,6 +1115,16 @@ impl Carrier {
|
|||
Message::RotateCancelled => {
|
||||
self.dialog = None;
|
||||
}
|
||||
Message::ResetPressed => {
|
||||
self.dialog = Some(Dialog::Reset);
|
||||
}
|
||||
Message::ResetCancelled => {
|
||||
self.dialog = None;
|
||||
}
|
||||
Message::ResetConfirmed => {
|
||||
self.dialog = None;
|
||||
self.factory_reset();
|
||||
}
|
||||
Message::RotateConfirmed => {
|
||||
self.dialog = None;
|
||||
return vec![PendingOp::Rotate];
|
||||
|
|
@ -1030,6 +1166,13 @@ impl Carrier {
|
|||
OpResult::Generic(Ok(note)) => {
|
||||
self.done();
|
||||
self.note(note);
|
||||
// The only `Generic` ops reachable from the signup page are
|
||||
// Register and Restore — LearnKey/Pin report through their
|
||||
// own variants — so a success here always means the
|
||||
// address is bound and it's safe to leave the page.
|
||||
if self.page == Page::Signup {
|
||||
self.page = Page::Mail;
|
||||
}
|
||||
self.refresh()
|
||||
}
|
||||
OpResult::Generic(Err(e)) => {
|
||||
|
|
@ -1049,6 +1192,7 @@ impl Carrier {
|
|||
}
|
||||
OpResult::PinOffered(Ok(None)) => {
|
||||
self.done();
|
||||
self.signup.key_trusted = true;
|
||||
self.note("This server's key is already pinned.".into());
|
||||
None
|
||||
}
|
||||
|
|
@ -1124,6 +1268,194 @@ mod tests {
|
|||
assert_eq!(app.busy.as_deref(), Some("fetching"));
|
||||
}
|
||||
|
||||
/// Off by default — a fetch deletes what it takes — and the Settings
|
||||
/// switch is a plain toggle, not busy-guarded like a network action.
|
||||
#[test]
|
||||
fn keep_mail_defaults_off_and_toggles() {
|
||||
let mut app = app();
|
||||
assert!(!app.keep_mail);
|
||||
app.handle(Message::KeepMailChanged(true));
|
||||
assert!(app.keep_mail);
|
||||
app.handle(Message::KeepMailChanged(false));
|
||||
assert!(!app.keep_mail);
|
||||
}
|
||||
|
||||
/// A factory reset asks first, then erases every file in the config
|
||||
/// directory and returns the model to onboarding, as if the app had
|
||||
/// never run.
|
||||
#[test]
|
||||
fn factory_reset_erases_the_files_and_returns_to_onboarding() {
|
||||
let dir = tempfile::tempdir().unwrap().keep();
|
||||
let (mut app, _) = Carrier::boot(dir.clone());
|
||||
let master = ops::new_master();
|
||||
ops::save_key(&dir.join("carrier.key"), &master).unwrap();
|
||||
app.master = Some(master);
|
||||
std::fs::write(dir.join("read.txt"), "ab").unwrap();
|
||||
std::fs::write(dir.join("drafts.txt"), "draft2 id 1 -\n\n\n\n").unwrap();
|
||||
app.signup.host = "typed-before-the-reset.example".into();
|
||||
app.signup.user = "alice".into();
|
||||
|
||||
// The button is busy-guarded like every other action.
|
||||
app.busy = Some("fetching".into());
|
||||
app.handle(Message::ResetPressed);
|
||||
assert!(app.dialog.is_none());
|
||||
|
||||
app.busy = None;
|
||||
app.handle(Message::ResetPressed);
|
||||
assert!(matches!(app.dialog, Some(Dialog::Reset)));
|
||||
|
||||
app.handle(Message::ResetConfirmed);
|
||||
assert!(app.dialog.is_none());
|
||||
assert_eq!(app.page, Page::Onboard);
|
||||
assert_eq!(app.onboard.mode, OnboardMode::Start);
|
||||
assert!(app.master.is_none());
|
||||
assert!(app.snapshot.is_none());
|
||||
assert!(app.drafts.is_empty());
|
||||
// The fields themselves, not just the model, must come back
|
||||
// clean — form_fresh tells the view to push the blank state
|
||||
// into the entries instead of leaving stale typed text on them.
|
||||
assert!(app.signup.host.is_empty());
|
||||
assert!(app.signup.user.is_empty());
|
||||
assert!(app.form_fresh.get());
|
||||
for name in ["carrier.key", "read.txt", "drafts.txt"] {
|
||||
assert!(!dir.join(name).exists(), "{name} survived the reset");
|
||||
}
|
||||
// The store is reopened by boot, so a fresh empty db is the one
|
||||
// file a reset legitimately leaves behind.
|
||||
assert!(dir.join("carrier.db").exists());
|
||||
assert!(app.store.account().unwrap().is_none());
|
||||
}
|
||||
|
||||
/// Backing out of a freshly created, not-yet-registered identity must
|
||||
/// discard it — otherwise a later Create can silently overwrite it out
|
||||
/// from under an in-flight or already-committed registration.
|
||||
#[test]
|
||||
fn onboard_back_from_created_discards_the_key() {
|
||||
let dir = tempfile::tempdir().unwrap().keep();
|
||||
let (mut app, _) = Carrier::boot(dir.clone());
|
||||
app.handle(Message::OnboardCreate);
|
||||
assert!(app.master.is_some());
|
||||
assert!(dir.join("carrier.key").exists());
|
||||
|
||||
app.handle(Message::OnboardBack);
|
||||
assert!(app.master.is_none());
|
||||
assert!(!dir.join("carrier.key").exists());
|
||||
assert_eq!(app.page, Page::Onboard);
|
||||
assert_eq!(app.onboard.mode, OnboardMode::Start);
|
||||
}
|
||||
|
||||
/// Restore mode writes no key until the seed is actually submitted, so
|
||||
/// backing out of it has nothing to discard — just the typed seed.
|
||||
#[test]
|
||||
fn onboard_back_from_restore_only_clears_the_typed_seed() {
|
||||
let mut app = app();
|
||||
app.handle(Message::OnboardRestore);
|
||||
app.handle(Message::OnboardSeedChanged("not yet submitted".into()));
|
||||
app.handle(Message::OnboardBack);
|
||||
assert!(app.master.is_none());
|
||||
assert!(app.onboard.seed.is_empty());
|
||||
assert_eq!(app.onboard.mode, OnboardMode::Start);
|
||||
}
|
||||
|
||||
/// Start Over on the signup page's server step is the same discard,
|
||||
/// reachable after onboarding has already handed off to Page::Signup.
|
||||
#[test]
|
||||
fn signup_start_over_discards_the_key() {
|
||||
let dir = tempfile::tempdir().unwrap().keep();
|
||||
let (mut app, _) = Carrier::boot(dir.clone());
|
||||
app.handle(Message::OnboardCreate);
|
||||
app.handle(Message::OnboardSeedSaved);
|
||||
assert_eq!(app.page, Page::Signup);
|
||||
app.signup.host = "example.org".into();
|
||||
|
||||
app.handle(Message::SignupStartOver);
|
||||
assert!(app.master.is_none());
|
||||
assert!(!dir.join("carrier.key").exists());
|
||||
assert_eq!(app.page, Page::Onboard);
|
||||
assert!(app.signup.host.is_empty());
|
||||
}
|
||||
|
||||
/// Register and Restore are long-running, so Back (and a second click)
|
||||
/// must be locked out for their whole in-flight window, the same way
|
||||
/// FetchPressed already locks out everything else.
|
||||
#[test]
|
||||
fn signup_register_and_recall_engage_the_busy_guard() {
|
||||
let mut app = app();
|
||||
app.master = Some([0u8; KEY_LEN]);
|
||||
app.page = Page::Signup;
|
||||
app.signup.user = "alice".into();
|
||||
app.signup.host = "example.org".into();
|
||||
|
||||
let ops = app.handle(Message::SignupRegister);
|
||||
assert_eq!(app.busy.as_deref(), Some("registering"));
|
||||
assert!(matches!(ops.as_slice(), [PendingOp::Register { .. }]));
|
||||
assert!(app.handle(Message::SignupStartOver).is_empty());
|
||||
assert_eq!(app.page, Page::Signup);
|
||||
|
||||
app.busy = None;
|
||||
let ops = app.handle(Message::SignupRecall);
|
||||
assert_eq!(app.busy.as_deref(), Some("restoring"));
|
||||
assert!(matches!(ops.as_slice(), [PendingOp::Restore { .. }]));
|
||||
}
|
||||
|
||||
/// A successful Register/Restore must leave the signup page, or a
|
||||
/// lingering Back there could still race a fresh identity into
|
||||
/// existence against an address the store already considers bound.
|
||||
#[test]
|
||||
fn generic_success_on_the_signup_page_moves_on_to_mail() {
|
||||
let mut app = app();
|
||||
app.page = Page::Signup;
|
||||
app.busy = Some("registering".into());
|
||||
app.handle_result(OpResult::Generic(Ok("registered".into())));
|
||||
assert_eq!(app.page, Page::Mail);
|
||||
}
|
||||
|
||||
/// The server step's one button fetches a key when none is on
|
||||
/// screen yet, then pins it and advances once one is.
|
||||
#[test]
|
||||
fn signup_key_action_fetches_then_pins_and_advances() {
|
||||
let mut app = app();
|
||||
app.signup.host = "example.org".into();
|
||||
|
||||
let ops = app.handle(Message::SignupKeyAction);
|
||||
assert!(matches!(ops.as_slice(), [PendingOp::LearnKey { .. }]));
|
||||
assert_eq!(app.signup.step, SignupStep::Server);
|
||||
|
||||
let addr = Address::parse("x@example.org").unwrap();
|
||||
app.pin_offer = Some((addr, [0u8; KEY_LEN]));
|
||||
let ops = app.handle(Message::SignupKeyAction);
|
||||
assert!(matches!(ops.as_slice(), [PendingOp::Pin { .. }]));
|
||||
assert!(app.pin_offer.is_none());
|
||||
assert_eq!(app.signup.step, SignupStep::Account);
|
||||
}
|
||||
|
||||
/// An already-trusted host (pinned in an earlier session) needs no
|
||||
/// key to show — Continue just advances with no background op.
|
||||
#[test]
|
||||
fn signup_key_action_advances_without_a_pin_when_already_trusted() {
|
||||
let mut app = app();
|
||||
app.signup.host = "example.org".into();
|
||||
app.signup.key_trusted = true;
|
||||
|
||||
let ops = app.handle(Message::SignupKeyAction);
|
||||
assert!(ops.is_empty());
|
||||
assert_eq!(app.signup.step, SignupStep::Account);
|
||||
}
|
||||
|
||||
/// Editing the host after a key was fetched (or already trusted)
|
||||
/// invalidates it: there is only ever one key pending trust.
|
||||
#[test]
|
||||
fn editing_the_host_resets_a_fetched_or_trusted_key() {
|
||||
let mut app = app();
|
||||
let addr = Address::parse("x@example.org").unwrap();
|
||||
app.pin_offer = Some((addr, [0u8; KEY_LEN]));
|
||||
app.signup.key_trusted = true;
|
||||
|
||||
app.handle(Message::SignupHostChanged("example.net".into()));
|
||||
assert!(app.pin_offer.is_none());
|
||||
assert!(!app.signup.key_trusted);
|
||||
}
|
||||
|
||||
fn opened() -> OpenedMsg {
|
||||
OpenedMsg {
|
||||
id: "ab".repeat(32),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue