198 lines
6.8 KiB
C#
198 lines
6.8 KiB
C#
|
|
using RudderSdk.Core;
|
||
|
|
using RudderSdk.Core.Abstractions;
|
||
|
|
using RudderSdk.Core.Models.Auth;
|
||
|
|
using RudderSdk.Core.Models.Player;
|
||
|
|
|
||
|
|
using Xunit;
|
||
|
|
|
||
|
|
namespace RudderSdk.Core.Tests;
|
||
|
|
|
||
|
|
public sealed class AuthSessionTests
|
||
|
|
{
|
||
|
|
[Fact]
|
||
|
|
public async Task Request_401_Refreshes_Tokens_And_Retries_Once()
|
||
|
|
{
|
||
|
|
var tokenStore = new FakeTokenStore { AccessToken = "old-access", RefreshToken = "refresh" };
|
||
|
|
var transport = new FakeTransport();
|
||
|
|
var profileCalls = 0;
|
||
|
|
transport.Handler = (method, path, accessToken) =>
|
||
|
|
{
|
||
|
|
if (path == "/sdk/v1/authorization/refresh")
|
||
|
|
{
|
||
|
|
var request = new RefreshAccessTokenResponse { AccessToken = "new-access", RefreshToken = "new-refresh" };
|
||
|
|
return Task.FromResult<object?>(request);
|
||
|
|
}
|
||
|
|
|
||
|
|
profileCalls++;
|
||
|
|
if (profileCalls == 1)
|
||
|
|
throw new RudderAuthException(401, "unauthorized", "Unauthorized");
|
||
|
|
|
||
|
|
Assert.Equal("new-access", accessToken);
|
||
|
|
return Task.FromResult<object?>(new PlayerProfile());
|
||
|
|
};
|
||
|
|
var client = CreateClient(transport, tokenStore);
|
||
|
|
|
||
|
|
var profile = await client.Player.GetProfileAsync();
|
||
|
|
|
||
|
|
Assert.NotNull(profile);
|
||
|
|
Assert.Equal(2, profileCalls);
|
||
|
|
Assert.Equal("new-access", tokenStore.AccessToken);
|
||
|
|
Assert.Equal("new-refresh", tokenStore.RefreshToken);
|
||
|
|
}
|
||
|
|
|
||
|
|
[Fact]
|
||
|
|
public async Task Concurrent_401s_Share_One_Refresh_Request()
|
||
|
|
{
|
||
|
|
var tokenStore = new FakeTokenStore { AccessToken = "old-access", RefreshToken = "refresh" };
|
||
|
|
var transport = new FakeTransport();
|
||
|
|
var refreshCalls = 0;
|
||
|
|
transport.Handler = async (method, path, accessToken) =>
|
||
|
|
{
|
||
|
|
if (path == "/sdk/v1/authorization/refresh")
|
||
|
|
{
|
||
|
|
refreshCalls++;
|
||
|
|
await Task.Delay(50);
|
||
|
|
return new RefreshAccessTokenResponse { AccessToken = "new-access", RefreshToken = "new-refresh" };
|
||
|
|
}
|
||
|
|
|
||
|
|
if (accessToken != "new-access")
|
||
|
|
throw new RudderAuthException(401, "unauthorized", "Unauthorized");
|
||
|
|
|
||
|
|
return new PlayerProfile();
|
||
|
|
};
|
||
|
|
var client = CreateClient(transport, tokenStore);
|
||
|
|
|
||
|
|
await Task.WhenAll(
|
||
|
|
client.Player.GetProfileAsync(),
|
||
|
|
client.Player.GetProfileAsync(),
|
||
|
|
client.Player.GetProfileAsync());
|
||
|
|
|
||
|
|
Assert.Equal(1, refreshCalls);
|
||
|
|
}
|
||
|
|
|
||
|
|
[Fact]
|
||
|
|
public async Task Failed_Refresh_Clears_Tokens_Raises_SignedOut_And_Rethrows()
|
||
|
|
{
|
||
|
|
var tokenStore = new FakeTokenStore { AccessToken = "old-access", RefreshToken = "refresh" };
|
||
|
|
var transport = new FakeTransport();
|
||
|
|
transport.Handler = (method, path, accessToken) =>
|
||
|
|
throw new RudderAuthException(401, "unauthorized", "Unauthorized");
|
||
|
|
var client = CreateClient(transport, tokenStore);
|
||
|
|
var states = new List<RudderAuthState>();
|
||
|
|
client.Auth.AuthStateChanged += states.Add;
|
||
|
|
|
||
|
|
await Assert.ThrowsAsync<RudderAuthException>(() => client.Player.GetProfileAsync());
|
||
|
|
|
||
|
|
Assert.Null(tokenStore.AccessToken);
|
||
|
|
Assert.Null(tokenStore.RefreshToken);
|
||
|
|
Assert.Equal(new[] { RudderAuthState.SignedOut }, states);
|
||
|
|
}
|
||
|
|
|
||
|
|
[Fact]
|
||
|
|
public async Task Missing_Refresh_Token_Fails_Without_Refresh_Call()
|
||
|
|
{
|
||
|
|
var tokenStore = new FakeTokenStore { AccessToken = "old-access" };
|
||
|
|
var transport = new FakeTransport();
|
||
|
|
var refreshCalls = 0;
|
||
|
|
transport.Handler = (method, path, accessToken) =>
|
||
|
|
{
|
||
|
|
if (path == "/sdk/v1/authorization/refresh")
|
||
|
|
{
|
||
|
|
refreshCalls++;
|
||
|
|
return Task.FromResult<object?>(new RefreshAccessTokenResponse());
|
||
|
|
}
|
||
|
|
|
||
|
|
throw new RudderAuthException(401, "unauthorized", "Unauthorized");
|
||
|
|
};
|
||
|
|
var client = CreateClient(transport, tokenStore);
|
||
|
|
var states = new List<RudderAuthState>();
|
||
|
|
client.Auth.AuthStateChanged += states.Add;
|
||
|
|
|
||
|
|
await Assert.ThrowsAsync<RudderAuthException>(() => client.Player.GetProfileAsync());
|
||
|
|
|
||
|
|
Assert.Equal(0, refreshCalls);
|
||
|
|
Assert.Equal(new[] { RudderAuthState.SignedOut }, states);
|
||
|
|
}
|
||
|
|
|
||
|
|
[Fact]
|
||
|
|
public async Task RefreshAsync_Stores_New_Token_Pair()
|
||
|
|
{
|
||
|
|
var tokenStore = new FakeTokenStore { AccessToken = "old-access", RefreshToken = "refresh" };
|
||
|
|
var transport = new FakeTransport();
|
||
|
|
transport.Handler = (method, path, accessToken) =>
|
||
|
|
{
|
||
|
|
Assert.Equal("/sdk/v1/authorization/refresh", path);
|
||
|
|
Assert.Null(accessToken);
|
||
|
|
return Task.FromResult<object?>(new RefreshAccessTokenResponse
|
||
|
|
{
|
||
|
|
AccessToken = "new-access",
|
||
|
|
RefreshToken = "new-refresh"
|
||
|
|
});
|
||
|
|
};
|
||
|
|
var client = CreateClient(transport, tokenStore);
|
||
|
|
|
||
|
|
var refreshed = await client.Auth.RefreshAsync();
|
||
|
|
|
||
|
|
Assert.True(refreshed);
|
||
|
|
Assert.Equal("new-access", tokenStore.AccessToken);
|
||
|
|
Assert.Equal("new-refresh", tokenStore.RefreshToken);
|
||
|
|
}
|
||
|
|
|
||
|
|
private static RudderClient CreateClient(FakeTransport transport, FakeTokenStore tokenStore)
|
||
|
|
{
|
||
|
|
return new RudderClient(new RudderClientOptions
|
||
|
|
{
|
||
|
|
BaseUrl = "http://localhost:8082",
|
||
|
|
ProjectKey = "project-key",
|
||
|
|
Transport = transport,
|
||
|
|
TokenStore = tokenStore,
|
||
|
|
DeviceIdProvider = new FakeDeviceIdProvider()
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
private sealed class FakeTransport : IRudderTransport
|
||
|
|
{
|
||
|
|
public Func<string, string, string?, Task<object?>>? Handler;
|
||
|
|
|
||
|
|
public Task<TResponse> SendAsync<TRequest, TResponse>(
|
||
|
|
string method,
|
||
|
|
string path,
|
||
|
|
TRequest? request,
|
||
|
|
string? accessToken,
|
||
|
|
CancellationToken cancellationToken = default)
|
||
|
|
{
|
||
|
|
return Handle<TResponse>(method, path, accessToken);
|
||
|
|
}
|
||
|
|
|
||
|
|
private async Task<TResponse> Handle<TResponse>(string method, string path, string? accessToken)
|
||
|
|
{
|
||
|
|
var result = await Handler!(method, path, accessToken);
|
||
|
|
return (TResponse)result!;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private sealed class FakeTokenStore : ITokenStore
|
||
|
|
{
|
||
|
|
public string? AccessToken { get; set; }
|
||
|
|
public string? RefreshToken { get; set; }
|
||
|
|
public string? GetAccessToken() => AccessToken;
|
||
|
|
public string? GetRefreshToken() => RefreshToken;
|
||
|
|
public void SaveTokens(string accessToken, string refreshToken)
|
||
|
|
{
|
||
|
|
AccessToken = accessToken;
|
||
|
|
RefreshToken = refreshToken;
|
||
|
|
}
|
||
|
|
|
||
|
|
public void Clear()
|
||
|
|
{
|
||
|
|
AccessToken = null;
|
||
|
|
RefreshToken = null;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private sealed class FakeDeviceIdProvider : IDeviceIdProvider
|
||
|
|
{
|
||
|
|
public string DeviceId => "device-id";
|
||
|
|
}
|
||
|
|
}
|