Files
tiku-backend.net/Tiku.IntegrationTests/Api/AuthSessionLifecycleTests.cs

277 lines
11 KiB
C#

using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Tiku.Application.Auth;
using Tiku.Domain.Identity;
using Tiku.Domain.Operations;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Persistence;
namespace Tiku.IntegrationTests.Api;
public sealed class AuthSessionLifecycleTests
{
[Fact]
public async Task Refresh_rotation_creates_a_child_and_replay_revokes_the_entire_family()
{
await using var factory = new ApiTestFactory();
var seed = await SeedActiveMemberAsync(factory);
var original = await IssueAsync(factory, seed);
AuthTokenPair rotated;
using (var scope = factory.CreateSystemScope("Rotate refresh token"))
{
rotated = await scope.ServiceProvider.GetRequiredService<IAuthSessionStore>()
.RotateAsync(original.RefreshToken, "127.0.0.1", "integration-test");
}
Assert.True(TryLocate(factory, original.RefreshToken, out var originalLocator));
Assert.True(TryLocate(factory, rotated.RefreshToken, out var rotatedLocator));
using (var scope = factory.CreateSystemScope("Verify rotated session lineage"))
{
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
var sessions = await dbContext.AuthSessions
.Where(session => session.Id == originalLocator.SessionId || session.Id == rotatedLocator.SessionId)
.OrderBy(session => session.ParentSessionId == null ? 0 : 1)
.ToListAsync();
Assert.Equal(2, sessions.Count);
Assert.Equal(originalLocator.SessionId, sessions[0].Id);
Assert.Equal(rotatedLocator.SessionId, sessions[0].ReplacedBySessionId);
Assert.Equal("rotated", sessions[0].RevokedReason);
Assert.Equal(originalLocator.SessionId, sessions[1].ParentSessionId);
Assert.Equal(sessions[0].TokenFamilyId, sessions[1].TokenFamilyId);
}
using (var scope = factory.CreateSystemScope("Replay rotated refresh token"))
{
await Assert.ThrowsAsync<SessionRevokedException>(() =>
scope.ServiceProvider.GetRequiredService<IAuthSessionStore>()
.RotateAsync(original.RefreshToken, null, null));
}
using (var scope = factory.CreateSystemScope("Verify refresh family revocation"))
{
var store = scope.ServiceProvider.GetRequiredService<IAuthSessionStore>();
var validation = await store.ValidateAccessSessionAsync(
rotatedLocator.SessionId,
seed.UserId,
AuthRealm.Tenant,
seed.TenantId);
Assert.Null(validation);
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
var family = await dbContext.AuthSessions
.Where(session => session.TokenFamilyId == originalLocator.SessionId)
.ToListAsync();
Assert.All(family, session => Assert.NotNull(session.RevokedAt));
Assert.Contains(family, session => session.RevokedReason == "refresh_token_reuse");
}
}
[Fact]
public async Task Concurrent_refresh_allows_only_one_rotation_and_revokes_the_replayed_family()
{
await using var factory = new ApiTestFactory();
var seed = await SeedActiveMemberAsync(factory);
var original = await IssueAsync(factory, seed);
Assert.True(TryLocate(factory, original.RefreshToken, out var originalLocator));
using var firstScope = factory.CreateSystemScope("First concurrent refresh");
using var secondScope = factory.CreateSystemScope("Second concurrent refresh");
var first = TryRotateAsync(
firstScope.ServiceProvider.GetRequiredService<IAuthSessionStore>(),
original.RefreshToken);
var second = TryRotateAsync(
secondScope.ServiceProvider.GetRequiredService<IAuthSessionStore>(),
original.RefreshToken);
var results = await Task.WhenAll(first, second);
Assert.Single(results, result => result is not null);
Assert.Single(results, result => result is null);
using var verificationScope = factory.CreateSystemScope("Verify concurrent refresh family");
var dbContext = verificationScope.ServiceProvider.GetRequiredService<TikuDbContext>();
var family = await dbContext.AuthSessions
.Where(session => session.TokenFamilyId == originalLocator.SessionId)
.ToListAsync();
Assert.Equal(2, family.Count);
Assert.All(family, session => Assert.NotNull(session.RevokedAt));
Assert.Contains(family, session => session.RevokedReason == "refresh_token_reuse");
}
[Fact]
public async Task Access_session_fails_immediately_after_membership_is_disabled()
{
await using var factory = new ApiTestFactory();
var seed = await SeedActiveMemberAsync(factory);
var tokens = await IssueAsync(factory, seed);
Assert.True(TryLocate(factory, tokens.RefreshToken, out var locator));
using (var scope = factory.CreateSystemScope("Disable tenant membership"))
{
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
var membership = await dbContext.TenantMemberships.SingleAsync(item =>
item.TenantId == seed.TenantId && item.UserId == seed.UserId);
membership.Status = MembershipStatus.Disabled;
await dbContext.SaveChangesAsync();
}
using (var scope = factory.CreateSystemScope("Validate disabled membership session"))
{
var validation = await scope.ServiceProvider.GetRequiredService<IAuthSessionStore>()
.ValidateAccessSessionAsync(locator.SessionId, seed.UserId, AuthRealm.Tenant, seed.TenantId);
Assert.Null(validation);
}
}
[Fact]
public async Task Access_session_fails_immediately_after_security_stamp_changes()
{
await using var factory = new ApiTestFactory();
var seed = await SeedActiveMemberAsync(factory);
var tokens = await IssueAsync(factory, seed);
Assert.True(TryLocate(factory, tokens.RefreshToken, out var locator));
using (var scope = factory.CreateSystemScope("Change user security stamp"))
{
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
var user = await dbContext.Users.SingleAsync(item => item.Id == seed.UserId);
user.SecurityStamp = Guid.NewGuid().ToString("N");
await dbContext.SaveChangesAsync();
}
using (var scope = factory.CreateSystemScope("Validate stale security stamp session"))
{
var validation = await scope.ServiceProvider.GetRequiredService<IAuthSessionStore>()
.ValidateAccessSessionAsync(locator.SessionId, seed.UserId, AuthRealm.Tenant, seed.TenantId);
Assert.Null(validation);
}
}
[Fact]
public async Task Tenant_session_remains_valid_after_a_backend_permission_is_revoked()
{
await using var factory = new ApiTestFactory();
var seed = await SeedActiveMemberAsync(factory);
var role = new TenantBackendRole
{
TenantId = seed.TenantId,
Code = "session-test-admin",
Name = "Session test administrator"
};
const string permissionCode = "tenant:session-test:manage";
await factory.SeedAsync(
new BackendPermission
{
Code = permissionCode,
Name = permissionCode,
Area = BackendPermissionArea.Tenant,
PermissionModuleCode = "tenant_dashboard"
},
role,
new TenantBackendRolePermission
{
TenantId = seed.TenantId,
RoleId = role.Id,
PermissionCode = permissionCode
},
new TenantBackendUserRole
{
TenantId = seed.TenantId,
UserId = seed.UserId,
RoleId = role.Id
});
var tokens = await IssueAsync(factory, seed);
Assert.True(TryLocate(factory, tokens.RefreshToken, out var locator));
using (var scope = factory.CreateSystemScope("Revoke final backend permission"))
{
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
var binding = await dbContext.TenantBackendRolePermissions.SingleAsync(item =>
item.TenantId == seed.TenantId && item.RoleId == role.Id);
dbContext.TenantBackendRolePermissions.Remove(binding);
await dbContext.SaveChangesAsync();
}
using (var scope = factory.CreateSystemScope("Validate tenant session"))
{
var store = scope.ServiceProvider.GetRequiredService<IAuthSessionStore>();
Assert.NotNull(await store.ValidateAccessSessionAsync(
locator.SessionId, seed.UserId, AuthRealm.Tenant, seed.TenantId));
Assert.NotNull(await store.RotateAsync(tokens.RefreshToken, null, null));
}
}
private static bool TryLocate(
ApiTestFactory factory,
string refreshToken,
out RefreshTokenLocator locator)
{
using var scope = factory.CreateSystemScope("Parse refresh token locator");
return scope.ServiceProvider.GetRequiredService<IAuthSessionStore>()
.TryParseRefreshToken(refreshToken, out locator);
}
private static async Task<AuthTokenPair?> TryRotateAsync(IAuthSessionStore store, string refreshToken)
{
try
{
return await store.RotateAsync(refreshToken, null, null);
}
catch (SessionRevokedException)
{
return null;
}
}
private static async Task<AuthTokenPair> IssueAsync(
ApiTestFactory factory,
SessionSeed seed)
{
using var scope = factory.CreateSystemScope("Issue authentication session");
return await scope.ServiceProvider.GetRequiredService<IAuthSessionStore>().IssueAsync(
new AuthSessionIssueRequest(
seed.UserId,
seed.Phone,
null,
seed.SecurityStamp,
AuthRealm.Tenant,
seed.TenantId,
"integration-test",
"127.0.0.1",
"integration-test"));
}
private static async Task<SessionSeed> SeedActiveMemberAsync(ApiTestFactory factory)
{
var tenantId = Guid.NewGuid();
var user = new User
{
Id = Guid.NewGuid(),
Phone = $"13{Random.Shared.Next(100_000_000, 1_000_000_000)}",
Name = "Session lifecycle user"
};
await factory.SeedAsync(
new Tenant
{
Id = tenantId,
Slug = tenantId.ToString("N"),
Name = "Session lifecycle tenant",
Status = TenantStatus.Active
},
user,
new TenantMembership
{
TenantId = tenantId,
UserId = user.Id,
Role = TenantRole.Student,
Status = MembershipStatus.Active
});
return new SessionSeed(tenantId, user.Id, user.Phone, user.SecurityStamp!);
}
private sealed record SessionSeed(Guid TenantId, Guid UserId, string Phone, string SecurityStamp);
}