org.springframework.boot
spring-boot-testcontainers
diff --git a/src/main/java/com/itsthevine/web/AdminController.java b/src/main/java/com/itsthevine/web/AdminController.java
new file mode 100644
index 0000000..66a0535
--- /dev/null
+++ b/src/main/java/com/itsthevine/web/AdminController.java
@@ -0,0 +1,175 @@
+package com.itsthevine.web;
+
+import java.time.Instant;
+import java.util.List;
+import java.util.UUID;
+
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.http.HttpStatus;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.ResponseStatus;
+import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.server.ResponseStatusException;
+
+import com.itsthevine.web.domain.ContactEnquiry;
+import com.itsthevine.web.domain.ContactEnquiryRepository;
+import com.itsthevine.web.domain.Product;
+import com.itsthevine.web.domain.ProductRepository;
+
+import net.thebennett.platform.storage.StorageService;
+
+/**
+ * Everything behind the login: the catalogue, and the enquiries people have sent.
+ *
+ * The whole of {@code /api/admin/**} is gated by {@code platform.security.authenticated-paths}, so
+ * any signed-in Authentik user is an administrator here. That is deliberate for a two-person bakery —
+ * the alternative is a role model nobody would maintain.
+ */
+@RestController
+@RequestMapping("/api/admin")
+public class AdminController {
+
+ private final ProductRepository products;
+ private final ContactEnquiryRepository enquiries;
+ private final StorageService storage;
+ private final String bucket;
+ private final String publicBaseUrl;
+
+ public AdminController(ProductRepository products, ContactEnquiryRepository enquiries,
+ StorageService storage,
+ @Value("${vine.storage.bucket:itsthevine}") String bucket,
+ @Value("${site.assets.base-url:https://s3.thebennett.net/itsthevine}") String publicBaseUrl) {
+ this.products = products;
+ this.enquiries = enquiries;
+ this.storage = storage;
+ this.bucket = bucket;
+ this.publicBaseUrl = publicBaseUrl.replaceAll("/+$", "");
+ }
+
+ // ---- products ----
+
+ /** @param imageKeys bucket keys, in display order; the first is the one the card shows */
+ public record ProductForm(String name, String category, Integer position, List imageKeys) {}
+
+ public record AdminProduct(Long id, String name, String category, int position,
+ List imageKeys, List imageUrls) {}
+
+ @GetMapping("/products")
+ @Transactional(readOnly = true)
+ public List list() {
+ return products.findAllByOrderByPositionAsc().stream().map(this::toAdmin).toList();
+ }
+
+ @PostMapping("/products")
+ @ResponseStatus(HttpStatus.CREATED)
+ @Transactional
+ public AdminProduct create(@RequestBody ProductForm form) {
+ validate(form);
+ // Default to the end of the list so a new product does not silently displace an existing one.
+ int position = form.position() != null ? form.position() : nextPosition();
+ return toAdmin(products.save(new Product(form.name().trim(), form.category().trim(),
+ position, cleanKeys(form.imageKeys()))));
+ }
+
+ @PutMapping("/products/{id}")
+ @Transactional
+ public AdminProduct update(@PathVariable Long id, @RequestBody ProductForm form) {
+ validate(form);
+ Product p = products.findById(id)
+ .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "no such product"));
+ p.update(form.name().trim(), form.category().trim(),
+ form.position() != null ? form.position() : p.getPosition(),
+ cleanKeys(form.imageKeys()));
+ return toAdmin(p);
+ }
+
+ @DeleteMapping("/products/{id}")
+ @ResponseStatus(HttpStatus.NO_CONTENT)
+ @Transactional
+ public void delete(@PathVariable Long id) {
+ if (!products.existsById(id)) {
+ throw new ResponseStatusException(HttpStatus.NOT_FOUND, "no such product");
+ }
+ // The photos stay in the bucket: they are cheap, and an accidental delete is recoverable if
+ // the images survive it.
+ products.deleteById(id);
+ }
+
+ // ---- enquiries ----
+
+ /** @param delivered false means the relay refused it and nobody was notified */
+ public record AdminEnquiry(Long id, String name, String email, String message,
+ boolean delivered, Instant receivedAt) {}
+
+ @GetMapping("/enquiries")
+ @Transactional(readOnly = true)
+ public List enquiries() {
+ return enquiries.findAllByOrderByCreatedAtDesc().stream()
+ .map(e -> new AdminEnquiry(e.getId(), e.getName(), e.getEmail(), e.getMessage(),
+ e.isDelivered(), e.getCreatedAt()))
+ .toList();
+ }
+
+ // ---- photo upload ----
+
+ /**
+ * @param key what to store on the product
+ * @param uploadUrl short-lived; the browser PUTs the file straight to the bucket so the photo
+ * never passes through this app
+ * @param publicUrl where it will be readable from afterwards
+ */
+ public record UploadTarget(String key, String uploadUrl, String publicUrl) {}
+
+ @PostMapping("/images/presign-upload")
+ public UploadTarget presignUpload(@RequestParam String filename,
+ @RequestParam(defaultValue = "application/octet-stream") String contentType) {
+ // A UUID prefix rather than the bare filename: two people uploading "cake.jpg" must not
+ // overwrite each other, and the bucket is public so keys should not be guessable.
+ String safe = filename.toLowerCase().replaceAll("[^a-z0-9._-]", "-");
+ String key = "images/products/" + UUID.randomUUID() + "-" + safe;
+ return new UploadTarget(key.substring("images/".length()),
+ storage.presignPut(bucket, key, contentType).toString(),
+ publicBaseUrl + "/" + key);
+ }
+
+ // ---- helpers ----
+
+ private void validate(ProductForm form) {
+ if (form.name() == null || form.name().isBlank()) {
+ throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "a product needs a name");
+ }
+ if (form.category() == null || form.category().isBlank()) {
+ throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "a product needs a category");
+ }
+ if (form.imageKeys() == null || cleanKeys(form.imageKeys()).isEmpty()) {
+ throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "a product needs at least one photo");
+ }
+ }
+
+ private static List cleanKeys(List keys) {
+ return keys == null ? List.of()
+ : keys.stream().filter(k -> k != null && !k.isBlank()).map(String::trim).toList();
+ }
+
+ private int nextPosition() {
+ return products.findAllByOrderByPositionAsc().stream()
+ .mapToInt(Product::getPosition).max().orElse(0) + 1;
+ }
+
+ private AdminProduct toAdmin(Product p) {
+ return new AdminProduct(p.getId(), p.getName(), p.getCategory(), p.getPosition(),
+ p.getImageKeys(), p.getImageKeys().stream().map(this::publicUrl).toList());
+ }
+
+ private String publicUrl(String key) {
+ return publicBaseUrl + "/images/" + key.replaceAll("^/+", "");
+ }
+}
diff --git a/src/main/java/com/itsthevine/web/MeController.java b/src/main/java/com/itsthevine/web/MeController.java
new file mode 100644
index 0000000..e77cea0
--- /dev/null
+++ b/src/main/java/com/itsthevine/web/MeController.java
@@ -0,0 +1,35 @@
+package com.itsthevine.web;
+
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.security.oauth2.core.oidc.user.OidcUser;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * Who, if anyone, is signed in.
+ *
+ * Deliberately PUBLIC: the SPA asks on every page load, and if this required a login the site would
+ * bounce anonymous visitors — every one of them — to Authentik just to render the front page.
+ */
+@RestController
+public class MeController {
+
+ /** @param admin true for any signed-in user; there is one level of access here */
+ public record Me(boolean authenticated, boolean admin, String name) {}
+
+ @GetMapping("/api/me")
+ public Me me() {
+ Authentication auth = SecurityContextHolder.getContext().getAuthentication();
+ boolean signedIn = auth != null && auth.isAuthenticated()
+ && !"anonymousUser".equals(auth.getPrincipal());
+ if (!signedIn) {
+ return new Me(false, false, null);
+ }
+ String name = auth.getName();
+ if (auth.getPrincipal() instanceof OidcUser user) {
+ name = user.getPreferredUsername() != null ? user.getPreferredUsername() : user.getSubject();
+ }
+ return new Me(true, true, name);
+ }
+}
diff --git a/src/main/java/com/itsthevine/web/domain/ContactEnquiryRepository.java b/src/main/java/com/itsthevine/web/domain/ContactEnquiryRepository.java
index d8ce5c2..7306721 100644
--- a/src/main/java/com/itsthevine/web/domain/ContactEnquiryRepository.java
+++ b/src/main/java/com/itsthevine/web/domain/ContactEnquiryRepository.java
@@ -1,6 +1,11 @@
package com.itsthevine.web.domain;
+import java.util.List;
+
import org.springframework.data.jpa.repository.JpaRepository;
public interface ContactEnquiryRepository extends JpaRepository {
+
+ /** Newest first — the admin screen reads like an inbox. */
+ List findAllByOrderByCreatedAtDesc();
}
diff --git a/src/main/java/com/itsthevine/web/domain/Product.java b/src/main/java/com/itsthevine/web/domain/Product.java
index edfa30c..e3114f0 100644
--- a/src/main/java/com/itsthevine/web/domain/Product.java
+++ b/src/main/java/com/itsthevine/web/domain/Product.java
@@ -12,6 +12,8 @@ import jakarta.persistence.JoinColumn;
import jakarta.persistence.OrderColumn;
import jakarta.persistence.Table;
+import org.hibernate.annotations.BatchSize;
+
import net.thebennett.platform.data.BaseEntity;
/** Something the bakery makes, with the photos that show it off. */
@@ -32,17 +34,40 @@ public class Product extends BaseEntity {
/**
* Object keys, not URLs — where the bucket lives is deployment configuration, so the absolute
* URL is built at the edge of the app ({@code ProductCatalog}) rather than baked into the data.
+ *
+ * {@code @BatchSize} because the products page loads the whole catalogue at once: without it
+ * Hibernate issues a separate query per product for its photos — forty-odd round trips for a page
+ * that needs two.
*/
@ElementCollection(fetch = FetchType.EAGER)
@CollectionTable(name = "product_image", joinColumns = @JoinColumn(name = "product_id"))
@OrderColumn(name = "position")
@Column(name = "image_key", nullable = false, length = 300)
+ @BatchSize(size = 64)
private List imageKeys = new ArrayList<>();
protected Product() {
// for JPA
}
+ public Product(String name, String category, int position, List imageKeys) {
+ this.name = name;
+ this.category = category;
+ this.position = position;
+ this.imageKeys = new ArrayList<>(imageKeys);
+ }
+
+ /** Replaces every editable field — the admin form always submits the whole product. */
+ public void update(String name, String category, int position, List imageKeys) {
+ this.name = name;
+ this.category = category;
+ this.position = position;
+ // Mutate in place rather than reassigning: Hibernate tracks THIS list instance, and handing it
+ // a different one makes it delete and re-insert every row.
+ this.imageKeys.clear();
+ this.imageKeys.addAll(imageKeys);
+ }
+
public String getName() { return name; }
public String getCategory() { return category; }
public int getPosition() { return position; }
diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml
index 30afc48..ea7a05b 100644
--- a/src/main/resources/application.yaml
+++ b/src/main/resources/application.yaml
@@ -36,11 +36,27 @@ platform:
data:
auditing:
enabled: true
+ security:
+ # Public site: only the admin API needs a login. An allowlist of public paths would mean
+ # enumerating every static directory, and anything missed 401s — which is exactly how the
+ # confessions site broke its own cover images. mode=OIDC comes from the deploy env so tests
+ # stay on NONE.
+ authenticated-paths:
+ - /api/admin/**
+ storage:
+ endpoint: ${S3_ENDPOINT:https://s3.thebennett.net}
+ access-key: ${S3_ACCESS_KEY:}
+ secret-key: ${S3_SECRET_KEY:}
+ path-style-access: true
contact:
to: ${CONTACT_TO:}
from: ${CONTACT_FROM:}
hub-url: ${CONTACT_HUB_URL:}
+vine:
+ storage:
+ bucket: ${VINE_BUCKET:itsthevine}
+
# Absolute URLs for og:url. Only matters to link-preview scrapers, which need a full URL.
site:
base-url: ${SITE_BASE_URL:https://itsthevine.com}
diff --git a/src/test/java/com/itsthevine/web/AdminControllerTest.java b/src/test/java/com/itsthevine/web/AdminControllerTest.java
new file mode 100644
index 0000000..b92302c
--- /dev/null
+++ b/src/test/java/com/itsthevine/web/AdminControllerTest.java
@@ -0,0 +1,146 @@
+package com.itsthevine.web;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
+import org.springframework.web.server.ResponseStatusException;
+import org.testcontainers.containers.PostgreSQLContainer;
+import org.testcontainers.junit.jupiter.Container;
+import org.testcontainers.junit.jupiter.Testcontainers;
+import org.testcontainers.utility.DockerImageName;
+
+import com.itsthevine.web.domain.ProductRepository;
+
+/**
+ * The admin catalogue operations. Whether they're reachable without a login is covered separately by
+ * {@link AdminSecurityTest} — this is about what they do once you're in.
+ */
+@SpringBootTest(properties = {
+ "platform.contact.to=test@example.com",
+ "platform.contact.from=noreply@example.com",
+ "platform.storage.access-key=test",
+ "platform.storage.secret-key=test",
+ "site.assets.base-url=https://s3.example.test/itsthevine"
+})
+@Testcontainers
+class AdminControllerTest {
+
+ @Container
+ @ServiceConnection
+ static PostgreSQLContainer> postgres =
+ new PostgreSQLContainer<>(DockerImageName.parse("postgres:18-alpine"));
+
+ @Autowired
+ AdminController admin;
+
+ @Autowired
+ ProductCatalog catalog;
+
+ @Autowired
+ ProductRepository products;
+
+ private static AdminController.ProductForm form(String name, String category, List keys) {
+ return new AdminController.ProductForm(name, category, null, keys);
+ }
+
+ @Test
+ void createsAProductAndItAppearsOnThePublicSite() {
+ int before = catalog.list(null).size();
+
+ var created = admin.create(form("Test Loaf", "Rolls", List.of("products/test-loaf.webp")));
+
+ assertThat(created.id()).isNotNull();
+ assertThat(catalog.list(null)).hasSize(before + 1);
+ assertThat(catalog.list("Rolls"))
+ .extracting(ProductCatalog.ProductView::name)
+ .contains("Test Loaf");
+
+ admin.delete(created.id());
+ }
+
+ @Test
+ void aNewProductGoesToTheEndRatherThanDisplacingOne() {
+ // Position defaults matter: reusing an existing one would reorder the curated catalogue.
+ int maxBefore = admin.list().stream().mapToInt(AdminController.AdminProduct::position).max().orElse(0);
+
+ var created = admin.create(form("末 Loaf", "Rolls", List.of("products/x.webp")));
+
+ assertThat(created.position()).isGreaterThan(maxBefore);
+ admin.delete(created.id());
+ }
+
+ @Test
+ void editingReplacesTheFieldsAndKeepsTheOrderOfPhotos() {
+ var created = admin.create(form("Before", "Cakes", List.of("products/a.webp", "products/b.webp")));
+
+ var updated = admin.update(created.id(),
+ new AdminController.ProductForm("After", "Pie", 3,
+ List.of("products/b.webp", "products/a.webp", "products/c.webp")));
+
+ assertThat(updated.name()).isEqualTo("After");
+ assertThat(updated.category()).isEqualTo("Pie");
+ assertThat(updated.position()).isEqualTo(3);
+ assertThat(updated.imageKeys())
+ .containsExactly("products/b.webp", "products/a.webp", "products/c.webp");
+
+ admin.delete(created.id());
+ }
+
+ @Test
+ void aProductWithoutAPhotoIsRejected() {
+ // The card is a photo with a caption; without one it renders as an empty square.
+ assertThatThrownBy(() -> admin.create(form("No photo", "Cakes", List.of())))
+ .isInstanceOf(ResponseStatusException.class)
+ .hasMessageContaining("at least one photo");
+ assertThatThrownBy(() -> admin.create(form("No photo", "Cakes", List.of(" "))))
+ .isInstanceOf(ResponseStatusException.class);
+ }
+
+ @Test
+ void aProductWithoutANameOrCategoryIsRejected() {
+ assertThatThrownBy(() -> admin.create(form(" ", "Cakes", List.of("products/a.webp"))))
+ .isInstanceOf(ResponseStatusException.class).hasMessageContaining("name");
+ assertThatThrownBy(() -> admin.create(form("Thing", " ", List.of("products/a.webp"))))
+ .isInstanceOf(ResponseStatusException.class).hasMessageContaining("category");
+ }
+
+ @Test
+ void editingSomethingThatIsGoneIs404NotACrash() {
+ assertThatThrownBy(() -> admin.update(9_999_999L, form("x", "Cakes", List.of("products/a.webp"))))
+ .isInstanceOf(ResponseStatusException.class)
+ .hasMessageContaining("404");
+ assertThatThrownBy(() -> admin.delete(9_999_999L))
+ .isInstanceOf(ResponseStatusException.class)
+ .hasMessageContaining("404");
+ }
+
+ @Test
+ void deletingRemovesItFromThePublicCatalogue() {
+ var created = admin.create(form("Temporary", "Brownies", List.of("products/t.webp")));
+ assertThat(catalog.list("Brownies")).extracting(ProductCatalog.ProductView::name).contains("Temporary");
+
+ admin.delete(created.id());
+
+ assertThat(catalog.list("Brownies")).extracting(ProductCatalog.ProductView::name)
+ .doesNotContain("Temporary");
+ assertThat(products.findById(created.id())).isEmpty();
+ }
+
+ @Test
+ void adminListsCarryBothKeysAndUrlsSoTheEditorCanShowThumbnails() {
+ var created = admin.create(form("Thumb", "Cookies", List.of("products/thumb.webp")));
+
+ var found = admin.list().stream().filter(p -> p.id().equals(created.id())).findFirst().orElseThrow();
+ assertThat(found.imageKeys()).containsExactly("products/thumb.webp");
+ assertThat(found.imageUrls())
+ .containsExactly("https://s3.example.test/itsthevine/images/products/thumb.webp");
+
+ admin.delete(created.id());
+ }
+}
diff --git a/src/test/java/com/itsthevine/web/AdminSecurityTest.java b/src/test/java/com/itsthevine/web/AdminSecurityTest.java
new file mode 100644
index 0000000..dd23000
--- /dev/null
+++ b/src/test/java/com/itsthevine/web/AdminSecurityTest.java
@@ -0,0 +1,113 @@
+package com.itsthevine.web;
+
+import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
+import org.springframework.http.MediaType;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers;
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
+import org.springframework.web.context.WebApplicationContext;
+import org.testcontainers.containers.PostgreSQLContainer;
+import org.testcontainers.junit.jupiter.Container;
+import org.testcontainers.junit.jupiter.Testcontainers;
+import org.testcontainers.utility.DockerImageName;
+
+/**
+ * What an anonymous visitor can and cannot reach.
+ *
+ * This is the test that matters most on this branch: the admin API can create, edit and delete the
+ * menu, and the whole site is otherwise public. Running with {@code platform.security.mode=OIDC}, as
+ * production does — the default of NONE would leave everything open and prove nothing.
+ */
+@SpringBootTest(properties = {
+ "platform.security.mode=OIDC",
+ // Endpoints stated outright rather than an issuer-uri: an issuer-uri makes Spring fetch the
+ // discovery document at startup, which needs the network and a real identity provider.
+ "spring.security.oauth2.client.provider.authentik.authorization-uri=https://sso.example.test/authorize",
+ "spring.security.oauth2.client.provider.authentik.token-uri=https://sso.example.test/token",
+ "spring.security.oauth2.client.provider.authentik.jwk-set-uri=https://sso.example.test/jwks",
+ "spring.security.oauth2.client.provider.authentik.user-info-uri=https://sso.example.test/userinfo",
+ "spring.security.oauth2.client.provider.authentik.user-name-attribute=preferred_username",
+ "spring.security.oauth2.client.registration.authentik.scope=openid,profile,email",
+ "spring.security.oauth2.client.registration.authentik.authorization-grant-type=authorization_code",
+ "spring.security.oauth2.client.registration.authentik.redirect-uri={baseUrl}/login/oauth2/code/{registrationId}",
+ "spring.security.oauth2.client.registration.authentik.client-id=test",
+ "spring.security.oauth2.client.registration.authentik.client-secret=test",
+ "platform.contact.to=test@example.com",
+ "platform.contact.from=noreply@example.com",
+ "platform.storage.access-key=test",
+ "platform.storage.secret-key=test"
+})
+@Testcontainers
+class AdminSecurityTest {
+
+ @Container
+ @ServiceConnection
+ static PostgreSQLContainer> postgres =
+ new PostgreSQLContainer<>(DockerImageName.parse("postgres:18-alpine"));
+
+ @Autowired
+ WebApplicationContext context;
+
+ MockMvc mvc;
+
+ @BeforeEach
+ void setUp() {
+ // .apply(springSecurity()) is not optional here: webAppContextSetup alone leaves the security
+ // filter chain out, so every protected path returns 200 and the test proves nothing.
+ mvc = MockMvcBuilders.webAppContextSetup(context)
+ .apply(SecurityMockMvcConfigurers.springSecurity())
+ .build();
+ }
+
+ @Test
+ void everyAdminEndpointIsClosedToAnonymousVisitors() throws Exception {
+ // 401 rather than a redirect: the platform's security starter answers /api/** with a status so
+ // the SPA can handle it, instead of bouncing an XHR to the identity provider.
+ mvc.perform(get("/api/admin/products")).andExpect(status().isUnauthorized());
+ mvc.perform(get("/api/admin/enquiries")).andExpect(status().isUnauthorized());
+ mvc.perform(post("/api/admin/products").with(csrf()).contentType(MediaType.APPLICATION_JSON)
+ .content("{\"name\":\"x\",\"category\":\"Cakes\",\"imageKeys\":[\"a\"]}"))
+ .andExpect(status().isUnauthorized());
+ mvc.perform(post("/api/admin/images/presign-upload?filename=x.jpg").with(csrf()))
+ .andExpect(status().isUnauthorized());
+ }
+
+ @Test
+ void theShopStaysPublic() throws Exception {
+ // The whole point of authenticated-paths: locking the admin API must not lock the menu.
+ mvc.perform(get("/api/products")).andExpect(status().isOk());
+ mvc.perform(get("/api/categories")).andExpect(status().isOk());
+ mvc.perform(post("/api/contact").with(csrf()).contentType(MediaType.APPLICATION_JSON)
+ .content("{\"name\":\"Ada\",\"email\":\"nope\",\"message\":\"hi\"}"))
+ .andExpect(status().isBadRequest()); // reached the controller, rejected on content
+ }
+
+ @Test
+ void theContactFormNeedsItsCsrfToken() throws Exception {
+ // Turning on the security starter turns on CSRF, which applies to the PUBLIC contact form too.
+ // Without the token the form silently 403s — the SPA reads the XSRF-TOKEN cookie and sends
+ // X-XSRF-TOKEN for exactly this reason.
+ mvc.perform(post("/api/contact").contentType(MediaType.APPLICATION_JSON)
+ .content("{\"name\":\"Ada\",\"email\":\"ada@example.com\",\"message\":\"hi\"}"))
+ .andExpect(status().isForbidden());
+ }
+
+ @Test
+ void meIsPublicAndSaysNobodyIsSignedIn() throws Exception {
+ // If this required a login, every anonymous visitor would be bounced to Authentik on page load.
+ mvc.perform(get("/api/me"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.authenticated").value(false))
+ .andExpect(jsonPath("$.admin").value(false));
+ }
+}
diff --git a/src/test/java/com/itsthevine/web/ContactControllerTest.java b/src/test/java/com/itsthevine/web/ContactControllerTest.java
index aab6800..6cc41f7 100644
--- a/src/test/java/com/itsthevine/web/ContactControllerTest.java
+++ b/src/test/java/com/itsthevine/web/ContactControllerTest.java
@@ -4,6 +4,7 @@ import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
@@ -15,8 +16,10 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.mail.MailSendException;
-import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSender;
+import org.springframework.mail.javamail.JavaMailSenderImpl;
+
+import jakarta.mail.internet.MimeMessage;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
@@ -47,6 +50,8 @@ class ContactControllerTest {
// Activates the contact starter without pointing it at anything real.
registry.add("platform.contact.to", () -> "shop@example.com");
registry.add("platform.contact.from", () -> "noreply@example.com");
+ registry.add("platform.storage.access-key", () -> "test");
+ registry.add("platform.storage.secret-key", () -> "test");
}
/** Nothing in a test run may reach a real relay. */
@@ -65,6 +70,9 @@ class ContactControllerTest {
@BeforeEach
void setUp() {
+ // The contact starter builds a MimeMessage through the sender (platform 0.1.6, so the display
+ // name is quoted properly). A bare mock returns null for that, so give it a real one.
+ when(mailSender.createMimeMessage()).thenAnswer(i -> new JavaMailSenderImpl().createMimeMessage());
mvc = MockMvcBuilders.webAppContextSetup(context).build();
enquiries.deleteAll();
}
@@ -82,7 +90,7 @@ class ContactControllerTest {
.andExpect(status().isOk())
.andExpect(jsonPath("$.ok").value(true));
- verify(mailSender).send(any(SimpleMailMessage.class));
+ verify(mailSender).send(any(MimeMessage.class));
assertThat(enquiries.findAll()).singleElement().satisfies(e -> {
assertThat(e.getName()).isEqualTo("Ada");
@@ -107,7 +115,7 @@ class ContactControllerTest {
@Test
void keepsTheEnquiryWhenTheRelayIsDown() throws Exception {
// The whole reason the row is written before delivery: a broken relay must not lose business.
- doThrow(new MailSendException("relay down")).when(mailSender).send(any(SimpleMailMessage.class));
+ doThrow(new MailSendException("relay down")).when(mailSender).send(any(MimeMessage.class));
mvc.perform(post("/api/contact").contentType(MediaType.APPLICATION_JSON)
.content(body("Ada", "ada@example.com", "Cinnamon rolls for 30?")))
diff --git a/src/test/java/com/itsthevine/web/PlatformContractTest.java b/src/test/java/com/itsthevine/web/PlatformContractTest.java
index 16bedb5..e3e1f6b 100644
--- a/src/test/java/com/itsthevine/web/PlatformContractTest.java
+++ b/src/test/java/com/itsthevine/web/PlatformContractTest.java
@@ -11,6 +11,10 @@ import net.thebennett.platform.test.PlatformWebContract;
/** Everything in {@link PlatformWebContract} — what this app must do because it is on the platform. */
@SpringBootTest(properties = {
+ // The storage starter activates on its default endpoint, so an S3 client is built even in
+ // tests and fails on blank keys.
+ "platform.storage.access-key=test",
+ "platform.storage.secret-key=test",
"platform.contact.to=test@example.com",
"platform.contact.from=noreply@example.com"
})
diff --git a/src/test/java/com/itsthevine/web/ProductCatalogTest.java b/src/test/java/com/itsthevine/web/ProductCatalogTest.java
index 44776ca..abea7ff 100644
--- a/src/test/java/com/itsthevine/web/ProductCatalogTest.java
+++ b/src/test/java/com/itsthevine/web/ProductCatalogTest.java
@@ -29,6 +29,12 @@ class ProductCatalogTest {
registry.add("spring.datasource.username", POSTGRES::getUsername);
registry.add("spring.datasource.password", POSTGRES::getPassword);
registry.add("site.assets.base-url", () -> "https://s3.example.test/itsthevine");
+ // The contact starter refuses to start on a blank recipient, and this app has a
+ // ContactController, so the context needs one even to test the catalogue.
+ registry.add("platform.contact.to", () -> "test@example.com");
+ registry.add("platform.contact.from", () -> "noreply@example.com");
+ registry.add("platform.storage.access-key", () -> "test");
+ registry.add("platform.storage.secret-key", () -> "test");
}
@Autowired