feat(ayarlar): hane ve üyeler, davet bağlantısı, hesap ve şifre, bütçe varsayılanları
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -425,3 +425,26 @@ POST {{baseUrl}}/backup/restore-points/{{pointId}}/restore
|
||||
|
||||
### Dönemin raporu (Markdown; açık dönem → ara rapor)
|
||||
GET {{baseUrl}}/periods/{{periodId}}/report.md
|
||||
|
||||
# ─────────────────────────── Hesap ve hane ayarları ───────────────────────────
|
||||
|
||||
### Profil adı
|
||||
PATCH {{baseUrl}}/auth/me
|
||||
Content-Type: application/json
|
||||
|
||||
{ "name": "Suphi" }
|
||||
|
||||
### Şifre değiştir (diğer cihazlarda oturum kapanır, bu cihaza yeni çerez)
|
||||
POST {{baseUrl}}/auth/me/password
|
||||
Content-Type: application/json
|
||||
|
||||
{ "current_password": "…", "new_password": "…" }
|
||||
|
||||
### Hane adı + bütçe varsayılanları (sahip; toplam 100)
|
||||
PATCH {{baseUrl}}/household
|
||||
Content-Type: application/json
|
||||
|
||||
{ "name": "Çakır Hanesi", "budget_split_pct": { "needs": 50, "wants": 30, "culture": 10, "unexpected": 10 }, "budget_suggestion": "avg3" }
|
||||
|
||||
### Üyeyi çıkar (sahip)
|
||||
DELETE {{baseUrl}}/household/members/{{memberId}}
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
//! Bir route parametrelerine `user: AuthUser` eklediğinde, Axum isteği işlemeden önce:
|
||||
//! 1. cookie'deki JWT'nin imzasını ve süresini doğrular,
|
||||
//! 2. kullanıcının veritabanında hâlâ var olduğunu ve aynı haneye ait olduğunu kontrol eder
|
||||
//! (silinen kullanıcının token'ı anında geçersiz olur, rol değişikliği hemen etkili olur).
|
||||
//! (silinen kullanıcının token'ı anında geçersiz olur, rol değişikliği hemen etkili olur),
|
||||
//! 3. jeton şifre değişikliğinden önce verilmişse reddeder (`sessions_valid_from`).
|
||||
//!
|
||||
//! Herhangi biri başarısızsa route hiç çalışmaz, 401 döner.
|
||||
//! **Hane izolasyonu:** route'lar hane kimliğini her zaman `AuthUser.household_id`'den alır,
|
||||
@@ -67,6 +68,9 @@ impl AuthUser {
|
||||
#[derive(Deserialize)]
|
||||
struct RoleOnly {
|
||||
role: Role,
|
||||
/// Şifre değişince "şimdi": bundan önce verilmiş jetonlar geçersiz
|
||||
#[serde(default)]
|
||||
sessions_valid_from: Option<i64>,
|
||||
}
|
||||
|
||||
impl FromRequestParts<AppState> for AuthUser {
|
||||
@@ -85,9 +89,15 @@ impl FromRequestParts<AppState> for AuthUser {
|
||||
.db
|
||||
.collection::<RoleOnly>(col::USERS)
|
||||
.find_one(doc! { "_id": user_id, "household_id": household_id, "deleted_at": null })
|
||||
.projection(doc! { "role": 1 })
|
||||
.projection(doc! { "role": 1, "sessions_valid_from": 1 })
|
||||
.await?
|
||||
.ok_or(AppError::Unauthorized)?;
|
||||
if current
|
||||
.sessions_valid_from
|
||||
.is_some_and(|from| (claims.iat as i64) < from)
|
||||
{
|
||||
return Err(AppError::Unauthorized);
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
user_id,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Kimlik doğrulama uç noktaları:
|
||||
//! `POST /api/auth/register`, `POST /api/auth/login`, `POST /api/auth/logout`, `GET /api/auth/me`
|
||||
//! `POST /api/auth/register`, `POST /api/auth/login`, `POST /api/auth/logout`, `GET /api/auth/me`,
|
||||
//! `PATCH /api/auth/me` (ad), `POST /api/auth/me/password` (şifre değiştir → diğer oturumlar kapanır)
|
||||
|
||||
use axum::{Json, extract::State, http::StatusCode};
|
||||
use axum_extra::extract::cookie::CookieJar;
|
||||
@@ -11,7 +12,7 @@ use validator::Validate;
|
||||
use crate::{
|
||||
auth::{
|
||||
AuthUser, password,
|
||||
session::{removal_cookie, session_cookie, ttl},
|
||||
session::{COOKIE_NAME, removal_cookie, session_cookie, ttl},
|
||||
},
|
||||
db::col,
|
||||
error::{AppError, AppResult},
|
||||
@@ -77,6 +78,22 @@ struct HouseholdDto {
|
||||
name: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Validate, TS)]
|
||||
#[ts(export)]
|
||||
pub struct ProfileRequest {
|
||||
#[validate(length(min = 1, max = 80, message = "Ad 1–80 karakter olmalı"))]
|
||||
name: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Validate, TS)]
|
||||
#[ts(export)]
|
||||
pub struct PasswordChangeRequest {
|
||||
#[validate(length(min = 1, message = "Mevcut şifreyi girin"))]
|
||||
current_password: String,
|
||||
#[validate(length(min = 8, max = 128, message = "Yeni şifre en az 8 karakter olmalı"))]
|
||||
new_password: String,
|
||||
}
|
||||
|
||||
// ---------- Uç noktalar ----------
|
||||
|
||||
pub async fn register(
|
||||
@@ -200,6 +217,59 @@ pub async fn me(State(state): State<AppState>, auth: AuthUser) -> AppResult<Json
|
||||
.map(Json)
|
||||
}
|
||||
|
||||
pub async fn update_me(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
ValidJson(req): ValidJson<ProfileRequest>,
|
||||
) -> AppResult<Json<MeResponse>> {
|
||||
account::update_name(&state.db, auth.household_id, auth.user_id, &req.name).await?;
|
||||
load_me(&state, auth.user_id, auth.household_id)
|
||||
.await
|
||||
.map(Json)
|
||||
}
|
||||
|
||||
/// Şifre değiştir: mevcut şifre doğrulanır (deneme sınırlı), diğer cihazlardaki oturumlar
|
||||
/// kapanır, bu cihaza yeni oturum çerezi verilir ("beni hatırla" durumu korunur).
|
||||
pub async fn change_password(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
auth: AuthUser,
|
||||
ValidJson(req): ValidJson<PasswordChangeRequest>,
|
||||
) -> AppResult<(StatusCode, CookieJar)> {
|
||||
let key = format!("password:{}", auth.user_id);
|
||||
if !state.login_limiter.is_allowed(&key) {
|
||||
return Err(AppError::TooManyRequests);
|
||||
}
|
||||
let remember = jar
|
||||
.get(COOKIE_NAME)
|
||||
.and_then(|c| state.jwt.verify(c.value()))
|
||||
.is_some_and(|c| c.exp.saturating_sub(c.iat) > ttl(false).as_secs());
|
||||
|
||||
match account::change_password(
|
||||
&state.db,
|
||||
auth.household_id,
|
||||
auth.user_id,
|
||||
req.current_password,
|
||||
req.new_password,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(()) => state.login_limiter.reset(&key),
|
||||
Err(e) => {
|
||||
if matches!(&e, AppError::BadRequest(m) if m.contains("Mevcut şifre")) {
|
||||
state.login_limiter.record_failure(&key);
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
|
||||
let token = state
|
||||
.jwt
|
||||
.issue(auth.user_id, auth.household_id, auth.role, ttl(remember))?;
|
||||
let jar = jar.add(session_cookie(token, remember, state.config.is_https()));
|
||||
Ok((StatusCode::NO_CONTENT, jar))
|
||||
}
|
||||
|
||||
// ---------- Yardımcılar ----------
|
||||
|
||||
fn normalize_email(email: &str) -> String {
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
//! - `GET /api/household/invites` → aktif davetler (yalnızca owner)
|
||||
//! - `POST /api/household/invites` → yeni davet kodu (yalnızca owner)
|
||||
//! - `DELETE /api/household/invites/{id}` → daveti iptal et (yalnızca owner)
|
||||
//! - `PATCH /api/household` → ad + bütçe varsayılanları (yalnızca owner)
|
||||
//! - `DELETE /api/household/members/{id}` → üyeyi çıkar (yalnızca owner)
|
||||
|
||||
use axum::{
|
||||
Json,
|
||||
@@ -12,13 +14,15 @@ use axum::{
|
||||
use mongodb::bson::{DateTime, Uuid, doc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use ts_rs::TS;
|
||||
use validator::Validate;
|
||||
|
||||
use crate::{
|
||||
auth::AuthUser,
|
||||
db::col,
|
||||
error::{AppError, AppResult},
|
||||
extract::ValidJson,
|
||||
models::{common::scope, household::Household, invite::Invite, user::Role},
|
||||
services::invite,
|
||||
services::{account, invite},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
@@ -28,6 +32,41 @@ pub struct HouseholdResponse {
|
||||
id: String,
|
||||
name: String,
|
||||
members: Vec<MemberDto>,
|
||||
settings: SettingsDto,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Validate, TS, Clone, Copy)]
|
||||
#[ts(export, rename = "BudgetSplitPct")]
|
||||
pub struct SplitDto {
|
||||
#[validate(range(max = 100))]
|
||||
needs: u32,
|
||||
#[validate(range(max = 100))]
|
||||
wants: u32,
|
||||
#[validate(range(max = 100))]
|
||||
culture: u32,
|
||||
#[validate(range(max = 100))]
|
||||
unexpected: u32,
|
||||
}
|
||||
|
||||
#[derive(Serialize, TS)]
|
||||
#[ts(export, rename = "HouseholdSettings")]
|
||||
struct SettingsDto {
|
||||
/// Varsayılan bütçe dağılımı (yüzde, toplam 100)
|
||||
budget_split_pct: SplitDto,
|
||||
/// Plan ekranında önce seçili öneri yöntemi
|
||||
#[ts(type = "\"default\" | \"previous\" | \"avg3\"")]
|
||||
budget_suggestion: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Validate, TS)]
|
||||
#[ts(export, optional_fields)]
|
||||
pub struct HouseholdUpdateRequest {
|
||||
#[validate(length(min = 1, max = 80, message = "Hane adı 1–80 karakter olmalı"))]
|
||||
name: Option<String>,
|
||||
#[validate(nested)]
|
||||
budget_split_pct: Option<SplitDto>,
|
||||
#[ts(type = "\"default\" | \"previous\" | \"avg3\"", optional)]
|
||||
budget_suggestion: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, TS)]
|
||||
@@ -78,9 +117,16 @@ pub async fn get(
|
||||
let household = state
|
||||
.db
|
||||
.collection::<Household>(col::HOUSEHOLDS)
|
||||
.find_one(hh_filter)
|
||||
.find_one(hh_filter.clone())
|
||||
.await?
|
||||
.ok_or(AppError::NotFound)?;
|
||||
let settings = state
|
||||
.db
|
||||
.collection::<mongodb::bson::Document>(col::HOUSEHOLDS)
|
||||
.find_one(hh_filter)
|
||||
.await?
|
||||
.and_then(|d| d.get_document("settings").ok().cloned())
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut cursor = state
|
||||
.db
|
||||
@@ -105,9 +151,66 @@ pub async fn get(
|
||||
id: hid.to_string(),
|
||||
name: household.name,
|
||||
members,
|
||||
settings: settings_dto(&settings),
|
||||
}))
|
||||
}
|
||||
|
||||
fn settings_dto(s: &mongodb::bson::Document) -> SettingsDto {
|
||||
let pct = s.get_document("budget_split_pct").ok();
|
||||
let v = |k: &str, d: u32| {
|
||||
pct.and_then(|p| match p.get(k) {
|
||||
Some(mongodb::bson::Bson::Int32(v)) => u32::try_from(*v).ok(),
|
||||
Some(mongodb::bson::Bson::Int64(v)) => u32::try_from(*v).ok(),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or(d)
|
||||
};
|
||||
SettingsDto {
|
||||
budget_split_pct: SplitDto {
|
||||
needs: v("needs", 50),
|
||||
wants: v("wants", 30),
|
||||
culture: v("culture", 10),
|
||||
unexpected: v("unexpected", 10),
|
||||
},
|
||||
budget_suggestion: s
|
||||
.get_str("budget_suggestion")
|
||||
.unwrap_or("default")
|
||||
.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn update(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
ValidJson(req): ValidJson<HouseholdUpdateRequest>,
|
||||
) -> AppResult<Json<HouseholdResponse>> {
|
||||
auth.require_owner()?;
|
||||
account::update_household(
|
||||
&state.db,
|
||||
auth.household_id,
|
||||
account::HouseholdChanges {
|
||||
name: req.name,
|
||||
split_pct: req
|
||||
.budget_split_pct
|
||||
.map(|p| [p.needs, p.wants, p.culture, p.unexpected]),
|
||||
suggestion: req.budget_suggestion,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
get(State(state), auth).await
|
||||
}
|
||||
|
||||
pub async fn remove_member(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Path(id): Path<String>,
|
||||
) -> AppResult<StatusCode> {
|
||||
auth.require_owner()?;
|
||||
let id = Uuid::parse_str(&id).map_err(|_| AppError::NotFound)?;
|
||||
account::remove_member(&state.db, auth.household_id, auth.user_id, id).await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
pub async fn list_invites(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
|
||||
@@ -49,8 +49,10 @@ pub fn router(state: AppState) -> Router {
|
||||
.route("/auth/registration", get(auth::registration_info))
|
||||
.route("/auth/login", post(auth::login))
|
||||
.route("/auth/logout", post(auth::logout))
|
||||
.route("/auth/me", get(auth::me))
|
||||
.route("/household", get(household::get))
|
||||
.route("/auth/me", get(auth::me).patch(auth::update_me))
|
||||
.route("/household", get(household::get).patch(household::update))
|
||||
.route("/household/members/{id}", delete(household::remove_member))
|
||||
.route("/auth/me/password", post(auth::change_password))
|
||||
.route(
|
||||
"/household/invites",
|
||||
get(household::list_invites).post(household::create_invite),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Hesap işlemleri: kayıt.
|
||||
//! Hesap işlemleri: kayıt; profil, şifre, hane ayarları, üye çıkarma.
|
||||
//!
|
||||
//! İki yol var:
|
||||
//! - **Davetsiz:** yeni hane + `owner` kullanıcı + örnek kategori/ödeme türleri
|
||||
@@ -147,3 +147,178 @@ async fn create_in_tx(
|
||||
role,
|
||||
})
|
||||
}
|
||||
|
||||
// ---------- Profil, şifre, hane ayarları, üyeler (Faz 10) ----------
|
||||
|
||||
/// Oturumların geçerlilik başlangıcı (Unix saniye). Şifre değişince "şimdi" yapılır:
|
||||
/// daha önce verilmiş tüm oturum jetonları (`iat` daha küçük) reddedilir.
|
||||
pub const SESSIONS_VALID_FROM: &str = "sessions_valid_from";
|
||||
|
||||
fn unix_now() -> i64 {
|
||||
time::OffsetDateTime::now_utc().unix_timestamp()
|
||||
}
|
||||
|
||||
fn clean_text(v: &str, what: &str, max: usize) -> AppResult<String> {
|
||||
let v = v.trim().to_string();
|
||||
if v.is_empty() {
|
||||
return Err(AppError::BadRequest(format!("{what} boş olamaz")));
|
||||
}
|
||||
if v.chars().count() > max {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"{what} en fazla {max} karakter olabilir"
|
||||
)));
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
pub async fn update_name(
|
||||
db: &Database,
|
||||
household_id: Uuid,
|
||||
user_id: Uuid,
|
||||
name: &str,
|
||||
) -> AppResult<()> {
|
||||
let name = clean_text(name, "Ad", 80)?;
|
||||
let r = db
|
||||
.collection::<Document>(col::USERS)
|
||||
.update_one(
|
||||
doc! { "_id": user_id, "household_id": household_id, "deleted_at": null },
|
||||
doc! { "$set": { "name": name, "updated_at": crate::models::common::now() }, "$inc": { "version": 1 } },
|
||||
)
|
||||
.await?;
|
||||
if r.matched_count == 0 {
|
||||
return Err(AppError::Unauthorized);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Mevcut şifre doğruysa yenisini yazar ve bu andan önce verilmiş tüm oturumları geçersiz
|
||||
/// kılar (route bu cihaza hemen yeni bir oturum verir).
|
||||
pub async fn change_password(
|
||||
db: &Database,
|
||||
household_id: Uuid,
|
||||
user_id: Uuid,
|
||||
current: String,
|
||||
new: String,
|
||||
) -> AppResult<()> {
|
||||
if new.chars().count() < 8 || new.chars().count() > 128 {
|
||||
return Err(AppError::BadRequest(
|
||||
"Yeni şifre en az 8 karakter olmalı".into(),
|
||||
));
|
||||
}
|
||||
if new == current {
|
||||
return Err(AppError::BadRequest(
|
||||
"Yeni şifre mevcut şifreden farklı olmalı".into(),
|
||||
));
|
||||
}
|
||||
let users = db.collection::<Document>(col::USERS);
|
||||
let filter = doc! { "_id": user_id, "household_id": household_id, "deleted_at": null };
|
||||
let user = users
|
||||
.find_one(filter.clone())
|
||||
.await?
|
||||
.ok_or(AppError::Unauthorized)?;
|
||||
let hash = user.get_str("password_hash").ok().map(str::to_string);
|
||||
if !crate::auth::password::verify(current, hash).await {
|
||||
return Err(AppError::BadRequest("Mevcut şifre hatalı".into()));
|
||||
}
|
||||
let new_hash = crate::auth::password::hash(new).await?;
|
||||
users
|
||||
.update_one(
|
||||
filter,
|
||||
doc! {
|
||||
"$set": {
|
||||
"password_hash": new_hash,
|
||||
SESSIONS_VALID_FROM: unix_now(),
|
||||
"updated_at": crate::models::common::now(),
|
||||
},
|
||||
"$inc": { "version": 1 },
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
tracing::info!("Kullanıcı {user_id} şifresini değiştirdi; diğer oturumlar kapatıldı");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Hane ayarlarında değiştirilebilenler (`None` → dokunma)
|
||||
#[derive(Default)]
|
||||
pub struct HouseholdChanges {
|
||||
pub name: Option<String>,
|
||||
/// `Group::ALL` sırasıyla, toplamı 100
|
||||
pub split_pct: Option<[u32; 4]>,
|
||||
/// `default` | `previous` | `avg3`
|
||||
pub suggestion: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn update_household(
|
||||
db: &Database,
|
||||
household_id: Uuid,
|
||||
c: HouseholdChanges,
|
||||
) -> AppResult<()> {
|
||||
let mut set = doc! { "updated_at": crate::models::common::now() };
|
||||
if let Some(name) = c.name {
|
||||
set.insert("name", clean_text(&name, "Hane adı", 80)?);
|
||||
}
|
||||
if let Some(p) = c.split_pct {
|
||||
if p.iter().any(|v| *v > 100) || p.iter().sum::<u32>() != 100 {
|
||||
return Err(AppError::BadRequest("Yüzdelerin toplamı 100 olmalı".into()));
|
||||
}
|
||||
let mut pct = Document::new();
|
||||
for (g, v) in crate::models::group::Group::ALL.iter().zip(p) {
|
||||
pct.insert(g.as_str(), v as i32);
|
||||
}
|
||||
set.insert("settings.budget_split_pct", pct);
|
||||
}
|
||||
if let Some(m) = c.suggestion {
|
||||
if !["default", "previous", "avg3"].contains(&m.as_str()) {
|
||||
return Err(AppError::BadRequest("Geçersiz öneri yöntemi".into()));
|
||||
}
|
||||
set.insert("settings.budget_suggestion", m);
|
||||
}
|
||||
db.collection::<Document>(col::HOUSEHOLDS)
|
||||
.update_one(
|
||||
doc! { "_id": household_id, "deleted_at": null },
|
||||
doc! { "$set": set, "$inc": { "version": 1 } },
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Sahip bir üyeyi çıkarır: kullanıcı silinmiş işaretlenir (oturumu anında geçersiz),
|
||||
/// e-postası serbest kalır (aynı adresle yeniden davet edilebilir). Kayıtları hanede kalır.
|
||||
pub async fn remove_member(
|
||||
db: &Database,
|
||||
household_id: Uuid,
|
||||
owner_id: Uuid,
|
||||
member_id: Uuid,
|
||||
) -> AppResult<()> {
|
||||
if member_id == owner_id {
|
||||
return Err(AppError::BadRequest("Hane sahibi kendini çıkaramaz".into()));
|
||||
}
|
||||
let users = db.collection::<Document>(col::USERS);
|
||||
let filter = doc! { "_id": member_id, "household_id": household_id, "deleted_at": null };
|
||||
let user = users
|
||||
.find_one(filter.clone())
|
||||
.await?
|
||||
.ok_or(AppError::NotFound)?;
|
||||
if user.get_str("role") == Ok(Role::Owner.as_str()) {
|
||||
return Err(AppError::BadRequest("Hane sahibi çıkarılamaz".into()));
|
||||
}
|
||||
let email = user.get_str("email").unwrap_or_default().to_string();
|
||||
let ts = crate::models::common::now();
|
||||
users
|
||||
.update_one(
|
||||
filter,
|
||||
doc! {
|
||||
"$set": {
|
||||
"deleted_at": ts,
|
||||
"updated_at": ts,
|
||||
// Benzersiz e-posta indeksi: adres serbest kalsın, kayıtta iz kalsın
|
||||
"email": format!("{email}#removed-{member_id}"),
|
||||
"removed_email": email,
|
||||
},
|
||||
"$inc": { "version": 1 },
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
tracing::info!("Hane {household_id}: üye {member_id} çıkarıldı");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
//! Hesap ve hane ayarları: profil adı, şifre değiştirme (diğer oturumlar kapanır),
|
||||
//! hane adı + bütçe varsayılanları, üye çıkarma, yetkiler.
|
||||
|
||||
mod common;
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::http::{Method, StatusCode};
|
||||
use common::{Session, TestApp};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
async fn login(app: &TestApp, email: &str, password: &str) -> (StatusCode, Option<String>) {
|
||||
let (st, _, cookie) = app
|
||||
.send(
|
||||
Method::POST,
|
||||
"/auth/login",
|
||||
None,
|
||||
Some(json!({ "email": email, "password": password, "remember_me": true })),
|
||||
)
|
||||
.await;
|
||||
(st, cookie)
|
||||
}
|
||||
|
||||
async fn me(app: &TestApp, s: &Session) -> (StatusCode, Value) {
|
||||
app.get("/auth/me", s).await
|
||||
}
|
||||
|
||||
fn with_cookie(s: &Session, cookie: String) -> Session {
|
||||
Session {
|
||||
cookie,
|
||||
household_id: s.household_id.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn profile_name_and_password_change_closes_other_sessions() {
|
||||
let app = TestApp::new().await;
|
||||
let a = app.register("Ayse", None).await;
|
||||
let (_, m) = me(&app, &a).await;
|
||||
let email = m["user"]["email"].as_str().unwrap().to_string();
|
||||
|
||||
// Ad
|
||||
let (st, m) = app
|
||||
.patch("/auth/me", &a, json!({ "name": " Ayşe Ç. " }))
|
||||
.await;
|
||||
assert_eq!(
|
||||
(st, m["user"]["name"].as_str()),
|
||||
(StatusCode::OK, Some("Ayşe Ç."))
|
||||
);
|
||||
let (st, _) = app.patch("/auth/me", &a, json!({ "name": " " })).await;
|
||||
assert_eq!(st, StatusCode::BAD_REQUEST);
|
||||
|
||||
// İkinci cihaz
|
||||
let (st, other) = login(&app, &email, "test-sifre-123").await;
|
||||
assert_eq!(st, StatusCode::OK);
|
||||
let other = with_cookie(&a, other.unwrap());
|
||||
tokio::time::sleep(Duration::from_millis(1100)).await; // iat saniye hassasiyetinde
|
||||
|
||||
// Yanlış mevcut şifre / kısa / aynı şifre
|
||||
let pw = |cur: &str, new: &str| json!({ "current_password": cur, "new_password": new });
|
||||
let (st, b) = app
|
||||
.post(
|
||||
"/auth/me/password",
|
||||
&a,
|
||||
pw("yanlis-sifre", "yeni-sifre-456"),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::BAD_REQUEST);
|
||||
assert_eq!(b["error"]["message"], "Mevcut şifre hatalı");
|
||||
let (st, _) = app
|
||||
.post("/auth/me/password", &a, pw("test-sifre-123", "kisa"))
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::BAD_REQUEST);
|
||||
let (st, b) = app
|
||||
.post(
|
||||
"/auth/me/password",
|
||||
&a,
|
||||
pw("test-sifre-123", "test-sifre-123"),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::BAD_REQUEST, "{b}");
|
||||
|
||||
// Doğru → bu cihaz yeni çerezle devam, diğer cihaz düşer
|
||||
let (st, _, cookie) = app
|
||||
.send(
|
||||
Method::POST,
|
||||
"/auth/me/password",
|
||||
Some(&a.cookie),
|
||||
Some(pw("test-sifre-123", "yeni-sifre-456")),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::NO_CONTENT);
|
||||
let fresh = with_cookie(&a, cookie.expect("yeni oturum çerezi"));
|
||||
assert_eq!(me(&app, &fresh).await.0, StatusCode::OK);
|
||||
assert_eq!(
|
||||
me(&app, &other).await.0,
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"diğer cihaz"
|
||||
);
|
||||
assert_eq!(me(&app, &a).await.0, StatusCode::UNAUTHORIZED, "eski çerez");
|
||||
|
||||
// Giriş: eski şifre geçmez, yeni geçer
|
||||
assert_eq!(
|
||||
login(&app, &email, "test-sifre-123").await.0,
|
||||
StatusCode::UNAUTHORIZED
|
||||
);
|
||||
assert_eq!(
|
||||
login(&app, &email, "yeni-sifre-456").await.0,
|
||||
StatusCode::OK
|
||||
);
|
||||
|
||||
app.cleanup().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn wrong_current_password_is_rate_limited() {
|
||||
let app = TestApp::new().await;
|
||||
let a = app.register("Ayse", None).await;
|
||||
let body = json!({ "current_password": "yanlis", "new_password": "yeni-sifre-456" });
|
||||
for _ in 0..5 {
|
||||
let (st, _) = app.post("/auth/me/password", &a, body.clone()).await;
|
||||
assert_eq!(st, StatusCode::BAD_REQUEST);
|
||||
}
|
||||
let (st, _) = app
|
||||
.post(
|
||||
"/auth/me/password",
|
||||
&a,
|
||||
json!({ "current_password": "test-sifre-123", "new_password": "yeni-sifre-456" }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::TOO_MANY_REQUESTS, "doğru şifre de bekler");
|
||||
app.cleanup().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn household_settings_and_budget_default_split() {
|
||||
let app = TestApp::new().await;
|
||||
let owner = app.register("Sahip", None).await;
|
||||
let (_, inv) = app.post("/household/invites", &owner, json!({})).await;
|
||||
let member = app
|
||||
.register("Uye", Some(inv["code"].as_str().unwrap()))
|
||||
.await;
|
||||
|
||||
let (_, h) = app.get("/household", &owner).await;
|
||||
assert_eq!(
|
||||
h["settings"],
|
||||
json!({ "budget_split_pct": { "needs": 50, "wants": 30, "culture": 10, "unexpected": 10 }, "budget_suggestion": "default" })
|
||||
);
|
||||
|
||||
let update = json!({
|
||||
"name": "Çakır Ailesi",
|
||||
"budget_split_pct": { "needs": 60, "wants": 20, "culture": 10, "unexpected": 10 },
|
||||
"budget_suggestion": "avg3",
|
||||
});
|
||||
let (st, _) = app.patch("/household", &member, update.clone()).await;
|
||||
assert_eq!(st, StatusCode::FORBIDDEN, "üye değiştiremez");
|
||||
let (st, h) = app.patch("/household", &owner, update).await;
|
||||
assert_eq!(st, StatusCode::OK, "{h}");
|
||||
assert_eq!(h["name"], "Çakır Ailesi");
|
||||
assert_eq!(h["settings"]["budget_split_pct"]["needs"], 60);
|
||||
assert_eq!(h["settings"]["budget_suggestion"], "avg3");
|
||||
let (_, m) = me(&app, &member).await;
|
||||
assert_eq!(
|
||||
m["household"]["name"], "Çakır Ailesi",
|
||||
"üye de yeni adı görür"
|
||||
);
|
||||
|
||||
// Toplam 100 değil / geçersiz yöntem
|
||||
for bad in [
|
||||
json!({ "budget_split_pct": { "needs": 60, "wants": 30, "culture": 10, "unexpected": 10 } }),
|
||||
json!({ "budget_suggestion": "hepsi" }),
|
||||
json!({ "name": "" }),
|
||||
] {
|
||||
let (st, b) = app.patch("/household", &owner, bad).await;
|
||||
assert_eq!(st, StatusCode::BAD_REQUEST, "{b}");
|
||||
}
|
||||
|
||||
// Varsayılan öneri yeni yüzdeleri kullanır: harcanabilir 100.000 → 60/20/10/10
|
||||
let (_, p) = app
|
||||
.post(
|
||||
"/periods",
|
||||
&owner,
|
||||
json!({ "start_date": "2026-11-01", "end_date": "2026-12-01" }),
|
||||
)
|
||||
.await;
|
||||
let pid = p["id"].as_str().unwrap();
|
||||
app.post(
|
||||
&format!("/periods/{pid}/plan-items"),
|
||||
&owner,
|
||||
json!({ "kind": "income", "name": "Maaş", "amount": 100_000 }),
|
||||
)
|
||||
.await;
|
||||
let (_, sug) = app
|
||||
.get(
|
||||
&format!("/periods/{pid}/budget/suggestion?method=default"),
|
||||
&owner,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
sug["amounts"],
|
||||
json!({ "needs": 60_000, "wants": 20_000, "culture": 10_000, "unexpected": 10_000 })
|
||||
);
|
||||
|
||||
app.cleanup().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn owner_removes_member_who_can_be_invited_again() {
|
||||
let app = TestApp::new().await;
|
||||
let owner = app.register("Sahip", None).await;
|
||||
let (_, inv) = app.post("/household/invites", &owner, json!({})).await;
|
||||
let member = app
|
||||
.register("Uye", Some(inv["code"].as_str().unwrap()))
|
||||
.await;
|
||||
let (_, mm) = me(&app, &member).await;
|
||||
let member_id = mm["user"]["id"].as_str().unwrap().to_string();
|
||||
let member_email = mm["user"]["email"].as_str().unwrap().to_string();
|
||||
let (_, om) = me(&app, &owner).await;
|
||||
let owner_id = om["user"]["id"].as_str().unwrap().to_string();
|
||||
|
||||
// Üye kimseyi çıkaramaz; sahip kendini çıkaramaz; başka hane bulunamaz
|
||||
let (st, _) = app
|
||||
.delete(&format!("/household/members/{owner_id}"), &member)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::FORBIDDEN);
|
||||
let (st, _) = app
|
||||
.delete(&format!("/household/members/{owner_id}"), &owner)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::BAD_REQUEST);
|
||||
let stranger = app.register("Yabanci", None).await;
|
||||
let (st, _) = app
|
||||
.delete(&format!("/household/members/{member_id}"), &stranger)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::NOT_FOUND);
|
||||
|
||||
// Çıkar → oturumu anında düşer, listede yok, giriş yapamaz
|
||||
let (st, _) = app
|
||||
.delete(&format!("/household/members/{member_id}"), &owner)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::NO_CONTENT);
|
||||
assert_eq!(me(&app, &member).await.0, StatusCode::UNAUTHORIZED);
|
||||
let (_, h) = app.get("/household", &owner).await;
|
||||
assert_eq!(h["members"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(
|
||||
login(&app, &member_email, "test-sifre-123").await.0,
|
||||
StatusCode::UNAUTHORIZED
|
||||
);
|
||||
|
||||
// Aynı e-posta yeni davetle yeniden katılabilir
|
||||
let (_, inv) = app.post("/household/invites", &owner, json!({})).await;
|
||||
let (st, b, _) = app
|
||||
.send(
|
||||
Method::POST,
|
||||
"/auth/register",
|
||||
None,
|
||||
Some(json!({
|
||||
"name": "Uye",
|
||||
"email": member_email,
|
||||
"password": "test-sifre-123",
|
||||
"invite_code": inv["code"],
|
||||
})),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::CREATED, "{b}");
|
||||
|
||||
app.cleanup().await;
|
||||
}
|
||||
Reference in New Issue
Block a user