package com.xuekairui.gateway; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.http.MediaType; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; /** * 安全访问控制测试 * 验证 Security 白名单和鉴权逻辑 */ @DisplayName("安全访问控制测试") class SecurityAccessTest extends GatewayBaseTest { @Nested @DisplayName("公开接口(无需 Token)") class PublicEndpoints { @Test @DisplayName("GET /api/membership/levels - 等级列表应公开访问") void levelListShouldBePublic() throws Exception { mockMvc.perform(get("/api/membership/levels")) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(200)); } @Test @DisplayName("GET /api/membership/levels/{id} - 等级详情公开访问(/api/membership/levels/* 在白名单中)") void levelDetailShouldRequireAuth() throws Exception { mockMvc.perform(get("/api/membership/levels/1")) .andExpect(status().isOk()); } @Test @DisplayName("POST /api/auth/sms/send - 发送验证码应公开访问") void sendCodeShouldBePublic() throws Exception { mockMvc.perform(post("/api/auth/sms/send") .contentType(MediaType.APPLICATION_JSON) .content("{\"phone\":\"13800138000\",\"scene\":\"login\"}")) .andExpect(status().isOk()); } @Test @DisplayName("POST /api/auth/sms/login - 登录接口应公开访问") void smsLoginShouldBePublic() throws Exception { mockMvc.perform(post("/api/auth/sms/login") .contentType(MediaType.APPLICATION_JSON) .content("{\"phone\":\"13800138000\",\"code\":\"123456\"}")) .andExpect(status().isOk()); } @Test @DisplayName("GET /api/auth/wechat/url - 微信登录URL应公开访问") void wechatUrlShouldBePublic() throws Exception { mockMvc.perform(get("/api/auth/wechat/url")) .andExpect(status().isOk()); } @Test @DisplayName("GET /api/auth/wechat/callback - 微信回调应公开访问") void wechatCallbackShouldBePublic() throws Exception { mockMvc.perform(get("/api/auth/wechat/callback") .param("code", "test") .param("state", "login")) .andExpect(status().isOk()); } @Test @DisplayName("GET /api/invite/page/{code} - 邀请落地页应公开访问") void invitePageShouldBePublic() throws Exception { mockMvc.perform(get("/api/invite/page/TESTCODE")) .andExpect(status().isOk()); } @Test @DisplayName("POST /api/invite/click/{code} - 点击追踪应公开访问") void inviteClickShouldBePublic() throws Exception { mockMvc.perform(post("/api/invite/click/TESTCODE")) .andExpect(status().isOk()); } @Test @DisplayName("GET /api/invite/page/{code}?channel=app - 多渠道落地页应公开访问") void invitePageMultiChannelShouldBePublic() throws Exception { mockMvc.perform(get("/api/invite/page/TESTCODE?channel=app")) .andExpect(status().isOk()); } @Test @DisplayName("GET /api/trial-quota/active - 体验配额应公开访问") void trialQuotaActiveShouldBePublic() throws Exception { mockMvc.perform(get("/api/trial-quota/active")) .andExpect(status().isOk()); } @Test @DisplayName("GET /api/platform-config/enabled - 平台配置应公开访问") void platformConfigShouldBePublic() throws Exception { mockMvc.perform(get("/api/platform-config/enabled")) .andExpect(status().isOk()); } } @Nested @DisplayName("受保护接口 - 用户级(需要 Token)") class ProtectedEndpoints { @Test @DisplayName("GET /api/membership/my - 无Token应返回401") void myLevelWithoutTokenShouldReturn401() throws Exception { mockMvc.perform(get("/api/membership/my")) .andExpect(status().isUnauthorized()); } @Test @DisplayName("GET /api/crawler/status - 无Token应返回401") void crawlerStatusWithoutTokenShouldReturn401() throws Exception { mockMvc.perform(get("/api/crawler/status")) .andExpect(status().isUnauthorized()); } @Test @DisplayName("POST /api/crawler/consume - 无Token应返回401") void crawlerConsumeWithoutTokenShouldReturn401() throws Exception { mockMvc.perform(post("/api/crawler/consume") .contentType(MediaType.APPLICATION_JSON) .content("{\"count\":1}")) .andExpect(status().isUnauthorized()); } @Test @DisplayName("GET /api/crawler/logs - 无Token应返回401") void crawlerLogsWithoutTokenShouldReturn401() throws Exception { mockMvc.perform(get("/api/crawler/logs")) .andExpect(status().isUnauthorized()); } @Test @DisplayName("GET /api/coupon/my - 无Token应返回401") void myCouponsWithoutTokenShouldReturn401() throws Exception { mockMvc.perform(get("/api/coupon/my")) .andExpect(status().isUnauthorized()); } @Test @DisplayName("GET /api/coupon/my/available - 无Token应返回401") void availableCouponsWithoutTokenShouldReturn401() throws Exception { mockMvc.perform(get("/api/coupon/my/available")) .andExpect(status().isUnauthorized()); } @Test @DisplayName("POST /api/coupon/issue - 无Token应返回401") void issueCouponWithoutTokenShouldReturn401() throws Exception { mockMvc.perform(post("/api/coupon/issue") .contentType(MediaType.APPLICATION_JSON) .content("{}")) .andExpect(status().isUnauthorized()); } @Test @DisplayName("GET /api/auth/user/info - 无Token应返回401") void userInfoWithoutTokenShouldReturn401() throws Exception { mockMvc.perform(get("/api/auth/user/info")) .andExpect(status().isUnauthorized()); } @Test @DisplayName("GET /api/invite/code - 无Token应返回401") void inviteCodeWithoutTokenShouldReturn401() throws Exception { mockMvc.perform(get("/api/invite/code")) .andExpect(status().isUnauthorized()); } @Test @DisplayName("GET /api/invite/stats - 无Token应返回401") void inviteStatsWithoutTokenShouldReturn401() throws Exception { mockMvc.perform(get("/api/invite/stats")) .andExpect(status().isUnauthorized()); } @Test @DisplayName("GET /api/invite/rewards - 无Token应返回401") void inviteRewardsWithoutTokenShouldReturn401() throws Exception { mockMvc.perform(get("/api/invite/rewards")) .andExpect(status().isUnauthorized()); } @Test @DisplayName("GET /api/invite/extra-quota - 无Token应返回401") void extraQuotaWithoutTokenShouldReturn401() throws Exception { mockMvc.perform(get("/api/invite/extra-quota")) .andExpect(status().isUnauthorized()); } } @Nested @DisplayName("受保护接口 - 管理级(需要 Token)") class AdminEndpoints { @Test @DisplayName("GET /api/admin/invite/config - 无Token应返回401") void adminInviteConfigWithoutTokenShouldReturn401() throws Exception { mockMvc.perform(get("/api/admin/invite/config")) .andExpect(status().isUnauthorized()); } @Test @DisplayName("PUT /api/admin/invite/config - 无Token应返回401") void adminUpdateInviteConfigWithoutTokenShouldReturn401() throws Exception { mockMvc.perform(put("/api/admin/invite/config") .contentType(MediaType.APPLICATION_JSON) .content("{}")) .andExpect(status().isUnauthorized()); } @Test @DisplayName("POST /api/admin/crawler/grant - 无Token应返回401") void adminGrantCrawlerWithoutTokenShouldReturn401() throws Exception { mockMvc.perform(post("/api/admin/crawler/grant") .contentType(MediaType.APPLICATION_JSON) .content("{\"userId\":1,\"quotaCount\":50}")) .andExpect(status().isUnauthorized()); } @Test @DisplayName("GET /api/admin/crawler/grants - 无Token应返回401") void adminListGrantsWithoutTokenShouldReturn401() throws Exception { mockMvc.perform(get("/api/admin/crawler/grants")) .andExpect(status().isUnauthorized()); } @Test @DisplayName("GET /api/admin/invite/rewards - 无Token应返回401") void adminInviteRewardsWithoutTokenShouldReturn401() throws Exception { mockMvc.perform(get("/api/admin/invite/rewards")) .andExpect(status().isUnauthorized()); } @Test @DisplayName("POST /api/admin/membership/grant - 无Token应返回401") void setUserLevelWithoutTokenShouldReturn401() throws Exception { mockMvc.perform(post("/api/admin/membership/grant") .contentType(MediaType.APPLICATION_JSON) .content("{\"userId\":1,\"level\":\"PRO\"}")) .andExpect(status().isUnauthorized()); } @Test @DisplayName("GET /api/admin/trial-quota/configs - 无Token应返回401") void adminTrialQuotaConfigsWithoutTokenShouldReturn401() throws Exception { mockMvc.perform(get("/api/admin/trial-quota/configs")) .andExpect(status().isUnauthorized()); } @Test @DisplayName("POST /api/admin/trial-quota/config - 无Token应返回401") void adminCreateTrialQuotaConfigWithoutTokenShouldReturn401() throws Exception { mockMvc.perform(post("/api/admin/trial-quota/config") .contentType(MediaType.APPLICATION_JSON) .content("{}")) .andExpect(status().isUnauthorized()); } @Test @DisplayName("PUT /api/admin/trial-quota/config/{id} - 无Token应返回401") void adminUpdateTrialQuotaConfigWithoutTokenShouldReturn401() throws Exception { mockMvc.perform(put("/api/admin/trial-quota/config/1") .contentType(MediaType.APPLICATION_JSON) .content("{}")) .andExpect(status().isUnauthorized()); } @Test @DisplayName("DELETE /api/admin/trial-quota/config/{id} - 无Token应返回401") void adminDeleteTrialQuotaConfigWithoutTokenShouldReturn401() throws Exception { mockMvc.perform(delete("/api/admin/trial-quota/config/1")) .andExpect(status().isUnauthorized()); } @Test @DisplayName("GET /api/admin/audit-log - 无Token应返回401") void adminAuditLogWithoutTokenShouldReturn401() throws Exception { mockMvc.perform(get("/api/admin/audit-log")) .andExpect(status().isUnauthorized()); } @Test @DisplayName("GET /api/admin/audit-log/sensitive - 无Token应返回401") void adminSensitiveLogsWithoutTokenShouldReturn401() throws Exception { mockMvc.perform(get("/api/admin/audit-log/sensitive")) .andExpect(status().isUnauthorized()); } @Test @DisplayName("GET /api/admin/audit-log/user/{userId} - 无Token应返回401") void adminUserLogsWithoutTokenShouldReturn401() throws Exception { mockMvc.perform(get("/api/admin/audit-log/user/1")) .andExpect(status().isUnauthorized()); } @Test @DisplayName("GET /api/admin/audit-log/target/{type}/{id} - 无Token应返回401") void adminTargetHistoryWithoutTokenShouldReturn401() throws Exception { mockMvc.perform(get("/api/admin/audit-log/target/USER/1")) .andExpect(status().isUnauthorized()); } @Test @DisplayName("GET /api/admin/audit-log/stats - 无Token应返回401") void adminStatsWithoutTokenShouldReturn401() throws Exception { mockMvc.perform(get("/api/admin/audit-log/stats?module=USER")) .andExpect(status().isUnauthorized()); } @Test @DisplayName("GET /api/admin/license/review - 无Token应返回401") void adminLicenseReviewWithoutTokenShouldReturn401() throws Exception { mockMvc.perform(get("/api/admin/license/review")) .andExpect(status().isUnauthorized()); } @Test @DisplayName("POST /api/admin/license/review - 无Token应返回401") void adminReviewLicenseWithoutTokenShouldReturn401() throws Exception { mockMvc.perform(post("/api/admin/license/review") .contentType(MediaType.APPLICATION_JSON) .content("{}")) .andExpect(status().isUnauthorized()); } } @Nested @DisplayName("Token 有效性验证") class TokenValidation { @Test @DisplayName("GET /api/membership/my - 携带有效Token应正常返回") void myLevelWithValidTokenShouldSucceed() throws Exception { String token = generateAccessToken(1L, "13800138000"); mockMvc.perform(get("/api/membership/my") .header("Authorization", "Bearer " + token)) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(200)); } @Test @DisplayName("GET /api/crawler/status - 携带有效Token应正常返回或业务错误") void crawlerStatusWithValidTokenShouldSucceed() throws Exception { String token = generateAccessToken(1L, "13800138000"); mockMvc.perform(get("/api/crawler/status") .header("Authorization", "Bearer " + token)) .andExpect(status().isOk()); // 可能返回200(配额正常)或200+业务错误码(配额耗尽),但不应401 } @Test @DisplayName("携带无效Token应返回401") void invalidTokenShouldReturn401() throws Exception { mockMvc.perform(get("/api/membership/my") .header("Authorization", "Bearer invalid.token.here")) .andExpect(status().isUnauthorized()); } @Test @DisplayName("携带空Token应返回401") void emptyTokenShouldReturn401() throws Exception { mockMvc.perform(get("/api/membership/my") .header("Authorization", "Bearer ")) .andExpect(status().isUnauthorized()); } @Test @DisplayName("不同用户的Token应各自有效") void differentUserTokensShouldWork() throws Exception { String token1 = generateAccessToken(1L, "13800138000"); String token2 = generateAccessToken(2L, "13900139000"); mockMvc.perform(get("/api/membership/my") .header("Authorization", "Bearer " + token1)) .andExpect(status().isOk()); mockMvc.perform(get("/api/membership/my") .header("Authorization", "Bearer " + token2)) .andExpect(status().isOk()); } } @Nested @DisplayName("HTTP 方法验证") class HttpMethodValidation { @Test @DisplayName("GET /api/auth/sms/send - 应只支持POST") void sendCode_getMethodShouldFail() throws Exception { mockMvc.perform(get("/api/auth/sms/send")) .andExpect(status().is5xxServerError()); } @Test @DisplayName("DELETE /api/membership/levels - 不支持的方法") void levelList_deleteMethodShouldReturn405() throws Exception { mockMvc.perform(delete("/api/membership/levels")) .andExpect(status().is5xxServerError()); } } }