mirror of
https://github.com/JogmansonReal/Radicade.git
synced 2026-10-25 15:09:26 -07:00
1817 lines
80 KiB
C#
1817 lines
80 KiB
C#
using System.IdentityModel.Tokens.Jwt;
|
|
using System.Security.Claims;
|
|
using System.Text;
|
|
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.Configuration;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Microsoft.IdentityModel.Tokens;
|
|
using RadicadeServer;
|
|
using RadicadeServer.API.Accounts;
|
|
using RadicadeServer.Auth;
|
|
using RadicadeServer.API.Moderation;
|
|
using RadicadeServer.Auth.Cached;
|
|
using RadicadeServer.DB;
|
|
using RadicadeServer.RecNet.Auth;
|
|
using RadicadeServer.RecNet.Auth.Cached;
|
|
using RadicadeServer.Extensions;
|
|
using RadicadeServer.RecNet.Config;
|
|
using RadicadeServer.RecNet.Progression;
|
|
using RadicadeServer.RecNet.Accounts;
|
|
using RadicadeServer.RecNet.Accounts.Local;
|
|
using RadicadeServer.RecNet.Auth.Connection;
|
|
using RadicadeServer.RecNet.Enums.Players;
|
|
using RadicadeServer.RecNet.Moderation;
|
|
using RadicadeServer.RecNet.Responses;
|
|
using RadicadeServer.Utils;
|
|
using Xunit;
|
|
|
|
namespace RadicadeServer.Tests;
|
|
|
|
[Collection(DbFactoryCollection.Name)]
|
|
public class AuthOnboardingFlowTests
|
|
{
|
|
private const string PlatformId = "76561198000000000";
|
|
private const string DeviceId = "device-onboarding-flow";
|
|
|
|
[Theory]
|
|
[InlineData("cached_login")]
|
|
[InlineData("refresh_token")]
|
|
[InlineData("create_account")]
|
|
public async Task StandaloneCannotAuthenticateWithOnlyDeviceOrPlatformIdentity(string grant)
|
|
{
|
|
await using var db = CreateDb();
|
|
await SeedLinkedAccountAsync(db, true, true);
|
|
var request = RequestData();
|
|
request.platform = Platforms.Standalone;
|
|
request.grant_type = grant;
|
|
request.account_id = 17;
|
|
request.refresh_token = Guid.NewGuid().ToString();
|
|
var api = CreateApi(db);
|
|
api.Request.Headers.Authorization = "Bearer invalid";
|
|
Assert.IsType<UnauthorizedObjectResult>((await api.ConnectWithToken(request)).Result);
|
|
Assert.Single(await db.CachedLogins.ToListAsync());
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(true)]
|
|
[InlineData(false)]
|
|
public async Task StandalonePasswordResolvesAccountAndDoesNotTrustPostedIdentity(bool correctPassword)
|
|
{
|
|
var oldKey = DBConfig.JwtKey;
|
|
DBConfig.JwtKey = "standalone-test-signing-key-with-sufficient-length";
|
|
try
|
|
{
|
|
await using var db = CreateDb();
|
|
await SeedLinkedAccountAsync(db, true, true);
|
|
var player = await db.Players.SingleAsync();
|
|
player.Hashword = new Microsoft.AspNetCore.Identity.PasswordHasher<PlayerData>()
|
|
.HashPassword(player, "test-password-quest");
|
|
await db.SaveChangesAsync();
|
|
var request = RequestData();
|
|
request.platform = Platforms.Standalone;
|
|
request.grant_type = "password";
|
|
request.account_id = 123456;
|
|
request.username = "ExistingPlayer";
|
|
request.password = correctPassword ? "test-password-quest" : "incorrect-password";
|
|
var api = CreateApi(db);
|
|
api.Request.Headers.Authorization = "Bearer invalid";
|
|
var result = await api.ConnectWithToken(request);
|
|
var response = Assert.IsType<AuthResponse>(Assert.IsType<OkObjectResult>(result.Result).Value);
|
|
if (!correctPassword)
|
|
{
|
|
Assert.Null(response.access_token);
|
|
Assert.NotEmpty(response.error);
|
|
Assert.DoesNotContain(await db.CachedLogins.ToListAsync(), x => x.platform == Platforms.Standalone);
|
|
return;
|
|
}
|
|
Assert.Empty(response.error);
|
|
Assert.False(string.IsNullOrWhiteSpace(response.access_token));
|
|
var token = new JwtSecurityTokenHandler().ReadJwtToken(response.access_token);
|
|
Assert.Equal("17", token.Claims.Single(x => x.Type == "userId").Value);
|
|
Assert.Equal("7", token.Claims.Single(x => x.Type == "rn.plat").Value);
|
|
Assert.Equal(17, request.account_id);
|
|
Assert.Equal("radicade-quest:" + DeviceId, request.platform_id);
|
|
Assert.Contains(await db.CachedLogins.ToListAsync(), x =>
|
|
x.accountId == 17 && x.platform == Platforms.Standalone && x.platformId == request.platform_id);
|
|
var lookup = new CachedLoginAPI(db);
|
|
var list = Assert.IsType<OkObjectResult>(await lookup.GetCachedLoginsForPlatformId(Platforms.Standalone, request.platform_id));
|
|
Assert.Empty(Assert.IsType<List<RadicadeServer.RecNet.Auth.Cached.CachedLogin>>(list.Value));
|
|
Assert.Empty(await lookup.GetCachedLoginsForPlatformIds(new[] { request.platform_id }));
|
|
}
|
|
finally { DBConfig.JwtKey = oldKey; }
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("valid", true)]
|
|
[InlineData("tampered", false)]
|
|
[InlineData("device", false)]
|
|
[InlineData("account", false)]
|
|
[InlineData("revoked", false)]
|
|
[InlineData("password_changed", false)]
|
|
[InlineData("expired", false)]
|
|
[InlineData("platform", false)]
|
|
public async Task QuestRememberedLoginRequiresBoundSignedSession(string scenario, bool accepted)
|
|
{
|
|
var oldKey = DBConfig.JwtKey;
|
|
DBConfig.JwtKey = "quest-remembered-test-signing-key-with-sufficient-length";
|
|
try
|
|
{
|
|
await using var db = CreateDb();
|
|
await SeedLinkedAccountAsync(db, true, true);
|
|
var player = await db.Players.SingleAsync();
|
|
player.Hashword = new Microsoft.AspNetCore.Identity.PasswordHasher<PlayerData>()
|
|
.HashPassword(player, "quest-session-test-password");
|
|
await db.SaveChangesAsync();
|
|
var request = RequestData();
|
|
request.platform = Platforms.Standalone;
|
|
request.grant_type = "password";
|
|
request.username = "ExistingPlayer";
|
|
request.password = "quest-session-test-password";
|
|
var login = CreateApi(db);
|
|
var initial = Assert.IsType<AuthResponse>(Assert.IsType<OkObjectResult>(
|
|
(await login.ConnectWithToken(request)).Result).Value);
|
|
Assert.False(string.IsNullOrEmpty(initial.access_token));
|
|
var session = login.Response.Headers["X-Radicade-Quest-Session"].ToString();
|
|
Assert.Equal(initial.access_token, session);
|
|
if (scenario is "expired" or "platform")
|
|
{
|
|
var original = new JwtSecurityTokenHandler().ReadJwtToken(session);
|
|
var claims = original.Claims.Where(c => c.Type is not "exp" and not "nbf" and not "iat" and not "iss").ToList();
|
|
if (scenario == "platform")
|
|
{
|
|
claims.RemoveAll(c => c.Type == "rn.plat");
|
|
claims.Add(new Claim("rn.plat", "1"));
|
|
}
|
|
session = new JwtSecurityTokenHandler().WriteToken(new JwtSecurityToken(
|
|
issuer: DBConfig.TokenIssuer, claims: claims,
|
|
notBefore: DateTime.UtcNow.AddDays(-2),
|
|
expires: scenario == "expired" ? DateTime.UtcNow.AddDays(-1) : DateTime.UtcNow.AddDays(1),
|
|
signingCredentials: new SigningCredentials(new SymmetricSecurityKey(Encoding.ASCII.GetBytes(DBConfig.JwtKey)), SecurityAlgorithms.HmacSha256)));
|
|
}
|
|
|
|
var resume = RequestData();
|
|
resume.platform = Platforms.Standalone;
|
|
resume.grant_type = "cached_login";
|
|
resume.account_id = scenario == "account" ? 999 : 0;
|
|
if (scenario == "device") resume.device_id = "different-headset";
|
|
if (scenario == "revoked") player.TokenVersion++;
|
|
if (scenario == "password_changed") player.Hashword = "different-password-hash";
|
|
await db.SaveChangesAsync();
|
|
var api = CreateApi(db);
|
|
api.Request.Headers["X-Radicade-Quest-Session"] = scenario == "tampered" ? "invalid." + session : session;
|
|
var result = (await api.ConnectWithToken(resume)).Result;
|
|
if (!accepted) Assert.IsType<UnauthorizedObjectResult>(result);
|
|
else
|
|
{
|
|
var response = Assert.IsType<AuthResponse>(Assert.IsType<OkObjectResult>(result).Value);
|
|
Assert.False(string.IsNullOrEmpty(response.access_token));
|
|
Assert.Equal(17, resume.account_id);
|
|
}
|
|
}
|
|
finally { DBConfig.JwtKey = oldKey; }
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CachedLoginForUnknownPlatformIdentityIsNotCreatedByTheLoginAttempt()
|
|
{
|
|
var previousJwtKey = DBConfig.JwtKey;
|
|
var previousSteamApiKey = DBConfig.SteamApiKey;
|
|
DBConfig.JwtKey = "unit-test-signing-key-with-sufficient-length";
|
|
DBConfig.SteamApiKey = "unit-test-steam-api-key";
|
|
try
|
|
{
|
|
await using var db = CreateDb();
|
|
await SeedLinkedAccountAsync(db, flowFinished: true, complete: true);
|
|
var request = RequestData();
|
|
request.grant_type = "cached_login";
|
|
request.account_id = 17;
|
|
request.platform_id = "76561198999999999";
|
|
var api = CreateApi(db, request.platform_id);
|
|
|
|
var result = await api.ConnectWithToken(request);
|
|
|
|
var refusal = Assert.IsType<ObjectResult>(result.Result);
|
|
Assert.Equal(400, refusal.StatusCode);
|
|
var body = Assert.IsType<AuthResponse>(refusal.Value);
|
|
Assert.Equal("invalid_grant", body.error);
|
|
Assert.Equal("no linked account for this platform identity", body.error_description);
|
|
Assert.Single(await db.CachedLogins.ToListAsync());
|
|
Assert.DoesNotContain(await db.CachedLogins.ToListAsync(), login =>
|
|
login.platform == Platforms.Steam && login.platformId == request.platform_id);
|
|
}
|
|
finally
|
|
{
|
|
DBConfig.JwtKey = previousJwtKey;
|
|
DBConfig.SteamApiKey = previousSteamApiKey;
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task TitleScreenLoginIsRefusedWhenItNamesNoAccount()
|
|
{
|
|
var previousJwtKey = DBConfig.JwtKey;
|
|
var previousSteamApiKey = DBConfig.SteamApiKey;
|
|
var previousPlayerFactory = PlayerUtils.DbFactory;
|
|
var previousRoomFactory = RoomUtils.DbFactory;
|
|
var factory = new TestDbContextFactory();
|
|
DBConfig.JwtKey = "unit-test-signing-key-with-sufficient-length";
|
|
DBConfig.SteamApiKey = "unit-test-steam-api-key";
|
|
PlayerUtils.DbFactory = factory;
|
|
RoomUtils.DbFactory = factory;
|
|
|
|
try
|
|
{
|
|
await using var db = factory.CreateDbContext();
|
|
db.Profiles.Add(new Profile
|
|
{
|
|
accountId = 1,
|
|
profileImage = "default",
|
|
username = "SystemAccount",
|
|
displayName = "SystemAccount",
|
|
createdAt = DateTime.UtcNow
|
|
});
|
|
await db.SaveChangesAsync();
|
|
|
|
var api = CreateApi(db, PlatformId);
|
|
var result = await api.ConnectWithToken(RequestData());
|
|
|
|
var refusalResult = Assert.IsType<ObjectResult>(result.Result);
|
|
Assert.Equal(400, refusalResult.StatusCode);
|
|
var refusal = Assert.IsType<AuthResponse>(refusalResult.Value);
|
|
Assert.Equal("invalid_request", refusal.error);
|
|
Assert.True(string.IsNullOrWhiteSpace(refusal.access_token));
|
|
|
|
var unknown = RequestData();
|
|
unknown.account_id = 4242;
|
|
var unknownResult = await api.ConnectWithToken(unknown);
|
|
var unknownRefusal = Assert.IsType<ObjectResult>(unknownResult.Result);
|
|
Assert.Equal(400, unknownRefusal.StatusCode);
|
|
Assert.Equal("invalid_grant", Assert.IsType<AuthResponse>(unknownRefusal.Value).error);
|
|
|
|
Assert.Empty(await db.Players.ToListAsync());
|
|
Assert.Empty(await db.CachedLogins.ToListAsync());
|
|
Assert.Single(await db.Profiles.ToListAsync());
|
|
}
|
|
finally
|
|
{
|
|
DBConfig.JwtKey = previousJwtKey;
|
|
DBConfig.SteamApiKey = previousSteamApiKey;
|
|
PlayerUtils.DbFactory = previousPlayerFactory;
|
|
RoomUtils.DbFactory = previousRoomFactory;
|
|
}
|
|
}
|
|
|
|
private sealed class TestAuthAPI : AuthAPI
|
|
{
|
|
private readonly string? authenticatedSteamId;
|
|
|
|
public TestAuthAPI(RecDBContext db, string? authenticatedSteamId)
|
|
: base(db)
|
|
{
|
|
this.authenticatedSteamId = authenticatedSteamId;
|
|
}
|
|
|
|
protected override Task<string?> AuthenticateSteamTicketAsync(string ticket, string appId, CancellationToken cancellationToken)
|
|
{
|
|
Assert.Equal("140test-ticket", ticket);
|
|
Assert.Equal("471710", appId);
|
|
return Task.FromResult(authenticatedSteamId);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// AuthAPI with no override of the ticket check, so a test exercises the retry the server
|
|
/// actually runs rather than the harness's fixed answer.
|
|
/// </summary>
|
|
private sealed class RealSteamTicketAuthAPI : AuthAPI
|
|
{
|
|
public RealSteamTicketAuthAPI(RecDBContext db)
|
|
: base(db)
|
|
{
|
|
}
|
|
}
|
|
|
|
private sealed class TestDbContextFactory : IDbContextFactory<RecDBContext>
|
|
{
|
|
private readonly DbContextOptions<RecDBContext> options = TestDbOptions.Create()
|
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
|
.Options;
|
|
|
|
public RecDBContext CreateDbContext() => new(options);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task FirstRunGetsNoSessionUntilTheOnboardingFlowSignsUp()
|
|
{
|
|
var previousJwtKey = DBConfig.JwtKey;
|
|
var previousSteamApiKey = DBConfig.SteamApiKey;
|
|
var previousPlayerFactory = PlayerUtils.DbFactory;
|
|
var previousRoomFactory = RoomUtils.DbFactory;
|
|
var factory = new TestDbContextFactory();
|
|
DBConfig.JwtKey = "unit-test-signing-key-with-sufficient-length";
|
|
DBConfig.SteamApiKey = "unit-test-steam-api-key";
|
|
PlayerUtils.DbFactory = factory;
|
|
RoomUtils.DbFactory = factory;
|
|
|
|
try
|
|
{
|
|
await using var db = factory.CreateDbContext();
|
|
db.Profiles.Add(new Profile
|
|
{
|
|
accountId = 1,
|
|
profileImage = "default",
|
|
username = "SystemAccount",
|
|
displayName = "SystemAccount",
|
|
createdAt = DateTime.UtcNow
|
|
});
|
|
await db.SaveChangesAsync();
|
|
|
|
var lookupApi = new CachedLoginAPI(db)
|
|
{
|
|
ControllerContext = CreateControllerContext()
|
|
};
|
|
var lookup = Assert.IsType<OkObjectResult>(
|
|
await lookupApi.GetCachedLoginsForPlatformId(Platforms.Steam, PlatformId));
|
|
Assert.Empty(Assert.IsAssignableFrom<IEnumerable<CachedLogin>>(lookup.Value));
|
|
Assert.Empty(await db.Players.ToListAsync());
|
|
Assert.Empty(await db.CachedLogins.ToListAsync());
|
|
|
|
var authApi = CreateApi(db, PlatformId);
|
|
|
|
var refused = await authApi.ConnectWithToken(RequestData());
|
|
var refusalResult = Assert.IsType<ObjectResult>(refused.Result);
|
|
Assert.Equal(400, refusalResult.StatusCode);
|
|
var refusal = Assert.IsType<AuthResponse>(refusalResult.Value);
|
|
Assert.Equal("invalid_request", refusal.error);
|
|
Assert.True(string.IsNullOrWhiteSpace(refusal.access_token));
|
|
Assert.Empty(await db.Players.ToListAsync());
|
|
Assert.Empty(await db.CachedLogins.ToListAsync());
|
|
Assert.Single(await db.Profiles.ToListAsync());
|
|
|
|
// The sign-up the flow ends on is what provisions the identity, and it keeps what the
|
|
// player chose - the birthday, name and password its pages collected - rather than
|
|
// replacing them with a generated name and an unset birthday.
|
|
var createRequest = RequestData();
|
|
createRequest.grant_type = "create_account";
|
|
createRequest.username = "ChosenOnboardingName";
|
|
createRequest.password = "onboarding-password";
|
|
createRequest.birthday = new DateTime(1999, 5, 4);
|
|
|
|
var createResult = await authApi.ConnectWithToken(createRequest);
|
|
var response = Assert.IsType<AuthResponse>(Assert.IsType<OkObjectResult>(createResult.Result).Value);
|
|
|
|
Assert.True(string.IsNullOrEmpty(response.error), response.error_description);
|
|
Assert.False(string.IsNullOrWhiteSpace(response.access_token));
|
|
var cachedLogin = await db.CachedLogins.SingleAsync();
|
|
var player = await db.Players.SingleAsync();
|
|
var profile = await db.Profiles.SingleAsync(candidate => candidate.accountId == cachedLogin.accountId);
|
|
var inventory = await db.Unlocks.SingleAsync(candidate => candidate.AccountId == player.AccountId);
|
|
Assert.NotEmpty(inventory.GetAvatarItems());
|
|
var starterDescriptions = RadicadeServer.RecNet.Avatar.AvatarUnlockCatalog.ParseCatalog(
|
|
RadicadeServer.RecNet.Avatar.AvatarUnlockCatalog.LoadStarterJson())
|
|
.Select(item => item.AvatarItemDesc).ToHashSet();
|
|
Assert.All(inventory.GetAvatarItems(), item => Assert.True(item.IsBaseAvatarItem ||
|
|
starterDescriptions.Contains(RadicadeServer.RecNet.Avatar.AvatarItemDescription.Normalize(item.AvatarItemDesc))));
|
|
Assert.Empty(inventory.GetEquipment());
|
|
Assert.Empty(inventory.GetConsumables());
|
|
Assert.Equal(PlatformId, cachedLogin.platformId);
|
|
Assert.Equal(Platforms.Steam, cachedLogin.platform);
|
|
Assert.Equal(cachedLogin.accountId, player.AccountId);
|
|
Assert.Equal(cachedLogin.accountId, profile.accountId);
|
|
Assert.Equal("ChosenOnboardingName", profile.username);
|
|
Assert.Equal("ChosenOnboardingName", profile.displayName);
|
|
Assert.Equal(new DateTime(1999, 5, 4), player.Birthday);
|
|
Assert.False(player.CodeOfConductAccepted);
|
|
Assert.True(PlayerUtils.BuildSelfAccount(profile, player).newPlayer);
|
|
|
|
// The flow's own settings have to say it is under way and how far along: not that it never
|
|
// began (the client would restart it) and not that it finished (the client would skip into
|
|
// the game). The avatar customization and Code of Conduct pages are still ahead of the
|
|
// player, so those stay false until the client itself records them.
|
|
var onboarding = (await db.PlayerSettings.SingleAsync()).DeserializeSettings();
|
|
string OnboardingSetting(string key) => onboarding.Single(setting => setting.Key == key).Value;
|
|
Assert.Equal("True", OnboardingSetting("Recroom.AccountCreation.HasStarted"));
|
|
Assert.Equal("True", OnboardingSetting("Recroom.AccountCreation.HasChosenUsername"));
|
|
Assert.Equal("True", OnboardingSetting("Recroom.AccountCreation.HasCreatedPassword"));
|
|
Assert.Equal("False", OnboardingSetting("Recroom.AccountCreation.HasFinished"));
|
|
Assert.Equal("False", OnboardingSetting("Recroom.AccountCreation.FinishedAvatarCustomization"));
|
|
Assert.Equal("False", OnboardingSetting("Recroom.AccountCreation.AcceptedCodeOfConduct"));
|
|
Assert.Equal("1", OnboardingSetting("Radicade.NeedsOrientation"));
|
|
|
|
// The preliminary GET stays read-only, and now reports the link the sign-up created.
|
|
lookup = Assert.IsType<OkObjectResult>(
|
|
await lookupApi.GetCachedLoginsForPlatformId(Platforms.Steam, PlatformId));
|
|
Assert.Single(Assert.IsAssignableFrom<IEnumerable<CachedLogin>>(lookup.Value));
|
|
|
|
// A sign-up retried after a dropped response has to resolve to the account it already made:
|
|
// a second account for one platform identity would let a player hold two, and a banned one
|
|
// hold a clean one.
|
|
var retryRequest = RequestData();
|
|
retryRequest.grant_type = "create_account";
|
|
var retry = await authApi.ConnectWithToken(retryRequest);
|
|
var retryResponse = Assert.IsType<AuthResponse>(Assert.IsType<OkObjectResult>(retry.Result).Value);
|
|
Assert.True(string.IsNullOrEmpty(retryResponse.error), retryResponse.error_description);
|
|
Assert.False(string.IsNullOrWhiteSpace(retryResponse.access_token));
|
|
Assert.Single(await db.CachedLogins.ToListAsync());
|
|
Assert.Single(await db.Players.ToListAsync());
|
|
Assert.Equal(cachedLogin.accountId, (await db.Players.SingleAsync()).AccountId);
|
|
|
|
var tokenHandler = new JwtSecurityTokenHandler();
|
|
var principal = tokenHandler.ValidateToken(response.access_token, new TokenValidationParameters
|
|
{
|
|
ValidateIssuerSigningKey = true,
|
|
IssuerSigningKey = new SymmetricSecurityKey(Encoding.ASCII.GetBytes(DBConfig.JwtKey)),
|
|
ValidateIssuer = false,
|
|
ValidateAudience = false,
|
|
ClockSkew = TimeSpan.Zero
|
|
}, out _);
|
|
Assert.Equal(cachedLogin.accountId.ToString(), principal.FindFirst(ClaimTypes.NameIdentifier)?.Value);
|
|
|
|
var deviceIdMethod = typeof(PlayerReportingAPI).GetMethod(nameof(PlayerReportingAPI.PostDeviceId))!;
|
|
Assert.Contains(deviceIdMethod.GetCustomAttributes(inherit: true), attribute => attribute is AllowAnonymousAttribute);
|
|
var deviceApi = new PlayerReportingAPI(db)
|
|
{
|
|
ControllerContext = new ControllerContext
|
|
{
|
|
HttpContext = new DefaultHttpContext { User = principal }
|
|
}
|
|
};
|
|
Assert.IsType<OkResult>(await deviceApi.PostDeviceId(new DeviceId
|
|
{
|
|
oldDeviceId = DeviceId,
|
|
newDeviceId = "device-after-authenticated-update"
|
|
}));
|
|
Assert.Equal("device-after-authenticated-update", (await db.Players.SingleAsync()).DeviceId);
|
|
|
|
var anonymousDeviceApi = new PlayerReportingAPI(db)
|
|
{
|
|
ControllerContext = new ControllerContext
|
|
{
|
|
HttpContext = new DefaultHttpContext { User = new ClaimsPrincipal(new ClaimsIdentity()) }
|
|
}
|
|
};
|
|
Assert.IsType<OkResult>(await anonymousDeviceApi.PostDeviceId(new DeviceId
|
|
{
|
|
oldDeviceId = DeviceId,
|
|
newDeviceId = "must-not-overwrite"
|
|
}));
|
|
Assert.Equal("device-after-authenticated-update", (await db.Players.SingleAsync()).DeviceId);
|
|
|
|
var invalidBearerDeviceApi = new PlayerReportingAPI(db)
|
|
{
|
|
ControllerContext = new ControllerContext
|
|
{
|
|
HttpContext = new DefaultHttpContext
|
|
{
|
|
User = new ClaimsPrincipal(new ClaimsIdentity())
|
|
}
|
|
}
|
|
};
|
|
invalidBearerDeviceApi.Request.Headers.Authorization = "Bearer invalid";
|
|
Assert.IsType<UnauthorizedResult>(await invalidBearerDeviceApi.PostDeviceId(new DeviceId
|
|
{
|
|
oldDeviceId = DeviceId,
|
|
newDeviceId = "invalid-bearer-must-not-overwrite"
|
|
}));
|
|
Assert.Equal("device-after-authenticated-update", (await db.Players.SingleAsync()).DeviceId);
|
|
}
|
|
finally
|
|
{
|
|
DBConfig.JwtKey = previousJwtKey;
|
|
DBConfig.SteamApiKey = previousSteamApiKey;
|
|
PlayerUtils.DbFactory = previousPlayerFactory;
|
|
RoomUtils.DbFactory = previousRoomFactory;
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task FirstBootGetsASessionForTheAccountItCreates()
|
|
{
|
|
var previousJwtKey = DBConfig.JwtKey;
|
|
var previousSteamApiKey = DBConfig.SteamApiKey;
|
|
var previousPlayerFactory = PlayerUtils.DbFactory;
|
|
var previousRoomFactory = RoomUtils.DbFactory;
|
|
var factory = new TestDbContextFactory();
|
|
DBConfig.JwtKey = "unit-test-signing-key-with-sufficient-length";
|
|
DBConfig.SteamApiKey = "unit-test-steam-api-key";
|
|
PlayerUtils.DbFactory = factory;
|
|
RoomUtils.DbFactory = factory;
|
|
|
|
try
|
|
{
|
|
await using var db = factory.CreateDbContext();
|
|
db.Profiles.Add(new Profile
|
|
{
|
|
accountId = 17,
|
|
profileImage = "default",
|
|
username = "ReservedAccount",
|
|
displayName = "ReservedAccount",
|
|
createdAt = DateTime.UtcNow
|
|
});
|
|
await db.SaveChangesAsync();
|
|
|
|
var api = CreateApi(db, PlatformId);
|
|
|
|
var boot = RequestData();
|
|
boot.grant_type = "create_account";
|
|
|
|
var first = await api.ConnectWithToken(boot);
|
|
var response = Assert.IsType<AuthResponse>(Assert.IsType<OkObjectResult>(first.Result).Value);
|
|
Assert.True(string.IsNullOrEmpty(response.error), response.error_description);
|
|
Assert.False(string.IsNullOrWhiteSpace(response.access_token));
|
|
|
|
var bootedId = boot.account_id;
|
|
Assert.NotEqual(17, bootedId);
|
|
Assert.NotEqual("ReservedAccount", (await db.Profiles.SingleAsync(x => x.accountId == bootedId)).username);
|
|
|
|
var cachedLogin = await db.CachedLogins.SingleAsync();
|
|
Assert.Equal(bootedId, cachedLogin.accountId);
|
|
Assert.Equal(PlatformId, cachedLogin.platformId);
|
|
|
|
var lookupApi = new CachedLoginAPI(db) { ControllerContext = CreateControllerContext() };
|
|
var lookup = Assert.IsType<OkObjectResult>(
|
|
await lookupApi.GetCachedLoginsForPlatformId(Platforms.Steam, PlatformId));
|
|
var links = Assert.IsAssignableFrom<IEnumerable<CachedLogin>>(lookup.Value).ToList();
|
|
Assert.Single(links);
|
|
Assert.Equal(bootedId, links[0].accountId);
|
|
|
|
var onboarding = (await db.PlayerSettings.SingleAsync(x => x.AccountId == bootedId)).DeserializeSettings();
|
|
string BootSetting(string key) => onboarding.Single(setting => setting.Key == key).Value;
|
|
// The client creates the account here and then walks its creation pages, so this marker
|
|
// has to read true: it is what puts the client into account creation instead of the
|
|
// cached-account route. False here is the state that skipped the welcome screen, the
|
|
// username and every other onboarding step.
|
|
Assert.Equal("True", BootSetting("Recroom.AccountCreation.HasStarted"));
|
|
Assert.Equal("False", BootSetting("Recroom.AccountCreation.HasFinished"));
|
|
Assert.Equal("0", BootSetting("Recroom.OOBE"));
|
|
|
|
var retry = RequestData();
|
|
retry.grant_type = "create_account";
|
|
var again = await api.ConnectWithToken(retry);
|
|
Assert.IsType<OkObjectResult>(again.Result);
|
|
Assert.Equal(bootedId, retry.account_id);
|
|
Assert.Single(await db.Profiles.Where(x => x.accountId != 17).ToListAsync());
|
|
Assert.Single(await db.CachedLogins.ToListAsync());
|
|
}
|
|
finally
|
|
{
|
|
DBConfig.JwtKey = previousJwtKey;
|
|
DBConfig.SteamApiKey = previousSteamApiKey;
|
|
PlayerUtils.DbFactory = previousPlayerFactory;
|
|
RoomUtils.DbFactory = previousRoomFactory;
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void SteamApiKeyLoadsFromRadicadeConfigurationAndTrimsQuotes()
|
|
{
|
|
var previousSteamApiKey = DBConfig.SteamApiKey;
|
|
var previousAdminAccounts = DBConfig.AdminAccounts;
|
|
var previousInfluencerIds = DBConfig.TestInfluencerAccountIds;
|
|
var previousCoOwnerIds = DBConfig.TestCoOwnerAccountIds;
|
|
try
|
|
{
|
|
var configuration = new ConfigurationBuilder()
|
|
.AddInMemoryCollection(new Dictionary<string, string?>
|
|
{
|
|
["Radicade:SteamApiKey"] = " \"steam-test-key\" "
|
|
})
|
|
.Build();
|
|
|
|
DBConfig.LoadConfiguration(configuration);
|
|
|
|
Assert.Equal("steam-test-key", DBConfig.SteamApiKey);
|
|
}
|
|
finally
|
|
{
|
|
DBConfig.SteamApiKey = previousSteamApiKey;
|
|
DBConfig.AdminAccounts = previousAdminAccounts;
|
|
DBConfig.TestInfluencerAccountIds = previousInfluencerIds;
|
|
DBConfig.TestCoOwnerAccountIds = previousCoOwnerIds;
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CachedLoginFailsClosedWithoutConfiguredSteamApiKey()
|
|
{
|
|
var previousSteamApiKey = DBConfig.SteamApiKey;
|
|
var previousTrust = DBConfig.TrustUnverifiedSteamIdentity;
|
|
DBConfig.SteamApiKey = "";
|
|
DBConfig.TrustUnverifiedSteamIdentity = false;
|
|
try
|
|
{
|
|
await using var db = CreateDb();
|
|
var api = new TestAuthAPI(db, PlatformId)
|
|
{
|
|
ControllerContext = CreateControllerContext()
|
|
};
|
|
|
|
var result = await api.ConnectWithToken(RequestData());
|
|
|
|
Assert.IsType<UnauthorizedResult>(result.Result);
|
|
Assert.Empty(await db.Profiles.ToListAsync());
|
|
Assert.Empty(await db.Players.ToListAsync());
|
|
Assert.Empty(await db.CachedLogins.ToListAsync());
|
|
}
|
|
finally
|
|
{
|
|
DBConfig.SteamApiKey = previousSteamApiKey;
|
|
DBConfig.TrustUnverifiedSteamIdentity = previousTrust;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// A sign-up the server refuses has to be a FAILURE the client can see. Answering 200 puts
|
|
/// `access_token: null` in a body the client reads as a finished login: it holds no token, makes
|
|
/// no further request at all, resets its scene and retries - the exact shape of the 2026-09-30
|
|
/// log from a machine with no account on this server. The client's own onboarding trace read that
|
|
/// same 200 + null token as "Login token captured", so the server was the only place the reason
|
|
/// existed. HTTP 400 with `error`/`error_description` is the shape the unlinked-identity refusal
|
|
/// already uses and the one the client acts on.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task RefusedSignUpIsABadRequest_NotA200WithANullToken()
|
|
{
|
|
var previousJwtKey = DBConfig.JwtKey;
|
|
var previousSteamApiKey = DBConfig.SteamApiKey;
|
|
var previousPlayerFactory = PlayerUtils.DbFactory;
|
|
var previousRoomFactory = RoomUtils.DbFactory;
|
|
var factory = new TestDbContextFactory();
|
|
DBConfig.JwtKey = "unit-test-signing-key-with-sufficient-length";
|
|
DBConfig.SteamApiKey = "unit-test-steam-api-key";
|
|
PlayerUtils.DbFactory = factory;
|
|
RoomUtils.DbFactory = factory;
|
|
|
|
try
|
|
{
|
|
await using var db = factory.CreateDbContext();
|
|
db.Profiles.Add(new Profile
|
|
{
|
|
accountId = 1,
|
|
profileImage = "default",
|
|
username = "SystemAccount",
|
|
displayName = "SystemAccount",
|
|
createdAt = DateTime.UtcNow
|
|
});
|
|
await db.SaveChangesAsync();
|
|
|
|
var api = CreateApi(db, PlatformId);
|
|
var request = RequestData();
|
|
request.grant_type = "create_account";
|
|
|
|
request.username = "RefusedSignUpName";
|
|
request.password = "refused-sign-up-password";
|
|
request.birthday = new DateTime(1999, 5, 4);
|
|
request.device_id = "";
|
|
|
|
var result = await api.ConnectWithToken(request);
|
|
|
|
var refusalResult = Assert.IsType<ObjectResult>(result.Result);
|
|
Assert.Equal(400, refusalResult.StatusCode);
|
|
var refusal = Assert.IsType<AuthResponse>(refusalResult.Value);
|
|
Assert.False(string.IsNullOrWhiteSpace(refusal.error_description));
|
|
Assert.True(string.IsNullOrWhiteSpace(refusal.access_token));
|
|
Assert.Empty(await db.Players.ToListAsync());
|
|
Assert.Empty(await db.CachedLogins.ToListAsync());
|
|
}
|
|
finally
|
|
{
|
|
DBConfig.JwtKey = previousJwtKey;
|
|
DBConfig.SteamApiKey = previousSteamApiKey;
|
|
PlayerUtils.DbFactory = previousPlayerFactory;
|
|
RoomUtils.DbFactory = previousRoomFactory;
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DevelopmentBootWithoutASteamKeyStillLetsANewPlayerSignUp()
|
|
{
|
|
// What a local server runs on: no publisher key, so no ticket can be verified - and with the
|
|
// ticket required, a Steam client could neither sign up nor log in at all. The fallback only
|
|
// exists for that case (a production boot throws on the missing key), and the identity it binds
|
|
// is still the posted one, so this pins that the sign-up completes and nothing else loosens.
|
|
var previousJwtKey = DBConfig.JwtKey;
|
|
var previousSteamApiKey = DBConfig.SteamApiKey;
|
|
var previousTrust = DBConfig.TrustUnverifiedSteamIdentity;
|
|
var previousPlayerFactory = PlayerUtils.DbFactory;
|
|
var previousRoomFactory = RoomUtils.DbFactory;
|
|
var factory = new TestDbContextFactory();
|
|
DBConfig.JwtKey = "unit-test-signing-key-with-sufficient-length";
|
|
DBConfig.SteamApiKey = "";
|
|
DBConfig.TrustUnverifiedSteamIdentity = true;
|
|
PlayerUtils.DbFactory = factory;
|
|
RoomUtils.DbFactory = factory;
|
|
|
|
try
|
|
{
|
|
await using var db = factory.CreateDbContext();
|
|
// Account 1 is the system account, which must never be handed a client token - so the
|
|
// generated ids have to start after it, exactly as they do on a seeded server.
|
|
db.Profiles.Add(new Profile
|
|
{
|
|
accountId = 1,
|
|
profileImage = "default",
|
|
username = "SystemAccount",
|
|
displayName = "SystemAccount",
|
|
createdAt = DateTime.UtcNow
|
|
});
|
|
await db.SaveChangesAsync();
|
|
|
|
// No TestAuthAPI ticket stub: the whole point is that nothing verifies a ticket here.
|
|
var api = new AuthAPI(db)
|
|
{
|
|
ControllerContext = CreateControllerContext()
|
|
};
|
|
var request = RequestData();
|
|
request.grant_type = "create_account";
|
|
request.username = "LocalDevPlayer";
|
|
|
|
var result = await api.ConnectWithToken(request);
|
|
var response = Assert.IsType<AuthResponse>(Assert.IsType<OkObjectResult>(result.Result).Value);
|
|
|
|
Assert.True(string.IsNullOrEmpty(response.error), response.error_description);
|
|
Assert.False(string.IsNullOrWhiteSpace(response.access_token));
|
|
var cachedLogin = await db.CachedLogins.SingleAsync();
|
|
Assert.Equal(PlatformId, cachedLogin.platformId);
|
|
var profile = await db.Profiles.SingleAsync(candidate => candidate.accountId == cachedLogin.accountId);
|
|
Assert.Equal("LocalDevPlayer", profile.username);
|
|
}
|
|
finally
|
|
{
|
|
DBConfig.JwtKey = previousJwtKey;
|
|
DBConfig.SteamApiKey = previousSteamApiKey;
|
|
DBConfig.TrustUnverifiedSteamIdentity = previousTrust;
|
|
PlayerUtils.DbFactory = previousPlayerFactory;
|
|
RoomUtils.DbFactory = previousRoomFactory;
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CachedLoginRequiresAValidSteamOwnershipTicketBeforeProvisioning()
|
|
{
|
|
var previousSteamApiKey = DBConfig.SteamApiKey;
|
|
DBConfig.SteamApiKey = "unit-test-steam-api-key";
|
|
try
|
|
{
|
|
await using var db = CreateDb();
|
|
var api = CreateApi(db);
|
|
|
|
var result = await api.ConnectWithToken(RequestData());
|
|
|
|
Assert.IsType<UnauthorizedResult>(result.Result);
|
|
Assert.Empty(await db.Profiles.ToListAsync());
|
|
Assert.Empty(await db.Players.ToListAsync());
|
|
Assert.Empty(await db.CachedLogins.ToListAsync());
|
|
}
|
|
finally
|
|
{
|
|
DBConfig.SteamApiKey = previousSteamApiKey;
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CachedLoginRejectsSteamTicketForDifferentPlatformIdentity()
|
|
{
|
|
var previousSteamApiKey = DBConfig.SteamApiKey;
|
|
DBConfig.SteamApiKey = "unit-test-steam-api-key";
|
|
try
|
|
{
|
|
await using var db = CreateDb();
|
|
var api = CreateApi(db, "76561198000000001");
|
|
|
|
var result = await api.ConnectWithToken(RequestData());
|
|
|
|
Assert.IsType<UnauthorizedResult>(result.Result);
|
|
Assert.Empty(await db.Profiles.ToListAsync());
|
|
Assert.Empty(await db.Players.ToListAsync());
|
|
Assert.Empty(await db.CachedLogins.ToListAsync());
|
|
}
|
|
finally
|
|
{
|
|
DBConfig.SteamApiKey = previousSteamApiKey;
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CreateAccountGrantRequiresAVerifiedPlatformTicketAndProvisionsNothing()
|
|
{
|
|
var previousJwtKey = DBConfig.JwtKey;
|
|
var previousSteamApiKey = DBConfig.SteamApiKey;
|
|
DBConfig.JwtKey = "unit-test-signing-key-with-sufficient-length";
|
|
DBConfig.SteamApiKey = "unit-test-steam-api-key";
|
|
try
|
|
{
|
|
await using var db = CreateDb();
|
|
// Same request as the flow's sign-up, but with no ticket the server has verified: a sign-up
|
|
// binds a platform identity, so it cannot run on an identity nobody proved.
|
|
var api = CreateApi(db);
|
|
var request = RequestData();
|
|
request.grant_type = "create_account";
|
|
|
|
var result = await api.ConnectWithToken(request);
|
|
|
|
Assert.IsType<UnauthorizedResult>(result.Result);
|
|
Assert.Empty(await db.Profiles.ToListAsync());
|
|
Assert.Empty(await db.Players.ToListAsync());
|
|
Assert.Empty(await db.CachedLogins.ToListAsync());
|
|
}
|
|
finally
|
|
{
|
|
DBConfig.JwtKey = previousJwtKey;
|
|
DBConfig.SteamApiKey = previousSteamApiKey;
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CachedLoginLookupDoesNotOfferOrphanedOrIncompleteAccountLinks()
|
|
{
|
|
await using var db = CreateDb();
|
|
db.Profiles.Add(new Profile
|
|
{
|
|
accountId = 17,
|
|
profileImage = "default",
|
|
username = "CompleteAccount",
|
|
displayName = "CompleteAccount",
|
|
createdAt = DateTime.UtcNow
|
|
});
|
|
db.Players.Add(new PlayerData { AccountId = 17, DeviceId = DeviceId });
|
|
db.Presences.Add(new RealPresence { playerId = 17, deviceClass = DeviceClass.Screen });
|
|
db.Profiles.Add(new Profile
|
|
{
|
|
accountId = 18,
|
|
profileImage = "default",
|
|
username = "MissingPlayer",
|
|
displayName = "MissingPlayer",
|
|
createdAt = DateTime.UtcNow
|
|
});
|
|
db.Players.Add(new PlayerData { AccountId = 19, DeviceId = DeviceId });
|
|
db.CachedLogins.AddRange(
|
|
new CachedLogin { accountId = 17, platform = Platforms.Steam, platformId = PlatformId, lastLoginTime = DateTime.UtcNow },
|
|
new CachedLogin { accountId = 18, platform = Platforms.Steam, platformId = PlatformId, lastLoginTime = DateTime.UtcNow.AddMinutes(1) },
|
|
new CachedLogin { accountId = 19, platform = Platforms.Steam, platformId = PlatformId, lastLoginTime = DateTime.UtcNow.AddMinutes(2) },
|
|
new CachedLogin { accountId = 20, platform = Platforms.Steam, platformId = PlatformId, lastLoginTime = DateTime.UtcNow.AddMinutes(3) });
|
|
db.Presences.Add(new RealPresence { playerId = 19, deviceClass = DeviceClass.Screen });
|
|
await db.SaveChangesAsync();
|
|
|
|
var api = new CachedLoginAPI(db) { ControllerContext = CreateControllerContext() };
|
|
var singleLookup = Assert.IsType<OkObjectResult>(
|
|
await api.GetCachedLoginsForPlatformId(Platforms.Steam, PlatformId));
|
|
var singleLinks = Assert.IsAssignableFrom<IEnumerable<CachedLogin>>(singleLookup.Value).ToList();
|
|
Assert.Single(singleLinks);
|
|
Assert.Equal(17, singleLinks[0].accountId);
|
|
|
|
var bulkLinks = await api.GetCachedLoginsForPlatformIds(new[] { PlatformId, PlatformId });
|
|
Assert.Single(bulkLinks);
|
|
Assert.Equal(17, bulkLinks[0].accountId);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CachedLoginForLinkedPlatformStillIssuesTokenWithoutCreatingAnotherIdentity()
|
|
{
|
|
var previousJwtKey = DBConfig.JwtKey;
|
|
var previousSteamApiKey = DBConfig.SteamApiKey;
|
|
DBConfig.JwtKey = "unit-test-signing-key-with-sufficient-length";
|
|
DBConfig.SteamApiKey = "unit-test-steam-api-key";
|
|
try
|
|
{
|
|
await using var db = CreateDb();
|
|
db.Profiles.Add(new Profile
|
|
{
|
|
accountId = 17,
|
|
profileImage = "default",
|
|
username = "ExistingUser",
|
|
displayName = "ExistingUser",
|
|
createdAt = DateTime.UtcNow
|
|
});
|
|
db.Players.Add(new PlayerData
|
|
{
|
|
AccountId = 17,
|
|
DeviceId = DeviceId,
|
|
DeviceClass = DeviceClass.Screen,
|
|
AppVersion = "20230831"
|
|
});
|
|
db.CachedLogins.Add(new CachedLogin
|
|
{
|
|
accountId = 17,
|
|
platform = Platforms.Steam,
|
|
platformId = PlatformId,
|
|
lastLoginTime = DateTime.UtcNow
|
|
});
|
|
db.Presences.Add(new RealPresence
|
|
{
|
|
playerId = 17,
|
|
deviceClass = DeviceClass.Screen
|
|
});
|
|
await db.SaveChangesAsync();
|
|
|
|
var api = CreateApi(db, PlatformId);
|
|
var request = RequestData();
|
|
request.account_id = 17;
|
|
var result = await api.ConnectWithToken(request);
|
|
var response = Assert.IsType<AuthResponse>(Assert.IsType<OkObjectResult>(result.Result).Value);
|
|
|
|
Assert.True(string.IsNullOrEmpty(response.error), response.error_description);
|
|
Assert.False(string.IsNullOrWhiteSpace(response.access_token));
|
|
Assert.Single(await db.Profiles.ToListAsync());
|
|
Assert.Single(await db.CachedLogins.ToListAsync());
|
|
}
|
|
finally
|
|
{
|
|
DBConfig.JwtKey = previousJwtKey;
|
|
DBConfig.SteamApiKey = previousSteamApiKey;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// The player's own case: the identity they sign up with already owns an account (their Steam
|
|
/// account was linked on an earlier boot), so the sign-up resumes THAT account - and the name,
|
|
/// birthday and password they typed on the flow's pages have to land on it. This is what used to
|
|
/// be dropped: the grant matched the existing link, issued a token for the account, and threw the
|
|
/// payload away, so the player finished the flow looking at a name they never chose.
|
|
/// </summary>
|
|
[Theory]
|
|
[InlineData("TheNameIChose", false)]
|
|
[InlineData("ExistingPlayer", false)]
|
|
[InlineData("TheNameIChose", true)]
|
|
public async Task CreateAccountOnALinkedIdentityFinishesThatAccountWithTheNameTheFlowCollected(string chosenName, bool missingSettings)
|
|
{
|
|
var previousJwtKey = DBConfig.JwtKey;
|
|
var previousSteamApiKey = DBConfig.SteamApiKey;
|
|
var previousPlayerFactory = PlayerUtils.DbFactory;
|
|
var previousRoomFactory = RoomUtils.DbFactory;
|
|
var factory = new TestDbContextFactory();
|
|
DBConfig.JwtKey = "unit-test-signing-key-with-sufficient-length";
|
|
DBConfig.SteamApiKey = "unit-test-steam-api-key";
|
|
PlayerUtils.DbFactory = factory;
|
|
RoomUtils.DbFactory = factory;
|
|
|
|
try
|
|
{
|
|
await using var db = factory.CreateDbContext();
|
|
await SeedLinkedAccountAsync(db, flowFinished: false, complete: false);
|
|
if (missingSettings)
|
|
{
|
|
db.PlayerSettings.Remove(await db.PlayerSettings.SingleAsync());
|
|
await db.SaveChangesAsync();
|
|
}
|
|
|
|
var api = CreateApi(db, PlatformId);
|
|
var request = RequestData();
|
|
request.grant_type = "create_account";
|
|
request.username = chosenName;
|
|
if (missingSettings) request.account_id = 17;
|
|
request.password = "the-password-from-the-flow";
|
|
request.birthday = new DateTime(2011, 8, 30);
|
|
|
|
var result = await api.ConnectWithToken(request);
|
|
var response = Assert.IsType<AuthResponse>(Assert.IsType<OkObjectResult>(result.Result).Value);
|
|
|
|
Assert.True(string.IsNullOrEmpty(response.error), response.error_description);
|
|
Assert.False(string.IsNullOrWhiteSpace(response.access_token));
|
|
|
|
// One identity, one account: the sign-up finished the account it was resuming rather than
|
|
// minting a second one behind it.
|
|
var cachedLogin = await db.CachedLogins.SingleAsync();
|
|
Assert.Equal(17, cachedLogin.accountId);
|
|
Assert.Single(await db.Profiles.ToListAsync());
|
|
|
|
var profile = await db.Profiles.SingleAsync();
|
|
Assert.Equal(chosenName, profile.username);
|
|
Assert.Equal(chosenName, profile.displayName);
|
|
|
|
var player = await db.Players.SingleAsync();
|
|
Assert.Equal(new DateTime(2011, 8, 30), player.Birthday);
|
|
Assert.False(player.IsJunior);
|
|
Assert.False(string.IsNullOrWhiteSpace(player.Hashword));
|
|
var settings = (await db.PlayerSettings.SingleAsync()).DeserializeSettings();
|
|
Assert.Contains(settings, setting => setting.Key == "Recroom.AccountCreation.HasChosenUsername" && setting.Value == "True");
|
|
Assert.Contains(settings, setting => setting.Key == "Recroom.AccountCreation.HasCreatedPassword" && setting.Value == "True");
|
|
Assert.DoesNotContain(settings, setting => setting.Key == "Recroom.AccountCreation.HasFinished" && setting.Value == "True");
|
|
}
|
|
finally
|
|
{
|
|
DBConfig.JwtKey = previousJwtKey;
|
|
DBConfig.SteamApiKey = previousSteamApiKey;
|
|
PlayerUtils.DbFactory = previousPlayerFactory;
|
|
RoomUtils.DbFactory = previousRoomFactory;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// A finished account can still lack required details. A new-account request must not
|
|
/// reuse it, even when the client carries the previously selected account ID.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task CreateAccountDoesNotReuseAFinishedAccountWithMissingDetails()
|
|
{
|
|
var previousJwtKey = DBConfig.JwtKey;
|
|
var previousSteamApiKey = DBConfig.SteamApiKey;
|
|
var previousPlayerFactory = PlayerUtils.DbFactory;
|
|
var previousRoomFactory = RoomUtils.DbFactory;
|
|
var factory = new TestDbContextFactory();
|
|
DBConfig.JwtKey = "unit-test-signing-key-with-sufficient-length";
|
|
DBConfig.SteamApiKey = "unit-test-steam-api-key";
|
|
PlayerUtils.DbFactory = factory;
|
|
RoomUtils.DbFactory = factory;
|
|
|
|
try
|
|
{
|
|
await using var db = factory.CreateDbContext();
|
|
await SeedLinkedAccountAsync(db, flowFinished: true, complete: false);
|
|
|
|
var player = await db.Players.SingleAsync();
|
|
Assert.True(PlayerUtils.IsNewPlayer(player), "the fixture has to be an account the client still shows the flow for");
|
|
|
|
var api = CreateApi(db, PlatformId);
|
|
var request = RequestData();
|
|
request.grant_type = "create_account";
|
|
request.username = "NamedOnAResumedFlow";
|
|
request.account_id = 17;
|
|
request.password = "the-password-from-the-flow";
|
|
|
|
var result = await api.ConnectWithToken(request);
|
|
var response = Assert.IsType<AuthResponse>(Assert.IsType<OkObjectResult>(result.Result).Value);
|
|
|
|
Assert.True(string.IsNullOrEmpty(response.error), response.error_description);
|
|
Assert.False(string.IsNullOrWhiteSpace(response.access_token));
|
|
Assert.Equal(2, await db.CachedLogins.CountAsync());
|
|
Assert.NotEqual(17, request.account_id);
|
|
Assert.Equal("NamedOnAResumedFlow", (await db.Profiles.SingleAsync(x => x.accountId == request.account_id)).username);
|
|
Assert.Equal("ExistingPlayer", (await db.Profiles.SingleAsync(x => x.accountId == 17)).username);
|
|
Assert.False(string.IsNullOrWhiteSpace((await db.Players.SingleAsync(x => x.AccountId == request.account_id)).Hashword));
|
|
}
|
|
finally
|
|
{
|
|
DBConfig.JwtKey = previousJwtKey;
|
|
DBConfig.SteamApiKey = previousSteamApiKey;
|
|
PlayerUtils.DbFactory = previousPlayerFactory;
|
|
RoomUtils.DbFactory = previousRoomFactory;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// The other half of the rule: an account that is complete - birthday, password and accepted Code
|
|
/// of Conduct - is not renamed or re-keyed by a sign-up grant that arrives later.
|
|
/// The new sign-up receives its own account.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task CreateAccountLeavesACompleteAccountsNameAndPasswordAlone()
|
|
{
|
|
var previousJwtKey = DBConfig.JwtKey;
|
|
var previousSteamApiKey = DBConfig.SteamApiKey;
|
|
var previousPlayerFactory = PlayerUtils.DbFactory;
|
|
var previousRoomFactory = RoomUtils.DbFactory;
|
|
var factory = new TestDbContextFactory();
|
|
DBConfig.JwtKey = "unit-test-signing-key-with-sufficient-length";
|
|
DBConfig.SteamApiKey = "unit-test-steam-api-key";
|
|
PlayerUtils.DbFactory = factory;
|
|
RoomUtils.DbFactory = factory;
|
|
|
|
try
|
|
{
|
|
await using var db = factory.CreateDbContext();
|
|
await SeedLinkedAccountAsync(db, flowFinished: true, complete: true);
|
|
|
|
var api = CreateApi(db, PlatformId);
|
|
var request = RequestData();
|
|
request.grant_type = "create_account";
|
|
request.username = "SomebodyElsesTurn";
|
|
request.account_id = 17;
|
|
request.password = "a-password-from-a-later-flow";
|
|
request.birthday = new DateTime(1999, 5, 4);
|
|
|
|
var result = await api.ConnectWithToken(request);
|
|
var response = Assert.IsType<AuthResponse>(Assert.IsType<OkObjectResult>(result.Result).Value);
|
|
|
|
Assert.True(string.IsNullOrEmpty(response.error), response.error_description);
|
|
Assert.False(string.IsNullOrWhiteSpace(response.access_token));
|
|
|
|
Assert.NotEqual(17, request.account_id);
|
|
var profile = await db.Profiles.SingleAsync(x => x.accountId == 17);
|
|
Assert.Equal("ExistingPlayer", profile.username);
|
|
Assert.Equal("ExistingPlayer", profile.displayName);
|
|
|
|
var player = await db.Players.SingleAsync(x => x.AccountId == 17);
|
|
Assert.Equal(new DateTime(2000, 1, 1), player.Birthday);
|
|
Assert.Equal("seeded-hash", player.Hashword);
|
|
}
|
|
finally
|
|
{
|
|
DBConfig.JwtKey = previousJwtKey;
|
|
DBConfig.SteamApiKey = previousSteamApiKey;
|
|
PlayerUtils.DbFactory = previousPlayerFactory;
|
|
RoomUtils.DbFactory = previousRoomFactory;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// </summary>
|
|
[Theory]
|
|
[InlineData(true)]
|
|
[InlineData(false)]
|
|
public async Task BareCreateAccountResolvesToTheLinkedAccountAndNeverMintsASecond(bool complete)
|
|
{
|
|
var previousJwtKey = DBConfig.JwtKey;
|
|
var previousSteamApiKey = DBConfig.SteamApiKey;
|
|
var previousPlayerFactory = PlayerUtils.DbFactory;
|
|
var previousRoomFactory = RoomUtils.DbFactory;
|
|
var factory = new TestDbContextFactory();
|
|
DBConfig.JwtKey = "unit-test-signing-key-with-sufficient-length";
|
|
DBConfig.SteamApiKey = "unit-test-steam-api-key";
|
|
PlayerUtils.DbFactory = factory;
|
|
RoomUtils.DbFactory = factory;
|
|
try
|
|
{
|
|
await using var db = factory.CreateDbContext();
|
|
await SeedLinkedAccountAsync(db, flowFinished: true, complete: complete);
|
|
var api = CreateApi(db, PlatformId);
|
|
var request = RequestData();
|
|
request.grant_type = "create_account";
|
|
var result = await api.ConnectWithToken(request);
|
|
var response = Assert.IsType<AuthResponse>(Assert.IsType<OkObjectResult>(result.Result).Value);
|
|
Assert.True(string.IsNullOrEmpty(response.error), response.error_description);
|
|
Assert.False(string.IsNullOrWhiteSpace(response.access_token));
|
|
|
|
Assert.Equal(17, request.account_id);
|
|
Assert.Single(await db.Profiles.ToListAsync());
|
|
Assert.Single(await db.CachedLogins.ToListAsync());
|
|
Assert.Single(await db.Players.ToListAsync());
|
|
Assert.Equal("ExistingPlayer", (await db.Profiles.SingleAsync(x => x.accountId == 17)).username);
|
|
|
|
var retry = RequestData();
|
|
retry.grant_type = "create_account";
|
|
await api.ConnectWithToken(retry);
|
|
Assert.Equal(17, retry.account_id);
|
|
Assert.Single(await db.Profiles.ToListAsync());
|
|
|
|
var login = RequestData();
|
|
login.account_id = 17;
|
|
var loginResult = await api.ConnectWithToken(login);
|
|
var loginResponse = Assert.IsType<AuthResponse>(Assert.IsType<OkObjectResult>(loginResult.Result).Value);
|
|
Assert.False(string.IsNullOrWhiteSpace(loginResponse.access_token));
|
|
Assert.Equal(17, login.account_id);
|
|
Assert.Equal("ExistingPlayer", (await db.Profiles.SingleAsync(x => x.accountId == 17)).username);
|
|
}
|
|
finally
|
|
{
|
|
DBConfig.JwtKey = previousJwtKey;
|
|
DBConfig.SteamApiKey = previousSteamApiKey;
|
|
PlayerUtils.DbFactory = previousPlayerFactory;
|
|
RoomUtils.DbFactory = previousRoomFactory;
|
|
}
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("developer,influencer,betastudio", true)]
|
|
[InlineData("developer,influencer", false)]
|
|
[InlineData(" BETASTUDIO ", true)]
|
|
public async Task StudioRoleIsIncludedInTokensOnlyWhenAssigned(string roles, bool expected)
|
|
{
|
|
var previousKey = DBConfig.JwtKey;
|
|
var previousFactory = RoomUtils.DbFactory;
|
|
var factory = new TestDbContextFactory();
|
|
DBConfig.JwtKey = "unit-test-signing-key-with-sufficient-length";
|
|
RoomUtils.DbFactory = factory;
|
|
try
|
|
{
|
|
using var db = factory.CreateDbContext();
|
|
await SeedLinkedAccountAsync(db, true, true);
|
|
var profile = await db.Profiles.SingleAsync();
|
|
profile.role = roles;
|
|
await db.SaveChangesAsync();
|
|
var generate = typeof(AuthAPI).GetMethod("GenerateToken",
|
|
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static)!;
|
|
var result = await (Task<Success>)generate.Invoke(null, new object[] {
|
|
db, 17L, "20230831", DeviceClass.Screen, Platforms.Steam, "studio:17" })!;
|
|
Assert.True(result.success);
|
|
var jwt = new JwtSecurityTokenHandler().ReadJwtToken(Assert.IsType<string>(result.value));
|
|
Assert.Equal(expected, jwt.Claims.Any(c => c.Type == "role" && c.Value == "betastudio"));
|
|
}
|
|
finally
|
|
{
|
|
DBConfig.JwtKey = previousKey;
|
|
RoomUtils.DbFactory = previousFactory;
|
|
}
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(true)]
|
|
[InlineData(false)]
|
|
public async Task KeepsakeTokenRolesFollowFeatureWithoutGrantingDeveloperAccess(bool enabled)
|
|
{
|
|
var previousKey = DBConfig.JwtKey;
|
|
var previousFactory = RoomUtils.DbFactory;
|
|
var previousFeature = RadicadeServer.RecNet.Keepsakes.KeepsakeFeature.Override;
|
|
var factory = new TestDbContextFactory();
|
|
DBConfig.JwtKey = "unit-test-signing-key-with-sufficient-length";
|
|
RoomUtils.DbFactory = factory;
|
|
RadicadeServer.RecNet.Keepsakes.KeepsakeFeature.Override = enabled;
|
|
try
|
|
{
|
|
using var db = factory.CreateDbContext();
|
|
await SeedLinkedAccountAsync(db, true, true);
|
|
var profile = await db.Profiles.SingleAsync();
|
|
profile.role = "";
|
|
await db.SaveChangesAsync();
|
|
var generate = typeof(AuthAPI).GetMethod("GenerateToken",
|
|
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static)!;
|
|
var result = await (Task<Success>)generate.Invoke(null, new object[] {
|
|
db, 17L, "20230831", DeviceClass.Screen, Platforms.Steam, "keepsake:17" })!;
|
|
var jwt = new JwtSecurityTokenHandler().ReadJwtToken(Assert.IsType<string>(result.value));
|
|
var roles = jwt.Claims.Where(c => c.Type == "role").Select(c => c.Value).ToArray();
|
|
Assert.Equal(enabled, roles.Contains("keepsake"));
|
|
Assert.Equal(enabled, roles.Contains("livekeepsakeeventroomsaveoverride"));
|
|
Assert.DoesNotContain("developer", roles);
|
|
Assert.DoesNotContain("moderator", roles);
|
|
}
|
|
finally
|
|
{
|
|
DBConfig.JwtKey = previousKey;
|
|
RoomUtils.DbFactory = previousFactory;
|
|
RadicadeServer.RecNet.Keepsakes.KeepsakeFeature.Override = previousFeature;
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CreateAccountRouteAdoptsTheAccountTheBootWasHanded()
|
|
{
|
|
var previousJwtKey = DBConfig.JwtKey;
|
|
var previousSteamApiKey = DBConfig.SteamApiKey;
|
|
var previousPlayerFactory = PlayerUtils.DbFactory;
|
|
var previousRoomFactory = RoomUtils.DbFactory;
|
|
var factory = new TestDbContextFactory();
|
|
DBConfig.JwtKey = "unit-test-signing-key-with-sufficient-length";
|
|
DBConfig.SteamApiKey = "unit-test-steam-api-key";
|
|
PlayerUtils.DbFactory = factory;
|
|
RoomUtils.DbFactory = factory;
|
|
|
|
try
|
|
{
|
|
await using var db = factory.CreateDbContext();
|
|
|
|
var bootApi = CreateApi(db, PlatformId);
|
|
var boot = RequestData();
|
|
boot.grant_type = "create_account";
|
|
Assert.IsType<OkObjectResult>((await bootApi.ConnectWithToken(boot)).Result);
|
|
var bootedId = boot.account_id;
|
|
Assert.Equal(bootedId, (await db.CachedLogins.SingleAsync()).accountId);
|
|
|
|
using var steam = new SteamStub(StubSteamTicket(PlatformId));
|
|
|
|
var context = CreateAccountContext(PlatformId, steamTicket: "140test-ticket");
|
|
var api = new AccountsAPI(db) { ControllerContext = context };
|
|
|
|
var result = await api.CreateAccount(new AccountCreationData { platformId = PlatformId });
|
|
|
|
Assert.True(result.success, result.error);
|
|
Assert.NotNull(result.value);
|
|
|
|
Assert.Single(await db.Profiles.ToListAsync());
|
|
Assert.Single(await db.Players.ToListAsync());
|
|
var cachedLogin = await db.CachedLogins.SingleAsync();
|
|
Assert.Equal(bootedId, cachedLogin.accountId);
|
|
Assert.Equal(PlatformId, cachedLogin.platformId);
|
|
Assert.Equal(DeviceId, (await db.Players.SingleAsync()).DeviceId);
|
|
|
|
var onboarding = (await db.PlayerSettings.SingleAsync()).DeserializeSettings();
|
|
Assert.Equal("True", onboarding.Single(x => x.Key == "Recroom.AccountCreation.HasStarted").Value);
|
|
Assert.Equal("False", onboarding.Single(x => x.Key == "Recroom.AccountCreation.HasFinished").Value);
|
|
|
|
var lookupApi = new CachedLoginAPI(db) { ControllerContext = CreateControllerContext() };
|
|
var lookup = Assert.IsType<OkObjectResult>(
|
|
await lookupApi.GetCachedLoginsForPlatformId(Platforms.Steam, PlatformId));
|
|
var links = Assert.IsAssignableFrom<IEnumerable<CachedLogin>>(lookup.Value).ToList();
|
|
Assert.Single(links);
|
|
Assert.Equal(bootedId, links[0].accountId);
|
|
}
|
|
finally
|
|
{
|
|
DBConfig.JwtKey = previousJwtKey;
|
|
DBConfig.SteamApiKey = previousSteamApiKey;
|
|
PlayerUtils.DbFactory = previousPlayerFactory;
|
|
RoomUtils.DbFactory = previousRoomFactory;
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CreateAccountRouteMakesASecondAccountWhenTheDevicesAccountIsFinished()
|
|
{
|
|
var previousJwtKey = DBConfig.JwtKey;
|
|
var previousSteamApiKey = DBConfig.SteamApiKey;
|
|
var previousPlayerFactory = PlayerUtils.DbFactory;
|
|
var previousRoomFactory = RoomUtils.DbFactory;
|
|
var factory = new TestDbContextFactory();
|
|
DBConfig.JwtKey = "unit-test-signing-key-with-sufficient-length";
|
|
DBConfig.SteamApiKey = "unit-test-steam-api-key";
|
|
PlayerUtils.DbFactory = factory;
|
|
RoomUtils.DbFactory = factory;
|
|
|
|
try
|
|
{
|
|
await using var db = factory.CreateDbContext();
|
|
await SeedLinkedAccountAsync(db, flowFinished: true, complete: true);
|
|
|
|
using var steam = new SteamStub(StubSteamTicket(PlatformId));
|
|
|
|
var context = CreateAccountContext(PlatformId, steamTicket: "140test-ticket");
|
|
var api = new AccountsAPI(db) { ControllerContext = context };
|
|
|
|
var result = await api.CreateAccount(new AccountCreationData { platformId = PlatformId });
|
|
|
|
Assert.True(result.success, result.error);
|
|
Assert.Equal(2, (await db.Profiles.ToListAsync()).Count);
|
|
Assert.NotEqual(17, (await db.Profiles.SingleAsync(x => x.accountId != 17)).accountId);
|
|
}
|
|
finally
|
|
{
|
|
DBConfig.JwtKey = previousJwtKey;
|
|
DBConfig.SteamApiKey = previousSteamApiKey;
|
|
PlayerUtils.DbFactory = previousPlayerFactory;
|
|
RoomUtils.DbFactory = previousRoomFactory;
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CreateAccountRouteEstablishesTheAccountOnlyForAProvedSteamIdentity() =>
|
|
await RunCreateAccountTest(async db =>
|
|
{
|
|
using var steam = new SteamStub(StubSteamTicket(PlatformId));
|
|
|
|
var result = await PostCreateAccount(db, PlatformId, steamTicket: "140test-ticket");
|
|
|
|
Assert.True(result.success, result.error);
|
|
Assert.Single(await db.Profiles.ToListAsync());
|
|
Assert.Single(await db.Players.ToListAsync());
|
|
Assert.Contains(steam.Handler.Requests, url => url.Contains("AuthenticateUserTicket"));
|
|
});
|
|
|
|
[Fact]
|
|
public async Task CreateAccountRouteRefusesAnIdentityWithNoSteamTicket() =>
|
|
await RunCreateAccountTest(async db =>
|
|
{
|
|
using var steam = new SteamStub(StubSteamTicket(PlatformId));
|
|
|
|
var result = await PostCreateAccount(db, PlatformId);
|
|
|
|
Assert.False(result.success);
|
|
Assert.Contains("No Steam ticket", Assert.IsType<string>(result.value));
|
|
Assert.Empty(await db.Profiles.ToListAsync());
|
|
Assert.Empty(await db.Players.ToListAsync());
|
|
Assert.Empty(steam.Handler.Requests);
|
|
});
|
|
|
|
[Fact]
|
|
public async Task CreateAccountRouteRefusesATicketForAnotherSteamAccount() =>
|
|
await RunCreateAccountTest(async db =>
|
|
{
|
|
using var steam = new SteamStub(StubSteamTicket("76561198000000001"));
|
|
|
|
var result = await PostCreateAccount(db, PlatformId, steamTicket: "140someone-elses-ticket");
|
|
|
|
Assert.False(result.success);
|
|
Assert.Contains("76561198000000001", Assert.IsType<string>(result.value));
|
|
Assert.Empty(await db.Profiles.ToListAsync());
|
|
});
|
|
|
|
[Fact]
|
|
public async Task CreateAccountRouteRefusesATicketSteamWillNotAccept() =>
|
|
await RunCreateAccountTest(async db =>
|
|
{
|
|
using var steam = new SteamStub(StubRejectedSteamTicket(101, "Invalid ticket"));
|
|
|
|
var result = await PostCreateAccount(db, PlatformId, steamTicket: "140forged-ticket");
|
|
|
|
Assert.False(result.success);
|
|
Assert.Contains("Invalid ticket", Assert.IsType<string>(result.value));
|
|
Assert.Empty(await db.Profiles.ToListAsync());
|
|
});
|
|
|
|
[Fact]
|
|
public async Task CreateAccountRouteRefusesATicketIssuedForAnotherApp() =>
|
|
await RunCreateAccountTest(async db =>
|
|
{
|
|
using var steam = new SteamStub(StubRejectedSteamTicket(102, "The ticket is for another app"));
|
|
|
|
var result = await PostCreateAccount(db, PlatformId, steamTicket: "140other-app-ticket");
|
|
|
|
Assert.False(result.success);
|
|
Assert.Contains("not for an app this server accepts", Assert.IsType<string>(result.value));
|
|
Assert.Empty(await db.Profiles.ToListAsync());
|
|
});
|
|
|
|
[Fact]
|
|
public async Task CreateAccountRouteAcceptsATicketCarriedInPlatformAuth() =>
|
|
await RunCreateAccountTest(async db =>
|
|
{
|
|
using var steam = new SteamStub(StubSteamTicket(PlatformId));
|
|
|
|
var result = await PostCreateAccount(db, PlatformId,
|
|
platformAuth: "{\"Ticket\":\"140test-ticket\",\"AppId\":\"471710\"}");
|
|
|
|
Assert.True(result.success, result.error);
|
|
Assert.Single(await db.Profiles.ToListAsync());
|
|
});
|
|
|
|
[Fact]
|
|
public async Task CreateAccountRouteRefusesASteamAccountWithACommunityBan() =>
|
|
await RunCreateAccountTest(async db =>
|
|
{
|
|
using var steam = new SteamStub(StubSteamTicket(PlatformId, communityBanned: true));
|
|
|
|
var result = await PostCreateAccount(db, PlatformId, steamTicket: "140test-ticket");
|
|
|
|
Assert.False(result.success);
|
|
Assert.Contains("community ban", Assert.IsType<string>(result.value));
|
|
Assert.Empty(await db.Profiles.ToListAsync());
|
|
});
|
|
|
|
[Theory]
|
|
[InlineData(0)]
|
|
[InlineData(3)]
|
|
[InlineData(3650)]
|
|
public async Task CreateAccountRouteAcceptsASteamAccountOfAnyAge(int daysOld) =>
|
|
await RunCreateAccountTest(async db =>
|
|
{
|
|
using var steam = new SteamStub(StubSteamTicket(PlatformId,
|
|
timeCreated: DateTimeOffset.UtcNow.AddDays(-daysOld).ToUnixTimeSeconds()));
|
|
|
|
var result = await PostCreateAccount(db, PlatformId, steamTicket: "140test-ticket");
|
|
|
|
Assert.True(result.success, result.error);
|
|
Assert.Single(await db.Profiles.ToListAsync());
|
|
});
|
|
|
|
[Fact]
|
|
public async Task CreateAccountRouteRefusesASteamProfileThatIsNotSetUp() =>
|
|
await RunCreateAccountTest(async db =>
|
|
{
|
|
using var steam = new SteamStub(StubSteamTicket(PlatformId, profileState: 0));
|
|
|
|
var result = await PostCreateAccount(db, PlatformId, steamTicket: "140test-ticket");
|
|
|
|
Assert.False(result.success);
|
|
Assert.Contains("set up", Assert.IsType<string>(result.value));
|
|
Assert.Empty(await db.Profiles.ToListAsync());
|
|
});
|
|
|
|
[Fact]
|
|
public async Task CreateAccountRouteAllowsTheAccountWhenSteamCannotAnswerTheStandingCheck() =>
|
|
await RunCreateAccountTest(async db =>
|
|
{
|
|
using var steam = new SteamStub(StubSteamTicket(PlatformId, summariesUnavailable: true));
|
|
|
|
var result = await PostCreateAccount(db, PlatformId, steamTicket: "140test-ticket");
|
|
|
|
Assert.True(result.success, result.error);
|
|
Assert.Single(await db.Profiles.ToListAsync());
|
|
});
|
|
|
|
[Fact]
|
|
public async Task CreateAccountRouteRefusesSteamWhenThereIsNoKeyAndUnverifiedIdentitiesAreNotTrusted() =>
|
|
await RunCreateAccountTest(async db =>
|
|
{
|
|
using var steam = new SteamStub(StubSteamTicket(PlatformId));
|
|
|
|
var result = await PostCreateAccount(db, PlatformId, steamTicket: "140test-ticket");
|
|
|
|
Assert.False(result.success);
|
|
Assert.Contains("no SteamApiKey", Assert.IsType<string>(result.value));
|
|
Assert.Empty(await db.Profiles.ToListAsync());
|
|
Assert.Empty(steam.Handler.Requests);
|
|
}, steamApiKey: "");
|
|
|
|
[Fact]
|
|
public async Task CreateAccountRouteTrustsAnUnverifiedSteamIdentityOnlyWhenToldTo() =>
|
|
await RunCreateAccountTest(async db =>
|
|
{
|
|
var result = await PostCreateAccount(db, PlatformId, steamTicket: "140test-ticket");
|
|
|
|
Assert.True(result.success, result.error);
|
|
Assert.Single(await db.Profiles.ToListAsync());
|
|
}, steamApiKey: "", trustUnverified: true);
|
|
|
|
private static async Task RunCreateAccountTest(Func<RecDBContext, Task> body,
|
|
string steamApiKey = "unit-test-steam-api-key", bool trustUnverified = false)
|
|
{
|
|
var previousJwtKey = DBConfig.JwtKey;
|
|
var previousSteamApiKey = DBConfig.SteamApiKey;
|
|
var previousTrust = DBConfig.TrustUnverifiedSteamIdentity;
|
|
var previousPlayerFactory = PlayerUtils.DbFactory;
|
|
var previousRoomFactory = RoomUtils.DbFactory;
|
|
var factory = new TestDbContextFactory();
|
|
DBConfig.JwtKey = "unit-test-signing-key-with-sufficient-length";
|
|
DBConfig.SteamApiKey = steamApiKey;
|
|
DBConfig.TrustUnverifiedSteamIdentity = trustUnverified;
|
|
PlayerUtils.DbFactory = factory;
|
|
RoomUtils.DbFactory = factory;
|
|
|
|
try
|
|
{
|
|
await using var db = factory.CreateDbContext();
|
|
await body(db);
|
|
}
|
|
finally
|
|
{
|
|
DBConfig.JwtKey = previousJwtKey;
|
|
DBConfig.SteamApiKey = previousSteamApiKey;
|
|
DBConfig.TrustUnverifiedSteamIdentity = previousTrust;
|
|
PlayerUtils.DbFactory = previousPlayerFactory;
|
|
RoomUtils.DbFactory = previousRoomFactory;
|
|
}
|
|
}
|
|
|
|
private static ControllerContext CreateAccountContext(string platformId, string? steamTicket = null, string? platformAuth = null)
|
|
{
|
|
var context = CreateControllerContext();
|
|
context.HttpContext.Request.ContentType = "application/x-www-form-urlencoded";
|
|
var form = new Dictionary<string, Microsoft.Extensions.Primitives.StringValues>
|
|
{
|
|
["platform"] = "0",
|
|
["platformId"] = platformId,
|
|
["device_id"] = DeviceId,
|
|
["device_class"] = "0"
|
|
};
|
|
if (platformAuth != null) form["platform_auth"] = platformAuth;
|
|
context.HttpContext.Request.Form = new FormCollection(form);
|
|
if (steamTicket != null) context.HttpContext.Request.Headers["x-steam-ticket"] = steamTicket;
|
|
return context;
|
|
}
|
|
|
|
private static Task<Success> PostCreateAccount(RecDBContext db, string platformId,
|
|
string? steamTicket = null, string? platformAuth = null)
|
|
{
|
|
var api = new AccountsAPI(db) { ControllerContext = CreateAccountContext(platformId, steamTicket, platformAuth) };
|
|
return api.CreateAccount(new AccountCreationData { platformId = platformId });
|
|
}
|
|
|
|
private static readonly long LongStandingSteamAccount =
|
|
DateTimeOffset.UtcNow.AddYears(-5).ToUnixTimeSeconds();
|
|
|
|
/// <summary>
|
|
/// The accepted-app-id retry on the token grant. TestAuthAPI overrides the ticket check, which is
|
|
/// exactly where that retry lives, so anything asserted through it would pass either way - these
|
|
/// two run the production implementation instead. The 2023 client identifies as app 480 while
|
|
/// platform_auth can claim 471710 (or the reverse), and Valve answers 102 for the mismatch.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task CreateAccountGrantRetriesAnAcceptedAppIdWhenValveSaysTheTicketIsForAnotherApp()
|
|
{
|
|
var previousJwtKey = DBConfig.JwtKey;
|
|
var previousSteamApiKey = DBConfig.SteamApiKey;
|
|
var previousPlayerFactory = PlayerUtils.DbFactory;
|
|
var previousRoomFactory = RoomUtils.DbFactory;
|
|
var factory = new TestDbContextFactory();
|
|
DBConfig.JwtKey = "unit-test-signing-key-with-sufficient-length";
|
|
DBConfig.SteamApiKey = "unit-test-steam-api-key";
|
|
PlayerUtils.DbFactory = factory;
|
|
RoomUtils.DbFactory = factory;
|
|
|
|
try
|
|
{
|
|
using var steam = new SteamStub(new SteamStubHandler(url =>
|
|
url.Contains("appid=471710", StringComparison.Ordinal)
|
|
? SteamJson("{\"response\":{\"error\":{\"errorcode\":102,\"errordesc\":\"The ticket is for another app\"}}}")
|
|
: SteamJson($"{{\"response\":{{\"params\":{{\"result\":\"OK\",\"steamid\":\"{PlatformId}\"}}}}}}")));
|
|
|
|
await using var db = factory.CreateDbContext();
|
|
var api = new RealSteamTicketAuthAPI(db) { ControllerContext = CreateControllerContext() };
|
|
var request = RequestData();
|
|
request.grant_type = "create_account";
|
|
request.device_id = "device-steam-appid-retry";
|
|
|
|
var result = await api.ConnectWithToken(request);
|
|
|
|
Assert.IsType<OkObjectResult>(result.Result);
|
|
Assert.Equal(2, steam.Handler.Requests.Count);
|
|
Assert.Contains("appid=480", steam.Handler.Requests[1], StringComparison.Ordinal);
|
|
Assert.Single(await db.Profiles.ToListAsync());
|
|
}
|
|
finally
|
|
{
|
|
DBConfig.JwtKey = previousJwtKey;
|
|
DBConfig.SteamApiKey = previousSteamApiKey;
|
|
PlayerUtils.DbFactory = previousPlayerFactory;
|
|
RoomUtils.DbFactory = previousRoomFactory;
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CreateAccountGrantStillRefusesATicketNoAcceptedAppIdConfirms()
|
|
{
|
|
var previousSteamApiKey = DBConfig.SteamApiKey;
|
|
DBConfig.SteamApiKey = "unit-test-steam-api-key";
|
|
try
|
|
{
|
|
using var steam = new SteamStub(StubRejectedSteamTicket(102, "The ticket is for another app"));
|
|
|
|
await using var db = CreateDb();
|
|
var api = new RealSteamTicketAuthAPI(db) { ControllerContext = CreateControllerContext() };
|
|
var request = RequestData();
|
|
request.grant_type = "create_account";
|
|
request.device_id = "device-steam-appid-refused";
|
|
|
|
Assert.IsType<UnauthorizedResult>((await api.ConnectWithToken(request)).Result);
|
|
Assert.Empty(await db.Profiles.ToListAsync());
|
|
}
|
|
finally
|
|
{
|
|
DBConfig.SteamApiKey = previousSteamApiKey;
|
|
}
|
|
}
|
|
|
|
private static SteamStubHandler StubSteamTicket(string steamId, long? timeCreated = null,
|
|
bool communityBanned = false, int profileState = 1, bool summariesUnavailable = false)
|
|
{
|
|
var created = timeCreated ?? LongStandingSteamAccount;
|
|
return new SteamStubHandler(url =>
|
|
{
|
|
if (url.Contains("AuthenticateUserTicket"))
|
|
return SteamJson($"{{\"response\":{{\"params\":{{\"result\":\"OK\",\"steamid\":\"{steamId}\"}}}}}}");
|
|
|
|
if (url.Contains("GetPlayerSummaries"))
|
|
return summariesUnavailable
|
|
? new HttpResponseMessage(System.Net.HttpStatusCode.InternalServerError)
|
|
: SteamJson($"{{\"response\":{{\"players\":[{{\"steamid\":\"{steamId}\"," +
|
|
$"\"timecreated\":{created},\"profilestate\":{profileState}}}]}}}}");
|
|
|
|
if (url.Contains("GetPlayerBans"))
|
|
return SteamJson($"{{\"players\":[{{\"SteamId\":\"{steamId}\",\"CommunityBanned\":" +
|
|
$"{(communityBanned ? "true" : "false")}}}]}}");
|
|
|
|
return new HttpResponseMessage(System.Net.HttpStatusCode.NotFound);
|
|
});
|
|
}
|
|
|
|
private static SteamStubHandler StubRejectedSteamTicket(int errorCode, string description) =>
|
|
new(url => url.Contains("AuthenticateUserTicket")
|
|
? SteamJson($"{{\"response\":{{\"error\":{{\"errorcode\":{errorCode},\"errordesc\":\"{description}\"}}}}}}")
|
|
: new HttpResponseMessage(System.Net.HttpStatusCode.NotFound));
|
|
|
|
private static HttpResponseMessage SteamJson(string body) =>
|
|
new(System.Net.HttpStatusCode.OK) { Content = new StringContent(body, Encoding.UTF8, "application/json") };
|
|
|
|
private sealed class SteamStubHandler(Func<string, HttpResponseMessage> respond) : HttpMessageHandler
|
|
{
|
|
public List<string> Requests { get; } = [];
|
|
|
|
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
|
{
|
|
var url = request.RequestUri?.ToString() ?? string.Empty;
|
|
Requests.Add(url);
|
|
return Task.FromResult(respond(url));
|
|
}
|
|
}
|
|
|
|
private sealed class SteamStub : IDisposable
|
|
{
|
|
private readonly HttpClient previous;
|
|
private readonly HttpClient created;
|
|
|
|
public SteamStub(SteamStubHandler handler)
|
|
{
|
|
Handler = handler;
|
|
previous = PlatformIdentity.Client;
|
|
created = new HttpClient(handler);
|
|
PlatformIdentity.Client = created;
|
|
}
|
|
|
|
public SteamStubHandler Handler { get; }
|
|
|
|
public void Dispose()
|
|
{
|
|
PlatformIdentity.Client = previous;
|
|
created.Dispose();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// The client asks for an age only when the server says the age is unsettled, and it picks its
|
|
/// junior or non-junior username page from that same answer. pendingJunior was hardcoded false
|
|
/// next to a computed HasBirthday, so an account with no birthday still read as settled: the 2023
|
|
/// title screen took the non-junior username page and skipped the BIRTHDAY page of its new-user
|
|
/// flow, which is why no account on this server ever ended up with a birthday.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task AnAccountWithoutABirthdayReportsAPendingJuniorSoTheClientAsksForAnAge()
|
|
{
|
|
await using var db = CreateDb();
|
|
await SeedLinkedAccountAsync(db, flowFinished: false, complete: false);
|
|
await SeedReputationAndProgressionAsync(db);
|
|
|
|
var player = await db.GetPlayer(17);
|
|
|
|
Assert.NotNull(player);
|
|
Assert.False(player!.HasBirthday);
|
|
Assert.True(player.PendingJunior);
|
|
Assert.False(player.JuniorProfile);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AnAccountWithABirthdayIsNotPendingSoTheAgePageIsNotOfferedAgain()
|
|
{
|
|
await using var db = CreateDb();
|
|
await SeedLinkedAccountAsync(db, flowFinished: true, complete: true);
|
|
await SeedReputationAndProgressionAsync(db);
|
|
|
|
var player = await db.GetPlayer(17);
|
|
|
|
Assert.NotNull(player);
|
|
Assert.True(player!.HasBirthday);
|
|
Assert.False(player.PendingJunior);
|
|
}
|
|
|
|
private static async Task SeedReputationAndProgressionAsync(RecDBContext db)
|
|
{
|
|
db.Reputations.Add(new Reputation { AccountId = 17 });
|
|
db.Progression.Add(new ProgressionData
|
|
{
|
|
PlayerId = 17,
|
|
XP = 0,
|
|
Level = 1,
|
|
Progress = new ProgressionJson().ToJson()
|
|
});
|
|
await db.SaveChangesAsync();
|
|
}
|
|
|
|
private static async Task SeedLinkedAccountAsync(RecDBContext db, bool flowFinished, bool complete)
|
|
{
|
|
db.Profiles.Add(new Profile
|
|
{
|
|
accountId = 17,
|
|
profileImage = "default",
|
|
username = "ExistingPlayer",
|
|
displayName = "ExistingPlayer",
|
|
createdAt = DateTime.UtcNow
|
|
});
|
|
db.Players.Add(new PlayerData
|
|
{
|
|
AccountId = 17,
|
|
DeviceId = DeviceId,
|
|
DeviceClass = DeviceClass.Screen,
|
|
AppVersion = "20230831",
|
|
Birthday = complete ? new DateTime(2000, 1, 1) : DateTime.MinValue,
|
|
Hashword = complete ? "seeded-hash" : "",
|
|
CodeOfConductAccepted = complete
|
|
});
|
|
db.CachedLogins.Add(new CachedLogin
|
|
{
|
|
accountId = 17,
|
|
platform = Platforms.Steam,
|
|
platformId = PlatformId,
|
|
lastLoginTime = DateTime.UtcNow
|
|
});
|
|
db.PlayerSettings.Add(new PlayerSettings
|
|
{
|
|
AccountId = 17,
|
|
Settings =
|
|
"[{\"Key\":\"Recroom.AccountCreation.HasStarted\",\"Value\":\"true\"}," +
|
|
$"{{\"Key\":\"Recroom.AccountCreation.HasFinished\",\"Value\":\"{(flowFinished ? "true" : "false")}\"}}]"
|
|
});
|
|
db.Presences.Add(new RealPresence
|
|
{
|
|
playerId = 17,
|
|
deviceClass = DeviceClass.Screen
|
|
});
|
|
await db.SaveChangesAsync();
|
|
}
|
|
|
|
private static TokenConnectData RequestData() => new()
|
|
{
|
|
grant_type = "cached_login",
|
|
account_id = 0,
|
|
ver = "20230831",
|
|
device_class = DeviceClass.Screen,
|
|
device_id = DeviceId,
|
|
platform = Platforms.Steam,
|
|
platform_id = PlatformId,
|
|
client_id = "recroom",
|
|
platform_auth = "{\"Ticket\":\"140test-ticket\",\"AppId\":\"471710\"}"
|
|
};
|
|
|
|
private static RecDBContext CreateDb()
|
|
{
|
|
var options = TestDbOptions.Create()
|
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
|
.Options;
|
|
return new RecDBContext(options);
|
|
}
|
|
|
|
private static AuthAPI CreateApi(RecDBContext db, string? authenticatedSteamId = null) => new TestAuthAPI(db, authenticatedSteamId)
|
|
{
|
|
ControllerContext = CreateControllerContext()
|
|
};
|
|
|
|
private static ControllerContext CreateControllerContext()
|
|
{
|
|
var httpContext = new DefaultHttpContext
|
|
{
|
|
User = new ClaimsPrincipal(new ClaimsIdentity())
|
|
};
|
|
httpContext.Connection.RemoteIpAddress = System.Net.IPAddress.Loopback;
|
|
return new ControllerContext { HttpContext = httpContext };
|
|
}
|
|
}
|