Skip to content

Commit 7b7c251

Browse files
feat: Implement Global Settings management with authorization and view model
1 parent e36ca43 commit 7b7c251

10 files changed

Lines changed: 506 additions & 0 deletions

File tree

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
using Microsoft.ApplicationInsights;
2+
using Microsoft.ApplicationInsights.Extensibility;
3+
using Microsoft.AspNetCore.Http;
4+
using Microsoft.AspNetCore.Mvc;
5+
using Microsoft.Extensions.Configuration;
6+
using Microsoft.Extensions.Logging;
7+
using Moq;
8+
using System.Security.Claims;
9+
using XtremeIdiots.Portal.Repository.Api.Client.V1;
10+
using XtremeIdiots.Portal.Web.Controllers;
11+
12+
namespace XtremeIdiots.Portal.Web.Tests.Controllers;
13+
14+
public class GlobalSettingsControllerTests
15+
{
16+
private readonly Mock<IRepositoryApiClient> mockRepositoryApiClient = new(MockBehavior.Default) { DefaultValue = DefaultValue.Mock };
17+
private readonly TelemetryClient telemetryClient = new(new TelemetryConfiguration());
18+
private readonly Mock<ILogger<GlobalSettingsController>> mockLogger = new();
19+
private readonly Mock<IConfiguration> mockConfiguration = new();
20+
21+
private GlobalSettingsController CreateSut(ClaimsPrincipal? user = null)
22+
{
23+
var controller = new GlobalSettingsController(
24+
mockRepositoryApiClient.Object,
25+
telemetryClient,
26+
mockLogger.Object,
27+
mockConfiguration.Object);
28+
29+
var httpContext = new DefaultHttpContext
30+
{
31+
User = user ?? new ClaimsPrincipal(new ClaimsIdentity("TestAuth"))
32+
};
33+
controller.ControllerContext = new ControllerContext { HttpContext = httpContext };
34+
35+
return controller;
36+
}
37+
38+
[Fact]
39+
public void Constructor_WithValidDependencies_DoesNotThrow()
40+
{
41+
var sut = CreateSut();
42+
Assert.NotNull(sut);
43+
}
44+
45+
[Fact]
46+
public void Constructor_WithNullTelemetryClient_ThrowsArgumentNullException()
47+
{
48+
Assert.Throws<ArgumentNullException>(() =>
49+
new GlobalSettingsController(
50+
mockRepositoryApiClient.Object,
51+
null!,
52+
mockLogger.Object,
53+
mockConfiguration.Object));
54+
}
55+
56+
[Fact]
57+
public void Constructor_WithNullLogger_ThrowsArgumentNullException()
58+
{
59+
Assert.Throws<ArgumentNullException>(() =>
60+
new GlobalSettingsController(
61+
mockRepositoryApiClient.Object,
62+
telemetryClient,
63+
null!,
64+
mockConfiguration.Object));
65+
}
66+
67+
[Fact]
68+
public void Constructor_WithNullConfiguration_ThrowsArgumentNullException()
69+
{
70+
Assert.Throws<ArgumentNullException>(() =>
71+
new GlobalSettingsController(
72+
mockRepositoryApiClient.Object,
73+
telemetryClient,
74+
mockLogger.Object,
75+
null!));
76+
}
77+
}

src/XtremeIdiots.Portal.Web/Auth/Constants/AuthPolicies.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@ public static class AuthPolicies
3333
public const string AccessDemos = nameof(AccessDemos);
3434
public const string DeleteDemo = nameof(DeleteDemo);
3535

36+
// Global Settings policies
37+
public const string AccessGlobalSettings = nameof(AccessGlobalSettings);
38+
3639
// Game Server policies
3740
public const string AccessGameServers = nameof(AccessGameServers);
3841
public const string CreateGameServer = nameof(CreateGameServer);
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
using Microsoft.AspNetCore.Authorization;
2+
using XtremeIdiots.Portal.Web.Auth.Requirements;
3+
4+
namespace XtremeIdiots.Portal.Web.Auth.Handlers;
5+
6+
/// <summary>
7+
/// Authorization handler for global settings operations — restricted to senior admins only
8+
/// </summary>
9+
public class GlobalSettingsAuthHandler : IAuthorizationHandler
10+
{
11+
public Task HandleAsync(AuthorizationHandlerContext context)
12+
{
13+
var pendingRequirements = context.PendingRequirements;
14+
15+
foreach (var requirement in pendingRequirements)
16+
{
17+
if (requirement is AccessGlobalSettings)
18+
{
19+
BaseAuthorizationHelper.CheckSeniorAdminAccess(context, requirement);
20+
}
21+
}
22+
23+
return Task.CompletedTask;
24+
}
25+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
using Microsoft.AspNetCore.Authorization;
2+
3+
namespace XtremeIdiots.Portal.Web.Auth.Requirements;
4+
5+
/// <summary>
6+
/// Authorization requirement for accessing the global settings management interface
7+
/// </summary>
8+
public class AccessGlobalSettings : IAuthorizationRequirement
9+
{
10+
}
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
using System.Text.Json;
2+
using Microsoft.ApplicationInsights;
3+
using Microsoft.AspNetCore.Authorization;
4+
using Microsoft.AspNetCore.Mvc;
5+
using XtremeIdiots.Portal.Repository.Abstractions.Models.V1.Configurations;
6+
using XtremeIdiots.Portal.Repository.Api.Client.V1;
7+
using XtremeIdiots.Portal.Web.Auth.Constants;
8+
using XtremeIdiots.Portal.Web.Extensions;
9+
using XtremeIdiots.Portal.Web.ViewModels;
10+
11+
namespace XtremeIdiots.Portal.Web.Controllers;
12+
13+
/// <summary>
14+
/// Manages fleet-wide global configuration defaults for agents, ban files, moderation, and events
15+
/// </summary>
16+
[Authorize(Policy = AuthPolicies.AccessGlobalSettings)]
17+
public class GlobalSettingsController(
18+
IRepositoryApiClient repositoryApiClient,
19+
TelemetryClient telemetryClient,
20+
ILogger<GlobalSettingsController> logger,
21+
IConfiguration configuration) : BaseController(telemetryClient, logger, configuration)
22+
{
23+
private readonly static JsonSerializerOptions configJsonOptions = new()
24+
{
25+
PropertyNameCaseInsensitive = true
26+
};
27+
28+
[HttpGet]
29+
public async Task<IActionResult> Index(CancellationToken cancellationToken = default)
30+
{
31+
return await ExecuteWithErrorHandlingAsync(async () =>
32+
{
33+
var model = new GlobalSettingsViewModel();
34+
35+
try
36+
{
37+
var configsResult = await repositoryApiClient.GlobalConfigurations.V1
38+
.GetConfigurations(cancellationToken).ConfigureAwait(false);
39+
40+
if (configsResult.IsSuccess && configsResult.Result?.Data?.Items != null)
41+
{
42+
foreach (var config in configsResult.Result.Data.Items)
43+
{
44+
PopulateModelFromNamespace(model, config);
45+
}
46+
}
47+
else
48+
{
49+
Logger.LogWarning("Failed to retrieve global configurations, using defaults");
50+
}
51+
}
52+
catch (Exception ex)
53+
{
54+
Logger.LogWarning(ex, "Failed to fetch global configurations, using defaults");
55+
}
56+
57+
return View(model);
58+
}, nameof(Index)).ConfigureAwait(false);
59+
}
60+
61+
[HttpPost]
62+
[ValidateAntiForgeryToken]
63+
public async Task<IActionResult> Index(GlobalSettingsViewModel model, CancellationToken cancellationToken = default)
64+
{
65+
return await ExecuteWithErrorHandlingAsync(async () =>
66+
{
67+
var modelStateResult = CheckModelState(model);
68+
if (modelStateResult is not null)
69+
return modelStateResult;
70+
71+
var errors = new List<string>();
72+
73+
await UpsertConfigSafeAsync("agent", JsonSerializer.Serialize(new
74+
{
75+
pollIntervalMs = model.AgentPollIntervalMs,
76+
statusPublishIntervalSeconds = model.AgentStatusPublishIntervalSeconds,
77+
rconSyncIntervalSeconds = model.AgentRconSyncIntervalSeconds,
78+
offsetSaveIntervalSeconds = model.AgentOffsetSaveIntervalSeconds
79+
}, configJsonOptions), errors, cancellationToken).ConfigureAwait(false);
80+
81+
await UpsertConfigSafeAsync("banfiles", JsonSerializer.Serialize(new
82+
{
83+
checkIntervalSeconds = model.BanFileSyncCheckIntervalSeconds
84+
}, configJsonOptions), errors, cancellationToken).ConfigureAwait(false);
85+
86+
await UpsertConfigSafeAsync("moderation", JsonSerializer.Serialize(new
87+
{
88+
contentSafetySeverityThreshold = model.ModerationSeverityThreshold,
89+
minMessageLength = model.ModerationMinMessageLength
90+
}, configJsonOptions), errors, cancellationToken).ConfigureAwait(false);
91+
92+
await UpsertConfigSafeAsync("events", JsonSerializer.Serialize(new
93+
{
94+
staleThresholdSeconds = model.EventsStaleThresholdSeconds,
95+
playerCacheExpirationSeconds = model.EventsPlayerCacheExpirationSeconds
96+
}, configJsonOptions), errors, cancellationToken).ConfigureAwait(false);
97+
98+
if (errors.Count > 0)
99+
{
100+
this.AddAlertDanger($"Failed to save configuration for: {string.Join(", ", errors)}");
101+
}
102+
else
103+
{
104+
this.AddAlertSuccess("Global settings saved successfully.");
105+
}
106+
107+
return RedirectToAction(nameof(Index));
108+
}, nameof(Index)).ConfigureAwait(false);
109+
}
110+
111+
private void PopulateModelFromNamespace(GlobalSettingsViewModel model, ConfigurationDto config)
112+
{
113+
try
114+
{
115+
if (string.IsNullOrWhiteSpace(config.Configuration))
116+
return;
117+
118+
using var doc = JsonDocument.Parse(config.Configuration);
119+
var root = doc.RootElement;
120+
121+
switch (config.Namespace)
122+
{
123+
case "agent":
124+
model.AgentPollIntervalMs = GetIntProperty(root, "pollIntervalMs", model.AgentPollIntervalMs);
125+
model.AgentStatusPublishIntervalSeconds = GetIntProperty(root, "statusPublishIntervalSeconds", model.AgentStatusPublishIntervalSeconds);
126+
model.AgentRconSyncIntervalSeconds = GetIntProperty(root, "rconSyncIntervalSeconds", model.AgentRconSyncIntervalSeconds);
127+
model.AgentOffsetSaveIntervalSeconds = GetIntProperty(root, "offsetSaveIntervalSeconds", model.AgentOffsetSaveIntervalSeconds);
128+
break;
129+
case "banfiles":
130+
model.BanFileSyncCheckIntervalSeconds = GetIntProperty(root, "checkIntervalSeconds", model.BanFileSyncCheckIntervalSeconds);
131+
break;
132+
case "moderation":
133+
model.ModerationSeverityThreshold = GetIntProperty(root, "contentSafetySeverityThreshold", model.ModerationSeverityThreshold);
134+
model.ModerationMinMessageLength = GetIntProperty(root, "minMessageLength", model.ModerationMinMessageLength);
135+
break;
136+
case "events":
137+
model.EventsStaleThresholdSeconds = GetIntProperty(root, "staleThresholdSeconds", model.EventsStaleThresholdSeconds);
138+
model.EventsPlayerCacheExpirationSeconds = GetIntProperty(root, "playerCacheExpirationSeconds", model.EventsPlayerCacheExpirationSeconds);
139+
break;
140+
default:
141+
Logger.LogDebug("Unknown global configuration namespace '{Namespace}'", config.Namespace);
142+
break;
143+
}
144+
}
145+
catch (JsonException ex)
146+
{
147+
Logger.LogWarning(ex, "Failed to parse global configuration for namespace '{Namespace}'", config.Namespace);
148+
}
149+
}
150+
151+
private static int GetIntProperty(JsonElement root, string propertyName, int defaultValue)
152+
{
153+
return root.TryGetProperty(propertyName, out var prop) &&
154+
prop.ValueKind == JsonValueKind.Number &&
155+
prop.TryGetInt32(out var value)
156+
? value
157+
: defaultValue;
158+
}
159+
160+
private async Task UpsertConfigSafeAsync(
161+
string ns,
162+
string configJson,
163+
List<string> errors,
164+
CancellationToken cancellationToken)
165+
{
166+
try
167+
{
168+
var result = await repositoryApiClient.GlobalConfigurations.V1.UpsertConfiguration(
169+
ns, new UpsertConfigurationDto { Configuration = configJson }, cancellationToken).ConfigureAwait(false);
170+
171+
if (!result.IsSuccess)
172+
{
173+
Logger.LogWarning("Failed to upsert global configuration namespace '{Namespace}'", ns);
174+
errors.Add(ns);
175+
}
176+
}
177+
catch (Exception ex)
178+
{
179+
Logger.LogWarning(ex, "Error upserting global configuration namespace '{Namespace}'", ns);
180+
errors.Add(ns);
181+
}
182+
}
183+
}

src/XtremeIdiots.Portal.Web/Extensions/PolicyExtensions.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ public static void AddXtremeIdiotsPolicies(this AuthorizationOptions options)
3232
options.AddPolicy(AuthPolicies.AccessDemos, policy => policy.Requirements.Add(new AccessDemos()));
3333
options.AddPolicy(AuthPolicies.DeleteDemo, policy => policy.Requirements.Add(new DeleteDemo()));
3434

35+
options.AddPolicy(AuthPolicies.AccessGlobalSettings, policy => policy.Requirements.Add(new AccessGlobalSettings()));
36+
3537
options.AddPolicy(AuthPolicies.AccessGameServers, policy => policy.Requirements.Add(new AccessGameServers()));
3638
options.AddPolicy(AuthPolicies.CreateGameServer, policy => policy.Requirements.Add(new CreateGameServer()));
3739
options.AddPolicy(AuthPolicies.DeleteGameServer, policy => policy.Requirements.Add(new DeleteGameServer()));

src/XtremeIdiots.Portal.Web/Extensions/ServiceCollectionExtensions.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ public static void AddXtremeIdiotsAuth(this IServiceCollection services)
1616
services.AddSingleton<IAuthorizationHandler, CredentialsAuthHandler>();
1717
services.AddSingleton<IAuthorizationHandler, DemosAuthHandler>();
1818
services.AddSingleton<IAuthorizationHandler, GameServersAuthHandler>();
19+
services.AddSingleton<IAuthorizationHandler, GlobalSettingsAuthHandler>();
1920
services.AddSingleton<IAuthorizationHandler, DashboardAuthHandler>();
2021
services.AddSingleton<IAuthorizationHandler, HomeAuthHandler>();
2122
services.AddSingleton<IAuthorizationHandler, ProfileAuthHandler>();
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
using System.ComponentModel;
2+
using System.ComponentModel.DataAnnotations;
3+
4+
namespace XtremeIdiots.Portal.Web.ViewModels;
5+
6+
/// <summary>
7+
/// View model for the global settings admin page, representing fleet-wide configuration defaults
8+
/// </summary>
9+
public class GlobalSettingsViewModel
10+
{
11+
// Agent defaults
12+
[DisplayName("Poll Interval (ms)")]
13+
[Range(100, int.MaxValue, ErrorMessage = "Poll interval must be at least 100ms.")]
14+
public int AgentPollIntervalMs { get; set; } = 500;
15+
16+
[DisplayName("Status Publish Interval (s)")]
17+
[Range(1, int.MaxValue, ErrorMessage = "Status publish interval must be at least 1 second.")]
18+
public int AgentStatusPublishIntervalSeconds { get; set; } = 60;
19+
20+
[DisplayName("RCON Sync Interval (s)")]
21+
[Range(1, int.MaxValue, ErrorMessage = "RCON sync interval must be at least 1 second.")]
22+
public int AgentRconSyncIntervalSeconds { get; set; } = 300;
23+
24+
[DisplayName("Offset Save Interval (s)")]
25+
[Range(1, int.MaxValue, ErrorMessage = "Offset save interval must be at least 1 second.")]
26+
public int AgentOffsetSaveIntervalSeconds { get; set; } = 30;
27+
28+
// Ban file sync defaults
29+
[DisplayName("Check Interval (s)")]
30+
[Range(1, int.MaxValue, ErrorMessage = "Check interval must be at least 1 second.")]
31+
public int BanFileSyncCheckIntervalSeconds { get; set; } = 60;
32+
33+
// Moderation defaults
34+
[DisplayName("Severity Threshold")]
35+
[Range(0, 6, ErrorMessage = "Severity threshold must be between 0 and 6.")]
36+
public int ModerationSeverityThreshold { get; set; } = 4;
37+
38+
[DisplayName("Minimum Message Length")]
39+
[Range(1, int.MaxValue, ErrorMessage = "Minimum message length must be at least 1.")]
40+
public int ModerationMinMessageLength { get; set; } = 5;
41+
42+
// Events defaults
43+
[DisplayName("Stale Event Threshold (s)")]
44+
[Range(1, int.MaxValue, ErrorMessage = "Stale event threshold must be at least 1 second.")]
45+
public int EventsStaleThresholdSeconds { get; set; } = 120;
46+
47+
[DisplayName("Player Cache Expiration (s)")]
48+
[Range(1, int.MaxValue, ErrorMessage = "Player cache expiration must be at least 1 second.")]
49+
public int EventsPlayerCacheExpirationSeconds { get; set; } = 900;
50+
}

0 commit comments

Comments
 (0)