fix(pack): /tutorial/pack_open restricted to starter pack + pre-END viewer
The tutorial alias bypassed the currency / type_detail / open-count guards unconditionally. Combined with the unconditional TutorialState=100 write, any authenticated viewer could send /tutorial/pack_open with any parent_gacha_id to draw a pack for free and clobber their state down to 100. Two gates: parent_gacha_id MUST be 99047 (the legendary starter), and the viewer's TutorialState MUST be below 100. The state write is also max-preserved as a belt-and-braces backstop. Mirrors the 31→41 guard in GiftController. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -146,6 +146,14 @@ public class PackController : SVSimController
|
|||||||
|
|
||||||
bool isTutorialPath = HttpContext.Request.Path.StartsWithSegments("/tutorial/pack_open");
|
bool isTutorialPath = HttpContext.Request.Path.StartsWithSegments("/tutorial/pack_open");
|
||||||
|
|
||||||
|
// The tutorial alias bypasses the currency / type_detail / open-count guards because
|
||||||
|
// the legendary starter pack (99047) is a free server-grant during the 41→100 tutorial
|
||||||
|
// transition. Constrain the alias to that one pack so the bypass isn't a free draw on
|
||||||
|
// ANY pack the client supplies a parent_gacha_id for.
|
||||||
|
const int StarterParentGachaId = 99047;
|
||||||
|
if (isTutorialPath && request.ParentGachaId != StarterParentGachaId)
|
||||||
|
return BadRequest(new { error = "tutorial_path_only_for_starter_pack" });
|
||||||
|
|
||||||
// Reject paths up front — class_id/target_card_id overloads aren't implemented.
|
// Reject paths up front — class_id/target_card_id overloads aren't implemented.
|
||||||
if (request.ClassId.HasValue)
|
if (request.ClassId.HasValue)
|
||||||
return StatusCode(StatusCodes.Status501NotImplemented, new { error = "starter_overload_not_implemented" });
|
return StatusCode(StatusCodes.Status501NotImplemented, new { error = "starter_overload_not_implemented" });
|
||||||
@@ -186,6 +194,15 @@ public class PackController : SVSimController
|
|||||||
.Include(v => v.Items).ThenInclude(i => i.Item)
|
.Include(v => v.Items).ThenInclude(i => i.Item)
|
||||||
.AsSplitQuery()
|
.AsSplitQuery()
|
||||||
.FirstAsync(v => v.Id == viewerId);
|
.FirstAsync(v => v.Id == viewerId);
|
||||||
|
|
||||||
|
// Tutorial alias is only valid pre-END. After state>=100 the viewer has already
|
||||||
|
// completed the tutorial — re-running the path would re-consume the ticket they
|
||||||
|
// chose to keep, and (without the max-preserve write below) could regress a higher
|
||||||
|
// state value. Mirrors the 31<41 guard in GiftController.TutorialGiftReceive.
|
||||||
|
const int TutorialEndStep = 100;
|
||||||
|
if (isTutorialPath && viewer.MissionData.TutorialState >= TutorialEndStep)
|
||||||
|
return BadRequest(new { error = "tutorial_already_complete" });
|
||||||
|
|
||||||
int packNumber = Math.Max(1, request.PackNumber);
|
int packNumber = Math.Max(1, request.PackNumber);
|
||||||
|
|
||||||
// Currency check + deduction (skipped for tutorial path — starter pack is free)
|
// Currency check + deduction (skipped for tutorial path — starter pack is free)
|
||||||
@@ -293,9 +310,14 @@ public class PackController : SVSimController
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
viewer.MissionData.TutorialState = 100;
|
// Max-preserve: never regress the persisted state, even though Gate B already
|
||||||
|
// rejected state>=100 above. Belt-and-braces against a future caller that
|
||||||
|
// bypasses Gate B (refactor, new alias, etc.). Wire still emits 100 — that's
|
||||||
|
// the tutorial-END signal the client expects.
|
||||||
|
if (viewer.MissionData.TutorialState < TutorialEndStep)
|
||||||
|
viewer.MissionData.TutorialState = TutorialEndStep;
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
responseTutorialStep = 100;
|
responseTutorialStep = TutorialEndStep;
|
||||||
}
|
}
|
||||||
|
|
||||||
return new PackOpenResponse
|
return new PackOpenResponse
|
||||||
|
|||||||
@@ -128,4 +128,70 @@ public class PackControllerTests
|
|||||||
Assert.That(body.Contains("\"tutorial_step\""), Is.False,
|
Assert.That(body.Contains("\"tutorial_step\""), Is.False,
|
||||||
"Regular /pack/open must never emit tutorial_step.");
|
"Regular /pack/open must never emit tutorial_step.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task TutorialPackOpen_rejects_non_starter_parent_gacha_id()
|
||||||
|
{
|
||||||
|
using var factory = new SVSimTestFactory();
|
||||||
|
await factory.SeedGlobalsAsync();
|
||||||
|
long viewerId = await factory.SeedViewerAsync(tutorialState: 41);
|
||||||
|
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||||
|
|
||||||
|
// Pick any non-99047 parent_gacha_id seeded by SeedGlobalsAsync (10032 is the most
|
||||||
|
// recent crystal-multi pack in the catalog). The alias must reject it BadRequest.
|
||||||
|
var requestJson = """{"parent_gacha_id":10032,"gacha_id":100320,"gacha_type":1,"pack_number":1,"exclude_card_ids":[],"viewer_id":"0","steam_id":0,"steam_session_ticket":""}""";
|
||||||
|
var response = await client.PostAsync("/tutorial/pack_open",
|
||||||
|
new StringContent(requestJson, Encoding.UTF8, "application/json"));
|
||||||
|
|
||||||
|
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest),
|
||||||
|
"Tutorial alias must only accept the starter pack (99047); otherwise any authenticated " +
|
||||||
|
"viewer can draw any pack for free via the currency-bypass tutorial path.");
|
||||||
|
|
||||||
|
// State must NOT have advanced.
|
||||||
|
Assert.That(await factory.GetViewerTutorialStateAsync(viewerId), Is.EqualTo(41),
|
||||||
|
"Rejected requests leave TutorialState untouched.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task TutorialPackOpen_rejects_completed_viewer()
|
||||||
|
{
|
||||||
|
using var factory = new SVSimTestFactory();
|
||||||
|
await factory.SeedGlobalsAsync();
|
||||||
|
long viewerId = await factory.SeedViewerAsync(tutorialState: 100);
|
||||||
|
await factory.SeedOwnedItemAsync(viewerId, itemId: 90001, count: 1, itemName: "Starter Legendary Ticket");
|
||||||
|
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||||
|
|
||||||
|
var requestJson = """{"parent_gacha_id":99047,"gacha_id":990047,"gacha_type":1,"pack_number":1,"exclude_card_ids":[],"viewer_id":"0","steam_id":0,"steam_session_ticket":""}""";
|
||||||
|
var response = await client.PostAsync("/tutorial/pack_open",
|
||||||
|
new StringContent(requestJson, Encoding.UTF8, "application/json"));
|
||||||
|
|
||||||
|
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest),
|
||||||
|
"Tutorial alias must reject viewers past the tutorial-end gate (state>=100); the path " +
|
||||||
|
"would otherwise re-clobber state and consume a ticket the viewer kept post-tutorial.");
|
||||||
|
|
||||||
|
Assert.That(await factory.GetOwnedItemCountAsync(viewerId, 90001), Is.EqualTo(1),
|
||||||
|
"Rejected requests do not consume tickets.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task TutorialPackOpen_does_not_downgrade_state_past_100()
|
||||||
|
{
|
||||||
|
// This is the max-preserve check. A future state > 100 (e.g., a post-tutorial training
|
||||||
|
// sentinel) must not be clobbered down to 100. Today nothing in prod sets state above 100,
|
||||||
|
// so synthesize the case directly.
|
||||||
|
using var factory = new SVSimTestFactory();
|
||||||
|
await factory.SeedGlobalsAsync();
|
||||||
|
long viewerId = await factory.SeedViewerAsync(tutorialState: 200);
|
||||||
|
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||||
|
|
||||||
|
var requestJson = """{"parent_gacha_id":99047,"gacha_id":990047,"gacha_type":1,"pack_number":1,"exclude_card_ids":[],"viewer_id":"0","steam_id":0,"steam_session_ticket":""}""";
|
||||||
|
var response = await client.PostAsync("/tutorial/pack_open",
|
||||||
|
new StringContent(requestJson, Encoding.UTF8, "application/json"));
|
||||||
|
|
||||||
|
// Either the request is rejected (because state>=100, see Gate B above), OR — if the
|
||||||
|
// implementation reads the gate differently — at minimum the persisted state must not
|
||||||
|
// regress. Encode the load-bearing invariant: state never goes backwards.
|
||||||
|
Assert.That(await factory.GetViewerTutorialStateAsync(viewerId), Is.GreaterThanOrEqualTo(200),
|
||||||
|
"TutorialState must not regress regardless of the alias's accept/reject decision.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user