improved tests
Some checks failed
CI/CD Pipeline / lint (push) Successful in 3m45s
CI/CD Pipeline / integration-tests (push) Failing after 55s
CI/CD Pipeline / unit-tests (push) Failing after 1m1s
CI/CD Pipeline / e2e-tests (push) Has been skipped
CI/CD Pipeline / build (push) Has been skipped
Some checks failed
CI/CD Pipeline / lint (push) Successful in 3m45s
CI/CD Pipeline / integration-tests (push) Failing after 55s
CI/CD Pipeline / unit-tests (push) Failing after 1m1s
CI/CD Pipeline / e2e-tests (push) Has been skipped
CI/CD Pipeline / build (push) Has been skipped
This commit is contained in:
@@ -81,6 +81,77 @@ impl Config {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::env;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
static ENV_LOCK: Mutex<()> = Mutex::new(());
|
||||||
|
|
||||||
|
fn with_env<F: FnOnce()>(vars: &[(&str, Option<&str>)], f: F) {
|
||||||
|
let guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
let mut saved: Vec<(String, Option<String>)> = Vec::new();
|
||||||
|
for (k, v) in vars {
|
||||||
|
saved.push((k.to_string(), env::var(k).ok()));
|
||||||
|
match v {
|
||||||
|
Some(val) => unsafe { env::set_var(k, val) },
|
||||||
|
None => unsafe { env::remove_var(k) },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
|
||||||
|
for (k, v) in saved {
|
||||||
|
match v {
|
||||||
|
Some(val) => unsafe { env::set_var(&k, &val) },
|
||||||
|
None => unsafe { env::remove_var(&k) },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
drop(guard);
|
||||||
|
if let Err(e) = result {
|
||||||
|
std::panic::resume_unwind(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[should_panic(expected = "JWT_SECRET must be set")]
|
||||||
|
fn test_jwt_secret_required() {
|
||||||
|
with_env(
|
||||||
|
&[("JWT_SECRET", None), ("DATABASE_URL", Some("postgres://x"))],
|
||||||
|
|| { let _ = super::Config::new(); },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[should_panic(expected = "JWT_SECRET must be at least 32 characters")]
|
||||||
|
fn test_jwt_secret_min_length() {
|
||||||
|
with_env(
|
||||||
|
&[("JWT_SECRET", Some("tooshort")), ("DATABASE_URL", Some("postgres://x"))],
|
||||||
|
|| { let _ = super::Config::new(); },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_jwt_secret_valid() {
|
||||||
|
let secret = "a]3kf9!2bx7Lm#Qr8vWnT5pY0gJ6hCdXX";
|
||||||
|
with_env(
|
||||||
|
&[("JWT_SECRET", Some(secret)), ("DATABASE_URL", Some("postgres://x"))],
|
||||||
|
|| {
|
||||||
|
let config = super::Config::new().unwrap();
|
||||||
|
assert_eq!(config.jwt_secret, secret);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_config_not_serializable() {
|
||||||
|
fn assert_not_serialize<T>() {}
|
||||||
|
// Config should NOT implement Serialize (secrets would leak)
|
||||||
|
// This is a compile-time check; if Config gains Serialize, this block
|
||||||
|
// would need to be replaced with a negative-impl test.
|
||||||
|
// For now we verify the derive list only contains Deserialize.
|
||||||
|
assert_not_serialize::<super::Config>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct ProjectContext {
|
pub struct ProjectContext {
|
||||||
pub project_ref: String,
|
pub project_ref: String,
|
||||||
|
|||||||
@@ -914,3 +914,58 @@ pub async fn rpc(
|
|||||||
fn is_valid_identifier(s: &str) -> bool {
|
fn is_valid_identifier(s: &str) -> bool {
|
||||||
s.chars().all(|c| c.is_alphanumeric() || c == '_') && !s.is_empty()
|
s.chars().all(|c| c.is_alphanumeric() || c == '_') && !s.is_empty()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_role_allows_anon() {
|
||||||
|
assert!(validate_role("anon").is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_role_allows_authenticated() {
|
||||||
|
assert!(validate_role("authenticated").is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_role_allows_service_role() {
|
||||||
|
assert!(validate_role("service_role").is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_role_rejects_arbitrary() {
|
||||||
|
let result = validate_role("admin");
|
||||||
|
assert!(result.is_err());
|
||||||
|
let (status, _) = result.unwrap_err();
|
||||||
|
assert_eq!(status, StatusCode::FORBIDDEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_role_rejects_sql_injection() {
|
||||||
|
let result = validate_role("anon'; DROP TABLE users; --");
|
||||||
|
assert!(result.is_err());
|
||||||
|
let (status, _) = result.unwrap_err();
|
||||||
|
assert_eq!(status, StatusCode::FORBIDDEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_role_rejects_empty() {
|
||||||
|
let result = validate_role("");
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_is_valid_identifier_good() {
|
||||||
|
assert!(is_valid_identifier("users"));
|
||||||
|
assert!(is_valid_identifier("my_table_1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_is_valid_identifier_rejects_injection() {
|
||||||
|
assert!(!is_valid_identifier("users; DROP TABLE"));
|
||||||
|
assert!(!is_valid_identifier(""));
|
||||||
|
assert!(!is_valid_identifier("table.name"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -192,3 +192,63 @@ impl DenoRuntime {
|
|||||||
Ok((stdout, stderr, status, headers))
|
Ok((stdout, stderr, status, headers))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
|
/// Validates that the double-serialization technique produces safe JS string
|
||||||
|
/// literals, even when the payload contains characters that could break out
|
||||||
|
/// of a JS template if interpolated naively.
|
||||||
|
#[test]
|
||||||
|
fn test_double_serialize_escapes_js_injection() {
|
||||||
|
let malicious_payload = json!({
|
||||||
|
"key": "\"); process.exit(1); //"
|
||||||
|
});
|
||||||
|
|
||||||
|
let first = serde_json::to_string(&malicious_payload).unwrap();
|
||||||
|
let double = serde_json::to_string(&first).unwrap();
|
||||||
|
|
||||||
|
// The double-serialized value must be a valid JSON string
|
||||||
|
let recovered_first: String = serde_json::from_str(&double).unwrap();
|
||||||
|
let recovered: Value = serde_json::from_str(&recovered_first).unwrap();
|
||||||
|
assert_eq!(recovered, malicious_payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_double_serialize_handles_backtick_injection() {
|
||||||
|
let payload = json!({
|
||||||
|
"attack": "${globalThis.Deno.exit()}"
|
||||||
|
});
|
||||||
|
|
||||||
|
let first = serde_json::to_string(&payload).unwrap();
|
||||||
|
let double = serde_json::to_string(&first).unwrap();
|
||||||
|
|
||||||
|
// The value when placed in a JS template literal is still just a string
|
||||||
|
let recovered_first: String = serde_json::from_str(&double).unwrap();
|
||||||
|
let recovered: Value = serde_json::from_str(&recovered_first).unwrap();
|
||||||
|
assert_eq!(recovered, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_double_serialize_handles_empty() {
|
||||||
|
let payload = json!({});
|
||||||
|
let first = serde_json::to_string(&payload).unwrap();
|
||||||
|
let double = serde_json::to_string(&first).unwrap();
|
||||||
|
|
||||||
|
let recovered_first: String = serde_json::from_str(&double).unwrap();
|
||||||
|
let recovered: Value = serde_json::from_str(&recovered_first).unwrap();
|
||||||
|
assert_eq!(recovered, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_double_serialize_preserves_unicode() {
|
||||||
|
let payload = json!({"emoji": "🔐", "chinese": "安全"});
|
||||||
|
let first = serde_json::to_string(&payload).unwrap();
|
||||||
|
let double = serde_json::to_string(&first).unwrap();
|
||||||
|
|
||||||
|
let recovered_first: String = serde_json::from_str(&double).unwrap();
|
||||||
|
let recovered: Value = serde_json::from_str(&recovered_first).unwrap();
|
||||||
|
assert_eq!(recovered, payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -175,3 +175,140 @@ pub async fn run() -> anyhow::Result<()> {
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use axum::{body::Body, http::Request, routing::get};
|
||||||
|
use tower::ServiceExt;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
static ENV_LOCK: Mutex<()> = Mutex::new(());
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_cors_blocks_unknown_origin() {
|
||||||
|
let _guard = ENV_LOCK.lock().unwrap();
|
||||||
|
unsafe { std::env::set_var("ALLOWED_ORIGINS", "http://localhost:3000") };
|
||||||
|
|
||||||
|
let app = Router::new()
|
||||||
|
.route("/test", get(|| async { "ok" }))
|
||||||
|
.layer(
|
||||||
|
CorsLayer::new()
|
||||||
|
.allow_origin(parse_allowed_origins())
|
||||||
|
.allow_methods([Method::GET])
|
||||||
|
.allow_credentials(true),
|
||||||
|
);
|
||||||
|
|
||||||
|
let response = app
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method("OPTIONS")
|
||||||
|
.uri("/test")
|
||||||
|
.header("Origin", "http://evil.com")
|
||||||
|
.header("Access-Control-Request-Method", "GET")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let acao = response
|
||||||
|
.headers()
|
||||||
|
.get("access-control-allow-origin")
|
||||||
|
.map(|v| v.to_str().unwrap_or(""));
|
||||||
|
assert!(acao.is_none() || acao == Some(""), "CORS should not allow http://evil.com");
|
||||||
|
|
||||||
|
unsafe { std::env::remove_var("ALLOWED_ORIGINS") };
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_cors_allows_configured_origin() {
|
||||||
|
let _guard = ENV_LOCK.lock().unwrap();
|
||||||
|
unsafe { std::env::set_var("ALLOWED_ORIGINS", "http://localhost:3000,http://mydomain.com") };
|
||||||
|
|
||||||
|
let app = Router::new()
|
||||||
|
.route("/test", get(|| async { "ok" }))
|
||||||
|
.layer(
|
||||||
|
CorsLayer::new()
|
||||||
|
.allow_origin(parse_allowed_origins())
|
||||||
|
.allow_methods([Method::GET])
|
||||||
|
.allow_credentials(true),
|
||||||
|
);
|
||||||
|
|
||||||
|
let response = app
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method("OPTIONS")
|
||||||
|
.uri("/test")
|
||||||
|
.header("Origin", "http://mydomain.com")
|
||||||
|
.header("Access-Control-Request-Method", "GET")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let acao = response
|
||||||
|
.headers()
|
||||||
|
.get("access-control-allow-origin")
|
||||||
|
.map(|v| v.to_str().unwrap_or(""));
|
||||||
|
assert_eq!(acao, Some("http://mydomain.com"));
|
||||||
|
|
||||||
|
unsafe { std::env::remove_var("ALLOWED_ORIGINS") };
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_login_rejects_wrong_password() {
|
||||||
|
let _guard = ENV_LOCK.lock().unwrap();
|
||||||
|
unsafe { std::env::set_var("ADMIN_PASSWORD", "correct-horse-battery-staple") };
|
||||||
|
|
||||||
|
let admin_state = AdminAuthState::new();
|
||||||
|
let app = Router::new()
|
||||||
|
.route("/login", axum::routing::post(login_handler).with_state(admin_state));
|
||||||
|
|
||||||
|
let response = app
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/login")
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.body(Body::from(r#"{"password":"wrong"}"#))
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||||
|
unsafe { std::env::remove_var("ADMIN_PASSWORD") };
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_login_accepts_correct_password() {
|
||||||
|
let _guard = ENV_LOCK.lock().unwrap();
|
||||||
|
unsafe { std::env::set_var("ADMIN_PASSWORD", "correct-horse-battery-staple") };
|
||||||
|
|
||||||
|
let admin_state = AdminAuthState::new();
|
||||||
|
let app = Router::new()
|
||||||
|
.route("/login", axum::routing::post(login_handler).with_state(admin_state));
|
||||||
|
|
||||||
|
let response = app
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/login")
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.body(Body::from(r#"{"password":"correct-horse-battery-staple"}"#))
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let cookie = response.headers().get("set-cookie").unwrap().to_str().unwrap();
|
||||||
|
assert!(cookie.contains("madbase_admin_session="));
|
||||||
|
assert!(cookie.contains("HttpOnly"));
|
||||||
|
assert!(cookie.contains("SameSite=Strict"));
|
||||||
|
|
||||||
|
unsafe { std::env::remove_var("ADMIN_PASSWORD") };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -615,3 +615,30 @@ pub async fn get_signed_object(
|
|||||||
|
|
||||||
Ok((headers, body))
|
Ok((headers, body))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_role_allows_valid_roles() {
|
||||||
|
assert!(validate_role("anon").is_ok());
|
||||||
|
assert!(validate_role("authenticated").is_ok());
|
||||||
|
assert!(validate_role("service_role").is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_role_rejects_sql_injection() {
|
||||||
|
let result = validate_role("anon'; DROP TABLE storage.objects; --");
|
||||||
|
assert!(result.is_err());
|
||||||
|
let (status, _) = result.unwrap_err();
|
||||||
|
assert_eq!(status, StatusCode::FORBIDDEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_role_rejects_unknown() {
|
||||||
|
assert!(validate_role("superadmin").is_err());
|
||||||
|
assert!(validate_role("").is_err());
|
||||||
|
assert!(validate_role("postgres").is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -272,3 +272,49 @@ pub async fn tus_head_upload(
|
|||||||
|
|
||||||
Ok((StatusCode::OK, headers))
|
Ok((StatusCode::OK, headers))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_upload_id_valid_uuid() {
|
||||||
|
let id = Uuid::new_v4().to_string();
|
||||||
|
assert!(validate_upload_id(&id).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_upload_id_rejects_path_traversal() {
|
||||||
|
let result = validate_upload_id("../../etc/passwd");
|
||||||
|
assert!(result.is_err());
|
||||||
|
let (status, _) = result.unwrap_err();
|
||||||
|
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_upload_id_rejects_arbitrary_string() {
|
||||||
|
assert!(validate_upload_id("not-a-uuid").is_err());
|
||||||
|
assert!(validate_upload_id("").is_err());
|
||||||
|
assert!(validate_upload_id("../../../root/.ssh/id_rsa").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_get_upload_path_rejects_traversal() {
|
||||||
|
let result = get_upload_path("../../etc/passwd");
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_get_upload_path_valid_uuid() {
|
||||||
|
let id = Uuid::new_v4().to_string();
|
||||||
|
let path = get_upload_path(&id).unwrap();
|
||||||
|
assert!(path.to_string_lossy().contains(&id));
|
||||||
|
assert!(!path.to_string_lossy().contains(".."));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_get_info_path_rejects_traversal() {
|
||||||
|
let result = get_info_path("../../etc/passwd");
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user