From: Lukas Wagner <l.wagner@proxmox.com>
To: pdm-devel@lists.proxmox.com
Subject: [PATCH datacenter-manager 20/20] tests: add example tests for remote subscription management
Date: Mon, 17 Aug 2026 14:57:27 +0200 [thread overview]
Message-ID: <20260817125727.454039-21-l.wagner@proxmox.com> (raw)
In-Reply-To: <20260817125727.454039-1-l.wagner@proxmox.com>
This is obviously pretty incomplete, but it should demonstrate how easy
it is to write tests now, given the injected context.
Signed-off-by: Lukas Wagner <l.wagner@proxmox.com>
---
server/src/api/nodes/tasks.rs | 2 +-
server/src/api/subscriptions/mod.rs | 12 +--
server/tests/common/environment.rs | 8 +-
server/tests/common/mod.rs | 50 ++++++++-
server/tests/common/test_application.rs | 53 +++++++++-
server/tests/test_subscriptions.rs | 130 ++++++++++++++++++++++++
6 files changed, 240 insertions(+), 15 deletions(-)
create mode 100644 server/tests/test_subscriptions.rs
diff --git a/server/src/api/nodes/tasks.rs b/server/src/api/nodes/tasks.rs
index 31ddb8f1..4c28880f 100644
--- a/server/src/api/nodes/tasks.rs
+++ b/server/src/api/nodes/tasks.rs
@@ -277,7 +277,7 @@ fn stop_task(upid: UPID, rpcenv: &mut dyn RpcEnvironment) -> Result<(), Error> {
},
)]
/// Get task status.
-async fn get_task_status(upid: UPID, rpcenv: &mut dyn RpcEnvironment) -> Result<Value, Error> {
+pub async fn get_task_status(upid: UPID, rpcenv: &mut dyn RpcEnvironment) -> Result<Value, Error> {
let auth_id: Authid = rpcenv
.get_auth_id()
.context("no authid available")?
diff --git a/server/src/api/subscriptions/mod.rs b/server/src/api/subscriptions/mod.rs
index 922b43f7..087dbb0c 100644
--- a/server/src/api/subscriptions/mod.rs
+++ b/server/src/api/subscriptions/mod.rs
@@ -133,7 +133,7 @@ fn key_not_found(key: &str) -> Error {
/// additionally gated on per-remote `PRIV_RESOURCE_AUDIT` so that an operator who can audit the
/// pool but not a specific remote does not learn which keys are pinned to it (and through that,
/// the existence and rough size of that remote's deployment).
-fn list_keys(
+pub fn list_keys(
rpcenv: &mut dyn RpcEnvironment,
app: State<PdmApplication>,
) -> Result<Vec<SubscriptionKeyEntry>, Error> {
@@ -195,7 +195,7 @@ fn list_keys(
///
/// The post-save digest is set on the response so clients can chain a follow-up mutation without
/// a refetch round-trip.
-async fn add_keys(
+pub async fn add_keys(
keys: Vec<String>,
digest: Option<ConfigDigest>,
rpcenv: &mut dyn RpcEnvironment,
@@ -277,7 +277,7 @@ async fn add_keys(
/// Bound entries are hidden from operators who cannot audit the bound remote (mirrors the
/// `list_keys` filter); the response is the same 404 either way so a probe cannot distinguish
/// "key exists but you cannot see it" from "key not in pool".
-fn get_key(
+pub fn get_key(
key: String,
rpcenv: &mut dyn RpcEnvironment,
app: State<PdmApplication>,
@@ -329,7 +329,7 @@ fn get_key(
/// another admin had pinned. Refuses if the key is currently the live active key on its bound
/// node, since dropping the pool entry would orphan that subscription on the remote: the
/// operator must run Clear Key on the Node Subscription Status panel first.
-async fn delete_key(
+pub async fn delete_key(
key: String,
digest: Option<ConfigDigest>,
rpcenv: &mut dyn RpcEnvironment,
@@ -463,7 +463,7 @@ async fn delete_key(
/// `PRIV_SYS_MODIFY` lets the caller touch the pool config; per-remote `PRIV_RESOURCE_MODIFY`
/// is enforced inside this handler so an operator cannot push a key to a remote they have no
/// other authority on.
-async fn set_assignment(
+pub async fn set_assignment(
key: String,
remote: String,
node: String,
@@ -1818,7 +1818,7 @@ fn compute_proposals(
/// no longer sees. The worker itself deliberately re-reads the pool when it fires (a worker can
/// be scheduled with delay), so a parallel admin edit between API return and worker firing is
/// still honoured - the digest only pins the at-API-call-time plan, not the executed plan.
-async fn apply_pending(
+pub async fn apply_pending(
digest: Option<ConfigDigest>,
rpcenv: &mut dyn RpcEnvironment,
app: State<PdmApplication>,
diff --git a/server/tests/common/environment.rs b/server/tests/common/environment.rs
index 5dad7b21..0ac841c3 100644
--- a/server/tests/common/environment.rs
+++ b/server/tests/common/environment.rs
@@ -1,14 +1,16 @@
use proxmox_router::RpcEnvironment;
-pub struct TestRpcEnvironment;
+pub struct TestRpcEnvironment {
+ pub attribs: serde_json::Value,
+}
impl RpcEnvironment for TestRpcEnvironment {
fn result_attrib_mut(&mut self) -> &mut serde_json::Value {
- unimplemented!()
+ &mut self.attribs
}
fn result_attrib(&self) -> &serde_json::Value {
- unimplemented!()
+ &self.attribs
}
fn env_type(&self) -> proxmox_router::RpcEnvironmentType {
diff --git a/server/tests/common/mod.rs b/server/tests/common/mod.rs
index 49646706..d65918d3 100644
--- a/server/tests/common/mod.rs
+++ b/server/tests/common/mod.rs
@@ -1,9 +1,10 @@
use std::{
path::{Path, PathBuf},
sync::{Once, OnceLock},
+ time::Duration,
};
-use anyhow::Context;
+use anyhow::{Context, Error, bail};
use serde::de::DeserializeOwned;
use proxmox_sys::fs::CreateOptions;
@@ -52,5 +53,50 @@ pub fn test_setup() {
}
pub fn rpcenv() -> TestRpcEnvironment {
- TestRpcEnvironment
+ TestRpcEnvironment {
+ attribs: serde_json::Value::Null,
+ }
+}
+
+#[allow(unused)]
+macro_rules! assert_http_error {
+ ($result:expr, $status:expr $(, $expected:expr)? $(,)?) => {{
+ let err = $result.unwrap_err();
+ let http_err = err.downcast_ref::<proxmox_router::HttpError>().unwrap();
+
+ assert_eq!(http_err.code, $status);
+
+ $(
+ let err_text = err.to_string();
+ assert!(
+ err_text.contains($expected),
+ "'{}' not contained in '{}'",
+ $expected,
+ err_text,
+ );
+ )?
+ }};
+}
+
+#[allow(unused)]
+pub(crate) use assert_http_error;
+
+pub async fn wait_for_task(upid: &str) -> Result<String, Error> {
+ let upid = upid.parse::<pdm_api_types::UPID>()?;
+
+ for _ in 0..50 {
+ let response =
+ server::api::nodes::tasks::get_task_status(upid.clone(), &mut rpcenv()).await?;
+
+ if response["status"].as_str().unwrap() != "running" {
+ return Ok(response["exitstatus"]
+ .as_str()
+ .context("expected exitstatus to be a string")?
+ .into());
+ }
+
+ tokio::time::sleep(Duration::from_millis(100)).await;
+ }
+
+ bail!("worker did not finish after timeout");
}
diff --git a/server/tests/common/test_application.rs b/server/tests/common/test_application.rs
index 436513f0..f81a9681 100644
--- a/server/tests/common/test_application.rs
+++ b/server/tests/common/test_application.rs
@@ -1,21 +1,22 @@
use std::collections::HashMap;
-use std::sync::Arc;
-use std::sync::Mutex;
+use std::sync::{Arc, Mutex};
use anyhow::{Context, Error, bail};
use serde::{Serialize, de::DeserializeOwned};
use nix::unistd::User;
use proxmox_client::Client;
+use proxmox_product_config::create_mocked_lock;
use proxmox_section_config::typed::SectionConfigData;
use pbs_api_types::Authid;
use pdm_api_types::{
ConfigDigest,
remotes::{Remote, RemoteType},
+ subscription::{SubscriptionKeyEntry, SubscriptionKeyShadow},
};
-use pdm_config::remotes::RemoteConfig;
+use pdm_config::{remotes::RemoteConfig, subscriptions::SubscriptionKeyConfig};
use server::context::product_config::ProductConfig;
use server::{
@@ -124,6 +125,12 @@ impl ContextFactory for TestApplication {
Ok(Box::new(self.clone()))
}
+ fn make_subscription_key_config(
+ &self,
+ ) -> Result<Box<dyn SubscriptionKeyConfig + Send + Sync>, Error> {
+ Ok(Box::new(TestSubscritionKeyConfig::default()))
+ }
+
fn make_product_config(&self) -> Result<ProductConfig, Error> {
let user = User::from_uid(nix::unistd::getuid())
.ok()
@@ -230,6 +237,46 @@ impl ClientFactory for TestApplication {
}
}
+#[derive(Default)]
+struct TestSubscritionKeyConfig {
+ shadow: Mutex<SectionConfigData<SubscriptionKeyShadow>>,
+ config: Mutex<SectionConfigData<SubscriptionKeyEntry>>,
+}
+
+impl SubscriptionKeyConfig for TestSubscritionKeyConfig {
+ fn read(&self) -> Result<(SectionConfigData<SubscriptionKeyEntry>, ConfigDigest), Error> {
+ Ok((
+ self.config.lock().unwrap().clone(),
+ ConfigDigest::from_slice([]),
+ ))
+ }
+
+ fn read_shadow(&self) -> Result<SectionConfigData<SubscriptionKeyShadow>, Error> {
+ Ok(self.shadow.lock().unwrap().clone())
+ }
+
+ fn lock(&self) -> Result<proxmox_product_config::ApiLockGuard, Error> {
+ Ok(unsafe { create_mocked_lock() })
+ }
+
+ fn write(
+ &self,
+ config: &SectionConfigData<SubscriptionKeyEntry>,
+ ) -> Result<ConfigDigest, Error> {
+ let mut guard = self.config.lock().unwrap();
+ *guard = config.clone();
+
+ Ok(ConfigDigest::from_slice([]))
+ }
+
+ fn write_shadow(&self, shadow: &SectionConfigData<SubscriptionKeyShadow>) -> Result<(), Error> {
+ let mut guard = self.shadow.lock().unwrap();
+ *guard = shadow.clone();
+
+ Ok(())
+ }
+}
+
macro_rules! test_pve_client {
($ty:ident { $($overrides:tt)* }) => {
diff --git a/server/tests/test_subscriptions.rs b/server/tests/test_subscriptions.rs
new file mode 100644
index 00000000..122e0517
--- /dev/null
+++ b/server/tests/test_subscriptions.rs
@@ -0,0 +1,130 @@
+use http::StatusCode;
+use pdm_api_types::subscription::{ProductType, SubscriptionLevel};
+use proxmox_router::State;
+use pve_api_types::{ClusterNodeIndexResponse, SetSubscription};
+
+use crate::common::{TestApplication, TestRemoteState, rpcenv};
+
+use server::{api, context::ContextFactory};
+
+pub mod common;
+
+#[tokio::test]
+async fn test_manage_inventory() {
+ common::test_setup();
+
+ let test_app = TestApplication::new();
+ let app = test_app.make_pdm_application().unwrap();
+
+ let existing_keys = api::subscriptions::list_keys(&mut rpcenv(), State(app.clone())).unwrap();
+ assert!(existing_keys.is_empty());
+
+ let keys = vec![
+ "pve4c-aaaaaaaaaa".into(),
+ "pve4c-bbbbbbbbbb".into(),
+ "pve4c-bbbbbbbbbb".into(),
+ ];
+
+ let add_result = api::subscriptions::add_keys(keys, None, &mut rpcenv(), State(app.clone()))
+ .await
+ .unwrap();
+
+ assert_eq!(add_result.added, 2);
+ assert_eq!(add_result.deduplicated, 1);
+
+ let existing_keys = api::subscriptions::list_keys(&mut rpcenv(), State(app.clone())).unwrap();
+ assert_eq!(existing_keys.len(), 2);
+
+ let key =
+ api::subscriptions::get_key("pve4c-aaaaaaaaaa".into(), &mut rpcenv(), State(app.clone()))
+ .unwrap();
+
+ assert_eq!(key.key, "pve4c-aaaaaaaaaa");
+ assert_eq!(key.product_type, ProductType::Pve);
+ assert_eq!(key.level, SubscriptionLevel::Community);
+ assert_eq!(key.remote, None);
+
+ api::subscriptions::delete_key(
+ "pve4c-aaaaaaaaaa".into(),
+ None,
+ &mut rpcenv(),
+ State(app.clone()),
+ )
+ .await
+ .unwrap();
+
+ let result =
+ api::subscriptions::get_key("pve4c-aaaaaaaaaa".into(), &mut rpcenv(), State(app.clone()));
+
+ common::assert_http_error!(result, StatusCode::NOT_FOUND, "not found in pool");
+}
+
+#[tokio::test]
+async fn test_invalid_key_format() {
+ common::test_setup();
+
+ let test_app = TestApplication::new();
+ let app = test_app.make_pdm_application().unwrap();
+
+ let keys = vec!["aaaaa".into()];
+
+ let result = api::subscriptions::add_keys(keys, None, &mut rpcenv(), State(app.clone())).await;
+
+ common::assert_http_error!(result, StatusCode::BAD_REQUEST, "unrecognised key format");
+}
+
+common::test_pve_client!(SubscriptionClient {
+ async fn set_subscription(&self, node: &str, params: SetSubscription) -> Result<(), proxmox_client::Error> {
+ self.state().set(format!("set_subscription-{node}"), ¶ms);
+
+ Ok(())
+ }
+});
+
+#[tokio::test]
+async fn test_apply_key() {
+ common::test_setup();
+
+ let remote_state = TestRemoteState::default();
+
+ let test_app = TestApplication::new().with_pve_remote(
+ "remote-a",
+ remote_state.clone(),
+ SubscriptionClient,
+ );
+
+ let app = test_app.make_pdm_application().unwrap();
+
+ let keys = vec![
+ "pve4c-aaaaaaaaaa".into(),
+ "pve4c-bbbbbbbbbb".into(),
+ "pve4c-bbbbbbbbbb".into(),
+ ];
+ let _ = api::subscriptions::add_keys(keys.clone(), None, &mut rpcenv(), State(app.clone()))
+ .await
+ .unwrap();
+
+ api::subscriptions::set_assignment(
+ keys[0].clone(),
+ "remote-a".into(),
+ "remote-a-node-0".into(),
+ None,
+ &mut rpcenv(),
+ State(app.clone()),
+ )
+ .await
+ .unwrap();
+
+ let upid = api::subscriptions::apply_pending(None, &mut rpcenv(), State(app.clone()))
+ .await
+ .unwrap()
+ .unwrap();
+
+ assert_eq!(common::wait_for_task(&upid).await.unwrap(), "OK");
+
+ let set_subscription: SetSubscription = remote_state
+ .get("set_subscription-remote-a-node-0")
+ .expect("subscription was set on remote-a-node-0");
+
+ assert_eq!(set_subscription.key, keys[0]);
+}
--
2.47.3
next prev parent reply other threads:[~2026-08-17 12:58 UTC|newest]
Thread overview: 28+ messages / expand[flat|nested] mbox.gz Atom feed top
2026-08-17 12:57 [PATCH datacenter-manager/proxmox 00/20] inject application context via API macro for easier integration testing Lukas Wagner
2026-08-17 12:57 ` [PATCH proxmox 01/20] router: introduce shared state Lukas Wagner
2026-08-17 13:26 ` Lukas Wagner
2026-08-20 11:23 ` Lukas Wagner
2026-08-21 13:59 ` Robert Obkircher
2026-08-17 12:57 ` [PATCH proxmox 02/20] rest-server: allow to inject " Lukas Wagner
2026-08-17 12:57 ` [PATCH proxmox 03/20] api-macro: support shared state extraction type Lukas Wagner
2026-08-21 13:59 ` Robert Obkircher
2026-08-17 12:57 ` [PATCH datacenter-manager 04/20] context: promote context to a dir-style module Lukas Wagner
2026-08-17 12:57 ` [PATCH datacenter-manager 05/20] pdm-config: remotes: rename trait methods to read/write/lock Lukas Wagner
2026-08-17 12:57 ` [PATCH datacenter-manager 06/20] pdm-config: subscriptions: " Lukas Wagner
2026-08-21 14:00 ` Robert Obkircher
2026-08-17 12:57 ` [PATCH datacenter-manager 07/20] remote iterator: pass remote config reader explicitly Lukas Wagner
2026-08-17 12:57 ` [PATCH datacenter-manager 08/20] context: introduce a ContextFactory to build application context Lukas Wagner
2026-08-17 12:57 ` [PATCH datacenter-manager 09/20] context: establish PdmApplication object Lukas Wagner
2026-08-17 12:57 ` [PATCH datacenter-manager 10/20] context: register PdmApplication in router Lukas Wagner
2026-08-17 12:57 ` [PATCH datacenter-manager 11/20] parallel fetcher: pass arguments to closure in a single type Lukas Wagner
2026-08-21 14:00 ` Robert Obkircher
2026-08-17 12:57 ` [PATCH datacenter-manager 12/20] parallel fetcher: support a custom client factory Lukas Wagner
2026-08-17 12:57 ` [PATCH datacenter-manager 13/20] api: sdn: use PdmApplication handle for accessing remotes Lukas Wagner
2026-08-17 12:57 ` [PATCH datacenter-manager 14/20] tests: add helpers for building API-handler-level integration tests Lukas Wagner
2026-08-17 12:57 ` [PATCH datacenter-manager 15/20] tests: add example tests for SDN API routes Lukas Wagner
2026-08-17 12:57 ` [PATCH datacenter-manager 16/20] api-cache: add wrapper type Lukas Wagner
2026-08-17 12:57 ` [PATCH datacenter-manager 17/20] context: provide api-cache on the app object Lukas Wagner
2026-08-17 12:57 ` [PATCH datacenter-manager 18/20] api: subscriptions: use PdmApplication instead of globals Lukas Wagner
2026-08-17 12:57 ` [PATCH datacenter-manager 19/20] pdm-config: subscriptions: drop unused accessor functions Lukas Wagner
2026-08-17 12:57 ` Lukas Wagner [this message]
2026-08-20 14:54 ` superseded: [PATCH datacenter-manager/proxmox 00/20] inject application context via API macro for easier integration testing Lukas Wagner
Reply instructions:
You may reply publicly to this message via plain-text email
using any one of the following methods:
* Save the following mbox file, import it into your mail client,
and reply-to-all from there: mbox
Avoid top-posting and favor interleaved quoting:
https://en.wikipedia.org/wiki/Posting_style#Interleaved_style
* Reply using the --to, --cc, and --in-reply-to
switches of git-send-email(1):
git send-email \
--in-reply-to=20260817125727.454039-21-l.wagner@proxmox.com \
--to=l.wagner@proxmox.com \
--cc=pdm-devel@lists.proxmox.com \
/path/to/YOUR_REPLY
https://kernel.org/pub/software/scm/git/docs/git-send-email.html
* If your mail client supports setting the In-Reply-To header
via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line
before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox