fixup
This commit is contained in:
Vendored
BIN
Binary file not shown.
+1300
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,203 @@
|
||||
//! 帳戶控制器:`/api/accounts` 資源的 REST CRUD。
|
||||
|
||||
use axum::extract::{Path, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::routing::get;
|
||||
use axum::{Json, Router};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::models::account::{Account, AccountInput};
|
||||
use crate::state::AppState;
|
||||
use crate::storage::{self, StorageError};
|
||||
|
||||
/// 控制器層錯誤:統一轉為帶 JSON 錯誤體的 HTTP 響應。
|
||||
#[derive(Debug)]
|
||||
enum ApiError {
|
||||
/// 指定的資源不存在(404)
|
||||
NotFound,
|
||||
/// 唯一欄位衝突(409)
|
||||
Conflict,
|
||||
/// 輸入驗證失敗(400),內為可直接展示的錯誤訊息
|
||||
BadRequest(String),
|
||||
/// 存儲層錯誤(503 / 500)
|
||||
Storage(StorageError),
|
||||
}
|
||||
|
||||
impl From<StorageError> for ApiError {
|
||||
fn from(err: StorageError) -> Self {
|
||||
if err.is_unique_violation() {
|
||||
ApiError::Conflict
|
||||
} else {
|
||||
ApiError::Storage(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for ApiError {
|
||||
fn into_response(self) -> Response {
|
||||
let (status, message) = match self {
|
||||
ApiError::NotFound => (StatusCode::NOT_FOUND, "帳戶不存在".to_string()),
|
||||
ApiError::Conflict => (
|
||||
StatusCode::CONFLICT,
|
||||
"username 或 email 已被其他帳戶使用".to_string(),
|
||||
),
|
||||
ApiError::BadRequest(message) => (StatusCode::BAD_REQUEST, message),
|
||||
ApiError::Storage(err) => {
|
||||
tracing::error!("帳戶存儲層錯誤:{err}");
|
||||
if matches!(err, StorageError::Pool(_)) {
|
||||
(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"資料庫暫時不可用".to_string(),
|
||||
)
|
||||
} else {
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "內部錯誤".to_string())
|
||||
}
|
||||
}
|
||||
};
|
||||
(status, Json(json!({ "error": message }))).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
/// 列出所有帳戶
|
||||
async fn list(State(state): State<AppState>) -> Result<Json<Vec<Account>>, ApiError> {
|
||||
let accounts = storage::account::list(&state.db).await?;
|
||||
Ok(Json(accounts))
|
||||
}
|
||||
|
||||
/// 建立帳戶;輸入會先經 [`AccountInput::normalized`] 驗證與正規化
|
||||
async fn create(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<AccountInput>,
|
||||
) -> Result<(StatusCode, Json<Account>), ApiError> {
|
||||
let payload = payload.normalized().map_err(ApiError::BadRequest)?;
|
||||
let account = storage::account::create(&state.db, payload).await?;
|
||||
Ok((StatusCode::CREATED, Json(account)))
|
||||
}
|
||||
|
||||
/// 取得單一帳戶
|
||||
async fn show(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
) -> Result<Json<Account>, ApiError> {
|
||||
storage::account::get_by_id(&state.db, id)
|
||||
.await?
|
||||
.map(Json)
|
||||
.ok_or(ApiError::NotFound)
|
||||
}
|
||||
|
||||
/// 更新帳戶的全部可編輯欄位(username / email / display_name / is_active)
|
||||
async fn update(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
Json(payload): Json<AccountInput>,
|
||||
) -> Result<Json<Account>, ApiError> {
|
||||
let payload = payload.normalized().map_err(ApiError::BadRequest)?;
|
||||
storage::account::update(&state.db, id, payload)
|
||||
.await?
|
||||
.map(Json)
|
||||
.ok_or(ApiError::NotFound)
|
||||
}
|
||||
|
||||
/// 刪除帳戶
|
||||
async fn remove(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
match storage::account::delete(&state.db, id).await? {
|
||||
true => Ok(StatusCode::NO_CONTENT),
|
||||
false => Err(ApiError::NotFound),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/api/accounts", get(list).post(create))
|
||||
.route("/api/accounts/{id}", get(show).put(update).delete(remove))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::storage::testutil;
|
||||
|
||||
fn payload(username: &str, email: &str) -> AccountInput {
|
||||
AccountInput {
|
||||
username: username.into(),
|
||||
email: email.into(),
|
||||
display_name: None,
|
||||
is_active: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn accounts_crud_via_handlers() {
|
||||
let (pool, path) = testutil::test_pool("accounts-api").await;
|
||||
let state = State(AppState {
|
||||
db: pool.clone(),
|
||||
sessions: crate::session::SessionStore::new(),
|
||||
});
|
||||
|
||||
// 建立:輸入被 trim、display_name 默認同 username → 201
|
||||
let (status, Json(created)) =
|
||||
create(state.clone(), Json(payload(" alice ", "alice@example.com")))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(status, StatusCode::CREATED);
|
||||
assert_eq!(created.username, "alice");
|
||||
assert_eq!(created.display_name, "alice");
|
||||
|
||||
// 密碼雜湊永不外洩到序列化結果
|
||||
let json = serde_json::to_value(&created).unwrap();
|
||||
assert!(json.get("password_hash").is_none());
|
||||
|
||||
// 列表包含新帳戶(另有初始化自動建立的 admin 在前)
|
||||
let Json(accounts) = list(state.clone()).await.unwrap();
|
||||
assert_eq!(accounts.len(), 2);
|
||||
assert_eq!(accounts[0].username, "admin");
|
||||
assert_eq!(accounts[1].id, created.id);
|
||||
|
||||
// username 重複 → 409;輸入驗證失敗 → 400
|
||||
let err = create(state.clone(), Json(payload("alice", "bob@example.com")))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(err.into_response().status(), StatusCode::CONFLICT);
|
||||
let err = create(state.clone(), Json(payload("", "x@example.com")))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(err.into_response().status(), StatusCode::BAD_REQUEST);
|
||||
|
||||
// 查詢:存在 / 不存在 → 200 / 404
|
||||
let Json(found) = show(state.clone(), Path(created.id)).await.unwrap();
|
||||
assert_eq!(found.id, created.id);
|
||||
let err = show(state.clone(), Path(9999)).await.unwrap_err();
|
||||
assert_eq!(err.into_response().status(), StatusCode::NOT_FOUND);
|
||||
|
||||
// 更新:欄位被覆寫
|
||||
let Json(updated) = update(
|
||||
state.clone(),
|
||||
Path(created.id),
|
||||
Json(AccountInput {
|
||||
username: "alice".into(),
|
||||
email: "alice@example.com".into(),
|
||||
display_name: Some("Alice".into()),
|
||||
is_active: Some(false),
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(updated.display_name, "Alice");
|
||||
assert!(!updated.is_active);
|
||||
|
||||
// 刪除:→ 204,再刪 → 404
|
||||
assert_eq!(
|
||||
remove(state.clone(), Path(created.id)).await.unwrap(),
|
||||
StatusCode::NO_CONTENT
|
||||
);
|
||||
let err = remove(state, Path(created.id)).await.unwrap_err();
|
||||
assert_eq!(err.into_response().status(), StatusCode::NOT_FOUND);
|
||||
|
||||
pool.close();
|
||||
testutil::cleanup(&path);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,702 @@
|
||||
//! 認證控制器:登入頁面、會話的建立與銷毀,以及變更密碼。
|
||||
//!
|
||||
//! - `GET /login` 渲染登入表單;已登入者直接回到首頁
|
||||
//! - `POST /login` 校驗用戶名 / 密碼(Argon2),成功後建立會話並
|
||||
//! 以 `Set-Cookie` 下發 token,重定向回首頁
|
||||
//! - `POST /logout` 銷毀會話、清除 Cookie,回到登入頁
|
||||
//! - `GET /password` 變更密碼頁(`CurrentUser` 保證已登入)
|
||||
//! - `POST /password` 校驗原密碼與新密碼規則後更新密碼;成功即銷毀
|
||||
//! 該帳戶的全部會話(含當前)、清除 Cookie,導向 `/login?changed=1`
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use askama::Template;
|
||||
use axum::Router;
|
||||
use axum::extract::{Form, Query, State};
|
||||
use axum::http::{HeaderValue, StatusCode, header};
|
||||
use axum::response::{Html, IntoResponse, Redirect, Response};
|
||||
use axum::routing::{get, post};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::models::account::{Account, hash_password, verify_password};
|
||||
use crate::session::{CurrentUser, clear_cookie_header, set_cookie_header, token_from_headers};
|
||||
use crate::state::AppState;
|
||||
use crate::storage;
|
||||
|
||||
/// 登入表單載荷(`application/x-www-form-urlencoded`)
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct LoginForm {
|
||||
username: String,
|
||||
password: String,
|
||||
}
|
||||
|
||||
/// 登入頁查詢參數:`?changed=1` 表示剛完成密碼變更,顯示成功提示
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
struct LoginQuery {
|
||||
changed: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "login.html")]
|
||||
struct LoginTemplate {
|
||||
error: Option<String>,
|
||||
notice: Option<String>,
|
||||
}
|
||||
|
||||
/// 渲染登入頁;`status` 用於失敗重渲染時上報 401
|
||||
fn render_login(error: Option<String>, notice: Option<String>, status: StatusCode) -> Response {
|
||||
let tpl = LoginTemplate { error, notice };
|
||||
match tpl.render() {
|
||||
Ok(html) => (status, Html(html)).into_response(),
|
||||
Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, "登入頁渲染失敗").into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 登入頁;已持有效會話者直接回到首頁
|
||||
async fn login_page(
|
||||
State(state): State<AppState>,
|
||||
Query(query): Query<LoginQuery>,
|
||||
headers: axum::http::HeaderMap,
|
||||
) -> Response {
|
||||
match token_from_headers(&headers) {
|
||||
Some(token) if state.sessions.get(&token).is_some() => Redirect::to("/").into_response(),
|
||||
_ => render_login(
|
||||
None,
|
||||
query
|
||||
.changed
|
||||
.is_some()
|
||||
.then(|| "密碼已更新,請以新密碼重新登入。".to_string()),
|
||||
StatusCode::OK,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// 提交登入:校驗帳密,成功則建立會話並下發 Cookie
|
||||
async fn login(State(state): State<AppState>, Form(input): Form<LoginForm>) -> Response {
|
||||
let username = input.username.trim().to_string();
|
||||
let account = match storage::account::get_by_username(&state.db, &username).await {
|
||||
Ok(account) => account,
|
||||
Err(err) => {
|
||||
tracing::error!("登入查詢失敗:{err}");
|
||||
return render_login(
|
||||
Some("系統暫時不可用,請稍後再試".into()),
|
||||
None,
|
||||
StatusCode::OK,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
if !credentials_valid(account.as_ref(), &input.password) {
|
||||
tracing::warn!("登入失敗:username={username:?}");
|
||||
return render_login(
|
||||
Some("用戶名或密碼不正確".into()),
|
||||
None,
|
||||
StatusCode::UNAUTHORIZED,
|
||||
);
|
||||
}
|
||||
|
||||
let account = account.expect("credentials_valid 為 true 時帳號必存在");
|
||||
let token = state
|
||||
.sessions
|
||||
.insert(account.id, &account.username, &account.display_name);
|
||||
tracing::info!("用戶登入成功:username={}", account.username);
|
||||
|
||||
let mut response = Redirect::to("/").into_response();
|
||||
append_set_cookie(&mut response, set_cookie_header(&token));
|
||||
response
|
||||
}
|
||||
|
||||
/// 登出:銷毀會話並清除 Cookie
|
||||
async fn logout(State(state): State<AppState>, headers: axum::http::HeaderMap) -> Response {
|
||||
if let Some(token) = token_from_headers(&headers) {
|
||||
state.sessions.remove(&token);
|
||||
}
|
||||
let mut response = Redirect::to("/login").into_response();
|
||||
append_set_cookie(&mut response, clear_cookie_header());
|
||||
response
|
||||
}
|
||||
|
||||
/// 新密碼的長度下限(字符數);與密碼頁表單的 `minlength` 一致
|
||||
const PASSWORD_MIN_CHARS: usize = 8;
|
||||
/// 新密碼的長度上限(字符數);與密碼頁表單的 `maxlength` 一致
|
||||
const PASSWORD_MAX_CHARS: usize = 128;
|
||||
|
||||
/// 變更密碼表單載荷(`application/x-www-form-urlencoded`)
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct PasswordForm {
|
||||
current_password: String,
|
||||
new_password: String,
|
||||
confirm_password: String,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "password.html")]
|
||||
struct PasswordTemplate {
|
||||
user_display_name: String,
|
||||
user_username: String,
|
||||
/// 側邊導航欄的當前頁標記;主導航無密碼項,僅標記所屬頁
|
||||
nav_active: &'static str,
|
||||
/// 失敗重渲染時的錯誤訊息
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
/// 渲染變更密碼頁;`status` 用於失敗重渲染時上報 4xx
|
||||
fn render_password(user: &CurrentUser, error: Option<String>, status: StatusCode) -> Response {
|
||||
let tpl = PasswordTemplate {
|
||||
user_display_name: user.display_name.clone(),
|
||||
user_username: user.username.clone(),
|
||||
nav_active: "password",
|
||||
error,
|
||||
};
|
||||
match tpl.render() {
|
||||
Ok(html) => (status, Html(html)).into_response(),
|
||||
Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, "密碼頁渲染失敗").into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 變更密碼頁;`CurrentUser` 提取器保證已登入,未登入者被導向 `/login`
|
||||
async fn password_page(user: CurrentUser) -> Response {
|
||||
render_password(&user, None, StatusCode::OK)
|
||||
}
|
||||
|
||||
/// 提交變更密碼:校驗原密碼與新密碼規則,成功後寫入新雜湊、銷毀該
|
||||
/// 帳戶的全部會話(含當前)並導向 `/login?changed=1`,以新密碼重新登入。
|
||||
async fn change_password(
|
||||
State(state): State<AppState>,
|
||||
user: CurrentUser,
|
||||
Form(input): Form<PasswordForm>,
|
||||
) -> Response {
|
||||
if let Err(message) = validate_new_password(&input.new_password, &input.confirm_password) {
|
||||
return render_password(&user, Some(message), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
let account = match storage::account::get_by_id(&state.db, user.user_id).await {
|
||||
Ok(account) => account,
|
||||
Err(err) => {
|
||||
tracing::error!("變更密碼查詢失敗:{err}");
|
||||
return render_password(
|
||||
&user,
|
||||
Some("系統暫時不可用,請稍後再試".into()),
|
||||
StatusCode::OK,
|
||||
);
|
||||
}
|
||||
};
|
||||
let Some(account) = account else {
|
||||
// 帳號已被刪除:殘留會話一併銷毀,回到登入頁
|
||||
state.sessions.remove_all_for_user(user.user_id);
|
||||
return expired_session_redirect();
|
||||
};
|
||||
|
||||
if !account.is_active {
|
||||
return render_password(
|
||||
&user,
|
||||
Some("帳號已停用,無法變更密碼".into()),
|
||||
StatusCode::FORBIDDEN,
|
||||
);
|
||||
}
|
||||
if !verify_password(&input.current_password, &account.password_hash) {
|
||||
tracing::warn!("變更密碼失敗(原密碼不正確):username={}", user.username);
|
||||
return render_password(&user, Some("原密碼不正確".into()), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
if verify_password(&input.new_password, &account.password_hash) {
|
||||
return render_password(
|
||||
&user,
|
||||
Some("新密碼不可與原密碼相同".into()),
|
||||
StatusCode::BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
|
||||
let new_hash = match hash_password(&input.new_password) {
|
||||
Ok(hash) => hash,
|
||||
Err(err) => {
|
||||
tracing::error!("新密碼雜湊失敗:{err}");
|
||||
return render_password(
|
||||
&user,
|
||||
Some("系統暫時不可用,請稍後再試".into()),
|
||||
StatusCode::OK,
|
||||
);
|
||||
}
|
||||
};
|
||||
match storage::account::update_password(&state.db, account.id, &new_hash).await {
|
||||
Ok(true) => {}
|
||||
Ok(false) => {
|
||||
// 帳號在驗證後被刪除:與查無帳號同等處理
|
||||
state.sessions.remove_all_for_user(account.id);
|
||||
return expired_session_redirect();
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::error!("變更密碼寫入失敗:{err}");
|
||||
return render_password(
|
||||
&user,
|
||||
Some("系統暫時不可用,請稍後再試".into()),
|
||||
StatusCode::OK,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!("密碼已變更:username={}", user.username);
|
||||
state.sessions.remove_all_for_user(account.id);
|
||||
let mut response = Redirect::to("/login?changed=1").into_response();
|
||||
append_set_cookie(&mut response, clear_cookie_header());
|
||||
response
|
||||
}
|
||||
|
||||
/// 帳號已不存在時的回應:清除 Cookie 並回到登入頁
|
||||
fn expired_session_redirect() -> Response {
|
||||
let mut response = Redirect::to("/login").into_response();
|
||||
append_set_cookie(&mut response, clear_cookie_header());
|
||||
response
|
||||
}
|
||||
|
||||
/// 校驗新密碼:兩次輸入一致,長度介於 [`PASSWORD_MIN_CHARS`] 與
|
||||
/// [`PASSWORD_MAX_CHARS`] 之間。`Err` 的訊息可直接展示在頁面錯誤條。
|
||||
fn validate_new_password(new: &str, confirm: &str) -> Result<(), String> {
|
||||
if new != confirm {
|
||||
return Err("兩次輸入的新密碼不一致".into());
|
||||
}
|
||||
let length = new.chars().count();
|
||||
if length < PASSWORD_MIN_CHARS {
|
||||
return Err(format!("新密碼長度至少 {PASSWORD_MIN_CHARS} 個字符"));
|
||||
}
|
||||
if length > PASSWORD_MAX_CHARS {
|
||||
return Err(format!("新密碼長度不可超過 {PASSWORD_MAX_CHARS} 個字符"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 校驗登入:帳號需存在、啟用且密碼相符。
|
||||
///
|
||||
/// 帳號不存在時仍對一個固定雜湊跑一次 Argon2 驗證,使「用戶不存在」
|
||||
/// 與「密碼錯誤」的回應時間相近,避免攻擊者以時間差列舉有效用戶名。
|
||||
fn credentials_valid(account: Option<&Account>, password: &str) -> bool {
|
||||
match account {
|
||||
Some(account) => account.is_active && verify_password(password, &account.password_hash),
|
||||
None => {
|
||||
static DUMMY_HASH: OnceLock<String> = OnceLock::new();
|
||||
let hash = DUMMY_HASH
|
||||
.get_or_init(|| hash_password("alterdb-dummy-password").unwrap_or_default());
|
||||
let _ = verify_password(password, hash);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn append_set_cookie(response: &mut Response, value: String) {
|
||||
let header = HeaderValue::from_str(&value).expect("會話 Cookie 值應為合法標頭");
|
||||
response.headers_mut().append(header::SET_COOKIE, header);
|
||||
}
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/login", get(login_page).post(login))
|
||||
.route("/logout", post(logout))
|
||||
.route("/password", get(password_page).post(change_password))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::session::SessionStore;
|
||||
use crate::storage::testutil;
|
||||
use axum::http::header::{COOKIE, LOCATION};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// 建立測試狀態(含初始化完畢、已播種 admin 的暫存資料庫)
|
||||
async fn test_state(label: &str) -> (AppState, deadpool_sqlite::Pool, PathBuf) {
|
||||
let (pool, path) = testutil::test_pool(label).await;
|
||||
let state = AppState {
|
||||
db: pool.clone(),
|
||||
sessions: SessionStore::new(),
|
||||
};
|
||||
(state, pool, path)
|
||||
}
|
||||
|
||||
/// 登入成功後回應中的會話 token
|
||||
async fn login_token(state: &AppState, username: &str, password: &str) -> Option<String> {
|
||||
let response = login(
|
||||
State(state.clone()),
|
||||
Form(LoginForm {
|
||||
username: username.into(),
|
||||
password: password.into(),
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
if response.status() != StatusCode::SEE_OTHER {
|
||||
return None;
|
||||
}
|
||||
response
|
||||
.headers()
|
||||
.get(header::SET_COOKIE)?
|
||||
.to_str()
|
||||
.ok()?
|
||||
.split(';')
|
||||
.next()?
|
||||
.strip_prefix("alterdb_session=")
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
/// 讀取回應主體為字串(斷言頁面內容用)
|
||||
async fn response_body(response: Response) -> String {
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
String::from_utf8(body.to_vec()).unwrap()
|
||||
}
|
||||
|
||||
/// 以既有會話構造 CurrentUser(模擬提取器的解析結果)
|
||||
fn current_user(state: &AppState, token: &str) -> CurrentUser {
|
||||
let session = state.sessions.get(token).expect("會話應存在");
|
||||
CurrentUser {
|
||||
user_id: session.user_id,
|
||||
username: session.username,
|
||||
display_name: session.display_name,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn login_success_sets_session_cookie() {
|
||||
let (state, pool, path) = test_state("auth-login-ok").await;
|
||||
|
||||
let response = login(
|
||||
State(state.clone()),
|
||||
Form(LoginForm {
|
||||
username: " admin ".into(), // 用戶名會被 trim
|
||||
password: "admin".into(),
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response.status(), StatusCode::SEE_OTHER);
|
||||
assert_eq!(response.headers().get(LOCATION).unwrap(), "/");
|
||||
|
||||
let cookie = response.headers().get(header::SET_COOKIE).unwrap();
|
||||
let cookie = cookie.to_str().unwrap();
|
||||
assert!(cookie.starts_with("alterdb_session="));
|
||||
assert!(cookie.contains("HttpOnly"));
|
||||
assert!(cookie.contains("SameSite=Lax"));
|
||||
|
||||
// Cookie 對應到有效會話
|
||||
let token = cookie.split(';').next().unwrap();
|
||||
let token = token.strip_prefix("alterdb_session=").unwrap();
|
||||
let session = state.sessions.get(token).expect("會話應已建立");
|
||||
assert_eq!(session.username, "admin");
|
||||
assert_eq!(session.display_name, "Administrator");
|
||||
|
||||
pool.close();
|
||||
testutil::cleanup(&path);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn login_failure_re_renders_form_with_401() {
|
||||
let (state, pool, path) = test_state("auth-login-bad").await;
|
||||
|
||||
for (username, password) in [("admin", "wrong"), ("nobody", "whatever")] {
|
||||
let response = login(
|
||||
State(state.clone()),
|
||||
Form(LoginForm {
|
||||
username: username.into(),
|
||||
password: password.into(),
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED, "{username}");
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let html = String::from_utf8(body.to_vec()).unwrap();
|
||||
assert!(html.contains("用戶名或密碼不正確"), "{username}");
|
||||
assert!(html.contains("name=\"username\"")); // 表單仍在,可直接重試
|
||||
}
|
||||
|
||||
pool.close();
|
||||
testutil::cleanup(&path);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn login_rejects_disabled_account() {
|
||||
let (state, pool, path) = test_state("auth-login-disabled").await;
|
||||
|
||||
// 建立後即停用的帳戶:即使日後補上正確密碼也不得登入
|
||||
storage::account::create(
|
||||
&pool,
|
||||
crate::models::account::AccountInput {
|
||||
username: "bob".into(),
|
||||
email: "bob@example.com".into(),
|
||||
display_name: None,
|
||||
is_active: Some(false),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let response = login(
|
||||
State(state),
|
||||
Form(LoginForm {
|
||||
username: "bob".into(),
|
||||
password: "whatever".into(),
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
|
||||
pool.close();
|
||||
testutil::cleanup(&path);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn login_page_redirects_when_already_logged_in() {
|
||||
let (state, pool, path) = test_state("auth-page-logged").await;
|
||||
|
||||
// 未登入:渲染表單(200)
|
||||
let response = login_page(
|
||||
State(state.clone()),
|
||||
Query(LoginQuery::default()),
|
||||
axum::http::HeaderMap::new(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
|
||||
// 已登入:直接回到首頁
|
||||
let token = state.sessions.insert(1, "admin", "Administrator");
|
||||
let mut headers = axum::http::HeaderMap::new();
|
||||
headers.insert(COOKIE, format!("alterdb_session={token}").parse().unwrap());
|
||||
let response = login_page(State(state), Query(LoginQuery::default()), headers).await;
|
||||
assert_eq!(response.status(), StatusCode::SEE_OTHER);
|
||||
assert_eq!(response.headers().get(LOCATION).unwrap(), "/");
|
||||
|
||||
pool.close();
|
||||
testutil::cleanup(&path);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn login_page_shows_notice_after_password_change() {
|
||||
let (state, pool, path) = test_state("auth-page-notice").await;
|
||||
|
||||
// ?changed=1:顯示成功提示(無錯誤)
|
||||
let response = login_page(
|
||||
State(state.clone()),
|
||||
Query(LoginQuery {
|
||||
changed: Some("1".into()),
|
||||
}),
|
||||
axum::http::HeaderMap::new(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let html = response_body(response).await;
|
||||
assert!(html.contains("密碼已更新"));
|
||||
assert!(!html.contains("role=\"alert\"")); // 非錯誤情境
|
||||
|
||||
// 無查詢參數:不出現提示
|
||||
let response = login_page(
|
||||
State(state),
|
||||
Query(LoginQuery::default()),
|
||||
axum::http::HeaderMap::new(),
|
||||
)
|
||||
.await;
|
||||
let html = response_body(response).await;
|
||||
assert!(!html.contains("密碼已更新"));
|
||||
|
||||
pool.close();
|
||||
testutil::cleanup(&path);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn logout_destroys_session_and_clears_cookie() {
|
||||
let (state, pool, path) = test_state("auth-logout").await;
|
||||
let token = login_token(&state, "admin", "admin")
|
||||
.await
|
||||
.expect("登入應成功");
|
||||
assert!(state.sessions.get(&token).is_some());
|
||||
|
||||
let mut headers = axum::http::HeaderMap::new();
|
||||
headers.insert(COOKIE, format!("alterdb_session={token}").parse().unwrap());
|
||||
let response = logout(State(state.clone()), headers).await;
|
||||
assert_eq!(response.status(), StatusCode::SEE_OTHER);
|
||||
assert_eq!(response.headers().get(LOCATION).unwrap(), "/login");
|
||||
|
||||
let cookie = response.headers().get(header::SET_COOKIE).unwrap();
|
||||
assert!(cookie.to_str().unwrap().starts_with("alterdb_session=;"));
|
||||
|
||||
// 會話已銷毀;舊 token 無法再換取會話
|
||||
assert!(state.sessions.get(&token).is_none());
|
||||
|
||||
pool.close();
|
||||
testutil::cleanup(&path);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn password_page_renders_form() {
|
||||
let user = CurrentUser {
|
||||
user_id: 1,
|
||||
username: "admin".into(),
|
||||
display_name: "Administrator".into(),
|
||||
};
|
||||
let response = password_page(user).await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let html = response_body(response).await;
|
||||
// 表單三欄齊全,提交至 /password
|
||||
assert!(html.contains("變更密碼"));
|
||||
assert!(html.contains("action=\"/password\""));
|
||||
for field in ["current_password", "new_password", "confirm_password"] {
|
||||
assert!(html.contains(&format!("name=\"{field}\"")), "{field}");
|
||||
}
|
||||
// 外殼要素:登入者資訊與側欄的變更密碼入口
|
||||
assert!(html.contains("Administrator"));
|
||||
assert!(html.contains("href=\"/password\""));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn change_password_updates_hash_and_invalidates_sessions() {
|
||||
let (state, pool, path) = test_state("auth-pw-change").await;
|
||||
let token = login_token(&state, "admin", "admin")
|
||||
.await
|
||||
.expect("登入應成功");
|
||||
|
||||
let response = change_password(
|
||||
State(state.clone()),
|
||||
current_user(&state, &token),
|
||||
Form(PasswordForm {
|
||||
current_password: "admin".into(),
|
||||
new_password: "new-password-123".into(),
|
||||
confirm_password: "new-password-123".into(),
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response.status(), StatusCode::SEE_OTHER);
|
||||
assert_eq!(
|
||||
response.headers().get(LOCATION).unwrap(),
|
||||
"/login?changed=1"
|
||||
);
|
||||
// Cookie 被清除(舊會話隨之失效)
|
||||
assert!(
|
||||
response
|
||||
.headers()
|
||||
.get(header::SET_COOKIE)
|
||||
.unwrap()
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.starts_with("alterdb_session=;")
|
||||
);
|
||||
assert!(state.sessions.get(&token).is_none());
|
||||
|
||||
// 資料庫已更新:新密碼可登入、原密碼不可
|
||||
assert!(
|
||||
login_token(&state, "admin", "new-password-123")
|
||||
.await
|
||||
.is_some()
|
||||
);
|
||||
let response = login(
|
||||
State(state.clone()),
|
||||
Form(LoginForm {
|
||||
username: "admin".into(),
|
||||
password: "admin".into(),
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
|
||||
pool.close();
|
||||
testutil::cleanup(&path);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn change_password_rejects_wrong_current_password() {
|
||||
let (state, pool, path) = test_state("auth-pw-wrong").await;
|
||||
let token = login_token(&state, "admin", "admin")
|
||||
.await
|
||||
.expect("登入應成功");
|
||||
|
||||
let response = change_password(
|
||||
State(state.clone()),
|
||||
current_user(&state, &token),
|
||||
Form(PasswordForm {
|
||||
current_password: "wrong".into(),
|
||||
new_password: "new-password-123".into(),
|
||||
confirm_password: "new-password-123".into(),
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
let html = response_body(response).await;
|
||||
assert!(html.contains("原密碼不正確"));
|
||||
assert!(html.contains("name=\"current_password\"")); // 表單仍在,可直接重試
|
||||
|
||||
// 密碼未變更:原密碼仍可登入,會話未被銷毀
|
||||
assert!(state.sessions.get(&token).is_some());
|
||||
assert!(login_token(&state, "admin", "admin").await.is_some());
|
||||
|
||||
pool.close();
|
||||
testutil::cleanup(&path);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn change_password_validates_new_password() {
|
||||
let (state, pool, path) = test_state("auth-pw-validate").await;
|
||||
let token = login_token(&state, "admin", "admin")
|
||||
.await
|
||||
.expect("登入應成功");
|
||||
let user = current_user(&state, &token);
|
||||
|
||||
for (new, confirm, message) in [
|
||||
(
|
||||
"new-password-123",
|
||||
"different-456",
|
||||
"兩次輸入的新密碼不一致",
|
||||
),
|
||||
("short", "short", "新密碼長度至少 8 個字符"),
|
||||
] {
|
||||
let response = change_password(
|
||||
State(state.clone()),
|
||||
user.clone(),
|
||||
Form(PasswordForm {
|
||||
current_password: "admin".into(),
|
||||
new_password: new.into(),
|
||||
confirm_password: confirm.into(),
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST, "{new}");
|
||||
let html = response_body(response).await;
|
||||
assert!(html.contains(message), "{new}");
|
||||
}
|
||||
|
||||
// 密碼未變更:原密碼仍可登入
|
||||
assert!(login_token(&state, "admin", "admin").await.is_some());
|
||||
|
||||
pool.close();
|
||||
testutil::cleanup(&path);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn change_password_rejects_same_as_current() {
|
||||
let (state, pool, path) = test_state("auth-pw-same").await;
|
||||
// 原密碼 admin 僅 5 字符,先換成符合長度要求的密碼再測「同原密碼」
|
||||
let admin = storage::account::get_by_username(&state.db, "admin")
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let hash = hash_password("long-enough-123").unwrap();
|
||||
storage::account::update_password(&state.db, admin.id, &hash)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let token = login_token(&state, "admin", "long-enough-123")
|
||||
.await
|
||||
.expect("登入應成功");
|
||||
let response = change_password(
|
||||
State(state.clone()),
|
||||
current_user(&state, &token),
|
||||
Form(PasswordForm {
|
||||
current_password: "long-enough-123".into(),
|
||||
new_password: "long-enough-123".into(),
|
||||
confirm_password: "long-enough-123".into(),
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
let html = response_body(response).await;
|
||||
assert!(html.contains("新密碼不可與原密碼相同"));
|
||||
|
||||
pool.close();
|
||||
testutil::cleanup(&path);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,791 @@
|
||||
//! 數據庫連接控制器:`/api/databases` 資源的 REST CRUD,
|
||||
//! 以及對外部數據庫的實際連線操作(測試連接、列出數據表、
|
||||
//! 檢視欄位結構、分頁瀏覽資料、新增 / 刪除欄位與修改資料列)。
|
||||
|
||||
use axum::extract::{Path, Query, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::routing::{delete, get, post};
|
||||
use axum::{Json, Router};
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::connect;
|
||||
use crate::models::database::{Database, DatabaseInput};
|
||||
use crate::state::AppState;
|
||||
use crate::storage::{self, StorageError};
|
||||
|
||||
/// 控制器層錯誤:統一轉為帶 JSON 錯誤體的 HTTP 響應。
|
||||
#[derive(Debug)]
|
||||
enum ApiError {
|
||||
/// 指定的資源不存在(404)
|
||||
NotFound,
|
||||
/// 目標庫中不存在指定的資料表(404)
|
||||
TableNotFound,
|
||||
/// 目標表中不存在指定的欄位(404)
|
||||
ColumnNotFound,
|
||||
/// 唯一性衝突(409),內為可直接展示的錯誤訊息
|
||||
Conflict(String),
|
||||
/// 輸入驗證失敗(400),內為可直接展示的錯誤訊息
|
||||
BadRequest(String),
|
||||
/// 無法連上目標數據庫(502),內為連線錯誤訊息
|
||||
Upstream(String),
|
||||
/// 存儲層錯誤(503 / 500)
|
||||
Storage(StorageError),
|
||||
}
|
||||
|
||||
impl From<StorageError> for ApiError {
|
||||
fn from(err: StorageError) -> Self {
|
||||
if err.is_unique_violation() {
|
||||
ApiError::Conflict("name 已被其他數據庫連接使用".to_string())
|
||||
} else {
|
||||
ApiError::Storage(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for ApiError {
|
||||
fn into_response(self) -> Response {
|
||||
let (status, message) = match self {
|
||||
ApiError::NotFound => (StatusCode::NOT_FOUND, "數據庫連接不存在".to_string()),
|
||||
ApiError::TableNotFound => (StatusCode::NOT_FOUND, "資料表不存在".to_string()),
|
||||
ApiError::ColumnNotFound => (StatusCode::NOT_FOUND, "欄位不存在".to_string()),
|
||||
ApiError::Conflict(message) => (StatusCode::CONFLICT, message),
|
||||
ApiError::BadRequest(message) => (StatusCode::BAD_REQUEST, message),
|
||||
ApiError::Upstream(message) => (StatusCode::BAD_GATEWAY, message),
|
||||
ApiError::Storage(err) => {
|
||||
tracing::error!("數據庫連接存儲層錯誤:{err}");
|
||||
if matches!(err, StorageError::Pool(_)) {
|
||||
(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"資料庫暫時不可用".to_string(),
|
||||
)
|
||||
} else {
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "內部錯誤".to_string())
|
||||
}
|
||||
}
|
||||
};
|
||||
(status, Json(json!({ "error": message }))).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
/// 列出所有數據庫連接
|
||||
async fn list(State(state): State<AppState>) -> Result<Json<Vec<Database>>, ApiError> {
|
||||
let databases = storage::database::list(&state.db).await?;
|
||||
Ok(Json(databases))
|
||||
}
|
||||
|
||||
/// 建立數據庫連接;輸入會先經 [`DatabaseInput::normalized`] 驗證與正規化
|
||||
async fn create(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<DatabaseInput>,
|
||||
) -> Result<(StatusCode, Json<Database>), ApiError> {
|
||||
let payload = payload.normalized().map_err(ApiError::BadRequest)?;
|
||||
let database = storage::database::create(&state.db, payload).await?;
|
||||
Ok((StatusCode::CREATED, Json(database)))
|
||||
}
|
||||
|
||||
/// 取得單一數據庫連接
|
||||
async fn show(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
) -> Result<Json<Database>, ApiError> {
|
||||
storage::database::get_by_id(&state.db, id)
|
||||
.await?
|
||||
.map(Json)
|
||||
.ok_or(ApiError::NotFound)
|
||||
}
|
||||
|
||||
/// 更新連接的全部可編輯欄位
|
||||
/// (name / type / host / port / username / password / database_name)
|
||||
async fn update(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
Json(payload): Json<DatabaseInput>,
|
||||
) -> Result<Json<Database>, ApiError> {
|
||||
let payload = payload.normalized().map_err(ApiError::BadRequest)?;
|
||||
storage::database::update(&state.db, id, payload)
|
||||
.await?
|
||||
.map(Json)
|
||||
.ok_or(ApiError::NotFound)
|
||||
}
|
||||
|
||||
/// 刪除數據庫連接
|
||||
async fn remove(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
match storage::database::delete(&state.db, id).await? {
|
||||
true => Ok(StatusCode::NO_CONTENT),
|
||||
false => Err(ApiError::NotFound),
|
||||
}
|
||||
}
|
||||
|
||||
/// 測試連接:以連接記錄的參數實際連線目標數據庫。
|
||||
///
|
||||
/// 連線成功與失敗都以 200 回報(結果本身在 `ok` 欄位),
|
||||
/// 僅連接記錄不存在時返回 404。
|
||||
async fn test_connection(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
) -> Result<Json<connect::TestOutcome>, ApiError> {
|
||||
let database = storage::database::get_by_id(&state.db, id)
|
||||
.await?
|
||||
.ok_or(ApiError::NotFound)?;
|
||||
Ok(Json(connect::test(&database).await))
|
||||
}
|
||||
|
||||
/// 列出目標資料庫當前 schema 下的數據表(需可實際連上目標)
|
||||
async fn tables(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
) -> Result<Json<Vec<connect::TableSummary>>, ApiError> {
|
||||
let database = storage::database::get_by_id(&state.db, id)
|
||||
.await?
|
||||
.ok_or(ApiError::NotFound)?;
|
||||
let tables = connect::list_tables(&database)
|
||||
.await
|
||||
.map_err(ApiError::Upstream)?;
|
||||
Ok(Json(tables))
|
||||
}
|
||||
|
||||
/// 分頁瀏覽資料每頁列數的默認值與上限(保護瀏覽器與目標庫)
|
||||
const ROWS_LIMIT_DEFAULT: i64 = 50;
|
||||
const ROWS_LIMIT_MAX: i64 = 200;
|
||||
|
||||
/// `GET .../rows` 的分頁參數(`?limit=&offset=`)
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct RowsQuery {
|
||||
limit: Option<i64>,
|
||||
offset: Option<i64>,
|
||||
}
|
||||
|
||||
impl RowsQuery {
|
||||
/// 驗證並補默認:limit 1–200(缺省 50)、offset ≥ 0(缺省 0)。
|
||||
/// `Err` 的訊息可直接作為 400 響應的錯誤內容。
|
||||
fn normalized(self) -> Result<(i64, i64), String> {
|
||||
let limit = self.limit.unwrap_or(ROWS_LIMIT_DEFAULT);
|
||||
if !(1..=ROWS_LIMIT_MAX).contains(&limit) {
|
||||
return Err(format!("limit 須介於 1 與 {ROWS_LIMIT_MAX} 之間"));
|
||||
}
|
||||
let offset = self.offset.unwrap_or(0);
|
||||
if offset < 0 {
|
||||
return Err("offset 不可為負".into());
|
||||
}
|
||||
Ok((limit, offset))
|
||||
}
|
||||
}
|
||||
|
||||
/// 列出資料表的欄位結構(需可實際連上目標)。
|
||||
/// 連接記錄與資料表不存在分別返回 404,連不上目標返回 502。
|
||||
async fn table_columns(
|
||||
State(state): State<AppState>,
|
||||
Path((id, table)): Path<(i64, String)>,
|
||||
) -> Result<Json<Vec<connect::ColumnSummary>>, ApiError> {
|
||||
let database = storage::database::get_by_id(&state.db, id)
|
||||
.await?
|
||||
.ok_or(ApiError::NotFound)?;
|
||||
connect::describe_table(&database, &table)
|
||||
.await
|
||||
.map_err(ApiError::Upstream)?
|
||||
.map(Json)
|
||||
.ok_or(ApiError::TableNotFound)
|
||||
}
|
||||
|
||||
/// 分頁瀏覽資料表資料(`?limit=&offset=`,需可實際連上目標)
|
||||
async fn table_rows(
|
||||
State(state): State<AppState>,
|
||||
Path((id, table)): Path<(i64, String)>,
|
||||
Query(query): Query<RowsQuery>,
|
||||
) -> Result<Json<connect::TableRows>, ApiError> {
|
||||
let (limit, offset) = query.normalized().map_err(ApiError::BadRequest)?;
|
||||
let database = storage::database::get_by_id(&state.db, id)
|
||||
.await?
|
||||
.ok_or(ApiError::NotFound)?;
|
||||
connect::select_rows(&database, &table, limit, offset)
|
||||
.await
|
||||
.map_err(ApiError::Upstream)?
|
||||
.map(Json)
|
||||
.ok_or(ApiError::TableNotFound)
|
||||
}
|
||||
|
||||
/// 新增欄位(`ALTER TABLE … ADD COLUMN`,直接修改目標庫)。
|
||||
/// 先經 [`connect::ColumnInput::normalized`] 驗證、核對資料表存在
|
||||
/// 與欄位名未衝突,再執行 DDL;成功返回 204。
|
||||
async fn create_column(
|
||||
State(state): State<AppState>,
|
||||
Path((id, table)): Path<(i64, String)>,
|
||||
Json(payload): Json<connect::ColumnInput>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
let payload = payload.normalized().map_err(ApiError::BadRequest)?;
|
||||
let database = storage::database::get_by_id(&state.db, id)
|
||||
.await?
|
||||
.ok_or(ApiError::NotFound)?;
|
||||
let columns = connect::describe_table(&database, &table)
|
||||
.await
|
||||
.map_err(ApiError::Upstream)?
|
||||
.ok_or(ApiError::TableNotFound)?;
|
||||
if columns.iter().any(|column| column.name == payload.name) {
|
||||
return Err(ApiError::Conflict(format!("欄位 {} 已存在", payload.name)));
|
||||
}
|
||||
connect::add_column(&database, &table, &payload)
|
||||
.await
|
||||
.map_err(ApiError::Upstream)?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
/// 刪除欄位(`ALTER TABLE … DROP COLUMN`,該欄位資料隨之丟失且無法復原)。
|
||||
/// 先核對資料表與欄位存在再執行 DDL;成功返回 204。
|
||||
async fn drop_column(
|
||||
State(state): State<AppState>,
|
||||
Path((id, table, column)): Path<(i64, String, String)>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
let database = storage::database::get_by_id(&state.db, id)
|
||||
.await?
|
||||
.ok_or(ApiError::NotFound)?;
|
||||
let columns = connect::describe_table(&database, &table)
|
||||
.await
|
||||
.map_err(ApiError::Upstream)?
|
||||
.ok_or(ApiError::TableNotFound)?;
|
||||
if !columns.iter().any(|existing| existing.name == column) {
|
||||
return Err(ApiError::ColumnNotFound);
|
||||
}
|
||||
connect::drop_column(&database, &table, &column)
|
||||
.await
|
||||
.map_err(ApiError::Upstream)?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
/// 修改資料列(以主鍵欄位原值定位,`where` 鍵須恰好為主鍵欄位集合)。
|
||||
/// 輸入先驗證,再核對資料表存在、主鍵與 `set` 欄位存在,最後執行
|
||||
/// UPDATE;目標列不存在(已被刪除或主鍵值已變更)返回 409,成功返回 204。
|
||||
async fn update_row(
|
||||
State(state): State<AppState>,
|
||||
Path((id, table)): Path<(i64, String)>,
|
||||
Json(payload): Json<connect::RowUpdateInput>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
let payload = payload.normalized().map_err(ApiError::BadRequest)?;
|
||||
let database = storage::database::get_by_id(&state.db, id)
|
||||
.await?
|
||||
.ok_or(ApiError::NotFound)?;
|
||||
let columns = connect::describe_table(&database, &table)
|
||||
.await
|
||||
.map_err(ApiError::Upstream)?
|
||||
.ok_or(ApiError::TableNotFound)?;
|
||||
|
||||
let mut primary_key: Vec<&str> = columns
|
||||
.iter()
|
||||
.filter(|column| column.primary_key)
|
||||
.map(|column| column.name.as_str())
|
||||
.collect();
|
||||
if primary_key.is_empty() {
|
||||
return Err(ApiError::BadRequest("此表沒有主鍵,無法定位資料列".into()));
|
||||
}
|
||||
// where 的鍵須恰好覆蓋主鍵欄位(順序無關):少了定位不唯一,多了無意義
|
||||
let mut filter_columns: Vec<&str> = payload.filter.keys().map(String::as_str).collect();
|
||||
primary_key.sort_unstable();
|
||||
filter_columns.sort_unstable();
|
||||
if filter_columns != primary_key {
|
||||
return Err(ApiError::BadRequest(format!(
|
||||
"where 須恰好包含主鍵欄位:{}",
|
||||
primary_key.join(", ")
|
||||
)));
|
||||
}
|
||||
for column in payload.set.keys() {
|
||||
if !columns.iter().any(|existing| &existing.name == column) {
|
||||
return Err(ApiError::BadRequest(format!("欄位 {column} 不存在")));
|
||||
}
|
||||
}
|
||||
|
||||
match connect::update_row(&database, &table, &payload)
|
||||
.await
|
||||
.map_err(ApiError::Upstream)?
|
||||
{
|
||||
true => Ok(StatusCode::NO_CONTENT),
|
||||
false => Err(ApiError::Conflict(
|
||||
"目標資料列不存在(可能已被刪除或主鍵值已變更)".to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/api/databases", get(list).post(create))
|
||||
.route("/api/databases/{id}", get(show).put(update).delete(remove))
|
||||
.route("/api/databases/{id}/test", post(test_connection))
|
||||
.route("/api/databases/{id}/tables", get(tables))
|
||||
.route(
|
||||
"/api/databases/{id}/tables/{table}/columns",
|
||||
get(table_columns).post(create_column),
|
||||
)
|
||||
.route(
|
||||
"/api/databases/{id}/tables/{table}/columns/{column}",
|
||||
delete(drop_column),
|
||||
)
|
||||
.route(
|
||||
"/api/databases/{id}/tables/{table}/rows",
|
||||
get(table_rows).patch(update_row),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::models::database::DatabaseType;
|
||||
use crate::session::SessionStore;
|
||||
use crate::storage::testutil;
|
||||
|
||||
/// 取得一個「已關閉埠」:綁定後立即釋放,埠上無任何監聽,
|
||||
/// 連線會即刻被拒,得到確定性的失敗結果
|
||||
fn closed_port() -> i64 {
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
drop(listener);
|
||||
port as i64
|
||||
}
|
||||
|
||||
/// 連線類端點(test / tables)的錯誤路徑:目標不可達與連接記錄不存在
|
||||
#[tokio::test]
|
||||
async fn connect_endpoints_handle_unreachable_and_missing() {
|
||||
let (pool, path) = testutil::test_pool("databases-connect").await;
|
||||
let state = State(AppState {
|
||||
db: pool.clone(),
|
||||
sessions: SessionStore::new(),
|
||||
});
|
||||
|
||||
let (_, Json(created)) = create(
|
||||
state.clone(),
|
||||
Json(DatabaseInput {
|
||||
name: "dead".into(),
|
||||
db_type: DatabaseType::MySql,
|
||||
host: "127.0.0.1".into(),
|
||||
port: Some(closed_port()),
|
||||
username: "app".into(),
|
||||
password: None,
|
||||
database_name: "shop".into(),
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// 測試連接:連不上也是一種結果 → 200 + ok=false + 錯誤訊息與耗時
|
||||
let Json(outcome) = test_connection(state.clone(), Path(created.id))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!outcome.ok);
|
||||
assert!(!outcome.error.unwrap_or_default().is_empty());
|
||||
assert!(outcome.server_version.is_none());
|
||||
|
||||
// 列出數據表:連不上屬於錯誤 → 502,錯誤體附原因
|
||||
let err = tables(state.clone(), Path(created.id)).await.unwrap_err();
|
||||
let response = err.into_response();
|
||||
assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
serde_json::from_slice::<serde_json::Value>(&body)
|
||||
.unwrap()
|
||||
.get("error")
|
||||
.is_some()
|
||||
);
|
||||
|
||||
// 欄位結構 / 資料瀏覽:連不上同樣 → 502
|
||||
//(Path 未實作 Clone,先持有 tuple 再各自包裝)
|
||||
let table = (created.id, "users".to_string());
|
||||
let err = table_columns(state.clone(), Path(table.clone()))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(err.into_response().status(), StatusCode::BAD_GATEWAY);
|
||||
let err = table_rows(
|
||||
state.clone(),
|
||||
Path(table),
|
||||
Query(RowsQuery {
|
||||
limit: None,
|
||||
offset: None,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(err.into_response().status(), StatusCode::BAD_GATEWAY);
|
||||
|
||||
// 新增 / 刪除欄位:連不上 → 502
|
||||
let input = connect::ColumnInput {
|
||||
name: "bio".into(),
|
||||
data_type: "text".into(),
|
||||
nullable: None,
|
||||
default: None,
|
||||
};
|
||||
let err = create_column(
|
||||
state.clone(),
|
||||
Path((created.id, "users".into())),
|
||||
Json(input),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(err.into_response().status(), StatusCode::BAD_GATEWAY);
|
||||
let err = drop_column(
|
||||
state.clone(),
|
||||
Path((created.id, "users".into(), "bio".into())),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(err.into_response().status(), StatusCode::BAD_GATEWAY);
|
||||
|
||||
// 修改資料列:連不上 → 502
|
||||
let err = update_row(
|
||||
state.clone(),
|
||||
Path((created.id, "users".into())),
|
||||
Json(connect::RowUpdateInput {
|
||||
filter: [("id".into(), Some("7".into()))].into_iter().collect(),
|
||||
set: [("name".into(), Some("new".into()))].into_iter().collect(),
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(err.into_response().status(), StatusCode::BAD_GATEWAY);
|
||||
|
||||
// 連接記錄不存在 → 各端點都 404
|
||||
for id in [created.id + 1000] {
|
||||
let err = test_connection(state.clone(), Path(id)).await.unwrap_err();
|
||||
assert_eq!(err.into_response().status(), StatusCode::NOT_FOUND);
|
||||
let err = tables(state.clone(), Path(id)).await.unwrap_err();
|
||||
assert_eq!(err.into_response().status(), StatusCode::NOT_FOUND);
|
||||
let err = table_columns(state.clone(), Path((id, "users".into())))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(err.into_response().status(), StatusCode::NOT_FOUND);
|
||||
let err = table_rows(
|
||||
state.clone(),
|
||||
Path((id, "users".into())),
|
||||
Query(RowsQuery {
|
||||
limit: None,
|
||||
offset: None,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(err.into_response().status(), StatusCode::NOT_FOUND);
|
||||
let err = create_column(
|
||||
state.clone(),
|
||||
Path((id, "users".into())),
|
||||
Json(connect::ColumnInput {
|
||||
name: "bio".into(),
|
||||
data_type: "text".into(),
|
||||
nullable: None,
|
||||
default: None,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(err.into_response().status(), StatusCode::NOT_FOUND);
|
||||
let err = drop_column(state.clone(), Path((id, "users".into(), "bio".into())))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(err.into_response().status(), StatusCode::NOT_FOUND);
|
||||
let err = update_row(
|
||||
state.clone(),
|
||||
Path((id, "users".into())),
|
||||
Json(connect::RowUpdateInput {
|
||||
filter: [("id".into(), Some("7".into()))].into_iter().collect(),
|
||||
set: [("name".into(), Some("new".into()))].into_iter().collect(),
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(err.into_response().status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
pool.close();
|
||||
testutil::cleanup(&path);
|
||||
}
|
||||
|
||||
/// 新增欄位的輸入驗證在查庫前攔截:名稱 / 類型非法或片段含
|
||||
/// 分號、註解、控制字元 → 400,無需連接記錄存在
|
||||
#[tokio::test]
|
||||
async fn create_column_rejects_invalid_input() {
|
||||
let (pool, path) = testutil::test_pool("databases-column-input").await;
|
||||
let state = State(AppState {
|
||||
db: pool.clone(),
|
||||
sessions: SessionStore::new(),
|
||||
});
|
||||
|
||||
let invalid = [
|
||||
("", "int"),
|
||||
("bio", ""),
|
||||
("bad\nname", "int"),
|
||||
("bio", "text; DROP TABLE users"),
|
||||
("bio", "text--"),
|
||||
("bio", "text/*"),
|
||||
];
|
||||
for (name, data_type) in invalid {
|
||||
let err = create_column(
|
||||
state.clone(),
|
||||
Path((1, "users".into())),
|
||||
Json(connect::ColumnInput {
|
||||
name: name.into(),
|
||||
data_type: data_type.into(),
|
||||
nullable: None,
|
||||
default: None,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
let response = err.into_response();
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST, "name={name:?}");
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
serde_json::from_slice::<serde_json::Value>(&body)
|
||||
.unwrap()
|
||||
.get("error")
|
||||
.is_some()
|
||||
);
|
||||
}
|
||||
|
||||
pool.close();
|
||||
testutil::cleanup(&path);
|
||||
}
|
||||
|
||||
/// 修改資料列的輸入驗證在查庫前攔截:set / where 為空、欄位名非法
|
||||
/// 或值過長 → 400,無需連接記錄存在
|
||||
#[tokio::test]
|
||||
async fn update_row_rejects_invalid_input() {
|
||||
let (pool, path) = testutil::test_pool("databases-row-update-input").await;
|
||||
let state = State(AppState {
|
||||
db: pool.clone(),
|
||||
sessions: SessionStore::new(),
|
||||
});
|
||||
|
||||
let input = |filter: &[(&str, Option<&str>)],
|
||||
set: &[(&str, Option<&str>)]|
|
||||
-> connect::RowUpdateInput {
|
||||
connect::RowUpdateInput {
|
||||
filter: filter
|
||||
.iter()
|
||||
.map(|(k, v)| ((*k).into(), v.map(str::to_string)))
|
||||
.collect(),
|
||||
set: set
|
||||
.iter()
|
||||
.map(|(k, v)| ((*k).into(), v.map(str::to_string)))
|
||||
.collect(),
|
||||
}
|
||||
};
|
||||
|
||||
let invalid = [
|
||||
// set 為空 / where 為空
|
||||
input(&[("id", None)], &[]),
|
||||
input(&[], &[("name", None)]),
|
||||
// 欄位名:空、控制字元
|
||||
input(&[("id", None)], &[(" ", Some("v"))]),
|
||||
input(&[("id", None)], &[("bad\nname", None)]),
|
||||
input(&[("bad\nkey", None)], &[("name", None)]),
|
||||
// 值過長
|
||||
input(
|
||||
&[("id", None)],
|
||||
&[("name", Some("v".repeat(65_537).as_str()))],
|
||||
),
|
||||
];
|
||||
for payload in invalid {
|
||||
let err = update_row(state.clone(), Path((1, "users".into())), Json(payload))
|
||||
.await
|
||||
.unwrap_err();
|
||||
let response = err.into_response();
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
serde_json::from_slice::<serde_json::Value>(&body)
|
||||
.unwrap()
|
||||
.get("error")
|
||||
.is_some()
|
||||
);
|
||||
}
|
||||
|
||||
pool.close();
|
||||
testutil::cleanup(&path);
|
||||
}
|
||||
|
||||
fn payload(name: &str, db_type: DatabaseType) -> DatabaseInput {
|
||||
DatabaseInput {
|
||||
name: name.into(),
|
||||
db_type,
|
||||
host: "10.0.0.5".into(),
|
||||
port: None,
|
||||
username: "app".into(),
|
||||
password: Some("secret".into()),
|
||||
database_name: "shop".into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 分頁參數在查庫前驗證:超出範圍 → 400(錯誤體附可直接展示的訊息),
|
||||
/// 即使連接記錄不存在也先擋下
|
||||
#[tokio::test]
|
||||
async fn table_rows_reject_out_of_range_pagination() {
|
||||
let (pool, path) = testutil::test_pool("databases-table-pagination").await;
|
||||
let state = State(AppState {
|
||||
db: pool.clone(),
|
||||
sessions: SessionStore::new(),
|
||||
});
|
||||
|
||||
for (limit, offset) in [
|
||||
(Some(0), None),
|
||||
(Some(-3), None),
|
||||
(Some(201), None),
|
||||
(None, Some(-1)),
|
||||
] {
|
||||
let err = table_rows(
|
||||
state.clone(),
|
||||
Path((1, "users".into())),
|
||||
Query(RowsQuery { limit, offset }),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
let response = err.into_response();
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
serde_json::from_slice::<serde_json::Value>(&body)
|
||||
.unwrap()
|
||||
.get("error")
|
||||
.is_some()
|
||||
);
|
||||
}
|
||||
|
||||
pool.close();
|
||||
testutil::cleanup(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rows_query_defaults_and_bounds() {
|
||||
// 缺省 → 50 / 0
|
||||
assert_eq!(
|
||||
RowsQuery {
|
||||
limit: None,
|
||||
offset: None
|
||||
}
|
||||
.normalized()
|
||||
.unwrap(),
|
||||
(50, 0)
|
||||
);
|
||||
// 邊界值合法
|
||||
assert_eq!(
|
||||
RowsQuery {
|
||||
limit: Some(200),
|
||||
offset: Some(12345)
|
||||
}
|
||||
.normalized()
|
||||
.unwrap(),
|
||||
(200, 12345)
|
||||
);
|
||||
assert_eq!(
|
||||
RowsQuery {
|
||||
limit: Some(1),
|
||||
offset: Some(0)
|
||||
}
|
||||
.normalized()
|
||||
.unwrap(),
|
||||
(1, 0)
|
||||
);
|
||||
// 超出範圍一律拒絕
|
||||
for (limit, offset) in [
|
||||
(Some(0), None),
|
||||
(Some(-1), None),
|
||||
(Some(201), None),
|
||||
(None, Some(-1)),
|
||||
] {
|
||||
assert!(RowsQuery { limit, offset }.normalized().is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn databases_crud_via_handlers() {
|
||||
let (pool, path) = testutil::test_pool("databases-api").await;
|
||||
let state = State(AppState {
|
||||
db: pool.clone(),
|
||||
sessions: crate::session::SessionStore::new(),
|
||||
});
|
||||
|
||||
// 建立:輸入被 trim、port 按類型補默認 → 201
|
||||
let (status, Json(created)) = create(
|
||||
state.clone(),
|
||||
Json(payload(" shop-prod ", DatabaseType::MySql)),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(status, StatusCode::CREATED);
|
||||
assert_eq!(created.name, "shop-prod");
|
||||
assert_eq!(created.port, 3306);
|
||||
|
||||
// 密碼永不外洩到序列化結果;類型欄位名為 "type"
|
||||
let json = serde_json::to_value(&created).unwrap();
|
||||
assert!(json.get("password").is_none());
|
||||
assert_eq!(json.get("type"), Some(&serde_json::json!("mysql")));
|
||||
|
||||
// 列表包含新連接
|
||||
let Json(databases) = list(state.clone()).await.unwrap();
|
||||
assert_eq!(databases.len(), 1);
|
||||
assert_eq!(databases[0].id, created.id);
|
||||
|
||||
// name 重複 → 409;輸入驗證失敗 → 400
|
||||
let err = create(
|
||||
state.clone(),
|
||||
Json(payload("shop-prod", DatabaseType::Postgres)),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(err.into_response().status(), StatusCode::CONFLICT);
|
||||
let err = create(state.clone(), Json(payload("", DatabaseType::Postgres)))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(err.into_response().status(), StatusCode::BAD_REQUEST);
|
||||
|
||||
// 查詢:存在 / 不存在 → 200 / 404
|
||||
let Json(found) = show(state.clone(), Path(created.id)).await.unwrap();
|
||||
assert_eq!(found.id, created.id);
|
||||
let err = show(state.clone(), Path(9999)).await.unwrap_err();
|
||||
assert_eq!(err.into_response().status(), StatusCode::NOT_FOUND);
|
||||
|
||||
// 更新:欄位被覆寫(含類型變更為 postgres、port 重補默認 5432)
|
||||
let Json(updated) = update(
|
||||
state.clone(),
|
||||
Path(created.id),
|
||||
Json(DatabaseInput {
|
||||
name: "shop-prod".into(),
|
||||
db_type: DatabaseType::Postgres,
|
||||
host: "10.0.0.6".into(),
|
||||
port: None,
|
||||
username: "app2".into(),
|
||||
password: None,
|
||||
database_name: "shop2".into(),
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(updated.db_type, DatabaseType::Postgres);
|
||||
assert_eq!(updated.port, 5432);
|
||||
assert_eq!(updated.database_name, "shop2");
|
||||
|
||||
// 更新時 password 缺省(None)=保留原密碼(回應體不可見,直查存儲層)
|
||||
let stored = storage::database::get_by_id(&state.db, created.id)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(stored.password, "secret");
|
||||
|
||||
// 刪除:→ 204,再刪 → 404
|
||||
assert_eq!(
|
||||
remove(state.clone(), Path(created.id)).await.unwrap(),
|
||||
StatusCode::NO_CONTENT
|
||||
);
|
||||
let err = remove(state, Path(created.id)).await.unwrap_err();
|
||||
assert_eq!(err.into_response().status(), StatusCode::NOT_FOUND);
|
||||
|
||||
pool.close();
|
||||
testutil::cleanup(&path);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
//! 健康檢查控制器:`GET /api/health`。
|
||||
//!
|
||||
//! 會實際向 SQLite 發出一條查詢,資料庫不可用時返回 503。
|
||||
|
||||
use axum::extract::State;
|
||||
use axum::http::StatusCode;
|
||||
use axum::routing::get;
|
||||
use axum::{Json, Router};
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct HealthResponse {
|
||||
status: &'static str,
|
||||
service: &'static str,
|
||||
version: &'static str,
|
||||
database: DatabaseStatus,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct DatabaseStatus {
|
||||
status: &'static str,
|
||||
sqlite_version: String,
|
||||
}
|
||||
|
||||
async fn health(State(state): State<AppState>) -> Result<Json<HealthResponse>, StatusCode> {
|
||||
let sqlite_version = state
|
||||
.db
|
||||
.get()
|
||||
.await
|
||||
.map_err(|_| StatusCode::SERVICE_UNAVAILABLE)?
|
||||
.interact(|conn| {
|
||||
conn.query_row("SELECT sqlite_version()", [], |row| row.get::<_, String>(0))
|
||||
})
|
||||
.await
|
||||
.map_err(|_| StatusCode::SERVICE_UNAVAILABLE)?
|
||||
.map_err(|_| StatusCode::SERVICE_UNAVAILABLE)?;
|
||||
|
||||
Ok(Json(HealthResponse {
|
||||
status: "ok",
|
||||
service: env!("CARGO_PKG_NAME"),
|
||||
version: env!("CARGO_PKG_VERSION"),
|
||||
database: DatabaseStatus {
|
||||
status: "ok",
|
||||
sqlite_version,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new().route("/api/health", get(health))
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
//! 控制器層:HTTP API 的路由與處理器。
|
||||
//!
|
||||
//! 每個控制器一個模組,各自透過 `routes()` 暴露子路由,
|
||||
//! 再由本模組匯總掛載到應用。
|
||||
|
||||
pub mod accounts;
|
||||
pub mod auth;
|
||||
pub mod database;
|
||||
pub mod health;
|
||||
pub mod pages;
|
||||
|
||||
use axum::Router;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
/// 匯總所有控制器的路由;未匹配任何路由的請求由 404 頁面接手
|
||||
pub fn routes() -> Router<AppState> {
|
||||
health::routes()
|
||||
.merge(pages::routes())
|
||||
.merge(auth::routes())
|
||||
.merge(accounts::routes())
|
||||
.merge(database::routes())
|
||||
.fallback(pages::not_found)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::session::SessionStore;
|
||||
use crate::storage::testutil;
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode, header};
|
||||
use tower::ServiceExt;
|
||||
|
||||
/// 以完整路由(oneshot,无需真實 socket)走一遍:
|
||||
/// 未登入攔截 → 登入失敗 → 登入成功 → 攜 Cookie 訪問首頁 → 登出 → 會話失效
|
||||
#[tokio::test]
|
||||
async fn full_login_flow_through_router() {
|
||||
let (pool, path) = testutil::test_pool("router-login-flow").await;
|
||||
let state = AppState {
|
||||
db: pool.clone(),
|
||||
sessions: SessionStore::new(),
|
||||
};
|
||||
let app = routes().with_state(state);
|
||||
|
||||
let request = |method: &str, uri: &str, cookie: Option<&str>, body: Body| {
|
||||
let mut builder = Request::builder().method(method).uri(uri);
|
||||
if method == "POST" {
|
||||
builder = builder.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded");
|
||||
}
|
||||
if let Some(cookie) = cookie {
|
||||
builder = builder.header(header::COOKIE, cookie);
|
||||
}
|
||||
builder.body(body).unwrap()
|
||||
};
|
||||
let form = |payload: &'static str| Body::from(payload);
|
||||
|
||||
// 未登入訪問首頁 → 303 /login
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(request("GET", "/", None, Body::empty()))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::SEE_OTHER);
|
||||
assert_eq!(response.headers().get(header::LOCATION).unwrap(), "/login");
|
||||
|
||||
// 未登入訪問數據庫連接頁 → 同樣 303 /login
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(request("GET", "/databases", None, Body::empty()))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::SEE_OTHER);
|
||||
assert_eq!(response.headers().get(header::LOCATION).unwrap(), "/login");
|
||||
|
||||
// 未登入訪問新增 / 詳情 / 編輯 / 表管理頁 / 數據管理頁 → 同樣 303 /login
|
||||
for uri in [
|
||||
"/databases/new",
|
||||
"/databases/1",
|
||||
"/databases/1/edit",
|
||||
"/databases/1/tables/users",
|
||||
"/databases/1/tables/users/data",
|
||||
] {
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(request("GET", uri, None, Body::empty()))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::SEE_OTHER, "{uri}");
|
||||
assert_eq!(response.headers().get(header::LOCATION).unwrap(), "/login");
|
||||
}
|
||||
|
||||
// 未登入訪問變更密碼頁(GET / POST)→ 同樣 303 /login
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(request("GET", "/password", None, Body::empty()))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::SEE_OTHER);
|
||||
assert_eq!(response.headers().get(header::LOCATION).unwrap(), "/login");
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
request("POST", "/password", None, form("current_password=admin&new_password=new-password-123&confirm_password=new-password-123")),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::SEE_OTHER);
|
||||
assert_eq!(response.headers().get(header::LOCATION).unwrap(), "/login");
|
||||
|
||||
// 錯誤密碼 → 401
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(request(
|
||||
"POST",
|
||||
"/login",
|
||||
None,
|
||||
form("username=admin&password=wrong"),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
|
||||
// 正確登入 → 303 / 且下發會話 Cookie
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(request(
|
||||
"POST",
|
||||
"/login",
|
||||
None,
|
||||
form("username=admin&password=admin"),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::SEE_OTHER);
|
||||
let cookie = response
|
||||
.headers()
|
||||
.get(header::SET_COOKIE)
|
||||
.unwrap()
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.split(';')
|
||||
.next()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
|
||||
// 攜 Cookie 訪問首頁 → 200,渲染登入者與登出按鈕
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(request("GET", "/", Some(&cookie), Body::empty()))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let html = String::from_utf8(body.to_vec()).unwrap();
|
||||
assert!(html.contains("Administrator"));
|
||||
assert!(html.contains("登出"));
|
||||
|
||||
// 已登入者再訪問 /login → 303 /(不重複登入)
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(request("GET", "/login", Some(&cookie), Body::empty()))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.headers().get(header::LOCATION).unwrap(), "/");
|
||||
|
||||
// 登出 → 303 /login 且清除 Cookie
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(request("POST", "/logout", Some(&cookie), Body::empty()))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::SEE_OTHER);
|
||||
let cleared = response.headers().get(header::SET_COOKIE).unwrap();
|
||||
assert!(cleared.to_str().unwrap().starts_with("alterdb_session=;"));
|
||||
|
||||
// 舊 token 已失效:訪問首頁再次被導向 /login
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(request("GET", "/", Some(&cookie), Body::empty()))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.headers().get(header::LOCATION).unwrap(), "/login");
|
||||
|
||||
pool.close();
|
||||
testutil::cleanup(&path);
|
||||
}
|
||||
|
||||
/// 以完整路由走一遍變更密碼流程:
|
||||
/// 登入 → 變更密碼(會話全銷毀 + Cookie 清除 + 303 /login?changed=1)→
|
||||
/// 舊會話失效、舊密碼不可登入 → 新密碼登入成功 → 登入頁顯示成功提示。
|
||||
#[tokio::test]
|
||||
async fn password_change_flow_through_router() {
|
||||
let (pool, path) = testutil::test_pool("router-pw-flow").await;
|
||||
let state = AppState {
|
||||
db: pool.clone(),
|
||||
sessions: SessionStore::new(),
|
||||
};
|
||||
let app = routes().with_state(state);
|
||||
|
||||
let request = |method: &str, uri: &str, cookie: Option<&str>, body: Body| {
|
||||
let mut builder = Request::builder().method(method).uri(uri);
|
||||
if method == "POST" {
|
||||
builder = builder.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded");
|
||||
}
|
||||
if let Some(cookie) = cookie {
|
||||
builder = builder.header(header::COOKIE, cookie);
|
||||
}
|
||||
builder.body(body).unwrap()
|
||||
};
|
||||
let form = |payload: &'static str| Body::from(payload);
|
||||
|
||||
// 登入取得會話 Cookie
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(request(
|
||||
"POST",
|
||||
"/login",
|
||||
None,
|
||||
form("username=admin&password=admin"),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::SEE_OTHER);
|
||||
let cookie = response
|
||||
.headers()
|
||||
.get(header::SET_COOKIE)
|
||||
.unwrap()
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.split(';')
|
||||
.next()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
|
||||
// 變更密碼頁可訪問(200,含表單)
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(request("GET", "/password", Some(&cookie), Body::empty()))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
String::from_utf8(body.to_vec())
|
||||
.unwrap()
|
||||
.contains("變更密碼")
|
||||
);
|
||||
|
||||
// 提交變更密碼 → 303 /login?changed=1 且清除 Cookie
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(request(
|
||||
"POST",
|
||||
"/password",
|
||||
Some(&cookie),
|
||||
form("current_password=admin&new_password=new-password-123&confirm_password=new-password-123"),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::SEE_OTHER);
|
||||
assert_eq!(
|
||||
response.headers().get(header::LOCATION).unwrap(),
|
||||
"/login?changed=1"
|
||||
);
|
||||
assert!(
|
||||
response
|
||||
.headers()
|
||||
.get(header::SET_COOKIE)
|
||||
.unwrap()
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.starts_with("alterdb_session=;")
|
||||
);
|
||||
|
||||
// 舊 Cookie 已失效:首頁再次被導向 /login
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(request("GET", "/", Some(&cookie), Body::empty()))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.headers().get(header::LOCATION).unwrap(), "/login");
|
||||
|
||||
// 舊密碼登入 → 401;新密碼登入 → 303 /
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(request(
|
||||
"POST",
|
||||
"/login",
|
||||
None,
|
||||
form("username=admin&password=admin"),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(request(
|
||||
"POST",
|
||||
"/login",
|
||||
None,
|
||||
form("username=admin&password=new-password-123"),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::SEE_OTHER);
|
||||
|
||||
// 登入頁帶 ?changed=1 時顯示成功提示
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(request("GET", "/login?changed=1", None, Body::empty()))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
String::from_utf8(body.to_vec())
|
||||
.unwrap()
|
||||
.contains("密碼已更新")
|
||||
);
|
||||
|
||||
pool.close();
|
||||
testutil::cleanup(&path);
|
||||
}
|
||||
|
||||
/// 走完整路由:未知路徑(含未知 /api 端點)一律返回 404 並渲染 404 頁面。
|
||||
/// 接線方式與 main.rs 一致,確保 fallback 在 merge + layer 之後仍生效。
|
||||
#[tokio::test]
|
||||
async fn unknown_routes_render_404_page() {
|
||||
use tower_http::trace::TraceLayer;
|
||||
|
||||
let (pool, path) = testutil::test_pool("router-404").await;
|
||||
let state = AppState {
|
||||
db: pool.clone(),
|
||||
sessions: SessionStore::new(),
|
||||
};
|
||||
let app = axum::Router::new()
|
||||
.merge(routes())
|
||||
.layer(TraceLayer::new_for_http())
|
||||
.with_state(state);
|
||||
|
||||
for uri in ["/no-such-page", "/api/no-such-endpoint"] {
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(Request::get(uri).body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND, "{uri}");
|
||||
assert!(
|
||||
response.headers().get(header::CONTENT_TYPE).is_some(),
|
||||
"{uri}"
|
||||
);
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let html = String::from_utf8(body.to_vec()).unwrap();
|
||||
assert!(html.contains("找不到頁面"), "{uri}");
|
||||
assert!(html.contains(&format!("<code>{uri}</code>")), "{uri}");
|
||||
}
|
||||
|
||||
pool.close();
|
||||
testutil::cleanup(&path);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,710 @@
|
||||
//! 頁面控制器:以 Askama 渲染 Web 管理介面的 HTML 頁面。
|
||||
//!
|
||||
//! 頁面一律以 [`CurrentUser`] 作提取器,未登入的請求會被
|
||||
//! 重定向到 `/login`,處理器內無需再逐一檢查會話。
|
||||
//! 頁面僅渲染外殼;`/databases` 等管理頁的數據由內嵌 JS
|
||||
//! 調用對應的 `/api/*` 端點取得。
|
||||
|
||||
use askama::Template;
|
||||
use axum::Router;
|
||||
use axum::extract::{Path, State};
|
||||
use axum::http::{StatusCode, Uri};
|
||||
use axum::response::{Html, IntoResponse, Response};
|
||||
use axum::routing::get;
|
||||
|
||||
use crate::models::database::Database;
|
||||
use crate::session::CurrentUser;
|
||||
use crate::state::AppState;
|
||||
use crate::storage;
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "index.html")]
|
||||
struct IndexTemplate {
|
||||
service: &'static str,
|
||||
version: &'static str,
|
||||
user_display_name: String,
|
||||
user_username: String,
|
||||
/// 側邊導航欄的當前頁標記(aria-current),由各頁處理器固定提供
|
||||
nav_active: &'static str,
|
||||
}
|
||||
|
||||
async fn index(user: CurrentUser) -> Result<Html<String>, StatusCode> {
|
||||
let tpl = IndexTemplate {
|
||||
service: env!("CARGO_PKG_NAME"),
|
||||
version: env!("CARGO_PKG_VERSION"),
|
||||
user_display_name: user.display_name,
|
||||
user_username: user.username,
|
||||
nav_active: "index",
|
||||
};
|
||||
render(tpl)
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "databases.html")]
|
||||
struct DatabasesTemplate {
|
||||
user_display_name: String,
|
||||
user_username: String,
|
||||
nav_active: &'static str,
|
||||
}
|
||||
|
||||
async fn databases_page(user: CurrentUser) -> Result<Html<String>, StatusCode> {
|
||||
render(DatabasesTemplate {
|
||||
user_display_name: user.display_name,
|
||||
user_username: user.username,
|
||||
nav_active: "databases",
|
||||
})
|
||||
}
|
||||
|
||||
/// 新增 / 編輯連接的共用表單頁;`editing` 為 `None` 時是新增模式。
|
||||
/// 密碼不會回填到頁面:API 與模板皆不輸出密碼,客戶端只能選擇覆寫或保留。
|
||||
#[derive(Template)]
|
||||
#[template(path = "database_form.html")]
|
||||
struct DatabaseFormTemplate {
|
||||
user_display_name: String,
|
||||
user_username: String,
|
||||
editing: Option<Database>,
|
||||
nav_active: &'static str,
|
||||
}
|
||||
|
||||
async fn database_new_page(user: CurrentUser) -> Result<Html<String>, StatusCode> {
|
||||
render(DatabaseFormTemplate {
|
||||
user_display_name: user.display_name,
|
||||
user_username: user.username,
|
||||
editing: None,
|
||||
nav_active: "databases",
|
||||
})
|
||||
}
|
||||
|
||||
/// 編輯頁:服務端依主鍵預填表單;連接不存在時渲染 404 頁面
|
||||
async fn database_edit_page(
|
||||
State(state): State<AppState>,
|
||||
user: CurrentUser,
|
||||
Path(id): Path<i64>,
|
||||
uri: Uri,
|
||||
) -> Result<Response, StatusCode> {
|
||||
let database = storage::database::get_by_id(&state.db, id)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
match database {
|
||||
Some(database) => Ok(render(DatabaseFormTemplate {
|
||||
user_display_name: user.display_name,
|
||||
user_username: user.username,
|
||||
editing: Some(database),
|
||||
nav_active: "databases",
|
||||
})?
|
||||
.into_response()),
|
||||
None => render_not_found(uri.path()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 連接詳情頁:服務端渲染連接靜態資訊,連接狀態與數據表
|
||||
/// 由內嵌 JS 調用 `/api/databases/{id}/test`、`/tables` 即時取得。
|
||||
#[derive(Template)]
|
||||
#[template(path = "database_detail.html")]
|
||||
struct DatabaseDetailTemplate {
|
||||
user_display_name: String,
|
||||
user_username: String,
|
||||
database: Database,
|
||||
/// RFC 3339 原值轉為易讀格式(與列表頁 JS 的格式一致),見 [`format_timestamp`]
|
||||
created_at_display: String,
|
||||
updated_at_display: String,
|
||||
nav_active: &'static str,
|
||||
}
|
||||
|
||||
/// RFC 3339(`2026-01-01T08:00:00.123Z`)→ `2026-01-01 08:00:00 UTC`
|
||||
fn format_timestamp(iso: &str) -> String {
|
||||
match iso.split_once('.') {
|
||||
Some((head, _)) => format!("{} UTC", head.replacen('T', " ", 1)),
|
||||
None => iso.replacen('T', " ", 1),
|
||||
}
|
||||
}
|
||||
|
||||
async fn database_detail_page(
|
||||
State(state): State<AppState>,
|
||||
user: CurrentUser,
|
||||
Path(id): Path<i64>,
|
||||
uri: Uri,
|
||||
) -> Result<Response, StatusCode> {
|
||||
let database = storage::database::get_by_id(&state.db, id)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
match database {
|
||||
Some(database) => Ok(render(DatabaseDetailTemplate {
|
||||
user_display_name: user.display_name,
|
||||
user_username: user.username,
|
||||
created_at_display: format_timestamp(&database.created_at),
|
||||
updated_at_display: format_timestamp(&database.updated_at),
|
||||
database,
|
||||
nav_active: "databases",
|
||||
})?
|
||||
.into_response()),
|
||||
None => render_not_found(uri.path()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 數據表管理頁:服務端渲染連接與表名等靜態資訊,欄位結構
|
||||
/// 由內嵌 JS 調用 `/api/databases/{id}/tables/{table}/columns` 即時取得,
|
||||
/// 頁面本身不請求表內資料(資料瀏覽在獨立的數據管理頁)。
|
||||
/// 表是否存在須實際連線目標庫方能得知,頁面本身不檢查(由 API 回報)。
|
||||
#[derive(Template)]
|
||||
#[template(path = "table_detail.html")]
|
||||
struct TableDetailTemplate {
|
||||
user_display_name: String,
|
||||
user_username: String,
|
||||
database: Database,
|
||||
table: String,
|
||||
nav_active: &'static str,
|
||||
}
|
||||
|
||||
async fn table_detail_page(
|
||||
State(state): State<AppState>,
|
||||
user: CurrentUser,
|
||||
Path((id, table)): Path<(i64, String)>,
|
||||
uri: Uri,
|
||||
) -> Result<Response, StatusCode> {
|
||||
let database = storage::database::get_by_id(&state.db, id)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
match database {
|
||||
Some(database) => Ok(render(TableDetailTemplate {
|
||||
user_display_name: user.display_name,
|
||||
user_username: user.username,
|
||||
database,
|
||||
table,
|
||||
nav_active: "databases",
|
||||
})?
|
||||
.into_response()),
|
||||
None => render_not_found(uri.path()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 數據管理頁:表內資料的分頁瀏覽,由內嵌 JS 調用
|
||||
/// `/api/databases/{id}/tables/{table}/rows` 即時取得;欄位結構
|
||||
/// 管理留在表管理頁。與表管理頁同樣不檢查表是否存在(由 API 回報)。
|
||||
#[derive(Template)]
|
||||
#[template(path = "table_data.html")]
|
||||
struct TableDataTemplate {
|
||||
user_display_name: String,
|
||||
user_username: String,
|
||||
database: Database,
|
||||
table: String,
|
||||
nav_active: &'static str,
|
||||
}
|
||||
|
||||
async fn table_data_page(
|
||||
State(state): State<AppState>,
|
||||
user: CurrentUser,
|
||||
Path((id, table)): Path<(i64, String)>,
|
||||
uri: Uri,
|
||||
) -> Result<Response, StatusCode> {
|
||||
let database = storage::database::get_by_id(&state.db, id)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
match database {
|
||||
Some(database) => Ok(render(TableDataTemplate {
|
||||
user_display_name: user.display_name,
|
||||
user_username: user.username,
|
||||
database,
|
||||
table,
|
||||
nav_active: "databases",
|
||||
})?
|
||||
.into_response()),
|
||||
None => render_not_found(uri.path()),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "404.html")]
|
||||
struct NotFoundTemplate {
|
||||
path: String,
|
||||
}
|
||||
|
||||
/// 渲染 404 頁面;不需要登入,對未登入的訪客同樣可見
|
||||
fn render_not_found(path: &str) -> Result<Response, StatusCode> {
|
||||
let html = render(NotFoundTemplate {
|
||||
path: path.to_string(),
|
||||
})?;
|
||||
Ok((StatusCode::NOT_FOUND, html).into_response())
|
||||
}
|
||||
|
||||
/// 路由未匹配時的全站 fallback
|
||||
pub async fn not_found(uri: Uri) -> Result<Response, StatusCode> {
|
||||
render_not_found(uri.path())
|
||||
}
|
||||
|
||||
/// 渲染模板為 HTML 回應;渲染失敗統一上報 500
|
||||
fn render(tpl: impl Template) -> Result<Html<String>, StatusCode> {
|
||||
tpl.render()
|
||||
.map(Html)
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
|
||||
}
|
||||
|
||||
pub fn routes() -> Router<crate::state::AppState> {
|
||||
Router::new()
|
||||
.route("/", get(index))
|
||||
.route("/databases", get(databases_page))
|
||||
.route("/databases/new", get(database_new_page))
|
||||
.route("/databases/{id}", get(database_detail_page))
|
||||
.route("/databases/{id}/edit", get(database_edit_page))
|
||||
.route("/databases/{id}/tables/{table}", get(table_detail_page))
|
||||
.route("/databases/{id}/tables/{table}/data", get(table_data_page))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::models::database::{DatabaseInput, DatabaseType};
|
||||
use crate::session::SessionStore;
|
||||
use crate::storage::testutil;
|
||||
|
||||
fn user() -> CurrentUser {
|
||||
CurrentUser {
|
||||
user_id: 1,
|
||||
username: "admin".into(),
|
||||
display_name: "Administrator".into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn index_renders_logged_in_user() {
|
||||
let Html(html) = index(user()).await.unwrap();
|
||||
assert!(html.contains("Administrator"));
|
||||
assert!(html.contains("登出"));
|
||||
// 側邊導航欄含數據庫連接頁連結,首頁為當前頁(aria-current)
|
||||
assert!(html.contains("href=\"/databases\""));
|
||||
assert!(html.contains("<a href=\"/\" aria-current=\"page\">首頁</a>"));
|
||||
assert!(html.contains("<a href=\"/databases\">數據庫</a>"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn databases_page_renders_shell() {
|
||||
let Html(html) = databases_page(user()).await.unwrap();
|
||||
// 外殼要素:標題、表格掛載點、新增連接入口與腳本
|
||||
assert!(html.contains("數據庫連接"));
|
||||
assert!(html.contains("id=\"db-rows\""));
|
||||
assert!(html.contains("href=\"/databases/new\""));
|
||||
assert!(html.contains("/api/databases"));
|
||||
// 內嵌表單已拆到獨立頁面
|
||||
assert!(!html.contains("id=\"db-form\""));
|
||||
// 頁面同樣受會話保護(側邊欄已渲染登入者與登出)
|
||||
assert!(html.contains("Administrator"));
|
||||
assert!(html.contains("登出"));
|
||||
// 數據庫頁在側邊欄標記為當前頁,首頁不標記
|
||||
assert!(html.contains("<a href=\"/databases\" aria-current=\"page\">數據庫</a>"));
|
||||
assert!(html.contains("<a href=\"/\">首頁</a>"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn database_new_page_renders_empty_form() {
|
||||
let Html(html) = database_new_page(user()).await.unwrap();
|
||||
assert!(html.contains("新增連接"));
|
||||
assert!(html.contains("data-mode=\"create\""));
|
||||
assert!(html.contains("id=\"db-form\""));
|
||||
// 新增模式:密碼提示為「可留空」,且無預填值
|
||||
assert!(html.contains("可留空"));
|
||||
assert!(!html.contains("value=\"shop-prod\""));
|
||||
// 表單頁隸屬數據庫區,側邊欄高亮數據庫項
|
||||
assert!(html.contains("<a href=\"/databases\" aria-current=\"page\">數據庫</a>"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn database_edit_page_prefills_form() {
|
||||
let (pool, path) = testutil::test_pool("page-edit-prefill").await;
|
||||
let state = AppState {
|
||||
db: pool.clone(),
|
||||
sessions: SessionStore::new(),
|
||||
};
|
||||
let created = storage::database::create(
|
||||
&pool,
|
||||
DatabaseInput {
|
||||
name: "shop-prod".into(),
|
||||
db_type: DatabaseType::Postgres,
|
||||
host: "10.0.0.5".into(),
|
||||
port: Some(5433),
|
||||
username: "app".into(),
|
||||
password: Some("secret".into()),
|
||||
database_name: "shop".into(),
|
||||
}
|
||||
.normalized()
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let uri = format!("/databases/{}/edit", created.id)
|
||||
.parse::<Uri>()
|
||||
.unwrap();
|
||||
let response = database_edit_page(State(state), user(), Path(created.id), uri)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let html = String::from_utf8(body.to_vec()).unwrap();
|
||||
// 預填現值(type 為 postgres)、編輯模式標記與密碼提示
|
||||
assert!(html.contains("value=\"shop-prod\""));
|
||||
assert!(html.contains("value=\"10.0.0.5\""));
|
||||
assert!(html.contains("value=\"5433\""));
|
||||
assert!(html.contains("data-mode=\"edit\""));
|
||||
assert!(html.contains(&format!("data-id=\"{}\"", created.id)));
|
||||
assert!(html.contains("<option value=\"postgres\" selected>"));
|
||||
assert!(html.contains("留空保留原密碼"));
|
||||
// 密碼永不回填到頁面
|
||||
assert!(!html.contains("secret"));
|
||||
|
||||
pool.close();
|
||||
testutil::cleanup(&path);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn database_edit_page_returns_404_for_missing_id() {
|
||||
let (pool, path) = testutil::test_pool("page-edit-404").await;
|
||||
let state = AppState {
|
||||
db: pool.clone(),
|
||||
sessions: SessionStore::new(),
|
||||
};
|
||||
|
||||
let uri = "/databases/9999/edit".parse::<Uri>().unwrap();
|
||||
let response = database_edit_page(State(state), user(), Path(9999), uri)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||
// 404 一律渲染 404 頁面(含返回首頁連結),而非純文字
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let html = String::from_utf8(body.to_vec()).unwrap();
|
||||
assert!(html.contains("找不到頁面"));
|
||||
assert!(html.contains("href=\"/\""));
|
||||
assert!(html.contains("/databases/9999/edit"));
|
||||
|
||||
pool.close();
|
||||
testutil::cleanup(&path);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn database_detail_page_renders_info_and_live_sections() {
|
||||
let (pool, path) = testutil::test_pool("page-detail").await;
|
||||
let state = AppState {
|
||||
db: pool.clone(),
|
||||
sessions: SessionStore::new(),
|
||||
};
|
||||
let created = storage::database::create(
|
||||
&pool,
|
||||
DatabaseInput {
|
||||
name: "shop-prod".into(),
|
||||
db_type: DatabaseType::MySql,
|
||||
host: "10.0.0.5".into(),
|
||||
port: Some(3307),
|
||||
username: "app".into(),
|
||||
password: Some("secret".into()),
|
||||
database_name: "shop".into(),
|
||||
}
|
||||
.normalized()
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let uri = format!("/databases/{}", created.id).parse::<Uri>().unwrap();
|
||||
let response = database_detail_page(State(state), user(), Path(created.id), uri)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let html = String::from_utf8(body.to_vec()).unwrap();
|
||||
// 服務端預填的靜態資訊
|
||||
assert!(html.contains("shop-prod"));
|
||||
assert!(html.contains("10.0.0.5:3307"));
|
||||
assert!(html.contains("mysql"));
|
||||
assert!(html.contains(&format!("data-database-id=\"{}\"", created.id)));
|
||||
// JS 掛載點:連接狀態與數據表
|
||||
assert!(html.contains("id=\"status\""));
|
||||
assert!(html.contains("id=\"table-rows\""));
|
||||
assert!(html.contains("/api/databases/' + databaseId + '/test"));
|
||||
assert!(html.contains("/api/databases/' + databaseId + '/tables"));
|
||||
assert!(html.contains("重新測試"));
|
||||
// 時間戳已轉為易讀格式(T 分隔與毫秒尾已去除)
|
||||
assert!(!html.contains(&created.created_at));
|
||||
assert!(html.contains(" UTC"));
|
||||
// 密碼永不輸出到頁面
|
||||
assert!(!html.contains("secret"));
|
||||
// 側邊欄高亮數據庫項
|
||||
assert!(html.contains("<a href=\"/databases\" aria-current=\"page\">數據庫</a>"));
|
||||
|
||||
pool.close();
|
||||
testutil::cleanup(&path);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn database_detail_page_returns_404_for_missing_id() {
|
||||
let (pool, path) = testutil::test_pool("page-detail-404").await;
|
||||
let state = AppState {
|
||||
db: pool.clone(),
|
||||
sessions: SessionStore::new(),
|
||||
};
|
||||
|
||||
let uri = "/databases/9999".parse::<Uri>().unwrap();
|
||||
let response = database_detail_page(State(state), user(), Path(9999), uri)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let html = String::from_utf8(body.to_vec()).unwrap();
|
||||
assert!(html.contains("找不到頁面"));
|
||||
|
||||
pool.close();
|
||||
testutil::cleanup(&path);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn table_detail_page_renders_shell_and_mount_points() {
|
||||
let (pool, path) = testutil::test_pool("page-table-detail").await;
|
||||
let state = AppState {
|
||||
db: pool.clone(),
|
||||
sessions: SessionStore::new(),
|
||||
};
|
||||
let created = storage::database::create(
|
||||
&pool,
|
||||
DatabaseInput {
|
||||
name: "shop-prod".into(),
|
||||
db_type: DatabaseType::MySql,
|
||||
host: "10.0.0.5".into(),
|
||||
port: Some(3307),
|
||||
username: "app".into(),
|
||||
password: Some("secret".into()),
|
||||
database_name: "shop".into(),
|
||||
}
|
||||
.normalized()
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let uri = format!("/databases/{}/tables/users", created.id)
|
||||
.parse::<Uri>()
|
||||
.unwrap();
|
||||
let response = table_detail_page(
|
||||
State(state),
|
||||
user(),
|
||||
Path((created.id, "users".into())),
|
||||
uri,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let html = String::from_utf8(body.to_vec()).unwrap();
|
||||
// 服務端預填的靜態資訊:表名(屬性轉義後仍可原樣讀回)與所屬連接
|
||||
assert!(html.contains("data-table=\"users\""));
|
||||
assert!(html.contains(&format!("data-database-id=\"{}\"", created.id)));
|
||||
assert!(html.contains("shop-prod"));
|
||||
assert!(html.contains("10.0.0.5:3307"));
|
||||
// JS 掛載點:欄位結構(本頁不再請求表內資料)
|
||||
assert!(html.contains("欄位結構"));
|
||||
assert!(html.contains("id=\"column-rows\""));
|
||||
assert!(!html.contains("id=\"row-body\""));
|
||||
assert!(!html.contains("/rows?limit="));
|
||||
assert!(html.contains(
|
||||
"/api/databases/' + databaseId + '/tables/' + encodeURIComponent(tableName)"
|
||||
));
|
||||
// 工具列提供通往數據管理頁的連結(表名路徑段已編碼)
|
||||
assert!(html.contains(&format!(
|
||||
"href=\"/databases/{}/tables/users/data\"",
|
||||
created.id
|
||||
)));
|
||||
// 新增 / 刪除欄位的內嵌表單與 DDL 調用
|
||||
assert!(html.contains("新增欄位"));
|
||||
assert!(html.contains("id=\"column-form\""));
|
||||
assert!(html.contains("id=\"column-name\""));
|
||||
assert!(html.contains("id=\"column-type\""));
|
||||
assert!(html.contains("id=\"column-nullable\""));
|
||||
assert!(html.contains("id=\"column-default\""));
|
||||
assert!(html.contains("tableApi + '/columns', {"));
|
||||
assert!(html.contains("tableApi + '/columns/' + encodeURIComponent(name)"));
|
||||
assert!(html.contains("確定刪除欄位"));
|
||||
// 密碼永不輸出到頁面;側邊欄高亮數據庫項
|
||||
assert!(!html.contains("secret"));
|
||||
assert!(html.contains("<a href=\"/databases\" aria-current=\"page\">數據庫</a>"));
|
||||
|
||||
pool.close();
|
||||
testutil::cleanup(&path);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn table_detail_page_returns_404_for_missing_database() {
|
||||
let (pool, path) = testutil::test_pool("page-table-detail-404").await;
|
||||
let state = AppState {
|
||||
db: pool.clone(),
|
||||
sessions: SessionStore::new(),
|
||||
};
|
||||
|
||||
let uri = "/databases/9999/tables/users".parse::<Uri>().unwrap();
|
||||
let response = table_detail_page(State(state), user(), Path((9999, "users".into())), uri)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let html = String::from_utf8(body.to_vec()).unwrap();
|
||||
assert!(html.contains("找不到頁面"));
|
||||
assert!(html.contains("/databases/9999/tables/users"));
|
||||
|
||||
pool.close();
|
||||
testutil::cleanup(&path);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn table_data_page_renders_shell_and_mount_points() {
|
||||
let (pool, path) = testutil::test_pool("page-table-data").await;
|
||||
let state = AppState {
|
||||
db: pool.clone(),
|
||||
sessions: SessionStore::new(),
|
||||
};
|
||||
let created = storage::database::create(
|
||||
&pool,
|
||||
DatabaseInput {
|
||||
name: "shop-prod".into(),
|
||||
db_type: DatabaseType::MySql,
|
||||
host: "10.0.0.5".into(),
|
||||
port: Some(3307),
|
||||
username: "app".into(),
|
||||
password: Some("secret".into()),
|
||||
database_name: "shop".into(),
|
||||
}
|
||||
.normalized()
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let uri = format!("/databases/{}/tables/users/data", created.id)
|
||||
.parse::<Uri>()
|
||||
.unwrap();
|
||||
let response = table_data_page(
|
||||
State(state),
|
||||
user(),
|
||||
Path((created.id, "users".into())),
|
||||
uri,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let html = String::from_utf8(body.to_vec()).unwrap();
|
||||
// 服務端預填的靜態資訊:表名與所屬連接
|
||||
assert!(html.contains("data-table=\"users\""));
|
||||
assert!(html.contains(&format!("data-database-id=\"{}\"", created.id)));
|
||||
assert!(html.contains("shop-prod"));
|
||||
assert!(html.contains("10.0.0.5:3307"));
|
||||
// JS 掛載點:資料表頭 / 表身與分頁控件;資料端點與分頁參數拼接
|
||||
assert!(html.contains("id=\"row-head\""));
|
||||
assert!(html.contains("id=\"row-body\""));
|
||||
assert!(html.contains("id=\"page-size\""));
|
||||
assert!(html.contains("id=\"prev\""));
|
||||
assert!(html.contains("id=\"next\""));
|
||||
assert!(html.contains(
|
||||
"/api/databases/' + databaseId + '/tables/' + encodeURIComponent(tableName)"
|
||||
));
|
||||
assert!(html.contains("tableApi + '/rows?limit=' + limit + '&offset=' + offset"));
|
||||
// 編輯資料列:對話框表單、欄位 metadata 載入與 PATCH 調用;
|
||||
// 無主鍵時的僅瀏覽提示也在頁面上
|
||||
assert!(html.contains("id=\"row-dialog\""));
|
||||
assert!(html.contains("id=\"row-form\""));
|
||||
assert!(html.contains("id=\"row-fields\""));
|
||||
assert!(html.contains("id=\"no-pk-note\""));
|
||||
assert!(html.contains("column.primary_key"));
|
||||
assert!(html.contains("method: 'PATCH'"));
|
||||
assert!(html.contains("editButton.textContent = '編輯';"));
|
||||
// 返回表管理頁(結構管理)與連接詳情的連結;欄位管理表單留在表管理頁
|
||||
assert!(html.contains(&format!(
|
||||
"href=\"/databases/{}/tables/users\"",
|
||||
created.id
|
||||
)));
|
||||
assert!(!html.contains("id=\"column-form\""));
|
||||
// 密碼永不輸出到頁面;側邊欄高亮數據庫項
|
||||
assert!(!html.contains("secret"));
|
||||
assert!(html.contains("<a href=\"/databases\" aria-current=\"page\">數據庫</a>"));
|
||||
|
||||
pool.close();
|
||||
testutil::cleanup(&path);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn table_data_page_returns_404_for_missing_database() {
|
||||
let (pool, path) = testutil::test_pool("page-table-data-404").await;
|
||||
let state = AppState {
|
||||
db: pool.clone(),
|
||||
sessions: SessionStore::new(),
|
||||
};
|
||||
|
||||
let uri = "/databases/9999/tables/users/data".parse::<Uri>().unwrap();
|
||||
let response = table_data_page(State(state), user(), Path((9999, "users".into())), uri)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let html = String::from_utf8(body.to_vec()).unwrap();
|
||||
assert!(html.contains("找不到頁面"));
|
||||
assert!(html.contains("/databases/9999/tables/users/data"));
|
||||
|
||||
pool.close();
|
||||
testutil::cleanup(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_timestamp_strips_separator_and_millis() {
|
||||
assert_eq!(
|
||||
format_timestamp("2026-01-02T03:04:05.678Z"),
|
||||
"2026-01-02 03:04:05 UTC"
|
||||
);
|
||||
assert_eq!(
|
||||
format_timestamp("2026-01-02T03:04:05Z"),
|
||||
"2026-01-02 03:04:05Z"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pages_ship_theme_toggle() {
|
||||
// 深淺色切換:head 內聯腳本(防閃爍)與 data-theme-toggle 按鈕由 base 模板全站供應
|
||||
let Html(html) = index(user()).await.unwrap();
|
||||
assert!(html.contains("data-theme-toggle"));
|
||||
assert!(html.contains("localStorage.getItem('theme')"));
|
||||
|
||||
// 獨立版式頁(404,無側欄)同樣帶有切換按鈕
|
||||
let uri = "/no/such/page".parse::<Uri>().unwrap();
|
||||
let response = not_found(uri).await.unwrap();
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let html_404 = String::from_utf8(body.to_vec()).unwrap();
|
||||
assert!(html_404.contains("data-theme-toggle"));
|
||||
assert!(html_404.contains("localStorage.getItem('theme')"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn not_found_renders_page_with_requested_path() {
|
||||
let uri = "/no/such/page".parse::<Uri>().unwrap();
|
||||
let response = not_found(uri).await.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let html = String::from_utf8(body.to_vec()).unwrap();
|
||||
assert!(html.contains("找不到頁面"));
|
||||
assert!(html.contains("<code>/no/such/page</code>"));
|
||||
assert!(html.contains("href=\"/\""));
|
||||
}
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
//! alterdb 程序入口:初始化存儲層與 HTTP API 伺服器。
|
||||
|
||||
mod connect;
|
||||
mod controllers;
|
||||
mod models;
|
||||
mod session;
|
||||
mod state;
|
||||
mod storage;
|
||||
|
||||
use axum::Router;
|
||||
use tower_http::trace::TraceLayer;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
// 日誌級別可透過 RUST_LOG 環境變數覆蓋,默認 info
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")),
|
||||
)
|
||||
.init();
|
||||
|
||||
// 資料庫檔案路徑可透過 ALTERDB_DB_PATH 覆蓋,默認 alterdb.db
|
||||
let db_path =
|
||||
std::env::var("ALTERDB_DB_PATH").unwrap_or_else(|_| storage::DEFAULT_DB_PATH.to_string());
|
||||
let pool = storage::create_pool(&db_path).expect("無法建立資料庫連接池");
|
||||
storage::init_db(&pool).await.expect("資料庫初始化失敗");
|
||||
tracing::info!("SQLite 資料庫已就緒:{db_path}");
|
||||
|
||||
let state = AppState {
|
||||
db: pool,
|
||||
sessions: session::SessionStore::new(),
|
||||
};
|
||||
|
||||
let app = Router::new()
|
||||
.merge(controllers::routes())
|
||||
.layer(TraceLayer::new_for_http())
|
||||
.with_state(state);
|
||||
|
||||
// 綁定位址可透過 ALTERDB_BIND 覆蓋(容器內需設 0.0.0.0:8080),默認僅本機可達
|
||||
let bind_addr =
|
||||
std::env::var("ALTERDB_BIND").unwrap_or_else(|_| "127.0.0.1:8080".to_string());
|
||||
let listener = tokio::net::TcpListener::bind(&bind_addr)
|
||||
.await
|
||||
.unwrap_or_else(|e| panic!("無法綁定 {bind_addr}:{e}"));
|
||||
tracing::info!(
|
||||
"alterdb 伺服器已啟動:http://{}",
|
||||
listener.local_addr().unwrap()
|
||||
);
|
||||
|
||||
axum::serve(listener, app).await.expect("伺服器運行錯誤");
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
//! Account 模型:管理介面用戶帳戶的數據結構與輸入驗證。
|
||||
//!
|
||||
//! 本模組只定義「數據長什麼樣」:實體結構([`Account`])、
|
||||
//! 建立與更新的請求載荷([`AccountInput`])及其正規化規則;
|
||||
//! SQL 與持久化細節見 [`crate::storage::account`]。
|
||||
|
||||
use argon2::Argon2;
|
||||
use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
|
||||
use rand_core::OsRng;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 帳戶實體,對應存儲層 `accounts` 表的一行。
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct Account {
|
||||
/// 主鍵(SQLite rowid)
|
||||
pub id: i64,
|
||||
/// 登入名,全表唯一
|
||||
pub username: String,
|
||||
/// 電子郵件,全表唯一
|
||||
pub email: String,
|
||||
/// 密碼的 Argon2 雜湊(PHC 格式,含隨機鹽);永不透過 API 序列化輸出
|
||||
#[serde(skip_serializing)]
|
||||
pub password_hash: String,
|
||||
/// 顯示名稱;未提供時默認同 `username`
|
||||
pub display_name: String,
|
||||
/// 是否啟用
|
||||
pub is_active: bool,
|
||||
/// 建立時間(RFC 3339 UTC,如 `2026-01-01T08:00:00.123Z`)
|
||||
pub created_at: String,
|
||||
/// 最後更新時間(RFC 3339 UTC)
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
/// 建立與更新帳戶的請求載荷(POST / PUT 共用)。
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AccountInput {
|
||||
/// 登入名(必填)
|
||||
pub username: String,
|
||||
/// 電子郵件(必填)
|
||||
pub email: String,
|
||||
/// 顯示名稱(可選,默認同 `username`)
|
||||
pub display_name: Option<String>,
|
||||
/// 是否啟用(可選,默認 `true`)
|
||||
pub is_active: Option<bool>,
|
||||
}
|
||||
|
||||
/// `username` 的長度上限(字符數)
|
||||
const USERNAME_MAX_CHARS: usize = 64;
|
||||
/// `email` 的長度上限(字符數)
|
||||
const EMAIL_MAX_CHARS: usize = 255;
|
||||
/// `display_name` 的長度上限(字符數)
|
||||
const DISPLAY_NAME_MAX_CHARS: usize = 128;
|
||||
|
||||
/// 以 Argon2(含隨機鹽)產生密碼雜湊,輸出為 PHC 格式字串(如 `$argon2id$...`)。
|
||||
///
|
||||
/// Argon2 為記憶體困難的密碼雜湊演算法,同一密碼每次雜湊的結果都不同
|
||||
/// (鹽隨機),無法從雜湊反推明文。
|
||||
pub fn hash_password(password: &str) -> Result<String, argon2::password_hash::Error> {
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
let hash = Argon2::default().hash_password(password.as_bytes(), &salt)?;
|
||||
Ok(hash.to_string())
|
||||
}
|
||||
|
||||
/// 驗證密碼是否與儲存的 PHC 格式雜湊相符;雜湊格式無效時視為不相符。
|
||||
pub fn verify_password(password: &str, stored_hash: &str) -> bool {
|
||||
let Ok(parsed) = PasswordHash::new(stored_hash) else {
|
||||
return false;
|
||||
};
|
||||
Argon2::default()
|
||||
.verify_password(password.as_bytes(), &parsed)
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
impl AccountInput {
|
||||
/// 驗證並正規化輸入:去除首尾空白、補齊默認值。
|
||||
///
|
||||
/// 返回值的 `display_name` 與 `is_active` 必為 `Some`,
|
||||
/// 可直接交給存儲層;`Err` 的訊息可直接作為 API 400
|
||||
/// 響應的錯誤內容。
|
||||
pub fn normalized(self) -> Result<Self, String> {
|
||||
let username = self.username.trim().to_string();
|
||||
if username.is_empty() {
|
||||
return Err("username 不可為空".into());
|
||||
}
|
||||
if username.chars().count() > USERNAME_MAX_CHARS {
|
||||
return Err(format!("username 長度不可超過 {USERNAME_MAX_CHARS} 個字符"));
|
||||
}
|
||||
|
||||
let email = self.email.trim().to_string();
|
||||
if email.is_empty() {
|
||||
return Err("email 不可為空".into());
|
||||
}
|
||||
if !email.contains('@') {
|
||||
return Err("email 格式不正確".into());
|
||||
}
|
||||
if email.chars().count() > EMAIL_MAX_CHARS {
|
||||
return Err(format!("email 長度不可超過 {EMAIL_MAX_CHARS} 個字符"));
|
||||
}
|
||||
|
||||
let provided_name = self
|
||||
.display_name
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|name| !name.is_empty());
|
||||
let display_name = match provided_name {
|
||||
Some(name) => name.to_string(),
|
||||
None => username.clone(),
|
||||
};
|
||||
if display_name.chars().count() > DISPLAY_NAME_MAX_CHARS {
|
||||
return Err(format!(
|
||||
"display_name 長度不可超過 {DISPLAY_NAME_MAX_CHARS} 個字符"
|
||||
));
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
username,
|
||||
email,
|
||||
display_name: Some(display_name),
|
||||
is_active: Some(self.is_active.unwrap_or(true)),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn input(username: &str, email: &str) -> AccountInput {
|
||||
AccountInput {
|
||||
username: username.into(),
|
||||
email: email.into(),
|
||||
display_name: None,
|
||||
is_active: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalized_trims_and_fills_defaults() {
|
||||
let normalized = input(" alice ", " alice@example.com ")
|
||||
.normalized()
|
||||
.unwrap();
|
||||
assert_eq!(normalized.username, "alice");
|
||||
assert_eq!(normalized.email, "alice@example.com");
|
||||
// display_name 默認同 username、is_active 默認啟用
|
||||
assert_eq!(normalized.display_name.as_deref(), Some("alice"));
|
||||
assert_eq!(normalized.is_active, Some(true));
|
||||
|
||||
// 明確提供 display_name / is_active 時:trim 後保留原值
|
||||
let normalized = AccountInput {
|
||||
display_name: Some(" Alice Chen ".into()),
|
||||
is_active: Some(false),
|
||||
..input("alice", "alice@example.com")
|
||||
}
|
||||
.normalized()
|
||||
.unwrap();
|
||||
assert_eq!(normalized.display_name.as_deref(), Some("Alice Chen"));
|
||||
assert_eq!(normalized.is_active, Some(false));
|
||||
|
||||
// 純空白的 display_name 視同未提供
|
||||
let normalized = AccountInput {
|
||||
display_name: Some(" ".into()),
|
||||
..input("alice", "alice@example.com")
|
||||
}
|
||||
.normalized()
|
||||
.unwrap();
|
||||
assert_eq!(normalized.display_name.as_deref(), Some("alice"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalized_rejects_invalid_input() {
|
||||
// 缺 username / email、email 缺 @、超長
|
||||
assert!(input("", "a@b.c").normalized().is_err());
|
||||
assert!(input("alice", "").normalized().is_err());
|
||||
assert!(input("alice", "not-an-email").normalized().is_err());
|
||||
assert!(input(&"x".repeat(65), "a@b.c").normalized().is_err());
|
||||
|
||||
// 純空白等價於空值
|
||||
assert!(input(" ", "a@b.c").normalized().is_err());
|
||||
assert!(input("alice", " ").normalized().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn password_hash_roundtrips_with_random_salt() {
|
||||
let hash = hash_password("admin").unwrap();
|
||||
// PHC 格式($argon2...)且可驗證
|
||||
assert!(hash.starts_with("$argon2"));
|
||||
assert!(verify_password("admin", &hash));
|
||||
assert!(!verify_password("wrong-password", &hash));
|
||||
// 格式無效的雜湊一律視為不相符
|
||||
assert!(!verify_password("admin", "not-a-valid-hash"));
|
||||
|
||||
// 相同密碼因隨機鹽產生不同雜湊
|
||||
assert_ne!(hash, hash_password("admin").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn account_serialization_hides_password_hash() {
|
||||
let account = Account {
|
||||
id: 1,
|
||||
username: "alice".into(),
|
||||
email: "alice@example.com".into(),
|
||||
password_hash: "$argon2id$secret".into(),
|
||||
display_name: "Alice".into(),
|
||||
is_active: true,
|
||||
created_at: "2026-01-01T00:00:00.000Z".into(),
|
||||
updated_at: "2026-01-01T00:00:00.000Z".into(),
|
||||
};
|
||||
let json = serde_json::to_value(&account).unwrap();
|
||||
assert!(json.get("password_hash").is_none());
|
||||
assert_eq!(json.get("username"), Some(&serde_json::json!("alice")));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
//! Database 模型:外部數據庫連接的數據結構與輸入驗證。
|
||||
//!
|
||||
//! 本模組只定義「數據長什麼樣」:連接類型([`DatabaseType`])、
|
||||
//! 實體結構([`Database`])、建立與更新的請求載荷([`DatabaseInput`])
|
||||
//! 及其正規化規則;SQL 與持久化細節見 [`crate::storage::database`]。
|
||||
//!
|
||||
//! 目前僅做連接的建模與管理(CRUD),尚不實際連到目標伺服器。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 數據庫類型;目前支援 MySQL 與 PostgreSQL。
|
||||
///
|
||||
/// API 與存儲層的規範字串為小寫 `"mysql"` / `"postgres"`
|
||||
/// (serde 重新命名,見枚舉屬性)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum DatabaseType {
|
||||
/// MySQL
|
||||
MySql,
|
||||
/// PostgreSQL
|
||||
Postgres,
|
||||
}
|
||||
|
||||
impl DatabaseType {
|
||||
/// 規範字串(`"mysql"` / `"postgres"`),同存儲層 `db_type` 欄位內容
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
DatabaseType::MySql => "mysql",
|
||||
DatabaseType::Postgres => "postgres",
|
||||
}
|
||||
}
|
||||
|
||||
/// 該類型的默認埠:MySQL 3306、PostgreSQL 5432
|
||||
pub fn default_port(self) -> i64 {
|
||||
match self {
|
||||
DatabaseType::MySql => 3306,
|
||||
DatabaseType::Postgres => 5432,
|
||||
}
|
||||
}
|
||||
|
||||
/// 由字串解析類型(大小寫不敏感);不支援的類型返回 `None`
|
||||
pub fn parse(value: &str) -> Option<Self> {
|
||||
match value.trim().to_ascii_lowercase().as_str() {
|
||||
"mysql" => Some(DatabaseType::MySql),
|
||||
"postgres" => Some(DatabaseType::Postgres),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 數據庫連接實體,對應存儲層 `databases` 表的一行。
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct Database {
|
||||
/// 主鍵(SQLite rowid)
|
||||
pub id: i64,
|
||||
/// 連接顯示名稱,全表唯一
|
||||
pub name: String,
|
||||
/// 數據庫類型;API 序列化欄位名為 `type`
|
||||
#[serde(rename = "type")]
|
||||
pub db_type: DatabaseType,
|
||||
/// 主機名或 IP(暫不支援 URL 形式)
|
||||
pub host: String,
|
||||
/// 埠(1–65535)
|
||||
pub port: i64,
|
||||
/// 連接用戶名
|
||||
pub username: String,
|
||||
/// 連接密碼;日後實際連線需明文可用,故不做單向雜湊,但永不透過 API 序列化輸出。
|
||||
/// 連線功能接入前僅測試讀取
|
||||
#[allow(dead_code)]
|
||||
#[serde(skip_serializing)]
|
||||
pub password: String,
|
||||
/// 目標資料庫名
|
||||
pub database_name: String,
|
||||
/// 建立時間(RFC 3339 UTC,如 `2026-01-01T08:00:00.123Z`)
|
||||
pub created_at: String,
|
||||
/// 最後更新時間(RFC 3339 UTC)
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
/// 建立與更新連接的請求載荷(POST / PUT 共用)。
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct DatabaseInput {
|
||||
/// 連接顯示名稱(必填,全表唯一)
|
||||
pub name: String,
|
||||
/// 數據庫類型(必填,`"mysql"` / `"postgres"`;API 欄位名為 `type`,
|
||||
/// 非法值由 serde 反序列化直接拒絕)
|
||||
#[serde(rename = "type")]
|
||||
pub db_type: DatabaseType,
|
||||
/// 主機名或 IP(必填)
|
||||
pub host: String,
|
||||
/// 埠(可選,默認按類型補 3306 / 5432)
|
||||
pub port: Option<i64>,
|
||||
/// 連接用戶名(必填)
|
||||
pub username: String,
|
||||
/// 連接密碼(可選;更新時缺省=保留原值,見 [`DatabaseInput::normalized`])
|
||||
pub password: Option<String>,
|
||||
/// 目標資料庫名(必填)
|
||||
pub database_name: String,
|
||||
}
|
||||
|
||||
/// `name` 的長度上限(字符數)
|
||||
const NAME_MAX_CHARS: usize = 128;
|
||||
/// `host` 的長度上限(字符數)
|
||||
const HOST_MAX_CHARS: usize = 255;
|
||||
/// `username` 的長度上限(字符數)
|
||||
const USERNAME_MAX_CHARS: usize = 64;
|
||||
/// `password` 的長度上限(字符數)
|
||||
const PASSWORD_MAX_CHARS: usize = 128;
|
||||
/// `database_name` 的長度上限(字符數;MySQL 資料庫名上限為 64)
|
||||
const DATABASE_NAME_MAX_CHARS: usize = 64;
|
||||
/// 埠的合法範圍
|
||||
const PORT_MIN: i64 = 1;
|
||||
const PORT_MAX: i64 = 65535;
|
||||
|
||||
impl DatabaseInput {
|
||||
/// 驗證並正規化輸入:去除首尾空白(密碼除外)、補齊默認值。
|
||||
///
|
||||
/// 返回值的 `port` 必為 `Some`,可直接交給存儲層。`password` 保持
|
||||
/// `Option` 貫穿:`None`(JSON 缺省該鍵)在**更新**時表示保留原密碼
|
||||
/// ——API 回應不會輸出密碼,客戶端編輯時無從回填,只能選擇覆寫或不變;
|
||||
/// 在**建立**時視為空密碼。`Err` 的訊息可直接作為 API 400 響應的錯誤內容。
|
||||
pub fn normalized(self) -> Result<Self, String> {
|
||||
let name = self.name.trim().to_string();
|
||||
if name.is_empty() {
|
||||
return Err("name 不可為空".into());
|
||||
}
|
||||
if name.chars().count() > NAME_MAX_CHARS {
|
||||
return Err(format!("name 長度不可超過 {NAME_MAX_CHARS} 個字符"));
|
||||
}
|
||||
|
||||
let host = self.host.trim().to_string();
|
||||
if host.is_empty() {
|
||||
return Err("host 不可為空".into());
|
||||
}
|
||||
if host.chars().count() > HOST_MAX_CHARS {
|
||||
return Err(format!("host 長度不可超過 {HOST_MAX_CHARS} 個字符"));
|
||||
}
|
||||
|
||||
let port = match self.port {
|
||||
Some(port) if !(PORT_MIN..=PORT_MAX).contains(&port) => {
|
||||
return Err(format!("port 須介於 {PORT_MIN} 與 {PORT_MAX} 之間"));
|
||||
}
|
||||
other => other.or(Some(self.db_type.default_port())),
|
||||
};
|
||||
|
||||
let username = self.username.trim().to_string();
|
||||
if username.is_empty() {
|
||||
return Err("username 不可為空".into());
|
||||
}
|
||||
if username.chars().count() > USERNAME_MAX_CHARS {
|
||||
return Err(format!("username 長度不可超過 {USERNAME_MAX_CHARS} 個字符"));
|
||||
}
|
||||
|
||||
if let Some(password) = self.password.as_deref() {
|
||||
if password.chars().count() > PASSWORD_MAX_CHARS {
|
||||
return Err(format!("password 長度不可超過 {PASSWORD_MAX_CHARS} 個字符"));
|
||||
}
|
||||
}
|
||||
|
||||
let database_name = self.database_name.trim().to_string();
|
||||
if database_name.is_empty() {
|
||||
return Err("database_name 不可為空".into());
|
||||
}
|
||||
if database_name.chars().count() > DATABASE_NAME_MAX_CHARS {
|
||||
return Err(format!(
|
||||
"database_name 長度不可超過 {DATABASE_NAME_MAX_CHARS} 個字符"
|
||||
));
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
name,
|
||||
db_type: self.db_type,
|
||||
host,
|
||||
port,
|
||||
username,
|
||||
password: self.password,
|
||||
database_name,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn input(name: &str, db_type: DatabaseType) -> DatabaseInput {
|
||||
DatabaseInput {
|
||||
name: name.into(),
|
||||
db_type,
|
||||
host: "127.0.0.1".into(),
|
||||
port: None,
|
||||
username: "app".into(),
|
||||
password: None,
|
||||
database_name: "shop".into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn database_type_roundtrips_through_canonical_string() {
|
||||
for db_type in [DatabaseType::MySql, DatabaseType::Postgres] {
|
||||
// as_str ↔ parse 往返;解析大小寫不敏感、容忍首尾空白
|
||||
assert_eq!(DatabaseType::parse(db_type.as_str()), Some(db_type));
|
||||
assert_eq!(
|
||||
DatabaseType::parse(&db_type.as_str().to_uppercase()),
|
||||
Some(db_type)
|
||||
);
|
||||
assert_eq!(DatabaseType::parse(" oracle "), None);
|
||||
assert_eq!(DatabaseType::parse(""), None);
|
||||
}
|
||||
assert_eq!(DatabaseType::MySql.as_str(), "mysql");
|
||||
assert_eq!(DatabaseType::Postgres.as_str(), "postgres");
|
||||
|
||||
// 默認埠
|
||||
assert_eq!(DatabaseType::MySql.default_port(), 3306);
|
||||
assert_eq!(DatabaseType::Postgres.default_port(), 5432);
|
||||
|
||||
// serde 以小寫規範字串序列化 / 反序列化
|
||||
assert_eq!(
|
||||
serde_json::to_string(&DatabaseType::Postgres).unwrap(),
|
||||
"\"postgres\""
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<DatabaseType>("\"mysql\"").unwrap(),
|
||||
DatabaseType::MySql
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalized_trims_and_fills_defaults() {
|
||||
// 缺省 port 按類型補默認;password 缺省保持 None(建立視為空、更新保留原值)
|
||||
let normalized = input(" shop-prod ", DatabaseType::MySql)
|
||||
.normalized()
|
||||
.unwrap();
|
||||
assert_eq!(normalized.name, "shop-prod");
|
||||
assert_eq!(normalized.host, "127.0.0.1");
|
||||
assert_eq!(normalized.port, Some(3306));
|
||||
assert_eq!(normalized.password, None);
|
||||
|
||||
let normalized = input("shop-prod", DatabaseType::Postgres)
|
||||
.normalized()
|
||||
.unwrap();
|
||||
assert_eq!(normalized.port, Some(5432));
|
||||
|
||||
// 明確提供 port / password 時保留原值(password 不 trim,可含空白)
|
||||
let normalized = DatabaseInput {
|
||||
port: Some(5433),
|
||||
password: Some(" pass word ".into()),
|
||||
..input("shop-prod", DatabaseType::Postgres)
|
||||
}
|
||||
.normalized()
|
||||
.unwrap();
|
||||
assert_eq!(normalized.port, Some(5433));
|
||||
assert_eq!(normalized.password.as_deref(), Some(" pass word "));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalized_rejects_invalid_input() {
|
||||
// 必填欄位空或純空白
|
||||
assert!(input("", DatabaseType::MySql).normalized().is_err());
|
||||
assert!(input(" ", DatabaseType::MySql).normalized().is_err());
|
||||
|
||||
let mut payload = input("shop-prod", DatabaseType::MySql);
|
||||
payload.host = " ".into();
|
||||
assert!(payload.normalized().is_err());
|
||||
|
||||
let mut payload = input("shop-prod", DatabaseType::MySql);
|
||||
payload.username = "".into();
|
||||
assert!(payload.normalized().is_err());
|
||||
|
||||
let mut payload = input("shop-prod", DatabaseType::MySql);
|
||||
payload.database_name = " ".into();
|
||||
assert!(payload.normalized().is_err());
|
||||
|
||||
// 埠超出範圍
|
||||
let mut payload = input("shop-prod", DatabaseType::MySql);
|
||||
payload.port = Some(0);
|
||||
assert!(payload.normalized().is_err());
|
||||
let mut payload = input("shop-prod", DatabaseType::MySql);
|
||||
payload.port = Some(65536);
|
||||
assert!(payload.normalized().is_err());
|
||||
|
||||
// 超長
|
||||
let mut payload = input("shop-prod", DatabaseType::MySql);
|
||||
payload.name = "x".repeat(129);
|
||||
assert!(payload.normalized().is_err());
|
||||
let mut payload = input("shop-prod", DatabaseType::MySql);
|
||||
payload.password = Some("x".repeat(129));
|
||||
assert!(payload.normalized().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn database_serialization_hides_password_and_renames_type() {
|
||||
let database = Database {
|
||||
id: 1,
|
||||
name: "shop-prod".into(),
|
||||
db_type: DatabaseType::MySql,
|
||||
host: "10.0.0.5".into(),
|
||||
port: 3306,
|
||||
username: "app".into(),
|
||||
password: "secret".into(),
|
||||
database_name: "shop".into(),
|
||||
created_at: "2026-01-01T00:00:00.000Z".into(),
|
||||
updated_at: "2026-01-01T00:00:00.000Z".into(),
|
||||
};
|
||||
let json = serde_json::to_value(&database).unwrap();
|
||||
// 密碼永不輸出;類型欄位名為 "type"、值為小寫規範字串
|
||||
assert!(json.get("password").is_none());
|
||||
assert_eq!(json.get("type"), Some(&serde_json::json!("mysql")));
|
||||
assert_eq!(json.get("database_name"), Some(&serde_json::json!("shop")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn input_deserializes_type_field_and_rejects_unknown_variants() {
|
||||
// API 欄位名為 "type"
|
||||
let payload = serde_json::json!({
|
||||
"name": "shop-prod",
|
||||
"type": "postgres",
|
||||
"host": "10.0.0.5",
|
||||
"username": "app",
|
||||
"database_name": "shop"
|
||||
});
|
||||
let parsed: DatabaseInput = serde_json::from_value(payload).unwrap();
|
||||
assert_eq!(parsed.db_type, DatabaseType::Postgres);
|
||||
assert_eq!(parsed.port, None);
|
||||
|
||||
// 尚未支援的類型在反序列化階段即被拒絕
|
||||
let payload = serde_json::json!({
|
||||
"name": "shop-prod",
|
||||
"type": "oracle",
|
||||
"host": "10.0.0.5",
|
||||
"username": "app",
|
||||
"database_name": "shop"
|
||||
});
|
||||
assert!(serde_json::from_value::<DatabaseInput>(payload).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
//! 模型層:定義對外的數據結構與輸入驗證,不含存儲細節。
|
||||
//!
|
||||
//! 每個模型一個子模組(如 [`account`]);對應的 SQL 與持久化
|
||||
//! 實作放在 [`crate::storage`] 的同名子模組中。
|
||||
|
||||
pub mod account;
|
||||
pub mod database;
|
||||
+257
@@ -0,0 +1,257 @@
|
||||
//! 會話管理:登入狀態的服務端存儲與 [`CurrentUser`] 提取器。
|
||||
//!
|
||||
//! 會話保存在進程內存(`HashMap`),以 OS 級亂數產生的 256 位 token
|
||||
//! 作為鑰匙,token 透過 `HttpOnly` Cookie 傳遞。伺服器重啟後會話
|
||||
//! 全部失效(重新登入即可),對本地管理工具而言可接受,且無需
|
||||
//! 外部依賴。
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use axum::extract::FromRequestParts;
|
||||
use axum::http::request::Parts;
|
||||
use axum::http::{HeaderMap, header};
|
||||
use axum::response::Redirect;
|
||||
use rand_core::{OsRng, RngCore};
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
/// 會話 Cookie 的名稱
|
||||
pub const COOKIE_NAME: &str = "alterdb_session";
|
||||
|
||||
/// 會話有效期;與 Cookie 的 `Max-Age` 保持一致
|
||||
const SESSION_TTL: Duration = Duration::from_secs(24 * 60 * 60);
|
||||
|
||||
/// 單筆會話數據(登入當下對帳戶的快照;帳戶日後變更不回寫)
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Session {
|
||||
/// 所屬帳戶主鍵;變更密碼時以 [`SessionStore::remove_all_for_user`]
|
||||
/// 批次銷毀該帳戶的全部會話
|
||||
pub user_id: i64,
|
||||
pub username: String,
|
||||
pub display_name: String,
|
||||
/// 過期時間;到期後 [`SessionStore::get`] 視同不存在
|
||||
expires_at: Instant,
|
||||
}
|
||||
|
||||
/// 進程內會話存儲;`Clone` 後內部仍共享同一份數據。
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct SessionStore {
|
||||
inner: Arc<Mutex<HashMap<String, Session>>>,
|
||||
}
|
||||
|
||||
impl SessionStore {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// 為帳戶建立會話,返回隨機 token(同時作為 Cookie 值與查詢鑰匙)
|
||||
pub fn insert(&self, user_id: i64, username: &str, display_name: &str) -> String {
|
||||
let session = Session {
|
||||
user_id,
|
||||
username: username.to_string(),
|
||||
display_name: display_name.to_string(),
|
||||
expires_at: Instant::now() + SESSION_TTL,
|
||||
};
|
||||
let token = generate_token();
|
||||
let mut guard = self.lock();
|
||||
// 順手清理已過期會話,避免長時間運行下無界增長
|
||||
guard.retain(|_, session| session.expires_at > Instant::now());
|
||||
guard.insert(token.clone(), session);
|
||||
token
|
||||
}
|
||||
|
||||
/// 以 token 查詢有效會話;不存在或已過期返回 `None`
|
||||
pub fn get(&self, token: &str) -> Option<Session> {
|
||||
self.lock()
|
||||
.get(token)
|
||||
.filter(|session| session.expires_at > Instant::now())
|
||||
.cloned()
|
||||
}
|
||||
|
||||
/// 刪除會話(登出);返回會話是否原本存在
|
||||
pub fn remove(&self, token: &str) -> bool {
|
||||
self.lock().remove(token).is_some()
|
||||
}
|
||||
|
||||
/// 銷毀指定帳戶的全部會話(變更密碼時呼叫,含當前會話);
|
||||
/// 返回被銷毀的會話數。
|
||||
pub fn remove_all_for_user(&self, user_id: i64) -> usize {
|
||||
let mut guard = self.lock();
|
||||
let before = guard.len();
|
||||
guard.retain(|_, session| session.user_id != user_id);
|
||||
before - guard.len()
|
||||
}
|
||||
|
||||
fn lock(&self) -> std::sync::MutexGuard<'_, HashMap<String, Session>> {
|
||||
self.inner.lock().expect("會話存儲鎖被毒化")
|
||||
}
|
||||
}
|
||||
|
||||
/// 生成 256 位隨機 token(64 個十六進制字符),由 OS 級密碼學安全亂數產生
|
||||
fn generate_token() -> String {
|
||||
let mut bytes = [0u8; 32];
|
||||
OsRng.fill_bytes(&mut bytes);
|
||||
bytes.iter().map(|byte| format!("{byte:02x}")).collect()
|
||||
}
|
||||
|
||||
/// 從請求的 Cookie 標頭中取出會話 token;可能有多個 Cookie 標頭,逐一掃描
|
||||
pub fn token_from_headers(headers: &HeaderMap) -> Option<String> {
|
||||
for value in headers.get_all(header::COOKIE) {
|
||||
let Ok(raw) = value.to_str() else { continue };
|
||||
for pair in raw.split(';') {
|
||||
let Some(rest) = pair.trim().strip_prefix(COOKIE_NAME) else {
|
||||
continue;
|
||||
};
|
||||
if let Some(token) = rest.strip_prefix('=') {
|
||||
if !token.is_empty() {
|
||||
return Some(token.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// 構造設置會話 Cookie 的 `Set-Cookie` 值。
|
||||
///
|
||||
/// `HttpOnly` 阻擋 JavaScript 讀取、`SameSite=Lax` 緩解 CSRF;
|
||||
/// 本地以 HTTP 提供服務,故不加 `Secure`(加上後 Cookie 不會被送出)。
|
||||
pub fn set_cookie_header(token: &str) -> String {
|
||||
format!(
|
||||
"{COOKIE_NAME}={token}; Path=/; Max-Age={}; HttpOnly; SameSite=Lax",
|
||||
SESSION_TTL.as_secs()
|
||||
)
|
||||
}
|
||||
|
||||
/// 構造清除會話 Cookie 的 `Set-Cookie` 值(登出時覆寫瀏覽器端的舊值)
|
||||
pub fn clear_cookie_header() -> String {
|
||||
format!("{COOKIE_NAME}=; Path=/; Max-Age=0; HttpOnly; SameSite=Lax")
|
||||
}
|
||||
|
||||
/// 已登入用戶(頁面處理器的提取器)。
|
||||
///
|
||||
/// 未持有效會話的請求會被重定向到 `/login`,頁面處理器無需
|
||||
/// 自行檢查登入狀態。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CurrentUser {
|
||||
/// 所屬帳戶主鍵;變更密碼等自身帳戶操作以此定位資料行
|
||||
pub user_id: i64,
|
||||
pub username: String,
|
||||
pub display_name: String,
|
||||
}
|
||||
|
||||
impl FromRequestParts<AppState> for CurrentUser {
|
||||
type Rejection = Redirect;
|
||||
|
||||
async fn from_request_parts(
|
||||
parts: &mut Parts,
|
||||
state: &AppState,
|
||||
) -> Result<Self, Self::Rejection> {
|
||||
let token = token_from_headers(&parts.headers).ok_or_else(login_redirect)?;
|
||||
let session = state.sessions.get(&token).ok_or_else(login_redirect)?;
|
||||
Ok(Self {
|
||||
user_id: session.user_id,
|
||||
username: session.username,
|
||||
display_name: session.display_name,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn login_redirect() -> Redirect {
|
||||
Redirect::to("/login")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn tokens_are_unique_64_hex_chars() {
|
||||
let token = generate_token();
|
||||
assert_eq!(token.len(), 64);
|
||||
assert!(token.chars().all(|c| c.is_ascii_hexdigit()));
|
||||
assert_ne!(token, generate_token());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn store_roundtrip_and_remove() {
|
||||
let store = SessionStore::new();
|
||||
let token = store.insert(7, "alice", "Alice");
|
||||
|
||||
let session = store.get(&token).expect("剛建立的會話應可查得");
|
||||
assert_eq!(session.user_id, 7);
|
||||
assert_eq!(session.username, "alice");
|
||||
assert_eq!(session.display_name, "Alice");
|
||||
|
||||
assert!(store.remove(&token));
|
||||
assert!(store.get(&token).is_none());
|
||||
assert!(!store.remove(&token)); // 再刪無此會話
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_all_for_user_destroys_only_that_users_sessions() {
|
||||
let store = SessionStore::new();
|
||||
let alice = store.insert(1, "alice", "Alice");
|
||||
let alice_again = store.insert(1, "alice", "Alice");
|
||||
let bob = store.insert(2, "bob", "Bob");
|
||||
|
||||
assert_eq!(store.remove_all_for_user(1), 2);
|
||||
assert!(store.get(&alice).is_none());
|
||||
assert!(store.get(&alice_again).is_none());
|
||||
// 其他帳戶的會話不受影響
|
||||
assert!(store.get(&bob).is_some());
|
||||
// 帳戶已無會話時再銷毀:返回 0
|
||||
assert_eq!(store.remove_all_for_user(1), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expired_session_is_rejected_and_purged() {
|
||||
let store = SessionStore::new();
|
||||
let token = "expired-token".to_string();
|
||||
store.lock().insert(
|
||||
token.clone(),
|
||||
Session {
|
||||
user_id: 1,
|
||||
username: "u".into(),
|
||||
display_name: "d".into(),
|
||||
expires_at: Instant::now() - Duration::from_secs(1),
|
||||
},
|
||||
);
|
||||
assert!(store.get(&token).is_none());
|
||||
|
||||
// 過期會話在下次 insert 時被清理
|
||||
store.insert(2, "v", "V");
|
||||
assert!(!store.lock().contains_key(&token));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_token_from_cookie_headers() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
header::COOKIE,
|
||||
"theme=dark; alterdb_session=abc123".parse().unwrap(),
|
||||
);
|
||||
assert_eq!(token_from_headers(&headers).as_deref(), Some("abc123"));
|
||||
|
||||
// 空 token 視同沒有會話
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(header::COOKIE, "alterdb_session=".parse().unwrap());
|
||||
assert_eq!(token_from_headers(&headers), None);
|
||||
|
||||
// 沒有任何 Cookie 標頭
|
||||
assert_eq!(token_from_headers(&HeaderMap::new()), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cookie_headers_carry_security_attributes() {
|
||||
let set = set_cookie_header("tok");
|
||||
assert!(set.starts_with("alterdb_session=tok; Path=/; Max-Age=86400"));
|
||||
assert!(set.contains("HttpOnly"));
|
||||
assert!(set.contains("SameSite=Lax"));
|
||||
|
||||
let clear = clear_cookie_header();
|
||||
assert!(clear.starts_with("alterdb_session=; Path=/; Max-Age=0"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
//! 應用共享狀態:控制器透過 `State<AppState>` 取用。
|
||||
|
||||
use crate::session::SessionStore;
|
||||
use deadpool_sqlite::Pool;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub db: Pool,
|
||||
/// 登入會話存儲(進程內存,伺服器重啟後全部失效)
|
||||
pub sessions: SessionStore,
|
||||
}
|
||||
@@ -0,0 +1,473 @@
|
||||
//! Account 模型的存儲層:`accounts` 表結構與 CRUD 查詢。
|
||||
|
||||
use deadpool_sqlite::Pool;
|
||||
use rusqlite::{Connection, OptionalExtension, Row, params};
|
||||
|
||||
use super::StorageError;
|
||||
use crate::models::account::{Account, AccountInput};
|
||||
|
||||
/// `accounts` 表的建表語句(`IF NOT EXISTS`,可重複執行)。
|
||||
///
|
||||
/// 時間戳以 RFC 3339 UTC 文字存儲,由 SQLite 於寫入時生成,
|
||||
/// 因此所有時間來源一致,無需依賴應用端的時鐘。
|
||||
const CREATE_TABLE_SQL: &str = "\
|
||||
CREATE TABLE IF NOT EXISTS accounts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL DEFAULT '',
|
||||
display_name TEXT NOT NULL DEFAULT '',
|
||||
is_active INTEGER NOT NULL DEFAULT 1 CHECK (is_active IN (0, 1)),
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||
)";
|
||||
|
||||
/// 查詢時選取的欄位清單(與 [`row_to_account`] 的讀取順序對應)
|
||||
const ACCOUNT_COLUMNS: &str =
|
||||
"id, username, email, password_hash, display_name, is_active, created_at, updated_at";
|
||||
|
||||
/// 默認管理員帳號的用戶名與初始密碼
|
||||
pub const ADMIN_USERNAME: &str = "admin";
|
||||
pub const ADMIN_DEFAULT_PASSWORD: &str = "admin";
|
||||
|
||||
/// 確保 `accounts` 表的結構符合最新 schema;由 [`super::init_db`] 於啟動時呼叫。
|
||||
///
|
||||
/// 冪等可重複執行;對舊版表結構(缺 `password_hash` 欄位)會做輕量遷移。
|
||||
pub fn ensure_table(conn: &Connection) -> rusqlite::Result<()> {
|
||||
conn.execute_batch(CREATE_TABLE_SQL)?;
|
||||
migrate(conn)
|
||||
}
|
||||
|
||||
/// 輕量遷移:為舊版 `accounts` 表補上缺失的欄位(既有資料填默認值)
|
||||
fn migrate(conn: &Connection) -> rusqlite::Result<()> {
|
||||
if !has_column(conn, "accounts", "password_hash")? {
|
||||
conn.execute(
|
||||
"ALTER TABLE accounts ADD COLUMN password_hash TEXT NOT NULL DEFAULT ''",
|
||||
[],
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 檢查表是否存在指定欄位(PRAGMA table_info 的 name 欄位位於索引 1)
|
||||
fn has_column(conn: &Connection, table: &str, column: &str) -> rusqlite::Result<bool> {
|
||||
let mut stmt = conn.prepare(&format!("PRAGMA table_info({table})"))?;
|
||||
let names = stmt.query_map([], |row| row.get::<_, String>(1))?;
|
||||
for name in names {
|
||||
if name? == column {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
/// 確保默認管理員帳號(`admin` / `admin`)存在。
|
||||
///
|
||||
/// 僅在 `admin` 不存在時建立,重啟不會覆蓋既有帳號(含日後改過的密碼);
|
||||
/// 返回是否實際建立。密碼雜湊由呼叫端([`super::init_db`])先行產生後傳入。
|
||||
pub fn seed_admin(conn: &Connection, password_hash: &str) -> rusqlite::Result<bool> {
|
||||
let exists: bool = conn.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM accounts WHERE username = ?1)",
|
||||
params![ADMIN_USERNAME],
|
||||
|row| Ok(row.get::<_, i64>(0)? != 0),
|
||||
)?;
|
||||
if exists {
|
||||
return Ok(false);
|
||||
}
|
||||
conn.execute(
|
||||
"INSERT INTO accounts (username, email, display_name, is_active, password_hash)
|
||||
VALUES (?1, ?2, ?3, 1, ?4)",
|
||||
params![
|
||||
ADMIN_USERNAME,
|
||||
"admin@example.com",
|
||||
"Administrator",
|
||||
password_hash
|
||||
],
|
||||
)?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// 將查詢結果行映射為 [`Account`]
|
||||
fn row_to_account(row: &Row<'_>) -> rusqlite::Result<Account> {
|
||||
Ok(Account {
|
||||
id: row.get(0)?,
|
||||
username: row.get(1)?,
|
||||
email: row.get(2)?,
|
||||
password_hash: row.get(3)?,
|
||||
display_name: row.get(4)?,
|
||||
is_active: row.get::<_, i64>(5)? != 0,
|
||||
created_at: row.get(6)?,
|
||||
updated_at: row.get(7)?,
|
||||
})
|
||||
}
|
||||
|
||||
/// 列出所有帳戶(按 `id` 升序)
|
||||
pub async fn list(pool: &Pool) -> Result<Vec<Account>, StorageError> {
|
||||
let sql = format!("SELECT {ACCOUNT_COLUMNS} FROM accounts ORDER BY id");
|
||||
pool.get()
|
||||
.await
|
||||
.map_err(StorageError::pool)?
|
||||
.interact(move |conn| {
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let accounts = stmt
|
||||
.query_map([], row_to_account)?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
Ok(accounts)
|
||||
})
|
||||
.await
|
||||
.map_err(StorageError::interact)?
|
||||
.map_err(StorageError::Sqlite)
|
||||
}
|
||||
|
||||
/// 建立帳戶;`input` 應先經 [`AccountInput::normalized`] 正規化。
|
||||
///
|
||||
/// 唯一欄位(username / email)衝突時,返回的錯誤可由
|
||||
/// [`StorageError::is_unique_violation`] 判別。
|
||||
pub async fn create(pool: &Pool, input: AccountInput) -> Result<Account, StorageError> {
|
||||
// 經正規化的輸入必為 Some;此處僅作防禦性默認值
|
||||
let display_name = input.display_name.unwrap_or_default();
|
||||
let is_active = input.is_active.unwrap_or(true);
|
||||
let sql = format!(
|
||||
"INSERT INTO accounts (username, email, display_name, is_active)
|
||||
VALUES (?1, ?2, ?3, ?4)
|
||||
RETURNING {ACCOUNT_COLUMNS}"
|
||||
);
|
||||
pool.get()
|
||||
.await
|
||||
.map_err(StorageError::pool)?
|
||||
.interact(move |conn| {
|
||||
conn.query_row(
|
||||
&sql,
|
||||
params![input.username, input.email, display_name, is_active],
|
||||
row_to_account,
|
||||
)
|
||||
})
|
||||
.await
|
||||
.map_err(StorageError::interact)?
|
||||
.map_err(StorageError::Sqlite)
|
||||
}
|
||||
|
||||
/// 依主鍵查詢帳戶;不存在時返回 `None`
|
||||
pub async fn get_by_id(pool: &Pool, id: i64) -> Result<Option<Account>, StorageError> {
|
||||
let sql = format!("SELECT {ACCOUNT_COLUMNS} FROM accounts WHERE id = ?1");
|
||||
pool.get()
|
||||
.await
|
||||
.map_err(StorageError::pool)?
|
||||
.interact(move |conn| conn.query_row(&sql, params![id], row_to_account).optional())
|
||||
.await
|
||||
.map_err(StorageError::interact)?
|
||||
.map_err(StorageError::Sqlite)
|
||||
}
|
||||
|
||||
/// 依用戶名查詢帳戶(登入用);不存在時返回 `None`
|
||||
pub async fn get_by_username(pool: &Pool, username: &str) -> Result<Option<Account>, StorageError> {
|
||||
let username = username.to_string(); // interact 閉包需 'static
|
||||
let sql = format!("SELECT {ACCOUNT_COLUMNS} FROM accounts WHERE username = ?1");
|
||||
pool.get()
|
||||
.await
|
||||
.map_err(StorageError::pool)?
|
||||
.interact(move |conn| {
|
||||
conn.query_row(&sql, params![username], row_to_account)
|
||||
.optional()
|
||||
})
|
||||
.await
|
||||
.map_err(StorageError::interact)?
|
||||
.map_err(StorageError::Sqlite)
|
||||
}
|
||||
|
||||
/// 依主鍵更新帳戶的可編輯欄位並刷新 `updated_at`;不存在時返回 `None`
|
||||
pub async fn update(
|
||||
pool: &Pool,
|
||||
id: i64,
|
||||
input: AccountInput,
|
||||
) -> Result<Option<Account>, StorageError> {
|
||||
let display_name = input.display_name.unwrap_or_default();
|
||||
let is_active = input.is_active.unwrap_or(true);
|
||||
let sql = format!(
|
||||
"UPDATE accounts
|
||||
SET username = ?1,
|
||||
email = ?2,
|
||||
display_name = ?3,
|
||||
is_active = ?4,
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
||||
WHERE id = ?5
|
||||
RETURNING {ACCOUNT_COLUMNS}"
|
||||
);
|
||||
pool.get()
|
||||
.await
|
||||
.map_err(StorageError::pool)?
|
||||
.interact(move |conn| {
|
||||
conn.query_row(
|
||||
&sql,
|
||||
params![input.username, input.email, display_name, is_active, id],
|
||||
row_to_account,
|
||||
)
|
||||
.optional()
|
||||
})
|
||||
.await
|
||||
.map_err(StorageError::interact)?
|
||||
.map_err(StorageError::Sqlite)
|
||||
}
|
||||
|
||||
/// 依主鍵更新密碼雜湊並刷新 `updated_at`;返回帳戶是否存在(不存在時無行受影響)。
|
||||
///
|
||||
/// `password_hash` 應為 Argon2 PHC 格式字串(由
|
||||
/// [`crate::models::account::hash_password`] 產生),本層不做密碼規則驗證。
|
||||
pub async fn update_password(
|
||||
pool: &Pool,
|
||||
id: i64,
|
||||
password_hash: &str,
|
||||
) -> Result<bool, StorageError> {
|
||||
let password_hash = password_hash.to_string(); // interact 閉包需 'static
|
||||
pool.get()
|
||||
.await
|
||||
.map_err(StorageError::pool)?
|
||||
.interact(move |conn| {
|
||||
conn.execute(
|
||||
"UPDATE accounts
|
||||
SET password_hash = ?1,
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
||||
WHERE id = ?2",
|
||||
params![password_hash, id],
|
||||
)
|
||||
})
|
||||
.await
|
||||
.map_err(StorageError::interact)?
|
||||
.map_err(StorageError::Sqlite)
|
||||
.map(|affected| affected > 0)
|
||||
}
|
||||
|
||||
/// 依主鍵刪除帳戶;返回是否有行被刪除
|
||||
pub async fn delete(pool: &Pool, id: i64) -> Result<bool, StorageError> {
|
||||
pool.get()
|
||||
.await
|
||||
.map_err(StorageError::pool)?
|
||||
.interact(move |conn| conn.execute("DELETE FROM accounts WHERE id = ?1", params![id]))
|
||||
.await
|
||||
.map_err(StorageError::interact)?
|
||||
.map_err(StorageError::Sqlite)
|
||||
.map(|affected| affected > 0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::storage::testutil;
|
||||
|
||||
/// 構造已正規化的輸入(與控制器層的實際調用路徑一致)
|
||||
fn input(username: &str, email: &str) -> AccountInput {
|
||||
AccountInput {
|
||||
username: username.into(),
|
||||
email: email.into(),
|
||||
display_name: None,
|
||||
is_active: None,
|
||||
}
|
||||
.normalized()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn account_crud_roundtrip() {
|
||||
let (pool, path) = testutil::test_pool("storage-account").await;
|
||||
|
||||
// 建立:默認值生效(display_name 同 username、is_active = true)
|
||||
let created = create(&pool, input("alice", "alice@example.com"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(created.username, "alice");
|
||||
assert_eq!(created.display_name, "alice");
|
||||
assert!(created.is_active);
|
||||
assert!(created.created_at.ends_with('Z')); // RFC 3339 UTC
|
||||
|
||||
// 依主鍵查詢:存在 / 不存在
|
||||
let fetched = get_by_id(&pool, created.id).await.unwrap().unwrap();
|
||||
assert_eq!(fetched.email, "alice@example.com");
|
||||
assert!(get_by_id(&pool, created.id + 1000).await.unwrap().is_none());
|
||||
|
||||
// 列表:按 id 升序包含全部帳戶(含初始化自動建立的 admin)
|
||||
create(&pool, input("bob", "bob@example.com"))
|
||||
.await
|
||||
.unwrap();
|
||||
let accounts = list(&pool).await.unwrap();
|
||||
assert_eq!(accounts.len(), 3);
|
||||
assert!(accounts.windows(2).all(|pair| pair[0].id < pair[1].id));
|
||||
|
||||
// 更新:可編輯欄位被覆寫
|
||||
let updated = update(
|
||||
&pool,
|
||||
created.id,
|
||||
AccountInput {
|
||||
username: "alice".into(),
|
||||
email: "alice@new.example.com".into(),
|
||||
display_name: Some("Alice".into()),
|
||||
is_active: Some(false),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("被更新的帳戶應存在");
|
||||
assert_eq!(updated.display_name, "Alice");
|
||||
assert!(!updated.is_active);
|
||||
assert_eq!(updated.email, "alice@new.example.com");
|
||||
|
||||
// 刪除:成功一次,再刪無行受影響
|
||||
assert!(delete(&pool, created.id).await.unwrap());
|
||||
assert!(get_by_id(&pool, created.id).await.unwrap().is_none());
|
||||
assert!(!delete(&pool, created.id).await.unwrap());
|
||||
|
||||
pool.close();
|
||||
testutil::cleanup(&path);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn updates_password_hash_by_id() {
|
||||
let (pool, path) = testutil::test_pool("storage-update-password").await;
|
||||
let admin = get_by_username(&pool, super::ADMIN_USERNAME)
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("admin 應存在");
|
||||
|
||||
let new_hash = crate::models::account::hash_password("new-secret").unwrap();
|
||||
assert!(update_password(&pool, admin.id, &new_hash).await.unwrap());
|
||||
|
||||
let updated = get_by_id(&pool, admin.id).await.unwrap().unwrap();
|
||||
assert_eq!(updated.password_hash, new_hash);
|
||||
// 其餘欄位不受影響
|
||||
assert_eq!(updated.username, admin.username);
|
||||
assert_eq!(updated.email, admin.email);
|
||||
assert!(updated.is_active);
|
||||
|
||||
// 不存在的帳號:無行受影響 → false
|
||||
assert!(
|
||||
!update_password(&pool, admin.id + 1000, &new_hash)
|
||||
.await
|
||||
.unwrap()
|
||||
);
|
||||
|
||||
pool.close();
|
||||
testutil::cleanup(&path);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn finds_account_by_username() {
|
||||
let (pool, path) = testutil::test_pool("storage-by-username").await;
|
||||
|
||||
// 初始化播種的 admin 可用用戶名查得(登入路徑)
|
||||
let admin = get_by_username(&pool, super::ADMIN_USERNAME)
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("admin 應可依用戶名查得");
|
||||
assert_eq!(admin.email, "admin@example.com");
|
||||
|
||||
// 不存在的用戶名 → None;區分大小寫(SQLite TEXT 預設 BINARY 排序)
|
||||
assert!(
|
||||
get_by_username(&pool, "no-such-user")
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
assert!(get_by_username(&pool, "Admin").await.unwrap().is_none());
|
||||
|
||||
pool.close();
|
||||
testutil::cleanup(&path);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unique_constraint_conflicts() {
|
||||
let (pool, path) = testutil::test_pool("storage-account-unique").await;
|
||||
|
||||
create(&pool, input("alice", "alice@example.com"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let duplicate_username = create(&pool, input("alice", "other@example.com"))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(duplicate_username.is_unique_violation());
|
||||
|
||||
let duplicate_email = create(&pool, input("bob", "alice@example.com"))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(duplicate_email.is_unique_violation());
|
||||
|
||||
pool.close();
|
||||
testutil::cleanup(&path);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn seeds_admin_once_with_hashed_password() {
|
||||
// test_pool 內部走的就是 init_db:應已建立 admin
|
||||
let (pool, path) = testutil::test_pool("seed-admin").await;
|
||||
|
||||
let accounts = list(&pool).await.unwrap();
|
||||
assert_eq!(accounts.len(), 1);
|
||||
let admin = &accounts[0];
|
||||
assert_eq!(admin.username, super::ADMIN_USERNAME);
|
||||
assert_eq!(admin.display_name, "Administrator");
|
||||
assert!(admin.is_active);
|
||||
assert!(crate::models::account::verify_password(
|
||||
super::ADMIN_DEFAULT_PASSWORD,
|
||||
&admin.password_hash,
|
||||
));
|
||||
assert!(!crate::models::account::verify_password(
|
||||
"wrong",
|
||||
&admin.password_hash
|
||||
));
|
||||
|
||||
// 再次初始化:不重複建立、不覆蓋(日後改過密碼也不會被重置)
|
||||
crate::storage::init_db(&pool).await.unwrap();
|
||||
assert_eq!(list(&pool).await.unwrap().len(), 1);
|
||||
|
||||
pool.close();
|
||||
testutil::cleanup(&path);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn migrates_legacy_schema_then_seeds_admin() {
|
||||
let (pool, path) = testutil::raw_pool("legacy-migrate").await;
|
||||
|
||||
// 舊版結構:accounts 表沒有 password_hash 欄位,且已有舊資料
|
||||
pool.get()
|
||||
.await
|
||||
.unwrap()
|
||||
.interact(|conn| {
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE accounts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
display_name TEXT NOT NULL DEFAULT '',
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
);
|
||||
INSERT INTO accounts (username, email) VALUES ('legacy', 'legacy@example.com');",
|
||||
)
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
// init_db 應完成遷移(補欄位)並播種 admin
|
||||
crate::storage::init_db(&pool).await.unwrap();
|
||||
|
||||
let accounts = list(&pool).await.unwrap();
|
||||
assert_eq!(accounts.len(), 2);
|
||||
let legacy = accounts
|
||||
.iter()
|
||||
.find(|account| account.username == "legacy")
|
||||
.expect("舊資料應保留");
|
||||
assert_eq!(legacy.password_hash, ""); // 舊資料補默認空值
|
||||
let admin = accounts
|
||||
.iter()
|
||||
.find(|account| account.username == super::ADMIN_USERNAME)
|
||||
.expect("admin 應被播種");
|
||||
assert!(crate::models::account::verify_password(
|
||||
super::ADMIN_DEFAULT_PASSWORD,
|
||||
&admin.password_hash,
|
||||
));
|
||||
|
||||
pool.close();
|
||||
testutil::cleanup(&path);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
//! Database 模型的存儲層:`databases` 表結構與 CRUD 查詢。
|
||||
|
||||
use deadpool_sqlite::Pool;
|
||||
use rusqlite::types::{FromSql, FromSqlError, FromSqlResult, ToSql, ToSqlOutput, ValueRef};
|
||||
use rusqlite::{Connection, OptionalExtension, Row, params};
|
||||
|
||||
use super::StorageError;
|
||||
use crate::models::database::{Database, DatabaseInput, DatabaseType};
|
||||
|
||||
/// `databases` 表的建表語句(`IF NOT EXISTS`,可重複執行)。
|
||||
///
|
||||
/// 時間戳以 RFC 3339 UTC 文字存儲,由 SQLite 於寫入時生成,
|
||||
/// 因此所有時間來源一致,無需依賴應用端的時鐘。
|
||||
/// `db_type` 以小寫規範字串存儲,CHECK 約束保證只含支援的類型。
|
||||
const CREATE_TABLE_SQL: &str = "\
|
||||
CREATE TABLE IF NOT EXISTS databases (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
db_type TEXT NOT NULL CHECK (db_type IN ('mysql', 'postgres')),
|
||||
host TEXT NOT NULL,
|
||||
port INTEGER NOT NULL,
|
||||
username TEXT NOT NULL,
|
||||
password TEXT NOT NULL DEFAULT '',
|
||||
database_name TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||
)";
|
||||
|
||||
/// 查詢時選取的欄位清單(與 [`row_to_database`] 的讀取順序對應)
|
||||
const DATABASE_COLUMNS: &str =
|
||||
"id, name, db_type, host, port, username, password, database_name, created_at, updated_at";
|
||||
|
||||
impl ToSql for DatabaseType {
|
||||
fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
|
||||
Ok(ToSqlOutput::Borrowed(self.as_str().into()))
|
||||
}
|
||||
}
|
||||
|
||||
impl FromSql for DatabaseType {
|
||||
fn column_result(value: ValueRef<'_>) -> FromSqlResult<Self> {
|
||||
let text = value.as_str()?;
|
||||
DatabaseType::parse(text).ok_or(FromSqlError::InvalidType)
|
||||
}
|
||||
}
|
||||
|
||||
/// 確保 `databases` 表的結構存在;由 [`super::init_db`] 於啟動時呼叫。
|
||||
///
|
||||
/// 冪等可重複執行;全新表,無舊版結構需遷移。
|
||||
pub fn ensure_table(conn: &Connection) -> rusqlite::Result<()> {
|
||||
conn.execute_batch(CREATE_TABLE_SQL)
|
||||
}
|
||||
|
||||
/// 將查詢結果行映射為 [`Database`]
|
||||
fn row_to_database(row: &Row<'_>) -> rusqlite::Result<Database> {
|
||||
Ok(Database {
|
||||
id: row.get(0)?,
|
||||
name: row.get(1)?,
|
||||
db_type: row.get(2)?,
|
||||
host: row.get(3)?,
|
||||
port: row.get(4)?,
|
||||
username: row.get(5)?,
|
||||
password: row.get(6)?,
|
||||
database_name: row.get(7)?,
|
||||
created_at: row.get(8)?,
|
||||
updated_at: row.get(9)?,
|
||||
})
|
||||
}
|
||||
|
||||
/// 列出所有數據庫連接(按 `id` 升序)
|
||||
pub async fn list(pool: &Pool) -> Result<Vec<Database>, StorageError> {
|
||||
let sql = format!("SELECT {DATABASE_COLUMNS} FROM databases ORDER BY id");
|
||||
pool.get()
|
||||
.await
|
||||
.map_err(StorageError::pool)?
|
||||
.interact(move |conn| {
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let databases = stmt
|
||||
.query_map([], row_to_database)?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
Ok(databases)
|
||||
})
|
||||
.await
|
||||
.map_err(StorageError::interact)?
|
||||
.map_err(StorageError::Sqlite)
|
||||
}
|
||||
|
||||
/// 建立數據庫連接;`input` 應先經 [`DatabaseInput::normalized`] 正規化。
|
||||
///
|
||||
/// `name` 衝突時,返回的錯誤可由 [`StorageError::is_unique_violation`] 判別。
|
||||
pub async fn create(pool: &Pool, input: DatabaseInput) -> Result<Database, StorageError> {
|
||||
// port 經正規化必為 Some;password 為 None 時建立視為空密碼
|
||||
let port = input.port.unwrap_or_else(|| input.db_type.default_port());
|
||||
let password = input.password.unwrap_or_default();
|
||||
let sql = format!(
|
||||
"INSERT INTO databases (name, db_type, host, port, username, password, database_name)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
|
||||
RETURNING {DATABASE_COLUMNS}"
|
||||
);
|
||||
pool.get()
|
||||
.await
|
||||
.map_err(StorageError::pool)?
|
||||
.interact(move |conn| {
|
||||
conn.query_row(
|
||||
&sql,
|
||||
params![
|
||||
input.name,
|
||||
input.db_type,
|
||||
input.host,
|
||||
port,
|
||||
input.username,
|
||||
password,
|
||||
input.database_name
|
||||
],
|
||||
row_to_database,
|
||||
)
|
||||
})
|
||||
.await
|
||||
.map_err(StorageError::interact)?
|
||||
.map_err(StorageError::Sqlite)
|
||||
}
|
||||
|
||||
/// 依主鍵查詢數據庫連接;不存在時返回 `None`
|
||||
pub async fn get_by_id(pool: &Pool, id: i64) -> Result<Option<Database>, StorageError> {
|
||||
let sql = format!("SELECT {DATABASE_COLUMNS} FROM databases WHERE id = ?1");
|
||||
pool.get()
|
||||
.await
|
||||
.map_err(StorageError::pool)?
|
||||
.interact(move |conn| {
|
||||
conn.query_row(&sql, params![id], row_to_database)
|
||||
.optional()
|
||||
})
|
||||
.await
|
||||
.map_err(StorageError::interact)?
|
||||
.map_err(StorageError::Sqlite)
|
||||
}
|
||||
|
||||
/// 依主鍵更新連接的全部可編輯欄位並刷新 `updated_at`;不存在時返回 `None`。
|
||||
///
|
||||
/// `input.password` 為 `None`(JSON 缺省該鍵)時保留原密碼:API 回應不
|
||||
/// 輸出密碼,編輯端無從回填,缺省即「不變更」;`Some` 則覆寫(含清空)。
|
||||
pub async fn update(
|
||||
pool: &Pool,
|
||||
id: i64,
|
||||
input: DatabaseInput,
|
||||
) -> Result<Option<Database>, StorageError> {
|
||||
let port = input.port.unwrap_or_else(|| input.db_type.default_port());
|
||||
let sql = format!(
|
||||
"UPDATE databases
|
||||
SET name = ?1,
|
||||
db_type = ?2,
|
||||
host = ?3,
|
||||
port = ?4,
|
||||
username = ?5,
|
||||
password = COALESCE(?6, password),
|
||||
database_name = ?7,
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
||||
WHERE id = ?8
|
||||
RETURNING {DATABASE_COLUMNS}"
|
||||
);
|
||||
pool.get()
|
||||
.await
|
||||
.map_err(StorageError::pool)?
|
||||
.interact(move |conn| {
|
||||
conn.query_row(
|
||||
&sql,
|
||||
params![
|
||||
input.name,
|
||||
input.db_type,
|
||||
input.host,
|
||||
port,
|
||||
input.username,
|
||||
input.password,
|
||||
input.database_name,
|
||||
id
|
||||
],
|
||||
row_to_database,
|
||||
)
|
||||
.optional()
|
||||
})
|
||||
.await
|
||||
.map_err(StorageError::interact)?
|
||||
.map_err(StorageError::Sqlite)
|
||||
}
|
||||
|
||||
/// 依主鍵刪除數據庫連接;返回是否有行被刪除
|
||||
pub async fn delete(pool: &Pool, id: i64) -> Result<bool, StorageError> {
|
||||
pool.get()
|
||||
.await
|
||||
.map_err(StorageError::pool)?
|
||||
.interact(move |conn| conn.execute("DELETE FROM databases WHERE id = ?1", params![id]))
|
||||
.await
|
||||
.map_err(StorageError::interact)?
|
||||
.map_err(StorageError::Sqlite)
|
||||
.map(|affected| affected > 0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::storage::testutil;
|
||||
|
||||
/// 構造已正規化的輸入(與控制器層的實際調用路徑一致)
|
||||
fn input(name: &str, db_type: DatabaseType) -> DatabaseInput {
|
||||
DatabaseInput {
|
||||
name: name.into(),
|
||||
db_type,
|
||||
host: "10.0.0.5".into(),
|
||||
port: None,
|
||||
username: "app".into(),
|
||||
password: Some("secret".into()),
|
||||
database_name: "shop".into(),
|
||||
}
|
||||
.normalized()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn database_crud_roundtrip() {
|
||||
let (pool, path) = testutil::test_pool("storage-database").await;
|
||||
|
||||
// 建立:port 按類型補默認(MySQL → 3306)
|
||||
let created = create(&pool, input("shop-prod", DatabaseType::MySql))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(created.name, "shop-prod");
|
||||
assert_eq!(created.db_type, DatabaseType::MySql);
|
||||
assert_eq!(created.port, 3306);
|
||||
assert_eq!(created.password, "secret"); // 內部讀取可得,僅 API 序列化時隱藏
|
||||
assert!(created.created_at.ends_with('Z')); // RFC 3339 UTC
|
||||
|
||||
// 依主鍵查詢:存在 / 不存在
|
||||
let fetched = get_by_id(&pool, created.id).await.unwrap().unwrap();
|
||||
assert_eq!(fetched.host, "10.0.0.5");
|
||||
assert!(get_by_id(&pool, created.id + 1000).await.unwrap().is_none());
|
||||
|
||||
// 列表:按 id 升序包含全部連接
|
||||
create(&pool, input("reporting", DatabaseType::Postgres))
|
||||
.await
|
||||
.unwrap();
|
||||
let databases = list(&pool).await.unwrap();
|
||||
assert_eq!(databases.len(), 2);
|
||||
assert!(databases.windows(2).all(|pair| pair[0].id < pair[1].id));
|
||||
assert_eq!(databases[1].db_type, DatabaseType::Postgres);
|
||||
assert_eq!(databases[1].port, 5432); // PostgreSQL 默認埠
|
||||
|
||||
// 更新:可編輯欄位被覆寫(含類型變更)
|
||||
let updated = update(
|
||||
&pool,
|
||||
created.id,
|
||||
DatabaseInput {
|
||||
name: "shop-prod".into(),
|
||||
db_type: DatabaseType::Postgres,
|
||||
host: "10.0.0.6".into(),
|
||||
port: Some(5433),
|
||||
username: "app2".into(),
|
||||
password: Some("new-secret".into()),
|
||||
database_name: "shop2".into(),
|
||||
}
|
||||
.normalized()
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("被更新的連接應存在");
|
||||
assert_eq!(updated.db_type, DatabaseType::Postgres);
|
||||
assert_eq!(updated.host, "10.0.0.6");
|
||||
assert_eq!(updated.port, 5433);
|
||||
assert_eq!(updated.password, "new-secret");
|
||||
|
||||
// 更新時 password 缺省(None)=保留原密碼
|
||||
let updated = update(
|
||||
&pool,
|
||||
created.id,
|
||||
DatabaseInput {
|
||||
name: "shop-prod".into(),
|
||||
db_type: DatabaseType::Postgres,
|
||||
host: "10.0.0.6".into(),
|
||||
port: Some(5433),
|
||||
username: "app2".into(),
|
||||
password: None,
|
||||
database_name: "shop2".into(),
|
||||
}
|
||||
.normalized()
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("被更新的連接應存在");
|
||||
assert_eq!(updated.password, "new-secret");
|
||||
|
||||
// 刪除:成功一次,再刪無行受影響
|
||||
assert!(delete(&pool, created.id).await.unwrap());
|
||||
assert!(get_by_id(&pool, created.id).await.unwrap().is_none());
|
||||
assert!(!delete(&pool, created.id).await.unwrap());
|
||||
|
||||
pool.close();
|
||||
testutil::cleanup(&path);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unique_name_conflicts() {
|
||||
let (pool, path) = testutil::test_pool("storage-database-unique").await;
|
||||
|
||||
create(&pool, input("shop-prod", DatabaseType::MySql))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let duplicate = create(&pool, input("shop-prod", DatabaseType::Postgres))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(duplicate.is_unique_violation());
|
||||
|
||||
pool.close();
|
||||
testutil::cleanup(&path);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn db_type_column_roundtrips_as_canonical_string() {
|
||||
let (pool, path) = testutil::test_pool("storage-database-type").await;
|
||||
|
||||
let created = create(&pool, input("shop-prod", DatabaseType::Postgres))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// 存儲內容為小寫規範字串(非 Debug 格式 "Postgres")
|
||||
let stored: String = pool
|
||||
.get()
|
||||
.await
|
||||
.unwrap()
|
||||
.interact(move |conn| {
|
||||
conn.query_row(
|
||||
"SELECT db_type FROM databases WHERE id = ?1",
|
||||
params![created.id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(stored, "postgres");
|
||||
|
||||
pool.close();
|
||||
testutil::cleanup(&path);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
//! 存儲層:SQLite 連接池的建立、資料庫初始化與各模型的持久化實作。
|
||||
//!
|
||||
//! 每個模型一個子模組(如 [`account`]),各自封裝對應表的 SQL;
|
||||
//! 共用的錯誤類型 [`StorageError`] 定義於本模組。
|
||||
|
||||
pub mod account;
|
||||
pub mod database;
|
||||
|
||||
use deadpool_sqlite::{Config, Pool, Runtime};
|
||||
|
||||
/// 預設資料庫檔案路徑,可透過環境變數 `ALTERDB_DB_PATH` 覆蓋
|
||||
pub const DEFAULT_DB_PATH: &str = "alterdb.db";
|
||||
|
||||
/// 建立 SQLite 連接池
|
||||
pub fn create_pool(db_path: &str) -> Result<Pool, Box<dyn std::error::Error>> {
|
||||
Ok(Config::new(db_path).create_pool(Runtime::Tokio1)?)
|
||||
}
|
||||
|
||||
/// 初始化資料庫:啟用 WAL 日誌模式(一次寫入即持久生效)、確保各模型的
|
||||
/// 表結構存在(冪等,含輕量遷移),並播種默認管理員帳號(僅在不存在時建立)。
|
||||
pub async fn init_db(pool: &Pool) -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Argon2 雜湊需耗時數十至數百毫秒,放在互動閉包外先行計算
|
||||
let admin_hash = crate::models::account::hash_password(account::ADMIN_DEFAULT_PASSWORD)?;
|
||||
pool.get()
|
||||
.await?
|
||||
.interact(move |conn| {
|
||||
conn.query_row("PRAGMA journal_mode=WAL", [], |_| Ok(()))?;
|
||||
account::ensure_table(conn)?;
|
||||
database::ensure_table(conn)?;
|
||||
if account::seed_admin(conn, &admin_hash)? {
|
||||
tracing::info!(
|
||||
"已自動建立默認管理員帳號:{}(初始密碼同帳號,建議儘早變更)",
|
||||
account::ADMIN_USERNAME
|
||||
);
|
||||
}
|
||||
Ok::<(), rusqlite::Error>(())
|
||||
})
|
||||
.await??;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 存儲層錯誤:涵蓋連接池、連線互動與 SQLite 執行三類來源。
|
||||
#[derive(Debug)]
|
||||
pub enum StorageError {
|
||||
/// 無法從連接池取得連線(資料庫可能不可用)
|
||||
Pool(String),
|
||||
/// 與連線的互動失敗(如 SQL 閉包 panic)
|
||||
Interact(String),
|
||||
/// SQLite 執行錯誤(語法、約束等)
|
||||
Sqlite(rusqlite::Error),
|
||||
}
|
||||
|
||||
impl StorageError {
|
||||
fn pool(err: impl std::fmt::Display) -> Self {
|
||||
StorageError::Pool(err.to_string())
|
||||
}
|
||||
|
||||
fn interact(err: impl std::fmt::Display) -> Self {
|
||||
StorageError::Interact(err.to_string())
|
||||
}
|
||||
|
||||
/// 是否為唯一性約束衝突(控制器可上報為 HTTP 409)
|
||||
pub fn is_unique_violation(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::Sqlite(rusqlite::Error::SqliteFailure(err, _))
|
||||
if err.code == rusqlite::ErrorCode::ConstraintViolation
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for StorageError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Pool(err) => write!(f, "無法取得資料庫連線:{err}"),
|
||||
Self::Interact(err) => write!(f, "資料庫連線互動失敗:{err}"),
|
||||
Self::Sqlite(err) => write!(f, "SQLite 錯誤:{err}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for StorageError {}
|
||||
|
||||
/// 測試輔助:以暫存檔建立彼此獨立的資料庫,避免污染工作目錄。
|
||||
#[cfg(test)]
|
||||
pub(crate) mod testutil {
|
||||
use super::{create_pool, init_db};
|
||||
use deadpool_sqlite::Pool;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// 生成測試專用的暫存資料庫路徑(以進程 ID + 奈秒時間戳保證唯一)
|
||||
fn temp_path(label: &str) -> PathBuf {
|
||||
let unique = format!(
|
||||
"{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos(),
|
||||
);
|
||||
std::env::temp_dir().join(format!("alterdb-{label}-{unique}.db"))
|
||||
}
|
||||
|
||||
/// 建立一個指向暫存檔的連接池(不做任何初始化,供遷移測試自建舊版結構)
|
||||
pub async fn raw_pool(label: &str) -> (Pool, PathBuf) {
|
||||
let path = temp_path(label);
|
||||
let pool = create_pool(path.to_str().expect("暫存路徑應為合法 UTF-8"))
|
||||
.expect("應能建立測試連接池");
|
||||
(pool, path)
|
||||
}
|
||||
|
||||
/// 建立並初始化一個指向暫存檔的連接池
|
||||
pub async fn test_pool(label: &str) -> (Pool, PathBuf) {
|
||||
let (pool, path) = raw_pool(label).await;
|
||||
init_db(&pool).await.expect("測試資料庫初始化應成功");
|
||||
(pool, path)
|
||||
}
|
||||
|
||||
/// 盡力清理測試資料庫檔案(含 WAL / SHM 旁車檔)
|
||||
pub fn cleanup(path: &Path) {
|
||||
for suffix in ["", "-wal", "-shm"] {
|
||||
let _ = std::fs::remove_file(format!("{}{suffix}", path.display()));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user