From 928646437578bfaa70363a48ff4925ddb2cdb046 Mon Sep 17 00:00:00 2001 From: Multiman155 Date: Wed, 25 Mar 2026 08:04:14 -0400 Subject: [PATCH] general hardening fixes, duplication prevention --- .github/dependabot.yml | 13 + .github/workflows/build.yml | 8 +- .github/workflows/codeql.yml | 51 ++++ .github/workflows/dependency-review.yml | 15 + .../realty/database/RealtyLogicImpl.java | 268 ++++++++++++++--- .../realty/database/AbstractDatabaseTest.java | 4 +- .../realty/database/AgentLogicTest.java | 85 ++++-- .../realty/database/RealtyLogicImplTest.java | 271 +++++++++++++++++- .../realty/api/RegionProfileService.java | 10 +- .../realty/api/SignCommandSanitizer.java | 41 +++ .../command/AgentInviteWithdrawCommand.java | 9 +- .../realty/command/AgentRemoveCommand.java | 33 ++- .../realty/command/AuctionCommandGroup.java | 38 ++- .../realty/command/ExtendCommand.java | 25 +- .../md5sha256/realty/command/RentCommand.java | 25 +- .../realty/command/SetCommandGroup.java | 74 +++-- .../realty/command/UnrentCommand.java | 5 + .../realty/command/UnsetCommandGroup.java | 38 ++- .../util/DeferredEconomySettlement.java | 36 +++ .../realty/api/SignCommandSanitizerTest.java | 39 +++ .../util/DeferredEconomySettlementTest.java | 58 ++++ 21 files changed, 973 insertions(+), 173 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/codeql.yml create mode 100644 .github/workflows/dependency-review.yml create mode 100644 realty-paper/src/main/java/io/github/md5sha256/realty/api/SignCommandSanitizer.java create mode 100644 realty-paper/src/main/java/io/github/md5sha256/realty/util/DeferredEconomySettlement.java create mode 100644 realty-paper/src/test/java/io/github/md5sha256/realty/api/SignCommandSanitizerTest.java create mode 100644 realty-paper/src/test/java/io/github/md5sha256/realty/util/DeferredEconomySettlementTest.java diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..f17594f --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,13 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 10 + + - package-ecosystem: gradle + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 10 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3cbe6c1..610a309 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -18,16 +18,16 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Set up JDK 21 - uses: actions/setup-java@v5 + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 with: distribution: temurin java-version: '21' - name: Set up Gradle - uses: gradle/actions/setup-gradle@v6 + uses: gradle/actions/setup-gradle@39e147cb9de83bb9910b8ef8bd7fff0ee20fcd6f # v6 - name: Build run: | @@ -45,7 +45,7 @@ jobs: -exec cp {} jars/ \; - name: Upload JARs - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 with: name: jars-${{ github.sha }} path: jars/*.jar diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..9294e60 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,51 @@ +name: CodeQL + +on: + push: + branches: [main, master, develop] + pull_request: + schedule: + - cron: '23 7 * * 1' + workflow_dispatch: + +permissions: + actions: read + contents: read + security-events: write + +jobs: + analyze: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + language: [java-kotlin] + + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: Set up JDK 21 + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 + with: + distribution: temurin + java-version: '21' + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@39e147cb9de83bb9910b8ef8bd7fff0ee20fcd6f # v6 + + - name: Initialize CodeQL + uses: github/codeql-action/init@cb06a0a8527b2c6970741b3a0baa15231dc74a4c # v4.34.1 + with: + languages: ${{ matrix.language }} + + - name: Make Gradle executable + run: chmod +x ./gradlew + + - name: Autobuild + uses: github/codeql-action/autobuild@cb06a0a8527b2c6970741b3a0baa15231dc74a4c # v4.34.1 + + - name: Analyze + uses: github/codeql-action/analyze@cb06a0a8527b2c6970741b3a0baa15231dc74a4c # v4.34.1 + with: + category: /language:${{ matrix.language }} diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml new file mode 100644 index 0000000..44694b7 --- /dev/null +++ b/.github/workflows/dependency-review.yml @@ -0,0 +1,15 @@ +name: Dependency Review + +on: + pull_request: + +permissions: + contents: read + pull-requests: write + +jobs: + dependency-review: + runs-on: ubuntu-latest + steps: + - name: Dependency Review + uses: actions/dependency-review-action@2031cfc080254a8a887f58cffee85186f0e49e48 # v4.9.0 diff --git a/realty-common/src/main/java/io/github/md5sha256/realty/database/RealtyLogicImpl.java b/realty-common/src/main/java/io/github/md5sha256/realty/database/RealtyLogicImpl.java index 24dc8bf..8d2ea5f 100644 --- a/realty-common/src/main/java/io/github/md5sha256/realty/database/RealtyLogicImpl.java +++ b/realty-common/src/main/java/io/github/md5sha256/realty/database/RealtyLogicImpl.java @@ -45,6 +45,10 @@ public class RealtyLogicImpl { + // Treat sub-cent floating-point residue as settled so partial payments + // cannot get stuck behind binary rounding noise. + private static final double PAYMENT_EPSILON = 0.01; + private final Database database; private final Function nameResolver; private final Function dateFormatter; @@ -63,22 +67,62 @@ public void setOfferPaymentDurationSeconds(long offerPaymentDurationSeconds) { this.offerPaymentDurationSeconds = offerPaymentDurationSeconds; } + // Freehold mutations follow the persisted title holder, while leasehold + // mutations follow the persisted landlord. WorldGuard owners are rewritten + // for presentation and can point at the current tenant instead. + private boolean isFreeholdTitleHolder(@NotNull FreeholdContractEntity freehold, + @Nullable UUID callerId) { + return callerId != null && callerId.equals(freehold.titleHolderId()); + } + + private boolean isLeaseholdLandlord(@NotNull LeaseholdContractEntity lease, + @Nullable UUID callerId) { + return callerId != null && callerId.equals(lease.landlordId()); + } + + private boolean isSanctionedAuctionActor(@NotNull SqlSessionWrapper wrapper, + @NotNull String worldGuardRegionId, + @NotNull UUID worldId, + @Nullable UUID callerId, + @NotNull FreeholdContractEntity freehold) { + return callerId != null + && (callerId.equals(freehold.authorityId()) + || callerId.equals(freehold.titleHolderId()) + || wrapper.freeholdContractSanctionedAuctioneerMapper() + .existsByRegionAndAuctioneer(worldGuardRegionId, worldId, callerId)); + } + // --- Sanctioned Auctioneers --- - public int removeSanctionedAuctioneer(@NotNull String worldGuardRegionId, - @NotNull UUID worldId, - @NotNull UUID auctioneerId, - @NotNull UUID actorId) { + public sealed interface RemoveSanctionedAuctioneerResult { + record Success() implements RemoveSanctionedAuctioneerResult {} + record NoFreeholdContract() implements RemoveSanctionedAuctioneerResult {} + record NotTitleHolder() implements RemoveSanctionedAuctioneerResult {} + record NotFound() implements RemoveSanctionedAuctioneerResult {} + } + + public @NotNull RemoveSanctionedAuctioneerResult removeSanctionedAuctioneer(@NotNull String worldGuardRegionId, + @NotNull UUID worldId, + @NotNull UUID auctioneerId, + @NotNull UUID actorId) { try (SqlSessionWrapper wrapper = database.openSession(); SqlSession session = wrapper.session()) { + FreeholdContractEntity freehold = wrapper.freeholdContractMapper().selectByRegion(worldGuardRegionId, worldId); + if (freehold == null) { + return new RemoveSanctionedAuctioneerResult.NoFreeholdContract(); + } + if (!isFreeholdTitleHolder(freehold, actorId)) { + return new RemoveSanctionedAuctioneerResult.NotTitleHolder(); + } int rows = wrapper.freeholdContractSanctionedAuctioneerMapper() .deleteByRegionAndAuctioneer(worldGuardRegionId, worldId, auctioneerId); - if (rows > 0) { - wrapper.agentHistoryMapper().insert(worldGuardRegionId, worldId, - HistoryEventType.AGENT_REMOVE.name(), auctioneerId, actorId); + if (rows == 0) { + return new RemoveSanctionedAuctioneerResult.NotFound(); } + wrapper.agentHistoryMapper().insert(worldGuardRegionId, worldId, + HistoryEventType.AGENT_REMOVE.name(), auctioneerId, actorId); session.commit(); - return rows; + return new RemoveSanctionedAuctioneerResult.Success(); } } @@ -165,20 +209,31 @@ record AlreadyAgent() implements AcceptAgentInviteResult {} public sealed interface WithdrawAgentInviteResult { record Success() implements WithdrawAgentInviteResult {} + record NoFreeholdContract() implements WithdrawAgentInviteResult {} + record NotTitleHolder() implements WithdrawAgentInviteResult {} record NotFound() implements WithdrawAgentInviteResult {} } public @NotNull WithdrawAgentInviteResult withdrawAgentInvite(@NotNull String worldGuardRegionId, - @NotNull UUID worldId, - @NotNull UUID inviteeId) { + @NotNull UUID worldId, + @NotNull UUID inviterId, + @NotNull UUID inviteeId) { try (SqlSessionWrapper wrapper = database.openSession(); SqlSession session = wrapper.session()) { + FreeholdContractEntity freehold = wrapper.freeholdContractMapper().selectByRegion(worldGuardRegionId, worldId); + if (freehold == null) { + return new WithdrawAgentInviteResult.NoFreeholdContract(); + } + if (!isFreeholdTitleHolder(freehold, inviterId)) { + return new WithdrawAgentInviteResult.NotTitleHolder(); + } int rows = wrapper.freeholdContractAgentInviteMapper() .deleteByRegionAndInvitee(worldGuardRegionId, worldId, inviteeId); + if (rows == 0) { + return new WithdrawAgentInviteResult.NotFound(); + } session.commit(); - return rows > 0 - ? new WithdrawAgentInviteResult.Success() - : new WithdrawAgentInviteResult.NotFound(); + return new WithdrawAgentInviteResult.Success(); } } @@ -239,14 +294,30 @@ record NoFreeholdContract() implements CreateAuctionResult {} } } - public record CancelAuctionResult(int deleted, @NotNull List bidderIds) {} + public sealed interface CancelAuctionResult { + record Success(int deleted, @NotNull List bidderIds) implements CancelAuctionResult {} + record NotSanctioned() implements CancelAuctionResult {} + record NoFreeholdContract() implements CancelAuctionResult {} + } - public @NotNull CancelAuctionResult cancelAuction(@NotNull String worldGuardRegionId, @NotNull UUID worldId) { + public @NotNull CancelAuctionResult cancelAuction(@NotNull String worldGuardRegionId, + @NotNull UUID worldId, + @NotNull UUID callerId) { try (SqlSessionWrapper wrapper = database.openSession()) { + FreeholdContractEntity freehold = wrapper.freeholdContractMapper().selectByRegion(worldGuardRegionId, worldId); + if (freehold == null) { + return new CancelAuctionResult.NoFreeholdContract(); + } + if (!callerId.equals(freehold.authorityId()) + && !callerId.equals(freehold.titleHolderId()) + && !wrapper.freeholdContractSanctionedAuctioneerMapper() + .existsByRegionAndAuctioneer(worldGuardRegionId, worldId, callerId)) { + return new CancelAuctionResult.NotSanctioned(); + } List bidderIds = wrapper.freeholdContractBidMapper().selectDistinctBidders(worldGuardRegionId, worldId); int deleted = wrapper.freeholdContractAuctionMapper().deleteActiveAuctionByRegion(worldGuardRegionId, worldId); wrapper.session().commit(); - return new CancelAuctionResult(deleted, bidderIds); + return new CancelAuctionResult.Success(deleted, bidderIds); } } @@ -316,6 +387,7 @@ record AlreadyHighestBidder() implements BidResult {} public sealed interface SetPriceResult { record Success() implements SetPriceResult {} + record NotAuthorized() implements SetPriceResult {} record NoFreeholdContract() implements SetPriceResult {} record AuctionExists() implements SetPriceResult {} record OfferPaymentInProgress() implements SetPriceResult {} @@ -324,14 +396,25 @@ record UpdateFailed() implements SetPriceResult {} } public @NotNull SetPriceResult setPrice(@NotNull String worldGuardRegionId, - @NotNull UUID worldId, - double price) { + @NotNull UUID worldId, + double price) { + return setPrice(worldGuardRegionId, worldId, price, null, true); + } + + public @NotNull SetPriceResult setPrice(@NotNull String worldGuardRegionId, + @NotNull UUID worldId, + double price, + @Nullable UUID callerId, + boolean bypassAuth) { try (SqlSessionWrapper wrapper = database.openSession()) { FreeholdContractMapper freeholdMapper = wrapper.freeholdContractMapper(); FreeholdContractEntity freehold = freeholdMapper.selectByRegion(worldGuardRegionId, worldId); if (freehold == null) { return new SetPriceResult.NoFreeholdContract(); } + if (!bypassAuth && !isFreeholdTitleHolder(freehold, callerId)) { + return new SetPriceResult.NotAuthorized(); + } if (wrapper.freeholdContractAuctionMapper().existsByRegion(worldGuardRegionId, worldId)) { return new SetPriceResult.AuctionExists(); } @@ -354,6 +437,7 @@ record UpdateFailed() implements SetPriceResult {} public sealed interface UnsetPriceResult { record Success() implements UnsetPriceResult {} + record NotAuthorized() implements UnsetPriceResult {} record NoFreeholdContract() implements UnsetPriceResult {} record OfferPaymentInProgress() implements UnsetPriceResult {} record BidPaymentInProgress() implements UnsetPriceResult {} @@ -361,13 +445,23 @@ record UpdateFailed() implements UnsetPriceResult {} } public @NotNull UnsetPriceResult unsetPrice(@NotNull String worldGuardRegionId, - @NotNull UUID worldId) { + @NotNull UUID worldId) { + return unsetPrice(worldGuardRegionId, worldId, null, true); + } + + public @NotNull UnsetPriceResult unsetPrice(@NotNull String worldGuardRegionId, + @NotNull UUID worldId, + @Nullable UUID callerId, + boolean bypassAuth) { try (SqlSessionWrapper wrapper = database.openSession()) { FreeholdContractMapper freeholdMapper = wrapper.freeholdContractMapper(); FreeholdContractEntity freehold = freeholdMapper.selectByRegion(worldGuardRegionId, worldId); if (freehold == null) { return new UnsetPriceResult.NoFreeholdContract(); } + if (!bypassAuth && !isFreeholdTitleHolder(freehold, callerId)) { + return new UnsetPriceResult.NotAuthorized(); + } if (wrapper.freeholdContractOfferPaymentMapper().existsByRegion(worldGuardRegionId, worldId)) { return new UnsetPriceResult.OfferPaymentInProgress(); } @@ -387,19 +481,31 @@ record UpdateFailed() implements UnsetPriceResult {} public sealed interface SetDurationResult { record Success() implements SetDurationResult {} + record NotAuthorized() implements SetDurationResult {} record NoLeaseholdContract() implements SetDurationResult {} record UpdateFailed() implements SetDurationResult {} } public @NotNull SetDurationResult setDuration(@NotNull String worldGuardRegionId, - @NotNull UUID worldId, - long durationSeconds) { + @NotNull UUID worldId, + long durationSeconds) { + return setDuration(worldGuardRegionId, worldId, durationSeconds, null, true); + } + + public @NotNull SetDurationResult setDuration(@NotNull String worldGuardRegionId, + @NotNull UUID worldId, + long durationSeconds, + @Nullable UUID callerId, + boolean bypassAuth) { try (SqlSessionWrapper wrapper = database.openSession()) { LeaseholdContractMapper leaseholdMapper = wrapper.leaseholdContractMapper(); LeaseholdContractEntity lease = leaseholdMapper.selectByRegion(worldGuardRegionId, worldId); if (lease == null) { return new SetDurationResult.NoLeaseholdContract(); } + if (!bypassAuth && !isLeaseholdLandlord(lease, callerId)) { + return new SetDurationResult.NotAuthorized(); + } int updated = leaseholdMapper.updateDurationByRegion(worldGuardRegionId, worldId, durationSeconds); if (updated == 0) { return new SetDurationResult.UpdateFailed(); @@ -413,20 +519,32 @@ record UpdateFailed() implements SetDurationResult {} public sealed interface SetMaxRenewalsResult { record Success() implements SetMaxRenewalsResult {} + record NotAuthorized() implements SetMaxRenewalsResult {} record NoLeaseholdContract() implements SetMaxRenewalsResult {} record BelowCurrentExtensions(int currentExtensions) implements SetMaxRenewalsResult {} record UpdateFailed() implements SetMaxRenewalsResult {} } public @NotNull SetMaxRenewalsResult setMaxRenewals(@NotNull String worldGuardRegionId, - @NotNull UUID worldId, - int maxRenewals) { + @NotNull UUID worldId, + int maxRenewals) { + return setMaxRenewals(worldGuardRegionId, worldId, maxRenewals, null, true); + } + + public @NotNull SetMaxRenewalsResult setMaxRenewals(@NotNull String worldGuardRegionId, + @NotNull UUID worldId, + int maxRenewals, + @Nullable UUID callerId, + boolean bypassAuth) { try (SqlSessionWrapper wrapper = database.openSession()) { LeaseholdContractMapper leaseholdMapper = wrapper.leaseholdContractMapper(); LeaseholdContractEntity lease = leaseholdMapper.selectByRegion(worldGuardRegionId, worldId); if (lease == null) { return new SetMaxRenewalsResult.NoLeaseholdContract(); } + if (!bypassAuth && !isLeaseholdLandlord(lease, callerId)) { + return new SetMaxRenewalsResult.NotAuthorized(); + } if (maxRenewals >= 0 && lease.tenantId() != null && lease.currentMaxExtensions() != null && maxRenewals < lease.currentMaxExtensions()) { @@ -445,19 +563,31 @@ record UpdateFailed() implements SetMaxRenewalsResult {} public sealed interface SetLandlordResult { record Success(@NotNull UUID previousLandlord) implements SetLandlordResult {} + record NotAuthorized() implements SetLandlordResult {} record NoLeaseholdContract() implements SetLandlordResult {} record UpdateFailed() implements SetLandlordResult {} } public @NotNull SetLandlordResult setLandlord(@NotNull String worldGuardRegionId, - @NotNull UUID worldId, - @NotNull UUID landlordId) { + @NotNull UUID worldId, + @NotNull UUID landlordId) { + return setLandlord(worldGuardRegionId, worldId, landlordId, null, true); + } + + public @NotNull SetLandlordResult setLandlord(@NotNull String worldGuardRegionId, + @NotNull UUID worldId, + @NotNull UUID landlordId, + @Nullable UUID callerId, + boolean bypassAuth) { try (SqlSessionWrapper wrapper = database.openSession()) { LeaseholdContractMapper leaseholdMapper = wrapper.leaseholdContractMapper(); LeaseholdContractEntity lease = leaseholdMapper.selectByRegion(worldGuardRegionId, worldId); if (lease == null) { return new SetLandlordResult.NoLeaseholdContract(); } + if (!bypassAuth && !isLeaseholdLandlord(lease, callerId)) { + return new SetLandlordResult.NotAuthorized(); + } UUID previousLandlord = lease.landlordId(); int updated = leaseholdMapper.updateLandlordByRegion(worldGuardRegionId, worldId, landlordId); if (updated == 0) { @@ -472,19 +602,31 @@ record UpdateFailed() implements SetLandlordResult {} public sealed interface SetTitleHolderResult { record Success(@Nullable UUID previousTitleHolder) implements SetTitleHolderResult {} + record NotAuthorized() implements SetTitleHolderResult {} record NoFreeholdContract() implements SetTitleHolderResult {} record UpdateFailed() implements SetTitleHolderResult {} } public @NotNull SetTitleHolderResult setTitleHolder(@NotNull String worldGuardRegionId, - @NotNull UUID worldId, - @Nullable UUID titleHolderId) { + @NotNull UUID worldId, + @Nullable UUID titleHolderId) { + return setTitleHolder(worldGuardRegionId, worldId, titleHolderId, null, true); + } + + public @NotNull SetTitleHolderResult setTitleHolder(@NotNull String worldGuardRegionId, + @NotNull UUID worldId, + @Nullable UUID titleHolderId, + @Nullable UUID callerId, + boolean bypassAuth) { try (SqlSessionWrapper wrapper = database.openSession()) { FreeholdContractMapper freeholdMapper = wrapper.freeholdContractMapper(); FreeholdContractEntity freehold = freeholdMapper.selectByRegion(worldGuardRegionId, worldId); if (freehold == null) { return new SetTitleHolderResult.NoFreeholdContract(); } + if (!bypassAuth && !isFreeholdTitleHolder(freehold, callerId)) { + return new SetTitleHolderResult.NotAuthorized(); + } UUID previousTitleHolder = freehold.titleHolderId(); int updated = freeholdMapper.updateTitleHolderByRegion(worldGuardRegionId, worldId, titleHolderId); if (updated == 0) { @@ -516,19 +658,31 @@ public void updateSubregionLandlords(@NotNull List childRegionIds, public sealed interface SetTenantResult { record Success(@Nullable UUID previousTenant, @NotNull UUID landlordId) implements SetTenantResult {} + record NotAuthorized() implements SetTenantResult {} record NoLeaseholdContract() implements SetTenantResult {} record UpdateFailed() implements SetTenantResult {} } public @NotNull SetTenantResult setTenant(@NotNull String worldGuardRegionId, - @NotNull UUID worldId, - @Nullable UUID tenantId) { + @NotNull UUID worldId, + @Nullable UUID tenantId) { + return setTenant(worldGuardRegionId, worldId, tenantId, null, true); + } + + public @NotNull SetTenantResult setTenant(@NotNull String worldGuardRegionId, + @NotNull UUID worldId, + @Nullable UUID tenantId, + @Nullable UUID callerId, + boolean bypassAuth) { try (SqlSessionWrapper wrapper = database.openSession()) { LeaseholdContractMapper leaseholdMapper = wrapper.leaseholdContractMapper(); LeaseholdContractEntity lease = leaseholdMapper.selectByRegion(worldGuardRegionId, worldId); if (lease == null) { return new SetTenantResult.NoLeaseholdContract(); } + if (!bypassAuth && !isLeaseholdLandlord(lease, callerId)) { + return new SetTenantResult.NotAuthorized(); + } UUID previousTenant = lease.tenantId(); int updated = leaseholdMapper.updateTenantByRegion(worldGuardRegionId, worldId, tenantId); if (updated == 0) { @@ -650,18 +804,28 @@ public boolean createLeasehold(@NotNull String worldGuardRegionId, public sealed interface RentResult { record Success(double price, long durationSeconds, @NotNull UUID landlordId) implements RentResult {} record NoLeaseholdContract() implements RentResult {} + record IsLandlord() implements RentResult {} record AlreadyOccupied() implements RentResult {} record UpdateFailed() implements RentResult {} } public @NotNull RentResult previewRent(@NotNull String worldGuardRegionId, @NotNull UUID worldId) { + return previewRent(worldGuardRegionId, worldId, null); + } + + public @NotNull RentResult previewRent(@NotNull String worldGuardRegionId, + @NotNull UUID worldId, + @Nullable UUID tenantId) { try (SqlSessionWrapper wrapper = database.openSession()) { LeaseholdContractMapper leaseholdMapper = wrapper.leaseholdContractMapper(); LeaseholdContractEntity lease = leaseholdMapper.selectByRegion(worldGuardRegionId, worldId); if (lease == null) { return new RentResult.NoLeaseholdContract(); } + if (tenantId != null && tenantId.equals(lease.landlordId())) { + return new RentResult.IsLandlord(); + } if (lease.tenantId() != null) { return new RentResult.AlreadyOccupied(); } @@ -678,6 +842,9 @@ record UpdateFailed() implements RentResult {} if (lease == null) { return new RentResult.NoLeaseholdContract(); } + if (tenantId.equals(lease.landlordId())) { + return new RentResult.IsLandlord(); + } if (lease.tenantId() != null) { return new RentResult.AlreadyOccupied(); } @@ -697,6 +864,7 @@ record UpdateFailed() implements RentResult {} public sealed interface UnrentResult { record Success(double refund, @NotNull UUID tenantId, @NotNull UUID landlordId) implements UnrentResult {} record NoLeaseholdContract() implements UnrentResult {} + record NotTenant() implements UnrentResult {} record UpdateFailed() implements UnrentResult {} } @@ -709,6 +877,9 @@ record UpdateFailed() implements UnrentResult {} if (lease == null) { return new UnrentResult.NoLeaseholdContract(); } + if (!tenantId.equals(lease.tenantId())) { + return new UnrentResult.NotTenant(); + } long totalSeconds = lease.durationSeconds(); long remainingSeconds = lease.endDate() == null ? 0 : Math.max(0, java.time.Duration.between(java.time.LocalDateTime.now(), lease.endDate()).getSeconds()); @@ -729,18 +900,28 @@ record UpdateFailed() implements UnrentResult {} public sealed interface RenewLeaseholdResult { record Success(double price, @NotNull UUID landlordId) implements RenewLeaseholdResult {} record NoLeaseholdContract() implements RenewLeaseholdResult {} + record NotTenant() implements RenewLeaseholdResult {} record NoExtensionsRemaining() implements RenewLeaseholdResult {} record UpdateFailed() implements RenewLeaseholdResult {} } public @NotNull RenewLeaseholdResult previewRenewLeasehold(@NotNull String worldGuardRegionId, - @NotNull UUID worldId) { + @NotNull UUID worldId) { + return previewRenewLeasehold(worldGuardRegionId, worldId, null); + } + + public @NotNull RenewLeaseholdResult previewRenewLeasehold(@NotNull String worldGuardRegionId, + @NotNull UUID worldId, + @Nullable UUID tenantId) { try (SqlSessionWrapper wrapper = database.openSession()) { LeaseholdContractMapper leaseholdMapper = wrapper.leaseholdContractMapper(); LeaseholdContractEntity lease = leaseholdMapper.selectByRegion(worldGuardRegionId, worldId); if (lease == null) { return new RenewLeaseholdResult.NoLeaseholdContract(); } + if (tenantId != null && !tenantId.equals(lease.tenantId())) { + return new RenewLeaseholdResult.NotTenant(); + } if (lease.maxExtensions() != null && lease.currentMaxExtensions() >= lease.maxExtensions()) { return new RenewLeaseholdResult.NoExtensionsRemaining(); } @@ -757,6 +938,9 @@ record UpdateFailed() implements RenewLeaseholdResult {} if (lease == null) { return new RenewLeaseholdResult.NoLeaseholdContract(); } + if (!tenantId.equals(lease.tenantId())) { + return new RenewLeaseholdResult.NotTenant(); + } if (lease.maxExtensions() != null && lease.currentMaxExtensions() >= lease.maxExtensions()) { return new RenewLeaseholdResult.NoExtensionsRemaining(); } @@ -1312,15 +1496,19 @@ record ExceedsAmountOwed(double amountOwed) implements PayOfferResult {} return new PayOfferResult.NoPaymentRecord(); } double amountOwed = payment.offerPrice() - payment.currentPayment(); - if (amount > amountOwed) { + double newTotal = payment.currentPayment() + amount; + double remaining = payment.offerPrice() - newTotal; + if (remaining < -PAYMENT_EPSILON) { return new PayOfferResult.ExceedsAmountOwed(amountOwed); } - double newTotal = payment.currentPayment() + amount; FreeholdContractMapper freeholdMapper = wrapper.freeholdContractMapper(); FreeholdContractEntity freehold = freeholdMapper.selectByRegion(worldGuardRegionId, worldId); UUID authorityId = freehold.authorityId(); UUID titleHolderId = freehold.titleHolderId(); - if (newTotal == payment.offerPrice()) { + // A near-zero remainder here is floating-point residue from prior + // partial payments, so finish the transfer instead of leaving a + // dust balance that can never be paid exactly. + if (Math.abs(remaining) < PAYMENT_EPSILON) { // Fully paid — transfer ownership, reset price (not for freehold) freeholdMapper.updateFreeholdByRegion(worldGuardRegionId, worldId, payment.offerPrice(), offererId); freeholdMapper.updatePriceByRegion(worldGuardRegionId, worldId, null); @@ -1335,7 +1523,7 @@ record ExceedsAmountOwed(double amountOwed) implements PayOfferResult {} paymentMapper.updatePayment(worldGuardRegionId, worldId, offererId, newTotal); } wrapper.session().commit(); - return new PayOfferResult.Success(newTotal, payment.offerPrice() - newTotal, authorityId, titleHolderId); + return new PayOfferResult.Success(newTotal, remaining, authorityId, titleHolderId); } } @@ -1364,15 +1552,19 @@ record ExceedsAmountOwed(double amountOwed) implements PayBidResult {} return new PayBidResult.PaymentExpired(); } double amountOwed = payment.bidPrice() - payment.currentPayment(); - if (amount > amountOwed) { + double newTotal = payment.currentPayment() + amount; + double remaining = payment.bidPrice() - newTotal; + if (remaining < -PAYMENT_EPSILON) { return new PayBidResult.ExceedsAmountOwed(amountOwed); } - double newTotal = payment.currentPayment() + amount; FreeholdContractMapper freeholdMapper = wrapper.freeholdContractMapper(); FreeholdContractEntity freehold = freeholdMapper.selectByRegion(worldGuardRegionId, worldId); UUID authorityId = freehold.authorityId(); UUID titleHolderId = freehold.titleHolderId(); - if (newTotal == payment.bidPrice()) { + // A near-zero remainder here is floating-point residue from prior + // partial payments, so finish the transfer instead of leaving a + // dust balance that can never be paid exactly. + if (Math.abs(remaining) < PAYMENT_EPSILON) { // Fully paid — transfer ownership, reset price (not for freehold) freeholdMapper.updateFreeholdByRegion(worldGuardRegionId, worldId, payment.bidPrice(), bidderId); freeholdMapper.updatePriceByRegion(worldGuardRegionId, worldId, null); @@ -1385,7 +1577,7 @@ record ExceedsAmountOwed(double amountOwed) implements PayBidResult {} } paymentMapper.updatePayment(worldGuardRegionId, worldId, bidderId, newTotal); wrapper.session().commit(); - return new PayBidResult.Success(newTotal, payment.bidPrice() - newTotal, authorityId, titleHolderId); + return new PayBidResult.Success(newTotal, remaining, authorityId, titleHolderId); } } diff --git a/realty-common/src/test/java/io/github/md5sha256/realty/database/AbstractDatabaseTest.java b/realty-common/src/test/java/io/github/md5sha256/realty/database/AbstractDatabaseTest.java index f995f43..931f26c 100644 --- a/realty-common/src/test/java/io/github/md5sha256/realty/database/AbstractDatabaseTest.java +++ b/realty-common/src/test/java/io/github/md5sha256/realty/database/AbstractDatabaseTest.java @@ -64,7 +64,7 @@ void truncateTables() throws SQLException { SET FOREIGN_KEY_CHECKS = 0; TRUNCATE TABLE AgentHistory; TRUNCATE TABLE FreeholdHistory; - TRUNCATE TABLE LeaseHistory; + TRUNCATE TABLE LeaseholdHistory; TRUNCATE TABLE FreeholdContractAgentInvite; TRUNCATE TABLE FreeholdContractBidPayment; TRUNCATE TABLE FreeholdContractBid; @@ -72,7 +72,7 @@ void truncateTables() throws SQLException { TRUNCATE TABLE FreeholdContractOffer; TRUNCATE TABLE FreeholdContractSanctionedAuctioneers; TRUNCATE TABLE FreeholdContractAuction; - TRUNCATE TABLE LeaseContract; + TRUNCATE TABLE LeaseholdContract; TRUNCATE TABLE FreeholdContract; TRUNCATE TABLE Contract; TRUNCATE TABLE RealtyRegion; diff --git a/realty-common/src/test/java/io/github/md5sha256/realty/database/AgentLogicTest.java b/realty-common/src/test/java/io/github/md5sha256/realty/database/AgentLogicTest.java index cf9e2e6..17d3ba9 100644 --- a/realty-common/src/test/java/io/github/md5sha256/realty/database/AgentLogicTest.java +++ b/realty-common/src/test/java/io/github/md5sha256/realty/database/AgentLogicTest.java @@ -2,6 +2,7 @@ import io.github.md5sha256.realty.database.RealtyLogicImpl.AcceptAgentInviteResult; import io.github.md5sha256.realty.database.RealtyLogicImpl.InviteAgentResult; +import io.github.md5sha256.realty.database.RealtyLogicImpl.RemoveSanctionedAuctioneerResult; import io.github.md5sha256.realty.database.RealtyLogicImpl.RejectAgentInviteResult; import io.github.md5sha256.realty.database.RealtyLogicImpl.WithdrawAgentInviteResult; import org.apache.ibatis.session.SqlSession; @@ -202,7 +203,7 @@ void succeeds() { createFreeholdRegion(regionId); logic.inviteAgent(regionId, WORLD_ID, TITLE_HOLDER, PLAYER_A); - WithdrawAgentInviteResult result = logic.withdrawAgentInvite(regionId, WORLD_ID, PLAYER_A); + WithdrawAgentInviteResult result = logic.withdrawAgentInvite(regionId, WORLD_ID, TITLE_HOLDER, PLAYER_A); Assertions.assertInstanceOf(WithdrawAgentInviteResult.Success.class, result); } @@ -212,21 +213,42 @@ void notFound() { String regionId = uniqueRegionId(); createFreeholdRegion(regionId); - WithdrawAgentInviteResult result = logic.withdrawAgentInvite(regionId, WORLD_ID, PLAYER_A); + WithdrawAgentInviteResult result = logic.withdrawAgentInvite(regionId, WORLD_ID, TITLE_HOLDER, PLAYER_A); Assertions.assertInstanceOf(WithdrawAgentInviteResult.NotFound.class, result); } + @Test + @DisplayName("returns NoFreeholdContract when region has no freehold") + void noFreeholdContract() { + WithdrawAgentInviteResult result = logic.withdrawAgentInvite("missing", WORLD_ID, TITLE_HOLDER, PLAYER_A); + Assertions.assertInstanceOf(WithdrawAgentInviteResult.NoFreeholdContract.class, result); + } + @Test @DisplayName("withdrawing prevents future acceptance") void preventsAcceptance() { String regionId = uniqueRegionId(); createFreeholdRegion(regionId); logic.inviteAgent(regionId, WORLD_ID, TITLE_HOLDER, PLAYER_A); - logic.withdrawAgentInvite(regionId, WORLD_ID, PLAYER_A); + logic.withdrawAgentInvite(regionId, WORLD_ID, TITLE_HOLDER, PLAYER_A); AcceptAgentInviteResult result = logic.acceptAgentInvite(regionId, WORLD_ID, PLAYER_A); Assertions.assertInstanceOf(AcceptAgentInviteResult.NotFound.class, result); } + + @Test + @DisplayName("returns NotTitleHolder when caller is not the title holder") + void notTitleHolder() { + String regionId = uniqueRegionId(); + createFreeholdRegion(regionId); + logic.inviteAgent(regionId, WORLD_ID, TITLE_HOLDER, PLAYER_A); + + WithdrawAgentInviteResult result = logic.withdrawAgentInvite(regionId, WORLD_ID, PLAYER_B, PLAYER_A); + Assertions.assertInstanceOf(WithdrawAgentInviteResult.NotTitleHolder.class, result); + + AcceptAgentInviteResult stillPending = logic.acceptAgentInvite(regionId, WORLD_ID, PLAYER_A); + Assertions.assertInstanceOf(AcceptAgentInviteResult.Success.class, stillPending); + } } // --- Reject Agent Invite --- @@ -284,18 +306,26 @@ void succeeds() { createFreeholdRegion(regionId); inviteAndAcceptAgent(regionId, PLAYER_A); - int rows = logic.removeSanctionedAuctioneer(regionId, WORLD_ID, PLAYER_A, TITLE_HOLDER); - Assertions.assertEquals(1, rows); + RemoveSanctionedAuctioneerResult result = logic.removeSanctionedAuctioneer(regionId, WORLD_ID, PLAYER_A, TITLE_HOLDER); + Assertions.assertInstanceOf(RemoveSanctionedAuctioneerResult.Success.class, result); } @Test - @DisplayName("returns 0 when agent does not exist") + @DisplayName("returns NotFound when agent does not exist") void notFound() { String regionId = uniqueRegionId(); createFreeholdRegion(regionId); - int rows = logic.removeSanctionedAuctioneer(regionId, WORLD_ID, PLAYER_A, TITLE_HOLDER); - Assertions.assertEquals(0, rows); + RemoveSanctionedAuctioneerResult result = logic.removeSanctionedAuctioneer(regionId, WORLD_ID, PLAYER_A, TITLE_HOLDER); + Assertions.assertInstanceOf(RemoveSanctionedAuctioneerResult.NotFound.class, result); + } + + @Test + @DisplayName("returns NoFreeholdContract when region has no freehold") + void noFreeholdContract() { + RemoveSanctionedAuctioneerResult result = logic.removeSanctionedAuctioneer( + "missing", WORLD_ID, PLAYER_A, TITLE_HOLDER); + Assertions.assertInstanceOf(RemoveSanctionedAuctioneerResult.NoFreeholdContract.class, result); } @Test @@ -305,23 +335,38 @@ void canReInviteAfterRemoval() { createFreeholdRegion(regionId); inviteAndAcceptAgent(regionId, PLAYER_A); - logic.removeSanctionedAuctioneer(regionId, WORLD_ID, PLAYER_A, TITLE_HOLDER); + RemoveSanctionedAuctioneerResult removal = logic.removeSanctionedAuctioneer(regionId, WORLD_ID, PLAYER_A, TITLE_HOLDER); + Assertions.assertInstanceOf(RemoveSanctionedAuctioneerResult.Success.class, removal); InviteAgentResult result = logic.inviteAgent(regionId, WORLD_ID, TITLE_HOLDER, PLAYER_A); Assertions.assertInstanceOf(InviteAgentResult.Success.class, result); } @Test - @DisplayName("removing is idempotent - second removal returns 0") + @DisplayName("removing is idempotent - second removal returns NotFound") void doubleRemoval() { String regionId = uniqueRegionId(); createFreeholdRegion(regionId); inviteAndAcceptAgent(regionId, PLAYER_A); - int first = logic.removeSanctionedAuctioneer(regionId, WORLD_ID, PLAYER_A, TITLE_HOLDER); - int second = logic.removeSanctionedAuctioneer(regionId, WORLD_ID, PLAYER_A, TITLE_HOLDER); - Assertions.assertEquals(1, first); - Assertions.assertEquals(0, second); + RemoveSanctionedAuctioneerResult first = logic.removeSanctionedAuctioneer(regionId, WORLD_ID, PLAYER_A, TITLE_HOLDER); + RemoveSanctionedAuctioneerResult second = logic.removeSanctionedAuctioneer(regionId, WORLD_ID, PLAYER_A, TITLE_HOLDER); + Assertions.assertInstanceOf(RemoveSanctionedAuctioneerResult.Success.class, first); + Assertions.assertInstanceOf(RemoveSanctionedAuctioneerResult.NotFound.class, second); + } + + @Test + @DisplayName("returns NotTitleHolder when caller is not the title holder") + void notTitleHolder() { + String regionId = uniqueRegionId(); + createFreeholdRegion(regionId); + inviteAndAcceptAgent(regionId, PLAYER_A); + + RemoveSanctionedAuctioneerResult result = logic.removeSanctionedAuctioneer(regionId, WORLD_ID, PLAYER_A, PLAYER_B); + Assertions.assertInstanceOf(RemoveSanctionedAuctioneerResult.NotTitleHolder.class, result); + + InviteAgentResult stillAgent = logic.inviteAgent(regionId, WORLD_ID, TITLE_HOLDER, PLAYER_A); + Assertions.assertInstanceOf(InviteAgentResult.AlreadyAgent.class, stillAgent); } } @@ -350,8 +395,8 @@ void fullCycle() { Assertions.assertInstanceOf(InviteAgentResult.AlreadyAgent.class, duplicate); // Remove - int rows = logic.removeSanctionedAuctioneer(regionId, WORLD_ID, PLAYER_A, TITLE_HOLDER); - Assertions.assertEquals(1, rows); + RemoveSanctionedAuctioneerResult removal = logic.removeSanctionedAuctioneer(regionId, WORLD_ID, PLAYER_A, TITLE_HOLDER); + Assertions.assertInstanceOf(RemoveSanctionedAuctioneerResult.Success.class, removal); // Can invite again after removal InviteAgentResult reInvite = logic.inviteAgent(regionId, WORLD_ID, TITLE_HOLDER, PLAYER_A); @@ -368,10 +413,10 @@ void multipleAgents() { inviteAndAcceptAgent(regionId, PLAYER_B); // Both should be removable independently - int rowsA = logic.removeSanctionedAuctioneer(regionId, WORLD_ID, PLAYER_A, TITLE_HOLDER); - int rowsB = logic.removeSanctionedAuctioneer(regionId, WORLD_ID, PLAYER_B, TITLE_HOLDER); - Assertions.assertEquals(1, rowsA); - Assertions.assertEquals(1, rowsB); + RemoveSanctionedAuctioneerResult rowsA = logic.removeSanctionedAuctioneer(regionId, WORLD_ID, PLAYER_A, TITLE_HOLDER); + RemoveSanctionedAuctioneerResult rowsB = logic.removeSanctionedAuctioneer(regionId, WORLD_ID, PLAYER_B, TITLE_HOLDER); + Assertions.assertInstanceOf(RemoveSanctionedAuctioneerResult.Success.class, rowsA); + Assertions.assertInstanceOf(RemoveSanctionedAuctioneerResult.Success.class, rowsB); } } } diff --git a/realty-common/src/test/java/io/github/md5sha256/realty/database/RealtyLogicImplTest.java b/realty-common/src/test/java/io/github/md5sha256/realty/database/RealtyLogicImplTest.java index d9cc5b4..d5db53e 100644 --- a/realty-common/src/test/java/io/github/md5sha256/realty/database/RealtyLogicImplTest.java +++ b/realty-common/src/test/java/io/github/md5sha256/realty/database/RealtyLogicImplTest.java @@ -167,6 +167,167 @@ void succeedsWithNullTenant() { } } + // --- Setter Authorization --- + + @Nested + @DisplayName("setter authorization") + class SetterAuthorization { + + @Test + @DisplayName("non-title-holder cannot set freehold price") + void nonTitleHolderCannotSetPrice() { + String regionId = uniqueRegionId(); + createFreeholdRegion(regionId, WORLD_ID, AUTHORITY, PLAYER_A); + + RealtyLogicImpl.SetPriceResult result = logic.setPrice(regionId, WORLD_ID, 900.0, PLAYER_B, false); + + Assertions.assertInstanceOf(RealtyLogicImpl.SetPriceResult.NotAuthorized.class, result); + Assertions.assertEquals(1000.0, logic.getFreeholdContract(regionId, WORLD_ID).price()); + } + + @Test + @DisplayName("non-title-holder cannot transfer freehold ownership") + void nonTitleHolderCannotSetTitleHolder() { + String regionId = uniqueRegionId(); + createFreeholdRegion(regionId, WORLD_ID, AUTHORITY, PLAYER_A); + + RealtyLogicImpl.SetTitleHolderResult result = logic.setTitleHolder( + regionId, WORLD_ID, PLAYER_B, PLAYER_C, false); + + Assertions.assertInstanceOf(RealtyLogicImpl.SetTitleHolderResult.NotAuthorized.class, result); + Assertions.assertEquals(PLAYER_A, logic.getFreeholdContract(regionId, WORLD_ID).titleHolderId()); + } + + @Test + @DisplayName("tenant cannot change lease duration") + void tenantCannotSetDuration() { + String regionId = uniqueRegionId(); + Assertions.assertTrue(logic.createLeasehold(regionId, WORLD_ID, 200.0, 86400, 5, PLAYER_A)); + Assertions.assertInstanceOf(RealtyLogicImpl.RentResult.Success.class, + logic.rentRegion(regionId, WORLD_ID, PLAYER_B)); + + RealtyLogicImpl.SetDurationResult result = logic.setDuration( + regionId, WORLD_ID, 172800, PLAYER_B, false); + + Assertions.assertInstanceOf(RealtyLogicImpl.SetDurationResult.NotAuthorized.class, result); + Assertions.assertEquals(86400, logic.getLeaseholdContract(regionId, WORLD_ID).durationSeconds()); + } + + @Test + @DisplayName("tenant cannot reassign lease tenant") + void tenantCannotSetTenant() { + String regionId = uniqueRegionId(); + Assertions.assertTrue(logic.createLeasehold(regionId, WORLD_ID, 200.0, 86400, 5, PLAYER_A)); + Assertions.assertInstanceOf(RealtyLogicImpl.RentResult.Success.class, + logic.rentRegion(regionId, WORLD_ID, PLAYER_B)); + + RealtyLogicImpl.SetTenantResult result = logic.setTenant( + regionId, WORLD_ID, PLAYER_C, PLAYER_B, false); + + Assertions.assertInstanceOf(RealtyLogicImpl.SetTenantResult.NotAuthorized.class, result); + Assertions.assertEquals(PLAYER_B, logic.getLeaseholdContract(regionId, WORLD_ID).tenantId()); + } + + @Test + @DisplayName("landlord can reassign lease tenant through shared logic") + void landlordCanSetTenant() { + String regionId = uniqueRegionId(); + Assertions.assertTrue(logic.createLeasehold(regionId, WORLD_ID, 200.0, 86400, 5, PLAYER_A)); + Assertions.assertInstanceOf(RealtyLogicImpl.RentResult.Success.class, + logic.rentRegion(regionId, WORLD_ID, PLAYER_B)); + + RealtyLogicImpl.SetTenantResult result = logic.setTenant( + regionId, WORLD_ID, PLAYER_C, PLAYER_A, false); + + Assertions.assertInstanceOf(RealtyLogicImpl.SetTenantResult.Success.class, result); + Assertions.assertEquals(PLAYER_C, logic.getLeaseholdContract(regionId, WORLD_ID).tenantId()); + } + } + + // --- Rent --- + + @Nested + @DisplayName("rentRegion") + class RentRegion { + + @Test + @DisplayName("previewRent returns IsLandlord for the landlord") + void previewRentIsLandlord() { + String regionId = uniqueRegionId(); + Assertions.assertTrue(logic.createLeasehold(regionId, WORLD_ID, 200.0, 86400, 5, PLAYER_A)); + + RealtyLogicImpl.RentResult result = logic.previewRent(regionId, WORLD_ID, PLAYER_A); + + Assertions.assertInstanceOf(RealtyLogicImpl.RentResult.IsLandlord.class, result); + } + + @Test + @DisplayName("rentRegion returns IsLandlord for the landlord") + void rentRegionIsLandlord() { + String regionId = uniqueRegionId(); + Assertions.assertTrue(logic.createLeasehold(regionId, WORLD_ID, 200.0, 86400, 5, PLAYER_A)); + + RealtyLogicImpl.RentResult result = logic.rentRegion(regionId, WORLD_ID, PLAYER_A); + + Assertions.assertInstanceOf(RealtyLogicImpl.RentResult.IsLandlord.class, result); + Assertions.assertNull(logic.getLeaseholdContract(regionId, WORLD_ID).tenantId()); + } + } + + // --- Unrent --- + + @Nested + @DisplayName("unrentRegion") + class UnrentRegion { + + @Test + @DisplayName("returns NotTenant when another player tries to unrent") + void notTenant() { + String regionId = uniqueRegionId(); + Assertions.assertTrue(logic.createLeasehold(regionId, WORLD_ID, 200.0, 86400, 5, PLAYER_A)); + Assertions.assertInstanceOf(RealtyLogicImpl.RentResult.Success.class, + logic.rentRegion(regionId, WORLD_ID, PLAYER_B)); + + RealtyLogicImpl.UnrentResult result = logic.unrentRegion(regionId, WORLD_ID, PLAYER_C); + + Assertions.assertInstanceOf(RealtyLogicImpl.UnrentResult.NotTenant.class, result); + Assertions.assertEquals(PLAYER_B, logic.getLeaseholdContract(regionId, WORLD_ID).tenantId()); + } + } + + // --- Renew Leasehold --- + + @Nested + @DisplayName("renewLeasehold") + class RenewLeasehold { + + @Test + @DisplayName("previewRenewLeasehold returns NotTenant for another player") + void previewNotTenant() { + String regionId = uniqueRegionId(); + Assertions.assertTrue(logic.createLeasehold(regionId, WORLD_ID, 200.0, 86400, 5, PLAYER_A)); + Assertions.assertInstanceOf(RealtyLogicImpl.RentResult.Success.class, + logic.rentRegion(regionId, WORLD_ID, PLAYER_B)); + + RealtyLogicImpl.RenewLeaseholdResult result = logic.previewRenewLeasehold(regionId, WORLD_ID, PLAYER_C); + + Assertions.assertInstanceOf(RealtyLogicImpl.RenewLeaseholdResult.NotTenant.class, result); + } + + @Test + @DisplayName("renewLeasehold returns NotTenant for another player") + void renewNotTenant() { + String regionId = uniqueRegionId(); + Assertions.assertTrue(logic.createLeasehold(regionId, WORLD_ID, 200.0, 86400, 5, PLAYER_A)); + Assertions.assertInstanceOf(RealtyLogicImpl.RentResult.Success.class, + logic.rentRegion(regionId, WORLD_ID, PLAYER_B)); + + RealtyLogicImpl.RenewLeaseholdResult result = logic.renewLeasehold(regionId, WORLD_ID, PLAYER_C); + + Assertions.assertInstanceOf(RealtyLogicImpl.RenewLeaseholdResult.NotTenant.class, result); + } + } + // --- DeleteRegion --- @Nested @@ -376,27 +537,44 @@ void createSucceeds() { } @Test - @DisplayName("cancelAuction returns 1 when active auction exists") + @DisplayName("cancelAuction returns Success when caller is sanctioned") void cancelExisting() { String regionId = uniqueRegionId(); createFreeholdRegion(regionId, WORLD_ID, AUTHORITY, PLAYER_A); createAuctionOnRegion(regionId, WORLD_ID); - RealtyLogicImpl.CancelAuctionResult result = logic.cancelAuction(regionId, WORLD_ID); - Assertions.assertEquals(1, result.deleted()); + RealtyLogicImpl.CancelAuctionResult result = logic.cancelAuction(regionId, WORLD_ID, AUTHORITY); + Assertions.assertInstanceOf(RealtyLogicImpl.CancelAuctionResult.Success.class, result); + Assertions.assertEquals(1, ((RealtyLogicImpl.CancelAuctionResult.Success) result).deleted()); RegionInfo info = logic.getRegionInfo(regionId, WORLD_ID); Assertions.assertNull(info.auction()); } @Test - @DisplayName("cancelAuction returns 0 when no auction exists") + @DisplayName("cancelAuction returns Success with 0 deleted when no auction exists") void cancelNonExistent() { String regionId = uniqueRegionId(); createFreeholdRegion(regionId, WORLD_ID, AUTHORITY, PLAYER_A); - RealtyLogicImpl.CancelAuctionResult result = logic.cancelAuction(regionId, WORLD_ID); - Assertions.assertEquals(0, result.deleted()); + RealtyLogicImpl.CancelAuctionResult result = logic.cancelAuction(regionId, WORLD_ID, AUTHORITY); + Assertions.assertInstanceOf(RealtyLogicImpl.CancelAuctionResult.Success.class, result); + Assertions.assertEquals(0, ((RealtyLogicImpl.CancelAuctionResult.Success) result).deleted()); + } + + @Test + @DisplayName("cancelAuction returns NotSanctioned when caller is not authorized") + void cancelNotSanctioned() { + String regionId = uniqueRegionId(); + createFreeholdRegion(regionId, WORLD_ID, AUTHORITY, PLAYER_A); + createAuctionOnRegion(regionId, WORLD_ID); + + RealtyLogicImpl.CancelAuctionResult result = logic.cancelAuction(regionId, WORLD_ID, PLAYER_B); + Assertions.assertInstanceOf(RealtyLogicImpl.CancelAuctionResult.NotSanctioned.class, result); + + // Auction should still exist + RegionInfo info = logic.getRegionInfo(regionId, WORLD_ID); + Assertions.assertNotNull(info.auction()); } @Test @@ -678,6 +856,47 @@ void nonSanctionedCannotAccept() { } } + // --- Pay Bid --- + + @Nested + @DisplayName("payBid") + class PayBid { + + @Test + @DisplayName("returns FullyPaid after floating point partial payments") + void fullyPaidAfterFloatingPointPartials() { + String regionId = uniqueRegionId(); + createFreeholdRegion(regionId, WORLD_ID, AUTHORITY, PLAYER_A); + CreateAuctionResult auction = logic.createAuction(regionId, WORLD_ID, AUTHORITY, 3600, 3600, 0.1, 0.1); + Assertions.assertInstanceOf(CreateAuctionResult.Success.class, auction); + Assertions.assertInstanceOf(BidResult.Success.class, logic.performBid(regionId, WORLD_ID, PLAYER_B, 0.3)); + insertBidPaymentWithDeadline(regionId, WORLD_ID, PLAYER_B, LocalDateTime.now().plusDays(1)); + + PayBidResult first = logic.payBid(regionId, WORLD_ID, PLAYER_B, 0.1); + PayBidResult second = logic.payBid(regionId, WORLD_ID, PLAYER_B, 0.2); + + Assertions.assertInstanceOf(PayBidResult.Success.class, first); + Assertions.assertInstanceOf(PayBidResult.FullyPaid.class, second); + } + + @Test + @DisplayName("returns ExceedsAmountOwed for meaningful overpayment") + void exceedsAmountOwed() { + String regionId = uniqueRegionId(); + createFreeholdRegion(regionId, WORLD_ID, AUTHORITY, PLAYER_A); + CreateAuctionResult auction = logic.createAuction(regionId, WORLD_ID, AUTHORITY, 3600, 3600, 0.1, 0.1); + Assertions.assertInstanceOf(CreateAuctionResult.Success.class, auction); + Assertions.assertInstanceOf(BidResult.Success.class, logic.performBid(regionId, WORLD_ID, PLAYER_B, 0.3)); + insertBidPaymentWithDeadline(regionId, WORLD_ID, PLAYER_B, LocalDateTime.now().plusHours(1)); + Assertions.assertInstanceOf(PayBidResult.Success.class, logic.payBid(regionId, WORLD_ID, PLAYER_B, 0.1)); + + PayBidResult result = logic.payBid(regionId, WORLD_ID, PLAYER_B, 0.25); + + Assertions.assertInstanceOf(PayBidResult.ExceedsAmountOwed.class, result); + Assertions.assertEquals(0.2, ((PayBidResult.ExceedsAmountOwed) result).amountOwed(), 0.000001); + } + } + // --- Pay Offer --- @Nested @@ -771,6 +990,34 @@ void fullyPaidAfterPartials() { Assertions.assertInstanceOf(PayOfferResult.FullyPaid.class, second); } + @Test + @DisplayName("returns FullyPaid after floating point partial payments") + void fullyPaidAfterFloatingPointPartials() { + String regionId = uniqueRegionId(); + createFreeholdRegion(regionId, WORLD_ID, AUTHORITY, PLAYER_A); + placeAndAcceptOffer(regionId, WORLD_ID, PLAYER_B, 0.3); + + PayOfferResult first = logic.payOffer(regionId, WORLD_ID, PLAYER_B, 0.1); + PayOfferResult second = logic.payOffer(regionId, WORLD_ID, PLAYER_B, 0.2); + + Assertions.assertInstanceOf(PayOfferResult.Success.class, first); + Assertions.assertInstanceOf(PayOfferResult.FullyPaid.class, second); + } + + @Test + @DisplayName("returns ExceedsAmountOwed for meaningful overpayment after partial payment") + void exceedsAfterFloatingPointPartialPayment() { + String regionId = uniqueRegionId(); + createFreeholdRegion(regionId, WORLD_ID, AUTHORITY, PLAYER_A); + placeAndAcceptOffer(regionId, WORLD_ID, PLAYER_B, 0.3); + Assertions.assertInstanceOf(PayOfferResult.Success.class, logic.payOffer(regionId, WORLD_ID, PLAYER_B, 0.1)); + + PayOfferResult result = logic.payOffer(regionId, WORLD_ID, PLAYER_B, 0.25); + + Assertions.assertInstanceOf(PayOfferResult.ExceedsAmountOwed.class, result); + Assertions.assertEquals(0.2, ((PayOfferResult.ExceedsAmountOwed) result).amountOwed(), 0.000001); + } + @Test @DisplayName("full payment transfers title holder to offerer") void transfersTitleHolder() { @@ -832,10 +1079,16 @@ void noExpired() { createFreeholdRegion(regionId, WORLD_ID, AUTHORITY, PLAYER_A); createAuctionOnRegion(regionId, WORLD_ID); logic.performBid(regionId, WORLD_ID, PLAYER_B, 200.0); - insertBidPaymentWithDeadline(regionId, WORLD_ID, PLAYER_B, LocalDateTime.now().plusHours(1)); + insertBidPaymentWithDeadline(regionId, WORLD_ID, PLAYER_B, LocalDateTime.now().plusDays(1)); List refunds = logic.clearExpiredBidPayments(); - Assertions.assertTrue(refunds.isEmpty()); + Assertions.assertTrue(refunds.stream().noneMatch(refund -> regionId.equals(refund.regionId()))); + try (SqlSessionWrapper wrapper = database.openSession()) { + FreeholdContractBidPaymentEntity payment = wrapper.freeholdContractBidPaymentMapper() + .selectByRegion(regionId, WORLD_ID); + Assertions.assertNotNull(payment); + Assertions.assertEquals(PLAYER_B, payment.bidderId()); + } } @Test @@ -978,7 +1231,7 @@ void noExpired() { String regionId = uniqueRegionId(); createFreeholdRegion(regionId, WORLD_ID, AUTHORITY, PLAYER_A); logic.placeOffer(regionId, WORLD_ID, PLAYER_B, 500.0); - insertOfferPaymentWithDeadline(regionId, WORLD_ID, PLAYER_B, LocalDateTime.now().plusHours(1)); + insertOfferPaymentWithDeadline(regionId, WORLD_ID, PLAYER_B, LocalDateTime.now().plusDays(1)); List refunds = logic.clearExpiredOfferPayments(); Assertions.assertTrue(refunds.isEmpty()); diff --git a/realty-paper/src/main/java/io/github/md5sha256/realty/api/RegionProfileService.java b/realty-paper/src/main/java/io/github/md5sha256/realty/api/RegionProfileService.java index ec432d4..22597e6 100644 --- a/realty-paper/src/main/java/io/github/md5sha256/realty/api/RegionProfileService.java +++ b/realty-paper/src/main/java/io/github/md5sha256/realty/api/RegionProfileService.java @@ -208,13 +208,19 @@ public void clearGroupedSignProfiles() { } private @NotNull List resolveCommands(@Nullable List commands, - @NotNull Map placeholders) { + @NotNull Map placeholders) { if (commands == null || commands.isEmpty()) { return List.of(); } List resolved = new ArrayList<>(commands.size()); for (String command : commands) { - resolved.add(replacePlaceholders(command, placeholders)); + // Validate after placeholder expansion so config values cannot + // smuggle blank, multiline, or slash-prefixed commands through. + String sanitized = SignCommandSanitizer.sanitize( + replacePlaceholders(command, placeholders), this.logger); + if (sanitized != null) { + resolved.add(sanitized); + } } return resolved; } diff --git a/realty-paper/src/main/java/io/github/md5sha256/realty/api/SignCommandSanitizer.java b/realty-paper/src/main/java/io/github/md5sha256/realty/api/SignCommandSanitizer.java new file mode 100644 index 0000000..f6f5765 --- /dev/null +++ b/realty-paper/src/main/java/io/github/md5sha256/realty/api/SignCommandSanitizer.java @@ -0,0 +1,41 @@ +package io.github.md5sha256.realty.api; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.logging.Logger; +import java.util.regex.Pattern; + +/** + * Validates sign-triggered commands after placeholder expansion so a profile + * can only dispatch a single well-formed plugin command. + */ +final class SignCommandSanitizer { + + private static final Pattern COMMAND_LABEL_PATTERN = Pattern.compile("[A-Za-z0-9:_-]+"); + + private SignCommandSanitizer() { + } + + static @Nullable String sanitize(@NotNull String command, @NotNull Logger logger) { + String trimmed = command.trim(); + if (trimmed.isEmpty()) { + logger.warning("Skipping blank sign command"); + return null; + } + if (trimmed.indexOf('\n') >= 0 || trimmed.indexOf('\r') >= 0) { + logger.warning("Skipping multiline sign command: " + trimmed); + return null; + } + if (trimmed.startsWith("/")) { + logger.warning("Skipping slash-prefixed sign command: " + trimmed); + return null; + } + String commandLabel = trimmed.split("\\s+", 2)[0]; + if (!COMMAND_LABEL_PATTERN.matcher(commandLabel).matches()) { + logger.warning("Skipping malformed sign command: " + trimmed); + return null; + } + return trimmed; + } +} diff --git a/realty-paper/src/main/java/io/github/md5sha256/realty/command/AgentInviteWithdrawCommand.java b/realty-paper/src/main/java/io/github/md5sha256/realty/command/AgentInviteWithdrawCommand.java index 5d06fc9..f48d917 100644 --- a/realty-paper/src/main/java/io/github/md5sha256/realty/command/AgentInviteWithdrawCommand.java +++ b/realty-paper/src/main/java/io/github/md5sha256/realty/command/AgentInviteWithdrawCommand.java @@ -70,7 +70,8 @@ private void execute(@NotNull CommandContext ctx) { } CompletableFuture.runAsync(() -> { try { - RealtyLogicImpl.WithdrawAgentInviteResult result = logic.withdrawAgentInvite(regionId, worldId, inviteeId); + RealtyLogicImpl.WithdrawAgentInviteResult result = logic.withdrawAgentInvite( + regionId, worldId, player.getUniqueId(), inviteeId); switch (result) { case RealtyLogicImpl.WithdrawAgentInviteResult.Success() -> { sender.sendMessage(messages.messageFor(MessageKeys.AGENT_INVITE_WITHDRAW_SUCCESS, @@ -81,6 +82,12 @@ private void execute(@NotNull CommandContext ctx) { Placeholder.unparsed("player", resolveName(player.getUniqueId())), Placeholder.unparsed("region", regionId))); } + case RealtyLogicImpl.WithdrawAgentInviteResult.NoFreeholdContract() -> + sender.sendMessage(messages.messageFor(MessageKeys.AGENT_INVITE_NO_FREEHOLD, + Placeholder.unparsed("region", regionId))); + case RealtyLogicImpl.WithdrawAgentInviteResult.NotTitleHolder() -> + sender.sendMessage(messages.messageFor(MessageKeys.AGENT_INVITE_NOT_TITLEHOLDER, + Placeholder.unparsed("region", regionId))); case RealtyLogicImpl.WithdrawAgentInviteResult.NotFound() -> sender.sendMessage(messages.messageFor(MessageKeys.AGENT_INVITE_WITHDRAW_NOT_FOUND, Placeholder.unparsed("player", inviteeName), diff --git a/realty-paper/src/main/java/io/github/md5sha256/realty/command/AgentRemoveCommand.java b/realty-paper/src/main/java/io/github/md5sha256/realty/command/AgentRemoveCommand.java index c3929d2..c647d49 100644 --- a/realty-paper/src/main/java/io/github/md5sha256/realty/command/AgentRemoveCommand.java +++ b/realty-paper/src/main/java/io/github/md5sha256/realty/command/AgentRemoveCommand.java @@ -70,19 +70,28 @@ private void execute(@NotNull CommandContext ctx) { UUID actorId = player.getUniqueId(); CompletableFuture.runAsync(() -> { try { - int rows = logic.removeSanctionedAuctioneer(regionId, worldId, targetId, actorId); - if (rows > 0) { - sender.sendMessage(messages.messageFor(MessageKeys.AGENT_REMOVE_SUCCESS, - Placeholder.unparsed("player", targetName), - Placeholder.unparsed("region", regionId))); - notificationService.queueNotification(targetId, - messages.messageFor(MessageKeys.NOTIFICATION_AGENT_REMOVED, - Placeholder.unparsed("player", player.getName()), + RealtyLogicImpl.RemoveSanctionedAuctioneerResult result = logic.removeSanctionedAuctioneer( + regionId, worldId, targetId, actorId); + switch (result) { + case RealtyLogicImpl.RemoveSanctionedAuctioneerResult.Success() -> { + sender.sendMessage(messages.messageFor(MessageKeys.AGENT_REMOVE_SUCCESS, + Placeholder.unparsed("player", targetName), + Placeholder.unparsed("region", regionId))); + notificationService.queueNotification(targetId, + messages.messageFor(MessageKeys.NOTIFICATION_AGENT_REMOVED, + Placeholder.unparsed("player", player.getName()), + Placeholder.unparsed("region", regionId))); + } + case RealtyLogicImpl.RemoveSanctionedAuctioneerResult.NoFreeholdContract() -> + sender.sendMessage(messages.messageFor(MessageKeys.AGENT_INVITE_NO_FREEHOLD, + Placeholder.unparsed("region", regionId))); + case RealtyLogicImpl.RemoveSanctionedAuctioneerResult.NotTitleHolder() -> + sender.sendMessage(messages.messageFor(MessageKeys.AGENT_INVITE_NOT_TITLEHOLDER, + Placeholder.unparsed("region", regionId))); + case RealtyLogicImpl.RemoveSanctionedAuctioneerResult.NotFound() -> + sender.sendMessage(messages.messageFor(MessageKeys.AGENT_REMOVE_NOT_FOUND, + Placeholder.unparsed("player", targetName), Placeholder.unparsed("region", regionId))); - } else { - sender.sendMessage(messages.messageFor(MessageKeys.AGENT_REMOVE_NOT_FOUND, - Placeholder.unparsed("player", targetName), - Placeholder.unparsed("region", regionId))); } } catch (Exception ex) { sender.sendMessage(messages.messageFor(MessageKeys.AGENT_REMOVE_ERROR, diff --git a/realty-paper/src/main/java/io/github/md5sha256/realty/command/AuctionCommandGroup.java b/realty-paper/src/main/java/io/github/md5sha256/realty/command/AuctionCommandGroup.java index 7535d7c..30cb2de 100644 --- a/realty-paper/src/main/java/io/github/md5sha256/realty/command/AuctionCommandGroup.java +++ b/realty-paper/src/main/java/io/github/md5sha256/realty/command/AuctionCommandGroup.java @@ -215,9 +215,12 @@ private void executeCreate(@NotNull CommandContext ctx) { private void executeCancel(@NotNull CommandContext ctx) { CommandSender sender = ctx.sender().getSender(); + if (!(sender instanceof Player player)) { + sender.sendMessage(messages.messageFor(MessageKeys.COMMON_PLAYERS_ONLY)); + return; + } WorldGuardRegion region = ctx.optional("region") - .orElseGet(() -> sender instanceof Player player - ? WorldGuardRegionResolver.resolveAtLocation(player.getLocation()) : null); + .orElseGet(() -> WorldGuardRegionResolver.resolveAtLocation(player.getLocation())); if (region == null) { sender.sendMessage(messages.messageFor(MessageKeys.ERROR_NO_REGION)); return; @@ -225,16 +228,27 @@ private void executeCancel(@NotNull CommandContext ctx) { String regionId = region.region().getId(); CompletableFuture.runAsync(() -> { try { - RealtyLogicImpl.CancelAuctionResult result = logic.cancelAuction(regionId, region.world().getUID()); - if (result.deleted() == 0) { - sender.sendMessage(messages.messageFor(MessageKeys.CANCEL_AUCTION_NO_AUCTION)); - return; - } - sender.sendMessage(messages.messageFor(MessageKeys.CANCEL_AUCTION_SUCCESS, - Placeholder.unparsed("region", regionId))); - for (UUID bidderId : result.bidderIds()) { - notificationService.queueNotification(bidderId, - messages.messageFor(MessageKeys.NOTIFICATION_AUCTION_CANCELLED, + RealtyLogicImpl.CancelAuctionResult result = logic.cancelAuction( + regionId, region.world().getUID(), player.getUniqueId()); + switch (result) { + case RealtyLogicImpl.CancelAuctionResult.Success success -> { + if (success.deleted() == 0) { + sender.sendMessage(messages.messageFor(MessageKeys.CANCEL_AUCTION_NO_AUCTION)); + return; + } + sender.sendMessage(messages.messageFor(MessageKeys.CANCEL_AUCTION_SUCCESS, + Placeholder.unparsed("region", regionId))); + for (UUID bidderId : success.bidderIds()) { + notificationService.queueNotification(bidderId, + messages.messageFor(MessageKeys.NOTIFICATION_AUCTION_CANCELLED, + Placeholder.unparsed("region", regionId))); + } + } + case RealtyLogicImpl.CancelAuctionResult.NotSanctioned ignored -> + sender.sendMessage(messages.messageFor(MessageKeys.AUCTION_NOT_SANCTIONED, + Placeholder.unparsed("region", regionId))); + case RealtyLogicImpl.CancelAuctionResult.NoFreeholdContract ignored -> + sender.sendMessage(messages.messageFor(MessageKeys.AUCTION_NO_FREEHOLD_CONTRACT, Placeholder.unparsed("region", regionId))); } } catch (Exception ex) { diff --git a/realty-paper/src/main/java/io/github/md5sha256/realty/command/ExtendCommand.java b/realty-paper/src/main/java/io/github/md5sha256/realty/command/ExtendCommand.java index 192e4a9..b05915e 100644 --- a/realty-paper/src/main/java/io/github/md5sha256/realty/command/ExtendCommand.java +++ b/realty-paper/src/main/java/io/github/md5sha256/realty/command/ExtendCommand.java @@ -8,6 +8,7 @@ import io.github.md5sha256.realty.database.RealtyLogicImpl; import io.github.md5sha256.realty.localisation.MessageContainer; import io.github.md5sha256.realty.localisation.MessageKeys; +import io.github.md5sha256.realty.util.DeferredEconomySettlement; import io.github.md5sha256.realty.util.ExecutorState; import io.papermc.paper.command.brigadier.CommandSourceStack; import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; @@ -61,7 +62,7 @@ private void execute(@NotNull CommandContext ctx) { String regionId = region.region().getId(); CompletableFuture.supplyAsync(() -> { try { - return logic.previewRenewLeasehold(regionId, region.world().getUID()); + return logic.previewRenewLeasehold(regionId, region.world().getUID(), sender.getUniqueId()); } catch (Exception ex) { sender.sendMessage(messages.messageFor(MessageKeys.EXTEND_ERROR, Placeholder.unparsed("error", ex.getMessage()))); @@ -75,6 +76,9 @@ private void execute(@NotNull CommandContext ctx) { case RealtyLogicImpl.RenewLeaseholdResult.NoLeaseholdContract ignored -> sender.sendMessage(messages.messageFor(MessageKeys.EXTEND_NO_LEASEHOLD_CONTRACT, Placeholder.unparsed("region", regionId))); + case RealtyLogicImpl.RenewLeaseholdResult.NotTenant ignored -> + sender.sendMessage(messages.messageFor(MessageKeys.EXTEND_NOT_TENANT, + Placeholder.unparsed("region", regionId))); case RealtyLogicImpl.RenewLeaseholdResult.NoExtensionsRemaining ignored -> sender.sendMessage(messages.messageFor(MessageKeys.EXTEND_NO_EXTENSIONS, Placeholder.unparsed("region", regionId))); @@ -97,9 +101,17 @@ private void execute(@NotNull CommandContext ctx) { Placeholder.unparsed("error", response.errorMessage))); return; } - OfflinePlayer landlord = Bukkit.getOfflinePlayer(success.landlordId()); - economy.depositPlayer(landlord, price); } + // Mirror the rent flow: keep the landlord payment deferred until + // the renewal is durably written. + DeferredEconomySettlement settlement = price > 0 + ? new DeferredEconomySettlement( + () -> economy.depositPlayer(sender, price), + () -> { + OfflinePlayer landlord = Bukkit.getOfflinePlayer(success.landlordId()); + economy.depositPlayer(landlord, price); + }) + : null; CompletableFuture.supplyAsync(() -> { try { RealtyLogicImpl.RenewLeaseholdResult result = logic.renewLeasehold( @@ -113,13 +125,16 @@ private void execute(@NotNull CommandContext ctx) { } }, executorState.dbExec()).thenAcceptAsync(placeholders -> { if (placeholders == null) { - if (price > 0) { - economy.depositPlayer(sender, price); + if (settlement != null) { + settlement.refundPayer(); } sender.sendMessage(messages.messageFor(MessageKeys.EXTEND_UPDATE_FAILED, Placeholder.unparsed("region", regionId))); return; } + if (settlement != null) { + settlement.settleRecipient(); + } signTextApplicator.updateLoadedSigns(region.world(), regionId, RegionState.LEASED, placeholders); sender.sendMessage(messages.messageFor(MessageKeys.EXTEND_SUCCESS, Placeholder.unparsed("region", regionId), diff --git a/realty-paper/src/main/java/io/github/md5sha256/realty/command/RentCommand.java b/realty-paper/src/main/java/io/github/md5sha256/realty/command/RentCommand.java index 7e0787f..8395d0a 100644 --- a/realty-paper/src/main/java/io/github/md5sha256/realty/command/RentCommand.java +++ b/realty-paper/src/main/java/io/github/md5sha256/realty/command/RentCommand.java @@ -12,6 +12,7 @@ import io.github.md5sha256.realty.database.RealtyLogicImpl; import io.github.md5sha256.realty.localisation.MessageContainer; import io.github.md5sha256.realty.localisation.MessageKeys; +import io.github.md5sha256.realty.util.DeferredEconomySettlement; import io.github.md5sha256.realty.util.ExecutorState; import io.papermc.paper.command.brigadier.CommandSourceStack; import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; @@ -69,7 +70,7 @@ private void execute(@NotNull CommandContext ctx) { // Step 1: preview rent eligibility (DB, no mutation) CompletableFuture.supplyAsync(() -> { try { - return logic.previewRent(regionId, region.world().getUID()); + return logic.previewRent(regionId, region.world().getUID(), sender.getUniqueId()); } catch (Exception ex) { sender.sendMessage(messages.messageFor(MessageKeys.RENT_ERROR, Placeholder.unparsed("error", ex.getMessage()))); @@ -83,6 +84,9 @@ private void execute(@NotNull CommandContext ctx) { case RealtyLogicImpl.RentResult.NoLeaseholdContract ignored -> sender.sendMessage(messages.messageFor(MessageKeys.RENT_NO_LEASEHOLD_CONTRACT, Placeholder.unparsed("region", regionId))); + case RealtyLogicImpl.RentResult.IsLandlord ignored -> + sender.sendMessage(messages.messageFor(MessageKeys.RENT_IS_LANDLORD, + Placeholder.unparsed("region", regionId))); case RealtyLogicImpl.RentResult.AlreadyOccupied ignored -> sender.sendMessage(messages.messageFor(MessageKeys.RENT_ALREADY_OCCUPIED, Placeholder.unparsed("region", regionId))); @@ -106,9 +110,17 @@ private void execute(@NotNull CommandContext ctx) { Placeholder.unparsed("error", response.errorMessage))); return; } - OfflinePlayer landlord = Bukkit.getOfflinePlayer(success.landlordId()); - economy.depositPlayer(landlord, price); } + // Do not pay the landlord until the lease write commits; otherwise + // a failed DB write can duplicate currency. + DeferredEconomySettlement settlement = price > 0 + ? new DeferredEconomySettlement( + () -> economy.depositPlayer(sender, price), + () -> { + OfflinePlayer landlord = Bukkit.getOfflinePlayer(success.landlordId()); + economy.depositPlayer(landlord, price); + }) + : null; // Step 3: execute DB mutation CompletableFuture.supplyAsync(() -> { try { @@ -124,13 +136,16 @@ private void execute(@NotNull CommandContext ctx) { }, executorState.dbExec()).thenAcceptAsync(placeholders -> { // Step 4: finalize or refund if (placeholders == null) { - if (price > 0) { - economy.depositPlayer(sender, price); + if (settlement != null) { + settlement.refundPayer(); } sender.sendMessage(messages.messageFor(MessageKeys.RENT_UPDATE_FAILED, Placeholder.unparsed("region", regionId))); return; } + if (settlement != null) { + settlement.settleRecipient(); + } ProtectedRegion protectedRegion = region.region(); protectedRegion.getOwners().clear(); protectedRegion.getMembers().clear(); diff --git a/realty-paper/src/main/java/io/github/md5sha256/realty/command/SetCommandGroup.java b/realty-paper/src/main/java/io/github/md5sha256/realty/command/SetCommandGroup.java index 8d60817..6a21aa4 100644 --- a/realty-paper/src/main/java/io/github/md5sha256/realty/command/SetCommandGroup.java +++ b/realty-paper/src/main/java/io/github/md5sha256/realty/command/SetCommandGroup.java @@ -114,21 +114,21 @@ private void executeSetPrice(@NotNull CommandContext ctx) { } String regionId = region.region().getId(); UUID worldId = region.world().getUID(); - if (sender instanceof Player player - && !sender.hasPermission("realty.command.set.price.others") - && !region.region().getOwners().contains(player.getUniqueId())) { - sender.sendMessage(messages.messageFor(MessageKeys.SET_NO_PERMISSION)); - return; - } + // Shared logic owns the real authorization check because WorldGuard + // owners are rewritten during rental flows. + UUID callerId = sender instanceof Player player ? player.getUniqueId() : null; + boolean bypassAuth = !(sender instanceof Player) || sender.hasPermission("realty.command.set.price.others"); CompletableFuture.runAsync(() -> { try { RealtyLogicImpl.SetPriceResult result = logic.setPrice( - regionId, worldId, price); + regionId, worldId, price, callerId, bypassAuth); switch (result) { case RealtyLogicImpl.SetPriceResult.Success ignored -> sender.sendMessage(messages.messageFor(MessageKeys.SET_PRICE_SUCCESS, Placeholder.unparsed("price", CurrencyFormatter.format(price)), Placeholder.unparsed("region", regionId))); + case RealtyLogicImpl.SetPriceResult.NotAuthorized ignored -> + sender.sendMessage(messages.messageFor(MessageKeys.SET_NO_PERMISSION)); case RealtyLogicImpl.SetPriceResult.NoFreeholdContract ignored -> sender.sendMessage(messages.messageFor(MessageKeys.SET_PRICE_NO_FREEHOLD_CONTRACT, Placeholder.unparsed("region", regionId))); @@ -164,21 +164,19 @@ private void executeSetDuration(@NotNull CommandContext ctx) } String regionId = region.region().getId(); UUID worldId = region.world().getUID(); - if (sender instanceof Player player - && !sender.hasPermission("realty.command.set.duration.others") - && !region.region().getOwners().contains(player.getUniqueId())) { - sender.sendMessage(messages.messageFor(MessageKeys.SET_NO_PERMISSION)); - return; - } + UUID callerId = sender instanceof Player player ? player.getUniqueId() : null; + boolean bypassAuth = !(sender instanceof Player) || sender.hasPermission("realty.command.set.duration.others"); CompletableFuture.runAsync(() -> { try { RealtyLogicImpl.SetDurationResult result = logic.setDuration( - regionId, worldId, duration.toSeconds()); + regionId, worldId, duration.toSeconds(), callerId, bypassAuth); switch (result) { case RealtyLogicImpl.SetDurationResult.Success ignored -> sender.sendMessage(messages.messageFor(MessageKeys.SET_DURATION_SUCCESS, Placeholder.unparsed("duration", duration.toString()), Placeholder.unparsed("region", regionId))); + case RealtyLogicImpl.SetDurationResult.NotAuthorized ignored -> + sender.sendMessage(messages.messageFor(MessageKeys.SET_NO_PERMISSION)); case RealtyLogicImpl.SetDurationResult.NoLeaseholdContract ignored -> sender.sendMessage(messages.messageFor(MessageKeys.SET_DURATION_NO_LEASEHOLD_CONTRACT, Placeholder.unparsed("region", regionId))); @@ -205,16 +203,12 @@ private void executeSetLandlord(@NotNull CommandContext ctx) } String regionId = region.region().getId(); UUID worldId = region.world().getUID(); - if (sender instanceof Player player - && !sender.hasPermission("realty.command.set.landlord.others") - && !region.region().getOwners().contains(player.getUniqueId())) { - sender.sendMessage(messages.messageFor(MessageKeys.SET_NO_PERMISSION)); - return; - } + UUID callerId = sender instanceof Player player ? player.getUniqueId() : null; + boolean bypassAuth = !(sender instanceof Player) || sender.hasPermission("realty.command.set.landlord.others"); CompletableFuture.runAsync(() -> { try { RealtyLogicImpl.SetLandlordResult result = logic.setLandlord( - regionId, worldId, landlordId); + regionId, worldId, landlordId, callerId, bypassAuth); switch (result) { case RealtyLogicImpl.SetLandlordResult.Success(UUID previousLandlord) -> { executorState.mainThreadExec().execute(() -> { @@ -224,6 +218,8 @@ private void executeSetLandlord(@NotNull CommandContext ctx) Placeholder.unparsed("landlord", resolveName(landlordId)), Placeholder.unparsed("region", regionId))); } + case RealtyLogicImpl.SetLandlordResult.NotAuthorized ignored -> + sender.sendMessage(messages.messageFor(MessageKeys.SET_NO_PERMISSION)); case RealtyLogicImpl.SetLandlordResult.NoLeaseholdContract ignored -> sender.sendMessage(messages.messageFor(MessageKeys.SET_LANDLORD_NO_LEASEHOLD_CONTRACT, Placeholder.unparsed("region", regionId))); @@ -250,16 +246,12 @@ private void executeSetTitleHolder(@NotNull CommandContext c } String regionId = region.region().getId(); UUID worldId = region.world().getUID(); - if (sender instanceof Player player - && !sender.hasPermission("realty.command.set.titleholder.others") - && !region.region().getOwners().contains(player.getUniqueId())) { - sender.sendMessage(messages.messageFor(MessageKeys.SET_NO_PERMISSION)); - return; - } + UUID callerId = sender instanceof Player player ? player.getUniqueId() : null; + boolean bypassAuth = !(sender instanceof Player) || sender.hasPermission("realty.command.set.titleholder.others"); CompletableFuture.runAsync(() -> { try { RealtyLogicImpl.SetTitleHolderResult result = logic.setTitleHolder( - regionId, worldId, titleHolderId); + regionId, worldId, titleHolderId, callerId, bypassAuth); switch (result) { case RealtyLogicImpl.SetTitleHolderResult.Success(UUID previousTitleHolder) -> { Map placeholders = logic.getRegionPlaceholders(regionId, @@ -286,6 +278,8 @@ private void executeSetTitleHolder(@NotNull CommandContext c Placeholder.unparsed("titleholder", resolveName(titleHolderId)), Placeholder.unparsed("region", regionId))); } + case RealtyLogicImpl.SetTitleHolderResult.NotAuthorized ignored -> + sender.sendMessage(messages.messageFor(MessageKeys.SET_NO_PERMISSION)); case RealtyLogicImpl.SetTitleHolderResult.NoFreeholdContract ignored -> sender.sendMessage(messages.messageFor(MessageKeys.SET_TITLEHOLDER_NO_FREEHOLD_CONTRACT, Placeholder.unparsed("region", regionId))); @@ -312,16 +306,12 @@ private void executeSetTenant(@NotNull CommandContext ctx) { } String regionId = region.region().getId(); UUID worldId = region.world().getUID(); - if (sender instanceof Player player - && !sender.hasPermission("realty.command.set.tenant.others") - && !region.region().getOwners().contains(player.getUniqueId())) { - sender.sendMessage(messages.messageFor(MessageKeys.SET_NO_PERMISSION)); - return; - } + UUID callerId = sender instanceof Player player ? player.getUniqueId() : null; + boolean bypassAuth = !(sender instanceof Player) || sender.hasPermission("realty.command.set.tenant.others"); CompletableFuture.runAsync(() -> { try { RealtyLogicImpl.SetTenantResult result = logic.setTenant( - regionId, worldId, tenantId); + regionId, worldId, tenantId, callerId, bypassAuth); switch (result) { case RealtyLogicImpl.SetTenantResult.Success(UUID previousTenant, UUID ignored2) -> { Map placeholders = logic.getRegionPlaceholders(regionId, @@ -344,6 +334,8 @@ private void executeSetTenant(@NotNull CommandContext ctx) { Placeholder.unparsed("tenant", resolveName(tenantId)), Placeholder.unparsed("region", regionId))); } + case RealtyLogicImpl.SetTenantResult.NotAuthorized ignored -> + sender.sendMessage(messages.messageFor(MessageKeys.SET_NO_PERMISSION)); case RealtyLogicImpl.SetTenantResult.NoLeaseholdContract ignored -> sender.sendMessage(messages.messageFor(MessageKeys.SET_TENANT_NO_LEASEHOLD_CONTRACT, Placeholder.unparsed("region", regionId))); @@ -370,22 +362,20 @@ private void executeSetMaxExtensions(@NotNull CommandContext } String regionId = region.region().getId(); UUID worldId = region.world().getUID(); - if (sender instanceof Player player - && !sender.hasPermission("realty.command.set.maxextensions.others") - && !region.region().getOwners().contains(player.getUniqueId())) { - sender.sendMessage(messages.messageFor(MessageKeys.SET_NO_PERMISSION)); - return; - } + UUID callerId = sender instanceof Player player ? player.getUniqueId() : null; + boolean bypassAuth = !(sender instanceof Player) || sender.hasPermission("realty.command.set.maxextensions.others"); CompletableFuture.runAsync(() -> { try { RealtyLogicImpl.SetMaxRenewalsResult result = logic.setMaxRenewals( - regionId, worldId, maxExtensions); + regionId, worldId, maxExtensions, callerId, bypassAuth); switch (result) { case RealtyLogicImpl.SetMaxRenewalsResult.Success ignored -> sender.sendMessage(messages.messageFor(MessageKeys.SET_MAX_EXTENSIONS_SUCCESS, Placeholder.unparsed("maxextensions", maxExtensions < 0 ? "unlimited" : String.valueOf(maxExtensions)), Placeholder.unparsed("region", regionId))); + case RealtyLogicImpl.SetMaxRenewalsResult.NotAuthorized ignored -> + sender.sendMessage(messages.messageFor(MessageKeys.SET_NO_PERMISSION)); case RealtyLogicImpl.SetMaxRenewalsResult.NoLeaseholdContract ignored -> sender.sendMessage(messages.messageFor(MessageKeys.SET_MAX_EXTENSIONS_NO_LEASEHOLD_CONTRACT, Placeholder.unparsed("region", regionId))); diff --git a/realty-paper/src/main/java/io/github/md5sha256/realty/command/UnrentCommand.java b/realty-paper/src/main/java/io/github/md5sha256/realty/command/UnrentCommand.java index 58b07c3..7034828 100644 --- a/realty-paper/src/main/java/io/github/md5sha256/realty/command/UnrentCommand.java +++ b/realty-paper/src/main/java/io/github/md5sha256/realty/command/UnrentCommand.java @@ -140,6 +140,11 @@ private void execute(@NotNull CommandContext ctx) { sender.sendMessage(messages.messageFor(MessageKeys.UNRENT_NO_LEASEHOLD_CONTRACT, Placeholder.unparsed("region", regionId))); } + case RealtyLogicImpl.UnrentResult.NotTenant ignored -> { + revertEconomy(sender, lease, refund); + sender.sendMessage(messages.messageFor(MessageKeys.UNRENT_NOT_TENANT, + Placeholder.unparsed("region", regionId))); + } case RealtyLogicImpl.UnrentResult.UpdateFailed ignored -> { revertEconomy(sender, lease, refund); sender.sendMessage(messages.messageFor(MessageKeys.UNRENT_UPDATE_FAILED, diff --git a/realty-paper/src/main/java/io/github/md5sha256/realty/command/UnsetCommandGroup.java b/realty-paper/src/main/java/io/github/md5sha256/realty/command/UnsetCommandGroup.java index 3499b9b..669b767 100644 --- a/realty-paper/src/main/java/io/github/md5sha256/realty/command/UnsetCommandGroup.java +++ b/realty-paper/src/main/java/io/github/md5sha256/realty/command/UnsetCommandGroup.java @@ -74,20 +74,20 @@ private void executeUnsetPrice(@NotNull CommandContext ctx) } String regionId = region.region().getId(); UUID worldId = region.world().getUID(); - if (sender instanceof Player player - && !sender.hasPermission("realty.command.unset.price.others") - && !region.region().getOwners().contains(player.getUniqueId())) { - sender.sendMessage(messages.messageFor(MessageKeys.UNSET_NO_PERMISSION)); - return; - } + // Shared logic owns the real authorization check because WorldGuard + // owners are rewritten during rental flows. + UUID callerId = sender instanceof Player player ? player.getUniqueId() : null; + boolean bypassAuth = !(sender instanceof Player) || sender.hasPermission("realty.command.unset.price.others"); CompletableFuture.runAsync(() -> { try { RealtyLogicImpl.UnsetPriceResult result = logic.unsetPrice( - regionId, worldId); + regionId, worldId, callerId, bypassAuth); switch (result) { case RealtyLogicImpl.UnsetPriceResult.Success ignored -> sender.sendMessage(messages.messageFor(MessageKeys.UNSET_PRICE_SUCCESS, Placeholder.unparsed("region", regionId))); + case RealtyLogicImpl.UnsetPriceResult.NotAuthorized ignored -> + sender.sendMessage(messages.messageFor(MessageKeys.UNSET_NO_PERMISSION)); case RealtyLogicImpl.UnsetPriceResult.NoFreeholdContract ignored -> sender.sendMessage(messages.messageFor(MessageKeys.UNSET_PRICE_NO_FREEHOLD_CONTRACT, Placeholder.unparsed("region", regionId))); @@ -119,16 +119,12 @@ private void executeUnsetTitleHolder(@NotNull CommandContext } String regionId = region.region().getId(); UUID worldId = region.world().getUID(); - if (sender instanceof Player player - && !sender.hasPermission("realty.command.unset.titleholder.others") - && !region.region().getOwners().contains(player.getUniqueId())) { - sender.sendMessage(messages.messageFor(MessageKeys.UNSET_NO_PERMISSION)); - return; - } + UUID callerId = sender instanceof Player player ? player.getUniqueId() : null; + boolean bypassAuth = !(sender instanceof Player) || sender.hasPermission("realty.command.unset.titleholder.others"); CompletableFuture.runAsync(() -> { try { RealtyLogicImpl.SetTitleHolderResult result = logic.setTitleHolder( - regionId, worldId, null); + regionId, worldId, null, callerId, bypassAuth); switch (result) { case RealtyLogicImpl.SetTitleHolderResult.Success(UUID previousTitleHolder) -> { Map placeholders = logic.getRegionPlaceholders(regionId, worldId); @@ -141,6 +137,8 @@ private void executeUnsetTitleHolder(@NotNull CommandContext sender.sendMessage(messages.messageFor(MessageKeys.UNSET_TITLEHOLDER_SUCCESS, Placeholder.unparsed("region", regionId))); } + case RealtyLogicImpl.SetTitleHolderResult.NotAuthorized ignored -> + sender.sendMessage(messages.messageFor(MessageKeys.UNSET_NO_PERMISSION)); case RealtyLogicImpl.SetTitleHolderResult.NoFreeholdContract ignored -> sender.sendMessage(messages.messageFor(MessageKeys.UNSET_TITLEHOLDER_NO_FREEHOLD_CONTRACT, Placeholder.unparsed("region", regionId))); @@ -166,16 +164,12 @@ private void executeUnsetTenant(@NotNull CommandContext ctx) } String regionId = region.region().getId(); UUID worldId = region.world().getUID(); - if (sender instanceof Player player - && !sender.hasPermission("realty.command.unset.tenant.others") - && !region.region().getOwners().contains(player.getUniqueId())) { - sender.sendMessage(messages.messageFor(MessageKeys.UNSET_NO_PERMISSION)); - return; - } + UUID callerId = sender instanceof Player player ? player.getUniqueId() : null; + boolean bypassAuth = !(sender instanceof Player) || sender.hasPermission("realty.command.unset.tenant.others"); CompletableFuture.runAsync(() -> { try { RealtyLogicImpl.SetTenantResult result = logic.setTenant( - regionId, worldId, null); + regionId, worldId, null, callerId, bypassAuth); switch (result) { case RealtyLogicImpl.SetTenantResult.Success(UUID previousTenant, UUID ignored2) -> { Map placeholders = logic.getRegionPlaceholders(regionId, worldId); @@ -188,6 +182,8 @@ private void executeUnsetTenant(@NotNull CommandContext ctx) sender.sendMessage(messages.messageFor(MessageKeys.UNSET_TENANT_SUCCESS, Placeholder.unparsed("region", regionId))); } + case RealtyLogicImpl.SetTenantResult.NotAuthorized ignored -> + sender.sendMessage(messages.messageFor(MessageKeys.UNSET_NO_PERMISSION)); case RealtyLogicImpl.SetTenantResult.NoLeaseholdContract ignored -> sender.sendMessage(messages.messageFor(MessageKeys.UNSET_TENANT_NO_LEASEHOLD_CONTRACT, Placeholder.unparsed("region", regionId))); diff --git a/realty-paper/src/main/java/io/github/md5sha256/realty/util/DeferredEconomySettlement.java b/realty-paper/src/main/java/io/github/md5sha256/realty/util/DeferredEconomySettlement.java new file mode 100644 index 0000000..84cda88 --- /dev/null +++ b/realty-paper/src/main/java/io/github/md5sha256/realty/util/DeferredEconomySettlement.java @@ -0,0 +1,36 @@ +package io.github.md5sha256.realty.util; + +import org.jetbrains.annotations.NotNull; + +/** + * Holds the second half of an economy transfer until the database mutation has + * committed, so failure paths can refund the payer without minting money. + */ +public final class DeferredEconomySettlement { + + private final Runnable refundPayer; + private final Runnable payRecipient; + private boolean completed; + + public DeferredEconomySettlement(@NotNull Runnable refundPayer, + @NotNull Runnable payRecipient) { + this.refundPayer = refundPayer; + this.payRecipient = payRecipient; + } + + public void refundPayer() { + if (this.completed) { + return; + } + this.refundPayer.run(); + this.completed = true; + } + + public void settleRecipient() { + if (this.completed) { + return; + } + this.payRecipient.run(); + this.completed = true; + } +} diff --git a/realty-paper/src/test/java/io/github/md5sha256/realty/api/SignCommandSanitizerTest.java b/realty-paper/src/test/java/io/github/md5sha256/realty/api/SignCommandSanitizerTest.java new file mode 100644 index 0000000..880ccb9 --- /dev/null +++ b/realty-paper/src/test/java/io/github/md5sha256/realty/api/SignCommandSanitizerTest.java @@ -0,0 +1,39 @@ +package io.github.md5sha256.realty.api; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import java.util.logging.Logger; + +class SignCommandSanitizerTest { + + private static final Logger LOGGER = Logger.getLogger(SignCommandSanitizerTest.class.getName()); + + @ParameterizedTest + @ValueSource(strings = { + "realty info spawn", + " realty:info spawn ", + "warp-home", + }) + @DisplayName("valid commands are preserved") + void validCommandsArePreserved(String command) { + String sanitized = SignCommandSanitizer.sanitize(command, LOGGER); + Assertions.assertNotNull(sanitized); + Assertions.assertEquals(command.trim(), sanitized); + } + + @ParameterizedTest + @ValueSource(strings = { + "", + " ", + "/realty info spawn", + "realty info spawn\nrealty buy spawn", + "bad;command", + }) + @DisplayName("unsafe commands are rejected") + void unsafeCommandsAreRejected(String command) { + Assertions.assertNull(SignCommandSanitizer.sanitize(command, LOGGER)); + } +} diff --git a/realty-paper/src/test/java/io/github/md5sha256/realty/util/DeferredEconomySettlementTest.java b/realty-paper/src/test/java/io/github/md5sha256/realty/util/DeferredEconomySettlementTest.java new file mode 100644 index 0000000..b0de88d --- /dev/null +++ b/realty-paper/src/test/java/io/github/md5sha256/realty/util/DeferredEconomySettlementTest.java @@ -0,0 +1,58 @@ +package io.github.md5sha256.realty.util; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.concurrent.atomic.AtomicInteger; + +class DeferredEconomySettlementTest { + + @Test + @DisplayName("refundPayer runs only once") + void refundRunsOnlyOnce() { + AtomicInteger refunds = new AtomicInteger(); + AtomicInteger recipientPayments = new AtomicInteger(); + DeferredEconomySettlement settlement = new DeferredEconomySettlement( + refunds::incrementAndGet, + recipientPayments::incrementAndGet); + + settlement.refundPayer(); + settlement.refundPayer(); + + Assertions.assertEquals(1, refunds.get()); + Assertions.assertEquals(0, recipientPayments.get()); + } + + @Test + @DisplayName("settleRecipient runs only once") + void settleRunsOnlyOnce() { + AtomicInteger refunds = new AtomicInteger(); + AtomicInteger recipientPayments = new AtomicInteger(); + DeferredEconomySettlement settlement = new DeferredEconomySettlement( + refunds::incrementAndGet, + recipientPayments::incrementAndGet); + + settlement.settleRecipient(); + settlement.settleRecipient(); + + Assertions.assertEquals(0, refunds.get()); + Assertions.assertEquals(1, recipientPayments.get()); + } + + @Test + @DisplayName("first terminal action wins") + void firstActionWins() { + AtomicInteger refunds = new AtomicInteger(); + AtomicInteger recipientPayments = new AtomicInteger(); + DeferredEconomySettlement settlement = new DeferredEconomySettlement( + refunds::incrementAndGet, + recipientPayments::incrementAndGet); + + settlement.refundPayer(); + settlement.settleRecipient(); + + Assertions.assertEquals(1, refunds.get()); + Assertions.assertEquals(0, recipientPayments.get()); + } +}