- 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)
523 lines
16 KiB
Rust
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(())
|
|
}
|