Files
2026-08-03 20:30:32 +08:00

246 lines
8.9 KiB
C#

using System.Text.Json;
using System.Security.Claims;
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.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
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<LoginResponse>(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);
}
[Fact]
public async Task Account_ReportsCurrentKeycloakBindingAndConfiguredCallback()
{
await using var fixture = await BindingFixture.CreateAsync();
Assert.True((await fixture.UserManager.AddLoginAsync(
fixture.User,
new UserLoginInfo(
SsoAuthSchemes.LoginProvider,
"keycloak-subject",
"学校统一身份认证"))).Succeeded);
fixture.SignInController();
var result = await fixture.Controller.Account();
var response = Assert.IsType<SsoAccountResponse>(result.Value);
Assert.True(response.Enabled);
Assert.True(response.IsBound);
Assert.Equal(
"https://jiaowu.example.edu.cn/signin-keycloak",
response.CallbackUrl);
}
[Fact]
public async Task Unbind_WithCurrentPassword_RemovesOnlyKeycloakLogin()
{
await using var fixture = await BindingFixture.CreateAsync();
Assert.True((await fixture.UserManager.AddLoginAsync(
fixture.User,
new UserLoginInfo(
SsoAuthSchemes.LoginProvider,
"keycloak-subject",
"学校统一身份认证"))).Succeeded);
fixture.SignInController();
var result = await fixture.Controller.Unbind(
new SsoUnbindRequest("LocalUser@123"));
Assert.IsType<NoContentResult>(result);
Assert.Null(await fixture.UserManager.FindByLoginAsync(
SsoAuthSchemes.LoginProvider,
"keycloak-subject"));
Assert.True(await fixture.UserManager.CheckPasswordAsync(
fixture.User,
"LocalUser@123"));
}
[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<ApplicationUser> userManager,
IDistributedCache cache,
SsoController controller)
{
_connection = connection;
_provider = provider;
User = user;
UserManager = userManager;
Cache = cache;
Controller = controller;
}
public ApplicationUser User { get; }
public UserManager<ApplicationUser> UserManager { get; }
public IDistributedCache Cache { get; }
public SsoController Controller { get; }
public void SignInController()
{
Controller.ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext
{
User = new ClaimsPrincipal(new ClaimsIdentity(
[new Claim(ClaimTypes.NameIdentifier, User.Id.ToString())],
"test"))
}
};
}
public static async Task<BindingFixture> CreateAsync()
{
var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
var services = new ServiceCollection();
services.AddLogging();
services.AddDistributedMemoryCache();
services.AddDbContext<AppDbContext>(options => options.UseSqlite(connection));
services
.AddIdentityCore<ApplicationUser>()
.AddRoles<ApplicationRole>()
.AddEntityFrameworkStores<AppDbContext>();
var provider = services.BuildServiceProvider();
var db = provider.GetRequiredService<AppDbContext>();
await db.Database.EnsureCreatedAsync();
var userManager = provider.GetRequiredService<UserManager<ApplicationUser>>();
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<IDistributedCache>();
var controller = new SsoController(
userManager,
new StubAuthSessionService(),
cache,
Options.Create(new SsoOptions
{
Enabled = true,
DisplayName = "学校统一身份认证",
CallbackUrl = "https://jiaowu.example.edu.cn/signin-keycloak"
}),
NullLogger<SsoController>.Instance);
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<AuthSessionResult> CreateAsync(
ApplicationUser user,
IEnumerable<string> 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<AuthSessionResult?> RefreshAsync(
string refreshToken,
CancellationToken cancellationToken = default) => Task.FromResult<AuthSessionResult?>(null);
public Task RevokeAsync(
string refreshToken,
CancellationToken cancellationToken = default) => Task.CompletedTask;
}
}