v0.24.5
This commit is contained in:
commit
5e23e9af02
240 files changed
+99957
No files matched your search
@@ -0,0 +1,560 @@
|
||||
//! SQLite stores desired configuration, administrator state and bounded audit history.
|
||||
//! No database call is made from the public HTTP forwarding path.
|
||||
use crate::config::{Config, Envelope};
|
||||
use anyhow::{ensure, Context, Result};
|
||||
use serde::Serialize;
|
||||
use sqlx::{
|
||||
sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteSynchronous},
|
||||
Row, SqliteConnection, SqlitePool,
|
||||
};
|
||||
use std::{path::Path, time::Duration};
|
||||
|
||||
pub const SCHEMA_VERSION: i64 = 6;
|
||||
pub const CERTIFICATE_API_CREDENTIAL_CAPACITY: i64 = 32;
|
||||
pub const AUDIT_CAPACITY: i64 = 10_000;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Store {
|
||||
pub(crate) pool: SqlitePool,
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct CertificateApiCredential {
|
||||
pub key_id: String,
|
||||
pub comment: String,
|
||||
pub created_at: i64,
|
||||
pub last_used_at: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct AuditEntry {
|
||||
pub id: i64,
|
||||
pub time: i64,
|
||||
pub actor: String,
|
||||
pub action: String,
|
||||
pub detail: String,
|
||||
}
|
||||
pub(crate) async fn audit_tx(
|
||||
connection: &mut SqliteConnection,
|
||||
actor: &str,
|
||||
action: &str,
|
||||
detail: &str,
|
||||
) -> Result<()> {
|
||||
// Callers only supply allowlisted actions and non-secret, bounded metadata.
|
||||
sqlx::query("INSERT INTO admin_audit(time, actor, action, detail) VALUES(?, ?, ?, ?)")
|
||||
.bind(crate::auth::now())
|
||||
.bind(actor)
|
||||
.bind(action)
|
||||
.bind(detail)
|
||||
.execute(&mut *connection)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"DELETE FROM admin_audit WHERE id <= (SELECT COALESCE(MAX(id), 0) - ? FROM admin_audit)",
|
||||
)
|
||||
.bind(AUDIT_CAPACITY)
|
||||
.execute(&mut *connection)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
impl Store {
|
||||
pub async fn open(path: &Path, initial: &Config) -> Result<Self> {
|
||||
let options = SqliteConnectOptions::new()
|
||||
.filename(path)
|
||||
.create_if_missing(true)
|
||||
.journal_mode(SqliteJournalMode::Wal)
|
||||
.synchronous(SqliteSynchronous::Full)
|
||||
.busy_timeout(Duration::from_secs(5))
|
||||
.foreign_keys(true);
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(4)
|
||||
.connect_with(options)
|
||||
.await?;
|
||||
let version: i64 = sqlx::query_scalar("PRAGMA user_version")
|
||||
.fetch_one(&pool)
|
||||
.await?;
|
||||
ensure!(
|
||||
version <= SCHEMA_VERSION,
|
||||
"database schema is newer than this binary; refusing downgrade"
|
||||
);
|
||||
let mut tx = pool.begin().await?;
|
||||
sqlx::raw_sql(include_str!("../migrations/0002_admin.sql"))
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
sqlx::raw_sql(include_str!("../migrations/0003_certificate_renewals.sql"))
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
sqlx::raw_sql(include_str!("../migrations/0004_admin_preferences.sql"))
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
sqlx::raw_sql(include_str!("../migrations/0005_certificate_api.sql"))
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
sqlx::raw_sql(include_str!("../migrations/0006_certificate_api_credentials.sql"))
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
sqlx::query("INSERT OR IGNORE INTO configuration(id, revision, document) VALUES(1, 0, ?)")
|
||||
.bind(serde_json::to_string(initial)?)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
sqlx::query("PRAGMA user_version = 6")
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
Ok(Self { pool })
|
||||
}
|
||||
pub async fn load(&self) -> Result<Envelope> {
|
||||
let row = sqlx::query("SELECT revision, document FROM configuration WHERE id = 1")
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
let revision: i64 = row.try_get("revision")?;
|
||||
let document: String = row.try_get("document")?;
|
||||
ensure!(
|
||||
document.len() <= crate::config::MAX_CONFIG_BYTES,
|
||||
"stored configuration exceeds size limit"
|
||||
);
|
||||
Ok(Envelope {
|
||||
revision: revision.try_into()?,
|
||||
config: serde_json::from_str(&document)
|
||||
.context("invalid stored configuration; database was not reset")?,
|
||||
})
|
||||
}
|
||||
pub async fn replace(&self, expected: u64, config: &Config) -> Result<bool> {
|
||||
self.replace_as(expected, config, "local").await
|
||||
}
|
||||
/// Compare-and-swap and audit are one transaction. Publication is handled by App.
|
||||
pub async fn replace_as(&self, expected: u64, config: &Config, actor: &str) -> Result<bool> {
|
||||
let expected: i64 = expected.try_into().context("revision overflow")?;
|
||||
ensure!(expected < i64::MAX, "revision overflow");
|
||||
let mut tx = self.pool.begin().await?;
|
||||
let result = sqlx::query("UPDATE configuration SET revision = revision + 1, document = ? WHERE id = 1 AND revision = ?")
|
||||
.bind(serde_json::to_string(config)?).bind(expected).execute(&mut *tx).await?;
|
||||
if result.rows_affected() == 0 {
|
||||
return Ok(false);
|
||||
}
|
||||
audit_tx(
|
||||
&mut tx,
|
||||
actor,
|
||||
"config.apply",
|
||||
&format!("revision={}", expected + 1),
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
Ok(true)
|
||||
}
|
||||
pub async fn audit(&self, actor: &str, action: &str, detail: &str) -> Result<()> {
|
||||
let mut tx = self.pool.begin().await?;
|
||||
audit_tx(&mut tx, actor, action, detail).await?;
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
pub async fn load_certificate_renewals(
|
||||
&self,
|
||||
) -> Result<std::collections::BTreeMap<String, crate::acme::RenewalState>> {
|
||||
let rows = sqlx::query(
|
||||
"SELECT certificate_id, last_checked, next_check, last_renewed, not_after, failures, status, message FROM certificate_renewals ORDER BY certificate_id",
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
let mut result = std::collections::BTreeMap::new();
|
||||
for row in rows {
|
||||
let certificate_id: String = row.try_get("certificate_id")?;
|
||||
if !crate::config::valid_id(&certificate_id) {
|
||||
continue;
|
||||
}
|
||||
let failures: i64 = row.try_get("failures")?;
|
||||
result.insert(
|
||||
certificate_id,
|
||||
crate::acme::RenewalState {
|
||||
last_checked: row.try_get("last_checked")?,
|
||||
next_check: row.try_get("next_check")?,
|
||||
last_renewed: row.try_get("last_renewed")?,
|
||||
not_after: row.try_get("not_after")?,
|
||||
failures: failures.max(0).min(u32::MAX as i64) as u32,
|
||||
status: row.try_get("status")?,
|
||||
message: row.try_get("message")?,
|
||||
},
|
||||
);
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
pub async fn replace_certificate_renewals(
|
||||
&self,
|
||||
values: &std::collections::BTreeMap<String, crate::acme::RenewalState>,
|
||||
) -> Result<()> {
|
||||
ensure!(
|
||||
values.len() <= 64,
|
||||
"too many certificate renewal observations"
|
||||
);
|
||||
let mut tx = self.pool.begin().await?;
|
||||
sqlx::query("DELETE FROM certificate_renewals")
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
for (certificate_id, state) in values {
|
||||
ensure!(
|
||||
crate::config::valid_id(certificate_id),
|
||||
"invalid certificate renewal id"
|
||||
);
|
||||
sqlx::query(
|
||||
"INSERT INTO certificate_renewals(certificate_id, last_checked, next_check, last_renewed, not_after, failures, status, message) VALUES(?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(certificate_id)
|
||||
.bind(state.last_checked)
|
||||
.bind(state.next_check)
|
||||
.bind(state.last_renewed)
|
||||
.bind(state.not_after)
|
||||
.bind(i64::from(state.failures))
|
||||
.bind(&state.status)
|
||||
.bind(&state.message)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn certificate_api_credentials(&self) -> Result<Vec<CertificateApiCredential>> {
|
||||
let rows = sqlx::query(
|
||||
"SELECT key_id, comment, created_at, last_used_at FROM certificate_api_credentials ORDER BY key_id",
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
rows.into_iter()
|
||||
.map(|row| {
|
||||
Ok(CertificateApiCredential {
|
||||
key_id: row.try_get("key_id")?,
|
||||
comment: row.try_get("comment")?,
|
||||
created_at: row.try_get("created_at")?,
|
||||
last_used_at: row.try_get("last_used_at")?,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub async fn upsert_certificate_api_credential(
|
||||
&self,
|
||||
key_id: &str,
|
||||
token_hash: &str,
|
||||
comment: &str,
|
||||
actor: &str,
|
||||
) -> Result<CertificateApiCredential> {
|
||||
ensure!(
|
||||
(3..=64).contains(&key_id.len()) && crate::config::valid_id(key_id),
|
||||
"invalid certificate API key id"
|
||||
);
|
||||
ensure!(
|
||||
token_hash.len() == 64 && token_hash.bytes().all(|b| b.is_ascii_hexdigit()),
|
||||
"invalid certificate API token hash"
|
||||
);
|
||||
ensure!(
|
||||
comment.len() <= 256 && !comment.chars().any(char::is_control),
|
||||
"invalid certificate API comment"
|
||||
);
|
||||
let now = crate::auth::now();
|
||||
let mut tx = self.pool.begin().await?;
|
||||
let exists: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM certificate_api_credentials WHERE key_id = ?",
|
||||
)
|
||||
.bind(key_id)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
if exists == 0 {
|
||||
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM certificate_api_credentials")
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
ensure!(
|
||||
count < CERTIFICATE_API_CREDENTIAL_CAPACITY,
|
||||
"certificate API credential limit reached"
|
||||
);
|
||||
}
|
||||
sqlx::query(
|
||||
"INSERT INTO certificate_api_credentials(key_id, token_hash, comment, created_at, last_used_at) VALUES(?, ?, ?, ?, NULL) \
|
||||
ON CONFLICT(key_id) DO UPDATE SET token_hash=excluded.token_hash, comment=excluded.comment, created_at=excluded.created_at, last_used_at=NULL",
|
||||
)
|
||||
.bind(key_id)
|
||||
.bind(token_hash)
|
||||
.bind(comment)
|
||||
.bind(now)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
audit_tx(
|
||||
&mut tx,
|
||||
actor,
|
||||
"certificate_api.rotate",
|
||||
&format!("key_id={key_id}"),
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
Ok(CertificateApiCredential {
|
||||
key_id: key_id.to_owned(),
|
||||
comment: comment.to_owned(),
|
||||
created_at: now,
|
||||
last_used_at: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn revoke_certificate_api_credential(&self, key_id: &str, actor: &str) -> Result<bool> {
|
||||
ensure!(
|
||||
(3..=64).contains(&key_id.len()) && crate::config::valid_id(key_id),
|
||||
"invalid certificate API key id"
|
||||
);
|
||||
let mut tx = self.pool.begin().await?;
|
||||
let result = sqlx::query("DELETE FROM certificate_api_credentials WHERE key_id = ?")
|
||||
.bind(key_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
if result.rows_affected() > 0 {
|
||||
audit_tx(
|
||||
&mut tx,
|
||||
actor,
|
||||
"certificate_api.revoke",
|
||||
&format!("key_id={key_id}"),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
tx.commit().await?;
|
||||
Ok(result.rows_affected() > 0)
|
||||
}
|
||||
|
||||
pub async fn revoke_all_certificate_api_credentials(&self, actor: &str) -> Result<u64> {
|
||||
let mut tx = self.pool.begin().await?;
|
||||
let result = sqlx::query("DELETE FROM certificate_api_credentials")
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
if result.rows_affected() > 0 {
|
||||
audit_tx(
|
||||
&mut tx,
|
||||
actor,
|
||||
"certificate_api.revoke_all",
|
||||
&format!("count={}", result.rows_affected()),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
tx.commit().await?;
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
|
||||
pub async fn verify_certificate_api_credential(&self, key_id: &str, token_hash: &str) -> Result<bool> {
|
||||
let row = sqlx::query(
|
||||
"SELECT token_hash FROM certificate_api_credentials WHERE key_id = ?",
|
||||
)
|
||||
.bind(key_id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
let Some(row) = row else { return Ok(false); };
|
||||
let expected: String = row.try_get("token_hash")?;
|
||||
if !crate::auth::equal_secret(token_hash, &expected) {
|
||||
return Ok(false);
|
||||
}
|
||||
sqlx::query(
|
||||
"UPDATE certificate_api_credentials SET last_used_at = ? WHERE key_id = ?",
|
||||
)
|
||||
.bind(crate::auth::now())
|
||||
.bind(key_id)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub async fn recent_audit(&self) -> Result<Vec<AuditEntry>> {
|
||||
let rows = sqlx::query(
|
||||
"SELECT id, time, actor, action, detail FROM admin_audit ORDER BY id DESC LIMIT 100",
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
rows.into_iter()
|
||||
.map(|row| {
|
||||
Ok(AuditEntry {
|
||||
id: row.try_get("id")?,
|
||||
time: row.try_get("time")?,
|
||||
actor: row.try_get("actor")?,
|
||||
action: row.try_get("action")?,
|
||||
detail: row.try_get("detail")?,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
pub async fn close(&self) {
|
||||
self.pool.close().await;
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[tokio::test]
|
||||
async fn persists_and_rejects_stale_writes() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file = dir.path().join("test.sqlite3");
|
||||
let store = Store::open(&file, &Config::default()).await.unwrap();
|
||||
assert_eq!(store.load().await.unwrap().revision, 0);
|
||||
let config = crate::config::demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
assert!(store.replace(0, &config).await.unwrap());
|
||||
assert!(!store.replace(0, &Config::default()).await.unwrap());
|
||||
assert_eq!(store.recent_audit().await.unwrap().len(), 1);
|
||||
store.close().await;
|
||||
let reopened = Store::open(&file, &Config::default()).await.unwrap();
|
||||
let data = reopened.load().await.unwrap();
|
||||
assert_eq!(data.revision, 1);
|
||||
assert_eq!(data.config.vhosts[0].backends[0].id, "demo");
|
||||
reopened.close().await;
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn certificate_renewal_observations_survive_reopen_in_schema_v3() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file = dir.path().join("renewals.sqlite3");
|
||||
let store = Store::open(&file, &Config::default()).await.unwrap();
|
||||
let mut values = std::collections::BTreeMap::new();
|
||||
values.insert(
|
||||
"example-cert".to_string(),
|
||||
crate::acme::RenewalState {
|
||||
last_checked: 100,
|
||||
next_check: 200,
|
||||
last_renewed: Some(50),
|
||||
not_after: Some(500),
|
||||
failures: 2,
|
||||
status: "retry".to_string(),
|
||||
message: "temporary CA failure".to_string(),
|
||||
},
|
||||
);
|
||||
store.replace_certificate_renewals(&values).await.unwrap();
|
||||
let version: i64 = sqlx::query_scalar("PRAGMA user_version")
|
||||
.fetch_one(&store.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(version, SCHEMA_VERSION);
|
||||
store.close().await;
|
||||
|
||||
let reopened = Store::open(&file, &Config::default()).await.unwrap();
|
||||
let saved = reopened.load_certificate_renewals().await.unwrap();
|
||||
let state = saved.get("example-cert").unwrap();
|
||||
assert_eq!(state.last_checked, 100);
|
||||
assert_eq!(state.next_check, 200);
|
||||
assert_eq!(state.last_renewed, Some(50));
|
||||
assert_eq!(state.not_after, Some(500));
|
||||
assert_eq!(state.failures, 2);
|
||||
assert_eq!(state.status, "retry");
|
||||
assert_eq!(state.message, "temporary CA failure");
|
||||
reopened.close().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn certificate_api_credentials_persist_hash_comments_and_individual_usage() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file = dir.path().join("certificate-api.sqlite3");
|
||||
let store = Store::open(&file, &Config::default()).await.unwrap();
|
||||
let token = "b".repeat(64);
|
||||
let hash = crate::auth::secret_hash(&token);
|
||||
let saved = store
|
||||
.upsert_certificate_api_credential(
|
||||
"certbot-main",
|
||||
&hash,
|
||||
"production certbot",
|
||||
"owner",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(saved.key_id, "certbot-main");
|
||||
assert_eq!(saved.comment, "production certbot");
|
||||
assert!(saved.last_used_at.is_none());
|
||||
|
||||
let raw: String = sqlx::query_scalar(
|
||||
"SELECT token_hash FROM certificate_api_credentials WHERE key_id = ?",
|
||||
)
|
||||
.bind("certbot-main")
|
||||
.fetch_one(&store.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(raw, hash);
|
||||
assert_ne!(raw, token);
|
||||
|
||||
let second_hash = crate::auth::secret_hash(&"d".repeat(64));
|
||||
store
|
||||
.upsert_certificate_api_credential(
|
||||
"deploy-hook",
|
||||
&second_hash,
|
||||
"secondary deploy hook",
|
||||
"owner",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(store.certificate_api_credentials().await.unwrap().len(), 2);
|
||||
|
||||
assert!(store
|
||||
.verify_certificate_api_credential("certbot-main", &hash)
|
||||
.await
|
||||
.unwrap());
|
||||
assert!(!store
|
||||
.verify_certificate_api_credential(
|
||||
"certbot-main",
|
||||
&crate::auth::secret_hash(&"c".repeat(64)),
|
||||
)
|
||||
.await
|
||||
.unwrap());
|
||||
let credentials = store.certificate_api_credentials().await.unwrap();
|
||||
assert!(credentials
|
||||
.iter()
|
||||
.find(|item| item.key_id == "certbot-main")
|
||||
.unwrap()
|
||||
.last_used_at
|
||||
.is_some());
|
||||
assert!(credentials
|
||||
.iter()
|
||||
.find(|item| item.key_id == "deploy-hook")
|
||||
.unwrap()
|
||||
.last_used_at
|
||||
.is_none());
|
||||
|
||||
assert!(store
|
||||
.revoke_certificate_api_credential("certbot-main", "owner")
|
||||
.await
|
||||
.unwrap());
|
||||
let remaining = store.certificate_api_credentials().await.unwrap();
|
||||
assert_eq!(remaining.len(), 1);
|
||||
assert_eq!(remaining[0].key_id, "deploy-hook");
|
||||
assert_eq!(store.revoke_all_certificate_api_credentials("owner").await.unwrap(), 1);
|
||||
assert!(store.certificate_api_credentials().await.unwrap().is_empty());
|
||||
store.close().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn existing_empty_config_is_not_reseeded() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file = dir.path().join("test.sqlite3");
|
||||
let store = Store::open(&file, &Config::default()).await.unwrap();
|
||||
store.close().await;
|
||||
let demo = crate::config::demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
let store = Store::open(&file, &demo).await.unwrap();
|
||||
assert!(store.load().await.unwrap().config.vhosts.is_empty());
|
||||
store.close().await;
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn migration_keeps_v1_config_and_revision() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("old.sqlite3");
|
||||
let options = SqliteConnectOptions::new()
|
||||
.filename(&path)
|
||||
.create_if_missing(true);
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect_with(options)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("CREATE TABLE configuration(id INTEGER PRIMARY KEY, revision INTEGER NOT NULL, document TEXT NOT NULL)")
|
||||
.execute(&pool).await.unwrap();
|
||||
let demo = crate::config::demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
sqlx::query("INSERT INTO configuration VALUES(1, 42, ?)")
|
||||
.bind(serde_json::to_string(&demo).unwrap())
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("PRAGMA user_version = 1")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
pool.close().await;
|
||||
let store = Store::open(&path, &Config::default()).await.unwrap();
|
||||
let saved = store.load().await.unwrap();
|
||||
assert_eq!(saved.revision, 42);
|
||||
assert_eq!(saved.config.vhosts[0].backends[0].id, "demo");
|
||||
assert!(store.admin_record().await.unwrap().is_none());
|
||||
store.close().await;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user