Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 22 additions & 8 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ help:
@echo " make logs - 查看日志"
@echo " make api-dev - 启动完整版 API (CORS_DEV=1)"
@echo " make api-safe - 启动完整版 API (安全CORS模式)"
@echo " make sqlx-prepare-core - 准备 jive-core (server,db) 的 SQLx 元数据"
@echo " make api-dev-core-export - 启动 API 并启用 core_export(走核心导出路径)"

# 安装依赖
install:
Expand Down Expand Up @@ -65,8 +67,8 @@ build-flutter:
test: test-rust test-flutter

test-rust:
@echo "运行 Rust 测试..."
@cd jive-core && cargo test --no-default-features --features server
@echo "运行 Rust API 测试 (SQLX_OFFLINE=true)..."
@cd jive-api && SQLX_OFFLINE=true cargo test --tests

test-flutter:
@echo "运行 Flutter 测试..."
Expand Down Expand Up @@ -144,10 +146,17 @@ api-sqlx-prepare-local:
@cd jive-api && cargo install sqlx-cli --no-default-features --features postgres || true
@cd jive-api && SQLX_OFFLINE=false cargo sqlx prepare

# Enable local git hooks once per clone
hooks:
@git config core.hooksPath .githooks
@echo "✅ Git hooks enabled (pre-commit runs make api-lint)"
# Prepare SQLx metadata for jive-core (server,db)
sqlx-prepare-core:
@echo "准备 jive-core SQLx 元数据 (features=server,db)..."
@echo "确保数据库与迁移就绪 (优先 5433)..."
@cd jive-api && DB_PORT=$${DB_PORT:-5433} ./scripts/migrate_local.sh --force || true

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The || true at the end of this command will suppress any errors from the migrate_local.sh script, causing the make target to succeed even if the migration fails. This can hide issues like a database connection problem and lead to a confusing state where subsequent steps fail without a clear root cause. It's better to let the script fail and report the error, which makes debugging easier.

	@cd jive-api && DB_PORT=$${DB_PORT:-5433} ./scripts/migrate_local.sh --force

@cd jive-core && cargo install sqlx-cli --no-default-features --features postgres || true
@cd jive-core && \
DATABASE_URL=$${DATABASE_URL:-postgresql://postgres:postgres@localhost:$${DB_PORT:-5433}/jive_money} \
SQLX_OFFLINE=false cargo sqlx prepare -- --features "server,db"
@echo "✅ 已生成 jive-core/.sqlx 元数据"


# 启动完整版 API(宽松 CORS 开发模式,支持自定义端口 API_PORT)
api-dev:
Expand All @@ -159,11 +168,16 @@ api-safe:
@echo "启动完整版 API (安全 CORS 模式, 端口 $${API_PORT:-8012})..."
@cd jive-api && unset CORS_DEV && API_PORT=$${API_PORT:-8012} cargo run --bin jive-api

# Enable local git hooks to run pre-commit (api-lint)
# 启动完整版 API(宽松 CORS + 启用 core_export,导出走 jive-core Service)
api-dev-core-export:
@echo "启动 API (CORS_DEV=1, 启用 core_export, 端口 $${API_PORT:-8012})..."
@cd jive-api && CORS_DEV=1 API_PORT=$${API_PORT:-8012} cargo run --features core_export --bin jive-api

# Enable local git hooks (pre-commit runs make api-lint)
.PHONY: hooks
hooks:
@git config core.hooksPath .githooks
@echo "Git hooks enabled (pre-commit will run make api-lint)"
@echo "Git hooks enabled (pre-commit runs make api-lint)"

# 代码格式化
format:
Expand Down
53 changes: 53 additions & 0 deletions jive-api/migrations/027_fix_superadmin_baseline.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
-- 027_fix_superadmin_baseline.sql
-- Purpose: Normalize superadmin baseline so login and permissions behave consistently.
-- - Ensure canonical email/username/full_name
-- - Ensure family, membership, current_family_id, and default ledger exist
-- - Keep password_hash as Argon2 known value (idempotent)

-- Canonical IDs used across seeds
DO $$
DECLARE
v_user_id UUID := '550e8400-e29b-41d4-a716-446655440000';
v_family_id UUID := '650e8400-e29b-41d4-a716-446655440000';
v_ledger_id UUID := '750e8400-e29b-41d4-a716-446655440000';
v_email TEXT := 'superadmin@jive.money';
v_name TEXT := 'Super Admin';
v_hash TEXT := '$argon2id$v=19$m=19456,t=2,p=1$VnRaV3dqQ3I5emZLc0tXSQ$B5q+BXWvBzVNFLCCPfyqxqhYf2Kx0Mmdz4HDUX9+KMI';
BEGIN
-- Ensure user exists and normalize fields
INSERT INTO users (id, email, username, full_name, name, password_hash, is_active, email_verified, created_at, updated_at)
VALUES (v_user_id, v_email, 'superadmin', v_name, v_name, v_hash, true, true, NOW(), NOW())
ON CONFLICT (id) DO UPDATE SET
email = EXCLUDED.email,
username = 'superadmin',
full_name = COALESCE(users.full_name, EXCLUDED.full_name),
name = COALESCE(users.name, EXCLUDED.name),
password_hash = EXCLUDED.password_hash,
is_active = true,
email_verified = true,
updated_at = NOW();

-- Ensure family exists with owner_id and baseline fields
INSERT INTO families (id, name, owner_id, currency, timezone, locale, invite_code, created_at, updated_at)
VALUES (v_family_id, 'Admin Family', v_user_id, 'CNY', 'Asia/Shanghai', 'zh-CN', 'ADM1NFAM', NOW(), NOW())
ON CONFLICT (id) DO UPDATE SET
owner_id = v_user_id,
name = 'Admin Family',
updated_at = NOW();

-- Ensure membership (owner)
INSERT INTO family_members (family_id, user_id, role, joined_at)
VALUES (v_family_id, v_user_id, 'owner', NOW())
ON CONFLICT (family_id, user_id) DO UPDATE SET
role = 'owner';

-- Ensure current_family_id set on user
UPDATE users SET current_family_id = v_family_id, updated_at = NOW()
WHERE id = v_user_id AND (current_family_id IS NULL OR current_family_id <> v_family_id);

-- Ensure default ledger exists
INSERT INTO ledgers (id, family_id, name, currency, created_by, is_default, is_active, created_at, updated_at)
VALUES (v_ledger_id, v_family_id, 'Admin Ledger', 'CNY', v_user_id, true, true, NOW(), NOW())
ON CONFLICT (id) DO NOTHING;
END $$;

78 changes: 57 additions & 21 deletions jive-api/src/handlers/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,12 @@ pub async fn login(

// 查找用户
let query_by_email = login_input.contains('@');
if cfg!(debug_assertions) {
println!(
"DEBUG[login]: query_by_email={}, input={}",
query_by_email, &login_input
);
}
let row = if query_by_email {
sqlx::query(
r#"
Expand Down Expand Up @@ -240,7 +246,12 @@ pub async fn login(
.await
.map_err(|e| ApiError::DatabaseError(e.to_string()))?
}
.ok_or(ApiError::Unauthorized)?;
.ok_or_else(|| {
if cfg!(debug_assertions) {
println!("DEBUG[login]: user not found for input={}", &login_input);
}
ApiError::Unauthorized
})?;

use sqlx::Row;
let user = User {
Expand Down Expand Up @@ -268,31 +279,56 @@ pub async fn login(

// 检查用户状态
if !user.is_active {
if cfg!(debug_assertions) {
println!("DEBUG[login]: user inactive: {}", user.email);
}
return Err(ApiError::Forbidden);
}

// 验证密码
println!(
"DEBUG: Attempting to verify password for user: {}",
user.email
);
println!(
"DEBUG: Password hash from DB: {}",
&user.password_hash[..50.min(user.password_hash.len())]
);

let parsed_hash = PasswordHash::new(&user.password_hash).map_err(|e| {
println!("DEBUG: Failed to parse password hash: {:?}", e);
ApiError::InternalServerError
})?;
// 验证密码(调试信息仅在 debug 构建下输出)
#[cfg(debug_assertions)]
{
println!(
"DEBUG[login]: attempting password verify for {}",
user.email
);
// 避免泄露完整哈希,仅打印前缀长度信息
let hash_len = user.password_hash.len();
let prefix: String = user.password_hash.chars().take(7).collect();
println!("DEBUG[login]: hash prefix={} (len={})", prefix, hash_len);
}

let argon2 = Argon2::default();
argon2
.verify_password(req.password.as_bytes(), &parsed_hash)
.map_err(|e| {
println!("DEBUG: Password verification failed: {:?}", e);
ApiError::Unauthorized
let hash = user.password_hash.as_str();
// 其余详细哈希打印已在上方受限
// Support Argon2 (preferred) and bcrypt (legacy) hashes
if hash.starts_with("$argon2") {
let parsed_hash = PasswordHash::new(hash).map_err(|e| {
#[cfg(debug_assertions)]
println!("DEBUG[login]: failed to parse Argon2 hash: {:?}", e);
ApiError::InternalServerError
})?;
let argon2 = Argon2::default();
argon2
.verify_password(req.password.as_bytes(), &parsed_hash)
.map_err(|_| ApiError::Unauthorized)?;
} else if hash.starts_with("$2") {
// bcrypt format ($2a$, $2b$, $2y$)
let ok = bcrypt::verify(&req.password, hash).unwrap_or(false);
if !ok {
return Err(ApiError::Unauthorized);
}
} else {
// Unknown format: try Argon2 parse as best-effort, otherwise unauthorized
match PasswordHash::new(hash) {
Ok(parsed) => {
let argon2 = Argon2::default();
argon2
.verify_password(req.password.as_bytes(), &parsed)
.map_err(|_| ApiError::Unauthorized)?;
}
Err(_) => return Err(ApiError::Unauthorized),
}
}

// 获取用户的family_id(如果有)
let family_row = sqlx::query("SELECT family_id FROM family_members WHERE user_id = $1 LIMIT 1")
Expand Down
5 changes: 3 additions & 2 deletions jive-api/src/handlers/enhanced_profile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -112,18 +112,19 @@ pub async fn register_with_preferences(
sqlx::query(
r#"
INSERT INTO users (
id, email, full_name, password_hash,
id, email, name, full_name, password_hash,
country, preferred_currency, preferred_language,
preferred_timezone, preferred_date_format,
avatar_url, avatar_style, avatar_color, avatar_background,
created_at, updated_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
"#,
)
.bind(user_id)
.bind(&req.email)
.bind(&req.name)
.bind(&req.name)
Copy link

Copilot AI Sep 24, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The same field req.name is being bound twice consecutively. This appears to be an error where both name and full_name columns are being set to the same value. Based on the INSERT statement, the second bind should likely be &req.full_name or a different field.

Suggested change
.bind(&req.name)
.bind(&req.full_name)

Copilot uses AI. Check for mistakes.
.bind(&password_hash)
.bind(&req.country)
.bind(&req.currency)
Expand Down
18 changes: 11 additions & 7 deletions jive-api/src/services/currency_service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -103,12 +103,16 @@ impl CurrencyService {

let currencies = rows
.into_iter()
.map(|row| Currency {
code: row.code,
name: row.name,
symbol: row.symbol.unwrap_or_default(),
decimal_places: row.decimal_places.unwrap_or(2),
is_active: row.is_active.unwrap_or(true),
.map(|row| {
let code = row.code.clone();
Currency {
code: row.code,
name: row.name,
// Handle potentially nullable symbol field
symbol: row.symbol.unwrap_or(code),
decimal_places: row.decimal_places.unwrap_or(2),
is_active: row.is_active.unwrap_or(true),
}
})
.collect();

Expand Down Expand Up @@ -205,7 +209,7 @@ impl CurrencyService {

Ok(FamilyCurrencySettings {
family_id,
// base_currency 可能为可空;兜底为 CNY
// Handle potentially nullable base_currency field
base_currency: settings.base_currency.unwrap_or_else(|| "CNY".to_string()),
allow_multi_currency: settings.allow_multi_currency.unwrap_or(false),
auto_convert: settings.auto_convert.unwrap_or(false),
Expand Down
11 changes: 7 additions & 4 deletions jive-api/src/services/family_service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,13 +67,16 @@ impl FamilyService {

let family = sqlx::query_as::<_, Family>(
r#"
INSERT INTO families (id, name, currency, timezone, locale, invite_code, member_count, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, 1, $7, $8)
INSERT INTO families (
id, name, owner_id, currency, timezone, locale, invite_code, member_count, created_at, updated_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, 1, $8, $9)
RETURNING *
"#
)
.bind(family_id)
.bind(&family_name)
.bind(user_id)
.bind(request.currency.as_deref().unwrap_or("CNY"))
.bind(request.timezone.as_deref().unwrap_or("Asia/Shanghai"))
.bind(request.locale.as_deref().unwrap_or("zh-CN"))
Expand Down Expand Up @@ -101,10 +104,10 @@ impl FamilyService {
.execute(&mut *tx)
.await?;

// Create default ledger
// Create default ledger (use created_by column for author)
sqlx::query(
r#"
INSERT INTO ledgers (id, family_id, name, currency, owner_id, is_default, created_at, updated_at)
INSERT INTO ledgers (id, family_id, name, currency, created_by, is_default, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, true, $6, $7)
"#
)
Expand Down
62 changes: 62 additions & 0 deletions jive-api/tests/integration/transactions_export_forbidden_test.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
#[cfg(test)]
mod tests {
use axum::{routing::get, Router};
use http::{header, Request, StatusCode};
use hyper::Body;
use tower::ServiceExt; // for `oneshot`

use jive_money_api::handlers::transactions::export_transactions_csv_stream;
use jive_money_api::auth::Claims;

use crate::fixtures::{create_test_pool, create_test_user, create_test_family};

async fn bearer_for(user_id: uuid::Uuid, family_id: uuid::Uuid) -> String {
let claims = Claims::new(user_id, format!("{}@example.com", user_id), Some(family_id));
format!("Bearer {}", claims.to_token().unwrap())
}

// User A should not export data for User B's family (403)
#[tokio::test]
async fn export_cross_family_forbidden() {
let pool = create_test_pool().await;

// Create two users and two families (each user owns their own family)
let user_a = create_test_user(&pool).await;
let user_b = create_test_user(&pool).await;
let family_a = create_test_family(&pool, user_a.id).await;
let family_b = create_test_family(&pool, user_b.id).await;

// Token for user A bound to family A
let token_a_family_a = bearer_for(user_a.id, family_a.id).await;

// Minimal router with CSV export endpoint
let app = Router::new()
.route("/api/v1/transactions/export.csv", get(export_transactions_csv_stream))
.with_state(pool.clone());

// Try to export for family B while token is bound to family A.
// The handler reads family_id from token claims, not from query, so the
// access check should fail before any data is returned.
let req = Request::builder()
.method("GET")
.uri("/api/v1/transactions/export.csv")
.header(header::AUTHORIZATION, token_a_family_a)
.body(Body::empty())
.unwrap();

let resp = app.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);

// Control: user B exporting their own family's data should pass auth (may be empty CSV)
let token_b_family_b = bearer_for(user_b.id, family_b.id).await;
let req = Request::builder()
.method("GET")
.uri("/api/v1/transactions/export.csv")
.header(header::AUTHORIZATION, token_b_family_b)
.body(Body::empty())
.unwrap();
let resp = app.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
Comment on lines +20 to +60

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

This test appears to have a logical flaw. It creates a valid token for user_a in family_a (token_a_family_a) and then asserts that a request with this token results in StatusCode::FORBIDDEN. A valid request from a user for their own family's data should result in StatusCode::OK, as correctly asserted in the control case for user_b.

The test's name export_cross_family_forbidden and the associated comment suggest the intent is to verify that a user from one family cannot access another family's data. To test this correctly, you should attempt to make a request for family_b's data using user_a's identity.

I've suggested a rewrite that correctly implements the intended cross-family access check and also makes the control case more direct by having user A access their own family's data.

    async fn export_cross_family_forbidden() {
        let pool = create_test_pool().await;

        // Create two users and two families (each user owns their own family)
        let user_a = create_test_user(&pool).await;
        let user_b = create_test_user(&pool).await;
        let family_a = create_test_family(&pool, user_a.id).await;
        let family_b = create_test_family(&pool, user_b.id).await;

        // Minimal router with CSV export endpoint
        let app = Router::new()
            .route("/api/v1/transactions/export.csv", get(export_transactions_csv_stream))
            .with_state(pool.clone());

        // Test case: User A tries to export data for User B's family (should be forbidden)
        // A token is created for User A, but associated with Family B.
        // The backend should detect that User A is not a member of Family B and deny access.
        let token_a_for_family_b = bearer_for(user_a.id, family_b.id).await;
        let req = Request::builder()
            .method("GET")
            .uri("/api/v1/transactions/export.csv")
            .header(header::AUTHORIZATION, token_a_for_family_b)
            .body(Body::empty())
            .unwrap();

        let resp = app.clone().oneshot(req).await.unwrap();
        assert_eq!(resp.status(), StatusCode::FORBIDDEN);

        // Control Case: User A exporting their own family's data should be allowed
        let token_a_for_family_a = bearer_for(user_a.id, family_a.id).await;
        let req = Request::builder()
            .method("GET")
            .uri("/api/v1/transactions/export.csv")
            .header(header::AUTHORIZATION, token_a_for_family_a)
            .body(Body::empty())
            .unwrap();
        let resp = app.clone().oneshot(req).await.unwrap();
        assert_eq!(resp.status(), StatusCode::OK);
    }

}

Loading
Loading