Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,4 @@ const val SECURITY_PATH_SEGMENT = "security.txt"
const val JOINT_LANDLORD_INVITATION_PATH_SEGMENT = "joint-landlord-invitation"
const val JOIN_PROPERTY_PATH_SEGMENT = "join-property"
const val SUBJECT_IDENTIFIER_PATH_SEGMENT = "subject-identifier"
const val REMOVE_EXPIRED_INVITE_PATH_SEGMENT = "remove-expired-invite"
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package uk.gov.communities.prsdb.webapp.constants.enums

enum class JointLandlordInvitationStatus {
PENDING,
EXPIRED,
HIDDEN,
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.servlet.ModelAndView
import org.springframework.web.servlet.mvc.support.RedirectAttributes
import org.springframework.web.util.UriTemplate
import uk.gov.communities.prsdb.webapp.annotations.webAnnotations.PrsdbController
import uk.gov.communities.prsdb.webapp.config.interceptors.BackLinkInterceptor.Companion.overrideBackLinkForUrl
Expand All @@ -19,6 +20,7 @@ import uk.gov.communities.prsdb.webapp.constants.LANDLORD_DETAILS_FRAGMENT
import uk.gov.communities.prsdb.webapp.constants.LANDLORD_PATH_SEGMENT
import uk.gov.communities.prsdb.webapp.constants.LOCAL_COUNCIL_PATH_SEGMENT
import uk.gov.communities.prsdb.webapp.constants.PROPERTY_DETAILS_SEGMENT
import uk.gov.communities.prsdb.webapp.constants.REMOVE_EXPIRED_INVITE_PATH_SEGMENT
import uk.gov.communities.prsdb.webapp.controllers.LandlordController.Companion.LANDLORD_DASHBOARD_URL
import uk.gov.communities.prsdb.webapp.controllers.LocalCouncilDashboardController.Companion.LOCAL_COUNCIL_DASHBOARD_URL
import uk.gov.communities.prsdb.webapp.helpers.DateTimeHelper
Expand Down Expand Up @@ -108,6 +110,19 @@ class PropertyDetailsController(
return modelAndView
}

@PreAuthorize("hasRole('LANDLORD')")
@GetMapping(REMOVE_EXPIRED_INVITE_ROUTE)
fun removeExpiredInvite(
@PathVariable propertyOwnershipId: Long,
@PathVariable invitationId: Long,
redirectAttributes: RedirectAttributes,
): String {
val baseUserId = SecurityContextHolder.getContext().authentication.name
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

copilot wanted this a POST but I didn't really see the point of wrapping the link in a form to make that work

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, but I agree with Copilot here - although it is really an issue with the design (we've explained that state changes = button, and that GDS really doesn't like buttons styled as links...). As it stands though if a user clicks on that link twice in quick succession (before the first response has arrived) they'll get a 404 page, because browsers don't protect against accidental repeated GET requests. The security side of things is less important because hiding an already expired invite is low stakes - but just having the code smell of GETs that make state changes as a precedent risks others copying it in higher stakes cases where we want the protection of a CSRF token.

My suggestion would be that we merge as-is but we a) create a ticket to add a confirmation page, b) put a bit TODO & health warning here about it being a GET when it should be a POST, just in case that change never materialises, c) ask the designers for a confirmation page (and maybe suggest this should be a 'delete' or 'cancel' rather than a 'hide' action to make clear that it's destructive).

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tbf have spotted this won't trigger a 404, we'll just set hidden = true again even though it was already true. The wider point still stands though

jointLandlordInvitationService.hideExpiredInvitation(invitationId, baseUserId)
redirectAttributes.addFlashAttribute("inviteRemoved", true)
return "redirect:${getPropertyDetailsPath(propertyOwnershipId)}#$LANDLORD_DETAILS_FRAGMENT"
}

@PreAuthorize("hasAnyRole('LOCAL_COUNCIL_USER', 'LOCAL_COUNCIL_ADMIN')")
@GetMapping(LOCAL_COUNCIL_PROPERTY_DETAILS_ROUTE)
fun getPropertyDetailsLocalCouncilView(
Expand Down Expand Up @@ -166,6 +181,8 @@ class PropertyDetailsController(
companion object {
const val LANDLORD_PROPERTY_DETAILS_ROUTE = "/$LANDLORD_PATH_SEGMENT/$PROPERTY_DETAILS_SEGMENT/{propertyOwnershipId}"

const val REMOVE_EXPIRED_INVITE_ROUTE = "$LANDLORD_PROPERTY_DETAILS_ROUTE/$REMOVE_EXPIRED_INVITE_PATH_SEGMENT/{invitationId}"

const val LOCAL_COUNCIL_PROPERTY_DETAILS_ROUTE = "/$LOCAL_COUNCIL_PATH_SEGMENT/$PROPERTY_DETAILS_SEGMENT/{propertyOwnershipId}"

fun getPropertyDetailsPath(
Expand All @@ -178,5 +195,10 @@ class PropertyDetailsController(

fun getPropertyCompliancePath(propertyOwnershipId: Long): String =
UriTemplate("$LANDLORD_PROPERTY_DETAILS_ROUTE#$COMPLIANCE_INFO_FRAGMENT").expand(propertyOwnershipId).toASCIIString()

fun getRemoveExpiredInvitePath(
propertyOwnershipId: Long,
invitationId: Long,
): String = UriTemplate(REMOVE_EXPIRED_INVITE_ROUTE).expand(propertyOwnershipId, invitationId).toASCIIString()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import kotlinx.datetime.plus
import kotlinx.datetime.toJavaLocalDate
import kotlinx.datetime.toKotlinInstant
import uk.gov.communities.prsdb.webapp.constants.JOINT_LANDLORD_INVITATION_LIFETIME_IN_DAYS
import uk.gov.communities.prsdb.webapp.constants.enums.JointLandlordInvitationStatus
import uk.gov.communities.prsdb.webapp.helpers.DateTimeHelper
import java.time.temporal.ChronoUnit
import java.util.UUID
Expand Down Expand Up @@ -41,13 +42,16 @@ class JointLandlordInvitation(
lateinit var invitingLandlord: Landlord
private set

@Column(nullable = false)
var isHidden: Boolean = false

val expiresOnDate: LocalDate
get() =
DateTimeHelper
.getDateInUK(createdDate.toKotlinInstant())
.plus(DatePeriod(days = JOINT_LANDLORD_INVITATION_LIFETIME_IN_DAYS))

val isExpired: Boolean
private val isExpired: Boolean
get() = DateTimeHelper().getCurrentDateInUK() > expiresOnDate

val daysUntilExpiry: Long
Expand All @@ -58,6 +62,14 @@ class JointLandlordInvitation(
expiresOnDate.toJavaLocalDate(),
).coerceAtLeast(0)

val status: JointLandlordInvitationStatus
get() =
when {
isHidden -> JointLandlordInvitationStatus.HIDDEN
isExpired -> JointLandlordInvitationStatus.EXPIRED
else -> JointLandlordInvitationStatus.PENDING
}

constructor(
token: UUID,
email: String,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package uk.gov.communities.prsdb.webapp.models.viewModels
import kotlinx.datetime.LocalDate
import kotlinx.datetime.toJavaLocalDate
import kotlinx.datetime.toKotlinInstant
import uk.gov.communities.prsdb.webapp.controllers.PropertyDetailsController
import uk.gov.communities.prsdb.webapp.database.entity.JointLandlordInvitation
import uk.gov.communities.prsdb.webapp.helpers.DateTimeHelper
import java.time.format.DateTimeFormatter
Expand All @@ -16,8 +17,10 @@ data class PendingInvitationViewModel(
)

data class ExpiredInvitationViewModel(
val invitationId: Long,
val email: String,
val expiredDate: String,
val removeFromListUrl: String,
)

class InvitationViewModelBuilder {
Expand All @@ -34,8 +37,14 @@ class InvitationViewModelBuilder {

fun buildExpiredViewModel(invitation: JointLandlordInvitation): ExpiredInvitationViewModel =
ExpiredInvitationViewModel(
invitationId = invitation.id,
email = invitation.invitedEmail,
expiredDate = formatDate(invitation.expiresOnDate),
removeFromListUrl =
PropertyDetailsController.getRemoveExpiredInvitePath(
invitation.registeredOwnership.id,
invitation.id,
),
)

private fun formatDate(date: LocalDate): String = date.toJavaLocalDate().format(DATE_FORMATTER)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
package uk.gov.communities.prsdb.webapp.services

import jakarta.servlet.http.HttpSession
import jakarta.transaction.Transactional
import org.springframework.http.HttpStatus
import org.springframework.web.server.ResponseStatusException
import uk.gov.communities.prsdb.webapp.annotations.webAnnotations.PrsdbWebService
import uk.gov.communities.prsdb.webapp.constants.JOINT_LANDLORD_INVITATION_TOKEN_WITH_ACCEPTANCE_JOURNEY_IDS
import uk.gov.communities.prsdb.webapp.constants.USER_SENT_TO_LANDLORD_REGISTRATION_WHILE_ACCEPTING_JOINT_LANDLORD_INVITATION
import uk.gov.communities.prsdb.webapp.constants.enums.JointLandlordInvitationStatus
import uk.gov.communities.prsdb.webapp.database.entity.JointLandlordInvitation
import uk.gov.communities.prsdb.webapp.database.entity.Landlord
import uk.gov.communities.prsdb.webapp.database.entity.PropertyOwnership
Expand All @@ -21,12 +25,14 @@ class JointLandlordInvitationService(
fun getPendingAndExpiredInvitations(
propertyOwnership: PropertyOwnership,
): Pair<List<JointLandlordInvitation>, List<JointLandlordInvitation>> {
val (expired, pending) =
val grouped =
invitationRepository
.findByRegisteredOwnership(propertyOwnership)
.sortedByDescending { it.createdDate }
.partition { it.isExpired }
return Pair(pending, expired) // flips the above pair from expired, pending to pending, expired
.groupBy { it.status }
val pending = grouped[JointLandlordInvitationStatus.PENDING].orEmpty()
val expired = grouped[JointLandlordInvitationStatus.EXPIRED].orEmpty()
return Pair(pending, expired)
}

fun sendInvitationEmails(
Expand Down Expand Up @@ -111,4 +117,26 @@ class JointLandlordInvitationService(
@Suppress("UNCHECKED_CAST")
private fun <T1, T2> getListOfPairsFromSession(sessionAttributeName: String): MutableList<Pair<T1, T2>>? =
session.getAttribute(sessionAttributeName) as? MutableList<Pair<T1, T2>>

@Transactional
fun hideExpiredInvitation(
invitationId: Long,
baseUserId: String,
) {
val invitation =
invitationRepository.findById(invitationId).orElseThrow {
ResponseStatusException(HttpStatus.NOT_FOUND, "Invitation with id $invitationId was not found")
}

if (invitation.registeredOwnership.primaryLandlord.baseUser.id != baseUserId) {
throw ResponseStatusException(HttpStatus.FORBIDDEN, "User is not authorized to modify this invitation")
}

if (invitation.status != JointLandlordInvitationStatus.EXPIRED) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Only expired invitations can be hidden")
}

invitation.isHidden = true
invitationRepository.save(invitation)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE joint_landlord_invitation ADD COLUMN is_hidden BOOLEAN NOT NULL DEFAULT FALSE;
3 changes: 3 additions & 0 deletions src/main/resources/messages/propertyDetails.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ landlordDetails:
expiredOn: 'Expired on {0}'
sendNewInvitationEmail: Send a new invitation email
removeFromList: Remove from list
removedBanner:
title: Success
content: Expired invitation removed.
complianceInformation:
heading: Compliance information
certificateStatus: Certificate status
Expand Down
13 changes: 11 additions & 2 deletions src/main/resources/templates/propertyDetailsView.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,16 @@
<a href="#" th:replace="~{fragments/forms/backLink :: backLink(${backUrl})}">Back link</a>
<main class="govuk-main-wrapper">
<div id="page-contents">
<th:block th:if="${inviteRemoved}" th:with="isSuccess=true">
<div id="invite-removed-banner"
th:replace="~{fragments/banners/notificationBanner ::
notificationBanner(#{propertyDetails.landlordDetails.invitations.expiredInvitations.removedBanner.title}, ~{::#invite-removed-banner/content()})}"
th:with="isSuccess=true">
<p class="govuk-body" th:text="#{propertyDetails.landlordDetails.invitations.expiredInvitations.removedBanner.content}">
propertyDetails.landlordDetails.invitations.expiredInvitations.removedBanner.content
</p>
</div>
</th:block>
<div id="notification-banner"
th:replace="${complianceDetails != null && #lists.size(complianceDetails.notificationMessages) > 0 } ? ~{fragments/banners/notificationBanner ::
notificationBanner(#{propertyDetails.complianceInformation.notificationBanner.title}, ~{::#notification-banner/content()})} : ~{}">
Expand Down Expand Up @@ -133,8 +143,7 @@ <h3 class="govuk-heading-s" th:text="#{propertyDetails.landlordDetails.registere
th:text="#{propertyDetails.landlordDetails.invitations.expiredInvitations.sendNewInvitationEmail}">propertyDetails.landlordDetails.invitations.expiredInvitations.sendNewInvitationEmail</a>
</li>
<li class="prsdb-link-group-item">
<!--/* TODO: PDJB-302 - Implement remove from list action */-->
<a class="govuk-link" href="#"
<a class="govuk-link" th:href="${invitation.removeFromListUrl}"
th:text="#{propertyDetails.landlordDetails.invitations.expiredInvitations.removeFromList}">propertyDetails.landlordDetails.invitations.expiredInvitations.removeFromList</a>
</li>
</ul>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -184,4 +184,33 @@ class PropertyDetailsControllerTests(
}
}
}

@Nested
inner class RemoveExpiredInviteTests {
@Test
fun `removeExpiredInvite returns a redirect for an unauthenticated user`() {
mvc.get(PropertyDetailsController.getRemoveExpiredInvitePath(1L, 1L)).andExpect {
status { is3xxRedirection() }
}
}

@Test
@WithMockUser
fun `removeExpiredInvite returns 403 for an unauthorized user`() {
mvc.get(PropertyDetailsController.getRemoveExpiredInvitePath(1L, 1L)).andExpect {
status { status { isForbidden() } }
}
}

@Test
@WithMockUser(roles = ["LANDLORD"])
fun `removeExpiredInvite redirects to property details with flash attribute on success`() {
mvc.get(PropertyDetailsController.getRemoveExpiredInvitePath(1L, 1L)).andExpect {
status { is3xxRedirection() }
flash { attribute("inviteRemoved", true) }
}

verify(jointLandlordInvitationService).hideExpiredInvitation(eq(1L), any())
}
}
}
Original file line number Diff line number Diff line change
@@ -1,35 +1,35 @@
package uk.gov.communities.prsdb.webapp.database.entity

import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import uk.gov.communities.prsdb.webapp.constants.JOINT_LANDLORD_INVITATION_LIFETIME_IN_DAYS
import uk.gov.communities.prsdb.webapp.constants.enums.JointLandlordInvitationStatus
import uk.gov.communities.prsdb.webapp.testHelpers.mockObjects.MockJointLandlordData
import java.time.Instant
import java.time.temporal.ChronoUnit
import kotlin.test.assertEquals

class JointLandlordInvitationTests {
@Test
fun `isExpired returns false when the current day is earlier than the expiry date`() {
fun `status returns PENDING when the current day is earlier than the expiry date`() {
val createdDate = Instant.now().minus((JOINT_LANDLORD_INVITATION_LIFETIME_IN_DAYS - 1).toLong(), ChronoUnit.DAYS)
val invitation = MockJointLandlordData.createJointLandlordInvitation(createdDate = createdDate)

assertFalse(invitation.isExpired)
assertEquals(invitation.status, JointLandlordInvitationStatus.PENDING)
}

@Test
fun `isExpired returns false when the current day equals the expiry date`() {
fun `status returns PENDING when the current day equals the expiry date`() {
val createdDate = Instant.now().minus(JOINT_LANDLORD_INVITATION_LIFETIME_IN_DAYS.toLong(), ChronoUnit.DAYS)
val invitation = MockJointLandlordData.createJointLandlordInvitation(createdDate = createdDate)

assertFalse(invitation.isExpired)
assertEquals(invitation.status, JointLandlordInvitationStatus.PENDING)
}

@Test
fun `isExpired returns true when the current day is later than the expiry date`() {
fun `status returns EXPIRED when the current day is later than the expiry date`() {
val createdDate = Instant.now().minus((JOINT_LANDLORD_INVITATION_LIFETIME_IN_DAYS + 1).toLong(), ChronoUnit.DAYS)
val invitation = MockJointLandlordData.createJointLandlordInvitation(createdDate = createdDate)

assertTrue(invitation.isExpired)
assertEquals(invitation.status, JointLandlordInvitationStatus.EXPIRED)
}
}
Loading
Loading