Drobne poprawki bezpieczenstwa z audytu
Deploy Polska Lokalnie / deploy (push) Successful in 12s

- /api/listings/favorites i /mine/** wymagaja logowania (anonimowo byl blad 500).
- Nowa rozmowa tylko o opublikowanym ogloszeniu.
- Licznik wyswietlen: jedno wyswietlenie na IP i ogloszenie na godzine.
- Rezygnacja z mailingu: GET pyta o potwierdzenie, wypisuje dopiero POST
  (tez z przycisku programu pocztowego); podpis porownywany w stalym czasie.
- Usuniecie konta usuwa jego ogloszenia.
- Haslo przy rejestracji min. 8 znakow; resend-phone-otp nie zdradza, czy konto istnieje.
- docker-compose.prod.yml bez domyslnego Admin123!; bez hasla konto admina
  nie jest zakladane (blad w logu).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-23 17:03:05 +02:00
parent a53cae7c37
commit ad1d573e89
18 changed files with 340 additions and 45 deletions
@@ -183,6 +183,7 @@ public class AdminController {
if (user.getRole() == Role.ADMIN) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Nie można usunąć konta administratora");
}
listingService.deleteAllOfOwner(user.getEmail());
userRepository.delete(user);
}
@@ -14,6 +14,7 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import pl.polskalokalnie.lead.LeadSyncService;
import pl.polskalokalnie.listing.ListingService;
import pl.polskalokalnie.moderation.TextModerationService;
import pl.polskalokalnie.auth.dto.AuthResponse;
import pl.polskalokalnie.auth.dto.LoginRequest;
@@ -52,6 +53,7 @@ public class AuthService {
private final PhoneVerificationService phoneVerificationService;
private final NickService nickService;
private final UserSettingsService userSettingsService;
private final ListingService listingService;
public AuthService(
UserRepository userRepository,
@@ -64,7 +66,8 @@ public class AuthService {
AccountEmailService accountEmailService,
PhoneVerificationService phoneVerificationService,
NickService nickService,
UserSettingsService userSettingsService
UserSettingsService userSettingsService,
ListingService listingService
) {
this.userRepository = userRepository;
this.blockedEmailRepository = blockedEmailRepository;
@@ -77,6 +80,7 @@ public class AuthService {
this.phoneVerificationService = phoneVerificationService;
this.nickService = nickService;
this.userSettingsService = userSettingsService;
this.listingService = listingService;
}
@Transactional
@@ -212,8 +216,12 @@ public class AuthService {
*/
public void resendPhoneOtp(String email) {
String normalized = normalizeEmail(email);
AppUser user = userRepository.findByEmailIgnoreCase(normalized)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Użytkownik nie istnieje"));
// Endpoint jest publiczny - dla nieznanego adresu odpowiadamy tak samo jak po wyslaniu kodu,
// zeby nie dalo sie nim sprawdzac, czy dany e-mail ma konto w serwisie.
AppUser user = userRepository.findByEmailIgnoreCase(normalized).orElse(null);
if (user == null) {
return;
}
if (user.getPhone() == null || user.getPhone().isBlank()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Konto nie ma przypisanego numeru telefonu");
}
@@ -435,6 +443,7 @@ public class AuthService {
}
userSettingsService.deleteForUser(user.getId());
listingService.deleteAllOfOwner(user.getEmail());
userRepository.delete(user);
}
@@ -43,9 +43,14 @@ public class SecurityConfig {
.requestMatchers(HttpMethod.GET, "/api/fx/rates").permitAll()
.requestMatchers(HttpMethod.POST, "/api/advertising-enquiries").permitAll()
.requestMatchers(HttpMethod.GET, "/api/gus/company").permitAll()
// GET pokazuje pytanie o potwierdzenie, POST wypisuje (takze z przycisku programu pocztowego).
.requestMatchers(HttpMethod.GET, "/api/unsubscribe").permitAll()
.requestMatchers(HttpMethod.POST, "/api/unsubscribe").permitAll()
.requestMatchers("/api/auth/me").authenticated()
.requestMatchers(HttpMethod.GET, "/api/listings/mine").authenticated()
// Prywatne listy pod /api/listings - przed publiczna regula GET /api/listings/**,
// ktora inaczej przepuszczala je anonimowo (konczylo sie bledem 500).
.requestMatchers(HttpMethod.GET, "/api/listings/mine", "/api/listings/mine/**",
"/api/listings/favorites").authenticated()
.requestMatchers(HttpMethod.GET, "/api/listings", "/api/listings/**").permitAll()
// Podglad ogloszenia dla crawlerow social media (Open Graph) - publiczny, tylko GET.
.requestMatchers(HttpMethod.GET, "/api/og/**").permitAll()
@@ -7,7 +7,7 @@ import pl.polskalokalnie.user.AccountType;
public record RegisterRequest(
@NotBlank @Email String email,
@NotBlank @Size(min = 6, max = 72) String password,
@NotBlank @Size(min = 8, max = 72) String password,
@NotBlank @Size(max = 120) String fullName,
AccountType accountType,
String phone,
@@ -147,7 +147,15 @@ public class DataSeeder implements CommandLineRunner {
String email = adminEmail.trim().toLowerCase();
AppUser existing = userRepository.findByEmailIgnoreCase(email).orElse(null);
// Bez hasla w konfiguracji nie zakladamy ani nie resetujemy konta - na produkcji nie ma wartosci
// domyslnej, a jawne haslo z repozytorium (Admin123!) nie moze trafic na serwer przez pomylke.
boolean passwordMissing = adminPassword == null || adminPassword.isBlank();
if (existing == null) {
if (passwordMissing) {
log.error("Brak APP_ADMIN_PASSWORD - konto administratora {} NIE zostalo utworzone.", email);
return;
}
AppUser admin = new AppUser();
admin.setEmail(email);
admin.setFullName(adminName);
@@ -164,6 +172,10 @@ public class DataSeeder implements CommandLineRunner {
if (!adminResetPassword) {
return;
}
if (passwordMissing) {
log.error("APP_ADMIN_RESET_PASSWORD=true, ale brak APP_ADMIN_PASSWORD - haslo konta {} bez zmian.", email);
return;
}
// Awaryjne przywrocenie dostepu: haslo, rola i aktywacja wracaja do stanu z konfiguracji.
// Blokada konta tez znika - inaczej reset hasla nie przywrocilby logowania.
@@ -1,5 +1,6 @@
package pl.polskalokalnie.listing;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import java.net.URI;
import java.util.List;
@@ -21,9 +22,11 @@ import org.springframework.web.bind.annotation.RestController;
public class ListingController {
private final ListingService listingService;
private final ListingViewGuard viewGuard;
public ListingController(ListingService listingService) {
public ListingController(ListingService listingService, ListingViewGuard viewGuard) {
this.listingService = listingService;
this.viewGuard = viewGuard;
}
/**
@@ -56,12 +59,16 @@ public class ListingController {
public ListingDetailResponse getById(
@PathVariable Long id,
@RequestParam(defaultValue = "false") boolean incrementView,
Authentication authentication
Authentication authentication,
HttpServletRequest request
) {
String viewerEmail = authentication == null ? null : authentication.getName();
boolean viewerIsAdmin = authentication != null && authentication.getAuthorities().stream()
.anyMatch(authority -> "ROLE_ADMIN".equals(authority.getAuthority()));
return listingService.getById(id, incrementView, viewerEmail, viewerIsAdmin);
// Adres klienta ustawia nginx (X-Real-IP) - backend nie jest osiagalny z pominieciem nginx.
String viewer = request.getHeader("X-Real-IP") != null ? request.getHeader("X-Real-IP") : request.getRemoteAddr();
boolean countView = incrementView && viewGuard.shouldCount(id, viewer);
return listingService.getById(id, countView, viewerEmail, viewerIsAdmin);
}
@PostMapping
@@ -75,7 +82,7 @@ public class ListingController {
.body(response);
}
// Edycja wlasnego ogloszenia (pelna aktualizacja pol; status bez zmian).
// Edycja wlasnego ogloszenia (pelna aktualizacja pol; istotna zmiana tresci wraca do moderacji).
@PutMapping("/{id}")
public ListingDetailResponse update(
@PathVariable Long id,
@@ -6,4 +6,6 @@ import org.springframework.data.jpa.repository.JpaRepository;
public interface ListingRepository extends JpaRepository<PropertyListing, Long> {
List<PropertyListing> findByOwnerEmailIgnoreCaseAndStatusOrderByCreatedAtDesc(String ownerEmail, ListingStatus status);
List<PropertyListing> findByOwnerEmailIgnoreCase(String ownerEmail);
}
@@ -342,6 +342,17 @@ public class ListingService {
listingRepository.deleteById(id);
}
/**
* Usuniecie wszystkich ogloszen konta - przy usuwaniu konta. Bez tego opublikowane ogloszenia
* (z imieniem i telefonem wlasciciela) zostawaly w serwisie bez wlasciciela.
*/
@Transactional
public void deleteAllOfOwner(String ownerEmail) {
if (ownerEmail != null && !ownerEmail.isBlank()) {
listingRepository.deleteAll(listingRepository.findByOwnerEmailIgnoreCase(ownerEmail));
}
}
private static String trimToNull(String value) {
if (value == null) {
return null;
@@ -0,0 +1,39 @@
package pl.polskalokalnie.listing;
import java.time.Duration;
import java.time.Instant;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import org.springframework.stereotype.Component;
/**
* Jedno wyswietlenie ogloszenia na adres IP na godzine. Licznik wyswietlen wplywa na ranking
* dzielnic i to, co widza sprzedajacy - bez tego dalo sie go dowolnie nabijac odswiezaniem strony.
* Pamiec jest ograniczona: po przekroczeniu limitu wpisow stare sa czyszczone.
*/
@Component
public class ListingViewGuard {
private static final Duration WINDOW = Duration.ofHours(1);
private static final int MAX_ENTRIES = 100_000;
private final Map<String, Instant> lastCounted = new ConcurrentHashMap<>();
/** true, gdy to wyswietlenie ma sie wliczyc do licznika. */
public boolean shouldCount(Long listingId, String viewer) {
Instant now = Instant.now();
if (lastCounted.size() > MAX_ENTRIES) {
lastCounted.values().removeIf(at -> at.isBefore(now.minus(WINDOW)));
if (lastCounted.size() > MAX_ENTRIES) {
lastCounted.clear();
}
}
String key = listingId + "|" + (viewer == null ? "" : viewer);
Instant previous = lastCounted.get(key);
if (previous != null && previous.isAfter(now.minus(WINDOW))) {
return false;
}
lastCounted.put(key, now);
return true;
}
}
@@ -59,6 +59,12 @@ public class ConversationService {
public ConversationDetail start(AppUser me, Long listingId, String content) {
PropertyListing listing = listingRepository.findById(listingId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Nie znaleziono ogłoszenia"));
// Nowa rozmowa tylko o opublikowanym ogloszeniu - inaczej kolejne numery ujawnialy tytul, cene
// i sprzedajacego ogloszen oczekujacych, wstrzymanych i odrzuconych. Trwajaca rozmowa zostaje.
if (listing.getStatus() != ListingStatus.APPROVED
&& conversationRepository.findByListingIdAndBuyerId(listingId, me.getId()).isEmpty()) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Nie znaleziono ogłoszenia");
}
AppUser seller = listing.getOwnerEmail() == null ? null
: userRepository.findByEmailIgnoreCase(listing.getOwnerEmail()).orElse(null);
if (seller == null) {
@@ -7,6 +7,7 @@ import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import pl.polskalokalnie.listing.ListingService;
import pl.polskalokalnie.message.Conversation;
import pl.polskalokalnie.message.ConversationRepository;
import pl.polskalokalnie.message.Message;
@@ -25,13 +26,16 @@ public class UserReportService {
private final UserRepository userRepository;
private final ConversationRepository conversationRepository;
private final MessageRepository messageRepository;
private final ListingService listingService;
public UserReportService(UserReportRepository userReportRepository, UserRepository userRepository,
ConversationRepository conversationRepository, MessageRepository messageRepository) {
ConversationRepository conversationRepository, MessageRepository messageRepository,
ListingService listingService) {
this.userReportRepository = userReportRepository;
this.userRepository = userRepository;
this.conversationRepository = conversationRepository;
this.messageRepository = messageRepository;
this.listingService = listingService;
}
@Transactional
@@ -102,6 +106,7 @@ public class UserReportService {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Nie można usunąć konta administratora");
}
listingService.deleteAllOfOwner(reported.getEmail());
userRepository.delete(reported);
report.setReportedUserDeleted(true);
@@ -2,9 +2,11 @@ package pl.polskalokalnie.unsubscribe;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.util.HtmlUtils;
/**
* Publiczny endpoint rezygnacji - link z tokenem doklejany do kazdej wiadomosci marketingowej.
@@ -20,17 +22,40 @@ public class UnsubscribeController {
this.unsubscribeService = unsubscribeService;
}
/**
* Otwarcie linku tylko pyta o potwierdzenie. Skanery poczty (antywirusy, podglad linkow) same
* otwieraja linki z maili - gdyby GET od razu wypisywal, ludzie tracili by subskrypcje bez klikniecia.
*/
@GetMapping(produces = MediaType.TEXT_HTML_VALUE)
public String unsubscribe(@RequestParam("token") String token) {
UnsubscribeService.Result result = unsubscribeService.unsubscribe(token);
String kanal = "sms".equals(result.channel()) ? "SMS" : "e-mail";
return page("Rezygnacja przyjęta",
"Twój adres" + (result.maskedContact().isEmpty() ? "" : " (" + result.maskedContact() + ")")
+ " został wypisany z wiadomości marketingowych (" + kanal + "). "
+ "Nie będziemy już wysyłać Ci kampanii tym kanałem.");
public String confirm(@RequestParam("token") String token) {
UnsubscribeService.Result result = unsubscribeService.preview(token);
String form = "<form method=\"post\" action=\"/api/unsubscribe?token=" + HtmlUtils.htmlEscape(token) + "\">"
+ "<button type=\"submit\">Wypisz mnie</button></form>";
return page("Rezygnacja z wiadomości",
"Czy wypisać " + contact(result) + " z wiadomości marketingowych (" + channel(result) + ")?", form);
}
private static String page(String title, String message) {
/**
* Wlasciwa rezygnacja. Token w adresie, wiec ten sam POST obsluguje tez przycisk "Wypisz sie"
* programow pocztowych (List-Unsubscribe-Post, RFC 8058).
*/
@PostMapping(produces = MediaType.TEXT_HTML_VALUE)
public String unsubscribe(@RequestParam("token") String token) {
UnsubscribeService.Result result = unsubscribeService.unsubscribe(token);
return page("Rezygnacja przyjęta",
"Twój adres " + contact(result) + " został wypisany z wiadomości marketingowych (" + channel(result) + "). "
+ "Nie będziemy już wysyłać Ci kampanii tym kanałem.", "");
}
private static String contact(UnsubscribeService.Result result) {
return result.maskedContact().isEmpty() ? "" : "(" + HtmlUtils.htmlEscape(result.maskedContact()) + ")";
}
private static String channel(UnsubscribeService.Result result) {
return "sms".equals(result.channel()) ? "SMS" : "e-mail";
}
private static String page(String title, String message, String action) {
return "<!doctype html><html lang=\"pl\"><head><meta charset=\"utf-8\">"
+ "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">"
+ "<title>" + title + "</title>"
@@ -38,7 +63,10 @@ public class UnsubscribeController {
+ "display:flex;min-height:100vh;align-items:center;justify-content:center;padding:24px}"
+ ".card{background:#fff;max-width:460px;padding:36px 32px;border-radius:16px;"
+ "box-shadow:0 12px 40px rgba(15,23,42,.12);text-align:center}"
+ "h1{font-size:20px;color:#0f172a;margin:0 0 12px}p{color:#475569;line-height:1.6;margin:0}</style>"
+ "</head><body><div class=\"card\"><h1>" + title + "</h1><p>" + message + "</p></div></body></html>";
+ "h1{font-size:20px;color:#0f172a;margin:0 0 12px}p{color:#475569;line-height:1.6;margin:0}"
+ "button{margin-top:20px;padding:12px 22px;border:0;border-radius:10px;background:#12a764;color:#fff;"
+ "font-size:15px;font-weight:600;cursor:pointer}</style>"
+ "</head><body><div class=\"card\"><h1>" + title + "</h1><p>" + message + "</p>" + action
+ "</div></body></html>";
}
}
@@ -41,28 +41,17 @@ public class UnsubscribeService {
return encodedPayload + "." + sign(encodedPayload);
}
/** Sprawdza link bez zmieniania czegokolwiek - do strony z pytaniem o potwierdzenie rezygnacji. */
public Result preview(String token) {
Target target = parse(token);
return new Result(target.channel(), maskContact(target.lead(), target.channel()));
}
@Transactional
public Result unsubscribe(String token) {
if (token == null || !token.contains(".")) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Nieprawidłowy link rezygnacji");
}
int dot = token.lastIndexOf('.');
String encodedPayload = token.substring(0, dot);
String signature = token.substring(dot + 1);
if (!sign(encodedPayload).equals(signature)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Link rezygnacji jest nieprawidłowy lub wygasł");
}
String payload = new String(URL_DECODER.decode(encodedPayload), StandardCharsets.UTF_8);
String[] parts = payload.split(":", 2);
if (parts.length != 2) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Nieprawidłowy link rezygnacji");
}
Long leadId = parseLeadId(parts[0]);
String channel = normalizeChannel(parts[1]);
Lead lead = leadRepository.findById(leadId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Kontakt nie istnieje"));
Target target = parse(token);
Lead lead = target.lead();
String channel = target.channel();
if ("sms".equals(channel)) {
lead.setSmsOptOut(true);
@@ -76,6 +65,38 @@ public class UnsubscribeService {
return new Result(channel, maskContact(lead, channel));
}
private record Target(Lead lead, String channel) {
}
private Target parse(String token) {
if (token == null || !token.contains(".")) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Nieprawidłowy link rezygnacji");
}
int dot = token.lastIndexOf('.');
String encodedPayload = token.substring(0, dot);
String signature = token.substring(dot + 1);
// Porownanie w stalym czasie - String.equals konczy sie na pierwszej roznicy i zdradza,
// ile poczatkowych znakow podpisu sie zgadza.
if (!java.security.MessageDigest.isEqual(sign(encodedPayload).getBytes(StandardCharsets.UTF_8),
signature.getBytes(StandardCharsets.UTF_8))) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Link rezygnacji jest nieprawidłowy lub wygasł");
}
String payload;
try {
payload = new String(URL_DECODER.decode(encodedPayload), StandardCharsets.UTF_8);
} catch (IllegalArgumentException ex) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Nieprawidłowy link rezygnacji");
}
String[] parts = payload.split(":", 2);
if (parts.length != 2) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Nieprawidłowy link rezygnacji");
}
Lead lead = leadRepository.findById(parseLeadId(parts[0]))
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Kontakt nie istnieje"));
return new Target(lead, normalizeChannel(parts[1]));
}
private String sign(String data) {
try {
Mac mac = Mac.getInstance(HMAC_ALGO);
@@ -0,0 +1,80 @@
package pl.polskalokalnie;
import static org.hamcrest.Matchers.containsString;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import pl.polskalokalnie.lead.Lead;
import pl.polskalokalnie.lead.LeadRepository;
import pl.polskalokalnie.unsubscribe.UnsubscribeService;
/** Publiczne endpointy - regresje z audytu bezpieczenstwa 2026-09-23. */
@SpringBootTest
@AutoConfigureMockMvc
class PublicEndpointsSecurityTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private LeadRepository leadRepository;
@Autowired
private UnsubscribeService unsubscribeService;
@Test
void privateListingListsRequireLogin() throws Exception {
// Regresja: publiczna regula GET /api/listings/** przepuszczala je anonimowo (blad 500).
mockMvc.perform(get("/api/listings/favorites")).andExpect(status().isForbidden());
mockMvc.perform(get("/api/listings/mine/saves")).andExpect(status().isForbidden());
}
@Test
void phoneCodeResendDoesNotRevealWhetherAccountExists() throws Exception {
// Regresja: nieznany adres dawal 404 "Uzytkownik nie istnieje".
mockMvc.perform(post("/api/auth/resend-phone-otp")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"email\":\"nikogo-takiego@example.com\"}"))
.andExpect(status().isNoContent());
}
@Test
void registrationRequiresEightCharacterPassword() throws Exception {
mockMvc.perform(post("/api/auth/register")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"email\":\"nowy@example.com\",\"password\":\"abc123\",\"fullName\":\"Jan Nowak\"}"))
.andExpect(status().isBadRequest());
}
@Test
void openingUnsubscribeLinkOnlyAsksForConfirmation() throws Exception {
// Regresja: sam GET (np. skaner linkow w poczcie) wypisywal z mailingu.
Lead lead = new Lead();
lead.setEmail("subskrybent@example.com");
lead.setEmailConsent(true);
lead = leadRepository.save(lead);
String token = unsubscribeService.generateToken(lead.getId(), "email");
mockMvc.perform(get("/api/unsubscribe").param("token", token))
.andExpect(status().isOk())
.andExpect(content().string(containsString("method=\"post\"")));
org.assertj.core.api.Assertions.assertThat(leadRepository.findById(lead.getId()).orElseThrow().isEmailOptOut()).isFalse();
mockMvc.perform(post("/api/unsubscribe").param("token", token))
.andExpect(status().isOk())
.andExpect(content().string(containsString("Rezygnacja przyjęta")));
org.assertj.core.api.Assertions.assertThat(leadRepository.findById(lead.getId()).orElseThrow().isEmailOptOut()).isTrue();
}
@Test
void tamperedUnsubscribeTokenIsRejected() throws Exception {
mockMvc.perform(post("/api/unsubscribe").param("token", "MTpbZW1haWw.nieprawidlowy-podpis"))
.andExpect(status().isBadRequest());
}
}
@@ -0,0 +1,19 @@
package pl.polskalokalnie.listing;
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test;
class ListingViewGuardTest {
@Test
void countsOneViewPerVisitorAndListing() {
// Regresja: kazde odswiezenie strony podbijalo licznik wyswietlen.
ListingViewGuard guard = new ListingViewGuard();
assertThat(guard.shouldCount(1L, "203.0.113.1")).isTrue();
assertThat(guard.shouldCount(1L, "203.0.113.1")).isFalse();
assertThat(guard.shouldCount(1L, "203.0.113.2")).isTrue();
assertThat(guard.shouldCount(2L, "203.0.113.1")).isTrue();
}
}
@@ -0,0 +1,48 @@
package pl.polskalokalnie.message;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.Optional;
import org.junit.jupiter.api.Test;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.http.HttpStatus;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.server.ResponseStatusException;
import pl.polskalokalnie.listing.ListingRepository;
import pl.polskalokalnie.listing.ListingStatus;
import pl.polskalokalnie.listing.PropertyListing;
import pl.polskalokalnie.moderation.TextModerationService;
import pl.polskalokalnie.user.AppUser;
import pl.polskalokalnie.user.UserRepository;
class ConversationStartTest {
@Test
void cannotStartConversationAboutUnpublishedListing() {
// Regresja: rozmowa o ogloszeniu oczekujacym/odrzuconym ujawniala jego tytul, cene i sprzedajacego.
ConversationRepository conversations = mock(ConversationRepository.class);
ListingRepository listings = mock(ListingRepository.class);
ConversationService service = new ConversationService(conversations, mock(MessageRepository.class),
mock(UserBlockRepository.class), mock(UserRepository.class), listings,
mock(TextModerationService.class), mock(ApplicationEventPublisher.class));
PropertyListing pending = new PropertyListing();
pending.setStatus(ListingStatus.PENDING);
pending.setOwnerEmail("sprzedajacy@example.com");
when(listings.findById(5L)).thenReturn(Optional.of(pending));
AppUser me = new AppUser();
ReflectionTestUtils.setField(me, "id", 1L);
when(conversations.findByListingIdAndBuyerId(5L, 1L)).thenReturn(Optional.empty());
assertThatThrownBy(() -> service.start(me, 5L, "Dzień dobry"))
.isInstanceOfSatisfying(ResponseStatusException.class,
ex -> assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND));
verify(conversations, never()).save(any());
}
}
+3 -1
View File
@@ -31,7 +31,9 @@ services:
# Bez tych zmiennych backend bierze wartosci domyslne z application.yml, wiec login i haslo
# administratora ustawione w .env nie mialy zadnego wplywu na dzialajaca aplikacje.
APP_ADMIN_EMAIL: ${APP_ADMIN_EMAIL:-admin@softspm.com}
APP_ADMIN_PASSWORD: ${APP_ADMIN_PASSWORD:-Admin123!}
# Bez wartosci domyslnej: brak hasla w .env.production oznacza, ze konto administratora nie
# zostanie zalozone (blad w logu), zamiast powstac z jawnym haslem z repozytorium.
APP_ADMIN_PASSWORD: ${APP_ADMIN_PASSWORD:-}
APP_ADMIN_NAME: ${APP_ADMIN_NAME:-Administrator Polska Lokalnie}
# true = przy starcie ustaw haslo administratora na APP_ADMIN_PASSWORD (odzyskanie dostepu).
APP_ADMIN_RESET_PASSWORD: ${APP_ADMIN_RESET_PASSWORD:-false}
+4 -4
View File
@@ -12724,7 +12724,7 @@ function LoginPage({ onLogin }: { onLogin: (user: AuthUser) => void }) {
value={password}
onChange={(event) => setPassword(event.target.value)}
autoComplete="new-password"
minLength={6}
minLength={8}
required
/>
<button
@@ -12746,7 +12746,7 @@ function LoginPage({ onLogin }: { onLogin: (user: AuthUser) => void }) {
value={confirmPassword}
onChange={(event) => setConfirmPassword(event.target.value)}
autoComplete="new-password"
minLength={6}
minLength={8}
required
/>
<button
@@ -12901,7 +12901,7 @@ function LoginPage({ onLogin }: { onLogin: (user: AuthUser) => void }) {
value={password}
onChange={(event) => setPassword(event.target.value)}
autoComplete="new-password"
minLength={6}
minLength={8}
required
/>
<button
@@ -12923,7 +12923,7 @@ function LoginPage({ onLogin }: { onLogin: (user: AuthUser) => void }) {
value={confirmPassword}
onChange={(event) => setConfirmPassword(event.target.value)}
autoComplete="new-password"
minLength={6}
minLength={8}
required
/>
<button