photoncloud-monorepo/k8shost/crates/k8shost-server/tests/integration_test.rs
centra a7ec7e2158 Add T026 practical test + k8shost to flake + workspace files
- Created T026-practical-test task.yaml for MVP smoke testing
- Added k8shost-server to flake.nix (packages, apps, overlays)
- Staged all workspace directories for nix flake build
- Updated flake.nix shellHook to include k8shost

Resolves: T026.S1 blocker (R8 - nix submodule visibility)
2025-12-09 06:07:50 +09:00

523 lines
16 KiB
Rust

//! Integration tests for k8shost API Server
//!
//! These tests verify end-to-end functionality including:
//! - Pod lifecycle (create, get, list, delete)
//! - Service exposure and cluster IP allocation
//! - Multi-tenant isolation
//! - IAM authentication and authorization
use k8shost_proto::{
pod_service_client::PodServiceClient, service_service_client::ServiceServiceClient,
node_service_client::NodeServiceClient, Container, ContainerPort, CreatePodRequest,
CreateServiceRequest, DeletePodRequest, DeleteServiceRequest, GetPodRequest,
GetServiceRequest, ListPodsRequest, ListServicesRequest, ObjectMeta, Pod, PodSpec, Service,
ServicePort, ServiceSpec,
};
use std::collections::HashMap;
use tonic::metadata::MetadataValue;
use tonic::transport::Channel;
use tonic::{Request, Status};
// Type alias for intercepted service with our authentication closure
type AuthInterceptor = fn(Request<()>) -> Result<Request<()>, Status>;
/// Test configuration
struct TestConfig {
server_addr: String,
flaredb_addr: String,
iam_addr: String,
}
impl TestConfig {
fn from_env() -> Self {
Self {
server_addr: std::env::var("K8SHOST_SERVER_ADDR")
.unwrap_or_else(|_| "http://127.0.0.1:6443".to_string()),
flaredb_addr: std::env::var("FLAREDB_PD_ADDR")
.unwrap_or_else(|_| "127.0.0.1:2379".to_string()),
iam_addr: std::env::var("IAM_SERVER_ADDR")
.unwrap_or_else(|_| "http://127.0.0.1:50051".to_string()),
}
}
}
/// Helper to create an authenticated gRPC client with bearer token
async fn create_authenticated_pod_client(
token: &str,
) -> Result<PodServiceClient<tonic::service::interceptor::InterceptedService<Channel, impl FnMut(Request<()>) -> Result<Request<()>, Status> + Clone>>, Box<dyn std::error::Error>> {
let config = TestConfig::from_env();
let channel = Channel::from_shared(config.server_addr.clone())?
.connect()
.await?;
let token_value = format!("Bearer {}", token);
let token_metadata: MetadataValue<_> = token_value.parse()?;
// Create a channel-based client with interceptor
let client = PodServiceClient::with_interceptor(
channel,
move |mut req: Request<()>| -> Result<Request<()>, Status> {
req.metadata_mut()
.insert("authorization", token_metadata.clone());
Ok(req)
},
);
Ok(client)
}
/// Helper to create an authenticated service client
async fn create_authenticated_service_client(
token: &str,
) -> Result<ServiceServiceClient<tonic::service::interceptor::InterceptedService<Channel, impl FnMut(Request<()>) -> Result<Request<()>, Status> + Clone>>, Box<dyn std::error::Error>> {
let config = TestConfig::from_env();
let channel = Channel::from_shared(config.server_addr.clone())?
.connect()
.await?;
let token_value = format!("Bearer {}", token);
let token_metadata: MetadataValue<_> = token_value.parse()?;
let client = ServiceServiceClient::with_interceptor(
channel,
move |mut req: Request<()>| -> Result<Request<()>, Status> {
req.metadata_mut()
.insert("authorization", token_metadata.clone());
Ok(req)
},
);
Ok(client)
}
/// Mock token generator for testing
/// In a real setup, this would call IAM to issue tokens
fn generate_mock_token(org_id: &str, project_id: &str, principal: &str) -> String {
// For testing purposes, we'll use a simple format
// In production, this should be a proper JWT from IAM
format!("mock-token-{}-{}-{}", org_id, project_id, principal)
}
/// Helper to create a test pod spec
fn create_test_pod_spec(
name: &str,
namespace: &str,
org_id: &str,
project_id: &str,
) -> Pod {
Pod {
metadata: Some(ObjectMeta {
name: name.to_string(),
namespace: Some(namespace.to_string()),
uid: None,
resource_version: None,
creation_timestamp: None,
labels: HashMap::from([("app".to_string(), "test".to_string())]),
annotations: HashMap::new(),
org_id: Some(org_id.to_string()),
project_id: Some(project_id.to_string()),
}),
spec: Some(PodSpec {
containers: vec![Container {
name: "nginx".to_string(),
image: "nginx:latest".to_string(),
command: vec![],
args: vec![],
ports: vec![ContainerPort {
name: Some("http".to_string()),
container_port: 80,
protocol: Some("TCP".to_string()),
}],
env: vec![],
}],
restart_policy: Some("Always".to_string()),
node_name: None,
}),
status: None,
}
}
/// Helper to create a test service spec
fn create_test_service_spec(
name: &str,
namespace: &str,
org_id: &str,
project_id: &str,
) -> Service {
Service {
metadata: Some(ObjectMeta {
name: name.to_string(),
namespace: Some(namespace.to_string()),
uid: None,
resource_version: None,
creation_timestamp: None,
labels: HashMap::new(),
annotations: HashMap::new(),
org_id: Some(org_id.to_string()),
project_id: Some(project_id.to_string()),
}),
spec: Some(ServiceSpec {
ports: vec![ServicePort {
name: Some("http".to_string()),
port: 80,
target_port: Some(80),
protocol: Some("TCP".to_string()),
}],
selector: HashMap::from([("app".to_string(), "test".to_string())]),
cluster_ip: None,
r#type: Some("ClusterIP".to_string()),
}),
status: None,
}
}
/// Test 1: Pod Lifecycle
/// Create, get, list, and delete a pod
#[tokio::test]
#[ignore] // Run with --ignored flag when server is running
async fn test_pod_lifecycle() -> Result<(), Box<dyn std::error::Error>> {
// Generate test token
let token = generate_mock_token("org-test", "project-test", "user-1");
let mut client = create_authenticated_pod_client(&token).await?;
// Create a pod
let pod_name = "test-pod-lifecycle";
let namespace = "default";
let pod = create_test_pod_spec(pod_name, namespace, "org-test", "project-test");
let create_response = client
.create_pod(Request::new(CreatePodRequest { pod: Some(pod) }))
.await?;
let created_pod = create_response
.into_inner()
.pod
.expect("Created pod should be returned");
println!("Created pod: {:?}", created_pod.metadata);
// Verify pod has UID and creation timestamp
assert!(created_pod.metadata.as_ref().unwrap().uid.is_some());
assert!(created_pod
.metadata
.as_ref()
.unwrap()
.creation_timestamp
.is_some());
// Get the pod
let get_response = client
.get_pod(Request::new(GetPodRequest {
name: pod_name.to_string(),
namespace: namespace.to_string(),
}))
.await?;
let fetched_pod = get_response
.into_inner()
.pod
.expect("Fetched pod should be returned");
assert_eq!(
&fetched_pod.metadata.as_ref().unwrap().name,
pod_name
);
// List pods
let list_response = client
.list_pods(Request::new(ListPodsRequest {
namespace: Some(namespace.to_string()),
label_selector: HashMap::new(),
}))
.await?;
let pods = list_response.into_inner().items;
assert!(
pods.iter()
.any(|p| {
if let Some(meta) = &p.metadata {
return &meta.name == pod_name;
}
false
}),
"Created pod should be in the list"
);
// Delete the pod
let delete_response = client
.delete_pod(Request::new(DeletePodRequest {
name: pod_name.to_string(),
namespace: namespace.to_string(),
}))
.await?;
assert!(
delete_response.into_inner().success,
"Pod should be deleted successfully"
);
// Verify pod is deleted (get should fail)
let get_result = client
.get_pod(Request::new(GetPodRequest {
name: pod_name.to_string(),
namespace: namespace.to_string(),
}))
.await;
assert!(
get_result.is_err(),
"Get should fail after pod is deleted"
);
Ok(())
}
/// Test 2: Service Exposure
/// Create a service and verify cluster IP allocation
#[tokio::test]
#[ignore] // Run with --ignored flag when server is running
async fn test_service_exposure() -> Result<(), Box<dyn std::error::Error>> {
let token = generate_mock_token("org-test", "project-test", "user-1");
let mut client = create_authenticated_service_client(&token).await?;
// Create a service
let service_name = "test-service-exposure";
let namespace = "default";
let service = create_test_service_spec(service_name, namespace, "org-test", "project-test");
let create_response = client
.create_service(Request::new(CreateServiceRequest {
service: Some(service),
}))
.await?;
let created_service = create_response
.into_inner()
.service
.expect("Created service should be returned");
println!("Created service: {:?}", created_service.metadata);
// Verify service has cluster IP allocated
assert!(
created_service
.spec
.as_ref()
.unwrap()
.cluster_ip
.is_some(),
"Cluster IP should be allocated"
);
let cluster_ip = created_service.spec.as_ref().unwrap().cluster_ip.clone().unwrap();
println!("Allocated cluster IP: {}", cluster_ip);
// Get the service
let get_response = client
.get_service(Request::new(GetServiceRequest {
name: service_name.to_string(),
namespace: namespace.to_string(),
}))
.await?;
let fetched_service = get_response
.into_inner()
.service
.expect("Fetched service should be returned");
assert_eq!(
&fetched_service.metadata.as_ref().unwrap().name,
service_name
);
assert_eq!(
fetched_service.spec.as_ref().unwrap().cluster_ip,
Some(cluster_ip)
);
// List services
let list_response = client
.list_services(Request::new(ListServicesRequest {
namespace: Some(namespace.to_string()),
}))
.await?;
let services = list_response.into_inner().items;
assert!(
services
.iter()
.any(|s| {
if let Some(meta) = &s.metadata {
return &meta.name == service_name;
}
false
}),
"Created service should be in the list"
);
// Delete the service
let delete_response = client
.delete_service(Request::new(DeleteServiceRequest {
name: service_name.to_string(),
namespace: namespace.to_string(),
}))
.await?;
assert!(
delete_response.into_inner().success,
"Service should be deleted successfully"
);
Ok(())
}
/// Test 3: Multi-Tenant Isolation
/// Verify that resources from one tenant cannot be accessed by another
#[tokio::test]
#[ignore] // Run with --ignored flag when server is running
async fn test_multi_tenant_isolation() -> Result<(), Box<dyn std::error::Error>> {
// Create pod in org-A with token-A
let token_a = generate_mock_token("org-a", "project-a", "user-a");
let mut client_a = create_authenticated_pod_client(&token_a).await?;
let pod_name = "test-isolation-pod";
let namespace = "default";
let pod = create_test_pod_spec(pod_name, namespace, "org-a", "project-a");
let create_response = client_a
.create_pod(Request::new(CreatePodRequest { pod: Some(pod) }))
.await?;
println!(
"Created pod in org-a: {:?}",
create_response.into_inner().pod
);
// Try to get the pod with token-B (different org)
let token_b = generate_mock_token("org-b", "project-b", "user-b");
let mut client_b = create_authenticated_pod_client(&token_b).await?;
let get_result = client_b
.get_pod(Request::new(GetPodRequest {
name: pod_name.to_string(),
namespace: namespace.to_string(),
}))
.await;
// Should return NotFound because the pod belongs to org-a, not org-b
assert!(
get_result.is_err(),
"Token from org-b should not be able to access pod from org-a"
);
if let Err(status) = get_result {
assert_eq!(
status.code(),
tonic::Code::NotFound,
"Should return NotFound status"
);
}
// List pods with token-B should return empty list
let list_response = client_b
.list_pods(Request::new(ListPodsRequest {
namespace: Some(namespace.to_string()),
label_selector: HashMap::new(),
}))
.await?;
let pods = list_response.into_inner().items;
assert!(
!pods
.iter()
.any(|p| {
if let Some(meta) = &p.metadata {
return &meta.name == pod_name;
}
false
}),
"Token from org-b should not see pods from org-a"
);
// Cleanup: delete pod with token-A
let delete_response = client_a
.delete_pod(Request::new(DeletePodRequest {
name: pod_name.to_string(),
namespace: namespace.to_string(),
}))
.await?;
assert!(delete_response.into_inner().success);
Ok(())
}
/// Test 4: Invalid Token Handling
/// Verify that requests without valid tokens are rejected
#[tokio::test]
#[ignore] // Run with --ignored flag when server is running
async fn test_invalid_token_handling() -> Result<(), Box<dyn std::error::Error>> {
// Try to create client with invalid token
let invalid_token = "invalid-token-xyz";
let mut client = create_authenticated_pod_client(invalid_token).await?;
let pod_name = "test-invalid-token-pod";
let namespace = "default";
let pod = create_test_pod_spec(pod_name, namespace, "org-test", "project-test");
// Attempt to create pod should fail with Unauthenticated
let create_result = client
.create_pod(Request::new(CreatePodRequest { pod: Some(pod) }))
.await;
assert!(
create_result.is_err(),
"Request with invalid token should fail"
);
if let Err(status) = create_result {
assert_eq!(
status.code(),
tonic::Code::Unauthenticated,
"Should return Unauthenticated status"
);
}
Ok(())
}
/// Test 5: Missing Authorization Header
/// Verify that requests without Authorization header are rejected
#[tokio::test]
#[ignore] // Run with --ignored flag when server is running
async fn test_missing_authorization() -> Result<(), Box<dyn std::error::Error>> {
let config = TestConfig::from_env();
let channel = Channel::from_shared(config.server_addr.clone())?
.connect()
.await?;
let mut client = PodServiceClient::new(channel);
let pod_name = "test-no-auth-pod";
let namespace = "default";
let pod = create_test_pod_spec(pod_name, namespace, "org-test", "project-test");
// Attempt to create pod without authorization header should fail
let create_result = client
.create_pod(Request::new(CreatePodRequest { pod: Some(pod) }))
.await;
assert!(
create_result.is_err(),
"Request without authorization should fail"
);
if let Err(status) = create_result {
assert_eq!(
status.code(),
tonic::Code::Unauthenticated,
"Should return Unauthenticated status"
);
}
Ok(())
}