This commit is contained in:
2026-10-02 13:12:49 +08:00
parent 2d4bdd0f85
commit a8ab1dfcc6
38 changed files with 10385 additions and 0 deletions
BIN
View File
Binary file not shown.
+1300
View File
File diff suppressed because it is too large Load Diff
+203
View File
@@ -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);
}
}
+702
View File
@@ -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);
}
}
+791
View File
@@ -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);
}
}
+53
View File
@@ -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))
}
+370
View File
@@ -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);
}
}
+710
View File
@@ -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
View File
@@ -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("伺服器運行錯誤");
}
+212
View File
@@ -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")));
}
}
+336
View File
@@ -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());
}
}
+7
View File
@@ -0,0 +1,7 @@
//! 模型層:定義對外的數據結構與輸入驗證,不含存儲細節。
//!
//! 每個模型一個子模組(如 [`account`]);對應的 SQL 與持久化
//! 實作放在 [`crate::storage`] 的同名子模組中。
pub mod account;
pub mod database;
+257
View File
@@ -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"));
}
}
+11
View File
@@ -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,
}
+473
View File
@@ -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);
}
}
+345
View File
@@ -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);
}
}
+125
View File
@@ -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()));
}
}
}