Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added RetryCustomConfirmationAsync #3468

Merged
merged 3 commits into from
Nov 3, 2023
Merged
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 CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
## vNext (TBD)

### Enhancements
* Added the `App.EmailPasswordAuth.RetryCustomConfirmationAsync` method to be able to run again the confirmation function on the server for a given email. (Issue [#3463](https://github.com/realm/realm-dotnet/issues/3463))
* Added `User.Changed` event that can be used to notify subscribers that something about the user changed - typically this would be the user state or the access token. (Issue [#3429](https://github.com/realm/realm-dotnet/issues/3429))
* Added support for customizing the ignore attribute applied on certain generated properties of Realm models. The configuration option is called `realm.custom_ignore_attribute` and can be set in a global configuration file (more information about global configuration files can be found in the [.NET documentation](https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/configuration-files)). The Realm generator will treat this as an opaque string, that will be appended to the `IgnoreDataMember` and `XmlIgnore` attributes already applied on these members. The attributes must be fully qualified unless the namespace they reside in is added to a global usings file. For example, this is how you would add `JsonIgnore` from `System.Text.Json`:

Expand Down
22 changes: 22 additions & 0 deletions Realm/Realm/Handles/AppHandle.EmailPassword.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,11 @@ public static extern void resend_confirmation_email(AppHandle app,
[MarshalAs(UnmanagedType.LPWStr)] string email, IntPtr email_len,
IntPtr tcs_ptr, out NativeException ex);

[DllImport(InteropConfig.DLL_NAME, EntryPoint = "shared_app_email_retry_custom_confirmation", CallingConvention = CallingConvention.Cdecl)]
public static extern void retry_custom_comfirmation(AppHandle app,
[MarshalAs(UnmanagedType.LPWStr)] string email, IntPtr email_len,
IntPtr tcs_ptr, out NativeException ex);

[DllImport(InteropConfig.DLL_NAME, EntryPoint = "shared_app_email_send_reset_password_email", CallingConvention = CallingConvention.Cdecl)]
public static extern void send_reset_password_email(AppHandle app,
[MarshalAs(UnmanagedType.LPWStr)] string email, IntPtr email_len,
Expand Down Expand Up @@ -125,6 +130,23 @@ public async Task ResendConfirmationEmailAsync(string email)
}
}

public async Task RetryCustomConfirmationAsync(string email)
{
var tcs = new TaskCompletionSource();
var tcsHandle = GCHandle.Alloc(tcs);

try
{
EmailNativeMethods.retry_custom_comfirmation(_appHandle, email, (IntPtr)email.Length, GCHandle.ToIntPtr(tcsHandle), out var ex);
ex.ThrowIfNecessary();
await tcs.Task;
}
finally
{
tcsHandle.Free();
}
}

public async Task SendResetPasswordEmailAsync(string username)
{
var tcs = new TaskCompletionSource();
Expand Down
15 changes: 15 additions & 0 deletions Realm/Realm/Sync/App.cs
Original file line number Diff line number Diff line change
Expand Up @@ -408,6 +408,21 @@ public Task ResendConfirmationEmailAsync(string email)
return _app.Handle.EmailPassword.ResendConfirmationEmailAsync(email);
}

/// <summary>
/// Rerun the custom confirmation function for the given mail.
/// </summary>
/// <param name="email">The email of the user.</param>
/// <returns>
/// An awaitable <see cref="Task"/> representing the asynchronous request to the server that the custom confirmation function is run again. Successful
/// completion indicates that the user has been confirmed on the server.
/// </returns>
public Task RetryCustomConfirmationAsync(string email)
{
Argument.NotNullOrEmpty(email, nameof(email));

return _app.Handle.EmailPassword.RetryCustomConfirmationAsync(email);
}

/// <summary>
/// Sends a password reset email to the specified address.
/// </summary>
Expand Down
2 changes: 2 additions & 0 deletions Tests/Realm.Tests/Sync/SyncTestHelpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,8 @@ public static void RunBaasTestAsync(Func<Task> testFunc, int timeout = 30000)

public static string GetVerifiedUsername() => $"realm_tests_do_autoverify-{Guid.NewGuid()}";

public static string GetUnconfirmedUsername() => $"realm_tests_do_not_confirm-{Guid.NewGuid()}@g.it";
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

RetryCustomConfirmation will fail if the input doesn't look like a proper email. We could add the same to GetVerifiedUsername for consistency


public static async Task TriggerClientResetOnServer(SyncConfigurationBase config)
{
var userId = config.User.Id;
Expand Down
36 changes: 36 additions & 0 deletions Tests/Realm.Tests/Sync/UserManagementTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@

await DefaultApp.RemoveUserAsync(second);

// TODO: validate that the refresh token is invalidated.

Check warning on line 140 in Tests/Realm.Tests/Sync/UserManagementTests.cs

View workflow job for this annotation

GitHub Actions / Verify TODOs

Tests/Realm.Tests/Sync/UserManagementTests.cs#L140

TODO entry doesn't have a link to Github issue or Jira ticket validate that the refresh token is invalidated.
Assert.That(second.State, Is.EqualTo(UserState.Removed));
Assert.That(second.AccessToken, Is.Empty);
Assert.That(second.RefreshToken, Is.Empty);
Expand Down Expand Up @@ -400,6 +400,42 @@
});
}

[Test]
public void User_RetryCustomConfirmationAsync_WorksInAllScenarios()
{
SyncTestHelpers.RunBaasTestAsync(async () =>
{
// Standard case
var unconfirmedMail = SyncTestHelpers.GetUnconfirmedUsername();
var credentials = Credentials.EmailPassword(unconfirmedMail, SyncTestHelpers.DefaultPassword);

// The first time the confirmation function is called we return "pending", so the user needs to be confirmed.
// At the same time we save the user email in a collection.
await DefaultApp.EmailPasswordAuth.RegisterUserAsync(unconfirmedMail, SyncTestHelpers.DefaultPassword).Timeout(10_000, detail: "Failed to register user");

var ex3 = await TestHelpers.AssertThrows<AppException>(() => DefaultApp.LogInAsync(credentials));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not super important, but why is this ex3 rather than just ex?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because before I had the sections in the opposite order and I forgot to rename the exceptions 😁

Assert.That(ex3.Message, Does.Contain("confirmation required"));

// The second time we call the confirmation function we find the email we saved in the collection and return "success", so the user
// gets confirmed and can log in.
await DefaultApp.EmailPasswordAuth.RetryCustomConfirmationAsync(unconfirmedMail);
var user = await DefaultApp.LogInAsync(credentials);
Assert.That(user.State, Is.EqualTo(UserState.LoggedIn));

// Logged in user case
var loggedInUser = await GetUserAsync();
var ex = await TestHelpers.AssertThrows<AppException>(() => DefaultApp.EmailPasswordAuth.RetryCustomConfirmationAsync(loggedInUser.Profile.Email!));
Assert.That(ex.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest));
Assert.That(ex.Message, Does.Contain("already confirmed"));

// Unknown user case
var invalidEmail = "test@gmail.com";
var ex2 = await TestHelpers.AssertThrows<AppException>(() => DefaultApp.EmailPasswordAuth.RetryCustomConfirmationAsync(invalidEmail));
Assert.That(ex2.StatusCode, Is.EqualTo(HttpStatusCode.NotFound));
Assert.That(ex2.Message, Does.Contain("user not found"));
});
}

[Test]
public void User_JWT_LogsInAndReadsDataFromToken()
{
Expand Down
27 changes: 24 additions & 3 deletions Tools/DeployApps/BaasClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -88,12 +88,33 @@ public class FunctionReturn
}

private const string ConfirmFuncSource =
@"exports = ({ token, tokenId, username }) => {
@"exports = async function ({ token, tokenId, username }) {
// process the confirm token, tokenId and username
if (username.includes(""realm_tests_do_autoverify"")) {
return { status: 'success' }
return { status: 'success' };
}
// do not confirm the user

if (username.includes(""realm_tests_do_not_confirm"")) {
const mongodb = context.services.get('BackingDB');
let collection = mongodb.db('test_db').collection('not_confirmed');
let result = await collection.findOne({'email': username});

if(result === null)
{
let newVal = {
'email': username,
'token': token,
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Saving the token and tokenId is not necessary now, but it could be useful if we want to write tests for ConfirmAsync

'tokenId': tokenId,
}

await collection.insertOne(newVal);
return { status: 'pending' };
}

return { status: 'success' };
}

// fail the user confirmation
return { status: 'fail' };
};";

Expand Down
8 changes: 8 additions & 0 deletions wrappers/src/app_cs.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,14 @@ extern "C" {
});
}

REALM_EXPORT void shared_app_email_retry_custom_confirmation(SharedApp& app, uint16_t* email_buf, size_t email_len, void* tcs_ptr, NativeException::Marshallable& ex)
{
handle_errors(ex, [&]() {
Utf16StringAccessor email(email_buf, email_len);
app->provider_client<App::UsernamePasswordProviderClient>().retry_custom_confirmation(email, get_callback_handler(tcs_ptr));
});
}

REALM_EXPORT void shared_app_email_send_reset_password_email(SharedApp& app, uint16_t* email_buf, size_t email_len, void* tcs_ptr, NativeException::Marshallable& ex)
{
handle_errors(ex, [&]() {
Expand Down
Loading