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(request); } profileCalls++; if (profileCalls == 1) throw new RudderAuthException(401, "unauthorized", "Unauthorized"); Assert.Equal("new-access", accessToken); return Task.FromResult(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(); client.Auth.AuthStateChanged += states.Add; await Assert.ThrowsAsync(() => 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(new RefreshAccessTokenResponse()); } throw new RudderAuthException(401, "unauthorized", "Unauthorized"); }; var client = CreateClient(transport, tokenStore); var states = new List(); client.Auth.AuthStateChanged += states.Add; await Assert.ThrowsAsync(() => 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(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>? Handler; public Task SendAsync( string method, string path, TRequest? request, string? accessToken, CancellationToken cancellationToken = default) { return Handle(method, path, accessToken); } private async Task Handle(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"; } }