1use chrono::{DateTime, Utc};
2use credential_exchange_format::{
3 Account as CxfAccount, AddressCredential, ApiKeyCredential, BasicAuthCredential, Credential,
4 CreditCardCredential, CustomFieldsCredential, DriversLicenseCredential, EditableField,
5 EditableFieldString, EditableFieldValue, IdentityDocumentCredential, Item, NoteCredential,
6 PasskeyCredential, PassportCredential, PersonNameCredential, SshKeyCredential, TotpCredential,
7 WifiCredential,
8};
9
10use crate::{
11 cxf::{
12 api_key::api_key_to_fields,
13 card::to_card,
14 editable_field::create_field,
15 identity::{
16 address_to_identity, drivers_license_to_identity, identity_document_to_identity,
17 passport_to_identity, person_name_to_identity,
18 },
19 login::to_login,
20 note::extract_note_content,
21 ssh::to_ssh,
22 wifi::wifi_to_fields,
23 CxfError,
24 },
25 CipherType, Field, ImportingCipher, SecureNote, SecureNoteType,
26};
27
28pub(crate) fn parse_cxf(payload: String) -> Result<Vec<ImportingCipher>, CxfError> {
32 let account: CxfAccount = serde_json::from_str(&payload)?;
33
34 let items: Vec<ImportingCipher> = account.items.into_iter().flat_map(parse_item).collect();
35
36 Ok(items)
37}
38
39fn convert_date(ts: Option<u64>) -> DateTime<Utc> {
43 ts.and_then(|ts| DateTime::from_timestamp(ts as i64, 0))
44 .unwrap_or(Utc::now())
45}
46
47fn custom_fields_to_fields(custom_fields: &CustomFieldsCredential) -> Vec<Field> {
50 custom_fields
51 .fields
52 .iter()
53 .map(|field_value| match field_value {
54 EditableFieldValue::String(field) => create_field(field, None::<String>),
55 EditableFieldValue::ConcealedString(field) => create_field(field, None::<String>),
56 EditableFieldValue::Boolean(field) => create_field(field, None::<String>),
57 EditableFieldValue::Date(field) => create_field(field, None::<String>),
58 EditableFieldValue::YearMonth(field) => create_field(field, None::<String>),
59 EditableFieldValue::SubdivisionCode(field) => create_field(field, None::<String>),
60 EditableFieldValue::CountryCode(field) => create_field(field, None::<String>),
61 EditableFieldValue::WifiNetworkSecurityType(field) => {
62 create_field(field, None::<String>)
63 }
64 _ => create_field(
65 &EditableField {
66 id: None,
67 label: Some("Unknown Field".to_string()),
68 value: EditableFieldString("".to_string()),
69 extensions: None,
70 },
71 None::<String>,
72 ),
73 })
74 .collect()
75}
76
77pub(super) fn parse_item(value: Item) -> Vec<ImportingCipher> {
78 let grouped = group_credentials_by_type(value.credentials);
79
80 let creation_date = convert_date(value.creation_at);
81 let revision_date = convert_date(value.modified_at);
82
83 let mut output = vec![];
84
85 let scope = value.scope.as_ref();
86
87 let note_content = grouped.note.first().map(extract_note_content);
89
90 let mut add_item = |t: CipherType, fields: Vec<Field>| {
92 output.push(ImportingCipher {
93 folder_id: None, name: value.title.clone(),
95 notes: note_content.clone(),
96 r#type: t,
97 favorite: false,
98 reprompt: 0,
99 fields,
100 revision_date,
101 creation_date,
102 deleted_date: None,
103 })
104 };
105
106 if !grouped.basic_auth.is_empty() || !grouped.passkey.is_empty() || !grouped.totp.is_empty() {
108 let basic_auth = grouped.basic_auth.first();
109 let passkey = grouped.passkey.first();
110 let totp = grouped.totp.first();
111
112 let login = to_login(creation_date, basic_auth, passkey, totp, scope);
113 add_item(CipherType::Login(Box::new(login)), vec![]);
114 }
115
116 if let Some(credit_card) = grouped.credit_card.first() {
118 let (card, fields) = to_card(credit_card);
119
120 add_item(CipherType::Card(Box::new(card)), fields);
121 }
122
123 let secure_note_type = || {
125 CipherType::SecureNote(Box::new(SecureNote {
126 r#type: SecureNoteType::Generic,
127 }))
128 };
129
130 if let Some(api_key) = grouped.api_key.first() {
132 let fields = api_key_to_fields(api_key);
133 add_item(secure_note_type(), fields);
134 }
135
136 if let Some(wifi) = grouped.wifi.first() {
138 let fields = wifi_to_fields(wifi);
139 add_item(secure_note_type(), fields);
140 }
141
142 [
144 grouped
145 .address
146 .first()
147 .map(|a| address_to_identity(a.clone())),
148 grouped
149 .passport
150 .first()
151 .map(|p| passport_to_identity(p.clone())),
152 grouped
153 .person_name
154 .first()
155 .map(|p| person_name_to_identity(p.clone())),
156 grouped
157 .drivers_license
158 .first()
159 .map(|d| drivers_license_to_identity(d.clone())),
160 grouped
161 .identity_document
162 .first()
163 .map(|i| identity_document_to_identity(i.clone())),
164 ]
165 .into_iter()
166 .flatten()
167 .for_each(|(identity, custom_fields)| {
168 add_item(CipherType::Identity(Box::new(identity)), custom_fields);
169 });
170
171 if let Some(ssh) = grouped.ssh.first() {
173 match to_ssh(ssh) {
174 Ok((ssh_key, fields)) => add_item(CipherType::SshKey(Box::new(ssh_key)), fields),
175 Err(_) => {
176 }
178 }
179 }
180
181 if let Some(custom_fields) = grouped.custom_fields.first() {
183 let fields = custom_fields_to_fields(custom_fields);
184 add_item(secure_note_type(), fields);
185 }
186
187 if !grouped.note.is_empty() && output.is_empty() {
189 let standalone_note_content = grouped.note.first().map(extract_note_content);
190 output.push(ImportingCipher {
191 folder_id: None, name: value.title.clone(),
193 notes: standalone_note_content,
194 r#type: secure_note_type(),
195 favorite: false,
196 reprompt: 0,
197 fields: vec![],
198 revision_date,
199 creation_date,
200 deleted_date: None,
201 });
202 }
203
204 output
205}
206
207fn group_credentials_by_type(credentials: Vec<Credential>) -> GroupedCredentials {
214 fn filter_credentials<T>(
215 credentials: &[Credential],
216 f: impl Fn(&Credential) -> Option<&T>,
217 ) -> Vec<T>
218 where
219 T: Clone,
220 {
221 credentials.iter().filter_map(f).cloned().collect()
222 }
223
224 macro_rules! extract_credential {
225 ($field:ident, $variant:path, $type:ty) => {
226 filter_credentials(&credentials, |c| match c {
227 $variant(ref inner) => Some(inner.as_ref()),
228 _ => None,
229 })
230 };
231 }
232
233 GroupedCredentials {
234 api_key: extract_credential!(api_key, Credential::ApiKey, ApiKeyCredential),
235 basic_auth: extract_credential!(basic_auth, Credential::BasicAuth, BasicAuthCredential),
236 credit_card: extract_credential!(credit_card, Credential::CreditCard, CreditCardCredential),
237 custom_fields: extract_credential!(custom_fields, Credential::CustomFields, CustomFields),
238 passkey: extract_credential!(passkey, Credential::Passkey, PasskeyCredential),
239 ssh: extract_credential!(ssh, Credential::SshKey, SshKeyCredential),
240 totp: extract_credential!(totp, Credential::Totp, TotpCredential),
241 wifi: extract_credential!(wifi, Credential::Wifi, WifiCredential),
242 address: extract_credential!(address, Credential::Address, AddressCredential),
243 passport: extract_credential!(passport, Credential::Passport, PassportCredential),
244 person_name: extract_credential!(person_name, Credential::PersonName, PersonNameCredential),
245 drivers_license: extract_credential!(
246 drivers_license,
247 Credential::DriversLicense,
248 DriversLicenseCredential
249 ),
250 identity_document: extract_credential!(
251 identity_document,
252 Credential::IdentityDocument,
253 IdentityDocumentCredential
254 ),
255 note: extract_credential!(note, Credential::Note, NoteCredential),
256 }
257}
258
259struct GroupedCredentials {
260 address: Vec<AddressCredential>,
261 api_key: Vec<ApiKeyCredential>,
262 basic_auth: Vec<BasicAuthCredential>,
263 credit_card: Vec<CreditCardCredential>,
264 custom_fields: Vec<CustomFieldsCredential>,
265 drivers_license: Vec<DriversLicenseCredential>,
266 identity_document: Vec<IdentityDocumentCredential>,
267 note: Vec<NoteCredential>,
268 passkey: Vec<PasskeyCredential>,
269 passport: Vec<PassportCredential>,
270 person_name: Vec<PersonNameCredential>,
271 ssh: Vec<SshKeyCredential>,
272 totp: Vec<TotpCredential>,
273 wifi: Vec<WifiCredential>,
274}
275
276#[cfg(test)]
277mod tests {
278 use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
279 use chrono::{Duration, Month};
280 use credential_exchange_format::{CreditCardCredential, EditableFieldYearMonth};
281
282 use super::*;
283
284 #[test]
285 fn test_convert_date() {
286 let timestamp: u64 = 1706613834;
287 let datetime = convert_date(Some(timestamp));
288 assert_eq!(
289 datetime,
290 "2024-01-30T11:23:54Z".parse::<DateTime<Utc>>().unwrap()
291 );
292 }
293
294 #[test]
295 fn test_convert_date_none() {
296 let datetime = convert_date(None);
297 assert!(datetime > Utc::now() - Duration::seconds(1));
298 assert!(datetime <= Utc::now());
299 }
300
301 #[test]
302 fn test_parse_empty_item() {
303 let item = Item {
304 id: [0, 1, 2, 3, 4, 5, 6].as_ref().into(),
305 creation_at: Some(1706613834),
306 modified_at: Some(1706623773),
307 title: "Bitwarden".to_string(),
308 subtitle: None,
309 favorite: None,
310 credentials: vec![],
311 tags: None,
312 extensions: None,
313 scope: None,
314 };
315
316 let ciphers: Vec<ImportingCipher> = parse_item(item);
317 assert_eq!(ciphers.len(), 0);
318 }
319
320 #[test]
321 fn test_parse_passkey() {
322 let item = Item {
323 id: URL_SAFE_NO_PAD
324 .decode("Njk1RERENTItNkQ0Ny00NERBLTlFN0EtNDM1MjNEQjYzNjVF")
325 .unwrap()
326 .as_slice()
327 .into(),
328 creation_at: Some(1732181986),
329 modified_at: Some(1732182026),
330 title: "example.com".to_string(),
331 subtitle: None,
332 favorite: None,
333 credentials: vec![Credential::Passkey(Box::new(PasskeyCredential {
334 credential_id: URL_SAFE_NO_PAD
335 .decode("6NiHiekW4ZY8vYHa-ucbvA")
336 .unwrap()
337 .as_slice()
338 .into(),
339 rp_id: "example.com".to_string(),
340 username: "pj-fry".to_string(),
341 user_display_name: "Philip J. Fry".to_string(),
342 user_handle: URL_SAFE_NO_PAD
343 .decode("YWxleCBtdWxsZXI")
344 .unwrap()
345 .as_slice()
346 .into(),
347 key: URL_SAFE_NO_PAD
348 .decode("MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgPzvtWYWmIsvqqr3LsZB0K-cbjuhJSGTGziL1LksHAPShRANCAAT-vqHTyEDS9QBNNi2BNLyu6TunubJT_L3G3i7KLpEDhMD15hi24IjGBH0QylJIrvlT4JN2tdRGF436XGc-VoAl")
349 .unwrap()
350 .as_slice()
351 .into(),
352 fido2_extensions: None,
353 }))],
354 tags: None,
355 extensions: None,
356 scope: None,
357 };
358
359 let ciphers: Vec<ImportingCipher> = parse_item(item);
360 assert_eq!(ciphers.len(), 1);
361 let cipher = ciphers.first().unwrap();
362
363 assert_eq!(cipher.folder_id, None);
364 assert_eq!(cipher.name, "example.com");
365
366 let login = match &cipher.r#type {
367 CipherType::Login(login) => login,
368 _ => panic!("Expected login"),
369 };
370
371 assert_eq!(login.username, Some("pj-fry".to_string()));
372 assert_eq!(login.password, None);
373 assert_eq!(login.login_uris.len(), 1);
374 assert_eq!(
375 login.login_uris[0].uri,
376 Some("https://example.com".to_string())
377 );
378 assert_eq!(login.totp, None);
379
380 let passkey = login.fido2_credentials.as_ref().unwrap().first().unwrap();
381 assert_eq!(passkey.credential_id, "b64.6NiHiekW4ZY8vYHa-ucbvA");
382 assert_eq!(passkey.key_type, "public-key");
383 assert_eq!(passkey.key_algorithm, "ECDSA");
384 assert_eq!(passkey.key_curve, "P-256");
385 assert_eq!(
386 passkey.key_value,
387 "MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgPzvtWYWmIsvqqr3LsZB0K-cbjuhJSGTGziL1LksHAPShRANCAAT-vqHTyEDS9QBNNi2BNLyu6TunubJT_L3G3i7KLpEDhMD15hi24IjGBH0QylJIrvlT4JN2tdRGF436XGc-VoAl"
388 );
389 assert_eq!(passkey.rp_id, "example.com");
390 assert_eq!(
391 passkey.user_handle.as_ref().map(|h| h.to_string()).unwrap(),
392 "YWxleCBtdWxsZXI"
393 );
394 assert_eq!(passkey.user_name, Some("pj-fry".to_string()));
395 assert_eq!(passkey.counter, 0);
396 assert_eq!(passkey.rp_name, Some("example.com".to_string()));
397 assert_eq!(passkey.user_display_name, Some("Philip J. Fry".to_string()));
398 assert_eq!(passkey.discoverable, "true");
399 assert_eq!(
400 passkey.creation_date,
401 "2024-11-21T09:39:46Z".parse::<DateTime<Utc>>().unwrap()
402 );
403 }
404
405 #[test]
406 fn test_passkey_with_basic_auth_and_scope() {
407 use credential_exchange_format::{BasicAuthCredential, CredentialScope};
408
409 let item = Item {
410 id: URL_SAFE_NO_PAD
411 .decode("Njk1RERENTItNkQ0Ny00NERBLTlFN0EtNDM1MjNEQjYzNjVF")
412 .unwrap()
413 .as_slice()
414 .into(),
415 creation_at: Some(1732181986),
416 modified_at: Some(1732182026),
417 title: "Combined Login".to_string(),
418 subtitle: None,
419 favorite: None,
420 credentials: vec![
421 Credential::BasicAuth(Box::new(BasicAuthCredential {
422 username: Some("basic_username".to_string().into()),
423 password: Some("basic_password".to_string().into()),
424 })),
425 Credential::Passkey(Box::new(PasskeyCredential {
426 credential_id: URL_SAFE_NO_PAD
427 .decode("6NiHiekW4ZY8vYHa-ucbvA")
428 .unwrap()
429 .as_slice()
430 .into(),
431 rp_id: "passkey-domain.com".to_string(),
432 username: "passkey_username".to_string(),
433 user_display_name: "Passkey User".to_string(),
434 user_handle: URL_SAFE_NO_PAD
435 .decode("YWxleCBtdWxsZXI")
436 .unwrap()
437 .as_slice()
438 .into(),
439 key: URL_SAFE_NO_PAD
440 .decode("MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgPzvtWYWmIsvqqr3LsZB0K-cbjuhJSGTGziL1LksHAPShRANCAAT-vqHTyEDS9QBNNi2BNLyu6TunubJT_L3G3i7KLpEDhMD15hi24IjGBH0QylJIrvlT4JN2tdRGF436XGc-VoAl")
441 .unwrap()
442 .as_slice()
443 .into(),
444 fido2_extensions: None,
445 }))
446 ],
447 tags: None,
448 extensions: None,
449 scope: Some(CredentialScope {
450 urls: vec!["https://example.com".to_string()],
451 android_apps: vec![],
452 }),
453 };
454
455 let ciphers: Vec<ImportingCipher> = parse_item(item);
456 assert_eq!(ciphers.len(), 1);
457 let cipher = ciphers.first().unwrap();
458
459 let login = match &cipher.r#type {
460 CipherType::Login(login) => login,
461 _ => panic!("Expected login"),
462 };
463
464 assert_eq!(login.username, Some("basic_username".to_string()));
466 assert_eq!(login.password, Some("basic_password".to_string()));
467
468 assert_eq!(login.login_uris.len(), 1);
470 assert_eq!(
471 login.login_uris[0].uri,
472 Some("https://example.com".to_string())
473 );
474
475 assert!(login.fido2_credentials.is_some());
477 }
478
479 #[test]
480 fn test_passkey_with_empty_username() {
481 let item = Item {
482 id: URL_SAFE_NO_PAD
483 .decode("Njk1RERENTItNkQ0Ny00NERBLTlFN0EtNDM1MjNEQjYzNjVF")
484 .unwrap()
485 .as_slice()
486 .into(),
487 creation_at: Some(1732181986),
488 modified_at: Some(1732182026),
489 title: "Empty Username Passkey".to_string(),
490 subtitle: None,
491 favorite: None,
492 credentials: vec![Credential::Passkey(Box::new(PasskeyCredential {
493 credential_id: URL_SAFE_NO_PAD
494 .decode("6NiHiekW4ZY8vYHa-ucbvA")
495 .unwrap()
496 .as_slice()
497 .into(),
498 rp_id: "example.com".to_string(),
499 username: "".to_string(), user_display_name: "User Display".to_string(),
501 user_handle: URL_SAFE_NO_PAD
502 .decode("YWxleCBtdWxsZXI")
503 .unwrap()
504 .as_slice()
505 .into(),
506 key: URL_SAFE_NO_PAD
507 .decode("MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgPzvtWYWmIsvqqr3LsZB0K-cbjuhJSGTGziL1LksHAPShRANCAAT-vqHTyEDS9QBNNi2BNLyu6TunubJT_L3G3i7KLpEDhMD15hi24IjGBH0QylJIrvlT4JN2tdRGF436XGc-VoAl")
508 .unwrap()
509 .as_slice()
510 .into(),
511 fido2_extensions: None,
512 }))],
513 tags: None,
514 extensions: None,
515 scope: None,
516 };
517
518 let ciphers: Vec<ImportingCipher> = parse_item(item);
519 assert_eq!(ciphers.len(), 1);
520 let cipher = ciphers.first().unwrap();
521
522 let login = match &cipher.r#type {
523 CipherType::Login(login) => login,
524 _ => panic!("Expected login"),
525 };
526
527 assert_eq!(login.username, None);
529 assert_eq!(login.password, None);
530
531 assert_eq!(login.login_uris.len(), 1);
533 assert_eq!(
534 login.login_uris[0].uri,
535 Some("https://example.com".to_string())
536 );
537 }
538
539 #[test]
540 fn test_credit_card() {
541 let item = Item {
542 id: [0, 1, 2, 3, 4, 5, 6].as_ref().into(),
543 creation_at: Some(1706613834),
544 modified_at: Some(1706623773),
545 title: "My MasterCard".to_string(),
546 subtitle: None,
547 favorite: None,
548 credentials: vec![Credential::CreditCard(Box::new(CreditCardCredential {
549 number: Some("1234 5678 9012 3456".to_string().into()),
550 full_name: Some("John Doe".to_string().into()),
551 card_type: Some("MasterCard".to_string().into()),
552 verification_number: Some("123".to_string().into()),
553 pin: None,
554 expiry_date: Some(
555 EditableFieldYearMonth {
556 year: 2026,
557 month: Month::January,
558 }
559 .into(),
560 ),
561 valid_from: None,
562 }))],
563 tags: None,
564 extensions: None,
565 scope: None,
566 };
567
568 let ciphers: Vec<ImportingCipher> = parse_item(item);
569 assert_eq!(ciphers.len(), 1);
570 let cipher = ciphers.first().unwrap();
571
572 assert_eq!(cipher.folder_id, None);
573 assert_eq!(cipher.name, "My MasterCard");
574
575 let card = match &cipher.r#type {
576 CipherType::Card(card) => card,
577 _ => panic!("Expected card"),
578 };
579
580 assert_eq!(card.cardholder_name, Some("John Doe".to_string()));
581 assert_eq!(card.exp_month, Some("1".to_string()));
582 assert_eq!(card.exp_year, Some("2026".to_string()));
583 assert_eq!(card.code, Some("123".to_string()));
584 assert_eq!(card.brand, Some("Mastercard".to_string()));
585 assert_eq!(card.number, Some("1234 5678 9012 3456".to_string()));
586 }
587
588 #[test]
589 fn test_totp() {
590 use credential_exchange_format::{OTPHashAlgorithm, TotpCredential};
591
592 let item = Item {
593 id: [0, 1, 2, 3, 4, 5, 6].as_ref().into(),
594 creation_at: Some(1706613834),
595 modified_at: Some(1706623773),
596 title: "My TOTP".to_string(),
597 subtitle: None,
598 favorite: None,
599 credentials: vec![Credential::Totp(Box::new(TotpCredential {
600 secret: "Hello World!".as_bytes().to_vec().into(),
601 period: 30,
602 digits: 6,
603 username: Some("[email protected]".to_string()),
604 algorithm: OTPHashAlgorithm::Sha1,
605 issuer: Some("Example Service".to_string()),
606 }))],
607 tags: None,
608 extensions: None,
609 scope: None,
610 };
611
612 let ciphers: Vec<ImportingCipher> = parse_item(item);
613 assert_eq!(ciphers.len(), 1);
614 let cipher = ciphers.first().unwrap();
615
616 assert_eq!(cipher.folder_id, None);
617 assert_eq!(cipher.name, "My TOTP");
618 assert_eq!(cipher.notes, None);
619 assert!(!cipher.favorite);
620 assert_eq!(cipher.reprompt, 0);
621 assert_eq!(cipher.fields, vec![]);
622
623 let login = match &cipher.r#type {
624 CipherType::Login(login) => login,
625 _ => panic!("Expected login cipher for TOTP"),
626 };
627
628 assert!(login.totp.is_some());
630 let otpauth = login.totp.as_ref().unwrap();
631
632 assert!(
634 otpauth.starts_with("otpauth://totp/Example%20Service:test%40example%2Ecom?secret=")
635 );
636 assert!(otpauth.contains("&issuer=Example%20Service"));
637
638 assert!(!otpauth.contains("&period=30"));
640 assert!(!otpauth.contains("&digits=6"));
641 assert!(!otpauth.contains("&algorithm=SHA1"));
642
643 assert_eq!(login.username, None);
645 assert_eq!(login.password, None);
646 assert_eq!(login.login_uris, vec![]);
647 }
648
649 #[test]
650 fn test_totp_combined_with_basic_auth() {
651 use credential_exchange_format::{BasicAuthCredential, OTPHashAlgorithm, TotpCredential};
652
653 let item = Item {
654 id: [0, 1, 2, 3, 4, 5, 6].as_ref().into(),
655 creation_at: Some(1706613834),
656 modified_at: Some(1706623773),
657 title: "Login with TOTP".to_string(),
658 subtitle: None,
659 favorite: None,
660 credentials: vec![
661 Credential::BasicAuth(Box::new(BasicAuthCredential {
662 username: Some("myuser".to_string().into()),
663 password: Some("mypass".to_string().into()),
664 })),
665 Credential::Totp(Box::new(TotpCredential {
666 secret: "totpkey".as_bytes().to_vec().into(),
667 period: 30,
668 digits: 6,
669 username: Some("totpuser".to_string()),
670 algorithm: OTPHashAlgorithm::Sha1,
671 issuer: Some("Service".to_string()),
672 })),
673 ],
674 tags: None,
675 extensions: None,
676 scope: None,
677 };
678
679 let ciphers: Vec<ImportingCipher> = parse_item(item);
680 assert_eq!(ciphers.len(), 1);
681 let cipher = ciphers.first().unwrap();
682
683 let login = match &cipher.r#type {
684 CipherType::Login(login) => login,
685 _ => panic!("Expected login cipher"),
686 };
687
688 assert_eq!(login.username, Some("myuser".to_string()));
690 assert_eq!(login.password, Some("mypass".to_string()));
691 assert!(login.totp.is_some());
692
693 let otpauth = login.totp.as_ref().unwrap();
694 assert!(otpauth.starts_with("otpauth://totp/Service:totpuser?secret="));
695 assert!(otpauth.contains("&issuer=Service"));
696 }
697
698 #[test]
701 fn test_note_as_part_of_login() {
702 use credential_exchange_format::{BasicAuthCredential, Credential, Item, NoteCredential};
703
704 let item = Item {
705 id: [0, 1, 2, 3, 4, 5, 6].as_ref().into(),
706 creation_at: Some(1706613834),
707 modified_at: Some(1706623773),
708 title: "Login with Note".to_string(),
709 subtitle: None,
710 favorite: None,
711 credentials: vec![
712 Credential::BasicAuth(Box::new(BasicAuthCredential {
713 username: Some("testuser".to_string().into()),
714 password: Some("testpass".to_string().into()),
715 })),
716 Credential::Note(Box::new(NoteCredential {
717 content: "This note should be added to the login cipher."
718 .to_string()
719 .into(),
720 })),
721 ],
722 tags: None,
723 extensions: None,
724 scope: None,
725 };
726
727 let ciphers: Vec<ImportingCipher> = parse_item(item);
728 assert_eq!(ciphers.len(), 1); let cipher = ciphers.first().unwrap();
730
731 assert_eq!(cipher.name, "Login with Note");
732 assert_eq!(
733 cipher.notes,
734 Some("This note should be added to the login cipher.".to_string())
735 );
736
737 match &cipher.r#type {
738 CipherType::Login(_) => (), _ => panic!("Expected Login cipher with note content"),
740 };
741 }
742
743 #[test]
744 fn test_note_as_part_of_api_key() {
745 use credential_exchange_format::{ApiKeyCredential, Credential, Item, NoteCredential};
746
747 let item = Item {
748 id: [0, 1, 2, 3, 4, 5, 6].as_ref().into(),
749 creation_at: Some(1706613834),
750 modified_at: Some(1706623773),
751 title: "API Key with Note".to_string(),
752 subtitle: None,
753 favorite: None,
754 credentials: vec![
755 Credential::ApiKey(Box::new(ApiKeyCredential {
756 key: Some("api-key-12345".to_string().into()),
757 username: Some("api-user".to_string().into()),
758 key_type: Some("Bearer".to_string().into()),
759 url: None,
760 valid_from: None,
761 expiry_date: None,
762 })),
763 Credential::Note(Box::new(NoteCredential {
764 content: "This note should be added to the API key cipher."
765 .to_string()
766 .into(),
767 })),
768 ],
769 tags: None,
770 extensions: None,
771 scope: None,
772 };
773
774 let ciphers: Vec<ImportingCipher> = parse_item(item);
775 assert_eq!(ciphers.len(), 1); let cipher = ciphers.first().unwrap();
777
778 assert_eq!(cipher.name, "API Key with Note");
779 assert_eq!(
780 cipher.notes,
781 Some("This note should be added to the API key cipher.".to_string())
782 );
783
784 match &cipher.r#type {
785 CipherType::SecureNote(_) => (), _ => panic!("Expected SecureNote cipher with note content"),
787 };
788
789 assert!(!cipher.fields.is_empty());
791 }
792
793 #[test]
794 fn test_note_as_part_of_credit_card() {
795 use chrono::Month;
796 use credential_exchange_format::{Credential, CreditCardCredential, Item, NoteCredential};
797
798 let item = Item {
799 id: [0, 1, 2, 3, 4, 5, 6].as_ref().into(),
800 creation_at: Some(1706613834),
801 modified_at: Some(1706623773),
802 title: "Credit Card with Note".to_string(),
803 subtitle: None,
804 favorite: None,
805 credentials: vec![
806 Credential::CreditCard(Box::new(CreditCardCredential {
807 number: Some("1234 5678 9012 3456".to_string().into()),
808 full_name: Some("John Doe".to_string().into()),
809 card_type: Some("Visa".to_string().into()),
810 verification_number: Some("123".to_string().into()),
811 pin: None,
812 expiry_date: Some(
813 credential_exchange_format::EditableFieldYearMonth {
814 year: 2026,
815 month: Month::December,
816 }
817 .into(),
818 ),
819 valid_from: None,
820 })),
821 Credential::Note(Box::new(NoteCredential {
822 content: "This note should be added to the credit card cipher."
823 .to_string()
824 .into(),
825 })),
826 ],
827 tags: None,
828 extensions: None,
829 scope: None,
830 };
831
832 let ciphers: Vec<ImportingCipher> = parse_item(item);
833 assert_eq!(ciphers.len(), 1); let cipher = ciphers.first().unwrap();
835
836 assert_eq!(cipher.name, "Credit Card with Note");
837 assert_eq!(
838 cipher.notes,
839 Some("This note should be added to the credit card cipher.".to_string())
840 );
841
842 match &cipher.r#type {
843 CipherType::Card(_) => (), _ => panic!("Expected Card cipher with note content"),
845 };
846 }
847
848 #[test]
849 fn test_note_as_part_of_wifi() {
850 use credential_exchange_format::{
851 Credential, EditableFieldWifiNetworkSecurityType, Item, NoteCredential, WifiCredential,
852 };
853
854 let item = Item {
855 id: [0, 1, 2, 3, 4, 5, 6].as_ref().into(),
856 creation_at: Some(1706613834),
857 modified_at: Some(1706623773),
858 title: "WiFi with Note".to_string(),
859 subtitle: None,
860 favorite: None,
861 credentials: vec![
862 Credential::Wifi(Box::new(WifiCredential {
863 ssid: Some("MyNetwork".to_string().into()),
864 passphrase: Some("password123".to_string().into()),
865 network_security_type: Some(
866 EditableFieldWifiNetworkSecurityType::Wpa3Personal.into(),
867 ),
868 hidden: Some(false.into()),
869 })),
870 Credential::Note(Box::new(NoteCredential {
871 content: "This note should be added to the WiFi cipher."
872 .to_string()
873 .into(),
874 })),
875 ],
876 tags: None,
877 extensions: None,
878 scope: None,
879 };
880
881 let ciphers: Vec<ImportingCipher> = parse_item(item);
882 assert_eq!(ciphers.len(), 1); let cipher = ciphers.first().unwrap();
884
885 assert_eq!(cipher.name, "WiFi with Note");
886 assert_eq!(
887 cipher.notes,
888 Some("This note should be added to the WiFi cipher.".to_string())
889 );
890
891 match &cipher.r#type {
892 CipherType::SecureNote(_) => (), _ => panic!("Expected SecureNote cipher with note content"),
894 };
895
896 assert!(!cipher.fields.is_empty());
898 }
899
900 #[test]
901 fn test_note_as_part_of_identity() {
902 use credential_exchange_format::{AddressCredential, Credential, Item, NoteCredential};
903
904 let item = Item {
905 id: [0, 1, 2, 3, 4, 5, 6].as_ref().into(),
906 creation_at: Some(1706613834),
907 modified_at: Some(1706623773),
908 title: "Address with Note".to_string(),
909 subtitle: None,
910 favorite: None,
911 credentials: vec![
912 Credential::Address(Box::new(AddressCredential {
913 street_address: Some("123 Main St".to_string().into()),
914 city: Some("Springfield".to_string().into()),
915 territory: Some("CA".to_string().into()),
916 postal_code: Some("12345".to_string().into()),
917 country: Some("US".to_string().into()),
918 tel: Some("+1-555-123-4567".to_string().into()),
919 })),
920 Credential::Note(Box::new(NoteCredential {
921 content: "This note should be added to the address identity cipher."
922 .to_string()
923 .into(),
924 })),
925 ],
926 tags: None,
927 extensions: None,
928 scope: None,
929 };
930
931 let ciphers: Vec<ImportingCipher> = parse_item(item);
932 assert_eq!(ciphers.len(), 1); let cipher = ciphers.first().unwrap();
934
935 assert_eq!(cipher.name, "Address with Note");
936 assert_eq!(
937 cipher.notes,
938 Some("This note should be added to the address identity cipher.".to_string())
939 );
940
941 match &cipher.r#type {
942 CipherType::Identity(_) => (), _ => panic!("Expected Identity cipher"),
944 };
945 }
946}