use cosmic_pim_accounts::{Account, AccountStore, OAuthCredential, Provider, Registry};
use cosmic_pim_auth::{OnlineAccount, OnlineAccounts};
pub trait OnlineAccountSource {
fn list(&self) -> Result<Vec<OnlineAccount>, cosmic_pim_auth::Error>;
fn access_token(&self, id: &str) -> Result<OAuthCredential, cosmic_pim_auth::Error>;
}
impl OnlineAccountSource for OnlineAccounts {
fn list(&self) -> Result<Vec<OnlineAccount>, cosmic_pim_auth::Error> {
OnlineAccounts::list(self)
}
fn access_token(&self, id: &str) -> Result<OAuthCredential, cosmic_pim_auth::Error> {
OnlineAccounts::access_token(self, id)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum Route {
SignIn,
OnlineAccounts,
Password {
hint: Option<String>,
help_url: Option<String>,
},
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct Services {
pub mail: bool,
pub calendar: bool,
pub contacts: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct Plan {
pub address: String,
pub provider: Option<Provider>,
pub routes: Vec<Route>,
}
impl Plan {
#[must_use]
pub fn services(&self, route: &Route) -> Services {
let Some(provider) = &self.provider else {
return Services {
mail: true,
..Services::default()
};
};
let everything = Services {
mail: provider.services.mail.is_some(),
calendar: provider.services.calendar.is_some(),
contacts: provider.services.contacts.is_some(),
};
match route {
Route::SignIn | Route::OnlineAccounts => everything,
Route::Password { .. } if provider.oauth.is_some() => Services {
mail: everything.mail,
..Services::default()
},
Route::Password { .. } => everything,
}
}
}
#[must_use]
pub fn services_of(account: &Account, registry: &Registry) -> Services {
use crate::engine::{Service, service_url};
Services {
mail: account.mail.is_some(),
calendar: !service_url(account, registry, Service::Calendar).is_empty(),
contacts: !service_url(account, registry, Service::Contacts).is_empty(),
}
}
#[must_use]
pub fn plan(registry: &Registry, address: &str, online_accounts: bool) -> Option<Plan> {
let address = address.trim();
let (local, domain) = address.rsplit_once('@')?;
if local.is_empty() || !domain.contains('.') || domain.starts_with('.') || domain.ends_with('.')
{
return None;
}
let provider = registry.for_email(address).cloned();
let routes = match &provider {
None => vec![Route::Password {
hint: None,
help_url: None,
}],
Some(provider) => match &provider.oauth {
None => vec![Route::Password {
hint: provider.hint.clone(),
help_url: provider.help_url.clone(),
}],
Some(oauth) => {
let mut routes = Vec::new();
if oauth.is_configured() {
routes.push(Route::SignIn);
}
if online_accounts && cosmic_pim_auth::online_accounts::serves(&provider.id) {
routes.push(Route::OnlineAccounts);
}
if let Some(fallback) = &provider.app_password {
routes.push(Route::Password {
hint: Some(fallback.hint.clone()),
help_url: fallback.help_url.clone(),
});
}
routes
}
},
};
Some(Plan {
address: address.to_owned(),
provider,
routes,
})
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum SetupError {
#[error("the password was not accepted: {0}")]
Rejected(String),
#[error("the server could not be reached: {0}")]
Unreachable(String),
#[error("no mail server could be found for this address: {0}")]
NoServer(String),
#[error("this address cannot be added that way on this computer")]
NoRoute,
#[error(transparent)]
SignIn(#[from] cosmic_pim_auth::Error),
#[error(transparent)]
Store(#[from] cosmic_pim_accounts::Error),
#[error("{0}")]
Failed(String),
}
pub fn add_with_password(
store: &mut AccountStore,
registry: &Registry,
plan: &Plan,
name: &str,
password: &str,
) -> Result<String, SetupError> {
let mut account = password_account(plan)?;
name_account(&mut account, name);
verify(&account, registry, password)?;
let id = account.id.clone();
store.add(account, password)?;
Ok(id)
}
fn password_account(plan: &Plan) -> Result<Account, SetupError> {
if !plan
.routes
.iter()
.any(|route| matches!(route, Route::Password { .. }))
{
return Err(SetupError::NoRoute);
}
match &plan.provider {
Some(provider) if provider.oauth.is_some() => provider
.app_password_account(&plan.address)
.ok_or(SetupError::NoRoute),
Some(provider) => Ok(provider.account_for(&plan.address)),
None => discovered_account(&plan.address),
}
}
#[cfg(feature = "mail")]
fn discovered_account(address: &str) -> Result<Account, SetupError> {
use cosmic_pim_accounts::{MailEndpoint, Transport};
use cosmic_pim_mail::imap::Security;
let found = cosmic_pim_mail::discovery::discover(address)
.map_err(|why| SetupError::NoServer(why.to_string()))?;
let transport = |security: Security| match security {
Security::Tls => Transport::Tls,
Security::StartTls => Transport::StartTls,
Security::Plaintext => Transport::Plaintext,
};
let mut account = Account::new(address, "", address);
account.mail = Some(MailEndpoint {
imap_port: found.imap_port,
imap_transport: transport(found.imap_security),
imap_username: Some(found.username).filter(|login| !login.is_empty() && login != address),
smtp_host: found.smtp_host,
smtp_port: found.smtp_port,
smtp_transport: transport(found.smtp_security),
from_address: address.to_owned(),
..MailEndpoint::tls(found.imap_host)
});
Ok(account)
}
#[cfg(not(feature = "mail"))]
fn discovered_account(_address: &str) -> Result<Account, SetupError> {
Err(SetupError::NoServer(
"this application finds calendar and contacts servers only when they are named".to_owned(),
))
}
fn verify(account: &Account, registry: &Registry, password: &str) -> Result<(), SetupError> {
if account.mail.is_some() {
return verify_mail(account, registry, password);
}
verify_dav(account, registry, password)
}
#[cfg(feature = "mail")]
fn verify_mail(account: &Account, _registry: &Registry, password: &str) -> Result<(), SetupError> {
use cosmic_pim_accounts::{MailProtocol, Transport};
use cosmic_pim_mail::imap::Security;
use cosmic_pim_mail::{Credentials, imap, jmap, pop3};
let Some(mail) = account.mail.as_ref() else {
return Ok(());
};
let credentials = Credentials::Password(password.to_owned());
let security = |transport: Transport| match transport {
Transport::Tls => Security::Tls,
Transport::StartTls => Security::StartTls,
Transport::Plaintext => Security::Plaintext,
};
let login = account.mail_username().to_owned();
let outcome = match mail.protocol {
MailProtocol::Imap => imap::Session::connect(
&imap::Endpoint {
host: mail.imap_host.clone(),
port: mail.imap_port,
security: security(mail.imap_transport),
username: login,
},
&credentials,
)
.map(drop),
MailProtocol::Jmap => {
let Some(url) = mail.jmap_session_url.as_deref() else {
return Err(SetupError::NoServer("no JMAP session address".to_owned()));
};
jmap::Session::connect(url, &login, &credentials).map(drop)
}
MailProtocol::Pop3 => pop3::Session::connect(
&pop3::Endpoint {
host: mail.pop3_host.clone(),
port: mail.pop3_port,
security: security(mail.pop3_transport),
username: login,
},
&credentials,
)
.map(drop),
MailProtocol::Gmail | MailProtocol::Graph => return Err(SetupError::NoRoute),
};
outcome.map_err(|why| match why {
cosmic_pim_mail::Error::Auth(reason) => SetupError::Rejected(reason),
other @ (cosmic_pim_mail::Error::Io(_) | cosmic_pim_mail::Error::Transport { .. }) => {
SetupError::Unreachable(other.to_string())
}
other if other.is_transient() => SetupError::Unreachable(other.to_string()),
other => SetupError::Failed(other.to_string()),
})
}
#[cfg(not(feature = "mail"))]
fn verify_mail(account: &Account, registry: &Registry, password: &str) -> Result<(), SetupError> {
verify_dav(account, registry, password)
}
fn verify_dav(account: &Account, registry: &Registry, password: &str) -> Result<(), SetupError> {
use cosmic_pim_caldav::{Auth, CaldavClient, Disposition, Flavor};
let url = crate::engine::service_url(account, registry, crate::engine::Service::Calendar);
if url.is_empty() {
return Ok(());
}
let auth = Auth::Basic {
username: account.username.clone(),
password: password.to_owned(),
};
CaldavClient::with_auth(&url, Flavor::CalDav, &auth)
.discover()
.map_err(|why| match why.disposition() {
Disposition::NeedsUser => SetupError::Rejected(why.to_string()),
Disposition::Retry => SetupError::Unreachable(why.to_string()),
Disposition::Reconcile | Disposition::Fatal => SetupError::Failed(why.to_string()),
})
}
fn name_account(account: &mut Account, name: &str) {
let name = name.trim();
if !name.is_empty() {
account.display_name = name.to_owned();
}
if let Some(mail) = account.mail.as_mut() {
mail.from_name = name.to_owned();
}
}
pub fn sign_in(
store: &mut AccountStore,
plan: &Plan,
open: impl FnOnce(&str) -> std::io::Result<()>,
) -> Result<String, SetupError> {
let provider = plan.provider.as_ref().ok_or(SetupError::NoRoute)?;
let oauth = provider
.oauth
.as_ref()
.filter(|oauth| oauth.is_configured())
.ok_or(SetupError::NoRoute)?;
let pending = cosmic_pim_auth::begin(oauth)?;
open(pending.authorize_url())
.map_err(|why| SetupError::Failed(format!("the browser could not be opened: {why}")))?;
let code = pending.wait()?;
let grant = pending.exchange(&code, oauth)?;
let (address, name) = grant.identity.as_ref().map_or_else(
|| (plan.address.clone(), None),
|identity| (identity.email.clone(), identity.name.clone()),
);
let mut account = provider.account_for(&address);
if let Some(name) = name {
name_account(&mut account, &name);
}
let id = account.id.clone();
store.add_oauth(account, &provider.id, &grant.credential)?;
Ok(id)
}
pub fn link_online_account(
store: &mut AccountStore,
registry: &Registry,
service: &impl OnlineAccountSource,
online: &OnlineAccount,
) -> Result<String, SetupError> {
let provider = online
.provider_id()
.and_then(|id| registry.get(id))
.ok_or(SetupError::NoRoute)?;
let address = online.address().ok_or_else(|| {
SetupError::Failed(format!(
"Online Accounts names this account “{}”, which is not a mail address",
online.identity.escape_debug()
))
})?;
let mut account =
Account::for_online_account(&provider.name, address, &provider.id, &online.id).ok_or_else(
|| {
SetupError::Failed(format!(
"Online Accounts gave this account an id that cannot be used: “{}”",
online.id.escape_debug()
))
},
)?;
if let Some(existing) = store.get(&account.id) {
return Ok(existing.id.clone());
}
account.mail = provider
.services
.mail
.as_ref()
.filter(|_| !online.mail_disabled)
.map(|mail| mail.endpoint_for(address));
let credential = service.access_token(&online.id)?;
let id = account.id.clone();
store.add_oauth(account, &provider.id, &credential)?;
Ok(id)
}
#[derive(Debug, Default)]
#[non_exhaustive]
pub struct Adopted {
pub linked: Vec<String>,
pub removed: Vec<String>,
pub failed: Vec<(String, String)>,
}
pub fn adopt_online_accounts(
store: &mut AccountStore,
registry: &Registry,
service: &impl OnlineAccountSource,
) -> Result<Adopted, SetupError> {
let online = service.list()?;
let mut adopted = Adopted::default();
for account in &online {
if account.provider_id().is_none() {
continue;
}
let already = store
.accounts()
.iter()
.any(|known| known.online_account() == Some(account.id.as_str()));
if already {
continue;
}
match link_online_account(store, registry, service, account) {
Ok(id) => adopted.linked.push(id),
Err(why) => adopted.failed.push((account.id.clone(), why.to_string())),
}
}
let gone: Vec<String> = store
.accounts()
.iter()
.filter(|known| {
known
.online_account()
.is_some_and(|id| !online.iter().any(|account| account.id == id))
})
.map(|known| known.id.clone())
.collect();
for id in gone {
store.remove(&id)?;
adopted.removed.push(id);
}
Ok(adopted)
}
#[cfg(test)]
mod tests {
use std::io::{BufRead as _, BufReader, Write as _};
use std::net::{TcpListener, TcpStream};
use std::path::Path;
use cosmic_pim_accounts::{MailProtocol, SecretStore};
use super::*;
fn store_at(dir: &Path) -> AccountStore {
let secrets = SecretStore::open_envelope_only("cosmic-pim-test", dir);
AccountStore::open(&dir.join("accounts.toml"), secrets).unwrap()
}
fn built_in() -> Registry {
Registry::load_from(Path::new("/nonexistent"))
}
fn routes(plan: &Plan) -> Vec<&'static str> {
plan.routes
.iter()
.map(|route| match route {
Route::SignIn => "sign-in",
Route::OnlineAccounts => "online-accounts",
Route::Password { .. } => "password",
})
.collect()
}
#[test]
fn a_stored_account_says_what_it_brings() {
let registry = built_in();
let fastmail = registry
.get("fastmail")
.unwrap()
.account_for("ada@fastmail.com");
let gmail_by_password = registry
.get("google")
.unwrap()
.app_password_account("ada@gmail.com")
.unwrap();
assert_eq!(
services_of(&fastmail, ®istry),
Services {
mail: true,
calendar: true,
contacts: true
}
);
assert_eq!(
services_of(&gmail_by_password, ®istry),
Services {
mail: true,
..Services::default()
}
);
}
#[test]
fn text_that_is_not_yet_an_address_has_no_plan() {
for text in ["", "ada", "ada@", "@example.com", "ada@example", "ada@.com"] {
assert!(plan(&built_in(), text, true).is_none(), "{text:?}");
}
}
#[test]
fn a_password_provider_asks_for_its_own_kind_of_password() {
let plan = plan(&built_in(), " ada@fastmail.com ", false).unwrap();
assert_eq!(plan.address, "ada@fastmail.com");
let [Route::Password { hint, help_url }] = plan.routes.as_slice() else {
panic!("expected a password, got {:?}", plan.routes);
};
assert!(
hint.as_deref()
.is_some_and(|hint| hint.contains("app password"))
);
assert!(help_url.is_some());
assert_eq!(
plan.services(&plan.routes[0]),
Services {
mail: true,
calendar: true,
contacts: true
}
);
}
#[test]
fn gmail_with_no_client_id_offers_what_does_work_best_first() {
let without = plan(&built_in(), "ada@gmail.com", false).unwrap();
assert_eq!(routes(&without), ["password"]);
assert_eq!(
without.services(&without.routes[0]),
Services {
mail: true,
..Services::default()
},
"an app password reaches mail only, and the window has to say so"
);
let with_goa = plan(&built_in(), "ada@gmail.com", true).unwrap();
assert_eq!(routes(&with_goa), ["online-accounts", "password"]);
assert!(with_goa.services(&with_goa.routes[0]).calendar);
}
#[test]
fn a_configured_client_id_puts_the_providers_own_sign_in_first() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("google.toml"),
"id = \"google\"\n[oauth]\nclient_id = \"magnetar.apps.googleusercontent.com\"\n",
)
.unwrap();
let plan = plan(&Registry::load_from(dir.path()), "ada@gmail.com", true).unwrap();
assert_eq!(routes(&plan), ["sign-in", "online-accounts", "password"]);
}
#[test]
fn outlook_with_neither_a_client_id_nor_online_accounts_has_no_way_in() {
let alone = plan(&built_in(), "ada@outlook.com", false).unwrap();
assert_eq!(alone.routes, []);
let with_goa = plan(&built_in(), "ada@outlook.com", true).unwrap();
assert_eq!(routes(&with_goa), ["online-accounts"]);
}
#[test]
fn an_address_no_provider_claims_is_a_password_account_for_mail() {
let plan = plan(&built_in(), "ada@uni.example", true).unwrap();
assert!(plan.provider.is_none());
assert_eq!(routes(&plan), ["password"]);
let services = plan.services(&plan.routes[0]);
assert!(services.mail && !services.calendar && !services.contacts);
}
fn imap_server(password: &'static str) -> u16 {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
std::thread::spawn(move || {
let Ok((stream, _)) = listener.accept() else {
return;
};
let mut out = stream.try_clone().unwrap();
let mut reader = BufReader::new(stream);
let _ = write!(out, "* OK ready\r\n");
let mut line = String::new();
while reader.read_line(&mut line).is_ok_and(|n| n > 0) {
let command = line.trim_end().to_owned();
line.clear();
let (tag, rest) = command.split_once(' ').unwrap_or((&command, ""));
let upper = rest.to_ascii_uppercase();
let _ = if upper.starts_with("LOGIN") {
if rest.ends_with(&format!(" \"{password}\""))
|| rest.ends_with(&format!(" {password}"))
{
write!(out, "{tag} OK logged in\r\n")
} else {
write!(
out,
"{tag} NO [AUTHENTICATIONFAILED] Invalid credentials\r\n"
)
}
} else if upper.starts_with("CAPABILITY") {
write!(out, "* CAPABILITY IMAP4rev1\r\n{tag} OK done\r\n")
} else if upper.starts_with("LOGOUT") {
let _ = write!(out, "* BYE\r\n{tag} OK done\r\n");
break;
} else {
write!(out, "{tag} OK done\r\n")
};
}
});
port
}
fn local_provider(dir: &Path, port: u16) -> Registry {
std::fs::write(
dir.join("local.toml"),
format!(
"id = \"local\"\nname = \"Local Mail\"\nhint = \"Use the password the server gave you.\"\n\
domains = [\"example.test\"]\n\n[services.mail]\nprotocol = \"imap\"\n\
imap_host = \"127.0.0.1\"\nimap_port = {port}\nimap_transport = \"plaintext\"\n\
smtp_host = \"127.0.0.1\"\nsmtp_port = 1\nsmtp_transport = \"plaintext\"\n"
),
)
.unwrap();
Registry::load_from(dir)
}
#[test]
fn a_password_the_server_accepts_is_stored_with_the_whole_account() {
let dir = tempfile::tempdir().unwrap();
let registry = local_provider(dir.path(), imap_server("right"));
let mut store = store_at(dir.path());
let plan = plan(®istry, "ada@example.test", false).unwrap();
let id = add_with_password(&mut store, ®istry, &plan, "Ada Lovelace", "right")
.expect("the password was right");
let account = store.get(&id).expect("stored");
assert_eq!(account.display_name, "Ada Lovelace");
assert_eq!(
account.provider.as_deref(),
Some("local"),
"without the provider, Slate and Circle cannot find its calendars"
);
let mail = account.mail.as_ref().expect("mail endpoints");
assert_eq!(mail.imap_host, "127.0.0.1");
assert_eq!(mail.from_name, "Ada Lovelace");
assert_eq!(store.password(&id).unwrap().as_deref(), Some("right"));
}
#[test]
fn a_password_the_server_refuses_is_not_stored() {
let dir = tempfile::tempdir().unwrap();
let registry = local_provider(dir.path(), imap_server("right"));
let mut store = store_at(dir.path());
let plan = plan(®istry, "ada@example.test", false).unwrap();
let error = add_with_password(&mut store, ®istry, &plan, "", "wrong").unwrap_err();
assert!(matches!(error, SetupError::Rejected(_)), "got {error}");
assert_eq!(store.accounts(), []);
}
#[test]
fn a_server_that_is_not_listening_is_unreachable_not_a_wrong_password() {
let port = TcpListener::bind("127.0.0.1:0")
.unwrap()
.local_addr()
.unwrap()
.port();
let dir = tempfile::tempdir().unwrap();
let registry = local_provider(dir.path(), port);
let mut store = store_at(dir.path());
let plan = plan(®istry, "ada@example.test", false).unwrap();
let error = add_with_password(&mut store, ®istry, &plan, "", "right").unwrap_err();
assert!(matches!(error, SetupError::Unreachable(_)), "got {error}");
assert_eq!(store.accounts(), []);
}
#[test]
fn a_password_cannot_add_an_account_that_has_no_password_route() {
let dir = tempfile::tempdir().unwrap();
let mut store = store_at(dir.path());
let plan = plan(&built_in(), "ada@outlook.com", false).unwrap();
let error = add_with_password(&mut store, &built_in(), &plan, "", "x").unwrap_err();
assert!(matches!(error, SetupError::NoRoute));
}
#[test]
fn a_gmail_app_password_makes_an_imap_account_with_no_provider() {
let plan = plan(&built_in(), "ada@gmail.com", false).unwrap();
let account = password_account(&plan).unwrap();
assert_eq!(account.provider, None);
let mail = account.mail.unwrap();
assert_eq!(mail.protocol, MailProtocol::Imap);
assert_eq!(mail.imap_host, "imap.gmail.com");
}
fn browser(url: &str) -> std::io::Result<()> {
let port: u16 = url
.split("redirect_uri=http%3A%2F%2F127.0.0.1%3A")
.nth(1)
.and_then(|rest| rest.split("%2F").next())
.and_then(|port| port.parse().ok())
.expect("a loopback redirect in the authorize URL");
let state = url
.split(['?', '&'])
.find_map(|pair| pair.strip_prefix("state="))
.expect("a state in the authorize URL")
.to_owned();
std::thread::spawn(move || {
let mut stream = TcpStream::connect(("127.0.0.1", port)).unwrap();
let _ = write!(
stream,
"GET /callback?code=the-code&state={state} HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n"
);
let _ = std::io::Read::read_to_end(&mut stream, &mut Vec::new());
});
Ok(())
}
fn token_endpoint() -> String {
use base64::Engine as _;
let server = tiny_http::Server::http("127.0.0.1:0").unwrap();
let port = server.server_addr().to_ip().unwrap().port();
std::thread::spawn(move || {
if let Ok(request) = server.recv() {
let header = "Content-Type: application/json"
.parse::<tiny_http::Header>()
.unwrap();
let claims = format!(
r#"{{"aud":"test","exp":{},"email":"grace@gmail.com","name":"Grace Hopper"}}"#,
chrono::Utc::now().timestamp() + 3600
);
let body = format!(
r#"{{"access_token":"at","refresh_token":"rt","expires_in":3600,"id_token":"e30.{}.sig"}}"#,
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(claims)
);
let _ = request.respond(tiny_http::Response::from_string(body).with_header(header));
}
});
format!("http://127.0.0.1:{port}/token")
}
#[test]
fn a_sign_in_names_the_account_after_whoever_signed_in() {
let dir = tempfile::tempdir().unwrap();
let redirect_port = TcpListener::bind("127.0.0.1:0")
.unwrap()
.local_addr()
.unwrap()
.port();
std::fs::write(
dir.path().join("google.toml"),
format!(
"id = \"google\"\n[oauth]\nclient_id = \"test\"\ntoken_url = \"{}\"\n\
redirect_port = {redirect_port}\n",
token_endpoint()
),
)
.unwrap();
let registry = Registry::load_from(dir.path());
let mut store = store_at(dir.path());
let plan = plan(®istry, "ada@gmail.com", false).unwrap();
let id = sign_in(&mut store, &plan, browser).expect("signed in");
let account = store.get(&id).unwrap();
assert_eq!(account.username, "grace@gmail.com");
assert_eq!(account.display_name, "Grace Hopper");
assert_eq!(account.provider.as_deref(), Some("google"));
assert!(account.is_oauth());
assert_eq!(
account.mail.as_ref().unwrap().protocol,
MailProtocol::Gmail,
"a signed-in Google account uses the Gmail engine"
);
assert!(store.credential(&id).unwrap().unwrap().is_renewable());
}
struct FakeSource {
accounts: Vec<OnlineAccount>,
listing_fails: bool,
refuses: Vec<&'static str>,
}
impl FakeSource {
fn with(accounts: &[(&str, &str, &str)]) -> Self {
Self {
accounts: accounts
.iter()
.map(|(id, kind, identity)| OnlineAccount::new(id, kind, identity))
.collect(),
listing_fails: false,
refuses: Vec::new(),
}
}
}
impl OnlineAccountSource for FakeSource {
fn list(&self) -> Result<Vec<OnlineAccount>, cosmic_pim_auth::Error> {
if self.listing_fails {
return Err(cosmic_pim_auth::Error::Transport(
"the daemon went away".into(),
));
}
Ok(self.accounts.clone())
}
fn access_token(&self, id: &str) -> Result<OAuthCredential, cosmic_pim_auth::Error> {
if self.refuses.contains(&id) {
return Err(cosmic_pim_auth::Error::GrantRejected("expired".into()));
}
Ok(OAuthCredential {
access_token: format!("token-{id}"),
refresh_token: None,
expires_at: None,
scopes: Vec::new(),
token_type: "Bearer".into(),
})
}
}
#[test]
fn online_accounts_are_linked_once_each() {
let dir = tempfile::tempdir().unwrap();
let mut store = store_at(dir.path());
let source = FakeSource::with(&[
("account_1", "google", "ada@gmail.com"),
("account_2", "ms_graph", "ada@outlook.com"),
("account_3", "kerberos", "ada@EXAMPLE.COM"),
]);
let first = adopt_online_accounts(&mut store, &built_in(), &source).unwrap();
let again = adopt_online_accounts(&mut store, &built_in(), &source).unwrap();
assert_eq!(first.linked.len(), 2);
assert!(again.linked.is_empty(), "a second pass linked a duplicate");
assert_eq!(store.accounts().len(), 2);
let outlook = store
.accounts()
.iter()
.find(|account| account.online_account() == Some("account_2"))
.unwrap();
assert_eq!(outlook.provider.as_deref(), Some("microsoft"));
assert_eq!(outlook.username, "ada@outlook.com");
assert_eq!(
outlook.mail.as_ref().unwrap().protocol,
MailProtocol::Graph,
"Microsoft's engine is Graph, whoever holds the grant"
);
}
#[test]
fn an_account_removed_from_online_accounts_is_removed_here_and_nothing_else_is() {
let dir = tempfile::tempdir().unwrap();
let mut store = store_at(dir.path());
let mine = Account::new("Fastmail", "", "ada@fastmail.com");
let mine_id = mine.id.clone();
store.add(mine, "app-password").unwrap();
adopt_online_accounts(
&mut store,
&built_in(),
&FakeSource::with(&[
("account_1", "google", "ada@gmail.com"),
("account_2", "google", "grace@gmail.com"),
]),
)
.unwrap();
let after = adopt_online_accounts(
&mut store,
&built_in(),
&FakeSource::with(&[("account_2", "google", "grace@gmail.com")]),
)
.unwrap();
assert_eq!(after.removed, ["goa-account_1"]);
let left: Vec<&str> = store.accounts().iter().map(|a| a.id.as_str()).collect();
assert!(
left.contains(&mine_id.as_str()),
"an account of its own was removed"
);
assert!(left.contains(&"goa-account_2"));
assert_eq!(left.len(), 2);
}
#[test]
fn a_failed_listing_removes_nothing() {
let dir = tempfile::tempdir().unwrap();
let mut store = store_at(dir.path());
adopt_online_accounts(
&mut store,
&built_in(),
&FakeSource::with(&[("account_1", "google", "ada@gmail.com")]),
)
.unwrap();
let failing = FakeSource {
listing_fails: true,
..FakeSource::with(&[])
};
assert!(adopt_online_accounts(&mut store, &built_in(), &failing).is_err());
assert_eq!(store.accounts().len(), 1);
}
#[test]
fn an_online_account_with_an_id_that_names_a_path_is_not_linked() {
let dir = tempfile::tempdir().unwrap();
let mut store = store_at(dir.path());
let source = FakeSource::with(&[
("../../etc/cron.d/x", "google", "ada@gmail.com"),
("account/1", "google", "grace@gmail.com"),
("", "google", "alan@gmail.com"),
]);
let adopted = adopt_online_accounts(&mut store, &built_in(), &source).unwrap();
assert_eq!(adopted.failed.len(), 3, "{adopted:?}");
assert_eq!(adopted.linked, [] as [String; 0]);
assert_eq!(store.accounts(), []);
}
#[test]
fn an_online_account_whose_identity_is_not_an_address_is_not_linked() {
let dir = tempfile::tempdir().unwrap();
let mut store = store_at(dir.path());
let source = FakeSource::with(&[
("account_1", "google", "Ada Lovelace"),
(
"account_2",
"google",
"ada@gmail.com\r\nBcc: eve@example.com",
),
]);
let adopted = adopt_online_accounts(&mut store, &built_in(), &source).unwrap();
assert_eq!(adopted.failed.len(), 2, "{adopted:?}");
assert_eq!(store.accounts(), []);
}
#[test]
fn an_online_account_that_cannot_sign_in_is_reported_not_stored() {
let dir = tempfile::tempdir().unwrap();
let mut store = store_at(dir.path());
let source = FakeSource {
refuses: vec!["account_1"],
..FakeSource::with(&[("account_1", "google", "ada@gmail.com")])
};
let adopted = adopt_online_accounts(&mut store, &built_in(), &source).unwrap();
assert_eq!(adopted.failed.len(), 1);
assert_eq!(store.accounts(), []);
}
}