test: raise unit test coverage to 60 percent

This commit is contained in:
agile.zhou
2026-08-26 01:47:44 +08:00
parent 7f91ff0c6d
commit 4f89e6daef
8 changed files with 1376 additions and 0 deletions
+15
View File
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<RunSettings>
<DataCollectionRunSettings>
<DataCollectors>
<DataCollector friendlyName="XPlat code coverage">
<Configuration>
<Format>cobertura</Format>
<Exclude>[*.Tests]*,[Agile.Config.Protocol]*,[AgileConfig.Server.Event]*,[AgileConfig.Server.IService]*</Exclude>
<ExcludeByAttribute>System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverageAttribute,GeneratedCodeAttribute</ExcludeByAttribute>
<ExcludeByFile>**/obj/**/*.cs,**/AgileConfig.Server.Data.Entity/App.cs,**/AgileConfig.Server.Data.Entity/AppInheritanced.cs,**/AgileConfig.Server.Data.Entity/Config.cs,**/AgileConfig.Server.Data.Entity/Function.cs,**/AgileConfig.Server.Data.Entity/PublishDetail.cs,**/AgileConfig.Server.Data.Entity/PublishTimeline.cs,**/AgileConfig.Server.Data.Entity/Role.cs,**/AgileConfig.Server.Data.Entity/RoleFunction.cs,**/AgileConfig.Server.Data.Entity/ServerNode.cs,**/AgileConfig.Server.Data.Entity/ServiceInfo.cs,**/AgileConfig.Server.Data.Entity/Setting.cs,**/AgileConfig.Server.Data.Entity/User.cs,**/AgileConfig.Server.Data.Entity/UserAppAuth.cs,**/AgileConfig.Server.Data.Entity/UserRole.cs,**/AppExportVM.cs,**/ApiPublishTimelineVM.cs,**/ApiServiceInfoVM.cs,**/HeartbeatParam.cs,**/HeartbeatResultVM.cs,**/RegisterResultVM.cs,**/ApplicationModels.cs,**/ConfigurationModels.cs,**/NodeModels.cs,**/ReleaseModels.cs,**/ApplicationManagement.cs,**/ConfigurationManagementContracts.cs,**/NodeManagementModels.cs,**/ReleaseManagementContracts.cs,**/ServiceInstanceManagementModels.cs,**/DbConfigInfo.cs,**/OidcSetting.cs,**/UIFileBag.cs,**/IWebsocketCollection.cs,**/Appsettings.cs,**/SystemSettings.cs,**/Global.cs</ExcludeByFile>
</Configuration>
</DataCollector>
</DataCollectors>
</DataCollectionRunSettings>
</RunSettings>
@@ -18,6 +18,7 @@
<ItemGroup>
<ProjectReference Include="..\..\src\AgileConfig.Server.Common\AgileConfig.Server.Common.csproj" />
<ProjectReference Include="..\..\src\AgileConfig.Server.OIDC\AgileConfig.Server.OIDC.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,259 @@
using System;
using System.ComponentModel;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Security.Claims;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using AgileConfig.Server.Common.EventBus;
using AgileConfig.Server.Common.RestClient;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace AgileConfig.Server.Common.Tests;
[TestClass]
[DoNotParallelize]
public class CommonInfrastructureTests
{
[TestMethod]
public void RetryHelpers_RetryUntilSuccessfulAndRethrowFinalFailure()
{
var attempts = 0;
var result = FunctionUtil.TRY(() =>
{
attempts++;
if (attempts < 3) throw new InvalidOperationException();
return "done";
}, 3);
Assert.AreEqual("done", result);
Assert.AreEqual(3, attempts);
Assert.ThrowsExactly<InvalidOperationException>(() => FunctionUtil.TRY<int>(() => throw new InvalidOperationException(), 2));
attempts = 0;
FunctionUtil.TRY(() =>
{
attempts++;
if (attempts == 1) throw new InvalidOperationException();
}, 2);
Assert.AreEqual(2, attempts);
}
[TestMethod]
public async Task AsyncRetryHelpers_RetryRethrowAndSwallowAsSpecified()
{
var attempts = 0;
var result = await FunctionUtil.TRYAsync(async () =>
{
attempts++;
await Task.Yield();
if (attempts == 1) throw new InvalidOperationException();
return 42;
}, 2);
Assert.AreEqual(42, result);
Assert.AreEqual(2, attempts);
await Assert.ThrowsExactlyAsync<InvalidOperationException>(async () =>
await FunctionUtil.TRYAsync<int>(() => Task.FromException<int>(new InvalidOperationException()), 1));
var swallowed = await FunctionUtil.EATAsync<int>(() => Task.FromException<int>(new InvalidOperationException()), 2);
Assert.AreEqual(0, swallowed);
}
[TestMethod]
public async Task RetryHelpers_RejectNullDelegates()
{
Assert.ThrowsExactly<ArgumentNullException>(() => FunctionUtil.TRY<string>(null, 1));
Assert.ThrowsExactly<ArgumentNullException>(() => FunctionUtil.TRY(null, 1));
await Assert.ThrowsExactlyAsync<ArgumentNullException>(() => FunctionUtil.TRYAsync<string>(null, 1));
await Assert.ThrowsExactlyAsync<ArgumentNullException>(() => FunctionUtil.EATAsync<string>(null, 1));
}
[TestMethod]
public void BasicAuthorizationExtensions_ParseValidCredentialsAndRejectInvalidHeaders()
{
var context = new DefaultHttpContext();
const string encoded = "YXBwOnNlY3JldDpleHRyYQ==";
context.Request.Headers.Authorization = "Basic " + encoded;
Assert.AreEqual(("app", "secret"), context.Request.GetUserNamePasswordFromBasicAuthorization());
Assert.AreEqual(("app", "secret"), Encrypt.UnboxBasicAuth(context.Request));
context.Request.Headers.Authorization = "Bearer token";
Assert.AreEqual(("", ""), context.Request.GetUserNamePasswordFromBasicAuthorization());
Assert.AreEqual(("", ""), Encrypt.UnboxBasicAuth(context.Request));
context.Request.Headers.Authorization = "Basic not-base64";
Assert.AreEqual(("", ""), context.Request.GetUserNamePasswordFromBasicAuthorization());
Assert.AreEqual(("", ""), Encrypt.UnboxBasicAuth(context.Request));
}
[TestMethod]
public void HttpAndEnvironmentExtensions_ReadClaimsAndQueryEnvironment()
{
var context = new DefaultHttpContext();
context.Request.QueryString = new QueryString("?env=STAGING");
context.User = new ClaimsPrincipal(new ClaimsIdentity(
[
new Claim("username", "alice"),
new Claim("id", "user-1")
]));
var accessor = new HttpContextAccessor { HttpContext = context };
var envAccessor = new EnvAccessor(accessor);
Assert.AreEqual("STAGING", envAccessor.Env);
Assert.AreEqual("alice", context.GetUserNameFromClaim());
Assert.AreEqual("user-1", context.GetUserIdFromClaim());
var services = new ServiceCollection();
Assert.AreSame(services, services.AddEnvAccessor());
Assert.IsTrue(services.Any(x => x.ServiceType == typeof(IEnvAccessor) && x.Lifetime == ServiceLifetime.Singleton));
}
[TestMethod]
public void EnumExtensions_UseDescriptionWhenPresent()
{
Assert.AreEqual("described", DescribedValue.Described.ToDesc());
Assert.AreEqual(nameof(DescribedValue.Plain), DescribedValue.Plain.ToDesc());
}
[TestMethod]
public async Task TinyEventBus_DispatchesRegisteredHandler()
{
var services = new ServiceCollection();
services.AddLogging();
var bus = new TinyEventBus(services);
var completion = new TaskCompletionSource<TestEvent>(TaskCreationOptions.RunContinuationsAsynchronously);
TestEventHandler.Completion = completion;
bus.Register<TestEventHandler>();
var evt = new TestEvent();
bus.Fire(evt);
var completed = await Task.WhenAny(completion.Task, Task.Delay(TimeSpan.FromSeconds(2)));
Assert.AreSame(completion.Task, completed);
Assert.AreSame(evt, await completion.Task);
}
[TestMethod]
public async Task TinyEventBus_RegistersAdditionalHandlersAndIsolatesHandlerFailures()
{
var services = new ServiceCollection();
services.AddLogging();
var bus = new TinyEventBus(services);
var completion = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
ThrowingTestEventHandler.Completion = completion;
bus.Register<ThrowingTestEventHandler>();
bus.Register<ThrowingTestEventHandler>();
bus.Fire(new ThrowingTestEvent());
var completed = await Task.WhenAny(completion.Task, Task.Delay(TimeSpan.FromSeconds(2)));
Assert.AreSame(completion.Task, completed);
Assert.IsTrue(await completion.Task);
await Task.Delay(50);
}
[TestMethod]
public async Task DefaultRestClient_SendsHeadersAndSerializesResponses()
{
var handler = new RecordingHandler(_ => new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("{\"value\":\"ok\"}", Encoding.UTF8, "application/json")
});
var client = new HttpClient(handler) { BaseAddress = new Uri("https://example.test") };
var sut = new DefaultRestClient(new StaticHttpClientFactory(client));
var get = await sut.GetAsync<TestPayload>("/items", new() { ["X-Request-Id"] = "abc" });
Assert.AreEqual("ok", get.Value);
Assert.AreEqual(HttpMethod.Get, handler.Request.Method);
Assert.AreEqual("abc", handler.Request.Headers.GetValues("X-Request-Id").Single());
var post = await sut.PostAsync<TestPayload>("/items", new { name = "agile" });
Assert.AreEqual("ok", post.Value);
Assert.AreEqual(HttpMethod.Post, handler.Request.Method);
StringAssert.Contains(handler.Body, "agile");
}
[TestMethod]
public async Task ExceptionMiddleware_SetsResponseThenRethrows()
{
var loggerFactory = LoggerFactory.Create(_ => { });
var middleware = new ExceptionHandlerMiddleware(_ => throw new InvalidOperationException("failure"), loggerFactory);
var context = new DefaultHttpContext();
context.Response.Body = new MemoryStream();
await Assert.ThrowsExactlyAsync<InvalidOperationException>(() => middleware.Invoke(context));
Assert.AreEqual(StatusCodes.Status500InternalServerError, context.Response.StatusCode);
Assert.AreEqual("text/html", context.Response.ContentType);
context.Response.Body.Position = 0;
using var reader = new StreamReader(context.Response.Body, Encoding.UTF8, leaveOpen: true);
StringAssert.Contains(await reader.ReadToEndAsync(), "500 InternalServerError");
}
private enum DescribedValue
{
[System.ComponentModel.Description("described")]
Described,
Plain
}
private sealed class TestEvent : IEvent;
private sealed class TestEventHandler : IEventHandler<TestEvent>
{
public static TaskCompletionSource<TestEvent> Completion { get; set; }
public Task Handle(IEvent evt)
{
Completion.TrySetResult((TestEvent)evt);
return Task.CompletedTask;
}
}
private sealed class ThrowingTestEvent : IEvent;
private sealed class ThrowingTestEventHandler : IEventHandler<ThrowingTestEvent>
{
public static TaskCompletionSource<bool> Completion { get; set; }
public async Task Handle(IEvent evt)
{
await Task.Yield();
Completion.TrySetResult(true);
throw new InvalidOperationException("handler failure");
}
}
private sealed class TestPayload
{
public string Value { get; set; }
}
private sealed class StaticHttpClientFactory(HttpClient client) : IHttpClientFactory
{
public HttpClient CreateClient(string name) => client;
}
private sealed class RecordingHandler(Func<HttpRequestMessage, HttpResponseMessage> responseFactory) : HttpMessageHandler
{
public HttpRequestMessage Request { get; private set; }
public string Body { get; private set; }
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
Request = request;
Body = request.Content == null ? null : await request.Content.ReadAsStringAsync(cancellationToken);
return responseFactory(request);
}
}
}
@@ -0,0 +1,126 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using AgileConfig.Server.OIDC;
using AgileConfig.Server.OIDC.SettingProvider;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace AgileConfig.Server.Common.Tests;
[TestClass]
public class OidcClientTests
{
[TestMethod]
public async Task Validate_UsesConfiguredAuthenticationMethodsAndReadsIdToken()
{
var setting = CreateSetting("client_secret_basic");
var basicHandler = new RecordingHandler();
var client = new OidcClient(new StaticSettingProvider(setting), new StaticHttpClientFactory(new HttpClient(basicHandler)));
var token = await client.Validate("code-1");
Assert.AreEqual("token-value", token.IdToken);
Assert.AreEqual("Basic Y2xpZW50OnNlY3JldA==", basicHandler.Request.Headers.Authorization.ToString());
StringAssert.Contains(basicHandler.Body, "code=code-1");
Assert.DoesNotContain("client_secret", basicHandler.Body);
setting.TokenEndpointAuthMethod = "client_secret_post";
var postHandler = new RecordingHandler();
client = new OidcClient(new StaticSettingProvider(setting), new StaticHttpClientFactory(new HttpClient(postHandler)));
await client.Validate("code-2");
Assert.IsNull(postHandler.Request.Headers.Authorization);
StringAssert.Contains(postHandler.Body, "client_id=client");
StringAssert.Contains(postHandler.Body, "client_secret=secret");
setting.TokenEndpointAuthMethod = "none";
var noneHandler = new RecordingHandler();
client = new OidcClient(new StaticSettingProvider(setting), new StaticHttpClientFactory(new HttpClient(noneHandler)));
await client.Validate("code-3");
Assert.IsNull(noneHandler.Request.Headers.Authorization);
Assert.DoesNotContain("client_id", noneHandler.Body);
}
[TestMethod]
public void GetAuthorizeUrl_UsesConfiguredClientParameters()
{
var setting = CreateSetting("none");
var client = new OidcClient(new StaticSettingProvider(setting), new StaticHttpClientFactory(new HttpClient(new RecordingHandler())));
var authorizeUrl = client.GetAuthorizeUrl();
StringAssert.StartsWith(authorizeUrl, "https://issuer.test/authorize?");
StringAssert.Contains(authorizeUrl, "client_id=client");
StringAssert.Contains(authorizeUrl, "redirect_uri=https://app.test/callback");
}
[TestMethod]
public async Task Validate_RejectsEmptyAndIncompleteResponses()
{
var setting = CreateSetting("none");
var emptyClient = new OidcClient(new StaticSettingProvider(setting), new StaticHttpClientFactory(new HttpClient(new RecordingHandler(""))));
await Assert.ThrowsExactlyAsync<Exception>(() => emptyClient.Validate("code"));
var missingTokenClient = new OidcClient(new StaticSettingProvider(setting), new StaticHttpClientFactory(new HttpClient(new RecordingHandler("{}"))));
await Assert.ThrowsExactlyAsync<Exception>(() => missingTokenClient.Validate("code"));
}
[TestMethod]
public void SettingsProviderAndServiceRegistration_ReadConfiguration()
{
Global.Config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string>
{
["SSO:OIDC:clientId"] = "configured-client",
["SSO:OIDC:clientSecret"] = "configured-secret",
["SSO:OIDC:redirectUri"] = "https://configured/callback",
["SSO:OIDC:tokenEndpoint"] = "https://configured/token",
["SSO:OIDC:authorizationEndpoint"] = "https://configured/authorize",
["SSO:OIDC:userIdClaim"] = "id",
["SSO:OIDC:userNameClaim"] = "username",
["SSO:OIDC:scope"] = "openid profile",
["SSO:OIDC:tokenEndpointAuthMethod"] = "none"
}).Build();
using var loggerFactory = LoggerFactory.Create(_ => { });
var provider = new ConfigfileOidcSettingProvider(loggerFactory.CreateLogger<ConfigfileOidcSettingProvider>());
Assert.AreEqual("configured-client", provider.GetSetting().ClientId);
var services = new ServiceCollection();
services.AddOIDC();
Assert.IsTrue(services.Any(x => x.ServiceType == typeof(IOidcClient) && x.Lifetime == ServiceLifetime.Singleton));
Assert.IsTrue(services.Any(x => x.ServiceType == typeof(IOidcSettingProvider) && x.Lifetime == ServiceLifetime.Singleton));
}
private static OidcSetting CreateSetting(string method) => new(
"client", "secret", "https://app.test/callback", "https://issuer.test/token", "https://issuer.test/authorize",
"sub", "name", "openid profile", method);
private sealed class StaticSettingProvider(OidcSetting setting) : IOidcSettingProvider
{
public OidcSetting GetSetting() => setting;
}
private sealed class StaticHttpClientFactory(HttpClient client) : IHttpClientFactory
{
public HttpClient CreateClient(string name) => client;
}
private sealed class RecordingHandler(string response = "{\"id_token\":\"token-value\"}") : HttpMessageHandler
{
public HttpRequestMessage Request { get; private set; }
public string Body { get; private set; }
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
Request = request;
Body = await request.Content.ReadAsStringAsync(cancellationToken);
return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(response) };
}
}
}
@@ -0,0 +1,357 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks;
using AgileConfig.Server.Apisite;
using AgileConfig.Server.Apisite.Controllers;
using AgileConfig.Server.Apisite.Models;
using AgileConfig.Server.Common;
using AgileConfig.Server.Common.EventBus;
using AgileConfig.Server.Common.Resources;
using AgileConfig.Server.Data.Entity;
using AgileConfig.Server.Event;
using AgileConfig.Server.IService;
using AgileConfig.Server.OIDC;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Moq;
namespace ApiSiteTests;
[TestClass]
public sealed class AdminControllerCoverageTests
{
[TestMethod]
public async Task Login_HandlesEmptyInvalidAndSuccessfulCredentials()
{
SetConfig();
var userService = new Mock<IUserService>();
var permissionService = new Mock<IPermissionService>();
var jwtService = new Mock<IJwtService>();
var eventBus = new Mock<ITinyEventBus>();
var controller = CreateController(userService, permissionService, jwtService, eventBus);
var empty = AsJson(await controller.Login4AntdPro(new LoginVM { userName = "admin", password = "" }));
Assert.AreEqual("error", Read<string>(empty, "status"));
Assert.AreEqual(Messages.PasswordCannotBeEmpty, Read<string>(empty, "message"));
userService.Verify(x => x.ValidateUserPassword(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
userService.Setup(x => x.ValidateUserPassword("admin", "bad")).ReturnsAsync(false);
var invalid = AsJson(await controller.Login4AntdPro(new LoginVM { userName = "admin", password = "bad" }));
Assert.AreEqual("error", Read<string>(invalid, "status"));
Assert.AreEqual(Messages.PasswordError, Read<string>(invalid, "message"));
var user = new User { Id = "admin-id", UserName = "admin", Status = UserStatus.Normal };
userService.Setup(x => x.ValidateUserPassword("admin", "good")).ReturnsAsync(true);
userService.Setup(x => x.GetUsersByNameAsync("admin")).ReturnsAsync(new List<User> { user });
userService.Setup(x => x.GetUserRolesAsync("admin-id")).ReturnsAsync(new List<Role>
{
new() { Id = SystemRoleConstants.AdminId, Name = "Administrator" },
new() { Id = SystemRoleConstants.OperatorId, Name = "Operator" }
});
permissionService.Setup(x => x.GetUserPermission("admin-id")).ReturnsAsync(new List<string> { Functions.User_Read });
jwtService.Setup(x => x.GetToken("admin-id", "admin", true)).Returns("jwt-token");
var success = AsJson(await controller.Login4AntdPro(new LoginVM { userName = "admin", password = "good" }));
Assert.AreEqual("ok", Read<string>(success, "status"));
Assert.AreEqual("jwt-token", Read<string>(success, "token"));
Assert.AreEqual("Bearer", Read<string>(success, "type"));
CollectionAssert.AreEqual(new[] { "Administrator", "Operator" },
((IEnumerable<string>)Read<object>(success, "currentAuthority")).ToArray());
CollectionAssert.AreEqual(new[] { Functions.User_Read },
((IEnumerable<string>)Read<object>(success, "currentFunctions")).ToArray());
eventBus.Verify(x => x.Fire(It.Is<LoginEvent>(evt => evt.UserName == "admin")), Times.Once);
}
[TestMethod]
public async Task OidcLogin_RejectsDisabledAndInvalidTokens()
{
var oidcClient = new Mock<IOidcClient>();
var controller = CreateController(new Mock<IUserService>(), new Mock<IPermissionService>(),
new Mock<IJwtService>(), new Mock<ITinyEventBus>(), oidcClient);
SetConfig(ssoEnabled: false);
var disabled = await controller.OidcLoginByCode("code");
Assert.IsInstanceOfType(disabled, typeof(BadRequestObjectResult));
Assert.AreEqual("SSO not enabled", ((BadRequestObjectResult)disabled).Value);
oidcClient.Verify(x => x.Validate(It.IsAny<string>()), Times.Never);
SetConfig(ssoEnabled: true);
oidcClient.Setup(x => x.Validate("empty")).ReturnsAsync(("", "access"));
var empty = AsJson(await controller.OidcLoginByCode("empty"));
Assert.IsFalse(Read<bool>(empty, "success"));
Assert.AreEqual("Code validate failed", Read<string>(empty, "message"));
oidcClient.Setup(x => x.Validate("invalid")).ReturnsAsync(("token", "access"));
oidcClient.Setup(x => x.UnboxIdToken("token")).Returns(("", "user"));
var invalidId = AsJson(await controller.OidcLoginByCode("invalid"));
Assert.IsFalse(Read<bool>(invalidId, "success"));
Assert.AreEqual("IdToken invalid", Read<string>(invalidId, "message"));
oidcClient.Setup(x => x.UnboxIdToken("token")).Returns(("id", ""));
var invalidName = AsJson(await controller.OidcLoginByCode("invalid"));
Assert.IsFalse(Read<bool>(invalidName, "success"));
Assert.AreEqual("IdToken invalid", Read<string>(invalidName, "message"));
}
[TestMethod]
public async Task OidcLogin_CreatesFirstUserAndRejectsDeletedUser()
{
SetConfig(ssoEnabled: true);
var oidcClient = new Mock<IOidcClient>();
var userService = new Mock<IUserService>();
var permissionService = new Mock<IPermissionService>();
var jwtService = new Mock<IJwtService>();
var eventBus = new Mock<ITinyEventBus>();
var controller = CreateController(userService, permissionService, jwtService, eventBus, oidcClient);
oidcClient.Setup(x => x.Validate("first")).ReturnsAsync(("id-token", "access"));
oidcClient.Setup(x => x.UnboxIdToken("id-token")).Returns(("oidc-id", "oidc-user"));
User added = null;
userService.SetupSequence(x => x.GetUsersByNameAsync("oidc-user"))
.ReturnsAsync(new List<User>())
.ReturnsAsync(() => new List<User> { added });
userService.Setup(x => x.AddAsync(It.IsAny<User>()))
.Callback<User>(user => added = user)
.ReturnsAsync(true);
userService.Setup(x => x.UpdateUserRolesAsync("oidc-id", It.IsAny<List<string>>())).ReturnsAsync(true);
userService.Setup(x => x.GetUserRolesAsync("oidc-id")).ReturnsAsync(new List<Role>
{
new() { Id = SystemRoleConstants.OperatorId, Name = "Operator" }
});
permissionService.Setup(x => x.GetUserPermission("oidc-id")).ReturnsAsync(new List<string>());
jwtService.Setup(x => x.GetToken("oidc-id", "oidc-user", false)).Returns("oidc-jwt");
var firstLogin = AsJson(await controller.OidcLoginByCode("first"));
Assert.AreEqual("ok", Read<string>(firstLogin, "status"));
Assert.AreEqual("oidc-jwt", Read<string>(firstLogin, "token"));
Assert.IsNotNull(added);
Assert.AreEqual("oidc-id", added.Id);
Assert.AreEqual(UserSource.SSO, added.Source);
Assert.AreEqual(UserStatus.Normal, added.Status);
userService.Verify(x => x.UpdateUserRolesAsync("oidc-id",
It.Is<List<string>>(roles => roles.SequenceEqual(new[] { SystemRoleConstants.OperatorId }))), Times.Once);
eventBus.Verify(x => x.Fire(It.Is<LoginEvent>(evt => evt.UserName == "oidc-user")), Times.Once);
var deleted = new User { Id = "deleted-id", UserName = "deleted", Status = UserStatus.Deleted };
oidcClient.Setup(x => x.Validate("deleted")).ReturnsAsync(("deleted-token", "access"));
oidcClient.Setup(x => x.UnboxIdToken("deleted-token")).Returns(("deleted-id", "deleted"));
userService.Setup(x => x.GetUsersByNameAsync("deleted")).ReturnsAsync(new List<User> { deleted });
var deletedResult = AsJson(await controller.OidcLoginByCode("deleted"));
Assert.AreEqual("error", Read<string>(deletedResult, "status"));
Assert.AreEqual(Messages.UserDeleted, Read<string>(deletedResult, "message"));
}
[TestMethod]
public async Task PasswordInitialization_ReportsStateAndValidationOutcomes()
{
SetConfig();
var system = new Mock<ISystemInitializationService>();
var eventBus = new Mock<ITinyEventBus>();
var controller = CreateController(new Mock<IUserService>(), new Mock<IPermissionService>(),
new Mock<IJwtService>(), eventBus, system: system);
system.SetupSequence(x => x.HasSa()).Returns(false).Returns(true);
var state = AsJson(controller.PasswordInited());
Assert.IsTrue(Read<bool>(state, "success"));
Assert.IsFalse(Read<bool>(state, "data"));
var empty = AsJson(controller.InitPassword(new InitPasswordVM { password = "", confirmPassword = "" }));
Assert.IsFalse(Read<bool>(empty, "success"));
Assert.AreEqual(Messages.PasswordCannotBeEmpty, Read<string>(empty, "message"));
var tooLong = AsJson(controller.InitPassword(new InitPasswordVM
{
password = new string('p', 51),
confirmPassword = new string('p', 51)
}));
Assert.IsFalse(Read<bool>(tooLong, "success"));
Assert.AreEqual(Messages.PasswordMaxLength50, Read<string>(tooLong, "message"));
var mismatch = AsJson(controller.InitPassword(new InitPasswordVM { password = "one", confirmPassword = "two" }));
Assert.IsFalse(Read<bool>(mismatch, "success"));
Assert.AreEqual(Messages.PasswordMismatch, Read<string>(mismatch, "message"));
var alreadySet = AsJson(controller.InitPassword(new InitPasswordVM { password = "one", confirmPassword = "one" }));
Assert.IsFalse(Read<bool>(alreadySet, "success"));
Assert.AreEqual(Messages.PasswordAlreadySet, Read<string>(alreadySet, "message"));
system.Setup(x => x.HasSa()).Returns(false);
system.SetupSequence(x => x.TryInitSaPassword("good-password")).Returns(false).Returns(true);
var failed = AsJson(controller.InitPassword(new InitPasswordVM
{
password = "good-password",
confirmPassword = "good-password"
}));
Assert.IsFalse(Read<bool>(failed, "success"));
Assert.AreEqual(Messages.InitPasswordFailed, Read<string>(failed, "message"));
var succeeded = AsJson(controller.InitPassword(new InitPasswordVM
{
password = "good-password",
confirmPassword = "good-password"
}));
Assert.IsTrue(Read<bool>(succeeded, "success"));
eventBus.Verify(x => x.Fire(It.IsAny<InitSaPasswordSuccessful>()), Times.Once);
}
[TestMethod]
public async Task ChangePassword_HandlesPreviewValidationAndPersistenceOutcomes()
{
var userService = new Mock<IUserService>();
var eventBus = new Mock<ITinyEventBus>();
var system = new Mock<ISystemInitializationService>();
var controller = CreateController(userService, new Mock<IPermissionService>(), new Mock<IJwtService>(),
eventBus, system: system);
SetConfig(previewMode: true);
var preview = AsJson(await controller.ChangePassword(new ChangePasswordVM
{
oldPassword = "old",
password = "new",
confirmPassword = "new"
}));
Assert.IsFalse(Read<bool>(preview, "success"));
Assert.AreEqual(Messages.DemoModeNoPasswordChange, Read<string>(preview, "message"));
userService.Verify(x => x.ValidateUserPassword(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
SetConfig();
var emptyOld = AsJson(await controller.ChangePassword(new ChangePasswordVM { oldPassword = "" }));
Assert.AreEqual("err_resetpassword_01", Read<string>(emptyOld, "err_code"));
userService.Setup(x => x.ValidateUserPassword("current-user", "bad-old")).ReturnsAsync(false);
var badOld = AsJson(await controller.ChangePassword(new ChangePasswordVM { oldPassword = "bad-old" }));
Assert.AreEqual(Messages.OriginalPasswordError, Read<string>(badOld, "message"));
Assert.AreEqual("err_resetpassword_02", Read<string>(badOld, "err_code"));
userService.Setup(x => x.ValidateUserPassword("current-user", "old")).ReturnsAsync(true);
var emptyNew = AsJson(await controller.ChangePassword(new ChangePasswordVM
{
oldPassword = "old",
password = "",
confirmPassword = ""
}));
Assert.AreEqual(Messages.NewPasswordCannotBeEmpty, Read<string>(emptyNew, "message"));
var tooLong = AsJson(await controller.ChangePassword(new ChangePasswordVM
{
oldPassword = "old",
password = new string('n', 51),
confirmPassword = new string('n', 51)
}));
Assert.AreEqual(Messages.NewPasswordMaxLength50, Read<string>(tooLong, "message"));
var mismatch = AsJson(await controller.ChangePassword(new ChangePasswordVM
{
oldPassword = "old",
password = "new-one",
confirmPassword = "new-two"
}));
Assert.AreEqual(Messages.NewPasswordMismatch, Read<string>(mismatch, "message"));
userService.Setup(x => x.GetUsersByNameAsync("current-user"))
.ReturnsAsync(new List<User> { new() { Id = "deleted", UserName = "current-user", Status = UserStatus.Deleted } });
var missing = AsJson(await controller.ChangePassword(new ChangePasswordVM
{
oldPassword = "old",
password = "new-one",
confirmPassword = "new-one"
}));
Assert.AreEqual(Messages.UserNotFound, Read<string>(missing, "message"));
Assert.AreEqual("err_resetpassword_06", Read<string>(missing, "err_code"));
var user = new User { Id = "current-id", UserName = "current-user", Status = UserStatus.Normal, Salt = "salt" };
userService.Setup(x => x.GetUsersByNameAsync("current-user")).ReturnsAsync(new List<User> { user });
userService.SetupSequence(x => x.UpdateAsync(It.IsAny<User>())).ReturnsAsync(false).ReturnsAsync(true);
var failed = AsJson(await controller.ChangePassword(new ChangePasswordVM
{
oldPassword = "old",
password = "new-one",
confirmPassword = "new-one"
}));
Assert.AreEqual(Messages.ChangePasswordFailed, Read<string>(failed, "message"));
var succeeded = AsJson(await controller.ChangePassword(new ChangePasswordVM
{
oldPassword = "old",
password = "new-two",
confirmPassword = "new-two"
}));
Assert.IsTrue(Read<bool>(succeeded, "success"));
Assert.AreEqual(Encrypt.Md5("new-two" + user.Salt), user.Password);
eventBus.Verify(x => x.Fire(It.Is<ChangeUserPasswordSuccessful>(evt => evt.UserName == "current-user")), Times.Once);
}
[TestMethod]
public async Task Logoff_SignsOutAndRedirectsToLogin()
{
SetConfig();
var auth = new Mock<IAuthenticationService>();
auth.Setup(x => x.SignOutAsync(It.IsAny<HttpContext>(), It.IsAny<string>(), It.IsAny<AuthenticationProperties>()))
.Returns(Task.CompletedTask);
var controller = CreateController(new Mock<IUserService>(), new Mock<IPermissionService>(),
new Mock<IJwtService>(), new Mock<ITinyEventBus>());
controller.HttpContext.RequestServices = new ServiceCollection()
.AddSingleton(auth.Object)
.BuildServiceProvider();
var result = await controller.Logoff();
Assert.IsInstanceOfType(result, typeof(RedirectResult));
Assert.AreEqual("Login", ((RedirectResult)result).Url);
auth.Verify(x => x.SignOutAsync(It.IsAny<HttpContext>(), null, null), Times.Once);
}
private static AdminController CreateController(
Mock<IUserService> userService,
Mock<IPermissionService> permissionService,
Mock<IJwtService> jwtService,
Mock<ITinyEventBus> eventBus,
Mock<IOidcClient> oidcClient = null,
Mock<ISystemInitializationService> system = null)
{
var context = new DefaultHttpContext
{
User = new ClaimsPrincipal(new ClaimsIdentity(
new[] { new Claim("username", "current-user") }, "test"))
};
var controller = new AdminController(
new Mock<ISettingService>().Object,
userService.Object,
permissionService.Object,
jwtService.Object,
(oidcClient ?? new Mock<IOidcClient>()).Object,
eventBus.Object,
(system ?? new Mock<ISystemInitializationService>()).Object);
controller.ControllerContext = new ControllerContext { HttpContext = context };
return controller;
}
private static void SetConfig(bool ssoEnabled = false, bool previewMode = false)
{
Global.Config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string>
{
["SSO:enabled"] = ssoEnabled.ToString(),
["preview_mode"] = previewMode.ToString()
}).Build();
}
private static JsonResult AsJson(IActionResult result)
{
Assert.IsInstanceOfType(result, typeof(JsonResult));
return (JsonResult)result;
}
private static T Read<T>(JsonResult result, string propertyName)
{
Assert.IsNotNull(result.Value);
var property = result.Value.GetType().GetProperty(propertyName);
Assert.IsNotNull(property, $"Missing JSON property '{propertyName}'.");
return (T)property.GetValue(result.Value);
}
}
@@ -0,0 +1,191 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using AgileConfig.Server.Application;
using AgileConfig.Server.Application.Configurations;
using AgileConfig.Server.Application.Releases;
using AgileConfig.Server.Apisite.Controllers;
using AgileConfig.Server.Apisite.Models;
using AgileConfig.Server.Common.EventBus;
using AgileConfig.Server.Data.Entity;
using AgileConfig.Server.IService;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Moq;
namespace ApiSiteTests;
[TestClass]
public class LegacyConfigControllerTests
{
[TestMethod]
public async Task Mutations_MapApplicationOutcomesToLegacyResponses()
{
var dependencies = new Dependencies();
var controller = dependencies.CreateController();
var env = new EnvString { Value = "DEV" };
var model = new ConfigVM { Id = "config-1", AppId = "app-1", Key = "key", Value = "value" };
dependencies.ConfigurationManagement
.Setup(x => x.CreateAsync(It.IsAny<CreateConfigurationCommand>()))
.ReturnsAsync(ApplicationResult<Config>.Failure(ApplicationError.Conflict));
Assert.IsNotNull(await controller.Add(model, env) as JsonResult);
dependencies.ConfigurationManagement
.Setup(x => x.CreateAsync(It.IsAny<CreateConfigurationCommand>()))
.ReturnsAsync(ApplicationResult<Config>.Success(new Config { Id = model.Id }));
Assert.IsNotNull(await controller.Add(model, env) as JsonResult);
dependencies.ConfigurationManagement
.Setup(x => x.UpdateAsync(It.IsAny<UpdateConfigurationCommand>()))
.ReturnsAsync(ApplicationResult<Config>.Failure(ApplicationError.NotFound));
dependencies.ConfigService.Setup(x => x.GetAsync(model.Id, env.Value)).ReturnsAsync((Config)null);
Assert.IsNotNull(await controller.Edit(model, env) as JsonResult);
dependencies.ConfigurationManagement
.Setup(x => x.UpdateAsync(It.IsAny<UpdateConfigurationCommand>()))
.ReturnsAsync(ApplicationResult<Config>.Failure(ApplicationError.Conflict));
Assert.IsNotNull(await controller.Edit(model, env) as JsonResult);
dependencies.ConfigurationManagement
.Setup(x => x.UpdateAsync(It.IsAny<UpdateConfigurationCommand>()))
.ReturnsAsync(ApplicationResult<Config>.Success(new Config { Id = model.Id }));
Assert.IsNotNull(await controller.Edit(model, env) as JsonResult);
dependencies.ConfigurationManagement
.Setup(x => x.DeleteAsync(It.IsAny<DeleteConfigurationCommand>()))
.ReturnsAsync(ApplicationResult<Config>.Failure(ApplicationError.NotFound));
Assert.IsNotNull(await controller.Delete(model.Id, env) as JsonResult);
dependencies.ConfigurationManagement
.Setup(x => x.DeleteAsync(It.IsAny<DeleteConfigurationCommand>()))
.ReturnsAsync(ApplicationResult<Config>.Success(new Config { Id = model.Id }));
Assert.IsNotNull(await controller.Delete(model.Id, env) as JsonResult);
dependencies.ReleaseManagement
.Setup(x => x.PublishAsync(It.IsAny<PublishConfigurationsCommand>()))
.ReturnsAsync(ApplicationResult<PublishTimeline>.Success(new PublishTimeline()));
dependencies.ReleaseManagement
.Setup(x => x.RollbackAsync(It.IsAny<RollbackConfigurationCommand>()))
.ReturnsAsync(ApplicationResult<PublishTimeline>.Failure(ApplicationError.OperationFailed));
Assert.IsNotNull(await controller.Publish(new PublishLogVM { AppId = "app-1", Ids = [], Log = "publish" }, env) as JsonResult);
Assert.IsNotNull(await controller.Rollback("timeline-1", env) as JsonResult);
}
[TestMethod]
public async Task SearchAndReadEndpoints_FilterSortAndReturnLegacyPayloads()
{
var dependencies = new Dependencies();
var controller = dependencies.CreateController();
var env = new EnvString { Value = "DEV" };
var configs = new List<Config>
{
new() { Id = "disabled", Key = "disabled", Status = ConfigStatus.Deleted, CreateTime = DateTime.UtcNow },
new() { Id = "old", Key = "old", Group = "z", Status = ConfigStatus.Enabled, OnlineStatus = OnlineStatus.WaitPublish, CreateTime = DateTime.UtcNow.AddMinutes(-1), EditStatus = EditStatus.Add },
new() { Id = "new", Key = "new", Group = "a", Status = ConfigStatus.Enabled, OnlineStatus = OnlineStatus.Online, CreateTime = DateTime.UtcNow, EditStatus = EditStatus.Edit }
};
dependencies.ConfigService.Setup(x => x.Search("app-1", It.IsAny<string>(), It.IsAny<string>(), env.Value))
.ReturnsAsync(configs);
dependencies.ConfigService.Setup(x => x.GetAllConfigsAsync(env.Value)).ReturnsAsync(configs);
dependencies.ConfigService.Setup(x => x.GetAsync("new", env.Value)).ReturnsAsync(configs.Last());
dependencies.ConfigService.Setup(x => x.GetAsync("missing", env.Value)).ReturnsAsync((Config)null);
Assert.IsNotNull(await controller.All(env.Value) as JsonResult);
Assert.IsNotNull(await controller.Search("app-1", null, null, null, "group", "ascend", env, 10, 1) as JsonResult);
Assert.IsNotNull(await controller.Search("app-1", null, null, OnlineStatus.Online, "createTime", "descend", env, 10, 1) as JsonResult);
Assert.IsNotNull(await controller.Get("new", env) as JsonResult);
Assert.IsNotNull(await controller.Get("missing", env) as JsonResult);
Assert.IsNotNull(await controller.WaitPublishStatus("app-1", env) as JsonResult);
}
[TestMethod]
public async Task BatchAndContentEndpoints_UseConfigurationServiceResults()
{
var dependencies = new Dependencies();
var controller = dependencies.CreateController();
var env = new EnvString { Value = "DEV" };
var existing = new List<Config>
{
new() { Id = "old", AppId = "app-1", Key = "old", Value = "old", EditStatus = EditStatus.Commit }
};
dependencies.ConfigService.Setup(x => x.GetByAppIdAsync("app-1", env.Value)).ReturnsAsync(existing);
dependencies.ConfigService.Setup(x => x.GenerateKey(It.IsAny<Config>())).Returns<Config>(config =>
string.IsNullOrEmpty(config.Group) ? config.Key : $"{config.Group}:{config.Key}");
dependencies.ConfigService.Setup(x => x.AddRangeAsync(It.IsAny<List<Config>>(), env.Value)).ReturnsAsync(true);
dependencies.ConfigService.Setup(x => x.UpdateAsync(It.IsAny<List<Config>>(), env.Value)).ReturnsAsync(true);
dependencies.ConfigService.Setup(x => x.IsPublishedAsync(It.IsAny<string>(), env.Value)).ReturnsAsync(false);
dependencies.ConfigService.Setup(x => x.GetAsync("old", env.Value)).ReturnsAsync(existing.Single());
dependencies.ConfigService.Setup(x => x.CancelEdit(It.IsAny<List<string>>(), env.Value)).ReturnsAsync(true);
dependencies.ConfigService.Setup(x => x.SaveJsonAsync(It.IsAny<string>(), "app-1", env.Value, It.IsAny<bool>())).ReturnsAsync(true);
dependencies.ConfigService.Setup(x => x.ValidateKvString("key=value")).Returns((true, ""));
dependencies.ConfigService.Setup(x => x.SaveKvListAsync("key=value", "app-1", env.Value, It.IsAny<bool>())).ReturnsAsync(true);
var add = await controller.AddRange(new List<ConfigVM>
{
new() { AppId = "app-1", Key = "new", Value = "value" }
}, env) as JsonResult;
Assert.IsNotNull(add);
var duplicate = await controller.AddRange(new List<ConfigVM>
{
new() { AppId = "app-1", Key = "old", Value = "value" }
}, env) as JsonResult;
Assert.IsNotNull(duplicate);
Assert.IsNotNull(await controller.DeleteSome(new List<string> { "old" }, env) as JsonResult);
Assert.IsNotNull(await controller.CancelEdit("old", env) as JsonResult);
Assert.IsNotNull(await controller.CancelSomeEdit(new List<string> { "old" }, env) as JsonResult);
Assert.IsNotNull(await controller.GetKvList("app-1", env) as JsonResult);
Assert.IsNotNull(await controller.GetJson("app-1", env) as JsonResult);
Assert.IsNotNull(await controller.SaveJson(new SaveJsonVM { json = "{}", isPatch = true }, "app-1", env) as JsonResult);
Assert.IsNotNull(await controller.SaveKvList(new SaveKVListVM { str = "key=value", isPatch = false }, "app-1", env) as JsonResult);
}
[TestMethod]
public async Task SynchronizationAndHistoryEndpoints_HandleFoundAndMissingApps()
{
var dependencies = new Dependencies();
var controller = dependencies.CreateController();
var env = new EnvString { Value = "DEV" };
dependencies.AppService.Setup(x => x.GetAsync("missing")).ReturnsAsync((App)null);
dependencies.AppService.Setup(x => x.GetAsync("app-1")).ReturnsAsync(new App { Id = "app-1" });
dependencies.ConfigService.Setup(x => x.EnvSync("app-1", "DEV", It.IsAny<List<string>>())).ReturnsAsync(true);
dependencies.ConfigService.Setup(x => x.GetPublishDetailListAsync("app-1", env.Value)).ReturnsAsync(new List<PublishDetail>
{
new() { Version = 2, PublishTimelineId = "t2" },
new() { Version = 1, PublishTimelineId = "t1" }
});
dependencies.ConfigService.Setup(x => x.GetPublishTimeLineNodeAsync(It.IsAny<string>(), env.Value))
.ReturnsAsync(new PublishTimeline());
dependencies.ConfigService.Setup(x => x.GetConfigPublishedHistory("config-1", env.Value))
.ReturnsAsync(new List<PublishDetail> { new() { Version = 1, PublishTimelineId = "t1" } });
Assert.IsNotNull(await controller.SyncEnv(new List<string> { "TEST" }, "missing", "DEV") as JsonResult);
Assert.IsNotNull(await controller.SyncEnv(new List<string> { "TEST" }, "app-1", "DEV") as JsonResult);
Assert.IsNotNull(await controller.PublishHistory("app-1", env) as JsonResult);
Assert.IsNotNull(await controller.ConfigPublishedHistory("config-1", env) as JsonResult);
}
private sealed class Dependencies
{
public Mock<IConfigService> ConfigService { get; } = new();
public Mock<IAppService> AppService { get; } = new();
public Mock<ITinyEventBus> EventBus { get; } = new();
public Mock<IConfigurationManagementService> ConfigurationManagement { get; } = new();
public Mock<IReleaseManagementService> ReleaseManagement { get; } = new();
public ConfigController CreateController()
{
var controller = new ConfigController(
ConfigService.Object,
AppService.Object,
EventBus.Object,
ConfigurationManagement.Object,
ReleaseManagement.Object);
controller.ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() };
return controller;
}
}
}
+79
View File
@@ -8,6 +8,7 @@ using System.Threading.Tasks;
using AgileConfig.Server.Apisite.Controllers;
using AgileConfig.Server.Apisite.Models;
using AgileConfig.Server.Application;
using AgileConfig.Server.Common;
using AgileConfig.Server.Data.Entity;
using AgileConfig.Server.IService;
using Microsoft.AspNetCore.Http;
@@ -213,4 +214,82 @@ public class TestAppController
Assert.IsTrue(addedConfigs.All(x => x.OnlineStatus == OnlineStatus.WaitPublish));
Assert.IsTrue(addedConfigs.All(x => x.Status == ConfigStatus.Enabled));
}
[TestMethod]
public async Task SearchAndReadEndpoints_ReturnAppsForAdministrators()
{
var appService = new Mock<IAppService>();
var userService = new Mock<IUserService>();
var configService = new Mock<IConfigService>();
var settingService = new Mock<ISettingService>();
var controller = BuildController(appService, configService, settingService, userService);
controller.ControllerContext.HttpContext.User = new System.Security.Claims.ClaimsPrincipal(
new System.Security.Claims.ClaimsIdentity(
[new System.Security.Claims.Claim("id", "admin-user")], "test"));
var parent = new App { Id = "parent", Name = "Parent", Enabled = true };
var child = new App { Id = "child", Name = "Child", Enabled = true };
appService.Setup(x => x.SearchAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<string>(), It.IsAny<int>(), It.IsAny<int>(), "admin-user", true))
.ReturnsAsync((new List<App> { parent }, 1L));
appService.Setup(x => x.SearchGroupedAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<string>(), It.IsAny<int>(), It.IsAny<int>(), "admin-user", true))
.ReturnsAsync((new List<GroupedApp> { new() { App = parent, Children = [new GroupedApp { App = child }] } }, 1L));
appService.Setup(x => x.GetInheritancedAppsAsync(It.IsAny<string>())).ReturnsAsync(new List<App>());
appService.Setup(x => x.GetAsync("parent")).ReturnsAsync(parent);
userService.Setup(x => x.GetUserRolesAsync("admin-user"))
.ReturnsAsync(new List<Role> { new() { Id = SystemRoleConstants.AdminId } });
Assert.IsInstanceOfType(await controller.Search(null, null, null, "name", "ascend", false), typeof(JsonResult));
Assert.IsInstanceOfType(await controller.Search(null, null, null, "name", "ascend", true), typeof(JsonResult));
Assert.IsInstanceOfType(await controller.Get("parent"), typeof(JsonResult));
Assert.IsInstanceOfType(await controller.Get("missing"), typeof(NotFoundObjectResult));
}
[TestMethod]
public async Task AppManagementAndExportEndpoints_ExecuteExpectedServiceOperations()
{
var appService = new Mock<IAppService>();
var userService = new Mock<IUserService>();
var configService = new Mock<IConfigService>();
var settingService = new Mock<ISettingService>();
var applicationManagement = new Mock<IApplicationManagementService>();
var controller = new AppController(appService.Object, userService.Object, configService.Object, settingService.Object,
applicationManagement.Object);
controller.ControllerContext.HttpContext = new DefaultHttpContext();
controller.ControllerContext.HttpContext.User = new System.Security.Claims.ClaimsPrincipal(
new System.Security.Claims.ClaimsIdentity(
[new System.Security.Claims.Claim("id", "admin-user")], "test"));
var app = new App { Id = "app-1", Name = "App", Enabled = true, Type = AppType.PRIVATE };
appService.Setup(x => x.GetAsync("app-1")).ReturnsAsync(app);
appService.Setup(x => x.GetInheritancedAppsAsync("app-1")).ReturnsAsync(new List<App>());
appService.Setup(x => x.UpdateAsync(app)).ReturnsAsync(true);
appService.Setup(x => x.SearchAsync("app-1", null, null, nameof(App.Id), "ascend", 1, 1, "admin-user", false))
.ReturnsAsync((new List<App> { app }, 1L));
appService.Setup(x => x.SaveUserAppAuth("app-1", It.IsAny<List<string>>())).ReturnsAsync(true);
appService.Setup(x => x.GetUserAppAuth("app-1")).ReturnsAsync(new List<User> { new() { Id = "user-1" } });
appService.Setup(x => x.GetAllInheritancedAppsAsync()).ReturnsAsync(new List<App> { app, new() { Id = "hidden", Enabled = false } });
appService.Setup(x => x.GetAppGroups()).ReturnsAsync(new List<string> { "z", "a" });
userService.Setup(x => x.GetUserRolesAsync("admin-user"))
.ReturnsAsync(new List<Role> { new() { Id = SystemRoleConstants.AdminId } });
settingService.Setup(x => x.GetEnvironmentList()).ReturnsAsync(["DEV", "DEV", ""]);
configService.Setup(x => x.GetByAppIdAsync("app-1", "DEV")).ReturnsAsync(new List<Config>
{
new() { Key = "key", Value = "value", Group = "group" }
});
applicationManagement.Setup(x => x.UpdateAsync(It.IsAny<UpdateApplicationCommand>()))
.ReturnsAsync(ApplicationResult<App>.Failure(ApplicationError.ValidationFailed));
applicationManagement.Setup(x => x.DeleteAsync(It.IsAny<DeleteApplicationCommand>()))
.ReturnsAsync(ApplicationResult<App>.Success(app));
Assert.IsInstanceOfType(await controller.DisableOrEnable("app-1"), typeof(JsonResult));
Assert.IsInstanceOfType(await controller.Edit(new AppVM { Id = "app-1" }), typeof(JsonResult));
Assert.IsInstanceOfType(await controller.Delete("app-1"), typeof(JsonResult));
Assert.IsInstanceOfType(await controller.Export(new AppExportRequest { AppIds = ["app-1", "APP-1"] }), typeof(FileContentResult));
Assert.IsInstanceOfType(await controller.InheritancedApps("app-1"), typeof(JsonResult));
Assert.IsInstanceOfType(await controller.SaveAppAuth(new AppAuthVM { AppId = "app-1", AuthorizedUsers = ["user-1"] }), typeof(JsonResult));
Assert.IsInstanceOfType(await controller.GetUserAppAuth("app-1"), typeof(JsonResult));
Assert.IsInstanceOfType(await controller.GetAppGroups(), typeof(JsonResult));
}
}
@@ -0,0 +1,348 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks;
using AgileConfig.Server.Apisite.Controllers;
using AgileConfig.Server.Apisite.Models;
using AgileConfig.Server.Common;
using AgileConfig.Server.Common.EventBus;
using AgileConfig.Server.Common.Resources;
using AgileConfig.Server.Data.Entity;
using AgileConfig.Server.Event;
using AgileConfig.Server.IService;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Moq;
namespace ApiSiteTests;
[TestClass]
public sealed class UserControllerCoverageTests
{
[TestMethod]
public async Task Search_FiltersSortsPagesAndMapsRoles()
{
var userService = new Mock<IUserService>();
var eventBus = new Mock<ITinyEventBus>();
var baseTime = new DateTime(2026, 1, 1, 12, 0, 0, DateTimeKind.Utc);
var users = new List<User>
{
new() { Id = "old", UserName = "alice", Team = "zeta", Status = UserStatus.Normal, CreateTime = baseTime.AddMinutes(-10) },
new() { Id = "new", UserName = "alice", Team = "alpha", Status = UserStatus.Normal, CreateTime = baseTime },
new() { Id = "null-name", UserName = null, Team = "alpha", Status = UserStatus.Normal, CreateTime = baseTime.AddMinutes(-1) },
new() { Id = "null-team", UserName = "alice", Team = null, Status = UserStatus.Normal, CreateTime = baseTime.AddMinutes(-2) },
new() { Id = "deleted", UserName = "alice", Team = "alpha", Status = UserStatus.Deleted, CreateTime = baseTime.AddMinutes(1) },
new() { Id = SystemSettings.SuperAdminId, UserName = "admin", Team = "root", Status = UserStatus.Normal, CreateTime = baseTime.AddMinutes(2) }
};
userService.Setup(x => x.GetAll()).ReturnsAsync(users);
userService.Setup(x => x.GetUserRolesAsync(It.IsAny<string>()))
.ReturnsAsync((string id) => new List<Role>
{
new() { Id = id + "-role", Name = id + " role" }
});
var controller = CreateController(userService, eventBus);
var filtered = AsJson(await controller.Search("alice", "alpha", 1, 10));
var filteredUsers = Read<IEnumerable<UserVM>>(filtered, "data").ToList();
Assert.AreEqual(1, Read<int>(filtered, "total"));
Assert.AreEqual("new", filteredUsers.Single().Id);
CollectionAssert.AreEqual(new[] { "new-role" }, filteredUsers.Single().UserRoleIds);
CollectionAssert.AreEqual(new[] { "new role" }, filteredUsers.Single().UserRoleNames);
var paged = AsJson(await controller.Search(null, null, 2, 2));
var pagedUsers = Read<IEnumerable<UserVM>>(paged, "data").ToList();
Assert.AreEqual(2, Read<int>(paged, "current"));
Assert.AreEqual(2, Read<int>(paged, "pageSize"));
Assert.AreEqual(4, Read<int>(paged, "total"));
CollectionAssert.AreEqual(new[] { "null-team", "old" }, pagedUsers.Select(x => x.Id).ToArray());
userService.Verify(x => x.GetUserRolesAsync(It.IsAny<string>()), Times.Exactly(3));
}
[TestMethod]
public void Search_RejectsInvalidPaging()
{
var userService = new Mock<IUserService>();
var controller = CreateController(userService, new Mock<ITinyEventBus>());
Assert.ThrowsExactly<ArgumentException>(() => controller.Search(null, null, 0, 20).GetAwaiter().GetResult());
Assert.ThrowsExactly<ArgumentException>(() => controller.Search(null, null, 1, 0).GetAwaiter().GetResult());
userService.Verify(x => x.GetAll(), Times.Never);
}
[TestMethod]
public async Task Add_RejectsNullAndExistingNormalUsers()
{
var userService = new Mock<IUserService>();
var controller = CreateController(userService, new Mock<ITinyEventBus>());
Assert.ThrowsExactly<ArgumentNullException>(() => controller.Add(null).GetAwaiter().GetResult());
userService.Setup(x => x.GetUsersByNameAsync("already"))
.ReturnsAsync(new List<User>
{
new() { Id = "existing", UserName = "already", Status = UserStatus.Normal }
});
var result = AsJson(await controller.Add(new UserVM { UserName = "already", Password = "password" }));
Assert.IsFalse(Read<bool>(result, "success"));
StringAssert.Contains(Read<string>(result, "message"), "already");
userService.Verify(x => x.AddAsync(It.IsAny<User>()), Times.Never);
}
[TestMethod]
public async Task Add_CreatesUserWithDefaultRoleAndRaisesEvent()
{
var userService = new Mock<IUserService>();
var eventBus = new Mock<ITinyEventBus>();
User addedUser = null;
List<string> addedRoleIds = null;
userService.Setup(x => x.GetUsersByNameAsync("new-user"))
.ReturnsAsync(new List<User>
{
new() { Id = "deleted-user", UserName = "new-user", Status = UserStatus.Deleted }
});
userService.Setup(x => x.AddAsync(It.IsAny<User>()))
.Callback<User>(user => addedUser = user)
.ReturnsAsync(true);
userService.Setup(x => x.UpdateUserRolesAsync(It.IsAny<string>(), It.IsAny<List<string>>()))
.Callback<string, List<string>>((_, roleIds) => addedRoleIds = roleIds)
.ReturnsAsync(true);
var controller = CreateController(userService, eventBus, "admin-user");
var result = AsJson(await controller.Add(new UserVM
{
UserName = "new-user",
Password = "new-password",
Team = "platform",
UserRoleIds = null
}));
Assert.IsTrue(Read<bool>(result, "success"));
Assert.IsNotNull(addedUser);
Assert.AreEqual("new-user", addedUser.UserName);
Assert.AreEqual("platform", addedUser.Team);
Assert.AreEqual(UserStatus.Normal, addedUser.Status);
Assert.IsFalse(string.IsNullOrEmpty(addedUser.Id));
Assert.AreEqual(Encrypt.Md5("new-password" + addedUser.Salt), addedUser.Password);
CollectionAssert.AreEqual(new[] { SystemRoleConstants.OperatorId }, addedRoleIds);
eventBus.Verify(x => x.Fire(It.Is<AddUserSuccessful>(evt =>
ReferenceEquals(evt.User, addedUser) && evt.UserName == "admin-user")), Times.Once);
}
[TestMethod]
public async Task Add_ReportsAddAndRoleFailures()
{
var userService = new Mock<IUserService>();
var eventBus = new Mock<ITinyEventBus>();
userService.Setup(x => x.GetUsersByNameAsync(It.IsAny<string>())).ReturnsAsync(new List<User>());
userService.Setup(x => x.AddAsync(It.IsAny<User>())).ReturnsAsync(false);
userService.Setup(x => x.UpdateUserRolesAsync(It.IsAny<string>(), It.IsAny<List<string>>())).ReturnsAsync(true);
var controller = CreateController(userService, eventBus);
var addFailure = AsJson(await controller.Add(new UserVM
{
UserName = "failed-add",
Password = "password",
UserRoleIds = new List<string> { "role-1", "", "role-1", " " }
}));
Assert.IsFalse(Read<bool>(addFailure, "success"));
Assert.AreEqual(Messages.AddUserFailed, Read<string>(addFailure, "message"));
eventBus.Verify(x => x.Fire(It.IsAny<AddUserSuccessful>()), Times.Never);
userService.Setup(x => x.AddAsync(It.IsAny<User>())).ReturnsAsync(true);
userService.Setup(x => x.UpdateUserRolesAsync(It.IsAny<string>(), It.IsAny<List<string>>())).ReturnsAsync(false);
var roleFailure = AsJson(await controller.Add(new UserVM
{
UserName = "failed-roles",
Password = "password",
UserRoleIds = new List<string> { "role-1", "", "role-1", " " }
}));
Assert.IsFalse(Read<bool>(roleFailure, "success"));
Assert.AreEqual(Messages.AddUserFailed, Read<string>(roleFailure, "message"));
eventBus.Verify(x => x.Fire(It.IsAny<AddUserSuccessful>()), Times.Once);
}
[TestMethod]
public async Task Edit_HandlesNullMissingAndServiceResults()
{
var userService = new Mock<IUserService>();
var eventBus = new Mock<ITinyEventBus>();
var existingUser = new User { Id = "user-1", UserName = "user", Team = "old-team", Salt = "salt" };
userService.Setup(x => x.GetUserAsync("missing")).ReturnsAsync((User)null);
userService.Setup(x => x.GetUserAsync("user-1")).ReturnsAsync(existingUser);
userService.SetupSequence(x => x.UpdateAsync(It.IsAny<User>()))
.ReturnsAsync(true)
.ReturnsAsync(false)
.ReturnsAsync(true);
userService.SetupSequence(x => x.UpdateUserRolesAsync(It.IsAny<string>(), It.IsAny<List<string>>()))
.ReturnsAsync(true)
.ReturnsAsync(true)
.ReturnsAsync(false);
var controller = CreateController(userService, eventBus, "editor");
Assert.ThrowsExactly<ArgumentNullException>(() => controller.Edit(null).GetAwaiter().GetResult());
var missing = AsJson(await controller.Edit(new UserVM { Id = "missing", Team = "new-team" }));
Assert.IsFalse(Read<bool>(missing, "success"));
Assert.AreEqual(Messages.UserNotFoundForOperation, Read<string>(missing, "message"));
var success = AsJson(await controller.Edit(new UserVM
{
Id = "user-1",
Team = "new-team",
UserRoleIds = null
}));
Assert.IsTrue(Read<bool>(success, "success"));
Assert.AreEqual("new-team", existingUser.Team);
Assert.IsNotNull(existingUser.UpdateTime);
var updateFailure = AsJson(await controller.Edit(new UserVM
{
Id = "user-1",
Team = "another-team",
UserRoleIds = new List<string> { "role-1", "", "role-1" }
}));
Assert.IsFalse(Read<bool>(updateFailure, "success"));
Assert.AreEqual(Messages.UpdateUserFailed, Read<string>(updateFailure, "message"));
var roleFailure = AsJson(await controller.Edit(new UserVM
{
Id = "user-1",
Team = "final-team",
UserRoleIds = new List<string> { "role-1", "", "role-1" }
}));
Assert.IsFalse(Read<bool>(roleFailure, "success"));
eventBus.Verify(x => x.Fire(It.Is<EditUserSuccessful>(evt =>
ReferenceEquals(evt.User, existingUser) && evt.UserName == "editor")), Times.Exactly(2));
}
[TestMethod]
public async Task ResetPassword_HandlesMissingAndUpdateResults()
{
var userService = new Mock<IUserService>();
var eventBus = new Mock<ITinyEventBus>();
var user = new User { Id = "user-1", UserName = "target", Salt = "user-salt", Password = "old" };
userService.Setup(x => x.GetUserAsync("missing")).ReturnsAsync((User)null);
userService.Setup(x => x.GetUserAsync("user-1")).ReturnsAsync(user);
userService.SetupSequence(x => x.UpdateAsync(It.IsAny<User>())).ReturnsAsync(false).ReturnsAsync(true);
var controller = CreateController(userService, eventBus, "operator");
Assert.ThrowsExactly<ArgumentNullException>(() => controller.ResetPassword(null).GetAwaiter().GetResult());
var missing = AsJson(await controller.ResetPassword("missing"));
Assert.IsFalse(Read<bool>(missing, "success"));
Assert.AreEqual(Messages.UserNotFoundForOperation, Read<string>(missing, "message"));
var failed = AsJson(await controller.ResetPassword("user-1"));
Assert.IsFalse(Read<bool>(failed, "success"));
Assert.AreEqual(Messages.ResetUserPasswordFailed, Read<string>(failed, "message"));
Assert.AreEqual(Encrypt.Md5("123456" + user.Salt), user.Password);
var succeeded = AsJson(await controller.ResetPassword("user-1"));
Assert.IsTrue(Read<bool>(succeeded, "success"));
Assert.AreEqual("", Read<string>(succeeded, "message"));
eventBus.Verify(x => x.Fire(It.Is<ResetUserPasswordSuccessful>(evt =>
evt.OpUser == "operator" && evt.UserName == "target")), Times.Once);
}
[TestMethod]
public async Task Delete_HandlesMissingAndUpdateResults()
{
var userService = new Mock<IUserService>();
var eventBus = new Mock<ITinyEventBus>();
var user = new User { Id = "user-1", UserName = "target", Status = UserStatus.Normal };
userService.Setup(x => x.GetUserAsync("missing")).ReturnsAsync((User)null);
userService.Setup(x => x.GetUserAsync("user-1")).ReturnsAsync(user);
userService.SetupSequence(x => x.UpdateAsync(It.IsAny<User>())).ReturnsAsync(false).ReturnsAsync(true);
var controller = CreateController(userService, eventBus, "operator");
Assert.ThrowsExactly<ArgumentNullException>(() => controller.Delete(null).GetAwaiter().GetResult());
var missing = AsJson(await controller.Delete("missing"));
Assert.IsFalse(Read<bool>(missing, "success"));
Assert.AreEqual(Messages.UserNotFoundForOperation, Read<string>(missing, "message"));
var failed = AsJson(await controller.Delete("user-1"));
Assert.IsFalse(Read<bool>(failed, "success"));
Assert.AreEqual(Messages.DeleteUserFailed, Read<string>(failed, "message"));
Assert.AreEqual(UserStatus.Deleted, user.Status);
var succeeded = AsJson(await controller.Delete("user-1"));
Assert.IsTrue(Read<bool>(succeeded, "success"));
Assert.AreEqual("", Read<string>(succeeded, "message"));
eventBus.Verify(x => x.Fire(It.Is<DeleteUserSuccessful>(evt =>
ReferenceEquals(evt.User, user) && evt.UserName == "operator")), Times.Once);
}
[TestMethod]
public async Task AdminUsers_FiltersAndSortsNormalAdministrators()
{
var userService = new Mock<IUserService>();
userService.Setup(x => x.GetUsersByRoleAsync(SystemRoleConstants.AdminId))
.ReturnsAsync(new List<User>
{
new() { Id = "z", UserName = "z-user", Team = "z-team", Status = UserStatus.Normal },
new() { Id = "a2", UserName = "z-user", Team = "a-team", Status = UserStatus.Normal },
new() { Id = "a1", UserName = "a-user", Team = "a-team", Status = UserStatus.Normal },
new() { Id = "deleted", UserName = "deleted", Team = "", Status = UserStatus.Deleted }
});
var controller = CreateController(userService, new Mock<ITinyEventBus>());
var result = AsJson(await controller.AdminUsers());
var users = Read<IEnumerable<UserVM>>(result, "data").ToList();
Assert.IsTrue(Read<bool>(result, "success"));
CollectionAssert.AreEqual(new[] { "a1", "a2", "z" }, users.Select(x => x.Id).ToArray());
userService.Verify(x => x.GetUsersByRoleAsync(SystemRoleConstants.AdminId), Times.Once);
}
[TestMethod]
public async Task AllUsers_FiltersAndSortsNormalNonSuperAdministrators()
{
var userService = new Mock<IUserService>();
userService.Setup(x => x.GetAll()).ReturnsAsync(new List<User>
{
new() { Id = SystemSettings.SuperAdminId, UserName = "admin", Team = "root", Status = UserStatus.Normal },
new() { Id = "deleted", UserName = "deleted", Team = "a-team", Status = UserStatus.Deleted },
new() { Id = "z", UserName = "z-user", Team = "z-team", Status = UserStatus.Normal },
new() { Id = "a2", UserName = "z-user", Team = "a-team", Status = UserStatus.Normal },
new() { Id = "a1", UserName = "a-user", Team = "a-team", Status = UserStatus.Normal }
});
var controller = CreateController(userService, new Mock<ITinyEventBus>());
var result = AsJson(await controller.AllUsers());
var users = Read<IEnumerable<UserVM>>(result, "data").ToList();
Assert.IsTrue(Read<bool>(result, "success"));
CollectionAssert.AreEqual(new[] { "a1", "a2", "z" }, users.Select(x => x.Id).ToArray());
userService.Verify(x => x.GetAll(), Times.Once);
}
private static UserController CreateController(Mock<IUserService> userService,
Mock<ITinyEventBus> eventBus, string currentUser = "test-user")
{
var context = new DefaultHttpContext
{
User = new ClaimsPrincipal(new ClaimsIdentity(
new[] { new Claim("username", currentUser) }, "test"))
};
var controller = new UserController(userService.Object, eventBus.Object);
controller.ControllerContext = new ControllerContext { HttpContext = context };
return controller;
}
private static JsonResult AsJson(IActionResult result)
{
Assert.IsInstanceOfType(result, typeof(JsonResult));
return (JsonResult)result;
}
private static T Read<T>(JsonResult result, string propertyName)
{
Assert.IsNotNull(result.Value);
var property = result.Value.GetType().GetProperty(propertyName);
Assert.IsNotNull(property);
return (T)property.GetValue(result.Value);
}
}