mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 23:18:44 -07:00
90e069ee4b
Fourteen agents worked one server file each over the audit's open findings,
re-verifying every claim against the client binary before changing anything.
Route+verb coverage against the recovered client inventory is now 474/522
(90.8%), up from 86.2% when the harness was first wired up.
Adds ClientRouteCoverageTests, which turns "are we compliant?" into a number
instead of an opinion. It reads data/client-routes-2023.tsv — every request the
2023-03-21 client can issue, recovered from the binary — and asserts each one
has a handler under the verb the client actually uses, separating "no handler"
from "handler exists, wrong verb" since those have different fixes. Remaining
gaps live in KnownGaps, which is expected to shrink and never grow.
Also fixes a real blind spot in EndpointContractDiscovery: it only collected
HttpMethodAttribute, so an action carrying a bare [Route] with no verb attribute
was invisible to it. The CDN's unity_assets handler is exactly that shape, which
had been showing up as a phantom gap.
Notable findings from this pass:
- The club permissions envelope key is literally "Type", not "MembershipType"
as the audit's suggested fix guessed. Read off the generated reader, then
cross-checked against the 2020 decompile — the int bitmask we were emitting
was wrong for BOTH client generations, not just 2023.
- Room-consumable purchase responses carry OperationResult as an OBJECT
{ Status, InventoryItem }; the scalar operation enum belongs in
BalanceUpdateResult. The base type OKHNOPLBOFP holds a single BGHBAILNNJJ,
which exposes exactly those two properties. A test asserting the old scalar
shape was corrected rather than the code.
- buyProgressionEventXpBoost blind-inserted an ObjectiveProgress row whose
(PlayerId, Key) is unique, so a second purchase of the same boost threw
"UNIQUE constraint failed" and 500'd AFTER taking the player's currency.
Now an upsert.
Agents were forbidden from faking fixes they could not complete in their own
file, and several correctly refused: club role permissions, club additional
image slots, announcement meta, MinLevel/ClubChatEnabled, currency purchase
offer ordering, and room-key image/currency columns all need new entities or
columns. Those are deliberately left for a single coherent schema change rather
than stubbed out here.
236 lines
13 KiB
C#
236 lines
13 KiB
C#
using System.Net.Http.Headers;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using DorkNet.Server.Data;
|
|
using DorkNet.Server.Data.Entities;
|
|
using DorkNet.Server.Services;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
|
|
namespace DorkNet.Server.Tests;
|
|
|
|
/// <summary>
|
|
/// End-to-end coverage of the 2023 client's in-room shop
|
|
/// (api/roomconsumables/v1) using the exact request/response wire shapes
|
|
/// the 2023.03.21 client serialises (verified against the RecNet.Runtime
|
|
/// formatters in the ISIL dump). The critical regression guard: every
|
|
/// inventory row returned by <c>/room/{id}/me</c> must carry a non-null
|
|
/// <c>Consumable</c> desc — RoomConsumablesManager NREs on room join
|
|
/// otherwise.
|
|
/// </summary>
|
|
public sealed class RoomConsumablesShopTests : IClassFixture<DorkNetServerFactory>
|
|
{
|
|
private readonly DorkNetServerFactory _factory;
|
|
|
|
public RoomConsumablesShopTests(DorkNetServerFactory factory) => _factory = factory;
|
|
|
|
[Fact]
|
|
public async Task Full_shop_loop_create_purchase_inventory_consume_delete()
|
|
{
|
|
using var setupClient = ApiClient();
|
|
var owner = await GameClientSessionFactory.CreateAsync(setupClient, _factory.ApexDomain);
|
|
var buyer = await GameClientSessionFactory.CreateAsync(setupClient, _factory.ApexDomain);
|
|
|
|
var roomId = 9_200_001L;
|
|
await using (var scope = _factory.Services.CreateAsyncScope())
|
|
{
|
|
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
|
db.Rooms.Add(new RoomEntity
|
|
{
|
|
Id = roomId,
|
|
Name = $"ShopRoom_{Guid.NewGuid():N}"[..24],
|
|
Description = "shop test room",
|
|
CreatorPlayerId = owner.PlayerId,
|
|
ImageName = RoomService.DefaultRoomImageName,
|
|
State = 0,
|
|
Accessibility = 1,
|
|
IsAGRoom = true,
|
|
TagsCsv = "community",
|
|
});
|
|
await db.SaveChangesAsync();
|
|
|
|
var level = scope.ServiceProvider.GetRequiredService<LevelService>();
|
|
await level.GrantCurrencyAsync(buyer.PlayerId, 2, 100, "test-seed");
|
|
}
|
|
|
|
using var ownerClient = ApiClient(owner);
|
|
using var buyerClient = ApiClient(buyer);
|
|
|
|
// ── Create (client body = the desc DTO itself) ───────────────────
|
|
var created = await PostJsonAsync(ownerClient, "/api/roomconsumables/v1/roomConsumable", $$"""
|
|
{
|
|
"RoomId": {{roomId}},
|
|
"Name": "Cola",
|
|
"Description": "A refreshing test beverage",
|
|
"ImageName": "",
|
|
"PriceAndCurrency": { "Price": 10, "CurrencyId": null }
|
|
}
|
|
""");
|
|
Assert.Equal(0, created.GetProperty("Status").GetInt32());
|
|
var consumableId = created.GetProperty("Consumable").GetProperty("RoomConsumableId").GetGuid();
|
|
Assert.NotEqual(Guid.Empty, consumableId);
|
|
Assert.Equal(roomId, created.GetProperty("Consumable").GetProperty("RoomId").GetInt64());
|
|
|
|
// ── Non-owner cannot create in someone else's room ───────────────
|
|
var forbidden = await PostJsonAsync(buyerClient, "/api/roomconsumables/v1/roomConsumable", $$"""
|
|
{ "RoomId": {{roomId}}, "Name": "Hax", "Description": "", "ImageName": "",
|
|
"PriceAndCurrency": { "Price": 1, "CurrencyId": null } }
|
|
""");
|
|
Assert.Equal(7, forbidden.GetProperty("Status").GetInt32()); // PlayerDoesntHavePermission
|
|
|
|
// ── Room catalog ─────────────────────────────────────────────────
|
|
var catalog = await GetJsonAsync(buyerClient, $"/api/roomconsumables/v1/roomConsumable/room/{roomId}");
|
|
Assert.Equal(1, catalog.GetArrayLength());
|
|
// Responses carry the price FLAT. The client's consumable formatter
|
|
// (RecNet.Runtime/FCIBLPCOODP) reads RoomConsumableId/RoomId/Name/
|
|
// Description/ImageName/Price/PurchaseCurrencyId/ModifiedAt and has no
|
|
// "PriceAndCurrency" key at all — only REQUEST bodies nest it.
|
|
Assert.Equal(10, catalog[0].GetProperty("Price").GetInt64());
|
|
|
|
// ── Purchase at the wrong price is rejected ──────────────────────
|
|
var codeA = Guid.NewGuid();
|
|
var badPrice = await PostJsonAsync(buyerClient,
|
|
$"/api/roomconsumables/v1/roomconsumable/{consumableId}/purchase/tokens", $$"""
|
|
{
|
|
"ConcurrencyCodes": { "CurrentConcurrencyCode": null, "NewConcurrencyCode": "{{codeA}}" },
|
|
"ExpectedPriceAndCurrency": { "Price": 5, "CurrencyId": null }
|
|
}
|
|
""");
|
|
// OperationResult is an OBJECT { Status, InventoryItem } — the base type
|
|
// OKHNOPLBOFP holds a single BGHBAILNNJJ, which itself exposes the status
|
|
// enum plus the inventory row. The scalar token-operation enum lives in
|
|
// BalanceUpdateResult (Nullable<CABBDKFODEC>) on MJKFEHPIDME.
|
|
Assert.Equal(6, badPrice.GetProperty("BalanceUpdateResult").GetInt32()); // RequestedPriceDoesNotMatch
|
|
Assert.Equal(38, badPrice.GetProperty("OperationResult").GetProperty("Status").GetInt32());
|
|
var balanceBefore = badPrice.GetProperty("TokenBalanceResponse").GetProperty("Balance").GetInt64();
|
|
|
|
// ── Purchase (client stores NewConcurrencyCode locally) ──────────
|
|
var purchase = await PostJsonAsync(buyerClient,
|
|
$"/api/roomconsumables/v1/roomconsumable/{consumableId}/purchase/tokens", $$"""
|
|
{
|
|
"ConcurrencyCodes": { "CurrentConcurrencyCode": null, "NewConcurrencyCode": "{{codeA}}" },
|
|
"ExpectedPriceAndCurrency": { "Price": 10, "CurrencyId": null }
|
|
}
|
|
""");
|
|
Assert.Equal(0, purchase.GetProperty("OperationResult").GetProperty("Status").GetInt32());
|
|
var tokenBalance = purchase.GetProperty("TokenBalanceResponse");
|
|
Assert.Equal(2, tokenBalance.GetProperty("CurrencyType").GetInt32());
|
|
Assert.Equal(balanceBefore - 10, tokenBalance.GetProperty("Balance").GetInt64());
|
|
|
|
// ── Inventory on room join: Consumable must NEVER be null ────────
|
|
var inventory = await GetJsonAsync(buyerClient, $"/api/roomconsumables/v1/roomConsumable/room/{roomId}/me");
|
|
Assert.Equal(1, inventory.GetArrayLength());
|
|
var row = inventory[0];
|
|
Assert.Equal(consumableId, row.GetProperty("RoomConsumableId").GetGuid());
|
|
Assert.Equal((int)buyer.PlayerId, row.GetProperty("AccountId").GetInt32());
|
|
Assert.Equal(1, row.GetProperty("Count").GetInt32());
|
|
Assert.Equal(codeA, row.GetProperty("ConcurrencyCode").GetGuid());
|
|
Assert.Equal(JsonValueKind.Object, row.GetProperty("Consumable").ValueKind);
|
|
Assert.Equal(roomId, row.GetProperty("Consumable").GetProperty("RoomId").GetInt64());
|
|
|
|
// ── isOwned: a non-creator holds stock ───────────────────────────
|
|
using var isOwnedResponse = await ownerClient.GetAsync(
|
|
$"/api/roomconsumables/v1/roomConsumable/{consumableId}/isOwned");
|
|
Assert.Equal("true", await isOwnedResponse.Content.ReadAsStringAsync());
|
|
|
|
// ── Consume with a stale concurrency code resyncs the client ─────
|
|
var codeB = Guid.NewGuid();
|
|
var staleConsume = await PostJsonAsync(buyerClient,
|
|
$"/api/roomconsumables/v1/roomConsumable/{consumableId}/consume", $$"""
|
|
{ "CurrentConcurrencyCode": "{{Guid.NewGuid()}}", "NewConcurrencyCode": "{{codeB}}" }
|
|
""");
|
|
Assert.Equal(32, staleConsume.GetProperty("Status").GetInt32()); // ConcurrencyCodeMismatch
|
|
Assert.Equal(1, staleConsume.GetProperty("InventoryItem").GetProperty("Count").GetInt32());
|
|
|
|
// ── Consume with the right code decrements and rotates the code ──
|
|
var consume = await PostJsonAsync(buyerClient,
|
|
$"/api/roomconsumables/v1/roomConsumable/{consumableId}/consume", $$"""
|
|
{ "CurrentConcurrencyCode": "{{codeA}}", "NewConcurrencyCode": "{{codeB}}" }
|
|
""");
|
|
Assert.Equal(0, consume.GetProperty("Status").GetInt32());
|
|
Assert.Equal(0, consume.GetProperty("InventoryItem").GetProperty("Count").GetInt32());
|
|
Assert.Equal(codeB, consume.GetProperty("InventoryItem").GetProperty("ConcurrencyCode").GetGuid());
|
|
|
|
// ── Consuming an empty stack is refused ──────────────────────────
|
|
var emptyConsume = await PostJsonAsync(buyerClient,
|
|
$"/api/roomconsumables/v1/roomConsumable/{consumableId}/consume", $$"""
|
|
{ "CurrentConcurrencyCode": "{{codeB}}", "NewConcurrencyCode": "{{Guid.NewGuid()}}" }
|
|
""");
|
|
Assert.Equal(33, emptyConsume.GetProperty("Status").GetInt32()); // PlayerDoesNotOwnConsumable
|
|
|
|
// ── Update via POST with RoomConsumableId in the body ────────────
|
|
var updated = await PostJsonAsync(ownerClient, "/api/roomconsumables/v1/roomConsumable", $$"""
|
|
{
|
|
"RoomConsumableId": "{{consumableId}}",
|
|
"RoomId": {{roomId}},
|
|
"Name": "Diet Cola",
|
|
"Description": "Now with fewer tokens",
|
|
"ImageName": "",
|
|
"PriceAndCurrency": { "Price": 5, "CurrencyId": null }
|
|
}
|
|
""");
|
|
Assert.Equal(0, updated.GetProperty("Status").GetInt32());
|
|
Assert.Equal("Diet Cola", updated.GetProperty("Consumable").GetProperty("Name").GetString());
|
|
Assert.Equal(5, updated.GetProperty("Consumable").GetProperty("Price").GetInt64());
|
|
|
|
// ── Delete: buyer refused, owner succeeds, catalog empties ───────
|
|
using var buyerDelete = await buyerClient.DeleteAsync(
|
|
$"/api/roomconsumables/v1/roomConsumable/{consumableId}");
|
|
Assert.Equal("7", await buyerDelete.Content.ReadAsStringAsync()); // PlayerDoesntHavePermission
|
|
|
|
using var ownerDelete = await ownerClient.DeleteAsync(
|
|
$"/api/roomconsumables/v1/roomConsumable/{consumableId}");
|
|
Assert.Equal("0", await ownerDelete.Content.ReadAsStringAsync());
|
|
|
|
var emptied = await GetJsonAsync(buyerClient, $"/api/roomconsumables/v1/roomConsumable/room/{roomId}");
|
|
Assert.Equal(0, emptied.GetArrayLength());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Unprefixed_2023_commerce_probes_respond()
|
|
{
|
|
// The 2023 client calls these WITHOUT the api/ prefix on startup;
|
|
// a 404 here surfaces as "CleanupPendingTransactions failed" +
|
|
// unobserved-HTTP-404 crash reports in Player.log.
|
|
using var client = ApiClient();
|
|
|
|
using var cleanup = await client.PostAsync("/purchase/v1/cleanuppending",
|
|
new StringContent("", Encoding.UTF8, "application/x-www-form-urlencoded"));
|
|
Assert.True(cleanup.IsSuccessStatusCode);
|
|
|
|
using var hasSpent = await client.GetAsync("/purchase/v1/hasspentmoney");
|
|
Assert.Equal("false", await hasSpent.Content.ReadAsStringAsync());
|
|
|
|
var bundles = await GetJsonAsync(client, "/reminder/currentTokenBundles/v2");
|
|
Assert.Equal(JsonValueKind.Array, bundles.ValueKind);
|
|
}
|
|
|
|
private HttpClient ApiClient(GameClientSession? session = null)
|
|
{
|
|
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
|
|
client.BaseAddress = new Uri($"http://api.{_factory.ApexDomain}");
|
|
client.Timeout = TimeSpan.FromSeconds(30);
|
|
client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
|
|
client.DefaultRequestHeaders.UserAgent.ParseAdd("RecRoom/2023.03.21");
|
|
client.DefaultRequestHeaders.TryAddWithoutValidation("X-DorkNet-Version", "march_2023_03_21");
|
|
if (session is not null)
|
|
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken);
|
|
return client;
|
|
}
|
|
|
|
private static async Task<JsonElement> PostJsonAsync(HttpClient client, string path, string json)
|
|
{
|
|
using var response = await client.PostAsync(path, new StringContent(json, Encoding.UTF8, "application/json"));
|
|
response.EnsureSuccessStatusCode();
|
|
using var document = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync());
|
|
return document.RootElement.Clone();
|
|
}
|
|
|
|
private static async Task<JsonElement> GetJsonAsync(HttpClient client, string path)
|
|
{
|
|
using var response = await client.GetAsync(path);
|
|
response.EnsureSuccessStatusCode();
|
|
using var document = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync());
|
|
return document.RootElement.Clone();
|
|
}
|
|
}
|