diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bf8ef47..2442f82 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,11 +2,11 @@ name: CI/CD Pipeline on: pull_request: - branches: ['main'] + branches: ["main"] push: branches: [develop] tags-ignore: - - 'v*' + - "v*" jobs: PR: @@ -42,8 +42,7 @@ jobs: permissions: contents: write needs: CHECK_MESSAGE - # Only run if the PR commit message contains 'release' - if: ${{ contains(needs.CHECK_MESSAGE.outputs.sms, 'release') }} + if: ${{ contains(needs.CHECK_MESSAGE.outputs.sms, 'release') && github.event.pull_request.head.repo.full_name == github.repository }} steps: - name: Checkout Source Branch uses: actions/checkout@v4 @@ -56,8 +55,8 @@ jobs: - name: Set up JDK 17 uses: actions/setup-java@v4 with: - java-version: '17' - distribution: 'temurin' + java-version: "17" + distribution: "temurin" cache: gradle - name: Grant execute permission for gradlew @@ -67,38 +66,38 @@ jobs: id: versioning run: | GRADLE_FILE="build.gradle" - + # 1. Read current version CURRENT_VERSION=$(grep "version = " $GRADLE_FILE | sed -E "s/.*version = '([0-9]+\.[0-9]+\.[0-9]+)'.*/\1/") echo "Current Version: $CURRENT_VERSION" - + # 2. Parse and Increment IFS='.' read -r -a VERSION_PARTS <<< "$CURRENT_VERSION" MAJOR=${VERSION_PARTS[0]} MINOR=${VERSION_PARTS[1]} PATCH=${VERSION_PARTS[2]} - + PATCH=$((PATCH + 1)) if [ "$PATCH" -gt 99 ]; then PATCH=0 MINOR=$((MINOR + 1)) fi - + NEW_VERSION="$MAJOR.$MINOR.$PATCH" echo "New Version: $NEW_VERSION" echo "version=$NEW_VERSION" >> $GITHUB_OUTPUT - + # 3. Update build.gradle sed -i "s/version = '$CURRENT_VERSION'/version = '$NEW_VERSION'/" $GRADLE_FILE - + # 4. Commit and Tag git config --global user.name "github-actions[bot]" git config --global user.email "github-actions[bot]@users.noreply.github.com" - + git add $GRADLE_FILE git commit -m "Release v$NEW_VERSION [skip ci]" git tag "v$NEW_VERSION" - + # 5. Push changes back to the source branch (develop) # Use HEAD:${{ github.head_ref }} to ensure it pushes to the PR source branch git push origin HEAD:${{ github.head_ref }} @@ -119,17 +118,17 @@ jobs: IMAGE_NAME: flexcodelabs/flextuma run: | echo "Building Docker image for version $VERSION..." - + docker build --target prod \ -t $IMAGE_NAME:$VERSION \ -t $IMAGE_NAME:latest \ . - + echo "Pushing images..." docker push $IMAGE_NAME:$VERSION docker push $IMAGE_NAME:latest - + - name: 🔀 uses: BaharaJr/merge-pr@0.0.1 with: - GITHUB_TOKEN: ${{ secrets.TOKEN }} \ No newline at end of file + GITHUB_TOKEN: ${{ secrets.TOKEN }} diff --git a/README.md b/README.md index 1ed482c..e15c441 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,8 @@ Create a `.env` file in the root directory or export the variables in your shell | `SMS_PRICE_PER_SEGMENT` | ❌ | `20.0` | Price per SMS segment (in TZS) | | `FLEXTUMA_SMS_BEEM_DELIVERY_POLL_INTERVAL_MS` | ❌ | `60000` | Beem delivery-report polling interval in milliseconds | | `FLEXTUMA_SMS_BEEM_DELIVERY_MINIMUM_DELAY_MINUTES` | ❌ | `5` | Minimum delay before the first Beem delivery lookup | +| `FLEXTUMA_ADMIN_SEED_PASSWORD` | ✅ (first boot only) | — | Password for the seeded `admin` account. Only required until that account exists; ignored on later restarts | +| `FLEXTUMA_SYSTEM_SEED_PASSWORD` | ✅ (first boot only) | — | Password for the seeded `SYSTEM` account. Only required until that account exists; ignored on later restarts | ### 3. Build the application @@ -699,7 +701,7 @@ This is enforced in `BaseService.buildTenantSpec()` — all subclass services be ## Data Seeding -On startup, `DataInitializer` runs `DataSeederService.seedSystemData()`, which executes `seed.sql` via JDBC to ensure system-level data (privileges, default roles, system user) is present before the application accepts requests. +On startup, `DataInitializer` runs `DataSeederService.seedSystemData()`, which issues the seeding SQL directly via `JdbcTemplate` to ensure system-level data (privileges, default roles, the `admin` and `SYSTEM` accounts) is present before the application accepts requests. The `admin`/`SYSTEM` accounts are only created on a deployment's first-ever boot and require `FLEXTUMA_ADMIN_SEED_PASSWORD`/`FLEXTUMA_SYSTEM_SEED_PASSWORD` to be set at that point — startup fails fast if either account doesn't exist yet and its password isn't configured. --- diff --git a/build.gradle b/build.gradle index 8efc076..7f14e78 100644 --- a/build.gradle +++ b/build.gradle @@ -8,7 +8,7 @@ plugins { } group = 'com.flexcodelabs' -version = '0.0.72' +version = '0.0.73' description = 'Flextuma App' java { diff --git a/seed.sql b/seed.sql deleted file mode 100644 index eeb07a6..0000000 --- a/seed.sql +++ /dev/null @@ -1,22 +0,0 @@ -INSERT INTO privilege (id, name, value, system, active, created, updated) -VALUES ('5269df21-c8a0-4776-bd89-1015521bc19d', 'Super Admin', 'SUPER_ADMIN', true, true, NOW(), NOW()) -ON CONFLICT (id) DO NOTHING; - -INSERT INTO role (id, name, system, active, created, updated) -VALUES ('6269df23-f8a0-4776-bd89-3015521bc19d', 'Super Admin', true, true, NOW(), NOW()) -ON CONFLICT (id) DO NOTHING; - -INSERT INTO userprivilege (role, privilege) -VALUES ('6269df23-f8a0-4776-bd89-3015521bc19d', '5269df21-c8a0-4776-bd89-1015521bc19d') -ON CONFLICT DO NOTHING; - -INSERT INTO "user" (id, username, name, email, phonenumber, password, type, active, verified, system, created, updated) -VALUES ('6269df23-f8a0-4776-bd89-3015521bc19d', 'admin', 'ADMIN', 'admin@flextuma.com', '123456789', '$2a$10$7P8p.0K1iUInX9O0Xo6S/.8L.y0W5K1f6TqN9Y8I0X.hU5y8kY6u.', 'SYSTEM', true, true, true, NOW(), NOW()) -ON CONFLICT (id) DO NOTHING; - -INSERT INTO "user" (id, username, name, email, phonenumber, password, type, active, verified, system, created, updated) -VALUES ('5269df21-c8a0-4776-bd89-1015521bc19d', 'SYSTEM', 'SYSTEM', 'system@flextuma.com', '0000000000', '$2a$10$7P8p.0K1iUInX9O0Xo6S/.8L.y0W5K1f6TqN9Y8I0X.hU5y8kY6u.', 'SYSTEM', true, true, true, NOW(), NOW()) -ON CONFLICT (id) DO NOTHING; - -INSERT INTO userrole (owner, role) VALUES ('6269df23-f8a0-4776-bd89-3015521bc19d', '6269df23-f8a0-4776-bd89-3015521bc19d') ON CONFLICT DO NOTHING; -INSERT INTO userrole (owner, role) VALUES ('5269df21-c8a0-4776-bd89-1015521bc19d', '6269df23-f8a0-4776-bd89-3015521bc19d') ON CONFLICT DO NOTHING; \ No newline at end of file diff --git a/src/main/java/com/flexcodelabs/flextuma/core/config/DataInitializer.java b/src/main/java/com/flexcodelabs/flextuma/core/config/DataInitializer.java index 2f5df14..572b4f6 100644 --- a/src/main/java/com/flexcodelabs/flextuma/core/config/DataInitializer.java +++ b/src/main/java/com/flexcodelabs/flextuma/core/config/DataInitializer.java @@ -3,6 +3,7 @@ import org.springframework.boot.CommandLineRunner; import org.springframework.stereotype.Component; +import com.flexcodelabs.flextuma.core.exceptions.MissingSeedConfigurationException; import com.flexcodelabs.flextuma.core.services.DataSeederService; import lombok.RequiredArgsConstructor; @@ -23,6 +24,11 @@ public void run(String... args) { log.info("🌱 FLEXTUMA: Calling seeder service..."); seederService.seedSystemData(); log.info("✅ FLEXTUMA: System seeding completed successfully!"); + } catch (MissingSeedConfigurationException e) { + // Unlike other seeding failures below, this must abort startup: continuing would + // either boot with no admin account reachable, or silently skip creating one. + log.error("❌ FLEXTUMA: {}", e.getMessage()); + throw e; } catch (Exception e) { log.error("❌ FLEXTUMA: System seeding failed: {}", e.getMessage(), e); // Don't throw - allow application to continue diff --git a/src/main/java/com/flexcodelabs/flextuma/core/dtos/UsernameAvailabilityDto.java b/src/main/java/com/flexcodelabs/flextuma/core/dtos/UsernameAvailabilityDto.java index 8b15656..1137445 100644 --- a/src/main/java/com/flexcodelabs/flextuma/core/dtos/UsernameAvailabilityDto.java +++ b/src/main/java/com/flexcodelabs/flextuma/core/dtos/UsernameAvailabilityDto.java @@ -3,7 +3,8 @@ import java.util.List; /** Suggestions is empty when {@code available} is true -- there's nothing to suggest an - * alternative to. {@code emailAvailable} is null when no email was passed to the check. */ + * alternative to. {@code emailAvailable}/{@code phoneAvailable} are null when no email/phone + * number was passed to the check. */ public record UsernameAvailabilityDto(String username, boolean available, List suggestions, - Boolean emailAvailable) { + Boolean emailAvailable, Boolean phoneAvailable) { } diff --git a/src/main/java/com/flexcodelabs/flextuma/core/dtos/UsernameAvailabilityRequestDto.java b/src/main/java/com/flexcodelabs/flextuma/core/dtos/UsernameAvailabilityRequestDto.java index c801ab0..6c268dc 100644 --- a/src/main/java/com/flexcodelabs/flextuma/core/dtos/UsernameAvailabilityRequestDto.java +++ b/src/main/java/com/flexcodelabs/flextuma/core/dtos/UsernameAvailabilityRequestDto.java @@ -1,5 +1,6 @@ package com.flexcodelabs.flextuma.core.dtos; -/** Request body for the username-availability check. {@code email} is optional. */ -public record UsernameAvailabilityRequestDto(String username, String email) { +/** Request body for the username-availability check. {@code email} and {@code phoneNumber} are + * optional. */ +public record UsernameAvailabilityRequestDto(String username, String email, String phoneNumber) { } diff --git a/src/main/java/com/flexcodelabs/flextuma/core/exceptions/MissingSeedConfigurationException.java b/src/main/java/com/flexcodelabs/flextuma/core/exceptions/MissingSeedConfigurationException.java new file mode 100644 index 0000000..f16c8a8 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/core/exceptions/MissingSeedConfigurationException.java @@ -0,0 +1,11 @@ +package com.flexcodelabs.flextuma.core.exceptions; + +/** Thrown when a required seed-time secret (e.g. an initial account password) isn't configured. + * Unlike other seeding failures, this must abort startup rather than be logged and swallowed -- + * silently continuing would boot the app with no way to log in, or worse, quietly skip creating + * the account at all. See DataInitializer, which re-throws this one specifically. */ +public class MissingSeedConfigurationException extends RuntimeException { + public MissingSeedConfigurationException(String message) { + super(message); + } +} diff --git a/src/main/java/com/flexcodelabs/flextuma/core/repositories/UserRepository.java b/src/main/java/com/flexcodelabs/flextuma/core/repositories/UserRepository.java index 979dd11..15cd2bc 100644 --- a/src/main/java/com/flexcodelabs/flextuma/core/repositories/UserRepository.java +++ b/src/main/java/com/flexcodelabs/flextuma/core/repositories/UserRepository.java @@ -28,4 +28,6 @@ public interface UserRepository extends JpaRepository, JpaSpecificat boolean existsByUsername(String username); boolean existsByEmail(String email); + + boolean existsByPhoneNumber(String phoneNumber); } \ No newline at end of file diff --git a/src/main/java/com/flexcodelabs/flextuma/core/services/DataSeederService.java b/src/main/java/com/flexcodelabs/flextuma/core/services/DataSeederService.java index 042d78e..5e5963c 100644 --- a/src/main/java/com/flexcodelabs/flextuma/core/services/DataSeederService.java +++ b/src/main/java/com/flexcodelabs/flextuma/core/services/DataSeederService.java @@ -2,11 +2,14 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import com.flexcodelabs.flextuma.core.exceptions.MissingSeedConfigurationException; + import java.nio.charset.StandardCharsets; import java.util.List; import java.util.UUID; @@ -19,6 +22,15 @@ public class DataSeederService { private final JdbcTemplate jdbcTemplate; private final PasswordEncoder passwordEncoder; + // No default on purpose: a fresh deployment must supply these before its first boot, so a + // public checkout of this repo can never ship a working default credential. Once the + // account exists, later boots don't re-check the property (see seedUserIfAbsent). + @Value("${flextuma.admin-seed.password:}") + private String adminSeedPassword; + + @Value("${flextuma.system-seed.password:}") + private String systemSeedPassword; + @Transactional public void seedSystemData() { log.info("🌱 Starting system data seeding..."); @@ -50,10 +62,12 @@ public void seedSystemData() { seedReadPrivileges(); - seedUser(roleId, "admin", "admin@flextuma.com", "Admin123", roleId); + seedUserIfAbsent(roleId, "admin", "admin@flextuma.com", adminSeedPassword, + roleId, "FLEXTUMA_ADMIN_SEED_PASSWORD"); - seedUser(UUID.fromString("7269df24-68a0-4776-bd89-4015521bc19d"), "SYSTEM", - "system@flextuma.com", "system_secret_key", roleId); + seedUserIfAbsent(UUID.fromString("7269df24-68a0-4776-bd89-4015521bc19d"), "SYSTEM", + "system@flextuma.com", systemSeedPassword, roleId, + "FLEXTUMA_SYSTEM_SEED_PASSWORD"); log.info("✅✅✅ System seeding via JDBC completed successfully. ✅✅✅"); } catch (Exception e) { @@ -81,6 +95,25 @@ private void seedReadPrivileges() { }); } + /** Only the first-ever boot for a given userId needs the password: once the row exists, + * later restarts skip straight past the property check (ON CONFLICT already makes the + * insert itself idempotent, but checking here avoids demanding the env var forever). */ + private void seedUserIfAbsent(UUID userId, String username, String email, String pass, UUID roleId, + String requiredEnvVarName) { + Boolean exists = jdbcTemplate.queryForObject( + "SELECT EXISTS(SELECT 1 FROM \"user\" WHERE id = ?)", Boolean.class, userId); + if (Boolean.TRUE.equals(exists)) { + log.info("👤 User {} already seeded, skipping.", username); + return; + } + if (pass == null || pass.isBlank()) { + throw new MissingSeedConfigurationException( + requiredEnvVarName + " must be set before the initial '" + username + + "' account can be created."); + } + seedUser(userId, username, email, pass, roleId); + } + private void seedUser(UUID userId, String username, String email, String pass, UUID roleId) { log.info("👤 Seeding user: {} ({})", username, email); String hashedPass = passwordEncoder.encode(pass); diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/auth/controllers/PublicUserController.java b/src/main/java/com/flexcodelabs/flextuma/modules/auth/controllers/PublicUserController.java index bc5bc63..25b5e7d 100644 --- a/src/main/java/com/flexcodelabs/flextuma/modules/auth/controllers/PublicUserController.java +++ b/src/main/java/com/flexcodelabs/flextuma/modules/auth/controllers/PublicUserController.java @@ -38,6 +38,7 @@ public class PublicUserController { public ResponseEntity checkUsernameAvailability( @RequestBody UsernameAvailabilityRequestDto request, HttpServletRequest httpRequest) { rateLimitService.checkAndRecord(USERNAME_AVAILABILITY_BUCKET, httpRequest, maxRequestsPerWindow, windowSeconds); - return ResponseEntity.ok(userService.checkUsernameAvailability(request.username(), request.email())); + return ResponseEntity.ok(userService.checkUsernameAvailability( + request.username(), request.email(), request.phoneNumber())); } } diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/auth/services/UserService.java b/src/main/java/com/flexcodelabs/flextuma/modules/auth/services/UserService.java index 9d138f8..211ff14 100644 --- a/src/main/java/com/flexcodelabs/flextuma/modules/auth/services/UserService.java +++ b/src/main/java/com/flexcodelabs/flextuma/modules/auth/services/UserService.java @@ -151,7 +151,8 @@ public User findByUsername(String username) { * validation. When taken, suggests alternatives so the caller isn't left to guess one -- * built from the email's local part (before '@') when an email was passed and it's still * free, since that's more likely to read as "theirs" than a random suffix. */ - public UsernameAvailabilityDto checkUsernameAvailability(String rawUsername, String rawEmail) { + public UsernameAvailabilityDto checkUsernameAvailability(String rawUsername, String rawEmail, + String rawPhoneNumber) { String username = rawUsername == null ? "" : rawUsername.trim(); if (username.isBlank()) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Username is required"); @@ -171,6 +172,9 @@ public UsernameAvailabilityDto checkUsernameAvailability(String rawUsername, Str } } + String phoneNumber = rawPhoneNumber == null ? "" : rawPhoneNumber.trim(); + Boolean phoneAvailable = phoneNumber.isBlank() ? null : !repository.existsByPhoneNumber(phoneNumber); + boolean available = !repository.existsByUsername(username); List suggestions; if (available) { @@ -181,7 +185,7 @@ public UsernameAvailabilityDto checkUsernameAvailability(String rawUsername, Str : username; suggestions = generateAvailableUsernames(suggestionBase); } - return new UsernameAvailabilityDto(username, available, suggestions, emailAvailable); + return new UsernameAvailabilityDto(username, available, suggestions, emailAvailable, phoneAvailable); } private List generateAvailableUsernames(String requested) { @@ -214,6 +218,12 @@ public User register(RegisterDto request) { "User with email " + request.getEmail() + " already exists"); }); } + if (request.getPhoneNumber() != null && !request.getPhoneNumber().isBlank()) { + repository.findByPhoneNumber(request.getPhoneNumber()).ifPresent(u -> { + throw new ResponseStatusException(HttpStatus.CONFLICT, + "User with phone number " + request.getPhoneNumber() + " already exists"); + }); + } User user = new User(); user.setName(request.getName()); diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 677892f..808a456 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -65,6 +65,12 @@ flextuma.rate-limit.username-availability.window-seconds=${FLEXTUMA_RATE_LIMIT_U # Base64-encoded 32-byte AES key. Required before connector credentials can be created or updated. flextuma.connector-secrets.encryption-key=${FLEXTUMA_CONNECTOR_ENCRYPTION_KEY:} + +# Passwords for the seeded 'admin' and 'SYSTEM' accounts. No default on purpose: required only +# for a deployment's first-ever boot (DataSeederService skips accounts that already exist), so +# startup fails fast rather than silently creating a well-known default credential. +flextuma.admin-seed.password=${FLEXTUMA_ADMIN_SEED_PASSWORD:} +flextuma.system-seed.password=${FLEXTUMA_SYSTEM_SEED_PASSWORD:} # Set to 0 only for a deliberately unlimited plan. This cap applies before a shared system-connector message is charged. flextuma.system-connectors.daily-message-limit-per-user=${FLEXTUMA_SYSTEM_CONNECTORS_DAILY_MESSAGE_LIMIT_PER_USER:1000} diff --git a/src/test/java/com/flexcodelabs/flextuma/core/config/DataInitializerTest.java b/src/test/java/com/flexcodelabs/flextuma/core/config/DataInitializerTest.java new file mode 100644 index 0000000..f25993f --- /dev/null +++ b/src/test/java/com/flexcodelabs/flextuma/core/config/DataInitializerTest.java @@ -0,0 +1,38 @@ +package com.flexcodelabs.flextuma.core.config; + +import com.flexcodelabs.flextuma.core.exceptions.MissingSeedConfigurationException; +import com.flexcodelabs.flextuma.core.services.DataSeederService; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.doThrow; + +@ExtendWith(MockitoExtension.class) +class DataInitializerTest { + + @Mock + private DataSeederService seederService; + + @Test + void run_shouldPropagate_whenSeedConfigurationIsMissing() { + doThrow(new MissingSeedConfigurationException("FLEXTUMA_ADMIN_SEED_PASSWORD must be set")) + .when(seederService).seedSystemData(); + + DataInitializer initializer = new DataInitializer(seederService); + + assertThrows(MissingSeedConfigurationException.class, () -> initializer.run()); + } + + @Test + void run_shouldSwallow_whenSeedingFailsForAnyOtherReason() { + doThrow(new RuntimeException("transient DB issue")).when(seederService).seedSystemData(); + + DataInitializer initializer = new DataInitializer(seederService); + + assertDoesNotThrow(() -> initializer.run()); + } +} diff --git a/src/test/java/com/flexcodelabs/flextuma/core/services/DataSeederServiceTest.java b/src/test/java/com/flexcodelabs/flextuma/core/services/DataSeederServiceTest.java new file mode 100644 index 0000000..5a70cff --- /dev/null +++ b/src/test/java/com/flexcodelabs/flextuma/core/services/DataSeederServiceTest.java @@ -0,0 +1,91 @@ +package com.flexcodelabs.flextuma.core.services; + +import com.flexcodelabs.flextuma.core.exceptions.MissingSeedConfigurationException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class DataSeederServiceTest { + + private static final UUID ADMIN_ID = UUID.fromString("6269df23-f8a0-4776-bd89-3015521bc19d"); + private static final UUID SYSTEM_ID = UUID.fromString("7269df24-68a0-4776-bd89-4015521bc19d"); + + @Mock + private JdbcTemplate jdbcTemplate; + + @Mock + private PasswordEncoder passwordEncoder; + + private DataSeederService service; + + @BeforeEach + void setUp() { + service = new DataSeederService(jdbcTemplate, passwordEncoder); + } + + private void stubAccountExists(UUID userId, boolean exists) { + when(jdbcTemplate.queryForObject(anyString(), eq(Boolean.class), eq(userId))).thenReturn(exists); + } + + @Test + void seedSystemData_shouldThrow_whenAdminMissingAndNoPasswordConfigured() { + stubAccountExists(ADMIN_ID, false); + + var ex = assertThrows(MissingSeedConfigurationException.class, () -> service.seedSystemData()); + + assertTrue(ex.getMessage().contains("FLEXTUMA_ADMIN_SEED_PASSWORD")); + verify(passwordEncoder, never()).encode(any()); + } + + @Test + void seedSystemData_shouldThrow_whenSystemMissingAndNoPasswordConfigured() { + ReflectionTestUtils.setField(service, "adminSeedPassword", "Sup3r$ecret!"); + stubAccountExists(ADMIN_ID, true); + stubAccountExists(SYSTEM_ID, false); + + var ex = assertThrows(MissingSeedConfigurationException.class, () -> service.seedSystemData()); + + assertTrue(ex.getMessage().contains("FLEXTUMA_SYSTEM_SEED_PASSWORD")); + } + + @Test + void seedSystemData_shouldSkipBothAccounts_whenAlreadySeeded() { + stubAccountExists(ADMIN_ID, true); + stubAccountExists(SYSTEM_ID, true); + + service.seedSystemData(); + + verify(passwordEncoder, never()).encode(any()); + } + + @Test + void seedSystemData_shouldCreateAdmin_whenPasswordConfiguredAndAccountAbsent() { + ReflectionTestUtils.setField(service, "adminSeedPassword", "Sup3r$ecret!"); + ReflectionTestUtils.setField(service, "systemSeedPassword", "AnotherSecret!"); + stubAccountExists(ADMIN_ID, false); + stubAccountExists(SYSTEM_ID, false); + when(passwordEncoder.encode(anyString())).thenReturn("hashed"); + + service.seedSystemData(); + + verify(passwordEncoder).encode("Sup3r$ecret!"); + verify(passwordEncoder).encode("AnotherSecret!"); + } +} diff --git a/src/test/java/com/flexcodelabs/flextuma/modules/auth/controllers/PublicUserControllerTest.java b/src/test/java/com/flexcodelabs/flextuma/modules/auth/controllers/PublicUserControllerTest.java index be63a80..f16330d 100644 --- a/src/test/java/com/flexcodelabs/flextuma/modules/auth/controllers/PublicUserControllerTest.java +++ b/src/test/java/com/flexcodelabs/flextuma/modules/auth/controllers/PublicUserControllerTest.java @@ -49,8 +49,8 @@ void setUp() { @Test void checkUsernameAvailability_shouldRecordAgainstRateLimiter_beforeReturningResult() throws Exception { - when(userService.checkUsernameAvailability("jane", null)) - .thenReturn(new UsernameAvailabilityDto("jane", true, List.of(), null)); + when(userService.checkUsernameAvailability("jane", null, null)) + .thenReturn(new UsernameAvailabilityDto("jane", true, List.of(), null, null)); mockMvc.perform(post("/api/public/users/username-availability") .contentType(MediaType.APPLICATION_JSON) @@ -71,6 +71,6 @@ void checkUsernameAvailability_shouldRejectWithTooManyRequests_whenRateLimited() .content("{\"username\":\"jane\"}")) .andExpect(status().isTooManyRequests()); - verify(userService, never()).checkUsernameAvailability(any(), any()); + verify(userService, never()).checkUsernameAvailability(any(), any(), any()); } } diff --git a/src/test/java/com/flexcodelabs/flextuma/modules/auth/services/UserServiceTest.java b/src/test/java/com/flexcodelabs/flextuma/modules/auth/services/UserServiceTest.java index d925c05..b7c5ce0 100644 --- a/src/test/java/com/flexcodelabs/flextuma/modules/auth/services/UserServiceTest.java +++ b/src/test/java/com/flexcodelabs/flextuma/modules/auth/services/UserServiceTest.java @@ -137,11 +137,12 @@ void login_shouldNotLookUpByUsername_whenIdentifierLooksLikeEmailButUnknown() { void checkUsernameAvailability_shouldReportAvailable_withNoSuggestions_whenUsernameIsFree() { when(repository.existsByUsername("newname")).thenReturn(false); - var result = service.checkUsernameAvailability("newname", null); + var result = service.checkUsernameAvailability("newname", null, null); assertTrue(result.available()); assertEquals(List.of(), result.suggestions()); assertNull(result.emailAvailable()); + assertNull(result.phoneAvailable()); } @Test @@ -150,7 +151,7 @@ void checkUsernameAvailability_shouldSuggestAlternatives_whenUsernameIsTaken() { when(repository.existsByUsername(argThat(candidate -> candidate.startsWith("jane") && !candidate.equals("jane")))) .thenReturn(false); - var result = service.checkUsernameAvailability("jane", null); + var result = service.checkUsernameAvailability("jane", null, null); assertFalse(result.available()); assertEquals(5, result.suggestions().size()); @@ -165,7 +166,7 @@ void checkUsernameAvailability_shouldSuggestFromEmailLocalPart_whenUsernameTaken when(repository.existsByUsername(argThat(candidate -> candidate.startsWith("janedoe") && !candidate.equals("janedoe")))) .thenReturn(false); - var result = service.checkUsernameAvailability("jane", "jane.doe@example.com"); + var result = service.checkUsernameAvailability("jane", "jane.doe@example.com", null); assertFalse(result.available()); assertTrue(result.emailAvailable()); @@ -180,16 +181,27 @@ void checkUsernameAvailability_shouldSuggestFromUsername_whenUsernameAndEmailBot when(repository.existsByUsername(argThat(candidate -> candidate.startsWith("jane") && !candidate.equals("jane")))) .thenReturn(false); - var result = service.checkUsernameAvailability("jane", "jane@example.com"); + var result = service.checkUsernameAvailability("jane", "jane@example.com", null); assertFalse(result.available()); assertFalse(result.emailAvailable()); assertTrue(result.suggestions().stream().allMatch(s -> s.startsWith("jane"))); } + @Test + void checkUsernameAvailability_shouldReportPhoneAvailability_whenPhoneNumberPassed() { + when(repository.existsByUsername("jane")).thenReturn(false); + when(repository.existsByPhoneNumber("+255700000000")).thenReturn(true); + + var result = service.checkUsernameAvailability("jane", null, "+255700000000"); + + assertTrue(result.available()); + assertFalse(result.phoneAvailable()); + } + @Test void checkUsernameAvailability_shouldThrow_whenUsernameBlank() { - assertThrows(ResponseStatusException.class, () -> service.checkUsernameAvailability(" ", null)); + assertThrows(ResponseStatusException.class, () -> service.checkUsernameAvailability(" ", null, null)); } @Test