1
use oo7::dbus;
2

            
3
use super::*;
4
use crate::tests::TestServiceSetup;
5

            
6
#[tokio::test]
7
async fn open_session_plain() -> Result<(), Box<dyn std::error::Error>> {
8
    let setup = TestServiceSetup::plain_session(true).await?;
9

            
10
    assert!(
11
        setup.aes_key.is_none(),
12
        "Plain session should not have AES key"
13
    );
14

            
15
    // Should have 2 collections: default + session
16
    assert_eq!(
17
        setup.collections.len(),
18
        2,
19
        "Expected default and session collections"
20
    );
21
    Ok(())
22
}
23

            
24
#[tokio::test]
25
async fn open_session_encrypted() -> Result<(), Box<dyn std::error::Error>> {
26
    let setup = TestServiceSetup::encrypted_session(false).await?;
27
    assert!(
28
        setup.server_public_key.is_some(),
29
        "Encrypted session should have server public key"
30
    );
31
    let key = setup.aes_key.unwrap().clone();
32
    assert_eq!((*key).as_ref().len(), 16, "AES key should be 16 bytes");
33
    Ok(())
34
}
35

            
36
#[tokio::test]
37
async fn session_collection_only() -> Result<(), Box<dyn std::error::Error>> {
38
    let setup = TestServiceSetup::plain_session(false).await?;
39

            
40
    // Should have only session collection (no default)
41
    assert_eq!(
42
        setup.collections.len(),
43
        1,
44
        "Should have exactly one collection"
45
    );
46
    Ok(())
47
}
48

            
49
#[tokio::test]
50
async fn search_items() -> Result<(), Box<dyn std::error::Error>> {
51
    let setup = TestServiceSetup::plain_session(true).await?;
52

            
53
    // Search for items (should return empty initially)
54
    let (unlocked, locked) = setup
55
        .service_api
56
        .search_items(&[("application", "test-app")])
57
        .await?;
58

            
59
    assert!(
60
        unlocked.is_empty(),
61
        "Should have no unlocked items initially"
62
    );
63
    assert!(locked.is_empty(), "Should have no locked items initially");
64

            
65
    // Search with empty attributes - edge case
66
    let attributes: HashMap<&str, &str> = HashMap::default();
67
    let (unlocked, locked) = setup.service_api.search_items(&attributes).await?;
68

            
69
    assert!(
70
        locked.is_empty(),
71
        "Should have no locked items with empty search"
72
    );
73
    assert!(
74
        unlocked.is_empty(),
75
        "Should have no unlocked items with empty search"
76
    );
77

            
78
    // Test with both locked and unlocked items
79
    // Create items in default collection (unlocked)
80
    setup
81
        .create_item("Unlocked Item", &[("app", "testapp")], "password1", false)
82
        .await?;
83

            
84
    // Create item in default collection and lock it
85
    let locked_item = setup
86
        .create_item("Locked Item", &[("app", "testapp")], "password2", false)
87
        .await?;
88

            
89
    // Lock just this item (not the whole collection)
90
    setup.lock_item(&locked_item).await?;
91

            
92
    // Search for items with the shared attribute
93
    let (unlocked, locked) = setup
94
        .service_api
95
        .search_items(&[("app", "testapp")])
96
        .await?;
97

            
98
    assert_eq!(unlocked.len(), 1, "Should find 1 unlocked item");
99
    assert_eq!(locked.len(), 1, "Should find 1 locked item");
100

            
101
    Ok(())
102
}
103

            
104
#[tokio::test]
105
async fn get_secrets() -> Result<(), Box<dyn std::error::Error>> {
106
    let setup = TestServiceSetup::plain_session(true).await?;
107

            
108
    // Test with empty items list - edge case
109
    #[allow(clippy::mutable_key_type)]
110
    let secrets = setup.service_api.secrets(&[], &setup.session).await?;
111
    assert!(
112
        secrets.is_empty(),
113
        "Should return empty secrets for empty items list"
114
    );
115

            
116
    // Create two items with different secrets
117
    let secret1 = Secret::text("password1");
118
    let item1 = setup
119
        .create_item("Item 1", &[("app", "test1")], secret1.clone(), false)
120
        .await?;
121

            
122
    let secret2 = Secret::text("password2");
123
    let item2 = setup
124
        .create_item("Item 2", &[("app", "test2")], secret2.clone(), false)
125
        .await?;
126

            
127
    // Get secrets for both items
128
    let item_paths = vec![item1.clone(), item2.clone()];
129
    #[allow(clippy::mutable_key_type)]
130
    let secrets = setup
131
        .service_api
132
        .secrets(&item_paths, &setup.session)
133
        .await?;
134

            
135
    // Should have both secrets
136
    assert_eq!(secrets.len(), 2, "Should retrieve both secrets");
137

            
138
    // Verify first secret
139
    let retrieved_secret1 = secrets.get(&item1).unwrap();
140
    assert_eq!(retrieved_secret1.value(), secret1.as_bytes());
141

            
142
    // Verify second secret
143
    let retrieved_secret2 = secrets.get(&item2).unwrap();
144
    assert_eq!(retrieved_secret2.value(), secret2.as_bytes());
145

            
146
    Ok(())
147
}
148

            
149
#[tokio::test]
150
async fn get_secrets_multiple_collections() -> Result<(), Box<dyn std::error::Error>> {
151
    let setup = TestServiceSetup::plain_session(true).await?;
152

            
153
    // Should have 2 collections: default (Login) and session
154
    assert_eq!(setup.collections.len(), 2);
155

            
156
    // Create item in default collection (index 0)
157
    let secret1 = Secret::text("default-password");
158
    let item1 = setup
159
        .create_item(
160
            "Default Item",
161
            &[("app", "default-app")],
162
            secret1.clone(),
163
            false,
164
        )
165
        .await?;
166

            
167
    // Create item in session collection (index 1)
168
    let secret2 = Secret::text("session-password");
169
    let dbus_secret2 = setup.create_dbus_secret(secret2.clone())?;
170

            
171
    let item2 = setup.collections[1]
172
        .create_item(
173
            "Session Item",
174
            &[("app", "session-app")],
175
            &dbus_secret2,
176
            false,
177
            None,
178
        )
179
        .await?;
180

            
181
    // Get secrets for both items from different collections
182
    let item_paths = vec![item1.clone(), item2.clone()];
183
    #[allow(clippy::mutable_key_type)]
184
    let secrets = setup
185
        .service_api
186
        .secrets(&item_paths, &setup.session)
187
        .await?;
188

            
189
    // Should have both secrets
190
    assert_eq!(
191
        secrets.len(),
192
        2,
193
        "Should retrieve secrets from both collections"
194
    );
195

            
196
    // Verify default collection secret
197
    let retrieved_secret1 = secrets.get(&item1).unwrap();
198
    assert_eq!(retrieved_secret1.value(), secret1.as_bytes());
199

            
200
    // Verify session collection secret
201
    let retrieved_secret2 = secrets.get(&item2).unwrap();
202
    assert_eq!(retrieved_secret2.value(), secret2.as_bytes());
203

            
204
    Ok(())
205
}
206

            
207
#[tokio::test]
208
async fn read_alias() -> Result<(), Box<dyn std::error::Error>> {
209
    let setup = TestServiceSetup::plain_session(true).await?;
210

            
211
    // Default collection should have "default" alias
212
    let default_collection = setup.service_api.read_alias("default").await?;
213
    assert!(
214
        default_collection.is_some(),
215
        "Default alias should return a collection"
216
    );
217

            
218
    // Verify it's the Login collection by checking its label
219
    let label = default_collection.as_ref().unwrap().label().await?;
220
    assert_eq!(
221
        label, "Login",
222
        "Default alias should point to Login collection"
223
    );
224

            
225
    // Non-existent alias should return None
226
    let nonexistent = setup.service_api.read_alias("nonexistent").await?;
227
    assert!(
228
        nonexistent.is_none(),
229
        "Non-existent alias should return None"
230
    );
231

            
232
    Ok(())
233
}
234

            
235
#[tokio::test]
236
async fn set_alias() -> Result<(), Box<dyn std::error::Error>> {
237
    let setup = TestServiceSetup::plain_session(true).await?;
238

            
239
    // Set alias for session collection
240
    setup
241
        .service_api
242
        .set_alias("my-alias", &setup.collections[1])
243
        .await?;
244

            
245
    // Read the alias back
246
    let alias_collection = setup.service_api.read_alias("my-alias").await?;
247
    assert!(
248
        alias_collection.is_some(),
249
        "Alias should return a collection"
250
    );
251
    assert_eq!(
252
        alias_collection.unwrap().inner().path(),
253
        setup.collections[1].inner().path(),
254
        "Alias should point to session collection"
255
    );
256

            
257
    Ok(())
258
}
259

            
260
#[tokio::test]
261
async fn search_items_with_results() -> Result<(), Box<dyn std::error::Error>> {
262
    let setup = TestServiceSetup::plain_session(true).await?;
263

            
264
    // Create items in default collection
265
    setup
266
        .create_item(
267
            "Firefox Login",
268
            &[("application", "firefox"), ("type", "login")],
269
            "password1",
270
            false,
271
        )
272
        .await?;
273

            
274
    setup
275
        .create_item(
276
            "Chrome Login",
277
            &[("application", "chrome"), ("type", "login")],
278
            "password2",
279
            false,
280
        )
281
        .await?;
282

            
283
    // Create item in session collection
284
    let dbus_secret3 = setup.create_dbus_secret("password3")?;
285

            
286
    setup.collections[1]
287
        .create_item(
288
            "Session Item",
289
            &[("application", "firefox"), ("type", "session")],
290
            &dbus_secret3,
291
            false,
292
            None,
293
        )
294
        .await?;
295

            
296
    // Search for all firefox items
297
    let (unlocked, locked) = setup
298
        .service_api
299
        .search_items(&[("application", "firefox")])
300
        .await?;
301

            
302
    assert_eq!(unlocked.len(), 2, "Should find 2 firefox items");
303
    assert!(locked.is_empty(), "Should have no locked items");
304

            
305
    // Search for login type items
306
    let (unlocked, locked) = setup.service_api.search_items(&[("type", "login")]).await?;
307

            
308
    assert_eq!(unlocked.len(), 2, "Should find 2 login items");
309
    assert!(locked.is_empty(), "Should have no locked items");
310

            
311
    // Search for chrome items
312
    let (unlocked, locked) = setup
313
        .service_api
314
        .search_items(&[("application", "chrome")])
315
        .await?;
316

            
317
    assert_eq!(unlocked.len(), 1, "Should find 1 chrome item");
318
    assert!(locked.is_empty(), "Should have no locked items");
319

            
320
    // Search for non-existent
321
    let (unlocked, locked) = setup
322
        .service_api
323
        .search_items(&[("application", "nonexistent")])
324
        .await?;
325

            
326
    assert!(unlocked.is_empty(), "Should find no items");
327
    assert!(locked.is_empty(), "Should have no locked items");
328

            
329
    Ok(())
330
}
331

            
332
#[tokio::test]
333
async fn get_secrets_invalid_session() -> Result<(), Box<dyn std::error::Error>> {
334
    let setup = TestServiceSetup::plain_session(true).await?;
335

            
336
    // Create an item
337
    let item = setup
338
        .create_item("Test Item", &[("app", "test")], "test-password", false)
339
        .await?;
340

            
341
    // Try to get secrets with invalid session path
342
    let invalid_session =
343
        dbus::api::Session::new(&setup.client_conn, "/invalid/session/path").await?;
344
    let result = setup.service_api.secrets(&[item], &invalid_session).await;
345

            
346
    assert!(
347
        matches!(
348
            result,
349
            Err(oo7::dbus::Error::Service(
350
                oo7::dbus::ServiceError::NoSession(_)
351
            ))
352
        ),
353
        "Should be NoSession error"
354
    );
355

            
356
    Ok(())
357
}
358

            
359
#[tokio::test]
360
async fn set_alias_invalid_collection() -> Result<(), Box<dyn std::error::Error>> {
361
    let setup = TestServiceSetup::plain_session(true).await?;
362

            
363
    // Try to set alias for non-existent collection
364
    let invalid_collection = dbus::api::Collection::new(
365
        &setup.client_conn,
366
        "/org/freedesktop/secrets/collection/nonexistent",
367
    )
368
    .await?;
369
    let result = setup
370
        .service_api
371
        .set_alias("test-alias", &invalid_collection)
372
        .await;
373

            
374
    assert!(
375
        matches!(
376
            result,
377
            Err(oo7::dbus::Error::Service(
378
                oo7::dbus::ServiceError::NoSuchObject(_)
379
            ))
380
        ),
381
        "Should be NoSuchObject error"
382
    );
383

            
384
    Ok(())
385
}
386

            
387
#[tokio::test]
388
async fn get_secrets_with_non_existent_items() -> Result<(), Box<dyn std::error::Error>> {
389
    let setup = TestServiceSetup::plain_session(true).await?;
390

            
391
    // Create one real item
392
    let item1 = setup
393
        .create_item("Item 1", &[("app", "test")], "password1", false)
394
        .await?;
395

            
396
    // Create a fake item path that doesn't exist
397
    let fake_item = dbus::api::Item::new(
398
        &setup.client_conn,
399
        "/org/freedesktop/secrets/collection/Login/999",
400
    )
401
    .await?;
402

            
403
    // Request secrets for both real and fake items
404
    let item_paths = vec![item1.clone(), fake_item];
405
    #[allow(clippy::mutable_key_type)]
406
    let secrets = setup
407
        .service_api
408
        .secrets(&item_paths, &setup.session)
409
        .await?;
410

            
411
    // Should only get the secret for the real item
412
    assert_eq!(
413
        secrets.len(),
414
        1,
415
        "Should only retrieve secret for existing item"
416
    );
417
    assert!(secrets.contains_key(&item1), "Should have item1 secret");
418

            
419
    Ok(())
420
}
421

            
422
#[tokio::test]
423
async fn search_items_across_collections() -> Result<(), Box<dyn std::error::Error>> {
424
    let setup = TestServiceSetup::plain_session(true).await?;
425

            
426
    let collections = setup.service_api.collections().await?;
427
    assert_eq!(collections.len(), 2, "Should have 2 collections");
428

            
429
    // Create item in first collection
430
    let dbus_secret1 = setup.create_dbus_secret("password1")?;
431

            
432
    collections[0]
433
        .create_item(
434
            "Default Item",
435
            &[("shared", "attr")],
436
            &dbus_secret1,
437
            false,
438
            None,
439
        )
440
        .await?;
441

            
442
    // Create item in second collection with same attributes
443
    let dbus_secret2 = setup.create_dbus_secret("password2")?;
444

            
445
    collections[1]
446
        .create_item(
447
            "Session Item",
448
            &[("shared", "attr")],
449
            &dbus_secret2,
450
            false,
451
            None,
452
        )
453
        .await?;
454

            
455
    // Search should find items from both collections
456
    let (unlocked, locked) = setup
457
        .service_api
458
        .search_items(&[("shared", "attr")])
459
        .await?;
460

            
461
    assert_eq!(unlocked.len(), 2, "Should find items from both collections");
462
    assert!(locked.is_empty(), "Should have no locked items");
463

            
464
    Ok(())
465
}
466

            
467
#[tokio::test]
468
async fn unlock_edge_cases() -> Result<(), Box<dyn std::error::Error>> {
469
    let setup = TestServiceSetup::plain_session(true).await?;
470

            
471
    // Test 1: Empty object list
472
    let items: Vec<ObjectPath<'_>> = vec![];
473
    let unlocked = setup.service_api.unlock(&items, None).await?;
474
    assert!(unlocked.is_empty(), "Should return empty for empty input");
475

            
476
    // Test 2: Non-existent objects
477
    let fake_collection = dbus::api::Collection::new(
478
        &setup.client_conn,
479
        "/org/freedesktop/secrets/collection/NonExistent",
480
    )
481
    .await?;
482

            
483
    let fake_item = dbus::api::Item::new(
484
        &setup.client_conn,
485
        "/org/freedesktop/secrets/collection/Login/999",
486
    )
487
    .await?;
488

            
489
    let unlocked = setup
490
        .service_api
491
        .unlock(
492
            &[fake_collection.inner().path(), fake_item.inner().path()],
493
            None,
494
        )
495
        .await?;
496

            
497
    assert!(
498
        unlocked.is_empty(),
499
        "Should have no unlocked objects for non-existent paths"
500
    );
501

            
502
    // Test 3: Already unlocked objects
503
    let item = setup
504
        .create_item("Test Item", &[("app", "test")], "test-password", false)
505
        .await?;
506

            
507
    // Verify item is unlocked
508
    assert!(!item.is_locked().await?, "Item should be unlocked");
509

            
510
    // Try to unlock already unlocked item
511
    let unlocked = setup
512
        .service_api
513
        .unlock(&[item.inner().path()], None)
514
        .await?;
515

            
516
    assert_eq!(unlocked.len(), 1, "Should return the already-unlocked item");
517
    assert_eq!(
518
        unlocked[0].as_str(),
519
        item.inner().path().as_str(),
520
        "Should return the same item path"
521
    );
522

            
523
    // Also test with collection (starts unlocked by default)
524
    assert!(
525
        !setup.collections[0].is_locked().await?,
526
        "Collection should be unlocked"
527
    );
528

            
529
    let unlocked = setup
530
        .service_api
531
        .unlock(&[setup.collections[0].inner().path()], None)
532
        .await?;
533

            
534
    assert_eq!(
535
        unlocked.len(),
536
        1,
537
        "Should return the already-unlocked collection"
538
    );
539

            
540
    Ok(())
541
}
542

            
543
#[tokio::test]
544
async fn lock_non_existent_objects() -> Result<(), Box<dyn std::error::Error>> {
545
    let setup = TestServiceSetup::encrypted_session(true).await?;
546

            
547
    // Test with empty object list
548
    let items: Vec<ObjectPath<'_>> = vec![];
549
    let locked = setup.service_api.lock(&items, None).await?;
550
    assert!(locked.is_empty(), "Should return empty for empty input");
551

            
552
    // Test locking non-existent objects
553
    let fake_collection = dbus::api::Collection::new(
554
        &setup.client_conn,
555
        "/org/freedesktop/secrets/collection/NonExistent",
556
    )
557
    .await?;
558

            
559
    let fake_item = dbus::api::Item::new(
560
        &setup.client_conn,
561
        "/org/freedesktop/secrets/collection/Login/999",
562
    )
563
    .await?;
564

            
565
    let locked = setup
566
        .service_api
567
        .lock(
568
            &[fake_collection.inner().path(), fake_item.inner().path()],
569
            None,
570
        )
571
        .await?;
572

            
573
    assert!(
574
        locked.is_empty(),
575
        "Should have no locked objects for non-existent paths"
576
    );
577

            
578
    Ok(())
579
}
580

            
581
4
async fn unlock_collection_prompt_impl(
582
    prompter_type: PrompterType,
583
) -> Result<(), Box<dyn std::error::Error>> {
584
15
    let setup = TestServiceSetup::plain_session(true).await?;
585
9
    setup.server.set_prompter_type(prompter_type).await;
586

            
587
    // Lock the collection using server-side API
588
4
    setup.lock_collection(&setup.collections[0]).await?;
589

            
590
    assert!(
591
        setup.collections[0].is_locked().await?,
592
        "Collection should be locked"
593
    );
594

            
595
    // Test 1: Unlock with accept
596
19
    let unlocked = setup
597
        .service_api
598
12
        .unlock(&[setup.collections[0].inner().path()], None)
599
22
        .await?;
600

            
601
    assert_eq!(unlocked.len(), 1, "Should have unlocked 1 collection");
602
    assert_eq!(
603
        unlocked[0].as_str(),
604
        setup.collections[0].inner().path().as_str(),
605
        "Should return the collection path"
606
    );
607
    assert!(
608
        !setup.collections[0].is_locked().await?,
609
        "Collection should be unlocked after accepting prompt"
610
    );
611

            
612
    // Lock the collection again for dismiss test
613
9
    setup.lock_collection(&setup.collections[0]).await?;
614
    assert!(
615
        setup.collections[0].is_locked().await?,
616
        "Collection should be locked again"
617
    );
618

            
619
    // Test 2: Unlock with dismiss
620
8
    setup.set_password_accept(false).await;
621
12
    let result = setup
622
        .service_api
623
4
        .unlock(&[setup.collections[0].inner().path()], None)
624
16
        .await;
625

            
626
    assert!(
627
        matches!(result, Err(oo7::dbus::Error::Dismissed)),
628
        "Should return Dismissed error when prompt dismissed"
629
    );
630
    assert!(
631
        setup.collections[0].is_locked().await?,
632
        "Collection should still be locked after dismissing prompt"
633
    );
634

            
635
4
    Ok(())
636
}
637

            
638
#[cfg(any(feature = "gnome_native_crypto", feature = "gnome_openssl_crypto"))]
639
#[tokio::test]
640
async fn unlock_collection_prompt_gnome() -> Result<(), Box<dyn std::error::Error>> {
641
    unlock_collection_prompt_impl(PrompterType::GNOME).await
642
}
643

            
644
#[cfg(any(feature = "plasma_native_crypto", feature = "plasma_openssl_crypto"))]
645
#[tokio::test]
646
async fn unlock_collection_prompt_plasma() -> Result<(), Box<dyn std::error::Error>> {
647
    unlock_collection_prompt_impl(PrompterType::Plasma).await
648
}
649

            
650
4
async fn unlock_item_prompt_impl(
651
    prompter_type: PrompterType,
652
) -> Result<(), Box<dyn std::error::Error>> {
653
12
    let setup = TestServiceSetup::plain_session(true).await?;
654
8
    setup.server.set_prompter_type(prompter_type).await;
655

            
656
    // Create an item
657
4
    let dbus_secret = setup.create_dbus_secret("test-password")?;
658
12
    let default_collection = setup.service_api.read_alias("default").await?.unwrap();
659
16
    let item = default_collection
660
4
        .create_item("Test Item", &[("app", "test")], &dbus_secret, false, None)
661
16
        .await?;
662

            
663
    // Lock the collection (which locks the item)
664
8
    setup.lock_collection(&default_collection).await?;
665

            
666
    assert!(
667
        item.is_locked().await?,
668
        "Item should be locked when collection is locked"
669
    );
670

            
671
    // Test 1: Unlock with accept
672
16
    let unlocked = setup
673
        .service_api
674
8
        .unlock(&[item.inner().path()], None)
675
16
        .await?;
676

            
677
    assert_eq!(unlocked.len(), 1, "Should have unlocked 1 item");
678
    assert_eq!(
679
        unlocked[0].as_str(),
680
        item.inner().path().as_str(),
681
        "Should return the item path"
682
    );
683
    assert!(
684
        !item.is_locked().await?,
685
        "Item should be unlocked after accepting prompt"
686
    );
687

            
688
    // Lock the item again for dismiss test
689
9
    setup.lock_collection(&default_collection).await?;
690
    assert!(item.is_locked().await?, "Item should be locked again");
691

            
692
    // Test 2: Unlock with dismiss
693
8
    setup.set_password_accept(false).await;
694
8
    let result = setup.service_api.unlock(&[item.inner().path()], None).await;
695

            
696
    assert!(
697
        matches!(result, Err(oo7::dbus::Error::Dismissed)),
698
        "Should return Dismissed error when prompt dismissed"
699
    );
700
    assert!(
701
        item.is_locked().await?,
702
        "Item should still be locked after dismissing prompt"
703
    );
704

            
705
4
    Ok(())
706
}
707

            
708
#[tokio::test]
709
async fn lock_item_in_unlocked_collection() -> Result<(), Box<dyn std::error::Error>> {
710
    let setup = TestServiceSetup::plain_session(true).await?;
711

            
712
    // Create an item (starts unlocked)
713
    let item = setup
714
        .create_item("Test Item", &[("app", "test")], "test-password", false)
715
        .await?;
716

            
717
    assert!(!item.is_locked().await?, "Item should start unlocked");
718
    assert!(
719
        !setup.collections[0].is_locked().await?,
720
        "Collection should be unlocked"
721
    );
722

            
723
    // When collection is unlocked, locking an item should happen directly without a
724
    // prompt
725
    let locked = setup.service_api.lock(&[item.inner().path()], None).await?;
726

            
727
    assert_eq!(locked.len(), 1, "Should have locked 1 item");
728
    assert_eq!(
729
        locked[0].as_str(),
730
        item.inner().path().as_str(),
731
        "Should return the item path"
732
    );
733
    assert!(item.is_locked().await?, "Item should be locked directly");
734

            
735
    // Unlock the item again (using service API to unlock just the item)
736
    let unlocked = setup
737
        .service_api
738
        .unlock(&[item.inner().path()], None)
739
        .await?;
740
    assert_eq!(unlocked.len(), 1, "Should have unlocked 1 item");
741
    assert!(!item.is_locked().await?, "Item should be unlocked again");
742

            
743
    // Locking again should work the same way (no prompt)
744
    let locked = setup.service_api.lock(&[item.inner().path()], None).await?;
745
    assert_eq!(locked.len(), 1, "Should have locked 1 item again");
746
    assert!(item.is_locked().await?, "Item should be locked again");
747

            
748
    Ok(())
749
}
750

            
751
#[tokio::test]
752
async fn lock_collection_no_prompt() -> Result<(), Box<dyn std::error::Error>> {
753
    let setup = TestServiceSetup::plain_session(true).await?;
754

            
755
    // Collection starts unlocked
756
    assert!(
757
        !setup.collections[0].is_locked().await?,
758
        "Collection should start unlocked"
759
    );
760

            
761
    // Lock the collection
762
    let locked = setup
763
        .service_api
764
        .lock(&[setup.collections[0].inner().path()], None)
765
        .await?;
766

            
767
    assert_eq!(locked.len(), 1, "Should have locked 1 collection");
768
    assert_eq!(
769
        locked[0].as_str(),
770
        setup.collections[0].inner().path().as_str(),
771
        "Should return the collection path"
772
    );
773
    assert!(
774
        setup.collections[0].is_locked().await?,
775
        "Collection should be locked instantly"
776
    );
777

            
778
    // Unlock the collection
779
    setup.unlock_collection(&setup.collections[0]).await?;
780
    assert!(
781
        !setup.collections[0].is_locked().await?,
782
        "Collection should be unlocked"
783
    );
784

            
785
    // Lock again to verify it works multiple times
786
    let locked = setup
787
        .service_api
788
        .lock(&[setup.collections[0].inner().path()], None)
789
        .await?;
790

            
791
    assert_eq!(locked.len(), 1, "Should have locked 1 collection again");
792
    assert!(
793
        setup.collections[0].is_locked().await?,
794
        "Collection should be locked again"
795
    );
796

            
797
    Ok(())
798
}
799

            
800
#[cfg(any(feature = "gnome_native_crypto", feature = "gnome_openssl_crypto"))]
801
#[tokio::test]
802
async fn unlock_item_prompt_gnome() -> Result<(), Box<dyn std::error::Error>> {
803
    unlock_item_prompt_impl(PrompterType::GNOME).await
804
}
805

            
806
#[cfg(any(feature = "plasma_native_crypto", feature = "plasma_openssl_crypto"))]
807
#[tokio::test]
808
async fn unlock_item_prompt_plasma() -> Result<(), Box<dyn std::error::Error>> {
809
    unlock_item_prompt_impl(PrompterType::Plasma).await
810
}
811

            
812
5
async fn create_collection_basic_impl(
813
    prompter_type: PrompterType,
814
) -> Result<(), Box<dyn std::error::Error>> {
815
14
    let setup = TestServiceSetup::plain_session(true).await?;
816
10
    setup.server.set_prompter_type(prompter_type).await;
817

            
818
    // Get initial collection count
819
5
    let initial_collections = setup.service_api.collections().await?;
820
10
    let initial_count = initial_collections.len();
821

            
822
    // Create a new collection
823
20
    let collection = setup
824
        .service_api
825
4
        .create_collection("MyNewKeyring", Some("my-custom-alias"), None)
826
20
        .await?;
827

            
828
    // Verify collection appears in collections list
829
11
    let collections = setup.service_api.collections().await?;
830
    assert_eq!(
831
        collections.len(),
832
        initial_count + 1,
833
        "Should have one more collection"
834
    );
835

            
836
    // Verify the collection label
837
15
    let label = collection.label().await?;
838
    assert_eq!(
839
        label, "MyNewKeyring",
840
        "Collection should have correct label"
841
    );
842

            
843
    // Verify the keyring file exists on disk
844
22
    let server_collection = setup
845
        .server
846
10
        .collection_from_path(collection.inner().path())
847
15
        .await
848
        .expect("Collection should exist on server");
849
10
    let keyring_guard = server_collection.keyring.read().await;
850
11
    let keyring_path = keyring_guard.as_ref().unwrap().path().unwrap();
851

            
852
    assert!(
853
        keyring_path.exists(),
854
        "Keyring file should exist on disk at {:?}",
855
        keyring_path
856
    );
857

            
858
    // Verify the alias was set
859
18
    let alias_collection = setup.service_api.read_alias("my-custom-alias").await?;
860
    assert!(
861
        alias_collection.is_some(),
862
        "Should be able to read collection by alias"
863
    );
864
    assert_eq!(
865
        alias_collection.unwrap().inner().path(),
866
        collection.inner().path(),
867
        "Alias should point to the new collection"
868
    );
869

            
870
11
    tokio::fs::remove_file(keyring_path).await?;
871
5
    Ok(())
872
}
873

            
874
#[cfg(any(feature = "gnome_native_crypto", feature = "gnome_openssl_crypto"))]
875
#[tokio::test]
876
async fn create_collection_basic_gnome() -> Result<(), Box<dyn std::error::Error>> {
877
    create_collection_basic_impl(PrompterType::GNOME).await
878
}
879

            
880
#[cfg(any(feature = "plasma_native_crypto", feature = "plasma_openssl_crypto"))]
881
#[tokio::test]
882
async fn create_collection_basic_plasma() -> Result<(), Box<dyn std::error::Error>> {
883
    create_collection_basic_impl(PrompterType::Plasma).await
884
}
885

            
886
6
async fn create_collection_signal_impl(
887
    prompter_type: PrompterType,
888
) -> Result<(), Box<dyn std::error::Error>> {
889
17
    let setup = TestServiceSetup::plain_session(true).await?;
890
11
    setup.server.set_prompter_type(prompter_type).await;
891

            
892
    // Subscribe to CollectionCreated signal
893
16
    let signal_stream = setup.service_api.receive_collection_created().await?;
894
10
    tokio::pin!(signal_stream);
895

            
896
    // Create a new collection
897
17
    let collection = setup
898
        .service_api
899
4
        .create_collection("TestKeyring", None, None)
900
16
        .await?;
901

            
902
    // Wait for signal with timeout
903
8
    let signal_result =
904
        tokio::time::timeout(tokio::time::Duration::from_secs(1), signal_stream.next()).await;
905

            
906
    assert!(
907
        signal_result.is_ok(),
908
        "Should receive CollectionCreated signal"
909
    );
910
9
    let signal = signal_result.unwrap();
911
    assert!(signal.is_some(), "Signal should not be None");
912

            
913
8
    let signal_collection = signal.unwrap();
914
    assert_eq!(
915
        signal_collection.inner().path().as_str(),
916
        collection.inner().path().as_str(),
917
        "Signal should contain the created collection path"
918
    );
919

            
920
16
    let server_collection = setup
921
        .server
922
8
        .collection_from_path(collection.inner().path())
923
12
        .await
924
        .expect("Collection should exist on server");
925
8
    let keyring_guard = server_collection.keyring.read().await;
926
8
    let keyring_path = keyring_guard.as_ref().unwrap().path().unwrap();
927
8
    tokio::fs::remove_file(keyring_path).await?;
928
4
    Ok(())
929
}
930

            
931
#[cfg(any(feature = "gnome_native_crypto", feature = "gnome_openssl_crypto"))]
932
#[tokio::test]
933
async fn create_collection_signal_gnome() -> Result<(), Box<dyn std::error::Error>> {
934
    create_collection_signal_impl(PrompterType::GNOME).await
935
}
936

            
937
#[cfg(any(feature = "plasma_native_crypto", feature = "plasma_openssl_crypto"))]
938
#[tokio::test]
939
async fn create_collection_signal_plasma() -> Result<(), Box<dyn std::error::Error>> {
940
    create_collection_signal_impl(PrompterType::Plasma).await
941
}
942

            
943
4
async fn create_collection_and_add_items_impl(
944
    prompter_type: PrompterType,
945
) -> Result<(), Box<dyn std::error::Error>> {
946
12
    let setup = TestServiceSetup::plain_session(true).await?;
947
8
    setup.server.set_prompter_type(prompter_type).await;
948

            
949
    // Create a new collection
950
18
    let collection = setup
951
        .service_api
952
4
        .create_collection("ItemTestKeyring", None, None)
953
17
        .await?;
954

            
955
    // Verify collection is unlocked and ready for items
956
    assert!(
957
        !collection.is_locked().await?,
958
        "New collection should be unlocked"
959
    );
960

            
961
    // Create an item in the new collection
962
5
    let secret = oo7::Secret::text("hello-world-test");
963
9
    let dbus_secret = setup.create_dbus_secret(secret.clone())?;
964

            
965
20
    let item = collection
966
        .create_item(
967
            "Test Item",
968
            &[("app", "test-app")],
969
            &dbus_secret,
970
            false,
971
5
            None,
972
        )
973
21
        .await?;
974

            
975
    // Verify item was created
976
16
    let items = collection.items().await?;
977
    assert_eq!(items.len(), 1, "Should have one item in new collection");
978
    assert_eq!(
979
        items[0].inner().path(),
980
        item.inner().path(),
981
        "Item path should match"
982
    );
983

            
984
    // Verify we can retrieve the secret
985
15
    let retrieved_secret = item.secret(&setup.session).await?;
986
    assert_eq!(
987
        retrieved_secret.value(),
988
        secret.as_bytes(),
989
        "Should be able to retrieve secret from item in new collection"
990
    );
991

            
992
22
    let server_collection = setup
993
        .server
994
10
        .collection_from_path(collection.inner().path())
995
15
        .await
996
        .expect("Collection should exist on server");
997
14
    let keyring_guard = server_collection.keyring.read().await;
998
14
    let keyring_path = keyring_guard.as_ref().unwrap().path().unwrap();
999
11
    tokio::fs::remove_file(&keyring_path).await?;
4
    Ok(())
}
#[cfg(any(feature = "gnome_native_crypto", feature = "gnome_openssl_crypto"))]
#[tokio::test]
async fn create_collection_and_add_items_gnome() -> Result<(), Box<dyn std::error::Error>> {
    create_collection_and_add_items_impl(PrompterType::GNOME).await
}
#[cfg(any(feature = "plasma_native_crypto", feature = "plasma_openssl_crypto"))]
#[tokio::test]
async fn create_collection_and_add_items_plasma() -> Result<(), Box<dyn std::error::Error>> {
    create_collection_and_add_items_impl(PrompterType::Plasma).await
}
7
async fn create_collection_dismissed_impl(
    prompter_type: PrompterType,
) -> Result<(), Box<dyn std::error::Error>> {
16
    let setup = TestServiceSetup::plain_session(true).await?;
11
    setup.server.set_prompter_type(prompter_type).await;
    // Get initial collection count
5
    let initial_collections = setup.service_api.collections().await?;
12
    let initial_count = initial_collections.len();
    // Set mock prompter to dismiss
5
    setup.set_password_accept(false).await;
    // Try to create a collection
14
    let result = setup
        .service_api
5
        .create_collection("DismissedKeyring", None, None)
19
        .await;
    // Should get Dismissed error
    assert!(
        matches!(result, Err(oo7::dbus::Error::Dismissed)),
        "Should return Dismissed error when prompt dismissed"
    );
    // Verify collection was NOT created
8
    let collections = setup.service_api.collections().await?;
    assert_eq!(
        collections.len(),
        initial_count,
        "Should not have created a new collection after dismissal"
    );
4
    Ok(())
}
#[cfg(any(feature = "gnome_native_crypto", feature = "gnome_openssl_crypto"))]
#[tokio::test]
async fn create_collection_dismissed_gnome() -> Result<(), Box<dyn std::error::Error>> {
    create_collection_dismissed_impl(PrompterType::GNOME).await
}
#[cfg(any(feature = "plasma_native_crypto", feature = "plasma_openssl_crypto"))]
#[tokio::test]
async fn create_collection_dismissed_plasma() -> Result<(), Box<dyn std::error::Error>> {
    create_collection_dismissed_impl(PrompterType::Plasma).await
}
#[tokio::test]
async fn complete_collection_creation_no_pending() -> Result<(), Box<dyn std::error::Error>> {
    let setup = TestServiceSetup::plain_session(true).await?;
    // Try to complete collection creation with a prompt path that has no pending
    // collection
    let fake_prompt_path = ObjectPath::try_from("/org/freedesktop/secrets/prompt/p999").unwrap();
    let secret = Secret::from("test-password-long-enough");
    let result = setup
        .server
        .complete_collection_creation(&fake_prompt_path, secret)
        .await;
    // Should get NoSuchObject error
    assert!(
        matches!(result, Err(ServiceError::NoSuchObject(_))),
        "Should return NoSuchObject error when no pending collection exists"
    );
    Ok(())
}
#[tokio::test]
async fn discover_v1_keyrings() -> Result<(), Box<dyn std::error::Error>> {
    // Set up a temporary data directory
    let temp_dir = tempfile::tempdir()?;
    let service = Service::new(temp_dir.path().to_path_buf(), None);
    // Create v1 keyrings directory
    let v1_dir = temp_dir.path().join("keyrings/v1");
    tokio::fs::create_dir_all(&v1_dir).await?;
    // Test 1: Empty directory
    let discovered = service.discover_keyrings(None).await?;
    assert!(
        discovered.is_empty(),
        "Should discover no keyrings in empty directory"
    );
    // Create multiple keyrings with different passwords
    // Add items to each so password validation works
    let secret1 = Secret::from("password-for-work");
    let keyring1 = UnlockedKeyring::open_at(temp_dir.path(), "work", secret1.clone()).await?;
    keyring1
        .create_item(
            "Work Item",
            &[("type", "work")],
            Secret::text("work-secret"),
            false,
        )
        .await?;
    keyring1.write().await?;
    let secret2 = Secret::from("password-for-personal");
    let keyring2 = UnlockedKeyring::open_at(temp_dir.path(), "personal", secret2.clone()).await?;
    keyring2
        .create_item(
            "Personal Item",
            &[("type", "personal")],
            Secret::text("personal-secret"),
            false,
        )
        .await?;
    keyring2.write().await?;
    // Create a "login" keyring which should get the default alias
    let secret3 = Secret::from("password-for-login");
    let keyring3 = UnlockedKeyring::open_at(temp_dir.path(), "login", secret3.clone()).await?;
    keyring3
        .create_item(
            "Login Item",
            &[("type", "login")],
            Secret::text("login-secret"),
            false,
        )
        .await?;
    keyring3.write().await?;
    // Create some non-keyring files that should be skipped
    tokio::fs::write(v1_dir.join("README.txt"), b"This is a readme").await?;
    tokio::fs::write(v1_dir.join("config.json"), b"{}").await?;
    tokio::fs::create_dir(v1_dir.join("subdir")).await?;
    // Test 2: Discover without any password, all should be locked
    let discovered = service.discover_keyrings(None).await?;
    assert_eq!(discovered.len(), 3, "Should discover 3 keyrings");
    for (_, _, _, keyring) in &discovered {
        assert!(
            keyring.is_locked(),
            "All keyrings should be locked without secret"
        );
    }
    // Test 3: Discover with one password, only that keyring should be unlocked
    let discovered = service.discover_keyrings(Some(secret1.clone())).await?;
    assert_eq!(discovered.len(), 3, "Should discover 3 keyrings");
    let work_keyring = discovered
        .iter()
        .find(|(_, label, ..)| label == "Work")
        .unwrap();
    assert!(
        !work_keyring.3.is_locked(),
        "Work keyring should be unlocked with correct password"
    );
    let personal_keyring = discovered
        .iter()
        .find(|(_, label, ..)| label == "Personal")
        .unwrap();
    assert!(
        personal_keyring.3.is_locked(),
        "Personal keyring should be locked with wrong password"
    );
    // Test 4: Verify login keyring gets default alias
    let login_keyring = discovered
        .iter()
        .find(|(_, label, ..)| label == "Login")
        .unwrap();
    assert_eq!(
        login_keyring.2,
        oo7::dbus::Service::DEFAULT_COLLECTION,
        "Login keyring should have default alias"
    );
    assert!(
        login_keyring.3.is_locked(),
        "Login keyring should be locked with wrong password"
    );
    // Test 5: Verify labels are properly capitalized
    let labels: Vec<_> = discovered
        .iter()
        .map(|(_, label, ..)| label.as_str())
        .collect();
    assert!(labels.contains(&"Work"), "Should have Work with capital W");
    assert!(
        labels.contains(&"Personal"),
        "Should have Personal with capital P"
    );
    assert!(
        labels.contains(&"Login"),
        "Should have Login with capital L"
    );
    Ok(())
}
#[cfg(any(feature = "gnome_native_crypto", feature = "gnome_openssl_crypto"))]
#[tokio::test]
async fn unlock_pending_v0_migration_gnome() -> Result<(), Box<dyn std::error::Error>> {
    unlock_pending_v0_migration_impl(PrompterType::GNOME).await
}
#[cfg(any(feature = "plasma_native_crypto", feature = "plasma_openssl_crypto"))]
#[tokio::test]
async fn unlock_pending_v0_migration_plasma() -> Result<(), Box<dyn std::error::Error>> {
    unlock_pending_v0_migration_impl(PrompterType::Plasma).await
}
4
async fn unlock_pending_v0_migration_impl(
    prompter_type: PrompterType,
) -> Result<(), Box<dyn std::error::Error>> {
8
    let temp_dir = tempfile::tempdir()?;
9
    let keyrings_dir = temp_dir.path().join("keyrings");
9
    let v1_dir = keyrings_dir.join("v1");
13
    tokio::fs::create_dir_all(&v1_dir).await?;
    // Copy the v0 keyring fixture
5
    let v0_secret = Secret::from("test");
17
    let fixture_path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
        .parent()
        .unwrap()
        .join("client/fixtures/legacy.keyring");
5
    let v0_path = keyrings_dir.join("legacy.keyring");
13
    tokio::fs::copy(&fixture_path, &v0_path).await?;
    // Create test service that discovers keyrings WITHOUT the password
    let setup = crate::tests::TestServiceSetup::with_disk_keyrings(
5
        temp_dir.path().to_path_buf(),
5
        None,
5
        None,
    )
21
    .await?;
11
    setup.server.set_prompter_type(prompter_type).await;
    // Verify v0 is pending migration
    assert_eq!(
        setup.server.pending_migrations.lock().await.len(),
        1,
        "V0 keyring should be pending migration"
    );
    // Find the placeholder collection
6
    let collections = setup.service_api.collections().await?;
7
    let mut legacy_collection = None;
22
    for collection in &collections {
18
        if collection.label().await? == "Legacy" {
5
            legacy_collection = Some(collection);
            break;
        }
    }
11
    let legacy_collection = legacy_collection.expect("Should have Legacy placeholder collection");
    // Verify it's locked
    assert!(
        legacy_collection.is_locked().await?,
        "Placeholder should be locked"
    );
    // Set up mock prompter to provide the correct password
10
    setup.set_password_queue(vec![v0_secret.clone()]).await;
    // Unlock via D-Bus API (should trigger migration)
18
    let unlocked = setup
        .service_api
5
        .unlock(&[legacy_collection.inner().path()], None)
19
        .await?;
    assert_eq!(unlocked.len(), 1, "Should have unlocked the collection");
    assert!(
        !legacy_collection.is_locked().await?,
        "Collection should be unlocked"
    );
    // Verify migration happened
    assert_eq!(
        setup.server.pending_migrations.lock().await.len(),
        0,
        "Should have no pending migrations after unlock"
    );
    // Verify v1 file was created
4
    let v1_migrated = v1_dir.join("legacy.keyring");
    assert!(v1_migrated.exists(), "V1 file should exist after migration");
    // Verify v0 file was removed
    assert!(
        !v0_path.exists(),
        "V0 file should be removed after migration"
    );
4
    Ok(())
}
#[tokio::test]
async fn discover_v0_keyrings() -> Result<(), Box<dyn std::error::Error>> {
    let temp_dir = tempfile::tempdir()?;
    let service = Service::new(temp_dir.path().to_path_buf(), None);
    let keyrings_dir = temp_dir.path().join("keyrings");
    let v1_dir = keyrings_dir.join("v1");
    tokio::fs::create_dir_all(&keyrings_dir).await?;
    tokio::fs::create_dir_all(&v1_dir).await?;
    // Copy the existing v0 keyring fixture
    let v0_secret = Secret::from("test");
    let fixture_path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
        .parent()
        .unwrap()
        .join("client/fixtures/legacy.keyring");
    let v0_path = keyrings_dir.join("legacy.keyring");
    tokio::fs::copy(&fixture_path, &v0_path).await?;
    // Create a v1 keyring for mixed scenario
    let v1_secret = Secret::from("v1-password");
    let v1_keyring = UnlockedKeyring::open_at(temp_dir.path(), "modern", v1_secret.clone()).await?;
    v1_keyring
        .create_item(
            "V1 Item",
            &[("type", "v1")],
            Secret::text("v1-secret"),
            false,
        )
        .await?;
    v1_keyring.write().await?;
    // Test 1: Discover without secret, v0 marked for migration, v1 locked
    let discovered = service.discover_keyrings(None).await?;
    assert_eq!(
        discovered.len(),
        2,
        "Should discover v1 keyring + v0 placeholder"
    );
    let pending = service.pending_migrations.lock().await;
    assert_eq!(pending.len(), 1, "V0 should be pending migration");
    assert!(pending.contains_key("legacy"));
    drop(pending);
    // Test 2: Discover with v0 secret, v0 migrated, v1 locked
    service.pending_migrations.lock().await.clear();
    let discovered = service.discover_keyrings(Some(v0_secret.clone())).await?;
    assert_eq!(discovered.len(), 2, "Should discover both keyrings");
    let legacy = discovered.iter().find(|(_, l, ..)| l == "Legacy").unwrap();
    assert!(!legacy.3.is_locked(), "V0 should be migrated and unlocked");
    assert_eq!(
        service.pending_migrations.lock().await.len(),
        0,
        "No pending after successful migration"
    );
    // Verify v1 file was created
    let v1_migrated = temp_dir.path().join("keyrings/v1/legacy.keyring");
    assert!(v1_migrated.exists(), "V1 file should exist after migration");
    // Test 3: Discover with wrong v0 secret,  marked for pending migration
    tokio::fs::remove_file(&v1_migrated).await?;
    service.pending_migrations.lock().await.clear();
    // Restore the v0 file for this test
    tokio::fs::copy(&fixture_path, &v0_path).await?;
    let wrong_secret = Secret::from("wrong-password");
    let discovered = service.discover_keyrings(Some(wrong_secret)).await?;
    assert_eq!(
        discovered.len(),
        2,
        "V1 + v0 placeholder should be discovered with wrong v0 password"
    );
    assert_eq!(
        service.pending_migrations.lock().await.len(),
        1,
        "V0 should be pending with wrong password"
    );
    Ok(())
}
#[cfg(feature = "kwallet_migration")]
#[tokio::test]
async fn discover_kwallet_keyrings() -> Result<(), Box<dyn std::error::Error>> {
    let temp_dir = tempfile::tempdir()?;
    let service = Service::new(temp_dir.path().to_path_buf(), None);
    let kwallet_dir = temp_dir.path().join("kwalletd");
    let v1_dir = temp_dir.path().join("keyrings/v1");
    tokio::fs::create_dir_all(&kwallet_dir).await?;
    tokio::fs::create_dir_all(&v1_dir).await?;
    // Copy KWallet test fixture
    let kwallet_secret = Secret::from("password");
    let fixture_path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
        .parent()
        .unwrap()
        .join("kwallet/parser/tests/blowfish_cbc_pbkdf2_sha512_manual.kwl");
    let kwallet_path = kwallet_dir.join("kdewallet.kwl");
    tokio::fs::copy(&fixture_path, &kwallet_path).await?;
    // Copy the salt file too (required for PBKDF2-SHA512)
    let salt_fixture = fixture_path.with_extension("salt");
    let salt_path = kwallet_path.with_extension("salt");
    tokio::fs::copy(&salt_fixture, &salt_path).await?;
    // Create a v1 keyring for mixed scenario
    let v1_secret = Secret::from("v1-password");
    let v1_keyring = UnlockedKeyring::open_at(temp_dir.path(), "modern", v1_secret.clone()).await?;
    v1_keyring
        .create_item(
            "V1 Item",
            &[("type", "v1")],
            Secret::text("v1-secret"),
            false,
        )
        .await?;
    v1_keyring.write().await?;
    // Test 1: Discover without secret, KWallet marked for migration, v1 locked
    let discovered = service.discover_keyrings(None).await?;
    assert_eq!(
        discovered.len(),
        2,
        "Should discover v1 keyring + kwallet placeholder"
    );
    let pending = service.pending_migrations.lock().await;
    assert_eq!(pending.len(), 1, "KWallet should be pending migration");
    assert!(pending.contains_key("kdewallet"));
    drop(pending);
    // Test 2: Discover with KWallet secret, KWallet migrated, v1 locked
    service.pending_migrations.lock().await.clear();
    let discovered = service
        .discover_keyrings(Some(kwallet_secret.clone()))
        .await?;
    assert_eq!(discovered.len(), 2, "Should discover both keyrings");
    let kdewallet = discovered
        .iter()
        .find(|(_, l, ..)| l == "Kdewallet")
        .unwrap();
    assert!(
        !kdewallet.3.is_locked(),
        "KWallet should be migrated and unlocked"
    );
    assert_eq!(
        kdewallet.2, "kdewallet",
        "kdewallet should have kdewallet alias (not default)"
    );
    assert_eq!(
        service.pending_migrations.lock().await.len(),
        0,
        "No pending after successful migration"
    );
    // Verify v1 file was created
    let v1_migrated = temp_dir.path().join("keyrings/v1/kdewallet.keyring");
    assert!(v1_migrated.exists(), "V1 file should exist after migration");
    // Verify old KWallet files were removed
    assert!(
        !kwallet_path.exists(),
        "Original .kwl file should be removed"
    );
    assert!(!salt_path.exists(), "Original .salt file should be removed");
    // Test 3: Discover with wrong KWallet secret, marked for pending migration
    tokio::fs::remove_file(&v1_migrated).await?;
    service.pending_migrations.lock().await.clear();
    // Restore the KWallet files for this test
    tokio::fs::copy(&fixture_path, &kwallet_path).await?;
    tokio::fs::copy(&salt_fixture, &salt_path).await?;
    let wrong_secret = Secret::from("wrong-password");
    let discovered = service.discover_keyrings(Some(wrong_secret)).await?;
    assert_eq!(
        discovered.len(),
        2,
        "V1 + kwallet placeholder should be discovered with wrong KWallet password"
    );
    assert_eq!(
        service.pending_migrations.lock().await.len(),
        1,
        "KWallet should be pending with wrong password"
    );
    // Verify the pending migration has the correct type
    let pending = service.pending_migrations.lock().await;
    let migration = pending.get("kdewallet").unwrap();
    assert_eq!(migration.label(), "Kdewallet");
    assert_eq!(migration.alias(), "kdewallet");
    Ok(())
}