bitwarden_exporters/cxf/
import.rs

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
28/**
29 * Parse CXF payload in the format compatible with Apple (At the Account-level)
30 */
31pub(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
39/// Convert a CXF timestamp to a [`DateTime<Utc>`].
40///
41/// If the timestamp is None, the current time is used.
42fn 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
47/// Convert CustomFields credentials to Bitwarden Fields
48/// CustomFields contain arbitrary key-value pairs that should be preserved as custom fields
49fn 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    // Extract note content if present (to be added to parent cipher)
88    let note_content = grouped.note.first().map(extract_note_content);
89
90    // Helper to add ciphers with consistent boilerplate
91    let mut add_item = |t: CipherType, fields: Vec<Field>| {
92        output.push(ImportingCipher {
93            folder_id: None, // TODO: Handle folders
94            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    // Login credentials
107    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    // Credit Card credentials
117    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    // Helper for creating SecureNote cipher type
124    let secure_note_type = || {
125        CipherType::SecureNote(Box::new(SecureNote {
126            r#type: SecureNoteType::Generic,
127        }))
128    };
129
130    // API Key credentials -> Secure Note
131    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    // WiFi credentials -> Secure Note
137    if let Some(wifi) = grouped.wifi.first() {
138        let fields = wifi_to_fields(wifi);
139        add_item(secure_note_type(), fields);
140    }
141
142    // Identity credentials (address, passport, person name, drivers license, identity document)
143    [
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    // SSH Key credentials
172    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                // Include information about the failed items, or import as note?
177            }
178        }
179    }
180
181    // CustomFields credentials -> Secure Note
182    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    // Standalone Note credentials -> Secure Note (only if no other credentials exist)
188    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, // TODO: Handle folders
192            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
207/// Group credentials by type.
208///
209/// The Credential Exchange protocol allows multiple identical credentials to be stored in a single
210/// item. Currently we only support one of each type and grouping allows an easy way to fetch the
211/// first of each type. Eventually we should add support for handling multiple credentials of the
212/// same type.
213fn 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 chrono::{Duration, Month};
279    use credential_exchange_format::{B64Url, CreditCardCredential, EditableFieldYearMonth};
280
281    use super::*;
282
283    #[test]
284    fn test_convert_date() {
285        let timestamp: u64 = 1706613834;
286        let datetime = convert_date(Some(timestamp));
287        assert_eq!(
288            datetime,
289            "2024-01-30T11:23:54Z".parse::<DateTime<Utc>>().unwrap()
290        );
291    }
292
293    #[test]
294    fn test_convert_date_none() {
295        let datetime = convert_date(None);
296        assert!(datetime > Utc::now() - Duration::seconds(1));
297        assert!(datetime <= Utc::now());
298    }
299
300    #[test]
301    fn test_parse_empty_item() {
302        let item = Item {
303            id: [0, 1, 2, 3, 4, 5, 6].as_ref().into(),
304            creation_at: Some(1706613834),
305            modified_at: Some(1706623773),
306            title: "Bitwarden".to_string(),
307            subtitle: None,
308            favorite: None,
309            credentials: vec![],
310            tags: None,
311            extensions: None,
312            scope: None,
313        };
314
315        let ciphers: Vec<ImportingCipher> = parse_item(item);
316        assert_eq!(ciphers.len(), 0);
317    }
318
319    #[test]
320    fn test_parse_passkey() {
321        let item = Item {
322            id: B64Url::try_from("Njk1RERENTItNkQ0Ny00NERBLTlFN0EtNDM1MjNEQjYzNjVF")
323                .unwrap(),
324            creation_at: Some(1732181986),
325            modified_at: Some(1732182026),
326            title: "example.com".to_string(),
327            subtitle: None,
328            favorite: None,
329            credentials: vec![Credential::Passkey(Box::new(PasskeyCredential {
330                credential_id: B64Url::try_from("6NiHiekW4ZY8vYHa-ucbvA")
331                    .unwrap(),
332                rp_id: "example.com".to_string(),
333                username: "pj-fry".to_string(),
334                user_display_name: "Philip J. Fry".to_string(),
335                user_handle: B64Url::try_from("YWxleCBtdWxsZXI").unwrap(),
336                key: B64Url::try_from("MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgPzvtWYWmIsvqqr3LsZB0K-cbjuhJSGTGziL1LksHAPShRANCAAT-vqHTyEDS9QBNNi2BNLyu6TunubJT_L3G3i7KLpEDhMD15hi24IjGBH0QylJIrvlT4JN2tdRGF436XGc-VoAl")
337                    .unwrap(),
338                fido2_extensions: None,
339            }))],
340            tags: None,
341            extensions: None,
342            scope: None,
343        };
344
345        let ciphers: Vec<ImportingCipher> = parse_item(item);
346        assert_eq!(ciphers.len(), 1);
347        let cipher = ciphers.first().unwrap();
348
349        assert_eq!(cipher.folder_id, None);
350        assert_eq!(cipher.name, "example.com");
351
352        let login = match &cipher.r#type {
353            CipherType::Login(login) => login,
354            _ => panic!("Expected login"),
355        };
356
357        assert_eq!(login.username, Some("pj-fry".to_string()));
358        assert_eq!(login.password, None);
359        assert_eq!(login.login_uris.len(), 1);
360        assert_eq!(
361            login.login_uris[0].uri,
362            Some("https://example.com".to_string())
363        );
364        assert_eq!(login.totp, None);
365
366        let passkey = login.fido2_credentials.as_ref().unwrap().first().unwrap();
367        assert_eq!(passkey.credential_id, "b64.6NiHiekW4ZY8vYHa-ucbvA");
368        assert_eq!(passkey.key_type, "public-key");
369        assert_eq!(passkey.key_algorithm, "ECDSA");
370        assert_eq!(passkey.key_curve, "P-256");
371        assert_eq!(
372            passkey.key_value,
373            "MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgPzvtWYWmIsvqqr3LsZB0K-cbjuhJSGTGziL1LksHAPShRANCAAT-vqHTyEDS9QBNNi2BNLyu6TunubJT_L3G3i7KLpEDhMD15hi24IjGBH0QylJIrvlT4JN2tdRGF436XGc-VoAl"
374        );
375        assert_eq!(passkey.rp_id, "example.com");
376        assert_eq!(
377            passkey.user_handle.as_ref().map(|h| h.to_string()).unwrap(),
378            "YWxleCBtdWxsZXI"
379        );
380        assert_eq!(passkey.user_name, Some("pj-fry".to_string()));
381        assert_eq!(passkey.counter, 0);
382        assert_eq!(passkey.rp_name, Some("example.com".to_string()));
383        assert_eq!(passkey.user_display_name, Some("Philip J. Fry".to_string()));
384        assert_eq!(passkey.discoverable, "true");
385        assert_eq!(
386            passkey.creation_date,
387            "2024-11-21T09:39:46Z".parse::<DateTime<Utc>>().unwrap()
388        );
389    }
390
391    #[test]
392    fn test_passkey_with_basic_auth_and_scope() {
393        use credential_exchange_format::{BasicAuthCredential, CredentialScope};
394
395        let item = Item {
396            id: B64Url::try_from("Njk1RERENTItNkQ0Ny00NERBLTlFN0EtNDM1MjNEQjYzNjVF")
397                .unwrap(),
398            creation_at: Some(1732181986),
399            modified_at: Some(1732182026),
400            title: "Combined Login".to_string(),
401            subtitle: None,
402            favorite: None,
403            credentials: vec![
404                Credential::BasicAuth(Box::new(BasicAuthCredential {
405                    username: Some("basic_username".to_string().into()),
406                    password: Some("basic_password".to_string().into()),
407                })),
408                Credential::Passkey(Box::new(PasskeyCredential {
409                    credential_id: B64Url::try_from("6NiHiekW4ZY8vYHa-ucbvA")
410                        .unwrap(),
411                    rp_id: "passkey-domain.com".to_string(),
412                    username: "passkey_username".to_string(),
413                    user_display_name: "Passkey User".to_string(),
414                    user_handle: B64Url::try_from("YWxleCBtdWxsZXI")
415                        .unwrap(),
416                    key: B64Url::try_from("MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgPzvtWYWmIsvqqr3LsZB0K-cbjuhJSGTGziL1LksHAPShRANCAAT-vqHTyEDS9QBNNi2BNLyu6TunubJT_L3G3i7KLpEDhMD15hi24IjGBH0QylJIrvlT4JN2tdRGF436XGc-VoAl")
417                        .unwrap(),
418                    fido2_extensions: None,
419                }))
420            ],
421            tags: None,
422            extensions: None,
423            scope: Some(CredentialScope {
424                urls: vec!["https://example.com".to_string()],
425                android_apps: vec![],
426            }),
427        };
428
429        let ciphers: Vec<ImportingCipher> = parse_item(item);
430        assert_eq!(ciphers.len(), 1);
431        let cipher = ciphers.first().unwrap();
432
433        let login = match &cipher.r#type {
434            CipherType::Login(login) => login,
435            _ => panic!("Expected login"),
436        };
437
438        // Basic auth username should take priority over passkey username
439        assert_eq!(login.username, Some("basic_username".to_string()));
440        assert_eq!(login.password, Some("basic_password".to_string()));
441
442        // Scope URIs should take priority over passkey rp_id
443        assert_eq!(login.login_uris.len(), 1);
444        assert_eq!(
445            login.login_uris[0].uri,
446            Some("https://example.com".to_string())
447        );
448
449        // Passkey should still be present
450        assert!(login.fido2_credentials.is_some());
451    }
452
453    #[test]
454    fn test_passkey_with_empty_username() {
455        let item = Item {
456            id: B64Url::try_from("Njk1RERENTItNkQ0Ny00NERBLTlFN0EtNDM1MjNEQjYzNjVF").unwrap(),
457            creation_at: Some(1732181986),
458            modified_at: Some(1732182026),
459            title: "Empty Username Passkey".to_string(),
460            subtitle: None,
461            favorite: None,
462            credentials: vec![Credential::Passkey(Box::new(PasskeyCredential {
463                credential_id: B64Url::try_from("6NiHiekW4ZY8vYHa-ucbvA")
464                    .unwrap(),
465                rp_id: "example.com".to_string(),
466                username: "".to_string(),  // Empty username
467                user_display_name: "User Display".to_string(),
468                user_handle: B64Url::try_from("YWxleCBtdWxsZXI")
469                    .unwrap(),
470                key: B64Url::try_from("MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgPzvtWYWmIsvqqr3LsZB0K-cbjuhJSGTGziL1LksHAPShRANCAAT-vqHTyEDS9QBNNi2BNLyu6TunubJT_L3G3i7KLpEDhMD15hi24IjGBH0QylJIrvlT4JN2tdRGF436XGc-VoAl")
471                    .unwrap(),
472                fido2_extensions: None,
473            }))],
474            tags: None,
475            extensions: None,
476            scope: None,
477        };
478
479        let ciphers: Vec<ImportingCipher> = parse_item(item);
480        assert_eq!(ciphers.len(), 1);
481        let cipher = ciphers.first().unwrap();
482
483        let login = match &cipher.r#type {
484            CipherType::Login(login) => login,
485            _ => panic!("Expected login"),
486        };
487
488        // Empty username should not be mapped
489        assert_eq!(login.username, None);
490        assert_eq!(login.password, None);
491
492        // Should still map rp_id to URI
493        assert_eq!(login.login_uris.len(), 1);
494        assert_eq!(
495            login.login_uris[0].uri,
496            Some("https://example.com".to_string())
497        );
498    }
499
500    #[test]
501    fn test_credit_card() {
502        let item = Item {
503            id: [0, 1, 2, 3, 4, 5, 6].as_ref().into(),
504            creation_at: Some(1706613834),
505            modified_at: Some(1706623773),
506            title: "My MasterCard".to_string(),
507            subtitle: None,
508            favorite: None,
509            credentials: vec![Credential::CreditCard(Box::new(CreditCardCredential {
510                number: Some("1234 5678 9012 3456".to_string().into()),
511                full_name: Some("John Doe".to_string().into()),
512                card_type: Some("MasterCard".to_string().into()),
513                verification_number: Some("123".to_string().into()),
514                pin: None,
515                expiry_date: Some(
516                    EditableFieldYearMonth {
517                        year: 2026,
518                        month: Month::January,
519                    }
520                    .into(),
521                ),
522                valid_from: None,
523            }))],
524            tags: None,
525            extensions: None,
526            scope: None,
527        };
528
529        let ciphers: Vec<ImportingCipher> = parse_item(item);
530        assert_eq!(ciphers.len(), 1);
531        let cipher = ciphers.first().unwrap();
532
533        assert_eq!(cipher.folder_id, None);
534        assert_eq!(cipher.name, "My MasterCard");
535
536        let card = match &cipher.r#type {
537            CipherType::Card(card) => card,
538            _ => panic!("Expected card"),
539        };
540
541        assert_eq!(card.cardholder_name, Some("John Doe".to_string()));
542        assert_eq!(card.exp_month, Some("1".to_string()));
543        assert_eq!(card.exp_year, Some("2026".to_string()));
544        assert_eq!(card.code, Some("123".to_string()));
545        assert_eq!(card.brand, Some("Mastercard".to_string()));
546        assert_eq!(card.number, Some("1234 5678 9012 3456".to_string()));
547    }
548
549    #[test]
550    fn test_totp() {
551        use credential_exchange_format::{OTPHashAlgorithm, TotpCredential};
552
553        let item = Item {
554            id: [0, 1, 2, 3, 4, 5, 6].as_ref().into(),
555            creation_at: Some(1706613834),
556            modified_at: Some(1706623773),
557            title: "My TOTP".to_string(),
558            subtitle: None,
559            favorite: None,
560            credentials: vec![Credential::Totp(Box::new(TotpCredential {
561                secret: "Hello World!".as_bytes().to_vec().into(),
562                period: 30,
563                digits: 6,
564                username: Some("[email protected]".to_string()),
565                algorithm: OTPHashAlgorithm::Sha1,
566                issuer: Some("Example Service".to_string()),
567            }))],
568            tags: None,
569            extensions: None,
570            scope: None,
571        };
572
573        let ciphers: Vec<ImportingCipher> = parse_item(item);
574        assert_eq!(ciphers.len(), 1);
575        let cipher = ciphers.first().unwrap();
576
577        assert_eq!(cipher.folder_id, None);
578        assert_eq!(cipher.name, "My TOTP");
579        assert_eq!(cipher.notes, None);
580        assert!(!cipher.favorite);
581        assert_eq!(cipher.reprompt, 0);
582        assert_eq!(cipher.fields, vec![]);
583
584        let login = match &cipher.r#type {
585            CipherType::Login(login) => login,
586            _ => panic!("Expected login cipher for TOTP"),
587        };
588
589        // TOTP should be mapped to login.totp as otpauth URI
590        assert!(login.totp.is_some());
591        let otpauth = login.totp.as_ref().unwrap();
592
593        // Verify the otpauth URI format and content
594        assert!(
595            otpauth.starts_with("otpauth://totp/Example%20Service:test%40example%2Ecom?secret=")
596        );
597        assert!(otpauth.contains("&issuer=Example%20Service"));
598
599        // Default values should not be present in URI
600        assert!(!otpauth.contains("&period=30"));
601        assert!(!otpauth.contains("&digits=6"));
602        assert!(!otpauth.contains("&algorithm=SHA1"));
603
604        // Other login fields should be None since only TOTP was provided
605        assert_eq!(login.username, None);
606        assert_eq!(login.password, None);
607        assert_eq!(login.login_uris, vec![]);
608    }
609
610    #[test]
611    fn test_totp_combined_with_basic_auth() {
612        use credential_exchange_format::{BasicAuthCredential, OTPHashAlgorithm, TotpCredential};
613
614        let item = Item {
615            id: [0, 1, 2, 3, 4, 5, 6].as_ref().into(),
616            creation_at: Some(1706613834),
617            modified_at: Some(1706623773),
618            title: "Login with TOTP".to_string(),
619            subtitle: None,
620            favorite: None,
621            credentials: vec![
622                Credential::BasicAuth(Box::new(BasicAuthCredential {
623                    username: Some("myuser".to_string().into()),
624                    password: Some("mypass".to_string().into()),
625                })),
626                Credential::Totp(Box::new(TotpCredential {
627                    secret: "totpkey".as_bytes().to_vec().into(),
628                    period: 30,
629                    digits: 6,
630                    username: Some("totpuser".to_string()),
631                    algorithm: OTPHashAlgorithm::Sha1,
632                    issuer: Some("Service".to_string()),
633                })),
634            ],
635            tags: None,
636            extensions: None,
637            scope: None,
638        };
639
640        let ciphers: Vec<ImportingCipher> = parse_item(item);
641        assert_eq!(ciphers.len(), 1);
642        let cipher = ciphers.first().unwrap();
643
644        let login = match &cipher.r#type {
645            CipherType::Login(login) => login,
646            _ => panic!("Expected login cipher"),
647        };
648
649        // Should have both basic auth and TOTP
650        assert_eq!(login.username, Some("myuser".to_string()));
651        assert_eq!(login.password, Some("mypass".to_string()));
652        assert!(login.totp.is_some());
653
654        let otpauth = login.totp.as_ref().unwrap();
655        assert!(otpauth.starts_with("otpauth://totp/Service:totpuser?secret="));
656        assert!(otpauth.contains("&issuer=Service"));
657    }
658
659    // Note integration tests
660
661    #[test]
662    fn test_note_as_part_of_login() {
663        use credential_exchange_format::{BasicAuthCredential, Credential, Item, NoteCredential};
664
665        let item = Item {
666            id: [0, 1, 2, 3, 4, 5, 6].as_ref().into(),
667            creation_at: Some(1706613834),
668            modified_at: Some(1706623773),
669            title: "Login with Note".to_string(),
670            subtitle: None,
671            favorite: None,
672            credentials: vec![
673                Credential::BasicAuth(Box::new(BasicAuthCredential {
674                    username: Some("testuser".to_string().into()),
675                    password: Some("testpass".to_string().into()),
676                })),
677                Credential::Note(Box::new(NoteCredential {
678                    content: "This note should be added to the login cipher."
679                        .to_string()
680                        .into(),
681                })),
682            ],
683            tags: None,
684            extensions: None,
685            scope: None,
686        };
687
688        let ciphers: Vec<ImportingCipher> = parse_item(item);
689        assert_eq!(ciphers.len(), 1); // Should create only one cipher (Login with note content)
690        let cipher = ciphers.first().unwrap();
691
692        assert_eq!(cipher.name, "Login with Note");
693        assert_eq!(
694            cipher.notes,
695            Some("This note should be added to the login cipher.".to_string())
696        );
697
698        match &cipher.r#type {
699            CipherType::Login(_) => (), // Should be a Login cipher
700            _ => panic!("Expected Login cipher with note content"),
701        };
702    }
703
704    #[test]
705    fn test_note_as_part_of_api_key() {
706        use credential_exchange_format::{ApiKeyCredential, Credential, Item, NoteCredential};
707
708        let item = Item {
709            id: [0, 1, 2, 3, 4, 5, 6].as_ref().into(),
710            creation_at: Some(1706613834),
711            modified_at: Some(1706623773),
712            title: "API Key with Note".to_string(),
713            subtitle: None,
714            favorite: None,
715            credentials: vec![
716                Credential::ApiKey(Box::new(ApiKeyCredential {
717                    key: Some("api-key-12345".to_string().into()),
718                    username: Some("api-user".to_string().into()),
719                    key_type: Some("Bearer".to_string().into()),
720                    url: None,
721                    valid_from: None,
722                    expiry_date: None,
723                })),
724                Credential::Note(Box::new(NoteCredential {
725                    content: "This note should be added to the API key cipher."
726                        .to_string()
727                        .into(),
728                })),
729            ],
730            tags: None,
731            extensions: None,
732            scope: None,
733        };
734
735        let ciphers: Vec<ImportingCipher> = parse_item(item);
736        assert_eq!(ciphers.len(), 1); // Should create only one cipher (SecureNote with note content)
737        let cipher = ciphers.first().unwrap();
738
739        assert_eq!(cipher.name, "API Key with Note");
740        assert_eq!(
741            cipher.notes,
742            Some("This note should be added to the API key cipher.".to_string())
743        );
744
745        match &cipher.r#type {
746            CipherType::SecureNote(_) => (), // Should be a SecureNote cipher
747            _ => panic!("Expected SecureNote cipher with note content"),
748        };
749
750        // Should have API key fields
751        assert!(!cipher.fields.is_empty());
752    }
753
754    #[test]
755    fn test_note_as_part_of_credit_card() {
756        use chrono::Month;
757        use credential_exchange_format::{Credential, CreditCardCredential, Item, NoteCredential};
758
759        let item = Item {
760            id: [0, 1, 2, 3, 4, 5, 6].as_ref().into(),
761            creation_at: Some(1706613834),
762            modified_at: Some(1706623773),
763            title: "Credit Card with Note".to_string(),
764            subtitle: None,
765            favorite: None,
766            credentials: vec![
767                Credential::CreditCard(Box::new(CreditCardCredential {
768                    number: Some("1234 5678 9012 3456".to_string().into()),
769                    full_name: Some("John Doe".to_string().into()),
770                    card_type: Some("Visa".to_string().into()),
771                    verification_number: Some("123".to_string().into()),
772                    pin: None,
773                    expiry_date: Some(
774                        credential_exchange_format::EditableFieldYearMonth {
775                            year: 2026,
776                            month: Month::December,
777                        }
778                        .into(),
779                    ),
780                    valid_from: None,
781                })),
782                Credential::Note(Box::new(NoteCredential {
783                    content: "This note should be added to the credit card cipher."
784                        .to_string()
785                        .into(),
786                })),
787            ],
788            tags: None,
789            extensions: None,
790            scope: None,
791        };
792
793        let ciphers: Vec<ImportingCipher> = parse_item(item);
794        assert_eq!(ciphers.len(), 1); // Should create only one cipher (Card with note content)
795        let cipher = ciphers.first().unwrap();
796
797        assert_eq!(cipher.name, "Credit Card with Note");
798        assert_eq!(
799            cipher.notes,
800            Some("This note should be added to the credit card cipher.".to_string())
801        );
802
803        match &cipher.r#type {
804            CipherType::Card(_) => (), // Should be a Card cipher
805            _ => panic!("Expected Card cipher with note content"),
806        };
807    }
808
809    #[test]
810    fn test_note_as_part_of_wifi() {
811        use credential_exchange_format::{
812            Credential, EditableFieldWifiNetworkSecurityType, Item, NoteCredential, WifiCredential,
813        };
814
815        let item = Item {
816            id: [0, 1, 2, 3, 4, 5, 6].as_ref().into(),
817            creation_at: Some(1706613834),
818            modified_at: Some(1706623773),
819            title: "WiFi with Note".to_string(),
820            subtitle: None,
821            favorite: None,
822            credentials: vec![
823                Credential::Wifi(Box::new(WifiCredential {
824                    ssid: Some("MyNetwork".to_string().into()),
825                    passphrase: Some("password123".to_string().into()),
826                    network_security_type: Some(
827                        EditableFieldWifiNetworkSecurityType::Wpa3Personal.into(),
828                    ),
829                    hidden: Some(false.into()),
830                })),
831                Credential::Note(Box::new(NoteCredential {
832                    content: "This note should be added to the WiFi cipher."
833                        .to_string()
834                        .into(),
835                })),
836            ],
837            tags: None,
838            extensions: None,
839            scope: None,
840        };
841
842        let ciphers: Vec<ImportingCipher> = parse_item(item);
843        assert_eq!(ciphers.len(), 1); // Should create only one cipher (SecureNote with note content)
844        let cipher = ciphers.first().unwrap();
845
846        assert_eq!(cipher.name, "WiFi with Note");
847        assert_eq!(
848            cipher.notes,
849            Some("This note should be added to the WiFi cipher.".to_string())
850        );
851
852        match &cipher.r#type {
853            CipherType::SecureNote(_) => (), // Should be a SecureNote cipher
854            _ => panic!("Expected SecureNote cipher with note content"),
855        };
856
857        // Should have WiFi fields
858        assert!(!cipher.fields.is_empty());
859    }
860
861    #[test]
862    fn test_note_as_part_of_identity() {
863        use credential_exchange_format::{AddressCredential, Credential, Item, NoteCredential};
864
865        let item = Item {
866            id: [0, 1, 2, 3, 4, 5, 6].as_ref().into(),
867            creation_at: Some(1706613834),
868            modified_at: Some(1706623773),
869            title: "Address with Note".to_string(),
870            subtitle: None,
871            favorite: None,
872            credentials: vec![
873                Credential::Address(Box::new(AddressCredential {
874                    street_address: Some("123 Main St".to_string().into()),
875                    city: Some("Springfield".to_string().into()),
876                    territory: Some("CA".to_string().into()),
877                    postal_code: Some("12345".to_string().into()),
878                    country: Some("US".to_string().into()),
879                    tel: Some("+1-555-123-4567".to_string().into()),
880                })),
881                Credential::Note(Box::new(NoteCredential {
882                    content: "This note should be added to the address identity cipher."
883                        .to_string()
884                        .into(),
885                })),
886            ],
887            tags: None,
888            extensions: None,
889            scope: None,
890        };
891
892        let ciphers: Vec<ImportingCipher> = parse_item(item);
893        assert_eq!(ciphers.len(), 1); // Should create only one cipher (Identity with note content)
894        let cipher = ciphers.first().unwrap();
895
896        assert_eq!(cipher.name, "Address with Note");
897        assert_eq!(
898            cipher.notes,
899            Some("This note should be added to the address identity cipher.".to_string())
900        );
901
902        match &cipher.r#type {
903            CipherType::Identity(_) => (), // Should be an Identity cipher
904            _ => panic!("Expected Identity cipher"),
905        };
906    }
907}