mirror of
https://github.com/aliasvault/aliasvault.git
synced 2026-05-14 02:15:57 -04:00
Add if debug to protect test api endpoints (#1404)
This commit is contained in:
@@ -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>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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)"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user