feat(org): enforce tenant isolation in ws/kc with leave and stats APIs
Add org_id schema migrations and service-level filtering for teacher-student and class workflows, then cover org-context behavior with focused unit/e2e tests for leave and hour statistics. Made-with: Cursor
This commit is contained in:
@@ -0,0 +1,223 @@
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode};
|
||||
use axum::Router;
|
||||
use dotenv::dotenv;
|
||||
use htycommons::common::current_local_datetime;
|
||||
use htycommons::jwt::jwt_encode_token;
|
||||
use htycommons::redis_util::{get_token_expiration_days, save_token_with_exp_days};
|
||||
use htycommons::web::HtyToken;
|
||||
use http_body_util::BodyExt;
|
||||
use serde_json::{json, Value};
|
||||
use tower::util::ServiceExt;
|
||||
|
||||
fn setup() -> Router {
|
||||
dotenv().ok();
|
||||
std::env::set_var("POOL_SIZE", "1");
|
||||
let db_url = std::env::var("KC_DB_URL")
|
||||
.unwrap_or_else(|_| "postgres://htykc:htykc@localhost:5432/htykc_test".to_string());
|
||||
htykc::clazz_router(&db_url)
|
||||
}
|
||||
|
||||
fn build_test_token(with_org_context: bool) -> String {
|
||||
std::env::set_var("JWT_KEY", "htykc-e2e-test-jwt-key");
|
||||
let token = HtyToken {
|
||||
token_id: "test-token-id".to_string(),
|
||||
hty_id: Some("teacher-1".to_string()),
|
||||
app_id: Some("app-1".to_string()),
|
||||
ts: current_local_datetime(),
|
||||
roles: None,
|
||||
tags: None,
|
||||
current_org_id: if with_org_context {
|
||||
Some("org-1".to_string())
|
||||
} else {
|
||||
None
|
||||
},
|
||||
current_org_role_keys: Some(vec!["TEACHER".to_string()]),
|
||||
};
|
||||
jwt_encode_token(token).expect("encode test token")
|
||||
}
|
||||
|
||||
fn prepare_verified_token(with_org_context: bool) -> Option<String> {
|
||||
let token = build_test_token(with_org_context);
|
||||
let decoded = htycommons::jwt::jwt_decode_token(&token.clone()).ok()?;
|
||||
let expiration_days = get_token_expiration_days().ok()?;
|
||||
if save_token_with_exp_days(&decoded, expiration_days).is_err() {
|
||||
return None;
|
||||
}
|
||||
Some(token)
|
||||
}
|
||||
|
||||
async fn get_with_headers(router: &Router, uri: &str, headers: Vec<(&str, &str)>) -> (StatusCode, Value) {
|
||||
let mut request = Request::builder().method("GET").uri(uri);
|
||||
for (key, value) in headers {
|
||||
request = request.header(key, value);
|
||||
}
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(request.body(Body::empty()).expect("build request"))
|
||||
.await
|
||||
.expect("send request");
|
||||
let status = response.status();
|
||||
let body_bytes = response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("collect body")
|
||||
.to_bytes();
|
||||
let json_body: Value = serde_json::from_slice(&body_bytes).unwrap_or(Value::Null);
|
||||
(status, json_body)
|
||||
}
|
||||
|
||||
async fn post_with_headers(
|
||||
router: &Router,
|
||||
uri: &str,
|
||||
body: Value,
|
||||
headers: Vec<(&str, &str)>,
|
||||
) -> (StatusCode, Value) {
|
||||
let mut request = Request::builder()
|
||||
.method("POST")
|
||||
.uri(uri)
|
||||
.header("Content-Type", "application/json");
|
||||
for (key, value) in headers {
|
||||
request = request.header(key, value);
|
||||
}
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
request
|
||||
.body(Body::from(body.to_string()))
|
||||
.expect("build request"),
|
||||
)
|
||||
.await
|
||||
.expect("send request");
|
||||
let status = response.status();
|
||||
let body_bytes = response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("collect body")
|
||||
.to_bytes();
|
||||
let json_body: Value = serde_json::from_slice(&body_bytes).unwrap_or(Value::Null);
|
||||
(status, json_body)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_teacher_hour_stats_requires_current_org_id() {
|
||||
let router = match std::panic::catch_unwind(setup) {
|
||||
Ok(ok) => ok,
|
||||
Err(_) => {
|
||||
println!("DB unavailable in current test env, skip stats test");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let token = match prepare_verified_token(false) {
|
||||
Some(ok) => ok,
|
||||
None => {
|
||||
println!("Redis unavailable in current test env, skip stats test");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let (status, response) = get_with_headers(
|
||||
&router,
|
||||
"/api/v1/clazz/stats/my-hours?start_date=2026-01-01%2000:00:00&end_date=2026-12-31%2023:59:59",
|
||||
vec![
|
||||
("Authorization", token.as_str()),
|
||||
("HtySudoerToken", token.as_str()),
|
||||
("HtyHost", "root"),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(status, StatusCode::OK, "stats request should return business response envelope");
|
||||
assert!(
|
||||
!response["r"].as_bool().unwrap_or(true),
|
||||
"stats should fail without current_org_id, body: {:?}",
|
||||
response
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_create_clazz_leave_requires_current_org_id() {
|
||||
let router = match std::panic::catch_unwind(setup) {
|
||||
Ok(ok) => ok,
|
||||
Err(_) => {
|
||||
println!("DB unavailable in current test env, skip leave test");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let token = match prepare_verified_token(false) {
|
||||
Some(ok) => ok,
|
||||
None => {
|
||||
println!("Redis unavailable in current test env, skip leave test");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let (status, response) = post_with_headers(
|
||||
&router,
|
||||
"/api/v1/clazz/leave/create",
|
||||
json!({
|
||||
"clazz_id": "clazz-1",
|
||||
"student_id": "student-1",
|
||||
"teacher_id": "teacher-1",
|
||||
"leave_type": "PERSONAL",
|
||||
"reason": "test leave request"
|
||||
}),
|
||||
vec![
|
||||
("Authorization", token.as_str()),
|
||||
("HtySudoerToken", token.as_str()),
|
||||
("HtyHost", "root"),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(status, StatusCode::OK, "leave request should return business response envelope");
|
||||
assert!(
|
||||
!response["r"].as_bool().unwrap_or(true),
|
||||
"create leave should fail without current_org_id, body: {:?}",
|
||||
response
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_teacher_hour_stats_with_current_org_id_should_not_fail_on_org_context_check() {
|
||||
let router = match std::panic::catch_unwind(setup) {
|
||||
Ok(ok) => ok,
|
||||
Err(_) => {
|
||||
println!("DB unavailable in current test env, skip stats positive test");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let token = match prepare_verified_token(true) {
|
||||
Some(ok) => ok,
|
||||
None => {
|
||||
println!("Redis unavailable in current test env, skip stats positive test");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let (status, response) = get_with_headers(
|
||||
&router,
|
||||
"/api/v1/clazz/stats/my-hours?start_date=2026-01-01%2000:00:00&end_date=2026-12-31%2023:59:59",
|
||||
vec![
|
||||
("Authorization", token.as_str()),
|
||||
("HtySudoerToken", token.as_str()),
|
||||
("HtyHost", "root"),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(status, StatusCode::OK, "stats request should return business response envelope");
|
||||
|
||||
let error_text = response
|
||||
.get("e")
|
||||
.and_then(|error_node| error_node.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
assert!(
|
||||
!error_text.contains("current_org_id is required"),
|
||||
"stats with org context should not hit org-context-missing error, body: {:?}",
|
||||
response
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user