Includes all pending changes needed for nixos-anywhere: - fiberlb: L7 policy, rule, certificate types - deployer: New service for cluster management - nix-nos: Generic network modules - Various service updates and fixes 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
544 lines
17 KiB
Rust
544 lines
17 KiB
Rust
//! Integration tests for FlashDNS
|
|
//!
|
|
//! Run with: cargo test -p flashdns-server --test integration -- --ignored
|
|
|
|
use std::sync::Arc;
|
|
|
|
use flashdns_api::proto::{ListRecordsRequest, ListRecordsResponse, ListZonesRequest, ListZonesResponse};
|
|
use flashdns_api::{RecordService, ZoneService};
|
|
use flashdns_server::metadata::DnsMetadataStore;
|
|
use flashdns_server::record_service::RecordServiceImpl;
|
|
use flashdns_server::zone_service::ZoneServiceImpl;
|
|
use flashdns_types::{Record, RecordData, RecordType, Ttl, Zone, ZoneName};
|
|
use tonic::{Request, Response};
|
|
|
|
/// Test zone and record lifecycle via DnsMetadataStore
|
|
#[tokio::test]
|
|
#[ignore = "Integration test"]
|
|
async fn test_zone_and_record_lifecycle() {
|
|
let metadata = Arc::new(DnsMetadataStore::new_in_memory());
|
|
|
|
// 1. Create zone
|
|
let zone_name = ZoneName::new("example.com").unwrap();
|
|
let zone = Zone::new(zone_name, "test-org", "test-project");
|
|
metadata.save_zone(&zone).await.unwrap();
|
|
|
|
// 2. Verify zone was created
|
|
let loaded_zone = metadata
|
|
.load_zone("test-org", "test-project", "example.com.")
|
|
.await
|
|
.unwrap();
|
|
assert!(loaded_zone.is_some());
|
|
assert_eq!(loaded_zone.unwrap().id, zone.id);
|
|
|
|
// 3. Add A record
|
|
let record_data = RecordData::a_from_str("192.168.1.100").unwrap();
|
|
let mut record = Record::new(zone.id, "www", record_data);
|
|
record.ttl = Ttl::new(300).unwrap();
|
|
metadata.save_record(&record).await.unwrap();
|
|
|
|
// 4. Verify record via metadata
|
|
let loaded = metadata
|
|
.load_record(&zone.id, "www", RecordType::A)
|
|
.await
|
|
.unwrap();
|
|
assert!(loaded.is_some());
|
|
let loaded_record = loaded.unwrap();
|
|
assert_eq!(loaded_record.id, record.id);
|
|
assert_eq!(loaded_record.ttl.as_secs(), 300);
|
|
|
|
// 5. List records
|
|
let records = metadata.list_records(&zone.id).await.unwrap();
|
|
assert_eq!(records.len(), 1);
|
|
|
|
// 6. Add more records
|
|
let ipv6: std::net::Ipv6Addr = "2001:db8::1".parse().unwrap();
|
|
let aaaa_data = RecordData::Aaaa { address: ipv6.octets() };
|
|
let aaaa_record = Record::new(zone.id, "www", aaaa_data);
|
|
metadata.save_record(&aaaa_record).await.unwrap();
|
|
|
|
let mx_data = RecordData::Mx {
|
|
preference: 10,
|
|
exchange: "mail.example.com.".to_string(),
|
|
};
|
|
let mx_record = Record::new(zone.id, "@", mx_data);
|
|
metadata.save_record(&mx_record).await.unwrap();
|
|
|
|
let txt_data = RecordData::Txt {
|
|
text: "v=spf1 include:_spf.example.com ~all".to_string(),
|
|
};
|
|
let txt_record = Record::new(zone.id, "@", txt_data);
|
|
metadata.save_record(&txt_record).await.unwrap();
|
|
|
|
// 7. List all records - should have 4
|
|
let all_records = metadata.list_records(&zone.id).await.unwrap();
|
|
assert_eq!(all_records.len(), 4);
|
|
|
|
// 8. List records by name
|
|
let www_records = metadata.list_records_by_name(&zone.id, "www").await.unwrap();
|
|
assert_eq!(www_records.len(), 2); // A + AAAA
|
|
|
|
let root_records = metadata.list_records_by_name(&zone.id, "@").await.unwrap();
|
|
assert_eq!(root_records.len(), 2); // MX + TXT
|
|
|
|
// 9. Cleanup - delete records
|
|
metadata.delete_record(&record).await.unwrap();
|
|
metadata.delete_record(&aaaa_record).await.unwrap();
|
|
metadata.delete_record(&mx_record).await.unwrap();
|
|
metadata.delete_record(&txt_record).await.unwrap();
|
|
|
|
// 10. Verify records deleted
|
|
let remaining = metadata.list_records(&zone.id).await.unwrap();
|
|
assert_eq!(remaining.len(), 0);
|
|
|
|
// 11. Delete zone
|
|
metadata.delete_zone(&zone).await.unwrap();
|
|
|
|
// 12. Verify zone deleted
|
|
let deleted_zone = metadata
|
|
.load_zone("test-org", "test-project", "example.com.")
|
|
.await
|
|
.unwrap();
|
|
assert!(deleted_zone.is_none());
|
|
}
|
|
|
|
/// Test multi-zone scenario
|
|
#[tokio::test]
|
|
#[ignore = "Integration test"]
|
|
async fn test_multi_zone_scenario() {
|
|
let metadata = Arc::new(DnsMetadataStore::new_in_memory());
|
|
|
|
// Create multiple zones
|
|
let zone1 = Zone::new(
|
|
ZoneName::new("example.com").unwrap(),
|
|
"org1",
|
|
"project1",
|
|
);
|
|
let zone2 = Zone::new(
|
|
ZoneName::new("example.org").unwrap(),
|
|
"org1",
|
|
"project1",
|
|
);
|
|
let zone3 = Zone::new(
|
|
ZoneName::new("other.net").unwrap(),
|
|
"org2",
|
|
"project2",
|
|
);
|
|
|
|
metadata.save_zone(&zone1).await.unwrap();
|
|
metadata.save_zone(&zone2).await.unwrap();
|
|
metadata.save_zone(&zone3).await.unwrap();
|
|
|
|
// Add records to each zone
|
|
let a1 = Record::new(
|
|
zone1.id,
|
|
"www",
|
|
RecordData::a_from_str("10.0.0.1").unwrap(),
|
|
);
|
|
let a2 = Record::new(
|
|
zone2.id,
|
|
"www",
|
|
RecordData::a_from_str("10.0.0.2").unwrap(),
|
|
);
|
|
let a3 = Record::new(
|
|
zone3.id,
|
|
"www",
|
|
RecordData::a_from_str("10.0.0.3").unwrap(),
|
|
);
|
|
|
|
metadata.save_record(&a1).await.unwrap();
|
|
metadata.save_record(&a2).await.unwrap();
|
|
metadata.save_record(&a3).await.unwrap();
|
|
|
|
// List zones for org1 - should have 2
|
|
let org1_zones = metadata.list_zones("org1", None).await.unwrap();
|
|
assert_eq!(org1_zones.len(), 2);
|
|
|
|
// List zones for org1/project1 - should have 2
|
|
let org1_p1_zones = metadata.list_zones("org1", Some("project1")).await.unwrap();
|
|
assert_eq!(org1_p1_zones.len(), 2);
|
|
|
|
// List zones for org2 - should have 1
|
|
let org2_zones = metadata.list_zones("org2", None).await.unwrap();
|
|
assert_eq!(org2_zones.len(), 1);
|
|
|
|
// Load zone by ID
|
|
let loaded = metadata.load_zone_by_id(&zone1.id).await.unwrap();
|
|
assert!(loaded.is_some());
|
|
assert_eq!(loaded.unwrap().name.as_str(), "example.com.");
|
|
|
|
// Cleanup
|
|
metadata.delete_zone_records(&zone1.id).await.unwrap();
|
|
metadata.delete_zone_records(&zone2.id).await.unwrap();
|
|
metadata.delete_zone_records(&zone3.id).await.unwrap();
|
|
metadata.delete_zone(&zone1).await.unwrap();
|
|
metadata.delete_zone(&zone2).await.unwrap();
|
|
metadata.delete_zone(&zone3).await.unwrap();
|
|
}
|
|
|
|
/// Test record type coverage
|
|
#[tokio::test]
|
|
#[ignore = "Integration test"]
|
|
async fn test_record_type_coverage() {
|
|
let metadata = Arc::new(DnsMetadataStore::new_in_memory());
|
|
|
|
let zone = Zone::new(
|
|
ZoneName::new("types.test").unwrap(),
|
|
"test-org",
|
|
"test-project",
|
|
);
|
|
metadata.save_zone(&zone).await.unwrap();
|
|
|
|
// A record
|
|
let a = Record::new(
|
|
zone.id,
|
|
"a",
|
|
RecordData::a_from_str("192.168.1.1").unwrap(),
|
|
);
|
|
metadata.save_record(&a).await.unwrap();
|
|
|
|
// AAAA record
|
|
let ipv6: std::net::Ipv6Addr = "2001:db8::1".parse().unwrap();
|
|
let aaaa = Record::new(
|
|
zone.id,
|
|
"aaaa",
|
|
RecordData::Aaaa { address: ipv6.octets() },
|
|
);
|
|
metadata.save_record(&aaaa).await.unwrap();
|
|
|
|
// CNAME record
|
|
let cname = Record::new(
|
|
zone.id,
|
|
"cname",
|
|
RecordData::Cname {
|
|
target: "target.types.test.".to_string(),
|
|
},
|
|
);
|
|
metadata.save_record(&cname).await.unwrap();
|
|
|
|
// MX record
|
|
let mx = Record::new(
|
|
zone.id,
|
|
"mx",
|
|
RecordData::Mx {
|
|
preference: 10,
|
|
exchange: "mail.types.test.".to_string(),
|
|
},
|
|
);
|
|
metadata.save_record(&mx).await.unwrap();
|
|
|
|
// TXT record
|
|
let txt = Record::new(
|
|
zone.id,
|
|
"txt",
|
|
RecordData::Txt {
|
|
text: "test value".to_string(),
|
|
},
|
|
);
|
|
metadata.save_record(&txt).await.unwrap();
|
|
|
|
// NS record
|
|
let ns = Record::new(
|
|
zone.id,
|
|
"ns",
|
|
RecordData::Ns {
|
|
nameserver: "ns1.types.test.".to_string(),
|
|
},
|
|
);
|
|
metadata.save_record(&ns).await.unwrap();
|
|
|
|
// SRV record
|
|
let srv = Record::new(
|
|
zone.id,
|
|
"_sip._tcp",
|
|
RecordData::Srv {
|
|
priority: 10,
|
|
weight: 20,
|
|
port: 5060,
|
|
target: "sip.types.test.".to_string(),
|
|
},
|
|
);
|
|
metadata.save_record(&srv).await.unwrap();
|
|
|
|
// PTR record
|
|
let ptr = Record::new(
|
|
zone.id,
|
|
"1.1.168.192.in-addr.arpa",
|
|
RecordData::Ptr {
|
|
target: "host.types.test.".to_string(),
|
|
},
|
|
);
|
|
metadata.save_record(&ptr).await.unwrap();
|
|
|
|
// CAA record
|
|
let caa = Record::new(
|
|
zone.id,
|
|
"caa",
|
|
RecordData::Caa {
|
|
flags: 0,
|
|
tag: "issue".to_string(),
|
|
value: "letsencrypt.org".to_string(),
|
|
},
|
|
);
|
|
metadata.save_record(&caa).await.unwrap();
|
|
|
|
// Verify all records
|
|
let records = metadata.list_records(&zone.id).await.unwrap();
|
|
assert_eq!(records.len(), 9);
|
|
|
|
// Cleanup
|
|
metadata.delete_zone_records(&zone.id).await.unwrap();
|
|
metadata.delete_zone(&zone).await.unwrap();
|
|
}
|
|
|
|
/// Manual test documentation for DNS query resolution
|
|
///
|
|
/// To test DNS query resolution manually:
|
|
///
|
|
/// 1. Start the server:
|
|
/// ```
|
|
/// cargo run -p flashdns-server
|
|
/// ```
|
|
///
|
|
/// 2. Create a zone via gRPC (using grpcurl):
|
|
/// ```
|
|
/// grpcurl -plaintext -d '{"name":"example.com","org_id":"test","project_id":"test"}' \
|
|
/// localhost:9053 flashdns.ZoneService/CreateZone
|
|
/// ```
|
|
///
|
|
/// 3. Add an A record:
|
|
/// ```
|
|
/// grpcurl -plaintext -d '{"zone_id":"<zone_id>","name":"www","record_type":"A","ttl":300,"data":{"a":{"address":"192.168.1.100"}}}' \
|
|
/// localhost:9053 flashdns.RecordService/CreateRecord
|
|
/// ```
|
|
///
|
|
/// 4. Query via DNS:
|
|
/// ```
|
|
/// dig @127.0.0.1 -p 5353 www.example.com A
|
|
/// ```
|
|
///
|
|
/// Expected: Answer section should contain www.example.com with 192.168.1.100
|
|
#[tokio::test]
|
|
#[ignore = "Integration test - requires DNS handler and manual verification"]
|
|
async fn test_dns_query_resolution_docs() {
|
|
// This test documents manual testing procedure
|
|
// Actual automated DNS query testing would require:
|
|
// 1. Starting DnsHandler on a test port
|
|
// 2. Using a DNS client library to send queries
|
|
// 3. Verifying responses
|
|
|
|
// For CI, we verify the components individually:
|
|
// - DnsMetadataStore (tested above)
|
|
// - DnsQueryHandler logic (unit tested in handler.rs)
|
|
// - Wire format (handled by trust-dns-proto)
|
|
}
|
|
|
|
/// Test zone listing pagination
|
|
#[tokio::test]
|
|
#[ignore = "Integration test"]
|
|
async fn test_zone_pagination() {
|
|
let metadata = Arc::new(DnsMetadataStore::new_in_memory());
|
|
let zone_service = ZoneServiceImpl::new(metadata.clone());
|
|
|
|
// Create 15 zones
|
|
for i in 1..=15 {
|
|
let zone_name = format!("zone{:02}.example.com", i);
|
|
let zone = Zone::new(
|
|
ZoneName::new(&zone_name).unwrap(),
|
|
"test-org",
|
|
"test-project",
|
|
);
|
|
metadata.save_zone(&zone).await.unwrap();
|
|
}
|
|
|
|
// Test 1: List first page with page_size=5
|
|
let request = Request::new(ListZonesRequest {
|
|
org_id: "test-org".to_string(),
|
|
project_id: "test-project".to_string(),
|
|
name_filter: String::new(),
|
|
page_size: 5,
|
|
page_token: String::new(),
|
|
});
|
|
|
|
let response: Response<ListZonesResponse> = zone_service.list_zones(request).await.unwrap();
|
|
let page1 = response.into_inner();
|
|
|
|
assert_eq!(page1.zones.len(), 5);
|
|
assert!(!page1.next_page_token.is_empty(), "Should have next page token");
|
|
|
|
// Test 2: Fetch second page using next_page_token
|
|
let request = Request::new(ListZonesRequest {
|
|
org_id: "test-org".to_string(),
|
|
project_id: "test-project".to_string(),
|
|
name_filter: String::new(),
|
|
page_size: 5,
|
|
page_token: page1.next_page_token.clone(),
|
|
});
|
|
|
|
let response = zone_service.list_zones(request).await.unwrap();
|
|
let page2 = response.into_inner();
|
|
|
|
assert_eq!(page2.zones.len(), 5);
|
|
assert!(!page2.next_page_token.is_empty(), "Should have next page token");
|
|
|
|
// Test 3: Fetch third page
|
|
let request = Request::new(ListZonesRequest {
|
|
org_id: "test-org".to_string(),
|
|
project_id: "test-project".to_string(),
|
|
name_filter: String::new(),
|
|
page_size: 5,
|
|
page_token: page2.next_page_token.clone(),
|
|
});
|
|
|
|
let response = zone_service.list_zones(request).await.unwrap();
|
|
let page3 = response.into_inner();
|
|
|
|
assert_eq!(page3.zones.len(), 5);
|
|
assert!(page3.next_page_token.is_empty(), "Should NOT have next page token (last page)");
|
|
|
|
// Test 4: Verify zone IDs are unique across pages
|
|
let all_zone_ids: Vec<String> = page1
|
|
.zones
|
|
.iter()
|
|
.chain(page2.zones.iter())
|
|
.chain(page3.zones.iter())
|
|
.map(|z| z.id.clone())
|
|
.collect();
|
|
|
|
assert_eq!(all_zone_ids.len(), 15);
|
|
let unique_ids: std::collections::HashSet<_> = all_zone_ids.iter().collect();
|
|
assert_eq!(unique_ids.len(), 15, "All zone IDs should be unique");
|
|
|
|
// Test 5: Default page size (page_size=0 should use default of 50)
|
|
let request = Request::new(ListZonesRequest {
|
|
org_id: "test-org".to_string(),
|
|
project_id: "test-project".to_string(),
|
|
name_filter: String::new(),
|
|
page_size: 0,
|
|
page_token: String::new(),
|
|
});
|
|
|
|
let response = zone_service.list_zones(request).await.unwrap();
|
|
let default_page = response.into_inner();
|
|
|
|
assert_eq!(default_page.zones.len(), 15, "Should return all zones with default page size");
|
|
assert!(default_page.next_page_token.is_empty());
|
|
}
|
|
|
|
/// Test record listing pagination
|
|
#[tokio::test]
|
|
#[ignore = "Integration test"]
|
|
async fn test_record_pagination() {
|
|
let metadata = Arc::new(DnsMetadataStore::new_in_memory());
|
|
let record_service = RecordServiceImpl::new(metadata.clone());
|
|
|
|
// Create a zone
|
|
let zone = Zone::new(
|
|
ZoneName::new("example.com").unwrap(),
|
|
"test-org",
|
|
"test-project",
|
|
);
|
|
metadata.save_zone(&zone).await.unwrap();
|
|
|
|
// Create 25 A records
|
|
for i in 1..=25 {
|
|
let name = format!("host{:02}", i);
|
|
let address = format!("10.0.0.{}", i);
|
|
let record_data = RecordData::a_from_str(&address).unwrap();
|
|
let record = Record::new(zone.id, &name, record_data);
|
|
metadata.save_record(&record).await.unwrap();
|
|
}
|
|
|
|
// Test 1: List first page with page_size=10
|
|
let request = Request::new(ListRecordsRequest {
|
|
zone_id: zone.id.to_string(),
|
|
name_filter: String::new(),
|
|
type_filter: String::new(),
|
|
page_size: 10,
|
|
page_token: String::new(),
|
|
});
|
|
|
|
let response: Response<ListRecordsResponse> = record_service.list_records(request).await.unwrap();
|
|
let page1 = response.into_inner();
|
|
|
|
assert_eq!(page1.records.len(), 10);
|
|
assert!(!page1.next_page_token.is_empty(), "Should have next page token");
|
|
|
|
// Test 2: Fetch second page
|
|
let request = Request::new(ListRecordsRequest {
|
|
zone_id: zone.id.to_string(),
|
|
name_filter: String::new(),
|
|
type_filter: String::new(),
|
|
page_size: 10,
|
|
page_token: page1.next_page_token.clone(),
|
|
});
|
|
|
|
let response = record_service.list_records(request).await.unwrap();
|
|
let page2 = response.into_inner();
|
|
|
|
assert_eq!(page2.records.len(), 10);
|
|
assert!(!page2.next_page_token.is_empty(), "Should have next page token");
|
|
|
|
// Test 3: Fetch third page (partial)
|
|
let request = Request::new(ListRecordsRequest {
|
|
zone_id: zone.id.to_string(),
|
|
name_filter: String::new(),
|
|
type_filter: String::new(),
|
|
page_size: 10,
|
|
page_token: page2.next_page_token.clone(),
|
|
});
|
|
|
|
let response = record_service.list_records(request).await.unwrap();
|
|
let page3 = response.into_inner();
|
|
|
|
assert_eq!(page3.records.len(), 5, "Last page should have remaining 5 records");
|
|
assert!(page3.next_page_token.is_empty(), "Should NOT have next page token (last page)");
|
|
|
|
// Test 4: Verify all record IDs are unique
|
|
let all_record_ids: Vec<String> = page1
|
|
.records
|
|
.iter()
|
|
.chain(page2.records.iter())
|
|
.chain(page3.records.iter())
|
|
.map(|r| r.id.clone())
|
|
.collect();
|
|
|
|
assert_eq!(all_record_ids.len(), 25);
|
|
let unique_ids: std::collections::HashSet<_> = all_record_ids.iter().collect();
|
|
assert_eq!(unique_ids.len(), 25, "All record IDs should be unique");
|
|
|
|
// Test 5: Pagination with name filter
|
|
let request = Request::new(ListRecordsRequest {
|
|
zone_id: zone.id.to_string(),
|
|
name_filter: "host1".to_string(), // Matches host1, host10-19
|
|
type_filter: String::new(),
|
|
page_size: 5,
|
|
page_token: String::new(),
|
|
});
|
|
|
|
let response = record_service.list_records(request).await.unwrap();
|
|
let filtered_page1 = response.into_inner();
|
|
|
|
assert_eq!(filtered_page1.records.len(), 5);
|
|
assert!(!filtered_page1.next_page_token.is_empty());
|
|
|
|
// Continue to second page of filtered results
|
|
let request = Request::new(ListRecordsRequest {
|
|
zone_id: zone.id.to_string(),
|
|
name_filter: "host1".to_string(),
|
|
type_filter: String::new(),
|
|
page_size: 5,
|
|
page_token: filtered_page1.next_page_token.clone(),
|
|
});
|
|
|
|
let response = record_service.list_records(request).await.unwrap();
|
|
let filtered_page2 = response.into_inner();
|
|
|
|
assert!(filtered_page2.records.len() <= 6); // host1 + host10-19 = 11 total, so 5+6
|
|
assert!(filtered_page2.next_page_token.is_empty());
|
|
|
|
// Verify all filtered records contain "host1"
|
|
for record in filtered_page1.records.iter().chain(filtered_page2.records.iter()) {
|
|
assert!(record.name.contains("host1"), "Filtered record should match name filter");
|
|
}
|
|
}
|