mirror of
https://github.com/Shu-Ying/farm_service.git
synced 2026-11-04 11:40:04 +08:00
add # 初始化仓库
This commit is contained in:
+169
@@ -0,0 +1,169 @@
|
||||
use axum::{
|
||||
body::{Body, to_bytes},
|
||||
http::{Request, StatusCode},
|
||||
};
|
||||
use farm_service::assets::{self, AssetBundle, Manifest, Sources, digest};
|
||||
use tower::ServiceExt;
|
||||
|
||||
fn fixture() -> (tempfile::TempDir, Manifest, Vec<u8>) {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
// A real ZIP with one member named x containing a; no external ZIP dependency.
|
||||
let content = vec![
|
||||
80, 75, 3, 4, 20, 0, 0, 0, 0, 0, 0, 0, 0, 0, 67, 190, 183, 232, 1, 0, 0, 0, 1, 0, 0, 0, 1,
|
||||
0, 0, 0, 120, 97, 80, 75, 1, 2, 20, 3, 20, 0, 0, 0, 0, 0, 0, 0, 0, 0, 67, 190, 183, 232, 1,
|
||||
0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 128, 1, 0, 0, 0, 0, 120, 80, 75,
|
||||
5, 6, 0, 0, 0, 0, 1, 0, 1, 0, 47, 0, 0, 0, 32, 0, 0, 0, 0, 0,
|
||||
];
|
||||
let sha256 = digest(&content);
|
||||
let manifest = Manifest {
|
||||
schema: 1,
|
||||
version: "assets-test-1".into(),
|
||||
size_bytes: content.len() as u64,
|
||||
sources: Sources {
|
||||
github: "https://raw.githubusercontent.com/owner/farm/main/assets.zip".into(),
|
||||
gitea: "https://gitea.example/owner/farm/raw/branch/main/assets.zip".into(),
|
||||
farm_service: format!("https://farm.example/v1/assets/{sha256}/assets.zip"),
|
||||
},
|
||||
sha256,
|
||||
};
|
||||
std::fs::write(directory.path().join("assets.zip"), &content).unwrap();
|
||||
save(&directory, &manifest);
|
||||
(directory, manifest, content)
|
||||
}
|
||||
|
||||
fn save(directory: &tempfile::TempDir, manifest: &Manifest) {
|
||||
std::fs::write(
|
||||
directory.path().join("manifest.json"),
|
||||
serde_json::to_vec(manifest).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn public_manifest_download_cache_and_snapshot() {
|
||||
let (directory, manifest, content) = fixture();
|
||||
let bundle = AssetBundle::load(&directory.path().join("manifest.json")).unwrap();
|
||||
let app = assets::router(Some(bundle));
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/v1/assets/manifest.json")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = to_bytes(response.into_body(), 16384).await.unwrap();
|
||||
let result: Manifest = serde_json::from_slice(&body).unwrap();
|
||||
assert_eq!(result.sha256, manifest.sha256);
|
||||
assert_eq!(result.sources.github, manifest.sources.github);
|
||||
// Requests must keep serving exactly the already-verified bytes, even if disk changes.
|
||||
std::fs::write(directory.path().join("assets.zip"), b"changed").unwrap();
|
||||
let uri = format!("/v1/assets/{}/assets.zip", manifest.sha256);
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(Request::builder().uri(&uri).body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(response.headers()["content-type"], "application/zip");
|
||||
let etag = response.headers()["etag"].clone();
|
||||
assert_eq!(to_bytes(response.into_body(), 4096).await.unwrap(), content);
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(uri)
|
||||
.header("if-none-match", etag)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::NOT_MODIFIED);
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/v1/assets/unknown/assets.zip")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_corruption_invalid_urls_and_missing_files() {
|
||||
let (directory, original, mut content) = fixture();
|
||||
let path = directory.path().join("manifest.json");
|
||||
for value in [
|
||||
"http://farm.example/a",
|
||||
"https://user:pass@farm.example/a",
|
||||
"https://farm.example/a?q=token",
|
||||
] {
|
||||
let mut manifest = original.clone();
|
||||
manifest.sources.github = value.into();
|
||||
save(&directory, &manifest);
|
||||
assert!(AssetBundle::load(&path).is_err());
|
||||
}
|
||||
let mut manifest = original.clone();
|
||||
manifest.size_bytes += 1;
|
||||
save(&directory, &manifest);
|
||||
assert!(AssetBundle::load(&path).is_err());
|
||||
save(&directory, &original);
|
||||
content[31] ^= 1;
|
||||
std::fs::write(directory.path().join("assets.zip"), content).unwrap();
|
||||
assert!(
|
||||
AssetBundle::load(&path)
|
||||
.err()
|
||||
.unwrap()
|
||||
.to_string()
|
||||
.contains("SHA256")
|
||||
);
|
||||
std::fs::write(directory.path().join("assets.zip"), b"corrupt").unwrap();
|
||||
assert!(AssetBundle::load(&path).is_err());
|
||||
std::fs::remove_file(directory.path().join("assets.zip")).unwrap();
|
||||
assert!(AssetBundle::load(&path).is_err());
|
||||
std::fs::write(path.clone(), b"{}").unwrap();
|
||||
assert!(AssetBundle::load(&path).is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn disabled_resources_do_not_expose_files() {
|
||||
let response = assets::router(None)
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/v1/assets/manifest.json")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn public_assets_do_not_bypass_profile_authentication() {
|
||||
let (directory, _, _) = fixture();
|
||||
let pool = farm_service::db::open_database(&directory.path().join("users.sqlite3"))
|
||||
.await
|
||||
.unwrap();
|
||||
let bundle = AssetBundle::load(&directory.path().join("manifest.json")).unwrap();
|
||||
let app = farm_service::api::router(pool.clone(), "test-only-token".into())
|
||||
.merge(assets::router(Some(bundle)));
|
||||
for (uri, status) in [
|
||||
("/v1/assets/manifest.json", StatusCode::OK),
|
||||
("/v1/users/qq/123456", StatusCode::UNAUTHORIZED),
|
||||
] {
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), status);
|
||||
}
|
||||
pool.close().await;
|
||||
}
|
||||
+231
@@ -0,0 +1,231 @@
|
||||
use axum::{
|
||||
Router,
|
||||
body::{Body, to_bytes},
|
||||
http::{Request, StatusCode},
|
||||
response::Response,
|
||||
};
|
||||
use farm_service::{api, db};
|
||||
use serde_json::{Value, json};
|
||||
use sqlx::SqlitePool;
|
||||
use tempfile::TempDir;
|
||||
use tower::ServiceExt;
|
||||
|
||||
const TOKEN: &str = "integration-test-token-not-for-production";
|
||||
|
||||
async fn app() -> (TempDir, SqlitePool, Router) {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let pool = db::open_database(&directory.path().join("test.sqlite3"))
|
||||
.await
|
||||
.unwrap();
|
||||
let router = api::router(pool.clone(), TOKEN.into());
|
||||
(directory, pool, router)
|
||||
}
|
||||
|
||||
fn profile(platform: &str, id: &str, nickname: &str) -> Value {
|
||||
json!({
|
||||
"platform": platform,
|
||||
"platform_user_id": id,
|
||||
"nickname": nickname
|
||||
})
|
||||
}
|
||||
|
||||
async fn send(app: Router, method: &str, uri: &str, body: Value, authenticated: bool) -> Response {
|
||||
let mut request = Request::builder()
|
||||
.method(method)
|
||||
.uri(uri)
|
||||
.header("content-type", "application/json");
|
||||
if authenticated {
|
||||
request = request.header("authorization", format!("Bearer {TOKEN}"));
|
||||
}
|
||||
app.oneshot(request.body(Body::from(body.to_string())).unwrap())
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn json_body(response: Response) -> Value {
|
||||
let bytes = to_bytes(response.into_body(), 8192).await.unwrap();
|
||||
serde_json::from_slice(&bytes).unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn health_checks_database_readiness() {
|
||||
let (_directory, pool, router) = app().await;
|
||||
let response = send(router.clone(), "GET", "/health", Value::Null, false).await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(json_body(response).await, json!({"status": "ok"}));
|
||||
pool.close().await;
|
||||
let response = send(router, "GET", "/health", Value::Null, false).await;
|
||||
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn authentication_prevents_profile_reads_and_writes() {
|
||||
let (_directory, pool, router) = app().await;
|
||||
for (method, uri) in [("PUT", "/v1/users"), ("GET", "/v1/users/qq/12345678")] {
|
||||
let response = send(
|
||||
router.clone(),
|
||||
method,
|
||||
uri,
|
||||
profile("qq", "12345678", "测试昵称"),
|
||||
false,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
let response = router
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("PUT")
|
||||
.uri("/v1/users")
|
||||
.header("authorization", "Bearer incorrect-token")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(profile("qq", "12345678", "测试").to_string()))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(count, 0);
|
||||
pool.close().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn profile_updates_are_persistent_and_preserve_creation_time() {
|
||||
let (directory, pool, router) = app().await;
|
||||
let first = send(
|
||||
router.clone(),
|
||||
"PUT",
|
||||
"/v1/users",
|
||||
profile("qq", "12345678", " 初始昵称 "),
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(first.status(), StatusCode::OK);
|
||||
let first = json_body(first).await;
|
||||
assert_eq!(first["nickname"], "初始昵称");
|
||||
let updated = send(
|
||||
router.clone(),
|
||||
"PUT",
|
||||
"/v1/users",
|
||||
profile("qq", "12345678", "新昵称"),
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(updated.status(), StatusCode::OK);
|
||||
let updated = json_body(updated).await;
|
||||
assert_eq!(first["created_at"], updated["created_at"]);
|
||||
assert_eq!(updated["platform_user_id"], "12345678");
|
||||
drop(router);
|
||||
pool.close().await;
|
||||
|
||||
let reopened = db::open_database(&directory.path().join("test.sqlite3"))
|
||||
.await
|
||||
.unwrap();
|
||||
let response = send(
|
||||
api::router(reopened.clone(), TOKEN.into()),
|
||||
"GET",
|
||||
"/v1/users/qq/12345678",
|
||||
Value::Null,
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(json_body(response).await["nickname"], "新昵称");
|
||||
reopened.close().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn platforms_do_not_merge_equal_numeric_ids() {
|
||||
let (_directory, pool, router) = app().await;
|
||||
for platform in ["qq", "telegram", "discord"] {
|
||||
let response = send(
|
||||
router.clone(),
|
||||
"PUT",
|
||||
"/v1/users",
|
||||
profile(platform, "12345678", platform),
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
}
|
||||
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(count, 3);
|
||||
let missing = send(router, "GET", "/v1/users/qq/99999999", Value::Null, true).await;
|
||||
assert_eq!(missing.status(), StatusCode::NOT_FOUND);
|
||||
pool.close().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_profiles_do_not_write_any_data() {
|
||||
let (_directory, pool, router) = app().await;
|
||||
let mut cases = vec![
|
||||
profile("qq", "0", "昵称"),
|
||||
profile("qq", "00123456", "昵称"),
|
||||
profile("qq", "-123", "昵称"),
|
||||
profile("qq", "123", "昵称"),
|
||||
profile("qq", "12345678", " "),
|
||||
profile("qq", "12345678", "换行\n昵称"),
|
||||
profile("qq", "12345678", &"字".repeat(65)),
|
||||
profile("qq", &"1".repeat(21), "昵称"),
|
||||
profile("unknown", "12345678", "昵称"),
|
||||
json!({"platform": "qq", "platform_user_id": 12345678, "nickname": "昵称"}),
|
||||
];
|
||||
let mut unknown = profile("qq", "12345678", "昵称");
|
||||
unknown["unused_field"] = json!(true);
|
||||
cases.push(unknown);
|
||||
for body in cases {
|
||||
let response = send(router.clone(), "PUT", "/v1/users", body, true).await;
|
||||
assert!(response.status().is_client_error());
|
||||
}
|
||||
let oversized = send(
|
||||
router,
|
||||
"PUT",
|
||||
"/v1/users",
|
||||
profile("qq", "12345678", &"a".repeat(5000)),
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(oversized.status(), StatusCode::PAYLOAD_TOO_LARGE);
|
||||
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(count, 0);
|
||||
pool.close().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn concurrent_reports_create_one_profile() {
|
||||
let (_directory, pool, router) = app().await;
|
||||
let mut tasks = tokio::task::JoinSet::new();
|
||||
for index in 0..12 {
|
||||
let router = router.clone();
|
||||
tasks.spawn(async move {
|
||||
send(
|
||||
router,
|
||||
"PUT",
|
||||
"/v1/users",
|
||||
profile("qq", "12345678", &format!("昵称{index}")),
|
||||
true,
|
||||
)
|
||||
.await
|
||||
.status()
|
||||
});
|
||||
}
|
||||
while let Some(status) = tasks.join_next().await {
|
||||
assert_eq!(status.unwrap(), StatusCode::OK);
|
||||
}
|
||||
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(count, 1);
|
||||
pool.close().await;
|
||||
}
|
||||
Reference in New Issue
Block a user