From 581c67b5ef38d0803777165d7b54055abf81c7f6 Mon Sep 17 00:00:00 2001 From: DJAngel973 Date: Fri, 24 Apr 2026 23:38:49 -0500 Subject: [PATCH 1/4] 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/4] 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/4] 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/4] 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: