feat(admin): shared-secret auth for /admin/import_viewer + Swagger authorize
Anonymous /admin/import_viewer was safe locally but exposed viewer-import surface whenever the server was reachable off-box. Gate it on a shared secret carried in X-Admin-Secret, sourced from Admin:ImportSecret in appsettings (override via ADMIN__IMPORTSECRET env var). Fails closed: an unconfigured deployment 401s every request (with a logged warning) instead of leaving the endpoint open. Implementation is a plain IAuthorizationFilter attribute (RequireAdminSecret) that runs before model binding — short-circuits the ImportViewerRequest deserialize on unauthorized calls. Compare uses CryptographicOperations.FixedTimeEquals to avoid timing signal. A companion Swagger IOperationFilter registers an ApiKey security definition and attaches the requirement ONLY to actions carrying the attribute, so the Swagger UI shows an Authorize dialog for admin endpoints without decorating ordinary game routes with a padlock. Tests: existing 27 AdminController tests routed through a new SVSimTestFactory.CreateAdminClient() helper that bakes the header from appsettings.Testing.json; added two negative-path tests (missing_secret_header, wrong_secret) — 29/29 passing. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
17
SVSim.EmulatedEntrypoint/Configuration/AdminOptions.cs
Normal file
17
SVSim.EmulatedEntrypoint/Configuration/AdminOptions.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
namespace SVSim.EmulatedEntrypoint.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Config for the /admin/* util endpoints. Bound from the "Admin" section of appsettings.
|
||||
/// </summary>
|
||||
public class AdminOptions
|
||||
{
|
||||
public const string SectionName = "Admin";
|
||||
|
||||
/// <summary>
|
||||
/// Shared secret required in the <c>X-Admin-Secret</c> header on protected admin endpoints
|
||||
/// (see <see cref="Infrastructure.RequireAdminSecretAttribute"/>). Empty / whitespace means
|
||||
/// the endpoint is disabled — the filter fails closed so an unconfigured deployment never
|
||||
/// exposes the admin surface.
|
||||
/// </summary>
|
||||
public string ImportSecret { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -16,8 +16,10 @@ using SVSim.EmulatedEntrypoint.Services;
|
||||
namespace SVSim.EmulatedEntrypoint.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Util endpoints for bootstrapping the dev environment. Anonymous-allowed today — security
|
||||
/// audit pending (don't expose these to the public internet).
|
||||
/// Util endpoints for bootstrapping the dev environment. Actions are gated by
|
||||
/// <see cref="RequireAdminSecretAttribute"/>: callers must send the shared secret from
|
||||
/// <c>Admin:ImportSecret</c> in the <c>X-Admin-Secret</c> header. Missing/blank config
|
||||
/// disables the endpoint (fail-closed).
|
||||
/// </summary>
|
||||
public class AdminController : SVSimController
|
||||
{
|
||||
@@ -41,6 +43,7 @@ public class AdminController : SVSimController
|
||||
/// Only essential fields are imported today — extend as needed.
|
||||
/// </summary>
|
||||
[AllowAnonymous]
|
||||
[RequireAdminSecret]
|
||||
[HttpPost("import_viewer")]
|
||||
public async Task<ActionResult<ImportViewerResponse>> ImportViewer(ImportViewerRequest request)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
using Microsoft.OpenApi.Models;
|
||||
using Swashbuckle.AspNetCore.SwaggerGen;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// Attaches the <see cref="RequireAdminSecretAttribute.HeaderName"/> security requirement to
|
||||
/// Swagger operations whose action carries <see cref="RequireAdminSecretAttribute"/>. The
|
||||
/// matching <see cref="OpenApiSecurityScheme"/> is registered by <c>Program.cs</c> under the
|
||||
/// same scheme id (<see cref="SchemeId"/>) so the Swagger UI shows an Authorize dialog and,
|
||||
/// once populated, sends the header on gated endpoints only.
|
||||
/// </summary>
|
||||
public sealed class AdminSecretOperationFilter : IOperationFilter
|
||||
{
|
||||
public const string SchemeId = "AdminSecret";
|
||||
|
||||
public void Apply(OpenApiOperation operation, OperationFilterContext context)
|
||||
{
|
||||
var hasAttribute = context.MethodInfo.GetCustomAttributes(true)
|
||||
.OfType<RequireAdminSecretAttribute>().Any()
|
||||
|| (context.MethodInfo.DeclaringType?.GetCustomAttributes(true)
|
||||
.OfType<RequireAdminSecretAttribute>().Any() ?? false);
|
||||
|
||||
if (!hasAttribute) return;
|
||||
|
||||
operation.Security ??= new List<OpenApiSecurityRequirement>();
|
||||
operation.Security.Add(new OpenApiSecurityRequirement
|
||||
{
|
||||
[new OpenApiSecurityScheme
|
||||
{
|
||||
Reference = new OpenApiReference
|
||||
{
|
||||
Type = ReferenceType.SecurityScheme,
|
||||
Id = SchemeId,
|
||||
}
|
||||
}] = Array.Empty<string>()
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Filters;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using SVSim.EmulatedEntrypoint.Configuration;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// Gates a controller or action on a shared secret carried in the <c>X-Admin-Secret</c> header,
|
||||
/// compared against <see cref="AdminOptions.ImportSecret"/>. Runs as an authorization filter so
|
||||
/// unauthorized requests short-circuit before model binding.
|
||||
///
|
||||
/// Fail-closed: if the configured secret is null/empty the endpoint is treated as disabled and
|
||||
/// every request gets a 401 (with a warning logged once per request). This means a deployment
|
||||
/// that forgets to set the secret leaves the endpoint locked, not open.
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, Inherited = true)]
|
||||
public sealed class RequireAdminSecretAttribute : Attribute, IAuthorizationFilter
|
||||
{
|
||||
public const string HeaderName = "X-Admin-Secret";
|
||||
|
||||
public void OnAuthorization(AuthorizationFilterContext context)
|
||||
{
|
||||
var services = context.HttpContext.RequestServices;
|
||||
var options = services.GetRequiredService<IOptions<AdminOptions>>().Value;
|
||||
var logger = services.GetRequiredService<ILogger<RequireAdminSecretAttribute>>();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(options.ImportSecret))
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Rejecting request to {Path}: Admin:ImportSecret is not configured. " +
|
||||
"Set it in appsettings (or the NPGSQL_ADMIN__IMPORTSECRET env var) to enable the endpoint.",
|
||||
context.HttpContext.Request.Path);
|
||||
context.Result = new UnauthorizedResult();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!context.HttpContext.Request.Headers.TryGetValue(HeaderName, out var provided)
|
||||
|| provided.Count == 0
|
||||
|| string.IsNullOrEmpty(provided[0]))
|
||||
{
|
||||
context.Result = new UnauthorizedResult();
|
||||
return;
|
||||
}
|
||||
|
||||
var providedBytes = Encoding.UTF8.GetBytes(provided[0]!);
|
||||
var expectedBytes = Encoding.UTF8.GetBytes(options.ImportSecret);
|
||||
if (!CryptographicOperations.FixedTimeEquals(providedBytes, expectedBytes))
|
||||
{
|
||||
context.Result = new UnauthorizedResult();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ using SVSim.Database.Services.Friend;
|
||||
using SVSim.Database.Services.Replay;
|
||||
using SVSim.EmulatedEntrypoint.Configuration;
|
||||
using SVSim.EmulatedEntrypoint.Extensions;
|
||||
using SVSim.EmulatedEntrypoint.Infrastructure;
|
||||
using SVSim.EmulatedEntrypoint.Matching;
|
||||
using SVSim.EmulatedEntrypoint.Middlewares;
|
||||
using SVSim.EmulatedEntrypoint.Security.SteamSessionAuthentication;
|
||||
@@ -64,6 +65,19 @@ public class Program
|
||||
// Disambiguate same-named DTOs across families (e.g. Story.StartRequest vs
|
||||
// BasicPuzzle.StartRequest) by qualifying schema ids with the full type name.
|
||||
c.CustomSchemaIds(t => t.FullName?.Replace("+", "."));
|
||||
|
||||
// Register the X-Admin-Secret shared-secret header so the Swagger UI shows an
|
||||
// Authorize dialog for it. AdminSecretOperationFilter then attaches the requirement
|
||||
// only to endpoints that carry [RequireAdminSecret], keeping the padlock off
|
||||
// ordinary game endpoints.
|
||||
c.AddSecurityDefinition(AdminSecretOperationFilter.SchemeId, new Microsoft.OpenApi.Models.OpenApiSecurityScheme
|
||||
{
|
||||
Name = RequireAdminSecretAttribute.HeaderName,
|
||||
In = Microsoft.OpenApi.Models.ParameterLocation.Header,
|
||||
Type = Microsoft.OpenApi.Models.SecuritySchemeType.ApiKey,
|
||||
Description = "Shared secret for /admin/* endpoints (matches Admin:ImportSecret in appsettings).",
|
||||
});
|
||||
c.OperationFilter<AdminSecretOperationFilter>();
|
||||
});
|
||||
builder.Services.AddHttpLogging(opt =>
|
||||
{
|
||||
@@ -71,6 +85,7 @@ public class Program
|
||||
});
|
||||
|
||||
builder.Services.Configure<DeckOptions>(builder.Configuration.GetSection(DeckOptions.SectionName));
|
||||
builder.Services.Configure<AdminOptions>(builder.Configuration.GetSection(AdminOptions.SectionName));
|
||||
|
||||
#region Database Services
|
||||
|
||||
|
||||
@@ -6,5 +6,10 @@
|
||||
"Microsoft.AspNetCore.HttpLogging.HttpLoggingMiddleware": "Warning",
|
||||
"Microsoft.EntityFrameworkCore": "Warning"
|
||||
}
|
||||
},
|
||||
"Admin": {
|
||||
// Fixed secret so AdminControllerTests can send X-Admin-Secret without going through
|
||||
// a per-test config knob. Tests read it back via AdminControllerTests.TestSecret.
|
||||
"ImportSecret": "test-admin-secret"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,13 @@
|
||||
"Deck": {
|
||||
"MaxDeckSlots": 36
|
||||
},
|
||||
"Admin": {
|
||||
// Shared secret required in the X-Admin-Secret header on /admin/* util endpoints
|
||||
// (see RequireAdminSecretAttribute). Leaving this blank disables the endpoint entirely
|
||||
// — fail-closed so an unconfigured deployment never exposes admin surface. Override with
|
||||
// the ADMIN__IMPORTSECRET env var.
|
||||
"ImportSecret": "replaceme"
|
||||
},
|
||||
"BattleNode": {
|
||||
// Socket.IO v2 endpoint URL echoed to the client on /*/do_matching success.
|
||||
// Wire format: "host[:port]/socket.io/" — no scheme prefix, trailing slash REQUIRED.
|
||||
|
||||
@@ -14,10 +14,12 @@ using SVSim.UnitTests.Infrastructure;
|
||||
namespace SVSim.UnitTests.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end coverage for <c>/admin/import_viewer</c>. The endpoint is [AllowAnonymous] so
|
||||
/// these tests don't need to seed a viewer first; the fresh-user path exercises the just-fixed
|
||||
/// nav-graph NRE inside <c>ViewerRepository.RegisterViewer</c>, and the existing-user path
|
||||
/// exercises the owned-type lookup used to dedupe by Steam id.
|
||||
/// End-to-end coverage for <c>/admin/import_viewer</c>. The endpoint is [AllowAnonymous] +
|
||||
/// [RequireAdminSecret], so tests reach it through <see cref="SVSimTestFactory.CreateAdminClient"/>
|
||||
/// which bakes in the <c>X-Admin-Secret</c> header from <c>appsettings.Testing.json</c>. The
|
||||
/// fresh-user path exercises the nav-graph NRE fix inside <c>ViewerRepository.RegisterViewer</c>;
|
||||
/// the existing-user path exercises the owned-type Steam-id lookup. Negative-path tests at the
|
||||
/// bottom of the file cover the header gate itself.
|
||||
/// </summary>
|
||||
public class AdminControllerTests
|
||||
{
|
||||
@@ -27,7 +29,7 @@ public class AdminControllerTests
|
||||
public async Task ImportViewer_fresh_user_creates_viewer_and_returns_ids()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
using var client = factory.CreateClient();
|
||||
using var client = factory.CreateAdminClient();
|
||||
|
||||
var response = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest
|
||||
{
|
||||
@@ -71,7 +73,7 @@ public class AdminControllerTests
|
||||
const ulong steamId = 76_561_198_555_666_777UL;
|
||||
long seededId = await factory.SeedViewerAsync(steamId: steamId, displayName: "Original Name");
|
||||
|
||||
using var client = factory.CreateClient();
|
||||
using var client = factory.CreateAdminClient();
|
||||
var response = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest
|
||||
{
|
||||
SteamId = steamId,
|
||||
@@ -107,7 +109,7 @@ public class AdminControllerTests
|
||||
public async Task ImportViewer_missing_steam_id_returns_400()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
using var client = factory.CreateClient();
|
||||
using var client = factory.CreateAdminClient();
|
||||
|
||||
var response = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest
|
||||
{
|
||||
@@ -122,7 +124,7 @@ public class AdminControllerTests
|
||||
public async Task ImportViewer_imports_owned_cards_and_skips_unknown()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
using var client = factory.CreateClient();
|
||||
using var client = factory.CreateAdminClient();
|
||||
|
||||
// 10001001 is in the minimal test card set; 99999999 is not.
|
||||
var response = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest
|
||||
@@ -156,7 +158,7 @@ public class AdminControllerTests
|
||||
public async Task ImportViewer_clamps_card_count_to_max_copies()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
using var client = factory.CreateClient();
|
||||
using var client = factory.CreateAdminClient();
|
||||
|
||||
var response = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest
|
||||
{
|
||||
@@ -181,7 +183,7 @@ public class AdminControllerTests
|
||||
long viewerId = await factory.SeedViewerAsync(steamId: steamId);
|
||||
await factory.SeedOwnedCardAsync(viewerId, 10001001L, count: 3);
|
||||
|
||||
using var client = factory.CreateClient();
|
||||
using var client = factory.CreateAdminClient();
|
||||
var response = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest
|
||||
{
|
||||
SteamId = steamId,
|
||||
@@ -207,7 +209,7 @@ public class AdminControllerTests
|
||||
// Registers the ItemEntry master row (70001) and gives an initial owned count to be replaced.
|
||||
await factory.SeedOwnedItemAsync(viewerId, itemId: 70001, count: 1);
|
||||
|
||||
using var client = factory.CreateClient();
|
||||
using var client = factory.CreateAdminClient();
|
||||
var response = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest
|
||||
{
|
||||
SteamId = steamId,
|
||||
@@ -242,7 +244,7 @@ public class AdminControllerTests
|
||||
leaderSkinId = (await db.LeaderSkins.FirstAsync()).Id;
|
||||
}
|
||||
|
||||
using var client = factory.CreateClient();
|
||||
using var client = factory.CreateAdminClient();
|
||||
var response = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest
|
||||
{
|
||||
SteamId = steamId,
|
||||
@@ -294,7 +296,7 @@ public class AdminControllerTests
|
||||
leaderSkinId = (await db.LeaderSkins.FirstAsync()).Id;
|
||||
}
|
||||
|
||||
using var client = factory.CreateClient();
|
||||
using var client = factory.CreateAdminClient();
|
||||
var response = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest
|
||||
{
|
||||
SteamId = steamId,
|
||||
@@ -329,7 +331,7 @@ public class AdminControllerTests
|
||||
public async Task ImportViewer_fresh_user_has_no_decks_when_none_imported()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
using var client = factory.CreateClient();
|
||||
using var client = factory.CreateAdminClient();
|
||||
|
||||
var response = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest
|
||||
{
|
||||
@@ -380,7 +382,7 @@ public class AdminControllerTests
|
||||
}
|
||||
""";
|
||||
|
||||
using var client = factory.CreateClient();
|
||||
using var client = factory.CreateAdminClient();
|
||||
using var content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
|
||||
var response = await client.PostAsync("/admin/import_viewer", content);
|
||||
|
||||
@@ -438,7 +440,7 @@ public class AdminControllerTests
|
||||
}
|
||||
""";
|
||||
|
||||
using var client = factory.CreateClient();
|
||||
using var client = factory.CreateAdminClient();
|
||||
using var content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
|
||||
var response = await client.PostAsync("/admin/import_viewer", content);
|
||||
|
||||
@@ -457,7 +459,7 @@ public class AdminControllerTests
|
||||
public async Task ImportViewer_MissionMeta_RoundTrips()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
var client = factory.CreateClient();
|
||||
var client = factory.CreateAdminClient();
|
||||
ulong steamId = 70000000000000001UL;
|
||||
|
||||
var resp = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest
|
||||
@@ -501,7 +503,7 @@ public class AdminControllerTests
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
var client = factory.CreateClient();
|
||||
var client = factory.CreateAdminClient();
|
||||
ulong steamId = 70000000000000002UL;
|
||||
|
||||
var resp = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest
|
||||
@@ -532,7 +534,7 @@ public class AdminControllerTests
|
||||
public async Task ImportViewer_Missions_UnknownMissionIdSkipped()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
var client = factory.CreateClient();
|
||||
var client = factory.CreateAdminClient();
|
||||
ulong steamId = 70000000000000003UL;
|
||||
|
||||
var resp = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest
|
||||
@@ -566,7 +568,7 @@ public class AdminControllerTests
|
||||
});
|
||||
await seedDb.SaveChangesAsync();
|
||||
}
|
||||
var client = factory.CreateClient();
|
||||
var client = factory.CreateAdminClient();
|
||||
ulong steamId = 70000000000000004UL;
|
||||
|
||||
var resp = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest
|
||||
@@ -604,7 +606,7 @@ public class AdminControllerTests
|
||||
new MissionCatalogEntry { Id = 9004, LotType = 2, EventType = "battle_win_total", EventArg = null });
|
||||
await seedDb.SaveChangesAsync();
|
||||
}
|
||||
var client = factory.CreateClient();
|
||||
var client = factory.CreateAdminClient();
|
||||
ulong steamId = 70000000000000005UL;
|
||||
|
||||
var resp = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest
|
||||
@@ -651,7 +653,7 @@ public class AdminControllerTests
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
var client = factory.CreateClient();
|
||||
var client = factory.CreateAdminClient();
|
||||
ulong steamId = 70000000000000007UL;
|
||||
|
||||
var resp = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest
|
||||
@@ -686,7 +688,7 @@ public class AdminControllerTests
|
||||
public async Task ImportViewer_Achievements_UnknownTypeSkipped()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
var client = factory.CreateClient();
|
||||
var client = factory.CreateAdminClient();
|
||||
ulong steamId = 70000000000000008UL;
|
||||
|
||||
var resp = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest
|
||||
@@ -723,7 +725,7 @@ public class AdminControllerTests
|
||||
new AchievementCatalogEntry { AchievementType = 602, Level = 1, EventType = "battle_win_total", EventArg = null });
|
||||
await seedDb.SaveChangesAsync();
|
||||
}
|
||||
var client = factory.CreateClient();
|
||||
var client = factory.CreateAdminClient();
|
||||
ulong steamId = 70000000000000009UL;
|
||||
|
||||
var resp = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest
|
||||
@@ -773,7 +775,7 @@ public class AdminControllerTests
|
||||
});
|
||||
await seedDb.SaveChangesAsync();
|
||||
}
|
||||
var client = factory.CreateClient();
|
||||
var client = factory.CreateAdminClient();
|
||||
ulong steamId = 70000000000000006UL;
|
||||
|
||||
var resp = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest
|
||||
@@ -810,7 +812,7 @@ public class AdminControllerTests
|
||||
db.StoryWorlds.Add(new StoryWorld { Id = 20_000_005 }); // Event
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
var client = factory.CreateClient();
|
||||
var client = factory.CreateAdminClient();
|
||||
ulong steamId = 70000000000000007UL;
|
||||
|
||||
var resp = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest
|
||||
@@ -849,7 +851,7 @@ public class AdminControllerTests
|
||||
db.StoryWorlds.Add(new StoryWorld { Id = 11 }); // sub_chapter (own row)
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
var client = factory.CreateClient();
|
||||
var client = factory.CreateAdminClient();
|
||||
ulong steamId = 70000000000000008UL;
|
||||
|
||||
var resp = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest
|
||||
@@ -877,7 +879,7 @@ public class AdminControllerTests
|
||||
public async Task ImportViewer_StoryProgress_UnknownStoryIdSkipped()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
var client = factory.CreateClient();
|
||||
var client = factory.CreateAdminClient();
|
||||
ulong steamId = 70000000000000009UL;
|
||||
|
||||
var resp = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest
|
||||
@@ -913,7 +915,7 @@ public class AdminControllerTests
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var client = factory.CreateClient();
|
||||
var client = factory.CreateAdminClient();
|
||||
const ulong steamId = 70000000000000020UL;
|
||||
|
||||
// First POST: both missions present (explicit slots to avoid the (ViewerId,Slot) unique index).
|
||||
@@ -980,7 +982,7 @@ public class AdminControllerTests
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var client = factory.CreateClient();
|
||||
var client = factory.CreateAdminClient();
|
||||
const ulong steamId = 70000000000000021UL;
|
||||
|
||||
// First POST: both achievements present.
|
||||
@@ -1043,7 +1045,7 @@ public class AdminControllerTests
|
||||
db.StoryWorlds.Add(new StoryWorld { Id = 50 });
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
var client = factory.CreateClient();
|
||||
var client = factory.CreateAdminClient();
|
||||
ulong steamId = 70000000000000010UL;
|
||||
|
||||
var req = new ImportViewerRequest
|
||||
@@ -1069,4 +1071,35 @@ public class AdminControllerTests
|
||||
Assert.That(verifyDb.ViewerStoryProgress.Count(p => p.ViewerId == viewerId), Is.EqualTo(1));
|
||||
Assert.That(verifyDb.ViewerEventCounters.Count(c => c.ViewerId == viewerId), Is.EqualTo(2));
|
||||
}
|
||||
|
||||
// --- X-Admin-Secret gate ---
|
||||
|
||||
[Test]
|
||||
public async Task ImportViewer_without_secret_header_returns_401()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
using var client = factory.CreateClient(); // no X-Admin-Secret header
|
||||
|
||||
var response = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest
|
||||
{
|
||||
SteamId = 76_561_198_999_999_001UL
|
||||
});
|
||||
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ImportViewer_with_wrong_secret_returns_401()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
using var client = factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-Admin-Secret", "not-the-real-secret");
|
||||
|
||||
var response = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest
|
||||
{
|
||||
SteamId = 76_561_198_999_999_002UL
|
||||
});
|
||||
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -397,6 +397,20 @@ internal class SVSimTestFactory : WebApplicationFactory<Program>
|
||||
return client;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shared secret baked into <c>appsettings.Testing.json</c> for <c>/admin/*</c> gating. Kept
|
||||
/// here so tests can construct requests with or without a valid header via the same source.
|
||||
/// </summary>
|
||||
public const string AdminSecret = "test-admin-secret";
|
||||
|
||||
/// <summary>Convenience: bake the X-Admin-Secret header into a fresh client.</summary>
|
||||
public HttpClient CreateAdminClient()
|
||||
{
|
||||
var client = CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-Admin-Secret", AdminSecret);
|
||||
return client;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Inserts a deck for the viewer via the real <see cref="IDeckRepository.UpsertDeck"/>
|
||||
/// path. Picks the first seeded class/sleeve/leader-skin from the master tables; tests
|
||||
|
||||
Reference in New Issue
Block a user