From 3982b9cd3fcd681e0bb6dc5eb28aa780b109cafa Mon Sep 17 00:00:00 2001 From: Technofied <40795318+Technofied@users.noreply.github.com> Date: Sun, 23 Aug 2026 23:29:50 +0800 Subject: [PATCH] fix(leasehold): block unrent while a termination is pending An eviction is a landlord-initiated termination, held on the contract as terminationEffectiveDate + terminatedByRole. The unrentRegion UPDATE cleared both alongside the tenant, and rentRegion had no termination guard, so an evicted tenant could /realty unrent (erasing the eviction and taking a pro-rata refund) and immediately re-rent on a fresh full-length lease -- unilaterally cancelling an eviction that /realty terminate cancel restricts to the initiating party. Guard unrent the way extend and modify are already guarded: a scheduled termination now yields UnrentResult.Terminating, so the lease can only end via the sweep on its effective date. The guard is applied both on the read in RealtyBackendImpl (for the message) and as a WHERE predicate on the UPDATE, since serializeByRegion only locks within a single server and a scheduleTermination from another could otherwise land between the two. This covers tenant-initiated terminations too, consistent with extend and modify; a tenant who scheduled their own can cancel it first, which an evicted tenant cannot. Co-Authored-By: Claude Opus 5 (1M context) --- .../md5sha256/realty/api/RealtyBackend.java | 2 ++ .../realty/database/RealtyBackendImpl.java | 6 +++++ .../mapper/LeaseholdContractMapper.java | 1 + .../mapper/MariaLeaseholdContractMapper.java | 1 + .../database/RealtyBackendImplTest.java | 25 +++++++++++++++++++ .../md5sha256/realty/api/RealtyPaperApi.java | 2 ++ .../realty/api/RealtyPaperApiImpl.java | 3 +++ .../realty/command/UnrentCommand.java | 3 +++ .../realty/localisation/MessageKeys.java | 1 + realty-paper/src/main/resources/messages.yml | 2 ++ .../realty/api/RealtyPaperApiImplTest.java | 15 +++++++++++ 11 files changed, 61 insertions(+) diff --git a/realty-backend-api/src/main/java/io/github/md5sha256/realty/api/RealtyBackend.java b/realty-backend-api/src/main/java/io/github/md5sha256/realty/api/RealtyBackend.java index 5f3641c..0b540db 100644 --- a/realty-backend-api/src/main/java/io/github/md5sha256/realty/api/RealtyBackend.java +++ b/realty-backend-api/src/main/java/io/github/md5sha256/realty/api/RealtyBackend.java @@ -298,6 +298,8 @@ void rollbackRent(@NotNull String worldGuardRegionId, sealed interface UnrentResult { record Success(double refund, @NotNull UUID tenantId, @NotNull UUID landlordId) implements UnrentResult {} record NoLeaseholdContract() implements UnrentResult {} + /** The lease is scheduled for termination; it can only end via the sweep on the effective date. */ + record Terminating() implements UnrentResult {} record UpdateFailed() implements UnrentResult {} } diff --git a/realty-backend/src/main/java/io/github/md5sha256/realty/database/RealtyBackendImpl.java b/realty-backend/src/main/java/io/github/md5sha256/realty/database/RealtyBackendImpl.java index 15ded67..f337c3c 100644 --- a/realty-backend/src/main/java/io/github/md5sha256/realty/database/RealtyBackendImpl.java +++ b/realty-backend/src/main/java/io/github/md5sha256/realty/database/RealtyBackendImpl.java @@ -807,6 +807,12 @@ public void rollbackRent(@NotNull String worldGuardRegionId, if (lease == null) { return new UnrentResult.NoLeaseholdContract(); } + // A scheduled termination — an eviction in particular — must run its notice period out. + // Without this guard the tenant could unrent (which clears terminationEffectiveDate along + // with the tenant) and immediately re-rent, unilaterally cancelling their own eviction. + if (lease.terminationEffectiveDate() != null) { + return new UnrentResult.Terminating(); + } long totalSeconds = lease.durationSeconds(); // Pro-rata refund of the UNUSED portion of the current period. Renewals push // endDate out by another durationSeconds each (see renewLeasehold) but are not diff --git a/realty-backend/src/main/java/io/github/md5sha256/realty/database/mapper/LeaseholdContractMapper.java b/realty-backend/src/main/java/io/github/md5sha256/realty/database/mapper/LeaseholdContractMapper.java index 27756c2..cded96e 100644 --- a/realty-backend/src/main/java/io/github/md5sha256/realty/database/mapper/LeaseholdContractMapper.java +++ b/realty-backend/src/main/java/io/github/md5sha256/realty/database/mapper/LeaseholdContractMapper.java @@ -57,6 +57,7 @@ int scheduleTermination(@NotNull String worldGuardRegionId, /** Leaseholds whose scheduled termination date has elapsed, due to be ended by the sweep. */ @NotNull List selectTerminatedLeaseholds(); + /** Clears the tenant, guarded on the caller still being the tenant and no termination pending. */ int unrentRegion(@NotNull String worldGuardRegionId, @NotNull UUID worldId, @NotNull UUID tenantId); diff --git a/realty-backend/src/main/java/io/github/md5sha256/realty/database/maria/mapper/MariaLeaseholdContractMapper.java b/realty-backend/src/main/java/io/github/md5sha256/realty/database/maria/mapper/MariaLeaseholdContractMapper.java index eaf019b..059e822 100644 --- a/realty-backend/src/main/java/io/github/md5sha256/realty/database/maria/mapper/MariaLeaseholdContractMapper.java +++ b/realty-backend/src/main/java/io/github/md5sha256/realty/database/maria/mapper/MariaLeaseholdContractMapper.java @@ -257,6 +257,7 @@ int clearTermination(@Param("worldGuardRegionId") @NotNull String worldGuardRegi WHERE rr.worldGuardRegionId = #{worldGuardRegionId} AND rr.worldId = #{worldId} AND lc.tenantId = #{tenantId} + AND lc.terminationEffectiveDate IS NULL """) int unrentRegion(@Param("worldGuardRegionId") @NotNull String worldGuardRegionId, @Param("worldId") @NotNull UUID worldId, diff --git a/realty-backend/src/test/java/io/github/md5sha256/realty/database/RealtyBackendImplTest.java b/realty-backend/src/test/java/io/github/md5sha256/realty/database/RealtyBackendImplTest.java index 3786401..059971c 100644 --- a/realty-backend/src/test/java/io/github/md5sha256/realty/database/RealtyBackendImplTest.java +++ b/realty-backend/src/test/java/io/github/md5sha256/realty/database/RealtyBackendImplTest.java @@ -12,6 +12,7 @@ import io.github.md5sha256.realty.api.RealtyBackend.PayOfferResult; import io.github.md5sha256.realty.api.RealtyBackend.RegionInfo; import io.github.md5sha256.realty.database.entity.FreeholdContractBidPaymentEntity; +import io.github.md5sha256.realty.database.entity.LeaseholdContractEntity; import io.github.md5sha256.realty.database.entity.FreeholdContractOfferPaymentEntity; import org.apache.ibatis.session.SqlSession; import org.junit.jupiter.api.Assertions; @@ -320,6 +321,30 @@ void scheduleBlocksExtensionAndSweepEndsLease() { Assertions.assertNull(logic.getLeaseholdContract(regionId, WORLD_ID).tenantId()); } + @Test + @DisplayName("a tenant under eviction cannot unrent their way out and re-rent") + void unrentBlockedWhileTerminating() { + String regionId = uniqueRegionId(); + logic.createLeasehold(regionId, WORLD_ID, 200.0, 3600, 5, PLAYER_A); + logic.rentRegion(regionId, WORLD_ID, PLAYER_B); + + LocalDateTime effective = LocalDateTime.now().plusDays(7); + Assertions.assertInstanceOf(RealtyBackend.TerminateLeaseholdResult.Success.class, + logic.terminateLease(regionId, WORLD_ID, effective, effective, "landlord")); + + // Pre-fix this succeeded, clearing the termination along with the tenant. + Assertions.assertInstanceOf(RealtyBackend.UnrentResult.Terminating.class, + logic.unrentRegion(regionId, WORLD_ID, PLAYER_B)); + + LeaseholdContractEntity lease = logic.getLeaseholdContract(regionId, WORLD_ID); + Assertions.assertEquals(PLAYER_B, lease.tenantId(), "Tenant must still be on the lease"); + Assertions.assertNotNull(lease.terminationEffectiveDate(), "Eviction must still be pending"); + + // ...so the region is still occupied and cannot be re-rented. + Assertions.assertInstanceOf(RealtyBackend.RentResult.AlreadyOccupied.class, + logic.rentRegion(regionId, WORLD_ID, PLAYER_B)); + } + @Test @DisplayName("cannot terminate a vacant lease") void cannotTerminateVacant() { diff --git a/realty-paper-api/src/main/java/io/github/md5sha256/realty/api/RealtyPaperApi.java b/realty-paper-api/src/main/java/io/github/md5sha256/realty/api/RealtyPaperApi.java index 612e71f..88b60b0 100644 --- a/realty-paper-api/src/main/java/io/github/md5sha256/realty/api/RealtyPaperApi.java +++ b/realty-paper-api/src/main/java/io/github/md5sha256/realty/api/RealtyPaperApi.java @@ -75,6 +75,8 @@ sealed interface UnrentResult { record Success(double refund, @NotNull String regionId, @NotNull UUID landlordId) implements UnrentResult {} record NoLeaseholdContract(@NotNull String regionId) implements UnrentResult {} + /** The lease is scheduled for termination and can only end on the effective date. */ + record Terminating(@NotNull String regionId) implements UnrentResult {} record RefundFailed(@NotNull String error) implements UnrentResult {} record UpdateFailed(@NotNull String regionId) implements UnrentResult {} record Error(@NotNull String message) implements UnrentResult {} diff --git a/realty-paper/src/main/java/io/github/md5sha256/realty/api/RealtyPaperApiImpl.java b/realty-paper/src/main/java/io/github/md5sha256/realty/api/RealtyPaperApiImpl.java index 7217376..1380ba5 100644 --- a/realty-paper/src/main/java/io/github/md5sha256/realty/api/RealtyPaperApiImpl.java +++ b/realty-paper/src/main/java/io/github/md5sha256/realty/api/RealtyPaperApiImpl.java @@ -313,6 +313,9 @@ public void setSafeBlockPredicate(@NotNull Predicate predicate) { case RealtyBackend.UnrentResult.NoLeaseholdContract ignored -> CompletableFuture.completedFuture( (UnrentResult) new UnrentResult.NoLeaseholdContract(regionId)); + case RealtyBackend.UnrentResult.Terminating ignored -> + CompletableFuture.completedFuture( + (UnrentResult) new UnrentResult.Terminating(regionId)); case RealtyBackend.UnrentResult.UpdateFailed ignored -> CompletableFuture.completedFuture( (UnrentResult) new UnrentResult.UpdateFailed(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 288f34a..4655a13 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 @@ -74,6 +74,9 @@ private void execute(@NotNull CommandContext ctx) { case RealtyPaperApi.UnrentResult.NoLeaseholdContract noContract -> sender.sendMessage(messages.messageFor(MessageKeys.UNRENT_NO_LEASEHOLD_CONTRACT, Placeholder.unparsed("region", noContract.regionId()))); + case RealtyPaperApi.UnrentResult.Terminating terminating -> + sender.sendMessage(messages.messageFor(MessageKeys.UNRENT_TERMINATING, + Placeholder.unparsed("region", terminating.regionId()))); case RealtyPaperApi.UnrentResult.RefundFailed failed -> sender.sendMessage(messages.messageFor(MessageKeys.UNRENT_REFUND_FAILED, Placeholder.unparsed("error", failed.error()))); diff --git a/realty-paper/src/main/java/io/github/md5sha256/realty/localisation/MessageKeys.java b/realty-paper/src/main/java/io/github/md5sha256/realty/localisation/MessageKeys.java index c71878f..1d46039 100644 --- a/realty-paper/src/main/java/io/github/md5sha256/realty/localisation/MessageKeys.java +++ b/realty-paper/src/main/java/io/github/md5sha256/realty/localisation/MessageKeys.java @@ -285,6 +285,7 @@ private MessageKeys() {} public static final String UNRENT_SUCCESS = "unrent.success"; public static final String UNRENT_NO_LEASEHOLD_CONTRACT = "unrent.no-leasehold-contract"; public static final String UNRENT_NOT_TENANT = "unrent.not-tenant"; + public static final String UNRENT_TERMINATING = "unrent.terminating"; public static final String UNRENT_UPDATE_FAILED = "unrent.update-failed"; public static final String UNRENT_REFUND_FAILED = "unrent.refund-failed"; public static final String UNRENT_ERROR = "unrent.error"; diff --git a/realty-paper/src/main/resources/messages.yml b/realty-paper/src/main/resources/messages.yml index 224b4a1..93e1712 100644 --- a/realty-paper/src/main/resources/messages.yml +++ b/realty-paper/src/main/resources/messages.yml @@ -619,6 +619,8 @@ unrent: $.' no-leasehold-contract: Region is not a leasehold. not-tenant: You are not the tenant of region . + terminating: Region is scheduled for termination + and can no longer be unrented. It ends on the scheduled date. update-failed: Failed to unrent region . refund-failed: ' Failed to process refund: ' error: ' Failed to unrent region: ' diff --git a/realty-paper/src/test/java/io/github/md5sha256/realty/api/RealtyPaperApiImplTest.java b/realty-paper/src/test/java/io/github/md5sha256/realty/api/RealtyPaperApiImplTest.java index abab5d7..7295dcf 100644 --- a/realty-paper/src/test/java/io/github/md5sha256/realty/api/RealtyPaperApiImplTest.java +++ b/realty-paper/src/test/java/io/github/md5sha256/realty/api/RealtyPaperApiImplTest.java @@ -387,6 +387,21 @@ void noLeaseholdContract() { Assertions.assertInstanceOf(RealtyPaperApi.UnrentResult.NoLeaseholdContract.class, result); } + @Test + @DisplayName("returns Terminating and leaves the region alone while an eviction is pending") + void terminating() { + when(realtyApi.unrentRegion(REGION_ID, WORLD_ID, TENANT_ID)) + .thenReturn(new RealtyBackend.UnrentResult.Terminating()); + + protectedRegion.getOwners().addPlayer(TENANT_ID); + + RealtyPaperApi.UnrentResult result = api.unrent(wgRegion, TENANT_ID).join(); + + Assertions.assertInstanceOf(RealtyPaperApi.UnrentResult.Terminating.class, result); + Assertions.assertEquals(1, protectedRegion.getOwners().size()); + verify(regionProfileService, never()).applyFlags(any(), any(), any()); + } + @Test @DisplayName("success clears owners and applies FOR_LEASE flags") void success() {