mirror of
https://github.com/iflytek/skillhub.git
synced 2026-11-14 09:01:16 +00:00
fix(release): correct avatar security, missing routes and scan refresh (#970)
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
parent
90c5834269
commit
0318b75e03
9 changed files with 416 additions and 2 deletions
|
|
@ -50,8 +50,9 @@ public class ClawHubRegistrySecurityConfig {
|
|||
new AntPathRequestMatcher("/api/v1/search"),
|
||||
new AntPathRequestMatcher("/api/v1/download"),
|
||||
new AntPathRequestMatcher("/api/v1/skills/*")),
|
||||
new NegatedRequestMatcher(new AntPathRequestMatcher(
|
||||
"/api/v1/skills/package-preview", HttpMethod.POST.name()))
|
||||
new NegatedRequestMatcher(new OrRequestMatcher(
|
||||
new AntPathRequestMatcher("/api/v1/skills/package-preview", HttpMethod.POST.name()),
|
||||
new AntPathRequestMatcher("/api/v1/skills/avatar-uploads", HttpMethod.POST.name())))
|
||||
))
|
||||
.authorizeHttpRequests(auth -> auth.anyRequest().permitAll())
|
||||
.requestCache(cache -> cache.disable())
|
||||
|
|
|
|||
|
|
@ -32,6 +32,8 @@ import org.springframework.web.bind.annotation.RestControllerAdvice;
|
|||
import org.springframework.web.context.request.async.AsyncRequestTimeoutException;
|
||||
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
|
||||
import org.springframework.web.multipart.support.MissingServletRequestPartException;
|
||||
import org.springframework.web.servlet.NoHandlerFoundException;
|
||||
import org.springframework.web.servlet.resource.NoResourceFoundException;
|
||||
|
||||
/**
|
||||
* Translates application, domain, auth, and infrastructure exceptions into the platform's JSON API
|
||||
|
|
@ -129,6 +131,13 @@ public class GlobalExceptionHandler {
|
|||
return response.body(apiResponseFactory.error(405, "error.methodNotAllowed"));
|
||||
}
|
||||
|
||||
@ExceptionHandler({NoResourceFoundException.class, NoHandlerFoundException.class})
|
||||
public ResponseEntity<ApiResponse<Void>> handleNotFound(Exception ex, HttpServletRequest request) {
|
||||
logHandledException(HttpStatus.NOT_FOUND, "error.notFound", request);
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(
|
||||
apiResponseFactory.error(404, "error.notFound"));
|
||||
}
|
||||
|
||||
@ExceptionHandler(HttpMediaTypeNotSupportedException.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleUnsupportedMediaType(
|
||||
HttpMediaTypeNotSupportedException ex,
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ error.auth.sessionBootstrap.disabled=Session bootstrap is disabled
|
|||
error.auth.sessionBootstrap.providerUnsupported=Unsupported session bootstrap provider: {0}
|
||||
error.auth.sessionBootstrap.notAuthenticated=No authenticated external session found
|
||||
error.badRequest=Invalid request
|
||||
error.notFound=Resource not found
|
||||
error.suite.workspace.invalidFilter=Invalid suite status filter
|
||||
error.methodNotAllowed=HTTP method is not supported
|
||||
error.unsupportedMediaType=Unsupported media type
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ error.auth.sessionBootstrap.disabled=Инициализация сессии о
|
|||
error.auth.sessionBootstrap.providerUnsupported=Неподдерживаемый провайдер инициализации сессии: {0}
|
||||
error.auth.sessionBootstrap.notAuthenticated=Внешняя аутентифицированная сессия не найдена
|
||||
error.badRequest=Некорректный запрос
|
||||
error.notFound=Ресурс не найден
|
||||
error.suite.workspace.invalidFilter=Некорректный фильтр статуса набора
|
||||
error.methodNotAllowed=HTTP-метод не поддерживается
|
||||
error.unsupportedMediaType=Неподдерживаемый тип медиа
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ error.auth.sessionBootstrap.disabled=会话引导能力未启用
|
|||
error.auth.sessionBootstrap.providerUnsupported=不支持的会话引导提供方:{0}
|
||||
error.auth.sessionBootstrap.notAuthenticated=未检测到已认证的外部会话
|
||||
error.badRequest=请求参数不合法
|
||||
error.notFound=未找到资源
|
||||
error.suite.workspace.invalidFilter=套件状态筛选不合法
|
||||
error.methodNotAllowed=不支持的请求方法
|
||||
error.unsupportedMediaType=不支持的请求内容类型
|
||||
|
|
|
|||
|
|
@ -0,0 +1,229 @@
|
|||
package com.iflytek.skillhub.controller.portal;
|
||||
|
||||
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
|
||||
import com.iflytek.skillhub.auth.token.ApiTokenService;
|
||||
import com.iflytek.skillhub.compat.ClawHubCompatAppService;
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException;
|
||||
import com.iflytek.skillhub.domain.user.UserAccount;
|
||||
import com.iflytek.skillhub.domain.user.UserAccountRepository;
|
||||
import com.iflytek.skillhub.dto.SkillAvatarUploadResponse;
|
||||
import com.iflytek.skillhub.service.SkillAvatarUploadService;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import jakarta.servlet.http.Cookie;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
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.mock.web.MockHttpSession;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.web.context.HttpSessionSecurityContextRepository;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.ArgumentMatchers.isNull;
|
||||
import static org.mockito.BDDMockito.given;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.hamcrest.Matchers.containsString;
|
||||
import static org.hamcrest.Matchers.not;
|
||||
import org.springframework.test.web.servlet.request.RequestPostProcessor;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
/** Exercises the production ordered chains, stored sessions, CSRF and token route policy. */
|
||||
@SpringBootTest(properties = "skillhub.ratelimit.enabled=false")
|
||||
@AutoConfigureMockMvc
|
||||
@ActiveProfiles("test")
|
||||
class SkillAvatarUploadSecurityIntegrationTest {
|
||||
@Autowired MockMvc mockMvc;
|
||||
@Autowired UserAccountRepository users;
|
||||
@MockBean ApiTokenService tokens;
|
||||
@MockBean SkillAvatarUploadService uploads;
|
||||
@MockBean ClawHubCompatAppService compatibility;
|
||||
private String userId;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
userId = "avatar-security-" + UUID.randomUUID();
|
||||
users.save(new UserAccount(userId, "Avatar user", userId + "@example.test", ""));
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"/api/v1", "/api/web"})
|
||||
void browserSessionWithCsrfCanUpload(String prefix) throws Exception {
|
||||
given(uploads.upload(eq(userId), any(byte[].class))).willReturn(new SkillAvatarUploadResponse(
|
||||
"upload-1", prefix + "/skills/avatar-uploads/upload-1/image", Instant.now().plusSeconds(600)));
|
||||
mockMvc.perform(multipart(prefix + "/skills/avatar-uploads").file(image())
|
||||
.session(session()).with(csrf()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data.uploadId").value("upload-1"));
|
||||
verify(uploads).upload(eq(userId), any(byte[].class));
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"/api/v1", "/api/web"})
|
||||
void browserCookieToHeaderCsrfCanUpload(String prefix) throws Exception {
|
||||
var browserSession = session();
|
||||
String csrfToken = UUID.randomUUID().toString();
|
||||
given(uploads.upload(eq(userId), any(byte[].class))).willReturn(new SkillAvatarUploadResponse(
|
||||
"upload-cookie", prefix + "/skills/avatar-uploads/upload-cookie/image", Instant.now().plusSeconds(600)));
|
||||
mockMvc.perform(multipart(prefix + "/skills/avatar-uploads").file(image()).session(browserSession)
|
||||
.cookie(new Cookie("SESSION", browserSession.getId()), new Cookie("XSRF-TOKEN", csrfToken))
|
||||
.header("X-XSRF-TOKEN", csrfToken))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data.uploadId").value("upload-cookie"));
|
||||
verify(uploads).upload(eq(userId), any(byte[].class));
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"/api/v1", "/api/web"})
|
||||
void browserSessionWithoutCsrfHeaderCannotUpload(String prefix) throws Exception {
|
||||
mockMvc.perform(multipart(prefix + "/skills/avatar-uploads").file(image()).session(session())
|
||||
.cookie(new Cookie("XSRF-TOKEN", UUID.randomUUID().toString())))
|
||||
.andExpect(status().isForbidden());
|
||||
verifyNoInteractions(uploads);
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"/api/v1", "/api/web"})
|
||||
void browserSessionWithoutCsrfCookieCannotUpload(String prefix) throws Exception {
|
||||
mockMvc.perform(multipart(prefix + "/skills/avatar-uploads").file(image()).session(session()))
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andExpect(jsonPath("$.msg").value("Session expired"));
|
||||
verifyNoInteractions(uploads);
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"/api/v1", "/api/web"})
|
||||
void browserSessionWithInvalidCsrfCannotUpload(String prefix) throws Exception {
|
||||
mockMvc.perform(multipart(prefix + "/skills/avatar-uploads").file(image())
|
||||
.session(session()).with(invalidCsrf()))
|
||||
.andExpect(status().isForbidden());
|
||||
verifyNoInteractions(uploads);
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"/api/v1", "/api/web"})
|
||||
void anonymousWithCsrfCannotUpload(String prefix) throws Exception {
|
||||
mockMvc.perform(multipart(prefix + "/skills/avatar-uploads").file(image()).with(csrf()))
|
||||
.andExpect(status().isUnauthorized());
|
||||
verifyNoInteractions(uploads);
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"/api/v1", "/api/web"})
|
||||
void publishTokenWithCsrfCannotUseSessionOnlyUpload(String prefix) throws Exception {
|
||||
String rawToken = "avatar-test-" + UUID.randomUUID();
|
||||
var token = new com.iflytek.skillhub.auth.entity.ApiToken(userId, "avatar", "avatar", "test-hash",
|
||||
"[\"skill:publish\"]");
|
||||
given(tokens.validateToken(rawToken)).willReturn(java.util.Optional.of(token));
|
||||
mockMvc.perform(multipart(prefix + "/skills/avatar-uploads").file(image()).with(csrf())
|
||||
.header("Authorization", "Bearer " + rawToken))
|
||||
.andExpect(status().isForbidden());
|
||||
verifyNoInteractions(uploads);
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"/api/v1", "/api/web"})
|
||||
void publishTokenWithoutCsrfCannotUseSessionOnlyUpload(String prefix) throws Exception {
|
||||
String rawToken = "avatar-test-" + UUID.randomUUID();
|
||||
var token = new com.iflytek.skillhub.auth.entity.ApiToken(userId, "avatar", "avatar", "test-hash",
|
||||
"[\"skill:publish\"]");
|
||||
given(tokens.validateToken(rawToken)).willReturn(java.util.Optional.of(token));
|
||||
mockMvc.perform(multipart(prefix + "/skills/avatar-uploads").file(image())
|
||||
.header("Authorization", "Bearer " + rawToken))
|
||||
.andExpect(status().isForbidden());
|
||||
verifyNoInteractions(uploads);
|
||||
}
|
||||
|
||||
@Test
|
||||
void sameNamedGetSlugStillUsesCompatibilityEndpoint() throws Exception {
|
||||
given(compatibility.getSkill(eq("avatar-uploads"), isNull(), isNull()))
|
||||
.willThrow(new DomainNotFoundException("error.skill.notFound", "avatar-uploads"));
|
||||
mockMvc.perform(get("/api/v1/skills/avatar-uploads")).andExpect(status().isNotFound());
|
||||
verify(compatibility).getSkill(eq("avatar-uploads"), isNull(), isNull());
|
||||
verifyNoInteractions(uploads);
|
||||
}
|
||||
|
||||
@Test
|
||||
void unmappedApiReturnsNotFoundEnvelopeWithoutResourceDetails() throws Exception {
|
||||
mockMvc.perform(get("/api/v1/release-validation/internal-path-secret").session(session()))
|
||||
.andExpect(status().isNotFound())
|
||||
.andExpect(jsonPath("$.code").value(404))
|
||||
.andExpect(jsonPath("$.msg").value("Resource not found"))
|
||||
.andExpect(jsonPath("$.timestamp").exists())
|
||||
.andExpect(jsonPath("$.requestId").exists())
|
||||
.andExpect(content().string(not(containsString("internal-path-secret"))))
|
||||
.andExpect(content().string(not(containsString("NoResourceFoundException"))));
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"zh", "ru"})
|
||||
void unmappedApiNotFoundIsLocalized(String language) throws Exception {
|
||||
mockMvc.perform(get("/api/v1/release-validation/internal-path-secret").session(session())
|
||||
.header("Accept-Language", language))
|
||||
.andExpect(status().isNotFound())
|
||||
.andExpect(jsonPath("$.code").value(404))
|
||||
.andExpect(jsonPath("$.msg").value("zh".equals(language) ? "未找到资源" : "Ресурс не найден"))
|
||||
.andExpect(content().string(not(containsString("internal-path-secret"))));
|
||||
}
|
||||
|
||||
@Test
|
||||
void realInternalFailureRemains500WithoutExceptionDetails() throws Exception {
|
||||
given(uploads.upload(eq(userId), any(byte[].class)))
|
||||
.willThrow(new RuntimeException("database-password-secret"));
|
||||
mockMvc.perform(multipart("/api/web/skills/avatar-uploads").file(image())
|
||||
.session(session()).with(csrf()))
|
||||
.andExpect(status().isInternalServerError())
|
||||
.andExpect(jsonPath("$.code").value(500))
|
||||
.andExpect(jsonPath("$.msg").value("An unexpected error occurred"))
|
||||
.andExpect(content().string(not(containsString("database-password-secret"))));
|
||||
}
|
||||
|
||||
private MockHttpSession session() {
|
||||
var context = SecurityContextHolder.createEmptyContext();
|
||||
var principal = new PlatformPrincipal(userId, "Avatar user", userId + "@example.test", "", "local", Set.of());
|
||||
context.setAuthentication(new UsernamePasswordAuthenticationToken(principal, null, List.of()));
|
||||
var session = new MockHttpSession();
|
||||
session.setAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY, context);
|
||||
return session;
|
||||
}
|
||||
|
||||
private MockMultipartFile image() {
|
||||
byte[] png = java.util.Base64.getDecoder().decode(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII=");
|
||||
return new MockMultipartFile("file", "avatar.png", "image/png", png);
|
||||
}
|
||||
|
||||
// Use the production cookie-to-header protocol. SecurityMockMvc's csrf() replaces the
|
||||
// filter's token repository, which would stop these tests from exercising real cookies.
|
||||
private RequestPostProcessor csrf() {
|
||||
return request -> {
|
||||
String token = UUID.randomUUID().toString();
|
||||
request.setCookies(new Cookie("XSRF-TOKEN", token));
|
||||
request.addHeader("X-XSRF-TOKEN", token);
|
||||
return request;
|
||||
};
|
||||
}
|
||||
|
||||
private RequestPostProcessor invalidCsrf() {
|
||||
return request -> {
|
||||
request.setCookies(new Cookie("XSRF-TOKEN", UUID.randomUUID().toString()));
|
||||
request.addHeader("X-XSRF-TOKEN", "invalid-csrf-token");
|
||||
return request;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -43,6 +43,16 @@ import org.springframework.web.HttpRequestMethodNotSupportedException;
|
|||
import org.springframework.web.context.request.async.AsyncRequestTimeoutException;
|
||||
import org.springframework.web.bind.MissingServletRequestParameterException;
|
||||
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
|
||||
import org.springframework.web.servlet.NoHandlerFoundException;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
|
||||
import static org.hamcrest.Matchers.containsString;
|
||||
import static org.hamcrest.Matchers.not;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class GlobalExceptionHandlerTest {
|
||||
|
|
@ -70,6 +80,7 @@ class GlobalExceptionHandlerTest {
|
|||
StaticMessageSource messageSource = new StaticMessageSource();
|
||||
messageSource.addMessage("error.request.timeout", java.util.Locale.getDefault(), "Request timed out");
|
||||
messageSource.addMessage("error.badRequest", java.util.Locale.getDefault(), "Invalid request");
|
||||
messageSource.addMessage("error.notFound", java.util.Locale.getDefault(), "Resource not found");
|
||||
messageSource.addMessage("error.methodNotAllowed", java.util.Locale.getDefault(), "HTTP method is not supported");
|
||||
messageSource.addMessage("error.unsupportedMediaType", java.util.Locale.getDefault(), "Unsupported media type");
|
||||
messageSource.addMessage("error.notAcceptable", java.util.Locale.getDefault(),
|
||||
|
|
@ -273,6 +284,28 @@ class GlobalExceptionHandlerTest {
|
|||
.contains("code=error.notAcceptable"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void noHandlerFoundReturns404EnvelopeWithoutExceptionDetails() throws Exception {
|
||||
var mvc = MockMvcBuilders.standaloneSetup(new MissingHandlerController())
|
||||
.setControllerAdvice(handler).build();
|
||||
mvc.perform(get("/missing-handler").locale(java.util.Locale.getDefault()))
|
||||
.andExpect(status().isNotFound())
|
||||
.andExpect(jsonPath("$.code").value(404))
|
||||
.andExpect(jsonPath("$.msg").value("Resource not found"))
|
||||
.andExpect(jsonPath("$.timestamp").exists())
|
||||
.andExpect(content().string(not(containsString("internal-route-secret"))))
|
||||
.andExpect(content().string(not(containsString("NoHandlerFoundException"))));
|
||||
}
|
||||
|
||||
@org.springframework.boot.test.context.TestComponent
|
||||
@RestController
|
||||
static class MissingHandlerController {
|
||||
@GetMapping("/missing-handler")
|
||||
void missing() throws NoHandlerFoundException {
|
||||
throw new NoHandlerFoundException("GET", "/internal-route-secret", new org.springframework.http.HttpHeaders());
|
||||
}
|
||||
}
|
||||
|
||||
private void authenticateRequest() {
|
||||
PlatformPrincipal principal = new PlatformPrincipal(
|
||||
STABLE_USER_ID,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { getSkillDetailQueryKey } from './query-keys'
|
|||
import { buildSkillSearchUrl } from './skill-query-helpers'
|
||||
|
||||
const PUBLISH_REQUEST_TIMEOUT_MS = 60_000
|
||||
const SCAN_POLL_INTERVAL_MS = 5_000
|
||||
|
||||
async function searchSkills(params: SearchParams): Promise<PagedResponse<SkillSummary>> {
|
||||
return fetchJson<PagedResponse<SkillSummary>>(buildSkillSearchUrl(params))
|
||||
|
|
@ -91,6 +92,10 @@ export function useSkillDetail(namespace: string, slug: string, enabled = true)
|
|||
queryFn: () => getSkillDetail(namespace, slug),
|
||||
enabled: enabled && !!namespace && !!slug,
|
||||
refetchOnMount: 'always',
|
||||
refetchInterval: query => query.state.status !== 'error'
|
||||
&& (query.state.data?.headlineVersion?.status === 'SCANNING'
|
||||
|| query.state.data?.ownerPreviewVersion?.status === 'SCANNING')
|
||||
? SCAN_POLL_INTERVAL_MS : false,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -99,6 +104,9 @@ export function useSkillVersions(namespace: string, slug: string, enabled = true
|
|||
queryKey: ['skills', namespace, slug, 'versions'],
|
||||
queryFn: () => getSkillVersions(namespace, slug),
|
||||
enabled: enabled && !!namespace && !!slug,
|
||||
refetchInterval: query => query.state.status !== 'error'
|
||||
&& query.state.data?.some(version => version.status === 'SCANNING')
|
||||
? SCAN_POLL_INTERVAL_MS : false,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -138,6 +146,8 @@ export function useSkillVersionDetail(namespace: string, slug: string, version?:
|
|||
queryKey: ['skills', namespace, slug, 'versions', version, 'detail'],
|
||||
queryFn: () => getSkillVersionDetail(namespace, slug, version!),
|
||||
enabled: enabled && !!namespace && !!slug && !!version,
|
||||
refetchInterval: query => query.state.status !== 'error' && query.state.data?.status === 'SCANNING'
|
||||
? SCAN_POLL_INTERVAL_MS : false,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
129
web/src/shared/hooks/use-skill-scan-polling.test.ts
Normal file
129
web/src/shared/hooks/use-skill-scan-polling.test.ts
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { createElement, type ReactNode } from 'react'
|
||||
import { focusManager, QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { act, cleanup, renderHook } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { SkillLifecycleVersion, SkillVersion } from '@/api/types'
|
||||
|
||||
const { fetchJson } = vi.hoisted(() => ({ fetchJson: vi.fn() }))
|
||||
vi.mock('@/api/client', async (original) => ({
|
||||
...await original<typeof import('@/api/client')>(),
|
||||
fetchJson,
|
||||
}))
|
||||
|
||||
import { useSkillDetail, useSkillVersionDetail, useSkillVersions } from './use-skill-queries'
|
||||
|
||||
function version(status: string): SkillVersion {
|
||||
return { id: 1, version: '1.0.0', status, fileCount: 1, totalSize: 100,
|
||||
publishedAt: '', downloadAvailable: status === 'PUBLISHED' }
|
||||
}
|
||||
|
||||
function lifecycle(status: string): SkillLifecycleVersion {
|
||||
return { id: 1, version: '1.0.0', status }
|
||||
}
|
||||
|
||||
const clients: QueryClient[] = []
|
||||
function wrapper() {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: Infinity } } })
|
||||
clients.push(client)
|
||||
return function Wrapper({ children }: { children: ReactNode }) {
|
||||
return createElement(QueryClientProvider, { client }, children)
|
||||
}
|
||||
}
|
||||
|
||||
describe('skill scan polling lifecycle', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
focusManager.setFocused(true)
|
||||
fetchJson.mockReset()
|
||||
})
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
for (const client of clients.splice(0)) client.clear()
|
||||
focusManager.setFocused(undefined)
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it.each(['UPLOADED', 'PENDING_REVIEW', 'SCAN_FAILED'])('refreshes scanning versions to %s without navigation, then stops', async (terminal) => {
|
||||
fetchJson.mockResolvedValueOnce({ items: [version('SCANNING')] })
|
||||
.mockResolvedValue({ items: [version(terminal)] })
|
||||
const hook = renderHook(() => useSkillVersions('team', 'demo'), { wrapper: wrapper() })
|
||||
await vi.waitFor(() => expect(hook.result.current.data?.[0].status).toBe('SCANNING'))
|
||||
|
||||
await act(() => vi.advanceTimersByTimeAsync(5_000))
|
||||
await vi.waitFor(() => expect(hook.result.current.data?.[0].status).toBe(terminal))
|
||||
expect(fetchJson).toHaveBeenLastCalledWith('/api/web/skills/team/demo/versions')
|
||||
const calls = fetchJson.mock.calls.length
|
||||
await act(() => vi.advanceTimersByTimeAsync(15_000))
|
||||
expect(fetchJson).toHaveBeenCalledTimes(calls)
|
||||
})
|
||||
|
||||
it('observes a scanning owner preview even while the published headline is stable', async () => {
|
||||
fetchJson.mockResolvedValueOnce({ headlineVersion: lifecycle('PUBLISHED'), ownerPreviewVersion: lifecycle('SCANNING') })
|
||||
.mockResolvedValue({ headlineVersion: lifecycle('PUBLISHED'), ownerPreviewVersion: lifecycle('UPLOADED') })
|
||||
const hook = renderHook(() => useSkillDetail('team', 'demo'), { wrapper: wrapper() })
|
||||
await vi.waitFor(() => expect(hook.result.current.data?.ownerPreviewVersion?.status).toBe('SCANNING'))
|
||||
await act(() => vi.advanceTimersByTimeAsync(5_000))
|
||||
await vi.waitFor(() => expect(hook.result.current.data?.ownerPreviewVersion?.status).toBe('UPLOADED'))
|
||||
const calls = fetchJson.mock.calls.length
|
||||
await act(() => vi.advanceTimersByTimeAsync(15_000))
|
||||
expect(fetchJson).toHaveBeenCalledTimes(calls)
|
||||
})
|
||||
|
||||
it('refreshes a scanning headline and an explicitly selected version detail', async () => {
|
||||
fetchJson.mockImplementation(async (path: string) => path.endsWith('/1.0.0')
|
||||
? version('SCANNING') : { headlineVersion: lifecycle('SCANNING') })
|
||||
const hook = renderHook(() => ({
|
||||
detail: useSkillDetail('team', 'demo'),
|
||||
version: useSkillVersionDetail('team', 'demo', '1.0.0'),
|
||||
}), { wrapper: wrapper() })
|
||||
await vi.waitFor(() => expect(hook.result.current.version.data?.status).toBe('SCANNING'))
|
||||
fetchJson.mockImplementation(async (path: string) => path.endsWith('/1.0.0')
|
||||
? version('UPLOADED') : { headlineVersion: lifecycle('UPLOADED') })
|
||||
await act(() => vi.advanceTimersByTimeAsync(5_000))
|
||||
await vi.waitFor(() => {
|
||||
expect(hook.result.current.detail.data?.headlineVersion?.status).toBe('UPLOADED')
|
||||
expect(hook.result.current.version.data?.status).toBe('UPLOADED')
|
||||
})
|
||||
})
|
||||
|
||||
it('stops after disabling or unmounting instead of continuing private background reads', async () => {
|
||||
fetchJson.mockResolvedValue({ items: [version('SCANNING')] })
|
||||
const hook = renderHook(({ enabled }) => useSkillVersions('team', 'demo', enabled), {
|
||||
initialProps: { enabled: true }, wrapper: wrapper(),
|
||||
})
|
||||
await vi.waitFor(() => expect(hook.result.current.data).toHaveLength(1))
|
||||
hook.rerender({ enabled: false })
|
||||
const calls = fetchJson.mock.calls.length
|
||||
await act(() => vi.advanceTimersByTimeAsync(15_000))
|
||||
expect(fetchJson).toHaveBeenCalledTimes(calls)
|
||||
hook.unmount()
|
||||
await act(() => vi.advanceTimersByTimeAsync(15_000))
|
||||
expect(fetchJson).toHaveBeenCalledTimes(calls)
|
||||
})
|
||||
|
||||
it('does not poll a stable published version or an empty list', async () => {
|
||||
fetchJson.mockResolvedValueOnce({ items: [version('PUBLISHED')] }).mockResolvedValue({ items: [] })
|
||||
renderHook(() => useSkillVersions('team', 'stable'), { wrapper: wrapper() })
|
||||
renderHook(() => useSkillVersions('team', 'empty'), { wrapper: wrapper() })
|
||||
await vi.waitFor(() => expect(fetchJson).toHaveBeenCalledTimes(2))
|
||||
await act(() => vi.advanceTimersByTimeAsync(15_000))
|
||||
expect(fetchJson).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('stops polling cached scanning data after a read fails', async () => {
|
||||
fetchJson.mockResolvedValueOnce({ items: [version('SCANNING')] })
|
||||
.mockRejectedValue(new Error('No longer visible'))
|
||||
const hook = renderHook(() => {
|
||||
const query = useSkillVersions('team', 'demo')
|
||||
return { data: query.data, isError: query.isError }
|
||||
}, { wrapper: wrapper() })
|
||||
await vi.waitFor(() => expect(hook.result.current.data).toHaveLength(1))
|
||||
await act(() => vi.advanceTimersByTimeAsync(5_000))
|
||||
await vi.waitFor(() => expect(hook.result.current.isError).toBe(true))
|
||||
const calls = fetchJson.mock.calls.length
|
||||
await act(() => vi.advanceTimersByTimeAsync(15_000))
|
||||
expect(fetchJson).toHaveBeenCalledTimes(calls)
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Reference in a new issue