Add if debug to protect test api endpoints (#1404)

This commit is contained in:
Leendert de Borst
2026-01-15 14:33:46 +01:00
parent 613cad6a6a
commit b2eff99e41
4 changed files with 1129 additions and 393 deletions

View File

@@ -416,6 +416,7 @@ export default function UnlockScreen() : React.ReactNode {
<RobustPressable
style={styles.logoutButton}
onPress={handleLogout}
testID="logout-button"
>
<ThemedText style={styles.logoutButtonText}>{t('auth.logout')}</ThemedText>
</RobustPressable>

View File

File diff suppressed because it is too large Load Diff

View File

@@ -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)"])
}
}
}

View File

@@ -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
/// <summary>
/// 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;
/// <param name="environment">IWebHostEnvironment instance.</param>
/// <param name="dbContextFactory">DbContext factory instance.</param>
[ApiVersion("1")]
[ApiExplorerSettings(IgnoreApi = true)]
public class TestController(
UserManager<AliasVaultUser> userManager,
IWebHostEnvironment environment,
@@ -160,4 +159,257 @@ public class TestController(
revisions,
});
}
/// <summary>
/// Block the current user's account.
/// Used for testing forced logout scenarios.
/// After calling this, any subsequent API calls to /status will return 401.
/// </summary>
/// <returns>OK with the blocked status.</returns>
[HttpPost("block-user")]
public async Task<IActionResult> 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",
});
}
/// <summary>
/// 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.
/// </summary>
/// <returns>OK with the blocked status.</returns>
[HttpPost("unblock-user")]
public async Task<IActionResult> 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",
});
}
/// <summary>
/// Get vault revision information for a user by username.
/// Anonymous endpoint for E2E tests that cannot access auth tokens.
/// Only available in DEBUG builds.
/// </summary>
/// <param name="username">The username to look up.</param>
/// <returns>Vault revision information.</returns>
[AllowAnonymous]
[HttpGet("vault-revisions/by-username/{username}")]
public async Task<IActionResult> 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,
});
}
/// <summary>
/// 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.
/// </summary>
/// <param name="username">The username to look up.</param>
/// <param name="count">Number of newest revisions to delete.</param>
/// <returns>OK with the number of deleted revisions.</returns>
[AllowAnonymous]
[HttpDelete("vault-revisions/by-username/{username}/{count:int}")]
public async Task<IActionResult> 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)",
});
}
/// <summary>
/// Block a user's account by username.
/// Anonymous endpoint for E2E tests that cannot access auth tokens.
/// Only available in DEBUG builds.
/// </summary>
/// <param name="username">The username to block.</param>
/// <returns>OK with the blocked status.</returns>
[AllowAnonymous]
[HttpPost("block-user/by-username/{username}")]
public async Task<IActionResult> 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",
});
}
/// <summary>
/// Unblock a user's account by username.
/// Anonymous endpoint for E2E tests that cannot access auth tokens.
/// Only available in DEBUG builds.
/// </summary>
/// <param name="username">The username to unblock.</param>
/// <returns>OK with the blocked status.</returns>
[AllowAnonymous]
[HttpPost("unblock-user/by-username/{username}")]
public async Task<IActionResult> 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