package com.ruoyi.app.user; import com.ruoyi.app.user.service.DeviceTrustService; 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); RequestContextHolder.setRequestAttributes( new ServletRequestAttributes(new MockHttpServletRequest())); 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", new DeviceTrustService(deviceMapper)); ReflectionTestUtils.setField(controller, "appRedirect", "com.test.app://oauthLogin"); 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.verify("line", "code-1")).thenReturn("line-uid-1"); when(infoUserOauthMapper.selectOne(any())).thenReturn(null); when(deviceMapper.selectById("dev-1")).thenReturn(trust("dev-1", "0987654321", 100L)); controller.callback("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.verify("line", "code-2")).thenReturn("line-uid-2"); when(infoUserOauthMapper.selectOne(any())).thenReturn(null); when(deviceMapper.selectById("dev-x")).thenReturn(null); controller.callback("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)); } @Test void boundUserWithDeviceStateRedirectsTokenAndRecordsTrust() throws Exception { when(oauthVerifyService.verify("line", "code-3")).thenReturn("line-uid-3"); InfoUserOauth bind = new InfoUserOauth(); 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.setStatus("0"); when(infoUserService.getOne(any())).thenReturn(u); when(deviceMapper.selectById("dev-1")).thenReturn(null); controller.callback("code-3", "dev-1", response); String location = response.getRedirectedUrl(); assertTrue(location != null && location.startsWith("com.test.app://oauthLogin?token="), "已绑定应直接回跳 token,实际: " + location); verify(deviceMapper).insert(any(InfoUserDevice.class)); } }