From 0318b75e039f5ca7cd6dddc630765804b7593e77 Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Mon, 19 Oct 2026 13:53:41 +0800 Subject: [PATCH] fix(release): correct avatar security, missing routes and scan refresh (#970) Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- .../compat/ClawHubRegistrySecurityConfig.java | 5 +- .../exception/GlobalExceptionHandler.java | 9 + .../src/main/resources/messages.properties | 1 + .../src/main/resources/messages_ru.properties | 1 + .../src/main/resources/messages_zh.properties | 1 + ...llAvatarUploadSecurityIntegrationTest.java | 229 ++++++++++++++++++ .../exception/GlobalExceptionHandlerTest.java | 33 +++ web/src/shared/hooks/use-skill-queries.ts | 10 + .../hooks/use-skill-scan-polling.test.ts | 129 ++++++++++ 9 files changed, 416 insertions(+), 2 deletions(-) create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillAvatarUploadSecurityIntegrationTest.java create mode 100644 web/src/shared/hooks/use-skill-scan-polling.test.ts diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubRegistrySecurityConfig.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubRegistrySecurityConfig.java index 128d838f8..ce48c5822 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubRegistrySecurityConfig.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubRegistrySecurityConfig.java @@ -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()) diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/GlobalExceptionHandler.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/GlobalExceptionHandler.java index 70b30a41d..856fe1d14 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/GlobalExceptionHandler.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/GlobalExceptionHandler.java @@ -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> 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> handleUnsupportedMediaType( HttpMediaTypeNotSupportedException ex, diff --git a/server/skillhub-app/src/main/resources/messages.properties b/server/skillhub-app/src/main/resources/messages.properties index 0f677523c..ee396ef3f 100644 --- a/server/skillhub-app/src/main/resources/messages.properties +++ b/server/skillhub-app/src/main/resources/messages.properties @@ -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 diff --git a/server/skillhub-app/src/main/resources/messages_ru.properties b/server/skillhub-app/src/main/resources/messages_ru.properties index fcc6aa389..23e9f0ec7 100644 --- a/server/skillhub-app/src/main/resources/messages_ru.properties +++ b/server/skillhub-app/src/main/resources/messages_ru.properties @@ -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=Неподдерживаемый тип медиа diff --git a/server/skillhub-app/src/main/resources/messages_zh.properties b/server/skillhub-app/src/main/resources/messages_zh.properties index 1c5d8ea62..348facf40 100644 --- a/server/skillhub-app/src/main/resources/messages_zh.properties +++ b/server/skillhub-app/src/main/resources/messages_zh.properties @@ -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=不支持的请求内容类型 diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillAvatarUploadSecurityIntegrationTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillAvatarUploadSecurityIntegrationTest.java new file mode 100644 index 000000000..4de0c764a --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillAvatarUploadSecurityIntegrationTest.java @@ -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; + }; + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/exception/GlobalExceptionHandlerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/exception/GlobalExceptionHandlerTest.java index f469638f2..d931f1a56 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/exception/GlobalExceptionHandlerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/exception/GlobalExceptionHandlerTest.java @@ -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, diff --git a/web/src/shared/hooks/use-skill-queries.ts b/web/src/shared/hooks/use-skill-queries.ts index 6183073e1..f9a6c3b9b 100644 --- a/web/src/shared/hooks/use-skill-queries.ts +++ b/web/src/shared/hooks/use-skill-queries.ts @@ -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> { return fetchJson>(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, }) } diff --git a/web/src/shared/hooks/use-skill-scan-polling.test.ts b/web/src/shared/hooks/use-skill-scan-polling.test.ts new file mode 100644 index 000000000..97e370e77 --- /dev/null +++ b/web/src/shared/hooks/use-skill-scan-polling.test.ts @@ -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(), + 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) + }) +})