Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions OneGateApp.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@
<Project Path="OneGate.DebugProtocol/OneGate.DebugProtocol.csproj" />
<Project Path="OneGate.DebugProtocol.Tests/OneGate.DebugProtocol.Tests.csproj" />
<Project Path="OneGate.Tools/OneGate.Tools.csproj" />
<Project Path="tests/P2-03/AuthorizationCancellation.Tests.csproj" />
</Solution>
2 changes: 1 addition & 1 deletion OneGateApp/Services/DataProtectionService.Android.cs
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ static Task<bool> AuthenticateWithCipherAsync(Cipher? cipher = null, string? tit
var builder = new BiometricPrompt.Builder(activity)
.SetTitle(title ?? Strings.BiometricAuthentication)
.SetSubtitle(message ?? Strings.VerifyBiometricText);
builder = builder.SetNegativeButton(Strings.Cancel, executor, new NegativeClickListener(() => tcs.TrySetCanceled()));
builder = builder.SetNegativeButton(Strings.Cancel, executor, new NegativeClickListener(() => tcs.TrySetResult(false)));
var prompt = builder.Build();
if (cipher is null)
{
Expand Down
30 changes: 18 additions & 12 deletions OneGateApp/Services/WalletAuthorizationService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,20 @@ namespace NeoOrder.OneGate.Services;
public class WalletAuthorizationService(IServiceProvider serviceProvider, ApplicationDbContext dbContext, IWalletProvider walletProvider)
{
public async Task<bool> RequestAuthorizationAsync(Page page, string title, string? message = null, string? domain = null)
{
try
{
return await RequestAuthorizationCoreAsync(page, title, message, domain);
}
catch (OperationCanceledException)
{
// Cancelling an authorization is a rejected request in every path,
// including when the in-memory wallet has already been unlocked.
return false;
}
}

async Task<bool> RequestAuthorizationCoreAsync(Page page, string title, string? message, string? domain)
{
byte[]? credential = await dbContext.Settings.GetAsync<byte[]>("biometric/credential");
if (credential is null)
Expand All @@ -35,19 +49,11 @@ public async Task<bool> RequestAuthorizationAsync(Page page, string title, strin
}
else
{
string password;
using var progressOverlay = new ProgressWindowOverlay(page.GetParentWindow(), title, Strings.UnlockingWallet);
try
{
password = await DataProtectionService.UnprotectAsync(credential, title, message);
if (!await Task.Run(() => wallet.VerifyPassword(password)))
throw new InvalidOperationException("Stored credential is invalid.");
return true;
}
catch (OperationCanceledException)
{
return false;
}
string password = await DataProtectionService.UnprotectAsync(credential, title, message);
if (!await Task.Run(() => wallet.VerifyPassword(password)))
throw new InvalidOperationException("Stored credential is invalid.");
return true;
}
}
}
Expand Down
17 changes: 17 additions & 0 deletions tests/P2-03/AuthorizationCancellation.Tests.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../OneGateApp/OneGateApp.csproj" ReferenceOutputAssembly="false" BuildReference="false" SkipGetTargetFrameworkProperties="true" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
<PackageReference Include="Neo" Version="3.10.0" />
<Compile Include="../../OneGateApp/Services/WalletAuthorizationService.cs" Link="WalletAuthorizationService.cs" />
</ItemGroup>
</Project>
74 changes: 74 additions & 0 deletions tests/P2-03/AuthorizationCancellationTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
using Neo;
using Neo.Wallets;
using NeoOrder.OneGate.Data;
using NeoOrder.OneGate.Services;
using Xunit;

public class AuthorizationCancellationTests
{
[Fact]
public async Task Unlocked_wallet_cancelled_biometrics_returns_false()
{
using var fixture = new WalletFixture(locked: false);
DataProtectionService.Authenticate = () => Task.FromCanceled<bool>(new CancellationToken(true));
Assert.False(await fixture.Service.RequestAuthorizationAsync(new Page(), "Export"));
}

[Fact]
public async Task Locked_wallet_cancelled_biometrics_returns_false_and_stays_locked()
{
using var fixture = new WalletFixture(locked: true);
DataProtectionService.Unprotect = () => Task.FromCanceled<string>(new CancellationToken(true));
Assert.False(await fixture.Service.RequestAuthorizationAsync(new Page(), "Send"));
Assert.False(fixture.Wallet.IsUnlocked);
}

[Fact]
public async Task Rejected_biometrics_does_not_authorize()
{
using var fixture = new WalletFixture(locked: false);
DataProtectionService.Authenticate = () => Task.FromResult(false);
Assert.False(await fixture.Service.RequestAuthorizationAsync(new Page(), "Send"));
}

[Fact]
public async Task Successful_biometrics_authorizes_both_wallet_states()
{
using var fixture = new WalletFixture(locked: true);
DataProtectionService.Unprotect = () => Task.FromResult(WalletFixture.Password);
Assert.True(await fixture.Service.RequestAuthorizationAsync(new Page(), "Export"));
Assert.True(fixture.Wallet.IsUnlocked);
DataProtectionService.Authenticate = () => Task.FromResult(true);
Assert.True(await fixture.Service.RequestAuthorizationAsync(new Page(), "Export"));
}

[Fact]
public async Task System_failure_is_not_silently_converted_to_user_cancellation()
{
using var fixture = new WalletFixture(locked: false);
DataProtectionService.Authenticate = () => Task.FromException<bool>(new InvalidOperationException("Biometric hardware unavailable"));
await Assert.ThrowsAsync<InvalidOperationException>(() => fixture.Service.RequestAuthorizationAsync(new Page(), "Export"));
}
}

sealed class WalletFixture : IWalletProvider, IDisposable
{
public const string Password = "disposable-authorization-password";
readonly string directory = Directory.CreateTempSubdirectory("onegate-authorization-test-").FullName;
public Wallet Wallet { get; }
public WalletAuthorizationService Service { get; }
public event EventHandler<Wallet?>? WalletChanged { add { } remove { } }
public WalletFixture(bool locked)
{
string path = Path.Combine(directory, "wallet.json");
Wallet = Neo.Wallets.Wallet.Create("Disposable authorization wallet", path, Password, ProtocolSettings.Default)!;
Wallet.CreateAccount();
Wallet.Save();
if (locked) Wallet = Neo.Wallets.Wallet.Open(path, null, ProtocolSettings.Default)!;
DataProtectionService.Authenticate = () => throw new InvalidOperationException("Unexpected authentication branch");
DataProtectionService.Unprotect = () => throw new InvalidOperationException("Unexpected unprotect branch");
Service = new(new EmptyServices(), new ApplicationDbContext(), this);
}
public Wallet? GetWallet() => Wallet;
public void Dispose() => Directory.Delete(directory, true);
}
57 changes: 57 additions & 0 deletions tests/P2-03/PlatformStubs.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
// Exercise the linked production authorization service with real Neo wallet
// states, replacing only platform prompts, MAUI views and the settings store.
public sealed class Page { public object GetParentWindow() => new(); }
public sealed class EmptyServices : IServiceProvider { public object? GetService(Type type) => null; }
namespace NeoOrder.OneGate.Services
{
public static class ServiceExtensions
{
public static T GetServiceOrCreateInstance<T>(this IServiceProvider services) where T : new() => new();
}
public sealed class ProgressWindowOverlay : IDisposable
{
public ProgressWindowOverlay(object window, string title, string message) { }
public void Dispose() { }
}
public static class DataProtectionService
{
public static Func<Task<bool>> Authenticate { get; set; } = () => Task.FromResult(false);
public static Func<Task<string>> Unprotect { get; set; } = () => Task.FromResult("");
public static Task<bool> AuthenticateAsync(string title, string? message) => Authenticate();
public static Task<string> UnprotectAsync(byte[] credential, string title, string? message) => Unprotect();
}
}
namespace NeoOrder.OneGate.Controls.Popups
{
public sealed class WalletAuthorizationPopup
{
public string? Title { get; set; }
public string? Message { get; set; }
public string? Domain { get; set; }
}
}
namespace CommunityToolkit.Maui.Extensions
{
public sealed class PopupResult<T> { public T? Result { get; set; } }
public static class PopupExtensions
{
public static Task<PopupResult<T>> ShowPopupAsync<T>(this Page page, object popup) => Task.FromResult(new PopupResult<T>());
}
}
namespace NeoOrder.OneGate.Data
{
public sealed class ApplicationDbContext { public SettingsStore Settings { get; } = new(); }
public sealed class SettingsStore
{
public Task<T?> GetAsync<T>(string key) => Task.FromResult((T?)(object)new byte[] { 1 });
}
}
namespace NeoOrder.OneGate.Properties
{
public static class Strings
{
public const string LoginRequestText = "Login request";
public const string Domain = "Domain";
public const string UnlockingWallet = "Unlocking wallet";
}
}
15 changes: 15 additions & 0 deletions tests/P2-03/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# P2-03 consistent authorization cancellation

Run `dotnet test tests/P2-03/AuthorizationCancellation.Tests.csproj`.

Tests link the production WalletAuthorizationService and use Neo 3.10.0 to
create disposable locked/unlocked wallets. Platform prompt doubles inject user
cancellation, rejection, success and a separate hardware error. No credentials
or real wallets are accessed. This verifies cancellation semantics rather than
claiming that a desktop test displays the native biometric prompt.

On both mobile simulators: use a disposable wallet and enrolled test biometrics,
authorize once, then cancel another transfer/export authorization. Cancellation
must return to the previous screen without a signature, export or crash.
Repeat after a cold start while the wallet is locked. Real native cancellation
and both caller flows are required before submission.