public inbox for pdm-devel@lists.proxmox.com
 help / color / mirror / Atom feed
From: Lukas Wagner <l.wagner@proxmox.com>
To: pdm-devel@lists.proxmox.com
Subject: [PATCH datacenter-manager v3 21/21] tests: add example tests for remote subscription management
Date: Thu, 27 Aug 2026 13:42:44 +0200	[thread overview]
Message-ID: <20260827114244.424784-22-l.wagner@proxmox.com> (raw)
In-Reply-To: <20260827114244.424784-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      |   9 +-
 server/tests/common/mod.rs              |  59 ++++++++++--
 server/tests/common/test_application.rs |  52 +++++++++-
 server/tests/test_subscriptions.rs      | 122 ++++++++++++++++++++++++
 6 files changed, 238 insertions(+), 18 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 370941a2..d9dfb1a2 100644
--- a/server/src/api/subscriptions/mod.rs
+++ b/server/src/api/subscriptions/mod.rs
@@ -132,7 +132,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,
     #[state] app: PdmApplication,
 ) -> Result<Vec<SubscriptionKeyEntry>, Error> {
@@ -194,7 +194,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,
@@ -276,7 +276,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,
     #[state] app: PdmApplication,
@@ -328,7 +328,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,
@@ -462,7 +462,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,
@@ -1817,7 +1817,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,
     #[state] app: PdmApplication,
diff --git a/server/tests/common/environment.rs b/server/tests/common/environment.rs
index 5dad7b21..dbfb2918 100644
--- a/server/tests/common/environment.rs
+++ b/server/tests/common/environment.rs
@@ -1,14 +1,17 @@
 use proxmox_router::RpcEnvironment;
 
-pub struct TestRpcEnvironment;
+/// An [`RpcEnvironment`] that can be used in integration tests.
+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 38697353..058f0a20 100644
--- a/server/tests/common/mod.rs
+++ b/server/tests/common/mod.rs
@@ -1,9 +1,8 @@
-use std::{
-    path::{Path, PathBuf},
-    sync::{Once, OnceLock},
-};
+use std::path::{Path, PathBuf};
+use std::sync::{Once, OnceLock};
+use std::time::Duration;
 
-use anyhow::Context;
+use anyhow::{Context, Error, bail};
 use serde::de::DeserializeOwned;
 
 use proxmox_sys::fs::CreateOptions;
@@ -60,5 +59,53 @@ pub fn test_setup() {
 
 /// Create a [`TestRpcEnvironment`] for the use in a test.
 pub fn rpcenv() -> TestRpcEnvironment {
-    TestRpcEnvironment
+    TestRpcEnvironment {
+        attribs: serde_json::json!({}),
+    }
+}
+
+#[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;
+
+/// Wait for a task to finish.
+///
+/// At the moment, this uses a hard-coded timeout of 5 seconds.
+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 83b35bf6..054cfb8e 100644
--- a/server/tests/common/test_application.rs
+++ b/server/tests/common/test_application.rs
@@ -1,6 +1,5 @@
 use std::collections::HashMap;
-use std::sync::Arc;
-use std::sync::Mutex;
+use std::sync::{Arc, Mutex};
 
 use anyhow::{Context, Error, bail};
 use nix::unistd::User;
@@ -8,12 +7,15 @@ use serde::{Serialize, de::DeserializeOwned};
 
 use pbs_api_types::Authid;
 use proxmox_client::Client;
+use proxmox_product_config::create_mocked_lock;
 use proxmox_product_config::{ProductConfig, ProductConfigParams};
 use proxmox_section_config::typed::SectionConfigData;
 
 use pdm_api_types::ConfigDigest;
 use pdm_api_types::remotes::{Remote, RemoteType};
+use pdm_api_types::subscription::{SubscriptionKeyEntry, SubscriptionKeyShadow};
 use pdm_config::remotes::RemoteConfig;
+use pdm_config::subscriptions::SubscriptionKeyConfig;
 
 use server::connection::{ClientFactory, PveClient};
 use server::context::ContextFactory;
@@ -137,6 +139,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(TestSubscriptionKeyConfig::default()))
+    }
+
     fn make_product_config(&self) -> Result<ProductConfig, Error> {
         let user = User::from_uid(nix::unistd::getuid())
             .ok()
@@ -241,6 +249,46 @@ impl ClientFactory for TestApplication {
     }
 }
 
+#[derive(Default)]
+struct TestSubscriptionKeyConfig {
+    shadow: Mutex<SectionConfigData<SubscriptionKeyShadow>>,
+    config: Mutex<SectionConfigData<SubscriptionKeyEntry>>,
+}
+
+impl SubscriptionKeyConfig for TestSubscriptionKeyConfig {
+    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..58dcb546
--- /dev/null
+++ b/server/tests/test_subscriptions.rs
@@ -0,0 +1,122 @@
+use http::StatusCode;
+use pdm_api_types::subscription::{ProductType, SubscriptionLevel};
+use pve_api_types::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(), 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(), 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(), app.clone()).unwrap();
+    assert_eq!(existing_keys.len(), 2);
+
+    let key =
+        api::subscriptions::get_key("pve4c-aaaaaaaaaa".into(), &mut rpcenv(), 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(), app.clone())
+        .await
+        .unwrap();
+
+    let result = api::subscriptions::get_key("pve4c-aaaaaaaaaa".into(), &mut rpcenv(), 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(), 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}"), &params);
+
+        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(), app.clone())
+        .await
+        .unwrap();
+
+    api::subscriptions::set_assignment(
+        keys[0].clone(),
+        "remote-a".into(),
+        "remote-a-node-0".into(),
+        None,
+        &mut rpcenv(),
+        app.clone(),
+    )
+    .await
+    .unwrap();
+
+    let upid = api::subscriptions::apply_pending(None, &mut rpcenv(), 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





      parent reply	other threads:[~2026-08-27 11:43 UTC|newest]

Thread overview: 22+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2026-08-27 11:42 [PATCH datacenter-manager/proxmox v3 00/21] inject application context via API macro for easier integration testing Lukas Wagner
2026-08-27 11:42 ` [PATCH proxmox v3 01/21] router: introduce shared state Lukas Wagner
2026-08-27 11:42 ` [PATCH proxmox v3 02/21] rest-server: allow to inject " Lukas Wagner
2026-08-27 11:42 ` [PATCH proxmox v3 03/21] api-macro: support shared state extraction type Lukas Wagner
2026-08-27 11:42 ` [PATCH proxmox v3 04/21] product-config: add ProductConfig type Lukas Wagner
2026-08-27 11:42 ` [PATCH datacenter-manager v3 05/21] context: promote context to a dir-style module Lukas Wagner
2026-08-27 11:42 ` [PATCH datacenter-manager v3 06/21] pdm-config: remotes: rename trait methods to read/write/lock Lukas Wagner
2026-08-27 11:42 ` [PATCH datacenter-manager v3 07/21] pdm-config: subscriptions: " Lukas Wagner
2026-08-27 11:42 ` [PATCH datacenter-manager v3 08/21] remote iterator: pass remote config reader explicitly Lukas Wagner
2026-08-27 11:42 ` [PATCH datacenter-manager v3 09/21] context: introduce a ContextFactory to build application context Lukas Wagner
2026-08-27 11:42 ` [PATCH datacenter-manager v3 10/21] context: establish PdmApplication object Lukas Wagner
2026-08-27 11:42 ` [PATCH datacenter-manager v3 11/21] context: register PdmApplication in router Lukas Wagner
2026-08-27 11:42 ` [PATCH datacenter-manager v3 12/21] connection: use client factory from PdmApplication handle Lukas Wagner
2026-08-27 11:42 ` [PATCH datacenter-manager v3 13/21] parallel fetcher: pass arguments to closure in a single type Lukas Wagner
2026-08-27 11:42 ` [PATCH datacenter-manager v3 14/21] server: migrate existing ParallelFetcher users to use PdmApplication Lukas Wagner
2026-08-27 11:42 ` [PATCH datacenter-manager v3 15/21] tests: add helpers for building API-handler-level integration tests Lukas Wagner
2026-08-27 11:42 ` [PATCH datacenter-manager v3 16/21] tests: add example tests for SDN API routes Lukas Wagner
2026-08-27 11:42 ` [PATCH datacenter-manager v3 17/21] api-cache: add wrapper type Lukas Wagner
2026-08-27 11:42 ` [PATCH datacenter-manager v3 18/21] context: provide api-cache on the app object Lukas Wagner
2026-08-27 11:42 ` [PATCH datacenter-manager v3 19/21] api: subscriptions: use PdmApplication instead of globals Lukas Wagner
2026-08-27 11:42 ` [PATCH datacenter-manager v3 20/21] pdm-config: subscriptions: drop unused accessor functions Lukas Wagner
2026-08-27 11:42 ` Lukas Wagner [this message]

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=20260827114244.424784-22-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
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal