1
// Copyright 2020, Collabora Ltd.
2
// SPDX-License-Identifier: MIT OR Apache-2.0
3

            
4
//! [Direct Device Integration](https://www.eclipse.org/hawkbit/apis/ddi_api/) mock server.
5
//!
6
//! This module provides a hawkBit mock server implementing the [DDI API](https://www.eclipse.org/hawkbit/apis/ddi_api/).
7
//! It can be instrumented to test any hawkbit client.
8
//!
9
//! # Examples
10
//!
11
//! ```
12
//! use hawkbit_mock::ddi::ServerBuilder;
13
//!
14
//! let server = ServerBuilder::default().build();
15
//! let target = server.add_target("Target1");
16
//! ```
17
//!
18
//! You can tell call [`Target::request_config`] or [`Target::push_deployment`] to
19
//! to interact with the server.
20
//!
21
//! Check the the hawbit crate for actual tests using this mock server.
22

            
23
// FIXME: set link to hawbit/tests/tests.rs once we have the final public repo
24

            
25
use std::rc::Rc;
26
use std::{
27
    cell::{Cell, RefCell},
28
    path::PathBuf,
29
};
30

            
31
use httpmock::{
32
    Method::{GET, POST, PUT},
33
    Mock, MockExt, MockServer,
34
};
35
use httpmock::{Then, When};
36
use serde_json::{json, Map, Value};
37

            
38
use hawkbit::ddi::{
39
    ClientAuthorization, ConfirmationResponse, Execution, Finished, MaintenanceWindow, Type,
40
};
41

            
42
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
43
/// Authorization method that is required in requests by the target
44
pub enum TargetAuthorization {
45
    /// No authorization
46
    None,
47
    /// Require a target token
48
    TargetToken,
49
    /// Require a gateway token
50
    GatewayToken,
51
}
52

            
53
/// Builder of [`Server`].
54
///
55
/// # Examples
56
///
57
/// ```
58
/// use hawkbit_mock::ddi::ServerBuilder;
59
///
60
/// let server = ServerBuilder::default().build();
61
/// ```
62
pub struct ServerBuilder {
63
    tenant: String,
64
    target_authorization: TargetAuthorization,
65
}
66

            
67
impl Default for ServerBuilder {
68
30
    fn default() -> Self {
69
30
        Self {
70
30
            tenant: "DEFAULT".into(),
71
30
            target_authorization: TargetAuthorization::TargetToken,
72
30
        }
73
30
    }
74
}
75

            
76
impl ServerBuilder {
77
    /// Set the tenant of the server, default to `DEFAULT`.
78
2
    pub fn tenant(self, tenant: &str) -> Self {
79
2
        let mut builder = self;
80
2
        builder.tenant = tenant.to_string();
81
2
        builder
82
2
    }
83

            
84
    /// Set the client authorization method, default to `TargetToken`.
85
8
    pub fn target_authorization(self, target_authorization: TargetAuthorization) -> Self {
86
8
        let mut builder = self;
87
8
        builder.target_authorization = target_authorization;
88
8
        builder
89
8
    }
90

            
91
    /// Create the [`Server`].
92
30
    pub fn build(self) -> Server {
93
30
        Server {
94
30
            server: Rc::new(MockServer::start()),
95
30
            tenant: self.tenant,
96
30
            target_authorization: self.target_authorization,
97
30
        }
98
30
    }
99
}
100

            
101
/// Mock DDI server instance.
102
pub struct Server {
103
    /// The tenant of the server.
104
    pub tenant: String,
105
    /// The authorization method of the server.
106
    pub target_authorization: TargetAuthorization,
107
    server: Rc<MockServer>,
108
}
109

            
110
impl Server {
111
    /// The base URL of the server, such as `http://my-server.com:8080`
112
44
    pub fn base_url(&self) -> String {
113
44
        self.server.base_url()
114
44
    }
115

            
116
    /// Add a new target named `name` to the server.
117
30
    pub fn add_target(&self, name: &str) -> Target {
118
30
        let client_auth = match self.target_authorization {
119
4
            TargetAuthorization::None => ClientAuthorization::None,
120
            TargetAuthorization::TargetToken => {
121
24
                ClientAuthorization::TargetToken(format!("Key{}", name))
122
            }
123
            TargetAuthorization::GatewayToken => {
124
2
                ClientAuthorization::GatewayToken(format!("Key{}", self.tenant))
125
            }
126
        };
127
30
        Target::new(name, &self.server, &self.tenant, &client_auth)
128
30
    }
129
}
130

            
131
/// A configured device the server can request configuration for and push updates to.
132
pub struct Target {
133
    /// The name of the target.
134
    pub name: String,
135
    /// The secret authentification token used to identify the target on the server.
136
    pub client_auth: ClientAuthorization,
137
    server: Rc<MockServer>,
138
    tenant: String,
139
    poll: Cell<usize>,
140
    config_data: RefCell<Option<PendingAction>>,
141
    confirmation: RefCell<Option<PendingAction>>,
142
    deployment: RefCell<Option<PendingAction>>,
143
    cancel_action: RefCell<Option<PendingAction>>,
144
}
145

            
146
impl Target {
147
30
    fn new(
148
30
        name: &str,
149
30
        server: &Rc<MockServer>,
150
30
        tenant: &str,
151
30
        client_auth: &ClientAuthorization,
152
30
    ) -> Self {
153
30
        let poll = Self::create_poll(server, tenant, name, client_auth, None, None, None, None);
154
30
        Target {
155
30
            name: name.to_string(),
156
30
            client_auth: client_auth.clone(),
157
30
            server: server.clone(),
158
30
            tenant: tenant.to_string(),
159
30
            poll: Cell::new(poll),
160
30
            config_data: RefCell::new(None),
161
30
            confirmation: RefCell::new(None),
162
30
            deployment: RefCell::new(None),
163
30
            cancel_action: RefCell::new(None),
164
30
        }
165
30
    }
166

            
167
    #[allow(clippy::too_many_arguments)]
168
52
    fn create_poll(
169
52
        server: &MockServer,
170
52
        tenant: &str,
171
52
        name: &str,
172
52
        client_auth: &ClientAuthorization,
173
52
        expected_config_data: Option<&PendingAction>,
174
52
        confirmation: Option<&PendingAction>,
175
52
        deployment: Option<&PendingAction>,
176
52
        cancel_action: Option<&PendingAction>,
177
52
    ) -> usize {
178
52
        let mut links = Map::new();
179

            
180
52
        if let Some(pending) = expected_config_data {
181
6
            links.insert("configData".into(), json!({ "href": pending.path }));
182
46
        }
183
52
        if let Some(pending) = confirmation {
184
4
            links.insert("confirmationBase".into(), json!({ "href": pending.path }));
185
48
        }
186
52
        if let Some(pending) = deployment {
187
12
            links.insert("deploymentBase".into(), json!({ "href": pending.path }));
188
40
        }
189
52
        if let Some(pending) = cancel_action {
190
2
            links.insert("cancelAction".into(), json!({ "href": pending.path }));
191
50
        }
192

            
193
52
        let response = json!({
194
52
            "config": {
195
52
                "polling": {
196
52
                    "sleep": "00:01:00"
197
                }
198
            },
199
52
            "_links": links
200
        });
201

            
202
78
        let mock = server.mock(|when, then| {
203
52
            let when = when
204
52
                .method(GET)
205
52
                .path(format!("/{}/controller/v1/{}", tenant, name));
206

            
207
52
            match client_auth {
208
4
                ClientAuthorization::None => { /* do not require Authorization header */ }
209
46
                ClientAuthorization::TargetToken(key) => {
210
46
                    when.header("Authorization", format!("TargetToken {}", key));
211
46
                }
212
2
                ClientAuthorization::GatewayToken(key) => {
213
2
                    when.header("Authorization", format!("GatewayToken {}", key));
214
2
                }
215
            };
216

            
217
52
            then.status(200)
218
52
                .header("Content-Type", "application/json")
219
52
                .json_body(response);
220
52
        });
221

            
222
52
        mock.id()
223
52
    }
224

            
225
22
    fn update_poll(&self) {
226
22
        let old = self.poll.replace(Self::create_poll(
227
22
            &self.server,
228
22
            &self.tenant,
229
22
            &self.name,
230
22
            &self.client_auth,
231
22
            self.config_data.borrow().as_ref(),
232
22
            self.confirmation.borrow().as_ref(),
233
22
            self.deployment.borrow().as_ref(),
234
22
            self.cancel_action.borrow().as_ref(),
235
        ));
236

            
237
22
        let mut old = Mock::new(old, &self.server);
238
22
        old.delete();
239
22
    }
240

            
241
    /// Request the target to upload its configuration to the server.
242
    /// One can then use [`Target::config_data_hits`] to check that the client
243
    /// uploaded its configuration and that it matches the one passed as `expected_config_data`.
244
    ///
245
    /// # Examples
246
    ///
247
    /// ```
248
    /// use hawkbit_mock::ddi::ServerBuilder;
249
    /// use serde_json::json;
250
    ///
251
    /// let server = ServerBuilder::default().build();
252
    /// let target = server.add_target("Target1");
253
    /// let expected_config_data = json!({
254
    ///         "mode" : "merge",
255
    ///         "data" : {
256
    ///             "awesome" : true,
257
    ///         },
258
    ///         "status" : {
259
    ///             "result" : {
260
    ///             "finished" : "success"
261
    ///             },
262
    ///             "execution" : "closed",
263
    ///             "details" : [ "Some stuffs" ]
264
    ///         }
265
    ///     });
266
    /// target.request_config(expected_config_data);
267
    ///
268
    /// // Client handles the request and upload its configuration
269
    /// //assert_eq!(target.config_data_hits(), 1);
270
    /// ```
271
4
    pub fn request_config(&self, expected_config_data: Value) {
272
4
        let config_path = self
273
4
            .server
274
4
            .url(format!("/DEFAULT/controller/v1/{}/configData", self.name));
275

            
276
6
        let config_data = self.server.mock(|when, then| {
277
4
            let when = when
278
4
                .method(PUT)
279
4
                .path(format!("/DEFAULT/controller/v1/{}/configData", self.name))
280
4
                .header("Content-Type", "application/json")
281
4
                .json_body(expected_config_data);
282

            
283
4
            match &self.client_auth {
284
                ClientAuthorization::None => { /* do not require Authorization header */ }
285
4
                ClientAuthorization::TargetToken(key) => {
286
4
                    when.header("Authorization", format!("TargetToken {}", key));
287
4
                }
288
                ClientAuthorization::GatewayToken(key) => {
289
                    when.header("Authorization", format!("GatewayToken {}", key));
290
                }
291
            };
292

            
293
4
            then.status(200);
294
4
        });
295

            
296
4
        self.config_data.replace(Some(PendingAction {
297
4
            server: self.server.clone(),
298
4
            path: config_path,
299
4
            mock: config_data.id(),
300
4
        }));
301

            
302
4
        self.update_poll();
303
4
    }
304

            
305
    /// Push a deployment update to the target.
306
    /// One can then use [`Target::config_data_hits`] to check that the client
307
    /// retrieve the deployment details as expected.
308
    ///
309
    /// # Examples
310
    ///
311
    /// ```
312
    /// use std::path::Path;
313
    /// use hawkbit_mock::ddi::{ChunkProtocol, ServerBuilder, DeploymentBuilder};
314
    /// use hawkbit::ddi::{Type, MaintenanceWindow};
315
    ///
316
    /// let server = ServerBuilder::default().build();
317
    /// let target = server.add_target("Target1");
318
    ///
319
    /// let deployment = DeploymentBuilder::new("10", Type::Forced, Type::Attempt)
320
    ///    .maintenance_window(MaintenanceWindow::Available)
321
    ///    .chunk(
322
    ///       ChunkProtocol::BOTH,
323
    ///       "app",
324
    ///       "1.0",
325
    ///        "some-chunk",
326
    ///        vec![(
327
    ///            Path::new("README.md").to_path_buf(),
328
    ///            "42cf69051362d8fa2883cc9b56799fa4",
329
    ///            "16da060b7ff443a6b3a7662ad21a9b3023c12627",
330
    ///            "5010fbc2769bfc655d15aa9a883703d5b19a320732d37f70703ab3e3b416a602",
331
    ///        )],
332
    ///   )
333
    ///    .build();
334
    /// target.push_deployment(deployment);
335
    ///
336
    /// // Client handles the update and fetch details
337
    /// //assert_eq!(target.deployment_hits(), 1);
338
    /// ```
339
16
    pub fn push_deployment(&self, deploy: Deployment) {
340
16
        if deploy.confirmation_required {
341
4
            let confirmation_path = self.server.url(format!(
342
4
                "/DEFAULT/controller/v1/{}/confirmationBase/{}",
343
4
                self.name, deploy.id
344
4
            ));
345

            
346
4
            let base_url = self.server.url("/download");
347
4
            let response = deploy.json(&base_url);
348

            
349
6
            let confirmation_mock = self.server.mock(|when, then| {
350
4
                let when = when.method(GET).path(format!(
351
                    "/DEFAULT/controller/v1/{}/confirmationBase/{}",
352
                    self.name, deploy.id
353
                ));
354

            
355
4
                match &self.client_auth {
356
                    ClientAuthorization::None => { /* do not require Authorization header */ }
357
4
                    ClientAuthorization::TargetToken(key) => {
358
4
                        when.header("Authorization", format!("TargetToken {}", key));
359
4
                    }
360
                    ClientAuthorization::GatewayToken(key) => {
361
                        when.header("Authorization", format!("GatewayToken {}", key));
362
                    }
363
                };
364

            
365
4
                then.status(200)
366
4
                    .header("Content-Type", "application/json")
367
4
                    .json_body(response);
368
4
            });
369

            
370
4
            self.deployment.replace(None);
371
4
            self.confirmation.replace(Some(PendingAction {
372
4
                server: self.server.clone(),
373
4
                path: confirmation_path,
374
4
                mock: confirmation_mock.id(),
375
4
            }));
376
        } else {
377
12
            let deploy_path = self.server.url(format!(
378
12
                "/DEFAULT/controller/v1/{}/deploymentBase/{}",
379
12
                self.name, deploy.id
380
12
            ));
381

            
382
12
            let base_url = self.server.url("/download");
383
12
            let response = deploy.json(&base_url);
384

            
385
18
            let deploy_mock = self.server.mock(|when, then| {
386
12
                let when = when.method(GET).path(format!(
387
                    "/DEFAULT/controller/v1/{}/deploymentBase/{}",
388
                    self.name, deploy.id
389
                ));
390

            
391
12
                match &self.client_auth {
392
                    ClientAuthorization::None => { /* do not require Authorization header */ }
393
12
                    ClientAuthorization::TargetToken(key) => {
394
12
                        when.header("Authorization", format!("TargetToken {}", key));
395
12
                    }
396
                    ClientAuthorization::GatewayToken(key) => {
397
                        when.header("Authorization", format!("GatewayToken {}", key));
398
                    }
399
                };
400

            
401
12
                then.status(200)
402
12
                    .header("Content-Type", "application/json")
403
12
                    .json_body(response);
404
12
            });
405

            
406
            // Serve the artifacts
407
32
            for chunk in deploy.chunks.iter() {
408
32
                for (artifact, _md5, _sha1, _sha256) in chunk.artifacts.iter() {
409
32
                    let file_name = artifact.file_name().unwrap().to_str().unwrap();
410
32
                    let path = format!("/download/{}", file_name);
411

            
412
32
                    if let Some(mock_fn) = &chunk.mock {
413
2
                        self.server.mock(mock_fn);
414
2
                    } else {
415
45
                        self.server.mock(|when, then| {
416
30
                            let when = when.method(GET).path(path);
417

            
418
30
                            match &self.client_auth {
419
                                ClientAuthorization::None => { /* do not require Authorization header */
420
                                }
421
30
                                ClientAuthorization::TargetToken(key) => {
422
30
                                    when.header("Authorization", format!("TargetToken {}", key));
423
30
                                }
424
                                ClientAuthorization::GatewayToken(key) => {
425
                                    when.header("Authorization", format!("GatewayToken {}", key));
426
                                }
427
                            };
428

            
429
30
                            then.status(200).body_from_file(artifact.to_str().unwrap());
430
30
                        });
431
                    }
432
                }
433
            }
434

            
435
12
            self.confirmation.replace(None);
436
12
            self.deployment.replace(Some(PendingAction {
437
12
                server: self.server.clone(),
438
12
                path: deploy_path,
439
12
                mock: deploy_mock.id(),
440
12
            }));
441
        }
442

            
443
16
        self.update_poll();
444
16
    }
445

            
446
    /// Configure the server to expect deployment feedback from the target.
447
    /// One can then check the feedback has actually been received using
448
    /// `hits()` on the returned object.
449
    ///
450
    /// # Examples
451
    ///
452
    /// ```
453
    /// use hawkbit_mock::ddi::{ServerBuilder, DeploymentBuilder};
454
    /// use hawkbit::ddi::{Execution, Finished};
455
    /// use serde_json::json;
456
    ///
457
    /// let server = ServerBuilder::default().build();
458
    /// let target = server.add_target("Target1");
459
    /// let mut mock = target.expect_deployment_feedback(
460
    ///         "10",
461
    ///         Execution::Closed,
462
    ///         Finished::Success,
463
    ///         Some(json!({"awesome": true})),
464
    ///         vec!["Done"],
465
    ///     );
466
    /// assert_eq!(mock.hits(), 0);
467
    ///
468
    /// //Client send the feedback
469
    /// //assert_eq!(mock.hits(), 1);
470
    /// ```
471
4
    pub fn expect_deployment_feedback(
472
4
        &self,
473
4
        deployment_id: &str,
474
4
        execution: Execution,
475
4
        finished: Finished,
476
4
        progress: Option<serde_json::Value>,
477
4
        details: Vec<&str>,
478
4
    ) -> Mock<'_> {
479
6
        self.server.mock(|when, then| {
480
4
            let expected = match progress {
481
2
                Some(progress) => json!({
482
2
                    "id": deployment_id,
483
2
                    "status": {
484
2
                        "result": {
485
2
                            "progress": progress,
486
2
                            "finished": finished
487
                        },
488
2
                        "execution": execution,
489
2
                        "details": details,
490
                    },
491
                }),
492
2
                None => json!({
493
2
                    "id": deployment_id,
494
2
                    "status": {
495
2
                        "result": {
496
2
                            "finished": finished
497
                        },
498
2
                        "execution": execution,
499
2
                        "details": details,
500
                    },
501
                }),
502
            };
503

            
504
4
            let when = when
505
4
                .method(POST)
506
4
                .path(format!(
507
                    "/{}/controller/v1/{}/deploymentBase/{}/feedback",
508
                    self.tenant, self.name, deployment_id
509
                ))
510
4
                .header("Content-Type", "application/json")
511
4
                .json_body(expected);
512

            
513
4
            match &self.client_auth {
514
                ClientAuthorization::None => { /* do not require Authorization header */ }
515
4
                ClientAuthorization::TargetToken(key) => {
516
4
                    when.header("Authorization", format!("TargetToken {}", key));
517
4
                }
518
                ClientAuthorization::GatewayToken(key) => {
519
                    when.header("Authorization", format!("GatewayToken {}", key));
520
                }
521
            };
522

            
523
4
            then.status(200);
524
4
        })
525
4
    }
526

            
527
    /// Configure the server to expect confirmation feedback from the target.
528
    /// One can then check the feedback has actually been received using
529
    /// `hits()` on the returned object.
530
    ///
531
    /// # Examples
532
    ///
533
    /// ```
534
    /// use hawkbit_mock::ddi::{ServerBuilder, DeploymentBuilder};
535
    /// use hawkbit::ddi::ConfirmationResponse;
536
    /// use serde_json::json;
537
    ///
538
    /// let server = ServerBuilder::default().build();
539
    /// let target = server.add_target("Target1");
540
    /// let mut mock = target.expect_confirmation_feedback(
541
    ///         "10",
542
    ///         Some(200),
543
    ///         ConfirmationResponse::Confirmed,
544
    ///         vec!["Update accepted"],
545
    ///     );
546
    /// assert_eq!(mock.hits(), 0);
547
    ///
548
    /// //Client send the feedback
549
    /// //assert_eq!(mock.hits(), 1);
550
    /// ```
551
4
    pub fn expect_confirmation_feedback(
552
4
        &self,
553
4
        deployment_id: &str,
554
4
        code: Option<i32>,
555
4
        confirmation: ConfirmationResponse,
556
4
        details: Vec<&str>,
557
4
    ) -> Mock<'_> {
558
6
        self.server.mock(|when, then| {
559
4
            let expected = match code {
560
4
                Some(code) => json!({
561
4
                    "confirmation": confirmation,
562
4
                    "code": code,
563
4
                    "details": details,
564
                }),
565
                None => json!({
566
                    "confirmation": confirmation,
567
                    "details": details,
568
                }),
569
            };
570

            
571
4
            let when = when
572
4
                .method(POST)
573
4
                .path(format!(
574
                    "/{}/controller/v1/{}/confirmationBase/{}/feedback",
575
                    self.tenant, self.name, deployment_id
576
                ))
577
4
                .header("Content-Type", "application/json")
578
4
                .json_body(expected);
579

            
580
4
            match &self.client_auth {
581
                ClientAuthorization::None => { /* do not require Authorization header */ }
582
4
                ClientAuthorization::TargetToken(key) => {
583
4
                    when.header("Authorization", format!("TargetToken {}", key));
584
4
                }
585
                ClientAuthorization::GatewayToken(key) => {
586
                    when.header("Authorization", format!("GatewayToken {}", key));
587
                }
588
            };
589

            
590
4
            then.status(200);
591
4
        })
592
4
    }
593

            
594
    /// Push a cancel action update to the target.
595
    /// One can then use [`Target::cancel_action_hits`] to check that the client
596
    /// fetched the details about the cancel action.
597
    ///
598
    /// # Examples
599
    ///
600
    /// ```
601
    /// use hawkbit_mock::ddi::ServerBuilder;
602
    ///
603
    /// let server = ServerBuilder::default().build();
604
    /// let target = server.add_target("Target1");
605
    /// target.cancel_action("5");
606
    ///
607
    /// // Client fetches details about the cancel action
608
    /// //assert_eq!(target.cancel_action_hits(), 1);
609
    /// ```
610
2
    pub fn cancel_action(&self, id: &str) {
611
2
        let cancel_path = self.server.url(format!(
612
2
            "/DEFAULT/controller/v1/{}/cancelAction/{}",
613
2
            self.name, id
614
2
        ));
615

            
616
2
        let response = json!({
617
2
            "id": id,
618
2
            "cancelAction": {
619
2
                "stopId": id
620
            }
621
        });
622

            
623
3
        let cancel_mock = self.server.mock(|when, then| {
624
2
            let when = when.method(GET).path(format!(
625
                "/DEFAULT/controller/v1/{}/cancelAction/{}",
626
                self.name, id
627
            ));
628

            
629
2
            match &self.client_auth {
630
                ClientAuthorization::None => { /* do not require Authorization header */ }
631
2
                ClientAuthorization::TargetToken(key) => {
632
2
                    when.header("Authorization", format!("TargetToken {}", key));
633
2
                }
634
                ClientAuthorization::GatewayToken(key) => {
635
                    when.header("Authorization", format!("GatewayToken {}", key));
636
                }
637
            };
638

            
639
2
            then.status(200)
640
2
                .header("Content-Type", "application/json")
641
2
                .json_body(response);
642
2
        });
643

            
644
2
        self.cancel_action.replace(Some(PendingAction {
645
2
            server: self.server.clone(),
646
2
            path: cancel_path,
647
2
            mock: cancel_mock.id(),
648
2
        }));
649

            
650
2
        self.update_poll();
651
2
    }
652

            
653
    /// Configure the server to expect cancel feedback from the target.
654
    /// One can then check the feedback has actually been received using
655
    /// `hits()` on the returned object.
656
    ///
657
    /// # Examples
658
    ///
659
    /// ```
660
    /// use hawkbit_mock::ddi::{ServerBuilder, DeploymentBuilder};
661
    /// use hawkbit::ddi::{Execution, Finished};
662
    /// use serde_json::json;
663
    ///
664
    /// let server = ServerBuilder::default().build();
665
    /// let target = server.add_target("Target1");
666
    /// target.cancel_action("10");
667
    ///
668
    /// let mut mock = target.expect_cancel_feedback(
669
    ///         "10",
670
    ///         Execution::Closed,
671
    ///         Finished::Success,
672
    ///         vec!["Cancelled"],
673
    ///     );
674
    /// assert_eq!(mock.hits(), 0);
675
    ///
676
    /// //Client send the feedback
677
    /// //assert_eq!(mock.hits(), 1);
678
    /// ```
679
2
    pub fn expect_cancel_feedback(
680
2
        &self,
681
2
        cancel_id: &str,
682
2
        execution: Execution,
683
2
        finished: Finished,
684
2
        details: Vec<&str>,
685
2
    ) -> Mock<'_> {
686
3
        self.server.mock(|when, then| {
687
2
            let expected = json!({
688
2
                "id": cancel_id,
689
2
                "status": {
690
2
                    "result": {
691
2
                        "finished": finished
692
                    },
693
2
                    "execution": execution,
694
2
                    "details": details,
695
                },
696
            });
697

            
698
2
            let when = when
699
2
                .method(POST)
700
2
                .path(format!(
701
                    "/{}/controller/v1/{}/cancelAction/{}/feedback",
702
                    self.tenant, self.name, cancel_id
703
                ))
704
2
                .header("Content-Type", "application/json")
705
2
                .json_body(expected);
706

            
707
2
            match &self.client_auth {
708
                ClientAuthorization::None => { /* do not require Authorization header */ }
709
2
                ClientAuthorization::TargetToken(key) => {
710
2
                    when.header("Authorization", format!("TargetToken {}", key));
711
2
                }
712
                ClientAuthorization::GatewayToken(key) => {
713
                    when.header("Authorization", format!("GatewayToken {}", key));
714
                }
715
            };
716

            
717
2
            then.status(200);
718
2
        })
719
2
    }
720

            
721
    /// Return the number of times the poll API has been called by the client.
722
10
    pub fn poll_hits(&self) -> usize {
723
10
        let mock = Mock::new(self.poll.get(), &self.server);
724
10
        mock.calls()
725
10
    }
726

            
727
    /// Return the number of times the target configuration has been uploaded by the client.
728
2
    pub fn config_data_hits(&self) -> usize {
729
3
        self.config_data.borrow().as_ref().map_or(0, |m| {
730
2
            let mock = Mock::new(m.mock, &self.server);
731
2
            mock.calls()
732
2
        })
733
2
    }
734

            
735
    /// Return the number of times the deployment details have been fetched by the client.
736
8
    pub fn deployment_hits(&self) -> usize {
737
12
        self.deployment.borrow().as_ref().map_or(0, |m| {
738
8
            let mock = Mock::new(m.mock, &self.server);
739
8
            mock.calls()
740
8
        })
741
8
    }
742

            
743
    /// Return the number of times the confirmation details have been fetched by the client.
744
    pub fn confirmation_hits(&self) -> usize {
745
        self.confirmation.borrow().as_ref().map_or(0, |m| {
746
            let mock = Mock::new(m.mock, &self.server);
747
            mock.calls()
748
        })
749
    }
750

            
751
    /// Return the number of times the cancel action URL has been fetched by the client.
752
2
    pub fn cancel_action_hits(&self) -> usize {
753
3
        self.cancel_action.borrow().as_ref().map_or(0, |m| {
754
2
            let mock = Mock::new(m.mock, &self.server);
755
2
            mock.calls()
756
2
        })
757
2
    }
758
}
759

            
760
struct PendingAction {
761
    server: Rc<MockServer>,
762
    mock: usize,
763
    path: String,
764
}
765

            
766
impl Drop for PendingAction {
767
22
    fn drop(&mut self) {
768
22
        let mut mock = Mock::new(self.mock, &self.server);
769
22
        mock.delete();
770
22
    }
771
}
772

            
773
/// Builder of [`Deployment`].
774
pub struct DeploymentBuilder {
775
    id: String,
776
    confirmation_required: bool,
777
    download_type: Type,
778
    update_type: Type,
779
    maintenance_window: Option<MaintenanceWindow>,
780
    chunks: Vec<Chunk>,
781
}
782

            
783
/// A pending deployment update pushed to the target.
784
pub struct Deployment {
785
    /// The id of the deployment
786
    pub id: String,
787
    confirmation_required: bool,
788
    download_type: Type,
789
    update_type: Type,
790
    maintenance_window: Option<MaintenanceWindow>,
791
    chunks: Vec<Chunk>,
792
}
793

            
794
impl DeploymentBuilder {
795
    /// Start building a new [`Deployment`].
796
16
    pub fn new(id: &str, download_type: Type, update_type: Type) -> Self {
797
16
        Self {
798
16
            id: id.to_string(),
799
16
            confirmation_required: false,
800
16
            download_type,
801
16
            update_type,
802
16
            maintenance_window: None,
803
16
            chunks: Vec::new(),
804
16
        }
805
16
    }
806

            
807
    /// Set whether the deployment requires confirmation from the target before downloading.
808
16
    pub fn confirmation_required(self, confirmation_required: bool) -> Self {
809
16
        let mut builder = self;
810
16
        builder.confirmation_required = confirmation_required;
811
16
        builder
812
16
    }
813

            
814
    /// Set the maintenance window status of the deployment.
815
16
    pub fn maintenance_window(self, maintenance_window: MaintenanceWindow) -> Self {
816
16
        let mut builder = self;
817
16
        builder.maintenance_window = Some(maintenance_window);
818
16
        builder
819
16
    }
820

            
821
    /// Add a new software chunk to the deployment.
822
    /// # Arguments
823
    /// * `protocol`: The protocols over which chunks are downloadable
824
    /// * `part`: the type of chunk, e.g. `firmware`, `bundle`, `app`
825
    /// * `version`: software version of the chunk
826
    /// * `name`: name of the chunk
827
    /// * `artifacts`: a [`Vec`] of tuples containing:
828
    ///   * the local path of the file;
829
    ///   * the `md5sum` of the file;
830
    ///   * the `sha1sum` of the file;
831
    ///   * the `sha256sum` of the file.
832
14
    pub fn chunk(
833
14
        self,
834
14
        protocol: ChunkProtocol,
835
14
        part: &str,
836
14
        version: &str,
837
14
        name: &str,
838
14
        artifacts: Vec<(PathBuf, &str, &str, &str)>,
839
14
    ) -> Self {
840
14
        let mut builder = self;
841

            
842
14
        let artifacts = artifacts
843
14
            .into_iter()
844
21
            .map(|(path, md5, sha1, sha256)| {
845
14
                assert!(path.exists());
846
14
                (path, md5.to_string(), sha1.to_string(), sha256.to_string())
847
14
            })
848
14
            .collect();
849

            
850
14
        let chunk = Chunk {
851
14
            protocol,
852
14
            part: part.to_string(),
853
14
            version: version.to_string(),
854
14
            name: name.to_string(),
855
14
            artifacts,
856
14
            mock: None,
857
14
            metadata: None,
858
14
        };
859
14
        builder.chunks.push(chunk);
860

            
861
14
        builder
862
14
    }
863

            
864
    /// Add a new software chunk to the deployment.
865
    /// # Arguments
866
    /// * `protocol`: The protocols over which chunks are downloadable
867
    /// * `part`: the type of chunk, e.g. `firmware`, `bundle`, `app`
868
    /// * `version`: software version of the chunk
869
    /// * `name`: name of the chunk
870
    /// * `artifacts`: a [`Vec`] of tuples containing:
871
    ///   * the local path of the file;
872
    ///   * the `md5sum` of the file;
873
    ///   * the `sha1sum` of the file;
874
    ///   * the `sha256sum` of the file.
875
    /// * `metadata`: a [`Vec`] of pairs containing:
876
    ///   * the key of the metadata;
877
    ///   * the value of the metadata.
878
28
    pub fn chunk_with_metadata(
879
28
        self,
880
28
        protocol: ChunkProtocol,
881
28
        part: &str,
882
28
        version: &str,
883
28
        name: &str,
884
28
        artifacts: Vec<(PathBuf, &str, &str, &str)>,
885
28
        metadata: Vec<(String, String)>,
886
28
    ) -> Self {
887
28
        let mut builder = self;
888

            
889
28
        let artifacts = artifacts
890
28
            .into_iter()
891
42
            .map(|(path, md5, sha1, sha256)| {
892
28
                assert!(path.exists());
893
28
                (path, md5.to_string(), sha1.to_string(), sha256.to_string())
894
28
            })
895
28
            .collect();
896

            
897
28
        let chunk = Chunk {
898
28
            protocol,
899
28
            part: part.to_string(),
900
28
            version: version.to_string(),
901
28
            name: name.to_string(),
902
28
            artifacts,
903
            metadata: Some(
904
28
                metadata
905
28
                    .into_iter()
906
56
                    .map(|(key, value)| Metadata { key, value })
907
28
                    .collect(),
908
            ),
909
28
            mock: None,
910
        };
911
28
        builder.chunks.push(chunk);
912

            
913
28
        builder
914
28
    }
915

            
916
    /// Add a new software chunk to the deployment.
917
    /// # Arguments
918
    /// * `protocol`: The protocols over which chunks are downloadable
919
    /// * `part`: the type of chunk, e.g. `firmware`, `bundle`, `app`
920
    /// * `version`: software version of the chunk
921
    /// * `name`: name of the chunk
922
    /// * `artifacts`: a [`Vec`] of tuples containing:
923
    ///   * the local path of the file;
924
    ///   * the `md5sum` of the file;
925
    ///   * the `sha1sum` of the file;
926
    ///   * the `sha256sum` of the file.
927
    /// * `mock`: a custom mock function that is used to create the mock
928
    ///   for the download endpoint.
929
2
    pub fn chunk_with_mock(
930
2
        self,
931
2
        protocol: ChunkProtocol,
932
2
        part: &str,
933
2
        version: &str,
934
2
        name: &str,
935
2
        artifacts: Vec<(PathBuf, &str, &str, &str)>,
936
2
        mock: Box<dyn Fn(When, Then)>,
937
2
    ) -> Self {
938
2
        let mut builder = self;
939

            
940
2
        let artifacts = artifacts
941
2
            .into_iter()
942
3
            .map(|(path, md5, sha1, sha256)| {
943
2
                assert!(path.exists());
944
2
                (path, md5.to_string(), sha1.to_string(), sha256.to_string())
945
2
            })
946
2
            .collect();
947

            
948
2
        let chunk = Chunk {
949
2
            protocol,
950
2
            part: part.to_string(),
951
2
            version: version.to_string(),
952
2
            name: name.to_string(),
953
2
            artifacts,
954
2
            mock: Some(mock),
955
2
            metadata: None,
956
2
        };
957
2
        builder.chunks.push(chunk);
958

            
959
2
        builder
960
2
    }
961

            
962
    /// Create the [`Deployment`].
963
16
    pub fn build(self) -> Deployment {
964
16
        Deployment {
965
16
            id: self.id,
966
16
            confirmation_required: self.confirmation_required,
967
16
            download_type: self.download_type,
968
16
            update_type: self.update_type,
969
16
            maintenance_window: self.maintenance_window,
970
16
            chunks: self.chunks,
971
16
        }
972
16
    }
973
}
974

            
975
/// Protocol(s) over which chunks are served
976
pub enum ChunkProtocol {
977
    /// Both http and https
978
    BOTH,
979
    /// Http only
980
    HTTP,
981
    /// Https only
982
    HTTPS,
983
}
984

            
985
impl ChunkProtocol {
986
    /// Return whether the http protocol is used for downloads
987
44
    pub fn http(&self) -> bool {
988
44
        matches!(self, Self::BOTH | Self::HTTP)
989
44
    }
990

            
991
    /// Return whether the https protocol is used for downloads
992
44
    pub fn https(&self) -> bool {
993
44
        matches!(self, Self::BOTH | Self::HTTPS)
994
44
    }
995
}
996

            
997
/// Key-value pair of metadata of a software set
998
pub struct Metadata {
999
    key: String,
    value: String,
}
impl Metadata {
42
    fn json(&self) -> serde_json::Value {
42
        json!({
42
            "key": self.key,
42
            "value": self.value,
        })
42
    }
}
/// Software chunk of an update.
pub struct Chunk {
    protocol: ChunkProtocol,
    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 {
44
    fn json(&self, base_url: &str) -> serde_json::Value {
44
        let artifacts: Vec<serde_json::Value> = self
44
            .artifacts
44
            .iter()
66
            .map(|(path, md5, sha1, sha256)| {
44
                let meta = path.metadata().unwrap();
44
                let file_name = path.file_name().unwrap().to_str().unwrap();
44
                let download_url = format!("{}/{}", base_url, file_name);
                // TODO: the md5 url is not served by the http server
44
                let md5_url = format!("{}.MD5SUM", download_url);
44
                let mut links = serde_json::Map::new();
44
                if self.protocol.https() {
30
                    links.insert("download".to_string(), json!({ "href": download_url }));
30
                    links.insert("md5sum".to_string(), json!({ "href": md5_url }));
30
                }
44
                if self.protocol.http() {
30
                    links.insert("download-http".to_string(), json!({ "href": download_url }));
30
                    links.insert("md5sum-http".to_string(), json!({ "href": md5_url }));
30
                }
44
                json!({
44
                    "filename": file_name,
44
                    "hashes": {
44
                        "sha1": sha1,
44
                        "md5": md5,
44
                        "sha256": sha256,
                    },
44
                    "size": meta.len(),
44
                    "_links": links,
                })
44
            })
44
            .collect();
44
        let mut result = json!({
44
            "part": self.part,
44
            "version": self.version,
44
            "name": self.name,
44
            "artifacts": artifacts,
        });
44
        if let Some(metadata) = &self.metadata {
56
            let metadata: Vec<serde_json::Value> = metadata.iter().map(|m| m.json()).collect();
28
            result
28
                .as_object_mut()
28
                .unwrap()
28
                .insert("metadata".to_string(), json!(metadata));
16
        }
44
        result
44
    }
}
impl Deployment {
16
    fn json(&self, base_url: &str) -> serde_json::Value {
52
        let chunks: Vec<serde_json::Value> = self.chunks.iter().map(|c| c.json(base_url)).collect();
16
        let mut j = if self.confirmation_required {
4
            json!({
4
                "id": self.id,
4
                "confirmation": {
4
                    "download": self.download_type,
4
                    "update": self.update_type,
4
                    "chunks": chunks,
                }
            })
        } else {
12
            json!({
12
                "id": self.id,
12
                "deployment": {
12
                    "download": self.download_type,
12
                    "update": self.update_type,
12
                    "chunks": chunks,
                }
            })
        };
16
        if let Some(maintenance_window) = &self.maintenance_window {
16
            let d = j
16
                .get_mut(if self.confirmation_required {
4
                    "confirmation"
                } else {
12
                    "deployment"
                })
16
                .unwrap()
16
                .as_object_mut()
16
                .unwrap();
16
            d.insert("maintenanceWindow".to_string(), json!(maintenance_window));
        }
16
        j
16
    }
}