b52d4000b4
PR Checks / test-and-build (pull_request) Successful in 11m56s
Use the existing group_managers.created_at column when picking owner profile links for public pages. Bump version -> 3.5.1
201 lines
10 KiB
C#
201 lines
10 KiB
C#
namespace GmRelay.Bot.Tests.Web;
|
|
|
|
public sealed class MasterProfilesTests
|
|
{
|
|
[Fact]
|
|
public async Task MigrationV028_ShouldAddMasterProfilesWithoutExternalIdentifiers()
|
|
{
|
|
var migration = await ReadRepositoryFileAsync("src/GmRelay.Bot/Migrations/V028__add_master_profiles.sql");
|
|
|
|
Assert.Contains("CREATE TABLE master_profiles", migration, StringComparison.Ordinal);
|
|
Assert.Contains("player_id", migration, StringComparison.Ordinal);
|
|
Assert.Contains("public_slug", migration, StringComparison.Ordinal);
|
|
Assert.Contains("is_public", migration, StringComparison.Ordinal);
|
|
Assert.Contains("display_name", migration, StringComparison.Ordinal);
|
|
Assert.Contains("bio", migration, StringComparison.Ordinal);
|
|
Assert.Contains("ux_master_profiles_public_slug", migration, StringComparison.Ordinal);
|
|
Assert.DoesNotContain("external_user_id", migration, StringComparison.OrdinalIgnoreCase);
|
|
Assert.DoesNotContain("telegram_id", migration, StringComparison.OrdinalIgnoreCase);
|
|
Assert.DoesNotContain("discord", migration, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SessionStore_ShouldExposeSanitizedMasterProfileContracts()
|
|
{
|
|
var sessionStore = await ReadRepositoryFileAsync("src/GmRelay.Web/Services/ISessionStore.cs");
|
|
|
|
Assert.Contains("MasterProfileSettings", sessionStore, StringComparison.Ordinal);
|
|
Assert.Contains("PublicMasterProfile", sessionStore, StringComparison.Ordinal);
|
|
Assert.Contains("PublicMasterClub", sessionStore, StringComparison.Ordinal);
|
|
Assert.Contains("GetMasterProfileSettingsAsync", sessionStore, StringComparison.Ordinal);
|
|
Assert.Contains("UpdateMasterProfileSettingsAsync", sessionStore, StringComparison.Ordinal);
|
|
Assert.Contains("GetPublicMasterProfileBySlugAsync", sessionStore, StringComparison.Ordinal);
|
|
|
|
var publicProfileSection = RecordSection(sessionStore, "PublicMasterProfile");
|
|
Assert.DoesNotContain("AvatarUrl", publicProfileSection, StringComparison.Ordinal);
|
|
Assert.DoesNotContain("ExternalUserId", publicProfileSection, StringComparison.Ordinal);
|
|
Assert.DoesNotContain("TelegramId", publicProfileSection, StringComparison.Ordinal);
|
|
Assert.DoesNotContain("DiscordId", publicProfileSection, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PublicMasterProfilePage_ShouldBePublicAndHideTechnicalIdentityData()
|
|
{
|
|
var publicProfilePage = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Pages/PublicMasterProfile.razor");
|
|
|
|
Assert.Contains("@page \"/gm/{Slug}\"", publicProfilePage, StringComparison.Ordinal);
|
|
Assert.Contains("@layout PublicLayout", publicProfilePage, StringComparison.Ordinal);
|
|
Assert.Contains("GetPublicMasterProfileBySlugAsync", publicProfilePage, StringComparison.Ordinal);
|
|
Assert.DoesNotContain("@attribute [Authorize]", publicProfilePage, StringComparison.Ordinal);
|
|
Assert.DoesNotContain("ExternalUserId", publicProfilePage, StringComparison.Ordinal);
|
|
Assert.DoesNotContain("TelegramId", publicProfilePage, StringComparison.Ordinal);
|
|
Assert.DoesNotContain("DiscordId", publicProfilePage, StringComparison.Ordinal);
|
|
Assert.DoesNotContain("AvatarUrl", publicProfilePage, StringComparison.Ordinal);
|
|
Assert.DoesNotContain("LinkedIdentity", publicProfilePage, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ProfilePage_ShouldManageMasterProfilePublication()
|
|
{
|
|
var profilePage = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Pages/Profile.razor");
|
|
var authorizedService = await ReadRepositoryFileAsync("src/GmRelay.Web/Services/AuthorizedSessionService.cs");
|
|
|
|
Assert.Contains("GetMasterProfileSettingsForCurrentUserAsync", profilePage, StringComparison.Ordinal);
|
|
Assert.Contains("UpdateMasterProfileSettingsForCurrentUserAsync", profilePage, StringComparison.Ordinal);
|
|
Assert.Contains("masterProfileModel", profilePage, StringComparison.Ordinal);
|
|
Assert.Contains("PublicMasterProfileUrl", profilePage, StringComparison.Ordinal);
|
|
Assert.Contains("NormalizeMasterProfileSlug", authorizedService, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task TelegramLoginEndpoints_ShouldUpsertPlayersForProfileManagement()
|
|
{
|
|
var program = await ReadRepositoryFileAsync("src/GmRelay.Web/Program.cs");
|
|
|
|
Assert.Contains("ISessionStore sessionStore", EndpointSection(program, "auth/telegram-webapp"), StringComparison.Ordinal);
|
|
Assert.Contains("sessionStore.UpsertPlayerAsync", EndpointSection(program, "auth/telegram-webapp"), StringComparison.Ordinal);
|
|
Assert.Contains("ISessionStore sessionStore", EndpointSection(program, "auth/telegram-login"), StringComparison.Ordinal);
|
|
Assert.Contains("sessionStore.UpsertPlayerAsync", EndpointSection(program, "auth/telegram-login"), StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PublicGamePages_ShouldLinkPublishedMasterProfilesWithoutPrivateIds()
|
|
{
|
|
var publicClubPage = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Pages/PublicClub.razor");
|
|
var publicSessionPage = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Pages/PublicSession.razor");
|
|
var showcasePage = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Pages/Showcase.razor");
|
|
var sessionService = await ReadRepositoryFileAsync("src/GmRelay.Web/Services/SessionService.cs");
|
|
|
|
Assert.Contains("MasterProfileSlug", publicClubPage, StringComparison.Ordinal);
|
|
Assert.Contains("MasterProfileSlug", publicSessionPage, StringComparison.Ordinal);
|
|
Assert.Contains("MasterProfileSlug", showcasePage, StringComparison.Ordinal);
|
|
Assert.Contains("master_profiles", sessionService, StringComparison.Ordinal);
|
|
Assert.DoesNotContain("targetExternalUserId", PublicQuerySection(sessionService), StringComparison.Ordinal);
|
|
Assert.DoesNotContain("target_platform", PublicQuerySection(sessionService), StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PublicMasterProfileQueries_ShouldIncludeCoGmManagedPublishedGames()
|
|
{
|
|
var sessionService = await ReadRepositoryFileAsync("src/GmRelay.Web/Services/SessionService.cs");
|
|
|
|
var clubsQuery = MethodSection(sessionService, "GetPublicClubsForMasterAsync");
|
|
var sessionsQuery = MethodSection(sessionService, "GetPublicSessionsForMasterAsync");
|
|
|
|
Assert.Contains("JOIN group_managers gm", clubsQuery, StringComparison.Ordinal);
|
|
Assert.Contains("JOIN group_managers gm", sessionsQuery, StringComparison.Ordinal);
|
|
Assert.DoesNotContain("gm.role = @OwnerRole", clubsQuery, StringComparison.Ordinal);
|
|
Assert.DoesNotContain("gm.role = @OwnerRole", sessionsQuery, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PublicOwnerProfileLinks_ShouldOrderByExistingGroupManagerTimestamp()
|
|
{
|
|
var migration = await ReadRepositoryFileAsync("src/GmRelay.Bot/Migrations/V008__add_group_managers.sql");
|
|
var sessionService = await ReadRepositoryFileAsync("src/GmRelay.Web/Services/SessionService.cs");
|
|
|
|
Assert.Contains("created_at", migration, StringComparison.Ordinal);
|
|
Assert.DoesNotContain("added_at", migration, StringComparison.Ordinal);
|
|
Assert.DoesNotContain("gm.added_at", PublicQuerySection(sessionService), StringComparison.Ordinal);
|
|
Assert.Contains("gm.created_at", PublicQuerySection(sessionService), StringComparison.Ordinal);
|
|
}
|
|
|
|
private static string RecordSection(string source, string recordName)
|
|
{
|
|
var start = source.IndexOf($"record {recordName}", StringComparison.Ordinal);
|
|
if (start < 0)
|
|
return string.Empty;
|
|
|
|
var end = source.IndexOf(");", start, StringComparison.Ordinal);
|
|
return end < 0 ? source[start..] : source[start..(end + 2)];
|
|
}
|
|
|
|
private static string PublicQuerySection(string source)
|
|
{
|
|
var start = source.IndexOf("GetPublicMasterProfileBySlugAsync", StringComparison.Ordinal);
|
|
if (start < 0)
|
|
return string.Empty;
|
|
|
|
var end = source.IndexOf("// --- Identity linking", start, StringComparison.Ordinal);
|
|
return end < 0 ? source[start..] : source[start..end];
|
|
}
|
|
|
|
private static string MethodSection(string source, string methodName)
|
|
{
|
|
var start = -1;
|
|
var searchFrom = 0;
|
|
while (searchFrom < source.Length)
|
|
{
|
|
var candidate = source.IndexOf(methodName, searchFrom, StringComparison.Ordinal);
|
|
if (candidate < 0)
|
|
return string.Empty;
|
|
|
|
var lineStart = source.LastIndexOf('\n', candidate);
|
|
var headerStart = lineStart < 0 ? 0 : lineStart + 1;
|
|
var header = source[headerStart..candidate];
|
|
if (header.Contains("private static async Task", StringComparison.Ordinal))
|
|
{
|
|
start = candidate;
|
|
break;
|
|
}
|
|
|
|
searchFrom = candidate + methodName.Length;
|
|
}
|
|
|
|
var nextMethod = source.IndexOf("\n private static async Task", start + methodName.Length, StringComparison.Ordinal);
|
|
if (nextMethod < 0)
|
|
{
|
|
nextMethod = source.IndexOf("\n public async Task", start + methodName.Length, StringComparison.Ordinal);
|
|
}
|
|
|
|
return nextMethod < 0 ? source[start..] : source[start..nextMethod];
|
|
}
|
|
|
|
private static string EndpointSection(string source, string route)
|
|
{
|
|
var start = source.IndexOf($"\"/{route}\"", StringComparison.Ordinal);
|
|
if (start < 0)
|
|
return string.Empty;
|
|
|
|
var nextEndpoint = source.IndexOf("app.Map", start + route.Length, StringComparison.Ordinal);
|
|
return nextEndpoint < 0 ? source[start..] : source[start..nextEndpoint];
|
|
}
|
|
|
|
private static async Task<string> ReadRepositoryFileAsync(string relativePath)
|
|
{
|
|
var directory = new DirectoryInfo(AppContext.BaseDirectory);
|
|
while (directory is not null)
|
|
{
|
|
var candidate = Path.Combine(directory.FullName, relativePath);
|
|
if (File.Exists(candidate))
|
|
{
|
|
return await File.ReadAllTextAsync(candidate);
|
|
}
|
|
|
|
directory = directory.Parent;
|
|
}
|
|
|
|
throw new FileNotFoundException($"Could not locate repository file '{relativePath}'.");
|
|
}
|
|
}
|