use super::{ http::{Server, request, request_head}, *, }; use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; use hmac::{Hmac, Mac}; use sha2::Sha256; mod jobs; mod metadata; mod rest; mod rest_json; mod roles; mod tasks; use std::{ net::{TcpListener, TcpStream}, time::{Duration, Instant, SystemTime, UNIX_EPOCH}, }; pub(super) const KEY: &str = "native-test-key-only-not-for-deployment"; fn token(subject: &str, session: &str) -> String { tenant_token(subject, session, None) } pub(super) fn tenant_token(subject: &str, session: &str, tenant: Option<&str>) -> String { let now = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap() .as_secs() as i64; let claims = serde_json::json!({"iss":"session","aud":"admin","sub":subject,"sid":session,"tenant":tenant,"iat":now-1,"exp":now+59}); let signed = format!( "{}.{}", URL_SAFE_NO_PAD.encode(r#"{"alg":"HS256","typ":"JWT"}"#), URL_SAFE_NO_PAD.encode(claims.to_string()) ); let mut mac = Hmac::::new_from_slice(KEY.as_bytes()).unwrap(); mac.update(signed.as_bytes()); format!( "{signed}.{}", URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes()) ) } #[test] fn authenticated_http_executes_dever_verify_and_observes_revocation() { let directory = Directory::new(); verify_authentication( &directory, serde_json::json!({"type":"sqlite","path":"data/account.db","max_connections":4}), ); } #[cfg(feature = "postgres")] #[test] #[ignore = "requires explicit isolated config/setting.json database.postgres_test"] fn postgres_authenticated_http_executes_dever_verify_and_observes_revocation() { let directory = Directory::new(); let schema = super::database::postgres::Schema::new( &directory.0, "dever_native_auth", "CREATE SCHEMA dever_native_auth", "DROP SCHEMA dever_native_auth CASCADE", ); let mut connection = schema.connection.clone(); connection["max_connections"] = 4.into(); verify_authentication(&directory, connection); } // 相同业务源码覆盖两种驱动的登录、角色、REST 和入队后撤销会话。 fn verify_authentication(directory: &Directory, connection: serde_json::Value) { let project = Project::load(&fixture().with_file_name("auth")).unwrap(); let program = project.check().unwrap(); assert_eq!(program.verifiers.len(), 1); let binary = build::build( &program, &project.sources, "main", &directory.0.join("auth"), ) .unwrap(); let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let port = listener.local_addr().unwrap().port(); drop(listener); std::fs::create_dir(directory.0.join("config")).unwrap(); let settings = serde_json::json!({ "http":{"host":"127.0.0.1","port":port}, "sites":{"admin":{"path":"admin","auth":"session","hosts":["fixture.test"],"origin":"https://fixture.test"}}, "database":{"default":connection}, "runtime":{"mode":"api"}, "auth":{"providers":{"session":{"verify":"demo.account.verify","jwtSecret":KEY,"ttlSeconds":60,"cookie":"session"}}} }); std::fs::write( directory.0.join("config/setting.json"), settings.to_string(), ) .unwrap(); let seeded = execute( &binary, "demo.account.seed", r#"{"subject":"user","session":"valid-session"}"#, ); assert!( seeded.status.success(), "{}", String::from_utf8_lossy(&seeded.stderr) ); let mut server = Server( Command::new(&binary) .stdout(std::process::Stdio::null()) .spawn() .unwrap(), ); let deadline = Instant::now() + Duration::from_secs(5); while TcpStream::connect(("127.0.0.1", port)).is_err() { assert!(server.0.try_wait().unwrap().is_none()); assert!(Instant::now() < deadline); std::thread::sleep(Duration::from_millis(10)); } let path = "/demo/account/admin/session/me"; metadata::check(port); let (status, body) = request( port, "POST", "/demo/account/admin/secret/probe", "fixture.test", "Content-Type: application/json\r\n", r#"{"input":{"key":"fixture-only-key"}}"#, ); assert_eq!(status, 200, "{body}"); assert_eq!( serde_json::from_str::(&body).unwrap()["data"], 32 ); let health = "/demo/account/admin/session/health"; let login = "/demo/account/admin/session/login"; let origin = "Origin: https://fixture.test\r\nContent-Type: application/json\r\n"; let credentials = r#"{"subject":"user","session":"valid-session"}"#; assert_eq!( request( port, "POST", login, "fixture.test", "Content-Type: application/json\r\n", credentials ) .0, 403 ); let (status, headers, _) = request_head(port, "POST", login, "fixture.test", origin, credentials); assert_eq!(status, 200); let set_cookie = headers .lines() .find_map(|line| line.strip_prefix("set-cookie: ")) .unwrap(); assert!(set_cookie.contains("; Secure; HttpOnly; SameSite=Lax; Max-Age=60")); let issued = format!("Cookie: {}\r\n", set_cookie.split(';').next().unwrap()); assert_eq!( request(port, "GET", path, "fixture.test", &issued, "").0, 200 ); let (status, headers, _) = request_head( port, "POST", "/demo/account/admin/session/login_fail", "fixture.test", origin, credentials, ); assert_eq!(status, 409); assert!(!headers.to_ascii_lowercase().contains("set-cookie")); let (status, headers, _) = request_head( port, "POST", "/demo/account/admin/session/logout", "fixture.test", origin, "{}", ); assert_eq!(status, 200); assert!(headers.contains("session=; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=0")); assert_eq!(request(port, "GET", path, "fixture.test", "", "").0, 401); assert_eq!( request( port, "GET", &format!("{path}?extra=1"), "fixture.test", "", "" ) .0, 401 ); assert_eq!( request( port, "GET", health, "fixture.test", "Cookie: session=expired\r\n", "" ) .0, 200 ); assert_eq!( request( port, "GET", health, "fixture.test", "Authorization: Bearer invalid\r\n", "" ) .0, 401 ); let bearer = format!( "Authorization: Bearer {}\r\n", token("user", "valid-session") ); let cookie = format!( "Cookie: session=v1.{}\r\n", URL_SAFE_NO_PAD.encode(token("user", "valid-session")) ); for credentials in [&bearer, &cookie] { let (status, body) = request(port, "GET", path, "fixture.test", credentials, ""); assert_eq!(status, 200, "{body}"); assert_eq!( serde_json::from_str::(&body).unwrap()["data"], serde_json::json!({"id":"user", "session":"valid-session", "site":"admin", "user_id":1, "tenant_id":null}) ); } assert_eq!( request( port, "GET", &format!("{path}?extra=1"), "fixture.test", &bearer, "" ) .0, 400 ); assert_eq!( request( port, "GET", path, "fixture.test", &format!("{bearer}{cookie}"), "" ) .0, 401 ); assert_eq!( request( port, "GET", path, "fixture.test", &format!( "Authorization: Bearer {}\r\n", token("user", "revoked-session") ), "" ) .0, 401 ); assert_eq!( request( port, "POST", path, "fixture.test", &format!("{cookie}Content-Type: application/json\r\n"), "{}" ) .0, 403 ); assert_eq!( request( port, "POST", path, "fixture.test", &format!("{cookie}Origin: https://fixture.test\r\nContent-Type: application/json\r\n"), "{}" ) .0, 200 ); let expired = execute( &binary, "demo.account.set_deadline", r#"{"id":1,"expires":0}"#, ); assert!( expired.status.success(), "{}", String::from_utf8_lossy(&expired.stderr) ); assert_eq!( request(port, "GET", path, "fixture.test", &bearer, "").0, 401 ); let renewed = execute( &binary, "demo.account.set_deadline", r#"{"id":1,"expires":9223372036854775807}"#, ); assert!( renewed.status.success(), "{}", String::from_utf8_lossy(&renewed.stderr) ); assert_eq!( request(port, "GET", path, "fixture.test", &bearer, "").0, 200 ); roles::check(&binary, port, &bearer); rest::check(&binary, port, &bearer); rest_json::check(port, &bearer); let job_count = jobs::before_revocation(&binary, port, &bearer, &settings); let revoked = execute(&binary, "demo.account.revoke", r#"{"id":1}"#); assert!(revoked.status.success()); jobs::after_revocation(&binary, &settings, job_count); assert_eq!( request(port, "GET", path, "fixture.test", &bearer, "").0, 401 ); assert_eq!( request(port, "GET", health, "fixture.test", &cookie, "").0, 200 ); assert_eq!( request(port, "GET", health, "fixture.test", &bearer, "").0, 401 ); assert!( Command::new("sh") .args([ "-c", "kill -TERM \"$1\"", "dever-test", &server.0.id().to_string() ]) .status() .unwrap() .success() ); let deadline = Instant::now() + Duration::from_secs(5); loop { if let Some(status) = server.0.try_wait().unwrap() { assert!(status.success()); break; } assert!(Instant::now() < deadline); std::thread::sleep(Duration::from_millis(10)); } let mut cross_pool = settings.clone(); cross_pool["database"]["demo"] = serde_json::json!({"type":"sqlite","path":"data/other.db","max_connections":4}); std::fs::write( directory.0.join("config/setting.json"), cross_pool.to_string(), ) .unwrap(); let rejected = Command::new(&binary).output().unwrap(); assert!(!rejected.status.success()); assert!( String::from_utf8_lossy(&rejected.stderr) .contains("transaction cannot span configured database pools"), "{}", String::from_utf8_lossy(&rejected.stderr) ); assert!(TcpStream::connect(("127.0.0.1", port)).is_err()); let mut invalid = settings; invalid["auth"]["providers"]["session"]["verify"] = "demo.account.unknown".into(); std::fs::write(directory.0.join("config/setting.json"), invalid.to_string()).unwrap(); let invalid = Command::new(&binary).output().unwrap(); assert!(!invalid.status.success()); assert!(String::from_utf8_lossy(&invalid.stderr).contains("uncompiled verify")); }