test(auth): cover persisted CLI token states (#605)

Signed-off-by: dongmucat <1127093059@qq.com>
This commit is contained in:
dongmucat 2026-07-28 12:03:24 +08:00
parent e5b8439678
commit 83b621880e

View file

@ -0,0 +1,257 @@
package com.iflytek.skillhub.controller.cli;
import com.iflytek.skillhub.auth.entity.ApiToken;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.auth.repository.ApiTokenRepository;
import com.iflytek.skillhub.auth.token.ApiTokenService;
import com.iflytek.skillhub.domain.user.UserAccount;
import com.iflytek.skillhub.domain.user.UserAccountRepository;
import com.iflytek.skillhub.dto.cli.CliResolveResponse;
import com.iflytek.skillhub.service.cli.CliSkillAppService;
import java.io.ByteArrayInputStream;
import java.time.Clock;
import java.time.Instant;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.EnumSource;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.core.io.InputStreamResource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.clearInvocations;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
class CliTokenLifecycleSecurityIntegrationTest {
private enum InvalidCredentialState {
REVOKED,
EXPIRED,
UNKNOWN,
EMPTY,
MALFORMED
}
@Autowired MockMvc mockMvc;
@Autowired ApiTokenService apiTokenService;
@Autowired ApiTokenRepository apiTokenRepository;
@Autowired UserAccountRepository userAccountRepository;
@Autowired Clock clock;
@MockBean CliSkillAppService cliSkillAppService;
private String userId;
@BeforeEach
void setUp() {
userId = "token-matrix-" + UUID.randomUUID();
userAccountRepository.save(new UserAccount(
userId, "Token Matrix", userId + "@example.com", ""));
given(cliSkillAppService.search(any(), anyInt(), any(), any()))
.willReturn(new CliSkillAppService.CliSearchResult(List.of(), 0, 20));
given(cliSkillAppService.resolve(anyString(), anyString(), any(), any(), any()))
.willReturn(new CliResolveResponse(
"global", "demo", "1.0.0", 1L, "sha256:empty",
"/api/v1/skills/global/demo/versions/1.0.0/download"));
given(cliSkillAppService.downloadLatest(anyString(), anyString(), any()))
.willAnswer(ignored -> downloadResponse());
given(cliSkillAppService.downloadVersion(anyString(), anyString(), anyString(), any()))
.willAnswer(ignored -> downloadResponse());
}
@Test
void whoamiWithoutAuthorizationReturns401() throws Exception {
mockMvc.perform(get("/api/cli/v1/auth/whoami"))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.code").value(401));
}
@Test
void whoamiWithValidPersistedTokenReturns200() throws Exception {
String token = createActiveToken();
mockMvc.perform(withBearer(get("/api/cli/v1/auth/whoami"), token))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.handle").value(userId));
}
@ParameterizedTest(name = "whoami rejects {0}")
@EnumSource(InvalidCredentialState.class)
void whoamiRejectsInvalidBearer(InvalidCredentialState state) throws Exception {
clearInvocations(cliSkillAppService);
mockMvc.perform(withInvalidBearer(get("/api/cli/v1/auth/whoami"), state))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.code").value(401));
verifyNoInteractions(cliSkillAppService);
}
@Test
void searchWithoutAuthorizationReturns200() throws Exception {
mockMvc.perform(get("/api/cli/v1/skills/search").param("q", "demo").param("limit", "20"))
.andExpect(status().isOk());
}
@Test
void searchWithValidPersistedTokenReturns200() throws Exception {
String token = createActiveToken();
mockMvc.perform(withBearer(
get("/api/cli/v1/skills/search").param("q", "demo").param("limit", "20"), token))
.andExpect(status().isOk());
}
@ParameterizedTest(name = "search rejects {0}")
@EnumSource(InvalidCredentialState.class)
void searchRejectsInvalidBearer(InvalidCredentialState state) throws Exception {
clearInvocations(cliSkillAppService);
mockMvc.perform(withInvalidBearer(
get("/api/cli/v1/skills/search").param("q", "demo").param("limit", "20"), state))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.code").value(401));
verifyNoInteractions(cliSkillAppService);
}
@Test
void resolveWithoutAuthorizationReturns200() throws Exception {
mockMvc.perform(get("/api/cli/v1/skills/global/demo/resolve"))
.andExpect(status().isOk());
}
@Test
void resolveWithValidPersistedTokenReturns200() throws Exception {
String token = createActiveToken();
mockMvc.perform(withBearer(get("/api/cli/v1/skills/global/demo/resolve"), token))
.andExpect(status().isOk());
}
@ParameterizedTest(name = "resolve rejects {0}")
@EnumSource(InvalidCredentialState.class)
void resolveRejectsInvalidBearer(InvalidCredentialState state) throws Exception {
clearInvocations(cliSkillAppService);
mockMvc.perform(withInvalidBearer(get("/api/cli/v1/skills/global/demo/resolve"), state))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.code").value(401));
verifyNoInteractions(cliSkillAppService);
}
@Test
void latestDownloadWithoutAuthorizationReturns200() throws Exception {
mockMvc.perform(get("/api/cli/v1/skills/global/demo/download"))
.andExpect(status().isOk());
}
@Test
void latestDownloadWithValidPersistedTokenReturns200() throws Exception {
String token = createActiveToken();
mockMvc.perform(withBearer(get("/api/cli/v1/skills/global/demo/download"), token))
.andExpect(status().isOk());
}
@ParameterizedTest(name = "latest download rejects {0}")
@EnumSource(InvalidCredentialState.class)
void latestDownloadRejectsInvalidBearer(InvalidCredentialState state) throws Exception {
clearInvocations(cliSkillAppService);
mockMvc.perform(withInvalidBearer(get("/api/cli/v1/skills/global/demo/download"), state))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.code").value(401));
verifyNoInteractions(cliSkillAppService);
}
@Test
void versionedDownloadWithoutAuthorizationReturns200() throws Exception {
mockMvc.perform(get("/api/cli/v1/skills/global/demo/versions/1.0.0/download"))
.andExpect(status().isOk());
}
@Test
void versionedDownloadWithValidPersistedTokenReturns200() throws Exception {
String token = createActiveToken();
mockMvc.perform(withBearer(
get("/api/cli/v1/skills/global/demo/versions/1.0.0/download"), token))
.andExpect(status().isOk());
}
@ParameterizedTest(name = "versioned download rejects {0}")
@EnumSource(InvalidCredentialState.class)
void versionedDownloadRejectsInvalidBearer(InvalidCredentialState state) throws Exception {
clearInvocations(cliSkillAppService);
mockMvc.perform(withInvalidBearer(
get("/api/cli/v1/skills/global/demo/versions/1.0.0/download"), state))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.code").value(401));
verifyNoInteractions(cliSkillAppService);
}
private MockHttpServletRequestBuilder withInvalidBearer(
MockHttpServletRequestBuilder request,
InvalidCredentialState state) {
return request
.header(HttpHeaders.AUTHORIZATION, authorizationHeader(state))
.with(authentication(sessionAuthentication()));
}
private MockHttpServletRequestBuilder withBearer(
MockHttpServletRequestBuilder request,
String rawToken) {
return request.header(HttpHeaders.AUTHORIZATION, "Bearer " + rawToken);
}
private String authorizationHeader(InvalidCredentialState state) {
return switch (state) {
case REVOKED -> {
ApiTokenService.TokenCreateResult result = createToken();
apiTokenService.revokeToken(result.entity().getId(), userId);
yield "Bearer " + result.rawToken();
}
case EXPIRED -> {
ApiTokenService.TokenCreateResult result = createToken();
ApiToken token = result.entity();
token.setExpiresAt(Instant.now(clock).minusSeconds(1));
apiTokenRepository.saveAndFlush(token);
yield "Bearer " + result.rawToken();
}
case UNKNOWN -> "Bearer sk_unknown_" + UUID.randomUUID();
case EMPTY -> "Bearer ";
case MALFORMED -> "Bearer";
};
}
private String createActiveToken() {
return createToken().rawToken();
}
private ApiTokenService.TokenCreateResult createToken() {
return apiTokenService.createToken(
userId, "matrix-" + UUID.randomUUID(), "[\"skill:read\"]");
}
private UsernamePasswordAuthenticationToken sessionAuthentication() {
PlatformPrincipal principal = new PlatformPrincipal(
userId, "Session User", userId + "@example.com", "", "session", Set.of("USER"));
return new UsernamePasswordAuthenticationToken(principal, null, List.of());
}
private ResponseEntity<InputStreamResource> downloadResponse() {
return ResponseEntity.ok(new InputStreamResource(
new ByteArrayInputStream("zip".getBytes(java.nio.charset.StandardCharsets.UTF_8))));
}
}