Lines
92.56 %
Functions
48.11 %
Branches
100 %
// Copyright 2020, Collabora Ltd.
// SPDX-License-Identifier: MIT OR Apache-2.0
//! [Direct Device Integration](https://www.eclipse.org/hawkbit/apis/ddi_api/) mock server.
//!
//! This module provides a hawkBit mock server implementing the [DDI API](https://www.eclipse.org/hawkbit/apis/ddi_api/).
//! It can be instrumented to test any hawkbit client.
//! # Examples
//! ```
//! use hawkbit_mock::ddi::ServerBuilder;
//! let server = ServerBuilder::default().build();
//! let target = server.add_target("Target1");
//! You can tell call [`Target::request_config`] or [`Target::push_deployment`] to
//! to interact with the server.
//! Check the the hawbit crate for actual tests using this mock server.
// FIXME: set link to hawbit/tests/tests.rs once we have the final public repo
use std::rc::Rc;
use std::{
cell::{Cell, RefCell},
path::PathBuf,
};
use httpmock::{
Method::{GET, POST, PUT},
Mock, MockExt, MockServer,
use httpmock::{Then, When};
use serde_json::{json, Map, Value};
use hawkbit::ddi::{
ClientAuthorization, ConfirmationResponse, Execution, Finished, MaintenanceWindow, Type,
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
/// Authorization method that is required in requests by the target
pub enum TargetAuthorization {
/// No authorization
None,
/// Require a target token
TargetToken,
/// Require a gateway token
GatewayToken,
}
/// Builder of [`Server`].
///
/// # Examples
/// ```
/// use hawkbit_mock::ddi::ServerBuilder;
/// let server = ServerBuilder::default().build();
pub struct ServerBuilder {
tenant: String,
target_authorization: TargetAuthorization,
impl Default for ServerBuilder {
fn default() -> Self {
Self {
tenant: "DEFAULT".into(),
target_authorization: TargetAuthorization::TargetToken,
impl ServerBuilder {
/// Set the tenant of the server, default to `DEFAULT`.
pub fn tenant(self, tenant: &str) -> Self {
let mut builder = self;
builder.tenant = tenant.to_string();
builder
/// Set the client authorization method, default to `TargetToken`.
pub fn target_authorization(self, target_authorization: TargetAuthorization) -> Self {
builder.target_authorization = target_authorization;
/// Create the [`Server`].
pub fn build(self) -> Server {
Server {
server: Rc::new(MockServer::start()),
tenant: self.tenant,
target_authorization: self.target_authorization,
/// Mock DDI server instance.
pub struct Server {
/// The tenant of the server.
pub tenant: String,
/// The authorization method of the server.
pub target_authorization: TargetAuthorization,
server: Rc<MockServer>,
impl Server {
/// The base URL of the server, such as `http://my-server.com:8080`
pub fn base_url(&self) -> String {
self.server.base_url()
/// Add a new target named `name` to the server.
pub fn add_target(&self, name: &str) -> Target {
let client_auth = match self.target_authorization {
TargetAuthorization::None => ClientAuthorization::None,
TargetAuthorization::TargetToken => {
ClientAuthorization::TargetToken(format!("Key{}", name))
TargetAuthorization::GatewayToken => {
ClientAuthorization::GatewayToken(format!("Key{}", self.tenant))
Target::new(name, &self.server, &self.tenant, &client_auth)
/// A configured device the server can request configuration for and push updates to.
pub struct Target {
/// The name of the target.
pub name: String,
/// The secret authentification token used to identify the target on the server.
pub client_auth: ClientAuthorization,
poll: Cell<usize>,
config_data: RefCell<Option<PendingAction>>,
confirmation: RefCell<Option<PendingAction>>,
deployment: RefCell<Option<PendingAction>>,
cancel_action: RefCell<Option<PendingAction>>,
impl Target {
fn new(
name: &str,
server: &Rc<MockServer>,
tenant: &str,
client_auth: &ClientAuthorization,
) -> Self {
let poll = Self::create_poll(server, tenant, name, client_auth, None, None, None, None);
Target {
name: name.to_string(),
client_auth: client_auth.clone(),
server: server.clone(),
tenant: tenant.to_string(),
poll: Cell::new(poll),
config_data: RefCell::new(None),
confirmation: RefCell::new(None),
deployment: RefCell::new(None),
cancel_action: RefCell::new(None),
#[allow(clippy::too_many_arguments)]
fn create_poll(
server: &MockServer,
expected_config_data: Option<&PendingAction>,
confirmation: Option<&PendingAction>,
deployment: Option<&PendingAction>,
cancel_action: Option<&PendingAction>,
) -> usize {
let mut links = Map::new();
if let Some(pending) = expected_config_data {
links.insert("configData".into(), json!({ "href": pending.path }));
if let Some(pending) = confirmation {
links.insert("confirmationBase".into(), json!({ "href": pending.path }));
if let Some(pending) = deployment {
links.insert("deploymentBase".into(), json!({ "href": pending.path }));
if let Some(pending) = cancel_action {
links.insert("cancelAction".into(), json!({ "href": pending.path }));
let response = json!({
"config": {
"polling": {
"sleep": "00:01:00"
},
"_links": links
});
let mock = server.mock(|when, then| {
let when = when
.method(GET)
.path(format!("/{}/controller/v1/{}", tenant, name));
match client_auth {
ClientAuthorization::None => { /* do not require Authorization header */ }
ClientAuthorization::TargetToken(key) => {
when.header("Authorization", format!("TargetToken {}", key));
ClientAuthorization::GatewayToken(key) => {
when.header("Authorization", format!("GatewayToken {}", key));
then.status(200)
.header("Content-Type", "application/json")
.json_body(response);
mock.id()
fn update_poll(&self) {
let old = self.poll.replace(Self::create_poll(
&self.server,
&self.tenant,
&self.name,
&self.client_auth,
self.config_data.borrow().as_ref(),
self.confirmation.borrow().as_ref(),
self.deployment.borrow().as_ref(),
self.cancel_action.borrow().as_ref(),
));
let mut old = Mock::new(old, &self.server);
old.delete();
/// Request the target to upload its configuration to the server.
/// One can then use [`Target::config_data_hits`] to check that the client
/// uploaded its configuration and that it matches the one passed as `expected_config_data`.
/// use serde_json::json;
/// let target = server.add_target("Target1");
/// let expected_config_data = json!({
/// "mode" : "merge",
/// "data" : {
/// "awesome" : true,
/// },
/// "status" : {
/// "result" : {
/// "finished" : "success"
/// "execution" : "closed",
/// "details" : [ "Some stuffs" ]
/// }
/// });
/// target.request_config(expected_config_data);
/// // Client handles the request and upload its configuration
/// //assert_eq!(target.config_data_hits(), 1);
pub fn request_config(&self, expected_config_data: Value) {
let config_path = self
.server
.url(format!("/DEFAULT/controller/v1/{}/configData", self.name));
let config_data = self.server.mock(|when, then| {
.method(PUT)
.path(format!("/DEFAULT/controller/v1/{}/configData", self.name))
.json_body(expected_config_data);
match &self.client_auth {
then.status(200);
self.config_data.replace(Some(PendingAction {
server: self.server.clone(),
path: config_path,
mock: config_data.id(),
}));
self.update_poll();
/// Push a deployment update to the target.
/// retrieve the deployment details as expected.
/// use std::path::Path;
/// use hawkbit_mock::ddi::{ChunkProtocol, ServerBuilder, DeploymentBuilder};
/// use hawkbit::ddi::{Type, MaintenanceWindow};
/// let deployment = DeploymentBuilder::new("10", Type::Forced, Type::Attempt)
/// .maintenance_window(MaintenanceWindow::Available)
/// .chunk(
/// ChunkProtocol::BOTH,
/// "app",
/// "1.0",
/// "some-chunk",
/// vec![(
/// Path::new("README.md").to_path_buf(),
/// "42cf69051362d8fa2883cc9b56799fa4",
/// "16da060b7ff443a6b3a7662ad21a9b3023c12627",
/// "5010fbc2769bfc655d15aa9a883703d5b19a320732d37f70703ab3e3b416a602",
/// )],
/// )
/// .build();
/// target.push_deployment(deployment);
/// // Client handles the update and fetch details
/// //assert_eq!(target.deployment_hits(), 1);
pub fn push_deployment(&self, deploy: Deployment) {
if deploy.confirmation_required {
let confirmation_path = self.server.url(format!(
"/DEFAULT/controller/v1/{}/confirmationBase/{}",
self.name, deploy.id
let base_url = self.server.url("/download");
let response = deploy.json(&base_url);
let confirmation_mock = self.server.mock(|when, then| {
let when = when.method(GET).path(format!(
self.deployment.replace(None);
self.confirmation.replace(Some(PendingAction {
path: confirmation_path,
mock: confirmation_mock.id(),
} else {
let deploy_path = self.server.url(format!(
"/DEFAULT/controller/v1/{}/deploymentBase/{}",
let deploy_mock = self.server.mock(|when, then| {
// Serve the artifacts
for chunk in deploy.chunks.iter() {
for (artifact, _md5, _sha1, _sha256) in chunk.artifacts.iter() {
let file_name = artifact.file_name().unwrap().to_str().unwrap();
let path = format!("/download/{}", file_name);
if let Some(mock_fn) = &chunk.mock {
self.server.mock(mock_fn);
self.server.mock(|when, then| {
let when = when.method(GET).path(path);
ClientAuthorization::None => { /* do not require Authorization header */
then.status(200).body_from_file(artifact.to_str().unwrap());
self.confirmation.replace(None);
self.deployment.replace(Some(PendingAction {
path: deploy_path,
mock: deploy_mock.id(),
/// Configure the server to expect deployment feedback from the target.
/// One can then check the feedback has actually been received using
/// `hits()` on the returned object.
/// use hawkbit_mock::ddi::{ServerBuilder, DeploymentBuilder};
/// use hawkbit::ddi::{Execution, Finished};
/// let mut mock = target.expect_deployment_feedback(
/// "10",
/// Execution::Closed,
/// Finished::Success,
/// Some(json!({"awesome": true})),
/// vec!["Done"],
/// );
/// assert_eq!(mock.hits(), 0);
/// //Client send the feedback
/// //assert_eq!(mock.hits(), 1);
pub fn expect_deployment_feedback(
&self,
deployment_id: &str,
execution: Execution,
finished: Finished,
progress: Option<serde_json::Value>,
details: Vec<&str>,
) -> Mock<'_> {
let expected = match progress {
Some(progress) => json!({
"id": deployment_id,
"status": {
"result": {
"progress": progress,
"finished": finished
"execution": execution,
"details": details,
}),
None => json!({
.method(POST)
.path(format!(
"/{}/controller/v1/{}/deploymentBase/{}/feedback",
self.tenant, self.name, deployment_id
))
.json_body(expected);
})
/// Configure the server to expect confirmation feedback from the target.
/// use hawkbit::ddi::ConfirmationResponse;
/// let mut mock = target.expect_confirmation_feedback(
/// Some(200),
/// ConfirmationResponse::Confirmed,
/// vec!["Update accepted"],
pub fn expect_confirmation_feedback(
code: Option<i32>,
confirmation: ConfirmationResponse,
let expected = match code {
Some(code) => json!({
"confirmation": confirmation,
"code": code,
"/{}/controller/v1/{}/confirmationBase/{}/feedback",
/// Push a cancel action update to the target.
/// One can then use [`Target::cancel_action_hits`] to check that the client
/// fetched the details about the cancel action.
/// target.cancel_action("5");
/// // Client fetches details about the cancel action
/// //assert_eq!(target.cancel_action_hits(), 1);
pub fn cancel_action(&self, id: &str) {
let cancel_path = self.server.url(format!(
"/DEFAULT/controller/v1/{}/cancelAction/{}",
self.name, id
"id": id,
"cancelAction": {
"stopId": id
let cancel_mock = self.server.mock(|when, then| {
self.cancel_action.replace(Some(PendingAction {
path: cancel_path,
mock: cancel_mock.id(),
/// Configure the server to expect cancel feedback from the target.
/// target.cancel_action("10");
/// let mut mock = target.expect_cancel_feedback(
/// vec!["Cancelled"],
pub fn expect_cancel_feedback(
cancel_id: &str,
let expected = json!({
"id": cancel_id,
"/{}/controller/v1/{}/cancelAction/{}/feedback",
self.tenant, self.name, cancel_id
/// Return the number of times the poll API has been called by the client.
pub fn poll_hits(&self) -> usize {
let mock = Mock::new(self.poll.get(), &self.server);
mock.calls()
/// Return the number of times the target configuration has been uploaded by the client.
pub fn config_data_hits(&self) -> usize {
self.config_data.borrow().as_ref().map_or(0, |m| {
let mock = Mock::new(m.mock, &self.server);
/// Return the number of times the deployment details have been fetched by the client.
pub fn deployment_hits(&self) -> usize {
self.deployment.borrow().as_ref().map_or(0, |m| {
/// Return the number of times the confirmation details have been fetched by the client.
pub fn confirmation_hits(&self) -> usize {
self.confirmation.borrow().as_ref().map_or(0, |m| {
/// Return the number of times the cancel action URL has been fetched by the client.
pub fn cancel_action_hits(&self) -> usize {
self.cancel_action.borrow().as_ref().map_or(0, |m| {
struct PendingAction {
mock: usize,
path: String,
impl Drop for PendingAction {
fn drop(&mut self) {
let mut mock = Mock::new(self.mock, &self.server);
mock.delete();
/// Builder of [`Deployment`].
pub struct DeploymentBuilder {
id: String,
confirmation_required: bool,
download_type: Type,
update_type: Type,
maintenance_window: Option<MaintenanceWindow>,
chunks: Vec<Chunk>,
/// A pending deployment update pushed to the target.
pub struct Deployment {
/// The id of the deployment
pub id: String,
impl DeploymentBuilder {
/// Start building a new [`Deployment`].
pub fn new(id: &str, download_type: Type, update_type: Type) -> Self {
id: id.to_string(),
confirmation_required: false,
download_type,
update_type,
maintenance_window: None,
chunks: Vec::new(),
/// Set whether the deployment requires confirmation from the target before downloading.
pub fn confirmation_required(self, confirmation_required: bool) -> Self {
builder.confirmation_required = confirmation_required;
/// Set the maintenance window status of the deployment.
pub fn maintenance_window(self, maintenance_window: MaintenanceWindow) -> Self {
builder.maintenance_window = Some(maintenance_window);
/// Add a new software chunk to the deployment.
/// # Arguments
/// * `protocol`: The protocols over which chunks are downloadable
/// * `part`: the type of chunk, e.g. `firmware`, `bundle`, `app`
/// * `version`: software version of the chunk
/// * `name`: name of the chunk
/// * `artifacts`: a [`Vec`] of tuples containing:
/// * the local path of the file;
/// * the `md5sum` of the file;
/// * the `sha1sum` of the file;
/// * the `sha256sum` of the file.
pub fn chunk(
self,
protocol: ChunkProtocol,
part: &str,
version: &str,
artifacts: Vec<(PathBuf, &str, &str, &str)>,
let artifacts = artifacts
.into_iter()
.map(|(path, md5, sha1, sha256)| {
assert!(path.exists());
(path, md5.to_string(), sha1.to_string(), sha256.to_string())
.collect();
let chunk = Chunk {
protocol,
part: part.to_string(),
version: version.to_string(),
artifacts,
mock: None,
metadata: None,
builder.chunks.push(chunk);
/// * `metadata`: a [`Vec`] of pairs containing:
/// * the key of the metadata;
/// * the value of the metadata.
pub fn chunk_with_metadata(
metadata: Vec<(String, String)>,
metadata: Some(
metadata
.map(|(key, value)| Metadata { key, value })
.collect(),
),
/// * `mock`: a custom mock function that is used to create the mock
/// for the download endpoint.
pub fn chunk_with_mock(
mock: Box<dyn Fn(When, Then)>,
mock: Some(mock),
/// Create the [`Deployment`].
pub fn build(self) -> Deployment {
Deployment {
id: self.id,
confirmation_required: self.confirmation_required,
download_type: self.download_type,
update_type: self.update_type,
maintenance_window: self.maintenance_window,
chunks: self.chunks,
/// Protocol(s) over which chunks are served
pub enum ChunkProtocol {
/// Both http and https
BOTH,
/// Http only
HTTP,
/// Https only
HTTPS,
impl ChunkProtocol {
/// Return whether the http protocol is used for downloads
pub fn http(&self) -> bool {
matches!(self, Self::BOTH | Self::HTTP)
/// Return whether the https protocol is used for downloads
pub fn https(&self) -> bool {
matches!(self, Self::BOTH | Self::HTTPS)
/// Key-value pair of metadata of a software set
pub struct Metadata {
key: String,
value: String,
impl Metadata {
fn json(&self) -> serde_json::Value {
json!({
"key": self.key,
"value": self.value,
/// Software chunk of an update.
pub struct Chunk {
part: String,
version: String,
name: String,
artifacts: Vec<(PathBuf, String, String, String)>, // (path, md5, sha1, sha256)
metadata: Option<Vec<Metadata>>,
mock: Option<Box<dyn Fn(When, Then)>>,
impl Chunk {
fn json(&self, base_url: &str) -> serde_json::Value {
let artifacts: Vec<serde_json::Value> = self
.artifacts
.iter()
let meta = path.metadata().unwrap();
let file_name = path.file_name().unwrap().to_str().unwrap();
let download_url = format!("{}/{}", base_url, file_name);
// TODO: the md5 url is not served by the http server
let md5_url = format!("{}.MD5SUM", download_url);
let mut links = serde_json::Map::new();
if self.protocol.https() {
links.insert("download".to_string(), json!({ "href": download_url }));
links.insert("md5sum".to_string(), json!({ "href": md5_url }));
if self.protocol.http() {
links.insert("download-http".to_string(), json!({ "href": download_url }));
links.insert("md5sum-http".to_string(), json!({ "href": md5_url }));
"filename": file_name,
"hashes": {
"sha1": sha1,
"md5": md5,
"sha256": sha256,
"size": meta.len(),
"_links": links,
let mut result = json!({
"part": self.part,
"version": self.version,
"name": self.name,
"artifacts": artifacts,
if let Some(metadata) = &self.metadata {
let metadata: Vec<serde_json::Value> = metadata.iter().map(|m| m.json()).collect();
result
.as_object_mut()
.unwrap()
.insert("metadata".to_string(), json!(metadata));
impl Deployment {
let chunks: Vec<serde_json::Value> = self.chunks.iter().map(|c| c.json(base_url)).collect();
let mut j = if self.confirmation_required {
"id": self.id,
"confirmation": {
"download": self.download_type,
"update": self.update_type,
"chunks": chunks,
"deployment": {
if let Some(maintenance_window) = &self.maintenance_window {
let d = j
.get_mut(if self.confirmation_required {
"confirmation"
"deployment"
.unwrap();
d.insert("maintenanceWindow".to_string(), json!(maintenance_window));
j