diff --git a/build.gradle b/build.gradle index 2d57854..7d69af9 100644 --- a/build.gradle +++ b/build.gradle @@ -149,6 +149,9 @@ dependencies { testImplementation 'org.junit-pioneer:junit-pioneer:2.2.0' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' +implementation 'org.springframework.boot:spring-boot-starter-security' +implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' +implementation 'org.springframework.session:spring-session-jdbc' implementation 'org.flywaydb:flyway-core' testImplementation 'com.h2database:h2' testImplementation 'org.mockito:mockito-core:5.3.1' diff --git a/src/main/java/school/hei/vola/endpoint/rest/controller/PaymentController.java b/src/main/java/school/hei/vola/endpoint/rest/controller/PaymentController.java index 4ecb109..ec2bc6c 100644 --- a/src/main/java/school/hei/vola/endpoint/rest/controller/PaymentController.java +++ b/src/main/java/school/hei/vola/endpoint/rest/controller/PaymentController.java @@ -67,16 +67,20 @@ public Payment getPayment( @GetMapping("/payments/export/csv") public ResponseEntity exportPaymentsCsv( - @RequestParam String adminKey, + @RequestParam(required = false) String adminKey, @RequestParam String applicationName, @RequestParam(required = false) String scope, @RequestParam(required = false) @DateTimeFormat(iso = ISO.DATE) LocalDate startDate, @RequestParam(required = false) @DateTimeFormat(iso = ISO.DATE) LocalDate endDate) { - adminAuthorizer.accept(adminKey); + if (adminKey != null && !adminKey.isBlank()) { + adminAuthorizer.accept(adminKey); + } + var normalizedScope = (scope == null || scope.isBlank()) ? null : scope; var start = startDate != null ? startDate.atStartOfDay(UTC).toInstant() : Instant.EPOCH; var end = endDate != null ? endDate.plusDays(1).atStartOfDay(UTC).toInstant() : Instant.now(); - var csv = paymentService.buildPaymentsCsv(applicationName, scope, start, end); + var csv = paymentService.buildPaymentsCsv(applicationName, normalizedScope, start, end); + var filename = "payments_" + applicationName + "_" + currentTimeMillis() + ".csv"; return ResponseEntity.ok() diff --git a/src/main/java/school/hei/vola/endpoint/rest/controller/PaymentViewController.java b/src/main/java/school/hei/vola/endpoint/rest/controller/PaymentViewController.java new file mode 100644 index 0000000..2d9ec35 --- /dev/null +++ b/src/main/java/school/hei/vola/endpoint/rest/controller/PaymentViewController.java @@ -0,0 +1,91 @@ +package school.hei.vola.endpoint.rest.controller; + +import static java.time.ZoneOffset.UTC; + +import java.time.Instant; +import java.time.LocalDate; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import school.hei.vola.repository.jpa.JApplicationRepository; +import school.hei.vola.service.PaymentService; + +@Controller +public class PaymentViewController { + + private final PaymentService paymentService; + private final JApplicationRepository jApplicationRepository; + + public PaymentViewController( + PaymentService paymentService, JApplicationRepository jApplicationRepository) { + this.paymentService = paymentService; + this.jApplicationRepository = jApplicationRepository; + } + + @GetMapping("/") + public String index() { + return "redirect:/payments"; + } + + @GetMapping("/payments") + public String paymentsPage( + @RequestParam(required = false) String applicationName, + @RequestParam(required = false) String scope, + @RequestParam(required = false) String startDate, + @RequestParam(required = false) String endDate, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "15") int size, + Model model) { + model.addAttribute("applications", jApplicationRepository.findAll()); + + var effectiveApp = normalizeFilter(applicationName); + var effectiveScope = normalizeFilter(scope); + var parsedStartDate = parseDate(startDate); + var parsedEndDate = parseDate(endDate); + + var start = + parsedStartDate != null ? parsedStartDate.atStartOfDay(UTC).toInstant() : Instant.EPOCH; + var end = + parsedEndDate != null + ? parsedEndDate.plusDays(1).atStartOfDay(UTC).toInstant() + : Instant.now(); + + var totalAmount = + paymentService.sumAmountForSucceeded(effectiveApp, effectiveScope, start, end); + var pendingCount = paymentService.countPending(effectiveApp, effectiveScope, start, end); + var totalCount = paymentService.countFiltered(effectiveApp, effectiveScope, start, end); + + var paymentsPage = + paymentService.findFilteredPage( + effectiveApp, + effectiveScope, + start, + end, + PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "creationInstant"))); + + model.addAttribute("payments", paymentsPage.getContent()); + model.addAttribute("totalCollected", String.format("%,d Ar", totalAmount)); + model.addAttribute("pendingCount", pendingCount); + model.addAttribute("totalCount", totalCount); + model.addAttribute("currentPage", page); + model.addAttribute("totalPages", paymentsPage.getTotalPages()); + model.addAttribute("pageSize", size); + model.addAttribute("scopes", paymentService.findDistinctScopes(effectiveApp)); + model.addAttribute("selectedApplication", effectiveApp); + model.addAttribute("selectedScope", effectiveScope); + model.addAttribute("selectedStartDate", parsedStartDate); + model.addAttribute("selectedEndDate", parsedEndDate); + return "payments"; + } + + private static String normalizeFilter(String value) { + return (value == null || value.isBlank() || "all".equals(value)) ? null : value; + } + + private static LocalDate parseDate(String dateStr) { + return (dateStr == null || dateStr.isBlank()) ? null : LocalDate.parse(dateStr); + } +} diff --git a/src/main/java/school/hei/vola/endpoint/rest/security/ApiKeyAuthenticationFilter.java b/src/main/java/school/hei/vola/endpoint/rest/security/ApiKeyAuthenticationFilter.java new file mode 100644 index 0000000..bf7324a --- /dev/null +++ b/src/main/java/school/hei/vola/endpoint/rest/security/ApiKeyAuthenticationFilter.java @@ -0,0 +1,54 @@ +package school.hei.vola.endpoint.rest.security; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.List; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.filter.OncePerRequestFilter; + +public class ApiKeyAuthenticationFilter extends OncePerRequestFilter { + + private final ApplicationAuthorizer applicationAuthorizer; + private final AdminAuthorizer adminAuthorizer; + + public ApiKeyAuthenticationFilter( + ApplicationAuthorizer applicationAuthorizer, AdminAuthorizer adminAuthorizer) { + this.applicationAuthorizer = applicationAuthorizer; + this.adminAuthorizer = adminAuthorizer; + } + + @Override + protected void doFilterInternal( + HttpServletRequest request, HttpServletResponse response, FilterChain chain) + throws ServletException, IOException { + var apiKey = request.getParameter("apiKey"); + var adminKey = request.getParameter("adminKey"); + + try { + if (apiKey != null && !apiKey.isBlank()) { + applicationAuthorizer.accept(apiKey); + SecurityContextHolder.getContext() + .setAuthentication( + UsernamePasswordAuthenticationToken.authenticated( + apiKey, null, List.of(new SimpleGrantedAuthority("ROLE_APP")))); + } else if (adminKey != null && !adminKey.isBlank()) { + adminAuthorizer.accept(adminKey); + SecurityContextHolder.getContext() + .setAuthentication( + UsernamePasswordAuthenticationToken.authenticated( + adminKey, null, List.of(new SimpleGrantedAuthority("ROLE_ADMIN")))); + } + } catch (UnauthorizedException e) { + SecurityContextHolder.clearContext(); + response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Invalid key"); + return; + } + + chain.doFilter(request, response); + } +} diff --git a/src/main/java/school/hei/vola/endpoint/rest/security/SecurityConf.java b/src/main/java/school/hei/vola/endpoint/rest/security/SecurityConf.java new file mode 100644 index 0000000..83f659a --- /dev/null +++ b/src/main/java/school/hei/vola/endpoint/rest/security/SecurityConf.java @@ -0,0 +1,69 @@ +package school.hei.vola.endpoint.rest.security; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.env.Environment; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.crypto.factory.PasswordEncoderFactories; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.provisioning.InMemoryUserDetailsManager; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +@Configuration +@EnableWebSecurity +public class SecurityConf { + + @Bean + public SecurityFilterChain filterChain( + HttpSecurity http, ApiKeyAuthenticationFilter apiKeyAuthenticationFilter) throws Exception { + http.addFilterBefore(apiKeyAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) + .authorizeHttpRequests( + auth -> + auth.requestMatchers( + "/ping", + "/health/**", + "/error", + "/orange/sync", + "/css/**", + "/js/**", + "/script/**", + "/style/**", + "/images/**") + .permitAll() + .requestMatchers("/payment", "/payments/search", "/orange/transactions/import") + .hasRole("APP") + .requestMatchers("/payments/**") + .hasRole("ADMIN") + .anyRequest() + .denyAll()) + .formLogin(login -> login.usernameParameter("email").defaultSuccessUrl("/payments", true)) + .logout(logout -> logout.logoutSuccessUrl("/")) + .csrf(csrf -> csrf.ignoringRequestMatchers("/payment", "/payments/search", "/orange/**")); + return http.build(); + } + + @Bean + public ApiKeyAuthenticationFilter apiKeyAuthenticationFilter( + ApplicationAuthorizer applicationAuthorizer, AdminAuthorizer adminAuthorizer) { + return new ApiKeyAuthenticationFilter(applicationAuthorizer, adminAuthorizer); + } + + @Bean + public UserDetailsService users(Environment env, PasswordEncoder passwordEncoder) { + return new InMemoryUserDetailsManager( + User.builder() + .username(env.getRequiredProperty("ADMIN_EMAIL")) + .password(passwordEncoder.encode(env.getRequiredProperty("ADMIN_PASSWORD"))) + .roles("ADMIN") + .build()); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return PasswordEncoderFactories.createDelegatingPasswordEncoder(); + } +} diff --git a/src/main/resources/static/script/filters.js b/src/main/resources/static/script/filters.js new file mode 100644 index 0000000..a1c5f8b --- /dev/null +++ b/src/main/resources/static/script/filters.js @@ -0,0 +1,66 @@ +(function () { + const scopeInput = document.getElementById('scope'); + const scopeDropdown = document.getElementById('scope-dropdown'); + const scopeOptions = [...scopeDropdown.querySelectorAll('.scope-option')]; + const scopeClear = document.getElementById('scope-clear'); + + function setScopeClearVisible() { + scopeClear.classList.toggle('hidden', !scopeInput.value); + } + + function filterAndShowScope() { + const query = scopeInput.value.toLowerCase(); + const hasVisible = scopeOptions.some(function (opt) { + const matches = opt.textContent.toLowerCase().includes(query); + opt.classList.toggle('hidden', !matches); + return matches; + }); + scopeDropdown.classList.toggle('hidden', !hasVisible); + } + + scopeInput.addEventListener('focus', filterAndShowScope); + scopeDropdown.addEventListener('mousedown', function (e) { e.preventDefault(); }); + scopeInput.addEventListener('blur', function () { + scopeDropdown.classList.add('hidden'); + }); + scopeInput.addEventListener('input', function () { + filterAndShowScope(); + setScopeClearVisible(); + }); + scopeClear.addEventListener('click', function () { + scopeInput.value = ''; + scopeInput.form.submit(); + }); + scopeOptions.forEach(function (opt) { + opt.addEventListener('click', function () { + scopeInput.value = opt.dataset.value; + scopeDropdown.classList.add('hidden'); + setScopeClearVisible(); + scopeInput.form.submit(); + }); + }); + setScopeClearVisible(); + + const appHidden = document.getElementById('applicationName'); + const appDisplay = document.getElementById('app-display'); + const appTrigger = document.getElementById('app-trigger'); + const appDropdown = document.getElementById('app-dropdown'); + const appOptions = [...appDropdown.querySelectorAll('.app-option')]; + + appTrigger.addEventListener('click', function (e) { + e.stopPropagation(); + appDropdown.classList.toggle('hidden'); + }); + appOptions.forEach(function (opt) { + opt.addEventListener('click', function () { + const val = opt.dataset.value; + appHidden.value = val; + appDisplay.textContent = val === 'all' ? 'Toutes les applications' : val; + appDropdown.classList.add('hidden'); + appHidden.form.submit(); + }); + }); + document.addEventListener('click', function () { + appDropdown.classList.add('hidden'); + }); +})(); diff --git a/src/main/resources/static/script/logout.js b/src/main/resources/static/script/logout.js new file mode 100644 index 0000000..a583b52 --- /dev/null +++ b/src/main/resources/static/script/logout.js @@ -0,0 +1,27 @@ +(function () { + var logoutBtn = document.getElementById('logout-btn'); + var logoutDialog = document.getElementById('logout-dialog'); + var cancelBtn = document.getElementById('logout-cancel-btn'); + + function openDialog() { + if (logoutDialog) logoutDialog.showModal(); + } + + function closeDialog() { + if (logoutDialog) logoutDialog.close(); + } + + if (logoutBtn) { + logoutBtn.addEventListener('click', openDialog); + } + + if (cancelBtn) { + cancelBtn.addEventListener('click', closeDialog); + } + + if (logoutDialog) { + logoutDialog.addEventListener('click', function (e) { + if (e.target === logoutDialog) closeDialog(); + }); + } +})(); diff --git a/src/main/resources/static/style/history.css b/src/main/resources/static/style/history.css new file mode 100644 index 0000000..f9be3dd --- /dev/null +++ b/src/main/resources/static/style/history.css @@ -0,0 +1,51 @@ +:root { + --color-primary: #800000; + --color-primary-dark: #530c08; + --color-primary-light: #8a0a0a; + --color-accent: #D32F2F; + --color-accent-dark: #b71c1c; + --color-success: #22c55e; + --color-error: #ef4444; + --color-warning: #eab308; + --color-psp-bg: #DBEAFE; + --color-psp-text: #1E40AF; + --color-page-bg: #F4EFE3; + --color-input-bg: #F0EDE8; + --color-border: #D4C5B5; + --color-border-light: #E0D5CA; + --color-text-dark: #3C3C3C; + --dot-white: rgba(255, 255, 255, 0.12); + --dot-white-dim: rgba(255, 255, 255, 0.08); + --dot-size: 14px; +} + +.psp-badge { + background-color: var(--color-psp-bg); + color: var(--color-psp-text); +} + +.border-left-succeeded { + border-left: 3px solid var(--color-success); +} + +.border-left-failed { + border-left: 3px solid var(--color-error); +} + +.border-left-verifying { + border-left: 3px solid var(--color-warning); +} + +.stat-card { + background-image: + radial-gradient(var(--dot-white) 1px, transparent 1px), + linear-gradient(135deg, var(--color-primary) 0%, var(--color-primary-dark) 100%); + background-size: var(--dot-size) var(--dot-size), cover; +} + +.app-header { + background-image: + radial-gradient(var(--dot-white-dim) 1px, transparent 1px), + linear-gradient(120deg, var(--color-primary-light) 0%, var(--color-primary) 45%, var(--color-primary-dark) 100%); + background-size: var(--dot-size) var(--dot-size), cover; +} diff --git a/src/main/resources/templates/fragments/filter-form.html b/src/main/resources/templates/fragments/filter-form.html new file mode 100644 index 0000000..f18ab10 --- /dev/null +++ b/src/main/resources/templates/fragments/filter-form.html @@ -0,0 +1,80 @@ + + + +
+
+ +
+ + +
+
Toutes les applications
+ arrow_drop_down +
+ +
+ + +
+ +
+ + + arrow_drop_down +
+ +
+ + +
+ + +
+
+ + +
+ + +
+ + + refresh + Réinitialiser + +
+
+
+ + diff --git a/src/main/resources/templates/fragments/header.html b/src/main/resources/templates/fragments/header.html new file mode 100644 index 0000000..1d18c6d --- /dev/null +++ b/src/main/resources/templates/fragments/header.html @@ -0,0 +1,61 @@ + + + +
+
+
+ + account_balance_wallet + +

Vola

+
+
+ + person + Admin + + +
+
+ + +
+
+
+ logout +
+

Confirmer la déconnexion

+

Voulez-vous vraiment vous déconnecter ?

+
+ +
+ + +
+ + + +
+
+ info +

Vous serez redirigé vers la page d'accueil après la déconnexion.

+
+
+
+
+
+ + diff --git a/src/main/resources/templates/fragments/pagination.html b/src/main/resources/templates/fragments/pagination.html new file mode 100644 index 0000000..f67af74 --- /dev/null +++ b/src/main/resources/templates/fragments/pagination.html @@ -0,0 +1,31 @@ + + + +
+ +
+ + diff --git a/src/main/resources/templates/fragments/stats-cards.html b/src/main/resources/templates/fragments/stats-cards.html new file mode 100644 index 0000000..5895d3c --- /dev/null +++ b/src/main/resources/templates/fragments/stats-cards.html @@ -0,0 +1,33 @@ + + + +
+
+
+
+ payments +

Total collecté

+
+

0 Ar

+

Au total

+
+
+
+ confirmation_number +

Nombre de paiements

+
+

0

+

Au total

+
+
+
+ av_timer +

En attente

+
+

0

+

Au total

+
+
+
+ + diff --git a/src/main/resources/templates/payments.html b/src/main/resources/templates/payments.html new file mode 100644 index 0000000..89257af --- /dev/null +++ b/src/main/resources/templates/payments.html @@ -0,0 +1,126 @@ + + + + Vola - Historique + + + + + + + + +
+ +
+
+

+ Historique des paiements +

+ +
+ +
+ +
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Date créationEmail payeurApplicationScopePSPMontantStatutDate vérificationRéférence OM
+ 2025-01-01 + 12:00:00 + + email@example.com + + App + + -- + + ORANGE_MONEY + + 0 Ar + + + En vérification + Succès + Échoué + + + + 2025-01-01 + 12:00:00 + + -- + + MP250917.1604.D33118 +
+
+ inbox + Aucun paiement trouvé +
+
+
+
+

+ swipe + Faites défiler horizontalement pour voir plus +

+
+
+ +
+
+ + + + diff --git a/src/test/java/school/hei/vola/conf/EnvConf.java b/src/test/java/school/hei/vola/conf/EnvConf.java index 7e53f3e..65d8fbd 100644 --- a/src/test/java/school/hei/vola/conf/EnvConf.java +++ b/src/test/java/school/hei/vola/conf/EnvConf.java @@ -12,5 +12,9 @@ void configureProperties(DynamicPropertyRegistry registry) { registry.add("spring.jpa.database-platform", () -> "org.hibernate.dialect.H2Dialect"); registry.add("orange.api.url", () -> apiUrl); registry.add("ADMIN_API_KEY", () -> "admin-api-key"); + registry.add("ADMIN_PASSWORD", () -> "test-password"); + registry.add("ADMIN_EMAIL", () -> "admin@cute.dev"); + registry.add("spring.session.store-type", () -> "jdbc"); + registry.add("spring.session.jdbc.initialize-schema", () -> "always"); } } diff --git a/src/test/java/school/hei/vola/endpoint/rest/controller/PaymentViewControllerIT.java b/src/test/java/school/hei/vola/endpoint/rest/controller/PaymentViewControllerIT.java new file mode 100644 index 0000000..f773ae6 --- /dev/null +++ b/src/test/java/school/hei/vola/endpoint/rest/controller/PaymentViewControllerIT.java @@ -0,0 +1,165 @@ +package school.hei.vola.endpoint.rest.controller; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Objects; +import java.util.regex.Pattern; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.util.LinkedMultiValueMap; +import school.hei.vola.conf.FacadeIT; +import school.hei.vola.repository.jpa.JApplicationRepository; +import school.hei.vola.repository.jpa.model.JApplication; + +class PaymentViewControllerIT extends FacadeIT { + + @Autowired private TestRestTemplate restTemplate; + @Autowired private JApplicationRepository jApplicationRepository; + + @Test + void unauthenticated_request_to_payments_shows_login_page() { + var response = restTemplate.getForEntity("/payments", String.class); + + assertEquals(200, response.getStatusCodeValue()); + assertNotNull(response.getBody()); + assertTrue(response.getBody().contains("Please sign in")); + } + + @Test + void authenticated_user_can_access_payments() { + var sessionCookie = fetchSessionCookie(); + var csrfToken = fetchCsrfToken(sessionCookie); + + var loginResponse = login(sessionCookie, csrfToken, "admin@cute.dev"); + assertEquals(302, loginResponse.getStatusCodeValue()); + assertEquals( + "/payments", Objects.requireNonNull(loginResponse.getHeaders().getLocation()).getPath()); + + var authCookie = extractSessionId(loginResponse); + var paymentsHeaders = new HttpHeaders(); + paymentsHeaders.add(HttpHeaders.COOKIE, authCookie); + var paymentsResponse = + restTemplate.exchange( + "/payments", HttpMethod.GET, new HttpEntity<>(paymentsHeaders), String.class); + + assertEquals(200, paymentsResponse.getStatusCodeValue()); + } + + @Test + void unknown_user_cannot_login() { + var sessionCookie = fetchSessionCookie(); + var csrfToken = fetchCsrfToken(sessionCookie); + + var loginResponse = login(sessionCookie, csrfToken, "nobody@user.guest"); + assertEquals(302, loginResponse.getStatusCodeValue()); + assertTrue( + Objects.requireNonNull(loginResponse.getHeaders().getLocation()) + .getPath() + .contains("/login")); + } + + @Test + void static_resources_are_accessible_without_authentication() { + var cssResponse = restTemplate.getForEntity("/style/history.css", String.class); + assertEquals(200, cssResponse.getStatusCodeValue()); + } + + @Test + void api_endpoint_with_invalid_adminKey_returns_401() { + var response = + restTemplate.getForEntity( + "/payments/export/csv?adminKey=wrong&applicationName=test", String.class); + assertEquals(401, response.getStatusCodeValue()); + } + + @Test + void app_api_key_cannot_access_admin_dashboard() { + var app = new JApplication(); + app.setId("app-klioba"); + app.setName("klioba"); + app.setApiKey("klioba-api-key"); + jApplicationRepository.save(app); + + var response = restTemplate.getForEntity("/payments?apiKey=klioba-api-key", String.class); + + assertEquals(403, response.getStatusCodeValue()); + } + + @Test + void export_with_adminKey_returns_csv() { + var response = + restTemplate.getForEntity( + "/payments/export/csv?adminKey=admin-api-key&applicationName=test", String.class); + + assertEquals(200, response.getStatusCodeValue()); + assertNotNull(response.getBody()); + } + + @Test + void admin_session_can_export_without_adminKey() { + var sessionCookie = fetchSessionCookie(); + var csrfToken = fetchCsrfToken(sessionCookie); + var loginResponse = login(sessionCookie, csrfToken, "admin@cute.dev"); + + var authCookie = extractSessionId(loginResponse); + var headers = new HttpHeaders(); + headers.add(HttpHeaders.COOKIE, authCookie); + var response = + restTemplate.exchange( + "/payments/export/csv?applicationName=test", + HttpMethod.GET, + new HttpEntity<>(headers), + String.class); + + assertEquals(200, response.getStatusCodeValue()); + } + + private String fetchSessionCookie() { + var loginPage = restTemplate.getForEntity("/login", String.class); + var setCookie = loginPage.getHeaders().getFirst(HttpHeaders.SET_COOKIE); + assertNotNull(setCookie); + return extractSessionId(loginPage); + } + + private String fetchCsrfToken(String sessionCookie) { + var headers = new HttpHeaders(); + headers.add(HttpHeaders.COOKIE, sessionCookie); + var loginPage = + restTemplate.exchange("/login", HttpMethod.GET, new HttpEntity<>(headers), String.class); + var token = extractCsrfToken(loginPage.getBody()); + assertNotNull(token); + return token; + } + + private ResponseEntity login(String sessionCookie, String csrfToken, String email) { + var headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + headers.add(HttpHeaders.COOKIE, sessionCookie); + + var formData = new LinkedMultiValueMap(); + formData.add("email", email); + formData.add("password", "test-password"); + formData.add("_csrf", csrfToken); + + return restTemplate.postForEntity("/login", new HttpEntity<>(formData, headers), String.class); + } + + private static String extractSessionId(ResponseEntity response) { + var cookie = response.getHeaders().getFirst(HttpHeaders.SET_COOKIE); + return cookie != null ? cookie.split(";")[0] : null; + } + + private static String extractCsrfToken(String html) { + var pattern = Pattern.compile("name=\"_csrf\"[^>]*value=\"([^\"]+)\""); + var matcher = pattern.matcher(html); + return matcher.find() ? matcher.group(1) : null; + } +}