Public Access
feat(steam): verify the Steam ID with Sign in through Steam
The Steam ID is no longer typed in at registration or in the settings. A logged-in user links it through Steam OpenID 2.0: Auth checks the answer with Steam (check_authentication) and stores the ID as verified (V11 steam_id_verified). A one-time state plus a binding cookie tie the callback to the browser that started the link, so a foreign Steam login link cannot move someone's Steam ID into another account. Typed-in IDs from before stay unverified; a verified link from another account takes them away. DELETE /api/users/steam-link unlinks.
This commit is contained in:
1 parent
e3433799f1
commit
4b2acef381
44 files changed
+1079
-449
No files matched your search
@@ -0,0 +1,154 @@
|
||||
package auth_service.configuration.security;
|
||||
|
||||
import auth_service.modules.user.service.SteamOpenIdService;
|
||||
import auth_service.shared.api.AuthCookies;
|
||||
import auth_service.shared.test.AbstractIntegrationTest;
|
||||
import jakarta.servlet.http.Cookie;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.mock.web.MockHttpServletResponse;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.anyMap;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
/**
|
||||
* "Sign in through Steam" through the whole filter chain and a real database; only Steam itself is mocked.
|
||||
*/
|
||||
@AutoConfigureMockMvc
|
||||
class SteamLinkFlowTest extends AbstractIntegrationTest {
|
||||
|
||||
private static final String EMAIL = "steam-link@example.com";
|
||||
private static final String TYPIST = "steam-typist@example.com";
|
||||
private static final String PASSWORD = "correct horse battery";
|
||||
private static final long STEAM_ID = 76561198000000042L;
|
||||
private static final String CALLBACK = "http://localhost:8080/api/steam-link/callback?state=";
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
@Autowired
|
||||
private JdbcTemplate jdbc;
|
||||
@Autowired
|
||||
private PasswordEncoder passwordEncoder;
|
||||
@Autowired
|
||||
private JwtService jwtService;
|
||||
@MockitoBean
|
||||
private SteamOpenIdService steamOpenIdService;
|
||||
|
||||
@BeforeEach
|
||||
void seedUsers() {
|
||||
jdbc.update("DELETE FROM users WHERE email IN (?, ?)", EMAIL, TYPIST);
|
||||
jdbc.update("INSERT INTO users (email, password, enabled) VALUES (?, ?, true)", EMAIL, passwordEncoder.encode(PASSWORD));
|
||||
// Someone who typed the same ID in before Steam linking existed.
|
||||
jdbc.update("INSERT INTO users (email, password, enabled, steam_id) VALUES (?, 'x', true, ?)", TYPIST, STEAM_ID);
|
||||
when(steamOpenIdService.loginUrl(anyString(), anyString())).thenReturn("https://steamcommunity.com/openid/login?test");
|
||||
}
|
||||
|
||||
private MockHttpServletResponse login() throws Exception {
|
||||
return mockMvc.perform(post("/api/auth/login")
|
||||
.header(ClientHeaderFilter.CLIENT_HEADER, "web")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"email\":\"" + EMAIL + "\",\"password\":\"" + PASSWORD + "\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andReturn().getResponse();
|
||||
}
|
||||
|
||||
private MockHttpServletResponse startLink(MockHttpServletResponse session) throws Exception {
|
||||
return mockMvc.perform(post("/api/users/steam-link")
|
||||
.header(ClientHeaderFilter.CLIENT_HEADER, "web")
|
||||
.cookie(session.getCookie(AuthCookies.ACCESS_COOKIE)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.redirectUrl").value("https://steamcommunity.com/openid/login?test"))
|
||||
.andReturn().getResponse();
|
||||
}
|
||||
|
||||
private Map<String, Object> row(String email) {
|
||||
return jdbc.queryForMap("SELECT steam_id, steam_id_verified FROM users WHERE email = ?", email);
|
||||
}
|
||||
|
||||
@Test
|
||||
void link_ProvedIdIsStoredVerified_TakenFromTheTypist_AndInTheNextToken() throws Exception {
|
||||
MockHttpServletResponse session = login();
|
||||
MockHttpServletResponse started = startLink(session);
|
||||
// Lax: Steam sends the user back with a cross-site GET, Strict would drop the cookie.
|
||||
assertThat(started.getHeader("Set-Cookie")).contains("SameSite=Lax");
|
||||
Cookie binding = started.getCookie(AuthCookies.STEAM_LINK_COOKIE);
|
||||
assertThat(binding).isNotNull();
|
||||
assertThat(binding.isHttpOnly()).isTrue();
|
||||
assertThat(binding.getPath()).isEqualTo("/api/steam-link");
|
||||
String state = binding.getValue();
|
||||
when(steamOpenIdService.verify(anyMap(), eq(CALLBACK + state))).thenReturn(Optional.of(STEAM_ID));
|
||||
|
||||
// Steam's redirect carries no login cookie here: the binding cookie alone ties it to the user.
|
||||
mockMvc.perform(get("/api/steam-link/callback").param("state", state).param("openid.mode", "id_res").cookie(binding))
|
||||
.andExpect(status().isFound())
|
||||
.andExpect(header().string("Location", "http://localhost:5173/settings?steam=linked"))
|
||||
.andExpect(header().string("Set-Cookie", org.hamcrest.Matchers.containsString("Max-Age=0")));
|
||||
|
||||
assertThat(row(EMAIL)).containsEntry("steam_id", STEAM_ID).containsEntry("steam_id_verified", true);
|
||||
assertThat(row(TYPIST)).containsEntry("steam_id", null).containsEntry("steam_id_verified", false);
|
||||
MockHttpServletResponse refreshed = mockMvc.perform(post("/api/auth/refresh")
|
||||
.header(ClientHeaderFilter.CLIENT_HEADER, "web")
|
||||
.cookie(session.getCookie(AuthCookies.REFRESH_COOKIE)))
|
||||
.andExpect(status().isNoContent())
|
||||
.andReturn().getResponse();
|
||||
assertThat(jwtService.parseLoginToken(refreshed.getCookie(AuthCookies.ACCESS_COOKIE).getValue()).orElseThrow().steamId())
|
||||
.isEqualTo(STEAM_ID);
|
||||
}
|
||||
|
||||
@Test
|
||||
void callback_WithoutTheBindingCookie_FailsAndAsksSteamNothing() throws Exception {
|
||||
String state = startLink(login()).getCookie(AuthCookies.STEAM_LINK_COOKIE).getValue();
|
||||
|
||||
mockMvc.perform(get("/api/steam-link/callback").param("state", state).param("openid.mode", "id_res"))
|
||||
.andExpect(status().isFound())
|
||||
.andExpect(header().string("Location", "http://localhost:5173/settings?steam=failed"))
|
||||
// A forged callback must not clear the cookie of the real link.
|
||||
.andExpect(header().doesNotExist("Set-Cookie"));
|
||||
|
||||
verify(steamOpenIdService, never()).verify(anyMap(), anyString());
|
||||
assertThat(row(EMAIL)).containsEntry("steam_id", null);
|
||||
}
|
||||
|
||||
@Test
|
||||
void startLink_NeedsALogin() throws Exception {
|
||||
mockMvc.perform(post("/api/users/steam-link").header(ClientHeaderFilter.CLIENT_HEADER, "web"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
void unlink_ClearsTheIdAndTheNewCookieHasNoSteamId() throws Exception {
|
||||
jdbc.update("UPDATE users SET steam_id = NULL WHERE email = ?", TYPIST);
|
||||
jdbc.update("UPDATE users SET steam_id = ?, steam_id_verified = TRUE WHERE email = ?", STEAM_ID, EMAIL);
|
||||
|
||||
MockHttpServletResponse unlinked = mockMvc.perform(delete("/api/users/steam-link")
|
||||
.header(ClientHeaderFilter.CLIENT_HEADER, "web")
|
||||
.cookie(login().getCookie(AuthCookies.ACCESS_COOKIE)))
|
||||
.andExpect(status().isNoContent())
|
||||
.andReturn().getResponse();
|
||||
|
||||
assertThat(row(EMAIL)).containsEntry("steam_id", null).containsEntry("steam_id_verified", false);
|
||||
assertThat(jwtService.parseLoginToken(unlinked.getCookie(AuthCookies.ACCESS_COOKIE).getValue()).orElseThrow().steamId())
|
||||
.isNull();
|
||||
}
|
||||
}
|
||||
+4
-48
@@ -64,7 +64,6 @@ class RegistrationControllerTest extends AbstractWebMvcTest {
|
||||
.email("test@example.com")
|
||||
.password("SuperSecret123!")
|
||||
.inviteCode("test-invite-code")
|
||||
.steamId("76561198000000001")
|
||||
.build();
|
||||
|
||||
long expectedUserId = 42L;
|
||||
@@ -87,7 +86,6 @@ class RegistrationControllerTest extends AbstractWebMvcTest {
|
||||
.email("test@example.com")
|
||||
.password("SuperSecret123!")
|
||||
.inviteCode("test-invite-code")
|
||||
.steamId("76561198000000001")
|
||||
.build();
|
||||
|
||||
when(registrationService.register(any(Registration.class), any()))
|
||||
@@ -103,60 +101,18 @@ class RegistrationControllerTest extends AbstractWebMvcTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void register_SteamIdIsParsedExactly() throws Exception {
|
||||
// Arrange
|
||||
var request = RegistrationRequest.builder()
|
||||
.email("test@example.com")
|
||||
.password("SuperSecret123!")
|
||||
.inviteCode("code")
|
||||
.steamId("76561198000000001")
|
||||
.build();
|
||||
void register_OldClientSendingASteamId_IsAcceptedAndTheIdIgnored() throws Exception {
|
||||
when(registrationService.register(any(Registration.class), any())).thenReturn(42L);
|
||||
|
||||
// Act
|
||||
mockMvc.perform(post("/api/registration/new")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(registrationRequestJson.write(request).getJson()))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
// Assert
|
||||
var captor = ArgumentCaptor.forClass(Registration.class);
|
||||
verify(registrationService).register(captor.capture(), any());
|
||||
assertEquals(76561198000000001L, captor.getValue().getSteamId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void register_InvalidSteamId() throws Exception {
|
||||
// Arrange
|
||||
var request = RegistrationRequest.builder()
|
||||
.email("test@example.com")
|
||||
.password("SuperSecret123!")
|
||||
.inviteCode("code")
|
||||
.steamId("12345")
|
||||
.build();
|
||||
|
||||
// Act & Assert
|
||||
mockMvc.perform(post("/api/registration/new")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(registrationRequestJson.write(request).getJson()))
|
||||
.andExpect(status().isBadRequest());
|
||||
|
||||
verifyNoInteractions(registrationService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void register_NumericSteamIdIsRejected() throws Exception {
|
||||
mockMvc.perform(post("/api/registration/new")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"email":"test@example.com","password":"SuperSecret123!","inviteCode":"code","steamId":76561198005679790}"""))
|
||||
.andExpect(status().isBadRequest());
|
||||
|
||||
verifyNoInteractions(registrationService);
|
||||
{"email":"test@example.com","password":"SuperSecret123!","inviteCode":"code","steamId":"76561198000000001"}"""))
|
||||
.andExpect(status().isOk());
|
||||
}
|
||||
|
||||
@Test
|
||||
void register_DuplicateEmailRaceIsNotReportedAsSteamIdConflict() throws Exception {
|
||||
void register_DuplicateEmailRaceIsNotReportedAsConflict() throws Exception {
|
||||
// Arrange
|
||||
var request = RegistrationRequest.builder()
|
||||
.email("test@example.com")
|
||||
|
||||
-21
@@ -27,7 +27,6 @@ class RegisteredUserRepositoryTest extends AbstractPersistenceTest {
|
||||
.email(email)
|
||||
.password("password")
|
||||
.enabled(true)
|
||||
.steamId(111L)
|
||||
.registrationDate(java.time.LocalDateTime.now())
|
||||
.build();
|
||||
entityManager.persist(entity);
|
||||
@@ -38,32 +37,12 @@ class RegisteredUserRepositoryTest extends AbstractPersistenceTest {
|
||||
assertFalse(registeredUserRepository.existsByEmail("other@example.com"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void existsBySteamId_Success() {
|
||||
// Arrange
|
||||
var steamId = 222L;
|
||||
var entity = RegisteredUserEntity.builder()
|
||||
.email("steam@example.com")
|
||||
.password("password")
|
||||
.enabled(true)
|
||||
.steamId(steamId)
|
||||
.registrationDate(java.time.LocalDateTime.now())
|
||||
.build();
|
||||
entityManager.persist(entity);
|
||||
entityManager.flush();
|
||||
|
||||
// Act & Assert
|
||||
assertTrue(registeredUserRepository.existsBySteamId(steamId));
|
||||
assertFalse(registeredUserRepository.existsBySteamId(333L));
|
||||
}
|
||||
|
||||
@Test
|
||||
void save_Success() {
|
||||
// Arrange
|
||||
var registration = Registration.builder()
|
||||
.email("save@example.com")
|
||||
.encryptedPassword("hashed")
|
||||
.steamId(444L)
|
||||
.build();
|
||||
|
||||
// Act
|
||||
|
||||
@@ -3,7 +3,6 @@ package auth_service.modules.registration.service;
|
||||
import auth_service.modules.registration.exception.EmailAlreadyInUseException;
|
||||
import auth_service.modules.registration.exception.InvalidInvitationCodeException;
|
||||
import auth_service.modules.registration.exception.DisplayNameAlreadyRegisteredException;
|
||||
import auth_service.modules.registration.exception.SteamIdAlreadyRegisteredException;
|
||||
import auth_service.modules.registration.exception.TooManyRegistrationAttemptsException;
|
||||
import auth_service.modules.registration.model.Registration;
|
||||
import auth_service.modules.registration.persistence.RegisteredUserRepository;
|
||||
@@ -49,7 +48,6 @@ class RegistrationServiceTest extends AbstractUnitTest {
|
||||
.email("test@example.com")
|
||||
.rawPassword("SuperSecret123!")
|
||||
.inviteCode("test-invite-code")
|
||||
.steamId(76561198000000001L)
|
||||
.build();
|
||||
}
|
||||
|
||||
@@ -59,7 +57,6 @@ class RegistrationServiceTest extends AbstractUnitTest {
|
||||
long expectedUserId = 42L;
|
||||
when(codeValidationService.isCodeValid(validRegistration.getInviteCode())).thenReturn(true);
|
||||
when(registeredUserRepository.existsByEmail(validRegistration.getEmail())).thenReturn(false);
|
||||
when(registeredUserRepository.existsBySteamId(validRegistration.getSteamId())).thenReturn(false);
|
||||
when(passwordEncoder.encode(validRegistration.getRawPassword())).thenReturn("hashedPasswordXYZ");
|
||||
when(registeredUserRepository.save(any(Registration.class))).thenReturn(expectedUserId);
|
||||
|
||||
@@ -97,49 +94,16 @@ class RegistrationServiceTest extends AbstractUnitTest {
|
||||
// Act & Assert
|
||||
assertThrows(EmailAlreadyInUseException.class, () -> registrationService.register(validRegistration, IP));
|
||||
|
||||
verify(registeredUserRepository, never()).existsBySteamId(anyLong());
|
||||
verify(registeredUserRepository, never()).save(any());
|
||||
verify(registrationAttemptService, never()).release(anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void register_SteamIdAlreadyRegistered() {
|
||||
// Arrange
|
||||
when(codeValidationService.isCodeValid(validRegistration.getInviteCode())).thenReturn(true);
|
||||
when(registeredUserRepository.existsByEmail(validRegistration.getEmail())).thenReturn(false);
|
||||
when(registeredUserRepository.existsBySteamId(validRegistration.getSteamId())).thenReturn(true);
|
||||
|
||||
// Act & Assert
|
||||
assertThrows(SteamIdAlreadyRegisteredException.class, () -> registrationService.register(validRegistration, IP));
|
||||
|
||||
verify(registeredUserRepository, never()).save(any());
|
||||
verify(registrationAttemptService, never()).release(anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void register_WithoutSteamId() {
|
||||
// Arrange
|
||||
validRegistration.setSteamId(null);
|
||||
when(codeValidationService.isCodeValid(validRegistration.getInviteCode())).thenReturn(true);
|
||||
when(registeredUserRepository.existsByEmail(validRegistration.getEmail())).thenReturn(false);
|
||||
when(passwordEncoder.encode(validRegistration.getRawPassword())).thenReturn("hashedPasswordXYZ");
|
||||
when(registeredUserRepository.save(any(Registration.class))).thenReturn(42L);
|
||||
|
||||
// Act
|
||||
long result = registrationService.register(validRegistration, IP);
|
||||
|
||||
// Assert
|
||||
assertEquals(42L, result);
|
||||
verify(registeredUserRepository, never()).existsBySteamId(anyLong());
|
||||
}
|
||||
|
||||
@Test
|
||||
void register_DisplayNameTaken() {
|
||||
// Arrange
|
||||
validRegistration.setDisplayName("Lobo");
|
||||
when(codeValidationService.isCodeValid(validRegistration.getInviteCode())).thenReturn(true);
|
||||
when(registeredUserRepository.existsByEmail(validRegistration.getEmail())).thenReturn(false);
|
||||
when(registeredUserRepository.existsBySteamId(validRegistration.getSteamId())).thenReturn(false);
|
||||
when(registeredUserRepository.existsByDisplayName("Lobo")).thenReturn(true);
|
||||
|
||||
// Act & Assert
|
||||
|
||||
@@ -4,7 +4,6 @@ import auth_service.configuration.security.JwtAuthenticationFilter;
|
||||
import auth_service.configuration.security.JwtService;
|
||||
import auth_service.shared.security.UserPrincipal;
|
||||
import auth_service.modules.user.exception.DisplayNameAlreadyInUseException;
|
||||
import auth_service.modules.user.exception.SteamIdAlreadyInUseException;
|
||||
import auth_service.modules.user.exception.UserNotFoundException;
|
||||
import auth_service.modules.user.model.Appearance;
|
||||
import auth_service.modules.user.model.User;
|
||||
@@ -33,6 +32,7 @@ import org.springframework.test.web.servlet.MockMvc;
|
||||
import java.util.Collections;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
@@ -89,7 +89,7 @@ class UserControllerTest extends AbstractWebMvcTest {
|
||||
// Arrange
|
||||
var email = "test@example.com";
|
||||
var settings = UserSettings.builder().language("en").theme("dark").build();
|
||||
var user = User.builder().email(email).steamId(76561198000000001L).settings(settings).build();
|
||||
var user = User.builder().email(email).steamId(76561198000000001L).steamIdVerified(true).settings(settings).build();
|
||||
var principal = new UserPrincipal("1", email, null);
|
||||
SecurityContextHolder.getContext().setAuthentication(
|
||||
new UsernamePasswordAuthenticationToken(principal, null, Collections.emptyList())
|
||||
@@ -100,7 +100,8 @@ class UserControllerTest extends AbstractWebMvcTest {
|
||||
mockMvc.perform(get("/api/users/me")
|
||||
.accept(MediaType.APPLICATION_JSON))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.steamId").value("76561198000000001"));
|
||||
.andExpect(jsonPath("$.steamId").value("76561198000000001"))
|
||||
.andExpect(jsonPath("$.steamIdVerified").value(true));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -139,130 +140,31 @@ class UserControllerTest extends AbstractWebMvcTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateUserSettings_Success() throws Exception {
|
||||
// Arrange
|
||||
var email = "test@example.com";
|
||||
var principal = new UserPrincipal("1", email, null);
|
||||
void updateUserSettings_OldClientSendingASteamId_SavesTheRestAndIssuesNoCookie() throws Exception {
|
||||
var principal = new UserPrincipal("1", "test@example.com", null);
|
||||
SecurityContextHolder.getContext().setAuthentication(
|
||||
new UsernamePasswordAuthenticationToken(principal, null, Collections.emptyList())
|
||||
);
|
||||
when(userService.updateUserSettings(eq(1L), eq(email), eq(76561198000000001L), any(), any())).thenReturn("new-token");
|
||||
|
||||
// Act & Assert
|
||||
mockMvc.perform(put("/api/users/settings")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"useDarkTheme":true,"language":"en","steamId":"76561198000000001"}"""))
|
||||
.andExpect(status().isNoContent())
|
||||
.andExpect(cookie().value(AuthCookies.ACCESS_COOKIE, "new-token"))
|
||||
.andExpect(cookie().httpOnly(AuthCookies.ACCESS_COOKIE, true));
|
||||
.andExpect(cookie().doesNotExist(AuthCookies.ACCESS_COOKIE));
|
||||
|
||||
verify(userService).updateUserSettings(eq(1L), isNull(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateUserSettings_NoSteamId() throws Exception {
|
||||
void updateUserSettings_OtherConstraintIsNotAConflict() throws Exception {
|
||||
// Arrange
|
||||
var email = "test@example.com";
|
||||
var principal = new UserPrincipal("1", email, null);
|
||||
SecurityContextHolder.getContext().setAuthentication(
|
||||
new UsernamePasswordAuthenticationToken(principal, null, Collections.emptyList())
|
||||
);
|
||||
|
||||
// Act & Assert
|
||||
mockMvc.perform(put("/api/users/settings")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"useDarkTheme":true,"language":"en","steamId":null}"""))
|
||||
.andExpect(status().isNoContent());
|
||||
|
||||
verify(userService).updateUserSettings(eq(1L), eq(email), isNull(), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateUserSettings_InvalidSteamId() throws Exception {
|
||||
// Arrange
|
||||
var principal = new UserPrincipal("1", "test@example.com", null);
|
||||
SecurityContextHolder.getContext().setAuthentication(
|
||||
new UsernamePasswordAuthenticationToken(principal, null, Collections.emptyList())
|
||||
);
|
||||
|
||||
// Act & Assert
|
||||
mockMvc.perform(put("/api/users/settings")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"useDarkTheme":true,"language":"en","steamId":"7656119800567978x"}"""))
|
||||
.andExpect(status().isBadRequest());
|
||||
|
||||
verifyNoInteractions(userService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateUserSettings_UniqueConstraintRaceIsConflict() throws Exception {
|
||||
// Arrange
|
||||
var email = "test@example.com";
|
||||
var principal = new UserPrincipal("1", email, null);
|
||||
SecurityContextHolder.getContext().setAuthentication(
|
||||
new UsernamePasswordAuthenticationToken(principal, null, Collections.emptyList())
|
||||
);
|
||||
when(userService.updateUserSettings(eq(1L), eq(email), eq(76561198000000001L), any(), any()))
|
||||
.thenThrow(new DataIntegrityViolationException("duplicate key value violates unique constraint \"uc_steam_id\""));
|
||||
|
||||
// Act & Assert
|
||||
mockMvc.perform(put("/api/users/settings")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"useDarkTheme":true,"language":"en","steamId":"76561198000000001"}"""))
|
||||
.andExpect(status().isConflict())
|
||||
.andExpect(jsonPath("$.code").value("STEAM_ID_ALREADY_IN_USE"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateUserSettings_NumericSteamIdIsRejected() throws Exception {
|
||||
// Arrange
|
||||
var principal = new UserPrincipal("1", "test@example.com", null);
|
||||
SecurityContextHolder.getContext().setAuthentication(
|
||||
new UsernamePasswordAuthenticationToken(principal, null, Collections.emptyList())
|
||||
);
|
||||
|
||||
// Act & Assert
|
||||
mockMvc.perform(put("/api/users/settings")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"useDarkTheme":true,"language":"en","steamId":76561198005679790}"""))
|
||||
.andExpect(status().isBadRequest());
|
||||
|
||||
verifyNoInteractions(userService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateUserSettings_SteamIdOfOtherUser() throws Exception {
|
||||
// Arrange
|
||||
var email = "test@example.com";
|
||||
var principal = new UserPrincipal("1", email, null);
|
||||
SecurityContextHolder.getContext().setAuthentication(
|
||||
new UsernamePasswordAuthenticationToken(principal, null, Collections.emptyList())
|
||||
);
|
||||
when(userService.updateUserSettings(eq(1L), eq(email), eq(76561198000000001L), any(), any()))
|
||||
.thenThrow(new SteamIdAlreadyInUseException("taken"));
|
||||
|
||||
// Act & Assert
|
||||
mockMvc.perform(put("/api/users/settings")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"useDarkTheme":true,"language":"en","steamId":"76561198000000001"}"""))
|
||||
.andExpect(status().isConflict())
|
||||
.andExpect(jsonPath("$.code").value("STEAM_ID_ALREADY_IN_USE"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateUserSettings_OtherConstraintIsNotSteamIdConflict() throws Exception {
|
||||
// Arrange
|
||||
var email = "test@example.com";
|
||||
var principal = new UserPrincipal("1", email, null);
|
||||
SecurityContextHolder.getContext().setAuthentication(
|
||||
new UsernamePasswordAuthenticationToken(principal, null, Collections.emptyList())
|
||||
);
|
||||
when(userService.updateUserSettings(eq(1L), eq(email), isNull(), any(), any()))
|
||||
.thenThrow(new DataIntegrityViolationException("value too long for type character varying(10)"));
|
||||
doThrow(new DataIntegrityViolationException("value too long for type character varying(10)")).when(userService).updateUserSettings(eq(1L), any(), any());
|
||||
|
||||
// Act & Assert
|
||||
mockMvc.perform(put("/api/users/settings")
|
||||
@@ -299,7 +201,6 @@ class UserControllerTest extends AbstractWebMvcTest {
|
||||
SecurityContextHolder.getContext().setAuthentication(
|
||||
new UsernamePasswordAuthenticationToken(principal, null, Collections.emptyList())
|
||||
);
|
||||
when(userService.updateUserSettings(eq(1L), eq(email), isNull(), eq("Lobo Corp_1"), any())).thenReturn("t");
|
||||
|
||||
// Act & Assert
|
||||
mockMvc.perform(put("/api/users/settings")
|
||||
@@ -335,8 +236,7 @@ class UserControllerTest extends AbstractWebMvcTest {
|
||||
SecurityContextHolder.getContext().setAuthentication(
|
||||
new UsernamePasswordAuthenticationToken(principal, null, Collections.emptyList())
|
||||
);
|
||||
when(userService.updateUserSettings(eq(1L), eq(email), any(), eq("Lobo"), any()))
|
||||
.thenThrow(new DisplayNameAlreadyInUseException("taken"));
|
||||
doThrow(new DisplayNameAlreadyInUseException("taken")).when(userService).updateUserSettings(eq(1L), eq("Lobo"), any());
|
||||
|
||||
// Act & Assert
|
||||
mockMvc.perform(put("/api/users/settings")
|
||||
@@ -355,8 +255,7 @@ class UserControllerTest extends AbstractWebMvcTest {
|
||||
SecurityContextHolder.getContext().setAuthentication(
|
||||
new UsernamePasswordAuthenticationToken(principal, null, Collections.emptyList())
|
||||
);
|
||||
when(userService.updateUserSettings(eq(1L), eq(email), any(), eq("Lobo"), any()))
|
||||
.thenThrow(new DataIntegrityViolationException("duplicate key value violates unique constraint \"uq_users_display_name_lower\""));
|
||||
doThrow(new DataIntegrityViolationException("duplicate key value violates unique constraint \"uq_users_display_name_lower\"")).when(userService).updateUserSettings(eq(1L), eq("Lobo"), any());
|
||||
|
||||
// Act & Assert
|
||||
mockMvc.perform(put("/api/users/settings")
|
||||
@@ -380,7 +279,7 @@ class UserControllerTest extends AbstractWebMvcTest {
|
||||
.content("{\"useDarkTheme\":true,\"language\":\"en\",\"steamId\":null,\"displayName\":\"Lobo\"" + field + "}"))
|
||||
.andExpect(status().isNoContent());
|
||||
|
||||
verify(userService).updateUserSettings(eq(1L), eq("test@example.com"), isNull(), eq("Lobo"),
|
||||
verify(userService).updateUserSettings(eq(1L), eq("Lobo"),
|
||||
argThat(settings -> settings.isPublicProfile() == expected));
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package auth_service.modules.user.persistence;
|
||||
|
||||
import auth_service.modules.user.exception.UserNotFoundException;
|
||||
import auth_service.modules.user.model.Appearance;
|
||||
import auth_service.modules.user.model.SteamIdHolder;
|
||||
import auth_service.modules.user.model.UserSettings;
|
||||
import auth_service.modules.user.persistence.entities.UserEntity;
|
||||
import auth_service.shared.test.AbstractPersistenceTest;
|
||||
@@ -11,6 +12,7 @@ import org.springframework.boot.jpa.test.autoconfigure.TestEntityManager;
|
||||
import org.springframework.context.annotation.Import;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
@@ -65,13 +67,12 @@ class UserRepositoryTest extends AbstractPersistenceTest {
|
||||
.build();
|
||||
|
||||
// Act
|
||||
var updated = userRepository.updateSettings(userEntity.getId(), 654321L, null, settings);
|
||||
userRepository.updateSettings(userEntity.getId(), null, settings);
|
||||
|
||||
// Assert
|
||||
assertTrue(updated);
|
||||
// Assert: the Steam ID changes only through the Steam link
|
||||
var updatedUser = entityManager.find(UserEntity.class, userEntity.getId());
|
||||
assertNotNull(updatedUser);
|
||||
assertEquals(654321L, updatedUser.getSteamId());
|
||||
assertEquals(123456L, updatedUser.getSteamId());
|
||||
assertNotNull(updatedUser.getSettings());
|
||||
assertEquals("de", updatedUser.getSettings().getLanguage());
|
||||
}
|
||||
@@ -96,55 +97,54 @@ class UserRepositoryTest extends AbstractPersistenceTest {
|
||||
assertNull(result.get().getSteamId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateSettings_ClearsSteamId() {
|
||||
// Arrange
|
||||
var userEntity = entityManager.persist(UserEntity.builder()
|
||||
.email("clear@example.com")
|
||||
private UserEntity user(String email, Long steamId, boolean verified) {
|
||||
UserEntity user = entityManager.persist(UserEntity.builder()
|
||||
.email(email)
|
||||
.password("password")
|
||||
.enabled(true)
|
||||
.steamId(123456L)
|
||||
.steamId(steamId)
|
||||
.steamIdVerified(verified)
|
||||
.registrationDate(LocalDateTime.now())
|
||||
.build());
|
||||
entityManager.flush();
|
||||
var settings = UserSettings.builder().language("en").theme("light").build();
|
||||
|
||||
// Act
|
||||
userRepository.updateSettings(userEntity.getId(), null, null, settings);
|
||||
|
||||
// Assert
|
||||
assertNull(entityManager.find(UserEntity.class, userEntity.getId()).getSteamId());
|
||||
return user;
|
||||
}
|
||||
|
||||
@Test
|
||||
void isSteamIdUsedByOtherUser() {
|
||||
// Arrange
|
||||
var owner = entityManager.persist(UserEntity.builder()
|
||||
.email("owner@example.com")
|
||||
.password("password")
|
||||
.enabled(true)
|
||||
.steamId(76561198000000001L)
|
||||
.registrationDate(LocalDateTime.now())
|
||||
.build());
|
||||
var other = entityManager.persist(UserEntity.builder()
|
||||
.email("other@example.com")
|
||||
.password("password")
|
||||
.enabled(true)
|
||||
.registrationDate(LocalDateTime.now())
|
||||
.build());
|
||||
entityManager.flush();
|
||||
void findSteamIdHolder_ReturnsTheAccountAndWhetherItIsVerified() {
|
||||
UserEntity owner = user("owner@example.com", 76561198000000001L, true);
|
||||
|
||||
// Act & Assert
|
||||
assertFalse(userRepository.isSteamIdUsedByOtherUser(owner.getId(), 76561198000000001L));
|
||||
assertTrue(userRepository.isSteamIdUsedByOtherUser(other.getId(), 76561198000000001L));
|
||||
assertFalse(userRepository.isSteamIdUsedByOtherUser(other.getId(), 76561198000000000L));
|
||||
assertEquals(Optional.of(new SteamIdHolder(owner.getId(), true)), userRepository.findSteamIdHolder(76561198000000001L));
|
||||
assertTrue(userRepository.findSteamIdHolder(76561198000000000L).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void clearThenSet_MovesTheIdWithoutBreakingTheUniqueConstraint() {
|
||||
UserEntity typist = user("typist@example.com", 76561198000000001L, false);
|
||||
UserEntity owner = user("owner@example.com", null, false);
|
||||
|
||||
userRepository.clearSteamId(typist.getId());
|
||||
userRepository.setVerifiedSteamId(owner.getId(), 76561198000000001L);
|
||||
entityManager.clear();
|
||||
|
||||
UserEntity cleared = entityManager.find(UserEntity.class, typist.getId());
|
||||
assertNull(cleared.getSteamId());
|
||||
assertFalse(cleared.isSteamIdVerified());
|
||||
UserEntity linked = entityManager.find(UserEntity.class, owner.getId());
|
||||
assertEquals(76561198000000001L, linked.getSteamId());
|
||||
assertTrue(linked.isSteamIdVerified());
|
||||
}
|
||||
|
||||
@Test
|
||||
void setVerifiedSteamId_UnknownUser() {
|
||||
assertThrows(UserNotFoundException.class, () -> userRepository.setVerifiedSteamId(999_999L, 76561198000000001L));
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateSettings_UnknownUser() {
|
||||
var settings = UserSettings.builder().language("en").theme("light").build();
|
||||
|
||||
assertThrows(UserNotFoundException.class, () -> userRepository.updateSettings(-1L, null, null, settings));
|
||||
assertThrows(UserNotFoundException.class, () -> userRepository.updateSettings(-1L, null, settings));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -172,7 +172,7 @@ class UserRepositoryTest extends AbstractPersistenceTest {
|
||||
var settings = UserSettings.builder().language("en").theme("light").build();
|
||||
|
||||
// Act
|
||||
userRepository.updateSettings(owner.getId(), null, "Lobo", settings);
|
||||
userRepository.updateSettings(owner.getId(), "Lobo", settings);
|
||||
entityManager.flush();
|
||||
|
||||
// Assert
|
||||
@@ -192,7 +192,7 @@ class UserRepositoryTest extends AbstractPersistenceTest {
|
||||
.build());
|
||||
entityManager.flush();
|
||||
|
||||
userRepository.updateSettings(userEntity.getId(), null, "Lobo",
|
||||
userRepository.updateSettings(userEntity.getId(), "Lobo",
|
||||
UserSettings.builder().language("de").theme("dark").publicProfile(true).build());
|
||||
entityManager.flush();
|
||||
entityManager.clear();
|
||||
@@ -230,7 +230,7 @@ class UserRepositoryTest extends AbstractPersistenceTest {
|
||||
userRepository.updateAppearance(userEntity.getId(), new Appearance("pink", "sharp", false, 220L, null, true));
|
||||
entityManager.flush();
|
||||
|
||||
userRepository.updateSettings(userEntity.getId(), null, null,
|
||||
userRepository.updateSettings(userEntity.getId(), null,
|
||||
UserSettings.builder().language("de").theme("dark").build());
|
||||
entityManager.flush();
|
||||
entityManager.clear();
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
package auth_service.modules.user.service;
|
||||
|
||||
import auth_service.modules.user.model.SteamLinkOutcome;
|
||||
import auth_service.shared.test.AbstractUnitTest;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.Mock;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.anyMap;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class SteamLinkServiceTest extends AbstractUnitTest {
|
||||
|
||||
private static final String PUBLIC_URL = "https://auth.example";
|
||||
private static final Map<String, String> ANSWER = Map.of("openid.mode", "id_res");
|
||||
|
||||
@Mock
|
||||
private SteamOpenIdService steamOpenIdService;
|
||||
@Mock
|
||||
private UserService userService;
|
||||
|
||||
private SteamLinkService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service = new SteamLinkService(steamOpenIdService, userService, PUBLIC_URL, "https://tbp.example");
|
||||
}
|
||||
|
||||
private String start() {
|
||||
when(steamOpenIdService.loginUrl(anyString(), eq(PUBLIC_URL))).thenReturn("https://steamcommunity.com/openid/login?x");
|
||||
return service.start(7L).state();
|
||||
}
|
||||
|
||||
@Test
|
||||
void start_ReturnsSteamsUrlWithOurCallbackAndAFreshState() {
|
||||
String first = start();
|
||||
String second = service.start(7L).state();
|
||||
|
||||
assertThat(first).hasSizeGreaterThanOrEqualTo(43).isNotEqualTo(second);
|
||||
verify(steamOpenIdService).loginUrl(PUBLIC_URL + "/api/steam-link/callback?state=" + first, PUBLIC_URL);
|
||||
}
|
||||
|
||||
@Test
|
||||
void complete_ProvedId_IsLinkedToTheUserWhoStarted() {
|
||||
String state = start();
|
||||
when(steamOpenIdService.verify(ANSWER, PUBLIC_URL + "/api/steam-link/callback?state=" + state))
|
||||
.thenReturn(Optional.of(76561198000000001L));
|
||||
when(userService.linkVerifiedSteamId(7L, 76561198000000001L)).thenReturn(SteamLinkOutcome.LINKED);
|
||||
|
||||
assertThat(service.complete(state, state, ANSWER)).isEqualTo(SteamLinkOutcome.LINKED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void complete_StateIsOneTime() {
|
||||
String state = start();
|
||||
when(steamOpenIdService.verify(anyMap(), anyString())).thenReturn(Optional.of(76561198000000001L));
|
||||
when(userService.linkVerifiedSteamId(7L, 76561198000000001L)).thenReturn(SteamLinkOutcome.LINKED);
|
||||
service.complete(state, state, ANSWER);
|
||||
|
||||
assertThat(service.complete(state, state, ANSWER)).isEqualTo(SteamLinkOutcome.FAILED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void complete_CookieOfAnotherBrowser_FailsWithoutAskingSteam() {
|
||||
String state = start();
|
||||
|
||||
assertThat(service.complete(state, "other", ANSWER)).isEqualTo(SteamLinkOutcome.FAILED);
|
||||
assertThat(service.complete(state, null, ANSWER)).isEqualTo(SteamLinkOutcome.FAILED);
|
||||
verify(steamOpenIdService, never()).verify(anyMap(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void complete_UnknownState_FailsWithoutAskingSteam() {
|
||||
assertThat(service.complete("made-up", "made-up", ANSWER)).isEqualTo(SteamLinkOutcome.FAILED);
|
||||
verify(steamOpenIdService, never()).verify(anyMap(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void complete_CookieMismatch_KeepsTheStateForTheRightBrowser() {
|
||||
String state = start();
|
||||
service.complete(state, "attacker", ANSWER);
|
||||
when(steamOpenIdService.verify(anyMap(), anyString())).thenReturn(Optional.of(76561198000000001L));
|
||||
when(userService.linkVerifiedSteamId(7L, 76561198000000001L)).thenReturn(SteamLinkOutcome.LINKED);
|
||||
|
||||
assertThat(service.complete(state, state, ANSWER)).isEqualTo(SteamLinkOutcome.LINKED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void complete_UserCancelledOnSteam() {
|
||||
assertThat(service.complete(null, null, Map.of("openid.mode", "cancel"))).isEqualTo(SteamLinkOutcome.CANCELLED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void complete_SteamDoesNotConfirm_Fails() {
|
||||
String state = start();
|
||||
when(steamOpenIdService.verify(anyMap(), anyString())).thenReturn(Optional.empty());
|
||||
|
||||
assertThat(service.complete(state, state, ANSWER)).isEqualTo(SteamLinkOutcome.FAILED);
|
||||
verify(userService, never()).linkVerifiedSteamId(anyLong(), anyLong());
|
||||
}
|
||||
|
||||
@Test
|
||||
void complete_SameIdProvedByAnotherAccountAtOnce_IsInUse() {
|
||||
String state = start();
|
||||
when(steamOpenIdService.verify(anyMap(), anyString())).thenReturn(Optional.of(76561198000000001L));
|
||||
when(userService.linkVerifiedSteamId(any(Long.class), any(Long.class)))
|
||||
.thenThrow(new DataIntegrityViolationException("uc_steam_id"));
|
||||
|
||||
assertThat(service.complete(state, state, ANSWER)).isEqualTo(SteamLinkOutcome.IN_USE);
|
||||
}
|
||||
|
||||
@Test
|
||||
void complete_StoreFails_IsFailedNotAnException() {
|
||||
String state = start();
|
||||
when(steamOpenIdService.verify(anyMap(), anyString())).thenReturn(Optional.of(76561198000000001L));
|
||||
when(userService.linkVerifiedSteamId(7L, 76561198000000001L))
|
||||
.thenThrow(new org.springframework.dao.DataAccessResourceFailureException("connection lost"));
|
||||
|
||||
assertThat(service.complete(state, state, ANSWER)).isEqualTo(SteamLinkOutcome.FAILED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void start_Again_ReplacesTheUsersEarlierState() {
|
||||
String first = start();
|
||||
String second = service.start(7L).state();
|
||||
when(steamOpenIdService.verify(anyMap(), anyString())).thenReturn(Optional.of(76561198000000001L));
|
||||
when(userService.linkVerifiedSteamId(7L, 76561198000000001L)).thenReturn(SteamLinkOutcome.LINKED);
|
||||
|
||||
assertThat(service.complete(first, first, ANSWER)).isEqualTo(SteamLinkOutcome.FAILED);
|
||||
assertThat(service.complete(second, second, ANSWER)).isEqualTo(SteamLinkOutcome.LINKED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void urlsWithATrailingSlash_GiveNoDoubleSlash() {
|
||||
service = new SteamLinkService(steamOpenIdService, userService, PUBLIC_URL + "/", "https://tbp.example/");
|
||||
String state = start();
|
||||
|
||||
verify(steamOpenIdService).loginUrl(PUBLIC_URL + "/api/steam-link/callback?state=" + state, PUBLIC_URL);
|
||||
assertThat(service.frontendRedirect(SteamLinkOutcome.LINKED)).isEqualTo("https://tbp.example/settings?steam=linked");
|
||||
}
|
||||
|
||||
@Test
|
||||
void frontendRedirect_GoesToTheSettingsWithTheOutcome() {
|
||||
assertThat(service.frontendRedirect(SteamLinkOutcome.IN_USE)).isEqualTo("https://tbp.example/settings?steam=in-use");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package auth_service.modules.user.service;
|
||||
|
||||
import auth_service.shared.test.AbstractUnitTest;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.web.client.MockRestServiceServer;
|
||||
import org.springframework.web.client.RestClient;
|
||||
import org.springframework.web.util.UriComponentsBuilder;
|
||||
|
||||
import java.net.URLDecoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.hamcrest.Matchers.containsString;
|
||||
import static org.springframework.test.web.client.match.MockRestRequestMatchers.content;
|
||||
import static org.springframework.test.web.client.match.MockRestRequestMatchers.method;
|
||||
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
|
||||
import static org.springframework.test.web.client.response.MockRestResponseCreators.withServerError;
|
||||
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
|
||||
|
||||
class SteamOpenIdServiceTest extends AbstractUnitTest {
|
||||
|
||||
private static final String RETURN_TO = "https://auth.example/api/steam-link/callback?state=abc";
|
||||
private static final String CLAIMED = "https://steamcommunity.com/openid/id/76561198000000001";
|
||||
|
||||
private MockRestServiceServer steam;
|
||||
private SteamOpenIdService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
RestClient.Builder builder = RestClient.builder();
|
||||
steam = MockRestServiceServer.bindTo(builder).build();
|
||||
service = new SteamOpenIdService(builder.build());
|
||||
}
|
||||
|
||||
private static Map<String, String> answer() {
|
||||
Map<String, String> params = new HashMap<>();
|
||||
params.put("state", "abc");
|
||||
params.put("openid.ns", "http://specs.openid.net/auth/2.0");
|
||||
params.put("openid.mode", "id_res");
|
||||
params.put("openid.op_endpoint", "https://steamcommunity.com/openid/login");
|
||||
params.put("openid.claimed_id", CLAIMED);
|
||||
params.put("openid.identity", CLAIMED);
|
||||
params.put("openid.return_to", RETURN_TO);
|
||||
params.put("openid.response_nonce", "2026-09-26T10:00:00Zabc");
|
||||
params.put("openid.assoc_handle", "1234567890");
|
||||
params.put("openid.signed", "signed,op_endpoint,claimed_id,identity,return_to,response_nonce,assoc_handle");
|
||||
params.put("openid.sig", "c2lnbmF0dXJl");
|
||||
return params;
|
||||
}
|
||||
|
||||
private void steamAnswers(String body) {
|
||||
steam.expect(requestTo("https://steamcommunity.com/openid/login"))
|
||||
.andExpect(method(HttpMethod.POST))
|
||||
.andExpect(content().string(containsString("openid.mode=check_authentication")))
|
||||
.andExpect(content().string(containsString("openid.sig=c2lnbmF0dXJl")))
|
||||
.andRespond(withSuccess(body, MediaType.TEXT_PLAIN));
|
||||
}
|
||||
|
||||
@Test
|
||||
void loginUrl_AsksSteamToPickTheAccountAndComeBackToUs() {
|
||||
String url = service.loginUrl(RETURN_TO, "https://auth.example");
|
||||
|
||||
Map<String, String> query = UriComponentsBuilder.fromUriString(url).build().getQueryParams().toSingleValueMap();
|
||||
assertThat(url).startsWith("https://steamcommunity.com/openid/login?");
|
||||
assertThat(query).containsEntry("openid.mode", "checkid_setup")
|
||||
.containsEntry("openid.claimed_id", "http%3A%2F%2Fspecs.openid.net%2Fauth%2F2.0%2Fidentifier_select");
|
||||
assertThat(URLDecoder.decode(query.get("openid.return_to"), StandardCharsets.UTF_8)).isEqualTo(RETURN_TO);
|
||||
assertThat(URLDecoder.decode(query.get("openid.realm"), StandardCharsets.UTF_8)).isEqualTo("https://auth.example");
|
||||
}
|
||||
|
||||
@Test
|
||||
void verify_SteamConfirms_ReturnsTheSteamId() {
|
||||
steamAnswers("ns:http://specs.openid.net/auth/2.0\nis_valid:true\n");
|
||||
|
||||
assertThat(service.verify(answer(), RETURN_TO)).isEqualTo(Optional.of(76561198000000001L));
|
||||
steam.verify();
|
||||
}
|
||||
|
||||
@Test
|
||||
void verify_SteamSaysInvalid_IsEmpty() {
|
||||
steamAnswers("ns:http://specs.openid.net/auth/2.0\nis_valid:false\n");
|
||||
|
||||
assertThat(service.verify(answer(), RETURN_TO)).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void verify_SteamError_IsEmpty() {
|
||||
steam.expect(requestTo("https://steamcommunity.com/openid/login")).andRespond(withServerError());
|
||||
|
||||
assertThat(service.verify(answer(), RETURN_TO)).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void verify_AnswerNotForUs_IsEmptyWithoutAskingSteam() {
|
||||
Map<String, Map<String, String>> wrong = new HashMap<>();
|
||||
wrong.put("other state", with("openid.return_to", "https://auth.example/api/steam-link/callback?state=xyz"));
|
||||
wrong.put("other endpoint", with("openid.op_endpoint", "https://evil.example/openid/login"));
|
||||
wrong.put("claimed differs", with("openid.identity", "https://steamcommunity.com/openid/id/76561198000000002"));
|
||||
wrong.put("http claimed", withClaimedId("http://steamcommunity.com/openid/id/76561198000000001"));
|
||||
wrong.put("extra path", withClaimedId(CLAIMED + "/x"));
|
||||
wrong.put("unsigned claimed_id", with("openid.signed", "signed,op_endpoint,identity,return_to,response_nonce"));
|
||||
wrong.put("mode", with("openid.mode", "setup_needed"));
|
||||
|
||||
wrong.forEach((reason, params) -> assertThat(service.verify(params, RETURN_TO)).as(reason).isEmpty());
|
||||
steam.verify();
|
||||
}
|
||||
|
||||
private static Map<String, String> with(String key, String value) {
|
||||
Map<String, String> params = answer();
|
||||
params.put(key, value);
|
||||
return params;
|
||||
}
|
||||
|
||||
private static Map<String, String> withClaimedId(String claimedId) {
|
||||
Map<String, String> params = answer();
|
||||
params.put("openid.claimed_id", claimedId);
|
||||
params.put("openid.identity", claimedId);
|
||||
return params;
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,17 @@
|
||||
package auth_service.modules.user.service;
|
||||
|
||||
import auth_service.modules.user.exception.DisplayNameAlreadyInUseException;
|
||||
import auth_service.modules.user.exception.SteamIdAlreadyInUseException;
|
||||
import auth_service.modules.user.exception.UserNotFoundException;
|
||||
import auth_service.modules.user.model.Appearance;
|
||||
import auth_service.modules.user.model.SteamIdHolder;
|
||||
import auth_service.modules.user.model.SteamLinkOutcome;
|
||||
import auth_service.modules.user.model.User;
|
||||
import auth_service.modules.user.model.UserSettings;
|
||||
import auth_service.modules.user.persistence.UserRepository;
|
||||
import auth_service.shared.security.TokenGenerator;
|
||||
import auth_service.shared.test.AbstractUnitTest;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.InOrder;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
|
||||
@@ -60,52 +62,15 @@ class UserServiceTest extends AbstractUnitTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateUserSettings_ReturnsTokenWithNewSteamId() {
|
||||
// Arrange
|
||||
long userId = 1L;
|
||||
var email = "test@example.com";
|
||||
Long steamId = 76561198000000001L;
|
||||
void updateUserSettings_NeverTouchesTheSteamIdAndIssuesNoToken() {
|
||||
var userSettings = UserSettings.builder().language("en").build();
|
||||
when(userRepository.isSteamIdUsedByOtherUser(userId, steamId)).thenReturn(false);
|
||||
when(tokenGenerator.generateToken("1", email, steamId)).thenReturn("new-token");
|
||||
|
||||
// Act
|
||||
var token = userService.updateUserSettings(userId, email, steamId, null, userSettings);
|
||||
userService.updateUserSettings(1L, null, userSettings);
|
||||
|
||||
// Assert
|
||||
assertEquals("new-token", token);
|
||||
verify(userRepository).updateSettings(userId, steamId, null, userSettings);
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateUserSettings_WithoutSteamId() {
|
||||
// Arrange
|
||||
long userId = 1L;
|
||||
var email = "test@example.com";
|
||||
var userSettings = UserSettings.builder().language("en").build();
|
||||
when(tokenGenerator.generateToken("1", email, null)).thenReturn("new-token");
|
||||
|
||||
// Act
|
||||
var token = userService.updateUserSettings(userId, email, null, null, userSettings);
|
||||
|
||||
// Assert
|
||||
assertEquals("new-token", token);
|
||||
verify(userRepository).updateSettings(userId, null, null, userSettings);
|
||||
verify(userRepository, never()).isSteamIdUsedByOtherUser(anyLong(), anyLong());
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateUserSettings_SteamIdOfOtherUser() {
|
||||
// Arrange
|
||||
long userId = 1L;
|
||||
Long steamId = 76561198000000001L;
|
||||
var userSettings = UserSettings.builder().language("en").build();
|
||||
when(userRepository.isSteamIdUsedByOtherUser(userId, steamId)).thenReturn(true);
|
||||
|
||||
// Act & Assert
|
||||
assertThrows(SteamIdAlreadyInUseException.class,
|
||||
() -> userService.updateUserSettings(userId, "test@example.com", steamId, null, userSettings));
|
||||
verify(userRepository, never()).updateSettings(anyLong(), any(), any(), any());
|
||||
verify(userRepository).updateSettings(1L, null, userSettings);
|
||||
verify(userRepository, never()).clearSteamId(anyLong());
|
||||
verify(userRepository, never()).setVerifiedSteamId(anyLong(), anyLong());
|
||||
verifyNoInteractions(tokenGenerator);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -116,8 +81,8 @@ class UserServiceTest extends AbstractUnitTest {
|
||||
|
||||
// Act & Assert
|
||||
assertThrows(DisplayNameAlreadyInUseException.class,
|
||||
() -> userService.updateUserSettings(1L, "test@example.com", null, "Lobo", userSettings));
|
||||
verify(userRepository, never()).updateSettings(anyLong(), any(), any(), any());
|
||||
() -> userService.updateUserSettings(1L, "Lobo", userSettings));
|
||||
verify(userRepository, never()).updateSettings(anyLong(), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -127,10 +92,61 @@ class UserServiceTest extends AbstractUnitTest {
|
||||
when(userRepository.isDisplayNameUsedByOtherUser(1L, "Lobo")).thenReturn(false);
|
||||
|
||||
// Act
|
||||
userService.updateUserSettings(1L, "test@example.com", null, "Lobo", userSettings);
|
||||
userService.updateUserSettings(1L, "Lobo", userSettings);
|
||||
|
||||
// Assert
|
||||
verify(userRepository).updateSettings(1L, null, "Lobo", userSettings);
|
||||
verify(userRepository).updateSettings(1L, "Lobo", userSettings);
|
||||
}
|
||||
|
||||
private static final long STEAM_ID = 76561198000000001L;
|
||||
|
||||
@Test
|
||||
void linkVerifiedSteamId_FreeId_IsStoredVerified() {
|
||||
when(userRepository.findSteamIdHolder(STEAM_ID)).thenReturn(Optional.empty());
|
||||
|
||||
assertEquals(SteamLinkOutcome.LINKED, userService.linkVerifiedSteamId(1L, STEAM_ID));
|
||||
|
||||
verify(userRepository).setVerifiedSteamId(1L, STEAM_ID);
|
||||
}
|
||||
|
||||
@Test
|
||||
void linkVerifiedSteamId_OwnTypedId_BecomesVerified() {
|
||||
when(userRepository.findSteamIdHolder(STEAM_ID)).thenReturn(Optional.of(new SteamIdHolder(1L, false)));
|
||||
|
||||
assertEquals(SteamLinkOutcome.LINKED, userService.linkVerifiedSteamId(1L, STEAM_ID));
|
||||
|
||||
verify(userRepository, never()).clearSteamId(anyLong());
|
||||
verify(userRepository).setVerifiedSteamId(1L, STEAM_ID);
|
||||
}
|
||||
|
||||
@Test
|
||||
void linkVerifiedSteamId_TypedInByAnotherAccount_IsTakenFromIt() {
|
||||
when(userRepository.findSteamIdHolder(STEAM_ID)).thenReturn(Optional.of(new SteamIdHolder(2L, false)));
|
||||
|
||||
assertEquals(SteamLinkOutcome.LINKED, userService.linkVerifiedSteamId(1L, STEAM_ID));
|
||||
|
||||
InOrder order = inOrder(userRepository);
|
||||
order.verify(userRepository).clearSteamId(2L);
|
||||
order.verify(userRepository).setVerifiedSteamId(1L, STEAM_ID);
|
||||
}
|
||||
|
||||
@Test
|
||||
void linkVerifiedSteamId_ProvedByAnotherAccount_IsInUse() {
|
||||
when(userRepository.findSteamIdHolder(STEAM_ID)).thenReturn(Optional.of(new SteamIdHolder(2L, true)));
|
||||
|
||||
assertEquals(SteamLinkOutcome.IN_USE, userService.linkVerifiedSteamId(1L, STEAM_ID));
|
||||
|
||||
verify(userRepository, never()).clearSteamId(anyLong());
|
||||
verify(userRepository, never()).setVerifiedSteamId(anyLong(), anyLong());
|
||||
}
|
||||
|
||||
@Test
|
||||
void unlinkSteamId_ClearsItAndReturnsATokenWithoutIt() {
|
||||
when(tokenGenerator.generateToken("1", "test@example.com", null)).thenReturn("token-without-steam");
|
||||
|
||||
assertEquals("token-without-steam", userService.unlinkSteamId(1L, "test@example.com"));
|
||||
|
||||
verify(userRepository).clearSteamId(1L);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
Reference in new issue
Block a user