using System.Text.Json; using Jiaowu.Api.Controllers; using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Infrastructure.Auth; using Jiaowu.Api.Infrastructure.Persistence; using Microsoft.AspNetCore.Identity; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; namespace Jiaowu.Api.Tests; public sealed class SsoControllerTests { [Fact] public async Task Bind_WithValidLocalCredentials_LinksKeycloakIdentityAndLogsIn() { await using var fixture = await BindingFixture.CreateAsync(); var code = new string('a', 32); await fixture.StoreTicketAsync(code, "keycloak-subject", "external.name"); var result = await fixture.Controller.Bind( new SsoBindRequest(code, fixture.User.UserName!, "LocalUser@123"), CancellationToken.None); var response = Assert.IsType(result.Value); Assert.Equal("test-token", response.Token); Assert.Equal(fixture.User.Id, response.User.Id); var linkedUser = await fixture.UserManager.FindByLoginAsync( SsoAuthSchemes.LoginProvider, "keycloak-subject"); Assert.Equal(fixture.User.Id, linkedUser?.Id); Assert.Null(await fixture.Cache.GetStringAsync($"sso:binding:{code}")); } [Fact] public async Task Bind_WithWrongPassword_DoesNotLinkKeycloakIdentity() { await using var fixture = await BindingFixture.CreateAsync(); var code = new string('b', 32); await fixture.StoreTicketAsync(code, "keycloak-subject", "external.name"); var result = await fixture.Controller.Bind( new SsoBindRequest(code, fixture.User.UserName!, "WrongPassword@123"), CancellationToken.None); Assert.NotNull(result.Result); Assert.Null(await fixture.UserManager.FindByLoginAsync( SsoAuthSchemes.LoginProvider, "keycloak-subject")); var user = await fixture.UserManager.FindByIdAsync(fixture.User.Id.ToString()); Assert.Equal(1, user?.AccessFailedCount); } [Theory] [InlineData(null, "/dashboard")] [InlineData("", "/dashboard")] [InlineData("https://evil.example/path", "/dashboard")] [InlineData("//evil.example/path", "/dashboard")] [InlineData("/grades?term=2026-1", "/grades?term=2026-1")] public void NormalizeReturnUrl_AllowsOnlyLocalApplicationPaths( string? value, string expected) { Assert.Equal(expected, SsoController.NormalizeReturnUrl(value)); } private sealed class BindingFixture : IAsyncDisposable { private readonly SqliteConnection _connection; private readonly ServiceProvider _provider; private BindingFixture( SqliteConnection connection, ServiceProvider provider, ApplicationUser user, UserManager userManager, IDistributedCache cache, SsoController controller) { _connection = connection; _provider = provider; User = user; UserManager = userManager; Cache = cache; Controller = controller; } public ApplicationUser User { get; } public UserManager UserManager { get; } public IDistributedCache Cache { get; } public SsoController Controller { get; } public static async Task CreateAsync() { var connection = new SqliteConnection("Data Source=:memory:"); await connection.OpenAsync(); var services = new ServiceCollection(); services.AddLogging(); services.AddDistributedMemoryCache(); services.AddDbContext(options => options.UseSqlite(connection)); services .AddIdentityCore() .AddRoles() .AddEntityFrameworkStores(); var provider = services.BuildServiceProvider(); var db = provider.GetRequiredService(); await db.Database.EnsureCreatedAsync(); var userManager = provider.GetRequiredService>(); var user = new ApplicationUser { UserName = "local.user", DisplayName = "本地用户", IsEnabled = true, LockoutEnabled = true }; Assert.True((await userManager.CreateAsync(user, "LocalUser@123")).Succeeded); var cache = provider.GetRequiredService(); var controller = new SsoController( userManager, new StubAuthSessionService(), cache, Options.Create(new SsoOptions { Enabled = true, DisplayName = "学校统一身份认证" })); return new BindingFixture( connection, provider, user, userManager, cache, controller); } public Task StoreTicketAsync( string code, string subject, string externalUserName) => Cache.SetStringAsync( $"sso:binding:{code}", JsonSerializer.Serialize( new SsoBindingTicket(subject, externalUserName))); public async ValueTask DisposeAsync() { await _provider.DisposeAsync(); await _connection.DisposeAsync(); } } private sealed class StubAuthSessionService : IAuthSessionService { public Task CreateAsync( ApplicationUser user, IEnumerable roles, AuthenticationClientType clientType, CancellationToken cancellationToken = default) => Task.FromResult(new AuthSessionResult( "test-token", DateTime.UtcNow.AddMinutes(10), "test-refresh-token-value-with-sufficient-length", DateTime.UtcNow.AddMinutes(30), user, roles.ToList())); public Task RefreshAsync( string refreshToken, CancellationToken cancellationToken = default) => Task.FromResult(null); public Task RevokeAsync( string refreshToken, CancellationToken cancellationToken = default) => Task.CompletedTask; } }