use std::future::Future;
use sqlx::SqlitePool;
use super::Error;
pub type Result<T> = std::result::Result<T, Error>;
#[derive(Debug, Clone, PartialEq)]
pub struct ForwardZone {
pub id: i64,
pub zone_suffix: String,
pub target: Option<String>,
pub enabled: bool,
pub sort_order: i64,
}
#[derive(Debug, Clone)]
pub struct NewForwardZone {
pub zone_suffix: String,
pub target: Option<String>,
pub enabled: bool,
pub sort_order: i64,
}
struct ForwardZoneRow {
id: i64,
zone_suffix: String,
target: Option<String>,
enabled: bool,
sort_order: i64,
}
impl From<ForwardZoneRow> for ForwardZone {
fn from(row: ForwardZoneRow) -> Self {
ForwardZone {
id: row.id,
zone_suffix: row.zone_suffix,
target: row.target,
enabled: row.enabled,
sort_order: row.sort_order,
}
}
}
pub trait ForwardZoneRepository {
fn list(&self) -> impl Future<Output = Result<Vec<ForwardZone>>>;
fn list_enabled(&self) -> impl Future<Output = Result<Vec<ForwardZone>>>;
fn set_target(&self, id: i64, target: Option<&str>) -> impl Future<Output = Result<()>>;
fn set_enabled(&self, id: i64, enabled: bool) -> impl Future<Output = Result<()>>;
fn insert(&self, zone: NewForwardZone) -> impl Future<Output = Result<ForwardZone>>;
fn delete(&self, id: i64) -> impl Future<Output = Result<()>>;
}
pub struct SqliteForwardZoneRepo {
pool: SqlitePool,
}
impl SqliteForwardZoneRepo {
pub fn new(pool: SqlitePool) -> Self {
Self { pool }
}
}
impl ForwardZoneRepository for SqliteForwardZoneRepo {
async fn list(&self) -> Result<Vec<ForwardZone>> {
let rows = sqlx::query_as!(
ForwardZoneRow,
r#"SELECT
id AS "id!",
zone_suffix,
target,
enabled AS "enabled!: bool",
sort_order AS "sort_order!"
FROM forward_zones
ORDER BY sort_order"#
)
.fetch_all(&self.pool)
.await?;
Ok(rows.into_iter().map(ForwardZone::from).collect())
}
async fn list_enabled(&self) -> Result<Vec<ForwardZone>> {
let rows = sqlx::query_as!(
ForwardZoneRow,
r#"SELECT
id AS "id!",
zone_suffix,
target,
enabled AS "enabled!: bool",
sort_order AS "sort_order!"
FROM forward_zones
WHERE enabled = 1 AND target IS NOT NULL
ORDER BY sort_order"#
)
.fetch_all(&self.pool)
.await?;
Ok(rows.into_iter().map(ForwardZone::from).collect())
}
async fn set_target(&self, id: i64, target: Option<&str>) -> Result<()> {
sqlx::query!(
"UPDATE forward_zones SET target = ? WHERE id = ?",
target,
id,
)
.execute(&self.pool)
.await?;
Ok(())
}
async fn set_enabled(&self, id: i64, enabled: bool) -> Result<()> {
let enabled_int = enabled as i64;
sqlx::query!(
"UPDATE forward_zones SET enabled = ? WHERE id = ?",
enabled_int,
id,
)
.execute(&self.pool)
.await?;
Ok(())
}
async fn insert(&self, zone: NewForwardZone) -> Result<ForwardZone> {
let enabled = zone.enabled as i64;
let id = sqlx::query!(
r#"INSERT INTO forward_zones (zone_suffix, target, enabled, sort_order)
VALUES (?, ?, ?, ?)
RETURNING id"#,
zone.zone_suffix,
zone.target,
enabled,
zone.sort_order,
)
.fetch_one(&self.pool)
.await?
.id;
Ok(ForwardZone {
id,
zone_suffix: zone.zone_suffix,
target: zone.target,
enabled: zone.enabled,
sort_order: zone.sort_order,
})
}
async fn delete(&self, id: i64) -> Result<()> {
sqlx::query!("DELETE FROM forward_zones WHERE id = ?", id)
.execute(&self.pool)
.await?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::storage::Db;
use tempfile::TempDir;
async fn open_repo() -> (TempDir, SqliteForwardZoneRepo) {
let dir = TempDir::new().expect("temp dir");
let path = dir.path().join("test.db");
let db = Db::connect(&path).await.expect("connect");
let repo = SqliteForwardZoneRepo::new(db.pool().clone());
(dir, repo)
}
#[tokio::test]
async fn seed_zones_present_disabled_and_untargeted() {
let (_dir, repo) = open_repo().await;
let zones = repo.list().await.expect("list");
assert_eq!(zones.len(), 20, "all private reverse zones must be seeded");
for z in &zones {
assert!(!z.enabled, "{} must be seeded disabled", z.zone_suffix);
assert!(
z.target.is_none(),
"{} must be seeded with a NULL target",
z.zone_suffix
);
}
for suffix in [
"10.in-addr.arpa",
"168.192.in-addr.arpa",
"16.172.in-addr.arpa",
"31.172.in-addr.arpa",
"c.f.ip6.arpa",
"d.f.ip6.arpa",
] {
assert!(
zones.iter().any(|z| z.zone_suffix == suffix),
"seed must contain {suffix}"
);
}
}
#[tokio::test]
async fn list_is_ordered_by_sort_order() {
let (_dir, repo) = open_repo().await;
let zones = repo.list().await.expect("list");
let mut prev = i64::MIN;
for z in &zones {
assert!(z.sort_order >= prev, "zones must be returned in sort order");
prev = z.sort_order;
}
}
#[tokio::test]
async fn set_target_and_enabled_round_trip() {
let (_dir, repo) = open_repo().await;
let zones = repo.list().await.expect("list");
let id = zones
.iter()
.find(|z| z.zone_suffix == "168.192.in-addr.arpa")
.expect("seed zone")
.id;
repo.set_target(id, Some("192.168.1.1"))
.await
.expect("set target");
repo.set_enabled(id, true).await.expect("enable");
let after = repo.list().await.expect("list after");
let zone = after.iter().find(|z| z.id == id).expect("zone present");
assert_eq!(zone.target.as_deref(), Some("192.168.1.1"));
assert!(zone.enabled);
repo.set_target(id, None).await.expect("clear target");
let after = repo.list().await.expect("list after clear");
let zone = after.iter().find(|z| z.id == id).expect("zone present");
assert!(zone.target.is_none(), "target must be cleared to NULL");
}
#[tokio::test]
async fn list_enabled_filters_disabled_and_untargeted() {
let (_dir, repo) = open_repo().await;
assert!(
repo.list_enabled().await.expect("list_enabled").is_empty(),
"no zone is actionable before configuration"
);
let zones = repo.list().await.expect("list");
let id = zones[0].id;
repo.set_enabled(id, true).await.expect("enable");
assert!(
repo.list_enabled().await.expect("list_enabled").is_empty(),
"enabled-but-untargeted zone must be excluded"
);
repo.set_target(id, Some("10.0.0.1"))
.await
.expect("set target");
let enabled = repo.list_enabled().await.expect("list_enabled");
assert_eq!(enabled.len(), 1);
assert_eq!(enabled[0].id, id);
assert_eq!(enabled[0].target.as_deref(), Some("10.0.0.1"));
repo.set_enabled(id, false).await.expect("disable");
assert!(
repo.list_enabled().await.expect("list_enabled").is_empty(),
"disabled zone must be excluded even with a target"
);
}
#[tokio::test]
async fn insert_and_delete_custom_zone() {
let (_dir, repo) = open_repo().await;
let new = NewForwardZone {
zone_suffix: "corp.internal".to_owned(),
target: Some("10.0.0.53".to_owned()),
enabled: true,
sort_order: 100,
};
let inserted = repo.insert(new).await.expect("insert");
assert!(inserted.id > 0);
assert_eq!(inserted.zone_suffix, "corp.internal");
let enabled = repo.list_enabled().await.expect("list_enabled");
assert!(enabled.iter().any(|z| z.id == inserted.id));
repo.delete(inserted.id).await.expect("delete");
let after = repo.list().await.expect("list after delete");
assert!(
!after.iter().any(|z| z.id == inserted.id),
"deleted zone must be gone"
);
}
#[tokio::test]
async fn duplicate_zone_suffix_is_rejected() {
let (_dir, repo) = open_repo().await;
let new = NewForwardZone {
zone_suffix: "10.in-addr.arpa".to_owned(), target: None,
enabled: false,
sort_order: 0,
};
assert!(
repo.insert(new).await.is_err(),
"duplicate zone_suffix must fail the UNIQUE constraint"
);
}
}