Archived
Admin for the menu and enquiries, plus gallery fixes
Admin - /api/admin: products CRUD, the enquiry inbox, and presigned photo upload straight to the bucket so images never pass through the app. Gated by platform.security.authenticated-paths = /api/admin/**, so any signed-in Authentik user is staff — the alternative is a role model a two-person bakery would never maintain. - /api/me is deliberately PUBLIC. The SPA asks on every page load, and requiring a login would bounce every anonymous visitor to Authentik just to read the menu. - /admin screens: product list with edit and remove, an editor with drag-free photo reordering and upload, and an enquiry inbox that flags anything the relay refused. Gallery - swipe on touch devices, which the react-awesome-slider it replaced had and this did not, plus arrow keys and position dots — with swipe there is otherwise nothing to say a card holds more than one photo. Vertical drags are ignored so page scrolling still works. - @BatchSize on the photo collection: the products page loaded the whole catalogue and Hibernate issued a query per product for its images, forty-odd round trips for a page that needs two. Three things the tests caught, none of which are obvious: - Adding the storage starter broke every existing test. It activates on a default endpoint, so an S3 client is built even in tests and dies on blank keys. - MockMvc's webAppContextSetup leaves the security filter chain OUT, so the first version of the security test passed 200s and proved the opposite of what it claimed. It needs .apply(springSecurity()). - Turning on the security starter turns on CSRF — for the PUBLIC contact form too, which then 403s. The SPA now reads the XSRF-TOKEN cookie and sends X-XSRF-TOKEN, and there is a test asserting the form is rejected without it.
This commit is contained in:
@@ -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 = {
|
||||
"[email protected]",
|
||||
"[email protected]",
|
||||
"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<String> 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());
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
* <p>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",
|
||||
"[email protected]",
|
||||
"[email protected]",
|
||||
"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\":\"[email protected]\",\"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));
|
||||
}
|
||||
}
|
||||
@@ -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", () -> "[email protected]");
|
||||
registry.add("platform.contact.from", () -> "[email protected]");
|
||||
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", "[email protected]", "Cinnamon rolls for 30?")))
|
||||
|
||||
@@ -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",
|
||||
"[email protected]",
|
||||
"[email protected]"
|
||||
})
|
||||
|
||||
@@ -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", () -> "[email protected]");
|
||||
registry.add("platform.contact.from", () -> "[email protected]");
|
||||
registry.add("platform.storage.access-key", () -> "test");
|
||||
registry.add("platform.storage.secret-key", () -> "test");
|
||||
}
|
||||
|
||||
@Autowired
|
||||
|
||||
Reference in New Issue
Block a user