Files
Academic-Affairs-System/tests/Jiaowu.Api.Tests/SsoControllerTests.cs
T
2026-08-03 15:57:40 +08:00

163 lines
5.8 KiB
C#

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<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);
}
[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 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 StubTokenService(),
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 StubTokenService : ITokenService
{
public string Create(ApplicationUser user, IEnumerable<string> roles) =>
"test-token";
}
}