use axum::body::{Body, to_bytes};
use axum::http::{Request, StatusCode};
use oxipage_core::config::Config;
use oxipage_core::extension::{Extension, Lang, LobbyCard, Migration};
use oxipage_core::registry::ExtensionRegistry;
use oxipage_core::state::AppState;
use std::sync::Arc;
use tower::ServiceExt;
struct DummyExt;
#[async_trait::async_trait]
impl Extension for DummyExt {
fn id(&self) -> &'static str {
"dummy"
}
fn display_name(&self, lang: Lang) -> String {
match lang {
Lang::Ko => "더미".to_string(),
Lang::En => "Dummy".to_string(),
}
}
fn migrations(&self) -> Vec<Migration> {
vec![Migration {
version: 1,
name: "init",
sql: "CREATE TABLE IF NOT EXISTS dummy_t (id INTEGER PRIMARY KEY)",
}]
}
fn table_names(&self) -> Vec<&'static str> {
vec!["dummy_t"]
}
fn routes(&self) -> axum::Router<AppState> {
use axum::routing::get;
axum::Router::new().route(
"/",
get(|| async { axum::Json(serde_json::json!({"data": {"ok": true}})) }),
)
}
async fn lobby_summary(&self, _ctx: &AppState) -> Option<LobbyCard> {
None
}
}
async fn test_app() -> axum::Router {
let pool = oxipage_core::db::connect_memory().await.unwrap();
let registry = Arc::new(ExtensionRegistry::new(vec![Arc::new(DummyExt)]));
registry.run_migrations(&pool, &[]).await.unwrap();
let state = AppState {
db: pool,
config: Arc::new(Config::default()),
admin_token: None,
registry,
wasm_loader: None,
site_override: std::sync::Arc::new(tokio::sync::RwLock::new(None)),
builders: std::sync::Arc::new(vec![]),
};
oxipage_core::http::build_app(state)
}
async fn body_string(res: axum::response::Response) -> String {
let bytes = to_bytes(res.into_body(), usize::MAX).await.unwrap();
String::from_utf8(bytes.to_vec()).unwrap()
}
#[tokio::test]
async fn healthz_returns_ok() {
let app = test_app().await;
let res = app
.oneshot(Request::get("/healthz").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(res.status(), StatusCode::OK);
assert!(body_string(res).await.contains("\"ok\""));
}
#[tokio::test]
async fn manifest_lists_enabled_extensions() {
let app = test_app().await;
let res = app
.oneshot(
Request::get("/api/console/lobby/manifest")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let body = body_string(res).await;
assert!(body.contains("\"id\":\"dummy\""));
assert!(body.contains("\"ko\":\"더미\""));
assert!(body.contains("\"en\":\"Dummy\""));
assert!(body.contains("\"default_lang\":\"ko\""));
}
#[tokio::test]
async fn extension_routes_are_mounted_under_api_v1() {
let app = test_app().await;
let res = app
.oneshot(Request::get("/api/console/dummy").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(res.status(), StatusCode::OK);
assert!(body_string(res).await.contains("\"ok\":true"));
}
#[tokio::test]
async fn spa_fallback_serves_index_html_for_unknown_paths() {
let app = test_app().await;
let res = app
.oneshot(
Request::get("/some/unknown/path")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let body = body_string(res).await;
assert!(body.to_lowercase().contains("<!doctype html"));
}
#[tokio::test]
async fn unknown_api_path_returns_404_json() {
let app = test_app().await;
let res = app
.oneshot(Request::get("/api/console/nope/").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(res.status(), StatusCode::NOT_FOUND);
let body = body_string(res).await;
assert!(body.contains("\"error\""));
}
use oxipage_core::auth::sha256_hex;
use sqlx::SqlitePool;
async fn pat_setup(admin_token: Option<&str>) -> (axum::Router, SqlitePool) {
let pool = oxipage_core::db::connect_memory().await.unwrap();
let registry = Arc::new(ExtensionRegistry::new(vec![Arc::new(DummyExt)]));
registry.run_migrations(&pool, &[]).await.unwrap();
let state = AppState {
db: pool.clone(),
config: Arc::new(Config::default()),
admin_token: admin_token.map(Arc::<str>::from),
registry,
wasm_loader: None,
site_override: std::sync::Arc::new(tokio::sync::RwLock::new(None)),
builders: std::sync::Arc::new(vec![]),
};
let app = oxipage_core::http::build_app(state);
(app, pool)
}
async fn seed_pat(pool: &SqlitePool, label: &str, scopes: &[&str]) -> String {
let plain = format!("oxp_test_{label}");
let hash = sha256_hex(plain.as_bytes());
let prefix: String = plain.chars().take(12).collect();
sqlx::query(
"INSERT INTO auth_token (label, token_hash, token_prefix, scopes)
VALUES (?, ?, ?, ?)",
)
.bind(label)
.bind(&hash)
.bind(&prefix)
.bind(serde_json::to_string(scopes).unwrap())
.execute(pool)
.await
.unwrap();
plain
}
async fn put_lobby_config(app: axum::Router, token: &str) -> axum::response::Response {
app.oneshot(
Request::put("/api/console/lobby/config/dummy")
.header("content-type", "application/json")
.header("authorization", format!("Bearer {token}"))
.body(Body::from(r#"{"display_mode":"grid"}"#))
.unwrap(),
)
.await
.unwrap()
}
async fn post_create_pat(app: axum::Router, token: &str) -> axum::response::Response {
app.oneshot(
Request::post("/api/console/auth/tokens")
.header("content-type", "application/json")
.header("authorization", format!("Bearer {token}"))
.body(Body::from(r#"{"label":"x","scopes":["read"]}"#))
.unwrap(),
)
.await
.unwrap()
}
#[tokio::test]
async fn pat_read_only_rejected_on_write_route() {
let (app, pool) = pat_setup(Some("root")).await;
let pat = seed_pat(&pool, "reader", &["read"]).await;
let res = put_lobby_config(app, &pat).await;
assert_eq!(res.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn pat_write_can_write_but_not_admin_action() {
let (app, pool) = pat_setup(Some("root")).await;
let pat = seed_pat(&pool, "writer", &["post:write"]).await;
let res = put_lobby_config(app.clone(), &pat).await;
assert_eq!(res.status(), StatusCode::OK);
let res = post_create_pat(app, &pat).await;
assert_eq!(res.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn pat_admin_can_manage_tokens() {
let (app, pool) = pat_setup(Some("root")).await;
let pat = seed_pat(&pool, "admin_tok", &["admin"]).await;
let res = post_create_pat(app, &pat).await;
assert_eq!(res.status(), StatusCode::OK);
let body = body_string(res).await;
assert!(body.contains("data"), "expected data envelope: {body}");
}
#[tokio::test]
async fn pat_with_neither_admin_nor_write_is_rejected_even_with_valid_token() {
let (app, pool) = pat_setup(Some("root")).await;
let pat = seed_pat(&pool, "reader2", &["read"]).await;
let res = post_create_pat(app, &pat).await;
assert_eq!(res.status(), StatusCode::FORBIDDEN);
}
async fn admin_app() -> axum::Router {
let pool = oxipage_core::db::connect_memory().await.unwrap();
let registry = Arc::new(ExtensionRegistry::new(vec![Arc::new(DummyExt)]));
registry.run_migrations(&pool, &[]).await.unwrap();
let state = AppState {
db: pool,
config: Arc::new(Config::default()),
admin_token: Some(Arc::from("test-admin-token")),
registry,
wasm_loader: None,
site_override: std::sync::Arc::new(tokio::sync::RwLock::new(None)),
builders: std::sync::Arc::new(vec![]),
};
oxipage_core::http::build_app(state)
}
fn admin_req(method: &str, uri: &str) -> Request<Body> {
Request::builder()
.method(method)
.uri(uri)
.header("authorization", "Bearer test-admin-token")
.body(Body::empty())
.unwrap()
}
#[tokio::test]
async fn extensions_list_returns_admin_state() {
let app = admin_app().await;
let res = app.oneshot(admin_req("GET", "/api/console/extensions")).await.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let body = body_string(res).await;
assert!(body.contains("dummy"), "body: {body}");
}
#[tokio::test]
async fn disable_gates_extension_routes() {
let app = admin_app().await;
let before = app
.clone()
.oneshot(Request::get("/api/console/dummy").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(before.status(), StatusCode::OK);
let res = app
.clone()
.oneshot(admin_req("POST", "/api/console/extensions/dummy/disable"))
.await
.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let gated = app
.oneshot(Request::get("/api/console/dummy").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(gated.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn enable_restores_extension_routes() {
let app = admin_app().await;
app.clone()
.oneshot(admin_req("POST", "/api/console/extensions/dummy/disable"))
.await
.unwrap();
let res = app
.clone()
.oneshot(admin_req("POST", "/api/console/extensions/dummy/enable"))
.await
.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let after = app
.oneshot(Request::get("/api/console/dummy").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(after.status(), StatusCode::OK);
}
#[tokio::test]
async fn purge_marks_extension_and_gates_routes() {
let app = admin_app().await;
let res = app
.clone()
.oneshot(admin_req("DELETE", "/api/console/extensions/dummy"))
.await
.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let body = body_string(res).await;
assert!(body.contains("\"purged\":true"), "body: {body}");
let gated = app
.oneshot(Request::get("/api/console/dummy").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(gated.status(), StatusCode::NOT_FOUND);
}
#[cfg_attr(target_os = "linux", ignore)]
#[tokio::test]
async fn install_writes_wasm_and_registers_state() {
let data_dir =
std::env::temp_dir().join(format!("oxipage-install-test-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&data_dir);
std::fs::create_dir_all(&data_dir).unwrap();
let pool = oxipage_core::db::connect_memory().await.unwrap();
let registry = Arc::new(ExtensionRegistry::new(vec![Arc::new(DummyExt)]));
registry.run_migrations(&pool, &[]).await.unwrap();
let mut config = Config::default();
config.server.data_dir = data_dir.clone();
let state = AppState {
db: pool.clone(),
config: Arc::new(config),
admin_token: Some(Arc::from("test-admin-token")),
registry,
wasm_loader: None,
site_override: std::sync::Arc::new(tokio::sync::RwLock::new(None)),
builders: std::sync::Arc::new(vec![]),
};
let app = oxipage_core::http::build_app(state);
let req = Request::builder()
.method("POST")
.uri("/api/console/extensions/install")
.header("authorization", "Bearer test-admin-token")
.header("content-type", "application/json")
.body(Body::from(r#"{"name":"wasm-demo"}"#))
.unwrap();
let res = app.oneshot(req).await.unwrap();
assert_eq!(res.status(), StatusCode::OK, "install should succeed");
let body = body_string(res).await;
assert!(body.contains("wasm-demo"), "body: {body}");
assert!(body.contains("\"bytes\""), "body should report byte count: {body}");
let wasm_path = data_dir.join("extensions").join("wasm-demo.wasm");
assert!(wasm_path.exists(), "wasm artifact should be written at {}", wasm_path.display());
let meta = std::fs::metadata(&wasm_path).unwrap();
assert!(meta.len() > 100, "wasm artifact non-trivial size, got {}", meta.len());
let (enabled,): (i64,) =
sqlx::query_as("SELECT enabled FROM extension_state WHERE extension_id = ?")
.bind("wasm-demo")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(
enabled, 0,
"newly installed wasm ext should be disabled (enabled=0) until restart+enable"
);
let _ = std::fs::remove_dir_all(&data_dir);
}