Skip to content
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 packages/Gotrue/Gotrue.Tests/Gotrue.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
<None Update="TokenRefresh\Fixtures\*.json" CopyToOutputDirectory="PreserveNewest" />
<None Update="Common\Data\*.json" CopyToOutputDirectory="PreserveNewest" />
<None Update="Identities\Data\*.json" CopyToOutputDirectory="PreserveNewest" />
<None Update="UpdateFactor\Data\*.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>

</Project>
6 changes: 3 additions & 3 deletions packages/Gotrue/Gotrue.Tests/Mfa/MfaFixture.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,11 @@ namespace Gotrue.Tests.Mfa;
public abstract class MfaFixture : AuthClientFixture
{
private IGotrueAdminClient<User>? admin;
protected IGotrueAdminClient<User> Admin => admin ??= TestClients.AdminAgainstCliStack();
protected IGotrueAdminClient<User> Admin => this.admin ??= TestClients.AdminAgainstCliStack();

protected async Task<MfaEnrollResponse> EnrollTotp()
protected async Task<MfaEnrollResponse> EnrollTotp(string friendlyName = "Enroll test")
{
var enrollment = await this.Client.Enroll(new MfaEnrollParams { Issuer = "Supabase", FactorType = "totp", FriendlyName = "Enroll test" });
var enrollment = await this.Client.Enroll(new MfaEnrollParams { Issuer = "Supabase", FactorType = "totp", FriendlyName = friendlyName });
enrollment.Should().NotBeNull();
return enrollment!;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"friendly_name":"Work phone","phone":"+15555550199"}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"friendly_name":"Work phone"}
10 changes: 10 additions & 0 deletions packages/Gotrue/Gotrue.Tests/UpdateFactor/Data/phone_factor.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"id": "2b306a77-21dc-4110-ba71-537cb56b9e98",
"created_at": "2026-10-30T09:15:00Z",
"updated_at": "2026-10-30T10:20:00Z",
"status": "verified",
"friendly_name": "Work phone",
"factor_type": "phone",
"phone": "15555550199",
"last_challenged_at": null
}
16 changes: 16 additions & 0 deletions packages/Gotrue/Gotrue.Tests/UpdateFactor/FactorResponses.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
#region

using System;
using System.IO;

#endregion

namespace Gotrue.Tests.UpdateFactor;

/// <summary>
/// Factor bodies as the auth server returns them, read from <c>UpdateFactor/Data</c>.
/// </summary>
internal static class FactorResponses
{
internal static string PhoneFactor => File.ReadAllText(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "UpdateFactor", "Data", "phone_factor.json"));
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
#region

using System.Threading.Tasks;
using Gotrue.Tests.Support;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Supabase.Gotrue.UpdateFactor;

#endregion

namespace Gotrue.Tests.UpdateFactor;

/// <summary>
/// Pins the exact bytes a factor update puts on the wire: only the members set, every member when all are set, and
/// an empty object when none is.
/// </summary>
[TestClass]
[TestCategory("Contract")]
public class UpdateFactorApprovalTests : RequestApprovalFixture
{
private const string UserId = "8d3f1c2a-5b6e-4f70-9a81-b2c3d4e5f607";

private const string FactorId = "2b306a77-21dc-4110-ba71-537cb56b9e98";

[TestMethod]
public async Task UpdateFactorRequest_ShouldSendOnlyTheFriendlyName_GivenFriendlyNameOnly()
{
await this.Api.UpdateFactorAsync("service-jwt", UserId, FactorId, new FactorChanges { FriendlyName = "Work phone" });
await this.Verify(this.EmittedRequestBody).UseDirectory("Data");
}

[TestMethod]
public async Task UpdateFactorRequest_ShouldSendEveryChange_GivenEveryMember()
{
await this.Api.UpdateFactorAsync("service-jwt", UserId, FactorId, new FactorChanges { FriendlyName = "Work phone", Phone = "+15555550199" });
await this.Verify(this.EmittedRequestBody).UseDirectory("Data");
}

[TestMethod]
public async Task UpdateFactorRequest_ShouldSendEmptyObject_GivenNoChange()
{
await this.Api.UpdateFactorAsync("service-jwt", UserId, FactorId, new FactorChanges());
await this.Verify(this.EmittedRequestBody).UseDirectory("Data");
}
}
141 changes: 141 additions & 0 deletions packages/Gotrue/Gotrue.Tests/UpdateFactor/UpdateFactorContractTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
#region

using System;
using System.Net;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using Gotrue.Tests.Support;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Supabase.Gotrue;
using Supabase.Gotrue.Exceptions;
using Supabase.Gotrue.UpdateFactor;
using WireMock.Matchers;
using WireMock.RequestBuilders;
using WireMock.ResponseBuilders;

#endregion

namespace Gotrue.Tests.UpdateFactor;

/// <summary>
/// Pins the wire contract of updating a user's MFA factor against a stubbed auth server: the route, the
/// service-role credentials, the response and failure mapping, argument checks, and cancellation.
/// </summary>
[TestClass]
[TestCategory("Contract")]
public class UpdateFactorContractTests
{
private const string ServiceKey = "service-role-jwt";

private const string UserId = "8d3f1c2a-5b6e-4f70-9a81-b2c3d4e5f607";

private const string FactorId = "2b306a77-21dc-4110-ba71-537cb56b9e98";

private static readonly FactorChanges Rename = new() { FriendlyName = "Work phone" };

private MockGotrueServer server = null!;

[TestInitialize]
public void TestInitialize() => this.server = new MockGotrueServer();

[TestCleanup]
public void TestCleanup() => this.server.Dispose();

[TestMethod]
public async Task UpdateFactorAsync_ShouldPutChangesByUserAndFactorWithServiceRoleCredentials()
{
this.AnswerWith(HttpStatusCode.OK, FactorResponses.PhoneFactor);
await this.Admin.UpdateFactorAsync(UserId, FactorId, Rename);
this.server.VerifySingleReceivedRequest()
.WithMethod(HttpMethod.Put)
.WithPath($"/admin/users/{UserId}/factors/{FactorId}")
.WithHeader("Authorization", $"Bearer {ServiceKey}")
.WithJsonContentType();
}

[TestMethod]
public async Task UpdateFactorAsync_ShouldSendIdsAsGiven_GivenUppercaseIds()
{
this.AnswerWith(HttpStatusCode.OK, FactorResponses.PhoneFactor);
await this.Admin.UpdateFactorAsync(UserId.ToUpperInvariant(), FactorId.ToUpperInvariant(), Rename);
this.server.VerifySingleReceivedRequest().WithPath($"/admin/users/{UserId.ToUpperInvariant()}/factors/{FactorId.ToUpperInvariant()}");
}

[TestMethod]
public async Task UpdateFactorAsync_ShouldReturnUpdatedFactor()
{
this.AnswerWith(HttpStatusCode.OK, FactorResponses.PhoneFactor);
(await this.Admin.UpdateFactorAsync(UserId, FactorId, new FactorChanges { Phone = "+15555550199" })).Should().BeEquivalentTo(new
{
Id = FactorId,
FriendlyName = "Work phone",
FactorType = "phone",
Status = "verified",
Phone = "15555550199",
}, "the auth server answers 200 with the factor as stored after the update");
}

[TestMethod]
public async Task UpdateFactorAsync_ShouldThrowWithErrorCode_GivenUnknownFactor()
{
this.AnswerWith(HttpStatusCode.NotFound, """{"code":404,"error_code":"mfa_factor_not_found","msg":"Factor not found"}""");
var update = () => this.Admin.UpdateFactorAsync(UserId, FactorId, Rename);
await update.Should().ThrowExactlyAsync<GotrueException>()
.Where(failure => failure.ErrorCode == "mfa_factor_not_found",
"the auth server answers 404 mfa_factor_not_found when the user owns no such factor");
}

[TestMethod]
public async Task UpdateFactorAsync_ShouldThrowWithErrorCode_GivenPhoneNotInE164()
{
this.AnswerWith(HttpStatusCode.BadRequest, """{"code":400,"error_code":"validation_failed","msg":"Invalid phone number format (E.164 required)"}""");
var update = () => this.Admin.UpdateFactorAsync(UserId, FactorId, new FactorChanges { Phone = "not-a-phone" });
await update.Should().ThrowExactlyAsync<GotrueException>()
.Where(failure => failure.ErrorCode == "validation_failed",
"the auth server answers 400 validation_failed to a phone number that is not E.164 on a phone factor");
}

[TestMethod]
public async Task UpdateFactorAsync_ShouldThrowGotrueException_GivenNullResponseBody()
{
this.AnswerWith(HttpStatusCode.OK, "null");
var update = () => this.Admin.UpdateFactorAsync(UserId, FactorId, Rename);
await update.Should().ThrowExactlyAsync<GotrueException>().WithMessage("*returned no factor*",
"a 200 always carries the updated factor; anything else is a broken response, not a factor");
}

[TestMethod]
public async Task UpdateFactorAsync_ShouldThrowArgumentNull_GivenNullChanges()
{
var update = () => this.Admin.UpdateFactorAsync(UserId, FactorId, null!);
await update.Should().ThrowExactlyAsync<ArgumentNullException>().WithParameterName("changes");
this.server.CountReceivedRequests().Should().Be(0, "argument misuse is rejected before any I/O");
}

[TestMethod]
public void UpdateFactorAsync_ShouldComplete_GivenCallerBlockingOnContextThatNeverRunsContinuations()
{
this.AnswerWith(HttpStatusCode.OK, FactorResponses.PhoneFactor);
BlockingCaller.Completes(() => this.Admin.UpdateFactorAsync(UserId, FactorId, Rename)).Should().BeTrue(
"a library must not resume on the caller's context: a UI thread blocking on the task would deadlock");
}

[TestMethod]
public async Task UpdateFactorAsync_ShouldNotSendRequest_GivenCancelledToken()
{
using var cancelled = new CancellationTokenSource();
cancelled.Cancel();
var update = () => this.Admin.UpdateFactorAsync(UserId, FactorId, Rename, cancelled.Token);
await update.Should().ThrowAsync<OperationCanceledException>();
this.server.CountReceivedRequests().Should().Be(0, "a cancelled token must stop the request before it reaches the wire");
}

private AdminClient Admin => new(ServiceKey, new ClientOptions { Url = this.server.Url });

private void AnswerWith(HttpStatusCode status, string body) =>
this.server.Given(Request.Create().WithPath(new WildcardMatcher("/admin/users/*/factors/*")).UsingPut())
.RespondWith(Response.Create().WithStatusCode(status)
.WithHeader("Content-Type", "application/json").WithBody(body));
}
74 changes: 74 additions & 0 deletions packages/Gotrue/Gotrue.Tests/UpdateFactor/UpdateFactorIdTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
#region

using System;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Supabase.Gotrue;
using Supabase.Gotrue.UpdateFactor;

#endregion

namespace Gotrue.Tests.UpdateFactor;

/// <summary>
/// The id checks made before a factor update leaves the SDK: both ids must be canonical UUIDs, the form the auth
/// server issues them in, so a typo never reaches the server as a 404 that reads like a missing record.
/// </summary>
[TestClass]
[TestCategory("Unit")]
public class UpdateFactorIdTests
{
private const string Uuid = "2b306a77-21dc-4110-ba71-537cb56b9e98";

private static readonly AdminClient Admin = new("service-role-jwt", new ClientOptions { Url = "http://localhost" });

private static readonly FactorChanges Rename = new() { FriendlyName = "Renamed" };

[TestMethod]
[DataRow("")]
[DataRow("not-a-uuid")]
[DataRow("2b306a7721dc4110ba71537cb56b9e98")]
[DataRow("{2b306a77-21dc-4110-ba71-537cb56b9e98}")]
[DataRow("x/../../users/2b306a77-21dc-4110-ba71-537cb56b9e98")]
public async Task UpdateFactorAsync_ShouldThrowArgument_GivenUserIdThatIsNotCanonicalUuid(string userId)
{
var update = () => Admin.UpdateFactorAsync(userId, Uuid, Rename);
await update.Should().ThrowExactlyAsync<ArgumentException>()
.WithParameterName("userId", "the spec rejects a user id that is not a UUID before any request is sent");
}

[TestMethod]
[DataRow("")]
[DataRow("not-a-uuid")]
[DataRow("2b306a7721dc4110ba71537cb56b9e98")]
[DataRow("x/../../2b306a77-21dc-4110-ba71-537cb56b9e98")]
public async Task UpdateFactorAsync_ShouldThrowArgument_GivenFactorIdThatIsNotCanonicalUuid(string factorId)
{
var update = () => Admin.UpdateFactorAsync(Uuid, factorId, Rename);
await update.Should().ThrowExactlyAsync<ArgumentException>()
.WithParameterName("factorId", "the spec rejects a factor id that is not a UUID before any request is sent");
}

[TestMethod]
public async Task UpdateFactorAsync_ShouldShowTheExpectedForm_GivenIdThatIsNotCanonicalUuid()
{
var update = () => Admin.UpdateFactorAsync(Uuid, "not-a-uuid", Rename);
await update.Should().ThrowExactlyAsync<ArgumentException>()
.WithMessage("*canonical form, for example '????????-????-????-????-????????????'*", "the caller learns which form of id the SDK accepts without reading its source");
}

[TestMethod]
public async Task UpdateFactorAsync_ShouldThrowArgumentNull_GivenNullUserId()
{
var update = () => Admin.UpdateFactorAsync(null!, Uuid, Rename);
await update.Should().ThrowExactlyAsync<ArgumentNullException>().WithParameterName("userId");
}

[TestMethod]
public async Task UpdateFactorAsync_ShouldThrowArgumentNull_GivenNullFactorId()
{
var update = () => Admin.UpdateFactorAsync(Uuid, null!, Rename);
await update.Should().ThrowExactlyAsync<ArgumentNullException>().WithParameterName("factorId");
}
}
Loading
Loading