//! Owner-controlled explicit/all-user shares. Recipients never receive secrets. use crate::{ApiError, AppState, auth::AuthUser}; use axum::{ Extension, Json, Router, extract::{Path, Query, State}, http::{HeaderMap, StatusCode}, response::{IntoResponse, Response}, routing::{get, patch, post}, }; use serde_json::{Value, json}; use sqlx::{Row, SqliteConnection, SqlitePool}; use std::collections::{HashMap, HashSet}; type ResultResponse = Result; pub fn routes() -> Router { Router::new() .route( "/twofaccounts/{id}/shares", get(index).post(store).delete(destroy_all), ) .route("/twofaccounts/{id}/shares/all", post(share_all)) .route( "/twofaccounts/{id}/shares/{user}", axum::routing::delete(destroy), ) .route("/twofaccounts/{id}/recipients", get(recipients)) .route("/twofaccounts/{id}/owner", patch(transfer)) } pub async fn flags(db: &SqlitePool) -> Result<(bool, bool), ApiError> { let settings = crate::settings::all(db).await?; Ok(( crate::settings::php_bool(&settings["enableSharing"]), crate::settings::php_bool(&settings["enableAllUsersSharingScope"]), )) } /// Shared visibility and OTP permission; secret reads and mutations remain owner-only. pub async fn can_access(db: &SqlitePool, user: i64, account: i64) -> Result { let (enabled, all) = flags(db).await?; Ok(sqlx::query_scalar::<_, bool>("SELECT EXISTS(SELECT 1 FROM twofaccounts a WHERE a.id=? AND (a.user_id=? OR (? AND EXISTS(SELECT 1 FROM twofaccount_shares s WHERE s.twofaccount_id=a.id AND ((s.scope='user' AND s.shared_with_user_id=?) OR (? AND s.scope='all_users'))))))") .bind(account).bind(user).bind(enabled).bind(user).bind(all).fetch_one(db).await?) } async fn authorize(s: &AppState, user: i64, account: i64) -> Result { let (enabled, all) = flags(&s.db).await?; if !enabled { return Err(ApiError( StatusCode::FORBIDDEN, "Sharing feature is disabled", )); } let owner: Option = sqlx::query_scalar("SELECT user_id FROM twofaccounts WHERE id=?") .bind(account) .fetch_optional(&s.db) .await?; match owner { None => Err(ApiError(StatusCode::NOT_FOUND, "not found")), Some(owner) if owner != user => Err(ApiError( StatusCode::FORBIDDEN, "This action is unauthorized.", )), _ => Ok(all), } } async fn verify_owner( conn: &mut SqliteConnection, user: i64, account: i64, ) -> Result<(), ApiError> { let valid: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM twofaccounts WHERE id=? AND user_id=?)") .bind(account) .bind(user) .fetch_one(conn) .await?; if !valid { return Err(ApiError( StatusCode::FORBIDDEN, "This action is unauthorized.", )); } Ok(()) } async fn is_all(conn: &mut SqliteConnection, account: i64) -> Result { Ok(sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM twofaccount_shares WHERE twofaccount_id=? AND scope='all_users')").bind(account).fetch_one(conn).await?) } fn conflict() -> Response { ( StatusCode::CONFLICT, Json( json!({"message":"conflict","reason":"This account is already shared with all users."}), ), ) .into_response() } fn invalid(field: &str) -> Response { ( StatusCode::UNPROCESSABLE_ENTITY, Json(json!({"message":"bad request","reason":{field:["The given data was invalid."]}})), ) .into_response() } fn integer(v: &Value) -> Option { v.as_i64().or_else(|| v.as_str()?.parse().ok()) } async fn recipient_values( db: &SqlitePool, account: i64, viewer: i64, ids: Option<&[i64]>, exclude: Option, filter: Option<&str>, ) -> Result, ApiError> { let raw: Option = sqlx::query_scalar("SELECT preferences FROM users WHERE id=?") .bind(viewer) .fetch_optional(db) .await? .flatten(); let preferences = crate::settings::preferences_from(raw.as_deref()); let timezone = preferences["timezone"] .as_str() .and_then(|tz| tz.parse::().ok()) .unwrap_or(chrono_tz::UTC); let rows = sqlx::query("SELECT u.id,u.name,u.email,(SELECT created_at FROM twofaccount_shares s WHERE s.twofaccount_id=? AND s.shared_with_user_id=u.id AND s.scope='user' LIMIT 1) AS shared_since,EXISTS(SELECT 1 FROM twofaccount_shares s WHERE s.twofaccount_id=? AND s.shared_with_user_id=u.id AND s.scope='user') AS shared FROM users u ORDER BY u.id").bind(account).bind(account).fetch_all(db).await?; let mut result = vec![]; for row in rows { let id: i64 = row.try_get("id")?; if exclude == Some(id) || ids.is_some_and(|ids| !ids.contains(&id)) { continue; } let name: String = row.try_get("name")?; if let Some(filter) = filter { let email: String = row.try_get("email")?; if filter.is_empty() || (if filter.contains('@') { email != filter } else { !name.to_lowercase().contains(&filter.to_lowercase()) }) { continue; } } let shared: bool = row.try_get("shared")?; let mut value = json!({"id":id,"name":name,"is_shared_with":shared}); if shared { let timestamp: Option = row.try_get("shared_since")?; value["is_shared_since"] = json!(timestamp.map(|t| { chrono::NaiveDateTime::parse_from_str(&t, "%Y-%m-%d %H:%M:%S") .map(|d| { d.and_utc() .with_timezone(&timezone) .format("%a, %b %-d, %Y %-I:%M %p") .to_string() }) .unwrap_or(t) })); } result.push(value); } Ok(result) } async fn index( State(s): State, Extension(u): Extension, Path(id): Path, ) -> ResultResponse { let all = authorize(&s, u.id, id).await?; let shared_all = if all { let mut conn = s.db.acquire().await?; is_all(&mut conn, id).await? } else { false }; let mut value = json!({"twofaccount_id":id,"is_shared_with_all":shared_all}); if !shared_all { let ids: Vec = sqlx::query_scalar("SELECT shared_with_user_id FROM twofaccount_shares WHERE twofaccount_id=? AND scope='user'").bind(id).fetch_all(&s.db).await?; value["specific_users"] = json!(recipient_values(&s.db, id, u.id, Some(&ids), None, None).await?); } Ok(Json(value).into_response()) } async fn store( State(s): State, Extension(u): Extension, h: HeaderMap, Path(id): Path, Json(v): Json, ) -> ResultResponse { let all = authorize(&s, u.id, id).await?; let values = v .get("user_ids") .and_then(Value::as_array) .cloned() .or_else(|| v.get("user_id").map(|id| vec![id.clone()])); let Some(values) = values else { return Ok(invalid("user_ids")); }; let ids: Option> = values.iter().map(integer).collect(); let Some(mut ids) = ids else { return Ok(invalid("user_ids")); }; if ids.is_empty() || ids.iter().collect::>().len() != ids.len() || ids.contains(&u.id) { return Ok(invalid("user_ids")); } ids.sort_unstable(); let mut tx = s.db.begin().await?; verify_owner(&mut tx, u.id, id).await?; if all && is_all(&mut tx, id).await? { return Ok(conflict()); } for target in &ids { if !sqlx::query_scalar::<_, bool>("SELECT EXISTS(SELECT 1 FROM users WHERE id=?)") .bind(target) .fetch_one(&mut *tx) .await? { return Ok(invalid("user_ids")); } } if !all { sqlx::query("DELETE FROM twofaccount_shares WHERE twofaccount_id=? AND scope='all_users'") .bind(id) .execute(&mut *tx) .await?; } let mut created = 0; let mut notified = vec![]; for target in &ids { let inserted = sqlx::query("INSERT OR IGNORE INTO twofaccount_shares(twofaccount_id,shared_with_user_id,scope,created_by_user_id,created_at,updated_at) VALUES(?,?,'user',?,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP)").bind(id).bind(target).bind(u.id).execute(&mut *tx).await?.rows_affected(); created += inserted; if inserted > 0 { notified.push(*target); } } tx.commit().await?; notify_shares(&s, u.id, id, ¬ified, Notification::Shared, false, &h).await; Ok((if created>0 {StatusCode::CREATED} else {StatusCode::OK},Json(json!({"twofaccount_id":id,"is_shared_with_all":false,"specific_users":recipient_values(&s.db,id,u.id,Some(&ids),None,None).await?}))).into_response()) } async fn recipients( State(s): State, Extension(u): Extension, Path(id): Path, Query(q): Query>, ) -> ResultResponse { authorize(&s, u.id, id).await?; if q.keys() .any(|k| k.starts_with("filter[") && k != "filter[nameOrEmail]") { return Err(ApiError( StatusCode::BAD_REQUEST, "Requested filter is not allowed.", )); } Ok(Json(json!( recipient_values( &s.db, id, u.id, None, Some(u.id), q.get("filter[nameOrEmail]").map(String::as_str) ) .await? )) .into_response()) } async fn destroy( State(s): State, Extension(u): Extension, h: HeaderMap, Path((id, target)): Path<(i64, i64)>, ) -> ResultResponse { let all = authorize(&s, u.id, id).await?; let mut tx = s.db.begin().await?; verify_owner(&mut tx, u.id, id).await?; if !sqlx::query_scalar::<_, bool>("SELECT EXISTS(SELECT 1 FROM users WHERE id=?)") .bind(target) .fetch_one(&mut *tx) .await? { return Err(ApiError(StatusCode::NOT_FOUND, "not found")); } if all && is_all(&mut tx, id).await? { return Ok(conflict()); } sqlx::query("DELETE FROM twofaccount_user_orders WHERE twofaccount_id=? AND user_id=? AND EXISTS(SELECT 1 FROM twofaccount_shares WHERE twofaccount_id=? AND shared_with_user_id=? AND scope='user')").bind(id).bind(target).bind(id).bind(target).execute(&mut *tx).await?; let deleted=sqlx::query("DELETE FROM twofaccount_shares WHERE twofaccount_id=? AND shared_with_user_id=? AND scope='user'").bind(id).bind(target).execute(&mut *tx).await?.rows_affected(); tx.commit().await?; if deleted > 0 { notify_shares(&s, u.id, id, &[target], Notification::Revoked, false, &h).await; } Ok(StatusCode::NO_CONTENT.into_response()) } async fn destroy_all( State(s): State, Extension(u): Extension, h: HeaderMap, Path(id): Path, ) -> ResultResponse { let all = authorize(&s, u.id, id).await?; let mut tx = s.db.begin().await?; verify_owner(&mut tx, u.id, id).await?; let explicit:Vec=sqlx::query_scalar("SELECT shared_with_user_id FROM twofaccount_shares WHERE twofaccount_id=? AND scope='user' AND shared_with_user_id IS NOT NULL").bind(id).fetch_all(&mut *tx).await?; let all_recipients: Vec = if all && is_all(&mut tx, id).await? { sqlx::query_scalar("SELECT id FROM users WHERE id!=? ORDER BY id") .bind(u.id) .fetch_all(&mut *tx) .await? } else { vec![] }; sqlx::query("DELETE FROM twofaccount_user_orders WHERE twofaccount_id=? AND (user_id IN(SELECT shared_with_user_id FROM twofaccount_shares WHERE twofaccount_id=? AND scope='user') OR (? AND user_id!=? AND EXISTS(SELECT 1 FROM twofaccount_shares WHERE twofaccount_id=? AND scope='all_users')))").bind(id).bind(id).bind(all).bind(u.id).bind(id).execute(&mut *tx).await?; sqlx::query("DELETE FROM twofaccount_shares WHERE twofaccount_id=? AND (scope='user' OR (? AND scope='all_users'))").bind(id).bind(all).execute(&mut *tx).await?; tx.commit().await?; notify_shares(&s, u.id, id, &explicit, Notification::Revoked, false, &h).await; notify_shares( &s, u.id, id, &all_recipients, Notification::Revoked, true, &h, ) .await; Ok(StatusCode::NO_CONTENT.into_response()) } async fn share_all( State(s): State, Extension(u): Extension, h: HeaderMap, Path(id): Path, ) -> ResultResponse { if !authorize(&s, u.id, id).await? { return Err(ApiError( StatusCode::FORBIDDEN, "All Users sharing scope is disabled", )); } let mut tx = s.db.begin().await?; verify_owner(&mut tx, u.id, id).await?; sqlx::query("DELETE FROM twofaccount_shares WHERE twofaccount_id=? AND scope='user'") .bind(id) .execute(&mut *tx) .await?; let created = !is_all(&mut tx, id).await?; if created { sqlx::query("INSERT INTO twofaccount_shares(twofaccount_id,scope,created_by_user_id,created_at,updated_at) VALUES(?,'all_users',?,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP)").bind(id).bind(u.id).execute(&mut *tx).await?; } tx.commit().await?; if created { let recipients: Vec = sqlx::query_scalar("SELECT id FROM users WHERE id!=? ORDER BY id") .bind(u.id) .fetch_all(&s.db) .await?; notify_shares(&s, u.id, id, &recipients, Notification::Shared, true, &h).await; } Ok(( if created { StatusCode::CREATED } else { StatusCode::OK }, Json(json!({"twofaccount_id":id,"is_shared_with_all":true})), ) .into_response()) } async fn transfer( State(s): State, Extension(u): Extension, h: HeaderMap, Path(id): Path, Json(v): Json, ) -> ResultResponse { if !crate::auth::throttle_limit(&s.db, format!("ownership-transfer:{}", u.id), 6).await? { return Err(ApiError( StatusCode::TOO_MANY_REQUESTS, "Too Many Attempts.", )); } let all = authorize(&s, u.id, id).await?; let Some(target) = v.get("new_owner_id").and_then(integer) else { return Ok(invalid("new_owner_id")); }; let Some(password) = v .get("confirm_password") .and_then(Value::as_str) .filter(|v| !v.is_empty()) else { return Ok(invalid("confirm_password")); }; if target == u.id { return Ok((StatusCode::UNPROCESSABLE_ENTITY,Json(json!({"message":"The new owner must not be the current owner.","errors":{"new_owner_id":["The new owner must not be the current owner."]}}))).into_response()); } if !sqlx::query_scalar::<_, bool>("SELECT EXISTS(SELECT 1 FROM users WHERE id=?)") .bind(target) .fetch_one(&s.db) .await? { return Ok(invalid("new_owner_id")); } let hash: String = sqlx::query_scalar("SELECT password FROM users WHERE id=?") .bind(u.id) .fetch_one(&s.db) .await?; let password = password.to_owned(); let valid = tokio::task::spawn_blocking(move || crate::auth::verify(password, hash)) .await .unwrap_or(false); if !valid { return Err(ApiError(StatusCode::UNAUTHORIZED, "unauthorized")); } let mut tx = s.db.begin().await?; verify_owner(&mut tx, u.id, id).await?; // Recheck ownership under the write transaction to prevent a stale authorization transfer. let updated = sqlx::query( "UPDATE twofaccounts SET user_id=?,updated_at=CURRENT_TIMESTAMP WHERE id=? AND user_id=?", ) .bind(target) .bind(id) .bind(u.id) .execute(&mut *tx) .await? .rows_affected(); if updated == 0 { return Err(ApiError( StatusCode::FORBIDDEN, "This action is unauthorized.", )); } sqlx::query("DELETE FROM twofaccount_shares WHERE twofaccount_id=? AND scope='user' AND shared_with_user_id=?").bind(id).bind(target).execute(&mut *tx).await?; if !(all && is_all(&mut tx, id).await?) { sqlx::query("DELETE FROM twofaccount_user_orders WHERE twofaccount_id=? AND user_id!=? AND user_id NOT IN(SELECT shared_with_user_id FROM twofaccount_shares WHERE twofaccount_id=? AND scope='user' AND shared_with_user_id IS NOT NULL)").bind(id).bind(target).bind(id).execute(&mut *tx).await?; } tx.commit().await?; notify_shares(&s, u.id, id, &[target], Notification::Ownership, false, &h).await; Ok(Json(json!({"twofaccount_id":id,"owner_id":target})).into_response()) } #[derive(Clone, Copy)] enum Notification { Shared, Revoked, Ownership, } fn notification_locale(preferences: &Value, browser: &str) -> String { preferences["lang"] .as_str() .filter(|v| *v != "browser") .unwrap_or(browser) .to_owned() } fn browser_locale(headers: &HeaderMap) -> String { let mut languages: Vec<(&str, f32)> = headers .get("accept-language") .and_then(|v| v.to_str().ok()) .unwrap_or("") .split(',') .filter_map(|entry| { let mut parts = entry.trim().split(';'); let name = parts.next()?; let weight = parts .next() .and_then(|q| q.trim().strip_prefix("q=")) .and_then(|q| q.parse::().ok()) .unwrap_or(1.); if name.is_empty() || !weight.is_finite() || weight <= 0. { None } else { Some((name, weight)) } }) .collect(); languages.sort_by(|a, b| b.1.total_cmp(&a.1)); languages .first() .map(|(language, _)| (*language).to_owned()) .unwrap_or_else(|| std::env::var("APP_FALLBACK_LOCALE").unwrap_or_else(|_| "en".into())) } fn notification_content( kind: Notification, all: bool, locale: &str, actor: &str, label: &str, recipient: &str, url: &str, ) -> (String, String) { let prefix = match kind { Notification::Shared => "message.notifications.twofaccount_shared", Notification::Revoked => "message.notifications.twofaccount_share_revoked", Notification::Ownership => "message.notifications.twofaccount_ownership_transferred", }; let replacements = [ ("actor", actor), ("twofaccount", label), ("username", recipient), ]; let subject = crate::mail::translate(locale, &format!("{prefix}.subject"), &replacements); let greeting = crate::mail::translate(locale, "message.notifications.hello_user", &replacements); let resume = match kind { Notification::Ownership => format!("{prefix}.resume"), _ => format!("{prefix}.resume_{}", if all { "all_users" } else { "user" }), }; let mut lines = vec![ greeting, crate::mail::translate(locale, &resume, &replacements), ]; let extra = match kind { Notification::Shared => Some("use_it_to_generate_otp"), Notification::Ownership => Some("check_shares"), Notification::Revoked => None, }; if let Some(extra) = extra { lines.push(crate::mail::translate( locale, &format!("{prefix}.{extra}"), &replacements, )); } lines.push(format!( "{}: {url}", crate::mail::translate(locale, "link.go_to_2fauth_host", &replacements) )); (subject, lines.join("\n\n")) } async fn notify_shares( s: &AppState, actor: i64, account: i64, recipients: &[i64], kind: Notification, all: bool, headers: &HeaderMap, ) { if recipients.is_empty() { return; } match prepare_notifications(s, actor, account, recipients, kind, all, headers).await { Ok(deliveries) => { for (email, subject, body) in deliveries { if crate::mail::notify(&email, &subject, &body).await.is_err() { // Upstream catches all delivery exceptions: the sharing action remains committed. tracing::warn!("Sharing notification delivery failed"); } } } Err(_) => tracing::warn!("Sharing notification could not be prepared"), } } async fn prepare_notifications( s: &AppState, actor: i64, account: i64, recipients: &[i64], kind: Notification, all: bool, headers: &HeaderMap, ) -> Result, ApiError> { // Select only printable account fields. OTP secrets never enter notification content. let row = sqlx::query("SELECT service,account FROM twofaccounts WHERE id=?") .bind(account) .fetch_one(&s.db) .await?; let read = |field: &str| -> Result { let text = row.try_get::, _>(field)?.unwrap_or_default(); if s.encryption_enabled() && !text.is_empty() { s.cipher .as_ref() .ok_or(ApiError( StatusCode::SERVICE_UNAVAILABLE, "Encrypted notification label unavailable", ))? .decrypt(&text) .map_err(|_| { ApiError( StatusCode::SERVICE_UNAVAILABLE, "Encrypted notification label unavailable", ) }) } else { Ok(text) } }; let service = read("service")?; let name = read("account")?; let label = if service.trim().is_empty() { name.trim().to_owned() } else { format!("{} ({})", service.trim(), name.trim()) }; let actor = sqlx::query("SELECT name,preferences FROM users WHERE id=?") .bind(actor) .fetch_one(&s.db) .await?; let actor_name: String = actor.try_get("name")?; let prefs = crate::settings::preferences_from( actor .try_get::, _>("preferences")? .as_deref(), ); let locale = notification_locale(&prefs, &browser_locale(headers)); let users = sqlx::query("SELECT id,name,email,preferences FROM users ORDER BY id") .fetch_all(&s.db) .await?; let targets: HashSet<_> = recipients.iter().copied().collect(); let host = std::env::var("APP_URL").unwrap_or_else(|_| "http://localhost".into()); let mut deliveries = vec![]; for recipient in users { if !targets.contains(&recipient.try_get::("id")?) { continue; } let prefs = crate::settings::preferences_from( recipient .try_get::, _>("preferences")? .as_deref(), ); let preference = match kind { Notification::Ownership => "notifyOnOwnershipTransfer", _ => "notifyOnShare", }; if !crate::settings::php_bool(&prefs[preference]) { continue; } let locale = notification_locale(&prefs, &locale); let (subject, body) = notification_content( kind, all, &locale, &actor_name, &label, recipient.try_get("name")?, &host, ); deliveries.push((recipient.try_get("email")?, subject, body)); } Ok(deliveries) } #[cfg(test)] mod tests { use super::*; use axum::{ body::{Body, to_bytes}, http::Request, }; use tower::ServiceExt; async fn setup() -> AppState { let db = sqlx::sqlite::SqlitePoolOptions::new() .max_connections(1) .connect("sqlite::memory:") .await .unwrap(); crate::db::init(&db).await.unwrap(); crate::auth::init(&db).await.unwrap(); sqlx::raw_sql("INSERT INTO users(id,name,email,password) VALUES(1,'Alice','alice@example.org','unused'),(2,'Bob','bob@example.org','unused'),(3,'Carol','carol@example.org','unused'); INSERT INTO options(key,value) VALUES('enableSharing','{{1}}'),('enableAllUsersSharingScope','{{1}}'); INSERT INTO twofaccounts(id,user_id,account,otp_type,secret,algorithm,digits,counter) VALUES(1,1,'alice','hotp','GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ','sha1',6,0);").execute(&db).await.unwrap(); sqlx::query("UPDATE users SET preferences='{\"notifyOnShare\":false,\"notifyOnOwnershipTransfer\":false}'").execute(&db).await.unwrap(); AppState { db, cipher: None, encrypt_db: std::sync::atomic::AtomicBool::new(false).into(), } } async fn request( s: &AppState, user: i64, method: &str, url: &str, v: Value, ) -> (StatusCode, Value) { let app = routes() .merge(crate::accounts::routes()) .merge(crate::groups::routes()) .layer(Extension(AuthUser { id: user, is_admin: false, })) .with_state(s.clone()); let response = app .oneshot( Request::builder() .method(method) .uri(url) .header("content-type", "application/json") .body(Body::from(v.to_string())) .unwrap(), ) .await .unwrap(); let status = response.status(); let bytes = to_bytes(response.into_body(), 1024 * 1024).await.unwrap(); ( status, serde_json::from_slice(&bytes).unwrap_or(Value::Null), ) } #[tokio::test] async fn notification_targets_respect_preferences_locales_scope_and_hide_secrets() { let mut s = setup().await; sqlx::query("UPDATE users SET preferences=? WHERE id=2") .bind( json!({"notifyOnShare":true,"notifyOnOwnershipTransfer":false,"lang":"fr"}) .to_string(), ) .execute(&s.db) .await .unwrap(); sqlx::query("UPDATE users SET preferences=? WHERE id=3") .bind( json!({"notifyOnShare":false,"notifyOnOwnershipTransfer":true,"lang":"en"}) .to_string(), ) .execute(&s.db) .await .unwrap(); sqlx::query("UPDATE twofaccounts SET service=' Example ',account=' alice@example.org '") .execute(&s.db) .await .unwrap(); let h = HeaderMap::new(); let deliveries = prepare_notifications(&s, 1, 1, &[2, 3, 2], Notification::Shared, false, &h) .await .unwrap(); assert_eq!(deliveries.len(), 1); assert_eq!(deliveries[0].0, "bob@example.org"); assert_eq!( deliveries[0].1, crate::mail::translate( "fr", "message.notifications.twofaccount_shared.subject", &[] ) ); assert!(deliveries[0].2.contains("Example (alice@example.org)")); assert!(deliveries[0].2.contains("Alice")); assert!(!deliveries[0].2.contains("GEZDGNBV")); let ownership = prepare_notifications(&s, 1, 1, &[2, 3], Notification::Ownership, false, &h) .await .unwrap(); assert_eq!(ownership.len(), 1); assert_eq!(ownership[0].0, "carol@example.org"); assert!(ownership[0].2.contains("review")); let (subject, body) = notification_content( Notification::Revoked, true, "en", "Alice", "Account", "Bob", "https://example.org", ); assert_eq!(subject, "2FA account sharing revoked"); assert!(body.contains("for all users")); assert!(body.contains("https://example.org")); assert!(!body.contains("You can now")); assert!( prepare_notifications(&s, 1, 1, &[1], Notification::Shared, false, &h) .await .unwrap() .is_empty() ); s.cipher = Some(std::sync::Arc::new( crate::crypto::LaravelCipher::from_app_key( "base64:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", ) .unwrap(), )); s.encrypt_db .store(true, std::sync::atomic::Ordering::SeqCst); let cipher = s.cipher.as_ref().unwrap(); sqlx::query("UPDATE twofaccounts SET account=?,service=?,secret='must-never-be-read'") .bind(cipher.encrypt("hidden@example.org").unwrap()) .bind(cipher.encrypt("Encrypted Label").unwrap()) .execute(&s.db) .await .unwrap(); let encrypted = prepare_notifications(&s, 1, 1, &[2], Notification::Shared, false, &h) .await .unwrap(); assert!( encrypted[0] .2 .contains("Encrypted Label (hidden@example.org)") ); assert!(!encrypted[0].2.contains("must-never-be-read")); } #[tokio::test] async fn explicit_share_is_idempotent_and_read_only_with_independent_groups() { let s = setup().await; let (status, v) = request( &s, 1, "POST", "/twofaccounts/1/shares", json!({"user_ids":[2]}), ) .await; assert_eq!(status, StatusCode::CREATED); assert_eq!(v["specific_users"][0]["is_shared_with"], true); assert_eq!( request( &s, 1, "POST", "/twofaccounts/1/shares", json!({"user_id":"2"}) ) .await .0, StatusCode::OK ); let (_, v) = request(&s, 2, "GET", "/twofaccounts?withSecret=true", Value::Null).await; assert_eq!(v[0]["is_borrowed"], true); assert_eq!(v[0]["borrowed_by"], "Alice"); assert!(v[0].get("secret").is_none()); let (_, v) = request(&s, 2, "GET", "/twofaccounts/1?withSecret=true", Value::Null).await; assert!(v.get("secret").is_none()); assert_eq!( request(&s, 2, "GET", "/twofaccounts/1/otp", Value::Null) .await .1["password"], "755224" ); assert_eq!( request(&s, 2, "GET", "/twofaccounts/1/otp-logs", Value::Null) .await .0, StatusCode::FORBIDDEN ); let (_, logs) = request( &s, 1, "GET", "/twofaccounts/1/otp-logs?limit=1", Value::Null, ) .await; assert_eq!(logs[0]["requester_name"], "Bob"); assert_eq!(logs[0]["owner_name"], "Alice"); assert_eq!(logs[0]["counter"], 1); assert!(logs[0].get("secret").is_none()); assert_eq!( request(&s, 2, "GET", "/twofaccounts/export?ids=1", Value::Null) .await .0, StatusCode::FORBIDDEN ); assert_eq!( request(&s, 2, "DELETE", "/twofaccounts/1", Value::Null) .await .0, StatusCode::FORBIDDEN ); assert_eq!( request(&s, 2, "DELETE", "/twofaccounts/1/shares", Value::Null) .await .0, StatusCode::FORBIDDEN ); let (_, group) = request(&s, 2, "POST", "/groups", json!({"name":"Borrowed"})).await; let group = group["id"].as_i64().unwrap(); assert_eq!( request( &s, 2, "POST", &format!("/groups/{group}/assign"), json!({"ids":[1]}) ) .await .0, StatusCode::OK ); assert_eq!( request(&s, 2, "GET", "/twofaccounts/1", Value::Null) .await .1["group_id"], group ); assert!( request(&s, 1, "GET", "/twofaccounts/1", Value::Null) .await .1["group_id"] .is_null() ); assert_eq!( request(&s, 2, "GET", "/twofaccounts/count", Value::Null) .await .1, json!({"owned":0,"shared":1,"total":1}) ); assert_eq!( request( &s, 2, "POST", "/twofaccounts/reorder", json!({"orderedIds":[1]}) ) .await .0, StatusCode::OK ); request(&s, 1, "DELETE", "/twofaccounts/1/shares/2", Value::Null).await; let remaining: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM twofaccount_user_orders WHERE user_id=2") .fetch_one(&s.db) .await .unwrap(); assert_eq!(remaining, 0); assert_eq!( request(&s, 2, "GET", "/twofaccounts", Value::Null).await.1, json!([]) ); assert_eq!( request(&s, 2, "GET", &format!("/groups/{group}"), Value::Null) .await .1["twofaccounts_count"], 0 ); } #[tokio::test] async fn all_scope_conflict_disablement_and_batch_validation_are_atomic() { let s = setup().await; assert_eq!( request( &s, 1, "POST", "/twofaccounts/1/shares", json!({"user_ids":[2,999]}) ) .await .0, StatusCode::UNPROCESSABLE_ENTITY ); assert!(!can_access(&s.db, 2, 1).await.unwrap()); assert_eq!( request(&s, 1, "POST", "/twofaccounts/1/shares/all", json!({})) .await .0, StatusCode::CREATED ); assert_eq!( request(&s, 1, "POST", "/twofaccounts/1/shares/all", json!({})) .await .0, StatusCode::OK ); assert!(can_access(&s.db, 3, 1).await.unwrap()); assert_eq!( request( &s, 1, "POST", "/twofaccounts/1/shares", json!({"user_ids":[2]}) ) .await .0, StatusCode::CONFLICT ); sqlx::query("UPDATE options SET value='{{}}' WHERE key='enableAllUsersSharingScope'") .execute(&s.db) .await .unwrap(); assert!(!can_access(&s.db, 3, 1).await.unwrap()); assert_eq!( request( &s, 1, "POST", "/twofaccounts/1/shares", json!({"user_ids":[2]}) ) .await .0, StatusCode::CREATED ); assert!(can_access(&s.db, 2, 1).await.unwrap()); let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM twofaccount_shares WHERE scope='all_users'") .fetch_one(&s.db) .await .unwrap(); assert_eq!(count, 0); sqlx::query("UPDATE options SET value='{{}}' WHERE key='enableSharing'") .execute(&s.db) .await .unwrap(); assert!(!can_access(&s.db, 2, 1).await.unwrap()); assert_eq!( request(&s, 1, "GET", "/twofaccounts/1/shares", Value::Null) .await .0, StatusCode::FORBIDDEN ); } #[tokio::test] async fn transfer_requires_password_removes_new_owner_share_and_preserves_others() { let s = setup().await; let hash = bcrypt::hash("password", 4).unwrap(); sqlx::query("UPDATE users SET password=? WHERE id=1") .bind(hash) .execute(&s.db) .await .unwrap(); request( &s, 1, "POST", "/twofaccounts/1/shares", json!({"user_ids":[2,3]}), ) .await; sqlx::query("INSERT INTO twofaccount_user_orders(user_id,twofaccount_id,position) VALUES(1,1,1),(2,1,1),(3,1,1)").execute(&s.db).await.unwrap(); assert_eq!( request( &s, 1, "PATCH", "/twofaccounts/1/owner", json!({"new_owner_id":2,"confirm_password":"wrong"}) ) .await .0, StatusCode::UNAUTHORIZED ); assert_eq!( request( &s, 1, "PATCH", "/twofaccounts/1/owner", json!({"new_owner_id":2,"confirm_password":"password"}) ) .await .0, StatusCode::OK ); let (_, v) = request(&s, 2, "GET", "/twofaccounts/1", Value::Null).await; assert!(v.get("secret").is_some()); assert!(!can_access(&s.db, 1, 1).await.unwrap()); assert!(can_access(&s.db, 3, 1).await.unwrap()); let ids: Vec = sqlx::query_scalar("SELECT shared_with_user_id FROM twofaccount_shares") .fetch_all(&s.db) .await .unwrap(); assert_eq!(ids, vec![3]); let orders: Vec = sqlx::query_scalar("SELECT user_id FROM twofaccount_user_orders ORDER BY user_id") .fetch_all(&s.db) .await .unwrap(); assert_eq!(orders, vec![2, 3]); } }