feat: 左侧导航改版与 Python 镜像管理

- 应用外壳换成 stash-ui Layout:左侧导航(任务调度 / Python 镜像)+ 页面标题区,窄屏折叠为顶部导航
- 新增 python_mirrors 表与 CRUD/设默认接口,内置清华、阿里云、中科大、PyPI 官方
- Python 脚本任务运行时注入默认镜像的 PIP_INDEX_URL / UV_INDEX_URL,任务自身环境变量优先
- 新增镜像管理页与表单,支持新增、编辑、启用/停用、设默认、删除
- 重新生成 OpenAPI 与 TS 类型;补齐后端镜像测试、前端单测与 e2e,修复窄屏页面被宽表格撑开
This commit is contained in:
2026-11-03 22:12:11 +08:00
parent d7a6e17531
commit 29fc579275
25 changed files with 2491 additions and 47 deletions
@@ -0,0 +1,62 @@
CREATE TABLE IF NOT EXISTS python_mirrors (
id TEXT PRIMARY KEY NOT NULL,
name TEXT NOT NULL,
index_url TEXT NOT NULL,
trusted_host TEXT,
enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
is_default INTEGER NOT NULL DEFAULT 0 CHECK (is_default IN (0, 1)),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
-- 同一时间只允许一个默认镜像
CREATE UNIQUE INDEX IF NOT EXISTS idx_python_mirrors_default
ON python_mirrors(is_default)
WHERE is_default = 1;
CREATE UNIQUE INDEX IF NOT EXISTS idx_python_mirrors_index_url
ON python_mirrors(index_url);
INSERT OR IGNORE INTO python_mirrors
(id, name, index_url, trusted_host, enabled, is_default, created_at, updated_at)
VALUES
(
'tsinghua',
'清华 TUNA',
'https://pypi.tuna.tsinghua.edu.cn/simple',
NULL,
1,
1,
strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),
strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
),
(
'aliyun',
'阿里云',
'https://mirrors.aliyun.com/pypi/simple/',
NULL,
1,
0,
strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),
strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
),
(
'ustc',
'中科大',
'https://pypi.mirrors.ustc.edu.cn/simple/',
NULL,
1,
0,
strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),
strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
),
(
'pypi',
'PyPI 官方',
'https://pypi.org/simple',
NULL,
1,
0,
strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),
strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
);
+18
View File
@@ -20,6 +20,14 @@ use crate::{
python_env::{
self, InterpreterKind, VenvProbeRequest, VenvProbeResponse, VenvProbeStatus, VenvSource,
},
python_mirrors::{
model::{CreatePythonMirrorRequest, PythonMirrorResponse, UpdatePythonMirrorRequest},
routes::{
__path_create_python_mirror, __path_delete_python_mirror, __path_get_python_mirror,
__path_list_python_mirrors, __path_set_default_python_mirror,
__path_update_python_mirror,
},
},
runs::{
model::{LogChunkResponse, TaskRunListResponse, TaskRunResponse},
routes::{
@@ -219,6 +227,12 @@ pub async fn list_schedule(
session,
preview_cron,
probe_python_venv,
list_python_mirrors,
get_python_mirror,
create_python_mirror,
update_python_mirror,
delete_python_mirror,
set_default_python_mirror,
list_directories,
task_stats,
list_schedule,
@@ -247,6 +261,9 @@ pub async fn list_schedule(
VenvProbeStatus,
VenvSource,
InterpreterKind,
PythonMirrorResponse,
CreatePythonMirrorRequest,
UpdatePythonMirrorRequest,
DirectoryEntry,
DirectoryListingResponse,
TaskStatsResponse,
@@ -305,6 +322,7 @@ pub fn router(state: AppState) -> Router {
.route("/api/v1/schedule", get(list_schedule))
.route("/api/v1/openapi.json", get(openapi_json))
.merge(crate::tasks::routes::router())
.merge(crate::python_mirrors::routes::router())
.merge(crate::runs::routes::router())
.route("/api/{*path}", any(api_not_found))
// 本地工具也不需要接受超大请求体;任务载荷与日志上传都用不到 2 MiB 以上。
+20
View File
@@ -109,6 +109,26 @@ impl ApiError {
}
}
pub fn mirror_not_found(message: impl Into<String>) -> Self {
Self {
status: StatusCode::NOT_FOUND,
code: "MIRROR_NOT_FOUND",
message: message.into(),
field: None,
details: BTreeMap::new(),
}
}
pub fn mirror_conflict(message: impl Into<String>) -> Self {
Self {
status: StatusCode::CONFLICT,
code: "MIRROR_CONFLICT",
message: message.into(),
field: Some("index_url".to_owned()),
details: BTreeMap::new(),
}
}
pub fn route_not_found(message: impl Into<String>) -> Self {
Self {
status: StatusCode::NOT_FOUND,
+1
View File
@@ -5,6 +5,7 @@ pub mod directory_browser;
pub mod error;
pub mod process_job;
pub mod python_env;
pub mod python_mirrors;
pub mod runs;
pub mod scheduler;
pub mod security;
+1 -1
View File
@@ -472,7 +472,7 @@ fn absolute_path(path: &Path) -> io::Result<PathBuf> {
std::path::absolute(path)
}
fn is_python_interpreter(interpreter: &str) -> bool {
pub fn is_python_interpreter(interpreter: &str) -> bool {
let name = Path::new(interpreter)
.file_name()
.and_then(|name| name.to_str())
+4
View File
@@ -0,0 +1,4 @@
pub mod model;
pub mod repository;
pub mod routes;
pub mod service;
+52
View File
@@ -0,0 +1,52 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
/// 一个 Python 包索引镜像(pip / uv)。
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct PythonMirrorResponse {
pub id: String,
pub name: String,
/// pip 的 `--index-url`,运行时注入 `PIP_INDEX_URL`
pub index_url: String,
/// pip 的 `--trusted-host`;https 源通常为空
pub trusted_host: Option<String>,
pub enabled: bool,
/// 当前默认镜像;同一时间最多一个
pub is_default: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Deserialize, ToSchema)]
#[serde(deny_unknown_fields)]
pub struct CreatePythonMirrorRequest {
pub name: String,
pub index_url: String,
#[serde(default)]
pub trusted_host: Option<String>,
#[serde(default = "default_enabled")]
pub enabled: bool,
}
#[derive(Debug, Deserialize, ToSchema)]
#[serde(deny_unknown_fields)]
pub struct UpdatePythonMirrorRequest {
pub name: String,
pub index_url: String,
#[serde(default)]
pub trusted_host: Option<String>,
pub enabled: bool,
}
const fn default_enabled() -> bool {
true
}
/// 执行器每次运行 Python 任务时读取的默认镜像。
#[derive(Debug, Clone)]
pub struct DefaultPythonMirror {
pub name: String,
pub index_url: String,
pub trusted_host: Option<String>,
}
+215
View File
@@ -0,0 +1,215 @@
use chrono::{DateTime, Utc};
use sqlx::{FromRow, SqlitePool};
use uuid::Uuid;
use crate::{
error::{ApiError, ApiResult},
python_mirrors::{
model::{DefaultPythonMirror, PythonMirrorResponse},
service::ValidatedMirror,
},
};
#[derive(Debug, FromRow)]
struct MirrorRow {
id: String,
name: String,
index_url: String,
trusted_host: Option<String>,
enabled: bool,
is_default: bool,
created_at: DateTime<Utc>,
updated_at: DateTime<Utc>,
}
impl From<MirrorRow> for PythonMirrorResponse {
fn from(row: MirrorRow) -> Self {
Self {
id: row.id,
name: row.name,
index_url: row.index_url,
trusted_host: row.trusted_host,
enabled: row.enabled,
is_default: row.is_default,
created_at: row.created_at,
updated_at: row.updated_at,
}
}
}
pub async fn list(pool: &SqlitePool) -> ApiResult<Vec<PythonMirrorResponse>> {
let rows = sqlx::query_as::<_, MirrorRow>(
"SELECT id, name, index_url, trusted_host, enabled, is_default, created_at, updated_at
FROM python_mirrors
ORDER BY is_default DESC, enabled DESC, name COLLATE NOCASE ASC",
)
.fetch_all(pool)
.await?;
Ok(rows.into_iter().map(Into::into).collect())
}
pub async fn get(pool: &SqlitePool, id: &str) -> ApiResult<PythonMirrorResponse> {
let row = sqlx::query_as::<_, MirrorRow>(
"SELECT id, name, index_url, trusted_host, enabled, is_default, created_at, updated_at
FROM python_mirrors WHERE id = ?",
)
.bind(id)
.fetch_optional(pool)
.await?
.ok_or_else(|| ApiError::mirror_not_found(format!("镜像 {id} 不存在")))?;
Ok(row.into())
}
pub async fn create(pool: &SqlitePool, input: ValidatedMirror) -> ApiResult<PythonMirrorResponse> {
if index_url_exists(pool, &input.index_url, None).await? {
return Err(ApiError::mirror_conflict("该索引地址已经存在"));
}
let id = Uuid::new_v4().simple().to_string();
let now = Utc::now();
sqlx::query(
"INSERT INTO python_mirrors
(id, name, index_url, trusted_host, enabled, is_default, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, 0, ?, ?)",
)
.bind(&id)
.bind(&input.name)
.bind(&input.index_url)
.bind(input.trusted_host.as_deref())
.bind(input.enabled)
.bind(now)
.bind(now)
.execute(pool)
.await
.map_err(map_mirror_error)?;
get(pool, &id).await
}
pub async fn update(
pool: &SqlitePool,
id: &str,
input: ValidatedMirror,
) -> ApiResult<PythonMirrorResponse> {
if index_url_exists(pool, &input.index_url, Some(id)).await? {
return Err(ApiError::mirror_conflict("该索引地址已经存在"));
}
let now = Utc::now();
let result = sqlx::query(
"UPDATE python_mirrors
SET name = ?, index_url = ?, trusted_host = ?, enabled = ?,
is_default = CASE WHEN ? = 0 THEN 0 ELSE is_default END,
updated_at = ?
WHERE id = ?",
)
.bind(&input.name)
.bind(&input.index_url)
.bind(input.trusted_host.as_deref())
.bind(input.enabled)
.bind(input.enabled)
.bind(now)
.bind(id)
.execute(pool)
.await
.map_err(map_mirror_error)?;
if result.rows_affected() == 0 {
return Err(ApiError::mirror_not_found(format!("镜像 {id} 不存在")));
}
get(pool, id).await
}
pub async fn delete(pool: &SqlitePool, id: &str) -> ApiResult<()> {
let result = sqlx::query("DELETE FROM python_mirrors WHERE id = ?")
.bind(id)
.execute(pool)
.await?;
if result.rows_affected() == 0 {
return Err(ApiError::mirror_not_found(format!("镜像 {id} 不存在")));
}
Ok(())
}
/// 设为默认:同一事务里先清掉旧默认,再把目标设为默认并启用。
pub async fn set_default(pool: &SqlitePool, id: &str) -> ApiResult<PythonMirrorResponse> {
let now = Utc::now();
let mut transaction = pool.begin().await?;
let exists: Option<(i64,)> = sqlx::query_as("SELECT 1 FROM python_mirrors WHERE id = ?")
.bind(id)
.fetch_optional(&mut *transaction)
.await?;
if exists.is_none() {
transaction.rollback().await?;
return Err(ApiError::mirror_not_found(format!("镜像 {id} 不存在")));
}
sqlx::query("UPDATE python_mirrors SET is_default = 0, updated_at = ? WHERE is_default = 1")
.bind(now)
.execute(&mut *transaction)
.await?;
sqlx::query(
"UPDATE python_mirrors SET is_default = 1, enabled = 1, updated_at = ? WHERE id = ?",
)
.bind(now)
.bind(id)
.execute(&mut *transaction)
.await?;
transaction.commit().await?;
get(pool, id).await
}
/// 执行器读取:只返回「启用且是默认」的镜像。
pub async fn default_enabled(pool: &SqlitePool) -> ApiResult<Option<DefaultPythonMirror>> {
let row = sqlx::query_as::<_, (String, String, Option<String>)>(
"SELECT name, index_url, trusted_host
FROM python_mirrors
WHERE enabled = 1 AND is_default = 1
LIMIT 1",
)
.fetch_optional(pool)
.await?;
Ok(
row.map(|(name, index_url, trusted_host)| DefaultPythonMirror {
name,
index_url,
trusted_host,
}),
)
}
async fn index_url_exists(
pool: &SqlitePool,
index_url: &str,
excluding_id: Option<&str>,
) -> ApiResult<bool> {
let existing: Option<(String,)> = match excluding_id {
Some(id) => {
sqlx::query_as(
"SELECT id FROM python_mirrors WHERE lower(index_url) = lower(?) AND id <> ?",
)
.bind(index_url)
.bind(id)
.fetch_optional(pool)
.await?
}
None => {
sqlx::query_as("SELECT id FROM python_mirrors WHERE lower(index_url) = lower(?)")
.bind(index_url)
.fetch_optional(pool)
.await?
}
};
Ok(existing.is_some())
}
fn map_mirror_error(error: sqlx::Error) -> ApiError {
if let sqlx::Error::Database(database_error) = &error
&& database_error.is_unique_violation()
{
return ApiError::mirror_conflict("该索引地址已经存在");
}
ApiError::from(error)
}
+151
View File
@@ -0,0 +1,151 @@
use axum::{
Json, Router,
extract::{Path, State},
http::StatusCode,
routing::{get, post},
};
use crate::{
error::{ApiJson, ApiResult},
python_mirrors::{
model::{CreatePythonMirrorRequest, PythonMirrorResponse, UpdatePythonMirrorRequest},
repository, service,
},
state::AppState,
};
pub fn router() -> Router<AppState> {
Router::new()
.route(
"/api/v1/python/mirrors",
get(list_python_mirrors).post(create_python_mirror),
)
.route(
"/api/v1/python/mirrors/{id}",
get(get_python_mirror)
.put(update_python_mirror)
.delete(delete_python_mirror),
)
.route(
"/api/v1/python/mirrors/{id}/default",
post(set_default_python_mirror),
)
}
#[utoipa::path(
get,
path = "/api/v1/python/mirrors",
tag = "python-mirrors",
responses(
(status = 200, description = "Python package mirrors", body = Vec<PythonMirrorResponse>)
)
)]
pub async fn list_python_mirrors(
State(state): State<AppState>,
) -> ApiResult<Json<Vec<PythonMirrorResponse>>> {
Ok(Json(repository::list(&state.pool).await?))
}
#[utoipa::path(
post,
path = "/api/v1/python/mirrors",
tag = "python-mirrors",
request_body = CreatePythonMirrorRequest,
responses(
(status = 201, description = "Mirror created", body = PythonMirrorResponse),
(status = 400, description = "Validation failed", body = crate::error::ErrorResponse),
(status = 409, description = "Mirror URL already exists", body = crate::error::ErrorResponse)
)
)]
pub async fn create_python_mirror(
State(state): State<AppState>,
ApiJson(request): ApiJson<CreatePythonMirrorRequest>,
) -> ApiResult<(StatusCode, Json<PythonMirrorResponse>)> {
let input = service::validate(
&request.name,
&request.index_url,
request.trusted_host.as_deref(),
request.enabled,
)?;
let mirror = repository::create(&state.pool, input).await?;
Ok((StatusCode::CREATED, Json(mirror)))
}
#[utoipa::path(
get,
path = "/api/v1/python/mirrors/{id}",
tag = "python-mirrors",
params(("id" = String, Path, description = "Mirror identifier")),
responses(
(status = 200, description = "Mirror detail", body = PythonMirrorResponse),
(status = 404, description = "Mirror not found", body = crate::error::ErrorResponse)
)
)]
pub async fn get_python_mirror(
State(state): State<AppState>,
Path(id): Path<String>,
) -> ApiResult<Json<PythonMirrorResponse>> {
Ok(Json(repository::get(&state.pool, &id).await?))
}
#[utoipa::path(
put,
path = "/api/v1/python/mirrors/{id}",
tag = "python-mirrors",
params(("id" = String, Path, description = "Mirror identifier")),
request_body = UpdatePythonMirrorRequest,
responses(
(status = 200, description = "Mirror updated", body = PythonMirrorResponse),
(status = 400, description = "Validation failed", body = crate::error::ErrorResponse),
(status = 404, description = "Mirror not found", body = crate::error::ErrorResponse),
(status = 409, description = "Mirror URL already exists", body = crate::error::ErrorResponse)
)
)]
pub async fn update_python_mirror(
State(state): State<AppState>,
Path(id): Path<String>,
ApiJson(request): ApiJson<UpdatePythonMirrorRequest>,
) -> ApiResult<Json<PythonMirrorResponse>> {
let input = service::validate(
&request.name,
&request.index_url,
request.trusted_host.as_deref(),
request.enabled,
)?;
Ok(Json(repository::update(&state.pool, &id, input).await?))
}
#[utoipa::path(
delete,
path = "/api/v1/python/mirrors/{id}",
tag = "python-mirrors",
params(("id" = String, Path, description = "Mirror identifier")),
responses(
(status = 204, description = "Mirror deleted"),
(status = 404, description = "Mirror not found", body = crate::error::ErrorResponse)
)
)]
pub async fn delete_python_mirror(
State(state): State<AppState>,
Path(id): Path<String>,
) -> ApiResult<StatusCode> {
repository::delete(&state.pool, &id).await?;
Ok(StatusCode::NO_CONTENT)
}
#[utoipa::path(
post,
path = "/api/v1/python/mirrors/{id}/default",
tag = "python-mirrors",
params(("id" = String, Path, description = "Mirror identifier")),
responses(
(status = 200, description = "Default mirror updated", body = PythonMirrorResponse),
(status = 404, description = "Mirror not found", body = crate::error::ErrorResponse)
)
)]
pub async fn set_default_python_mirror(
State(state): State<AppState>,
Path(id): Path<String>,
) -> ApiResult<Json<PythonMirrorResponse>> {
Ok(Json(repository::set_default(&state.pool, &id).await?))
}
+79
View File
@@ -0,0 +1,79 @@
use url::Url;
use crate::error::{ApiError, ApiResult};
const MAX_NAME_CHARS: usize = 64;
const MAX_URL_CHARS: usize = 2048;
/// 校验并规范化后的镜像输入。
#[derive(Debug)]
pub struct ValidatedMirror {
pub name: String,
pub index_url: String,
pub trusted_host: Option<String>,
pub enabled: bool,
}
pub fn validate(
name: &str,
index_url: &str,
trusted_host: Option<&str>,
enabled: bool,
) -> ApiResult<ValidatedMirror> {
let name = name.trim();
if name.is_empty() {
return Err(ApiError::validation("name", "请输入镜像名称"));
}
if name.chars().count() > MAX_NAME_CHARS {
return Err(ApiError::validation(
"name",
format!("镜像名称不能超过 {MAX_NAME_CHARS} 个字符"),
));
}
let index_url = index_url.trim();
if index_url.is_empty() {
return Err(ApiError::validation("index_url", "请输入索引地址"));
}
if index_url.chars().count() > MAX_URL_CHARS {
return Err(ApiError::validation("index_url", "索引地址过长"));
}
let parsed = Url::parse(index_url)
.map_err(|_| ApiError::validation("index_url", "索引地址不是有效的 URL"))?;
if !matches!(parsed.scheme(), "http" | "https") {
return Err(ApiError::validation(
"index_url",
"索引地址只支持 http 或 https",
));
}
if parsed.host_str().is_none() {
return Err(ApiError::validation("index_url", "索引地址缺少主机名"));
}
if !parsed.username().is_empty() || parsed.password().is_some() {
return Err(ApiError::validation(
"index_url",
"索引地址不能包含用户名或密码",
));
}
let trusted_host = trusted_host
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|value| {
if value.contains("://") || value.contains('/') || value.contains(char::is_whitespace) {
return Err(ApiError::validation(
"trusted_host",
"信任主机只填主机名,可带端口,例如 mirrors.aliyun.com",
));
}
Ok(value.to_owned())
})
.transpose()?;
Ok(ValidatedMirror {
name: name.to_owned(),
index_url: index_url.to_owned(),
trusted_host,
enabled,
})
}
+48 -6
View File
@@ -1,5 +1,5 @@
use std::{
collections::HashMap,
collections::{BTreeMap, HashMap},
path::{Path, PathBuf},
process::Stdio,
sync::{
@@ -26,7 +26,7 @@ use tokio_util::sync::CancellationToken;
use crate::{
error::ApiResult,
process_job::ProcessJob,
python_env::prepare_script_environment,
python_env::{is_python_interpreter, prepare_script_environment},
runs::{
model::{RunStatus, RunTrigger, TaskRunResponse},
repository,
@@ -447,11 +447,48 @@ impl TaskExecutor {
};
let mut command = Command::new(&prepared.interpreter);
prepared.apply(&mut command);
let mut notes = Vec::new();
if let Some(venv) = &prepared.virtual_env {
notes.push(format!("使用 Python 虚拟环境: {}", venv.display()));
}
if is_python_interpreter(interpreter) {
match crate::python_mirrors::repository::default_enabled(&self.inner.pool).await
{
Ok(Some(mirror)) => {
if !env_has(env, "PIP_INDEX_URL") {
command.env("PIP_INDEX_URL", &mirror.index_url);
}
if !env_has(env, "UV_INDEX_URL") {
command.env("UV_INDEX_URL", &mirror.index_url);
}
if let Some(host) = mirror
.trusted_host
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
&& !env_has(env, "PIP_TRUSTED_HOST")
{
command.env("PIP_TRUSTED_HOST", host);
}
notes.push(format!(
"Python 包镜像: {} ({})",
mirror.name, mirror.index_url
));
}
Ok(None) => {}
Err(error) => {
tracing::warn!(
task_id = %task.id,
%error,
"failed to load default python mirror"
);
}
}
}
command.arg(script).args(args).current_dir(working_dir);
let startup_note = prepared
.virtual_env
.as_ref()
.map(|venv| format!("使用 Python 虚拟环境: {}", venv.display()));
let startup_note = (!notes.is_empty()).then(|| notes.join(";"));
self.run_command(task, run_id, cancellation, command, startup_note)
.await
}
@@ -1064,6 +1101,11 @@ async fn wait_after_termination(child: &mut Child) {
}
}
fn env_has(env: &BTreeMap<String, String>, key: &str) -> bool {
env.keys()
.any(|candidate| candidate.eq_ignore_ascii_case(key))
}
fn sensitive_values(action: &TaskAction) -> Vec<String> {
match action {
TaskAction::Script { env, .. } | TaskAction::Process { env, .. } => env
+243
View File
@@ -0,0 +1,243 @@
use axum::{
body::Body,
http::{Method, Request, StatusCode, header},
};
use http_body_util::BodyExt;
use serde_json::{Value, json};
use tempfile::TempDir;
use tower::ServiceExt;
async fn test_app() -> (axum::Router, TempDir) {
let temp_dir = TempDir::new().expect("temporary directory should be created");
let pool = task_pilot_backend::database::connect("sqlite::memory:")
.await
.expect("test database should initialize");
let app = task_pilot_backend::app(pool, temp_dir.path().join("logs"))
.await
.expect("test app should start");
(app, temp_dir)
}
async fn auth_token(app: &axum::Router) -> String {
let response = app
.clone()
.oneshot(
Request::builder()
.uri("/api/v1/session")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("router should respond");
let body = response
.into_body()
.collect()
.await
.expect("body should be readable")
.to_bytes();
let json: Value = serde_json::from_slice(&body).expect("session should be JSON");
json["token"]
.as_str()
.expect("token should be a string")
.to_owned()
}
async fn send_json(
app: &axum::Router,
method: Method,
uri: &str,
token: Option<&str>,
body: Option<Value>,
) -> (StatusCode, Value) {
let mut builder = Request::builder().method(method).uri(uri);
if let Some(token) = token {
builder = builder.header("x-taskpilot-token", token);
}
let request = if let Some(body) = body {
builder
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(
serde_json::to_vec(&body).expect("body should serialize"),
))
.expect("request should build")
} else {
builder.body(Body::empty()).expect("request should build")
};
let response = app
.clone()
.oneshot(request)
.await
.expect("router should respond");
let status = response.status();
let body = response
.into_body()
.collect()
.await
.expect("body should be readable")
.to_bytes();
let json = serde_json::from_slice(&body).unwrap_or(Value::Null);
(status, json)
}
#[tokio::test]
async fn seeds_python_mirrors_with_one_default() {
let (app, _temp_dir) = test_app().await;
let (status, body) = send_json(&app, Method::GET, "/api/v1/python/mirrors", None, None).await;
assert_eq!(status, StatusCode::OK);
let mirrors = body.as_array().expect("mirrors should be an array");
assert_eq!(mirrors.len(), 4);
let defaults: Vec<&Value> = mirrors
.iter()
.filter(|mirror| mirror["is_default"] == true)
.collect();
assert_eq!(defaults.len(), 1);
assert_eq!(defaults[0]["id"], "tsinghua");
assert!(mirrors.iter().all(|mirror| mirror["enabled"] == true));
}
#[tokio::test]
async fn mirrors_crud_and_default_switch() {
let (app, _temp_dir) = test_app().await;
let token = auth_token(&app).await;
let (status, created) = send_json(
&app,
Method::POST,
"/api/v1/python/mirrors",
Some(&token),
Some(json!({
"name": "内网镜像",
"index_url": "https://pypi.example.local/simple",
"trusted_host": "pypi.example.local",
"enabled": true
})),
)
.await;
assert_eq!(status, StatusCode::CREATED);
let id = created["id"]
.as_str()
.expect("created mirror should have an id")
.to_owned();
assert_eq!(created["is_default"], false);
assert_eq!(created["trusted_host"], "pypi.example.local");
let (status, mirror) = send_json(
&app,
Method::POST,
&format!("/api/v1/python/mirrors/{id}/default"),
Some(&token),
None,
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(mirror["is_default"], true);
let (status, list) = send_json(&app, Method::GET, "/api/v1/python/mirrors", None, None).await;
assert_eq!(status, StatusCode::OK);
let defaults = list
.as_array()
.expect("mirrors should be an array")
.iter()
.filter(|mirror| mirror["is_default"] == true)
.count();
assert_eq!(defaults, 1);
let (status, updated) = send_json(
&app,
Method::PUT,
&format!("/api/v1/python/mirrors/{id}"),
Some(&token),
Some(json!({
"name": "内网镜像(停用)",
"index_url": "https://pypi.example.local/simple",
"trusted_host": null,
"enabled": false
})),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(updated["name"], "内网镜像(停用)");
assert_eq!(updated["enabled"], false);
// 停用默认镜像时连带清掉默认标记,避免执行器继续引用一个停用的源
assert_eq!(updated["is_default"], false);
let (status, _) = send_json(
&app,
Method::DELETE,
&format!("/api/v1/python/mirrors/{id}"),
Some(&token),
None,
)
.await;
assert_eq!(status, StatusCode::NO_CONTENT);
let (status, body) = send_json(
&app,
Method::GET,
&format!("/api/v1/python/mirrors/{id}"),
None,
None,
)
.await;
assert_eq!(status, StatusCode::NOT_FOUND);
assert_eq!(body["code"], "MIRROR_NOT_FOUND");
}
#[tokio::test]
async fn rejects_invalid_mirror_url_and_duplicate_index() {
let (app, _temp_dir) = test_app().await;
let token = auth_token(&app).await;
let (status, body) = send_json(
&app,
Method::POST,
"/api/v1/python/mirrors",
Some(&token),
Some(json!({
"name": "坏地址",
"index_url": "ftp://example.com/simple",
"enabled": true
})),
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(body["field"], "index_url");
let (status, body) = send_json(
&app,
Method::POST,
"/api/v1/python/mirrors",
Some(&token),
Some(json!({
"name": "重复镜像",
"index_url": "https://pypi.org/simple",
"enabled": true
})),
)
.await;
assert_eq!(status, StatusCode::CONFLICT);
assert_eq!(body["code"], "MIRROR_CONFLICT");
}
#[tokio::test]
async fn mirror_mutations_require_token() {
let (app, _temp_dir) = test_app().await;
let (status, body) = send_json(
&app,
Method::POST,
"/api/v1/python/mirrors",
None,
Some(json!({
"name": "未授权",
"index_url": "https://example.com/simple",
"enabled": true
})),
)
.await;
assert_eq!(status, StatusCode::FORBIDDEN);
assert_eq!(body["code"], "FORBIDDEN");
}