diff --git a/build.gradle b/build.gradle index 7046df3..ec8667a 100644 --- a/build.gradle +++ b/build.gradle @@ -8,7 +8,7 @@ plugins { } group = 'com.flexcodelabs' -version = '0.0.36' +version = '0.0.37' description = 'Flextuma App' java { diff --git a/docs/deployment.md b/docs/deployment.md new file mode 100644 index 0000000..7caa789 --- /dev/null +++ b/docs/deployment.md @@ -0,0 +1,73 @@ +# Flextuma deployment guide + +This guide describes a production deployment of the Flextuma Spring Boot service. Flextuma is stateful only through PostgreSQL and Redis: the application containers can be replicated once those services are shared and durable. + +## Production topology + +Place a TLS-terminating reverse proxy or load balancer in front of one or more application containers. PostgreSQL must have backups and a tested restore procedure. Redis is required for HTTP sessions and rate-limit state; run it with persistence and high availability appropriate to the availability target. + +``` +Internet -> TLS proxy / WAF -> Flextuma app replicas -> PostgreSQL + | -> Redis + -> SMS providers / tenant APIs +``` + +The proxy must pass `Host`, `X-Forwarded-For`, and `X-Forwarded-Proto`. Make the app reachable only from the proxy, and make PostgreSQL/Redis private to the application network. Permit egress only to approved SMS providers and customer APIs. + +## Required configuration + +Provide secrets through the platform secret manager, never in the image, repository, or `compose.yaml`. These values are consumed by the current application configuration. + +| Setting | Required | Production guidance | +| --- | --- | --- | +| `SPRING_DATASOURCE_URL` | Yes | PostgreSQL JDBC URL, with TLS when supported by the provider. | +| `SPRING_DATASOURCE_USERNAME` / `SPRING_DATASOURCE_PASSWORD` | Yes | Dedicated least-privilege database account. | +| `SPRING_DATA_REDIS_HOST` / `SPRING_DATA_REDIS_PORT` | Yes | Shared Redis service; protect with network controls and authentication/TLS where available. | +| `SPRING_JPA_HIBERNATE_DDL_AUTO` | Yes | Set to `validate`; do not use the repository default `update` in production. | +| `HIKARI_MAX_POOL`, `HIKARI_MIN_IDLE` | Recommended | Size across all replicas below PostgreSQL’s connection limit. | +| `SESSION_TIMEOUT` | Recommended | Session lifetime, e.g. `30m`. | +| `SMS_PRICE_PER_SEGMENT` | Yes | Decimal cost used for wallet accounting; confirm the business unit and currency. | +| `FLEXTUMA_WEBHOOKS_SMS_SHARED_SECRET` | Yes, for DLRs | Shared callback secret supplied by providers in `X-Flextuma-Webhook-Secret`; use a high-entropy secret from the secret manager. | +| `APP_FRONTEND_DIRECTORY` | If serving UI | Read-only directory containing `index.html` and assets. | +| `APP_UPLOAD_DIRECTORY` | If app uploads are enabled | Durable, access-controlled storage; `/tmp` loses uploads on restart. | +| `LOG_MIN_LEVEL`, `LOG_RETENTION_DAYS` | Recommended | Tune for operating requirements; database log retention has storage impact. | +| `flextuma.auth.*`, `flextuma.verification.*` | Recommended | Explicitly configure authentication attempt, verification, and expiry policy. | + +For secure session cookies behind HTTPS, explicitly configure the session cookie domain/path and secure attributes at the deployment layer and test cross-site login behavior. The `AuthCookieProperties` record exists, but the active cookie serializer currently hard-codes an HttpOnly, `SameSite=Lax` cookie and does not apply all of those properties. + +## Build and run + +Build the immutable production image using the `prod` target: + +```bash +docker build --target prod -t registry.example.com/flextuma:VERSION . +docker push registry.example.com/flextuma:VERSION +``` + +Deploy that image with the required environment above. The committed `compose.yaml` is explicitly a development setup: it selects the `dev` target, enables DevTools/restart behavior, bind-mounts source/classes/client files, uses Hibernate `update`, and depends on an external `local-docker-network`. Do not promote it unchanged. + +## Release procedure + +1. Run the Gradle test suite and build the WAR/image in CI. +2. Scan the image and dependency tree; record the image digest. +3. Take/verify a PostgreSQL backup and apply versioned database migrations before rollout. The repository currently has no migration tool configured; introduce Flyway or Liquibase before the first managed production release. +4. Deploy one canary replica, validate login, a PAT-authenticated API call, Redis session continuity, a test SMS, and provider callback receipt. +5. Roll out remaining replicas only after the canary passes. Monitor 5xx responses, database connections, Redis availability, pending/failed SMS logs, and callback mismatch warnings. +6. Keep the preceding image digest available for rollback. A rollback must be compatible with the database schema; this is why versioned, backward-compatible migrations are essential. + +## Health, backups, and operations + +No Actuator health endpoint is currently included. Until one is added, use a platform TCP check plus a protected, lightweight authenticated API check; do not treat `GET /` as an application dependency check. Add Spring Boot Actuator and separate liveness/readiness probes before relying on automated replacement or horizontal scaling. + +Back up PostgreSQL (including point-in-time recovery if available), test restores regularly, and monitor storage growth for `smslog`, wallet transactions, and database-backed system logs. Redis loss invalidates active sessions and rate-limit buckets, so document that operational effect and choose persistence accordingly. + +Rotate database, Redis, provider, tenant-API, and PAT credentials. Provider connector secrets are stored in the application database; database encryption/backups and access controls therefore fall within the secret-management boundary. + +## Pre-launch checklist + +- HTTPS, HSTS, proxy headers, WAF/rate limits, and restricted network paths are in place. +- Production configuration does not enable DevTools or Hibernate schema update. +- Database migrations, backup/restore, monitoring, alerting, and on-call ownership are verified. +- Frontend/upload storage is durable where required and has a retention policy. +- SMS sender IDs, provider account limits, wallet price, and DLR callbacks have been tested in the production-like environment. +- The high-priority items in [integration and gap analysis](third-party-integration.md#implementation-gaps-and-recommendations) are resolved or formally accepted. diff --git a/docs/third-party-integration.md b/docs/third-party-integration.md new file mode 100644 index 0000000..bdfe581 --- /dev/null +++ b/docs/third-party-integration.md @@ -0,0 +1,97 @@ +# Flextuma third-party integration guide and gap analysis + +## Integration surfaces + +Flextuma currently integrates with: + +| Party | Direction | Mechanism | +| --- | --- | --- | +| BEEM SMS | Outbound SMS; intended inbound delivery reports | JSON over HTTPS using provider credentials. | +| NextSMS | Outbound SMS; intended inbound delivery reports | JSON over HTTPS using Basic authentication. | +| Tenant/customer data API | Outbound recipient lookup and member hydration | Configurable GET requests plus JSONPath field mapping. | +| Client/automation | Inbound API requests | Session cookie or personal access token (PAT) in `X-API-KEY`. | + +There is no OpenAPI document or API version prefix. Treat the current API as an internal integration contract and pin integrations to an application release until a versioning policy is introduced. + +## API authentication for clients + +Create a Personal Access Token through the authenticated CRUD endpoint `POST /api/tokens`. A newly generated raw token is exposed through the entity’s transient `rawToken` field, so capture it at creation time; only its SHA-256 hash is persisted. Send it on every automation request: + +```http +X-API-KEY: ft_ +``` + +PATs act as the owning user and inherit that user’s privileges. Use one token per integration, give it an expiry, rotate it, and disable/delete it when no longer needed. Do not put PATs in browser code, query strings, logs, or support tickets. + +All API paths other than login, registration, and frontend assets require authentication under the active security configuration. In particular, there is no separate, functioning API-key scheme for anonymous webhooks despite the `flextuma.auth.api-key-endpoints` configuration property. + +## SMS provider setup + +Create an SMS connector using `POST /api/connectors` with the provider string, provider endpoint, credentials, and sender ID. The provider names implemented by the service are `BEEM` and `NEXT` (case-insensitive at send time). Connector key and secret fields are write-only/masked in API responses; retain the original values in your secret manager. + +### BEEM + +The BEEM adapter sends a JSON request with `source_addr`, `message`, `schedule_time`, `encoding`, and a one-item `recipients` list. It supplies credentials both as `api_key`/`secret_key` headers and HTTP Basic authentication. Configure the provider endpoint and sender ID supplied by BEEM. Confirm the expected response contains an actual provider message ID before enabling delivery reports. + +### NextSMS + +The NextSMS adapter sends `{ "from", "to", "text" }` as JSON and uses HTTP Basic authentication (`key:secret`). It records `messages[0].messageId` when provided. Configure the exact NextSMS endpoint and approved sender ID from the provider account. + +### Delivery reports (DLRs) + +The implemented route is: + +```http +POST https:///api/webhooks/BEEM +POST https:///api/webhooks/NEXT +Content-Type: application/json +``` + +BEEM parser expects `messageID` and `status`; Next parser expects `message_id` (or `messageId`) and `status`. The documented source comment incorrectly describes `/api/webhooks/sms/{provider}/dlr`; that route does not exist. + +DLR routes are public only for the one-segment callback path and require the `X-Flextuma-Webhook-Secret` header. Set `FLEXTUMA_WEBHOOKS_SMS_SHARED_SECRET` from the deployment secret manager and configure the same high-entropy value at the provider. Requests with a missing/incorrect secret are rejected. The callback now correlates using `providerMessageId`; verify the provider’s response/callback message-ID shape in staging before enabling it. + +## Tenant/customer data API + +An administrator can create a `ConnectorConfig` via `/api/connectorConfigs`. It supports `NONE`, `BASIC`, `BEARER`, and `API_KEY` authentication and maps response fields with JSONPath expressions. + +- `url` is the remote service base URL. +- `endpoint` is used for a member lookup, replacing `{id}` with the member ID. +- `search` is used for recipient lookup; Flextuma appends caller-supplied query parameters. +- `mappings` maps JSONPath source values to Flextuma keys such as `phoneNumber`, `name`, and template variables. + +The search response must be a top-level JSON array. Example mapping: + +```json +[ + {"systemKey":"phoneNumber", "jsonPath":"$.phone"}, + {"systemKey":"firstName", "jsonPath":"$.profile.first_name"} +] +``` + +The trigger endpoint is `POST /api/webhooks/{connector-config-uuid}/sms`; it fetches recipients from the configured tenant API and queues SMS. It requires normal Flextuma authentication. Send either a template code or a raw message body, together with the provider and optional search filter, according to the controller’s `DispatchRequest` contract. + +## Implementation gaps and recommendations + +These are code-observed findings as of this repository revision, ordered by impact. + +| Priority | Finding | Impact and recommended action | +| --- | --- | --- | +| Resolved | DLR endpoint authentication and lookup were incompatible with provider callbacks. | The callback path is now narrowly public, requires a constant-time compared shared secret, and correlates with `providerMessageId`. Upgrade next to provider-specific HMAC signatures, timestamp/replay controls, and IP allowlists where supported. | +| Resolved | BEEM did not retain its response message ID. | The adapter now reads `message_id` and saves it as `providerMessageId`; validate the exact live provider schema during staging. | +| Resolved | Generic single-record read, update, and delete skipped the tenant specification. | These operations now use the tenant-scoped specification. Maintain cross-tenant authorization tests as new endpoints are added. | +| Resolved | Raw dispatch used `content` while the queue required `message`. | The trigger now maps its request content to the required queue field. | +| Resolved | PAT authentication ignored a token’s `active` flag. | Inactive tokens are now rejected. | +| Resolved | Tenant API-key authentication sent the wrong stored value. | `API_KEY` connector authentication now sends `apiKey` in `X-API-KEY`. | +| High | API-driven recipient hydration accepts arbitrary stored URLs and caller-controlled query filters without egress controls, timeouts, size limits, or pagination. | Creates SSRF, resource exhaustion, and unintended data-exposure risk. Enforce HTTPS/host allowlists, block private/link-local ranges, set connect/read timeouts and response limits, validate filters, paginate, and audit access. | +| Partially resolved | SMS and campaign workers now use atomic conditional status updates to claim work. | This prevents concurrent replicas from claiming the same PENDING/SCHEDULED row. Add provider idempotency keys and a lease/recovery policy for rows left `PROCESSING` after process failure. | +| High | Campaign dispatch catches errors but can leave campaigns in `PROCESSING`; it also completes after per-recipient debit failures without an explicit partial-failure result. | Operators cannot reliably recover or reconcile campaigns. Model failed/partial states, persist per-recipient outcomes, and alert on stuck campaigns. | +| Medium | Production deployment defaults are unsafe: development Compose, Hibernate `update`, DevTools, mutable bind mounts, `/tmp` uploads, and no health endpoint/migration framework. | Releases are not reproducible or safely observable. Follow [the deployment guide](deployment.md) and add Actuator plus Flyway/Liquibase. | +| Medium | Global CSRF is disabled while cookie sessions are used. | Browser-authenticated write endpoints are exposed to CSRF risk. Enable CSRF protection for session flows, or separate browser/session and token API security models. | +| Medium | Security/operability controls are incomplete: no request timeout for legacy `RestTemplate`, no circuit breaker, no outbound provider rate/concurrency control, no OpenAPI contract, and limited metrics. | Failures are harder to contain, diagnose, and integrate against. Add timeouts, retries with jitter, circuit breaking, metrics/alerts, and a versioned OpenAPI specification. | +| Medium | Sensitive values are stored in database connector records and masked only at JSON serialization. | Database readers/backups may expose third-party credentials. Encrypt at rest with managed keys or reference a secret manager; define rotation and audit procedures. | +| Low | `POST /api/webhooks/{id}/sms` is named as a webhook but is an authenticated dispatch command. | The naming invites unsafe exposure/misconfiguration. Move it under an authenticated integrations/dispatch namespace and document its authorization scope. | + +## Minimum acceptance tests + +Before enabling any external party in production, automate these tests: valid and invalid PAT authentication; provider credential rejection; one successful send and one provider failure; idempotent retry behavior; a signed DLR that changes the correct SMS log; malformed/duplicate/out-of-order DLRs; tenant API timeout/5xx/oversize response; recipient pagination; wallet debit/refund reconciliation; and authorization isolation between organisations. diff --git a/src/main/java/com/flexcodelabs/flextuma/core/helpers/DynamicFetchSpecification.java b/src/main/java/com/flexcodelabs/flextuma/core/helpers/DynamicFetchSpecification.java index b566f71..7ec31f7 100644 --- a/src/main/java/com/flexcodelabs/flextuma/core/helpers/DynamicFetchSpecification.java +++ b/src/main/java/com/flexcodelabs/flextuma/core/helpers/DynamicFetchSpecification.java @@ -57,14 +57,14 @@ private void applyFetch(Root root, String path) { for (String part : parts) { Attribute attribute = getAttribute(currentType, part); - if (attribute != null && attribute.isAssociation()) { - current = SafeFetch.fetch(current, part); - currentType = getTargetType(attribute); - } else { - currentType = null; - } - if (currentType == null) { + // Skip if attribute doesn't exist, is not an association, or is a collection + // (collections are skipped to avoid in-memory pagination issues) + // Also break if we can't continue processing further after fetch + if (attribute == null || !attribute.isAssociation() || + attribute instanceof jakarta.persistence.metamodel.PluralAttribute || + (current = SafeFetch.fetch(current, part)) == null || + (currentType = getTargetType(attribute)) == null) { break; } } diff --git a/src/main/java/com/flexcodelabs/flextuma/core/helpers/TenantAwareSpecification.java b/src/main/java/com/flexcodelabs/flextuma/core/helpers/TenantAwareSpecification.java index 7f8eaa5..86177bd 100644 --- a/src/main/java/com/flexcodelabs/flextuma/core/helpers/TenantAwareSpecification.java +++ b/src/main/java/com/flexcodelabs/flextuma/core/helpers/TenantAwareSpecification.java @@ -14,7 +14,7 @@ /** * A JPA Specification that mirrors the Node.js getWhere pattern: * - * - SUPER_ADMIN / ALL authority → no restriction (sees everything) + * - SUPER_ADMIN authority → no restriction (sees everything) * - User with an organisation → sees resources created by anyone in the same * org OR by themselves * - User without an organisation → sees only resources they created @@ -30,7 +30,7 @@ public class TenantAwareSpecification implements Specifica private static final String CREATED_BY = "createdBy"; private static final String ORGANISATION = "organisation"; - private static final Set BYPASS_AUTHORITIES = Set.of("ALL", "SUPER_ADMIN"); + private static final Set BYPASS_AUTHORITIES = Set.of("SUPER_ADMIN"); private final transient User currentUser; private final transient Set userAuthorities; diff --git a/src/main/java/com/flexcodelabs/flextuma/core/repositories/SmsCampaignRepository.java b/src/main/java/com/flexcodelabs/flextuma/core/repositories/SmsCampaignRepository.java index 57a809a..c73e05c 100644 --- a/src/main/java/com/flexcodelabs/flextuma/core/repositories/SmsCampaignRepository.java +++ b/src/main/java/com/flexcodelabs/flextuma/core/repositories/SmsCampaignRepository.java @@ -5,6 +5,7 @@ import com.flexcodelabs.flextuma.core.enums.SmsCampaignStatus; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.Query; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; @@ -23,6 +24,12 @@ List findDueCampaigns( @Param("now") LocalDateTime now, Pageable pageable); + @Modifying + @Query("UPDATE SmsCampaign c SET c.status = :processing WHERE c.id = :id AND c.status = :scheduled") + int claimScheduledCampaign(@Param("id") UUID id, + @Param("scheduled") SmsCampaignStatus scheduled, + @Param("processing") SmsCampaignStatus processing); + long countByCreatedByAndStatusIn(User user, Collection statuses); long countByStatusIn(Collection statuses); diff --git a/src/main/java/com/flexcodelabs/flextuma/core/repositories/SmsLogRepository.java b/src/main/java/com/flexcodelabs/flextuma/core/repositories/SmsLogRepository.java index efe2ca3..fe16f1e 100644 --- a/src/main/java/com/flexcodelabs/flextuma/core/repositories/SmsLogRepository.java +++ b/src/main/java/com/flexcodelabs/flextuma/core/repositories/SmsLogRepository.java @@ -10,6 +10,7 @@ import org.springframework.data.jpa.repository.JpaSpecificationExecutor; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Repository; +import org.springframework.data.jpa.repository.Modifying; import com.flexcodelabs.flextuma.core.entities.auth.User; import com.flexcodelabs.flextuma.core.entities.sms.SmsLog; @@ -27,7 +28,13 @@ List findDueMessages( @org.springframework.data.repository.query.Param("now") java.time.LocalDateTime now, org.springframework.data.domain.Pageable pageable); - Optional findByProviderResponse(String providerResponse); + Optional findByProviderMessageId(String providerMessageId); + + @Modifying + @org.springframework.data.jpa.repository.Query("UPDATE SmsLog s SET s.status = :processing WHERE s.id = :id AND s.status = :pending") + int claimPendingMessage(@org.springframework.data.repository.query.Param("id") UUID id, + @org.springframework.data.repository.query.Param("pending") SmsLogStatus pending, + @org.springframework.data.repository.query.Param("processing") SmsLogStatus processing); Page findByCreatedByOrderByCreatedDesc(User user, Pageable pageable); diff --git a/src/main/java/com/flexcodelabs/flextuma/core/security/PatAuthenticationFilter.java b/src/main/java/com/flexcodelabs/flextuma/core/security/PatAuthenticationFilter.java index 3195d31..f63b838 100644 --- a/src/main/java/com/flexcodelabs/flextuma/core/security/PatAuthenticationFilter.java +++ b/src/main/java/com/flexcodelabs/flextuma/core/security/PatAuthenticationFilter.java @@ -43,7 +43,8 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse if (patOpt.isPresent()) { PersonalAccessToken pat = patOpt.get(); - if (pat.getExpiresAt() == null || pat.getExpiresAt().isAfter(LocalDateTime.now())) { + if (Boolean.TRUE.equals(pat.getActive()) + && (pat.getExpiresAt() == null || pat.getExpiresAt().isAfter(LocalDateTime.now()))) { User user = pat.getUser(); Set authorities = user.getRoles().stream() diff --git a/src/main/java/com/flexcodelabs/flextuma/core/security/SecurityConfig.java b/src/main/java/com/flexcodelabs/flextuma/core/security/SecurityConfig.java index 4285a17..c90d42b 100644 --- a/src/main/java/com/flexcodelabs/flextuma/core/security/SecurityConfig.java +++ b/src/main/java/com/flexcodelabs/flextuma/core/security/SecurityConfig.java @@ -59,6 +59,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) { .authorizeHttpRequests(auth -> auth .requestMatchers("/api/login").permitAll() .requestMatchers("/api/register").permitAll() + .requestMatchers(org.springframework.http.HttpMethod.POST, "/api/webhooks/*").permitAll() .requestMatchers("/").permitAll() .requestMatchers("/assets/**").permitAll() .requestMatchers(new RegexRequestMatcher("^/(?!api(?:/|$)).*", null)).permitAll() diff --git a/src/main/java/com/flexcodelabs/flextuma/core/senders/BeemSender.java b/src/main/java/com/flexcodelabs/flextuma/core/senders/BeemSender.java index 8b56ec1..fbbfde7 100644 --- a/src/main/java/com/flexcodelabs/flextuma/core/senders/BeemSender.java +++ b/src/main/java/com/flexcodelabs/flextuma/core/senders/BeemSender.java @@ -100,7 +100,7 @@ private SmsSendResult processResponse(ResponseEntity response, log.info("BEEM: SMS sent successfully to {}", to); return SmsSendResult.success( "SMS sent successfully", - null, + responseBody != null ? responseBody.getMessageId() : null, responseMap); } else { return SmsSendResult.failure( @@ -178,5 +178,14 @@ static class BeemSmsResponse { private boolean valid; private String message; private int code; + + @JsonProperty("message_id") + private String messageId; + + BeemSmsResponse(boolean valid, String message, int code) { + this.valid = valid; + this.message = message; + this.code = code; + } } -} \ No newline at end of file +} diff --git a/src/main/java/com/flexcodelabs/flextuma/core/services/BaseService.java b/src/main/java/com/flexcodelabs/flextuma/core/services/BaseService.java index 0cfc559..7edd33d 100644 --- a/src/main/java/com/flexcodelabs/flextuma/core/services/BaseService.java +++ b/src/main/java/com/flexcodelabs/flextuma/core/services/BaseService.java @@ -51,6 +51,7 @@ public void setEventPublisher(ApplicationEventPublisher eventPublisher) { private CurrentUserResolver currentUserResolver; private EntityResponseInitializer entityResponseInitializer; + private EntityAssociationReferenceResolver entityAssociationReferenceResolver; @org.springframework.beans.factory.annotation.Autowired public void setCurrentUserResolver(CurrentUserResolver currentUserResolver) { @@ -62,6 +63,12 @@ public void setEntityResponseInitializer(EntityResponseInitializer entityRespons this.entityResponseInitializer = entityResponseInitializer; } + @Autowired + public void setEntityAssociationReferenceResolver( + EntityAssociationReferenceResolver entityAssociationReferenceResolver) { + this.entityAssociationReferenceResolver = entityAssociationReferenceResolver; + } + protected abstract JpaRepository getRepository(); protected abstract String getReadPermission(); @@ -177,7 +184,10 @@ private Pagination doFindAllPaginated(Pageable pageable, List filter, @SuppressWarnings("unchecked") private Specification buildTenantSpec() { - return currentUserResolver.getCurrentUser() + Optional currentUser = currentUserResolver == null + ? Optional.empty() + : Optional.ofNullable(currentUserResolver.getCurrentUser()).orElse(Optional.empty()); + return currentUser .map(user -> (Specification) new TenantAwareSpecification<>(user, SecurityUtils.getCurrentUserAuthorities())) .orElse((root, query, cb) -> cb.conjunction()); @@ -232,7 +242,9 @@ private List doFindAll(String fields, List filter, String rootJoin) { @Transactional(readOnly = true) public Optional findById(UUID id) { checkPermission(getReadPermission()); - Optional result = getRepository().findById(id); + Specification spec = buildTenantSpec() + .and((root, query, cb) -> cb.equal(root.get("id"), id)); + Optional result = getRepositoryAsExecutor().findOne(spec); result.ifPresent(this::initializeAssociationsForResponse); return result; } @@ -254,6 +266,7 @@ public Optional findById(UUID id, String fields) { public T save(T entity) { checkPermission(getAddPermission()); onPreSave(entity); + prepareEntityForPersistence(entity); T saved = getRepository().save(entity); initializeAssociationsForResponse(saved); onPostSave(saved); @@ -264,10 +277,9 @@ public T save(T entity) { @Transactional public T update(UUID id, T entity) { checkPermission(getUpdatePermission()); - T existing = getRepository().findById(id) - .orElseThrow(() -> new org.springframework.web.server.ResponseStatusException( - org.springframework.http.HttpStatus.NOT_FOUND, getEntitySingular() + " not found")); + T existing = findAccessibleById(id); onPreUpdate(entity, existing); + prepareEntityForPersistence(entity); String[] excludedFields = getNullPropertyNames(entity); org.springframework.beans.BeanUtils.copyProperties(entity, existing, excludedFields); T saved = getRepository().save(existing); @@ -302,9 +314,7 @@ private String[] getNullPropertyNames(T source) { public Map delete(UUID id) { checkPermission(getDeletePermission()); - T entity = getRepository().findById(id) - .orElseThrow(() -> new org.springframework.web.server.ResponseStatusException( - org.springframework.http.HttpStatus.NOT_FOUND, getEntitySingular() + " not found")); + T entity = findAccessibleById(id); validateDelete(entity); @@ -453,6 +463,20 @@ protected void initializeAssociationsForResponse(Object entity) { } } + protected void prepareEntityForPersistence(Object entity) { + if (entityAssociationReferenceResolver != null) { + entityAssociationReferenceResolver.resolve(entity); + } + } + + private T findAccessibleById(UUID id) { + Specification spec = buildTenantSpec() + .and((root, query, cb) -> cb.equal(root.get("id"), id)); + return getRepositoryAsExecutor().findOne(spec) + .orElseThrow(() -> new org.springframework.web.server.ResponseStatusException( + org.springframework.http.HttpStatus.NOT_FOUND, getEntitySingular() + " not found")); + } + private final ObjectMapper objectMapper = new ObjectMapper() .setSerializationInclusion(JsonInclude.Include.NON_NULL); diff --git a/src/main/java/com/flexcodelabs/flextuma/core/services/EntityAssociationReferenceResolver.java b/src/main/java/com/flexcodelabs/flextuma/core/services/EntityAssociationReferenceResolver.java new file mode 100644 index 0000000..4ada5ce --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/core/services/EntityAssociationReferenceResolver.java @@ -0,0 +1,106 @@ +package com.flexcodelabs.flextuma.core.services; + +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.Collection; +import java.util.IdentityHashMap; +import java.util.LinkedHashSet; +import java.util.Set; + +import org.hibernate.Hibernate; +import org.springframework.stereotype.Component; + +import com.flexcodelabs.flextuma.core.entities.base.BaseEntity; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.ManyToMany; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToMany; +import jakarta.persistence.OneToOne; +import jakarta.persistence.PersistenceContext; + +@Component +public class EntityAssociationReferenceResolver { + + @PersistenceContext + private EntityManager entityManager; + + public T resolve(T entity) { + if (entity == null) { + return null; + } + + resolveEntity(entity, java.util.Collections.newSetFromMap(new IdentityHashMap<>())); + return entity; + } + + private void resolveEntity(Object entity, Set visited) { + if (entity == null || !visited.add(entity)) { + return; + } + + for (Field field : getAllFields(entity.getClass())) { + if (!isAssociationField(field)) { + continue; + } + + field.setAccessible(true); + + try { + Object value = field.get(entity); + if (value == null) { + continue; + } + + if (value instanceof Collection collection) { + field.set(entity, resolveCollection(collection, visited)); + continue; + } + + field.set(entity, resolveAssociationValue(value, visited)); + } catch (IllegalAccessException ignored) { + // Ignore inaccessible association fields and continue resolving the rest. + } + } + } + + private Object resolveAssociationValue(Object value, Set visited) { + if (value instanceof BaseEntity baseEntity && baseEntity.getId() != null) { + @SuppressWarnings("unchecked") + Class entityClass = (Class) Hibernate.getClass(value); + return entityManager.getReference(entityClass, baseEntity.getId()); + } + + resolveEntity(value, visited); + return value; + } + + private Collection resolveCollection(Collection collection, Set visited) { + Collection resolved = collection instanceof Set ? new LinkedHashSet<>() : new ArrayList<>(); + + for (Object item : collection) { + resolved.add(resolveAssociationValue(item, visited)); + } + + return resolved; + } + + private boolean isAssociationField(Field field) { + return field.isAnnotationPresent(ManyToOne.class) + || field.isAnnotationPresent(OneToOne.class) + || field.isAnnotationPresent(OneToMany.class) + || field.isAnnotationPresent(ManyToMany.class); + } + + private java.util.List getAllFields(Class type) { + java.util.List fields = new ArrayList<>(); + Class current = type; + + while (current != null && current != Object.class) { + java.util.Collections.addAll(fields, current.getDeclaredFields()); + current = current.getSuperclass(); + } + + return fields; + } +} diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/auth/controllers/AuthController.java b/src/main/java/com/flexcodelabs/flextuma/modules/auth/controllers/AuthController.java index 21e3a50..7a354af 100644 --- a/src/main/java/com/flexcodelabs/flextuma/modules/auth/controllers/AuthController.java +++ b/src/main/java/com/flexcodelabs/flextuma/modules/auth/controllers/AuthController.java @@ -9,7 +9,6 @@ import org.springframework.security.authentication.AnonymousAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.security.crypto.bcrypt.BCrypt; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; @@ -194,11 +193,11 @@ public ResponseEntity changePassword( .badRequest("New password and confirmation do not match")); } - userService.changePassword(user, BCrypt.hashpw(request.getNewPassword(), BCrypt.gensalt())); + User updatedUser = userService.changePassword(user, request.getNewPassword()); securityLogService.logPasswordChange(user.getUsername(), httpRequest, true); return ResponseEntity.ok() - .body(ApiResponse.success(UserResponseDto.fromUser(user))); + .body(ApiResponse.success(UserResponseDto.fromUser(updatedUser))); } } 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 4676e60..b2fbfe8 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 @@ -12,6 +12,7 @@ import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.context.SecurityContext; import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.context.HttpSessionSecurityContextRepository; import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; import org.springframework.stereotype.Service; @@ -29,6 +30,7 @@ @RequiredArgsConstructor public class UserService extends BaseService { private final UserRepository repository; + private final PasswordEncoder passwordEncoder; @Override protected boolean isAdminEntity() { @@ -149,10 +151,14 @@ public User register(RegisterDto request) { return repository.save(user); } - public void changePassword(User user, String newPassword) { - user.setPassword(newPassword); - user.setChangePassword(false); - repository.save(user); + public User changePassword(User user, String newPassword) { + User managedUser = repository.findById(user.getId()) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "User not found")); + + managedUser.setPassword(passwordEncoder.encode(newPassword)); + managedUser.setChangePassword(false); + + return repository.save(managedUser); } } diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/connector/services/DataHydratorService.java b/src/main/java/com/flexcodelabs/flextuma/modules/connector/services/DataHydratorService.java index f550aee..a31c92d 100644 --- a/src/main/java/com/flexcodelabs/flextuma/modules/connector/services/DataHydratorService.java +++ b/src/main/java/com/flexcodelabs/flextuma/modules/connector/services/DataHydratorService.java @@ -123,7 +123,7 @@ private void applyAuthentication(HttpHeaders headers, ConnectorConfig config) { switch (config.getAuthType()) { case BEARER -> headers.setBearerAuth(config.getToken()); - case API_KEY -> headers.set("X-API-KEY", config.getToken()); + case API_KEY -> headers.set("X-API-KEY", config.getApiKey()); case BASIC -> headers.setBasicAuth(config.getUsername(), config.getPassword()); case NONE -> { // Intentionally empty: no authentication required @@ -131,4 +131,4 @@ private void applyAuthentication(HttpHeaders headers, ConnectorConfig config) { default -> throw new IllegalArgumentException("Unsupported auth type: " + config.getAuthType()); } } -} \ No newline at end of file +} diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/finance/services/WalletService.java b/src/main/java/com/flexcodelabs/flextuma/modules/finance/services/WalletService.java index 82edb56..d040342 100644 --- a/src/main/java/com/flexcodelabs/flextuma/modules/finance/services/WalletService.java +++ b/src/main/java/com/flexcodelabs/flextuma/modules/finance/services/WalletService.java @@ -7,6 +7,7 @@ import com.flexcodelabs.flextuma.core.repositories.WalletRepository; import com.flexcodelabs.flextuma.core.repositories.WalletTransactionRepository; import com.flexcodelabs.flextuma.core.services.BaseService; +import com.flexcodelabs.flextuma.core.services.EntityAssociationReferenceResolver; import com.flexcodelabs.flextuma.modules.notification.services.PersonalNotificationService; import lombok.RequiredArgsConstructor; @@ -30,6 +31,7 @@ public class WalletService extends BaseService { private final WalletRepository repository; private final WalletTransactionRepository transactionRepository; private final PersonalNotificationService personalNotificationService; + private final EntityAssociationReferenceResolver entityAssociationReferenceResolver; @Value("${flextuma.sms.price-per-segment:20.0}") private BigDecimal smsPricePerSegment; @@ -46,6 +48,7 @@ public Wallet getOrCreateWallet(User user) { newWallet.setCreatedBy(user); newWallet.setSmsCost(smsPricePerSegment); + entityAssociationReferenceResolver.resolve(newWallet); return repository.save(newWallet); } diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/notification/services/CampaignDispatchWorker.java b/src/main/java/com/flexcodelabs/flextuma/modules/notification/services/CampaignDispatchWorker.java index d528f6a..d6e846d 100644 --- a/src/main/java/com/flexcodelabs/flextuma/modules/notification/services/CampaignDispatchWorker.java +++ b/src/main/java/com/flexcodelabs/flextuma/modules/notification/services/CampaignDispatchWorker.java @@ -50,15 +50,15 @@ public void processCampaigns() { log.info("CampaignDispatchWorker: Processing {} scheduled campaign(s)", dueCampaigns.size()); for (SmsCampaign campaign : dueCampaigns) { - processSingleCampaign(campaign); + if (campaignRepository.claimScheduledCampaign(campaign.getId(), SmsCampaignStatus.SCHEDULED, + SmsCampaignStatus.PROCESSING) == 1) { + processSingleCampaign(campaign); + } } } private void processSingleCampaign(SmsCampaign campaign) { try { - campaign.setStatus(SmsCampaignStatus.PROCESSING); - campaignRepository.save(campaign); - String recipientsStr = campaign.getRecipients(); if (recipientsStr == null || recipientsStr.isBlank()) { campaign.setStatus(SmsCampaignStatus.COMPLETED); diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/notification/services/NotificationService.java b/src/main/java/com/flexcodelabs/flextuma/modules/notification/services/NotificationService.java index 78bd8ed..43349a9 100644 --- a/src/main/java/com/flexcodelabs/flextuma/modules/notification/services/NotificationService.java +++ b/src/main/java/com/flexcodelabs/flextuma/modules/notification/services/NotificationService.java @@ -20,6 +20,7 @@ import com.flexcodelabs.flextuma.core.repositories.SmsLogRepository; import com.flexcodelabs.flextuma.core.repositories.SmsTemplateRepository; import com.flexcodelabs.flextuma.core.repositories.UserRepository; +import com.flexcodelabs.flextuma.core.services.EntityAssociationReferenceResolver; import com.flexcodelabs.flextuma.core.services.EntityResponseInitializer; import com.flexcodelabs.flextuma.modules.finance.services.WalletService; import com.flexcodelabs.flextuma.core.services.RateLimiterService; @@ -42,6 +43,7 @@ public class NotificationService { private final RateLimiterService rateLimiterService; private final SmsSegmentCalculator segmentCalculator; private final EntityResponseInitializer entityResponseInitializer; + private final EntityAssociationReferenceResolver entityAssociationReferenceResolver; @Value("${flextuma.sms.price-per-segment:1.0}") private BigDecimal pricePerSegment; @@ -148,6 +150,7 @@ private SmsLog processAndSaveSms(User user, SmsConnector connector, String phone } } + entityAssociationReferenceResolver.resolve(log); SmsLog savedLog = logRepository.save(log); entityResponseInitializer.initialize(savedLog); return savedLog; diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/notification/services/PersonalNotificationService.java b/src/main/java/com/flexcodelabs/flextuma/modules/notification/services/PersonalNotificationService.java index 95b6ff2..8b08a17 100644 --- a/src/main/java/com/flexcodelabs/flextuma/modules/notification/services/PersonalNotificationService.java +++ b/src/main/java/com/flexcodelabs/flextuma/modules/notification/services/PersonalNotificationService.java @@ -20,6 +20,7 @@ import com.flexcodelabs.flextuma.core.helpers.CurrentUserResolver; import com.flexcodelabs.flextuma.core.repositories.PersonalNotificationRepository; import com.flexcodelabs.flextuma.core.services.BaseService; +import com.flexcodelabs.flextuma.core.services.EntityAssociationReferenceResolver; import com.flexcodelabs.flextuma.modules.notification.dtos.NotificationSummaryDTO; import lombok.RequiredArgsConstructor; @@ -32,6 +33,7 @@ public class PersonalNotificationService extends BaseService getRepository() { @@ -156,6 +158,7 @@ public void notifyLowBalance(User user, BigDecimal balance) { notification.setLinkUrl("/finance/wallet"); notification.setCode("LOW_BALANCE_" + user.getId()); notification.setReadAt(null); + entityAssociationReferenceResolver.resolve(notification); repository.save(notification); } @@ -173,6 +176,7 @@ public void notifyCampaignCompleted(User user, String campaignName) { : campaignName) + " has finished sending."); notification.setLinkUrl("/campaigns"); notification.setCode("CAMPAIGN_COMPLETED_" + UUID.randomUUID()); + entityAssociationReferenceResolver.resolve(notification); repository.save(notification); } diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/notification/services/SmsDispatchWorker.java b/src/main/java/com/flexcodelabs/flextuma/modules/notification/services/SmsDispatchWorker.java index aa4f47c..61f86cb 100644 --- a/src/main/java/com/flexcodelabs/flextuma/modules/notification/services/SmsDispatchWorker.java +++ b/src/main/java/com/flexcodelabs/flextuma/modules/notification/services/SmsDispatchWorker.java @@ -40,15 +40,16 @@ public void dispatch() { log.debug("SmsDispatchWorker: picking up {} PENDING log(s)", pending.size()); - for (SmsLog smsLog : pending) { - markProcessing(smsLog); - send(smsLog); + for (SmsLog smsLog : pending) { + if (markProcessing(smsLog)) { + send(smsLog); + } } } - private void markProcessing(SmsLog smsLog) { - smsLog.setStatus(SmsLogStatus.PROCESSING); - logRepository.save(smsLog); + private boolean markProcessing(SmsLog smsLog) { + return logRepository.claimPendingMessage(smsLog.getId(), SmsLogStatus.PENDING, + SmsLogStatus.PROCESSING) == 1; } private void send(SmsLog smsLog) { diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/webhook/controllers/SmsWebhookController.java b/src/main/java/com/flexcodelabs/flextuma/modules/webhook/controllers/SmsWebhookController.java index 9e4cffe..064e91b 100644 --- a/src/main/java/com/flexcodelabs/flextuma/modules/webhook/controllers/SmsWebhookController.java +++ b/src/main/java/com/flexcodelabs/flextuma/modules/webhook/controllers/SmsWebhookController.java @@ -9,6 +9,7 @@ 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.RequestHeader; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.server.ResponseStatusException; import org.springframework.http.HttpStatus; @@ -24,6 +25,9 @@ import com.flexcodelabs.flextuma.core.webhooks.DlrResult; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; /** * Receives Delivery Report (DLR) callbacks from SMS providers. @@ -51,6 +55,9 @@ public class SmsWebhookController { private final DataHydratorService hydratorService; private final NotificationService notificationService; + @Value("${flextuma.webhooks.sms.shared-secret:}") + private String webhookSharedSecret; + public SmsWebhookController(SmsLogRepository logRepository, List dlrParsers, ConnectorConfigService configService, DataHydratorService hydratorService, NotificationService notificationService) { @@ -64,8 +71,16 @@ public SmsWebhookController(SmsLogRepository logRepository, List dlrP @PostMapping("/{provider}") public ResponseEntity deliveryReport( @PathVariable String provider, + @RequestHeader(value = "X-Flextuma-Webhook-Secret", required = false) String providedSecret, @RequestBody Map payload) { + if (webhookSharedSecret.isBlank() || providedSecret == null + || !MessageDigest.isEqual(webhookSharedSecret.getBytes(StandardCharsets.UTF_8), + providedSecret.getBytes(StandardCharsets.UTF_8))) { + log.warn("Rejected DLR from provider [{}] due to invalid webhook credential", provider); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } + log.debug("DLR received from provider [{}]: {}", provider, payload); DlrParser parser = dlrParsers.stream() @@ -90,7 +105,7 @@ public ResponseEntity deliveryReport( return ResponseEntity.ok().build(); } - Optional logOpt = logRepository.findByProviderResponse(result.messageId()); + Optional logOpt = logRepository.findByProviderMessageId(result.messageId()); if (logOpt.isEmpty()) { log.warn("DLR from [{}]: no SmsLog found for messageId [{}]", provider, result.messageId()); @@ -130,7 +145,7 @@ public ResponseEntity> triggerDispatch( recipient.put("provider", request.getProvider()); try { if (request.getContent() != null && !request.getContent().isBlank()) { - recipient.put("content", request.getContent()); + recipient.put("message", request.getContent()); notificationService.queueRawSms(recipient, username); } else { recipient.put("templateCode", request.getTemplateCode()); diff --git a/src/test/java/com/flexcodelabs/flextuma/core/helpers/DynamicFetchSpecificationTest.java b/src/test/java/com/flexcodelabs/flextuma/core/helpers/DynamicFetchSpecificationTest.java index 9526430..23c6918 100644 --- a/src/test/java/com/flexcodelabs/flextuma/core/helpers/DynamicFetchSpecificationTest.java +++ b/src/test/java/com/flexcodelabs/flextuma/core/helpers/DynamicFetchSpecificationTest.java @@ -127,15 +127,12 @@ void testApplyFetchPluralAttribute() { doReturn(Object.class).when(query).getResultType(); when(rootType.getAttribute("collection")).thenReturn(pluralAttribute); when(pluralAttribute.isAssociation()).thenReturn(true); - when(pluralAttribute.getElementType()).thenReturn(targetType); - when(root.getFetches()).thenReturn(Collections.emptySet()); - when(root.fetch("collection", JoinType.LEFT)).thenReturn(mock(Fetch.class)); when(cb.conjunction()).thenReturn(mock(Predicate.class)); spec.toPredicate(root, query, cb); - verify(root).fetch("collection", JoinType.LEFT); + verify(root, never()).fetch("collection", JoinType.LEFT); } @Test @@ -243,7 +240,7 @@ void testGetTargetTypeReturnsNull() { // SingularAttribute returns null Type (not instance of ManagedType) Type nonManagedType = mock(Type.class); - when(singularAttribute.getType()).thenReturn(nonManagedType); + lenient().when(singularAttribute.getType()).thenReturn(nonManagedType); when(cb.conjunction()).thenReturn(mock(Predicate.class)); @@ -264,13 +261,13 @@ void testGetTargetTypePluralReturnsNull() { // PluralAttribute returns null element Type Type nonManagedType = mock(Type.class); - when(pluralAttribute.getElementType()).thenReturn(nonManagedType); + lenient().when(pluralAttribute.getElementType()).thenReturn(nonManagedType); when(cb.conjunction()).thenReturn(mock(Predicate.class)); spec.toPredicate(root, query, cb); - verify(root).fetch("collection", JoinType.LEFT); + verify(root, never()).fetch("collection", JoinType.LEFT); } /** diff --git a/src/test/java/com/flexcodelabs/flextuma/core/services/BaseServiceTest.java b/src/test/java/com/flexcodelabs/flextuma/core/services/BaseServiceTest.java index a3ff0de..409b6fa 100644 --- a/src/test/java/com/flexcodelabs/flextuma/core/services/BaseServiceTest.java +++ b/src/test/java/com/flexcodelabs/flextuma/core/services/BaseServiceTest.java @@ -53,6 +53,9 @@ class BaseServiceTest { @Mock private CurrentUserResolver currentUserResolver; + @Mock + private EntityAssociationReferenceResolver entityAssociationReferenceResolver; + @Mock private JpaRepository repository; @@ -139,6 +142,7 @@ void setUp() { service.entityManager = entityManager; service.setEventPublisher(eventPublisher); service.setCurrentUserResolver(currentUserResolver); + service.setEntityAssociationReferenceResolver(entityAssociationReferenceResolver); securityUtilsMock = Mockito.mockStatic(SecurityUtils.class); securityUtilsMock.when(SecurityUtils::getCurrentUserAuthorities) @@ -220,6 +224,7 @@ void save_shouldSaveEntityAndPublishEvent() { TestEntity saved = service.save(entity); assertNotNull(saved); + verify(entityAssociationReferenceResolver).resolve(entity); verify(repository).save(entity); verify(eventPublisher).publishEvent(any()); } @@ -231,20 +236,21 @@ void update_shouldUpdateExistingEntity() { TestEntity updatePayload = new TestEntity(); updatePayload.setName("new name"); - when(repository.findById(id)).thenReturn(Optional.of(existing)); + when(executor.findOne(any(Specification.class))).thenReturn(Optional.of(existing)); when(repository.save(existing)).thenReturn(existing); TestEntity result = service.update(id, updatePayload); assertNotNull(result); assertEquals("new name", existing.getName()); + verify(entityAssociationReferenceResolver).resolve(updatePayload); verify(repository).save(existing); } @Test void findById_shouldReturnEmpty_whenNotFound() { UUID id = UUID.randomUUID(); - when(repository.findById(id)).thenReturn(Optional.empty()); + when(executor.findOne(any(Specification.class))).thenReturn(Optional.empty()); Optional result = service.findById(id); @@ -377,7 +383,7 @@ void deleteMany_shouldThrowAccessDenied_whenUnauthorized() { void delete_shouldDeleteById() { UUID id = UUID.randomUUID(); TestEntity entity = new TestEntity(); - when(repository.findById(id)).thenReturn(Optional.of(entity)); + when(executor.findOne(any(Specification.class))).thenReturn(Optional.of(entity)); // Mock the native query execution Query query = mock(Query.class); @@ -450,4 +456,4 @@ void getAggregatedData_shouldReturnComplexData() { assertEquals("test", result.get(0).get("name")); assertEquals(100L, result.get(0).get("total")); } -} \ No newline at end of file +} diff --git a/src/test/java/com/flexcodelabs/flextuma/modules/auth/services/RoleServiceTest.java b/src/test/java/com/flexcodelabs/flextuma/modules/auth/services/RoleServiceTest.java index de1d37b..07ee42c 100644 --- a/src/test/java/com/flexcodelabs/flextuma/modules/auth/services/RoleServiceTest.java +++ b/src/test/java/com/flexcodelabs/flextuma/modules/auth/services/RoleServiceTest.java @@ -79,7 +79,8 @@ void delete_shouldThrowException_whenRoleIsSystem() { role.setId(id); role.setSystem(true); - when(repository.findById(id)).thenReturn(Optional.of(role)); + when(repository.findOne(org.mockito.ArgumentMatchers.>any())) + .thenReturn(Optional.of(role)); assertThrows(IllegalStateException.class, () -> service.delete(id)); verify(repository, never()).deleteById(any()); @@ -93,7 +94,8 @@ void delete_shouldDelete_whenRoleIsNotSystem() { role.setId(id); role.setSystem(false); - when(repository.findById(id)).thenReturn(Optional.of(role)); + when(repository.findOne(org.mockito.ArgumentMatchers.>any())) + .thenReturn(Optional.of(role)); // Mock the native query execution Query query = mock(Query.class); 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 f1b2d0d..ecdc48b 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 @@ -18,6 +18,7 @@ import org.mockito.Mockito; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.security.crypto.bcrypt.BCrypt; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.web.server.ResponseStatusException; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContext; @@ -38,13 +39,16 @@ class UserServiceTest { @Mock private Authentication authentication; + @Mock + private PasswordEncoder passwordEncoder; + private MockedStatic securityContextHolderMock; private UserService service; @BeforeEach void setUp() { - service = new UserService(repository); + service = new UserService(repository, passwordEncoder); securityContextHolderMock = Mockito.mockStatic(SecurityContextHolder.class); securityContextHolderMock.when(SecurityContextHolder::getContext).thenReturn(securityContext); @@ -108,12 +112,35 @@ void delete_shouldThrowException_whenUserIsSystem() { user.setId(id); user.setSystem(true); - when(repository.findById(id)).thenReturn(Optional.of(user)); + when(repository.findOne(org.mockito.ArgumentMatchers.>any())) + .thenReturn(Optional.of(user)); assertThrows(IllegalStateException.class, () -> service.delete(id)); verify(repository, never()).deleteById(any()); } + @Test + void changePassword_shouldEncodeAndSaveManagedUser() { + UUID id = UUID.randomUUID(); + User detachedUser = new User(); + detachedUser.setId(id); + + User managedUser = new User(); + managedUser.setId(id); + managedUser.setChangePassword(true); + + when(repository.findById(id)).thenReturn(Optional.of(managedUser)); + when(passwordEncoder.encode("new-password")).thenReturn("encoded-password"); + when(repository.save(managedUser)).thenReturn(managedUser); + + User result = service.changePassword(detachedUser, "new-password"); + + assertSame(managedUser, result); + assertEquals("encoded-password", managedUser.getPassword()); + assertFalse(Boolean.TRUE.equals(managedUser.getChangePassword())); + verify(repository).save(managedUser); + } + private void mockPermissions(Set permissions) { when(authentication.isAuthenticated()).thenReturn(true); List authorities = permissions.stream() diff --git a/src/test/java/com/flexcodelabs/flextuma/modules/connector/services/DataHydratorServiceTest.java b/src/test/java/com/flexcodelabs/flextuma/modules/connector/services/DataHydratorServiceTest.java index 2abad48..87d51bc 100644 --- a/src/test/java/com/flexcodelabs/flextuma/modules/connector/services/DataHydratorServiceTest.java +++ b/src/test/java/com/flexcodelabs/flextuma/modules/connector/services/DataHydratorServiceTest.java @@ -152,7 +152,7 @@ void getMemberData_shouldApplyApiKeyAuth() { config.setUrl("http://api.example.com"); config.setEndpoint("/users/{id}"); config.setAuthType(AuthType.API_KEY); - config.setToken("api-key-123"); + config.setApiKey("api-key-123"); config.setMappings(new ArrayList<>()); when(repository.findByTenantId("tenant1")).thenReturn(Optional.of(config)); diff --git a/src/test/java/com/flexcodelabs/flextuma/modules/notification/services/CampaignDispatchWorkerTest.java b/src/test/java/com/flexcodelabs/flextuma/modules/notification/services/CampaignDispatchWorkerTest.java index 06885ef..a8984dd 100644 --- a/src/test/java/com/flexcodelabs/flextuma/modules/notification/services/CampaignDispatchWorkerTest.java +++ b/src/test/java/com/flexcodelabs/flextuma/modules/notification/services/CampaignDispatchWorkerTest.java @@ -9,6 +9,7 @@ import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import static org.mockito.Mockito.lenient; import java.math.BigDecimal; import java.time.LocalDateTime; @@ -60,6 +61,8 @@ class CampaignDispatchWorkerTest { @BeforeEach void setUp() { ReflectionTestUtils.setField(worker, "pricePerSegment", new BigDecimal("1.5")); + lenient().when(campaignRepository.claimScheduledCampaign(any(), eq(SmsCampaignStatus.SCHEDULED), + eq(SmsCampaignStatus.PROCESSING))).thenReturn(1); } @Test diff --git a/src/test/java/com/flexcodelabs/flextuma/modules/notification/services/NotificationServiceTest.java b/src/test/java/com/flexcodelabs/flextuma/modules/notification/services/NotificationServiceTest.java index e472142..57a55a5 100644 --- a/src/test/java/com/flexcodelabs/flextuma/modules/notification/services/NotificationServiceTest.java +++ b/src/test/java/com/flexcodelabs/flextuma/modules/notification/services/NotificationServiceTest.java @@ -9,6 +9,7 @@ import com.flexcodelabs.flextuma.core.repositories.SmsLogRepository; import com.flexcodelabs.flextuma.core.repositories.SmsTemplateRepository; import com.flexcodelabs.flextuma.core.repositories.UserRepository; +import com.flexcodelabs.flextuma.core.services.EntityAssociationReferenceResolver; import com.flexcodelabs.flextuma.core.services.EntityResponseInitializer; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -65,6 +66,9 @@ class NotificationServiceTest { @Mock private EntityResponseInitializer entityResponseInitializer; + @Mock + private EntityAssociationReferenceResolver entityAssociationReferenceResolver; + @InjectMocks private NotificationService notificationService; @@ -185,6 +189,7 @@ void queueTemplatedSms_shouldQueueSmsSuccessfully() { assertEquals(template, capturedLog.getTemplate()); assertEquals(connector, capturedLog.getConnector()); assertEquals(SmsLogStatus.PENDING, capturedLog.getStatus()); + verify(entityAssociationReferenceResolver).resolve(capturedLog); assertNotNull(result); } diff --git a/src/test/java/com/flexcodelabs/flextuma/modules/notification/services/PersonalNotificationServiceTest.java b/src/test/java/com/flexcodelabs/flextuma/modules/notification/services/PersonalNotificationServiceTest.java index a7274bf..6942975 100644 --- a/src/test/java/com/flexcodelabs/flextuma/modules/notification/services/PersonalNotificationServiceTest.java +++ b/src/test/java/com/flexcodelabs/flextuma/modules/notification/services/PersonalNotificationServiceTest.java @@ -28,6 +28,7 @@ import com.flexcodelabs.flextuma.core.helpers.CurrentUserResolver; import com.flexcodelabs.flextuma.core.repositories.PersonalNotificationRepository; import com.flexcodelabs.flextuma.core.security.SecurityUtils; +import com.flexcodelabs.flextuma.core.services.EntityAssociationReferenceResolver; import com.flexcodelabs.flextuma.core.services.EntityResponseInitializer; import com.flexcodelabs.flextuma.modules.notification.dtos.NotificationSummaryDTO; @@ -49,12 +50,15 @@ class PersonalNotificationServiceTest { @Mock private ApplicationEventPublisher eventPublisher; + @Mock + private EntityAssociationReferenceResolver entityAssociationReferenceResolver; + private PersonalNotificationService service; private MockedStatic securityUtilsMock; @BeforeEach void setUp() { - service = new PersonalNotificationService(repository, currentUserResolver); + service = new PersonalNotificationService(repository, currentUserResolver, entityAssociationReferenceResolver); service.setEntityResponseInitializer(entityResponseInitializer); service.setEventPublisher(eventPublisher); securityUtilsMock = Mockito.mockStatic(SecurityUtils.class); @@ -99,6 +103,7 @@ void notifyLowBalance_shouldCreateUnreadLowBalanceNotification() { assertEquals(PersonalNotificationType.LOW_BALANCE_ALERT, saved.getType()); assertEquals("/finance/wallet", saved.getLinkUrl()); assertEquals("LOW_BALANCE_" + user.getId(), saved.getCode()); + verify(entityAssociationReferenceResolver).resolve(saved); } @Test diff --git a/src/test/java/com/flexcodelabs/flextuma/modules/notification/services/SmsDispatchWorkerTest.java b/src/test/java/com/flexcodelabs/flextuma/modules/notification/services/SmsDispatchWorkerTest.java index 9e26e83..2511a71 100644 --- a/src/test/java/com/flexcodelabs/flextuma/modules/notification/services/SmsDispatchWorkerTest.java +++ b/src/test/java/com/flexcodelabs/flextuma/modules/notification/services/SmsDispatchWorkerTest.java @@ -61,6 +61,7 @@ void dispatch_shouldMarkSent_whenSendSucceeds() { SmsSendResult.success("SMS sent successfully", "provider-msg-id-123", Map.of("status", "sent", "message_id", "provider-msg-id-123"))); when(logRepository.save(any())).thenAnswer(i -> i.getArgument(0)); + when(logRepository.claimPendingMessage(any(), eq(SmsLogStatus.PENDING), eq(SmsLogStatus.PROCESSING))).thenReturn(1); worker.dispatch(); @@ -78,6 +79,7 @@ void dispatch_shouldRetry_whenSendFailsAndRetriesBelow3() { .thenReturn(List.of(log)); when(smsSender.sendSms(any(), any(), any())).thenThrow(new RuntimeException("timeout")); when(logRepository.save(any())).thenAnswer(i -> i.getArgument(0)); + when(logRepository.claimPendingMessage(any(), eq(SmsLogStatus.PENDING), eq(SmsLogStatus.PROCESSING))).thenReturn(1); worker.dispatch(); @@ -96,6 +98,7 @@ void dispatch_shouldMarkFailed_whenMaxRetriesReached() { .thenReturn(List.of(log)); when(smsSender.sendSms(any(), any(), any())).thenThrow(new RuntimeException("timeout")); when(logRepository.save(any())).thenAnswer(i -> i.getArgument(0)); + when(logRepository.claimPendingMessage(any(), eq(SmsLogStatus.PENDING), eq(SmsLogStatus.PROCESSING))).thenReturn(1); worker.dispatch(); @@ -113,6 +116,7 @@ void dispatch_shouldMarkFailed_whenNoConnector() { any(org.springframework.data.domain.Pageable.class))) .thenReturn(List.of(log)); when(logRepository.save(any())).thenAnswer(i -> i.getArgument(0)); + when(logRepository.claimPendingMessage(any(), eq(SmsLogStatus.PENDING), eq(SmsLogStatus.PROCESSING))).thenReturn(1); worker.dispatch(); diff --git a/src/test/java/com/flexcodelabs/flextuma/modules/webhook/controllers/SmsWebhookControllerTest.java b/src/test/java/com/flexcodelabs/flextuma/modules/webhook/controllers/SmsWebhookControllerTest.java index 68a55ba..dd97f7a 100644 --- a/src/test/java/com/flexcodelabs/flextuma/modules/webhook/controllers/SmsWebhookControllerTest.java +++ b/src/test/java/com/flexcodelabs/flextuma/modules/webhook/controllers/SmsWebhookControllerTest.java @@ -31,6 +31,7 @@ import java.util.UUID; import org.springframework.http.ResponseEntity; import static org.mockito.Mockito.times; +import org.springframework.test.util.ReflectionTestUtils; @ExtendWith(MockitoExtension.class) class SmsWebhookControllerTest { @@ -51,8 +52,10 @@ class SmsWebhookControllerTest { private NotificationService notificationService; private SmsWebhookController buildController() { - return new SmsWebhookController(logRepository, List.of(dlrParser), configService, hydratorService, + SmsWebhookController controller = new SmsWebhookController(logRepository, List.of(dlrParser), configService, hydratorService, notificationService); + ReflectionTestUtils.setField(controller, "webhookSharedSecret", "test-secret"); + return controller; } private Map payload(String msgId, String status) { @@ -69,12 +72,12 @@ void deliveryReport_shouldUpdateToSent_whenDelivered() { SmsLog log = new SmsLog(); log.setStatus(SmsLogStatus.SENT); - when(logRepository.findByProviderResponse("msg-123")).thenReturn(Optional.of(log)); + when(logRepository.findByProviderMessageId("msg-123")).thenReturn(Optional.of(log)); when(logRepository.save(any())).thenReturn(log); - buildController().deliveryReport("beem", payload("msg-123", "delivered")); + buildController().deliveryReport("beem", "test-secret", payload("msg-123", "delivered")); - verify(logRepository).findByProviderResponse("msg-123"); + verify(logRepository).findByProviderMessageId("msg-123"); verify(logRepository).save(any()); assertEquals(SmsLogStatus.SENT, log.getStatus()); } @@ -86,10 +89,10 @@ void deliveryReport_shouldUpdateToFailed_whenFailed() { SmsLog log = new SmsLog(); log.setStatus(SmsLogStatus.PROCESSING); - when(logRepository.findByProviderResponse("msg-789")).thenReturn(Optional.of(log)); + when(logRepository.findByProviderMessageId("msg-789")).thenReturn(Optional.of(log)); when(logRepository.save(any())).thenReturn(log); - buildController().deliveryReport("beem", payload("msg-789", "failed")); + buildController().deliveryReport("beem", "test-secret", payload("msg-789", "failed")); verify(logRepository).save(any()); assertEquals(SmsLogStatus.FAILED, log.getStatus()); @@ -102,9 +105,9 @@ void deliveryReport_shouldNotSave_whenAlreadySentAndFailedDlrArrives() { SmsLog log = new SmsLog(); log.setStatus(SmsLogStatus.SENT); - lenient().when(logRepository.findByProviderResponse("msg-456")).thenReturn(Optional.of(log)); + lenient().when(logRepository.findByProviderMessageId("msg-456")).thenReturn(Optional.of(log)); - buildController().deliveryReport("beem", payload("msg-456", "failed")); + buildController().deliveryReport("beem", "test-secret", payload("msg-456", "failed")); verify(logRepository, never()).save(any()); } @@ -113,7 +116,7 @@ void deliveryReport_shouldNotSave_whenAlreadySentAndFailedDlrArrives() { void deliveryReport_shouldNotSave_whenUnknownProvider() { when(dlrParser.getProvider()).thenReturn("BEEM"); - buildController().deliveryReport("unknown_provider", payload("msg-000", "delivered")); + buildController().deliveryReport("unknown_provider", "test-secret", payload("msg-000", "delivered")); verify(logRepository, never()).save(any()); } @@ -123,7 +126,7 @@ void deliveryReport_shouldNotSave_whenNoLogFound() { when(dlrParser.getProvider()).thenReturn("BEEM"); when(dlrParser.parse(any())).thenReturn(new DlrResult("msg-999", SmsLogStatus.SENT, "delivered")); - buildController().deliveryReport("beem", payload("msg-999", "delivered")); + buildController().deliveryReport("beem", "test-secret", payload("msg-999", "delivered")); verify(logRepository, never()).save(any()); } @@ -133,7 +136,7 @@ void deliveryReport_shouldNotSave_whenIntermediateStatus() { when(dlrParser.getProvider()).thenReturn("BEEM"); when(dlrParser.parse(any())).thenReturn(new DlrResult("msg-001", null, "submitted")); - buildController().deliveryReport("beem", payload("msg-001", "submitted")); + buildController().deliveryReport("beem", "test-secret", payload("msg-001", "submitted")); verify(logRepository, never()).save(any()); }