use crate::provider::{Provider, UserMap};
use rustlavel_auth::Authenticatable;
use rustlavel_core::Json;
#[derive(Debug, Clone, PartialEq)]
pub struct SocialUser {
pub provider: String,
pub id: String,
pub name: Option<String>,
pub email: Option<String>,
pub avatar: Option<String>,
pub raw: Json,
}
impl SocialUser {
pub fn from_json(provider: &str, map: &UserMap, raw: &Json) -> Option<SocialUser> {
let id = first(raw, &map.id)?;
let avatar = match (&map.avatar_template, first(raw, &map.avatar)) {
(Some(template), Some(value)) => {
Some(template.replace("{id}", &id).replace("{avatar}", &value))
}
(None, avatar) => avatar,
(Some(_), None) => None,
};
Some(SocialUser {
provider: provider.to_string(),
id,
name: first(raw, &map.name),
email: first(raw, &map.email),
avatar,
raw: raw.clone(),
})
}
pub fn from_provider(provider: &Provider, raw: &Json) -> Option<SocialUser> {
SocialUser::from_json(&provider.name, &provider.map, raw)
}
pub fn qualified_id(&self) -> String {
format!("{}:{}", self.provider, self.id)
}
}
impl Authenticatable for SocialUser {
fn auth_identifier(&self) -> String {
self.qualified_id()
}
}
fn first(raw: &Json, paths: &[String]) -> Option<String> {
paths.iter().find_map(|path| raw.get(path).and_then(as_text))
}
fn as_text(value: &Json) -> Option<String> {
match value {
Json::String(text) if !text.is_empty() => Some(text.clone()),
Json::Number(number) if number.fract() == 0.0 && number.abs() < 9e15 => {
Some(format!("{}", *number as i64))
}
Json::Number(number) => Some(number.to_string()),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn user(provider: Provider, body: &str) -> SocialUser {
SocialUser::from_provider(&provider, &Json::parse(body).unwrap())
.expect("the fixture has an id")
}
#[test]
fn a_numeric_id_becomes_the_integer_and_not_a_float() {
let mapped = user(Provider::github(), r#"{"id":12345,"login":"ada"}"#);
assert_eq!(mapped.id, "12345");
assert_eq!(mapped.qualified_id(), "github:12345");
}
#[test]
fn github_falls_back_to_the_handle_when_there_is_no_real_name() {
let named = user(Provider::github(), r#"{"id":1,"name":"Ada Lovelace","login":"ada"}"#);
assert_eq!(named.name.as_deref(), Some("Ada Lovelace"));
let anonymous = user(Provider::github(), r#"{"id":1,"name":null,"login":"ada"}"#);
assert_eq!(anonymous.name.as_deref(), Some("ada"));
}
#[test]
fn a_null_email_is_an_absence_rather_than_the_string_null() {
let mapped = user(Provider::github(), r#"{"id":1,"login":"ada","email":null}"#);
assert_eq!(mapped.email, None);
}
#[test]
fn discords_avatar_hash_is_assembled_into_a_url() {
let mapped = user(
Provider::discord(),
r#"{"id":"80351110","username":"nelly","global_name":"Nelly","avatar":"8342729096"}"#,
);
assert_eq!(mapped.name.as_deref(), Some("Nelly"));
assert_eq!(
mapped.avatar.as_deref(),
Some("https://cdn.discordapp.com/avatars/80351110/8342729096.png")
);
}
#[test]
fn a_discord_user_with_no_avatar_gets_none_rather_than_a_broken_link() {
let mapped = user(Provider::discord(), r#"{"id":"1","username":"nelly","avatar":null}"#);
assert_eq!(mapped.avatar, None);
assert_eq!(mapped.name.as_deref(), Some("nelly"), "no global_name, so the username");
}
#[test]
fn microsoft_falls_back_to_the_principal_name_when_there_is_no_mailbox() {
let with_mail = user(
Provider::microsoft(),
r#"{"id":"aad-1","displayName":"Ada","mail":"ada@corp.test",
"userPrincipalName":"ada@corp.onmicrosoft.test"}"#,
);
assert_eq!(with_mail.email.as_deref(), Some("ada@corp.test"));
let without = user(
Provider::microsoft(),
r#"{"id":"aad-1","displayName":"Ada","mail":null,
"userPrincipalName":"ada@corp.onmicrosoft.test"}"#,
);
assert_eq!(without.email.as_deref(), Some("ada@corp.onmicrosoft.test"));
assert_eq!(without.avatar, None, "Graph has no photo URL to map");
}
#[test]
fn google_reads_the_openid_connect_names() {
let mapped = user(
Provider::google(),
r#"{"sub":"1078","name":"Ada","email":"ada@x.test","picture":"https://x.test/a.png"}"#,
);
assert_eq!(mapped.id, "1078");
assert_eq!(mapped.avatar.as_deref(), Some("https://x.test/a.png"));
assert_eq!(mapped.provider, "google");
}
#[test]
fn gitlab_reads_its_own_names() {
let mapped = user(
Provider::gitlab(),
r#"{"id":42,"username":"ada","name":"Ada","avatar_url":"https://gl.test/a.png"}"#,
);
assert_eq!(mapped.qualified_id(), "gitlab:42");
assert_eq!(mapped.avatar.as_deref(), Some("https://gl.test/a.png"));
}
#[test]
fn the_same_id_at_two_providers_is_two_different_people() {
let github = user(Provider::github(), r#"{"id":1,"login":"ada"}"#);
let gitlab = user(Provider::gitlab(), r#"{"id":1,"username":"mallory"}"#);
assert_eq!(github.id, gitlab.id);
assert_ne!(github.auth_identifier(), gitlab.auth_identifier());
}
#[test]
fn a_response_with_no_id_is_refused_rather_than_half_mapped() {
assert!(
SocialUser::from_provider(&Provider::github(), &Json::parse(r#"{"login":"ada"}"#).unwrap())
.is_none()
);
}
#[test]
fn the_whole_response_is_kept_for_fields_this_crate_does_not_know() {
let mapped = user(Provider::github(), r#"{"id":1,"login":"ada","company":"Analytical"}"#);
assert_eq!(mapped.raw.get("company").and_then(Json::as_str), Some("Analytical"));
}
}