diff --git a/backend/src/main/java/pl/polskalokalnie/admin/AdminController.java b/backend/src/main/java/pl/polskalokalnie/admin/AdminController.java index 5f5bf71..bf7ec66 100644 --- a/backend/src/main/java/pl/polskalokalnie/admin/AdminController.java +++ b/backend/src/main/java/pl/polskalokalnie/admin/AdminController.java @@ -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); } diff --git a/backend/src/main/java/pl/polskalokalnie/auth/AuthService.java b/backend/src/main/java/pl/polskalokalnie/auth/AuthService.java index cefefd3..40d8519 100644 --- a/backend/src/main/java/pl/polskalokalnie/auth/AuthService.java +++ b/backend/src/main/java/pl/polskalokalnie/auth/AuthService.java @@ -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); } diff --git a/backend/src/main/java/pl/polskalokalnie/auth/SecurityConfig.java b/backend/src/main/java/pl/polskalokalnie/auth/SecurityConfig.java index 0cb382e..ba87c9f 100644 --- a/backend/src/main/java/pl/polskalokalnie/auth/SecurityConfig.java +++ b/backend/src/main/java/pl/polskalokalnie/auth/SecurityConfig.java @@ -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() diff --git a/backend/src/main/java/pl/polskalokalnie/auth/dto/RegisterRequest.java b/backend/src/main/java/pl/polskalokalnie/auth/dto/RegisterRequest.java index 5a12f45..c16c96a 100644 --- a/backend/src/main/java/pl/polskalokalnie/auth/dto/RegisterRequest.java +++ b/backend/src/main/java/pl/polskalokalnie/auth/dto/RegisterRequest.java @@ -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, diff --git a/backend/src/main/java/pl/polskalokalnie/config/DataSeeder.java b/backend/src/main/java/pl/polskalokalnie/config/DataSeeder.java index 1142813..f59ecab 100644 --- a/backend/src/main/java/pl/polskalokalnie/config/DataSeeder.java +++ b/backend/src/main/java/pl/polskalokalnie/config/DataSeeder.java @@ -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. diff --git a/backend/src/main/java/pl/polskalokalnie/listing/ListingController.java b/backend/src/main/java/pl/polskalokalnie/listing/ListingController.java index 441e90f..47ff828 100644 --- a/backend/src/main/java/pl/polskalokalnie/listing/ListingController.java +++ b/backend/src/main/java/pl/polskalokalnie/listing/ListingController.java @@ -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, diff --git a/backend/src/main/java/pl/polskalokalnie/listing/ListingRepository.java b/backend/src/main/java/pl/polskalokalnie/listing/ListingRepository.java index 5eaea11..238123d 100644 --- a/backend/src/main/java/pl/polskalokalnie/listing/ListingRepository.java +++ b/backend/src/main/java/pl/polskalokalnie/listing/ListingRepository.java @@ -6,4 +6,6 @@ import org.springframework.data.jpa.repository.JpaRepository; public interface ListingRepository extends JpaRepository { List findByOwnerEmailIgnoreCaseAndStatusOrderByCreatedAtDesc(String ownerEmail, ListingStatus status); + + List findByOwnerEmailIgnoreCase(String ownerEmail); } diff --git a/backend/src/main/java/pl/polskalokalnie/listing/ListingService.java b/backend/src/main/java/pl/polskalokalnie/listing/ListingService.java index 0ee9f42..78eceb0 100644 --- a/backend/src/main/java/pl/polskalokalnie/listing/ListingService.java +++ b/backend/src/main/java/pl/polskalokalnie/listing/ListingService.java @@ -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; diff --git a/backend/src/main/java/pl/polskalokalnie/listing/ListingViewGuard.java b/backend/src/main/java/pl/polskalokalnie/listing/ListingViewGuard.java new file mode 100644 index 0000000..08828de --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/listing/ListingViewGuard.java @@ -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 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; + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/message/ConversationService.java b/backend/src/main/java/pl/polskalokalnie/message/ConversationService.java index c330242..c93cbe3 100644 --- a/backend/src/main/java/pl/polskalokalnie/message/ConversationService.java +++ b/backend/src/main/java/pl/polskalokalnie/message/ConversationService.java @@ -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) { diff --git a/backend/src/main/java/pl/polskalokalnie/report/UserReportService.java b/backend/src/main/java/pl/polskalokalnie/report/UserReportService.java index b17cad6..462ccbe 100644 --- a/backend/src/main/java/pl/polskalokalnie/report/UserReportService.java +++ b/backend/src/main/java/pl/polskalokalnie/report/UserReportService.java @@ -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); diff --git a/backend/src/main/java/pl/polskalokalnie/unsubscribe/UnsubscribeController.java b/backend/src/main/java/pl/polskalokalnie/unsubscribe/UnsubscribeController.java index bfcbe42..6b63df1 100644 --- a/backend/src/main/java/pl/polskalokalnie/unsubscribe/UnsubscribeController.java +++ b/backend/src/main/java/pl/polskalokalnie/unsubscribe/UnsubscribeController.java @@ -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 = "
" + + "
"; + 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 "" + "" + "" + 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}" - + "

" + title + "

" + message + "

"; + + "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}" + + "

" + title + "

" + message + "

" + action + + "
"; } } diff --git a/backend/src/main/java/pl/polskalokalnie/unsubscribe/UnsubscribeService.java b/backend/src/main/java/pl/polskalokalnie/unsubscribe/UnsubscribeService.java index c2c80b8..c516f3b 100644 --- a/backend/src/main/java/pl/polskalokalnie/unsubscribe/UnsubscribeService.java +++ b/backend/src/main/java/pl/polskalokalnie/unsubscribe/UnsubscribeService.java @@ -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); diff --git a/backend/src/test/java/pl/polskalokalnie/PublicEndpointsSecurityTest.java b/backend/src/test/java/pl/polskalokalnie/PublicEndpointsSecurityTest.java new file mode 100644 index 0000000..548fea0 --- /dev/null +++ b/backend/src/test/java/pl/polskalokalnie/PublicEndpointsSecurityTest.java @@ -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()); + } +} diff --git a/backend/src/test/java/pl/polskalokalnie/listing/ListingViewGuardTest.java b/backend/src/test/java/pl/polskalokalnie/listing/ListingViewGuardTest.java new file mode 100644 index 0000000..e905edb --- /dev/null +++ b/backend/src/test/java/pl/polskalokalnie/listing/ListingViewGuardTest.java @@ -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(); + } +} diff --git a/backend/src/test/java/pl/polskalokalnie/message/ConversationStartTest.java b/backend/src/test/java/pl/polskalokalnie/message/ConversationStartTest.java new file mode 100644 index 0000000..3efefd7 --- /dev/null +++ b/backend/src/test/java/pl/polskalokalnie/message/ConversationStartTest.java @@ -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()); + } +} diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 4136766..8f037ec 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -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} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 11dd27f..0a306cc 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 />