Merge pull request #190 from simoleo89/fix/catalog-page-mutation-guards

fix(catalog): harden admin mutations and voucher claims
This commit is contained in:
DuckieTM
2026-06-15 07:22:47 +02:00
committed by GitHub
13 changed files with 461 additions and 70 deletions
@@ -711,18 +711,22 @@ public class CatalogManager {
return;
}
if (voucher.isExhausted()) {
client.sendResponse(new RedeemVoucherErrorComposer(Emulator.getGameEnvironment().getCatalogManager().deleteVoucher(voucher) ? RedeemVoucherErrorComposer.INVALID_CODE : RedeemVoucherErrorComposer.TECHNICAL_ERROR));
return;
Voucher.ClaimResult claimResult = voucher.claimForUser(habbo.getHabboInfo().getId());
switch (claimResult) {
case CLAIMED:
break;
case EXHAUSTED:
client.sendResponse(new RedeemVoucherErrorComposer(Emulator.getGameEnvironment().getCatalogManager().deleteVoucher(voucher) ? RedeemVoucherErrorComposer.INVALID_CODE : RedeemVoucherErrorComposer.TECHNICAL_ERROR));
return;
case USER_LIMIT:
client.sendResponse(new ModToolIssueHandledComposer("You have exceeded the limit for redeeming this voucher."));
return;
case FAILED:
default:
client.sendResponse(new RedeemVoucherErrorComposer(RedeemVoucherErrorComposer.TECHNICAL_ERROR));
return;
}
if (voucher.hasUserExhausted(habbo.getHabboInfo().getId())) {
client.sendResponse(new ModToolIssueHandledComposer("You have exceeded the limit for redeeming this voucher."));
return;
}
voucher.addHistoryEntry(habbo.getHabboInfo().getId());
if (voucher.points > 0) {
client.getHabbo().givePoints(voucher.pointsType, voucher.points);
}
@@ -14,6 +14,13 @@ import java.util.List;
public class Voucher {
private static final Logger LOGGER = LoggerFactory.getLogger(Voucher.class);
public enum ClaimResult {
CLAIMED,
EXHAUSTED,
USER_LIMIT,
FAILED
}
public final int id;
public final String code;
public final int credits;
@@ -58,18 +65,34 @@ public class Voucher {
return this.amount > 0 && this.history.size() >= this.amount;
}
public void addHistoryEntry(int userId) {
int timestamp = Emulator.getIntUnixTimestamp();
this.history.add(new VoucherHistoryEntry(this.id, userId, timestamp));
public synchronized ClaimResult claimForUser(int userId) {
if (this.isExhausted()) {
return ClaimResult.EXHAUSTED;
}
if (this.hasUserExhausted(userId)) {
return ClaimResult.USER_LIMIT;
}
int timestamp = Emulator.getIntUnixTimestamp();
if (!this.insertHistoryEntry(userId, timestamp)) {
return ClaimResult.FAILED;
}
this.history.add(new VoucherHistoryEntry(this.id, userId, timestamp));
return ClaimResult.CLAIMED;
}
private boolean insertHistoryEntry(int userId, int timestamp) {
try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); PreparedStatement statement = connection.prepareStatement("INSERT INTO voucher_history (`voucher_id`, `user_id`, `timestamp`) VALUES (?, ?, ?)")) {
statement.setInt(1, this.id);
statement.setInt(2, userId);
statement.setInt(3, timestamp);
statement.execute();
return statement.executeUpdate() > 0;
} catch (SQLException e) {
LOGGER.error("Caught SQL exception", e);
return false;
}
}
}
@@ -35,33 +35,45 @@ public class CatalogAdminCreateOfferEvent extends MessageHandler {
int orderNumber = this.packet.readInt();
CatalogPageType pageType = CatalogPageType.fromString(this.packet.readString());
CatalogAdminOfferPayload payload = CatalogAdminOfferPayload.validate(pageId, itemIds, catalogName, costCredits,
costPoints, pointsType, amount, clubOnly, extradata, haveOffer, offerIdGroup, limitedStack,
orderNumber, pageType);
if (payload == null) {
this.client.sendResponse(new CatalogAdminResultComposer(false, "Invalid offer payload"));
return;
}
if (Emulator.getGameEnvironment().getCatalogManager().getCatalogPage(payload.pageId, payload.pageType) == null) {
this.client.sendResponse(new CatalogAdminResultComposer(false, "Page not found: " + payload.pageId));
return;
}
int newId = -1;
try (Connection connection = Emulator.getDatabase().getDataSource().getConnection();
PreparedStatement statement = connection.prepareStatement(
(pageType == CatalogPageType.BUILDER)
(payload.pageType == CatalogPageType.BUILDER)
? "INSERT INTO catalog_items_bc (page_id, item_ids, catalog_name, order_number, extradata) VALUES (?, ?, ?, ?, ?)"
: "INSERT INTO catalog_items (page_id, item_ids, catalog_name, cost_credits, cost_points, points_type, amount, club_only, extradata, have_offer, offer_id, limited_stack, order_number) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
Statement.RETURN_GENERATED_KEYS)) {
String cleanItemIds = (itemIds == null || itemIds.trim().isEmpty()) ? "0" : itemIds.trim();
statement.setInt(1, pageId);
statement.setString(2, cleanItemIds);
statement.setString(3, catalogName);
statement.setInt(1, payload.pageId);
statement.setString(2, payload.itemIds);
statement.setString(3, payload.catalogName);
if (pageType == CatalogPageType.BUILDER) {
statement.setInt(4, orderNumber);
statement.setString(5, extradata);
if (payload.pageType == CatalogPageType.BUILDER) {
statement.setInt(4, payload.orderNumber);
statement.setString(5, payload.extradata);
} else {
statement.setInt(4, costCredits);
statement.setInt(5, costPoints);
statement.setInt(6, pointsType);
statement.setInt(7, amount);
statement.setString(8, clubOnly == 1 ? "1" : "0");
statement.setString(9, extradata);
statement.setString(10, haveOffer ? "1" : "0");
statement.setInt(11, offerIdGroup);
statement.setInt(12, limitedStack);
statement.setInt(13, orderNumber);
statement.setInt(4, payload.costCredits);
statement.setInt(5, payload.costPoints);
statement.setInt(6, payload.pointsType);
statement.setInt(7, payload.amount);
statement.setString(8, payload.clubOnly == 1 ? "1" : "0");
statement.setString(9, payload.extradata);
statement.setString(10, payload.haveOffer ? "1" : "0");
statement.setInt(11, payload.offerIdGroup);
statement.setInt(12, payload.limitedStack);
statement.setInt(13, payload.orderNumber);
}
statement.execute();
@@ -36,7 +36,7 @@ public class CatalogAdminCreatePageEvent extends MessageHandler {
pageLayout = CatalogPageLayouts.default_3x3;
}
if (parentId != -1 && Emulator.getGameEnvironment().getCatalogManager().getCatalogPage(parentId) == null) {
if (parentId != -1 && Emulator.getGameEnvironment().getCatalogManager().getCatalogPage(parentId, pageType) == null) {
this.client.sendResponse(new CatalogAdminResultComposer(false, "Parent page not found: " + parentId));
return;
}
@@ -36,7 +36,10 @@ public class CatalogAdminDeletePageEvent extends MessageHandler {
try (Connection connection = Emulator.getDatabase().getDataSource().getConnection();
PreparedStatement statement = connection.prepareStatement(query)) {
statement.setInt(1, pageId);
statement.execute();
if (statement.executeUpdate() == 0) {
this.client.sendResponse(new CatalogAdminResultComposer(false, "Page not found: " + pageId));
return;
}
}
Emulator.getGameEnvironment().getCatalogManager().getCatalogPagesMap(pageType).remove(pageId);
@@ -28,12 +28,21 @@ public class CatalogAdminMovePageEvent extends MessageHandler {
CatalogPageType pageType = CatalogPageType.fromString(this.packet.readString());
String tableName = (pageType == CatalogPageType.BUILDER) ? "catalog_pages_bc" : "catalog_pages";
CatalogPage page = Emulator.getGameEnvironment().getCatalogManager().getCatalogPage(pageId, pageType);
if (page == null) {
this.client.sendResponse(new CatalogAdminResultComposer(false, "Page not found: " + pageId));
return;
}
if (newParentId == -1) {
try (Connection connection = Emulator.getDatabase().getDataSource().getConnection();
PreparedStatement statement = connection.prepareStatement(
"UPDATE " + tableName + " SET enabled = IF(enabled = '1', '0', '1') WHERE id = ?")) {
statement.setInt(1, pageId);
statement.execute();
if (statement.executeUpdate() == 0) {
this.client.sendResponse(new CatalogAdminResultComposer(false, "Page not found: " + pageId));
return;
}
}
this.client.sendResponse(new CatalogAdminResultComposer(true, "Page toggled"));
return;
@@ -44,30 +53,27 @@ public class CatalogAdminMovePageEvent extends MessageHandler {
PreparedStatement statement = connection.prepareStatement(
"UPDATE " + tableName + " SET visible = IF(visible = '1', '0', '1') WHERE id = ?")) {
statement.setInt(1, pageId);
statement.execute();
if (statement.executeUpdate() == 0) {
this.client.sendResponse(new CatalogAdminResultComposer(false, "Page not found: " + pageId));
return;
}
}
this.client.sendResponse(new CatalogAdminResultComposer(true, "Visibility toggled"));
return;
}
CatalogPage page = Emulator.getGameEnvironment().getCatalogManager().getCatalogPage(pageId, pageType);
if (page == null) {
this.client.sendResponse(new CatalogAdminResultComposer(false, "Page not found: " + pageId));
return;
}
if (newParentId == pageId) {
this.client.sendResponse(new CatalogAdminResultComposer(false, "A page cannot be its own parent"));
return;
}
CatalogPage parent = Emulator.getGameEnvironment().getCatalogManager().getCatalogPage(newParentId);
CatalogPage parent = Emulator.getGameEnvironment().getCatalogManager().getCatalogPage(newParentId, pageType);
if (parent == null) {
this.client.sendResponse(new CatalogAdminResultComposer(false, "Parent page not found: " + newParentId));
return;
}
if (this.wouldCreateCycle(pageId, newParentId)) {
if (this.wouldCreateCycle(pageId, newParentId, pageType)) {
this.client.sendResponse(new CatalogAdminResultComposer(false, "Refusing to move: that would create a cycle"));
return;
}
@@ -80,18 +86,21 @@ public class CatalogAdminMovePageEvent extends MessageHandler {
statement.setInt(1, newParentId);
statement.setInt(2, newIndex);
statement.setInt(3, pageId);
statement.execute();
if (statement.executeUpdate() == 0) {
this.client.sendResponse(new CatalogAdminResultComposer(false, "Page not found: " + pageId));
return;
}
}
this.client.sendResponse(new CatalogAdminResultComposer(true, "Page moved"));
}
private boolean wouldCreateCycle(int pageId, int parentId) {
private boolean wouldCreateCycle(int pageId, int parentId, CatalogPageType pageType) {
int current = parentId;
for (int hops = 0; hops < MAX_PARENT_WALK; hops++) {
if (current == ROOT_PARENT_ID) return false;
if (current == pageId) return true;
CatalogPage parent = Emulator.getGameEnvironment().getCatalogManager().getCatalogPage(current);
CatalogPage parent = Emulator.getGameEnvironment().getCatalogManager().getCatalogPage(current, pageType);
if (parent == null) return false;
current = parent.getParentId();
}
@@ -0,0 +1,125 @@
package com.eu.habbo.messages.incoming.catalog.catalogadmin;
import com.eu.habbo.habbohotel.catalog.CatalogPageType;
final class CatalogAdminOfferPayload {
private static final int MAX_ITEM_IDS_LENGTH = 512;
private static final int MAX_ITEM_IDS = 100;
private static final int MAX_CATALOG_NAME_LENGTH = 128;
private static final int MAX_EXTRADATA_LENGTH = 1024;
private static final int MAX_CURRENCY_VALUE = 1_000_000_000;
private static final int MAX_AMOUNT = 10_000;
private static final int MAX_POINTS_TYPE = 10_000;
private static final int MAX_ORDER_NUMBER = 1_000_000;
private static final int MAX_LIMITED_STACK = 1_000_000;
final int pageId;
final String itemIds;
final String catalogName;
final int costCredits;
final int costPoints;
final int pointsType;
final int amount;
final int clubOnly;
final String extradata;
final boolean haveOffer;
final int offerIdGroup;
final int limitedStack;
final int orderNumber;
final CatalogPageType pageType;
private CatalogAdminOfferPayload(int pageId, String itemIds, String catalogName, int costCredits, int costPoints,
int pointsType, int amount, int clubOnly, String extradata, boolean haveOffer,
int offerIdGroup, int limitedStack, int orderNumber, CatalogPageType pageType) {
this.pageId = pageId;
this.itemIds = itemIds;
this.catalogName = catalogName;
this.costCredits = costCredits;
this.costPoints = costPoints;
this.pointsType = pointsType;
this.amount = amount;
this.clubOnly = clubOnly;
this.extradata = extradata;
this.haveOffer = haveOffer;
this.offerIdGroup = offerIdGroup;
this.limitedStack = limitedStack;
this.orderNumber = orderNumber;
this.pageType = pageType;
}
static CatalogAdminOfferPayload validate(int pageId, String itemIds, String catalogName, int costCredits,
int costPoints, int pointsType, int amount, int clubOnly,
String extradata, boolean haveOffer, int offerIdGroup,
int limitedStack, int orderNumber, CatalogPageType pageType) {
String cleanItemIds = normalizeItemIds(itemIds);
String cleanCatalogName = clamp(catalogName, MAX_CATALOG_NAME_LENGTH);
String cleanExtradata = clamp(extradata, MAX_EXTRADATA_LENGTH);
if (pageId <= 0
|| cleanItemIds == null
|| cleanCatalogName.isBlank()
|| !isInRange(orderNumber, 0, MAX_ORDER_NUMBER)) {
return null;
}
if (pageType != CatalogPageType.BUILDER) {
if (!isInRange(costCredits, 0, MAX_CURRENCY_VALUE)
|| !isInRange(costPoints, 0, MAX_CURRENCY_VALUE)
|| !isInRange(pointsType, 0, MAX_POINTS_TYPE)
|| !isInRange(amount, 1, MAX_AMOUNT)
|| !isInRange(clubOnly, 0, 1)
|| offerIdGroup < 0
|| !isInRange(limitedStack, 0, MAX_LIMITED_STACK)) {
return null;
}
}
return new CatalogAdminOfferPayload(pageId, cleanItemIds, cleanCatalogName, costCredits, costPoints,
pointsType, amount, clubOnly, cleanExtradata, haveOffer, offerIdGroup, limitedStack, orderNumber,
pageType);
}
private static String normalizeItemIds(String value) {
if (value == null || value.trim().isEmpty()) {
return "0";
}
String clean = value.trim();
if (clean.length() > MAX_ITEM_IDS_LENGTH) {
return null;
}
String[] parts = clean.split(",");
if (parts.length == 0 || parts.length > MAX_ITEM_IDS) {
return null;
}
for (String part : parts) {
if (part.isBlank()) {
return null;
}
try {
if (Integer.parseInt(part.trim()) < 0) {
return null;
}
} catch (NumberFormatException e) {
return null;
}
}
return clean.replaceAll("\\s+", "");
}
private static boolean isInRange(int value, int min, int max) {
return value >= min && value <= max;
}
private static String clamp(String value, int maxLength) {
if (value == null) {
return "";
}
return value.length() <= maxLength ? value : value.substring(0, maxLength);
}
}
@@ -34,10 +34,28 @@ public class CatalogAdminSaveOfferEvent extends MessageHandler {
int orderNumber = this.packet.readInt();
CatalogPageType pageType = CatalogPageType.fromString(this.packet.readString());
if (offerId <= 0) {
this.client.sendResponse(new CatalogAdminResultComposer(false, "Invalid offer id"));
return;
}
CatalogAdminOfferPayload payload = CatalogAdminOfferPayload.validate(pageId, itemIds, catalogName, costCredits,
costPoints, pointsType, amount, clubOnly, extradata, haveOffer, offerIdGroup, limitedStack,
orderNumber, pageType);
if (payload == null) {
this.client.sendResponse(new CatalogAdminResultComposer(false, "Invalid offer payload"));
return;
}
if (Emulator.getGameEnvironment().getCatalogManager().getCatalogPage(payload.pageId, payload.pageType) == null) {
this.client.sendResponse(new CatalogAdminResultComposer(false, "Page not found: " + payload.pageId));
return;
}
boolean updateItemIds = itemIds != null && !itemIds.trim().isEmpty();
String sql;
if (pageType == CatalogPageType.BUILDER) {
if (payload.pageType == CatalogPageType.BUILDER) {
sql = updateItemIds
? "UPDATE catalog_items_bc SET page_id = ?, item_ids = ?, catalog_name = ?, order_number = ?, extradata = ? WHERE id = ?"
: "UPDATE catalog_items_bc SET page_id = ?, catalog_name = ?, order_number = ?, extradata = ? WHERE id = ?";
@@ -50,30 +68,33 @@ public class CatalogAdminSaveOfferEvent extends MessageHandler {
try (Connection connection = Emulator.getDatabase().getDataSource().getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
int idx = 1;
statement.setInt(idx++, pageId);
statement.setInt(idx++, payload.pageId);
if (updateItemIds) {
statement.setString(idx++, itemIds.trim());
statement.setString(idx++, payload.itemIds);
}
statement.setString(idx++, catalogName);
statement.setString(idx++, payload.catalogName);
if (pageType == CatalogPageType.BUILDER) {
statement.setInt(idx++, orderNumber);
statement.setString(idx++, extradata);
if (payload.pageType == CatalogPageType.BUILDER) {
statement.setInt(idx++, payload.orderNumber);
statement.setString(idx++, payload.extradata);
statement.setInt(idx, offerId);
} else {
statement.setInt(idx++, costCredits);
statement.setInt(idx++, costPoints);
statement.setInt(idx++, pointsType);
statement.setInt(idx++, amount);
statement.setString(idx++, clubOnly == 1 ? "1" : "0");
statement.setString(idx++, extradata);
statement.setString(idx++, haveOffer ? "1" : "0");
statement.setInt(idx++, offerIdGroup);
statement.setInt(idx++, limitedStack);
statement.setInt(idx++, orderNumber);
statement.setInt(idx++, payload.costCredits);
statement.setInt(idx++, payload.costPoints);
statement.setInt(idx++, payload.pointsType);
statement.setInt(idx++, payload.amount);
statement.setString(idx++, payload.clubOnly == 1 ? "1" : "0");
statement.setString(idx++, payload.extradata);
statement.setString(idx++, payload.haveOffer ? "1" : "0");
statement.setInt(idx++, payload.offerIdGroup);
statement.setInt(idx++, payload.limitedStack);
statement.setInt(idx++, payload.orderNumber);
statement.setInt(idx, offerId);
}
statement.execute();
if (statement.executeUpdate() == 0) {
this.client.sendResponse(new CatalogAdminResultComposer(false, "Offer not found: " + offerId));
return;
}
}
this.client.sendResponse(new CatalogAdminResultComposer(true, "Offer saved"));
@@ -74,13 +74,13 @@ public class CatalogAdminSavePageEvent extends MessageHandler {
return;
}
CatalogPage parent = Emulator.getGameEnvironment().getCatalogManager().getCatalogPage(parentId);
CatalogPage parent = Emulator.getGameEnvironment().getCatalogManager().getCatalogPage(parentId, pageType);
if (parent == null) {
this.client.sendResponse(new CatalogAdminResultComposer(false, "Parent page not found: " + parentId));
return;
}
if (this.wouldCreateCycle(pageId, parentId)) {
if (this.wouldCreateCycle(pageId, parentId, pageType)) {
this.client.sendResponse(new CatalogAdminResultComposer(false, "Refusing to re-parent: that would create a cycle"));
return;
}
@@ -144,18 +144,21 @@ public class CatalogAdminSavePageEvent extends MessageHandler {
statement.setInt(15, pageId);
}
statement.execute();
if (statement.executeUpdate() == 0) {
this.client.sendResponse(new CatalogAdminResultComposer(false, "Page not found: " + pageId));
return;
}
}
this.client.sendResponse(new CatalogAdminResultComposer(true, "Page saved"));
}
private boolean wouldCreateCycle(int pageId, int parentId) {
private boolean wouldCreateCycle(int pageId, int parentId, CatalogPageType pageType) {
int current = parentId;
for (int hops = 0; hops < MAX_PARENT_WALK; hops++) {
if (current == ROOT_PARENT_ID) return false;
if (current == pageId) return true;
CatalogPage parent = Emulator.getGameEnvironment().getCatalogManager().getCatalogPage(current);
CatalogPage parent = Emulator.getGameEnvironment().getCatalogManager().getCatalogPage(current, pageType);
if (parent == null) return false;
current = parent.getParentId();
}
@@ -0,0 +1,50 @@
package com.eu.habbo.habbohotel.catalog;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.nio.file.Files;
import java.nio.file.Path;
import org.junit.jupiter.api.Test;
class VoucherClaimContractTest {
private static String voucherSource() throws Exception {
return Files.readString(Path.of("src/main/java/com/eu/habbo/habbohotel/catalog/Voucher.java"));
}
private static String catalogManagerSource() throws Exception {
return Files.readString(Path.of("src/main/java/com/eu/habbo/habbohotel/catalog/CatalogManager.java"));
}
@Test
void voucherClaimIsSynchronizedAndPersistsBeforeRewardEligibility() throws Exception {
String source = voucherSource();
assertTrue(source.contains("public synchronized ClaimResult claimForUser(int userId)"),
"voucher claim should check limits and persist history under a per-voucher lock");
assertTrue(source.contains("private boolean insertHistoryEntry"),
"history insert should report database failure to the caller");
int insertCall = source.indexOf("insertHistoryEntry(userId, timestamp)");
int memoryAppend = source.indexOf("this.history.add(new VoucherHistoryEntry(this.id, userId, timestamp))");
assertTrue(insertCall > -1, "claimForUser must persist the history row");
assertTrue(memoryAppend > insertCall,
"in-memory history must only be updated after the database insert succeeds");
}
@Test
void catalogRewardsOnlyAfterVoucherClaimSucceeds() throws Exception {
String source = catalogManagerSource();
int claim = source.indexOf("Voucher.ClaimResult claimResult = voucher.claimForUser");
int claimedGuard = source.indexOf("case CLAIMED", claim);
int pointsGrant = source.indexOf("client.getHabbo().givePoints", claim);
int creditsGrant = source.indexOf("client.getHabbo().giveCredits", claim);
assertTrue(claim > -1, "CatalogManager must claim the voucher before applying rewards");
assertTrue(claimedGuard > claim, "voucher rewards should only continue for a CLAIMED result");
assertTrue(pointsGrant > claimedGuard, "points must be granted only after CLAIMED");
assertTrue(creditsGrant > claimedGuard, "credits must be granted only after CLAIMED");
}
}
@@ -0,0 +1,43 @@
package com.eu.habbo.messages.incoming.catalog.catalogadmin;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import org.junit.jupiter.api.Test;
class CatalogAdminOfferMutationContractTest {
private static final Path CREATE_SOURCE = Path.of(
"src/main/java/com/eu/habbo/messages/incoming/catalog/catalogadmin/CatalogAdminCreateOfferEvent.java");
private static final Path SAVE_SOURCE = Path.of(
"src/main/java/com/eu/habbo/messages/incoming/catalog/catalogadmin/CatalogAdminSaveOfferEvent.java");
@Test
void createAndSaveValidatePayloadAndTargetPageBeforeWriting() throws IOException {
String create = Files.readString(CREATE_SOURCE);
String save = Files.readString(SAVE_SOURCE);
assertTrue(create.contains("CatalogAdminOfferPayload.validate("));
assertTrue(save.contains("CatalogAdminOfferPayload.validate("));
assertTrue(create.contains("getCatalogPage(payload.pageId, payload.pageType) == null"));
assertTrue(save.contains("getCatalogPage(payload.pageId, payload.pageType) == null"));
int createValidation = create.indexOf("CatalogAdminOfferPayload.validate(");
int createInsert = create.indexOf("INSERT INTO catalog_items");
int saveValidation = save.indexOf("CatalogAdminOfferPayload.validate(");
int saveUpdate = save.indexOf("UPDATE catalog_items");
assertTrue(createValidation < createInsert, "create offer should validate before insert SQL is prepared");
assertTrue(saveValidation < saveUpdate, "save offer should validate before update SQL is prepared");
}
@Test
void saveOfferReportsMissingRowsInsteadOfAlwaysSucceeding() throws IOException {
String save = Files.readString(SAVE_SOURCE);
assertTrue(save.contains("statement.executeUpdate() == 0"));
assertTrue(save.contains("Offer not found: "));
}
}
@@ -0,0 +1,41 @@
package com.eu.habbo.messages.incoming.catalog.catalogadmin;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import com.eu.habbo.habbohotel.catalog.CatalogPageType;
import org.junit.jupiter.api.Test;
class CatalogAdminOfferPayloadTest {
@Test
void acceptsAndNormalizesValidOfferPayload() {
CatalogAdminOfferPayload payload = CatalogAdminOfferPayload.validate(
42, "1, 2,3", "Rare Chair", 100, 5, 0, 1, 0,
"extra", true, 0, 0, 10, CatalogPageType.NORMAL);
assertNotNull(payload);
assertEquals("1,2,3", payload.itemIds);
assertEquals("Rare Chair", payload.catalogName);
}
@Test
void rejectsInvalidItemIdsAndNegativeEconomyValues() {
assertNull(CatalogAdminOfferPayload.validate(42, "1,abc", "Name", 0, 0, 0, 1, 0,
"", false, 0, 0, 0, CatalogPageType.NORMAL));
assertNull(CatalogAdminOfferPayload.validate(42, "1", "Name", -1, 0, 0, 1, 0,
"", false, 0, 0, 0, CatalogPageType.NORMAL));
assertNull(CatalogAdminOfferPayload.validate(42, "1", "Name", 0, 0, 0, 0, 0,
"", false, 0, 0, 0, CatalogPageType.NORMAL));
}
@Test
void builderOffersStillRequireSafeCommonFields() {
assertNotNull(CatalogAdminOfferPayload.validate(42, "", "BC Offer", -1, -1, -1, -1, -1,
"", false, -1, -1, 0, CatalogPageType.BUILDER));
assertNull(CatalogAdminOfferPayload.validate(0, "1", "BC Offer", 0, 0, 0, 1, 0,
"", false, 0, 0, 0, CatalogPageType.BUILDER));
assertNull(CatalogAdminOfferPayload.validate(42, "1", "", 0, 0, 0, 1, 0,
"", false, 0, 0, 0, CatalogPageType.BUILDER));
}
}
@@ -0,0 +1,57 @@
package com.eu.habbo.messages.incoming.catalog.catalogadmin;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.assertTrue;
class CatalogAdminPageMutationContractTest {
private static final Path CREATE_SOURCE = Path.of(
"src/main/java/com/eu/habbo/messages/incoming/catalog/catalogadmin/CatalogAdminCreatePageEvent.java");
private static final Path SAVE_SOURCE = Path.of(
"src/main/java/com/eu/habbo/messages/incoming/catalog/catalogadmin/CatalogAdminSavePageEvent.java");
private static final Path MOVE_SOURCE = Path.of(
"src/main/java/com/eu/habbo/messages/incoming/catalog/catalogadmin/CatalogAdminMovePageEvent.java");
private static final Path DELETE_SOURCE = Path.of(
"src/main/java/com/eu/habbo/messages/incoming/catalog/catalogadmin/CatalogAdminDeletePageEvent.java");
@Test
void pageParentChecksStayWithinTheSameCatalogPageType() throws IOException {
String create = Files.readString(CREATE_SOURCE);
String save = Files.readString(SAVE_SOURCE);
String move = Files.readString(MOVE_SOURCE);
assertTrue(create.contains("getCatalogPage(parentId, pageType)"));
assertTrue(save.contains("getCatalogPage(parentId, pageType)"));
assertTrue(save.contains("getCatalogPage(current, pageType)"));
assertTrue(move.contains("getCatalogPage(newParentId, pageType)"));
assertTrue(move.contains("getCatalogPage(current, pageType)"));
}
@Test
void movePageValidatesTargetBeforeTogglingVisibilityOrEnabledState() throws IOException {
String move = Files.readString(MOVE_SOURCE);
int pageLookup = move.indexOf("getCatalogPage(pageId, pageType)");
int enabledToggle = move.indexOf("SET enabled = IF");
int visibleToggle = move.indexOf("SET visible = IF");
assertTrue(pageLookup >= 0, "move page should load the page before mutating it");
assertTrue(pageLookup < enabledToggle, "enabled toggle must not run before page existence is checked");
assertTrue(pageLookup < visibleToggle, "visible toggle must not run before page existence is checked");
}
@Test
void pageMutationsReportMissingRowsInsteadOfAlwaysSucceeding() throws IOException {
String save = Files.readString(SAVE_SOURCE);
String move = Files.readString(MOVE_SOURCE);
String delete = Files.readString(DELETE_SOURCE);
assertTrue(save.contains("statement.executeUpdate() == 0"));
assertTrue(move.contains("statement.executeUpdate() == 0"));
assertTrue(delete.contains("statement.executeUpdate() == 0"));
}
}