From 6fb546733c3798be3d943893274768c4aec6bda6 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Tue, 11 Nov 2025 09:24:11 +0000 Subject: [PATCH 1/9] fix(forms): Resolve race condition in form handlers Refactored SubmitFormHandler and AjaxSubmitFormHandler to correctly handle asynchronous form processing. The response logic is now executed within the Promise callbacks, ensuring that the response is sent only after the form processing is complete. --- .../forms/handler/AjaxSubmitFormHandler.java | 38 ++++++++++++------- .../forms/handler/SubmitFormHandler.java | 20 ++++++---- 2 files changed, 37 insertions(+), 21 deletions(-) diff --git a/src/main/java/com/condation/cms/modules/forms/handler/AjaxSubmitFormHandler.java b/src/main/java/com/condation/cms/modules/forms/handler/AjaxSubmitFormHandler.java index f0e271a..9009233 100644 --- a/src/main/java/com/condation/cms/modules/forms/handler/AjaxSubmitFormHandler.java +++ b/src/main/java/com/condation/cms/modules/forms/handler/AjaxSubmitFormHandler.java @@ -63,12 +63,12 @@ public boolean handle(Request request, Response response, Callback callback) thr FormsHandling formHandling = new FormsHandling(hookSystem); - final AtomicReference formResponse = new AtomicReference<>(); try { if (MimeTypes.Type.FORM_ENCODED.is(contentType)) { FormFields.onFields(request, StandardCharsets.UTF_8, new Promise.Invocable() { @Override public void succeeded(Fields fields) { + FormResponse formResponse; try { final String formName = fields.get("form").getValue(); var form = FormsLifecycleExtension.FORMSCONFIG.findForm(formName).get(); @@ -78,16 +78,21 @@ public void succeeded(Fields fields) { } return field; }); - formResponse.set(new FormResponse(false)); + formResponse = new FormResponse(false); + response.setStatus(HttpStatus.OK_200); } catch (FormHandlingException fhe) { log.error(null, fhe); - formResponse.set(new FormResponse(true)); + formResponse = new FormResponse(true); + response.setStatus(HttpStatus.BAD_REQUEST_400); } + Content.Sink.write(response, true, GSON.toJson(formResponse), callback); } @Override public void failed(Throwable x) { - formResponse.set(new FormResponse(true)); + var formResponse = new FormResponse(true); + response.setStatus(HttpStatus.BAD_REQUEST_400); + Content.Sink.write(response, true, GSON.toJson(formResponse), callback); } }); } else if (contentType.startsWith(MimeTypes.Type.MULTIPART_FORM_DATA.asString())) { @@ -98,11 +103,14 @@ public void failed(Throwable x) { parser.parse(request, new Promise.Invocable() { @Override public void failed(Throwable x) { - formResponse.set(new FormResponse(true)); + var formResponse = new FormResponse(true); + response.setStatus(HttpStatus.BAD_REQUEST_400); + Content.Sink.write(response, true, GSON.toJson(formResponse), callback); } @Override public void succeeded(MultiPartFormData.Parts parts) { + FormResponse formResponse; try { String formName = parts.getFirst("form").getContentAsString(StandardCharsets.UTF_8); @@ -114,26 +122,28 @@ public void succeeded(MultiPartFormData.Parts parts) { return field; }); - formResponse.set(new FormResponse(false)); - + formResponse = new FormResponse(false); + response.setStatus(HttpStatus.OK_200); } catch (FormHandlingException fhe) { log.error(null, fhe); - formResponse.set(new FormResponse(true)); + formResponse = new FormResponse(true); + response.setStatus(HttpStatus.BAD_REQUEST_400); } + Content.Sink.write(response, true, GSON.toJson(formResponse), callback); } }); + } else { + var formResponse = new FormResponse(true); + response.setStatus(HttpStatus.BAD_REQUEST_400); + Content.Sink.write(response, true, GSON.toJson(formResponse), callback); } } catch (Exception e) { log.error("error processing form", e); - } - - if (formResponse.get().error()) { + var formResponse = new FormResponse(true); response.setStatus(HttpStatus.BAD_REQUEST_400); - } else { - response.setStatus(HttpStatus.OK_200); + Content.Sink.write(response, true, GSON.toJson(formResponse), callback); } - Content.Sink.write(response, true, GSON.toJson(formResponse.get()), callback); return true; } diff --git a/src/main/java/com/condation/cms/modules/forms/handler/SubmitFormHandler.java b/src/main/java/com/condation/cms/modules/forms/handler/SubmitFormHandler.java index 9c25885..918cd99 100644 --- a/src/main/java/com/condation/cms/modules/forms/handler/SubmitFormHandler.java +++ b/src/main/java/com/condation/cms/modules/forms/handler/SubmitFormHandler.java @@ -75,6 +75,7 @@ public boolean handle(Request request, Response response, Callback callback) thr public void failed(Throwable x) { response.getHeaders().add("Location", FormsLifecycleExtension.FORMSCONFIG.getRedirects().getError()); response.setStatus(HttpStatus.MOVED_TEMPORARILY_302); + callback.succeeded(); } @Override @@ -101,10 +102,11 @@ public void succeeded(Fields fields) { response.getHeaders().add("Location", FormsLifecycleExtension.FORMSCONFIG.getRedirects().getError()); response.setStatus(HttpStatus.MOVED_TEMPORARILY_302); } + } finally { + callback.succeeded(); } } }); - return true; } else if (contentType.startsWith(MimeTypes.Type.MULTIPART_FORM_DATA.asString())) { String boundary = MultiPart.extractBoundary(contentType); MultiPartFormData.Parser parser = new MultiPartFormData.Parser(boundary); @@ -114,7 +116,7 @@ public void succeeded(Fields fields) { public void failed(Throwable x) { response.getHeaders().add("Location", FormsLifecycleExtension.FORMSCONFIG.getRedirects().getError()); response.setStatus(HttpStatus.MOVED_TEMPORARILY_302); - + callback.succeeded(); } @Override @@ -143,19 +145,23 @@ public void succeeded(MultiPartFormData.Parts parts) { response.getHeaders().add("Location", FormsLifecycleExtension.FORMSCONFIG.getRedirects().getError()); response.setStatus(HttpStatus.MOVED_TEMPORARILY_302); } + } finally { + callback.succeeded(); } } }); - return true; + } else { + response.getHeaders().add("Location", FormsLifecycleExtension.FORMSCONFIG.getRedirects().getError()); + response.setStatus(HttpStatus.MOVED_TEMPORARILY_302); + callback.succeeded(); } } catch (Exception e) { log.error("error processing form", e); + response.getHeaders().add("Location", FormsLifecycleExtension.FORMSCONFIG.getRedirects().getError()); + response.setStatus(HttpStatus.MOVED_TEMPORARILY_302); + callback.succeeded(); } - - response.getHeaders().add("Location", FormsLifecycleExtension.FORMSCONFIG.getRedirects().getError()); - response.setStatus(HttpStatus.MOVED_TEMPORARILY_302); - callback.succeeded(); return true; } } From b3459f5fa71632ee6d7a4bb173076361cf766b19 Mon Sep 17 00:00:00 2001 From: Thorsten Marx Date: Sun, 26 Jul 2026 22:02:59 +0200 Subject: [PATCH 2/9] multiple updates to make the module production ready --- demo/hosts/demo/assets/form-1.js | 77 +------ demo/hosts/demo/config/forms.yaml | 30 ++- demo/hosts/demo/templates/ajax.html | 7 +- demo/hosts/demo/templates/contact.html | 10 +- demo/hosts/demo/templates/page.html | 8 +- pom.xml | 1 + .../cms/modules/forms/FormsConfig.java | 155 +++++++++++++- .../cms/modules/forms/FormsFeature.java | 94 +++++++++ .../forms/FormsHttpHandlerExtension.java | 8 +- .../forms/FormsLifecycleExtension.java | 34 ++- .../handler/AjaxCaptchaValidationHandler.java | 82 -------- .../forms/handler/AjaxSubmitFormHandler.java | 199 +++++++++--------- .../forms/handler/FormHandlingException.java | 24 ++- .../modules/forms/handler/FormsHandling.java | 126 +++++++++-- .../forms/handler/GenerateCaptchaHandler.java | 55 ++++- .../forms/handler/RequestSecurity.java | 79 +++++++ .../forms/handler/SubmitFormHandler.java | 189 +++++++---------- .../forms/template/FormsTemplateModel.java | 2 +- .../cms/modules/forms/utils/StringUtil.java | 17 +- .../cms/modules/forms/FormConfigTest.java | 5 + .../cms/modules/forms/FormsHandlingTest.java | 116 ++++++++++ src/test/resources/config/forms.yaml | 15 +- 22 files changed, 876 insertions(+), 457 deletions(-) create mode 100644 src/main/java/com/condation/cms/modules/forms/FormsFeature.java delete mode 100644 src/main/java/com/condation/cms/modules/forms/handler/AjaxCaptchaValidationHandler.java create mode 100644 src/main/java/com/condation/cms/modules/forms/handler/RequestSecurity.java create mode 100644 src/test/java/com/condation/cms/modules/forms/FormsHandlingTest.java diff --git a/demo/hosts/demo/assets/form-1.js b/demo/hosts/demo/assets/form-1.js index 618a3db..e6be660 100644 --- a/demo/hosts/demo/assets/form-1.js +++ b/demo/hosts/demo/assets/form-1.js @@ -10,56 +10,11 @@ const generateString = (length) => { return result; } -const validateCaptcha = async (event) => { - event.preventDefault(); - let request = { - code: document.getElementById("inputCaptcha").value, - key: document.getElementById("captchaKey").value - } - - const response = await fetch('/module/forms-module/captcha/validate', { - method: 'POST', - body: JSON.stringify(request) - }) - - const validationResponse = await response.json() - - if (!validationResponse.valid) { - alert("captcha code is not valid") - event.preventDefault() - return false - } else { - console.log(event.target) - event.target.submit() - return true - } -} - -const ajaxValidateCaptcha = async () => { - let request = { - code: document.getElementById("inputCaptcha").value, - key: document.getElementById("captchaKey").value - } - - const response = await fetch('/module/forms-module/captcha/validate', { - method: 'POST', - body: JSON.stringify(request) - }) - - const validationResponse = await response.json() - - if (!validationResponse.valid) { - return false - } else { - return true - } -} - document.addEventListener("DOMContentLoaded", () => { if (document.getElementById("reloadCaptcha")) { document.getElementById("reloadCaptcha").addEventListener("click", () => { let href = new URL(document.getElementById("captchaImg").src) - let key = generateString(8) + let key = crypto.randomUUID().replaceAll('-', '') + crypto.randomUUID().replaceAll('-', '') href.searchParams.set('key', key) document.getElementById("captchaKey").value = key @@ -70,36 +25,20 @@ document.addEventListener("DOMContentLoaded", () => { if (document.getElementById("ajaxForm")) { document.getElementById("ajaxForm").addEventListener("submit", (event) => { event.preventDefault() - console.log("send form via ajax") - if (!ajaxValidateCaptcha()) { - alert("invalid captcha provided"); - return false - } var form = event.target; - var formData = new FormData(form); + var formData = new URLSearchParams(new FormData(form)); fetch(form.action, { method: "post", + headers: {"Content-Type": "application/x-www-form-urlencoded"}, body: formData - }).then(res => res.json()).then(console.log); + }).then(res => res.json()).then(result => { + if (!result.success) { + alert(result.code || "The form could not be submitted") + } + }); return false }) - document.getElementById("submit-btn-test").addEventListener("click", (event) => { - event.preventDefault() - - if (ajaxValidateCaptcha()) { - alert("invalid captcha provided"); - return false - } - - var form = document.getElementById("ajaxForm") - var formData = new FormData(form); - fetch(form.action, { - method: "post", - body: formData - }).then(res => res.json()).then(console.log); - return false; - }) } }) diff --git a/demo/hosts/demo/config/forms.yaml b/demo/hosts/demo/config/forms.yaml index e73dc41..5a515c2 100644 --- a/demo/hosts/demo/config/forms.yaml +++ b/demo/hosts/demo/config/forms.yaml @@ -8,12 +8,36 @@ forms: - name: contact to: contact@example.com subject: Ich suche Kontakt! - fields: [message] + fields: + from: + type: email + required: true + message: + required: true + minLength: 10 + maxLength: 5000 + mail: + account: default + from: forms@example.com + spam: + honeypot: + enabled: true + field: website redirects: success: /forms/contact/success - name: test-form - fields: [message] + fields: + from: + type: email + required: true + message: + required: true + minLength: 3 + spam: + honeypot: + enabled: true + field: website redirects: success: /forms/contact/success redirects: - error: /forms/error \ No newline at end of file + error: /forms/error diff --git a/demo/hosts/demo/templates/ajax.html b/demo/hosts/demo/templates/ajax.html index 2760a19..35af069 100644 --- a/demo/hosts/demo/templates/ajax.html +++ b/demo/hosts/demo/templates/ajax.html @@ -17,11 +17,12 @@

Test Formular

+
@@ -31,7 +32,7 @@

Test Formular

- + reload
@@ -49,4 +50,4 @@

Test Formular

- \ No newline at end of file + diff --git a/demo/hosts/demo/templates/contact.html b/demo/hosts/demo/templates/contact.html index 2a07bfc..d7215fb 100644 --- a/demo/hosts/demo/templates/contact.html +++ b/demo/hosts/demo/templates/contact.html @@ -13,14 +13,14 @@
-
+

Contact form

+
@@ -30,7 +30,7 @@

Contact form

- + reload
@@ -47,4 +47,4 @@

Contact form

- \ No newline at end of file + diff --git a/demo/hosts/demo/templates/page.html b/demo/hosts/demo/templates/page.html index 4c89e20..b1e4fa8 100644 --- a/demo/hosts/demo/templates/page.html +++ b/demo/hosts/demo/templates/page.html @@ -17,10 +17,10 @@

Test Formular

+
@@ -30,7 +30,7 @@

Test Formular

- + reload
@@ -48,4 +48,4 @@

Test Formular

- \ No newline at end of file + diff --git a/pom.xml b/pom.xml index 251dbd3..21b73f6 100644 --- a/pom.xml +++ b/pom.xml @@ -155,6 +155,7 @@ maven-compiler-plugin 3.15.0 + full org.projectlombok diff --git a/src/main/java/com/condation/cms/modules/forms/FormsConfig.java b/src/main/java/com/condation/cms/modules/forms/FormsConfig.java index f9b95f3..c707181 100644 --- a/src/main/java/com/condation/cms/modules/forms/FormsConfig.java +++ b/src/main/java/com/condation/cms/modules/forms/FormsConfig.java @@ -23,9 +23,14 @@ */ +import java.util.Collections; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.Set; +import java.util.regex.Pattern; +import java.util.regex.PatternSyntaxException; import lombok.Data; /** @@ -38,21 +43,132 @@ public class FormsConfig { private List forms; private Redirects redirects; + + private RateLimit captchaRateLimit = RateLimit.captchaDefaults(); + + private Csrf csrf = new Csrf(); public Optional findForm (final String name) { - return forms.stream().filter(form -> form.getName().equals(name)).findFirst(); + if (name == null || forms == null) { + return Optional.empty(); + } + return forms.stream() + .filter(form -> form != null && name.equals(form.getName())) + .findFirst(); + } + + public void validate() { + if (forms == null || forms.isEmpty()) { + throw new IllegalArgumentException("At least one form must be configured"); + } + var names = new java.util.HashSet(); + for (var form : forms) { + if (form == null || isBlank(form.getName())) { + throw new IllegalArgumentException("Every form needs a name"); + } + if (!names.add(form.getName())) { + throw new IllegalArgumentException("Duplicate form name: " + form.getName()); + } + if (!isBlank(form.getTo()) && (form.getMail() == null || isBlank(form.getMail().getFrom()))) { + throw new IllegalArgumentException("Form '%s' needs mail.from when to is configured".formatted(form.getName())); + } + for (var entry : form.getFields().entrySet()) { + entry.getValue().validate(form.getName(), entry.getKey()); + } + validateRateLimit(form.getRateLimit(), "form " + form.getName()); + if (form.getSpam() != null && form.getSpam().getHoneypot() != null + && form.getSpam().getHoneypot().isEnabled() + && isBlank(form.getSpam().getHoneypot().getField())) { + throw new IllegalArgumentException("Enabled honeypot needs a field for form " + form.getName()); + } + safeRedirect(form.getRedirects() == null ? null : form.getRedirects().getSuccess(), null); + safeRedirect(form.getRedirects() == null ? null : form.getRedirects().getError(), null); + } + safeRedirect(redirects == null ? null : redirects.getSuccess(), null); + safeRedirect(redirects == null ? null : redirects.getError(), null); + validateRateLimit(captchaRateLimit, "captcha"); + } + + private static void validateRateLimit(final RateLimit rateLimit, final String scope) { + if (rateLimit != null && rateLimit.isEnabled() + && (rateLimit.getRequests() < 1 || rateLimit.getPeriodSeconds() < 1)) { + throw new IllegalArgumentException("Invalid rate limit for " + scope); + } + } + + public String successRedirect(final Form form) { + return safeRedirect( + form.getRedirects() == null ? null : form.getRedirects().getSuccess(), + safeRedirect(redirects == null ? null : redirects.getSuccess(), "/")); + } + + public String errorRedirect(final Form form) { + return safeRedirect( + form == null || form.getRedirects() == null ? null : form.getRedirects().getError(), + safeRedirect(redirects == null ? null : redirects.getError(), "/")); + } + + private static String safeRedirect(final String redirect, final String fallback) { + if (isBlank(redirect)) { + return fallback; + } + if (!redirect.startsWith("/") || redirect.startsWith("//") + || redirect.contains("\r") || redirect.contains("\n")) { + throw new IllegalArgumentException("Redirects must be local absolute paths: " + redirect); + } + return redirect; + } + + private static boolean isBlank(final String value) { + return value == null || value.isBlank(); } @Data public static class Form { private String name; private Redirects redirects; - private List fields; + private Map fields = new LinkedHashMap<>(); private String to; private String subject; private Map data; - private Mail mail = new Mail(); + private Spam spam = new Spam(); + private RateLimit rateLimit = new RateLimit(); + + public void setFields(final Map configuredFields) { + this.fields = configuredFields == null + ? new LinkedHashMap<>() + : new LinkedHashMap<>(configuredFields); + } + } + + @Data + public static class Field { + private String type = "string"; + private boolean required; + private Integer minLength; + private Integer maxLength; + private String pattern; + private Set allowedValues = Collections.emptySet(); + + void validate(final String formName, final String fieldName) { + if (!Set.of("string", "email", "integer", "boolean").contains(type)) { + throw new IllegalArgumentException("Unsupported type for %s.%s: %s" + .formatted(formName, fieldName, type)); + } + if (minLength != null && minLength < 0 + || maxLength != null && maxLength < 0 + || minLength != null && maxLength != null && minLength > maxLength) { + throw new IllegalArgumentException("Invalid length constraints for " + formName + "." + fieldName); + } + if (pattern != null) { + try { + Pattern.compile(pattern); + } catch (PatternSyntaxException ex) { + throw new IllegalArgumentException("Invalid pattern for " + formName + "." + fieldName, ex); + } + } + } } @Data @@ -64,6 +180,37 @@ public static class Redirects { @Data public static class Mail { private String account = "default"; - + private String from; + } + + @Data + public static class Spam { + private Honeypot honeypot = new Honeypot(); + } + + @Data + public static class Honeypot { + private boolean enabled; + private String field = "website"; + } + + @Data + public static class RateLimit { + private boolean enabled = true; + private int requests = 5; + private long periodSeconds = 600; + + static RateLimit captchaDefaults() { + var result = new RateLimit(); + result.setRequests(20); + result.setPeriodSeconds(60); + return result; + } + } + + @Data + public static class Csrf { + private boolean enabled = true; + private Set allowedOrigins = Collections.emptySet(); } } diff --git a/src/main/java/com/condation/cms/modules/forms/FormsFeature.java b/src/main/java/com/condation/cms/modules/forms/FormsFeature.java new file mode 100644 index 0000000..c90592a --- /dev/null +++ b/src/main/java/com/condation/cms/modules/forms/FormsFeature.java @@ -0,0 +1,94 @@ +package com.condation.cms.modules.forms; + +/*- + * #%L + * forms-module + * %% + * Copyright (C) 2024 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public + * License along with this program. If not, see + * . + * #L% + */ + +import com.condation.cms.api.feature.Feature; +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import java.time.Duration; +import java.time.Instant; + +/** + * Site-local state of the forms module. + */ +public final class FormsFeature implements Feature { + + private final FormsConfig config; + private final Cache captchas; + private final Cache rateLimits; + + public FormsFeature(final FormsConfig config) { + this.config = config; + this.captchas = Caffeine.newBuilder() + .maximumSize(10_000) + .expireAfterWrite(Duration.ofMinutes(5)) + .build(); + this.rateLimits = Caffeine.newBuilder() + .maximumSize(50_000) + .expireAfterAccess(Duration.ofHours(1)) + .build(); + } + + public FormsConfig config() { + return config; + } + + public Cache captchas() { + return captchas; + } + + public boolean allow(final String key, final FormsConfig.RateLimit policy) { + if (policy == null || !policy.isEnabled()) { + return true; + } + + var window = rateLimits.get(key, ignored -> new RateLimitWindow()); + return window.tryAcquire(policy.getRequests(), Duration.ofSeconds(policy.getPeriodSeconds())); + } + + public record CaptchaChallenge(String answer, String formName, int attempts) { + + public CaptchaChallenge failedAttempt() { + return new CaptchaChallenge(answer, formName, attempts + 1); + } + } + + private static final class RateLimitWindow { + + private Instant startedAt = Instant.now(); + private int requests; + + synchronized boolean tryAcquire(final int maximumRequests, final Duration period) { + var now = Instant.now(); + if (!now.isBefore(startedAt.plus(period))) { + startedAt = now; + requests = 0; + } + if (requests >= maximumRequests) { + return false; + } + requests++; + return true; + } + } +} diff --git a/src/main/java/com/condation/cms/modules/forms/FormsHttpHandlerExtension.java b/src/main/java/com/condation/cms/modules/forms/FormsHttpHandlerExtension.java index 066b1fc..4909336 100644 --- a/src/main/java/com/condation/cms/modules/forms/FormsHttpHandlerExtension.java +++ b/src/main/java/com/condation/cms/modules/forms/FormsHttpHandlerExtension.java @@ -26,7 +26,6 @@ import com.condation.cms.api.extensions.HttpHandlerExtensionPoint; import com.condation.cms.api.extensions.Mapping; import com.condation.cms.api.feature.features.HookSystemFeature; -import com.condation.cms.modules.forms.handler.AjaxCaptchaValidationHandler; import com.condation.cms.modules.forms.handler.AjaxSubmitFormHandler; import com.condation.cms.modules.forms.handler.GenerateCaptchaHandler; import com.condation.cms.modules.forms.handler.SubmitFormHandler; @@ -44,13 +43,12 @@ public class FormsHttpHandlerExtension extends HttpHandlerExtensionPoint { public Mapping getMapping() { Mapping mapping = new Mapping(); - mapping.add(PathSpec.from("/captcha/validate"), new AjaxCaptchaValidationHandler()); - mapping.add(PathSpec.from("/captcha/generate"), new GenerateCaptchaHandler()); + mapping.add(PathSpec.from("/captcha/generate"), new GenerateCaptchaHandler(getContext())); mapping.add(PathSpec.from("/form/submit/ajax"), - new AjaxSubmitFormHandler(requestContext.get(HookSystemFeature.class).hookSystem(), getContext()) + new AjaxSubmitFormHandler(getRequestContext().get(HookSystemFeature.class).hookSystem(), getContext()) ); mapping.add(PathSpec.from("/form/submit"), - new SubmitFormHandler(requestContext.get(HookSystemFeature.class).hookSystem(), getContext()) + new SubmitFormHandler(getRequestContext().get(HookSystemFeature.class).hookSystem(), getContext()) ); return mapping; diff --git a/src/main/java/com/condation/cms/modules/forms/FormsLifecycleExtension.java b/src/main/java/com/condation/cms/modules/forms/FormsLifecycleExtension.java index ef7c55f..3e6d5ed 100644 --- a/src/main/java/com/condation/cms/modules/forms/FormsLifecycleExtension.java +++ b/src/main/java/com/condation/cms/modules/forms/FormsLifecycleExtension.java @@ -25,47 +25,43 @@ import com.condation.cms.api.feature.features.DBFeature; import com.condation.cms.api.module.SiteModuleContext; -import com.condation.cms.api.module.SiteRequestContext; import com.condation.modules.api.ModuleLifeCycleExtension; import com.condation.modules.api.annotation.Extension; -import com.github.benmanes.caffeine.cache.Cache; -import com.github.benmanes.caffeine.cache.Caffeine; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; -import java.time.Duration; -import lombok.extern.slf4j.Slf4j; import org.yaml.snakeyaml.Yaml; /** * * @author t.marx */ -@Slf4j @Extension(ModuleLifeCycleExtension.class) -public class FormsLifecycleExtension extends ModuleLifeCycleExtension { - - public static Cache CAPTCHAS; - public static FormsConfig FORMSCONFIG; +public class FormsLifecycleExtension extends ModuleLifeCycleExtension { @Override public void init() { - } @Override public void activate() { - CAPTCHAS = Caffeine.newBuilder() - .maximumSize(10_000) - .expireAfterWrite(Duration.ofMinutes(5)) - .build(); - Path formsConfig = getContext().get(DBFeature.class).db().getFileSystem().resolve("config/forms.yaml"); try { - FORMSCONFIG = new Yaml().loadAs(Files.readString(formsConfig, StandardCharsets.UTF_8), FormsConfig.class); - } catch (IOException ex) { - log.error(null, ex); + var config = new Yaml().loadAs( + Files.readString(formsConfig, StandardCharsets.UTF_8), + FormsConfig.class); + if (config == null) { + throw new IllegalArgumentException("forms.yaml is empty"); + } + config.validate(); + getContext().add(FormsFeature.class, new FormsFeature(config)); + } catch (IOException | RuntimeException ex) { + System.getLogger(getClass().getName()).log( + System.Logger.Level.ERROR, + "Could not activate forms module: invalid config " + formsConfig, + ex); + throw new IllegalStateException("Could not load forms configuration", ex); } } diff --git a/src/main/java/com/condation/cms/modules/forms/handler/AjaxCaptchaValidationHandler.java b/src/main/java/com/condation/cms/modules/forms/handler/AjaxCaptchaValidationHandler.java deleted file mode 100644 index 4a7e809..0000000 --- a/src/main/java/com/condation/cms/modules/forms/handler/AjaxCaptchaValidationHandler.java +++ /dev/null @@ -1,82 +0,0 @@ -package com.condation.cms.modules.forms.handler; - -/*- - * #%L - * forms-module - * %% - * Copyright (C) 2024 CondationCMS - * %% - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public - * License along with this program. If not, see - * . - * #L% - */ - - -import com.condation.cms.api.extensions.HttpHandler; -import com.condation.cms.modules.forms.FormsLifecycleExtension; -import com.google.gson.Gson; -import java.nio.charset.StandardCharsets; -import lombok.extern.slf4j.Slf4j; -import org.eclipse.jetty.http.HttpHeader; -import org.eclipse.jetty.io.Content; -import org.eclipse.jetty.server.Request; -import org.eclipse.jetty.server.Response; -import org.eclipse.jetty.util.Callback; - -/** - * - * @author t.marx - */ -@Slf4j -public class AjaxCaptchaValidationHandler implements HttpHandler { - - private static final Gson GSON = new Gson(); - - @Override - public boolean handle(Request request, Response response, Callback callback) throws Exception { - - response.getHeaders().add(HttpHeader.CONTENT_TYPE, "application/json"); - - if (!"POST".equalsIgnoreCase(request.getMethod())) { - response.setStatus(405); - callback.succeeded(); - return true; - } - - String body = readBody(request); - var formData = GSON.fromJson(body, FormsData.class); - - boolean valid = false; - String captchaCode = FormsLifecycleExtension.CAPTCHAS.getIfPresent(formData.key()); - if (captchaCode != null && captchaCode.equals(formData.code())) { - valid = true; - } - - Content.Sink.write(response, true, GSON.toJson(new ValidationResponse(valid)), callback); - - return true; - } - - private String readBody(final Request request) { - try (var inputStream = Request.asInputStream(request)) { - return new String(inputStream.readAllBytes(), StandardCharsets.UTF_8); - } catch (Exception ex) { - log.error("", ex); - } - return ""; - } - - public record FormsData(String code, String key) {} - public record ValidationResponse (boolean valid) {} -} diff --git a/src/main/java/com/condation/cms/modules/forms/handler/AjaxSubmitFormHandler.java b/src/main/java/com/condation/cms/modules/forms/handler/AjaxSubmitFormHandler.java index 5c32778..d0740a7 100644 --- a/src/main/java/com/condation/cms/modules/forms/handler/AjaxSubmitFormHandler.java +++ b/src/main/java/com/condation/cms/modules/forms/handler/AjaxSubmitFormHandler.java @@ -10,31 +10,29 @@ * it under the terms of the GNU General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. - * + * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. - * + * * You should have received a copy of the GNU General Public * License along with this program. If not, see * . * #L% */ + import com.condation.cms.api.extensions.HttpHandler; import com.condation.cms.api.hooks.HookSystem; import com.condation.cms.api.module.SiteModuleContext; -import com.condation.cms.modules.forms.FormsLifecycleExtension; +import com.condation.cms.modules.forms.FormsConfig; +import com.condation.cms.modules.forms.FormsFeature; import com.google.gson.Gson; import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; +import java.util.Map; import org.eclipse.jetty.http.HttpHeader; import org.eclipse.jetty.http.HttpStatus; import org.eclipse.jetty.http.MimeTypes; -import org.eclipse.jetty.http.MultiPart; -import org.eclipse.jetty.http.MultiPartFormData; import org.eclipse.jetty.io.Content; import org.eclipse.jetty.server.FormFields; import org.eclipse.jetty.server.Request; @@ -43,115 +41,110 @@ import org.eclipse.jetty.util.Fields; import org.eclipse.jetty.util.Promise; -/** - * - * @author t.marx - */ -@Slf4j -@RequiredArgsConstructor public class AjaxSubmitFormHandler implements HttpHandler { - private final static Gson GSON = new Gson(); + private static final Gson GSON = new Gson(); private final HookSystem hookSystem; - private final SiteModuleContext siteModuleContext; + public AjaxSubmitFormHandler(final HookSystem hookSystem, final SiteModuleContext siteModuleContext) { + this.hookSystem = hookSystem; + this.siteModuleContext = siteModuleContext; + } + @Override - public boolean handle(Request request, Response response, Callback callback) throws Exception { + public boolean handle(final Request request, final Response response, final Callback callback) { + response.getHeaders().put(HttpHeader.CONTENT_TYPE, "application/json; charset=utf-8"); + response.getHeaders().put(HttpHeader.CACHE_CONTROL, "no-store"); + + if (!"POST".equalsIgnoreCase(request.getMethod())) { + response.getHeaders().put(HttpHeader.ALLOW, "POST"); + write(response, callback, HttpStatus.METHOD_NOT_ALLOWED_405, + new FormResponse(false, "METHOD_NOT_ALLOWED", Map.of())); + return true; + } + + var feature = siteModuleContext.get(FormsFeature.class); + if (!RequestSecurity.isAllowed(request, feature.config().getCsrf())) { + write(response, callback, HttpStatus.FORBIDDEN_403, + new FormResponse(false, "CSRF_REJECTED", Map.of())); + return true; + } String contentType = request.getHeaders().get(HttpHeader.CONTENT_TYPE); - response.getHeaders().add(HttpHeader.CONTENT_TYPE, "application/json"); - - FormsHandling formHandling = new FormsHandling(hookSystem, siteModuleContext); - - try { - if (MimeTypes.Type.FORM_ENCODED.is(contentType)) { - FormFields.onFields(request, StandardCharsets.UTF_8, new Promise.Invocable() { - @Override - public void succeeded(Fields fields) { - FormResponse formResponse; - try { - final String formName = fields.get("form").getValue(); - var form = FormsLifecycleExtension.FORMSCONFIG.findForm(formName).get(); - formHandling.handleForm(form, (field) -> { - if (fields.get(field) != null) { - return fields.get(field).getValue(); - } - return field; - }); - formResponse = new FormResponse(false); - response.setStatus(HttpStatus.OK_200); - } catch (FormHandlingException fhe) { - log.error(null, fhe); - formResponse = new FormResponse(true); - response.setStatus(HttpStatus.BAD_REQUEST_400); - } - Content.Sink.write(response, true, GSON.toJson(formResponse), callback); - } - - @Override - public void failed(Throwable x) { - var formResponse = new FormResponse(true); - response.setStatus(HttpStatus.BAD_REQUEST_400); - Content.Sink.write(response, true, GSON.toJson(formResponse), callback); - } - }); - } else if (contentType.startsWith(MimeTypes.Type.MULTIPART_FORM_DATA.asString())) { - String boundary = MultiPart.extractBoundary(contentType); - MultiPartFormData.Parser parser = new MultiPartFormData.Parser(boundary); - parser.setFilesDirectory(Files.createTempDirectory("cms-upload")); - - parser.parse(request, new Promise.Invocable() { - @Override - public void failed(Throwable x) { - var formResponse = new FormResponse(true); - response.setStatus(HttpStatus.BAD_REQUEST_400); - Content.Sink.write(response, true, GSON.toJson(formResponse), callback); - } - - @Override - public void succeeded(MultiPartFormData.Parts parts) { - FormResponse formResponse; - try { - - String formName = parts.getFirst("form").getContentAsString(StandardCharsets.UTF_8); - var form = FormsLifecycleExtension.FORMSCONFIG.findForm(formName).get(); - formHandling.handleForm(form, (field) -> { - if (parts.getAll(field) != null && !parts.getAll(field).isEmpty()) { - return parts.getAll(field).getFirst().getContentAsString(StandardCharsets.UTF_8); - } - return field; - }); - - formResponse = new FormResponse(false); - response.setStatus(HttpStatus.OK_200); - } catch (FormHandlingException fhe) { - log.error(null, fhe); - formResponse = new FormResponse(true); - response.setStatus(HttpStatus.BAD_REQUEST_400); - } - Content.Sink.write(response, true, GSON.toJson(formResponse), callback); - } - - }); - } else { - var formResponse = new FormResponse(true); - response.setStatus(HttpStatus.BAD_REQUEST_400); - Content.Sink.write(response, true, GSON.toJson(formResponse), callback); - } - } catch (Exception e) { - log.error("error processing form", e); - var formResponse = new FormResponse(true); - response.setStatus(HttpStatus.BAD_REQUEST_400); - Content.Sink.write(response, true, GSON.toJson(formResponse), callback); + if (contentType == null || !MimeTypes.Type.FORM_ENCODED.is(contentType)) { + write(response, callback, HttpStatus.UNSUPPORTED_MEDIA_TYPE_415, + new FormResponse(false, "UNSUPPORTED_MEDIA_TYPE", Map.of())); + return true; } + var formHandling = new FormsHandling(hookSystem, siteModuleContext); + FormFields.onFields(request, StandardCharsets.UTF_8, new Promise.Invocable() { + @Override + public void failed(final Throwable failure) { + logger().log(System.Logger.Level.WARNING, "Could not parse AJAX form submission", failure); + write(response, callback, HttpStatus.BAD_REQUEST_400, + new FormResponse(false, "INVALID_REQUEST", Map.of())); + } + + @Override + public void succeeded(final Fields fields) { + FormsConfig.Form form = null; + try { + var formName = value(fields, "form"); + form = feature.config().findForm(formName) + .orElseThrow(() -> new FormHandlingException( + "UNKNOWN_FORM", "unknown form", null, Map.of())); + enforceRateLimit(request, feature, form); + var selectedForm = form; + formHandling.handleForm(selectedForm, name -> value(fields, name)); + write(response, callback, HttpStatus.OK_200, + new FormResponse(true, null, Map.of())); + } catch (FormHandlingException ex) { + logger().log(System.Logger.Level.INFO, "Rejected AJAX form submission: " + ex.getCode()); + int status = "RATE_LIMITED".equals(ex.getCode()) + ? HttpStatus.TOO_MANY_REQUESTS_429 : HttpStatus.BAD_REQUEST_400; + write(response, callback, status, + new FormResponse(false, ex.getCode(), ex.getFieldErrors())); + } catch (RuntimeException ex) { + logger().log(System.Logger.Level.ERROR, "Unexpected AJAX form submission error", ex); + write(response, callback, HttpStatus.INTERNAL_SERVER_ERROR_500, + new FormResponse(false, "INTERNAL_ERROR", Map.of())); + } + } + }); return true; } - private static record FormResponse(boolean error) { + private void enforceRateLimit( + final Request request, + final FormsFeature feature, + final FormsConfig.Form form) throws FormHandlingException { + var client = RequestSecurity.clientIdentifier(request); + if (!feature.allow("submit:" + form.getName() + ":" + client, form.getRateLimit())) { + throw new FormHandlingException("RATE_LIMITED", "rate limit exceeded", form, Map.of()); + } + } + + private static String value(final Fields fields, final String name) { + var field = fields.get(name); + return field == null ? null : field.getValue(); + } + + private static void write( + final Response response, + final Callback callback, + final int status, + final FormResponse formResponse) { + response.setStatus(status); + Content.Sink.write(response, true, GSON.toJson(formResponse), callback); + } + + private record FormResponse(boolean success, String code, Map fieldErrors) { + } + private static System.Logger logger() { + return System.getLogger(AjaxSubmitFormHandler.class.getName()); } -; } diff --git a/src/main/java/com/condation/cms/modules/forms/handler/FormHandlingException.java b/src/main/java/com/condation/cms/modules/forms/handler/FormHandlingException.java index dbcd7b1..c15c5e2 100644 --- a/src/main/java/com/condation/cms/modules/forms/handler/FormHandlingException.java +++ b/src/main/java/com/condation/cms/modules/forms/handler/FormHandlingException.java @@ -24,6 +24,7 @@ import com.condation.cms.modules.forms.FormsConfig; +import java.util.Map; import java.util.Optional; /** @@ -33,11 +34,14 @@ public class FormHandlingException extends Exception { private FormsConfig.Form form = null; + private final String code; + private final Map fieldErrors; /** * Creates a new instance of FormHandlingException without detail message. */ public FormHandlingException() { + this("FORM_ERROR", "form handling failed", null, Map.of()); } /** @@ -46,15 +50,33 @@ public FormHandlingException() { * @param msg the detail message. */ public FormHandlingException(String msg) { - super(msg); + this("FORM_ERROR", msg, null, Map.of()); } public FormHandlingException(String msg, final FormsConfig.Form form) { + this("FORM_ERROR", msg, form, Map.of()); + } + + public FormHandlingException( + final String code, + final String msg, + final FormsConfig.Form form, + final Map fieldErrors) { super(msg); this.form = form; + this.code = code; + this.fieldErrors = Map.copyOf(fieldErrors); } public Optional getForm () { return Optional.ofNullable(form); } + + public String getCode() { + return code; + } + + public Map getFieldErrors() { + return fieldErrors; + } } diff --git a/src/main/java/com/condation/cms/modules/forms/handler/FormsHandling.java b/src/main/java/com/condation/cms/modules/forms/handler/FormsHandling.java index e291b68..0ae123b 100644 --- a/src/main/java/com/condation/cms/modules/forms/handler/FormsHandling.java +++ b/src/main/java/com/condation/cms/modules/forms/handler/FormsHandling.java @@ -29,41 +29,61 @@ import com.condation.cms.api.mail.Message; import com.condation.cms.api.module.SiteModuleContext; import com.condation.cms.modules.forms.FormsConfig; -import com.condation.cms.modules.forms.FormsLifecycleExtension; +import com.condation.cms.modules.forms.FormsFeature; import com.condation.cms.modules.forms.utils.StringUtil; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.Map; +import java.util.Set; import java.util.function.Function; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; +import java.util.regex.Pattern; /** * * @author t.marx */ -@RequiredArgsConstructor -@Slf4j public class FormsHandling { + private static final Pattern EMAIL = Pattern.compile( + "^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$"); + private static final Set TRUE_VALUES = Set.of("true", "1", "on", "yes"); + private static final Set FALSE_VALUES = Set.of("false", "0", "off", "no"); + private final HookSystem hookSystem; private final SiteModuleContext siteModuleContext; + + public FormsHandling(final HookSystem hookSystem, final SiteModuleContext siteModuleContext) { + this.hookSystem = hookSystem; + this.siteModuleContext = siteModuleContext; + } private void validateCaptcha(final FormsConfig.Form form, final String key, final String code) throws FormHandlingException { - String captchaCode = FormsLifecycleExtension.CAPTCHAS.getIfPresent(key); - if (captchaCode == null || !captchaCode.equals(code)) { - throw new FormHandlingException("invalid captcha", form); + var captchas = siteModuleContext.get(FormsFeature.class).captchas(); + var challenge = key == null ? null : captchas.getIfPresent(key); + if (challenge == null || challenge.formName() != null && !challenge.formName().equals(form.getName()) + || code == null || !challenge.answer().equalsIgnoreCase(code.trim())) { + if (challenge != null) { + if (challenge.attempts() >= 4) { + captchas.invalidate(key); + } else { + captchas.put(key, challenge.failedAttempt()); + } + } + throw new FormHandlingException("INVALID_CAPTCHA", "invalid captcha", form, Map.of()); } - + captchas.invalidate(key); } private String buildMessage(final FormsConfig.Form form, final Function parameters) { StringBuilder message = new StringBuilder(); if (form.getFields() != null) { - form.getFields().forEach(field -> { + form.getFields().keySet().forEach(field -> { var value = parameters.apply(field); - message.append("field: ").append(field).append("\r\n").append(value); + message.append(field).append(":\r\n") + .append(value == null ? "" : value) + .append("\r\n\r\n"); }); } @@ -74,7 +94,7 @@ private Map hookData (final FormsConfig.Form form, final Functio Map data = new HashMap<>(); if (form.getFields() != null) { - form.getFields().forEach(field -> { + form.getFields().keySet().forEach(field -> { var value = parameters.apply(field); data.put(field, value); }); @@ -86,18 +106,71 @@ private Map hookData (final FormsConfig.Form form, final Functio return data; } + + private void validateSpam(final FormsConfig.Form form, final Function parameters) + throws FormHandlingException { + var spam = form.getSpam(); + if (spam != null && spam.getHoneypot() != null && spam.getHoneypot().isEnabled()) { + var value = parameters.apply(spam.getHoneypot().getField()); + if (!StringUtil.isNullOrEmpty(value)) { + throw new FormHandlingException("SPAM_REJECTED", "submission rejected", form, Map.of()); + } + } + } + + private void validateFields(final FormsConfig.Form form, final Function parameters) + throws FormHandlingException { + var errors = new LinkedHashMap(); + form.getFields().forEach((name, definition) -> { + var value = parameters.apply(name); + if (StringUtil.isNullOrEmpty(value)) { + if (definition.isRequired()) { + errors.put(name, "required"); + } + return; + } + + var normalized = value.trim(); + if (definition.getMinLength() != null && normalized.length() < definition.getMinLength()) { + errors.put(name, "min_length"); + } else if (definition.getMaxLength() != null && normalized.length() > definition.getMaxLength()) { + errors.put(name, "max_length"); + } else if ("email".equals(definition.getType()) && !EMAIL.matcher(normalized).matches()) { + errors.put(name, "invalid_email"); + } else if ("integer".equals(definition.getType())) { + try { + Long.valueOf(normalized); + } catch (NumberFormatException ex) { + errors.put(name, "invalid_integer"); + } + } else if ("boolean".equals(definition.getType()) + && !TRUE_VALUES.contains(normalized.toLowerCase()) + && !FALSE_VALUES.contains(normalized.toLowerCase())) { + errors.put(name, "invalid_boolean"); + } else if (definition.getPattern() != null + && !Pattern.compile(definition.getPattern()).matcher(value).matches()) { + errors.put(name, "pattern"); + } else if (definition.getAllowedValues() != null + && !definition.getAllowedValues().isEmpty() + && !definition.getAllowedValues().contains(value)) { + errors.put(name, "not_allowed"); + } + }); + if (!errors.isEmpty()) { + throw new FormHandlingException( + "VALIDATION_FAILED", "field validation failed", form, errors); + } + } public void handleForm(final FormsConfig.Form form, final Function parameters) throws FormHandlingException { - try { - final String key = parameters.apply("key"); - String captchaCode = FormsLifecycleExtension.CAPTCHAS.getIfPresent(key); - - validateCaptcha(form, key, captchaCode); - FormsLifecycleExtension.CAPTCHAS.invalidate(key); + validateSpam(form, parameters); + validateFields(form, parameters); + validateCaptcha(form, parameters.apply("key"), parameters.apply("code")); + try { var data = hookData(form, parameters); data.put("form", form.getName()); - hookSystem.execute( + hookSystem.doAction( "forms/%s/submit".formatted(form.getName()), data); @@ -108,16 +181,23 @@ public void handleForm(final FormsConfig.Form form, final Function> queryParameters, final int defaultValue) { String sizeParam = queryParameters.getOrDefault(name, List.of(String.valueOf(defaultValue))).get(0); - - int intValue = Integer.parseInt(sizeParam.trim()); - if (intValue > defaultValue) { + + try { + int intValue = Integer.parseInt(sizeParam.trim()); + return Math.clamp(intValue, MIN_CAPTCHA_SIZE, defaultValue); + } catch (NumberFormatException ex) { return defaultValue; - } else { - return intValue; } } + private String first(final Map> parameters, final String name) { + var values = parameters.get(name); + return values == null || values.isEmpty() ? null : values.getFirst(); + } + + private boolean validKey(final String key) { + return key != null && key.length() >= 32 && key.length() <= 128 + && key.matches("[A-Za-z0-9_-]+"); + } } diff --git a/src/main/java/com/condation/cms/modules/forms/handler/RequestSecurity.java b/src/main/java/com/condation/cms/modules/forms/handler/RequestSecurity.java new file mode 100644 index 0000000..95e0939 --- /dev/null +++ b/src/main/java/com/condation/cms/modules/forms/handler/RequestSecurity.java @@ -0,0 +1,79 @@ +package com.condation.cms.modules.forms.handler; + +/*- + * #%L + * forms-module + * %% + * Copyright (C) 2024 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public + * License along with this program. If not, see + * . + * #L% + */ + +import com.condation.cms.modules.forms.FormsConfig; +import java.net.InetSocketAddress; +import java.net.URI; +import java.util.Locale; +import org.eclipse.jetty.http.HttpHeader; +import org.eclipse.jetty.server.Request; + +final class RequestSecurity { + + private RequestSecurity() { + } + + static boolean isAllowed(final Request request, final FormsConfig.Csrf policy) { + if (policy == null || !policy.isEnabled()) { + return true; + } + + String origin = request.getHeaders().get(HttpHeader.ORIGIN); + if (origin != null && policy.getAllowedOrigins() != null + && policy.getAllowedOrigins().contains(origin)) { + return true; + } + + String fetchSite = request.getHeaders().get("Sec-Fetch-Site"); + if ("cross-site".equalsIgnoreCase(fetchSite)) { + return false; + } + if (origin == null) { + return true; + } + + String host = request.getHeaders().get(HttpHeader.HOST); + if (host == null) { + return false; + } + try { + var originUri = URI.create(origin); + return originUri.getRawAuthority() != null + && originUri.getRawAuthority().toLowerCase(Locale.ROOT) + .equals(host.toLowerCase(Locale.ROOT)); + } catch (IllegalArgumentException ex) { + return false; + } + } + + static String clientIdentifier(final Request request) { + var remote = request.getConnectionMetaData().getRemoteSocketAddress(); + if (remote instanceof InetSocketAddress inet) { + return inet.getAddress() == null + ? inet.getHostString() + : inet.getAddress().getHostAddress(); + } + return String.valueOf(remote); + } +} diff --git a/src/main/java/com/condation/cms/modules/forms/handler/SubmitFormHandler.java b/src/main/java/com/condation/cms/modules/forms/handler/SubmitFormHandler.java index b0e5e1a..ad5a310 100644 --- a/src/main/java/com/condation/cms/modules/forms/handler/SubmitFormHandler.java +++ b/src/main/java/com/condation/cms/modules/forms/handler/SubmitFormHandler.java @@ -10,32 +10,27 @@ * it under the terms of the GNU General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. - * + * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. - * + * * You should have received a copy of the GNU General Public * License along with this program. If not, see * . * #L% */ + import com.condation.cms.api.extensions.HttpHandler; import com.condation.cms.api.hooks.HookSystem; import com.condation.cms.api.module.SiteModuleContext; -import com.condation.cms.modules.forms.FormsLifecycleExtension; -import com.google.common.base.Strings; -import com.google.gson.Gson; +import com.condation.cms.modules.forms.FormsConfig; +import com.condation.cms.modules.forms.FormsFeature; import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; import org.eclipse.jetty.http.HttpHeader; import org.eclipse.jetty.http.HttpStatus; import org.eclipse.jetty.http.MimeTypes; -import org.eclipse.jetty.http.MultiPart; -import org.eclipse.jetty.http.MultiPartFormData; import org.eclipse.jetty.server.FormFields; import org.eclipse.jetty.server.Request; import org.eclipse.jetty.server.Response; @@ -44,126 +39,96 @@ import org.eclipse.jetty.util.Promise; /** - * - * @author t.marx + * Handles browser form submissions. Uploads are deliberately rejected until a + * bounded and validated upload policy exists. */ -@Slf4j -@RequiredArgsConstructor public class SubmitFormHandler implements HttpHandler { - private static Gson GSON = new Gson(); - private final HookSystem hookSystem; - private final SiteModuleContext siteModuleContext; - @Override - public boolean handle(Request request, Response response, Callback callback) throws Exception { + public SubmitFormHandler(final HookSystem hookSystem, final SiteModuleContext siteModuleContext) { + this.hookSystem = hookSystem; + this.siteModuleContext = siteModuleContext; + } + @Override + public boolean handle(final Request request, final Response response, final Callback callback) { if (!"POST".equalsIgnoreCase(request.getMethod())) { + response.getHeaders().put(HttpHeader.ALLOW, "POST"); Response.writeError(request, response, callback, HttpStatus.METHOD_NOT_ALLOWED_405, "invalid request"); return true; } + var feature = siteModuleContext.get(FormsFeature.class); + if (!RequestSecurity.isAllowed(request, feature.config().getCsrf())) { + redirect(response, callback, feature.config().errorRedirect(null)); + return true; + } String contentType = request.getHeaders().get(HttpHeader.CONTENT_TYPE); + if (contentType == null || !MimeTypes.Type.FORM_ENCODED.is(contentType)) { + redirect(response, callback, feature.config().errorRedirect(null)); + return true; + } - FormsHandling formHandling = new FormsHandling(hookSystem, siteModuleContext); - - try { - if (MimeTypes.Type.FORM_ENCODED.is(contentType)) { - - FormFields.onFields(request, StandardCharsets.UTF_8, new Promise.Invocable() { - @Override - public void failed(Throwable x) { - response.getHeaders().add("Location", FormsLifecycleExtension.FORMSCONFIG.getRedirects().getError()); - response.setStatus(HttpStatus.MOVED_TEMPORARILY_302); - callback.succeeded(); - } - - @Override - public void succeeded(Fields fields) { - try { - - final String formName = fields.get("form").getValue(); - var form = FormsLifecycleExtension.FORMSCONFIG.findForm(formName).get(); - formHandling.handleForm(form, (field) -> { - if (fields.get(field) != null) { - return fields.get(field).getValue(); - } - return field; - }); - response.getHeaders().add("Location", form.getRedirects().getSuccess()); - response.setStatus(HttpStatus.MOVED_TEMPORARILY_302); - } catch (FormHandlingException fhe) { - log.error(null, fhe); - var formOpt = fhe.getForm(); - if (formOpt.isPresent() && !Strings.isNullOrEmpty(formOpt.get().getRedirects().getError())) { - response.getHeaders().add("Location", formOpt.get().getRedirects().getError()); - response.setStatus(HttpStatus.MOVED_TEMPORARILY_302); - } else { - response.getHeaders().add("Location", FormsLifecycleExtension.FORMSCONFIG.getRedirects().getError()); - response.setStatus(HttpStatus.MOVED_TEMPORARILY_302); - } - } finally { - callback.succeeded(); - } - } - }); - } else if (contentType.startsWith(MimeTypes.Type.MULTIPART_FORM_DATA.asString())) { - String boundary = MultiPart.extractBoundary(contentType); - MultiPartFormData.Parser parser = new MultiPartFormData.Parser(boundary); - parser.setFilesDirectory(Files.createTempDirectory("cms-upload")); - parser.parse(request, new Promise.Invocable() { - @Override - public void failed(Throwable x) { - response.getHeaders().add("Location", FormsLifecycleExtension.FORMSCONFIG.getRedirects().getError()); - response.setStatus(HttpStatus.MOVED_TEMPORARILY_302); - callback.succeeded(); - } + var formHandling = new FormsHandling(hookSystem, siteModuleContext); + FormFields.onFields(request, StandardCharsets.UTF_8, new Promise.Invocable() { + @Override + public void failed(final Throwable failure) { + logger().log(System.Logger.Level.WARNING, "Could not parse form submission", failure); + redirect(response, callback, feature.config().errorRedirect(null)); + } - @Override - public void succeeded(MultiPartFormData.Parts parts) { - try { + @Override + public void succeeded(final Fields fields) { + FormsConfig.Form form = null; + try { + var formName = value(fields, "form"); + form = feature.config().findForm(formName) + .orElseThrow(() -> new FormHandlingException( + "UNKNOWN_FORM", "unknown form", null, java.util.Map.of())); + enforceRateLimit(request, feature, form); + var selectedForm = form; + formHandling.handleForm(selectedForm, name -> value(fields, name)); + redirect(response, callback, feature.config().successRedirect(selectedForm)); + } catch (FormHandlingException ex) { + logger().log(System.Logger.Level.INFO, "Rejected form submission: " + ex.getCode()); + redirect(response, callback, feature.config().errorRedirect(form)); + } catch (RuntimeException ex) { + logger().log(System.Logger.Level.ERROR, "Unexpected form submission error", ex); + redirect(response, callback, feature.config().errorRedirect(form)); + } + } + }); + return true; + } - String formName = parts.getFirst("form").getContentAsString(StandardCharsets.UTF_8); - var form = FormsLifecycleExtension.FORMSCONFIG.findForm(formName).get(); - formHandling.handleForm(form, (field) -> { - if (parts.getAll(field) != null && !parts.getAll(field).isEmpty()) { - return parts.getAll(field).getFirst().getContentAsString(StandardCharsets.UTF_8); - } - return field; - }); + private void enforceRateLimit( + final Request request, + final FormsFeature feature, + final FormsConfig.Form form) throws FormHandlingException { + var client = RequestSecurity.clientIdentifier(request); + if (!feature.allow("submit:" + form.getName() + ":" + client, form.getRateLimit())) { + throw new FormHandlingException( + "RATE_LIMITED", "rate limit exceeded", form, java.util.Map.of()); + } + } - response.getHeaders().add("Location", form.getRedirects().getSuccess()); - response.setStatus(HttpStatus.MOVED_TEMPORARILY_302); + private static String value(final Fields fields, final String name) { + var field = fields.get(name); + return field == null ? null : field.getValue(); + } - } catch (FormHandlingException fhe) { - log.error(null, fhe); - var formOpt = fhe.getForm(); - if (formOpt.isPresent() && !Strings.isNullOrEmpty(formOpt.get().getRedirects().getError())) { - response.getHeaders().add("Location", formOpt.get().getRedirects().getError()); - response.setStatus(HttpStatus.MOVED_TEMPORARILY_302); - } else { - response.getHeaders().add("Location", FormsLifecycleExtension.FORMSCONFIG.getRedirects().getError()); - response.setStatus(HttpStatus.MOVED_TEMPORARILY_302); - } - } finally { - callback.succeeded(); - } - } + private static void redirect( + final Response response, + final Callback callback, + final String location) { + response.getHeaders().put(HttpHeader.LOCATION, location); + response.setStatus(HttpStatus.SEE_OTHER_303); + callback.succeeded(); + } - }); - } else { - response.getHeaders().add("Location", FormsLifecycleExtension.FORMSCONFIG.getRedirects().getError()); - response.setStatus(HttpStatus.MOVED_TEMPORARILY_302); - callback.succeeded(); - } - } catch (Exception e) { - log.error("error processing form", e); - response.getHeaders().add("Location", FormsLifecycleExtension.FORMSCONFIG.getRedirects().getError()); - response.setStatus(HttpStatus.MOVED_TEMPORARILY_302); - callback.succeeded(); - } - return true; + private static System.Logger logger() { + return System.getLogger(SubmitFormHandler.class.getName()); } } diff --git a/src/main/java/com/condation/cms/modules/forms/template/FormsTemplateModel.java b/src/main/java/com/condation/cms/modules/forms/template/FormsTemplateModel.java index e585b99..82c2692 100644 --- a/src/main/java/com/condation/cms/modules/forms/template/FormsTemplateModel.java +++ b/src/main/java/com/condation/cms/modules/forms/template/FormsTemplateModel.java @@ -37,7 +37,7 @@ public Captcha getCaptcha () { return captcha; } - public class Captcha { + public static class Captcha { public String generateKey () { return StringUtil.random_string(); } diff --git a/src/main/java/com/condation/cms/modules/forms/utils/StringUtil.java b/src/main/java/com/condation/cms/modules/forms/utils/StringUtil.java index 3f84ce2..f163856 100644 --- a/src/main/java/com/condation/cms/modules/forms/utils/StringUtil.java +++ b/src/main/java/com/condation/cms/modules/forms/utils/StringUtil.java @@ -23,7 +23,8 @@ */ -import java.util.Random; +import java.security.SecureRandom; +import java.util.Base64; /** * @@ -31,18 +32,12 @@ */ public class StringUtil { - static Random random = new Random(); + private static final SecureRandom RANDOM = new SecureRandom(); public static String random_string() { - int leftLimit = 48; // numeral '0' - int rightLimit = 122; // letter 'z' - int targetStringLength = 10; - - return random.ints(leftLimit, rightLimit + 1) - .filter(i -> (i <= 57 || i >= 65) && (i <= 90 || i >= 97)) - .limit(targetStringLength) - .collect(StringBuilder::new, StringBuilder::appendCodePoint, StringBuilder::append) - .toString(); + var bytes = new byte[24]; + RANDOM.nextBytes(bytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); } public static boolean isNullOrEmpty (String value) { diff --git a/src/test/java/com/condation/cms/modules/forms/FormConfigTest.java b/src/test/java/com/condation/cms/modules/forms/FormConfigTest.java index d774c30..5dc078e 100644 --- a/src/test/java/com/condation/cms/modules/forms/FormConfigTest.java +++ b/src/test/java/com/condation/cms/modules/forms/FormConfigTest.java @@ -39,11 +39,16 @@ public class FormConfigTest { void test_config () throws Exception { var FORMSCONFIG = new Yaml().loadAs( Files.readString(Path.of("src/test/resources/config/forms.yaml"), StandardCharsets.UTF_8), FormsConfig.class); + FORMSCONFIG.validate(); Assertions.assertThat(FORMSCONFIG.findForm("contact")).isPresent(); Assertions.assertThat(FORMSCONFIG.findForm("test-form")).isPresent(); + Assertions.assertThat(FORMSCONFIG.findForm("missing")).isEmpty(); Assertions.assertThat(FORMSCONFIG.findForm("contact").get().getMail().getAccount()).isEqualTo("default"); + Assertions.assertThat(FORMSCONFIG.findForm("contact").get().getMail().getFrom()).isEqualTo("forms@example.com"); + Assertions.assertThat(FORMSCONFIG.findForm("contact").get().getFields().get("from").getType()).isEqualTo("email"); + Assertions.assertThat(FORMSCONFIG.findForm("contact").get().getFields().get("message").getMinLength()).isEqualTo(10); Assertions.assertThat(FORMSCONFIG.findForm("test-form").get().getMail().getAccount()).isEqualTo("other"); } } diff --git a/src/test/java/com/condation/cms/modules/forms/FormsHandlingTest.java b/src/test/java/com/condation/cms/modules/forms/FormsHandlingTest.java new file mode 100644 index 0000000..048d1f4 --- /dev/null +++ b/src/test/java/com/condation/cms/modules/forms/FormsHandlingTest.java @@ -0,0 +1,116 @@ +package com.condation.cms.modules.forms; + +/*- + * #%L + * forms-module + * %% + * Copyright (C) 2024 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public + * License along with this program. If not, see + * . + * #L% + */ + +import com.condation.cms.api.hooks.HookSystem; +import com.condation.cms.api.module.SiteModuleContext; +import com.condation.cms.modules.forms.handler.FormHandlingException; +import com.condation.cms.modules.forms.handler.FormsHandling; +import java.lang.reflect.Proxy; +import java.util.LinkedHashMap; +import java.util.Map; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class FormsHandlingTest { + + private FormsConfig.Form form; + private FormsFeature feature; + private FormsHandling handling; + + @BeforeEach + void setUp() { + form = new FormsConfig.Form(); + form.setName("contact"); + var email = new FormsConfig.Field(); + email.setType("email"); + email.setRequired(true); + form.setFields(Map.of("email", email)); + + var config = new FormsConfig(); + config.setForms(java.util.List.of(form)); + feature = new FormsFeature(config); + + var context = new SiteModuleContext(); + context.add(FormsFeature.class, feature); + var hooks = (HookSystem) Proxy.newProxyInstance( + HookSystem.class.getClassLoader(), + new Class[]{HookSystem.class}, + (proxy, method, arguments) -> method.getName().equals("doAction") + ? java.util.List.of() : null); + handling = new FormsHandling(hooks, context); + } + + @Test + void rejectsSubmittedCaptchaCodeInsteadOfComparingStoredValueWithItself() { + feature.captchas().put("key", new FormsFeature.CaptchaChallenge("correct", "contact", 0)); + var values = validValues(); + values.put("code", "wrong"); + + Assertions.assertThatThrownBy(() -> handling.handleForm(form, values::get)) + .isInstanceOf(FormHandlingException.class) + .extracting("code") + .isEqualTo("INVALID_CAPTCHA"); + } + + @Test + void acceptsAndConsumesCorrectCaptcha() throws Exception { + feature.captchas().put("key", new FormsFeature.CaptchaChallenge("correct", "contact", 0)); + handling.handleForm(form, validValues()::get); + + Assertions.assertThat(feature.captchas().getIfPresent("key")).isNull(); + } + + @Test + void reportsMissingRequiredFields() { + feature.captchas().put("key", new FormsFeature.CaptchaChallenge("correct", "contact", 0)); + var values = validValues(); + values.remove("email"); + + Assertions.assertThatThrownBy(() -> handling.handleForm(form, values::get)) + .isInstanceOf(FormHandlingException.class) + .satisfies(ex -> Assertions.assertThat(((FormHandlingException) ex).getFieldErrors()) + .containsEntry("email", "required")); + } + + @Test + void rejectsFilledHoneypot() { + form.getSpam().getHoneypot().setEnabled(true); + var values = validValues(); + values.put("website", "https://spam.example"); + + Assertions.assertThatThrownBy(() -> handling.handleForm(form, values::get)) + .isInstanceOf(FormHandlingException.class) + .extracting("code") + .isEqualTo("SPAM_REJECTED"); + } + + private Map validValues() { + var values = new LinkedHashMap(); + values.put("email", "visitor@example.com"); + values.put("key", "key"); + values.put("code", "correct"); + return values; + } +} diff --git a/src/test/resources/config/forms.yaml b/src/test/resources/config/forms.yaml index 2585c24..99c372a 100644 --- a/src/test/resources/config/forms.yaml +++ b/src/test/resources/config/forms.yaml @@ -2,13 +2,24 @@ forms: - name: contact to: contact@example.com subject: Ich suche Kontakt! - fields: [message] + fields: + from: + type: email + required: true + message: + required: true + minLength: 10 + maxLength: 5000 + mail: + from: forms@example.com redirects: success: /forms/contact/success - name: test-form + fields: + message: {} redirects: success: /forms/contact/success mail: account: other redirects: - error: /forms/error \ No newline at end of file + error: /forms/error From 85a951e3331e6a8be3c23a4a3f71121e6711165c Mon Sep 17 00:00:00 2001 From: Thorsten Marx Date: Mon, 27 Jul 2026 13:20:45 +0200 Subject: [PATCH 3/9] Add design spec for forms-module E2E tests and optional captcha Documents the plan to adapt the copied video-module E2ETest/test-server to forms-module, fix the missing module-deploy step that leaves the CMS server with 0 loaded extensions, and make captcha optional per form so automated E2E tests don't need to solve captchas. --- ...7-e2e-tests-and-optional-captcha-design.md | 204 ++++++++++++++++++ 1 file changed, 204 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-27-e2e-tests-and-optional-captcha-design.md diff --git a/docs/superpowers/specs/2026-07-27-e2e-tests-and-optional-captcha-design.md b/docs/superpowers/specs/2026-07-27-e2e-tests-and-optional-captcha-design.md new file mode 100644 index 0000000..7f8adb3 --- /dev/null +++ b/docs/superpowers/specs/2026-07-27-e2e-tests-and-optional-captcha-design.md @@ -0,0 +1,204 @@ +# E2E-Tests für forms-module + optionales Captcha + +## Kontext + +Die Klasse `E2ETest.java` und der `test-server/`-Ordner wurden 1:1 aus dem +`video-module` kopiert (siehe `module/src/test/java/.../e2e/E2ETest.java`, +`test-server/`) und referenzieren noch video-module-Inhalte (Titel +"video-module test page", `/module/video-module/...`). Sie müssen an +forms-module angepasst werden. + +Der alte `demo/`-Ordner (Beispielprojekt) nutzt Thymeleaf-Templatesyntax +(`th:replace`, `th:utext`, `th:with`), die von der aktuellen Template-Engine +nicht mehr unterstützt wird. `demo/` bleibt in diesem Vorhaben **unangetastet** +— eine Migration auf die neue Syntax ist ein separates, späteres Thema. + +Das Modul erzwingt aktuell in jedem Formular ein Captcha +(`FormsHandling.validateCaptcha(...)` wird unconditional aufgerufen). Für +automatisiertes E2E-Testing soll das Captcha pro Formular abschaltbar sein. + +Ein Vorab-Testlauf des video-module-Vorbilds +(`video-module/target/surefire-reports/....E2ETest.txt`) zeigt, dass dessen +Setup selbst kaputt ist: der Server meldet `Loaded 0 extension libraries`, +weil das gebaute Modul-JAR nie nach `test-server/modules//libs/` deployt +wird. Dieses Problem wird für forms-module mitbehoben. + +## Ziele + +1. Captcha ist pro Formular deaktivierbar (Config-Flag), Default bleibt "an" + (kein Breaking Change für bestehende Configs). +2. Das forms-module-JAR wird beim Build automatisch nach + `test-server/modules/forms-module/` deployt, sodass der CMS-Server im Test + das Modul tatsächlich lädt. +3. `test-server/` enthält eine funktionierende, auf forms-module zugeschnittene + Site-Konfiguration mit zwei Formularen (normal + AJAX), jeweils ohne + Captcha-Pflicht, plus Mail-Konfiguration für einen lokalen Test-SMTP-Server. +4. Ein Satz Playwright-basierter E2E-Tests deckt den Kernablauf (Erfolg, + Validierungsfehler, Spam/Honeypot, AJAX-Erfolg, AJAX-Fehler) ab und prüft + bei erfolgreicher Einreichung auch den tatsächlichen Mailversand via + GreenMail. +5. `mvn verify` baut, deployt und führt die E2E-Tests aus, ohne manuelle + Zwischenschritte. Normale Unit-Tests laufen unverändert in der + `test`-Phase. + +## Nicht-Ziele + +- Migration von `demo/` auf die neue Template-Syntax. +- Änderungen an Rate-Limiting, CSRF oder sonstigen bestehenden + Sicherheits-Mechanismen. +- Neue Formular-Feature (z.B. neue Feldtypen). + +## Design + +### 1. Captcha optional (Config + Handling) + +`FormsConfig.Form` erhält ein neues verschachteltes Feld: + +```java +private Captcha captcha = new Captcha(); + +@Data +public static class Captcha { + private boolean enabled = true; +} +``` + +Default `true` → bestehende YAML-Configs ohne `captcha:`-Block verhalten sich +exakt wie bisher. + +`FormsHandling.handleForm(...)` ruft `validateCaptcha(form, key, code)` nur +noch auf, wenn `form.getCaptcha().isEnabled()` true ist. Ist Captcha +deaktiviert, werden `key`/`code` nicht ausgewertet — die Submission braucht +diese Parameter nicht, und `GenerateCaptchaHandler` muss vom Formular-Template +nicht aufgerufen werden. + +`FormConfigTest` bzw. ein neuer Test deckt ab: Default `captcha.enabled=true`, +explizit `false` überschreibbar, `FormsHandlingTest` bekommt einen Fall für +ein Formular mit deaktiviertem Captcha (kein `key`/`code` nötig, keine +`INVALID_CAPTCHA`-Exception). + +### 2. Build/Deploy-Pipeline für den Modultest + +**Problem:** Das Modul-JAR (inkl. `libs/`-Runtime-Deps) entsteht erst in der +Maven-Phase `package`. E2E-Tests, die einen echten CMS-Server mit geladenem +Modul brauchen, müssen also *nach* `package` laufen — normale Unit-Tests +(Surefire) laufen aber in der früheren Phase `test`. + +**Lösung:** + +- `module/src/main/assembly/assembly.xml`: zusätzlich zum bisherigen + `zip`-Format ein `dir`-Format ergänzen. Maven erzeugt dadurch beim + `package`-Ziel automatisch einen Verzeichnisbaum + `target/forms-module-bin/` mit dem korrekten Modul-Layout + (`module.properties` im Root, `libs/*.jar` inkl. Runtime-Dependencies). +- `module/pom.xml`: neue Execution des `maven-resources-plugin` + (`copy-resources`) in Phase `pre-integration-test`, die + `target/forms-module-bin/**` nach `test-server/modules/forms-module/` + kopiert (überschreibend, damit Re-Builds den Stand aktuell halten). +- `maven-failsafe-plugin` wird ergänzt (Standard-Includes + `**/*IT.java`, gebunden an `integration-test`/`verify`). +- `E2ETest.java` wird zu `E2EIT.java` umbenannt (gleiches Package + `com.condation.cms.modules.forms.e2e`), damit Failsafe statt Surefire + greift und der Test erst nach dem Kopierschritt läuft. +- `.gitignore` (im Modul-Root) wird um Build-/Laufzeit-Artefakte ergänzt, die + aktuell fehlen: `test-server/modules/`, `test-server/logs/`, + `test-server/cms.pid`, `test-server/hosts/demo/modules_data/`, + `test-server/hosts/demo/temp/`, `test-server/hosts/demo/data/`. + +Ergebnis: `mvn verify` (oder `mvn install`) baut das Modul, kopiert es +automatisch ins Test-Server-Layout und führt anschließend die E2E-Tests +gegen einen echten, das Modul ladenden CMS-Server aus. `mvn test` bleibt +schnell und deckt nur die bestehenden Unit-Tests ab. + +### 3. test-server-Inhalte + +- `hosts/demo/site.toml`: `[modules] active = ["forms-module"]` aktivieren + (statt auskommentiertem `videos-module`-Platzhalter). +- `hosts/demo/config/forms.yaml` (neu): zwei Formulare — + - `contact`: Felder `from` (email, required), `message` (required, + minLength); `captcha.enabled: false`; Honeypot aktiviert + (`spam.honeypot.enabled: true`, Feld `website`); `mail.account: default`; + `redirects.success: /forms/contact/success`; `rateLimit.enabled: false` + (damit die Testreihe nicht ins Rate-Limit läuft). + - `ajax-contact`: gleiche Feldstruktur, ebenfalls `captcha.enabled: false`, + `rateLimit.enabled: false`, kein `to`/Mailversand nötig (AJAX-Pfad testet + nur JSON-Antwort, nicht Mail). + - Globale `redirects.error: /forms/error`. +- `hosts/demo/config/mail.yaml` (neu): `accounts.default` mit `host: + localhost`, `port: 3025`, `fromMail`, `username`/`password` passend zur + GreenMail-Testkonfiguration im E2E-Test. +- Templates (Pebble-Syntax, siehe reales Vorbild + `demo/condation-server/themes/demo/templates/contact.html` im + Gesamtworkspace): + - `hosts/demo/templates/contact.html`: normales ``, + `method="post"`, `action="/module/forms-module/form/submit"`, Felder + `from`/`message`, Honeypot-Feld `website` (versteckt), **kein** + Captcha-Markup. + - `hosts/demo/templates/ajax.html`: analoges Formular, `action=".../form/submit/ajax"`, + per `fetch()` abgeschickt (Skript analog zu `demo/hosts/demo/assets/form-1.js`, + ohne die Captcha-Reload-Logik), erwartet JSON-Antwort + `{success, code, fieldErrors}`. +- Content: + - `hosts/demo/content/contact.md` (`template: contact.html`) + - `hosts/demo/content/ajax.md` (`template: ajax.html`) + - `hosts/demo/content/forms/contact/success.md` + - `hosts/demo/content/forms/error.md` + - bestehendes `content/index.md` bleibt (Startseite), Titel wird auf einen + forms-module-spezifischen Text angepasst (`node.meta.title` wird im + Basistest geprüft). +- Aufräumen: Video-spezifische Leftovers (`assets/thumbnails/mountains.jpg`, + `config/media.toml`), sofern sie von den neuen Templates nicht referenziert + werden. + +### 4. E2E-Testfälle (`E2EIT.java`) + +`GreenMailExtension` mit fixem Port 3025 +(`new ServerSetup(3025, null, ServerSetup.PROTOCOL_SMTP)`) wird als +`@RegisterExtension`-Feld **vor** `CMSServerExtension` deklariert, damit der +SMTP-Server steht, bevor der CMS-Prozess (der `config/mail.yaml` beim ersten +Mailversand liest) benötigt wird. Da `CMSServerExtension` den Server nur als +Thread in derselben JVM startet (kein separater OS-Prozess), teilen sich +GreenMail und der CMS-Server denselben Prozessraum unproblematisch. + +Testfälle: + +1. **Server startet korrekt** (angepasste Version des bestehenden Tests). +2. **Startseite** zeigt den erwarteten, forms-module-spezifischen Titel. +3. **Erfolgreiche Einreichung (`contact`)**: Playwright füllt `from` und + `message` aus, submittet, erwartet Redirect auf + `/forms/contact/success`. Zusätzlich: `greenMail.getReceivedMessagesForDomain(...)` + liefert genau eine Mail mit erwartetem Empfänger/Betreff/Inhalt. +4. **Validierungsfehler**: `message` bleibt leer → Redirect auf + `/forms/error`, keine Mail bei GreenMail eingegangen. +5. **Honeypot/Spam**: verstecktes Feld `website` wird befüllt → Redirect auf + `/forms/error`, keine Mail. +6. **AJAX-Erfolg**: Formular `ajax-contact` wird per `fetch` submittet, + JSON-Antwort `{success: true}`. +7. **AJAX-Validierungsfehler**: ungültige E-Mail-Adresse im Feld `from` → + JSON-Antwort `{success: false, code: "VALIDATION_FAILED", fieldErrors: + {...}}`. + +Kein E2E-Test prüft den Mailversand für den AJAX-Pfad gesondert — der +Mailversand-Mechanismus ist derselbe wie beim normalen Pfad und wird dort +abgedeckt (YAGNI: keine Doppelabdeckung). + +## Betroffene Dateien (Übersicht) + +- `module/src/main/java/.../FormsConfig.java` — neues `Captcha`-Feld. +- `module/src/main/java/.../handler/FormsHandling.java` — Captcha-Check + conditional machen. +- `module/src/test/java/.../FormConfigTest.java`, + `FormsHandlingTest.java` — Tests für den neuen Schalter. +- `module/src/test/java/.../e2e/E2ETest.java` → `E2EIT.java` (umbenannt, + inhaltlich neu). +- `module/src/main/assembly/assembly.xml` — `dir`-Format ergänzen. +- `module/pom.xml` — `maven-resources-plugin`-Copy-Step, + `maven-failsafe-plugin`. +- `test-server/hosts/demo/site.toml`, + `test-server/hosts/demo/config/forms.yaml` (neu), + `test-server/hosts/demo/config/mail.yaml` (neu), + `test-server/hosts/demo/templates/contact.html`, + `test-server/hosts/demo/templates/ajax.html`, + `test-server/hosts/demo/content/*.md`. +- `.gitignore` (Modul-Root) — Build-/Laufzeitartefakte ergänzen. +- `demo/` — unverändert. From ddc972248a045ec205660496a0f740c09f116564 Mon Sep 17 00:00:00 2001 From: Thorsten Marx Date: Mon, 27 Jul 2026 13:40:50 +0200 Subject: [PATCH 4/9] Add implementation plan for forms-module E2E tests and optional captcha Breaks the design into five tasks: make captcha optional per form, fix the module-deploy gap that leaves the CMS test server with 0 loaded extensions, rewrite test-server content for forms-module, rewrite the E2E suite with GreenMail-backed mail assertions, and finalize .gitignore. --- ...26-07-27-e2e-tests-and-optional-captcha.md | 987 ++++++++++++++++++ 1 file changed, 987 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-27-e2e-tests-and-optional-captcha.md diff --git a/docs/superpowers/plans/2026-07-27-e2e-tests-and-optional-captcha.md b/docs/superpowers/plans/2026-07-27-e2e-tests-and-optional-captcha.md new file mode 100644 index 0000000..657d1f4 --- /dev/null +++ b/docs/superpowers/plans/2026-07-27-e2e-tests-and-optional-captcha.md @@ -0,0 +1,987 @@ +# E2E Tests and Optional Captcha Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Adapt the video-module-derived `E2ETest`/`test-server` scaffolding to forms-module, fix the module-deploy gap that leaves the CMS test server with 0 loaded extensions, and make captcha optional per form so the E2E suite can exercise real form submissions (including actual mail delivery via GreenMail) without solving a captcha. + +**Architecture:** `module/pom.xml` gains an assembly `dir` format plus a `maven-resources-plugin` copy step bound to `pre-integration-test`, so `mvn verify` produces a deployable module layout under `test-server/modules/forms-module/` before Failsafe runs. `E2ETest.java` is renamed to `E2EIT.java` so Failsafe (not Surefire) picks it up in the `integration-test` phase, after `package`. `FormsConfig.Form` gets a `captcha.enabled` flag (default `true`); `FormsHandling` skips captcha validation when it's `false`. `test-server/hosts/demo/` is rewritten with a real `forms.yaml`/`mail.yaml`, Pebble-syntax templates, and content pages so the E2E suite can drive two forms (plain POST + AJAX) through Playwright, with GreenMail acting as the SMTP backend. + +**Tech Stack:** Java 25, Maven (assembly/resources/failsafe/surefire plugins), JUnit 5, Playwright (Java), GreenMail (`greenmail-junit5`), SnakeYAML, Lombok `@Data`. + +## Global Constraints + +- Module id/artifact stays `forms-module`; module basedir is `module/` (sibling of `test-server/`), so any path passed to `CMSServerExtension` must be `"../test-server"` relative to `module/`, not `"test-server"`. +- Captcha default must remain `true` — no existing YAML config may change behavior. +- `demo/` (the old Thymeleaf example project) is explicitly out of scope and must not be modified. +- No secrets/passwords beyond throwaway test credentials (GreenMail test account) are introduced. +- Rate limiting, CSRF, and honeypot logic must not be touched except where explicitly noted. +- All new/modified Java files keep the existing GPLv3 header block (the `license-maven-plugin` `update-file-header` goal regenerates it on `process-sources`, so it's fine to omit it while writing and let the build add it — but keep the package/import structure consistent with existing files). + +--- + +## File Structure + +| File | Responsibility | +|---|---| +| `module/src/main/java/com/condation/cms/modules/forms/FormsConfig.java` | Add `Form.Captcha` nested config (`enabled`, default `true`). | +| `module/src/main/java/com/condation/cms/modules/forms/handler/FormsHandling.java` | Skip `validateCaptcha(...)` when `form.getCaptcha().isEnabled()` is `false`. | +| `module/src/test/java/com/condation/cms/modules/forms/FormConfigTest.java` | Assert captcha default/override parsing. | +| `module/src/test/java/com/condation/cms/modules/forms/FormsHandlingTest.java` | Assert captcha-disabled form skips captcha validation. | +| `module/src/main/assembly/assembly.xml` | Add `dir` format alongside existing `zip`. | +| `module/pom.xml` | Add `maven-resources-plugin` copy execution (`pre-integration-test`), `maven-failsafe-plugin`. | +| `module/src/test/java/com/condation/cms/modules/forms/e2e/E2ETest.java` → `E2EIT.java` | Rewritten E2E suite: server boot, plain-form success/validation/honeypot, AJAX success/validation, GreenMail assertion. | +| `test-server/hosts/demo/site.toml` | Activate `forms-module`. | +| `test-server/hosts/demo/config/forms.yaml` (new) | `contact` + `ajax-contact` form definitions, captcha disabled, rate limit disabled. | +| `test-server/hosts/demo/config/mail.yaml` (new) | `default` SMTP account pointing at GreenMail's fixed port. | +| `test-server/hosts/demo/templates/contact.html` (new) | Plain POST form, Pebble syntax, no captcha markup. | +| `test-server/hosts/demo/templates/ajax.html` (new) | AJAX form + fetch-based submit script. | +| `test-server/hosts/demo/content/contact.md`, `content/ajax.md`, `content/forms/contact/success.md`, `content/forms/error.md` (new) | Pages rendered by the two templates above. | +| `test-server/hosts/demo/content/index.md` | Title updated from "video-module test page" to a forms-module-specific title. | +| `.gitignore` (module root) | Add `test-server/modules/`, `test-server/logs/`, `test-server/cms.pid`, `test-server/hosts/demo/modules_data/`, `test-server/hosts/demo/temp/`, `test-server/hosts/demo/data/`. | +| `test-server/hosts/demo/assets/thumbnails/`, `test-server/hosts/demo/config/media.toml` | Removed (video-module leftovers, unreferenced by new templates). | + +--- + +### Task 1: Make captcha optional per form + +**Files:** +- Modify: `module/src/main/java/com/condation/cms/modules/forms/FormsConfig.java:126-143` (the `Form` class) +- Modify: `module/src/main/java/com/condation/cms/modules/forms/handler/FormsHandling.java:132-146` (`handleForm`) +- Test: `module/src/test/java/com/condation/cms/modules/forms/FormConfigTest.java` +- Test: `module/src/test/java/com/condation/cms/modules/forms/FormsHandlingTest.java` + +**Interfaces:** +- Produces: `FormsConfig.Form.getCaptcha()` returning `FormsConfig.Captcha` with `isEnabled()` (default `true`), used by `FormsHandling.handleForm` and by the E2E test-server config (`captcha.enabled: false` in YAML). + +- [ ] **Step 1: Write the failing config test** + +Add to `module/src/test/java/com/condation/cms/modules/forms/FormConfigTest.java` (inside the existing `FormConfigTest` class, as a new `@Test` method): + +```java + @Test + void captcha_defaults_to_enabled_and_can_be_disabled() throws Exception { + var yaml = """ + forms: + - name: with-default + fields: + message: {} + - name: without-captcha + captcha: + enabled: false + fields: + message: {} + """; + var config = new org.yaml.snakeyaml.Yaml().loadAs(yaml, FormsConfig.class); + config.validate(); + + Assertions.assertThat(config.findForm("with-default").get().getCaptcha().isEnabled()).isTrue(); + Assertions.assertThat(config.findForm("without-captcha").get().getCaptcha().isEnabled()).isFalse(); + } +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd module && mvn -q -Dtest=FormConfigTest#captcha_defaults_to_enabled_and_can_be_disabled test` +Expected: compilation error — `getCaptcha()` does not exist on `FormsConfig.Form`. + +- [ ] **Step 3: Add the `Captcha` config class and wire it into `Form`** + +In `module/src/main/java/com/condation/cms/modules/forms/FormsConfig.java`, inside `public static class Form { ... }` (currently ending at line 143), add a field: + +```java + private Captcha captcha = new Captcha(); +``` + +so the full `Form` class becomes: + +```java + @Data + public static class Form { + private String name; + private Redirects redirects; + private Map fields = new LinkedHashMap<>(); + private String to; + private String subject; + private Map data; + private Mail mail = new Mail(); + private Spam spam = new Spam(); + private RateLimit rateLimit = new RateLimit(); + private Captcha captcha = new Captcha(); + + public void setFields(final Map configuredFields) { + this.fields = configuredFields == null + ? new LinkedHashMap<>() + : new LinkedHashMap<>(configuredFields); + } + } +``` + +Then add a new nested class next to `Csrf` (after the `Csrf` class, before the closing brace of `FormsConfig`): + +```java + @Data + public static class Captcha { + private boolean enabled = true; + } +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd module && mvn -q -Dtest=FormConfigTest#captcha_defaults_to_enabled_and_can_be_disabled test` +Expected: PASS. + +- [ ] **Step 5: Write the failing handling test** + +Add to `module/src/test/java/com/condation/cms/modules/forms/FormsHandlingTest.java` (new `@Test` method in the existing class): + +```java + @Test + void skipsCaptchaValidationWhenDisabled() throws Exception { + form.getCaptcha().setEnabled(false); + var values = validValues(); + values.remove("key"); + values.remove("code"); + + handling.handleForm(form, values::get); + } +``` + +- [ ] **Step 6: Run test to verify it fails** + +Run: `cd module && mvn -q -Dtest=FormsHandlingTest#skipsCaptchaValidationWhenDisabled test` +Expected: FAIL — `FormHandlingException: INVALID_CAPTCHA` is thrown because `key`/`code` are missing and captcha is still enforced. + +- [ ] **Step 7: Make captcha validation conditional** + +In `module/src/main/java/com/condation/cms/modules/forms/handler/FormsHandling.java`, locate `handleForm` (currently): + +```java + public void handleForm(final FormsConfig.Form form, final Function parameters) throws FormHandlingException { + validateSpam(form, parameters); + validateFields(form, parameters); + validateCaptcha(form, parameters.apply("key"), parameters.apply("code")); +``` + +Change the captcha line to: + +```java + public void handleForm(final FormsConfig.Form form, final Function parameters) throws FormHandlingException { + validateSpam(form, parameters); + validateFields(form, parameters); + if (form.getCaptcha().isEnabled()) { + validateCaptcha(form, parameters.apply("key"), parameters.apply("code")); + } +``` + +- [ ] **Step 8: Run test to verify it passes** + +Run: `cd module && mvn -q -Dtest=FormsHandlingTest#skipsCaptchaValidationWhenDisabled test` +Expected: PASS. + +- [ ] **Step 9: Run the full unit test suite** + +Run: `cd module && mvn -q test` +Expected: all tests pass, including the pre-existing `FormsHandlingTest` cases (`rejectsSubmittedCaptchaCodeInsteadOfComparingStoredValueWithItself`, `acceptsAndConsumesCorrectCaptcha`, etc.), which still exercise the default (`captcha.enabled = true`) path unchanged. + +- [ ] **Step 10: Commit** + +```bash +cd /Users/thorstenmarx/entwicklung/workspaces/tma/cms/modules/forms-module +git add module/src/main/java/com/condation/cms/modules/forms/FormsConfig.java \ + module/src/main/java/com/condation/cms/modules/forms/handler/FormsHandling.java \ + module/src/test/java/com/condation/cms/modules/forms/FormConfigTest.java \ + module/src/test/java/com/condation/cms/modules/forms/FormsHandlingTest.java +git commit -m "Make captcha optional per form" +``` + +--- + +### Task 2: Fix the module-deploy gap (assembly dir format + copy step + failsafe) + +**Files:** +- Modify: `module/src/main/assembly/assembly.xml` +- Modify: `module/pom.xml` +- Rename: `module/src/test/java/com/condation/cms/modules/forms/e2e/E2ETest.java` → `E2EIT.java` (content rewritten in Task 3; this task only renames + fixes the constructor path so the class still compiles as-is) + +**Interfaces:** +- Produces: after `mvn package`, `module/target/forms-module-bin/` exists containing `module.properties` and `libs/*.jar` (module jar + runtime deps). After `mvn pre-integration-test` (or later phases), `test-server/modules/forms-module/` mirrors that directory. Failsafe runs any `**/*IT.java` in `integration-test`/`verify`. + +- [ ] **Step 1: Add the `dir` format to the assembly descriptor** + +Current `module/src/main/assembly/assembly.xml`: + +```xml + + bin + + zip + + + + target/${project.build.finalName}.${project.packaging} + libs/ + + + + + ${project.basedir} + / + + module.properties + + true + + + + + libs + true + runtime + + + +``` + +Change `` to include `dir`: + +```xml + + zip + dir + +``` + +Everything else in the file stays the same. With `bin` and `${module.id}` = `forms-module`, the assembly plugin (per its `finalName` config, see Step 2) will produce `target/forms-module-bin/` as a real directory in addition to `target/forms-module-bin.zip`. + +- [ ] **Step 2: Add the resources-copy execution and failsafe plugin to `module/pom.xml`** + +Current relevant block in `module/pom.xml`: + +```xml + + + + maven-assembly-plugin + 3.8.0 + + + src/main/assembly/assembly.xml + + ${module.id} + + + + package + + single + + + + + + +``` + +Replace it with: + +```xml + + + + maven-assembly-plugin + 3.8.0 + + + src/main/assembly/assembly.xml + + ${module.id} + + + + package + + single + + + + + + org.apache.maven.plugins + maven-resources-plugin + 3.3.1 + + + deploy-module-to-test-server + pre-integration-test + + copy-resources + + + ${project.basedir}/../test-server/modules/${module.id} + true + + + ${project.build.directory}/${module.id}-bin + + + + + + + + org.apache.maven.plugins + maven-failsafe-plugin + 3.5.4 + + + + integration-test + verify + + + + + + +``` + +Notes: +- `${project.build.directory}/${module.id}-bin` resolves to `target/forms-module-bin`, matching the assembly `bin` + `finalName=${module.id}` combination. +- `${project.basedir}/../test-server/modules/${module.id}` resolves to `module/../test-server/modules/forms-module` = `test-server/modules/forms-module`, a sibling of `module/`. +- Failsafe's default includes (`**/*IT.java`, `**/IT*.java`, `**/*ITCase.java`) will pick up `E2EIT.java` once renamed in Step 3; default excludes keep Surefire from also running it (Surefire's default excludes already skip `**/*IT.java`). + +- [ ] **Step 3: Rename the E2E test class** + +```bash +cd /Users/thorstenmarx/entwicklung/workspaces/tma/cms/modules/forms-module +git mv module/src/test/java/com/condation/cms/modules/forms/e2e/E2ETest.java \ + module/src/test/java/com/condation/cms/modules/forms/e2e/E2EIT.java +``` + +Edit the file to rename the class declaration (content will be fully rewritten in Task 3, but make the minimal rename now so the module still compiles): + +Open `module/src/test/java/com/condation/cms/modules/forms/e2e/E2EIT.java` and change: + +```java +public class E2ETest { +``` + +to: + +```java +public class E2EIT { +``` + +Also fix the `CMSServerExtension` path, which currently reads `"test-server"` (correct for the video-module's flat layout, wrong here since `module/` and `test-server/` are siblings under `forms-module/`): + +```java + @RegisterExtension + static CMSServerExtension serverExtensions = new CMSServerExtension("test-server"); +``` + +becomes: + +```java + @RegisterExtension + static CMSServerExtension serverExtensions = new CMSServerExtension("../test-server"); +``` + +Leave the three existing `@Test` methods (`server_is_started`, `start_page`, `contains_header`) as-is for now — they still reference video-module content and will be replaced in Task 3. + +- [ ] **Step 4: Verify `mvn package` produces the dir layout** + +Run: `cd module && mvn -q clean package -DskipTests` +Expected: exit code 0. Then check: + +```bash +ls module/target/forms-module-bin/ +``` +Expected output includes `module.properties` and a `libs/` directory containing `forms-module-.jar` plus runtime dependency jars (nanocaptcha, caffeine, snakeyaml, gson, etc.). + +- [ ] **Step 5: Verify the copy step deploys to test-server** + +Run: `cd module && mvn -q pre-integration-test -DskipTests` +Expected: exit code 0. Then check: + +```bash +ls test-server/modules/forms-module/ +ls test-server/modules/forms-module/libs/ | grep forms-module +``` +Expected: `module.properties` and `libs/forms-module-.jar` present under `test-server/modules/forms-module/`. + +- [ ] **Step 6: Commit** + +```bash +cd /Users/thorstenmarx/entwicklung/workspaces/tma/cms/modules/forms-module +git add module/src/main/assembly/assembly.xml module/pom.xml \ + module/src/test/java/com/condation/cms/modules/forms/e2e/E2EIT.java +git status --short module/src/test/java/com/condation/cms/modules/forms/e2e/ +git commit -m "Deploy built module jar to test-server before integration tests" +``` + +(`git status` first to confirm the old `E2ETest.java` path is gone and only `E2EIT.java` is staged, since `git mv` already recorded the rename.) + +--- + +### Task 3: Rewrite `test-server/` content for forms-module (site config, forms.yaml, mail.yaml, templates, content) + +**Files:** +- Modify: `test-server/hosts/demo/site.toml` +- Create: `test-server/hosts/demo/config/forms.yaml` +- Create: `test-server/hosts/demo/config/mail.yaml` +- Create: `test-server/hosts/demo/templates/contact.html` +- Create: `test-server/hosts/demo/templates/ajax.html` +- Create: `test-server/hosts/demo/content/contact.md` +- Create: `test-server/hosts/demo/content/ajax.md` +- Create: `test-server/hosts/demo/content/forms/contact/success.md` +- Create: `test-server/hosts/demo/content/forms/error.md` +- Modify: `test-server/hosts/demo/content/index.md` +- Delete: `test-server/hosts/demo/assets/thumbnails/mountains.jpg`, `test-server/hosts/demo/config/media.toml` + +**Interfaces:** +- Produces: two working forms reachable at `/contact` (plain POST to `/module/forms-module/form/submit`) and `/ajax` (fetch POST to `/module/forms-module/form/submit/ajax`), form names `contact` and `ajax-contact`, both with `captcha.enabled: false` and `rateLimit.enabled: false`. Mail account `default` in `mail.yaml` with a placeholder port `3025` (GreenMail in Task 4 binds exactly this port before the server starts). + +- [ ] **Step 1: Activate forms-module in `site.toml`** + +Current `test-server/hosts/demo/site.toml`: + +```toml +id = "demo-site" +hostname = [ "localhost", "127.0.0.1" ] +baseurl = "http://localhost:2020" +locale = "en_US" +context_path = "/" + +# modules to load for this site +[modules] +#active = ["videos-module"] # list of active modules for this sites +``` + +Replace with: + +```toml +id = "demo-site" +hostname = [ "localhost", "127.0.0.1" ] +baseurl = "http://localhost:2020" +locale = "en_US" +context_path = "/" + +# modules to load for this site +[modules] +active = ["forms-module"] +``` + +- [ ] **Step 2: Create `test-server/hosts/demo/config/forms.yaml`** + +```yaml +forms: + - name: contact + to: contact@example.com + subject: New contact form submission + captcha: + enabled: false + rateLimit: + enabled: false + fields: + from: + type: email + required: true + message: + required: true + minLength: 3 + maxLength: 5000 + mail: + account: default + from: forms@example.com + spam: + honeypot: + enabled: true + field: website + redirects: + success: /forms/contact/success + - name: ajax-contact + captcha: + enabled: false + rateLimit: + enabled: false + fields: + from: + type: email + required: true + message: + required: true + minLength: 3 + spam: + honeypot: + enabled: true + field: website +redirects: + error: /forms/error +``` + +- [ ] **Step 3: Create `test-server/hosts/demo/config/mail.yaml`** + +```yaml +accounts: + default: + host: localhost + fromMail: forms@example.com + port: 3025 + username: forms-test + password: forms-test-password +``` + +(Port `3025` and the `forms-test`/`forms-test-password` credentials must match exactly what `E2EIT.java` configures on the `GreenMailExtension` in Task 4 — see that task's `greenMail.setUser("forms-test", "forms-test-password")` call.) + +- [ ] **Step 4: Create `test-server/hosts/demo/templates/contact.html`** + +```html + + + + + {{ node.meta.title }} + + + + + + {{ node.content | raw }} + + + + +
+ + +
+
+ + +
+
+ +
+ + + + + +``` + +- [ ] **Step 5: Create `test-server/hosts/demo/templates/ajax.html`** + +```html + + + + + {{ node.meta.title }} + + + + + + {{ node.content | raw }} + +
+ + +
+ + +
+
+ + +
+
+ +
+
+
+ + + + + + +``` + +- [ ] **Step 6: Create content pages** + +`test-server/hosts/demo/content/contact.md`: + +```markdown +--- +title: Contact +template: contact.html +search: + index: false +published: true +--- + +# Contact us +``` + +`test-server/hosts/demo/content/ajax.md`: + +```markdown +--- +title: Ajax Contact +template: ajax.html +search: + index: false +published: true +--- + +# Contact us via ajax +``` + +`test-server/hosts/demo/content/forms/contact/success.md` (uses the plain `start.html` template already present in `test-server/hosts/demo/templates/start.html`, which just renders `node.content` — reusing `contact.html` here would incorrectly re-render the form itself): + +```markdown +--- +title: Form submitted +template: start.html +search: + index: false +published: true +--- + +## Your request was successfully submitted +``` + +`test-server/hosts/demo/content/forms/error.md` (same reasoning — plain `start.html`, not `contact.html`): + +```markdown +--- +title: Error sending form +template: start.html +search: + index: false +published: true +--- + +## Error submitting your request! +``` + +- [ ] **Step 7: Update `test-server/hosts/demo/content/index.md`** + +Current: + +```markdown +--- +title: video-module test page +template: start.html +search: + index: false +published: true +--- + +# Vimeo Shortcode + +[[video type="vimeo" id="170338499" title="Everybody loves little cats" /]] +``` + +Replace with: + +```markdown +--- +title: forms-module test page +template: start.html +search: + index: false +published: true +--- + +# Forms module test page +``` + +- [ ] **Step 8: Remove video-module leftovers** + +```bash +cd /Users/thorstenmarx/entwicklung/workspaces/tma/cms/modules/forms-module +git rm -r test-server/hosts/demo/assets/thumbnails test-server/hosts/demo/config/media.toml +``` + +- [ ] **Step 9: Commit** + +```bash +cd /Users/thorstenmarx/entwicklung/workspaces/tma/cms/modules/forms-module +git add test-server/hosts/demo/site.toml \ + test-server/hosts/demo/config/forms.yaml \ + test-server/hosts/demo/config/mail.yaml \ + test-server/hosts/demo/templates/contact.html \ + test-server/hosts/demo/templates/ajax.html \ + test-server/hosts/demo/content/contact.md \ + test-server/hosts/demo/content/ajax.md \ + test-server/hosts/demo/content/forms \ + test-server/hosts/demo/content/index.md +git commit -m "Rewrite test-server content for forms-module" +``` + +(The `git rm` from Step 8 is already staged as part of the deletion; it will be included in this commit too — run `git status --short` beforehand if you want to double check exactly what's staged.) + +--- + +### Task 4: Rewrite the E2E test suite (`E2EIT.java`) with GreenMail + +**Files:** +- Modify: `module/src/test/java/com/condation/cms/modules/forms/e2e/E2EIT.java` (full rewrite) + +**Interfaces:** +- Consumes: `CMSServerExtension("../test-server")` (Task 2), `GreenMailExtension` with fixed `ServerSetup(3025, "127.0.0.1", ServerSetup.PROTOCOL_SMTP)` (from `com.icegreen.greenmail.util.ServerSetup`, constructor `ServerSetup(int port, String bindAddress, String protocol)`), matching `host: localhost` in Task 3's `mail.yaml`, forms `contact` and `ajax-contact` as configured in Task 3's `forms.yaml`, mail account `default`/port `3025`/user `forms-test`/password `forms-test-password` as configured in Task 3's `mail.yaml`. +- Produces: no new public interface; this is the terminal artifact for this plan. + +- [ ] **Step 1: Write the full `E2EIT.java` test class** + +Replace the entire content of `module/src/test/java/com/condation/cms/modules/forms/e2e/E2EIT.java` with: + +```java +package com.condation.cms.modules.forms.e2e; + +/*- + * #%L + * forms-module + * %% + * Copyright (C) 2024 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public + * License along with this program. If not, see + * . + * #L% + */ + +import com.condation.cms.cli.tools.CLIServerUtils; +import com.condation.cms.test.e2e.CMSServerExtension; +import com.icegreen.greenmail.junit5.GreenMailExtension; +import com.icegreen.greenmail.util.GreenMailUtil; +import com.icegreen.greenmail.util.ServerSetup; +import com.microsoft.playwright.Page; +import com.microsoft.playwright.junit.UsePlaywright; +import java.util.Map; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +/** + * + * @author thorstenmarx + */ +@UsePlaywright +public class E2EIT { + + @RegisterExtension + static GreenMailExtension greenMail = new GreenMailExtension( + new ServerSetup(3025, "127.0.0.1", ServerSetup.PROTOCOL_SMTP)); + + @RegisterExtension + static CMSServerExtension serverExtensions = new CMSServerExtension("../test-server"); + + @Test + void server_is_started() throws Exception { + Assertions.assertThat(CLIServerUtils.getCMSProcess()).isPresent(); + } + + @Test + void start_page(Page page) { + page.navigate("http://localhost:2020"); + Assertions.assertThat(page.locator("title").innerText()).isEqualTo("forms-module test page"); + } + + @Test + void successful_submission_sends_mail_and_redirects(Page page) { + greenMail.setUser("forms-test", "forms-test-password"); + + page.navigate("http://localhost:2020/contact"); + page.fill("#from", "visitor@example.com"); + page.fill("#message", "Hello from the E2E test"); + page.click("#submit-btn"); + + Assertions.assertThat(page.url()).contains("/forms/contact/success"); + + var messages = greenMail.getReceivedMessagesForDomain("contact@example.com"); + Assertions.assertThat(messages).hasSize(1); + Assertions.assertThat(messages[0].getSubject()).isEqualTo("New contact form submission"); + Assertions.assertThat(GreenMailUtil.getBody(messages[0])).contains("Hello from the E2E test"); + } + + @Test + void missing_required_field_redirects_to_error_and_sends_no_mail(Page page) { + page.navigate("http://localhost:2020/contact"); + page.fill("#from", "visitor@example.com"); + page.click("#submit-btn"); + + Assertions.assertThat(page.url()).contains("/forms/error"); + Assertions.assertThat(greenMail.getReceivedMessages()).isEmpty(); + } + + @Test + void filled_honeypot_redirects_to_error_and_sends_no_mail(Page page) { + page.navigate("http://localhost:2020/contact"); + page.fill("#from", "visitor@example.com"); + page.fill("#message", "Hello from the E2E test"); + page.fill("input[name=website]", "https://spam.example"); + page.click("#submit-btn"); + + Assertions.assertThat(page.url()).contains("/forms/error"); + Assertions.assertThat(greenMail.getReceivedMessages()).isEmpty(); + } + + @Test + void ajax_form_returns_success_json(Page page) { + page.navigate("http://localhost:2020/ajax"); + page.fill("#from", "visitor@example.com"); + page.fill("#message", "Hello via ajax"); + page.click("#submit-btn"); + + page.waitForFunction("() => document.getElementById('ajaxResult').hasAttribute('data-success')"); + + Assertions.assertThat(page.locator("#ajaxResult").getAttribute("data-success")).isEqualTo("true"); + } + + @Test + void ajax_form_returns_validation_error_json(Page page) { + page.navigate("http://localhost:2020/ajax"); + page.fill("#from", "not-an-email"); + page.fill("#message", "Hello via ajax"); + page.click("#submit-btn"); + + page.waitForFunction("() => document.getElementById('ajaxResult').hasAttribute('data-success')"); + + Assertions.assertThat(page.locator("#ajaxResult").getAttribute("data-success")).isEqualTo("false"); + Assertions.assertThat(page.locator("#ajaxResult").getAttribute("data-code")).isEqualTo("VALIDATION_FAILED"); + } +} +``` + +Notes on the code above: +- `GreenMailExtension` is declared *before* `CMSServerExtension` as a field, and JUnit 5 runs static `@RegisterExtension` fields' `beforeAll` callbacks in declaration order for top-level static extensions registered this way — GreenMail's SMTP listener is bound first, so `config/mail.yaml`'s `port: 3025` is already accepting connections before `Startup.run()` (triggered by `CMSServerExtension.beforeAll`) constructs `DefaultMailService`. +- `greenMail.setUser("forms-test", "forms-test-password")` only needs to be called once before the mail-sending test; GreenMail's SMTP server does not require authentication to accept a message by default, but this matches the credentials in `mail.yaml` for clarity and future-proofing if the mailer library enforces auth. +- `messages[0].getSubject()` and `GreenMailUtil.getBody(messages[0])` use `jakarta.mail.internet.MimeMessage` (returned by `getReceivedMessagesForDomain`) and the `com.icegreen.greenmail.util.GreenMailUtil.getBody(Part)` helper — both already on the test classpath via `greenmail-junit5`. +- The honeypot field is targeted via `page.fill("input[name=website]", ...)` instead of an `id` selector since the hidden honeypot input in the templates (Task 3) has no `id` attribute, matching the existing `demo/` convention of using `name="website"` for this field. + +- [ ] **Step 2: Run the E2E suite** + +Run: `cd module && mvn -q verify` +Expected: exit code 0. All Failsafe-run tests in `E2EIT` pass: +- `server_is_started` +- `start_page` +- `successful_submission_sends_mail_and_redirects` +- `missing_required_field_redirects_to_error_and_sends_no_mail` +- `filled_honeypot_redirects_to_error_and_sends_no_mail` +- `ajax_form_returns_success_json` +- `ajax_form_returns_validation_error_json` + +If any test fails, check `module/test-server-logs-equivalent` — actually check `test-server/logs/` (the running CMS server's own logs) for stack traces, since `CMSServerExtension` runs the server in-process but its own logging still writes there. + +- [ ] **Step 3: Run the full build one more time from a clean state to confirm reproducibility** + +Run: `cd module && mvn -q clean verify` +Expected: exit code 0 (clean removes `target/`, so this re-validates that `package` → `pre-integration-test` copy → `integration-test` all run in the correct order from scratch). + +- [ ] **Step 4: Commit** + +```bash +cd /Users/thorstenmarx/entwicklung/workspaces/tma/cms/modules/forms-module +git add module/src/test/java/com/condation/cms/modules/forms/e2e/E2EIT.java +git commit -m "Add E2E tests for forms-module covering success, validation, spam, and ajax paths" +``` + +--- + +### Task 5: Finalize `.gitignore` and verify overall repo cleanliness + +**Files:** +- Modify: `.gitignore` (module root, i.e. `/Users/thorstenmarx/entwicklung/workspaces/tma/cms/modules/forms-module/.gitignore`) + +**Interfaces:** none (repo hygiene only). + +- [ ] **Step 1: Update `.gitignore`** + +Current content: + +``` +target/ +demo/lib +demo/logs +demo/modules +demo/hosts/demo/modules_data +demo/cms.pid +demo/*.jar +demo/LICENSE +demo/log4j2.xml +demo/README.md +demo/server.yaml +.vscode/settings.json +``` + +Append these new lines at the end: + +``` +test-server/modules/ +test-server/logs/ +test-server/cms.pid +test-server/hosts/demo/modules_data/ +test-server/hosts/demo/temp/ +test-server/hosts/demo/data/ +``` + +- [ ] **Step 2: Verify no unwanted build/runtime artifacts remain tracked** + +Run: `git status --short` +Expected: only the intentional source/config files from Tasks 1-4 show as staged/committed; `test-server/modules/`, `test-server/logs/`, and any `*.log`/`cms.pid`/`modules_data`/`temp`/`data` paths under `test-server/hosts/demo/` do not appear as untracked (they're now ignored) or, if they were already tracked from a prior accidental commit, remove them: + +```bash +git rm -r --cached test-server/logs test-server/hosts/demo/modules_data 2>/dev/null || true +``` + +(This is a no-op if those paths were never tracked — safe to run unconditionally.) + +- [ ] **Step 3: Commit** + +```bash +cd /Users/thorstenmarx/entwicklung/workspaces/tma/cms/modules/forms-module +git add .gitignore +git status --short +git commit -m "Ignore test-server build and runtime artifacts" +``` + +--- + +## Final Verification + +- [ ] Run `cd module && mvn -q clean verify` one final time end-to-end. +- [ ] Confirm `mvn -q clean test` (Surefire only, no `verify`) still passes quickly without needing the module deployed to `test-server/` — this proves unit tests (`FormConfigTest`, `FormsHandlingTest`, `CaptchaTest`) remain fast and independent of the E2E machinery. +- [ ] Confirm `demo/` has zero diffs: `git status --short demo/` shows nothing. From a9bba11e22e6a0dade4a8eb4c60f044583ada530 Mon Sep 17 00:00:00 2001 From: Thorsten Marx Date: Mon, 27 Jul 2026 14:12:56 +0200 Subject: [PATCH 5/9] Restructure into forms-module-parent with module/ submodule Splits the single-module layout into a parent POM (dependency management) plus a module/ submodule, and adds the video-module-derived test-server/ and E2ETest.java scaffolding that the following commits will adapt to forms-module. --- .gitignore | 6 + module.properties => module/module.properties | 0 module/pom.xml | 111 +++++++++++ .../src}/main/assembly/assembly.xml | 0 .../cms/modules/forms/FormsConfig.java | 0 .../cms/modules/forms/FormsFeature.java | 0 .../forms/FormsHttpHandlerExtension.java | 0 .../forms/FormsLifecycleExtension.java | 0 .../FormsTemplateModelExtensionPoint.java | 0 .../forms/handler/AjaxSubmitFormHandler.java | 0 .../forms/handler/FormHandlingException.java | 0 .../modules/forms/handler/FormsHandling.java | 0 .../forms/handler/GenerateCaptchaHandler.java | 0 .../forms/handler/RequestSecurity.java | 0 .../forms/handler/SubmitFormHandler.java | 0 .../forms/template/FormsTemplateModel.java | 0 .../cms/modules/forms/utils/StringUtil.java | 0 .../cms/modules/forms/CaptchaTest.java | 0 .../cms/modules/forms/FormConfigTest.java | 0 .../cms/modules/forms/FormsHandlingTest.java | 0 .../cms/modules/forms/e2e/E2ETest.java | 66 +++++++ .../src}/test/resources/config/forms.yaml | 0 pom.xml | 177 +++++++++--------- test-server/.env | 1 + test-server/config/manager-users.realm | 1 + .../demo/assets/thumbnails/mountains.jpg | Bin 0 -> 50993 bytes test-server/hosts/demo/config/media.toml | 22 +++ .../hosts/demo/content/.technical/404.md | 5 + test-server/hosts/demo/content/index.md | 11 ++ .../demo/public/.well-known/security.txt | 1 + test-server/hosts/demo/public/favicon.ico | Bin 0 -> 1406 bytes test-server/hosts/demo/public/robots.txt | 3 + test-server/hosts/demo/site-dev.toml | 7 + test-server/hosts/demo/site.toml | 9 + test-server/hosts/demo/templates/start.html | 17 ++ test-server/log4j2.xml | 69 +++++++ test-server/server.toml | 34 ++++ 37 files changed, 449 insertions(+), 91 deletions(-) rename module.properties => module/module.properties (100%) create mode 100644 module/pom.xml rename {src => module/src}/main/assembly/assembly.xml (100%) rename {src => module/src}/main/java/com/condation/cms/modules/forms/FormsConfig.java (100%) rename {src => module/src}/main/java/com/condation/cms/modules/forms/FormsFeature.java (100%) rename {src => module/src}/main/java/com/condation/cms/modules/forms/FormsHttpHandlerExtension.java (100%) rename {src => module/src}/main/java/com/condation/cms/modules/forms/FormsLifecycleExtension.java (100%) rename {src => module/src}/main/java/com/condation/cms/modules/forms/FormsTemplateModelExtensionPoint.java (100%) rename {src => module/src}/main/java/com/condation/cms/modules/forms/handler/AjaxSubmitFormHandler.java (100%) rename {src => module/src}/main/java/com/condation/cms/modules/forms/handler/FormHandlingException.java (100%) rename {src => module/src}/main/java/com/condation/cms/modules/forms/handler/FormsHandling.java (100%) rename {src => module/src}/main/java/com/condation/cms/modules/forms/handler/GenerateCaptchaHandler.java (100%) rename {src => module/src}/main/java/com/condation/cms/modules/forms/handler/RequestSecurity.java (100%) rename {src => module/src}/main/java/com/condation/cms/modules/forms/handler/SubmitFormHandler.java (100%) rename {src => module/src}/main/java/com/condation/cms/modules/forms/template/FormsTemplateModel.java (100%) rename {src => module/src}/main/java/com/condation/cms/modules/forms/utils/StringUtil.java (100%) rename {src => module/src}/test/java/com/condation/cms/modules/forms/CaptchaTest.java (100%) rename {src => module/src}/test/java/com/condation/cms/modules/forms/FormConfigTest.java (100%) rename {src => module/src}/test/java/com/condation/cms/modules/forms/FormsHandlingTest.java (100%) create mode 100644 module/src/test/java/com/condation/cms/modules/forms/e2e/E2ETest.java rename {src => module/src}/test/resources/config/forms.yaml (100%) create mode 100644 test-server/.env create mode 100644 test-server/config/manager-users.realm create mode 100644 test-server/hosts/demo/assets/thumbnails/mountains.jpg create mode 100644 test-server/hosts/demo/config/media.toml create mode 100644 test-server/hosts/demo/content/.technical/404.md create mode 100644 test-server/hosts/demo/content/index.md create mode 100644 test-server/hosts/demo/public/.well-known/security.txt create mode 100644 test-server/hosts/demo/public/favicon.ico create mode 100644 test-server/hosts/demo/public/robots.txt create mode 100644 test-server/hosts/demo/site-dev.toml create mode 100644 test-server/hosts/demo/site.toml create mode 100644 test-server/hosts/demo/templates/start.html create mode 100644 test-server/log4j2.xml create mode 100644 test-server/server.toml diff --git a/.gitignore b/.gitignore index 5239179..43dc124 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,9 @@ demo/log4j2.xml demo/README.md demo/server.yaml .vscode/settings.json +test-server/logs +test-server/cms.pid +test-server/hosts/demo/modules_data/ +test-server/hosts/demo/temp/ +test-server/hosts/demo/data/ +.DS_Store diff --git a/module.properties b/module/module.properties similarity index 100% rename from module.properties rename to module/module.properties diff --git a/module/pom.xml b/module/pom.xml new file mode 100644 index 0000000..2e6afa2 --- /dev/null +++ b/module/pom.xml @@ -0,0 +1,111 @@ + + + 4.0.0 + + + com.condation.cms.modules + forms-module-parent + 4.1.0 + + + forms-module + jar + + + + + net.logicsquad + nanocaptcha + + + org.slf4j + slf4j-api + + + + + com.github.ben-manes.caffeine + caffeine + + + org.yaml + snakeyaml + + + com.google.code.gson + gson + + + + org.junit.jupiter + junit-jupiter + test + + + com.icegreen + greenmail-junit5 + test + + + org.slf4j + slf4j-api + + + + + org.assertj + assertj-core + test + + + + com.condation.cms + cms-test-server + test + + + com.microsoft.playwright + playwright + test + + + + + com.condation.cms + cms-api + provided + + + com.condation.modules.framework + modules-api + provided + + + org.projectlombok + lombok + provided + + + + + + maven-assembly-plugin + 3.8.0 + + + src/main/assembly/assembly.xml + + ${module.id} + + + + package + + single + + + + + + + diff --git a/src/main/assembly/assembly.xml b/module/src/main/assembly/assembly.xml similarity index 100% rename from src/main/assembly/assembly.xml rename to module/src/main/assembly/assembly.xml diff --git a/src/main/java/com/condation/cms/modules/forms/FormsConfig.java b/module/src/main/java/com/condation/cms/modules/forms/FormsConfig.java similarity index 100% rename from src/main/java/com/condation/cms/modules/forms/FormsConfig.java rename to module/src/main/java/com/condation/cms/modules/forms/FormsConfig.java diff --git a/src/main/java/com/condation/cms/modules/forms/FormsFeature.java b/module/src/main/java/com/condation/cms/modules/forms/FormsFeature.java similarity index 100% rename from src/main/java/com/condation/cms/modules/forms/FormsFeature.java rename to module/src/main/java/com/condation/cms/modules/forms/FormsFeature.java diff --git a/src/main/java/com/condation/cms/modules/forms/FormsHttpHandlerExtension.java b/module/src/main/java/com/condation/cms/modules/forms/FormsHttpHandlerExtension.java similarity index 100% rename from src/main/java/com/condation/cms/modules/forms/FormsHttpHandlerExtension.java rename to module/src/main/java/com/condation/cms/modules/forms/FormsHttpHandlerExtension.java diff --git a/src/main/java/com/condation/cms/modules/forms/FormsLifecycleExtension.java b/module/src/main/java/com/condation/cms/modules/forms/FormsLifecycleExtension.java similarity index 100% rename from src/main/java/com/condation/cms/modules/forms/FormsLifecycleExtension.java rename to module/src/main/java/com/condation/cms/modules/forms/FormsLifecycleExtension.java diff --git a/src/main/java/com/condation/cms/modules/forms/FormsTemplateModelExtensionPoint.java b/module/src/main/java/com/condation/cms/modules/forms/FormsTemplateModelExtensionPoint.java similarity index 100% rename from src/main/java/com/condation/cms/modules/forms/FormsTemplateModelExtensionPoint.java rename to module/src/main/java/com/condation/cms/modules/forms/FormsTemplateModelExtensionPoint.java diff --git a/src/main/java/com/condation/cms/modules/forms/handler/AjaxSubmitFormHandler.java b/module/src/main/java/com/condation/cms/modules/forms/handler/AjaxSubmitFormHandler.java similarity index 100% rename from src/main/java/com/condation/cms/modules/forms/handler/AjaxSubmitFormHandler.java rename to module/src/main/java/com/condation/cms/modules/forms/handler/AjaxSubmitFormHandler.java diff --git a/src/main/java/com/condation/cms/modules/forms/handler/FormHandlingException.java b/module/src/main/java/com/condation/cms/modules/forms/handler/FormHandlingException.java similarity index 100% rename from src/main/java/com/condation/cms/modules/forms/handler/FormHandlingException.java rename to module/src/main/java/com/condation/cms/modules/forms/handler/FormHandlingException.java diff --git a/src/main/java/com/condation/cms/modules/forms/handler/FormsHandling.java b/module/src/main/java/com/condation/cms/modules/forms/handler/FormsHandling.java similarity index 100% rename from src/main/java/com/condation/cms/modules/forms/handler/FormsHandling.java rename to module/src/main/java/com/condation/cms/modules/forms/handler/FormsHandling.java diff --git a/src/main/java/com/condation/cms/modules/forms/handler/GenerateCaptchaHandler.java b/module/src/main/java/com/condation/cms/modules/forms/handler/GenerateCaptchaHandler.java similarity index 100% rename from src/main/java/com/condation/cms/modules/forms/handler/GenerateCaptchaHandler.java rename to module/src/main/java/com/condation/cms/modules/forms/handler/GenerateCaptchaHandler.java diff --git a/src/main/java/com/condation/cms/modules/forms/handler/RequestSecurity.java b/module/src/main/java/com/condation/cms/modules/forms/handler/RequestSecurity.java similarity index 100% rename from src/main/java/com/condation/cms/modules/forms/handler/RequestSecurity.java rename to module/src/main/java/com/condation/cms/modules/forms/handler/RequestSecurity.java diff --git a/src/main/java/com/condation/cms/modules/forms/handler/SubmitFormHandler.java b/module/src/main/java/com/condation/cms/modules/forms/handler/SubmitFormHandler.java similarity index 100% rename from src/main/java/com/condation/cms/modules/forms/handler/SubmitFormHandler.java rename to module/src/main/java/com/condation/cms/modules/forms/handler/SubmitFormHandler.java diff --git a/src/main/java/com/condation/cms/modules/forms/template/FormsTemplateModel.java b/module/src/main/java/com/condation/cms/modules/forms/template/FormsTemplateModel.java similarity index 100% rename from src/main/java/com/condation/cms/modules/forms/template/FormsTemplateModel.java rename to module/src/main/java/com/condation/cms/modules/forms/template/FormsTemplateModel.java diff --git a/src/main/java/com/condation/cms/modules/forms/utils/StringUtil.java b/module/src/main/java/com/condation/cms/modules/forms/utils/StringUtil.java similarity index 100% rename from src/main/java/com/condation/cms/modules/forms/utils/StringUtil.java rename to module/src/main/java/com/condation/cms/modules/forms/utils/StringUtil.java diff --git a/src/test/java/com/condation/cms/modules/forms/CaptchaTest.java b/module/src/test/java/com/condation/cms/modules/forms/CaptchaTest.java similarity index 100% rename from src/test/java/com/condation/cms/modules/forms/CaptchaTest.java rename to module/src/test/java/com/condation/cms/modules/forms/CaptchaTest.java diff --git a/src/test/java/com/condation/cms/modules/forms/FormConfigTest.java b/module/src/test/java/com/condation/cms/modules/forms/FormConfigTest.java similarity index 100% rename from src/test/java/com/condation/cms/modules/forms/FormConfigTest.java rename to module/src/test/java/com/condation/cms/modules/forms/FormConfigTest.java diff --git a/src/test/java/com/condation/cms/modules/forms/FormsHandlingTest.java b/module/src/test/java/com/condation/cms/modules/forms/FormsHandlingTest.java similarity index 100% rename from src/test/java/com/condation/cms/modules/forms/FormsHandlingTest.java rename to module/src/test/java/com/condation/cms/modules/forms/FormsHandlingTest.java diff --git a/module/src/test/java/com/condation/cms/modules/forms/e2e/E2ETest.java b/module/src/test/java/com/condation/cms/modules/forms/e2e/E2ETest.java new file mode 100644 index 0000000..d405740 --- /dev/null +++ b/module/src/test/java/com/condation/cms/modules/forms/e2e/E2ETest.java @@ -0,0 +1,66 @@ +package com.condation.cms.modules.forms.e2e; + +/*- + * #%L + * forms-module + * %% + * Copyright (C) 2024 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public + * License along with this program. If not, see + * . + * #L% + */ + +import com.condation.cms.cli.tools.CLIServerUtils; +import com.condation.cms.test.e2e.CMSServerExtension; +import com.microsoft.playwright.Page; +import com.microsoft.playwright.junit.UsePlaywright; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.Test; + +import org.junit.jupiter.api.extension.RegisterExtension; + +/** + * + * @author thorstenmarx + */ +@UsePlaywright +public class E2ETest { + + @RegisterExtension + static CMSServerExtension serverExtensions = new CMSServerExtension("test-server"); + + @Test + void server_is_started() throws Exception { + Assertions.assertThat(CLIServerUtils.getCMSProcess()).isPresent(); + } + + @Test + void start_page(Page page) { + page.navigate("http://localhost:2020"); + Assertions.assertThat(page.locator("title").innerText()).isEqualTo("video-module test page"); + } + + @Test + void contains_header(Page page) { + page.navigate("http://localhost:2020"); + /** + * + + */ + Assertions.assertThat(page.locator("head").innerHTML()) + .contains("") + .contains(""); + } +} diff --git a/src/test/resources/config/forms.yaml b/module/src/test/resources/config/forms.yaml similarity index 100% rename from src/test/resources/config/forms.yaml rename to module/src/test/resources/config/forms.yaml diff --git a/pom.xml b/pom.xml index 21b73f6..243dbd6 100644 --- a/pom.xml +++ b/pom.xml @@ -4,9 +4,12 @@ com.condation.cms.modules - forms-module + forms-module-parent 4.1.0 - jar + pom + + module + UTF-8 @@ -30,100 +33,92 @@ https://www.gnu.org/licenses/gpl-3.0.html - + - - net.logicsquad - nanocaptcha - 2.1 - - - org.slf4j - slf4j-api - - - - - com.github.ben-manes.caffeine - caffeine - 3.2.4 - - - org.yaml - snakeyaml - 2.6 - - - com.google.code.gson - gson - 2.14.0 - + - - org.junit.jupiter - junit-jupiter - 6.1.2 - test - - - com.icegreen - greenmail-junit5 - 2.1.11 - test - - - org.slf4j - slf4j-api - - - - - org.assertj - assertj-core - 3.27.7 - test - + + net.logicsquad + nanocaptcha + 2.1 + + + org.slf4j + slf4j-api + + + + + com.github.ben-manes.caffeine + caffeine + 3.2.4 + + + org.yaml + snakeyaml + 2.6 + + + com.google.code.gson + gson + 2.14.0 + + + + org.junit.jupiter + junit-jupiter + 6.1.2 + test + + + com.icegreen + greenmail-junit5 + 2.1.11 + test + + + org.slf4j + slf4j-api + + + + + org.assertj + assertj-core + 3.27.7 + + + + com.condation.cms + cms-test-server + ${cms.version} + + + com.microsoft.playwright + playwright + 1.60.0 + - - com.condation.cms - cms-api - ${cms.version} - provided - - - com.condation.modules.framework - modules-api - ${modules.version} - provided - - - org.projectlombok - lombok - ${lombok.version} - provided - - + + com.condation.cms + cms-api + ${cms.version} + + + com.condation.modules.framework + modules-api + ${modules.version} + + + org.projectlombok + lombok + ${lombok.version} + + + - - maven-assembly-plugin - 3.8.0 - - - src/main/assembly/assembly.xml - - ${module.id} - - - - package - - single - - - - org.codehaus.mojo license-maven-plugin @@ -169,7 +164,7 @@
- + diff --git a/test-server/.env b/test-server/.env new file mode 100644 index 0000000..80b7bbb --- /dev/null +++ b/test-server/.env @@ -0,0 +1 @@ +CMS_UI_SECRET=xnK82mcK7I9s_K3j-L8vK9L2m_N3o_P4q_R5s_T6u_V7w_X8y_Z9a_B0c_D1e \ No newline at end of file diff --git a/test-server/config/manager-users.realm b/test-server/config/manager-users.realm new file mode 100644 index 0000000..eeee8e3 --- /dev/null +++ b/test-server/config/manager-users.realm @@ -0,0 +1 @@ +test:5Y03iNt821JqVjsutDrZ2DRlKfBefu6mleCg2ThiqdY=:manager:eyJtYWlsIjoidGVzdEBsb2NhbC5kZSIsInNhbHQiOiJneFlPTTZ5V1J1U3NPNGVLRGY3TGh3XHUwMDNkXHUwMDNkIn0= \ No newline at end of file diff --git a/test-server/hosts/demo/assets/thumbnails/mountains.jpg b/test-server/hosts/demo/assets/thumbnails/mountains.jpg new file mode 100644 index 0000000000000000000000000000000000000000..9256fe11532df196b8f43c9e6ad9768de7a02397 GIT binary patch literal 50993 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 literal 0 HcmV?d00001 diff --git a/test-server/hosts/demo/config/media.toml b/test-server/hosts/demo/config/media.toml new file mode 100644 index 0000000..599c04b --- /dev/null +++ b/test-server/hosts/demo/config/media.toml @@ -0,0 +1,22 @@ +processor = "imageio" +[[formats]] +name = "small" +width = 256 +height = 256 +format = "webp" +compression = true + +[[formats]] +name = "big" +width = 512 +height = 512 +format = "webp" +compression = true + +[[formats]] +name = "test2" +width = 72 +height = 72 +format = "webp" +compression = true + diff --git a/test-server/hosts/demo/content/.technical/404.md b/test-server/hosts/demo/content/.technical/404.md new file mode 100644 index 0000000..776ccf0 --- /dev/null +++ b/test-server/hosts/demo/content/.technical/404.md @@ -0,0 +1,5 @@ +--- +title: Leider nichts gefunden +template: error.html +--- +Da haben wir leider nichts gefunden! diff --git a/test-server/hosts/demo/content/index.md b/test-server/hosts/demo/content/index.md new file mode 100644 index 0000000..3f33552 --- /dev/null +++ b/test-server/hosts/demo/content/index.md @@ -0,0 +1,11 @@ +--- +title: video-module test page +template: start.html +search: + index: false +published: true +--- + +# Vimeo Shortcode + +[[video type="vimeo" id="170338499" title="Everybody loves little cats" /]] \ No newline at end of file diff --git a/test-server/hosts/demo/public/.well-known/security.txt b/test-server/hosts/demo/public/.well-known/security.txt new file mode 100644 index 0000000..27bdbf6 --- /dev/null +++ b/test-server/hosts/demo/public/.well-known/security.txt @@ -0,0 +1 @@ +CondationCMS is secure \ No newline at end of file diff --git a/test-server/hosts/demo/public/favicon.ico b/test-server/hosts/demo/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..6848441be374fc0a908926cea0be3bdfa815414d GIT binary patch literal 1406 zcmZQzU<5(|0R}M0U}azs1F|%L7$l?s#Ec9aKoZP=&`9k6|NkSzMp>gFFd71*Aut*O HM27$XOB)0R literal 0 HcmV?d00001 diff --git a/test-server/hosts/demo/public/robots.txt b/test-server/hosts/demo/public/robots.txt new file mode 100644 index 0000000..d107a5f --- /dev/null +++ b/test-server/hosts/demo/public/robots.txt @@ -0,0 +1,3 @@ +User-agent: * +Disallow: /about/impressum +Allow: / diff --git a/test-server/hosts/demo/site-dev.toml b/test-server/hosts/demo/site-dev.toml new file mode 100644 index 0000000..7c75c2d --- /dev/null +++ b/test-server/hosts/demo/site-dev.toml @@ -0,0 +1,7 @@ +# site configuration for dev environment + +hostname = [ "localhost5" ] # hostnames for this site, used for request matching + +[api] +enabled = true +whitelist = ["meta.*", "title"] \ No newline at end of file diff --git a/test-server/hosts/demo/site.toml b/test-server/hosts/demo/site.toml new file mode 100644 index 0000000..ac1fdfd --- /dev/null +++ b/test-server/hosts/demo/site.toml @@ -0,0 +1,9 @@ +id = "demo-site" +hostname = [ "localhost", "127.0.0.1" ] +baseurl = "http://localhost:2020" +locale = "en_US" +context_path = "/" + +# modules to load for this site +[modules] +#active = ["videos-module"] # list of active modules for this sites \ No newline at end of file diff --git a/test-server/hosts/demo/templates/start.html b/test-server/hosts/demo/templates/start.html new file mode 100644 index 0000000..006443b --- /dev/null +++ b/test-server/hosts/demo/templates/start.html @@ -0,0 +1,17 @@ + + + + + + {{ node.meta.title }} + + + + + + {{ node.content | raw }} + + + + + \ No newline at end of file diff --git a/test-server/log4j2.xml b/test-server/log4j2.xml new file mode 100644 index 0000000..3c88dbf --- /dev/null +++ b/test-server/log4j2.xml @@ -0,0 +1,69 @@ + + + + %m%n + %d{yyyy-MM-dd HH:mm:ss} %-5p %c{1} - %m%n + ${sys:cms-logs-folder} + 5 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test-server/server.toml b/test-server/server.toml new file mode 100644 index 0000000..5daacce --- /dev/null +++ b/test-server/server.toml @@ -0,0 +1,34 @@ +# environment: dev and prod +env = "dev" + +# server settings +[server] +port = 2020 # server port +ip = "127.0.0.1" # ip bind to + +# inter process communication +[ipc] +port = 6868 # ipc port +password = "test_pwd" # ipc password + +# application performance management +[apm] +enabled = false # enable +max_requests = 100 # max requests per remote IP +thread_limit = 10 # thread limit per remote IP + +[performance] +pool_enabled = false +pool_size = 10 +pool_expire = 1000 + + +[list] +test = ["eins", "zwei"] + +[map] +test = {"key1"="value2", "key2"="value2"} + +# ui manager properties +[ui] +secret = "${env:CMS_UI_SECRET}" # secret for JWT From c069542b33060fe5f198373ccb03ddac832c21c1 Mon Sep 17 00:00:00 2001 From: Thorsten Marx Date: Mon, 27 Jul 2026 14:48:22 +0200 Subject: [PATCH 6/9] Make captcha optional per form --- .../cms/modules/forms/FormsConfig.java | 6 ++++++ .../modules/forms/handler/FormsHandling.java | 4 +++- .../cms/modules/forms/FormConfigTest.java | 20 +++++++++++++++++++ .../cms/modules/forms/FormsHandlingTest.java | 10 ++++++++++ 4 files changed, 39 insertions(+), 1 deletion(-) diff --git a/module/src/main/java/com/condation/cms/modules/forms/FormsConfig.java b/module/src/main/java/com/condation/cms/modules/forms/FormsConfig.java index c707181..6232c13 100644 --- a/module/src/main/java/com/condation/cms/modules/forms/FormsConfig.java +++ b/module/src/main/java/com/condation/cms/modules/forms/FormsConfig.java @@ -134,6 +134,7 @@ public static class Form { private Mail mail = new Mail(); private Spam spam = new Spam(); private RateLimit rateLimit = new RateLimit(); + private Captcha captcha = new Captcha(); public void setFields(final Map configuredFields) { this.fields = configuredFields == null @@ -213,4 +214,9 @@ public static class Csrf { private boolean enabled = true; private Set allowedOrigins = Collections.emptySet(); } + + @Data + public static class Captcha { + private boolean enabled = true; + } } diff --git a/module/src/main/java/com/condation/cms/modules/forms/handler/FormsHandling.java b/module/src/main/java/com/condation/cms/modules/forms/handler/FormsHandling.java index 0ae123b..6dba59c 100644 --- a/module/src/main/java/com/condation/cms/modules/forms/handler/FormsHandling.java +++ b/module/src/main/java/com/condation/cms/modules/forms/handler/FormsHandling.java @@ -165,7 +165,9 @@ private void validateFields(final FormsConfig.Form form, final Function parameters) throws FormHandlingException { validateSpam(form, parameters); validateFields(form, parameters); - validateCaptcha(form, parameters.apply("key"), parameters.apply("code")); + if (form.getCaptcha().isEnabled()) { + validateCaptcha(form, parameters.apply("key"), parameters.apply("code")); + } try { var data = hookData(form, parameters); diff --git a/module/src/test/java/com/condation/cms/modules/forms/FormConfigTest.java b/module/src/test/java/com/condation/cms/modules/forms/FormConfigTest.java index 5dc078e..9c5ce44 100644 --- a/module/src/test/java/com/condation/cms/modules/forms/FormConfigTest.java +++ b/module/src/test/java/com/condation/cms/modules/forms/FormConfigTest.java @@ -51,4 +51,24 @@ void test_config () throws Exception { Assertions.assertThat(FORMSCONFIG.findForm("contact").get().getFields().get("message").getMinLength()).isEqualTo(10); Assertions.assertThat(FORMSCONFIG.findForm("test-form").get().getMail().getAccount()).isEqualTo("other"); } + + @Test + void captcha_defaults_to_enabled_and_can_be_disabled() throws Exception { + var yaml = """ + forms: + - name: with-default + fields: + message: {} + - name: without-captcha + captcha: + enabled: false + fields: + message: {} + """; + var config = new org.yaml.snakeyaml.Yaml().loadAs(yaml, FormsConfig.class); + config.validate(); + + Assertions.assertThat(config.findForm("with-default").get().getCaptcha().isEnabled()).isTrue(); + Assertions.assertThat(config.findForm("without-captcha").get().getCaptcha().isEnabled()).isFalse(); + } } diff --git a/module/src/test/java/com/condation/cms/modules/forms/FormsHandlingTest.java b/module/src/test/java/com/condation/cms/modules/forms/FormsHandlingTest.java index 048d1f4..ffafbe7 100644 --- a/module/src/test/java/com/condation/cms/modules/forms/FormsHandlingTest.java +++ b/module/src/test/java/com/condation/cms/modules/forms/FormsHandlingTest.java @@ -106,6 +106,16 @@ void rejectsFilledHoneypot() { .isEqualTo("SPAM_REJECTED"); } + @Test + void skipsCaptchaValidationWhenDisabled() throws Exception { + form.getCaptcha().setEnabled(false); + var values = validValues(); + values.remove("key"); + values.remove("code"); + + handling.handleForm(form, values::get); + } + private Map validValues() { var values = new LinkedHashMap(); values.put("email", "visitor@example.com"); From 1125a34eab7203a82b6057ec02261e25aa480c3b Mon Sep 17 00:00:00 2001 From: Thorsten Marx Date: Mon, 27 Jul 2026 15:25:11 +0200 Subject: [PATCH 7/9] prepare for e2e tests --- .gitignore | 1 + ...26-07-27-e2e-tests-and-optional-captcha.md | 987 ------------------ ...7-e2e-tests-and-optional-captcha-design.md | 204 ---- module/pom.xml | 38 +- module/src/main/assembly/assembly.xml | 1 + .../forms/e2e/{E2ETest.java => E2EIT.java} | 18 +- test-server/hosts/demo/content/index.md | 8 +- 7 files changed, 44 insertions(+), 1213 deletions(-) delete mode 100644 docs/superpowers/plans/2026-07-27-e2e-tests-and-optional-captcha.md delete mode 100644 docs/superpowers/specs/2026-07-27-e2e-tests-and-optional-captcha-design.md rename module/src/test/java/com/condation/cms/modules/forms/e2e/{E2ETest.java => E2EIT.java} (73%) diff --git a/.gitignore b/.gitignore index 43dc124..089cffb 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ demo/server.yaml .vscode/settings.json test-server/logs test-server/cms.pid +test-server/modules test-server/hosts/demo/modules_data/ test-server/hosts/demo/temp/ test-server/hosts/demo/data/ diff --git a/docs/superpowers/plans/2026-07-27-e2e-tests-and-optional-captcha.md b/docs/superpowers/plans/2026-07-27-e2e-tests-and-optional-captcha.md deleted file mode 100644 index 657d1f4..0000000 --- a/docs/superpowers/plans/2026-07-27-e2e-tests-and-optional-captcha.md +++ /dev/null @@ -1,987 +0,0 @@ -# E2E Tests and Optional Captcha Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Adapt the video-module-derived `E2ETest`/`test-server` scaffolding to forms-module, fix the module-deploy gap that leaves the CMS test server with 0 loaded extensions, and make captcha optional per form so the E2E suite can exercise real form submissions (including actual mail delivery via GreenMail) without solving a captcha. - -**Architecture:** `module/pom.xml` gains an assembly `dir` format plus a `maven-resources-plugin` copy step bound to `pre-integration-test`, so `mvn verify` produces a deployable module layout under `test-server/modules/forms-module/` before Failsafe runs. `E2ETest.java` is renamed to `E2EIT.java` so Failsafe (not Surefire) picks it up in the `integration-test` phase, after `package`. `FormsConfig.Form` gets a `captcha.enabled` flag (default `true`); `FormsHandling` skips captcha validation when it's `false`. `test-server/hosts/demo/` is rewritten with a real `forms.yaml`/`mail.yaml`, Pebble-syntax templates, and content pages so the E2E suite can drive two forms (plain POST + AJAX) through Playwright, with GreenMail acting as the SMTP backend. - -**Tech Stack:** Java 25, Maven (assembly/resources/failsafe/surefire plugins), JUnit 5, Playwright (Java), GreenMail (`greenmail-junit5`), SnakeYAML, Lombok `@Data`. - -## Global Constraints - -- Module id/artifact stays `forms-module`; module basedir is `module/` (sibling of `test-server/`), so any path passed to `CMSServerExtension` must be `"../test-server"` relative to `module/`, not `"test-server"`. -- Captcha default must remain `true` — no existing YAML config may change behavior. -- `demo/` (the old Thymeleaf example project) is explicitly out of scope and must not be modified. -- No secrets/passwords beyond throwaway test credentials (GreenMail test account) are introduced. -- Rate limiting, CSRF, and honeypot logic must not be touched except where explicitly noted. -- All new/modified Java files keep the existing GPLv3 header block (the `license-maven-plugin` `update-file-header` goal regenerates it on `process-sources`, so it's fine to omit it while writing and let the build add it — but keep the package/import structure consistent with existing files). - ---- - -## File Structure - -| File | Responsibility | -|---|---| -| `module/src/main/java/com/condation/cms/modules/forms/FormsConfig.java` | Add `Form.Captcha` nested config (`enabled`, default `true`). | -| `module/src/main/java/com/condation/cms/modules/forms/handler/FormsHandling.java` | Skip `validateCaptcha(...)` when `form.getCaptcha().isEnabled()` is `false`. | -| `module/src/test/java/com/condation/cms/modules/forms/FormConfigTest.java` | Assert captcha default/override parsing. | -| `module/src/test/java/com/condation/cms/modules/forms/FormsHandlingTest.java` | Assert captcha-disabled form skips captcha validation. | -| `module/src/main/assembly/assembly.xml` | Add `dir` format alongside existing `zip`. | -| `module/pom.xml` | Add `maven-resources-plugin` copy execution (`pre-integration-test`), `maven-failsafe-plugin`. | -| `module/src/test/java/com/condation/cms/modules/forms/e2e/E2ETest.java` → `E2EIT.java` | Rewritten E2E suite: server boot, plain-form success/validation/honeypot, AJAX success/validation, GreenMail assertion. | -| `test-server/hosts/demo/site.toml` | Activate `forms-module`. | -| `test-server/hosts/demo/config/forms.yaml` (new) | `contact` + `ajax-contact` form definitions, captcha disabled, rate limit disabled. | -| `test-server/hosts/demo/config/mail.yaml` (new) | `default` SMTP account pointing at GreenMail's fixed port. | -| `test-server/hosts/demo/templates/contact.html` (new) | Plain POST form, Pebble syntax, no captcha markup. | -| `test-server/hosts/demo/templates/ajax.html` (new) | AJAX form + fetch-based submit script. | -| `test-server/hosts/demo/content/contact.md`, `content/ajax.md`, `content/forms/contact/success.md`, `content/forms/error.md` (new) | Pages rendered by the two templates above. | -| `test-server/hosts/demo/content/index.md` | Title updated from "video-module test page" to a forms-module-specific title. | -| `.gitignore` (module root) | Add `test-server/modules/`, `test-server/logs/`, `test-server/cms.pid`, `test-server/hosts/demo/modules_data/`, `test-server/hosts/demo/temp/`, `test-server/hosts/demo/data/`. | -| `test-server/hosts/demo/assets/thumbnails/`, `test-server/hosts/demo/config/media.toml` | Removed (video-module leftovers, unreferenced by new templates). | - ---- - -### Task 1: Make captcha optional per form - -**Files:** -- Modify: `module/src/main/java/com/condation/cms/modules/forms/FormsConfig.java:126-143` (the `Form` class) -- Modify: `module/src/main/java/com/condation/cms/modules/forms/handler/FormsHandling.java:132-146` (`handleForm`) -- Test: `module/src/test/java/com/condation/cms/modules/forms/FormConfigTest.java` -- Test: `module/src/test/java/com/condation/cms/modules/forms/FormsHandlingTest.java` - -**Interfaces:** -- Produces: `FormsConfig.Form.getCaptcha()` returning `FormsConfig.Captcha` with `isEnabled()` (default `true`), used by `FormsHandling.handleForm` and by the E2E test-server config (`captcha.enabled: false` in YAML). - -- [ ] **Step 1: Write the failing config test** - -Add to `module/src/test/java/com/condation/cms/modules/forms/FormConfigTest.java` (inside the existing `FormConfigTest` class, as a new `@Test` method): - -```java - @Test - void captcha_defaults_to_enabled_and_can_be_disabled() throws Exception { - var yaml = """ - forms: - - name: with-default - fields: - message: {} - - name: without-captcha - captcha: - enabled: false - fields: - message: {} - """; - var config = new org.yaml.snakeyaml.Yaml().loadAs(yaml, FormsConfig.class); - config.validate(); - - Assertions.assertThat(config.findForm("with-default").get().getCaptcha().isEnabled()).isTrue(); - Assertions.assertThat(config.findForm("without-captcha").get().getCaptcha().isEnabled()).isFalse(); - } -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `cd module && mvn -q -Dtest=FormConfigTest#captcha_defaults_to_enabled_and_can_be_disabled test` -Expected: compilation error — `getCaptcha()` does not exist on `FormsConfig.Form`. - -- [ ] **Step 3: Add the `Captcha` config class and wire it into `Form`** - -In `module/src/main/java/com/condation/cms/modules/forms/FormsConfig.java`, inside `public static class Form { ... }` (currently ending at line 143), add a field: - -```java - private Captcha captcha = new Captcha(); -``` - -so the full `Form` class becomes: - -```java - @Data - public static class Form { - private String name; - private Redirects redirects; - private Map fields = new LinkedHashMap<>(); - private String to; - private String subject; - private Map data; - private Mail mail = new Mail(); - private Spam spam = new Spam(); - private RateLimit rateLimit = new RateLimit(); - private Captcha captcha = new Captcha(); - - public void setFields(final Map configuredFields) { - this.fields = configuredFields == null - ? new LinkedHashMap<>() - : new LinkedHashMap<>(configuredFields); - } - } -``` - -Then add a new nested class next to `Csrf` (after the `Csrf` class, before the closing brace of `FormsConfig`): - -```java - @Data - public static class Captcha { - private boolean enabled = true; - } -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `cd module && mvn -q -Dtest=FormConfigTest#captcha_defaults_to_enabled_and_can_be_disabled test` -Expected: PASS. - -- [ ] **Step 5: Write the failing handling test** - -Add to `module/src/test/java/com/condation/cms/modules/forms/FormsHandlingTest.java` (new `@Test` method in the existing class): - -```java - @Test - void skipsCaptchaValidationWhenDisabled() throws Exception { - form.getCaptcha().setEnabled(false); - var values = validValues(); - values.remove("key"); - values.remove("code"); - - handling.handleForm(form, values::get); - } -``` - -- [ ] **Step 6: Run test to verify it fails** - -Run: `cd module && mvn -q -Dtest=FormsHandlingTest#skipsCaptchaValidationWhenDisabled test` -Expected: FAIL — `FormHandlingException: INVALID_CAPTCHA` is thrown because `key`/`code` are missing and captcha is still enforced. - -- [ ] **Step 7: Make captcha validation conditional** - -In `module/src/main/java/com/condation/cms/modules/forms/handler/FormsHandling.java`, locate `handleForm` (currently): - -```java - public void handleForm(final FormsConfig.Form form, final Function parameters) throws FormHandlingException { - validateSpam(form, parameters); - validateFields(form, parameters); - validateCaptcha(form, parameters.apply("key"), parameters.apply("code")); -``` - -Change the captcha line to: - -```java - public void handleForm(final FormsConfig.Form form, final Function parameters) throws FormHandlingException { - validateSpam(form, parameters); - validateFields(form, parameters); - if (form.getCaptcha().isEnabled()) { - validateCaptcha(form, parameters.apply("key"), parameters.apply("code")); - } -``` - -- [ ] **Step 8: Run test to verify it passes** - -Run: `cd module && mvn -q -Dtest=FormsHandlingTest#skipsCaptchaValidationWhenDisabled test` -Expected: PASS. - -- [ ] **Step 9: Run the full unit test suite** - -Run: `cd module && mvn -q test` -Expected: all tests pass, including the pre-existing `FormsHandlingTest` cases (`rejectsSubmittedCaptchaCodeInsteadOfComparingStoredValueWithItself`, `acceptsAndConsumesCorrectCaptcha`, etc.), which still exercise the default (`captcha.enabled = true`) path unchanged. - -- [ ] **Step 10: Commit** - -```bash -cd /Users/thorstenmarx/entwicklung/workspaces/tma/cms/modules/forms-module -git add module/src/main/java/com/condation/cms/modules/forms/FormsConfig.java \ - module/src/main/java/com/condation/cms/modules/forms/handler/FormsHandling.java \ - module/src/test/java/com/condation/cms/modules/forms/FormConfigTest.java \ - module/src/test/java/com/condation/cms/modules/forms/FormsHandlingTest.java -git commit -m "Make captcha optional per form" -``` - ---- - -### Task 2: Fix the module-deploy gap (assembly dir format + copy step + failsafe) - -**Files:** -- Modify: `module/src/main/assembly/assembly.xml` -- Modify: `module/pom.xml` -- Rename: `module/src/test/java/com/condation/cms/modules/forms/e2e/E2ETest.java` → `E2EIT.java` (content rewritten in Task 3; this task only renames + fixes the constructor path so the class still compiles as-is) - -**Interfaces:** -- Produces: after `mvn package`, `module/target/forms-module-bin/` exists containing `module.properties` and `libs/*.jar` (module jar + runtime deps). After `mvn pre-integration-test` (or later phases), `test-server/modules/forms-module/` mirrors that directory. Failsafe runs any `**/*IT.java` in `integration-test`/`verify`. - -- [ ] **Step 1: Add the `dir` format to the assembly descriptor** - -Current `module/src/main/assembly/assembly.xml`: - -```xml - - bin - - zip - - - - target/${project.build.finalName}.${project.packaging} - libs/ - - - - - ${project.basedir} - / - - module.properties - - true - - - - - libs - true - runtime - - - -``` - -Change `` to include `dir`: - -```xml - - zip - dir - -``` - -Everything else in the file stays the same. With `bin` and `${module.id}` = `forms-module`, the assembly plugin (per its `finalName` config, see Step 2) will produce `target/forms-module-bin/` as a real directory in addition to `target/forms-module-bin.zip`. - -- [ ] **Step 2: Add the resources-copy execution and failsafe plugin to `module/pom.xml`** - -Current relevant block in `module/pom.xml`: - -```xml - - - - maven-assembly-plugin - 3.8.0 - - - src/main/assembly/assembly.xml - - ${module.id} - - - - package - - single - - - - - - -``` - -Replace it with: - -```xml - - - - maven-assembly-plugin - 3.8.0 - - - src/main/assembly/assembly.xml - - ${module.id} - - - - package - - single - - - - - - org.apache.maven.plugins - maven-resources-plugin - 3.3.1 - - - deploy-module-to-test-server - pre-integration-test - - copy-resources - - - ${project.basedir}/../test-server/modules/${module.id} - true - - - ${project.build.directory}/${module.id}-bin - - - - - - - - org.apache.maven.plugins - maven-failsafe-plugin - 3.5.4 - - - - integration-test - verify - - - - - - -``` - -Notes: -- `${project.build.directory}/${module.id}-bin` resolves to `target/forms-module-bin`, matching the assembly `bin` + `finalName=${module.id}` combination. -- `${project.basedir}/../test-server/modules/${module.id}` resolves to `module/../test-server/modules/forms-module` = `test-server/modules/forms-module`, a sibling of `module/`. -- Failsafe's default includes (`**/*IT.java`, `**/IT*.java`, `**/*ITCase.java`) will pick up `E2EIT.java` once renamed in Step 3; default excludes keep Surefire from also running it (Surefire's default excludes already skip `**/*IT.java`). - -- [ ] **Step 3: Rename the E2E test class** - -```bash -cd /Users/thorstenmarx/entwicklung/workspaces/tma/cms/modules/forms-module -git mv module/src/test/java/com/condation/cms/modules/forms/e2e/E2ETest.java \ - module/src/test/java/com/condation/cms/modules/forms/e2e/E2EIT.java -``` - -Edit the file to rename the class declaration (content will be fully rewritten in Task 3, but make the minimal rename now so the module still compiles): - -Open `module/src/test/java/com/condation/cms/modules/forms/e2e/E2EIT.java` and change: - -```java -public class E2ETest { -``` - -to: - -```java -public class E2EIT { -``` - -Also fix the `CMSServerExtension` path, which currently reads `"test-server"` (correct for the video-module's flat layout, wrong here since `module/` and `test-server/` are siblings under `forms-module/`): - -```java - @RegisterExtension - static CMSServerExtension serverExtensions = new CMSServerExtension("test-server"); -``` - -becomes: - -```java - @RegisterExtension - static CMSServerExtension serverExtensions = new CMSServerExtension("../test-server"); -``` - -Leave the three existing `@Test` methods (`server_is_started`, `start_page`, `contains_header`) as-is for now — they still reference video-module content and will be replaced in Task 3. - -- [ ] **Step 4: Verify `mvn package` produces the dir layout** - -Run: `cd module && mvn -q clean package -DskipTests` -Expected: exit code 0. Then check: - -```bash -ls module/target/forms-module-bin/ -``` -Expected output includes `module.properties` and a `libs/` directory containing `forms-module-.jar` plus runtime dependency jars (nanocaptcha, caffeine, snakeyaml, gson, etc.). - -- [ ] **Step 5: Verify the copy step deploys to test-server** - -Run: `cd module && mvn -q pre-integration-test -DskipTests` -Expected: exit code 0. Then check: - -```bash -ls test-server/modules/forms-module/ -ls test-server/modules/forms-module/libs/ | grep forms-module -``` -Expected: `module.properties` and `libs/forms-module-.jar` present under `test-server/modules/forms-module/`. - -- [ ] **Step 6: Commit** - -```bash -cd /Users/thorstenmarx/entwicklung/workspaces/tma/cms/modules/forms-module -git add module/src/main/assembly/assembly.xml module/pom.xml \ - module/src/test/java/com/condation/cms/modules/forms/e2e/E2EIT.java -git status --short module/src/test/java/com/condation/cms/modules/forms/e2e/ -git commit -m "Deploy built module jar to test-server before integration tests" -``` - -(`git status` first to confirm the old `E2ETest.java` path is gone and only `E2EIT.java` is staged, since `git mv` already recorded the rename.) - ---- - -### Task 3: Rewrite `test-server/` content for forms-module (site config, forms.yaml, mail.yaml, templates, content) - -**Files:** -- Modify: `test-server/hosts/demo/site.toml` -- Create: `test-server/hosts/demo/config/forms.yaml` -- Create: `test-server/hosts/demo/config/mail.yaml` -- Create: `test-server/hosts/demo/templates/contact.html` -- Create: `test-server/hosts/demo/templates/ajax.html` -- Create: `test-server/hosts/demo/content/contact.md` -- Create: `test-server/hosts/demo/content/ajax.md` -- Create: `test-server/hosts/demo/content/forms/contact/success.md` -- Create: `test-server/hosts/demo/content/forms/error.md` -- Modify: `test-server/hosts/demo/content/index.md` -- Delete: `test-server/hosts/demo/assets/thumbnails/mountains.jpg`, `test-server/hosts/demo/config/media.toml` - -**Interfaces:** -- Produces: two working forms reachable at `/contact` (plain POST to `/module/forms-module/form/submit`) and `/ajax` (fetch POST to `/module/forms-module/form/submit/ajax`), form names `contact` and `ajax-contact`, both with `captcha.enabled: false` and `rateLimit.enabled: false`. Mail account `default` in `mail.yaml` with a placeholder port `3025` (GreenMail in Task 4 binds exactly this port before the server starts). - -- [ ] **Step 1: Activate forms-module in `site.toml`** - -Current `test-server/hosts/demo/site.toml`: - -```toml -id = "demo-site" -hostname = [ "localhost", "127.0.0.1" ] -baseurl = "http://localhost:2020" -locale = "en_US" -context_path = "/" - -# modules to load for this site -[modules] -#active = ["videos-module"] # list of active modules for this sites -``` - -Replace with: - -```toml -id = "demo-site" -hostname = [ "localhost", "127.0.0.1" ] -baseurl = "http://localhost:2020" -locale = "en_US" -context_path = "/" - -# modules to load for this site -[modules] -active = ["forms-module"] -``` - -- [ ] **Step 2: Create `test-server/hosts/demo/config/forms.yaml`** - -```yaml -forms: - - name: contact - to: contact@example.com - subject: New contact form submission - captcha: - enabled: false - rateLimit: - enabled: false - fields: - from: - type: email - required: true - message: - required: true - minLength: 3 - maxLength: 5000 - mail: - account: default - from: forms@example.com - spam: - honeypot: - enabled: true - field: website - redirects: - success: /forms/contact/success - - name: ajax-contact - captcha: - enabled: false - rateLimit: - enabled: false - fields: - from: - type: email - required: true - message: - required: true - minLength: 3 - spam: - honeypot: - enabled: true - field: website -redirects: - error: /forms/error -``` - -- [ ] **Step 3: Create `test-server/hosts/demo/config/mail.yaml`** - -```yaml -accounts: - default: - host: localhost - fromMail: forms@example.com - port: 3025 - username: forms-test - password: forms-test-password -``` - -(Port `3025` and the `forms-test`/`forms-test-password` credentials must match exactly what `E2EIT.java` configures on the `GreenMailExtension` in Task 4 — see that task's `greenMail.setUser("forms-test", "forms-test-password")` call.) - -- [ ] **Step 4: Create `test-server/hosts/demo/templates/contact.html`** - -```html - - - - - {{ node.meta.title }} - - - - - - {{ node.content | raw }} - -
- - -
- - -
-
- - -
-
- -
-
- - - - -``` - -- [ ] **Step 5: Create `test-server/hosts/demo/templates/ajax.html`** - -```html - - - - - {{ node.meta.title }} - - - - - - {{ node.content | raw }} - -
- - -
- - -
-
- - -
-
- -
-
-
- - - - - - -``` - -- [ ] **Step 6: Create content pages** - -`test-server/hosts/demo/content/contact.md`: - -```markdown ---- -title: Contact -template: contact.html -search: - index: false -published: true ---- - -# Contact us -``` - -`test-server/hosts/demo/content/ajax.md`: - -```markdown ---- -title: Ajax Contact -template: ajax.html -search: - index: false -published: true ---- - -# Contact us via ajax -``` - -`test-server/hosts/demo/content/forms/contact/success.md` (uses the plain `start.html` template already present in `test-server/hosts/demo/templates/start.html`, which just renders `node.content` — reusing `contact.html` here would incorrectly re-render the form itself): - -```markdown ---- -title: Form submitted -template: start.html -search: - index: false -published: true ---- - -## Your request was successfully submitted -``` - -`test-server/hosts/demo/content/forms/error.md` (same reasoning — plain `start.html`, not `contact.html`): - -```markdown ---- -title: Error sending form -template: start.html -search: - index: false -published: true ---- - -## Error submitting your request! -``` - -- [ ] **Step 7: Update `test-server/hosts/demo/content/index.md`** - -Current: - -```markdown ---- -title: video-module test page -template: start.html -search: - index: false -published: true ---- - -# Vimeo Shortcode - -[[video type="vimeo" id="170338499" title="Everybody loves little cats" /]] -``` - -Replace with: - -```markdown ---- -title: forms-module test page -template: start.html -search: - index: false -published: true ---- - -# Forms module test page -``` - -- [ ] **Step 8: Remove video-module leftovers** - -```bash -cd /Users/thorstenmarx/entwicklung/workspaces/tma/cms/modules/forms-module -git rm -r test-server/hosts/demo/assets/thumbnails test-server/hosts/demo/config/media.toml -``` - -- [ ] **Step 9: Commit** - -```bash -cd /Users/thorstenmarx/entwicklung/workspaces/tma/cms/modules/forms-module -git add test-server/hosts/demo/site.toml \ - test-server/hosts/demo/config/forms.yaml \ - test-server/hosts/demo/config/mail.yaml \ - test-server/hosts/demo/templates/contact.html \ - test-server/hosts/demo/templates/ajax.html \ - test-server/hosts/demo/content/contact.md \ - test-server/hosts/demo/content/ajax.md \ - test-server/hosts/demo/content/forms \ - test-server/hosts/demo/content/index.md -git commit -m "Rewrite test-server content for forms-module" -``` - -(The `git rm` from Step 8 is already staged as part of the deletion; it will be included in this commit too — run `git status --short` beforehand if you want to double check exactly what's staged.) - ---- - -### Task 4: Rewrite the E2E test suite (`E2EIT.java`) with GreenMail - -**Files:** -- Modify: `module/src/test/java/com/condation/cms/modules/forms/e2e/E2EIT.java` (full rewrite) - -**Interfaces:** -- Consumes: `CMSServerExtension("../test-server")` (Task 2), `GreenMailExtension` with fixed `ServerSetup(3025, "127.0.0.1", ServerSetup.PROTOCOL_SMTP)` (from `com.icegreen.greenmail.util.ServerSetup`, constructor `ServerSetup(int port, String bindAddress, String protocol)`), matching `host: localhost` in Task 3's `mail.yaml`, forms `contact` and `ajax-contact` as configured in Task 3's `forms.yaml`, mail account `default`/port `3025`/user `forms-test`/password `forms-test-password` as configured in Task 3's `mail.yaml`. -- Produces: no new public interface; this is the terminal artifact for this plan. - -- [ ] **Step 1: Write the full `E2EIT.java` test class** - -Replace the entire content of `module/src/test/java/com/condation/cms/modules/forms/e2e/E2EIT.java` with: - -```java -package com.condation.cms.modules.forms.e2e; - -/*- - * #%L - * forms-module - * %% - * Copyright (C) 2024 - 2026 CondationCMS - * %% - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public - * License along with this program. If not, see - * . - * #L% - */ - -import com.condation.cms.cli.tools.CLIServerUtils; -import com.condation.cms.test.e2e.CMSServerExtension; -import com.icegreen.greenmail.junit5.GreenMailExtension; -import com.icegreen.greenmail.util.GreenMailUtil; -import com.icegreen.greenmail.util.ServerSetup; -import com.microsoft.playwright.Page; -import com.microsoft.playwright.junit.UsePlaywright; -import java.util.Map; -import org.assertj.core.api.Assertions; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.RegisterExtension; - -/** - * - * @author thorstenmarx - */ -@UsePlaywright -public class E2EIT { - - @RegisterExtension - static GreenMailExtension greenMail = new GreenMailExtension( - new ServerSetup(3025, "127.0.0.1", ServerSetup.PROTOCOL_SMTP)); - - @RegisterExtension - static CMSServerExtension serverExtensions = new CMSServerExtension("../test-server"); - - @Test - void server_is_started() throws Exception { - Assertions.assertThat(CLIServerUtils.getCMSProcess()).isPresent(); - } - - @Test - void start_page(Page page) { - page.navigate("http://localhost:2020"); - Assertions.assertThat(page.locator("title").innerText()).isEqualTo("forms-module test page"); - } - - @Test - void successful_submission_sends_mail_and_redirects(Page page) { - greenMail.setUser("forms-test", "forms-test-password"); - - page.navigate("http://localhost:2020/contact"); - page.fill("#from", "visitor@example.com"); - page.fill("#message", "Hello from the E2E test"); - page.click("#submit-btn"); - - Assertions.assertThat(page.url()).contains("/forms/contact/success"); - - var messages = greenMail.getReceivedMessagesForDomain("contact@example.com"); - Assertions.assertThat(messages).hasSize(1); - Assertions.assertThat(messages[0].getSubject()).isEqualTo("New contact form submission"); - Assertions.assertThat(GreenMailUtil.getBody(messages[0])).contains("Hello from the E2E test"); - } - - @Test - void missing_required_field_redirects_to_error_and_sends_no_mail(Page page) { - page.navigate("http://localhost:2020/contact"); - page.fill("#from", "visitor@example.com"); - page.click("#submit-btn"); - - Assertions.assertThat(page.url()).contains("/forms/error"); - Assertions.assertThat(greenMail.getReceivedMessages()).isEmpty(); - } - - @Test - void filled_honeypot_redirects_to_error_and_sends_no_mail(Page page) { - page.navigate("http://localhost:2020/contact"); - page.fill("#from", "visitor@example.com"); - page.fill("#message", "Hello from the E2E test"); - page.fill("input[name=website]", "https://spam.example"); - page.click("#submit-btn"); - - Assertions.assertThat(page.url()).contains("/forms/error"); - Assertions.assertThat(greenMail.getReceivedMessages()).isEmpty(); - } - - @Test - void ajax_form_returns_success_json(Page page) { - page.navigate("http://localhost:2020/ajax"); - page.fill("#from", "visitor@example.com"); - page.fill("#message", "Hello via ajax"); - page.click("#submit-btn"); - - page.waitForFunction("() => document.getElementById('ajaxResult').hasAttribute('data-success')"); - - Assertions.assertThat(page.locator("#ajaxResult").getAttribute("data-success")).isEqualTo("true"); - } - - @Test - void ajax_form_returns_validation_error_json(Page page) { - page.navigate("http://localhost:2020/ajax"); - page.fill("#from", "not-an-email"); - page.fill("#message", "Hello via ajax"); - page.click("#submit-btn"); - - page.waitForFunction("() => document.getElementById('ajaxResult').hasAttribute('data-success')"); - - Assertions.assertThat(page.locator("#ajaxResult").getAttribute("data-success")).isEqualTo("false"); - Assertions.assertThat(page.locator("#ajaxResult").getAttribute("data-code")).isEqualTo("VALIDATION_FAILED"); - } -} -``` - -Notes on the code above: -- `GreenMailExtension` is declared *before* `CMSServerExtension` as a field, and JUnit 5 runs static `@RegisterExtension` fields' `beforeAll` callbacks in declaration order for top-level static extensions registered this way — GreenMail's SMTP listener is bound first, so `config/mail.yaml`'s `port: 3025` is already accepting connections before `Startup.run()` (triggered by `CMSServerExtension.beforeAll`) constructs `DefaultMailService`. -- `greenMail.setUser("forms-test", "forms-test-password")` only needs to be called once before the mail-sending test; GreenMail's SMTP server does not require authentication to accept a message by default, but this matches the credentials in `mail.yaml` for clarity and future-proofing if the mailer library enforces auth. -- `messages[0].getSubject()` and `GreenMailUtil.getBody(messages[0])` use `jakarta.mail.internet.MimeMessage` (returned by `getReceivedMessagesForDomain`) and the `com.icegreen.greenmail.util.GreenMailUtil.getBody(Part)` helper — both already on the test classpath via `greenmail-junit5`. -- The honeypot field is targeted via `page.fill("input[name=website]", ...)` instead of an `id` selector since the hidden honeypot input in the templates (Task 3) has no `id` attribute, matching the existing `demo/` convention of using `name="website"` for this field. - -- [ ] **Step 2: Run the E2E suite** - -Run: `cd module && mvn -q verify` -Expected: exit code 0. All Failsafe-run tests in `E2EIT` pass: -- `server_is_started` -- `start_page` -- `successful_submission_sends_mail_and_redirects` -- `missing_required_field_redirects_to_error_and_sends_no_mail` -- `filled_honeypot_redirects_to_error_and_sends_no_mail` -- `ajax_form_returns_success_json` -- `ajax_form_returns_validation_error_json` - -If any test fails, check `module/test-server-logs-equivalent` — actually check `test-server/logs/` (the running CMS server's own logs) for stack traces, since `CMSServerExtension` runs the server in-process but its own logging still writes there. - -- [ ] **Step 3: Run the full build one more time from a clean state to confirm reproducibility** - -Run: `cd module && mvn -q clean verify` -Expected: exit code 0 (clean removes `target/`, so this re-validates that `package` → `pre-integration-test` copy → `integration-test` all run in the correct order from scratch). - -- [ ] **Step 4: Commit** - -```bash -cd /Users/thorstenmarx/entwicklung/workspaces/tma/cms/modules/forms-module -git add module/src/test/java/com/condation/cms/modules/forms/e2e/E2EIT.java -git commit -m "Add E2E tests for forms-module covering success, validation, spam, and ajax paths" -``` - ---- - -### Task 5: Finalize `.gitignore` and verify overall repo cleanliness - -**Files:** -- Modify: `.gitignore` (module root, i.e. `/Users/thorstenmarx/entwicklung/workspaces/tma/cms/modules/forms-module/.gitignore`) - -**Interfaces:** none (repo hygiene only). - -- [ ] **Step 1: Update `.gitignore`** - -Current content: - -``` -target/ -demo/lib -demo/logs -demo/modules -demo/hosts/demo/modules_data -demo/cms.pid -demo/*.jar -demo/LICENSE -demo/log4j2.xml -demo/README.md -demo/server.yaml -.vscode/settings.json -``` - -Append these new lines at the end: - -``` -test-server/modules/ -test-server/logs/ -test-server/cms.pid -test-server/hosts/demo/modules_data/ -test-server/hosts/demo/temp/ -test-server/hosts/demo/data/ -``` - -- [ ] **Step 2: Verify no unwanted build/runtime artifacts remain tracked** - -Run: `git status --short` -Expected: only the intentional source/config files from Tasks 1-4 show as staged/committed; `test-server/modules/`, `test-server/logs/`, and any `*.log`/`cms.pid`/`modules_data`/`temp`/`data` paths under `test-server/hosts/demo/` do not appear as untracked (they're now ignored) or, if they were already tracked from a prior accidental commit, remove them: - -```bash -git rm -r --cached test-server/logs test-server/hosts/demo/modules_data 2>/dev/null || true -``` - -(This is a no-op if those paths were never tracked — safe to run unconditionally.) - -- [ ] **Step 3: Commit** - -```bash -cd /Users/thorstenmarx/entwicklung/workspaces/tma/cms/modules/forms-module -git add .gitignore -git status --short -git commit -m "Ignore test-server build and runtime artifacts" -``` - ---- - -## Final Verification - -- [ ] Run `cd module && mvn -q clean verify` one final time end-to-end. -- [ ] Confirm `mvn -q clean test` (Surefire only, no `verify`) still passes quickly without needing the module deployed to `test-server/` — this proves unit tests (`FormConfigTest`, `FormsHandlingTest`, `CaptchaTest`) remain fast and independent of the E2E machinery. -- [ ] Confirm `demo/` has zero diffs: `git status --short demo/` shows nothing. diff --git a/docs/superpowers/specs/2026-07-27-e2e-tests-and-optional-captcha-design.md b/docs/superpowers/specs/2026-07-27-e2e-tests-and-optional-captcha-design.md deleted file mode 100644 index 7f8adb3..0000000 --- a/docs/superpowers/specs/2026-07-27-e2e-tests-and-optional-captcha-design.md +++ /dev/null @@ -1,204 +0,0 @@ -# E2E-Tests für forms-module + optionales Captcha - -## Kontext - -Die Klasse `E2ETest.java` und der `test-server/`-Ordner wurden 1:1 aus dem -`video-module` kopiert (siehe `module/src/test/java/.../e2e/E2ETest.java`, -`test-server/`) und referenzieren noch video-module-Inhalte (Titel -"video-module test page", `/module/video-module/...`). Sie müssen an -forms-module angepasst werden. - -Der alte `demo/`-Ordner (Beispielprojekt) nutzt Thymeleaf-Templatesyntax -(`th:replace`, `th:utext`, `th:with`), die von der aktuellen Template-Engine -nicht mehr unterstützt wird. `demo/` bleibt in diesem Vorhaben **unangetastet** -— eine Migration auf die neue Syntax ist ein separates, späteres Thema. - -Das Modul erzwingt aktuell in jedem Formular ein Captcha -(`FormsHandling.validateCaptcha(...)` wird unconditional aufgerufen). Für -automatisiertes E2E-Testing soll das Captcha pro Formular abschaltbar sein. - -Ein Vorab-Testlauf des video-module-Vorbilds -(`video-module/target/surefire-reports/....E2ETest.txt`) zeigt, dass dessen -Setup selbst kaputt ist: der Server meldet `Loaded 0 extension libraries`, -weil das gebaute Modul-JAR nie nach `test-server/modules//libs/` deployt -wird. Dieses Problem wird für forms-module mitbehoben. - -## Ziele - -1. Captcha ist pro Formular deaktivierbar (Config-Flag), Default bleibt "an" - (kein Breaking Change für bestehende Configs). -2. Das forms-module-JAR wird beim Build automatisch nach - `test-server/modules/forms-module/` deployt, sodass der CMS-Server im Test - das Modul tatsächlich lädt. -3. `test-server/` enthält eine funktionierende, auf forms-module zugeschnittene - Site-Konfiguration mit zwei Formularen (normal + AJAX), jeweils ohne - Captcha-Pflicht, plus Mail-Konfiguration für einen lokalen Test-SMTP-Server. -4. Ein Satz Playwright-basierter E2E-Tests deckt den Kernablauf (Erfolg, - Validierungsfehler, Spam/Honeypot, AJAX-Erfolg, AJAX-Fehler) ab und prüft - bei erfolgreicher Einreichung auch den tatsächlichen Mailversand via - GreenMail. -5. `mvn verify` baut, deployt und führt die E2E-Tests aus, ohne manuelle - Zwischenschritte. Normale Unit-Tests laufen unverändert in der - `test`-Phase. - -## Nicht-Ziele - -- Migration von `demo/` auf die neue Template-Syntax. -- Änderungen an Rate-Limiting, CSRF oder sonstigen bestehenden - Sicherheits-Mechanismen. -- Neue Formular-Feature (z.B. neue Feldtypen). - -## Design - -### 1. Captcha optional (Config + Handling) - -`FormsConfig.Form` erhält ein neues verschachteltes Feld: - -```java -private Captcha captcha = new Captcha(); - -@Data -public static class Captcha { - private boolean enabled = true; -} -``` - -Default `true` → bestehende YAML-Configs ohne `captcha:`-Block verhalten sich -exakt wie bisher. - -`FormsHandling.handleForm(...)` ruft `validateCaptcha(form, key, code)` nur -noch auf, wenn `form.getCaptcha().isEnabled()` true ist. Ist Captcha -deaktiviert, werden `key`/`code` nicht ausgewertet — die Submission braucht -diese Parameter nicht, und `GenerateCaptchaHandler` muss vom Formular-Template -nicht aufgerufen werden. - -`FormConfigTest` bzw. ein neuer Test deckt ab: Default `captcha.enabled=true`, -explizit `false` überschreibbar, `FormsHandlingTest` bekommt einen Fall für -ein Formular mit deaktiviertem Captcha (kein `key`/`code` nötig, keine -`INVALID_CAPTCHA`-Exception). - -### 2. Build/Deploy-Pipeline für den Modultest - -**Problem:** Das Modul-JAR (inkl. `libs/`-Runtime-Deps) entsteht erst in der -Maven-Phase `package`. E2E-Tests, die einen echten CMS-Server mit geladenem -Modul brauchen, müssen also *nach* `package` laufen — normale Unit-Tests -(Surefire) laufen aber in der früheren Phase `test`. - -**Lösung:** - -- `module/src/main/assembly/assembly.xml`: zusätzlich zum bisherigen - `zip`-Format ein `dir`-Format ergänzen. Maven erzeugt dadurch beim - `package`-Ziel automatisch einen Verzeichnisbaum - `target/forms-module-bin/` mit dem korrekten Modul-Layout - (`module.properties` im Root, `libs/*.jar` inkl. Runtime-Dependencies). -- `module/pom.xml`: neue Execution des `maven-resources-plugin` - (`copy-resources`) in Phase `pre-integration-test`, die - `target/forms-module-bin/**` nach `test-server/modules/forms-module/` - kopiert (überschreibend, damit Re-Builds den Stand aktuell halten). -- `maven-failsafe-plugin` wird ergänzt (Standard-Includes - `**/*IT.java`, gebunden an `integration-test`/`verify`). -- `E2ETest.java` wird zu `E2EIT.java` umbenannt (gleiches Package - `com.condation.cms.modules.forms.e2e`), damit Failsafe statt Surefire - greift und der Test erst nach dem Kopierschritt läuft. -- `.gitignore` (im Modul-Root) wird um Build-/Laufzeit-Artefakte ergänzt, die - aktuell fehlen: `test-server/modules/`, `test-server/logs/`, - `test-server/cms.pid`, `test-server/hosts/demo/modules_data/`, - `test-server/hosts/demo/temp/`, `test-server/hosts/demo/data/`. - -Ergebnis: `mvn verify` (oder `mvn install`) baut das Modul, kopiert es -automatisch ins Test-Server-Layout und führt anschließend die E2E-Tests -gegen einen echten, das Modul ladenden CMS-Server aus. `mvn test` bleibt -schnell und deckt nur die bestehenden Unit-Tests ab. - -### 3. test-server-Inhalte - -- `hosts/demo/site.toml`: `[modules] active = ["forms-module"]` aktivieren - (statt auskommentiertem `videos-module`-Platzhalter). -- `hosts/demo/config/forms.yaml` (neu): zwei Formulare — - - `contact`: Felder `from` (email, required), `message` (required, - minLength); `captcha.enabled: false`; Honeypot aktiviert - (`spam.honeypot.enabled: true`, Feld `website`); `mail.account: default`; - `redirects.success: /forms/contact/success`; `rateLimit.enabled: false` - (damit die Testreihe nicht ins Rate-Limit läuft). - - `ajax-contact`: gleiche Feldstruktur, ebenfalls `captcha.enabled: false`, - `rateLimit.enabled: false`, kein `to`/Mailversand nötig (AJAX-Pfad testet - nur JSON-Antwort, nicht Mail). - - Globale `redirects.error: /forms/error`. -- `hosts/demo/config/mail.yaml` (neu): `accounts.default` mit `host: - localhost`, `port: 3025`, `fromMail`, `username`/`password` passend zur - GreenMail-Testkonfiguration im E2E-Test. -- Templates (Pebble-Syntax, siehe reales Vorbild - `demo/condation-server/themes/demo/templates/contact.html` im - Gesamtworkspace): - - `hosts/demo/templates/contact.html`: normales `
`, - `method="post"`, `action="/module/forms-module/form/submit"`, Felder - `from`/`message`, Honeypot-Feld `website` (versteckt), **kein** - Captcha-Markup. - - `hosts/demo/templates/ajax.html`: analoges Formular, `action=".../form/submit/ajax"`, - per `fetch()` abgeschickt (Skript analog zu `demo/hosts/demo/assets/form-1.js`, - ohne die Captcha-Reload-Logik), erwartet JSON-Antwort - `{success, code, fieldErrors}`. -- Content: - - `hosts/demo/content/contact.md` (`template: contact.html`) - - `hosts/demo/content/ajax.md` (`template: ajax.html`) - - `hosts/demo/content/forms/contact/success.md` - - `hosts/demo/content/forms/error.md` - - bestehendes `content/index.md` bleibt (Startseite), Titel wird auf einen - forms-module-spezifischen Text angepasst (`node.meta.title` wird im - Basistest geprüft). -- Aufräumen: Video-spezifische Leftovers (`assets/thumbnails/mountains.jpg`, - `config/media.toml`), sofern sie von den neuen Templates nicht referenziert - werden. - -### 4. E2E-Testfälle (`E2EIT.java`) - -`GreenMailExtension` mit fixem Port 3025 -(`new ServerSetup(3025, null, ServerSetup.PROTOCOL_SMTP)`) wird als -`@RegisterExtension`-Feld **vor** `CMSServerExtension` deklariert, damit der -SMTP-Server steht, bevor der CMS-Prozess (der `config/mail.yaml` beim ersten -Mailversand liest) benötigt wird. Da `CMSServerExtension` den Server nur als -Thread in derselben JVM startet (kein separater OS-Prozess), teilen sich -GreenMail und der CMS-Server denselben Prozessraum unproblematisch. - -Testfälle: - -1. **Server startet korrekt** (angepasste Version des bestehenden Tests). -2. **Startseite** zeigt den erwarteten, forms-module-spezifischen Titel. -3. **Erfolgreiche Einreichung (`contact`)**: Playwright füllt `from` und - `message` aus, submittet, erwartet Redirect auf - `/forms/contact/success`. Zusätzlich: `greenMail.getReceivedMessagesForDomain(...)` - liefert genau eine Mail mit erwartetem Empfänger/Betreff/Inhalt. -4. **Validierungsfehler**: `message` bleibt leer → Redirect auf - `/forms/error`, keine Mail bei GreenMail eingegangen. -5. **Honeypot/Spam**: verstecktes Feld `website` wird befüllt → Redirect auf - `/forms/error`, keine Mail. -6. **AJAX-Erfolg**: Formular `ajax-contact` wird per `fetch` submittet, - JSON-Antwort `{success: true}`. -7. **AJAX-Validierungsfehler**: ungültige E-Mail-Adresse im Feld `from` → - JSON-Antwort `{success: false, code: "VALIDATION_FAILED", fieldErrors: - {...}}`. - -Kein E2E-Test prüft den Mailversand für den AJAX-Pfad gesondert — der -Mailversand-Mechanismus ist derselbe wie beim normalen Pfad und wird dort -abgedeckt (YAGNI: keine Doppelabdeckung). - -## Betroffene Dateien (Übersicht) - -- `module/src/main/java/.../FormsConfig.java` — neues `Captcha`-Feld. -- `module/src/main/java/.../handler/FormsHandling.java` — Captcha-Check - conditional machen. -- `module/src/test/java/.../FormConfigTest.java`, - `FormsHandlingTest.java` — Tests für den neuen Schalter. -- `module/src/test/java/.../e2e/E2ETest.java` → `E2EIT.java` (umbenannt, - inhaltlich neu). -- `module/src/main/assembly/assembly.xml` — `dir`-Format ergänzen. -- `module/pom.xml` — `maven-resources-plugin`-Copy-Step, - `maven-failsafe-plugin`. -- `test-server/hosts/demo/site.toml`, - `test-server/hosts/demo/config/forms.yaml` (neu), - `test-server/hosts/demo/config/mail.yaml` (neu), - `test-server/hosts/demo/templates/contact.html`, - `test-server/hosts/demo/templates/ajax.html`, - `test-server/hosts/demo/content/*.md`. -- `.gitignore` (Modul-Root) — Build-/Laufzeitartefakte ergänzen. -- `demo/` — unverändert. diff --git a/module/pom.xml b/module/pom.xml index 2e6afa2..706c6c2 100644 --- a/module/pom.xml +++ b/module/pom.xml @@ -105,7 +105,43 @@ - + + + org.apache.maven.plugins + maven-resources-plugin + 3.3.1 + + + deploy-module-to-test-server + pre-integration-test + + copy-resources + + + ${project.basedir}/../test-server/modules + true + + + ${project.build.directory}/${module.id}-bin + + + + + + + + org.apache.maven.plugins + maven-failsafe-plugin + 3.5.4 + + + + integration-test + verify + + + + diff --git a/module/src/main/assembly/assembly.xml b/module/src/main/assembly/assembly.xml index 5bf5a85..998989a 100644 --- a/module/src/main/assembly/assembly.xml +++ b/module/src/main/assembly/assembly.xml @@ -4,6 +4,7 @@ bin zip + dir diff --git a/module/src/test/java/com/condation/cms/modules/forms/e2e/E2ETest.java b/module/src/test/java/com/condation/cms/modules/forms/e2e/E2EIT.java similarity index 73% rename from module/src/test/java/com/condation/cms/modules/forms/e2e/E2ETest.java rename to module/src/test/java/com/condation/cms/modules/forms/e2e/E2EIT.java index d405740..d9c26f2 100644 --- a/module/src/test/java/com/condation/cms/modules/forms/e2e/E2ETest.java +++ b/module/src/test/java/com/condation/cms/modules/forms/e2e/E2EIT.java @@ -36,10 +36,10 @@ * @author thorstenmarx */ @UsePlaywright -public class E2ETest { +public class E2EIT { @RegisterExtension - static CMSServerExtension serverExtensions = new CMSServerExtension("test-server"); + static CMSServerExtension serverExtensions = new CMSServerExtension("../test-server"); @Test void server_is_started() throws Exception { @@ -49,18 +49,6 @@ void server_is_started() throws Exception { @Test void start_page(Page page) { page.navigate("http://localhost:2020"); - Assertions.assertThat(page.locator("title").innerText()).isEqualTo("video-module test page"); - } - - @Test - void contains_header(Page page) { - page.navigate("http://localhost:2020"); - /** - * - - */ - Assertions.assertThat(page.locator("head").innerHTML()) - .contains("") - .contains(""); + Assertions.assertThat(page.locator("title").innerText()).isEqualTo("forms test site"); } } diff --git a/test-server/hosts/demo/content/index.md b/test-server/hosts/demo/content/index.md index 3f33552..771a1ee 100644 --- a/test-server/hosts/demo/content/index.md +++ b/test-server/hosts/demo/content/index.md @@ -1,11 +1,7 @@ --- -title: video-module test page +title: forms test site template: start.html -search: - index: false published: true +status: published --- -# Vimeo Shortcode - -[[video type="vimeo" id="170338499" title="Everybody loves little cats" /]] \ No newline at end of file From e77f648326ed082dc2682ed1511c4a9d64522697 Mon Sep 17 00:00:00 2001 From: Thorsten Marx Date: Mon, 27 Jul 2026 20:31:46 +0200 Subject: [PATCH 8/9] update tests --- module/pom.xml | 3 + .../cms/modules/forms/e2e/E2EIT.java | 136 ++++++++++++++++-- test-server/hosts/demo/config/forms.yaml | 57 ++++++++ test-server/hosts/demo/config/mail.yaml | 7 + test-server/hosts/demo/content/forms/error.md | 9 ++ .../hosts/demo/content/forms/mail-error.md | 9 ++ .../hosts/demo/content/forms/mail-success.md | 9 ++ test-server/hosts/demo/content/forms/mail.md | 8 ++ .../demo/content/forms/security-error.md | 9 ++ .../demo/content/forms/security-success.md | 9 ++ .../hosts/demo/content/forms/security.md | 8 ++ .../hosts/demo/content/forms/success.md | 9 ++ .../demo/content/forms/validation-error.md | 9 ++ .../demo/content/forms/validation-success.md | 9 ++ .../hosts/demo/content/forms/validation.md | 8 ++ .../demo/public/.well-known/security.txt | 1 - test-server/hosts/demo/public/favicon.ico | Bin 1406 -> 0 bytes test-server/hosts/demo/public/robots.txt | 3 - test-server/hosts/demo/site-dev.toml | 7 - test-server/hosts/demo/site.toml | 2 +- .../hosts/demo/templates/mail-form.html | 23 +++ test-server/hosts/demo/templates/result.html | 13 ++ .../hosts/demo/templates/security-form.html | 25 ++++ .../hosts/demo/templates/validation-form.html | 23 +++ 24 files changed, 376 insertions(+), 20 deletions(-) create mode 100644 test-server/hosts/demo/config/forms.yaml create mode 100644 test-server/hosts/demo/config/mail.yaml create mode 100644 test-server/hosts/demo/content/forms/error.md create mode 100644 test-server/hosts/demo/content/forms/mail-error.md create mode 100644 test-server/hosts/demo/content/forms/mail-success.md create mode 100644 test-server/hosts/demo/content/forms/mail.md create mode 100644 test-server/hosts/demo/content/forms/security-error.md create mode 100644 test-server/hosts/demo/content/forms/security-success.md create mode 100644 test-server/hosts/demo/content/forms/security.md create mode 100644 test-server/hosts/demo/content/forms/success.md create mode 100644 test-server/hosts/demo/content/forms/validation-error.md create mode 100644 test-server/hosts/demo/content/forms/validation-success.md create mode 100644 test-server/hosts/demo/content/forms/validation.md delete mode 100644 test-server/hosts/demo/public/.well-known/security.txt delete mode 100644 test-server/hosts/demo/public/favicon.ico delete mode 100644 test-server/hosts/demo/public/robots.txt delete mode 100644 test-server/hosts/demo/site-dev.toml create mode 100644 test-server/hosts/demo/templates/mail-form.html create mode 100644 test-server/hosts/demo/templates/result.html create mode 100644 test-server/hosts/demo/templates/security-form.html create mode 100644 test-server/hosts/demo/templates/validation-form.html diff --git a/module/pom.xml b/module/pom.xml index 706c6c2..afe221e 100644 --- a/module/pom.xml +++ b/module/pom.xml @@ -133,6 +133,9 @@ org.apache.maven.plugins maven-failsafe-plugin 3.5.4 + + --enable-preview + diff --git a/module/src/test/java/com/condation/cms/modules/forms/e2e/E2EIT.java b/module/src/test/java/com/condation/cms/modules/forms/e2e/E2EIT.java index d9c26f2..4af09b3 100644 --- a/module/src/test/java/com/condation/cms/modules/forms/e2e/E2EIT.java +++ b/module/src/test/java/com/condation/cms/modules/forms/e2e/E2EIT.java @@ -24,9 +24,19 @@ import com.condation.cms.cli.tools.CLIServerUtils; import com.condation.cms.test.e2e.CMSServerExtension; +import com.icegreen.greenmail.configuration.GreenMailConfiguration; +import com.icegreen.greenmail.junit5.GreenMailExtension; +import com.icegreen.greenmail.util.ServerSetup; import com.microsoft.playwright.Page; import com.microsoft.playwright.junit.UsePlaywright; +import jakarta.mail.internet.MimeMessage; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Order; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; @@ -38,17 +48,127 @@ @UsePlaywright public class E2EIT { + private static final String SMTP_HOST = "127.0.0.1"; + private static final int SMTP_PORT = 3025; + private static final String SMTP_USERNAME = "test@example.test"; + private static final String SMTP_PASSWORD = "password"; + private static final String BASE_URL = "http://localhost:2020"; + private static final HttpClient HTTP_CLIENT = HttpClient.newHttpClient(); + + @RegisterExtension + @Order(1) + static final GreenMailExtension GREEN_MAIL = new GreenMailExtension( + new ServerSetup(SMTP_PORT, SMTP_HOST, ServerSetup.PROTOCOL_SMTP)) + .withConfiguration(GreenMailConfiguration.aConfig() + .withUser(SMTP_USERNAME, SMTP_USERNAME, SMTP_PASSWORD)) + .withPerMethodLifecycle(false); + @RegisterExtension - static CMSServerExtension serverExtensions = new CMSServerExtension("../test-server"); + @Order(2) + static final CMSServerExtension SERVER = new CMSServerExtension("../test-server"); + + @BeforeEach + void resetMailServer() throws Exception { + GREEN_MAIL.purgeEmailFromAllMailboxes(); + } @Test - void server_is_started() throws Exception { - Assertions.assertThat(CLIServerUtils.getCMSProcess()).isPresent(); - } + void server_is_started() throws Exception { + Assertions.assertThat(CLIServerUtils.getCMSProcess()).isPresent(); + } @Test - void start_page(Page page) { - page.navigate("http://localhost:2020"); - Assertions.assertThat(page.locator("title").innerText()).isEqualTo("forms test site"); - } + void start_page(Page page) { + page.navigate("http://localhost:2020"); + Assertions.assertThat(page.title()).isEqualTo("forms test site"); + } + + @Test + void mail_form_is_rendered(Page page) { + page.navigate("http://localhost:2020/forms/mail"); + + Assertions.assertThat(page.title()).isEqualTo("Mail form test"); + Assertions.assertThat(page.locator("#mail-form").count()).isEqualTo(1); + Assertions.assertThat(page.locator("input[name=form]").inputValue()).isEqualTo("mail"); + } + + @Test + void valid_form_sends_mail(Page page) throws Exception { + page.navigate("http://localhost:2020/forms/mail"); + page.locator("#mail-email").fill("visitor@example.test"); + page.locator("#mail-message").fill("This message was submitted by the E2E test."); + page.locator("#mail-submit").click(); + + page.waitForURL("**/forms/mail-success"); + Assertions.assertThat(page.locator("#result").innerText()).isEqualTo("mail-success"); + Assertions.assertThat(GREEN_MAIL.waitForIncomingEmail(5_000, 1)).isTrue(); + + MimeMessage message = GREEN_MAIL.getReceivedMessages()[0]; + Assertions.assertThat(message.getSubject()).isEqualTo("Forms E2E mail"); + Assertions.assertThat(message.getAllRecipients()) + .extracting(Object::toString) + .containsExactly("recipient@example.test"); + Assertions.assertThat(message.getFrom()) + .extracting(Object::toString) + .containsExactly("Forms E2E test "); + Assertions.assertThat(message.getContent().toString()) + .contains("email:", "visitor@example.test") + .contains("message:", "This message was submitted by the E2E test."); + } + + @Test + void invalid_form_redirects_to_its_error_page(Page page) { + page.navigate("http://localhost:2020/forms/validation"); + page.locator("#validation-email").fill("not-an-email"); + page.locator("#validation-message").fill("short"); + page.locator("#validation-submit").click(); + + page.waitForURL("**/forms/validation-error"); + Assertions.assertThat(page.locator("#result").innerText()).isEqualTo("validation-error"); + Assertions.assertThat(GREEN_MAIL.getReceivedMessages()).isEmpty(); + } + + @Test + void security_features_reject_cross_site_spam_and_excess_requests(Page page) throws Exception { + page.navigate(BASE_URL + "/forms/security"); + Assertions.assertThat(page.title()).isEqualTo("Security form test"); + Assertions.assertThat(page.locator("#security-form").count()).isEqualTo(1); + Assertions.assertThat(page.locator("input[name=website]").count()).isEqualTo(1); + + var crossSite = submitSecurityForm( + "https://attacker.example", + "form=security&message=Cross-site+submission"); + Assertions.assertThat(crossSite.statusCode()).isEqualTo(403); + Assertions.assertThat(crossSite.body()).contains("\"code\":\"CSRF_REJECTED\""); + + var honeypot = submitSecurityForm( + BASE_URL, + "form=security&message=Automated+submission&website=https%3A%2F%2Fspam.example"); + Assertions.assertThat(honeypot.statusCode()).isEqualTo(400); + Assertions.assertThat(honeypot.body()).contains("\"code\":\"SPAM_REJECTED\""); + + var valid = submitSecurityForm( + BASE_URL, + "form=security&message=Allowed+submission"); + Assertions.assertThat(valid.statusCode()).isEqualTo(200); + Assertions.assertThat(valid.body()).contains("\"success\":true"); + + var rateLimited = submitSecurityForm( + BASE_URL, + "form=security&message=One+request+too+many"); + Assertions.assertThat(rateLimited.statusCode()).isEqualTo(429); + Assertions.assertThat(rateLimited.body()).contains("\"code\":\"RATE_LIMITED\""); + } + + private HttpResponse submitSecurityForm( + final String origin, + final String formBody) throws Exception { + var request = HttpRequest.newBuilder() + .uri(URI.create(BASE_URL + "/module/forms-module/form/submit/ajax")) + .header("Content-Type", "application/x-www-form-urlencoded") + .header("Origin", origin) + .POST(HttpRequest.BodyPublishers.ofString(formBody)) + .build(); + return HTTP_CLIENT.send(request, HttpResponse.BodyHandlers.ofString()); + } } diff --git a/test-server/hosts/demo/config/forms.yaml b/test-server/hosts/demo/config/forms.yaml new file mode 100644 index 0000000..9a5b241 --- /dev/null +++ b/test-server/hosts/demo/config/forms.yaml @@ -0,0 +1,57 @@ +forms: + - name: mail + to: recipient@example.test + subject: Forms E2E mail + fields: + email: + type: email + required: true + message: + required: true + minLength: 5 + maxLength: 500 + mail: + account: default + from: Forms E2E test + captcha: + enabled: false + redirects: + success: /forms/mail-success + error: /forms/mail-error + + - name: validation + fields: + email: + type: email + required: true + message: + required: true + minLength: 10 + captcha: + enabled: false + redirects: + success: /forms/validation-success + error: /forms/validation-error + + - name: security + fields: + message: + required: true + minLength: 5 + captcha: + enabled: false + spam: + honeypot: + enabled: true + field: website + rateLimit: + enabled: true + requests: 2 + periodSeconds: 60 + redirects: + success: /forms/security-success + error: /forms/security-error + +redirects: + success: /forms/success + error: /forms/error diff --git a/test-server/hosts/demo/config/mail.yaml b/test-server/hosts/demo/config/mail.yaml new file mode 100644 index 0000000..03f3db4 --- /dev/null +++ b/test-server/hosts/demo/config/mail.yaml @@ -0,0 +1,7 @@ +accounts: + default: + fromMail: "test@example.test" + host: "127.0.0.1" + port: "3025" + username: "test@example.test" + password: "password" diff --git a/test-server/hosts/demo/content/forms/error.md b/test-server/hosts/demo/content/forms/error.md new file mode 100644 index 0000000..102f62a --- /dev/null +++ b/test-server/hosts/demo/content/forms/error.md @@ -0,0 +1,9 @@ +--- +title: Form failed +result: error +template: result.html +published: true +status: published +--- + +The form could not be processed. diff --git a/test-server/hosts/demo/content/forms/mail-error.md b/test-server/hosts/demo/content/forms/mail-error.md new file mode 100644 index 0000000..e05d288 --- /dev/null +++ b/test-server/hosts/demo/content/forms/mail-error.md @@ -0,0 +1,9 @@ +--- +title: Mail failed +result: mail-error +template: result.html +published: true +status: published +--- + +The mail form could not be processed. diff --git a/test-server/hosts/demo/content/forms/mail-success.md b/test-server/hosts/demo/content/forms/mail-success.md new file mode 100644 index 0000000..2104d92 --- /dev/null +++ b/test-server/hosts/demo/content/forms/mail-success.md @@ -0,0 +1,9 @@ +--- +title: Mail sent +result: mail-success +template: result.html +published: true +status: published +--- + +The mail form was accepted. diff --git a/test-server/hosts/demo/content/forms/mail.md b/test-server/hosts/demo/content/forms/mail.md new file mode 100644 index 0000000..df2a2bf --- /dev/null +++ b/test-server/hosts/demo/content/forms/mail.md @@ -0,0 +1,8 @@ +--- +title: Mail form test +template: mail-form.html +published: true +status: published +--- + +Mail submission test. diff --git a/test-server/hosts/demo/content/forms/security-error.md b/test-server/hosts/demo/content/forms/security-error.md new file mode 100644 index 0000000..39e3205 --- /dev/null +++ b/test-server/hosts/demo/content/forms/security-error.md @@ -0,0 +1,9 @@ +--- +title: Security form rejected +result: security-error +template: result.html +published: true +status: published +--- + +The security form was rejected. diff --git a/test-server/hosts/demo/content/forms/security-success.md b/test-server/hosts/demo/content/forms/security-success.md new file mode 100644 index 0000000..c124520 --- /dev/null +++ b/test-server/hosts/demo/content/forms/security-success.md @@ -0,0 +1,9 @@ +--- +title: Security form successful +result: security-success +template: result.html +published: true +status: published +--- + +The security form was accepted. diff --git a/test-server/hosts/demo/content/forms/security.md b/test-server/hosts/demo/content/forms/security.md new file mode 100644 index 0000000..5f2a7de --- /dev/null +++ b/test-server/hosts/demo/content/forms/security.md @@ -0,0 +1,8 @@ +--- +title: Security form test +template: security-form.html +published: true +status: published +--- + +Security feature test. diff --git a/test-server/hosts/demo/content/forms/success.md b/test-server/hosts/demo/content/forms/success.md new file mode 100644 index 0000000..c1d60bb --- /dev/null +++ b/test-server/hosts/demo/content/forms/success.md @@ -0,0 +1,9 @@ +--- +title: Form successful +result: success +template: result.html +published: true +status: published +--- + +The form was accepted. diff --git a/test-server/hosts/demo/content/forms/validation-error.md b/test-server/hosts/demo/content/forms/validation-error.md new file mode 100644 index 0000000..8bf583c --- /dev/null +++ b/test-server/hosts/demo/content/forms/validation-error.md @@ -0,0 +1,9 @@ +--- +title: Validation failed +result: validation-error +template: result.html +published: true +status: published +--- + +The submitted values were invalid. diff --git a/test-server/hosts/demo/content/forms/validation-success.md b/test-server/hosts/demo/content/forms/validation-success.md new file mode 100644 index 0000000..1396453 --- /dev/null +++ b/test-server/hosts/demo/content/forms/validation-success.md @@ -0,0 +1,9 @@ +--- +title: Validation successful +result: validation-success +template: result.html +published: true +status: published +--- + +The submitted values were valid. diff --git a/test-server/hosts/demo/content/forms/validation.md b/test-server/hosts/demo/content/forms/validation.md new file mode 100644 index 0000000..083800b --- /dev/null +++ b/test-server/hosts/demo/content/forms/validation.md @@ -0,0 +1,8 @@ +--- +title: Validation form test +template: validation-form.html +published: true +status: published +--- + +Validation test. diff --git a/test-server/hosts/demo/public/.well-known/security.txt b/test-server/hosts/demo/public/.well-known/security.txt deleted file mode 100644 index 27bdbf6..0000000 --- a/test-server/hosts/demo/public/.well-known/security.txt +++ /dev/null @@ -1 +0,0 @@ -CondationCMS is secure \ No newline at end of file diff --git a/test-server/hosts/demo/public/favicon.ico b/test-server/hosts/demo/public/favicon.ico deleted file mode 100644 index 6848441be374fc0a908926cea0be3bdfa815414d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1406 zcmZQzU<5(|0R}M0U}azs1F|%L7$l?s#Ec9aKoZP=&`9k6|NkSzMp>gFFd71*Aut*O HM27$XOB)0R diff --git a/test-server/hosts/demo/public/robots.txt b/test-server/hosts/demo/public/robots.txt deleted file mode 100644 index d107a5f..0000000 --- a/test-server/hosts/demo/public/robots.txt +++ /dev/null @@ -1,3 +0,0 @@ -User-agent: * -Disallow: /about/impressum -Allow: / diff --git a/test-server/hosts/demo/site-dev.toml b/test-server/hosts/demo/site-dev.toml deleted file mode 100644 index 7c75c2d..0000000 --- a/test-server/hosts/demo/site-dev.toml +++ /dev/null @@ -1,7 +0,0 @@ -# site configuration for dev environment - -hostname = [ "localhost5" ] # hostnames for this site, used for request matching - -[api] -enabled = true -whitelist = ["meta.*", "title"] \ No newline at end of file diff --git a/test-server/hosts/demo/site.toml b/test-server/hosts/demo/site.toml index ac1fdfd..b4280fe 100644 --- a/test-server/hosts/demo/site.toml +++ b/test-server/hosts/demo/site.toml @@ -6,4 +6,4 @@ context_path = "/" # modules to load for this site [modules] -#active = ["videos-module"] # list of active modules for this sites \ No newline at end of file +active = ["forms-module"] # list of active modules for this sites \ No newline at end of file diff --git a/test-server/hosts/demo/templates/mail-form.html b/test-server/hosts/demo/templates/mail-form.html new file mode 100644 index 0000000..d9d3ecd --- /dev/null +++ b/test-server/hosts/demo/templates/mail-form.html @@ -0,0 +1,23 @@ + + + + + {{ node.meta.title }} + + +
+

Mail form

+ + + + + + + + + + + +
+ + diff --git a/test-server/hosts/demo/templates/result.html b/test-server/hosts/demo/templates/result.html new file mode 100644 index 0000000..58e7006 --- /dev/null +++ b/test-server/hosts/demo/templates/result.html @@ -0,0 +1,13 @@ + + + + + {{ node.meta.title }} + + +
+

{{ node.meta.result }}

+ {{ node.content | raw }} +
+ + diff --git a/test-server/hosts/demo/templates/security-form.html b/test-server/hosts/demo/templates/security-form.html new file mode 100644 index 0000000..76f4c92 --- /dev/null +++ b/test-server/hosts/demo/templates/security-form.html @@ -0,0 +1,25 @@ + + + + + {{ node.meta.title }} + + +
+

Security form

+
+ + + + + + + + +
+
+ + diff --git a/test-server/hosts/demo/templates/validation-form.html b/test-server/hosts/demo/templates/validation-form.html new file mode 100644 index 0000000..4ab8a7a --- /dev/null +++ b/test-server/hosts/demo/templates/validation-form.html @@ -0,0 +1,23 @@ + + + + + {{ node.meta.title }} + + +
+

Validation form

+
+ + + + + + + + + +
+
+ + From 9b2ec855be196b3c2af3dc138f692a018691f407 Mon Sep 17 00:00:00 2001 From: Thorsten Marx Date: Mon, 27 Jul 2026 20:40:46 +0200 Subject: [PATCH 9/9] remove temp module after test run --- module/pom.xml | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/module/pom.xml b/module/pom.xml index afe221e..a879ed1 100644 --- a/module/pom.xml +++ b/module/pom.xml @@ -145,6 +145,28 @@
+ + org.apache.maven.plugins + maven-clean-plugin + 3.2.0 + + + remove-module-from-test-server + verify + + clean + + + true + + + ${project.basedir}/../test-server/modules/${module.id} + + + + + +