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 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        // Basic auth username should take priority over passkey username
465        assert_eq!(login.username, Some("basic_username".to_string()));
466        assert_eq!(login.password, Some("basic_password".to_string()));
467
468        // Scope URIs should take priority over passkey rp_id
469        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        // Passkey should still be present
476        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(),  // Empty username
500                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        // Empty username should not be mapped
528        assert_eq!(login.username, None);
529        assert_eq!(login.password, None);
530
531        // Should still map rp_id to URI
532        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        // TOTP should be mapped to login.totp as otpauth URI
629        assert!(login.totp.is_some());
630        let otpauth = login.totp.as_ref().unwrap();
631
632        // Verify the otpauth URI format and content
633        assert!(
634            otpauth.starts_with("otpauth://totp/Example%20Service:test%40example%2Ecom?secret=")
635        );
636        assert!(otpauth.contains("&issuer=Example%20Service"));
637
638        // Default values should not be present in URI
639        assert!(!otpauth.contains("&period=30"));
640        assert!(!otpauth.contains("&digits=6"));
641        assert!(!otpauth.contains("&algorithm=SHA1"));
642
643        // Other login fields should be None since only TOTP was provided
644        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        // Should have both basic auth and TOTP
689        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    // Note integration tests
699
700    #[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); // Should create only one cipher (Login with note content)
729        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(_) => (), // Should be a Login cipher
739            _ => 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); // Should create only one cipher (SecureNote with note content)
776        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(_) => (), // Should be a SecureNote cipher
786            _ => panic!("Expected SecureNote cipher with note content"),
787        };
788
789        // Should have API key fields
790        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); // Should create only one cipher (Card with note content)
834        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(_) => (), // Should be a Card cipher
844            _ => 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); // Should create only one cipher (SecureNote with note content)
883        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(_) => (), // Should be a SecureNote cipher
893            _ => panic!("Expected SecureNote cipher with note content"),
894        };
895
896        // Should have WiFi fields
897        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); // Should create only one cipher (Identity with note content)
933        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(_) => (), // Should be an Identity cipher
943            _ => panic!("Expected Identity cipher"),
944        };
945    }
946}