From 581c67b5ef38d0803777165d7b54055abf81c7f6 Mon Sep 17 00:00:00 2001 From: DJAngel973 Date: Fri, 24 Apr 2026 23:38:49 -0500 Subject: [PATCH 1/5] feat(tests): add SessionServiceTest - add methods tests create, validation, revoke sessions --- .../auth/service/SessionServiceTest.java | 469 ++++++++++++++++++ 1 file changed, 469 insertions(+) create mode 100644 src/test/java/com/wallet/secure/auth/service/SessionServiceTest.java diff --git a/src/test/java/com/wallet/secure/auth/service/SessionServiceTest.java b/src/test/java/com/wallet/secure/auth/service/SessionServiceTest.java new file mode 100644 index 0000000..26c137c --- /dev/null +++ b/src/test/java/com/wallet/secure/auth/service/SessionServiceTest.java @@ -0,0 +1,469 @@ +package com.wallet.secure.auth.service; + +import com.wallet.secure.auth.dto.SessionResponse; +import com.wallet.secure.auth.entity.Session; +import com.wallet.secure.auth.repository.SessionRepository; +import com.wallet.secure.auth.security.JwtService; +import com.wallet.secure.common.exception.InvalidCredentialsException; +import com.wallet.secure.common.exception.ResourceNotFoundException; +import com.wallet.secure.common.response.ApiResponse; +import com.wallet.secure.user.entity.User; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +/** + * Unit tests for SessionService. + * + * WHAT we test: + * 1. createSession() — builds entity with correct fields and hashes the token + * 2. validateSession() — rejects unknown, revoked and expired sessions + * 3. revokeByToken() — marks session as revoked, no-op when not found + * 4. revokeAllSessions() — delegates bulk update to repository + * 5. revokeSessionById() — ownership check (OWASP A01), already-revoked guard + * 6. getActiveSessions() — flags current session, handles null token + * 7. hashToken() — deterministic, 64-char hex output (SHA-256) + * + * WHAT we do NOT test: + * → @Transactional behavior — requires Spring context + * → Real SHA-256 correctness — guaranteed by the JVM spec + */ +@ExtendWith(MockitoExtension.class) +@DisplayName("SessionService") +class SessionServiceTest { + + @Mock private SessionRepository sessionRepository; + @Mock private JwtService jwtService; + + @InjectMocks + private SessionService sessionService; + + // ─── Shared test data + + private User testUser; + private UUID userId; + private UUID sessionId; + + private static final String RAW_TOKEN = "test.refresh.token.device.A"; + private static final String OTHER_TOKEN = "test.refresh.token.device.B"; + private static final String TEST_IP = "192.168.1.100"; + private static final String TEST_UA = "Mozilla/5.0 Test"; + + @BeforeEach + void setUp() { + userId = UUID.randomUUID(); + sessionId = UUID.randomUUID(); + + testUser = User.builder() + .id(userId) + .email("angel@test.com") + .passwordHash("$2a$12$hashedpassword") + .build(); + } + + // ─── Helper — builds a minimal valid Session + + private Session buildActiveSession(String rawToken) { + String hash = sessionService.hashToken(rawToken); + return Session.builder() + .user(testUser) + .tokenHash(hash) + .ipAddress(TEST_IP) + .userAgent(TEST_UA) + .expiresAt(Instant.now().plusSeconds(3600)) // expires in 1 hour + .build(); + } + + private Session buildRevokedSession(String rawToken) { + Session session = buildActiveSession(rawToken); + session.revokeNow(); + return session; + } + + private Session buildExpiredSession(String rawToken) { + String hash = sessionService.hashToken(rawToken); + return Session.builder() + .user(testUser) + .tokenHash(hash) + .ipAddress(TEST_IP) + .userAgent(TEST_UA) + .expiresAt(Instant.now().minusSeconds(60)) // expired 1 minute ago + .build(); + } + + // ─── createSession() + + @Nested + @DisplayName("createSession()") + class CreateSessionTests { + + @Test + @DisplayName("saves session with hashed token — raw token never stored in DB") + void createSession_savesHashedToken() { + // GIVEN + lenient().when(jwtService.getRefreshExpirationMs()).thenReturn(86_400_000L); // 1 day + when(sessionRepository.save(any(Session.class))) + .thenAnswer(inv -> inv.getArgument(0)); + + // WHEN + sessionService.createSession(testUser, RAW_TOKEN, TEST_IP, TEST_UA); + + // THEN — capture what was saved + ArgumentCaptor captor = ArgumentCaptor.forClass(Session.class); + verify(sessionRepository).save(captor.capture()); + + Session saved = captor.getValue(); + // OWASP A02: raw token is NEVER stored — only the SHA-256 hash + assertThat(saved.getTokenHash()).isNotEqualTo(RAW_TOKEN); + assertThat(saved.getTokenHash()).isEqualTo(sessionService.hashToken(RAW_TOKEN)); + assertThat(saved.getTokenHash()).hasSize(64); // SHA-256 in hex = 64 chars + } + + @Test + @DisplayName("saves session with correct user, ip and userAgent") + void createSession_savesCorrectContext() { + // GIVEN + lenient().when(jwtService.getRefreshExpirationMs()).thenReturn(86_400_000L); + when(sessionRepository.save(any(Session.class))) + .thenAnswer(inv -> inv.getArgument(0)); + + // WHEN + sessionService.createSession(testUser, RAW_TOKEN, TEST_IP, TEST_UA); + + // THEN + ArgumentCaptor captor = ArgumentCaptor.forClass(Session.class); + verify(sessionRepository).save(captor.capture()); + + Session saved = captor.getValue(); + assertThat(saved.getUser()).isEqualTo(testUser); + assertThat(saved.getIpAddress()).isEqualTo(TEST_IP); + assertThat(saved.getUserAgent()).isEqualTo(TEST_UA); + assertThat(saved.getExpiresAt()).isAfter(Instant.now()); + } + } + + // ─── validateSession() + + @Nested + @DisplayName("validateSession()") + class ValidateSessionTests { + + @Test + @DisplayName("returns session when token is valid and not expired") + void validateSession_validToken_returnsSession() { + // GIVEN + Session activeSession = buildActiveSession(RAW_TOKEN); + when(sessionRepository.findByTokenHash(sessionService.hashToken(RAW_TOKEN))) + .thenReturn(Optional.of(activeSession)); + + // WHEN + Session result = sessionService.validateSession(RAW_TOKEN); + + // THEN + assertThat(result).isEqualTo(activeSession); + } + + @Test + @DisplayName("throws when session is not found — possible token reuse after logout") + void validateSession_sessionNotFound_throwsException() { + // GIVEN — token was never issued or was deleted + when(sessionRepository.findByTokenHash(any())).thenReturn(Optional.empty()); + + // WHEN / THEN + // OWASP A07: unknown token = definitive rejection + assertThatThrownBy(() -> sessionService.validateSession(RAW_TOKEN)) + .isInstanceOf(InvalidCredentialsException.class) + .hasMessageContaining("revoked"); + } + + @Test + @DisplayName("throws when session is revoked — user already logged out") + void validateSession_revokedSession_throwsException() { + // GIVEN — user called logout → session was marked revoked + Session revokedSession = buildRevokedSession(RAW_TOKEN); + when(sessionRepository.findByTokenHash(any())) + .thenReturn(Optional.of(revokedSession)); + + // WHEN / THEN + // OWASP A07: revoked token cannot refresh — even with valid JWT signature + assertThatThrownBy(() -> sessionService.validateSession(RAW_TOKEN)) + .isInstanceOf(InvalidCredentialsException.class) + .hasMessageContaining("revoked or expired"); + } + + @Test + @DisplayName("throws when session is expired — natural expiration") + void validateSession_expiredSession_throwsException() { + // GIVEN — session was issued long ago and expiresAt < now + Session expiredSession = buildExpiredSession(RAW_TOKEN); + when(sessionRepository.findByTokenHash(any())) + .thenReturn(Optional.of(expiredSession)); + + // WHEN / THEN + assertThatThrownBy(() -> sessionService.validateSession(RAW_TOKEN)) + .isInstanceOf(InvalidCredentialsException.class) + .hasMessageContaining("revoked or expired"); + } + } + + // ─── revokeByToken() + + @Nested + @DisplayName("revokeByToken()") + class RevokeByTokenTests { + + @Test + @DisplayName("marks session as revoked when found") + void revokeByToken_sessionFound_revokesSession() { + // GIVEN + Session activeSession = buildActiveSession(RAW_TOKEN); + when(sessionRepository.findByTokenHash(sessionService.hashToken(RAW_TOKEN))) + .thenReturn(Optional.of(activeSession)); + when(sessionRepository.save(any(Session.class))) + .thenAnswer(inv -> inv.getArgument(0)); + + // WHEN + sessionService.revokeByToken(RAW_TOKEN); + + // THEN + assertThat(activeSession.isRevoked()).isTrue(); + assertThat(activeSession.getRevokedAt()).isNotNull(); + verify(sessionRepository).save(activeSession); + } + + @Test + @DisplayName("does nothing (no-op) when session is not found — safe to call twice") + void revokeByToken_sessionNotFound_doesNotThrow() { + // GIVEN — token may have already expired and been cleaned up + when(sessionRepository.findByTokenHash(any())).thenReturn(Optional.empty()); + + // WHEN / THEN — must not throw; idempotent revocation + assertThatNoException().isThrownBy(() -> sessionService.revokeByToken(RAW_TOKEN)); + verify(sessionRepository, never()).save(any()); + } + } + + // ─── revokeAllSessions() + + @Nested + @DisplayName("revokeAllSessions()") + class RevokeAllSessionsTests { + + @Test + @DisplayName("delegates bulk revocation to repository with userId and current time") + void revokeAllSessions_delegatesToRepository() { + // GIVEN — no return value needed for @Modifying query + doNothing().when(sessionRepository) + .revokeAllActiveSessionsForUser(any(UUID.class), any(Instant.class)); + + // WHEN + sessionService.revokeAllSessions(userId); + + // THEN — bulk update is issued once with the correct userId + ArgumentCaptor timeCaptor = ArgumentCaptor.forClass(Instant.class); + verify(sessionRepository).revokeAllActiveSessionsForUser(eq(userId), timeCaptor.capture()); + + // The Instant passed must be "now" — allow 5 seconds of tolerance + Instant revokedAt = timeCaptor.getValue(); + assertThat(revokedAt).isBefore(Instant.now().plusSeconds(1)); + assertThat(revokedAt).isAfter(Instant.now().minusSeconds(5)); + } + } + + // ─── revokeSessionById() + + @Nested + @DisplayName("revokeSessionById()") + class RevokeSessionByIdTests { + + @Test + @DisplayName("revokes session when it belongs to the requesting user — OWASP A01") + void revokeSessionById_owner_revokesSuccessfully() { + // GIVEN + Session activeSession = buildActiveSession(RAW_TOKEN); + when(sessionRepository.findById(sessionId)) + .thenReturn(Optional.of(activeSession)); + when(sessionRepository.save(any(Session.class))) + .thenAnswer(inv -> inv.getArgument(0)); + + // Inject a UUID field into the session for the test + // Session.id is set by the DB — we need to simulate it here + // We test ownership via User.id comparison, not Session.id + + // WHEN + ApiResponse response = sessionService.revokeSessionById(sessionId, userId); + + // THEN + assertThat(response.isSuccess()).isTrue(); + assertThat(activeSession.isRevoked()).isTrue(); + } + + @Test + @DisplayName("throws 404 when session does not exist") + void revokeSessionById_sessionNotFound_throwsNotFoundException() { + // GIVEN + when(sessionRepository.findById(sessionId)).thenReturn(Optional.empty()); + + // WHEN / THEN + assertThatThrownBy(() -> sessionService.revokeSessionById(sessionId, userId)) + .isInstanceOf(ResourceNotFoundException.class) + .hasMessageContaining("Session not found"); + } + + @Test + @DisplayName("throws 401 when session belongs to a different user — OWASP A01") + void revokeSessionById_differentUser_throwsUnauthorized() { + // GIVEN — session belongs to testUser but attacker uses their own userId + UUID attackerId = UUID.randomUUID(); + Session victimSession = buildActiveSession(RAW_TOKEN); + + when(sessionRepository.findById(sessionId)) + .thenReturn(Optional.of(victimSession)); + + // WHEN / THEN + // OWASP A01: user cannot revoke another user's session + assertThatThrownBy(() -> sessionService.revokeSessionById(sessionId, attackerId)) + .isInstanceOf(InvalidCredentialsException.class) + .hasMessageContaining("Not authorized"); + + verify(sessionRepository, never()).save(any()); + } + + @Test + @DisplayName("returns success without re-revoking when session is already revoked") + void revokeSessionById_alreadyRevoked_returnsSuccessIdempotent() { + // GIVEN — session already revoked (user called this endpoint twice) + Session revokedSession = buildRevokedSession(RAW_TOKEN); + when(sessionRepository.findById(sessionId)) + .thenReturn(Optional.of(revokedSession)); + + // WHEN + ApiResponse response = sessionService.revokeSessionById(sessionId, userId); + + // THEN — idempotent: success, but no extra save() + assertThat(response.isSuccess()).isTrue(); + verify(sessionRepository, never()).save(any()); + } + } + + // ─── getActiveSessions() + + @Nested + @DisplayName("getActiveSessions()") + class GetActiveSessionsTests { + + @Test + @DisplayName("flags the session matching currentRefreshToken as current=true") + void getActiveSessions_withMatchingToken_flagsCurrentSession() { + // GIVEN — two active sessions; first one belongs to the current request + Session currentSession = buildActiveSession(RAW_TOKEN); + Session otherSession = buildActiveSession(OTHER_TOKEN); + + when(sessionRepository.findActiveSessionsByUserId(eq(userId), any(Instant.class))) + .thenReturn(List.of(currentSession, otherSession)); + + // WHEN + ApiResponse> response = + sessionService.getActiveSessions(userId, RAW_TOKEN); + + // THEN + assertThat(response.isSuccess()).isTrue(); + assertThat(response.getData()).hasSize(2); + + // The session that matches the current token must be flagged + SessionResponse current = response.getData().get(0); + SessionResponse other = response.getData().get(1); + + assertThat(current.isCurrent()).isTrue(); + assertThat(other.isCurrent()).isFalse(); + } + + @Test + @DisplayName("returns current=false for all sessions when currentRefreshToken is null") + void getActiveSessions_nullToken_noSessionFlagged() { + // GIVEN — caller did not provide a current token (e.g. admin lookup) + Session session = buildActiveSession(RAW_TOKEN); + when(sessionRepository.findActiveSessionsByUserId(eq(userId), any(Instant.class))) + .thenReturn(List.of(session)); + + // WHEN + ApiResponse> response = + sessionService.getActiveSessions(userId, null); + + // THEN — null token → no session can be flagged as current + assertThat(response.getData().get(0).isCurrent()).isFalse(); + } + + @Test + @DisplayName("returns empty list when user has no active sessions") + void getActiveSessions_noSessions_returnsEmptyList() { + // GIVEN + when(sessionRepository.findActiveSessionsByUserId(eq(userId), any(Instant.class))) + .thenReturn(List.of()); + + // WHEN + ApiResponse> response = + sessionService.getActiveSessions(userId, null); + + // THEN + assertThat(response.isSuccess()).isTrue(); + assertThat(response.getData()).isEmpty(); + } + } + + // ─── hashToken() + + @Nested + @DisplayName("hashToken()") + class HashTokenTests { + + @Test + @DisplayName("returns 64-character lowercase hex string — SHA-256 output") + void hashToken_returnsCorrectFormat() { + // WHEN + String hash = sessionService.hashToken(RAW_TOKEN); + + // THEN — SHA-256 produces 32 bytes = 64 hex chars + assertThat(hash).hasSize(64); + assertThat(hash).matches("[0-9a-f]{64}"); + } + + @Test + @DisplayName("is deterministic — same input always produces the same hash") + void hashToken_deterministic_sameInputSameOutput() { + // WHEN + String hash1 = sessionService.hashToken(RAW_TOKEN); + String hash2 = sessionService.hashToken(RAW_TOKEN); + + // THEN — SHA-256 is deterministic by definition + // OWASP A02: lookup by hash only works if hash is reproducible + assertThat(hash1).isEqualTo(hash2); + } + + @Test + @DisplayName("different tokens produce different hashes — no collisions") + void hashToken_differentInputs_differentHashes() { + // WHEN + String hash1 = sessionService.hashToken(RAW_TOKEN); + String hash2 = sessionService.hashToken(OTHER_TOKEN); + + // THEN — collision resistance: distinct tokens must not share a hash + assertThat(hash1).isNotEqualTo(hash2); + } + } +} From 6f1ee79e4c10f0744caa36b987a6e4a822cd0573 Mon Sep 17 00:00:00 2001 From: DJAngel973 Date: Sat, 25 Apr 2026 00:07:33 -0500 Subject: [PATCH 2/5] feat(tests): add TransactionHistoryServiceTest with methods logic service and delete WalletApplicationTests --- .../wallet/secure/WalletApplicationTests.java | 13 - .../TransactionHistoryServiceTest.java | 359 ++++++++++++++++++ 2 files changed, 359 insertions(+), 13 deletions(-) delete mode 100644 src/test/java/com/wallet/secure/WalletApplicationTests.java create mode 100644 src/test/java/com/wallet/secure/transaction/service/TransactionHistoryServiceTest.java diff --git a/src/test/java/com/wallet/secure/WalletApplicationTests.java b/src/test/java/com/wallet/secure/WalletApplicationTests.java deleted file mode 100644 index d828ef4..0000000 --- a/src/test/java/com/wallet/secure/WalletApplicationTests.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.wallet.secure; - -import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; - -@SpringBootTest -class WalletApplicationTests { - - @Test - void contextLoads() { - } - -} diff --git a/src/test/java/com/wallet/secure/transaction/service/TransactionHistoryServiceTest.java b/src/test/java/com/wallet/secure/transaction/service/TransactionHistoryServiceTest.java new file mode 100644 index 0000000..bf666bc --- /dev/null +++ b/src/test/java/com/wallet/secure/transaction/service/TransactionHistoryServiceTest.java @@ -0,0 +1,359 @@ +package com.wallet.secure.transaction.service; + +import com.wallet.secure.common.enums.TransactionStatus; +import com.wallet.secure.common.exception.ResourceNotFoundException; +import com.wallet.secure.common.response.ApiResponse; +import com.wallet.secure.transaction.dto.TransactionHistoryResponse; +import com.wallet.secure.transaction.entity.Transaction; +import com.wallet.secure.transaction.entity.TransactionHistory; +import com.wallet.secure.transaction.repository.TransactionHistoryRepository; +import com.wallet.secure.transaction.repository.TransactionRepository; +import com.wallet.secure.user.entity.User; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +/** + * Unit tests for TransactionHistoryService. + * + * WHAT we test: + * 1. record() — persists a system entry with correct fields + * 2. recordManual() — persists a human entry with changedBy + reason + * 3. getTransactionTimeline() — ownership check (OWASP A01), not found, returns list + * 4. getTransactionTimelineAdmin() — no ownership check, not found, returns list + * 5. getWalletHistory() — delegates to repository, maps to response list + * + * WHAT we do NOT test: + * → @Transactional behavior — requires Spring context + * → fromEntity() mapping detail — covered implicitly via response assertions + */ +@ExtendWith(MockitoExtension.class) +@DisplayName("TransactionHistoryService") +class TransactionHistoryServiceTest { + + @Mock private TransactionHistoryRepository historyRepository; + @Mock private TransactionRepository transactionRepository; + + @InjectMocks + private TransactionHistoryService historyService; + + // ─── Shared test data + + private UUID userId; + private UUID transactionId; + private UUID walletId; + private Transaction testTransaction; + private User testUser; + + @BeforeEach + void setUp() { + userId = UUID.randomUUID(); + transactionId = UUID.randomUUID(); + walletId = UUID.randomUUID(); + + testUser = User.builder() + .id(userId) + .email("angel@test.com") + .passwordHash("$2a$12$hash") + .build(); + + testTransaction = Transaction.builder() + .id(transactionId) + .build(); + + // save() returns what is passed — standard repository stub + lenient().when(historyRepository.save(any(TransactionHistory.class))) + .thenAnswer(inv -> inv.getArgument(0)); + } + + // ─── Helper — builds a minimal TransactionHistory entity + + private TransactionHistory buildSystemEntry(TransactionStatus oldStatus, + TransactionStatus newStatus) { + return TransactionHistory.system(testTransaction, oldStatus, newStatus); + } + + private TransactionHistory buildManualEntry(TransactionStatus oldStatus, + TransactionStatus newStatus) { + return TransactionHistory.manual( + testTransaction, oldStatus, newStatus, testUser, "Reversed by admin"); + } + + // ─── record() + + @Nested + @DisplayName("record()") + class RecordTests { + + @Test + @DisplayName("saves a system entry with correct transaction, oldStatus and newStatus") + void record_savesSystemEntry() { + // WHEN + historyService.record(testTransaction, null, TransactionStatus.PENDING); + + // THEN — capture what was passed to save() + ArgumentCaptor captor = + ArgumentCaptor.forClass(TransactionHistory.class); + verify(historyRepository).save(captor.capture()); + + TransactionHistory saved = captor.getValue(); + assertThat(saved.getTransaction()).isEqualTo(testTransaction); + assertThat(saved.getOldStatus()).isNull(); // initial entry + assertThat(saved.getNewStatus()).isEqualTo(TransactionStatus.PENDING); + assertThat(saved.getChangedBy()).isNull(); // system = no actor + assertThat(saved.getReason()).isNull(); // system = no reason + } + + @Test + @DisplayName("saves each lifecycle step — PENDING→PROCESSING→COMPLETED trail") + void record_savesEachLifecycleStep() { + // WHEN — three steps of a normal transaction lifecycle + historyService.record(testTransaction, null, TransactionStatus.PENDING); + historyService.record(testTransaction, TransactionStatus.PENDING, TransactionStatus.PROCESSING); + historyService.record(testTransaction, TransactionStatus.PROCESSING, TransactionStatus.COMPLETED); + + // THEN — one save() call per step + verify(historyRepository, times(3)).save(any(TransactionHistory.class)); + } + + @Test + @DisplayName("does not throw when called fire-and-forget — OWASP A09 resilience") + void record_doesNotThrow() { + // WHEN / THEN — history recording must never crash the business operation + assertThatNoException().isThrownBy(() -> + historyService.record(testTransaction, + TransactionStatus.PENDING, TransactionStatus.FAILED)); + } + } + + // ─── recordManual() + + @Nested + @DisplayName("recordManual()") + class RecordManualTests { + + @Test + @DisplayName("saves a manual entry with changedBy user and reason") + void recordManual_savesHumanEntry() { + // WHEN + historyService.recordManual( + testTransaction, + TransactionStatus.COMPLETED, + TransactionStatus.FAILED, + testUser, + "Reversed by admin due to fraud"); + + // THEN + ArgumentCaptor captor = + ArgumentCaptor.forClass(TransactionHistory.class); + verify(historyRepository).save(captor.capture()); + + TransactionHistory saved = captor.getValue(); + assertThat(saved.getOldStatus()).isEqualTo(TransactionStatus.COMPLETED); + assertThat(saved.getNewStatus()).isEqualTo(TransactionStatus.FAILED); + assertThat(saved.getChangedBy()).isEqualTo(testUser); + assertThat(saved.getReason()).isEqualTo("Reversed by admin due to fraud"); + } + + @Test + @DisplayName("system() entry has changedBy=null; manual() entry has changedBy set") + void record_vs_recordManual_actorDifference() { + // WHEN + historyService.record(testTransaction, null, TransactionStatus.PENDING); + historyService.recordManual( + testTransaction, + TransactionStatus.PENDING, TransactionStatus.FAILED, + testUser, "Cancelled by user request"); + + // THEN + ArgumentCaptor captor = + ArgumentCaptor.forClass(TransactionHistory.class); + verify(historyRepository, times(2)).save(captor.capture()); + + List allSaved = captor.getAllValues(); + assertThat(allSaved.get(0).getChangedBy()).isNull(); // system + assertThat(allSaved.get(1).getChangedBy()).isEqualTo(testUser); // human + } + } + + // ─── getTransactionTimeline() + + @Nested + @DisplayName("getTransactionTimeline()") + class GetTransactionTimelineTests { + + @Test + @DisplayName("returns ordered timeline when user owns the transaction — OWASP A01") + void getTransactionTimeline_owner_returnsTimeline() { + // GIVEN — ownership check passes + when(transactionRepository.findByIdAndUserId(transactionId, userId)) + .thenReturn(Optional.of(testTransaction)); + + List entries = List.of( + buildSystemEntry(null, TransactionStatus.PENDING), + buildSystemEntry(TransactionStatus.PENDING, TransactionStatus.COMPLETED) + ); + when(historyRepository.findByTransactionIdOrderByCreatedAtAsc(transactionId)) + .thenReturn(entries); + + // WHEN + ApiResponse> response = + historyService.getTransactionTimeline(transactionId, userId); + + // THEN + assertThat(response.isSuccess()).isTrue(); + assertThat(response.getData()).hasSize(2); + assertThat(response.getData().get(0).getNewStatus()) + .isEqualTo(TransactionStatus.PENDING); + assertThat(response.getData().get(1).getNewStatus()) + .isEqualTo(TransactionStatus.COMPLETED); + } + + @Test + @DisplayName("throws 404 when transaction does not belong to the requesting user — OWASP A01") + void getTransactionTimeline_differentUser_throws404() { + // GIVEN — findByIdAndUserId returns empty → ownership failed + // Returns 404 (not 403) to prevent resource enumeration + UUID attackerId = UUID.randomUUID(); + when(transactionRepository.findByIdAndUserId(transactionId, attackerId)) + .thenReturn(Optional.empty()); + + // WHEN / THEN + // OWASP A01: attacker learns nothing — same response as "not found" + assertThatThrownBy(() -> + historyService.getTransactionTimeline(transactionId, attackerId)) + .isInstanceOf(ResourceNotFoundException.class) + .hasMessageContaining("Transaction not found"); + + // History repository is never queried — ownership failed early + verify(historyRepository, never()).findByTransactionIdOrderByCreatedAtAsc(any()); + } + + @Test + @DisplayName("returns empty list when transaction exists but has no history entries yet") + void getTransactionTimeline_noEntries_returnsEmptyList() { + // GIVEN — valid owner, but history table is empty for this transaction + when(transactionRepository.findByIdAndUserId(transactionId, userId)) + .thenReturn(Optional.of(testTransaction)); + when(historyRepository.findByTransactionIdOrderByCreatedAtAsc(transactionId)) + .thenReturn(List.of()); + + // WHEN + ApiResponse> response = + historyService.getTransactionTimeline(transactionId, userId); + + // THEN + assertThat(response.isSuccess()).isTrue(); + assertThat(response.getData()).isEmpty(); + } + } + + // ─── getTransactionTimelineAdmin() + + @Nested + @DisplayName("getTransactionTimelineAdmin()") + class GetTransactionTimelineAdminTests { + + @Test + @DisplayName("returns timeline for any transaction — no ownership check for admin") + void getTransactionTimelineAdmin_anyTransaction_returnsTimeline() { + // GIVEN — admin can query any transaction, no userId involved + when(transactionRepository.findById(transactionId)) + .thenReturn(Optional.of(testTransaction)); + + List entries = List.of( + buildSystemEntry(null, TransactionStatus.PENDING), + buildManualEntry(TransactionStatus.COMPLETED, TransactionStatus.FAILED) + ); + when(historyRepository.findByTransactionIdOrderByCreatedAtAsc(transactionId)) + .thenReturn(entries); + + // WHEN + ApiResponse> response = + historyService.getTransactionTimelineAdmin(transactionId); + + // THEN + assertThat(response.isSuccess()).isTrue(); + assertThat(response.getData()).hasSize(2); + + // First entry = system (automatic=true), second = manual (automatic=false) + assertThat(response.getData().get(0).isAutomatic()).isTrue(); + assertThat(response.getData().get(1).isAutomatic()).isFalse(); + assertThat(response.getData().get(1).getChangedById()).isEqualTo(userId); + assertThat(response.getData().get(1).getChangedByEmail()).isEqualTo("angel@test.com"); + } + + @Test + @DisplayName("throws 404 when transaction does not exist") + void getTransactionTimelineAdmin_transactionNotFound_throwsException() { + // GIVEN + when(transactionRepository.findById(transactionId)).thenReturn(Optional.empty()); + + // WHEN / THEN + assertThatThrownBy(() -> + historyService.getTransactionTimelineAdmin(transactionId)) + .isInstanceOf(ResourceNotFoundException.class) + .hasMessageContaining("Transaction not found"); + + verify(historyRepository, never()).findByTransactionIdOrderByCreatedAtAsc(any()); + } + } + + // ─── getWalletHistory() + + @Nested + @DisplayName("getWalletHistory()") + class GetWalletHistoryTests { + + @Test + @DisplayName("returns all history entries for a wallet — admin only") + void getWalletHistory_returnsAllEntries() { + // GIVEN + List entries = List.of( + buildSystemEntry(null, TransactionStatus.PENDING), + buildSystemEntry(TransactionStatus.PENDING, TransactionStatus.COMPLETED), + buildSystemEntry(null, TransactionStatus.PENDING), + buildSystemEntry(TransactionStatus.PENDING, TransactionStatus.FAILED) + ); + when(historyRepository.findByWalletId(walletId)).thenReturn(entries); + + // WHEN + ApiResponse> response = + historyService.getWalletHistory(walletId); + + // THEN + assertThat(response.isSuccess()).isTrue(); + assertThat(response.getData()).hasSize(4); + verify(historyRepository).findByWalletId(walletId); + } + + @Test + @DisplayName("returns empty list when wallet has no transaction history") + void getWalletHistory_noEntries_returnsEmptyList() { + // GIVEN + when(historyRepository.findByWalletId(walletId)).thenReturn(List.of()); + + // WHEN + ApiResponse> response = + historyService.getWalletHistory(walletId); + + // THEN + assertThat(response.isSuccess()).isTrue(); + assertThat(response.getData()).isEmpty(); + } + } +} From f0caf76a12e6285cb14706d182e9d61a591146ad Mon Sep 17 00:00:00 2001 From: DJAngel973 Date: Fri, 1 May 2026 21:07:49 -0500 Subject: [PATCH 3/5] docs: Update readme project --- README.md | 407 ++++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 347 insertions(+), 60 deletions(-) diff --git a/README.md b/README.md index 472c4b0..a92af62 100644 --- a/README.md +++ b/README.md @@ -1,63 +1,214 @@ -# Secure Wallet API +# 🔐 Secure Wallet API -A backend REST API for a secure digital wallet system, built with **Java 17**, **Spring Boot 3**, **PostgreSQL 16**, and **Docker**. +A **learning project** built to explore real-world security practices in the context of a digital savings wallet REST API. -Designed with a **DevSecOps-first approach**: security, automation, testing, and code quality are built in from day one — not added as an afterthought. +Built with **Java 17**, **Spring Boot 3.4.2**, **PostgreSQL 16** and **Docker**. + +> **Security is a first-class concern here — not an afterthought.** +> Every architectural decision is documented, every security rule references OWASP, and every sensitive operation is audited. -> **This project is a portfolio project, actively under development.** --- -## Features +## 🎯 What This Project Is + +This is an academic/portfolio project designed to answer a specific question: + +> *How would a financial API look if security, auditability, and correctness were treated as requirements from day one — before a single line of business code is written?* -### User Management (Admin) -- Create, update, and soft-delete users +The result is a digital savings wallet where users can deposit, withdraw, and transfer money between accounts — built with the same security discipline you'd expect in a real fintech product. + +**Core priorities (in order):** +1. **Security** — OWASP Top 10 (2025) compliance throughout +2. **Correctness** — ACID financial transactions, no money lost +3. **Auditability** — every sensitive operation logged and traceable +4. **Maintainability** — clean, documented, testable code + +--- + +## ✨ Features + +### 👤 User Management +- Register, update profile, and soft-delete accounts - Role-based access control: `USER`, `ADMIN`, `MANAGER` -- Account locking after failed login attempts -- Full audit trail of administrative actions +- Account lockout after 5 failed login attempts (30-minute cooldown) +- Two-Factor Authentication (2FA / TOTP — RFC 6238, Google Authenticator compatible) +- Email verification flag and login tracking -### Wallet Service (User) -- Digital savings account (wallet) per user, per currency -- Multi-currency support (USD, EUR, COP, MXN, ARS) +### 💰 Savings Wallets +- One wallet per user per currency +- Multi-currency support: USD, EUR, COP, MXN, ARS - Real-time balance with ACID-guaranteed updates - -### Transaction Service (User) -- **Deposit**: add funds from an external source -- **Withdrawal**: withdraw funds to an external destination -- **Transfer**: move funds between any two wallets -- Transaction history and account statement export - -### Security (DevSecOps) -- JWT authentication with refresh token rotation -- Two-Factor Authentication (2FA / TOTP) -- BCrypt password hashing — never stored in plain text -- Rate limiting and brute-force protection -- OWASP Dependency Check on every PR (CVE scan) +- Wallet states: `ACTIVE`, `SUSPENDED`, `CLOSED` + +### 💸 Financial Transactions +| Operation | Description | +|-----------|-------------| +| **Deposit** | Add funds from an external source into a wallet | +| **Withdrawal** | Move funds from a wallet to an external destination | +| **Transfer** | Move funds between any two wallets in the system | +| **History** | Full state-change log per transaction | + +- 2FA required for operations above **$100** +- Transaction limit: $10,000 | Daily limit: $50,000 +- Pessimistic locking (`FOR UPDATE`) on all balance reads +- Unique reference code per transaction +- JSONB metadata (IP, device, geolocation) for fraud detection + +### 🔒 Security & DevSecOps +- Dual-token JWT: Access Token (15 min) + Refresh Token (7 days) +- Refresh Token rotation and revocation stored in the database +- SHA-256 for token hashing — BCrypt only for passwords +- UUID primary keys on all entities (prevents enumeration) +- Stack traces never exposed to the client +- OWASP Dependency Check in CI — **fails build on CVSS ≥ 7** - CodeQL static analysis on every PR +### 📋 Audit Trail +- Independent `audit/` domain — auditing is a business requirement, not a cross-cutting concern +- Every sensitive operation generates an `audit_logs` entry +- IP address, User-Agent, timestamp, and JSONB details per event +- PostgreSQL triggers for automatic state-change auditing + --- -## Tech Stack +## 🏗️ Architecture -| Layer | Technology | -|-------|-----------| -| Language | Java 17 | -| Framework | Spring Boot 3 | -| Security | Spring Security + JWT | -| Database | PostgreSQL 16 | -| Containers | Docker + Docker Compose | -| Build | Maven 3.9 | -| CI/CD | GitHub Actions | -| Security Scan | OWASP Dependency Check | -| Static Analysis | CodeQL | +**Pattern:** Hybrid Domain-Driven Design — layers organized **by domain**, never by class type. + +``` +com.wallet.secure/ +├── config/ # SecurityConfig, OpenApiConfig, AuditConfig, JwtConfig +├── auth/ # Authentication: JWT, 2FA, sessions +│ ├── controller/ # AuthController, SessionController +│ ├── dto/ # LoginRequest, AuthResponse, RefreshTokenRequest +│ ├── entity/ # Session +│ ├── repository/ # SessionRepository +│ ├── security/ # JwtAuthFilter, JwtService, UserDetailsServiceImpl +│ └── service/ # AuthService, SessionService +├── user/ # Users and profiles +├── wallet/ # Wallets and balances +├── transaction/ # Transactions + history (business core) +├── audit/ # Security audit trail +└── common/ # ApiResponse, exceptions, enums, validators +``` + +**Architecture rule:** NEVER create root-level folders by type (`controllers/`, `services/`). Each domain owns its own layers. + +> 📖 [`CONTEXT.md`](CONTEXT.md) — Single source of truth: full architecture, stack, conventions, and rules. +> 📖 [`DECISIONS.md`](DECISIONS.md) — 8 Architecture Decision Records with full reasoning for every major choice. --- -## Getting Started +## 🛡️ Security Model + +### Why Authentication and Authorization Matter Here + +A wallet API without a solid auth model is not a wallet — it's an open bank account. + +This project enforces two distinct layers: + +**Authentication** — *Who are you?* +Dual-token JWT ensures a stolen Access Token is useless after 15 minutes. The Refresh Token is stored as a SHA-256 hash in the database, enabling real revocation on logout. Sessions are tracked per device. + +**Authorization** — *What are you allowed to do?* +`userId` is **always** extracted from the JWT — never from the request body or path parameters. This means a user cannot access another user's wallet by simply changing an ID in the URL. Resources respond with `404` (not `403`) to prevent enumeration. + +``` +POST /auth/login + → Validate credentials (BCrypt password check) + → Check account not locked + → If operation requires 2FA → POST /auth/2fa/verify + → Issue Access Token (15 min) + Refresh Token (7 days) + → Register session in DB + → Audit log event + → Return tokens + +POST /auth/refresh + → Validate Refresh Token (signature + expiry + exists in DB) + → Rotate token (invalidate old, issue new) + → Return new Access Token + new Refresh Token + +POST /auth/logout + → Revoke Refresh Token in DB + → Audit log event + → Access Token expires naturally within 15 min +``` + +### Session Parameters + +| Parameter | Value | Reason | +|-----------|-------|--------| +| Access Token TTL | 15 minutes | Limits exposure if stolen | +| Refresh Token TTL | 7 days | Security/UX balance | +| Max inactivity | 30 minutes | Banking standard | +| Concurrent sessions | Allowed | Tracked per device in DB | +| 2FA threshold | $100 | Protects everyday transactions, not just large ones | + +### OWASP Top 10 (2025) Coverage + +> OWASP Top 10 2025 was published on November 6, 2025 at the Global AppSec Conference in Washington D.C. + +| # | Risk | Status | How it is addressed | +|---|------|--------|---------------------| +| A01 | Broken Access Control *(includes SSRF)* | ✅ | `userId` from JWT only — never from request. `404` on ownership mismatch. RBAC via Spring Security. API makes no outbound calls (SSRF N/A) | +| A02 | Cryptographic Failures | ✅ | BCrypt strength 12 for passwords. SHA-256 for token hashing. JWT signed with HS256. HTTPS required in production. `DECIMAL(19,4)` for money — no float precision errors | +| A03 | Software Supply Chain Failures 🆕 | ✅ | OWASP Dependency Check in CI (`failOnCVSS ≥ 7`). All dependency versions pinned in `pom.xml` | +| A04 | Injection | ✅ | Hibernate prepared statements on all queries. Bean Validation (`@Valid`) on every endpoint input | +| A05 | Security Misconfiguration | ✅ | Error messages never expose internals. Stack traces never sent to client. No default credentials. Secure HTTP headers. Limited Actuator exposure | +| A06 | Insecure Design | ✅ | Documented threat model. Transaction limits. 2FA threshold at $100. Pessimistic locking for concurrent balance reads | +| A07 | Identification & Authentication Failures | ✅ | Dual-token JWT (15 min / 7 days). TOTP 2FA (RFC 6238). Account lockout. 30-min inactivity. Refresh Token rotation and revocation | +| A08 | Software and Data Integrity Failures | ✅ | CodeQL static analysis in GitHub Actions. SQL schema versioned in Git. Hibernate `ddl-auto: validate` | +| A09 | Security Logging & Monitoring Failures | ✅ | Every security event logged to `audit_logs`. Log4j2 async logging. No passwords or tokens ever written to logs | +| A10 | Mishandling of Exceptional Conditions 🆕 | ✅ | Centralized `GlobalExceptionHandler` — never "fail open". No sensitive data in error responses | + +--- + +## 🗄️ Database + +PostgreSQL 16 with schema managed exclusively by versioned SQL scripts. Hibernate only validates — never creates or modifies tables. + +| # | Script | Purpose | +|---|--------|---------| +| 01 | `extensions.sql` | PostgreSQL extensions: `pgcrypto`, `uuid-ossp` | +| 02 | `types.sql` | 7 custom ENUMs (roles, currencies, statuses) | +| 03 | `tables.sql` | 6 core tables with constraints and FK rules | +| 04 | `index.sql` | Partial, composite, and GIN indexes | +| 05 | `triggers.sql` | Auto-update timestamps, balance validation, state-change audit | +| 06 | `functions.sql` | `process_transaction()` ACID, `cleanup_expired_sessions()` | +| 07 | `seed.sql` | Development test data only | +| 08 | `migrations.sql` | Incremental schema changes | + +> 📖 [`database/README.md`](database/README.md) — Full ER diagram, FK rules, and DB security design decisions. + +--- + +## 🛠️ Tech Stack + +| Component | Technology | Version | +|-----------|-----------|---------| +| Language | Java | 17 | +| Framework | Spring Boot | 3.4.2 | +| Security | Spring Security + JWT | jjwt 0.12.6 | +| Database | PostgreSQL | 16-alpine | +| ORM | Spring Data JPA / Hibernate | — | +| Logging | Log4j2 | 2.25.3 | +| Validation | Spring Bean Validation | — | +| Boilerplate | Lombok | 1.18.36 | +| API Docs | SpringDoc OpenAPI | 2.8.8 | +| Tests | JUnit 5 + Mockito + AssertJ | — | +| Containers | Docker + Docker Compose | — | +| CI/CD | GitHub Actions | — | +| Static Analysis | CodeQL | — | +| Dependency Scanning | OWASP Dependency Check | 12.2.0 | + +--- + +## 🚀 Getting Started ### Prerequisites - Java 17+ - Docker & Docker Compose -- Maven 3.9+ +- Maven 3.9+ (Maven wrapper `./mvnw` included) ### Run locally @@ -66,56 +217,192 @@ Designed with a **DevSecOps-first approach**: security, automation, testing, and git clone https://github.com/DJAngel973/Secure-Wallet-API.git cd Secure-Wallet-API -# 2. Copy environment variables +# 2. Set up environment variables cp .env.example .env -# Edit .env with your local values +# Edit .env with your local values (DB credentials, JWT secret, ports) -# 3. Start PostgreSQL -docker compose up -d +# 3. Start everything (automated script) +./script/dev-start.sh -# 4. Run the application +# Or step by step: +docker compose up -d # PostgreSQL only +docker compose --profile tools up -d # PostgreSQL + pgAdmin at http://localhost:5050 ./mvnw spring-boot:run -Dspring-boot.run.profiles=dev ``` ---- +### Stop -## Testing +```bash +./script/dev-stop.sh +``` + +### Run tests ```bash -# Run unit tests -./mvnw test +./mvnw test # All unit tests (H2 in-memory, no DB needed) +./mvnw test -Dtest=TransactionServiceTest # Single test class +``` + +--- + +## 📡 Using the API + +Once the application is running, the full interactive API documentation is available at: + +``` +http://localhost:8080/swagger-ui.html +``` + +All endpoints return a standard `ApiResponse` envelope: + +```json +{ + "success": true, + "message": "Operation completed", + "data": { } +} +``` + +### 1. Register a new user + +```http +POST /api/v1/users/register +Content-Type: application/json + +{ + "email": "alice@example.com", + "password": "ExampleSecureP@ss1", + "firstName": "Alice", + "lastName": "Smith" +} +``` + +### 2. Login and get tokens + +```http +POST /api/v1/auth/login +Content-Type: application/json + +{ + "email": "alice@example.com", + "password": "ExampleSecureP@ss1" +} +``` + +```json +{ + "success": true, + "data": { + "accessToken": "ExampleekeypracticeyJhbGc...", + "refreshToken": "ExampleekeypracticeyJhbGcNiJ9...", + "tokenType": "Bearer", + "expiresIn": 900 + } +} +``` + +> Use the `accessToken` as a Bearer token in the `Authorization` header for all subsequent requests. + +### 3. Create a wallet + +```http +POST /api/v1/wallets +Authorization: Bearer +Content-Type: application/json + +{ + "currency": "USD" +} +``` + +### 4. Deposit funds + +```http +POST /api/v1/transactions/deposit +Authorization: Bearer +Content-Type: application/json + +{ + "walletId": "ExampleekeypracticeyJhbGcafa6", + "amount": 500.00, + "description": "Initial deposit" +} +``` + +### 5. Transfer between wallets + +```http +POST /api/v1/transactions/transfer +Authorization: Bearer +Content-Type: application/json + +{ + "sourceWalletId": "ExampleekeypracticeyJhbGc-2c963f66afa6", + "targetWalletId": "ExampleekeypracticeyJhbGc-3d074g77bgb7", + "amount": 150.00, + "description": "Splitting dinner" +} +``` + +> **Transfers above $100 require a valid TOTP code.** Include the `X-2FA-Code` header with a 6-digit code from your authenticator app. + +### 6. Refresh your access token + +```http +POST /api/v1/auth/refresh +Content-Type: application/json + +{ + "refreshToken": "ExampleekeypracticeyJhbGcNiJ9..." +} +``` + +### 7. Logout + +```http +POST /api/v1/auth/logout +Authorization: Bearer +Content-Type: application/json + +{ + "refreshToken": "ExampleekeypracticeyJhbGcNiJ9..." +} ``` --- -## CI/CD Pipeline +## 🔄 CI/CD Pipeline | Job | Trigger | Description | |-----|---------|-------------| -| Build & Test | Every PR / push | Compile + unit tests | -| OWASP Dependency Check | Every PR / push | CVE scan — fails on CVSS ≥ 7 | -| CodeQL Analysis | Every PR / push | Static security analysis | -| Package JAR | Merge to `main` | Build production artifact | +| **Build & Test** | Every PR / push | Compile + unit tests (JUnit 5, `test` profile with H2) | +| **OWASP Dependency Check** | Every PR / push | CVE scan — **fails on CVSS ≥ 7** | +| **CodeQL Analysis** | Every PR / push | Static security analysis | +| **Package JAR** | Merge to `main` | Builds production artifact | --- -## Project Documentation +## 📚 Project Documentation | Document | Description | |----------|-------------| -| [`database/README.md`](database/README.md) | Database schema, ER diagram, and setup guide | +| [`CONTEXT.md`](CONTEXT.md) | Single source of truth: architecture, stack, conventions, and rules | +| [`DECISIONS.md`](DECISIONS.md) | 8 Architecture Decision Records with full reasoning | +| [`SECURITY.md`](SECURITY.md) | Threat model, OWASP Top 10 (2025) full coverage, unbreakable rules | +| [`AGENTS.md`](AGENTS.md) | Instructions for AI agents working on this codebase | +| [`database/README.md`](database/README.md) | ER diagram, FK rules, DB security design decisions | | [`CONTRIBUTING.md`](CONTRIBUTING.md) | How to contribute | | [`CODE_OF_CONDUCT.md`](CODE_OF_CONDUCT.md) | Community standards | --- -## Security Policy +## 🔐 Security Vulnerability Reporting -If you discover a vulnerability, please **do not open a public issue**. -Contact via GitHub private security advisory. +This is an academic/portfolio project. +If you find a vulnerability, please open an issue with the `security` label. --- -## License +## 📄 License -[MIT](LICENSE) © 2025 DJAngel973 \ No newline at end of file +[MIT](LICENSE) © 2025–2026 DJAngel973 From d1a32f292782041cd40e11539390df8bb40ef169 Mon Sep 17 00:00:00 2001 From: DJAngel973 Date: Fri, 1 May 2026 21:25:11 -0500 Subject: [PATCH 4/5] docs: correct endpoints in readme and application,yml --- README.md | 46 ++++++++++++++++++++---------- src/main/resources/application.yml | 2 +- 2 files changed, 32 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index a92af62..a0dcac2 100644 --- a/README.md +++ b/README.md @@ -266,12 +266,12 @@ All endpoints return a standard `ApiResponse` envelope: ### 1. Register a new user ```http -POST /api/v1/users/register +POST /auth/register Content-Type: application/json { "email": "alice@example.com", - "password": "ExampleSecureP@ss1", + "password": "SecureP@ss1", "firstName": "Alice", "lastName": "Smith" } @@ -280,7 +280,7 @@ Content-Type: application/json ### 2. Login and get tokens ```http -POST /api/v1/auth/login +POST /auth/login Content-Type: application/json { @@ -292,6 +292,7 @@ Content-Type: application/json ```json { "success": true, + "message": "Login successful", "data": { "accessToken": "ExampleekeypracticeyJhbGc...", "refreshToken": "ExampleekeypracticeyJhbGcNiJ9...", @@ -301,12 +302,19 @@ Content-Type: application/json } ``` -> Use the `accessToken` as a Bearer token in the `Authorization` header for all subsequent requests. +> Use the `accessToken` as a Bearer token in the `Authorization` header for all protected requests. -### 3. Create a wallet +### 3. Get your own profile ```http -POST /api/v1/wallets +GET /users/me +Authorization: Bearer +``` + +### 4. Create a wallet + +```http +POST /wallets Authorization: Bearer Content-Type: application/json @@ -315,10 +323,10 @@ Content-Type: application/json } ``` -### 4. Deposit funds +### 5. Deposit funds ```http -POST /api/v1/transactions/deposit +POST /transactions/deposit Authorization: Bearer Content-Type: application/json @@ -329,11 +337,14 @@ Content-Type: application/json } ``` -### 5. Transfer between wallets +### 6. Transfer between wallets + +> Amounts above **$100** require a valid TOTP code. Add the `X-2FA-Code` header with the 6-digit code from your authenticator app. ```http -POST /api/v1/transactions/transfer +POST /transactions/transfer Authorization: Bearer +X-2FA-Code: 123456 Content-Type: application/json { @@ -344,12 +355,17 @@ Content-Type: application/json } ``` -> **Transfers above $100 require a valid TOTP code.** Include the `X-2FA-Code` header with a 6-digit code from your authenticator app. +### 7. Get transaction history for a wallet -### 6. Refresh your access token +```http +GET /transactions/wallet/ExampleekeypracticeyJhbGcafa6 +Authorization: Bearer +``` + +### 8. Refresh your access token ```http -POST /api/v1/auth/refresh +POST /auth/refresh Content-Type: application/json { @@ -357,10 +373,10 @@ Content-Type: application/json } ``` -### 7. Logout +### 9. Logout ```http -POST /api/v1/auth/logout +POST /auth/logout Authorization: Bearer Content-Type: application/json diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index c9374e4..b223c15 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -123,7 +123,7 @@ app: transaction: max-amount: 10000.00 # Maximum amount per transaction daily-limit: 50000.00 # Daily limit per user - require-2fa-amount: 5000.00 # Requires 2FA for larger amounts + require-2fa-amount: 100.00 # Requires 2FA for amounts above $100 (ADR-005) # Security settings security: From 51644cc597df23426e6303c29263d971200c1d6c Mon Sep 17 00:00:00 2001 From: DJAngel973 Date: Sat, 2 May 2026 13:26:54 -0500 Subject: [PATCH 5/5] fix: bug desactiveAccount(), UserService without AuditService and restoreWallet() without audit log --- database/08-migrations.sql | 10 ++- .../secure/audit/service/AuditService.java | 21 ++++++ .../secure/common/enums/AuditAction.java | 1 + .../secure/user/service/UserService.java | 35 +++++++--- .../secure/wallet/service/WalletService.java | 4 ++ .../secure/user/service/UserServiceTest.java | 69 +++++++++++++++++-- 6 files changed, 125 insertions(+), 15 deletions(-) diff --git a/database/08-migrations.sql b/database/08-migrations.sql index de56775..1ba226d 100644 --- a/database/08-migrations.sql +++ b/database/08-migrations.sql @@ -11,4 +11,12 @@ CREATE INDEX IF NOT EXISTS idx_users_refresh_token WHERE refresh_token IS NOT NULL; COMMENT ON COLUMN users.refresh_token IS - 'Active refresh token. NULL = logged out. Invalidated on logout.'; \ No newline at end of file + 'Active refresh token. NULL = logged out. Invalidated on logout.'; + +-- Migration: add WALLET_RESTORE to audit_action enum +-- Date: 2026-05-02 +-- Reason: restoreWallet() is an ADMIN action and must be auditable (OWASP A09). +-- All other wallet admin ops (suspend, close) already have their own action. +-- Using ALTER TYPE ... ADD VALUE because PostgreSQL enums are append-only. + +ALTER TYPE audit_action ADD VALUE IF NOT EXISTS 'WALLET_RESTORE'; \ No newline at end of file diff --git a/src/main/java/com/wallet/secure/audit/service/AuditService.java b/src/main/java/com/wallet/secure/audit/service/AuditService.java index b6db81e..dcd99a0 100644 --- a/src/main/java/com/wallet/secure/audit/service/AuditService.java +++ b/src/main/java/com/wallet/secure/audit/service/AuditService.java @@ -227,6 +227,27 @@ public void logWalletClosed(UUID adminId, UUID walletId, details, ipAddress, userAgent)); } + /** + * Logs a wallet restoration from SUSPENDED to ACTIVE — ADMIN action. + * Called by WalletService.restoreWallet(). + * WHY this needs its own audit entry: restoring a wallet re-enables all + * financial operations on it. That is a security-relevant state change + * that must be traceable to the admin who performed it. + * OWASP A09: all wallet state changes by admins must be logged. + */ + @Async + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void logWalletRestored(UUID adminId, UUID walletId, + String ipAddress, String userAgent) { + String details = buildDetails( + "\"walletId\":\"" + walletId + "\"", + "\"adminId\":\"" + adminId + "\"", + "\"outcome\":\"SUCCESS\"" + ); + save(AuditLog.success(adminId, AuditAction.WALLET_RESTORE, LogSeverity.WARNING, + details, ipAddress, userAgent)); + } + // ─── Security Events /** diff --git a/src/main/java/com/wallet/secure/common/enums/AuditAction.java b/src/main/java/com/wallet/secure/common/enums/AuditAction.java index f4cff43..03be828 100644 --- a/src/main/java/com/wallet/secure/common/enums/AuditAction.java +++ b/src/main/java/com/wallet/secure/common/enums/AuditAction.java @@ -25,6 +25,7 @@ public enum AuditAction { WALLET_CREATE, WALLET_SUSPEND, WALLET_CLOSE, + WALLET_RESTORE, // Transaction lifecycle TRANSACTION_CREATE, diff --git a/src/main/java/com/wallet/secure/user/service/UserService.java b/src/main/java/com/wallet/secure/user/service/UserService.java index 2a6fb7c..3296999 100644 --- a/src/main/java/com/wallet/secure/user/service/UserService.java +++ b/src/main/java/com/wallet/secure/user/service/UserService.java @@ -1,10 +1,13 @@ package com.wallet.secure.user.service; +import com.wallet.secure.audit.service.AuditService; +import com.wallet.secure.common.enums.UserRole; import com.wallet.secure.common.exception.EmailAlreadyExistsException; import com.wallet.secure.common.exception.InvalidCredentialsException; import com.wallet.secure.common.exception.UnauthorizedOperationException; import com.wallet.secure.common.exception.UserNotFoundException; import com.wallet.secure.common.response.ApiResponse; +import com.wallet.secure.common.util.LogSanitizer; import com.wallet.secure.user.dto.RegisterRequest; import com.wallet.secure.user.dto.UpdateProfileRequest; import com.wallet.secure.user.dto.UserResponse; @@ -15,7 +18,6 @@ import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import com.wallet.secure.common.util.LogSanitizer; import java.util.UUID; @@ -42,6 +44,7 @@ public class UserService { private final UserRepository userRepository; private final PasswordEncoder passwordEncoder; + private final AuditService auditService; // ─── Registration ───────────────────────────────────────────────────────── @@ -149,6 +152,8 @@ public ApiResponse updateProfile(UUID userId, UpdateProfileRequest user.setPasswordHash(passwordEncoder.encode(request.getPassword())); log.info("Password changed: userId={}", userId); updated = true; + // OWASP A09: password change is a high-sensitivity event — always audited + auditService.logPasswordChange(userId, user.getEmail(), null, null); } if (!updated) { @@ -177,18 +182,32 @@ public ApiResponse updateProfile(UUID userId, UpdateProfileRequest public ApiResponse deactivateAccount(UUID userId, UUID requesterId) { User user = findUserById(userId); - // OWASP A01: only the user or an ADMIN can deactivate an account - // Fine-grained check — controller also uses @PreAuthorize - if (!userId.equals(requesterId)) { - log.warn("Unauthorized deactivation attempt: requesterId={} tried userId={}", - requesterId, userId); - throw new UnauthorizedOperationException("Not authorized to deactivate this account"); + // OWASP A01: only the account owner OR an ADMIN can deactivate an account. + // WHY we also load the requester: comparing UUIDs alone is not enough — + // an ADMIN will always have a different UUID than the target user. + // We must verify the requester's role to allow cross-user deactivation. + boolean isSelf = userId.equals(requesterId); + if (!isSelf) { + User requester = findUserById(requesterId); + boolean isAdmin = UserRole.ADMIN.equals(requester.getRole()); + if (!isAdmin) { + log.warn("Unauthorized deactivation attempt: requesterId={} tried userId={}", + requesterId, userId); + throw new UnauthorizedOperationException("Not authorized to deactivate this account"); + } } user.setIsActive(false); userRepository.save(user); - log.info("Account deactivated: userId={}", userId); + // OWASP A09: account deactivation is a high-sensitivity operation — always audited + auditService.logCriticalSecurityEvent( + userId, + "Account deactivated by " + (userId.equals(requesterId) ? "owner" : "ADMIN requesterId=" + requesterId), + null, null + ); + + log.info("Account deactivated: userId={} by requesterId={}", userId, requesterId); return ApiResponse.ok("Account deactivated successfully"); } diff --git a/src/main/java/com/wallet/secure/wallet/service/WalletService.java b/src/main/java/com/wallet/secure/wallet/service/WalletService.java index 6c83ab8..6c160e1 100644 --- a/src/main/java/com/wallet/secure/wallet/service/WalletService.java +++ b/src/main/java/com/wallet/secure/wallet/service/WalletService.java @@ -239,6 +239,10 @@ public ApiResponse restoreWallet(UUID walletId) { wallet.setStatus(WalletStatus.ACTIVE); Wallet saved = walletRepository.save(wallet); + // OWASP A09: wallet restoration re-enables all financial operations on it — + // this is a security-relevant state change that must be traceable to the admin. + auditService.logWalletRestored(wallet.getUser().getId(), walletId, null, null); + log.warn("Wallet restored to ACTIVE: walletId={}", walletId); return ApiResponse.ok("Wallet restored", WalletResponse.fromEntity(saved)); diff --git a/src/test/java/com/wallet/secure/user/service/UserServiceTest.java b/src/test/java/com/wallet/secure/user/service/UserServiceTest.java index 68d74f6..f5781bc 100644 --- a/src/test/java/com/wallet/secure/user/service/UserServiceTest.java +++ b/src/test/java/com/wallet/secure/user/service/UserServiceTest.java @@ -1,7 +1,9 @@ package com.wallet.secure.user.service; +import com.wallet.secure.audit.service.AuditService; import com.wallet.secure.common.exception.EmailAlreadyExistsException; import com.wallet.secure.common.exception.InvalidCredentialsException; +import com.wallet.secure.common.exception.UnauthorizedOperationException; import com.wallet.secure.common.exception.UserNotFoundException; import com.wallet.secure.common.response.ApiResponse; import com.wallet.secure.common.enums.UserRole; @@ -49,6 +51,9 @@ class UserServiceTest { @Mock private PasswordEncoder passwordEncoder; + @Mock + private AuditService auditService; + @InjectMocks private UserService userService; @@ -221,10 +226,8 @@ void shouldUpdatePasswordSuccessfully() { setField(request, "password", "NewPass12!"); when(userRepository.findById(testUserId)).thenReturn(Optional.of(testUser)); - // currentPassword matches the stored hash when(passwordEncoder.matches("OldPass12!", "$2a$12$hashedPassword")) .thenReturn(true); - // new password is different from current when(passwordEncoder.matches("NewPass12!", "$2a$12$hashedPassword")) .thenReturn(false); when(passwordEncoder.encode("NewPass12!")).thenReturn("$2a$12$newHash"); @@ -237,6 +240,8 @@ void shouldUpdatePasswordSuccessfully() { assertThat(response.isSuccess()).isTrue(); verify(passwordEncoder).encode("NewPass12!"); verify(userRepository).save(any(User.class)); + // OWASP A09: password change MUST generate an audit log entry + verify(auditService).logPasswordChange(eq(testUserId), anyString(), isNull(), isNull()); } @Test @@ -289,17 +294,69 @@ class DeactivateTests { @Test @DisplayName("Should deactivate own account successfully") void shouldDeactivateOwnAccount() { - // ARRANGE — user deactivates themselves (userId == requesterId) + // GIVEN — user deactivates themselves (userId == requesterId) when(userRepository.findById(testUserId)).thenReturn(Optional.of(testUser)); when(userRepository.save(any(User.class))).thenReturn(testUser); - // ACT + // WHEN ApiResponse response = userService.deactivateAccount(testUserId, testUserId); - // ASSERT + // THEN + assertThat(response.isSuccess()).isTrue(); + verify(userRepository).save(argThat(u -> !u.getIsActive())); + // OWASP A09: deactivation is a critical event — must be audited + verify(auditService).logCriticalSecurityEvent(eq(testUserId), anyString(), isNull(), isNull()); + } + + @Test + @DisplayName("Should allow ADMIN to deactivate another user") + void shouldAllowAdminToDeactivateAnotherUser() { + // GIVEN — admin (different UUID) deactivates target user + UUID adminId = UUID.randomUUID(); + User adminUser = User.builder() + .email("admin@test.com") + .passwordHash("$2a$12$adminHash") + .role(UserRole.ADMIN) + .build(); + adminUser.setId(adminId); + + // findById is called twice: once for the target, once for the requester + when(userRepository.findById(testUserId)).thenReturn(Optional.of(testUser)); + when(userRepository.findById(adminId)).thenReturn(Optional.of(adminUser)); + when(userRepository.save(any(User.class))).thenReturn(testUser); + + // WHEN + ApiResponse response = userService.deactivateAccount(testUserId, adminId); + + // THEN assertThat(response.isSuccess()).isTrue(); - // Verify isActive was set to false verify(userRepository).save(argThat(u -> !u.getIsActive())); + verify(auditService).logCriticalSecurityEvent(eq(testUserId), anyString(), isNull(), isNull()); + } + + @Test + @DisplayName("Should throw UnauthorizedOperationException when non-ADMIN tries to deactivate another user") + void shouldThrowWhenNonAdminTriesToDeactivateAnotherUser() { + // GIVEN — a regular USER tries to deactivate someone else's account + UUID intruderId = UUID.randomUUID(); + User intruder = User.builder() + .email("intruder@test.com") + .passwordHash("$2a$12$hash") + .role(UserRole.USER) + .build(); + intruder.setId(intruderId); + + when(userRepository.findById(testUserId)).thenReturn(Optional.of(testUser)); + when(userRepository.findById(intruderId)).thenReturn(Optional.of(intruder)); + + // WHEN / THEN + assertThatThrownBy(() -> userService.deactivateAccount(testUserId, intruderId)) + .isInstanceOf(UnauthorizedOperationException.class); + + // Account must NOT have been deactivated + verify(userRepository, never()).save(any()); + // No audit log for a blocked attempt + verify(auditService, never()).logCriticalSecurityEvent(any(), any(), any(), any()); } }