diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ed87ccebe..87b9fb6ad 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -321,7 +321,9 @@ jobs: make manifestlink Flavor=Net - name: Install NuGet dependencies (net) - run: make nuget Flavor=Net + run: | + dotnet workload restore --project src/KeePass.sln + make nuget Flavor=NoNet - name: Build keepass2android (net) run: | @@ -349,7 +351,9 @@ jobs: make manifestlink Flavor=NoNet - name: Install NuGet dependencies (nonet) - run: make nuget Flavor=NoNet + run: | + dotnet workload restore --project src/KeePass.sln + make nuget Flavor=NoNet - name: Build keepass2android (nonet) run: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0921c72dd..917a3b32e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -121,7 +121,9 @@ jobs: make manifestlink Flavor=${{ matrix.flavor }} - name: Install NuGet dependencies - run: make nuget Flavor=${{ matrix.flavor }} + run: | + dotnet workload restore --project src/KeePass.sln + make nuget Flavor=${{ matrix.flavor }} - name: Build APK (net) env: diff --git a/.gitignore b/.gitignore index edfb7c624..bbb1ba6a4 100644 --- a/.gitignore +++ b/.gitignore @@ -48,6 +48,8 @@ Thumbs.db /src/java/kp2akeytransform/.settings +/src/java/PluginQR/.settings + /src/java/workspace/UITest /src/java/JavaFileStorage/gen/keepass2android/javafilestorage/BuildConfig.java /src/java/JavaFileStorage/gen/keepass2android/javafilestorage/R.java diff --git a/docs/KeeShare-Implementation-Notes.md b/docs/KeeShare-Implementation-Notes.md new file mode 100644 index 000000000..62462db02 --- /dev/null +++ b/docs/KeeShare-Implementation-Notes.md @@ -0,0 +1,323 @@ +# KeeShare Implementation Notes for keepass2android + +This document captures the implementation work done on KeeShare support for Android, the challenges encountered, and what remains to be done. + +## Overview + +KeeShare allows secure password group synchronization between KeePass databases. The goal was to implement the ability for Android-only users to create KeeShare import configurations directly from the app, rather than requiring a desktop KeePass client. + +## What Was Implemented + +### 1. Add KeeShare Configuration Feature + +**Files Modified:** +- `src/keepass2android-app/ConfigureKeeShareActivity.cs` +- `src/keepass2android-app/Resources/layout/config_keeshare.xml` (added FAB) +- `src/keepass2android-app/Resources/layout/dialog_add_keeshare.xml` (new dialog) +- `src/keepass2android-app/Resources/values/strings.xml` (new strings) + +**Features Added:** +- Floating Action Button (FAB) to add new KeeShare configurations +- "Add KeeShare" dialog with: + - Group selection (create new or use existing) + - Share type selection (Import/Synchronize/Export) + - Password field for the shared file + - Browse button to select the KeeShare file + +### 2. Edit KeeShare Configuration Feature + +**Files Modified:** +- `src/keepass2android-app/Resources/layout/keeshare_config_row.xml` (added Edit button) +- `src/keepass2android-app/Resources/layout/dialog_edit_keeshare.xml` (new dialog) + +**Features Added:** +- Edit button on each KeeShare configuration row +- Edit dialog allowing: + - Password updates + - Share type changes +- Password status indicator (shows if password is configured) + +### 3. Improved Error Handling + +**Files Modified:** +- `src/keepass2android-app/KeeShare.cs` + +**Changes:** +- User-friendly error messages for wrong password +- Clear guidance to use Edit button when password is needed + +## Technical Challenges and Solutions + +### Challenge 1: File Selection Flow Going Through PasswordActivity + +**Problem:** When using FileSelectActivity to browse for the KeeShare file, the app would launch PasswordActivity to open the database, instead of just returning the selected file path. + +**Solution:** Changed the Browse button to use Android's Storage Access Framework directly: +```csharp +Intent intent = new Intent(Intent.ActionOpenDocument); +intent.AddCategory(Intent.CategoryOpenable); +intent.SetType("*/*"); +StartActivityForResult(intent, ReqCodeSelectFileForNewConfig); +``` + +### Challenge 2: SingleInstance LaunchMode Breaking StartActivityForResult + +**Problem:** ConfigureKeeShareActivity had `LaunchMode = LaunchMode.SingleInstance`, which caused the file picker result to not be delivered properly because SingleInstance activities run in their own task. + +**Solution:** Changed to `LaunchMode = LaunchMode.SingleTop`: +```csharp +[Activity(Label = "@string/keeshare_title", ... LaunchMode = LaunchMode.SingleTop, ...)] +``` + +### Challenge 3: Group Creation Before File Selection Causing Database Association Issues + +**Problem:** When creating a new group in the Browse button handler before launching the file picker, the group would not be properly recognized by `FindDatabaseForElement` after the activity was recreated. + +**Solution:** Deferred group creation to OnActivityResult: +- Store `_pendingCreateNewGroup` and `_pendingNewGroupName` flags +- Create the group only when file selection completes in OnActivityResult +- Use `SaveDatabase(db)` directly instead of `SaveGroup(group)` to avoid FindDatabaseForElement lookup + +### Challenge 4: Threading Issue in Sync Callback + +**Problem:** The Update() call in OnSyncNow's completion callback was being called from a background thread, causing `CalledFromWrongThreadException`. + +**Solution:** Wrapped in RunOnUiThread: +```csharp +activity?.RunOnUiThread(() => activity.Update()); +``` + +### Challenge 5: Display Bug with Imported Entries + +**Problem:** After a successful KeeShare sync/import, when trying to view the entries in the imported group, the app crashes with: +``` +System.Exception: Database element KeePassLib.PwUuid not found in any of 1 databases! + at keepass2android.Kp2aApp.FindDatabaseForElement(IStructureItem element) + at keepass2android.view.PwEntryView..ctor(...) +``` + +**Root Cause:** Imported/cloned entries were not registered with Kp2a's database tracking system (Elements, EntriesById, GroupsById collections). + +**Solution:** Added `UpdateGlobals()` and `MarkAllGroupsAsDirty()` after MergeIn in KeeShare.cs: +```csharp +// In SyncGroups method, after MergeIn: +_app.CurrentDb.UpdateGlobals(); +_app.MarkAllGroupsAsDirty(); +``` + +**Status:** FIXED. Imported entries can now be viewed without crashing. + +## Unit Tests + +The KeeShare functionality has comprehensive unit tests in `src/KeeShare.Tests/`. + +### Test Architecture + +Due to framework incompatibility (the app targets `net9.0-android`, tests target `net8.0`), tests use a pattern of copying pure logic into test helpers to avoid Android dependencies. + +**Test Helpers (`TestHelpers/`):** +| File | Purpose | +|------|---------| +| `PwGroupStub.cs` | Stub for PwGroup with CustomData, Groups, Entries | +| `PwEntryStub.cs` | Stub for PwEntry with ParentGroup | +| `PwDatabaseStub.cs` | Stub for PwDatabase with IsOpen, RootGroup | +| `KeeShareConfigLogic.cs` | Copy of configuration methods from KeeShare.cs | +| `KeeShareCompatibilityLogic.cs` | Copy of KeePassXC compatibility logic | +| `KeeShareStateLogic.cs` | Copy of state checking methods | +| `KeeShareTestHelpers.cs` | Signature verification logic | + +### Running Unit Tests + +```bash +cd src/KeeShare.Tests +dotnet test --verbosity normal +``` + +### Test Coverage (141 tests) + +| Test Class | Tests | Coverage | +|------------|-------|----------| +| `ConfigurationTests.cs` | 37 | EnableKeeShare, DisableKeeShare, GetEffectiveFilePath, etc. | +| `KeePassXCCompatibilityTests.cs` | 30 | HasKeePassXCFormat, TryImportKeePassXCConfig | +| `StateCheckingTests.cs` | 39 | IsReadOnlyBecauseKeeShareImport, HasExportableKeeShareGroups | +| `KeeShareItemTests.cs` | 15 | KeeShareItem properties | +| `SignatureVerificationTests.cs` | 14 | VerifySignatureCore with various inputs | +| `HasKeeShareGroupsTests.cs` | 6 | HasKeeShareGroups recursive detection | + +### Mutation Testing + +To validate that tests are meaningful (actually catch bugs), we use manual mutation testing. + +**What is Mutation Testing?** +Mutation testing introduces deliberate bugs (mutations) into the code and verifies that tests fail. If tests still pass after a mutation, the tests aren't catching that class of bug. + +**Running Manual Mutation Tests:** + +1. Introduce a mutation in a test helper file +2. Run tests +3. Verify tests fail (mutation "killed") +4. Revert the mutation + +**Example Mutations and Results:** + +| Mutation | Description | Result | +|----------|-------------|--------| +| Change `"true"` to `"false"` in EnableKeeShare | Sets Active to wrong value | **KILLED** (4 tests failed) | +| Remove type validation | Accept invalid types | **KILLED** (1 test failed) | +| Check "Export" instead of "Import" in IsReadOnlyBecauseKeeShareImport | Wrong type check | **KILLED** (7 tests failed) | +| HasKeePassXCFormat always returns false | Skip format detection | **KILLED** (21 tests failed) | +| GetEffectiveFilePath returns original path first | Wrong priority | **KILLED** (1 test failed) | + +All mutations were killed, demonstrating the tests validate: +- Correct values being set +- Validation logic working +- Business logic (Import vs Export distinctions) +- Priority/ordering logic +- Detection algorithms + +**Automated Mutation Testing with Stryker.NET:** + +Stryker.NET is the standard tool for .NET mutation testing, but requires separate source and test projects. Since our logic is in test helpers (same project), Stryker can't directly mutate it. + +If you restructure to have logic in a separate library: + +```bash +# Install Stryker +dotnet tool install --global dotnet-stryker + +# Run mutation testing +cd src/KeeShare.Tests +dotnet-stryker +``` + +### Keeping Test Helpers in Sync + +When modifying `KeeShare.cs`, update the corresponding test helper: + +| Production Code | Test Helper | +|-----------------|-------------| +| `KeeShare.cs` lines 46-195 (config) | `KeeShareConfigLogic.cs` | +| `KeeShare.cs` lines 197-308 (KeePassXC) | `KeeShareCompatibilityLogic.cs` | +| `KeeShare.cs` lines 358-542 (state) | `KeeShareStateLogic.cs` | +| `KeeShareCheckOperation.VerifySignatureCore` | `KeeShareTestHelpers.cs` | + +Each helper includes a comment with the sync date - update this when syncing. + +## E2E Test Files + +Test database files in `e2e-tests/test-data/`: +- `keeshare-test-main.kdbx` - Password: `test123` - Main database to open +- `keeshare-test-export.kdbx` - Password: `share123` - Contains "Test Service Account" entry to import + +**Important:** The main test database can accumulate KeeShare groups from previous test runs. If tests start failing with "wrong password" errors on multiple groups, recreate the main database: +```bash +# Recreate fresh main database +rm e2e-tests/test-data/keeshare-test-main.kdbx +echo -e "test123\ntest123" | keepassxc-cli db-create -p e2e-tests/test-data/keeshare-test-main.kdbx +echo "test123" | keepassxc-cli add -u testuser -p e2e-tests/test-data/keeshare-test-main.kdbx "Sample Entry" <<< "testpass" + +# Push to emulator +adb push e2e-tests/test-data/keeshare-test-main.kdbx /sdcard/Download/ +adb push e2e-tests/test-data/keeshare-test-export.kdbx /sdcard/Download/ +``` + +## Maestro E2E Tests + +**Location:** `e2e-tests/.maestro/` + +### keeshare_import_flow.yaml +Tests the complete flow: +1. Open main test database +2. Navigate to Settings -> Database -> Configure KeeShare +3. Tap FAB to add new KeeShare +4. Enter group name +5. Browse and select export file +6. Edit to set password +7. Tap Sync now +8. Verify sync completes + +**Current Status:** Test passes completely, including verifying imported entries can be viewed. + +### Other test files +- `keeshare_edit_password.yaml` - Tests editing password +- `keeshare_wrong_password.yaml` - Tests error handling + +## What's Working + +1. **KeeShare Configuration Screen** - Shows existing configurations +2. **Add KeeShare Button (FAB)** - Opens add dialog +3. **Add KeeShare Dialog** - Group selection, type selection, password, browse +4. **File Selection** - Uses direct Android file picker (ACTION_OPEN_DOCUMENT) +5. **Edit KeeShare** - Can update password and type +6. **Sync** - Actually imports entries from the KeeShare file +7. **Error Messages** - Shows user-friendly password errors + +## What's Working (All Features!) + +1. **Viewing Imported Entries** - FIXED! The display bug has been resolved + - Root cause was: `FindDatabaseForElement` didn't recognize imported entries because they weren't in the tracking collections + - Fix: Added `UpdateGlobals()` and `MarkAllGroupsAsDirty()` after MergeIn in KeeShare.cs + +## State Preservation + +Added state preservation for activity recreation during file selection: +- `_pendingConfigItem` (existing) +- `_pendingNewConfigGroup` +- `_pendingNewConfigType` +- `_pendingNewConfigPassword` +- `_pendingCreateNewGroup` (new) +- `_pendingNewGroupName` (new) + +## Key Files to Review + +1. `src/keepass2android-app/ConfigureKeeShareActivity.cs` - Main configuration UI +2. `src/keepass2android-app/KeeShare.cs` - Core sync logic +3. `src/keepass2android-app/KeeShareCheckOperation.cs` - Sync operation +4. `src/keepass2android-app/Kp2aApp.cs` - Database tracking (`FindDatabaseForElement`) + +## Completed Fixes + +1. **Display Bug - FIXED:** + - Added `UpdateGlobals()` and `MarkAllGroupsAsDirty()` after MergeIn in `KeeShare.cs` (SyncGroups method) + - This registers imported entries in the tracking collections (Elements, EntriesById, GroupsById) + - Imported entries can now be viewed without crashing + +2. **UI Refresh Bug - FIXED:** + - Added synchronous `db.UpdateGlobals()` call in `SaveDatabase` callback in `ConfigureKeeShareActivity.cs` + - Newly created KeeShare groups are now visible immediately in the UI + +## Next Steps + +1. **Test on Real Device:** + - Verify functionality on physical Android device + - Test with real KeeShare files from KeePass desktop + +2. **Consider Additional Tests:** + - Test Synchronize mode (bidirectional sync) + - Test Export mode + - Test with signed containers + +## Building and Testing + +```bash +# Build Release APK +cd src +dotnet build keepass2android-app/keepass2android-app.csproj -c Release -f net9.0-android + +# Install +adb install -r keepass2android-app/bin/Release/net9.0-android/keepass2android.keepass2android_nonet-Signed.apk + +# Run Maestro test +cd /path/to/keepass2android +~/.maestro/bin/maestro test e2e-tests/.maestro/keeshare_import_flow.yaml + +# Check logs +adb logcat -d | grep "KP2A\|KeeShare" +``` + +## PR Status + +This work is part of the keeshare-support branch. The core "Add KeeShare" feature is fully functional: +- Add, Edit, and Sync KeeShare configurations work correctly +- Imported entries can be viewed without crashing (display bug fixed) +- E2E tests pass for the complete flow including viewing imported entries diff --git a/docs/KeeShare.md b/docs/KeeShare.md new file mode 100644 index 000000000..559a76980 --- /dev/null +++ b/docs/KeeShare.md @@ -0,0 +1,381 @@ +# KeeShare - Password Sharing for KeePass2Android + +KeeShare enables secure sharing and synchronization of password entries between KeePass databases. Originally developed for [KeePassXC](https://keepassxc.org/docs/KeePassXC_UserGuide), this feature allows you to share subsets of your passwords with family members, team members, or synchronize between your own devices. + +## Table of Contents + +- [Overview](#overview) +- [How It Works](#how-it-works) +- [Sharing Modes](#sharing-modes) +- [Setup Guide](#setup-guide) + - [Option A: Configure in KeePass2Android](#option-a-configure-in-keepass2android-recommended-for-mobile-only) + - [Option B: Configure in KeePassXC First](#option-b-configure-in-keepassxc-desktop-first) +- [Security Considerations](#security-considerations) +- [Troubleshooting](#troubleshooting) +- [Testing the KeeShare Feature](#testing-the-keeshare-feature) + +## Overview + +KeeShare allows you to: + +- **Share passwords** with other users via a shared container file +- **Synchronize entries** between multiple devices or databases +- **Import credentials** from shared databases created by others +- **Export credentials** to share with team members or family + +Sharing is configured at the **group level** - when you enable KeeShare on a group, all entries within that group (and its subgroups) are included in the share. + +> **Warning:** If you enable sharing on the root group, every password in your database will be shared! + +## How It Works + +``` +┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐ +│ Your Database │ ──────► │ Shared Container │ ◄────── │ Other Database │ +│ │ │ (.kdbx file) │ │ │ +│ ┌───────────┐ │ │ │ │ ┌───────────┐ │ +│ │ Shared │ │ Export │ Stored on: │ Import │ │ Shared │ │ +│ │ Group │──┼────────►│ - Cloud storage │◄────────┼──│ Group │ │ +│ │ │ │ │ - Network share │ │ │ │ │ +│ └───────────┘ │ │ - Local folder │ │ └───────────┘ │ +└─────────────────┘ └──────────────────┘ └─────────────────┘ +``` + +1. **Export**: Your shared group is written to a separate encrypted `.kdbx` file +2. **Storage**: The container file is stored in a location accessible to all parties (cloud storage, network share, etc.) +3. **Import**: Other databases read and merge entries from the shared container +4. **Synchronize**: Two-way sync keeps all databases up to date + +## Sharing Modes + +| Mode | Description | Use Case | +|------|-------------|----------| +| **Inactive** | Sharing disabled for this group | Temporarily pause sharing | +| **Import** | Read-only; pulls changes from shared file | Receive credentials from others | +| **Export** | Write-only; pushes changes to shared file | Share credentials with others | +| **Synchronize** | Two-way; both imports and exports | Keep multiple devices in sync | + +### Mode Selection Guide + +- **Personal sync between devices**: Use `Synchronize` on all devices +- **Team password sharing (one admin)**: Admin uses `Export`, team uses `Import` +- **Family sharing (equal access)**: Everyone uses `Synchronize` + +## Setup Guide + +There are two ways to set up KeeShare: + +**Option A: Configure entirely in KeePass2Android** (new!) +- Create a new KeeShare import/export directly in the app +- Best for Android-only users or when you don't have access to KeePassXC + +**Option B: Configure in KeePassXC first** +- Set up sharing in KeePassXC on desktop +- Configure device-specific paths in KeePass2Android +- Best when working across desktop and mobile + +### Option A: Configure in KeePass2Android (Recommended for Mobile-Only) + +You can now create KeeShare configurations directly in KeePass2Android without needing KeePassXC. + +1. **Open your database** in KeePass2Android + +2. **Navigate to KeeShare settings**: + - Tap the menu (⋮) → **Settings** → **Database** → **Configure KeeShare groups...** + +3. **Tap the + button** (FAB) in the bottom right corner + + KeeShare with FAB + +4. **Configure the KeeShare**: + + Add KeeShare Dialog + + - **Group to sync**: Select an existing group or create a new one + - **Share Type**: Choose Import, Export, or Synchronize + - **Shared File Path**: Tap "Browse..." to select the shared `.kdbx` file + - **Password**: Enter the password for the shared container (if any) + +5. **Tap OK** to save the configuration + +6. The entries will sync when you save your database + +### Option B: Configure in KeePassXC (Desktop) First + +If you're working with both desktop and mobile, you can configure KeeShare in KeePassXC first. + +1. **Open your database** in KeePassXC on your computer + +2. **Enable KeeShare** in settings: + - Go to **Tools → Settings → KeeShare** + - Check **"Allow import"** to receive shared credentials + - Check **"Allow export"** to share your credentials + +3. **Configure a group for sharing**: + - Right-click on the group you want to share + - Select **"Edit Group"** + - Go to the **"KeeShare"** tab + - Select the sharing mode (Import, Export, or Synchronize) + - Set the **file path** for the shared container (use a cloud-synced folder like Dropbox, Google Drive, etc.) + - Set a **password** for the shared container + +4. **Save your database** - This creates the shared container file + +### Step 2: Configure Device Paths in KeePass2Android + +Since file paths on Android differ from desktop paths, you need to tell KeePass2Android where to find the shared container file on your device. + +#### Opening the KeeShare Configuration + +1. **Open your database** in KeePass2Android + + Database Groups + +2. **Open the menu** by tapping the three dots (⋮) in the top right + + Overflow Menu + +3. **Tap "Settings"** + + Settings Main + +4. **Tap "Database"** to access database settings + + Database Settings + +5. **Tap "Configure KeeShare groups..."** + + KeeShare Groups + +#### Setting Device-Specific Paths + +If you have groups configured for KeeShare (from KeePassXC), they will appear in the list. For each group: + +1. **Tap "Configure path"** to set the Android-specific location of the shared container file + +2. **Navigate to the shared file** on your device: + - If using Dropbox: Look in `/storage/emulated/0/Dropbox/...` + - If using Google Drive: The file may be in `/storage/emulated/0/Android/data/com.google/...` + - If using a local folder synced via another method, navigate to that location + +3. **Select the shared container file** (`.kdbx` file) + +4. The path will be saved and sync will work on this device + +#### Example Path Mappings + +| Platform | Example Path | +|----------|--------------| +| Windows (KeePassXC) | `C:\Users\Me\Dropbox\Shared\team-passwords.kdbx` | +| macOS (KeePassXC) | `/Users/Me/Dropbox/Shared/team-passwords.kdbx` | +| Android (KeePass2Android) | `/storage/emulated/0/Dropbox/Shared/team-passwords.kdbx` | + +All three paths point to the same Dropbox file, synced by the Dropbox app on each device. + +## Security Considerations + +### Password Protection + +- The shared container file (`.kdbx`) is encrypted with the password you specify +- Use a **strong, unique password** different from your main database password +- Share the password securely (in person, encrypted message, etc.) +- Consider this password equally sensitive as your main database password + +### Storage Location + +- Choose a storage location you trust (your own cloud account, private network share) +- Shared container files contain real passwords - treat them with the same security as your main database +- Ensure cloud storage accounts have strong authentication (2FA recommended) + +### Access Control + +- Use `Import` mode for users who should only read passwords, not modify them +- Use `Export` mode to share without receiving changes from others +- Use `Synchronize` only when two-way sync is truly needed + +### Encryption + +- Shared containers use the same strong encryption as regular KeePass databases +- AES-256 encryption by default +- Key derivation function (Argon2) protects against brute-force attacks + +## Troubleshooting + +### "No KeeShare groups found" in KeePass2Android + +This means no groups in your database have KeeShare configured. You need to: +1. Open the database in KeePassXC on your computer +2. Configure KeeShare on the groups you want to share +3. Save the database +4. Re-open it in KeePass2Android + +### "Wrong password" or "master key is invalid" errors + +This means the password configured for the KeeShare group doesn't match the password used to encrypt the shared container file. + +**To fix:** + +1. Go to **Settings** → **Database** → **Configure KeeShare groups...** +2. Find the KeeShare group showing the error +3. Check the password status indicator: + - Orange text "Password: not set" = you need to set a password + - Green text "Password: configured" = password may be wrong +4. Tap **Edit** to open the settings dialog +5. Enter the correct password (the one used when exporting from KeePassXC) +6. Tap **OK** to save +7. Tap **Sync now** to retry + +**Note:** The password for the shared container is separate from your main database password. Make sure you're entering the password that was set when the KeeShare was originally configured in KeePassXC. + +### Shared entries not appearing + +1. Verify the device-specific path is correct and points to the shared container file +2. Check the password matches exactly (case-sensitive) +3. Ensure the sharing mode is set correctly (Import or Synchronize to receive) +4. Tap "Sync now" in the KeeShare configuration screen + +### "File not found" errors + +1. Check if the shared container file exists at the specified path +2. On Android, verify the app has storage permissions +3. For cloud storage, ensure the sync app has finished syncing the file +4. Try reconfiguring the device-specific path + +### Sync conflicts + +When the same entry is modified on multiple devices: +- KeeShare merges changes based on modification time +- The newer change wins +- Older data is preserved in the entry's history + +### Permission issues on Android + +1. Go to Android Settings → Apps → KeePass2Android → Permissions +2. Ensure Storage permission is granted +3. For Android 11+, you may need to grant "All files access" for some storage locations + +### KeePassXC groups not showing in KeePass2Android + +1. Make sure you saved the database after configuring KeeShare in KeePassXC +2. Close and reopen the database in KeePass2Android +3. Check that the groups have `KeeShare.Active = true` in their CustomData + +## Testing the KeeShare Feature + +This section describes how to manually test the KeeShare functionality and run the automated E2E tests. + +### Test Files + +The repository includes test files in `e2e-tests/test-data/`: + +| File | Password | Description | +|------|----------|-------------| +| `keeshare-test-export.kdbx` | `share123` | A KeeShare export containing a test entry | +| `keeshare-test-main.kdbx` | `test123` | A main database for testing imports (contains "Sample Entry") | + +**Test export contents** (verified with `keepassxc-cli`): +- Entry (at the root group): `Test Service Account` + - Username: `testuser@example.com` + - URL: `https://test.example.com` + +### Manual Testing Steps + +#### Prerequisites + +1. An Android emulator or device with KeePass2Android installed +2. The test files copied to the device's Downloads folder + +#### Setup + +1. **Copy test files to the emulator:** + ```bash + adb push e2e-tests/test-data/keeshare-test-export.kdbx /sdcard/Download/ + adb push e2e-tests/test-data/keeshare-test-main.kdbx /sdcard/Download/ + ``` + +2. **Open KeePass2Android** and select the main test database: + - Tap "Open file" + - Navigate to Downloads + - Select `keeshare-test-main.kdbx` + - Enter password: `test123` + +#### Test: Add KeeShare Import + +1. Go to **Menu (⋮)** → **Settings** → **Database** → **Configure KeeShare groups...** +2. Tap the **+ button** (FAB) in the bottom right +3. Configure: + - **Group**: Select "Create new group" and name it "Imported Credentials" + - **Share Type**: Import (read-only) + - **File Path**: Tap "Browse..." and select `keeshare-test-export.kdbx` from Downloads + - **Password**: `share123` +4. Tap **OK** +5. Verify the new KeeShare group appears in the list with: + - Green "Password: configured" status +6. Tap **Sync now** +7. Navigate back to the database and verify the "Test Service Account" entry appears + +#### Test: Wrong Password Error Handling + +1. Go to **Configure KeeShare groups...** +2. Tap **Edit** on the KeeShare group +3. Change the password to something wrong (e.g., `wrongpassword`) +4. Tap **OK**, then **Sync now** +5. Verify you see a user-friendly error message about wrong password +6. Tap **Edit** again and fix the password to `share123` +7. Tap **Sync now** and verify it succeeds + +### Running Automated E2E Tests + +The project uses [Maestro](https://maestro.mobile.dev/) for E2E testing. + +#### Prerequisites + +1. Install Maestro: + ```bash + curl -Ls "https://get.maestro.mobile.dev" | bash + ``` + +2. Start an Android emulator or connect a device + +3. Install the app: + ```bash + adb install -r src/keepass2android-app/bin/Release/net9.0-android/keepass2android.keepass2android_nonet-Signed.apk + ``` + +#### Running the KeeShare Tests + +```bash +# Run all KeeShare tests +maestro test e2e-tests/.maestro/keeshare_import_flow.yaml + +# Run with verbose output +maestro test --debug-output e2e-tests/.maestro/keeshare_import_flow.yaml +``` + +#### Test Scenarios Covered + +| Test File | Description | +|-----------|-------------| +| `keeshare_import_flow.yaml` | Complete flow: add import, set password, sync, verify entry | +| `keeshare_edit_password.yaml` | Edit existing KeeShare config to update password | +| `keeshare_wrong_password.yaml` | Verify error handling for wrong password | + +### Creating Your Own Test Export + +To create a test KeeShare export file in KeePassXC: + +1. Open or create a database in KeePassXC +2. Create a group for sharing (e.g., "Test Share") +3. Add test entries to the group +4. Right-click the group → **Edit Group** → **KeeShare** tab +5. Select **Export** mode +6. Set the export path and password +7. Save the database + +The export file will be created at the specified path and can be used for testing imports in KeePass2Android. + +## References + +- [KeePassXC KeeShare Documentation](https://github.com/keepassxreboot/keepassxc/blob/develop/docs/topics/KeeShare.adoc) +- [KeePassXC User Guide](https://keepassxc.org/docs/KeePassXC_UserGuide) +- [KeePass2Android Wiki](https://github.com/PhilippC/keepass2android/wiki) diff --git a/docs/capture_emulator.sh b/docs/capture_emulator.sh new file mode 100755 index 000000000..2dac6d60f --- /dev/null +++ b/docs/capture_emulator.sh @@ -0,0 +1,26 @@ +#!/bin/bash +# Capture Android emulator window screenshot on macOS +# Usage: ./capture_emulator.sh output_filename.png + +OUTPUT_FILE="${1:-screenshot.png}" +OUTPUT_DIR="$(dirname "$0")/images" + +mkdir -p "$OUTPUT_DIR" + +WINDOW_ID=$(python3 -c " +import Quartz.CoreGraphics as CG +window_list = CG.CGWindowListCopyWindowInfo(CG.kCGWindowListOptionOnScreenOnly, CG.kCGNullWindowID) +for window in window_list: + owner = window.get('kCGWindowOwnerName', '') + if 'qemu' in owner.lower(): + print(window.get('kCGWindowNumber', '')) + break +") + +if [ -z "$WINDOW_ID" ]; then + echo "Error: Could not find emulator window" + exit 1 +fi + +screencapture -l"$WINDOW_ID" "$OUTPUT_DIR/$OUTPUT_FILE" +echo "Captured: $OUTPUT_DIR/$OUTPUT_FILE" diff --git a/docs/images/02_database_groups.png b/docs/images/02_database_groups.png new file mode 100644 index 000000000..0d8ee5639 Binary files /dev/null and b/docs/images/02_database_groups.png differ diff --git a/docs/images/03_overflow_menu.png b/docs/images/03_overflow_menu.png new file mode 100644 index 000000000..106405e94 Binary files /dev/null and b/docs/images/03_overflow_menu.png differ diff --git a/docs/images/04_settings_main.png b/docs/images/04_settings_main.png new file mode 100644 index 000000000..f1637ff63 Binary files /dev/null and b/docs/images/04_settings_main.png differ diff --git a/docs/images/05_database_settings.png b/docs/images/05_database_settings.png new file mode 100644 index 000000000..d5546d901 Binary files /dev/null and b/docs/images/05_database_settings.png differ diff --git a/docs/images/06_keeshare_groups.png b/docs/images/06_keeshare_groups.png new file mode 100644 index 000000000..af6d896cc Binary files /dev/null and b/docs/images/06_keeshare_groups.png differ diff --git a/docs/images/keeshare_add_dialog.png b/docs/images/keeshare_add_dialog.png new file mode 100644 index 000000000..4f8c8d95c Binary files /dev/null and b/docs/images/keeshare_add_dialog.png differ diff --git a/docs/images/keeshare_with_fab.png b/docs/images/keeshare_with_fab.png new file mode 100644 index 000000000..af6d896cc Binary files /dev/null and b/docs/images/keeshare_with_fab.png differ diff --git a/docs/superpowers/plans/2026-06-28-keeshare-implementation-plan.md b/docs/superpowers/plans/2026-06-28-keeshare-implementation-plan.md new file mode 100644 index 000000000..b5de2d1a8 --- /dev/null +++ b/docs/superpowers/plans/2026-06-28-keeshare-implementation-plan.md @@ -0,0 +1,74 @@ +# KeeShare — Implementation Plan (executable) + +**Date:** 2026-06-28 +**Inputs:** `specs/2026-06-28-keeshare-testing-and-lindrew-rollout-design.md` + `reviews/2026-06-28-keeshare-findings.md` +**State at write time:** build works, app runs on emulator, 141 unit tests pass (but test real copies, not prod), findings catalogued. Finding IDs (H-Sync, H-Lock, …) reference the findings report. + +This plan is ordered so each step has a concrete verification gate. Use TDD for every production-behavior change (write/adjust a failing test first, then fix). Prefer the `feature-dev` / `superpowers:test-driven-development` flow. + +--- + +## Phase A — Rebase & de-noise (do first; unblocks everything) + +- [ ] **A1. Rebase `keeshare-support` onto `main`.** Branch is 113 behind. Most of the 187-file diff is phantom (PR template, `pref_app_display.xml`, `AddTemplateEntries.cs`, manifests — all 0 branch-commits). + - Steps: `git fetch origin`; create a safety branch (`git branch keeshare-support-prerebase`); `git rebase origin/main`. Resolve conflicts in the **real** KeeShare-touched files only (`KeeShare.cs`, the two activities + layouts, `SaveDB.cs`, `LoadDB.cs`, `SyncUtil.cs`, `App.cs`, `GroupBaseActivity.cs`, `FileSelectActivity.cs`, tests, docs, maestro). + - **Gate:** `git diff --stat main..keeshare-support` shows ~30 files (not 187); `dotnet build` (recipe below) still produces the APK; 141 unit tests still pass. + +## Phase B — Safe quick wins (low risk, ship with build verification) + +Some already done this session: `X509Certificate2` `using` leak fix (H-Cert) and `KeeShare.md` fixture-password/structure correction. Remaining: + +- [ ] **B1. Tighten vacuous Maestro assertions** (`keeshare_full_test.yaml` `visible: ".*"` + `".*KeeShare.*"`; `keeshare_wrong_password.yaml` `.*Edit.*` branch; `keeshare_import_flow.yaml` `.*KeeShare.*` branch). Replace each with an assertion on a *specific* outcome string. **Verify by running each flow on the emulator** (don't ship blind — that's why it wasn't done autonomously). +- [ ] **B2. Localise hardcoded English** (Q1/Q2): `EditKeeShareActivity.cs:51` "Invalid group identifier"; default group name "KeeShare Import" at `ConfigureKeeShareActivity.cs:519/589/720`. Add `@string` resources. +- [ ] **B3. Remove premature no-op `Update()`** at `ConfigureKeeShareActivity.cs:738` (U2). +- [ ] **B4. Color resources for password-status** (`#CC6600`/`#008800`) → dark-mode-safe `@color`/theme attrs (Q3). +- **Gate:** build succeeds; affected Maestro flows pass on emulator. + +## Phase C — Lindrew blockers (TDD; do before trusting real secrets) + +Each needs the test layer from Phase D to verify properly; build C and D together (write the failing test in D, fix in C). + +- [ ] **C1. H-RootGroup** — exclude root group from `CollectGroups` (`ConfigureKeeShareActivity.cs:470`); add a confirm dialog if a near-root/large group is chosen. *Test:* UI/integration test that root is not offered. +- [ ] **C2. H-Thread** — wrap per-group error `ShowMessage` at `KeeShare.cs:805` and `:715` in `_app.UiThreadHandler.Post(...)`. *Test:* a wrong-password sync does not crash (emulator E2E with a real wrong password). +- [ ] **C3. H-Lock** — wrap `SyncGroups` mutation (`ClearGroupContents`/`MergeIn`/`UpdateGlobals`/`MarkAllGroupsAsDirty`) and the export op in a `BackgroundDatabaseModificationLocker` (mirror `SynchronizeCachedDatabase`). *Test:* integration test that a sync while a read-lock is held doesn't corrupt lookup dicts. +- [ ] **C4. H-Sync** — make Synchronize actually export during background sync: after `Import(...)` for `type=="Synchronize"` in `ProcessKeeShare`, also run the export leg (guard against write-amplification; respect C3 locking). *Test (the important one):* two-database round-trip — A edits → sync → B sees it **and** B edits → sync → A sees it, with no explicit save. This is the Phase D two-emulator E2E. +- [ ] **C5. H-DeadCheck** — remove the dead `Contains("master key")` branch (`ConfigureKeeShareActivity.cs:229`); rely on the per-group message (now UI-dispatched). *Test:* wrong-password shows exactly one error. +- **Gate:** all C-tests green; manual two-emulator Synchronize round-trip works. + +## Phase D — Test pyramid (the strategic fix) + +- [ ] **D1. Extract `KeeShare.Core`** (`netstandard2.x`) from `KeeShare.cs`: constants, config (`EnableKeeShare`/`DisableKeeShare`/`UpdateKeeShareConfig`), KeePassXC compat, `VerifySignatureCore`, state predicates, and the merge orchestration (`SyncGroups`) behind an interface. Inject Android/IO/device deps as delegates (e.g. `Func` for `IOConnectionInfo` parsing). Reference from **both** the app and `KeeShare.Tests`. + - **Gate:** app builds; the existing 141 tests now run against `KeeShare.Core` (delete the `TestHelpers/*Logic.cs` copies). Fixes the already-drifted helpers (HasKeeShareGroups side-effect, VerifySignatureCore Base64-first, GetEffectiveFilePath algorithm). +- [ ] **D2. Real-logic unit + integration tests**: drive `SyncGroups`/Export against real `PwDatabase` (KeePassLib is netstandard) — CustomData survival after merge, Import destructive pre-clear, Synchronize additive merge, the `UpdateGlobals` crash-fix path, Export entry-clone correctness. +- [ ] **D3. Two-emulator deterministic E2E** (spec Q2=A, Option-1 orchestrator under `e2e-tests/`): boot 2 AVDs (a 2nd is creatable from the installed `android-35` images), install build on each, run per-emulator Maestro subflows by serial, sync the shared container between them via `adb pull`/`adb push`, record both screens. Scenarios: Import, Export, **Synchronize** (round-trip), edit-conflict (newer-wins + history). +- [ ] **D4. Cross-compat suite** (spec Q3=C, **revised**): `keepassxc-cli` has **no `keeshare` command** and can't make *signed* containers. So: (a) **unsigned round-trip** via `db-create`/`add`/`merge`/`export` — KP2A imports a CLI-authored container; CLI reads back a KP2A export; (b) **signed-container** compat via **fixtures captured once from a real KeePassXC GUI export** (commit them), imported by an automated KP2A test, + manual check in Phase F. +- **Gate:** all four layers green at least once. + +## Phase E — Reliability hardening + +- [ ] **E1.** Run D3 + D4 **10× back-to-back**; isolate AVD state per run; replace sleeps with explicit waits; make container-sync steps idempotent/ordered. +- [ ] **E2.** Capture clean-run **dual-emulator video** as the evidence artifact. +- **Gate:** 10/10 green + recorded video. + +## Phase F — Real-device dogfooding ("Lindrew") — manual, needs Andrew + Lindsay + +- [ ] F1. Build signed APK; install on both phones. +- [ ] F2. Create a **Dropbox shared folder** between the two accounts for the Lindrew container (the two-separate-accounts solution). +- [ ] F3. **Back up both personal databases first.** Add a Synchronize "Lindrew" group on each (Mac KeePassXC + Android), pointing at the shared container; seed a couple of throwaway secrets. +- [ ] F4. Live with it for several days; verify edits propagate both directions across all four endpoints; watch conflicts. +- **Gate:** reliable in real life over a multi-day window. + +## Phase G — Upstream prep (do NOT start until F passes; per Q1=C) + +- [ ] De-WIP; complete the PR checklist; fold the findings report + test pyramid + video into the PR description; wire CI for the CLI-able suites (unit + integration + cross-compat); leave the two-emulator E2E as a local/recorded gate. + +--- + +## Build recipe (verified) +```bash +export ANDROID_HOME=$HOME/Library/Android/sdk ANDROID_SDK_ROOT=$HOME/Library/Android/sdk +export PATH=/usr/local/share/dotnet:$PATH # .NET 10.0.102 SDK w/ android workload +dotnet build src/keepass2android-app/keepass2android-app.csproj -c Release -f net9.0-android -p:Flavor=NoNet +adb install -r src/keepass2android-app/bin/Release/net9.0-android/keepass2android.keepass2android_nonet-Signed.apk +``` +Emulator: `Pixel_8_API_35_Root` (a 2nd AVD for D3 is creatable from `system-images/android-35/...`). Unit tests: `dotnet test src/KeeShare.Tests` (net8, uses the homebrew dotnet 8 fine). diff --git a/docs/superpowers/reviews/2026-06-28-keeshare-findings.md b/docs/superpowers/reviews/2026-06-28-keeshare-findings.md new file mode 100644 index 000000000..4e57bef14 --- /dev/null +++ b/docs/superpowers/reviews/2026-06-28-keeshare-findings.md @@ -0,0 +1,129 @@ +# KeeShare (PR #3165) — Quality & Test-Quality Findings Report + +**Date:** 2026-06-28 +**Branch:** `keeshare-support` (46 commits ahead / **113 behind** `main`) +**Reviewer:** autonomous session (4 parallel code-review agents + direct verification) +**Companion spec:** `docs/superpowers/specs/2026-06-28-keeshare-testing-and-lindrew-rollout-design.md` + +## How this was produced + +- **Build verified:** the app builds. `/usr/local/share/dotnet` (.NET **10.0.102** SDK, `android` workload) builds `net9.0-android` with `ANDROID_HOME=~/Library/Android/sdk`. Produces the 33 MB signed APK. (The spec's "blocked on dotnet 8" assumption is **resolved** — see Build Recipe below.) +- **App runs:** installed on emulator `Pixel_8_API_35_Root`, launches clean (`KP2A: Creating application … Version=244`, no crash). +- **Unit tests:** `dotnet test src/KeeShare.Tests` → **141 passed, 0 failed** (928 ms). But see the test-quality verdict — green here means little. +- **Reviews:** four focused agents over (1) core `KeeShare.cs`, (2) UI activities, (3) the test suite, (4) collateral/core-file changes. Cross-checked against `git log` to separate real edits from staleness. +- **Cross-impl probe:** `keepassxc-cli 2.7.11` has **no `keeshare` command** — KeeShare config and *signed*-container export are GUI-only. Affects the Phase 3 cross-compat plan (below). + +## Overall verdict + +The headline feature (in-app Add/Edit/Sync KeeShare, Import mode) **builds, runs, and demos**. But for a feature meant to sync real secrets between two people, there are **real correctness, concurrency, and security defects on the exact paths the "Lindrew" use case depends on (Synchronize + background sync)**, and the **test suite does not actually exercise the production code** — so its green status is not trustworthy. None of this is fatal; it's a clear, fixable worklist. **Do not enable Synchronize on real databases until H-Sync / H-Lock / H-Thread below are fixed and covered by tests.** + +--- + +## CRITICAL / HIGH findings + +### H-Sync — "Synchronize" never exports during background sync (the Lindrew path is half-broken) +**`KeeShare.cs` ~832–836 (`ProcessKeeShare`); confidence 90.** `KeeShareCheckOperation` only *imports* for both Import and Synchronize. The export leg is wired only to `SaveDb.OnSaveCompleteKeeShareExport`, which fires on **explicit save**, not during `SyncInBackground`. Result: after a background sync, the in-memory DB absorbs peers' changes but **your own changes are not written back to the shared container** until a manual save. For Andrew↔Lindsay, this means edits silently fail to propagate until someone explicitly saves — the opposite of what "Synchronize" implies. **This is the most important functional bug for the mission.** Fix needs care (it interacts with H-Lock and with write-amplification/conflict concerns) → test-driven. + +### H-Lock — merge mutates the database without the background-modification write lock +**`KeeShare.cs` ~1087–1115 (`SyncGroups`), also the export op; confidence 90.** Every other background mutator (`SynchronizeCachedDatabase`, `SaveDb`) wraps changes in `BackgroundDatabaseModificationLocker` (`EnterWriteLock`). `SyncGroups` calls `ClearGroupContents`, `MergeIn`, `UpdateGlobals`, `MarkAllGroupsAsDirty` — all mutating non-thread-safe collections (`GroupsById`, `EntriesById`, `Elements`, `DirtyGroups`) — **with no write lock**. A UI activity holding a read lock (e.g. `ShareUrlResults`, `EntryEditActivity`) during a background sync can hit concurrent structural modification → crash or corrupted lookup state. Fix: take a `BackgroundDatabaseModificationLocker` around the mutation (same pattern as `SynchronizeCachedDatabase`). + +### H-Thread — per-group sync error crashes on the common failure path +**`KeeShare.cs` 805 & 715; confidence 87.** Per-group error messages call `_app.ShowMessage(...)` **directly from the `OperationRunner` background thread**. With a `GroupBaseActivity` visible, `ChainedSnackbarPresenter` can call `snackbar.Show()` on that thread → `CalledFromWrongThreadException`. This is the *error* path (wrong password, file not found) — the most-hit path in real use. Fix: wrap both in `_app.UiThreadHandler.Post(...)` (the completion message in `SyncUtil` already does this; these two were missed). + +### H-RootGroup — the entire vault can be shared with no guard +**`ConfigureKeeShareActivity.cs` ~470 (`CollectGroups`); confidence 85.** The add-dialog group spinner includes the **root group**. Selecting it enables KeeShare on root → *every* entry in the database is in the share boundary, exported to the external container. No warning, no confirmation. The user-facing docs even warn against this, but the UI offers it freely. Fix: exclude root from `CollectGroups` (and/or hard-confirm). + +### H-Cert — `X509Certificate2` leaked per signature verification +**`KeeShare.cs` 1233; confidence 95.** `X509Certificate2.CreateFromPem(...)` is never disposed (holds native handles on Android). Wrap in `using`. Easy, safe fix. + +### H-DeadCheck — wrong-password handling is dead code → double error toast +**`ConfigureKeeShareActivity.cs` 229–234; confidence 90.** The callback receives the generic `"KeeShare sync completed with errors"`, so the `Contains("master key")` branch never matches; the friendly per-group message is already shown by `ProcessGroup`, so the user gets **two** errors and the `keeshare_wrong_password` string is unreachable. Fix: drop the dead branch; rely on the per-group message (dispatched per H-Thread). + +--- + +## MEDIUM findings + +| ID | File:loc | Issue | Conf | +|----|----------|-------|------| +| M-SignedAsym | `KeeShare.cs` 930–934 | If a `.sig` is present but no trusted cert is configured, the container is imported **without verification** (silent "backward compatibility"). Inverse case correctly rejects. An attacker who can write the container can swap the payload. At minimum surface a user-visible warning; ideally require explicit opt-in. | 82 | +| M-SideEffect | `KeeShare.cs` 467–481 (`HasKeeShareGroups`) | The "is there KeeShare here?" check **mutates**: on KeePassXC-format detection it runs `TryImportKeePassXCConfig` → `EnableKeeShare` → `group.Touch()`. Called from `App.cs` load + `SyncUtil` sync as if read-only. Marks the DB dirty with no user action → can trigger spurious uploads / conflict warnings. Split the side-effecting import out of the predicate. | 80 | +| M-EditCtx | `EditKeeShareActivity.cs` 154 | Uses `ActionOnOperationFinished` instead of `ActionInContextInstanceOnOperationFinished`; a screen rotation during save fires the callback on a destroyed activity → `BadTokenException`. Match `ConfigureKeeShareActivity`'s pattern. | 80 | +| M-PathConflict | `EditKeeShareActivity.cs` 141,149 | Writes the **global** `FilePathKey`, while `ConfigureKeeShareActivity` writes the **device-specific** key (which `GetEffectiveFilePath` prefers). Editing the path here is silently ignored on the current device and leaks as the cross-device "original path". Unify on `SetDeviceFilePath`. | 80 | +| U-ContentUri | `ConfigureKeeShareActivity.cs` GetView ~134 | SAF selection shows the raw `content://…` URI as the device path instead of a human filename (`AndroidContentStorage.GetDisplayName` exists but isn't used). | 95 | +| M-SaveDefer | `SaveDB.cs` `TriggerKeeShareExportThenFinish` | `Finish(true)` is deferred behind KeeShare export and **hardcoded to success** — a bug in the export op hangs *every* save; export failure is silent; the catch-block message is unlocalised English. Fast-path guard protects non-KeeShare DBs. Document/flag the silent-failure; localise the string. | 85 | +| M-StaticDelegates | `SaveDB.cs`, `LoadDB.cs` | KeeShare hooks are `public static Action<…>` on core business classes (`OnSaveCompleteKeeShareExport`, `OnLoadCompleteKeeShareCheck`). Global mutable state; breaks test isolation for all `SaveDb`/`LoadDb` tests. Prefer an event/observer or DI. | 85 | +| M-OtpGated | `SyncUtil.cs` 119–137 | `SyncOtpAuxFileIfNeeded` now only runs inside the KeeShare callback branch; if KeeShare's callback doesn't fire, OTP aux sync silently stops. Plus `HasKeeShareGroups`'s side effect (M-SideEffect) runs on **every** sync for all users. | 80 | + +## LOW findings (code quality / polish) + +- Hardcoded English strings instead of `@string`: `EditKeeShareActivity.cs:51` ("Invalid group identifier"), default group name "KeeShare Import" at `ConfigureKeeShareActivity.cs:519/589/720`. +- Hardcoded hex colors (`#CC6600`/`#008800`) in `GetView` → break dark-mode contrast, parsed on every bind. Use color resources / theme attrs. +- `keeshare_config_row.xml` 78–111: four action buttons in one un-weighted row → clip on ≤360 dp screens. +- `dialog_add_keeshare.xml:132` / `dialog_edit_keeshare.xml:68`: `xmlns:app` declared on an inner view, not the root. +- `ConfigureKeeShareActivity.cs:738`: premature `Update()` (no-op; real refresh happens in the `SaveDatabase` callback). Remove. +- Debug `Log.Debug("KP2A", …)` lines in `GroupBaseActivity.cs` / `FileSelectActivity.cs` — confirm intentional before upstreaming. + +--- + +## Test-quality verdict (this is the big one) + +**The 141 passing unit tests do not test production code — they test hand-maintained *copies* of the logic in `src/KeeShare.Tests/TestHelpers/*Logic.cs`, and the copies have already silently diverged from `KeeShare.cs`:** + +- `HasKeeShareGroups` helper is **missing the KeePassXC auto-import side-effect** that production has. +- `VerifySignatureCore` helper is **hex-only**; production tries **Base64 first**, then hex. The "whitespace" test asserts the *helper's* behavior, not production's. +- `GetEffectiveFilePath` helper uses a **string-heuristic**; production uses `IOConnectionInfo.UnserializeFromString`. They diverge on real inputs. +- `TrustedCertificateKey` is a named const in the helper but an inline literal in production. + +**And the most dangerous code has zero coverage:** `SyncGroups` (the CustomData-preserving, UUID-aliased merge — where data loss would live) and the entire **Export** path are untested at the unit level. The Maestro suite never configures Export or Synchronize. + +**Maestro assertions are partly vacuous** (always-true), so the E2E "passes" can be hollow: +- `keeshare_full_test.yaml`: `extendedWaitUntil: visible: ".*"` then `assertVisible: ".*KeeShare.*"` — both trivially true; it's a navigation smoke test, not a behavior test. +- `keeshare_wrong_password.yaml` / `import_flow.yaml`: `".*[Ww]rong password.*|.*Edit.*"` and `".*sync complete.*|.*KeeShare.*"` — the `.*Edit.*` / `.*KeeShare.*` branches match the config screen regardless of outcome. +- E2E fixtures are **mutable and accumulate state** across runs (README documents manual DB-recreation as the "fix") → a real flakiness source. + +**Remedy (this is the spec's Phase 2/3 core):** extract the pure logic from `KeeShare.cs` into a platform-neutral `KeeShare.Core` library (`netstandard2.x`), referenced by **both** the app and the tests, injecting IO/device dependencies as delegates. Then: (a) the 141 tests exercise real code; (b) add `SyncGroups`/Export tests using real `PwDatabase` (KeePassLib is netstandard-compatible); (c) tighten the Maestro assertions to check specific outcome strings and use per-test `clearState`/fresh fixtures. + +--- + +## Branch hygiene: rebase first + +The branch is **113 commits behind main**. Most of the 187-file, ~19k-line diff is **phantom** — files `main` changed while the branch was parked (verified: 0 branch commits on `.github/PULL_REQUEST_TEMPLATE.md`, `pref_app_display.xml`, `AddTemplateEntries.cs`, `kp2akeytransform/AndroidManifest.xml`, etc.; those show as diffs only because the branch predates main's edits). **A rebase onto `main` collapses the diff to the true ~30-file KeeShare change set** and is the right first move before any upstreaming. Real KeeShare-touched core files (have branch commits): `KeeShare.cs`, the two activities + layouts, `SaveDB.cs`(4), `LoadDB.cs`(6), `SyncUtil.cs`(4), `App.cs`(2), `GroupBaseActivity.cs`(2), `FileSelectActivity.cs`(1), plus the tests/docs/maestro. + +> Note: the collateral review initially flagged "PR template deleted — revert!" — that was a false alarm from reviewing without git history. There is nothing to revert; rebase resolves it. + +--- + +## Cross-impl reality check (revises Phase 3 plan) + +`keepassxc-cli` cannot configure KeeShare or produce a **signed** container (GUI-only). It *can* `db-create`/`add`/`merge`/`export`, so the cross-compat suite can still test the **unsigned container round-trip** (KP2A imports a CLI-authored container; CLI reads back a KP2A-exported one). **Signed-container** compatibility needs **fixtures captured once from a real KeePassXC GUI export** (committed to the repo), imported by an automated KP2A test, plus a manual check in Phase 5. + +--- + +## Prioritised remediation roadmap + +**Do before trusting real secrets (Lindrew blockers):** +1. H-Sync (Synchronize export leg) — test-driven; the mission depends on it. +2. H-Lock (write lock around merge) — concurrency/corruption. +3. H-Thread (UI-thread dispatch for errors) — crash on common path. +4. H-RootGroup (exclude root / confirm) — prevents catastrophic over-share. + +**Quick, safe wins (low risk; some shipped this session — see below):** +5. H-Cert `using`; H-DeadCheck dead branch; U2 premature `Update()`; localise hardcoded strings; tighten vacuous Maestro assertions. + +**Strategic (Phase 2/3):** +6. Extract `KeeShare.Core`; retarget unit tests at real code; add `SyncGroups`/Export tests; build the two-emulator E2E + cross-compat suites; de-flake. + +**Hygiene:** rebase onto `main`; then de-WIP for upstream (Phase 6). + +**Triage note:** Per Andrew's Q4=A decision, the substantive production-bug fixes (1–3, 6) are left for triaged, test-driven work rather than changed blind. This session ships only the safe, high-confidence quick wins and documents the rest here. + +## Build recipe (verified working) + +```bash +export ANDROID_HOME=$HOME/Library/Android/sdk ANDROID_SDK_ROOT=$HOME/Library/Android/sdk +export PATH=/usr/local/share/dotnet:$PATH # .NET 10.0.102 SDK w/ android workload +dotnet build src/keepass2android-app/keepass2android-app.csproj \ + -c Release -f net9.0-android -p:Flavor=NoNet +# -> src/keepass2android-app/bin/Release/net9.0-android/keepass2android.keepass2android_nonet-Signed.apk +adb install -r # package: keepass2android.keepass2android_nonet +``` diff --git a/docs/superpowers/specs/2026-06-28-keeshare-testing-and-lindrew-rollout-design.md b/docs/superpowers/specs/2026-06-28-keeshare-testing-and-lindrew-rollout-design.md new file mode 100644 index 000000000..e8f4b4dca --- /dev/null +++ b/docs/superpowers/specs/2026-06-28-keeshare-testing-and-lindrew-rollout-design.md @@ -0,0 +1,135 @@ +# KeeShare: Test Hardening + "Lindrew" Real‑World Rollout — Design + +**Date:** 2026-06-28 +**Branch:** `keeshare-support` (PR [#3165](https://github.com/PhilippC/keepass2android/pull/3165), `beveradb` fork → `PhilippC:main`, currently `[WIP]`) +**Author:** Andrew Beveridge + +> **Update 2026-06-28 (autonomous session).** Phase 0 is **done**: the app builds and runs (the "blocked on dotnet 8" assumption was wrong — the `/usr/local/share/dotnet` .NET 10 SDK with the `android` workload builds `net9.0-android`; see the build recipe in the findings report). Phase 1 findings are in **`../reviews/2026-06-28-keeshare-findings.md`**, and the executable breakdown is in **`../plans/2026-06-28-keeshare-implementation-plan.md`**. Two revisions to this spec emerged: (a) `keepassxc-cli` has no `keeshare` command, so the cross-compat suite tests the *unsigned* round-trip via CLI and uses *captured GUI fixtures* for signed containers; (b) the branch is 113 commits behind `main`, so a **rebase comes first** (most of the 187-file diff is phantom). + +## Goal + +Pick the parked KeeShare work back up and get it to a state where: + +1. The existing work has been reviewed for code quality and **test** quality. +2. The feature is covered by **meaningful, non‑flaky automated tests at every layer**, including a true multi‑party end‑to‑end test across **two independent emulators** with a deterministic stand‑in for cloud file sync. +3. There is **video evidence** of the E2E tests running green. +4. The build is installed on **Andrew's and Lindsay's real phones** and used to share real secrets via a shared **"Lindrew"** folder between their two separate KeePass databases. +5. Only *then* is the PR de‑WIP'd and offered upstream — backed by the testing + dogfooding evidence. + +Sequencing is deliberate (decision Q1=C): **prove it for real on our own devices first; that evidence becomes the upstream case.** We do not want to waste the maintainer's time with something untested, and we genuinely want to use this feature daily, so it has to be good. + +## Non‑Goals + +- Re‑architecting KeePass2Android beyond what serves KeeShare reliability. +- Real cloud (Dropbox) sync *inside the automated test loop* — explicitly avoided to keep automated tests deterministic. Real Dropbox is exercised only in the manual dogfooding phase (Phase 5). +- Broad refactoring unrelated to KeeShare. + +## Key Decisions (from brainstorming) + +| # | Decision | Choice | +|---|----------|--------| +| Q1 | Primary goal | **C** — dogfood for real first, then upstream; real‑world proof *is* the test and the PR case. | +| Q2 | Automated multi‑party E2E sync layer | **A** — two deterministic emulators; the "shared folder" is a harness‑controlled file location synced via `adb` at defined moments; real Android↔Android KeeShare logic, zero network/auth flakiness. | +| Q3 | Cross‑implementation (KeePassXC) coverage | **C** — two separate automated suites: Android↔Android sync **and** KP2A↔KeePassXC cross‑compat via `keepassxc-cli`. Each failure points at exactly one risk. | +| Q4 | How review findings are handled | **A** — written findings report first (code quality + test quality), triage together, then change. Nothing changes without sign‑off. | +| — | E2E orchestration tooling | **Option 1** — custom orchestrator script wrapping Maestro subflows + `adb` for the deterministic sync and dual‑screen recording. | + +## Current State (verified 2026-06-28) + +- **PR #3165:** OPEN, `[WIP]`, mergeable, CI checks 0/0. **45 commits** ahead of `main`. Last commit **2026‑01‑27** (~5 months parked). +- **Feature, per implementation notes, working on emulator:** in‑app "Add KeeShare" (FAB → dialog: group select/create, share type, password, SAF file browse), "Edit KeeShare" (password/type), friendly wrong‑password errors, and five documented bug fixes — notably the post‑sync `FindDatabaseForElement` crash fixed via `UpdateGlobals()` + `MarkAllGroupsAsDirty()` after `MergeIn`. +- **Tests present:** ~129 xUnit `[Fact]/[Theory]` methods across 6 classes in `src/KeeShare.Tests/` (docs claim 141 — drift to reconcile); Maestro E2E flows in `e2e-tests/.maestro/` (import flow is the main passing one) with `.kdbx` fixtures. +- **Docs:** `docs/KeeShare.md` (user guide), `docs/KeeShare-Implementation-Notes.md` (dev notes + "next steps"). + +### Known weaknesses to confront + +- **Test fidelity:** unit tests exercise **copied logic helpers** (`TestHelpers/KeeShare*Logic.cs`), *not* the real `KeeShare.cs`. Tests can stay green while production code breaks. The notes even instruct manually re‑syncing helpers when `KeeShare.cs` changes — a smell. +- **Coverage gaps (per notes):** only **Import** mode is well‑exercised; **Synchronize** (two‑way) and **Export** modes, and **signed containers**, are under‑tested. All prior testing was emulator‑only. +- **Doc inconsistency:** the two docs disagree on fixture passwords (`test123`/`share123` vs `TestMain123!`/`TestKeeShare123!`). +- **Build blocker:** app targets `net9.0-android`; the dev machine has **only dotnet 8.0.123**. Building the APK needs the **.NET 9 SDK + Android workload**. (`adb`, `~/.maestro/bin/maestro`, and `keepassxc-cli` are installed.) + +### Real‑world architecture wrinkle (Lindrew) + +Andrew and Lindsay have **separate Dropbox accounts**. KeeShare needs **one shared container file both can read *and* write**. The natural solution is a **Dropbox shared folder** (Dropbox mirrors a shared folder into both accounts). Both run **Mac KeePassXC + Android KeePass2Android**, each syncing their *own* personal database via *their own* Dropbox; only the Lindrew container lives in the shared folder. Each party adds a **Synchronize** KeeShare group ("Lindrew") to their personal database pointing at that shared container. + +## Phase Plan + +Each phase has an explicit gate; later phases assume earlier gates passed. + +### Phase 0 — Build bring‑up (prerequisite) +- Install .NET 9 SDK + `android` workload; build `keepass2android-app` (`net9.0-android`) Release APK. +- Boot an emulator, install, smoke‑test the existing import flow against the committed fixtures. +- **Gate:** APK builds and the existing import flow runs by hand on a local emulator. + +### Phase 1 — Quality + test‑quality review → findings report (Q4=A) +- Code‑review the 45‑commit diff (`main..keeshare-support`) and the test suite. Dispatch focused review subagents where the diff is large. +- Produce a **ranked findings report** (`docs/superpowers/reviews/2026-06-28-keeshare-findings.md`): correctness bugs, UX rough edges, code‑quality issues, and test‑quality gaps (copied‑helper fidelity, mode coverage, flakiness, weak assertions, fixture/doc drift). +- Triage together → a decided list of fix / defer / drop. +- **Gate:** triaged, signed‑off action list. + +### Phase 2 — Act on triaged findings +- Fix agreed bugs / UX / code‑quality items. +- **Likely central item:** extract the pure KeeShare sync/merge logic out of Android‑coupled `KeeShare.cs` into a **platform‑neutral library** referenced by *both* the app and the tests — so tests exercise real code and an integration layer becomes possible. (Confirmed/scoped during Phase 1 triage.) +- Reconcile fixture passwords + the 141‑vs‑129 test‑count drift across docs. +- **Gate:** findings list closed out; build + existing tests green. + +### Phase 3 — Build the test pyramid (all layers) +1. **Unit** (net8): realign onto the real extracted logic instead of copies. +2. **Integration** (headless, no emulator): drive the real merge/sync against real `.kdbx` files — fast, high‑fidelity. *Depends on the Phase 2 extraction; if not done, the emulator E2E remains the lowest test of real code.* +3. **E2E** (two‑emulator, deterministic — Q2=A): Android↔Android Synchronize across two isolated AVDs, harness‑synced container, dual‑screen video. Covers Import, Export, **Synchronize**, and conflict/merge. +4. **Cross‑compat** (Q3=C): KP2A ↔ `keepassxc-cli` round‑trip — KeePassXC authors a container → emulator imports/syncs → KP2A exports → `keepassxc-cli` reads it back. Includes **signed containers**. +- **Gate:** every layer green at least once. + +### Phase 4 — Reliability hardening (de‑flake) +- Run E2E + cross‑compat suites **N consecutive times** (target **10/10**); isolate AVD state per run; replace sleeps with explicit waits; make container‑sync steps idempotent and ordered. +- Capture **clean‑run video evidence** (dual‑emulator screen recordings) as the proof artifact. +- **Gate:** 10/10 green runs + recorded video. + +### Phase 5 — Real‑device dogfooding ("Lindrew") +- Build a **signed** APK; install on Andrew's and Lindsay's phones. +- Create a **Dropbox shared folder** between the two accounts for the Lindrew container. +- On each personal database (Mac KeePassXC + Android), add a **Synchronize** KeeShare group "Lindrew" → shared container; seed a couple of genuinely shared secrets. +- Live with it for several days; verify edits propagate both directions across all four endpoints; watch for conflicts. +- **Gate:** reliable in real life over a multi‑day window. + +### Phase 6 — Upstream prep +- De‑WIP; complete the PR checklist; fold the findings report, test pyramid, and video evidence into the PR description. +- Wire CI (GitHub Actions) for the **CLI‑able** suites (unit + integration + cross‑compat). The two‑emulator E2E stays a **local/manual gate with recorded evidence** (two emulators on hosted macOS CI is expensive and itself flaky). +- **Gate:** PR ready for maintainer review with evidence attached. + +## E2E Orchestrator Design (Option 1) + +A single top‑level orchestrator (shell or Python under `e2e-tests/`) that: + +1. Boots **two isolated AVDs** (separate AVD names / data dirs), records each serial. +2. Installs the build on both; loads a distinct personal database fixture into each. +3. Runs **per‑emulator Maestro subflows** addressed by `--device `, reusing existing `.maestro` flows where possible. +4. Implements the **deterministic "shared folder"**: after party A exports/syncs, the orchestrator `adb pull`s the container from A and `adb push`es it to B (and vice versa) — standing in for Dropbox with full ordering control. +5. Records **both screens** (`adb screenrecord` / `scrcpy --record`) for the video artifact. +6. Asserts via Maestro UI assertions **and** out‑of‑band file/entry checks (e.g. `keepassxc-cli` reads of pulled containers). + +Scenario coverage: one‑way Import, one‑way Export, **two‑way Synchronize**, and an **edit‑conflict** case (both sides edit the same entry between syncs → newer‑wins + history preserved). + +## Risks & Mitigations + +| Risk | Mitigation | +|------|------------| +| .NET 9 / Android workload setup friction | Phase 0 gate before anything depends on a local build. | +| Pure‑logic extraction is more invasive than hoped | Decide scope in Phase 1 triage; if too costly, fall back to emulator E2E as lowest real‑code test and document the gap. | +| Two‑emulator E2E flakiness (the thing we're trying to avoid) | Deterministic harness sync; isolated AVD state; explicit waits; Phase 4 N‑run gate. | +| KP2A↔KeePassXC format incompatibility surfacing only in real life | Dedicated cross‑compat suite (Q3=C) incl. signed containers, before dogfooding. | +| Two separate Dropbox accounts | Dropbox **shared folder** for the Lindrew container; validated in Phase 5. | +| Real‑data risk during dogfooding | Start with throwaway secrets; keep per‑database backups before enabling Synchronize on real DBs. | + +## Success Criteria + +- All four test layers green; E2E + cross‑compat **10/10** with recorded video. +- KeeShare verified across **Import, Export, Synchronize, and signed containers**, and across **KP2A↔KeePassXC**. +- "Lindrew" Synchronize group working reliably across Andrew's + Lindsay's four endpoints for several days. +- PR #3165 de‑WIP'd with findings + test + video evidence attached. + +## Open Questions (to resolve as we go) + +- Exact scope of the pure‑logic extraction (decided in Phase 1 triage). +- Orchestrator language: shell vs Python (decide at Phase 3 start; Python likely easier for sequencing + assertions). +- Whether to also stand the integration layer up against KeePassLib directly vs. the extracted lib only. diff --git a/e2e-tests/.maestro/1_launch_app.yaml b/e2e-tests/.maestro/1_launch_app.yaml new file mode 100644 index 000000000..7b3647ec3 --- /dev/null +++ b/e2e-tests/.maestro/1_launch_app.yaml @@ -0,0 +1,9 @@ +appId: keepass2android.keepass2android_nonet +--- +# Launch app and dismiss changelog dialog +- launchApp +- tapOn: + id: "android:id/button1" + optional: true +- assertVisible: + text: "Keepass2Android" diff --git a/e2e-tests/.maestro/2_create_database.yaml b/e2e-tests/.maestro/2_create_database.yaml new file mode 100644 index 000000000..d4c977874 --- /dev/null +++ b/e2e-tests/.maestro/2_create_database.yaml @@ -0,0 +1,41 @@ +appId: keepass2android.keepass2android_nonet +--- +# Test: Create a new database for KeeShare testing +- launchApp: + clearState: true + +# Dismiss changelog if present +- tapOn: + id: "android:id/button1" + optional: true + +# Create new database +- tapOn: "Create new database..." + +# Wait for create database screen +- assertVisible: "DATABASE LOCATION" + +# Enter master password +- tapOn: + text: "password" +- inputText: "testpassword123" + +# Confirm password +- tapOn: + text: "confirm password" +- inputText: "testpassword123" + +# Hide keyboard +- hideKeyboard + +# Create the database +- scrollUntilVisible: + element: "Create database" + direction: DOWN +- tapOn: "Create database" + +# Wait for database to be created and opened - look for the group screen +- extendedWaitUntil: + visible: + text: "keepass" + timeout: 15000 diff --git a/e2e-tests/.maestro/3_unlock_and_navigate.yaml b/e2e-tests/.maestro/3_unlock_and_navigate.yaml new file mode 100644 index 000000000..debda7939 --- /dev/null +++ b/e2e-tests/.maestro/3_unlock_and_navigate.yaml @@ -0,0 +1,21 @@ +appId: keepass2android.keepass2android_nonet +--- +# Test: Unlock database and navigate to group settings +- launchApp + +# Enter password - the database was created with "testpassword123" +- tapOn: + text: "Password" +- inputText: "testpassword123" + +# Hide keyboard and unlock +- hideKeyboard +- tapOn: "Unlock" + +# Wait for database to load - look for group list +- extendedWaitUntil: + visible: + text: ".*" + timeout: 10000 + +# The database should now be open showing groups diff --git a/e2e-tests/.maestro/4_keeshare_config.yaml b/e2e-tests/.maestro/4_keeshare_config.yaml new file mode 100644 index 000000000..3fc434807 --- /dev/null +++ b/e2e-tests/.maestro/4_keeshare_config.yaml @@ -0,0 +1,30 @@ +appId: keepass2android.keepass2android_nonet +--- +# Test: Configure KeeShare on a group +- launchApp + +# Unlock database if on password screen +- tapOn: + text: "Password" + optional: true +- runFlow: + when: + visible: "Unlock" + commands: + - inputText: "testpassword123" + - hideKeyboard + - tapOn: "Unlock" + - extendedWaitUntil: + visible: "eMail" + timeout: 10000 + +# Dismiss autofill prompt if present +- tapOn: + text: "Do not show again" + optional: true + +# Long press on Internet group to access group settings +- longPressOn: "Internet" + +# Check what menu appears +- assertVisible: ".*" diff --git a/e2e-tests/.maestro/5_explore_keeshare.yaml b/e2e-tests/.maestro/5_explore_keeshare.yaml new file mode 100644 index 000000000..9d8d97266 --- /dev/null +++ b/e2e-tests/.maestro/5_explore_keeshare.yaml @@ -0,0 +1,35 @@ +appId: keepass2android.keepass2android_nonet +--- +# Test: Explore how to access KeeShare settings +- launchApp + +# Unlock if needed +- tapOn: + text: "Password" + optional: true +- runFlow: + when: + visible: "Unlock" + commands: + - inputText: "testpassword123" + - hideKeyboard + - tapOn: "Unlock" + - extendedWaitUntil: + visible: "eMail" + timeout: 10000 + +# Dismiss any dialogs +- tapOn: + text: "I don't care" + optional: true +- tapOn: + text: "Do not show again" + optional: true + +# Tap on a group to open it +- tapOn: "Internet" + +# Wait for group contents to load +- extendedWaitUntil: + visible: ".*" + timeout: 5000 diff --git a/e2e-tests/.maestro/6_group_menu.yaml b/e2e-tests/.maestro/6_group_menu.yaml new file mode 100644 index 000000000..8aed421cc --- /dev/null +++ b/e2e-tests/.maestro/6_group_menu.yaml @@ -0,0 +1,45 @@ +appId: keepass2android.keepass2android_nonet +--- +# Test: Access group menu options +- launchApp + +# Unlock if needed +- tapOn: + text: "Password" + optional: true +- runFlow: + when: + visible: "Unlock" + commands: + - inputText: "testpassword123" + - hideKeyboard + - tapOn: "Unlock" + - extendedWaitUntil: + visible: "eMail" + timeout: 10000 + +# Dismiss any dialogs +- tapOn: + text: "I don't care" + optional: true +- tapOn: + text: "Do not show again" + optional: true + +# Tap on a group to open it +- tapOn: "Internet" + +# Wait for group to load +- extendedWaitUntil: + visible: + text: "Internet" + timeout: 5000 + +# Tap on More options menu (overflow menu icon) +- tapOn: + text: "More options" + +# Wait for menu to appear +- extendedWaitUntil: + visible: ".*" + timeout: 3000 diff --git a/e2e-tests/.maestro/7_find_keeshare.yaml b/e2e-tests/.maestro/7_find_keeshare.yaml new file mode 100644 index 000000000..43dd2967b --- /dev/null +++ b/e2e-tests/.maestro/7_find_keeshare.yaml @@ -0,0 +1,39 @@ +appId: keepass2android.keepass2android_nonet +--- +# Test: Find KeeShare in Settings +- launchApp + +# Unlock if needed +- tapOn: + text: "Password" + optional: true +- runFlow: + when: + visible: "Unlock" + commands: + - inputText: "testpassword123" + - hideKeyboard + - tapOn: "Unlock" + - extendedWaitUntil: + visible: "eMail" + timeout: 10000 + +# Dismiss any dialogs +- tapOn: + text: "I don't care" + optional: true +- tapOn: + text: "Do not show again" + optional: true + +# Open menu +- tapOn: + text: "More options" + +# Tap Settings +- tapOn: "Settings" + +# Wait for settings to load +- extendedWaitUntil: + visible: ".*" + timeout: 5000 diff --git a/e2e-tests/.maestro/8_database_settings.yaml b/e2e-tests/.maestro/8_database_settings.yaml new file mode 100644 index 000000000..7725bf630 --- /dev/null +++ b/e2e-tests/.maestro/8_database_settings.yaml @@ -0,0 +1,42 @@ +appId: keepass2android.keepass2android_nonet +--- +# Test: Check Database settings for KeeShare +- launchApp + +# Unlock if needed +- tapOn: + text: "Password" + optional: true +- runFlow: + when: + visible: "Unlock" + commands: + - inputText: "testpassword123" + - hideKeyboard + - tapOn: "Unlock" + - extendedWaitUntil: + visible: "eMail" + timeout: 10000 + +# Dismiss any dialogs +- tapOn: + text: "I don't care" + optional: true +- tapOn: + text: "Do not show again" + optional: true + +# Open menu +- tapOn: + text: "More options" + +# Tap Settings +- tapOn: "Settings" + +# Tap Database settings +- tapOn: "Database" + +# Wait for database settings +- extendedWaitUntil: + visible: ".*" + timeout: 5000 diff --git a/e2e-tests/.maestro/keeshare_edit_password.yaml b/e2e-tests/.maestro/keeshare_edit_password.yaml new file mode 100644 index 000000000..250696f12 --- /dev/null +++ b/e2e-tests/.maestro/keeshare_edit_password.yaml @@ -0,0 +1,112 @@ +appId: keepass2android.keepass2android_nonet +--- +# Test: KeeShare Edit Password Functionality +# Tests the ability to edit an existing KeeShare configuration to update the password. +# +# Prerequisites: +# - Database already open with a KeeShare group configured +# - keeshare-test-export.kdbx password: share123 +# +# This is a utility flow - typically run after keeshare_import_flow.yaml +# Run with: maestro test e2e-tests/.maestro/keeshare_edit_password.yaml + +- launchApp + +# Wait for app - should show database or unlock screen +- extendedWaitUntil: + visible: ".*Unlock.*|.*Root.*|.*Sample.*" + timeout: 10000 + +# If locked, unlock with test password +- runFlow: + when: + visible: "Unlock" + commands: + - tapOn: + text: "Password" + optional: true + - inputText: "test123" + - hideKeyboard + - tapOn: "Unlock" + - extendedWaitUntil: + visible: ".*Root.*|.*Sample.*" + timeout: 15000 + +# Dismiss dialogs +- tapOn: + text: "OK" + optional: true + +# Navigate to KeeShare settings +- tapOn: + id: ".*overflow.*|.*more.*" + optional: true +- tapOn: + text: "More options" + optional: true +- tapOn: "Settings" + +- extendedWaitUntil: + visible: "Database" + timeout: 5000 +- tapOn: "Database" + +- scrollUntilVisible: + element: "Configure KeeShare groups" + direction: DOWN + timeout: 10000 +- tapOn: + text: "Configure KeeShare groups" + +# Wait for KeeShare screen +- extendedWaitUntil: + visible: ".*KeeShare.*|.*Edit.*" + timeout: 5000 + +# Verify Edit button is visible +- assertVisible: + text: "Edit" + +# Tap Edit on the first KeeShare group +- tapOn: + text: "Edit" + +# Wait for edit dialog +- extendedWaitUntil: + visible: ".*Edit KeeShare.*|.*Password.*" + timeout: 5000 + +# Verify dialog components are present +- assertVisible: + text: ".*Share Type.*|.*Import.*|.*Export.*|.*Synchronize.*" + +# Clear and enter a new password +- tapOn: + id: ".*edit_password.*" +- clearText +- inputText: "share123" +- hideKeyboard + +# Save changes +- tapOn: + text: "OK" + +# Wait for save to complete +- extendedWaitUntil: + visible: ".*Password.*configured.*|.*Sync now.*" + timeout: 5000 + +# Verify password status shows configured +- assertVisible: + text: ".*Password.*configured.*" + +# Test the sync works with the new password +- tapOn: + text: "Sync now" + +# Wait for sync result +- extendedWaitUntil: + visible: ".*sync.*|.*KeeShare.*" + timeout: 15000 + +# Test passed - edit functionality works diff --git a/e2e-tests/.maestro/keeshare_full_test.yaml b/e2e-tests/.maestro/keeshare_full_test.yaml new file mode 100644 index 000000000..7676aa385 --- /dev/null +++ b/e2e-tests/.maestro/keeshare_full_test.yaml @@ -0,0 +1,54 @@ +appId: keepass2android.keepass2android_nonet +--- +# Test: Full KeeShare configuration flow +# This test verifies the KeeShare functionality added in PR #3106 + +- launchApp + +# Unlock if needed +- tapOn: + text: "Password" + optional: true +- runFlow: + when: + visible: "Unlock" + commands: + - inputText: "testpassword123" + - hideKeyboard + - tapOn: "Unlock" + - extendedWaitUntil: + visible: "eMail" + timeout: 10000 + +# Dismiss any dialogs +- tapOn: + text: "I don't care" + optional: true +- tapOn: + text: "Do not show again" + optional: true + +# Navigate to Settings +- tapOn: + text: "More options" +- tapOn: "Settings" + +# Go to Database settings +- tapOn: "Database" + +# Scroll down to find KeeShare option +- scrollUntilVisible: + element: "Configure KeeShare groups…" + direction: DOWN + +# Tap on Configure KeeShare groups +- tapOn: "Configure KeeShare groups…" + +# Wait for KeeShare configuration screen to load +- extendedWaitUntil: + visible: ".*" + timeout: 5000 + +# Verify we're on the KeeShare configuration screen +- assertVisible: + text: ".*KeeShare.*" diff --git a/e2e-tests/.maestro/keeshare_import_flow.yaml b/e2e-tests/.maestro/keeshare_import_flow.yaml new file mode 100644 index 000000000..068a247e3 --- /dev/null +++ b/e2e-tests/.maestro/keeshare_import_flow.yaml @@ -0,0 +1,258 @@ +appId: keepass2android.keepass2android_nonet +--- +# Test: Complete KeeShare Import Flow +# Tests the full workflow of adding a KeeShare import configuration, +# setting the password, syncing, and verifying entries appear. +# +# Prerequisites: +# - keeshare-test-main.kdbx in /sdcard/Download/ (password: test123) +# - keeshare-test-export.kdbx in /sdcard/Download/ (password: share123) +# +# Run with: maestro test e2e-tests/.maestro/keeshare_import_flow.yaml + +- launchApp: + clearState: true + +# Wait for app to fully load +- extendedWaitUntil: + visible: ".*database.*|.*Open.*|.*Unlock.*|.*Change log.*" + timeout: 10000 + +# Dismiss changelog dialog if it appears +- tapOn: + text: "OK" + optional: true + +# Wait a moment for the main screen +- extendedWaitUntil: + visible: ".*Open.*|.*file.*|.*database.*" + timeout: 5000 + +# Dismiss any other dialogs +- tapOn: + text: "OK" + optional: true +- tapOn: + text: "I don't care" + optional: true + +# Open the test main database - tap the open file button/FAB +- tapOn: + text: "Open file" + optional: true +- tapOn: + id: ".*open.*|.*fab.*" + optional: true + +# Handle storage type selection if it appears +- runFlow: + when: + visible: "Select the storage type" + commands: + - tapOn: "System file picker" + +# Wait for system file picker +- extendedWaitUntil: + visible: ".*Download.*|.*Recent.*|.*Files.*|.*Open from.*" + timeout: 10000 + +# Tap the hamburger menu to access navigation drawer +- tapOn: + id: ".*drawer.*|.*navigation.*" + optional: true + +# Look for Downloads in the navigation +- tapOn: + text: "Downloads" + optional: true + +- tapOn: + text: "Download" + optional: true + +# Select the test main database file +- tapOn: + text: "keeshare-test-main.kdbx" + optional: true + +- tapOn: + text: "keeshare-test-main" + optional: true + +# Wait for password screen +- extendedWaitUntil: + visible: ".*Password.*|.*Unlock.*" + timeout: 5000 + +# Enter the main database password +- tapOn: + text: "Password" + optional: true + +- inputText: "test123" +- hideKeyboard + +# Tap Unlock +- tapOn: "Unlock" + +# Wait for database to open +- extendedWaitUntil: + visible: ".*Sample Entry.*|.*Root.*|.*group.*" + timeout: 15000 + +# Dismiss any tutorial/info dialogs +- tapOn: + text: "OK" + optional: true +- tapOn: + text: "I don't care" + optional: true +- tapOn: + text: "Do not show again" + optional: true +- tapOn: + text: "Got it" + optional: true + +# Navigate to Settings -> Database -> Configure KeeShare groups +- tapOn: + id: ".*overflow.*|.*more.*" + optional: true +- tapOn: + text: "More options" + optional: true + +- tapOn: "Settings" + +- extendedWaitUntil: + visible: "Database" + timeout: 5000 + +- tapOn: "Database" + +# Scroll to find KeeShare option (use regex to match ellipsis variants) +- scrollUntilVisible: + element: + text: ".*Configure KeeShare.*" + direction: DOWN + timeout: 10000 + +- tapOn: + text: ".*Configure KeeShare.*" + +# Wait for KeeShare configuration screen +- extendedWaitUntil: + visible: ".*KeeShare.*|.*No KeeShare.*" + timeout: 5000 + +# Tap the FAB to add a new KeeShare configuration +- tapOn: + id: ".*fab.*add.*keeshare.*" + +# Wait for the Add KeeShare dialog +- extendedWaitUntil: + visible: ".*Add KeeShare.*|.*Group to sync.*" + timeout: 5000 + +# Select "Create new group" (should be default) or keep existing selection +# The spinner should default to "Create new group" + +# Enter a name for the new group +- tapOn: + id: ".*edit_new_group_name.*" + optional: true +- inputText: "Imported Credentials" +- hideKeyboard + +# Select Import type (should be default) +- tapOn: + id: ".*radio_import.*" + optional: true + +# Tap Browse to select the file - now opens Android system file picker directly +- tapOn: + text: ".*Browse.*" + +# Wait for system file picker +- extendedWaitUntil: + visible: ".*Download.*|.*Recent.*|.*Files.*|.*Open from.*" + timeout: 10000 + +# Navigate to Downloads +- tapOn: + id: ".*drawer.*|.*navigation.*" + optional: true +- tapOn: + text: "Downloads" + optional: true + +# Select the test export file +- tapOn: + text: "keeshare-test-export.kdbx" + optional: true +- tapOn: + text: "keeshare-test-export" + optional: true + +# With direct file picker, we should return straight to KeeShare config +- extendedWaitUntil: + visible: ".*KeeShare.*|.*Configure KeeShare.*" + timeout: 15000 + +# Wait for save dialog to complete (shows "Saving database...") +# Then wait for the KeeShare group to appear with Sync/Edit buttons +- extendedWaitUntil: + visible: ".*Sync now.*|.*Edit.*|.*Imported Credentials.*|.*No path configured.*" + timeout: 20000 + +# First tap Edit to set the password for the KeeShare file +- tapOn: + text: "Edit" + +# Wait for Edit dialog +- extendedWaitUntil: + visible: ".*Edit KeeShare.*|.*Password.*" + timeout: 5000 + +# Enter the password for the export file +- tapOn: + id: ".*edit_password.*" +- eraseText: 20 +- inputText: "share123" +- hideKeyboard + +# Wait for text to commit +- waitForAnimationToEnd + +# Tap OK to save +- tapOn: + text: "OK" + +# Wait for save and return to config screen +- extendedWaitUntil: + visible: ".*Sync now.*|.*Edit.*" + timeout: 10000 + +# Now tap Sync now to trigger the import +- tapOn: + text: "Sync now" + +# Wait for sync to complete +- extendedWaitUntil: + visible: ".*sync complete.*|.*KeeShare.*" + timeout: 15000 + +# Verify the KeeShare configuration shows success indicators +# The sync completed, which means the import was successful +# Note: There's a known display bug that prevents viewing imported entries +# but the core KeeShare sync functionality is working + +# Navigate back to verify database is still accessible +- back + +# Wait for settings screen +- extendedWaitUntil: + visible: ".*Database.*|.*Settings.*" + timeout: 5000 + +# Test passed - KeeShare configuration created and sync completed successfully diff --git a/e2e-tests/.maestro/keeshare_view_imported_entries.yaml b/e2e-tests/.maestro/keeshare_view_imported_entries.yaml new file mode 100644 index 000000000..0ef5c284d --- /dev/null +++ b/e2e-tests/.maestro/keeshare_view_imported_entries.yaml @@ -0,0 +1,253 @@ +appId: keepass2android.keepass2android_nonet +--- +# Test: KeeShare Import and View Entries +# This test verifies that after KeeShare import, the imported entries +# can be viewed without crashing (tests the UpdateGlobals fix). +# +# Prerequisites: +# - keeshare-test-main.kdbx in /sdcard/Download/ (password: test123) +# - keeshare-test-export.kdbx in /sdcard/Download/ (password: share123) +# +# Run with: maestro test e2e-tests/.maestro/keeshare_view_imported_entries.yaml + +- launchApp: + clearState: true + +# Wait for app to fully load and dismiss dialogs +- extendedWaitUntil: + visible: ".*database.*|.*Open.*|.*Unlock.*|.*Change log.*" + timeout: 10000 + +- tapOn: + text: "OK" + optional: true + +- extendedWaitUntil: + visible: ".*Open.*|.*file.*|.*database.*" + timeout: 5000 + +- tapOn: + text: "OK" + optional: true +- tapOn: + text: "I don't care" + optional: true + +# Open the test main database +- tapOn: + text: "Open file" + optional: true +- tapOn: + id: ".*open.*|.*fab.*" + optional: true + +# Handle storage type selection +- runFlow: + when: + visible: "Select the storage type" + commands: + - tapOn: "System file picker" + +# Wait for and navigate file picker +- extendedWaitUntil: + visible: ".*Download.*|.*Recent.*|.*Files.*|.*Open from.*" + timeout: 10000 + +- tapOn: + id: ".*drawer.*|.*navigation.*" + optional: true +- tapOn: + text: "Downloads" + optional: true +- tapOn: + text: "Download" + optional: true +- tapOn: + text: "keeshare-test-main.kdbx" + optional: true +- tapOn: + text: "keeshare-test-main" + optional: true + +# Enter password and unlock +- extendedWaitUntil: + visible: ".*Password.*|.*Unlock.*" + timeout: 5000 + +- tapOn: + text: "Password" + optional: true +- inputText: "test123" +- hideKeyboard +- tapOn: "Unlock" + +# Wait for database to open +- extendedWaitUntil: + visible: ".*Sample Entry.*|.*Root.*|.*group.*" + timeout: 15000 + +# Dismiss dialogs +- tapOn: + text: "OK" + optional: true +- tapOn: + text: "I don't care" + optional: true +- tapOn: + text: "Do not show again" + optional: true +- tapOn: + text: "Got it" + optional: true + +# Navigate to Settings -> Database -> Configure KeeShare +- tapOn: + id: ".*overflow.*|.*more.*" + optional: true +- tapOn: + text: "More options" + optional: true +- tapOn: "Settings" + +- extendedWaitUntil: + visible: "Database" + timeout: 5000 +- tapOn: "Database" + +- scrollUntilVisible: + element: + text: ".*Configure KeeShare.*" + direction: DOWN + timeout: 10000 +- tapOn: + text: ".*Configure KeeShare.*" + +# Wait for KeeShare config screen +- extendedWaitUntil: + visible: ".*KeeShare.*|.*No KeeShare.*" + timeout: 5000 + +# Add new KeeShare configuration +- tapOn: + id: ".*fab.*add.*keeshare.*" + +- extendedWaitUntil: + visible: ".*Add KeeShare.*|.*Group to sync.*" + timeout: 5000 + +# Enter group name +- tapOn: + id: ".*edit_new_group_name.*" + optional: true +- inputText: "Shared Passwords" +- hideKeyboard + +# Select Import type +- tapOn: + id: ".*radio_import.*" + optional: true + +# Browse for file +- tapOn: + text: ".*Browse.*" + +- extendedWaitUntil: + visible: ".*Download.*|.*Recent.*|.*Files.*|.*Open from.*" + timeout: 10000 + +- tapOn: + id: ".*drawer.*|.*navigation.*" + optional: true +- tapOn: + text: "Downloads" + optional: true +- tapOn: + text: "keeshare-test-export.kdbx" + optional: true +- tapOn: + text: "keeshare-test-export" + optional: true + +# Wait for return to KeeShare config +- extendedWaitUntil: + visible: ".*KeeShare.*|.*Configure KeeShare.*" + timeout: 15000 + +- extendedWaitUntil: + visible: ".*Sync now.*|.*Edit.*|.*Shared Passwords.*|.*No path configured.*" + timeout: 20000 + +# Set password via Edit +- tapOn: + text: "Edit" + +- extendedWaitUntil: + visible: ".*Edit KeeShare.*|.*Password.*" + timeout: 5000 + +- tapOn: + id: ".*edit_password.*" +- eraseText: 20 +- inputText: "share123" +- hideKeyboard + +# Wait for text to commit +- waitForAnimationToEnd + +- tapOn: + text: "OK" + +- extendedWaitUntil: + visible: ".*Sync now.*|.*Edit.*" + timeout: 10000 + +# Trigger sync +- tapOn: + text: "Sync now" + +# Wait for sync to complete +- extendedWaitUntil: + visible: ".*sync complete.*|.*KeeShare.*" + timeout: 15000 + +# Now navigate back to the database root to find the imported group +- back +- extendedWaitUntil: + visible: ".*Database.*|.*Settings.*" + timeout: 5000 + +- back +- extendedWaitUntil: + visible: ".*Settings.*|.*Application.*" + timeout: 5000 + +- back +# Should be back at the database view +- extendedWaitUntil: + visible: ".*Sample Entry.*|.*Root.*|.*group.*|.*Shared Passwords.*" + timeout: 10000 + +# Look for the imported group "Shared Passwords" +- scrollUntilVisible: + element: + text: ".*Shared Passwords.*" + direction: DOWN + timeout: 10000 + +# TAP ON THE IMPORTED GROUP - This is the critical test! +# If UpdateGlobals wasn't called, this would crash with +# "Database element not found" error +- tapOn: + text: ".*Shared Passwords.*" + +# Wait for group contents to load - should show the imported entry +- extendedWaitUntil: + visible: ".*Test Service Account.*|.*entry.*|.*Shared Passwords.*" + timeout: 10000 + +# Verify the imported entry is visible (from keeshare-test-export.kdbx) +- assertVisible: + text: ".*Test Service Account.*" + enabled: true + +# Test passed! The imported entry is visible and the app didn't crash diff --git a/e2e-tests/.maestro/keeshare_wrong_password.yaml b/e2e-tests/.maestro/keeshare_wrong_password.yaml new file mode 100644 index 000000000..ec1950eae --- /dev/null +++ b/e2e-tests/.maestro/keeshare_wrong_password.yaml @@ -0,0 +1,209 @@ +appId: keepass2android.keepass2android_nonet +--- +# Test: KeeShare Wrong Password Error Handling +# Tests that wrong password errors are handled gracefully with helpful messages. +# +# Prerequisites: +# - keeshare-test-main.kdbx in /sdcard/Download/ (password: test123) +# - keeshare-test-export.kdbx in /sdcard/Download/ (password: share123) +# - A KeeShare import already configured (run keeshare_import_flow.yaml first, +# or this test will set one up with wrong password) +# +# Run with: maestro test e2e-tests/.maestro/keeshare_wrong_password.yaml + +- launchApp: + clearState: true + +# Wait for app to load +- extendedWaitUntil: + visible: ".*database.*|.*Open.*|.*Unlock.*" + timeout: 10000 + +# Open the test main database +- tapOn: + text: "Open file" + optional: true +- tapOn: + text: "Change database" + optional: true + +# Navigate to Downloads +- runFlow: + when: + visible: ".*Open.*|.*Browse.*" + commands: + - tapOn: + id: ".*fab.*|.*open.*" + optional: true + +- extendedWaitUntil: + visible: ".*Download.*|.*Recent.*|.*Files.*" + timeout: 5000 + +- tapOn: + text: "Downloads" + optional: true +- tapOn: + text: "Download" + optional: true + +# Select the test main database +- tapOn: + text: "keeshare-test-main" + optional: true +- tapOn: + text: ".*keeshare-test-main.*" + optional: true + +# Wait for password screen and enter password +- extendedWaitUntil: + visible: ".*Password.*|.*Unlock.*" + timeout: 5000 + +- tapOn: + text: "Password" + optional: true +- inputText: "test123" +- hideKeyboard +- tapOn: "Unlock" + +# Wait for database to open +- extendedWaitUntil: + visible: ".*Sample Entry.*|.*Root.*|.*group.*" + timeout: 15000 + +# Dismiss dialogs +- tapOn: + text: "OK" + optional: true +- tapOn: + text: "I don't care" + optional: true + +# Navigate to KeeShare settings +- tapOn: + id: ".*overflow.*|.*more.*" + optional: true +- tapOn: + text: "More options" + optional: true +- tapOn: "Settings" + +- extendedWaitUntil: + visible: "Database" + timeout: 5000 +- tapOn: "Database" + +- scrollUntilVisible: + element: "Configure KeeShare groups" + direction: DOWN + timeout: 10000 +- tapOn: + text: "Configure KeeShare groups" + +# Wait for KeeShare screen +- extendedWaitUntil: + visible: ".*KeeShare.*" + timeout: 5000 + +# If no KeeShare groups exist, create one with wrong password +- runFlow: + when: + visible: ".*No KeeShare.*" + commands: + # Tap FAB to add + - tapOn: + id: ".*fab.*add.*" + - extendedWaitUntil: + visible: ".*Add KeeShare.*" + timeout: 5000 + # Enter group name + - tapOn: + id: ".*edit_new_group_name.*" + optional: true + - inputText: "Test Wrong Password" + - hideKeyboard + # Tap Browse + - tapOn: + text: "Browse" + # Select file + - extendedWaitUntil: + visible: ".*Download.*" + timeout: 5000 + - tapOn: + text: "Downloads" + optional: true + - tapOn: + text: "keeshare-test-export" + # Wait for return + - extendedWaitUntil: + visible: ".*KeeShare.*" + timeout: 5000 + +# Now we should have a KeeShare group. Edit it to set wrong password. +- tapOn: + text: "Edit" + +- extendedWaitUntil: + visible: ".*Edit KeeShare.*|.*Password.*" + timeout: 5000 + +# Clear any existing password and enter wrong one +- tapOn: + id: ".*edit_password.*" +- clearText +- inputText: "wrong123" +- hideKeyboard + +- tapOn: + text: "OK" + +# Wait for save +- extendedWaitUntil: + visible: ".*Sync now.*" + timeout: 5000 + +# Now sync - this should fail with wrong password +- tapOn: + text: "Sync now" + +# Wait for error message +- extendedWaitUntil: + visible: ".*[Ww]rong password.*|.*[Ee]rror.*|.*[Ff]ailed.*" + timeout: 15000 + +# Verify we see a user-friendly error about wrong password +- assertVisible: + text: ".*[Ww]rong password.*|.*Edit.*" + +# Now fix the password +- tapOn: + text: "Edit" + +- extendedWaitUntil: + visible: ".*Edit KeeShare.*" + timeout: 5000 + +- tapOn: + id: ".*edit_password.*" +- clearText +- inputText: "share123" +- hideKeyboard + +- tapOn: + text: "OK" + +# Sync again - should succeed now +- extendedWaitUntil: + visible: ".*Sync now.*" + timeout: 5000 + +- tapOn: + text: "Sync now" + +# Wait for success +- extendedWaitUntil: + visible: ".*sync complete.*|.*Password.*configured.*" + timeout: 15000 + +# Test passed - error handling works correctly diff --git a/e2e-tests/.maestro/nav_open_overflow.yaml b/e2e-tests/.maestro/nav_open_overflow.yaml new file mode 100644 index 000000000..b3cd69bd3 --- /dev/null +++ b/e2e-tests/.maestro/nav_open_overflow.yaml @@ -0,0 +1,5 @@ +appId: keepass2android.keepass2android_nonet +--- +# Open the overflow menu (three dots) +- tapOn: + text: "More options" diff --git a/e2e-tests/.maestro/nav_tap_fab.yaml b/e2e-tests/.maestro/nav_tap_fab.yaml new file mode 100644 index 000000000..fc9ed08fa --- /dev/null +++ b/e2e-tests/.maestro/nav_tap_fab.yaml @@ -0,0 +1,5 @@ +appId: keepass2android.keepass2android_nonet +--- +# Tap the FAB (floating action button) to add KeeShare +- tapOn: + id: ".*fab.*" diff --git a/e2e-tests/.maestro/nav_to_database_settings.yaml b/e2e-tests/.maestro/nav_to_database_settings.yaml new file mode 100644 index 000000000..7559af70a --- /dev/null +++ b/e2e-tests/.maestro/nav_to_database_settings.yaml @@ -0,0 +1,5 @@ +appId: keepass2android.keepass2android_nonet +--- +# Tap Database in settings +- tapOn: + text: "Database" diff --git a/e2e-tests/.maestro/nav_to_keeshare.yaml b/e2e-tests/.maestro/nav_to_keeshare.yaml new file mode 100644 index 000000000..b4269ee38 --- /dev/null +++ b/e2e-tests/.maestro/nav_to_keeshare.yaml @@ -0,0 +1,11 @@ +appId: keepass2android.keepass2android_nonet +--- +# Scroll to and tap Configure KeeShare groups +- scrollUntilVisible: + element: + text: ".*Configure KeeShare.*" + direction: DOWN + timeout: 10000 + +- tapOn: + text: ".*Configure KeeShare.*" diff --git a/e2e-tests/.maestro/nav_to_settings.yaml b/e2e-tests/.maestro/nav_to_settings.yaml new file mode 100644 index 000000000..257c4f7cc --- /dev/null +++ b/e2e-tests/.maestro/nav_to_settings.yaml @@ -0,0 +1,5 @@ +appId: keepass2android.keepass2android_nonet +--- +# Tap Settings in the overflow menu +- tapOn: + text: "Settings" diff --git a/e2e-tests/.maestro/open_database.yaml b/e2e-tests/.maestro/open_database.yaml new file mode 100644 index 000000000..4b02b97ac --- /dev/null +++ b/e2e-tests/.maestro/open_database.yaml @@ -0,0 +1,58 @@ +appId: keepass2android.keepass2android_nonet +--- +- launchApp + +# Dismiss changelog dialog if present +- runFlow: + when: + visible: "Change log" + commands: + - tapOn: "OK" + +- extendedWaitUntil: + visible: ".*Open.*file.*" + timeout: 5000 + +- tapOn: + text: "Open file..." + +- runFlow: + when: + visible: "Select the storage type" + commands: + - tapOn: "System file picker" + +- extendedWaitUntil: + visible: ".*Download.*|.*Recent.*|.*Files.*" + timeout: 10000 + +- tapOn: + id: ".*drawer.*|.*navigation.*" + optional: true +- tapOn: + text: "Downloads" + optional: true +- tapOn: + text: "keeshare-test-main.kdbx" + +- extendedWaitUntil: + visible: ".*Password.*|.*Unlock.*" + timeout: 5000 + +- tapOn: + text: "Password" + optional: true +- inputText: "test123" +- hideKeyboard +- tapOn: "Unlock" + +- extendedWaitUntil: + visible: ".*Sample Entry.*|.*Root.*" + timeout: 15000 + +- tapOn: + text: "OK" + optional: true +- tapOn: + text: "I don't care" + optional: true diff --git a/e2e-tests/.maestro/tap_fab.yaml b/e2e-tests/.maestro/tap_fab.yaml new file mode 100644 index 000000000..09b0bd8da --- /dev/null +++ b/e2e-tests/.maestro/tap_fab.yaml @@ -0,0 +1,4 @@ +appId: keepass2android.keepass2android_nonet +--- +- tapOn: + id: ".*fab_add_keeshare.*" diff --git a/e2e-tests/README.md b/e2e-tests/README.md new file mode 100644 index 000000000..ccae87ecf --- /dev/null +++ b/e2e-tests/README.md @@ -0,0 +1,192 @@ +# KeePass2Android E2E Tests + +End-to-end tests using [Maestro](https://maestro.mobile.dev/) for testing the KeePass2Android app. + +## Prerequisites + +1. Install Maestro: + ```bash + curl -Ls "https://get.maestro.mobile.dev" | bash + ``` + +2. Have an Android emulator running or physical device connected: + ```bash + # List devices + adb devices + + # Start emulator (example) + emulator -avd Pixel_8_API_35 + ``` + +3. Install the app APK: + ```bash + adb install path/to/keepass2android.apk + ``` + +## Running Tests + +Run all tests: +```bash +cd e2e-tests +maestro test .maestro/ +``` + +Run a specific test: +```bash +maestro test .maestro/keeshare_full_test.yaml +``` + +## Test Files + +### Core Tests +| File | Description | +|------|-------------| +| `1_launch_app.yaml` | Basic app launch and changelog dismissal | +| `2_create_database.yaml` | Create a new test database with password | +| `3_unlock_and_navigate.yaml` | Unlock existing database | + +### KeeShare Tests +| File | Description | +|------|-------------| +| `keeshare_import_flow.yaml` | **Complete KeeShare import workflow** - Creates config, sets password, syncs | +| `keeshare_view_imported_entries.yaml` | **Display bug regression test** - Verifies imported entries can be viewed | +| `keeshare_edit_password.yaml` | Tests editing KeeShare password via Edit dialog | +| `keeshare_wrong_password.yaml` | Tests error handling for incorrect passwords | +| `keeshare_full_test.yaml` | Basic KeeShare configuration screen test | + +## Notes + +- The app uses `FLAG_SECURE` for password protection, so screenshots will appear black +- Maestro can still interact with UI elements despite the secure flag +- Test databases are in `test-data/` directory + +## Capturing Screenshots for Documentation + +### The Problem: FLAG_SECURE Blocks Screenshots + +KeePass2Android uses Android's `FLAG_SECURE` window flag to prevent screenshots for security reasons. This means: + +- **`adb shell screencap`** produces black images +- **Maestro's `takeScreenshot`** produces black images +- **Android's built-in screenshot** produces black images + +This is intentional security behavior and cannot be bypassed from within Android. + +### The Solution: Capture the macOS Emulator Window + +Since `FLAG_SECURE` only blocks Android-level screenshot APIs, we can capture the emulator window itself using macOS screen capture: + +```bash +# Capture the emulator window directly +screencapture -l -x -o screenshot.png +``` + +The `capture_docs_screenshots.sh` script automates this by: +1. Finding the Android Emulator window ID using Python/Quartz +2. Using Maestro to navigate through the app (Maestro can interact with secure windows) +3. Capturing the emulator window at each step using `screencapture` + +### Using the Screenshot Capture Script + +```bash +# Run full screenshot capture for documentation +./e2e-tests/capture_docs_screenshots.sh + +# Test that window capture works (captures current screen) +./e2e-tests/capture_docs_screenshots.sh --test + +# Show help +./e2e-tests/capture_docs_screenshots.sh --help +``` + +**Prerequisites:** +- macOS (uses `screencapture` and Python/Quartz) +- Android Emulator running and visible +- Maestro installed (`~/.maestro/bin/maestro`) +- App installed with test database in Downloads (`keeshare-test-main.kdbx`, password: `test123`) + +**What it captures:** +| Screenshot | Description | +|------------|-------------| +| `02_database_groups.png` | Database view after unlock | +| `03_overflow_menu.png` | Overflow menu open | +| `04_settings_main.png` | Main settings screen | +| `05_database_settings.png` | Database settings with KeeShare option | +| `06_keeshare_groups.png` | KeeShare groups list | +| `keeshare_with_fab.png` | KeeShare list with FAB visible | +| `keeshare_add_dialog.png` | Add KeeShare dialog | + +### Technical Details for Future Debugging + +**Getting the window ID:** +The script uses Python/Quartz to get the CGWindowID required by `screencapture -l`: + +```python +import Quartz +windows = Quartz.CGWindowListCopyWindowInfo(Quartz.kCGWindowListOptionOnScreenOnly, Quartz.kCGNullWindowID) +for w in windows: + if 'qemu' in w.get('kCGWindowOwnerName', '').lower(): + print(w.get('kCGWindowNumber')) # This is the window ID +``` + +**Why osascript doesn't work:** +AppleScript's `id of window` returns a different identifier than what `screencapture -l` expects. You must use the CGWindowID from Quartz. + +**Navigation flows:** +The script uses dedicated Maestro YAML files for each navigation step (in `.maestro/`): +- `open_database.yaml` - Launch app, dismiss changelog, open and unlock database +- `nav_open_overflow.yaml` - Open the three-dot menu +- `nav_to_settings.yaml` - Tap Settings +- `nav_to_database_settings.yaml` - Tap Database +- `nav_to_keeshare.yaml` - Scroll to and tap Configure KeeShare groups +- `nav_tap_fab.yaml` - Tap the floating action button + +**Common issues:** +- If screenshots are black: You're using adb/Maestro screenshot, not window capture +- If navigation fails: Check Maestro flow files match current UI text/IDs +- If window not found: Ensure emulator is running and visible (not minimized) +- If wrong activity: The main activity is `crc64c98c008c0cd742cb.KeePass` (MAUI-generated) + +## KeeShare Testing + +### Test Databases + +Located in `test-data/`: +- `keeshare-test-main.kdbx` - Main database (password: `test123`) +- `keeshare-test-export.kdbx` - KeeShare export file (password: `share123`) + +### Running KeeShare Tests + +```bash +# Push test files to emulator +adb push e2e-tests/test-data/keeshare-test-main.kdbx /sdcard/Download/ +adb push e2e-tests/test-data/keeshare-test-export.kdbx /sdcard/Download/ + +# Clear app data and run test +adb shell pm clear keepass2android.keepass2android_nonet +maestro test e2e-tests/.maestro/keeshare_import_flow.yaml +``` + +### What the Tests Verify + +1. **keeshare_import_flow.yaml**: + - Opens test database + - Navigates to KeeShare configuration + - Creates new KeeShare import configuration + - Sets password via Edit dialog + - Triggers sync and verifies completion + +2. **keeshare_view_imported_entries.yaml**: + - Complete import flow (same as above) + - Navigates back to database view + - Opens the imported "Shared Passwords" group + - **Verifies "Test Service Account" entry is visible** (regression test for display bug) + +### Recreating Test Databases + +If tests fail with stale keeshare groups, recreate the main database: +```bash +rm e2e-tests/test-data/keeshare-test-main.kdbx +echo -e "test123\ntest123" | keepassxc-cli db-create -p e2e-tests/test-data/keeshare-test-main.kdbx +echo "test123" | keepassxc-cli add -u testuser -p e2e-tests/test-data/keeshare-test-main.kdbx "Sample Entry" <<< "testpass" +``` diff --git a/e2e-tests/capture_docs_screenshots.sh b/e2e-tests/capture_docs_screenshots.sh new file mode 100755 index 000000000..1800e6c49 --- /dev/null +++ b/e2e-tests/capture_docs_screenshots.sh @@ -0,0 +1,353 @@ +#!/bin/bash +# Capture screenshots for documentation by capturing the macOS emulator window +# This bypasses Android's FLAG_SECURE which blocks adb screencap +# +# Usage: ./capture_docs_screenshots.sh +# +# Prerequisites: +# - macOS (uses screencapture and osascript) +# - Android Emulator running +# - Maestro installed (~/.maestro/bin/maestro) +# - App installed on emulator +# - Test database file on emulator + +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +MAESTRO="${MAESTRO_PATH:-$HOME/.maestro/bin/maestro}" +OUTPUT_DIR="$PROJECT_ROOT/docs/images" +TEMP_DIR=$(mktemp -d) + +# App constants +APP_PACKAGE="keepass2android.keepass2android_nonet" +APP_ACTIVITY="crc64c98c008c0cd742cb.KeePass" + +# Cleanup temp dir on exit +trap "rm -rf $TEMP_DIR" EXIT + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +log_info() { + echo -e "${GREEN}[INFO]${NC} $1" +} + +log_warn() { + echo -e "${YELLOW}[WARN]${NC} $1" +} + +log_error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +# Get Android Emulator window ID (CGWindowID for screencapture -l) +get_emulator_window_id() { + # Use Python/Quartz to get the CGWindowID + # osascript can't get the numeric window ID that screencapture needs + python3 -c " +import Quartz +windows = Quartz.CGWindowListCopyWindowInfo(Quartz.kCGWindowListOptionOnScreenOnly, Quartz.kCGNullWindowID) +for w in windows: + name = w.get('kCGWindowOwnerName', '') + title = w.get('kCGWindowName', '') + wid = w.get('kCGWindowNumber', 0) + # Look for the main emulator window (has a title with 'Android Emulator') + if 'qemu' in name.lower() and 'Android Emulator' in title: + print(wid) + break +" 2>/dev/null +} + +# Capture the emulator window +capture_window() { + local name=$1 + local window_id + + window_id=$(get_emulator_window_id) + + if [[ -z "$window_id" || "$window_id" == "" ]]; then + log_error "Could not find Android Emulator window. Is the emulator running?" + return 1 + fi + + log_info "Capturing: $name (window ID: $window_id)" + + # Capture the window + # -l captures a specific window + # -x suppresses the shutter sound + # -o excludes window shadow + if screencapture -l "$window_id" -x -o "$TEMP_DIR/$name.png" 2>/dev/null; then + # Copy to output directory + cp "$TEMP_DIR/$name.png" "$OUTPUT_DIR/$name.png" + log_info " -> Saved to $OUTPUT_DIR/$name.png" + return 0 + else + log_error "Failed to capture screenshot: $name" + return 1 + fi +} + +# Run a Maestro flow +run_maestro() { + local flow=$1 + local flow_path="$SCRIPT_DIR/.maestro/$flow" + + if [[ ! -f "$flow_path" ]]; then + log_error "Maestro flow not found: $flow_path" + return 1 + fi + + log_info "Running Maestro flow: $flow" + if ! "$MAESTRO" test "$flow_path" 2>&1 | grep -v "^$"; then + log_warn "Maestro flow may have had issues, but continuing..." + fi +} + +# Wait for a moment to let UI settle +wait_for_ui() { + local seconds=${1:-1} + sleep "$seconds" +} + +# Get list of available AVDs +get_avd_list() { + local emulator_path="${ANDROID_HOME:-$HOME/Library/Android/sdk}/emulator/emulator" + "$emulator_path" -list-avds 2>/dev/null | head -1 +} + +# Check if emulator is running +is_emulator_running() { + adb devices 2>/dev/null | grep -q "emulator" +} + +# Start the emulator if not running +start_emulator() { + local avd_name + avd_name=$(get_avd_list) + + if [[ -z "$avd_name" ]]; then + log_error "No AVD found. Please create an emulator first." + exit 1 + fi + + log_info "Starting emulator: $avd_name" + local emulator_path="${ANDROID_HOME:-$HOME/Library/Android/sdk}/emulator/emulator" + + # Start emulator in background + "$emulator_path" -avd "$avd_name" -no-snapshot-load & + + # Wait for emulator to boot + log_info "Waiting for emulator to boot..." + local max_wait=120 + local waited=0 + + while ! adb shell getprop sys.boot_completed 2>/dev/null | grep -q "1"; do + sleep 2 + waited=$((waited + 2)) + if [[ $waited -ge $max_wait ]]; then + log_error "Emulator failed to boot within ${max_wait}s" + exit 1 + fi + echo -n "." + done + echo "" + + log_info "Emulator booted successfully" + + # Wait a bit more for the window to appear + sleep 5 +} + +# Check prerequisites +check_prerequisites() { + log_info "Checking prerequisites..." + + # Check for macOS + if [[ "$(uname)" != "Darwin" ]]; then + log_error "This script requires macOS (uses screencapture and osascript)" + exit 1 + fi + + # Check for Maestro + if [[ ! -x "$MAESTRO" ]]; then + log_error "Maestro not found at $MAESTRO" + log_error "Install with: curl -Ls 'https://get.maestro.mobile.dev' | bash" + exit 1 + fi + + # Check for emulator and start if needed + if ! is_emulator_running; then + log_warn "No emulator detected via adb. Starting emulator..." + start_emulator + fi + + # Check for emulator window (wait up to 30s for it to appear) + local window_id + local max_wait=30 + local waited=0 + + while [[ $waited -lt $max_wait ]]; do + window_id=$(get_emulator_window_id) + if [[ -n "$window_id" && "$window_id" != "" ]]; then + break + fi + sleep 2 + waited=$((waited + 2)) + log_info "Waiting for emulator window... (${waited}s)" + done + + if [[ -z "$window_id" || "$window_id" == "" ]]; then + log_error "Could not find Android Emulator window" + log_error "Make sure the emulator is running and visible" + exit 1 + fi + + log_info "Found emulator window (ID: $window_id)" + + # Create output directory if needed + mkdir -p "$OUTPUT_DIR" + + log_info "Prerequisites OK" +} + +# Run a Maestro flow file +run_maestro_flow() { + local flow_name=$1 + local flow_path="$SCRIPT_DIR/.maestro/$flow_name" + + if [[ ! -f "$flow_path" ]]; then + log_warn "Flow not found: $flow_path" + return 1 + fi + + log_info " Running: $flow_name" + "$MAESTRO" --no-ansi test "$flow_path" 2>&1 | grep -E "(COMPLETED|WARNED|Error|Tap|Assert)" || true +} + +# Main screenshot capture sequence using Maestro for navigation +capture_all_screenshots() { + log_info "Starting screenshot capture sequence..." + log_info "Output directory: $OUTPUT_DIR" + echo "" + + # Clear app state and launch fresh + log_info "Clearing app state..." + adb shell pm clear "$APP_PACKAGE" 2>/dev/null || true + wait_for_ui 2 + + # Use Maestro to open database - this flow handles the full navigation + log_info "Opening database with Maestro..." + run_maestro_flow "open_database.yaml" + wait_for_ui 2 + + # SCREENSHOT 1: Database groups view + log_info "" + log_info "=== Screenshot 1: Database Groups ===" + capture_window "02_database_groups" + + # Open overflow menu + log_info "" + log_info "=== Screenshot 2: Overflow Menu ===" + run_maestro_flow "nav_open_overflow.yaml" + wait_for_ui 1 + capture_window "03_overflow_menu" + + # Navigate to Settings + log_info "" + log_info "=== Screenshot 3: Settings Main ===" + run_maestro_flow "nav_to_settings.yaml" + wait_for_ui 1 + capture_window "04_settings_main" + + # Navigate to Database settings + log_info "" + log_info "=== Screenshot 4: Database Settings ===" + run_maestro_flow "nav_to_database_settings.yaml" + wait_for_ui 1 + capture_window "05_database_settings" + + # Navigate to KeeShare groups + log_info "" + log_info "=== Screenshot 5 & 6: KeeShare Groups ===" + run_maestro_flow "nav_to_keeshare.yaml" + wait_for_ui 2 + capture_window "06_keeshare_groups" + capture_window "keeshare_with_fab" + + # Open Add KeeShare dialog - tap the FAB + log_info "" + log_info "=== Screenshot 7: Add KeeShare Dialog ===" + run_maestro_flow "nav_tap_fab.yaml" + wait_for_ui 1 + capture_window "keeshare_add_dialog" + + echo "" + log_info "Screenshot capture complete!" + echo "" + log_info "Captured screenshots:" + ls -la "$OUTPUT_DIR"/*.png | awk '{print " " $NF " (" $5 " bytes)"}' +} + +# Alternative: Use the simpler approach (kept for backwards compatibility) +capture_with_maestro() { + # Now the default capture_all_screenshots uses Maestro, so just call it + capture_all_screenshots +} + +# Print usage +usage() { + cat << EOF +Usage: $0 [OPTIONS] + +Capture screenshots for KeePass2Android documentation by capturing +the macOS emulator window (bypasses FLAG_SECURE). + +Options: + -h, --help Show this help message + -t, --test Test screenshot capture without navigation + -m, --maestro Use Maestro for navigation (experimental) + +Prerequisites: + - macOS (uses screencapture and osascript) + - Android Emulator running and visible + - Maestro installed + - App installed with test database available + +EOF +} + +# Test mode - just capture current screen +test_capture() { + log_info "Test mode: Capturing current emulator screen..." + check_prerequisites + capture_window "test_capture" + log_info "Test capture saved to $OUTPUT_DIR/test_capture.png" +} + +# Main entry point +main() { + case "${1:-}" in + -h|--help) + usage + exit 0 + ;; + -t|--test) + test_capture + exit 0 + ;; + -m|--maestro) + check_prerequisites + capture_with_maestro + ;; + *) + check_prerequisites + capture_all_screenshots + ;; + esac +} + +main "$@" diff --git a/e2e-tests/test-data/keeshare-test-export.kdbx b/e2e-tests/test-data/keeshare-test-export.kdbx new file mode 100644 index 000000000..0cfc2abab Binary files /dev/null and b/e2e-tests/test-data/keeshare-test-export.kdbx differ diff --git a/e2e-tests/test-data/keeshare-test-main.kdbx b/e2e-tests/test-data/keeshare-test-main.kdbx new file mode 100644 index 000000000..15bc8dd4d Binary files /dev/null and b/e2e-tests/test-data/keeshare-test-main.kdbx differ diff --git a/src/KeeShare.Tests/ConfigurationTests.cs b/src/KeeShare.Tests/ConfigurationTests.cs new file mode 100644 index 000000000..7c60cfc3b --- /dev/null +++ b/src/KeeShare.Tests/ConfigurationTests.cs @@ -0,0 +1,398 @@ +using KeeShare.Tests.TestHelpers; + +namespace KeeShare.Tests +{ + public class ConfigurationTests + { + public ConfigurationTests() + { + // Reset device ID before each test + KeeShareConfigLogic.TestDeviceId = "test-device-id"; + } + + #region GetDeviceFilePathKey Tests + + [Fact] + public void GetDeviceFilePathKey_ReturnsKeyWithPrefix() + { + string key = KeeShareConfigLogic.GetDeviceFilePathKey(); + Assert.StartsWith(KeeShareConfigLogic.DeviceFilePathKeyPrefix, key); + } + + [Fact] + public void GetDeviceFilePathKey_ContainsDeviceId() + { + KeeShareConfigLogic.TestDeviceId = "my-unique-device"; + string key = KeeShareConfigLogic.GetDeviceFilePathKey(); + Assert.Equal("KeeShare.FilePath.my-unique-device", key); + } + + [Fact] + public void GetDeviceFilePathKey_ChangesWithDeviceId() + { + KeeShareConfigLogic.TestDeviceId = "device-1"; + string key1 = KeeShareConfigLogic.GetDeviceFilePathKey(); + + KeeShareConfigLogic.TestDeviceId = "device-2"; + string key2 = KeeShareConfigLogic.GetDeviceFilePathKey(); + + Assert.NotEqual(key1, key2); + } + + #endregion + + #region GetEffectiveFilePath Tests + + [Fact] + public void GetEffectiveFilePath_NullGroup_ReturnsNull() + { + string? result = KeeShareConfigLogic.GetEffectiveFilePath(null); + Assert.Null(result); + } + + [Fact] + public void GetEffectiveFilePath_DevicePathExists_ReturnsDevicePath() + { + var group = new PwGroupStub(); + string deviceKey = KeeShareConfigLogic.GetDeviceFilePathKey(); + group.CustomData.Set(deviceKey, "/device/specific/path.kdbx"); + group.CustomData.Set(KeeShareConfigLogic.FilePathKey, "/original/path.kdbx"); + + string? result = KeeShareConfigLogic.GetEffectiveFilePath(group); + + Assert.Equal("/device/specific/path.kdbx", result); + } + + [Fact] + public void GetEffectiveFilePath_NoDevicePath_FallsBackToOriginal() + { + var group = new PwGroupStub(); + group.CustomData.Set(KeeShareConfigLogic.FilePathKey, "/original/path.kdbx"); + + string? result = KeeShareConfigLogic.GetEffectiveFilePath(group); + + Assert.Equal("/original/path.kdbx", result); + } + + [Fact] + public void GetEffectiveFilePath_NoPaths_ReturnsNull() + { + var group = new PwGroupStub(); + string? result = KeeShareConfigLogic.GetEffectiveFilePath(group); + Assert.Null(result); + } + + [Fact] + public void GetEffectiveFilePath_EmptyDevicePath_FallsBackToOriginal() + { + var group = new PwGroupStub(); + string deviceKey = KeeShareConfigLogic.GetDeviceFilePathKey(); + group.CustomData.Set(deviceKey, ""); + group.CustomData.Set(KeeShareConfigLogic.FilePathKey, "/original/path.kdbx"); + + string? result = KeeShareConfigLogic.GetEffectiveFilePath(group); + + Assert.Equal("/original/path.kdbx", result); + } + + #endregion + + #region SetDeviceFilePath Tests + + [Fact] + public void SetDeviceFilePath_NullGroup_DoesNotThrow() + { + // Should be a no-op + var ex = Record.Exception(() => KeeShareConfigLogic.SetDeviceFilePath(null, "/some/path")); + Assert.Null(ex); + } + + [Fact] + public void SetDeviceFilePath_SetsPath() + { + var group = new PwGroupStub(); + KeeShareConfigLogic.SetDeviceFilePath(group, "/new/device/path.kdbx"); + + string deviceKey = KeeShareConfigLogic.GetDeviceFilePathKey(); + Assert.Equal("/new/device/path.kdbx", group.CustomData.Get(deviceKey)); + } + + [Fact] + public void SetDeviceFilePath_CallsTouch() + { + var group = new PwGroupStub(); + KeeShareConfigLogic.SetDeviceFilePath(group, "/some/path.kdbx"); + + Assert.True(group.WasTouched); + Assert.Equal((true, false), group.LastTouchArgs); + } + + [Fact] + public void SetDeviceFilePath_NullPath_RemovesKey() + { + var group = new PwGroupStub(); + string deviceKey = KeeShareConfigLogic.GetDeviceFilePathKey(); + group.CustomData.Set(deviceKey, "/existing/path.kdbx"); + + KeeShareConfigLogic.SetDeviceFilePath(group, null); + + Assert.False(group.CustomData.Exists(deviceKey)); + } + + [Fact] + public void SetDeviceFilePath_EmptyPath_RemovesKey() + { + var group = new PwGroupStub(); + string deviceKey = KeeShareConfigLogic.GetDeviceFilePathKey(); + group.CustomData.Set(deviceKey, "/existing/path.kdbx"); + + KeeShareConfigLogic.SetDeviceFilePath(group, ""); + + Assert.False(group.CustomData.Exists(deviceKey)); + } + + [Fact] + public void SetDeviceFilePath_UpdatesExisting() + { + var group = new PwGroupStub(); + string deviceKey = KeeShareConfigLogic.GetDeviceFilePathKey(); + group.CustomData.Set(deviceKey, "/old/path.kdbx"); + + KeeShareConfigLogic.SetDeviceFilePath(group, "/new/path.kdbx"); + + Assert.Equal("/new/path.kdbx", group.CustomData.Get(deviceKey)); + } + + #endregion + + #region EnableKeeShare Tests + + [Fact] + public void EnableKeeShare_NullGroup_ThrowsArgumentNullException() + { + Assert.Throws(() => + KeeShareConfigLogic.EnableKeeShare(null, "Export", "/path.kdbx")); + } + + [Fact] + public void EnableKeeShare_NullType_ThrowsArgumentException() + { + var group = new PwGroupStub(); + Assert.Throws(() => + KeeShareConfigLogic.EnableKeeShare(group, null, "/path.kdbx")); + } + + [Fact] + public void EnableKeeShare_EmptyType_ThrowsArgumentException() + { + var group = new PwGroupStub(); + Assert.Throws(() => + KeeShareConfigLogic.EnableKeeShare(group, "", "/path.kdbx")); + } + + [Fact] + public void EnableKeeShare_InvalidType_ThrowsArgumentException() + { + var group = new PwGroupStub(); + Assert.Throws(() => + KeeShareConfigLogic.EnableKeeShare(group, "Invalid", "/path.kdbx")); + } + + [Theory] + [InlineData("Export")] + [InlineData("Import")] + [InlineData("Synchronize")] + public void EnableKeeShare_ValidType_SetsAllFields(string type) + { + var group = new PwGroupStub(); + KeeShareConfigLogic.EnableKeeShare(group, type, "/path/to/share.kdbx", "password123"); + + Assert.Equal("true", group.CustomData.Get(KeeShareConfigLogic.ActiveKey)); + Assert.Equal(type, group.CustomData.Get(KeeShareConfigLogic.TypeKey)); + Assert.Equal("/path/to/share.kdbx", group.CustomData.Get(KeeShareConfigLogic.FilePathKey)); + Assert.Equal("password123", group.CustomData.Get(KeeShareConfigLogic.PasswordKey)); + } + + [Fact] + public void EnableKeeShare_NullPassword_RemovesPasswordKey() + { + var group = new PwGroupStub(); + group.CustomData.Set(KeeShareConfigLogic.PasswordKey, "old-password"); + + KeeShareConfigLogic.EnableKeeShare(group, "Export", "/path.kdbx", null); + + Assert.False(group.CustomData.Exists(KeeShareConfigLogic.PasswordKey)); + } + + [Fact] + public void EnableKeeShare_EmptyPassword_RemovesPasswordKey() + { + var group = new PwGroupStub(); + group.CustomData.Set(KeeShareConfigLogic.PasswordKey, "old-password"); + + KeeShareConfigLogic.EnableKeeShare(group, "Export", "/path.kdbx", ""); + + Assert.False(group.CustomData.Exists(KeeShareConfigLogic.PasswordKey)); + } + + [Fact] + public void EnableKeeShare_CallsTouch() + { + var group = new PwGroupStub(); + KeeShareConfigLogic.EnableKeeShare(group, "Export", "/path.kdbx"); + + Assert.True(group.WasTouched); + Assert.Equal((true, false), group.LastTouchArgs); + } + + [Fact] + public void EnableKeeShare_NullFilePath_DoesNotSetFilePathKey() + { + var group = new PwGroupStub(); + KeeShareConfigLogic.EnableKeeShare(group, "Export", null); + + Assert.False(group.CustomData.Exists(KeeShareConfigLogic.FilePathKey)); + } + + #endregion + + #region UpdateKeeShareConfig Tests + + [Fact] + public void UpdateKeeShareConfig_NullGroup_ThrowsArgumentNullException() + { + Assert.Throws(() => + KeeShareConfigLogic.UpdateKeeShareConfig(null, "Export", "/path.kdbx", null)); + } + + [Fact] + public void UpdateKeeShareConfig_InvalidType_ThrowsArgumentException() + { + var group = new PwGroupStub(); + Assert.Throws(() => + KeeShareConfigLogic.UpdateKeeShareConfig(group, "Invalid", "/path.kdbx", null)); + } + + [Fact] + public void UpdateKeeShareConfig_UpdatesType() + { + var group = new PwGroupStub(); + group.CustomData.Set(KeeShareConfigLogic.TypeKey, "Export"); + + KeeShareConfigLogic.UpdateKeeShareConfig(group, "Import", null, null); + + Assert.Equal("Import", group.CustomData.Get(KeeShareConfigLogic.TypeKey)); + } + + [Fact] + public void UpdateKeeShareConfig_UpdatesFilePath() + { + var group = new PwGroupStub(); + group.CustomData.Set(KeeShareConfigLogic.FilePathKey, "/old/path.kdbx"); + + KeeShareConfigLogic.UpdateKeeShareConfig(group, null, "/new/path.kdbx", null); + + Assert.Equal("/new/path.kdbx", group.CustomData.Get(KeeShareConfigLogic.FilePathKey)); + } + + [Fact] + public void UpdateKeeShareConfig_UpdatesPassword() + { + var group = new PwGroupStub(); + group.CustomData.Set(KeeShareConfigLogic.PasswordKey, "old-pass"); + + KeeShareConfigLogic.UpdateKeeShareConfig(group, null, null, "new-pass"); + + Assert.Equal("new-pass", group.CustomData.Get(KeeShareConfigLogic.PasswordKey)); + } + + [Fact] + public void UpdateKeeShareConfig_NullPassword_RemovesPasswordKey() + { + var group = new PwGroupStub(); + group.CustomData.Set(KeeShareConfigLogic.PasswordKey, "old-pass"); + + KeeShareConfigLogic.UpdateKeeShareConfig(group, null, null, null); + + Assert.False(group.CustomData.Exists(KeeShareConfigLogic.PasswordKey)); + } + + [Fact] + public void UpdateKeeShareConfig_NullType_DoesNotUpdateType() + { + var group = new PwGroupStub(); + group.CustomData.Set(KeeShareConfigLogic.TypeKey, "Export"); + + KeeShareConfigLogic.UpdateKeeShareConfig(group, null, "/path.kdbx", null); + + Assert.Equal("Export", group.CustomData.Get(KeeShareConfigLogic.TypeKey)); + } + + [Fact] + public void UpdateKeeShareConfig_CallsTouch() + { + var group = new PwGroupStub(); + KeeShareConfigLogic.UpdateKeeShareConfig(group, "Export", null, null); + + Assert.True(group.WasTouched); + } + + #endregion + + #region DisableKeeShare Tests + + [Fact] + public void DisableKeeShare_NullGroup_DoesNotThrow() + { + var ex = Record.Exception(() => KeeShareConfigLogic.DisableKeeShare(null)); + Assert.Null(ex); + } + + [Fact] + public void DisableKeeShare_RemovesAllKeeShareKeys() + { + var group = new PwGroupStub(); + group.CustomData.Set(KeeShareConfigLogic.ActiveKey, "true"); + group.CustomData.Set(KeeShareConfigLogic.TypeKey, "Export"); + group.CustomData.Set(KeeShareConfigLogic.FilePathKey, "/path.kdbx"); + group.CustomData.Set(KeeShareConfigLogic.PasswordKey, "password"); + group.CustomData.Set(KeeShareConfigLogic.TrustedCertificateKey, "cert"); + string deviceKey = KeeShareConfigLogic.GetDeviceFilePathKey(); + group.CustomData.Set(deviceKey, "/device/path.kdbx"); + + KeeShareConfigLogic.DisableKeeShare(group); + + Assert.False(group.CustomData.Exists(KeeShareConfigLogic.ActiveKey)); + Assert.False(group.CustomData.Exists(KeeShareConfigLogic.TypeKey)); + Assert.False(group.CustomData.Exists(KeeShareConfigLogic.FilePathKey)); + Assert.False(group.CustomData.Exists(KeeShareConfigLogic.PasswordKey)); + Assert.False(group.CustomData.Exists(KeeShareConfigLogic.TrustedCertificateKey)); + Assert.False(group.CustomData.Exists(deviceKey)); + } + + [Fact] + public void DisableKeeShare_PreservesOtherCustomData() + { + var group = new PwGroupStub(); + group.CustomData.Set(KeeShareConfigLogic.ActiveKey, "true"); + group.CustomData.Set("SomeOtherKey", "SomeValue"); + + KeeShareConfigLogic.DisableKeeShare(group); + + Assert.False(group.CustomData.Exists(KeeShareConfigLogic.ActiveKey)); + Assert.Equal("SomeValue", group.CustomData.Get("SomeOtherKey")); + } + + [Fact] + public void DisableKeeShare_CallsTouch() + { + var group = new PwGroupStub(); + KeeShareConfigLogic.DisableKeeShare(group); + + Assert.True(group.WasTouched); + Assert.Equal((true, false), group.LastTouchArgs); + } + + #endregion + } +} diff --git a/src/KeeShare.Tests/HasKeeShareGroupsTests.cs b/src/KeeShare.Tests/HasKeeShareGroupsTests.cs new file mode 100644 index 000000000..69c6adcb8 --- /dev/null +++ b/src/KeeShare.Tests/HasKeeShareGroupsTests.cs @@ -0,0 +1,72 @@ +using KeeShare.Tests.TestHelpers; + +namespace KeeShare.Tests +{ + public class HasKeeShareGroupsTests + { + [Fact] + public void HasKeeShareGroups_WithNoGroups_ReturnsFalse() + { + var root = new PwGroupStub(); + bool result = KeeShareLogic.HasKeeShareGroups(root); + Assert.False(result); + } + + [Fact] + public void HasKeeShareGroups_WithKeeShareActiveTrue_ReturnsTrue() + { + var root = new PwGroupStub(); + root.CustomData.Set("KeeShare.Active", "true"); + bool result = KeeShareLogic.HasKeeShareGroups(root); + Assert.True(result); + } + + [Fact] + public void HasKeeShareGroups_WithKeeShareActiveFalse_ReturnsFalse() + { + var root = new PwGroupStub(); + root.CustomData.Set("KeeShare.Active", "false"); + bool result = KeeShareLogic.HasKeeShareGroups(root); + Assert.False(result); + } + + [Fact] + public void HasKeeShareGroups_WithNestedKeeShareGroup_ReturnsTrue() + { + var root = new PwGroupStub(); + var child1 = new PwGroupStub(); + var child2 = new PwGroupStub(); + child2.CustomData.Set("KeeShare.Active", "true"); + child1.AddGroup(child2, true); + root.AddGroup(child1, true); + bool result = KeeShareLogic.HasKeeShareGroups(root); + Assert.True(result); + } + + [Fact] + public void HasKeeShareGroups_WithMultipleNonKeeShareGroups_ReturnsFalse() + { + var root = new PwGroupStub(); + root.AddGroup(new PwGroupStub(), true); + root.AddGroup(new PwGroupStub(), true); + root.AddGroup(new PwGroupStub(), true); + bool result = KeeShareLogic.HasKeeShareGroups(root); + Assert.False(result); + } + + [Fact] + public void HasKeeShareGroups_WithDeeplyNestedKeeShareGroup_ReturnsTrue() + { + var root = new PwGroupStub(); + var level1 = new PwGroupStub(); + var level2 = new PwGroupStub(); + var level3 = new PwGroupStub(); + level3.CustomData.Set("KeeShare.Active", "true"); + level2.AddGroup(level3, true); + level1.AddGroup(level2, true); + root.AddGroup(level1, true); + bool result = KeeShareLogic.HasKeeShareGroups(root); + Assert.True(result); + } + } +} diff --git a/src/KeeShare.Tests/KeePassXCCompatibilityTests.cs b/src/KeeShare.Tests/KeePassXCCompatibilityTests.cs new file mode 100644 index 000000000..739468fbc --- /dev/null +++ b/src/KeeShare.Tests/KeePassXCCompatibilityTests.cs @@ -0,0 +1,266 @@ +using KeeShare.Tests.TestHelpers; + +namespace KeeShare.Tests +{ + public class KeePassXCCompatibilityTests + { + #region HasKeePassXCFormat Tests + + [Fact] + public void HasKeePassXCFormat_NullGroup_ReturnsFalse() + { + bool result = KeeShareCompatibilityLogic.HasKeePassXCFormat(null); + Assert.False(result); + } + + [Fact] + public void HasKeePassXCFormat_NoKeys_ReturnsFalse() + { + var group = new PwGroupStub(); + bool result = KeeShareCompatibilityLogic.HasKeePassXCFormat(group); + Assert.False(result); + } + + [Fact] + public void HasKeePassXCFormat_WithKeeShareReferencePath_ReturnsTrue() + { + var group = new PwGroupStub(); + group.CustomData.Set("KeeShareReference.Path", "/path/to/share.kdbx"); + + bool result = KeeShareCompatibilityLogic.HasKeePassXCFormat(group); + + Assert.True(result); + } + + [Fact] + public void HasKeePassXCFormat_WithKPXCKeeSharePath_ReturnsTrue() + { + var group = new PwGroupStub(); + group.CustomData.Set("KPXC_KeeShare_Path", "/path/to/share.kdbx"); + + bool result = KeeShareCompatibilityLogic.HasKeePassXCFormat(group); + + Assert.True(result); + } + + [Fact] + public void HasKeePassXCFormat_WithKeeShareKey_ReturnsTrue() + { + var group = new PwGroupStub(); + group.CustomData.Set("KeeShare", "path=\"/share.kdbx\""); + + bool result = KeeShareCompatibilityLogic.HasKeePassXCFormat(group); + + Assert.True(result); + } + + [Fact] + public void HasKeePassXCFormat_WithKeeShareAndActiveKey_ReturnsFalse() + { + // If both old KeeShare key and our Active key exist, it's already in KP2A format + var group = new PwGroupStub(); + group.CustomData.Set("KeeShare", "path=\"/share.kdbx\""); + group.CustomData.Set(KeeShareConfigLogic.ActiveKey, "true"); + + bool result = KeeShareCompatibilityLogic.HasKeePassXCFormat(group); + + Assert.False(result); + } + + [Fact] + public void HasKeePassXCFormat_WithOnlyActiveKey_ReturnsFalse() + { + var group = new PwGroupStub(); + group.CustomData.Set(KeeShareConfigLogic.ActiveKey, "true"); + + bool result = KeeShareCompatibilityLogic.HasKeePassXCFormat(group); + + Assert.False(result); + } + + #endregion + + #region TryImportKeePassXCConfig Tests + + [Fact] + public void TryImportKeePassXCConfig_NullGroup_ReturnsFalse() + { + bool result = KeeShareCompatibilityLogic.TryImportKeePassXCConfig(null); + Assert.False(result); + } + + [Fact] + public void TryImportKeePassXCConfig_NotKeePassXCFormat_ReturnsFalse() + { + var group = new PwGroupStub(); + bool result = KeeShareCompatibilityLogic.TryImportKeePassXCConfig(group); + Assert.False(result); + } + + [Fact] + public void TryImportKeePassXCConfig_AlreadyHasKP2AConfig_ReturnsFalse() + { + var group = new PwGroupStub(); + group.CustomData.Set("KeeShareReference.Path", "/path/to/share.kdbx"); + group.CustomData.Set(KeeShareConfigLogic.ActiveKey, "true"); + + bool result = KeeShareCompatibilityLogic.TryImportKeePassXCConfig(group); + + Assert.False(result); + } + + [Fact] + public void TryImportKeePassXCConfig_WithKeeShareReferencePath_ImportsSuccessfully() + { + var group = new PwGroupStub(); + group.CustomData.Set("KeeShareReference.Path", "/path/to/share.kdbx"); + + bool result = KeeShareCompatibilityLogic.TryImportKeePassXCConfig(group); + + Assert.True(result); + Assert.Equal("true", group.CustomData.Get(KeeShareConfigLogic.ActiveKey)); + Assert.Equal("/path/to/share.kdbx", group.CustomData.Get(KeeShareConfigLogic.FilePathKey)); + } + + [Fact] + public void TryImportKeePassXCConfig_WithKPXCKeeSharePath_ImportsSuccessfully() + { + var group = new PwGroupStub(); + group.CustomData.Set("KPXC_KeeShare_Path", "/kpxc/share.kdbx"); + + bool result = KeeShareCompatibilityLogic.TryImportKeePassXCConfig(group); + + Assert.True(result); + Assert.Equal("/kpxc/share.kdbx", group.CustomData.Get(KeeShareConfigLogic.FilePathKey)); + } + + [Fact] + public void TryImportKeePassXCConfig_DefaultsToSynchronizeType() + { + var group = new PwGroupStub(); + group.CustomData.Set("KeeShareReference.Path", "/path/to/share.kdbx"); + + KeeShareCompatibilityLogic.TryImportKeePassXCConfig(group); + + Assert.Equal("Synchronize", group.CustomData.Get(KeeShareConfigLogic.TypeKey)); + } + + [Theory] + [InlineData("Export", "Export")] + [InlineData("export", "Export")] + [InlineData("Import", "Import")] + [InlineData("import", "Import")] + [InlineData("Sync", "Synchronize")] + [InlineData("sync", "Synchronize")] + [InlineData("Synchronize", "Synchronize")] + public void TryImportKeePassXCConfig_MapsKeeShareReferenceType(string inputType, string expectedType) + { + var group = new PwGroupStub(); + group.CustomData.Set("KeeShareReference.Path", "/path.kdbx"); + group.CustomData.Set("KeeShareReference.Type", inputType); + + KeeShareCompatibilityLogic.TryImportKeePassXCConfig(group); + + Assert.Equal(expectedType, group.CustomData.Get(KeeShareConfigLogic.TypeKey)); + } + + [Theory] + [InlineData("0", "Export")] + [InlineData("1", "Import")] + [InlineData("2", "Synchronize")] + public void TryImportKeePassXCConfig_MapsKPXCTypeNumeric(string numericType, string expectedType) + { + var group = new PwGroupStub(); + group.CustomData.Set("KPXC_KeeShare_Path", "/path.kdbx"); + group.CustomData.Set("KPXC_KeeShare_Type", numericType); + + KeeShareCompatibilityLogic.TryImportKeePassXCConfig(group); + + Assert.Equal(expectedType, group.CustomData.Get(KeeShareConfigLogic.TypeKey)); + } + + [Fact] + public void TryImportKeePassXCConfig_ExtractsKeeShareReferencePassword() + { + var group = new PwGroupStub(); + group.CustomData.Set("KeeShareReference.Path", "/path.kdbx"); + group.CustomData.Set("KeeShareReference.Password", "mypassword"); + + KeeShareCompatibilityLogic.TryImportKeePassXCConfig(group); + + Assert.Equal("mypassword", group.CustomData.Get(KeeShareConfigLogic.PasswordKey)); + } + + [Fact] + public void TryImportKeePassXCConfig_ExtractsKPXCPassword() + { + var group = new PwGroupStub(); + group.CustomData.Set("KPXC_KeeShare_Path", "/path.kdbx"); + group.CustomData.Set("KPXC_KeeShare_Password", "kpxcpassword"); + + KeeShareCompatibilityLogic.TryImportKeePassXCConfig(group); + + Assert.Equal("kpxcpassword", group.CustomData.Get(KeeShareConfigLogic.PasswordKey)); + } + + [Fact] + public void TryImportKeePassXCConfig_ParsesPathFromKeeShareXml_DoubleQuotes() + { + var group = new PwGroupStub(); + group.CustomData.Set("KeeShare", "type=\"sync\" path=\"/xml/path.kdbx\""); + + bool result = KeeShareCompatibilityLogic.TryImportKeePassXCConfig(group); + + Assert.True(result); + Assert.Equal("/xml/path.kdbx", group.CustomData.Get(KeeShareConfigLogic.FilePathKey)); + } + + [Fact] + public void TryImportKeePassXCConfig_ParsesPathFromKeeShareXml_SingleQuotes() + { + var group = new PwGroupStub(); + group.CustomData.Set("KeeShare", "type='sync' path='/xml/single.kdbx'"); + + bool result = KeeShareCompatibilityLogic.TryImportKeePassXCConfig(group); + + Assert.True(result); + Assert.Equal("/xml/single.kdbx", group.CustomData.Get(KeeShareConfigLogic.FilePathKey)); + } + + [Fact] + public void TryImportKeePassXCConfig_NoPathFound_ReturnsFalse() + { + var group = new PwGroupStub(); + // Use data that truly has no "path=" pattern + group.CustomData.Set("KeeShare", "type='sync' location='here'"); + + bool result = KeeShareCompatibilityLogic.TryImportKeePassXCConfig(group); + + Assert.False(result); + } + + [Fact] + public void TryImportKeePassXCConfig_EmptyPath_ReturnsFalse() + { + var group = new PwGroupStub(); + group.CustomData.Set("KeeShareReference.Path", ""); + + bool result = KeeShareCompatibilityLogic.TryImportKeePassXCConfig(group); + + Assert.False(result); + } + + [Fact] + public void TryImportKeePassXCConfig_CallsTouch() + { + var group = new PwGroupStub(); + group.CustomData.Set("KeeShareReference.Path", "/path.kdbx"); + + KeeShareCompatibilityLogic.TryImportKeePassXCConfig(group); + + Assert.True(group.WasTouched); + } + + #endregion + } +} diff --git a/src/KeeShare.Tests/KeeShare.Tests.csproj b/src/KeeShare.Tests/KeeShare.Tests.csproj new file mode 100644 index 000000000..854b6b542 --- /dev/null +++ b/src/KeeShare.Tests/KeeShare.Tests.csproj @@ -0,0 +1,48 @@ + + + + net8.0 + enable + enable + + false + true + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + PreserveNewest + + + + + + + + + + diff --git a/src/KeeShare.Tests/KeeShareItemTests.cs b/src/KeeShare.Tests/KeeShareItemTests.cs new file mode 100644 index 000000000..e3b687867 --- /dev/null +++ b/src/KeeShare.Tests/KeeShareItemTests.cs @@ -0,0 +1,152 @@ +using KeeShare.Tests.TestHelpers; + +namespace KeeShare.Tests +{ + public class KeeShareItemTests + { + #region Constructor Tests + + [Fact] + public void Constructor_NullGroup_ThrowsArgumentNullException() + { + var db = new PwDatabaseStub(); + Assert.Throws(() => new KeeShareItemStub(null!, db)); + } + + [Fact] + public void Constructor_NullDatabase_ThrowsArgumentNullException() + { + var group = new PwGroupStub(); + Assert.Throws(() => new KeeShareItemStub(group, null!)); + } + + [Fact] + public void Constructor_ValidArguments_SetsProperties() + { + var group = new PwGroupStub(); + var db = new PwDatabaseStub(); + + var item = new KeeShareItemStub(group, db); + + Assert.Same(group, item.Group); + Assert.Same(db, item.Database); + } + + #endregion + + #region Type Property Tests + + [Fact] + public void Type_NoTypeSet_ReturnsEmptyString() + { + var group = new PwGroupStub(); + var item = new KeeShareItemStub(group, new PwDatabaseStub()); + + Assert.Equal("", item.Type); + } + + [Theory] + [InlineData("Export")] + [InlineData("Import")] + [InlineData("Synchronize")] + public void Type_TypeSet_ReturnsType(string type) + { + var group = new PwGroupStub(); + group.CustomData.Set(KeeShareConfigLogic.TypeKey, type); + var item = new KeeShareItemStub(group, new PwDatabaseStub()); + + Assert.Equal(type, item.Type); + } + + #endregion + + #region OriginalPath Property Tests + + [Fact] + public void OriginalPath_NoPathSet_ReturnsEmptyString() + { + var group = new PwGroupStub(); + var item = new KeeShareItemStub(group, new PwDatabaseStub()); + + Assert.Equal("", item.OriginalPath); + } + + [Fact] + public void OriginalPath_PathSet_ReturnsPath() + { + var group = new PwGroupStub(); + group.CustomData.Set(KeeShareConfigLogic.FilePathKey, "/path/to/share.kdbx"); + var item = new KeeShareItemStub(group, new PwDatabaseStub()); + + Assert.Equal("/path/to/share.kdbx", item.OriginalPath); + } + + #endregion + + #region Password Property Tests + + [Fact] + public void Password_NoPasswordSet_ReturnsEmptyString() + { + var group = new PwGroupStub(); + var item = new KeeShareItemStub(group, new PwDatabaseStub()); + + Assert.Equal("", item.Password); + } + + [Fact] + public void Password_PasswordSet_ReturnsPassword() + { + var group = new PwGroupStub(); + group.CustomData.Set(KeeShareConfigLogic.PasswordKey, "secret123"); + var item = new KeeShareItemStub(group, new PwDatabaseStub()); + + Assert.Equal("secret123", item.Password); + } + + #endregion + + #region IsActive Property Tests + + [Fact] + public void IsActive_NoActiveKey_ReturnsFalse() + { + var group = new PwGroupStub(); + var item = new KeeShareItemStub(group, new PwDatabaseStub()); + + Assert.False(item.IsActive); + } + + [Fact] + public void IsActive_ActiveKeyTrue_ReturnsTrue() + { + var group = new PwGroupStub(); + group.CustomData.Set(KeeShareConfigLogic.ActiveKey, "true"); + var item = new KeeShareItemStub(group, new PwDatabaseStub()); + + Assert.True(item.IsActive); + } + + [Fact] + public void IsActive_ActiveKeyFalse_ReturnsFalse() + { + var group = new PwGroupStub(); + group.CustomData.Set(KeeShareConfigLogic.ActiveKey, "false"); + var item = new KeeShareItemStub(group, new PwDatabaseStub()); + + Assert.False(item.IsActive); + } + + [Fact] + public void IsActive_ActiveKeyOtherValue_ReturnsFalse() + { + var group = new PwGroupStub(); + group.CustomData.Set(KeeShareConfigLogic.ActiveKey, "yes"); + var item = new KeeShareItemStub(group, new PwDatabaseStub()); + + Assert.False(item.IsActive); + } + + #endregion + } +} diff --git a/src/KeeShare.Tests/SignatureVerificationTests.cs b/src/KeeShare.Tests/SignatureVerificationTests.cs new file mode 100644 index 000000000..e07320f45 --- /dev/null +++ b/src/KeeShare.Tests/SignatureVerificationTests.cs @@ -0,0 +1,222 @@ +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Text; +using KeeShare.Tests.TestHelpers; + +namespace KeeShare.Tests +{ + public class SignatureVerificationTests + { + /// + /// Helper to convert bytes to hex string (KeeShare format) + /// + private static string BytesToHex(byte[] bytes) + { + return BitConverter.ToString(bytes).Replace("-", "").ToLowerInvariant(); + } + + /// + /// Helper to format signature in KeeShare format: "rsa|" + /// + private static byte[] FormatKeeShareSignature(byte[] signature) + { + string hex = BytesToHex(signature); + return Encoding.UTF8.GetBytes($"rsa|{hex}"); + } + + [Fact] + public void VerifySignature_WithValidSignature_ReturnsTrue() + { + using var rsa = RSA.Create(2048); + var publicKeyBytes = rsa.ExportSubjectPublicKeyInfo(); + var publicKeyCert = Convert.ToBase64String(publicKeyBytes); + byte[] testData = Encoding.UTF8.GetBytes("Test KDBX data content"); + byte[] hash = SHA256.HashData(testData); + byte[] signature = rsa.SignHash(hash, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + byte[] signatureData = FormatKeeShareSignature(signature); + bool result = KeeShareTestHelpers.VerifySignatureCore(publicKeyCert, testData, signatureData); + Assert.True(result, "Signature verification should succeed with valid signature"); + } + + [Fact] + public void VerifySignature_WithValidSignatureWithoutPrefix_ReturnsTrue() + { + using var rsa = RSA.Create(2048); + var publicKeyBytes = rsa.ExportSubjectPublicKeyInfo(); + var publicKeyCert = Convert.ToBase64String(publicKeyBytes); + byte[] testData = Encoding.UTF8.GetBytes("Test KDBX data content"); + byte[] hash = SHA256.HashData(testData); + byte[] signature = rsa.SignHash(hash, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + // Hex without "rsa|" prefix should also work + string signatureHex = BytesToHex(signature); + byte[] signatureData = Encoding.UTF8.GetBytes(signatureHex); + bool result = KeeShareTestHelpers.VerifySignatureCore(publicKeyCert, testData, signatureData); + Assert.True(result, "Signature verification should succeed with hex signature without prefix"); + } + + [Fact] + public void VerifySignature_WithInvalidSignature_ReturnsFalse() + { + using var rsa = RSA.Create(2048); + var publicKeyBytes = rsa.ExportSubjectPublicKeyInfo(); + var publicKeyCert = Convert.ToBase64String(publicKeyBytes); + byte[] testData = Encoding.UTF8.GetBytes("Test KDBX data content"); + byte[] invalidSignature = new byte[256]; + new Random().NextBytes(invalidSignature); + byte[] signatureData = FormatKeeShareSignature(invalidSignature); + bool result = KeeShareTestHelpers.VerifySignatureCore(publicKeyCert, testData, signatureData); + Assert.False(result, "Signature verification should fail with invalid signature"); + } + + [Fact] + public void VerifySignature_WithTamperedData_ReturnsFalse() + { + using var rsa = RSA.Create(2048); + var publicKeyBytes = rsa.ExportSubjectPublicKeyInfo(); + var publicKeyCert = Convert.ToBase64String(publicKeyBytes); + byte[] originalData = Encoding.UTF8.GetBytes("Original KDBX data"); + byte[] hash = SHA256.HashData(originalData); + byte[] signature = rsa.SignHash(hash, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + byte[] signatureData = FormatKeeShareSignature(signature); + byte[] tamperedData = Encoding.UTF8.GetBytes("Tampered KDBX data"); + bool result = KeeShareTestHelpers.VerifySignatureCore(publicKeyCert, tamperedData, signatureData); + Assert.False(result, "Signature verification should fail when data is tampered"); + } + + [Fact] + public void VerifySignature_WithPemFormattedPublicKey_ReturnsTrue() + { + using var rsa = RSA.Create(2048); + var publicKeyBytes = rsa.ExportSubjectPublicKeyInfo(); + var publicKeyCertBase64 = Convert.ToBase64String(publicKeyBytes, Base64FormattingOptions.InsertLineBreaks); + string publicKeyPem = $"-----BEGIN PUBLIC KEY-----\n{publicKeyCertBase64}\n-----END PUBLIC KEY-----"; + byte[] testData = Encoding.UTF8.GetBytes("Test KDBX data content"); + byte[] hash = SHA256.HashData(testData); + byte[] signature = rsa.SignHash(hash, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + byte[] signatureData = FormatKeeShareSignature(signature); + bool result = KeeShareTestHelpers.VerifySignatureCore(publicKeyPem, testData, signatureData); + Assert.True(result, "Signature verification should work with PEM formatted public key"); + } + + [Fact] + public void VerifySignature_WithEmptyCertificate_ReturnsFalse() + { + byte[] testData = Encoding.UTF8.GetBytes("Test data"); + byte[] signatureData = Encoding.UTF8.GetBytes("rsa|abcd1234"); + bool result = KeeShareTestHelpers.VerifySignatureCore("", testData, signatureData); + Assert.False(result, "Signature verification should fail with empty certificate"); + } + + [Fact] + public void VerifySignature_WithNullData_ReturnsFalse() + { + using var rsa = RSA.Create(2048); + var publicKeyBytes = rsa.ExportSubjectPublicKeyInfo(); + var publicKeyCert = Convert.ToBase64String(publicKeyBytes); + byte[] signatureData = Encoding.UTF8.GetBytes("rsa|abcd1234"); + bool result = KeeShareTestHelpers.VerifySignatureCore(publicKeyCert, null!, signatureData); + Assert.False(result, "Signature verification should fail with null data"); + } + + [Fact] + public void VerifySignature_WithEmptyData_ReturnsFalse() + { + using var rsa = RSA.Create(2048); + var publicKeyBytes = rsa.ExportSubjectPublicKeyInfo(); + var publicKeyCert = Convert.ToBase64String(publicKeyBytes); + byte[] signatureData = Encoding.UTF8.GetBytes("rsa|abcd1234"); + bool result = KeeShareTestHelpers.VerifySignatureCore(publicKeyCert, Array.Empty(), signatureData); + Assert.False(result, "Signature verification should fail with empty data"); + } + + [Fact] + public void VerifySignature_WithMalformedHexSignature_ReturnsFalse() + { + using var rsa = RSA.Create(2048); + var publicKeyBytes = rsa.ExportSubjectPublicKeyInfo(); + var publicKeyCert = Convert.ToBase64String(publicKeyBytes); + byte[] testData = Encoding.UTF8.GetBytes("Test data"); + // Invalid hex characters + byte[] signatureData = Encoding.UTF8.GetBytes("rsa|not-valid-hex!@#$GHIJ"); + bool result = KeeShareTestHelpers.VerifySignatureCore(publicKeyCert, testData, signatureData); + Assert.False(result, "Signature verification should fail with malformed hex"); + } + + [Fact] + public void VerifySignature_WithOddLengthHex_ReturnsFalse() + { + using var rsa = RSA.Create(2048); + var publicKeyBytes = rsa.ExportSubjectPublicKeyInfo(); + var publicKeyCert = Convert.ToBase64String(publicKeyBytes); + byte[] testData = Encoding.UTF8.GetBytes("Test data"); + // Odd-length hex string (invalid) + byte[] signatureData = Encoding.UTF8.GetBytes("rsa|abc"); + bool result = KeeShareTestHelpers.VerifySignatureCore(publicKeyCert, testData, signatureData); + Assert.False(result, "Signature verification should fail with odd-length hex"); + } + + [Fact] + public void VerifySignature_WithSignatureContainingWhitespace_ReturnsTrue() + { + using var rsa = RSA.Create(2048); + var publicKeyBytes = rsa.ExportSubjectPublicKeyInfo(); + var publicKeyCert = Convert.ToBase64String(publicKeyBytes); + byte[] testData = Encoding.UTF8.GetBytes("Test KDBX data content"); + byte[] hash = SHA256.HashData(testData); + byte[] signature = rsa.SignHash(hash, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + string signatureHex = BytesToHex(signature); + // Add whitespace around the signature + string signatureWithWhitespace = $"\r\n rsa|{signatureHex} \r\n"; + byte[] signatureData = Encoding.UTF8.GetBytes(signatureWithWhitespace); + bool result = KeeShareTestHelpers.VerifySignatureCore(publicKeyCert, testData, signatureData); + Assert.True(result, "Signature verification should handle whitespace in signature"); + } + + [Fact] + public void VerifySignature_WithUppercaseHex_ReturnsTrue() + { + using var rsa = RSA.Create(2048); + var publicKeyBytes = rsa.ExportSubjectPublicKeyInfo(); + var publicKeyCert = Convert.ToBase64String(publicKeyBytes); + byte[] testData = Encoding.UTF8.GetBytes("Test KDBX data content"); + byte[] hash = SHA256.HashData(testData); + byte[] signature = rsa.SignHash(hash, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + // Use uppercase hex + string signatureHex = BitConverter.ToString(signature).Replace("-", "").ToUpperInvariant(); + byte[] signatureData = Encoding.UTF8.GetBytes($"rsa|{signatureHex}"); + bool result = KeeShareTestHelpers.VerifySignatureCore(publicKeyCert, testData, signatureData); + Assert.True(result, "Signature verification should handle uppercase hex"); + } + + [Fact] + public void VerifySignature_WithUppercaseRsaPrefix_ReturnsTrue() + { + using var rsa = RSA.Create(2048); + var publicKeyBytes = rsa.ExportSubjectPublicKeyInfo(); + var publicKeyCert = Convert.ToBase64String(publicKeyBytes); + byte[] testData = Encoding.UTF8.GetBytes("Test KDBX data content"); + byte[] hash = SHA256.HashData(testData); + byte[] signature = rsa.SignHash(hash, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + string signatureHex = BytesToHex(signature); + // Use uppercase "RSA|" prefix + byte[] signatureData = Encoding.UTF8.GetBytes($"RSA|{signatureHex}"); + bool result = KeeShareTestHelpers.VerifySignatureCore(publicKeyCert, testData, signatureData); + Assert.True(result, "Signature verification should handle uppercase RSA prefix"); + } + + [Fact] + public void VerifySignature_WithX509Certificate_ReturnsTrue() + { + using var rsa = RSA.Create(2048); + var request = new CertificateRequest("CN=Test", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + var certificate = request.CreateSelfSigned(DateTimeOffset.Now, DateTimeOffset.Now.AddYears(1)); + string certificatePem = certificate.ExportCertificatePem(); + byte[] testData = Encoding.UTF8.GetBytes("Test KDBX data content"); + byte[] hash = SHA256.HashData(testData); + byte[] signature = rsa.SignHash(hash, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + byte[] signatureData = FormatKeeShareSignature(signature); + bool result = KeeShareTestHelpers.VerifySignatureCore(certificatePem, testData, signatureData); + Assert.True(result, "Signature verification should work with X.509 certificate format"); + } + } +} diff --git a/src/KeeShare.Tests/StateCheckingTests.cs b/src/KeeShare.Tests/StateCheckingTests.cs new file mode 100644 index 000000000..f63d1cf3e --- /dev/null +++ b/src/KeeShare.Tests/StateCheckingTests.cs @@ -0,0 +1,504 @@ +using KeeShare.Tests.TestHelpers; + +namespace KeeShare.Tests +{ + public class StateCheckingTests + { + public StateCheckingTests() + { + // Reset device ID before each test + KeeShareConfigLogic.TestDeviceId = "test-device-id"; + } + + #region HasDeviceFilePath Tests + + [Fact] + public void HasDeviceFilePath_NullGroup_ReturnsFalse() + { + bool result = KeeShareStateLogic.HasDeviceFilePath(null); + Assert.False(result); + } + + [Fact] + public void HasDeviceFilePath_NoDeviceKey_ReturnsFalse() + { + var group = new PwGroupStub(); + bool result = KeeShareStateLogic.HasDeviceFilePath(group); + Assert.False(result); + } + + [Fact] + public void HasDeviceFilePath_EmptyDeviceValue_ReturnsFalse() + { + var group = new PwGroupStub(); + string deviceKey = KeeShareConfigLogic.GetDeviceFilePathKey(); + group.CustomData.Set(deviceKey, ""); + + bool result = KeeShareStateLogic.HasDeviceFilePath(group); + + Assert.False(result); + } + + [Fact] + public void HasDeviceFilePath_HasDeviceValue_ReturnsTrue() + { + var group = new PwGroupStub(); + string deviceKey = KeeShareConfigLogic.GetDeviceFilePathKey(); + group.CustomData.Set(deviceKey, "/device/path.kdbx"); + + bool result = KeeShareStateLogic.HasDeviceFilePath(group); + + Assert.True(result); + } + + #endregion + + #region IsEnabledOnThisDevice Tests + + [Fact] + public void IsEnabledOnThisDevice_NullGroup_ReturnsFalse() + { + bool result = KeeShareStateLogic.IsEnabledOnThisDevice(null); + Assert.False(result); + } + + [Fact] + public void IsEnabledOnThisDevice_NoEffectivePath_ReturnsFalse() + { + var group = new PwGroupStub(); + bool result = KeeShareStateLogic.IsEnabledOnThisDevice(group); + Assert.False(result); + } + + [Fact] + public void IsEnabledOnThisDevice_HasDevicePath_ReturnsTrue() + { + var group = new PwGroupStub(); + string deviceKey = KeeShareConfigLogic.GetDeviceFilePathKey(); + group.CustomData.Set(deviceKey, "/device/path.kdbx"); + + bool result = KeeShareStateLogic.IsEnabledOnThisDevice(group); + + Assert.True(result); + } + + [Fact] + public void IsEnabledOnThisDevice_HasFallbackPath_ReturnsTrue() + { + var group = new PwGroupStub(); + group.CustomData.Set(KeeShareConfigLogic.FilePathKey, "/fallback/path.kdbx"); + + bool result = KeeShareStateLogic.IsEnabledOnThisDevice(group); + + Assert.True(result); + } + + #endregion + + #region GetKeeShareItems Tests + + [Fact] + public void GetKeeShareItems_NullDb_ReturnsEmptyList() + { + var items = KeeShareStateLogic.GetKeeShareItems(null); + Assert.Empty(items); + } + + [Fact] + public void GetKeeShareItems_ClosedDb_ReturnsEmptyList() + { + var db = new PwDatabaseStub { IsOpen = false, RootGroup = new PwGroupStub() }; + var items = KeeShareStateLogic.GetKeeShareItems(db); + Assert.Empty(items); + } + + [Fact] + public void GetKeeShareItems_NullRootGroup_ReturnsEmptyList() + { + var db = new PwDatabaseStub { IsOpen = true, RootGroup = null }; + var items = KeeShareStateLogic.GetKeeShareItems(db); + Assert.Empty(items); + } + + [Fact] + public void GetKeeShareItems_NoKeeShareGroups_ReturnsEmptyList() + { + var db = new PwDatabaseStub { IsOpen = true, RootGroup = new PwGroupStub() }; + var items = KeeShareStateLogic.GetKeeShareItems(db); + Assert.Empty(items); + } + + [Fact] + public void GetKeeShareItems_RootLevelKeeShare_ReturnsItem() + { + var root = new PwGroupStub(); + var keeShareGroup = new PwGroupStub { Name = "Shared" }; + keeShareGroup.CustomData.Set(KeeShareConfigLogic.ActiveKey, "true"); + root.AddGroup(keeShareGroup); + + var db = new PwDatabaseStub { IsOpen = true, RootGroup = root }; + + var items = KeeShareStateLogic.GetKeeShareItems(db); + + Assert.Single(items); + Assert.Same(keeShareGroup, items[0].Group); + } + + [Fact] + public void GetKeeShareItems_NestedKeeShare_ReturnsItem() + { + var root = new PwGroupStub(); + var level1 = new PwGroupStub { Name = "Level1" }; + var level2 = new PwGroupStub { Name = "Shared" }; + level2.CustomData.Set(KeeShareConfigLogic.ActiveKey, "true"); + level1.AddGroup(level2); + root.AddGroup(level1); + + var db = new PwDatabaseStub { IsOpen = true, RootGroup = root }; + + var items = KeeShareStateLogic.GetKeeShareItems(db); + + Assert.Single(items); + Assert.Same(level2, items[0].Group); + } + + [Fact] + public void GetKeeShareItems_MultipleKeeShareGroups_ReturnsAll() + { + var root = new PwGroupStub(); + var group1 = new PwGroupStub { Name = "Shared1" }; + group1.CustomData.Set(KeeShareConfigLogic.ActiveKey, "true"); + var group2 = new PwGroupStub { Name = "Shared2" }; + group2.CustomData.Set(KeeShareConfigLogic.ActiveKey, "true"); + root.AddGroup(group1); + root.AddGroup(group2); + + var db = new PwDatabaseStub { IsOpen = true, RootGroup = root }; + + var items = KeeShareStateLogic.GetKeeShareItems(db); + + Assert.Equal(2, items.Count); + } + + [Fact] + public void GetKeeShareItems_InactiveKeeShare_NotIncluded() + { + var root = new PwGroupStub(); + var group = new PwGroupStub { Name = "NotActive" }; + group.CustomData.Set(KeeShareConfigLogic.ActiveKey, "false"); + root.AddGroup(group); + + var db = new PwDatabaseStub { IsOpen = true, RootGroup = root }; + + var items = KeeShareStateLogic.GetKeeShareItems(db); + + Assert.Empty(items); + } + + #endregion + + #region HasExportableKeeShareGroups Tests + + [Fact] + public void HasExportableKeeShareGroups_NullDb_ReturnsFalse() + { + bool result = KeeShareStateLogic.HasExportableKeeShareGroups(null); + Assert.False(result); + } + + [Fact] + public void HasExportableKeeShareGroups_ClosedDb_ReturnsFalse() + { + var db = new PwDatabaseStub { IsOpen = false, RootGroup = new PwGroupStub() }; + bool result = KeeShareStateLogic.HasExportableKeeShareGroups(db); + Assert.False(result); + } + + [Fact] + public void HasExportableKeeShareGroups_NullRootGroup_ReturnsFalse() + { + var db = new PwDatabaseStub { IsOpen = true, RootGroup = null }; + bool result = KeeShareStateLogic.HasExportableKeeShareGroups(db); + Assert.False(result); + } + + [Fact] + public void HasExportableKeeShareGroups_NoKeeShareGroups_ReturnsFalse() + { + var db = new PwDatabaseStub { IsOpen = true, RootGroup = new PwGroupStub() }; + bool result = KeeShareStateLogic.HasExportableKeeShareGroups(db); + Assert.False(result); + } + + [Fact] + public void HasExportableKeeShareGroups_ImportOnlyGroup_ReturnsFalse() + { + var root = new PwGroupStub(); + var group = new PwGroupStub(); + group.CustomData.Set(KeeShareConfigLogic.ActiveKey, "true"); + group.CustomData.Set(KeeShareConfigLogic.TypeKey, "Import"); + group.CustomData.Set(KeeShareConfigLogic.FilePathKey, "/path.kdbx"); + root.AddGroup(group); + + var db = new PwDatabaseStub { IsOpen = true, RootGroup = root }; + + bool result = KeeShareStateLogic.HasExportableKeeShareGroups(db); + + Assert.False(result); + } + + [Fact] + public void HasExportableKeeShareGroups_ExportGroupWithPath_ReturnsTrue() + { + var root = new PwGroupStub(); + var group = new PwGroupStub(); + group.CustomData.Set(KeeShareConfigLogic.ActiveKey, "true"); + group.CustomData.Set(KeeShareConfigLogic.TypeKey, "Export"); + group.CustomData.Set(KeeShareConfigLogic.FilePathKey, "/path.kdbx"); + root.AddGroup(group); + + var db = new PwDatabaseStub { IsOpen = true, RootGroup = root }; + + bool result = KeeShareStateLogic.HasExportableKeeShareGroups(db); + + Assert.True(result); + } + + [Fact] + public void HasExportableKeeShareGroups_SynchronizeGroupWithPath_ReturnsTrue() + { + var root = new PwGroupStub(); + var group = new PwGroupStub(); + group.CustomData.Set(KeeShareConfigLogic.ActiveKey, "true"); + group.CustomData.Set(KeeShareConfigLogic.TypeKey, "Synchronize"); + group.CustomData.Set(KeeShareConfigLogic.FilePathKey, "/path.kdbx"); + root.AddGroup(group); + + var db = new PwDatabaseStub { IsOpen = true, RootGroup = root }; + + bool result = KeeShareStateLogic.HasExportableKeeShareGroups(db); + + Assert.True(result); + } + + [Fact] + public void HasExportableKeeShareGroups_ExportGroupWithoutPath_ReturnsFalse() + { + var root = new PwGroupStub(); + var group = new PwGroupStub(); + group.CustomData.Set(KeeShareConfigLogic.ActiveKey, "true"); + group.CustomData.Set(KeeShareConfigLogic.TypeKey, "Export"); + // No file path set + root.AddGroup(group); + + var db = new PwDatabaseStub { IsOpen = true, RootGroup = root }; + + bool result = KeeShareStateLogic.HasExportableKeeShareGroups(db); + + Assert.False(result); + } + + [Fact] + public void HasExportableKeeShareGroups_ExportGroupWithDevicePath_ReturnsTrue() + { + var root = new PwGroupStub(); + var group = new PwGroupStub(); + group.CustomData.Set(KeeShareConfigLogic.ActiveKey, "true"); + group.CustomData.Set(KeeShareConfigLogic.TypeKey, "Export"); + string deviceKey = KeeShareConfigLogic.GetDeviceFilePathKey(); + group.CustomData.Set(deviceKey, "/device/path.kdbx"); + root.AddGroup(group); + + var db = new PwDatabaseStub { IsOpen = true, RootGroup = root }; + + bool result = KeeShareStateLogic.HasExportableKeeShareGroups(db); + + Assert.True(result); + } + + [Fact] + public void HasExportableKeeShareGroups_NestedExportGroup_ReturnsTrue() + { + var root = new PwGroupStub(); + var level1 = new PwGroupStub(); + var exportGroup = new PwGroupStub(); + exportGroup.CustomData.Set(KeeShareConfigLogic.ActiveKey, "true"); + exportGroup.CustomData.Set(KeeShareConfigLogic.TypeKey, "Export"); + exportGroup.CustomData.Set(KeeShareConfigLogic.FilePathKey, "/path.kdbx"); + level1.AddGroup(exportGroup); + root.AddGroup(level1); + + var db = new PwDatabaseStub { IsOpen = true, RootGroup = root }; + + bool result = KeeShareStateLogic.HasExportableKeeShareGroups(db); + + Assert.True(result); + } + + #endregion + + #region IsReadOnlyBecauseKeeShareImport (Group) Tests + + [Fact] + public void IsReadOnlyBecauseKeeShareImport_NullGroup_ReturnsFalse() + { + bool result = KeeShareStateLogic.IsReadOnlyBecauseKeeShareImport((PwGroupStub?)null); + Assert.False(result); + } + + [Fact] + public void IsReadOnlyBecauseKeeShareImport_NotInKeeShare_ReturnsFalse() + { + var group = new PwGroupStub(); + bool result = KeeShareStateLogic.IsReadOnlyBecauseKeeShareImport(group); + Assert.False(result); + } + + [Fact] + public void IsReadOnlyBecauseKeeShareImport_InExportGroup_ReturnsFalse() + { + var exportGroup = new PwGroupStub(); + exportGroup.CustomData.Set(KeeShareConfigLogic.ActiveKey, "true"); + exportGroup.CustomData.Set(KeeShareConfigLogic.TypeKey, "Export"); + var child = new PwGroupStub(); + exportGroup.AddGroup(child); + + bool result = KeeShareStateLogic.IsReadOnlyBecauseKeeShareImport(child); + + Assert.False(result); + } + + [Fact] + public void IsReadOnlyBecauseKeeShareImport_InSynchronizeGroup_ReturnsFalse() + { + var syncGroup = new PwGroupStub(); + syncGroup.CustomData.Set(KeeShareConfigLogic.ActiveKey, "true"); + syncGroup.CustomData.Set(KeeShareConfigLogic.TypeKey, "Synchronize"); + var child = new PwGroupStub(); + syncGroup.AddGroup(child); + + bool result = KeeShareStateLogic.IsReadOnlyBecauseKeeShareImport(child); + + Assert.False(result); + } + + [Fact] + public void IsReadOnlyBecauseKeeShareImport_IsImportGroup_ReturnsTrue() + { + var importGroup = new PwGroupStub(); + importGroup.CustomData.Set(KeeShareConfigLogic.ActiveKey, "true"); + importGroup.CustomData.Set(KeeShareConfigLogic.TypeKey, "Import"); + + bool result = KeeShareStateLogic.IsReadOnlyBecauseKeeShareImport(importGroup); + + Assert.True(result); + } + + [Fact] + public void IsReadOnlyBecauseKeeShareImport_ParentIsImport_ReturnsTrue() + { + var importGroup = new PwGroupStub(); + importGroup.CustomData.Set(KeeShareConfigLogic.ActiveKey, "true"); + importGroup.CustomData.Set(KeeShareConfigLogic.TypeKey, "Import"); + var child = new PwGroupStub(); + importGroup.AddGroup(child); + + bool result = KeeShareStateLogic.IsReadOnlyBecauseKeeShareImport(child); + + Assert.True(result); + } + + [Fact] + public void IsReadOnlyBecauseKeeShareImport_AncestorIsImport_ReturnsTrue() + { + var importGroup = new PwGroupStub(); + importGroup.CustomData.Set(KeeShareConfigLogic.ActiveKey, "true"); + importGroup.CustomData.Set(KeeShareConfigLogic.TypeKey, "Import"); + var level1 = new PwGroupStub(); + var level2 = new PwGroupStub(); + importGroup.AddGroup(level1); + level1.AddGroup(level2); + + bool result = KeeShareStateLogic.IsReadOnlyBecauseKeeShareImport(level2); + + Assert.True(result); + } + + [Fact] + public void IsReadOnlyBecauseKeeShareImport_ActiveFalseImportType_ReturnsFalse() + { + var group = new PwGroupStub(); + group.CustomData.Set(KeeShareConfigLogic.ActiveKey, "false"); + group.CustomData.Set(KeeShareConfigLogic.TypeKey, "Import"); + + bool result = KeeShareStateLogic.IsReadOnlyBecauseKeeShareImport(group); + + Assert.False(result); + } + + #endregion + + #region IsReadOnlyBecauseKeeShareImport (Entry) Tests + + [Fact] + public void IsReadOnlyBecauseKeeShareImport_NullEntry_ReturnsFalse() + { + bool result = KeeShareStateLogic.IsReadOnlyBecauseKeeShareImport((PwEntryStub?)null); + Assert.False(result); + } + + [Fact] + public void IsReadOnlyBecauseKeeShareImport_EntryWithNullParent_ReturnsFalse() + { + var entry = new PwEntryStub { ParentGroup = null }; + bool result = KeeShareStateLogic.IsReadOnlyBecauseKeeShareImport(entry); + Assert.False(result); + } + + [Fact] + public void IsReadOnlyBecauseKeeShareImport_EntryInImportGroup_ReturnsTrue() + { + var importGroup = new PwGroupStub(); + importGroup.CustomData.Set(KeeShareConfigLogic.ActiveKey, "true"); + importGroup.CustomData.Set(KeeShareConfigLogic.TypeKey, "Import"); + var entry = new PwEntryStub(); + importGroup.AddEntry(entry); + + bool result = KeeShareStateLogic.IsReadOnlyBecauseKeeShareImport(entry); + + Assert.True(result); + } + + [Fact] + public void IsReadOnlyBecauseKeeShareImport_EntryInExportGroup_ReturnsFalse() + { + var exportGroup = new PwGroupStub(); + exportGroup.CustomData.Set(KeeShareConfigLogic.ActiveKey, "true"); + exportGroup.CustomData.Set(KeeShareConfigLogic.TypeKey, "Export"); + var entry = new PwEntryStub(); + exportGroup.AddEntry(entry); + + bool result = KeeShareStateLogic.IsReadOnlyBecauseKeeShareImport(entry); + + Assert.False(result); + } + + [Fact] + public void IsReadOnlyBecauseKeeShareImport_EntryInNestedImportGroup_ReturnsTrue() + { + var importGroup = new PwGroupStub(); + importGroup.CustomData.Set(KeeShareConfigLogic.ActiveKey, "true"); + importGroup.CustomData.Set(KeeShareConfigLogic.TypeKey, "Import"); + var childGroup = new PwGroupStub(); + importGroup.AddGroup(childGroup); + var entry = new PwEntryStub(); + childGroup.AddEntry(entry); + + bool result = KeeShareStateLogic.IsReadOnlyBecauseKeeShareImport(entry); + + Assert.True(result); + } + + #endregion + } +} diff --git a/src/KeeShare.Tests/TestHelpers/KeeShareCompatibilityLogic.cs b/src/KeeShare.Tests/TestHelpers/KeeShareCompatibilityLogic.cs new file mode 100644 index 000000000..602066b2a --- /dev/null +++ b/src/KeeShare.Tests/TestHelpers/KeeShareCompatibilityLogic.cs @@ -0,0 +1,132 @@ +namespace KeeShare.Tests.TestHelpers +{ + /// + /// Copy of KeeShare KeePassXC compatibility logic for testing without Android dependencies. + /// Kept in sync with KeeShare.cs in the app project (lines 197-308). + /// Last synced: 2025-01-27 + /// + public static class KeeShareCompatibilityLogic + { + /// + /// Checks if a group has KeePassXC-style KeeShare configuration. + /// KeePassXC stores share info in CustomData with keys like "KeeShareReference.Path", etc. + /// + public static bool HasKeePassXCFormat(PwGroupStub? group) + { + if (group == null) return false; + + // Check for KeePassXC's CustomData keys + return group.CustomData.Exists("KeeShareReference.Path") || + group.CustomData.Exists("KPXC_KeeShare_Path") || + (group.CustomData.Exists("KeeShare") && + !group.CustomData.Exists(KeeShareConfigLogic.ActiveKey)); // Has old KeeShare key but not our format + } + + /// + /// Attempts to import KeePassXC KeeShare configuration into KP2A format. + /// This allows groups created in KeePassXC to work in KP2A. + /// Does NOT overwrite existing KP2A configuration. + /// + /// True if configuration was imported, false otherwise. + public static bool TryImportKeePassXCConfig(PwGroupStub? group) + { + if (group == null || !HasKeePassXCFormat(group)) return false; + + // Don't overwrite existing KP2A configuration + if (group.CustomData.Exists(KeeShareConfigLogic.ActiveKey)) return false; + + try + { + string? path = null; + string type = "Synchronize"; // KeePassXC default + string? password = null; + + // Try to extract path from various KeePassXC formats + if (group.CustomData.Exists("KeeShareReference.Path")) + { + path = group.CustomData.Get("KeeShareReference.Path"); + } + else if (group.CustomData.Exists("KPXC_KeeShare_Path")) + { + path = group.CustomData.Get("KPXC_KeeShare_Path"); + } + else if (group.CustomData.Exists("KeeShare")) + { + // Try to parse XML or structured format + string? data = group.CustomData.Get("KeeShare"); + if (data != null) + { + // Simple heuristic: look for path= pattern + int pathIdx = data.IndexOf("path=", StringComparison.OrdinalIgnoreCase); + if (pathIdx >= 0) + { + pathIdx += 5; // skip "path=" + if (pathIdx < data.Length) + { + char quote = data[pathIdx]; + if (quote == '"' || quote == '\'') + { + pathIdx++; + int endIdx = data.IndexOf(quote, pathIdx); + if (endIdx > pathIdx) + { + path = data.Substring(pathIdx, endIdx - pathIdx); + } + } + } + } + } + } + + // Try to extract type + if (group.CustomData.Exists("KeeShareReference.Type")) + { + string? xtype = group.CustomData.Get("KeeShareReference.Type"); + if (xtype != null) + { + // Map KeePassXC types to KP2A types + if (xtype.Equals("Export", StringComparison.OrdinalIgnoreCase)) + type = "Export"; + else if (xtype.Equals("Import", StringComparison.OrdinalIgnoreCase)) + type = "Import"; + else if (xtype.Equals("Sync", StringComparison.OrdinalIgnoreCase) || + xtype.Equals("Synchronize", StringComparison.OrdinalIgnoreCase)) + type = "Synchronize"; + } + } + else if (group.CustomData.Exists("KPXC_KeeShare_Type")) + { + string? xtype = group.CustomData.Get("KPXC_KeeShare_Type"); + if (xtype != null) + { + if (xtype.Equals("0")) type = "Export"; + else if (xtype.Equals("1")) type = "Import"; + else if (xtype.Equals("2")) type = "Synchronize"; + } + } + + // Try to extract password + if (group.CustomData.Exists("KeeShareReference.Password")) + { + password = group.CustomData.Get("KeeShareReference.Password"); + } + else if (group.CustomData.Exists("KPXC_KeeShare_Password")) + { + password = group.CustomData.Get("KPXC_KeeShare_Password"); + } + + if (!string.IsNullOrEmpty(path)) + { + KeeShareConfigLogic.EnableKeeShare(group, type, path, password); + return true; + } + } + catch + { + // Silently fail on import errors + } + + return false; + } + } +} diff --git a/src/KeeShare.Tests/TestHelpers/KeeShareConfigLogic.cs b/src/KeeShare.Tests/TestHelpers/KeeShareConfigLogic.cs new file mode 100644 index 000000000..8499c9396 --- /dev/null +++ b/src/KeeShare.Tests/TestHelpers/KeeShareConfigLogic.cs @@ -0,0 +1,170 @@ +namespace KeeShare.Tests.TestHelpers +{ + /// + /// Copy of KeeShare configuration logic for testing without Android dependencies. + /// Kept in sync with KeeShare.cs in the app project (lines 46-195). + /// Last synced: 2025-01-27 + /// + public static class KeeShareConfigLogic + { + public const string DeviceFilePathKeyPrefix = "KeeShare.FilePath."; + public const string ActiveKey = "KeeShare.Active"; + public const string TypeKey = "KeeShare.Type"; + public const string FilePathKey = "KeeShare.FilePath"; + public const string PasswordKey = "KeeShare.Password"; + public const string TrustedCertificateKey = "KeeShare.TrustedCertificate"; + + /// + /// Test device ID for testing. In production this comes from KeeAutoExecExt.ThisDeviceId. + /// + public static string TestDeviceId { get; set; } = "test-device-id"; + + /// + /// Gets the device-specific custom data key for storing file paths. + /// + public static string GetDeviceFilePathKey() + { + return DeviceFilePathKeyPrefix + TestDeviceId; + } + + /// + /// Gets the effective file path for a KeeShare group on this device. + /// First checks for a device-specific path, then falls back to the original path. + /// If the stored value is a serialized IOConnectionInfo, extracts the Path from it. + /// + public static string? GetEffectiveFilePath(PwGroupStub? group) + { + if (group == null) return null; + + string deviceKey = GetDeviceFilePathKey(); + string? devicePath = group.CustomData.Get(deviceKey); + + if (!string.IsNullOrEmpty(devicePath)) + { + // Try to detect if this is a serialized IOConnectionInfo + // The format typically includes encoded path info + // For simplicity in tests, we'll just return the value as-is + // unless it looks like a serialized IOC (contains specific markers) + if (devicePath.Contains("s://") || devicePath.StartsWith("Path=")) + { + // This looks like a serialized IOConnectionInfo + // Extract just the path portion - simplified for testing + // In production, IOConnectionInfo.UnserializeFromString is used + int pathIndex = devicePath.IndexOf("Path="); + if (pathIndex >= 0) + { + int startIndex = pathIndex + 5; + int endIndex = devicePath.IndexOf('&', startIndex); + if (endIndex < 0) endIndex = devicePath.Length; + return Uri.UnescapeDataString(devicePath.Substring(startIndex, endIndex - startIndex)); + } + } + return devicePath; + } + + return group.CustomData.Get(FilePathKey); + } + + /// + /// Sets the device-specific file path for a KeeShare group. + /// + public static void SetDeviceFilePath(PwGroupStub? group, string? pathOrSerializedIoc) + { + if (group == null) return; + + string deviceKey = GetDeviceFilePathKey(); + if (string.IsNullOrEmpty(pathOrSerializedIoc)) + { + group.CustomData.Remove(deviceKey); + } + else + { + group.CustomData.Set(deviceKey, pathOrSerializedIoc); + } + group.Touch(true, false); + } + + /// + /// Enables KeeShare on a group with the specified configuration. + /// + public static void EnableKeeShare(PwGroupStub? group, string? type, string? filePath, string? password = null) + { + if (group == null) + throw new ArgumentNullException(nameof(group)); + if (string.IsNullOrEmpty(type)) + throw new ArgumentException("Type cannot be null or empty", nameof(type)); + if (type != "Export" && type != "Import" && type != "Synchronize") + throw new ArgumentException("Type must be 'Export', 'Import', or 'Synchronize'", nameof(type)); + + group.CustomData.Set(ActiveKey, "true"); + group.CustomData.Set(TypeKey, type); + + if (!string.IsNullOrEmpty(filePath)) + { + group.CustomData.Set(FilePathKey, filePath); + } + + if (!string.IsNullOrEmpty(password)) + { + group.CustomData.Set(PasswordKey, password); + } + else + { + group.CustomData.Remove(PasswordKey); + } + + group.Touch(true, false); + } + + /// + /// Updates KeeShare configuration on a group. + /// + public static void UpdateKeeShareConfig(PwGroupStub? group, string? type, string? filePath, string? password) + { + if (group == null) + throw new ArgumentNullException(nameof(group)); + + if (!string.IsNullOrEmpty(type)) + { + if (type != "Export" && type != "Import" && type != "Synchronize") + throw new ArgumentException("Type must be 'Export', 'Import', or 'Synchronize'", nameof(type)); + group.CustomData.Set(TypeKey, type); + } + + if (!string.IsNullOrEmpty(filePath)) + { + group.CustomData.Set(FilePathKey, filePath); + } + + if (!string.IsNullOrEmpty(password)) + { + group.CustomData.Set(PasswordKey, password); + } + else + { + group.CustomData.Remove(PasswordKey); + } + + group.Touch(true, false); + } + + /// + /// Disables KeeShare on a group, removing all KeeShare-related CustomData. + /// + public static void DisableKeeShare(PwGroupStub? group) + { + if (group == null) return; + + group.CustomData.Remove(ActiveKey); + group.CustomData.Remove(TypeKey); + group.CustomData.Remove(FilePathKey); + group.CustomData.Remove(PasswordKey); + group.CustomData.Remove(TrustedCertificateKey); + + string deviceKey = GetDeviceFilePathKey(); + group.CustomData.Remove(deviceKey); + + group.Touch(true, false); + } + } +} diff --git a/src/KeeShare.Tests/TestHelpers/KeeShareStateLogic.cs b/src/KeeShare.Tests/TestHelpers/KeeShareStateLogic.cs new file mode 100644 index 000000000..a50059191 --- /dev/null +++ b/src/KeeShare.Tests/TestHelpers/KeeShareStateLogic.cs @@ -0,0 +1,136 @@ +namespace KeeShare.Tests.TestHelpers +{ + /// + /// Represents a KeeShare group item for testing, mirroring KeeShareItem from production. + /// + public class KeeShareItemStub + { + public PwGroupStub Group { get; } + public PwDatabaseStub Database { get; } + + public KeeShareItemStub(PwGroupStub group, PwDatabaseStub database) + { + Group = group ?? throw new ArgumentNullException(nameof(group)); + Database = database ?? throw new ArgumentNullException(nameof(database)); + } + + public string Type => Group.CustomData.Get(KeeShareConfigLogic.TypeKey) ?? ""; + public string OriginalPath => Group.CustomData.Get(KeeShareConfigLogic.FilePathKey) ?? ""; + public string Password => Group.CustomData.Get(KeeShareConfigLogic.PasswordKey) ?? ""; + public bool IsActive => Group.CustomData.Get(KeeShareConfigLogic.ActiveKey) == "true"; + } + + /// + /// Copy of KeeShare state checking logic for testing without Android dependencies. + /// Kept in sync with KeeShare.cs in the app project (lines 358-542). + /// Last synced: 2025-01-27 + /// + public static class KeeShareStateLogic + { + /// + /// Checks if this device has a configured path for the KeeShare group. + /// + public static bool HasDeviceFilePath(PwGroupStub? group) + { + if (group == null) return false; + string deviceKey = KeeShareConfigLogic.GetDeviceFilePathKey(); + return !string.IsNullOrEmpty(group.CustomData.Get(deviceKey)); + } + + /// + /// Gets whether the KeeShare group is enabled on this device. + /// A group is considered enabled if it has either a device-specific path or an original path. + /// + public static bool IsEnabledOnThisDevice(PwGroupStub? group) + { + return !string.IsNullOrEmpty(KeeShareConfigLogic.GetEffectiveFilePath(group)); + } + + /// + /// Gets all KeeShare groups from the database. + /// + public static List GetKeeShareItems(PwDatabaseStub? db) + { + var items = new List(); + if (db == null || !db.IsOpen) return items; + if (db.RootGroup == null) return items; + + CollectKeeShareGroups(db.RootGroup, db, items); + return items; + } + + private static void CollectKeeShareGroups(PwGroupStub group, PwDatabaseStub db, List items) + { + if (group.CustomData.Get(KeeShareConfigLogic.ActiveKey) == "true") + { + items.Add(new KeeShareItemStub(group, db)); + } + + foreach (var sub in group.Groups) + { + CollectKeeShareGroups(sub, db, items); + } + } + + /// + /// Checks if the database has any KeeShare groups that need to be exported on save. + /// (Export or Synchronize type groups with a configured path) + /// + public static bool HasExportableKeeShareGroups(PwDatabaseStub? db) + { + if (db == null || !db.IsOpen) return false; + if (db.RootGroup == null) return false; + return HasExportableKeeShareGroups(db.RootGroup); + } + + private static bool HasExportableKeeShareGroups(PwGroupStub group) + { + if (group.CustomData.Get(KeeShareConfigLogic.ActiveKey) == "true") + { + string? type = group.CustomData.Get(KeeShareConfigLogic.TypeKey); + if (type == "Export" || type == "Synchronize") + { + string? path = KeeShareConfigLogic.GetEffectiveFilePath(group); + if (!string.IsNullOrEmpty(path)) + return true; + } + } + + foreach (var sub in group.Groups) + { + if (HasExportableKeeShareGroups(sub)) return true; + } + return false; + } + + /// + /// Checks if a group is read-only because it's a KeeShare Import group + /// or is contained within one. Import groups replace their contents on sync, + /// so local modifications would be lost. + /// + public static bool IsReadOnlyBecauseKeeShareImport(PwGroupStub? group) + { + if (group == null) return false; + + PwGroupStub? current = group; + while (current != null) + { + if (current.CustomData.Get(KeeShareConfigLogic.ActiveKey) == "true" && + current.CustomData.Get(KeeShareConfigLogic.TypeKey) == "Import") + { + return true; + } + current = current.ParentGroup; + } + return false; + } + + /// + /// Checks if an entry is read-only because it's in a KeeShare Import group. + /// + public static bool IsReadOnlyBecauseKeeShareImport(PwEntryStub? entry) + { + return entry?.ParentGroup != null && IsReadOnlyBecauseKeeShareImport(entry.ParentGroup); + } + } +} diff --git a/src/KeeShare.Tests/TestHelpers/KeeShareTestHelpers.cs b/src/KeeShare.Tests/TestHelpers/KeeShareTestHelpers.cs new file mode 100644 index 000000000..1e8b12cb8 --- /dev/null +++ b/src/KeeShare.Tests/TestHelpers/KeeShareTestHelpers.cs @@ -0,0 +1,162 @@ +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Text; + +namespace KeeShare.Tests.TestHelpers +{ + /// + /// Copy of KeeShare signature verification logic for testing. + /// This is a pure function that doesn't depend on Android-specific code. + /// Kept in sync with KeeShareCheckOperation.VerifySignatureCore in the app project. + /// + public static class KeeShareTestHelpers + { + /// + /// Verifies a KeeShare signature. This is a copy of the production code + /// for testing purposes (to avoid Android assembly dependencies). + /// + public static bool VerifySignatureCore(string trustedCertificate, byte[] kdbxData, byte[] signatureData) + { + try + { + if (string.IsNullOrEmpty(trustedCertificate)) + { + return false; + } + + if (signatureData == null || signatureData.Length == 0) + { + return false; + } + + if (kdbxData == null || kdbxData.Length == 0) + { + return false; + } + + string signatureText = Encoding.UTF8.GetString(signatureData).Trim(); + + signatureText = signatureText.Replace("\r", "").Replace("\n", "").Replace(" ", ""); + + const string rsaPrefix = "rsa|"; + if (signatureText.StartsWith(rsaPrefix, StringComparison.OrdinalIgnoreCase)) + { + signatureText = signatureText.Substring(rsaPrefix.Length); + } + + byte[] signatureBytes; + try + { + signatureBytes = HexStringToByteArray(signatureText); + } + catch (Exception) + { + return false; + } + + if (signatureBytes == null || signatureBytes.Length == 0) + { + return false; + } + + byte[] publicKeyBytes; + try + { + string pemText = trustedCertificate.Trim(); + + if (pemText.StartsWith("-----BEGIN CERTIFICATE-----")) + { + var cert = X509Certificate2.CreateFromPem(pemText); + publicKeyBytes = cert.GetPublicKey(); + } + else if (pemText.StartsWith("-----BEGIN PUBLIC KEY-----")) + { + var lines = pemText.Split('\n'); + var base64Lines = lines + .Select(l => l.Trim()) + .Where(l => !string.IsNullOrEmpty(l) && !l.StartsWith("-----")); + string base64Content = string.Join("", base64Lines); + publicKeyBytes = Convert.FromBase64String(base64Content); + } + else + { + publicKeyBytes = Convert.FromBase64String(pemText); + } + } + catch (Exception) + { + return false; + } + + bool isValid = false; + bool importFailed = false; + using (RSA rsa = RSA.Create()) + { + bool importSucceeded = false; + try + { + rsa.ImportSubjectPublicKeyInfo(publicKeyBytes, out int bytesRead); + if (bytesRead == 0) + { + throw new CryptographicException("No bytes read from public key"); + } + importSucceeded = true; + } + catch (Exception) + { + try + { + rsa.ImportRSAPublicKey(publicKeyBytes, out int bytesRead); + if (bytesRead == 0) + { + throw new CryptographicException("No bytes read from public key"); + } + importSucceeded = true; + } + catch (Exception) + { + importFailed = true; + } + } + + if (!importFailed && importSucceeded) + { + byte[] hash; + using (SHA256 sha256 = SHA256.Create()) + { + hash = sha256.ComputeHash(kdbxData); + } + + isValid = rsa.VerifyHash(hash, signatureBytes, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + } + } + + return !importFailed && isValid; + } + catch (Exception) + { + return false; + } + } + + /// + /// Convert hex string to byte array (matching KeePassLib.Utility.MemUtil.HexStringToByteArray behavior) + /// + private static byte[] HexStringToByteArray(string hex) + { + if (string.IsNullOrEmpty(hex)) + return Array.Empty(); + + if (hex.Length % 2 != 0) + throw new ArgumentException("Hex string must have even length"); + + byte[] bytes = new byte[hex.Length / 2]; + for (int i = 0; i < bytes.Length; i++) + { + bytes[i] = Convert.ToByte(hex.Substring(i * 2, 2), 16); + } + return bytes; + } + } +} + diff --git a/src/KeeShare.Tests/TestHelpers/PwDatabaseStub.cs b/src/KeeShare.Tests/TestHelpers/PwDatabaseStub.cs new file mode 100644 index 000000000..32225ad72 --- /dev/null +++ b/src/KeeShare.Tests/TestHelpers/PwDatabaseStub.cs @@ -0,0 +1,11 @@ +namespace KeeShare.Tests.TestHelpers +{ + /// + /// Minimal stub of PwDatabase for testing KeeShare logic without Android dependencies. + /// + public class PwDatabaseStub + { + public bool IsOpen { get; set; } = true; + public PwGroupStub? RootGroup { get; set; } + } +} diff --git a/src/KeeShare.Tests/TestHelpers/PwEntryStub.cs b/src/KeeShare.Tests/TestHelpers/PwEntryStub.cs new file mode 100644 index 000000000..083fd30e8 --- /dev/null +++ b/src/KeeShare.Tests/TestHelpers/PwEntryStub.cs @@ -0,0 +1,10 @@ +namespace KeeShare.Tests.TestHelpers +{ + /// + /// Minimal stub of PwEntry for testing KeeShare logic without Android dependencies. + /// + public class PwEntryStub + { + public PwGroupStub? ParentGroup { get; set; } + } +} diff --git a/src/KeeShare.Tests/TestHelpers/PwGroupStub.cs b/src/KeeShare.Tests/TestHelpers/PwGroupStub.cs new file mode 100644 index 000000000..bc977691e --- /dev/null +++ b/src/KeeShare.Tests/TestHelpers/PwGroupStub.cs @@ -0,0 +1,137 @@ +using System.Collections; + +namespace KeeShare.Tests.TestHelpers +{ + /// + /// Minimal stub of PwGroup for testing KeeShare logic without Android dependencies. + /// + public class PwGroupStub + { + public CustomDataStub CustomData { get; } = new CustomDataStub(); + public List Groups { get; } = new List(); + public List Entries { get; } = new List(); + + public string Name { get; set; } = ""; + public PwGroupStub? ParentGroup { get; set; } + + /// + /// Tracks whether Touch was called (for test verification). + /// + public bool WasTouched { get; private set; } + + /// + /// The arguments passed to the last Touch call. + /// + public (bool bModified, bool bTouchParents)? LastTouchArgs { get; private set; } + + public void AddGroup(PwGroupStub group, bool takeOwnership = true) + { + Groups.Add(group); + if (takeOwnership) + { + group.ParentGroup = this; + } + } + + public void AddEntry(PwEntryStub entry, bool takeOwnership = true) + { + Entries.Add(entry); + if (takeOwnership) + { + entry.ParentGroup = this; + } + } + + public void Touch(bool bModified, bool bTouchParents = false) + { + WasTouched = true; + LastTouchArgs = (bModified, bTouchParents); + } + + /// + /// Resets the touch tracking state for tests. + /// + public void ResetTouchTracking() + { + WasTouched = false; + LastTouchArgs = null; + } + } + + /// + /// Minimal stub of CustomData for testing. + /// Implements IEnumerable for iteration support. + /// + public class CustomDataStub : IEnumerable> + { + private readonly Dictionary _data = new Dictionary(); + + public void Set(string key, string value) + { + _data[key] = value; + } + + public string? Get(string key) + { + return _data.TryGetValue(key, out var value) ? value : null; + } + + public bool Exists(string key) + { + return _data.ContainsKey(key); + } + + public bool Remove(string key) + { + return _data.Remove(key); + } + + public IEnumerator> GetEnumerator() + { + return _data.GetEnumerator(); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + + /// + /// Gets the number of items (for test assertions). + /// + public int Count => _data.Count; + } + + /// + /// Copy of HasKeeShareGroups logic for testing without Android dependencies. + /// Kept in sync with KeeShare.HasKeeShareGroups in the app project. + /// + public static class KeeShareLogic + { + public static bool HasKeeShareGroups(PwGroupStub group) + { + if (group.CustomData.Get("KeeShare.Active") == "true") + return true; + + foreach (var sub in group.Groups) + { + if (HasKeeShareGroups(sub)) return true; + } + return false; + } + } +} + + + + + + + + + + + + + + diff --git a/src/Kp2aBusinessLogic/Kp2aBusinessLogic.csproj b/src/Kp2aBusinessLogic/Kp2aBusinessLogic.csproj index 969ec79a9..89eb32334 100644 --- a/src/Kp2aBusinessLogic/Kp2aBusinessLogic.csproj +++ b/src/Kp2aBusinessLogic/Kp2aBusinessLogic.csproj @@ -39,9 +39,7 @@ - - - + \ No newline at end of file diff --git a/src/Kp2aBusinessLogic/OperationRunner.cs b/src/Kp2aBusinessLogic/OperationRunner.cs index 1af49c04d..9c0009a7f 100644 --- a/src/Kp2aBusinessLogic/OperationRunner.cs +++ b/src/Kp2aBusinessLogic/OperationRunner.cs @@ -114,7 +114,7 @@ public void Run(IKp2aApp app, OperationWithFinishHandler operation, bool runBloc var originalFinishedHandler = _currentlyRunningTask.Value.Operation.operationFinishedHandler; _currentlyRunningTask.Value.Operation.operationFinishedHandler = new ActionOnOperationFinished(app, ( - (success, message, context) => + (success, message, importantMessage, exception, context) => { if (_currentlyRunningTask?.RunBlocking == true) { diff --git a/src/Kp2aBusinessLogic/database/SynchronizeCachedDatabase.cs b/src/Kp2aBusinessLogic/database/SynchronizeCachedDatabase.cs index 12735f673..2df4dda2b 100644 --- a/src/Kp2aBusinessLogic/database/SynchronizeCachedDatabase.cs +++ b/src/Kp2aBusinessLogic/database/SynchronizeCachedDatabase.cs @@ -88,7 +88,7 @@ public override void Run() { //conflict! need to merge var _saveDb = new SaveDb(_app, new ActionOnOperationFinished(_app, - (success, result, activity) => + (success, result, importantMessage, exception, activity) => { if (!success) { @@ -111,7 +111,7 @@ public override void Run() else { //only the remote file was modified -> reload database. - var onFinished = new ActionOnOperationFinished(_app, (success, result, activity) => + var onFinished = new ActionOnOperationFinished(_app, (success, result, importantMessage, exception, activity) => { if (!success) { diff --git a/src/Kp2aBusinessLogic/database/edit/ActionOnOperationFinished.cs b/src/Kp2aBusinessLogic/database/edit/ActionOnOperationFinished.cs index f16f896c4..bacd10b01 100644 --- a/src/Kp2aBusinessLogic/database/edit/ActionOnOperationFinished.cs +++ b/src/Kp2aBusinessLogic/database/edit/ActionOnOperationFinished.cs @@ -25,7 +25,7 @@ namespace keepass2android { public class ActionOnOperationFinished : OnOperationFinishedHandler { - public delegate void ActionToPerformOnFinsh(bool success, String message, Context activeContext); + public delegate void ActionToPerformOnFinsh(bool success, String message, bool importantMessage, Exception exception, Context activeContext); readonly ActionToPerformOnFinsh _actionToPerform; @@ -47,12 +47,12 @@ public override void Run() { Handler.Post(() => { - _actionToPerform(Success, Message, ActiveContext); + _actionToPerform(Success, Message, ImportantMessage, Exception, ActiveContext); }); } else { - _actionToPerform(Success, Message, ActiveContext); + _actionToPerform(Success, Message, ImportantMessage, Exception, ActiveContext); } base.Run(); } diff --git a/src/Kp2aBusinessLogic/database/edit/DeleteRunnable.cs b/src/Kp2aBusinessLogic/database/edit/DeleteRunnable.cs index f91e37eb3..6acd4448a 100644 --- a/src/Kp2aBusinessLogic/database/edit/DeleteRunnable.cs +++ b/src/Kp2aBusinessLogic/database/edit/DeleteRunnable.cs @@ -229,7 +229,7 @@ public override void Run() Android.Util.Log.Debug("KP2A", "Calling PerformDelete.."); PerformDelete(touchedGroups, permanentlyDeletedGroups); - _operationFinishedHandler = new ActionOnOperationFinished(App, (success, message, context) => + _operationFinishedHandler = new ActionOnOperationFinished(App, (success, message, importantMessage, exception, context) => { if (success) { diff --git a/src/Kp2aBusinessLogic/database/edit/LoadDB.cs b/src/Kp2aBusinessLogic/database/edit/LoadDB.cs index ba15af21f..ff626090d 100644 --- a/src/Kp2aBusinessLogic/database/edit/LoadDB.cs +++ b/src/Kp2aBusinessLogic/database/edit/LoadDB.cs @@ -43,10 +43,17 @@ public class LoadDb : OperationWithFinishHandler public bool DoNotSetStatusLoggerMessage = false; + /// + /// Static callback that can be registered by the app to handle KeeShare check after database load. + /// Called with (IKp2aApp app, OnOperationFinishedHandler handler). + /// The callback should check if KeeShare groups exist and process them, then call handler.Run(). + /// + public static Action OnLoadCompleteKeeShareCheck { get; set; } + public LoadDb(IKp2aApp app, IOConnectionInfo ioc, Task databaseData, CompositeKey compositeKey, string keyfileOrProvider, OnOperationFinishedHandler operationFinishedHandler, - bool updateLastUsageTimestamp, bool makeCurrent, IDatabaseModificationWatcher modificationWatcher = null) : base(app, operationFinishedHandler) + bool updateLastUsageTimestamp, bool makeCurrent, IDatabaseModificationWatcher modificationWatcher = null) : base(app, WrapHandlerForKeeShare(app, operationFinishedHandler)) { _modificationWatcher = modificationWatcher ?? new NullDatabaseModificationWatcher(); _app = app; @@ -59,6 +66,53 @@ public LoadDb(IKp2aApp app, IOConnectionInfo ioc, Task databaseDat _rememberKeyfile = app.GetBooleanPreference(PreferenceKey.remember_keyfile); } + private static OnOperationFinishedHandler WrapHandlerForKeeShare(IKp2aApp app, OnOperationFinishedHandler originalHandler) + { + // Note: OnLoadCompleteKeeShareCheck runs in BOTH cases below (null or not null) + // The null check is just to handle the two scenarios differently: + // - If null: create a simple handler that only runs KeeShare check + // - If not null: wrap the existing handler to run KeeShare check before it + if (originalHandler == null) + { + return new ActionOnOperationFinished(app, (success, message, importantMessage, exception, context) => + { + if (success && app.CurrentDb?.KpDatabase?.IsOpen == true && OnLoadCompleteKeeShareCheck != null) + { + try + { + var noOpHandler = new ActionOnOperationFinished(app, (_, _, _, _, _) => { }); + OnLoadCompleteKeeShareCheck(app, noOpHandler); + } + catch (Exception ex) + { + Kp2aLog.Log("KeeShare check after load failed: " + ex.Message); + } + } + }); + } + + return new ActionOnOperationFinished(app, (success, message, importantMessage, exception, context) => + { + originalHandler.SetResult(success, message, importantMessage, exception); + if (success && app.CurrentDb?.KpDatabase?.IsOpen == true && OnLoadCompleteKeeShareCheck != null) + { + try + { + OnLoadCompleteKeeShareCheck(app, originalHandler); + } + catch (Exception ex) + { + Kp2aLog.Log("KeeShare check after load failed: " + ex.Message); + originalHandler.Run(); + } + } + else + { + originalHandler.Run(); + } + }); + } + protected bool success = false; private bool _updateLastUsageTimestamp; private readonly bool _makeCurrent; diff --git a/src/Kp2aBusinessLogic/database/edit/MoveElements.cs b/src/Kp2aBusinessLogic/database/edit/MoveElements.cs index 276be3a56..6a6f4144c 100644 --- a/src/Kp2aBusinessLogic/database/edit/MoveElements.cs +++ b/src/Kp2aBusinessLogic/database/edit/MoveElements.cs @@ -136,7 +136,7 @@ public override void Run() int indexToSave = 0; bool allSavesSuccess = true; - void ContinueSave(bool success, string message, Context activeActivity) + void ContinueSave(bool success, string message, bool importantMessage, Exception exception, Context activeActivity) { allSavesSuccess &= success; indexToSave++; diff --git a/src/Kp2aBusinessLogic/database/edit/SaveDB.cs b/src/Kp2aBusinessLogic/database/edit/SaveDB.cs index b0b8ec96d..7498bf44d 100644 --- a/src/Kp2aBusinessLogic/database/edit/SaveDB.cs +++ b/src/Kp2aBusinessLogic/database/edit/SaveDB.cs @@ -47,6 +47,12 @@ public class SaveDb : OperationWithFinishHandler private readonly IDatabaseModificationWatcher _modificationWatcher; private bool requiresSubsequentSync = false; //if true, we need to sync the file after saving. + /// + /// Static callback that can be registered by the app to handle KeeShare export after save. + /// Called with (IKp2aApp app, Database db, OnOperationFinishedHandler handler). + /// + public static Action OnSaveCompleteKeeShareExport { get; set; } + public bool DoNotSetStatusLoggerMessage = false; /// @@ -231,11 +237,12 @@ private void FinishWithSuccess() if (requiresSubsequentSync) { var syncTask = new SynchronizeCachedDatabase(_app, _db, new ActionOnOperationFinished(_app, - (success, message, context) => + (success, message, importantMessage, exception, context) => { if (!System.String.IsNullOrEmpty(message)) _app.ShowMessage(context, message, success ? MessageSeverity.Info : MessageSeverity.Error); + TriggerKeeShareExportThenFinish(); }), new BackgroundDatabaseModificationLocker(_app) ); OperationRunner.Instance.Run(_app, syncTask); @@ -243,9 +250,34 @@ private void FinishWithSuccess() else { _db.LastSyncTime = DateTime.Now; + TriggerKeeShareExportThenFinish(); + } + } + private void TriggerKeeShareExportThenFinish() + { + try + { + if (OnSaveCompleteKeeShareExport != null) + { + OnSaveCompleteKeeShareExport.Invoke(_app, _db, new ActionOnOperationFinished(_app, + (success, message, importantMessage, exception, context) => + { + Finish(true); + })); + } + else + { + Finish(true); + } + } + catch (Exception ex) + { + Kp2aLog.Log("KeeShare export after save failed: " + ex.Message); + _app.ShowMessage(_app.ActiveContext, "KeeShare export after save failed: " + ex.Message, MessageSeverity.Error); + // Database save succeeded; KeeShare export failure is non-blocking + Finish(true); } - Finish(true); } private void MergeAndFinish(IFileStorage fileStorage, IOConnectionInfo ioc) diff --git a/src/keepass2android-app/AssemblyInfo.cs b/src/keepass2android-app/AssemblyInfo.cs index 9e2a4a88c..1cfcb13f5 100644 --- a/src/keepass2android-app/AssemblyInfo.cs +++ b/src/keepass2android-app/AssemblyInfo.cs @@ -15,7 +15,8 @@ You should have received a copy of the GNU General Public License along with Keepass2Android. If not, see . */ +using System.Runtime.CompilerServices; - +[assembly: InternalsVisibleTo("KeeShare.Tests")] diff --git a/src/keepass2android-app/ConfigureChildDatabasesActivity.cs b/src/keepass2android-app/ConfigureChildDatabasesActivity.cs index 20b186de6..74805fde8 100644 --- a/src/keepass2android-app/ConfigureChildDatabasesActivity.cs +++ b/src/keepass2android-app/ConfigureChildDatabasesActivity.cs @@ -243,7 +243,7 @@ private void OnEnableCopy(AutoExecItem item) newEntry.SetUuid(new PwUuid(true), true); // Create new UUID string strTitle = newEntry.Strings.ReadSafe(PwDefs.TitleField); newEntry.Strings.Set(PwDefs.TitleField, new ProtectedString(false, strTitle + " (" + Android.OS.Build.Model + ")")); - var addTask = new AddEntry(App.Kp2a.CurrentDb, App.Kp2a, newEntry, item.Entry.ParentGroup, new ActionInContextInstanceOnOperationFinished(ContextInstanceId, App.Kp2a, (success, message, context) => (context as ConfigureChildDatabasesActivity)?.Update())); + var addTask = new AddEntry(App.Kp2a.CurrentDb, App.Kp2a, newEntry, item.Entry.ParentGroup, new ActionInContextInstanceOnOperationFinished(ContextInstanceId, App.Kp2a, (success, message, importantMessage, exception, context) => (context as ConfigureChildDatabasesActivity)?.Update())); BlockingOperationStarter pt = new BlockingOperationStarter(App.Kp2a, addTask); pt.Run(); @@ -275,7 +275,7 @@ private void OnEnable(AutoExecItem item) private void Save(AutoExecItem item) { - var addTask = new SaveDb(App.Kp2a, App.Kp2a.FindDatabaseForElement(item.Entry), new ActionInContextInstanceOnOperationFinished(ContextInstanceId, App.Kp2a, (success, message, context) => (context as ConfigureChildDatabasesActivity)?.Update())); + var addTask = new SaveDb(App.Kp2a, App.Kp2a.FindDatabaseForElement(item.Entry), new ActionInContextInstanceOnOperationFinished(ContextInstanceId, App.Kp2a, (success, message, importantMessage, exception, context) => (context as ConfigureChildDatabasesActivity)?.Update())); BlockingOperationStarter pt = new BlockingOperationStarter(App.Kp2a, addTask); pt.Run(); @@ -382,7 +382,7 @@ private void AddAutoOpenEntryForDatabase(Database db) {KeeAutoExecExt.ThisDeviceId, true} }))); - var addTask = new AddEntry(db, App.Kp2a, newEntry, autoOpenGroup, new ActionInContextInstanceOnOperationFinished(ContextInstanceId, App.Kp2a, (success, message, context) => (context as ConfigureChildDatabasesActivity)?.Update())); + var addTask = new AddEntry(db, App.Kp2a, newEntry, autoOpenGroup, new ActionInContextInstanceOnOperationFinished(ContextInstanceId, App.Kp2a, (success, message, importantMessage, exception, context) => (context as ConfigureChildDatabasesActivity)?.Update())); BlockingOperationStarter pt = new BlockingOperationStarter(App.Kp2a, addTask); pt.Run(); diff --git a/src/keepass2android-app/ConfigureKeeShareActivity.cs b/src/keepass2android-app/ConfigureKeeShareActivity.cs new file mode 100644 index 000000000..c896c1023 --- /dev/null +++ b/src/keepass2android-app/ConfigureKeeShareActivity.cs @@ -0,0 +1,753 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Android.App; +using AlertDialog = AndroidX.AppCompat.App.AlertDialog; +using Android.Content; +using Android.Content.PM; +using Android.OS; +using Android.Util; +using Android.Views; +using Android.Widget; +using Google.Android.Material.Dialog; +using Google.Android.Material.FloatingActionButton; +using Google.Android.Material.TextField; +using keepass2android.database.edit; +using KeePassLib; +using KeePassLib.Serialization; + +namespace keepass2android +{ + [Activity(Label = "@string/keeshare_title", MainLauncher = false, Theme = "@style/Kp2aTheme_BlueNoActionBar", LaunchMode = LaunchMode.SingleTop, ConfigurationChanges = ConfigChanges.Orientation | ConfigChanges.Keyboard | ConfigChanges.KeyboardHidden, Exported = true)] + [IntentFilter(new[] { "kp2a.action.ConfigureKeeShareActivity" }, Categories = new[] { Intent.CategoryDefault })] + public class ConfigureKeeShareActivity : LockCloseActivity + { + private KeeShareAdapter _adapter; + private const int ReqCodeSelectFile = 1; + private const int ReqCodeSelectFileForNewConfig = 2; + private const string PendingConfigItemUuidKey = "PendingConfigItemUuid"; + private const string PendingNewConfigGroupUuidKey = "PendingNewConfigGroupUuid"; + private KeeShareItem _pendingConfigItem; + private PwGroup _pendingNewConfigGroup; + private AlertDialog _addDialog; + private TextInputEditText _dialogFilePathEdit; + private string _pendingNewConfigType; + private string _pendingNewConfigPassword; + private string _pendingNewGroupName; // For creating a new group in OnActivityResult + private bool _pendingCreateNewGroup; // Flag to indicate if we should create a new group + + public class KeeShareAdapter : BaseAdapter + { + private readonly ConfigureKeeShareActivity _context; + internal List _displayedItems; + + public KeeShareAdapter(ConfigureKeeShareActivity context) + { + _context = context; + Update(); + } + + public override Java.Lang.Object GetItem(int position) + { + return _displayedItems[position]; + } + + public override long GetItemId(int position) + { + return position; + } + + private LayoutInflater _inflater; + + public override View GetView(int position, View convertView, ViewGroup parent) + { + if (_inflater == null) + _inflater = (LayoutInflater)_context.GetSystemService(Context.LayoutInflaterService); + + View view; + + if (convertView == null) + { + view = _inflater.Inflate(Resource.Layout.keeshare_config_row, parent, false); + + view.FindViewById