PSA/ee/runner/tests/ext_ui_integration.rs
Hermes 284313f908
Some checks are pending
Bidi Control Character Guard / bidi-control-guard (push) Waiting to run
Circular Dependency Check / Check for new circular dependencies (push) Waiting to run
Citus Migration Smoke / Combined migrations on single-node Citus (push) Waiting to run
E2E Fresh Install Tests / fresh-install-e2e (push) Waiting to run
ext-v2 guardrails / Run ext-v2 guard and ESLint (push) Waiting to run
Integration Tests / Check for relevant changes (push) Waiting to run
Integration Tests / ${{ (github.event_name == 'schedule' || github.event.inputs.suite == 'full') && 'Full integration suite' || 'Tier-1 integration subset' }} (push) Blocked by required conditions
Mobile checks / Mobile lint + typecheck (push) Waiting to run
Mobile checks / Mobile unit tests (push) Waiting to run
Mobile checks / Mobile dependency audit (report) (push) Waiting to run
Mobile checks / Mobile reproducibility checks (push) Waiting to run
Secrets guard (env backups) / Ensure no tracked env backup files (push) Waiting to run
Temporal Readiness / fast-readiness (push) Waiting to run
Temporal Readiness / docker-parity (push) Waiting to run
TypeScript Type Check / Nx affected typecheck (push) Waiting to run
Unit Tests / Skipped-test budget (push) Waiting to run
Unit Tests / Nx affected unit tests (push) Waiting to run
Unit Tests / Server unit coverage (informational) (push) Waiting to run
Validate Tenant Management Schema / Check for relevant changes (push) Waiting to run
Validate Tenant Management Schema / Validate Tenant Management Schema (push) Blocked by required conditions
EE Workflows Build Guard / ee-workflows-build-guard (push) Waiting to run
Initial import of AlgaPSA codebase from PSA server
Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz

Source: /opt/alga-psa on psa.joliet.tech
2026-06-22 16:12:17 -05:00

485 lines
15 KiB
Rust

use axum::{
body,
body::Body,
extract::State,
http::{header, HeaderValue, Method, Request, StatusCode},
response::IntoResponse,
routing::{get, post},
Json, Router,
};
use bytes::Bytes;
use serde_json::json;
use std::net::SocketAddr;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use tar::Builder;
use tokio::net::TcpListener;
use tokio::task::JoinHandle;
use tower::util::ServiceExt;
use url::Url;
use zstd::stream::encode_all as zstd_encode_all;
use alga_ext_runner::http::ext_ui::{handle_get, warmup, AppState as ExtState, WarmupReq};
use alga_ext_runner::registry::client::RegistryClient;
use alga_ext_runner::util::limits;
use serial_test::serial;
// Added imports
use alga_ext_runner::cache::fs as cache_fs;
use alga_ext_runner::engine::loader::verify_archive_sha256;
use alga_ext_runner::util::errors::IntegrityError;
struct AllowingRegistry;
#[async_trait::async_trait]
impl RegistryClient for AllowingRegistry {
async fn validate_install(
&self,
_tenant_id: &str,
_extension_id: &str,
_content_hash: &str,
) -> anyhow::Result<bool> {
Ok(true)
}
}
struct DenyingRegistry;
#[async_trait::async_trait]
impl RegistryClient for DenyingRegistry {
async fn validate_install(
&self,
_tenant_id: &str,
_extension_id: &str,
_content_hash: &str,
) -> anyhow::Result<bool> {
Ok(false)
}
}
fn make_bundle_tarzst() -> (Vec<u8>, String) {
// Build a tar bundle in-memory, then zstd-compress to produce bundle.tar.zst
let mut raw_tar: Vec<u8> = Vec::new();
{
let mut tar = Builder::new(&mut raw_tar);
// index.html
let mut hdr = tar::Header::new_gnu();
let content = b"<!doctype html><html><head><meta charset=\"utf-8\" /></head><body>Hello UI</body></html>";
hdr.set_size(content.len() as u64);
hdr.set_mode(0o644);
hdr.set_cksum();
tar.append_data(&mut hdr, "ui/index.html", &content[..])
.unwrap();
// assets/app.js
let mut hdr2 = tar::Header::new_gnu();
let js = b"console.log('hello');";
hdr2.set_size(js.len() as u64);
hdr2.set_mode(0o644);
hdr2.set_cksum();
tar.append_data(&mut hdr2, "ui/assets/app.js", &js[..])
.unwrap();
tar.finish().unwrap();
}
// zstd-compress the tar bytes
let tarzst = zstd_encode_all(&raw_tar[..], 0).unwrap();
// Compute hex of resulting tar.zst bytes
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(&tarzst);
let hex = hex::encode(hasher.finalize());
(tarzst, hex)
}
async fn start_bundle_http_server(bytes: Vec<u8>) -> (Url, JoinHandle<()>) {
// Serve at /sha256/:hex/bundle.tar.zst (hex extracted from request path but we ignore and always return same bytes)
let app = Router::new().route(
"/sha256/:hex/bundle.tar.zst",
get({
let blob = Bytes::from(bytes);
move || {
let b = blob.clone();
async move {
let mut h = axum::http::HeaderMap::new();
h.insert(
header::CONTENT_TYPE,
HeaderValue::from_static("application/octet-stream"),
);
(StatusCode::OK, h, b).into_response()
}
}
}),
);
let listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap();
let addr = listener.local_addr().unwrap();
let handle = tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
let base = Url::parse(&format!("http://{}/", addr)).unwrap();
(base, handle)
}
fn make_test_state(
cache_root: PathBuf,
bundle_base: Url,
strict: bool,
registry: Arc<dyn RegistryClient + Send + Sync>,
) -> ExtState {
// Control strict validation via env the handler reads
std::env::set_var(
"EXT_STATIC_STRICT_VALIDATION",
if strict { "true" } else { "false" },
);
ExtState {
registry,
cache_root,
bundle_store_base: bundle_base,
max_file_bytes: limits::max_file_bytes_from_env(),
}
}
fn router_for_state(state: ExtState) -> Router {
Router::new()
.route("/ext-ui/:extensionId/:contentHash/*path", get(handle_get))
.route("/warmup", post(warmup))
.with_state(state)
}
async fn request<R>(app: &Router, req: Request<Body>) -> axum::http::Response<Body>
where
R: IntoResponse,
{
app.clone().oneshot(req).await.unwrap()
}
#[tokio::test]
#[serial]
async fn cold_fetch_then_304() {
// Arrange: server serving bundle
let (buf, hex) = make_bundle_tarzst();
let (base, _handle) = start_bundle_http_server(buf).await;
// Temp cache root
let tmpdir = tempfile::tempdir().unwrap();
let cache_root = tmpdir.path().to_path_buf();
let state = make_test_state(cache_root.clone(), base, false, Arc::new(AllowingRegistry));
let app = router_for_state(state.clone());
// Warmup
let warm = Request::builder()
.method(Method::POST)
.uri("/warmup")
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(
serde_json::to_vec(&WarmupReq {
content_hash: format!("sha256:{}", hex),
})
.unwrap(),
))
.unwrap();
let warm_resp = request::<Json<serde_json::Value>>(&app, warm).await;
assert_eq!(warm_resp.status(), StatusCode::OK);
// GET index.html
let get1 = Request::builder()
.method(Method::GET)
.uri(format!("/ext-ui/demo-ext/sha256:{}/index.html", hex))
.header("x-tenant-id", "tenant-a")
.body(Body::empty())
.unwrap();
let resp1 = app.clone().oneshot(get1).await.unwrap();
assert_eq!(resp1.status(), StatusCode::OK);
let etag = resp1
.headers()
.get(header::ETAG)
.unwrap()
.to_str()
.unwrap()
.to_string();
let ct = resp1
.headers()
.get(header::CONTENT_TYPE)
.unwrap()
.to_str()
.unwrap()
.to_string();
assert!(ct.starts_with("text/html"));
let cache_control = resp1
.headers()
.get(header::CACHE_CONTROL)
.unwrap()
.to_str()
.unwrap()
.to_string();
assert!(cache_control.contains("immutable"));
// Second request with If-None-Match should 304
let get2 = Request::builder()
.method(Method::GET)
.uri(format!("/ext-ui/demo-ext/sha256:{}/index.html", hex))
.header("x-tenant-id", "tenant-a")
.header(header::IF_NONE_MATCH, etag)
.body(Body::empty())
.unwrap();
let resp2 = app.clone().oneshot(get2).await.unwrap();
assert_eq!(resp2.status(), StatusCode::NOT_MODIFIED);
}
#[tokio::test]
#[serial]
async fn strict_validation_denied_is_404() {
// Arrange bundle server
let (buf, hex) = make_bundle_tarzst();
let (base, _handle) = start_bundle_http_server(buf).await;
let tmpdir = tempfile::tempdir().unwrap();
let cache_root = tmpdir.path().to_path_buf();
// Strict true with DenyingRegistry
let state = make_test_state(cache_root, base, true, Arc::new(DenyingRegistry));
let app = router_for_state(state);
let get = Request::builder()
.method(Method::GET)
.uri(format!("/ext-ui/demo-ext/sha256:{}/index.html", hex))
.header("x-tenant-id", "tenant-a")
.body(Body::empty())
.unwrap();
let resp = app.clone().oneshot(get).await.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
#[serial]
async fn traversal_is_rejected() {
// Arrange server + state
let (buf, hex) = make_bundle_tarzst();
let (base, _handle) = start_bundle_http_server(buf).await;
let tmpdir = tempfile::tempdir().unwrap();
let cache_root = tmpdir.path().to_path_buf();
let state = make_test_state(cache_root, base, false, Arc::new(AllowingRegistry));
let app = router_for_state(state);
let get = Request::builder()
.method(Method::GET)
.uri(format!("/ext-ui/demo-ext/sha256:{}/../secret.txt", hex))
.header("x-tenant-id", "tenant-a")
.body(Body::empty())
.unwrap();
let resp = app.clone().oneshot(get).await.unwrap();
// Our sanitizer returns 400 for invalid path
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
#[serial]
async fn oversize_asset_returns_413() {
// Arrange bundle with large file
// Create a big asset under ui/assets/big.bin
let mut raw_tar: Vec<u8> = Vec::new();
{
let mut tar = Builder::new(&mut raw_tar);
// index.html small
let mut hdr = tar::Header::new_gnu();
let content = b"<html>ok</html>";
hdr.set_size(content.len() as u64);
hdr.set_mode(0o644);
hdr.set_cksum();
tar.append_data(&mut hdr, "ui/index.html", &content[..])
.unwrap();
// big file (use allowed extension so we don't fail sanitizer)
let big = vec![0u8; 128 * 1024]; // 128KiB
let mut hdr2 = tar::Header::new_gnu();
hdr2.set_size(big.len() as u64);
hdr2.set_mode(0o644);
hdr2.set_cksum();
tar.append_data(&mut hdr2, "ui/assets/big.png", &big[..])
.unwrap();
tar.finish().unwrap();
}
let tarzst = zstd_encode_all(&raw_tar[..], 0).unwrap();
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(&tarzst);
let hex = hex::encode(hasher.finalize());
let (base, _handle) = start_bundle_http_server(tarzst).await;
let tmpdir = tempfile::tempdir().unwrap();
let cache_root = tmpdir.path().to_path_buf();
// Set max file bytes small
std::env::set_var("EXT_STATIC_MAX_FILE_BYTES", "1024"); // 1KiB
let state = ExtState {
registry: Arc::new(AllowingRegistry),
cache_root,
bundle_store_base: base,
max_file_bytes: limits::max_file_bytes_from_env(),
};
let app = router_for_state(state);
// Warmup
let warm = Request::builder()
.method(Method::POST)
.uri("/warmup")
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(
serde_json::to_vec(&WarmupReq {
content_hash: format!("sha256:{}", hex),
})
.unwrap(),
))
.unwrap();
let warm_resp = app.clone().oneshot(warm).await.unwrap();
assert_eq!(warm_resp.status(), StatusCode::OK);
// Request the oversize asset
let get = Request::builder()
.method(Method::GET)
.uri(format!("/ext-ui/demo-ext/sha256:{}/assets/big.png", hex))
.header("x-tenant-id", "tenant-a")
.body(Body::empty())
.unwrap();
let resp = app.clone().oneshot(get).await.unwrap();
assert_eq!(resp.status(), StatusCode::PAYLOAD_TOO_LARGE);
}
// New tests for integrity and extraction failure
#[tokio::test]
#[serial]
async fn archive_hash_mismatch_returns_502() {
// Make a good bundle and its correct hex
let (good_bytes, good_hex) = make_bundle_tarzst();
// Create bad bytes by flipping one byte
let mut bad_bytes = good_bytes.clone();
if let Some(b) = bad_bytes.get_mut(0) {
*b ^= 0xFF;
}
let (base, _handle) = start_bundle_http_server(bad_bytes).await;
let tmpdir = tempfile::tempdir().unwrap();
let cache_root = tmpdir.path().to_path_buf();
let state = make_test_state(cache_root, base, false, Arc::new(AllowingRegistry));
let app = router_for_state(state);
// Direct GET should trigger download+verify and fail with 502
let req = Request::builder()
.method(Method::GET)
.uri(format!("/ext-ui/demo-ext/sha256:{}/index.html", good_hex))
.header("x-tenant-id", "tenant-a")
.body(Body::empty())
.unwrap();
let resp = app.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_GATEWAY);
// Parse JSON error code
let body_bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
.await
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap();
assert_eq!(
v.get("code").and_then(|x| x.as_str()),
Some("archive_hash_mismatch")
);
}
#[tokio::test]
#[serial]
async fn partial_extract_cleanup_on_failure() {
// Create bytes that hash to H but are not a valid tar.gz to force extraction error AFTER hash verification
// We'll use random bytes; compute their hash and serve them.
let bad_extraction_bytes: Vec<u8> = vec![0x1Fu8; 1024]; // not a valid gzip
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(&bad_extraction_bytes);
let hex = hex::encode(hasher.finalize());
let (base, _handle) = start_bundle_http_server(bad_extraction_bytes).await;
let tmpdir = tempfile::tempdir().unwrap();
let cache_root = tmpdir.path().to_path_buf();
let state = make_test_state(cache_root.clone(), base, false, Arc::new(AllowingRegistry));
let app = router_for_state(state);
// Trigger GET (no warmup) to cause extraction attempt
let req = Request::builder()
.method(Method::GET)
.uri(format!("/ext-ui/demo-ext/sha256:{}/index.html", hex))
.header("x-tenant-id", "tenant-a")
.body(Body::empty())
.unwrap();
let resp = app.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
let body_bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
.await
.unwrap();
if let Ok(v) = serde_json::from_slice::<serde_json::Value>(&body_bytes) {
assert_eq!(
v.get("code").and_then(|x| x.as_str()),
Some("extract_failed")
);
}
// Ensure no residual cache subtree exists
let ui_root = cache_fs::ui_cache_dir(&cache_root, &hex);
assert!(
!std::fs::metadata(&ui_root).is_ok(),
"ui cache dir should not remain on failure"
);
}
#[tokio::test]
#[serial]
async fn loader_verify_archive_sha256_unit() {
// Prepare known bytes and its hex; serve via tiny server
let bytes = b"unit-test-archive-contents".to_vec();
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(&bytes);
let hex = hex::encode(hasher.finalize());
let (base, _handle) = start_bundle_http_server(bytes.clone()).await;
let url = base
.join(&format!("sha256/{}/bundle.tar.zst", hex))
.unwrap();
// Success case
let tmp = verify_archive_sha256(&url, &hex)
.await
.expect("verify should pass");
assert!(std::fs::metadata(&tmp).is_ok());
// Cleanup
let _ = std::fs::remove_file(&tmp);
// Mismatch case: use different expected hex
let bad_url = base
.join(&format!("sha256/{}/bundle.tar.zst", "deadbeef"))
.unwrap();
let err = verify_archive_sha256(&bad_url, "deadbeef")
.await
.unwrap_err();
let ie = err
.downcast_ref::<IntegrityError>()
.expect("should be IntegrityError");
match ie {
IntegrityError::ArchiveHashMismatch {
expected_hex,
computed_hex,
} => {
assert_eq!(expected_hex, "deadbeef");
assert_ne!(computed_hex, expected_hex);
}
}
}