From 2c519c8efdda958273b525c574386a9ba47fc54c Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Mon, 20 Apr 2026 16:57:47 +0800 Subject: [PATCH 1/3] fix(search): harden portal search query parsing --- .../portal/SkillSearchController.java | 49 ++++- .../controller/SkillSearchControllerTest.java | 43 +++++ web/src/api/generated/schema.d.ts | 179 ++++++++++++++++++ 3 files changed, 265 insertions(+), 6 deletions(-) diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillSearchController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillSearchController.java index e2b8469e..36227b31 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillSearchController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillSearchController.java @@ -6,10 +6,13 @@ import com.iflytek.skillhub.dto.ApiResponse; import com.iflytek.skillhub.dto.ApiResponseFactory; import com.iflytek.skillhub.ratelimit.RateLimit; import com.iflytek.skillhub.service.SkillSearchAppService; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Schema; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.*; import java.util.Map; +import java.util.regex.Pattern; /** * Portal search endpoint that adapts HTTP query parameters to the search @@ -18,6 +21,10 @@ import java.util.Map; @RestController @RequestMapping({"/api/web/skills"}) public class SkillSearchController extends BaseApiController { + private static final Pattern NON_NEGATIVE_INTEGER = Pattern.compile("\\d+"); + private static final String DEFAULT_SORT = "newest"; + private static final int DEFAULT_PAGE = 0; + private static final int DEFAULT_SIZE = 20; private final SkillSearchAppService skillSearchAppService; @@ -33,18 +40,21 @@ public class SkillSearchController extends BaseApiController { @RequestParam(required = false) String q, @RequestParam(required = false) String namespace, @RequestParam(name = "label", required = false) java.util.List labels, - @RequestParam(defaultValue = "newest") String sort, - @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "20") int size, + @Parameter(schema = @Schema(defaultValue = DEFAULT_SORT)) + @RequestParam(required = false) String sort, + @Parameter(schema = @Schema(type = "integer", defaultValue = "0", minimum = "0")) + @RequestParam(required = false) String page, + @Parameter(schema = @Schema(type = "integer", defaultValue = "20", minimum = "1")) + @RequestParam(required = false) String size, @RequestAttribute(value = "userId", required = false) String userId, @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { SkillSearchAppService.SearchResponse response = skillSearchAppService.search( q, namespace, - sort, - page, - size, + normalizeSort(sort), + parseNonNegativeInt(page, DEFAULT_PAGE), + parsePositiveInt(size, DEFAULT_SIZE), labels, userId, userNsRoles @@ -52,4 +62,31 @@ public class SkillSearchController extends BaseApiController { return ok("response.success.read", response); } + + private String normalizeSort(String sort) { + if (sort == null || sort.isBlank()) { + return DEFAULT_SORT; + } + return sort.trim(); + } + + private int parseNonNegativeInt(String rawValue, int defaultValue) { + if (rawValue == null || rawValue.isBlank()) { + return defaultValue; + } + String normalized = rawValue.trim(); + if (!NON_NEGATIVE_INTEGER.matcher(normalized).matches()) { + return defaultValue; + } + try { + return Integer.parseInt(normalized); + } catch (NumberFormatException ex) { + return defaultValue; + } + } + + private int parsePositiveInt(String rawValue, int defaultValue) { + int parsed = parseNonNegativeInt(rawValue, defaultValue); + return parsed > 0 ? parsed : defaultValue; + } } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillSearchControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillSearchControllerTest.java index 601c9176..bed761ad 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillSearchControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillSearchControllerTest.java @@ -100,4 +100,47 @@ class SkillSearchControllerTest { .andExpect(status().isOk()) .andExpect(jsonPath("$.data.items").isArray()); } + + @Test + void searchShouldFallbackToDefaultsForBlankQueryParams() throws Exception { + when(skillSearchAppService.search( + eq(null), + eq(null), + eq("newest"), + eq(0), + eq(20), + eq(null), + any(), + any())) + .thenReturn(new SkillSearchAppService.SearchResponse(List.of(), 0, 0, 20)); + + mockMvc.perform(get("/api/web/skills") + .param("sort", " ") + .param("page", "") + .param("size", " ")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.page").value(0)) + .andExpect(jsonPath("$.data.size").value(20)); + } + + @Test + void searchShouldFallbackToDefaultsForInvalidPagination() throws Exception { + when(skillSearchAppService.search( + eq(null), + eq(null), + eq("newest"), + eq(0), + eq(20), + eq(null), + any(), + any())) + .thenReturn(new SkillSearchAppService.SearchResponse(List.of(), 0, 0, 20)); + + mockMvc.perform(get("/api/web/skills") + .param("page", "NaN") + .param("size", "-12")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.page").value(0)) + .andExpect(jsonPath("$.data.size").value(20)); + } } diff --git a/web/src/api/generated/schema.d.ts b/web/src/api/generated/schema.d.ts index d8714025..a734922a 100644 --- a/web/src/api/generated/schema.d.ts +++ b/web/src/api/generated/schema.d.ts @@ -468,6 +468,38 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/skills/{namespace}/{slug}/submit-review": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["submitForReview"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/web/skills/{namespace}/{slug}/submit-review": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["submitForReview_1"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/skills/{namespace}/{slug}/reports": { parameters: { query?: never; @@ -500,6 +532,38 @@ export interface paths { patch?: never; trace?: never; }; + "/api/web/skills/{namespace}/{slug}/confirm-publish": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["confirmPublish"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/skills/{namespace}/{slug}/confirm-publish": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["confirmPublish_1"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/skills/{namespace}/{slug}/archive": { parameters: { query?: never; @@ -3239,6 +3303,10 @@ export interface components { targetVersion: string; confirmWarnings?: boolean; }; + SubmitReviewRequest: { + version: string; + targetVisibility: string; + }; SkillReportSubmitRequest: { reason?: string; details?: string; @@ -3257,6 +3325,9 @@ export interface components { reportId?: number; status?: string; }; + ConfirmPublishRequest: { + version: string; + }; AdminSkillActionRequest: { reason?: string; }; @@ -5569,6 +5640,60 @@ export interface operations { }; }; }; + submitForReview: { + parameters: { + query?: never; + header?: never; + path: { + namespace: string; + slug: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SubmitReviewRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseSkillLifecycleMutationResponse"]; + }; + }; + }; + }; + submitForReview_1: { + parameters: { + query?: never; + header?: never; + path: { + namespace: string; + slug: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SubmitReviewRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseSkillLifecycleMutationResponse"]; + }; + }; + }; + }; submitReport: { parameters: { query?: never; @@ -5623,6 +5748,60 @@ export interface operations { }; }; }; + confirmPublish: { + parameters: { + query?: never; + header?: never; + path: { + namespace: string; + slug: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ConfirmPublishRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseSkillLifecycleMutationResponse"]; + }; + }; + }; + }; + confirmPublish_1: { + parameters: { + query?: never; + header?: never; + path: { + namespace: string; + slug: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ConfirmPublishRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseSkillLifecycleMutationResponse"]; + }; + }; + }; + }; archiveSkill: { parameters: { query?: never; From f902c6f59b8cd42585475be611751d39d1197e9a Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Tue, 21 Apr 2026 09:45:39 +0800 Subject: [PATCH 2/3] fix(search): restore clawhub namespace-only access --- .../compat/ClawHubRegistrySecurityConfig.java | 9 ++- .../skillhub/filter/AuthContextFilter.java | 3 + .../compat/ClawHubCompatControllerTest.java | 67 +++++++++++++++++++ .../PostgresFullTextQueryService.java | 6 -- .../PostgresFullTextQueryServiceTest.java | 28 ++++++++ 5 files changed, 105 insertions(+), 8 deletions(-) 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 7e7da3bc..e0df66d5 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 @@ -1,11 +1,13 @@ package com.iflytek.skillhub.compat; +import com.iflytek.skillhub.auth.token.ApiTokenAuthenticationFilter; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.core.annotation.Order; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.security.web.util.matcher.AntPathRequestMatcher; import org.springframework.security.web.util.matcher.OrRequestMatcher; @@ -37,7 +39,9 @@ public class ClawHubRegistrySecurityConfig { @Bean @Order(1) - public SecurityFilterChain clawHubRegistryFilterChain(HttpSecurity http) throws Exception { + public SecurityFilterChain clawHubRegistryFilterChain( + HttpSecurity http, + ApiTokenAuthenticationFilter apiTokenAuthenticationFilter) throws Exception { http .securityMatcher( "/api/v1/search", @@ -46,7 +50,8 @@ public class ClawHubRegistrySecurityConfig { ) .authorizeHttpRequests(auth -> auth.anyRequest().permitAll()) .requestCache(cache -> cache.disable()) - .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)); + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .addFilterBefore(apiTokenAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/AuthContextFilter.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/AuthContextFilter.java index d4df24c3..1c16fe89 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/AuthContextFilter.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/AuthContextFilter.java @@ -16,7 +16,9 @@ import jakarta.servlet.http.HttpSession; import java.io.IOException; import java.util.Map; import java.util.stream.Collectors; +import org.springframework.boot.autoconfigure.security.SecurityProperties; import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.annotation.Order; import org.springframework.http.MediaType; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; @@ -28,6 +30,7 @@ import org.springframework.web.filter.OncePerRequestFilter; * Projects the authenticated principal into request attributes consumed by the controller layer. */ @Component +@Order(SecurityProperties.DEFAULT_FILTER_ORDER + 1) public class AuthContextFilter extends OncePerRequestFilter { private final NamespaceMemberRepository namespaceMemberRepository; diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatControllerTest.java index a78c7d73..607e3d65 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatControllerTest.java @@ -2,15 +2,22 @@ package com.iflytek.skillhub.compat; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.auth.device.DeviceAuthService; +import com.iflytek.skillhub.auth.entity.ApiToken; +import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; +import com.iflytek.skillhub.auth.token.ApiTokenService; import com.iflytek.skillhub.domain.audit.AuditLogService; import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceMember; import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; import com.iflytek.skillhub.domain.skill.SkillVersion; import com.iflytek.skillhub.domain.skill.SkillVersionStatus; import com.iflytek.skillhub.domain.skill.service.SkillQueryService; import com.iflytek.skillhub.domain.skill.service.SkillPublishService; import com.iflytek.skillhub.domain.skill.Skill; import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; import com.iflytek.skillhub.dto.SkillLifecycleVersionResponse; import com.iflytek.skillhub.dto.SkillSummaryResponse; import com.iflytek.skillhub.service.SkillSearchAppService; @@ -39,6 +46,7 @@ import static org.mockito.BDDMockito.given; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyMap; import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.ArgumentMatchers.same; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; @@ -67,6 +75,15 @@ class ClawHubCompatControllerTest { @MockBean private SkillQueryService skillQueryService; + @MockBean + private ApiTokenService apiTokenService; + + @MockBean + private UserAccountRepository userAccountRepository; + + @MockBean + private UserRoleBindingRepository userRoleBindingRepository; + @MockBean private CompatSkillLookupService compatSkillLookupService; @@ -111,6 +128,56 @@ class ClawHubCompatControllerTest { .andExpect(jsonPath("$.results[0].version").value("1.2.0")); } + @Test + void search_withBearerToken_shouldProjectNamespaceRolesIntoRequestContext() throws Exception { + ApiToken token = new ApiToken("user-7", "cli", "sk_test", "hash", "[]"); + UserAccount user = new UserAccount("user-7", "Alice", "alice@example.com", null); + var nsRoles = java.util.Map.of(9L, NamespaceRole.MEMBER); + + when(apiTokenService.validateToken("raw-token")).thenReturn(Optional.of(token)); + when(userAccountRepository.findById("user-7")).thenReturn(Optional.of(user)); + when(userRoleBindingRepository.findByUserId("user-7")).thenReturn(List.of()); + when(namespaceMemberRepository.findByUserId("user-7")) + .thenReturn(List.of(new NamespaceMember(9L, "user-7", NamespaceRole.MEMBER))); + when(skillSearchAppService.search("token-search", null, "relevance", 0, 20, "user-7", nsRoles)) + .thenReturn(new SkillSearchAppService.SearchResponse(List.of(), 0, 0, 20)); + + mockMvc.perform(get("/api/v1/search") + .param("q", "token-search") + .header("Authorization", "Bearer raw-token")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.results").isArray()); + + verify(skillSearchAppService).search("token-search", null, "relevance", 0, 20, "user-7", nsRoles); + verify(apiTokenService).touchLastUsed(same(token)); + } + + @Test + void downloadQuery_withBearerToken_shouldProjectNamespaceRolesIntoRequestContext() throws Exception { + ApiToken token = new ApiToken("user-7", "cli", "sk_test", "hash", "[]"); + UserAccount user = new UserAccount("user-7", "Alice", "alice@example.com", null); + var nsRoles = java.util.Map.of(9L, NamespaceRole.MEMBER); + + when(apiTokenService.validateToken("raw-token")).thenReturn(Optional.of(token)); + when(userAccountRepository.findById("user-7")).thenReturn(Optional.of(user)); + when(userRoleBindingRepository.findByUserId("user-7")).thenReturn(List.of()); + when(namespaceMemberRepository.findByUserId("user-7")) + .thenReturn(List.of(new NamespaceMember(9L, "user-7", NamespaceRole.MEMBER))); + when(compatSkillLookupService.findByLegacySlug("private-skill")) + .thenReturn(legacyCompatContext("team-ai", "private-skill")); + when(compatSkillLookupService.canAccess(any(), eq("user-7"), eq(nsRoles))).thenReturn(true); + + mockMvc.perform(get("/api/v1/download") + .param("slug", "private-skill") + .param("version", "latest") + .header("Authorization", "Bearer raw-token")) + .andExpect(status().isFound()) + .andExpect(header().string("Location", "/api/v1/skills/team-ai/private-skill/download")); + + verify(compatSkillLookupService).canAccess(any(), eq("user-7"), eq(nsRoles)); + verify(apiTokenService).touchLastUsed(same(token)); + } + @Test void resolve_returns_correct_downloadUrl() throws Exception { when(skillQueryService.resolveVersion("global", "my-skill", null, "latest", null, null, java.util.Map.of())) diff --git a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryService.java b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryService.java index ec0cfa37..2e1ffcb1 100644 --- a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryService.java +++ b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryService.java @@ -102,10 +102,6 @@ public class PostgresFullTextQueryService implements SearchQueryService { Set memberNamespaceIds = query.visibilityScope().memberNamespaceIds().isEmpty() ? Set.of(-1L) : query.visibilityScope().memberNamespaceIds(); - Set adminNamespaceIds = query.visibilityScope().adminNamespaceIds().isEmpty() - ? Set.of(-1L) - : query.visibilityScope().adminNamespaceIds(); - StringBuilder sql = new StringBuilder(); sql.append("SELECT d.skill_id "); sql.append("FROM skill_search_document d "); @@ -190,7 +186,6 @@ public class PostgresFullTextQueryService implements SearchQueryService { if (query.visibilityScope().userId() != null) { nativeQuery.setParameter("memberNamespaceIds", memberNamespaceIds); - nativeQuery.setParameter("adminNamespaceIds", adminNamespaceIds); } if (query.namespaceId() != null) { @@ -235,7 +230,6 @@ public class PostgresFullTextQueryService implements SearchQueryService { if (query.visibilityScope().userId() != null) { countQuery.setParameter("memberNamespaceIds", memberNamespaceIds); - countQuery.setParameter("adminNamespaceIds", adminNamespaceIds); } if (query.namespaceId() != null) { diff --git a/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryServiceTest.java b/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryServiceTest.java index 0025f2ec..f89d05e9 100644 --- a/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryServiceTest.java +++ b/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryServiceTest.java @@ -392,6 +392,34 @@ class PostgresFullTextQueryServiceTest { assertThat(sqlCaptor.getAllValues().getFirst()).contains("OR d.namespace_id IN :memberNamespaceIds"); } + @Test + void authenticatedQueriesShouldNotBindUnusedAdminNamespaceIdsParameter() { + EntityManager entityManager = mock(EntityManager.class); + Query nativeQuery = mock(Query.class); + Query countQuery = mock(Query.class); + when(entityManager.createNativeQuery(anyString())) + .thenReturn(nativeQuery) + .thenReturn(countQuery); + when(nativeQuery.setParameter(anyString(), org.mockito.ArgumentMatchers.any())).thenReturn(nativeQuery); + when(countQuery.setParameter(anyString(), org.mockito.ArgumentMatchers.any())).thenReturn(countQuery); + when(nativeQuery.getResultList()).thenReturn(List.of()); + when(countQuery.getSingleResult()).thenReturn(0L); + + PostgresFullTextQueryService service = new PostgresFullTextQueryService(entityManager); + + service.search(new SearchQuery( + "issue331", + null, + new SearchVisibilityScope("user-1", Set.of(7L), Set.of(9L)), + "relevance", + 0, + 20 + )); + + verify(nativeQuery, never()).setParameter("adminNamespaceIds", Set.of(9L)); + verify(countQuery, never()).setParameter("adminNamespaceIds", Set.of(9L)); + } + @Test void platformWideAccessShouldNotBypassVisibilityInPortalSearch() { EntityManager entityManager = mock(EntityManager.class); From 701ef12d33052e24042c982411b70140abaa3bb3 Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Tue, 21 Apr 2026 17:55:53 +0800 Subject: [PATCH 3/3] test(e2e): wait for search cards before counting --- web/e2e/search-page-full.spec.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/web/e2e/search-page-full.spec.ts b/web/e2e/search-page-full.spec.ts index be14db8f..09117161 100644 --- a/web/e2e/search-page-full.spec.ts +++ b/web/e2e/search-page-full.spec.ts @@ -228,6 +228,7 @@ test.describe('Search Results (Real API)', () => { await page.goto(searchUrl(basicSeed!.keyword)) await page.waitForLoadState('networkidle') const cards = getSearchCards(page) + await expect(cards.first()).toBeVisible({ timeout: 10_000 }) const visibleCount = await cards.count() const countText = await page.getByText(/\d+\s+skills found/i).textContent() const totalMatch = countText?.match(/\d+/)