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 @@ -33,6 +33,7 @@
<ItemGroup>
<None Update="TokenRefresh\Fixtures\*.json" CopyToOutputDirectory="PreserveNewest" />
<None Update="Common\Data\*.json" CopyToOutputDirectory="PreserveNewest" />
<None Update="Identities\Data\*.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"code": 403,
"error_code": "bad_jwt",
"msg": "invalid JWT: unable to parse or verify signature, token has invalid claims: token is expired"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
{
"id": "user-id-123",
"aud": "authenticated",
"email": "user@example.com",
"identities": [
{
"identity_id": "identity-email-1",
"id": "user-id-123",
"user_id": "user-id-123",
"identity_data": { "email": "user@example.com", "sub": "user-id-123" },
"provider": "email",
"created_at": "2026-10-01T10:00:00Z",
"last_sign_in_at": "2026-10-01T10:00:00Z",
"updated_at": "2026-10-01T10:00:00Z"
},
{
"identity_id": "identity-github-1",
"id": "42",
"user_id": "user-id-123",
"identity_data": { "user_name": "octocat", "sub": "42" },
"provider": "github",
"created_at": "2026-10-02T10:00:00Z",
"last_sign_in_at": "2026-10-02T10:00:00Z",
"updated_at": "2026-10-02T10:00:00Z"
}
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"id": "user-id-123",
"aud": "authenticated",
"identities": null
}
172 changes: 172 additions & 0 deletions packages/Gotrue/Gotrue.Tests/Identities/UserIdentitiesContractTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
#region

using System;
using System.IO;
using System.Linq;
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.Interfaces;
using WireMock.RequestBuilders;
using WireMock.ResponseBuilders;
using static Supabase.Gotrue.Exceptions.FailureHint.Reason;

#endregion

namespace Gotrue.Tests.Identities;

/// <summary>
/// Pins how GetUserIdentitiesAsync reads the signed-in user's linked identities: the GET /user request
/// it puts on the wire, how the user's identities array maps to the result, and the failures raised before
/// and after the request — all against a stubbed server.
/// </summary>
[TestClass]
[TestCategory("Contract")]
public class UserIdentitiesContractTests
{
private const string AccessToken = "an-access-token";
private IGotrueClient<User, Session> client = null!;
private MockGotrueServer server = null!;

[TestInitialize]
public void TestInitializer()
{
this.server = new MockGotrueServer();
this.client = TestClients.Against(this.server);
}

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

[TestMethod]
public async Task GetUserIdentitiesAsync_ShouldGetTheUserWithTheSessionBearer()
{
this.SignIn();
this.MockUserResponse(200, Fixture("user_with_identities.json"));
await this.client.GetUserIdentitiesAsync();
this.server.VerifySingleReceivedRequest()
.WithMethod(HttpMethod.Get)
.WithPath("/user")
.WithHeader("Authorization", $"Bearer {AccessToken}")
.WithHeader("apikey", MockGotrueServer.ApiKey);
}

[TestMethod]
public async Task GetUserIdentitiesAsync_ShouldReturnEveryLinkedIdentity_GivenUserWithIdentities()
{
this.SignIn();
this.MockUserResponse(200, Fixture("user_with_identities.json"));
var identities = await this.client.GetUserIdentitiesAsync();
identities.Select(identity => identity.IdentityId).Should().Equal(new[] { "identity-email-1", "identity-github-1" },
"the result is the identities array of the user GoTrue returns from GET /user, in order, not the cached session user who has none");
}

[TestMethod]
public async Task GetUserIdentitiesAsync_ShouldReturnEmpty_GivenUserWithNullIdentities()
{
this.SignIn();
this.MockUserResponse(200, Fixture("user_with_null_identities.json"));
var identities = await this.client.GetUserIdentitiesAsync();
identities.Should().BeEmpty("collections are never null: an absent identities array means no linked identity");
}

[TestMethod]
public async Task GetUserIdentitiesAsync_ShouldReturnEmpty_GivenNullUserBody()
{
this.SignIn();
this.MockUserResponse(200, "null");
var identities = await this.client.GetUserIdentitiesAsync();
identities.Should().BeEmpty("collections are never null: a response without a user means no linked identity");
}

[TestMethod]
public async Task GetUserIdentitiesAsync_ShouldThrowServerRejection_GivenExpiredJwt()
{
this.SignIn();
this.MockUserResponse(403, Fixture("expired_jwt_error.json"));
var listing = () => this.client.GetUserIdentitiesAsync();
(await listing.Should().ThrowAsync<GotrueException>()).Which.ErrorCode.Should().Be("bad_jwt",
"GoTrue rejects an expired access token on GET /user with error_code bad_jwt");
}

[TestMethod]
public async Task GetUserIdentitiesAsync_ShouldThrowNoSessionFound_GivenNoSession()
{
var listing = () => this.client.GetUserIdentitiesAsync();
(await listing.Should().ThrowAsync<GotrueException>().WithMessage("A valid session is required.")).Which.Reason.Should().Be(NoSessionFound,
"GET /user requires the signed-in user's access token");
}

[TestMethod]
public async Task GetUserIdentitiesAsync_ShouldThrowNoSessionFound_GivenSessionWithoutAccessToken()
{
this.SignIn(new Session { RefreshToken = "a-refresh-token", ExpiresIn = 3600 });
var listing = () => this.client.GetUserIdentitiesAsync();
(await listing.Should().ThrowAsync<GotrueException>()).Which.Reason.Should().Be(NoSessionFound,
"GET /user requires the signed-in user's access token");
}

[TestMethod]
public async Task GetUserIdentitiesAsync_ShouldNotSendARequest_GivenNoSession()
{
var listing = () => this.client.GetUserIdentitiesAsync();
await listing.Should().ThrowAsync<GotrueException>();
this.server.CountReceivedRequests().Should().Be(0, "the missing session is detected before the wire");
}

[TestMethod]
public async Task GetUserIdentitiesAsync_ShouldThrowOffline_GivenClientIsOffline()
{
this.SignIn();
this.client.Online = false;
var listing = () => this.client.GetUserIdentitiesAsync();
(await listing.Should().ThrowAsync<GotrueException>().WithMessage("Only supported when online")).Which.Reason.Should().Be(Offline,
"reading the identities needs the server, like the other identity-linking operations");
}

[TestMethod]
public async Task GetUserIdentitiesAsync_ShouldCancelTheRequest_GivenCancelledToken()
{
this.SignIn();
this.MockUserResponse(200, Fixture("user_with_identities.json"));
using var cancellation = new CancellationTokenSource();
cancellation.Cancel();
var listing = () => this.client.GetUserIdentitiesAsync(cancellation.Token);
await listing.Should().ThrowAsync<OperationCanceledException>("the cancellation token reaches the HTTP call");
}

[TestMethod]
public void GetUserIdentitiesAsync_ShouldComplete_GivenCallerBlockingOnContextThatNeverRunsContinuations()
{
this.SignIn();
this.MockUserResponse(200, Fixture("user_with_identities.json"));
BlockingCaller.Completes(() => this.client.GetUserIdentitiesAsync()).Should().BeTrue(
"a library must not resume on the caller's context: a UI thread blocking on the task would deadlock");
}

private void SignIn() => this.SignIn(new Session { AccessToken = AccessToken, RefreshToken = "a-refresh-token", ExpiresIn = 3600, User = new User { Id = "user-id-123" } });

private void SignIn(Session session)
{
var persistence = SessionPersistenceSubstitute.Tracking();
persistence.SaveSession(session);
this.client.SetPersistence(persistence);
this.client.LoadSession();
}

private void MockUserResponse(int statusCode, string body) =>
this.server
.Given(Request.Create().WithPath("/user").UsingGet())
.RespondWith(Response.Create()
.WithStatusCode(statusCode)
.WithHeader("Content-Type", "application/json")
.WithBody(body));

private static string Fixture(string name) =>
File.ReadAllText(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Identities", "Data", name));
}
27 changes: 27 additions & 0 deletions packages/Gotrue/Gotrue.Tests/Identities/UserIdentitiesTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
#region

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

#endregion

namespace Gotrue.Tests.Identities;

/// <summary>
/// End-to-end listing of the identities linked to the signed-in user against the live stack.
/// </summary>
[TestClass]
[TestCategory("E2E")]
public class UserIdentitiesTests : AuthClientFixture
{
[TestMethod]
public async Task GetUserIdentitiesAsync_ShouldReturnTheEmailIdentity_GivenUserSignedUpWithEmail()
{
var session = await this.SignUpNewUser();
var identities = await this.Client.GetUserIdentitiesAsync();
identities.Should().ContainSingle(identity => identity.Provider == "email" && identity.UserId == session.User!.Id,
"GoTrue creates an email identity for an email/password sign-up (GET /user identities)");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,17 @@ public async Task DomainSpan_ShouldParentTheHttpSpan()
this.HttpTokenSpan().ParentSpanId.Should().Be(domainSpan.SpanId);
}

[TestMethod]
public async Task DomainSpan_ShouldParentTheHttpSpan_GivenUserIdentitiesListing()
{
this.MockTokenSuccess();
await this.Refresh();
this.MockUserSuccess();
await this.client.GetUserIdentitiesAsync();
var domainSpan = this.activities.Should().ContainSingle(a => a.OperationName == "gotrue.get_user_identities").Which;
this.activities.Should().ContainSingle(a => a.OperationName == "GET /user").Which.ParentSpanId.Should().Be(domainSpan.SpanId);
}

[TestMethod]
public async Task HttpSpan_ShouldBeMarkedError_GivenFailedRequest()
{
Expand Down Expand Up @@ -217,6 +228,14 @@ private void MockTokenSuccess() =>
.WithHeader("Content-Type", "application/json")
.WithBody(File.ReadAllText(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "TokenRefresh", "Fixtures", "token_success.json"))));

private void MockUserSuccess() =>
this.server
.Given(Request.Create().WithPath("/user").UsingGet())
.RespondWith(Response.Create()
.WithStatusCode(200)
.WithHeader("Content-Type", "application/json")
.WithBody(File.ReadAllText(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Identities", "Data", "user_with_identities.json"))));

private void MockTokenFailure() =>
this.server
.Given(Request.Create().WithPath("/token").UsingPost())
Expand Down
14 changes: 14 additions & 0 deletions packages/Gotrue/Gotrue/Client.cs
Original file line number Diff line number Diff line change
Expand Up @@ -537,6 +537,20 @@ public Task<bool> UnlinkIdentity(UserIdentity userIdentity)
return this.api.UnlinkIdentity(this.CurrentSession.AccessToken!, userIdentity);
}

/// <inheritdoc />
[ImplementedCapability("auth.identities.list_identities")]
public async Task<IReadOnlyList<UserIdentity>> GetUserIdentitiesAsync(CancellationToken cancellationToken = default)
{
using var activity = GotrueInstrumentation.Source.StartActivity(GotrueInstrumentation.Spans.GetUserIdentities);
if (!this.Online)
{
throw new GotrueException("Only supported when online", Offline);
}
var accessToken = this.CurrentSession?.AccessToken ?? throw new GotrueException("A valid session is required.", NoSessionFound);
var user = await this.api.GetUserAsync(accessToken, cancellationToken).ConfigureAwait(false);
return user?.Identities ?? new List<UserIdentity>();
}

/// <inheritdoc />
[ImplementedCapability("auth.session.sign_out")]
public async Task SignOut(SignOutScope scope = SignOutScope.Global)
Expand Down
Loading
Loading