Sesje sprawdzane w bazie, e-mail ogloszeniodawcy niepubliczny, zgody z formularza
Deploy Polska Lokalnie / deploy (push) Successful in 12s
Deploy Polska Lokalnie / deploy (push) Successful in 12s
- JwtAuthFilter bierze role i stan konta z bazy: blokada, usuniecie konta
i odebranie roli admina dzialaja od razu. Zmiana i reset hasla koncza
dotychczasowe sesje (sessionsValidFrom); zmiana hasla zwraca nowy token.
- Publiczne odpowiedzi ogloszen bez ownerEmail/contactEmail; inne oferty
sprzedajacego z GET /api/listings/{id}/seller-listings.
- Publiczny formularz reklamowy nie zmienia zgod ani nazwy istniejacego leada.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -166,6 +166,8 @@ public class AdminController {
|
||||
|
||||
String temporaryPassword = generateTemporaryPassword();
|
||||
user.setPasswordHash(passwordEncoder.encode(temporaryPassword));
|
||||
// Reset przez administracje (np. przy przejetym koncie) konczy wszystkie sesje uzytkownika.
|
||||
user.revokeSessions();
|
||||
userRepository.save(user);
|
||||
notificationService.create(user.getEmail(), "system", "lock",
|
||||
"Hasło zostało zresetowane",
|
||||
|
||||
@@ -112,10 +112,10 @@ public class AuthController {
|
||||
|
||||
// --- Operacje na wlasnym koncie z /konto/ustawienia ---
|
||||
|
||||
// Zmiana hasla konczy wszystkie dotychczasowe sesje - odpowiedz niesie nowy token dla biezacej.
|
||||
@PostMapping("/change-password")
|
||||
public ResponseEntity<Void> changePassword(Authentication authentication, @Valid @RequestBody ChangePasswordRequest request) {
|
||||
authService.changePassword(authentication.getName(), request.currentPassword(), request.newPassword());
|
||||
return ResponseEntity.noContent().build();
|
||||
public AuthResponse changePassword(Authentication authentication, @Valid @RequestBody ChangePasswordRequest request) {
|
||||
return authService.changePassword(authentication.getName(), request.currentPassword(), request.newPassword());
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -191,6 +191,8 @@ public class AuthService {
|
||||
AppUser user = userRepository.findByEmailIgnoreCase(authToken.getUserEmail())
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Użytkownik nie istnieje"));
|
||||
user.setPasswordHash(passwordEncoder.encode(newPassword));
|
||||
// Reset hasla konczy wszystkie dotychczasowe sesje - takze te przejete przez intruza.
|
||||
user.revokeSessions();
|
||||
// Reset hasla potwierdza tez adres e-mail - konto staje sie aktywne.
|
||||
user.setVerified(true);
|
||||
userRepository.save(user);
|
||||
@@ -330,7 +332,7 @@ public class AuthService {
|
||||
* wiec przejeta sesja nie pozwala przejac konta na stale.
|
||||
*/
|
||||
@Transactional
|
||||
public void changePassword(String email, String currentPassword, String newPassword) {
|
||||
public AuthResponse changePassword(String email, String currentPassword, String newPassword) {
|
||||
AppUser user = userRepository.findByEmailIgnoreCase(email)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Sesja wygasła"));
|
||||
|
||||
@@ -357,6 +359,8 @@ public class AuthService {
|
||||
|
||||
user.setPasswordHash(passwordEncoder.encode(newPassword));
|
||||
user.setPasswordChangedAt(Instant.now());
|
||||
// Inne sesje (np. przejete) koncza sie od razu; biezaca dostaje nowy token w odpowiedzi.
|
||||
user.revokeSessions();
|
||||
userRepository.save(user);
|
||||
|
||||
// Niewykorzystane linki resetu przestaja dzialac po recznej zmianie hasla.
|
||||
@@ -366,6 +370,7 @@ public class AuthService {
|
||||
authTokenRepository.save(old);
|
||||
}
|
||||
});
|
||||
return buildAuthResponse(user);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,6 +6,7 @@ import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import java.io.IOException;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import org.springframework.lang.NonNull;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
@@ -14,14 +15,18 @@ import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
import pl.polskalokalnie.user.AppUser;
|
||||
import pl.polskalokalnie.user.UserRepository;
|
||||
|
||||
@Component
|
||||
public class JwtAuthFilter extends OncePerRequestFilter {
|
||||
|
||||
private final JwtService jwtService;
|
||||
private final UserRepository userRepository;
|
||||
|
||||
public JwtAuthFilter(JwtService jwtService) {
|
||||
public JwtAuthFilter(JwtService jwtService, UserRepository userRepository) {
|
||||
this.jwtService = jwtService;
|
||||
this.userRepository = userRepository;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -34,12 +39,15 @@ public class JwtAuthFilter extends OncePerRequestFilter {
|
||||
if (token != null) {
|
||||
try {
|
||||
Claims claims = jwtService.parse(token);
|
||||
String email = claims.getSubject();
|
||||
String role = claims.get("role", String.class);
|
||||
var authorities = List.of(new SimpleGrantedAuthority("ROLE_" + role));
|
||||
var authentication = new UsernamePasswordAuthenticationToken(email, null, authorities);
|
||||
// Stan konta bierzemy z bazy, a nie z tokenu: zablokowanie, usuniecie konta, odebranie
|
||||
// roli administratora i zmiana hasla dzialaja od razu, a nie dopiero po wygasnieciu tokenu.
|
||||
AppUser user = userRepository.findByEmailIgnoreCase(claims.getSubject()).orElse(null);
|
||||
if (user != null && isSessionValid(user, claims.getIssuedAt() == null ? null : claims.getIssuedAt().toInstant())) {
|
||||
var authorities = List.of(new SimpleGrantedAuthority("ROLE_" + user.getRole().name()));
|
||||
var authentication = new UsernamePasswordAuthenticationToken(user.getEmail(), null, authorities);
|
||||
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
|
||||
SecurityContextHolder.getContext().setAuthentication(authentication);
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
// Nieprawidlowy lub wygasly token: zadanie leci dalej jako nieuwierzytelnione.
|
||||
SecurityContextHolder.clearContext();
|
||||
@@ -48,6 +56,14 @@ public class JwtAuthFilter extends OncePerRequestFilter {
|
||||
filterChain.doFilter(request, response);
|
||||
}
|
||||
|
||||
static boolean isSessionValid(AppUser user, Instant issuedAt) {
|
||||
if (user.isBlocked() || user.getRole() == null) {
|
||||
return false;
|
||||
}
|
||||
Instant validFrom = user.getSessionsValidFrom();
|
||||
return validFrom == null || (issuedAt != null && !issuedAt.isBefore(validFrom));
|
||||
}
|
||||
|
||||
// Token z naglowka Authorization, a dla SSE (EventSource nie ustawia naglowkow) - z parametru access_token.
|
||||
private String resolveToken(HttpServletRequest request) {
|
||||
String header = request.getHeader("Authorization");
|
||||
|
||||
@@ -231,7 +231,11 @@ public class LeadService {
|
||||
lead.setPhone(phone);
|
||||
}
|
||||
}
|
||||
// Formularz jest publiczny i nie potwierdza, ze wysylajacy jest wlascicielem adresu - wpisanie
|
||||
// cudzego e-maila nie moze nadpisac nazwy istniejacego kontaktu.
|
||||
if (lead.getId() == null || lead.getName() == null) {
|
||||
lead.setName(trimToNull(request.fullName()));
|
||||
}
|
||||
|
||||
List<String> tags = new ArrayList<>(lead.getTags());
|
||||
tags.add(ADVERTISING_TAG);
|
||||
@@ -239,9 +243,12 @@ public class LeadService {
|
||||
lead.setTags(cleanTags(tags));
|
||||
|
||||
// Zgoda z formularza dotyczy kontaktu z oferta - obejmuje oba kanaly, bo kontakt moze byc
|
||||
// telefoniczny. Raz cofnietej zgody nie przywracamy tutaj po cichu.
|
||||
// telefoniczny. Tylko dla NOWEGO kontaktu: formularz jest anonimowy, wiec wpisujac cudzy
|
||||
// e-mail lub telefon daloby sie komus przywrocic cofnieta zgode (albo ja odebrac).
|
||||
if (lead.getId() == null) {
|
||||
lead.setEmailConsent(consent);
|
||||
lead.setSmsConsent(consent);
|
||||
}
|
||||
lead.setNotes(appendEnquiryNote(lead.getNotes(), request, lead, email, phone));
|
||||
leadRepository.save(lead);
|
||||
}
|
||||
|
||||
@@ -40,6 +40,12 @@ public class ListingController {
|
||||
return listingService.search(city, offerType, propertyType, directOnly);
|
||||
}
|
||||
|
||||
// Inne oferty sprzedajacego - publiczne jak same ogloszenia (GET /api/listings/**).
|
||||
@GetMapping("/{id}/seller-listings")
|
||||
public List<ListingResponse> sellerListings(@PathVariable Long id) {
|
||||
return listingService.sellerListings(id);
|
||||
}
|
||||
|
||||
@GetMapping("/mine")
|
||||
public List<ListingResponse> mine(Authentication authentication) {
|
||||
return listingService.findMine(authentication.getName());
|
||||
|
||||
@@ -72,7 +72,7 @@ public record ListingDetailResponse(
|
||||
private static final double METERS_PER_DEGREE = 111_320d;
|
||||
|
||||
public static ListingDetailResponse from(PropertyListing listing, Long ownerId, String ownerAvatar) {
|
||||
return from(listing, ownerId, ownerAvatar, null, null, null, null, false);
|
||||
return from(listing, ownerId, ownerAvatar, null, null, null, null, false, false);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -80,10 +80,14 @@ public record ListingDetailResponse(
|
||||
* wlasciciel i administracja), z odpowiedzi znikaja ulica oraz numer budynku, a wspolrzedne sa
|
||||
* przesuwane w obrebie obszaru. Przesuniecie liczymy z identyfikatora ogloszenia, wiec dla danej
|
||||
* oferty jest zawsze takie samo - inaczej kolo skakaloby po mapie przy kazdym odswiezeniu.
|
||||
*
|
||||
* Gdy {@code publicView} jest true (oglada ktos inny niz wlasciciel i administracja), znika adres
|
||||
* e-mail konta wlasciciela - zarowno ownerEmail, jak i contactEmail, ktory formularz wypelnia
|
||||
* adresem konta. E-mail jest loginem do serwisu, a publiczny kontakt to telefon i wiadomosci.
|
||||
*/
|
||||
public static ListingDetailResponse from(PropertyListing listing, Long ownerId, String ownerAvatar,
|
||||
String ownerAccountType, String ownerName, Instant ownerCreatedAt, Instant ownerLastActiveAt,
|
||||
boolean masked) {
|
||||
boolean masked, boolean publicView) {
|
||||
String street = masked ? null : listing.getStreet();
|
||||
String building = masked ? null : listing.getBuilding();
|
||||
String address = masked ? approximateAddress(listing) : listing.getAddress();
|
||||
@@ -111,7 +115,7 @@ public record ListingDetailResponse(
|
||||
listing.getOwnership(),
|
||||
listing.getContactName(),
|
||||
listing.getContactPhone(),
|
||||
listing.getContactEmail(),
|
||||
publicView ? null : listing.getContactEmail(),
|
||||
listing.getRentExtra(),
|
||||
listing.getAvailableFrom(),
|
||||
listing.getFurnishing(),
|
||||
@@ -126,7 +130,7 @@ public record ListingDetailResponse(
|
||||
List.copyOf(listing.getAmenities()),
|
||||
List.copyOf(listing.getPhotos()),
|
||||
listing.getVirtualTourUrl(),
|
||||
listing.getOwnerEmail(),
|
||||
publicView ? null : listing.getOwnerEmail(),
|
||||
ownerId,
|
||||
ownerAvatar,
|
||||
ownerAccountType,
|
||||
|
||||
@@ -61,7 +61,7 @@ public class ListingFavoriteController {
|
||||
// Ogloszenie skasowane albo odrzucone po zapisaniu po prostu znika z listy.
|
||||
return listingRepository.findAllById(listingIds).stream()
|
||||
.filter(listing -> listing.getStatus() == ListingStatus.APPROVED)
|
||||
.map(listing -> ListingResponse.from(listing, listing.isApproximateLocation()))
|
||||
.map(ListingResponse::publicFrom)
|
||||
.toList();
|
||||
}
|
||||
|
||||
|
||||
@@ -30,15 +30,20 @@ public record ListingResponse(
|
||||
Instant promotedUntil,
|
||||
Instant createdAt
|
||||
) {
|
||||
/** Pelna wersja - dla panelu wlasciciela ("moje ogloszenia") i administracji. */
|
||||
public static ListingResponse from(PropertyListing listing) {
|
||||
return from(listing, false);
|
||||
return build(listing, false, listing.getOwnerEmail());
|
||||
}
|
||||
|
||||
/**
|
||||
* @param masked pokaz sama okolice zamiast adresu - dla ogloszen z ukryta dokladna lokalizacja
|
||||
* ogladanych spoza panelu wlasciciela i administracji.
|
||||
* Wersja publiczna (wyszukiwarka, ulubione, inne oferty sprzedajacego): sama okolica zamiast
|
||||
* adresu dla ogloszen z ukryta lokalizacja i bez e-maila wlasciciela - to login do serwisu.
|
||||
*/
|
||||
public static ListingResponse from(PropertyListing listing, boolean masked) {
|
||||
public static ListingResponse publicFrom(PropertyListing listing) {
|
||||
return build(listing, listing.isApproximateLocation(), null);
|
||||
}
|
||||
|
||||
private static ListingResponse build(PropertyListing listing, boolean masked, String ownerEmail) {
|
||||
String district = listing.getDistrict();
|
||||
String address = masked
|
||||
? (district == null || district.isBlank() ? listing.getCity() : district + ", " + listing.getCity())
|
||||
@@ -60,7 +65,7 @@ public record ListingResponse(
|
||||
listing.getMarket(),
|
||||
listing.getYearBuilt(),
|
||||
listing.getCoverPhoto(),
|
||||
listing.getOwnerEmail(),
|
||||
ownerEmail,
|
||||
listing.getStatus(),
|
||||
listing.getViewsCount(),
|
||||
listing.getPromotedUntil(),
|
||||
|
||||
@@ -52,21 +52,22 @@ public class ListingService {
|
||||
* Ogloszenia demonstracyjne nie maja konta w serwisie - wtedy oba pola sa puste.
|
||||
*/
|
||||
private ListingDetailResponse detailOf(PropertyListing listing) {
|
||||
return detailOf(listing, false);
|
||||
return detailOf(listing, false, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param masked ukryj dokladny adres (ulica, numer, wspolrzedne) - dla ogloszen, ktorych
|
||||
* wlasciciel wybral publikowanie samej przyblizonej lokalizacji.
|
||||
* @param publicView odpowiedz dla kogos innego niz wlasciciel i administracja - bez adresu e-mail konta.
|
||||
*/
|
||||
private ListingDetailResponse detailOf(PropertyListing listing, boolean masked) {
|
||||
private ListingDetailResponse detailOf(PropertyListing listing, boolean masked, boolean publicView) {
|
||||
AppUser owner = ownerOf(listing);
|
||||
if (owner == null) {
|
||||
return ListingDetailResponse.from(listing, null, null, null, null, null, null, masked);
|
||||
return ListingDetailResponse.from(listing, null, null, null, null, null, null, masked, publicView);
|
||||
}
|
||||
String ownerAccountType = owner.getAccountType() == null ? null : owner.getAccountType().name();
|
||||
return ListingDetailResponse.from(listing, owner.getId(), userSettingsService.avatarFor(owner.getId()),
|
||||
ownerAccountType, owner.getFullName(), owner.getCreatedAt(), owner.getLastActiveAt(), masked);
|
||||
ownerAccountType, owner.getFullName(), owner.getCreatedAt(), owner.getLastActiveAt(), masked, publicView);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -97,7 +98,7 @@ public class ListingService {
|
||||
.filter(listing -> propertyType == null || listing.getPropertyType() == propertyType)
|
||||
.filter(listing -> !directOnly || isDirectOffer(listing))
|
||||
.sorted(promotedFirst())
|
||||
.map(listing -> ListingResponse.from(listing, listing.isApproximateLocation()))
|
||||
.map(ListingResponse::publicFrom)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@@ -134,8 +135,29 @@ public class ListingService {
|
||||
listing = listingRepository.save(listing);
|
||||
}
|
||||
|
||||
// Wlasciciel i administracja widza pelny adres - reszta tylko przyblizona lokalizacje.
|
||||
return detailOf(listing, listing.isApproximateLocation() && !isOwner && !viewerIsAdmin);
|
||||
// Wlasciciel i administracja widza pelny adres i e-mail konta. Reszta - tylko przyblizona
|
||||
// lokalizacje i bez e-maila (to login do serwisu; kontakt idzie przez wiadomosci i telefon).
|
||||
boolean publicView = !isOwner && !viewerIsAdmin;
|
||||
return detailOf(listing, listing.isApproximateLocation() && publicView, publicView);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inne opublikowane oferty wlasciciela danego ogloszenia. Porownanie po e-mailu odbywa sie
|
||||
* na serwerze, zeby adres wlasciciela nie musial trafiac do przegladarki.
|
||||
*/
|
||||
public List<ListingResponse> sellerListings(Long listingId) {
|
||||
PropertyListing listing = listingRepository.findById(listingId).orElse(null);
|
||||
if (listing == null || listing.getStatus() != ListingStatus.APPROVED
|
||||
|| listing.getOwnerEmail() == null || listing.getOwnerEmail().isBlank()) {
|
||||
return List.of();
|
||||
}
|
||||
return listingRepository
|
||||
.findByOwnerEmailIgnoreCaseAndStatusOrderByCreatedAtDesc(listing.getOwnerEmail(), ListingStatus.APPROVED)
|
||||
.stream()
|
||||
.filter(other -> !other.getId().equals(listingId))
|
||||
.limit(6)
|
||||
.map(ListingResponse::publicFrom)
|
||||
.toList();
|
||||
}
|
||||
|
||||
public List<ListingResponse> findMine(String ownerEmail) {
|
||||
|
||||
@@ -95,6 +95,10 @@ public class AppUser {
|
||||
// odstep miedzy zmianami; null oznacza, ze haslo nie bylo zmieniane od zalozenia konta.
|
||||
private Instant passwordChangedAt;
|
||||
|
||||
// Tokeny sesji wydane przed tym momentem sa odrzucane - ustawiane przy zmianie i resecie hasla,
|
||||
// zeby przejeta sesja nie przetrwala zmiany hasla. null = wszystkie niewygasle tokeny wazne.
|
||||
private Instant sessionsValidFrom;
|
||||
|
||||
// Ostatnia aktywnosc uzytkownika (logowanie oraz kazde wejscie do aplikacji odswiezajace /auth/me).
|
||||
// Na karcie sprzedajacego pokazujemy z tego "ostatnio aktywny/a"; null = jeszcze nie odnotowano.
|
||||
private Instant lastActiveAt;
|
||||
@@ -261,6 +265,15 @@ public class AppUser {
|
||||
this.passwordChangedAt = passwordChangedAt;
|
||||
}
|
||||
|
||||
public Instant getSessionsValidFrom() {
|
||||
return sessionsValidFrom;
|
||||
}
|
||||
|
||||
/** Uniewaznia wszystkie dotychczasowe sesje. JWT ma dokladnosc do sekundy - stad obciecie. */
|
||||
public void revokeSessions() {
|
||||
this.sessionsValidFrom = Instant.now().truncatedTo(java.time.temporal.ChronoUnit.SECONDS);
|
||||
}
|
||||
|
||||
public Instant getLastActiveAt() {
|
||||
return lastActiveAt;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package pl.polskalokalnie.auth;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.time.Instant;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import pl.polskalokalnie.user.AppUser;
|
||||
import pl.polskalokalnie.user.Role;
|
||||
|
||||
class JwtAuthFilterTest {
|
||||
|
||||
private AppUser user() {
|
||||
AppUser user = new AppUser();
|
||||
user.setEmail("user@example.com");
|
||||
user.setRole(Role.USER);
|
||||
return user;
|
||||
}
|
||||
|
||||
@Test
|
||||
void activeUserWithFreshTokenIsAccepted() {
|
||||
assertThat(JwtAuthFilter.isSessionValid(user(), Instant.now())).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void blockedUserLosesAccessImmediately() {
|
||||
// Regresja: blokada dzialala tylko przy logowaniu - wydany token dzialal jeszcze do 24 h.
|
||||
AppUser user = user();
|
||||
user.setBlocked(true);
|
||||
assertThat(JwtAuthFilter.isSessionValid(user, Instant.now())).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void tokenIssuedBeforePasswordChangeIsRejected() {
|
||||
AppUser user = user();
|
||||
Instant oldToken = Instant.now().minusSeconds(60);
|
||||
user.revokeSessions();
|
||||
|
||||
assertThat(JwtAuthFilter.isSessionValid(user, oldToken)).isFalse();
|
||||
// Token wydany w tej samej sekundzie co zmiana (nowa sesja z odpowiedzi) jest wazny.
|
||||
assertThat(JwtAuthFilter.isSessionValid(user, user.getSessionsValidFrom())).isTrue();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package pl.polskalokalnie.lead;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.util.Optional;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
class AdvertisingEnquiryConsentTest {
|
||||
|
||||
private final LeadRepository repository = mock(LeadRepository.class);
|
||||
private final LeadService service = new LeadService(repository);
|
||||
|
||||
private AdvertisingEnquiryRequest enquiry(String name) {
|
||||
return new AdvertisingEnquiryRequest(name, "Firma", "ofiara@example.com", "500600700",
|
||||
"Remonty", null, true);
|
||||
}
|
||||
|
||||
@Test
|
||||
void anonymousFormDoesNotRestoreWithdrawnConsentOfExistingContact() {
|
||||
// Regresja: wpisanie cudzego e-maila w publicznym formularzu przywracalo mu zgody marketingowe.
|
||||
Lead existing = new Lead();
|
||||
ReflectionTestUtils.setField(existing, "id", 5L);
|
||||
existing.setEmail("ofiara@example.com");
|
||||
existing.setName("Prawdziwe Nazwisko");
|
||||
existing.setEmailConsent(false);
|
||||
existing.setSmsConsent(false);
|
||||
when(repository.findFirstByEmailIgnoreCase("ofiara@example.com")).thenReturn(Optional.of(existing));
|
||||
when(repository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
service.registerAdvertisingEnquiry(enquiry("<a href=https://evil>Kliknij</a>"));
|
||||
|
||||
assertThat(existing.isEmailConsent()).isFalse();
|
||||
assertThat(existing.isSmsConsent()).isFalse();
|
||||
assertThat(existing.getName()).isEqualTo("Prawdziwe Nazwisko");
|
||||
}
|
||||
|
||||
@Test
|
||||
void newContactGetsConsentFromForm() {
|
||||
when(repository.findFirstByEmailIgnoreCase(any())).thenReturn(Optional.empty());
|
||||
when(repository.findFirstByPhone(any())).thenReturn(Optional.empty());
|
||||
Lead[] saved = new Lead[1];
|
||||
when(repository.save(any())).thenAnswer(inv -> saved[0] = inv.getArgument(0));
|
||||
|
||||
service.registerAdvertisingEnquiry(enquiry("Jan Kowalski"));
|
||||
|
||||
assertThat(saved[0].isEmailConsent()).isTrue();
|
||||
assertThat(saved[0].isSmsConsent()).isTrue();
|
||||
assertThat(saved[0].getName()).isEqualTo("Jan Kowalski");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package pl.polskalokalnie.listing;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class ListingPrivacyTest {
|
||||
|
||||
private PropertyListing listing() {
|
||||
PropertyListing listing = new PropertyListing();
|
||||
listing.setOwnerEmail("wlasciciel@example.com");
|
||||
listing.setContactEmail("wlasciciel@example.com");
|
||||
listing.setStatus(ListingStatus.APPROVED);
|
||||
return listing;
|
||||
}
|
||||
|
||||
@Test
|
||||
void publicListDoesNotExposeOwnerEmail() {
|
||||
// Regresja: anonimowe GET /api/listings zwracalo e-mail (login) kazdego ogloszeniodawcy.
|
||||
assertThat(ListingResponse.publicFrom(listing()).ownerEmail()).isNull();
|
||||
assertThat(ListingResponse.from(listing()).ownerEmail()).isEqualTo("wlasciciel@example.com");
|
||||
}
|
||||
|
||||
@Test
|
||||
void publicDetailHidesAccountEmailButOwnerSeesIt() {
|
||||
ListingDetailResponse publicView = ListingDetailResponse.from(listing(), 7L, null, null, null, null, null, false, true);
|
||||
assertThat(publicView.ownerEmail()).isNull();
|
||||
assertThat(publicView.contactEmail()).isNull();
|
||||
assertThat(publicView.ownerId()).isEqualTo(7L);
|
||||
|
||||
ListingDetailResponse ownerView = ListingDetailResponse.from(listing(), 7L, null, null, null, null, null, false, false);
|
||||
assertThat(ownerView.ownerEmail()).isEqualTo("wlasciciel@example.com");
|
||||
assertThat(ownerView.contactEmail()).isEqualTo("wlasciciel@example.com");
|
||||
}
|
||||
}
|
||||
+9
-14
@@ -27125,25 +27125,20 @@ function ListingDetailPage({
|
||||
};
|
||||
}, [listingId]);
|
||||
|
||||
// Inne oferty tego samego sprzedajacego (po ownerEmail) - minimalistyczna lista na dole strony.
|
||||
// Inne oferty tego samego sprzedajacego - minimalistyczna lista na dole strony. Dopasowanie po
|
||||
// wlascicielu robi serwer, bo e-mail wlasciciela nie jest publiczny.
|
||||
useEffect(() => {
|
||||
const owner = listing?.ownerEmail?.trim().toLowerCase();
|
||||
if (!owner) {
|
||||
const currentId = listing?.id;
|
||||
if (currentId == null) {
|
||||
setSellerListings([]);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
apiFetch<ApiListingSummary[]>('/listings')
|
||||
.then((all) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
apiFetch<ApiListingSummary[]>(`/listings/${currentId}/seller-listings`)
|
||||
.then((items) => {
|
||||
if (!cancelled) {
|
||||
setSellerListings(items);
|
||||
}
|
||||
setSellerListings(
|
||||
all
|
||||
.filter((item) => item.id !== listing?.id && item.ownerEmail
|
||||
&& item.ownerEmail.trim().toLowerCase() === owner)
|
||||
.slice(0, 6),
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
@@ -27153,7 +27148,7 @@ function ListingDetailPage({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [listing?.ownerEmail, listing?.id]);
|
||||
}, [listing?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!listing) {
|
||||
|
||||
@@ -300,12 +300,13 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
return updated;
|
||||
}, []);
|
||||
|
||||
// Zmiana hasla konczy wszystkie dotychczasowe sesje - serwer oddaje nowy token dla tej.
|
||||
const changePassword = useCallback(async (currentPassword: string, newPassword: string) => {
|
||||
await apiFetch<void>('/auth/change-password', {
|
||||
applyAuth(await apiFetch<AuthResponse>('/auth/change-password', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ currentPassword, newPassword }),
|
||||
});
|
||||
}, []);
|
||||
}));
|
||||
}, [applyAuth]);
|
||||
|
||||
// Sprawdzenie dotychczasowego hasla bez zmiany czegokolwiek. Rzuca bledem, gdy haslo jest zle.
|
||||
const verifyPassword = useCallback(async (password: string) => {
|
||||
|
||||
Reference in New Issue
Block a user