From 9a386c4ed4fd7820dadef7958c1e779c26da6442 Mon Sep 17 00:00:00 2001 From: Mathieu-bot Date: Mon, 27 Jul 2026 23:37:27 +0300 Subject: [PATCH 01/15] build: add thymeleaf for local admin UI dev, temporary until Poja bot adds it --- build.gradle | 1 + 1 file changed, 1 insertion(+) diff --git a/build.gradle b/build.gradle index ba7cc269..04c75430 100644 --- a/build.gradle +++ b/build.gradle @@ -150,6 +150,7 @@ implementation 'org.flywaydb:flyway-core' testImplementation 'com.h2database:h2' testImplementation 'org.mockito:mockito-core:5.3.1' implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.5.0' +implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' implementation 'org.postgresql:postgresql' implementation("org.apache.poi:poi:5.2.5") implementation("org.apache.poi:poi-ooxml:5.2.5") From 80b796a38693d85ce649edddb7ab06b82f23e8d9 Mon Sep 17 00:00:00 2001 From: Mathieu-bot Date: Mon, 27 Jul 2026 23:37:34 +0300 Subject: [PATCH 02/15] feat: add admin dashboard templates and static assets --- src/main/resources/static/script/filters.js | 66 +++++++++++ .../templates/fragments/filter-form.html | 80 +++++++++++++ .../resources/templates/fragments/header.html | 19 +++ .../templates/fragments/pagination.html | 31 +++++ .../templates/fragments/stats-cards.html | 33 ++++++ src/main/resources/templates/payments.html | 110 ++++++++++++++++++ 6 files changed, 339 insertions(+) create mode 100644 src/main/resources/static/script/filters.js create mode 100644 src/main/resources/templates/fragments/filter-form.html create mode 100644 src/main/resources/templates/fragments/header.html create mode 100644 src/main/resources/templates/fragments/pagination.html create mode 100644 src/main/resources/templates/fragments/stats-cards.html create mode 100644 src/main/resources/templates/payments.html diff --git a/src/main/resources/static/script/filters.js b/src/main/resources/static/script/filters.js new file mode 100644 index 00000000..91ab3fa2 --- /dev/null +++ b/src/main/resources/static/script/filters.js @@ -0,0 +1,66 @@ +(function () { + var scopeInput = document.getElementById('scope'); + var scopeDropdown = document.getElementById('scope-dropdown'); + var scopeOptions = [...scopeDropdown.querySelectorAll('.scope-option')]; + var scopeClear = document.getElementById('scope-clear'); + + function setScopeClearVisible() { + scopeClear.classList.toggle('hidden', !scopeInput.value); + } + + function filterAndShowScope() { + var query = scopeInput.value.toLowerCase(); + var hasVisible = scopeOptions.some(function (opt) { + var 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(); + + var appHidden = document.getElementById('applicationName'); + var appDisplay = document.getElementById('app-display'); + var appTrigger = document.getElementById('app-trigger'); + var appDropdown = document.getElementById('app-dropdown'); + var appOptions = [...appDropdown.querySelectorAll('.app-option')]; + + appTrigger.addEventListener('click', function (e) { + e.stopPropagation(); + appDropdown.classList.toggle('hidden'); + }); + appOptions.forEach(function (opt) { + opt.addEventListener('click', function () { + var val = opt.dataset.value; + appHidden.value = val === 'all' ? '' : 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/templates/fragments/filter-form.html b/src/main/resources/templates/fragments/filter-form.html new file mode 100644 index 00000000..f18ab109 --- /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 00000000..9d6954a5 --- /dev/null +++ b/src/main/resources/templates/fragments/header.html @@ -0,0 +1,19 @@ + + + +
+
+
+ + account_balance_wallet + +

Vola

+
+ + person + Admin + +
+
+ + diff --git a/src/main/resources/templates/fragments/pagination.html b/src/main/resources/templates/fragments/pagination.html new file mode 100644 index 00000000..50b97744 --- /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 00000000..5895d3c4 --- /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 00000000..7e7bdbbc --- /dev/null +++ b/src/main/resources/templates/payments.html @@ -0,0 +1,110 @@ + + + + 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 +

+
+
+ +
+
+ + + From 7ab697fcb989168d848bc0dcb6500a319d4d93a6 Mon Sep 17 00:00:00 2001 From: Mathieu-bot Date: Mon, 27 Jul 2026 23:39:19 +0300 Subject: [PATCH 03/15] feat: add PaymentViewController and DateParser for admin dashboard --- .../controller/PaymentViewController.java | 84 +++++++++++++++++++ .../hei/vola/service/utils/DateParser.java | 15 ++++ 2 files changed, 99 insertions(+) create mode 100644 src/main/java/school/hei/vola/endpoint/rest/controller/PaymentViewController.java create mode 100644 src/main/java/school/hei/vola/service/utils/DateParser.java 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 00000000..171483d2 --- /dev/null +++ b/src/main/java/school/hei/vola/endpoint/rest/controller/PaymentViewController.java @@ -0,0 +1,84 @@ +package school.hei.vola.endpoint.rest.controller; + +import static java.time.ZoneOffset.UTC; + +import java.time.Instant; +import lombok.RequiredArgsConstructor; +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; +import school.hei.vola.service.utils.DateParser; + +@Controller +@RequiredArgsConstructor +public class PaymentViewController { + + private static final int PAGE_SIZE = 15; + + private final PaymentService paymentService; + private final 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, + Model model) { + model.addAttribute("applications", jApplicationRepository.findAll()); + + var effectiveApp = normalizeFilter(applicationName); + var effectiveScope = normalizeFilter(scope); + var parsedStartDate = DateParser.parseDate(startDate); + var parsedEndDate = DateParser.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, 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", PAGE_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; + } +} diff --git a/src/main/java/school/hei/vola/service/utils/DateParser.java b/src/main/java/school/hei/vola/service/utils/DateParser.java new file mode 100644 index 00000000..98095660 --- /dev/null +++ b/src/main/java/school/hei/vola/service/utils/DateParser.java @@ -0,0 +1,15 @@ +package school.hei.vola.service.utils; + +import java.time.LocalDate; + +public class DateParser { + + private DateParser() {} + + public static LocalDate parseDate(String dateStr) { + if (dateStr == null || dateStr.isBlank()) { + return null; + } + return LocalDate.parse(dateStr); + } +} From 3277dfff47afa9415f2b0946b7d5e8d9582126c8 Mon Sep 17 00:00:00 2001 From: Mathieu-bot Date: Mon, 27 Jul 2026 23:39:48 +0300 Subject: [PATCH 04/15] test: add PaymentViewControllerIT --- .../controller/PaymentViewControllerIT.java | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 src/test/java/school/hei/vola/endpoint/rest/controller/PaymentViewControllerIT.java 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 00000000..17fbac4b --- /dev/null +++ b/src/test/java/school/hei/vola/endpoint/rest/controller/PaymentViewControllerIT.java @@ -0,0 +1,44 @@ +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.springframework.test.annotation.DirtiesContext.MethodMode.BEFORE_METHOD; + +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.ui.Model; +import org.springframework.validation.support.BindingAwareModelMap; +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 PaymentViewController subject; + + @Autowired private JApplicationRepository jApplicationRepository; + + @DirtiesContext(methodMode = BEFORE_METHOD) + @Test + void payments_page_returns_payments_view_with_default_filters() { + var app = new JApplication(); + app.setName("test-app"); + app.setId(UUID.randomUUID().toString()); + app.setApiKey(UUID.randomUUID().toString()); + jApplicationRepository.save(app); + + Model model = new BindingAwareModelMap(); + String viewName = subject.paymentsPage(null, null, null, null, 0, model); + + assertEquals("payments", viewName); + assertEquals(0, model.getAttribute("currentPage")); + assertNotNull(model.getAttribute("applications")); + assertNotNull(model.getAttribute("totalCollected")); + assertNotNull(model.getAttribute("totalCount")); + assertNotNull(model.getAttribute("pendingCount")); + assertNotNull(model.getAttribute("payments")); + assertNotNull(model.getAttribute("scopes")); + } +} From 27a96ed348cc1a2739f69154848282e879fbc100 Mon Sep 17 00:00:00 2001 From: Mathieu-bot Date: Mon, 27 Jul 2026 23:52:18 +0300 Subject: [PATCH 05/15] fix: add missing history.css and favicon --- src/main/resources/static/favicon.svg | 4 ++ src/main/resources/static/style/history.css | 51 +++++++++++++++++++++ src/main/resources/templates/payments.html | 1 + 3 files changed, 56 insertions(+) create mode 100644 src/main/resources/static/favicon.svg create mode 100644 src/main/resources/static/style/history.css diff --git a/src/main/resources/static/favicon.svg b/src/main/resources/static/favicon.svg new file mode 100644 index 00000000..a2d4f456 --- /dev/null +++ b/src/main/resources/static/favicon.svg @@ -0,0 +1,4 @@ + + + V + diff --git a/src/main/resources/static/style/history.css b/src/main/resources/static/style/history.css new file mode 100644 index 00000000..f9be3dd9 --- /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/payments.html b/src/main/resources/templates/payments.html index 7e7bdbbc..53f13fc9 100644 --- a/src/main/resources/templates/payments.html +++ b/src/main/resources/templates/payments.html @@ -7,6 +7,7 @@ + From 3b7ba1ed2fac76f503c3baec849a31b5312e8eec Mon Sep 17 00:00:00 2001 From: SalomiaZK Date: Tue, 28 Jul 2026 16:11:48 +0300 Subject: [PATCH 06/15] fix: fix test --- src/test/java/school/hei/vola/conf/TestData.java | 2 +- .../endpoint/rest/controller/PaymentControllerIT.java | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/test/java/school/hei/vola/conf/TestData.java b/src/test/java/school/hei/vola/conf/TestData.java index cfc6bd47..26471bed 100644 --- a/src/test/java/school/hei/vola/conf/TestData.java +++ b/src/test/java/school/hei/vola/conf/TestData.java @@ -4,5 +4,5 @@ public class TestData { public static final String ORANGE_REF_SUCCEEDED = // note(unique_pspPayment) // Changed on waiting of scraper fix - "MP260710.1455.C58661"; + "MP260727.1005.B84945"; } diff --git a/src/test/java/school/hei/vola/endpoint/rest/controller/PaymentControllerIT.java b/src/test/java/school/hei/vola/endpoint/rest/controller/PaymentControllerIT.java index 86ca0cfc..01de7027 100644 --- a/src/test/java/school/hei/vola/endpoint/rest/controller/PaymentControllerIT.java +++ b/src/test/java/school/hei/vola/endpoint/rest/controller/PaymentControllerIT.java @@ -97,13 +97,13 @@ void can_create_payment_beforeOrangeDailyRetrieval_then_verify_it() { assertEquals(VERIFYING, createdPayment.getVerificationStatus()); orangeDailyTransactionsRetrievalRequestedService.accept( - new OrangeDailyTransactionsRetrievalRequested(LocalDate.of(2026, 7, 10))); + new OrangeDailyTransactionsRetrievalRequested(LocalDate.of(2026, 7, 27))); var retrievedPayment = subject.getPayment(apiKey, email, pspType, pspPaymentId); assertEquals( createdPayment.pspPayment().toBuilder() .amount(356400) - .creationInstant(Instant.parse("2026-07-10T14:55:57Z")) + .creationInstant(Instant.parse("2026-07-27T10:05:43Z")) .build(), retrievedPayment.pspPayment()); assertNotNull(retrievedPayment.lastPspVerificationInstant()); @@ -119,7 +119,7 @@ void can_create_payment_afterOrangeDailyRetrieval_then_verify_it() { var pspPaymentId = ORANGE_REF_SUCCEEDED; try { orangeDailyTransactionsRetrievalRequestedService.accept( - new OrangeDailyTransactionsRetrievalRequested(LocalDate.of(2026, 7, 10))); + new OrangeDailyTransactionsRetrievalRequested(LocalDate.of(2026, 7, 27))); } catch (Exception e) { log.error("Failed to retrieve transactions, an error occured: ", e.getMessage()); @@ -144,7 +144,7 @@ void can_create_payment_afterOrangeDailyRetrieval_then_verify_it() { assertEquals( createdPayment.pspPayment().toBuilder() .amount(356400) - .creationInstant(Instant.parse("2026-07-10T14:55:57Z")) + .creationInstant(Instant.parse("2026-07-27T10:05:43Z")) .build(), retrievedPayment.pspPayment()); assertNotNull(retrievedPayment.lastPspVerificationInstant()); From 7490565f1c9559b6759d76822021e0fb77a362b5 Mon Sep 17 00:00:00 2001 From: Tsiory Jonathan <167012196+TsioryJonathan@users.noreply.github.com> Date: Wed, 29 Jul 2026 21:52:00 +0300 Subject: [PATCH 07/15] feat: add CSV export for payments * feat: add CSV export endpoint and service methods * test: add CSV export integration and service tests * refactor: make applicationName optional in CSV export endpoint * refactor: replace System.currentTimeMillis() with static import * refactor: ZoneOffset.UTC as static import * refactor: improve CSV builder readability with String.format * refactor: use English instead of French for csv title * chore: add .env to .gitignore * test: replace inline CSV assertions with expected-export.csv comparison * test: replace inline CSV assertions with expected-export.csv comparison * test: remove unused PaymentRepository import --------- Co-authored-by: Mathieu-bot * test: simplify CSV test to single data row * feat(security): require apiKey on GET /payments/export/csv, remove applicationName param * feat: add AdminAuthorizer, applicationName param for CSV export * fix: validate admin API key via env var instead of DB * style: inline format string in String.format * style: format code --------- Co-authored-by: Tafita Mathieu <199898191+Mathieu-bot@users.noreply.github.com> Co-authored-by: Mathieu-bot --- .gitignore | 3 +- .../rest/controller/PaymentController.java | 29 ++++++- .../rest/security/AdminAuthorizer.java | 22 +++++ .../hei/vola/service/PaymentService.java | 46 ++++++++++ .../java/school/hei/vola/conf/EnvConf.java | 1 + .../rest/controller/PaymentControllerIT.java | 83 +++++++++++++++++++ src/test/resources/csv/expected-export.csv | 2 + 7 files changed, 184 insertions(+), 2 deletions(-) create mode 100644 src/main/java/school/hei/vola/endpoint/rest/security/AdminAuthorizer.java create mode 100644 src/test/resources/csv/expected-export.csv diff --git a/.gitignore b/.gitignore index 8c901fb7..9bfd2e63 100644 --- a/.gitignore +++ b/.gitignore @@ -40,4 +40,5 @@ out/ **.toml ### Mac OS ### -.DS_Store \ No newline at end of file +.DS_Store +.env 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 4acc080b..4ecb109b 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 @@ -1,13 +1,18 @@ package school.hei.vola.endpoint.rest.controller; -import static org.springframework.format.annotation.DateTimeFormat.ISO; +import static java.lang.System.currentTimeMillis; +import static java.time.ZoneOffset.UTC; +import static org.springframework.http.HttpHeaders.CONTENT_DISPOSITION; import static org.springframework.http.MediaType.MULTIPART_FORM_DATA_VALUE; import java.io.IOException; +import java.time.Instant; import java.time.LocalDate; import java.util.List; import lombok.AllArgsConstructor; import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.format.annotation.DateTimeFormat.ISO; +import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PutMapping; @@ -16,6 +21,7 @@ import org.springframework.web.bind.annotation.RequestPart; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; +import school.hei.vola.endpoint.rest.security.AdminAuthorizer; import school.hei.vola.endpoint.rest.security.ApplicationAuthorizer; import school.hei.vola.model.ImportedTransactionDetails; import school.hei.vola.model.Payment; @@ -32,6 +38,7 @@ public class PaymentController { private final PaymentService paymentService; private final ApplicationAuthorizer applicationAuthorizer; + private final AdminAuthorizer adminAuthorizer; private final OrangeSyncService recoveryService; private final MultipartFileConverter multipartFileConverter; @@ -58,6 +65,26 @@ public Payment getPayment( .orElseThrow(NotFoundException::new); } + @GetMapping("/payments/export/csv") + public ResponseEntity exportPaymentsCsv( + @RequestParam 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); + 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 filename = "payments_" + applicationName + "_" + currentTimeMillis() + ".csv"; + + return ResponseEntity.ok() + .header(CONTENT_DISPOSITION, "attachment; filename=" + filename) + .header("Content-Type", "text/csv") + .body(csv.getBytes()); + } + @PutMapping("/payments/search") public List getPayments( @RequestParam String apiKey, @RequestBody List paymentSearch) { diff --git a/src/main/java/school/hei/vola/endpoint/rest/security/AdminAuthorizer.java b/src/main/java/school/hei/vola/endpoint/rest/security/AdminAuthorizer.java new file mode 100644 index 00000000..17ce0045 --- /dev/null +++ b/src/main/java/school/hei/vola/endpoint/rest/security/AdminAuthorizer.java @@ -0,0 +1,22 @@ +package school.hei.vola.endpoint.rest.security; + +import java.util.function.Consumer; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Component +public class AdminAuthorizer implements Consumer { + + private final String adminApiKey; + + public AdminAuthorizer(@Value("${ADMIN_API_KEY}") String adminApiKey) { + this.adminApiKey = adminApiKey; + } + + @Override + public void accept(String apiKey) { + if (!adminApiKey.equals(apiKey)) { + throw new UnauthorizedException(); + } + } +} diff --git a/src/main/java/school/hei/vola/service/PaymentService.java b/src/main/java/school/hei/vola/service/PaymentService.java index 53041c5c..dbc0701b 100644 --- a/src/main/java/school/hei/vola/service/PaymentService.java +++ b/src/main/java/school/hei/vola/service/PaymentService.java @@ -20,6 +20,7 @@ import school.hei.vola.model.ImportedTransactionDetails; import school.hei.vola.model.Payment; import school.hei.vola.model.PaymentInfo; +import school.hei.vola.model.VerificationStatus; import school.hei.vola.model.psp.PspType; import school.hei.vola.repository.OrangePaymentRepository; import school.hei.vola.repository.PaymentRepository; @@ -121,6 +122,51 @@ public List findDistinctScopes(String applicationName) { return paymentRepository.findDistinctScopes(applicationName); } + public String buildPaymentsCsv(String applicationName, String scope, Instant start, Instant end) { + List payments = + findPaymentsByApplicationNameAndDateRange(applicationName, scope, start, end); + + var header = + "Payer email;PSP;Payment ref;Amount (Ar);Status;Creation date;Last" + + " verification;Scope;Application\n"; + var builder = new StringBuilder(header); + + for (var p : payments) { + var amount = p.pspPayment().amount(); + builder.append( + String.format( + "%s;%s;%s;%s;%s;%s;%s;%s;%s\n", + escapeCsv(p.payer().email()), + p.pspPayment().pspType(), + escapeCsv(p.pspPayment().id()), + amount != null ? amount : "", + statusLabel(p.getVerificationStatus()), + p.creationInstant() != null ? p.creationInstant().toString() : "", + p.lastPspVerificationInstant() != null + ? p.lastPspVerificationInstant().toString() + : "", + escapeCsv(p.scope()), + p.application().name())); + } + return builder.toString(); + } + + private String escapeCsv(String value) { + if (value == null) return ""; + if (value.contains(";") || value.contains("\"") || value.contains("\n")) { + return "\"" + value.replace("\"", "\"\"") + "\""; + } + return value; + } + + private String statusLabel(VerificationStatus status) { + return switch (status) { + case VERIFYING -> "Verifying"; + case SUCCEEDED -> "Succeeded"; + case FAILED -> "Failed"; + }; + } + public ImportedTransactionDetails saveTransactionFromExcel(File excel) { log.info("File name : " + excel.getName()); var bucketKey = TRANSACTIONS_XLS_IMPORT_BUCKET_KEY + excel.getName(); diff --git a/src/test/java/school/hei/vola/conf/EnvConf.java b/src/test/java/school/hei/vola/conf/EnvConf.java index b27597cb..7e53f3e6 100644 --- a/src/test/java/school/hei/vola/conf/EnvConf.java +++ b/src/test/java/school/hei/vola/conf/EnvConf.java @@ -11,5 +11,6 @@ void configureProperties(DynamicPropertyRegistry registry) { registry.add("spring.datasource.driverClassName", () -> "org.h2.Driver"); registry.add("spring.jpa.database-platform", () -> "org.hibernate.dialect.H2Dialect"); registry.add("orange.api.url", () -> apiUrl); + registry.add("ADMIN_API_KEY", () -> "admin-api-key"); } } diff --git a/src/test/java/school/hei/vola/endpoint/rest/controller/PaymentControllerIT.java b/src/test/java/school/hei/vola/endpoint/rest/controller/PaymentControllerIT.java index 01de7027..5ee90c84 100644 --- a/src/test/java/school/hei/vola/endpoint/rest/controller/PaymentControllerIT.java +++ b/src/test/java/school/hei/vola/endpoint/rest/controller/PaymentControllerIT.java @@ -5,6 +5,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; @@ -16,6 +17,7 @@ import static school.hei.vola.model.psp.PspType.ORANGE_MONEY; import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.nio.file.Paths; import java.time.Instant; import java.time.LocalDate; @@ -26,6 +28,7 @@ import org.mockito.Captor; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.core.io.ClassPathResource; import org.springframework.mock.web.MockMultipartFile; import org.springframework.test.annotation.DirtiesContext; import school.hei.vola.conf.FacadeIT; @@ -33,7 +36,14 @@ import school.hei.vola.endpoint.event.model.OrangeDailyTransactionsRetrievalRequested; import school.hei.vola.endpoint.event.model.OrangeTransactionsImportRequested; import school.hei.vola.endpoint.event.model.PaymentVerificationRequested; +import school.hei.vola.endpoint.rest.security.UnauthorizedException; import school.hei.vola.file.bucket.BucketComponent; +import school.hei.vola.model.Application; +import school.hei.vola.model.Payment; +import school.hei.vola.model.User; +import school.hei.vola.model.psp.PspPayment; +import school.hei.vola.repository.PaymentRepository; +import school.hei.vola.repository.UserRepository; import school.hei.vola.repository.jpa.JApplicationRepository; import school.hei.vola.repository.jpa.model.JApplication; import school.hei.vola.service.event.OrangeDailyTransactionsRetrievalRequestedService; @@ -55,6 +65,9 @@ class PaymentControllerIT extends FacadeIT { @Autowired JApplicationRepository jApplicationRepository; + @Autowired private PaymentRepository paymentRepository; + @Autowired private UserRepository userRepository; + JApplication randomJApplication() { var jApplication = new JApplication(); jApplication.setName(randomUUID().toString()); @@ -222,6 +235,76 @@ void save_transactions_from_xls_file_K0() throws IOException { assertTrue(events.getFirst().getBucketKey().contains(bucketKey)); } + @DirtiesContext(methodMode = BEFORE_METHOD) + @Test + void exportPaymentsCsv_with_data_matches_expected() throws IOException { + var app = new JApplication(); + app.setName("klioba"); + app.setId("app-klioba"); + app.setApiKey("klioba-api-key"); + jApplicationRepository.save(app); + userRepository.save(new User("mata@cu.te")); + + paymentRepository.save( + Payment.builder() + .id("p1") + .pspPayment( + PspPayment.builder().pspType(ORANGE_MONEY).id("MP260715.1234.A1B2C3").build()) + .creationInstant(Instant.parse("2026-01-15T10:30:00Z")) + .verificationAttemptNb(0) + .payer(new User("mata@cu.te")) + .application(new Application("klioba", "klioba-api-key")) + .build()); + + var response = subject.exportPaymentsCsv("admin-api-key", "klioba", null, null, null); + assertNotNull(response.getBody()); + var csv = new String(response.getBody(), StandardCharsets.UTF_8); + + assertEquals(200, response.getStatusCodeValue()); + assertEquals(readResource(), csv); + } + + @DirtiesContext(methodMode = BEFORE_METHOD) + @Test + void exportPaymentsCsv_empty_returns_header_only() throws IOException { + var app = new JApplication(); + app.setName("EmptyApp"); + app.setId("app-empty"); + app.setApiKey("empty-api-key"); + jApplicationRepository.save(app); + + var response = subject.exportPaymentsCsv("admin-api-key", "EmptyApp", null, null, null); + assertNotNull(response.getBody()); + var csv = new String(response.getBody(), StandardCharsets.UTF_8); + + assertEquals(200, response.getStatusCodeValue()); + assertEquals( + "Payer email;PSP;Payment ref;Amount (Ar);Status;Creation date;Last" + + " verification;Scope;Application\n", + csv); + } + + @Test + void exportPaymentsCsv_invalid_apiKey_throws_401() { + assertThrows( + UnauthorizedException.class, + () -> subject.exportPaymentsCsv("non-existent-key", "klioba", null, null, null)); + } + + @Test + void exportPaymentsCsv_app_apiKey_rejected() { + assertThrows( + UnauthorizedException.class, + () -> subject.exportPaymentsCsv("klioba-api-key", "klioba", null, null, null)); + } + + private String readResource() throws IOException { + var resource = new ClassPathResource("csv/expected-export.csv"); + try (var is = resource.getInputStream()) { + return new String(is.readAllBytes(), StandardCharsets.UTF_8); + } + } + private static String randomEmail() { return "lou+" + randomUUID() + "@cute.dev"; } diff --git a/src/test/resources/csv/expected-export.csv b/src/test/resources/csv/expected-export.csv new file mode 100644 index 00000000..3594be4e --- /dev/null +++ b/src/test/resources/csv/expected-export.csv @@ -0,0 +1,2 @@ +Payer email;PSP;Payment ref;Amount (Ar);Status;Creation date;Last verification;Scope;Application +mata@cu.te;ORANGE_MONEY;MP260715.1234.A1B2C3;;Verifying;2026-01-15T10:30:00Z;;;klioba From 6718fa0da1622f8d575c3ef841d5beb80b7fdaa9 Mon Sep 17 00:00:00 2001 From: Mathieu-bot Date: Thu, 30 Jul 2026 06:38:50 +0300 Subject: [PATCH 08/15] feat: wall off /payments behind a shared password --- .../endpoint/rest/security/SecurityConf.java | 62 +++++++++++++++++++ .../endpoint/rest/security/SessionConfig.java | 42 +++++++++++++ .../java/school/hei/vola/conf/EnvConf.java | 2 + 3 files changed, 106 insertions(+) create mode 100644 src/main/java/school/hei/vola/endpoint/rest/security/SecurityConf.java create mode 100644 src/main/java/school/hei/vola/endpoint/rest/security/SessionConfig.java 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 00000000..f443236b --- /dev/null +++ b/src/main/java/school/hei/vola/endpoint/rest/security/SecurityConf.java @@ -0,0 +1,62 @@ +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.session.jdbc.config.annotation.web.http.EnableJdbcHttpSession; + +@Configuration +@EnableWebSecurity +@EnableJdbcHttpSession +public class SecurityConf { + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http.authorizeHttpRequests( + auth -> + auth.requestMatchers( + "/ping", + "/health/**", + "/error", + "/payment", + "/payments/search", + "/payments/export/csv", + "/orange/**", + "/css/**", + "/js/**", + "/script/**", + "/style/**", + "/images/**") + .permitAll() + .requestMatchers("/payments/**") + .authenticated() + .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 UserDetailsService users(Environment env, PasswordEncoder passwordEncoder) { + return new InMemoryUserDetailsManager( + User.builder() + .username(env.getRequiredProperty("ADMIN_EMAIL")) + .password(passwordEncoder.encode(env.getRequiredProperty("ADMIN_PASSWORD"))) + .build()); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return PasswordEncoderFactories.createDelegatingPasswordEncoder(); + } +} diff --git a/src/main/java/school/hei/vola/endpoint/rest/security/SessionConfig.java b/src/main/java/school/hei/vola/endpoint/rest/security/SessionConfig.java new file mode 100644 index 00000000..2b63b4ad --- /dev/null +++ b/src/main/java/school/hei/vola/endpoint/rest/security/SessionConfig.java @@ -0,0 +1,42 @@ +package school.hei.vola.endpoint.rest.security; + +import jakarta.annotation.PostConstruct; +import java.sql.SQLException; +import javax.sql.DataSource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.io.ClassPathResource; +import org.springframework.jdbc.datasource.init.DatabasePopulatorUtils; +import org.springframework.jdbc.datasource.init.ResourceDatabasePopulator; + +@Configuration +@Slf4j +public class SessionConfig { + + private final DataSource dataSource; + + public SessionConfig(DataSource dataSource) { + this.dataSource = dataSource; + } + + @PostConstruct + public void initSessionSchema() { + var populator = new ResourceDatabasePopulator(); + populator.addScript(new ClassPathResource(schemaPath())); + populator.setContinueOnError(true); + DatabasePopulatorUtils.execute(populator, dataSource); + log.info("Spring Session schema initialized using {}", schemaPath()); + } + + private String schemaPath() { + try (var conn = dataSource.getConnection()) { + var productName = conn.getMetaData().getDatabaseProductName(); + if ("H2".equalsIgnoreCase(productName)) { + return "org/springframework/session/jdbc/schema-h2.sql"; + } + } catch (SQLException e) { + log.warn("Could not detect database product name, falling back to PostgreSQL schema", e); + } + return "org/springframework/session/jdbc/schema-postgresql.sql"; + } +} diff --git a/src/test/java/school/hei/vola/conf/EnvConf.java b/src/test/java/school/hei/vola/conf/EnvConf.java index 7e53f3e6..4ad7b911 100644 --- a/src/test/java/school/hei/vola/conf/EnvConf.java +++ b/src/test/java/school/hei/vola/conf/EnvConf.java @@ -12,5 +12,7 @@ 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"); } } From 7d75492def26cfcbb943cd47cb8ebc36d0adefb6 Mon Sep 17 00:00:00 2001 From: Mathieu-bot Date: Thu, 30 Jul 2026 06:39:08 +0300 Subject: [PATCH 09/15] refactor: inline DateParser into PaymentViewController --- .../rest/controller/PaymentViewController.java | 15 ++++++++++++--- .../school/hei/vola/service/utils/DateParser.java | 15 --------------- 2 files changed, 12 insertions(+), 18 deletions(-) delete mode 100644 src/main/java/school/hei/vola/service/utils/DateParser.java 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 index 171483d2..be7a4e47 100644 --- a/src/main/java/school/hei/vola/endpoint/rest/controller/PaymentViewController.java +++ b/src/main/java/school/hei/vola/endpoint/rest/controller/PaymentViewController.java @@ -3,7 +3,9 @@ import static java.time.ZoneOffset.UTC; import java.time.Instant; +import java.time.LocalDate; import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Sort; import org.springframework.stereotype.Controller; @@ -12,7 +14,6 @@ import org.springframework.web.bind.annotation.RequestParam; import school.hei.vola.repository.jpa.JApplicationRepository; import school.hei.vola.service.PaymentService; -import school.hei.vola.service.utils.DateParser; @Controller @RequiredArgsConstructor @@ -23,6 +24,9 @@ public class PaymentViewController { private final PaymentService paymentService; private final JApplicationRepository jApplicationRepository; + @Value("${ADMIN_API_KEY}") + private String adminKey; + @GetMapping("/") public String index() { return "redirect:/payments"; @@ -40,8 +44,8 @@ public String paymentsPage( var effectiveApp = normalizeFilter(applicationName); var effectiveScope = normalizeFilter(scope); - var parsedStartDate = DateParser.parseDate(startDate); - var parsedEndDate = DateParser.parseDate(endDate); + var parsedStartDate = parseDate(startDate); + var parsedEndDate = parseDate(endDate); var start = parsedStartDate != null ? parsedStartDate.atStartOfDay(UTC).toInstant() : Instant.EPOCH; @@ -75,10 +79,15 @@ public String paymentsPage( model.addAttribute("selectedScope", effectiveScope); model.addAttribute("selectedStartDate", parsedStartDate); model.addAttribute("selectedEndDate", parsedEndDate); + model.addAttribute("adminKey", adminKey); 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/service/utils/DateParser.java b/src/main/java/school/hei/vola/service/utils/DateParser.java deleted file mode 100644 index 98095660..00000000 --- a/src/main/java/school/hei/vola/service/utils/DateParser.java +++ /dev/null @@ -1,15 +0,0 @@ -package school.hei.vola.service.utils; - -import java.time.LocalDate; - -public class DateParser { - - private DateParser() {} - - public static LocalDate parseDate(String dateStr) { - if (dateStr == null || dateStr.isBlank()) { - return null; - } - return LocalDate.parse(dateStr); - } -} From 54fab777e1e37a81fe2b093da5e6e10c9dcfd61d Mon Sep 17 00:00:00 2001 From: Mathieu-bot Date: Thu, 30 Jul 2026 06:39:30 +0300 Subject: [PATCH 10/15] feat: add CSV export and logout to admin UI --- src/main/resources/static/script/logout.js | 27 ++++++++++ .../resources/templates/fragments/header.html | 52 +++++++++++++++++-- src/main/resources/templates/payments.html | 24 +++++++-- 3 files changed, 94 insertions(+), 9 deletions(-) create mode 100644 src/main/resources/static/script/logout.js diff --git a/src/main/resources/static/script/logout.js b/src/main/resources/static/script/logout.js new file mode 100644 index 00000000..a583b523 --- /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/templates/fragments/header.html b/src/main/resources/templates/fragments/header.html index 9d6954a5..1d18c6dc 100644 --- a/src/main/resources/templates/fragments/header.html +++ b/src/main/resources/templates/fragments/header.html @@ -2,18 +2,60 @@
-
+
account_balance_wallet

Vola

- - person - Admin - +
+ + 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/payments.html b/src/main/resources/templates/payments.html index 53f13fc9..c2bec53e 100644 --- a/src/main/resources/templates/payments.html +++ b/src/main/resources/templates/payments.html @@ -7,16 +7,31 @@ -
-

- Historique des paiements -

+
+

+ Historique des paiements +

+ +
@@ -107,5 +122,6 @@

+ From 3cb3e46602d3a96ddf63275471a912d16bacd10e Mon Sep 17 00:00:00 2001 From: Mathieu-bot Date: Thu, 30 Jul 2026 06:39:42 +0300 Subject: [PATCH 11/15] test: switch from unit mocks to real HTTP integration --- .../controller/PaymentViewControllerIT.java | 124 ++++++++++++++---- 1 file changed, 96 insertions(+), 28 deletions(-) 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 index 17fbac4b..3fe3e8d3 100644 --- a/src/test/java/school/hei/vola/endpoint/rest/controller/PaymentViewControllerIT.java +++ b/src/test/java/school/hei/vola/endpoint/rest/controller/PaymentViewControllerIT.java @@ -2,43 +2,111 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.springframework.test.annotation.DirtiesContext.MethodMode.BEFORE_METHOD; +import static org.junit.jupiter.api.Assertions.assertTrue; -import java.util.UUID; +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.test.annotation.DirtiesContext; -import org.springframework.ui.Model; -import org.springframework.validation.support.BindingAwareModelMap; +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 PaymentViewController subject; + @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")); + } - @DirtiesContext(methodMode = BEFORE_METHOD) @Test - void payments_page_returns_payments_view_with_default_filters() { - var app = new JApplication(); - app.setName("test-app"); - app.setId(UUID.randomUUID().toString()); - app.setApiKey(UUID.randomUUID().toString()); - jApplicationRepository.save(app); - - Model model = new BindingAwareModelMap(); - String viewName = subject.paymentsPage(null, null, null, null, 0, model); - - assertEquals("payments", viewName); - assertEquals(0, model.getAttribute("currentPage")); - assertNotNull(model.getAttribute("applications")); - assertNotNull(model.getAttribute("totalCollected")); - assertNotNull(model.getAttribute("totalCount")); - assertNotNull(model.getAttribute("pendingCount")); - assertNotNull(model.getAttribute("payments")); - assertNotNull(model.getAttribute("scopes")); + void static_resources_are_accessible_without_authentication() { + var cssResponse = restTemplate.getForEntity("/style/history.css", String.class); + assertEquals(200, cssResponse.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; } } From b92d5f62d88e10e6f800fb72a68df1b95ae878a1 Mon Sep 17 00:00:00 2001 From: Mathieu-bot Date: Thu, 30 Jul 2026 06:39:53 +0300 Subject: [PATCH 12/15] chore: clean up filters.js and remove unused favicon --- src/main/resources/static/favicon.svg | 4 --- src/main/resources/static/script/filters.js | 28 ++++++++++----------- 2 files changed, 14 insertions(+), 18 deletions(-) delete mode 100644 src/main/resources/static/favicon.svg diff --git a/src/main/resources/static/favicon.svg b/src/main/resources/static/favicon.svg deleted file mode 100644 index a2d4f456..00000000 --- a/src/main/resources/static/favicon.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - V - diff --git a/src/main/resources/static/script/filters.js b/src/main/resources/static/script/filters.js index 91ab3fa2..a1c5f8b4 100644 --- a/src/main/resources/static/script/filters.js +++ b/src/main/resources/static/script/filters.js @@ -1,17 +1,17 @@ (function () { - var scopeInput = document.getElementById('scope'); - var scopeDropdown = document.getElementById('scope-dropdown'); - var scopeOptions = [...scopeDropdown.querySelectorAll('.scope-option')]; - var scopeClear = document.getElementById('scope-clear'); + 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() { - var query = scopeInput.value.toLowerCase(); - var hasVisible = scopeOptions.some(function (opt) { - var matches = opt.textContent.toLowerCase().includes(query); + 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; }); @@ -41,11 +41,11 @@ }); setScopeClearVisible(); - var appHidden = document.getElementById('applicationName'); - var appDisplay = document.getElementById('app-display'); - var appTrigger = document.getElementById('app-trigger'); - var appDropdown = document.getElementById('app-dropdown'); - var appOptions = [...appDropdown.querySelectorAll('.app-option')]; + 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(); @@ -53,8 +53,8 @@ }); appOptions.forEach(function (opt) { opt.addEventListener('click', function () { - var val = opt.dataset.value; - appHidden.value = val === 'all' ? '' : val; + const val = opt.dataset.value; + appHidden.value = val; appDisplay.textContent = val === 'all' ? 'Toutes les applications' : val; appDropdown.classList.add('hidden'); appHidden.form.submit(); From 27d34069f4d1a792dc1dc945c69c6a98290645fd Mon Sep 17 00:00:00 2001 From: Mathieu-bot Date: Thu, 30 Jul 2026 08:41:39 +0300 Subject: [PATCH 13/15] fix normalize empty scope param in export endpoint --- .../vola/endpoint/rest/controller/PaymentController.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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 4ecb109b..6faa4eb5 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 @@ -73,15 +73,19 @@ public ResponseEntity exportPaymentsCsv( @RequestParam(required = false) @DateTimeFormat(iso = ISO.DATE) LocalDate startDate, @RequestParam(required = false) @DateTimeFormat(iso = ISO.DATE) LocalDate endDate) { adminAuthorizer.accept(adminKey); + String 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() .header(CONTENT_DISPOSITION, "attachment; filename=" + filename) .header("Content-Type", "text/csv") + .header("Cache-Control", "no-cache, no-store, must-revalidate") + .header("Pragma", "no-cache") .body(csv.getBytes()); } From 88b857577b96f5b3b6a884c99efc14349538347f Mon Sep 17 00:00:00 2001 From: Mathieu-bot Date: Thu, 30 Jul 2026 09:27:08 +0300 Subject: [PATCH 14/15] chore: remove thymeleaf dep from build.gradle (managed by Poja console) --- build.gradle | 1 - 1 file changed, 1 deletion(-) diff --git a/build.gradle b/build.gradle index 04c75430..ba7cc269 100644 --- a/build.gradle +++ b/build.gradle @@ -150,7 +150,6 @@ implementation 'org.flywaydb:flyway-core' testImplementation 'com.h2database:h2' testImplementation 'org.mockito:mockito-core:5.3.1' implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.5.0' -implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' implementation 'org.postgresql:postgresql' implementation("org.apache.poi:poi:5.2.5") implementation("org.apache.poi:poi-ooxml:5.2.5") From d8e7031e2ebc10683b4732c6ab11c522279b695d Mon Sep 17 00:00:00 2001 From: Mathieu-bot Date: Thu, 30 Jul 2026 10:20:04 +0300 Subject: [PATCH 15/15] chore: re-add spring deps (thymeleaf, security, session-jdbc) --- build.gradle | 3 +++ 1 file changed, 3 insertions(+) diff --git a/build.gradle b/build.gradle index 2d57854e..7d69af93 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'