mirror of
https://github.com/JogmansonReal/Radicade.git
synced 2026-10-25 15:09:26 -07:00
2197 lines
92 KiB
C#
2197 lines
92 KiB
C#
using System.Net;
|
|
using System.IdentityModel.Tokens.Jwt;
|
|
using System.Reflection;
|
|
using System.Security.Claims;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using Microsoft.AspNetCore.Hosting;
|
|
using Microsoft.Extensions.FileProviders;
|
|
using Microsoft.Extensions.Hosting;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.EntityFrameworkCore.Storage;
|
|
using Microsoft.IdentityModel.Tokens;
|
|
using RadicadeServer.API;
|
|
using RadicadeServer.API.Accounts;
|
|
using RadicadeServer.API.Analytics;
|
|
using RadicadeServer.API.Avatars;
|
|
using RadicadeServer.RecNet.Avatar;
|
|
using RadicadeServer.API.Avatars.V1;
|
|
using RadicadeServer.API.Avatars.V2;
|
|
using RadicadeServer.API.Avatars.V3;
|
|
using RadicadeServer.API.Avatars.V4;
|
|
using RadicadeServer.API.Config;
|
|
using RadicadeServer.API.Events;
|
|
using RadicadeServer.API.Link;
|
|
using RadicadeServer.API.Matchmaking;
|
|
using RadicadeServer.API.Progression;
|
|
using RadicadeServer.API.Progression.Objectives.V1;
|
|
using RadicadeServer.API.Players.V2;
|
|
using RadicadeServer.API.Storefront.V3;
|
|
using RadicadeServer.Auth;
|
|
using RadicadeServer.API.Moderation;
|
|
using RadicadeServer.CDN;
|
|
using RadicadeServer.Clubs.Announcements;
|
|
using RadicadeServer.DB;
|
|
using RadicadeServer.Extensions;
|
|
using RadicadeServer.Leaderboard;
|
|
using RadicadeServer.RecNet.Accounts;
|
|
using RadicadeServer.RecNet.Accounts.Local;
|
|
using RadicadeServer.RecNet.Config;
|
|
using RadicadeServer.RecNet.Enums;
|
|
using RadicadeServer.RecNet.Events;
|
|
using RadicadeServer.RecNet.Inventions;
|
|
using RadicadeServer.API.Inventions.V1;
|
|
using RadicadeServer.RecNet.Enums.Players;
|
|
using RadicadeServer.RecNet.Enums.Rooms;
|
|
using RadicadeServer.RecNet.Leaderboards;
|
|
using RadicadeServer.RecNet.Responses;
|
|
using RadicadeServer.RecNet.Rooms;
|
|
using RadicadeServer.Utils;
|
|
using Xunit;
|
|
|
|
namespace RadicadeServer.Tests;
|
|
|
|
/// <summary>
|
|
/// Conformance smoke-tests for the wire shapes the game client deserializes. The
|
|
/// client's deserializer is strict — it logs "Failed to find key ..." when a DTO is
|
|
/// missing a field, and casts (e.g. Dictionary -> List) when an endpoint returns the
|
|
/// wrong root shape — so this suite pins the exact JSON each RecNet endpoint must emit.
|
|
/// </summary>
|
|
[Collection(DbFactoryCollection.Name)]
|
|
public class RecNetShapeConformanceTests
|
|
{
|
|
private const long CreatorId = 42;
|
|
|
|
private static readonly JsonSerializerOptions ServerJson = new()
|
|
{
|
|
PropertyNamingPolicy = null,
|
|
WriteIndented = true,
|
|
};
|
|
|
|
// The full key set the client's PlayerEvent model requires (verified against the
|
|
// archived FemRec-2023 server's event schema).
|
|
private static readonly string[] PlayerEventKeys =
|
|
{
|
|
"PlayerEventId", "Name", "Description", "ImageName", "StartTime", "EndTime",
|
|
"CreatorPlayerId", "AttendeeCount", "RoomId", "SubRoomId", "Accessibility",
|
|
"ClubId", "State", "Tags", "CreatedAt",
|
|
};
|
|
|
|
// Search/searchlive results carry these two extra fields the client reads
|
|
// (verified against the live recconned reference server).
|
|
private static readonly string[] PlayerEventSearchKeys =
|
|
{
|
|
"PlayerCount", "IsFull",
|
|
};
|
|
|
|
private static string Serialize(object? value) => JsonSerializer.Serialize(value, ServerJson);
|
|
|
|
private static RecDBContext CreateDb()
|
|
{
|
|
var options = TestDbOptions.Create()
|
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
|
.Options;
|
|
return new RecDBContext(options);
|
|
}
|
|
|
|
private sealed class TestDbContextFactory : IDbContextFactory<RecDBContext>
|
|
{
|
|
private readonly DbContextOptions<RecDBContext> options;
|
|
|
|
public TestDbContextFactory()
|
|
{
|
|
var root = new InMemoryDatabaseRoot();
|
|
options = TestDbOptions.Create()
|
|
.UseInMemoryDatabase(Guid.NewGuid().ToString(), root)
|
|
.Options;
|
|
}
|
|
|
|
public RecDBContext CreateDbContext() => new(options);
|
|
}
|
|
|
|
private static EventsAPI CreateEventsController(RecDBContext db)
|
|
{
|
|
var identity = new ClaimsIdentity("TestAuth");
|
|
identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, CreatorId.ToString()));
|
|
return new EventsAPI(db)
|
|
{
|
|
ControllerContext = new ControllerContext
|
|
{
|
|
HttpContext = new DefaultHttpContext { User = new ClaimsPrincipal(identity) }
|
|
}
|
|
};
|
|
}
|
|
|
|
// Gives the controller a request body (and content type) the way the client sends it.
|
|
private static void SetBody(Controller controller, string? body, string contentType, string query = "")
|
|
{
|
|
var context = controller.ControllerContext.HttpContext;
|
|
context.Request.ContentType = contentType;
|
|
context.Request.QueryString = new QueryString(query);
|
|
context.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes(body ?? string.Empty));
|
|
}
|
|
|
|
private static void SetUser(Controller controller, long accountId = CreatorId, string platformId = "0")
|
|
{
|
|
var identity = new ClaimsIdentity("TestAuth");
|
|
identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, accountId.ToString()));
|
|
identity.AddClaim(new Claim("rn.platid", platformId));
|
|
controller.ControllerContext = new ControllerContext
|
|
{
|
|
HttpContext = new DefaultHttpContext { User = new ClaimsPrincipal(identity) }
|
|
};
|
|
}
|
|
|
|
private static async Task<long> SeedRoomAsync(RecDBContext db)
|
|
{
|
|
var room = new RoomData { Name = "Test Room" };
|
|
db.Rooms.Add(room);
|
|
await db.SaveChangesAsync();
|
|
return room.RoomId;
|
|
}
|
|
|
|
private static async Task<PlayerEvent> CreateEventAsync(EventsAPI controller, long roomId)
|
|
{
|
|
var result = await controller.CreateEvent("Conformance", null, roomId, null, null, null, null);
|
|
return Assert.IsType<PlayerEvent>(Assert.IsType<OkObjectResult>(result.Result).Value);
|
|
}
|
|
|
|
private static string[] TopLevelKeys(string json)
|
|
{
|
|
using var doc = JsonDocument.Parse(json);
|
|
return doc.RootElement.EnumerateObject().Select(p => p.Name).ToArray();
|
|
}
|
|
|
|
private static void AssertIsEmptyArray(string json)
|
|
{
|
|
using var doc = JsonDocument.Parse(json);
|
|
Assert.Equal(JsonValueKind.Array, doc.RootElement.ValueKind);
|
|
Assert.Equal(0, doc.RootElement.GetArrayLength());
|
|
}
|
|
|
|
private static string CreateBearerToken(long accountId)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(DBConfig.JwtKey))
|
|
{
|
|
DBConfig.JwtKey = "radicade-local-development-jwt-key-change-before-production";
|
|
}
|
|
|
|
var key = new SymmetricSecurityKey(Encoding.ASCII.GetBytes(DBConfig.JwtKey));
|
|
var token = new JwtSecurityToken(
|
|
claims: new[] { new Claim("userId", accountId.ToString()) },
|
|
signingCredentials: new SigningCredentials(key, SecurityAlgorithms.HmacSha256));
|
|
|
|
return "Bearer " + new JwtSecurityTokenHandler().WriteToken(token);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PlayerEvent_ContainsAllClientRequiredFields()
|
|
{
|
|
var db = CreateDb();
|
|
var controller = CreateEventsController(db);
|
|
var roomId = await SeedRoomAsync(db);
|
|
var ev = await CreateEventAsync(controller, roomId);
|
|
|
|
var keys = TopLevelKeys(Serialize(ev));
|
|
|
|
foreach (var key in PlayerEventKeys)
|
|
Assert.Contains(key, keys);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task EventSearch_EmptyResult_IsBareArray()
|
|
{
|
|
var db = CreateDb();
|
|
var controller = CreateEventsController(db);
|
|
|
|
var result = await controller.SearchEvents(null, null);
|
|
|
|
AssertIsEmptyArray(Serialize(result));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task EventSearch_Result_HasPlayerCountAndIsFull()
|
|
{
|
|
var db = CreateDb();
|
|
var controller = CreateEventsController(db);
|
|
var roomId = await SeedRoomAsync(db);
|
|
await CreateEventAsync(controller, roomId);
|
|
|
|
var result = await controller.SearchEvents(null, null);
|
|
var json = Serialize(result);
|
|
|
|
using var doc = JsonDocument.Parse(json);
|
|
Assert.Equal(1, doc.RootElement.GetArrayLength());
|
|
var keys = doc.RootElement[0].EnumerateObject().Select(p => p.Name).ToArray();
|
|
foreach (var key in PlayerEventSearchKeys)
|
|
Assert.Contains(key, keys);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RoomEvents_EmptyResult_IsBareArray()
|
|
{
|
|
var db = CreateDb();
|
|
var controller = CreateEventsController(db);
|
|
var roomId = await SeedRoomAsync(db);
|
|
|
|
var result = await controller.GetEventsInRoom(roomId);
|
|
|
|
AssertIsEmptyArray(Serialize(result));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CreatedEvents_EmptyResult_IsBareArray()
|
|
{
|
|
var db = CreateDb();
|
|
var controller = CreateEventsController(db);
|
|
|
|
var result = await controller.GetCreatedEvents();
|
|
|
|
AssertIsEmptyArray(Serialize(result));
|
|
}
|
|
|
|
[Fact]
|
|
public void EventTagFilters_ReturnExpectedKeys()
|
|
{
|
|
var db = CreateDb();
|
|
var controller = CreateEventsController(db);
|
|
|
|
var keys = TopLevelKeys(Serialize(controller.GetEventTagFilters()));
|
|
|
|
Assert.Contains("PinnedFilters", keys);
|
|
Assert.Contains("PopularFilters", keys);
|
|
Assert.Contains("TrendingFilters", keys);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AllEvents_ReturnCreatedAndResponsesKeys()
|
|
{
|
|
var db = CreateDb();
|
|
var controller = CreateEventsController(db);
|
|
|
|
var keys = TopLevelKeys(Serialize(await controller.GetAllEvents()));
|
|
|
|
Assert.Contains("Created", keys);
|
|
Assert.Contains("Responses", keys);
|
|
}
|
|
|
|
[Fact]
|
|
public void ClubAnnouncementDeleteRouteIsRegistered()
|
|
{
|
|
var delete = typeof(ClubAnnouncementsAPI).GetMethod(nameof(ClubAnnouncementsAPI.DeleteClubAnnouncement))!;
|
|
var routes = delete.GetCustomAttributes(typeof(HttpDeleteAttribute), false)
|
|
.Cast<HttpDeleteAttribute>()
|
|
.Select(attribute => attribute.Template)
|
|
.ToArray();
|
|
Assert.Contains("club/{clubId:long}/{announcementId:long}", routes);
|
|
Assert.DoesNotContain("~/clubs/announcements/club/{clubId:long}/{announcementId:long}", routes);
|
|
}
|
|
|
|
[Fact]
|
|
public void Success_SpellsItsAbsentErrorAsAnEmptyString()
|
|
{
|
|
// The reference serves error: "" on success and never null. The 2023 client builds its
|
|
// own failure exception straight from this field, so `"error": null` on a 200 turns into
|
|
// `ArgumentException: Error message cannot be null` in its response continuation - the
|
|
// friend's client logged exactly that after an invite it had actually sent
|
|
// (docs/2023-0831-CLIENT-SWITCH.md, the invite section).
|
|
using var document = JsonDocument.Parse(Serialize(new Success(true, null, null)));
|
|
Assert.Equal(string.Empty, document.RootElement.GetProperty("error").GetString());
|
|
Assert.Equal(JsonValueKind.Null, document.RootElement.GetProperty("value").ValueKind);
|
|
|
|
// A refusal still carries the reason it was refused.
|
|
using var refusal = JsonDocument.Parse(Serialize(new Success(false, "nope", null)));
|
|
Assert.False(refusal.RootElement.GetProperty("success").GetBoolean());
|
|
Assert.Equal("nope", refusal.RootElement.GetProperty("error").GetString());
|
|
}
|
|
|
|
[Fact]
|
|
public void ClubAnnouncements_ReturnsTheReferenceEnvelopeShape()
|
|
{
|
|
var api = new ClubAnnouncementsAPI();
|
|
var ok = Assert.IsType<OkObjectResult>(api.GetClubAnnouncements(0));
|
|
|
|
using var document = JsonDocument.Parse(Serialize(ok.Value));
|
|
var root = document.RootElement;
|
|
Assert.Equal(JsonValueKind.Object, root.ValueKind);
|
|
Assert.Equal(string.Empty, root.GetProperty("error").GetString());
|
|
Assert.True(root.GetProperty("success").GetBoolean());
|
|
|
|
var value = root.GetProperty("value");
|
|
Assert.Equal(JsonValueKind.Object, value.ValueKind);
|
|
Assert.Equal(JsonValueKind.Array, value.GetProperty("Announcements").ValueKind);
|
|
Assert.Equal(0, value.GetProperty("ClubId").GetInt64());
|
|
Assert.Equal(JsonValueKind.Null, value.GetProperty("LastAnnouncementId").ValueKind);
|
|
Assert.Equal(0, value.GetProperty("LastReadAnnouncementId").GetInt64());
|
|
}
|
|
[Fact]
|
|
public async Task EventResponses_ContainAllClientRequiredFields()
|
|
{
|
|
// The client's PlayerEventResponse model reads PlayerEventResponseId,
|
|
// PlayerEventId, PlayerId, CreatedAt and Type and throws a raw
|
|
// KeyNotFoundException when any are missing (verified against the EpicQuest
|
|
// client metadata deserializer).
|
|
var db = CreateDb();
|
|
var controller = CreateEventsController(db);
|
|
var roomId = await SeedRoomAsync(db);
|
|
var ev = await CreateEventAsync(controller, roomId);
|
|
|
|
var result = await controller.GetResponses(ev.PlayerEventId);
|
|
var responses = Assert.IsType<List<EventResponse>>(Assert.IsType<OkObjectResult>(result.Result).Value);
|
|
|
|
var response = Assert.Single(responses);
|
|
var keys = TopLevelKeys(Serialize(response));
|
|
|
|
foreach (var key in new[] { "PlayerEventResponseId", "PlayerEventId", "PlayerId", "CreatedAt", "Type" })
|
|
Assert.Contains(key, keys);
|
|
}
|
|
|
|
[Fact]
|
|
public void UnreadAnnouncements_EmptyResult_IsBareArray()
|
|
{
|
|
var api = new ClubAnnouncementsAPI();
|
|
|
|
AssertIsEmptyArray(Serialize(api.GetUnreadAnnouncements()));
|
|
AssertIsEmptyArray(Serialize(api.GetUnreadSubscribedAnnouncements()));
|
|
}
|
|
|
|
[Fact]
|
|
public void EventGetByIdRoute_IsRegistered()
|
|
{
|
|
var method = typeof(EventsAPI).GetMethod(nameof(EventsAPI.GetEvent));
|
|
|
|
var templates = method!.GetCustomAttributes<HttpGetAttribute>().Select(a => a.Template);
|
|
|
|
Assert.Contains("v1/{eventId:long}", templates);
|
|
}
|
|
|
|
[Fact]
|
|
public void GotoEventRoute_IsRegistered()
|
|
{
|
|
var method = typeof(MatchmakingAPI).GetMethod(nameof(MatchmakingAPI.GoToEvent));
|
|
|
|
var templates = method!.GetCustomAttributes<HttpPostAttribute>().Select(a => a.Template);
|
|
|
|
Assert.Contains("goto/event/{eventId}", templates);
|
|
}
|
|
|
|
[Fact]
|
|
public void ClubAnnouncementsRoute_IsRegistered()
|
|
{
|
|
var method = typeof(ClubAnnouncementsAPI).GetMethod(nameof(ClubAnnouncementsAPI.PostClubAnnouncements));
|
|
|
|
var templates = method!.GetCustomAttributes<HttpPostAttribute>().Select(a => a.Template);
|
|
|
|
Assert.Contains("club/{clubId}", templates);
|
|
}
|
|
|
|
[Fact]
|
|
public void EventResponsesAndAttendeesRoutes_AreRegistered()
|
|
{
|
|
var responses = typeof(EventsAPI).GetMethod(nameof(EventsAPI.GetResponses))!;
|
|
var responseTemplates = responses.GetCustomAttributes<HttpGetAttribute>().Select(a => a.Template);
|
|
Assert.Contains("v1/{eventId:long}/responses", responseTemplates);
|
|
|
|
var attendees = typeof(EventsAPI).GetMethod(nameof(EventsAPI.GetAttendees))!;
|
|
var attendeeTemplates = attendees.GetCustomAttributes<HttpGetAttribute>().Select(a => a.Template);
|
|
Assert.Contains("v1/{eventId:long}/attendees", attendeeTemplates);
|
|
}
|
|
|
|
// The RRUI events service fetches club-event feeds from
|
|
// /api/playerevents/v1/... (verified from the client's string literals:
|
|
// "api/playerevents/v1/club/{0}", "api/playerevents/v1/clubs"). The single-club
|
|
// awaiter parses the paged envelope { Events, ContinuationToken }; the multi-club
|
|
// awaiter parses a bare list. Both were 404s -> "Failed to get club events".
|
|
[Fact]
|
|
public void ClubEventFeeds_AreRegisteredUnderPlayerevents()
|
|
{
|
|
var single = typeof(EventsAPI).GetMethod(nameof(EventsAPI.GetEventsByClub))!;
|
|
var singleTemplates = single.GetCustomAttributes<HttpGetAttribute>().Select(a => a.Template);
|
|
Assert.Contains("v1/club/{clubId:long}", singleTemplates);
|
|
Assert.Contains("v1/club_events/{clubId:long}", singleTemplates);
|
|
|
|
var multi = typeof(EventsAPI).GetMethod(nameof(EventsAPI.GetEventsByClubs))!;
|
|
var multiTemplates = multi.GetCustomAttributes<HttpGetAttribute>().Select(a => a.Template);
|
|
Assert.Contains("v1/clubs", multiTemplates);
|
|
|
|
var upcoming = typeof(EventsAPI).GetMethod(nameof(EventsAPI.GetUpcomingEvents))!;
|
|
Assert.Contains("v1/upcoming_events",
|
|
upcoming.GetCustomAttributes<HttpGetAttribute>().Select(a => a.Template));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ClubEventSingleFeed_ServesPagedEnvelope()
|
|
{
|
|
var api = CreateEventsController(CreateDb());
|
|
var result = Assert.IsType<OkObjectResult>(await api.GetEventsByClub(123));
|
|
var json = Serialize(result.Value);
|
|
using var doc = System.Text.Json.JsonDocument.Parse(json);
|
|
Assert.Equal(System.Text.Json.JsonValueKind.Object, doc.RootElement.ValueKind);
|
|
// Ordinal codec: Events (list) first, ContinuationToken (string) second.
|
|
var props = doc.RootElement.EnumerateObject().ToList();
|
|
Assert.Equal("Events", props[0].Name);
|
|
Assert.Equal("ContinuationToken", props[1].Name);
|
|
Assert.Equal(System.Text.Json.JsonValueKind.Array, props[0].Value.ValueKind);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ClubEventMultiFeed_ServesBareList()
|
|
{
|
|
var api = CreateEventsController(CreateDb());
|
|
var events = await api.GetEventsByClubs(new List<long> { 1, 2 });
|
|
Assert.NotNull(events);
|
|
Assert.Empty(events);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AccountSearch_ReturnsBareArrayWithPlayerKeys()
|
|
{
|
|
// Verified against the live recconned reference server: account search is a
|
|
// bare array of player DTOs with this exact lowercase key set. Missing keys
|
|
// (personalPronouns/identityFlags/displayEmoji) made the client's parser
|
|
// return null ("ArgumentNullException: source").
|
|
var db = CreateDb();
|
|
db.Profiles.Add(new RadicadeServer.RecNet.Auth.Profile
|
|
{
|
|
accountId = 7,
|
|
username = "bob",
|
|
displayName = "Bob",
|
|
profileImage = "img.png",
|
|
isJunior = false,
|
|
platforms = 0,
|
|
createdAt = DateTime.UtcNow,
|
|
});
|
|
await db.SaveChangesAsync();
|
|
|
|
var api = new AccountsAPI(db);
|
|
var result = await api.GetByName("bob");
|
|
|
|
var json = Serialize(result);
|
|
|
|
using var doc = JsonDocument.Parse(json);
|
|
Assert.Equal(JsonValueKind.Array, doc.RootElement.ValueKind);
|
|
Assert.Equal(1, doc.RootElement.GetArrayLength());
|
|
|
|
var playerKeys = doc.RootElement[0].EnumerateObject().Select(p => p.Name).ToArray();
|
|
foreach (var key in new[] { "accountId", "profileImage", "isJunior", "platforms",
|
|
"username", "displayName", "createdAt", "personalPronouns",
|
|
"identityFlags", "displayEmoji" })
|
|
Assert.Contains(key, playerKeys);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AccountSearch_ReportsStoredPronounsAndIdentityFlags()
|
|
{
|
|
// Both are stored on the profile and settable through
|
|
// /account/me/personalpronouns and /account/me/identityflags, so search results
|
|
// (the invite / player card) must echo what the player chose. They used to be
|
|
// hardcoded to 0 here, so every searched player showed no pronouns.
|
|
var db = CreateDb();
|
|
db.Profiles.Add(new RadicadeServer.RecNet.Auth.Profile
|
|
{
|
|
accountId = 7,
|
|
username = "bob",
|
|
displayName = "Bob",
|
|
profileImage = "img.png",
|
|
personalPronouns = 3,
|
|
identityFlags = 5,
|
|
});
|
|
await db.SaveChangesAsync();
|
|
|
|
var result = await new AccountsAPI(db).GetByName("bob");
|
|
|
|
using var doc = JsonDocument.Parse(Serialize(result));
|
|
var player = doc.RootElement[0];
|
|
Assert.Equal(3, player.GetProperty("personalPronouns").GetInt32());
|
|
Assert.Equal(5, player.GetProperty("identityFlags").GetInt32());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SetUsername_RefusesANameAnotherAccountAlreadyUses()
|
|
{
|
|
// Sign-in resolves an account by username OR display name, so letting two
|
|
// accounts share a name made that lookup land on whichever row came first and
|
|
// locked the other account out of the web login.
|
|
var db = CreateDb();
|
|
db.Profiles.Add(new RadicadeServer.RecNet.Auth.Profile
|
|
{
|
|
accountId = 7, username = "bob", displayName = "Bob", profileImage = "img.png"
|
|
});
|
|
db.Profiles.Add(new RadicadeServer.RecNet.Auth.Profile
|
|
{
|
|
accountId = 8, username = "alice", displayName = "Alice", profileImage = "img.png"
|
|
});
|
|
await db.SaveChangesAsync();
|
|
|
|
var api = new AccountsAPI(db);
|
|
SetUser(api, 8);
|
|
|
|
Assert.False((await api.SetUsername("bob")).success);
|
|
Assert.False((await api.SetUsername("Bob")).success);
|
|
Assert.False((await api.SetUsername(" ")).success);
|
|
|
|
Assert.True((await api.SetUsername(" alice2 ")).success);
|
|
|
|
var mine = await db.Profiles.SingleAsync(x => x.accountId == 8);
|
|
Assert.Equal("alice2", mine.username);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ProfileGallery_ReturnsOnlyPlayersPublicPhotos()
|
|
{
|
|
var db = CreateDb();
|
|
RadicadeServer.RecNet.CDN.ImageDB Photo(long id, long owner,
|
|
RadicadeServer.RecNet.CDN.SavedImageAccessibility access,
|
|
RadicadeServer.RecNet.CDN.SavedImageType type) => new()
|
|
{
|
|
Id = id, OwnerId = owner, Name = $"{id}.png", Accessibility = access,
|
|
ImageType = type, CreatedAt = new DateTime(2026, 1, 1).AddDays(id)
|
|
};
|
|
var publicAccess = RadicadeServer.RecNet.CDN.SavedImageAccessibility.Public;
|
|
var photoType = RadicadeServer.RecNet.CDN.SavedImageType.ShareCamera;
|
|
db.Images.AddRange(Photo(1, 7, publicAccess, photoType),
|
|
Photo(2, 7, publicAccess, photoType),
|
|
Photo(3, 8, publicAccess, photoType),
|
|
Photo(4, 7, RadicadeServer.RecNet.CDN.SavedImageAccessibility.Private, photoType),
|
|
Photo(5, 7, publicAccess, RadicadeServer.RecNet.CDN.SavedImageType.ProfileThumbnail));
|
|
await db.SaveChangesAsync();
|
|
|
|
var api = new ImagesAPI(db);
|
|
var images = await api.GetImagesFromPlayer(7);
|
|
// Reference behavior: a player's photo list serves every PUBLIC image
|
|
// they own (the profile-gallery grid), not just ShareCamera shots.
|
|
Assert.Equal(new long[] { 5, 2, 1 }, images.Select(image =>
|
|
(long)image.GetType().GetProperty("Id")!.GetValue(image)!));
|
|
Assert.All(images, image => Assert.Equal(7,
|
|
(long)image.GetType().GetProperty("PlayerId")!.GetValue(image)!));
|
|
Assert.Empty(await api.GetImagesFromPlayer(99));
|
|
}
|
|
|
|
[Fact]
|
|
public void ProfileWishlist_HasEconRoute()
|
|
{
|
|
var routes = typeof(RadicadeServer.API.Wishlists.WishlistAPI)
|
|
.GetCustomAttributes<RouteAttribute>().Select(route => route.Template);
|
|
Assert.Contains("econ/api/itemWishlists/v1", routes);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ProfilePresence_UsesPlayersLoginVersion()
|
|
{
|
|
var db = CreateDb();
|
|
db.Players.Add(new RadicadeServer.RecNet.Auth.PlayerData { AccountId = 7, AppVersion = "20230301" });
|
|
await db.SaveChangesAsync();
|
|
var presence = await Presence.CreateAsync(db, new RealPresence { playerId = 7 }, null);
|
|
Assert.Equal("20230301", presence.appVersion);
|
|
var missing = await Presence.CreateAsync(db, new RealPresence { playerId = 99 }, null);
|
|
Assert.Equal(DBConfig.GameVersion, missing.appVersion);
|
|
}
|
|
|
|
// A stock client's key ID is "p1" + the stock ".rec.net" tail literal; the
|
|
// metadata patcher rewrites that tail to ".radicade.internal", which is what
|
|
// the patched build compares against. Serving either one on the wrong route
|
|
// makes the client drop the image with "Image signature malformed" before it
|
|
// ever reaches RSA verification, so the two IDs are pinned here.
|
|
[Fact]
|
|
public void ImageRoutesServeTheKeyIdTheirClientBuildCanParse()
|
|
{
|
|
Assert.Equal("KEY:RSA:p1.radicade.internal", RadicadeServer.Images.ImageAPI.PatchedKeyId);
|
|
Assert.Equal("KEY:RSA:p1.rec.net", RadicadeServer.Images.ImageAPI.StockKeyId);
|
|
Assert.StartsWith($"key-id={RadicadeServer.Images.ImageAPI.PatchedKeyId}; data=",
|
|
RadicadeServer.Images.ImageAPI.SignatureString);
|
|
Assert.StartsWith($"key-id={RadicadeServer.Images.ImageAPI.StockKeyId}; data=",
|
|
RadicadeServer.Images.ImageAPI.StockSignatureString);
|
|
// With no override in the environment the bare route serves the stock ID; the
|
|
// override exists only so a mis-mapped deploy can be corrected by a restart.
|
|
Assert.Equal(RadicadeServer.Images.ImageAPI.StockKeyId, RadicadeServer.Images.ImageAPI.BareRouteKeyId);
|
|
// Same placeholder signature bytes on both: only the key ID decides which
|
|
// build accepts the header.
|
|
Assert.Equal(RadicadeServer.Images.ImageAPI.SignatureString[
|
|
RadicadeServer.Images.ImageAPI.SignatureString.IndexOf("data=", StringComparison.Ordinal)..],
|
|
RadicadeServer.Images.ImageAPI.StockSignatureString[
|
|
RadicadeServer.Images.ImageAPI.StockSignatureString.IndexOf("data=", StringComparison.Ordinal)..]);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("img")]
|
|
[InlineData("alternate")]
|
|
[InlineData("default")]
|
|
public async Task ProfileImage_AllRoutesIncludeContentSignature(string route)
|
|
{ var originalImages = DBConfig.CDNImages;
|
|
var directory = Path.Combine(Path.GetTempPath(), "radicade-image-test-" + Guid.NewGuid());
|
|
Directory.CreateDirectory(directory);
|
|
try
|
|
{
|
|
DBConfig.CDNImages = directory;
|
|
var imageName = route == "default" ? DBConfig.DefaultRoomImage : "profile.png";
|
|
await File.WriteAllBytesAsync(Path.Combine(directory, imageName),
|
|
Convert.FromBase64String("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII="));
|
|
var api = new RadicadeServer.Images.ImageAPI
|
|
{
|
|
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
|
|
};
|
|
var result = route switch
|
|
{
|
|
"img" => await api.GetImageFile(imageName, null, null, "", null),
|
|
"alternate" => await api.GetImageFileAlt(imageName, null, null, "", null),
|
|
_ => await api.GetDefaultImage(null, null, "", null)
|
|
};
|
|
|
|
Assert.IsType<PhysicalFileResult>(result);
|
|
|
|
// The client compares the header's key ID against a literal its own
|
|
// global-metadata.dat carries, so each route has to answer with the ID
|
|
// its caller can parse: /images/img/<name> is the metadata-patched
|
|
// build's own URL, and the bare route is what the BepInEx plugin's
|
|
// in-flight rewrite of img.rec.net asks for (a stock client).
|
|
var expectedKeyId = route == "img"
|
|
? RadicadeServer.Images.ImageAPI.PatchedKeyId
|
|
: RadicadeServer.Images.ImageAPI.StockKeyId;
|
|
// ValidateImageSignature splits on ';', trims the fields, and compares
|
|
// the complete key ID before decoding data.
|
|
var fields = api.Response.Headers["Content-Signature"].ToString()
|
|
.Split(';').Select(field => field.Trim()).ToArray();
|
|
Assert.Equal($"key-id={expectedKeyId}", fields[0]);
|
|
Assert.Equal(256, Convert.FromBase64String(fields[1]["data=".Length..]).Length);
|
|
}
|
|
finally
|
|
{
|
|
DBConfig.CDNImages = originalImages;
|
|
Directory.Delete(directory, recursive: true);
|
|
}
|
|
}
|
|
|
|
// A third patcher (or a player who edited their own metadata) produces a key ID
|
|
// neither route default matches, and its requests are byte-identical to everyone
|
|
// else's. A client that can state its own literal is answered with it verbatim;
|
|
// anything malformed falls back to the route default rather than being echoed.
|
|
[Theory]
|
|
[InlineData("img", "KEY:RSA:p1.rec.net", "KEY:RSA:p1.rec.net")]
|
|
[InlineData("bare", "KEY:RSA:p1.radicade.internal", "KEY:RSA:p1.radicade.internal")]
|
|
[InlineData("bare", "KEY:RSA:p1.something.internal", "KEY:RSA:p1.something.internal")]
|
|
[InlineData("bare", null, "KEY:RSA:p1.rec.net")]
|
|
[InlineData("bare", "", "KEY:RSA:p1.rec.net")]
|
|
[InlineData("img", null, "KEY:RSA:p1.radicade.internal")]
|
|
public void StatedKeyIdWinsOverRouteDefault(string route, string clientKeyId, string expected)
|
|
{
|
|
var routeDefault = route == "img"
|
|
? RadicadeServer.Images.ImageAPI.PatchedKeyId
|
|
: RadicadeServer.Images.ImageAPI.StockKeyId;
|
|
var header = RadicadeServer.Images.ImageAPI.SignatureFor(clientKeyId, route, routeDefault);
|
|
Assert.StartsWith($"key-id={expected}; data=", header);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("stock")]
|
|
[InlineData("patched")]
|
|
[InlineData("KEY:RSA:p1")]
|
|
[InlineData("KEY:RSA:p1.")]
|
|
[InlineData("KEY:RSA:p1.rec.net; data=steal")]
|
|
[InlineData("KEY:RSA:p1.rec.net\\r\\nX-Injected: 1")]
|
|
[InlineData("KEY:RSA:p2.rec.net")]
|
|
[InlineData("KEY:RSA:p1.a b")]
|
|
public void MalformedKeyIdsAreRefusedRatherThanEchoed(string clientKeyId)
|
|
{
|
|
Assert.False(RadicadeServer.Images.ImageAPI.IsWellFormedKeyId(clientKeyId));
|
|
var header = RadicadeServer.Images.ImageAPI.SignatureFor(
|
|
clientKeyId, "bare", RadicadeServer.Images.ImageAPI.StockKeyId);
|
|
Assert.StartsWith($"key-id={RadicadeServer.Images.ImageAPI.StockKeyId}; data=", header);
|
|
Assert.DoesNotContain("Injected", header);
|
|
}
|
|
|
|
[Fact]
|
|
public void AcceptableKeyIdsAreTheShapeTheClientBuildsItself()
|
|
{
|
|
Assert.True(RadicadeServer.Images.ImageAPI.IsWellFormedKeyId("KEY:RSA:p1.rec.net"));
|
|
Assert.True(RadicadeServer.Images.ImageAPI.IsWellFormedKeyId("KEY:RSA:p1.radicade.internal"));
|
|
Assert.True(RadicadeServer.Images.ImageAPI.IsWellFormedKeyId("KEY:RSA:p1.a-b_c.1"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ProfileBio_NullStoredBioReturnsEmptyString()
|
|
{
|
|
var db = CreateDb();
|
|
db.Profiles.Add(new RadicadeServer.RecNet.Auth.Profile
|
|
{
|
|
accountId = 7, username = "bob", displayName = "Bob",
|
|
profileImage = "img.png", bio = null
|
|
});
|
|
await db.SaveChangesAsync();
|
|
var response = await new AccountsAPI(db).GetBioFromId("7");
|
|
Assert.NotNull(response);
|
|
Assert.Equal(string.Empty, response.bio);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ProfilePrivacy_ReturnsSettingsObject()
|
|
{
|
|
var db = CreateDb();
|
|
db.Profiles.Add(new RadicadeServer.RecNet.Auth.Profile
|
|
{
|
|
accountId = 7, username = "bob", displayName = "Bob", profileImage = "img.png"
|
|
});
|
|
await db.SaveChangesAsync();
|
|
var result = await new AccountsAPI(db).GetAccountPrivacySettings(7);
|
|
var ok = Assert.IsType<OkObjectResult>(result);
|
|
Assert.Contains("accountId", Serialize(ok.Value));
|
|
}
|
|
|
|
[Fact]
|
|
public void Reputation_FreshAccountHasTheGateOpenAndNoCheerCounters()
|
|
{
|
|
// Two separate things share one payload. IsCheerful is the gate the client reads to
|
|
// decide whether this player may hand cheers out at all, and the reference server
|
|
// answers true for everyone. The five counters are the earned badges, and those used
|
|
// to be floored at 1, which is what made the client draw all five as already earned.
|
|
var api = new ReputationAPI(CreateDb());
|
|
var result = Assert.IsType<OkObjectResult>(api.GetReputation(7));
|
|
var json = Serialize(result.Value);
|
|
using var doc = JsonDocument.Parse(json);
|
|
Assert.True(doc.RootElement.GetProperty("IsCheerful").GetBoolean());
|
|
Assert.Equal(0, doc.RootElement.GetProperty("CheerGeneral").GetInt32());
|
|
Assert.Equal(0, doc.RootElement.GetProperty("CheerHelpful").GetInt32());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Reputation_SelectedCheerCreatesMissingReputation()
|
|
{
|
|
var db = CreateDb();
|
|
var api = new ReputationAPI(db);
|
|
SetUser(api, 7);
|
|
SetBody(api, "Category=40", "application/x-www-form-urlencoded");
|
|
|
|
var result = await api.SetSelectedCheer();
|
|
|
|
Assert.IsType<OkObjectResult>(result.Result);
|
|
// 40 is Creative on the client's ladder (0/10/20/30/40); the badge has to store
|
|
// the value the client sent so it renders the badge it thinks it picked.
|
|
Assert.Equal(40, (await db.Reputations.SingleAsync()).SelectedCheer);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Reputation_SelectedCheerAcceptsEveryShapeTheClientUses()
|
|
{
|
|
// The client posts this as a form, and [FromBody] rejected every non-JSON request
|
|
// with "415 Unsupported Media Type", which is why the badge never changed. All
|
|
// four shapes have to keep working.
|
|
async Task<long> SelectedCheerFor(string? body, string contentType, string query = "")
|
|
{
|
|
var db = CreateDb();
|
|
var api = new ReputationAPI(db);
|
|
SetUser(api, 7);
|
|
SetBody(api, body, contentType, query);
|
|
Assert.IsType<OkObjectResult>((await api.SetSelectedCheer()).Result);
|
|
return (await db.Reputations.SingleAsync()).SelectedCheer;
|
|
}
|
|
|
|
Assert.Equal(10, await SelectedCheerFor("Category=10", "application/x-www-form-urlencoded"));
|
|
Assert.Equal(20, await SelectedCheerFor("", null!, "?category=20"));
|
|
Assert.Equal(30, await SelectedCheerFor("30", "text/plain"));
|
|
Assert.Equal(40, await SelectedCheerFor("{\"Category\":40}", "application/json"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Reputation_CheerPlayerCountsTheCategoryTheClientSends()
|
|
{
|
|
// The client's ladder is 0/10/20/30/40. With sequential enum values none of the
|
|
// four cheers below matched a switch arm, so they only moved Noteriety.
|
|
var db = CreateDb();
|
|
var api = new ReputationAPI(db);
|
|
SetUser(api, 7);
|
|
SetBody(api, "PlayerId=9&Category=10", "application/x-www-form-urlencoded");
|
|
|
|
var result = await api.CheerPlayer();
|
|
|
|
Assert.IsType<OkObjectResult>(result.Result);
|
|
var reputation = await db.Reputations.SingleAsync(x => x.AccountId == 9);
|
|
Assert.Equal(1, reputation.CheerHelpful);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Reputation_SelectedCheerAcceptsTheDeveloperBadge()
|
|
{
|
|
// 9000 is the client's RecRoomDeveloper badge. The developer profile screen posts
|
|
// it like any other badge, and a server that only knew 0/10/20/30/40 answered
|
|
// with an unknown-category error instead of equipping it.
|
|
var db = CreateDb();
|
|
var api = new ReputationAPI(db);
|
|
SetUser(api, 7);
|
|
SetBody(api, "Category=9000", "application/x-www-form-urlencoded");
|
|
|
|
Assert.IsType<OkObjectResult>((await api.SetSelectedCheer()).Result);
|
|
Assert.Equal(9000, (await db.Reputations.SingleAsync()).SelectedCheer);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(50)] // the spendable credit balance
|
|
[InlineData(9000)] // the staff-only badge
|
|
public async Task Reputation_CheerPlayerRefusesCategoriesThatAreNotAwardable(int category)
|
|
{
|
|
// Credit is the currency the client spends on cheers; a client that could buy it
|
|
// by cheering would never run out. RecRoomDeveloper is staff-only. Neither is
|
|
// something a player can hand out.
|
|
var db = CreateDb();
|
|
var api = new ReputationAPI(db);
|
|
SetUser(api, 7);
|
|
SetBody(api, $"PlayerId=9&Category={category}", "application/x-www-form-urlencoded");
|
|
|
|
var result = await api.CheerPlayer();
|
|
|
|
Assert.IsType<BadRequestObjectResult>(result.Result);
|
|
Assert.Empty(await db.Reputations.ToListAsync());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CreateActionLink_ReturnsScalarResultCode()
|
|
{
|
|
// The client converts the response root ("Object must implement
|
|
// IConvertible" when it gets an array), so the endpoint must return a
|
|
// bare scalar, not an array/object.
|
|
var api = new LinkAPI();
|
|
var context = new DefaultHttpContext();
|
|
context.Request.Method = "POST";
|
|
context.Request.ContentType = "application/json";
|
|
context.Request.Body = new MemoryStream(System.Text.Encoding.UTF8.GetBytes("{\"roomId\": 10}"));
|
|
api.ControllerContext = new ControllerContext { HttpContext = context };
|
|
|
|
var action = await api.CheckActionLink();
|
|
var ok = Assert.IsType<OkObjectResult>(action.Result);
|
|
|
|
var json = Serialize(ok.Value);
|
|
using var doc = JsonDocument.Parse(json);
|
|
Assert.Equal(JsonValueKind.Number, doc.RootElement.ValueKind);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task VersionCheck_AllowsPlay_WhenNoGameUpdateIsPublished()
|
|
{
|
|
// Wire shape pinned to the working 2023-era reference server (FemRec-2023,
|
|
// archived at essentialsasset/RecRoom-Live-Servers): the client's strict
|
|
// key lookups abort boot ("RecNet version check failed", error code:
|
|
// student) unless ValidVersion is present, even on HTTP 200. With no Game
|
|
// Update published the endpoint never gates - the client sends varying query
|
|
// keys across boots (v=, version=, none) - and the version a client sends is
|
|
// still never what decides. The one thing that does flip this answer is a
|
|
// live Game Update announcement: see GameUpdateVersionCheckTests.
|
|
var db = CreateDb();
|
|
db.ServerConfig.Add(new ServerConfigVal("GameVersion", DBConfig.GameVersion));
|
|
await db.SaveChangesAsync();
|
|
var api = new VersionCheckAPI(db);
|
|
|
|
foreach (var version in new[] { "20230414", DBConfig.GameVersion, "19990101", null })
|
|
{
|
|
var ok = Assert.IsType<OkObjectResult>(await api.Get(version, version));
|
|
using var doc = JsonDocument.Parse(Serialize(ok.Value));
|
|
|
|
Assert.Equal(0, doc.RootElement.GetProperty("ValidVersion").GetInt32());
|
|
Assert.Equal(0, doc.RootElement.GetProperty("VersionStatus").GetInt32());
|
|
Assert.Equal(0, doc.RootElement.GetProperty("UpdateNotificationStage").GetInt32());
|
|
Assert.False(doc.RootElement.GetProperty("IsVersionIslanded").GetBoolean());
|
|
Assert.False(doc.RootElement.GetProperty("IsCrossPlayDisabled").GetBoolean());
|
|
Assert.Equal(5, doc.RootElement.EnumerateObject().Count());
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void VersionCheck_IslandedVersions_IsBareEmptyArray()
|
|
{
|
|
var api = new VersionCheckAPI(CreateDb());
|
|
|
|
var ok = Assert.IsType<OkObjectResult>(api.GetIslandedVersions());
|
|
|
|
AssertIsEmptyArray(Serialize(ok.Value));
|
|
}
|
|
|
|
[Fact]
|
|
public void StartupConfig_ReferencePlaceholderEndpoints_ReturnExpectedShapes()
|
|
{
|
|
var api = new ConfigAPI(CreateDb());
|
|
|
|
foreach (var result in new[]
|
|
{
|
|
api.GetAzureSpeechConfig(),
|
|
api.GetBacktraceConfig(),
|
|
api.GetStatsigUserProperties(),
|
|
api.GetVoiceConfig(),
|
|
})
|
|
{
|
|
Assert.IsType<OkObjectResult>(result);
|
|
}
|
|
|
|
var azure = Assert.IsType<OkObjectResult>(api.GetAzureSpeechConfig());
|
|
var azureKeys = TopLevelKeys(Serialize(azure.Value));
|
|
Assert.Contains("Key", azureKeys);
|
|
Assert.Contains("Region", azureKeys);
|
|
Assert.Contains("Enabled", azureKeys);
|
|
|
|
var backtrace = Assert.IsType<OkObjectResult>(api.GetBacktraceConfig());
|
|
var backtraceKeys = TopLevelKeys(Serialize(backtrace.Value));
|
|
Assert.Contains("ReportBudget", backtraceKeys);
|
|
Assert.Contains("VersionRegex", backtraceKeys);
|
|
|
|
var amplitude = Assert.IsType<OkObjectResult>(api.GetAmplitudeConfig());
|
|
using var amplitudeDoc = JsonDocument.Parse(Serialize(amplitude.Value));
|
|
Assert.Equal("e1693a1003671058b6abc356c8ba8d59", amplitudeDoc.RootElement.GetProperty("AmplitudeKey").GetString());
|
|
Assert.True(amplitudeDoc.RootElement.GetProperty("UseRudderStack").GetBoolean());
|
|
Assert.Equal("23NiJHIgu3koaGNCZIiuYvIQNCu", amplitudeDoc.RootElement.GetProperty("RudderStackKey").GetString());
|
|
Assert.True(amplitudeDoc.RootElement.GetProperty("UseStatSig").GetBoolean());
|
|
Assert.Equal("client-SBZkOrjD3r1Cat3f3W8K6sBd11WKlXZXIlCWj6l4Aje", amplitudeDoc.RootElement.GetProperty("StatSigKey").GetString());
|
|
Assert.Equal(0, amplitudeDoc.RootElement.GetProperty("StatSigEnvironment").GetInt32());
|
|
}
|
|
|
|
[Fact]
|
|
public void NameServer_IncludesRecRoom2023ServiceKeys()
|
|
{
|
|
var nameServer = DBConfig.NameServer;
|
|
var keys = TopLevelKeys(Serialize(nameServer));
|
|
|
|
foreach (var key in new[]
|
|
{
|
|
"Accounts", "AI", "API", "Auth", "BugReporting", "Cards", "CDN",
|
|
"Chat", "Clubs", "CMS", "Commerce", "Data", "DataCollection",
|
|
"Discovery", "Econ", "GameLogs", "Geo", "Images", "Leaderboard",
|
|
"Link", "Lists", "Locker", "Matchmaking", "Moderation",
|
|
"Notifications", "PlatformNotifications", "PlayerSettings",
|
|
"RoomComments", "RoomieIntegrations", "Rooms", "Storage",
|
|
"Strings", "StringsCDN", "Studio", "Thorn", "Videos", "WWW",
|
|
})
|
|
{
|
|
Assert.Contains(key, keys);
|
|
}
|
|
|
|
// The dump's `Service` label is a logical route namespace: e.g. Econ routes share
|
|
// the econ base while API routes share the API base, and Commerce and Leaderboard
|
|
// are separate bases. Each advertised base is a host of its own with no path - the
|
|
// client appends its own path literal to it and the patch turns the host back into
|
|
// the route prefix (NameserverRedirect.cs).
|
|
Assert.Equal(DBConfig.ServiceUrl("api"), nameServer.API);
|
|
Assert.Equal(DBConfig.ServiceUrl("econ"), nameServer.Econ);
|
|
Assert.Equal(DBConfig.ServiceUrl("commerce"), nameServer.Commerce);
|
|
Assert.Equal(DBConfig.ServiceUrl("leaderboard"), nameServer.Leaderboard);
|
|
Assert.DoesNotContain("Service", keys);
|
|
}
|
|
|
|
[Fact]
|
|
public void EndpointServiceLabelsResolveThroughTheirConfiguredBaseHosts()
|
|
{
|
|
// The 2023 endpoint dump's Service column is logical classification. The
|
|
// nameserver values are base URLs - hosts now, one per service - and endpoint
|
|
// route templates supply the rest.
|
|
var apiBase = typeof(EventsAPI).GetCustomAttributes<RouteAttribute>()
|
|
.Select(attribute => attribute.Template);
|
|
Assert.Contains("api/playerevents", apiBase);
|
|
Assert.Equal(DBConfig.ServiceUrl("api"), DBConfig.NameServer.API);
|
|
|
|
var econBase = typeof(AvatarV1).GetCustomAttributes<RouteAttribute>()
|
|
.Select(attribute => attribute.Template);
|
|
Assert.Contains("econ/api/avatar/v1", econBase);
|
|
Assert.Equal(DBConfig.ServiceUrl("econ"), DBConfig.NameServer.Econ);
|
|
|
|
var commerceBase = typeof(RadicadeServer.Commerce.CommercePlaceholderAPI)
|
|
.GetCustomAttributes<RouteAttribute>()
|
|
.Select(attribute => attribute.Template);
|
|
Assert.Contains("commerce", commerceBase);
|
|
var catalog = typeof(RadicadeServer.Commerce.CommercePlaceholderAPI)
|
|
.GetMethod("GetCatalogAll")!;
|
|
Assert.Contains("api/catalog/v1/all", catalog.GetCustomAttributes<HttpGetAttribute>()
|
|
.Select(attribute => attribute.Template));
|
|
Assert.Equal(DBConfig.ServiceUrl("commerce"), DBConfig.NameServer.Commerce);
|
|
|
|
var leaderboardRoutes = typeof(LeaderboardAPI)
|
|
.GetCustomAttributes<RouteAttribute>()
|
|
.Select(attribute => attribute.Template);
|
|
Assert.Contains("leaderboard/leaderboard", leaderboardRoutes);
|
|
var leaderboardCompatRoutes = typeof(LeaderboardCompatAPI)
|
|
.GetCustomAttributes<RouteAttribute>()
|
|
.Select(attribute => attribute.Template);
|
|
Assert.Contains("leaderboard", leaderboardCompatRoutes);
|
|
Assert.Equal(DBConfig.ServiceUrl("leaderboard"), DBConfig.NameServer.Leaderboard);
|
|
}
|
|
|
|
[Fact]
|
|
public void AvatarV1_DefaultUnlocks_HasEconServiceRouteAlias()
|
|
{
|
|
var routes = typeof(AvatarV1).GetCustomAttributes<RouteAttribute>().Select(a => a.Template);
|
|
|
|
Assert.Contains("api/avatar/v1", routes);
|
|
Assert.Contains("econ/api/avatar/v1", routes);
|
|
}
|
|
|
|
[Fact]
|
|
public void AvatarV1_DefaultBaseAvatarItems_ReturnsReferenceShape()
|
|
{
|
|
var api = new AvatarV1(CreateDb());
|
|
|
|
var ok = Assert.IsType<OkObjectResult>(api.GetDefaultBaseAvatarItems());
|
|
using var doc = JsonDocument.Parse(Serialize(ok.Value));
|
|
|
|
Assert.Equal(JsonValueKind.Array, doc.RootElement.ValueKind);
|
|
Assert.All(doc.RootElement.EnumerateArray(), item =>
|
|
Assert.True(item.GetProperty("IsBaseAvatarItem").GetBoolean()));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AvatarV4_Items_FiltersExistingLegacyUnlockBlobs()
|
|
{
|
|
var db = CreateDb();
|
|
db.Unlocks.Add(new RadicadeServer.RecNet.Accounts.PlayerUnlocks
|
|
{
|
|
AccountId = 23,
|
|
AvatarItems = """
|
|
[
|
|
{
|
|
"AvatarItemDesc": "07116e9b-b182-40b4-90c2-99738e7aedd6,",
|
|
"UnlockedLevel": 0,
|
|
"PlatformMask": -1,
|
|
"FriendlyName": "Parrot Purse",
|
|
"Tooltip": "",
|
|
"Rarity": 50
|
|
},
|
|
{
|
|
"AvatarItemDesc": "d0a9262f-5504-46a7-bb10-7507503db58e,95e4cc30-cb68-473d-a395-feadf5b51512,0440f08f-ef1d-49d8-942b-523056e8bb45,",
|
|
"UnlockedLevel": 0,
|
|
"PlatformMask": -1,
|
|
"FriendlyName": "Coach Shirt",
|
|
"Tooltip": "",
|
|
"Rarity": 0
|
|
}
|
|
]
|
|
"""
|
|
});
|
|
await db.SaveChangesAsync();
|
|
|
|
var api = new AvatarV4(db);
|
|
SetUser(api, 23);
|
|
|
|
var ok = Assert.IsType<OkObjectResult>((await api.GetLocalAvatarItems()).Result);
|
|
using var doc = JsonDocument.Parse(Serialize(ok.Value));
|
|
var descs = doc.RootElement
|
|
.EnumerateArray()
|
|
.Select(item => item.GetProperty("AvatarItemDesc").GetString())
|
|
.ToArray();
|
|
|
|
Assert.DoesNotContain("07116e9b-b182-40b4-90c2-99738e7aedd6,", descs);
|
|
Assert.Contains("d0a9262f-5504-46a7-bb10-7507503db58e,95e4cc30-cb68-473d-a395-feadf5b51512,0440f08f-ef1d-49d8-942b-523056e8bb45,", descs);
|
|
Assert.Equal(descs.Length, descs.Distinct().Count());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AvatarV4_Items_PreservesPlayerUnlocksBeyondCoachShirt()
|
|
{
|
|
var db = CreateDb();
|
|
const string goggles = "_qXaNbm4yEeyalo372QbvA,";
|
|
db.Unlocks.Add(new PlayerUnlocks
|
|
{
|
|
AccountId = 24,
|
|
AvatarItems = """
|
|
[{"AvatarItemDesc":"_qXaNbm4yEeyalo372QbvA,,,","UnlockedLevel":0,"PlatformMask":-1,"FriendlyName":"Biker Goggles","Tooltip":"","Rarity":20}]
|
|
"""
|
|
});
|
|
await db.SaveChangesAsync();
|
|
var api = new AvatarV4(db);
|
|
SetUser(api, 24);
|
|
|
|
var ok = Assert.IsType<OkObjectResult>((await api.GetLocalAvatarItems()).Result);
|
|
var items = Assert.IsType<List<RadicadeServer.RecNet.Avatar.AvatarItemV4>>(ok.Value);
|
|
Assert.Contains(items, item => item.AvatarItemDesc == goggles);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AvatarV4_Items_ServesCatalogToUnauthenticatedWardrobeRequests()
|
|
{
|
|
var db = CreateDb();
|
|
var api = new AvatarV4(db);
|
|
// The client asks for this list from inside a room when the customize menu opens,
|
|
// and that request arrives without a usable bearer token. Answering 401 there
|
|
// leaves the player with an empty wardrobe, so the route must stay anonymous.
|
|
api.ControllerContext = new ControllerContext
|
|
{
|
|
HttpContext = new DefaultHttpContext { User = new ClaimsPrincipal(new ClaimsIdentity()) }
|
|
};
|
|
|
|
var result = await api.GetLocalAvatarItems();
|
|
|
|
Assert.IsType<OkObjectResult>(result.Result);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The 2023 client saves outfits through <c>/econ/api/avatar/v4/saved/set</c> and expects a
|
|
/// lean <c>{ Success, Slot }</c> ack - not the outfit it just posted. A 404 here is what the
|
|
/// client's "save this outfit" silently does when the route is missing.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task AvatarV4_SavedOutfitSet_AnswersTheLeanSuccessAck()
|
|
{
|
|
var db = CreateDb();
|
|
var api = new AvatarV4(db);
|
|
SetUser(api, 23);
|
|
|
|
var result = await api.SetSavedOutfit(new Outfit
|
|
{
|
|
Slot = 1,
|
|
PreviewImageName = "outfit-one.png",
|
|
OutfitSelectionsV2 = "[\"a\",\"b\"]",
|
|
CustomAvatarItems = new object[] { "item-1" },
|
|
});
|
|
|
|
var ok = Assert.IsType<OkObjectResult>(result);
|
|
using var doc = JsonDocument.Parse(Serialize(ok.Value));
|
|
Assert.True(doc.RootElement.GetProperty("Success").GetBoolean());
|
|
Assert.Equal(1, doc.RootElement.GetProperty("Slot").GetInt32());
|
|
}
|
|
|
|
/// <summary>
|
|
/// v4 and v3 are one slot table: the client saves through v4 and lists through v3, and
|
|
/// "save over this outfit" is a re-save of an existing slot. Separate stores would let a
|
|
/// player save an outfit that their own outfit list cannot find.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task AvatarV4_SavedOutfitSet_IsVisibleToTheV3ListAndOverwritesItsSlot()
|
|
{
|
|
var db = CreateDb();
|
|
var v4 = new AvatarV4(db);
|
|
SetUser(v4, 77);
|
|
|
|
Assert.IsType<OkObjectResult>(await v4.SetSavedOutfit(new Outfit { Slot = 0, PreviewImageName = "first.png" }));
|
|
Assert.IsType<OkObjectResult>(await v4.SetSavedOutfit(new Outfit { Slot = 1, PreviewImageName = "second.png" }));
|
|
Assert.IsType<OkObjectResult>(await v4.SetSavedOutfit(new Outfit { Slot = 0, PreviewImageName = "replaced.png" }));
|
|
|
|
var v3 = new AvatarV3(db);
|
|
SetUser(v3, 77);
|
|
var outfits = await v3.GetSavedOutfits();
|
|
|
|
Assert.Equal(2, outfits.Count);
|
|
Assert.Equal("replaced.png", outfits.Single(x => x.Slot == 0).PreviewImageName);
|
|
Assert.Equal("second.png", outfits.Single(x => x.Slot == 1).PreviewImageName);
|
|
}
|
|
|
|
/// <summary>
|
|
/// A body with no slot is refused instead of defaulted: guessing would overwrite slot 0,
|
|
/// which is exactly the outfit the player did not ask to replace.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task AvatarV4_SavedOutfitSet_RefusesABodyWithNoSlot()
|
|
{
|
|
var db = CreateDb();
|
|
var api = new AvatarV4(db);
|
|
SetUser(api, 23);
|
|
|
|
var result = await api.SetSavedOutfit(new Outfit { PreviewImageName = "orphan.png" });
|
|
|
|
Assert.IsType<BadRequestObjectResult>(result);
|
|
Assert.Empty(db.Avatars);
|
|
}
|
|
|
|
/// <summary>
|
|
/// A player who has never saved an outfit gets an empty list, not a 404 - the client's
|
|
/// saved-outfits screen reads this at the same time as the wardrobe.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task AvatarV4_SavedOutfitSet_NeverLeavesANullSlotBehind()
|
|
{
|
|
var db = CreateDb();
|
|
var api = new AvatarV4(db);
|
|
SetUser(api, 23);
|
|
|
|
// Pre-existing player data can carry the leading null the old handler stored when it
|
|
// accepted an empty body.
|
|
db.Avatars.Add(new PlayerAvatar { AccountId = 23, Avatar = "{}", Outfits = "[null]" });
|
|
await db.SaveChangesAsync();
|
|
|
|
Assert.IsType<OkObjectResult>(await api.SetSavedOutfit(new Outfit { Slot = 0 }));
|
|
|
|
var stored = await db.Avatars.AsNoTracking().SingleAsync(x => x.AccountId == 23);
|
|
using var doc = JsonDocument.Parse(stored.Outfits);
|
|
var entry = Assert.Single(doc.RootElement.EnumerateArray());
|
|
Assert.NotEqual(JsonValueKind.Null, entry.ValueKind);
|
|
|
|
var v3 = new AvatarV3(db);
|
|
SetUser(v3, 23);
|
|
Assert.Single(await v3.GetSavedOutfits());
|
|
}
|
|
|
|
[Fact]
|
|
public void AvatarCatalog_PreservesBrowsableMetadata()
|
|
{
|
|
var item = new RadicadeServer.RecNet.Avatar.AvatarItem
|
|
{
|
|
AvatarItemDesc = "_OWVy3z6iU-M3-zbQgSLig,,,", AvatarItemId = 835,
|
|
AvatarItemType = 0, FriendlyName = "Vampire Hunter Gloves (Blue)",
|
|
ThumbnailImage = "cimomkml6k4toyowd1voh7hqm.png", TagList = "gloves",
|
|
CreatedAt = new DateTime(2018, 11, 1, 17, 51, 50, DateTimeKind.Utc)
|
|
};
|
|
var copy = Assert.Single(RadicadeServer.RecNet.Avatar.AvatarUnlockCatalog.KeepSupportedItems([item]));
|
|
using var doc = JsonDocument.Parse(Serialize(copy));
|
|
Assert.Equal(835, doc.RootElement.GetProperty("AvatarItemId").GetInt32());
|
|
Assert.Equal(item.ThumbnailImage, copy.ThumbnailImage);
|
|
Assert.Equal(item.TagList, copy.TagList);
|
|
Assert.Equal(item.CreatedAt, copy.CreatedAt);
|
|
Assert.False(doc.RootElement.GetProperty("IsBaseAvatarItem").GetBoolean());
|
|
Assert.Equal(0, doc.RootElement.GetProperty("AvatarItemType").GetInt32());
|
|
}
|
|
|
|
[Fact]
|
|
public void AvatarCatalog_ReadsConfiguredCatalogInsteadOfBootFallback()
|
|
{
|
|
var originalCatalog = DBConfig.DefaultAvatarCatalog;
|
|
var originalFallback = DBConfig.DefaultAvatarUnlocks;
|
|
try
|
|
{
|
|
DBConfig.DefaultAvatarCatalog = """
|
|
[{"AvatarItemDesc":"catalog-item,,,","UnlockedLevel":0,"PlatformMask":-1,"FriendlyName":"Catalog Item","Rarity":1}]
|
|
""";
|
|
DBConfig.DefaultAvatarUnlocks = """
|
|
[{"AvatarItemDesc":"fallback-item,,,","UnlockedLevel":0,"PlatformMask":-1,"FriendlyName":"Fallback","Rarity":1}]
|
|
""";
|
|
var items = RadicadeServer.RecNet.Avatar.AvatarUnlockCatalog.GetSafeDefaultItems();
|
|
Assert.Contains(items, item => item.AvatarItemDesc == "catalog-item,");
|
|
Assert.DoesNotContain(items, item => item.AvatarItemDesc == "fallback-item,");
|
|
}
|
|
finally
|
|
{
|
|
DBConfig.DefaultAvatarCatalog = originalCatalog;
|
|
DBConfig.DefaultAvatarUnlocks = originalFallback;
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AvatarV2_GetLocalAvatar_RepairsShortUnsafeOutfitSelection()
|
|
{
|
|
var db = CreateDb();
|
|
db.Avatars.Add(new RadicadeServer.RecNet.Accounts.Local.PlayerAvatar
|
|
{
|
|
AccountId = 2,
|
|
Avatar = """
|
|
{"OutfitSelections":"d0a9262f-5504-46a7-bb10-7507503db58e,,,,1","HairColor":"fT0t-J1A-0i_tdoogFEz-g","SkinColor":"85343b16-d58a-4091-96d8-083a81fb03ae","FaceFeatures":"{}"}
|
|
""",
|
|
Outfits = "[]"
|
|
});
|
|
await db.SaveChangesAsync();
|
|
|
|
var api = new AvatarV2(db);
|
|
SetUser(api, 2);
|
|
|
|
var avatar = await api.GetLocalAvatar();
|
|
var storedAvatar = await db.Avatars.SingleAsync(x => x.AccountId == 2);
|
|
|
|
Assert.NotNull(avatar);
|
|
Assert.Equal(
|
|
"d0a9262f-5504-46a7-bb10-7507503db58e,95e4cc30-cb68-473d-a395-feadf5b51512,0440f08f-ef1d-49d8-942b-523056e8bb45,,1",
|
|
avatar.OutfitSelections);
|
|
Assert.Contains("95e4cc30-cb68-473d-a395-feadf5b51512", storedAvatar.Avatar);
|
|
Assert.Contains("0440f08f-ef1d-49d8-942b-523056e8bb45", storedAvatar.Avatar);
|
|
}
|
|
|
|
[Fact]
|
|
public void AvatarUnlockCatalog_PreservesConfiguredItemsExceptKnownUnsafeEntries()
|
|
{
|
|
var original = DBConfig.DefaultAvatarUnlocks;
|
|
try
|
|
{
|
|
DBConfig.DefaultAvatarUnlocks = """
|
|
[
|
|
{
|
|
"AvatarItemDesc": "07116e9b-b182-40b4-90c2-99738e7aedd6,",
|
|
"UnlockedLevel": 0,
|
|
"PlatformMask": -1,
|
|
"FriendlyName": "Parrot Purse",
|
|
"Tooltip": "",
|
|
"Rarity": 50
|
|
},
|
|
{
|
|
"AvatarItemDesc": "11b3413f-2c18-41cb-8ef2-55fb825ed476,",
|
|
"UnlockedLevel": 0,
|
|
"PlatformMask": -1,
|
|
"FriendlyName": "Missing 2023 Client Prefab",
|
|
"Tooltip": "",
|
|
"Rarity": 50
|
|
},
|
|
{
|
|
"AvatarItemDesc": "_qXaNbm4yEeyalo372QbvA,,,",
|
|
"UnlockedLevel": 0,
|
|
"PlatformMask": -1,
|
|
"FriendlyName": "Biker Goggles",
|
|
"Tooltip": "",
|
|
"Rarity": 20
|
|
}
|
|
]
|
|
""";
|
|
|
|
var items = RadicadeServer.RecNet.Avatar.AvatarUnlockCatalog.GetSafeDefaultItems();
|
|
|
|
Assert.DoesNotContain(items, item =>
|
|
item.AvatarItemDesc == "07116e9b-b182-40b4-90c2-99738e7aedd6,");
|
|
Assert.DoesNotContain(items, item =>
|
|
item.AvatarItemDesc == "11b3413f-2c18-41cb-8ef2-55fb825ed476,");
|
|
Assert.Contains(items, item =>
|
|
item.AvatarItemDesc == "_qXaNbm4yEeyalo372QbvA,");
|
|
Assert.Single(items);
|
|
}
|
|
finally
|
|
{
|
|
DBConfig.DefaultAvatarUnlocks = original;
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void EacChallenge_ReturnsJsonStringNotRawText()
|
|
{
|
|
var api = new EACApi();
|
|
|
|
var json = Assert.IsType<JsonResult>(api.GetFakeEACChallenge());
|
|
var serialized = Serialize(json.Value);
|
|
|
|
using var doc = JsonDocument.Parse(serialized);
|
|
Assert.Equal(JsonValueKind.String, doc.RootElement.ValueKind);
|
|
Assert.StartsWith("AQAAA", doc.RootElement.GetString());
|
|
}
|
|
|
|
[Fact]
|
|
public void RecRoom2023_ServiceRouteAliases_AreRegistered()
|
|
{
|
|
string[] Routes<T>() => typeof(T).GetCustomAttributes<RouteAttribute>().Select(a => a.Template ?? "").ToArray();
|
|
|
|
Assert.Contains("econ/api/avatar/v2", Routes<AvatarV2>());
|
|
Assert.Contains("econ/api/avatar/v4", Routes<AvatarV4>());
|
|
|
|
// The client saves outfits through v4 and reads them back through v3.
|
|
Assert.Contains("saved/set", typeof(AvatarV4)
|
|
.GetMethod(nameof(AvatarV4.SetSavedOutfit))!
|
|
.GetCustomAttributes<HttpPostAttribute>()
|
|
.Select(a => a.Template));
|
|
Assert.Contains("saved", typeof(AvatarV3)
|
|
.GetMethod(nameof(AvatarV3.GetSavedOutfits))!
|
|
.GetCustomAttributes<HttpGetAttribute>()
|
|
.Select(a => a.Template));
|
|
Assert.Contains("econ/api/objectives/v1", Routes<ObjectivesV1>());
|
|
Assert.Contains("econ/econ/customAvatarItems", Routes<CustomAvatarItemsAPI>());
|
|
Assert.Contains("econ/api/storefronts/v3", Routes<StorefrontsV3>());
|
|
Assert.Contains("cdn/sigs", Routes<SigsCDN>());
|
|
Assert.Contains("playersettings", Routes<PlayerSettingsAPI>());
|
|
|
|
var settingsRoute = typeof(PlayerSettingsAPI)
|
|
.GetMethod(nameof(PlayerSettingsAPI.GetSettings))!
|
|
.GetCustomAttributes<HttpGetAttribute>()
|
|
.Select(a => a.Template);
|
|
Assert.Contains("", settingsRoute);
|
|
Assert.Contains("playersettings", settingsRoute);
|
|
|
|
var setSettingsRoute = typeof(PlayerSettingsAPI)
|
|
.GetMethod(nameof(PlayerSettingsAPI.PutSettings))!
|
|
.GetCustomAttributes<HttpPutAttribute>()
|
|
.Select(a => a.Template);
|
|
Assert.Contains("", setSettingsRoute);
|
|
Assert.Contains("playersettings", setSettingsRoute);
|
|
|
|
var exclusiveLoginRoute = typeof(MatchmakingPlayerAPI)
|
|
.GetMethod(nameof(MatchmakingPlayerAPI.ExclusiveLogin))!
|
|
.GetCustomAttributes<HttpPostAttribute>()
|
|
.Select(a => a.Template);
|
|
Assert.Contains("exclusivelogin", exclusiveLoginRoute);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task MatchmakingPlayer_ExclusiveLogin_UsesRegularLoginShape()
|
|
{
|
|
var factory = new TestDbContextFactory();
|
|
RoomUtils.DbFactory = factory;
|
|
MatchmakingUtils.DbFactory = factory;
|
|
|
|
await using var db = factory.CreateDbContext();
|
|
db.Presences.Add(new RealPresence { playerId = 52 });
|
|
db.Rooms.Add(new RoomData
|
|
{
|
|
RoomId = 1,
|
|
Name = "DormRoom",
|
|
IsDorm = true,
|
|
CreatorAccountId = 1,
|
|
Accessibility = Accessibility.Private,
|
|
MaxPlayers = 4,
|
|
CreatedAt = DateTime.UtcNow,
|
|
});
|
|
db.SubRooms.Add(new SubRoom
|
|
{
|
|
RoomId = 1,
|
|
Name = "DormRoom",
|
|
UnitySceneId = "DormRoomScene",
|
|
MaxPlayers = 4,
|
|
Accessibility = Accessibility.Private,
|
|
DataBlob = "",
|
|
});
|
|
await db.SaveChangesAsync();
|
|
|
|
var api = new MatchmakingPlayerAPI(db);
|
|
SetUser(api, 52, "steam-test");
|
|
|
|
var ok = Assert.IsType<OkObjectResult>(
|
|
await api.ExclusiveLogin(loginLock: "lock-52"));
|
|
using var doc = JsonDocument.Parse(Serialize(ok.Value));
|
|
|
|
Assert.True(doc.RootElement.GetProperty("success").GetBoolean());
|
|
Assert.Equal(52, doc.RootElement.GetProperty("playerId").GetInt64());
|
|
Assert.Equal("lock-52", doc.RootElement.GetProperty("loginLock").GetString());
|
|
Assert.NotNull((await db.Presences.FirstAsync(x => x.playerId == 52)).roomInstance);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task MatchmakingPlayerPresence_DoesNotCreateDormAsALookupSideEffect()
|
|
{
|
|
const long playerId = 77;
|
|
|
|
var factory = new TestDbContextFactory();
|
|
RoomUtils.DbFactory = factory;
|
|
MatchmakingUtils.DbFactory = factory;
|
|
|
|
await using var db = factory.CreateDbContext();
|
|
db.Profiles.Add(new RadicadeServer.RecNet.Auth.Profile
|
|
{
|
|
accountId = playerId,
|
|
username = "test-player",
|
|
displayName = "TestPlayer",
|
|
profileImage = "DefaultProfileImage",
|
|
platforms = 0,
|
|
createdAt = DateTime.UtcNow,
|
|
});
|
|
db.Presences.Add(new RealPresence
|
|
{
|
|
playerId = playerId,
|
|
isOnline = false,
|
|
deviceClass = DeviceClass.Screen,
|
|
statusVisibility = StatusVisibility.Public,
|
|
});
|
|
db.Rooms.Add(new RoomData
|
|
{
|
|
RoomId = 1,
|
|
Name = "DormRoom",
|
|
IsDorm = true,
|
|
CreatorAccountId = 1,
|
|
Accessibility = Accessibility.Private,
|
|
MaxPlayers = 4,
|
|
CreatedAt = DateTime.UtcNow,
|
|
});
|
|
db.SubRooms.Add(new SubRoom
|
|
{
|
|
RoomId = 1,
|
|
Name = "DormRoom",
|
|
UnitySceneId = "DormRoomScene",
|
|
MaxPlayers = 4,
|
|
Accessibility = Accessibility.Private,
|
|
DataBlob = "",
|
|
});
|
|
await db.SaveChangesAsync();
|
|
|
|
var api = new MatchmakingAPI(db);
|
|
api.ControllerContext = new ControllerContext
|
|
{
|
|
HttpContext = new DefaultHttpContext()
|
|
};
|
|
|
|
var presences = await api.GetPlayerPresence(new List<long> { playerId });
|
|
var presence = Assert.Single(presences);
|
|
var storedPresence = await db.Presences.FirstAsync(x => x.playerId == playerId);
|
|
|
|
Assert.Equal(playerId, presence.playerId);
|
|
Assert.False(presence.isOnline);
|
|
Assert.Null(presence.roomInstance);
|
|
Assert.Null(storedPresence.roomInstance);
|
|
Assert.Empty(await db.RoomInstances.ToListAsync());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task InventionsFromCreators_ReturnsCreatorOwnedItemsInNewestFirstOrder()
|
|
{
|
|
using var db = CreateDb();
|
|
db.Inventions.AddRange(
|
|
new InventionDB
|
|
{
|
|
CreatorPlayerId = 7,
|
|
InventionId = 1,
|
|
Name = "Older creator item",
|
|
CreatedAt = new DateTime(2023, 9, 1, 0, 0, 0, DateTimeKind.Utc),
|
|
IsPublished = false,
|
|
},
|
|
new InventionDB
|
|
{
|
|
CreatorPlayerId = 7,
|
|
InventionId = 2,
|
|
Name = "Newer creator item",
|
|
CreatedAt = new DateTime(2023, 9, 2, 0, 0, 0, DateTimeKind.Utc),
|
|
IsPublished = true,
|
|
},
|
|
new InventionDB
|
|
{
|
|
CreatorPlayerId = 8,
|
|
InventionId = 3,
|
|
Name = "Other creator item",
|
|
CreatedAt = new DateTime(2023, 9, 3, 0, 0, 0, DateTimeKind.Utc),
|
|
IsPublished = true,
|
|
});
|
|
await db.SaveChangesAsync();
|
|
|
|
var api = new InventionsV1(db);
|
|
var route = typeof(InventionsV1).GetMethod(nameof(InventionsV1.GetInventionsFromCreators))!
|
|
.GetCustomAttributes<HttpGetAttribute>()
|
|
.Select(attribute => attribute.Template);
|
|
Assert.Contains("fromcreators", route);
|
|
|
|
var response = Assert.IsType<OkObjectResult>(
|
|
await api.GetInventionsFromCreators([7], skip: 0, take: 1));
|
|
var items = Assert.IsAssignableFrom<IEnumerable<Invention>>(response.Value).ToArray();
|
|
var item = Assert.Single(items);
|
|
Assert.Equal("Newer creator item", item.Name);
|
|
Assert.True(item.IsPublished);
|
|
|
|
var otherCreators = Assert.IsType<OkObjectResult>(
|
|
await api.GetInventionsFromCreators([7, 8], skip: 0, take: 10));
|
|
Assert.Equal(3, Assert.IsAssignableFrom<IEnumerable<Invention>>(otherCreators.Value).Count());
|
|
|
|
var missingCreators = Assert.IsType<BadRequestObjectResult>(
|
|
await api.GetInventionsFromCreators(null));
|
|
Assert.NotNull(missingCreators.Value);
|
|
}
|
|
|
|
[Fact]
|
|
public void DataCollection_Placeholders_ReturnSuccessShapes()
|
|
{
|
|
var api = new DataCollectionAPI();
|
|
|
|
Assert.Equal("{}", Serialize(Assert.IsType<OkObjectResult>(api.Event()).Value));
|
|
Assert.Equal("[]", Serialize(Assert.IsType<OkObjectResult>(api.Events()).Value));
|
|
Assert.Equal("{}", Serialize(Assert.IsType<OkObjectResult>(api.Sampling()).Value));
|
|
}
|
|
|
|
[Fact]
|
|
public void CustomAvatarItems_Placeholders_ReturnReferenceShapes()
|
|
{
|
|
using var db = CreateDb();
|
|
var api = new CustomAvatarItemsAPI(db);
|
|
SetUser(api, CreatorId);
|
|
|
|
// The gate the drawing screen asks before it enables anything. Its answer has to be the
|
|
// client's own two-member result, not the server's PascalCase one: a "success" body is an
|
|
// unknown member to that reader, its success flag stays false, and every create/modify
|
|
// option on the screen comes up disabled.
|
|
var allowed = Assert.IsType<OkObjectResult>(api.IsCreationAllowedForAccount());
|
|
var allowedKeys = TopLevelKeys(Serialize(allowed.Value));
|
|
Assert.Contains("ALOCDGMHFKA", allowedKeys);
|
|
Assert.Contains("FBILGKFKFJE", allowedKeys);
|
|
Assert.True(Assert.IsType<bool>(allowed.Value!.GetType().GetProperty("ALOCDGMHFKA")!.GetValue(allowed.Value)));
|
|
|
|
Assert.Equal("true", Serialize(Assert.IsType<OkObjectResult>(api.IsEnabled()).Value));
|
|
|
|
// Scalars, read into an int: the body has to be a bare number, since an object wrapper
|
|
// fails that read. Both were unmapped, so the item details page logged a 404 on open.
|
|
Assert.Equal("0", Serialize(Assert.IsType<OkObjectResult>(api.MinPriceForPublicItem()).Value));
|
|
Assert.True(Assert.IsType<OkObjectResult>(api.ItemOwnershipLimit()).Value is int limit && limit > 0);
|
|
|
|
// The two bare-array routes. The client reads them with a plain-list reader, so a page
|
|
// here would fail it at offset 0.
|
|
Assert.Equal("[]", Serialize(Assert.IsType<OkObjectResult>(api.Featured().GetAwaiter().GetResult()).Value));
|
|
|
|
// The paged { Results, TotalResults } object, not a bare array: the client reads these
|
|
// routes into a paged DTO and a body it cannot bind throws inside the wardrobe's item
|
|
// download, leaving the customize screen empty.
|
|
var owned = Assert.IsType<OkObjectResult>(api.Owned().GetAwaiter().GetResult());
|
|
var ownedBody = Serialize(owned.Value);
|
|
var ownedKeys = TopLevelKeys(ownedBody);
|
|
Assert.Contains("Results", ownedKeys);
|
|
Assert.Contains("TotalResults", ownedKeys);
|
|
Assert.StartsWith("{", ownedBody);
|
|
|
|
var page = Assert.IsType<OkObjectResult>(api.FromCreator(2).GetAwaiter().GetResult());
|
|
var pageKeys = TopLevelKeys(Serialize(page.Value));
|
|
Assert.Contains("Results", pageKeys);
|
|
Assert.Contains("TotalResults", pageKeys);
|
|
|
|
// The bare /v1 and /v1/me spellings the same client also asks for. Both were unmapped,
|
|
// so every boot logged a 404 for them; they must carry the paged body, not a 404 and
|
|
// not a bare array.
|
|
var routes = typeof(CustomAvatarItemsAPI)
|
|
.GetMethod(nameof(CustomAvatarItemsAPI.Items))!
|
|
.GetCustomAttributes<HttpGetAttribute>()
|
|
.Select(a => a.Template)
|
|
.ToArray();
|
|
Assert.Contains("v1", routes);
|
|
Assert.Contains("v1/me", routes);
|
|
|
|
var items = Assert.IsType<OkObjectResult>(api.Items().GetAwaiter().GetResult());
|
|
var itemsBody = Serialize(items.Value);
|
|
Assert.StartsWith("{", itemsBody);
|
|
Assert.Contains("Results", TopLevelKeys(itemsBody));
|
|
Assert.Contains("TotalResults", TopLevelKeys(itemsBody));
|
|
|
|
var saves = Assert.IsType<OkObjectResult>(api.CurrentSavesForLegacyAvatarItems());
|
|
Assert.Contains("customAvatarItemSavesByAvatarItemDesc", TopLevelKeys(Serialize(saves.Value)));
|
|
}
|
|
|
|
/// <summary>
|
|
/// The create call, exactly as the client sent it on the wire: multipart form data with a
|
|
/// <c>metadata</c> part and a thumbnail file part. This is the request the server used to
|
|
/// answer with an empty array, so the item was never stored and never showed up in the
|
|
/// creator's lists afterwards.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task CustomAvatarItems_Create_StoresTheItemAndServesItBack()
|
|
{
|
|
using var db = CreateDb();
|
|
var api = new CustomAvatarItemsAPI(db);
|
|
SetUser(api, CreatorId);
|
|
|
|
const string metadata = "{\"Name\":\"Test\",\"Description\":\"Test\",\"Price\":0,\"BaseAvatarItemId\":2184,\"BaseAvatarItemColor\":\"#F55C1A\",\"Accessibility\":0}";
|
|
var thumbnail = new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 1, 2, 3, 4 };
|
|
var design = new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 5, 6, 7, 8 };
|
|
var (body, contentType) = MultipartBody(metadata, thumbnail, design);
|
|
|
|
api.ControllerContext.HttpContext.Request.ContentType = contentType;
|
|
api.ControllerContext.HttpContext.Request.Body = new MemoryStream(body);
|
|
|
|
var created = Assert.IsType<OkObjectResult>(await api.Create());
|
|
var createdBody = Serialize(created.Value);
|
|
var createdKeys = TopLevelKeys(createdBody);
|
|
Assert.Contains("CustomAvatarItemId", createdKeys);
|
|
Assert.Contains("ImageName", createdKeys);
|
|
Assert.Contains("DesignFilename", createdKeys);
|
|
|
|
// The key the client's card art is actually looked up by. Without it the item lists in
|
|
// the watch but every card draws the placeholder, because the reader has no thumbnail
|
|
// name to fetch - which is what a create response carrying only ImageName produced.
|
|
Assert.Contains("ThumbnailImageFilename", createdKeys);
|
|
Assert.Contains("IsFeatured", createdKeys);
|
|
|
|
using var doc = JsonDocument.Parse(createdBody);
|
|
var itemId = doc.RootElement.GetProperty("CustomAvatarItemId").GetString();
|
|
Assert.True(Guid.TryParse(itemId, out _));
|
|
Assert.Equal("Test", doc.RootElement.GetProperty("Name").GetString());
|
|
Assert.Equal(2184, doc.RootElement.GetProperty("BaseAvatarItemId").GetInt32());
|
|
Assert.Equal("#F55C1A", doc.RootElement.GetProperty("BaseAvatarItemColor").GetString());
|
|
|
|
// Both uploads landed where the image route serves them from: the thumbnail is the card
|
|
// art, the design is the texture the shirt is drawn with.
|
|
var imageName = doc.RootElement.GetProperty("ImageName").GetString();
|
|
var designName = doc.RootElement.GetProperty("DesignFilename").GetString();
|
|
|
|
// Both spellings of the thumbnail name point at the same file, so whichever one the
|
|
// client's reader binds, the card art it asks the CDN for is the file that was written.
|
|
Assert.Equal(imageName, doc.RootElement.GetProperty("ThumbnailImageFilename").GetString());
|
|
|
|
var imagePath = Path.Join(DBConfig.CDNImages, imageName);
|
|
var designPath = Path.Join(DBConfig.CDNImages, designName);
|
|
Assert.True(File.Exists(imagePath), $"expected the thumbnail at {imagePath}");
|
|
Assert.True(File.Exists(designPath), $"expected the design at {designPath}");
|
|
Assert.NotEqual(imageName, designName);
|
|
try
|
|
{
|
|
// The design lookup the item pages make: a bare list, with the item's record in it.
|
|
var designApi = new CustomAvatarItemsAPI(db);
|
|
SetUser(designApi, CreatorId);
|
|
var designs = Assert.IsType<OkObjectResult>(await designApi.Design());
|
|
var designsBody = Serialize(designs.Value);
|
|
Assert.StartsWith("[", designsBody);
|
|
Assert.Contains(designName, designsBody);
|
|
|
|
// ... and the same lookup for another account stays empty while the item is private.
|
|
var strangerView = new CustomAvatarItemsAPI(db);
|
|
SetUser(strangerView, 999);
|
|
Assert.Equal("[]", Serialize(Assert.IsType<OkObjectResult>(await strangerView.Design()).Value));
|
|
|
|
// ... and the item is readable again: paged lists, single-item fetch, and the
|
|
// resolve call that takes ids.
|
|
var mine = Assert.IsType<OkObjectResult>(await api.Items());
|
|
using var mineDoc = JsonDocument.Parse(Serialize(mine.Value));
|
|
Assert.Equal(1, mineDoc.RootElement.GetProperty("TotalResults").GetInt32());
|
|
Assert.Equal(itemId, mineDoc.RootElement.GetProperty("Results")[0].GetProperty("CustomAvatarItemId").GetString());
|
|
|
|
// The list is what the watch draws its cards from, so the thumbnail name has to
|
|
// survive into it and not only into the create response.
|
|
Assert.Equal(imageName, mineDoc.RootElement.GetProperty("Results")[0].GetProperty("ThumbnailImageFilename").GetString());
|
|
|
|
var single = Assert.IsType<OkObjectResult>(await api.Item(Guid.Parse(itemId)));
|
|
using var singleDoc = JsonDocument.Parse(Serialize(single.Value));
|
|
Assert.Equal("Test", singleDoc.RootElement.GetProperty("Name").GetString());
|
|
|
|
// Fresh controllers: the form is parsed once per request, and the create above
|
|
// already consumed this context's body.
|
|
var resolveApi = new CustomAvatarItemsAPI(db);
|
|
SetUser(resolveApi, CreatorId);
|
|
SetBody(resolveApi, $"customAvatarItemIds={itemId}", "application/x-www-form-urlencoded");
|
|
var bulk = Assert.IsType<OkObjectResult>(await resolveApi.Bulk());
|
|
var bulkBody = Serialize(bulk.Value);
|
|
Assert.StartsWith("[", bulkBody);
|
|
Assert.Contains("Test", bulkBody);
|
|
|
|
// Another player asking for the same ids gets nothing while the item is private.
|
|
var strangerApi = new CustomAvatarItemsAPI(db);
|
|
SetUser(strangerApi, 999);
|
|
SetBody(strangerApi, $"customAvatarItemIds={itemId}", "application/x-www-form-urlencoded");
|
|
Assert.Equal("[]", Serialize(Assert.IsType<OkObjectResult>(await strangerApi.Bulk()).Value));
|
|
}
|
|
finally
|
|
{
|
|
if (File.Exists(imagePath))
|
|
File.Delete(imagePath);
|
|
if (File.Exists(designPath))
|
|
File.Delete(designPath);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// The design save, which the item pages make after a create and whenever a design is edited.
|
|
/// It has to attach to the right item: the id the caller names, or - which is what the create
|
|
/// flow means - the item they made most recently.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task CustomAvatarItems_SaveDesign_AttachesToTheCallersNewestItem()
|
|
{
|
|
using var db = CreateDb();
|
|
var older = new CustomAvatarItemDB
|
|
{
|
|
CustomAvatarItemId = Guid.NewGuid(),
|
|
CreatorAccountId = CreatorId,
|
|
Name = "Older",
|
|
BaseAvatarItemColor = "#111111",
|
|
CreatedAt = DateTime.UtcNow.AddMinutes(-5),
|
|
ModifiedAt = DateTime.UtcNow.AddMinutes(-5),
|
|
};
|
|
var newest = new CustomAvatarItemDB
|
|
{
|
|
CustomAvatarItemId = Guid.NewGuid(),
|
|
CreatorAccountId = CreatorId,
|
|
Name = "Newest",
|
|
BaseAvatarItemColor = "#222222",
|
|
CreatedAt = DateTime.UtcNow,
|
|
ModifiedAt = DateTime.UtcNow,
|
|
};
|
|
db.CustomAvatarItems.Add(older);
|
|
db.CustomAvatarItems.Add(newest);
|
|
await db.SaveChangesAsync();
|
|
|
|
var image = new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 9, 9, 9 };
|
|
var (body, contentType) = MultipartBody("{}", image, image);
|
|
|
|
var api = new CustomAvatarItemsAPI(db);
|
|
SetUser(api, CreatorId);
|
|
api.ControllerContext.HttpContext.Request.ContentType = contentType;
|
|
api.ControllerContext.HttpContext.Request.QueryString = new QueryString("?customAvatarItemColor=%23FF0000");
|
|
api.ControllerContext.HttpContext.Request.Body = new MemoryStream(body);
|
|
|
|
var saved = Assert.IsType<OkObjectResult>(await api.SaveDesign());
|
|
using var doc = JsonDocument.Parse(Serialize(saved.Value));
|
|
Assert.Equal(newest.CustomAvatarItemId.ToString(), doc.RootElement.GetProperty("CustomAvatarItemId").GetString());
|
|
Assert.Equal("#FF0000", doc.RootElement.GetProperty("CustomAvatarItemColor").GetString());
|
|
|
|
// The upload is kept where the image route serves it from, and the design lookup finds it.
|
|
var designName = doc.RootElement.GetProperty("DesignFilename").GetString();
|
|
Assert.False(string.IsNullOrEmpty(designName));
|
|
var designPath = Path.Join(DBConfig.CDNImages, designName);
|
|
Assert.True(File.Exists(designPath), $"expected the design at {designPath}");
|
|
try
|
|
{
|
|
var lookup = new CustomAvatarItemsAPI(db);
|
|
SetUser(lookup, CreatorId);
|
|
SetBody(lookup, null, "application/json", "?customAvatarItemId=" + newest.CustomAvatarItemId);
|
|
var found = Serialize(Assert.IsType<OkObjectResult>(await lookup.Design()).Value);
|
|
Assert.Contains(newest.CustomAvatarItemId.ToString(), found);
|
|
Assert.DoesNotContain(older.CustomAvatarItemId.ToString(), found);
|
|
}
|
|
finally
|
|
{
|
|
if (File.Exists(designPath))
|
|
File.Delete(designPath);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Builds the multipart body BestHTTP produces: a literal form-data envelope, headers and
|
|
/// all, so the controller's own parsing is what is under test.
|
|
/// </summary>
|
|
private static (byte[] Body, string ContentType) MultipartBody(string metadata, byte[] thumbnail, byte[] design)
|
|
{
|
|
const string boundary = "BestHTTP_HTTPMultiPartForm_Test";
|
|
using var stream = new MemoryStream();
|
|
|
|
void Write(string text) => stream.Write(Encoding.UTF8.GetBytes(text));
|
|
|
|
void WriteFile(string name, byte[] bytes)
|
|
{
|
|
Write($"--{boundary}\r\n");
|
|
Write("Content-Disposition: form-data; name=\"" + name + "\"; filename=\"file.bin\"\r\n");
|
|
Write("Content-Type: image/png\r\n\r\n");
|
|
stream.Write(bytes);
|
|
Write("\r\n");
|
|
}
|
|
|
|
Write($"--{boundary}\r\n");
|
|
Write("Content-Disposition: form-data; name=\"metadata\"\r\n");
|
|
Write("Content-Type: text/plain; charset=utf-8\r\n\r\n");
|
|
Write(metadata);
|
|
Write("\r\n");
|
|
WriteFile("thumbnailImage", thumbnail);
|
|
WriteFile("design", design);
|
|
Write($"--{boundary}--\r\n");
|
|
|
|
return (stream.ToArray(), $"multipart/form-data; boundary={boundary}");
|
|
}
|
|
|
|
[Fact]
|
|
public void CdnSigs_ReturnNonEmptyFileFor2023FileCheck()
|
|
{
|
|
var api = new SigsCDN();
|
|
|
|
var result = Assert.IsType<FileContentResult>(
|
|
api.GetSignature("dcfe17c0-7c51-49e8-ae74-691fc5bfc0ea"));
|
|
|
|
Assert.Equal("text/plain", result.ContentType);
|
|
Assert.NotEmpty(result.FileContents);
|
|
var text = Encoding.ASCII.GetString(result.FileContents);
|
|
Assert.Contains("RecRoom.exe:AAAAAAAAAAAAAAAAAAAAAA==:AAAAAAAAAAAAAAAAAAAAAA==", text);
|
|
}
|
|
|
|
[Fact]
|
|
public void CdnSigs_RangeRequestsReturnPartialContentShape()
|
|
{
|
|
var context = new DefaultHttpContext();
|
|
context.Request.Headers.Range = "bytes=2-4";
|
|
var api = new SigsCDN
|
|
{
|
|
ControllerContext = new ControllerContext
|
|
{
|
|
HttpContext = context
|
|
}
|
|
};
|
|
|
|
var result = Assert.IsType<FileContentResult>(
|
|
api.GetSignature("dcfe17c0-7c51-49e8-ae74-691fc5bfc0ea"));
|
|
|
|
Assert.Equal(206, context.Response.StatusCode);
|
|
Assert.Equal("bytes", context.Response.Headers.AcceptRanges);
|
|
Assert.StartsWith("bytes 2-4/", context.Response.Headers.ContentRange.ToString());
|
|
Assert.Equal(Encoding.ASCII.GetBytes("cRo"), result.FileContents);
|
|
}
|
|
|
|
[Fact]
|
|
public void Hile_ReturnsRawBooleanSuccessBody()
|
|
{
|
|
var api = new HileAPI();
|
|
|
|
var ok = Assert.IsType<OkObjectResult>(
|
|
api.PostHile(new RadicadeServer.RecNet.Moderation.Hile()));
|
|
|
|
Assert.True(Assert.IsType<bool>(ok.Value));
|
|
}
|
|
|
|
[Fact]
|
|
public void GameConfigs_DisableLocal2023HashChecks()
|
|
{
|
|
var db = CreateDb();
|
|
db.GameConfig.Add(new ConfigVal("AndroidMobile.runHashCheck", "1"));
|
|
db.GameConfig.Add(new ConfigVal("OculusQuest.enforceHashCheck", "1"));
|
|
db.SaveChanges();
|
|
var api = new GameConfigAPI(db);
|
|
|
|
var ok = Assert.IsType<OkObjectResult>(api.GetAllGameConfigs());
|
|
var configs = Assert.IsAssignableFrom<IEnumerable<ConfigVal>>(ok.Value);
|
|
var byKey = configs.ToDictionary(x => x.Key, x => x.Value);
|
|
|
|
Assert.Equal("0", byKey["AndroidMobile.runHashCheck"]);
|
|
Assert.Equal("0", byKey["AndroidMobile.enforceHashCheck"]);
|
|
Assert.Equal("0", byKey["Desktop_Oculus.runHashCheck"]);
|
|
Assert.Equal("0", byKey["Desktop_Steam.runHashCheck"]);
|
|
Assert.Equal("0", byKey["OculusQuest.enforceHashCheck"]);
|
|
Assert.Equal("0", byKey["OculusQuest.runFileWatcher"]);
|
|
Assert.Equal("0", byKey["Steam.runHashCheck"]);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(null)]
|
|
[InlineData("false")]
|
|
public void GameConfigs_EnableRRUIHomeScreen(string? storedValue)
|
|
{
|
|
var db = CreateDb();
|
|
if (storedValue != null)
|
|
{
|
|
db.GameConfig.Add(new ConfigVal("RRUI.UseRRUIHomeScreen", storedValue));
|
|
db.SaveChanges();
|
|
}
|
|
|
|
var ok = Assert.IsType<OkObjectResult>(new GameConfigAPI(db).GetAllGameConfigs());
|
|
var configs = Assert.IsAssignableFrom<IEnumerable<ConfigVal>>(ok.Value);
|
|
Assert.Equal("true", Assert.Single(configs, x => x.Key == "RRUI.UseRRUIHomeScreen").Value);
|
|
// USE_NEW_HOME_SCREEN is the watch home selector the 2023 client actually honours.
|
|
// Serving "false" here is what produced the old tile-grid watch, so the forced value
|
|
// is "true" even when the stored DB row says otherwise.
|
|
Assert.Equal("true", Assert.Single(configs, x => x.Key == "USE_NEW_HOME_SCREEN").Value);
|
|
Assert.Equal("true", Assert.Single(configs, x => x.Key == "HAS_SEEN_HOME_SCREEN_CHOICE").Value);
|
|
Assert.Equal("true", Assert.Single(configs, x => x.Key == "new_watch_architecture_enabled").Value);
|
|
Assert.Equal("true", Assert.Single(configs, x => x.Key == "watch_layer.new_watch_architecture_enabled").Value);
|
|
}
|
|
|
|
[Fact]
|
|
public void GameConfigs_SuppressInvalid2023SplitTestConfigs()
|
|
{
|
|
var db = CreateDb();
|
|
db.GameConfig.Add(new ConfigVal(
|
|
"splitTestHardOverrides",
|
|
"PlayMenuRRUI_2021_01_11=On"));
|
|
db.GameConfig.Add(new ConfigVal(
|
|
"splitTestsAvailable",
|
|
"PlayMenuRRUI_2021_01_11=Off,On"));
|
|
db.GameConfig.Add(new ConfigVal(
|
|
"splitTestSegmentProbabilities",
|
|
"{\"PlayMenuRRUI_2021_01_11\":[{\"SegmentName\":\"On\",\"Probability\":1.0}]}"));
|
|
db.SaveChanges();
|
|
var api = new GameConfigAPI(db);
|
|
|
|
var ok = Assert.IsType<OkObjectResult>(api.GetAllGameConfigs());
|
|
var configs = Assert.IsAssignableFrom<IEnumerable<ConfigVal>>(ok.Value);
|
|
var byKey = configs.ToDictionary(x => x.Key, x => x.Value);
|
|
|
|
// The 2023 client has no enum for PlayMenuRRUI_2021_01_11, so it is filtered
|
|
// out of the hard overrides before serving - here that empties the list
|
|
// entirely since the seeded row contains nothing else.
|
|
Assert.Equal("", byKey["splitTestHardOverrides"]);
|
|
// Available list and probabilities pass through from the DB.
|
|
Assert.Contains("PlayMenuRRUI_2021_01_11=Off,On", byKey["splitTestsAvailable"]);
|
|
Assert.Contains("PlayMenuRRUI_2021_01_11", byKey["splitTestSegmentProbabilities"]);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Leaderboard_GetRanks_EmptyBoardReturnsSelfRow()
|
|
{
|
|
var factory = new TestDbContextFactory();
|
|
|
|
var response = await LeaderboardStore.GetRanksAsync(factory, new GetRanksRequest
|
|
{
|
|
PlayerId = 2,
|
|
StatChannel = 1000,
|
|
RankStart = 1,
|
|
RankEnd = 10
|
|
});
|
|
|
|
Assert.Equal(1, response.Total);
|
|
Assert.Equal(1, response.MyRank);
|
|
var row = Assert.Single(response.Rows);
|
|
Assert.Equal(2, row.PlayerId);
|
|
Assert.Equal(1, row.Rank);
|
|
Assert.Equal(0, row.Score);
|
|
Assert.Equal(0, row.Time);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Leaderboard_GetNearbyScores_EmptyBoardReturnsSelfRow()
|
|
{
|
|
var factory = new TestDbContextFactory();
|
|
|
|
var response = await LeaderboardStore.GetNearbyScoresAsync(factory, new GetNearbyScoresRequest
|
|
{
|
|
PlayerId = 2,
|
|
StatChannel = 1000,
|
|
WindowSize = 10
|
|
});
|
|
|
|
Assert.Equal(1, response.Total);
|
|
Assert.Equal(1, response.MyRank);
|
|
var row = Assert.Single(response.Rows);
|
|
Assert.Equal(2, row.PlayerId);
|
|
Assert.Equal(1, row.Rank);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PlayersV2_ProgressionBulk_IncludesReferenceIdField()
|
|
{
|
|
var db = CreateDb();
|
|
var api = new PlayersV2(db);
|
|
|
|
using var json = JsonDocument.Parse(Serialize(await api.GetBulkProgressionV2(new[] { 2 })));
|
|
var first = json.RootElement[0];
|
|
|
|
Assert.Equal(2, first.GetProperty("Id").GetInt64());
|
|
Assert.Equal(2, first.GetProperty("PlayerId").GetInt64());
|
|
Assert.Equal(1, first.GetProperty("Level").GetInt32());
|
|
Assert.Equal(0, first.GetProperty("XP").GetInt32());
|
|
}
|
|
|
|
[Fact]
|
|
public void PlayerReputationV2_Bulk_ReturnsReferenceShape()
|
|
{
|
|
var db = CreateDb();
|
|
var api = new ReputationAPI(db);
|
|
|
|
var ok = Assert.IsType<OkObjectResult>(api.GetBulkReputationV2(new long[] { 2 }));
|
|
using var json = JsonDocument.Parse(Serialize(ok.Value));
|
|
var first = json.RootElement[0];
|
|
|
|
Assert.Equal(2, first.GetProperty("Id").GetInt64());
|
|
Assert.Equal(2, first.GetProperty("accountId").GetInt64());
|
|
// IsCheerful is the "may hand out cheers" gate the client reads, and the reference
|
|
// server (RecFlare 2023) answers it true for every account. The badges come from
|
|
// the five counters, which an uncheered account reports as 0.
|
|
Assert.True(first.GetProperty("IsCheerful").GetBoolean());
|
|
Assert.Equal(0, first.GetProperty("Noteriety").GetSingle());
|
|
Assert.Equal(0, first.GetProperty("SelectedCheer").GetInt32());
|
|
Assert.Equal(20, first.GetProperty("CheerCredit").GetInt32());
|
|
Assert.Equal(0, first.GetProperty("CheerGeneral").GetInt32());
|
|
Assert.Equal(0, first.GetProperty("CheerHelpful").GetInt32());
|
|
Assert.False(first.TryGetProperty("AccountId", out _));
|
|
Assert.False(first.TryGetProperty("ReputationScore", out _));
|
|
Assert.False(first.TryGetProperty("Trust", out _));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PlayerSettings_PutAlias_SavesSettings()
|
|
{
|
|
var db = CreateDb();
|
|
db.Players.Add(new RadicadeServer.RecNet.Auth.PlayerData { AccountId = 7, Hashword = "x" });
|
|
await db.SaveChangesAsync();
|
|
|
|
var api = new PlayerSettingsAPI(db);
|
|
SetUser(api, 7);
|
|
api.ControllerContext.HttpContext.Request.Body =
|
|
new MemoryStream(Encoding.UTF8.GetBytes("[{\"Key\":\"MusicVolume\",\"Value\":\"0.5\"}]"));
|
|
|
|
var ok = Assert.IsType<OkObjectResult>(await api.PutSettings());
|
|
using var json = JsonDocument.Parse(Serialize(ok.Value));
|
|
|
|
Assert.Equal(JsonValueKind.Array, json.RootElement.ValueKind);
|
|
Assert.Contains(json.RootElement.EnumerateArray(), item =>
|
|
item.GetProperty("Key").GetString() == "MusicVolume" &&
|
|
item.GetProperty("Value").GetString() == "0.5");
|
|
}
|
|
|
|
[Fact]
|
|
public void PlayerSettings_Defaults_UseRecRoom2023OnboardedState()
|
|
{
|
|
var defaults = DBConfig.DefaultSettings2023.FromJson<List<Setting>>();
|
|
|
|
Assert.NotNull(defaults);
|
|
|
|
// No AccountCreation flags here on purpose - see
|
|
// PlayerSettings_GetAlias_LeavesTheOnboardingFlagsToTheAccountSeed. An account with no settings
|
|
// row is one the server has never finished onboarding, and saying otherwise is what made the
|
|
// client skip the new-user pages.
|
|
Assert.DoesNotContain(defaults, item =>
|
|
item.Key.StartsWith("Recroom.AccountCreation.", StringComparison.Ordinal));
|
|
Assert.Contains(defaults, item =>
|
|
item.Key == "TUTORIAL_COMPLETE_MASK" &&
|
|
item.Value == "109");
|
|
Assert.Contains(defaults, item =>
|
|
item.Key == "TUTORIAL_COMPLETED_MASK" &&
|
|
item.Value == "109");
|
|
Assert.Contains(defaults, item =>
|
|
item.Key == "HAS_COMPLETED_ORIENTATION" &&
|
|
item.Value == "True");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PlayerSettings_SetV2_ReturnsReferenceEmptyArray()
|
|
{
|
|
var db = CreateDb();
|
|
db.Players.Add(new RadicadeServer.RecNet.Auth.PlayerData { AccountId = 9, Hashword = "x" });
|
|
await db.SaveChangesAsync();
|
|
|
|
var api = new PlayerSettingsAPI(db);
|
|
SetUser(api, 9);
|
|
|
|
var ok = Assert.IsType<OkObjectResult>(
|
|
await api.SetSetting(new Setting { Key = "MusicVolume", Value = "0.25" }));
|
|
|
|
Assert.Equal("[]", Serialize(ok.Value));
|
|
}
|
|
|
|
[Fact]
|
|
public void PlayerSettings_GetAlias_LeavesPasswordAndFinishIncompleteWithoutAPassword()
|
|
{
|
|
// Missing settings must not make an account without a credential skip the password page.
|
|
var db = CreateDb();
|
|
db.Players.Add(new RadicadeServer.RecNet.Auth.PlayerData { AccountId = 8, Hashword = "" });
|
|
db.SaveChanges();
|
|
|
|
var api = new PlayerSettingsAPI(db);
|
|
SetUser(api, 8);
|
|
|
|
var settings = api.GetSettings();
|
|
|
|
Assert.Contains(settings, item => item.Key == "Recroom.AccountCreation.HasCreatedPassword" && item.Value == "False");
|
|
Assert.Contains(settings, item => item.Key == "Recroom.AccountCreation.HasFinished" && item.Value == "False");
|
|
}
|
|
|
|
[Fact]
|
|
public void PlayerSettings_GetAlias_IncludesReferenceMetadataFields()
|
|
{
|
|
var db = CreateDb();
|
|
db.Players.Add(new RadicadeServer.RecNet.Auth.PlayerData { AccountId = 8, Hashword = "x" });
|
|
db.SaveChanges();
|
|
|
|
var api = new PlayerSettingsAPI(db);
|
|
SetUser(api, 8);
|
|
|
|
using var json = JsonDocument.Parse(Serialize(api.GetSettings()));
|
|
var first = json.RootElement[0];
|
|
|
|
Assert.Equal(1, first.GetProperty("Id").GetInt64());
|
|
Assert.Equal(8, first.GetProperty("accountId").GetInt64());
|
|
Assert.True(first.TryGetProperty("Key", out _));
|
|
Assert.True(first.TryGetProperty("Value", out _));
|
|
}
|
|
|
|
[Fact]
|
|
public void PlayerSettings_GetAlias_ClearsPersistedSplitTestAssignments()
|
|
{
|
|
var db = CreateDb();
|
|
db.Players.Add(new RadicadeServer.RecNet.Auth.PlayerData { AccountId = 8, Hashword = "x" });
|
|
db.PlayerSettings.Add(new PlayerSettings
|
|
{
|
|
AccountId = 8,
|
|
Settings = """
|
|
[{"Key":"SplitTestAssignedSegments","Value":"1|{\"SplitTesting+PlayMenuRRUI_2021_01_11\":\"On\"}"}]
|
|
"""
|
|
});
|
|
db.SaveChanges();
|
|
|
|
var api = new PlayerSettingsAPI(db);
|
|
SetUser(api, 8);
|
|
|
|
var settings = api.GetSettings();
|
|
|
|
Assert.Contains(settings, item =>
|
|
item.Key == "SplitTestAssignedSegments" &&
|
|
item.Value == "1|{}");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PlayerReporting_NoBan_ReturnsReferenceEmptyBlockDetails()
|
|
{
|
|
var db = CreateDb();
|
|
db.Players.Add(new RadicadeServer.RecNet.Auth.PlayerData { AccountId = 8, Hashword = "x" });
|
|
await db.SaveChangesAsync();
|
|
|
|
var api = new PlayerReportingAPI(db);
|
|
SetUser(api, 8);
|
|
|
|
var ok = Assert.IsType<OkObjectResult>(await api.GetBlockDetails());
|
|
using var json = JsonDocument.Parse(Serialize(ok.Value));
|
|
|
|
Assert.Equal(4, json.RootElement.EnumerateObject().Count());
|
|
Assert.Equal(0, json.RootElement.GetProperty("ReportCategory").GetInt32());
|
|
Assert.Equal(0, json.RootElement.GetProperty("Duration").GetInt32());
|
|
Assert.Equal(0, json.RootElement.GetProperty("GameSessionId").GetInt64());
|
|
Assert.Equal("", json.RootElement.GetProperty("Message").GetString());
|
|
}
|
|
|
|
[Fact]
|
|
public void Checklist_Current_ReturnsDefaultRows()
|
|
{
|
|
var api = new ChecklistAPI();
|
|
|
|
var ok = Assert.IsType<OkObjectResult>(api.Current());
|
|
using var doc = JsonDocument.Parse(Serialize(ok.Value));
|
|
|
|
Assert.Equal(JsonValueKind.Array, doc.RootElement.ValueKind);
|
|
Assert.Equal(4, doc.RootElement.GetArrayLength());
|
|
Assert.Equal(1, doc.RootElement[0].GetProperty("Order").GetInt32());
|
|
Assert.Equal(25, doc.RootElement[0].GetProperty("CreditAmount").GetInt32());
|
|
}
|
|
}
|