Add server gap RPO test iOS (#1404)

This commit is contained in:
Leendert de Borst
2026-01-15 12:37:52 +01:00
parent 1887fa2bc0
commit 613cad6a6a
4 changed files with 927 additions and 318 deletions

View File

@@ -3,7 +3,7 @@
archiveVersion = 1;
classes = {
};
objectVersion = 70;
objectVersion = 60;
objects = {
/* Begin PBXBuildFile section */
@@ -272,7 +272,7 @@
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
CEE9098F2DA548C7008D568F /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = {
CEE9098F2DA548C7008D568F /* Exceptions for "Autofill" folder in "Autofill" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Info.plist,
@@ -282,13 +282,84 @@
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
CE59C7602E4F47FD0024A246 /* AliasVaultUITests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = AliasVaultUITests; sourceTree = "<group>"; };
CE77825E2EA1822400A75E6F /* VaultUtils */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = VaultUtils; sourceTree = "<group>"; };
CEE480882DBE86DC00F4A367 /* VaultStoreKit */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = VaultStoreKit; sourceTree = "<group>"; };
CEE480972DBE86DD00F4A367 /* VaultStoreKitTests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = VaultStoreKitTests; sourceTree = "<group>"; };
CEE4816B2DBE8AC800F4A367 /* VaultUI */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = VaultUI; sourceTree = "<group>"; };
CEE482AB2DBE8EFE00F4A367 /* VaultModels */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = VaultModels; sourceTree = "<group>"; };
CEE909812DA548C7008D568F /* Autofill */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (CEE9098F2DA548C7008D568F /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = Autofill; sourceTree = "<group>"; };
CE59C7602E4F47FD0024A246 /* AliasVaultUITests */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
);
explicitFileTypes = {
};
explicitFolders = (
);
path = AliasVaultUITests;
sourceTree = "<group>";
};
CE77825E2EA1822400A75E6F /* VaultUtils */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
);
explicitFileTypes = {
};
explicitFolders = (
);
path = VaultUtils;
sourceTree = "<group>";
};
CEE480882DBE86DC00F4A367 /* VaultStoreKit */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
);
explicitFileTypes = {
};
explicitFolders = (
);
path = VaultStoreKit;
sourceTree = "<group>";
};
CEE480972DBE86DD00F4A367 /* VaultStoreKitTests */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
);
explicitFileTypes = {
};
explicitFolders = (
);
path = VaultStoreKitTests;
sourceTree = "<group>";
};
CEE4816B2DBE8AC800F4A367 /* VaultUI */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
);
explicitFileTypes = {
};
explicitFolders = (
);
path = VaultUI;
sourceTree = "<group>";
};
CEE482AB2DBE8EFE00F4A367 /* VaultModels */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
);
explicitFileTypes = {
};
explicitFolders = (
);
path = VaultModels;
sourceTree = "<group>";
};
CEE909812DA548C7008D568F /* Autofill */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
CEE9098F2DA548C7008D568F /* Exceptions for "Autofill" folder in "Autofill" target */,
);
explicitFileTypes = {
};
explicitFolders = (
);
path = Autofill;
sourceTree = "<group>";
};
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
@@ -448,7 +519,7 @@
path = Generated;
sourceTree = "<group>";
};
6114AD0A31612EA499210371 = {
6114AD0A31612EA499210371 /* */ = {
isa = PBXGroup;
children = (
8F8258CE46969A87661E0302 /* RustCore.swift */,
@@ -484,7 +555,7 @@
832341AE1AAA6A7D00B99B32 /* Libraries */,
D65327D7A22EEC0BE12398D9 /* Pods */,
83CBBA001A601CBA00E9B192 /* Products */,
6114AD0A31612EA499210371,
6114AD0A31612EA499210371 /* */,
CE5212B62F0C061800F4C835 /* Recovered References */,
3AD00C41DD88E219A26C2E41 /* RustCoreFramework */,
);
@@ -1642,7 +1713,10 @@
LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\"";
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
OTHER_LDFLAGS = "$(inherited) ";
OTHER_LDFLAGS = (
"$(inherited)",
" ",
);
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG";
@@ -1696,7 +1770,10 @@
);
LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\"";
MTL_ENABLE_DEBUG_INFO = NO;
OTHER_LDFLAGS = "$(inherited) ";
OTHER_LDFLAGS = (
"$(inherited)",
" ",
);
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
SDKROOT = iphoneos;
USE_HERMES = true;

View File

File diff suppressed because it is too large Load Diff

View File

@@ -400,4 +400,90 @@ enum TestUserRegistration {
return false
}
}
// MARK: - Test Helpers (DEV API Endpoints)
/// Delete the newest vault revisions for the authenticated user.
/// This endpoint only works in development mode.
/// Used for testing RPO (Recovery Point Objective) recovery scenarios.
///
/// - Parameters:
/// - count: Number of newest revisions to delete
/// - token: Authentication token
/// - apiBaseUrl: Optional API base URL (defaults to apiUrl)
/// - Returns: Number of deleted revisions
static func deleteVaultRevisions(
count: Int,
token: String,
apiBaseUrl: String? = nil
) async throws -> Int {
let url = (apiBaseUrl ?? apiUrl).trimmingCharacters(in: CharacterSet(charactersIn: "/")) + "/v1/"
var request = URLRequest(url: URL(string: "\(url)Test/vault-revisions/\(count)")!)
request.httpMethod = "DELETE"
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: 10,
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 to get deleted count
if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let deleted = json["deleted"] as? Int {
return deleted
}
return 0
}
/// Get vault revision information for the authenticated user.
/// This endpoint only works in development mode.
///
/// - Parameters:
/// - token: Authentication token
/// - apiBaseUrl: Optional API base URL (defaults to apiUrl)
/// - Returns: Tuple of (count, currentRevision)
static func getVaultRevisions(
token: String,
apiBaseUrl: String? = nil
) async throws -> (count: Int, currentRevision: Int) {
let url = (apiBaseUrl ?? apiUrl).trimmingCharacters(in: CharacterSet(charactersIn: "/")) + "/v1/"
var request = URLRequest(url: URL(string: "\(url)Test/vault-revisions")!)
request.httpMethod = "GET"
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: 10,
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)
}
}

View File

@@ -6,9 +6,12 @@
//-----------------------------------------------------------------------
/*
* Note: this file is used for E2E testing purposes only. It contains test endpoints that are called by pages on
* the client for testing purposes. Because certain endpoints that simulate exceptions are prone to Denial-Of-Service
* attack surfaces we don't include this file in the production build.
* 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
*/
namespace AliasVault.Api.Controllers.Tests;
@@ -19,33 +22,142 @@ using Asp.Versioning;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
/// <summary>
/// Test controller that contains test endpoints called by pages on the client for E2E testing purposes.
/// Test controller that contains test endpoints for E2E testing purposes.
/// All endpoints are hidden from Swagger and only work in Development environment.
/// </summary>
/// <param name="userManager">UserManager instance.</param>
/// <param name="environment">IWebHostEnvironment instance.</param>
/// <param name="dbContextFactory">DbContext factory instance.</param>
[ApiVersion("1")]
public class TestController(UserManager<AliasVaultUser> userManager) : AuthenticatedRequestController(userManager)
[ApiExplorerSettings(IgnoreApi = true)]
public class TestController(
UserManager<AliasVaultUser> userManager,
IWebHostEnvironment environment,
IAliasServerDbContextFactory dbContextFactory) : AuthenticatedRequestController(userManager)
{
/// <summary>
/// Authenticated test request.
/// Authenticated test request. Used to verify authentication is working.
/// </summary>
/// <returns>Static OK.</returns>
[HttpGet("")]
public IActionResult TestCall()
{
if (!environment.IsDevelopment())
{
return NotFound();
}
return Ok();
}
/// <summary>
/// Test request that throws an exception. Used for testing error handling.
/// </summary>
/// <returns>Static OK.</returns>
/// <returns>Never returns - always throws.</returns>
[AllowAnonymous]
[HttpGet("Error")]
public IActionResult TestCallError()
{
if (!environment.IsDevelopment())
{
return NotFound();
}
// Throw an exception here to test error handling.
throw new ArgumentException("Test error");
}
/// <summary>
/// Delete the newest vault revisions for the current user.
/// Used for testing RPO (Recovery Point Objective) recovery scenarios.
/// </summary>
/// <param name="count">Number of newest revisions to delete.</param>
/// <returns>OK with the number of deleted revisions, or NotFound in production.</returns>
[HttpDelete("vault-revisions/{count:int}")]
public async Task<IActionResult> DeleteVaultRevisions(int count)
{
if (!environment.IsDevelopment())
{
return NotFound();
}
if (count <= 0)
{
return BadRequest("Count must be greater than 0");
}
var user = await GetCurrentUserAsync();
if (user == null)
{
return Unauthorized();
}
await using var context = await dbContextFactory.CreateDbContextAsync();
// 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);
var deletedCount = await context.SaveChangesAsync();
return Ok(new
{
deleted = revisionsToDelete.Count,
deletedRevisions = revisionsToDelete.Select(r => r.RevisionNumber).ToList(),
message = $"Deleted {revisionsToDelete.Count} vault revision(s)",
});
}
/// <summary>
/// Get vault revision information for the current user.
/// Used for E2E tests to verify vault state.
/// </summary>
/// <returns>Vault revision information.</returns>
[HttpGet("vault-revisions")]
public async Task<IActionResult> GetVaultRevisions()
{
if (!environment.IsDevelopment())
{
return NotFound();
}
var user = await GetCurrentUserAsync();
if (user == null)
{
return Unauthorized();
}
await using var context = await dbContextFactory.CreateDbContextAsync();
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,
});
}
}