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:
gamer147
2026-07-04 18:24:59 -04:00
parent 7a13e99df7
commit c8e4bda6af
9 changed files with 223 additions and 33 deletions

View File

@@ -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));
}
}