4e41bd3e56
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m54s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 3m49s
Project CI / Backend tests (pull_request) Successful in 5m7s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 9m49s
Project CI / Native shell tests (pull_request) Successful in 6m47s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 10m45s
Project CI / Frontend tests (pull_request) Successful in 5m10s
Project CI / Repository checks (pull_request) Successful in 3m57s
Project CI / AI game creator shell web tests (pull_request) Failing after 3m26s
Project CI / AI game creator shell Rust crates (push) Successful in 1m16s
Project CI / AI game creator shell Rust smoke (push) Successful in 2m11s
Project CI / Backend tests (push) Successful in 4m55s
Project CI / AI game creator shell Rust lane 1/2 (push) Successful in 9m50s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 8m2s
Project CI / Native shell tests (push) Successful in 7m2s
Project CI / Repository checks (push) Successful in 3m51s
Project CI / AI game creator shell web tests (push) Successful in 3m23s
Project CI / Frontend tests (push) Successful in 5m48s
deploy_and_uninstall 测试在断言状态文件恢复前,改为轮询等待持久化状态到达 Stopped 新增 wait_for_persisted_status 辅助函数,经 load_deployments 真实加载路径校验状态文件
888 lines
30 KiB
Rust
888 lines
30 KiB
Rust
use std::{
|
|
sync::{
|
|
Arc, Mutex,
|
|
atomic::{AtomicUsize, Ordering},
|
|
},
|
|
time::Duration,
|
|
};
|
|
|
|
use axum::{
|
|
Json, Router,
|
|
body::Body,
|
|
extract::{Request, State},
|
|
http::{HeaderMap, StatusCode, header},
|
|
response::IntoResponse,
|
|
routing::{get, post},
|
|
};
|
|
use http_body_util::BodyExt;
|
|
use serde_json::{Value, json};
|
|
use tokio::net::TcpListener;
|
|
use tower::ServiceExt;
|
|
use url::Url;
|
|
|
|
use super::{AppState, Config, build_router};
|
|
|
|
const HOST: &str = "preview.internal:8080";
|
|
const ORIGIN: &str = "http://preview.internal:8080";
|
|
|
|
#[derive(Clone, Default)]
|
|
struct MockJenkinsState {
|
|
requests: Arc<Mutex<Vec<RecordedRequest>>>,
|
|
invalid_build_responses: Arc<AtomicUsize>,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct RecordedRequest {
|
|
path: String,
|
|
body: String,
|
|
crumb: Option<String>,
|
|
}
|
|
|
|
async fn start_mock_jenkins() -> (Url, MockJenkinsState) {
|
|
let state = MockJenkinsState::default();
|
|
let app = Router::new()
|
|
.route("/jenkins/crumbIssuer/api/json", get(|| async {
|
|
Json(json!({"crumbRequestField": "Jenkins-Crumb", "crumb": "safe-crumb"}))
|
|
}))
|
|
.route("/jenkins/job/shared/job/Genarrative-Preview-Deployer/buildWithParameters", post(mock_trigger))
|
|
.route("/jenkins/queue/item/{id}/api/json", get(mock_queue))
|
|
.route("/jenkins/job/shared/job/Genarrative-Preview-Deployer/{build}/api/json", get(mock_build))
|
|
.route("/jenkins/job/shared/job/Genarrative-Preview-Deployer/{build}/artifact/preview-result.json", get(mock_artifact))
|
|
.with_state(state.clone());
|
|
let listener = TcpListener::bind("127.0.0.1:0")
|
|
.await
|
|
.expect("mock Jenkins binds");
|
|
let address = listener.local_addr().expect("mock Jenkins address");
|
|
tokio::spawn(async move {
|
|
axum::serve(listener, app)
|
|
.await
|
|
.expect("mock Jenkins serves");
|
|
});
|
|
(
|
|
Url::parse(&format!("http://{address}/jenkins/")).unwrap(),
|
|
state,
|
|
)
|
|
}
|
|
|
|
async fn mock_trigger(
|
|
State(state): State<MockJenkinsState>,
|
|
headers: HeaderMap,
|
|
request: Request,
|
|
) -> impl IntoResponse {
|
|
let body = request
|
|
.into_body()
|
|
.collect()
|
|
.await
|
|
.expect("form body")
|
|
.to_bytes();
|
|
let body = String::from_utf8(body.to_vec()).expect("utf8 form");
|
|
let mut requests = state.requests.lock().expect("request lock");
|
|
let build_number = requests.len() + 1;
|
|
requests.push(RecordedRequest {
|
|
path: "/jenkins/job/shared/job/Genarrative-Preview-Deployer/buildWithParameters"
|
|
.to_string(),
|
|
body,
|
|
crumb: headers
|
|
.get("jenkins-crumb")
|
|
.and_then(|value| value.to_str().ok())
|
|
.map(ToOwned::to_owned),
|
|
});
|
|
let mut response = StatusCode::CREATED.into_response();
|
|
response.headers_mut().insert(
|
|
header::LOCATION,
|
|
format!("/jenkins/queue/item/{build_number}/")
|
|
.parse()
|
|
.unwrap(),
|
|
);
|
|
response
|
|
}
|
|
|
|
async fn mock_queue(axum::extract::Path(id): axum::extract::Path<u64>) -> Json<Value> {
|
|
Json(
|
|
json!({"cancelled": false, "executable": {"url": format!("http://192.168.35.82:8080/jenkins/job/shared/job/Genarrative-Preview-Deployer/{id}/")}}),
|
|
)
|
|
}
|
|
|
|
async fn mock_build(
|
|
State(state): State<MockJenkinsState>,
|
|
uri: axum::http::Uri,
|
|
) -> impl IntoResponse {
|
|
if uri.query() != Some("tree=building%2Cresult") {
|
|
return (StatusCode::BAD_REQUEST, "missing bounded tree query").into_response();
|
|
}
|
|
if state
|
|
.invalid_build_responses
|
|
.fetch_update(Ordering::SeqCst, Ordering::SeqCst, |remaining| {
|
|
remaining.checked_sub(1)
|
|
})
|
|
.is_ok()
|
|
{
|
|
return (StatusCode::OK, "Jenkins is finalizing the build").into_response();
|
|
}
|
|
Json(json!({"building": false, "result": "SUCCESS"})).into_response()
|
|
}
|
|
|
|
async fn mock_artifact(axum::extract::Path(build): axum::extract::Path<u64>) -> Json<Value> {
|
|
if build == 1 {
|
|
Json(json!({
|
|
"schemaVersion": 1,
|
|
"action": "DEPLOY",
|
|
"deploymentId": "preview-63d38d3da6bc9b06",
|
|
"projectName": "genarrative-preview-63d38d3da6bc9b06",
|
|
"branch": "feature/preview-ui",
|
|
"resolvedCommit": "0123456789abcdef0123456789abcdef01234567",
|
|
"phase": "RUNNING",
|
|
"healthStatus": "HEALTHY",
|
|
"webPort": 8400,
|
|
"webUrl": "http://192.168.35.82:8400",
|
|
"message": "预览实例已发布"
|
|
}))
|
|
} else {
|
|
Json(json!({
|
|
"schemaVersion": 1,
|
|
"action": "UNINSTALL",
|
|
"deploymentId": "preview-63d38d3da6bc9b06",
|
|
"projectName": "genarrative-preview-63d38d3da6bc9b06",
|
|
"branch": "feature/preview-ui",
|
|
"phase": "UNINSTALLED",
|
|
"healthStatus": "UNINSTALLED",
|
|
"message": "预览实例已卸载"
|
|
}))
|
|
}
|
|
}
|
|
|
|
fn test_config(jenkins_base_url: Url) -> Config {
|
|
let job_url = jenkins_base_url
|
|
.join("job/shared/job/Genarrative-Preview-Deployer/")
|
|
.unwrap();
|
|
Config {
|
|
bind_address: "127.0.0.1:0".to_string(),
|
|
jenkins_root_url: jenkins_base_url.clone(),
|
|
jenkins_base_url: job_url,
|
|
jenkins_public_base_url: Url::parse(
|
|
"http://192.168.35.82:8080/jenkins/job/shared/job/Genarrative-Preview-Deployer/",
|
|
)
|
|
.unwrap(),
|
|
jenkins_username: "preview-service".to_string(),
|
|
jenkins_api_token: "server-only-jenkins-token".to_string(),
|
|
git_remote_url: "test://preview-repository".to_string(),
|
|
git_ssh_command: None,
|
|
access_token: "correct horse battery staple".to_string(),
|
|
allowed_hosts: vec![HOST.to_string()],
|
|
allowed_origins: vec![ORIGIN.to_string()],
|
|
preview_web_host: "192.168.35.82".to_string(),
|
|
preview_web_domain: Some("preview.genarrative.world".to_string()),
|
|
secure_cookie: false,
|
|
static_dir: None,
|
|
state_file: std::env::temp_dir().join(format!(
|
|
"preview-deployer-test-{}.json",
|
|
uuid::Uuid::new_v4().simple()
|
|
)),
|
|
poll_interval: Duration::from_millis(5),
|
|
}
|
|
}
|
|
|
|
fn api_request(method: &str, uri: &str, body: Body) -> axum::http::Request<Body> {
|
|
axum::http::Request::builder()
|
|
.method(method)
|
|
.uri(uri)
|
|
.header(header::HOST, HOST)
|
|
.header(header::ORIGIN, ORIGIN)
|
|
.header(header::CONTENT_TYPE, "application/json")
|
|
.body(body)
|
|
.unwrap()
|
|
}
|
|
|
|
async fn login_cookie(app: &Router) -> String {
|
|
let response = app
|
|
.clone()
|
|
.oneshot(api_request(
|
|
"POST",
|
|
"/api/preview-deployer/session",
|
|
Body::from(r#"{"accessToken":"correct horse battery staple"}"#),
|
|
))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let cookie = response
|
|
.headers()
|
|
.get(header::SET_COOKIE)
|
|
.unwrap()
|
|
.to_str()
|
|
.unwrap();
|
|
assert!(cookie.contains("HttpOnly"));
|
|
assert!(cookie.contains("SameSite=Strict"));
|
|
cookie.split(';').next().unwrap().to_string()
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn session_and_request_boundaries_reject_anonymous_or_cross_origin_requests() {
|
|
let (jenkins_url, _) = start_mock_jenkins().await;
|
|
let app = build_router(AppState::new(test_config(jenkins_url)).unwrap());
|
|
|
|
let anonymous = app
|
|
.clone()
|
|
.oneshot(api_request(
|
|
"GET",
|
|
"/api/preview-deployer/deployments",
|
|
Body::empty(),
|
|
))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(anonymous.status(), StatusCode::UNAUTHORIZED);
|
|
|
|
let bad_login = app
|
|
.clone()
|
|
.oneshot(api_request(
|
|
"POST",
|
|
"/api/preview-deployer/session",
|
|
Body::from(r#"{"accessToken":"wrong"}"#),
|
|
))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(bad_login.status(), StatusCode::UNAUTHORIZED);
|
|
|
|
let cross_origin = axum::http::Request::builder()
|
|
.method("POST")
|
|
.uri("/api/preview-deployer/session")
|
|
.header(header::HOST, HOST)
|
|
.header(header::ORIGIN, "http://evil.invalid")
|
|
.header(header::CONTENT_TYPE, "application/json")
|
|
.body(Body::from(
|
|
r#"{"accessToken":"correct horse battery staple"}"#,
|
|
))
|
|
.unwrap();
|
|
assert_eq!(
|
|
app.clone().oneshot(cross_origin).await.unwrap().status(),
|
|
StatusCode::FORBIDDEN
|
|
);
|
|
|
|
let bad_host = axum::http::Request::builder()
|
|
.method("GET")
|
|
.uri("/api/preview-deployer/session")
|
|
.header(header::HOST, "evil.invalid")
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
assert_eq!(
|
|
app.oneshot(bad_host).await.unwrap().status(),
|
|
StatusCode::FORBIDDEN
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn healthz_is_available_without_a_control_session() {
|
|
let (jenkins_url, _) = start_mock_jenkins().await;
|
|
let app = build_router(AppState::new(test_config(jenkins_url)).unwrap());
|
|
let response = app
|
|
.oneshot(
|
|
axum::http::Request::builder()
|
|
.method("GET")
|
|
.uri("/healthz")
|
|
.header(header::HOST, HOST)
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(response.status(), StatusCode::NO_CONTENT);
|
|
}
|
|
|
|
#[test]
|
|
fn config_debug_never_exposes_access_or_jenkins_tokens() {
|
|
let root = Url::parse("http://127.0.0.1:8080/jenkins/").unwrap();
|
|
let config = test_config(root);
|
|
let debug = format!("{config:?}");
|
|
assert!(!debug.contains("server-only-jenkins-token"));
|
|
assert!(!debug.contains("correct horse battery staple"));
|
|
assert!(debug.contains("jenkins_api_token_configured: true"));
|
|
assert!(debug.contains("access_token_configured: true"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn deploy_and_uninstall_use_fixed_job_and_apply_owned_artifacts() {
|
|
let (jenkins_url, mock) = start_mock_jenkins().await;
|
|
let state = AppState::new(test_config(jenkins_url)).unwrap();
|
|
let app = build_router(state.clone());
|
|
let cookie = login_cookie(&app).await;
|
|
|
|
let invalid = axum::http::Request::builder()
|
|
.method("POST")
|
|
.uri("/api/preview-deployer/deployments")
|
|
.header(header::HOST, HOST)
|
|
.header(header::ORIGIN, ORIGIN)
|
|
.header(header::COOKIE, &cookie)
|
|
.header(header::CONTENT_TYPE, "application/json")
|
|
.body(Body::from(r#"{"branch":"--upload-pack=evil"}"#))
|
|
.unwrap();
|
|
assert_eq!(
|
|
app.clone().oneshot(invalid).await.unwrap().status(),
|
|
StatusCode::BAD_REQUEST
|
|
);
|
|
assert!(mock.requests.lock().unwrap().is_empty());
|
|
|
|
let id = super::derive_deployment_id("feature/preview-ui");
|
|
assert_eq!(id, "preview-63d38d3da6bc9b06");
|
|
let deploy_request = axum::http::Request::builder()
|
|
.method("POST")
|
|
.uri("/api/preview-deployer/deployments")
|
|
.header(header::HOST, HOST)
|
|
.header(header::ORIGIN, ORIGIN)
|
|
.header(header::COOKIE, &cookie)
|
|
.header(header::CONTENT_TYPE, "application/json")
|
|
.body(Body::from(
|
|
r#"{"branch":"feature/preview-ui","commitHash":"abcdef1"}"#,
|
|
))
|
|
.unwrap();
|
|
assert_eq!(
|
|
app.clone().oneshot(deploy_request).await.unwrap().status(),
|
|
StatusCode::ACCEPTED
|
|
);
|
|
wait_for_status(&state, &id, super::DeploymentStatus::Running)
|
|
.await
|
|
.expect("deployment reaches running");
|
|
|
|
let requests = mock.requests.lock().unwrap();
|
|
assert_eq!(requests.len(), 1);
|
|
assert_eq!(
|
|
requests[0].path,
|
|
"/jenkins/job/shared/job/Genarrative-Preview-Deployer/buildWithParameters"
|
|
);
|
|
assert_eq!(requests[0].crumb.as_deref(), Some("safe-crumb"));
|
|
assert!(requests[0].body.contains("ACTION=DEPLOY"));
|
|
assert!(
|
|
requests[0]
|
|
.body
|
|
.contains("DEPLOYMENT_ID=preview-63d38d3da6bc9b06")
|
|
);
|
|
assert!(
|
|
requests[0]
|
|
.body
|
|
.contains("SOURCE_BRANCH=feature%2Fpreview-ui")
|
|
);
|
|
assert!(requests[0].body.contains("COMMIT_HASH=abcdef1"));
|
|
assert!(!requests[0].body.contains("JOB"));
|
|
drop(requests);
|
|
|
|
let deployment = state
|
|
.deployments
|
|
.read()
|
|
.await
|
|
.get(&id)
|
|
.unwrap()
|
|
.public
|
|
.clone();
|
|
assert_eq!(deployment.status, super::DeploymentStatus::Running);
|
|
assert_eq!(deployment.id.as_deref(), Some("1"));
|
|
assert_eq!(
|
|
deployment.jenkins_build_url.as_deref(),
|
|
Some("http://192.168.35.82:8080/jenkins/job/shared/job/Genarrative-Preview-Deployer/1/")
|
|
);
|
|
assert_eq!(deployment.health, super::HealthStatus::Healthy);
|
|
assert_eq!(deployment.web_port, Some(8400));
|
|
assert_eq!(
|
|
deployment.web_public_url.as_deref(),
|
|
Some("https://preview-63d38d3da6bc9b06.preview.genarrative.world")
|
|
);
|
|
assert_eq!(
|
|
deployment.web_url.as_deref(),
|
|
Some("http://192.168.35.82:8400")
|
|
);
|
|
assert_eq!(
|
|
deployment.resolved_commit.as_deref(),
|
|
Some("0123456789abcdef0123456789abcdef01234567")
|
|
);
|
|
assert!(deployment.can_uninstall);
|
|
|
|
let uninstall_request = axum::http::Request::builder()
|
|
.method("POST")
|
|
.uri("/api/preview-deployer/deployments/1/uninstall")
|
|
.header(header::HOST, HOST)
|
|
.header(header::ORIGIN, ORIGIN)
|
|
.header(header::COOKIE, &cookie)
|
|
.header(header::CONTENT_TYPE, "application/json")
|
|
.body(Body::from("{}"))
|
|
.unwrap();
|
|
assert_eq!(
|
|
app.clone()
|
|
.oneshot(uninstall_request)
|
|
.await
|
|
.unwrap()
|
|
.status(),
|
|
StatusCode::ACCEPTED
|
|
);
|
|
wait_for_status(&state, &id, super::DeploymentStatus::Stopped)
|
|
.await
|
|
.expect("deployment reaches stopped");
|
|
let requests = mock.requests.lock().unwrap();
|
|
assert_eq!(requests.len(), 2);
|
|
assert!(requests[1].body.contains("ACTION=UNINSTALL"));
|
|
assert!(
|
|
requests[1]
|
|
.body
|
|
.contains("SOURCE_BRANCH=feature%2Fpreview-ui")
|
|
);
|
|
assert!(
|
|
requests[1]
|
|
.body
|
|
.contains("DEPLOYMENT_ID=preview-63d38d3da6bc9b06")
|
|
);
|
|
assert!(!requests[1].body.contains("CONTAINER"));
|
|
drop(requests);
|
|
|
|
let deployment = state
|
|
.deployments
|
|
.read()
|
|
.await
|
|
.get(&id)
|
|
.unwrap()
|
|
.public
|
|
.clone();
|
|
assert_eq!(deployment.status, super::DeploymentStatus::Stopped);
|
|
assert_eq!(deployment.health, super::HealthStatus::Unknown);
|
|
assert_eq!(deployment.web_port, None);
|
|
assert_eq!(deployment.web_url, None);
|
|
assert_eq!(deployment.web_public_url, None);
|
|
assert!(!deployment.can_uninstall);
|
|
|
|
let list_request = axum::http::Request::builder()
|
|
.method("GET")
|
|
.uri("/api/preview-deployer/deployments")
|
|
.header(header::HOST, HOST)
|
|
.header(header::COOKIE, &cookie)
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
let list_response = app.clone().oneshot(list_request).await.unwrap();
|
|
assert_eq!(list_response.status(), StatusCode::OK);
|
|
let list_body = list_response
|
|
.into_body()
|
|
.collect()
|
|
.await
|
|
.unwrap()
|
|
.to_bytes();
|
|
let list: Value = serde_json::from_slice(&list_body).unwrap();
|
|
assert_eq!(list["deployments"], json!([]));
|
|
wait_for_persisted_status(&state.config, &id, super::DeploymentStatus::Stopped)
|
|
.await
|
|
.expect("persisted deployment reaches stopped");
|
|
let state_file = state.config.state_file.clone();
|
|
let mut recovered_config = test_config(state.config.jenkins_root_url.clone());
|
|
recovered_config.state_file = state_file.clone();
|
|
let recovered = AppState::new(recovered_config);
|
|
assert!(recovered.is_ok());
|
|
assert_eq!(
|
|
recovered
|
|
.unwrap()
|
|
.deployments
|
|
.read()
|
|
.await
|
|
.get(&id)
|
|
.unwrap()
|
|
.public
|
|
.status,
|
|
super::DeploymentStatus::Stopped
|
|
);
|
|
std::fs::remove_file(state_file).unwrap();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn ref_search_requires_session_and_returns_server_side_matches() {
|
|
let (jenkins_url, _) = start_mock_jenkins().await;
|
|
let app = build_router(AppState::new(test_config(jenkins_url)).unwrap());
|
|
let anonymous = app
|
|
.clone()
|
|
.oneshot(api_request(
|
|
"GET",
|
|
"/api/preview-deployer/refs/branches?q=preview",
|
|
Body::empty(),
|
|
))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(anonymous.status(), StatusCode::UNAUTHORIZED);
|
|
let cookie = login_cookie(&app).await;
|
|
let request = axum::http::Request::builder()
|
|
.method("GET")
|
|
.uri("/api/preview-deployer/refs/branches?q=preview")
|
|
.header(header::HOST, HOST)
|
|
.header(header::COOKIE, &cookie)
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
let response = app.clone().oneshot(request).await.unwrap();
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let body = response.into_body().collect().await.unwrap().to_bytes();
|
|
let value: Value = serde_json::from_slice(&body).unwrap();
|
|
assert_eq!(value["items"][0]["name"], "feature/preview-ui");
|
|
|
|
let request = axum::http::Request::builder()
|
|
.method("GET")
|
|
.uri("/api/preview-deployer/refs/commits?branch=feature%2Fpreview-ui&q=0123456")
|
|
.header(header::HOST, HOST)
|
|
.header(header::COOKIE, &cookie)
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
let response = app.oneshot(request).await.unwrap();
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let body = response.into_body().collect().await.unwrap().to_bytes();
|
|
let value: Value = serde_json::from_slice(&body).unwrap();
|
|
assert_eq!(
|
|
value["items"][0]["commitHash"],
|
|
"0123456789abcdef0123456789abcdef01234567"
|
|
);
|
|
assert_eq!(value["items"][0]["shortHash"], "0123456");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn deployment_rejects_missing_or_unrelated_refs_before_jenkins() {
|
|
let (jenkins_url, mock) = start_mock_jenkins().await;
|
|
let app = build_router(AppState::new(test_config(jenkins_url)).unwrap());
|
|
let cookie = login_cookie(&app).await;
|
|
for payload in [
|
|
r#"{"branch":"feature/missing"}"#,
|
|
r#"{"branch":"feature/preview-ui","commitHash":"deadbee"}"#,
|
|
] {
|
|
let request = axum::http::Request::builder()
|
|
.method("POST")
|
|
.uri("/api/preview-deployer/deployments")
|
|
.header(header::HOST, HOST)
|
|
.header(header::ORIGIN, ORIGIN)
|
|
.header(header::COOKIE, &cookie)
|
|
.header(header::CONTENT_TYPE, "application/json")
|
|
.body(Body::from(payload))
|
|
.unwrap();
|
|
assert_eq!(
|
|
app.clone().oneshot(request).await.unwrap().status(),
|
|
StatusCode::UNPROCESSABLE_ENTITY
|
|
);
|
|
}
|
|
assert!(mock.requests.lock().unwrap().is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn transient_invalid_build_status_is_retried_before_reading_artifact() {
|
|
let (jenkins_url, mock) = start_mock_jenkins().await;
|
|
mock.invalid_build_responses.store(1, Ordering::SeqCst);
|
|
let state = AppState::new(test_config(jenkins_url)).unwrap();
|
|
let app = build_router(state.clone());
|
|
let cookie = login_cookie(&app).await;
|
|
let id = super::derive_deployment_id("feature/preview-ui");
|
|
let deploy_request = axum::http::Request::builder()
|
|
.method("POST")
|
|
.uri("/api/preview-deployer/deployments")
|
|
.header(header::HOST, HOST)
|
|
.header(header::ORIGIN, ORIGIN)
|
|
.header(header::COOKIE, &cookie)
|
|
.header(header::CONTENT_TYPE, "application/json")
|
|
.body(Body::from(r#"{"branch":"feature/preview-ui"}"#))
|
|
.unwrap();
|
|
assert_eq!(
|
|
app.oneshot(deploy_request).await.unwrap().status(),
|
|
StatusCode::ACCEPTED
|
|
);
|
|
wait_for_status(&state, &id, super::DeploymentStatus::Running)
|
|
.await
|
|
.expect("transient invalid Jenkins response is retried");
|
|
assert_eq!(
|
|
state
|
|
.deployments
|
|
.read()
|
|
.await
|
|
.get(&id)
|
|
.unwrap()
|
|
.public
|
|
.health,
|
|
super::HealthStatus::Healthy
|
|
);
|
|
std::fs::remove_file(&state.config.state_file).unwrap();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn duplicate_active_branch_is_rejected_without_second_jenkins_trigger() {
|
|
let (jenkins_url, mock) = start_mock_jenkins().await;
|
|
let state = AppState::new(test_config(jenkins_url)).unwrap();
|
|
let app = build_router(state.clone());
|
|
let cookie = login_cookie(&app).await;
|
|
let id = super::derive_deployment_id("feature/busy");
|
|
let now = super::unix_now();
|
|
state.deployments.write().await.insert(
|
|
id.clone(),
|
|
super::DeploymentRecord {
|
|
public: super::Deployment {
|
|
id: None,
|
|
branch: "feature/busy".to_string(),
|
|
commit_hash: None,
|
|
resolved_commit: None,
|
|
status: super::DeploymentStatus::Building,
|
|
health: super::HealthStatus::Pending,
|
|
web_port: None,
|
|
web_url: None,
|
|
web_public_url: None,
|
|
jenkins_build_url: None,
|
|
created_at: now,
|
|
updated_at: now,
|
|
message: None,
|
|
can_uninstall: false,
|
|
},
|
|
instance_id: id,
|
|
operation: super::Operation::Deploy,
|
|
queue_url: None,
|
|
},
|
|
);
|
|
let request = axum::http::Request::builder()
|
|
.method("POST")
|
|
.uri("/api/preview-deployer/deployments")
|
|
.header(header::HOST, HOST)
|
|
.header(header::ORIGIN, ORIGIN)
|
|
.header(header::COOKIE, &cookie)
|
|
.header(header::CONTENT_TYPE, "application/json")
|
|
.body(Body::from(r#"{"branch":"feature/busy"}"#))
|
|
.unwrap();
|
|
assert_eq!(
|
|
app.oneshot(request).await.unwrap().status(),
|
|
StatusCode::CONFLICT
|
|
);
|
|
assert!(mock.requests.lock().unwrap().is_empty());
|
|
}
|
|
|
|
async fn wait_for_status(
|
|
state: &AppState,
|
|
id: &str,
|
|
expected: super::DeploymentStatus,
|
|
) -> Result<(), ()> {
|
|
for _ in 0..100 {
|
|
if state
|
|
.deployments
|
|
.read()
|
|
.await
|
|
.get(id)
|
|
.is_some_and(|record| record.public.status == expected)
|
|
{
|
|
return Ok(());
|
|
}
|
|
tokio::time::sleep(Duration::from_millis(5)).await;
|
|
}
|
|
Err(())
|
|
}
|
|
|
|
async fn wait_for_persisted_status(
|
|
config: &Config,
|
|
id: &str,
|
|
expected: super::DeploymentStatus,
|
|
) -> Result<(), ()> {
|
|
for _ in 0..100 {
|
|
if super::load_deployments(config)
|
|
.ok()
|
|
.and_then(|deployments| deployments.get(id).map(|record| record.public.status))
|
|
.is_some_and(|status| status == expected)
|
|
{
|
|
return Ok(());
|
|
}
|
|
tokio::time::sleep(Duration::from_millis(5)).await;
|
|
}
|
|
Err(())
|
|
}
|
|
|
|
#[test]
|
|
fn public_web_url_is_derived_from_instance_id_and_configured_domain() {
|
|
assert_eq!(
|
|
super::public_web_url(
|
|
Some("preview.genarrative.world"),
|
|
"preview-63d38d3da6bc9b06"
|
|
)
|
|
.as_deref(),
|
|
Some("https://preview-63d38d3da6bc9b06.preview.genarrative.world")
|
|
);
|
|
assert_eq!(
|
|
super::public_web_url(None, "preview-63d38d3da6bc9b06"),
|
|
None
|
|
);
|
|
assert_eq!(
|
|
super::public_web_url(Some("preview.genarrative.world"), "feature/demo"),
|
|
None
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn preview_web_domain_config_is_strict() {
|
|
assert!(super::config::validate_preview_web_domain("preview.genarrative.world").is_ok());
|
|
assert!(super::config::validate_preview_web_domain("preview.genarrative.world:443").is_err());
|
|
assert!(
|
|
super::config::validate_preview_web_domain("https://preview.genarrative.world").is_err()
|
|
);
|
|
assert!(super::config::validate_preview_web_domain("Preview.Genarrative.World").is_err());
|
|
assert!(super::config::validate_preview_web_domain("preview").is_err());
|
|
assert!(super::config::validate_preview_web_domain("-preview.genarrative.world").is_err());
|
|
assert!(super::config::validate_preview_web_domain("preview..genarrative.world").is_err());
|
|
assert!(super::config::validate_preview_web_domain("").is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn branch_commit_and_web_url_validation_are_strict() {
|
|
assert!(super::validate_branch("feature/preview-ui").is_ok());
|
|
assert!(super::validate_branch("../master").is_err());
|
|
assert!(super::validate_branch("main;curl evil").is_err());
|
|
assert!(super::validate_commit("abcdef1").is_ok());
|
|
assert!(super::validate_commit("HEAD~1").is_err());
|
|
assert!(super::validate_deployment_id("preview-63d38d3da6bc9b06").is_ok());
|
|
assert!(super::validate_deployment_id("preview-63D38D3DA6BC9B06").is_err());
|
|
assert!(super::is_safe_web_url(
|
|
"http://192.168.35.82:8400",
|
|
"192.168.35.82"
|
|
));
|
|
assert!(!super::is_safe_web_url(
|
|
"javascript:alert(1)",
|
|
"192.168.35.82"
|
|
));
|
|
assert!(!super::is_safe_web_url(
|
|
"http://user:pass@preview.internal",
|
|
"192.168.35.82"
|
|
));
|
|
assert!(!super::is_safe_web_url(
|
|
"http://192.168.35.82:8500",
|
|
"192.168.35.82"
|
|
));
|
|
assert!(!super::is_safe_web_url(
|
|
"http://station.genarrative.world:8400",
|
|
"192.168.35.82"
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn legacy_running_state_recovers_web_port_from_validated_url() {
|
|
let config = test_config(Url::parse("http://127.0.0.1:18080/jenkins/").unwrap());
|
|
let id = super::derive_deployment_id("feature/legacy-running");
|
|
std::fs::write(
|
|
&config.state_file,
|
|
serde_json::to_vec(&json!({
|
|
"schemaVersion": 1,
|
|
"deployments": [{
|
|
"public": {
|
|
"id": id,
|
|
"branch": "feature/legacy-running",
|
|
"status": "running",
|
|
"health": "healthy",
|
|
"webUrl": "http://192.168.35.82:8407",
|
|
"jenkinsBuildUrl": "http://127.0.0.1:18080/jenkins/job/shared/job/Genarrative-Preview-Deployer/42/",
|
|
"createdAt": 1,
|
|
"updatedAt": 2,
|
|
"canUninstall": true
|
|
},
|
|
"operation": "deploy"
|
|
}]
|
|
}))
|
|
.unwrap(),
|
|
)
|
|
.unwrap();
|
|
|
|
let state = AppState::new(config).unwrap();
|
|
assert_eq!(
|
|
state
|
|
.deployments
|
|
.blocking_read()
|
|
.get(&id)
|
|
.unwrap()
|
|
.public
|
|
.web_port,
|
|
Some(8407)
|
|
);
|
|
let deployment = state
|
|
.deployments
|
|
.blocking_read()
|
|
.get(&id)
|
|
.unwrap()
|
|
.public
|
|
.clone();
|
|
assert_eq!(deployment.id.as_deref(), Some("42"));
|
|
assert_eq!(
|
|
deployment.jenkins_build_url.as_deref(),
|
|
Some("http://192.168.35.82:8080/jenkins/job/shared/job/Genarrative-Preview-Deployer/42/")
|
|
);
|
|
std::fs::remove_file(&state.config.state_file).unwrap();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn expired_terminal_records_are_pruned_but_uninstallable_failures_are_retained() {
|
|
let (jenkins_url, _) = start_mock_jenkins().await;
|
|
let state = AppState::new(test_config(jenkins_url)).unwrap();
|
|
let app = build_router(state.clone());
|
|
let cookie = login_cookie(&app).await;
|
|
let old = super::unix_now().saturating_sub(super::FAILED_RECORD_TTL_SECS + 1);
|
|
let failed_id = super::derive_deployment_id("feature/old-failure");
|
|
let uninstallable_id = super::derive_deployment_id("feature/failed-uninstall");
|
|
state.deployments.write().await.extend([
|
|
(
|
|
failed_id.clone(),
|
|
super::DeploymentRecord {
|
|
public: super::Deployment {
|
|
id: Some("91".to_string()),
|
|
branch: "feature/old-failure".to_string(),
|
|
commit_hash: None,
|
|
resolved_commit: None,
|
|
status: super::DeploymentStatus::Failed,
|
|
health: super::HealthStatus::Unknown,
|
|
web_port: None,
|
|
web_url: None,
|
|
web_public_url: None,
|
|
jenkins_build_url: None,
|
|
created_at: old,
|
|
updated_at: old,
|
|
message: None,
|
|
can_uninstall: false,
|
|
},
|
|
instance_id: failed_id.clone(),
|
|
operation: super::Operation::Deploy,
|
|
queue_url: None,
|
|
},
|
|
),
|
|
(
|
|
uninstallable_id.clone(),
|
|
super::DeploymentRecord {
|
|
public: super::Deployment {
|
|
id: Some("92".to_string()),
|
|
branch: "feature/failed-uninstall".to_string(),
|
|
commit_hash: None,
|
|
resolved_commit: None,
|
|
status: super::DeploymentStatus::Failed,
|
|
health: super::HealthStatus::Unknown,
|
|
web_port: Some(8401),
|
|
web_url: Some("http://192.168.35.82:8401".to_string()),
|
|
web_public_url: None,
|
|
jenkins_build_url: None,
|
|
created_at: old,
|
|
updated_at: old,
|
|
message: None,
|
|
can_uninstall: true,
|
|
},
|
|
instance_id: uninstallable_id.clone(),
|
|
operation: super::Operation::Uninstall,
|
|
queue_url: None,
|
|
},
|
|
),
|
|
]);
|
|
|
|
let response = app
|
|
.oneshot(
|
|
axum::http::Request::builder()
|
|
.method("GET")
|
|
.uri("/api/preview-deployer/deployments")
|
|
.header(header::HOST, HOST)
|
|
.header(header::COOKIE, &cookie)
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let body = response.into_body().collect().await.unwrap().to_bytes();
|
|
let listed: Value = serde_json::from_slice(&body).unwrap();
|
|
assert_eq!(listed["deployments"].as_array().unwrap().len(), 1);
|
|
assert_eq!(listed["deployments"][0]["id"], "92");
|
|
assert!(!state.deployments.read().await.contains_key(&failed_id));
|
|
assert!(
|
|
state
|
|
.deployments
|
|
.read()
|
|
.await
|
|
.contains_key(&uninstallable_id)
|
|
);
|
|
std::fs::remove_file(&state.config.state_file).unwrap();
|
|
}
|