Files
Genarrative/server-rs/crates/preview-deployer-server/src/tests.rs
T
lhk229 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 真实加载路径校验状态文件
2026-09-22 00:53:47 +08:00

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();
}