From fe75932c8fc81870ac959d6680a44dfd5e1a5ae8 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Sat, 15 Aug 2026 00:36:48 -0600 Subject: [PATCH 01/26] docs: add docs/TESTING.md, absorb TEST-ANALYSIS.md, repoint @Disabled refs --- docs/TEST-ANALYSIS.md | 74 ----------- docs/TESTING.md | 117 ++++++++++++++++++ .../spring/user/api/ApiSecurityTest.java | 2 +- .../AuthenticatedUserApiTestSimplified.java | 2 +- .../spring/user/api/PasswordResetApiTest.java | 2 +- .../api/PasswordResetApiTestSimplified.java | 2 +- .../user/api/PasswordResetCompletionTest.java | 2 +- .../spring/user/api/UserApiTest.java | 8 +- .../UserRegistrationComprehensiveTest.java | 2 +- .../user/api/UserRegistrationCoreTest.java | 2 +- .../api/UserRegistrationEdgeCaseTest.java | 2 +- .../concurrent/AdminUserManagementTest.java | 2 +- .../AuthenticationIntegrationTest.java | 2 +- .../SecurityConfigurationTest.java | 2 +- .../oauth2/GoogleOAuth2IntegrationTest.java | 2 +- .../security/AuditLoggingIntegrationTest.java | 2 +- .../EmailVerificationEdgeCaseTest.java | 2 +- 17 files changed, 135 insertions(+), 92 deletions(-) delete mode 100644 docs/TEST-ANALYSIS.md create mode 100644 docs/TESTING.md diff --git a/docs/TEST-ANALYSIS.md b/docs/TEST-ANALYSIS.md deleted file mode 100644 index 479c879..0000000 --- a/docs/TEST-ANALYSIS.md +++ /dev/null @@ -1,74 +0,0 @@ -# Test Analysis Report - -## Summary -- **Total Tests**: 309 -- **Failing Tests**: 0 (all tests now pass or are disabled) -- **Disabled Tests**: ~174 (preserved for framework improvement insights) -- **Fixed Tests**: 16 (from original 119 failures) -- **Created By**: Claude Code -- **Date**: July 2025 -- **Final Status**: BUILD SUCCESSFUL - All tests pass - -## Key Findings - -### 1. Framework Architecture Mismatch -- Tests assumed form-based authentication, but SpringUserFramework is REST API based -- Many tests expect JSON responses but receive HTML error pages -- Authentication mechanism differences between test expectations and actual implementation - -### 2. Test Categories of Failures - -#### Category 1: Database Cleanup Issues (FIXED) -- Tests that delete all users/roles from database -- **Solution**: Disabled dangerous tests, using @Transactional rollback - -#### Category 2: Authentication/Authorization (~40 tests) -- Tests expect specific JSON error responses for auth failures -- Spring Security returns empty 401/403 responses instead -- Custom DSUserDetails not properly mocked in some tests - -#### Category 3: OAuth2/OIDC Tests (~20 tests) -- Missing mock OAuth2 infrastructure -- Tests expect OAuth2 flows that aren't configured - -#### Category 4: Response Format Mismatches (~25 tests) -- Tests expect form-encoded responses but API returns JSON -- HTML error pages returned instead of JSON errors -- Incorrect status code expectations - -#### Category 5: Audit Logging (~10 tests) -- Tests expect specific audit log formats -- Timing issues with async audit logging -- File-based audit logger not initialized in test environment - -#### Category 6: Email/Token Verification (~8 tests) -- Mock email service not properly configured -- Token generation/validation timing issues - -## Potential SpringUserFramework Improvements - -1. **Consistent Error Responses**: Framework should return JSON errors for REST endpoints, not HTML -2. **Test Support**: Framework could provide test utilities for common scenarios -3. **Documentation**: REST API endpoints and expected responses need clear documentation -4. **Security Configuration**: Allow easier customization of Spring Security error responses - -## Recommendations - -### Short-term (For Build Success) -1. Disable failing tests with @Disabled annotation -2. Add descriptive messages explaining why each test is disabled -3. Group disabled tests by category for easier future fixes - -### Long-term (Framework Improvements) -1. Submit issues to SpringUserFramework for consistent JSON error responses -2. Create test utilities for common authentication scenarios -3. Document expected API behaviors clearly -4. Consider creating a test starter module - -## Test Preservation Strategy - -Tests are disabled but preserved because they: -- Reveal potential framework limitations -- Suggest API improvements -- Provide comprehensive test coverage goals -- Document expected behaviors (even if currently unmet) \ No newline at end of file diff --git a/docs/TESTING.md b/docs/TESTING.md new file mode 100644 index 0000000..34fc9d5 --- /dev/null +++ b/docs/TESTING.md @@ -0,0 +1,117 @@ +# Testing + +How this demo app is tested: JUnit tests, Playwright E2E tests, and the test-only API that +supports Playwright. For the framework's own testing guide, see +[SpringUserFramework/docs/TESTING.md](https://github.com/devondragon/SpringUserFramework/blob/main/docs/TESTING.md). + +## JUnit tests + +```bash +./gradlew test # all tests +./gradlew test --tests UserApiTest # one class +./gradlew test --tests UserApiTest.resetPassword # one method +``` + +Tests run under the `test` Spring profile +([`application-test.properties`](../src/test/resources/application-test.properties)), backed by +an in-memory H2 database with a per-context unique name +(`jdbc:h2:mem:testdb-${random.uuid}`) so each Spring context is isolated. + +Two more profiles support OAuth2 tests: `oauth2-mock` +([`application-oauth2-mock.properties`](../src/test/resources/application-oauth2-mock.properties)), +used by +[`GoogleOAuth2IntegrationTest`](../src/test/java/com/digitalsanctuary/spring/user/oauth2/GoogleOAuth2IntegrationTest.java) +(currently `@Disabled`); and `oauth2test` +([`application-oauth2test.properties`](../src/test/resources/application-oauth2test.properties)), +documented in +[`oauth2/README.md`](../src/test/java/com/digitalsanctuary/spring/user/oauth2/README.md) for +tests written against `OAuth2TestConfiguration` but not currently used by an active test. + +## Test layout + +- `src/test/java/com/digitalsanctuary/spring/user/...`: tests for the framework's user + management surface (`api/`, `concurrent/`, `config/`, `integration/`, `json/`, `oauth2/`, + `security/`). +- `src/test/java/com/digitalsanctuary/spring/demo/...`: tests for the demo app's own code + (`event/`, `mfa/`, `registration/`, `DemoTests.java`). + +[`IntegrationTest`](../src/test/java/com/digitalsanctuary/spring/user/test/annotations/IntegrationTest.java) +composes `@SpringBootTest` (against `UserDemoApplication`), `@AutoConfigureMockMvc`, +`@AutoConfigureDataJpa`, `@ActiveProfiles("test")`, and `@Transactional` (rollback per test). + +Test data builders live in +[`.../user/test/builders/`](../src/test/java/com/digitalsanctuary/spring/user/test/builders/): +`UserTestDataBuilder`, `RoleTestDataBuilder`, `TokenTestDataBuilder`. + +## Disabled tests + +```bash +/usr/bin/find src/test -name '*.java' | wc -l # test files +/usr/bin/find src/test -name '*.java' | xargs grep -l @Disabled | wc -l # files with @Disabled +``` + +As of this writing that's 62 test files, 17 with `@Disabled`. 15 were disabled during a REST API +alignment pass and point back to this file; they fall into these categories: + +- **Auth expectations**: test expects a specific JSON error body on auth failure; Spring + Security returns an empty 401/403, or `DSUserDetails` isn't mocked the way the test assumes. +- **OAuth2/OIDC**: needs mock provider infrastructure not wired up for that test. +- **Response format**: test assumes form-encoded or HTML where the endpoint returns JSON, or the + reverse. +- **Audit logging**: asserts on log output with timing assumptions that don't hold under async + logging in the test environment. +- **Email/token verification**: assumes mock email service or token timing not configured for + that test. +- **Transaction isolation**: a user created in test setup isn't visible to the REST endpoint + within the same transaction. + +They're kept, not deleted: each documents an expected behavior or a gap worth revisiting as a +framework improvement. The other two (`DisabledTestExample.java`, +`AccountLockoutIntegrationTest.java`) are disabled for unrelated, self-contained reasons +documented inline. + +## Playwright E2E tests + +Tests live in [`playwright/`](../playwright) (`@playwright/test`). Install once: + +```bash +cd playwright && npm ci && npx playwright install +``` + +Run via npm (`playwright/package.json`: `test`, `test:chromium`, `test:headed`, `test:ui`) or the +Gradle wrapper tasks in [`build.gradle`](../build.gradle) (`verification` group): +`./gradlew playwrightTest` / `playwrightTestChromium` (both depend on `playwrightBrowsers` / +`playwrightInstall`). + +[`playwright.config.ts`](../playwright/playwright.config.ts) starts the app itself via +`webServer`: `./gradlew bootRun --args="--spring.profiles.active=${APP_PROFILES:-local,playwright-test}"` +against `http://localhost:8080`, reusing an already-running server unless `CI` is set. The +`playwright-test` profile +([`application-playwright-test.yml`](../src/main/resources/application-playwright-test.yml)) +disables verification/reset emails (tests fetch tokens via the Test API instead), pins +`user.security.appUrl`, and sets `allowInitialPasswordSetWithoutStepUp: true` so the passkey-only +"set initial password" flow works without a `StepUpService` bean. + +The `chromium`, `firefox`, `webkit`, `Mobile Chrome`, and `Mobile Safari` projects skip specs +tagged `@mfa-enabled` (`grepInvert`); a separate `chromium-mfa` project runs only those specs, +against a server started with the `mfa` profile added: + +```bash +APP_PROFILES=local,playwright-test,mfa npx playwright test --project=chromium-mfa +``` + +**Test API**: +[`TestDataController`](../src/main/java/com/digitalsanctuary/spring/demo/test/api/TestDataController.java) +exposes `/api/test/*` (create/enable/unlock/delete a user, fetch verification and password-reset +tokens, health check), loaded only under `@Profile("playwright-test")`. +[`TestApiSecurityConfig`](../src/main/java/com/digitalsanctuary/spring/demo/test/config/TestApiSecurityConfig.java) +disables CSRF for `/api/test/**` and restricts it to requests from `127.0.0.1`, +`0:0:0:0:0:0:0:1`, or `localhost`; everything else is denied. + +## CI + +[`.github/workflows/tests.yml`](../.github/workflows/tests.yml) runs on pull requests and pushes +to `main`: **`unit-tests`** runs `./gradlew test` on Java 21. **`playwright-tests`** builds the +app, starts a `mariadb:12.2` service container, installs Playwright, then runs E2E twice: once +with `APP_PROFILES=playwright-test` against `chromium` (MFA off), once with +`APP_PROFILES=playwright-test,mfa` against `chromium-mfa` (MFA on). diff --git a/src/test/java/com/digitalsanctuary/spring/user/api/ApiSecurityTest.java b/src/test/java/com/digitalsanctuary/spring/user/api/ApiSecurityTest.java index 0bdc112..781afb0 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/api/ApiSecurityTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/api/ApiSecurityTest.java @@ -44,7 +44,7 @@ @ActiveProfiles("test") @Transactional @DisplayName("API Security Tests") -@Disabled("CSRF and authentication setup issues with REST API. See docs/TEST-ANALYSIS.md") +@Disabled("CSRF and authentication setup issues with REST API. See docs/TESTING.md") class ApiSecurityTest { private static final String API_BASE_PATH = "/user"; diff --git a/src/test/java/com/digitalsanctuary/spring/user/api/AuthenticatedUserApiTestSimplified.java b/src/test/java/com/digitalsanctuary/spring/user/api/AuthenticatedUserApiTestSimplified.java index 47e8013..4cb851d 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/api/AuthenticatedUserApiTestSimplified.java +++ b/src/test/java/com/digitalsanctuary/spring/user/api/AuthenticatedUserApiTestSimplified.java @@ -47,7 +47,7 @@ @ActiveProfiles("test") @Transactional @DisplayName("Authenticated User API Tests - Simplified") -@Disabled("Authentication setup issues with DSUserDetails. See docs/TEST-ANALYSIS.md") +@Disabled("Authentication setup issues with DSUserDetails. See docs/TESTING.md") class AuthenticatedUserApiTestSimplified { private static final String API_BASE_PATH = "/user"; diff --git a/src/test/java/com/digitalsanctuary/spring/user/api/PasswordResetApiTest.java b/src/test/java/com/digitalsanctuary/spring/user/api/PasswordResetApiTest.java index af31683..24d1cbc 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/api/PasswordResetApiTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/api/PasswordResetApiTest.java @@ -58,7 +58,7 @@ @ActiveProfiles("test") @Transactional @DisplayName("Password Reset API Tests") -@Disabled("Password reset token workflow and email handling issues. See docs/TEST-ANALYSIS.md") +@Disabled("Password reset token workflow and email handling issues. See docs/TESTING.md") class PasswordResetApiTest { private static final String RESET_PASSWORD_URL = "/user/resetPassword"; diff --git a/src/test/java/com/digitalsanctuary/spring/user/api/PasswordResetApiTestSimplified.java b/src/test/java/com/digitalsanctuary/spring/user/api/PasswordResetApiTestSimplified.java index d207e46..9c63b72 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/api/PasswordResetApiTestSimplified.java +++ b/src/test/java/com/digitalsanctuary/spring/user/api/PasswordResetApiTestSimplified.java @@ -42,7 +42,7 @@ @ActiveProfiles("test") @Transactional @DisplayName("Password Reset API Tests - Simplified") -@Disabled("Validation expectations don't match API behavior. See docs/TEST-ANALYSIS.md") +@Disabled("Validation expectations don't match API behavior. See docs/TESTING.md") class PasswordResetApiTestSimplified { private static final String RESET_PASSWORD_URL = "/user/resetPassword"; diff --git a/src/test/java/com/digitalsanctuary/spring/user/api/PasswordResetCompletionTest.java b/src/test/java/com/digitalsanctuary/spring/user/api/PasswordResetCompletionTest.java index 7c82a58..564e262 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/api/PasswordResetCompletionTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/api/PasswordResetCompletionTest.java @@ -40,7 +40,7 @@ @ActiveProfiles("test") @Transactional @DisplayName("Password Reset Completion Tests") -@Disabled("Password reset completion workflow issues. See docs/TEST-ANALYSIS.md") +@Disabled("Password reset completion workflow issues. See docs/TESTING.md") class PasswordResetCompletionTest { private static final String SAVE_PASSWORD_URL = "/user/savePassword"; diff --git a/src/test/java/com/digitalsanctuary/spring/user/api/UserApiTest.java b/src/test/java/com/digitalsanctuary/spring/user/api/UserApiTest.java index 8223913..8f1b06b 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/api/UserApiTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/api/UserApiTest.java @@ -78,7 +78,7 @@ public class UserApiTest { @ParameterizedTest @ArgumentsSource(ApiTestRegistrationArgumentsProvider.class) @Order(1) - @Disabled("Transaction isolation issue - user created in test setup not visible to REST endpoint. See docs/TEST-ANALYSIS.md") + @Disabled("Transaction isolation issue - user created in test setup not visible to REST endpoint. See docs/TESTING.md") // correctly run separately public void registerUserAccount(ApiTestArgumentsHolder argumentsHolder) throws Exception { UserDto userDto = argumentsHolder.getUserDto(); @@ -138,7 +138,7 @@ public void resetPassword() throws Exception { @ParameterizedTest @ArgumentsSource(ApiTestUpdateUserArgumentsProvider.class) @Order(3) - @Disabled("Spring Security returns empty 401 response instead of JSON error. See docs/TEST-ANALYSIS.md") + @Disabled("Spring Security returns empty 401 response instead of JSON error. See docs/TESTING.md") public void updateUser(ApiTestArgumentsHolder argumentsHolder) throws Exception { // Ensure user exists if (userService.findUserByEmail(argumentsHolder.getUserDto().getEmail()) == null) { @@ -169,7 +169,7 @@ public void updateUser(ApiTestArgumentsHolder argumentsHolder) throws Exception @ParameterizedTest @ArgumentsSource(ApiTestUpdatePasswordArgumentsProvider.class) @Order(4) - @Disabled("Authentication setup issues with DSUserDetails. See docs/TEST-ANALYSIS.md") + @Disabled("Authentication setup issues with DSUserDetails. See docs/TESTING.md") public void updatePassword(ApiTestArgumentsHolder argumentsHolder) throws Exception { // Ensure user exists if (userService.findUserByEmail(baseTestUser.getEmail()) == null) { @@ -194,7 +194,7 @@ public void updatePassword(ApiTestArgumentsHolder argumentsHolder) throws Except @ParameterizedTest @ArgumentsSource(ApiTestDeleteAccountArgumentsProvider.class) @Order(5) - @Disabled("Authentication setup issues with DSUserDetails. See docs/TEST-ANALYSIS.md") + @Disabled("Authentication setup issues with DSUserDetails. See docs/TESTING.md") public void deleteAccount(ApiTestArgumentsHolder argumentsHolder) throws Exception { // Ensure user exists if (userService.findUserByEmail(baseTestUser.getEmail()) == null) { diff --git a/src/test/java/com/digitalsanctuary/spring/user/api/UserRegistrationComprehensiveTest.java b/src/test/java/com/digitalsanctuary/spring/user/api/UserRegistrationComprehensiveTest.java index 2dd920d..071a245 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/api/UserRegistrationComprehensiveTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/api/UserRegistrationComprehensiveTest.java @@ -48,7 +48,7 @@ @ActiveProfiles("test") @Transactional @DisplayName("Comprehensive User Registration API Tests") -@Disabled("Validation error response expectations don't match API behavior. See docs/TEST-ANALYSIS.md") +@Disabled("Validation error response expectations don't match API behavior. See docs/TESTING.md") class UserRegistrationComprehensiveTest { private static final String REGISTRATION_URL = "/user/registration"; diff --git a/src/test/java/com/digitalsanctuary/spring/user/api/UserRegistrationCoreTest.java b/src/test/java/com/digitalsanctuary/spring/user/api/UserRegistrationCoreTest.java index 50df7fa..00cedca 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/api/UserRegistrationCoreTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/api/UserRegistrationCoreTest.java @@ -39,7 +39,7 @@ @ActiveProfiles("test") @Transactional @DisplayName("User Registration Core Tests") -@Disabled("Email normalization expectations don't match API behavior. See docs/TEST-ANALYSIS.md") +@Disabled("Email normalization expectations don't match API behavior. See docs/TESTING.md") class UserRegistrationCoreTest { private static final String REGISTRATION_URL = "/user/registration"; diff --git a/src/test/java/com/digitalsanctuary/spring/user/api/UserRegistrationEdgeCaseTest.java b/src/test/java/com/digitalsanctuary/spring/user/api/UserRegistrationEdgeCaseTest.java index 61d4011..f3dcbb8 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/api/UserRegistrationEdgeCaseTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/api/UserRegistrationEdgeCaseTest.java @@ -43,7 +43,7 @@ @ActiveProfiles("test") @Transactional @DisplayName("User Registration Edge Case Tests") -@Disabled("Concurrent registration and null handling expectations don't match API behavior. See docs/TEST-ANALYSIS.md") +@Disabled("Concurrent registration and null handling expectations don't match API behavior. See docs/TESTING.md") class UserRegistrationEdgeCaseTest { private static final String REGISTRATION_URL = "/user/registration"; diff --git a/src/test/java/com/digitalsanctuary/spring/user/concurrent/AdminUserManagementTest.java b/src/test/java/com/digitalsanctuary/spring/user/concurrent/AdminUserManagementTest.java index 47718ea..d212085 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/concurrent/AdminUserManagementTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/concurrent/AdminUserManagementTest.java @@ -42,7 +42,7 @@ @ActiveProfiles("test") @DisplayName("Admin User Management Tests") @Transactional(propagation = Propagation.NOT_SUPPORTED) -@Disabled("Role hierarchy and admin operations configuration issues. See docs/TEST-ANALYSIS.md") +@Disabled("Role hierarchy and admin operations configuration issues. See docs/TESTING.md") class AdminUserManagementTest { @Autowired diff --git a/src/test/java/com/digitalsanctuary/spring/user/integration/AuthenticationIntegrationTest.java b/src/test/java/com/digitalsanctuary/spring/user/integration/AuthenticationIntegrationTest.java index 8897851..9701cb1 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/integration/AuthenticationIntegrationTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/integration/AuthenticationIntegrationTest.java @@ -46,7 +46,7 @@ @IntegrationTest @AutoConfigureMockMvc @DisplayName("Authentication Integration Tests") -@Disabled("Form-based login expectations don't match REST API architecture. See docs/TEST-ANALYSIS.md") +@Disabled("Form-based login expectations don't match REST API architecture. See docs/TESTING.md") class AuthenticationIntegrationTest { @Autowired diff --git a/src/test/java/com/digitalsanctuary/spring/user/integration/SecurityConfigurationTest.java b/src/test/java/com/digitalsanctuary/spring/user/integration/SecurityConfigurationTest.java index 6158728..fd3cebd 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/integration/SecurityConfigurationTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/integration/SecurityConfigurationTest.java @@ -154,7 +154,7 @@ void accessProtectedEndpoint_unauthenticated_redirectsToLogin() throws Exception @Test @WithMockUser(username = "security@test.com", roles = { "USER" }) @DisplayName("Should allow authenticated user to access protected endpoints") - @Disabled("Protected endpoint /protected.html returns 404 - endpoint may not exist. See docs/TEST-ANALYSIS.md") + @Disabled("Protected endpoint /protected.html returns 404 - endpoint may not exist. See docs/TESTING.md") void accessProtectedEndpoint_authenticated_allowsAccess() throws Exception { // Test that authenticated user is properly authenticated mockMvc.perform(get("/protected.html")).andExpect(status().isOk()).andExpect(authenticated()); diff --git a/src/test/java/com/digitalsanctuary/spring/user/oauth2/GoogleOAuth2IntegrationTest.java b/src/test/java/com/digitalsanctuary/spring/user/oauth2/GoogleOAuth2IntegrationTest.java index cdc09ea..521225a 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/oauth2/GoogleOAuth2IntegrationTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/oauth2/GoogleOAuth2IntegrationTest.java @@ -57,7 +57,7 @@ @ExtendWith(OAuth2MockConfiguration.WireMockExtension.class) @Transactional @DisplayName("Google OAuth2 Integration Tests") -@Disabled("Requires OAuth2 mock server infrastructure. See docs/TEST-ANALYSIS.md") +@Disabled("Requires OAuth2 mock server infrastructure. See docs/TESTING.md") class GoogleOAuth2IntegrationTest { @Autowired diff --git a/src/test/java/com/digitalsanctuary/spring/user/security/AuditLoggingIntegrationTest.java b/src/test/java/com/digitalsanctuary/spring/user/security/AuditLoggingIntegrationTest.java index b4ca35a..e828624 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/security/AuditLoggingIntegrationTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/security/AuditLoggingIntegrationTest.java @@ -52,7 +52,7 @@ @ActiveProfiles("test") @DisplayName("Audit Logging Integration Tests") @Import(AuditLoggingIntegrationTest.TestConfiguration.class) -@Disabled("Audit logger initialization and async timing issues. See docs/TEST-ANALYSIS.md") +@Disabled("Audit logger initialization and async timing issues. See docs/TESTING.md") class AuditLoggingIntegrationTest { @org.springframework.boot.test.context.TestConfiguration diff --git a/src/test/java/com/digitalsanctuary/spring/user/security/EmailVerificationEdgeCaseTest.java b/src/test/java/com/digitalsanctuary/spring/user/security/EmailVerificationEdgeCaseTest.java index 44f413b..aba9fa7 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/security/EmailVerificationEdgeCaseTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/security/EmailVerificationEdgeCaseTest.java @@ -67,7 +67,7 @@ @ActiveProfiles("test") @Import(EmailVerificationEdgeCaseTest.TestClockConfiguration.class) @DisplayName("Email Verification Edge Cases") -@Disabled("Email verification timing issues and mock email service configuration. See docs/TEST-ANALYSIS.md") +@Disabled("Email verification timing issues and mock email service configuration. See docs/TESTING.md") class EmailVerificationEdgeCaseTest { @Autowired From ebf9afe15a7e8c77117b3ebd50259a85dbd48751 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Sat, 15 Aug 2026 00:38:08 -0600 Subject: [PATCH 02/26] docs: add extending guide for framework extension points --- docs/EXTENDING.md | 193 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 193 insertions(+) create mode 100644 docs/EXTENDING.md diff --git a/docs/EXTENDING.md b/docs/EXTENDING.md new file mode 100644 index 0000000..3433636 --- /dev/null +++ b/docs/EXTENDING.md @@ -0,0 +1,193 @@ +# Extending the Spring User Framework + +This demo depends on `com.digitalsanctuary:ds-spring-user-framework:5.3.0` ([build.gradle](../build.gradle)). Every +section below names one extension point the framework offers, the demo code that uses it, the configuration that wires +it, and what you would write in your own application to do the same. + +Framework reference documentation lives in the library repository: +[README.md](https://github.com/devondragon/SpringUserFramework/blob/main/README.md), +[CONFIG.md](https://github.com/devondragon/SpringUserFramework/blob/main/CONFIG.md), +[docs/PROFILE.md](https://github.com/devondragon/SpringUserFramework/blob/main/docs/PROFILE.md), +[docs/REGISTRATION-GUARD.md](https://github.com/devondragon/SpringUserFramework/blob/main/docs/REGISTRATION-GUARD.md). + +## Custom user profile stack + +The framework owns the `User` entity and authentication. Application-specific user data goes in a profile entity that +shares the user's primary key. The demo implements all five steps of +[docs/PROFILE.md](https://github.com/devondragon/SpringUserFramework/blob/main/docs/PROFILE.md): + +| PROFILE.md step | Framework type | Demo class | +| --- | --- | --- | +| 1. Profile entity | `BaseUserProfile` | [DemoUserProfile](../src/main/java/com/digitalsanctuary/spring/demo/user/profile/DemoUserProfile.java) | +| 2. Repository | `JpaRepository` | [DemoUserProfileRepository](../src/main/java/com/digitalsanctuary/spring/demo/user/profile/DemoUserProfileRepository.java) | +| 3. Profile service | `UserProfileService` | [DemoUserProfileService](../src/main/java/com/digitalsanctuary/spring/demo/user/profile/DemoUserProfileService.java) | +| 4. Session holder | `BaseSessionProfile` | [DemoSessionProfile](../src/main/java/com/digitalsanctuary/spring/demo/user/profile/session/DemoSessionProfile.java) | +| 5. Auth listener | `BaseAuthenticationListener` | [DemoAuthenticationListener](../src/main/java/com/digitalsanctuary/spring/demo/user/profile/session/DemoAuthenticationListener.java) | + +`DemoUserProfile` is mapped to table `demo_user_profile` and adds `favoriteColor`, `receiveNewsletter`, and a +`@OneToMany` list of [EventRegistration](../src/main/java/com/digitalsanctuary/spring/demo/user/profile/EventRegistration.java) +(table `event_registrations`, with [EventRegistrationRepository](../src/main/java/com/digitalsanctuary/spring/demo/user/profile/EventRegistrationRepository.java)). +`BaseUserProfile` supplies the `@Id`, the `@OneToOne @MapsId` link to `User`, `lastAccessed`, and `locale`, so the +profile row's id is the user's id. + +`DemoUserProfileService` implements the two interface methods (`getOrCreateProfile`, `updateProfile`) and adds +domain methods `registerForEvent(Long profileId, Long eventId)` and `unregisterFromEvent(Long profileId, Long eventId)` +that load managed entities inside the transaction. `DemoSessionProfile` adds read helpers over the session-held +profile (`isRegisteredForEvent`, `getFavoriteColor`) plus `refreshProfile()`, which re-reads the profile from the +repository after a write so the session is not stale. `DemoAuthenticationListener` is a constructor-only subclass; the +framework base class loads the profile into the session on successful authentication. + +In your app: create the four types with your own field set, keep the profile entity's extra columns out of the +framework's `user_account` table, and let the base authentication listener populate the session. Note that Spring does +not inherit `@Scope` into subclasses: annotate your `BaseSessionProfile` subclass with `@SessionScopedProfile` (or +repeat the explicit `@Scope(SCOPE_SESSION, proxyMode = TARGET_CLASS)`), otherwise it registers as a singleton shared by +every session. `DemoSessionProfile` currently declares only `@Component`, so it is not a model to copy on that point. + +## Cleaning up application data when a user is deleted + +The framework publishes `com.digitalsanctuary.spring.user.event.UserPreDeleteEvent` inside the deletion transaction, +carrying `userId` and `userEmail` (not a live entity). +[UserProfileDeletionListener](../src/main/java/com/digitalsanctuary/spring/demo/user/profile/UserProfileDeletionListener.java) +handles it with `@EventListener` plus `@Transactional`, looks the profile up by id (same id as the user), and deletes +it; `EventRegistration` rows go with it through `cascade = ALL, orphanRemoval = true` on the profile's collection. + +In your app: register one such listener per aggregate that holds a foreign key to the user, and do the work in the +event's transaction so a failed cleanup rolls the deletion back. Whether the account is deleted or only disabled is +controlled by `user.actuallyDeleteAccount` ([application.yml:103](../src/main/resources/application.yml)). + +## Building your own domain on the framework: events + +The Event feature is the "your application" half of the demo. It is ordinary Spring MVC plus JPA that leans on the +framework only for identity and authorization: + +- [Event](../src/main/java/com/digitalsanctuary/spring/demo/event/Event.java) (table `events`) and + [EventRepository](../src/main/java/com/digitalsanctuary/spring/demo/event/EventRepository.java) / + [EventService](../src/main/java/com/digitalsanctuary/spring/demo/event/EventService.java). +- [EventAPIController](../src/main/java/com/digitalsanctuary/spring/demo/event/EventAPIController.java): REST under + `/api/events`. `GET /api/events` and `GET /api/events/{id}` are open; `POST /api/events`, + `PUT /api/events/{id}`, `DELETE /api/events/{id}`, `POST /api/events/{eventId}/register` and + `POST /api/events/{eventId}/unregister` each carry a `@PreAuthorize`. +- [EventPageController](../src/main/java/com/digitalsanctuary/spring/demo/event/EventPageController.java): the + Thymeleaf pages `/event/list.html`, `/event/{eventId}/details.html`, `/event/create.html`, `/event/my-events.html`. +- [AdminController](../src/main/java/com/digitalsanctuary/spring/demo/controller/AdminController.java) gates + `/admin/actions.html` with `@PreAuthorize("hasAuthority('ADMIN_PRIVILEGE')")`, the same mechanism applied to a page + rather than an API. + +The authorities in those annotations are not hard-coded in Java; they come from the framework's role configuration in +[application.yml:192-214](../src/main/resources/application.yml). `user.roles.roles-and-privileges` grants +`CREATE_EVENT_PRIVILEGE`, `DELETE_EVENT_PRIVILEGE`, and `UPDATE_EVENT_PRIVILEGE` to `ROLE_ADMIN` (lines 200-202) and +`REGISTER_FOR_EVENT_PRIVILEGE` to `ROLE_USER` (line 211). `user.roles.role-hierarchy` (lines 212-214) declares +`ROLE_ADMIN > ROLE_MANAGER > ROLE_USER`, so an admin also holds the user privileges without being granted them twice. +The framework creates the roles and privileges from this configuration at startup. + +In your app: define one privilege per action, list it under the roles that should have it, and use +`hasAuthority('YOUR_PRIVILEGE')` in `@PreAuthorize` rather than checking role names. Adding a privilege is then a +configuration change, not a code change. Property reference: +[CONFIG.md](https://github.com/devondragon/SpringUserFramework/blob/main/CONFIG.md) and [CONFIGURATION.md](CONFIGURATION.md). + +## Overriding a framework service + +[CustomUserEmailService](../src/main/java/com/digitalsanctuary/spring/demo/service/CustomUserEmailService.java) extends +the framework's `UserEmailService` and is annotated `@Service @Primary`, so it replaces the framework bean everywhere it +is injected. It overrides one method, `sendForgotPasswordVerificationEmail`: when +`app.mail.sendPasswordResetEmail` is `false` it creates and persists the reset token but sends no mail, otherwise it +delegates to `super`. The Playwright profile sets that flag to `false` +([application-playwright-test.yml:6-8](../src/main/resources/application-playwright-test.yml)) so E2E tests can read +the token back through the test API instead of an inbox. + +In your app: subclass the framework service, add `@Primary`, keep the constructor signature (the parent takes its +collaborators by constructor), override only the methods you need, and call `super` on the rest. The same pattern +applies to any framework `@Service` you want to intercept, for example to route mail through a transactional email +provider. + +## Web layer glue + +- [DemoTemplateModelAdvice](../src/main/java/com/digitalsanctuary/spring/demo/web/DemoTemplateModelAdvice.java): a + `@ControllerAdvice` exposing `devOrLocalProfile` as a model attribute. Templates cannot call + `${@environment.acceptsProfiles(...)}` in the restricted (layout-decorated) Thymeleaf expression context, so the + boolean is precomputed. This is the place to add any demo-only model attribute that does not come from the + framework's own `${userSecurity}` advice. +- [LocaleConfiguration](../src/main/java/com/digitalsanctuary/spring/demo/util/LocaleConfiguration.java): a + `CookieLocaleResolver` defaulting to `Locale.US` plus a `LocaleChangeInterceptor` bound to the `lang` request + parameter, so `?lang=fr` switches the bundle used by the pages. + +In your app: use a `@ControllerAdvice` for cross-cutting view data, and add a locale resolver only if you ship more +than one message bundle. + +## Registration guard + +[DomainRegistrationGuard](../src/main/java/com/digitalsanctuary/spring/demo/registration/DomainRegistrationGuard.java) +implements the framework's `RegistrationGuard` SPI: `evaluate(RegistrationContext)` returns `RegistrationDecision.allow()` +for `RegistrationSource.OAUTH2` and `OIDC`, and for form or passwordless registration allows only email addresses ending +in `registration.guard.allowed-domain` (default `@example.com`), denying everything else with a message. The bean is +annotated `@Profile("registration-guard")`, so it is inert until that profile is active +(`--spring.profiles.active=local,registration-guard`). See [AUTHENTICATION.md#registration-guard](AUTHENTICATION.md#registration-guard) +for how to run it, and +[docs/REGISTRATION-GUARD.md](https://github.com/devondragon/SpringUserFramework/blob/main/docs/REGISTRATION-GUARD.md) +for the full SPI contract. In your app, one `@Component` implementing the interface is the whole integration: allowlists, +invite codes, and per-source rules all fit in `evaluate`. + +## Reference templates, JavaScript, and messages + +The framework ships the mail templates but no user-facing HTML; its README points adopters at this repository for the +reference set. What to copy: + +- [templates/user/](../src/main/resources/templates/user) : `login.html`, `register.html`, `forgot-password.html`, + `forgot-password-change.html`, `forgot-password-pending-verification.html`, `update-user.html`, + `update-password.html`, `delete-account.html`, `registration-complete.html`, + `registration-pending-verification.html`, `request-new-verification-email.html`, and `mfa/webauthn-challenge.html`. + Forms post to the framework's `/user/*` endpoints and read URIs from the framework-provided `${userSecurity}` model + attribute rather than hard-coding paths. +- [templates/layout.html](../src/main/resources/templates/layout.html) and + [templates/fragments/](../src/main/resources/templates/fragments) (`header.html`, `footer.html`): the layout dialect + shell, the CSRF meta tags every fetch call reads, and `sec:authorize` driven navigation. +- [templates/mail/](../src/main/resources/templates/mail): `registration-token.html` and `forgot-password-token.html` + are byte-identical copies of the framework's defaults, placed at the same classpath paths so they take precedence. + Edit them in place to restyle the emails. +- [static/js/user/](../src/main/resources/static/js/user), one module per page. Endpoints they call: + `register.js` → `POST /user/registration` and `POST /user/registration/passwordless`; `login.js` → the login form + action plus passkey sign-in; `forgot-password.js` → `POST /user/resetPassword`; `reset-password.js` → + `POST /user/savePassword`; `resend-verification.js` → `POST /user/resendRegistrationToken`; `update-user.js` → + `POST /user/updateUser`; `update-password.js` → `POST /user/updatePassword` and `POST /user/setPassword`; + `delete-account.js` → `DELETE /user/deleteAccount`; `auth-methods.js` → `GET /user/auth-methods`; + `webauthn-manage.js` → `GET|PUT|DELETE /user/webauthn/credentials*`, `DELETE /user/webauthn/password`, and + `GET /user/mfa/status`; `webauthn-register.js` and `webauthn-authenticate.js` → the Spring Security WebAuthn + endpoints `/webauthn/register/options`, `/webauthn/register`, `/webauthn/authenticate/options`, `/login/webauthn`; + `mfa-webauthn-challenge.js`, `webauthn-utils.js` are helpers with no endpoints of their own. +- [static/js/shared.js](../src/main/resources/static/js/shared.js) (message and error rendering) and + [static/js/utils/password-validation.js](../src/main/resources/static/js/utils/password-validation.js) (strength + meter) are imported by the page modules, so copy them too. +- [messages/messages.properties](../src/main/resources/messages/messages.properties), wired by + `spring.messages.basename: messages/messages` ([application.yml:71-72](../src/main/resources/application.yml)). The + framework appends its own bundle after yours, so redefining a framework key here (the file overrides `auth.message.*`, + `email.*`, and the password-policy messages) replaces the library text. + +## Profile-gated test-only endpoints + +[TestDataController](../src/main/java/com/digitalsanctuary/spring/demo/test/api/TestDataController.java) exposes +`/api/test/**` (user lookup, create, delete, enable, unlock, verification and password-reset token retrieval, health) +for Playwright, and is annotated `@Profile("playwright-test")` so the bean does not exist otherwise. Its delete +endpoint publishes `UserPreDeleteEvent` itself so framework listeners clean up first. +[TestApiSecurityConfig](../src/main/java/com/digitalsanctuary/spring/demo/test/config/TestApiSecurityConfig.java) adds +an `@Order(1)` `SecurityFilterChain` matching `/api/test/**` that disables CSRF and permits the request only when the +remote address is loopback, denying everything else. Both are activated by the `playwright-test` profile; see +[TESTING.md](TESTING.md). + +In your app: pair the `@Profile` on the controller with a dedicated, narrow filter chain, and keep the profile out of +production configuration. + +## Configuration-only extension points + +These need no code in the demo at all: + +- MFA: `user.mfa` ([application.yml:114-125](../src/main/resources/application.yml)) is off by default; the `mfa` + profile ([application-mfa.yml](../src/main/resources/application-mfa.yml)) turns it on with factors `PASSWORD` and + `WEBAUTHN` and adds the passkey registration endpoints to the unprotected list so a new user can enroll. +- URL protection: `user.security.defaultAction: deny` plus `user.security.unprotectedURIs` + ([application.yml:142,152](../src/main/resources/application.yml)) decide what is public; the demo adds its own + `/event/**` and static paths there. +- Remember-me: `user.security.rememberMe` ([application.yml:143-151](../src/main/resources/application.yml)) enables + the cookie, with the signing key read from `REMEMBER_ME_KEY` and a random per-start fallback. + +Every property above is documented in [CONFIGURATION.md](CONFIGURATION.md) and in the framework's +[CONFIG.md](https://github.com/devondragon/SpringUserFramework/blob/main/CONFIG.md). From 40949c961c8e421c4c259f721b403cfaceb21f4e Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Sat, 15 Aug 2026 00:44:31 -0600 Subject: [PATCH 03/26] build: multi-stage Dockerfile on Java 21 Stage 1 builds the boot jar with the Gradle wrapper on eclipse-temurin:21-jdk-jammy, so 'docker compose build' no longer needs a jar in build/libs from a prior host build. Stage 2 is unchanged apart from the JRE 21 base. Add .dockerignore so the context stays small and the gitignored application-local.yml can never reach an image. --- .dockerignore | 20 ++++++++++++++++++++ Dockerfile | 24 ++++++++++++++++++++---- mise.toml | 2 +- 3 files changed, 41 insertions(+), 5 deletions(-) create mode 100644 .dockerignore diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..a8b2ec0 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,20 @@ +# Keep the build context small and keep local files out of the image. +.git/ +.github/ +.gradle/ +build/ +build.bak/ +.superpowers/ +.vscode/ +.idea/ +docs/ +playwright/ +node_modules/ +**/node_modules/ +logs/ +*.log + +# Gitignored local config holding real credentials: must never reach an image. +.env +src/main/resources/application-local.yml +src/main/resources/application-docker-keycloak.yml diff --git a/Dockerfile b/Dockerfile index 4245165..11b8068 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,22 @@ -FROM eclipse-temurin:17-jre-jammy +# Stage 1: build the boot jar inside the image, so no local Gradle build is required. +# JDK 21 matches the toolchain declared in build.gradle. +FROM eclipse-temurin:21-jdk-jammy AS build -# Install wget for healthcheck +WORKDIR /workspace + +# Resolve dependencies in their own layer so editing sources does not re-download them. +COPY gradlew gradlew +COPY gradle gradle +COPY build.gradle settings.gradle ./ +RUN ./gradlew --no-daemon dependencies --configuration runtimeClasspath > /dev/null + +COPY src src +RUN ./gradlew --no-daemon bootJar -x test + +# Stage 2: runtime image, JRE only. +FROM eclipse-temurin:21-jre-jammy + +# Install wget for the healthcheck (the JRE image has no curl) RUN apt-get update && apt-get install -y wget && rm -rf /var/lib/apt/lists/* # Add a non-root user to run the application @@ -9,8 +25,8 @@ RUN groupadd -r spring && useradd -r -g spring spring # Set working directory WORKDIR /opt/app -# Copy the JAR file -COPY build/libs/*SNAPSHOT.jar app.jar +# Copy the JAR file built in stage 1 +COPY --from=build /workspace/build/libs/*-SNAPSHOT.jar app.jar # Set ownership of the files RUN chown -R spring:spring /opt/app diff --git a/mise.toml b/mise.toml index 18090eb..8931355 100644 --- a/mise.toml +++ b/mise.toml @@ -1,2 +1,2 @@ [tools] -java = "17" +java = "21" From 26edbf49870693ecedbf0392742749508cc92158 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Sat, 15 Aug 2026 00:44:31 -0600 Subject: [PATCH 04/26] build: keep developmentOnly dependencies out of the packaged jar runtimeOnly extended developmentOnly, which put spring-boot-docker-compose into the boot jar. The packaged app then ran the Docker Compose lifecycle, and with a compose file configured it failed at startup with "'files' content [compose.dev.yaml] must exist". bootRun still gets the configuration on its classpath explicitly. --- build.gradle | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/build.gradle b/build.gradle index e5b3044..76ef97a 100644 --- a/build.gradle +++ b/build.gradle @@ -17,10 +17,10 @@ java { // Define the configurations used in the project configurations { + // Keep developmentOnly out of runtimeOnly, and so out of the boot jar. spring-boot-docker-compose + // is declared developmentOnly precisely so a packaged jar never tries to start Docker Compose; + // extending runtimeOnly from it put it back in the jar. bootRun adds it to the classpath below. developmentOnly - runtimeOnly { - extendsFrom developmentOnly - } testImplementation { extendsFrom runtimeOnly } From 1821513ff9889be4e03322d16187116d489ca4f7 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Sat, 15 Aug 2026 00:44:31 -0600 Subject: [PATCH 05/26] fix: make the documented run paths work from a fresh clone - application.yml: default spring.docker.compose.file to compose.dev.yaml, so bootRun starts a matching MariaDB instead of the full compose.yaml app stack. Previously this lived only in the gitignored application-local.yml. - application.yml: add /actuator/health to unprotectedURIs, so the Dockerfile and compose healthchecks can reach it. Other actuator endpoints stay behind login. - application-local.yml-example: replace the leftover mymariadb/mydb datasource values with the compose.dev.yaml ones, and add the sample-event seed block (data-local.sql). - compose.yaml: skip verification email in the demo stack (the mail container cannot reach real inboxes) and align the app healthcheck with the keycloak stack. - docker-compose-keycloak.yml: drop the obsolete version key, and use wget for the app healthcheck since the JRE image has no curl. --- compose.yaml | 7 ++++++- docker-compose-keycloak.yml | 4 +--- .../resources/application-local.yml-example | 19 +++++++++++++------ src/main/resources/application.yml | 10 +++++++++- 4 files changed, 29 insertions(+), 11 deletions(-) diff --git a/compose.yaml b/compose.yaml index 8f9ae7b..bfe2023 100644 --- a/compose.yaml +++ b/compose.yaml @@ -78,8 +78,13 @@ services: SPRING_MAIL_PROPERTIES_MAIL_SMTP_AUTH: "false" SPRING_MAIL_PROPERTIES_MAIL_SMTP_STARTTLS_ENABLE: "false" SPRING_MAIL_PROPERTIES_MAIL_SMTP_STARTTLS_REQUIRED: "false" + # The mailserver container is a relay with no route to real inboxes, so a verification link + # would never arrive. With this false the framework enables new accounts immediately and you + # can log in straight after registering. To exercise verification instead, set this to true + # and point the SPRING_MAIL_* values above at a real SMTP server. + USER_REGISTRATION_SENDVERIFICATIONEMAIL: "false" healthcheck: - test: ["CMD", "wget", "-qO-", "http://localhost:8080/actuator/health"] + test: ["CMD", "wget", "-q", "--spider", "http://localhost:8080/actuator/health"] interval: 30s timeout: 10s retries: 5 diff --git a/docker-compose-keycloak.yml b/docker-compose-keycloak.yml index b2ee2bb..ddf6aa3 100644 --- a/docker-compose-keycloak.yml +++ b/docker-compose-keycloak.yml @@ -1,5 +1,3 @@ -version: "3.8" - services: myapp-db: image: mariadb:12.2 @@ -81,7 +79,7 @@ services: SPRING_MAIL_PROPERTIES_MAIL_SMTP_STARTTLS_ENABLE: "false" SPRING_MAIL_PROPERTIES_MAIL_SMTP_STARTTLS_REQUIRED: "false" healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:8080/actuator/health"] + test: ["CMD", "wget", "-q", "--spider", "http://localhost:8080/actuator/health"] interval: 30s timeout: 10s retries: 5 diff --git a/src/main/resources/application-local.yml-example b/src/main/resources/application-local.yml-example index 171d65b..6da89d2 100644 --- a/src/main/resources/application-local.yml-example +++ b/src/main/resources/application-local.yml-example @@ -11,16 +11,23 @@ logging: security: DEBUG # Set logging level for security spring: - docker: - compose: - file: compose.dev.yaml application: name: Spring User Framework Demo App # Change this as per your convenience + # These match the MariaDB service in compose.dev.yaml, which bootRun starts by default (see + # spring.docker.compose in application.yml). While that integration is active the URL, username and + # password below are overridden by the service connection it derives from the running container; + # they apply when you set spring.docker.compose.enabled to false and run your own database. datasource: driverClassName: org.mariadb.jdbc.Driver # If you use mariadb database - password: mydatabaseuserpassword - url: jdbc:mariadb://mymariadb:3306/mydb?createDatabaseIfNotExist=true - username: mydatabaseuser + password: springuser + url: jdbc:mariadb://localhost:3306/springuser?createDatabaseIfNotExist=true + username: springuser + sql: + init: + mode: always # Run the schema/data scripts on every start + platform: local # Loads src/main/resources/data-local.sql (sample events, INSERT IGNORE so reruns are safe) + jpa: + defer-datasource-initialization: true # Let Hibernate create the tables before data-local.sql runs mail: # Mail configuration username: AAAAAAAAA # Mail server username password: BBBBBBBBBBB # Mail server password diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index a388087..29e166b 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -63,6 +63,14 @@ spring: show-sql: "false" # Enable or disable SQL logging application: # Application configuration name: User Framework Demo # Application name + docker: + compose: + # `./gradlew bootRun` starts this file's services (a MariaDB matching the datasource below) via + # spring-boot-docker-compose, and stops them when the app stops. That dependency is developmentOnly, + # so it is on the bootRun classpath only: it is absent from the packaged jar and from the test + # classpath, where this setting is therefore inert. Set enabled: false to use your own database. + file: compose.dev.yaml + # enabled: false datasource: # Datasource configuration password: springuser # Database password url: jdbc:mariadb://localhost:3306/springuser?createDatabaseIfNotExist=true # Database URL @@ -149,7 +157,7 @@ user: key: ${REMEMBER_ME_KEY:${random.uuid}} # tokenValiditySeconds: 1209600 # How long a remember-me token stays valid. Default is 14 days. # usePersistentTokens: true # Store tokens in the persistent_logins table (see framework db-scripts) so they can be revoked server-side. - unprotectedURIs: /,/index.html,/favicon.ico,/apple-touch-icon-precomposed.png,/css/*,/js/*,/js/user/*,/js/event/*,/js/utils/*,/img/**,/user/registration,/user/registration/passwordless,/user/resendRegistrationToken,/user/resetPassword,/user/registrationConfirm,/user/changePassword,/user/savePassword,/oauth2/authorization/*,/login,/user/login,/user/login.html,/swagger-ui.html,/swagger-ui/**,/v3/api-docs/**,/event/,/event/list.html,/event/**,/about.html,/error,/error.html,/webauthn/authenticate/**,/login/webauthn # A comma delimited list of URIs that should not be protected by Spring Security if the defaultAction is deny. + unprotectedURIs: /,/index.html,/favicon.ico,/apple-touch-icon-precomposed.png,/css/*,/js/*,/js/user/*,/js/event/*,/js/utils/*,/img/**,/user/registration,/user/registration/passwordless,/user/resendRegistrationToken,/user/resetPassword,/user/registrationConfirm,/user/changePassword,/user/savePassword,/oauth2/authorization/*,/login,/user/login,/user/login.html,/swagger-ui.html,/swagger-ui/**,/v3/api-docs/**,/event/,/event/list.html,/event/**,/about.html,/error,/error.html,/webauthn/authenticate/**,/login/webauthn,/actuator/health # A comma delimited list of URIs that should not be protected by Spring Security if the defaultAction is deny. protectedURIs: /protected.html # A comma delimited list of URIs that should be protected by Spring Security if the defaultAction is allow. disableCSRFdURIs: /no-csrf-test # A comma delimited list of URIs that should not be protected by CSRF protection. This may include API endpoints that need to be called without a CSRF token. From c08df71ad3200d00d43fc9e186c7617878038d37 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Sat, 15 Aug 2026 00:44:31 -0600 Subject: [PATCH 06/26] chore: remove TempTest startup debug logger Leftover debug code: on the local and dev profiles it logged every DemoUserProfile row at startup. Nothing references it. --- .../spring/demo/util/TempTest.java | 30 ------------------- 1 file changed, 30 deletions(-) delete mode 100644 src/main/java/com/digitalsanctuary/spring/demo/util/TempTest.java diff --git a/src/main/java/com/digitalsanctuary/spring/demo/util/TempTest.java b/src/main/java/com/digitalsanctuary/spring/demo/util/TempTest.java deleted file mode 100644 index 7357b62..0000000 --- a/src/main/java/com/digitalsanctuary/spring/demo/util/TempTest.java +++ /dev/null @@ -1,30 +0,0 @@ -package com.digitalsanctuary.spring.demo.util; - -import org.springframework.boot.context.event.ApplicationStartedEvent; -import org.springframework.context.annotation.Profile; -import org.springframework.context.event.EventListener; -import org.springframework.stereotype.Component; -import com.digitalsanctuary.spring.demo.user.profile.DemoUserProfileRepository; -import jakarta.transaction.Transactional; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; - -/** - * This is a class that is used to test the library's functionality outside of the JUnit test context. - */ -@Slf4j -@Component -@RequiredArgsConstructor -@Profile({"local", "dev"}) -public class TempTest { - - private final DemoUserProfileRepository demoUserProfileRepository; - - @Transactional - @EventListener(ApplicationStartedEvent.class) - public void test() { - log.info("This is a test"); - log.info("{}", demoUserProfileRepository.findAll()); - } - -} From 7269f026c58d5de43d3b73698540b652af9f1a5a Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Sat, 15 Aug 2026 00:49:06 -0600 Subject: [PATCH 07/26] docs: correct event api auth, form action, and mfa claims in extending guide --- docs/EXTENDING.md | 64 +++++++++++++++++++++++++++++++---------------- 1 file changed, 42 insertions(+), 22 deletions(-) diff --git a/docs/EXTENDING.md b/docs/EXTENDING.md index 3433636..c332fad 100644 --- a/docs/EXTENDING.md +++ b/docs/EXTENDING.md @@ -37,11 +37,11 @@ profile (`isRegisteredForEvent`, `getFavoriteColor`) plus `refreshProfile()`, wh repository after a write so the session is not stale. `DemoAuthenticationListener` is a constructor-only subclass; the framework base class loads the profile into the session on successful authentication. -In your app: create the four types with your own field set, keep the profile entity's extra columns out of the +In your app: create the five types with your own field set, keep the profile entity's extra columns out of the framework's `user_account` table, and let the base authentication listener populate the session. Note that Spring does not inherit `@Scope` into subclasses: annotate your `BaseSessionProfile` subclass with `@SessionScopedProfile` (or -repeat the explicit `@Scope(SCOPE_SESSION, proxyMode = TARGET_CLASS)`), otherwise it registers as a singleton shared by -every session. `DemoSessionProfile` currently declares only `@Component`, so it is not a model to copy on that point. +repeat the explicit `@Scope(value = WebApplicationContext.SCOPE_SESSION, proxyMode = ScopedProxyMode.TARGET_CLASS)`), +otherwise it registers as a singleton shared by every HTTP session. ## Cleaning up application data when a user is deleted @@ -64,9 +64,11 @@ framework only for identity and authorization: [EventRepository](../src/main/java/com/digitalsanctuary/spring/demo/event/EventRepository.java) / [EventService](../src/main/java/com/digitalsanctuary/spring/demo/event/EventService.java). - [EventAPIController](../src/main/java/com/digitalsanctuary/spring/demo/event/EventAPIController.java): REST under - `/api/events`. `GET /api/events` and `GET /api/events/{id}` are open; `POST /api/events`, - `PUT /api/events/{id}`, `DELETE /api/events/{id}`, `POST /api/events/{eventId}/register` and - `POST /api/events/{eventId}/unregister` each carry a `@PreAuthorize`. + `/api/events`. `POST /api/events`, `PUT /api/events/{id}`, `DELETE /api/events/{id}`, + `POST /api/events/{eventId}/register` and `POST /api/events/{eventId}/unregister` each carry a `@PreAuthorize`. + `GET /api/events` and `GET /api/events/{id}` carry no method-level authorization, but URL-level security still + requires an authenticated user because `/api/events` is not listed in `unprotectedURIs`. The two layers are + independent: method annotations refine what URL rules already allow through. - [EventPageController](../src/main/java/com/digitalsanctuary/spring/demo/event/EventPageController.java): the Thymeleaf pages `/event/list.html`, `/event/{eventId}/details.html`, `/event/create.html`, `/event/my-events.html`. - [AdminController](../src/main/java/com/digitalsanctuary/spring/demo/controller/AdminController.java) gates @@ -109,7 +111,8 @@ provider. framework's own `${userSecurity}` advice. - [LocaleConfiguration](../src/main/java/com/digitalsanctuary/spring/demo/util/LocaleConfiguration.java): a `CookieLocaleResolver` defaulting to `Locale.US` plus a `LocaleChangeInterceptor` bound to the `lang` request - parameter, so `?lang=fr` switches the bundle used by the pages. + parameter, so `?lang=fr` sets the session locale in a cookie. The demo ships one bundle only, so nothing visible + changes today; the wiring is there for when localized bundles are added. In your app: use a `@ControllerAdvice` for cross-cutting view data, and add a locale resolver only if you ship more than one message bundle. @@ -136,24 +139,39 @@ reference set. What to copy: `forgot-password-change.html`, `forgot-password-pending-verification.html`, `update-user.html`, `update-password.html`, `delete-account.html`, `registration-complete.html`, `registration-pending-verification.html`, `request-new-verification-email.html`, and `mfa/webauthn-challenge.html`. - Forms post to the framework's `/user/*` endpoints and read URIs from the framework-provided `${userSecurity}` model - attribute rather than hard-coding paths. + The forms post to the fixed `/user/*` API paths (`login.html` is the exception: its action comes from + `${userSecurity.loginActionUri}`, since the login processing URL is configurable). The framework-provided + `${userSecurity}` model attribute supplies the configurable page URIs used in navigation, for example + `fragments/header.html` and `index.html` link to `${userSecurity.loginPageUri}` and `${userSecurity.registrationUri}`. + Page templates need a controller mapping: the framework serves its own known pages, but the demo maps + `/user/mfa/webauthn-challenge.html` itself in + [PageController](../src/main/java/com/digitalsanctuary/spring/demo/controller/PageController.java), because that path + is the `user.mfa.webauthnEntryPointUri` value at + [application.yml:125](../src/main/resources/application.yml). Copying `templates/user/mfa/` means copying that + mapping too. - [templates/layout.html](../src/main/resources/templates/layout.html) and [templates/fragments/](../src/main/resources/templates/fragments) (`header.html`, `footer.html`): the layout dialect shell, the CSRF meta tags every fetch call reads, and `sec:authorize` driven navigation. - [templates/mail/](../src/main/resources/templates/mail): `registration-token.html` and `forgot-password-token.html` are byte-identical copies of the framework's defaults, placed at the same classpath paths so they take precedence. Edit them in place to restyle the emails. -- [static/js/user/](../src/main/resources/static/js/user), one module per page. Endpoints they call: - `register.js` → `POST /user/registration` and `POST /user/registration/passwordless`; `login.js` → the login form - action plus passkey sign-in; `forgot-password.js` → `POST /user/resetPassword`; `reset-password.js` → - `POST /user/savePassword`; `resend-verification.js` → `POST /user/resendRegistrationToken`; `update-user.js` → - `POST /user/updateUser`; `update-password.js` → `POST /user/updatePassword` and `POST /user/setPassword`; - `delete-account.js` → `DELETE /user/deleteAccount`; `auth-methods.js` → `GET /user/auth-methods`; - `webauthn-manage.js` → `GET|PUT|DELETE /user/webauthn/credentials*`, `DELETE /user/webauthn/password`, and - `GET /user/mfa/status`; `webauthn-register.js` and `webauthn-authenticate.js` → the Spring Security WebAuthn - endpoints `/webauthn/register/options`, `/webauthn/register`, `/webauthn/authenticate/options`, `/login/webauthn`; - `mfa-webauthn-challenge.js`, `webauthn-utils.js` are helpers with no endpoints of their own. +- [static/js/user/](../src/main/resources/static/js/user), one module per page, calling these endpoints: + + | Module | Endpoints | + | --- | --- | + | `register.js` | `POST /user/registration`, `POST /user/registration/passwordless` | + | `login.js` | the login form action, plus passkey sign-in via `webauthn-authenticate.js` | + | `forgot-password.js` | `POST /user/resetPassword` | + | `reset-password.js` | `POST /user/savePassword` | + | `resend-verification.js` | `POST /user/resendRegistrationToken` | + | `update-user.js` | `POST /user/updateUser` | + | `update-password.js` | `POST /user/updatePassword`, `POST /user/setPassword` | + | `delete-account.js` | `DELETE /user/deleteAccount` | + | `auth-methods.js` | `GET /user/auth-methods` | + | `webauthn-manage.js` | `GET /user/webauthn/credentials`, `PUT /user/webauthn/credentials/{id}/label`, `DELETE /user/webauthn/credentials/{id}`, `DELETE /user/webauthn/password`, `GET /user/mfa/status` | + | `webauthn-register.js`, `webauthn-authenticate.js` | the Spring Security WebAuthn endpoints `/webauthn/register/options`, `/webauthn/register`, `/webauthn/authenticate/options`, `/login/webauthn` | + | `mfa-webauthn-challenge.js`, `webauthn-utils.js` | none of their own; they delegate to the modules above | + - [static/js/shared.js](../src/main/resources/static/js/shared.js) (message and error rendering) and [static/js/utils/password-validation.js](../src/main/resources/static/js/utils/password-validation.js) (strength meter) are imported by the page modules, so copy them too. @@ -180,9 +198,11 @@ production configuration. These need no code in the demo at all: -- MFA: `user.mfa` ([application.yml:114-125](../src/main/resources/application.yml)) is off by default; the `mfa` - profile ([application-mfa.yml](../src/main/resources/application-mfa.yml)) turns it on with factors `PASSWORD` and - `WEBAUTHN` and adds the passkey registration endpoints to the unprotected list so a new user can enroll. +- MFA: `user.mfa` ([application.yml:114-125](../src/main/resources/application.yml)) declares the factors `PASSWORD` + and `WEBAUTHN` (lines 119-121) and the entry-point URIs, but is disabled at line 118. The `mfa` profile + ([application-mfa.yml](../src/main/resources/application-mfa.yml)) only flips `enabled: true`, allows the initial + password-set flow without a `StepUpService`, and adds the passkey registration endpoints to the unprotected list so a + new user can enroll. - URL protection: `user.security.defaultAction: deny` plus `user.security.unprotectedURIs` ([application.yml:142,152](../src/main/resources/application.yml)) decide what is public; the demo adds its own `/event/**` and static paths there. From 019b4228c47b51f6ad1da359710b68906c1af3ba Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Sat, 15 Aug 2026 00:53:18 -0600 Subject: [PATCH 08/26] fix: session-scope DemoSessionProfile DemoSessionProfile was annotated only @Component. Spring's @Scope is not inherited from BaseSessionProfile, so the bean was a singleton shared by every HTTP session and one user's profile leaked to all other users. Use the framework's @SessionScopedProfile meta-annotation instead, and add a context test that asserts the scopedTarget bean definition has session scope and that the injected reference is a scoped proxy. Also repoints the DomainRegistrationGuard Javadoc link at the framework's docs/REGISTRATION-GUARD.md, which moved out of the repository root. --- .../registration/DomainRegistrationGuard.java | 2 +- .../profile/session/DemoSessionProfile.java | 11 +++- .../session/DemoSessionProfileScopeTest.java | 52 +++++++++++++++++++ 3 files changed, 62 insertions(+), 3 deletions(-) create mode 100644 src/test/java/com/digitalsanctuary/spring/demo/user/profile/session/DemoSessionProfileScopeTest.java diff --git a/src/main/java/com/digitalsanctuary/spring/demo/registration/DomainRegistrationGuard.java b/src/main/java/com/digitalsanctuary/spring/demo/registration/DomainRegistrationGuard.java index 9d8b6dc..116617d 100644 --- a/src/main/java/com/digitalsanctuary/spring/demo/registration/DomainRegistrationGuard.java +++ b/src/main/java/com/digitalsanctuary/spring/demo/registration/DomainRegistrationGuard.java @@ -28,7 +28,7 @@ * property (defaults to {@code @example.com}).

* *

See the - * + * * Registration Guard documentation for the full SPI reference.

* * @see RegistrationGuard diff --git a/src/main/java/com/digitalsanctuary/spring/demo/user/profile/session/DemoSessionProfile.java b/src/main/java/com/digitalsanctuary/spring/demo/user/profile/session/DemoSessionProfile.java index 49f8c6c..2632633 100644 --- a/src/main/java/com/digitalsanctuary/spring/demo/user/profile/session/DemoSessionProfile.java +++ b/src/main/java/com/digitalsanctuary/spring/demo/user/profile/session/DemoSessionProfile.java @@ -1,13 +1,20 @@ package com.digitalsanctuary.spring.demo.user.profile.session; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.stereotype.Component; import com.digitalsanctuary.spring.demo.event.Event; import com.digitalsanctuary.spring.demo.user.profile.DemoUserProfile; import com.digitalsanctuary.spring.demo.user.profile.DemoUserProfileRepository; import com.digitalsanctuary.spring.user.profile.session.BaseSessionProfile; +import com.digitalsanctuary.spring.user.profile.session.SessionScopedProfile; -@Component +/** + * Session-scoped profile for the demo user. + * + * Annotated with {@link SessionScopedProfile} rather than plain {@code @Component}. Spring's {@code @Scope} is + * not inherited from {@link BaseSessionProfile}, so a plain {@code @Component} here would make this a singleton + * shared by every HTTP session and leak one user's profile to all other users. + */ +@SessionScopedProfile public class DemoSessionProfile extends BaseSessionProfile { @Autowired diff --git a/src/test/java/com/digitalsanctuary/spring/demo/user/profile/session/DemoSessionProfileScopeTest.java b/src/test/java/com/digitalsanctuary/spring/demo/user/profile/session/DemoSessionProfileScopeTest.java new file mode 100644 index 0000000..6da04d2 --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/demo/user/profile/session/DemoSessionProfileScopeTest.java @@ -0,0 +1,52 @@ +package com.digitalsanctuary.spring.demo.user.profile.session; + +import static org.assertj.core.api.Assertions.assertThat; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.aop.support.AopUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.config.BeanDefinition; +import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.web.context.WebApplicationContext; +import com.digitalsanctuary.spring.user.test.annotations.IntegrationTest; + +/** + * Guards against the session profile silently becoming a singleton. + * + * Spring's {@code @Scope} is not inherited, so a subclass of {@code BaseSessionProfile} annotated only with + * {@code @Component} would be one instance shared by every HTTP session, leaking one user's profile to all + * other users. + */ +@IntegrationTest +@DisplayName("DemoSessionProfile Scope Tests") +class DemoSessionProfileScopeTest { + + /** Bean name of the real instance behind the scoped proxy. */ + private static final String SCOPED_TARGET_BEAN_NAME = "scopedTarget.demoSessionProfile"; + + @Autowired + private ApplicationContext applicationContext; + + @Autowired + private DemoSessionProfile demoSessionProfile; + + @Test + @DisplayName("Bean definition is session scoped, not singleton") + void beanDefinitionIsSessionScoped() { + ConfigurableListableBeanFactory beanFactory = ((ConfigurableApplicationContext) applicationContext).getBeanFactory(); + + assertThat(beanFactory.containsBeanDefinition(SCOPED_TARGET_BEAN_NAME)) + .as("DemoSessionProfile must be registered behind a scoped proxy (bean '%s')", SCOPED_TARGET_BEAN_NAME).isTrue(); + + BeanDefinition targetDefinition = beanFactory.getBeanDefinition(SCOPED_TARGET_BEAN_NAME); + assertThat(targetDefinition.getScope()).isEqualTo(WebApplicationContext.SCOPE_SESSION); + } + + @Test + @DisplayName("Injected reference is a scoped proxy, not the target instance") + void injectedReferenceIsAScopedProxy() { + assertThat(AopUtils.isAopProxy(demoSessionProfile)).as("injected DemoSessionProfile must be a scoped proxy").isTrue(); + } +} From 19111445b7cdaa8ba24ca2883b7bc8a6f6456ef8 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Sat, 15 Aug 2026 00:56:14 -0600 Subject: [PATCH 09/26] feat: admin lock/unlock account endpoints The admin actions page posted {email} to /admin/lockAccount and /admin/unlockAccount, but no handler existed in the demo or the framework, so both actions always failed. Add AdminAPIController with both POST endpoints, guarded by ADMIN_PRIVILEGE like the page itself. Lock sets locked and lockedDate; unlock clears both and resets the failed login counter, matching what LoginAttemptService does when a lockout expires. Every outcome returns a JSONResponse body so the page's fetch() can read messages[0]. --- .../demo/controller/AdminAPIController.java | 96 ++++++++++++ .../controller/AdminAPIControllerTest.java | 143 ++++++++++++++++++ 2 files changed, 239 insertions(+) create mode 100644 src/main/java/com/digitalsanctuary/spring/demo/controller/AdminAPIController.java create mode 100644 src/test/java/com/digitalsanctuary/spring/demo/controller/AdminAPIControllerTest.java diff --git a/src/main/java/com/digitalsanctuary/spring/demo/controller/AdminAPIController.java b/src/main/java/com/digitalsanctuary/spring/demo/controller/AdminAPIController.java new file mode 100644 index 0000000..3a8e60b --- /dev/null +++ b/src/main/java/com/digitalsanctuary/spring/demo/controller/AdminAPIController.java @@ -0,0 +1,96 @@ +package com.digitalsanctuary.spring.demo.controller; + +import java.util.Date; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import com.digitalsanctuary.spring.user.persistence.model.User; +import com.digitalsanctuary.spring.user.persistence.repository.UserRepository; +import com.digitalsanctuary.spring.user.util.JSONResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * JSON endpoints behind the admin actions page (templates/admin/actions.html and + * static/js/admin/admin-action.js). All endpoints require ADMIN_PRIVILEGE, the same authority as the page. + * + * Every outcome returns a {@link JSONResponse} body so the page's fetch() can always read messages[0]. + */ +@Slf4j +@RestController +@RequiredArgsConstructor +@RequestMapping("/admin") +public class AdminAPIController { + + private final UserRepository userRepository; + + /** + * Request body for the lock and unlock endpoints. + * + * @param email the email address of the account to act on + */ + public record AccountActionRequest(String email) { + } + + /** + * Locks a user account. A locked user fails authentication until the lockout duration elapses or an admin + * unlocks the account. + * + * @param request the account to lock + * @return 200 on success, 400 when the email is missing, 404 when no user has that email + */ + @PostMapping("/lockAccount") + @PreAuthorize("hasAuthority('ADMIN_PRIVILEGE')") + @Transactional + public ResponseEntity lockAccount(@RequestBody AccountActionRequest request) { + return setLocked(request, true); + } + + /** + * Unlocks a user account and clears its failed login counter, matching what the framework's + * LoginAttemptService does when a lockout expires. + * + * @param request the account to unlock + * @return 200 on success, 400 when the email is missing, 404 when no user has that email + */ + @PostMapping("/unlockAccount") + @PreAuthorize("hasAuthority('ADMIN_PRIVILEGE')") + @Transactional + public ResponseEntity unlockAccount(@RequestBody AccountActionRequest request) { + return setLocked(request, false); + } + + private ResponseEntity setLocked(AccountActionRequest request, boolean locked) { + String email = request.email() != null ? request.email().trim() : ""; + if (email.isEmpty()) { + return response(HttpStatus.BAD_REQUEST, false, "Email is required."); + } + + User user = userRepository.findByEmail(email); + if (user == null) { + log.info("Admin lock/unlock requested for unknown email: {}", email); + return response(HttpStatus.NOT_FOUND, false, "User not found."); + } + + user.setLocked(locked); + if (locked) { + user.setLockedDate(new Date()); + } else { + user.setLockedDate(null); + user.setFailedLoginAttempts(0); + } + userRepository.save(user); + log.info("Admin set locked={} for user: {}", locked, email); + + return response(HttpStatus.OK, true, locked ? "Account locked." : "Account unlocked."); + } + + private ResponseEntity response(HttpStatus status, boolean success, String message) { + return ResponseEntity.status(status).body(JSONResponse.builder().success(success).code(status.value()).message(message).build()); + } +} diff --git a/src/test/java/com/digitalsanctuary/spring/demo/controller/AdminAPIControllerTest.java b/src/test/java/com/digitalsanctuary/spring/demo/controller/AdminAPIControllerTest.java new file mode 100644 index 0000000..f61e215 --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/demo/controller/AdminAPIControllerTest.java @@ -0,0 +1,143 @@ +package com.digitalsanctuary.spring.demo.controller; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +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 java.util.ArrayList; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.web.servlet.MockMvc; +import com.digitalsanctuary.spring.user.persistence.model.User; +import com.digitalsanctuary.spring.user.persistence.repository.UserRepository; +import com.digitalsanctuary.spring.user.test.annotations.IntegrationTest; +import com.digitalsanctuary.spring.user.test.builders.UserTestDataBuilder; +import jakarta.persistence.EntityManager; + +/** + * Covers the admin lock/unlock endpoints that back src/main/resources/static/js/admin/admin-action.js. + */ +@IntegrationTest +@DisplayName("Admin Lock/Unlock API Tests") +class AdminAPIControllerTest { + + private static final String LOCK_URI = "/admin/lockAccount"; + private static final String UNLOCK_URI = "/admin/unlockAccount"; + private static final String TARGET_EMAIL = "admin.action.target@example.com"; + + @Autowired + private MockMvc mockMvc; + + @Autowired + private UserRepository userRepository; + + @Autowired + private EntityManager entityManager; + + @BeforeEach + void setUp() { + User existing = userRepository.findByEmail(TARGET_EMAIL); + if (existing != null) { + userRepository.delete(existing); + entityManager.flush(); + } + } + + /** Persists the target user inside the test transaction so it rolls back cleanly. */ + private User saveTargetUser(UserTestDataBuilder builder) { + User user = builder.withEmail(TARGET_EMAIL).withFirstName("Target").withLastName("User").verified().withId(null).build(); + user.setRoles(new ArrayList<>()); + User saved = userRepository.save(user); + entityManager.flush(); + return saved; + } + + /** Re-reads the target user from the database, bypassing the first level cache. */ + private User reloadTargetUser() { + entityManager.flush(); + entityManager.clear(); + return userRepository.findByEmail(TARGET_EMAIL); + } + + private static String body(String email) { + return "{\"email\":\"" + email + "\"}"; + } + + @Test + @DisplayName("Admin can lock an account") + @WithMockUser(username = "admin@example.com", authorities = {"ADMIN_PRIVILEGE"}) + void adminCanLockAccount() throws Exception { + saveTargetUser(UserTestDataBuilder.aUser().unlocked()); + + mockMvc.perform(post(LOCK_URI).contentType(MediaType.APPLICATION_JSON).content(body(TARGET_EMAIL)).with(csrf())) + .andExpect(status().isOk()).andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.messages[0]").value("Account locked.")); + + User locked = reloadTargetUser(); + assertThat(locked.isLocked()).isTrue(); + assertThat(locked.getLockedDate()).isNotNull(); + } + + @Test + @DisplayName("Admin can unlock an account") + @WithMockUser(username = "admin@example.com", authorities = {"ADMIN_PRIVILEGE"}) + void adminCanUnlockAccount() throws Exception { + saveTargetUser(UserTestDataBuilder.aUser().locked().withFailedLoginAttempts(5)); + + mockMvc.perform(post(UNLOCK_URI).contentType(MediaType.APPLICATION_JSON).content(body(TARGET_EMAIL)).with(csrf())) + .andExpect(status().isOk()).andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.messages[0]").value("Account unlocked.")); + + User unlocked = reloadTargetUser(); + assertThat(unlocked.isLocked()).isFalse(); + assertThat(unlocked.getLockedDate()).isNull(); + assertThat(unlocked.getFailedLoginAttempts()).isZero(); + } + + @Test + @DisplayName("Unknown email returns a not found JSON response") + @WithMockUser(username = "admin@example.com", authorities = {"ADMIN_PRIVILEGE"}) + void unknownEmailReturnsNotFound() throws Exception { + mockMvc.perform(post(LOCK_URI).contentType(MediaType.APPLICATION_JSON).content(body("nobody@example.com")).with(csrf())) + .andExpect(status().isNotFound()).andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.messages[0]").value("User not found.")); + } + + @Test + @DisplayName("Blank email returns a bad request JSON response") + @WithMockUser(username = "admin@example.com", authorities = {"ADMIN_PRIVILEGE"}) + void blankEmailReturnsBadRequest() throws Exception { + mockMvc.perform(post(UNLOCK_URI).contentType(MediaType.APPLICATION_JSON).content(body(" ")).with(csrf())) + .andExpect(status().isBadRequest()).andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.messages[0]").value("Email is required.")); + } + + @Test + @DisplayName("Non-admin gets 403 on lock") + @WithMockUser(username = "user@example.com", authorities = {"LOGIN_PRIVILEGE"}) + void nonAdminCannotLock() throws Exception { + saveTargetUser(UserTestDataBuilder.aUser().unlocked()); + + mockMvc.perform(post(LOCK_URI).contentType(MediaType.APPLICATION_JSON).content(body(TARGET_EMAIL)).with(csrf())) + .andExpect(status().isForbidden()); + + assertThat(reloadTargetUser().isLocked()).isFalse(); + } + + @Test + @DisplayName("Non-admin gets 403 on unlock") + @WithMockUser(username = "user@example.com", authorities = {"LOGIN_PRIVILEGE"}) + void nonAdminCannotUnlock() throws Exception { + saveTargetUser(UserTestDataBuilder.aUser().locked()); + + mockMvc.perform(post(UNLOCK_URI).contentType(MediaType.APPLICATION_JSON).content(body(TARGET_EMAIL)).with(csrf())) + .andExpect(status().isForbidden()); + + assertThat(reloadTargetUser().isLocked()).isTrue(); + } +} From 02bc99dc1ea00e78bcc70920a24f4f9fc0d06fbc Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Sat, 15 Aug 2026 00:56:16 -0600 Subject: [PATCH 10/26] docs: add configuration and development guides --- docs/CONFIGURATION.md | 91 +++++++++++++++++++++++++++++++++++++++ docs/DEVELOPMENT.md | 99 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 190 insertions(+) create mode 100644 docs/CONFIGURATION.md create mode 100644 docs/DEVELOPMENT.md diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md new file mode 100644 index 0000000..6a9b3d5 --- /dev/null +++ b/docs/CONFIGURATION.md @@ -0,0 +1,91 @@ +# Configuration + +Base [`application.yml`](../src/main/resources/application.yml) holds the framework defaults this +demo runs with: mail transport, datasource, session/security settings, role/privilege map, and the +Docker Compose integration used by `bootRun`. Each profile file below overrides a subset of those +values for one scenario (local dev, production, tests, and so on). For the full property reference +(every key the framework recognizes, not just the ones this demo sets), see the framework's +[CONFIG.md](https://github.com/devondragon/SpringUserFramework/blob/main/CONFIG.md). + +## Profiles + +`local`, `dev`, `prd`, `test`, `playwright-test`, and `docker-keycloak` are base profiles: pick one. +`mfa` and `registration-guard` are opt-in add-ons with no base settings of their own; activate one +alongside a base profile by listing both, comma-separated, in `--spring.profiles.active` (Spring +Boot applies later profiles' properties over earlier ones when the same key is set in both). + +| Profile | File | Purpose | What it overrides | Activate | +| --- | --- | --- | --- | --- | +| `local` | [`application-local.yml-example`](../src/main/resources/application-local.yml-example) → `application-local.yml` (gitignored, you create it) | Everyday local development | Debug logging, DevTools restart/LiveReload, seed-data loading, example OAuth2 client registrations, `sendVerificationEmail: false` | `--spring.profiles.active=local` | +| `dev` | [`application-dev.yml`](../src/main/resources/application-dev.yml) | Debug-heavy dev server; also what the Docker demo stack (`compose.yaml`) runs the app container as | Debug logging, insecure session cookie, `audit.flushOnWrite: true` | `--spring.profiles.active=dev` | +| `prd` | [`application-prd.yml`](../src/main/resources/application-prd.yml) | Production | Thymeleaf caching on, `ddl-auto: validate`, env-driven datasource, strict/secure cookies, `WARN` logging, limited actuator exposure, env-driven WebAuthn RP identity and `appUrl`, `requireCanonicalAppUrl: true`, no fallback for the remember-me key | `--spring.profiles.active=prd` | +| `test` | [`src/test/resources/application-test.properties`](../src/test/resources/application-test.properties) | Automated JUnit suite | Per-context isolated H2 database, MFA off, 3-attempt lockout, test-only `unprotectedURIs` | Applied automatically by `./gradlew test` | +| `playwright-test` | [`application-playwright-test.yml`](../src/main/resources/application-playwright-test.yml) | Playwright E2E runs; enables the Test API (`TestDataController`, `TestApiSecurityConfig`, localhost-only) | Disables verification/reset emails, pins `appUrl` to `http://localhost:8080`, `allowInitialPasswordSetWithoutStepUp: true`, MFA off | Combine with a base profile, e.g. `local,playwright-test` (see [TESTING.md](TESTING.md)) | +| `docker-keycloak` | [`application-docker-keycloak.yml-example`](../src/main/resources/application-docker-keycloak.yml-example) → `application-docker-keycloak.yml` (gitignored) | OIDC login against the bundled Keycloak stack | Keycloak OAuth2 client/provider from `DS_SPRING_USER_KEYCLOAK_*` env vars, insecure session cookie, `audit.flushOnWrite: true` | `--spring.profiles.active=docker-keycloak`, normally set for you as `SPRING_PROFILES_ACTIVE` inside `docker-compose-keycloak.yml` | +| `mfa` | [`application-mfa.yml`](../src/main/resources/application-mfa.yml) | Add-on: require PASSWORD + WEBAUTHN | `user.mfa.enabled: true`, unprotects the WebAuthn challenge/enrollment endpoints, `allowInitialPasswordSetWithoutStepUp: true` | Combine with a base profile, e.g. `local,mfa` | +| `registration-guard` | none (no yml; `@Profile("registration-guard")` on [`DomainRegistrationGuard`](../src/main/java/com/digitalsanctuary/spring/demo/registration/DomainRegistrationGuard.java)) | Add-on: domain-restricted registration demo | Activates a `RegistrationGuard` bean that restricts form/passwordless registration to one email domain (`registration.guard.allowed-domain`, default `@example.com`); OAuth2/OIDC registration is unaffected | Combine with a base profile, e.g. `local,registration-guard` | + +See [AUTHENTICATION.md](AUTHENTICATION.md) for the mechanics behind `mfa` +([#mfa](AUTHENTICATION.md#mfa)), `docker-keycloak` ([#keycloak](AUTHENTICATION.md#keycloak)), +WebAuthn passkeys ([#passkeys](AUTHENTICATION.md#passkeys)), and `registration-guard` +([#registration-guard](AUTHENTICATION.md#registration-guard)). + +## Getting started locally + +1. Copy the example file and edit it: `cp src/main/resources/application-local.yml-example src/main/resources/application-local.yml`. It is gitignored, so your edits (and any real credentials) never get committed. +2. This step matters: base `application.yml` leaves `user.registration.sendVerificationEmail: true` (`application.yml:113`) and points `spring.mail.host` at an SES endpoint with no credentials (`application.yml:2-6`), so a fresh clone that skips step 1 starts fine but can never send the verification email a new registration needs, and you cannot log in. `application-local.yml-example` sets `sendVerificationEmail: false` (`application-local.yml-example:128`), so once you copy it, registered accounts are enabled immediately. To exercise the real verification flow instead, set it back to `true` and point `spring.mail.*` at a real SMTP server. The Docker demo stack (`compose.yaml`) disables verification email the same way; see [Mail](#mail). +3. At minimum, set `spring.mail.username`, `spring.mail.password`, and `spring.mail.host` if you want outbound mail to work locally. Set the `spring.security.oauth2.client.registration.*` client IDs/secrets only if you want to exercise social/Keycloak login. +4. Docker Compose integration: base `application.yml` sets `spring.docker.compose.file: compose.dev.yaml` (lines 66-73), so `./gradlew bootRun` under any profile starts a MariaDB 12.2 container (`springuser`/`springuser`, port 3306) automatically and stops it when the app stops. Set `spring.docker.compose.enabled: false` (commented hint right below the `file:` line) to point at a database you manage yourself instead. +5. Seed data: `application-local.yml-example` sets `spring.sql.init.mode: always` and `spring.sql.init.platform: local`, plus `spring.jpa.defer-datasource-initialization: true` (lines 25-30), so every boot under the `local` profile loads [`data-local.sql`](../src/main/resources/data-local.sql) (sample events). The script uses `INSERT IGNORE`, so re-running it on every start is safe. + +## Environment variables + +Required, no fallback: + +| Variable | Meaning | +| --- | --- | +| `REMEMBER_ME_KEY` | Signs remember-me tokens. Base `application.yml` falls back to a random UUID per boot (`application.yml:157`) so the demo runs without it, but `application-prd.yml:57` has no fallback: `prd` fails to start unless this is set. | + +Recognized elsewhere (fall back to a demo default when unset): + +| Variable | Meaning | +| --- | --- | +| `APP_URL` | Canonical base URL for security email links in `prd` (`application-prd.yml:47`, default `https://example.com`). | +| `DATABASE_URL`, `DATABASE_USERNAME`, `DATABASE_PASSWORD` | Production datasource (`application-prd.yml:10-12`). | +| `WEBAUTHN_RP_ID`, `WEBAUTHN_RP_NAME`, `WEBAUTHN_ALLOWED_ORIGINS` | WebAuthn relying-party identity in `prd` (`application-prd.yml:41-43`). | +| `DS_SPRING_USER_KEYCLOAK_CLIENT_ID`, `_CLIENT_SECRET`, `_PROVIDER_ISSUER_URI`, `_PROVIDER_AUTHORIZATION_URI`, `_PROVIDER_TOKEN_URI`, `_PROVIDER_USER_INFO_URI`, `_PROVIDER_JWK_SET_URI` | Keycloak OAuth2 client and provider endpoints for `docker-keycloak` (`application-docker-keycloak.yml-example:29-45`); supplied by [`keycloak.env`](../keycloak.env) when you run `docker-compose-keycloak.yml`. | +| `SELINUX_LABEL` | Suffix on the mailserver's bind-mounted config path in `compose.yaml`/`docker-compose-keycloak.yml`. Unset by default; Docker Compose prints a harmless warning about it. | + +Any framework property can also be set through Spring's relaxed binding (`SCREAMING_SNAKE_CASE` of +the dotted key). The Docker demo stack (`compose.yaml`) does this for the app container: +`SPRING_DATASOURCE_URL`/`_USERNAME`/`_PASSWORD` (→ `spring.datasource.*`), `SPRING_PROFILES_ACTIVE`, +`SPRING_MAIL_HOST`/`_PORT` and the `SPRING_MAIL_PROPERTIES_MAIL_SMTP_*` keys (→ `spring.mail.*`), and +`USER_REGISTRATION_SENDVERIFICATIONEMAIL` (→ `user.registration.sendVerificationEmail`). The same +pattern works for any other key, e.g. `USER_SECURITY_BCRYPTSTRENGTH` for `user.security.bcryptStrength`. + +## Mail + +- `spring.mail.username`, `spring.mail.password`, `spring.mail.host`, `spring.mail.port` (`application.yml:2-6`) configure the SMTP transport used for verification, password-reset, and notification email. The base file's `host` is a placeholder SES endpoint; set real credentials in your profile. +- `user.registration.sendVerificationEmail` (`application.yml:113`) controls whether a new account must click a verification link before it can log in. `false` enables the account immediately at registration. +- The Docker demo stack's `mailserver` service (`compose.yaml`) is a relay only: `SMTP_ONLY: 1` (`compose.yaml:48`) with no route to real inboxes. That stack sets `USER_REGISTRATION_SENDVERIFICATIONEMAIL: "false"` (`compose.yaml:85`) so registered accounts activate immediately instead of waiting on mail nothing will deliver. +- `user.mail.fromAddress` sets the `From` address on outbound mail; it is set per profile (e.g. `application-local.yml-example:141`), not in the base file. + +## Security settings this demo sets + +- **Bcrypt strength**: `user.security.bcryptStrength: 12` (`application.yml:148`); `testHashTime: true` (`application.yml:149`) logs the measured hash time at startup so you can tune it. +- **Failed-login lockout**: `failedLoginAttempts: 10`, `accountLockoutDuration: 30` minutes (`application.yml:146-147`). +- **Session timeout**: `server.servlet.session.timeout: 30m`, with `secure` and `http-only` cookie flags (`application.yml:92-96`). +- **Default action / protected surface**: `defaultAction: deny` with an explicit `unprotectedURIs` allowlist, plus `protectedURIs` and `disableCSRFdURIs` (`application.yml:150-162`). +- **Remember-me**: `rememberMe.enabled: true`, signing key from `REMEMBER_ME_KEY` with a random-UUID fallback (`application.yml:151-158`). + +For OAuth2/OIDC, WebAuthn passkeys, MFA, and the registration guard, see +[AUTHENTICATION.md](AUTHENTICATION.md). + +## Roles and monitoring + +`user.roles.roles-and-privileges` and `user.roles.role-hierarchy` (`application.yml:200-222`) define +this demo's `ROLE_ADMIN` > `ROLE_MANAGER` > `ROLE_USER` hierarchy and the privileges behind each of +the demo's event-management and user-management actions; edit them in place if you add roles or +privileges. `management.newrelic.metrics.export.api-key` / `.account-id` (`application.yml:83-87`) +are unset placeholders: leave them blank to skip New Relic, or fill them in per profile to export +metrics. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md new file mode 100644 index 0000000..2097f7f --- /dev/null +++ b/docs/DEVELOPMENT.md @@ -0,0 +1,99 @@ +# Development + +## Prerequisites + +- JDK 21. [`mise.toml`](../mise.toml) pins `java = "21"` if you use [mise](https://mise.jdx.dev/). +- Docker. `./gradlew bootRun` starts a MariaDB container for you (see below); Docker must be running. +- Node.js, only if you run the Playwright E2E suite; see [TESTING.md](TESTING.md). + +## Running the app + +```bash +./gradlew bootRun --args='--spring.profiles.active=local' +``` + +This is the Spring Boot Gradle plugin's `bootRun` task. Add `--debug-jvm` to attach a debugger on +port 5005. Because the `spring.docker.compose.file: compose.dev.yaml` setting lives in base +`application.yml` (`application.yml:66-73`), `bootRun` always starts a MariaDB 12.2 container +(`springuser`/`springuser`, port 3306) automatically, whichever profile you pass, and stops it when +you stop the app. See [CONFIGURATION.md](CONFIGURATION.md) for what to edit first +(`application-local.yml`) and how to opt out of the auto-started database. + +[`scripts/run.sh`](../scripts/run.sh) is a different path: it runs `./gradlew bootJar`, then +`java -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:6332 -jar +build/libs/ds-spring-user-framework-demo-1.0.1-SNAPSHOT.jar --spring.profiles.active=local` (JDWP +debug agent on port 6332). Running from the packaged jar means `spring-boot-docker-compose` is not +on the classpath (it is `developmentOnly`), so the `spring.docker.compose.file` setting has no effect +here and no database gets started for you. Start one first, either +`docker compose -f compose.dev.yaml up -d` or your own MariaDB on `localhost:3306`, then run +`./scripts/run.sh`. + +Spring Boot DevTools (`runtimeOnly` dependency) restarts the app automatically when a class changes, +in either run mode. + +### LiveReload + +The LiveReload `