package com.ruoyi.app.user; import com.ruoyi.app.user.service.DeviceTrustService; import com.ruoyi.app.utils.oauth.LineOAuthProperties; import com.ruoyi.app.utils.oauth.OAuthIdentity; import com.ruoyi.common.core.redis.RedisCache; import com.ruoyi.common.utils.spring.SpringUtils; import com.ruoyi.system.domain.InfoUser; import com.ruoyi.system.domain.InfoUserDevice; import com.ruoyi.system.domain.InfoUserOauth; import com.ruoyi.system.mapper.InfoUserDeviceMapper; import com.ruoyi.system.mapper.InfoUserOauthMapper; import com.ruoyi.system.service.IInfoUserService; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; import org.springframework.beans.factory.support.DefaultListableBeanFactory; import org.springframework.context.support.StaticMessageSource; import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.mock.web.MockHttpServletResponse; import org.springframework.test.util.ReflectionTestUtils; import org.springframework.web.context.request.RequestContextHolder; import org.springframework.web.context.request.ServletRequestAttributes; import java.util.concurrent.TimeUnit; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.startsWith; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; /** * LINE 回调设备信任免绑分支测试(029-device-trust-phone-bind,TDD 先行)。 * state 参数语义:前端把 deviceId 放进 LINE authorize 的 state 带回(见 contracts/api-contract.md §5)。 */ class LineCallbackDeviceTrustTest { private static ConfigurableListableBeanFactory originalBeanFactory; private final RedisCache redisCache = mock(RedisCache.class); private final IInfoUserService infoUserService = mock(IInfoUserService.class); private final InfoUserOauthMapper infoUserOauthMapper = mock(InfoUserOauthMapper.class); private final com.ruoyi.app.utils.oauth.OAuthVerifyService oauthVerifyService = mock(com.ruoyi.app.utils.oauth.OAuthVerifyService.class); private final InfoUserDeviceMapper deviceMapper = mock(InfoUserDeviceMapper.class); private LineCallbackController controller; private MockHttpServletResponse response; @BeforeAll static void saveStatics() { originalBeanFactory = (ConfigurableListableBeanFactory) ReflectionTestUtils.getField(SpringUtils.class, "beanFactory"); } @AfterAll static void restoreStatics() { new SpringUtils().postProcessBeanFactory(originalBeanFactory); } @BeforeEach void setUp() throws Exception { DefaultListableBeanFactory beanFactory = new DefaultListableBeanFactory(); StaticMessageSource messageSource = new StaticMessageSource(); messageSource.setUseCodeAsDefaultMessage(true); beanFactory.registerSingleton("messageSource", messageSource); beanFactory.registerSingleton("redisCache", redisCache); new SpringUtils().postProcessBeanFactory(beanFactory); MockHttpServletRequest request = new MockHttpServletRequest(); request.setRemoteAddr("127.0.0.1"); request.addHeader("User-Agent", "JUnit"); RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request)); LineOAuthProperties properties = new LineOAuthProperties(); LineOAuthProperties.Channel user = new LineOAuthProperties.Channel(); user.setClientId("user-client"); user.setClientSecret("secret"); user.setRedirectUri("https://api.test/auth/line/callback"); user.setAppRedirect("com.test.app://oauthLogin"); properties.setUser(user); controller = new LineCallbackController(); ReflectionTestUtils.setField(controller, "redisCache", redisCache); ReflectionTestUtils.setField(controller, "infoUserService", infoUserService); ReflectionTestUtils.setField(controller, "infoUserOauthMapper", infoUserOauthMapper); ReflectionTestUtils.setField(controller, "oauthVerifyService", oauthVerifyService); ReflectionTestUtils.setField(controller, "deviceTrustService", DeviceTrustTestSupport.service(deviceMapper, infoUserOauthMapper)); ReflectionTestUtils.setField(controller, "lineOAuthProperties", properties); response = new MockHttpServletResponse(); } @AfterEach void tearDown() { RequestContextHolder.resetRequestAttributes(); } private static InfoUserDevice trust(String deviceId, String phone, long userId) { InfoUserDevice t = new InfoUserDevice(); t.setDeviceId(deviceId); t.setPhone(phone); t.setUserId(userId); return t; } @Test void unboundWithTrustedDeviceRedirectsDeviceConfirmAndWritesBothKeys() throws Exception { when(oauthVerifyService.verifyIdentity("line", "code-1")).thenReturn(new OAuthIdentity("line-uid-1", null)); when(infoUserOauthMapper.selectOne(any())).thenReturn(null); when(deviceMapper.selectById("dev-1")).thenReturn(trust("dev-1", "0987654321", 100L)); controller.callback("line", "code-1", "dev-1", response); String location = response.getRedirectedUrl(); assertTrue(location != null && location.startsWith("com.test.app://oauthLogin?deviceConfirm=1&tempKey="), "应回跳 deviceConfirm 分支,实际: " + location); assertTrue(location.endsWith("&maskedPhone=098****4321"), "应带脱敏手机号,实际: " + location); ArgumentCaptor tempKeyCaptor = ArgumentCaptor.forClass(String.class); verify(redisCache).setCacheObject(startsWith("oauth:bind:"), eq("line@line-uid-1"), eq(5), eq(TimeUnit.MINUTES)); verify(redisCache).setCacheObject(startsWith("oauth:bind:dev:"), eq("dev-1"), eq(5), eq(TimeUnit.MINUTES)); } @Test void unboundWithUnknownStateStillRedirectsNeedPhone() throws Exception { when(oauthVerifyService.verifyIdentity("line", "code-2")).thenReturn(new OAuthIdentity("line-uid-2", null)); when(infoUserOauthMapper.selectOne(any())).thenReturn(null); when(deviceMapper.selectById("dev-x")).thenReturn(null); controller.callback("line", "code-2", "dev-x", response); String location = response.getRedirectedUrl(); assertTrue(location != null && location.startsWith("com.test.app://oauthLogin?needPhone=1&tempKey="), "应维持 needPhone 回跳,实际: " + location); verify(redisCache, never()).setCacheObject(startsWith("oauth:bind:dev:"), anyString(), anyInt(), any(TimeUnit.class)); } @org.junit.jupiter.params.ParameterizedTest @org.junit.jupiter.params.provider.ValueSource(booleans = {false, true}) void boundUserWithDeviceStateRedirectsTokenAndRecordsTrust(boolean pending) throws Exception { when(oauthVerifyService.verifyIdentity("line", "code-3")).thenReturn(new OAuthIdentity("line-uid-3", null)); InfoUserOauth bind = new InfoUserOauth(); bind.setId(77L); bind.setUserId(100L); bind.setProvider("line"); bind.setProviderUid("line-uid-3"); when(infoUserOauthMapper.selectOne(any())).thenReturn(bind); InfoUser u = new InfoUser(); u.setUserId(100L); u.setPhone("0987654321"); u.setUserType("0"); u.setStatus("0"); u.setDelFlag(pending ? "1" : "0"); UserCancellationService cancellation = mock(UserCancellationService.class); ReflectionTestUtils.setField(controller, "userCancellationService", cancellation); if (pending) when(cancellation.prepareLogin(any())).thenReturn(new UserCancellationService.LoginConfirmationView( "cancellationConfirm", 1, new java.util.Date(System.currentTimeMillis() + 60000), "a".repeat(32), 60)); when(infoUserService.getOne(any())).thenReturn(u); when(deviceMapper.selectById("dev-1")).thenReturn(null); controller.callback("line", "code-3", "dev-1", response); if (pending) { assertTrue(response.getRedirectedUrl().contains("cancellationConfirm=1")); assertTrue(response.getRedirectedUrl().contains("remainingDays=1")); org.junit.jupiter.api.Assertions.assertFalse(response.getRedirectedUrl().contains("token=")); assertEquals("1", u.getDelFlag()); verify(cancellation).prepareLogin(any()); verify(deviceMapper, never()).updateById(any(InfoUserDevice.class)); return; } String location = response.getRedirectedUrl(); assertTrue(location != null && location.startsWith("com.test.app://oauthLogin?token="), "已绑定应直接回跳 token,实际: " + location); verify(deviceMapper).ensureExists("dev-1", "0987654321", 100L); org.mockito.Mockito.verifyNoInteractions(cancellation); } @Test void boundIdentityFromOldPhoneRequiresPhoneLoginWithoutIssuingToken() throws Exception { when(oauthVerifyService.verifyIdentity("line", "conflict")).thenReturn(new OAuthIdentity("old-identity", null)); InfoUserOauth bind = new InfoUserOauth(); bind.setId(77L); bind.setUserId(100L); bind.setProvider("line"); bind.setProviderUid("old-identity"); when(infoUserOauthMapper.selectOne(any())).thenReturn(bind); InfoUser user = new InfoUser(); user.setUserId(100L); user.setPhone("0987654321"); user.setUserType("0"); user.setStatus("0"); user.setDelFlag("0"); when(infoUserService.getOne(any())).thenReturn(user); when(deviceMapper.selectById("dev-1")).thenReturn(trust("dev-1", "0911111111", 200L)); controller.callback("line", "conflict", "dev-1", response); assertEquals("com.test.app://oauthLogin?error=phone_login_required", response.getRedirectedUrl()); verify(redisCache, never()).deleteKeys(anyString()); verify(deviceMapper, never()).updateById(any(InfoUserDevice.class)); } }