• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

Unleash / unleash-edge / 15441274495

04 Jun 2025 11:37AM UTC coverage: 78.265% (+10.3%) from 67.995%
15441274495

push

github

web-flow
task(rust): Update Rust version to 1.87.0 (#970)

10140 of 12956 relevant lines covered (78.26%)

158.36 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

82.19
/server/src/types.rs
1
use std::cmp::min;
2
use std::fmt;
3
use std::fmt::{Debug, Display, Formatter};
4
use std::net::IpAddr;
5
use std::sync::Arc;
6
use std::{
7
    collections::HashMap,
8
    hash::{Hash, Hasher},
9
    str::FromStr,
10
};
11

12
use actix_web::{http::header::EntityTag, web::Json};
13
use async_trait::async_trait;
14
use chrono::{DateTime, Duration, Utc};
15
use dashmap::DashMap;
16
use serde::{Deserialize, Deserializer, Serialize, Serializer};
17
use shadow_rs::shadow;
18
use unleash_types::client_features::Context;
19
use unleash_types::client_features::{ClientFeatures, ClientFeaturesDelta};
20
use unleash_types::client_metrics::{ClientApplication, ClientMetricsEnv};
21
use unleash_yggdrasil::EngineState;
22
use utoipa::{IntoParams, ToSchema};
23

24
use crate::error::EdgeError;
25
use crate::metrics::client_metrics::MetricsKey;
26

27
pub type EdgeJsonResult<T> = Result<Json<T>, EdgeError>;
28
pub type EdgeResult<T> = Result<T, EdgeError>;
29

30
#[derive(Deserialize, Serialize, Debug, Clone)]
31
#[serde(rename_all = "camelCase")]
32
pub struct IncomingContext {
33
    #[serde(flatten)]
34
    pub context: Context,
35

36
    #[serde(flatten)]
37
    pub extra_properties: HashMap<String, String>,
38
}
39

40
impl From<IncomingContext> for Context {
41
    fn from(input: IncomingContext) -> Self {
37✔
42
        let properties = if input.extra_properties.is_empty() {
37✔
43
            input.context.properties
27✔
44
        } else {
45
            let mut input_properties = input.extra_properties;
10✔
46
            input_properties.extend(input.context.properties.unwrap_or_default());
10✔
47
            Some(input_properties)
10✔
48
        };
49
        Context {
37✔
50
            properties,
37✔
51
            ..input.context
37✔
52
        }
37✔
53
    }
37✔
54
}
55

56
#[derive(Deserialize, Serialize, Debug, Clone)]
57
#[serde(rename_all = "camelCase")]
58
pub struct PostContext {
59
    pub context: Option<Context>,
60
    #[serde(flatten)]
61
    pub flattened_context: Option<Context>,
62
    #[serde(flatten)]
63
    pub extra_properties: HashMap<String, String>,
64
}
65

66
impl From<PostContext> for Context {
67
    fn from(input: PostContext) -> Self {
10✔
68
        if let Some(context) = input.context {
10✔
69
            context
3✔
70
        } else {
71
            IncomingContext {
7✔
72
                context: input.flattened_context.unwrap_or_default(),
7✔
73
                extra_properties: input.extra_properties,
7✔
74
            }
7✔
75
            .into()
7✔
76
        }
77
    }
10✔
78
}
79

80
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
×
81
#[serde(rename_all = "lowercase")]
82
pub enum TokenType {
83
    #[serde(alias = "FRONTEND")]
84
    Frontend,
85
    #[serde(alias = "CLIENT")]
86
    Client,
87
    #[serde(alias = "ADMIN")]
88
    Admin,
89
    Invalid,
90
}
91

92
#[derive(Clone, Debug)]
93
#[allow(clippy::large_enum_variant)]
94
pub enum ClientFeaturesResponse {
95
    NoUpdate(EntityTag),
96
    Updated(ClientFeatures, Option<EntityTag>),
97
}
98

99
#[derive(Clone, Debug)]
100
pub enum ClientFeaturesDeltaResponse {
101
    NoUpdate(EntityTag),
102
    Updated(ClientFeaturesDelta, Option<EntityTag>),
103
}
104

105
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Default, Deserialize, utoipa::ToSchema)]
×
106
pub enum TokenValidationStatus {
107
    Invalid,
108
    #[default]
109
    Unknown,
110
    Trusted,
111
    Validated,
112
}
113

114
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
115
#[serde(rename_all = "UPPERCASE")]
116
pub enum Status {
117
    Ok,
118
    NotOk,
119
    NotReady,
120
    Ready,
121
}
122
#[derive(Clone, Debug)]
123
pub struct ClientFeaturesRequest {
124
    pub api_key: String,
125
    pub etag: Option<EntityTag>,
126
    pub interval: Option<i64>,
127
}
128

129
#[derive(Clone, Debug, Serialize, Deserialize)]
130
pub struct ValidateTokensRequest {
131
    pub tokens: Vec<String>,
132
}
133

134
#[derive(Clone, Serialize, Deserialize, Eq, ToSchema)]
×
135
#[cfg_attr(test, derive(Default))]
136
#[serde(rename_all = "camelCase")]
137
pub struct EdgeToken {
138
    pub token: String,
139
    #[serde(rename = "type")]
140
    pub token_type: Option<TokenType>,
141
    pub environment: Option<String>,
142
    pub projects: Vec<String>,
143
    #[serde(default = "valid_status")]
144
    pub status: TokenValidationStatus,
145
}
146

147
impl Debug for EdgeToken {
148
    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
24✔
149
        f.debug_struct("EdgeToken")
24✔
150
            .field(
24✔
151
                "token",
24✔
152
                &format!(
24✔
153
                    "{}.[redacted]",
24✔
154
                    &self
24✔
155
                        .token
24✔
156
                        .chars()
24✔
157
                        .take_while(|p| p != &'.')
441✔
158
                        .collect::<String>()
24✔
159
                ),
24✔
160
            )
24✔
161
            .field("token_type", &self.token_type)
24✔
162
            .field("environment", &self.environment)
24✔
163
            .field("projects", &self.projects)
24✔
164
            .field("status", &self.status)
24✔
165
            .finish()
24✔
166
    }
24✔
167
}
168

169
#[derive(Debug, Clone)]
170
pub struct ServiceAccountToken {
171
    pub token: String,
172
}
173

174
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
×
175
#[serde(rename_all = "camelCase")]
176
pub struct ClientTokenResponse {
177
    pub secret: String,
178
    pub token_name: String,
179
    #[serde(rename = "type")]
180
    pub token_type: Option<TokenType>,
181
    pub environment: Option<String>,
182
    pub project: Option<String>,
183
    pub projects: Vec<String>,
184
    pub expires_at: Option<DateTime<Utc>>,
185
    pub created_at: Option<DateTime<Utc>>,
186
    pub seen_at: Option<DateTime<Utc>>,
187
    pub alias: Option<String>,
188
}
189

190
impl From<ClientTokenResponse> for EdgeToken {
191
    fn from(value: ClientTokenResponse) -> Self {
×
192
        Self {
×
193
            token: value.secret,
×
194
            token_type: value.token_type,
×
195
            environment: value.environment,
×
196
            projects: value.projects,
×
197
            status: TokenValidationStatus::Validated,
×
198
        }
×
199
    }
×
200
}
201

202
fn valid_status() -> TokenValidationStatus {
2✔
203
    TokenValidationStatus::Validated
2✔
204
}
2✔
205

206
impl PartialEq for EdgeToken {
207
    fn eq(&self, other: &EdgeToken) -> bool {
17✔
208
        self.token == other.token
17✔
209
    }
17✔
210
}
211

212
impl Hash for EdgeToken {
213
    fn hash<H: Hasher>(&self, state: &mut H) {
9✔
214
        self.token.hash(state);
9✔
215
    }
9✔
216
}
217

218
impl EdgeToken {
219
    pub fn to_client_token_request(&self) -> ClientTokenRequest {
×
220
        ClientTokenRequest {
×
221
            token_name: format!(
×
222
                "edge_data_token_{}",
×
223
                self.environment.clone().unwrap_or("default".into())
×
224
            ),
×
225
            token_type: TokenType::Client,
×
226
            projects: self.projects.clone(),
×
227
            environment: self.environment.clone().unwrap_or("default".into()),
×
228
            expires_at: Utc::now() + Duration::weeks(4),
×
229
        }
×
230
    }
×
231
    pub fn admin_token(secret: &str) -> Self {
×
232
        Self {
×
233
            token: format!("*:*.{}", secret),
×
234
            status: TokenValidationStatus::Validated,
×
235
            token_type: Some(TokenType::Admin),
×
236
            environment: None,
×
237
            projects: vec!["*".into()],
×
238
        }
×
239
    }
×
240

241
    #[cfg(test)]
242
    pub fn validated_client_token(token: &str) -> Self {
×
243
        EdgeToken::from_str(token)
×
244
            .map(|mut t| {
×
245
                t.status = TokenValidationStatus::Validated;
×
246
                t.token_type = Some(TokenType::Client);
×
247
                t
×
248
            })
×
249
            .unwrap()
×
250
    }
×
251
}
252

253
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
×
254
pub struct TokenStrings {
255
    pub tokens: Vec<String>,
256
}
257
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
×
258
pub struct ValidatedTokens {
259
    pub tokens: Vec<EdgeToken>,
260
}
261

262
#[derive(Debug, Clone, PartialEq, Eq)]
263
pub struct ClientIp {
264
    pub ip: IpAddr,
265
}
266

267
impl Display for ClientIp {
268
    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
5✔
269
        write!(f, "{}", self.ip)
5✔
270
    }
5✔
271
}
272

273
#[derive(Clone, Deserialize, Serialize)]
×
274
pub struct TokenRefresh {
275
    pub token: EdgeToken,
276
    #[serde(
277
        deserialize_with = "deserialize_entity_tag",
278
        serialize_with = "serialize_entity_tag"
279
    )]
280
    pub etag: Option<EntityTag>,
281
    pub next_refresh: Option<DateTime<Utc>>,
282
    pub last_refreshed: Option<DateTime<Utc>>,
283
    pub last_feature_count: Option<usize>,
284
    pub last_check: Option<DateTime<Utc>>,
285
    pub failure_count: u32,
286
}
287

288
#[derive(Clone, Deserialize, Serialize, Debug)]
289
pub struct UnleashValidationDetail {
290
    pub path: Option<String>,
291
    pub description: Option<String>,
292
    pub message: Option<String>,
293
}
294

295
#[derive(Clone, Deserialize, Serialize, Debug)]
296
pub struct UnleashBadRequest {
297
    pub id: Option<String>,
298
    pub name: Option<String>,
299
    pub message: Option<String>,
300
    pub details: Option<Vec<UnleashValidationDetail>>,
301
}
302

303
impl TokenRefresh {
304
    pub fn new(token: EdgeToken, etag: Option<EntityTag>) -> Self {
59✔
305
        Self {
59✔
306
            token,
59✔
307
            etag,
59✔
308
            last_refreshed: None,
59✔
309
            last_check: None,
59✔
310
            next_refresh: None,
59✔
311
            failure_count: 0,
59✔
312
            last_feature_count: None,
59✔
313
        }
59✔
314
    }
59✔
315

316
    /// Something went wrong (but it was retriable. Increment our failure count and set last_checked and next_refresh
317
    pub fn backoff(&self, refresh_interval: &Duration) -> Self {
6✔
318
        let failure_count: u32 = min(self.failure_count + 1, 10);
6✔
319
        let now = Utc::now();
6✔
320
        let next_refresh = calculate_next_refresh(now, *refresh_interval, failure_count as u64);
6✔
321
        Self {
6✔
322
            failure_count,
6✔
323
            next_refresh: Some(next_refresh),
6✔
324
            last_check: Some(now),
6✔
325
            ..self.clone()
6✔
326
        }
6✔
327
    }
6✔
328
    /// We successfully talked to upstream, but there was no updates. Update our next_refresh, decrement our failure count and set when we last_checked
329
    pub fn successful_check(&self, refresh_interval: &Duration) -> Self {
×
330
        let failure_count = if self.failure_count > 0 {
×
331
            self.failure_count - 1
×
332
        } else {
333
            0
×
334
        };
335
        let now = Utc::now();
×
336
        let next_refresh = calculate_next_refresh(now, *refresh_interval, failure_count as u64);
×
337
        Self {
×
338
            failure_count,
×
339
            next_refresh: Some(next_refresh),
×
340
            last_check: Some(now),
×
341
            ..self.clone()
×
342
        }
×
343
    }
×
344
    /// We successfully talked to upstream. There were updates. Update next_refresh, last_refreshed and last_check, and decrement our failure count
345
    pub fn successful_refresh(
17✔
346
        &self,
17✔
347
        refresh_interval: &Duration,
17✔
348
        etag: Option<EntityTag>,
17✔
349
        feature_count: usize,
17✔
350
    ) -> Self {
17✔
351
        let failure_count = if self.failure_count > 0 {
17✔
352
            self.failure_count - 1
×
353
        } else {
354
            0
17✔
355
        };
356
        let now = Utc::now();
17✔
357
        let next_refresh = calculate_next_refresh(now, *refresh_interval, failure_count as u64);
17✔
358
        Self {
17✔
359
            failure_count,
17✔
360
            next_refresh: Some(next_refresh),
17✔
361
            last_refreshed: Some(now),
17✔
362
            last_check: Some(now),
17✔
363
            last_feature_count: Some(feature_count),
17✔
364
            etag,
17✔
365
            ..self.clone()
17✔
366
        }
17✔
367
    }
17✔
368
}
369

370
fn calculate_next_refresh(
23✔
371
    now: DateTime<Utc>,
23✔
372
    refresh_interval: Duration,
23✔
373
    failure_count: u64,
23✔
374
) -> DateTime<Utc> {
23✔
375
    if failure_count == 0 {
23✔
376
        now + refresh_interval
17✔
377
    } else {
378
        now + refresh_interval + (refresh_interval * (failure_count.try_into().unwrap_or(0)))
6✔
379
    }
380
}
23✔
381

382
impl fmt::Debug for TokenRefresh {
383
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
×
384
        f.debug_struct("FeatureRefresh")
×
385
            .field("token", &"***")
×
386
            .field("etag", &self.etag)
×
387
            .field("last_refreshed", &self.last_refreshed)
×
388
            .field("last_check", &self.last_check)
×
389
            .finish()
×
390
    }
×
391
}
392

393
#[derive(Clone, Default)]
394
pub struct CacheHolder {
395
    pub token_cache: Arc<DashMap<String, EdgeToken>>,
396
    pub features_cache: Arc<DashMap<String, ClientFeatures>>,
397
    pub engine_cache: Arc<DashMap<String, EngineState>>,
398
}
399

400
fn deserialize_entity_tag<'de, D>(deserializer: D) -> Result<Option<EntityTag>, D::Error>
1✔
401
where
1✔
402
    D: Deserializer<'de>,
1✔
403
{
1✔
404
    let s: Option<String> = Option::deserialize(deserializer)?;
1✔
405

406
    s.map(|s| EntityTag::from_str(&s).map_err(serde::de::Error::custom))
1✔
407
        .transpose()
1✔
408
}
1✔
409

410
fn serialize_entity_tag<S>(etag: &Option<EntityTag>, serializer: S) -> Result<S::Ok, S::Error>
1✔
411
where
1✔
412
    S: Serializer,
1✔
413
{
1✔
414
    let s = etag.as_ref().map(|e| e.to_string());
1✔
415
    serializer.serialize_some(&s)
1✔
416
}
1✔
417

418
pub fn into_entity_tag(client_features: ClientFeatures) -> Option<EntityTag> {
×
419
    client_features.xx3_hash().ok().map(EntityTag::new_weak)
×
420
}
×
421

422
#[derive(Clone, Debug, Serialize, Deserialize)]
423
pub struct BatchMetricsRequest {
424
    pub api_key: String,
425
    pub body: BatchMetricsRequestBody,
426
}
427

428
#[derive(Clone, Debug, Serialize, Deserialize, utoipa::ToSchema)]
×
429
pub struct BatchMetricsRequestBody {
430
    pub applications: Vec<ClientApplication>,
431
    pub metrics: Vec<ClientMetricsEnv>,
432
}
433

434
#[derive(Debug, Serialize, Deserialize, Clone)]
435
#[serde(rename_all = "camelCase")]
436
pub struct ClientTokenRequest {
437
    pub token_name: String,
438
    #[serde(rename = "type")]
439
    pub token_type: TokenType,
440
    pub projects: Vec<String>,
441
    pub environment: String,
442
    pub expires_at: DateTime<Utc>,
443
}
444

445
#[async_trait]
446
pub trait TokenValidator {
447
    /// Will validate upstream, and add tokens with status from upstream to token cache.
448
    /// Will block until verified with upstream
449
    async fn register_tokens(&mut self, tokens: Vec<String>) -> EdgeResult<Vec<EdgeToken>>;
450
}
451

452
#[derive(Debug, Serialize, Deserialize, Clone)]
453
pub struct BuildInfo {
454
    pub package_version: String,
455
    pub app_name: String,
456
    pub package_major: String,
457
    pub package_minor: String,
458
    pub package_patch: String,
459
    pub package_version_pre: Option<String>,
460
    pub branch: String,
461
    pub tag: String,
462
    pub rust_version: String,
463
    pub rust_channel: String,
464
    pub short_commit_hash: String,
465
    pub full_commit_hash: String,
466
    pub build_os: String,
467
    pub build_target: String,
468
}
469
shadow!(build); // Get build information set to build placeholder
470
pub const EDGE_VERSION: &str = build::PKG_VERSION;
471
impl Default for BuildInfo {
472
    fn default() -> Self {
5✔
473
        BuildInfo {
5✔
474
            package_version: build::PKG_VERSION.into(),
5✔
475
            app_name: build::PROJECT_NAME.into(),
5✔
476
            package_major: build::PKG_VERSION_MAJOR.into(),
5✔
477
            package_minor: build::PKG_VERSION_MINOR.into(),
5✔
478
            package_patch: build::PKG_VERSION_PATCH.into(),
5✔
479
            #[allow(clippy::const_is_empty)]
5✔
480
            package_version_pre: if build::PKG_VERSION_PRE.is_empty() {
5✔
481
                None
5✔
482
            } else {
483
                Some(build::PKG_VERSION_PRE.into())
×
484
            },
485
            branch: build::BRANCH.into(),
5✔
486
            tag: build::TAG.into(),
5✔
487
            rust_version: build::RUST_VERSION.into(),
5✔
488
            rust_channel: build::RUST_CHANNEL.into(),
5✔
489
            short_commit_hash: build::SHORT_COMMIT.into(),
5✔
490
            full_commit_hash: build::COMMIT_HASH.into(),
5✔
491
            build_os: build::BUILD_OS.into(),
5✔
492
            build_target: build::BUILD_TARGET.into(),
5✔
493
        }
5✔
494
    }
5✔
495
}
496

497
#[derive(Clone, Debug, Serialize, Deserialize, IntoParams)]
498
#[serde(rename_all = "camelCase")]
499
pub struct FeatureFilters {
500
    pub name_prefix: Option<String>,
501
}
502

503
#[derive(Serialize, Deserialize, Debug, Clone)]
504
#[serde(rename_all = "camelCase")]
505
pub struct TokenInfo {
506
    pub token_refreshes: Vec<TokenRefresh>,
507
    pub token_validation_status: Vec<EdgeToken>,
508
}
509

510
#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)]
×
511
pub struct ClientMetric {
512
    pub key: MetricsKey,
513
    pub bucket: ClientMetricsEnv,
514
}
515
#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)]
×
516
pub struct MetricsInfo {
517
    pub applications: Vec<ClientApplication>,
518
    pub metrics: Vec<ClientMetric>,
519
}
520

521
#[cfg(test)]
522
mod tests {
523
    use std::collections::HashMap;
524
    use std::str::FromStr;
525

526
    use serde_json::json;
527
    use test_case::test_case;
528
    use tracing::warn;
529
    use unleash_types::client_features::Context;
530

531
    use crate::error::EdgeError::EdgeTokenParseError;
532
    use crate::http::unleash_client::EdgeTokens;
533
    use crate::types::{EdgeResult, EdgeToken, IncomingContext};
534

535
    use super::PostContext;
536

537
    fn test_str(token: &str) -> EdgeToken {
10✔
538
        EdgeToken::from_str(
10✔
539
            &(token.to_owned() + ".614a75cf68bef8703aa1bd8304938a81ec871f86ea40c975468eabd6"),
10✔
540
        )
10✔
541
        .unwrap()
10✔
542
    }
10✔
543

544
    fn test_token(env: Option<&str>, projects: Vec<&str>) -> EdgeToken {
2✔
545
        EdgeToken {
2✔
546
            environment: env.map(|env| env.into()),
2✔
547
            projects: projects.into_iter().map(|p| p.into()).collect(),
3✔
548
            ..EdgeToken::default()
2✔
549
        }
2✔
550
    }
2✔
551

552
    #[test_case("demo-app:production.614a75cf68bef8703aa1bd8304938a81ec871f86ea40c975468eabd6"; "demo token with project and environment")]
2✔
553
    #[test_case("*:default.5fa5ac2580c7094abf0d87c68b1eeb54bdc485014aef40f9fcb0673b"; "demo token with access to all projects and default environment")]
554
    fn edge_token_from_string(token: &str) {
2✔
555
        let parsed_token = EdgeToken::from_str(token);
2✔
556
        match parsed_token {
2✔
557
            Ok(t) => {
2✔
558
                assert_eq!(t.token, token);
2✔
559
            }
560
            Err(e) => {
×
561
                warn!("{}", e);
×
562
                panic!("Could not parse token");
×
563
            }
564
        }
565
    }
2✔
566

567
    #[test_case("943ca9171e2c884c545c5d82417a655fb77cec970cc3b78a8ff87f4406b495d0"; "old java client token")]
2✔
568
    #[test_case("secret-123"; "old example proxy token")]
569
    fn offline_token_from_string(token: &str) {
2✔
570
        let offline_token = EdgeToken::offline_token(token);
2✔
571
        assert_eq!(offline_token.environment, None);
2✔
572
        assert!(offline_token.projects.is_empty());
2✔
573
    }
2✔
574

575
    #[test_case(
576
        "demo-app:production",
577
        "demo-app:production"
578
        => true
579
    ; "idempotency")]
580
    #[test_case(
581
        "aproject:production",
582
        "another:production"
583
        => false
584
    ; "project mismatch")]
585
    #[test_case(
586
        "demo-app:development",
587
        "demo-app:production"
588
        => false
589
    ; "environment mismatch")]
590
    #[test_case(
591
        "*:production",
592
        "demo-app:production"
593
        => true
594
    ; "* subsumes a project token")]
595
    fn edge_token_subsumes_edge_token(token1: &str, token2: &str) -> bool {
4✔
596
        let t1 = test_str(token1);
4✔
597
        let t2 = test_str(token2);
4✔
598
        t1.subsumes(&t2)
4✔
599
    }
4✔
600

601
    #[test]
602
    fn edge_token_unrelated_by_subsume() {
1✔
603
        let t1 = test_str("demo-app:production");
1✔
604
        let t2 = test_str("another:production");
1✔
605
        assert!(!t1.subsumes(&t2));
1✔
606
        assert!(!t2.subsumes(&t1));
1✔
607
    }
1✔
608

609
    #[test]
610
    fn edge_token_does_not_subsume_if_projects_is_subset_of_other_tokens_project() {
1✔
611
        let token1 = test_token(None, vec!["p1", "p2"]);
1✔
612

1✔
613
        let token2 = test_token(None, vec!["p1"]);
1✔
614

1✔
615
        assert!(token1.subsumes(&token2));
1✔
616
        assert!(!token2.subsumes(&token1));
1✔
617
    }
1✔
618

619
    #[test]
620
    fn token_type_should_be_case_insensitive() {
1✔
621
        let json = r#"{ "tokens": [{
1✔
622
              "token": "chriswk-test:development.notusedsecret",
1✔
623
              "type": "CLIENT",
1✔
624
              "projects": [
1✔
625
                "chriswk-test"
1✔
626
              ]
1✔
627
            },
1✔
628
            {
1✔
629
              "token": "demo-app:production.notusedsecret",
1✔
630
              "type": "client",
1✔
631
              "projects": [
1✔
632
                "demo-app"
1✔
633
              ]
1✔
634
            }] }"#;
1✔
635
        let tokens: EdgeResult<EdgeTokens> =
1✔
636
            serde_json::from_str(json).map_err(|_| EdgeTokenParseError);
1✔
637
        assert!(tokens.is_ok());
1✔
638
        assert_eq!(tokens.unwrap().tokens.len(), 2);
1✔
639
    }
1✔
640

641
    #[test]
642
    fn context_conversion_works() {
1✔
643
        let context = Context {
1✔
644
            user_id: Some("user".into()),
1✔
645
            session_id: Some("session".into()),
1✔
646
            environment: Some("env".into()),
1✔
647
            app_name: Some("app".into()),
1✔
648
            current_time: Some("2024-03-12T11:42:46+01:00".into()),
1✔
649
            remote_address: Some("127.0.0.1".into()),
1✔
650
            properties: Some(HashMap::from([("normal property".into(), "normal".into())])),
1✔
651
        };
1✔
652

1✔
653
        let extra_properties =
1✔
654
            HashMap::from([(String::from("top-level property"), String::from("top"))]);
1✔
655

1✔
656
        let incoming_context = IncomingContext {
1✔
657
            context: context.clone(),
1✔
658
            extra_properties: extra_properties.clone(),
1✔
659
        };
1✔
660

1✔
661
        let converted: Context = incoming_context.into();
1✔
662
        assert_eq!(converted.user_id, context.user_id);
1✔
663
        assert_eq!(converted.session_id, context.session_id);
1✔
664
        assert_eq!(converted.environment, context.environment);
1✔
665
        assert_eq!(converted.app_name, context.app_name);
1✔
666
        assert_eq!(converted.current_time, context.current_time);
1✔
667
        assert_eq!(converted.remote_address, context.remote_address);
1✔
668
        assert_eq!(
1✔
669
            converted.properties,
1✔
670
            Some(HashMap::from([
1✔
671
                ("normal property".into(), "normal".into()),
1✔
672
                ("top-level property".into(), "top".into())
1✔
673
            ]))
1✔
674
        );
1✔
675
    }
1✔
676

677
    #[test]
678
    fn context_conversion_properties_level_properties_take_precedence_over_top_level() {
1✔
679
        let context = Context {
1✔
680
            properties: Some(HashMap::from([(
1✔
681
                "duplicated property".into(),
1✔
682
                "lower".into(),
1✔
683
            )])),
1✔
684
            ..Default::default()
1✔
685
        };
1✔
686

1✔
687
        let extra_properties =
1✔
688
            HashMap::from([(String::from("duplicated property"), String::from("upper"))]);
1✔
689

1✔
690
        let incoming_context = IncomingContext {
1✔
691
            context: context.clone(),
1✔
692
            extra_properties: extra_properties.clone(),
1✔
693
        };
1✔
694

1✔
695
        let converted: Context = incoming_context.into();
1✔
696
        assert_eq!(
1✔
697
            converted.properties,
1✔
698
            Some(HashMap::from([(
1✔
699
                "duplicated property".into(),
1✔
700
                "lower".into()
1✔
701
            ),]))
1✔
702
        );
1✔
703
    }
1✔
704

705
    #[test]
706
    fn context_conversion_if_there_are_no_extra_properties_the_properties_hash_map_is_none() {
1✔
707
        let context = Context {
1✔
708
            properties: None,
1✔
709
            ..Default::default()
1✔
710
        };
1✔
711

1✔
712
        let extra_properties = HashMap::new();
1✔
713

1✔
714
        let incoming_context = IncomingContext {
1✔
715
            context: context.clone(),
1✔
716
            extra_properties: extra_properties.clone(),
1✔
717
        };
1✔
718

1✔
719
        let converted: Context = incoming_context.into();
1✔
720
        assert_eq!(converted.properties, None);
1✔
721
    }
1✔
722

723
    #[test]
724
    fn completely_flat_json_parses_to_a_context() {
1✔
725
        let json = json!(
1✔
726
            {
1✔
727
                "userId": "7",
1✔
728
                "flat": "endsUpInProps",
1✔
729
                "invalidProperty": "alsoEndsUpInProps"
1✔
730
            }
1✔
731
        );
1✔
732

1✔
733
        let post_context: PostContext = serde_json::from_value(json).unwrap();
1✔
734
        let parsed_context: Context = post_context.into();
1✔
735

1✔
736
        assert_eq!(parsed_context.user_id, Some("7".into()));
1✔
737
        assert_eq!(
1✔
738
            parsed_context.properties,
1✔
739
            Some(HashMap::from([
1✔
740
                ("flat".into(), "endsUpInProps".into()),
1✔
741
                ("invalidProperty".into(), "alsoEndsUpInProps".into())
1✔
742
            ]))
1✔
743
        );
1✔
744
    }
1✔
745

746
    #[test]
747
    fn post_context_root_level_properties_are_ignored_if_context_property_is_set() {
1✔
748
        let json = json!(
1✔
749
            {
1✔
750
                "context": {
1✔
751
                    "userId":"7",
1✔
752
                },
1✔
753
                "invalidProperty": "thisNeverGoesAnywhere",
1✔
754
                "anotherInvalidProperty": "alsoGoesNoWhere"
1✔
755
            }
1✔
756
        );
1✔
757

1✔
758
        let post_context: PostContext = serde_json::from_value(json).unwrap();
1✔
759
        let parsed_context: Context = post_context.into();
1✔
760
        assert_eq!(parsed_context.properties, None);
1✔
761

762
        assert_eq!(parsed_context.user_id, Some("7".into()));
1✔
763
    }
1✔
764

765
    #[test]
766
    fn post_context_properties_are_taken_from_nested_context_object_but_root_levels_are_ignored() {
1✔
767
        let json = json!(
1✔
768
            {
1✔
769
                "context": {
1✔
770
                    "userId":"7",
1✔
771
                    "properties": {
1✔
772
                        "nested": "nestedValue"
1✔
773
                    }
1✔
774
                },
1✔
775
                "invalidProperty": "thisNeverGoesAnywhere"
1✔
776
            }
1✔
777
        );
1✔
778

1✔
779
        let post_context: PostContext = serde_json::from_value(json).unwrap();
1✔
780
        let parsed_context: Context = post_context.into();
1✔
781
        assert_eq!(
1✔
782
            parsed_context.properties,
1✔
783
            Some(HashMap::from([("nested".into(), "nestedValue".into()),]))
1✔
784
        );
1✔
785

786
        assert_eq!(parsed_context.user_id, Some("7".into()));
1✔
787
    }
1✔
788

789
    #[test]
790
    fn post_context_properties_are_taken_from_nested_context_object_but_custom_properties_on_context_are_ignored()
1✔
791
     {
1✔
792
        let json = json!(
1✔
793
            {
1✔
794
                "context": {
1✔
795
                    "userId":"7",
1✔
796
                    "howDidYouGetHere": "I dunno bro",
1✔
797
                    "properties": {
1✔
798
                        "nested": "nestedValue"
1✔
799
                    }
1✔
800
                },
1✔
801
                "flat": "endsUpInProps",
1✔
802
                "invalidProperty": "thisNeverGoesAnywhere"
1✔
803
            }
1✔
804
        );
1✔
805

1✔
806
        let post_context: PostContext = serde_json::from_value(json).unwrap();
1✔
807
        let parsed_context: Context = post_context.into();
1✔
808
        assert_eq!(
1✔
809
            parsed_context.properties,
1✔
810
            Some(HashMap::from([("nested".into(), "nestedValue".into()),]))
1✔
811
        );
1✔
812

813
        assert_eq!(parsed_context.user_id, Some("7".into()));
1✔
814
    }
1✔
815
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc