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:
XiaoSeS 2026-10-19 13:53:41 +08:00 • committed by GitHub
parent 90c5834269
commit 0318b75e03
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 416 additions and 2 deletions

View file

@ -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())

View file

@ -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,

View file

@ -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

View file

@ -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=Неподдерживаемый тип медиа

View file

@ -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=不支持的请求内容类型

View file

@ -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;
};
}
}

View file

@ -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,

View file

@ -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,
})
}

View 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)
})
})