main
java 183 lines 11.9 KB
Raw
1 package com.aiinvestment.auth;
2
3 import com.aiinvestment.shared.web.auth.HmacJwtService;
4 import jakarta.persistence.EntityManager;
5 import org.junit.jupiter.api.BeforeEach;
6 import org.junit.jupiter.api.Test;
7 import org.springframework.beans.factory.annotation.Autowired;
8 import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
9 import org.springframework.boot.test.context.SpringBootTest;
10 import org.springframework.http.MediaType;
11 import org.springframework.test.context.ActiveProfiles;
12 import org.springframework.test.web.servlet.MockMvc;
13 import org.springframework.transaction.annotation.Transactional;
14
15 import java.nio.charset.StandardCharsets;
16 import java.security.MessageDigest;
17 import java.time.Instant;
18 import java.util.Base64;
19 import java.util.UUID;
20
21 import static org.assertj.core.api.Assertions.assertThat;
22 import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
23 import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
24
25 @SpringBootTest
26 @AutoConfigureMockMvc
27 @ActiveProfiles("test")
28 class AuthLifecycleIntegrationTest {
29 private static final String SECRET = "test-auth-jwt-secret-at-least-32-characters";
30 @Autowired MockMvc mvc;
31 @Autowired AppUserRepository users;
32 @Autowired EmailVerificationTokenRepository tokens;
33 @Autowired PasswordResetTokenRepository resetTokens;
34 @Autowired EntityManager entityManager;
35
36 @BeforeEach
37 void clearData() {
38 tokens.deleteAll();
39 resetTokens.deleteAll();
40 users.deleteAll();
41 }
42
43 @Test
44 void registerVerifyAndLoginUsesDurableUuidSubjectAndHashedCredentials() throws Exception {
45 mvc.perform(post("/api/v1/auth/register").contentType(MediaType.APPLICATION_JSON)
46 .content("{\"email\":\" Real.User@Example.Test \",\"password\":\"correct-password-123\",\"firstName\":\"Real\",\"lastName\":\"User\"}"))
47 .andExpect(status().isCreated())
48 .andExpect(jsonPath("$.status").value("EMAIL_VERIFICATION_PENDING"));
49 AppUserEntity user = users.findByNormalizedEmail("real.user@example.test").orElseThrow();
50 assertThat(user.getPasswordHash()).startsWith("$2");
51 assertThat(user.getPasswordHash()).doesNotContain("correct-password-123");
52 assertThat(user.getAccountStatus()).isEqualTo("EMAIL_VERIFICATION_PENDING");
53
54 mvc.perform(post("/api/v1/auth/login").contentType(MediaType.APPLICATION_JSON)
55 .content("{\"email\":\"real.user@example.test\",\"password\":\"correct-password-123\"}"))
56 .andExpect(status().isForbidden());
57
58 String rawToken = "test-verification-token";
59 tokens.save(new EmailVerificationTokenEntity(UUID.randomUUID(), user.getId(), tokenHash(rawToken), Instant.now().plusSeconds(60), Instant.now()));
60 mvc.perform(post("/api/v1/auth/verify-email").contentType(MediaType.APPLICATION_JSON)
61 .content("{\"token\":\"" + rawToken + "\"}"))
62 .andExpect(status().isOk())
63 .andExpect(jsonPath("$.status").value("ACTIVE"));
64 mvc.perform(post("/api/v1/auth/verify-email").contentType(MediaType.APPLICATION_JSON)
65 .content("{\"token\":\"" + rawToken + "\"}"))
66 .andExpect(status().isBadRequest());
67
68 String response = mvc.perform(post("/api/v1/auth/login").contentType(MediaType.APPLICATION_JSON)
69 .content("{\"email\":\"REAL.USER@example.test\",\"password\":\"correct-password-123\"}"))
70 .andExpect(status().isOk())
71 .andExpect(jsonPath("$.tokenType").value("Bearer"))
72 .andExpect(jsonPath("$.user.userId").value(user.getId().toString()))
73 .andReturn().getResponse().getContentAsString();
74 String accessToken = new com.fasterxml.jackson.databind.ObjectMapper().readTree(response).get("accessToken").asText();
75 assertThat(new HmacJwtService(SECRET).verify(accessToken, "test-auth-issuer").subject()).isEqualTo(user.getId().toString());
76 assertThat(users.findById(user.getId()).orElseThrow().getLastLoginAt()).isNotNull();
77
78 mvc.perform(post("/api/v1/auth/login").contentType(MediaType.APPLICATION_JSON)
79 .content("{\"email\":\"real.user@example.test\",\"password\":\"wrong-password-123\"}"))
80 .andExpect(status().isUnauthorized());
81 }
82
83 @Test
84 void expiredVerificationAndDevLoginDisabledAreRejected() throws Exception {
85 AppUserEntity user = users.save(AppUserEntity.local(UUID.randomUUID(), "test-auth-issuer", "pending@example.test", "pending@example.test", "$2a$12$abcdefghijklmnopqrstuvabcdefghijklmnopqrstuvabcdefghijklmnop", "Pending", Instant.now()));
86 tokens.save(new EmailVerificationTokenEntity(UUID.randomUUID(), user.getId(), tokenHash("expired-token"), Instant.now().minusSeconds(1), Instant.now().minusSeconds(2)));
87 mvc.perform(post("/api/v1/auth/verify-email").contentType(MediaType.APPLICATION_JSON).content("{\"token\":\"expired-token\"}"))
88 .andExpect(status().isBadRequest());
89 mvc.perform(post("/api/v1/auth/dev/login").contentType(MediaType.APPLICATION_JSON).content("{\"userKey\":\"user-a\"}"))
90 .andExpect(status().isNotFound());
91 }
92
93 @Test
94 @Transactional
95 void disabledAccountCannotLogin() throws Exception {
96 mvc.perform(post("/api/v1/auth/register").contentType(MediaType.APPLICATION_JSON)
97 .content("{\"email\":\"disabled@example.test\",\"password\":\"correct-password-123\",\"firstName\":\"Disabled\",\"lastName\":\"User\"}"))
98 .andExpect(status().isCreated());
99 entityManager.createNativeQuery("UPDATE auth.app_users SET account_status = 'DISABLED' WHERE normalized_email = 'disabled@example.test'").executeUpdate();
100 mvc.perform(post("/api/v1/auth/login").contentType(MediaType.APPLICATION_JSON)
101 .content("{\"email\":\"disabled@example.test\",\"password\":\"correct-password-123\"}"))
102 .andExpect(status().isForbidden());
103 }
104
105 @Test
106 void passwordResetConsumesHashedOneTimeTokenAndReplacesPassword() throws Exception {
107 mvc.perform(post("/api/v1/auth/register").contentType(MediaType.APPLICATION_JSON)
108 .content("{\"email\":\"reset@example.test\",\"password\":\"correct-password-123\",\"firstName\":\"Reset\",\"lastName\":\"User\"}"))
109 .andExpect(status().isCreated());
110 AppUserEntity user = users.findByNormalizedEmail("reset@example.test").orElseThrow();
111 user.activate(Instant.now()); users.save(user);
112 String genericExisting = mvc.perform(post("/api/v1/auth/password-reset/request").contentType(MediaType.APPLICATION_JSON).content("{\"email\":\"reset@example.test\"}"))
113 .andExpect(status().isOk()).andReturn().getResponse().getContentAsString();
114 String genericUnknown = mvc.perform(post("/api/v1/auth/password-reset/request").contentType(MediaType.APPLICATION_JSON).content("{\"email\":\"unknown@example.test\"}"))
115 .andExpect(status().isOk()).andReturn().getResponse().getContentAsString();
116 assertThat(genericExisting).isEqualTo(genericUnknown);
117
118 String raw = "known-reset-token";
119 resetTokens.save(new PasswordResetTokenEntity(UUID.randomUUID(), user.getId(), tokenHash(raw), Instant.now().plusSeconds(60), Instant.now()));
120 mvc.perform(post("/api/v1/auth/password-reset/confirm").contentType(MediaType.APPLICATION_JSON)
121 .content("{\"token\":\"" + raw + "\",\"newPassword\":\"new-password-456\"}"))
122 .andExpect(status().isNoContent());
123 mvc.perform(post("/api/v1/auth/login").contentType(MediaType.APPLICATION_JSON).content("{\"email\":\"reset@example.test\",\"password\":\"correct-password-123\"}"))
124 .andExpect(status().isUnauthorized());
125 mvc.perform(post("/api/v1/auth/login").contentType(MediaType.APPLICATION_JSON).content("{\"email\":\"reset@example.test\",\"password\":\"new-password-456\"}"))
126 .andExpect(status().isOk());
127 mvc.perform(post("/api/v1/auth/password-reset/confirm").contentType(MediaType.APPLICATION_JSON)
128 .content("{\"token\":\"" + raw + "\",\"newPassword\":\"another-password-789\"}"))
129 .andExpect(status().isBadRequest());
130 }
131
132 @Test
133 void pendingReregistrationReusesOneUserAndSupersedesOldVerificationTokens() throws Exception {
134 mvc.perform(post("/api/v1/auth/register").contentType(MediaType.APPLICATION_JSON)
135 .content("{\"email\":\"Pending.User@Example.Test\",\"password\":\"first-password-123\",\"firstName\":\"Pending\",\"lastName\":\"User\"}"))
136 .andExpect(status().isCreated());
137 AppUserEntity user = users.findByNormalizedEmail("pending.user@example.test").orElseThrow();
138 String oldToken = "superseded-verification-token";
139 tokens.save(new EmailVerificationTokenEntity(UUID.randomUUID(), user.getId(), tokenHash(oldToken), Instant.now().plusSeconds(60), Instant.now()));
140
141 mvc.perform(post("/api/v1/auth/register").contentType(MediaType.APPLICATION_JSON)
142 .content("{\"email\":\" pending.user@example.test \",\"password\":\"second-password-456\",\"firstName\":\"Updated\",\"lastName\":\"User\"}"))
143 .andExpect(status().isCreated())
144 .andExpect(jsonPath("$.userId").value(user.getId().toString()))
145 .andExpect(jsonPath("$.status").value("EMAIL_VERIFICATION_PENDING"));
146
147 assertThat(users.count()).isEqualTo(1);
148 assertThat(tokens.count()).isEqualTo(1);
149 assertThat(users.findById(user.getId()).orElseThrow().getPasswordHash()).doesNotContain("second-password-456");
150 mvc.perform(post("/api/v1/auth/verify-email").contentType(MediaType.APPLICATION_JSON).content("{\"token\":\"" + oldToken + "\"}"))
151 .andExpect(status().isBadRequest());
152 mvc.perform(post("/api/v1/auth/login").contentType(MediaType.APPLICATION_JSON)
153 .content("{\"email\":\"pending.user@example.test\",\"password\":\"second-password-456\"}"))
154 .andExpect(status().isForbidden());
155
156 String freshToken = "fresh-verification-token";
157 tokens.save(new EmailVerificationTokenEntity(UUID.randomUUID(), user.getId(), tokenHash(freshToken), Instant.now().plusSeconds(60), Instant.now()));
158 mvc.perform(post("/api/v1/auth/verify-email").contentType(MediaType.APPLICATION_JSON).content("{\"token\":\"" + freshToken + "\"}"))
159 .andExpect(status().isOk());
160 mvc.perform(post("/api/v1/auth/login").contentType(MediaType.APPLICATION_JSON)
161 .content("{\"email\":\"pending.user@example.test\",\"password\":\"second-password-456\"}"))
162 .andExpect(status().isOk());
163 }
164
165 @Test
166 void activeEmailCannotBeRegisteredAgain() throws Exception {
167 mvc.perform(post("/api/v1/auth/register").contentType(MediaType.APPLICATION_JSON)
168 .content("{\"email\":\"active@example.test\",\"password\":\"correct-password-123\",\"firstName\":\"Active\",\"lastName\":\"User\"}"))
169 .andExpect(status().isCreated());
170 AppUserEntity user = users.findByNormalizedEmail("active@example.test").orElseThrow();
171 user.activate(Instant.now());
172 users.save(user);
173 mvc.perform(post("/api/v1/auth/register").contentType(MediaType.APPLICATION_JSON)
174 .content("{\"email\":\"ACTIVE@example.test\",\"password\":\"another-password-456\",\"firstName\":\"Active\",\"lastName\":\"User\"}"))
175 .andExpect(status().isConflict())
176 .andExpect(status().reason("An account already exists for this email. Sign in or reset your password."));
177 assertThat(users.count()).isEqualTo(1);
178 }
179
180 private static String tokenHash(String raw) throws Exception {
181 return Base64.getUrlEncoder().withoutPadding().encodeToString(MessageDigest.getInstance("SHA-256").digest(raw.getBytes(StandardCharsets.UTF_8)));
182 }
183 }