diff --git a/apps/mobile-app/app/unlock.tsx b/apps/mobile-app/app/unlock.tsx
index eedc9c373..1f3738bfa 100644
--- a/apps/mobile-app/app/unlock.tsx
+++ b/apps/mobile-app/app/unlock.tsx
@@ -416,6 +416,7 @@ export default function UnlockScreen() : React.ReactNode {
{t('auth.logout')}
diff --git a/apps/mobile-app/ios/AliasVaultUITests/AliasVaultUITests.swift b/apps/mobile-app/ios/AliasVaultUITests/AliasVaultUITests.swift
index f033f4345..fdaf4a55e 100644
--- a/apps/mobile-app/ios/AliasVaultUITests/AliasVaultUITests.swift
+++ b/apps/mobile-app/ios/AliasVaultUITests/AliasVaultUITests.swift
@@ -4,7 +4,7 @@ import XCTest
///
/// These tests use dynamically created test users via the API, so no pre-configured
/// credentials are needed. Tests run sequentially and share state (e.g., the test user
-/// created in test03 is reused in subsequent tests).
+/// created in test01 is reused in subsequent tests).
///
/// Prerequisites:
/// - Local API server running at the URL specified in TestConfiguration.apiUrl
@@ -115,130 +115,15 @@ final class AliasVaultUITests: XCTestCase {
}
}
- // MARK: - Test 01: App Launch
-
- /// Verifies the app launches correctly and shows the login screen with all expected elements.
- @MainActor
- func test01AppLaunch() {
- app.launchArguments.append("--reset-state")
- app.launch()
-
- XCTContext.runActivity(named: "Wait for app to load") { _ in
- let aliasVaultText = app.staticTexts["AliasVault"]
- assertElementExists(
- aliasVaultText,
- timeout: 15,
- message: "App should display AliasVault text on launch",
- context: "01-app-load"
- )
- }
-
- XCTContext.runActivity(named: "Verify login screen is displayed") { _ in
- let loginScreen = app.findElement(testID: "login-screen")
- assertElementExists(
- loginScreen,
- timeout: TestConfiguration.defaultTimeout,
- message: "Login screen should be visible",
- context: "01-login-screen"
- )
- }
-
- XCTContext.runActivity(named: "Verify login form elements") { _ in
- let usernameInput = app.findElement(testID: "username-input")
- if !usernameInput.exists {
- captureFailureState(context: "01-username-input-missing")
- XCTFail("Username input should be visible")
- }
-
- let passwordInput = app.findElement(testID: "password-input")
- if !passwordInput.exists {
- captureFailureState(context: "01-password-input-missing")
- XCTFail("Password input should be visible")
- }
-
- let loginButton = app.findElement(testID: "login-button")
- if !loginButton.exists {
- captureFailureState(context: "01-login-button-missing")
- XCTFail("Log in button should be visible")
- }
- }
-
- // Take success screenshot
- let screenshot = XCUIScreen.main.screenshot()
- let attachment = XCTAttachment(screenshot: screenshot)
- attachment.name = "01-app-launched"
- attachment.lifetime = .keepAlways
- add(attachment)
- }
-
- // MARK: - Test 02: Login Validation
-
- /// Verifies login form validation and error handling for empty and invalid credentials.
- @MainActor
- func test02LoginValidation() {
- app.launchArguments.append("--reset-state")
- app.launch()
-
- XCTContext.runActivity(named: "Wait for login screen") { _ in
- let loginScreen = app.findElement(testID: "login-screen")
- assertElementExists(
- loginScreen,
- timeout: 15,
- message: "Login screen should be visible",
- context: "02-login-screen"
- )
- }
-
- XCTContext.runActivity(named: "Test empty form submission") { _ in
- let loginButton = app.findElement(testID: "login-button")
- loginButton.tapNoIdle()
-
- // Capture state after empty form submission
- let emptyFormScreenshot = XCUIScreen.main.screenshot()
- let attachment1 = XCTAttachment(screenshot: emptyFormScreenshot)
- attachment1.name = "02-1-empty-form-validation"
- attachment1.lifetime = .keepAlways
- add(attachment1)
- }
-
- XCTContext.runActivity(named: "Test invalid credentials") { _ in
- let usernameInput = app.findTextField(testID: "username-input")
- usernameInput.tapNoIdle()
- usernameInput.typeText("invalid@test.com")
-
- let passwordInput = app.findTextField(testID: "password-input")
- passwordInput.tapNoIdle()
- passwordInput.typeText("wrongpassword")
-
- app.hideKeyboardIfVisible()
-
- let loginButton = app.findElement(testID: "login-button")
- loginButton.tapNoIdle()
-
- // Wait for error response (network request may take time)
- let errorMessage = app.findElement(testID: "error-message")
- let errorAppeared = errorMessage.waitForExistenceNoIdle(timeout: 15)
-
- let invalidCredentialsScreenshot = XCUIScreen.main.screenshot()
- let attachment2 = XCTAttachment(screenshot: invalidCredentialsScreenshot)
- attachment2.name = "02-2-invalid-credentials"
- attachment2.lifetime = .keepAlways
- add(attachment2)
-
- // Log whether error message appeared (informational, not a failure)
- print("[Test02] Error message appeared: \(errorAppeared)")
- }
- }
-
- // MARK: - Test 03: Successful Login
+ // MARK: - Test 01: Successful Login
/// Verifies successful login flow with a dynamically created test user.
/// Creates a test user via the API, configures the app to use the local server,
/// and logs in. The test user is stored for reuse in subsequent tests.
@MainActor
- func test03SuccessfulLogin() async throws {
+ func test01SuccessfulLogin() async throws {
let testUser = try await ensureTestUser()
- print("[Test03] Using test user: \(testUser.username)")
+ print("[Test01] Using test user: \(testUser.username)")
app.launchArguments.append("--reset-state")
app.launch()
@@ -249,7 +134,7 @@ final class AliasVaultUITests: XCTestCase {
loginScreen,
timeout: 15,
message: "Login screen should be visible",
- context: "03-login-screen"
+ context: "01-login-screen"
)
}
@@ -262,7 +147,7 @@ final class AliasVaultUITests: XCTestCase {
selfHostedOption,
timeout: 10,
message: "Settings screen should show Self-hosted option",
- context: "03-self-hosted-option"
+ context: "01-self-hosted-option"
)
selfHostedOption.tapNoIdle()
@@ -271,12 +156,12 @@ final class AliasVaultUITests: XCTestCase {
customApiUrlInput,
timeout: 5,
message: "Custom API URL input should appear",
- context: "03-custom-url-input"
+ context: "01-custom-url-input"
)
customApiUrlInput.tapNoIdle()
customApiUrlInput.clearAndTypeTextNoIdle(TestConfiguration.apiUrl)
- print("[Test03] Configured API URL: \(TestConfiguration.apiUrl)")
+ print("[Test01] Configured API URL: \(TestConfiguration.apiUrl)")
app.hideKeyboardIfVisible()
let backButton = app.findElement(testID: "back-button")
@@ -289,7 +174,7 @@ final class AliasVaultUITests: XCTestCase {
loginScreen,
timeout: 10,
message: "Should return to login screen after configuring API",
- context: "03-return-to-login"
+ context: "01-return-to-login"
)
let usernameInput = app.findTextField(testID: "username-input")
@@ -304,7 +189,7 @@ final class AliasVaultUITests: XCTestCase {
let credentialsScreenshot = XCUIScreen.main.screenshot()
let attachment1 = XCTAttachment(screenshot: credentialsScreenshot)
- attachment1.name = "03-1-credentials-entered"
+ attachment1.name = "01-1-credentials-entered"
attachment1.lifetime = .keepAlways
add(attachment1)
@@ -318,34 +203,34 @@ final class AliasVaultUITests: XCTestCase {
itemsScreen,
timeout: TestConfiguration.extendedTimeout,
message: "Should navigate to items screen after successful login",
- context: "03-items-screen"
+ context: "01-items-screen"
)
let itemsList = app.findElement(testID: "items-list")
if !itemsList.exists {
- captureFailureState(context: "03-items-list-missing")
+ captureFailureState(context: "01-items-list-missing")
XCTFail("Items list should be visible after login")
}
let loginSuccessScreenshot = XCUIScreen.main.screenshot()
let attachment2 = XCTAttachment(screenshot: loginSuccessScreenshot)
- attachment2.name = "03-2-login-successful"
+ attachment2.name = "01-2-login-successful"
attachment2.lifetime = .keepAlways
add(attachment2)
- print("[Test03] Login successful, items screen displayed")
+ print("[Test01] Login successful, items screen displayed")
}
}
- // MARK: - Test 04: Create New Item
+ // MARK: - Test 02: Create New Item
/// Verifies item creation flow: opens add form, fills in details, saves, and verifies
/// the item appears in the list. Handles vault unlock if needed between tests.
@MainActor
- func test04CreateItem() async throws {
+ func test02CreateItem() async throws {
let testUser = try await ensureTestUser()
let uniqueName = TestConfiguration.generateUniqueName(prefix: "E2E Test")
- print("[Test04] Creating item with name: \(uniqueName)")
+ print("[Test02] Creating item with name: \(uniqueName)")
app.launch()
unlockVaultIfNeeded(with: testUser)
@@ -356,7 +241,7 @@ final class AliasVaultUITests: XCTestCase {
itemsScreen,
timeout: TestConfiguration.extendedTimeout,
message: "Should be on items screen after launch/unlock",
- context: "04-items-screen"
+ context: "02-items-screen"
)
}
@@ -369,12 +254,12 @@ final class AliasVaultUITests: XCTestCase {
addEditScreen,
timeout: 10,
message: "Add/edit screen should appear",
- context: "04-add-edit-screen"
+ context: "02-add-edit-screen"
)
let addItemScreenshot = XCUIScreen.main.screenshot()
let attachment1 = XCTAttachment(screenshot: addItemScreenshot)
- attachment1.name = "04-1-add-item-screen"
+ attachment1.name = "02-1-add-item-screen"
attachment1.lifetime = .keepAlways
add(attachment1)
}
@@ -407,7 +292,7 @@ final class AliasVaultUITests: XCTestCase {
let itemFilledScreenshot = XCUIScreen.main.screenshot()
let attachment2 = XCTAttachment(screenshot: itemFilledScreenshot)
- attachment2.name = "04-2-item-filled"
+ attachment2.name = "02-2-item-filled"
attachment2.lifetime = .keepAlways
add(attachment2)
}
@@ -420,12 +305,12 @@ final class AliasVaultUITests: XCTestCase {
"Login credentials",
timeout: 10,
message: "Should show item detail screen with Login credentials after save",
- context: "04-item-detail-after-save"
+ context: "02-item-detail-after-save"
)
let itemDetailScreenshot = XCUIScreen.main.screenshot()
let attachment3 = XCTAttachment(screenshot: itemDetailScreenshot)
- attachment3.name = "04-3-item-detail-screen"
+ attachment3.name = "02-3-item-detail-screen"
attachment3.lifetime = .keepAlways
add(attachment3)
}
@@ -440,40 +325,44 @@ final class AliasVaultUITests: XCTestCase {
itemsScreen,
timeout: 10,
message: "Should return to items screen",
- context: "04-return-to-items"
+ context: "02-return-to-items"
)
+ // Wait for list to fully populate after navigation
+ sleep(2)
+
let newItemCard = app.descendants(matching: .any).matching(
NSPredicate(format: "label == %@", uniqueName)
).firstMatch
let itemFound = newItemCard.waitForExistenceNoIdle(timeout: 10)
if !itemFound {
- captureFailureState(context: "04-item-not-in-list")
+ captureFailureState(context: "02-item-not-in-list")
XCTFail("Newly created item '\(uniqueName)' should appear in list")
+ return
}
- print("[Test04] Item '\(uniqueName)' found in list, tapping to verify")
+ print("[Test02] Item '\(uniqueName)' found in list, tapping to verify")
newItemCard.tapNoIdle()
assertTextAppears(
"Login credentials",
timeout: 10,
message: "Should show item detail screen when tapping created item",
- context: "04-item-detail-verify"
+ context: "02-item-detail-verify"
)
let itemVerifiedScreenshot = XCUIScreen.main.screenshot()
let attachment4 = XCTAttachment(screenshot: itemVerifiedScreenshot)
- attachment4.name = "04-4-item-verified"
+ attachment4.name = "02-4-item-verified"
attachment4.lifetime = .keepAlways
add(attachment4)
- print("[Test04] Item creation and verification successful")
+ print("[Test02] Item creation and verification successful")
}
}
- // MARK: - Test 05: Offline Mode and Sync
+ // MARK: - Test 03: Offline Mode and Sync
/// Verifies offline mode and sync recovery:
/// 1. Goes offline by setting API URL to invalid (simulates network failure)
@@ -484,7 +373,7 @@ final class AliasVaultUITests: XCTestCase {
/// Uses debug deep links (`__debug__/set-api-url`) to toggle offline mode.
/// These only work in development builds.
@MainActor
- func test05OfflineModeAndSync() async throws {
+ func test03OfflineModeAndSync() async throws {
let testUser = try await ensureTestUser()
app.launch()
@@ -493,7 +382,7 @@ final class AliasVaultUITests: XCTestCase {
let originalApiUrl = TestConfiguration.apiUrl
let invalidApiUrl = "http://offline.invalid.localhost:9999"
let uniqueName = TestConfiguration.generateUniqueName(prefix: "Offline Test")
- print("[Test05] Creating offline item with name: \(uniqueName)")
+ print("[Test03] Creating offline item with name: \(uniqueName)")
let itemsScreen = app.findElement(testID: "items-screen")
let offlineIndicator = app.findElement(testID: "sync-indicator-offline")
@@ -503,19 +392,19 @@ final class AliasVaultUITests: XCTestCase {
itemsScreen,
timeout: TestConfiguration.extendedTimeout,
message: "Should be on items screen",
- context: "05-items-screen"
+ context: "03-items-screen"
)
let initialStateScreenshot = XCUIScreen.main.screenshot()
let attachment1 = XCTAttachment(screenshot: initialStateScreenshot)
- attachment1.name = "05-1-initial-state-online"
+ attachment1.name = "03-1-initial-state-online"
attachment1.lifetime = .keepAlways
add(attachment1)
}
XCTContext.runActivity(named: "Step 2: Enable offline mode via deep link") { _ in
let encodedInvalidUrl = invalidApiUrl.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? invalidApiUrl
- print("[Test05] Setting API URL to invalid: \(invalidApiUrl)")
+ print("[Test03] Setting API URL to invalid: \(invalidApiUrl)")
app.openDeepLink("aliasvault://open/__debug__/set-api-url/\(encodedInvalidUrl)")
unlockVaultIfNeeded(with: testUser)
@@ -524,7 +413,7 @@ final class AliasVaultUITests: XCTestCase {
itemsScreen,
timeout: 10,
message: "Should return to items screen after deep link",
- context: "05-after-offline-deeplink"
+ context: "03-after-offline-deeplink"
)
// Trigger a sync attempt to detect offline state
@@ -535,16 +424,16 @@ final class AliasVaultUITests: XCTestCase {
offlineIndicator,
timeout: 10,
message: "Offline indicator should appear after API URL change",
- context: "05-offline-indicator"
+ context: "03-offline-indicator"
)
let offlineModeScreenshot = XCUIScreen.main.screenshot()
let attachment2 = XCTAttachment(screenshot: offlineModeScreenshot)
- attachment2.name = "05-2-offline-mode-enabled"
+ attachment2.name = "03-2-offline-mode-enabled"
attachment2.lifetime = .keepAlways
add(attachment2)
- print("[Test05] Offline mode enabled successfully")
+ print("[Test03] Offline mode enabled successfully")
}
XCTContext.runActivity(named: "Step 3: Create item while offline") { _ in
@@ -556,12 +445,12 @@ final class AliasVaultUITests: XCTestCase {
addEditScreen,
timeout: 10,
message: "Add/edit screen should appear",
- context: "05-add-edit-screen"
+ context: "03-add-edit-screen"
)
let addItemOfflineScreenshot = XCUIScreen.main.screenshot()
let attachment3 = XCTAttachment(screenshot: addItemOfflineScreenshot)
- attachment3.name = "05-3-add-item-screen-offline"
+ attachment3.name = "03-3-add-item-screen-offline"
attachment3.lifetime = .keepAlways
add(attachment3)
@@ -585,7 +474,7 @@ final class AliasVaultUITests: XCTestCase {
let itemFilledOfflineScreenshot = XCUIScreen.main.screenshot()
let attachment4 = XCTAttachment(screenshot: itemFilledOfflineScreenshot)
- attachment4.name = "05-4-item-filled-offline"
+ attachment4.name = "03-4-item-filled-offline"
attachment4.lifetime = .keepAlways
add(attachment4)
@@ -596,110 +485,127 @@ final class AliasVaultUITests: XCTestCase {
"Login credentials",
timeout: 10,
message: "Should show item detail screen after save",
- context: "05-item-saved-offline"
+ context: "03-item-saved-offline"
)
- let itemSavedOfflineScreenshot = XCUIScreen.main.screenshot()
- let attachment5 = XCTAttachment(screenshot: itemSavedOfflineScreenshot)
- attachment5.name = "05-5-item-saved-offline"
- attachment5.lifetime = .keepAlways
- add(attachment5)
+ let itemSavedOfflineScreenshot = XCUIScreen.main.screenshot()
+ let attachment5 = XCTAttachment(screenshot: itemSavedOfflineScreenshot)
+ attachment5.name = "03-5-item-saved-offline"
+ attachment5.lifetime = .keepAlways
+ add(attachment5)
- // Go back to items list
- sleep(1)
- let backButton = app.findElement(testID: "back-button")
- backButton.tapNoIdle()
+ sleep(1)
+ let backButton = app.findElement(testID: "back-button")
+ backButton.tapNoIdle()
- // Wait for items screen
- XCTAssertTrue(
- itemsScreen.waitForExistenceNoIdle(timeout: 10),
- "Should return to items screen"
- )
+ assertElementExists(
+ itemsScreen,
+ timeout: 10,
+ message: "Should return to items screen after saving",
+ context: "03-return-to-items"
+ )
- // Verify the offline-created item appears in the list
- let offlineItemCard = app.descendants(matching: .any).matching(
- NSPredicate(format: "label == %@", uniqueName)
- ).firstMatch
- XCTAssertTrue(
- offlineItemCard.waitForExistenceNoIdle(timeout: 5),
- "Offline-created item should appear in list"
- )
+ // Wait for list to fully populate after navigation
+ sleep(2)
- let itemInListOfflineScreenshot = XCUIScreen.main.screenshot()
- let attachment6 = XCTAttachment(screenshot: itemInListOfflineScreenshot)
- attachment6.name = "05-6-item-in-list-offline"
- attachment6.lifetime = .keepAlways
- add(attachment6)
+ let offlineItemCard = app.descendants(matching: .any).matching(
+ NSPredicate(format: "label == %@", uniqueName)
+ ).firstMatch
- // Step 5: Go back online by restoring valid API URL
- let encodedValidUrl = originalApiUrl.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? originalApiUrl
- app.openDeepLink("aliasvault://open/__debug__/set-api-url/\(encodedValidUrl)")
+ let itemFound = offlineItemCard.waitForExistenceNoIdle(timeout: 5)
+ if !itemFound {
+ captureFailureState(context: "03-offline-item-not-in-list")
+ XCTFail("Offline-created item '\(uniqueName)' should appear in list")
+ return
+ }
- // Deep link may cause app to lock, unlock if needed
- unlockVaultIfNeeded(with: testUser)
+ let itemInListOfflineScreenshot = XCUIScreen.main.screenshot()
+ let attachment6 = XCTAttachment(screenshot: itemInListOfflineScreenshot)
+ attachment6.name = "03-6-item-in-list-offline"
+ attachment6.lifetime = .keepAlways
+ add(attachment6)
- // Wait for deep link to be processed
- XCTAssertTrue(
- itemsScreen.waitForExistenceNoIdle(timeout: 10),
- "Should return to items screen"
- )
+ print("[Test03] Item created while offline and appears in list")
+ }
- // Small delay for state to update
- sleep(2)
+ XCTContext.runActivity(named: "Step 4: Go back online and sync") { _ in
+ let encodedValidUrl = originalApiUrl.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? originalApiUrl
+ print("[Test03] Restoring API URL to: \(originalApiUrl)")
+ app.openDeepLink("aliasvault://open/__debug__/set-api-url/\(encodedValidUrl)")
- let backOnlineScreenshot = XCUIScreen.main.screenshot()
- let attachment9 = XCTAttachment(screenshot: backOnlineScreenshot)
- attachment9.name = "05-9-back-online"
- attachment9.lifetime = .keepAlways
- add(attachment9)
+ unlockVaultIfNeeded(with: testUser)
- // Step 7: Trigger sync
- app.pullToRefresh()
+ assertElementExists(
+ itemsScreen,
+ timeout: 10,
+ message: "Should return to items screen after restoring API URL",
+ context: "03-after-online-deeplink"
+ )
- // Wait for sync to complete
- sleep(5)
+ sleep(2)
- // Verify offline indicator is gone (we should be synced now)
- XCTAssertFalse(
- offlineIndicator.exists,
- "Offline indicator should be gone after sync"
- )
+ let backOnlineScreenshot = XCUIScreen.main.screenshot()
+ let attachment9 = XCTAttachment(screenshot: backOnlineScreenshot)
+ attachment9.name = "03-9-back-online"
+ attachment9.lifetime = .keepAlways
+ add(attachment9)
- // Verify the item still exists after sync
- XCTAssertTrue(
- offlineItemCard.exists,
- "Item should still exist after sync"
- )
+ app.pullToRefresh()
+ sleep(5)
- let syncedScreenshot = XCUIScreen.main.screenshot()
- let attachment10 = XCTAttachment(screenshot: syncedScreenshot)
- attachment10.name = "05-10-synced-successfully"
- attachment10.lifetime = .keepAlways
- add(attachment10)
+ // Verify offline indicator is gone
+ if offlineIndicator.exists {
+ captureFailureState(context: "03-offline-indicator-still-present")
+ XCTFail("Offline indicator should be gone after sync")
+ }
- // Step 8: Verify item details are preserved after sync
- offlineItemCard.tapNoIdle()
+ let syncedScreenshot = XCUIScreen.main.screenshot()
+ let attachment10 = XCTAttachment(screenshot: syncedScreenshot)
+ attachment10.name = "03-10-synced-successfully"
+ attachment10.lifetime = .keepAlways
+ add(attachment10)
- // Wait for item detail screen
- XCTAssertTrue(
- app.waitForText("Login credentials", timeout: 10),
- "Should show item detail screen"
- )
+ print("[Test03] Back online and synced successfully")
+ }
- // Verify email is preserved (use waitForTextContaining for flexible matching)
- XCTAssertTrue(
- app.waitForTextContaining("offline-test@example.com", timeout: 5),
- "Email should be preserved after sync"
- )
+ XCTContext.runActivity(named: "Step 5: Verify item persists after sync") { _ in
+ let offlineItemCard = app.descendants(matching: .any).matching(
+ NSPredicate(format: "label == %@", uniqueName)
+ ).firstMatch
- let itemVerifiedAfterSyncScreenshot = XCUIScreen.main.screenshot()
- let attachment11 = XCTAttachment(screenshot: itemVerifiedAfterSyncScreenshot)
- attachment11.name = "05-11-item-verified-after-sync"
- attachment11.lifetime = .keepAlways
- add(attachment11)
+ if !offlineItemCard.exists {
+ captureFailureState(context: "03-item-missing-after-sync")
+ XCTFail("Item '\(uniqueName)' should still exist after sync")
+ return
+ }
+
+ offlineItemCard.tapNoIdle()
+
+ assertTextAppears(
+ "Login credentials",
+ timeout: 10,
+ message: "Should show item detail screen",
+ context: "03-item-detail-after-sync"
+ )
+
+ assertTextContaining(
+ "offline-test@example.com",
+ timeout: 5,
+ message: "Email should be preserved after sync",
+ context: "03-email-preserved"
+ )
+
+ let itemVerifiedAfterSyncScreenshot = XCUIScreen.main.screenshot()
+ let attachment11 = XCTAttachment(screenshot: itemVerifiedAfterSyncScreenshot)
+ attachment11.name = "03-11-item-verified-after-sync"
+ attachment11.lifetime = .keepAlways
+ add(attachment11)
+
+ print("[Test03] Offline item verified after sync - test passed")
+ }
}
- // MARK: - Test 06: RPO Recovery
+ // MARK: - Test 04: RPO Recovery
/// Verifies RPO (Recovery Point Objective) recovery scenario:
/// When the client detects that its local server revision is higher than the actual server revision
@@ -722,229 +628,571 @@ final class AliasVaultUITests: XCTestCase {
/// 5. Verify credential still exists (client data was uploaded, not downloaded from server)
/// 6. Verify server revision is restored via API
@MainActor
- func test06RPORecovery() async throws {
+ func test04RPORecovery() async throws {
let testUser = try await ensureTestUser()
let uniqueName = TestConfiguration.generateUniqueName(prefix: "RPO Test")
+ print("[Test04] Testing RPO recovery with item: \(uniqueName)")
app.launch()
unlockVaultIfNeeded(with: testUser)
let itemsScreen = app.findElement(testID: "items-screen")
- XCTAssertTrue(
- itemsScreen.waitForExistenceNoIdle(timeout: TestConfiguration.extendedTimeout),
- "Should be on items screen after launch/unlock"
- )
+ var initialRevision = 0
+ var revisionAfterCreate = 0
+ var revisionAfterDelete = 0
- let initialStateScreenshot = XCUIScreen.main.screenshot()
- let attachment1 = XCTAttachment(screenshot: initialStateScreenshot)
- attachment1.name = "06-1-initial-state"
- attachment1.lifetime = .keepAlways
- add(attachment1)
+ XCTContext.runActivity(named: "Step 1: Verify initial state and get server revision") { _ in
+ assertElementExists(
+ itemsScreen,
+ timeout: TestConfiguration.extendedTimeout,
+ message: "Should be on items screen after launch/unlock",
+ context: "04-items-screen"
+ )
- // Step 1: Get initial server revision before creating credential
- let initialRevisions = try await TestUserRegistration.getVaultRevisions(token: testUser.token)
- let initialRevision = initialRevisions.currentRevision
- print("[RPO Test] Initial server revision: \(initialRevision)")
+ let initialStateScreenshot = XCUIScreen.main.screenshot()
+ let attachment1 = XCTAttachment(screenshot: initialStateScreenshot)
+ attachment1.name = "04-1-initial-state"
+ attachment1.lifetime = .keepAlways
+ add(attachment1)
+ }
- // Step 2: Create a credential while online (syncs to server normally)
- let addItemButton = app.findElement(testID: "add-item-button")
- addItemButton.tapNoIdle()
+ // Get initial revision using username-based API (no auth token needed)
+ let initialRevisions = try await TestUserRegistration.getVaultRevisionsByUsername(username: testUser.username)
+ initialRevision = initialRevisions.currentRevision
+ print("[Test04] Initial server revision: \(initialRevision)")
- let addEditScreen = app.findElement(testID: "add-edit-screen")
- XCTAssertTrue(
- addEditScreen.waitForExistenceNoIdle(timeout: 10),
- "Add/edit screen should appear"
- )
+ XCTContext.runActivity(named: "Step 2: Create credential while online") { _ in
+ let addItemButton = app.findElement(testID: "add-item-button")
+ addItemButton.tapNoIdle()
- // Enter item name
- let itemNameInput = app.findAndScrollToTextField(testID: "item-name-input")
- itemNameInput.tapNoIdle()
- itemNameInput.typeText(uniqueName)
+ let addEditScreen = app.findElement(testID: "add-edit-screen")
+ assertElementExists(
+ addEditScreen,
+ timeout: 10,
+ message: "Add/edit screen should appear",
+ context: "04-add-edit-screen"
+ )
- // Enter service URL
- let serviceUrlInput = app.findAndScrollToTextField(testID: "service-url-input")
- serviceUrlInput.tapNoIdle()
- serviceUrlInput.typeText("https://rpo-test.example.com")
+ let itemNameInput = app.findAndScrollToTextField(testID: "item-name-input")
+ itemNameInput.tapNoIdle()
+ itemNameInput.typeText(uniqueName)
- // Add email field
- let addEmailButton = app.findElement(testID: "add-email-button")
- app.scrollToElement(addEmailButton)
- addEmailButton.tapNoIdle()
+ let serviceUrlInput = app.findAndScrollToTextField(testID: "service-url-input")
+ serviceUrlInput.tapNoIdle()
+ serviceUrlInput.typeText("https://rpo-test.example.com")
- // Enter email
- let loginEmailInput = app.findAndScrollToTextField(testID: "login-email-input")
- loginEmailInput.tapNoIdle()
- loginEmailInput.typeText("rpo-test@example.com")
+ let addEmailButton = app.findElement(testID: "add-email-button")
+ app.scrollToElement(addEmailButton)
+ addEmailButton.tapNoIdle()
- app.hideKeyboardIfVisible()
+ let loginEmailInput = app.findAndScrollToTextField(testID: "login-email-input")
+ loginEmailInput.tapNoIdle()
+ loginEmailInput.typeText("rpo-test@example.com")
- let credentialCreatedScreenshot = XCUIScreen.main.screenshot()
- let attachment2 = XCTAttachment(screenshot: credentialCreatedScreenshot)
- attachment2.name = "06-2-credential-created"
- attachment2.lifetime = .keepAlways
- add(attachment2)
+ app.hideKeyboardIfVisible()
- // Save the item (syncs to server since we're online)
- let saveButton = app.findElement(testID: "save-button")
- saveButton.tapNoIdle()
+ let credentialCreatedScreenshot = XCUIScreen.main.screenshot()
+ let attachment2 = XCTAttachment(screenshot: credentialCreatedScreenshot)
+ attachment2.name = "04-2-credential-created"
+ attachment2.lifetime = .keepAlways
+ add(attachment2)
- // Wait for item to be saved
- XCTAssertTrue(
- app.waitForText("Login credentials", timeout: 10),
- "Should show item detail screen after save"
- )
+ let saveButton = app.findElement(testID: "save-button")
+ saveButton.tapNoIdle()
- // Go back to items list
- sleep(1)
- let backButton = app.findElement(testID: "back-button")
- backButton.tapNoIdle()
+ assertTextAppears(
+ "Login credentials",
+ timeout: 10,
+ message: "Should show item detail screen after save",
+ context: "04-item-saved"
+ )
- // Wait for items screen
- XCTAssertTrue(
- itemsScreen.waitForExistenceNoIdle(timeout: 10),
- "Should return to items screen"
- )
+ sleep(1)
+ let backButton = app.findElement(testID: "back-button")
+ backButton.tapNoIdle()
- // Step 3: Wait for sync to complete (credential is now on server)
- sleep(3)
+ assertElementExists(
+ itemsScreen,
+ timeout: 10,
+ message: "Should return to items screen",
+ context: "04-return-to-items"
+ )
+
+ sleep(1) // Wait for sync
+
+ print("[Test04] Credential created and synced to server")
+ }
+
+ // Verify revision increased (async)
+ let afterCreateRevisions = try await TestUserRegistration.getVaultRevisionsByUsername(username: testUser.username)
+ revisionAfterCreate = afterCreateRevisions.currentRevision
+ print("[Test04] Server revision after create: \(revisionAfterCreate)")
- // Verify server revision increased after sync
- let afterCreateRevisions = try await TestUserRegistration.getVaultRevisions(token: testUser.token)
- let revisionAfterCreate = afterCreateRevisions.currentRevision
- print("[RPO Test] Server revision after create: \(revisionAfterCreate)")
XCTAssertGreaterThan(
revisionAfterCreate, initialRevision,
"Server revision should increase after creating credential (was \(initialRevision), now \(revisionAfterCreate))"
)
- let afterSyncScreenshot = XCUIScreen.main.screenshot()
- let attachment3 = XCTAttachment(screenshot: afterSyncScreenshot)
- attachment3.name = "06-3-after-initial-sync"
- attachment3.lifetime = .keepAlways
- add(attachment3)
+ XCTContext.runActivity(named: "Step 3: Simulate server data loss") { _ in
+ let afterSyncScreenshot = XCUIScreen.main.screenshot()
+ let attachment3 = XCTAttachment(screenshot: afterSyncScreenshot)
+ attachment3.name = "04-3-after-initial-sync"
+ attachment3.lifetime = .keepAlways
+ add(attachment3)
+ }
- // Step 4: Simulate server data loss by deleting the latest vault revision
- // This makes the server roll back to an older state (without the credential we just created)
- // The client still has the credential locally and thinks it synced at the higher revision
- let deletedCount = try await TestUserRegistration.deleteVaultRevisions(
- count: 1,
- token: testUser.token
+ // Delete vault revision to simulate data loss (async)
+ let deletedCount = try await TestUserRegistration.deleteVaultRevisionsByUsername(
+ username: testUser.username,
+ count: 1
)
- print("[RPO Test] Deleted \(deletedCount) vault revision(s) from server to simulate data loss")
+ print("[Test04] Deleted \(deletedCount) vault revision(s) from server to simulate data loss")
+
+ let afterDeleteRevisions = try await TestUserRegistration.getVaultRevisionsByUsername(username: testUser.username)
+ revisionAfterDelete = afterDeleteRevisions.currentRevision
+ print("[Test04] Server revision after delete: \(revisionAfterDelete)")
- // Verify server revision decreased (simulating rollback)
- let afterDeleteRevisions = try await TestUserRegistration.getVaultRevisions(token: testUser.token)
- let revisionAfterDelete = afterDeleteRevisions.currentRevision
- print("[RPO Test] Server revision after delete: \(revisionAfterDelete)")
XCTAssertLessThan(
revisionAfterDelete, revisionAfterCreate,
"Server revision should decrease after deleting vault revision (was \(revisionAfterCreate), now \(revisionAfterDelete))"
)
- let afterServerRollbackScreenshot = XCUIScreen.main.screenshot()
- let attachment4 = XCTAttachment(screenshot: afterServerRollbackScreenshot)
- attachment4.name = "06-4-after-server-rollback"
- attachment4.lifetime = .keepAlways
- add(attachment4)
+ XCTContext.runActivity(named: "Step 4: Trigger sync for RPO recovery") { _ in
+ let afterServerRollbackScreenshot = XCUIScreen.main.screenshot()
+ let attachment4 = XCTAttachment(screenshot: afterServerRollbackScreenshot)
+ attachment4.name = "04-4-after-server-rollback"
+ attachment4.lifetime = .keepAlways
+ add(attachment4)
- // Step 5: Trigger sync - this should detect RPO scenario and upload vault
- // Client thinks: "I'm at revision N, server says revision N-1 (lower)"
- // → This triggers the RPO recovery path: upload client data to "recover" server
- app.pullToRefresh()
+ print("[Test04] Triggering sync - client should detect RPO scenario and upload vault")
+ app.pullToRefresh()
+ sleep(5)
- // Wait for sync to complete
- sleep(5)
+ let afterRpoSyncScreenshot = XCUIScreen.main.screenshot()
+ let attachment5 = XCTAttachment(screenshot: afterRpoSyncScreenshot)
+ attachment5.name = "04-5-after-rpo-sync"
+ attachment5.lifetime = .keepAlways
+ add(attachment5)
+ }
- let afterRpoSyncScreenshot = XCUIScreen.main.screenshot()
- let attachment5 = XCTAttachment(screenshot: afterRpoSyncScreenshot)
- attachment5.name = "06-5-after-rpo-sync"
- attachment5.lifetime = .keepAlways
- add(attachment5)
+ XCTContext.runActivity(named: "Step 5: Verify credential persists after RPO recovery") { _ in
+ let rpoItemCard = app.descendants(matching: .any).matching(
+ NSPredicate(format: "label == %@", uniqueName)
+ ).firstMatch
- // Step 6: Verify the credential still exists after RPO recovery
- // If the client correctly uploaded its data (RPO recovery path),
- // the credential should still be present
- // If client had downloaded from server instead, the credential would be GONE
- // (because we deleted the server revision that contained it)
- let rpoItemCard = app.descendants(matching: .any).matching(
- NSPredicate(format: "label == %@", uniqueName)
- ).firstMatch
+ let itemExists = rpoItemCard.waitForExistenceNoIdle(timeout: 10)
+ if !itemExists {
+ captureFailureState(context: "04-rpo-item-missing")
+ XCTFail("Credential '\(uniqueName)' should still exist after RPO recovery - this proves client uploaded to server instead of downloading (which would have lost the credential)")
+ return
+ }
- XCTAssertTrue(
- rpoItemCard.waitForExistenceNoIdle(timeout: 10),
- "Credential '\(uniqueName)' should still exist after RPO recovery - proves client uploaded to server"
- )
+ rpoItemCard.tapNoIdle()
- // Tap to verify item details are preserved
- rpoItemCard.tapNoIdle()
+ assertTextAppears(
+ "Login credentials",
+ timeout: 10,
+ message: "Should show item detail screen",
+ context: "04-item-detail"
+ )
- XCTAssertTrue(
- app.waitForText("Login credentials", timeout: 10),
- "Should show item detail screen"
- )
+ assertTextContaining(
+ "rpo-test@example.com",
+ timeout: 5,
+ message: "Email should be preserved after RPO recovery",
+ context: "04-email-preserved"
+ )
- // Verify email is preserved
- XCTAssertTrue(
- app.waitForTextContaining("rpo-test@example.com", timeout: 5),
- "Email should be preserved after RPO recovery"
- )
+ let itemVerifiedScreenshot = XCUIScreen.main.screenshot()
+ let attachment6 = XCTAttachment(screenshot: itemVerifiedScreenshot)
+ attachment6.name = "04-6-item-verified-after-rpo"
+ attachment6.lifetime = .keepAlways
+ add(attachment6)
+ }
- let itemVerifiedScreenshot = XCUIScreen.main.screenshot()
- let attachment6 = XCTAttachment(screenshot: itemVerifiedScreenshot)
- attachment6.name = "06-6-item-verified-after-rpo"
- attachment6.lifetime = .keepAlways
- add(attachment6)
-
- // Step 7: Verify server revision is restored via API
- // After RPO recovery, client should have uploaded its vault, restoring the revision
- let finalRevisions = try await TestUserRegistration.getVaultRevisions(token: testUser.token)
+ // Verify server revision restored (async)
+ let finalRevisions = try await TestUserRegistration.getVaultRevisionsByUsername(username: testUser.username)
let finalRevision = finalRevisions.currentRevision
- print("[RPO Test] Final server revision: \(finalRevision)")
+ print("[Test04] Final server revision: \(finalRevision)")
- // The final revision should be at least as high as after create
- // (client uploaded, creating a new revision)
XCTAssertGreaterThanOrEqual(
finalRevision, revisionAfterCreate,
"Server revision should be restored after RPO recovery (expected >= \(revisionAfterCreate), got \(finalRevision))"
)
- // Log success summary
- print("[RPO Test] SUCCESS - Revision flow: \(initialRevision) → \(revisionAfterCreate) (create) → \(revisionAfterDelete) (rollback) → \(finalRevision) (recovered)")
+ print("[Test04] SUCCESS - Revision flow: \(initialRevision) → \(revisionAfterCreate) (create) → \(revisionAfterDelete) (rollback) → \(finalRevision) (recovered)")
+ }
+
+ // MARK: - Test 05: Forced Logout Recovery
+
+ /// Verifies forced logout recovery mechanism:
+ /// When a forced logout occurs (e.g., 401 unauthorized due to token invalidation),
+ /// the client should preserve the encrypted vault locally. On next login with the same
+ /// credentials, the client should detect the preserved vault and recover by uploading
+ /// it to the server.
+ ///
+ /// This is different from normal logout:
+ /// - Normal logout: User explicitly logs out → vault is cleared
+ /// - Forced logout: Server rejects token → vault preserved locally for recovery
+ ///
+ /// Test flow:
+ /// 1. Login and create a credential (vault synced to server)
+ /// 2. Simulate server data loss by deleting latest vault revision
+ /// 3. Invalidate refresh tokens via API (simulates token expiry)
+ /// 4. Trigger API call to cause forced logout (401)
+ /// 5. Verify app shows login screen with username prefilled (orphan vault preserved)
+ /// 6. Re-login with same credentials
+ /// 7. Verify credential still exists (vault was recovered from local preserved copy)
+ /// 8. Verify server revision is restored
+ @MainActor
+ func test05ForcedLogoutRecovery() async throws {
+ let testUser = try await ensureTestUser()
+ let uniqueName = TestConfiguration.generateUniqueName(prefix: "Forced Logout Test")
+ print("[Test05] Testing forced logout recovery with item: \(uniqueName)")
+
+ app.launch()
+ unlockVaultIfNeeded(with: testUser)
+
+ let itemsScreen = app.findElement(testID: "items-screen")
+ var revisionBeforeLogout = 0
+
+ XCTContext.runActivity(named: "Step 1: Verify initial state") { _ in
+ assertElementExists(
+ itemsScreen,
+ timeout: TestConfiguration.extendedTimeout,
+ message: "Should be on items screen after launch/unlock",
+ context: "05-items-screen"
+ )
+
+ let initialStateScreenshot = XCUIScreen.main.screenshot()
+ let attachment1 = XCTAttachment(screenshot: initialStateScreenshot)
+ attachment1.name = "05-1-initial-state"
+ attachment1.lifetime = .keepAlways
+ add(attachment1)
+ }
+
+ XCTContext.runActivity(named: "Step 2: Create credential while online") { _ in
+ let addItemButton = app.findElement(testID: "add-item-button")
+ addItemButton.tapNoIdle()
+
+ let addEditScreen = app.findElement(testID: "add-edit-screen")
+ assertElementExists(
+ addEditScreen,
+ timeout: 10,
+ message: "Add/edit screen should appear",
+ context: "05-add-edit-screen"
+ )
+
+ let itemNameInput = app.findAndScrollToTextField(testID: "item-name-input")
+ itemNameInput.tapNoIdle()
+ itemNameInput.typeText(uniqueName)
+
+ let serviceUrlInput = app.findAndScrollToTextField(testID: "service-url-input")
+ serviceUrlInput.tapNoIdle()
+ serviceUrlInput.typeText("https://forced-logout-test.example.com")
+
+ let addEmailButton = app.findElement(testID: "add-email-button")
+ app.scrollToElement(addEmailButton)
+ addEmailButton.tapNoIdle()
+
+ let loginEmailInput = app.findAndScrollToTextField(testID: "login-email-input")
+ loginEmailInput.tapNoIdle()
+ loginEmailInput.typeText("forced-logout-test@example.com")
+
+ app.hideKeyboardIfVisible()
+
+ let credentialCreatedScreenshot = XCUIScreen.main.screenshot()
+ let attachment2 = XCTAttachment(screenshot: credentialCreatedScreenshot)
+ attachment2.name = "05-2-credential-created"
+ attachment2.lifetime = .keepAlways
+ add(attachment2)
+
+ let saveButton = app.findElement(testID: "save-button")
+ saveButton.tapNoIdle()
+
+ assertTextAppears(
+ "Login credentials",
+ timeout: 10,
+ message: "Should show item detail screen after save",
+ context: "05-item-saved"
+ )
+
+ sleep(1)
+ let backButton = app.findElement(testID: "back-button")
+ backButton.tapNoIdle()
+
+ assertElementExists(
+ itemsScreen,
+ timeout: 10,
+ message: "Should return to items screen",
+ context: "05-return-to-items"
+ )
+
+ sleep(3) // Wait for sync
+
+ print("[Test05] Credential created and synced to server")
+ }
+
+ // Get revision before logout using username-based API (no auth token needed)
+ let beforeLogoutRevisions = try await TestUserRegistration.getVaultRevisionsByUsername(username: testUser.username)
+ revisionBeforeLogout = beforeLogoutRevisions.currentRevision
+ print("[Test05] Server revision before forced logout: \(revisionBeforeLogout)")
+
+ // Simulate server data loss (delete latest revision)
+ let deletedCount = try await TestUserRegistration.deleteVaultRevisionsByUsername(
+ username: testUser.username,
+ count: 1
+ )
+ print("[Test05] Deleted \(deletedCount) vault revision(s) to simulate server data loss")
+
+ let afterRollbackRevisions = try await TestUserRegistration.getVaultRevisionsByUsername(username: testUser.username)
+ let revisionAfterRollback = afterRollbackRevisions.currentRevision
+ print("[Test05] Server revision after rollback: \(revisionAfterRollback)")
+
+ XCTContext.runActivity(named: "Step 3: Invalidate tokens and trigger forced logout") { _ in
+ let beforeForcedLogoutScreenshot = XCUIScreen.main.screenshot()
+ let attachment3 = XCTAttachment(screenshot: beforeForcedLogoutScreenshot)
+ attachment3.name = "05-3-before-forced-logout"
+ attachment3.lifetime = .keepAlways
+ add(attachment3)
+ }
+
+ // Block user via API (this will cause 401 on next auth check)
+ try await TestUserRegistration.blockUserByUsername(username: testUser.username)
+ print("[Test05] User blocked")
+
+ XCTContext.runActivity(named: "Step 4: Trigger sync to cause forced logout") { _ in
+ // Pull to refresh will trigger API call which should fail with 401
+ // and cause forced logout
+ print("[Test05] Triggering sync to cause forced logout...")
+ app.pullToRefresh()
+
+ // Wait for forced logout to occur
+ sleep(5)
+
+ let afterForcedLogoutScreenshot = XCUIScreen.main.screenshot()
+ let attachment4 = XCTAttachment(screenshot: afterForcedLogoutScreenshot)
+ attachment4.name = "05-4-after-forced-logout"
+ attachment4.lifetime = .keepAlways
+ add(attachment4)
+ }
+
+ XCTContext.runActivity(named: "Step 5: Verify login screen with prefilled username") { _ in
+ // Should be on login screen after forced logout
+ let loginScreen = app.findElement(testID: "login-screen")
+ assertElementExists(
+ loginScreen,
+ timeout: 15,
+ message: "Should be on login screen after forced logout",
+ context: "05-login-screen-after-logout"
+ )
+
+ // Verify username is prefilled (orphan vault preservation)
+ let usernameInput = app.findTextField(testID: "username-input")
+ if usernameInput.waitForExistenceNoIdle(timeout: 5) {
+ let usernameValue = usernameInput.value as? String ?? ""
+ print("[Test05] Username field value: '\(usernameValue)'")
+ // Note: Username may or may not be prefilled depending on implementation
+ }
+
+ let loginScreenScreenshot = XCUIScreen.main.screenshot()
+ let attachment5 = XCTAttachment(screenshot: loginScreenScreenshot)
+ attachment5.name = "05-5-login-screen-after-forced-logout"
+ attachment5.lifetime = .keepAlways
+ add(attachment5)
+
+ print("[Test05] Forced logout confirmed - on login screen")
+ }
+
+ // Unblock user so they can log in again
+ try await TestUserRegistration.unblockUserByUsername(username: testUser.username)
+ print("[Test05] User unblocked")
+
+ XCTContext.runActivity(named: "Step 6: Re-login with same credentials") { _ in
+ // Clear username field and enter credentials
+ let usernameInput = app.findTextField(testID: "username-input")
+ usernameInput.tapNoIdle()
+ usernameInput.clearAndTypeTextNoIdle(testUser.username)
+
+ let passwordInput = app.findTextField(testID: "password-input")
+ passwordInput.tapNoIdle()
+ passwordInput.typeText(testUser.password)
+
+ app.hideKeyboardIfVisible()
+
+ let credentialsScreenshot = XCUIScreen.main.screenshot()
+ let attachment6 = XCTAttachment(screenshot: credentialsScreenshot)
+ attachment6.name = "05-6-credentials-entered"
+ attachment6.lifetime = .keepAlways
+ add(attachment6)
+
+ let loginButton = app.findElement(testID: "login-button")
+ loginButton.tapNoIdle()
+
+ // Wait for login to complete and vault to load
+ assertElementExists(
+ itemsScreen,
+ timeout: TestConfiguration.extendedTimeout,
+ message: "Should navigate to items screen after re-login",
+ context: "05-items-after-relogin"
+ )
+
+ // Wait for sync to complete
+ sleep(5)
+
+ let afterReloginScreenshot = XCUIScreen.main.screenshot()
+ let attachment7 = XCTAttachment(screenshot: afterReloginScreenshot)
+ attachment7.name = "05-7-after-relogin"
+ attachment7.lifetime = .keepAlways
+ add(attachment7)
+
+ print("[Test05] Re-login successful")
+ }
+
+ XCTContext.runActivity(named: "Step 7: Verify credential still exists") { _ in
+ // The credential should still exist because:
+ // 1. It was in the local preserved vault during forced logout
+ // 2. On re-login, client detected local vault is more advanced than server
+ // 3. Client uploaded local vault to recover server
+ let itemCard = app.descendants(matching: .any).matching(
+ NSPredicate(format: "label == %@", uniqueName)
+ ).firstMatch
+
+ let itemExists = itemCard.waitForExistenceNoIdle(timeout: 10)
+ if !itemExists {
+ captureFailureState(context: "05-credential-missing")
+ XCTFail("Credential '\(uniqueName)' should still exist after forced logout recovery - this proves vault was preserved locally and uploaded to server")
+ return
+ }
+
+ itemCard.tapNoIdle()
+
+ assertTextAppears(
+ "Login credentials",
+ timeout: 10,
+ message: "Should show item detail screen",
+ context: "05-item-detail"
+ )
+
+ assertTextContaining(
+ "forced-logout-test@example.com",
+ timeout: 5,
+ message: "Email should be preserved after forced logout recovery",
+ context: "05-email-preserved"
+ )
+
+ let itemVerifiedScreenshot = XCUIScreen.main.screenshot()
+ let attachment8 = XCTAttachment(screenshot: itemVerifiedScreenshot)
+ attachment8.name = "05-8-item-verified-after-recovery"
+ attachment8.lifetime = .keepAlways
+ add(attachment8)
+
+ print("[Test05] Credential verified after forced logout recovery")
+ }
+
+ // Verify server revision is restored using username-based API (no auth token needed)
+ let finalRevisions = try await TestUserRegistration.getVaultRevisionsByUsername(username: testUser.username)
+ let finalRevision = finalRevisions.currentRevision
+ print("[Test05] Final server revision: \(finalRevision)")
+
+ XCTAssertGreaterThanOrEqual(
+ finalRevision, revisionBeforeLogout,
+ "Server revision should be restored after forced logout recovery (expected >= \(revisionBeforeLogout), got \(finalRevision))"
+ )
+
+ print("[Test05] SUCCESS - Forced logout recovery verified!")
+ print("[Test05] Revision flow: \(revisionBeforeLogout) (before) → \(revisionAfterRollback) (rollback) → \(finalRevision) (recovered)")
}
// MARK: - Helper Methods
- /// Checks if the unlock screen is displayed and unlocks the vault if needed.
- /// Called after app launch or deep links, which may trigger the vault to lock.
- /// - Parameter testUser: The test user whose password will be used for unlock
+ /// Checks if the unlock screen is displayed and handles it by logging out and logging in fresh.
+ /// This ensures we're using the test user created by ensureTestUser(), not a stale user from a previous run.
+ /// Called after app launch when a different user might be logged in.
+ /// - Parameter testUser: The test user to login with after logout
@MainActor
private func unlockVaultIfNeeded(with testUser: TestUser) {
sleep(1) // Allow app to settle
let unlockScreen = app.findElement(testID: "unlock-screen")
guard unlockScreen.waitForExistenceNoIdle(timeout: 3) else {
- return // Not on unlock screen, nothing to do
+ // Check if we're on login screen already
+ let loginScreen = app.findElement(testID: "login-screen")
+ if loginScreen.waitForExistenceNoIdle(timeout: 2) {
+ // Already on login screen, just login with test user
+ performLogin(with: testUser)
+ }
+ return
}
- // Wait for form to be ready (loading state finished)
- _ = app.waitForText("Unlock Vault", timeout: 5)
+ // We're on unlock screen - this means a different user might be logged in
+ // Logout and login fresh with our test user to ensure consistency
+ print("[Helper] Unlock screen detected - logging out to login fresh with test user")
- // Enter password
- let passwordInput = app.findTextField(testID: "unlock-password-input")
- if passwordInput.waitForExistenceNoIdle(timeout: 5) {
+ // Find and tap logout button on unlock screen
+ let logoutButton = app.findElement(testID: "logout-button")
+ if logoutButton.waitForExistenceNoIdle(timeout: 5) {
+ logoutButton.tapNoIdle()
+
+ // Handle logout confirmation alert if present
+ let confirmButton = app.buttons["Log Out"]
+ if confirmButton.waitForExistence(timeout: 3) {
+ confirmButton.tap()
+ }
+ }
+
+ // Wait for login screen
+ let loginScreen = app.findElement(testID: "login-screen")
+ _ = loginScreen.waitForExistenceNoIdle(timeout: 10)
+
+ // Login with test user
+ performLogin(with: testUser)
+ }
+
+ /// Performs login with the given test user credentials.
+ /// - Parameter testUser: The test user to login with
+ @MainActor
+ private func performLogin(with testUser: TestUser) {
+ // Configure self-hosted URL
+ let selfHostedOption = app.findElement(testID: "self-hosted-option")
+ if selfHostedOption.waitForExistenceNoIdle(timeout: 5) {
+ selfHostedOption.tapNoIdle()
+
+ let customUrlInput = app.findTextField(testID: "custom-url-input")
+ if customUrlInput.waitForExistenceNoIdle(timeout: 5) {
+ customUrlInput.clearAndTypeTextNoIdle(TestConfiguration.apiUrl)
+ }
+
+ app.hideKeyboardIfVisible()
+
+ // Tap back/return to login form
+ let backButton = app.findElement(testID: "back-to-login-button")
+ if backButton.waitForExistenceNoIdle(timeout: 3) {
+ backButton.tapNoIdle()
+ }
+ }
+
+ // Enter credentials
+ let usernameInput = app.findTextField(testID: "username-input")
+ if usernameInput.waitForExistenceNoIdle(timeout: 5) {
+ usernameInput.clearAndTypeTextNoIdle(testUser.username)
+ }
+
+ let passwordInput = app.findTextField(testID: "password-input")
+ if passwordInput.waitForExistenceNoIdle(timeout: 3) {
passwordInput.tapNoIdle()
passwordInput.typeText(testUser.password)
}
app.hideKeyboardIfVisible()
- // Tap unlock button
- let unlockButton = app.findElement(testID: "unlock-button")
- if unlockButton.waitForExistenceNoIdle(timeout: 3) {
- unlockButton.tapNoIdle()
+ // Tap login button
+ let loginButton = app.findElement(testID: "login-button")
+ if loginButton.waitForExistenceNoIdle(timeout: 3) {
+ loginButton.tapNoIdle()
}
- // Wait for items screen (unlock redirects through reinitialize)
+ // Wait for items screen
let itemsScreen = app.findElement(testID: "items-screen")
_ = itemsScreen.waitForExistenceNoIdle(timeout: TestConfiguration.extendedTimeout)
}
diff --git a/apps/mobile-app/ios/AliasVaultUITests/TestUserRegistration.swift b/apps/mobile-app/ios/AliasVaultUITests/TestUserRegistration.swift
index 25bcb17db..334913325 100644
--- a/apps/mobile-app/ios/AliasVaultUITests/TestUserRegistration.swift
+++ b/apps/mobile-app/ios/AliasVaultUITests/TestUserRegistration.swift
@@ -486,4 +486,239 @@ enum TestUserRegistration {
return (0, 0)
}
+
+ /// Block the authenticated user's account.
+ /// This endpoint only works in development mode.
+ /// Used for testing forced logout scenarios - blocked users get 401 from /status endpoint.
+ ///
+ /// - Parameters:
+ /// - token: Authentication token
+ /// - apiBaseUrl: Optional API base URL (defaults to apiUrl)
+ static func blockUser(
+ token: String,
+ apiBaseUrl: String? = nil
+ ) async throws {
+ let url = (apiBaseUrl ?? apiUrl).trimmingCharacters(in: CharacterSet(charactersIn: "/")) + "/v1/"
+
+ var request = URLRequest(url: URL(string: "\(url)Test/block-user")!)
+ request.httpMethod = "POST"
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
+ request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
+
+ let (data, response) = try await URLSession.shared.data(for: request)
+
+ guard let httpResponse = response as? HTTPURLResponse else {
+ throw NSError(domain: "TestUserRegistration", code: 11,
+ userInfo: [NSLocalizedDescriptionKey: "Invalid response"])
+ }
+
+ guard httpResponse.statusCode == 200 else {
+ let errorText = String(data: data, encoding: .utf8) ?? "Unknown error"
+ throw NSError(domain: "TestUserRegistration", code: httpResponse.statusCode,
+ userInfo: [NSLocalizedDescriptionKey: "Failed to block user: \(errorText)"])
+ }
+ }
+
+ /// Unblock the authenticated user's account.
+ /// This endpoint only works in development mode.
+ /// Used after forced logout tests to re-enable the account.
+ ///
+ /// - Parameters:
+ /// - token: Authentication token
+ /// - apiBaseUrl: Optional API base URL (defaults to apiUrl)
+ static func unblockUser(
+ token: String,
+ apiBaseUrl: String? = nil
+ ) async throws {
+ let url = (apiBaseUrl ?? apiUrl).trimmingCharacters(in: CharacterSet(charactersIn: "/")) + "/v1/"
+
+ var request = URLRequest(url: URL(string: "\(url)Test/unblock-user")!)
+ request.httpMethod = "POST"
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
+ request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
+
+ let (data, response) = try await URLSession.shared.data(for: request)
+
+ guard let httpResponse = response as? HTTPURLResponse else {
+ throw NSError(domain: "TestUserRegistration", code: 12,
+ userInfo: [NSLocalizedDescriptionKey: "Invalid response"])
+ }
+
+ guard httpResponse.statusCode == 200 else {
+ let errorText = String(data: data, encoding: .utf8) ?? "Unknown error"
+ throw NSError(domain: "TestUserRegistration", code: httpResponse.statusCode,
+ userInfo: [NSLocalizedDescriptionKey: "Failed to unblock user: \(errorText)"])
+ }
+ }
+
+ // MARK: - Username-based API calls (for UI tests that can't access app tokens)
+
+ /// Get vault revision information for a user by username.
+ /// This is an anonymous endpoint that doesn't require authentication.
+ /// Used by UI tests that cannot access the app's auth token.
+ ///
+ /// - Parameters:
+ /// - username: The username to look up
+ /// - apiBaseUrl: Optional API base URL (defaults to apiUrl)
+ /// - Returns: Tuple of (count, currentRevision)
+ static func getVaultRevisionsByUsername(
+ username: String,
+ apiBaseUrl: String? = nil
+ ) async throws -> (count: Int, currentRevision: Int) {
+ let url = (apiBaseUrl ?? apiUrl).trimmingCharacters(in: CharacterSet(charactersIn: "/")) + "/v1/"
+ // Use alphanumerics for safe URL path encoding (encodes @, ., etc.)
+ let encodedUsername = username.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? username
+
+ guard let requestUrl = URL(string: "\(url)Test/vault-revisions/by-username/\(encodedUsername)") else {
+ throw NSError(domain: "TestUserRegistration", code: 13,
+ userInfo: [NSLocalizedDescriptionKey: "Invalid URL for username: \(username)"])
+ }
+ var request = URLRequest(url: requestUrl)
+ request.httpMethod = "GET"
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
+
+ let (data, response) = try await URLSession.shared.data(for: request)
+
+ guard let httpResponse = response as? HTTPURLResponse else {
+ throw NSError(domain: "TestUserRegistration", code: 13,
+ userInfo: [NSLocalizedDescriptionKey: "Invalid response"])
+ }
+
+ guard httpResponse.statusCode == 200 else {
+ let errorText = String(data: data, encoding: .utf8) ?? "Unknown error"
+ throw NSError(domain: "TestUserRegistration", code: httpResponse.statusCode,
+ userInfo: [NSLocalizedDescriptionKey: "Failed to get vault revisions: \(errorText)"])
+ }
+
+ // Parse response
+ if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
+ let count = json["count"] as? Int,
+ let currentRevision = json["currentRevision"] as? Int {
+ return (count, currentRevision)
+ }
+
+ return (0, 0)
+ }
+
+ /// Delete the newest vault revisions for a user by username.
+ /// This is an anonymous endpoint that doesn't require authentication.
+ /// Used by UI tests that cannot access the app's auth token.
+ ///
+ /// - Parameters:
+ /// - username: The username to look up
+ /// - count: Number of newest revisions to delete
+ /// - apiBaseUrl: Optional API base URL (defaults to apiUrl)
+ /// - Returns: Number of deleted revisions
+ static func deleteVaultRevisionsByUsername(
+ username: String,
+ count: Int,
+ apiBaseUrl: String? = nil
+ ) async throws -> Int {
+ let url = (apiBaseUrl ?? apiUrl).trimmingCharacters(in: CharacterSet(charactersIn: "/")) + "/v1/"
+ // Use alphanumerics for safe URL path encoding (encodes @, ., etc.)
+ let encodedUsername = username.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? username
+
+ guard let requestUrl = URL(string: "\(url)Test/vault-revisions/by-username/\(encodedUsername)/\(count)") else {
+ throw NSError(domain: "TestUserRegistration", code: 14,
+ userInfo: [NSLocalizedDescriptionKey: "Invalid URL for username: \(username)"])
+ }
+ var request = URLRequest(url: requestUrl)
+ request.httpMethod = "DELETE"
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
+
+ let (data, response) = try await URLSession.shared.data(for: request)
+
+ guard let httpResponse = response as? HTTPURLResponse else {
+ throw NSError(domain: "TestUserRegistration", code: 14,
+ userInfo: [NSLocalizedDescriptionKey: "Invalid response"])
+ }
+
+ guard httpResponse.statusCode == 200 else {
+ let errorText = String(data: data, encoding: .utf8) ?? "Unknown error"
+ throw NSError(domain: "TestUserRegistration", code: httpResponse.statusCode,
+ userInfo: [NSLocalizedDescriptionKey: "Failed to delete vault revisions: \(errorText)"])
+ }
+
+ // Parse response
+ if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
+ let deleted = json["deleted"] as? Int {
+ return deleted
+ }
+
+ return 0
+ }
+
+ /// Block a user's account by username.
+ /// This is an anonymous endpoint that doesn't require authentication.
+ /// Used by UI tests that cannot access the app's auth token.
+ ///
+ /// - Parameters:
+ /// - username: The username to block
+ /// - apiBaseUrl: Optional API base URL (defaults to apiUrl)
+ static func blockUserByUsername(
+ username: String,
+ apiBaseUrl: String? = nil
+ ) async throws {
+ let url = (apiBaseUrl ?? apiUrl).trimmingCharacters(in: CharacterSet(charactersIn: "/")) + "/v1/"
+ // Use alphanumerics for safe URL path encoding (encodes @, ., etc.)
+ let encodedUsername = username.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? username
+
+ guard let requestUrl = URL(string: "\(url)Test/block-user/by-username/\(encodedUsername)") else {
+ throw NSError(domain: "TestUserRegistration", code: 15,
+ userInfo: [NSLocalizedDescriptionKey: "Invalid URL for username: \(username)"])
+ }
+ var request = URLRequest(url: requestUrl)
+ request.httpMethod = "POST"
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
+
+ let (data, response) = try await URLSession.shared.data(for: request)
+
+ guard let httpResponse = response as? HTTPURLResponse else {
+ throw NSError(domain: "TestUserRegistration", code: 15,
+ userInfo: [NSLocalizedDescriptionKey: "Invalid response"])
+ }
+
+ guard httpResponse.statusCode == 200 else {
+ let errorText = String(data: data, encoding: .utf8) ?? "Unknown error"
+ throw NSError(domain: "TestUserRegistration", code: httpResponse.statusCode,
+ userInfo: [NSLocalizedDescriptionKey: "Failed to block user: \(errorText)"])
+ }
+ }
+
+ /// Unblock a user's account by username.
+ /// This is an anonymous endpoint that doesn't require authentication.
+ /// Used by UI tests that cannot access the app's auth token.
+ ///
+ /// - Parameters:
+ /// - username: The username to unblock
+ /// - apiBaseUrl: Optional API base URL (defaults to apiUrl)
+ static func unblockUserByUsername(
+ username: String,
+ apiBaseUrl: String? = nil
+ ) async throws {
+ let url = (apiBaseUrl ?? apiUrl).trimmingCharacters(in: CharacterSet(charactersIn: "/")) + "/v1/"
+ // Use alphanumerics for safe URL path encoding (encodes @, ., etc.)
+ let encodedUsername = username.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? username
+
+ guard let requestUrl = URL(string: "\(url)Test/unblock-user/by-username/\(encodedUsername)") else {
+ throw NSError(domain: "TestUserRegistration", code: 16,
+ userInfo: [NSLocalizedDescriptionKey: "Invalid URL for username: \(username)"])
+ }
+ var request = URLRequest(url: requestUrl)
+ request.httpMethod = "POST"
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
+
+ let (data, response) = try await URLSession.shared.data(for: request)
+
+ guard let httpResponse = response as? HTTPURLResponse else {
+ throw NSError(domain: "TestUserRegistration", code: 16,
+ userInfo: [NSLocalizedDescriptionKey: "Invalid response"])
+ }
+
+ guard httpResponse.statusCode == 200 else {
+ let errorText = String(data: data, encoding: .utf8) ?? "Unknown error"
+ throw NSError(domain: "TestUserRegistration", code: httpResponse.statusCode,
+ userInfo: [NSLocalizedDescriptionKey: "Failed to unblock user: \(errorText)"])
+ }
+ }
}
diff --git a/apps/server/AliasVault.Api/Controllers/Tests/TestController.cs b/apps/server/AliasVault.Api/Controllers/Tests/TestController.cs
index e23224e0c..c82f3234e 100644
--- a/apps/server/AliasVault.Api/Controllers/Tests/TestController.cs
+++ b/apps/server/AliasVault.Api/Controllers/Tests/TestController.cs
@@ -9,9 +9,7 @@
* Note: this file is used for E2E testing purposes only. It contains test endpoints that are used by
* E2E tests (browser extension Playwright tests, mobile app UI tests) to manipulate server state.
*
- * Security measures:
- * 1. All endpoints check IsDevelopment() and return 404 in production
- * 2. All endpoints are hidden from Swagger documentation via ApiExplorerSettings
+ * These endpoints are only available in DEBUG builds.
*/
namespace AliasVault.Api.Controllers.Tests;
@@ -24,6 +22,8 @@ using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
+#if DEBUG
+
///
/// Test controller that contains test endpoints for E2E testing purposes.
/// All endpoints are hidden from Swagger and only work in Development environment.
@@ -32,7 +32,6 @@ using Microsoft.EntityFrameworkCore;
/// IWebHostEnvironment instance.
/// DbContext factory instance.
[ApiVersion("1")]
-[ApiExplorerSettings(IgnoreApi = true)]
public class TestController(
UserManager userManager,
IWebHostEnvironment environment,
@@ -160,4 +159,257 @@ public class TestController(
revisions,
});
}
+
+ ///
+ /// Block the current user's account.
+ /// Used for testing forced logout scenarios.
+ /// After calling this, any subsequent API calls to /status will return 401.
+ ///
+ /// OK with the blocked status.
+ [HttpPost("block-user")]
+ public async Task BlockUser()
+ {
+ if (!environment.IsDevelopment())
+ {
+ return NotFound();
+ }
+
+ var user = await GetCurrentUserAsync();
+ if (user == null)
+ {
+ return Unauthorized();
+ }
+
+ await using var context = await dbContextFactory.CreateDbContextAsync();
+
+ // Find the user in the new context and block them
+ var dbUser = await context.AliasVaultUsers.FindAsync(user.Id);
+ if (dbUser == null)
+ {
+ return NotFound("User not found");
+ }
+
+ dbUser.Blocked = true;
+ await context.SaveChangesAsync();
+
+ return Ok(new
+ {
+ blocked = true,
+ message = $"User {user.UserName} has been blocked",
+ });
+ }
+
+ ///
+ /// Unblock the current user's account.
+ /// Used for testing - allows re-enabling the account after forced logout test.
+ /// Note: This uses the JWT token which is still valid even for blocked users,
+ /// so the user can unblock themselves for testing purposes.
+ ///
+ /// OK with the blocked status.
+ [HttpPost("unblock-user")]
+ public async Task UnblockUser()
+ {
+ if (!environment.IsDevelopment())
+ {
+ return NotFound();
+ }
+
+ var user = await GetCurrentUserAsync();
+ if (user == null)
+ {
+ return Unauthorized();
+ }
+
+ await using var context = await dbContextFactory.CreateDbContextAsync();
+
+ // Find the user in the new context and unblock them
+ var dbUser = await context.AliasVaultUsers.FindAsync(user.Id);
+ if (dbUser == null)
+ {
+ return NotFound("User not found");
+ }
+
+ dbUser.Blocked = false;
+ await context.SaveChangesAsync();
+
+ return Ok(new
+ {
+ blocked = false,
+ message = $"User {user.UserName} has been unblocked",
+ });
+ }
+
+ ///
+ /// Get vault revision information for a user by username.
+ /// Anonymous endpoint for E2E tests that cannot access auth tokens.
+ /// Only available in DEBUG builds.
+ ///
+ /// The username to look up.
+ /// Vault revision information.
+ [AllowAnonymous]
+ [HttpGet("vault-revisions/by-username/{username}")]
+ public async Task GetVaultRevisionsByUsername(string username)
+ {
+ if (!environment.IsDevelopment())
+ {
+ return NotFound();
+ }
+
+ await using var context = await dbContextFactory.CreateDbContextAsync();
+
+ var user = await context.AliasVaultUsers
+ .FirstOrDefaultAsync(u => u.NormalizedUserName == username.ToUpperInvariant());
+
+ if (user == null)
+ {
+ return NotFound($"User '{username}' not found");
+ }
+
+ var revisions = await context.Vaults
+ .Where(v => v.UserId == user.Id)
+ .OrderByDescending(v => v.RevisionNumber)
+ .Select(v => new
+ {
+ v.RevisionNumber,
+ v.CreatedAt,
+ v.UpdatedAt,
+ })
+ .ToListAsync();
+
+ return Ok(new
+ {
+ count = revisions.Count,
+ currentRevision = revisions.FirstOrDefault()?.RevisionNumber ?? 0,
+ revisions,
+ });
+ }
+
+ ///
+ /// Delete the newest vault revisions for a user by username.
+ /// Anonymous endpoint for E2E tests that cannot access auth tokens.
+ /// Only available in DEBUG builds.
+ ///
+ /// The username to look up.
+ /// Number of newest revisions to delete.
+ /// OK with the number of deleted revisions.
+ [AllowAnonymous]
+ [HttpDelete("vault-revisions/by-username/{username}/{count:int}")]
+ public async Task DeleteVaultRevisionsByUsername(string username, int count)
+ {
+ if (!environment.IsDevelopment())
+ {
+ return NotFound();
+ }
+
+ if (count <= 0)
+ {
+ return BadRequest("Count must be greater than 0");
+ }
+
+ await using var context = await dbContextFactory.CreateDbContextAsync();
+
+ var user = await context.AliasVaultUsers
+ .FirstOrDefaultAsync(u => u.NormalizedUserName == username.ToUpperInvariant());
+
+ if (user == null)
+ {
+ return NotFound($"User '{username}' not found");
+ }
+
+ // Get the newest revisions to delete
+ var revisionsToDelete = await context.Vaults
+ .Where(v => v.UserId == user.Id)
+ .OrderByDescending(v => v.RevisionNumber)
+ .Take(count)
+ .ToListAsync();
+
+ if (revisionsToDelete.Count == 0)
+ {
+ return Ok(new { deleted = 0, message = "No revisions found to delete" });
+ }
+
+ // Delete the revisions
+ context.Vaults.RemoveRange(revisionsToDelete);
+ await context.SaveChangesAsync();
+
+ return Ok(new
+ {
+ deleted = revisionsToDelete.Count,
+ deletedRevisions = revisionsToDelete.Select(r => r.RevisionNumber).ToList(),
+ message = $"Deleted {revisionsToDelete.Count} vault revision(s)",
+ });
+ }
+
+ ///
+ /// Block a user's account by username.
+ /// Anonymous endpoint for E2E tests that cannot access auth tokens.
+ /// Only available in DEBUG builds.
+ ///
+ /// The username to block.
+ /// OK with the blocked status.
+ [AllowAnonymous]
+ [HttpPost("block-user/by-username/{username}")]
+ public async Task BlockUserByUsername(string username)
+ {
+ if (!environment.IsDevelopment())
+ {
+ return NotFound();
+ }
+
+ await using var context = await dbContextFactory.CreateDbContextAsync();
+
+ var user = await context.AliasVaultUsers
+ .FirstOrDefaultAsync(u => u.NormalizedUserName == username.ToUpperInvariant());
+
+ if (user == null)
+ {
+ return NotFound($"User '{username}' not found");
+ }
+
+ user.Blocked = true;
+ await context.SaveChangesAsync();
+
+ return Ok(new
+ {
+ blocked = true,
+ message = $"User {user.UserName} has been blocked",
+ });
+ }
+
+ ///
+ /// Unblock a user's account by username.
+ /// Anonymous endpoint for E2E tests that cannot access auth tokens.
+ /// Only available in DEBUG builds.
+ ///
+ /// The username to unblock.
+ /// OK with the blocked status.
+ [AllowAnonymous]
+ [HttpPost("unblock-user/by-username/{username}")]
+ public async Task UnblockUserByUsername(string username)
+ {
+ if (!environment.IsDevelopment())
+ {
+ return NotFound();
+ }
+
+ await using var context = await dbContextFactory.CreateDbContextAsync();
+
+ var user = await context.AliasVaultUsers
+ .FirstOrDefaultAsync(u => u.NormalizedUserName == username.ToUpperInvariant());
+
+ if (user == null)
+ {
+ return NotFound($"User '{username}' not found");
+ }
+
+ user.Blocked = false;
+ await context.SaveChangesAsync();
+
+ return Ok(new
+ {
+ blocked = false,
+ message = $"User {user.UserName} has been unblocked",
+ });
+ }
}
+#endif