refactor(daemon): AppState delegates to storage modules; dbus tests use tempdir

Lands plan tasks 2.15 (AppState refactor with StorageConfig + open()) and 2.16
(dbus.rs tests switched to tempdir-backed AppState; storage errors threaded
through D-Bus methods as Failed). Bundled because the AppState surface change
forces dbus.rs adjustments in the same commit.

- daemon/src/state.rs: AppState::open(StorageConfig) replaces with_fixtures().
  StorageConfig.from_env_or_defaults() reads AUTHFORGE_POLICY_DIR /
  _PENDING_DIR / _USERDB env vars (defaults: /etc/authforge/policy.d,
  /var/lib/authforge/pending, /var/lib/authforge/users.db). State delegates
  list/add/remove credentials to CredsPathResolver + CredentialsStore picked
  per-call from current Policy; pending and userdb operate independently.
- daemon/src/main.rs: opens state via env-driven config; reuses cfg.policy_dir
  for the watcher to keep one source of truth.
- daemon/src/dbus.rs: every write method maps StateError to fdo::Error::Failed.
  p2p_pair seeds 00-test.conf with [storage] backend = central pointing into
  the tempdir so credential writes don't try to touch /home/<user>/...
  (alice/bob/carol aren't real accounts in tests).
- Renamed: list_credentials_returns_fixture_for_alice ->
  list_credentials_after_enroll. Removed: with_fixtures().
- .gitignore: add .claude/ so leftover Phase 1 worktree state isn't committed.

Test count: 26/26 daemon tests green (was 17). Common: 13/13. Clippy + fmt clean.
This commit is contained in:
michael
2026-04-27 06:44:07 -07:00
parent 90f7a0f4fc
commit 24237cbf8b
5 changed files with 244 additions and 145 deletions

View File

@@ -38,7 +38,10 @@ impl AuthForge {
}
async fn get_policy(&self) -> zbus::fdo::Result<Policy> {
Ok(self.state.get_policy().await)
self.state
.get_policy()
.await
.map_err(|e| zbus::fdo::Error::Failed(e.to_string()))
}
async fn enroll_own(&self, user: String, nickname: String) -> zbus::fdo::Result<Credential> {
@@ -52,14 +55,22 @@ impl AuthForge {
transport: Transport::Usb,
created_unix: now,
};
self.state.add_credential(&user, cred.clone()).await;
self.state
.add_credential(&user, cred.clone())
.await
.map_err(|e| zbus::fdo::Error::Failed(e.to_string()))?;
Ok(cred)
}
async fn remove_own(&self, user: String, cred_id: String) -> zbus::fdo::Result<()> {
self.authz("io.dangerousthings.AuthForge.remove-own")
.await?;
if self.state.remove_credential(&user, &cred_id).await {
let removed = self
.state
.remove_credential(&user, &cred_id)
.await
.map_err(|e| zbus::fdo::Error::Failed(e.to_string()))?;
if removed {
Ok(())
} else {
Err(zbus::fdo::Error::Failed(format!(
@@ -79,14 +90,20 @@ impl AuthForge {
transport: Transport::Usb,
created_unix: now,
};
self.state.add_credential(&user, cred.clone()).await;
self.state
.add_credential(&user, cred.clone())
.await
.map_err(|e| zbus::fdo::Error::Failed(e.to_string()))?;
Ok(cred)
}
async fn set_policy(&self, p: Policy) -> zbus::fdo::Result<PolicyApplyResult> {
self.authz("io.dangerousthings.AuthForge.set-policy")
.await?;
self.state.set_policy(p).await;
self.state
.set_policy(p)
.await
.map_err(|e| zbus::fdo::Error::Failed(e.to_string()))?;
Ok(PolicyApplyResult {
applied: true,
violations: vec![],
@@ -96,14 +113,20 @@ impl AuthForge {
async fn set_pending_flag(&self, user: String, flag: PendingFlag) -> zbus::fdo::Result<()> {
self.authz("io.dangerousthings.AuthForge.set-pending")
.await?;
self.state.set_pending(&user, flag).await;
self.state
.set_pending(&user, flag)
.await
.map_err(|e| zbus::fdo::Error::Failed(e.to_string()))?;
Ok(())
}
async fn clear_pending_flag(&self, user: String) -> zbus::fdo::Result<()> {
self.authz("io.dangerousthings.AuthForge.clear-pending")
.await?;
self.state.clear_pending(&user).await;
self.state
.clear_pending(&user)
.await
.map_err(|e| zbus::fdo::Error::Failed(e.to_string()))?;
Ok(())
}
@@ -133,11 +156,39 @@ mod tests {
use zbus::connection::Builder;
use zbus::Connection;
/// Build a peer-to-peer pair: server side has the AuthForge interface registered
/// at /io/dangerousthings/AuthForge, client side is a bare connection. No system
/// bus is involved.
async fn p2p_pair() -> (Connection, Connection, Arc<AppState>) {
let state = Arc::new(AppState::with_fixtures());
/// Build a peer-to-peer pair: server side has the AuthForge interface
/// registered at /io/dangerousthings/AuthForge, client side is a bare
/// connection. No system bus is involved. The TempDir must be returned —
/// dropping it before the test ends would delete the storage directories.
///
/// Storage is seeded with a central-mode policy pointing at a file inside
/// the tempdir so credential writes don't try to touch /home/<user>/...
/// (test users like alice/bob/carol aren't real accounts).
async fn p2p_pair() -> (Connection, Connection, Arc<AppState>, tempfile::TempDir) {
let tmp = tempfile::tempdir().unwrap();
let policy_dir = tmp.path().join("policy.d");
std::fs::create_dir_all(&policy_dir).unwrap();
let central = tmp.path().join("u2f_keys");
std::fs::write(
policy_dir.join("00-test.conf"),
format!(
r#"[storage]
backend = "central"
central_path = "{}"
"#,
central.display()
),
)
.unwrap();
let state = Arc::new(
AppState::open(crate::state::StorageConfig {
policy_dir,
pending_dir: tmp.path().join("pending"),
userdb_path: tmp.path().join("users.db"),
})
.unwrap(),
);
let auth = AuthForge {
state: state.clone(),
polkit: Arc::new(Polkit::permissive()),
@@ -159,7 +210,7 @@ mod tests {
let client_build = Builder::socket(client_sock).p2p().build();
let (server, client) = tokio::join!(server_build, client_build);
(server.unwrap(), client.unwrap(), state)
(server.unwrap(), client.unwrap(), state, tmp)
}
async fn proxy(client: &Connection) -> zbus::Proxy<'_> {
@@ -174,9 +225,12 @@ mod tests {
}
#[tokio::test]
async fn list_credentials_returns_fixture_for_alice() {
let (_srv, client, _state) = p2p_pair().await;
async fn list_credentials_after_enroll() {
let (_srv, client, _state, _tmp) = p2p_pair().await;
let p = proxy(&client).await;
// Seed via the same EnrollOwn path the GUI would use; previously this
// was a Phase 1 fixture in AppState, but file-backed storage starts empty.
let _: Credential = p.call("EnrollOwn", &("alice", "Yellow")).await.unwrap();
let creds: Vec<Credential> = p.call("ListCredentials", &("alice",)).await.unwrap();
assert_eq!(creds.len(), 1);
assert_eq!(creds[0].method, Method::Fido2);
@@ -184,7 +238,7 @@ mod tests {
#[tokio::test]
async fn get_policy_returns_default() {
let (_srv, client, _state) = p2p_pair().await;
let (_srv, client, _state, _tmp) = p2p_pair().await;
let p = proxy(&client).await;
let pol: Policy = p.call("GetPolicy", &()).await.unwrap();
assert!(pol.stacks.is_empty());
@@ -192,7 +246,7 @@ mod tests {
#[tokio::test]
async fn enroll_own_appends_credential() {
let (_srv, client, state) = p2p_pair().await;
let (_srv, client, state, _tmp) = p2p_pair().await;
let p = proxy(&client).await;
let _: Credential = p.call("EnrollOwn", &("bob", "Bob's Key")).await.unwrap();
assert_eq!(state.list_credentials("bob").await.len(), 1);
@@ -200,10 +254,12 @@ mod tests {
#[tokio::test]
async fn remove_own_drops_credential() {
let (_srv, client, state) = p2p_pair().await;
let (_srv, client, state, _tmp) = p2p_pair().await;
let p = proxy(&client).await;
// Enroll, then remove by the cred id we just got back.
let cred: Credential = p.call("EnrollOwn", &("alice", "Yellow")).await.unwrap();
let _: () = p
.call("RemoveOwn", &("alice", "fixture-cred-1"))
.call("RemoveOwn", &("alice", cred.id.as_str()))
.await
.unwrap();
assert!(state.list_credentials("alice").await.is_empty());
@@ -211,7 +267,7 @@ mod tests {
#[tokio::test]
async fn remove_own_unknown_id_errors() {
let (_srv, client, _state) = p2p_pair().await;
let (_srv, client, _state, _tmp) = p2p_pair().await;
let p = proxy(&client).await;
let r: Result<(), zbus::Error> = p.call("RemoveOwn", &("alice", "no-such-id")).await;
assert!(r.is_err());
@@ -219,15 +275,15 @@ mod tests {
#[tokio::test]
async fn enroll_other_creates_credential() {
let (_srv, client, state) = p2p_pair().await;
let (_srv, client, state, _tmp) = p2p_pair().await;
let p = proxy(&client).await;
let _: Credential = p.call("EnrollOther", &("carol", "Hers")).await.unwrap();
assert_eq!(state.list_credentials("carol").await.len(), 1);
}
#[tokio::test]
async fn set_policy_replaces_state() {
let (_srv, client, state) = p2p_pair().await;
async fn set_policy_persists_stacks() {
let (_srv, client, state, _tmp) = p2p_pair().await;
let p = proxy(&client).await;
let mut stacks = BTreeMap::new();
stacks.insert(
@@ -238,17 +294,20 @@ mod tests {
},
);
let pol = Policy {
stacks,
stacks: stacks.clone(),
..Default::default()
};
let r: PolicyApplyResult = p.call("SetPolicy", &(pol.clone(),)).await.unwrap();
let r: PolicyApplyResult = p.call("SetPolicy", &(pol,)).await.unwrap();
assert!(r.applied);
assert_eq!(state.get_policy().await, pol);
// p2p_pair seeds 00-test.conf with [storage]; SetPolicy writes 50-local.conf.
// The merged read shows our new stack alongside the seeded storage block.
let merged = state.get_policy().await.unwrap();
assert_eq!(merged.stacks, stacks);
}
#[tokio::test]
async fn set_and_clear_pending_flag() {
let (_srv, client, state) = p2p_pair().await;
let (_srv, client, state, _tmp) = p2p_pair().await;
let p = proxy(&client).await;
let flag = PendingFlag {
required_methods: vec![Method::Fido2],
@@ -257,14 +316,14 @@ mod tests {
re_enroll: false,
};
let _: () = p.call("SetPendingFlag", &("alice", flag)).await.unwrap();
assert!(state.has_pending("alice").await);
assert!(state.has_pending("alice").await.unwrap());
let _: () = p.call("ClearPendingFlag", &("alice",)).await.unwrap();
assert!(!state.has_pending("alice").await);
assert!(!state.has_pending("alice").await.unwrap());
}
#[tokio::test]
async fn generate_recovery_code_returns_8_digits() {
let (_srv, client, _state) = p2p_pair().await;
let (_srv, client, _state, _tmp) = p2p_pair().await;
let p = proxy(&client).await;
let code: String = p.call("GenerateRecoveryCode", &("alice",)).await.unwrap();
assert_eq!(code.len(), 8);
@@ -274,7 +333,7 @@ mod tests {
#[tokio::test]
async fn policy_changed_signal_fires_on_emit() {
use futures_util::stream::StreamExt;
let (server, client, _state) = p2p_pair().await;
let (server, client, _state, _tmp) = p2p_pair().await;
let proxy = proxy(&client).await;
let mut stream = proxy.receive_signal("PolicyChanged").await.unwrap();