From c6e169cf7381a2aebacf4100b2e2f6ae6e4cae95 Mon Sep 17 00:00:00 2001 From: Nole Date: Tue, 3 Nov 2026 08:36:58 +0100 Subject: [PATCH 1/2] =?UTF-8?q?=D0=90dded=20interfaces=20for=20login=20and?= =?UTF-8?q?=20logout=20service=20for=20depenedencz=20injection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/Platform.Api/Services/Identity/ILoginService.cs | 7 +++++++ backend/Platform.Api/Services/Identity/ILogoutService.cs | 6 ++++++ backend/Platform.Api/Services/Identity/LoginService.cs | 2 +- .../Identity/{LogoutAccount.cs => LogoutService.cs} | 2 +- 4 files changed, 15 insertions(+), 2 deletions(-) create mode 100644 backend/Platform.Api/Services/Identity/ILoginService.cs create mode 100644 backend/Platform.Api/Services/Identity/ILogoutService.cs rename backend/Platform.Api/Services/Identity/{LogoutAccount.cs => LogoutService.cs} (89%) diff --git a/backend/Platform.Api/Services/Identity/ILoginService.cs b/backend/Platform.Api/Services/Identity/ILoginService.cs new file mode 100644 index 0000000..ecac65d --- /dev/null +++ b/backend/Platform.Api/Services/Identity/ILoginService.cs @@ -0,0 +1,7 @@ +namespace Platform.Api.Services.Identity; + +public interface ILoginService +{ + public Task ExecuteAsync(string? email, string? password, string correlationId, + CancellationToken ct = default); +} \ No newline at end of file diff --git a/backend/Platform.Api/Services/Identity/ILogoutService.cs b/backend/Platform.Api/Services/Identity/ILogoutService.cs new file mode 100644 index 0000000..4aa6460 --- /dev/null +++ b/backend/Platform.Api/Services/Identity/ILogoutService.cs @@ -0,0 +1,6 @@ +namespace Platform.Api.Services.Identity; + +public interface ILogoutService +{ + public Task ExecuteAsync(string? rawToken, string correlationId, CancellationToken ct = default); +} \ No newline at end of file diff --git a/backend/Platform.Api/Services/Identity/LoginService.cs b/backend/Platform.Api/Services/Identity/LoginService.cs index 7fe1493..de72193 100644 --- a/backend/Platform.Api/Services/Identity/LoginService.cs +++ b/backend/Platform.Api/Services/Identity/LoginService.cs @@ -22,7 +22,7 @@ public sealed class LoginService( ICredentialVerifier credentials, ISessionCreator sessions, IAuditWriter audit, - TimeProvider clock) + TimeProvider clock) : ILoginService { private const string AuditAction = "auth.login"; diff --git a/backend/Platform.Api/Services/Identity/LogoutAccount.cs b/backend/Platform.Api/Services/Identity/LogoutService.cs similarity index 89% rename from backend/Platform.Api/Services/Identity/LogoutAccount.cs rename to backend/Platform.Api/Services/Identity/LogoutService.cs index a42cde7..79c5d3e 100644 --- a/backend/Platform.Api/Services/Identity/LogoutAccount.cs +++ b/backend/Platform.Api/Services/Identity/LogoutService.cs @@ -7,7 +7,7 @@ namespace Platform.Api.Services.Identity; /// sessions and only touches this one session. Without a session to revoke it does nothing, so the caller /// answers the same in every case and learns nothing about the session state. /// -public sealed class LogoutAccount(ISessionStore sessions, IAuditWriter audit, TimeProvider clock) +public sealed class LogoutService(ISessionStore sessions, IAuditWriter audit, TimeProvider clock) : ILogoutService { private const string AuditAction = "auth.logout"; From 251d6dbccddec38552dc2110ed36797bfef8160b Mon Sep 17 00:00:00 2001 From: Nole Date: Tue, 3 Nov 2026 08:39:23 +0100 Subject: [PATCH 2/2] =?UTF-8?q?Merged=20login,=20logout=20and=20me=20contr?= =?UTF-8?q?oller=20into=20one=20=D0=90uthController.cs=20and=20merged=20te?= =?UTF-8?q?sts=20for=20these=20controllers=20into=20=D0=90uthControllerTes?= =?UTF-8?q?ts.cs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Controllers/Identity/AuthController.cs | 62 +++++++ .../Controllers/Identity/LoginController.cs | 34 ---- .../Controllers/Identity/LogoutController.cs | 24 --- .../Controllers/Identity/MeController.cs | 28 ---- backend/Platform.Api/Program.cs | 2 +- .../\320\220uthControllerTests.cs" | 153 ++++++++++++++++++ .../Platform.Tests/Identity/LogoutTests.cs | 96 ++--------- .../Identity/MeControllerTests.cs | 66 -------- 8 files changed, 227 insertions(+), 238 deletions(-) create mode 100644 backend/Platform.Api/Controllers/Identity/AuthController.cs delete mode 100644 backend/Platform.Api/Controllers/Identity/LoginController.cs delete mode 100644 backend/Platform.Api/Controllers/Identity/LogoutController.cs delete mode 100644 backend/Platform.Api/Controllers/Identity/MeController.cs create mode 100644 "backend/Platform.Tests/Identity/Controllers/\320\220uthControllerTests.cs" delete mode 100644 backend/Platform.Tests/Identity/MeControllerTests.cs diff --git a/backend/Platform.Api/Controllers/Identity/AuthController.cs b/backend/Platform.Api/Controllers/Identity/AuthController.cs new file mode 100644 index 0000000..91fb117 --- /dev/null +++ b/backend/Platform.Api/Controllers/Identity/AuthController.cs @@ -0,0 +1,62 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Platform.Api.Services.Identity; + +namespace Platform.Api.Controllers.Identity; + +public sealed record LoginRequest(string? Email, string? Password); + +[ApiController] +[Route("api/auth")] +public class AuthController(ILoginService login, ILogoutService logout, IIdentityContext identity) : ControllerBase +{ + private readonly IIdentityContext _identity = identity; + private readonly ILoginService _login = login; + private readonly ILogoutService _logout = logout; + + [HttpPost("login")] + public async Task Login([FromBody] LoginRequest body, CancellationToken ct) + { + var result = await _login.ExecuteAsync(body.Email, body.Password, HttpContext.TraceIdentifier, ct); + Response.Headers.CacheControl = "no-store"; + + switch (result.Outcome) + { + case LoginOutcome.Success: + // The token goes only into the cookie, never into the body. + Response.Cookies.Append(SessionCookie.Name, result.Session!.Token, SessionCookie.Options(result.Session.ExpiresAt)); + return Ok(new { userId = result.UserId }); + case LoginOutcome.InvalidRequest: + return BadRequest(new { code = "invalid_request" }); + case LoginOutcome.Unavailable: + return StatusCode(503, new { code = "login_unavailable" }); + default: + return Unauthorized(new { code = "invalid_credentials" }); + } + } + + [AllowAnonymous] + [HttpPost("logout")] + public async Task Logout() + { + // Revoke first: if it fails, the cookie stays and the client can try again. + // No request token: a closed tab or a broken connection must not leave the session valid. + await _logout.ExecuteAsync(Request.Cookies[SessionCookie.Name], HttpContext.TraceIdentifier, CancellationToken.None); + + Response.Headers.CacheControl = "no-store"; + Response.Cookies.Delete(SessionCookie.Name, SessionCookie.Options(DateTimeOffset.UnixEpoch)); + return NoContent(); + } + + [HttpGet("me")] + public IActionResult Me() + { + var caller = _identity.Current; + + if (caller == null || !caller.IsAuthenticated) + return Unauthorized(); + + Response.Headers.CacheControl = "no-store"; + return Ok(new { userId = caller.UserId, level = caller.Level.ToString() }); + } +} diff --git a/backend/Platform.Api/Controllers/Identity/LoginController.cs b/backend/Platform.Api/Controllers/Identity/LoginController.cs deleted file mode 100644 index a4e5dd1..0000000 --- a/backend/Platform.Api/Controllers/Identity/LoginController.cs +++ /dev/null @@ -1,34 +0,0 @@ -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; -using Platform.Api.Services.Identity; - -namespace Platform.Api.Controllers.Identity; - -public sealed record LoginRequest(string? Email, string? Password); - -[ApiController] -[AllowAnonymous] -[Route("api/auth")] -public class LoginController(LoginService login) : ControllerBase -{ - [HttpPost("login")] - public async Task Login([FromBody] LoginRequest body, CancellationToken ct) - { - var result = await login.ExecuteAsync(body.Email, body.Password, HttpContext.TraceIdentifier, ct); - Response.Headers.CacheControl = "no-store"; - - switch (result.Outcome) - { - case LoginOutcome.Success: - // The token goes only into the cookie, never into the body. - Response.Cookies.Append(SessionCookie.Name, result.Session!.Token, SessionCookie.Options(result.Session.ExpiresAt)); - return Ok(new { userId = result.UserId }); - case LoginOutcome.InvalidRequest: - return BadRequest(new { code = "invalid_request" }); - case LoginOutcome.Unavailable: - return StatusCode(503, new { code = "login_unavailable" }); - default: - return Unauthorized(new { code = "invalid_credentials" }); - } - } -} diff --git a/backend/Platform.Api/Controllers/Identity/LogoutController.cs b/backend/Platform.Api/Controllers/Identity/LogoutController.cs deleted file mode 100644 index 67eff05..0000000 --- a/backend/Platform.Api/Controllers/Identity/LogoutController.cs +++ /dev/null @@ -1,24 +0,0 @@ -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; -using Platform.Api.Services.Identity; - -namespace Platform.Api.Controllers.Identity; - -[ApiController] -[Route("api/auth")] -public class LogoutController(LogoutAccount logout) : ControllerBase -{ - // Anonymous on purpose: logout without a valid session is idempotent and must not become a 401. - [AllowAnonymous] - [HttpPost("logout")] - public async Task Logout() - { - // Revoke first: if it fails, the cookie stays and the client can try again. - // No request token: a closed tab or a broken connection must not leave the session valid. - await logout.ExecuteAsync(Request.Cookies[SessionCookie.Name], HttpContext.TraceIdentifier, CancellationToken.None); - - Response.Headers.CacheControl = "no-store"; - Response.Cookies.Delete(SessionCookie.Name, SessionCookie.Options(DateTimeOffset.UnixEpoch)); - return NoContent(); - } -} diff --git a/backend/Platform.Api/Controllers/Identity/MeController.cs b/backend/Platform.Api/Controllers/Identity/MeController.cs deleted file mode 100644 index 05095fa..0000000 --- a/backend/Platform.Api/Controllers/Identity/MeController.cs +++ /dev/null @@ -1,28 +0,0 @@ -using Microsoft.AspNetCore.Mvc; -using Platform.Api.Services.Identity; - -namespace Platform.Api.Controllers.Identity; - -[ApiController] -[Route("api/auth")] -public class MeController : ControllerBase -{ - private readonly IIdentityContext _identity; - - public MeController(IIdentityContext identity) - { - _identity = identity; - } - - [HttpGet("me")] - public IActionResult Me() - { - var caller = _identity.Current; - - if (caller == null || !caller.IsAuthenticated) - return Unauthorized(); - - Response.Headers.CacheControl = "no-store"; - return Ok(new { userId = caller.UserId, level = caller.Level.ToString() }); - } -} \ No newline at end of file diff --git a/backend/Platform.Api/Program.cs b/backend/Platform.Api/Program.cs index 4319083..b672c3a 100644 --- a/backend/Platform.Api/Program.cs +++ b/backend/Platform.Api/Program.cs @@ -30,7 +30,7 @@ builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddScoped(); -builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git "a/backend/Platform.Tests/Identity/Controllers/\320\220uthControllerTests.cs" "b/backend/Platform.Tests/Identity/Controllers/\320\220uthControllerTests.cs" new file mode 100644 index 0000000..4b90bfc --- /dev/null +++ "b/backend/Platform.Tests/Identity/Controllers/\320\220uthControllerTests.cs" @@ -0,0 +1,153 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Platform.Api.Controllers.Identity; +using Platform.Api.Domain.Authentication; +using Platform.Api.Services.Identity; +using Moq; + +namespace Platform.Tests.Identity.Controllers; + +[TestFixture] +[Category("US-06")] +public class AuthControllerTests +{ + private Mock _login = null!; + private Mock _logout = null!; + private Mock _identity = null!; + private AuthController _controller = null!; + + [SetUp] + public void SetUp() + { + _login = new Mock(); + _logout = new Mock(); + _identity = new Mock(); + _controller = new AuthController(_login.Object, _logout.Object, _identity.Object) + { + ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() } + }; + } + + [Test] + public void NoCaller_Returns401() + { + CallerIs(null); + + var result = _controller.Me(); + + Assert.That(result, Is.InstanceOf()); + } + + [Test] + public void MfaPendingCaller_Returns401() + { + CallerIs(new CallerIdentity(false, Guid.NewGuid(), AuthenticationLevel.MfaPending, Guid.NewGuid())); + + var result = _controller.Me(); + + Assert.That(result, Is.InstanceOf()); + } + + [Test] + public void ActiveCaller_ReturnsOnlyUserIdAndLevel() + { + var userId = Guid.NewGuid(); + CallerIs(new CallerIdentity(true, userId, AuthenticationLevel.Active, Guid.NewGuid())); + + var result = _controller.Me() as OkObjectResult; + + Assert.That(result, Is.Not.Null); + var body = result!.Value!; + var names = body.GetType().GetProperties().Select(p => p.Name).ToArray(); + Assert.That(names, Is.EquivalentTo(["userId", "level"])); + Assert.That(body.GetType().GetProperty("userId")!.GetValue(body), Is.EqualTo(userId)); + Assert.That(body.GetType().GetProperty("level")!.GetValue(body), Is.EqualTo("Active")); + Assert.That(_controller.Response.Headers.CacheControl.ToString(), Is.EqualTo("no-store")); + } + + private void CallerIs(CallerIdentity? caller) => + _identity.Setup(i => i.Current).Returns(caller); + + [Test] + public async Task Logout_RevokesCookieSession_AndClearsTheCookieWithItsAttributes() + { + const string token = "opaque-session-token-value"; + var http = HttpContextWithCookie(token); + _controller.ControllerContext.HttpContext = http; + + var response = await _controller.Logout(); + + Assert.That(response, Is.InstanceOf()); + _logout.Verify( + service => service.ExecuteAsync(token, http.TraceIdentifier, CancellationToken.None), + Times.Once); + Assert.That(http.Response.Headers.CacheControl.ToString(), Is.EqualTo("no-store")); + AssertCookieCleared(http); + } + + [Test] + public async Task Logout_ClientDisconnect_DoesNotCancelTheRevocation() + { + const string token = "opaque-session-token-value"; + var http = HttpContextWithCookie(token); + using var aborted = new CancellationTokenSource(); + aborted.Cancel(); + http.RequestAborted = aborted.Token; + _controller.ControllerContext.HttpContext = http; + + await _controller.Logout(); + + _logout.Verify( + service => service.ExecuteAsync(token, http.TraceIdentifier, CancellationToken.None), + Times.Once); + } + + [TestCase(null)] + [TestCase("unknown-token")] + public async Task Logout_WithoutValidSession_AnswersTheSame(string? token) + { + var http = HttpContextWithCookie(token); + _controller.ControllerContext.HttpContext = http; + + var response = await _controller.Logout(); + + Assert.That(response, Is.InstanceOf()); + _logout.Verify( + service => service.ExecuteAsync(token, http.TraceIdentifier, CancellationToken.None), + Times.Once); + AssertCookieCleared(http); + } + + [Test] + public void Logout_WhenRevokingFails_KeepsTheCookie() + { + const string token = "opaque-session-token-value"; + var http = HttpContextWithCookie(token); + _controller.ControllerContext.HttpContext = http; + _logout + .Setup(service => service.ExecuteAsync(It.IsAny(), It.IsAny(), CancellationToken.None)) + .ThrowsAsync(new InvalidOperationException("database is down")); + + Assert.ThrowsAsync(() => _controller.Logout()); + + Assert.That(http.Response.Headers.ContainsKey("Set-Cookie"), Is.False); + } + + private static DefaultHttpContext HttpContextWithCookie(string? token) + { + var http = new DefaultHttpContext(); + if (token is not null) + http.Request.Headers.Cookie = $"{SessionCookie.Name}={token}"; + + return http; + } + + private static void AssertCookieCleared(DefaultHttpContext http) + { + var setCookie = http.Response.Headers.SetCookie.ToString().ToLowerInvariant(); + Assert.That(setCookie, Does.StartWith(SessionCookie.Name.ToLowerInvariant() + "=;")); + Assert.That(setCookie, Does.Contain("expires=thu, 01 jan 1970")); + Assert.That(setCookie, + Does.Contain("path=/").And.Contain("httponly").And.Contain("secure").And.Contain("samesite=strict")); + } +} \ No newline at end of file diff --git a/backend/Platform.Tests/Identity/LogoutTests.cs b/backend/Platform.Tests/Identity/LogoutTests.cs index 67c84a6..97d614a 100644 --- a/backend/Platform.Tests/Identity/LogoutTests.cs +++ b/backend/Platform.Tests/Identity/LogoutTests.cs @@ -1,6 +1,3 @@ -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using Platform.Api.Controllers.Identity; using Platform.Api.Services.Identity; using Platform.Identity.Domain; using Platform.Identity.Services; @@ -14,14 +11,14 @@ public class LogoutTests private FakeStore _store = null!; private RecordingAudit _audit = null!; - private LogoutAccount _logout = null!; + private LogoutService _logout = null!; [SetUp] public void SetUp() { _store = new FakeStore(); _audit = new RecordingAudit(); - _logout = new LogoutAccount(_store, _audit, TimeProvider.System); + _logout = new LogoutService(_store, _audit, TimeProvider.System); } [Test] @@ -91,98 +88,25 @@ public async Task AuditEvent_NeverContainsTheToken() await _logout.ExecuteAsync(Token, "corr-7"); var e = _audit.Events.Single(); - Assert.That(string.Join('|', e.Action, e.Target, e.Outcome, e.CorrelationId, e.Detail), Does.Not.Contain(Token)); - } - - [Test] - public async Task Controller_RevokesCookieSession_AndClearsTheCookieWithItsAttributes() - { - var session = _store.Add(Token, Guid.NewGuid()); - var controller = ControllerWithHttpContext(out var http, Token); - - var response = await controller.Logout(); - - Assert.That(response, Is.InstanceOf()); - Assert.That(session.RevokedAt, Is.Not.Null); - Assert.That(http.Response.Headers.CacheControl.ToString(), Is.EqualTo("no-store")); - AssertCookieCleared(http); - } - - [Test] - public async Task Controller_ClientDisconnect_DoesNotCancelTheRevocation() - { - var session = _store.Add(Token, Guid.NewGuid()); - var controller = ControllerWithHttpContext(out var http, Token); - using var aborted = new CancellationTokenSource(); - aborted.Cancel(); - http.RequestAborted = aborted.Token; - - await controller.Logout(); - - Assert.That(_store.LastToken.CanBeCanceled, Is.False); - Assert.That(session.RevokedAt, Is.Not.Null); - } - - [TestCase(null)] - [TestCase("unknown-token")] - public async Task Controller_WithoutValidSession_AnswersTheSame(string? token) - { - var controller = ControllerWithHttpContext(out var http, token); - - var response = await controller.Logout(); - - Assert.That(response, Is.InstanceOf()); - AssertCookieCleared(http); - } - - [Test] - public void Controller_WhenRevokingFails_KeepsTheCookie() - { - _store.Failure = new InvalidOperationException("database is down"); - var controller = ControllerWithHttpContext(out var http, Token); - - Assert.ThrowsAsync(() => controller.Logout()); - - Assert.That(http.Response.Headers.ContainsKey("Set-Cookie"), Is.False); - } - - private static void AssertCookieCleared(DefaultHttpContext http) - { - var setCookie = http.Response.Headers.SetCookie.ToString().ToLowerInvariant(); - Assert.That(setCookie, Does.StartWith(SessionCookie.Name.ToLowerInvariant() + "=;")); - Assert.That(setCookie, Does.Contain("expires=thu, 01 jan 1970")); - Assert.That(setCookie, Does.Contain("path=/").And.Contain("httponly").And.Contain("secure").And.Contain("samesite=strict")); - } - - private LogoutController ControllerWithHttpContext(out DefaultHttpContext http, string? token) - { - http = new DefaultHttpContext(); - if (token is not null) - http.Request.Headers.Cookie = $"{SessionCookie.Name}={token}"; - - return new LogoutController(_logout) { ControllerContext = new ControllerContext { HttpContext = http } }; + Assert.That(string.Join('|', e.Action, e.Target, e.Outcome, e.CorrelationId, e.Detail), + Does.Not.Contain(Token)); } private sealed class FakeStore : ISessionStore { private readonly Dictionary _sessions = []; - public Exception? Failure { get; set; } - public CancellationToken LastToken { get; private set; } - public Session Add(string token, Guid userId, AuthLevel level = AuthLevel.Active) { var now = DateTimeOffset.UtcNow; - var session = new Session(Guid.NewGuid(), userId, new byte[32], Guid.NewGuid(), level, now, now.AddHours(8)); + var session = new Session(Guid.NewGuid(), userId, new byte[32], Guid.NewGuid(), level, now, + now.AddHours(8)); _sessions[token] = session; return session; } public Task RevokeByTokenAsync(string rawToken, CancellationToken ct) { - if (Failure is not null) throw Failure; - - LastToken = ct; if (!_sessions.TryGetValue(rawToken, out var session) || session.RevokedAt is not null) return Task.FromResult(null); @@ -193,9 +117,11 @@ public Session Add(string token, Guid userId, AuthLevel level = AuthLevel.Active public Task<(string RawToken, Session Session)> CreateAsync( Guid userId, AuthLevel level, TimeSpan lifetime, CancellationToken ct) => throw new NotSupportedException(); - public Task FindByTokenAsync(string rawToken, CancellationToken ct) => throw new NotSupportedException(); + public Task FindByTokenAsync(string rawToken, CancellationToken ct) => + throw new NotSupportedException(); - public Task TouchAsync(Session session, DateTimeOffset now, CancellationToken ct) => throw new NotSupportedException(); + public Task TouchAsync(Session session, DateTimeOffset now, CancellationToken ct) => + throw new NotSupportedException(); } private sealed class RecordingAudit : IAuditWriter @@ -203,4 +129,4 @@ private sealed class RecordingAudit : IAuditWriter public List Events { get; } = []; public void Write(AuditEvent e) => Events.Add(e); } -} +} \ No newline at end of file diff --git a/backend/Platform.Tests/Identity/MeControllerTests.cs b/backend/Platform.Tests/Identity/MeControllerTests.cs deleted file mode 100644 index e04042b..0000000 --- a/backend/Platform.Tests/Identity/MeControllerTests.cs +++ /dev/null @@ -1,66 +0,0 @@ -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using Platform.Api.Controllers.Identity; -using Platform.Api.Domain.Authentication; -using Platform.Api.Services.Identity; -using Moq; - -namespace Platform.Tests.Identity; - -[TestFixture] -[Category("US-06")] -public class MeControllerTests -{ - private Mock _identity = null!; - private MeController _controller = null!; - - [SetUp] - public void SetUp() - { - _identity = new Mock(); - _controller = new MeController(_identity.Object) - { - ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() } - }; - } - - [Test] - public void NoCaller_Returns401() - { - CallerIs(null); - - var result = _controller.Me(); - - Assert.That(result, Is.InstanceOf()); - } - - [Test] - public void MfaPendingCaller_Returns401() - { - CallerIs(new CallerIdentity(false, Guid.NewGuid(), AuthenticationLevel.MfaPending, Guid.NewGuid())); - - var result = _controller.Me(); - - Assert.That(result, Is.InstanceOf()); - } - - [Test] - public void ActiveCaller_ReturnsOnlyUserIdAndLevel() - { - var userId = Guid.NewGuid(); - CallerIs(new CallerIdentity(true, userId, AuthenticationLevel.Active, Guid.NewGuid())); - - var result = _controller.Me() as OkObjectResult; - - Assert.That(result, Is.Not.Null); - var body = result!.Value!; - var names = body.GetType().GetProperties().Select(p => p.Name).ToArray(); - Assert.That(names, Is.EquivalentTo(new[] { "userId", "level" })); - Assert.That(body.GetType().GetProperty("userId")!.GetValue(body), Is.EqualTo(userId)); - Assert.That(body.GetType().GetProperty("level")!.GetValue(body), Is.EqualTo("Active")); - Assert.That(_controller.Response.Headers.CacheControl.ToString(), Is.EqualTo("no-store")); - } - - private void CallerIs(CallerIdentity? caller) => - _identity.Setup(i => i.Current).Returns(caller); -}