bitwarden_exporters/cxf/
import.rs

1use chrono::{DateTime, Utc};
2use credential_exchange_format::{
3    Account as CxfAccount, AddressCredential, ApiKeyCredential, BasicAuthCredential, Credential,
4    CreditCardCredential, DriversLicenseCredential, IdentityDocumentCredential, Item,
5    NoteCredential, PasskeyCredential, PassportCredential, PersonNameCredential, SshKeyCredential,
6    TotpCredential, WifiCredential,
7};
8
9use crate::{
10    cxf::{
11        api_key::api_key_to_fields,
12        card::to_card,
13        identity::{
14            address_to_identity, drivers_license_to_identity, identity_document_to_identity,
15            passport_to_identity, person_name_to_identity,
16        },
17        login::to_login,
18        note::extract_note_content,
19        ssh::to_ssh,
20        wifi::wifi_to_fields,
21        CxfError,
22    },
23    CipherType, Field, ImportingCipher, SecureNote, SecureNoteType,
24};
25
26/**
27 * Parse CXF payload in the format compatible with Apple (At the Account-level)
28 */
29pub(crate) fn parse_cxf(payload: String) -> Result<Vec<ImportingCipher>, CxfError> {
30    let account: CxfAccount = serde_json::from_str(&payload)?;
31
32    let items: Vec<ImportingCipher> = account.items.into_iter().flat_map(parse_item).collect();
33
34    Ok(items)
35}
36
37/// Convert a CXF timestamp to a [`DateTime<Utc>`].
38///
39/// If the timestamp is None, the current time is used.
40fn convert_date(ts: Option<u64>) -> DateTime<Utc> {
41    ts.and_then(|ts| DateTime::from_timestamp(ts as i64, 0))
42        .unwrap_or(Utc::now())
43}
44
45pub(super) fn parse_item(value: Item) -> Vec<ImportingCipher> {
46    let grouped = group_credentials_by_type(value.credentials);
47
48    let creation_date = convert_date(value.creation_at);
49    let revision_date = convert_date(value.modified_at);
50
51    let mut output = vec![];
52
53    let scope = value.scope.as_ref();
54
55    // Extract note content if present (to be added to parent cipher)
56    let note_content = grouped.note.first().map(extract_note_content);
57
58    // Helper to add ciphers with consistent boilerplate
59    let mut add_item = |t: CipherType, fields: Vec<Field>| {
60        output.push(ImportingCipher {
61            folder_id: None, // TODO: Handle folders
62            name: value.title.clone(),
63            notes: note_content.clone(),
64            r#type: t,
65            favorite: false,
66            reprompt: 0,
67            fields,
68            revision_date,
69            creation_date,
70            deleted_date: None,
71        })
72    };
73
74    // Login credentials
75    if !grouped.basic_auth.is_empty() || !grouped.passkey.is_empty() || !grouped.totp.is_empty() {
76        let basic_auth = grouped.basic_auth.first();
77        let passkey = grouped.passkey.first();
78        let totp = grouped.totp.first();
79
80        let login = to_login(creation_date, basic_auth, passkey, totp, scope);
81        add_item(CipherType::Login(Box::new(login)), vec![]);
82    }
83
84    // Credit Card credentials
85    if let Some(credit_card) = grouped.credit_card.first() {
86        let (card, fields) = to_card(credit_card);
87
88        add_item(CipherType::Card(Box::new(card)), fields);
89    }
90
91    // Helper for creating SecureNote cipher type
92    let secure_note_type = || {
93        CipherType::SecureNote(Box::new(SecureNote {
94            r#type: SecureNoteType::Generic,
95        }))
96    };
97
98    // API Key credentials -> Secure Note
99    if let Some(api_key) = grouped.api_key.first() {
100        let fields = api_key_to_fields(api_key);
101        add_item(secure_note_type(), fields);
102    }
103
104    // WiFi credentials -> Secure Note
105    if let Some(wifi) = grouped.wifi.first() {
106        let fields = wifi_to_fields(wifi);
107        add_item(secure_note_type(), fields);
108    }
109
110    // Identity credentials (address, passport, person name, drivers license, identity document)
111    [
112        grouped
113            .address
114            .first()
115            .map(|a| address_to_identity(a.clone())),
116        grouped
117            .passport
118            .first()
119            .map(|p| passport_to_identity(p.clone())),
120        grouped
121            .person_name
122            .first()
123            .map(|p| person_name_to_identity(p.clone())),
124        grouped
125            .drivers_license
126            .first()
127            .map(|d| drivers_license_to_identity(d.clone())),
128        grouped
129            .identity_document
130            .first()
131            .map(|i| identity_document_to_identity(i.clone())),
132    ]
133    .into_iter()
134    .flatten()
135    .for_each(|(identity, custom_fields)| {
136        add_item(CipherType::Identity(Box::new(identity)), custom_fields);
137    });
138
139    // SSH Key credentials
140    if let Some(ssh) = grouped.ssh.first() {
141        match to_ssh(ssh) {
142            Ok((ssh_key, fields)) => add_item(CipherType::SshKey(Box::new(ssh_key)), fields),
143            Err(_) => {
144                // Include information about the failed items, or import as note?
145            }
146        }
147    }
148
149    // Standalone Note credentials -> Secure Note (only if no other credentials exist)
150    if !grouped.note.is_empty() && output.is_empty() {
151        let standalone_note_content = grouped.note.first().map(extract_note_content);
152        output.push(ImportingCipher {
153            folder_id: None, // TODO: Handle folders
154            name: value.title.clone(),
155            notes: standalone_note_content,
156            r#type: secure_note_type(),
157            favorite: false,
158            reprompt: 0,
159            fields: vec![],
160            revision_date,
161            creation_date,
162            deleted_date: None,
163        });
164    }
165
166    output
167}
168
169/// Group credentials by type.
170///
171/// The Credential Exchange protocol allows multiple identical credentials to be stored in a single
172/// item. Currently we only support one of each type and grouping allows an easy way to fetch the
173/// first of each type. Eventually we should add support for handling multiple credentials of the
174/// same type.
175fn group_credentials_by_type(credentials: Vec<Credential>) -> GroupedCredentials {
176    fn filter_credentials<T>(
177        credentials: &[Credential],
178        f: impl Fn(&Credential) -> Option<&T>,
179    ) -> Vec<T>
180    where
181        T: Clone,
182    {
183        credentials.iter().filter_map(f).cloned().collect()
184    }
185
186    GroupedCredentials {
187        api_key: filter_credentials(&credentials, |c| match c {
188            Credential::ApiKey(api_key) => Some(api_key.as_ref()),
189            _ => None,
190        }),
191        basic_auth: filter_credentials(&credentials, |c| match c {
192            Credential::BasicAuth(basic_auth) => Some(basic_auth.as_ref()),
193            _ => None,
194        }),
195        credit_card: filter_credentials(&credentials, |c| match c {
196            Credential::CreditCard(credit_card) => Some(credit_card.as_ref()),
197            _ => None,
198        }),
199        passkey: filter_credentials(&credentials, |c| match c {
200            Credential::Passkey(passkey) => Some(passkey.as_ref()),
201            _ => None,
202        }),
203        ssh: filter_credentials(&credentials, |c| match c {
204            Credential::SshKey(ssh) => Some(ssh.as_ref()),
205            _ => None,
206        }),
207        totp: filter_credentials(&credentials, |c| match c {
208            Credential::Totp(totp) => Some(totp.as_ref()),
209            _ => None,
210        }),
211        wifi: filter_credentials(&credentials, |c| match c {
212            Credential::Wifi(wifi) => Some(wifi.as_ref()),
213            _ => None,
214        }),
215        address: filter_credentials(&credentials, |c| match c {
216            Credential::Address(address) => Some(address.as_ref()),
217            _ => None,
218        }),
219        passport: filter_credentials(&credentials, |c| match c {
220            Credential::Passport(passport) => Some(passport.as_ref()),
221            _ => None,
222        }),
223        person_name: filter_credentials(&credentials, |c| match c {
224            Credential::PersonName(person_name) => Some(person_name.as_ref()),
225            _ => None,
226        }),
227        drivers_license: filter_credentials(&credentials, |c| match c {
228            Credential::DriversLicense(drivers_license) => Some(drivers_license.as_ref()),
229            _ => None,
230        }),
231        identity_document: filter_credentials(&credentials, |c| match c {
232            Credential::IdentityDocument(identity_document) => Some(identity_document.as_ref()),
233            _ => None,
234        }),
235        note: filter_credentials(&credentials, |c| match c {
236            Credential::Note(note) => Some(note.as_ref()),
237            _ => None,
238        }),
239    }
240}
241
242struct GroupedCredentials {
243    address: Vec<AddressCredential>,
244    api_key: Vec<ApiKeyCredential>,
245    basic_auth: Vec<BasicAuthCredential>,
246    credit_card: Vec<CreditCardCredential>,
247    drivers_license: Vec<DriversLicenseCredential>,
248    identity_document: Vec<IdentityDocumentCredential>,
249    note: Vec<NoteCredential>,
250    passkey: Vec<PasskeyCredential>,
251    passport: Vec<PassportCredential>,
252    person_name: Vec<PersonNameCredential>,
253    ssh: Vec<SshKeyCredential>,
254    totp: Vec<TotpCredential>,
255    wifi: Vec<WifiCredential>,
256}
257
258#[cfg(test)]
259mod tests {
260    use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
261    use chrono::{Duration, Month};
262    use credential_exchange_format::{CreditCardCredential, EditableFieldYearMonth};
263
264    use super::*;
265
266    #[test]
267    fn test_convert_date() {
268        let timestamp: u64 = 1706613834;
269        let datetime = convert_date(Some(timestamp));
270        assert_eq!(
271            datetime,
272            "2024-01-30T11:23:54Z".parse::<DateTime<Utc>>().unwrap()
273        );
274    }
275
276    #[test]
277    fn test_convert_date_none() {
278        let datetime = convert_date(None);
279        assert!(datetime > Utc::now() - Duration::seconds(1));
280        assert!(datetime <= Utc::now());
281    }
282
283    #[test]
284    fn test_parse_empty_item() {
285        let item = Item {
286            id: [0, 1, 2, 3, 4, 5, 6].as_ref().into(),
287            creation_at: Some(1706613834),
288            modified_at: Some(1706623773),
289            title: "Bitwarden".to_string(),
290            subtitle: None,
291            favorite: None,
292            credentials: vec![],
293            tags: None,
294            extensions: None,
295            scope: None,
296        };
297
298        let ciphers: Vec<ImportingCipher> = parse_item(item);
299        assert_eq!(ciphers.len(), 0);
300    }
301
302    #[test]
303    fn test_parse_passkey() {
304        let item = Item {
305            id: URL_SAFE_NO_PAD
306                .decode("Njk1RERENTItNkQ0Ny00NERBLTlFN0EtNDM1MjNEQjYzNjVF")
307                .unwrap()
308                .as_slice()
309                .into(),
310            creation_at: Some(1732181986),
311            modified_at: Some(1732182026),
312            title: "opotonniee.github.io".to_string(),
313            subtitle: None,
314            favorite: None,
315            credentials: vec![Credential::Passkey(Box::new(PasskeyCredential {
316                credential_id: URL_SAFE_NO_PAD
317                    .decode("6NiHiekW4ZY8vYHa-ucbvA")
318                    .unwrap()
319                    .as_slice()
320                    .into(),
321                rp_id: "opotonniee.github.io".to_string(),
322                username: "alex muller".to_string(),
323                user_display_name: "alex muller".to_string(),
324                user_handle: URL_SAFE_NO_PAD
325                    .decode("YWxleCBtdWxsZXI")
326                    .unwrap()
327                    .as_slice()
328                    .into(),
329                key: URL_SAFE_NO_PAD
330                    .decode("MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgPzvtWYWmIsvqqr3LsZB0K-cbjuhJSGTGziL1LksHAPShRANCAAT-vqHTyEDS9QBNNi2BNLyu6TunubJT_L3G3i7KLpEDhMD15hi24IjGBH0QylJIrvlT4JN2tdRGF436XGc-VoAl")
331                    .unwrap()
332                    .as_slice()
333                    .into(),
334                fido2_extensions: None,
335            }))],
336            tags: None,
337            extensions: None,
338            scope: None,
339        };
340
341        let ciphers: Vec<ImportingCipher> = parse_item(item);
342        assert_eq!(ciphers.len(), 1);
343        let cipher = ciphers.first().unwrap();
344
345        assert_eq!(cipher.folder_id, None);
346        assert_eq!(cipher.name, "opotonniee.github.io");
347
348        let login = match &cipher.r#type {
349            CipherType::Login(login) => login,
350            _ => panic!("Expected login"),
351        };
352
353        assert_eq!(login.username, None);
354        assert_eq!(login.password, None);
355        assert_eq!(login.login_uris.len(), 0);
356        assert_eq!(login.totp, None);
357
358        let passkey = login.fido2_credentials.as_ref().unwrap().first().unwrap();
359        assert_eq!(passkey.credential_id, "b64.6NiHiekW4ZY8vYHa-ucbvA");
360        assert_eq!(passkey.key_type, "public-key");
361        assert_eq!(passkey.key_algorithm, "ECDSA");
362        assert_eq!(passkey.key_curve, "P-256");
363        assert_eq!(
364            passkey.key_value,
365            "MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgPzvtWYWmIsvqqr3LsZB0K-cbjuhJSGTGziL1LksHAPShRANCAAT-vqHTyEDS9QBNNi2BNLyu6TunubJT_L3G3i7KLpEDhMD15hi24IjGBH0QylJIrvlT4JN2tdRGF436XGc-VoAl"
366        );
367        assert_eq!(passkey.rp_id, "opotonniee.github.io");
368        assert_eq!(
369            passkey.user_handle.as_ref().map(|h| h.to_string()).unwrap(),
370            "YWxleCBtdWxsZXI"
371        );
372        assert_eq!(passkey.user_name, Some("alex muller".to_string()));
373        assert_eq!(passkey.counter, 0);
374        assert_eq!(passkey.rp_name, Some("opotonniee.github.io".to_string()));
375        assert_eq!(passkey.user_display_name, Some("alex muller".to_string()));
376        assert_eq!(passkey.discoverable, "true");
377        assert_eq!(
378            passkey.creation_date,
379            "2024-11-21T09:39:46Z".parse::<DateTime<Utc>>().unwrap()
380        );
381    }
382
383    #[test]
384    fn test_credit_card() {
385        let item = Item {
386            id: [0, 1, 2, 3, 4, 5, 6].as_ref().into(),
387            creation_at: Some(1706613834),
388            modified_at: Some(1706623773),
389            title: "My MasterCard".to_string(),
390            subtitle: None,
391            favorite: None,
392            credentials: vec![Credential::CreditCard(Box::new(CreditCardCredential {
393                number: Some("1234 5678 9012 3456".to_string().into()),
394                full_name: Some("John Doe".to_string().into()),
395                card_type: Some("MasterCard".to_string().into()),
396                verification_number: Some("123".to_string().into()),
397                pin: None,
398                expiry_date: Some(
399                    EditableFieldYearMonth {
400                        year: 2026,
401                        month: Month::January,
402                    }
403                    .into(),
404                ),
405                valid_from: None,
406            }))],
407            tags: None,
408            extensions: None,
409            scope: None,
410        };
411
412        let ciphers: Vec<ImportingCipher> = parse_item(item);
413        assert_eq!(ciphers.len(), 1);
414        let cipher = ciphers.first().unwrap();
415
416        assert_eq!(cipher.folder_id, None);
417        assert_eq!(cipher.name, "My MasterCard");
418
419        let card = match &cipher.r#type {
420            CipherType::Card(card) => card,
421            _ => panic!("Expected card"),
422        };
423
424        assert_eq!(card.cardholder_name, Some("John Doe".to_string()));
425        assert_eq!(card.exp_month, Some("1".to_string()));
426        assert_eq!(card.exp_year, Some("2026".to_string()));
427        assert_eq!(card.code, Some("123".to_string()));
428        assert_eq!(card.brand, Some("Mastercard".to_string()));
429        assert_eq!(card.number, Some("1234 5678 9012 3456".to_string()));
430    }
431
432    #[test]
433    fn test_totp() {
434        use credential_exchange_format::{OTPHashAlgorithm, TotpCredential};
435
436        let item = Item {
437            id: [0, 1, 2, 3, 4, 5, 6].as_ref().into(),
438            creation_at: Some(1706613834),
439            modified_at: Some(1706623773),
440            title: "My TOTP".to_string(),
441            subtitle: None,
442            favorite: None,
443            credentials: vec![Credential::Totp(Box::new(TotpCredential {
444                secret: "Hello World!".as_bytes().to_vec().into(),
445                period: 30,
446                digits: 6,
447                username: Some("[email protected]".to_string()),
448                algorithm: OTPHashAlgorithm::Sha1,
449                issuer: Some("Example Service".to_string()),
450            }))],
451            tags: None,
452            extensions: None,
453            scope: None,
454        };
455
456        let ciphers: Vec<ImportingCipher> = parse_item(item);
457        assert_eq!(ciphers.len(), 1);
458        let cipher = ciphers.first().unwrap();
459
460        assert_eq!(cipher.folder_id, None);
461        assert_eq!(cipher.name, "My TOTP");
462        assert_eq!(cipher.notes, None);
463        assert!(!cipher.favorite);
464        assert_eq!(cipher.reprompt, 0);
465        assert_eq!(cipher.fields, vec![]);
466
467        let login = match &cipher.r#type {
468            CipherType::Login(login) => login,
469            _ => panic!("Expected login cipher for TOTP"),
470        };
471
472        // TOTP should be mapped to login.totp as otpauth URI
473        assert!(login.totp.is_some());
474        let otpauth = login.totp.as_ref().unwrap();
475
476        // Verify the otpauth URI format and content
477        assert!(
478            otpauth.starts_with("otpauth://totp/Example%20Service:test%40example%2Ecom?secret=")
479        );
480        assert!(otpauth.contains("&issuer=Example%20Service"));
481
482        // Default values should not be present in URI
483        assert!(!otpauth.contains("&period=30"));
484        assert!(!otpauth.contains("&digits=6"));
485        assert!(!otpauth.contains("&algorithm=SHA1"));
486
487        // Other login fields should be None since only TOTP was provided
488        assert_eq!(login.username, None);
489        assert_eq!(login.password, None);
490        assert_eq!(login.login_uris, vec![]);
491    }
492
493    #[test]
494    fn test_totp_combined_with_basic_auth() {
495        use credential_exchange_format::{BasicAuthCredential, OTPHashAlgorithm, TotpCredential};
496
497        let item = Item {
498            id: [0, 1, 2, 3, 4, 5, 6].as_ref().into(),
499            creation_at: Some(1706613834),
500            modified_at: Some(1706623773),
501            title: "Login with TOTP".to_string(),
502            subtitle: None,
503            favorite: None,
504            credentials: vec![
505                Credential::BasicAuth(Box::new(BasicAuthCredential {
506                    username: Some("myuser".to_string().into()),
507                    password: Some("mypass".to_string().into()),
508                })),
509                Credential::Totp(Box::new(TotpCredential {
510                    secret: "totpkey".as_bytes().to_vec().into(),
511                    period: 30,
512                    digits: 6,
513                    username: Some("totpuser".to_string()),
514                    algorithm: OTPHashAlgorithm::Sha1,
515                    issuer: Some("Service".to_string()),
516                })),
517            ],
518            tags: None,
519            extensions: None,
520            scope: None,
521        };
522
523        let ciphers: Vec<ImportingCipher> = parse_item(item);
524        assert_eq!(ciphers.len(), 1);
525        let cipher = ciphers.first().unwrap();
526
527        let login = match &cipher.r#type {
528            CipherType::Login(login) => login,
529            _ => panic!("Expected login cipher"),
530        };
531
532        // Should have both basic auth and TOTP
533        assert_eq!(login.username, Some("myuser".to_string()));
534        assert_eq!(login.password, Some("mypass".to_string()));
535        assert!(login.totp.is_some());
536
537        let otpauth = login.totp.as_ref().unwrap();
538        assert!(otpauth.starts_with("otpauth://totp/Service:totpuser?secret="));
539        assert!(otpauth.contains("&issuer=Service"));
540    }
541
542    // Note integration tests
543
544    #[test]
545    fn test_note_as_part_of_login() {
546        use credential_exchange_format::{BasicAuthCredential, Credential, Item, NoteCredential};
547
548        let item = Item {
549            id: [0, 1, 2, 3, 4, 5, 6].as_ref().into(),
550            creation_at: Some(1706613834),
551            modified_at: Some(1706623773),
552            title: "Login with Note".to_string(),
553            subtitle: None,
554            favorite: None,
555            credentials: vec![
556                Credential::BasicAuth(Box::new(BasicAuthCredential {
557                    username: Some("testuser".to_string().into()),
558                    password: Some("testpass".to_string().into()),
559                })),
560                Credential::Note(Box::new(NoteCredential {
561                    content: "This note should be added to the login cipher."
562                        .to_string()
563                        .into(),
564                })),
565            ],
566            tags: None,
567            extensions: None,
568            scope: None,
569        };
570
571        let ciphers: Vec<ImportingCipher> = parse_item(item);
572        assert_eq!(ciphers.len(), 1); // Should create only one cipher (Login with note content)
573        let cipher = ciphers.first().unwrap();
574
575        assert_eq!(cipher.name, "Login with Note");
576        assert_eq!(
577            cipher.notes,
578            Some("This note should be added to the login cipher.".to_string())
579        );
580
581        match &cipher.r#type {
582            CipherType::Login(_) => (), // Should be a Login cipher
583            _ => panic!("Expected Login cipher with note content"),
584        };
585    }
586
587    #[test]
588    fn test_note_as_part_of_api_key() {
589        use credential_exchange_format::{ApiKeyCredential, Credential, Item, NoteCredential};
590
591        let item = Item {
592            id: [0, 1, 2, 3, 4, 5, 6].as_ref().into(),
593            creation_at: Some(1706613834),
594            modified_at: Some(1706623773),
595            title: "API Key with Note".to_string(),
596            subtitle: None,
597            favorite: None,
598            credentials: vec![
599                Credential::ApiKey(Box::new(ApiKeyCredential {
600                    key: Some("api-key-12345".to_string().into()),
601                    username: Some("api-user".to_string().into()),
602                    key_type: Some("Bearer".to_string().into()),
603                    url: None,
604                    valid_from: None,
605                    expiry_date: None,
606                })),
607                Credential::Note(Box::new(NoteCredential {
608                    content: "This note should be added to the API key cipher."
609                        .to_string()
610                        .into(),
611                })),
612            ],
613            tags: None,
614            extensions: None,
615            scope: None,
616        };
617
618        let ciphers: Vec<ImportingCipher> = parse_item(item);
619        assert_eq!(ciphers.len(), 1); // Should create only one cipher (SecureNote with note content)
620        let cipher = ciphers.first().unwrap();
621
622        assert_eq!(cipher.name, "API Key with Note");
623        assert_eq!(
624            cipher.notes,
625            Some("This note should be added to the API key cipher.".to_string())
626        );
627
628        match &cipher.r#type {
629            CipherType::SecureNote(_) => (), // Should be a SecureNote cipher
630            _ => panic!("Expected SecureNote cipher with note content"),
631        };
632
633        // Should have API key fields
634        assert!(!cipher.fields.is_empty());
635    }
636
637    #[test]
638    fn test_note_as_part_of_credit_card() {
639        use chrono::Month;
640        use credential_exchange_format::{Credential, CreditCardCredential, Item, NoteCredential};
641
642        let item = Item {
643            id: [0, 1, 2, 3, 4, 5, 6].as_ref().into(),
644            creation_at: Some(1706613834),
645            modified_at: Some(1706623773),
646            title: "Credit Card with Note".to_string(),
647            subtitle: None,
648            favorite: None,
649            credentials: vec![
650                Credential::CreditCard(Box::new(CreditCardCredential {
651                    number: Some("1234 5678 9012 3456".to_string().into()),
652                    full_name: Some("John Doe".to_string().into()),
653                    card_type: Some("Visa".to_string().into()),
654                    verification_number: Some("123".to_string().into()),
655                    pin: None,
656                    expiry_date: Some(
657                        credential_exchange_format::EditableFieldYearMonth {
658                            year: 2026,
659                            month: Month::December,
660                        }
661                        .into(),
662                    ),
663                    valid_from: None,
664                })),
665                Credential::Note(Box::new(NoteCredential {
666                    content: "This note should be added to the credit card cipher."
667                        .to_string()
668                        .into(),
669                })),
670            ],
671            tags: None,
672            extensions: None,
673            scope: None,
674        };
675
676        let ciphers: Vec<ImportingCipher> = parse_item(item);
677        assert_eq!(ciphers.len(), 1); // Should create only one cipher (Card with note content)
678        let cipher = ciphers.first().unwrap();
679
680        assert_eq!(cipher.name, "Credit Card with Note");
681        assert_eq!(
682            cipher.notes,
683            Some("This note should be added to the credit card cipher.".to_string())
684        );
685
686        match &cipher.r#type {
687            CipherType::Card(_) => (), // Should be a Card cipher
688            _ => panic!("Expected Card cipher with note content"),
689        };
690    }
691
692    #[test]
693    fn test_note_as_part_of_wifi() {
694        use credential_exchange_format::{
695            Credential, EditableFieldWifiNetworkSecurityType, Item, NoteCredential, WifiCredential,
696        };
697
698        let item = Item {
699            id: [0, 1, 2, 3, 4, 5, 6].as_ref().into(),
700            creation_at: Some(1706613834),
701            modified_at: Some(1706623773),
702            title: "WiFi with Note".to_string(),
703            subtitle: None,
704            favorite: None,
705            credentials: vec![
706                Credential::Wifi(Box::new(WifiCredential {
707                    ssid: Some("MyNetwork".to_string().into()),
708                    passphrase: Some("password123".to_string().into()),
709                    network_security_type: Some(
710                        EditableFieldWifiNetworkSecurityType::Wpa3Personal.into(),
711                    ),
712                    hidden: Some(false.into()),
713                })),
714                Credential::Note(Box::new(NoteCredential {
715                    content: "This note should be added to the WiFi cipher."
716                        .to_string()
717                        .into(),
718                })),
719            ],
720            tags: None,
721            extensions: None,
722            scope: None,
723        };
724
725        let ciphers: Vec<ImportingCipher> = parse_item(item);
726        assert_eq!(ciphers.len(), 1); // Should create only one cipher (SecureNote with note content)
727        let cipher = ciphers.first().unwrap();
728
729        assert_eq!(cipher.name, "WiFi with Note");
730        assert_eq!(
731            cipher.notes,
732            Some("This note should be added to the WiFi cipher.".to_string())
733        );
734
735        match &cipher.r#type {
736            CipherType::SecureNote(_) => (), // Should be a SecureNote cipher
737            _ => panic!("Expected SecureNote cipher with note content"),
738        };
739
740        // Should have WiFi fields
741        assert!(!cipher.fields.is_empty());
742    }
743
744    #[test]
745    fn test_note_as_part_of_identity() {
746        use credential_exchange_format::{AddressCredential, Credential, Item, NoteCredential};
747
748        let item = Item {
749            id: [0, 1, 2, 3, 4, 5, 6].as_ref().into(),
750            creation_at: Some(1706613834),
751            modified_at: Some(1706623773),
752            title: "Address with Note".to_string(),
753            subtitle: None,
754            favorite: None,
755            credentials: vec![
756                Credential::Address(Box::new(AddressCredential {
757                    street_address: Some("123 Main St".to_string().into()),
758                    city: Some("Springfield".to_string().into()),
759                    territory: Some("CA".to_string().into()),
760                    postal_code: Some("12345".to_string().into()),
761                    country: Some("US".to_string().into()),
762                    tel: Some("+1-555-123-4567".to_string().into()),
763                })),
764                Credential::Note(Box::new(NoteCredential {
765                    content: "This note should be added to the address identity cipher."
766                        .to_string()
767                        .into(),
768                })),
769            ],
770            tags: None,
771            extensions: None,
772            scope: None,
773        };
774
775        let ciphers: Vec<ImportingCipher> = parse_item(item);
776        assert_eq!(ciphers.len(), 1); // Should create only one cipher (Identity with note content)
777        let cipher = ciphers.first().unwrap();
778
779        assert_eq!(cipher.name, "Address with Note");
780        assert_eq!(
781            cipher.notes,
782            Some("This note should be added to the address identity cipher.".to_string())
783        );
784
785        match &cipher.r#type {
786            CipherType::Identity(_) => (), // Should be an Identity cipher
787            _ => panic!("Expected Identity cipher"),
788        };
789    }
790}